A typo becomes a binding¶
Level: 201 · working knowledge
One line: Under use Enum::*, a mistyped match arm is not an error — it is a fresh variable that matches everything, and no lint in the compiler will tell you.
Qualify the paths and the bug cannot be written:
match suit {
Suit::Heart => "the red one you draw",
Suit::Diamond => "the other red one",
Suit::Spade => "the pointy one",
Suit::Club => "the clover",
}
Suit::spade would be E0599 — no variant named spade — so the typo is caught at the only moment it is cheap. The rest of this page is what happens when the paths are not qualified.
The trap¶
fn describe(s: &Suit) -> String {
use Suit::*;
match s {
Heart => String::from("the red one you draw"),
Diamond => String::from("the other red one"),
Club => String::from("the clover"),
spade => String::from("the pointy one"), // lowercase: not the variant
}
}
Heart, Diamond and Club resolve to variants because of the glob import. spade resolves to nothing — and a pattern that is a plain identifier resolving to nothing is not an error in Rust. It is an irrefutable binding: a new variable named spade, bound to whatever came in, matching every value.
So the last arm is a _ in disguise. And with four variants and four arms it still returns the right answer for all four, which is why it survives review.
Nothing warns¶
Two lints look like they should catch it. Both are denied at the top of the kata program below, and the file still compiles clean:
unreachable_patternsfires only if a later arm is now dead. Put the typo last — where a fallback naturally goes — and there is no later arm.bindings_with_variant_name(E0170) fires when a binding is spelled exactly like a variant, which is what happens if you forget the import entirely.spadeis not spelled likeSpade, so it says nothing.
unused_variables is the one that usually saves you, and it comes with an unusually good suggestion:
warning: unused variable: `spade`
help: you might have meant to pattern match on the similarly named variant `Spade`
|
9 - spade => "spade",
9 + Suit::Spade => "spade",
But it only fires when the arm body ignores the binding. Write spade => format!("unhandled: {spade:?}"), or silence it, and the last warning goes with it. Zero warnings, wrong program.
What it actually costs you¶
Nothing, until somebody adds a variant. Then:
The qualified version fails the build and names the gap:
The glob version compiles, says nothing, and calls the joker a spade. The exhaustiveness guarantee — the entire reason the type is an enum and not a string — was gone from the moment the typo was written, and the build that should have found it passed.
Verified output of a_typo_becomes_a_binding.rs — regenerated by tools/run_examples.py, never hand-typed.
suit glob-imported (typo) qualified
--------------------------------------------------------------
Heart the red one you draw the red one you draw
Diamond the other red one the other red one
Spade the pointy one the pointy one
Club the clover the clover
Joker the pointy one the wild card <- wrong
Four rows agree. The fifth is the variant added after the typo, and it is the only evidence the bug ever produces.
What to do¶
Qualify the paths. Suit::Spade cannot be mistyped into something that compiles. This is the whole fix, and it costs five characters per arm.
If the arms are long enough that the repetition genuinely hurts, the safe compromise is to keep the import out of the function that matches:
A named import fails to compile the day a variant is renamed, and a typo in the import list is E0432 rather than a silent binding. It does not protect the arms, so it is a smaller guarantee than qualifying — but it is a real one, which use Suit::*; is not.
Do not rely on capitalization. Rust's convention makes Spade look privileged and spade look wrong, and the compiler does not agree: an unresolved Spadd is a binding too. Only resolution decides, never case.
Practice¶
Which defence actually works? Build the trap yourself, then measure the three candidates instead of assuming.
- Write an enum of ballot marks —
Scored(u8),Blank,Spoiled— and a function that glob-imports it and matches, with the last arm mistyped in lowercase. - Add
#![deny(unreachable_patterns, bindings_with_variant_name, unused_variables)]to the top of the file. Predict which of the three fires before you compile. - Add a fourth variant,
Reissued. Predict what the function now reports for it. - Rewrite the same match with qualified paths. Note which of the two edits — the lints or the paths — the compiler actually made you do.
Solution
a_typo_becomes_a_binding_kata.rs in full — pasted here by tools/run_examples.py from the file CI compiles and runs.
//! Which defence actually catches a silent catch-all binding?
//! Two lints are denied at the crate level below. Neither one fires.
#![deny(unreachable_patterns, bindings_with_variant_name, unused_variables)]
#[derive(Debug, Clone, Copy)]
enum Mark { Scored(u8), Blank, Spoiled, Reissued }
/// The trap: `use Mark::*` plus a lowercase `spoiled` in the last arm.
/// `Reissued` was added afterwards and is silently reported as spoiled.
fn tally_glob(m: &Mark) -> (u8, &'static str) {
use Mark::*;
match m {
Scored(n) => (*n, "counted"),
Blank => (0, "left blank"),
spoiled => {
let _ = spoiled;
(0, "spoiled")
}
}
}
/// The fix, and the only one of the three that works: qualify every path.
/// `Mark::spoiled` is `E0599`, so the typo cannot be written at all — and the
/// exhaustiveness check comes back, so `Reissued` had to be given an arm.
fn tally_qualified(m: &Mark) -> (u8, &'static str) {
match m {
Mark::Scored(n) => (*n, "counted"),
Mark::Blank => (0, "left blank"),
Mark::Spoiled => (0, "spoiled"),
Mark::Reissued => (0, "spoiled, replacement issued"),
}
}
fn main() {
let ballot = [Mark::Scored(5), Mark::Blank, Mark::Spoiled, Mark::Reissued];
println!("two lints denied at the top of this file, and it still compiled.\n");
println!("{:<12} {:<22} {}", "mark", "glob-imported (typo)", "qualified");
println!("{}", "-".repeat(70));
for m in &ballot {
let (gv, gr) = tally_glob(m);
let (qv, qr) = tally_qualified(m);
let flag = if gr == qr { "" } else { " <- report is wrong" };
println!("{:<12} {:<22} {}{}", format!("{m:?}"), format!("{gv} / {gr}"), format!("{qv} / {qr}"), flag);
}
}
Verified output of a_typo_becomes_a_binding_kata.rs — regenerated by tools/run_examples.py, never hand-typed.
two lints denied at the top of this file, and it still compiled.
mark glob-imported (typo) qualified
----------------------------------------------------------------------
Scored(5) 5 / counted 5 / counted
Blank 0 / left blank 0 / left blank
Spoiled 0 / spoiled 0 / spoiled
Reissued 0 / spoiled 0 / spoiled, replacement issued <- report is wrong
None of the three lints fire. Qualifying the paths is the only one of the four steps that changes anything, and it fixes both halves at once: the typo becomes E0599, and the missing Reissued arm becomes E0004.
See also¶
- What an enum is — where the
use Enum::*shortcut is introduced if let— the other way to lose exhaustiveness, deliberately and visibly- One arm, many values —
A | Band the two lints that catch a botched collapse - What a warning is asking — why
_spadeandspadeare different answers to the same question
Po polsku¶
To jedna z niewielu sytuacji, w których literówka nie jest błędem, tylko nową zmienną. Pod use Suit::* nazwy Heart, Diamond i Club rozwiązują się do wariantów, a spade pisane małą literą nie rozwiązuje się do niczego — i wzorzec będący gołym identyfikatorem, który do niczego się nie rozwiązuje, jest w Ruście całkiem legalny. To wiązanie: nowa zmienna o nazwie spade, pasująca do każdej wartości, czyli _ z ładniejszą nazwą. Przy czterech wariantach i czterech ramionach program nadal zwraca poprawną odpowiedź dla wszystkich czterech, więc przechodzi przez przegląd kodu bez mrugnięcia okiem. I uwaga na fałszywe poczucie bezpieczeństwa: decyduje wyłącznie rozwiązanie nazwy, a nie wielkość liter — konwencja, że warianty pisze się wielką literą, jest tylko konwencją, więc Spadd też jest zwykłym wiązaniem. Przy pełnej ścieżce ta sama pomyłka to E0599 („no variant named spade”), czyli błąd w jedynym momencie, w którym jest tani.
Warto tu odwrócić odruch przyniesiony z Javy czy Pythona, gdzie import z gwiazdką jest co najwyżej kwestią higieny przestrzeni nazw, a pomyłka w etykiecie case i tak zostaje błędem kompilacji. Tutaj glob zamienia błąd w ciszę, i żaden z lintów, po których spodziewałbyś się reakcji, nie odezwie się ani słowem: unreachable_patterns sprawdza, czy późniejsze ramię umarło, a literówkę wpisuje się zwykle na końcu, gdzie naturalnie stoi przypadek zbiorczy; bindings_with_variant_name wymaga nazwy pisanej dokładnie jak wariant, a spade to nie Spade. Zostaje unused_variables — jedyny, który pomaga, i to z bardzo dobrą podpowiedzią wskazującą Suit::Spade — ale gaśnie w chwili, gdy ciało ramienia użyje tej zmiennej choćby w format!. Zero ostrzeżeń, zły program. Rachunek przychodzi dopiero z piątym wariantem: wersja z pełnymi ścieżkami przestaje się kompilować (E0004), a wersja z globem po cichu nazywa jokera pikiem — i ten jeden wiersz w tabelce wyników jest jedynym śladem, jaki ten błąd kiedykolwiek zostawia.
Lekarstwo jest nudne i skuteczne: kwalifikuj ścieżki, pięć znaków na ramię. Suit::spade po prostu nie istnieje, więc literówki nie da się napisać tak, żeby się skompilowała, a wyczerpujące dopasowanie wraca razem z nią. Jeśli powtórzenia naprawdę bolą, kompromisem jest import nazwany (use Suit::{Heart, Diamond, Spade, Club}) — przemianowanie wariantu i literówka w samej liście importu dają wtedy E0432, a nie cichą zmienną. To mniejsza gwarancja, bo nie chroni ramion, ale prawdziwa, czego o use Suit::* powiedzieć się nie da.
Szukaj po polsku: import z gwiazdką · dopasowanie wzorców · wiązanie zmiennej we wzorcu · rust match binds variable instead of variant · rust bindings_with_variant_name · rust E0599 no variant named