C++17 introduced std::variant, a type‑safe union that lets you store one of several types in a single variable. In C++20 the library received enhancements that make it even more useful for modern C++ developers who need runtime‑polymorphic behaviour without the overhead of virtual tables. This article walks through the key features of std::variant, shows how to implement type‑safe polymorphism, and discusses performance considerations and common pitfalls.
1. Recap of std::variant
std::variant<Ts...> is a discriminated union: it holds a value of one of the listed types Ts... and tracks which type is currently active.
#include <variant>
#include <iostream>
#include <string>
using Variant = std::variant<int, double, std::string>;
Variant v = 42; // holds an int
v = std::string("hello"); // now holds a string
`std::holds_alternative
(v)` tests the active type, while `std::get(v)` retrieves it. If you call `std::get(v)` when `T` is not the active type, a `std::bad_variant_access` exception is thrown. — ### 2. Using `std::visit` for Polymorphic Operations The canonical way to operate on a variant’s value is `std::visit`, which applies a visitor object (or lambda) to the active type: “`cpp std::visit([](auto&& arg){ std::cout << arg << '\n'; }, v); “` Because the visitor’s call operator is a template, the compiler generates overloads for each possible type automatically. #### Example: A Shape Hierarchy Suppose we need a small collection of shapes, each with a different data representation: “`cpp struct Circle { double radius; }; struct Rectangle { double width, height; }; struct Triangle { double a, b, c; }; using Shape = std::variant; “` We can write a single function that prints the perimeter of any shape: “`cpp double perimeter(const Shape& s) { return std::visit([](auto&& shape){ using T = std::decay_t; if constexpr (std::is_same_v) { return 2 * M_PI * shape.radius; } else if constexpr (std::is_same_v) { return 2 * (shape.width + shape.height); } else if constexpr (std::is_same_v) { return shape.a + shape.b + shape.c; } }, s); } “` The `if constexpr` chain ensures that only the branch matching the actual type is instantiated, giving zero runtime overhead. — ### 3. Variants vs. Polymorphic Base Classes | Feature | `std::variant` | Virtual Inheritance | |———|—————-|———————| | Compile‑time type safety | ✔ | ✔ | | Runtime dispatch | Template‑based | Virtual table lookup | | Memory layout | Contiguous | Usually a pointer per object | | Extensibility | Add types to the list | Add new derived class | | Performance | No v‑ptr, cache friendly | Possible pointer indirection | `std::variant` shines when the set of possible types is finite and known at compile time. For open‑ended hierarchies where new types are added frequently, traditional polymorphism may still be appropriate. — ### 4. Performance Tips 1. **Avoid Unnecessary Copies** Pass `const Variant&` to visitors whenever possible. Use `std::visit` overloads that accept `Variant&&` for move semantics. 2. **Pre‑compute Dispatch** If you call `std::visit` many times with the same variant layout, consider generating a static lookup table of function pointers using `std::variant`’s `index()` method. 3. **Avoid `std::any` for Polymorphism** `std::any` erases type information and incurs heap allocations. `std::variant` keeps the type in the type list, so the compiler can optimise more aggressively. 4. **Use `std::in_place_type_t` for In‑Place Construction** When constructing a variant in a large array, construct it in place to avoid extra moves: “`cpp std::vector shapes(1000, std::in_place_type); “` — ### 5. Common Pitfalls – **Mixing `std::visit` with `std::get`** If you first call `std::visit` and then later call `std::get` on the same variant, you must ensure that the active type hasn’t changed in the meantime. – **Exception Safety** `std::visit` is not guaranteed to be noexcept; if your visitor throws, the variant remains unchanged. – **Nested Variants** While legal, deep nesting can lead to complicated visitors. Consider flattening the type list if possible. — ### 6. Practical Use‑Case: Serialization Many serialization libraries (e.g., `nlohmann::json`) accept `std::variant` directly. Here’s a tiny example: “`cpp #include nlohmann::json serialize(const Variant& v) { return std::visit([](auto&& arg){ return nlohmann::json(arg); }, v); } “` The visitor automatically serialises each type according to its own `to_json` overload. — ### 7. Conclusion `std::variant` provides a powerful, type‑safe way to model discriminated unions in modern C++. With `std::visit` and compile‑time dispatch, you can write concise, efficient polymorphic code without virtual tables. While it’s not a silver bullet for every polymorphic scenario, understanding its strengths and limitations allows you to choose the right tool for the job—whether that be `std::variant`, traditional inheritance, or a hybrid approach. Happy coding!