이전 글에서는 템플릿 메서드(Template Method) 패턴을 모던 C++ 관점에서 재해석하며, 상속 없이도 람다, Concepts, std::expected, std::format, coroutine, Ranges 등을 활용해 알고리즘 골격을 선언적이고 타입 안전하게 표현할 수 있음을 확인했습니다. 이번에는 행동(Behavioral) 패턴 중 비지터(Visitor) 패턴을 다룹니다.
비지터 패턴은 객체 구조(예: 복합 구조나 노드 트리)를 순회하면서, 각 요소 타입에 따라 다른 연산을 수행할 수 있게 하는 패턴입니다. 전통적으로는 각 요소 클래스에 accept(Visitor&) 메서드를 정의하고, 비지터 인터페이스에 각 요소 타입별 visit() 메서드를 두어 이중 디스패치를 실현했으나, 이는 클래스와 메서드 증가, 유지보수 어려움, 타입 안전성 제약을 가져왔습니다.
C++20 이상에서는 std::variant, std::visit를 통해 이중 디스패치 없이도 값 기반 다형성을 활용해 각 요소 타입별 처리를 간결히 구현할 수 있습니다. 또한 Concepts, std::expected, coroutine, Ranges, std::format 등을 결합해 에러 처리, 비동기 방문, 조건부 로직, 로깅, 파이프라인 처리 등 다양한 시나리오에도 쉽게 대응할 수 있습니다.
패턴 소개: 비지터(Visitor)
의도:
- 객체 구조를 순회하며 요소 타입에 따른 연산을 수행하는 로직을 외부(Visitor)에 캡슐화.
- 전통적으로 이중 디스패치(double dispatch) 기법 사용: 요소가 accept(Visitor&) 호출 → Visitor가 요소 타입에 맞는 visit() 호출.
전통적 구현 문제점:
- Visitor 인터페이스 + 요소마다 accept() 메서드 + visit() 오버로드 → 클래스/메서드 증가
- 새로운 요소 타입 추가 시 Visitor, 요소 클래스 모두 수정 필요
- 에러 처리, 조건부 방문, 비동기 처리, 로깅 등 고급 기능 구현 복잡
기존 C++ 스타일 구현 (전통적 방식)
간단한 수식(Expression) 구조: Number, Add, Multiply 노드, Visitor를 통해 값 계산:
#include <iostream>
#include <memory>
struct ExpressionVisitor;
struct Expression {
virtual ~Expression() = default;
virtual void accept(ExpressionVisitor& v) = 0;
};
struct Number : Expression {
int value;
Number(int v):value(v){}
void accept(ExpressionVisitor& v) override;
};
struct Add : Expression {
std::unique_ptr<Expression> left, right;
Add(std::unique_ptr<Expression> l, std::unique_ptr<Expression> r)
:left(std::move(l)), right(std::move(r)) {}
void accept(ExpressionVisitor& v) override;
};
struct ExpressionVisitor {
virtual ~ExpressionVisitor()=default;
virtual void visit(Number& n)=0;
virtual void visit(Add& a)=0;
};
void Number::accept(ExpressionVisitor& v) { v.visit(*this); }
void Add::accept(ExpressionVisitor& v) { v.visit(*this); }
struct EvalVisitor : ExpressionVisitor {
int result;
void visit(Number& n) override { result = n.value; }
void visit(Add& a) override {
a.left->accept(*this);
int leftVal = result;
a.right->accept(*this);
int rightVal = result;
result = leftVal + rightVal;
}
};
int main() {
auto expr = std::make_unique<Add>(
std::make_unique<Number>(3),
std::make_unique<Number>(4)
);
EvalVisitor ev;
expr->accept(ev);
std::cout << "Result: " << ev.result << "\n"; // 7
}
문제점:
- Visitor 인터페이스, 각 요소에 accept(), visit() 메서드 필요 → 클래스/메서드 증가
- 요소 타입 추가 시 모든 Visitor 수정 필요
- 에러 처리나 비동기 방문, 조건부 연산 등 구현 복잡
모던 C++20 이상의 개선: std::variant, std::visit, Concepts
1. 값 기반 표현: std::variant로 요소 타입 관리
std::variant와 std::visit로 이중 디스패치 없이 요소 타입별 로직 구현 가능. 예: Number, Add를 variant로 표현:
#include <variant>
#include <expected>
#include <string>
#include <iostream>
#include <format>
struct NumberNode { int value; };
struct AddNode {
std::shared_ptr<struct ExprNode> left, right;
};
struct ExprNode {
std::variant<NumberNode, AddNode> expr;
};
// 해석 함수: std::expected<int,std::string> 반환
std::expected<int,std::string> evaluateExpr(const std::shared_ptr<ExprNode>& node) {
return std::visit([](auto& e)->std::expected<int,std::string> {
using T = std::decay_t<decltype(e)>;
if constexpr (std::is_same_v<T,NumberNode>) {
return e.value;
} else if constexpr(std::is_same_v<T,AddNode>) {
auto leftVal = evaluateExpr(e.left);
if(!leftVal) return std::unexpected(leftVal.error());
auto rightVal = evaluateExpr(e.right);
if(!rightVal) return std::unexpected(rightVal.error());
return *leftVal + *rightVal;
} else {
return std::unexpected("Unknown node type");
}
}, node->expr);
}
비교:
- 전통적: Visitor 인터페이스, accept/visit 오버로드 필요
- C++20: std::variant + std::visit로 타입별 처리 간단, 상속 없음
2. Visitor를 함수 합성으로 구현
Visitor 개념: 새로운 연산 추가 시 visit 함수 추가 필요. 모던 C++에선 std::visit로 각 타입에 대한 람다 제공.
예를 들어, 로깅 visitor:
auto loggingVisitor = [&](const std::shared_ptr<ExprNode>& node) -> std::expected<int,std::string> {
std::cout << "[LOG] Evaluating...\n";
auto res = evaluateExpr(node);
if(!res) std::cout << "[LOG] Error: " << res.error() << "\n";
else std::cout << std::format("[LOG] Result: {}\n", *res);
return res;
};
비교:
- 전통적: 새로운 방문 연산 추가 시 Visitor 인터페이스 수정, 하위 클래스 추가
- C++20: 람다 장식(데코레이터)로 새로운 연산(로깅) 쉽게 추가
3. Undo/Redo, 조건부 방문
조건부 방문(예: 특정 노드 타입만 방문):
auto conditionalVisit = [&](const std::shared_ptr<ExprNode>& node) -> std::expected<int,std::string> {
// 예: Add 노드 만 해석, Number 노드 시 에러
return std::visit([](auto& e)->std::expected<int,std::string> {
using T = std::decay_t<decltype(e)>;
if constexpr (std::is_same_v<T,AddNode>) {
// Add 처리
return 42; // 가상의 값
} else {
return std::unexpected("Number node not allowed here");
}
}, node->expr);
};
Undo/Redo나 상태 관리도 람다 캡처로 상태 저장 가능.
비교:
- 전통적: 조건부 방문 변경 시 Visitor, 요소 클래스 수정 필요
- C++20: 람다 내부 if constexpr로 조건부 처리, 상속 없음
4. 비동기 방문: coroutine
각 노드 방문 시 coroutine으로 비동기 I/O 가능:
// co_evaluateExpr(...) 가상의 coroutine, co_await 비동기 I/O, 완료 시 std::expected 반환
비교:
- 전통적: 비동기 처리 어렵, Future/Promise 또는 콜백 필요
- C++20: coroutine으로 비동기 방문 자연스럽게 구현
5. Ranges로 노드 파이프라인 처리
노드 리스트가 있다면 Ranges로 필터링, 변환 후 방문:
#include <vector>
#include <ranges>
std::vector<std::shared_ptr<ExprNode>> nodes;
// nodes를 | std::views::transform(evaluateExpr)로 처리해 모든 노드 해석
for (auto res : nodes | std::views::transform(evaluateExpr)) {
if(!res) std::cerr << "Error: " << res.error() << "\n";
else std::cout << "Val: " << *res << "\n";
}
비교:
- 전통적: Visitor로 일괄 적용 시 Visitor 타입 추가 필요
- C++20: Ranges로 노드 리스트 파이프라인 처리, visitor 없이도 쉽게 연산 적용
전통적 구현 vs C++11/14/17 vs C++20 이상 비교
전통적 (C++98/03):
- Visitor 인터페이스, 각 요소 accept() 메서드, visit() 오버로드 필요 → 클래스/메서드 증가
- 새로운 요소 타입/방문 연산 추가 시 코드 수정 다수
- 비동기, 에러 처리, 조건부 방문 등 고급 기능 구현 번거로움
C++11/14/17:
- 람다, std::function 도입으로 일부 개선
- 여전히 Concepts, std::expected, coroutine, Ranges 미지원 → 정적 타입 제약, 비동기/에러 처리 간결화 한계
C++20 이상(모던 C++):
- std::variant+std::visit로 이중 디스패치 없이 값 기반 다형성
- Concepts로 타입 안전한 요구사항 정의, std::expected로 에러 처리 명확화
- coroutine으로 비동기 방문, Ranges로 노드 목록 파이프라인 처리, std::format으로 로깅
- Undo/Redo, 조건부 방문 등 기능 추가 용이, 상속 필요 없음
- 유지보수성, 확장성, 타입 안전성 모두 향상
결론
비지터(Visitor) 패턴은 객체 구조를 순회하며 요소 타입에 따른 연산을 수행하는 강력한 패턴이지만, 전통적 구현은 Visitor 인터페이스와 accept/visit 메서드를 통한 이중 디스패치로 클래스와 메서드 증가, 유지보수 어려움을 야기했습니다.
C++20 이상에서는 std::variant, std::visit로 값 기반 다형성을 활용하고, Concepts, std::expected, coroutine, Ranges, std::format 등을 결합해 상속 없는 방문 로직을 구현할 수 있습니다. 이를 통해 전통적 구현 대비 훨씬 적은 코드로 더 많은 기능(에러 처리, 비동기 방문, 로깅, 조건부 처리, Undo/Redo) 지원이 가능하며, 타입 안전성과 유지보수성이 대폭 개선됩니다.