std::function vs Function Pointers vs Template Callables in C++: Type Erasure Cost, SBO and C Interop

Key takeaways

A function pointer is 8 bytes, never allocates, and works with C APIs, but it cannot carry state. std::function can hold any callable with a matching signature, but it costs an indirect call that blocks inlining, 32 bytes on libstdc++, a heap allocation for captures over 16 bytes there, and it rejects move-only lambdas. A template parameter is the fastest choice but cannot be stored in a container of mixed callables.

There are three common ways to accept “something callable” in C++, and they are not interchangeable:

void run(int (*fn)(int));                    // 1. function pointer
void run(std::function<int(int)> fn);        // 2. type-erased wrapper
template <class F> void run(F fn);           // 3. template parameter

The choice decides what callers can pass, whether the call can be inlined, whether constructing the callback allocates, and whether you can hand it to a C library. This article goes through each of those with real g++ 10.3 output. For the same trade-off seen as a design pattern, see the Strategy pattern in C++.

The short version

Function pointerstd::function<Sig>Template F
Accepts capturing lambdas / functorsNoYesYes
Size (x86-64, libstdc++)8 bytes32 bytessize of the callable
Heap allocationNeverWhen the callable doesn’t fit the inline bufferNever (by itself)
Inlinable at call siteOnly if the target is known after optimizationRarelyYes
Store mixed callables in one vectorYes, if all are plain functionsYesNo
Move-only callablesn/aNo (until std::move_only_function, C++23)Yes
Pass to a C APIYesNot directlyNot directly
Calling when emptyUndefined behaviorThrows std::bad_function_calln/a

Function pointers: a code address and nothing else

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

int (*op)(int, int) = add;          // or: using BinaryOp = int (*)(int, int);
int r = op(10, 20);                 // 30

A function pointer holds exactly one thing, the address of some code. That is why it is small, why it never allocates, and why every C library understands it. It is also why it cannot hold a lambda with captures, because there is no slot for the captured values:

int x = 5;
auto lam = [x](int y) { return x + y; };
int (*p)(int) = lam;
error: cannot convert 'main()::<lambda(int)>' to 'int (*)(int)' in initialization

A lambda with an empty capture list does convert implicitly. A unary + forces the conversion when you want auto to deduce a pointer type: auto fp = +[](int a, int b) { return a + b; };.

Passing state to a C API: the void* user-data trampoline

C callback APIs (qsort_r, pthread_create, SQLite’s sqlite3_exec, most GUI toolkits) solve the “no state” problem with an extra void* that they pass back to you unchanged. The idiomatic C++ side is a captureless lambda that casts the pointer back:

typedef void (*visit_fn)(int value, void* user);
void for_each_int(const int* data, int n, visit_fn fn, void* user);   // C API

struct Sum { int total = 0; };
Sum s;
int data[] = {3, 1, 2};
for_each_int(data, 3, [](int v, void* u) { static_cast<Sum*>(u)->total += v; }, &s);
// s.total == 6

You can bridge any std::function the same way by passing &cb as the user data and calling (*static_cast<std::function<void(int)>*>(u))(v) in the trampoline. The C library never owns that pointer. If it keeps the callback around after the call returns, as timers and event loops do, the object behind the void* has to outlive the registration. A stack variable won’t. Also, don’t let a C++ exception propagate out of the trampoline into C code. Catch it inside.

std::function: any callable, for a price

#include <functional>

int multiplier = 5;
std::function<int(int)> f = [multiplier](int x) { return x * multiplier; };
f(10);                                       // 50

struct Adder { int base; int operator()(int x) const { return x + base; } };
f = Adder{10};
f(5);                                        // 15

std::function uses type erasure. It stores the callable (or a pointer to it) plus a small table of “how to call, copy and destroy this thing”, so one type, std::function<int(int)>, can hold a lambda, a functor, a function pointer or a std::bind result. The mechanism is explained step by step in type erasure in C++. Three costs come with it.

1. The call goes through an indirection the optimizer usually can’t see through. The indirect call itself is cheap on modern CPUs. The bigger loss is that the target can’t be inlined, so a one-line comparator stays a real function call in the middle of your loop, and whatever optimizations inlining would have enabled (vectorization, constant folding) never happen. That is why std::sort takes its comparator as a template parameter. How much this matters depends entirely on how much work the callable does, so measure your own loop rather than trusting a generic multiplier.

2. Large captures allocate. Implementations store small callables inside the std::function object (the small-buffer optimization) and heap-allocate the rest. On g++ 10.3 / libstdc++, sizeof(std::function<void()>) is 32 and the inline buffer is 16 bytes. A test that counted operator new calls while constructing a std::function from a lambda capturing an N-byte blob printed:

  8-byte capture: 0 heap allocation(s)
 16-byte capture: 0 heap allocation(s)
 17-byte capture: 1 heap allocation(s)
 24-byte capture: 1 heap allocation(s)
 64-byte capture: 1 heap allocation(s)

So capturing this plus one pointer is free, but capturing a std::string by value (32 bytes) allocates on every construction and copy. libstdc++ also keeps a callable inline only if it is safe to move with a plain byte copy, so some small types allocate too. MSVC and libc++ use different buffer sizes. The standard only guarantees no allocation for function pointers and std::reference_wrapper.

3. The callable must be copyable. std::function is copyable, so it refuses anything that isn’t:

auto up = std::make_unique<int>(1);
std::function<int()> f = [p = std::move(up)] { return *p; };
error: use of deleted function 'std::unique_ptr<_Tp, _Dp>::unique_ptr(const std::unique_ptr<_Tp, _Dp>&) ...'

This hits every time you try to queue a task that owns a resource, such as a socket, a file handle or a std::promise. C++23’s std::move_only_function (GCC 12+, MSVC 17.2+) fixes it. Before that, the choices are a template parameter, a hand-written move-only wrapper, or std::shared_ptr around the resource when shared ownership is acceptable.

Calling an empty std::function

std::function<void()> empty;
try { empty(); }
catch (const std::bad_function_call& e) { std::cout << e.what(); }   // prints: bad_function_call

At least this is a defined exception, where a null function pointer is a crash. Neither should happen in normal flow, so check if (cb) wherever “no callback registered” is a legitimate state.

Signature mismatches are compile errors, but ugly ones

std::function<int(int)> func;
func = [](int a, int b) { return a + b; };
error: no match for 'operator=' (operand types are 'std::function<int(int)>' and 'main()::<lambda(int, int)>')
error: no type named 'type' in 'struct std::enable_if<false, std::function<int(int)>&>'

The second line is the SFINAE constraint that rejected the lambda. Read it as “this callable can’t be called as int(int)”. Conversions are allowed, though. A lambda taking long and returning short fits std::function<int(int)> silently, which can hide a narrowing bug.

Template parameters: fastest, but they spread

template <class F>
void process(const std::vector<int>& data, F&& callback) {
    for (int v : data) callback(v);        // callback's type is known: inlinable
}

The compiler sees the exact lambda type, so the call can be inlined and there is no allocation or indirection. The costs are structural rather than runtime:

  • The function has to live in a header, and each distinct callable type produces another instantiation, which adds compile time and code size.
  • You can’t store “some F” as a member unless the class is itself a template. Once callbacks must be held in a container, registered at runtime or crossed over an ABI boundary, you’re back to type erasure.
  • Error messages for a wrong callable point deep inside the template. A C++20 constraint such as std::invocable<F&, int> puts the error back at the call site.

A practical rule: take a template parameter in functions that call the callback immediately (algorithms, visitors, for_each-style helpers). Take std::function in classes that store it (event buses, job queues, UI signals). Take a function pointer plus void* only at a C boundary.

The bug std::function doesn’t protect you from: stored captures that outlive their target

The most expensive callback bugs I’ve debugged were not about speed. They came from a std::function that lived longer than something it captured. The typical shape is an object that registers [this] { onTick(); } with a timer or event bus and never unregisters in its destructor. The bus keeps a perfectly valid std::function. It just points at a freed object, and the crash shows up later somewhere unrelated to the registration. [&local] captures in a callback returned from a function fail the same way, only sooner:

std::function<int()> makeCallback() {
    int local = 42;
    return [&local] { return local; };      // dangling as soon as makeCallback returns
}

What helped me was a rule: every subscribe returns a handle whose destructor unsubscribes, and the handle is a member of the object whose this is captured. The two lifetimes are then tied by construction instead of by discipline. When the callback really must outlive its owner, capture a std::weak_ptr and lock() it at call time. See lambda captures for the capture rules themselves.