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++11Type 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 signaturestd::any(C++17) — erases anyCopyConstructiblevaluestd::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.
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 classclone() 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.
#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 casestd::move_only_function<Sig> (C++23) removes the CopyConstructible requirement and permits richer call qualifiers (const, noexcept, &/&&):
#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:
#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.
#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
| Type | Standard | Ownership | Allocation | Notes |
|---|---|---|---|---|
std::function<Sig> | C++11 | Owning, copying | SBO + heap | General purpose |
std::move_only_function<Sig> | C++23 | Owning, move-only | SBO + heap | Move-only callables; richer qualifiers |
std::copyable_function<Sig> | C++26 | Owning, copying | SBO + heap | Explicit copy semantics |
std::function_ref<Sig> | C++26 | Non-owning | None | Pointer pair; must not outlive callable |
| Template parameter | N/A | N/A | None | Compile-time only; fully inlineable |
| Virtual interface | C++98 | User-managed | User-managed | Intrusive; 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:
#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::variantwhen the type set is known at compile time. Stack-allocated, zero-overhead, exhaustiveness-checked. Reservestd::anyand custom erasure for genuinely open type sets. - Prefer
function_ref(C++26) or template parameters for function arguments. Only usestd::functionwhen 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 holdShapeinunique_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 orshared_ptrrather than by value for large objects. - Mark
Modelasfinal. When the compiler sees aModel*through theConcept*vtable,finalenables 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
- 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