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 pointer

Because 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:

  1. Erased Storage: A generic memory region (either inline storage or a heap pointer) that holds the actual state of the callable.
  2. An Invoker Stub: A templated function generated at compile time that casts the erased storage back to the concrete type and invokes it.
  3. 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 placement new.
  • Large Objects: If a lambda captures large arrays or objects exceeding the inline capacity, std::function allocates 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():

  1. std::function checks whether an invoker function pointer is bound. If it is nullptr, it throws std::bad_function_call.
  2. It forwards its arguments (std::forward<Args>(args)...) directly to the stored invoker stub pointer.
  3. The invoker stub reinterprets the untyped byte buffer (or dereferences the heap pointer) back into a concrete pointer of the original callable type (Callable*).
  4. The invoker invokes the object using standard call syntax (*target)(...) (or std::invoke to handle member functions) and returns the result, casting it to ReturnType.

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.