11. const, constexpr and compile-time programming¶
Time: ~2 hours Prerequisites: 08
1. In plain words¶
C has two ways to make a constant: #define SIZE 256 (textual, untyped, invisible to the debugger)
and const int size = 256; (typed, but in C not usable as an array bound at file scope and
sometimes not folded). C++ fixes this with constexpr: a typed constant that the compiler must be
able to evaluate at compile time, and which you can use anywhere a literal can go. More importantly,
constexpr applies to functions: the same code that runs at run time can be executed by the
compiler to build lookup tables, validate configuration, or check invariants before the program ever
starts.
Analogy: const is a sign that says "do not touch". constexpr is a vending machine built into the
compiler: feed it constants and it hands you the answer baked into the binary.
2. The real thing¶
2.1 const: immutability¶
const means "cannot be modified through this name". It is about access, not about when the value is
known: const int n = std::rand(); is fine. Uses:
- Variables:
const int max_retries = 5;(internal linkage by default at namespace scope in C++). - Pointers and references:
const T*(pointee const),T* const(pointer const),const T&. - Member functions:
long long balance() const;promises not to modify*this(module 01). mutablemembers may be modified insideconstmember functions (caches, mutexes).const_castremoves const; modifying an object that was defined const through it is undefined behaviour.
2.2 constexpr variables¶
constexpr int seconds_per_day = 60 * 60 * 24; // must be a constant expression; implies const
constexpr double pi = 3.141592653589793;
int table[seconds_per_day / 3600]; // usable as an array bound, template argument, case label
The initialiser is evaluated at compile time or the program does not compile. constexpr on a
variable also implies const and (since C++17) inline for static data members.
2.3 constexpr functions¶
constexpr std::uint32_t crc32_entry(std::uint32_t i) {
std::uint32_t c = i;
for (int k = 0; k < 8; ++k) c = (c & 1u) ? (0xEDB88320u ^ (c >> 1)) : (c >> 1);
return c;
}
static_assert(crc32_entry(1) == 0x77073096u); // evaluated by the compiler
std::uint32_t x = crc32_entry(user_input); // same function, evaluated at run time
A constexpr function can be evaluated at compile time when all its arguments are constants and
the context requires a constant (array bound, static_assert, constexpr variable, template
argument). Otherwise it is an ordinary function. Since C++14 the body may contain loops, local
variables, if, multiple statements. Not allowed (C++17): try, goto, static locals, uninitialised
variables, calls to non-constexpr functions, dynamic allocation (C++20 relaxes some of these),
reinterpret_cast, and undefined behaviour (the compiler must diagnose UB during constant evaluation,
which makes constexpr a UB detector).
2.4 Literal types¶
A type usable in constant expressions: scalars, references, arrays of literal types, and classes with a
constexpr constructor (or an aggregate), a trivial destructor, and literal-type members.
std::array, std::pair, std::string_view, std::optional of a literal type are literal types;
std::string and std::vector are not in C++17 (they allocate).
struct Version {
int major, minor, patch;
constexpr bool operator<(const Version& o) const { /* ... */ }
constexpr int packed() const { return major * 10000 + minor * 100 + patch; }
};
constexpr Version kMinSupported{1, 4, 0};
static_assert(kMinSupported < Version{2, 0, 0});
2.5 static_assert¶
A compile-time assertion with a message. Use it to pin down ABI assumptions (sizeof(Header) == 12),
type properties (std::is_trivially_copyable_v<T>), and the results of constexpr computations
(unit tests that run inside the compiler). A failing static_assert is a build error, which is the
cheapest possible time to find a bug.
2.6 Inline variables (C++17)¶
Before C++17, a non-const or class-type variable defined in a header produced "multiple definition"
link errors, so people wrote extern declarations plus one .cpp definition, or function-local
statics. inline on a variable means "every translation unit may define this; the linker keeps one".
static constexpr data members are implicitly inline, so they no longer need an out-of-class definition.
2.7 Compile-time tables¶
The pattern: a constexpr function returns a std::array filled by a loop; assign it to an
inline constexpr variable; use it. The compiler emits the finished table into .rodata. Zero
start-up cost, no "init on first use" race, and static_assert can verify entries. CRC tables, sine
tables, enum-to-string tables, state-machine transition tables, protocol opcode tables all fit.
2.8 if constexpr and constexpr lambdas¶
if constexpr (module 08) selects code at compile time. Lambdas are implicitly constexpr in C++17
when they can be, so constexpr auto sq = [](int x) { return x * x; }; static_assert(sq(3) == 9); works.
3. Code¶
code/m11_constexpr_tables.cpp builds a CRC-32 table at compile time and verifies it:
constexpr std::array<std::uint32_t, 256> make_crc_table() {
std::array<std::uint32_t, 256> t{};
for (std::uint32_t i = 0; i < 256; ++i) t[i] = crc32_table_entry(i);
return t;
}
inline constexpr auto kCrcTable = make_crc_table(); // 1 KiB in .rodata, computed by g++
constexpr std::uint32_t crc32(std::string_view data) {
std::uint32_t c = 0xFFFFFFFFu;
for (unsigned char ch : data) c = kCrcTable[(c ^ ch) & 0xFFu] ^ (c >> 8);
return c ^ 0xFFFFFFFFu;
}
static_assert(crc32("123456789") == 0xCBF43926u); // the standard CRC-32 check value
constexpr Version kMinSupported{1, 4, 0}; // literal type
static_assert(Version{1, 5, 2}.packed() == 10502);
const int runtime_const = std::rand(); // const: read-only, value known at run time
constexpr int compile_const = 60 * 60 * 24; // constexpr: value known at compile time
constexpr std::string_view op_name(Op op) { switch (op) { /* ... */ } }
static_assert(op_name(Op::Put) == "PUT");
Verify the table is really precomputed: g++ -std=c++17 -O2 -S m11_constexpr_tables.cpp and look
for kCrcTable in .rodata as 256 .long values, with no loop that fills it at start-up. Then
objdump -d the binary and confirm crc32("123456789") in main became a single constant load
(mov esi, 0xcbf43926).
4. Under the hood (senior-level)¶
Constant evaluation is an interpreter inside the compiler. GCC's constexpr evaluator walks the
function's tree representation, tracking the values of every variable, following loops and branches.
It has limits (-fconstexpr-loop-limit, default 262144 iterations; -fconstexpr-ops-limit,
33 million operations) to stop infinite compile times. The evaluation must be deterministic: no
reading uninitialised memory, no out-of-bounds, no signed overflow, no reinterpret_cast. Any UB
is a hard error with a message pointing at the operation, which is why pushing code into
constexpr tests is a cheap way to find UB.
When is a constexpr function evaluated at compile time? Only when the result is required to
be a constant: constexpr variable initialisers, static_assert, array bounds, template non-type
arguments, case labels, if constexpr conditions, enumerator values. In int x = f(3); the
compiler may fold it (and at -O2 it will for simple cases), but it is not required to. To force
evaluation use constexpr auto v = f(3); and then use v.
.rodata and relocations. A constexpr table of integers lands in .rodata, shared between
processes (the page is file-backed and read-only). A table of pointers (const char* names) needs
load-time relocations unless the binary is non-PIE or the strings are placed relative; std::string_view
tables have the same issue because they contain a pointer. Still constant-initialised, but the
dynamic loader touches those pages at start-up (.data.rel.ro).
Constant initialisation vs dynamic initialisation. A global whose initialiser is a constant
expression is constant-initialised: the bytes are in the binary, no code runs at start-up, and it is
usable from any other global's constructor without ordering problems (no "static initialisation order
fiasco"). A global initialised by a non-constexpr call runs code at start-up in an unspecified order
across translation units. constexpr globals are always safe; const globals might not be.
C++20 adds constinit to demand constant initialisation without implying const.
inline variables and the linker. An inline variable is emitted as a weak symbol in a COMDAT
section in every translation unit that uses it (exactly like inline functions and template
instantiations); the linker keeps one copy. Address identity is preserved: &kCrcTable is the same in
every TU. Before C++17, static const in a header gave each TU its own copy with different addresses.
const and the optimiser. const int n = 5; at local scope lets the compiler fold n because
it sees the initialiser. const int& n or const int* p gives the optimiser nothing: the object
behind the reference may be modified by someone else (it is "const through this view", not
"immutable"). Only constexpr or a const object with a visible constant initialiser is truly
foldable. const on a by-value parameter (void f(const int x)) is purely local documentation.
constexpr and ODR. A constexpr function is implicitly inline, so defining it in a header
is fine and expected.
5. In production¶
- Replace
#defineconstants.constexpr int kMaxConnections = 1024;is typed, scoped, visible to the debugger, and still folds.#defineonly remains for conditional compilation and include guards. - Protocol constants and wire formats.
static_assert(sizeof(WireHeader) == 7)next to the#pragma packstruct (module 12) makes a silent layout change a build error. The same for enum values that are part of the protocol:static_assert(static_cast<int>(Op::Del) == 2). - Lookup tables at zero start-up cost. A banking server that spends 200 ms at start-up building tables (character classes, checksum tables, holiday calendars for the next decade) can have most of them computed by the compiler instead, which also removes the "was it initialised yet?" race.
constexpras unit tests.static_assert(parse_amount("12.50") == 1250)runs on every build, on every developer machine, with no test harness and no flakiness. Keep the heavy tests in a real framework; usestatic_assertfor invariants of pure functions.- Compile-time cost. A
constexprloop of 10 million iterations takes seconds per translation unit on every build. Keep compile-time work small (tables of a few KB), or precompute offline and check it in. - 3 AM debugging. A value that "cannot change" changed: someone
const_casted aconstobject or wrote through a pointer. If the object wasconstexpr/const-defined, the compiler may have folded reads of it, so the code reads the old value in one place and the new one in another. Modifying a defined-const object is UB; find the cast.
6. Try it¶
- (easy) Build
m11_constexpr_tables.cppwith-O2 -S. FindkCrcTablein.rodataand the constant0xcbf43926inmain. Done when you can show both in the assembly. - (easy) Write
constexpr int days_in_month(int month, bool leap)andstatic_assertevery month. Then deliberately introduce an out-of-bounds array read and observe the compile-time UB diagnostic. Done when you can quote the error. - (medium) Write a
constexprenum-to-string table for a 6-valueenum classasstd::array<std::string_view, 6>withstatic_asserts that every entry is non-empty and thattable[static_cast<int>(E::Last)]is correct. Done when adding a 7th enumerator breaks the build. - (medium) Implement
constexpr std::array<int, 1000> primes()using a sieve, and time the compile with and without it (time g++ ...). Then raise to 100,000 and read the-fconstexpr-loop-limit/ ops-limit error. Done when you can explain the trade-off. - (hard) Write a
constexprstate-machine transition table for a tiny protocol parser (states: Start, Key, Sep, Value, Done; inputs: letter, colon, newline, other) and aconstexpr bool accepts(std::string_view)that walks it.static_assertfive inputs. Done when the same function parses runtime input and the table is in.rodata.
7. Interview questions¶
Q: What is the difference between const and constexpr?
const means the object cannot be modified through this name; the value may be computed at run time.
constexpr means the value must be a compile-time constant, usable as array bounds, template
arguments and in static_assert. constexpr implies const; the reverse is false.
Q: When is a constexpr function evaluated at compile time?
When it is called with constant arguments in a context that requires a constant expression
(constexpr variable, static_assert, array bound, template argument, case label). Elsewhere it is
a normal function, though the optimiser may still fold it.
Q: What can a constexpr function not do in C++17?
Allocate, use static or thread-local locals, try/catch, goto, reinterpret_cast, call
non-constexpr functions, leave variables uninitialised, or perform undefined behaviour (which is
diagnosed as an error during constant evaluation).
Q: What is a literal type?
A type whose values can exist in constant expressions: scalars, references, arrays of literal types,
and classes with a constexpr constructor or aggregate initialisation, a trivial destructor, and
literal-type members. std::array qualifies; std::string does not (in C++17).
Q: What problem do inline variables solve?
Defining a variable in a header without "multiple definition" link errors: each translation unit
emits a weak definition and the linker keeps one, with a single address program-wide. It replaces
extern declarations plus a .cpp definition for constants and makes static constexpr members
definition-free.
Q: Why are constexpr globals immune to the static initialisation order fiasco?
They are constant-initialised: their bytes are in the binary and no constructor runs at start-up, so
any other global's constructor can read them regardless of translation-unit order.
Q: Does const int& x help the optimiser?
No. const on a reference or pointer only restricts what you can do through it; the referenced
object may change through other paths, so the compiler must reload it. Only a const or constexpr
object with a visible constant initialiser is foldable.
8. Recap¶
const= read-only access;constexpr= compile-time constant (impliesconst).constexprfunctions run at compile time when the context requires a constant, else at run time; same source for both.- Constant evaluation diagnoses undefined behaviour:
constexprtests find UB. - Literal types:
constexprconstructor, trivial destructor, literal members;std::arrayyes,std::stringno (C++17). static_assertis a free, always-on unit test for invariants and ABI assumptions.inlinevariables (C++17) allow header-defined constants with one address;static constexprmembers are implicitly inline.- Compile-time tables:
constexprfunction returningstd::array, assigned to aninline constexprvariable, landing in.rodata. - Constant initialisation is free and order-safe; dynamic initialisation of globals is not.
constthrough references and pointers gives the optimiser nothing.