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.

See Also