C++ / LAMBDAS AND CALLABLE OBJECTS
Captures by value, reference, and their lifetime traps
Choose between by-value and by-reference lambda captures deliberately, and spot the stored-lambda and captured-this cases that dangle.
What you will learn
- Read a capture list as members of a closure initialized where the lambda is written
- Capture by reference only when the lambda runs before the captured objects die
- Use [x = std::move(x)] init-capture to store move-only or expensive state by value
- Recognize that [this] and [=] in a member function alias the object, not copy it
Understanding Captures by value, reference, and their lifetime traps
A lambda expression creates an object of a compiler-generated class, and the capture list describes that class's data members. Each by-value capture becomes a member copy-initialized from the enclosing variable at the moment control reaches the lambda expression; each by-reference capture behaves like a reference member bound to the original object at that same moment. That single fact explains the snapshot behaviour: writing to a variable after the lambda was created cannot change a copy that was already taken, but it is plainly visible through a reference. Capture happens once, at creation, and never again at each call.
Reference capture is the cheap and transparent choice, since nothing is copied and writes inside the body land on the original variable, but the closure carries no ownership at all. Nothing in the type of the closure records which objects it refers to, so the compiler will not extend a lifetime for you and usually will not warn when the referent dies first. The practical rule follows from usage rather than taste: a lambda consumed inside the scope that created it, such as a comparator handed to std::sort, can capture by reference freely, while a lambda that is returned, stored in a member or container, or handed to another thread must own everything it needs.
Two cases fool people who believe they are already capturing by value. Inside a member function, both [this] and the implicit capture in [=] copy only the object pointer, so the lambda reads members through the live object and dangles once that object is destroyed; a real copy takes [v = member] per member, or [*this] in C++17 and later. Copying a pointer, a std::string_view, or a std::span is the same shallow trap: the closure owns a snapshot of an address, not of the data behind it. Init-captures, written [name = expression], are the tool for stating exactly what the closure owns, including [p = std::move(p)] for move-only state.
placeholder
<iostream>
<string>
int main() {
int total = 1;
std::string label = "before";
auto snapshot = [total, label] {
std::cout << "snapshot: " << total << ' ' << label << '\n';
};
auto live = [&total, &label] {
std::cout << "live: " << total << ' ' << label << '\n';
};
auto bump = [&total] { total += 41; };
total = 2;
label = "after";
snapshot();
live();
bump();
live();
std::cout << "total in main: " << total << '\n';
}
A capture list fixes the closure's members where the lambda expression is evaluated: by-value captures are snapshots the closure owns, by-reference captures are aliases whose lifetime remains entirely your responsibility.
Worked examples
A closure that outlives its source variable
Shows why a factory function must capture by value, and that the by-reference alternative would compile just as readily.
<iostream>
<string>
auto make_greeter(const std::string& name) {
std::string greeting = "Hello, " + name;
// [&greeting] would dangle: greeting is destroyed at the closing brace.
// [&name] would dangle too, once the caller's temporary is gone.
return [greeting] { std::cout << greeting << '\n'; };
}
int main() {
auto g = make_greeter("Ada");
g();
}
Example explained
Line 1The deduced auto return type hands back the closure type itself, so the lambda is stored with no wrapper involved.
Line 2[greeting] makes the closure own a std::string of its own, copy-initialized before the function returns.
Line 3Switching to [&greeting] compiles with no error and reads a destroyed object at g(); the type system tracks nothing here.
Line 4[&name] is no safer, because the temporary bound to name dies at the end of the statement that called make_greeter.
[this] aliases, init-capture copies
Demonstrates that capturing in a member function reaches members through the object unless you copy them explicitly.
<iostream>
struct Counter {
int value = 1;
auto alias() const { return [this] { std::cout << "alias: " << value << '\n'; }; }
auto snapshot() const { return [v = value] { std::cout << "snapshot: " << v << '\n'; }; }
};
int main() {
Counter c;
auto a = c.alias();
auto b = c.snapshot();
c.value = 7;
a();
b();
}
Example explained
Line 1[this] stores only a pointer, so value is read from the original Counter at call time and prints 7.
Line 2[v = value] is an init-capture: a separate int member copied when snapshot() ran, so it prints 1.
Line 3Writing [=] here captures this with exactly the same aliasing behaviour, which is why C++20 deprecates it as a way to reach members.
Line 4If c were destroyed before a(), that call would dereference a dangling pointer while b() would still be correct.
One variable versus one copy per iteration
Shows how loop-scoped value captures differ from three closures aliasing a single counter.
<functional>
<iostream>
<vector>
int main() {
std::vector<std::function<void()>> per_iteration;
for (int i = 0; i < 3; ++i)
per_iteration.push_back([i] { std::cout << "copy of i: " << i << '\n'; });
int shared = 0;
std::vector<std::function<void()>> one_variable;
for (shared = 0; shared < 3; ++shared)
one_variable.push_back([&shared] { std::cout << "ref to shared: " << shared << '\n'; });
for (auto& task : per_iteration) task();
for (auto& task : one_variable) task();
}
Example explained
Line 1Each iteration of the first loop declares a fresh i, so each closure copies a different value as it is pushed.
Line 2The second loop has a single shared object; all three closures alias it and read whatever it holds when they finally run.
Line 3shared lives until the end of main, so printing 3 three times is defined but surprising; declaring it inside the loop would make those calls dangling reads.
Line 4The vector of std::function is only storage; the capture list, not the wrapper, decides copy versus alias.
Important notes
Writing through a reference capture needs no mutable, because the const call operator constrains the closure's members, not the objects those references designate.
Globals and static locals are never captured at all; a lambda with an empty capture list can still read and modify them, and always observes their current values.
Common mistakes
Returning or storing a lambda written with [&] or [&local]: those locals die at the closing brace, so later calls read reclaimed stack memory, which often prints the expected value in a debug build and corrupt data after optimization.
Assuming [=] in a member function snapshots the members: it captures this, so the lambda sees later mutations and dereferences a dangling pointer if the object is destroyed before the callback fires.
Treating a by-value capture of a pointer, std::string_view, or std::span as a safe copy: only the handle is copied, so the closure keeps pointing at a buffer that can die while the closure lives.
Try it yourself
Change, predict, then run
Write a function that builds a local std::vector<int> with a few elements and returns a lambda printing its size. Run it once with [&v] and once with [v], calling the result from main after the function has returned, and compare what each prints.
Open the C++ workspaceCheck your understanding
A helper creates a local std::string, returns a lambda that captured it with [&text], and the caller stores that lambda in a long-lived object. Calling it much later still prints the right characters on your machine. What is actually going on?
- Capturing by reference extends the string's lifetime for as long as the closure exists.
- The characters live on the heap, so only the std::string wrapper died and the reference remains usable.
- The program has undefined behaviour, and the reclaimed storage happens to still contain the old bytes.
- It is well defined because the string was never modified between capture and call.
Show answer
A reference capture stores no ownership and never extends a lifetime, so the local string is destroyed when the helper returns and every later call reads a dead object; the correct-looking output is a coincidence of memory nobody has reused yet. The heap answer is tempting because the characters really do live elsewhere, but the destructor already released that buffer, and the reference designates the destroyed string object itself.