Closures & Iterators 🏭
Time to trade loops-with-bookkeeping for assembly lines. This is the lesson that makes your code start looking like a fluent Rustacean wrote it.
Closures: functions to go 🥡
A closure is a small unnamed function you can store in a variable and pass around. Parameters go between pipes:
fn main() {
let double = |x| x * 2;
let add = |a, b| a + b;
println!("{}", double(21)); // 42
println!("{}", add(40, 2)); // 42
}
Their party trick (the reason they're called closures) is that they can capture variables from the surrounding scope:
fn main() {
let tax_rate = 0.19;
let with_tax = |price: f64| price * (1.0 + tax_rate); // captures tax_rate!
println!("{:.2}", with_tax(100.0)); // 119.00
}
A normal function couldn't see tax_rate; a closure closes over it,
carrying its environment along. That's what makes the machines below so handy.
Iterators: the assembly line
An iterator hands out items one at a time. Every collection
provides one via .iter(), and you can bolt processing stations
(adaptors) onto it, each taking a closure:
fn main() {
let catch_g = vec![12, 7, 30, 5, 18]; // crab weights in grams
let keepers: Vec<i32> = catch_g
.iter() // start the line
.filter(|w| **w >= 10) // station 1: keep the big ones
.map(|w| w * 2) // station 2: they double after molting
.collect(); // package the results
println!("{keepers:?}"); // [24, 60, 36]
}
The greatest hits:
| Tool | Job | Example |
|---|---|---|
map | transform each item | .map(|x| x * 2) |
filter | keep items that pass a test | .filter(|x| *x > 0) |
sum / count / max / min | reduce to one value | .sum::<i32>() |
collect | gather into a collection | .collect::<Vec<_>>() |
enumerate | add indexes | .enumerate() → (0, item)… |
rev | reverse the flow | .rev() |
take / skip | first n / all but first n | .take(3) |
zip | pair up two lines | a.iter().zip(b.iter()) |
The lazy secret 😴
Adaptors do nothing on their own. This line processes zero items:
let line = catch_g.iter().filter(|w| **w >= 10).map(|w| w * 2);
// nothing has happened yet: `line` is just a plan
Iterators are lazy: stations only run when a consumer
(collect(), sum(), a for loop) pulls items
through. Why is that brilliant? Rust fuses the whole pipeline into a single pass
(no wasteful intermediate lists), and the compiler then optimizes it as hard as a
hand-written loop. Zero-cost abstraction, again: code that reads like poetry,
runs like C.
Worked example: report card
fn main() {
let scores = vec![("math", 91), ("history", 58), ("biology", 77)];
let passed: Vec<&str> = scores
.iter()
.filter(|(_, s)| *s >= 60) // destructure tuples in the closure!
.map(|(subject, _)| *subject)
.collect();
let average: f64 = scores.iter().map(|(_, s)| *s as f64).sum::<f64>()
/ scores.len() as f64;
println!("passed: {passed:?}"); // ["math", "biology"]
println!("average: {average:.1}"); // 75.3
}
* and **
.iter() lends items, so closures receive references
(Lesson 7 again!). * just unwraps a reference to reach the value,
and filter adds one more layer, hence the occasional **.
When confused, try the compiler's suggestion; it's nearly always right about
these. With experience it becomes automatic.
- Building a pipeline and forgetting the consumer; the compiler literally warns: "iterators are lazy and do nothing unless consumed."
- Forgetting the type on
collect: it can build many collection types, so annotate:let v: Vec<i32> = ...collect(); - Using
.iter()when you want to keep the values themselves;.into_iter()consumes the collection and hands you owned items.
Loop → pipeline
Rewrite this with one iterator chain, no mut:
let numbers = vec![1, 2, 3, 4, 5, 6];
let mut result = Vec::new();
for n in &numbers {
if n % 2 == 0 {
result.push(n * 10);
}
}
Reveal solution
let numbers = vec![1, 2, 3, 4, 5, 6];
let result: Vec<i32> = numbers
.iter()
.filter(|n| *n % 2 == 0)
.map(|n| n * 10)
.collect();
println!("{result:?}"); // [20, 40, 60]
Longest word
Given "the quick rusty crab jumps over the lazy catfish", use
split_whitespace() and max_by_key (look it up in the std
docs; reading docs is a skill!) to find the longest word.
Reveal solution
fn main() {
let text = "the quick rusty crab jumps over the lazy catfish";
let longest = text
.split_whitespace()
.max_by_key(|word| word.len());
println!("{longest:?}"); // Some("catfish")
}
Note the Some(...): an empty sentence has no longest word, and
the type system never forgot that for a second.
FizzBuzz, graduation edition 🎓
Remember Lesson 5's FizzBuzz? Rebuild it as a pipeline: map the range
1..=20 to Strings, collect, and print. (Hint: the closure can contain
a full if/else, or even a match on
(n % 3, n % 5) if you're feeling fancy.)
Reveal solution
fn main() {
let output: Vec<String> = (1..=20)
.map(|n| match (n % 3, n % 5) {
(0, 0) => String::from("FizzBuzz"),
(0, _) => String::from("Fizz"),
(_, 0) => String::from("Buzz"),
_ => n.to_string(),
})
.collect();
println!("{}", output.join(", "));
}
Same program as Lesson 5, but look how far your vocabulary has come.