Skip to content

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:

Real output — Apple clang 21.0.0, x86_64 macOS: clang -std=c17 -Wall -O0 uninitialized_reads.c
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:

Real runs — the same two binaries, five times each
-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.

Real output — clang -fsanitize=memory uninitialized_reads.c
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.

[5, 0, 4] -> 9
[]        -> 0

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:

Real rustc output — read_before_assign.rs, --edition 2024
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 NameError when 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_total is 0 before you write a line. That convention costs you something else, though, and it is the thing Option exists to fix — you cannot tell "nobody has set this yet" from "somebody set it to zero". Rust's answer to the ABAP situation is not let total; but Option<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

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