How std::function Implements Type Erasure in C++
std::function is a general-purpose polymorphic function
wrapper in C++ that can store, copy, and invoke any callable target—such
as lambdas, free functions, member function pointers, and
functors—matching a specified signature. It accomplishes this uniform
interface without templating the enclosing class on the callable's
concrete type through a technique known as type erasure. This article
breaks down how std::function manages type erasure under
the hood, how it uses Small Buffer Optimization (SBO) to balance
performance and flexibility, and how it safely dispatches invocations to
the underlying callable.
The Challenge of Callable Abstraction
In C++, two callables with the exact same signature (e.g.,
int(int)) can have entirely different underlying types and
memory layouts:
auto lambda = [x = 10](int a) { return a + x; }; // Size depends on captured variables
int free_function(int a) { return a * 2; }; // Size of a function pointerBecause their sizes and calling mechanisms differ, a container cannot
store them interchangeably without an abstraction layer. Regular
templates resolve this at compile time, but
std::function<int(int)> must resolve this at runtime,
allowing reassignment between completely different callable types.
The Anatomy of Type Erasure
Standard library implementations (such as libc++ and
libstdc++) typically implement type erasure in
std::function using one of two patterns: classic
object-oriented polymorphism or function-pointer
dispatch (manual vtables). Most modern implementations favor
manual function pointers to optimize memory layout and avoid the
overhead of full C++ runtime type information (RTTI) and
compiler-generated vtables.
Regardless of the specific technique, type erasure relies on separating the callable into:
- Erased Storage: A generic memory region (either inline storage or a heap pointer) that holds the actual state of the callable.
- An Invoker Stub: A templated function generated at compile time that casts the erased storage back to the concrete type and invokes it.
- A Manager Stub: A templated function responsible for lifecycle operations: copying, moving, destroying, and querying RTTI for the erased callable.
Conceptual Implementation: Function Pointer Dispatch
To illustrate how std::function achieves this without
virtual inheritance overhead, consider this simplified model:
template <typename Signature>
class simple_function;
template <typename ReturnType, typename... Args>
class simple_function<ReturnType(Args...)> {
// Untyped storage for the callable
union Storage {
void* heap_ptr;
alignas(max_align_t) char local_buffer[32];
} storage;
// Function pointer signatures for invoker and manager
using InvokerFn = ReturnType(*)(Storage&, Args&&...);
using ManagerFn = void(*)(Storage& destination, Storage& source, Operation op);
InvokerFn invoker = nullptr;
ManagerFn manager = nullptr;
public:
template <typename Callable>
simple_function(Callable c) {
using DecayedCallable = std::decay_t<Callable>;
// 1. Store the callable (using SBO or heap allocation)
if constexpr (sizeof(DecayedCallable) <= sizeof(storage.local_buffer) &&
std::is_nothrow_move_constructible_v<DecayedCallable>) {
new (storage.local_buffer) DecayedCallable(std::move(c));
} else {
storage.heap_ptr = new DecayedCallable(std::move(c));
}
// 2. Instantiate and assign the invoker stub
invoker = [](Storage& s, Args&&... args) -> ReturnType {
DecayedCallable* target = /* resolve address from local_buffer or heap_ptr */;
return (*target)(std::forward<Args>(args)...);
};
// 3. Instantiate and assign the manager stub (handles copy, destroy, etc.)
manager = [](Storage& dest, Storage& src, Operation op) {
// Reinterprets storage back to DecayedCallable* to destroy or clone
};
}
ReturnType operator()(Args... args) const {
if (!invoker) throw std::bad_function_call();
return invoker(const_cast<Storage&>(storage), std::forward<Args>(args)...);
}
};Small Buffer Optimization (SBO)
Heap allocations introduce latency and cache misses. To mitigate
this, std::function includes a small internal byte
array—typically between 16 and 32 bytes (often equivalent to 3 to 4
pointers).
- Small Objects: If the callable (such as a function
pointer, a reference wrapper, or a small lambda) is smaller than the
internal buffer and satisfies nothrow-movability requirements, it is
constructed directly within
std::function's local storage via placementnew. - Large Objects: If a lambda captures large arrays or
objects exceeding the inline capacity,
std::functionallocates memory on the heap and stores the pointer within the local buffer.
The manager function tracks whether the callable is stored locally or
on the heap, ensuring that delete is only called when heap
memory was used.
Invocation Mechanics
When invoking operator():
std::functionchecks whether an invoker function pointer is bound. If it isnullptr, it throwsstd::bad_function_call.- It forwards its arguments
(
std::forward<Args>(args)...) directly to the storedinvokerstub pointer. - The
invokerstub reinterprets the untyped byte buffer (or dereferences the heap pointer) back into a concrete pointer of the original callable type (Callable*). - The
invokerinvokes the object using standard call syntax(*target)(...)(orstd::invoketo handle member functions) and returns the result, casting it toReturnType.
Because the concrete type is retained inside the static scope of the generated stub, the outer wrapper never needs to know the type it holds, completing the type-erasure lifecycle.