5.2.22 · D2C++ Programming

Visual walkthrough — Lambda expressions — capture list (by value, by reference)

2,012 words9 min readBack to topic

This is the picture-derivation of the parent idea in the capture-list topic.


Step 1 — A variable is just a labelled box in memory

WHAT. Before any lambda exists, we have a plain local variable.

int n = 10;

WHY start here. Everything about "by value" vs "by reference" is a story about where a number physically lives. We cannot talk about copying a box or pointing at a box until we can see the box.

PICTURE. Look at the figure: n is a small box in the function's stack frame. The label n is just a name a human uses; the box itself holds the pattern 10. Its location — call it an address (the box's street number) — is the thing we will later copy or point at.

  • ::: the name we type in code
  • the box ::: the actual memory that stores
  • the address ::: the box's location, which references will remember
Figure — Lambda expressions — capture list (by value, by reference)

Step 2 — A lambda is secretly a struct being built

WHAT. When you write a lambda, the compiler invents a hidden class (a closure type) and makes one object of it.

auto f = [/* capture */](int x){ return x + n; };

WHY. A function on its own has no memory between calls. But this lambda needs to remember n. The only way to attach memory to a callable is to make it an object with fields — a struct. So the compiler writes one for you. The capture list decides what fields go inside.

PICTURE. The figure shows two things side by side: your one line of code, and the hidden struct the compiler generates. The struct has a special method operator() — the code that runs when you write f(...). The empty slot at the top is where captures will land in the next steps.

Figure — Lambda expressions — capture list (by value, by reference)

Step 3 — Capture by value [n]: the compiler copies the box into the struct

WHAT. Writing [n] adds a field int n; to the struct and copies the current value in at creation time.

int n = 10;
auto f = [n](int x){ return x + n; };   // copy made HERE, n is 10

The generated struct:

WHY by value here. We want the lambda to be portable and safe — a self-contained thing that carries its own data. A copy achieves that: the struct now owns a completely separate box.

PICTURE. Follow the mint arrow: the value 10 flows from the outer box into a new, second box that lives inside the struct f. Two boxes now hold 10. They are unrelated from this moment on.

Figure — Lambda expressions — capture list (by value, by reference)

Step 4 — Change the original: the by-value copy does not flinch

WHAT. We mutate the outer variable after the copy was made.

n = 99;                 // edits the OUTER box only
std::cout << f(0);      // prints 10, not 99

WHY this case matters. This is the whole point of "snapshot." The copy happened in Step 3 while n was 10. Assigning n = 99 writes into the outer box; the struct's inner box was never wired to it.

PICTURE. The coral arrow writes 99 into the outer box. The inner box (inside f) still reads 10 — no arrow reaches it. Term by term when f(0) runs:

Figure — Lambda expressions — capture list (by value, by reference)

Step 5 — Capture by reference [&n]: the struct stores the address, not a copy

WHAT. Writing [&n] adds a field int& n; — a reference, which is another name for the very same box.

int n = 10;
auto g = [&n](int x){ return x + n; };   // struct stores &n (the address)

The generated struct:

WHY a reference here. Sometimes we want the lambda to talk to the live original — to read its latest value or to change it. A reference does exactly that: it keeps no data of its own, it just remembers where the real data is. Compare with references vs pointers — a reference is the pointer's polite twin: same "point at a box" idea, no * needed.

PICTURE. No second box appears this time. Instead a lavender arrow leaves the struct and points back at the one outer box. The struct is a signpost, not a warehouse.

Figure — Lambda expressions — capture list (by value, by reference)

Step 6 — Change the original: the by-reference lambda sees it instantly

WHAT. Mutate the outer variable, then call the lambda.

n = 99;
std::cout << g(0);      // prints 99

WHY. g never stored 10. It stored a route to the box. Reading n inside g means "follow the arrow, read whatever is there now." We just wrote 99 into that exact box.

PICTURE. The coral arrow writes 99 into the outer box; the lavender signpost from g still points at that same box, so it now reads 99. Term by term:

Figure — Lambda expressions — capture list (by value, by reference)

Step 7 — The mutable case: editing a by-value copy without touching the original

WHAT. By default operator() is const, so a by-value field is read-only. mutable unlocks it.

int n = 10;
auto c = [n]() mutable { n++; return n; };
c();   // 11
c();   // 12  (the inner box persists between calls)
n;     // 10  (outer box never moved)

WHY this edge case. People assume "I named n, so I can change n." But changing the copy is only legal after you drop the const guard with mutable. Even then, you edit the struct's own box, never the outer one — there is no arrow between them (Step 3 severed them).

PICTURE. The ++ acts only on the inner box: 10 → 11 → 12 across two calls. The outer box sits untouched at 10. See also const member functions — that same const on operator() is what forbids the edit until you say mutable.

Figure — Lambda expressions — capture list (by value, by reference)

Step 8 — The degenerate case: the reference outlives its box (dangling)

WHAT. A by-reference lambda is returned out of the function whose local it points at.

auto make() {
    int x = 42;
    return [&x]() { return x; };   // BUG
}
auto bad = make();
bad();   // undefined behaviour — the box is gone

WHY this is the most important case. Step 5 taught us the reference is just an address. When make() returns, its stack frame is torn down — the box at that address is demolished. The signpost inside bad now points at rubble. This ties directly to object lifetime and scope and matters most with threads and async, where lambdas routinely outlive the code that spawned them. Compare with how std::function stores such a closure long after its birthplace has vanished.

PICTURE. Left frame: inside make(), the box x = 42 exists and the arrow is valid. Right frame: make() has returned, the frame is greyed out, the box is gone, and the arrow points into empty space — the crash.

Fix. Capture by value ([x] or [=]) so the value lives inside the closure and travels with it — no arrow to demolish.

Figure — Lambda expressions — capture list (by value, by reference)

The one-picture summary

Everything above collapses into one contrast: value copies the box in; reference stores an arrow out. A copy is portable but frozen. An arrow is live but only valid while its box still stands.

Figure — Lambda expressions — capture list (by value, by reference)
Recall Feynman retelling — the whole walkthrough in plain words

Start with a box named n holding 10 (Step 1). When you write a lambda, the compiler quietly builds a little robot — a struct with a "do it" button, operator() (Step 2). Now the question is what the robot carries. If you wrote [n], the compiler pours a copy of 10 into a pocket inside the robot (Step 3). Change the outside n to 99 and the robot shrugs — its pocket still holds 10 (Step 4). If instead you wrote [&n], the robot carries no number at all, just a note saying "the number lives over there" (Step 5). Change the outside n to 99 and the robot now reports 99, because it re-reads the box every time (Step 6). By default the robot's button is sealed const, so it can't scribble on its own pocket copy — say mutable and now it can, but only its own copy, never the real box (Step 7). The one deadly trap: if the robot's note points at a box that gets demolished when its function ends, the robot is holding directions to a house that no longer exists — follow them and you crash (Step 8). Carrying it away? Put the real thing in your pocket (value). Staying home and wanting the freshest value? Keep the note (reference).


After [n] copies, are the outer box and the struct's inner box connected?
No — the copy severs them; editing one never affects the other.
What does a [&n] closure physically store?
The address of the outer box (an alias), not a value — so it re-reads the live box on every call.
Why is operator() being const relevant to by-value edits?
The const method can't modify its by-value member fields, so [n]() { n++; } is illegal until you add mutable.
Does mutable let you change the original captured variable?
No — it only unlocks editing the struct's internal copy; the original box is untouched.
What causes a dangling reference from a returned lambda?
[&x] stores the address of a local; when the function returns its frame is destroyed, so the address points at reclaimed memory (UB).