std::function can hold a lambda, a raw function pointer, or a functor object with no shared base class between any of them. The mechanism that makes that possible is type erasure, and it is not compiler magic: it is a pattern you can write by hand in about twenty lines.
The Three Components
A type-erased wrapper stores three things:
- A
void*pointing to the actual object on the heap - Function pointers for every operation the interface requires (call, destroy, copy)
- A template constructor that instantiates those function pointers for the exact type being stored
class Shape {
void* data;
void (*draw_fn)(const void*);
void (*destroy_fn)(void*);
public:
template<typename T>
Shape(T obj)
: data(new T(std::move(obj)))
, draw_fn([](const void* p) { static_cast<const T*>(p)->draw(); })
, destroy_fn([](void* p) { delete static_cast<T*>(p); })
{}
void draw() const { draw_fn(data); }
~Shape() { destroy_fn(data); }
};
Shape itself is never a template. Every instance of Shape has the identical type. The template only appears in the constructor, and that is the part that makes the whole thing work.
Why the Template Constructor Is the Key
When Shape(Circle{}) is called, the compiler instantiates the constructor for T = Circle. The lambdas inside it are also instantiated for Circle: draw_fn becomes a function that knows to cast the void* back to Circle* and call Circle::draw(). When Shape(Square{}) is called, a completely different set of function pointers is generated for Square.
The function pointer targets are type-specific. The wrapper that holds them is not. A Shape built from a Circle and a Shape built from a Square are the same type at the C++ level, can be stored in the same array, and called through the same interface, but carry different function pointers pointing at type-specific code generated by the template constructor.
This is what “type erasure” means literally: the type information (Circle, Square) was used at construction time to generate the right function pointers, and then it was erased. The wrapper type says nothing about what it is holding.
The Codegen Proof: No Vtable, One Indirect Call
The binary confirms the mechanism two ways.
nm main | grep -i vtable returns nothing. Shape never uses the virtual keyword, so the compiler generates no vtable symbol anywhere in the binary. There is no hidden pointer, no compiler-maintained dispatch table.
nm main | c++filt | grep -i shape returns four distinct lambda symbols: Shape::Shape<Circle>::{lambda...}::_FUN and Shape::Shape<Square>::{lambda...}::_FUN, two per type. These are the function pointer targets the template constructor generated, one set for each type the wrapper was instantiated with.
The disassembly of Shape::draw() at -O0 (to keep the function readable rather than inlined away) shows the call mechanism directly:
Shape::draw() const:
...
mov rdx, QWORD PTR [rax+0x8] ; load the stored function pointer
mov rax, QWORD PTR [rax] ; load the stored void*
mov rdi, rax
call rdx ; indirect call through register
...
The function pointer is loaded from [rax+0x8] (the second member, draw_fn) and called through rdx. Not a call to a fixed address. The same indirect-through-register pattern a vtable call would produce, but without the vtable. The dispatch mechanism is equivalent at the hardware level; the machinery that builds it is entirely different.
Type Erasure vs Virtual Dispatch: the Actual Difference
Both type erasure and virtual dispatch pay the same runtime cost: an indirect call through a function pointer stored in memory. From the CPU’s perspective, call rdx loaded from [rax+offset] is the same instruction whether rdx came from a vtable or from a type-erased wrapper.
The structural difference is where the function pointers live. A vtable is a static table shared by all instances of the same type. Each object holds one pointer to that table. A type-erased wrapper stores function pointers per-instance, inside the object itself. Each instance carries its own pointer set.
This means:
- Virtual dispatch is slightly more memory-efficient per object (one pointer to a shared table vs multiple inline pointers)
- Type erasure does not require inheritance, does not require the
virtualkeyword, and works with types that were never designed to participate in a class hierarchy
The practical implication: type erasure is the right tool when the types you want to unify behind a common interface do not share a base class and you do not control their source, third-party types, lambdas, function pointers. Virtual dispatch is simpler when you control the types and can add a common base class.
The Copy Problem: What This Implementation Is Missing
The Shape above has no copy constructor. If you try to copy a Shape, the compiler will either delete the copy constructor or generate one that copies the void*, leaving two Shape objects pointing to the same heap allocation. The destructor will delete it twice. That is undefined behavior.
Fixing this requires a fourth function pointer: a copy operation.
void* (*copy_fn)(const void*);
// in the template constructor:
, copy_fn([](const void* p) -> void* {
return new T(*static_cast<const T*>(p));
})
// in a copy constructor:
Shape(const Shape& other)
: data(other.copy_fn(other.data))
, draw_fn(other.draw_fn)
, destroy_fn(other.destroy_fn)
, copy_fn(other.copy_fn)
{}
The copy function captures the exact type T, so it can correctly copy-construct the stored object. This is the minimum to make the wrapper value-semantic. Production implementations, including std::function, carry this copy function internally.
Small Buffer Optimization: How std::function Avoids Heap Allocation
The Shape implementation above allocates on the heap for every stored object, even trivially small ones like a lambda capturing a single int. std::function avoids this for small callables through a small buffer optimization (SBO): an inline byte array inside the std::function object itself.
If the callable fits within the buffer (typically 16 bytes on most implementations), it is stored directly in the buffer with placement new. The void* stored in the wrapper points into the wrapper itself rather than the heap. No allocation happens.
If the callable is too large, the implementation falls back to heap allocation. The function pointers the template constructor generates vary: for small types they construct and destroy in the inline buffer, for large types they allocate and free on the heap. This detail is opaque to the caller but visible in profiling: passing a large capturing lambda into std::function allocates.
The SBO is why std::function is not trivially copyable and why its size is larger than a bare function pointer. The inline buffer has to be part of the std::function object itself. On most standard library implementations, sizeof(std::function<void()>) is 32 bytes on 64-bit platforms.
std::function, std::any, and std::move_only_function
std::function and std::any both use type erasure but erase different things.
std::function<R(Args...)> erases the callable type but preserves the call interface. The behavioral contract is: callable with Args..., returns R. The stored type must satisfy that contract, but can be any type that does.
std::any erases everything. It stores any type at all with no behavioral requirement. The only way to get the value back is std::any_cast<T>, which checks the stored type and returns a reference or throws std::bad_any_cast. There is no operation you can call on a std::any without knowing the type first.
std::move_only_function (C++23) is std::function without the copy requirement. A std::function requires that the stored callable be copyable, which excludes lambdas capturing move-only types like std::unique_ptr. std::move_only_function drops the copy function pointer and therefore accepts any callable, including ones that cannot be copied.
When Not to Use Type Erasure
Type erasure costs an indirect call on every operation, and for small callables stored on the heap it also costs an allocation and a cache miss. For hot paths called millions of times per second, this matters.
When the set of concrete types is known at compile time, CRTP or deducing this eliminates the indirection entirely. The call is direct and inlinable. Type erasure is the right choice when the concrete type is genuinely not known at compile time, when you need to store heterogeneous types in the same container, or when the callable comes from outside the codebase and cannot be made part of a class hierarchy.
std::variant with std::visit is a middle ground: the set of types is fixed and known at compile time (like CRTP), but they can be stored in the same variable and dispatched at runtime (like type erasure). For a bounded set of types, std::variant is often faster than type erasure because the visitor dispatch can be implemented with a jump table rather than an indirect function pointer call.
Run: main.cpp
g++ -O2 -std=c++26 main.cpp -o main
./main
Expect Circle::draw followed by Square::draw.
Run: confirm no vtable exists
nm main | grep -i vtable
nm main | c++filt | grep -i shape
Expect no output from the vtable check. Expect four distinct lambda function symbols from the shape check, two per type.
Run: confirm the indirect call
g++ -O0 -std=c++26 -c main.cpp -o main_o0.o
objdump -d -M intel --no-show-raw-insn main_o0.o | c++filt | grep -A 16 "^0000000000000000 <Shape::draw"
-O0 keeps the function un-inlined. Expect the disassembly to load the stored function pointer and call through a register.
Output
$ ./main
Circle::draw
Square::draw
$ nm main | grep -i vtable
(no output)
$ nm main | c++filt | grep -i shape
00000000000012b0 W Shape::Shape<Circle>(Circle)::{lambda(void const*)#1}::_FUN(void const*)
0000000000001270 W Shape::Shape<Circle>(Circle)::{lambda(void*)#1}::_FUN(void*)
00000000000012d0 W Shape::Shape<Square>(Square)::{lambda(void const*)#1}::_FUN(void const*)
0000000000001290 W Shape::Shape<Square>(Square)::{lambda(void*)#1}::_FUN(void*)
Two complete, separate sets of function pointer targets per type.
$ objdump -d -M intel --no-show-raw-insn main_o0.o | c++filt | grep -A 16 "^0000000000000000 <Shape::draw"
0000000000000000 <Shape::draw() const>:
0: push rbp
1: mov rbp,rsp
4: sub rsp,0x10
8: mov QWORD PTR [rbp-0x8],rdi
c: mov rax,QWORD PTR [rbp-0x8]
10: mov rdx,QWORD PTR [rax+0x8]
14: mov rax,QWORD PTR [rbp-0x8]
18: mov rax,QWORD PTR [rax]
1b: mov rdi,rax
1e: call rdx
20: nop
21: leave
22: ret
Stored function pointer loaded from [rax+0x8] into rdx, called through call rdx. Indirect call through register, same cost as virtual dispatch at the hardware level, no vtable involved.
Quick Reference
Coming from other languages
Languages with runtime type systems, like Java or C#, achieve something similar through interfaces and boxing. An interface reference in Java is already a form of type erasure: the concrete type is hidden behind the interface, and method calls go through a dispatch table the JVM maintains. The difference is that Java applies this uniformly to all objects while C++ lets you choose when to pay the cost and when to use zero-overhead static dispatch instead. std::any is closer to Java’s Object than std::function is, since std::any accepts any type with no behavioral contract.
The 90% mental model
Type erasure stores a void* to the actual object plus function pointers for each operation the interface requires. A template constructor generates those function pointers at construction time for the exact type being stored. After construction, the concrete type is gone: the wrapper holds only void* and function pointers, and the wrapper’s own type is always the same regardless of what it holds. The runtime cost is one indirect call per operation, the same as virtual dispatch. The structural difference from virtual dispatch is that function pointers are stored per-instance inside the wrapper rather than in a compiler-generated vtable shared across instances of the same type. The naive implementation requires heap allocation; std::function avoids this for small callables via a small buffer optimization that stores them inline.