[모던 C++ 디자인 패턴] 책을 바탕으로 공부하는 내용을 정리한 내용이다.
Visitor Pattern
계층을 이루는 클래스들을 사용해야 할 때, 소스 코드를 수정할 수 없다면 각 계층마다 멤버 함수를 추가하는 것이 불가능하다. 이 문제를 해결하기 위한 방문자 패턴은, 사용하기 위해서는 선제적으로 어떤 준비가 되어야 있어야만 한다.
침습적 방문자
가장 직접적인 접근 방법부터 시도해 보자. 이 방법은 OCP에 위배된다.
수학 수식을 파싱해야 할 때, Expression 인터페이스를 직접적으로 수정하는 방법이다. 상속 관계에 따라 모든 하위 클래스도 수정된다.
struct Expression {
virtual void print(ostringstream &oss) = 0;
};
OCP 원칙을 위배하는 것과 더불어 이런 수정은 클래스 계층에 대한 소스 코드 수정이 가능하다는 것을 전제로 하고 있다. 그러한 상황이 항상 가능하다고 보증할 수는 없다.
이런 변경은 AdditionExpression과 DoubleExpression에 print() 멤버 함수를 구현한다.
struct AdditionExpression : public Expression {
public:
AdditionExpression(Expression * const left, Expression * const right) : left(left), right(right) {}
!AdditionExpression() {
delete left;
delete right;
}
void print(ostringstream &oss) override {
oss << "(";
left->print(oss);
oss << "+";
right->print(oss);
oss << ")";
}
private:
Expression *left, *(right;
};
// DoubleExpression 생략
하위 수식에 대한 print() 호출은 재귀적이면서 다형적이다.
auto e = new AdditionExpression (
new DoubleExpression(1),
new AdditionExpression(
new DoubleExpression(2),
new DoubleExpression(3)
)
);
위와 같이 테스트 가능하지만, 클래스 계층이 10개에 이른다면 어떻게 될까? 추가로 eval() 연산을 만들면 어떻게 될까? 10개의 서로 다른 클래스 각각에 10가지 수정이 가해져야 한다. 이런 상황에서 OCP 위반은 문제도 아니다.
실질적 문제는 SRP다. 모든 Expression마다 스스로 자신의 print()를 하는 대신 출력 방법을 알고 있는 ExpressionPrinter를 별도로 도입하는 것은 어떨까? 나중에 비슷한 계산 방법을 알고 있는 ExpressionEvaluator의 도입도 가능할 것이다. 이럴 경우 Expression의 전체 상속 계층에 영향을 주지 않고도 목적하는 것을 할 수 있다.
반추적(reflective) 출력
별도의 출력 컴포넌트를 만드는 접근 방법을 사용해 보자. 베이스 클래스를 제외한 각 클래스의 print()를 제거한다. 하나 까다로운 부분은 Expression 클래스를 공백으로 남겨둘 수가 없다는 것이다. 다형성을 활용하려면 베이스 클래스에 무엇이 되었든 버추얼 속성을 가진 멤버 함수가 있어야 한다. 따라서 버추얼 소멸자를 두어 다형성을 갖도록 한다.
이제 ExpressionPrinter의 구현을 할 수 있다.
struct Expression {
virtual ~Expression() = default;
};
struct ExpressionPrinter {
void print(DoubleExpression *de, ostringstream &oss) const {
oss << de->value;
}
void print(AdditionExpression *ae, ostringstream &oss) const {
oss << "(";
print(ae->left, oss);
oss << "+";
print(ae->right, oss);
oss << ")";
}
};
이 코드는 컴파일이 안 된다. 컴파일 시점에서 ae->left 의 타입이 Expression이라는 사실만 안다. 다른 동적 프로그래밍 언어와 달리 C++는 런타임에 타입 체크를 해 오버로딩하는 방식이 아니라 컴파일 시점에 오버로딩이 결정되어, 두 개의 print() 중 어느 것이 선택되어야 하는지 알지 못한다.
유일한 해결책은 오버로딩을 버리고 런타임 타입 체크를 명시적으로 구현하는 것이다. 타입이 무엇인지 되돌아보기 때문에 반추적 방법이라 부른다.
struct ExpressionPrinter {
public:
void print(Expression *e) {
if ( auto de = dynamic_cast<DoubleExpression *>(e) ) {
oss << de->value;
}
else if ( auto ae = dynamic_cast<AdditionExpression *>(e) ) {
oss << "(";
print(ae->left, oss);
oss << "+";
print(ae->right, oss);
oss << ")";
}
}
string str() const {
return oss.str();
}
private:
ostringstream oss;
};
이런 접근 방법의 큰 단점은 모든 클래스 계층마다 구현해 넣은 print() 메서드에 대한 컴파일러의 타입 체크를 포기해야 하는 것이다.
즉, 새로운 항목이 추가되면 ExpressionPrinter에 필요한 수정을 하지 않더라도 그냥 컴파일이 되어 버린다. 런타임 타입을 체크하는 if 체인에 새로운 타입이 매칭되지 않아 무시되어 버린다.
그럼에도 쓸만한 방문자 패턴이다. 이 정도에서 추가적 개선을 굳이 고민하지 않고 멈춰도 된다. dynamic_cast는 비용이 크지 않고, 대부분의 개발자는 if 체인에 타입의 추가가 필요하다는 것을 잊지 않을 것이고 잊더라도 단위 테스트에서 문제를 발견할 것이다.
디스패치(Dispatch)?
방문자 패턴에서는 항상 디스패치라는 용어가 많이 언급된다. 주어진 정보 하에서 호출할 함수를 어떻게 특정하느냐는 문제다. 즉, 일을 처리할 담당자를 찾아 전달하는 작업이다.
struct Stuff {}
struct Foo : Stuff {}
struct Bar : Stuff {}
void func(Foo *foo) {}
void func(Bar *bar) {}
..
Foo *foo = new Foo;
func(foo); // OK
Stuff *stuff = new Stuff;
func(stuff); // 호출할 함수 특정 불가
위의 코드와 같이 Foo의 포인터를 하위 클래스로 업캐스팅하면 컴파일러가 오버로딩할 함수를 찾지 못한다.
다형성 관점에서 생각해보면, 런타임에 명시적으로 타입 체크를 하지 않고서도 올바르게 오버로딩할 방법이 있을까? 놀랍게도 있다. C++ 클래스의 vtable 덕분에 Stuff에 대해 어떤 함수 호출 시 다형성을 가질 수 있고 필요한 컴포넌트로 바로 디스패치될 수 있다. 이러한 방식을 이중 디스패치라고 부른다.
struct Stuff {
virtual void call() = 0;
}
struct Foo : Stuff {
// 객체 안에서 오버로딩 되면 this가 특정 타입을 갖기 때문에 가능하다.
void call() override { func(this); }
}
struct Bar : Stuff {
void call() override { func(this); }
}
void func(Foo *foo) {}
void func(Bar *bar) {}
전통적인 방문자
전통적으로는 이중 디스패치를 이용한다. 이때는 아래와 같이 호출될 방문자 멤버 함수에 대한 네이밍 관례를 따른다.
- 방문자의 멤버 함수는 visit()라는 이름을 가진다.
- 클래스 계층마다 구현될 멤버 함수는 accept()라는 이름을 가진다.
베이스 클래스 Expression에 virtual 소멸자를 두지 않아도 된다. 실질적으로 필요한 virtual 멤버 함수가 생겼기 때문이다. 아래와 같이 베이스 클래스에 virtual 멤버 함수 accept()를 둔다.
struct Expression {
virtual void accept(ExpressionVisitor *visitor) = 0;
};
ExpressionVisitor를 참조하는데, 여러 가지 방문자(ExpressionPrinter, ExpressionEvaluator 등)들의 베이스 클래스 역할을 한다. 이제 Expression을 상속받는 모든 클래스는 accept() 멤버 함수를 동일한 방식으로 구현해야 한다.
void accept(ExpressionVisitor *visitor) override {
visitor->visit(this);
}
ExpressionVisitor는 아래와 같이 정의할 수 있다.
struct ExpressionVisitor {
virtual void visit(DoubleExpression *de) = 0;
virtual void visit(AdditionExpression *ae) = 0;
};
struct ExpressionPrinter : ExpressionVisitor {
ostringstream oss;
string str() const { return oss.str(); }
void visit(DoubleExpression *de) override;
void visit(AdditionExpression *ae) override;
};
비순환 방문자
방문자 디자인 패턴은 다음과 같이 두 유형으로 나눌 수 있다.
- 순환 방문자
- 함수 오버로디에 기반하는 방문자다.
- 클래스 계층(방문자 타입 알아야 함)과 방문자(계층의 모든 클래스 타입을 알아야 함) 간에 상호 참조하는 순환적 종속성이 발생한다.
- 순환 종속성 때문에 클래스 계층의 안정성이 보장되는 경우에만 사용할 수 있다.
- 계층이 너무 자주 업데이트되면 문제가 발생할 수도 있다.
- 비순환 방문자
- 런타임 타입 정보(RTTI)에 의존하는 방문자다.
- 방문될 클래스 계층에 제한이 없다는 장점이 있다.
- 성능적 부분에서 약간의 손해가 있다는 단점이 있다.
비순환 방문자 구현하는 첫 단계는 방문자의 인터페이스를 정의하는 것이다. 방문자 클래스 계층의 각 타입마다 visit() 멤버 함수를 오버로딩 대신 아래와 같이 범용적 형태로 방문자 인터페이스를 정의한다.
template <typename Visitable>
struct Visitor {
virtual void visit(Visitable &obj) = 0;
};
도메인 모델의 항목마다 이런 방문자를 수용(accept)할 수 있어야 한다. 모든 실 구현 타입이 고유하기 때문에 버추얼 속성이 적용되도록 아래와 같이 버추얼 소멸자만을 가진 공백 클래스를 베이스로 둔다. 이렇게 동작에 대한 정의는 없지만 해당 인터페이스임을 표시하는 클래스를 마커(marker) 인터페이스라 한다.
struct VisitorBase {
virtual ~VisitorBase() = default;
};
앞서 보았던 Expression 클래스를 다음과 같이 재정의 된다.
struct Expression {
virtual ~Expression() = default;
virtual void accept(VisitorBase &obj) {
using EV = Visitor<Expression>;
if ( auto ev = dynamic_cast<EV *>(&obj) ) {
ev->visit(*this);
}
}
};
VisitorBase를 인자로 받지만 Visitor<T>로 타입 캐스팅을 시도한다. T는 accept()가 구현되고 있는 현재 클래스의 타입이다. 타입 캐스팅이 성공하면 방문자가 해당 타입을 어떻게 방문해야 하는지 알 수 있게 되고 캐스팅된 타입의 visit() 멤버 함수를 호출한다. 타입 캐스팅에 실패하면 아무것도 하지 않는다. 객체 자체에 직접 visit() 멤버 함수를 가질 경우 호출할 모든 타입마다 visit() 멤버 함수를 정의하여 오버로딩할 수 있어야 한다. 그렇게 하면 순환 종속성이 발생한다.
struct ExpressionPrinter : VisitorBase, Visitor<DoubleExpression>, Visitor<AdditionExpression> {
public:
void visit(DoubleExpression &obj) override;
void visit(AdditionExpression &obj) override;
string str() const {
return oss.str();
}
private:
ostringstream oss;
};
마커 인터페이스 VisitorBase와 함께 방문할 모든 타입 T에 대해 Visitor<T>를 구현한다. 특정 타입 T에 대한 구현을 누락하면 프로그램이 컴파일은 되지만 accept() 호출 시 아무 동작을 하지 않을 것이다. 위의 visit() 구현은 전통적 방문자 패턴의 방식과 거의 동일하다.
std::variant와 std::visit
전통적 방문자 패턴과 직접적 관련은 없지만 std::visit에 대해 짚고 넘어가보자. 방문자 패턴과 큰 관련이 있을 것 같지만 std::variant 타입변수에 대해 올바른 타입으로 접근할 수 있게 하는 용도로 사용된다.
variant<string, int> house;
// 아래 두 대입 모두 유효
// house = "국제빌딩";
// house = 221;
std::visit()을 이용해 자동으로 가변 타입에서 실제 저장된 타입에 맞춰 오버로딩되도록 함수 호출 연산자를 호출하자.
struct AddressPrinter {
void operator() (const string &house_name) const {
cout << "A house called " << house_name << "\n";
}
void operator() (const int house_number) const {
cout << "House number " << house_number << "\n";
}
};
variant<string, int> house;
house = 221;
AddressPrinter ap;
std::visit(ap, house); // 출력: "House number 221"
모던 C++ 기능을 사용하면 방문자 함수를 즉석에서 정의할 수도 있다 auto & 타입 변수와 람다 함수를 통해서 처리 가능하며, 변수 타입은 if constexpr 구문으로 검사 가능하다.
요약
방문자 디자인 패턴은 어떤 객체의 계층 각각마다 서로 다른 작업을 수행해야 할 때 편리하게 적용할 수 있다.
- 침습적 방법
- 개별 객체마다 버추얼 멤버 함수를 추가한다.
- 클래스 계층의 소스 코드 수정이 가능해야 한다.
- OCP를 위배하는 문제가 있다.
- 반추적 방법
- 객체를 수정할 필요가 없도록 별도 분리된 방문자를 만든다.
- 런타임 디스패치가 필요한 경우 dynamic_cast를 이용한다.
- 전통적 방법(이중 디스패치)
- 클래스 계층 전체에 걸쳐 수정이 필요하다.
- 단 한번 매우 범용적 형태로 만들면 다음부터는 필요한 부분만 수정해도 된다.
- 계층의 각 항목들이 방문자를 처리할 accept()를 갖는다.
- 새로운 기능 추가시 하위 클래스를 둬 새로운 방문자를 처리한다.
'study > design pattern' 카테고리의 다른 글
[디자인패턴][행위패턴] 템플릿 메서드 - C++ (0) | 2023.04.04 |
---|---|
[디자인패턴][행위패턴] 상태 State - C++ (0) | 2023.03.30 |
[디자인패턴][행위패턴] 관찰자 Observer - C++ (0) | 2023.03.25 |
[디자인패턴][행위패턴] Null 객체 - C++ (1) | 2023.03.25 |
[디자인패턴][행위패턴] 메멘토 Memento - C++ (0) | 2023.03.25 |