Level 3 · Power Tools

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:

ToolJobExample
maptransform each item.map(|x| x * 2)
filterkeep items that pass a test.filter(|x| *x > 0)
sum / count / max / minreduce to one value.sum::<i32>()
collectgather into a collection.collect::<Vec<_>>()
enumerateadd indexes.enumerate()(0, item)…
revreverse the flow.rev()
take / skipfirst n / all but first n.take(3)
zippair up two linesa.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
}
💡 About those * 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.
⚠️ Common stumbles
  • 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.
Exercise 1

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]
Exercise 2

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.

Exercise 3

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.