Type Erasure VII — std::any
Previously:
- Type Erasure I — Core Logic
- Type Erasure II — std::function
- Type Erasure III — Trade-offs
- Type Erasure IV — ROS 2 Messages
- Type Erasure V — std::variant
- Type Erasure VI — dynamic_cast & RTTI
Part II showed type erasure of callables via std::function — _M_manager and _M_invoker hide the concrete lambda or function object behind one signature. Part V showed erasure of a closed stored-value set via std::variant<Ts...>. This part covers std::any — open-set erasure of stored values: one public type name at the call site, any copy-constructible T at each construction, recovered later via any_cast.
- Part I recap: same signature, bound at construction
std::anyas the interface- How erasure is achieved: two layers
- Binding at construction — where erasure completes
- Unified lifetime: destructor, copy, move
- The manager’s unified signature
- End-to-end trace
- Storage: small-buffer vs heap
any_castand the type tag- Comparing two
std::anyobjects - Open vs closed spectrum
- Trade-offs
- Summary
- References
Part I recap: same signature, bound at construction
From Part I — Core logic:
- Encapsulate type information in the implementation; remove it from the interface the caller sees.
- Binding completes at construction — after that, dispatch is fixed.
- Dispatch redirects through function pointers of the same signature.
std::any follows this model exactly. The public API always speaks std::any; construction binds a type-specific manager function; every later lifetime operation calls that manager through one unified function-pointer type.
std::any as the interface
std::any a = 42;
a = std::string{"hi"};
// Call sites only ever name std::any — not int, not string.
Unlike variant<int, string>, any does not enumerate allowed types in its name. Each assignment can store a different copy-constructible type. The active type is hidden until you any_cast — or until you compare manager addresses inside the library.
How erasure is achieved: two layers
| Layer | Who sees it | Signatures | Knows stored T? |
|---|---|---|---|
Public std::any API |
Call sites, vector<any>, function parameters |
Fixed: any(), any(T&&), ~any(), copy/move, reset(), emplace<> |
No |
_Manager_internal<T> / _Manager_external<T> |
libstdc++ header (instantiated per T at bind site) |
Per-T _S_create, _S_manage, _S_access |
Yes |
You write any a = my_circle; once. Every subsequent operation on a uses the same any member signatures. Whether a Circle, int, or string lives inside is invisible until recovery.
Public any API Erased object Per-T _Manager<T>
~any reset copy move _M_manager + _M_storage _S_create _S_manage _S_access
fixed signatures same layout always generated at bind site

Binding at construction — where erasure completes
When you write any a = Circle{...};, the compiler instantiates the converting constructor template once for Circle:
// libstdc++ — simplified
template<typename _Tp, typename _VTp = decay_t<_Tp>,
typename _Mgr = _Manager<_VTp>, ...>
any(_Tp&& __value)
: _M_manager(&_Mgr::_S_manage) // (1) bind manager — the type tag
{
_Mgr::_S_create(_M_storage, std::forward<_Tp>(__value)); // (2) real T ctor
}
Step by step:
_Manager<Circle>is chosen at compile time — picks_Manager_internal<Circle>(small buffer) or_Manager_external<Circle>(heap) from size, alignment, andis_nothrow_move_constructible._M_manager = &_Manager<Circle>::_S_manage— after this initializer, runtime identity is fixed; all later ops go through that one function address._S_createruns the real constructor — placement-new ofCircleinto_M_buffer, ornew Circle(...)stored in_M_ptr. Theanyconstructor body never namesCircle.
When the constructor returns, the caller holds an any. Binding is complete; the stored type is erased at the interface.
Empty any has _M_manager == nullptr; has_value() is false.
Unified lifetime: destructor, copy, move
Public members never branch on “is it an int or a string?” They always call _M_manager with an opcode. The switch that knows T lives inside _S_manage, monomorphized per type at the site that first constructed that any.
Destructor and reset
~any() { reset(); }
void reset() noexcept {
if (has_value()) {
_M_manager(_Op_destroy, this, nullptr);
_M_manager = nullptr;
}
}
Inside _Manager_internal<Circle>::_S_manage for _Op_destroy:
case _Op_destroy:
__ptr->~Circle(); // only here does ~Circle run
break;
~any() has a single signature and one code path. The contained type’s destructor lives in the type-specific _S_manage, reached only via _M_manager — parallel to virtual ~Base() dispatching to ~Derived(), without an inheritance hierarchy.
Copy and move
| Operation | Public any API (fixed) |
Dispatch | Type-specific work |
|---|---|---|---|
| Copy ctor | any(const any& other) |
_M_manager(_Op_clone, &other, &arg) |
copy- or placement-new T in destination |
| Move ctor | any(any&& other) |
_M_manager(_Op_xfer, &other, &arg) |
move-construct T, destroy source, clear source _M_manager |
operator=(T&&) |
builds temporary any, move-assigns |
reset old + bind new manager | destroy old T, construct new |
Copy constructor (simplified):
any(const any& __other) {
if (__other.has_value()) {
_Arg __arg;
__arg._M_any = this;
__other._M_manager(_Op_clone, &__other, &__arg);
} else {
_M_manager = nullptr;
}
}
_Op_clone in _Manager_internal<T>::_S_manage placement-news a copy of T into the destination buffer and copies the manager pointer. any’s copy ctor contains no T-specific logic.
The manager’s unified signature
Every stored type shares one function-pointer type:
enum _Op { _Op_access, _Op_get_type_info, _Op_clone, _Op_destroy, _Op_xfer };
void (*_M_manager)(_Op, const any*, _Arg*);
Each T gets its own _S_manage function with that signature but a body that knows T. This is Part I’s dispatch rule: redirection of function pointers, with the same signature.
| Mechanism | What is erased | Tag | Lifetime dispatch |
|---|---|---|---|
| Virtual | Derived behind Base& |
vtable pointer + slot | virtual dtor, copy (if defined) |
std::function |
callable type | _M_manager + _M_invoker |
_M_manager opcodes |
std::any |
stored value type | _M_manager address |
_M_manager opcodes |
std::variant |
active alternative | index() |
__do_visit / visit table |
Why one function + opcodes (not a vtable)? libstdc++ keeps any compact — typically 16 bytes on 64-bit: one manager pointer plus a storage word/buffer. Move, copy, and destroy always need at least one indirect call; a single _S_manage with opcodes avoids a second pointer while preserving the unified-signature pattern.
End-to-end trace
std::any x = 42;
// Bind _Manager<int>, SBO: placement-new int in _M_buffer.
// _M_manager = &_Manager<int>::_S_manage
x = std::string{"hi"};
// operator=(T&&) → any(std::string{...}) temporary
// reset on x: _M_manager(_Op_destroy) → ~int
// bind _Manager<string>, construct string
// move-assign transfers _M_manager + storage
std::any y = x;
// y's copy ctor: x._M_manager(_Op_clone, &x, &arg)
// placement-new copy of string in y._M_buffer
// ~y, ~x: each _Op_destroy on string / empty
At no point does generic any code contain if (stored int) … else if (stored string). Each object’s _M_manager already points at the correct monomorphized handler.
Storage: small-buffer vs heap
Object layout (libstdc++):
std::any
_M_manager void (*)(_Op, const any*, _Arg*)
_M_storage union { void* _M_ptr; unsigned char _M_buffer[sizeof(void*)]; }
_Manager_internal<T> (SBO) when sizeof(T) <= sizeof(_Storage), alignof(T) <= alignof(_Storage), and is_nothrow_move_constructible_v<T>:
static void _S_create(_Storage& __storage, _Up&& __value) {
void* __addr = &__storage._M_buffer;
::new (__addr) _Tp(std::forward<_Up>(__value));
}
_Manager_external<T> otherwise — __storage._M_ptr = new _Tp(...).
Same small-object idea as std::function: avoid heap allocation for tiny, nothrow-movable types.
any_cast and the type tag
Recovery at the call site names T in the cast, not in the container type:
auto& s = std::any_cast<std::string&>(x);
libstdc++ __any_caster<T>:
- Primary (works without RTTI): compare
_M_manager == &any::_Manager<U>::_S_manage— function pointer equality as type tag. - Fallback (when
__cpp_rtti):__any->type() == typeid(T).
Then _Manager<U>::_S_access(_M_storage) returns T*. Mismatch throws bad_any_cast.
any::type() uses _Op_get_type_info when RTTI is enabled — links to Type Erasure VI — dynamic_cast & RTTI. The primary cast path does not need type_info; the manager address is the tag.
Contrast Part V: variant uses index() into a closed table; any uses manager address into an open per-T handler generated at each construction site.
Comparing two std::any objects
std::any provides no operator== (C++17 through C++26). Binding hides the stored type from lifetime APIs (~any, copy, move); it does not install a type-erased value comparison — there is no second function-pointer table analogous to std::function’s _M_invoker. Comparing two anys is a two-step problem you implement explicitly: same type? then same value?
Each object carries its own tag
After construction, a and b bind independently:
any a = 42; any b = std::string{"hi"};
_M_manager → &_Manager<int>::… _M_manager → &_Manager<string>::…
_M_storage → [ int ] _M_storage → [ string ]
The manager address is per type T in the program, not per instance — two anys both holding int share the same _M_manager value. Nothing links a and b automatically; you compare their tags yourself.
Step 1 — compare types
Use the public observer when RTTI is available:
if (!a.has_value() && !b.has_value())
; // both empty — equivalent state
else if (a.type() == b.type())
; // same stored type (check has_value separately for empty)
a.type() == b.type() compares type_info identity (Type Erasure VI — dynamic_cast & RTTI) — same mechanism any_cast uses as fallback. Empty any reports typeid(void); two empty anys have matching types.
Without RTTI at the call site, the implementation still compares manager pointers inside any_cast; for two anys you could compare has_value() and rely on casting (below), but type() is the portable public API when enabled.
Step 2 — compare values (you name T)
any_cast inspects one any at a time — it checks whether that object’s tag matches the T you named. It does not look at a second operand.
// Known both hold int (types matched above):
if (std::any_cast<int>(a) == std::any_cast<int>(b)) { /* ... */ }
// Safer — pointer overload, no exception:
if (const int* pa = std::any_cast<int>(&a))
if (const int* pb = std::any_cast<int>(&b))
if (*pa == *pb) { /* ... */ }
You must name T at the call site — the same static typing rule as everywhere else in C++. If you do not know which T might be stored, there is no single library helper: try a chain of any_cast attempts, a visitor, or use variant<Ts...> when the set is closed (Part V).
What erasure does and does not hide
| Question | Mechanism | Needs T at call site? |
|---|---|---|
Same stored type in a and b? |
a.type() == b.type() (or equal _M_manager) |
No |
| Equal contained values? | any_cast<T>(a) vs any_cast<T>(b) after type match |
Yes |
Destroy / copy / move one any? |
_M_manager(_Op_…) |
No |
Lifetime stays fully erased behind the unified manager signature. Value equality is deliberately not erased — unlike std::function, which adds _M_invoker to call through a fixed signature, std::any stops at storage and recovery.
Open vs closed spectrum
| Virtual | std::any |
std::variant |
std::function |
|
|---|---|---|---|---|
| What is erased | Derived type |
stored value type | active alternative | callable type |
| Open vs closed | Open (inheritance) | Open (any copy-constructible T) |
Closed (variant<Ts...>) |
Open (any matching callable) |
| Runtime tag | vtable | _M_manager address |
index() |
_M_manager + _M_invoker |
| Recovery | dynamic_cast / virtual call |
any_cast |
std::visit / get |
operator() |
std::any is the value counterpart to std::function: Part II erases behavior (call signature fixed); Part VII erases payload (no call — storage and lifetime only).
Trade-offs
- Copy-only contents —
Tmust be copy-constructible; noanyof references or arrays (cppreference constraints). - Heap fallback — large or throwing-move types allocate; SBO is an optimization, not a guarantee.
type()needs RTTI — when disabled, rely on manager-pointer equality forany_cast.- No visitation protocol — unlike
variant/visitor virtual Visitor; you recover withany_castor comparetype(). Nooperator==on twoanys — see Comparing twostd::anyobjects.
See Type Erasure III — Trade-offs for general type-erasure costs.
Summary
std::anyis type erasure — uniform public interface, binding at construction, dispatch through_M_managerwith unified signature and type-specific_S_managebody.- Two layers — call sites see fixed
anysignatures; per-T_Manager<T>hides ctor, dtor, copy, and move behind opcodes. - Constructor binds
_M_manager = &_Manager<T>::_S_manageand_S_create; after return, stored type is erased at the interface. ~any/ copy / move never branch on stored type; they call_M_manager(_Op_destroy | _Op_clone | _Op_xfer, …).- Open set — any copy-constructible
Tper construction; contrast closedvariant<Ts...>in Part V. - No built-in value comparison — no
operator==; compare types viatype(), then values viaany_cast<T>after namingT. - Same manager pattern as Part II — but erases value, not callable; recovery via
any_cast(manager address ≈ tag). - Series synthesis: Type Erasure VIII — Final Thoughts.