In modern C++ (C++17 and later), std::variant is a type-safe union that allows a variable to hold one of several specified types at any given time. Unlike the traditional C-style union, a variant tracks which type is currently stored and enforces compile-time type safety. It has become an indispensable tool for developers who need to represent heterogeneous data without resorting to polymorphic class hierarchies or raw void* pointers.
1. Basic Anatomy of std::variant
#include <variant>
#include <string>
#include <iostream>
using MyVariant = std::variant<int, double, std::string>;
int main() {
MyVariant v = 42; // holds int
v = 3.14; // now holds double
v = std::string("Hello"); // now holds string
std::cout << std::get<std::string>(v) << '\n';
}
std::variant stores the value and an index that indicates which alternative is active. The compiler guarantees that the stored type matches the active index.
2. Querying the Active Type
| Function | Purpose |
|---|---|
index() |
Returns the zero-based index of the currently active alternative, or std::variant_npos if the variant is empty. |
| `holds_alternative | |
()| Checks ifT` is the active type. |
|
type() |
Returns a std::type_info reference for the active type. |
Example:
if (std::holds_alternative <int>(v)) {
std::cout << "int: " << std::get<int>(v) << '\n';
}
3. Accessing the Value
- **`std::get (v)`**: Returns a reference to the value if `T` matches the active type; otherwise throws `std::bad_variant_access`.
- **`std::get (v)`**: Returns a reference based on the stored index.
- **`std::get_if (&v)`**: Returns a pointer to the value if the type matches; otherwise `nullptr`. This is useful for safe access without exceptions.
4. Visiting Alternatives
The canonical way to work with a variant is to use std::visit, which applies a visitor (a function object) to the active alternative. The visitor must provide overloads for each type.
#include <variant>
#include <iostream>
#include <string>
int main() {
std::variant<int, double, std::string> v = 10;
std::visit([](auto&& arg){
using T = std::decay_t<decltype(arg)>;
if constexpr (std::is_same_v<T, int>) {
std::cout << "int: " << arg << '\n';
} else if constexpr (std::is_same_v<T, double>) {
std::cout << "double: " << arg << '\n';
} else if constexpr (std::is_same_v<T, std::string>) {
std::cout << "string: " << arg << '\n';
}
}, v);
}
C++20 introduced std::visit with constexpr overloads and std::variant_alternative to further simplify visitor patterns.
5. Common Pitfalls and How to Avoid Them
| Issue | Explanation | Fix |
|---|---|---|
| Uninitialized variant | A default-constructed variant holds no active value and is considered empty. Accessing it throws std::bad_variant_access. |
Construct with a default alternative or use std::variant<T...>::value_type default constructor. |
| **Incorrect type in `std::get | ||
** | Passing the wrong type throws. | Useholds_alternativeorget_if` to check before accessing. |
||
| Copying a large alternative | std::get copies the value, which can be expensive for big types. |
Use `std::get |
(v)to get a reference, orstd::visit` to avoid copies. |
||
| Visitor overload ambiguity | If the visitor provides overloaded templates that are not distinguished by type, overload resolution fails. | Use overloaded helper or lambda chains. |
6. A Practical Example: An Expression Tree
Consider a simple arithmetic expression that can be either a constant, a variable, or a binary operation. A variant can represent each node type cleanly.
#include <variant>
#include <string>
#include <memory>
struct Const {
double value;
};
struct Var {
std::string name;
};
struct BinaryOp; // forward declaration
using ExprNode = std::variant<Const, Var, std::shared_ptr<BinaryOp>>;
struct BinaryOp {
char op; // '+', '-', '*', '/'
ExprNode left;
ExprNode right;
};
double evaluate(const ExprNode& node, const std::unordered_map<std::string, double>& env) {
return std::visit([&](auto&& arg) -> double {
using T = std::decay_t<decltype(arg)>;
if constexpr (std::is_same_v<T, Const>) {
return arg.value;
} else if constexpr (std::is_same_v<T, Var>) {
return env.at(arg.name);
} else if constexpr (std::is_same_v<T, std::shared_ptr<BinaryOp>>) {
double l = evaluate(arg->left, env);
double r = evaluate(arg->right, env);
switch (arg->op) {
case '+': return l + r;
case '-': return l - r;
case '*': return l * r;
case '/': return l / r;
}
}
}, node);
}
This approach keeps the expression tree type-safe, flexible, and easy to extend.
7. When Not to Use std::variant
- Large Number of Alternatives: If you need dozens of alternatives,
variantcan become unwieldy. Polymorphic class hierarchies may be clearer. - Polymorphic Behavior: If the alternatives require different interfaces or dynamic behavior beyond a simple data container, inheritance may be preferable.
- Runtime Extensibility:
variantis a compile-time type; you cannot add new alternatives at runtime.
8. Conclusion
std::variant provides a robust, type-safe mechanism for representing a value that can be one of several distinct types. Its integration with std::visit, compile-time type checks, and exception safety makes it an essential tool for modern C++ developers. By mastering its features—querying, accessing, visiting, and handling pitfalls—you can write clearer, safer code that elegantly replaces many traditional union or polymorphism patterns.