C++ Part I · Module 20 · Iterators, Algorithms, and Lambdas
std::function and Storing a Callable
Chapter 20.8 established that every lambda has its own unnamed type, which is why one is held in auto and passed straight to an algorithm. That is sufficient for everything in this module so far and insufficient the moment you need a variable, a member, or a container element whose type must be written down while the callable it holds is chosen elsewhere. A callback registered at run time, a strategy selected from a configuration file, a vector of different operations applied in sequence — none of those can name the type of what they hold. std::function solves it by type erasure: it declares only the call signature and accepts anything callable with it, hiding the concrete type behind an indirection. That is a genuine capability with no alternative in the language, and it has a price this chapter measures rather than estimates. The object is thirty-two bytes. It stores small closures inside itself and heap-allocates larger ones, and the threshold is measured exactly rather than guessed. The call cannot be inlined, because the compiler does not know what it is calling, and that costs roughly four and a half times a direct lambda call. The chapter also reports a measurement that came out differently from the expectation and therefore changes the conclusion: a plain function pointer, which ought to show the same indirection penalty, turned out indistinguishable from the lambda — so what is being paid for is the type erasure specifically, not indirection in general.
Advanced19 min readC++Part IFundamentalsAlgorithms
Part I · Module 20 · Chapter 20.9 · std::function and Storing a Callable
1. The Problem It Solves
Chapter 20.8 §1 measured that two lambdas with identical bodies have different types. That makes several ordinary things impossible:
std::vector<???> handlers; // what type?
class Button { ??? onClick; }; // what type for the member?
??? pick(bool fast) { return fast ? [](int x){return x;} : [](int x){return x*2;}; }The last one does not even work with auto, because the two branches of the conditional have different types and there is no common type for the compiler to deduce.
std::function<R(Args...)> is the answer. It is a class template parameterised on a call signature rather than on a concrete callable type, and it will hold anything callable with that signature:
std::function<int(int)> f;
f = [](int x){ return x * 2; }; // a lambda
f = someFreeFunction; // a function pointer
f = SomeFunctor{}; // a class with operator()
f = [k](int x){ return x + k; }; // a capturing lambdaAll four assignments compile and all four are the same type std::function<int(int)>. That is type erasure: the concrete type is hidden behind a uniform interface, and the cost of hiding it is the subject of §4.
A vector of them works, which is the capability §6 demonstrates:
a vector of four DIFFERENT callables, applied in order: 20 -> 21 -> 42 -> 1 -> -92. What the Object Costs
sizeof(std::function<int(int)>) = 32
sizeof(int(*)(int)) = 8 (a plain function pointer)
sizeof(a stateless lambda) = 1
sizeof(a 6-int-capture lambda) = 24A std::function is 32 bytes regardless of what it holds — that is the point of a fixed type, and it is the same trade Chapter 19.2 §2 measured for std::vector: a fixed-size handle with the variable part elsewhere.
Inside those 32 bytes there is room for a small closure plus the bookkeeping needed to call and destroy it. When the closure does not fit, the remainder goes on the heap.
3. The Small-Object Buffer, Measured
Where exactly is the boundary? This needs an instrument, and getting it wrong is instructive.
My first attempt used the per-container counting allocator this module has used throughout. It reported zero allocations for every row, including a captured std::string — which measured nothing at all, because std::function does not take an allocator parameter and allocates through global operator new. A container allocator never sees it.
The correct instrument overrides the global operator:
struct NewCounters { std::size_t news = 0, dels = 0, bytes = 0; bool on = false; };
inline NewCounters gn;
void* operator new(std::size_t n)
{
if (gn.on) { gn.news++; gn.bytes += n; }
void* p = std::malloc(n ? n : 1);
if (!p) throw std::bad_alloc();
return p;
}
void operator delete(void* p) noexcept { if (gn.on && p) gn.dels++; std::free(p); }With that in place:
stateless lambda new=0 bytes=0
capturing one int (4 B) new=0 bytes=0
capturing two longs (16 B) new=0 bytes=0
capturing three longs (24 B) new=1 bytes=32
capturing four longs (32 B) new=1 bytes=40
capturing five longs (40 B) new=1 bytes=48The threshold is 16 bytes. Two longs fit inside the std::function; three do not. Beyond it, one allocation of roughly the closure size plus a word.
That number is libc++'s and is not portable — the standard says only that an implementation may avoid allocation for small callables, and MSVC and libstdc++ choose differently. What is portable is the shape: small closures are free, larger ones cost an allocation per std::function construction.
The practical consequences follow directly. A std::function holding a stateless lambda or a function pointer never allocates. One capturing two pointers' worth of state never allocates. One capturing a std::string by value always does — and if it is constructed inside a loop, that is an allocation per iteration. Chapter 20.8 §8's [p = std::move(p)] does not help here: the closure is still too big, and the move avoids copying the string's buffer but not the std::function's allocation.
4. The Call Cost
lambda (inlinable) 0.71 ns/call (range 0.56-2.15)
function pointer 0.72 ns/call (range 0.47-3.49)
std::function 4.35 ns/call (range 2.27-21.09)Medians of nine repetitions, across nine whole-process runs. The ratio std::function to lambda had a median of 4.40×; through std::count_if over two million elements the ratio's median was 4.33× (1.79 ms against 7.44 ms).
The lambda figure is what it is because the type is known at the call site, so operator() inlines and the % 3 becomes two instructions in the loop. The std::function figure is an indirect call through a stored pointer to a type the compiler cannot see, so nothing inlines and the loop body becomes a function call.
And the middle row is the one that changes the conclusion. A plain function pointer ought to be indirect too, and it measured 0.72 ns against the lambda's 0.71 — a median ratio of 0.94×, which is to say indistinguishable. The reason is that fp was a local variable initialised from a named function, and clang can see that, so it inlines through the pointer exactly as it does through the lambda.
That matters for the chapter's claim. It would be easy to report "indirection costs 4×" from the first and third rows alone. The middle row shows that is wrong: indirection the compiler can see through costs nothing, and what the 4.4× is paying for is specifically the type erasure — a call target that is genuinely unknown at compile time. A function pointer assigned from a run-time choice, or stored in a member and set elsewhere, would show the penalty too.
A note on the ranges: they are wide — up to 21 ns for a std::function call — and that is this machine under load rather than variation in the mechanism. The medians were stable across nine runs; the ratios are the finding and the absolutes are the scale, which is the discipline Chapter 19.8 §10 sets out.
5. When the Cost Is Worth Paying
Four nanoseconds per call is either nothing or everything depending on what the call does.
It is nothing when the callable does real work. A std::function wrapping a database query, a file write or a hundred lines of logic has a 4 ns overhead on a microsecond operation — a rounding error. Most callbacks are in this category.
It is nothing when the call happens rarely. A handler invoked on a button press, a configuration hook read at startup, an error callback: the count is small enough that the per-call cost cannot matter.
It is everything inside a hot loop over millions of elements, which is exactly §4's count_if measurement: 1.79 ms became 7.44 ms, and that 5.6 ms is real if the loop runs often.
So the rule is about call frequency and body size, not about the mechanism being good or bad. Measure before replacing a std::function in a hot path, and do not replace one in a cold path at all.
6. What It Buys
std::vector<std::function<int(int)>> pipeline;
pipeline.emplace_back([](int x){ return x + 1; });
pipeline.emplace_back([](int x){ return x * 2; });
pipeline.emplace_back(freeFn);
int k = 10;
pipeline.emplace_back([k](int x){ return x - k; });
int val = 20;
for (auto& f : pipeline) val = f(val);a vector of four DIFFERENT callables, applied in order: 20 -> 21 -> 42 -> 1 -> -9
a vector of lambdas is impossible: each lambda is its own type.Four different kinds of callable — two stateless lambdas, a free function, and a capturing lambda — in one container, applied in order. There is no way to write that with auto, and no way to write it with a template parameter either, because the elements genuinely have different types.
The same capability shows up as:
- A class member holding a callback set after construction.
- A return type for a factory that chooses an implementation at run time.
- A parameter in a non-template function, so the function can live in a
.cppfile rather than a header. This is a real and underrated use: a template takingF&&must be in a header (Chapter 18.7 §4), while a function takingstd::function<void(int)>can be compiled once. - Recursion in a lambda, which needs a named type to refer to itself.
7. The Alternatives, and When Each Is Better
A template parameter. template <typename F> void each(F f) accepts any callable with no erasure, no allocation and full inlining. This is what the standard algorithms do, and it is the right default when the callable is known at the call site. The costs are that the function must be in a header and that every distinct callable instantiates it again — Chapter 18.1 §6's code-size measurement of 180 to 843 bytes for one to eight instantiations.
A function pointer. Smallest (8 bytes), no allocation, C-compatible. Cannot hold state, so a capturing lambda will not convert — Chapter 20.8 §3's measured error.
std::function_ref or std::move_only_function. C++23 adds non-owning and move-only variants that avoid some of std::function's costs. function_ref in particular is the right tool for a parameter that is only called during the call, since it neither owns nor allocates. Until then, a template parameter covers the same case.
A virtual interface. Chapter 17.9's polymorphic interface is the alternative when the callable has several operations rather than one, or when it has identity and state worth naming. std::function is for one operation; an interface is for a role.
The decision in short: a template parameter when the type is known at the call site, std::function when it cannot be, and an interface when there is more than one operation.
8. The Empty State
default-constructed: operator bool = 0
calling it threw std::bad_function_call: std::bad_function_callA default-constructed std::function holds nothing, converts to false, and throws std::bad_function_call if called. That is a real difference from a lambda, which always has a body, and it means a std::function member needs checking:
if (onComplete) onComplete(result); // the idiomatic guardAssigning nullptr empties it again. The pattern worth adopting for a member is to give it a default that does nothing, so callers never have to check:
std::function<void(int)> onComplete = [](int){}; // never emptyThat trades a tiny indirect call for the removal of a whole class of forgotten-check bugs, and for a cold callback it is the right trade.
9. Copying, and What It Requires
std::function is copyable, and that imposes a requirement people hit unexpectedly: the callable it holds must be copyable. A lambda capturing a std::unique_ptr by move is not copyable, so it cannot be stored in a std::function at all — the error appears at the assignment, inside the library headers, in the shape Chapter 18.6 §5 described.
Copying a std::function copies the held closure, which means an allocation if the closure is over §3's 16-byte threshold. A std::function passed by value into a function therefore copies and possibly allocates; pass by const& when you only call it.
C++23's std::move_only_function removes the copyability requirement, which is the clean fix for the unique_ptr case.
10. The Comparison Table
| lambda via template param | function pointer | std::function | virtual interface | |
|---|---|---|---|---|
| object size | 1 byte stateless (measured) | 8 (measured) | 32 (measured) | 8 (the pointer) |
| holds state | yes | no | yes | yes |
| allocates | never | never | above 16 B of captures (measured) | on construction |
| call cost | 0.71 ns (measured) | 0.72 ns — same (measured) | 4.35 ns, ~4.4× (measured) | similar to std::function |
| inlinable | yes | yes, when visible (measured) | no | no |
| one type for many callables | no | only stateless ones | yes | yes |
| storable in a container | no | yes | yes (measured) | yes, as pointers |
| usable as a member | no | yes | yes | yes |
can live in a .cpp | no — header only | yes | yes | yes |
| can be empty | no | yes (nullptr) | yes, throws if called (measured) | yes (null pointer) |
| requires copyability | no | no | yes | no |
| several operations | no | no | no | yes |
11. What to Actually Do
- Use a template parameter when the callable's type is known at the call site. Free, inlinable, and what the algorithms do.
- Use
std::functionwhen the type genuinely cannot be known — a member, a container element, a run-time choice, a.cpp-only signature. - Pass it by
const&when you only call it; by value copies and may allocate. - Keep captures under 16 bytes where it matters, to stay inside the small-object buffer. Measured on libc++; verify on yours.
- Do not construct one inside a hot loop if the closure is over the threshold — that is an allocation per iteration.
- Check it before calling, or give the member a do-nothing default so callers never have to.
- Measure before replacing one in a hot path, and leave cold ones alone. Measured 4 ns per call.
- Reach for a virtual interface when the role has more than one operation.
- Remember the held callable must be copyable — a
unique_ptrcapture will not fit until C++23'smove_only_function. - Do not claim indirection is the cost. It is the invisibility of the target; a function pointer measured identical to the lambda.
12. Mistakes Worth Making Once
Using std::function as an algorithm's predicate parameter. Measured 4.33× slower through count_if. The algorithms take template parameters for this reason.
Constructing one per iteration with a big capture. One allocation each time.
Calling an empty one. std::bad_function_call, which is at least loud.
Storing a unique_ptr-capturing lambda in one. Not copyable; the error is inside the headers.
Passing by value into a function that only calls it. A copy and possibly an allocation per call.
Measuring std::function against a lambda and concluding indirection is slow. The function-pointer row contradicts it.
Reaching for std::function where a template parameter would do, purely because the signature reads more nicely. It reads more nicely and costs 4.4×.
Using the per-container counting allocator to measure its allocations. My own first attempt; it reported 0 for every case because std::function uses global operator new.
13. Interview Relevance
The frozen curriculum marks this chapter High, and the questions are mostly about understanding the trade rather than the API.
"What is std::function for?" Holding a callable whose concrete type cannot be named — a member, a container element, a run-time choice. The strong answer leads with the problem (every lambda has its own type) rather than the feature.
"What does it cost?" 32 bytes, an allocation above a small-object threshold measured here at 16 bytes, and roughly 4.4× a direct call because the target cannot be inlined.
"Is that because of the indirection?" The answer that stands out is no: a function pointer measured indistinguishable from the lambda, because the compiler could see through it. The cost is the unknowability of the target.
"Why do the standard algorithms take a template parameter rather than std::function?" So the callable's type is known at the call site and the body inlines — measured 4.33× through count_if.
"What happens if you call an empty one?" It throws std::bad_function_call. Hence if (f) or a do-nothing default.
"Can it hold a move-only callable?" Not in C++17 — it must be copyable. std::move_only_function in C++23 fixes it.
"When would you use a virtual interface instead?" When the role has several operations, or identity and state worth naming. std::function is one operation.
14. What You Should Be Able to Say Now
- Why
std::functionexists: every lambda has its own type, so a member, a container element or a run-time choice has no type to write. - That it is 32 bytes whatever it holds, against 8 for a function pointer and 1 for a stateless lambda.
- That libc++ stores closures up to 16 bytes inline and allocates beyond — two
longs fit, three do not — and that the threshold is implementation-specific while the shape is not. - That it costs about 4.4× a lambda per call, and 4.33× through
count_if. - That a function pointer measured the same as the lambda, so the cost is type erasure rather than indirection.
- That the algorithms take template parameters precisely to avoid it.
- That an empty one throws
std::bad_function_call, and the two ways to avoid that. - That the held callable must be copyable until C++23.
- That measuring its allocations needs a global
operator newoverride, not a container allocator.
15. Exercises
Exercise 1 — the threshold. Reproduce §3's sweep with 1 to 8 captured longs and find your implementation's small-object limit. Then try a captured std::string and explain the byte count.
Exercise 2 — the wrong instrument. Deliberately measure the allocations with a container-style allocator and confirm it reports zero. Explain in two sentences why.
Exercise 3 — the three call forms. Reproduce §4 with medians and ranges. Then make the function pointer's value unknowable at compile time — read it from argv — and re-measure. Report whether it joins the std::function row.
Exercise 4 — template versus erased. Write the same each function twice, once with a template parameter and once taking std::function. Measure both over ten million elements and compare the generated code size.
Exercise 5 — the pipeline. Build §6's vector of four callables and add a fifth that captures a 64-byte struct. Count allocations for the whole construction.
Exercise 6 — the empty state. Write a class with a std::function member, call it before assignment, catch the exception, then fix it both ways — a guard and a do-nothing default — and say which you would ship.
Exercise 7 — move-only. Try to store a unique_ptr-capturing lambda in a std::function and read the error. Then achieve the same thing with a shared_ptr capture and explain the trade.
Exercise 8 — the header boundary. Write a function taking a callable as a template parameter and another taking std::function, put each in a .cpp, and explain which one links.
16. Summary
std::function<R(Args...)>is type erasure for callables: one type for anything with that signature, which is the only way to have a member, a container element or a run-time choice of callable.- Measured: 32 bytes whatever it holds, against 8 for a function pointer and 1 for a stateless lambda.
- Measured: libc++ stores closures up to 16 bytes inline — two
longs fit, three allocate 32 bytes. Implementation-specific; the shape is not. - Measuring that required overriding global
operator new. The per-container allocator reported 0 for every row — my first attempt measured nothing. - Measured call cost: lambda 0.71 ns,
std::function4.35 ns — about 4.4×; throughcount_ifover 2e6 elements, 1.79 ms against 7.44 ms, ratio median 4.33×. - Measured: a function pointer came out at 0.72 ns — indistinguishable from the lambda — so the cost is the invisibility of the target, not indirection as such.
- That is why the algorithms take template parameters, and why a template parameter is the right default when the type is known.
- Measured: a vector of four different callables applied in order, which no
autoor template formulation can express. - An empty one throws
std::bad_function_call— guard it, or give a member a do-nothing default. - The held callable must be copyable until C++23's
move_only_function.
That completes the pieces. Chapter 20.10 puts them together on the data this curriculum is actually aimed at: a transaction log of twenty thousand records, filtered to a subset by three conditions, sorted by address with a tie-break, summarised for mean, standard deviation, median, p90 and p99, grouped by initiator, and then compared against a second result set to find where and why the two differ. Nine named algorithms, no nested loops, and not one index variable — including the comparison step, where two independent methods are used to cross-check the same answer and both return fifteen.
Standards & specifications
- Governing standard
- ISO/IEC 14882 (the C++ language standard)(opens ISO/IEC (JTC 1/SC 22/WG 21) in a new tab)
Defines the C++ language and its standard library — the type system and templates, the object model and object lifetime, initialisation and overload resolution, the memory model and the containers, algorithms and utilities the library provides. Like C's standard it is a FAMILY of editions (C++98/03, C++11, C++14, C++17, C++20, C++23) and the editions are NOT interchangeable: a feature this curriculum relies on may be unavailable, differently specified or merely proposed in another edition, so each lesson names the edition its claim depends on. It does not define any foreign-function interface to a hardware description language, nor what a compiler actually emits, nor platform behaviour on real silicon — those are the implementation's and the device's contracts. Working drafts published by WG21 are freely available and are what most engineers read day to day; a draft is not the published standard.
This page also covers RTL structure, verification approach and debugging technique. Those are engineering practice built on the standard, not requirements the standard itself imposes.
Where this fits
Part of the C++ Programming curriculum.
