방문자 패턴은 어떤 객체의 계층 각각마다 서로 다른 작업을 수행해야 할때 사용하면 좋습니다.
기존 코드의 수정 없이 새로운 방문자를 추가하는 것만으로도 기능을 확장 할 수 있습니다.
기능 확장면에서 OCP 충실히 따를 수 있습니다.
다음과 같이 DoubleExpression과 AdditionExpression 수식이 있고 각 수식마다 새로운 동작을 추가해야 할때를 생각해봅시다.
struct Expression
{
};
struct DoubleExpression : Expression
{
double value;
explicit DoubleExpression(const double value)
: value{value} {}
};
struct AdditionExpression : Expression
{
Expression *left, *right;
AdditionExpression(Expression* const left, Expression* const right)
: left{left}, right{right} {}
~AdditionExpression()
{
delete left;
delete right;
}
침습적 방문자
원래의 방문자는 기존 코드에 영향을 주지 않고 확장하는 것이지만 직접 수정을 하여 print 동작을 추가합니다.
struct Expression
{
virtual void print(ostringstream& oss) = 0; // print 동작 추가
};
struct DoubleExpression : Expression
{
double value;
explicit DoubleExpression(const double value)
: value{value} {}
void print(ostringstream& oss) override // print 동작 추가
{
oss << value;
}
};
struct AdditionExpression : Expression
{
Expression *left, *right;
AdditionExpression(Expression* const left, Expression* const right)
: left{left}, right{right} {}
~AdditionExpression()
{
delete left;
delete right;
}
void print(ostringstream& oss) override // print 동작 추가
{
oss << "(";
left->print(oss);
oss << "+";
right->print(oss);
oss << ")";
}
};
이 방식으로 수식이나 동작들이 더 추가되려면 어떤 작업들을 해주어야 하는가?
OCP는 문제도 아닙니다. 실질적인 문제는 SRP입니다.
아래와 같이 테스트 해봅니다.
auto e = new AdditionExpression{
new DoubleExpression{1},
new AdditionExpression{
new DoubleExpression{2},
new DoubleExpression{3}
}
};
ostringstream oss;
e->print(oss);
cout << oss.str() << endl; // 출력 결과 : (1+(2+3))
반추적(reflective) 출력
이제 별도의 출력 컴포넌트를 만드는 접근 방법을 사용해봅시다.
print 메서드를 모두 제거하고 ExpressionPrinter 클래스를 도입합니다. print 기능만을 클래스를 구현하므로 SRP 위배하지 않게 됩니다.
struct Expression
{
virtual ~Expression() = default;
};
struct DoubleExpression : Expression
{
double value;
explicit DoubleExpression(const double value)
: value{ value } {}
};
struct AdditionExpression : Expression
{
Expression *left, *right;
AdditionExpression(Expression* const left, Expression* const right)
: left{ left }, right{ right } {}
~AdditionExpression()
{
delete left;
delete right;
}
};
struct ExpressionPrinter
{
void print(DoubleExpression *de, ostringstream& oss) const
{
oss << de->value;
}
void print(AdditionExpression *ae, ostringstream& oss) const
{
oss << "(";
print(ae->left, oss); // ERROR : left의 타입이 Expression* 이므로 두 print 함수 중 어느 것이 선택되어야 할지 모릅니다.
oss << "+";
print(ae->right, oss);
oss << ")";
}
};
C++은 런타임에 타입을 체크하여 오버로딩하는 방식이 아니라 컴파일 시점에 오버로딩이 결정되기 때문에 두 개의 print() 중에 어느 것이 선택되어 하는지 알지 못합니다.
이를 해결하는 방법이 바로 오버로딩을 버리고 런타임에 타입 체크를 명시적으로 구현해 넣는 것입니다. 타입이 무엇인지 되돌아보기 때문에 반추적 방법이라 합니다.
struct ExpressionPrinter
{
ostringstream oss;
void print(Expression *e)
{
if (auto de = dynamic_cast<DoubleExpression*>(e)) // 런타임에 타입 체크를 하여 구현, reflective
{
oss << de->value;
}
else if (auto ae = dynamic_cast<AdditionExpression*>(e))
{
oss << "(";
print(ae->left);
oss << "+";
print(ae->right);
oss << ")";
}
}
string str() const { return oss.str(); }
};
코드 품질이 좋지 않지만 꽤 실용적입니다.
auto e = new AdditionExpression{
new DoubleExpression{ 1 },
new AdditionExpression{
new DoubleExpression{ 2 },
new DoubleExpression{ 3 }
}
};
ExpressionPrinter ep;
ep.print(e);
cout << ep.str() << endl;
디스패치(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* foo = new Foo;
func(foo); // error
런타임에 명시적으로 타입 체크를 하지 않고서도(dynamic_cast 등을 이용) 올바르게 오버로딩할 방법이 있을까?
다음의 코드가 그 방법입니다. "이중 디스패치"라고 부릅니다.
struct Foo;
struct Bar;
void func(Foo* foo) { // 두번째 dispatch, this가 Foo이므로 func(Foo* foo) 호출
cout << "foo" << endl;
}
void func(Bar* bar) {
cout << "bar" << endl;
}
struct Stuff {
virtual void call() = 0;
};
struct Foo : Stuff {
void call() override { func(this); } // 첫번째 dispatch, vtable에 의해 Foo의 call() 호출
};
struct Bar : Stuff {
void call() override { func(this); }
};
int main()
{
Stuff* foo = new Foo;
foo->call();
return 0;
}
전통적인 방문자
방문자 패턴의 전통적인 구현은 이중 디스패치를 이용합니다. 방문자 구현시 다음과 같은 네이밍 관례가 있습니다.
- 방문자의 멤버 함수는 visit()라는 이름을 가집니다.
- 클래스 계층마다 구현될 멤버 함수는 accept()라는 이름을 가집니다.
이제 Expression에 accept 멤버 함수를 둡니다.
struct DoubleExpression : Expression
{
double value;
explicit DoubleExpression(const double value)
: value{ value } {}
void accept(ExpressionVisitor* visitor) override
{
visitor->visit(this); // 이중 디스패치
}
};
struct AdditionExpression : Expression
{
Expression *left, *right;
AdditionExpression(Expression* const left, Expression* const right)
: left{ left }, right{ right } {}
~AdditionExpression()
{
delete left;
delete right;
}
void accept(ExpressionVisitor* visitor) override
{
visitor->visit(this);
}
};
accept()의 구현을 보면 ExpressionVisitor 포인터를 인자로 받습니다. ExpressionVisitor는 여러가지 방문자(ExpressionPrinter, ExpressionEvaluator)의 베이스 클래스입니다.
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;
};
visit() 멤버 함수들의 구현입니다.
void ExpressionPrinter::visit(DoubleExpression* de)
{
oss << de->value;
}
void ExpressionPrinter::visit(AdditionExpression* e)
{
oss << "(";
e->left->accept(this); // 이중 디스패치
oss << "+";
e->right->accept(this);
oss << ")";
}
이중 디스패치를 채용한 Visitor는 다음과 같이 이용 될 수 있습니다.
auto e = new AdditionExpression{
// 동일
};
ostringstream oss;
ExpressionPrinter ep;
ep.visit(e);
cout << printer.str() << endl;
방문자 추가하기
다음과 같이 OCP를 준수하면서 새로운 방문자 ExpressionEvaluator를 추가 할 수 있습니다.
struct ExpressionEvaluator : ExpressionVisitor
{
double result;
void visit(DoubleExpression* de) override;
void visit(AdditionExpression* ae) override;
};
void ExpressionEvaluator::visit(DoubleExpression* de)
{
result = de->value;
}
void ExpressionEvaluator::visit(AdditionExpression* ae)
{
ae->left->accept(this);
auto temp = result;
ae->right->accept(this);
result += temp;
}
방문자를 추가하면서 accept 관련 코드는 전혀 손댈 필요가 없었습니다.
비순환 방문자
방문자 패턴은 다음과 같이 두 가지 유형으로 나눌 수 있습니다.
- 순환 방문자
함수 오버로딩에 기반하는 방문자입니다. 클래스 계층과 방문자간에 상호 참조하는 순화적인 종속성이 발생합니다.
24.4에서의 구현이 순환 방문자 입니다. DoubleExpression(클래스 계층)과 ExpressionPrinter(방문자)간의 상호 참조가 있는 것을 코드를 보면 바로 알 수 있습니다. - 비순환 방문자
런타임 타입 정보(RTTI)에 의존하는 방문자입니다. 이 방법은 방문 될 클래스 계층에 제한이 없다는 점입니다. 하지만 성능적인 부분의 약간의 손해가 았습니다.
비순환 방문자는 아래와 같이 최대한 범용적인 형태로 방문자 인터페이스를 정의합니다.
template <typename Visitable>
struct Visitor
{
virtual void visit(Visitable& obj) = 0;
};
앞서 보았던 Expression 클래스들은 다음과 같이 정의합니다.
struct Expression
{
virtual void accept(VisitorBase& obj)
{
using EV = Visitor<Expression>;
if (auto ev = dynamic_cast<EV*>(&obj))
ev->visit(*this);
}
};
struct DoubleExpression : Expression{
double value;
DoubleExpression(double value) : value(value) {}
virtual void accept(VisitorBase& obj)
{
using DEV = Visitor<DoubleExpression>;
if (auto ev = dynamic_cast<DEV*>(&obj)) // 타입 캐스팅 하여 성공하면 호출 합니다. 특정 방문자를 종속 하는 것이 아니라 VisitorBase를 참조하므로 비순환이라 합니다.
ev->visit(*this);
}
};
struct AdditionExpression : Expression
{
Expression *left, *right;
AdditionExpression(Expression *left, Expression *right) : left(left), right(right) {}
~AdditionExpression()
{
delete left;
delete right;
}
virtual void accept(VisitorBase& obj)
{
using AEV = Visitor<AdditionExpression>;
if (auto ev = dynamic_cast<AEV*>(&obj))
ev->visit(*this);
}
};
dynamic_cast<EV*>(&obj) 캐스팅이 성공하면 방문자가 해당 타입을 어떻게 방문해야하는지 알 수 있게 되고 캐스팅 된 타입의 visit() 멤버 함수를 호출합니다.
만약 캐스팅에 실패하면 아무것도 하지 않습니다.
ExpressionPrinter 클래스를 다음과 같이 구현하여 전체 표현을 방문 할 수 있도록 합니다.
struct ExpressionPrinter : VisitorBase,
Visitor<DoubleExpression>,
Visitor<AdditionExpression>
{
void visit(DoubleExpression &obj) override
{
oss << obj.value;
}
void visit(AdditionExpression &obj) override
{
oss << "(";
obj.left->accept(*this);
oss << "+";
obj.right->accept(*this);
oss << ")";
}
string str() const { return oss.str(); }
private:
ostringstream oss;
std::variant와 std::visit
std::any가 안전한 void*라면 std::variant는 안전한 union이라 할 수 있습니다.
다음과 같이 사용할 수 있습니다.
variant<string, int> house;
house = 221;
cout << std::get<int>(house) << endl;;
cout << std::get<1>(house) << endl;
std::visit를 이용해 자동으로 가변 타입에서 실제 저장된 타입에 맞추어 오버로딩 되도록 함수 호출 연산자를 호출 할 수 있습니다.
struct AddressPrinter
{
void operator() (const string& house_name) const {
cout << "A house called " << house_name << endl;
}
void operator() (const int house_number) const {
cout << "House number " << house_number << endl;
}
};
int main()
{
variant<string, int> house;
house = 221;
AddressPrinter ap;
std::visit(ap, house);
house = "jino";
std::visit(ap, house);
return 0;
}
// 출력 결과 :
// House number 221
// A house called jino
코드 참고 : https://cpp-design-patterns.readthedocs.io/en/latest/
'프로그래밍 일반 > 디자인 패턴' 카테고리의 다른 글
템플릿 메서드 패턴(Template method Pattern) (0) | 2020.03.04 |
---|---|
전략 패턴(Strategy Pattern) (0) | 2020.03.04 |
상태 패턴(State Pattern) (0) | 2020.03.04 |
관찰자 패턴(Observer Pattern) (0) | 2020.03.04 |
Null 객체 (0) | 2020.03.04 |