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

Type Erasure

Hide concrete type identity behind a uniform interface without inheritance. Covers std::function, std::any, std::variant, Concept+Model wrappers, and SBO.

Type Erasuresince C++11

Type erasure hides the concrete type of a stored object behind a uniform interface, enabling runtime polymorphism over an open set of types without requiring a shared base class in client code.

Overview

The classical OOP answer β€” a common abstract base class β€” requires every participant to opt in at definition time. Type erasure inverts this: the wrapper adapts to any type satisfying a behavioural contract, leaving the concrete types untouched. The standard library provides three ready-made forms:

  • std::function<Sig> (C++11) β€” erases any callable matching a signature
  • std::any (C++17) β€” erases any CopyConstructible value
  • std::variant<Ts...> (C++17) β€” closed-set discriminated union over a fixed type list
  • Custom Concept+Model β€” the general pattern, giving full control over interface and storage policy

The Concept+Model Pattern

All type erasure follows the same skeleton: an abstract Concept defines the interface via pure virtuals; a Model<T> template implements it by delegating to a stored T; the outer value type holds a Concept pointer and forwards calls. The result is a copyable, value-semantic object that owns any conforming concrete type.

cpp
class Shape {
    struct Concept {
        virtual double area()  const = 0;
        virtual void   draw()  const = 0;
        virtual std::unique_ptr<Concept> clone() const = 0;
        virtual ~Concept() = default;
    };

    template<typename T>
    struct Model final : Concept {        // 'final' lets the compiler devirtualise
        T obj_;
        explicit Model(T o) : obj_(std::move(o)) {}

        double area()  const override { return obj_.area(); }
        void   draw()  const override { obj_.draw(); }
        std::unique_ptr<Concept> clone() const override {
            return std::make_unique<Model>(*this);  // C++14 make_unique
        }
    };

    std::unique_ptr<Concept> impl_;  // C++11 unique_ptr

public:
    template<typename T>
    /*implicit*/ Shape(T shape)
        : impl_(std::make_unique<Model<T>>(std::move(shape))) {}

    Shape(const Shape& o) : impl_(o.impl_->clone()) {}
    Shape& operator=(const Shape& o) { impl_ = o.impl_->clone(); return *this; }
    Shape(Shape&&) noexcept = default;
    Shape& operator=(Shape&&) noexcept = default;

    double area() const { return impl_->area(); }
    void   draw() const { impl_->draw(); }
};

// No modification to Circle or Square required
struct Circle { double r; double area() const { return 3.14159 * r * r; } void draw() const; };
struct Square { double s; double area() const { return s * s; }           void draw() const; };

std::vector<Shape> shapes;
shapes.emplace_back(Circle{5.0});
shapes.emplace_back(Square{4.0});

for (const Shape& s : shapes)
    s.draw();  // virtual dispatch β€” no shared base class

clone() is the price of value semantics. If move-only ownership suffices, drop clone() and remove the copy constructor.

Const-correctness is subtle here. Model<T>::area() const invokes obj_.area() const, so T::area() must be const-qualified. For mutable callables β€” lambdas that modify captured state β€” the Concept's method must be non-const, making the outer wrapper non-const-callable as well. The std::function standard type handles this internally by storing the callable in a mutable buffer even though operator() is declared const.

std::function β€” Owning Callable Erasure

std::function<R(Args...)> (C++11, <functional>) stores any callable matching the given signature. It uses small buffer optimisation (SBO) internally: callables up to a threshold (typically 16–24 bytes, implementation-defined) are stored inline; larger ones are heap-allocated.

cpp
#include <functional>  // C++11

std::function<double(double, double)> op;

op = [](double a, double b) { return a + b; };        // lambda
op = std::plus<double>{};                              // functor (C++11)
op = static_cast<double(*)(double, double)>(std::pow); // function pointer

// Member function β€” lambda is cleaner than std::bind
struct Scaler { double factor; double scale(double x) const { return x * factor; } };
Scaler sc{2.5};
op = [&sc](double x, double) { return sc.scale(x); };

if (op) op(3.0, 0.0);  // bool conversion; false when empty

// Callables that exceed SBO are heap-allocated β€” avoid capturing large arrays by value
double coeffs[64] = {};
auto big = [coeffs](double x, double) { return x + coeffs[0]; };  // > SBO threshold β†’ heap
std::function<double(double,double)> f = big;

// Every call goes through virtual dispatch (or equivalent function pointer indirection)
// and is not inlineable in the general case

std::move_only_function<Sig> (C++23) removes the CopyConstructible requirement and permits richer call qualifiers (const, noexcept, &/&&):

cpp
#include <functional>  // C++23

auto resource = std::make_unique<int>(99);
std::move_only_function<int() const> fn =           // C++23: const-qualified signature
    [p = std::move(resource)]() -> int { return *p; };

std::any β€” Value Erasure

std::any (C++17, <any>) holds a single value of any CopyConstructible type with SBO for small values. std::any_cast has three access forms with different failure semantics:

cpp
#include <any>  // C++17

std::any val = 42;
val = std::string{"hello"};        // replaces; previous int is destroyed
val.emplace<std::string>("world"); // in-place construction (C++17)

// 1. Value access β€” copies the stored object; throws std::bad_any_cast on type mismatch
std::string s = std::any_cast<std::string>(val);

// 2. Reference access β€” no copy; throws std::bad_any_cast on mismatch
std::string& sr = std::any_cast<std::string&>(val);

// 3. Pointer access β€” no copy, no throw; returns nullptr on mismatch (prefer in uncertain contexts)
if (auto* p = std::any_cast<std::string>(&val))
    sr = *p;

val.type() == typeid(std::string)  // runtime type identity via std::type_info

// Heterogeneous property bag
std::unordered_map<std::string, std::any> props;
props["name"]  = std::string{"Alice"};
props["age"]   = 30;
props["score"] = 3.14;

std::any requires CopyConstructible. Storing a unique_ptr directly is a compile error β€” use shared_ptr or a custom wrapper.

std::variant β€” Closed-Set Erasure

When the type set is fixed at compile time, std::variant<Ts...> (C++17) wins on every performance metric: the discriminated union lives entirely on the stack, no heap allocation, no virtual dispatch, and the exhaustiveness of visitors is checked at compile time.

cpp
#include <variant>  // C++17

using Value = std::variant<int, double, std::string, bool>;

Value v = 42;
v = std::string{"hello"};

// Generic visitor with if constexpr (C++17)
auto to_str = [](const auto& x) -> std::string {
    using T = std::decay_t<decltype(x)>;  // std::decay_t is C++14
    if constexpr (std::is_same_v<T, std::string>)  // std::is_same_v is C++17
        return x;
    else
        return std::to_string(x);
};
std::string str = std::visit(to_str, v);

// Exhaustive overload set β€” fails to compile if a type is missing
template<typename... Ts> struct overloaded : Ts... { using Ts::operator()...; };
// Deduction guide needed in C++17; implicit in C++20

std::visit(overloaded{
    [](int n)               { /* ... */ },
    [](double d)            { /* ... */ },
    [](const std::string& s){ /* ... */ },
    [](bool b)              { /* ... */ },
}, v);

Callable Type-Erasure Alternatives

TypeStandardOwnershipAllocationNotes
std::function<Sig>C++11Owning, copyingSBO + heapGeneral purpose
std::move_only_function<Sig>C++23Owning, move-onlySBO + heapMove-only callables; richer qualifiers
std::copyable_function<Sig>C++26Owning, copyingSBO + heapExplicit copy semantics
std::function_ref<Sig>C++26Non-owningNonePointer pair; must not outlive callable
Template parameterN/AN/ANoneCompile-time only; fully inlineable
Virtual interfaceC++98User-managedUser-managedIntrusive; requires base class

std::function_ref<Sig> (C++26) compiles to a pair of pointers and imposes zero overhead when the callable type is visible at the call site:

cpp
#include <functional>  // C++26

void transform_all(std::span<int> v, std::function_ref<int(int)> fn) {  // C++26
    for (int& x : v) x = fn(x);
}

std::vector<int> nums = {1, 2, 3, 4};
transform_all(nums, [](int x) { return x * x; });

Best Practices

  • Prefer std::variant when the type set is known at compile time. Stack-allocated, zero-overhead, exhaustiveness-checked. Reserve std::any and custom erasure for genuinely open type sets.
  • Prefer function_ref (C++26) or template parameters for function arguments. Only use std::function when you need to store or own the callable across a call boundary.
  • Implement clone() only when value semantics are required. Move-only wrappers are cheaper and often sufficient. If users hold Shape in unique_ptr, clone() is unnecessary.
  • Keep captured state small in std::function. Callables exceeding SBO (typically 16–24 bytes) trigger a heap allocation on every construction. Capture by pointer or shared_ptr rather than by value for large objects.
  • Mark Model as final. When the compiler sees a Model* through the Concept* vtable, final enables devirtualisation in hot paths where the dynamic type is provably known.

Common Pitfalls

Missing virtual ~Concept(). Deleting through a Concept* without a virtual destructor is undefined behaviour β€” the Model<T> destructor never runs and resources leak.

std::any requires CopyConstructible. std::any val = std::make_unique<int>(1) is a compile error. Use shared_ptr, or wrap in a custom type-erased handle.

std::any_cast throws on mismatch. Use the pointer form (any_cast<T>(&val)) when the stored type is not guaranteed. The value and reference forms throw std::bad_any_cast, not return a sentinel.

std::function copies the callable. If the callable has expensive-to-copy state, capture by shared_ptr or use std::reference_wrapper. Assigning a std::function to another copies the stored callable.

function_ref does not extend lifetime. A std::function_ref holds a raw pointer to the callable. Returning one from a function or storing it in a data structure past the callable's lifetime is undefined behaviour β€” unlike std::function, which owns its callable.

Mutable lambdas with const wrappers. A mutable lambda has a non-const operator(). If your custom Concept::call() is const, delegating to a stored mutable lambda fails to compile. Either make call() non-const (and accept that the wrapper is not const-callable), or store the lambda through an indirection that bypasses const, as std::function does internally.

Small Buffer Optimization (SBO) in Type-Erased Wrappers

SBO eliminates the heap allocation for small callables by reserving a fixed inline buffer inside the wrapper itself. std::function implementations typically use 16–32 bytes of inline storage; any callable that fits (functor struct, tiny lambda capture) is placement-new'd there, and only oversized callables fall back to operator new.

cpp
// C++11
#include <cstddef>
#include <new>
#include <type_traits>
#include <utility>

template<typename Sig> class SmallFunction;

template<typename R, typename... Args>
class SmallFunction<R(Args...)> {
    static constexpr std::size_t kBufSize = 64;

    struct Vtable {
        R     (*invoke)(void*, Args&&...);
        void  (*destroy)(void*);
        void  (*copy)(const void* src, void* dst, bool* heap);
    };

    alignas(std::max_align_t) unsigned char buf_[kBufSize];
    Vtable const* vt_  = nullptr;
    bool          heap_ = false;

    template<typename F>
    static Vtable const* make_vtable() noexcept {
        static const Vtable vt {
            // invoke
            [](void* p, Args&&... a) -> R {
                return (*static_cast<F*>(p))(std::forward<Args>(a)...);
            },
            // destroy
            [](void* p) { static_cast<F*>(p)->~F(); },
            // copy
            [](const void* src, void* dst, bool* h) {
                ::new(dst) F(*static_cast<const F*>(src));
                *h = false;
            }
        };
        return &vt;
    }

public:
    SmallFunction() = default;

    template<typename F>
    SmallFunction(F f) {
        if constexpr (sizeof(F) <= kBufSize &&
                      alignof(F) <= alignof(std::max_align_t)) {
            ::new(buf_) F(std::move(f));   // inline β€” no heap
            heap_ = false;
        } else {
            auto* p = new F(std::move(f)); // heap fallback
            ::new(buf_) F*(p);
            heap_ = true;
        }
        vt_ = make_vtable<F>();
    }

    ~SmallFunction() { if (vt_) vt_->destroy(heap_ ? *reinterpret_cast<void**>(buf_) : buf_); }

    R operator()(Args... a) {
        return vt_->invoke(heap_ ? *reinterpret_cast<void**>(buf_) : buf_,
                           std::forward<Args>(a)...);
    }
};

The vtable pointer lives alongside the buffer so each SmallFunction is one word of metadata plus 64 bytes of inline storage β€” no secondary heap block for typical lambdas. Increasing kBufSize trades stack space for fewer heap allocations; the right value is workload-specific.

std::move_only_function (C++23)

std::function requires its stored callable to be CopyConstructible, which silently rejects any callable owning a unique resource such as a std::unique_ptr. std::move_only_function<Sig> (C++23, <functional>) lifts this restriction: the wrapper itself is move-only, and the callable need not be copyable.

cpp
// C++23
#include <functional>
#include <memory>
#include <queue>

// A lambda capturing a unique_ptr β€” not copyable, so std::function would fail to compile
auto make_task(std::unique_ptr<int> payload) {
    return [p = std::move(payload)]() {
        // consume *p exactly once
        return *p * 2;
    };
}

std::move_only_function<int()> task = make_task(std::make_unique<int>(21));
int result = task();   // 42

// Task queue of one-shot callbacks β€” a natural home for move_only_function
std::queue<std::move_only_function<void()>> work_queue;
work_queue.push([]{ /* ... */ });
work_queue.front()();
work_queue.pop();

std::move_only_function also enforces const-correctness that std::function silently violated: a std::move_only_function<void() const> will only accept callables with a const operator(), whereas std::function<void()> accepted mutable lambdas through its const operator() by storing the callable in a mutable internal buffer. For one-shot callbacks and task queues where the callable will never be copied, prefer std::move_only_function over std::function.

Type-Erased Ranges and Views

std::ranges::any_view<T> is proposed but not yet part of C++23; until it lands, a pull-based range can be approximated with a type-erased generator function. The generator returns std::optional<T> β€” std::nullopt signals exhaustion β€” and erases the concrete range type behind a std::function.

cpp
// C++23
#include <functional>
#include <optional>
#include <ranges>
#include <vector>
#include <list>

// Type-erased pull-based range: any sequence of T, source forgotten
template<typename T>
using AnyGenerator = std::function<std::optional<T>()>;

template<typename Range>
AnyGenerator<std::ranges::range_value_t<Range>>
erase_range(Range&& r) {
    auto it  = std::ranges::begin(r);
    auto end = std::ranges::end(r);
    return [it, end]() mutable -> std::optional<std::ranges::range_value_t<Range>> {
        if (it == end) return std::nullopt;
        return *it++;
    };
}

// Works identically for vector, list, iota_view, filter_view, …
std::vector<int> vec{1, 2, 3};
auto gen = erase_range(vec);
while (auto v = gen()) { /* process *v */ }

// C++23 std::generator<T> is a concrete coroutine-based alternative
// that preserves full type information and is lazily evaluated:
#include <generator>  // C++23
std::generator<int> iota_gen(int n) {
    for (int i = 0; i < n; ++i) co_yield i;
}
// std::generator is NOT type-erased β€” it carries the full coroutine type,
// giving the compiler visibility needed for inlining.

The AnyGenerator pattern pays one std::function indirect call per element; std::generator<T> avoids that indirection at the cost of exposing the concrete coroutine type in the interface. Use std::generator when the caller can be templated; use AnyGenerator when the sequence source must be hidden across a compilation boundary.

Extending Type-Erased Interfaces Without Inheritance

Affordances can be added to an existing type-erased wrapper without touching the original class by composing a second Concept+Model around the same stored value. Each capability is an independent concept; combining them keeps the original Drawable wrapper closed to modification.

cpp
// C++17
#include <memory>
#include <string>
#include <vector>

// ── Original type-erased drawable ─────────────────────────────────────────
struct Drawable {
    struct Concept {
        virtual void draw() const = 0;
        virtual std::unique_ptr<Concept> clone() const = 0;
        virtual ~Concept() = default;
    };
    template<typename T>
    struct Model final : Concept {
        T obj_;
        explicit Model(T o) : obj_(std::move(o)) {}
        void draw() const override { obj_.draw(); }
        std::unique_ptr<Concept> clone() const override { return std::make_unique<Model>(*this); }
    };
    std::unique_ptr<Concept> impl_;
    template<typename T>
    Drawable(T t) : impl_(std::make_unique<Model<T>>(std::move(t))) {}
    void draw() const { impl_->draw(); }
};

// ── New affordance: Serializable β€” added without touching Drawable ────────
template<typename T>
concept HasSerialize = requires(const T& t) { { t.serialize() } -> std::convertible_to<std::string>; };

struct Serializable {
    struct Concept {
        virtual std::string serialize() const = 0;
        virtual std::unique_ptr<Concept> clone() const = 0;
        virtual ~Concept() = default;
    };
    template<typename T> requires HasSerialize<T>
    struct Model final : Concept {
        T obj_;
        explicit Model(T o) : obj_(std::move(o)) {}
        std::string serialize() const override { return obj_.serialize(); }
        std::unique_ptr<Concept> clone() const override { return std::make_unique<Model>(*this); }
    };
    std::unique_ptr<Concept> impl_;
    template<typename T> requires HasSerialize<T>
    Serializable(T t) : impl_(std::make_unique<Model<T>>(std::move(t))) {}
    std::string serialize() const { return impl_->serialize(); }
};

// ── Combined wrapper β€” no shared base class required ─────────────────────
struct DrawableAndSerializable {
    Drawable     drawable_;
    Serializable serializable_;

    template<typename T> requires HasSerialize<T>
    DrawableAndSerializable(T t)
        : drawable_(t), serializable_(std::move(t)) {}

    void        draw()      const { drawable_.draw(); }
    std::string serialize() const { return serializable_.serialize(); }
};

// Contrast: the intrusive approach forces every new Drawable to also
// inherit SerializableBase and implement serialize(), coupling unrelated concerns.

The composing wrapper stores two independent type-erased objects that happen to wrap the same concrete type. This doubles the vtable indirection but preserves the open-world property: adding a third affordance (Printable, Cloneable, …) requires only another independent concept, not a rewrite of Drawable.

Type Erasure at ABI Boundaries

Template-based type erasure (Concept+Model) is header-only: every translation unit that uses it instantiates its own Model<T>, so the concrete type is never hidden from the linker. Crossing a shared library boundary requires an ABI-stable representation β€” a plain C vtable struct plus a void* context pointer β€” that the linker, language runtime, and foreign code can all agree on.

cpp
// C++11 β€” ABI-stable vtable for a "Drawable" interface
extern "C" {

typedef struct DrawableVtable {
    void (*draw)(void* self);
    void (*destroy)(void* self);
} DrawableVtable;

typedef struct DrawableHandle {
    void*                 self;
    const DrawableVtable* vt;
} DrawableHandle;

// Exported from the shared library β€” stable across recompilation
void drawable_draw(DrawableHandle h)    { h.vt->draw(h.self); }
void drawable_destroy(DrawableHandle h) { h.vt->destroy(h.self); if (h.vt->destroy) {} }

} // extern "C"

// C++ side: create a handle for any T without exposing T across the boundary
template<typename T>
DrawableHandle make_drawable_handle(T* obj) {
    static const DrawableVtable vt {
        [](void* p){ static_cast<T*>(p)->draw();    },
        [](void* p){ delete static_cast<T*>(p);      }
    };
    return DrawableHandle{ obj, &vt };
}

Passing std::function<void()> across a shared library boundary is safe only when both sides were compiled with the same standard library version and ABI: std::function's internal layout (vtable pointer, SBO buffer, allocator) is not standardised. For stable plugin interfaces, prefer the C vtable approach above; it survives recompilation of either side independently and works from C, Python (ctypes), and Rust (FFI) without a C++ toolchain.

Performance: Indirect Calls and Devirtualization

The fundamental cost of runtime type erasure is an indirect call through a pointer β€” the processor's branch predictor cannot inline across it, and the target address is not known until the call is executed. The table below compares the four main dispatch strategies:

MechanismHeap allocationIndirect callInlining possibleCache behavior
std::variant + visitNone (stack)None (jump table / if-chain)Yes β€” all types visibleHot: discriminator + data co-located
Hand-rolled vtable (Concept+Model)Yes (per object)1 (through Concept*)Only with final + PGOWarm: vtable pointer chased once
std::functionSBO or heap1–2 (function pointer + possible trampoline)RarelyCold: callable address opaque
Virtual dispatch (base class ptr)Yes (usually)1 (through vptr)Only with final + devirtWarm: vptr at fixed offset

std::variant + std::visit is the only form the compiler can fully devirtualize: all types are listed in the variant's type parameter pack, so the optimizer sees every branch at compile time and can inline the visitor body into each. Mark Model as final in the Concept+Model pattern to give the optimizer the same hint for cases where the dynamic type is provably known at a call site. Profile with a realistic workload before switching from std::function to a hand-rolled vtable β€” the SBO path is fast, and memory-access patterns often dominate over dispatch overhead.

See Also

  • CRTP β€” compile-time polymorphism without virtual dispatch
  • Policy-Based Design β€” static duck typing via template parameters
  • std::variant β€” closed-set type-safe discriminated union
  • Virtual Dispatch β€” intrusive runtime polymorphism baseline