Skip to content

4. constexpr & Compile-Time Programming

What & why

C++ can run ordinary code at compile time. Lookup tables, parsers, hash values, validation of format strings, unit conversions — computed once by the compiler, with zero runtime cost, and with bugs reported as compile errors. Modern constexpr is no longer template trickery: it's regular C++ with loops, if, std::vector, and std::string.

constexpr variables

constexpr int kMaxUsers = 1000;             // a compile-time constant
constexpr double kTau = 2 * 3.141592653589793;
std::array<int, kMaxUsers> slots{};          // usable where a constant expression is required

A constexpr variable must be initialized with a constant expression, and is implicitly const.

constexpr functions

A constexpr function can run at compile time when its arguments are constant expressions — and runs at runtime otherwise:

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

constexpr auto f10 = factorial(10);   // computed by the compiler
static_assert(factorial(5) == 120);   // a compile-time unit test

int n = read_int();
auto fn = factorial(n);               // runs at runtime — same function

Since C++20, constexpr functions may use almost anything: loops, local variables, try/catch (but not actually throw during constant evaluation), virtual functions, std::vector, std::string, dynamic allocation (as long as it's freed before evaluation ends), and std::is_constant_evaluated().

Not allowed during constant evaluation: undefined behavior (it becomes a compile error!), I/O, reinterpret_cast, non-constexpr function calls, static locals (until C++23), and reading non-constant globals.

UB is a compile error in constant evaluation

constexpr int overflow() {
    int x = std::numeric_limits<int>::max();
    return x + 1;
}
constexpr int bad = overflow();   // error: overflow in constant expression

This makes static_assert over constexpr functions a surprisingly strong testing tool.

consteval: must run at compile time

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

constexpr int a = square(4);   // OK
int n = 4;
int b = square(n);             // error: n is not a constant expression

Use consteval for "immediate functions" — things that must never cost runtime, like compile-time validation. This is how std::format checks format strings at compile time.

constinit: no static initialization order fiasco

Globals initialized with dynamic code run in an unspecified order across translation units — the static initialization order fiasco. constinit guarantees a variable is initialized at compile time (but, unlike constexpr, remains mutable):

constinit int g_counter = 0;                    // guaranteed constant-initialized
constinit thread_local int t_depth = 0;

if consteval / std::is_constant_evaluated

Choose different implementations for compile time and runtime:

#include <cmath>

constexpr double my_sqrt(double x) {
    if consteval {                                  // C++23; C++20: if (std::is_constant_evaluated())
        double guess = x / 2;                       // Newton's method: constexpr-friendly
        for (int i = 0; i < 30; ++i) guess = (guess + x / guess) / 2;
        return guess;
    } else {
        return std::sqrt(x);                        // fast hardware instruction at runtime
    }
}

Compile-time lookup tables

#include <array>
#include <cstdint>

constexpr 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 k = 0; k < 8; ++k) {
            c = (c & 1) ? 0xEDB88320u ^ (c >> 1) : c >> 1;
        }
        table[i] = c;
    }
    return table;
}

constexpr auto kCrcTable = make_crc_table();   // baked into the binary

constexpr std::uint32_t crc32(std::string_view data) {
    std::uint32_t crc = 0xFFFFFFFFu;
    for (unsigned char byte : data) {
        crc = kCrcTable[(crc ^ byte) & 0xFF] ^ (crc >> 8);
    }
    return crc ^ 0xFFFFFFFFu;
}

static_assert(crc32("hello") == 0x3610A686u);

Compile-time strings and hashing

constexpr std::size_t fnv1a(std::string_view s) {
    std::size_t h = 14695981039346656037ull;
    for (char c : s) {
        h ^= static_cast<unsigned char>(c);
        h *= 1099511628211ull;
    }
    return h;
}

// switch on strings via compile-time hashes
void dispatch(std::string_view cmd) {
    switch (fnv1a(cmd)) {
        case fnv1a("start"): start(); break;
        case fnv1a("stop"):  stop();  break;
        default: unknown(cmd);
    }
}

(A real implementation should also compare the string, since hashes can collide.)

std::vector and std::string in constexpr

C++20 allows transient allocation — memory allocated during constant evaluation must be freed before it ends. You can't have a constexpr std::vector variable, but you can use one inside a computation and return a fixed-size result:

#include <algorithm>
#include <array>
#include <vector>

constexpr auto primes_below_100() {
    std::vector<int> v;
    for (int n = 2; n < 100; ++n) {
        bool prime = true;
        for (int d = 2; d * d <= n; ++d) {
            if (n % d == 0) { prime = false; break; }
        }
        if (prime) v.push_back(n);
    }
    std::array<int, 25> out{};
    std::ranges::copy(v, out.begin());
    return out;
}

constexpr auto kPrimes = primes_below_100();
static_assert(kPrimes[24] == 97);

Non-type template parameters, revisited

C++20 allows class types (literal "structural" types) and floating-point values as template arguments. A classic trick is a compile-time string literal type:

#include <algorithm>

template <std::size_t N>
struct FixedString {
    char data[N]{};
    constexpr FixedString(const char (&s)[N]) { std::copy_n(s, N, data); }
    constexpr std::string_view view() const { return {data, N - 1}; }
};

template <FixedString Name>
struct Tag {
    static constexpr std::string_view name = Name.view();
};

using Meters = Tag<"meters">;
static_assert(Meters::name == "meters");

Where compile-time pays off — and where it doesn't

Good uses:

  • Lookup tables, constants derived from formulas, hashes of known strings.
  • Validating configuration and format strings at compile time.
  • static_assert tests of pure functions.
  • Type-safe units and dimensions.

Costs:

  • Compile time. Heavy constant evaluation slows builds; compilers also impose step limits (-fconstexpr-steps, -fconstexpr-ops-limit).
  • Debugging. You can't step through constant evaluation in a debugger — test the same function at runtime.
  • Binary size for huge tables.

Common mistakes

  • Assuming constexpr functions always run at compile time. They only do when required (a constexpr variable, template argument, static_assert, array bound) or when the optimizer decides to.
  • const vs constexpr — const int x = read(); is fine; constexpr int x = read(); is an error.
  • Non-literal types in constexpr contexts (e.g., std::map, std::unordered_map aren't constexpr-friendly).
  • Leaking allocations from constant evaluation — "constexpr variable must be initialized by a constant expression" when you try constexpr std::vector v{...}.
  • Static initialization order bugs in globals — use constinit, or function-local statics.

Exercises

  1. Write constexpr bool is_prime(int) and static_assert it on 20 values, including edge cases 0, 1, 2.
  2. Generate a constexpr std::array<double, 360> sine table using a Taylor series (no std::sin, which isn't constexpr until C++26), and check a few values with static_assert and a tolerance.
  3. Implement consteval validation of a hex color literal: color("#ff8800") compiles, color("#ff88") is a compile error.
  4. Implement string switch with fnv1a for a tiny command interpreter, then add a runtime string comparison guard.
  5. Write a FixedString-parameterized Unit<"m"> and Quantity<Unit> type, so that adding meters to seconds fails to compile.
  6. Deliberately cause UB (out-of-bounds read, signed overflow) in a constexpr function called from static_assert. Read the compiler's message.

Recap

  • constexpr variables are compile-time constants; constexpr functions can run at compile time.
  • C++20 constexpr allows loops, std::vector/std::string (transiently), virtual calls, and more.
  • UB during constant evaluation is a compile error — static_assert is a powerful test tool.
  • consteval forces compile time; constinit guarantees constant initialization of mutable globals.
  • if consteval picks compile-time-friendly vs. fast-runtime implementations.
  • Weigh compile-time wins against build times and debuggability.

Next: Concurrency: threads, mutexes & atomics →