constexpr and compile-time code

C++ · Advanced · 15 min

Moving work and checks to compile time catches bugs earlier and removes runtime cost, and modern C++ lets you do it with normal code.

A constexpr function can be evaluated by the compiler when its arguments are constants, and is an ordinary function otherwise. Modern constexpr allows loops, local variables, if, switch, and since C++20 even virtual calls, try blocks, and std::vector/std::string used temporarily within the evaluation.

  • constexpr variables must be initialized with a constant expression and are implicitly const. They can size arrays, be template arguments and be used in static_assert.
  • static_assert(cond, "msg") fails the build if cond is false, documenting assumptions about sizes, layouts and constants.
  • C++20 consteval makes an immediate function: every call must be evaluated at compile time. Use it when a runtime call would be a mistake, like validating format strings.
  • C++20 constinit guarantees a global is initialized at compile time (avoiding the static initialization order problem) while staying mutable.
  • if constexpr (cond) discards the untaken branch at compile time inside templates, so it doesn't even need to compile for that type.

A nice trick is building lookup tables with an immediately invoked lambda: constexpr auto table = [] { ... return t; }();. The table is computed by the compiler and stored in the binary.

A huge benefit: undefined behaviour isn't allowed in constant evaluation. Overflow, out-of-bounds access and use of uninitialized values become compile errors, so static_assert tests of constexpr functions double as UB checks.

std::is_constant_evaluated() (C++20) and C++23's if consteval let a function choose a different implementation at compile time, for example avoiding intrinsics the compiler can't evaluate.

Compile-time work isn't free: it costs build time, and compilers limit evaluation steps. Use it for tables, validation and configuration, not big computations.

Example

#include <array>
#include <iostream>
#include <string_view>

constexpr unsigned long long fib(int n) {        // usable at compile time AND run time
    unsigned long long a = 0, b = 1;
    for (int i = 0; i < n; ++i) { auto t = a + b; a = b; b = t; }
    return a;
}

consteval std::size_t count_vowels(std::string_view s) {   // compile time only
    std::size_t n = 0;
    for (char c : s) n += std::string_view{"aeiou"}.find(c) != std::string_view::npos;
    return n;
}

constexpr auto table = [] {                      // lookup table built by the compiler
    std::array<unsigned long long, 20> t{};
    for (int i = 0; i < 20; ++i) t[i] = fib(i);
    return t;
}();

static_assert(fib(10) == 55);                    // checked during compilation
constinit int counter = 0;                       // compile-time init, still mutable

int main(int argc, char**) {
    std::cout << table[19] << ' ' << count_vowels("constexpr") << '\n';  // 4181 2
    std::cout << fib(argc + 5) << '\n';          // runtime call: 8 when run with no args
    ++counter;
}

Remember

  • constexpr functions run at compile time when given constants, at run time otherwise
  • consteval forces compile time; constinit forces static initialisation
  • UB is a compile error in constant evaluation, so static_assert tests catch it

Watch out for

  • Assuming a constexpr function call is always computed at compile time — only contexts that require a constant guarantee it.

Check yourself

  1. `constexpr int sq(int x) { return x * x; }` is called as `sq(n)` where n is read from std::cin. What happens?
  2. Which keyword makes every call of a function a compile-time evaluation?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: en.cppreference.com/w/cpp/language/constexpr