In modern C++ development, type safety and flexibility often go hand‑in‑hand. One of the most powerful features that achieves this balance is std::variant, introduced in C++17. std::variant allows you to store one of several specified types in a single variable while preserving type safety at compile time. This article explores the inner workings of std::variant, common use‑cases, performance considerations, and practical tips for mastering its use.
1. What is std::variant?
std::variant is a type‑safe union. Unlike a traditional C union, which relies on manual bookkeeping to track the active member, std::variant maintains this information internally. A std::variant can hold any one of the types specified in its template parameter list:
std::variant<int, std::string, double> v;
v = 42; // holds an int
v = std::string("Hello"); // holds a string
Attempting to access the stored value with the wrong type throws a std::bad_variant_access exception, ensuring that errors are caught early.
2. Construction and Assignment
There are several ways to initialise or assign a std::variant:
// Direct list initialisation (default constructs the first type)
std::variant<int, std::string> v1{};
std::variant<int, std::string> v2{42}; // int
std::variant<int, std::string> v3{std::string("hi")}; // string
// Using std::in_place_index to specify the type by index
std::variant<int, std::string> v4{std::in_place_index<1>, "world"};
Copy and move operations are straightforward and propagate the active type automatically.
3. Visiting: The Key to Access
The idiomatic way to read a variant‘s value is through std::visit, which accepts a callable object (e.g., lambda, function object) and dispatches the call based on the active type:
std::visit([](auto&& arg){
std::cout << arg << '\n';
}, v);
The lambda uses a forwarding reference (auto&&) to avoid unnecessary copies and preserve constness.
If you need to handle a specific subset of types, you can create overloaded lambdas:
auto visitor = overloaded{
[](int i){ std::cout << "int: " << i << '\n'; },
[](const std::string& s){ std::cout << "string: " << s << '\n'; }
};
std::visit(visitor, v);
The overloaded helper (available in C++20 or as a simple template in C++17) merges multiple callables into one.
4. Extracting the Value
When you know the active type, you can use std::get or std::get_if:
int i = std::get <int>(v); // throws if not int
auto* s = std::get_if<std::string>(&v); // nullptr if not string
These functions are safe for use in conditional logic, especially get_if.
5. Practical Use‑Cases
5.1. Error Handling
Instead of returning `std::optional
` or throwing exceptions, `std::variant` can encapsulate both success and error states:
“`cpp
using Result = std::variant;
Result parse(const std::string& data) {
if (data.empty()) return std::error_code{…};
return std::string{“Parsed successfully”};
}
“`
A caller can then inspect which type it holds and act accordingly.
### 5.2. Polymorphic Storage Without Virtual Functions
If you have a few concrete types that share a common interface, you can store them in a `variant` and use `visit` to dispatch:
“`cpp
struct Circle { double radius; };
struct Rectangle { double w, h; };
using Shape = std::variant;
double area(const Shape& s) {
return std::visit([](auto&& shape){
using T = std::decay_t;
if constexpr (std::is_same_v)
return 3.14159 * shape.radius * shape.radius;
else if constexpr (std::is_same_v)
return shape.w * shape.h;
}, s);
}
“`
## 6. Performance Considerations
### 6.1. Size
`std::variant` allocates enough space to hold its largest type plus an index field (usually `std::size_t`). The size can be larger than the sum of the sizes of its alternatives due to alignment and padding.
“`cpp
static_assert(sizeof(std::variant) == 40, “Expect 40 bytes”);
“`
### 6.2. Copy/Move Costs
Copying or moving a `variant` involves copying or moving the active member and copying the index. If one of the alternatives has a heavy copy constructor, performance may suffer. Prefer move semantics when possible.
### 6.3. Visitor Dispatch
`std::visit` dispatches at runtime via an internal table, not a virtual function call. The overhead is minimal and comparable to a switch‑statement on an enum.
## 7. Common Pitfalls
1. **Uninitialized Variant** – If you default‑construct a `variant` without specifying the type, the first type in the list is default‑constructed. This can be surprising if the first type is expensive.
2. **Variant Index Out of Range** – Passing an invalid index to `std::in_place_index` throws `std::out_of_range`.
3. **Implicit Conversions** – If multiple alternatives are convertible from the same type, implicit construction may be ambiguous. Explicit cast or specifying the index can resolve this.
## 8. Tips for Mastery
– **Use `std::variant` sparingly**: It’s great for small, well‑defined sets of alternatives but can become unwieldy with many types.
– **Prefer `std::optional` for single alternatives**: If only two states exist, `std::optional` is clearer.
– **Combine with `std::expected` (C++23)**: For result/error pairs, `std::expected` is more semantically correct.
– **Leverage `std::holds_alternative`**: Quickly check the active type before accessing.
– **Write clear overloads**: Use the `overloaded` pattern to keep visitor logic readable.
## 9. Conclusion
`std::variant` is a powerful tool in the C++17 toolbox that offers type‑safe unions, eliminating many pitfalls of raw unions and variant‑style enums. By mastering construction, visitation, and extraction patterns, you can write cleaner, safer, and more expressive code. Whether you’re handling error states, designing lightweight polymorphic containers, or simply need a flexible container for a small set of types, `std::variant` is worth adding to your repertoire.