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:

  1. Locality: one visitor class holds all the code for one operation instead of spreading it across the data classes.

  2. 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 class That is the Open/Closed Principle (OCP) doing real work. Extend the code with new visitors; the data classes do not change.

  3. 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

  4. 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

Mermaid diagram
Class diagram of the Visitor pattern

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

Mermaid diagram

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 XmlVisitor accumulates a string buffer.
  • The element set is stable. Adding an element type forces every visitor to add a visit overload.

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 to overloaded
  • using Ts::operator()... (a C++17 feature) brings all operator() 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: auto parameters are template parameters, so the lambda becomes a template member function
  • Overload resolution: lambdas with specific types (like float, float) are more specialized than auto, auto, so they win
  • Fallback: if no specific overload matches, the auto version 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

  1. Multi-argument std::visit passes several variants; the visitor receives one alternative from each
  2. The overloaded pattern merges several lambdas into one callable with all overloads visible
  3. The auto catch-all provides a fallback for unhandled type combinations, turning compile-time failures into runtime errors
  4. Specialization order matters. Exact-type overloads are tried before the generic auto one.

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::visit cuts the boilerplate and keeps elements on the stack when the types are known at compile time.
  • Multi-argument std::visit with overloaded handles 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

Implementations in different languages