How to Use std::variant for Type-safe Polymorphism in C++17


Introduction

In traditional C++ programming, polymorphism is often achieved with class hierarchies and virtual functions. However, this approach introduces runtime overhead, dynamic memory allocation, and can lead to fragile designs if the hierarchy evolves. C++17’s std::variant provides an alternative that keeps type safety at compile time while still allowing a single value to hold one of several types. In this article, we’ll explore how std::variant can be used to implement type-safe polymorphism, compare it with classic virtual dispatch, and show practical examples.


1. What is std::variant?

std::variant is a type-safe union that can hold one value out of a set of specified types. Unlike a raw union, it tracks which type is currently active and prevents undefined behaviour when accessing the wrong member. The primary interface includes:

  • `std::get (variant)` – retrieves the value if the active type is `T`, otherwise throws `std::bad_variant_access`.
  • `std::get_if (&variant)` – returns a pointer to the value or `nullptr` if the active type is not `T`.
  • std::visit(visitor, variant) – applies a visitor (functor or lambda) to the active alternative.
  • `std::holds_alternative (variant)` – checks whether the active alternative is `T`.

Because std::variant is a regular type, it can be stored in containers, returned from functions, and moved or copied efficiently.


2. Traditional Polymorphism vs. std::variant

Feature Virtual Dispatch std::variant
Compile-time type safety No (dynamic dispatch) Yes
Memory overhead Dynamic allocation, vtable pointer None (fixed size)
Polymorphic behavior Inheritance hierarchy Visitor pattern
Extensibility Add new derived classes Add new alternatives
Use-case Runtime plugin systems Compile-time known alternatives

While virtual dispatch offers flexibility, it suffers from hidden costs. In performance-critical code (e.g., game engines, embedded systems), std::variant can replace virtual tables when the set of types is known at compile time.


3. Using std::variant for Polymorphic Behaviour

3.1 Defining a Variant Type

Suppose we have three geometric shapes that share no common base class:

struct Circle   { double radius; };
struct Rectangle{ double width, height; };
struct Triangle { double a, b, c; };

We define a variant that can hold any of these shapes:

using Shape = std::variant<Circle, Rectangle, Triangle>;

3.2 Creating and Manipulating Variants

Shape s = Circle{5.0};
if (auto p = std::get_if <Circle>(&s)) {
    std::cout << "Circle radius: " << p->radius << '\n';
}

Alternatively, we can assign a new type:

s = Rectangle{3.0, 4.0}; // implicit conversion to Shape

3.3 Visiting the Variant

The most powerful feature is std::visit. A visitor is a functor or lambda that knows how to handle each alternative:

auto area = [](auto&& shape) -> double {
    using T = std::decay_t<decltype(shape)>;
    if constexpr (std::is_same_v<T, Circle>) {
        return M_PI * shape.radius * shape.radius;
    } else if constexpr (std::is_same_v<T, Rectangle>) {
        return shape.width * shape.height;
    } else if constexpr (std::is_same_v<T, Triangle>) {
        double s = (shape.a + shape.b + shape.c) / 2.0;
        return std::sqrt(s * (s - shape.a) * (s - shape.b) * (s - shape.c));
    }
    return 0.0;
};

std::cout << "Area: " << std::visit(area, s) << '\n';

This approach removes the need for a virtual area() method in a base class, eliminates dynamic dispatch, and keeps the entire operation inlined.


4. Handling State and Mutability

std::variant can also store mutable objects:

struct Counter { int value; };
using Event = std::variant<std::string, Counter>;

Event ev = Counter{0};

std::visit([](auto&& e) {
    if constexpr (std::is_same_v<std::decay_t<decltype(e)>, Counter>) {
        ++e.value; // modify
        std::cout << "Counter: " << e.value << '\n';
    }
}, ev);

Because the variant holds the object by value, mutating it directly affects the stored state.


5. Interacting with External Libraries

When interfacing with APIs that expect a base class pointer, std::variant can be converted to a pointer using a visitor that returns Base*. For example:

struct Base { virtual void draw() = 0; };
struct CircleImpl : Base { void draw() override { /* ... */ } };
struct RectImpl   : Base { void draw() override { /* ... */ } };

using ShapeImpl = std::variant<std::unique_ptr<CircleImpl>, std::unique_ptr<RectImpl>>;

void render(ShapeImpl& shape) {
    std::visit([](auto&& p) { p->draw(); }, shape);
}

Here we still benefit from a variant while the actual objects are allocated on the heap to satisfy polymorphic API contracts.


6. Performance Considerations

  • Size: std::variant is typically as large as the biggest alternative plus space for a discriminator (usually a small integer). This is usually smaller than a polymorphic base class with a vtable pointer.
  • Inlining: Since visitors are usually implemented as lambdas, the compiler can inline the std::visit call, eliminating function-call overhead.
  • Cache locality: Storing a homogeneous array of variants can improve cache performance compared to an array of base pointers.

Benchmarks in a small graphics library showed a 20–30% speedup in shape processing when replacing virtual dispatch with std::visit.


7. Limitations

  • Dynamic Extensibility: If the set of types changes at runtime, std::variant cannot adapt. In such cases, virtual dispatch remains appropriate.
  • Polymorphic Interfaces: When you need to expose a stable interface (e.g., from a library), virtual functions may still be the easiest path.
  • Complexity: For very large unions, writing visitors becomes tedious; helper libraries or std::visit with std::apply can mitigate this.

8. Conclusion

std::variant offers a powerful, type-safe alternative to classic polymorphism in C++17. By combining a discriminated union with the visitor pattern, developers can write clearer, more efficient code when the set of possible types is known at compile time. While it doesn’t replace virtual functions in every scenario, understanding how to use variants expands the toolbox for designing modern C++ applications that prioritize performance and safety.

Happy coding!

发表评论