C++ Lambda Basics: [=] and [&] Capture, mutable, and Lambdas with STL Algorithms
Introduction: Sort without a functor class?
Before C++11, std::sort needed a functor with operator()—verbose for a one-liner.
Lambda: a closure you define at the call site: capture -> ret { body }.
Definition: A lambda is a nameless function object created where you need a callable—ideal for sort, find_if, thread entry points.
Replace functor:
std::sort(people.begin(), people.end(),
[](const Person& a, const Person& b) {
return a.age < b.age;
});
Basic syntax
// g++ -std=c++17 -o lambda_basic lambda_basic.cpp && ./lambda_basic
#include <iostream>
int main() {
auto add = [](int a, int b) -> int {
return a + b;
};
int result = add(3, 5);
std::cout << result << "\n";
return 0;
}
Return type can be omitted if deducible. IIFE (immediately invoked):
int result = [](int x) { return x * x; }(5);
Under the hood, every lambda expression creates an object of a unique, unnamed class type — the closure type — with an operator() whose parameters and body are the ones you wrote. Captured variables become data members of that class. That is why auto is required to store a lambda (you cannot name its type), why two lambdas with identical text still have different types, and why a lambda passed to a template such as std::sort is usually inlined completely: the compiler sees exactly which operator() is called. A capture-less lambda also converts implicitly to a plain function pointer, which lets you pass it to C APIs like qsort or a C callback registration.
Return-type deduction follows the same rules as auto functions: all return statements must deduce the same type. A lambda that returns 1 in one branch and 2.5 in another fails with “inconsistent deduction for auto return type: ‘int’ and then ‘double’”; write -> double explicitly in that case. The IIFE form is handy for initializing a const variable with logic that needs several statements, instead of leaving the variable non-const and assigning it later.
Capture modes
- []: nothing
- [=]: copy all used automatic variables by value (default capture by value)
- [&]: reference all used automatic variables
- [x, &y]: mixed
- [this]: capture
this(C++17: *[this] copies the object) Thread/async: prefer by-value capture of what the async work needs—never reference to stack unless it definitely outlives the call.
Captures are taken when the lambda is created, not when it is called. A by-value capture copies the variable’s value at that moment, so later changes to the original are invisible inside the lambda; a by-reference capture stores a reference, so the lambda sees later changes — and dangles if the variable is destroyed before the call. That timing difference is the root of most lambda bugs. Only variables with automatic storage duration are captured at all; globals and static locals are used directly, so [=] does not snapshot them, which surprises people who expect [=] to freeze “everything”.
[=] inside a member function has a trap of its own. Using a data member count_ in the body does not copy count_; it captures the this pointer and reads this->count_ at call time. So a “by-value” lambda stored in a callback still dangles when the object dies. C++20 deprecates this implicit this capture through [=] for that reason — write [this] to make it visible, or [*this] (C++17) to copy the whole object, or capture just the member you need with an init-capture: [count = count_].
Init-captures (C++14) such as [data = std::move(data)] also make move-only types capturable: a std::unique_ptr cannot be copied into a lambda, but it can be moved in. The resulting lambda is then itself move-only, which means it cannot be stored in a std::function (which requires copyable callables) — C++23’s std::move_only_function exists for that case.
mutable
By-value captures are const in operator() unless the lambda is mutable—then you can mutate the copies inside the closure, not the originals.
int n = 0;
auto inc = [n]() mutable { return ++n; };
inc(); // 1
inc(); // 2 — the closure's own copy persists between calls
// n is still 0
The default is const because a lambda is meant to behave like a function: calling it twice with the same arguments should give the same result. Without mutable, ++n fails with “increment of read-only variable ‘n’” (GCC) or “cannot assign to a variable captured by copy in a non-mutable lambda” (Clang). Note that the state lives in the closure object: if the lambda is copied — and algorithms and std::function copy callables freely — each copy has its own counter, which is why a mutable lambda used as a counter inside std::for_each or std::count_if can report surprising numbers. By-reference captures are not affected by mutable at all; the lambda can already modify what they refer to.
You can also mark a lambda noexcept ([](int x) noexcept { ... }). That documents a promise and becomes part of its type, so it matters mainly when the lambda is passed to code that checks std::is_nothrow_invocable; if an exception does escape, std::terminate is called.
Generic lambdas (C++14)
auto print = [](auto value) {
std::cout << value << "\n";
};
C++20: explicit template parameters on lambdas:
auto f = []<typename T>(T value) { /* ... */ };
A generic lambda’s operator() is a template, so one print object works for int, std::string, or any type with an operator<< — each call instantiates a separate version at compile time. The errors follow template rules too: calling print with a type that lacks operator<< produces an error pointing inside the lambda body at instantiation time, often with a long template backtrace. The C++20 form helps when you need the type’s name, for example to require that two parameters have the same type ([]<typename T>(T a, T b)), which (auto a, auto b) cannot express, or to write std::vector<T> in the parameter list. Combined with concepts, [](std::integral auto x) constrains the parameter and gives a much clearer error message.
Recursive lambdas
Use std::function and capture by reference, or Y-combinator style—beware value-capture of uninitialized std::function.
#include <functional>
std::function<int(int)> factorial;
factorial = [&factorial](int n) -> int {
if (n <= 1) return 1;
return n * factorial(n - 1);
};
A lambda cannot refer to itself by name, because the variable it is assigned to is not yet initialized while the lambda expression is being evaluated (auto f = [&f](...) fails with “use of ‘f’ before deduction of ‘auto’”). The std::function workaround compiles, but it has two costs. Each recursive call goes through type erasure — an indirect call the optimizer usually cannot inline. And the reference capture ties the lambda to the factorial variable: returning factorial from a function, or copying it somewhere that outlives the original, leaves a copy that calls through a dangling reference.
Two alternatives avoid both problems. Pass the lambda to itself as a parameter:
auto fact = [](auto self, int n) -> int {
return n <= 1 ? 1 : n * self(self, n - 1);
};
int r = fact(fact, 5); // 120
Or, with C++23’s explicit object parameter (“deducing this”), let the lambda receive itself directly: auto fact = [](this auto self, int n) -> int { return n <= 1 ? 1 : n * self(n - 1); };. Both are fully inlinable and have no lifetime hazard. For deep recursion, remember that a lambda is still a function call per level — a recursive DFS over a million-node graph overflows the stack just like a named function would.
Examples
Putting captures, mutable, and generic lambdas together in code you’d actually write:
#include <algorithm>
#include <vector>
#include <numeric>
std::vector<int> nums = {5, 3, 8, 1, 9, 2};
// No capture needed: generic (C++14) comparator
std::sort(nums.begin(), nums.end(), [](const auto& a, const auto& b) {
return a < b;
});
// Capture by value: a self-contained predicate
int threshold = 4;
auto is_above = [threshold](int x) {
return x > threshold;
};
auto above = std::count_if(nums.begin(), nums.end(), is_above);
// Capture by reference to accumulate into an outer variable
long total = 0;
std::for_each(nums.begin(), nums.end(), [&total](int x) { total += x; });
Each of these mirrors a real pattern: no capture for a pure comparison, value capture for a self-contained predicate you might reuse or pass around, and reference capture when the whole point is to affect a variable outside the lambda.
is_above captures threshold by value, so changing threshold after creating the lambda does not change its behavior — usually what you want for a predicate that is stored or passed around. Resist putting mutable state (such as a call counter) into predicates for algorithms like count_if or remove_if: the standard allows algorithms to copy the predicate, so each copy counts separately, and for remove_if a stateful predicate can even produce wrong results. The reference-capturing for_each is correct because the lambda does not outlive total; for this particular job, std::accumulate(nums.begin(), nums.end(), 0L) says the same thing more directly.
Common errors
- Dangling reference:
[&]to locals, lambda runs later → capture by value or extend lifetime - Loop variable
i: use [i] not [&i] in parallel work - mutable needed to increment by-value members
this/[*this]for async—object lifetimestd::function+ inline: type erasure can prevent inlining and allocate
The first error is by far the most common in production code, and it rarely looks like a lambda bug when it happens. A function registers a callback — a button handler, a timer, a std::thread, an async network completion — that captures a local by reference with [&], and returns. The callback runs later and reads a dead stack frame: sometimes it prints plausible stale values, sometimes it crashes somewhere unrelated. AddressSanitizer reports it as stack-use-after-return (enable detect_stack_use_after_return=1) or stack-use-after-scope. My rule of thumb is that [&] is fine for lambdas consumed within the current statement or scope (algorithms, immediately invoked helpers) and never fine for lambdas that are stored or handed to another thread.
For the loop case: in for (int i = 0; i < n; ++i) threads.emplace_back([&] { work(i); });, every thread reads the same i, which keeps changing and is gone after the loop. Capturing [i] gives each thread its own copy.
Best practices
- Prefer template parameters
template<typename F>overstd::functionwhen you don’t need type erasure - Minimal capture lists—avoid
[=]/[&]if you might add variables later unintentionally - noexcept only when the lambda truly never throws (an escaping exception calls
std::terminate)
Production patterns
- Background work: std::thread(data = std::move(data){ … });
- ScopeGuard with lambdas for cleanup
- Callbacks: value capture or
shared_ptrfor shared ownership
For callbacks into objects whose lifetime you do not control, the standard pattern is to capture a std::weak_ptr to the object and lock it at call time: [weak = weak_from_this()] { if (auto self = weak.lock()) self->onDone(); }. Capturing shared_from_this() directly also works, but it keeps the object alive until the callback runs, which can extend lifetimes unexpectedly or create reference cycles if the object also stores the callback.
Checklist
- Async/thread: value capture or proven lifetime
- Loop index: by-value in loop lambdas
-
find/sortpredicates:const¶meters for large objects
Related Articles
Capture syntax at a glance
| Item | Syntax |
|---|---|
| By-value default | [=] |
| By-reference default | [&] |
| Selective | [x, &y] |
this | [this] / [*this] (C++17) |
mutable | []() mutable { } |
Principles: short local logic; watch lifetimes; prefer templates over std::function when possible.
FAQ
Lambda vs free function performance?
A. Lambdas passed as template arguments inline like free functions; std::function may heap-allocate and type-erase. Lambdas give local, optimizable callables for STL and concurrency—mind capture lifetimes.