In C++17 and beyond, std::variant has emerged as a versatile, type-safe alternative to the classic union and the polymorphic std::any. By enabling an object to hold one value from a fixed set of types, std::variant offers compile-time guarantees that can simplify many coding patterns, from variant-based configurations to multi-typed return values. In this article, we’ll dive into the mechanics of std::variant, explore some practical use cases, and look at idiomatic ways to interact with it.
1. What is std::variant?
std::variant is a discriminated union: an object that can store exactly one value from a predefined list of types. Unlike a union, all types in a variant must be copyable or movable (unless you provide custom specialization), and the compiler tracks which type is currently active.
#include <variant>
#include <string>
#include <iostream>
std::variant<int, std::string, double> v;
v = 42; // holds an int
v = std::string("hello"); // now holds a string
The variant knows its active type internally, and you can query it with:
- `std::holds_alternative (v)` – checks if `v` currently holds a `T`.
- `std::get (v)` – retrieves the value; throws `std::bad_variant_access` if the type is wrong.
- `std::get_if (&v)` – returns a pointer to the value or `nullptr` if the type doesn’t match.
2. Common Patterns
2.1 Visitor
The canonical way to process a variant is the visitor pattern. A visitor is a callable object that overloads operator() for each type in the variant.
struct PrintVisitor {
void operator()(int i) const { std::cout << "int: " << i << '\n'; }
void operator()(const std::string& s) const { std::cout << "string: " << s << '\n'; }
void operator()(double d) const { std::cout << "double: " << d << '\n'; }
};
std::visit(PrintVisitor{}, v);
With C++20, you can use lambdas directly:
std::visit(overloaded{
[](int i){ std::cout << "int: " << i << '\n'; },
[](const std::string& s){ std::cout << "string: " << s << '\n'; },
[](double d){ std::cout << "double: " << d << '\n'; }
}, v);
2.2 Flattening Nested Variants
It is common to encounter nested std::variant types. The std::visit approach can be combined with recursion to “flatten” the structure:
template <typename Variant>
auto flatten(Variant&& var) {
return std::visit([](auto&& arg) {
using T = std::decay_t<decltype(arg)>;
if constexpr (std::is_same_v<T, std::variant<int, double>>)
return std::visit([](auto&& inner) { return inner; }, arg);
else
return arg;
}, std::forward <Variant>(var));
}
3. Practical Use Cases
3.1 Configuration Parsers
JSON-like configuration files often contain values of multiple types (int, string, bool, arrays). std::variant can model a single value node, enabling type-safe access:
using ConfigValue = std::variant<int, double, std::string, bool, std::vector<ConfigValue>, std::map<std::string, ConfigValue>>;
struct Config {
std::map<std::string, ConfigValue> data;
};
3.2 Multi-typed Return Values
Instead of throwing exceptions or using std::any, std::variant can express that a function may return several distinct types:
std::variant<int, std::string> get_status() {
if (success) return 200;
else return "Error";
}
This approach keeps return values strongly typed and encourages explicit handling.
3.3 Event Systems
In event-driven architectures, an event can be one of many types. std::variant provides a clean way to model the event type:
using Event = std::variant<LoginEvent, LogoutEvent, MessageEvent>;
Processing can then use visitors or pattern matching (C++23’s std::visit enhancements).
4. Performance Considerations
std::variant typically occupies memory equal to the largest member plus a few bytes for the discriminator. This can be more efficient than std::any, which stores a type erasure layer. However, be mindful of:
- Copy/move costs: Each alternative must be copy/movable; large types may incur heavy copies if not optimized with
std::move. - Alignment: The variant’s size is influenced by the strictest alignment requirement among its alternatives.
- Cache locality: A variant’s active member resides in a contiguous block, improving cache behavior compared to separate dynamic allocations.
5. Advanced Techniques
5.1 std::apply with Variants
If you want to apply a function to all alternatives of a variant, you can generate an overload set using helper templates. A popular utility is overloaded:
template<class... Ts> struct overloaded : Ts... { using Ts::operator()...; };
template<class... Ts> overloaded(Ts...) -> overloaded<Ts...>;
It allows you to pass multiple lambdas to std::visit seamlessly.
5.2 Type Erasure with std::variant
Combining std::variant with type erasure can provide a more efficient container for heterogeneous types. For example, an any_vector could be:
using Any = std::variant<int, double, std::string>;
std::vector <Any> vec;
The vector holds all elements contiguously, avoiding per-element heap allocations.
6. Summary
std::variant gives C++ developers a powerful tool to model finite sets of types safely and efficiently. Whether you’re parsing configurations, designing event systems, or simply handling multiple possible return values, std::variant offers compile-time guarantees that help eliminate a whole class of bugs. By mastering visitors, overload sets, and performance nuances, you can write cleaner, safer, and faster code.
Happy variant coding!