Skip to content
C++
Idiom
since C++11
Expert

Type Erasure: Value-Based Polymorphism Without Inheritance

How C++ type erasure works: the Concept/Model pattern, affordances, templated constructor with non-virtual interface, small buffer optimization, and std::function/any/move_only_function.

Type erasure is C++'s answer to Go's interfaces and Java's Object polymorphism: accept any type that satisfies a set of operations, without requiring that type to inherit from a base class. The result is a non-virtual, value-based API that hides the polymorphism from callers. std::function, std::any, and std::move_only_function are all built on this pattern.


The problem type erasure solves

Classical polymorphism requires callers to inherit from a base class β€” an intrusive requirement the caller's type must satisfy. Type erasure is non-intrusive: you can wrap any duck-typed value in a type-erased shell, even from a library that knows nothing about your interface.

cpp
// Classical polymorphism: callers must inherit
struct IDrawable {
    virtual void draw() const = 0;
    virtual ~IDrawable() = default;
};

struct Circle : IDrawable {      // must derive
    void draw() const override { /* ... */ }
};

void render(const IDrawable& d) { d.draw(); }

// Type erasure: no inheritance required
struct AnyDrawable {
    template<typename T> AnyDrawable(T obj);   // wraps anything with .draw()
    void draw() const;                         // non-virtual public interface
};

struct Square { void draw() const { /* ... */ } };  // no base class

AnyDrawable d{Square{}};   // just works β€” Square never heard of AnyDrawable
d.draw();

The Concept/Model pattern

Every type-erased type is built from three pieces (named after the pattern):

  1. Concept (IWhatever) β€” an abstract base class with the virtual interface
  2. Model (WrappingWhatever<T>) β€” a template that adapts any T to the concept
  3. Wrapper (Whatever) β€” the public value type; owns a unique_ptr<IWhatever> or SBO buffer
cpp
// Step 1: define the "affordances" β€” what callers need to do with the erased type
struct IConcept {
    virtual void draw() const = 0;
    virtual std::unique_ptr<IConcept> clone() const = 0;  // if copyable
    virtual ~IConcept() = default;
};

// Step 2: adapt any T to IConcept
template<typename T>
struct Model : IConcept {
    T obj_;
    explicit Model(T obj) : obj_(std::move(obj)) {}
    void draw() const override { obj_.draw(); }
    std::unique_ptr<IConcept> clone() const override {
        return std::make_unique<Model>(obj_);
    }
};

// Step 3: the public type-erased wrapper
class AnyDrawable {
    std::unique_ptr<IConcept> pimpl_;

public:
    template<typename T>
    AnyDrawable(T obj)
        : pimpl_(std::make_unique<Model<T>>(std::move(obj)))
    {}

    // Copyable because IConcept has clone():
    AnyDrawable(const AnyDrawable& other)
        : pimpl_(other.pimpl_->clone())
    {}

    AnyDrawable(AnyDrawable&&) noexcept = default;

    void draw() const { pimpl_->draw(); }
};

Usage β€” any type with a draw() method works, no inheritance:

cpp
struct Circle  { void draw() const { /* ... */ } };
struct Square  { void draw() const { /* ... */ } };
struct Texture { void draw() const { /* ... */ } };

std::vector<AnyDrawable> scene;
scene.emplace_back(Circle{});
scene.emplace_back(Square{});
scene.emplace_back(Texture{});

for (const auto& d : scene) d.draw();   // runtime dispatch without virtual base

Deciding affordances

The list of affordances determines the virtual interface. Get this list right before writing any code:

AffordanceWhat it meansAdds to IConcept
CallExecute the wrapped objectvirtual R call(args) = 0
CopyCreate an independent copyvirtual unique_ptr<IConcept> clone() const = 0
MoveTransfer ownership cheaplyMove constructor on the wrapper (no virtual needed if unique_ptr)
DestroyClean up the objectVirtual destructor (always needed)
CompareTest equality between two wrappersvirtual bool equals(const IConcept&) const = 0 β€” rarely worth it
SerializeConvert to bytesvirtual void write(Stream&) const = 0

std::function affords: call, copy, move, destroy. std::any affords: copy, move, destroy β€” no call (type is truly erased). std::move_only_function (C++23) affords: call, move, destroy β€” not copy.


std::function internals

std::function<R(Args...)> is the standard type-erased callable. It wraps any callable (lambda, function pointer, functor) that returns R and takes Args..., using the Concept/Model pattern internally.

cpp
#include <functional>

std::function<int(int)> f;

// Stores a lambda β€” heap-allocated internally (may be SBO for small lambdas)
f = [capture = large_data](int x) { return x + capture.value; };

// Stores a function pointer β€” no allocation, stored directly
f = &some_free_function;

// Stores a member function via bind:
f = [&obj](int x) { return obj.method(x); };

Performance notes:

  • std::function does a heap allocation if the stored callable exceeds its SBO buffer (typically 16–32 bytes)
  • Every call goes through a virtual dispatch (pointer to vtable in the internal concept)
  • Copying copies the whole callable including its captured state

When to avoid std::function:

  • In hot loops (allocation + virtual dispatch adds latency)
  • For non-copyable callables (use std::move_only_function instead)
  • When the callback type is always the same (use a template parameter β€” zero overhead)

std::any β€” type-erased value with no call interface

std::any stores a value of any CopyConstructible type and allows type-checked retrieval:

cpp
#include <any>

std::any a = 42;
std::any b = std::string{"hello"};

// Type-checked get:
int v = std::any_cast<int>(a);       // throws std::bad_any_cast if wrong type
int* p = std::any_cast<int>(&a);     // returns nullptr if wrong type (no throw)

// Check stored type:
if (a.type() == typeid(int)) { /* ... */ }

// Erase:
a.reset();            // a is now empty
a.has_value();        // false

std::any uses SBO for small types (typically ≀ pointer size or ≀ 16 bytes depending on implementation) and heap-allocates larger ones.

Comparison with alternatives:

std::anystd::variant<Ts...>void*
Types known in advanceNoYesNo
Type safetyRuntime checkCompile-timeNone
AllocationSBO + heapIn-placeManual
Use casePlugin registries, property bagsSum types with fixed alternativesLegacy C interop

Small buffer optimization (SBO)

Both std::function and std::any include an SBO buffer: a small chunk of storage on the object itself. If the erased object fits, it's stored there instead of on the heap.

cpp
// Conceptual layout of std::function:
struct FunctionStorage {
    alignas(std::max_align_t) char sbo_buf[sizeof(void*) * 3];  // ~24 bytes
    void* heap_ptr;      // used when SBO is too small
    FunctionVtable* vt;  // destroy, call, copy, move
};

When implementing your own type-erased type with SBO:

cpp
class AnyCallable {
    static constexpr std::size_t SBO_SIZE = 32;
    alignas(std::max_align_t) std::byte sbo_[SBO_SIZE];
    bool on_heap_ = false;
    IConcept* concept_ptr_ = nullptr;

    template<typename T>
    void store(T obj) {
        if constexpr (sizeof(Model<T>) <= SBO_SIZE &&
                      alignof(Model<T>) <= alignof(std::max_align_t) &&
                      std::is_nothrow_move_constructible_v<T>) {
            // In-place: no heap allocation
            new (&sbo_) Model<T>(std::move(obj));
            concept_ptr_ = reinterpret_cast<IConcept*>(&sbo_);
            on_heap_ = false;
        } else {
            // Heap allocation for large/throwing objects
            auto* p = new Model<T>(std::move(obj));
            concept_ptr_ = p;
            on_heap_ = true;
        }
    }
    // ... destructor must check on_heap_ before delete
};

Trade-offs of SBO:

  • Eliminates heap allocation for small objects (common case)
  • Makes the wrapper non-trivially movable (must relocate the in-place object)
  • Increases sizeof the wrapper (padded to SBO_SIZE)

std::move_only_function (C++23)

std::move_only_function<R(Args...)> is a move-only variant of std::function. It removes the copy affordance, which enables storing move-only callables (like lambdas that capture unique_ptr):

cpp
#include <functional>

auto make_handler = [](std::unique_ptr<Resource> r) {
    return std::move_only_function<void()>{
        [res = std::move(r)]() { res->handle(); }
    };
};

std::move_only_function<void()> h = make_handler(std::make_unique<Resource>());
h();
// Can be moved:
auto h2 = std::move(h);
// Cannot be copied:
// auto h3 = h2;   // compile error

Also new in C++23: std::function_ref (P0792) β€” a non-owning reference to a callable. Zero allocation, no virtual call setup cost, but the caller must ensure the callable outlives the function_ref.


When to use each

NeedUse
Store any callable, copy it aroundstd::function
Store move-only callable (captures unique_ptr)std::move_only_function (C++23)
Non-owning reference to a callable (callback in a single call)std::function_ref (C++23) or a template
Store any value and retrieve by typestd::any
Store one of a fixed set of typesstd::variant<Ts...>
Custom type-erased interface with specific operationsRoll your own with Concept/Model
Zero overhead, same-type callbackTemplate parameter (static polymorphism)

Type erasure at ABI boundaries

Templates cannot cross shared library ABI boundaries (the full T must be visible to both sides). Type-erased types can: the templated constructor runs on the caller's side and produces a concrete unique_ptr<IConcept>. The object that crosses the ABI boundary is the erased concrete type.

cpp
// In shared library header (no templates crossing the boundary):
class EXPORT_API AnyRenderer {
    struct IConcept;
    std::unique_ptr<IConcept> impl_;
public:
    template<typename T>
    AnyRenderer(T r) : impl_(make_model(std::move(r))) {}  // templated constructor: caller-side
    void render(Frame&) const;
};

// In shared library .cpp:
void AnyRenderer::render(Frame& f) const {
    impl_->render(f);  // virtual dispatch through stable IConcept interface
}

This is why plugin systems often use type erasure: plugins provide concrete types, the host wraps them in type-erased shells at load time, and the rest of the host code works through the stable non-virtual interface.


std::any implementation details: inline vs heap storage

std::any stores small types directly inside the object and heap-allocates anything larger. The threshold is implementation-defined but typically equals sizeof(void*) * 3 (24 bytes on 64-bit). The stored type must also satisfy is_nothrow_move_constructible to qualify for inline storage; a type with a throwing move forces heap allocation even if it fits in size.

cpp
#include <any>
#include <cstdio>

// Small type β€” stored inline, no heap allocation:
struct SmallTag { int id; };
// sizeof(SmallTag) == 4, is_nothrow_move_constructible == true β†’ inline

// Large type β€” heap-allocated by std::any:
struct LargeData { char buf[128]; };
// sizeof(LargeData) == 128 β†’ heap

std::any a = SmallTag{42};    // inline: std::any is on the stack, no malloc
std::any b = LargeData{};     // heap: std::any object holds a pointer internally

Internally, std::any stores a vtable-like pointer (a pointer to a static function or to a polymorphic struct) alongside an aligned storage buffer. Three operations flow through it: destroy, copy, and move. The "inline path" relocates the object directly in the buffer; the "heap path" copies/moves the pointer.

cpp
// What libc++ does internally (simplified):
union Storage {
    alignas(alignof(void*)) char buf[sizeof(void*) * 3]; // inline storage
    void* ptr;                                            // heap pointer
};

struct VTable {
    void (*destroy)(Storage&);
    void (*copy)(Storage& dst, const Storage& src);
    void (*move)(Storage& dst, Storage& src) noexcept;
};

// Each type T that goes into std::any gets a VTable instantiation:
template<typename T>
struct AnyOps {
    static constexpr bool use_inline =
        sizeof(T) <= sizeof(Storage::buf) &&
        alignof(T) <= alignof(void*) &&
        std::is_nothrow_move_constructible_v<T>;

    static void destroy(Storage& s) {
        if constexpr (use_inline)
            reinterpret_cast<T*>(s.buf)->~T();
        else
            delete static_cast<T*>(s.ptr);
    }
};

The practical implication: wrapping a small integer or pointer in std::any is cheap; wrapping a std::string that contains heap data of its own is not catastrophic but does heap-allocate a node for the string object itself.


std::move_only_function (C++23) β€” semantics vs std::function

std::move_only_function removes the copy affordance from std::function, enabling callables that capture unique ownership. std::function requires the stored callable to be CopyConstructible β€” which rules out lambdas that capture std::unique_ptr, std::thread, or other move-only types. std::move_only_function lifts that restriction entirely.

cpp
#include <functional>
#include <memory>

// Fails with std::function β€” lambda is not copyable:
// std::function<void()> bad = [p = std::make_unique<int>(42)]() { /* ... */ };

// Works with std::move_only_function:
std::move_only_function<void()> ok =
    [p = std::make_unique<int>(42)]() { *p += 1; };

ok();              // call it
auto ok2 = std::move(ok);   // move it β€” ok is now empty
ok2();

// Attempting to copy is a compile error:
// auto ok3 = ok2;  // error: copy constructor is deleted

Beyond copyability, std::move_only_function adds two subtler features. First, it supports const-qualified, ref-qualified, and noexcept-qualified signatures as template arguments, letting you express that a stored callable must be callable as const:

cpp
// Requires the stored callable to be const-callable:
std::move_only_function<int() const> f = []() { return 42; };

// A mutable lambda cannot satisfy "const" β€” compile error if uncommented:
// int x = 0;
// std::move_only_function<int() const> g = [x]() mutable { return x++; };

Second, because std::move_only_function need not be copyable, implementations can skip storing a clone vtable entry, reducing the vtable size and sometimes the object size. In practice libstdc++ and libc++ both use SBO for std::move_only_function and allocate on the heap for large callables, same as std::function.

Decision rule: prefer std::move_only_function when the callable captures ownership, prefer std::function only when you need to copy the wrapper itself, and prefer std::function_ref (C++23) or a template parameter when no storage is needed at all.


std::function overhead: the four sources

The performance gap between std::function and a direct call comes from four independent sources, each of which can be addressed separately. Understanding which source dominates lets you pick the right fix.

cpp
#include <functional>

// Source 1: Heap allocation on construction
// If the callable exceeds the SBO buffer (~24–32 bytes), std::function
// calls operator new. Benchmark: ~50-100 ns one-time cost.
auto big_lambda = [a=1,b=2,c=3,d=4,e=5,f=6](int x) { return a+b+c+d+e+f+x; };
std::function<int(int)> fn = big_lambda;  // heap alloc (6Γ—int = 24 bytes, borderline)

// Source 2: Indirect call (virtual dispatch)
// Every invocation goes through a function pointer stored in the vtable.
// Branch predictor can warm up if the same callable is called repeatedly;
// it fails for heterogeneous dispatch. Benchmark: +5-15 ns per call.
fn(42);

// Source 3: Type-check on assignment (not on call β€” common misconception)
// The type check happens when you assign a new callable, not when you call.
// Assignment is O(1) but does require RTTI lookup if you use target<T>().

// Source 4: Pointer indirection through the stored callable's state
// If the callable itself holds a pointer (e.g., a captured reference or
// pointer-to-member), that's an extra indirection on top of the vtable call.

Microbenchmark perspective (Clang 17, x86-64, -O2):

Invocation typeLatency (ns)
Direct function call~0.3
Template parameter (inlined)~0.3
Virtual dispatch~1.5–3
std::function (SBO, warmed)~3–6
std::function (heap, cold)~8–15
std::function first call after construction+50–200 (alloc amortized)

The numbers vary significantly with the callable's size, the branch predictor state, and whether the call is on a hot path. Profile before optimizing; the allocator overhead is one-time but the dispatch overhead is per-call.


Dyno β€” vtable as a template parameter

Dyno (Louis Dionne, Boost.TE) provides type erasure with the vtable stored as a template parameter, giving zero-cost dispatch without any virtual inheritance. Instead of relying on the C++ virtual dispatch machinery, Dyno generates vtables as plain structs of function pointers and stores them separately from the object data β€” enabling strategies like inline vtables, shared vtables, or remote vtables, all switchable at the call site.

cpp
// Dyno-style pseudocode (simplified from the actual library API):
#include <dyno.hpp>

// 1. Define the "concept" as a dyno::requires_:
struct Drawable : decltype(dyno::requires_(
    dyno::CopyConstructible{},
    "draw"_s = dyno::method<void() const>
)) {};

// 2. Define how types map to the concept's methods:
template<typename T>
auto const dyno::concept_map<Drawable, T> = dyno::make_concept_map(
    "draw"_s = [](const T& t) { t.draw(); }
);

// 3. The type-erased poly type uses the vtable strategy as a template parameter:
using LocalVtable  = dyno::poly<Drawable, dyno::local_storage<32>>;
using RemoteVtable = dyno::poly<Drawable, dyno::remote_storage<>>;

struct Circle { void draw() const { /* ... */ } };
struct Square { void draw() const { /* ... */ } };

LocalVtable  c{Circle{}};  // vtable stored inline alongside object data
RemoteVtable s{Square{}};  // vtable stored separately, object heap-allocated
c.virtual_("draw"_s)();

The key insight is that the vtable is a plain struct of function pointers, not a compiler-generated class hierarchy. This means:

  • The vtable layout is visible to the optimizer, enabling devirtualization in some cases
  • Different vtable storage strategies (local vs remote) are swap-in-swap-out without changing the interface
  • No dynamic_cast, no RTTI overhead at all
  • The library is a pure header-only C++14/17 solution β€” no compiler magic

In practice, Dyno is mostly a proof of concept and a research tool. Its production-readiness is limited by compilation time and compile errors that are difficult to decode. The pattern it demonstrates β€” separating the vtable struct from the model β€” is useful to internalize when writing your own type-erased types.


Type erasure at ABI boundaries: stable function pointer tables

Exporting a type-erased interface over a shared library boundary requires a stable function pointer table, not a vtable β€” because vtable layout is an implementation detail that differs between compilers and even compiler versions. The solution is to make the table explicit: a plain C struct of function pointers, filled in on the plugin side and consumed on the host side.

cpp
// plugin_api.h β€” shared between host and all plugins (C-compatible layout):
#ifdef __cplusplus
extern "C" {
#endif

typedef struct {
    void* obj;                          // opaque pointer to the plugin object
    void  (*render)(void* obj, int w, int h);
    void  (*update)(void* obj, float dt);
    void  (*destroy)(void* obj);        // plugin manages its own lifetime
} PluginVTable;

// Plugin entry point β€” must be exported with C linkage:
typedef PluginVTable (*PluginFactory)(void);

#ifdef __cplusplus
}  // extern "C"
#endif

On the plugin side, C++ code fills in the table:

cpp
// plugin.cpp β€” compiled independently, possibly with a different compiler:
#include "plugin_api.h"

class MyPlugin {
public:
    void render(int w, int h) { /* ... */ }
    void update(float dt)     { /* ... */ }
};

extern "C" PluginVTable create_plugin() {
    auto* p = new MyPlugin{};
    return PluginVTable{
        .obj     = p,
        .render  = [](void* o, int w, int h) { static_cast<MyPlugin*>(o)->render(w, h); },
        .update  = [](void* o, float dt)     { static_cast<MyPlugin*>(o)->update(dt); },
        .destroy = [](void* o) { delete static_cast<MyPlugin*>(o); },
    };
}

On the host side, a thin C++ wrapper type-erases the table:

cpp
// host.cpp β€” wraps the raw table in a safe RAII type:
#include "plugin_api.h"
#include <dlfcn.h>

class Plugin {
    PluginVTable vt_;
public:
    explicit Plugin(PluginVTable vt) : vt_{vt} {}
    ~Plugin() { vt_.destroy(vt_.obj); }

    Plugin(const Plugin&)            = delete;
    Plugin& operator=(const Plugin&) = delete;
    Plugin(Plugin&&)                 = default;

    void render(int w, int h) { vt_.render(vt_.obj, w, h); }
    void update(float dt)     { vt_.update(vt_.obj, dt); }
};

Plugin load_plugin(const char* path) {
    void* lib = dlopen(path, RTLD_LAZY);
    auto* factory = reinterpret_cast<PluginFactory>(dlsym(lib, "create_plugin"));
    return Plugin{factory()};
}

This pattern is stable across compilers because the function pointer table is a POD struct with C linkage. No C++ name mangling, no RTTI, no virtual dispatch β€” just a table of raw function pointers. COM, GStreamer plugins, and VST3 audio plugins all use this approach.


Benchmarking: type-erased dispatch vs virtual vs templates

Type-erased dispatch and virtual dispatch are nearly identical in steady-state throughput; the difference that matters is allocation and branch-predictor pressure, not the indirection itself. Direct comparisons should control for cache temperature, monomorphic vs polymorphic call sites, and devirtualization by the optimizer.

cpp
// Benchmark setup (Google Benchmark style):
#include <benchmark/benchmark.h>
#include <functional>
#include <memory>
#include <vector>

struct Base { virtual int work(int x) = 0; virtual ~Base() = default; };
struct Derived : Base { int work(int x) override { return x * 2 + 1; } };

// 1. Virtual dispatch through pointer:
static void BM_Virtual(benchmark::State& state) {
    std::vector<std::unique_ptr<Base>> objs;
    for (int i = 0; i < 1000; ++i) objs.push_back(std::make_unique<Derived>());
    int sum = 0;
    for (auto _ : state)
        for (auto& p : objs) benchmark::DoNotOptimize(sum += p->work(sum));
}

// 2. Type-erased via std::function:
static void BM_StdFunction(benchmark::State& state) {
    std::vector<std::function<int(int)>> fns;
    for (int i = 0; i < 1000; ++i)
        fns.push_back([](int x) { return x * 2 + 1; });
    int sum = 0;
    for (auto _ : state)
        for (auto& f : fns) benchmark::DoNotOptimize(sum += f(sum));
}

// 3. Template (static dispatch β€” can be inlined):
static void BM_Template(benchmark::State& state) {
    auto work = [](int x) { return x * 2 + 1; };
    int sum = 0;
    for (auto _ : state)
        for (int i = 0; i < 1000; ++i) benchmark::DoNotOptimize(sum += work(sum));
}

BENCHMARK(BM_Virtual);
BENCHMARK(BM_StdFunction);
BENCHMARK(BM_Template);

Typical results (Clang 17, -O2, x86-64, warmed branch predictor, monomorphic site):

MethodTime per 1000 callsNotes
Template (inlined)~200 nsCompiler vectorizes the loop
Virtual dispatch~900 nsBranch predictor warms up quickly for monomorphic site
std::function (SBO)~1 100 nsVtable call + slightly larger frame
std::function (heap)~1 200 nsOne extra indirection for the callable pointer
std::function (polymorphic)~2 000 nsCold branch predictor, many distinct callables

The template wins by a large margin because the compiler vectorizes the loop. For polymorphic sites (many different types through the same dispatch point), virtual and type-erased dispatch converge. The real cost of std::function vs raw virtual is modest (~10–20%) in the call itself; the allocation cost at construction time is larger and shows up in object creation benchmarks, not call benchmarks.


See Also

  • reference/idioms/runtime-polymorphism β€” the runtime-polymorphism idiom (Concept/Model in full detail)
  • learn/expert/template-metaprogramming β€” static polymorphism via templates and CRTP
  • learn/advanced/concepts-and-requires β€” using C++20 concepts to constrain type-erased wrapper templates
  • P0288 β€” std::move_only_function proposal
  • P0792 β€” std::function_ref proposal