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.
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.
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:
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.
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:
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:
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.
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:
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:
| Trait | The closure can be called… | Example consumer |
|---|---|---|
FnOnce | at least once; may consume captured values | Option::unwrap_or_else, thread::spawn |
FnMut | many times; may mutate captured values | Iterator::for_each, Vec::retain |
Fn | many times, even concurrently; only reads captures | Callbacks 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.
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:
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.
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:
| Adapter | Does |
|---|---|
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:
| Consumer | Returns |
|---|---|
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:
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).
- Write
fn parse(line: &str) -> Option<Request>usingsplit_whitespaceand?onOption.Requesthasmethod: String,path: String,status: u16,ms: u32. - 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
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/HashSetfor lookup by key,BTreeMapwhen 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;
moveforces ownership into the closure, which threads and tasks need. FnOnce,FnMutandFndescribe 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,sumorfinddrives them.
Check yourself
Quiz
0/4 correct · 4 left
1.What does this print? let v = vec![1, 2, 3]; v.iter().map(|x| { println!("{x}"); x * 2 });
2.Which is the idiomatic way to count word frequencies in a HashMap<&str, usize>?
3.Why do you usually need move when passing a closure to thread::spawn?
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.