Compile-time computation
Work the compiler does so the program does not have to.
By the end of this chapter you can
- Write a constexpr function and prove it runs at compile time
- Explain the difference between constexpr, consteval, and constinit
- Use static_assert to check an invariant at compile time
Every C++ program has two execution environments. One is the machine your users run. The other is the compiler, which has to evaluate array bounds, template arguments, and enumerator values before it can emit a single instruction — so it already contains an interpreter for a large subset of the language.
constexpr is how you get at that interpreter. Work you push into it costs
nothing at run time: no instructions, no cache misses, no failure mode. And it
gets checked at build time, which means a bad input is a compiler error rather
than a bug report.
#include <array>
#include <iostream>
constexpr std::array<int, 10> squares() {
std::array<int, 10> table{};
for (int i = 0; i < 10; ++i) table[i] = i * i;
return table;
}
constexpr auto squares_table = squares();
static_assert(squares_table[7] == 49);
int main() {
std::cout << "9 squared is " << squares_table[9] << '\n';
}That loop never runs. Press Assembly and look for it: there is no loop and
no multiply, only ten integers sitting in the read-only data section. The
static_assert is not a runtime test either — it is a question the compiler
answered before the program existed.
constexpr means may, not will
This is the single most misread keyword in modern C++. constexpr on a
function is a permission, not an instruction. It says the function is
allowed to run during constant evaluation, if a context demands a constant. In
any other context it is an ordinary function and runs at run time like all the
rest.
#include <iostream>
#include <type_traits>
constexpr int doubled(int n) {
if (std::is_constant_evaluated()) {
return n * 2; // the compiler took this path
}
std::cout << " doubling " << n << " at run time\n";
return n * 2;
}
int main() {
constexpr int a = doubled(21); // must be constant: compile time
std::cout << "a = " << a << " (nothing was printed above)\n";
int input = 21;
int b = doubled(input); // input is not a constant: run time
std::cout << "b = " << b << '\n';
}std::is_constant_evaluated() (C++20, in <type_traits>) is the only way to
ask which environment you are in. The rule it follows is not “did the optimiser
manage to fold it” — it is a language rule about the context. Initialising a
constexpr int requires a constant, so evaluation happens in the compiler.
Initialising a plain int does not, so it does not, regardless of what -O2
later decides to precompute.
So: if you want to guarantee compile-time evaluation, you have to demand a
constant. The three ways to demand one are constexpr on the variable,
static_assert, and using the value as a template argument or array bound.
What a constexpr function may do
The C++11 rule was famously one return statement. Almost nothing is left of
that. Since C++20 a constexpr function may use loops, mutate local variables,
call virtual functions, and — the big one — allocate memory.
#include <array>
#include <iostream>
#include <vector>
// Sieve of Eratosthenes, run by the compiler.
constexpr std::array<int, 25> primes_below_100() {
std::vector<bool> composite(100, false); // heap allocation, at compile time
std::array<int, 25> found{};
int n = 0;
for (int i = 2; i < 100; ++i) {
if (composite[i]) continue;
found[n++] = i;
for (int j = i * i; j < 100; j += i) composite[j] = true;
}
return found; // the vector dies here
}
constexpr auto primes = primes_below_100();
static_assert(primes[0] == 2);
static_assert(primes[24] == 97);
int main() {
std::cout << "the 25th prime is " << primes[24] << '\n';
}The std::vector really is allocated and freed by the compiler. What you cannot
do is let that allocation escape:
#include <vector>
constexpr std::vector<int> data = {1, 2, 3};
int main() { return data[0]; }error: … is not a constant expression because it refers to a result of
operator new
This is the rule of transient allocation: memory allocated during constant
evaluation must be freed before that evaluation ends. The compiler’s heap does
not exist at run time, so a pointer into it cannot be baked into the program.
Allocate all you like inside the function; return something that owns nothing —
an std::array, an integer, a struct of scalars.
consteval: no run-time option
Sometimes “may” is not enough. A function that validates a literal is worthless
if it can silently fall back to a run-time check — you wanted the error at build
time. consteval (C++20) declares an immediate function: every call must
produce a constant, or the program does not compile.
#include <cstdint>
#include <iostream>
#include <string_view>
consteval std::uint32_t colour(std::string_view hex) {
if (hex.size() != 7 || hex[0] != '#') throw "colour literal must look like #rrggbb";
std::uint32_t value = 0;
for (char c : hex.substr(1)) {
int digit = (c >= '0' && c <= '9') ? c - '0'
: (c >= 'a' && c <= 'f') ? c - 'a' + 10
: (c >= 'A' && c <= 'F') ? c - 'A' + 10
: -1;
if (digit < 0) throw "colour literal contains a non-hex digit";
value = value * 16 + static_cast<std::uint32_t>(digit);
}
return value;
}
int main() {
constexpr std::uint32_t accent = colour("#3b82f6");
std::cout << std::hex << "accent = 0x" << accent << '\n';
}Change the literal to "#3b82fg" and the build fails. The throw is the
mechanism: throwing is not allowed during constant evaluation, so reaching a
throw turns “this is not a constant expression” into a hard error, and the
compiler quotes the line — including, on GCC and Clang, the string you threw.
The exception is never actually thrown, because the code never runs.
The other half of consteval is that a run-time argument is simply not
accepted:
consteval int square(int n) { return n * n; }
int main(int argc, char**) {
return square(argc); // argc is not known until the program starts
}error: ‘argc’ is not a constant expression
That diagnostic is the whole point of the keyword. With constexpr this
compiles and quietly does the work at run time; with consteval you are told.
constinit: about when, not whether
constinit is the odd one out. It says nothing about computing a value in the
compiler — it constrains initialisation order.
A namespace-scope variable is initialised either statically (the value is
baked into the binary before main starts) or dynamically (code runs at
startup). Dynamic initialisation across translation units happens in an
unspecified order, which is the “static initialisation order fiasco”: a global
in one file reads a global in another that has not been initialised yet, and
gets zeroes.
constinit asserts that a variable is initialised statically, so it cannot be
caught by that. It does not make the variable const:
#include <iostream>
constexpr int slots_for(int workers) { return workers * 4; }
constinit int budget = slots_for(16); // computed before the program starts
int main() {
std::cout << "start: " << budget << '\n';
budget -= 10; // legal: constinit is not const
std::cout << "after: " << budget << '\n';
}Give it an initialiser the compiler cannot evaluate and it says so:
#include <cstdlib>
int roll() { return std::rand(); }
constinit int budget = roll();
int main() { return budget; }error: ‘constinit’ variable ‘budget’ does not have a constant initializer
Compare the three at a glance:
| Keyword | Applies to | Guarantees |
|---|---|---|
constexpr |
function | may be evaluated at compile time |
constexpr |
variable | is initialised at compile time, and is const |
consteval |
function | must be evaluated at compile time |
constinit |
variable | is initialised at compile time, and is not const |
The variable forms differ in one letter of intent: constexpr for a value
nobody may change, constinit for a mutable global you want initialised safely.
static_assert is a comment the compiler checks
A comment saying “this struct must stay 8 bytes — we memcpy it onto the wire”
is true on the day it is written and unverified forever after. A
static_assert is the same sentence, enforced.
#include <cstdint>
#include <iostream>
#include <type_traits>
struct Packet {
std::uint32_t id;
std::uint16_t length;
std::uint16_t flags;
};
static_assert(sizeof(Packet) == 8,
"Packet is written to the wire byte-for-byte; changing its size breaks the protocol");
static_assert(std::is_trivially_copyable_v<Packet>,
"Packet is memcpy'd into the send buffer");
static_assert(alignof(Packet) == 4);
int main() {
std::cout << "Packet is " << sizeof(Packet) << " bytes, aligned to "
<< alignof(Packet) << '\n';
}Add a std::string member to Packet and the build stops with your sentence,
at the line that broke it, rather than with a corrupted packet on a customer’s
network six months later. static_assert costs nothing at run time and is not
affected by NDEBUG — unlike assert, it is not a check that can be turned
off, because there is nothing to turn off.