C++ Lambdas: Closures, Captures, Generic Lambdas, std::function vs auto & Functional Patterns (C++23)

C++ Lambdas: Closures, Captures, Generic Lambdas, std::function vs auto & Functional Patterns (C++23)
Table of Contents
- Lambda Anatomy: The Complete Syntax
- Capture Semantics: Value, Reference, and Init Captures
- Capture by Move: unique_ptr in Lambdas
- Generic Lambdas: auto and Template Parameters
- Mutable Lambdas: Modifying Captured Variables
- Lambdas in STL Algorithms
- Immediately Invoked Lambda Expressions (IIFE)
- std::function vs auto: The Performance Tradeoff
- Recursive Lambdas: Y-Combinator and deducing this (C++23)
- constexpr Lambdas (C++17) and consteval (C++20)
- C++23: explicit object parameter (deducing this)
- Frequently Asked Questions
- Key Takeaway
Lambda Anatomy: The Complete Syntax
// Minimal lambda (no captures, no params, void return):
auto hello = []{ std::cout << "Hello!\n"; };
hello();
// Full syntax:
auto multiply = [factor = 10](int x, int y) mutable noexcept -> int {
factor++; // mutable: can modify captured copies
return x * y * factor;
};
// Immediately called (IIFE):
int result = [](int a, int b) { return a + b; }(3, 4); // result = 7
// What the compiler generates (conceptual equivalent):
struct __Lambda_multiply {
int factor; // Captured value
int operator()(int x, int y) noexcept {
factor++;
return x * y * factor;
}
};
// auto multiply = __Lambda_multiply{10};Capture Semantics: Value, Reference, and Init Captures
int x = 10;
int y = 20;
std::string name = "Alice";
// === Default captures ===
auto cap_all_by_val = [=]{ return x + y; }; // Copies x and y
auto cap_all_by_ref = [&]{ x++; y++; }; // References to x, y (careful: lifetime!)
// Note: [=] and [&] only capture what is actually USED in the body
// === Selective captures ===
auto selective = [x, &y]{ return x + y; }; // x by value, y by reference
auto with_this = [this, x]{ process(x); }; // Capture this pointer + x by value
// === Init captures (C++14): computed or renamed captures ===
int a = 5, b = 3;
auto init_cap = [sum = a + b, product = a * b]() {
return std::format("{} + {} = {}, {} * {} = {}", a, b, sum, a, b, product);
// Note: a and b NOT captured — only sum and product are
};
// === Capture this vs capture *this ===
class Widget {
int value_ = 42;
public:
auto get_adder() {
// BAD: captures this pointer — if Widget is destroyed, lambda dangles!
return [this](int n) { return value_ + n; };
// GOOD (C++17): capture *this — copies the entire Widget into the lambda
return [*this](int n) { return value_ + n; }; // self-contained copy
}
};Capture by Move: unique_ptr in Lambdas
unique_ptr can't be copied, so you can't use [=] to capture it. Use an init capture to move it:
#include <memory>
#include <functional>
auto resource = std::make_unique<ExpensiveResource>();
// WRONG: Can't copy unique_ptr
// auto task = [resource]{ resource->process(); }; // ERROR: copy required
// CORRECT: Init capture with move
auto task = [res = std::move(resource)]() mutable {
res->process();
// resource is now nullptr/empty
};
// resource is now null — ownership transferred to lambda
// Storing in a vector of tasks:
std::vector<std::function<void()>> tasks;
for (int i = 0; i < 5; i++) {
auto data = std::make_unique<WorkItem>(i);
tasks.emplace_back([item = std::move(data)]() {
item->execute();
});
}
// Each task owns its unique_ptr exclusivelyGeneric Lambdas: auto and Template Parameters
C++14 generic lambdas use auto parameters — each unique argument type generates a separate template instantiation:
// C++14: auto in parameter list
auto print = [](const auto& value) {
std::cout << value << '\n';
};
print(42); // Generates: operator()(const int& value)
print(3.14); // Generates: operator()(const double& value)
print("hello"); // Generates: operator()(const char[6]& value)
// C++20: explicit template syntax in lambdas
auto typed_print = []<typename T>(const T& value) {
std::cout << std::format("Type: {}, Value: {}\n",
typeid(T).name(), value);
};
// C++20: constrained generic lambda with concepts
auto add_numbers = []<std::integral T>(T a, T b) { return a + b; };
add_numbers(3, 4); // OK: ints satisfy integral concept
// add_numbers(1.5, 2.5); // ERROR: doubles don't satisfy integral
// C++20: variadic template lambda
auto sum_all = []<typename... Args>(Args&&... args) {
return (... + std::forward<Args>(args)); // Fold expression
};
auto total = sum_all(1, 2, 3, 4, 5); // 15
auto fsum = sum_all(1.5f, 2.5f); // 4.0fMutable Lambdas: Modifying Captured Variables
By default, captures by value create const copies inside the lambda body. mutable removes this restriction:
int counter = 0;
// Without mutable: capture is const, cannot modify
auto count_ro = [counter]() {
// counter++; // ERROR: assignment of read-only captured variable
return counter;
};
// With mutable: can modify the captured COPY (NOT the original)
auto count_mut = [counter]() mutable {
return ++counter; // Modifies the copy inside the lambda
};
std::cout << count_mut() << '\n'; // 1
std::cout << count_mut() << '\n'; // 2
std::cout << counter << '\n'; // Still 0! Original unchanged
// Stateful generator using mutable:
auto make_counter = [n = 0]() mutable { return n++; };
std::cout << make_counter() << '\n'; // 0
std::cout << make_counter() << '\n'; // 1
std::cout << make_counter() << '\n'; // 2Lambdas in STL Algorithms
Lambdas replace hand-written functors and function objects for STL algorithms:
#include <algorithm>
#include <ranges>
#include <vector>
std::vector<int> nums = {5, 2, 8, 1, 9, 3, 7, 4, 6};
// Sort with custom comparator:
std::sort(nums.begin(), nums.end(), [](int a, int b){ return a > b; }); // Descending
// Filter with ranges (C++20):
auto evens = nums | std::views::filter([](int n){ return n % 2 == 0; });
// Transform:
auto squares = nums
| std::views::filter([](int n){ return n > 3; })
| std::views::transform([](int n){ return n * n; });
// find_if:
auto it = std::find_if(nums.begin(), nums.end(), [](int n){ return n > 5; });
// count_if:
int odds = std::count_if(nums.begin(), nums.end(), [](int n){ return n % 2 != 0; });
// remove_if + erase:
nums.erase(
std::remove_if(nums.begin(), nums.end(), [](int n){ return n < 4; }),
nums.end()
);
// Comparator for priority_queue (min-heap):
auto cmp = [](const Task& a, const Task& b){ return a.priority > b.priority; };
std::priority_queue<Task, std::vector<Task>, decltype(cmp)> task_queue(cmp);Immediately Invoked Lambda Expressions (IIFE)
An IIFE executes a lambda immediately — useful for complex initialization of const variables:
// Initialize const from complex logic without a named helper function:
const int processed_count = [&data]() {
int count = 0;
for (const auto& item : data) {
if (item.is_valid() && item.value > 0) count++;
}
return count;
}(); // Immediately invoked
// Use in member initializer lists:
class Config {
const std::vector<std::string> allowed_hosts_;
public:
Config(const std::string& config_file)
: allowed_hosts_([&config_file]() {
std::vector<std::string> hosts;
parse_hosts(config_file, hosts);
return hosts;
}()) // IIFE in initializer list
{}
};
// Complex constexpr initialization:
constexpr auto LOOKUP_TABLE = [](){
std::array<uint8_t, 256> table{};
for (int i = 0; i < 256; i++) {
table[i] = __builtin_popcount(i); // Population count
}
return table;
}(); // Computed at compile timestd::function vs auto: The Performance Tradeoff
// auto: zero-overhead, lambda type preserved
auto fast_sorter = [](int a, int b){ return a < b; };
std::sort(v.begin(), v.end(), fast_sorter); // Inlined by compiler — fastest
// std::function: type-erased, extra overhead
std::function<bool(int, int)> slow_sorter = [](int a, int b){ return a < b; };
std::sort(v.begin(), v.end(), slow_sorter); // Virtual-call-like dispatch — slower
// std::function overhead sources:
// 1. Type erasure via internal virtual dispatch (~3-5ns per call)
// 2. Small buffer optimization (≤~16 bytes): stack allocation
// 3. Larger closures: heap allocation!
// RULE: Use auto when type doesn't need to cross interface boundaries
// RULE: Use std::function only when you need to store heterogeneous callables
// Storing callbacks in a class (correct use of std::function):
class EventBus {
std::vector<std::function<void(const Event&)>> handlers_;
public:
void subscribe(std::function<void(const Event&)> handler) {
handlers_.push_back(std::move(handler));
}
void publish(const Event& e) {
for (auto& h : handlers_) h(e); // Must handle any callable
}
};Recursive Lambdas: Y-Combinator and deducing this (C++23)
C++ lambdas cannot directly call themselves because they have no name inside their own body. Solutions:
// Pre-C++23: std::function (with overhead)
std::function<int(int)> factorial = [&factorial](int n) -> int {
return n <= 1 ? 1 : n * factorial(n - 1);
};
// Pre-C++23: Y-combinator (zero overhead, complex)
auto Y = [](auto f) {
return [f](auto x) { return f(f, x); };
};
auto factorial_y = Y([](auto self, int n) -> int {
return n <= 1 ? 1 : n * self(self, n - 1);
});
// C++23: "deducing this" — explicit object parameter — direct recursion!
auto factorial_23 = [](this auto self, int n) -> int {
return n <= 1 ? 1 : n * self(n - 1); // self refers to the lambda itself
};
// Zero overhead: same as a regular recursive function
int f10 = factorial_23(10); // 3628800Frequently Asked Questions
Is a lambda always faster than std::function?
When passed directly to a template function (like std::sort), yes — the compiler knows the exact type and inlines the lambda body. When stored in std::function, lambda calls involve type erasure overhead (~3-5ns per call). For callbacks stored in containers, std::function is unavoidable. For algorithms, always pass lambdas directly.
Can lambdas be constexpr?
Yes — from C++17, lambdas are implicitly constexpr if their body is valid as a constant expression. Lambda IIFE is a common pattern for initializing constexpr arrays with complex logic. C++20 allows lambdas in unevaluated contexts, template arguments, and more.
What's the capture overhead?
Captured values are stored as members of the compiler-generated closure type. Captured by-value: space for each captured variable. Captured by-reference: one pointer per reference. Capturing [=] only captures variables actually used in the body — no overhead for unused variables. The lambda object itself lives on the stack unless stored in a std::function (may heap-allocate large closures).
Key Takeaway
Lambdas transformed C++ from a language where "passing logic" required separate functor classes to one where inline, composable, closures are first-class. The key discipline: pass to algorithms directly (zero overhead via auto), store in std::function only when necessary (accepts type erasure overhead), and use C++23's "deducing this" for recursive lambdas without overhead. Together with std::ranges, lambdas enable functional pipeline programming that remains fully optimizable by the compiler.
Read next: Multithreading & Atomics: High-Performance Concurrency →
Part of the C++ Mastery Course — 30 modules from modern C++ basics to expert systems engineering.
