CodeRigor

FREE LESSON

Compile-time programming

Move work to compile time with constexpr, consteval and constinit — and let the compiler reject undefined behaviour for you.

C++ can run ordinary code inside the compiler. Used well, that gives you three things: work that costs nothing at run time (tables, parsed constants, hashes), values usable where the language demands constants (array bounds, template arguments, case labels), and — the underrated one — a compiler that refuses to accept undefined behaviour in anything it evaluates.

Four keywords, four different promises:

keyword applies to promise
constexpr variables the value is computed at compile time and the variable is const
constexpr functions the function may run at compile time
consteval functions every call must run at compile time
constinit variables initialised at compile time, but not const

For header definitions, distinguish functions from variables: a constexpr function is implicitly inline, but an ordinary namespace-scope constexpr variable is not. Use inline constexpr for one shared constant object across translation units; a static constexpr data member is already implicitly inline. The ODR section in project building explains the definition and linkage rules.

When each keyword's work happens
constexpr variables, consteval calls and constinit initialisers are finished inside the compiler; their results are bytes in the binary. Ordinary globals still run constructors at start-up, in an order that's unspecified across translation units.
When each keyword's work happens
constexpr variables, consteval calls and constinit initialisers are finished inside the compiler; their results are bytes in the binary. Ordinary globals still run constructors at start-up, in an order that's unspecified across translation units.

1. constexpr functions are permission, not a command

A constexpr function is an ordinary function that the compiler is allowed to evaluate. Whether it does depends on the call site:

C++
#include <print>

constexpr int factorial(int n) {
    int r = 1;
    for (int i = 2; i <= n; ++i) r *= i;
    return r;
}

int main(int argc, char**) {
    constexpr int a = factorial(5);   // must be a constant: evaluated by the compiler
    int arr[factorial(3)]{};          // array bound: a constant expression is required
    int b = factorial(argc + 2);      // argc is run-time: an ordinary call
    std::println("{} {} {}", a, sizeof arr / sizeof arr[0], b);
}
A constexpr function call: compile time or run time?
The call site decides. In a context that requires a constant, the compiler must evaluate it (and diagnose UB); anywhere else it's an ordinary call that the optimiser may or may not fold.
A constexpr function call: compile time or run time?
The call site decides. In a context that requires a constant, the compiler must evaluate it (and diagnose UB); anywhere else it's an ordinary call that the optimiser may or may not fold.

Constant evaluation is required (the language says "manifestly constant-evaluated") in: constexpr and constinit variable initialisers, template arguments, array bounds, case labels, static_assert, if constexpr conditions — and, less obviously, initialisers of const integral variables and of variables with static storage duration, which the compiler tries to constant-initialise first. Everywhere else, a call runs at run time (the optimiser may still fold it, but that's invisible to the language).

2. What you can do at compile time

Almost everything you'd write in a normal function:

  • locals, loops, if/switch, recursion, mutation of locals;
  • calls to other constexpr functions, including most of the standard library (std::array, std::string_view, std::optional, algorithms…);
  • transient allocation (C++20): new/delete, std::vector, std::string — as long as everything allocated is freed before the evaluation ends;
  • virtual calls, dynamic_cast, typeid (C++20);
  • exceptions (C++26): throw, try/catch, as long as nothing escapes the evaluation. GCC 16 implements this; Clang 23 doesn't yet.
C++
#include <algorithm>
#include <print>
#include <stdexcept>
#include <vector>

// A sorted copy of the input's distinct values, as a count: uses a vector
// during constant evaluation and frees it before returning.
constexpr int distinct(std::initializer_list<int> xs) {
    std::vector<int> v(xs);
    std::ranges::sort(v);
    auto [first, last] = std::ranges::unique(v);
    v.erase(first, last);
    return static_cast<int>(v.size());
}

constexpr int digit(char c) {
    if (c < '0' || c > '9') throw std::invalid_argument("not a digit");
    return c - '0';
}

constexpr int digit_or(char c, int fallback) {
    try {
        return digit(c);
    } catch (const std::invalid_argument&) {
        return fallback;
    }
}

static_assert(distinct({3, 1, 3, 2, 1}) == 3);
static_assert(digit_or('7', -1) == 7);
static_assert(digit_or('x', -1) == -1);

int main() { std::println("all checked by the compiler"); }

What you can't do: keep an allocation alive past the evaluation (constexpr std::vector<int> v{1, 2}; at namespace scope is an error — use std::array), reinterpret_cast (use std::bit_cast), read or write a mutable static local, call non-constexpr functions, or let an exception escape.

3. Undefined behaviour is a compile error

During constant evaluation the compiler is required to diagnose undefined behaviour: out-of-bounds access, signed overflow, null dereference, reading uninitialised memory, use after free. That turns static_assert into a sanitizer that runs on every build:

C++
#include <climits>

constexpr int sum_first(const int* p, int n) {
    int total = 0;
    for (int i = 0; i <= n; ++i)   // off by one: reads p[n]
        total += p[i];
    return total;
}

constexpr int data[4]{1, 2, 3, 4};
static_assert(sum_first(data, 4) == 10);   // error: read past the end of data

The same function called at run time compiles fine and silently reads past the array. constexpr doesn't check run-time calls — it only guarantees the compiler won't constant-evaluate UB. A cheap habit: give your pure helper functions a few static_assert tests next to their definition.

4. consteval: compile time only

A consteval (immediate) function must be evaluated at compile time at every call. Use it when a run-time call would be a mistake — building a table, validating a format string, hashing a literal:

C++
#include <print>

consteval unsigned fnv1a(const char* s) {
    unsigned h = 2166136261u;
    for (; *s; ++s) h = (h ^ static_cast<unsigned char>(*s)) * 16777619u;
    return h;
}

int main() {
    constexpr unsigned id = fnv1a("player.jump");   // computed by the compiler
    unsigned copy = fnv1a("player.jump");            // still compile time; stored at run time
    std::println("{:#x} {}", id, id == copy);
}

fnv1a(argv[0]) would not compile. Inside a plain constexpr function, a parameter is never a constant expression, so it can't be passed to a consteval function either — unless you're inside if consteval:

C++
#include <print>

consteval int square_ct(int x) { return x * x; }

constexpr int square(int x) {
    if consteval {
        return square_ct(x);   // only during constant evaluation: x is a constant here
    } else {
        return x * x;          // run time
    }
}

int main(int argc, char**) {
    constexpr int a = square(12);
    std::println("{} {}", a, square(argc));
}

The std::is_constant_evaluated() trap

std::is_constant_evaluated() (C++20) asks the same question as if consteval, but as an ordinary function call. Two traps:

  • if constexpr (std::is_constant_evaluated()) is always true — the condition of if constexpr is itself constant-evaluated. Use if consteval.
  • It reports manifest constant evaluation, not "will the optimiser fold this". int b = f(); gets the run-time branch; static int d = f(); and const int c = f(); get the compile-time branch, because the compiler must try to constant-initialise them.

5. constinit and the initialisation-order fiasco

Globals in different translation units are dynamically initialised in an unspecified order, so one global's constructor can use another before it has been constructed. constinit demands constant initialisation (a compile error otherwise) — and constant-initialised objects exist before any dynamic initialisation runs:

The initialisation-order fiasco, and why constinit avoids it
logger and config live in different files. If the linker happens to run logger's constructor first, it reads config before config's constructor has run. A constinit config is already initialised when the program is loaded.
The initialisation-order fiasco, and why constinit avoids it
logger and config live in different files. If the linker happens to run logger's constructor first, it reads config before config's constructor has run. A constinit config is already initialised when the program is loaded.
C++
#include <array>
#include <print>

struct Config {
    int verbosity;
    std::array<int, 3> limits;
};

constinit Config config{1, {10, 20, 30}};   // in the binary's data section, ready at load
constinit thread_local int depth = 0;       // also works for thread_local

int main() {
    config.verbosity = 2;   // unlike constexpr, constinit is not const
    ++depth;
    std::println("{} {} {}", config.verbosity, config.limits[2], depth);
}

6. Worked example: a compile-time CRC table and string switch

Three pieces of compile-time machinery working together:

C++
#include <algorithm>
#include <array>
#include <cstddef>
#include <cstdint>
#include <print>
#include <string_view>

// 1. A table only the compiler may build.
consteval std::array<std::uint32_t, 256> make_crc_table() {
    std::array<std::uint32_t, 256> table{};
    for (std::uint32_t i = 0; i < 256; ++i) {
        std::uint32_t c = i;
        for (int bit = 0; bit < 8; ++bit)
            c = (c & 1) ? (c >> 1) ^ 0xEDB88320u : c >> 1;
        table[i] = c;
    }
    return table;
}
inline constexpr auto crc_table = make_crc_table();   // 1 KiB of read-only data, no code

// 2. A function usable at both times.
constexpr std::uint32_t crc32(std::string_view s) {
    std::uint32_t crc = 0xFFFFFFFFu;
    for (char ch : s) crc = crc_table[(crc ^ static_cast<unsigned char>(ch)) & 0xFF] ^ (crc >> 8);
    return crc ^ 0xFFFFFFFFu;
}
static_assert(crc32("123456789") == 0xCBF43926u);   // the standard check value

// 3. A string literal as a template argument (a "structural" class type).
template <std::size_t N>
struct fixed_string {
    char chars[N]{};
    constexpr fixed_string(const char (&s)[N]) { std::copy_n(s, N, chars); }
    constexpr std::string_view view() const { return {chars, N - 1}; }
};

template <fixed_string S>
consteval std::uint32_t crc_of() { return crc32(S.view()); }

int command(std::string_view name) {
    switch (crc32(name)) {           // run time: hash the input once
        case crc_of<"start">(): return 1;   // compile time: constants
        case crc_of<"stop">(): return 2;
        default: return 0;
    }
}

int main() { std::println("{} {} {}", command("start"), command("stop"), command("pause")); }
crc_table: 256 entries computed by the compiler
make_crc_table is consteval, so no code for it exists in the binary — only these 1024 bytes in .rodata. crc_table[1] is the CRC step for byte value 1.
crc_table: 256 entries computed by the compiler
make_crc_table is consteval, so no code for it exists in the binary — only these 1024 bytes in .rodata. crc_table[1] is the CRC step for byte value 1.

Check it in the compiler explorer: make_crc_table produces no code at all — only a 256-entry table in .rodata — and each case is a plain integer compare. (Real code would still compare the strings after a hash match, since different strings can share a hash.)

Faded example — your turn

Build a 256-entry table of bit counts at compile time, and use it in a constexpr popcount for std::uint32_t (four table lookups). Which keyword goes on the builder, and how do you prove the result with no run-time test?

C++
/* ??? */ std::array<std::uint8_t, 256> make_bits_table() {
    std::array<std::uint8_t, 256> t{};
    for (int i = 1; i < 256; ++i) t[i] = /* ??? */;
    return t;
}
inline constexpr auto bits = make_bits_table();

constexpr int popcount(std::uint32_t x) { /* four lookups */ }
static_assert(/* ??? */);
Answer
The bit-count table builds on itself: t[i] = t[i >> 1] + (i & 1)
Dropping the lowest bit of i gives a smaller index whose count is already known; add 1 if the dropped bit was set. Hover 13: 13 >> 1 = 6, and t[6] = 2, plus 1.
The bit-count table builds on itself: t[i] = t[i >> 1] + (i & 1)
Dropping the lowest bit of i gives a smaller index whose count is already known; add 1 if the dropped bit was set. Hover 13: 13 >> 1 = 6, and t[6] = 2, plus 1.

consteval on the builder; t[i] = t[i >> 1] + (i & 1) (each entry reuses a smaller one); popcount sums bits[x & 0xFF] + bits[(x >> 8) & 0xFF] + …; and static_assert(popcount(0xF0F0'0001u) == 9) proves it at compile time. std::popcount from <bit> is the production answer.

Summary

  • constexpr function: may run at compile time; it does where a constant is required. Run-time calls are ordinary — and unchecked.
  • consteval: must run at compile time. Bridge from constexpr code with if consteval, not std::is_constant_evaluated() inside if constexpr.
  • constinit: constant-initialised but mutable — the cure for initialisation-order bugs.
  • Allowed at compile time: loops, transient allocation, virtual calls, and in C++26 exceptions. Not allowed: escaping allocations, reinterpret_cast, mutable statics.
  • UB during constant evaluation is a compile error — use static_assert tests as a free sanitizer.

Watch & read

Free lectures, talks and readings that cover this skill from another angle. All resources →

const vs constexpr vs consteval vs constinit, and what each guarantees.

Pushing whole data structures and a JSON parser to compile time — what constexpr can really do.

The 'constexpr two-step': building data at compile time then shrinking it for run time.

static, inline, const and constexpr — which keyword does what at namespace, class and function scope.

consteval in four minutes.

Every way to force compile-time evaluation (constexpr variables, consteval, templates…) and which actually guarantee it.

C++23 `if consteval`: branching on whether you are in a constant evaluation, and why it beats is_constant_evaluated.

Constant evaluation rejects UB: running code in constexpr catches out-of-bounds and lifetime errors at compile time.