The Visitor pattern adds new operations to a set of object types without changing the types themselves. It comes from the GoF book and is the usual answer when you need type-specific logic across a heterogeneous collection.
Motivation
Virtual methods
Object-oriented languages usually add behavior through virtual methods. Each class holds both data and the operations on that data. That arrangement runs into trouble in two situations.
Fixed operations, new classes
You have a fixed set of operations and expect to add new subclasses:
Current situation:
- You have a shape hierarchy: Shape, Circle, Rectangle, Triangle
- Operations like draw() are virtual methods in the base class
- Each subclass implements them
When you add a new subclass (for example Pentagon):
- You implement the virtual method in the new class
- Existing code keeps working
When you add a new operation (for example calculateArea()):
- You add the virtual method to the base class
- You implement it in every subclass, even ones that don't need it
- You end up editing every class in the hierarchy
Fixed classes, many operations
You have a fixed set of classes and need many different operations:
Current situation: - Your class structure is stable (geometric shapes, AST nodes) - You implement operations using virtual methods
The problem: Every new operation means editing the base class and implementing the method in every subclass. The classes fill up with method bodies that have little to do with their data, and adding an operation stays tied to the class definitions.
Give a shape class draw(), serialize(), calculateArea(), calculatePerimeter(), export(), and validate(), and it carries responsibilities that have nothing to do with each other.
Reuse when data carries behavior
Suppose you want to reuse these classes in another project:
With virtual methods:
- Your element classes have methods for rendering (draw()), XML serialization (toXML()), and analysis (calculateArea())
- You move the code to the new project
- The project only needs the structures for mathematical calculations, not rendering or serialization
- You are forced to carry all the unused methods and their dependencies
- The classes carry behavior the new project never calls
- If rendering uses a graphics library the new project lacks, you have a dependency problem
That violates the Single Responsibility Principle. The classes answer for more than their own data.
Separating behavior from data
Visitor solves this by taking operations out of the data structures.
Instead of adding methods to the class, you:
1. Keep the data structure simple: data and little else.
2. Put each operation in a separate visitor class.
3. Pass the visitor to the object through an accept() method.
4. Let each visitor decide how to handle each type.
Advantages:
-
Locality: one visitor class holds all the code for one operation instead of spreading it across the data classes.
-
New operations are cheap: write a new visitor class and leave the data classes alone.
new DrawingVisitor() // New operation, new class new SerializationVisitor() // Another operation, another class new ValidationVisitor() // Yet another operation, yet another classThat is the Open/Closed Principle (OCP) doing real work. Extend the code with new visitors; the data classes do not change. -
Reuse without baggage: the data structures stay small. Move them to another project and they carry no operations or dependencies you did not ask for. You include only the visitors you need:
Project A: Element + DrawingVisitor + SerializationVisitor Project B: Element + ValidationVisitor -
One reason to change: a visitor changes when its operation changes, a data class when its structure changes.
So adding an operation costs one new class, not an edit in every data class.
The problem
You have a hierarchy of classes (AST nodes, UI widgets, geometry shapes) and need to add operations like serialization, code generation, or hit-testing. Each new virtual method means editing every class, which is exactly what the Open/Closed principle tells you to avoid. Visitor moves the operation outside the classes.
The structure
The players
| Role | Responsibility |
|---|---|
| Visitor | Interface declaring visit overloads for each concrete element type |
| ConcreteVisitor | Implements one algorithm across all element types |
| Element | Interface declaring accept(Visitor&) |
| ConcreteElement | Implements accept by calling the matching visit overload on the visitor, passing *this |
Classic double dispatch
C++ virtual functions are single dispatch. The call target depends on the dynamic type of this, not on the argument types. The accept() trick adds double dispatch: the element's dynamic type picks the accept override, and that override picks the right visit overload from the visitor's dynamic type.
The code
#include <iostream>
#include <vector>
#include <memory>
// ── Forward declarations ──────────────────────────────────
class ElementA;
class ElementB;
// ── Visitor interface ─────────────────────────────────────
class Visitor {
public:
virtual ~Visitor() = default;
virtual void visit(ElementA&) = 0;
virtual void visit(ElementB&) = 0;
};
// ── Element interface ─────────────────────────────────────
class Element {
public:
virtual ~Element() = default;
virtual void accept(Visitor&) = 0;
};
// ── Concrete elements ─────────────────────────────────────
class ElementA : public Element {
public:
void accept(Visitor& v) override {
v.visit(*this); // double dispatch
}
std::string kind() const { return "ElementA"; }
};
class ElementB : public Element {
public:
void accept(Visitor& v) override {
v.visit(*this);
}
std::string kind() const { return "ElementB"; }
};
// ── Concrete visitors ─────────────────────────────────────
class XmlVisitor : public Visitor {
public:
void visit(ElementA& e) override {
std::cout << "<element type=\"" << e.kind() << "\"/>\n";
}
void visit(ElementB& e) override {
std::cout << "<element type=\"" << e.kind() << "\"/>\n";
}
};
class JsonVisitor : public Visitor {
public:
void visit(ElementA& e) override {
std::cout << "{ \"kind\": \"" << e.kind() << "\" }\n";
}
void visit(ElementB& e) override {
std::cout << "{ \"kind\": \"" << e.kind() << "\" }\n";
}
};
int main() {
std::vector<std::unique_ptr<Element>> elements;
elements.push_back(std::make_unique<ElementA>());
elements.push_back(std::make_unique<ElementB>());
XmlVisitor xml;
JsonVisitor json;
for (auto& e : elements) {
e->accept(xml);
e->accept(json);
}
}
How double dispatch works
The dynamic type of e resolves accept. The dynamic type of xml, known inside ElementA::accept, resolves visit. Two virtual dispatch steps, one function call. That is double dispatch.
When the classic form fits
- You own the element hierarchy and can add
accept. - You need stateful visitors, where
XmlVisitoraccumulates a string buffer. - The element set is stable. Adding an element type forces every visitor to add a
visitoverload.
A modern alternative: std::visit
C++17 added std::visit, which works with std::variant, a type-safe union. Elements become variant alternatives instead of a polymorphic hierarchy, and visitors become function objects with an operator() for each alternative.
The code
#include <iostream>
#include <vector>
#include <variant>
// ── Element types (no base class needed) ──────────────────
struct ElementA {
std::string kind() const { return "ElementA"; }
};
struct ElementB {
std::string kind() const { return "ElementB"; }
};
// ── The variant ───────────────────────────────────────────
using Element = std::variant<ElementA, ElementB>;
// ── Visitors (plain callables) ────────────────────────────
struct XmlVisitor {
void operator()(ElementA& e) const {
std::cout << "<element type=\"" << e.kind() << "\"/>\n";
}
void operator()(ElementB& e) const {
std::cout << "<element type=\"" << e.kind() << "\"/>\n";
}
};
struct JsonVisitor {
void operator()(ElementA& e) const {
std::cout << "{ \"kind\": \"" << e.kind() << "\" }\n";
}
void operator()(ElementB& e) const {
std::cout << "{ \"kind\": \"" << e.kind() << "\" }\n";
}
};
int main() {
std::vector<Element> elements;
elements.push_back(ElementA{});
elements.push_back(ElementB{});
for (auto& e : elements) {
std::visit(XmlVisitor{}, e);
std::visit(JsonVisitor{}, e);
}
}
Advantages of std::visit
| Aspect | Classic (accept/visit) | std::visit |
|---|---|---|
| Boilerplate | Base class, accept override per element |
None. Plain structs only |
| Memory | Heap (pointers) | Stack (value semantics) |
| Speed | Two indirect calls | Jump table, usually faster |
| Extensibility | Hard to add new element types | Same. Every visitor must handle every alternative |
| Stateful visitors | Easy (member variables) | Easy (capture or functor with members) |
The catch
A std::variant holds one type at a time, and the set of types is fixed at compile time. Adding an element type means editing the variant definition and every visit call. Classic Visitor has the same limitation, but polymorphism leaves you the option of a new subclass plus a dynamic_cast fallback. Ugly, but possible. With std::variant it is all or nothing at compile time.
Multi-argument std::visit and the overloaded pattern
std::visit can take several variants at once, which suits operations that combine two or more variant types. Binary operators are the usual example, because each type combination may need its own implementation.
Type-safe binary operations
Suppose you want to multiply values that can be float, int, or std::string. Only some combinations are valid:
- float × float → float
- int × int → int
- std::string × int → std::string (repeat the string)
- int × std::string → std::string (repeat the string)
- Invalid combinations (e.g., float × string, string × string) should be caught at compile time
Writing a separate operator* overload for each pair is tedious and easy to get wrong. The overloaded pattern with std::visit handles it in one place.
The overloaded pattern
The overloaded pattern is a CRTP-based trick that merges several lambda signatures into one functor. It inherits from every lambda type and pulls all their operator() overloads into scope with using declarations:
#include <iostream>
#include <variant>
#include <string>
#include <stdexcept>
// ── The Value type ───────────────────────────────────────
using Value = std::variant<float, int, std::string>;
// ── The overloaded pattern ───────────────────────────────
// This template merges multiple callable types (typically lambdas)
// into a single functor with all their operator() overloads visible.
template<class... Ts> struct overloaded : Ts... {
using Ts::operator()...; // C++17: inherit operator() from all Ts
};
// Deduction guide (optional in C++20, helpful in C++17)
template<class... Ts> overloaded(Ts...) -> overloaded<Ts...>;
// ── Multiply function ────────────────────────────────────
Value multiply(const Value& lhs, const Value& rhs) {
return std::visit(overloaded{
// float × float → float
[](float a, float b) { return Value(a * b); },
// int × int → int
[](int a, int b) { return Value(a * b); },
// string × int → string (repeat)
[](const std::string& s, int n) {
std::string result;
for (int i = 0; i < n; ++i) result += s;
return Value(result);
},
// int × string → string (repeat)
[](int n, const std::string& s) {
std::string result;
for (int i = 0; i < n; ++i) result += s;
return Value(result);
},
// Catch-all for invalid combinations
[](const auto&, const auto&) -> Value {
throw std::invalid_argument(
"Unsupported multiplication between these types"
);
}
}, lhs, rhs);
}
int main() {
// Valid operations
std::cout << std::get<float>(multiply(2.5f, 3.0f)) << '\n'; // 7.5
std::cout << std::get<int>(multiply(4, 5)) << '\n'; // 20
std::cout << std::get<std::string>(multiply("Hi", 3)) << '\n'; // HiHiHi
std::cout << std::get<std::string>(multiply(2, "xy")) << '\n'; // xyxy
// Invalid operation (throws)
try {
multiply(2.5f, "text");
} catch (const std::invalid_argument& e) {
std::cout << "Error: " << e.what() << '\n';
}
}
Output:
7.5
20
HiHiHi
xyxy
Error: Unsupported multiplication between these types
How the overloaded pattern works
1. Template definition:
template<class... Ts> struct overloaded : Ts... {
using Ts::operator()...;
};
struct overloaded : Ts...inherits from every type passed tooverloadedusing Ts::operator()...(a C++17 feature) brings alloperator()overloads from the base classes into scope
2. Deduction guide:
template<class... Ts> overloaded(Ts...) -> overloaded<Ts...>;
The compiler then deduces the template arguments from the lambdas passed to the constructor. Without the guide you would write overloaded<decltype(lambda1), decltype(lambda2)>{lambda1, lambda2} explicitly.
3. Creating the functor:
overloaded{
[](float a, float b) { return ...; },
[](int a, int b) { return ...; },
[](const std::string& s, int n) { return ...; },
// ...
}
The compiler deduces the lambda types and creates a single functor with all operator() overloads.
Using auto for the catch-all
The last lambda in the example uses auto:
[](const auto&, const auto&) -> Value {
throw std::invalid_argument(...);
}
This catch-all matches any pair of types the earlier overloads do not handle. Why it works:
- Type deduction:
autoparameters are template parameters, so the lambda becomes a template member function - Overload resolution: lambdas with specific types (like
float, float) are more specialized thanauto, auto, so they win - Fallback: if no specific overload matches, the
autoversion runs - Error reporting: with only specific overloads the mismatch fails at compile time; here you get a runtime exception
Without the auto catch-all:
// This would fail to compile if lhs and rhs don't match one of the overloads
std::visit(overloaded{
[](float, float) { ... },
[](int, int) { ... },
}, lhs, rhs); // Error: no matching overload if lhs=float, rhs=string
Pros and cons of auto catch-all:
| Aspect | Benefit | Drawback |
|---|---|---|
| Debugging | Clear error message at runtime | Error is late, harder to debug type mismatches |
| Flexibility | Handles unexpected combinations gracefully | May mask logic bugs |
| Type safety | All combination attempts compile | None |
Key points
- Multi-argument
std::visitpasses several variants; the visitor receives one alternative from each - The overloaded pattern merges several lambdas into one callable with all overloads visible
- The
autocatch-all provides a fallback for unhandled type combinations, turning compile-time failures into runtime errors - Specialization order matters. Exact-type overloads are tried before the generic
autoone.
Typical uses: - Binary operators (arithmetic, comparison) - Type-safe serialization/deserialization - Expression evaluation in interpreters - Multi-variant algorithms that depend on the dynamic types of multiple values
Pattern matching
Python 3.10+ supports pattern matching, as do some other modern languages. Instead of accept/visit or variant dispatch, you inspect an object and branch on its type and fields.
It removes the accept method and lets the language discriminate on type. It can also match on structure, which is often the more useful part.
Note: C++ has no pattern matching. The Python implementation shows how it simplifies Visitor, but C++ code still uses classic accept/visit or std::variant.
The Visitor pattern in Python page has a complete example that combines pattern matching with decorators.
Summary
- Visitor separates operations from object structure.
- Classic accept/visit works with polymorphic hierarchies and supports stateful algorithms.
std::visitcuts the boilerplate and keeps elements on the stack when the types are known at compile time.- Multi-argument
std::visitwithoverloadedhandles operations over several variants, with compile-time checking and a runtime fallback where you want one. - Pattern matching (Python 3.10+) gives a shorter alternative that can match on type and structure.
- Every form shares the same trade-off: easy to add new operations, hard to add new element types (the expression problem).
Further reading
- Design Patterns: Elements of Reusable Object-Oriented Software (GoF)
- Boost.Variant, the precursor to
std::variant - 《Patterns for Fault Tolerant Software》 by Robert Hanmer, on Visitor in safety-critical systems