Time to extend our running word-count example.
Back in the word count chapter you built word_count, char_count, and longest_word with simple for loops.
Then you used iterators to collapse those loops into one-liners.
Word frequencies answer two related questions: which words appear, and how often does each one occur.
The implementation combines iterators, hash maps, and Option.
max_by_key and HashMap::into_iter are the only new iterator tools needed here.
Splitting text into words. Both split_whitespace and split return iterators of &str.
The first handles any kind of whitespace and skips empties, which is usually what you want for natural text:
for word in "hello world\nrust".split_whitespace() {
println!("{word}"); // hello, world, rust
}
Counting things into a HashMap. Reach for entry(...).or_insert(0):
let mut counts: HashMap<String, usize> = HashMap::new();
for word in text.split_whitespace() {
*counts.entry(word.to_lowercase()).or_insert(0) += 1;
}
Finding the maximum by some property. max_by_key is the right tool for "give me the entry with the largest count":
let top = counts.iter().max_by_key(|(_, count)| *count);
// top: Option<(&String, &usize)>
Computing an average. Add the lengths, convert the totals to f64, and only then divide so integer truncation can't discard the fraction:
let total_chars: usize = words.iter().map(|w| w.len()).sum();
let avg = total_chars as f64 / words.len() as f64;
Start by turning a string of text into a HashMap<String, usize> that records how many times each word appears.
Words are separated by whitespace and the count should be case-insensitive: "Hello" and "hello" are the same word.
Build the map by splitting on whitespace, lowercasing each piece, and bumping its counter.
The entry API handles the lookup and default insertion together, then gives you the counter to update.
Useful from the standard library
str::split_whitespacesplits on any whitespace and skips empty pieces. That makes it a better default for natural text than splitting on one literal space.str::to_lowercasereturns a freshString. Use it as the map key soHelloandhellocollapse together.HashMap::entry+Entry::or_insertis the "look up; insert default; mutate" pattern you used with hashmaps.
use std::collections::HashMap;
/// Counts how many times each word appears in the text.
/// Words are separated by spaces and should be case-insensitive.
fn count_words(text: &str) -> HashMap<String, usize> {
let mut counts = HashMap::new();
for word in text.split_whitespace() {
*counts.entry(word.to_lowercase()).or_insert(0) += 1;
}
counts
}
#[test]
fn test_count_words() {
let text = "hello world hello rust world";
let counts = count_words(text);
assert_eq!(counts.get("hello"), Some(&2));
assert_eq!(counts.get("world"), Some(&2));
assert_eq!(counts.get("rust"), Some(&1));
}
#[test]
fn test_count_words_case_insensitive() {
let text = "Hello HELLO hello";
let counts = count_words(text);
assert_eq!(counts.get("hello"), Some(&3));
}
Now that you can count, finding the maximum is a one-liner, almost.
The choice between iter and into_iter determines whether you can return the winning word without cloning it.
count_words is duplicated below as a todo!() stub so this step compiles in isolation; you don't need to fill it in again.
You only need to work on most_common_word.
The test will call both functions and unwrap the result.
Useful from the standard library
HashMap::into_iterconsumes the map and yields owned(K, V)pairs. That's how you get an ownedStringout without cloning.Iterator::max_by_keyreturns the entry with the largest derived key as anOption. Use the count half of each(word, count)pair as that key.- An empty input naturally produces
None:count_wordsreturns an empty map,into_iter().max_by_key(...)returnsNone, and the function signature already saysOption<(String, usize)>. You don't need an extra branch for that case.
use std::collections::HashMap;
/// Counts how many times each word appears in the text.
/// Words are separated by spaces and should be case-insensitive.
fn count_words(text: &str) -> HashMap<String, usize> {
let mut counts = HashMap::new();
for word in text.split_whitespace() {
*counts.entry(word.to_lowercase()).or_insert(0) += 1;
}
counts
}
/// Finds the most common word in the text.
/// Returns the word and its count, or None if text is empty.
///
/// Tip: this is the function where the borrow checker pushes back. To
/// return `(String, usize)` you need to own the key, but `iter()` on
/// a `HashMap` only hands out borrows. The trick is
/// [`into_iter`](https://doc.rust-lang.org/std/collections/struct.HashMap.html#method.into_iter):
/// it consumes the map and yields `(K, V)` pairs by value, so combining
/// it with `max_by_key` gives you back an owned `(String, usize)`.
fn most_common_word(text: &str) -> Option<(String, usize)> {
count_words(text)
.into_iter()
.max_by_key(|(_, count)| *count)
}
#[test]
fn test_most_common_word() {
let text = "apple banana apple cherry apple";
let (word, count) = most_common_word(text).unwrap();
assert_eq!(word, "apple");
assert_eq!(count, 3);
}
Now you'll combine several small aggregations in one function.
text_stats returns three numbers about a piece of text: total word count, number of unique words, and the average word length as an f64.
You can compute all three from a single pass over count_words's result, or split the work; either is fine.
The average is where Rust makes you slow down.
Integer division truncates, so cast to f64 before you divide, not after.
The test compares the result against a small tolerance because calculations with f64 can introduce rounding error.
count_words is stubbed with todo!() again so this file compiles on its own.
Wire text_stats up however you like.
The test only cares about the returned tuple.
Useful from the standard library
- Each map value is an occurrence count, so adding the values gives you the total number of words.
- The map's length gives you the number of unique words because each key appears once.
- For the average length, account for both the length of each word and the number of times it occurred. Convert the total characters and total words to
f64before dividing.HashMap::valuesandHashMap::iterare the two iterator entry points you'll likely use here.
use std::collections::HashMap;
/// Counts how many times each word appears in the text.
/// Words are separated by spaces and should be case-insensitive.
fn count_words(text: &str) -> HashMap<String, usize> {
let mut counts = HashMap::new();
for word in text.split_whitespace() {
*counts.entry(word.to_lowercase()).or_insert(0) += 1;
}
counts
}
/// Calculates basic text statistics.
/// Returns (`total_words`, `unique_words`, `average_word_length`).
///
/// In real code you'd reach for a `struct TextStats { total: usize,
/// unique: usize, avg_len: f64 }` here; a 3-tuple is hard to read at
/// the call site. We're sticking with a tuple to keep the focus on the
/// iterator chain in the body.
fn text_stats(text: &str) -> (usize, usize, f64) {
let counts = count_words(text);
let total: usize = counts.values().sum();
let unique = counts.len();
let total_length: usize = counts
.iter()
.map(|(word, count)| word.chars().count() * count)
.sum();
let average_word_length = total_length as f64 / total as f64;
(total, unique, average_word_length)
}
#[test]
fn test_text_stats() {
let text = "hello world rust";
let (total, unique, avg_len) = text_stats(text);
assert_eq!(total, 3);
assert_eq!(unique, 3);
assert!((avg_len - 4.66).abs() < 0.01); // Average length ≈ 4.66
// Side note: floats don't compare exactly (the value here is
// really 14/3 = 4.666...), so we check that we're close enough
// by taking the absolute difference and comparing to a tolerance.
// Direct `==` on `f64` is almost always the wrong thing.
}
You glued together the chapters so far: a HashMap keyed by lowercased words, an into_iter() to escape the borrow checker, a max_by_key to pick a winner, and a few aggregations to compute summary stats.
What we learned
split_whitespace()is the right default for word-splitting in natural text. It collapses runs of whitespace and skips empties.- Lowercasing keys (or any other normalization step) belongs to the same pipeline that builds the map, not to the consumer side.
into_itertransfers the keys and values out of aHashMap, which lets you return owned data without cloning it. In contrast,iteronly lends you references to entries that remain in the map.max_by_keyreturns anOption, so empty input naturally collapses toNonewithout a special-case branch.- Watch the integer-division trap when computing averages: divide after casting to
f64, not before. Tests for calculatedf64values usually compare a tolerance such as(a - b).abs() < epsinstead of using==.- Tuples like
(usize, usize, f64)work for tiny ad-hoc returns, but a named struct (TextStats { total, unique, avg_len }) reads better at the call site as soon as a function takes off in scope.
You now have every tool you need to build a small program from scratch: structs, enums, iterators, Option, Result, vectors, and strings.
If you want a change of pace, the optional Creative Break is an open-ended password validator project rather than a guided lesson.
Nothing later depends on it, so you can take the detour now or keep going.