THIS EXPLANATION
THE ROOM
CDA·251 Computing, Data & AI 7 MIN · 8 STATIONS

Undefined behaviour

A Socratic walk-through of undefined behaviour — reasoned out one step at a time, not lectured.

abcdefgh
a

The question we started with

THE QUESTION #

Why can a program that ran correctly for years break the moment the compiler is asked to optimise it?

A piece of code has been in production for a decade. Someone raises the optimisation level, or upgrades the compiler, and it starts producing nonsense — not a traceable crash, but a result with no visible relation to what the source says.

The first instinct is that the optimiser has a bug. But hold the experiment still: same machine, same inputs, same source, only a flag changed. Whatever happened is something the compiler was entitled to do at one setting and did not bother to do at the other. So the question is not what went wrong, but what gave it the entitlement.

b

Reasoning it through

REASONING #

Begin with what a language standard is. It does not describe your processor; it describes an abstract machine and specifies what a conforming program's observable behaviour must be on it. A compiler is correct if the program you get behaves, observably, as that machine would have.

Now notice how such a document handles awkward cases. For some constructs it says the result is one of a listed set — the order function arguments are evaluated in, say. For others the implementation must pick a behaviour and document it. But for a third category — signed integer overflow, reading past the end of an array, dereferencing a null pointer, using an uninitialised value, a data race, accessing an object through the wrong type — it imposes no requirements whatsoever. That is undefined behaviour, and it is stronger than it sounds: not that the result is unpredictable, but that the standard says nothing about the program at all from that point, and, being silent, nothing about the parts before it either.

Why should silence be dangerous rather than merely unhelpful? Because of what an optimiser is: a machine for reasoning under premises, proving facts about your code and rewriting it wherever a cheaper form provably behaves the same. And it may take as a premise that your program has no undefined behaviour — because if it does, no output could be judged wrong.

Follow that premise through one case. A programmer guards against signed overflow: after computing x plus one, check whether the result came out smaller than x. The optimiser reasons: signed overflow is undefined, so I may assume it never happens, so x plus one always exceeds x, so this comparison is constantly false, so the branch is dead. It deletes the guard — the check written specifically to detect overflow is removed precisely because it can only fire when overflow occurs.

Now we can see why the effect is so bewildering. The consequence is not local: a premise proved at one point propagates in both directions, and each pass exploits it independently, so the visible damage can appear far from the construct that caused it, in a part of the program that looks innocent. Nothing "went wrong" at any step; every step was valid given the premise.

So what is the falsification test? If the cause were hardware unpredictability — the usual folk theory — a semantic flag should not fix it. Compile the same source on the same machine with signed overflow explicitly defined as wrapping, or with type-based aliasing assumptions switched off, and the program behaves as intended again. That a semantic option restores the behaviour is decisive: the cause was a premise, not a physical effect. The refuting observation would be a suspected undefined-behaviour bug that survives every such flag and survives compiling with optimisation off — which would mean the diagnosis is wrong and something else is at fault.

And the corollary: the program did not "work for years". It was always broken; the older compiler simply did not perform the inference. Non-observation is not correctness.

c

The analogy

THE ANALOGY #
THE FIGURE

Think of someone solving a logic puzzle who is handed a given: no house on the street is painted blue. They use it, correctly, to eliminate possibilities several steps away from anything to do with paint, and reach a confident conclusion about which house the dog lives in. If a house is in fact blue, that conclusion is not slightly off. It bears no relation to the puzzle at all — and it is useless to ask which step was mistaken, because every step was valid.

WHERE IT BREAKS DOWN

the puzzle-solver's given was stated aloud by somebody you could question, whereas the compiler's givens appear nowhere in your program and are recorded only as absences in a standards document, with each optimisation pass helping itself independently, so nothing logs which assumption justified the code you got.

d

Clarifying the model

THE MODEL #

Three refinements.

First, the three categories are genuinely different and constantly conflated. Unspecified behaviour has a bounded set of outcomes; implementation-defined behaviour a documented one; undefined behaviour is unbounded, with no set to enumerate — which is why "it will probably just wrap" is not a fact about the language but a guess about a compiler you have not upgraded yet.

Second, this is not a computability limit. The compiler is not failing to detect your mistake because detection is undecidable, though it often is; it is not trying to detect anything, but assuming absence — a different posture, which explains why warnings are so unreliable a defence. It differs too from the surprises of floating-point arithmetic, treated elsewhere in this collection: those come from behaviour specified precisely, in a form that defies intuition. Here nothing is specified at all.

Third, why does a language carry this? Two real reasons and one that is merely durable. Portability came first: C had to run on machines that disagreed about how signed numbers were represented and what overflow did, and leaving it undefined let each implementation do what its hardware did rather than emulate a common answer. Optimisation came second: assuming no overflow lets a compiler widen loop counters to native registers and prove loops terminate. The third reason is that narrowing undefined behaviour now is an ecosystem coordination problem rather than a technical one — an enormous installed base depends on current semantics, and compiler benchmarking rewards keeping the assumptions. Later languages chose differently, with checked or wrapping arithmetic and bounds, at a small measurable cost. The older choice persists on accumulated code and competitive pressure, not superiority.

e

A picture of it

THE PICTURE #
Undefined behaviour
Undefined behaviour Read top to bottom as one episode in the life of a single line of code. The crucial exchange is the second and third message: the optimiser asks the standard for a premise and gets it from a silence rather than a statement. The self-directed fourth message is where the damage is done -- a valid deduction from that premise, deleting the very guard the programmer wrote. The last two messages are separated from the rest by years in practice, which is why the cause looks like the flag change rather than the line. {"generator":"[email protected]","source":"../Socrates/.diagram-cache/_src/undefined-behaviour.md","sourceIndex":1,"sourceLine":4,"sourceHash":"0d481b9e58db6c32e8e50936a12edf6e3d4178e9e24246c238ca566f75c23539","diagramType":"sequence","layoutVariant":"source","repairedDuplicateIds":[],"motion":"entrance-with-reduced-motion-fallback","presentation":"editorial","attempt":1,"viewBox":{"x":0,"y":0,"width":1185,"height":714},"qa":{"passed":true,"findings":[]}} Machine 01 Optimiser 02 Language standard 03 Programmer 04 writes a guard against signed overflow 1 may I assume this addition never overflows 2 signed overflow is undefined, so yes 3 the guard can only fire on overflow, so it is dead 4 emits code with the guard removed 5 runs correctly on every ordinary input 6 supplies an input that overflows 7 behaviour unrelated to the source text 8
KINDSlifelineparticipantmessage

How to readRead top to bottom as one episode in the life of a single line of code. The crucial exchange is the second and third message: the optimiser asks the standard for a premise and gets it from a silence rather than a statement. The self-directed fourth message is where the damage is done — a valid deduction from that premise, deleting the very guard the programmer wrote. The last two messages are separated from the rest by years in practice, which is why the cause looks like the flag change rather than the line.

f

What became clearer

WHAT CLEARED #
WHAT CLEARED

Undefined behaviour is not a category of unpredictable results. It is a set of premises an optimiser is licensed to assume, granted by the places where the specification declines to say anything — and a false premise licenses any conclusion, at any distance from where it was used. That is why the symptom appears when optimisation is raised, why it appears far from the offending construct, and why a semantic flag can cure it while no amount of testing detected it. The lesson outlives C: wherever a system may optimise on the assumption that a contract holds, violating the contract does not degrade the result, it voids it.

g

Where to go next

ONWARD #
  • How sanitizers detect these violations at runtime, and what they cannot see.
  • Why data races are undefined rather than unpredictable, and what a memory model promises instead.
h

Key terms

TERMS #
TermWhat it means
Abstract machinethe idealised execution model a language standard describes, which a compiler must match in observable behaviour only.
Undefined behavioura construct for which the standard imposes no requirements at all, on any part of the program.
Unspecified behavioura construct with several permitted outcomes, one of which will occur.
Implementation-defined behavioura construct whose behaviour each implementation chooses and documents.

Every term the collection defines is gathered in the glossary.

Nearby on the shelf

4