Using std::variant to Implement a Type‑Safe Visitor Pattern in Modern C++
Article:
The classic visitor pattern is a powerful tool for operating on a class hierarchy without modifying the classes themselves.
However, traditional implementations rely on a separate Visitor interface, virtual dispatch, and often lead to boilerplate code.
With C++17’s std::variant and std::visit, we can achieve the same type‑safe dispatching in a more compact, compile‑time safe, and error‑free manner.
1. Problem Recap
Suppose we have an expression tree:
struct IntExpr { int value; };
struct AddExpr { std::unique_ptr <Expr> left, right; };
struct MulExpr { std::unique_ptr <Expr> left, right; };
We wish to evaluate, pretty‑print, or otherwise process these expressions without adding a virtual method to each node.
Traditionally, we’d define:
class Visitor { ... }; // visit(IntExpr), visit(AddExpr), …
class IntExpr : public Expr { void accept(Visitor&) override; };
This pattern becomes unwieldy as the hierarchy grows.
2. The Variant‑Based Approach
std::variant is a type‑safe union. It can hold one of several types, and std::visit applies a visitor lambda or functor to the active type.
using Expr = std::variant<IntExpr, AddExpr, MulExpr>;
Each node can be created directly:
Expr e = AddExpr{ std::make_unique <Expr>(IntExpr{1}),
std::make_unique <Expr>(IntExpr{2}) };
Because Expr is a variant, the actual type is known at compile time.
We can now define visitors as simple lambdas.
3. Evaluating an Expression
int evaluate(const Expr& expr) {
return std::visit([](auto&& arg) -> int {
using T = std::decay_t<decltype(arg)>;
if constexpr (std::is_same_v<T, IntExpr>) {
return arg.value;
} else if constexpr (std::is_same_v<T, AddExpr>) {
return evaluate(*arg.left) + evaluate(*arg.right);
} else if constexpr (std::is_same_v<T, MulExpr>) {
return evaluate(*arg.left) * evaluate(*arg.right);
} else {
static_assert(always_false <T>::value, "Non‑exhaustive visitor");
}
}, expr);
}
Key points:
std::visitdispatches at runtime but in a type‑safe manner.if constexprlets us write a single lambda that handles every case; no virtual functions needed.always_falseis a trick to force a compile‑time error if a new variant alternative is added but not handled.
4. Pretty‑Printing
std::string pretty_print(const Expr& expr) {
return std::visit([](auto&& arg) -> std::string {
using T = std::decay_t<decltype(arg)>;
if constexpr (std::is_same_v<T, IntExpr>) {
return std::to_string(arg.value);
} else if constexpr (std::is_same_v<T, AddExpr>) {
return "(" + pretty_print(*arg.left) + " + " + pretty_print(*arg.right) + ")";
} else if constexpr (std::is_same_v<T, MulExpr>) {
return "(" + pretty_print(*arg.left) + " * " + pretty_print(*arg.right) + ")";
}
}, expr);
}
Notice how the code is symmetrical to the evaluator but produces a string.
Both visitors share the same structure and can be extended together.
5. Adding New Node Types
Suppose we add a NegExpr:
struct NegExpr { std::unique_ptr <Expr> operand; };
Update the variant:
using Expr = std::variant<IntExpr, AddExpr, MulExpr, NegExpr>;
Now, because we use if constexpr with a static_assert fallback, the compiler will point out any missing case in our visitors.
We simply add:
} else if constexpr (std::is_same_v<T, NegExpr>) {
return -evaluate(*arg.operand); // for evaluation
}
or
} else if constexpr (std::is_same_v<T, NegExpr>) {
return "-(" + pretty_print(*arg.operand) + ")";
}
No other part of the codebase needs modification.
6. Advantages Over Classic Visitor
| Feature | Classic Visitor | Variant‑Based Visitor |
|---|---|---|
| Compile‑time safety | Partial; relies on developer discipline | Full; unhandled types trigger compile error |
| Boilerplate | accept methods + Visitor interface |
None |
| Extensibility | Adding a new node requires modifying visitor interface | Adding a node requires only updating variant and visitor lambdas |
| Runtime overhead | Virtual dispatch | Tag dispatch via std::visit (usually inlined) |
| Memory layout | Each node contains a vtable pointer | Each node is a distinct struct; no extra vptr |
7. Practical Tips
- Avoid deep recursion – use an explicit stack if the expression tree is large.
- Prefer
std::unique_ptr– ensures ownership and automatic cleanup. - Use
std::variantfor heterogeneous collections – e.g., a list of expressions. - Combine with
std::optional– to represent nullable child nodes. - Take advantage of
std::apply– for operations that need to apply the same function to each child.
8. Full Example
#include <iostream>
#include <variant>
#include <memory>
#include <string>
struct IntExpr { int value; };
struct AddExpr { std::unique_ptr<struct Expr> left, right; };
struct MulExpr { std::unique_ptr<struct Expr> left, right; };
struct Expr; // forward declaration
using Expr = std::variant<IntExpr, AddExpr, MulExpr>;
int evaluate(const Expr& e);
std::string pretty_print(const Expr& e);
int main() {
Expr tree = AddExpr{
std::make_unique <Expr>(IntExpr{4}),
std::make_unique <Expr>(MulExpr{
std::make_unique <Expr>(IntExpr{2}),
std::make_unique <Expr>(IntExpr{3})
})
};
std::cout << pretty_print(tree) << " = " << evaluate(tree) << '\n';
}
This prints:
(4 + (2 * 3)) = 10
9. Conclusion
Using std::variant and std::visit turns the visitor pattern into a modern, type‑safe, and concise technique.
It eliminates boilerplate, catches errors at compile time, and scales gracefully as your type hierarchy grows.
If your C++ compiler supports at least C++17, give this approach a try for all scenarios that previously required a classic visitor.