Lambdas bring no new expressive power to C++ — everything they do, you can do by hand. But they are such a convenient way to create function objects that they've changed how we write C++ forever.
Before diving into the items, let's untangle the three terms that everyone confuses: lambda expression, closure, and closure class.
| Term | What It Is | When It Exists |
|---|---|---|
| Lambda expression | The source code — the [captures](params){ body } syntax you type | Compile time |
| Closure class | The compiler-generated class with an operator() | Compile time |
| Closure | The runtime object — an instance of the closure class | Runtime |
c++ int x = 42; auto c1 = [x](int y) { return x * y > 55; }; // [x](int y){...} → the lambda expression (source code) // compiler generates → a closure class with operator()(int) // c1 → a closure (runtime object) auto c2 = c1; // c2 is a copy of the closure auto c3 = c2; // c3 is another copy
Lambdas make the STL usable. Without them, std::find_if, std::remove_if, and std::sort were limited to trivial predicates. With lambdas, complex conditions become inline and readable. They also simplify custom deleters, condition variable predicates, and one-off callbacks.
C++11 offers two default capture modes: by-reference ([&]) and by-value ([=]). Both are traps. Default by-reference capture leads to dangling references. Default by-value capture seems safe but isn't.
c++ using FilterContainer = std::vector<std::function<bool(int)>>; FilterContainer filters; void addDivisorFilter() { auto divisor = computeDivisor(); filters.emplace_back( [&](int value) { return value % divisor == 0; } ); // DANGER: divisor is destroyed when function returns! }
divisor is a local variable. When addDivisorFilter returns, it's destroyed — but the closure still holds a reference to it. Using the filter now is undefined behavior.Explicit capture ([&divisor]) doesn't fix the bug, but at least it makes the dependency visible, reminding you to check lifetimes.
You might think default by-value capture solves the dangling problem. It doesn't. Consider a member function:
c++ class Widget { int divisor; public: void addFilter() const { filters.emplace_back( [=](int value) { return value % divisor == 0; } ); } };
This looks safe — we're capturing divisor by value, right? Wrong. Lambdas can only capture local variables and parameters. divisor is a data member. What's actually captured by value is this:
c++ // What the compiler actually does: [this](int value) { return value % this->divisor == 0; } // If the Widget is destroyed, this-> is dangling!
[divisor = divisor](int value) { return value % divisor == 0; }c++ void addDivisorFilter() { static auto divisor = computeDivisor(); filters.emplace_back( [=](int value) { return value % divisor == 0; } ); // Captures NOTHING! Refers to static variable directly. ++divisor; // Modifies the static — all existing closures see the change! }
c++ void doSomeWork() { auto pw = std::make_unique<Widget>(); pw->addFilter(); // adds filter that captures this } // pw destroyed here — Widget gone! // The filter in 'filters' now holds a dangling this pointer // Using the filter = undefined behavior
[divisor = divisor](int value) { return value % divisor == 0; }
divisor is the closure member name. The right divisor is this->divisor. Now the closure owns its own copy — no dangling.| Default Mode | Risk | Fix |
|---|---|---|
[&] | Dangling references to destroyed locals | Name captures explicitly: [&divisor] |
[=] | Captures this (not members), dangling in member functions | C++14 init capture: [member = member] |
[=] | Misleads about static variables (not actually captured) | Name captures explicitly |
[=] captures data members by value. True or false?C++11 lambdas can capture by value or by reference. But what if you have a move-only object like std::unique_ptr? Or an expensive-to-copy container you'd rather move? C++11 can't do it. C++14's init capture (a.k.a. generalized lambda capture) can.
c++ // C++14: move a unique_ptr into a closure auto pw = std::make_unique<Widget>(); auto func = [pw = std::move(pw)] // init capture { return pw->isValidated() && pw->isArchived(); };
= is the closure member name (scope: closure class). Right of = is the initializer (scope: where the lambda is defined). So pw = std::move(pw) means "create a member pw in the closure, initialized by moving from the local pw."You can even skip the local variable entirely:
c++ // Create directly in the closure auto func = [pw = std::make_unique<Widget>()] { return pw->isValidated() && pw->isArchived(); };
In C++11, you can emulate move capture with std::bind:
c++ std::vector<double> data; // ... populate data ... // C++14 init capture auto func14 = [data = std::move(data)] { /* uses data */ }; // C++11 emulation via std::bind auto func11 = std::bind( [](const std::vector<double>& data) { /* uses data */ }, std::move(data) );
std::bind move-constructs its rvalue arguments into the bind object. The lambda then receives the move-constructed data by reference. The bind object's lifetime matches the closure's, so the data stays alive.std::bind receives the rvalue std::move(data) and move-constructs a copy into the bind object.The lambda takes const std::vector<double>& because the bind object's operator() is const by default, making its stored data effectively const. Use mutable on the lambda if you need to modify the captured data.
c++ // Mutable version for C++11 auto func = std::bind( [](std::vector<double>& data) mutable { /* can modify data */ }, std::move(data) );
[pw = std::move(pw)], the two pws refer to:C++14 introduced generic lambdas — lambdas with auto parameters. But how do you perfect-forward a generic lambda's parameter?
c++ // Generic lambda — but always passes lvalue to normalize! auto f = [](auto x) { return func(normalize(x)); }; // Fix: use auto&& + std::forward<decltype(param)> auto f = [](auto&& param) { return func(normalize(std::forward<decltype(param)>(param))); };
decltype(param) yields an lvalue reference — exactly what std::forward expects. For rvalue arguments, it yields an rvalue reference, which (after reference collapsing) produces the same result as the conventional non-reference type.This even works with variadic parameters:
c++ // Perfect-forwarding variadic generic lambda auto f = [](auto&&... params) { return func(normalize( std::forward<decltype(params)>(params)... )); };
Normally, std::forward<T> expects a non-reference type for rvalues. But decltype(x) yields an rvalue reference type when x binds to an rvalue. Let's trace through why it still works:
c++ // When rvalue Widget is passed, decltype(param) = Widget&& // std::forward<Widget&&> instantiates to: Widget&& && forward(Widget& param) // before collapsing { return static_cast<Widget&& &&>(param); } // After reference collapsing (&& + && = &&): Widget&& forward(Widget& param) // identical to conventional! { return static_cast<Widget&&>(param); }
Widget or Widget&& to std::forward, reference collapsing produces the same instantiation. So decltype works correctly for both lvalues and rvalues.[](auto&& x), what does decltype(x) yield when an rvalue is passed?Lambdas are more readable, more expressive, and often more efficient than std::bind. Let's see why with a concrete example.
c++ // Lambda: clear, readable, obvious auto setSoundL = [](Sound s) { using namespace std::chrono; setAlarm(steady_clock::now() + 1h, s, 30s); }; // std::bind: opaque, error-prone auto setSoundB = std::bind(setAlarm, std::bind(std::plus<>(), steady_clock::now(), 1h), _1, 30s); // BUG: steady_clock::now() is evaluated at bind time, not call time! // Need nested bind to fix. Lambda evaluates it naturally at call time.
setAlarm is visible in the lambda body — there's nothing to highlight in std::bind. (2) Arguments like _1 are magic — you have to mentally map placeholders. (3) std::bind evaluates arguments eagerly, causing subtle timing bugs.c++ // If setAlarm is overloaded: void setAlarm(Time t, Sound s, Duration d); void setAlarm(Time t, Sound s, Duration d, Volume v); // Lambda: just works (overload resolution picks 3-arg version) auto setSoundL = [](Sound s) { setAlarm(now() + 1h, s, 30s); }; // std::bind: won't compile! Must cast to function pointer type. using SetAlarm3 = void(*)(Time, Sound, Duration); auto setSoundB = std::bind(static_cast<SetAlarm3>(setAlarm), ...);
Lambda calls invoke setAlarm directly — compilers can inline it. std::bind calls through a function pointer, which compilers are less likely to inline.
std::bind can (see Item 32).auto params. Bind objects use perfect forwarding, so they accept any type.c++ // Lambda: now() evaluated at CALL TIME (correct) auto setSoundL = [](Sound s) { setAlarm(steady_clock::now() + 1h, s, 30s); }; // setSoundL(Sound::Siren); ← alarm goes off 1h from NOW // bind: now() evaluated at BIND TIME (wrong!) auto setSoundB = std::bind(setAlarm, steady_clock::now() + 1h, // evaluated RIGHT HERE _1, 30s); // If you call setSoundB 3 hours later, the alarm time // is 1h after the BIND, not 1h after the CALL. Already passed! // Fix requires nesting binds (unreadable!): auto setSoundB = std::bind(setAlarm, std::bind(std::plus<>(), steady_clock::now(), 1h), _1, 30s);
c++ class PolyWidget { public: template<typename T> void operator()(const T& param); }; PolyWidget pw; auto boundPW = std::bind(pw, _1); // bind uses perfect forwarding boundPW(1930); // calls operator()<int> boundPW(nullptr); // calls operator()<nullptr_t> boundPW("Rosebud"); // calls operator()<const char*> // C++11 lambda CAN'T do this (no auto params). // C++14 lambda CAN: auto boundPW14 = [pw](const auto& param) { pw(param); };
std::bind. Init capture handles move capture, and generic lambdas (auto params) handle polymorphic calls.std::bind(setAlarm, ...) compile when setAlarm is overloaded?Different capture modes create fundamentally different closures. Click each mode to see how data flows into the closure object.
Click a mode to see how the closure relates to the captured variable.
What [=] actually captures in a member function.
| Mode | Closure Holds | Original Affected? | Dangling Risk |
|---|---|---|---|
[x] | Copy of x | No | Low (but this pointer!) |
[&x] | Reference to x | Yes | High if closure outlives x |
[y=move(x)] | Moved-from x | Yes (emptied) | Low (closure owns it) |
See the same operations expressed as lambdas and as std::bind. The contrast speaks for itself.
Each row shows the same logic. Lambda on the left, bind on the right.
c++ // Lambda: obvious at a glance auto betweenL = [lowVal, highVal](const auto& val) { return lowVal <= val && val <= highVal; }; // bind: job security through code obscurity auto betweenB = std::bind(std::logical_and<>(), std::bind(std::less_equal<>(), lowVal, _1), std::bind(std::less_equal<>(), _1, highVal));
c++ Widget w; // Lambda: explicit — w is captured by value auto compressL = [w](CompLevel lev) { return compress(w, lev); }; // bind: is w stored by value or reference? // You have to KNOW that bind always copies. auto compressB = std::bind(compress, w, _1); // How is the argument passed? By value? By reference? // Answer: bind objects pass all args by reference (perfect forwarding). // But you'd only know that by memorizing how bind works.
std::bind hides all three behind memorization requirements.std::bind store its arguments?| Item | Key Takeaway |
|---|---|
| Item 31 | Avoid default capture modes. [&] risks dangling refs. [=] captures this, not members, and misleads about static variables. |
| Item 32 | Use C++14 init capture to move objects into closures. In C++11, emulate with std::bind. |
| Item 33 | Use decltype on auto&& params to std::forward them in generic lambdas. |
| Item 34 | Prefer lambdas to std::bind. More readable, more expressive, often faster. In C++14, bind has zero use cases. |
"Lambdas are such a convenient way to create function objects, the impact on day-to-day C++ software development is enormous." — Scott Meyers