Uninitialized reads¶
Level: 201 · for C and C++ programmers
One line: C lets you declare a variable and read it before anything assigns to it; Rust lets you declare it too, and checks every path that reaches the read.
The program¶
#include <stdio.h>
int main(void) {
int total; /* never assigned */
printf("%d\n", total);
return 0;
}
What it did¶
clang does warn, and the warning is a good one:
uninitialized_reads.c:5:20: warning: variable 'total' is uninitialized when used here [-Wuninitialized]
5 | printf("%d\n", total);
| ^~~~~
uninitialized_reads.c:4:14: note: initialize the variable 'total' to silence this warning
4 | int total; /* never assigned */
| ^
| = 0
1 warning generated.
It is a warning, so the program builds and runs. Five runs of each build:
-O0 1252390792 1252390792 1252390792 1252390792 1252390792
-O2 -1090125600 -1278684960 -1122090784 -1287466784 -1201487648
The -O0 build is the one that teaches the wrong lesson. It reads whatever the last function left at that stack slot, and since nothing else in this program disturbs it, the number is stable — stable enough to look like a value, stable enough to survive a code review, and stable enough that a test asserting on it passes all week. The -O2 build never touches the stack at all; it prints whatever happens to be in the register, which moves with the address-space layout.
Two builds of one program, and only one of them looks broken.
Why the standard allows it¶
The value is indeterminate, and reading an indeterminate value is undefined behaviour ↗ except in narrow cases such as unsigned char. Undefined does not mean "some number you did not choose" — it means the compiler may assume the read never happens, and optimize on that assumption. The -O2 column above is a mild demonstration; the sharp one is on the signed overflow page, where the assumption changes the answer to a comparison.
Sanitizers do not help here on this machine: -fsanitize=address runs the program and prints the same number, and MemorySanitizer — the one that tracks uninitialized reads — is not built for x86_64-apple-darwin.
clang: error: unsupported option '-fsanitize=memory' for target 'x86_64-apple-darwin25.5.0'
What Rust does instead¶
Declaring without a value is allowed. What is checked is the read:
let total; // fine — no value yet
if ballots.is_empty() {
total = 0;
} else {
total = ballots.iter().sum();
}
println!("{total}"); // fine — every path assigned
Verified output of uninitialized_reads.rs — regenerated by tools/run_examples.py, never hand-typed.
This is worth separating from "Rust initializes everything for you", which it does not. Late initialization is a normal thing to write, and it is how you build a value whose contents depend on a branch without paying for a placeholder first.
The refusal¶
Delete either branch and the read stops compiling:
error[E0381]: used binding `total` isn't initialized on any path leading to this point
--> read_before_assign.rs:5:16
|
4 | let total;
| ----- binding declared here but left uninitialized
5 | println!("{total}");
| ^^^^^ `total` used here but it isn't initialized on any path leading to this point
|
help: consider assigning a value
|
4 | let total = 42;
| ++++
"on any path leading to this point" is the whole difference from -Wuninitialized. The C warning is a best effort by the optimizer's dataflow analysis and goes quiet as soon as the assignment is hidden behind a function call it cannot see through. E0381 is a rule the language guarantees, so there is no arrangement of branches that gets a read past it.
If you are coming from another language¶
- Python — a name that was never assigned raises
NameErrorwhen you touch it, so the bug exists but announces itself, and it announces itself at run time on the branch that reached it. Rust asks the same question at compile time, about all branches at once. What genuinely transfers is the mental model: in both languages a binding is a name, not a box that exists in advance holding rubbish. - ABAP — this bug does not exist, because every data object is set to its type's initial value:
lv_totalis0before you write a line. That convention costs you something else, though, and it is the thingOptionexists to fix — you cannot tell "nobody has set this yet" from "somebody set it to zero". Rust's answer to the ABAP situation is notlet total;butOption<i32>, where the two cases are different values of a type the compiler makes you tell apart. See null dereference.
Practice¶
Declare it, then assign it on every path. Write a function with let label; and three branches that each assign it, and confirm it compiles. Then delete one branch and record the error.
Then rewrite the whole thing so the deferred binding is unnecessary, and say what property of the language makes that possible. Finish with the two things this flow analysis tracks besides initialisation, and the escape hatch for a genuinely uninitialised buffer.
Solution
uninitialized_reads_kata.rs in full — pasted here by tools/run_examples.py from the file CI compiles and runs.
//! Kata solution: declare it, and let the compiler check every path.
//!
//! rustc --edition 2024 uninitialized_reads_kata.rs -o /tmp/urk && /tmp/urk
fn classify(n: i32) -> &'static str {
// Declared without a value -- legal, and idiomatic. The compiler checks
// that every path reaching the read assigns exactly once first.
let label;
if n < 0 {
label = "negative";
} else if n == 0 {
label = "zero";
} else {
label = "positive";
}
label
}
fn main() {
println!("THE C SHAPE");
println!(" int x; if (cond) x = 1; printf(\"%d\", x);");
println!(" On the path where cond is false, x holds whatever was on the");
println!(" stack. -Wall may warn; it also may not, once the assignment is");
println!(" two functions away. And reading an uninitialised value is");
println!(" UNDEFINED, not merely unpredictable -- so the optimizer may");
println!(" assume the path never happens.");
println!();
println!("RUST LETS YOU DECLARE WITHOUT ASSIGNING TOO");
for n in [-5, 0, 7] {
println!(" classify({n:>2}) = {:?}", classify(n));
}
println!();
println!(" `let label;` with no value is fine. What the compiler checks is");
println!(" that every path to the READ assigns first -- so deleting the");
println!(" `else` arm above is E0381, 'used binding is possibly-");
println!(" uninitialized', naming the branch that skipped it.");
println!();
println!("AND THAT IS WHY THIS IS AN EXPRESSION LANGUAGE");
let n = 7;
let label = if n < 0 { "negative" } else if n == 0 { "zero" } else { "positive" };
println!(" let label = if ... {{ ... }} else {{ ... }}; -> {label:?}");
println!(" The if is an expression, so the same code needs no deferred");
println!(" binding at all -- every branch must produce a value of the same");
println!(" type, and a missing else is a type error rather than a missing");
println!(" assignment. Most of the C pattern disappears rather than being");
println!(" checked.");
println!();
println!("THE FLOW ANALYSIS IS NOT ONLY ABOUT INITIALISATION");
println!(" The same pass tracks MOVES: a value moved out on one branch and");
println!(" used after the join is 'possibly moved', reported the same way.");
println!(" Initialisedness and ownership are one analysis, which is why");
println!(" they produce errors that read alike.");
println!();
println!("THE ESCAPE HATCH, AND WHAT IT ADMITS");
println!(" MaybeUninit<T> exists for the cases that genuinely need an");
println!(" uninitialised buffer -- reading into it from the OS, say. Every");
println!(" read from it is `unsafe`, and the word is the point: you are");
println!(" asserting the initialisation the compiler could not check.");
assert_eq!(classify(-1), "negative");
assert_eq!(classify(0), "zero");
assert_eq!(label, "positive");
}
Verified output of uninitialized_reads_kata.rs — regenerated by tools/run_examples.py, never hand-typed.
THE C SHAPE
int x; if (cond) x = 1; printf("%d", x);
On the path where cond is false, x holds whatever was on the
stack. -Wall may warn; it also may not, once the assignment is
two functions away. And reading an uninitialised value is
UNDEFINED, not merely unpredictable -- so the optimizer may
assume the path never happens.
RUST LETS YOU DECLARE WITHOUT ASSIGNING TOO
classify(-5) = "negative"
classify( 0) = "zero"
classify( 7) = "positive"
`let label;` with no value is fine. What the compiler checks is
that every path to the READ assigns first -- so deleting the
`else` arm above is E0381, 'used binding is possibly-
uninitialized', naming the branch that skipped it.
AND THAT IS WHY THIS IS AN EXPRESSION LANGUAGE
let label = if ... { ... } else { ... }; -> "positive"
The if is an expression, so the same code needs no deferred
binding at all -- every branch must produce a value of the same
type, and a missing else is a type error rather than a missing
assignment. Most of the C pattern disappears rather than being
checked.
THE FLOW ANALYSIS IS NOT ONLY ABOUT INITIALISATION
The same pass tracks MOVES: a value moved out on one branch and
used after the join is 'possibly moved', reported the same way.
Initialisedness and ownership are one analysis, which is why
they produce errors that read alike.
THE ESCAPE HATCH, AND WHAT IT ADMITS
MaybeUninit<T> exists for the cases that genuinely need an
uninitialised buffer -- reading into it from the OS, say. Every
read from it is `unsafe`, and the word is the point: you are
asserting the initialisation the compiler could not check.
See also¶
- A type is not a constructor — the same rule where it bites hardest, on a struct with fields still to fill
SomeandNone— "not set yet" as a value you can hold and pass around- Signed overflow — the page where "the compiler may assume it never happens" stops being abstract
- The bugs Rust is a reply to — the other eight
Po polsku¶
C pozwala zadeklarować zmienną i przeczytać ją, zanim cokolwiek do niej trafi. Rust też pozwala zadeklarować ją bez wartości — różnica polega na tym, że sprawdza każdą ścieżkę prowadzącą do odczytu i odmawia, jeśli choćby jedna z nich nie przypisuje.
To rozróżnienie warto podkreślić, bo krąży o Ruscie fałszywe uproszczenie, że „wszystko trzeba od razu zainicjalizować". Nieprawda: let x; z przypisaniem w obu gałęziach if jest w pełni idiomatyczne i jest zwykłym wzorcem, nie obejściem. Kompilator nie żąda inicjalizacji w miejscu deklaracji — żąda dowodu, że w chwili odczytu wartość na pewno już tam jest.
Po stronie C to znów nie jest „śmieć w zmiennej", tylko zachowanie niezdefiniowane, więc skutki bywają dziwniejsze niż przypadkowa liczba — ze zmienną typu bool, która nie jest ani prawdą, ani fałszem, włącznie. Polskie kursy uczą tu odruchu int i = 0; przy każdej deklaracji. To dobry nawyk w C i zły w Ruscie: zerowa wartość-atrapa gasi analizę kompilatora i zamienia błąd kompilacji w cichy zły wynik.
Szukaj po polsku: zmienna niezainicjalizowana · zachowanie niezdefiniowane · rust E0381 · rust definite initialization