Skip to content
C++
Language
since C++11
Basic

Master Lambda Expressions to Write Cleaner, More Expressive C++

Learn to write lambda expressions in C++ — from basic syntax to capture semantics — so you can pass behavior as values and eliminate boilerplate functors.

By the end of this page, you will be able to write lambda expressions from scratch, choose the right capture mode for your situation, use lambdas with standard algorithms, and avoid the most common lifetime and performance traps.

What and Why

Before lambdas, passing custom behavior to an algorithm required writing a separate functor class — a struct with an operator(). That works, but it forces you to name a concept that only exists to be used once, and to scatter that logic far from where it is called.

A lambda expression is an anonymous function you define right where you need it. The compiler generates the functor class for you, behind the scenes. The result is code that reads the way you think: action, then the detail that customises it.

cpp
#include <algorithm>
#include <vector>
#include <iostream>

int main() {
    std::vector<int> v = {3, 1, 4, 1, 5, 9};

    // Sort descending — the sorting rule lives right here.
    std::sort(v.begin(), v.end(), [](int a, int b) { return a > b; });

    for (int x : v) std::cout << x << ' ';  // 9 5 4 3 1 1
}

That [](int a, int b) { return a > b; } is a lambda. Every piece of it has a name.

Step by Step

The anatomy of a lambda

cpp
[ captures ] ( parameters ) -> return_type { body }

Only the capture list [] and the body {} are required. Everything else is optional when the compiler can deduce it.

Minimal lambda

cpp
#include <iostream>

int main() {
    auto greet = []() { std::cout << "Hello\n"; };
    greet();   // prints: Hello
}

auto is almost always the right type to use when storing a lambda — each lambda has a unique, unnamed type.

Parameters work like any function

cpp
#include <iostream>

int main() {
    auto add = [](int a, int b) { return a + b; };
    std::cout << add(2, 3);   // 5
}

The return type is deduced automatically when the body contains a single return statement. For more complex bodies, annotate explicitly:

cpp
auto divide = [](double a, double b) -> double {
    if (b == 0.0) return 0.0;
    return a / b;
};

Capturing local variables

This is where lambdas become genuinely powerful. The capture list lets the lambda close over variables from the surrounding scope — hence the term closure.

Capture by value — a copy is made when the lambda is created:

cpp
#include <iostream>

int main() {
    int threshold = 5;
    auto above = [threshold](int x) { return x > threshold; };

    threshold = 100;          // too late — the lambda already has its own copy
    std::cout << above(7);    // 1 (true)
}

Capture by reference — the lambda holds a reference to the original variable:

cpp
#include <iostream>

int main() {
    int count = 0;
    auto inc = [&count]() { ++count; };

    inc(); inc(); inc();
    std::cout << count;   // 3
}

Default captures let you avoid listing every variable:

SyntaxMeaning
[=]Capture all used locals by value
[&]Capture all used locals by reference
[=, &x]Defaults to value, but x by reference
[&, y]Defaults to reference, but y by value

Prefer explicit captures when possible — they make dependencies obvious at a glance.

Mutable lambdas (C++11)

Value-captured copies are const by default. Add mutable to allow modification:

cpp
#include <iostream>

int main() {
    int x = 0;
    auto counter = [x]() mutable {
        ++x;           // modifies the lambda's own copy, not the outer x
        return x;
    };

    std::cout << counter() << ' ';   // 1
    std::cout << counter() << ' ';   // 2
    std::cout << x;                  // 0 — outer x is unchanged
}

Generic lambdas (C++14)

Use auto parameters to write lambdas that work for any type:

cpp
#include <iostream>

int main() {
    auto print = [](auto val) { std::cout << val << '\n'; };

    print(42);
    print(3.14);
    print("hello");
}

The compiler generates a templated operator() — one instantiation per distinct type used.

Common Patterns

1. Inline predicates for standard algorithms

cpp
#include <algorithm>
#include <vector>
#include <string>
#include <iostream>

int main() {
    std::vector<std::string> words = {"apple", "fig", "banana", "kiwi"};

    // Keep only short words.
    auto end = std::remove_if(words.begin(), words.end(),
        [](const std::string& s) { return s.size() > 4; });
    words.erase(end, words.end());

    for (const auto& w : words) std::cout << w << ' ';   // fig kiwi
}

2. Callbacks and deferred work

cpp
#include <functional>
#include <iostream>

void run_after(int times, std::function<void(int)> action) {
    for (int i = 0; i < times; ++i) action(i);
}

int main() {
    int total = 0;
    run_after(5, [&total](int i) { total += i; });
    std::cout << total;   // 0+1+2+3+4 = 10
}

std::function erases the lambda's unique type so it can be stored or passed through interfaces. It has overhead — prefer auto or templates when you control both sides.

3. Immediately invoked lambdas for complex initialization

When initialising a const value that requires several steps, an immediately invoked lambda keeps the logic inline without sacrificing const:

cpp
#include <iostream>
#include <vector>

int main() {
    const int result = [&]() {
        int acc = 0;
        for (int i = 1; i <= 10; ++i) acc += i;
        return acc;
    }();   // <-- called immediately

    std::cout << result;   // 55
}

What Can Go Wrong

Dangling reference captures

Capturing by reference is safe only while the referred-to variable is alive. Return a lambda that holds a reference to a local variable and you have undefined behaviour:

cpp
// BROKEN
auto make_counter() {
    int n = 0;
    return [&n]() { return ++n; };   // n is destroyed when make_counter returns
}

Fix: capture by value when the lambda outlives the surrounding scope.

cpp
auto make_counter() {
    int n = 0;
    return [n]() mutable { return ++n; };   // safe — owns its own copy
}

Capturing this incorrectly

Inside a member function, [=] before C++20 captures this by pointer, not by value. If the object is destroyed before the lambda runs, you dereference a dangling pointer.

cpp
// C++17 and earlier — [=] silently captures this
auto bad = [=]() { return member_; };   // copies this pointer, not *this

// C++17: explicit value copy of the whole object
auto good = [*this]() { return member_; };   // safe copy

Overusing std::function

std::function involves heap allocation and virtual dispatch. For a lambda used only in a tight loop, prefer a template parameter:

cpp
// Prefer this for performance-sensitive code:
template<typename F>
void apply(F func, int x) { func(x); }

// Over this:
void apply(std::function<void(int)> func, int x) { func(x); }

Quick Reference

FeatureSyntaxStandard
Basic lambda[](int x) { return x * 2; }C++11
Capture by value[x] or [=]C++11
Capture by reference[&x] or [&]C++11
Mutable capture[x]() mutable { ++x; }C++11
Explicit return type[]() -> int { ... }C++11
Generic lambda[](auto x) { ... }C++14
Copy of *this[*this]C++17
Template lambda[]<typename T>(T x) { ... }C++20
consteval lambda[]() consteval { ... }C++20

Capture Modes in Depth

Capture-by-value copies the variable at the moment the lambda is created, while capture-by-reference holds a live reference that reflects all subsequent changes to the original — choose based on which lifetime and mutation semantics you need. Mixing modes in a single capture list is common and explicit: list the exception first, then apply the default.

cpp
#include <iostream>
#include <string>

int main() {
    std::string prefix = "ID-";
    int counter = 0;

    // Mixed capture: prefix by value (a snapshot), counter by reference (live)
    auto tag = [prefix, &counter](int n) {
        ++counter;              // modifies the real counter
        return prefix + std::to_string(n);  // uses its own copy of prefix
    };

    prefix = "XX-";    // too late — tag already has "ID-"
    std::cout << tag(7) << '\n';  // "ID-7"
    std::cout << counter << '\n'; // 1

    // Default captures (less explicit but concise for large capture sets)
    int x = 10, y = 20, z = 30;
    auto sum_yz = [=, &z]() { return y + z; };  // y by value, z by reference
    z = 99;
    std::cout << sum_yz() << '\n'; // 20 + 99 = 119
}

Avoid [&] for lambdas that escape the current scope (stored in a member, returned from a function, posted to a thread pool). Any local variable they reference becomes a dangling reference the moment its enclosing function returns.

Mutable Lambdas

By default, value-captured variables inside a lambda are const — the mutable keyword lifts that restriction, allowing the lambda to modify its own internal copies without affecting the original. This matters most for stateful lambdas that must track internal state across calls.

cpp
#include <iostream>

int main() {
    int n = 0;

    // Without mutable: compile error — can't modify const copy
    // auto bad = [n]() { return ++n; };

    auto counter = [n]() mutable {
        return ++n;   // modifies the lambda's own copy
    };

    std::cout << counter() << '\n';  // 1
    std::cout << counter() << '\n';  // 2
    std::cout << n << '\n';          // 0 — outer n is untouched

    // Mutable + stateful: a simple ID generator
    auto make_id = [id = 1000]() mutable { return id++; };
    std::cout << make_id() << '\n';  // 1000
    std::cout << make_id() << '\n';  // 1001
}

mutable applies to the entire lambda body, not just specific captures. If you find yourself needing mutable state shared across multiple lambda instances, consider a small struct with operator() instead — it makes the ownership of that state explicit.

Generic Lambdas and C++20 Template Lambdas

Generic lambdas (C++14) accept auto parameters and expand to a templated operator() behind the scenes, letting a single lambda handle multiple types; C++20 extends this with explicit template parameter lists for cases where auto is too loose. Each distinct combination of argument types produces a separate instantiation.

cpp
#include <iostream>
#include <vector>
#include <algorithm>

int main() {
    // Generic lambda — works for any type with operator<<
    auto print = [](const auto& val) { std::cout << val << '\n'; };
    print(42);
    print(3.14);
    print("hello");

    // C++20 explicit template parameter — constrain or name the type
    auto add = []<typename T>(T a, T b) -> T {
        return a + b;
    };
    std::cout << add(1, 2) << '\n';        // 3
    std::cout << add(1.5, 2.5) << '\n';   // 4.0
    // add(1, 2.0);  // compile error — both must be the same T

    // Useful for perfect forwarding inside a lambda
    auto forward_print = []<typename T>(T&& val) {
        std::cout << std::forward<T>(val) << '\n';
    };
}

Explicit template parameters shine when you need the type name for constraints, perfect forwarding (T&& + std::forward<T>), or SFINAE. For simple cases, auto is shorter and equally effective.

Immediately Invoked Lambda Expressions (IIFE)

An immediately invoked lambda expression defines and calls a lambda in a single expression, initialising a variable with the result of complex multi-step logic while still allowing the variable to be const. Without the IIFE pattern, initialising a const variable that requires branching forces you to either use a mutable variable and defer the const, or extract a separate helper function.

cpp
#include <iostream>
#include <string>
#include <cstdlib>

int main() {
    // const variable that requires conditional logic to compute
    const std::string log_level = [&]() -> std::string {
        const char* env = std::getenv("LOG_LEVEL");
        if (!env) return "info";
        std::string s{env};
        for (char& c : s) c = static_cast<char>(std::tolower(c));
        if (s == "debug" || s == "info" || s == "warn" || s == "error")
            return s;
        return "info";
    }();  // <-- immediately invoked

    // log_level is const from here on — no chance of accidental mutation
    std::cout << "Log level: " << log_level << '\n';
}

The IIFE is also the standard C++ idiom for complex aggregate initialisation in contexts where structured bindings or designated initialisers are insufficient. Compared to extracting a named helper, the IIFE keeps the logic local to its point of use, which aids readability when the logic is short-lived.

Recursive Lambdas

Lambdas cannot refer to themselves by name during their own definition, so recursion requires either storing the lambda in a std::function and capturing it, or using C++23's deducing-this to pass the lambda explicitly as a parameter. The std::function approach is the most portable but incurs type-erasure overhead; the C++23 approach is zero-overhead.

cpp
#include <functional>
#include <iostream>

int main() {
    // C++11/14/17: recursive via std::function
    std::function<int(int)> fact = [&fact](int n) -> int {
        return n <= 1 ? 1 : n * fact(n - 1);
    };
    std::cout << fact(5) << '\n';   // 120

    // C++23: deducing-this — lambda receives itself as explicit first parameter
    auto fact23 = [](this auto&& self, int n) -> int {
        return n <= 1 ? 1 : n * self(n - 1);
    };
    std::cout << fact23(6) << '\n'; // 720
}

The std::function version captures fact by reference, which means fact must remain alive for the lambda's entire lifetime. Assigning a different lambda to fact after the initial binding breaks the recursion. The C++23 deducing-this pattern avoids both of these constraints: the lambda is self-contained and carries no external reference.

Lambdas as Comparators and Callbacks

Lambdas are the standard way to supply one-off comparison logic to sorting algorithms and to pass behaviour into event-driven or callback-based APIs. Unlike function pointers, lambdas can close over context, which makes them far more flexible as callbacks.

cpp
#include <algorithm>
#include <vector>
#include <string>
#include <iostream>
#include <functional>

struct Employee { std::string name; int salary; };

int main() {
    std::vector<Employee> staff = {
        {"Carol", 95000}, {"Alice", 82000}, {"Bob", 110000}
    };

    // Sort by salary descending — comparator closes over nothing here,
    // but could close over a "sort key" enum chosen at runtime
    std::sort(staff.begin(), staff.end(),
        [](const Employee& a, const Employee& b) {
            return a.salary > b.salary;
        });

    for (const auto& e : staff)
        std::cout << e.name << ": " << e.salary << '\n';

    // find_if with a threshold captured from the outer scope
    int threshold = 90000;
    auto it = std::find_if(staff.begin(), staff.end(),
        [threshold](const Employee& e) { return e.salary < threshold; });
    if (it != staff.end())
        std::cout << "First under threshold: " << it->name << '\n';
}

When storing a comparator lambda in a data structure (e.g., as a std::set template argument), you must either provide the lambda's type or wrap it in std::function. The idiomatic solution is to pass the lambda as a constructor argument and let the container store it:

cpp
auto cmp = [](int a, int b) { return a > b; };
std::set<int, decltype(cmp)> descending_set(cmp);

Lambda Performance vs. std::function

A lambda used directly as a template argument is zero-overhead — the compiler inlines the call site as if you had written the code inline; wrapping the same lambda in std::function adds heap allocation and a virtual dispatch on every call. The trade-off is flexibility: std::function stores any callable with a matching signature, including those whose type is not known at compile time.

cpp
#include <functional>
#include <vector>
#include <algorithm>

// Zero overhead — F is deduced at compile time, call is inlined
template<typename F>
void apply_all(std::vector<int>& v, F func) {
    for (int& x : v) func(x);
}

// Type-erased — heap allocation + virtual dispatch per call
void apply_all_erased(std::vector<int>& v, std::function<void(int&)> func) {
    for (int& x : v) func(x);
}

int main() {
    std::vector<int> v = {1, 2, 3, 4, 5};

    // This call compiles to a tight loop with the multiply inlined
    apply_all(v, [](int& x) { x *= 2; });

    // This call pays std::function overhead on each of the five iterations
    apply_all_erased(v, [](int& x) { x += 1; });
}

Prefer the template form (template<typename F> void f(F func)) whenever the lambda does not need to cross an ABI boundary, be stored heterogeneously, or be assigned dynamically. Reserve std::function for cases where the callable type cannot be known at compile time — for example, storing user-registered event handlers in a std::vector<std::function<void()>>.

C++23 Improvements: Deducing-this

C++23's deducing-this (the explicit object parameter) gives a lambda — or any member function — access to its own type and value category as a named parameter, enabling recursive lambdas without std::function and CRTP-free mixin patterns. The syntax places this auto&& (or a named deduced type) as the first parameter.

cpp
#include <iostream>
#include <vector>

int main() {
    // Recursive lambda: no std::function, no overhead
    auto flatten = [](this auto&& self, const auto& val, auto& out) {
        if constexpr (requires { val.begin(); val.end(); }) {
            for (const auto& item : val)
                self(item, out);
        } else {
            out.push_back(val);
        }
    };

    std::vector<int> result;
    std::vector<std::vector<int>> nested = {{1, 2}, {3, 4}, {5}};
    for (const auto& inner : nested)
        flatten(inner, result);
    // result: 1 2 3 4 5

    // Deducing-this also fixes the "const overload" problem in functors:
    // the lambda can inspect whether it was called on a const object
    auto maybe_modify = [data = std::vector<int>{1,2,3}](this auto& self) -> auto& {
        return self.data;  // returns const ref or mutable ref depending on self
    };
    (void)maybe_modify;
}

Deducing-this subsumes several older techniques: CRTP mixins, the Y-combinator pattern for recursive lambdas, and manually spelling out const/non-const overloads. If you are on C++23 and need a recursive lambda, reach for this auto&& rather than std::function.

What's Next

Lambdas interact with several powerful language features worth exploring next: