C++ Visitor Pattern: Classic Double Dispatch vs std::variant and std::visit
Key takeaways
Visitor pattern in modern C++: classic accept/visit vs std::variant + std::visit (C++17), ASTs, trade-offs when adding types vs operations—tutorial for English readers.
Visitor sits with other behavioral patterns in C++ behavioral patterns #20-1 and the overview #20-2.
What is Visitor Pattern?
Visitor solves a problem regular virtual functions can’t: picking the right behavior based on two types at once — the concrete shape (Circle vs. Rectangle) and the concrete operation (AreaCalculator vs. some other visitor) — when C++ only gives you single dispatch (one virtual call picks one override based on one object’s dynamic type). The trick, “double dispatch,” is really just two ordinary virtual calls chained together: shape.accept(visitor) is the first virtual call, dispatching on the shape’s dynamic type to run the right accept override; inside that override, visitor.visit(*this) is the second virtual call, dispatching on the visitor’s dynamic type and passing a statically-typed *this so overload resolution picks the right visit(Circle&) vs. visit(Rectangle&) overload. Neither call alone can express “do X specifically for a Circle visited by an AreaCalculator” — it takes both together.
#include <iostream>
// forward declaration
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;
};
// specific 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';
}
std::variant + std::visit (C++17)
The classic Visitor above needs an entire class hierarchy (ShapeVisitor, accept, virtual visit overloads) just to express “call the right function for whichever type this actually is.” std::variant + std::visit gets the same double-dispatch effect without any of that machinery, because a variant<Circle, Rectangle> already knows its own alternative — std::visit just calls whichever operator() overload matches the type currently held, resolved through ordinary overload resolution rather than a virtual call chain. The tradeoff is the one the FAQ above already names: variant’s type list is fixed at compile time, so this only works cleanly for a closed set of types — adding a new shape means editing the variant declaration everywhere it’s used, whereas the classic OO Visitor can accept new Shape subclasses without recompiling code that only knows about the Shape base interface.
#include <variant>
#include <iostream>
struct Circle {
double radius;
};
struct Rectangle {
double width, height;
};
using Shape = std::variant<Circle, Rectangle>;
// Visitor (function object)
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';
}
Multiple visitors, lambda visitors, state machines, and ASTs
Independent visitor structs
The point worth noticing here is that AreaVisitor and PrintVisitor are completely independent structs — adding a third visitor (say, SerializeVisitor) never requires touching Circle, Rectangle, or Triangle at all. This is the “easy to add operations” side of the classic Visitor-pattern tradeoff, and it holds just as well for the variant-based version: the cost of a closed type set is paid once per type you add, not once per operation.
#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>;
// area
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;
}
};
// output
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);
}
Ad-hoc visitors with overloaded
The overloaded helper is the piece that makes ad-hoc, throwaway visitors practical without writing a named struct every time. It works by inheriting from every lambda passed to it and pulling in each one’s operator() via using Ts::operator()... — the result is a single object whose call operator is overloaded once per lambda, so std::visit can pick the right one exactly like it would for a hand-written struct with multiple operator() overloads. The deduction guide right below it (overloaded(Ts...) -> overloaded<Ts...>) is what lets you write overloaded{lambda1, lambda2} instead of spelling out the template arguments yourself — a small piece of C++17 machinery worth recognizing when you see it elsewhere, since this exact helper shows up in most codebases that use std::variant heavily.
#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);
}
A state machine as a variant
Using std::variant to model a state machine like this — Idle/Running/Completed as the alternatives — is one of the pattern’s most common real-world uses outside of geometry examples. A pitfall worth flagging explicitly: StateHandler::operator()(Running& r) takes a non-const reference and mutates r.progress in place, which means calling std::visit(StateHandler{}, state) twice in a row (as main does below) genuinely advances the stored state each time — this isn’t read-only inspection, it’s live mutation through the variant. If a visitor is meant to only observe state without changing it, taking const Running& (matching the earlier AreaVisitor/PrintVisitor examples) makes that intent explicit and prevents an accidental side effect from a visitor that was only supposed to print.
#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);
}
Walking an AST
This is the use case where Visitor (in either form) earns its keep the most — an abstract syntax tree has a genuinely closed, well-known set of node types (Number, BinaryOp, and whatever else a small expression language needs), which is exactly the profile std::variant is good at, and tree-walking interpreters/compilers are full of “one operation per node type” logic (evaluation, pretty-printing, type-checking) that Visitor is specifically designed to keep decoupled from the node definitions themselves. The recursive std::visit(*this, op.left->data) call inside Evaluator::operator()(const BinaryOp&) is the pattern to recognize: a visitor calling itself on child nodes is how a single Evaluator walks an entire tree without any of the node types needing to know how to evaluate themselves.
#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;
}
}
};
Classic double dispatch vs std::visit
// traditional (virtual function)
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);
New types, return values, visitor state, and recursive types
Adding a new type touches every visitor
This is the exact tradeoff the pattern is famous for, sometimes called “the expression problem”: you can optimize for easy-to-add-operations (classic OO Visitor, where a new visitor class doesn’t touch existing types) or easy-to-add-types (variant + visit, where a new type doesn’t touch existing operations), but not both at once with either approach. The question worth asking before choosing isn’t “which is better” — it’s “which direction does my codebase actually grow in.” A compiler’s AST rarely gains new node types after the language stabilizes, but frequently gains new passes (optimization, analysis, codegen) — that’s a variant-friendly shape. A plugin system where third parties add new shape types to a fixed rendering pipeline is the opposite shape, and favors the classic OO Visitor.
// Tradition: Fix all Visitors
class Visitor {
virtual void visit(Circle&) = 0;
virtual void visit(Rectangle&) = 0;
// Modify all Visitors when adding a Triangle
};
// Modern: Modify only variant
using Shape = std::variant<Circle, Rectangle, Triangle>;
Returning values from a visitor
// ✅ Return value
struct AreaVisitor {
double operator()(const Circle& c) const {
return 3.14159 * c.radius * c.radius;
}
};
double area = std::visit(AreaVisitor{}, shape);
Keeping state in a visitor
// Status on 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);
}
Recursive types and circular dependencies
This is the one I’d actually expect to trip someone up in practice, not the toy examples above. A real AST needs BinaryOp to reference Expr (its operands) and Expr to reference BinaryOp (as one of its variant alternatives) — a genuine circular type dependency that can’t be resolved by simply reordering declarations, since each type is incomplete at the point the other needs it. std::unique_ptr<Expr> is what breaks the cycle: a pointer only needs Expr to be declared, not fully defined, at the point BinaryOp is declared, because the pointer itself is a fixed-size handle regardless of what it points to. Trying to store Expr by value instead of behind a unique_ptr here would fail to compile with a genuinely confusing “incomplete type” error — the kind of error that sends people down an unrelated rabbit hole before they realize the fix is “wrap it in a pointer,” not “fix my includes.”
// forward declaration + unique_ptr
struct Expr;
struct BinaryOp {
std::unique_ptr<Expr> left;
std::unique_ptr<Expr> right;
};
struct Expr {
std::variant<Number, BinaryOp> data;
};
Choosing between classic Visitor and std::visit
- Traditional Visitor: type fixed, polymorphism required
- std::variant + std::visit: type-restricted, performance-critical, modern C++
In practice, most new C++ code reaches for std::variant + std::visit first and only falls back to the classic OO Visitor when there’s a concrete reason the type set genuinely can’t be closed — a plugin architecture, a library boundary where downstream consumers need to add their own types without recompiling your code. The classic pattern isn’t obsolete, but it’s easy to over-apply out of habit from languages or codebases where it was the only option; if the actual requirement is “process a handful of known shapes with several operations,” reaching for variant first and only escalating to inheritance-based Visitor when a real extensibility requirement shows up tends to produce simpler code.
FAQ
Q1: Visitor Pattern?
A: Double dispatch pattern.
Q2: Purpose?
A: Processing by type.
Q3: std::visit?
A: Visit C++17 variant.
Q4: Advantages?
A: Type safety, extensibility.
Q5: Disadvantages?
A: Requires modification when adding type.
Q6: What are the learning resources?
A:
- “Design Patterns”
- “C++17 STL”
- cppreference.com
Related Articles
- CRTP vs Virtual Functions: Static Polymorphism Without vtable Overhead
- Factories in C++
- The Strategy Pattern in C++
- The Adapter Pattern in C++
- The Command Pattern in C++