all_lessons/C++/16 · const & constexprlesson 17 / 20

The type system as a tool: const, and compile-time computation

Lesson 15 gave us two channels for errors that do happen at runtime — exceptions and value-based results — and the safety guarantees that make them survivable. This lesson asks the prior question: which errors need to happen at runtime at all? The one new idea is push errors left — use the type system and the compiler to convert what would have been a runtime bug into a compile error, a mistake the program physically cannot be built to contain. We turn three tools to that end: const as a machine-checked promise, distinct types that make illegal states unrepresentable, and constexpr to move whole computations from run time to compile time.

The thesis, here
The cost-model habit usually asks "what does this line cost at runtime?" This lesson flips it: a bug caught by the compiler costs zero at runtime because the buggy program never ships — and a value computed by the compiler also costs zero at runtime because the work is already done. const, strong types, and constexpr are all the same move: pay the compiler once, at build time, so the running program pays nothing and carries one fewer way to be wrong.
Linear position
Prerequisite: Lesson 12 (templates — the compiler stamps out code per type, and computes things at build time) and Lesson 09 (classes, the implicit this pointer, and a method as a function with a hidden first argument). We also lean on Lesson 06's pass-by-const-reference.
New capability: express invariants in the type system so the compiler enforces them; write const-correct interfaces; build a distinct unit type that refuses to mix with another; and compute tables, sizes, and checks at compile time with constexpr and static_assert.
The plan
Five moves. (1) State the engineering principle — push errors left — and the spectrum of when a bug can be caught. (2) const-correctness in full: const variables, const references, const member functions, how const propagates, and why it is a checked contract not a comment. (3) Make illegal states unrepresentable with enum class and distinct strong types. (4) constexpr: compute at compile time, prove the result is baked into the binary, and use static_assert for build-time checks. (5) A taste of type traits and if constexpr. Worked examples throughout: a compile-time factorial table, and a unit mix-up that won't compile.

1 · The principle: push errors left

Imagine the life of a bug laid out left to right on a timeline: compile time → link time → unit test → integration test → staging → production → 3 a.m. page. The same logic error costs almost nothing on the far left and a fortune on the far right. Push errors left is the discipline of moving each class of bug as far toward compile time as the language allows, because a bug the compiler refuses to build is a bug that can never reach a user.

Most languages give you only a thin slice of the left end: a type mismatch, a missing argument. C++'s type system is unusually powerful at this — not because it is safe by default (it is not; Lesson 19), but because it lets you encode invariants into types so that violating them is a syntax-level impossibility. The three tools below are how you spend that power.

caught at compile time
The program cannot be built. Zero runtime cost, found by every developer instantly, impossible to ship. This is the target. const, strong types, static_assert all live here.
caught at run time
An exception, an assert, a returned error (Lesson 15). The program builds; the bug fires only if that path executes, on inputs you tested or didn't. Better than nothing, worse than left.
not caught at all
Undefined behavior (Lesson 19): a silent wrong answer, a corrupted byte, a crash next Tuesday. The whole point of pushing left is to shrink this region toward nothing.

2 · const-correctness: a promise the compiler enforces

const means this cannot change through this name. It is not a hint to the optimizer and not a comment — it is a contract the compiler checks: write through a const name and the program does not compile. That single guarantee, applied consistently, is called const-correctness, and it is one of the cheapest invariants you can buy.

const int kMax = 100;        // a constant; reassigning it is a compile error
int n = 5;
const int& r = n;            // a const *reference* — a read-only alias to n
// r = 7;                    // ERROR: assignment of read-only reference 'r'
n = 7;                       // fine — n itself is not const; r just can't write it

A const reference (const T&) is the workhorse. Recall from Lesson 06 that passing a parameter by value copies it. Passing by const T& does two jobs at once: it avoids the copy (a reference is just an address under the hood, so a huge object is passed as a pointer-sized alias) and it promises the callee will not mutate the argument. The caller reads that promise in the function's signature and relies on it without seeing the body.

// Cheap AND safe: no copy of the (possibly huge) string, and a checked
// guarantee that count_words leaves the caller's text untouched.
std::size_t count_words(const std::string& text);

// By contrast, this copies the whole string on every call:
std::size_t count_words_slow(std::string text);

const member functions

From Lesson 09, a member function receives a hidden this pointer to the object it was called on. A const member function — marked with const after the parameter list — promises this call will not modify the object. Mechanically, it makes this a pointer-to-const, so any line that would mutate a data member fails to compile inside it.

class Account {
  long cents_ = 0;
public:
  long balance() const { return cents_; }   // const: a pure observer
  void deposit(long c) { cents_ += c; }      // non-const: a mutator
  // long bad() const { cents_ += 1; return cents_; }  // ERROR: assignment of
                                                       // member in const method
};

This is where const earns its keep through propagation. Once you have a const Account& — which you will, the moment you pass an account by const reference — the compiler will let you call only the const member functions on it. Call balance(): fine. Call deposit(): compile error, because a non-const method might modify the object you promised not to touch.

Worked example — const propagation catches a bug at the boundary
// We promise (via const&) not to change the account when printing it.
void print_statement(const Account& a) {
  std::cout << a.balance() << '\n';   // OK: balance() is const
  a.deposit(100);                     // ERROR: passing 'const Account' as
                                      // 'this' discards qualifiers
}
The mistake — a stray deposit inside a function meant only to observe — would be a silent data-corruption bug in a language without const member functions. Here it is a build failure, found in milliseconds, by everyone. The const on balance() and the const on the parameter together formed a closed contract: the function declared "observer," and the compiler held it to it. const is viral on purpose — it propagates from the parameter into which methods you may call, into which members you may touch — and that viral spread is exactly the enforcement.

Two refinements. const T* (pointer to const data) versus T* const (a const pointer to mutable data) were drawn in Lesson 03 — read the declaration right-to-left: const int* is "pointer to const int," int* const is "const pointer to int." And mutable on a member exempts it from const (used for a cache or a mutex), an escape hatch you reach for rarely and deliberately.

3 · Make illegal states unrepresentable

The strongest form of pushing left is to choose types in which the wrong value cannot be written down at all. If a state is illegal, the best defense is a type that has no way to express it — then no validation code, no runtime check, and no test is needed, because the compiler rejects the typo.

enum class — a closed set of named values

A plain C enum is just an int in disguise: it implicitly converts to integers, leaks its names into the surrounding scope, and lets you compare a Color to a FileMode as if they were the same thing. A enum class (a scoped enumeration) fixes all three: its values are a distinct type, do not silently convert to int, and must be named through the enum (Color::Red).

enum class Color  { Red, Green, Blue };
enum class Status { Ok, Error };

Color c = Color::Red;
// int x = c;                 // ERROR: no implicit conversion to int
// if (c == Status::Ok) {}    // ERROR: comparing Color to Status — different types
if (c == Color::Red) { /* the only legal comparison — same type */ }

The payoff: a function taking a Color cannot be handed a raw 2, a Status, or a "blue" that was really a stale array index. The set of legal arguments is exactly the named members, enforced at the call site.

Strong (distinct) types — a Meters that is not a Seconds

The famous bug: a function expects a distance in meters and someone passes a duration in seconds. Both are double at runtime, so the compiler — seeing only double — is happy, and the rocket lands in the ocean. The fix is to give each quantity its own type that holds a double but is not interchangeable with one. This is a strong typedef: same representation, distinct identity.

Worked example — a unit mix-up that won't compile
struct Meters  { double value; };
struct Seconds { double value; };

// Define addition ONLY within a unit. There is no Meters+Seconds operator,
// so writing one is a compile error, not a wrong number.
Meters operator+(Meters a, Meters b) { return { a.value + b.value }; }

double speed(Meters distance, Seconds time) {
  return distance.value / time.value;          // m/s — explicit, intentional
}

int main() {
  Meters  d{100.0};
  Seconds t{9.58};
  auto v = speed(d, t);                         // OK
  // auto bad = d + t;                          // ERROR: no operator+(Meters, Seconds)
  // double w = speed(t, d);                    // ERROR: cannot convert Seconds to Meters
}
At runtime Meters is a single double with zero wrapper overhead — by the zero-overhead principle (Lesson 00) the struct compiles away to exactly the bare double the optimizer would have used. You paid nothing at run time and the unit mix-up — a real, costly, history-making class of bug — became a build failure you cannot miss. The types are the dimensional analysis, checked by the compiler.

The general pattern: wrap the primitive in a one-field struct (or a small class) so the type system can tell two things apart that the machine cannot. UserId vs OrderId (both int), Celsius vs Fahrenheit, SanitizedHtml vs RawInput — anywhere a function takes two arguments of the same primitive type, transposing them is a bug a strong type makes unrepresentable.

4 · constexpr — move the work to compile time

The other half of "the compiler works for free" is constexpr: a marker that a value or function can be evaluated at compile time. If the inputs are known at compile time, the compiler runs the computation during the build and bakes the result — a literal — into the binary. The running program does no work; it just loads a number that is already there. This generalizes Lesson 12's "the compiler computes at build time" from types to values.

constexpr int factorial(int n) {            // *can* run at compile time
  return n <= 1 ? 1 : n * factorial(n - 1);
}

constexpr int f10 = factorial(10);          // computed during compilation
int arr[factorial(5)];                      // legal: 120 is a compile-time constant,
                                            // and array sizes MUST be one
Worked example — prove it lands in the binary as a literal
The claim "factorial(10) costs nothing at runtime" is checkable. Compile constexpr int f10 = factorial(10); with optimizations and look at the assembly (e.g. on godbolt). There is no call to factorial and no loop — the function vanished entirely. What remains is the constant 3628800 stored directly:
; f10 = factorial(10), as compiled:
mov DWORD PTR [rbp-4], 3628800   ; the answer, precomputed — no call, no multiply
That is the whole point of pushing computation left. The factorial ran once, on the build machine, and every execution of the program for the rest of its life pays zero for it. Compare a runtime factorial(userInput): the input isn't known at build time, so the same function compiles to an actual recursive call — constexpr means "compile-time if it can," not "always." Mark a thing consteval (C++20) to require compile-time evaluation and turn any runtime use into an error.

static_assert — a check the build must pass

static_assert(condition, "message") is an assertion evaluated at compile time. If the condition is false, the program does not build and the message is the error. It is the most direct "push left" tool: a precondition that fails the compiler instead of the program.

static_assert(factorial(5) == 120, "factorial is wrong");   // checked at build
static_assert(sizeof(void*) == 8, "this code assumes a 64-bit target");
static_assert(factorial(0) == 1,  "0! must be 1");
// If any condition were false, compilation stops here with the message.

A compile-time lookup table

Because a constexpr function can fill data, you can build whole tables at compile time. The table ends up as a block of literals in the binary — the program never computes it, it ships pre-filled.

#include <array>
constexpr std::array<int, 8> factorials() {
  std::array<int, 8> t{};
  int acc = 1;
  for (int i = 0; i < 8; ++i) { acc = (i == 0) ? 1 : acc * i; t[i] = acc; }
  return t;
}
constexpr auto kFact = factorials();        // 1,1,2,6,24,120,720,5040 — in the binary
static_assert(kFact[5] == 120);             // and verified during the build

5 · A taste of type traits and if constexpr

The compiler can also ask questions about types and branch on the answers, all at build time. The standard <type_traits> header is a library of compile-time predicates — std::is_integral_v<T>, std::is_pointer_v<T>, and so on — each a constexpr bool the template machinery (Lesson 12) evaluates while stamping out code for a given T.

if constexpr (C++17) is an if resolved at compile time: the branch not taken is not just skipped at runtime, it is discarded entirely — never compiled for that T. This lets one template do the right thing per type with zero runtime branching.

#include <type_traits>
template <typename T>
void describe(T x) {
  if constexpr (std::is_integral_v<T>) {
    std::cout << "integer: " << x << '\n';      // compiled only when T is integral
  } else {
    std::cout << "not an integer\n";            // compiled only otherwise
  }
}
// describe(42)   -> the integral branch is the ONLY code generated
// describe(3.14) -> the else branch is the only code generated

You can pair traits with static_assert to reject misuse with a readable message — static_assert(std::is_integral_v<T>, "T must be an integer type"); turns a cryptic deep-template error into a one-line explanation. (C++20's concepts, previewed in Lesson 12, are the modern, even cleaner way to state such a requirement on a template.)

6 · Common mistakes / failure modes

Treating const as decoration
const is not advisory — it changes which methods you may call and fails the build when broken. Adding it late is painful (it propagates); design const-correct from the start. Mark every observer method const.
Plain enum where enum class belongs
Unscoped enum converts silently to int and pollutes the scope, re-opening exactly the mix-ups you wanted closed. Default to enum class; reach for plain enum only for deliberate bit-flag arithmetic.
Passing distinct types as the same primitive
Two doubles, two int IDs — the compiler can't tell them apart, so a transposed argument is silent. Wrap them in strong types and the transposition won't compile.
Expecting constexpr to force compile-time
constexpr means "can run at compile time," and falls back to a runtime call when inputs aren't constant. If you need a guarantee, assign to a constexpr variable, use it in a static_assert, or mark the function consteval.

Checkpoint exercise

Try it
Write a constexpr function int pow2(int e) returning 2 raised to e. (1) Add static_assert(pow2(10) == 1024); and confirm the build passes; change it to 1023 and watch compilation fail with your message — that is a bug caught at compile time. (2) Assign constexpr int k = pow2(16); and inspect the optimized assembly on godbolt: confirm there is no call and the literal 65536 appears — the work happened at build time. (3) Define struct Bytes { long n; }; and struct Megabytes { long n; };, write a function taking Bytes, and try to call it with a Megabytes; confirm it won't compile. You have now pushed three errors left: a wrong constant, a runtime cost, and a unit mix-up.

Where this points next

We have spent this movement making the compiler your strongest ally: it stamps out generic code (12), models containers and costs (13), composes algorithms (14), keeps exceptions safe (15), and now refuses to build code that violates the invariants you encoded in types. Lesson 17 leaves the comfort of a single thread and meets the one bug class the type system cannot push left on its own: the data race. When two threads touch the same memory and at least one writes, with no synchronization, the result is undefined — and no const, no strong type, catches it by itself. We will see why shared mutable state is the enemy, and how RAII (Lesson 05) returns to make locks leak-proof.

Takeaway
The cheapest bug is the one the compiler refuses to build, and the cheapest computation is the one the compiler already did — both are the same move, pushing work and errors left to build time. const is a machine-checked contract: a const reference passes big objects cheaply and promises no mutation (Lesson 06), a const member function promises the call won't modify the object, and const propagates — once you hold a const T& you may call only const methods, turning a stray mutation into a compile error. Make illegal states unrepresentable with enum class (a closed, non-converting set of named values) and strong types (a Meters that is a double at runtime yet refuses to add to a Seconds), so wrong code simply won't compile at zero runtime cost. And constexpr moves computation to build time — a factorial or table becomes a literal in the binary, verified by static_assert — while if constexpr and type traits let one template do the right thing per type. Every tool here spends the compiler so the running program carries one fewer way to be wrong.

Interview prompts