Lesson 2 of 5
The capture list is what separates a lambda from a plain function: it decides which surrounding variables the lambda can see, and how it holds them.
Getting that "how" wrong is the single most common lambda bug, and it is a use-after-free.
The capture modes
#include <iostream>
#include <string>
#include <vector>
int main() {
int count = 10;
std::string name = "original";
auto by_value = [count, name] { return name + " " + std::to_string(count); };
auto by_ref = [&count, &name] { return name + " " + std::to_string(count); };
std::cout << "before: value=" << by_value() << ", ref=" << by_ref() << "\n";
count = 99;
name = "changed";
std::cout << "after: value=" << by_value() << ", ref=" << by_ref() << "\n";
// Default captures: [=] copies everything used, [&] references everything used.
auto all_value = [=] { return count + static_cast<int>(name.size()); };
auto all_ref = [&] { return count + static_cast<int>(name.size()); };
std::cout << "defaults: " << all_value() << " " << all_ref() << "\n";
// Init-capture (C++14): create a new variable in the capture list.
auto with_init = [doubled = count * 2] { return doubled; };
std::cout << "init-capture: " << with_init() << "\n";
// Move-capture: the only way to capture a move-only type.
std::vector<int> data = {1, 2, 3};
auto owns = [d = std::move(data)] { return d.size(); };
std::cout << "moved in: " << owns() << ", source size now " << data.size() << "\n";
// Mixed: capture most by reference, but this one by value.
auto mixed = [&, count] { return count + static_cast<int>(name.size()); };
std::cout << "mixed: " << mixed() << "\n";
return 0;
}
before: value=original 10, ref=original 10
after: value=original 10, ref=changed 99
defaults: 106 106
init-capture: 198
moved in: 3, source size now 0
mixed: 106
The second line is the whole idea. After changing both variables, the by-value lambda still reports the old values and the by-reference one reports the new ones.
By value copies at the point the lambda is created, not when it is called. The copy lives inside the closure object (lesson 14.1) and is independent from then on.
By reference refers to the original for the lambda's whole life — which is the useful behaviour and the dangerous one.
| Syntax | Means |
|---|---|
| [x] | copy x |
| [&x] | reference x |
| [=] | copy everything the body uses |
| [&] | reference everything the body uses |
| [=, &x] | copy by default, but reference x |
| [&, x] | reference by default, but copy x |
| [y = expr] | create y in the closure from expr (C++14) |
| [y = std::move(v)] | move v into the closure |
| [this] | capture the enclosing object's pointer |
| [*this] | copy the enclosing object (C++17) |
Two lines from the output are worth pausing on.
init-capture: 198 — [doubled = count * 2] evaluated count * 2 when the lambda was
created, and count was 99 by then. Init-capture creates a new variable in the closure; it does
not name an existing one.
moved in: 3, source size now 0 — [d = std::move(data)] moved the vector into the closure,
leaving data empty (lesson 9.9). This is the only way to put a move-only type such as
std::unique_ptr into a lambda.
The bug
A reference capture is a reference. If what it refers to dies first, the lambda holds a dangling reference — lesson 8.5 in new clothing:
// CRASHES ON PURPOSE — build it and run it to see the failure for yourself.
#include <functional>
#include <iostream>
#include <string>
// Returns a lambda that captures a local by reference. The local dies when
// the function returns, so the captured reference dangles.
std::function<std::string()> make_broken() {
std::string message = "hello from a dead local";
return [&message] { return message; }; // BUG
}
std::function<std::string()> make_fixed() {
std::string message = "hello from a captured copy";
return [message] { return message; }; // copies, so it survives
}
int main() {
auto fixed = make_fixed();
std::cout << "fixed: " << fixed() << "\n";
auto broken = make_broken();
std::cout << "broken: " << broken() << "\n"; // use-after-free
return 0;
}
Compiled normally with -Wall -Wextra, that produces no warning at all. Run it and:
fixed: hello from a captured copy
terminate called after throwing an instance of 'std::bad_alloc'
what(): std::bad_alloc
Aborted
A bad_alloc from copying a string — because the dead std::string's length field now holds
garbage, and the copy tried to allocate that many bytes.
Built with -fsanitize=address and ASAN_OPTIONS=detect_stack_use_after_return=1:
==5094==ERROR: AddressSanitizer: stack-use-after-return on address 0x7f74a2100460
READ of size 8 at 0x7f74a2100460 thread T0
#0 ... in std::basic_string<char>::_M_data() const
#1 ... in std::basic_string<char>::basic_string(...)
#2 ... in operator() dangling.cpp:9
#3 ... in __invoke_impl<..., make_broken()::<lambda()>&>
Read it with lesson 11.5's technique. Frames #0 and #1 are inside std::string — library, skip. Frame
#2 is the first one in your own code: operator() at line 9, which is the lambda body. And frame
#3 names it outright — make_broken()::<lambda()> — so the trace tells you which lambda.
The rule
A lambda that outlives its enclosing scope must not capture by reference.
Which reduces to a simple test: where does this lambda go?
Passed straight to an algorithm → [&] is fine and preferred. The algorithm finishes before the
current function returns, so nothing can dangle, and you avoid copying.
Stored, returned, or given to another thread → capture by value or by move. The lambda will outlive the scope it was written in.
That covers essentially every case. std::sort(v.begin(), v.end(), [&](auto& a, auto& b) { ... })
is safe by construction; return [&] { ... }; almost never is.
The this trap
The subtler version, and the one that survives code review:
#include <functional>
#include <iostream>
#include <string>
#include <vector>
struct Widget {
std::string name = "widget";
int count = 42;
// [this] captures a POINTER, not the members. Every member access inside
// goes through it, so the lambda outliving the object is a use-after-free.
// ([=] does this implicitly, which C++20 deprecates for exactly this reason.)
std::function<std::string()> broken_describe() {
return [this] { return name + ":" + std::to_string(count); };
}
// C++17: [*this] copies the whole object into the closure.
std::function<std::string()> safe_describe() {
return [*this] { return name + ":" + std::to_string(count); };
}
};
int main() {
std::function<std::string()> safe;
std::function<std::string()> unsafe;
{
Widget w;
safe = w.safe_describe();
unsafe = w.broken_describe();
std::cout << "while alive — safe: " << safe() << ", unsafe: " << unsafe() << "\n";
} // w is destroyed here
std::cout << "after destruction — safe: " << safe() << "\n";
// Calling unsafe() here is a use-after-free.
return 0;
}
while alive — safe: widget:42, unsafe: widget:42
after destruction — safe: widget:42
Both worked while the object was alive. Only safe still works afterwards.
The trap is that [=] does not copy the members. Writing [=] inside a member function and
using name looks like a copy of name — it is not. name is this->name, so [=] captures
this, a bare pointer, and every member access goes through it.
C++20 deprecated this, and GCC says so directly:
warning: implicit capture of 'this' via '[=]' is deprecated in C++20 [-Wdeprecated]
note: add explicit 'this' or '*this' capture
That warning is worth having on. It is the difference between finding this at compile time and finding it in a crash report.
[*this] (C++17) copies the whole object into the closure, which is what you usually want for a
stored callback. For a large object that copy is real, and the alternative is to capture a
std::shared_ptr to it (lesson 10.4) so the lifetime is explicit.
[=] and [&] are worth avoiding
Both default captures hide what is being captured, and the cost of that is exactly the two bugs above. Naming captures explicitly means:
- The reader can see the lambda's dependencies without reading the body.
- Adding a variable to the body cannot silently add a capture.
[=]inside a member function cannot silently capturethis.
[&] for a lambda passed straight to an algorithm is the reasonable exception — it is provably
safe, and listing five captures for a one-line predicate is noise.
Try it
Write a function returning a lambda that computes a running average of numbers passed to it. Make it work correctly when called after the creating function has returned. Then write the broken reference-capturing version and describe what ASan reports.
Checkpoint
- The capture list decides which surrounding variables a lambda sees, and how it holds them.
- By value copies when the lambda is created, not when it is called.
- By reference refers to the original for the lambda's whole life.
- Init-capture
[y = expr]creates a new variable in the closure, evaluated at creation. [y = std::move(v)]is the only way to capture a move-only type.- A lambda that outlives its scope must not capture by reference — that is a use-after-free.
- Passed to an algorithm →
[&]is safe. Stored, returned, or threaded → capture by value. [=]in a member function capturesthis, not the members — deprecated in C++20.[*this](C++17) copies the whole object; a capturedshared_ptris the alternative.- A dangling capture produces no warning; ASan reports
stack-use-after-return. - Prefer naming captures explicitly over
[=]and[&].
Questions about this lesson
Log in to ask a question.