Visitor Pattern
C++ visitor pattern — double dispatch, std::variant + std::visit, multi-variant dispatch, recursive ASTs, and the acyclic variant.
Visitor Patternsince C++98Visitor externalises operations on an object hierarchy by encoding dispatch through a pair of virtual calls (classic double dispatch, C++98) or through exhaustive std::variant resolution (C++17), letting new algorithms be added without modifying the visited types.
Overview
The core problem is the expression problem: you have a fixed set of types and a growing set of operations. Adding each operation as a virtual function pollutes the base class and forces recompilation of every subclass. Visitor inverts the dependency — operations are separate classes (or callables), and types expose only a single accept() hook.
The dispatch mechanism is what makes it work: a single virtual call resolves on the static type of its argument, not the runtime type. Visitor achieves runtime-type resolution on both receiver and argument through double dispatch — shape.accept(visitor) dispatches on the shape's runtime type, and within accept(), visitor.visit(*this) dispatches on the shape's static type (now known).
C++17 provides a value-based alternative via std::variant + std::visit. The tradeoffs are symmetric and complementary:
- Classic: open to new types (add a subclass), closed to new operations (adding a
visitoverload touches every visitor). std::variant: closed to new types (the variant list is fixed at compile time), open to new operations (any callable works without touching the types).
Classic: Double Dispatch (C++98)
// Forward declarations required — ShapeVisitor names these before they are defined
struct Circle;
struct Rectangle;
struct Triangle;
struct ShapeVisitor {
virtual void visit(const Circle&) = 0;
virtual void visit(const Rectangle&) = 0;
virtual void visit(const Triangle&) = 0;
virtual ~ShapeVisitor() = default;
};
struct Shape {
virtual void accept(ShapeVisitor&) const = 0;
virtual ~Shape() = default;
};
struct Circle : Shape {
float radius;
explicit Circle(float r) : radius{r} {}
void accept(ShapeVisitor& v) const override { v.visit(*this); }
};
struct Rectangle : Shape {
float w, h;
void accept(ShapeVisitor& v) const override { v.visit(*this); }
};
struct Triangle : Shape {
float base, height;
void accept(ShapeVisitor& v) const override { v.visit(*this); }
};
struct AreaVisitor : ShapeVisitor {
double result = 0;
void visit(const Circle& c) override { result = M_PI * c.radius * c.radius; }
void visit(const Rectangle& r) override { result = double(r.w) * r.h; }
void visit(const Triangle& t) override { result = 0.5 * t.base * t.height; }
};
// Usage
std::vector<std::unique_ptr<Shape>> shapes; // C++11 unique_ptr
shapes.push_back(std::make_unique<Circle>(5.f)); // C++14 make_unique
shapes.push_back(std::make_unique<Rectangle>(Rectangle{3.f, 4.f}));
AreaVisitor av;
for (const auto& s : shapes) {
s->accept(av);
std::cout << av.result << '\n';
}Adding PerimeterVisitor requires no changes to any shape class. Adding an Ellipse type requires adding visit(const Ellipse&) to the ShapeVisitor base and to every concrete visitor — a recompile-the-world change.
Modern: std::variant + std::visit (C++17)
std::variant is a discriminated union that always holds exactly one of its listed alternatives — it is never empty and never null. std::visit applies a callable exhaustively, and the compiler rejects the program if any alternative is unhandled.
#include <variant>
struct Circle { float radius; };
struct Rectangle { float w, h; };
struct Triangle { float base, height; };
using Shape = std::variant<Circle, Rectangle, Triangle>; // C++17
// The overload helper composes lambdas into a single callable object
template<typename... Ts>
struct overload : Ts... { using Ts::operator()...; };
// C++17 requires this explicit deduction guide:
template<typename... Ts> overload(Ts...) -> overload<Ts...>;
// C++20 aggregate CTAD makes the deduction guide unnecessary
auto area(const Shape& s) -> double {
return std::visit(overload{
[](const Circle& c) { return M_PI * c.radius * c.radius; },
[](const Rectangle& r) { return double(r.w) * r.h; },
[](const Triangle& t) { return 0.5 * t.base * t.height; },
}, s);
}The exhaustiveness guarantee is the primary advantage over virtual dispatch: miss a case and the program does not compile.
A generic lambda arm (auto&&) acts as a catch-all. Use it intentionally for "ignore everything else" semantics; never use it as a shortcut that silently swallows unhandled types.
Multi-Variant Dispatch (C++17)
std::visit accepts multiple variants. The visitor receives one argument per variant; the total invocation count is the Cartesian product of all alternatives:
using Paint = std::variant<std::monostate, int>; // no fill, or RGB value
void render(const Shape& shape, const Paint& paint) {
std::visit(overload{
[](const Circle& c, std::monostate) { /* outline */ },
[](const Circle& c, int rgb) { /* filled */ },
[](const Rectangle& r, std::monostate) { /* outline */ },
[](const Rectangle& r, int rgb) { /* filled */ },
[](const Triangle& t, std::monostate) { /* outline */ },
[](const Triangle& t, int rgb) { /* filled */ },
}, shape, paint);
}The case count grows as M×N. Use multi-variant dispatch when you genuinely need two-dimensional dispatch; do not use it as a substitute for a single visitor that holds state.
Stateful Named Visitor
When a visitor carries mutable context or exceeds a handful of cases, a named struct with operator() overloads is more maintainable than a lambda pack:
struct Serialiser {
std::ostream& out;
int indent = 0;
void operator()(const Circle& c) const {
out << std::string(indent, ' ') << "circle r=" << c.radius << '\n';
}
void operator()(const Rectangle& r) const {
out << std::string(indent, ' ') << "rect " << r.w << 'x' << r.h << '\n';
}
void operator()(const Triangle& t) const {
out << std::string(indent, ' ') << "tri base=" << t.base << '\n';
}
};
for (const Shape& s : shapes)
std::visit(Serialiser{std::cout, 4}, s);Recursive Visitor: Expression Trees
Visitors compose naturally with recursive data structures. std::unique_ptr breaks the infinite-size cycle while preserving ownership:
struct Num;
struct Add;
struct Mul;
using Expr = std::variant<
Num,
std::unique_ptr<Add>,
std::unique_ptr<Mul>
>;
struct Num { double value; };
struct Add { Expr lhs, rhs; };
struct Mul { Expr lhs, rhs; };
double eval(const Expr& e) {
return std::visit(overload{
[](const Num& n) { return n.value; },
[](const std::unique_ptr<Add>& node) { return eval(node->lhs) + eval(node->rhs); },
[](const std::unique_ptr<Mul>& node) { return eval(node->lhs) * eval(node->rhs); },
}, e);
}
// (2 + 3) * 4 → 20
Expr tree = std::make_unique<Mul>(Mul{
std::make_unique<Add>(Add{Num{2}, Num{3}}),
Num{4}
});
// eval(tree) == 20.0unique_ptr wrapping means Expr is move-only. Replace with shared_ptr for copyable trees, or use a flat arena allocator (a std::deque<std::variant<...>> with index handles) to avoid per-node heap allocation entirely.
Acyclic Visitor (C++98)
Classic visitor creates a hard cycle: every visitor header must include every visited type. The acyclic variant breaks this using dynamic_cast, at the cost of exhaustiveness:
struct BaseVisitor { virtual ~BaseVisitor() = default; };
template<typename T>
struct VisitorFor { virtual void visit(T&) = 0; };
struct Shape {
virtual void accept(BaseVisitor&) = 0;
virtual ~Shape() = default;
};
struct Circle : Shape {
void accept(BaseVisitor& v) override {
if (auto* typed = dynamic_cast<VisitorFor<Circle>*>(&v))
typed->visit(*this);
// Unknown visitor types: silently ignored — this is the intended contract
}
};
// Handles only Circle; Rectangle and Triangle are silently skipped
struct AuditVisitor : BaseVisitor, VisitorFor<Circle> {
void visit(Circle& c) override { /* ... */ }
};This pattern is appropriate for plugin architectures where visitors and types ship in separate modules and neither can be recompiled when the other changes. The dynamic_cast overhead is real; benchmark before using it on hot paths.
Comparison
| Classic virtual | std::variant | Acyclic | |
|---|---|---|---|
| New types | Open (add subclass) | Closed (edit variant) | Open |
| New operations | Closed (edit base) | Open (new callable) | Partial |
| Type safety | Runtime | Compile-time | Runtime (dynamic_cast) |
| Heap requirement | Required | Optional | Required |
| Dispatch cost | vtable | Jump table | vtable + dynamic_cast |
| Unhandled case | Compile error (pure) | Compile error | Silent skip |
Best Practices
- Prefer
std::variant+std::visitwhen the type set is fixed at compile time. The exhaustiveness guarantee eliminates a whole class of runtime bugs. - Use the
overloadhelper for inline visitors with a small number of arms. Use a named struct when the visitor carries state or when maintainability matters. - Prefer returning values from visitor arms over mutating external state. Functional-style visitors compose cleanly and avoid partial-update bugs if an exception is thrown mid-traversal.
- For recursive trees, size the
unique_ptrwrappers only around recursive members, not all nodes, to minimise heap traffic. - In classic visitor, make all
visit()overloads in the base pure (= 0). A non-pure base allows accidental silent omissions in derived visitors.
Common Pitfalls
Missing deduction guide in C++17. The overload aggregate requires an explicit CTAD deduction guide in C++17. C++20 makes this unnecessary, but code targeting C++17 will fail to compile without it.
Mismatched return types across arms. All arms of std::visit must return a common type. int and double do not unify silently — one must cast, or the callable must declare an explicit return type:
// Won't compile: int vs double
std::visit(overload{
[](const Circle&) -> double { return 1; }, // explicit return type
[](const Rectangle&) -> double { return 1.0; },
[](const Triangle&) -> double { return 1; },
}, shape);Mutating a variant during visitation. Assigning to a std::variant while std::visit holds a reference into it is undefined behaviour. Copy the new value out and assign after the visit completes.
sizeof(variant) surprise. std::variant<A, B, C> is sizeof(max(A,B,C)) + discriminant. If alternatives have wildly different sizes, store them as std::unique_ptr<T> within the variant to flatten the footprint.
Silent classic visitor base. Omitting = 0 from ShapeVisitor::visit(const Circle&) compiles but leaves a do-nothing base implementation. Any subclass that forgets the override silently does nothing instead of failing to compile.
std::visit with std::variant
std::visit is the canonical dispatch mechanism for std::variant and should be preferred over std::get with index checks. It accepts any callable whose call operator covers every alternative in the variant — the compiler enforces exhaustiveness statically.
// C++17
#include <variant>
#include <string>
using Token = std::variant<int, double, std::string>;
void print_token(const Token& tok) {
std::visit([](const auto& val) {
std::cout << val << '\n';
}, tok);
}
// With explicit per-type handling using the overload helper
void describe_token(const Token& tok) {
std::visit(overload{
[](int v) { std::cout << "integer: " << v << '\n'; },
[](double v) { std::cout << "float: " << v << '\n'; },
[](const std::string& v){ std::cout << "string: " << v << '\n'; },
}, tok);
}std::visit dispatches through a compiler-generated jump table indexed by the variant's discriminant — no dynamic_cast, no manual if/else chains.
Overload Set Visitor (C++17)
The overload helper collapses a pack of lambdas into a single callable by inheriting all of their operator() overloads. It is a standard idiom that ships in most modern C++ codebases and is proposed for the standard library.
// C++17
template<typename... Ts>
struct overload : Ts... { using Ts::operator()...; };
template<typename... Ts> overload(Ts...) -> overload<Ts...>; // deduction guide
// C++20 — deduction guide is implicit for aggregates; the template above compiles unchanged// C++17 — inline overload-set visitor, no named type required
using Value = std::variant<int, float, bool>;
std::string to_string(const Value& v) {
return std::visit(overload{
[](int i) { return std::to_string(i); },
[](float f) { return std::to_string(f); },
[](bool b) { return std::string(b ? "true" : "false"); },
}, v);
}Avoid making the overload set's individual lambdas generic (auto&&) as a workaround for incomplete coverage — the compiler can no longer detect missing cases, defeating the exhaustiveness guarantee.
Returning Values from Visitors
All arms of a std::visit call must produce a common return type; the compiler deduces it via std::common_type and rejects the program if no common type exists. Declare an explicit return type on each arm to make intent clear and avoid surprising implicit conversions.
// C++17
using Number = std::variant<int, long, double>;
// All arms must return the same type — declare explicitly to be safe
double as_double(const Number& n) {
return std::visit(overload{
[](int v) -> double { return v; },
[](long v) -> double { return v; },
[](double v) -> double { return v; },
}, n);
}
// Returning std::string from every arm — mixed types require std::string wrapping
std::string label(const Number& n) {
return std::visit(overload{
[](int v) { return std::string{"int:"} + std::to_string(v); },
[](long v) { return std::string{"long:"} + std::to_string(v); },
[](double v) { return std::string{"dbl:"} + std::to_string(v); },
}, n);
}For operations that produce no value (side-effectful traversal), all arms must return void. Mixing void and non-void arms is a compile error.
Recursive Variant Types
Recursive variants require an indirection layer because std::variant<A, A*> cannot contain an incomplete type directly. The canonical solution wraps recursive members in std::unique_ptr; an arena-based index approach avoids per-node heap allocation.
// C++17 — recursive JSON-like value type
struct JsonNull {};
struct JsonBool { bool value; };
struct JsonNumber { double value; };
struct JsonString { std::string value; };
struct JsonArray; // forward declaration
struct JsonObject; // forward declaration
using JsonValue = std::variant<
JsonNull,
JsonBool,
JsonNumber,
JsonString,
std::unique_ptr<JsonArray>, // indirection breaks size cycle
std::unique_ptr<JsonObject>
>;
struct JsonArray { std::vector<JsonValue> elements; };
struct JsonObject { std::vector<std::pair<std::string, JsonValue>> members; };
// C++17 — recursive visitor using overload
std::string json_to_string(const JsonValue& v) {
return std::visit(overload{
[](const JsonNull&) { return std::string{"null"}; },
[](const JsonBool& b) { return std::string{b.value ? "true" : "false"}; },
[](const JsonNumber& n) { return std::to_string(n.value); },
[](const JsonString& s) { return '"' + s.value + '"'; },
[](const std::unique_ptr<JsonArray>& a) {
std::string out = "[";
for (const auto& el : a->elements)
out += json_to_string(el) + ',';
if (out.back() == ',') out.pop_back();
return out + ']';
},
[](const std::unique_ptr<JsonObject>&) { return std::string{"{}"}; },
}, v);
}unique_ptr-wrapped nodes make JsonValue move-only. Use shared_ptr if you need shared ownership, or a std::deque-backed arena with integer handles if you need value semantics without heap overhead per node.
Stateful Visitors
A visitor struct that carries mutable member data is the right model for accumulating results, threading context through a traversal, or enforcing ordering constraints. Lambda captures work for simple state but become unwieldy beyond one or two captured variables.
// C++17 — stateful visitor accumulating statistics
struct StatsVisitor {
int circle_count = 0;
int rectangle_count = 0;
double total_area = 0.0;
void operator()(const Circle& c) {
++circle_count;
total_area += M_PI * c.radius * c.radius;
}
void operator()(const Rectangle& r) {
++rectangle_count;
total_area += double(r.w) * r.h;
}
void operator()(const Triangle& t) {
total_area += 0.5 * t.base * t.height;
}
};
std::vector<Shape> shapes = { Circle{1.f}, Rectangle{2.f, 3.f}, Circle{4.f} };
StatsVisitor stats;
for (const Shape& s : shapes)
std::visit(stats, s);
std::cout << "circles=" << stats.circle_count
<< " total_area=" << stats.total_area << '\n';Pass a stateful visitor by reference to std::visit when you need accumulated state across multiple calls; std::visit takes the visitor by forwarding reference so no unnecessary copies are made.
Visitor for AST Traversal
Compilers, interpreters, and query engines use visitor over an AST to separate parsing from semantic analysis, optimisation, and code generation — each pass is an independent visitor with no modifications to the node types. The pattern scales from toy expression evaluators to production compiler front-ends.
// C++17 — three-pass visitor pipeline over an expression AST
struct Literal { double value; };
struct BinOp {
char op; // '+', '-', '*', '/'
std::unique_ptr<struct Expr> lhs, rhs;
};
struct UnaryOp {
char op; // '-', '!'
std::unique_ptr<struct Expr> operand;
};
struct Expr : std::variant<Literal, std::unique_ptr<BinOp>, std::unique_ptr<UnaryOp>> {
using variant::variant;
};
// Pass 1 — evaluate
struct EvalVisitor {
double operator()(const Literal& l) const { return l.value; }
double operator()(const std::unique_ptr<BinOp>& b) const {
double lv = std::visit(*this, *b->lhs);
double rv = std::visit(*this, *b->rhs);
switch (b->op) {
case '+': return lv + rv;
case '-': return lv - rv;
case '*': return lv * rv;
case '/': return lv / rv;
}
return 0;
}
double operator()(const std::unique_ptr<UnaryOp>& u) const {
double v = std::visit(*this, *u->operand);
return u->op == '-' ? -v : v;
}
};
// Pass 2 — pretty-print (different visitor, same nodes, zero changes to node types)
struct PrintVisitor {
std::ostream& out;
void operator()(const Literal& l) const { out << l.value; }
void operator()(const std::unique_ptr<BinOp>& b) const {
out << '(';
std::visit(*this, *b->lhs);
out << ' ' << b->op << ' ';
std::visit(*this, *b->rhs);
out << ')';
}
void operator()(const std::unique_ptr<UnaryOp>& u) const {
out << u->op;
std::visit(*this, *u->operand);
}
};Each pass is independently testable and replaceable. Add an optimisation pass (constant folding, dead-branch elimination) by writing a new visitor struct — no existing node or visitor needs modification.
Visitor vs std::visit Performance
The classic vtable path and the std::variant jump-table path have different performance profiles; neither is universally faster, and the right choice depends on access pattern and branch predictability. Benchmark with representative data before optimising.
| Aspect | Classic virtual dispatch | std::visit jump table |
|---|---|---|
| Dispatch mechanism | Two vtable indirections | One table lookup + indirect call |
| Branch predictor | Monomorphic call sites predict well | Jump table entries predict per-type |
| Cache behaviour | vtable pointer is in the object | Discriminant is adjacent to data |
| Inlining | Never inlined | Can be inlined by optimiser |
| Missed case | Pure virtual = abort | Compile error |
// C++17 — measuring visit overhead with a trivial benchmark scaffold
#include <variant>
#include <chrono>
#include <vector>
struct A { int x; };
struct B { int x; };
struct C { int x; };
using ABC = std::variant<A, B, C>;
int sum_visit(const std::vector<ABC>& v) {
int total = 0;
for (const auto& item : v)
std::visit([&total](const auto& x){ total += x.x; }, item);
return total;
}For hot paths with many small variants, consider packing variants into a contiguous std::vector (struct-of-arrays layout) and switching once per chunk rather than once per element. In practice, std::visit with a small alternative count (≤ 4) often compiles to a branchless sequence; larger variant lists may benefit from a manual sorted-frequency ordering of alternatives so the most common types occupy the lowest discriminant indices.
See Also
std::variant— the discriminated union underlying modern visitorstd::visit— multi-variant dispatch utility- Type Erasure — runtime polymorphism without inheritance
- CRTP — static polymorphism that eliminates vtable dispatch entirely
- Policy-Based Design — compile-time open/closed extension alternative