std::variant (introduced in C++17) is a powerful type‑safe union that can hold one value from a set of specified types. When combined with a visitor pattern, it becomes easy to write code that operates differently based on the actual type stored in the variant, without resorting to manual type checks or dynamic_cast. This article shows how to write a generic visitor helper and demonstrates its use with a simple shape hierarchy.
1. The Problem
Suppose you have a set of geometric shapes:
struct Circle { double radius; };
struct Rectangle{ double width, height; };
struct Triangle { double base, height; };
You want a function that can print the area of any shape, but you only want to write the logic once for each concrete type. A naive solution uses polymorphism:
class Shape { virtual double area() const = 0; /* ... */ };
However, polymorphism requires dynamic allocation and virtual tables, which can be avoided if all shape types are known at compile time. std::variant offers a compile‑time type‑safe alternative.
2. std::variant Basics
#include <variant>
#include <iostream>
#include <cmath>
using ShapeVariant = std::variant<Circle, Rectangle, Triangle>;
ShapeVariant can store any one of the three structs. You create it like this:
ShapeVariant shape = Circle{ 5.0 }; // a circle of radius 5
ShapeVariant shape = Rectangle{ 3.0, 4.0 }; // a rectangle 3x4
ShapeVariant shape = Triangle{ 3.0, 4.0 }; // a triangle base 3, height 4
3. Visiting a Variant
std::visit takes a visitor object (any callable) and a variant, and calls the visitor with the currently held type. The challenge is to write a visitor that can call the correct area function for each shape.
A minimal visitor could look like this:
double areaVisitor(const Circle& c) { return M_PI * c.radius * c.radius; }
double areaVisitor(const Rectangle& r) { return r.width * r.height; }
double areaVisitor(const Triangle& t) { return 0.5 * t.base * t.height; }
Then use:
double area = std::visit(areaVisitor, shape);
But writing a separate overload for each type can become cumbersome for large unions. A more general solution uses a lambda pack or a helper struct.
4. A Generic Visitor Helper
We can create a variant_visitor struct that aggregates multiple callables:
template<class... Ts>
struct variant_visitor : Ts... {
using Ts::operator()...; // bring all operator() into scope
constexpr variant_visitor(Ts... ts) : Ts(ts)... {}
};
template<class... Ts>
variant_visitor(Ts...) -> variant_visitor<Ts...>;
Now you can pass a tuple of lambdas to std::visit:
auto visitor = variant_visitor{
[](const Circle& c) { return M_PI * c.radius * c.radius; },
[](const Rectangle& r) { return r.width * r.height; },
[](const Triangle& t) { return 0.5 * t.base * t.height; }
};
double area = std::visit(visitor, shape);
Because variant_visitor inherits from all the lambda types, the operator() overloads are combined into a single callable that matches any of the stored types. The compiler deduces the return type automatically (here it is double).
5. Full Example
#include <variant>
#include <iostream>
#include <cmath>
// ---------- Shape definitions ----------
struct Circle { double radius; };
struct Rectangle{ double width, height; };
struct Triangle { double base, height; };
// ---------- Variant type ----------
using ShapeVariant = std::variant<Circle, Rectangle, Triangle>;
// ---------- Generic visitor helper ----------
template<class... Ts>
struct variant_visitor : Ts... {
using Ts::operator()...;
constexpr variant_visitor(Ts... ts) : Ts(ts)... {}
};
template<class... Ts>
variant_visitor(Ts...) -> variant_visitor<Ts...>;
// ---------- Main ----------
int main() {
ShapeVariant shapes[] = {
Circle{5.0},
Rectangle{3.0, 4.0},
Triangle{3.0, 4.0}
};
for (const auto& shape : shapes) {
double area = std::visit(
variant_visitor{
[](const Circle& c) { return M_PI * c.radius * c.radius; },
[](const Rectangle& r) { return r.width * r.height; },
[](const Triangle& t) { return 0.5 * t.base * t.height; }
},
shape
);
std::cout << "Area: " << area << '\n';
}
}
Compile with a C++17 compiler:
g++ -std=c++17 -O2 -Wall variant_example.cpp -o variant_example
./variant_example
Output
Area: 78.5398
Area: 12
Area: 6
6. Why Use This Pattern?
| Benefit | Explanation |
|---|---|
| Type safety | The compiler guarantees that every possible type is handled; missing a type will produce a compile‑time error. |
| No dynamic allocation | All objects are stored on the stack or in place; no heap allocation or virtual tables. |
| Extensible | Adding a new shape only requires adding a new variant alternative and a lambda overload. |
| Readability | The visitor keeps all shape‑specific logic in one place, reducing boilerplate. |
7. Tips & Tricks
- Return type deduction:
std::visitdeduces the return type from the visitor. All lambdas must return the same type (or a type convertible to a common type). - Ref‑qualifiers: If you need to modify the variant’s contents, pass it by reference:
auto visitor = variant_visitor{ [&](Circle& c){ c.radius *= 2; return 0.0; }, ... }; - Custom visitor structs: For more complex visitors, define a struct with multiple
operator()overloads instead of lambdas. - Combining with
std::optional: Often, you’ll want to store an optional shape; `std::optional ` is straightforward to use with the same pattern.
8. Conclusion
std::variant and std::visit together provide a clean, type‑safe, and efficient way to implement visitor logic without relying on inheritance or dynamic memory. By wrapping a set of overloaded lambdas in a variant_visitor, you get a reusable and expressive tool that scales well with the number of types in your variant. Whether you’re building a graphics engine, a compiler front‑end, or a simple calculator, this pattern will simplify your code and make it safer.