Using std::variant for Type-Safe Unions in C++
Introduced in C++17, std::variant provides a modern,
type-safe alternative to traditional C-style unions by representing a
type-safe tagged union. This article explains the limitations and risks
of classic C-style unions, examines how std::variant
automatically manages object lifetimes and tracks active types, and
demonstrates the core access patterns—including std::get,
std::get_if, and std::visit—that eliminate
undefined behavior when working with heterogeneous data.
The Limitations of Traditional C-Style Unions
Traditional C-style unions allow multiple data types to occupy the same memory location, enabling memory optimization. However, they lack type awareness:
union Data {
int i;
double d;
char str[16];
};A raw union does not track which member is currently active. If a
program writes to Data::i and subsequently reads from
Data::d, the compiler permits the operation, resulting in
type punning and undefined behavior. Furthermore, C-style unions cannot
cleanly handle non-trivial C++ types (such as std::string
or std::vector) because they do not automatically call
constructors or destructors when active members change. The developer
must manually invoke placement new and explicit
destructors, introducing significant risk of memory leaks and memory
corruption.
How
std::variant Solves These Issues
std::variant is a template class that holds a value from
a fixed set of types defined at compile time. It acts as a "tagged
union" by internally maintaining two components: a raw storage buffer
sized to hold the largest type in the list, and a discrete discriminator
(index) indicating which type is currently engaged.
Key safety guarantees include:
- Automatic Lifetime Management: When a new value is
assigned to a
std::variant, it automatically calls the destructor of the currently held object before constructing the new one. When thestd::variantitself goes out of scope, it cleans up whichever object is currently active. - Non-Trivial Type Support: Types with complex constructors, copy semantics, and move semantics are supported without manual intervention.
- No Unchecked Access: Memory locations cannot be arbitrarily read as alternative types; all read operations validate the internal discriminator.
Safe Access Patterns
C++ provides multiple mechanisms to access the data inside a
std::variant, each designed to prevent runtime type
mismatches.
Checked Retrieval with
std::get
The std::get<T> or
std::get<Index> function retrieves the value stored
inside the variant. If the requested type does not match the currently
held type, it immediately throws a std::bad_variant_access
exception rather than returning invalid data:
#include <variant>
#include <string>
#include <iostream>
std::variant<int, std::string> data = 42;
try {
std::cout << std::get<int>(data); // Succeeds: prints 42
std::cout << std::get<std::string>(data); // Throws std::bad_variant_access
} catch (const std::bad_variant_access& e) {
std::cerr << "Type mismatch: " << e.what() << '\n';
}Non-Throwing Inspection
with std::get_if
For code that avoids exceptions, std::get_if<T>
takes a pointer to a std::variant and returns a pointer to
the value if the type matches, or nullptr if it does
not:
if (const int* val = std::get_if<int>(&data)) {
std::cout << "Integer value: " << *val << '\n';
} else {
std::cout << "Variant does not contain an int.\n";
}Comprehensive Handling
with std::visit
The most idiomatic and compile-time-safe method for processing
std::variant is std::visit. It uses a callable
visitor (such as an overloaded lambda) to handle all possible types. The
compiler enforces that every type declared in the variant can be handled
by the visitor:
template<class... Ts> struct overloaded : Ts... { using Ts::operator()...; };
template<class... Ts> overloaded(Ts...) -> overloaded<Ts...>;
std::variant<int, double, std::string> item = "Hello";
std::visit(overloaded {
[](int arg) { std::cout << "int: " << arg << '\n'; },
[](double arg) { std::cout << "double: " << arg << '\n'; },
[](const std::string& arg) { std::cout << "string: " << arg << '\n'; }
}, item);If a case is omitted from the visitor, compilation fails, ensuring that unhandled types cannot cause unexpected runtime states.
The "Valueless by Exception" Guarantee
In rare cases, if an exception is thrown during an in-place
assignment or type transition, a std::variant can enter a
state known as valueless_by_exception(). In this state, it
holds no value. Calling std::get on a valueless variant
throws std::bad_variant_access, and std::visit
throws the same exception, preventing access to corrupted or
uninitialized memory.