C++ `constexpr` and Compile-Time Programming


  • Description: A note on constexpr (C++11+), consteval (C++20), constinit (C++20), if constexpr (C++17), static_assert, and compile-time evaluation rules
  • My Notion Note ID: K2A-B1-18
  • Created: 2019-08-15
  • Updated: 2026-02-28
  • License: Reuse is very welcome. Please credit Yu Zhang and link back to the original on yuzhang.io

Table of Contents


1. Compile-Time Programming Overview

C++ has three keywords for controlling when a value or function is evaluated: at compile time, at run time, or both.

Keyword Meaning
constexpr May be evaluated at compile time. Can also run at runtime.
consteval Must be evaluated at compile time. Pure compile-time function.
constinit Variable's initializer must run at compile time, but the variable itself isn't const.

The benefit of compile-time evaluation:

  1. Zero runtime cost for values known at compile time.
  2. Catches errors at compile time (out-of-bounds, division by zero, etc.).
  3. Usable in non-type template parameters and array sizes.

2. constexpr

constexpr (C++11) declares that a function or variable can be evaluated at compile time. Whether it actually is depends on the call context.

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

constexpr int a = square(5);     // compile-time: a = 25 (must be const expression)

int n = read_input();
int b = square(n);                // runtime: same function works dynamically too

static_assert(square(5) == 25);   // compile-time check

Rules for constexpr functions

  1. The function body must consist of statements that can be evaluated at compile time. C++14 relaxed this dramatically (loops, multiple statements, mutation of local variables are all allowed).
  2. Arguments must be constant expressions for compile-time evaluation.
  3. The function does not have to be evaluated at compile time — it depends on the call site.

constexpr variables

constexpr int kBufferSize = 256;        // compile-time constant
constexpr double kPi = 3.14159;         // also OK
constexpr int squared = square(7);      // OK: square is constexpr

int n = 5;
constexpr int x = square(n);            // ERROR: n is not a constant expression

const vs constexpr

const int a = read_input();             // a is const, but not necessarily compile-time
constexpr int b = read_input();         // ERROR: read_input() isn't constexpr

const int c = 100;                      // c may or may not be compile-time
constexpr int d = 100;                  // d is definitely compile-time

constexpr is a stronger guarantee than const: it requires compile-time evaluation. Use constexpr whenever the value can be known at compile time.


3. consteval (C++20)

consteval declares an immediate function — one that must be evaluated at compile time. There is no runtime version.

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

constexpr int a = cube(3);           // OK: 27, evaluated at compile time
int n = read_input();
// int b = cube(n);                   // ERROR: must be constant expression

static_assert(cube(4) == 64);

Use consteval when you want to enforce compile-time-only:

  1. Type-safe formatting (std::format uses consteval for compile-time format string checks).
  2. Generating embedded constants (lookup tables, hash codes, IDs).
  3. Refusing runtime use of a function that should never be called at runtime.

The compile-time-or-bust guarantee makes consteval strictly stronger than constexpr.


4. constinit (C++20)

constinit requires that a variable's initializer be evaluated at compile time, but the variable itself isn't necessarily const afterward.

constinit int counter = 0;            // initialized at compile time, mutable at runtime

void increment() {
    counter++;                         // OK: not const
}

The main use case is avoiding the static initialization order fiasco. By forcing compile-time initialization, you guarantee counter is ready before any other TU's runtime initializers fire.

// Without constinit — static init order is unspecified
int slow_init = compute();           // dynamic init: order unspecified across TUs

// With constinit — guaranteed initialized before any dynamic init
constinit int fast_init = 42;        // dynamic-init-free

constinit is purely about initialization; the variable is normal, mutable storage afterward. Compare:

Storage class Initializer After init
const int x = 5; runtime or compile-time read-only
constexpr int x = 5; must be compile-time read-only
constinit int x = 5; must be compile-time mutable

5. if constexpr (C++17)

if constexpr is a compile-time if. The compiler discards the not-taken branch entirely — including its type-check requirements. Essential in templates.

template <typename T>
auto serialize(const T& x) {
    if constexpr (std::is_arithmetic_v<T>) {
        return std::to_string(x);              // only compiled when T is numeric
    } else if constexpr (std::is_same_v<T, std::string>) {
        return x;                              // only compiled when T is string
    } else {
        return std::string{"?"};               // catch-all
    }
}

Without if constexpr, you'd need SFINAE or tag dispatch to express the same logic. The not-taken branches are not type-checked in their original context — the call to std::to_string(x) is fine even when T is std::string (because the branch is discarded).

This is why if constexpr is the modern replacement for enable_if chains.


6. constexpr Containers (C++20)

C++20 made many standard library types constexpr-friendly:

#include <vector>
#include <string>
#include <algorithm>

constexpr std::vector<int> compute() {
    std::vector<int> v = {3, 1, 4, 1, 5, 9, 2, 6};
    std::sort(v.begin(), v.end());
    return v;
}

constexpr auto sorted = compute();      // sorted at compile time!
static_assert(sorted[0] == 1);

Wait — that doesn't actually work directly. std::vector allocates memory, and constexpr allocations must be freed within the same constant expression (no dangling allocations leaving the constant expression).

The correct pattern for compile-time results: use std::array, or compute into a std::vector and copy out:

constexpr std::array<int, 5> first_five_primes = []() {
    std::array<int, 5> a = {2, 3, 5, 7, 11};
    return a;
}();

std::vector, std::string, etc. work in constexpr contexts (loops in constexpr functions can use them) — they just can't survive past the constant expression.


7. static_assert

static_assert(cond, "msg") is a compile-time assertion. The condition must be a constant expression.

static_assert(sizeof(int) >= 4, "int must be at least 4 bytes");

template <typename T>
struct Buffer {
    static_assert(std::is_trivially_copyable_v<T>,
                  "Buffer requires trivially copyable types");
    T data[64];
};

constexpr int compute() {
    static_assert(true, "always passes");    // OK in constexpr functions too
    return 42;
}

Since C++17, the message is optional:

static_assert(sizeof(void*) == 8);   // no message — pretty diagnostic from the expression

static_assert is the standard tool for:

  1. Validating template parameters ("T must be arithmetic").
  2. Asserting platform invariants (size, alignment, endianness).
  3. Pinning down constants that must hold across refactors.