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::function uses 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