Contact
Evil Genius SocietyHomeLearnForumLeaderboardSocialsMessages
← Back to module

Lesson 4 of 5

Every lambda has its own type, which no one can write (lesson 14.1). So how do you store one in a class member, or a std::vector of callbacks?

std::function<Signature> holds any callable matching a signature — at a price this lesson measures.

What it does

// std::function<int(int)> f;
//
// f = [](int x) { return x * 2; };        // a lambda
// f = doubler;                            // a function
// f = Multiplier{2};                      // a function object
// f = [n = 2](int x) { return x * n; };   // a capturing lambda
//
// All four fit, because std::function stores anything callable as int(int).

That is type erasure: the concrete type is hidden behind a uniform interface. It is what makes callbacks, event handlers, and std::vector<std::function<void()>> possible.

The cost, measured

#include <chrono>
#include <functional>
#include <iostream>
#include <vector>

template <typename F>
long long time_us(F f) {
    auto t0 = std::chrono::steady_clock::now();
    f();
    auto t1 = std::chrono::steady_clock::now();
    return std::chrono::duration_cast<std::chrono::microseconds>(t1 - t0).count();
}

// Takes the lambda's own type: the call can be inlined.
template <typename F>
long long sum_with_template(const std::vector<int>& v, F transform) {
    long long total = 0;
    for (int x : v) total += transform(x);
    return total;
}

// Takes std::function: an indirect call the compiler usually cannot inline.
long long sum_with_function(const std::vector<int>& v, const std::function<int(int)>& transform) {
    long long total = 0;
    for (int x : v) total += transform(x);
    return total;
}

// A plain function pointer, for comparison.
long long sum_with_pointer(const std::vector<int>& v, int (*transform)(int)) {
    long long total = 0;
    for (int x : v) total += transform(x);
    return total;
}

int doubler(int x) { return x * 2; }

int main() {
    std::vector<int> v(5000000);
    for (std::size_t i = 0; i < v.size(); ++i) v[i] = static_cast<int>(i % 100);

    auto lambda = [](int x) { return x * 2; };
    std::function<int(int)> fn = lambda;

    long long sink = 0;
    auto t_tmpl = time_us([&]{ sink += sum_with_template(v, lambda); });
    auto t_fn   = time_us([&]{ sink += sum_with_function(v, fn); });
    auto t_ptr  = time_us([&]{ sink += sum_with_pointer(v, doubler); });

    std::cout << "template parameter: " << t_tmpl << " us\n";
    std::cout << "std::function:      " << t_fn << " us\n";
    std::cout << "function pointer:   " << t_ptr << " us\n";
    std::cout << "(sink " << sink << ")\n";

    std::cout << "\nsizeof(lambda):              " << sizeof(lambda) << "\n";
    std::cout << "sizeof(std::function<int(int)>): " << sizeof(fn) << "\n";
    std::cout << "sizeof(int(*)(int)):         " << sizeof(&doubler) << "\n";
    return 0;
}

A representative run, compiled -O2:

template parameter: 1543 us
std::function:      6288 us
function pointer:   6217 us
(sink 1485000000)

sizeof(lambda):              1
sizeof(std::function<int(int)>): 32
sizeof(int(*)(int)):         8

The template version was 4× faster. Passing the lambda's own type lets the compiler see the body and inline it — the multiply happens directly in the loop, with no call at all.

std::function and the raw function pointer performed the same. That is the honest result and it is informative: the cost is not std::function being bloated, it is the indirect call. Any mechanism that hides which function will run pays it.

Sizes: the lambda is 1 byte, the function pointer 8, and std::function 32 — a small buffer plus bookkeeping.

When it allocates

std::function has a small-buffer optimisation. Counting heap allocations with a replaced operator new finds the boundary exactly:

#include <cstddef>
#include <cstdlib>
#include <functional>
#include <iostream>

static int allocations = 0;
void* operator new(std::size_t n) { ++allocations; return std::malloc(n); }
void operator delete(void* p) noexcept { std::free(p); }
void operator delete(void* p, std::size_t) noexcept { std::free(p); }

template <int N>
struct Blob { char data[N]; };

template <int N>
void probe() {
    Blob<N> blob{};
    allocations = 0;
    std::function<void()> f = [blob] { (void)blob; };
    std::cout << "  capture of " << N << " bytes: " << allocations << " allocation(s)\n";
}

int main() {
    std::cout << "sizeof(std::function<void()>) = " << sizeof(std::function<void()>) << "\n";
    probe<8>(); probe<12>(); probe<16>(); probe<17>(); probe<24>(); probe<32>();
    return 0;
}
sizeof(std::function<void()>) = 32
  capture of 8 bytes: 0 allocation(s)
  capture of 12 bytes: 0 allocation(s)
  capture of 16 bytes: 0 allocation(s)
  capture of 17 bytes: 1 allocation(s)
  capture of 24 bytes: 1 allocation(s)
  capture of 32 bytes: 1 allocation(s)

16 bytes is the exact threshold in libstdc++. Up to that, the closure lives inside the std::function; one byte more and it goes on the heap.

Sixteen bytes is two pointers, or two long longs, or a std::string — no, a std::string is 32, so capturing one by value allocates. That is easy to do by accident:

// std::function<void()> f = [name] { use(name); };     // allocates: string is 32 bytes
// std::function<void()> f = [&name] { use(name); };    // no allocation — but see lesson 14.2

The number is not portable. It is 16 in libstdc++ and different in libc++ and MSVC, so do not build a design on it — but do know that a callback stored per event, in a loop, may be allocating.

The alternative: a template parameter

// std::function — one compiled function, indirect call, may allocate.
// void apply(const std::vector<int>& v, const std::function<int(int)>& f);
//
// Template — one instantiation per callable, inlinable, no allocation.
// template <typename F>
// void apply(const std::vector<int>& v, F f);
//
// C++20 concept — same as the template, with a readable error.
// void apply(const std::vector<int>& v, std::invocable<int> auto f);

The template is faster and the std::function is more flexible, and the choice is decided by when you know which callable it is.

| | Template parameter | std::function | |---|---|---| | Call | inlinable | indirect | | Allocation | never | if the closure exceeds the buffer | | Stored in a member | no (type is unnamable) | yes | | Stored in a container | no | yes | | Reassigned at run time | no | yes | | Compiled | once per callable type | once | | Must be in a header | yes | no |

The last row matters in a large codebase: a template must be visible at every call site (lesson 12.1), while a std::function parameter lets the implementation live in a .cpp. That is a real reason to accept the indirect call.

When to use which

Use a template parameter for a callable you pass down and call immediately — algorithms, for_each-style helpers, anything in a hot loop. This is the default.

Use std::function when the callable must be stored: a class member, a std::vector of handlers, a registry, or an API boundary compiled separately.

Use a plain function pointer only for a C API, or when there is genuinely no state. It is no faster than std::function — the measurement above shows the two matching — and it cannot hold a capturing lambda at all.

A capture-less lambda converts implicitly to a function pointer, which is how you pass one to a C callback:

// void register_handler(void (*cb)(int));
// register_handler([](int x) { std::cout << x; });      // fine, no captures
// register_handler([&](int x) { std::cout << n + x; }); // error: captures

std::move_only_function (C++23)

std::function requires its target to be copyable, so a lambda capturing a std::unique_ptr does not fit. C++23 adds std::move_only_function, which does not:

// std::function<void()> f = [p = std::make_unique<int>(1)] { };            // error
// std::move_only_function<void()> g = [p = std::make_unique<int>(1)] { };  // fine

GCC 13 supports it with -std=c++23. Before that, the workaround is a captured std::shared_ptr, which is copyable — as lesson 14.2's exercise did.

Try it

Build a small event system: register handlers by name, then fire an event and run all of them. Then write the same thing as a template and say which parts of the design force std::function.

Checkpoint

  • std::function<Sig> holds any callable matching a signature — type erasure.
  • Measured: a template parameter was 4× faster, because the call inlines.
  • std::function and a raw function pointer performed the same — the cost is the indirect call.
  • sizeof(std::function<int(int)>) is 32; the lambda itself was 1 byte.
  • libstdc++ stores closures up to 16 bytes inline; 17 allocates. The number is not portable.
  • Capturing a std::string by value (32 bytes) allocates on every construction.
  • Use a template parameter for callables passed down and called immediately.
  • Use std::function when the callable must be stored, reassigned, or crossed over an ABI.
  • A template must live in a header; a std::function parameter lets the body stay in a .cpp.
  • A capture-less lambda converts to a function pointer; a capturing one does not.
  • std::function requires a copyable target; C++23's std::move_only_function does not.

Questions about this lesson

Log in to ask a question.