Skip to content
VLSI Mentor

C++ Part I · Module 20 · Iterators, Algorithms, and Lambdas

Lambdas and Captures

Every algorithm in the last four chapters took a callable and every example supplied a lambda, without ever saying what one is. This chapter answers that, and the answer makes the whole syntax predictable rather than something to be memorised: a lambda is an unnamed class that the compiler writes for you, its captures are that class's data members, and its body becomes an operator() on that class. Once that is established, each piece of the syntax stops being arbitrary. The size of a lambda is the size of what it captured — measured here for seven different capture lists, from one byte for a stateless one to forty for a by-everything capture of three variables. A stateless lambda converts to a function pointer because a class with no data members has nothing a function pointer could not represent, and a capturing one does not, because it does. The mutable keyword makes operator() non-const, which is why it lets you modify a by-value capture and does nothing to the original. And the one genuinely consequential decision, which the frozen purpose singles out, is the capture mode: a by-value capture copies at the point of capture, a by-reference capture reads the original at the point of call, and when the original has stopped existing by then the result is a dangling reference. The chapter measures that too, and the measured output is a plausible-looking integer with no compile warning at all.

Intermediate19 min readC++Part IFundamentalsAlgorithms

Part I · Module 20 · Chapter 20.8 · Lambdas and Captures

1. A Lambda Is a Class

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
auto isBig = [](int x) { return x > 100; };

That declaration creates an object of an unnamed class type that the compiler generates, roughly:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
class __lambda_1 {
public:
    bool operator()(int x) const { return x > 100; }
};
__lambda_1 isBig{};

Everything else in the chapter follows from reading the syntax that way. The parameter list becomes operator()'s parameter list. The body becomes its body. The capture list becomes the class's data members. And because the class is unnamed and distinct, every lambda has its own type:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
is_class<decltype(l1)> = 1
is_same<decltype(l0),decltype(l1)> = 0

Two lambdas with identical bodies are still different types. That is why you write auto to hold one, why two of them cannot go in the same std::vector, and why Chapter 20.9 exists — std::function is the thing that gives a common type to callables that have none.

The syntax in full is [captures](params) specifiers -> ret { body }. The return type is deduced and rarely written; -> is needed only when deduction picks the wrong thing or when there are multiple return statements of different types.

2. The Closure Object's Size Is Its Captures

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
[]            sizeof =  1   (stateless: 1, the empty-class minimum)
[a]           sizeof =  4   (one int)
[a,b]         sizeof = 16   (int + double, padded)
[&a]          sizeof =  8   (a reference is a pointer)
[s]           sizeof = 24   (a COPY of the std::string)
[&s]          sizeof =  8   (a pointer to it)
[=]           sizeof = 40   (only what it USES is captured)

Every row is explained by the class model and by layout rules established earlier.

[] is 1 byte because a class with no members still needs a distinct address, which is the empty-class minimum. Nothing is stored.

[a] is 4 — one int member. [a,b] is 16 rather than 12, because the double needs 8-byte alignment and the int is padded to match: Chapter 9.8 §5's "sizeof is not the sum".

[&a] is 8: a reference capture stores a pointer. Note that it stores a pointer to a and nothing about a's lifetime, which is §7.

[s] is 24 — the size of a std::string object, which Chapter 19.2 §2's reasoning covers: the object is a fixed size and its characters are elsewhere. A by-value capture of a string therefore copies the string, heap buffer included, at the moment of capture.

[=] is 40 and the interesting word is "only". [=] means capture-by-value-what-is-used, not capture-everything-in-scope: the lambda used a, b and s, so it got 4 + 8 (padding) + 8 + 24 — not every local in the enclosing function.

The practical reading: a lambda passed directly to an algorithm is usually free, because the object is tiny and the call inlines. One that captures a container by value copies the container.

3. Stateless Lambdas Convert to Function Pointers

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
int (*fp)(int) = [](int x){ return x * 2; };
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
stateless -> function pointer: fp(21) = 42

A lambda with an empty capture list has no state, so there is nothing a plain function could not do, and the standard gives it an implicit conversion to a function pointer. That is what lets a stateless lambda be passed to a C API expecting a callback.

A capturing lambda has state, and a function pointer has nowhere to put it:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
int k = 2;
int (*fp)(int) = [k](int x){ return x * k; };
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
nofp.cpp:6:11: error: no viable conversion from '(lambda at nofp.cpp:6:22)'
    to 'int (*)(int)'

One error, named at the right line. This is the fundamental reason C callback APIs take a void* user_data alongside the function pointer: the pointer carries the code and the void* carries the state that a capture would have held. Chapter 14.7 §2's observer-pointer discussion applies to what you put in that void*.

4. By Value Captures Now; By Reference Reads Later

This is the decision the frozen purpose asks you to make on purpose.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
int n = 1;
auto byVal = [n]  { return n; };
auto byRef = [&n] { return n; };
n = 99;
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
captured when n was 1, then set n = 99
[n]  -> 1   (the copy was taken at capture time)
[&n] -> 99   (reads n now)

[n] copies at the point the lambda is created. The value is frozen; later changes to n are invisible.

[&n] stores a pointer and reads through it at the point of call. The lambda sees whatever n holds when it runs.

Neither is correct in general; they answer different questions. Use by value when you want the value as it was — a threshold, a configuration snapshot, a loop index. Use by reference when you want the live object — an accumulator the lambda updates, a large object you do not want to copy, or a stream.

Two rules make the choice almost mechanical:

  • If the lambda is called before the enclosing scope ends, by reference is safe and avoids a copy. Every lambda passed straight to an algorithm is in this category, which is why [&] is common and fine there.
  • If the lambda outlives the scope — stored in a member, returned, put on a thread, kept in a std::function — capture by value. §7 measures why.

5. mutable and What It Actually Means

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
int count = 0;
auto tick = [count]() mutable { return ++count; };
int t1 = tick(); int t2 = tick(); int t3 = tick();
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
[count]() mutable, called three times: 1 2 3
the OUTER count is still 0 -- mutable makes the COPY writable
[&count] without mutable modifies the original: 2
(mutable removes the implicit const on operator(), nothing more)

operator() on a lambda is implicitly const, so the by-value captures are not modifiable. mutable removes that const — and that is the entire effect. It does not change what was captured, it does not make the capture by reference, and it does not touch the original variable: after three calls returning 1, 2 and 3, the outer count was still 0.

A by-reference capture needs no mutable to modify the original, because it is not modifying the lambda's own member; it is writing through a pointer. Measured, [&count] incremented the real variable to 2.

A note on how that measurement is written. The three calls are separate statements, not three arguments to one printf, because the order in which function arguments are evaluated is unspecified — a single call printing tick(), tick(), tick() could legitimately produce 3 2 1, and an output demonstrating a sequence must not depend on that.

A mutable lambda is a stateful object, which makes it a poor predicate: Chapter 20.5 §2's point that an algorithm may call a predicate an unspecified number of times means a counter inside one measures nothing reliable.

6. Capture Lists in Full, and the Frozen Example

capturemeaning
[]nothing; stateless, converts to a function pointer
[x]x by value, copied at capture
[&x]x by reference, read at call
[x, &y]mixed, explicitly
[=]by value, only what is used (measured: 40 bytes for three variables)
[&]by reference, only what is used
[=, &x]by value by default, x by reference
[&, x]by reference by default, x by value
[this]the enclosing object by pointer — members accessible, lifetime not captured
[*this]a copy of the enclosing object (C++17)
[y = expr]init-capture: a new member initialised from an expression (C++14)
[p = std::move(p)]move into the closure

The curriculum's own example for this chapter is the comparator shape, and it is worth reading against §2:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
std::sort(txns.begin(), txns.end(),
  [](const Txn& a, const Txn& b) { return a.addr < b.addr; });
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
sorted by addr: 0x1000 0x2000 0x3000 0x4000

Three things are deliberate. The capture list is empty, so the closure is one byte and the call inlines — a comparator should not need state. The parameters are const&, so no Txn is copied on any of the O(n log n) comparisons and the comparator cannot modify what it is ordering. And the body is a strict < on one field, which is Chapter 20.6 §1's requirement.

The explicit forms are better than [=] and [&] in code that will be read later, because the capture list is then a complete statement of what the lambda depends on. [&] in particular hides exactly the lifetime question §7 is about.

7. A Captured Reference That Outlives Its Referent

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
static std::function<int()> makeDangling()
{
    int local = 1234;
    return [&local] { return local; };      // local dies at the closing brace
}
static std::function<int()> makeSafe()
{
    int local = 1234;
    return [local] { return local; };       // a copy travels with the closure
}
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
by value   : 1234   (correct)
by reference: 2989   (reads a dead stack frame: UB)
compile warnings: 0

2989. The lambda stored a pointer to a stack slot in a frame that was destroyed when makeDangling returned. By the time it is called, that memory has been reused — the test deliberately overwrites it — and the read produces whatever is there now. A plausible integer, no crash, no compile warning.

This is the same failure shape as Chapter 20.3 §3's dangling pointer and Chapter 13.4 §3's point that nothing objects. The difference is that here the dangling is created by a capture mode, which is a one-character decision.

Unlike the invalidation bug, this one is caught by a sanitizer:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
ERROR: AddressSanitizer: stack-use-after-scope
Address ... is located in stack of thread T0 at offset 32 in frame
exit 134

ASan reports it as stack-use-after-scope and names the frame. That is because a dead stack frame is a region ASan poisons on scope exit, so the access is detectable — whereas Chapter 20.3's stale iterator stayed inside a live allocation. Same discriminator as every detection result in this module.

The rule that follows is short: a lambda that outlives the scope it was written in must not capture by reference. Stored in a member, returned, passed to a thread, put in a std::function, registered as a callback — all of those outlive the scope. Capture by value, or [p = std::move(p)] if the thing is expensive and you can give up ownership.

[this] deserves its own warning, because it is a reference capture wearing different syntax. A lambda capturing this and stored in a member of the same object is fine; the same lambda outliving the object is a dangling this, with no warning. C++17's [*this] copies the object instead, and in asynchronous code that is often what you want.

8. Init-Capture

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
std::string big(32, 'z');
auto own = [s = std::move(big)] { return s.size(); };
int x = 10;
auto plus = [y = x * 2] { return y; };
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
[s = std::move(big)] -> owns 32 chars; big is now 0
[y = x*2] -> 20 (an expression, not just a name)

C++14's init-capture declares a new member and initialises it from any expression. Two uses matter.

Moving into a closure. A by-value capture copies; [p = std::move(p)] moves. For a std::unique_ptr this is the only way to get ownership into a lambda at all, since the pointer is not copyable — Chapter 14.3 §2's transfer-by-move.

Capturing a computed value, so the computation happens once at capture rather than on every call.

It also lets you rename, which is occasionally useful for clarity: [total = runningTotal].

9. Generic Lambdas

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
auto twice = [](auto x) { return x + x; };
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
twice(21) = 42   twice(1.5) = 3.0   twice(string) = abab

An auto parameter makes operator() a template, so one lambda produced three instantiations. This is Chapter 18.2's function template with the template header written for you, and the same rules apply: the body is only compiled for the types actually used, and the requirements are whatever the body uses.

auto&& parameters plus std::forward give a perfectly-forwarding lambda — Chapter 18.9 §6's mechanism. C++20 adds an explicit template parameter list, []<typename T>(T x){}, for when you need to name the type.

10. Where the Cost Is

A lambda passed directly to an algorithm costs nothing: the type is known at the call site, so the body inlines. Chapter 20.9 measures the three forms side by side — a lambda at 0.71 ns per call, a function pointer at 0.72, and a std::function at 4.35 — and the conclusion is that type erasure is what costs, not the lambda.

Two real costs to watch for:

A by-value capture of something expensive. [v] on a std::vector copies it, once per lambda construction. In a loop that creates the lambda each iteration, that is a copy per iteration.

A mutable lambda used where a pure one was expected. Not a performance cost but a correctness one, per §5.

11. What to Actually Do

  • Write the capture list explicitly rather than [=] or [&], so it documents the dependency.
  • Capture by reference when the lambda runs before the scope ends — every algorithm call qualifies.
  • Capture by value when the lambda outlives the scope — stored, returned, threaded, or in a std::function.
  • Never return a lambda that captured a local by reference. Measured: 2989 instead of 1234, zero warnings.
  • Treat [this] as a reference capture. Use [*this] in C++17 when the object may not outlive the lambda.
  • Use const& parameters in comparators and predicates — no copy per call, and no accidental modification.
  • Keep the capture list empty for comparators. They should not need state.
  • Use [p = std::move(p)] to move something expensive or move-only into a closure.
  • Avoid mutable in a predicate. The number of calls is unspecified.
  • Use auto parameters for a generic lambda, and auto&& with std::forward when forwarding.
  • Hold a lambda in auto. It has no other spellable type.

12. Mistakes Worth Making Once

Returning [&local]{...}. Measured 2989, no warning, caught only by ASan.

[&] on a lambda stored in a member. The same bug with the capture mode hidden.

[this] in an asynchronous callback. A dangling this, with no diagnostic. [*this] copies.

Expecting mutable to modify the original. Measured: the outer count stayed 0.

A counter inside a predicate. The call count is unspecified.

Capturing a large container by value in a loop. One copy per iteration.

Assigning a capturing lambda to a function pointer. One clean error; the fix is std::function or a template parameter.

Trying to put two lambdas in one std::vector. Different types; that is Chapter 20.9's job.

Taking comparator parameters by value. A copy on every one of O(n log n) comparisons.

13. Interview Relevance

The frozen curriculum marks this chapter Critical, and the capture questions are near-universal.

"What is a lambda?" An unnamed class with an operator(), whose captures are its data members. Saying it that way rather than "an anonymous function" is the signal, because every follow-up then has an obvious answer.

"What is the difference between [x] and [&x]?" By value copies at the point of capture; by reference stores a pointer and reads at the point of call. Measured 1 versus 99 after the original changed.

"When is a reference capture dangerous?" When the lambda outlives the scope. Quoting the measured 2989 and the ASan stack-use-after-scope makes it concrete.

"What does mutable do?" Removes the implicit const on operator(), so by-value captures become modifiable. It does not affect the original — measured.

"Why can a lambda with no captures convert to a function pointer?" No state, so nothing a function pointer cannot represent. And the corollary about void* user_data in C APIs is a good thing to volunteer.

"How big is a lambda?" The size of its captures. [] is 1 byte; [s] on a std::string is 24.

"Does [=] capture everything in scope?" No — only what the body uses. Measured at 40 bytes for three variables.

"How do you get a unique_ptr into a lambda?" [p = std::move(p)]; it cannot be copied.

14. What You Should Be Able to Say Now

  • That a lambda is a class, its captures are data members, and its body is operator().
  • The seven measured sizes and the layout reason for each, including why [a,b] is 16 and [=] is 40.
  • That each lambda has a distinct type, which is why auto is required and std::function exists.
  • Why a stateless lambda converts to a function pointer and a capturing one gives "no viable conversion".
  • That [x] copies at capture and [&x] reads at call, measured as 1 versus 99.
  • That mutable only removes the const on operator() — the outer variable stayed 0.
  • That a returned by-reference capture printed 2989 with 0 warnings, and ASan reported stack-use-after-scope.
  • That [this] is a reference capture and [*this] copies.
  • That an auto parameter makes the lambda a template.

15. Exercises

Exercise 1 — the sizes. Reproduce §2's table and add [v] for a std::vector<int>, [&v], and [a,b,c,d] for four chars. Explain every number.

Exercise 2 — capture timing. Write a lambda capturing a variable by value and one by reference, change the variable, and print both. Then do it inside a loop capturing the loop index and explain which form is almost always wanted.

Exercise 3 — the dangle. Reproduce §7 under plain -O1 and under ASan. Then make the by-reference version appear to work by calling it immediately, and explain why that is not evidence of correctness.

Exercise 4 — this. Write a class storing a std::function member that captures this, destroy the object while the function is still held, and call it. Then fix it with [*this].

Exercise 5 — mutable. Write a mutable lambda counter, call it five times, and print both its result and the original variable. Then pass it to count_if and explain why the count is unreliable.

Exercise 6 — the function pointer. Pass a stateless lambda to a C API taking a function pointer. Then try a capturing one, read the error, and solve the problem the way C does — with a void*.

Exercise 7 — move into a closure. Put a std::unique_ptr into a lambda, store the lambda in a std::function, and call it. Explain what std::function's copyability requirement does here.

Exercise 8 — generic. Write one generic lambda that works on int, double and std::string, then write the equivalent function template, and compare the two.

16. Summary

  • A lambda is an unnamed class: captures are data members, the body is operator(), and each lambda has a distinct type — so hold one in auto.
  • Measured sizes: [] 1, [a] 4, [a,b] 16 (padding), [&a] 8, [s] 24, [&s] 8, [=] 40 — and [=] captures only what the body uses.
  • A stateless lambda converts to a function pointer (measured fp(21) = 42); a capturing one gives "no viable conversion", which is why C callbacks carry a void* user_data.
  • Measured: [n] returned 1 and [&n] returned 99 after the original changed — by value copies at capture, by reference reads at call.
  • Measured: mutable made three calls return 1 2 3 while the outer variable stayed 0. It only removes the const on operator().
  • Measured: a returned [&local] printed 2989 instead of 1234, with 0 compile warnings — and ASan caught it as stack-use-after-scope, because a dead stack frame is poisoned where Chapter 20.3's stale iterator was not.
  • A lambda that outlives its scope must capture by value, or move with [p = std::move(p)].
  • [this] is a reference capture; [*this] copies the object.
  • The frozen comparator shape is deliberate: empty capture, const& parameters, a strict <.
  • An auto parameter makes the lambda a template — one lambda, three instantiations, measured.

Every lambda in this chapter was held in auto or passed straight to an algorithm, and §1 noted the reason: each one has its own unnameable type. That is fine until you need a variable or a member that holds a callable whose concrete type you cannot know — a callback registered at run time, a strategy chosen from configuration, a vector of different operations to apply in order. Chapter 20.9 is std::function, the type-erased wrapper that solves exactly that, and it measures what the solution costs: the size of the object, the capture size at which it starts allocating, and the per-call price of an indirect call that cannot be inlined.

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.