**Title:**

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::visit dispatches at runtime but in a type‑safe manner.
  • if constexpr lets us write a single lambda that handles every case; no virtual functions needed.
  • always_false is 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

  1. Avoid deep recursion – use an explicit stack if the expression tree is large.
  2. Prefer std::unique_ptr – ensures ownership and automatic cleanup.
  3. Use std::variant for heterogeneous collections – e.g., a list of expressions.
  4. Combine with std::optional – to represent nullable child nodes.
  5. 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.

发表评论