Zero wins is not zero games¶
Level: 201 · working knowledge
One line: Returning Result proves the author knew the function could fail; it does not prove they guarded the input that actually has no answer — and the one they missed leaves through Ok as a plausible number.
Here is a function of the kind that turns up in every error-handling chapter, reconstructed. A global table holds each team's wins and losses; the caller wants a winning ratio:
fn winning_ratio(team: &String) -> Result<f32, &str> {
if let Some(&(wins, losses)) = TEAMS.get(team.as_str()) {
if wins == 0 {
Ok(wins as f32)
} else {
Ok(((wins + losses) as f32) / wins as f32)
}
} else {
Err("Invalid team")
}
}
It compiles. It returns a Result. It handles the missing key. It even has a special case for the awkward input. And on a table holding Bears 9-8, Lions 0-17, and an expansion team at 0-0, it prints this:
Two of those four lines are wrong, and the Result did not stop either one.
The bug the type system was never going to catch¶
A team that goes 9-8 has won nine of seventeen — 0.529. The listing computes (wins + losses) / wins, which is 17 / 9 = 1.889: games per win, the ratio upside down. Both operands are f32 and so is the return, so every version of this function type-checks equally well. Rust checks that you combined the right kinds of thing; it has no opinion about which way up you divided them.
Worth saying plainly because it is the honest limit of the whole toolkit: Result protects the shape of an answer, never its arithmetic. One line fixes it — and then the interesting bug is still there.
The guard is real. It is on the wrong condition.¶
Look again at the special case. The author knew this function was partial — undefined somewhere — because they wrote a branch for it. They just picked the wrong somewhere:
| Team | Record | wins == 0? |
Is there an answer? |
|---|---|---|---|
| Lions | 0-17 | yes | Yes — 0 / 17 is 0.0, and that is the true ratio |
| Expansion | 0-0 | yes | No — no games played, so no fraction exists |
Zero wins is unusual, and it caught the eye. Zero games is undefined, and it did not. Both land in the same branch, which returns Ok(wins as f32) — a hard-coded 0.0 dressed as a computed result. So the one input in the table with no answer is the one input that got a confident number, and it is indistinguishable downstream from a team that genuinely lost every game.
That is the shape worth memorising, because it long outlives this listing:
When a guard fires, ask what the branch returns. If it returns a value rather than an error, the function has stopped being partial-but-honest and started fabricating.
0,0.0,-1and""are the four values that fabrication usually wears.
The tell is that the guard and the failure are answering different questions. wins == 0 is a statement about a number being small. "This team has no ratio" is a statement about a fraction having no denominator. Guard the denominator.
The fix, at two sizes¶
Smallest version that stops it lying — right formula, guard moved to played == 0:
fn winning_ratio(team: &str) -> Result<f32, &'static str> {
let &(wins, losses) = TEAMS.get(team).ok_or("no such team")?;
let played = wins + losses;
if played == 0 {
return Err("that team has not played yet");
}
Ok(wins as f32 / played as f32)
}
Three smaller repairs rode along, and each is worth knowing on its own:
&str, not&String. A&Stringaccepts strictly less (every caller must own aStringfirst) and buys nothing.&'static str, not&str, in the error slot. With bare&strthe elided lifetime ties the error to the argument, so the message appears to borrow from the team name it is complaining about. The message is a literal; say so..ok_or(…)?instead ofif let … else. Crossing fromOptiontoResultis one call, and?keeps the happy path at the left margin.
Then the version the surrounding chapter is really arguing for. There are two ways to fail here, and a caller would act differently on each:
An unknown team is the caller's mistake — ask them to check the spelling. An unplayed season is the world's state — print a dash in the table and move on. With &str errors the caller can only print them; with the enum it can match and do the right thing for each. That is the same test the Option vs Result page uses, applied one level down: it is not just can the caller ask why?, it is would the caller do something different?
If you are coming from another language¶
- Python. The straight translation of the original raises nothing and returns
0.0for a team with no games, exactly as here — the bug is not Rust's and Rust does not catch it. What changes is where you put the answer: Python's habit isreturn Noneor a bareraise ValueError("bad team"), and both collapse the two failures into one.RatioErroris theValueErrorsubclass hierarchy you were unlikely to bother writing. - ABAP. This is the "returns 0 and sets no
sy-subrc" bug, which is the hardest kind to find in an ABAP report precisely because 0 is a legal amount. Moving the guard fromwins = 0toplayed = 0is the same discipline as checkingIF lines( lt_games ) = 0before dividing — except the compiler now forces the caller to open the result, so the zero cannot quietly reach a total.
Practice¶
Guard the input that has no answer. Write win_rate(wins, games) -> Result<f64, RateError> with the guard on wins == 0, and run it over three cases: some wins, no wins, and no games at all.
Find which line is wrong before reading on. The guard rejects a real answer (0 wins in 10 games is 0.0) and the one unanswerable input never reaches it. Then fix it twice: move the guard, and — better — make a Season whose constructor refuses games == 0, so the function returns a plain f64 and there is no failure left to report.
Solution
wrong_guard_kata.rs in full — pasted here by tools/run_examples.py from the file CI compiles and runs.
//! Kata solution: the guard is real, and it is on the wrong condition.
//!
//! rustc --edition 2024 wrong_guard_kata.rs -o /tmp/wgk && /tmp/wgk
#[derive(Debug)]
enum RateError {
NoGames,
}
/// The version that looks careful. It returns Result, it has a guard, and the
/// guard is on the input that always has an answer.
fn win_rate_guarded_wrongly(wins: u32, games: u32) -> Result<f64, RateError> {
if wins == 0 {
// "No wins? Bail out." — but 0 wins in 10 games is 0.0, a real answer.
return Err(RateError::NoGames);
}
Ok(wins as f64 / games as f64)
}
/// The fix, at the small size: guard the input that genuinely has no answer.
fn win_rate(wins: u32, games: u32) -> Result<f64, RateError> {
if games == 0 {
return Err(RateError::NoGames);
}
Ok(wins as f64 / games as f64)
}
/// The fix at the larger size: make the unanswerable input unrepresentable.
struct Season {
wins: u32,
games: u32, // invariant: > 0
}
impl Season {
fn new(wins: u32, games: u32) -> Option<Season> {
(games > 0 && wins <= games).then_some(Season { wins, games })
}
/// No Result at all: a Season that exists has a rate.
fn win_rate(&self) -> f64 {
self.wins as f64 / self.games as f64
}
}
fn main() {
let cases = [(7u32, 10u32), (0, 10), (0, 0)];
println!("Guarded on the wrong condition:");
for (w, g) in cases {
println!(" {w} wins in {g:>2} games -> {:?}", win_rate_guarded_wrongly(w, g));
}
println!(" Line 2 rejects a real answer (0 wins in 10 games IS 0.0), and");
println!(" line 3 — the one input with no answer — never reaches the guard");
println!(" at all, because 0 wins is caught first. It would have divided");
println!(" by zero and left through Ok as a plausible-looking number.");
println!("\nGuarded on the right one:");
for (w, g) in cases {
println!(" {w} wins in {g:>2} games -> {:?}", win_rate(w, g));
}
println!("\nAnd what the wrong guard would have returned, unguarded:");
println!(" 0 as f64 / 0 as f64 = {}", 0_f64 / 0_f64);
println!(" NaN. No panic — it sorts oddly, compares false against itself,");
println!(" and prints in a report as if it were a measurement.");
println!("\nThe fix that removes the question:");
println!(" Season::new(0, 0) -> {:?}", Season::new(0, 0).is_some());
println!(" Season::new(7, 10) -> rate {:.2}", Season::new(7, 10).unwrap().win_rate());
println!(" `win_rate` here returns f64, not Result. The unanswerable input");
println!(" was refused at the door, so there is no failure left to report.");
}
Verified output of wrong_guard_kata.rs — regenerated by tools/run_examples.py, never hand-typed.
Guarded on the wrong condition:
7 wins in 10 games -> Ok(0.7)
0 wins in 10 games -> Err(NoGames)
0 wins in 0 games -> Err(NoGames)
Line 2 rejects a real answer (0 wins in 10 games IS 0.0), and
line 3 — the one input with no answer — never reaches the guard
at all, because 0 wins is caught first. It would have divided
by zero and left through Ok as a plausible-looking number.
Guarded on the right one:
7 wins in 10 games -> Ok(0.7)
0 wins in 10 games -> Ok(0.0)
0 wins in 0 games -> Err(NoGames)
And what the wrong guard would have returned, unguarded:
0 as f64 / 0 as f64 = NaN
NaN. No panic — it sorts oddly, compares false against itself,
and prints in a report as if it were a measurement.
The fix that removes the question:
Season::new(0, 0) -> false
Season::new(7, 10) -> rate 0.70
`win_rate` here returns f64, not Result. The unanswerable input
was refused at the door, so there is no failure left to report.
The verified output¶
Every line below came from compiling and running wrong_guard.rs — including the broken first version, which is kept in the file precisely so the wrong numbers on this page are real ones.
Verified output of wrong_guard.rs — regenerated by tools/run_examples.py, never hand-typed.
──── Step 1: It compiles, it returns Result, and it is wrong twice
Bears -> Ok(1.8888888)
Lions -> Ok(0.0)
Expansion -> Ok(0.0)
Sharks -> Err("Invalid team")
Bears are 9-8. A winning ratio of 1.89 is not a ratio at all:
(wins + losses) / wins is games PER WIN. Both sides are f32, so
the compiler has nothing to object to. Arithmetic is on you.
──── Step 2: The guard is real — it is just on the wrong condition
Lions 0-17 wins == 0, and 0/17 = 0.000 is a TRUE answer
Expansion 0-0 no games played, so there is NO answer to give
The author guarded `wins == 0`, which is unusual but answerable,
and left `wins + losses == 0`, which is undefined, unguarded.
So the one input with no answer is the one that got a number.
──── Step 3: Move the guard to the undefined case
Bears -> Ok(0.5294118)
Lions -> Ok(0.0)
Expansion -> Err("that team has not played yet")
Sharks -> Err("no such team")
Lions keep their 0.0, because 0 wins in 17 games IS zero.
Expansion now fails, because 0 wins in 0 games is not a number.
──── Step 4: Two failures the caller treats differently
Bears -> 0.529
Expansion -> print a dash, not a 0: the season has not started for that team
Sharks -> typo, ask again: "Sharks" is not a team we track
A bad lookup is the caller's mistake; an unplayed season is the
world's. Only a named E lets the caller tell them apart.
──── Step 5: What would have caught it
Bears expected 0.5294 got 0.5294 ok
Lions expected 0.0000 got 0.0000 ok
Expansion is_err -> true
Not the compiler: every version above type-checks. What catches
an inverted formula is one hand-computed expectation, and what
catches a missing guard is a test at the boundary — 0 games.
Run it yourself:
Traps¶
- A guard whose branch returns a value. The whole page. If the condition means "there is no answer", the branch must return
Err(orNone), never a stand-in. - Guarding the numerator when the denominator is the problem.
wins == 0versuswins + losses == 0. Ask which quantity would make the arithmetic meaningless, and test that. - Trusting a signature because it says
Result. AResultreturn is evidence the author thought about failure once, not that they enumerated it. Read theOkarm before you believe it. 0as a legal value and as a sentinel in the same function. Once0.0can mean both "lost every game" and "no games", no caller can recover the difference. If a type must carry both, that is whatOption<f32>inside theOkis for.- A test suite with no boundary row. Bears and Lions both pass against the broken guard. Only the 0-0 team distinguishes the two versions, and it is the row nobody thinks to write.
See also¶
- Partial functions — the general case: a function undefined over part of its input range, and what widening the return type buys
OptionvsResult— which of the two to reach for, and designing theE- Returning
Noneon error — the neighbouring downgrade: a real reason thrown away rather than a fake answer invented - What a panic costs — the other end of the same branch: what it costs when the wrong path wins loudly instead of quietly, and why unwinding tidies your memory but not your half-done work
Po polsku¶
Ta strona pokazuje uczciwą granicę całego zestawu narzędzi: Result chroni kształt odpowiedzi, nigdy jej arytmetyki. Bears mają bilans 9-8, czyli dziewięć wygranych z siedemnastu, w zaokrągleniu 0,529, a funkcja drukuje Ok(1.8888888), bo liczy (wins + losses) / wins, czyli mecze na jedną wygraną, ułamek do góry nogami. Obie strony dzielenia są typu f32 i zwracany typ też, więc kompilator nie ma tu absolutnie nic do powiedzenia; sprawdza, czy łączysz właściwe rodzaje rzeczy, a nie którą stroną. Stąd wniosek wart zapamiętania wbrew popularnemu w polskich materiałach skrótowi „Rust wymusza obsługę błędów”: Result w sygnaturze jest dowodem, że autor raz pomyślał o porażce, a nie że wyliczył wszystkie przypadki. Zanim uwierzysz takiej funkcji, przeczytaj gałąź Ok.
Właściwa pułapka jest subtelniejsza: zabezpieczenie (guard) istnieje, tylko stoi przy złym warunku. wins == 0 jest nietypowe i dlatego rzuciło się w oczy — ale ma odpowiedź: Lions z bilansem 0-17 mają prawdziwy wynik 0.0. Niezdefiniowane jest wins + losses == 0 i tego nikt nie sprawdził, więc jedyne wejście bez odpowiedzi (drużyna 0-0) dostaje pewną siebie liczbę, nieodróżnialną dalej w programie od drużyny, która naprawdę przegrała wszystko. Reguła, którą warto stąd wynieść, przeżyje ten konkretny listing: kiedy warunek się spełnia, popatrz, co zwraca jego gałąź — jeśli wartość zamiast błędu, funkcja przestała być uczciwie częściowa i zaczęła zmyślać, a zmyślanie chodzi zwykle w czterech kostiumach: 0, 0.0, -1 i "". I pilnuj mianownika, nie licznika: „ta drużyna nie ma wskaźnika zwycięstw” to zdanie o braku mianownika, a nie o małej liczbie.
Dla czytelnika przyzwyczajonego do wyjątków warto dopowiedzieć rzecz, która w Ruscie zaskakuje w obie strony: dzielenie całkowitoliczbowe przez zero panikuje, ale 0.0 / 0.0 na f64 nie panikuje wcale — daje NaN, które porównane samo ze sobą zwraca fałsz, sortuje się dziwacznie i wchodzi do raportu jak wynik pomiaru. Nie ma więc żadnego huku, na który można by czekać. Lepszym ruchem niż samo przesunięcie warunku jest ten większy, pokazany w rozwiązaniu: Season::new odmawia przyjęcia games == 0, dzięki czemu win_rate zwraca zwykłe f64 i nie ma już czego raportować — wejście bez odpowiedzi odrzucono przy drzwiach. I na koniec rzecz o testach: przez zepsutą wersję przechodzą zarówno Bears, jak i Lions. Odróżnia obie wersje wyłącznie wiersz brzegowy 0-0, czyli dokładnie ten, którego nikt nie pisze.
Szukaj po polsku: dzielenie przez zero · przypadki brzegowe · obsługa błędów w Ruscie · rust integer division by zero panic · rust f64 NaN comparison · rust make illegal states unrepresentable