C++ Lambda Expressions: Capture Modes, mutable, Generic Lambdas and Dangling References

Key takeaways

Lambdas replace one-off functor classes with a callable defined at the call site. This post covers the syntax, every capture mode and when each one dangles, mutable, generic and template lambdas, and patterns with STL algorithms, threads and callbacks.

Introduction: functors feel too heavy

Sorting a vector of Person by age needs a comparison: a callable that returns true if the first element should come before the second. Before C++11 that meant a function object class written somewhere away from the call site:

struct CompareByAge {
    bool operator()(const Person& a, const Person& b) const {
        return a.age < b.age;
    }
};
std::vector<Person> people = /* ... */;
std::sort(people.begin(), people.end(), CompareByAge());

A lambda puts the same logic where it is used:

std::vector<Person> people = /* ... */;
std::sort(people.begin(), people.end(),
    [](const Person& a, const Person& b) {
        return a.age < b.age;
    });

The compiler turns the lambda into an unnamed class with an operator(), so it is the same functor as before, just without the boilerplate. Switching the sort key to the name is now a one-line change in the body. The same applies to predicates for std::find_if, to work handed to std::thread, and to callbacks stored for later.

That last case is where lambdas get dangerous. A lambda that runs immediately, like a sort comparator, can safely refer to local variables by reference. A lambda that runs later, on another thread, from a timer, or after being stored in a container, may outlive those locals, and a reference capture then points to destroyed objects. Most of this post is about choosing captures with that difference in mind.

flowchart TB
  subgraph capture[Capture time]
    A[Lambda definition] --> B{Capture mode}
    B --> C["[=] by value"]
    B --> D["[&] by reference"]
    B --> E["[x, &y] mixed"]
  end
  subgraph exec[Call time]
    F[Lambda call] --> G{Captured kind}
    G --> H["Value: snapshot"]
    G --> I["Reference: live object"]
  end
  capture --> exec

Lambda basics

Basic syntax

A lambda has a capture list [ ], a parameter list ( ), an optional trailing return type -> return_type, and a body { }. The capture list decides how outer variables are accessed; the parameters and body work like an ordinary function. The result is a closure object you can store in auto and call like a function.

// Basic shape:
// [capture](parameters) -> return_type { body }

// 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);  // 8
    std::cout << result << "\n";
    return 0;
}

This prints 8.

Omitting the return type

Without -> return_type, the compiler deduces the return type from the return statements. All of them must deduce the same type, so a lambda that returns 0 in one branch and 0.5 in another needs an explicit return type.

auto add = [](int a, int b) {
    return a + b;  // int
};
auto multiply = [](double a, double b) {
    return a * b;  // double
};

Lambdas with no parameters

With no parameters you can write () or leave the parentheses out: [] { ... } is a zero-argument lambda. Before C++23, the parentheses are required if you add mutable, noexcept or a trailing return type.

auto sayHello = []() {
    std::cout << "Hello!\n";
};
sayHello();  // Hello!
auto sayWorld = [] {
    std::cout << "World!\n";
};

Immediately invoked lambdas

A lambda can be defined and called in the same expression. This is useful for initializing a const variable with logic that needs several statements.

int result = [](int x) {
    return x * x;
}(5);  // 25

const auto data = [] {
    std::vector<int> vec;
    for (int i = 0; i < 10; ++i) {
        vec.push_back(i * i);
    }
    return vec;
}();

Capture modes

Capture by value [=]

[=] copies the automatic variables used in the body at the point where the lambda is created. The copies are snapshots: changing the outer x later does not change what the lambda sees.

int x = 10;
int y = 20;
auto lambda = [=]() {
    std::cout << x << ", " << y << "\n";
};
x = 100;   // the lambda's copy of x is unchanged
lambda();  // 10, 20

Inside a member function, [=] also captures this (the pointer, not the object), so member access through it is effectively by reference. This implicit capture of this is deprecated since C++20; write [=, this] to make it explicit.

Capture by reference [&]

[&] captures automatic variables by reference. Changes inside the lambda affect the originals, and reads see the current values at call time. This is only safe while the variables are alive.

int x = 10;
int y = 20;
auto lambda = [&]() {
    x += 5;
    y += 10;
};
lambda();
std::cout << x << ", " << y << "\n";  // 15, 30

Explicit capture

Listing names makes the intent explicit: [x] captures x by value, [&y] captures y by reference, and anything not listed is not accessible.

int x = 10;
int y = 20;
int z = 30;
// x by value, y by reference, z not captured
auto lambda = [x, &y]() {
    std::cout << x << ", " << y << "\n";
    // std::cout << z;  // error: z not captured
};

Mixed default capture

[=, &y] captures by value by default but y by reference; [&, x] captures by reference by default but x by value.

int x = 10;
int y = 20;
int z = 30;
auto lambda1 = [=, &y]() {
    std::cout << x << ", " << y << ", " << z << "\n";
};
auto lambda2 = [&, x]() {
    std::cout << x << ", " << y << ", " << z << "\n";
};

Capturing this

Inside member functions, [this] lets the lambda use members. It captures only the pointer, so if the lambda outlives the object, using it is undefined behavior. Since C++17, [*this] copies the whole enclosing object into the closure instead.

class Counter {
    int count = 0;

public:
    void increment() {
        auto lambda = [this]() {
            count++;  // member access through this
        };

        lambda();
    }

    int getCount() const { return count; }
};

Capture reference

SyntaxMeaningLifetime safetyTypical use
[]Nothing capturedAlways safeNo outer state
[=]Default by valueSafe for deferred calls, except for captured this and pointersAsync, threads, stored callbacks
[&]Default by referenceSafe only if invoked before scope endsSynchronous sort / find_if
[x]x by valueSafe for deferred callsCherry-picked copies
[&y]y by referenceImmediate useIn-place updates
[=, &y]Mostly value, y by referenceMind y’s lifetimeMostly copy, one reference
[&, x]Mostly reference, x by valuex is a snapshotMostly reference, one copy
[this]Current object pointerObject must outlive the lambdaMember functions
[*this] (C++17)Copy of *thisIndependent of the original objectWork handed to other threads
[p = std::move(ptr)]Init capture with moveClosure owns the objectunique_ptr, large buffers

Init capture (C++14)

[name = expr] creates a new member in the closure, initialized from expr. [p = std::move(ptr)] moves an object into the lambda, which is the only way to capture move-only types such as std::unique_ptr.

int x = 10;
auto lambda = [y = x + 5]() {
    std::cout << y << "\n";  // 15
};
auto ptr = std::make_unique<int>(42);
auto lambda2 = [p = std::move(ptr)]() {
    std::cout << *p << "\n";
};

A closure that owns a unique_ptr is itself move-only, so it cannot be stored in std::function before C++23’s std::move_only_function.


mutable, noexcept and attributes

mutable lambdas

The generated operator() is const by default, so by-value captures cannot be modified. mutable removes that const, which lets the lambda modify its own copies, never the original variables.

int x = 0;
auto lambda1 = [x]() {
    // x++;  // error: x is read-only here
    std::cout << x << "\n";
};
auto lambda2 = [x]() mutable {
    x++;  // modifies the copy inside the closure
    std::cout << x << "\n";
};
lambda2();  // 1
lambda2();  // 2
std::cout << x << "\n";  // 0 (outer x unchanged)

Because the state lives in the closure object, copying a mutable lambda also copies its counter. Algorithms are allowed to copy function objects, so a mutable lambda used as a predicate may not see a single shared count.

noexcept

noexcept declares that the call operator does not throw. If an exception escapes anyway, std::terminate is called. Code that checks noexcept at compile time, such as std::is_nothrow_invocable, then sees the lambda as non-throwing.

auto lambda = []() noexcept {
    return 42;
};

Attributes

An attribute written after the parameter list, as in []() [[nodiscard]] { ... }, applies to the type of the call operator, not to the function, so it does not make the result [[nodiscard]]; compilers ignore it with a warning or reject it. C++23 adds a position for attributes that apply to the call operator itself:

auto lambda = [] [[nodiscard]] () {  // C++23
    return 42;
};
lambda();  // warning: ignoring return value

Generic lambdas (C++14)

auto parameters

C++14 allows auto parameters. The closure type stays the same, but its operator() becomes a member function template, instantiated once for each argument type it is called with.

auto print = [](const auto& value) {
    std::cout << value << "\n";
};
print(42);
print(3.14);
print("hello");

Multiple generic parameters

auto add = [](auto a, auto b) {
    return a + b;
};
std::cout << add(1, 2) << "\n";
std::cout << add(1.5, 2.5) << "\n";
std::cout << add(std::string("Hello"), std::string(" World")) << "\n";

Each auto is deduced independently, so add(1, 2.5) is also valid and returns a double. When both parameters must have the same type, a template lambda says so directly.

Template lambdas (C++20)

#include <typeinfo>
auto lambda = []<typename T>(T value) {
    std::cout << typeid(T).name() << ": " << value << "\n";
};
lambda(42);
lambda(3.14);

auto same = []<typename T>(T a, T b) { return a + b; };
// same(1, 2.5);  // error: T deduced as both int and double

typeid(T).name() returns an implementation-defined string; GCC and Clang print mangled names such as i and d.


Practical patterns

Pattern 1: STL algorithms

Algorithms such as find_if, count_if, all_of and transform take predicates or unary operations, which is the most common use of lambdas.

// g++ -std=c++17 -o lambda_stl lambda_stl.cpp && ./lambda_stl
#include <algorithm>
#include <iostream>
#include <iterator>
#include <string>
#include <vector>
struct Person {
    std::string name;
    int age;
};
int main() {
    std::vector<Person> people = {
        {"Alice", 25}, {"Bob", 30}, {"Charlie", 20}, {"Diana", 25}
    };
    std::vector<int> numbers = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
    auto it = std::find_if(people.begin(), people.end(),
        [](const Person& p) { return p.age >= 25; });
    if (it != people.end())
        std::cout << "Found: " << it->name << ", " << it->age << "\n";
    auto count = std::count_if(people.begin(), people.end(),
        [](const Person& p) { return p.age >= 25; });
    std::cout << "Count (age>=25): " << count << "\n";
    bool allAdult = std::all_of(people.begin(), people.end(),
        [](const Person& p) { return p.age >= 18; });
    std::cout << "All adult: " << (allAdult ? "yes" : "no") << "\n";
    std::vector<int> squares;
    std::transform(numbers.begin(), numbers.end(),
        std::back_inserter(squares),
        [](int x) { return x * x; });
    std::cout << "Squares: ";
    for (int s : squares) std::cout << s << " ";
    std::cout << "\n";
    return 0;
}

This prints Found: Alice, 25, Count (age>=25): 3, All adult: yes and the squares from 1 to 100.

Pattern 2: Sorting

std::sort(people.begin(), people.end(),
    [](const Person& a, const Person& b) {
        return a.age < b.age;
    });
std::sort(people.begin(), people.end(),
    [](const Person& a, const Person& b) {
        return a.name.length() < b.name.length();
    });

A comparator must be a strict weak ordering: cmp(a, a) must be false. Writing <= instead of < breaks that rule, and std::sort may then read outside the range and crash, which makes it one of the harder bugs to trace back to a one-character lambda. std::sort is also not stable, so people with equal name lengths can end up in any order; use std::stable_sort if the previous order should be kept.

Pattern 3: std::thread and lambdas

Capture what the thread needs by value, so the thread does not depend on the parent’s stack frame.

// g++ -std=c++17 -pthread -o lambda_thread lambda_thread.cpp && ./lambda_thread
#include <iostream>
#include <thread>
#include <vector>
int main() {
    std::vector<int> data = {1, 2, 3, 4, 5};
    int multiplier = 10;
    std::thread t([data, multiplier]() {
        for (int x : data) {
            std::cout << x * multiplier << " ";
        }
        std::cout << "\n";
    });
    t.join();
    return 0;
}

This prints 10 20 30 40 50.

Pattern 4: Callbacks

#include <functional>
#include <iostream>
class Button {
    std::function<void()> onClick;

public:
    void setOnClick(std::function<void()> callback) {
        onClick = std::move(callback);
    }

    void click() {
        if (onClick) onClick();
    }
};
int main() {
    Button button;

    int clickCount = 0;
    button.setOnClick([&clickCount]() {
        clickCount++;
        std::cout << "Clicked " << clickCount << " times\n";
    });

    button.click();
    button.click();
}

The reference capture is safe here only because button and clickCount live in the same scope and button cannot be clicked after clickCount is gone. If the button were stored elsewhere and outlived main’s locals, the callback would need to own its state, for example [count = 0]() mutable { ... }.

Pattern 5: RAII helper (ScopeGuard)

#include <cstdio>
#include <iostream>
#include <string>
#include <utility>
template <typename Func>
class ScopeGuard {
    Func func;
    bool active = true;

public:
    explicit ScopeGuard(Func f) : func(std::move(f)) {}
    ScopeGuard(const ScopeGuard&) = delete;
    ScopeGuard& operator=(const ScopeGuard&) = delete;

    ~ScopeGuard() {
        if (active) func();
    }

    void dismiss() { active = false; }
};
template <typename Func>
ScopeGuard<Func> makeScopeGuard(Func func) {
    return ScopeGuard<Func>(std::move(func));  // C++17: guaranteed copy elision
}
void processFile(const std::string& filename) {
    std::FILE* file = std::fopen(filename.c_str(), "r");
    if (!file) return;

    auto guard = makeScopeGuard([file]() {
        std::fclose(file);
        std::cout << "File closed\n";
    });
    // ... read from file; fclose runs on every return path and on exceptions
}

The deleted copy constructor matters. Without it, a copy of the guard would also run func on destruction and close the file twice. In C++17, returning the prvalue from makeScopeGuard needs no copy or move constructor at all; in C++14 the guard would need a move constructor that dismisses the source.

Pattern 6: Lazy evaluation

#include <iostream>
#include <optional>
#include <type_traits>

template <typename Func>
class Lazy {
    Func func;
    mutable std::optional<std::invoke_result_t<Func&>> cached;

public:
    Lazy(Func f) : func(std::move(f)) {}

    auto operator()() const {
        if (!cached) {
            cached = func();
        }
        return *cached;
    }
};
int main() {
    Lazy expensive([] {
        std::cout << "Computing...\n";
        return 42;
    });

    std::cout << expensive() << "\n";  // Computing... then 42
    std::cout << expensive() << "\n";  // 42 (cached)
}

Class template argument deduction (C++17) deduces Func from the constructor argument, which is the only practical way to name a lambda’s type here. The cache is not thread-safe; concurrent first calls would race on cached.

Pattern 7: Recursive lambdas

A lambda cannot refer to itself by name, because the variable it is assigned to is not yet declared inside its own initializer. The two usual workarounds are std::function and, in C++23, an explicit object parameter.

#include <functional>
#include <iostream>

int main() {
    std::function<int(int)> factorial = [&factorial](int n) {
        return n <= 1 ? 1 : n * factorial(n - 1);
    };
    std::cout << factorial(5) << "\n";  // 120

    // C++23: deducing this
    auto factorial2 = [](this auto self, int n) -> int {
        return n <= 1 ? 1 : n * self(n - 1);
    };
    std::cout << factorial2(5) << "\n";  // 120
    return 0;
}

The std::function version adds an indirect call per recursion step and captures factorial by reference, so it must not be copied out of the scope that owns factorial. The C++23 version has neither problem.


Common mistakes

Dangling reference

The symptoms are crashes, garbage values, or bugs that appear only in optimized builds, because the destroyed stack slot happens to keep its old value in debug builds.

std::function<void()> createBroken() {
    int x = 42;
    return [&x]() { std::cout << x << "\n"; };  // x dies when the function returns
}
std::function<void()> createSafe() {
    int x = 42;
    return [x]() { std::cout << x << "\n"; };
}

Capturing a loop variable by reference

std::vector<std::thread> threads;
// ❌ every thread refers to the same i, which keeps changing and is destroyed after the loop
for (int i = 0; i < 5; ++i) {
    threads.emplace_back([&i]() {
        std::cout << i << "\n";
    });
}

Each thread reads i while the loop is still incrementing it, which is a data race, and threads that start late read a variable that no longer exists. The output can show repeated values, the value 5, or garbage. Capturing by value gives each thread its own copy:

// ✅ each thread gets its own copy of i
for (int i = 0; i < 5; ++i) {
    threads.emplace_back([i]() {
        std::cout << i << "\n";
    });
}
for (auto& t : threads) t.join();

Mutating a by-value capture without mutable

The error message is increment of read-only variable 'x' (GCC) or cannot assign to a variable captured by copy in a non-mutable lambda (Clang). Add mutable, or capture by reference if the intent was to change the original.

std::function overhead

std::function erases the lambda’s type, which costs an indirect call that the compiler usually cannot inline, and it may allocate when the closure is larger than its small internal buffer. For callables that are only passed down and called, a template parameter keeps the concrete type:

template <typename Func>
void run(Func&& f) { f(); }

void example() {
    run([] { std::cout << "inlined call, no type erasure\n"; });
}

std::function remains the right tool when callables of different types have to be stored in the same member or container, as in the Button example.

[this] after the object is destroyed

class Worker {
public:
    void start() {
        std::thread t([this]() {
            this->doWork();  // UB if the Worker is destroyed first
        });
        t.detach();
    }
    void doWork();
};

A detached thread can easily outlive the object. If the object is managed by std::shared_ptr, capturing a shared_ptr keeps it alive for as long as the thread needs it:

class SafeWorker : public std::enable_shared_from_this<SafeWorker> {
public:
    void start() {
        auto self = shared_from_this();  // requires the object to be owned by a shared_ptr
        std::thread t([self]() { self->doWork(); });
        t.detach();
    }
    void doWork();
};

Capture cost

By-value capture copies eagerly, at the moment the lambda is created. For a large object that the lambda only reads and does not outlive, a reference capture avoids the copy; for an object the lambda should own, move it in with an init capture. [=] copies only the variables the body actually uses, so it does not copy unrelated locals, but it hides which large objects are being copied.

std::string largeString(10000, 'x');
auto lambda1 = [largeString]() { /* copies 10,000 chars */ };
auto lambda2 = [&largeString]() { /* reference: no copy, must not outlive largeString */ };
auto lambda3 = [s = std::move(largeString)]() { /* moved: closure owns the buffer */ };

Passing a lambda to a function template, such as an algorithm, lets the compiler inline the body, which is usually as fast as hand-written code. The cost appears only when the lambda goes through std::function or a function pointer.


Production patterns

std::async with moved-in data

auto future = std::async(std::launch::async, [data = prepareData()]() {
    return process(data);
});
auto result = future.get();

The init capture moves the prepared data into the closure, so the task owns it and nothing on the caller’s stack has to stay alive. Passing std::launch::async matters: with the default policy the implementation may defer the task until get() and run it on the calling thread.

ScopeGuard on an error path

#include <filesystem>
#include <fstream>
#include <stdexcept>
#include <string>
void writeAtomically(const std::string& path, const std::string& data) {
    const std::string tmpPath = path + ".tmp";
    // declared before the stream, so it runs after the stream is closed
    auto guard = makeScopeGuard([tmpPath]() {
        std::error_code ec;
        std::filesystem::remove(tmpPath, ec);  // must not throw from a destructor
    });
    {
        std::ofstream out(tmpPath);
        if (!out) throw std::runtime_error("Cannot open " + tmpPath);
        out << data;
        out.close();
        if (!out) throw std::runtime_error("Write failed: " + tmpPath);
    }
    std::filesystem::rename(tmpPath, path);
    guard.dismiss();
}

Writing to a temporary file and renaming it means readers never see a half-written file. The guard deletes the temporary file on every failure path, including exceptions. It is declared before the stream so that, as locals are destroyed in reverse order, the file is closed before it is removed (on Windows an open file cannot be deleted). It uses the non-throwing remove overload because an exception escaping a destructor during stack unwinding calls std::terminate.

Strategy-style injection

template <typename OnSuccess, typename OnFailure>
void tryOperation(OnSuccess&& onOk, OnFailure&& onFail) {
    if (doSomething()) {
        onOk();
    } else {
        onFail();
    }
}
tryOperation(
    []() { log("OK"); commit(); },
    []() { log("Failed"); rollback(); }
);