Lambda Expressions in C++
Before C++11, passing behavior to algorithms required writing separate functor classes or using function pointers — verbose and far from the call site. Lambdas let you define anonymous functions inline, right where they are used. C++14 added generic lambdas (auto params) and init captures; C++17 made them constexpr; C++20 added template params.
Use lambdas for short callbacks, algorithm predicates, and any place you need a one-off callable. Prefer named functions for complex logic.
Syntax: [captures](params) -> return_type { body }
How the Compiler Transforms a Lambda Deep Dive
The compiler transforms every lambda expression into a unique,
unnamed class called the "closure type." That class has a single
public method: operator().
Example 1 — no captures:
[](int x) { return x * 2; }
becomes roughly:
struct __lambda_1 {
auto operator()(int x) const { return x * 2; }
};
Example 2 — capture by value:
[multiplier](int x) { return x * multiplier; }
becomes roughly:
struct __lambda_2 {
int multiplier; // captured value stored as member
auto operator()(int x) const { return x * multiplier; }
};
Example 3 — capture by reference:
[&counter]() { ++counter; }
becomes roughly:
struct __lambda_3 {
int& counter; // reference member
auto operator()() const { ++counter; }
};
Note: even though operator() is const, it can modify counter
because const on a reference member means the reference itself
can't be reseated — the referred-to value can still change.
The mutable keyword:
By default, operator() is const, which means by-value captures
cannot be modified inside the body. Adding "mutable" removes
the const qualifier from operator(), allowing the lambda to
modify its internal copies of captured variables.
Each lambda expression has a UNIQUE type — even two textually
identical lambdas produce different closure types. This means:
auto a = [](int x) { return x; };
auto b = [](int x) { return x; };
// decltype(a) != decltype(b) — they are different types!
Consequently, auto is the only way to store a lambda in its
"native" type. std::function<> can also hold a lambda, but it
type-erases it — adding indirection and potential heap allocation. 1. Basic lambda — no captures, no parameters
The simplest form: [] { body }.
Equivalent to a struct with an operator() — the compiler
generates one for you. 2. Lambda with parameters and return type
Return type is deduced unless explicitly specified. with different types, you must specify -> return_type.
Watch out: if the body has multiple return statements
3. Capture by value [x] vs by reference [&x]
By-value captures are const by default inside the lambda body. By-reference captures see (and can modify) the original variable. the lambda creates a dangling reference — the local dies at scope end.
Watch out: capturing a local by reference and returning or storing
Capture Modes Deep Dive
[=] — default capture by value. The compiler inspects the lambda body and generates a capture list of ALL local variables that are actually REFERENCED in the body, each copied by value. Variables in scope but not used are NOT captured. [&] — default capture by reference. Same analysis, but each referenced variable becomes a reference member in the closure. [=, &x] — capture everything by value, EXCEPT x which is captured by reference. You can mix defaults with explicit overrides for specific variables. [this] — captures the enclosing class's this pointer by value. This means the pointer is copied, NOT the object. The lambda can access all members through this pointer, but if the lambda outlives the object, the pointer dangles. Watch out: [=] in a member function implicitly captures this (the pointer, not the object). C++20 deprecated this behavior; use [=, this] or [=, *this] (copies the whole object) explicitly.
4. Mutable lambdas — modifying by-value captures
The mutable keyword lets you modify the lambda's internal copy of a captured variable. The original variable is unaffected.
5. Generic lambdas (C++14) — auto parameters
Each auto parameter makes the lambda's operator() a template. This is the simplest way to write type-generic inline code. separate specialization — keep generic lambda bodies small.
Watch out: each unique set of argument types instantiates a
6. Init captures (C++14) — create new variables in the capture
Syntax: [name = expr]. Lets you move objects into a lambda or rename captured variables. Essential for move-only types. but unspecified state — don't read it after the move.
Watch out: a moved-from variable (the source) is in a valid
7. Immediately-invoked lambda expressions (IILE)
Call the lambda right where you define it. Useful for complex initialization of const variables.
8. Lambdas with STL algorithms
Lambdas are the primary way to customize algorithm behavior. They replace the old-style functor objects.
9. Storing lambdas with std::function
std::function<R(Args...)> can hold any callable with the matching signature: lambdas, function pointers, functors. heap allocation). Prefer auto or templates when possible.
Watch out: std::function has overhead (type erasure, possible
Key Takeaways
- •Prefer [&] or [=] for short-lived lambdas; use explicit captures ([x, &y]) when the lambda may outlive the scope.
- •Use generic lambdas (auto params) for simple type-generic code.
- •Use init captures ([ptr = std::move(p)]) to move resources in.
- •Immediately-invoked lambdas are a clean way to init const variables.
- •Avoid std::function unless you need runtime polymorphism for callables.
int main() {
std::cout << "--- 1. Basic Lambda ---\n";
auto greet = [] { std::cout << "Hello from a lambda!\n"; };
greet();
std::cout << "\n--- 2. Lambda with Parameters ---\n";
auto add = [](int a, int b) { return a + b; };
std::cout << std::format("5 + 3 = {}\n", add(5, 3));
// Explicit return type needed when multiple return paths differ
auto safe_divide = [](double a, double b) -> double {
if (b == 0.0) return 0.0;
return a / b;
};
std::cout << std::format("10 / 3 = {:.2f}\n", safe_divide(10.0, 3.0));
std::cout << "\n--- 3. Capture by Value vs Reference ---\n";
int multiplier = 10;
auto times = [multiplier](int x) { return x * multiplier; };
std::cout << std::format("7 * 10 = {}\n", times(7));
int counter = 0;
auto increment = [&counter] { ++counter; };
increment();
increment();
std::cout << std::format("Counter after 2 increments: {}\n", counter);
// Mixed captures: a by value, b by reference
int a = 1, b = 2;
auto mixed = [a, &b] { b += a; };
mixed();
std::cout << std::format("b after mixed capture: {}\n", b); // b is now 3
std::cout << "\n--- 4. Mutable Lambda ---\n";
int x = 0;
auto mutable_lambda = [x]() mutable { return ++x; };
std::cout << std::format("Call 1: {}\n", mutable_lambda()); // 1
std::cout << std::format("Call 2: {}\n", mutable_lambda()); // 2
// The internal copy increments, but the original x is still 0
std::cout << std::format("Original x: {}\n", x);
std::cout << "\n--- 5. Generic Lambda (C++14) ---\n";
auto print_value = [](const auto& value) {
std::cout << value << '\n';
};
print_value(42);
print_value("Hello");
print_value(3.14);
// Generic lambda with multiple auto params — each independently deduced
auto max_of = [](const auto& a, const auto& b) {
return (a > b) ? a : b;
};
std::cout << std::format("max(3, 7) = {}\n", max_of(3, 7));
std::cout << "\n--- 6. Init Captures (C++14) ---\n";
// Move a unique_ptr into a lambda — only possible with init captures
auto ptr = std::make_unique<int>(42);
auto use_ptr = [p = std::move(ptr)] {
std::cout << std::format("Moved unique_ptr holds: {}\n", *p);
};
use_ptr();
// ptr is now nullptr — ownership was transferred to the lambda
// Rename a captured variable for clarity
std::string long_name = "some_important_value";
auto show = [val = std::move(long_name)] {
std::cout << std::format("Init-captured: {}\n", val);
};
show();
std::cout << "\n--- 7. Immediately-Invoked Lambda ---\n";
// Use IILE to initialize a const variable with complex logic
const auto config_value = []{
// Imagine reading from environment or config file
int base = 100;
int offset = 42;
return base + offset;
}(); // Note the () — invoked immediately
std::cout << std::format("config_value = {}\n", config_value);
std::cout << "\n--- 8. Lambdas with STL Algorithms ---\n";
std::vector<int> nums = {5, 2, 8, 1, 9, 3};
// Sort descending using a lambda comparator
std::sort(nums.begin(), nums.end(),
[](int a, int b) { return a > b; });
std::cout << "Sorted descending: ";
std::for_each(nums.begin(), nums.end(),
[](int n) { std::cout << n << ' '; });
std::cout << '\n';
// Count elements matching a predicate
auto count_even = std::count_if(nums.begin(), nums.end(),
[](int n) { return n % 2 == 0; });
std::cout << std::format("Even numbers: {}\n", count_even);
std::cout << "\n--- 9. std::function ---\n";
// std::function can store any callable matching the signature
std::function<int(int, int)> operation = add;
std::cout << std::format("operation(10, 20) = {}\n", operation(10, 20));
// Swap the operation at runtime
operation = [](int a, int b) { return a * b; };
std::cout << std::format("operation(10, 20) = {}\n", operation(10, 20));
return 0;
}