Understanding std::function in Modern C++

In this article, I wanted to understand std::function properly, not just at the surface level, but also what is happening under the hood. std::function is a type-erased wrapper introduced in C++11 that can store any callable target with a matching function signature. This sounds simple, but it solves a real problem in C++: every callable has its own type.

What is a Callable?

A callable is anything in C++ that can be called like a function. A function is one type of callable, but not all callables are functions. Lambdas, function pointers, and functor objects are also callables.

Normal Function

A normal function is the most obvious example of a callable.

int add(int a, int b) {
    return a + b;
}

add(2, 3);

Function Pointer

A function pointer stores the address of a function. Conceptually, it points to code in the code segment.

int add(int a, int b) {
    return a + b;
}

int (*fp)(int, int) = add;

fp(2, 3);

Lambda

A lambda is an anonymous function object created by the compiler. Lambdas deserve their own article, but at a basic level, they let you define a small callable inline.

auto f = [](int x) {
    return x + 1;
};

f(10);

Functor

A functor is an object that defines operator(). This allows an object to be called like a function.

struct AddOne {
    int factor;

    int operator()(int x) const {
        return x + factor;
    }
};

AddOne f{1};

f(10);

Callables matter because many C++ APIs accept callables as arguments. For example, you might pass in what comparison function to use, what callback to run, or what strategy to apply inside another function.

The Problem: Every Callable Has a Different Type

The key issue is that every callable has its own type. Even two lambdas that look almost identical have different compiler-generated types.

auto l1 = [](int x) { return x + 1; };
auto l2 = [factor = 10](int x) { return x * factor; };

Both lambdas take an int and return an int, but their actual types are different. The compiler creates a unique closure type for each lambda expression.

Why This Becomes Awkward

Homogeneous Containers

Containers like std::vector are homogeneous. This means every element inside the vector must have the same type. Since two lambdas have different types, you cannot directly put them into the same vector.

auto l1 = [](int x) { return x + 1; };
auto l2 = [factor = 10](int x) { return x * factor; };

// This does not work directly because l1 and l2 have different types.

Runtime Selection

Runtime selection also becomes awkward. Suppose you want to choose a strategy based on some runtime configuration.

bool use_fast_path = get_config();

auto f = use_fast_path
    ? [](int x) { return x + 1; }
    : [](int x) { return x * 2; };

This does not compile because the conditional operator needs both branches to have compatible types. These two lambdas have different types, even though their signatures look the same.

Storing Callbacks in Non-Template APIs

Without std::function, accepting any callable usually means writing a template.

template <class F>
void register_callback(F cb) {
    cb();
}

This is great for performance because the compiler knows the exact callable type, and it is great when you want to use the callable immediately. However, if you wish to store the callback as a member field while preserving its type, the whole class needs to be templated on the callable type too.

std::function Solves This

std::function gives all compatible callables one uniform wrapper type. Instead of caring about the exact type of the lambda, functor, or function pointer, we care only about the function signature.

std::vector<std::function<int(int)>> callbacks;

callbacks.push_back([](int x) { return x + 1; });
callbacks.push_back([factor = 10](int x) { return x * factor; });

Here, both lambdas can be stored in the same vector because both are wrapped inside the same type: std::function<int(int)>.

Why Not Just Use Function Pointers?

Function pointers work only when all you need is a pointer to code. They do not store state. This is why they work with normal functions and non-capturing lambdas, but not with capturing lambdas.

int (*fp)(int) = [](int x) {
    return x + 1;
};

A non-capturing lambda has no state, so it can decay to a function pointer.

auto l = [factor = 10](int x) {
    return x * factor;
};

int (*fp)(int) = l; // error

This does not work because the function pointer has nowhere to store factor = 10. A function pointer only points to code. A capturing lambda needs both code and state.

Where Does a Lambda Live?

A lambda has two conceptual parts:

For example, this lambda:

auto l = [factor = 10](int x) {
    return x * factor;
};

Is roughly transformed by the compiler into something like this:

struct Lambda {
    int factor;

    int operator()(int x) const {
        return x * factor;
    }
};

The machine code for Lambda::operator() lives in the code segment. The lambda object itself stores the captured data and lives wherever the object is stored.

What std::function Stores Internally

Conceptually, std::function stores three things:

  1. The actual callable object.
  2. An invoker function pointer.
  3. Manager logic for copy, move, and destroy, usually a pointer.

The std::function object can be thought of like this:

std::function<int(int)> f
+--------------------------------------+
| callable object storage pointer or stored inline   |
| invoker pointer: &invoke_impl<Lambda>        |
| manager pointer: &manager_impl<Lambda>       |
+--------------------------------------+

The callable object stores the actual lambda state. The invoker is a function that knows how to call that specific lambda type. The manager is logic that knows how to copy, move, and destroy that specific lambda type. The C++ standard does not force the exact layout but the manager and invoker are usually pointers into the code segment.

int invoke_impl(void* obj, int x) {
    Lambda* lambda = static_cast<Lambda*>(obj);
    return (*lambda)(x);
}

The invoker and manager are functions, so their machine code lives in the code segment. These helper functions are often called thunks. A thunk is a small helper function that adapts one calling convention or erased type into the real concrete call.

The Cost of std::function

std::function is flexible, but it is not free. When you call a std::function, the exact callable type has already been erased, so the call usually goes through the invoker pointer as below:

  1. Load the invoker pointer from the std::function object.
  2. Load or obtain a pointer to the stored callable object (depends if stored inline).
  3. Perform an indirect call to the address stored in the invoker pointer.

The invoker pointer is stored inside the std::function object, but the helper function it points to lives in the code segment. That helper function knows the original callable type, casts the erased object pointer back to the real type, and then calls the callable's operator().

This adds at least an invoker pointer load and an indirect call. If the callable is heap-allocated, there may also be an extra pointer dereference to reach the callable object.

Why It Is Harder for the Compiler to Optimize

With a direct lambda, the compiler knows the exact callable type. With std::function, the call target is erased behind an invoker pointer. This makes several optimizations harder.

Branch Prediction Cost

In CPU terms, a branch is any instruction that changes control flow. It is not just an if statement. Function calls, returns, jumps, and indirect calls are also control-flow changes.

A call through std::function is usually an indirect branch because the CPU must jump to an address stored in a pointer. If you have a vector of std::function objects and each element wraps a different callable, each iteration may jump to a different target address. This is harder for the CPU to predict. Some books call this indirect branch prediction.

Small Buffer Optimization

Most implementations of std::function use a small buffer optimization. The idea is simple: if the callable object is small enough, store it directly inside the std::function object instead of allocating it on the heap.

std::function<int(int)> f = [factor = 10](int x) {
    return x * factor;
};

This lambda object is roughly:

struct Lambda_456 {
    int factor;

    int operator()(int x) const {
        return x * factor;
    }
};

It only stores one integer, so it is probably 4 bytes, maybe padded to 4 or 8 bytes. This is small enough to fit in the internal buffer of many std::function implementations.

For a small lambda, the layout is conceptually:

std::function
+----------------------------------+
| Lambda stored inline             |
| invoker pointer                  |
| manager pointer                  |
+----------------------------------+

For a large lambda, the layout may instead be:

std::function
+----------------------------------+
| pointer to heap Lambda           |
| invoker pointer                  |
| manager pointer                  |
+----------------------------------+

heap:
+----------------------------------+
| actual Lambda object             |
+----------------------------------+

For the small inline case, there is usually no extra heap pointer dereference to reach the callable object. The lambda object is already stored inside the std::function object. However, the call still goes through the invoker pointer, so there is still an indirect call.An indirect call means the CPU does not call a fixed known function address encoded in the instruction. Instead, it first reads the target address from a register or memory, then jumps to that address.

Small buffer optimization avoids heap allocations, removes one pointer dereference, and improves cache locality. This is why small capturing lambdas are much cheaper to store in std::function than large capturing lambdas.

Large Captures

A large direct lambda can still live directly on the stack and may be called directly or even inlined.

auto f = [big](int x) {
    return big[x];
};

int y = f(5);

With std::function, a large captured object may not fit inside the small buffer, so it may be moved to the heap.

std::function<int(int)> f = [big](int x) {
    return big[x];
};

int y = f(5);

Direct lambda with large capture: large object on stack, direct call, easier to inline. std::function with large capture: wrapper on stack, captured object likely on heap, indirect call plus heap pointer dereference.

Copying and Moving std::function

Copying a std::function copies the stored callable state. If the callable is stored on the heap, this usually means allocating new storage and copying the target into it.

Moving a std::function is cheaper because the implementation can transfer ownership of the stored target where possible. For heap-allocated callables, this can often be a pointer transfer. For small-buffer callables, the target may still need to be move-constructed into the destination buffer.

When Should You Use std::function?

Use std::function when you need runtime flexibility, type erasure, storage, or a stable non-template interface.

Avoid std::function in very hot paths where the callable type is known at compile time. In those cases, templates or direct lambdas usually allow better inlining and optimization.

Full Flow: std::function

A useful mental model is:

Lambda object:
    factor = 10

std::function:
    storage = inline Lambda object or pointer to heap Lambda object
    invoker ptr = call_thunk<Lambda>
    manager ptr = manager_thunk<Lambda>

When constructing the std::function:

  1. The compiler creates a unique lambda type.
  2. The lambda object is constructed with factor = 10.
  3. std::function sees the concrete lambda type at construction time.
  4. If the lambda fits the small buffer, it is placement-newed into the internal buffer.
  5. If the lambda does not fit, it is allocated on the heap.
  6. An invoker function pointer specialized for that lambda type is stored.
  7. A manager function pointer specialized for that lambda type is stored.

When invoking the std::function:

  1. The user calls f(7).
  2. std::function::operator() checks whether the function is empty.
  3. If it is empty, it throws std::bad_function_call.
  4. It loads the invoker pointer.
  5. It obtains a pointer to the stored callable object.
  6. If the callable is stored inline, this pointer refers to the inline storage inside the std::function.
  7. If the callable is stored on the heap, this may require an extra pointer dereference to reach the heap-allocated callable object.
  8. It performs an indirect call: invoker(target_ptr, 7).
  9. The invoker casts target_ptr back to Lambda*.
  10. The lambda's operator() is called.

Full Flow: Direct Lambda

A direct lambda is much simpler.

auto f = [factor = 10](int x) {
    return x * factor;
};

int result = f(7);
  1. The lambda object exists with factor = 10.
  2. The compiler sees the exact lambda type.
  3. The compiler calls Lambda::operator()(7).
  4. The compiler can likely inline the call.
  5. The result can become 7 * 10.
  6. The final result is 70.

This is why direct lambdas are usually easier for the compiler to optimize. There is no type erasure, no invoker pointer, and no indirect call.

Final Thoughts

std::function is one of those C++ utilities that looks simple but hides a lot of interesting machinery. It lets us store different callable types behind one uniform interface, which is extremely useful for callbacks, strategies, and runtime polymorphism. The tradeoff is performance: extra indirection, harder inlining, possible heap allocation, and worse branch prediction.