Step 07 of 17

Collections, closures & iterators

Use Vec, HashMap and HashSet, write closures that capture by reference or move, and build iterator chains that compile to tight loops.

18 min 6 sections

Most real code is about collections: load some records, filter them, group them, sum them. Rust's standard collections are few and well designed, and its iterators let you express those transformations as a chain of small steps that compiles down to the same machine code as a hand-written loop. Closures tie it together. This lesson covers Vec, HashMap and HashSet, how closures capture their environment, and the iterator adapters you'll use every day.

Vec

Vec<T> is a growable array on the heap, and the collection you'll reach for 90% of the time.

rust
fn main() {
    let mut v: Vec<i32> = Vec::new();
    v.push(3);
    v.push(1);
    v.extend([4, 1, 5]);

    let w = vec![9, 2, 6]; // the vec! macro builds one from a list

    println!("{} {}", v.len(), v[0]);       // 5 3
    println!("{:?}", v.get(10));            // None instead of a panic
    println!("{:?}", v.last());             // Some(5)

    v.sort();                               // in place: [1, 1, 3, 4, 5]
    v.dedup();                              // remove consecutive duplicates: [1, 3, 4, 5]
    v.retain(|&x| x != 4);                  // keep matching: [1, 3, 5]
    let popped = v.pop();                   // Some(5)
    println!("{v:?} {popped:?} {}", w.contains(&2));
}

A Vec has a length and a capacity. When a push would exceed the capacity, it allocates a bigger buffer (roughly doubling) and moves the elements. If you know the size up front, Vec::with_capacity(n) avoids the regrowth.

Three ways to loop, and what each one does to the vector:

rust
fn main() {
    let mut names = vec![String::from("ana"), String::from("bo")];

    for n in &names { println!("{n}"); }          // &String: borrow each element
    for n in &mut names { n.push('!'); }          // &mut String: modify in place
    for n in names { println!("owned {n}"); }     // String: consumes the Vec
    // names can't be used here: the last loop moved it
}

for x in &v is shorthand for v.iter(), &mut v for v.iter_mut(), and plain v for v.into_iter().

HashMap and HashSet

HashMap<K, V> maps keys to values. Keys must implement Eq and Hash; strings, integers, tuples of those, and your own types with #[derive(PartialEq, Eq, Hash)] all do.

rust
use std::collections::HashMap;

fn main() {
    let mut stock: HashMap<String, u32> = HashMap::new();
    stock.insert("apple".to_string(), 3);
    stock.insert("pear".to_string(), 0);

    match stock.get("apple") {          // look up by &str: no allocation needed
        Some(n) => println!("{n} apples"),
        None => println!("no apples"),
    }

    if let Some(n) = stock.get_mut("pear") {
        *n += 10;
    }

    stock.remove("apple");
    for (fruit, count) in &stock {      // iteration order is unspecified
        println!("{fruit}: {count}");
    }
}

The entry API is the idiomatic way to insert-or-update in one lookup:

rust
use std::collections::HashMap;

fn main() {
    let text = "the cat and the hat and the bat";
    let mut counts: HashMap<&str, usize> = HashMap::new();
    for word in text.split_whitespace() {
        *counts.entry(word).or_insert(0) += 1;
    }
    println!("{}", counts["the"]); // 3

    let mut groups: HashMap<usize, Vec<&str>> = HashMap::new();
    for word in text.split_whitespace() {
        groups.entry(word.len()).or_default().push(word);
    }
    println!("{:?}", groups.get(&3).map(|g| g.len())); // Some(8)
}

entry(key) returns a handle to the slot, and or_insert / or_default / or_insert_with fill it if it's empty, then give you a &mut V.

HashSet<T> stores unique values and supports set operations (union, intersection, difference). Need sorted keys? Use BTreeMap and BTreeSet, which keep keys ordered and iterate in order. VecDeque is a double-ended queue for when you push and pop at both ends.

Closures

A closure is an anonymous function that can capture variables from the scope it's defined in:

rust
fn main() {
    let threshold = 10;
    let is_big = |n: i32| n > threshold; // captures threshold by reference
    println!("{} {}", is_big(5), is_big(50));

    let add = |a, b| a + b;              // parameter and return types inferred
    println!("{}", add(2, 3));
}

The compiler picks the least powerful capture that works:

  • By shared reference if the closure only reads the variable.
  • By mutable reference if it modifies it.
  • By value (move) if it consumes it, for example by returning or dropping it.
rust
fn main() {
    let mut log = Vec::new();
    let mut record = |msg: &str| log.push(msg.to_string()); // borrows log mutably
    record("start");
    record("stop");
    println!("{log:?}"); // fine: the closure's borrow ended at its last use
}

move forces every captured variable to be moved (or copied) into the closure. You need it when the closure must outlive the current scope, as with threads and async tasks:

rust
use std::thread;

fn main() {
    let name = String::from("worker-1");
    let handle = thread::spawn(move || {
        println!("hello from {name}"); // name was moved into the closure
    });
    handle.join().unwrap();
}

Functions that accept closures say what they'll do with them through three traits:

TraitThe closure can be called…Example consumer
FnOnceat least once; may consume captured valuesOption::unwrap_or_else, thread::spawn
FnMutmany times; may mutate captured valuesIterator::for_each, Vec::retain
Fnmany times, even concurrently; only reads capturesCallbacks shared between threads

Every Fn is also FnMut, and every FnMut is also FnOnce. When you write a function that takes a closure, ask for the most permissive trait that works for you (prefer FnOnce over FnMut over Fn), so callers can pass the widest range of closures.

rust
fn retry<T, E>(times: u32, mut op: impl FnMut() -> Result<T, E>) -> Result<T, E> {
    let mut last = op();
    for _ in 1..times {
        if last.is_ok() { break; }
        last = op();
    }
    last
}

Iterators

An iterator is anything that implements the Iterator trait, which has one required method:

rust
trait Iterator {
    type Item;
    fn next(&mut self) -> Option<Self::Item>;
}

Each call to next yields Some(item) until the iterator is exhausted, then None. A for loop is just repeated calls to next.

On top of next, the trait provides dozens of methods. Adapters take an iterator and return a new one; they're lazy and do nothing until something consumes them. Consumers drive the iteration and produce a result.

rust
fn main() {
    let orders = vec![("ana", 120), ("bo", 40), ("cy", 300), ("ana", 80)];

    let big_total: i32 = orders
        .iter()
        .filter(|(_, amount)| *amount >= 100)   // adapter
        .map(|(_, amount)| amount)              // adapter
        .sum();                                 // consumer
    println!("{big_total}"); // 420

    let names: Vec<&str> = orders.iter().map(|(name, _)| *name).collect();
    println!("{names:?}");
}

The adapters you'll use most:

AdapterDoes
map(f)Transforms each item
filter(p)Keeps items where p is true
filter_map(f)Maps to Option and drops the Nones — map and filter in one
flat_map(f) / flatten()Maps to an iterator and concatenates the results
enumerate()Pairs each item with its index
zip(other)Pairs items from two iterators
take(n) / skip(n)The first n / everything after the first n
take_while(p) / skip_while(p)Stop or start at the first item failing / passing p
chain(other)One iterator after another
rev()Reverse (for iterators that can run backwards)
peekable()Lets you look at the next item without consuming it

And consumers:

ConsumerReturns
collect()A collection: Vec, HashMap, String, HashSet…
sum() / product() / count()A number
min() / max() / min_by_key(f) / max_by_key(f)Option<Item>
find(p) / position(p)The first match / its index, as Option
any(p) / all(p)bool, stopping early
fold(init, f)A single accumulated value
for_each(f)Nothing; runs f for its side effects

collect is driven by the type you ask for. Turbofish syntax (collect::<Vec<_>>()) or a type annotation on the binding tells it what to build. It can even collect an iterator of Results into a Result of a collection, stopping at the first error:

rust
fn main() {
    let inputs = ["1", "2", "x", "4"];
    let parsed: Result<Vec<i32>, _> = inputs.iter().map(|s| s.parse::<i32>()).collect();
    println!("{parsed:?}"); // Err(ParseIntError { kind: InvalidDigit })

    let valid: Vec<i32> = inputs.iter().filter_map(|s| s.parse().ok()).collect();
    println!("{valid:?}"); // [1, 2, 4]

    let joined = valid.iter().map(|n| n.to_string()).collect::<Vec<_>>().join(", ");
    println!("{joined}"); // 1, 2, 4
}

Laziness and zero cost

Because adapters are lazy, nothing happens until a consumer runs, and then each item flows through the entire chain before the next one starts. No intermediate vectors are built. (1..).map(|n| n * n).filter(|n| n % 3 == 1).take(5) works on an infinite range because take(5) stops pulling after five items.

The compiler inlines the whole chain into one loop. Iterator code is usually exactly as fast as the indexed loop you'd write by hand, and sometimes faster, because it can skip bounds checks.

iter, iter_mut and into_iter

iter() yields &T, iter_mut() yields &mut T, and into_iter() yields T by value (consuming the collection). That's why closures in chains over .iter() see references, and why you'll write |&x| or *x to get at a Copy value. When the types confuse you, hover over the closure parameter in your editor.

Practice

You have web server log lines in this format: "GET /api/users 200 35" (method, path, status, milliseconds).

  1. Write fn parse(line: &str) -> Option<Request> using split_whitespace and ? on Option. Request has method: String, path: String, status: u16, ms: u32.
  2. From a Vec<&str> of lines, using iterator chains only (no explicit loops), compute:
    • the number of lines that failed to parse,
    • the average latency of successful (2xx) requests,
    • a HashMap<String, usize> of request counts per path,
    • the slowest request's path.
Show solution
rust
use std::collections::HashMap;

#[derive(Debug)]
struct Request {
    method: String,
    path: String,
    status: u16,
    ms: u32,
}

fn parse(line: &str) -> Option<Request> {
    let mut parts = line.split_whitespace();
    Some(Request {
        method: parts.next()?.to_string(),
        path: parts.next()?.to_string(),
        status: parts.next()?.parse().ok()?,
        ms: parts.next()?.parse().ok()?,
    })
}

fn main() {
    let lines = vec![
        "GET /api/users 200 35",
        "POST /api/users 201 80",
        "GET /api/users 500 1200",
        "garbage",
        "GET /health 200 2",
    ];

    let requests: Vec<Request> = lines.iter().filter_map(|l| parse(l)).collect();
    let failed = lines.len() - requests.len();

    let ok: Vec<u32> = requests
        .iter()
        .filter(|r| (200..300).contains(&r.status))
        .map(|r| r.ms)
        .collect();
    let avg = ok.iter().sum::<u32>() as f64 / ok.len().max(1) as f64;

    let per_path = requests.iter().fold(HashMap::new(), |mut acc, r| {
        *acc.entry(r.path.clone()).or_insert(0usize) += 1;
        acc
    });

    let slowest = requests.iter().max_by_key(|r| r.ms).map(|r| r.path.as_str());

    println!("failed: {failed}");               // 1
    println!("avg 2xx ms: {avg:.1}");           // 39.0
    println!("per path: {per_path:?}");
    println!("slowest: {slowest:?}");           // Some("/api/users")
    println!("first method: {}", requests[0].method);
}

parse uses ? on Option: any missing field or failed number returns None from the whole function. .ok()? turns a Result into an Option first. ok.len().max(1) avoids dividing by zero when there are no successful requests. fold builds the map in one pass; a for loop with entry is equally idiomatic and often clearer.

Key takeaways

  • Vec<T> is the default collection; HashMap/HashSet for lookup by key, BTreeMap when you need ordered keys.
  • The entry API (entry(k).or_insert(…)) inserts or updates in a single lookup.
  • Closures capture by reference, mutable reference or value; move forces ownership into the closure, which threads and tasks need.
  • FnOnce, FnMut and Fn describe how a closure may be called; accept the most permissive one that works.
  • Iterator adapters are lazy and compile to tight loops; a consumer such as collect, sum or find drives them.

Check yourself

Quiz

0/4 correct · 4 left

  1. 1.What does this print? let v = vec![1, 2, 3]; v.iter().map(|x| { println!("{x}"); x * 2 });

  2. 2.Which is the idiomatic way to count word frequencies in a HashMap<&str, usize>?

  3. 3.Why do you usually need move when passing a closure to thread::spawn?

  4. 4.What does ["1", "x", "3"].iter().map(|s| s.parse::<i32>()).collect::<Result<Vec<_>, _>>() produce?

Finished reading?

Mark it complete to fill in your roadmap.