all_lessons/C++/19 · Undefined behaviorlesson 20 / 20

Undefined behavior and the programmer–compiler contract

Lesson 18 closed the concurrency movement by showing what the compiler and CPU are allowed to reorder, and why a data race is not "a stale read" but something far worse — undefined. This final lesson names the thing every prior lesson has been quietly circling. Undefined behavior (UB) is not a bug in the standard; it is the standard's central bargain, and it is the exact price of the zero-overhead principle from Lesson 00. The one new idea here is the contract itself: you promise never to break a short list of rules, and in exchange the compiler is allowed to assume you never do — which is precisely what lets it emit machine code as tight as hand-written. Then we assemble the whole series.

The thesis, here
The cost-model question reaches its final form: what did I promise the compiler? Every optimization in C++ — eliding a bounds check, assuming a loop terminates, keeping a value in a register, reordering two writes — is the compiler spending a promise you made. UB is the itemized bill for speed you cannot see in the generated code: it is the safety the language did not charge you for, because you swore you would not need it.
Linear position
Prerequisite: all prior lessons — this is the capstone. It cashes the forward references planted in Lesson 00 (the "price of zero-overhead"), Lesson 02 (signed integer overflow), Lesson 04 (use-after-free / dangling pointers), Lesson 09 and 12 (zero-overhead abstraction), and Lessons 17–18 (the data race is UB).
New capability: define undefined behavior precisely, explain why it exists rather than treating it as a flaw, recognize the seven classic ways to trigger it, understand how UB can appear to "travel backward" and delete a safety check, deploy the standard defenses (sanitizers, warnings, the Core Guidelines, valgrind), and — finally — see the whole series as one habit.
The plan
Six moves. (1) Define UB and the two weaker cousins it is confused with. (2) Explain why it exists — the zero-overhead bargain, made mechanical. (3) The catalog: seven rules you must not break, each with a one-line example. (4) The worked example: watch the optimizer delete a null check because reaching it would have been UB — UB travelling backward. (5) The defense: ASan, UBSan, TSan, -Wall -Wextra, the Core Guidelines, valgrind. (6) Synthesis — the five questions, what transfers, and where to go next.

1 · What undefined behavior actually is

The C++ standard describes the behavior of correct programs. For certain operations it explicitly declines to say anything at all: it labels them undefined behavior and imposes no requirements whatsoever on what happens. Not "it crashes," not "it returns garbage," not "it's implementation-specific" — literally nothing is guaranteed. The program may crash, may produce wrong output, may appear to work for years, may delete unrelated code, may differ between optimization levels. Formally: if any execution of your program would, at any point, perform an operation with undefined behavior, the standard places no constraint on the entire execution — including the parts that ran before the UB. This is the property that makes UB so much worse than it sounds, and §4 turns on it.

Three terms are routinely confused; only the first is the dangerous one:

Undefined behavior
Anything may happen, including nothing visible. No requirements at all. Dereferencing a null pointer, signed overflow, a data race. This is the contract; this is what the optimizer exploits.
Unspecified behavior
The standard offers a set of allowed behaviors and the implementation picks one, but need not document it. Example: the order in which function arguments are evaluated. Surprising, but bounded — your program won't be reorganized around it.
Implementation-defined behavior
Like unspecified, but the implementation must document its choice. Example: sizeof(int), or whether plain char is signed. Portable code reads the docs; it is otherwise safe.

2 · Why UB exists — the zero-overhead bargain, made mechanical

It is tempting to read UB as a defect — why would a language define rules whose violation does something arbitrary? The answer is the thesis of this whole series (Lesson 00). UB is the mechanism by which the zero-overhead principle is implemented. The principle's first half — "you don't pay for what you don't use" — requires that the compiler omit checks you didn't ask for. But the compiler can only safely omit a check if it is entitled to assume the dangerous case never occurs. UB is precisely that entitlement, written into the standard.

The contract, in one sentence
The standard says "if your program does X, all bets are off." The compiler reads this as a license: "therefore I may assume the program never does X, and optimize as if X is impossible." You promise not to do X; in exchange you get machine code with no defensive check guarding against X. That is the trade. UB is not the punishment for breaking the rule — it is the permission slip that lets the rule pay off as speed.

Make it concrete. Consider a[i] + a[i+1] in a hot loop. A safe language inserts a bounds check before each access — a comparison and a branch, every iteration. C++ does not, because out-of-bounds access is UB: the compiler is entitled to assume i and i+1 are in range, so it emits two raw loads and an add — exactly what you would hand-write in assembly (Lesson 09's claim that abstraction is free, Lesson 13's claim that vector iteration matches a C array). The bounds check you "didn't pay for" is the bounds check the UB rule let the compiler skip.

Now read the optimizer's assumptions back through the lessons:

The compiler assumes……because this is UB…which buys (lesson)
every array index is in rangeout-of-bounds accessno bounds check on a[i] (09, 13)
signed arithmetic never overflowssigned integer overflowi < i+1 is always true → loops vectorize (02)
a dereferenced pointer is non-nullnull dereferenceno null check before *p (03)
distinct typed pointers don't aliasbreaking strict aliasingvalues stay in registers across stores (08)
no two threads race on a locationdata raceplain reads/writes need no fences (17, 18)
every object is alive when useduse-after-freeno liveness tracking at runtime (04, 05)

Each row is the same bargain: a thing the compiler is allowed to assume false-never-happens, and the runtime check it therefore deletes. This is the hidden engine under "C++ is fast." It is also why C++ is unforgiving — the assumptions are load-bearing, and the moment one is false, the optimizations built on it become miscompilations of your intent.

3 · The catalog — seven rules you must not break

You do not need to memorize the hundreds of clauses the standard marks undefined. In practice nearly all real bugs fall into seven families. Each is a promise; each has a one-line trigger.

// 1. Out-of-bounds access — index past the end of an array/vector.
int a[3];  a[3] = 0;                 // index 3 is one past the last valid (0..2)

// 2. Use-after-free / dangling pointer (Lesson 04).
int* p = new int(7);  delete p;  *p = 9;     // p points at freed storage

// 3. Signed integer overflow (Lesson 02). UNSIGNED wraps; SIGNED is UB.
int x = INT_MAX;  int y = x + 1;     // no defined "wrap to INT_MIN"

// 4. Reading an uninitialized variable.
int n;  if (n > 0) { /* ... */ }     // n holds whatever was on the stack

// 5. Null pointer dereference (Lesson 03).
int* q = nullptr;  int v = *q;       // reading through the null address

// 6. Data race — two threads, one location, ≥1 writer, no sync (Lessons 17–18).
// thread A: counter++;   thread B: counter++;   // counter is a plain int

// 7. Breaking strict aliasing — access an object through an unrelated type.
float f = 1.0f;  int  bits = *(int*)&f;        // read a float's storage as int

Two of these deserve a sentence of nuance. Signed vs unsigned overflow (rule 3): unsigned arithmetic is defined to wrap modulo 2ⁿ — it is not UB — which is exactly why signed loop counters can vectorize (the compiler assumes i+1 > i) while unsigned ones sometimes cannot. Strict aliasing (rule 7): the rule that two pointers of unrelated types never refer to the same storage is what lets the compiler keep a value in a register across a write through a different pointer type (Lesson 08's "values stay in registers"). To reinterpret bytes legally, use std::bit_cast (C++20) or memcpy, never a pointer cast.

4 · Worked example — the optimizer deletes your safety check

The folklore is that UB "crashes" or "returns garbage." The sharp, surprising truth is that UB can make the compiler silently remove code you wrote — and the removed code can sit before the offending line, so the bug appears to travel backward in time. Here is the canonical case, the kind that lands in real bug trackers.

Worked: a null check that vanishes
int read_value(int* p) {
    int v = *p;            // (A) dereference p
    if (p == nullptr)      // (B) ...then check whether p was null
        return -1;
    return v;
}

You intended (B) to guard against a null p. But the compiler reasons as follows. Line (A) dereferences p. If p were null, (A) would be undefined behavior — and the compiler is entitled to assume UB never happens (§2). Therefore, at line (B), p cannot be null: if it were, the program would already have invoked UB at (A), and the standard imposes no requirement on such a run. The condition p == nullptr is provably false on every defined execution. So the optimizer deletes the branch entirely:

// What -O2 actually compiles read_value() to:
int read_value(int* p) {
    return *p;             // the null check is gone — proven dead
}

Now your "defensive" check provides zero protection: pass a null pointer and you get a raw dereference and a crash (or worse), precisely the outcome the check existed to prevent. Note where the damage lands — the deleted code is line (B), which sits after the dereference in source order, yet the cause is line (A). UB reaches "backward": because the standard un-constrains the whole execution that contains UB, the compiler may rewrite anything around it.

The fix is to order the program so no defined execution reaches the dereference with a null pointer — check first: if (p == nullptr) return -1; int v = *p;. Now the dereference is unreachable when p is null, the assumption "p is non-null at (A)" holds for free, and the check survives because it is genuinely live. The same pattern explains miscompiled overflow checks (if (x + 1 < x) is deleted because signed overflow is UB, so x + 1 < x is "always false") and is why you must write overflow tests against the limit (if (x > INT_MAX - 1)) instead. This is not a compiler bug. The compiler did exactly what your promise licensed.

5 · The defense — make UB observable

UB is dangerous because it is silent: the program may look correct. The entire defensive craft is about converting silent UB into a loud, reproducible failure. None of these tools is part of the language; all are part of being a working C++ engineer, and the honest cost (Lesson 00) of this much control.

Sanitizers — -fsanitize=…
Compiler instrumentation that traps UB at runtime, where it happens, with a stack trace. ASan (AddressSanitizer): out-of-bounds, use-after-free, leaks (rules 1, 2). UBSan: signed overflow, null deref, bad shifts, misalignment (rules 3, 5). TSan (ThreadSanitizer): data races (rule 6, Lessons 17–18). Run your test suite under all three. They cost ~2–10× runtime, so they are a test-time tool, not a production one. (Strict aliasing, rule 7, is the one family no sanitizer reliably catches — lean on -Wstrict-aliasing and just use memcpy/std::bit_cast.)
Warnings — -Wall -Wextra -Wpedantic
The cheapest defense and the first you should enable. Catches uninitialized reads (rule 4), suspicious comparisons, unused results, and dozens more at compile time. Add -Werror in CI so warnings can never be ignored. Free; turn it on today.
valgrind / memcheck
A runtime tool that needs no recompilation: detects invalid reads/writes, use-after-free, leaks, and uninitialized-memory use by emulating execution. Slower and less precise than ASan but catches things in binaries you can't rebuild. Complementary, not a replacement.
The C++ Core Guidelines
A curated rule set (Stroustrup & Sutter) that steers you away from UB by construction: prefer RAII (Lesson 05) and smart pointers (Lesson 07) over raw new/delete, prefer .at() or ranges over raw indexing, prefer gsl::span over pointer+length. A static analyzer (clang-tidy) can enforce a subset before the code ever runs.

The strategy, in order of cost: write idiomatic modern C++ that avoids the sharp edges (Guidelines), compile with maximal warnings as errors (free), and run every test under ASan + UBSan + TSan (cheap at test time, decisive). That pipeline turns the silent-UB problem into a noisy-test problem, which is one you can actually solve.

6 · Common mistakes / failure modes

"It works, so it's correct"
UB programs frequently work — at -O0, on your machine, today. Then a compiler upgrade, a new optimization, or an inlining decision exposes the assumption and it breaks. "Works" is not evidence of defined behavior; a clean sanitizer run is.
Checking for overflow after it happens
if (x + 1 < x) is deleted (§4) — signed overflow is UB, so the compiler proves the test false. Check against the limit before: if (x > INT_MAX - 1).
Type-punning through pointer casts
*(int*)&myFloat breaks strict aliasing (rule 7) and may read a stale register copy. Use std::bit_cast or memcpy.
"The race is benign"
There is no benign data race in C++ (Lessons 17–18). Two unsynchronized accesses with one writer is UB even if you "only read a flag." Use std::atomic.
Trusting an uninitialized read to be zero
A local int n; is not zero-initialized; reading it is UB (rule 4). Initialize at declaration — int n = 0; — and let -Wextra catch the rest.
Returning a reference/pointer to a local
The local dies at scope exit (Lesson 02); the returned reference dangles (Lesson 04) — use-after-return, UB. ASan's stack-use-after-return catches it.

Checkpoint exercise

Try it
Compile the §4 read_value function on godbolt-style explorer at -O0 and again at -O2 with a modern Clang or GCC, and read the assembly for both. At -O0 you will see the null comparison and branch; at -O2 the comparison is gone and the function is a single load. Then build a tiny main that calls read_value(nullptr) and run it under -fsanitize=undefined,address — UBSan/ASan will report the null dereference at the exact line, with a stack trace, instead of letting it crash mysteriously. Finally, reorder the check to come first and confirm the branch survives at -O2. You will have watched a promise turn into deleted code, and watched a sanitizer turn silent UB into a loud, fixable error.

Where this points next

There is no Lesson 20 — this is the end of the spine, so "next" returns to the series index. But the series points outward in three directions. C++20/23 continue the project of making the right thing easy and UB harder to reach: concepts (Lesson 12) for sane template errors, std::span for bounds-aware ranges, std::expected (Lesson 15) for value-based errors, std::bit_cast for legal type-punning. The C++ Core Guidelines are the field manual for writing the UB-resistant subset we have taught. And Rust is the most interesting destination: it is, in effect, C++'s zero-overhead bargain with the contract moved into the type system — ownership, lifetimes, and the no-data-race rule are checked by the compiler at build time, so the most common UB families (use-after-free, dangling references, data races) become compile errors rather than silent runtime UB. Everything you learned here — ownership, lifetime, the borrow, the cost of an indirection — is exactly the vocabulary Rust makes mandatory. You are ready for it because you learned why the rules exist, not just that they do.

Takeaway
Undefined behavior is not a flaw in C++; it is the load-bearing mechanism of the zero-overhead principle from Lesson 00. The standard declaring some operation undefined is exactly the license that lets the compiler assume you never perform it and so delete the defensive check — the bounds check, the null check, the overflow guard, the fence — that a safe language would insert for everyone. That is the contract: you promise not to break the rules (out-of-bounds, use-after-free, signed overflow, uninitialized read, null deref, data race, strict aliasing), and in exchange your abstractions compile to code as tight as hand-written. The danger is that UB is silent and can travel backward, deleting code you wrote; the defense is to make it loud — -Wall -Wextra -Werror, ASan/UBSan/TSan, valgrind, the Core Guidelines. And here the whole series resolves into one habit: the five questions from Lesson 00 — what does this compile to, where does the memory live, who owns it, when does it die, and what did it cost? — now carry a sixth that contains them all: what did I promise the compiler? You can ask these of any line in any language now, because C++ made you answer them out loud. You see the allocation in a Python comprehension, the indirection behind a Go interface, the captured heap in a JS closure, the lifetime in a Rust borrow, and the contract underneath all of it. That x-ray vision — not the syntax — was the point.

Interview prompts