You'll learn: Option — the type Rust uses when a question might have no
answer — plus tuples and .enumerate(), the two small tools you need to use
it. Absence is an answer, and you will use all three twice: to fix a real bug
your own game shipped, and to build a line that remembers every letter you
have tried.
The bug you already shipped
The last lab ended with a confession. score_guess marks a letter Near if
the secret contains it anywhere — and it never checks how many times. Guess
eerie when the secret is speed and the row comes back:
e e r i e
~ ~ . . ~
Three ~ marks for the letter e. But speed has only two es. The row
promises a third e that speed does not have. A player who trusts it will
guess wrong, and it will be your game's fault.
You did not hide this. Your own test, simple_scoring_overcounts_duplicates,
pins it down as a fact: three Nears, asserted on purpose, with a comment
saying lab 06 fixes it. This is that lab.
Fix it by adding, not by rewriting
Here is the move this course makes every time it improves something:
score_guess stays exactly as it is. You will write a new function,
score_guess_exact, in a new file. The old one keeps its tests, keeps
passing them, and keeps its place in the library.
Why not fix score_guess where it stands? Because things depend on it.
Its own tests assert its current behaviour — including the flaw. If you
change what a published function does, everything that trusted it breaks at
once. Adding a new function costs nothing to anyone. Engineers call this
append-only: a published function only ever gets a neighbour, never a
rewrite.
The honest rule
The fix is a rule about evidence. Each letter of the secret is one piece of evidence, and each piece can be claimed once:
- First, every exact-position match (
Hit) claims its own letter. - Then walk the guess left to right, skipping the letters already scored
Hit. For each one, look for a secret letter that is still unclaimed and is the same letter. If you find one, that guess letter isNear— and the secret letter it found is now used up. If you find none, it is aMiss.
Run eerie against speed by hand. No position matches, so there are no
Hits, and all five secret letters — s, p, e, e, d — are unclaimed.
The first e of the guess claims one secret e: Near. The second e
claims the other: Near. Then r finds no r, i finds no i — two
Misses. The last e looks for an unclaimed e, and there are none left.
Miss. The row comes back:
e e r i e
~ ~ . . .
Two Nears, because there are two es. The score stops lying.
To write that rule as code you need to answer one question over and over: where is this letter in the pool of unclaimed letters — if it is there at all? That "if it is there at all" is the whole lab.
A question that might have no answer
What should a function called first_index_of return when the letter is not
in the string? Zero is wrong — zero is a real position. Minus one does not
exist for usize. A crash is far too dramatic for a question this ordinary.
Rust's answer is a type you already know the shape of. Last lab you built
LetterScore, an enum with three variants. Option is an enum from the
standard library with two:
enum Option<T> {
Some(T),
None,
}
Read it as: a value of type Option<usize> is either Some(3) — "there is
an answer, and it is 3" — or None — "there is no answer". The T means
Option works for any type: Option<usize>, Option<LetterScore>,
Option<String>. You do not define Option and you do not import it: you
can write Some and None in any Rust file, with nothing at the top.
The important part is what Option refuses to let you do. An
Option<usize> is not a usize, and the compiler will not let you use it as
one. Hand one to .remove(), which wants a plain usize, and rustc stops
you:
error[E0308]: mismatched types
|
6 | unclaimed.remove(index);
| ------ ^^^^^ expected `usize`, found `Option<usize>`
To get the usize out, you must go through match — and match must be
exhaustive, exactly as it was for LetterScore. Forget the None arm and:
error[E0004]: non-exhaustive patterns: `None` not covered
|
5 | match found {
| ^^^^^ pattern `None` not covered
This is the point of the type. "Might have no answer" is written into the type, and the compiler makes you say — at the moment you use it — what happens in both cases. You cannot forget, because forgetting does not compile.
match first_index_of(&unclaimed, letter) {
Some(index) => {
// it is there, and `index` is a plain usize now
}
None => {
// it is not — and you had to say what that means
}
}
The None => {} arm — do nothing — is legal and sometimes right. Writing it
is not busywork. It is the difference between forgetting the empty case and
deciding about it.
Two small tools: tuples, and .enumerate()
To write first_index_of you walk a string and keep count as you go. Rust
has a tidy way to say that, and it needs one new piece of grammar first.
A tuple is a pair (or triple, or more) of values in one bundle, written
in parentheses: (0, 'c') is a tuple holding a number and a character. You
can reach its parts by position — pair.0 is the first, pair.1 is the
second — or take it apart in one let:
let pair = (0, 'c');
let (index, letter) = pair; // index is 0, letter is 'c'
That second form is called destructuring: the left side is a pattern with two
names, the right side is a pair, and each name binds to its part. It works in
a for loop too, and that is where you will use it.
You met .chars() back in lab 03: it hands you each character of a string, in
order. Chain .enumerate() onto it and each character arrives with its
position attached — as a tuple:
for (index, c) in s.chars().enumerate() {
// first time around: index = 0, c is the first character
// next: index = 1, and so on
}
(index, c) is the same destructuring pattern, applied once per loop turn.
No counter variable to declare, increment, or get wrong: the count comes with
the character. Compare it with lab 05's render_score, where you kept a
position variable by hand and bumped it at the bottom of the loop —
.enumerate() is that same idea, built in. You will write exactly this loop
in the project's first function.
The index it hands you counts characters, and .remove() wants a byte
position. In this game those are the same number — lab 02's rule about the
word list is what makes them so.
A loop inside a loop
A loop's body is ordinary code, so it can hold another loop. Nothing new is being introduced; the inner one simply runs all the way through on every turn of the outer one:
for word in words {
for c in word.chars() {
println!("{c}");
}
}
With words holding ["at", "on"] that prints a, t, o, n — two turns
outside, two letters each. Two things to hold on to. The inner loop's name
(c) belongs to the inner loop and is gone when it ends; the outer one's name
(word) is still in scope inside it, which is what lets the inner body ask a
question about both at once. And each loop needs its own header line, so a
nest of two is two for lines, one inside the other's braces.
Step 4 of the project is exactly this shape: one guess at a time on the outside, one letter of that guess on the inside.
Remembering what you tried
Option earns its keep a second time in this lab, in a feature players of
this kind of game expect: by guess four you have evidence about a dozen
letters, and you should not have to re-read the board to collect it.
The tracker is one line for the whole alphabet:
a^ b c d e^ f g h i j k l. m n o p q r s. t. u v w x y z
Two questions you can ask a line like that one, which the project's last test
does: line.starts_with("a b c") and line.ends_with("z"). They are
.contains() from lab 01 pointed at the two ends instead of at the middle,
and each answers a bool — with line set to "a b c" both of those are
true.
Read it: a^ — proven in the right place, l. — ruled out, b (a blank) —
never tried. Look at that third state. It is not Miss. A Miss is
evidence: you played the letter and the word refused it. Untried is the
absence of evidence, and writing it as some fake score would be the same
lie the scorer told, in a new place.
So the question "what do I know about the letter b?" has type
Option<LetterScore> — Some(score) if any guess has tried it, None if
none has. The type is the design: a function that answers
letter_status(letter, …) cannot forget the untried case, because its
callers are forced to match both arms. None prints as a blank, and you
will build the line in one loop over 'a'..='z' — a range of characters,
working the way 0..n worked for numbers, with ..= meaning the last one is
included.
Across several guesses a letter can earn different scores — a was Near in
one guess, then Hit in a later one. The tracker keeps the best
evidence: "proven in place" beats "in the word somewhere" beats "ruled out".