Visitor Pattern
- Description: Separate an algorithm from the object structure it operates on by double dispatch — each element accepts a visitor and calls back the visit method matching its concrete type; lets you add operations without modifying the elements.
- My Notion Note ID: K2C-2-22
- Created: 2026-05-22
- Updated: 2026-05-22
- License: Reuse is very welcome. Please credit Yu Zhang and link back to the original on yuzhang.io
Table of Contents
- 1. Why It Exists
- 2. Structure
- 3. Classic C++ Example
- 4. Modern
std::variant/std::visit - 5. When to Use / When Not To
- 6. Variants and Pitfalls
- 7. Related Patterns
- 8. References
1. Why It Exists
- OO single-dispatch resolves only on the receiver's runtime type.
node->print()works;print(node)for an arbitraryNode*doesn't pick a derived overload. - Visitor encodes double dispatch in two single-dispatch hops:
node->accept(visitor)(dispatches on node type) →visitor->visit(SpecificNode&)(dispatches on visitor's overload set). - Decision axis: adding new operations vs adding new node types.
- Inheritance with virtual methods on the node: easy to add nodes, hard to add operations (touch every node).
- Visitor: easy to add operations (new visitor class), hard to add nodes (touch every visitor).
- Used heavily in compilers / AST processing, IR transforms, document tree analysis, geometry-kernel queries.
2. Structure
- Visitor (interface) — one
visit(ConcreteElementX&)overload per concrete element type. - ConcreteVisitor — implements visit overloads for one operation (print, eval, type-check, ...).
- Element (interface) — declares
accept(Visitor&). - ConcreteElement — implements
acceptasv.visit(*this);(the only place where its concrete type is known statically). - ObjectStructure — composite of elements; iterates and calls
accepton each.
3. Classic C++ Example
AST for a tiny expression language:
#include <memory>
#include <iostream>
#include <vector>
class IntLit;
class Add;
class Neg;
struct Visitor {
virtual ~Visitor() = default;
virtual void visit(IntLit&) = 0;
virtual void visit(Add&) = 0;
virtual void visit(Neg&) = 0;
};
struct Expr {
virtual ~Expr() = default;
virtual void accept(Visitor& v) = 0;
};
struct IntLit : Expr {
int value;
explicit IntLit(int v) : value(v) {}
void accept(Visitor& v) override { v.visit(*this); }
};
struct Add : Expr {
std::unique_ptr<Expr> lhs, rhs;
Add(std::unique_ptr<Expr> l, std::unique_ptr<Expr> r) : lhs(std::move(l)), rhs(std::move(r)) {}
void accept(Visitor& v) override { v.visit(*this); }
};
struct Neg : Expr {
std::unique_ptr<Expr> e;
explicit Neg(std::unique_ptr<Expr> x) : e(std::move(x)) {}
void accept(Visitor& v) override { v.visit(*this); }
};
// Operation 1: pretty-print
struct Printer : Visitor {
void visit(IntLit& n) override { std::cout << n.value; }
void visit(Add& n) override { std::cout << "("; n.lhs->accept(*this); std::cout << " + "; n.rhs->accept(*this); std::cout << ")"; }
void visit(Neg& n) override { std::cout << "-"; n.e->accept(*this); }
};
// Operation 2: eval — would require a separate visitor with a result field
struct Evaluator : Visitor {
int result = 0;
void visit(IntLit& n) override { result = n.value; }
void visit(Add& n) override { n.lhs->accept(*this); int l = result; n.rhs->accept(*this); result = l + result; }
void visit(Neg& n) override { n.e->accept(*this); result = -result; }
};
Adding Mul: edit Visitor base + every concrete visitor. Adding a new operation: write TypeChecker : Visitor — no node edits.
4. Modern std::variant / std::visit
Closed set of node types → std::variant + std::visit is often a cleaner Visitor. No accept boilerplate, no virtual hierarchy, exhaustiveness via if constexpr or overload set:
#include <variant>
#include <memory>
#include <iostream>
struct IntLit;
struct Add;
struct Neg;
using ExprV = std::variant<IntLit, Add, Neg>;
using ExprPtr = std::unique_ptr<ExprV>;
struct IntLit { int value; };
struct Add { ExprPtr lhs, rhs; };
struct Neg { ExprPtr e; };
// Overload-set helper
template <class... Fs> struct overload : Fs... { using Fs::operator()...; };
template <class... Fs> overload(Fs...) -> overload<Fs...>;
int eval(const ExprV& e) {
return std::visit(overload{
[](const IntLit& n) { return n.value; },
[&](const Add& n) { return eval(*n.lhs) + eval(*n.rhs); },
[&](const Neg& n) { return -eval(*n.e); },
}, e);
}
Trade-off:
- Open vs closed —
variantrequires the full type list at compile time. Add a node = touch the variant declaration and every visitor (compiler enforces exhaustiveness, which is a feature). - No
acceptboilerplate — every node automatically participates. - Stack-allocated node objects; smaller for leaf-heavy trees.
- Recursive types need
unique_ptr<variant<...>>indirection (variants must have known size).
For an AST with a fixed grammar, variant is usually the better default in modern C++. Classic Visitor still wins when the node set must be open to clients.
5. When to Use / When Not To
Use when:
- Object structure is stable (rare new node types) but operations grow over time.
- Operations span multiple node types and don't belong on the nodes themselves.
- You need double dispatch and the language doesn't natively support multimethods.
Avoid when:
- Node types are added frequently — every visitor breaks.
- There's only one operation per node — put the method on the node.
- The node set is closed and known at compile time — prefer
std::variant+std::visit.
6. Variants and Pitfalls
- Default
visitfor new nodes — provide a non-pure base with a default (e.g., assert, log, no-op) so adding a node doesn't break every visitor at once. Trade-off: weakens compile-time check. - Returning a value —
visittypically returns void in classic form. Workarounds: visitor holds a result member; template visitor returningR; or usestd::variant/std::visitwhich natively returns the common type. - Acyclic Visitor (Martin) — avoids the cyclic dependency between visitor base and every node by using
dynamic_castinsideaccept. Slower, breaks compile-time exhaustiveness; rarely worth it. - Visitor + const-correctness — separate
VisitorandConstVisitorhierarchies; otherwise one or the other is awkward. - Mutation during traversal — visiting and modifying a tree concurrently is a footgun. Either gather changes and apply after, or define traversal order explicitly.
- Performance — two virtual calls per node (
accept+visit).std::variantcollapses to one switch on the discriminator.
7. Related Patterns
- Modern alternative —
std::variant+std::visit— sum type + structural pattern match; see § 4. For closed AST-style hierarchies in modern C++ this is often the right default, not classic Visitor. - Double dispatch — Visitor is the textbook OO emulation. Languages with multimethods (CLOS, Julia, Dylan) don't need the pattern.
- Iterator — orthogonal; combine to walk a structure and visit each element.
- Composite — Visitor traverses a Composite. The AST in the example IS a Composite; Visitor adds operations without bloating the node interface.
- Interpreter — Visitor is the standard way to add operations (eval, print, type-check) to an Interpreter's AST.
8. References
- Gamma, Helm, Johnson, Vlissides. Design Patterns. Visitor chapter.
- cppreference: std::visit
- Bjarne Stroustrup: A Tour of C++, ch. on variant
- Martin, Acyclic Visitor (PLOPD3, 1998).
- LLVM RTTI / LLVM IR Visitor pattern — production-grade variant-of-visitor.