C and pre-C++17 code modelled “this value is one of several types” with a union and a separate tag — a struct holding the tag, a union holding the payload, and a discipline that the two stay in sync. Forget to update the tag, read the wrong member, or copy a non-trivial type through the union, and the program is in undefined-behaviour territory before any tool can warn you. C++17 introduced std::variant, a tagged union that the language itself keeps honest. This Nibble covers what variant actually buys you, how to read it without dynamic_cast, and where it sits next to inheritance.
Three things to take away:
std::variant<A, B, C>holds exactly one of its alternatives, with the tag managed by the language — no manual bookkeeping.- Read it with
std::visitand a callable; reach forstd::getorstd::get_ifonly when you genuinely care about a single alternative. - Prefer
variantfor closed sets of unrelated types; prefer inheritance for open-ended hierarchies of related ones.
The problem with raw unions
A C-style tagged union splits the responsibility for correctness between the programmer and the compiler:
enum class Kind { Int, Double, String };
struct Value {
Kind kind;
union {
int i;
double d;
std::string s; // not even legal pre-C++11 without manual ctors
};
};
Three problems, all unfixable from inside the union:
- Nothing checks the tag. You can write
v.iwhenkindisDoubleand the compiler will let you. - Non-trivial types break the union.
std::stringhas a constructor and destructor; a raw union won’t call them, so the string leaks or corrupts memory unless you write placement-new and explicit destructor calls yourself. - The tag and the payload can drift. Set
i = 7but forget to setkind = Kind::Int, and every reader sees the wrong type.
std::variant solves all three.
What std::variant is
std::variant<Ts...> is a type-safe tagged union. It holds exactly one value of one of its alternative types at any time, knows which one, and runs the right constructors and destructors when the held type changes:
#include <variant>
#include <string>
std::variant<int, double, std::string> v;
v = 42; // now holds int
v = 3.14; // int destroyed (trivially), now holds double
v = std::string{"hello"}; // double destroyed, now holds string
// (when v goes out of scope, ~string runs)
The index of the active alternative is queryable with v.index(), but you rarely need it directly. The two operations you actually reach for are std::visit and std::get_if.
Reading a variant: std::visit
std::visit takes a callable and a variant, then dispatches to the overload matching whatever the variant currently holds. The compiler checks that your callable handles every alternative — miss one and the code does not compile.
#include <iostream>
#include <variant>
#include <string>
using Value = std::variant<int, double, std::string>;
void print(const Value& v){
std::visit([](const auto& x) {
std::cout << x << '\n';
}, v);
}
The generic lambda works for all three alternatives because int, double, and std::string all stream into std::cout. When the behaviour differs per type, write an overload set explicitly:
struct Describe {
void operator()(int i) const{ std::cout << "int: " << i << '\n'; }
void operator()(double d) const{ std::cout << "double: " << d << '\n'; }
void operator()(const std::string& s) const{ std::cout << "string: " << s << '\n'; }
};
void describe(const Value& v){
std::visit(Describe{}, v);
}
Add a fourth alternative to Value and forget to add a fourth operator() — the compiler refuses to compile the visit call. This is the single biggest practical win over raw unions: the exhaustiveness check is mechanical.
When you only care about one alternative: get_if
Sometimes you don’t want to handle every case — you want to ask “is it the string?” and act only if it is. std::get_if returns a pointer to the held value if the variant currently holds the requested type, or nullptr otherwise:
if (const auto* s = std::get_if<std::string>(&v)) {
std::cout << "got a string of length " << s->length() << '\n';
}
This is the idiomatic single-alternative check. Avoid std::get<T> unless you can prove the variant holds T — it throws std::bad_variant_access if it doesn’t, and a get_if check is both cheaper and less surprising.
A worked example: a tiny expression tree
variant shines for closed sets of unrelated types. A small arithmetic expression tree is a clean fit:
#include <memory>
#include <variant>
struct Add;
struct Mul;
using Expr = std::variant<int, std::unique_ptr<Add>, std::unique_ptr<Mul>>;
struct Add { Expr lhs, rhs; };
struct Mul { Expr lhs, rhs; };
int eval(const Expr& e){
return std::visit([](const auto& node) -> int {
using T = std::decay_t<decltype(node)>;
if constexpr (std::is_same_v<T, int>) {
return node;
} else {
return (node->lhs.index() ? eval(node->lhs) : 0) // simplified
+ (node->rhs.index() ? eval(node->rhs) : 0);
}
}, e);
}
The eval function never asks “what kind of node is this?” and never reaches for dynamic_cast. Adding a new node type means adding a new alternative to Expr and a new branch in the visit — and the compiler tells you if you forget the latter.
Takeaway
std::variant is the modern C++ answer to “this value is one of several types,” and it earns its keep by moving the bookkeeping from the programmer into the language. Hold a closed set of alternatives in a variant, read it with std::visit to get a compile-time exhaustiveness check, and use std::get_if for single-alternative probes. Reach for inheritance when the set is open and dispatch happens through a shared interface; reach for variant when the set is closed and the cases have nothing in common except the slot they share. The rule is simple: closed sets of unrelated types belong in a variant, not a union and not a hierarchy.