[모던 C++로 다시 쓰는 GoF 디자인 패턴 총정리 #20] 비지터(Visitor) 패턴: std::variant와 std::visit로 이중 디스패치 없이 방문 로직 구현하기

이전 글에서는 템플릿 메서드(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) 지원이 가능하며, 타입 안전성과 유지보수성이 대폭 개선됩니다.

 

반응형