C++ / LAMBDAS AND CALLABLE OBJECTS
Generic lambdas and constexpr lambdas
Write lambdas that deduce parameter types like templates, forward arguments correctly, and call them inside constant expressions.
What you will learn
- Read [](auto x) as a class whose operator() is a template instantiated per call site
- Forward out of a generic lambda with std::forward<decltype(x)>(x)
- Use []<typename T>(...) in C++20 to name, reuse, or constrain the deduced type
- Call a captureless lambda in static_assert or as a template argument
Understanding Generic lambdas and constexpr lambdas
A lambda expression always produces an object of a unique unnamed class, and everything about generic lambdas follows from that. Writing auto for a parameter does not make the class a template; it adds an invented template parameter to that class's operator(). So a single closure object can carry many call operators, each instantiated the first time you call it with a new argument type, and deduction at each call site obeys ordinary template argument deduction: bare auto decays and copies, const auto& binds anything without copying, and auto&& is a forwarding reference.
That last form is where generic lambdas bite. In C++14 syntax the deduced type has no name you can spell, so forwarding must be written std::forward<decltype(x)>(x), which recovers the reference category from the declared type of the parameter instead of from a template parameter you introduced yourself. C++20 lets you write the template parameter list explicitly, as in []<typename T>(const std::vector<T>& v), which hands the name back so you can declare a local of type T, match the argument's shape, or force two parameters to deduce to the same type. Packs behave the same way: [](auto&&... args) yields a variadic call operator where sizeof...(args) and fold expressions work unchanged.
Since C++17 a lambda's operator() is implicitly constexpr whenever its body satisfies the rules for a constexpr function, so a captureless lambda already works inside static_assert, as an array bound, or as a template argument with no extra keywords. Two separate questions hide behind the word constexpr here: whether the call operator can run at compile time, and whether the closure object itself is a constant. The second is what constexpr auto f = ... asserts, and it requires every capture to be initialized by a constant expression. Adding the constexpr specifier after the parameter list buys a diagnostic: if the body accidentally does something forbidden in a constant expression, you learn it at the lambda rather than at the distant place that needed a constant.
// compile with -std=c++17
<array>
<iostream>
<string>
<type_traits>
int main() {
// One closure object; operator() is a template.
auto twice = [](auto x) { return x + x; };
std::cout << twice(21) << '\n';
std::cout << twice(1.25) << '\n';
std::cout << twice(std::string("ab")) << '\n';
// Each call site instantiated its own operator() with its own return type.
std::cout << std::boolalpha
<< std::is_same_v<decltype(twice(1)), int> << ' '
<< std::is_same_v<decltype(twice(1.0)), double> << '\n';
// Captureless lambda: operator() is implicitly constexpr,
// and the closure type is a literal type, so the object can be constexpr.
constexpr auto square = [](int n) { return n * n; };
static_assert(square(7) == 49, "square must fold at compile time");
std::array<int, square(3)> table{};
std::cout << table.size() << '\n';
}
A lambda is a class, so auto parameters make its operator() a template and constexpr is a property of that member function rather than of the lambda syntax.
Worked examples
auto&& does not forward on its own
Shows why a generic lambda that passes its parameter along needs std::forward<decltype(x)>(x).
// compile with -std=c++17
<iostream>
<utility>
void probe(int&) { std::cout << "lvalue\n"; }
void probe(int&&) { std::cout << "rvalue\n"; }
int main() {
auto broken = [](auto&& x) { probe(x); };
auto correct = [](auto&& x) { probe(std::forward<decltype(x)>(x)); };
int n = 1;
broken(n);
broken(2);
correct(n);
correct(2);
}
Example explained
Line 1broken(2) prints lvalue because inside the body x is a named variable, and a named variable is an lvalue whatever auto&& deduced.
Line 2decltype(x) is the declared type of the parameter: int& for the lvalue call and int&& for the rvalue call, which is exactly what std::forward needs as its argument.
Line 3There is no T to write here, so decltype is the only way to reach the invented template parameter behind auto&&.
Line 4Getting this wrong is silent for copyable types and only shows up as an extra copy or a failed overload with move-only types.
Naming the deduced type with a template parameter list
Uses the C++20 lambda template parameter list to get the element type of a vector and to require two arguments to share a type.
// compile with -std=c++20
<iostream>
<vector>
int main() {
auto total = []<typename T>(const std::vector<T>& v) {
T acc{};
for (const T& x : v) acc += x;
return acc;
};
std::vector<int> ints{1, 2, 3};
std::vector<double> reals{0.5, 0.25};
std::cout << total(ints) << '\n';
std::cout << total(reals) << '\n';
auto add_same = []<typename T>(T a, T b) { return a + b; };
std::cout << add_same(2, 3) << '\n';
// add_same(2, 3.0); // error: T cannot be both int and double
}
Example explained
Line 1T acc{} is possible only because the parameter list names the element type; with a plain auto v you would need typename std::decay_t<decltype(v)>::value_type.
Line 2total(reals) deduces T as double, so the accumulator starts at 0.0 and the sum stays 0.75 instead of truncating to 0.
Line 3add_same uses one T for both parameters, which rejects mixed-type calls; two separate auto parameters cannot express that constraint at all.
Line 4The parameter list also documents the accepted shape: anything that is not a std::vector fails deduction rather than failing inside the body.
One lambda used at compile time and at run time
Demonstrates that a captureless lambda can serve as a constant expression and as an ordinary function call.
// compile with -std=c++17
<array>
<iostream>
int main() {
constexpr auto digits = [](int n) constexpr {
int count = 1;
while (n >= 10) { n /= 10; ++count; }
return count;
};
static_assert(digits(9) == 1, "");
static_assert(digits(1000) == 4, "");
std::array<char, digits(12345) + 1> buf{};
std::cout << buf.size() << '\n';
int runtime = 987;
std::cout << digits(runtime) << '\n';
}
Example explained
Line 1digits captures nothing, so its closure type is a literal type and the object itself can be declared constexpr.
Line 2static_assert(digits(1000) == 4) runs the while loop during compilation; loops and mutation of a by-value parameter are both legal inside a constexpr function.
Line 3std::array<char, digits(12345) + 1> proves the call really is a constant expression, not merely something the optimizer might fold.
Line 4digits(runtime) compiles to a normal runtime call, because constexpr permits compile-time evaluation without demanding it.
Important notes
constexpr after the parameter list qualifies operator(); C++20's consteval in the same position rejects any call that is not evaluated at compile time, so the two are not interchangeable.
Because every auto parameter is a template parameter, misuse is reported at the call site rather than at the lambda; a requires clause or a static_assert on the deduced type keeps the diagnostic near the mistake.
Common mistakes
Assuming auto&& forwards by itself: [](auto&& x){ sink(x); } always hands sink an lvalue, so move-only types fail to compile and movable ones are copied without warning.
Writing twice<int>(3) to select an instantiation. The closure is an object, not a template, so the compiler reports that twice is not a template; the spelling is twice.operator()<int>(3).
Treating constexpr auto f = ... as a demand for compile-time evaluation. It only makes the closure object a constant, f(user_input) is a plain runtime call, and capturing a non-constant local makes the declaration itself ill-formed.
Try it yourself
Change, predict, then run
Write constexpr auto smaller = [](auto a, auto b){ return a < b ? a : b; }; and prove it at compile time with static_assert(smaller(3, 7) == 3) and static_assert(smaller('b', 'a') == 'a'). Then rewrite it as []<typename T>(T a, T b) and confirm that smaller(1, 2.0) no longer compiles.
Open the C++ workspaceCheck your understanding
auto f = [](auto x){ return x + x; }; is called once with an int, once with a double, and once with a std::string. What exists after compilation?
- Three closure objects sharing one operator()
- One closure object and one operator() that stores its argument in a type-erased slot
- One closure object and three instantiations of a template operator()
- Three closure objects, each with its own operator()
Show answer
An auto parameter adds an invented template parameter to the closure's call operator, not to the closure class, so f remains a single object of a single type while the compiler stamps out one operator() per distinct argument type. Option 2 describes std::function-style type erasure; a generic lambda never defers the choice to run time, which is why each instantiated call inlines just as well as a hand-written overload.