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!