std::variant has been part of C++17 and is a powerful tool that brings the flexibility of a union with the safety guarantees of a type‑safe discriminated union. It allows a single variable to hold one of several specified types, while guaranteeing that only one is active at a time and that you cannot inadvertently read the wrong type. In this article we’ll explore the practical use‑cases, common pitfalls, and advanced tricks that make std::variant a go‑to component in modern C++ codebases.
Why replace std::variant with a union?
- Safety –
std::variantmaintains a discriminant internally. If you try to access the wrong type, it throws an exception (std::bad_variant_access). A raw union, on the other hand, will simply produce garbage or invoke undefined behaviour. - Constructors & Destructors – It correctly constructs and destructs the active member, calling the right constructors, destructors, and copy/move operations.
- Value semantics –
std::variantbehaves like a regular value type: copyable, movable, assignable, and comparable (if all alternatives are comparable). - Type introspection – You can query the type held by a variant at compile time (
std::variant_alternative_t) or at runtime (std::holds_alternative/std::get_if).
Basic usage
#include <variant>
#include <iostream>
#include <string>
using Value = std::variant<int, double, std::string>;
int main() {
Value v = 42; // holds an int
std::visit([](auto&& x) { std::cout << x << '\n'; }, v);
v = 3.14; // now holds a double
std::visit([](auto&& x) { std::cout << x << '\n'; }, v);
v = std::string{"hello"}; // holds a string
std::visit([](auto&& x) { std::cout << x << '\n'; }, v);
}
The std::visit function dispatches a visitor to the active alternative. The visitor can be a lambda or a functor; the compiler deduces the type for each alternative.
Visiting with overloaded lambdas
A common pattern is to use a helper overloaded struct to combine multiple lambdas:
template<class... Ts> struct overloaded : Ts... { using Ts::operator()...; };
template<class... Ts> overloaded(Ts...) -> overloaded<Ts...>;
Value v = std::string{"example"};
std::visit(overloaded{
[](int i) { std::cout << "int: " << i; },
[](double d) { std::cout << "double: " << d; },
[](const std::string& s) { std::cout << "string: " << s; }
}, v);
This eliminates the need for manual if constexpr chains and keeps the visitor succinct.
Checking the active type
You can test which type the variant currently holds:
if (std::holds_alternative <int>(v)) {
std::cout << "int: " << std::get<int>(v) << '\n';
} else if (std::holds_alternative <double>(v)) {
std::cout << "double: " << std::get<double>(v) << '\n';
} else {
std::cout << "string: " << std::get<std::string>(v) << '\n';
}
Alternatively, you can retrieve the index with v.index() where the index corresponds to the order of types in the template parameter pack.
Common pitfalls
-
Copying from an empty variant
A default‑constructedstd::variantholds the first alternative by default. Trying to access it before setting a value may give you an unexpected default. Usestd::variant::valueless_by_exception()to detect if an exception during assignment left the variant in a valueless state. -
Returning a
std::variantfrom a function
Ensure that all alternative types are either copy‑constructible or move‑constructible, because the returned variant will be moved or copied by the caller. -
Exception safety
If an exception is thrown while constructing the new alternative, the variant remains in the previous state. If constructing the new alternative itself throws, the variant becomes valueless_by_exception. Handling this scenario gracefully is key for robust code.
Advanced techniques
1. Type‑safe arithmetic with std::variant
Value add(const Value& a, const Value& b) {
return std::visit([](auto&& x, auto&& y) {
using T1 = std::decay_t<decltype(x)>;
using T2 = std::decay_t<decltype(y)>;
if constexpr (std::is_arithmetic_v <T1> && std::is_arithmetic_v<T2>) {
return Value(x + y); // implicit promotion rules apply
} else {
throw std::logic_error("Unsupported types for addition");
}
}, a, b);
}
2. std::variant as a small object for visitor pattern
In event‑driven systems, std::variant can replace the classic visitor pattern:
struct MouseEvent { /* ... */ };
struct KeyboardEvent { /* ... */ };
struct ResizeEvent { /* ... */ };
using Event = std::variant<MouseEvent, KeyboardEvent, ResizeEvent>;
void dispatch(Event e) {
std::visit(overloaded{
[](MouseEvent const& m) { handleMouse(m); },
[](KeyboardEvent const& k) { handleKeyboard(k); },
[](ResizeEvent const& r) { handleResize(r); }
}, e);
}
This removes the need for virtual inheritance and keeps all event types in a single type‑safe container.
3. Combining with std::optional
If you need a “nullable variant” you can wrap it in std::optional:
std::optional <Value> maybeVal = std::nullopt; // empty
// Later assign a value
maybeVal = 5;
if (maybeVal) {
std::visit(/* visitor */, *maybeVal);
}
Alternatively, use std::variant<std::monostate, int, double, std::string> to encode an empty state inside the variant itself.
Performance considerations
- Size – The size of a
std::variantis the maximum size of its alternatives plus the size of the discriminant. For small types (e.g., primitives) this overhead is negligible. - Alignment –
std::variantguarantees proper alignment for all alternatives. - Copy/move costs – If you have a variant with expensive alternatives, each copy/move may copy the currently active alternative. Be mindful of copy elision and move semantics.
- Branching – The visitor dispatch incurs a virtual‑like dispatch at runtime. For high‑performance code, you might want to keep the alternative set small or unroll the visitor manually.
Real‑world example: JSON values
A lightweight JSON representation often uses a variant:
struct Json; // forward declaration
using JsonValue = std::variant<
std::nullptr_t,
bool,
int64_t,
double,
std::string,
std::vector <Json>,
std::map<std::string, Json>
>;
struct Json {
JsonValue value;
};
You can then write parsers and serializers that operate on Json without resorting to dynamic casts or a hand‑rolled type system.
Conclusion
std::variant is a versatile and type‑safe alternative to union, std::any, or dynamic polymorphism. It gives you compile‑time guarantees, clean syntax, and robust runtime behaviour. By mastering visitors, type checks, and the nuances of exception safety, you can use std::variant to build safer, more maintainable C++ codebases.
Happy coding, and may your variants always hold the correct type!