Implementing the Visitor Pattern with std::visit in C++

This article explores how modern C++ (C++17 and later) implements the Gang of Four Visitor design pattern without the need for complex object-oriented inheritance hierarchies. By pairing std::variant with std::visit, developers can achieve type-safe, compile-time double dispatch using value semantics. Below, you will learn the core mechanics of this modern approach, examine a full implementation using the popular overloaded pattern, and understand the performance and design advantages it offers over traditional object-oriented techniques.

The Shift from OOP to Value-Based Polymorphism

The classic Visitor pattern enables developers to define new operations on a set of types without modifying the classes themselves. Historically, C++ achieved this through virtual functions, an abstract base class, and double dispatch (an accept method in the element and a visit method in the visitor).

Modern C++ replaces class hierarchies with std::variant, which represents a type-safe union (a sum type), and std::visit, which applies a callable object (the visitor) to the element currently held by the variant.

The overloaded Pattern

To handle multiple types neatly, C++ developers commonly use an "overloaded" helper struct that inherits from multiple lambdas and brings their operator() into a single scope using parameter packs and deduction guides:

template<class... Ts> 
struct overloaded : Ts... { 
    using Ts::operator()...; 
};

// Explicit deduction guide (optional in C++20, required in C++17)
template<class... Ts> 
overloaded(Ts...) -> overloaded<Ts...>;

Complete Implementation Example

Consider an application that renders and calculates the area of different geometric shapes:

#include <iostream>
#include <variant>
#include <vector>

// 1. Define the types (no shared base class required)
struct Circle {
    double radius;
};

struct Rectangle {
    double width;
    double height;
};

// 2. Define the variant type
using Shape = std::variant<Circle, Rectangle>;

// Helper for visiting
template<class... Ts>
struct overloaded : Ts... {
    using Ts::operator()...;
};
template<class... Ts>
overloaded(Ts...) -> overloaded<Ts...>;

int main() {
    // A collection of shapes
    std::vector<Shape> shapes = {
        Circle{5.0},
        Rectangle{4.0, 6.0}
    };

    // 3. Define the visitor via lambdas
    auto areaVisitor = overloaded {
        [](const Circle& c) { return 3.14159 * c.radius * c.radius; },
        [](const Rectangle& r) { return r.width * r.height; }
    };

    // 4. Apply the visitor using std::visit
    for (const auto& shape : shapes) {
        double area = std::visit(areaVisitor, shape);
        std::cout << "Calculated area: " << area << '\n';
    }

    // Inline visitation for printing
    for (const auto& shape : shapes) {
        std::visit(overloaded {
            [](const Circle& c) { 
                std::cout << "Circle with radius " << c.radius << '\n'; 
            },
            [](const Rectangle& r) { 
                std::cout << "Rectangle " << r.width << "x" << r.height << '\n'; 
            }
        }, shape);
    }

    return 0;
}

Key Advantages of std::visit

  • No Dynamic Allocation: Types stored in std::variant live on the stack or inline within containing structures. Unlike base-pointer collections (std::vector<std::unique_ptr<Base>>), this avoids heap fragmentation and improves cache locality.
  • Non-Intrusive: The visited structs (Circle, Rectangle) do not require base classes, virtual destructors, or accept() boilerplate.
  • Compile-Time Exhaustiveness: If a new type is added to the std::variant and an existing visitor does not supply a viable call operator, the compiler issues an error immediately.
  • Performance: Compilers can often inline calls through std::visit or optimize them via jump tables, bypassing the overhead of traditional virtual method tables (vtables).