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_asserttests 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
constexprfunctions always run at compile time. They only do when required (aconstexprvariable, template argument,static_assert, array bound) or when the optimizer decides to. constvsconstexpr—const int x = read();is fine;constexpr int x = read();is an error.- Non-literal types in
constexprcontexts (e.g.,std::map,std::unordered_maparen'tconstexpr-friendly). - Leaking allocations from constant evaluation — "
constexprvariable must be initialized by a constant expression" when you tryconstexpr std::vector v{...}. - Static initialization order bugs in globals — use
constinit, or function-local statics.
Exercises¶
- Write
constexpr bool is_prime(int)andstatic_assertit on 20 values, including edge cases 0, 1, 2. - Generate a
constexpr std::array<double, 360>sine table using a Taylor series (nostd::sin, which isn'tconstexpruntil C++26), and check a few values withstatic_assertand a tolerance. - Implement
constevalvalidation of a hex color literal:color("#ff8800")compiles,color("#ff88")is a compile error. - Implement string
switchwithfnv1afor a tiny command interpreter, then add a runtime string comparison guard. - Write a
FixedString-parameterizedUnit<"m">andQuantity<Unit>type, so that adding meters to seconds fails to compile. - Deliberately cause UB (out-of-bounds read, signed overflow) in a
constexprfunction called fromstatic_assert. Read the compiler's message.
Recap¶
constexprvariables are compile-time constants;constexprfunctions can run at compile time.- C++20
constexprallows loops,std::vector/std::string(transiently), virtual calls, and more. - UB during constant evaluation is a compile error —
static_assertis a powerful test tool. constevalforces compile time;constinitguarantees constant initialization of mutable globals.if constevalpicks compile-time-friendly vs. fast-runtime implementations.- Weigh compile-time wins against build times and debuggability.