Lambda Advanced Features
C++ lambda advanced features — template lambdas, perfect forwarding, IIFE, recursive lambdas, overload pattern, constexpr, consteval, static lambdas, and C++23 additions.
Advanced Lambda Expressionssince C++14Advanced lambda features extend the basic closure mechanism with generic and template-parameterized call operators, init captures for move-only types, immediate invocation, recursive self-reference via deducing this, and compile-time evaluation — each governed by rules that accumulated from C++14 through C++23.
Overview
Lambdas began in C++11 as syntactic shorthand for local function objects. Each subsequent standard added meaningful capability: C++14 brought generic (auto) parameters and init captures; C++17 made lambdas implicitly constexpr when the body qualified; C++20 added explicit template parameters, consteval, and cleaner this-capture semantics; C++23 introduced static lambdas and relaxed the requirement for () in certain positions.
Syntax
The full lambda declarator (all specifiers optional, in order):
[capture-list](params) specifiers exception-spec -> return-type { body }
// ^^^^^^ ^^^^^^^^
// C++11 mutable (C++11), constexpr (C++17), consteval (C++20),
// static (C++23), noexcept, [[attributes]]C++20 additionally permits a template parameter list between the capture list and the parameter list:
[capture-list]<template-params>(params) specifiers { body } // C++20Examples
Template Lambdas (C++20)
C++14 generic lambdas use auto parameters, each deduced independently. When two arguments must share a type, or when a concept constraint is needed, C++20 template lambdas are the right tool:
// C++14: each auto is a separate, unrelated template parameter
auto add14 = [](auto a, auto b) { return a + b; };
add14(1, 2.0); // ok: a=int, b=double — mixed types allowed
// C++20: relate arguments through a shared T
auto add20 = []<typename T>(T a, T b) { return a + b; };
// add20(1, 2.0); // error: conflicting deductions for T
add20(1, 2); // ok: T=int
// C++20: constrained with a concept
auto sum_range = []<std::ranges::range R>(const R& r) {
return std::reduce(r.begin(), r.end());
};
// C++20: variadic template lambda with perfect forwarding (no decltype trick needed)
auto forward_all = []<typename... Ts>(Ts&&... args) {
return some_func(std::forward<Ts>(args)...);
};Perfect forwarding in C++14 generic lambdas requires a decltype workaround because there is no named template parameter to pass to std::forward. C++20 eliminates this:
// C++14: verbose and error-prone
auto fwd14 = [](auto&& x) {
return process(std::forward<decltype(x)>(x));
};
// C++20: clean and correct
auto fwd20 = []<typename T>(T&& x) {
return process(std::forward<T>(x));
};IIFE — Immediately Invoked Lambda
Invoking a lambda immediately at the point of definition initializes a const variable through arbitrary logic without polluting scope with a named helper:
// Complex const initialization
const auto primes_under_100 = []{
std::vector<int> v;
for (int i = 2; i < 100; ++i)
if (is_prime(i)) v.push_back(i);
return v;
}(); // () invokes immediately; type deduced as std::vector<int>
// Multi-step construction that must remain atomic
const auto conn = [&]() -> DatabaseConnection {
auto c = DatabaseConnection{host, port};
c.set_timeout(5s);
c.authenticate(creds);
return c;
}();
// constexpr IIFE — evaluate a loop at compile time (C++17)
constexpr int triangle_10 = []{
int sum = 0;
for (int i = 1; i <= 10; ++i) sum += i;
return sum;
}();
static_assert(triangle_10 == 55);Recursive Lambdas
A lambda cannot refer to itself by name. Three approaches exist, with different costs:
// C++23 (deducing this) — zero overhead, cleanest call site
auto factorial = [](this auto self, int n) -> int {
return n <= 1 ? 1 : n * self(n - 1);
};
factorial(10); // 3628800
// C++14: pass self by reference — requires explicit return type, awkward call site
auto fib = [](auto& self, int n) -> int {
return n <= 1 ? n : self(self, n-1) + self(self, n-2);
};
fib(fib, 10); // 55
// std::function — readable but adds type-erasure overhead and heap allocation
std::function<int(int)> fact;
fact = [&](int n) -> int { return n <= 1 ? 1 : n * fact(n-1); };
fact(5); // 120Prefer the deducing-this form in C++23. Use std::function only as a last resort.
mutable, constexpr, and consteval
// mutable: call operator becomes non-const; value-captured copies are modifiable
auto counter = [n = 0]() mutable { return ++n; };
counter(); counter(); counter(); // 1, 2, 3 — no external state
// C++17: lambda is implicitly constexpr if the body qualifies
constexpr auto square = [](int x) { return x * x; };
static_assert(square(7) == 49);
// C++17: explicit constexpr specifier (redundant but self-documenting)
constexpr auto cube = [](int x) constexpr { return x * x * x; };
// C++20: consteval — compile-time only; runtime call is a hard error
auto at_compile_time = []() consteval { return 42; };
constexpr int x = at_compile_time(); // ok
// int y = at_compile_time(); // error: not a constant expression contextStateless Lambdas and Function Pointers
A captureless lambda is implicitly convertible to a matching function pointer. This is the primary bridge to C APIs:
// Implicit conversion
int (*cmp)(int, int) = [](int a, int b) { return a - b; };
// Comparator for a C sorting API (no captures allowed)
qsort(data, n, sizeof(int), [](const void* a, const void* b) {
return *static_cast<const int*>(a) - *static_cast<const int*>(b);
});
// C++23: static lambda — stateless by declaration, always pointer-convertible
auto make_id = [](std::uint32_t seed) static { return seed * 2654435761u; };The static specifier (C++23) makes stateless intent explicit: the compiler rejects any capture.
Capture Edge Cases
// Init capture: expression evaluated once at capture time
auto deferred = [result = compute_expensive()]() { return result * 2; };
// Move-only types in captures — only possible via init capture (C++14)
auto ptr = std::make_unique<Resource>(42);
auto owner = [p = std::move(ptr)]() { return p->value(); };
// ptr is null here; owner holds the unique_ptr
// this in member functions
struct Widget {
int id_;
auto unsafe_handler() {
return [=]() { return id_; }; // C++11/14/17: captures 'this' by pointer!
}
auto safe_copy() {
return [*this]() { return id_; }; // C++17: copies entire *this — safe if Widget dies
}
auto explicit_pointer() {
return [=, this]() { return id_; }; // C++20: pointer capture, but stated explicitly
}
};[=] in a member function captures this by pointer, not by value — a silent dangling-pointer hazard when the lambda outlives the object. Prefer [*this] (C++17) for safety.
Overload Pattern (C++17 / C++20)
Inheriting from multiple lambdas and pulling all their call operators into scope creates an overload set — the standard technique for exhaustive std::visit on std::variant:
// C++17: requires an explicit deduction guide
template<typename... Ts> struct overloaded : Ts... { using Ts::operator()...; };
template<typename... Ts> overloaded(Ts...) -> overloaded<Ts...>; // C++17 only
// C++20: aggregate CTAD makes the deduction guide unnecessary
auto handler = overloaded{
[](int i) { std::println("int: {}", i); },
[](std::string s) { std::println("string: {}", s); },
[](auto x) { std::println("other: {}", x); }, // fallback
};
std::variant<int, std::string, double> v = 3.14;
std::visit(handler, v); // "other: 3.14"The using Ts::operator()... pack expansion (C++17) brings every inherited call operator into the derived class's overload set.
C++23: () Omission with Specifiers
Before C++23, any specifier after the capture list required an explicit () even with no parameters. C++23 removes this restriction:
// C++23: () can be omitted when no parameters are present
auto f = [] mutable { return state++; };
auto g = [] noexcept { return 42; };
auto h = [] -> int { return 0; }; // trailing return type also qualifies
auto s = [] static { return now(); }; // C++23 static lambda
// Pre-C++23: () was mandatory alongside any specifier
auto f2 = []() mutable { return state++; }; // valid in all standardsBest Practices
- Prefer
[*this](C++17) over[=]in member functions whenever the lambda may outlive the object. - Use C++20 template lambdas (
[]<typename T>) rather thanautoparameters when types must be related, constrained, or forwarded cleanly. - Use deducing-this (C++23) for recursive lambdas instead of
std::function— identical ergonomics, zero runtime overhead. - Mark stateless utility lambdas
static(C++23) to communicate intent and prevent accidental capture. - For
std::visitexhaustiveness, the overload pattern with a generalautofallback is cleaner than a chain ofif constexpr.
Common Pitfalls
[=] silently captures this by pointer. In C++14 and C++17, [=] in a class method captures the implicit this pointer, not a copy of *this. The lambda silently dangles if the object is destroyed first. Use [*this] or [=, this] to make the semantics explicit.
mutable does not affect reference captures. [&x]() mutable does nothing useful for x — mutable only removes const from the call operator's implicit object, which matters only for value-captured copies. Reference captures are always modifiable regardless.
constexpr lambda is not consteval. A constexpr lambda can be called at runtime without error. Only consteval (C++20) forces compile-time evaluation; a runtime call to a consteval lambda is a hard compiler error.
Overload pattern ambiguity. If two overloads are equally ranked for an argument type, the call is ambiguous. Add requires clauses or reorder from most-specific to least-specific, ending with a general auto fallback.
std::function recursion overhead. Capturing std::function by reference for recursion adds a virtual dispatch and potential heap allocation on every call. For performance-sensitive recursive lambdas, use the deducing-this form (C++23) or the self-reference trick (C++14).
Lambda capture by move (C++14 init-capture)
Init-capture solves the move-only problem. Before C++14, a lambda could only capture by value (copy) or by reference — move-only types like std::unique_ptr were impossible to capture. Init-capture introduces a named local inside the closure, initialized from an arbitrary expression evaluated once when the lambda is created.
// C++14: move a unique_ptr into the closure
auto resource = std::make_unique<Buffer>(4096);
auto task = [buf = std::move(resource)]() { // C++14
buf->flush();
};
// resource is null here; task exclusively owns buf
// Init-capture with a computed value (evaluated at capture time, not call time)
auto logger = [prefix = std::string("INFO: ")] // C++14
(std::string_view msg) {
std::println("{}{}", prefix, msg);
};
// Move a vector into a lambda for deferred consumption
std::vector<int> big(100'000);
std::iota(big.begin(), big.end(), 0);
auto consumer = [data = std::move(big)]() mutable { // C++14
std::sort(data.begin(), data.end(), std::greater<>{});
return data.front();
};
// big is empty; consumer owns the dataInit-captures are not captures in the traditional sense. The introduced name is a member of the closure object, not a rebinding of an outer variable. Consequently, [x = expr] does not require x to exist in the enclosing scope at all.
// C++14: synthesize a new closure member from a sub-expression
int base = 100;
auto shifted = [offset = base * 2 + 7](int x) { return x + offset; }; // C++14
// 'offset' is a separate closure member; changing 'base' later has no effectMulti-level lambda nesting
Lambdas compose freely, but capture rules apply at each level independently. Each lambda closure captures from its immediately enclosing scope. An inner lambda cannot skip levels to capture a variable from a grandparent scope unless the intermediate lambda also captures it.
// C++14: outer captures x; inner captures outer's 'x' by reference
int x = 10;
auto outer = [x]() { // x captured by value into outer
auto inner = [&x]() { // x captured by reference from outer's closure member
return x * 2;
};
return inner();
};
// outer() == 20
// C++14: each level passes through — default [=] at each level
auto make_adder = [](int base) { // C++14
return [base](int step) {
return [base, step](int n) {
return base + step * n;
};
};
};
auto add_by_3s_from_10 = make_adder(10)(3);
add_by_3s_from_10(4); // 10 + 3*4 = 22Avoid capturing by reference across lifetimes. An inner lambda that escapes the outer lambda (e.g., stored in a container) and holds a reference to the outer lambda's local — which itself is a closure member — creates a dangling reference the moment the outer lambda is destroyed.
// DANGER: inner lambda escapes outer, but holds a ref to outer's local copy
auto make_broken = [](int seed) {
int local = seed * 2;
return [&local]() { return local; }; // dangling: local dies with make_broken's frame
};
auto fn = make_broken(5);
fn(); // undefined behavior
// FIX: capture by value at each nesting level
auto make_safe = [](int seed) {
int local = seed * 2;
return [local]() { return local; }; // C++11: own copy, safe to escape
};Lambdas in constexpr and consteval (C++17/C++20)
Lambdas can participate in constant evaluation from C++17 onward. A lambda whose body satisfies constexpr requirements is implicitly constexpr; applying it in a constant-expression context folds it at compile time with no runtime residue.
// C++17: implicit constexpr — evaluated at compile time inside static_assert
constexpr auto clamp = [](int v, int lo, int hi) { // C++17
return v < lo ? lo : v > hi ? hi : v;
};
static_assert(clamp(-5, 0, 100) == 0);
static_assert(clamp(200, 0, 100) == 100);
// C++17: constexpr IIFE — compile-time population of a lookup table
constexpr auto powers_of_2 = []() constexpr { // C++17
std::array<int, 16> arr{};
for (int i = 0; i < 16; ++i) arr[i] = 1 << i;
return arr;
}();
static_assert(powers_of_2[10] == 1024);
// C++20: consteval — the lambda is an immediate function; every call is a
// constant expression. A call in a non-constant context is a hard error.
auto must_be_constexpr = []() consteval { return 255; }; // C++20
constexpr int c = must_be_constexpr(); // ok
// int r = must_be_constexpr(); // error: call is not a constant expression
// C++20: consteval lambda as a template argument validator
template<int N>
constexpr auto make_mask = []() consteval { // C++20
static_assert(N > 0 && N <= 32, "bit count out of range");
return (1u << N) - 1u;
}();
static_assert(make_mask<8> == 0xFF);consteval vs constexpr lambdas differ in error behaviour. A constexpr lambda silently falls back to runtime if the context is not constant. A consteval lambda turns a non-constant-context call into a compile error — useful for compile-time contract enforcement.
std::function vs template parameter for lambdas
Prefer template parameters over std::function whenever the callable type is fixed at the call site. std::function performs type erasure through a virtual-call indirection and may heap-allocate the closure; a template parameter resolves the call operator at compile time, enabling inlining.
#include <functional>
// std::function — flexible but erases the closure type; call site pays virtual dispatch
void apply_std(std::function<int(int)> fn, int x) { // C++11
std::println("{}", fn(x));
}
// Template parameter — monomorphized per lambda type; fully inlinable
template<std::invocable<int> F> // C++20 concept constraint
void apply_tmpl(F&& fn, int x) { // C++20
std::println("{}", fn(x));
}
// std::function is appropriate when:
// 1. The callable is stored in a data structure with uniform element type.
// 2. The function needs to be assigned different callables at runtime.
std::vector<std::function<void()>> callbacks; // C++11: requires type erasure
callbacks.push_back([]{ std::println("a"); });
callbacks.push_back([]{ std::println("b"); });
// For single-call or pass-through patterns, always prefer the template form:
auto square = [](int x) { return x * x; };
apply_tmpl(square, 7); // inlined, zero overhead
apply_std(square, 7); // wrapped, potential heap allocstd::move_only_function (C++23) fills the gap when type erasure is needed but the callable is move-only (e.g., a lambda capturing a unique_ptr). Unlike std::function, it does not require the stored callable to be copy-constructible.
// C++23: move-only type-erased callable
auto ptr = std::make_unique<int>(42);
std::move_only_function<int()> fn = // C++23
[p = std::move(ptr)]() { return *p; };
std::println("{}", fn()); // 42Lambda overloading and type deduction
The overload pattern turns a set of lambdas into a single polymorphic callable. By inheriting from each lambda and importing all their operator() overloads via a pack expansion, you get overload resolution across heterogeneous signatures without a manual dispatch table.
// C++17: struct template + deduction guide
template<typename... Ts>
struct overloaded : Ts... { using Ts::operator()...; }; // C++17
template<typename... Ts> overloaded(Ts...) -> overloaded<Ts...>; // C++17 guide
// C++20: aggregate CTAD — deduction guide no longer required
template<typename... Ts>
struct overloaded : Ts... { using Ts::operator()...; }; // C++20 (guide optional)
// Usage: exhaustive std::variant visitor
using Payload = std::variant<int, double, std::string>;
auto describe = overloaded{ // C++20
[](int v) { return std::format("int({})", v); },
[](double v) { return std::format("double({})", v); },
[](std::string v) { return std::format("string({})", v); },
};
Payload p = 3.14;
std::println("{}", std::visit(describe, p)); // "double(3.14)"Return-type deduction requires all overloads to agree. When used with std::visit and a non-void return, every branch of the overloaded set must return the same type (or a common type via implicit conversion), otherwise the call is ill-formed.
// Add a catch-all 'auto' overload for future variant members
auto safe_describe = overloaded{ // C++20
[](int v) { return std::format("int({})", v); },
[](double v) { return std::format("double({})", v); },
[](auto v) { return std::format("unknown"); }, // fallback — always last
};Coroutine-like patterns with lambdas
Lambdas can simulate generators and lazy sequences through stateful closures. A mutable lambda retaining internal state between calls is the simplest approximation of a coroutine's suspend/resume cycle, requiring no coroutine support from the compiler.
// C++14: stateful generator — each call produces the next Fibonacci number
auto fib_gen = [a = 0, b = 1]() mutable -> int { // C++14
int result = a;
std::tie(a, b) = std::make_pair(b, a + b);
return result;
};
for (int i = 0; i < 8; ++i)
std::print("{} ", fib_gen()); // 0 1 1 2 3 5 8 13
std::println();
// C++14: factory returning a range-producing lambda for use with algorithms
auto make_counter = [](int start, int step) { // C++14
return [v = start, step]() mutable { int r = v; v += step; return r; };
};
auto by_5 = make_counter(0, 5);
std::generate_n(std::ostream_iterator<int>(std::cout, " "), 5,
by_5); // 0 5 10 15 20
// C++20: lambda-wrapped coroutine — a lambda can be a coroutine body if it contains
// co_yield, co_await, or co_return.
auto coro_lambda = [](int n) -> std::generator<int> { // C++23 std::generator
for (int i = 0; i < n; ++i)
co_yield i * i;
};
for (int sq : coro_lambda(5))
std::print("{} ", sq); // 0 1 4 9 16A mutable closure is not a true coroutine — it does not suspend mid-body; the entire body runs to completion on each call. For genuine suspend/resume semantics (mid-loop yield, awaiting async I/O), use a co_yield/co_await lambda (C++20) or a dedicated coroutine function.
See Also
- Lambda Expressions — captures, basic syntax, closure types
- Deducing This —
this autoparameter, CRTP replacement - Fold Expressions — used inside variadic template lambdas
- if constexpr — compile-time branching inside generic lambdas