Motivation
객체 지향 프로그래밍 과제 프로젝트라 함은 무언가 엄청난 발명을 해서 실용적인 것을 추구하기보다는, 객체 지향 프로그래밍을 잘 이해할 목적으로 만들어지는 것이다. 그러므로 기본적으로 주제 선정에 있어서 4 Pillars of OOP를 마음에 새기는 것이 중요하다.
- 캡슐화(Encapsulation)
- 상속(Inheritance)
- 추상화(Abstraction)
- 다형성(Polymorphism)
개인적으로 나는 Polymorphism을 써서 만들 수 있는 프로그램이 뭐가 있을지 떠올리는 게 가장 힘들었다. 그러다가, "print() 멤버 함수로 문자열도 출력하고 표도 출력하고 그래프도 출력하는 프로그램이 있으면 재미있지 않을까?"라는 생각이 갑자기 들어서, 그때부터 보고서 작성 프로그램의 기획을 짜기 시작했다. 기획을 떠올린 과정에서 알 수 있듯 프로그래밍 패러다임에 억지로 프로그램을 짜맞춘 것이기 때문에 이런 구현이 상당히 부자연스러운 구현이 될 가능성도 있었으나, 실제로 코드를 짜 본 결과 나는 꽤 자연스럽고 읽기 쉬운 코드가 나왔다고 생각한다.
C++에서 다형성을 구현하는 방법은 정적 바인딩/동적 바인딩 2가지가 있는데 나는 동적 바인딩을 쓰기로 했다. 이유는 나중에 Document 클래스를 설명할 때 설명하겠다.
그렇게 해서 상속 관계도를 짜기 시작했고, 서로 상속 관계로 얽힌 6개의 클래스를 짰다. 위의 그림에서도 찾아볼 수 있듯, 각각 Holder, StringHolder, TableHolder, ChartHolder, LineHolder, HistogramHolder다. Holder와 ChartHolder는 abstract 클래스이고 나머지는 각각 문자열, 표, 꺾은선그래프, 막대그래프를 나타내는 클래스라는 것을 이름을 통해 알 수 있을 것이다.
상속 관계를 만들어 주었다면, 과연 이것을 상속을 사용하여 구현하는 것이 맞을지에 대해 한번 생각을 해볼 필요가 있다. 객체 지향 프로그래밍의 프로젝트 제안서를 읽다 보면 흔히 발견할 수 있는 실수인데, 상속은 is - a 관계일 때만 사용되는 것이 권장된다. 예컨대,
class Human {
// implements...
};
class Woman : Human {
// implements...
};
class Car {
// implements...
};
class GasolineCar : Car {
// implements...
};
같이 Derived Class가 Base Class에 포함될 때 써야 한다. 따라서 Car가 할 수 있는 모든 기능은 GasolineCar가 할 수 있어야 할 것이다. 이것을 리스코프 치환 원칙이라 한다. 이 원칙에 의거, 다음과 같은 코드는 나쁘다.
class Wheel {
// implements...
};
class Car : Wheel {
// implements...
};
앞서 말한 is - a 문장에 대입해 보면, Car is a Wheel을 얻는데 말이 안 되는 문장이라는 것을 알 수 있다. 만약 이런 코드를 짠 사람이 실제로 있다면, 그는 Wheel의 구현을 Car 클래스에서 재사용하려고 했을 것이다. 코드를 재사용하겠다는 의도는 좋으나 단순히 코드를 재사용하기 위해 상속을 사용하게 된다면 이후 여러 난관에 부딪히게 된다.
위의 예시에서, Wheel을 상속하지는 않지만 멤버 변수로 소유하고 있는 클래스 Train을 추가해 보자.
class Wheel {
// implements...
};
class Car : Wheel {
// implements...
};
class Train {
Wheel wheel;
};
그렇다면, Derived Class는 Base Class가 할 수 있는 모든 것을 할 수 있다는 C++ 상속의 원칙 상, 다음 코드가 가능해지는 것이다.
Train train;
train.wheel = Car();
기차의 바퀴로 자동차를 사용하는 말도 안 되는(...) 상황이 일어난다. 앞서 설명한 리스코프 치환 원칙을 철저히 지키면, 이런 말도 안 되는 코드가 실행되는 상황을 막을 수 있을 것이다. 어쨌든, 우리 프로젝트의 상속 관계는 리스코프 치환 원칙을 잘 지키고 있다.
6개의 Holder 클래스들을 살펴보았으면, 다음으로 볼 것은 Document 클래스. 당연한 이야기지만 표 하나로 보고서를 쓸 수는 없다. 그래프 하나로도 마찬가지다. 따라서 문자열, 표, 그래프 등을 이리저리 조합하여 전체 화면에 띄워 주는 객체 하나가 필요할 것이다. 그 이름을 Document라고 지었다. Document의 느낌을 알 수 있게 헤더 파일 중 일부를 가져와보고자 한다.
class Document {
private:
std::vector<Holder*> holders;
std::string filename;
public:
explicit Document(const std::vector<std::string>& data);
~Document();
void print() const;
void print_infos() const;
/* ... */
[[nodiscard]] std::string save() const;
Holder* at(int idx);
[[nodiscard]] unsigned int size() const;
[[nodiscard]] std::string get_filename() const;
};
Document의 구현은 간단하다. (비록 Document 클래스 하나가 200줄을 잡아먹긴 하지만...)
먼저 std::vector<Holder*> 타입의 holders는 하나의 Document 안에 있는 모든 Holder를 보유한다. 이때 Document와 Holder는 has - a 관계다. Document has a Holder. 이것처럼 is - a 관계가 아닌 has - a 관계에 대해서는 상속이 아닌 구성(Composition)이나 연관(Aggregation)을 사용하도록 하자. 위의 Document 구현도 Composition이다.
Document도 print() 멤버 함수를 들고 있다. print() 함수는 단순히 벡터를 한 번 돌며 벡터가 갖고 있는 모든 Holder들을 모두 print()한다. 이처럼 상위 객체와 부분 객체에 모두 print() 함수가 적용되어 있으므로, 사용자는 print()를 사용할 때 굳이 상위 객체와 부분 객체를 구분해 가며 사용할 필요가 없다. 전체 객체와 부분 객체에 비슷한 인터페이스를 주는 이 디자인 패턴을 컴포지트(Composite)라고 한다.
클래스 다이어그램에서 Document의 오른쪽에는 Listener가 있다. Listener는 user input을 읽는 데 특화된 클래스로, 각 Holder들이 갖고 있는 print() 멤버 함수를 제외하고는 std::cin, std::cout은 오직 Listener만 사용한다(는 것이 원칙이다. 안타깝게도 이 원칙은 잘 지켜지지 못했기 때문에 내가 열심히 수정하고 있다).
이렇게 해서 주요 클래스 8개에 대한 설명이 끝났다. 그것 말고도 Node 클래스와 Trie 클래스가 있는데, 이것들은 StringHolder의 기능 중 하나인 맞춤법 검사를 위해 필요한 클래스이다. Trie는 앞서 말한 Holder나 Document 같이 목적이 하나뿐인 클래스라기보다는, Stack이나 Queue 같은 자료구조이다. 내가 자료구조의 구현을 객체 지향 프로그래밍 프로젝트에 넣은 이유는, 모던하게 프로그래밍하는 법을 배우기 위해 딱 좋은 예제가 바로 자료구조라고 생각하기 때문이다. 특히 encapsulation과 abstraction을 배우는 데 있어 그렇다. (그래서 나는 여러 개의 프로그래밍 주제 중 하나를 선택할 때, 자료 구조 구현이 필요한 프로젝트에 높은 점수를 주었었다.) Trie는 그 자체가 백준 플래티넘 IV~V 정도 난이도로 평가되는 어려운 알고리즘이므로, 이 부분은 아마 이 프로젝트에서 가장 어려운 구현이 될 것이다.
'Computer Science > (temp)' 카테고리의 다른 글
C++로 문서 작성 프로그램 만들기 [객체 지향 프로그래밍 과제 가이드] (0) | 2022.08.15 |
---|
댓글