C++ Visitor 패턴: 더블 디스패치와 std::variant + std::visit 비교

이 글의 핵심

Visitor는 '연산 추가는 쉽고 타입 추가는 어려운' 구조입니다. 가상 함수 기반 더블 디스패치와 std::variant + std::visit을 같은 예제로 비교하고, overloaded 람다, AST 재귀, 반환 타입 불일치 에러, valueless_by_exception 같은 실제 함정을 정리합니다.

Visitor Pattern이란?

연산을 객체 구조 밖으로 빼는 흐름은 행동 패턴 시리즈에서 Observer·Command와 함께 묶여 있습니다. Visitor의 핵심은 더블 디스패치입니다.

일반적인 가상 함수 호출은 “디스패치”가 한 번만 일어납니다 — shape->draw()를 호출하면 shape의 실제 타입(동적 타입)에 따라 어떤 draw 구현이 실행될지가 결정되지만, 그 결정은 오직 shape의 타입 하나만 보고 이루어집니다. 만약 “두 객체의 타입 조합”에 따라 동작이 달라져야 한다면(예: 도형과 렌더러의 조합, 도형과 연산의 조합) 한 번의 가상 호출로는 부족합니다. Visitor 패턴이 “더블 디스패치”라 불리는 이유가 여기 있습니다 — shape.accept(visitor)가 첫 번째 디스패치로 shape의 실제 타입을 확정하고, 그 accept 구현 안에서 visitor.visit(*this)를 호출하는 것이 두 번째 디스패치로 visitor의 실제 타입과 *this의 정확한 타입(오버로드 해석을 통해)을 함께 확정합니다. 이 두 단계를 거쳐야만 “이 정확한 도형 타입에, 이 정확한 연산을 적용하라”는 조합이 컴파일 타임 오버로딩과 런타임 가상 호출을 조합해 올바르게 선택됩니다.

#include <iostream>

// 전방 선언
class Circle;
class Rectangle;

// Visitor
class ShapeVisitor {
public:
    virtual void visit(Circle& c) = 0;
    virtual void visit(Rectangle& r) = 0;
    virtual ~ShapeVisitor() = default;
};

// Shape
class Shape {
public:
    virtual void accept(ShapeVisitor& v) = 0;
    virtual ~Shape() = default;
};

class Circle : public Shape {
public:
    Circle(double r) : radius(r) {}
    void accept(ShapeVisitor& v) override { v.visit(*this); }
    double getRadius() const { return radius; }
private:
    double radius;
};

class Rectangle : public Shape {
public:
    Rectangle(double w, double h) : width(w), height(h) {}
    void accept(ShapeVisitor& v) override { v.visit(*this); }
    double getWidth() const { return width; }
    double getHeight() const { return height; }
private:
    double width, height;
};

// 구체적 Visitor
class AreaCalculator : public ShapeVisitor {
public:
    void visit(Circle& c) override {
        area = 3.14159 * c.getRadius() * c.getRadius();
    }
    
    void visit(Rectangle& r) override {
        area = r.getWidth() * r.getHeight();
    }
    
    double getArea() const { return area; }
    
private:
    double area = 0;
};

int main() {
    Circle c(5.0);
    Rectangle r(4.0, 6.0);
    
    AreaCalculator calc;
    c.accept(calc);
    std::cout << "Circle area: " << calc.getArea() << '\n';
    
    r.accept(calc);
    std::cout << "Rectangle area: " << calc.getArea() << '\n';
}

Circle::accept와 Rectangle::accept의 본문이 글자 그대로 v.visit(*this);로 똑같다는 점이 처음에는 이상해 보입니다. “베이스 클래스에 한 번만 쓰면 되지 않나?” 싶어 Shape::accept로 옮기면, 그 안에서 *this의 정적 타입은 Shape&가 되어 visit(Shape&) 오버로드를 찾다가 no matching function for call to 'ShapeVisitor::visit(Shape&)' 에러가 납니다. 오버로드 해석은 컴파일 타임에 정적 타입으로 이루어지므로, 각 파생 클래스 안에서 *this가 Circle&, Rectangle&로 보이는 위치에 같은 코드를 반복해 써야 하는 것입니다. 반복이 거슬린다면 CRTP로 accept를 한 번만 구현하는 방법도 있습니다(template<class D> struct Visitable : Shape { void accept(ShapeVisitor& v) override { v.visit(static_cast<D&>(*this)); } };).

이 설계의 대가도 분명합니다. ShapeVisitor가 모든 구체 도형 타입을 알아야 하므로 도형 계층과 방문자 인터페이스가 서로를 참조하는 강한 결합이 생기고, accept 호출과 visit 호출로 가상 호출이 두 번 일어납니다. 또 방문자가 도형의 내부 데이터에 접근하려면 getter를 공개해야 해서 캡슐화가 약해집니다. 연산이 두세 개뿐이고 앞으로도 늘지 않는다면 그냥 Shape에 가상 함수 area()를 두는 편이 훨씬 단순합니다. Visitor는 “타입 집합은 거의 고정인데 연산이 계속 늘어나는” 경우(컴파일러의 AST 패스, 문서 모델의 내보내기 형식 등)에 비용을 회수합니다.

std::variant + std::visit (C++17)

std::variant<Circle, Rectangle>는 “이 값은 Circle이거나 Rectangle 둘 중 하나이며, 그 이상은 아니다”를 타입 시스템 차원에서 표현합니다 — 앞의 전통적인 Visitor 예제가 Shape*라는 공통 베이스 포인터로 “무엇이든 될 수 있는 타입”을 표현했던 것과 정반대 접근입니다. 이 “닫힌 집합”이라는 성질 덕분에 accept/visit 가상 함수 계층 전체가 필요 없어집니다 — std::visit은 컴파일 타임에 variant가 담을 수 있는 모든 타입을 알고 있으므로, 그 타입들에 대한 operator() 오버로드를 가진 함수 객체(AreaCalculator)를 받아 현재 담긴 값의 실제 타입에 맞는 오버로드를 컴파일러가 생성한 코드로 직접 호출합니다. Circle과 Rectangle이 Shape라는 공통 베이스를 상속할 필요조차 없다는 점도 주목할 만합니다 — 서로 완전히 무관한 두 타입을 variant로 묶기만 하면 되므로, 상속 계층을 설계하는 부담 자체가 사라집니다.

#include <variant>
#include <iostream>

struct Circle {
    double radius;
};

struct Rectangle {
    double width, height;
};

using Shape = std::variant<Circle, Rectangle>;

// Visitor (함수 객체)
struct AreaCalculator {
    double operator()(const Circle& c) const {
        return 3.14159 * c.radius * c.radius;
    }
    
    double operator()(const Rectangle& r) const {
        return r.width * r.height;
    }
};

int main() {
    Shape s1 = Circle{5.0};
    Shape s2 = Rectangle{4.0, 6.0};
    
    double area1 = std::visit(AreaCalculator{}, s1);
    double area2 = std::visit(AreaCalculator{}, s2);
    
    std::cout << "Circle: " << area1 << '\n';
    std::cout << "Rectangle: " << area2 << '\n';
}

std::visit은 내부적으로 variant의 index()로 현재 타입을 확인하고, 타입마다 하나씩 생성된 호출 함수 표(또는 switch)로 점프합니다. 가상 함수 호출과 비슷한 간접 호출 한 번이지만, 객체가 variant 안에 값으로 들어 있어 힙 할당과 포인터 추적이 없고, 컴파일러가 방문자 본문을 인라인하기 쉽습니다. 대신 sizeof(Shape)는 가장 큰 대안 타입의 크기에 인덱스를 더한 값이 되므로, 대안 중 하나만 유난히 크면 모든 값이 그 크기만큼 메모리를 차지한다는 점은 감안해야 합니다.

std::visit의 모든 오버로드는 같은 반환 타입을 가져야 합니다. Circle에는 double을, Rectangle에는 float을 반환하는 식으로 어긋나면 libstdc++는 static assertion failed: std::visit requires the visitor to have the same return type for all alternatives of a variant를 냅니다. 오버로드 하나를 빠뜨렸을 때는 에러가 훨씬 길고 난해해서 std::__invoke_result나 no type named 'type' 같은 내부 이름이 수십 줄 이어지는데, 메시지 안에서 빠진 대안 타입 이름을 찾으면 원인을 바로 알 수 있습니다.

실전 예시

예시 1: 여러 Visitor

AreaVisitor와 PrintVisitor가 같은 Shape(variant<Circle, Rectangle, Triangle>)에 대해 완전히 독립적으로 정의되고 각자 std::visit으로 적용되는 것이 Visitor 패턴 전체의 존재 이유를 보여줍니다 — 새로운 연산(예: SerializeVisitor)이 필요해지면 Shape나 기존 도형 구조체를 전혀 건드리지 않고 새 함수 객체 하나만 추가하면 됩니다. 이는 객체지향 설계에서 “도형 클래스 안에 area(), print(), serialize() 멤버 함수를 계속 추가하는” 접근과 정반대 방향입니다 — 데이터(도형)와 그 데이터에 적용할 연산(면적 계산, 출력)을 완전히 분리해, 연산 종류가 늘어날수록 도형 클래스 자체는 전혀 커지지 않습니다.

#include <variant>
#include <iostream>
#include <string>

struct Circle { double radius; };
struct Rectangle { double width, height; };
struct Triangle { double base, height; };

using Shape = std::variant<Circle, Rectangle, Triangle>;

// 면적
struct AreaVisitor {
    double operator()(const Circle& c) const {
        return 3.14159 * c.radius * c.radius;
    }
    double operator()(const Rectangle& r) const {
        return r.width * r.height;
    }
    double operator()(const Triangle& t) const {
        return 0.5 * t.base * t.height;
    }
};

// 출력
struct PrintVisitor {
    void operator()(const Circle& c) const {
        std::cout << "Circle(r=" << c.radius << ")\n";
    }
    void operator()(const Rectangle& r) const {
        std::cout << "Rectangle(w=" << r.width << ", h=" << r.height << ")\n";
    }
    void operator()(const Triangle& t) const {
        std::cout << "Triangle(b=" << t.base << ", h=" << t.height << ")\n";
    }
};

int main() {
    Shape s = Circle{5.0};
    
    double area = std::visit(AreaVisitor{}, s);
    std::cout << "Area: " << area << '\n';
    
    std::visit(PrintVisitor{}, s);
}

예시 2: Lambda Visitor

매번 AreaVisitor, PrintVisitor처럼 이름 붙은 구조체를 선언하는 것이 번거로울 만큼 일회성인 방문 로직이라면, overloaded 헬퍼로 여러 람다를 그 자리에서 즉석 함수 객체로 합쳐 쓸 수 있습니다. 이 트릭이 동작하는 원리는 두 부분으로 나뉩니다 — struct overloaded : Ts... 부분은 여러 람다 타입을 다중 상속해 하나의 타입으로 합치고, using Ts::operator()...(C++17 가변 인자 using 선언)는 그 각각의 operator()를 오버로드 집합으로 끌어와 하나로 통합합니다. 아래의 클래스 템플릿 실체화 가이드(deduction guide, overloaded(Ts...) -> overloaded<Ts...>)는 overloaded{lambda1, lambda2}처럼 템플릿 인자를 명시하지 않고도 컴파일러가 자동으로 타입을 추론하게 해 줍니다. 결과적으로 별도 이름 없이 그 자리에서 “이 타입엔 이 람다, 저 타입엔 저 람다”를 나열하는 것만으로 완전한 Visitor를 즉석에서 구성할 수 있습니다.

#include <variant>
#include <iostream>

// overloaded helper
template<typename... Ts>
struct overloaded : Ts... {
    using Ts::operator()...;
};

template<typename... Ts>
overloaded(Ts...) -> overloaded<Ts...>;

struct Circle { double radius; };
struct Rectangle { double width, height; };

using Shape = std::variant<Circle, Rectangle>;

int main() {
    Shape s = Circle{5.0};
    
    std::visit(overloaded{
        [](const Circle& c) {
            std::cout << "Circle: " << c.radius << '\n';
        },
        [](const Rectangle& r) {
            std::cout << "Rectangle: " << r.width << "x" << r.height << '\n';
        }
    }, s);
}

overloaded를 쓸 때 흔히 빠지는 함정은 범용 람다 [](const auto&)를 “기본 처리”로 넣는 습관입니다. 편하긴 하지만, 나중에 variant에 새 타입을 추가해도 그 타입이 조용히 auto 분기로 흘러가서, variant 방식의 가장 큰 장점인 “처리하지 않은 타입은 컴파일 에러”라는 안전장치가 사라집니다. 저는 기본 처리가 정말로 필요한 곳에만 auto를 쓰고, 그렇지 않으면 모든 타입을 명시하는 쪽을 택합니다. 또 하나는 암시적 변환입니다. variant<int, double>에 [](double) 람다만 있고 int 람다가 없으면 int가 double로 변환되어 에러 없이 호출되므로, 숫자 타입이 섞인 variant에서는 오버로드 누락이 드러나지 않습니다. C++20부터는 overloaded(Ts...) -> overloaded<Ts...> 추론 가이드가 없어도 집합체 CTAD로 동작하지만, C++17 코드와 함께 쓴다면 가이드를 그대로 두는 것이 안전합니다.

예시 3: 상태 변경

이 예시는 std::visit이 단순 조회뿐 아니라 상태 변경(mutation)에도 쓰일 수 있음을 보여줍니다 — operator()(Running& r)이 비-const 참조를 받아 r.progress += 10으로 값을 직접 수정하므로, state에 실제로 Running이 담겨 있을 때 std::visit(StateHandler{}, state)를 호출하면 state 안의 값이 그 자리에서 갱신됩니다. Idle, Running, Completed라는 상태를 하나의 variant로 묶는 이 패턴은 상태 머신을 표현하는 자연스러운 방법이기도 합니다 — 잘못된 상태 전이(예: Idle에서 직접 progress를 읽으려는 시도)는 애초에 타입 시스템이 막아주므로, 문자열 태그나 열거형으로 상태를 표현하고 매번 switch로 분기하는 방식보다 실수의 여지가 줄어듭니다.

#include <variant>
#include <iostream>

struct Idle {};
struct Running { int progress; };
struct Completed { int result; };

using State = std::variant<Idle, Running, Completed>;

struct StateHandler {
    void operator()(Idle&) {
        std::cout << "State: Idle\n";
    }
    
    void operator()(Running& r) {
        std::cout << "State: Running (" << r.progress << "%)\n";
        r.progress += 10;
    }
    
    void operator()(Completed& c) {
        std::cout << "State: Completed (result=" << c.result << ")\n";
    }
};

int main() {
    State state = Running{50};
    
    std::visit(StateHandler{}, state);
    std::visit(StateHandler{}, state);
}

주의할 점은 이 StateHandler가 현재 상태 안의 값만 바꿀 수 있고, 상태 자체를 Running에서 Completed로 바꾸지는 못한다는 것입니다. 방문 중에 state = Completed{...}처럼 variant를 재할당하면, 방금 받은 Running& r 참조가 가리키던 객체가 파괴되어 이후 r을 쓰는 순간 미정의 동작입니다. 상태 전이가 필요하다면 방문자가 다음 상태를 반환하게 만들고(State operator()(Running& r) { ... return Completed{r.progress}; }), 호출하는 쪽에서 state = std::visit(Transition{}, state);처럼 방문이 끝난 뒤 대입하는 형태가 안전합니다.

variant에는 드물지만 알아 둘 상태가 하나 더 있습니다. 다른 대안 타입을 대입하는 도중 새 값의 생성자가 예외를 던지면 variant는 아무 값도 없는 valueless_by_exception() 상태가 될 수 있고, 이때 std::visit은 std::bad_variant_access를 던집니다. 예외를 잡고 계속 동작하는 상태 머신이라면 이 경우를 고려해야 합니다.

예시 4: AST 순회

이 예시는 Visitor 패턴이 가장 널리 실전에서 쓰이는 자리인 컴파일러·인터프리터의 추상 구문 트리(AST) 처리를 보여줍니다. BinaryOp가 left/right로 다시 Expr(그 안에 variant를 담은)을 가리키는 재귀적 구조이므로, Evaluator::operator()(const BinaryOp&)는 자기 자신(*this)을 left->data와 right->data에 재귀적으로 std::visit하며 트리를 아래에서 위로 평가합니다 — Number 리프 노드에서는 값을 그대로 반환하고, BinaryOp 노드에서는 두 자식을 먼저 재귀적으로 평가한 뒤 연산자에 따라 결합합니다. 재귀 구조체가 unique_ptr로 자기 자신을 가리켜야 하는 이유도 짚어둘 만합니다 — variant나 struct가 자신을 값으로 직접 포함하면 무한한 크기가 되어 컴파일이 불가능하므로, 포인터(고정 크기)를 통해 간접 참조해야 재귀적인 트리 구조가 성립합니다.

#include <variant>
#include <memory>
#include <iostream>

struct Number {
    int value;
};

struct BinaryOp {
    char op;
    std::unique_ptr<struct Expr> left;
    std::unique_ptr<struct Expr> right;
};

using ExprVariant = std::variant<Number, BinaryOp>;

struct Expr {
    ExprVariant data;
};

struct Evaluator {
    int operator()(const Number& n) const {
        return n.value;
    }
    
    int operator()(const BinaryOp& op) const {
        int l = std::visit(*this, op.left->data);
        int r = std::visit(*this, op.right->data);
        
        switch (op.op) {
        case '+': return l + r;
        case '-': return l - r;
        case '*': return l * r;
        case '/': return l / r;
        default: return 0;
        }
    }
};

실제로 이 평가기를 쓰려면 몇 가지를 더 챙겨야 합니다. '/'에서 r == 0이면 정수 0 나누기라 미정의 동작(리눅스에서는 보통 SIGFPE로 종료)이므로 먼저 검사해 예외를 던지는 편이 좋고, default: return 0;은 잘못된 연산자를 조용히 0으로 만들어 버그를 숨기므로 역시 예외가 낫습니다. 또 BinaryOp는 unique_ptr 멤버 때문에 복사가 안 되므로, Expr도 복사할 수 없고 이동만 됩니다. 트리를 복제해야 하는 최적화 패스가 있다면 명시적인 clone 함수가 필요합니다. 매우 깊은 트리(수만 단계로 한쪽으로 치우친 식)에서는 재귀 std::visit과 unique_ptr의 재귀 소멸자 모두 스택을 소모하므로, 입력 크기를 제한할 수 없는 파서라면 반복 방식의 평가나 소멸을 고려해야 합니다.

전통 vs Modern

흔히 “전통 Visitor는 타입에 열려 있고 variant는 닫혀 있다”고 설명하지만, 엄밀히 보면 둘 다 타입 집합이 닫혀 있습니다. 전통 Visitor도 ShapeVisitor 인터페이스에 visit(Triangle&)을 추가하지 않으면 새 도형을 방문할 수 없고, 플러그인이 그 인터페이스를 고칠 수는 없기 때문입니다. 두 방식의 실제 차이는 다른 곳에 있습니다.

  • 객체 표현: 전통 방식은 Shape*/unique_ptr<Shape>로 힙에 흩어진 다형 객체를 다루고, variant는 값을 연속된 메모리(std::vector<Shape>)에 담습니다. 이미 가상 함수 계층이 있는 코드베이스에 연산을 붙이는 경우라면 전통 방식이 자연스럽습니다.
  • 분리 컴파일: 전통 방식은 방문자 구현을 각자의 .cpp 파일에 두고 헤더에는 인터페이스만 노출할 수 있습니다. std::visit은 템플릿이라 호출 지점에서 모든 대안 타입의 완전한 정의가 보여야 하고, 타입이 많아지면 컴파일 시간이 늘어납니다.
  • 비용: variant는 가상 호출 두 번과 힙 할당이 없고, 반환값을 바로 돌려받을 수 있습니다.

정말로 열린 타입 집합이 필요하다면(플러그인이 새 노드 타입을 추가하는 경우) dynamic_cast로 방문자가 해당 타입을 지원하는지 확인하는 Acyclic Visitor 변형이 있지만, 런타임 캐스팅 비용과 “처리되지 않은 타입이 컴파일 타임에 드러나지 않는” 단점을 감수해야 합니다.

// 전통 (가상 함수)
class Visitor {
    virtual void visit(TypeA&) = 0;
    virtual void visit(TypeB&) = 0;
};

class Shape {
    virtual void accept(Visitor&) = 0;
};

// Modern (variant + visit)
using Shape = std::variant<TypeA, TypeB>;

struct Visitor {
    void operator()(TypeA&) { /* ... */ }
    void operator()(TypeB&) { /* ... */ }
};

std::visit(Visitor{}, shape);

자주 발생하는 문제

문제 1: 타입 추가

전통 방식에서 새 도형 Triangle을 추가하려면 ShapeVisitor 인터페이스에 virtual void visit(Triangle&) = 0을 추가해야 하는데, 이 변경은 그 인터페이스를 구현한 모든 기존 Visitor 클래스(AreaCalculator뿐 아니라 미래에 추가될 다른 Visitor까지)가 새 순수 가상 함수를 구현하도록 강제합니다 — 컴파일이 안 되므로 누락은 즉시 드러나지만, Visitor 구현체가 많을수록 한 번의 타입 추가가 광범위한 수정을 요구합니다. variant 방식은 variant<Circle, Rectangle, Triangle>처럼 타입 목록에 한 줄만 추가하면 되고, 만약 어떤 함수 객체가 Triangle에 대한 operator()를 빠뜨렸다면 std::visit 호출 자체가 컴파일 에러를 내므로 여전히 누락이 안전하게 감지되지만, 수정해야 할 지점 자체는 훨씬 좁습니다.

// 전통: 모든 Visitor 수정
class Visitor {
    virtual void visit(Circle&) = 0;
    virtual void visit(Rectangle&) = 0;
    // Triangle 추가 시 모든 Visitor 수정
};

// Modern: variant만 수정
using Shape = std::variant<Circle, Rectangle, Triangle>;

문제 2: 반환 값

전통적인 가상 함수 기반 ShapeVisitor::visit은 대개 반환 타입을 void로 고정하고(도형마다 다른 타입을 반환해야 한다면 가상 함수 시그니처를 통일하기 어렵기 때문에) AreaCalculator처럼 결과를 멤버 변수에 저장한 뒤 별도의 getter로 꺼내는 우회가 필요했습니다. std::visit은 이 제약이 없습니다 — 함수 객체의 operator()가 값을 직접 return하면, std::visit(AreaVisitor{}, shape) 자체가 그 값을 그대로 반환하는 표현식이 되어 멤버 변수와 getter라는 중간 단계 없이 곧바로 결과를 얻을 수 있습니다. 이는 사소해 보이지만 상태를 갖지 않는 순수한 계산 로직을 쓸 때 코드가 훨씬 간결해지는 실질적인 개선입니다.

// ✅ 반환 값
struct AreaVisitor {
    double operator()(const Circle& c) const {
        return 3.14159 * c.radius * c.radius;
    }
};

double area = std::visit(AreaVisitor{}, shape);

문제 3: 상태

Counter처럼 여러 번의 visit 호출에 걸쳐 값을 누적해야 하는 Visitor는, 함수 객체 자체를 멤버 변수를 가진 구조체로 만들고 같은 인스턴스를 반복해서 std::visit에 넘기는 방식으로 자연스럽게 표현됩니다. counter가 루프 바깥에서 한 번만 생성되고 그 안의 count가 루프의 각 std::visit 호출을 거치며 계속 누적되는 것이 핵심입니다 — 만약 루프 안에서 매번 Counter{}를 새로 만들어 넘겼다면 상태가 매번 리셋되어 항상 1(또는 0)만 나왔을 것입니다. 이는 펑터(함수 객체)가 일반 함수와 달리 상태를 가질 수 있다는 성질을 std::visit과 결합해 그대로 활용하는 예시입니다.

// Visitor에 상태
struct Counter {
    int count = 0;
    
    void operator()(const Circle&) { ++count; }
    void operator()(const Rectangle&) { ++count; }
};

Counter counter;
for (const auto& shape : shapes) {
    std::visit(counter, shape);
}

문제 4: 순환 의존

Expr이 variant<Number, BinaryOp>를 멤버로 갖고, BinaryOp는 다시 Expr을 가리켜야 하는 이 구조는 정의 순서상 서로가 서로를 필요로 하는 순환처럼 보이지만, unique_ptr<Expr>을 통한 간접 참조 덕분에 실제로는 컴파일이 가능합니다 — 포인터는 가리키는 타입이 아직 완전히 정의되지 않은 시점에도(전방 선언만 있어도) 선언할 수 있기 때문에, BinaryOp가 정의되는 시점에 Expr의 완전한 정의가 아직 없어도 unique_ptr<Expr> 멤버는 문제없이 선언됩니다. 이것이 앞서 AST 예시에서 짚었던 “재귀 구조체는 포인터로 간접 참조해야 한다”는 규칙의 근본 원리이며, 트리·연결 리스트처럼 자기 자신을 참조하는 모든 재귀적 데이터 구조가 예외 없이 이 패턴을 따릅니다.

// 전방 선언 + unique_ptr
struct Expr;

struct BinaryOp {
    std::unique_ptr<Expr> left;
    std::unique_ptr<Expr> right;
};

struct Expr {
    std::variant<Number, BinaryOp> data;
};

사용 시기

  • 전통 Visitor: 이미 가상 함수 기반 클래스 계층이 있고, 방문자 구현을 별도 번역 단위로 분리하고 싶을 때
  • std::variant + std::visit: 타입 목록을 한곳에서 관리할 수 있고, 값 시맨틱·성능·반환값이 중요할 때 (C++17 이상)
  • 둘 다 과할 때: 연산이 몇 개뿐이고 늘어날 계획이 없다면 그냥 가상 함수

FAQ

Q1: 방문자 안에서 방문 중인 variant를 다른 타입으로 바꿔도 되나요?

A: 안 됩니다. 방문자가 받은 참조는 variant 내부의 현재 값을 가리키므로, 재할당하는 순간 그 객체가 파괴되어 참조가 댕글링이 됩니다. 다음 상태를 반환값으로 돌려주고 std::visit이 끝난 뒤 대입하십시오.

Q2: 두 variant를 동시에 방문할 수 있나요?

A: std::visit(visitor, v1, v2)처럼 여러 variant를 넘기면 모든 타입 조합(대안 수의 곱만큼)에 대한 오버로드가 필요합니다. 도형 간 충돌 판정처럼 “두 객체 타입의 조합”에 따라 동작이 달라지는 경우에 유용하지만, 조합 수가 빠르게 늘어나므로 범용 auto 오버로드로 기본 동작을 두는 경우가 많습니다.

Q3: std::visit이 가상 함수보다 항상 빠른가요?

A: 대부분 비슷하거나 빠르지만 “항상”은 아닙니다. 속도 이득의 대부분은 호출 방식이 아니라 힙 할당이 없고 값이 연속 메모리에 있다는 데서 나옵니다. 대안 타입이 많고 크기 차이가 크면 variant 쪽이 메모리를 더 쓰기도 하므로, 성능이 중요하면 실제 데이터로 측정해야 합니다.


같이 보면 좋은 글