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.
// 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):
- Concept (
IWhatever) β an abstract base class with the virtual interface - Model (
WrappingWhatever<T>) β a template that adapts anyTto the concept - Wrapper (
Whatever) β the public value type; owns aunique_ptr<IWhatever>or SBO buffer
// 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:
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 baseDeciding affordances
The list of affordances determines the virtual interface. Get this list right before writing any code:
| Affordance | What it means | Adds to IConcept |
|---|---|---|
| Call | Execute the wrapped object | virtual R call(args) = 0 |
| Copy | Create an independent copy | virtual unique_ptr<IConcept> clone() const = 0 |
| Move | Transfer ownership cheaply | Move constructor on the wrapper (no virtual needed if unique_ptr) |
| Destroy | Clean up the object | Virtual destructor (always needed) |
| Compare | Test equality between two wrappers | virtual bool equals(const IConcept&) const = 0 β rarely worth it |
| Serialize | Convert to bytes | virtual 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.
#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::functiondoes 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_functioninstead) - 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:
#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(); // falsestd::any uses SBO for small types (typically β€ pointer size or β€ 16 bytes depending on implementation) and heap-allocates larger ones.
Comparison with alternatives:
std::any | std::variant<Ts...> | void* | |
|---|---|---|---|
| Types known in advance | No | Yes | No |
| Type safety | Runtime check | Compile-time | None |
| Allocation | SBO + heap | In-place | Manual |
| Use case | Plugin registries, property bags | Sum types with fixed alternatives | Legacy 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.
// 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:
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):
#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 errorAlso 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
| Need | Use |
|---|---|
| Store any callable, copy it around | std::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 type | std::any |
| Store one of a fixed set of types | std::variant<Ts...> |
| Custom type-erased interface with specific operations | Roll your own with Concept/Model |
| Zero overhead, same-type callback | Template 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.
// 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.
#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 internallyInternally, 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.
// 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.
#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 deletedBeyond 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:
// 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.
#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 type | Latency (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.
// 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.
// 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"
#endifOn the plugin side, C++ code fills in the table:
// 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:
// 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.
// 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):
| Method | Time per 1000 calls | Notes |
|---|---|---|
| Template (inlined) | ~200 ns | Compiler vectorizes the loop |
| Virtual dispatch | ~900 ns | Branch predictor warms up quickly for monomorphic site |
std::function (SBO) | ~1 100 ns | Vtable call + slightly larger frame |
std::function (heap) | ~1 200 ns | One extra indirection for the callable pointer |
std::function (polymorphic) | ~2 000 ns | Cold 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 CRTPlearn/advanced/concepts-and-requiresβ using C++20 concepts to constrain type-erased wrapper templatesP0288βstd::move_only_functionproposalP0792βstd::function_refproposal