C++ Generic Lambdas — auto Parameters and Template Lambdas

Key takeaways

This guide covers plain vs generic lambdas, how C++14 auto parameters turn operator() into a function template, C++20 template lambdas, practical STL patterns, type deduction rules, and performance considerations in one place.

What is a generic lambda?

A generic lambda is a C++14 feature: you put auto on a parameter so the same body can be reused for several types. Internally, the closure type’s operator() is a function template.

auto add = [](auto a, auto b) { return a + b; };
int x = add(1, 2);           // int
double y = add(1.5, 2.5);    // double

Why use it?

  • Less duplication: you do not need separate lambdas for int and double.
  • STL-friendly: easy to pass one comparison or predicate into std::sort, std::find_if, and similar.
  • C++20 onward: pairs naturally with the more explicit template lambda syntax.

Reading Lambda basics and auto type deduction first will make this article easier to follow.


Plain lambda vs generic lambda

Plain lambda (non-template operator())

If parameter types are fixed, the closure’s operator() is a single ordinary member function.

auto cmp = [](int a, int b) { return a < b; };
// Conceptually: struct __lambda { bool operator()(int a, int b) const; };

It fits one type; to use double or std::string you would need another lambda.

Generic lambda (auto parameters)

Using auto (or auto&, const auto&, etc.) on a parameter makes operator() a function template.

auto cmp = [](auto a, auto b) { return a < b; };
// Conceptually: struct __lambda {
//   template<typename T, typename U>
//   auto operator()(T a, U b) const { return a < b; }
// };

The same lambda object can sort and compare int, double, and user-defined types that define operator<.

AspectPlain lambdaGeneric lambda
ParametersConcrete typesauto / decltype(auto), etc.
operator()Non-templateFunction template
InstancesOneSpecializations as needed per call
ReadabilityClear when simpleSignals “multiple types allowed”

How auto parameters work

The C++14 rules boil down to this:

  1. For each auto parameter in a generic lambda, a distinct template type parameter is introduced.
  2. If the lambda’s return type is not specified, it is deduced from the body’s return statements using the rules for a plain auto return type (same as for ordinary lambdas): references and top-level const are dropped and arrays decay. It is not decltype(auto); you have to ask for that explicitly with -> decltype(auto).
[](auto x) { return x * 2; }   // A template instance per type of x
[](auto& x) { return x; }       // Parameter binds by reference, but the auto return type returns a copy

The second line is a common source of confusion. Taking auto& avoids copying the argument on the way in, but because the return type is deduced as plain auto, return x; produces a copy of the referenced object on the way out. If the intent is to hand back a reference to the caller’s object, write -> auto& or -> decltype(auto) (the latter returns T& here because x is an lvalue of reference type). Returning a reference is only safe if the referred-to object outlives the call; returning a reference to a by-value parameter compiles and dangles.

Caveat: plain auto is by value. For large objects, prefer const auto& or auto&& (forwarding-reference-like deduction). That lines up with perfect forwarding.

// Runnable example
std::vector<std::string> v = {"a", "b"};
std::for_each(v.begin(), v.end(), [](const auto& s) {
    std::cout << s << '\n';  // Avoid unnecessary copies
});

The compiler’s closure type is implementation-specific, but the key point is that operator() is a template. Template instantiations appear per call pattern, and like ordinary function templates they can be duplicated across TUs that are not merged—same trade-offs as templates generally.

Because only operator() is a template, the closure object itself is one ordinary type. That is why a single generic lambda can be stored in a variable and called with different argument types, and also why it cannot be stored in a std::function without committing to one signature: std::function<bool(int, int)> f = cmp; works, but the resulting f only accepts ints. The template-ness is lost at the point of type erasure. A related consequence is that a generic lambda without captures converts to a function pointer only once you name the exact pointer type (bool (*p)(int, int) = cmp;), which instantiates the template for those types.

Constness also carries over from ordinary lambdas: the generated operator() is const unless the lambda is declared mutable, so a generic lambda cannot modify its by-value captures by default, regardless of how its parameters are declared.


C++20 template lambdas

C++20 lets you express the same idea with explicit template syntax.

auto f = []<typename T>(T x) { return x + x; };           // One type parameter
auto g = []<class T, class U>(T a, U b) { return a < b; };

Differences and benefits

  • Named type parameters: T is visible in the signature.
  • Easier to attach requires constraints (C++20 concepts) directly on the lambda.
auto clamp_positive = []<typename T>(T x) requires std::is_arithmetic_v<T> {
    return x > T{0} ? x : T{0};
};

The T{0} in this example is only possible because the type has a name. With a plain auto x, you would have to write decltype(x){0} instead, which works but reads poorly, and gets worse once references are involved (std::remove_cvref_t<decltype(x)>). Named parameters are also the only way to require that two arguments share a type ([]<typename T>(T a, T b)) or to deduce the element type of a container ([]<typename T>(const std::vector<T>& v)), which is impossible to express with auto alone. C++20 also lets you constrain an auto parameter directly with a concept, [](std::integral auto x) { ... }, which covers the simplest cases without template syntax.

With auto parameters only, constraining the anonymous template parameters is awkward; template lambdas pair well with requires. For syntax details see C++ template lambdas and generic lambdas and error messages.


Practical use: STL algorithms

Sorting and comparison

std::vector<std::pair<int, std::string>> items = {{2, "b"}, {1, "a"}};
std::sort(items.begin(), items.end(),
    [](const auto& a, const auto& b) { return a.first < b.first; });

You do not need to spell out the pair type for a sort on the first element. The const auto& parameters matter here: std::sort calls the comparator O(n log n) times, and taking the pairs by value would copy the std::string inside each pair on every comparison. That is exactly the kind of cost that does not show up in a small test but becomes visible when sorting a few hundred thousand records.

One thing a generic comparator does not protect you from is an invalid ordering. std::sort requires a strict weak ordering; a comparator written as a.first <= b.first compiles fine for any type, but it breaks that requirement and can make std::sort read past the end of the range with some standard library implementations. The generic form makes such a comparator easy to reuse across many containers, so a bug in it spreads further.

auto it = std::find_if(vec.begin(), vec.end(),
    [](const auto& e) { return e.id == target_id; });

e is deduced as the container’s value_type (more precisely, as whatever the iterator dereferences to, with const auto& binding to it). The lambda does not capture anything here, which means target_id must be a variable with static storage duration or a constant; if it is a local variable, the lambda needs [target_id] or [&] in its capture list, and forgetting that produces the error 'target_id' is not captured, which is unrelated to the lambda being generic.

Transform and accumulate

std::transform(a.begin(), a.end(), out.begin(),
    [](auto x) { return std::abs(x); });
int sum = std::accumulate(v.begin(), v.end(), 0,
    [](auto acc, const auto& x) { return acc + x.size(); });

The accumulate call has a quiet type trap that generic lambdas make easy to miss. The accumulator type is decided by the initial value, 0, which is an int, so acc is deduced as int. acc + x.size() is computed as size_t, then converted back to int for the next step, and a large total silently wraps or triggers a narrowing warning. Writing the initial value as std::size_t{0} (or 0uz in C++23) fixes the type for the whole fold. Similarly, std::abs inside the transform lambda picks its overload from the deduced type of x; for unsigned types there is no sensible abs, and some compilers reject the call as ambiguous.

std::visit and variants

std::visit([](const auto& x) { std::cout << x; }, my_variant);

One lambda body can handle each alternative of std::variant (a distinct operator() instantiation per alternative).

This is where generic lambdas are hardest to replace. std::visit needs a callable that accepts every alternative, and without generic lambdas you would write a separate struct with one overloaded operator() per type. When different alternatives need different handling, the usual idioms are either if constexpr (std::is_same_v<std::decay_t<decltype(x)>, int>) inside one generic lambda, or the “overloaded” helper (template<class... Ts> struct overloaded : Ts... { using Ts::operator()...; };) that combines several lambdas into one visitor. A useful property of the generic form is that the body must compile for every alternative: if you add a type to the variant that cannot be streamed to std::cout, the build fails at the visit call rather than at run time.


Type deduction rules

  1. Parameter auto: Same family as auto in a function template parameter — template arguments are deduced from the arguments at the call site.
  2. auto& / const auto&: Express intent to preserve lvalue-ness and constness. const auto& also binds to temporaries and is widely applicable.
  3. auto&&: Forwarding reference rules apply, so you can bind lvalues and rvalues efficiently (typical for “perfect forwarding” inside a generic lambda).
  4. Return type: If omitted, all return paths must deduce to a compatible type. Different types in branches (e.g. int vs double) cause a deduction conflict.
// Error: two return statements deduce int and double
// [](auto x) { if (x > 0) return 0; return 1.0; }  // inconsistent deduction for auto return type

Note that a single conditional expression such as return cond ? 0 : 1.0; is not a clash: the conditional operator itself converts both operands to their common type (double) before the return type is deduced. The error only appears with separate return statements whose types differ, and the message is typically inconsistent deduction for auto return type: 'int' and then 'double'. With generic lambdas this can depend on the argument type, so the same lambda can compile for one call and fail for another: [](auto x) { if (x) return x; return 0; } is fine when called with an int and fails with a long.

When needed, give the lambda an explicit return type:

[](auto x) -> double { return x * 1.0; }

Performance considerations

  • Overhead: A lambda call is like a small inlineable function. Generics are still not virtual (standard lambdas are not polymorphic through vtables).
  • Code size: Each distinct argument-type combination can produce another template instance. Hundreds of distinct signatures can bloat the binary; sometimes a non-template function or a single-type lambda is better.
  • Capture: [=], [&], etc.—captured state affects closure size whether or not the lambda is generic.
  • Optimization: Compilers often inline comparison lambdas for std::sort aggressively. Do not assume “generic means slow”—measure before optimizing.

decltype and generic lambdas

For a parameter named x, decltype(x) gives the declared type of the parameter after deduction. That is handy when you need a local “same type as x” in the body. Be precise about what that type is: for a by-value auto x, it is the deduced, decayed type, so calling with a const int& argument gives int, and calling with a string literal gives const char*. For auto&& x, decltype(x) is T& for lvalue arguments and T&& for rvalues, which is exactly the information std::forward<decltype(x)>(x) relies on.

// Variable declaration and initialization
auto f = [](auto x) -> decltype(x) {
    decltype(x) copy = x;
    return copy;
};

Together with the decltype guide, this also clarifies differences from lambdas that use decltype(auto) as the return type.


constexpr generic lambdas (C++17)

If a lambda is constexpr and the body satisfies constexpr requirements, it can be evaluated at compile time. Generic lambdas behave the same: when arguments are literals (or otherwise constexpr-friendly), the corresponding template instance can be evaluated as a constexpr call.

constexpr auto sq = [](auto x) { return x * x; };
static_assert(sq(3) == 9);

Using non-literal-friendly types such as std::string as arguments usually forces runtime evaluation. See constexpr lambdas.


Common mistakes

  1. Only auto by value but you meant to mutate elements: You modify a copy; container elements stay unchanged. Use auto& or auto* as appropriate.
  2. Two different auto parameters: auto a, auto b are two independent template parameters. To force the same type, a C++20 template lambda with template<typename T> ... (T a, T b) is clearer.
  3. Recursion: A nameless lambda is awkward to call from inside itself. Consider std::function, a Y combinator pattern, or a named function object. Generic lambdas make one trick possible in C++14: pass the lambda to itself as an auto parameter, auto fib = [](auto self, int n) -> int { return n < 2 ? n : self(self, n - 1) + self(self, n - 2); }; fib(fib, 10);. C++23 cleans this up with an explicit object parameter, [](this auto self, int n) -> int { ... self(n - 1) ... }. Both avoid the heap allocation and indirect call that wrapping the lambda in std::function costs.
  4. Error messages from deep inside the body: because the body is only checked when it is instantiated, calling a generic lambda with an unsupported type produces an error that points inside the lambda, often several template layers deep inside an STL algorithm. When a generic lambda is part of an API, constrain it (with a concept on the auto parameter or a requires clause) so misuse is reported at the call site.
  5. Capturing by reference in a lambda that outlives the scope: this is not specific to generic lambdas, but they are often written as reusable helpers and stored for later. A [&] capture of a local variable in a lambda returned from a function or handed to a thread dangles as soon as the function returns.

When I first started using generic lambdas heavily, the problem I ran into most was the first one in a subtler form: for_each(v.begin(), v.end(), [](auto x) { x.normalize(); }) compiled, ran, and left every element untouched. Nothing warns about it, because modifying a by-value parameter is perfectly legal. Since then I default to const auto& for read-only lambdas and auto& when mutation is the point, so the choice is visible in the signature.


When to switch from auto parameters to a template lambda

auto parameters are enough as long as the body never needs to name the type. Switch to the C++20 form []<typename T>(...) when it does: to require that two arguments have the same type ([]<typename T>(T a, T b), which (auto a, auto b) cannot express), to accept only a std::vector<T> and use T inside, or to construct or static_assert on the type without writing std::decay_t<decltype(x)>.

For forwarding, the C++14 spelling is [](auto&& x) { f(std::forward<decltype(x)>(x)); }, and it works, but it is easy to write std::forward<auto> or forget the && and silently copy. With a template lambda, []<typename T>(T&& x) { f(std::forward<T>(x)); } reads like any other forwarding function.

See also: Lambda capture, constexpr lambdas, decltype.


More reading


Frequently Asked Questions (FAQ)

Q. How do I perfectly forward arguments inside a generic lambda?

A. Declare the parameter as auto&&, which acts as a forwarding reference, and forward it with std::forward<decltype(x)>(x), because a C++14 generic lambda has no named template parameter to put in std::forward<T>. In C++20 you can write a template lambda instead, []<typename T>(T&& x) { f(std::forward<T>(x)); }, which reads like an ordinary function template.