Initial values: when Option is the wrong tool¶
Level: 201 · working knowledge
One line: For "a variable with no value yet", Rust usually does not want Option at all — it lets you declare without initializing and proves you assigned before use.
This is the one job on std::option's list ↗ where the obvious reading leads most newcomers astray, so it is worth separating the case Option genuinely serves from the case it only appears to.
The pattern you will write first¶
let mut initial_value: Option<i32> = None;
initial_value = Some(42);
match initial_value {
Some(value) => println!("The initial value is: {value}"),
None => println!("No initial value"),
}
This works. But count what it costs: a mut, a wrapper, and a match over a case that — by the time we look — cannot happen. The None arm is unreachable and the compiler cannot tell you so, because you asked for a type where absence is legal.
The habit comes from languages where a variable must be given something at declaration, so you reach for a "nothing yet" placeholder: null, None, -1, "". Rust does not have that constraint.
What Rust actually offers¶
let settled: i32; // declared, not initialized — and not `mut`
if flag {
settled = 42;
} else {
settled = 7;
}
println!("{settled}"); // fine: every path assigned it exactly once
Rust does not require a value at declaration. It requires one before use, and it proves that at compile time. Miss a branch and you get error[E0381]: used binding is possibly-uninitialized; assign twice and the missing mut catches you. So settled is a plain i32 — no wrapper, no mut, no unreachable arm — and it is more strongly checked than the Option version, not less.
Most of the time you do not even need the statement form:
The test: if every path assigns before first use, the answer is deferred initialization. Option is for when that is not true.
When Option is genuinely right¶
The absence survives to the point of use. A setting nobody configured is still missing when you read it — None there is not "not assigned yet", it is "the user did not say", which is a fact about the world and belongs in the type:
let configured: Option<u16> = lookup("port").and_then(|s| s.parse().ok());
let port = configured.unwrap_or(8080);
There is no honest starting value. A running "best so far" has nothing sensible to start at — 0 is a lie for an empty list and wrong the moment negatives are allowed:
This is why iter().max() returns Option rather than picking a sentinel, and in real code you would just call it. The pattern still matters for folds that have no library equivalent.
It is a struct field. A field cannot be deferred — a struct is either fully built or does not exist — so "may not be set yet" has to be Option in the type. That case has its own page.
It is a static. Same reason, and the modern tool is OnceLock:
static GREETING: OnceLock<String> = OnceLock::new();
GREETING.set("hello".to_string()).expect("first set always succeeds");
Prefer it over a mut Option in a global: it guarantees the value is written exactly once, and it is thread-safe, neither of which a bare Option promises.
Practice¶
Declare it, then prove it. Write sample_size_for(rows: usize) -> usize whose value is decided in three branches — no Option, and no mut. Let the compiler be the thing that guarantees the variable is set before it is read.
Write it with let mut sample: Option<usize> = None; too, and read what the compiler says about the None you gave it. Then delete one branch from the plain version and read E0381. One of those two messages is a warning about a value you never needed; the other is a proof you cannot fake.
Solution
initial_values_kata.rs in full — pasted here by tools/run_examples.py from the file CI compiles and runs.
//! Kata solution: a value that is not ready yet is not the same as a missing one.
//!
//! rustc --edition 2024 initial_values_kata.rs -o /tmp/ivk && /tmp/ivk
/// Declared, not initialized. Every path assigns before the read, and the
/// compiler checks that — no `Option`, no `mut`, no unreachable arm.
fn sample_size_for(rows: usize) -> usize {
let sample: usize;
if rows == 0 {
sample = 0;
} else if rows < 10 {
sample = rows; // a tiny table: read all of it
} else {
sample = rows / 2 + 1;
}
sample
}
/// The `Option` version of the same job, for contrast: one more state to read,
/// one more way to be wrong, and an arm that cannot happen.
///
/// The allow is part of the lesson — without it the compiler warns that the
/// `None` is "never read", which is it telling you the initial value was dead
/// on arrival.
#[allow(unused_assignments)]
fn sample_size_awkward(rows: usize) -> usize {
let mut sample: Option<usize> = None;
if rows == 0 {
sample = Some(0);
} else if rows < 10 {
sample = Some(rows);
} else {
sample = Some(rows / 2 + 1);
}
sample.expect("every branch above assigns — but nothing checks that claim")
}
/// Where `Option` is genuinely right: the value may never arrive at all.
struct Job {
name: &'static str,
/// None until the job finishes. Not "not ready yet" — "may never happen".
finished_at: Option<&'static str>,
}
fn main() {
println!("Declared without a value, proved assigned before use:");
for rows in [0, 7, 461] {
println!(" sample_size_for({rows:>3}) -> {}", sample_size_for(rows));
}
println!("\nThe Option version returns the same numbers…");
for rows in [0, 7, 461] {
println!(" sample_size_awkward({rows:>3}) -> {}", sample_size_awkward(rows));
}
println!(" …and pays for it with a state that cannot occur, an `expect`");
println!(" whose claim nothing verifies, and a `mut` it did not need. The");
println!(" compiler even warns that the initial None is never read — the");
println!(" source has to #[allow] it to compile quietly.");
println!("\nWhere Option earns its place — absence that outlives the function:");
let running = Job { name: "nightly-reindex", finished_at: None };
let done = Job { name: "nightly-backup", finished_at: Some("03:12") };
for j in [&running, &done] {
match j.finished_at {
Some(at) => println!(" {:<18} finished at {at}", j.name),
None => println!(" {:<18} still running", j.name),
}
}
}
Verified output of initial_values_kata.rs — regenerated by tools/run_examples.py, never hand-typed.
Declared without a value, proved assigned before use:
sample_size_for( 0) -> 0
sample_size_for( 7) -> 7
sample_size_for(461) -> 231
The Option version returns the same numbers…
sample_size_awkward( 0) -> 0
sample_size_awkward( 7) -> 7
sample_size_awkward(461) -> 231
…and pays for it with a state that cannot occur, an `expect`
whose claim nothing verifies, and a `mut` it did not need. The
compiler even warns that the initial None is never read — the
source has to #[allow] it to compile quietly.
Where Option earns its place — absence that outlives the function:
nightly-reindex still running
nightly-backup finished at 03:12
The verified output¶
Verified output of initial_values.rs — regenerated by tools/run_examples.py, never hand-typed.
──── Step 1: The pattern you will see written first
The initial value is: 42
It works. But notice what it costs: a `mut`, a wrapper, and a `match`
over a case that — by the time we look — cannot happen.
rustc agrees, and says so: "value assigned to `initial_value` is
never read" — the placeholder was dead the moment it was written.
──── Step 2: What Rust actually offers: declare now, assign later
settled = 42
same = 42
Rust does not require a value at DECLARATION — only before USE, and
it proves that at compile time. So `settled` is never Option-shaped,
never mut, and no branch can forget to set it.
──── Step 3: When Option IS right: absence survives to the point of use
lookup("log_level") -> Some("debug")
lookup("port") -> None
port in use -> 8080
Here None is not 'not yet assigned', it is 'the user did not say'.
That fact is still true when we read it — so it belongs in the type.
──── Step 4: …and for a running 'best so far', where there is no sensible start
best of [3, 9, 4] -> Some(9)
best of [] -> None
Starting at 0 would be a LIE for an empty list, and wrong for negatives.
(In real code: scores.iter().max() — which returns Option for this reason.)
scores.iter().max() -> Some(9)
──── Step 5: Initialize once, later, globally: OnceLock
before set: None
after set: Some("hello")
second set: true
A `static` cannot be deferred — so this is the case that genuinely
needs an 'empty until later' box. OnceLock is that box, and unlike a
mut Option it guarantees the value is written exactly once.
Run it yourself:
rustc --edition 2024 17_Option_and_Result/initial_values/examples/initial_values.rs -o /tmp/iv && /tmp/iv
See also¶
Optionfields — the case where deferring is not available- Partial functions — why
iter().max()returnsOptionin the first place OnceLock↗ — initialize-once, for statics and lazy globals
Po polsku¶
W większości języków, z których przychodzi się do Rusta, deklaracja zmiennej domaga się czegoś po prawej stronie — stąd odruch, żeby napisać let mut initial_value: Option<i32> = None;, a prawdziwą wartość wstawić dopiero za chwilę. W Ruscie ten odruch jest zbędny: deklaracja nie wymaga wartości, wymaga jej dopiero użycie, a to, czy każda gałąź przypisała coś przed odczytem, sprawdza kompilator. let settled: i32; — bez mut, po jednym przypisaniu na każdej gałęzi; jeśli o którejś zapomnisz, dostaniesz error[E0381]: used binding is possibly-uninitialized. To jest odroczona inicjalizacja (deferred initialization) i warto ją tak nazywać, żeby nie mylić jej z brakiem wartości.
Rachunek wypada wyraźnie na niekorzyść Option. Wersja z opakowaniem kosztuje mut, jeden typ więcej i ramię None w matchu, do którego nigdy się nie dochodzi, a expect po niej to obietnica, której nic nie weryfikuje. Wersja bez Option jest sprawdzona mocniej, nie słabiej: brak mut wyklucza drugie przypisanie, a E0381 wyklucza brak pierwszego. Kompilator zresztą sam to podpowiada — ostrzega, że przypisana na starcie wartość „is never read”, czyli że zastępcza wartość początkowa była martwa już w chwili zapisu. Test do zapamiętania jest jednozdaniowy: jeśli każda ścieżka przypisuje wartość przed pierwszym odczytem, to jest odroczona inicjalizacja, a nie brak wartości.
Option zarabia na siebie wtedy, gdy brak dożywa momentu odczytu. Nieustawiony parametr konfiguracji to nie „jeszcze nieprzypisane”, tylko „użytkownik nic nie podał” — fakt o świecie, więc jego miejsce jest w typie (configured.unwrap_or(8080)). Podobnie tam, gdzie nie ma uczciwej wartości początkowej: dla „najlepszego dotąd” zero jest kłamstwem przy pustej liście i błędem przy liczbach ujemnych — dlatego iter().max() zwraca Option, zamiast wybierać wartownika (sentinel value) w rodzaju -1 czy "". Zostają jeszcze dwa miejsca, w których odroczenie po prostu nie istnieje: pole struktury (struktura jest albo w pełni zbudowana, albo nie ma jej wcale) oraz static — a dla tego drugiego sięgaj po OnceLock, który gwarantuje zapis dokładnie raz i jest bezpieczny wątkowo, czego mut Option nie obiecuje.
Szukaj po polsku: odroczona inicjalizacja · zmienna niezainicjalizowana · wartość wartownicza · rust E0381 possibly-uninitialized · rust OnceLock static