C++ Part I · Module 25 · Debugging, Undefined Behavior, and Performance
Uninitialized Data and Indeterminate Values
A variable declared without an initialiser holds an indeterminate value, and reading one is undefined behaviour in the full sense Chapter 25.1 established — not merely a wrong number but a premise the compiler is entitled to reason from. What makes this class worth its own chapter is the shape of the failure rather than the rule. The value is not random. It is whatever happens to be in that storage, which means it is reproducible within a build and changes between builds, so a bug of this kind will reliably work in one configuration and reliably fail in another, and the one it works in is as often the debug build as the release build. This chapter measures that in both directions on the same program: an uninitialised accumulator that returns a plausible answer at -O0 and garbage at -O2, and a half-assigned struct that shows garbage at -O0 and clean zeros under optimisation. It also measures what the warning flags report, which is less than people expect, and which tool was built for exactly this bug and cannot be run on this machine.
Intermediate18 min readC++Part IFundamentalsDebugging
Part I · Module 25 · Chapter 25.2 · Uninitialized Data and Indeterminate Values
1. What an Indeterminate Value Is
int n; // n holds an INDETERMINATE value
int m = 0; // m holds 0
int p{}; // p holds 0An object with automatic storage duration and no initialiser is uninitialised, and its value is indeterminate. Reading it is undefined behaviour, with one narrow exception for unsigned char and std::byte, which may hold an unspecified value instead — a distinction that matters for byte buffers and almost nowhere else.
Two things follow, and the second is the one people miss.
The value is not specified to be anything, including not specified to be stable. Chapter 25.1 §1's categories apply: this is undefined behaviour, not unspecified behaviour, so the compiler may reason from the assumption that you never read it.
Static and thread-local objects are different. They are zero-initialised before any other initialisation, so a file-scope int counter; is 0 and always was. The bug in this chapter is exclusively about automatic storage and about members of objects you did not value-initialise.
2. The Bug, Both Ways
One program, two defects, three optimisation levels. First the defect in a loop accumulator:
int worstResp(const std::vector<int>& codes)
{
int worst; // not initialised
for (int c : codes) if (c > worst) worst = c; // never assigned if codes is empty
return worst;
}And a struct whose fields are only partly assigned:
struct Sample { std::uint32_t addr; int cycles; int resp; };
Sample makeBad() { Sample s; s.addr = 0x4000A010; return s; } // cycles, resp unset
Sample makeGood() { Sample s{}; s.addr = 0x4000A010; return s; } // value-initialisedAt -O0:
worstResp({1,3,2}) = 3
worstResp({}) = 3 <-- nothing assigned it
worstRespFixed({}) = 0
makeGood(): addr=0x4000A010 cycles=0 resp=0worstResp({}) returned 3. Three is a plausible response code, and it is also the correct answer for the other input the same program passes. A reviewer reading the output would see a column of sensible numbers. The same build shows makeBad() returning varying garbage in cycles — so at -O0 one defect is invisible and the other is obvious.
At -O1 and -O2 the two swap:
-O0 | -O1 / -O2 | |
|---|---|---|
worstResp({}) | 3 — plausible, stable | large varying garbage |
makeBad().cycles | varying garbage | 0 — looks correct |
makeBad().resp | 1 | 0 — looks correct |
Each build vindicates one of the two bugs. The optimised build zeroes the struct's padding and unset members as a side effect of how it constructs and returns the object, so makeBad() produces exactly what makeGood() produces and the defect is undetectable from the output. The debug build leaves a plausible value in the accumulator.
There is no configuration in which both look wrong, which is why "it works in release" and "it works in debug" are both common sentences and neither is evidence.
3. The Value Is Stable, Which Is the Problem
The -O0 result is not noise. Measured over three runs it is 3 every time in that program. In a second program containing the same function it is 1 every time:
empty FIRST, before any other call : 1
after {1,3,2} : 3
empty again : 1
after {9,9,9} : 9
empty again : 1So: stable within a program, different between programs. That is the same shape as Chapter 24.7 §5's constant-folded undefined expression and Chapter 25.1 §7's warning — reproducibility is the trap. A value that comes back identical on every run looks like a property of the code.
And here is a hypothesis I tested and had to reject. The obvious explanation for "3" is that the stack slot still holds the previous call's result. The transcript above refutes it: the empty calls return 1, 1 and 1 — not 3 and then 9 — even though they are interleaved with calls returning 3 and 9. Whatever occupies that slot is determined by the surrounding code and the frame layout, not by the previous invocation of the same function. Had I published the stack-reuse story it would have been a plausible mechanism for a real observation and still wrong.
The honest statement is the narrow one: the value is whatever is in that storage, it is reproducible for a given build of a given program, and nothing about it generalises.
4. What the Compiler Reports
u.cpp:10:33: warning: variable 'worst' may be uninitialized when used here [-Wconditional-uninitialized]One warning — and it takes a flag most projects do not have:
| flags | warnings on this file |
|---|---|
-Wall | 0 |
-Wall -Wextra | 0 |
… -Wuninitialized -Wconditional-uninitialized | 1 |
-Wall -Wextra reported nothing. That is worth sitting with, because -Wall does catch the unconditional case — Chapter 24.7 §2 measured [-Wuninitialized] firing there on a variable read before any assignment. What it does not catch is the conditional case, where there exists a path that assigns the variable, so proving the bug requires reasoning about whether the loop body can execute zero times.
-Wconditional-uninitialized does that reasoning and is off by default because it is noisy: it warns wherever it cannot prove the variable is assigned, which on real code includes many instances where a human can see it is fine. That is a real cost, and it is still the flag that found this.
Note also what the warning says: "may be uninitialized". The compiler is reporting an unproven case, not a detected execution. Chapter 25.6's sanitisers are the opposite — they report executions and cannot report absence.
5. The Tool Built for This, Which Is Not Available Here
MemorySanitizer exists specifically to detect reads of uninitialised memory, propagating a shadow bit per byte.
clang++: error: unsupported option '-fsanitize=memory' for target 'arm64-apple-darwin25.5.0'It cannot be run on this machine. So this chapter does not show you an MSan report, and nothing here should be read as having been checked by one. On a platform that supports it — Linux on x86-64 — MSan is the right tool for this bug class and reports the line that read the uninitialised value along with the allocation it came from.
What is available here, and what it does for this bug:
| tool | on this bug |
|---|---|
-Wconditional-uninitialized | catches it at compile time — §4 |
-fsanitize=memory | unavailable on arm64 macOS |
-fsanitize=address | not designed for it; no report |
-fsanitize=undefined | not designed for it; no report |
| lldb | shows the value directly — §6 |
| reading the output | unreliable — §2 showed both directions |
That table is the practical argument for §7's rule. When the detection tools are this thin, prevention is not a style preference.
6. Seeing It in the Debugger
A breakpoint on the loop line, before anything assigns the variable:
(int) worst = 1That is the whole technique, and it is the fastest way to confirm a suspicion. frame variable at the point of first use shows you what the variable actually holds, which settles in one step what reading the source cannot: whether the value you are seeing came from your code or from the frame. Chapter 25.5 is the method this fits into.
A second lldb technique is worth knowing for this class specifically. If you suspect something is overwriting a variable, a watchpoint tells you what wrote to it:
// in lldb
watchpoint set variable worstFor an uninitialised read the watchpoint will show no write before the read, which is the positive confirmation that nothing assigned it.
7. Removing the Class
The rule is one line: give every variable a value where you declare it. The forms, and when each is right:
int n = 0; // explicit, and says what the initial value means
int n{}; // zero, for types where 0 is the natural start
Sample s{}; // VALUE-initialises every member, including padding
std::vector<int> v; // already empty -- a class type with a default ctor is fine
auto it = v.begin(); // initialised from something -- the best case
int worst = codes.empty() ? 0 : codes.front(); // when 0 is not a valid starting pointFour notes that matter in practice.
Sample s{} and Sample s; differ. The braces value-initialise, which zeroes every member of an aggregate — and Chapter 23.6 §3 measured that it also zeroes the padding, which matters whenever the object is written to a file or hashed.
Prefer initialising from the real value over initialising to a placeholder. int worst = codes.front() after an emptiness check is better than int worst = 0 followed by a loop, because a placeholder that is wrong for the domain — 0 as a "worst response" when 0 means OKAY — turns an uninitialised-read bug into a wrong-answer bug. §2's worstRespFixed returns 0 for an empty input, and whether that is correct depends on what the caller means by "worst of nothing"; the honest version returns std::optional<int>, per Chapter 22.7 §2.
Declare at the point of first use. The habit of declaring everything at the top of a function is where this bug lives; a variable declared where its value is known cannot be read before it has one.
Class members need an initialiser too, and the cleanest place is the member declaration:
struct Txn {
std::uint32_t addr = 0;
int cycles = 0;
int resp = 0;
};That makes Txn t; safe without relying on every constructor and every caller remembering the braces.
8. The Honest Exception
There is one case where leaving memory uninitialised is deliberate: a large buffer you are about to fill completely, where zeroing first is measurable waste.
std::vector<char> buf(1 << 20); // zeroes a megabyte
std::vector<char> buf2; buf2.reserve(1 << 20); // allocates, does not zero, size() == 0The second form is the right one, and note it is still not an uninitialised read: reserve leaves size() at zero, so there is nothing to read until you append. The dangerous form is a raw array or a resize followed by partial filling and a full read.
If you genuinely need uninitialised storage — a DMA target, a record you are about to read bytes into — say so at the point of declaration with a comment naming what will fill it, and keep the unfilled region unreadable by tracking a length. Chapter 23.6 §4's gcount() check is that discipline for a file read.
9. The Table
| the question | the answer | measured |
|---|---|---|
| is reading an uninitialised value UB | yes (except unsigned char/std::byte) | §1 |
| are statics affected | no — they are zero-initialised | §1 |
| is the value random | no — stable per build, differs between builds | §3 |
| does the debug build expose it | sometimes; -O0 returned a plausible 3 | §2 |
| does the release build expose it | sometimes; -O2 returned a clean 0 | §2 |
| does building both find it | not reliably — each build hid a different defect | §7 callout |
does -Wall -Wextra catch it | no — 0 warnings | §4 |
| what does | -Wconditional-uninitialized | §4, 1 warning |
| is there a sanitiser for it | MSan — unavailable on arm64 macOS | §5 |
| what does ASan or UBSan say | nothing; not their bug class | §5 |
| what removes the class | an initialiser at the declaration | §7 |
10. What to Actually Do
- Initialise every variable where you declare it. This removes the class rather than detecting instances.
- Declare at the point of first use, not at the top of the function.
- Give class members default initialisers in the declaration, so
Txn t;is safe. - Use
T x{}rather thanT x;for aggregates, which also zeroes padding — Chapter 23.6 §3. - Prefer initialising from the real value over a placeholder, and use
std::optionalwhen "no value yet" is a real state. - Turn on
-Wconditional-uninitializedin the debug configuration and accept the noise on new code. - Do not rely on
-Wall -Wextrafor this: measured, 0 warnings. - Do not rely on testing, and do not rely on comparing the two build configurations — each hid a different defect here.
- Use
reserve, not a sized-then-partially-filled buffer, when you want to avoid zeroing. - Check the value in the debugger when you suspect it:
frame variableat the point of first use settles it in one step.
11. Mistakes Worth Making Once
An accumulator declared without a value, assigned only inside a loop. Measured: a plausible 3 at -O0.
Concluding from -O0 that it works. The other defect in the same program looked correct at -O2.
Explaining the stable value as the previous call's result. I tested that and it is wrong — the empty calls returned 1, 1, 1 between calls returning 3 and 9.
Trusting -Wall -Wextra. Zero warnings on a file with two instances.
Assuming a sanitiser has you covered. The one for this bug does not build on arm64 macOS.
Sample s; where you meant Sample s{};. Half the members, and all the padding, are indeterminate.
A placeholder that is valid in the domain. int worst = 0 where 0 means OKAY converts a UB bug into a silent wrong answer.
Declaring everything at the top of a function. That is the habit the bug needs.
12. Interview Relevance
The frozen curriculum marks this chapter High, and it is usually asked as "why does this bug only appear in the release build?" — which is a leading question, and the strongest answer declines the premise. Measured, the direction is not fixed: an uninitialised accumulator returned a plausible value at -O0 and garbage at -O2, while a half-assigned struct in the same program showed garbage at -O0 and clean zeros at -O2. So the bug appears in whichever build does not happen to leave a convenient value there.
The follow-up worth being ready for is "how would you find it?", and the honest answer is a short list with a gap in it: -Wconditional-uninitialized catches it at compile time, -Wall -Wextra do not, MSan is the right tool and is unavailable on this platform, and ASan and UBSan are not looking for it. That gap is the argument for initialising at the declaration — not as a style rule but because the detection profile is too thin to rely on.
If you can add that the value is stable per build and differs between builds, and that this is what makes testing vindicate the bug, you have said the thing that makes the class dangerous rather than merely wrong.
13. What You Should Be Able to Say Now
- That an uninitialised automatic variable holds an indeterminate value, that reading it is undefined behaviour, and that statics are zero-initialised and unaffected.
- That the value is not random: measured stable 3/3 runs within a program, and different in another program containing the same function.
- That a hypothesis as plausible as "it holds the previous call's result" is false here, and how the interleaved transcript refutes it.
- That the two defects in one program were vindicated by different builds — a plausible 3 at
-O0, clean zeros at-O2— so neither configuration, and not the comparison of both, is a reliable detector. - That
-Wall -Wextraproduce 0 warnings, and-Wconditional-uninitializedproduces 1, and why the conditional case is harder than the unconditional one. - That MSan is unavailable on arm64 macOS, and that ASan and UBSan do not target this bug class.
- That
frame variableat the first use shows the value directly. - That the remedy is to initialise at the declaration, which removes the class rather than detecting instances — and why that argument is different from a style preference.
- That
T x{}value-initialises including padding, and thatreserveis the way to avoid zeroing a large buffer.
14. Exercises
Exercise 1 — both directions. Build §2's program at -O0, -O1 and -O2 and reproduce the table. Confirm that one defect looks correct in each of two builds.
Exercise 2 — stability. Run the -O0 build ten times and confirm the value does not change. Then copy the function into a different program and confirm the value does.
Exercise 3 — the rejected hypothesis. Reproduce §3's interleaved transcript. Explain why it refutes the stack-reuse explanation, and propose an experiment that would distinguish the remaining candidates.
Exercise 4 — the flags. Run the file under -Wall, -Wall -Wextra, and with -Wconditional-uninitialized. Then add -Weverything and read — do not count — what else it says.
Exercise 5 — the debugger. Set a breakpoint on the loop line and print the variable. Then set a watchpoint on it and confirm that nothing writes to it before the read.
Exercise 6 — remove the class. Fix both defects by initialising at the declaration. Then decide whether worstResp of an empty range should return 0 or std::optional<int>, and say why.
15. Summary
- An uninitialised automatic variable holds an indeterminate value and reading it is undefined behaviour — Chapter 25.1's full sense, not merely a wrong number. Statics are zero-initialised and exempt.
- The value is stable, which is what makes the bug dangerous. Measured 3/3 runs at
-O0in one program and 1/1/1 in another containing the same function. - The plausible mechanism is not the mechanism. "It holds the previous call's result" is refuted by a transcript where the empty calls return 1, 1, 1 between calls returning 3 and 9.
- Two defects in one program were vindicated by opposite builds:
worstResp({})returned a plausible 3 at-O0and garbage at-O2;makeBad()showed garbage at-O0and clean zeros at-O2. So building both configurations does not reliably find this. - The detection profile is thin.
-Wall -Wextra: 0 warnings.-Wconditional-uninitialized: 1, saying "may be uninitialized". MSan, the tool built for this, does not build on arm64 macOS. ASan and UBSan do not target it. - So initialise at the declaration. That removes the class — there is no indeterminate value for any path to read — rather than making an instance less likely, and it is the right response whenever the detectors are partial and disagree.
- Use
T x{}for aggregates (padding included), give class members default initialisers, prefer initialising from the real value over a placeholder, and usereserverather than a sized-then-partially-filled buffer.
Chapter 25.3 takes the next entry in Chapter 25.1 §6's catalogue, and finds that the two commonest out-of-bounds bugs have no detector in common.
Standards & specifications
- Governing standard
- ISO/IEC 14882 (the C++ language standard)(opens ISO/IEC (JTC 1/SC 22/WG 21) in a new tab)
Defines the C++ language and its standard library — the type system and templates, the object model and object lifetime, initialisation and overload resolution, the memory model and the containers, algorithms and utilities the library provides. Like C's standard it is a FAMILY of editions (C++98/03, C++11, C++14, C++17, C++20, C++23) and the editions are NOT interchangeable: a feature this curriculum relies on may be unavailable, differently specified or merely proposed in another edition, so each lesson names the edition its claim depends on. It does not define any foreign-function interface to a hardware description language, nor what a compiler actually emits, nor platform behaviour on real silicon — those are the implementation's and the device's contracts. Working drafts published by WG21 are freely available and are what most engineers read day to day; a draft is not the published standard.
This page also covers RTL structure, verification approach and debugging technique. Those are engineering practice built on the standard, not requirements the standard itself imposes.
Where this fits
Part of the C++ Programming curriculum.
