HashMap, and your first generic functionLesson 0008 · after 0007 · reading, then a 35-minute drill against a shipped test file · ~50 minutes
Every example on this page runs against the same four books, so it is worth reading the data model once
before the examples start. Then any snippet below can be read without guessing what a field is called or what
type it holds. This is the whole thing — two type definitions, two helpers, and one Vec:
use Shelf::*; // so the examples can say Fiction instead of Shelf::Fiction
#[derive(Debug, PartialEq, Eq, Hash, Clone, Copy)]
enum Shelf { Fiction, History, Poetry } // fieldless, so Copy is free
#[derive(Debug)]
struct Book {
title: String, // owned text
shelf: Shelf, // which shelf it belongs on
borrowed: bool, // is it out on loan right now
}
fn book(title: &str, shelf: Shelf, borrowed: bool) -> Book {
Book { title: title.to_string(), shelf, borrowed }
}
// "fiction" -> Some(Fiction), anything unknown -> None
fn shelf_of(word: &str) -> Option<Shelf> {
match word {
"fiction" => Some(Fiction),
"history" => Some(History),
"poetry" => Some(Poetry),
_ => None,
}
}
// The data. Four books, three shelves, one of them out on loan.
let mut shelf: Vec<Book> = vec![
book("Dubliners", Fiction, true),
book("SPQR", History, false),
book("Ariel", Poetry, false),
book("Beloved", Fiction, false),
];
// A second value, used only where an element must be a plain string:
let titles: Vec<&str> = vec!["Dubliners", "SPQR", "Ariel"];
Two naming conventions to hold on to, because they are the only way to know an element's type at a glance.
shelf (lowercase) is the Vec<Book>, so shelf.iter() hands you
&Book and the closure parameter is written |b|. Shelf (capitalised) is
the enum, so b.shelf is a field holding one of its three variants. And titles is a
Vec<&str>, so titles.iter() hands you &&str and the closure
parameter is written |t|.
The mapping onto your own crate is exact, which is what makes the translation mechanical rather than
creative: Book is Task, title is title,
Shelf is Priority, and borrowed is Status. So when a
snippet below counts books per shelf, you are reading the count_by_priority you are about to
write.
Thirty-two tests are green and the persistence layer behind them is real: fs::read_to_string and
fs::write, a NotFound match guard so a first run does not look broken, an
impl FromStr for Task with its own associated type Err, and a hand-written
PartialEq because io::Error refuses to have one. The best signal is in
command.rs: both hand-rolled match id.parse() blocks are gone, replaced by
.parse()?. That was the whole point of step 0, and it landed — ? plus From
is now a reflex rather than a fact.
But I predicted something in 0007 that turned out to be false, and it is worth a paragraph because the lesson
generalises. I claimed the compiler would force you to write impl From<io::Error> for
TaskError, because ? on fs::write would be the only reasonable shape. You wrote
this instead:
fs::write(path, contents).map_err(TaskError::Io)
That is perfectly good Rust. TaskError::Io is a tuple-variant constructor, which means it is also
a function of type fn(io::Error) -> TaskError, so handing it straight to map_err is
idiomatic and allocation-free. No From impl needed, no error, and the test still passes. My claim
was simply wrong: a compiler error can only force a design when no legal alternative exists, and
here a legal alternative existed.
So which one should you write? Both are correct, and the difference is leverage rather than style.
map_err converts at one call site; From converts at every call site,
including ones you have not written yet, and it is what makes bare ? work on any function in
std::fs, std::io, or a future crate that returns an io::Error. Today's
save gets rewritten anyway, and the rewrite is shorter when ? just works — so step 0 of
the drill writes the impl and deletes the map_err. One line each way.
You have written iterator code already, in bursts: env::args().skip(1).collect() in
main, self.tasks.iter().find(|t| t.id == id) in find, and
.map(|t| t.id).max().unwrap_or(0) in load. What you have not had yet is the model
underneath them, so each one was memorised separately. The model is unusually small — one trait, one method:
pub trait Iterator {
type Item;
fn next(&mut self) -> Option<Self::Item>;
// ~75 more methods, all with default bodies built on next()
}
Book: 13.2 — Processing a series of items with iterators · std: Iterator
That is the entire interface. next hands back Some(item) until the sequence runs
out, then None forever. Notice type Item: it is an associated type, the same
mechanism you filled in as type Err when you implemented FromStr last lesson. Every
other method — map, filter, find, collect, sum
— is a default method written in terms of next. Which is why learning the vocabulary is cheap:
there is no new machinery behind any of them, only different ways of pressing the same button.
The one property that trips everybody up is laziness. The book states it flatly:
In Rust, iterators are lazy, meaning they have no effect until you call methods that consume the iterator to use it up.
Book: 13.2
Building a chain of adapters does no work and touches no elements. It only describes work. Write a chain and forget to finish it, and the closure never runs even once — the compiler warns, because the warning is the only thing standing between you and a silently dead line of code:
titles.iter().map(|t| t.to_uppercase());
warning: unused `Map` that must be used
--> examples/e1.rs:3:5
|
3 | titles.iter().map(|t| t.to_uppercase());
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
= note: iterators are lazy and do nothing unless consumed
= note: `#[warn(unused_must_use)]` (part of `#[warn(unused)]`) on by default
So every chain has exactly two parts, and it is worth naming them because the names tell you where a chain
must end. Adapters take an iterator and return another iterator: map,
filter, enumerate, skip, take, rev. They are
lazy, and they compose. Consumers take an iterator and return something that is not an
iterator: collect, find, position, count, sum,
max, any, for_each. They do the work, and a chain that does not end in
one has not run.
The performance question answers itself once you see the structure, and it matters for the job you are aiming
at. A chain of adapters is not a chain of temporary vectors — each adapter is a small struct wrapping the previous
one, and the whole tower compiles down to a single pass. That is why rewriting a for loop as an
iterator chain costs nothing at runtime, and why nobody in Rust treats the choice as a speed trade-off.
Before any adapter runs you have to say how you want the elements, and this is the one place where iterators meet the borrow rules. There are three methods, they differ only in the ownership they hand out, and picking the wrong one is the most common way an iterator chain fails to compile:
| Call | Item type | Use it when |
|---|---|---|
v.iter() | &T | You are reading. The collection survives. |
v.iter_mut() | &mut T | You are editing in place. It survives. |
v.into_iter() | T | You want the elements out. It is consumed. |
Book: 13.2 — “if we want
to create an iterator that takes ownership … we can call into_iter”
This is the same three-way choice you already make with &self, &mut self,
and self in a method signature, applied one element at a time — so nothing new is being introduced,
only a new place for a rule you already know. It also explains a piece of your own code you may have written
without reading: your complete uses iter_mut because it assigns to
task.status, while find uses iter because it only looks. Swap them and
neither compiles.
One trap deserves seeing before you hit it in the drill, because the error message is about a borrow and the
cause is an iterator. When you keep the result of an iter_mut chain in a variable, the mutable
borrow of the whole collection stays alive for as long as that variable does:
struct Library { books: Vec<String> } // titles only, to keep the error bare
impl Library {
fn rename(&mut self, from: &str, to: &str) {
let book = self.books.iter_mut().find(|b| *b == from).unwrap();
println!("{} books", self.books.len()); // asks for a second borrow
*book = to.to_string();
}
}
error[E0502]: cannot borrow `self.books` as immutable because it is also borrowed as mutable
--> examples/e4.rs:5:44
|
4 | let book = self.books.iter_mut().find(|b| *b == from).unwrap();
| ---------- mutable borrow occurs here
5 | println!("renaming 1 of {} books", self.books.len());
| ^^^^^^^^^^ immutable borrow occurs here
6 | *book = to.to_string();
| ----- mutable borrow later used here
Read the three annotations as a timeline and the rule falls out: the borrow begins at
iter_mut(), and it ends after the last use of book, not at the end of the
statement that created it. Anything else touching self.books in between is a second borrow, and
that is exactly the rule from chapter 4. The fix is to reorder — read the length first, or finish with
book before asking. Nothing about iterators is special here; they just make the overlap easy to
write by accident.
Here is the whole working vocabulary, run against a shelf of books. Read the calls beside their real output rather than trying to memorise signatures — the shapes are what you want in your fingers:
// the same four books from the top of the page
let mut shelf: Vec<Book> = vec![
book("Dubliners", Fiction, true), book("SPQR", History, false),
book("Ariel", Poetry, false), book("Beloved", Fiction, false),
];
let all_titles: Vec<&str> = shelf.iter().map(|b| b.title.as_str()).collect();
shelf.iter_mut().for_each(|b| b.borrowed = false);
let fiction: Vec<&str> = shelf.iter()
.filter(|b| b.shelf == Shelf::Fiction)
.map(|b| b.title.as_str())
.collect();
shelf.iter().find(|b| b.title == "Ariel").map(|b| b.shelf);
shelf.iter().position(|b| b.title == "Ariel");
shelf.iter().any(|b| b.borrowed); // bool
shelf.iter().filter(|b| !b.borrowed).count(); // usize
shelf.retain(|b| b.shelf != Shelf::Poetry); // delete in place
all_titles -> ["Dubliners", "SPQR", "Ariel", "Beloved"]
iter_mut -> every book returned, borrowed set to false on each
fiction -> ["Dubliners", "Beloved"]
find -> Some(Poetry) // the ITEM, mapped: Option<Shelf>
position -> Some(2) // the INDEX: Option<usize>, Ariel is 3rd
any / count -> false / 4 // nothing is borrowed now, so all 4 are in
retain -> removed 1, 3 left // Ariel was the only poetry book
Two of those are worth a second look, because they are the ones your own store.rs currently
writes out longhand as loops.
find versus position is a question about what you need next.
find gives you the element, which is what complete wants — it has to assign to
status. position gives you the index, which is what remove wants —
Vec::remove takes an index, not an element. Both return an Option, and both compose
straight into the error handling you already have: .ok_or(TaskError::NotFound(id))? turns a
None into your own error and unwraps the rest, collapsing a nine-line loop into one expression.
retain is the one that saves you from a genuine bug. Deleting several elements
from a Vec by index in a loop is a classic error: each removal shifts everything after it down one,
so the loop skips elements. retain takes a predicate meaning “keep this one” and does a single
compacting pass. It is a method on Vec rather than on Iterator, because it mutates
the collection in place — one of several useful methods that live on the collection rather than on the trait.
std: Vec::retain · Iterator::position
collect is the interesting onecollect looks like “make a Vec”, and that undersells it enough to hide the single
most useful trick in this lesson. Its real signature says something much stronger:
fn collect<B: FromIterator<Self::Item>>(self) -> B
std: Iterator::collect · FromIterator
Read that as: collect will build any type that knows how to be built from this kind of item. The
target is chosen by B, and B is decided by you, at the call site — which is why
collect is the one method where you routinely have to state a type. Leave it out and the compiler
has nothing to go on:
let shouted = titles.iter().map(|t| t.to_uppercase()).collect();
error[E0283]: type annotations needed
--> examples/e2.rs:3:9
|
3 | let shouted = titles.iter().map(|t| t.to_uppercase()).collect();
| ^^^^^^^ ------- type must be known at this point
|
= note: multiple `impl`s satisfying `_: FromIterator<String>` found in the `alloc` crate:
- impl FromIterator<String> for Box<str>;
- impl FromIterator<String> for String;
The note is the teaching. This is not the compiler being fussy about vectors — it is telling you that several
types can be built from a stream of Strings and it will not guess which one you meant. You answer
either on the left, let shouted: Vec<String> = ..., or on the right with a turbofish,
.collect::<Vec<String>>(). Both are common; pick whichever reads better in the line.
Now the trick. Result and Option both implement FromIterator, so
an iterator of Results can collect into a single Result holding a
Vec. The same chain, with only the target type changed, gives two entirely different
answers:
let words = ["fiction", "history", "rubbish", "poetry"];
let each: Vec<Option<Shelf>> = words.iter().map(|w| shelf_of(w)).collect();
let all: Option<Vec<Shelf>> = words.iter().map(|w| shelf_of(w)).collect();
let numbers: Result<Vec<u32>, _> =
"1 2 x 4".split(' ').map(str::parse::<u32>).collect();
Vec<Option> -> [Some(Fiction), Some(History), None, Some(Poetry)]
Option<Vec> -> None
Result<Vec> -> Err("invalid digit found in string")
std: impl FromIterator<Result<A, E>> for Result<V, E>
Vec<Option<Shelf>> keeps every outcome, hole included. Option<Vec<Shelf>>
means all-or-nothing: the first None ends the iteration and the whole result is None.
That short-circuit is not a detail — it is the reason this is the right tool for reading a file. Your
load currently loops, parses each line, and pushes into a Vec, with
? inside the loop. One collect replaces all of it:
let tasks: Vec<Task> = contents.lines()
.map(str::parse)
.collect::<Result<Vec<Task>, TaskError>>()?;
The behaviour is exactly what a save file wants. Every line parses and you get the tasks; one line is corrupt
and you get that line's error and nothing else — no half-loaded store to accidentally save back over the good
file. And notice map(str::parse): you can pass a function path where a closure is expected,
because |line| line.parse() and str::parse are the same function. Which
parse, of the many possible, is settled by the collect target — the Vec<Task> tells
the compiler to look for Task's FromStr impl, the one you wrote last lesson.
One line-splitting detail comes with this rewrite, and it is a real trap rather than trivia:
let file = "1|fiction|Dubliners\n2|poetry|Ariel\n";
file.split('\n') // -> ["1|fiction|Dubliners", "2|poetry|Ariel", ""]
file.lines() // -> ["1|fiction|Dubliners", "2|poetry|Ariel"]
std: str::lines
split('\n') yields an empty final piece for a file that ends in a newline, because the text
after the last separator is the empty string. That is why your current load needs an
if !items.is_empty() guard — the guard exists to paper over the wrong splitter.
lines() is built for
this job: it treats the trailing newline as a terminator rather than a separator, and it strips a
\r\n too, which is free Windows compatibility. Switch splitters and the guard disappears — after
which a blank line in the middle of a file is no longer silently skipped but reported as a bad line,
which is the honest answer for a corrupt file. A shipped test pins that behaviour.
HashMap, and the two traits a key must haveHashMap<K, V> is the last of the three common collections, and it is the one you have not
used at all. It is a lookup by key rather than by position, it lives on the heap like Vec, and it
is not in the prelude, so it needs an import:
use std::collections::HashMap;
let mut counts: HashMap<Shelf, usize> = HashMap::new();
counts.insert(Shelf::Fiction, 2);
counts.get(&Shelf::Fiction); // Option<&usize> — may be absent
counts.get(&Shelf::Poetry).copied().unwrap_or(0); // absent counts as 0
for (shelf, n) in &counts { } // ARBITRARY order — never trust it
Book: 8.3 — Storing keys with associated values in hash maps
Two things there are easy to skim past and expensive to learn later. get returns an
Option<&V>, so “missing key” is a value you handle rather than a crash — the same shape as
Vec::get. And iteration order is arbitrary and not stable between runs. If a user is going to read
your output, you must impose an order yourself; the stats command in today's drill prints high,
medium, low in a fixed sequence for exactly that reason.
The idiom that makes hash maps worth their weight is entry. Counting things is the standard
example, and the book's version is four lines:
for b in &shelf {
*counts.entry(b.shelf).or_insert(0) += 1;
}
entry() -> {Fiction: 2, History: 1}
Book: 8.3 — Listing 8-25, counting occurrences of words
Take that line apart slowly, because it is dense and it is everywhere in real Rust.
entry(key) returns an Entry, an enum standing for a slot that may or may not be
filled. or_insert(0) fills it with 0 if it was empty, and either way hands back a
&mut usize pointing into the map. * follows that reference so
+= 1 lands on the number itself. The whole thing is one hash lookup — the version you would write
by hand, if !map.contains_key(k) { map.insert(k, 0) } followed by a get_mut, costs
two or three and reads worse.
Now the part that is specific to Rust. A key type must implement Eq and Hash, and
you will meet that rule at a call site from today's drill — so here is the line the next two errors point at,
before they point at it. Store::count_by_priority is one line long, and it hands the work to a
small generic function called tally. You write both in the drill; Part 6 builds
tally from this signature:
// src/stats.rs — Part 6 explains it and the drill writes the body
pub fn tally<T, K, F>(items: &[T], key: F) -> HashMap<K, usize>
// src/store.rs:68 — count_by_priority, in full
tally(self.tasks(), |task| task.priority)
Read that as “count the items, grouped by whatever the closure pulls out of each one”. It is all you need
for the errors below; the three type parameters and the body are Part 6's job. Your Priority
implements neither Eq nor Hash, so the first attempt at using it as a key fails twice
over:
error[E0277]: the trait bound `Priority: Eq` is not satisfied
--> src/store.rs:68:9
|
68 | tally(self.tasks(), |task| task.priority)
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `Eq` is not implemented for `Priority`
|
help: consider annotating `Priority` with `#[derive(Eq)]`
error[E0277]: the trait bound `Priority: Hash` is not satisfied
help: consider annotating `Priority` with `#[derive(Hash)]`
The requirement is not bureaucracy; it is the data structure stating its contract. To find a key the map
hashes it to pick a bucket, then compares for equality inside that bucket — so a key it cannot hash or cannot
compare is a key it cannot store. Hash gives it the first, Eq the second.
Eq is worth understanding rather than just deriving, since you already have
PartialEq and this looks like a duplicate. It is not: Eq is a marker with no methods
of its own, and it promises one extra property that PartialEq does not — that every value equals
itself. The famous exception is f64, where NAN != NAN, which is precisely why
f64 implements PartialEq but not Eq, and why a f64 cannot
be a HashMap key. A three-variant enum has no such problem, so the derive is honest.
Fix those two and a third error appears, which is the most instructive of the set:
error[E0507]: cannot move out of `task.priority` which is behind a shared reference
--> src/store.rs:68:36
|
68 | tally(self.tasks(), |task| task.priority)
| ^^^^^^^^^^^^^ move occurs because `task.priority` has type `Priority`,
| which does not implement the `Copy` trait
|
note: if `Priority` implemented `Clone`, you could clone the value
The closure receives &Task — a borrow — and a map key has to be owned, since the map keeps
it. Reading task.priority out of a borrow is a move out of something you do not own, which is
E0507, one of the most common errors in real Rust. Three fixes exist and they are not equivalent:
.clone() works but is noise for three variants; #[derive(Clone, Copy)] makes
Priority behave like u32, copied implicitly wherever it is read; keying by
task.priority.label() sidesteps it by using a &str instead. Derive
Copy. A fieldless enum is a single small integer at runtime, copying it is free, and it is what std
does for its own small enums such as ErrorKind.
Counting tasks by priority is one specific job, and today you will write it once and never again — because the function you write is generic over what it counts. This is your first hand-written generic, so here it is whole, and then taken apart:
use std::collections::HashMap;
use std::hash::Hash;
pub fn tally<T, K, F>(items: &[T], key: F) -> HashMap<K, usize>
where
K: Eq + Hash,
F: Fn(&T) -> K,
{
let mut counts = HashMap::new();
for item in items {
*counts.entry(key(item)).or_insert(0) += 1;
}
counts
}
Three type parameters, and each one is there for a reason. T is the element type, and the
function never looks inside a T, which is exactly why it works on tasks and on strings alike.
K is the key type, and it carries the bound Eq + Hash — not because tally
cares, but because the HashMap it returns does. F is the closure type. Every closure in
Rust has its own anonymous type, so the only way to accept one is a type parameter bounded by
Fn(&T) -> K, which reads as “anything callable that takes a &T and returns a
K”.
The where clause is worth seeing as the point of the exercise rather than syntax to tolerate. It
is a contract in both directions: callers must supply types that satisfy it, and inside the body you may use
exactly the operations it guarantees and nothing else. That is why generics in Rust do not blow up at the call
site the way C++ templates can — the bounds are checked once, against the definition. Try to call
item.to_string() in there and it will not compile, because nothing in the clause promised
T: Display.
The payoff is that one definition serves cases that have nothing to do with each other:
tally(&shelf, |b| b.shelf) // -> {History: 1, Fiction: 2}
tally(&["a", "bb", "cc"], |w| w.len()) // -> {1: 1, 2: 2}
Two calls, two different T, two different K, and — this is the part that matters
for the interviews you are aiming at — no runtime cost for the generality. Rust monomorphises: it compiles one
specialised copy of tally per combination of types actually used, so each call site gets code as
tight as if you had written that version by hand.
Book: 10.1 — Generic data types and 13.1 — Closures
Last note before the drill, and it is a taste question rather than a rule. tally's body keeps a
for loop, on purpose. Iterators replace loops that search, transform, or
collect — those have a named adapter and the chain reads better than the loop. A loop that folds many
items into one accumulator is the case where a loop is still the clearest thing to write; the iterator version
exists, fold, and here it would be harder to read for no gain. The drill's grep check is scoped to
store.rs for exactly this reason.
Six questions before you touch the keyboard. Answer each one out loud, in full sentences, before you reveal or click. An answer you can say is an answer you have understood; one you can only recognise on the page usually is not. Getting one wrong here costs nothing — getting it wrong twenty minutes into the drill costs you the drill.
Iterators
How many times does the closure run in v.iter().map(|x| f(x)); — with no collect?
Iterators
complete needs the task itself; remove needs its index. Which adapter does each want, and which iterator does each start from?
Collections
Four lines are parsed, the third is corrupt, and you collect::<Result<Vec<Task>, TaskError>>(). What comes back?
Collections
Why must a HashMap key implement both Eq and Hash, and why is PartialEq not enough?
Ownership
|task| task.priority in a closure over &Task gives error[E0507]: cannot move out of ... behind a shared reference. What is the cause, and which of the three fixes wins?
Traits
In fn tally<T, K, F>(items: &[T], key: F) with K: Eq + Hash, F: Fn(&T) -> K — why does F have to be a type parameter at all, and what does the where clause buy you?
Type it, do not paste it. The bookshelf above is a different program. Keep the iterators reference open — looking syntax up is free.
cd ~/learn-rust/tasks
cp ../lessons/0008-collections-spec.rs tests/collections.rs
cargo test # 14 new tests fail to compile — that is the starting line
Do not edit anything in tests/. All 32 existing tests must still pass. Target at the end:
46 passing.
From, two minutesWrite the impl that 0007 asked for and then delete the workaround, so ? handles io errors
everywhere from here on:
impl From<io::Error> for TaskError { .. } // in error.rs, beside the other
fs::write(path, contents)?; // in save — map_err goes away
Check: grep -c "impl From<io::Error>" src/error.rs prints 1,
grep -c "map_err(TaskError::Io)" src/store.rs prints 0, and
cargo test --test persist still passes 8.
Create src/stats.rs, declare it in lib.rs, and write tally from
Part 6 — from the signature, not by copying the body. It is nine lines.
Check: cargo test --test collections tally → 3 passed.
pub mod stats; in src/lib.rs, alphabetically beside the others. Without that line
the file is not compiled at all and you get error[E0432]: unresolved import from the test file —
the same error 0006 showed you.
Priority as a keyAdd count_by_priority(&self) -> HashMap<Priority, usize> to Store, as one
line delegating to tally. Let it fail first, read all three errors, and fix them with the derives
they ask for. Seeing E0277 twice and E0507 once, in that order, is the point of the step.
Check: cargo test --test collections count_by → 3 passed.
Storepub fn titles_with(&self, priority: Priority) -> Vec<&str>
pub fn remove_completed(&mut self) -> usize
titles_with answers “what am I meant to be doing at this priority?”. Hand it a priority and it
gives back the title of every task that carries that priority, in the order the tasks were added. Nothing
matches, and you get an empty Vec rather than an error — an empty answer is a legitimate answer
here. Note the return type: Vec<&str>, not Vec<String>. It hands back
borrows of titles the store still owns, so nothing is cloned, and task.title.as_str() is the
conversion you need.
remove_completed is the tidy-up: it deletes every task whose status is Done and
returns how many it deleted. Three details the tests hold you to. The tasks that survive keep their own ids —
you are removing rows, not renumbering them. The id counter is untouched, so the next add carries
on from where it had got to rather than reusing a freed number. And removing nothing is a normal outcome that
returns 0, not an error. One Vec method from Part 3 does the removal; the count is
the length before minus the length after.
Check: cargo test --test collections → 11 of 14 passed.
store.rs with what you learnedFour functions, all currently loops, all one expression each: complete with
iter_mut().find(), remove with iter().position(), save
with map(..).collect::<String>(), and load with
lines().map(str::parse).collect::<Result<Vec<Task>, TaskError>>()?. In load
the if !items.is_empty() guard goes away with the splitter, and the trailing
mut contents = String::new() dance collapses into the match from 0007 returning
a value.
Check: grep -c "for " src/store.rs prints 0,
grep -c "lines()" src/store.rs prints 1, and cargo test → 17 + 7 + 8 +
14 = 46 passed.
save building a String from an iterator?String implements FromIterator<String>, so a chain of owned lines collects
straight into one: self.tasks.iter().map(|t| format!("{}\n", t.to_line())).collect(). Annotate the
target — let contents: String = .. — or E0283 will ask you which of several possible types you
meant.
Add Stats and Clear to the Command enum and to
Command::parse (the words are stats and clear). The
match in main will refuse to compile until both are handled — that is
E0004, the same non-exhaustive-match error from 0006, doing its job again.
stats must print the three priorities in a fixed order, because hash map iteration order is
arbitrary. Loop over [Priority::High, Priority::Medium, Priority::Low] and ask the map for each,
with counts.get(&p).copied().unwrap_or(0) so an absent priority prints 0 rather
than vanishing.
Check — a real session, run today against the reference implementation:
$ cd $(mktemp -d)
$ run add "buy milk" high
added task 1
$ run add "call bank"
added task 2
$ run add "water plants" low
added task 3
$ run done 1
completed 1
$ run stats
high 1
medium 1
low 1
$ run clear
cleared 1 completed
$ run list
2 [todo] call bank (medium)
3 [todo] water plants (low)
$ cat t.txt
2|todo|medium|call bank
3|todo|low|water plants
$ run stats ; echo $?
high 0
medium 1
low 1
0
(run above is
TASKS_FILE=t.txt cargo run -q --manifest-path ~/learn-rust/tasks/Cargo.toml --.) That last
high 0 is .copied().unwrap_or(0) earning its place: the completed high-priority task
is gone, so the map has no High entry at all, and the absence prints as a zero instead of a
missing line.
Not today: fold and zip, BTreeMap (sorted keys — the right answer if
you ever want stats ordered without hard-coding), impl Iterator for your own type, and
itertools. Each is a small step from here, and none of them is on the path to the next gap.
Chapters 8 and 13 move to produced on the coverage map, and 10.1 opens with a real generic function of your own rather than a book example. What is left before the job-ready floor is short:
titles_with returns Vec<&str> borrowed from &self, and
elision filled in the annotation for you.serde → axum, where the trait work from 0005–0008 starts paying rent.Here is a question with genuine disagreement behind it, which makes it a good one to ask people rather than
docs. Your tally takes &[T]. Most experienced Rust developers would write it to
take impl IntoIterator<Item = T> instead, so it accepts a Vec, an array, a
HashSet, or any chain of adapters — not only a slice. Post tally on
users.rust-lang.org (Code Review category) and ask whether the
IntoIterator version is worth the extra signature complexity for a small crate, and where they
personally draw that line. The answers will teach you more about idiomatic API design than any chapter, because
it is a taste question and the book cannot have taste for you.
iter borrows, iter_mut borrows mutably, into_iter takes ownership —
the &self/&mut self/self choice, one element at a time.collect builds any FromIterator type, so an iterator of Results
collects into one Result<Vec<_>, E> that short-circuits on the first error.HashMap key needs Eq + Hash; *map.entry(k).or_insert(0) += 1 is the
counting idiom; iteration order is arbitrary, so impose your own before printing.where clause is a contract checked once against the definition, and
monomorphisation means the generality is free at runtime.