When you need a variable that can hold one of several different types but never more than one at a time, C++17’s std::variant provides a clean, type‑safe solution. This article explains the core concepts, walks through practical examples, highlights common pitfalls, and gives best‑practice tips for integrating std::variant into real‑world codebases.
1. Why Use std::variant?
| Problem | Traditional Approach | std::variant Solution |
|---|---|---|
| Storing one of several possible types | union + manual type tagging |
Automatic type safety, no manual tags |
| Runtime type checks | dynamic_cast + typeid |
Compile‑time type checking via visitor pattern |
| Null/empty state | std::optional + union |
std::variant already has an empty state |
| Performance | Potential extra branching | Lightweight, in‑place storage, small‑object optimization |
std::variant is essentially a discriminated union with built‑in safety and ergonomics. It eliminates the need for custom enums and type‑switching boilerplate, making code more maintainable and less error‑prone.
2. Basic Syntax & Construction
#include <variant>
#include <string>
#include <iostream>
using ConfigValue = std::variant<int, double, std::string>;
int main() {
ConfigValue val1 = 42; // int
ConfigValue val2 = 3.14; // double
ConfigValue val3 = std::string("hi"); // std::string
// Default construct (holds empty state)
ConfigValue val4{};
}
std::variant is a template taking a variadic list of types. It guarantees at most one of those types is active at a time.
3. Querying the Active Type
if (std::holds_alternative <int>(val1)) {
std::cout << "int: " << std::get<int>(val1) << '\n';
}
- `std::holds_alternative (v)` – Returns `true` if `v` currently holds a value of type `T`.
- `std::get (v)` – Retrieves the value, throwing `std::bad_variant_access` if the type is wrong.
- `std::get_if (&v)` – Returns a pointer to the value or `nullptr` if the type mismatches.
For debugging, std::visit can print the active index:
std::cout << "Index: " << val1.index() << '\n';
index() returns an integer from to variant_size - 1, or variant_npos if the variant is empty.
4. Visiting – The Preferred Access Pattern
auto printer = [](auto&& value) {
std::cout << value << '\n';
};
std::visit(printer, val3); // prints "hi"
std::visit takes a visitor (a callable that can accept each possible type) and dispatches to the appropriate overload. This pattern scales gracefully when you add more types.
Overload Sets
auto visitor = overloaded{
[](int i) { std::cout << "int: " << i; },
[](double d) { std::cout << "double: " << d; },
[](std::string s){ std::cout << "string: " << s; }
};
std::visit(visitor, val3);
overloaded is a small helper that merges multiple lambda overloads into one callable object (available since C++20, but can be written manually for C++17).
5. Storing and Modifying Values
val1 = std::string("now a string"); // reassigns
Reassignment automatically destroys the previous value and constructs the new one. std::variant handles copy/move semantics for all contained types, but be careful with non‑copyable types like std::unique_ptr. Use std::move or std::in_place_index_t for efficient construction.
val1 = std::in_place_index <2>, "raw string literal"; // index 2 corresponds to std::string
6. Common Pitfalls & How to Avoid Them
| Pitfall | Symptoms | Fix |
|---|---|---|
| *Using std::get | ||
| without checking* | Runtime crashes (std::bad_variant_access) |
Use std::holds_alternative<T> or std::get_if<T> |
| Variant as function return with many overloads | Verbose visitor boilerplate | Use std::variant + std::visit or create helper overload functions |
| Storing polymorphic types (base pointers) | Variant holds pointer, not object | Prefer `std::shared_ptr |
or use astd::variant<std::unique_ptr, …>` |
||
| Adding duplicate types | Compile‑time error (no overload) | Ensure type list contains unique types |
| Ignoring empty state | Uninitialized variant used | Initialize explicitly (ConfigValue val{}) or guard with if (!val.valueless_by_exception()) |
7. Performance Considerations
- Size –
std::variantuses the maximum of all alternative sizes plus a small discriminant. For small types (< 64 bytes) the overhead is minimal. - Empty State – The default state occupies no additional space; if you need a “null” sentinel, use
std::optional<std::variant<...>>. - Move vs Copy – Prefer move semantics when assigning large or non‑copyable types.
- Exception Safety – The variant’s constructors are noexcept if all alternatives are
noexcept. Otherwise, operations can throw. Always consider exception‑safety when working withstd::variant.
8. Real‑World Example: Configuration Parser
#include <variant>
#include <string>
#include <map>
#include <iostream>
#include <fstream>
#include <sstream>
#include <nlohmann/json.hpp> // Assume JSON parsing library
using ConfigValue = std::variant<int, double, std::string, bool>;
using ConfigMap = std::map<std::string, ConfigValue>;
ConfigMap parseConfig(const std::string& filename) {
ConfigMap cfg;
std::ifstream in(filename);
nlohmann::json j;
in >> j;
for (auto& [key, val] : j.items()) {
if (val.is_number_integer())
cfg[key] = val.get <int>();
else if (val.is_number_float())
cfg[key] = val.get <double>();
else if (val.is_boolean())
cfg[key] = val.get <bool>();
else if (val.is_string())
cfg[key] = val.get<std::string>();
else
throw std::runtime_error("Unsupported config type");
}
return cfg;
}
void printConfig(const ConfigMap& cfg) {
for (const auto& [k, v] : cfg) {
std::visit([&](auto&& val){ std::cout << k << " = " << val << '\n'; }, v);
}
}
This pattern eliminates a host of manual type‑switching, while keeping the configuration API type‑safe.
9. Conclusion
std::variant is a powerful, type‑safe alternative to manual unions, std::any, or ad‑hoc type tags. With its visitor pattern and straightforward construction, it integrates cleanly into modern C++17 codebases. By following the patterns above, you can reduce boilerplate, catch type errors at compile time, and write more maintainable, expressive code.
Happy coding!