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).
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