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::variantlive 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, oraccept()boilerplate. - Compile-Time Exhaustiveness: If a new type is added
to the
std::variantand an existing visitor does not supply a viable call operator, the compiler issues an error immediately. - Performance: Compilers can often inline calls
through
std::visitor optimize them via jump tables, bypassing the overhead of traditional virtual method tables (vtables).