What type erasure solves
Templates give you type safety and zero overhead — but they replicate code for every type, and they can’t be stored in heterogeneous containers.
Virtual dispatch gives you runtime polymorphism — but it requires inheriting from a base class, which you don’t always control.
Type erasure gives you both: store objects of any type behind a single interface, without requiring the stored types to inherit from anything.
std::function, std::any, and std::shared_ptr<void> are all type erasure.
std::function internals
std::function<void(int)> stores any callable that takes int and returns
void — a lambda, a function pointer, a functor, a std::bind result:
1std::function<void(int)> f;
2
3f = [](int x) { printf("%d\n", x); }; // lambda
4f = &freeFunction; // function pointer
5f = Functor{42}; // functor with state
6f = std::bind(&MyClass::method, obj, std::placeholders::_1);
Internally, std::function uses type erasure:
1// Simplified std::function implementation
2template <typename Signature>
3class function;
4
5template <typename R, typename... Args>
6class function<R(Args...)> {
7 // Small buffer — avoids heap for small callables
8 alignas(void*) char storage_[3 * sizeof(void*)];
9 bool heapAllocated_ = false;
10
11 // Type-erased vtable
12 struct VTable {
13 R (*invoke)(void* obj, Args&&... args);
14 void (*destroy)(void* obj);
15 void (*copy)(void* dst, const void* src);
16 };
17 const VTable* vtable_ = nullptr;
18
19public:
20 template <typename F>
21 function(F&& f) {
22 using Decay = std::decay_t<F>;
23 // Generate a vtable for this specific type F
24 static const VTable vt = {
25 // invoke: cast storage back to F, call it
26 [](void* obj, Args&&... args) -> R {
27 return (*static_cast<Decay*>(obj))(std::forward<Args>(args)...);
28 },
29 // destroy
30 [](void* obj) { static_cast<Decay*>(obj)->~Decay(); },
31 // copy
32 [](void* dst, const void* src) {
33 new (dst) Decay(*static_cast<const Decay*>(src));
34 }
35 };
36 vtable_ = &vt;
37
38 if constexpr (sizeof(Decay) <= sizeof(storage_) && alignof(Decay) <= alignof(void*)) {
39 new (storage_) Decay(std::forward<F>(f)); // small buffer optimisation
40 } else {
41 // heap allocation for large callables
42 }
43 }
44
45 R operator()(Args&&... args) {
46 return vtable_->invoke(storage_, std::forward<Args>(args)...);
47 }
48};
The key insight: the vtable lambdas capture the type F at construction time.
After that, all access goes through void* — the type is erased, but the
correct operations are still called via the vtable.
This is the pattern behind all type erasure in C++.
The small buffer optimisation
std::function implementations typically have a small inline buffer (24–32 bytes)
to avoid heap allocation for small callables (stateless lambdas, function
pointers, small functors). A large lambda that captures many variables spills
to the heap.
1// No heap allocation — fits in small buffer
2auto f1 = std::function<void()>{[] { return 42; }};
3
4// Heap allocation — captures 5 ints
5int a, b, c, d, e;
6auto f2 = std::function<void()>{[a, b, c, d, e] { return a + b + c + d + e; }};
For embedded or real-time use, heap allocation in a callback is problematic. The hand-rolled version below controls this.
Hand-rolled type erasure with fixed storage
A type-erased callable with a fixed inline buffer — no heap, ISR-safe:
1template <typename Signature, size_t StorageSize = 3 * sizeof(void*)>
2class FixedFunction;
3
4template <typename R, typename... Args, size_t StorageSize>
5class FixedFunction<R(Args...), StorageSize> {
6 using InvokeFn = R(*)(void*, Args&&...);
7 using DestroyFn = void(*)(void*);
8
9 alignas(std::max_align_t) char storage_[StorageSize];
10 InvokeFn invoke_ = nullptr;
11 DestroyFn destroy_ = nullptr;
12
13public:
14 FixedFunction() = default;
15
16 template <typename F>
17 FixedFunction(F&& f) {
18 using Decay = std::decay_t<F>;
19 static_assert(sizeof(Decay) <= StorageSize,
20 "Callable too large for fixed buffer — increase StorageSize");
21 static_assert(std::is_trivially_destructible_v<Decay> ||
22 std::is_nothrow_destructible_v<Decay>);
23
24 new (storage_) Decay(std::forward<F>(f));
25
26 invoke_ = [](void* s, Args&&... a) -> R {
27 return (*static_cast<Decay*>(s))(std::forward<Args>(a)...);
28 };
29 destroy_ = [](void* s) { static_cast<Decay*>(s)->~Decay(); };
30 }
31
32 ~FixedFunction() { if (destroy_) destroy_(storage_); }
33
34 // Non-copyable (would need copy vtable)
35 FixedFunction(const FixedFunction&) = delete;
36 FixedFunction& operator=(const FixedFunction&) = delete;
37
38 FixedFunction(FixedFunction&&) = default;
39
40 R operator()(Args&&... args) {
41 return invoke_(storage_, std::forward<Args>(args)...);
42 }
43
44 explicit operator bool() const { return invoke_ != nullptr; }
45};
46
47// Usage — no heap, no exception, compile-time size check
48FixedFunction<void(float)> handler;
49handler = [](float v) { display.update(v); };
50handler(23.5f);
If you try to store a lambda that captures too much:
1// Compile error — 8 floats = 32 bytes, doesn't fit in default 24-byte buffer
2float a[8];
3FixedFunction<void()> f = [a]() { /* use a */ };
4// error: Callable too large for fixed buffer
Adjust StorageSize at the call site if needed.
std::any — type erasure for storage, not invocation
std::any (C++17) stores a value of any copyable type. Unlike std::function,
it’s not specific to callables — it erases any type.
1#include <any>
2
3std::any value;
4value = 42; // int
5value = 3.14f; // float
6value = std::string("hello");
7
8// Extract — throws std::bad_any_cast if wrong type
9int i = std::any_cast<int>(value);
10std::string* s = std::any_cast<std::string>(&value); // returns nullptr if wrong
Use std::any when you need a heterogeneous container and don’t know the
types at compile time — plugin configurations, serialised settings:
1std::map<std::string, std::any> config;
2config["baud"] = 115200;
3config["device"] = std::string("/dev/ttyS0");
4config["timeout"] = 5.0f;
5
6int baud = std::any_cast<int>(config["baud"]);
Cost: std::any heap-allocates large values (with a small buffer optimisation
for small trivial types). Not suitable for embedded without heap.
Duck-typed type erasure with concepts (C++20)
C++20 concepts let you define what operations a type must support, without requiring inheritance:
1template <typename T>
2concept Sensor = requires(T s) {
3 { s.read() } -> std::convertible_to<float>;
4 { s.isReady() } -> std::convertible_to<bool>;
5};
6
7// Works with any type satisfying Sensor — no inheritance
8template <Sensor S>
9class Pipeline {
10 S& sensor_;
11public:
12 explicit Pipeline(S& s) : sensor_(s) {}
13 float measure() {
14 if (!sensor_.isReady()) return NAN;
15 return sensor_.read();
16 }
17};
18
19struct AnalogSensor {
20 float read() { return adcToVoltage(HAL_ADC_GetValue(hadc)); }
21 bool isReady() { return true; }
22};
23
24AnalogSensor s;
25Pipeline p(s); // CTAD — deduces Pipeline<AnalogSensor>
No ISensor base class. No vtable. The concept is a compile-time contract.
Choosing the right mechanism
| Mechanism | Runtime poly | Heap | Inheritance required | Use case |
|---|---|---|---|---|
std::function |
Yes | Sometimes | No | Callbacks, event handlers |
FixedFunction |
Yes | No | No | Embedded callbacks, ISR-safe |
std::any |
Yes (via cast) | Sometimes | No | Config maps, plugin values |
| Virtual inheritance | Yes | User-controlled | Yes | Classic OOP hierarchy |
| CRTP | No | No | Yes (CRTP base) | Zero-cost compile-time |
| Concepts (C++20) | No | No | No | Constrained templates |
Summary
- Type erasure stores any type behind one interface without requiring inheritance
std::functionuses an internal vtable + small buffer optimisation — may heap-allocate- Hand-rolled
FixedFunction: fixed inline buffer, compile-time size check, no heap std::any: erases any copyable type, not just callables- C++20 concepts: compile-time duck typing without type erasure overhead
What’s next
- CRTP — static polymorphism and mixins — zero-cost alternative for compile-time polymorphism
- Strategy pattern — runtime strategies via type erasure or templates
- Observer pattern — fixed-size observer lists using type erasure