03 · Compile-time Computation¶
C++ has two execution environments. One is the CPU at runtime; the other is the compiler, which can run a surprisingly large subset of the language while building your program. Work moved into the second environment costs nothing at runtime, cannot fail in production, and — this is the underrated part — turns a whole class of bugs into build errors.
This module covers constexpr, consteval, constinit, static_assert,
if constexpr, fold expressions, and the template metaprogramming they mostly
replaced.
constexpr — may run at compile time¶
A constexpr function is one the compiler is allowed to evaluate at build
time, when its arguments are themselves constants. The same function still works
perfectly well at runtime with runtime arguments.
#include <array>
#include <iostream>
#include <string_view>
constexpr unsigned long long factorial(int n) {
return n <= 1 ? 1ULL : n * factorial(n - 1);
}
// C++14 onwards: loops, locals and mutation are all fine in constexpr
constexpr std::array<int, 10> firstPrimes() {
std::array<int, 10> out{};
int found = 0, n = 2;
while (found < 10) {
bool prime = true;
for (int d = 2; d * d <= n; ++d) if (n % d == 0) { prime = false; break; }
if (prime) out[found++] = n;
++n;
}
return out;
}
constexpr std::size_t hashOf(std::string_view s) { // FNV-1a
std::size_t h = 1469598103934665603ULL;
for (char c : s) { h ^= static_cast<unsigned char>(c); h *= 1099511628211ULL; }
return h;
}
int main() {
static_assert(factorial(20) == 2432902008176640000ULL);
constexpr auto primes = firstPrimes(); // computed by the COMPILER
static_assert(primes[9] == 29);
static_assert(hashOf("GET") != hashOf("PUT"));
std::cout << "20! = " << factorial(20) << "\n";
std::cout << "primes:";
for (int p : primes) std::cout << " " << p;
std::cout << "\nhash(\"GET\") = " << hashOf("GET") << "\n";
std::cout << "sizeof(primes) = " << sizeof(primes) << " bytes, in .rodata\n";
}
20! = 2432902008176640000
primes: 2 3 5 7 11 13 17 19 23 29
hash("GET") = 15932647310772317037
sizeof(primes) = 40 bytes, in .rodata
primes is not computed when the program starts. The 40 bytes are baked into
the binary's read-only data by the compiler, exactly as if you had typed the ten
numbers out — but with the sieve as the single source of truth. That is the core
value proposition: generated tables you cannot get wrong.
static_assert is the verification tool for this world. It runs at compile time
and fails the build with your message. Every static_assert above is a test
that can never be skipped, never flake, and costs zero runtime.
consteval — must run at compile time (C++20)¶
constexpr is permission; consteval is a requirement. A consteval function
that cannot be evaluated at compile time is a compile error, not a silent
fallback to runtime.
consteval int square(int n) { return n * n; }
constexpr int s = square(12); // fine: 144, computed at build time
Feed it a runtime value:
error: call to consteval function 'square' is not a constant expression
note: read of non-const variable 'r' is not allowed in a constant expression
Use consteval when running at runtime would be a bug rather than merely slow —
a compile-time string validator, a units-checked literal, a lookup-table
generator that must never appear in the binary as executable code.
constinit (also C++20) is the third member of the family, and it solves a
different problem: it asserts that a static-storage variable is initialized at
compile time, eliminating the static initialization order fiasco without
forcing the variable to be const.
if constexpr — branches that don't exist¶
An ordinary if requires both branches to compile. if constexpr discards the
untaken branch before it is instantiated, which is what makes generic code
over unrelated types possible without overload tricks.
#include <type_traits>
template <typename T>
constexpr const char* kindOf() {
if constexpr (std::is_integral_v<T>) return "integral";
else if constexpr (std::is_floating_point_v<T>) return "floating";
else if constexpr (std::is_pointer_v<T>) return "pointer";
else return "other";
}
This one construct replaced most of the tag-dispatch and enable_if machinery
from Level 3 Module 1.
Fold expressions (C++17)¶
Variadic templates used to need a recursive base case plus a recursive step for every reduction. A fold expression is one line:
template <typename... Ts>
constexpr auto sumAll(Ts... vs) { return (vs + ... + 0); } // binary right fold
template <typename... Ts>
constexpr bool allPositive(Ts... vs) { return ((vs > 0) && ...); } // unary fold
static_assert(sumAll(1, 2, 3, 4) == 10);
static_assert(allPositive(1, 2, 3));
static_assert(!allPositive(1, -2, 3));
The + 0 is the identity element that makes the zero-argument case valid. Any
binary operator works — , is the trick for "do this to every argument":
(std::cout << ... << vs); prints them all.
Compile-time dispatch: hashing strings into a switch¶
switch requires integral constant labels, so it cannot switch on a string —
unless the hash is constexpr:
constexpr std::size_t operator""_fnv(const char* s, std::size_t n) {
return hashOf(std::string_view(s, n));
}
const char* route(std::string_view method) {
switch (hashOf(method)) { // hashed at RUNTIME (one pass)
case "GET"_fnv: return "read handler"; // hashed at COMPILE time
case "POST"_fnv: return "create handler";
case "DELETE"_fnv: return "delete handler";
default: return "405";
}
}
The case labels are integers by the time the compiler emits the jump table, so
this is a hash plus a jump instead of a chain of strcmp calls. It is also the
pattern's honest weakness: two colliding strings would silently route to the
same handler, and you would want a static_assert proving your specific label
set is collision-free.
Template metaprogramming, and why it's shrinking¶
Before constexpr functions, compile-time arithmetic meant recursive class
templates with static constexpr members:
template <int N> struct Fib { static constexpr long value = Fib<N-1>::value + Fib<N-2>::value; };
template <> struct Fib<0> { static constexpr long value = 0; };
template <> struct Fib<1> { static constexpr long value = 1; };
constexpr long fib(int n) { // the modern equivalent
long a = 0, b = 1;
for (int i = 0; i < n; ++i) { long t = a + b; a = b; b = t; }
return a;
}
static_assert(Fib<40>::value == fib(40));
Same answer. The Fib<N> version instantiates 41 distinct class templates,
bloats the compiler's memory, and produces incomprehensible errors; the
constexpr version is a loop anyone can read and is dramatically faster to
compile. Reach for template metaprogramming only for things that operate on
types — std::conditional_t, type lists, trait detection — because types are
the one thing constexpr functions still cannot compute.
Cheat sheet¶
| Construct | Std | Meaning |
|---|---|---|
constexpr function |
11/14 | May be evaluated at compile time; still callable at runtime |
constexpr variable |
11 | Value fixed at compile time; usable as an array size or case label |
consteval function |
20 | Must be evaluated at compile time — runtime call is an error |
constinit variable |
20 | Static-storage variable with guaranteed compile-time init (not const) |
static_assert(cond, "msg") |
11/17 | Compile-time test; message optional since C++17 |
if constexpr (cond) |
17 | Discard the untaken branch before instantiation |
(args + ... + init) |
17 | Fold a parameter pack with any binary operator |
std::is_integral_v<T> etc. |
11/17 | Type traits — the _v suffix gives the value directly |
std::conditional_t<B,T,F> |
11/14 | Pick one of two types at compile time |
std::array in constexpr |
17 | Compile-time computable fixed container |
std::vector/std::string in constexpr |
20 | Allowed, but the allocation must not escape the constant evaluation |
std::is_constant_evaluated() |
20 | Branch on "am I running at compile time right now?" |
Traps¶
constexpr is a permission, not a promise. constexpr int x = f(a);
forces compile-time evaluation because x is constexpr; a bare f(a); with a
runtime a just runs at runtime. If you need the guarantee, assign to a
constexpr variable, use it in a static_assert, or declare the function
consteval.
Recursion depth and step limits are finite. Compilers cap constant
evaluation (-fconstexpr-depth, -fconstexpr-steps, -fconstexpr-ops-limit).
A constexpr loop over ten million iterations will not fail at runtime — it
will fail the build, often with a confusing message. Keep compile-time work
proportionate.
C++20 constexpr allocation cannot escape. You may use std::vector inside
a constant-evaluated function, but every allocation must be freed before the
evaluation ends. Returning a constexpr std::vector to namespace scope does not
compile; return a std::array sized by a separate constexpr counting pass.
Everything constexpr in a header is recompiled everywhere. Heavy
compile-time computation is not free — it is paid by every translation unit that
includes it, on every build. Profile build times (-ftime-trace on Clang)
before scattering elaborate constexpr machinery across headers.
static_assert inside an uninstantiated template never fires. A
static_assert(false) in a template body is ill-formed in some compilers and
silently ignored in others until instantiation. Make the condition depend on the
template parameter — static_assert(sizeof(T) == 0, "...") — or use a
requires clause instead.
std::is_constant_evaluated() is always true inside an if constexpr. It
is a runtime-ish function whose value the compiler knows; putting it in
if constexpr makes the condition trivially true and silently deletes your
runtime path. Use a plain if.
How It Actually Works¶
constexpr functions are compiled twice, conceptually: the compiler
generates the normal runtime machine code for the function as usual, but
when it encounters a call in a constant-evaluated context (initializing
a constexpr variable, an array bound, a template argument), it instead
runs the function using an internal, restricted interpreter built into the
compiler itself — actually executing the loops, branches, and arithmetic at
compile time, subject to rules that forbid anything non-deterministic
(heap allocation before C++20, reading uninitialized memory, calling
non-constexpr functions). If it can't satisfy those rules for the given
arguments, the compiler falls back to leaving it as an ordinary runtime
call — this dual nature is exactly what "usable at compile time or
runtime" means mechanically, not two different functions.
static_assert performs its check using that same constant-evaluation
engine but at a point in compilation before code generation even happens —
a failing static_assert stops compilation outright with a message you
control, which is why it's the standard way to validate template parameters
(e.g. "T must be trivially copyable") entirely at build time, producing zero
runtime code either way.
Template metaprogramming (the older, C++03-era way of doing compile-time
computation via recursive template instantiation — e.g. a Factorial<N>
template that instantiates Factorial<N-1> recursively until a
specialization for Factorial<0> stops it) achieves the same "compute at
compile time" goal but through the instantiation mechanism from Level 3
Module 1 rather than function execution: each recursive instantiation is a
genuinely separate type/function the compiler generates, one per value of
N, which is why deep template recursion can hit real compiler recursion
limits and produce famously long error messages — constexpr functions
replaced most of this because they let you write ordinary, debuggable,
loop-based code instead of encoding arithmetic as type-level recursion.
Exercise¶
Build a compile-time, collision-checked HTTP status table.
- Write
constexpr std::string_view statusText(int code)covering at least 200, 201, 204, 400, 401, 403, 404, 409, 500, 503, returning"unknown"otherwise. Prove it withstatic_assert(statusText(404) == "Not Found");. - Write
constexpr std::array<int, N> knownCodes()listing the codes, and aconstexpr bool allDistinct(...)that verifies no duplicates — thenstatic_assert(allDistinct(knownCodes()));. - Add a
constevalfactoryStatus make(int code)that fails the build for any code not inknownCodes(). Confirm thatmake(499)produces a compile error andmake(404)does not. - Extend the
_fnvrouting example with aconstexprcheck that all your case-label strings hash distinctly, as astatic_assert. Then deliberately add a string that collides (find one by brute force in a small runtime program) and watch the build fail.
Finally, run g++ -ftime-report (or clang++ -ftime-trace) on the result and
note how much build time your compile-time work actually costs. Everything in
this module is a trade, and this is the price side of it.