Most data has shape: a JSON value is a null, a bool, a number, a string, an
array or an object; an expression is a number, a variable, or an operation on
sub-expressions. Classic C++ modelled "one of several kinds" with a base class
and virtual functions, held through pointers. Modern C++ gives you two
better-fitting tools:
std::variant— a closed set of alternatives, stored inline, with visitors the compiler checks for exhaustiveness.std::polymorphic(C++26) — an open class hierarchy with value semantics: copy it and you get a deep copy, noclone()boilerplate.
And for recursive data, std::indirect (C++26) is a heap-allocated value
that copies deeply, so a variant can contain "itself".
1. std::variant: one of a fixed set
A std::variant<A, B, C> holds exactly one value of one of its alternatives,
inline (no heap allocation). Its size is roughly the largest alternative plus
a small index.
#include <print>
#include <string>
#include <variant>
int main() {
std::variant<int, double, std::string> v = 42; // holds int
std::println("index {} holds int? {}", v.index(), std::holds_alternative<int>(v));
v = std::string("hello"); // now holds string
std::println("index {} value {}", v.index(), std::get<std::string>(v));
if (auto* d = std::get_if<double>(&v)) // pointer or nullptr
std::println("double {}", *d);
else
std::println("not a double");
try {
std::get<int>(v); // wrong alternative
} catch (const std::bad_variant_access& e) {
std::println("get<int> threw: {}", e.what());
}
}std::string), shared by all three, plus a 1-byte index saying which one is alive: 0 int, 1 double, 2 string.The access functions, from safest to sharpest:
| Function | Wrong alternative |
|---|---|
std::holds_alternative<T>(v) |
returns false |
std::get_if<T>(&v) |
returns nullptr — check it before dereferencing |
std::get<T>(v) / std::get<I>(v) |
throws std::bad_variant_access |
std::visit(f, v) / v.visit(f) |
can't happen: f is called with whatever is held |
A default-constructed variant holds a value-initialised first alternative.
If the first alternative isn't default-constructible, or you want an explicit
"empty" state, put std::monostate first.
Which alternative does v = x pick?
The converting constructor picks the alternative overload resolution would
pick, but since C++20 it excludes narrowing conversions (and pointer→bool).
So std::variant<std::string, bool> v = "hi"; holds the std::string — in
C++17 it famously held bool, because const char* → bool is a standard
conversion and beat the user-defined conversion to std::string.
2. Visiting: one function per alternative
std::visit(f, v) calls f with the held value — f must be callable with
every alternative, or the program doesn't compile. That's the payoff: when
you add an alternative, every visitor that doesn't handle it becomes a compile
error, pointing you at each place that needs updating.
The idiomatic visitor is a set of lambdas glued together with the
overloaded helper:
#include <format>
#include <print>
#include <string>
#include <variant>
template <class... Fs>
struct overloaded : Fs... {
using Fs::operator()...;
};
using Token = std::variant<int, char, std::string>;
std::string describe(const Token& t) {
return std::visit(overloaded{
[](int n) { return std::format("number {}", n); },
[](char op) { return std::format("operator '{}'", op); },
[](const std::string& name) { return std::format("name {}", name); },
}, t);
}
int main() {
for (Token t : {Token{7}, Token{'+'}, Token{std::string("x")}})
std::println("{}", describe(t));
}[](const auto&) catch-all quietly matches anything new.overloaded{...} is an aggregate whose bases are the lambdas' closure types;
the using declaration pulls every operator() into one overload set. (No
deduction guide is needed since C++20.) C++26 adds member visit, so
t.visit(overloaded{...}) reads left to right.
The catch-all trap. A generic lambda [](const auto&) { ... } matches
every type. It's handy for "everything else", but it switches off the
exhaustiveness check: add an alternative later and the catch-all silently
handles it. Use catch-alls deliberately, not as a habit.
#include <string>
#include <variant>
template <class... Fs>
struct overloaded : Fs... { using Fs::operator()...; };
int main() {
std::variant<int, double, std::string> v = 1;
// No overload accepts std::string: std::visit fails to compile.
return std::visit(overloaded{
[](int) { return 1; },
[](double) { return 2; },
}, v);
}The conversion trap. Exhaustiveness only catches alternatives that no
overload can accept. Implicit conversions count: with
std::variant<int, double, char>, the lambdas [](int) and [](double)
compile fine — char silently goes to the int lambda. Name each
alternative's exact type in its lambda, and be suspicious of numeric
alternatives that convert into each other.
Every overload must return the same type (or you give std::visit<R> an
explicit return type).
3. Closed set or open set?
The same problem — "a shape is a circle or a rectangle" — has two designs, and they're good at opposite things (the expression problem):
std::variant (closed) |
virtual functions (open) | |
|---|---|---|
| Add a new operation | easy: write one more visitor | touch every class |
| Add a new type | touch every visitor (the compiler finds them) | easy: one more subclass |
| Storage | inline, value semantics | usually heap, through a pointer |
| Who can extend it | only the owner of the variant type | anyone who can derive |
| Exhaustiveness checked | yes, at compile time | no (a forgotten override uses the base) |
Use a variant for data whose set of kinds you own and rarely changes (tokens, AST nodes, messages, states). Use a hierarchy when other code must add kinds (plugins, user extensions).
4. Recursion: std::indirect
An expression contains sub-expressions. You can't write
using Expr = std::variant<double, Add>;
struct Add { Expr lhs, rhs; }; // Add contains Expr contains Add ... infinite sizeThe recursion has to go through the heap. std::unique_ptr<Expr> breaks it,
but it also breaks value semantics: the type is no longer copyable, and
== compares pointers. std::indirect<T> (C++26) is a heap-allocated T
that behaves like a T:
- copying copies the pointee (deep copy);
==and<=>compare the pointees;*and->access it; there is no null state in normal use;- after being moved from it is valueless (
valueless_after_move()), and must only be assigned to or destroyed.
Its T may be incomplete where it's declared, which is exactly what recursive
data needs.
Worked example: an expression tree
#include <memory>
#include <print>
#include <string>
#include <variant>
template <class... Fs>
struct overloaded : Fs... { using Fs::operator()...; };
struct Expr;
struct Num { double value; };
struct Add { std::indirect<Expr> lhs, rhs; };
struct Mul { std::indirect<Expr> lhs, rhs; };
// A struct wrapping the variant lets Add/Mul refer to Expr before it's complete.
struct Expr {
std::variant<Num, Add, Mul> node;
};
Expr num(double v) { return Expr{Num{v}}; }
Expr operator+(Expr a, Expr b) { return Expr{Add{std::indirect<Expr>(std::move(a)), std::indirect<Expr>(std::move(b))}}; }
Expr operator*(Expr a, Expr b) { return Expr{Mul{std::indirect<Expr>(std::move(a)), std::indirect<Expr>(std::move(b))}}; }
double eval(const Expr& e) {
return e.node.visit(overloaded{
[](const Num& n) { return n.value; },
[](const Add& a) { return eval(*a.lhs) + eval(*a.rhs); },
[](const Mul& m) { return eval(*m.lhs) * eval(*m.rhs); },
});
}
int main() {
Expr e = num(2) * (num(3) + num(4));
Expr copy = e; // deep copy: every node duplicated
std::get<Mul>(copy.node).lhs->node = Num{10};
std::println("{} {}", eval(e), eval(copy)); // the original is untouched
}std::indirect<Expr>: a separate heap object. Copying e copies all five nodes, so changing the copy's left operand to 10 leaves e alone: eval(e) = 14, eval(copy) = 70.Read it as the type definitions say: an Expr is a number, a sum or a
product. eval is one function with one case per kind — the shape of the code
follows the shape of the data. The copy at the end is the value-semantics win:
no aliasing, no clone().
5. Open hierarchies with value semantics: std::polymorphic
When the set of kinds must stay open, you still don't have to give up value semantics. The pre-C++26 pattern was
struct Shape {
virtual ~Shape() = default;
virtual std::unique_ptr<Shape> clone() const = 0; // every subclass repeats this
};
std::vector<std::unique_ptr<Shape>> shapes; // not copyable without a loop of clone()std::polymorphic<Base> owns a heap object of Base or any class
derived from it, and copying it copies the dynamic type — it remembers
how to copy a Circle even though you only see a Shape. So a
std::vector<std::polymorphic<Shape>> is copyable as-is, and a class holding
one follows the rule of zero.
#include <memory>
#include <numbers>
#include <print>
#include <vector>
struct Shape {
virtual ~Shape() = default;
virtual double area() const = 0;
virtual void scale(double k) = 0;
};
struct Circle : Shape {
double r;
explicit Circle(double r) : r(r) {}
double area() const override { return std::numbers::pi * r * r; }
void scale(double k) override { r *= k; }
};
struct Square : Shape {
double side;
explicit Square(double s) : side(s) {}
double area() const override { return side * side; }
void scale(double k) override { side *= k; }
};
int main() {
std::vector<std::polymorphic<Shape>> shapes;
shapes.emplace_back(std::in_place_type<Circle>, 1.0);
shapes.emplace_back(std::in_place_type<Square>, 2.0);
auto copy = shapes; // deep copy of each Circle and Square
for (auto& s : copy) s->scale(10);
std::println("original square {}, scaled copy {}", shapes[1]->area(), copy[1]->area());
}auto copy = shapes; creates a new Circle and a new Square — no clone() anywhere — so scaling the copies leaves the originals unchanged.Construct with std::in_place_type<Derived> and the constructor arguments.
Like std::indirect, a moved-from std::polymorphic is valueless.
Faded example — your turn
Scene below holds shapes. Make Scene copyable with deep copies, without
writing a copy constructor. What should shapes_ be? (Hint: §5.)
class Scene {
public:
void add_square(double side) { /* ??? */ }
double total_area() const { /* sum of areas */ }
private:
/* ??? */ shapes_;
};Answer
std::vector<std::polymorphic<Shape>> shapes_; with
shapes_.emplace_back(std::in_place_type<Square>, side); — the defaulted copy
constructor copies the vector, which copies each polymorphic, which copies
the dynamic type.
6. valueless_by_exception
Assigning a new alternative destroys the old value and constructs the new one
in place. If that construction throws, the variant has nothing to hold: it
becomes valueless by exception (v.valueless_by_exception() is true,
v.index() is std::variant_npos), and visiting it throws
std::bad_variant_access. Implementations avoid this when they can (e.g. by
building a temporary first when the move is noexcept), so in practice you
only see it with alternatives whose construction can throw and whose moves
can throw.
7. And std::any?
std::any holds a value of any copyable type, decided at run time, and
you get it back with std::any_cast<T>, naming the exact type. It's an escape
hatch for "I genuinely don't know the type" (plugin property bags, type-erased
user data). It gives you no exhaustiveness, no visiting and no idea what's
inside without guessing. If you can list the possible types, use a variant.
Summary
std::variant<Ts...>= one of a closed set, stored inline. Access withget_if(nullable),get(throws) or — best — a visitor.std::visitwithoverloaded{lambdas...}is checked for exhaustiveness at compile time; generic catch-all lambdas switch that check off.- Closed set of kinds → variant; open set → class hierarchy.
- Recursive data →
std::indirect<T>: heap storage with value semantics (deep copy, value comparison). - Polymorphic values →
std::polymorphic<Base>: copies the dynamic type, so containers of shapes are copyable withoutclone(). - Moved-from
indirect/polymorphicare valueless: assign to them or let them die, nothing else.