C++17 constexpr Lambdas: Implicit constexpr, Captures, and Limits

Key takeaways

Since C++17 a lambda's call operator is constexpr whenever its body allows it. This covers what that means, when captures still work at compile time, and the errors you get when they do not.

Introduction

Before C++17, a lambda could not be used in a constant expression. If you wanted a small compile-time helper, you had to write a named constexpr function. C++17 changed two things:

  1. A lambda’s call operator is implicitly constexpr whenever its body satisfies the rules for a constexpr function.
  2. You can write constexpr explicitly after the parameter list: [](int x) constexpr { ... }.

Closure types also became literal types, so a closure object can be a constexpr variable, and the conversion from a captureless lambda to a function pointer is constexpr too. Together, these let lambdas appear anywhere a constant expression is needed: static_assert, array bounds, template arguments, and constexpr variable initializers.

All examples below compile with GCC 10 in -std=c++17 unless noted, and the error messages are copied from it.

Implicit constexpr

auto add = [](int a, int b) { return a + b; };

static_assert(add(3, 4) == 7);     // evaluated by the compiler

int x = 10, y = 20;
int r = add(x, y);                  // ordinary runtime call

add itself is not declared constexpr. What matters is the call operator, which the compiler marks constexpr because nothing in the body stops it. add is captureless, so the closure object carries no state, and calling it in static_assert works.

“constexpr” means may be evaluated at compile time, not will be. add(x, y) with runtime values is a normal function call (which the optimizer may inline). Even add(3, 4) outside a constant-expression context is not guaranteed to be computed during compilation. Only contexts that need a constant (a static_assert, an array bound, a template argument, a constexpr variable) force compile-time evaluation. If you want runtime calls to be rejected, that is C++20’s consteval.

Explicit constexpr, and why it is only partly a safety net

constexpr auto square = [](int x) constexpr { return x * x; };

int arr[square(4)];                          // array of 16 ints
constexpr int (*fp)(int) = square;           // constexpr conversion to function pointer
static_assert(fp(3) == 9);

There are two separate constexprs here. The one on the variable makes the closure object a constant. The one after the parameter list is a requirement on the call operator. The requirement is useful because it moves some errors from the point of use to the definition:

auto bad2 = []() constexpr { static int count = 0; return count++; };
// error: 'count' declared 'static' in 'constexpr' function

It does not catch everything. A call to a non-constexpr function inside an explicitly constexpr lambda is ill-formed only if no argument could ever make it a constant expression, and compilers are not required to diagnose that. GCC 10 accepts this without complaint:

int nonConstexpr(int x) { return x * 2; }
auto bad1 = [](int x) constexpr { return nonConstexpr(x); };   // no error yet

and only reports it when you actually use it in a constant context. This is the error you will see most often with implicit constexpr lambdas as well:

error: non-constant condition for static assertion
error: call to non-'constexpr' function '<lambda(int)>'
note: '<lambda(int)>' is not usable as a 'constexpr' function because:
error: call to non-'constexpr' function 'int nonConstexpr(int)'

The note: line and the error after it are the useful part. They point to the line inside the lambda that disqualified it. The failure mode I keep hitting is a lambda that was constexpr-usable for months until someone added a logging call or a helper that was not constexpr. Nothing breaks at that line. A static_assert in a different file breaks instead, with an error that names <lambda(int)> rather than anything recognizable. Adding a static_assert next to the lambda that exercises it at compile time keeps the error local.

Captures in constant evaluation

A lambda with captures can be used at compile time only if the closure object itself is a constant expression, which means the captured values must be known at compile time.

int main() {
    constexpr int k = 10;
    constexpr auto addK = [k](int y) { return k + y; };
    static_assert(addK(1) == 11);            // OK: k is a constant

    int n = 3;
    auto f = [n](int y) { return n + y; };
    constexpr int r = f(1);
    // error: the value of 'f' is not usable in a constant expression
    // note: 'f' was not declared 'constexpr'
}

Declaring f as constexpr would not help. The initializer copies n, which is a runtime value.

The most useful capture pattern is returning a lambda from a constexpr function:

constexpr auto makeAdder(int n) {
    return [n](int y) { return n + y; };
}

constexpr auto add5 = makeAdder(5);
static_assert(add5(2) == 7);

During constant evaluation n is a known value, so the closure that captures it is also a constant.

Note that namespace-scope variables should not be captured at all. A lambda can use them directly. GCC only warns about [x] for a global x (capture of variable 'x' with non-automatic storage duration), while Clang rejects it outright. That is one of the portability surprises worth knowing.

Capturing by reference in a constexpr closure is more restrictive. A constant expression cannot hold a reference to a local variable of a function that is not being constant-evaluated. In practice, capture by value for compile-time use.

Useful patterns

Building lookup tables

template<std::size_t N>
constexpr auto makeSquares = []() {
    std::array<int, N> a{};
    for (std::size_t i = 0; i < N; i++) a[i] = static_cast<int>(i * i);
    return a;
};

constexpr auto squares = makeSquares<5>();   // {0, 1, 4, 9, 16}
static_assert(squares[4] == 16);

The table is computed during compilation and stored as initialized data. In C++17, std::array::operator[] is constexpr for reads, and the non-const overload is also constexpr, which is why the loop can write into a. An immediately invoked lambda (constexpr auto t = []{ ... }();) is a common way to initialize a constant with a loop without creating a named helper function.

Compile-time recursion

Lambdas cannot refer to themselves by name, so recursion needs a trick. The usual one is passing the lambda to itself:

constexpr auto fibonacci = [](int n) {
    auto fib = [](int n, auto& self) -> int {
        if (n <= 1) return n;
        return self(n - 1, self) + self(n - 2, self);
    };
    return fib(n, fib);
};
static_assert(fibonacci(10) == 55);

This works, but naive recursion at compile time uses the compiler’s step budget. For deep or wide recursion GCC stops with an error about exceeding -fconstexpr-ops-limit or -fconstexpr-depth. A plain loop is kinder to both the compiler and the reader.

Generic lambdas with if constexpr

constexpr auto processValue = [](auto value) {
    if constexpr (std::is_integral_v<decltype(value)>)
        return value * 2;
    else if constexpr (std::is_floating_point_v<decltype(value)>)
        return value * 1.5;
    else
        return value;
};
static_assert(processValue(10) == 20);
static_assert(processValue(10.0) == 15.0);

Each instantiation keeps only one branch, so the return type differs per argument type (int for 10, double for 10.0). See if constexpr for the rules on discarded branches.

What disqualifies a lambda, by standard version

Construct in the bodyC++17C++20C++23
Loops, local variables, if, recursion via selfOKOKOK
Call to a non-constexpr function (on the evaluated path)Not constantNot constantNot constant
static / thread_local variableError in constexpr functionErrorMay appear if not reached during constant evaluation; static constexpr locals are usable
new / deleteNot constantAllowed if freed before evaluation endsSame as C++20
try blockNot allowedAllowed (throwing still ends evaluation)Allowed
std::cout and other I/ONot constantNot constantNot constant

The C++20 allocation rule is often misread. You can new and delete inside a constant evaluation (GCC 10 accepts it in -std=c++20), but the memory cannot outlive it. You cannot produce a constexpr std::vector that survives into runtime.

Two things C++20 added, and one header trap

Explicit consteval lambdas. C++20 accepts [](int x) consteval { return x * x; }. Every call must then be a constant expression, so sq(runtimeValue) is an error at the call site: the value of 'runtimeValue' is not usable in a constant expression. This is the tool when a lambda exists only to produce compile-time constants and an accidental runtime call would mean a mistake, such as a hash of a string literal that is supposed to be computed during compilation.

Default-constructible captureless lambdas. In C++20 the closure type of a captureless lambda has a default constructor and an assignment operator, and lambdas may appear in unevaluated contexts. That makes decltype([](int a, int b) { return a > b; }) usable directly as a template argument, for example as the comparator type of a std::set, without keeping the lambda object around. In C++17 the same code fails because the closure type has no default constructor.

A lambda in a header. constexpr auto square = [](int x) { return x * x; }; at namespace scope in a header looks harmless, but namespace-scope constexpr variables have internal linkage, so every .cpp file that includes the header gets its own variable, and every copy has a different closure type. That is fine for direct calls. It becomes a subtle One Definition Rule violation when an inline function or template in the same header uses square, because that function then refers to a different entity in each translation unit. Declaring the variable inline constexpr auto square = ...; (C++17) gives all translation units one variable and one closure type. For shared helpers, a named constexpr function avoids the question entirely.

constexpr lambda vs constexpr function

Use a named constexpr function when the helper is part of an interface, needs overloading, is recursive, or is used in several places. Its name appears in error messages instead of <lambda(int)>, which makes those errors much easier to read. Use a lambda when the computation is local: initializing one constant, a predicate passed to a constexpr algorithm, or a helper returned from a constexpr factory with captured configuration. In most code that is the whole difference. The rules on what can run at compile time are the same, because a lambda’s call operator is a constexpr function.