Effective Modern C++, Chapter 6

Lambda Expressions

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.

Prerequisites: Chapters 1-5. Understanding of closures and function objects.
9
Chapters
3+
Simulations

Chapter 0: Lambda Vocabulary

Before diving into the items, let's untangle the three terms that everyone confuses: lambda expression, closure, and closure class.

TermWhat It IsWhen It Exists
Lambda expressionThe source code — the [captures](params){ body } syntax you typeCompile time
Closure classThe compiler-generated class with an operator()Compile time
ClosureThe runtime object — an instance of the closure classRuntime
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
Key distinction: A lambda is what you write. A closure is what gets created at runtime. Informally, people use "lambda" for both — but in the items that follow, the difference matters.

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.

What is a "closure" in C++?

Chapter 1: Avoid Default Capture Modes

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.

The dangling reference trap

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!
}
The problem: 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.

The false safety of [=]

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!
C++14 fix: Use generalized lambda capture to make a true copy: [divisor = divisor](int value) { return value % divisor == 0; }

Static variables aren't captured

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!
}

The full danger: dangling this

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
C++14 fix with init capture: Make a real copy of the data member:
[divisor = divisor](int value) { return value % divisor == 0; }
The left divisor is the closure member name. The right divisor is this->divisor. Now the closure owns its own copy — no dangling.

Summary of default capture pitfalls

Default ModeRiskFix
[&]Dangling references to destroyed localsName captures explicitly: [&divisor]
[=]Captures this (not members), dangling in member functionsC++14 init capture: [member = member]
[=]Misleads about static variables (not actually captured)Name captures explicitly
In a member function, [=] captures data members by value. True or false?

Chapter 2: Init Capture — Moving Objects into Closures

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(); };
How to read init capture: Left of = 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(); };

C++11 workaround: std::bind

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)
);
How it works: 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.

C++11 emulation: how it works step by step

Step 1
std::bind receives the rvalue std::move(data) and move-constructs a copy into the bind object.
Step 2
When the bind object is called, it passes the stored (moved) data to the lambda by reference.
Step 3
The bind object's lifetime matches the closure's, so the data stays alive as long as the "closure" exists.

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)
);
In [pw = std::move(pw)], the two pws refer to:

Chapter 3: decltype on auto&& for Perfect Forwarding

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)));
};
Why decltype works: For lvalue arguments, 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)...
    ));
};

Why does decltype(x) work for rvalues?

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); }
Conclusion: Whether you pass Widget or Widget&& to std::forward, reference collapsing produces the same instantiation. So decltype works correctly for both lvalues and rvalues.
In a generic lambda [](auto&& x), what does decltype(x) yield when an rvalue is passed?

Chapter 4: Prefer Lambdas to std::bind

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.
Three advantages of lambdas: (1) The call to 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.

Overloads break std::bind

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), ...);

Inlining: lambdas win

Lambda calls invoke setAlarm directly — compilers can inline it. std::bind calls through a function pointer, which compilers are less likely to inline.

When std::bind is still useful (C++11 only)

Move capture emulation
C++11 lambdas can't move-capture. std::bind can (see Item 32).
Polymorphic function objects
In C++11, lambdas can't have auto params. Bind objects use perfect forwarding, so they accept any type.

The eager evaluation bug in detail

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);

Polymorphic function objects (C++11 only)

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); };
In C++14, there are no good use cases for std::bind. Init capture handles move capture, and generic lambdas (auto params) handle polymorphic calls.
Why can't std::bind(setAlarm, ...) compile when setAlarm is overloaded?

Chapter 5: Capture Modes Visualized

Different capture modes create fundamentally different closures. Click each mode to see how data flows into the closure object.

Capture Mode Comparison

Click a mode to see how the closure relates to the captured variable.

The this Pointer Trap

What [=] actually captures in a member function.

ModeClosure HoldsOriginal Affected?Dangling Risk
[x]Copy of xNoLow (but this pointer!)
[&x]Reference to xYesHigh if closure outlives x
[y=move(x)]Moved-from xYes (emptied)Low (closure owns it)
Which capture mode transfers ownership of a resource into the closure?

Chapter 6: Lambda vs bind Side by Side

See the same operations expressed as lambdas and as std::bind. The contrast speaks for itself.

Readability Comparison

Each row shows the same logic. Lambda on the left, bind on the right.

Range check

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));

Value storage semantics

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.
The bottom line: Lambdas make capture semantics, parameter passing, and evaluation timing explicit in the code. std::bind hides all three behind memorization requirements.
How does std::bind store its arguments?

Chapter 7: Beyond

ItemKey Takeaway
Item 31Avoid default capture modes. [&] risks dangling refs. [=] captures this, not members, and misleads about static variables.
Item 32Use C++14 init capture to move objects into closures. In C++11, emulate with std::bind.
Item 33Use decltype on auto&& params to std::forward them in generic lambdas.
Item 34Prefer lambdas to std::bind. More readable, more expressive, often faster. In C++14, bind has zero use cases.
Next: Chapter 7: The Concurrency API covers tasks, threads, futures, atomics, and volatile — the building blocks of safe concurrent C++.

"Lambdas are such a convenient way to create function objects, the impact on day-to-day C++ software development is enormous." — Scott Meyers