Type Erasure V — std::variant
Previously:
- Type Erasure I — Core Logic
- Type Erasure II — std::function
- Type Erasure III — Trade-offs
- Type Erasure IV — ROS 2 Messages
Part I introduced type erasure through virtual inheritance and summarized std::variant in the same terms. This part develops that summary in full: std::variant<Ts...> is type erasure — the same core mechanism as virtual inheritance on an inheritance hierarchy: one interface at the use site, runtime tag, redirect table, binding fixed at construction. What differs is encoding, not the erasure model. The key difference from virtual: closed alternative set (variant lists every Ti in the type) vs open set (new Derived can be added elsewhere).
- Part I recap: one mechanism
variant<Ts...>as the interface- Binding at construction
- Runtime dispatch: index ≈ vtable tag, table ≈ vtable entries
std::visitas an operation table- The key difference: open vs closed
- Comparison with Part I virtual erasure
- What this is not
- Summary
- References
Part I recap: one mechanism
From Part I — Core logic:
- Encapsulate type information in the implementation; remove it from the interface the caller sees.
- Binding completes at construction (or compile time for templates).
- Dispatch at runtime redirects through function pointers (vtable, visit table, or similar).
Virtual inheritance fits this model: call sites hold Shape&; the concrete Circle or Rectangle is hidden until the vtable resolves the override. variant<int, string> fits the same model: call sites hold variant<int, string>; the active int or string is hidden until index() and the redirect table pick the handler.

variant<Ts...> as the interface
At a variable declaration you write variant<int, std::string>, not int or string separately. That type name is the uniform interface — analogous to Shape& on an open hierarchy, except the allowed alternatives are enumerated in the template parameter pack.
std::variant<int, std::string> v = 42;
// Caller type: variant<int, string> — active alternative erased until dispatch.
// v.index() == 0 → int is live inside the union buffer.
Think of variant<A, B, C> as the base type name and A, B, C as the alternatives (like derived types). All are known when you compile. Runtime never adds a fourth type; it only records which slot is active.
Binding at construction
When you write v = 42 or v = "hello" or v.emplace<std::string>(...), the implementation:
- Destroys the previous active member (if any).
- Placement-news the new
Tinto the union slot for that alternative. - Sets
_M_indexto the compile-time-known index ofT.
After that, binding is fixed until the next assignment — parallel to constructing a Circle object whose dynamic type is fixed behind a Shape& reference.
Runtime dispatch: index ≈ vtable tag, table ≈ vtable entries
Tag: index() (libstdc++: _M_index) records which alternative is live — the same role as reading which vtable slot / dynamic type applies.
Table: std::visit, destruction, copy, and move use compile-generated function-pointer tables (__do_visit, _S_vtable of __visit_invoke thunks) to jump to the correct Ti handler.
// libstdc++ — simplified destruction path
std::__do_visit([](auto&& mem) {
std::_Destroy(std::addressof(mem));
}, __variant_cast<_Types...>(*this));
Explicit mapping:
| Virtual (open) | variant (closed) |
|---|---|
| vptr → vtable | _M_index |
vtable[i] → Circle::draw |
table[index] → __visit_invoke → f(int&) or f(string&) |
Binding at Circle construction |
Binding at v = 42 or v = "hi" |

The visit “vtable” is not type-erasing your callable — visit is a template instantiated for your exact lambda type. The stored value is what is erased behind variant<Ts...>; the table selects how to reach the active Ti&.
std::visit as an operation table
Each std::visit(callable, v) call site is like a new visitor derived class:
- The callable must handle every alternative (exhaustive at compile time).
- The library generates a handler table for this
(callable, variant<Ts...>)pair. - Runtime reads
index()and invokes the branch for the activeTi.
std::variant<int, std::string> v = /* ... */;
std::visit([](auto&& x) {
using T = std::decay_t<decltype(x)>;
if constexpr (std::is_same_v<T, int>) { /* print */ }
else { /* print string */ }
}, v); // one "PrintVisitor" monomorphization
std::visit([](auto&& x) { /* hash */ }, v); // another operation table
Axis 2 of classic double dispatch (which operation runs) is realized by separate call sites with different callables, not by a second runtime vtable on a shared Visitor&. See the double-dispatch companion post for libstdc++ internals and comparison with the virtual Visitor pattern.
The key difference: open vs closed
Same type-erasure mechanism. Different extensibility:
| Virtual (open) | variant (closed) |
|
|---|---|---|
| Who declares alternatives | Base + protocol; new Derived in other TUs/libraries |
Author lists every Ti in variant<Ts...> |
| Call-site interface | Shape& — does not enumerate derived types |
variant<A,B,C> — enumerates all allowed types |
| Add a new stored type | New class + (for Visitor) new visitXxx on base |
Change to e.g. variant<int, string, double> + recompile |
| Add a new operation | New Visitor subclass |
New std::visit(callable, v) call site |
| Stored value (open, no inheritance) | — | std::any — manager pointer tag, any copy-constructible T |
Virtual suits stable protocols where stored types grow in plugins (compilers, scene graphs). variant suits when you know the full closed set upfront and want tag+table erasure without inheritance or vtables on the element type.
Neither pattern introduces types at runtime that were absent from the build. Both select among compile-time-known alternatives.
Comparison with Part I virtual erasure
| Concept | Virtual (Part I) | variant (this part) |
|---|---|---|
| Uniform interface | Shape&, Visitor& |
variant<A,B,C> |
| Runtime tag | vtable pointer + slot | index() |
| Redirect table | vtable | _S_vtable / __do_visit thunks |
| Binding fixed | object construction | variant construction / assign / emplace |
| Alternative set | Open | Closed |
| Type erasure? | Yes | Yes — same core |
What this is not
- Not RTTI — no
type_infoordynamic_cast; see Type Erasure VI — dynamic_cast & RTTI for identity recovery on open hierarchies. - Not erasing the visit callable — unless you wrap it in
std::functionyourself;visitmonomorphizes your lambda type. - Not runtime typing — every
Tiis listed invariant<Ts...>before the program runs.
Summary
std::variant<Ts...>is type erasure — one interface type, active alternative hidden, recovered via index + function table, binding at construction.- Same mechanism as virtual — tag (index ≈ vtable slot), table (visit/lifetime thunks ≈ vtable entries), uniform interface at the use site.
- Key difference: open vs closed — virtual lets users add
Derivedelsewhere;variantrequires every possible type in the template list. - Each
std::visit(callable, v)≈ a new visitor class with its own handler table, selected byindex(). - Double dispatch on a closed set: Double Dispatch with std::variant and std::visit. Open stored-value erasure: Type Erasure VII — std::any. RTTI recovery on open hierarchies: Part VI.