Lecture 29 - Closures and Iterators
Logistics
- Almost done with new Rust material!
- Today: Three related topics that make Rust feel "functional"
- Next lecture (Lecture 30): File I/O, NDArray, and concurrency examples
- Then we switch to algorithms!
Take-aways from activity feedback
- Rust playground activities could be really useful, but sometimes you feel unprepared to start them / there's a gap between what we cover in lecture and what you need to get going. (I've felt this too - I'll try to be more careful about it today. I also think it helps when we pause mid-activity to go over some answers.)
- Folks want more hand-coding practice
- The live multiple-choice quiz was a (bit of a surprise) hit
- People enjoyed and benefitted from the "confidence-rating quiz" (I like it too) - we'll definitely do that again before the final
Learning objectives
By the end of today, you should be able to:
- Use iterator methods like
.map(),.filter(), and.collect() - Understand closures and the
|x|syntax (that thing that AI keeps telling you to do) - Chain iterator operations to process data functionally
- Understand references in iterators and when to use
.copied()or.cloned() - Write functional-style data processing instead of loops
Part 1: What are closures?
You've probably seen these already (I dropped them in last lecture, if you haven't seen them before... and AI suggests this a lot)
#![allow(unused)] fn main() { let numbers = vec![1, 2, 3, 4, 5]; let doubled: Vec<i32> = numbers.iter().map(|x| x * 2).collect(); }
That |x| x * 2 is a "closure"
Closures are:
- Anonymous functions (no name)
- Can be stored in variables (
let add_one = |x| x + 1;) - Can capture variables from their environment
Think of them as: Quick, throwaway functions for one-time use
Closure syntax
Basic syntax:
#![allow(unused)] fn main() { |parameters| expression }
Examples:
#![allow(unused)] fn main() { // No parameters let say_hi = || println!("Hi!"); say_hi(); // Prints "Hi!" // One parameter let double = |x| x * 2; println!("{}", double(5)); // Prints 10 // Multiple parameters let add = |x, y| x + y; println!("{}", add(3, 4)); // Prints 7 // Multiple statements (need curly braces) let complex = |x| { let doubled = x * 2; doubled + 1 }; println!("{}", complex(5)); // Prints 11 }
Compare to Python's lambda:
# Python
double = lambda x: x * 2
# Rust
let double = |x| x * 2;
Very similar!
Closures vs. functions
Functions are formal interfaces:
#![allow(unused)] fn main() { fn add(x: i32, y: i32) -> i32 { x + y } }
Closures are lightweight and flexible:
#![allow(unused)] fn main() { let add = |x, y| x + y; // Types inferred }
Closures capture their environment (TC 12:30)
This is the magic: Closures can use variables from outside!
#![allow(unused)] fn main() { let multiplier = 10; let multiply = |x| x * multiplier; // Uses 'multiplier' from outside! println!("{}", multiply(5)); // Prints 50 }
This wouldn't work with a regular function:
#![allow(unused)] fn main() { let multiplier = 10; fn multiply(x: i32) -> i32 { x * multiplier // Error! Functions can't capture environment } }
Why is this useful? You'll see in iterator examples!
Functions vs closures: When to use each
| Category | Functions | Closures |
|---|---|---|
| Scope | Can't capture variables from outside | Can capture surrounding variables |
| Reuse | Called from many places | Usually one-time use |
| Types | Explicit type annotations required | Types inferred from usage |
| Readability | Named, self-documenting | Concise for obvious operations |
| Best for | Public APIs, helper functions | Iterator chains, callbacks |
Part 2: Iterator methods
We've been using iterators since the beginning
#![allow(unused)] fn main() { let numbers = vec![1, 2, 3]; for num in numbers.iter() { // .iter() creates an iterator println!("{}", num); } }
Iterators:
- Provide values one at a time
- Lazy (don't do work until needed)
- Can be chained together
You can create iterators from:
- Vectors and arrays:
vec.iter(),arr.iter() - HashMaps:
map.iter(),map.keys(),map.values() - HashSets:
set.iter() - Strings:
s.chars(),s.split_whitespace(),s.split(',') - Ranges:
1..10 - Next lecture we'll see
reader.lines() - Anything that implements the
Iteratortrait!
The power of iterator methods
Instead of loops, we can use iterator methods
Traditional loop:
#![allow(unused)] fn main() { let numbers = vec![1, 2, 3, 4, 5]; let mut doubled = Vec::new(); for num in numbers.iter() { doubled.push(num * 2); } }
With .map():
#![allow(unused)] fn main() { let numbers = vec![1, 2, 3, 4, 5]; let doubled: Vec<i32> = numbers.iter() .map(|x| x * 2) .collect(); }
Compare to Python list comprehensions
# Python
numbers = [1, 2, 3, 4, 5]
doubled = [x * 2 for x in numbers]
# Rust equivalent
let numbers = vec![1, 2, 3, 4, 5];
let doubled: Vec<i32> = numbers.iter()
.map(|x| x * 2)
.collect();
The pattern is similar:
- Python:
[expression for item in iterable] - Rust:
iterable.iter().map(|item| expression).collect()
Iterator methods you'll use
.map() - transform each element
#![allow(unused)] fn main() { let numbers = vec![1, 2, 3]; let squared: Vec<i32> = numbers.iter() .map(|x| x * x) .collect(); // [1, 4, 9] }
.filter() - keep only some elements
#![allow(unused)] fn main() { let numbers = vec![1, 2, 3, 4, 5]; let evens: Vec<i32> = numbers.iter() .filter(|x| *x % 2 == 0) .copied() .collect(); // [2, 4] }
Compare filtering to Python:
# Python - list comprehension with condition
numbers = [1, 2, 3, 4, 5]
evens = [x for x in numbers if x % 2 == 0]
# Rust equivalent
let numbers = vec![1, 2, 3, 4, 5];
let evens: Vec<i32> = numbers.iter()
.filter(|x| *x % 2 == 0)
.copied()
.collect();
.collect() - turn iterator back into collection
#![allow(unused)] fn main() { let range: Vec<i32> = (1..=5).collect(); // [1, 2, 3, 4, 5] }
Understanding references in iterators
Let's break down what's happening with types in .map() and .filter():
Example 1: .map() with arithmetic
#![allow(unused)] fn main() { let numbers = vec![1, 2, 3]; let squared: Vec<i32> = numbers.iter() .map(|x| x * x) .collect(); }
Type breakdown:
numbers.iter()producesIterator<Item = &i32>.map(|x| x * x)takesx: &i32inx * xauto-dereferences&i32toi32for arithmetic, outputtingi32
Works without explicit dereferencing!
Example 2: .filter() with comparison
#![allow(unused)] fn main() { let numbers = vec![1, 2, 3, 4, 5]; let evens: Vec<i32> = numbers.iter() .filter(|x| *x % 2 == 0) .copied() .collect(); }
Type breakdown:
numbers.iter()→ producesIterator<Item = &i32>.filter()takesFn(&Item) -> bool, so it passes a reference to each item- Closure receives
x: &&i32(reference to the&i32iterator item) *xdereferences once:&&i32→&i32*x % 2auto-dereferences again:&i32→i32for the%operator.copied()convertsIterator<&i32>→Iterator<i32>
Alternative using pattern matching:
#![allow(unused)] fn main() { let evens: Vec<i32> = numbers.iter() .filter(|&&x| x % 2 == 0) // Destructure &&i32 to get i32 .copied() // Still need .copied() to convert Iterator<&i32> -> Iterator<i32> .collect(); }
Key differences
| Method | Closure receives | Why? |
|---|---|---|
.map(f) | Item directly | Signature: FnMut(Item) -> U |
.filter(f) | &Item (reference) | Signature: Fn(&Item) -> bool |
Why does .filter() pass a reference?
- It needs to inspect items without consuming them
- The iterator still owns the items
- Prevents accidentally moving/consuming items during filtering
The .copied() and .cloned() helpers
When working with Iterator<&T>, use these to convert to Iterator<T>:
For Copy types (i32, f64, char, etc.):
#![allow(unused)] fn main() { numbers.iter() // Iterator<&i32> .filter(|&&x| x > 2) // Still Iterator<&i32> .copied() // Now Iterator<i32> (bitwise copy, cheap!) .collect() // Vec<i32> }
For Clone types (String, Vec, etc.):
#![allow(unused)] fn main() { let words = vec!["hello".to_string(), "world".to_string()]; let filtered: Vec<String> = words.iter() // Iterator<&String> .filter(|s| s.len() > 4) // Still Iterator<&String> .cloned() // Now Iterator<String> (clones each) .collect(); // Vec<String> }
Chaining iterator operations (TC 12:35)
This is where it gets powerful:
#![allow(unused)] fn main() { let numbers = vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; let result: Vec<i32> = numbers.iter() .filter(|&&x| x % 2 == 0) // Keep evens .map(|&x| x * x) // Square them (&i32 -> i32) .filter(|&x| x > 10) // Keep if > 10 .collect(); println!("{:?}", result); // [16, 36, 64, 100] }
What happened:
- Start with [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
- Filter evens: [2, 4, 6, 8, 10]
- Square: [4, 16, 36, 64, 100]
- Keep > 10: [16, 36, 64, 100]
Compare to nested Python list comprehension:
numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
squared = [x*x for x in numbers if x % 2 == 0]
result = [x for x in squared if x > 10]
# or all at once:
result = [x*x for x in numbers if x % 2 == 0 if x*x > 10]
Rust's chained style is often clearer for multi-step transformations!
Consuming iterators: Final methods in the chain
These methods "consume" the iterator and produce a final result:
.collect() - Build a collection
#![allow(unused)] fn main() { let doubled: Vec<i32> = (1..=5).map(|x| x * 2).collect(); // Vec, HashSet, HashMap, etc. }
.sum() and .product() - Aggregate numbers
#![allow(unused)] fn main() { let total: i32 = vec![1, 2, 3, 4, 5].iter().sum(); // 15 let product: i32 = vec![2, 3, 4].iter().product(); // 24 }
The "turbofish" syntax ::<Type>:
Sometimes you need to tell Rust what type you want:
#![allow(unused)] fn main() { // If you don't annotate the variable, Rust doesn't know what type to sum to let total = vec![1, 2, 3].iter().sum::<i32>(); // Need turbofish! // Also useful when the type is truly ambiguous let result = (1..10).collect::<Vec<i32>>(); // Could be Vec, HashSet, etc. // If the variable type is annotated, turbofish is optional let values = vec![1.0, 2.5, 3.7]; let sum1: f64 = values.iter().sum(); // Type annotation on variable let sum2 = values.iter().sum::<f64>(); // Or use turbofish }
The ::<> is called "turbofish" because it looks like a fish
.max() and .min() - Find extremes
#![allow(unused)] fn main() { let numbers = vec![3, 7, 1, 9, 2]; let biggest = numbers.iter().max(); // Some(&9) let smallest = numbers.iter().min(); // Some(&1) // Returns Option because iterator might be empty! }
.count() - Count items
#![allow(unused)] fn main() { let evens = vec![1, 2, 3, 4, 5] .iter() .filter(|&&x| x % 2 == 0) .count(); // 2 }
.find() - Get first match
#![allow(unused)] fn main() { let numbers = vec![1, 2, 3, 4, 5]; let first_even = numbers.iter().find(|&&x| x % 2 == 0); // Some(&2) }
Key point: These methods consume the iterator - you can't use it after calling them!
Less common iterator methods
.fold() - Accumulate a result
#![allow(unused)] fn main() { let sum = (1..=5).fold(0, |acc, x| acc + x); // 0 + 1 + 2 + 3 + 4 + 5 = 15 }
.any() and .all() - Check conditions
#![allow(unused)] fn main() { let numbers = vec![2, 4, 6, 8]; let all_even = numbers.iter().all(|x| x % 2 == 0); // true let any_big = numbers.iter().any(|x| *x > 10); // false }
.take() and .skip() - Control how many
#![allow(unused)] fn main() { let first_three: Vec<i32> = (1..=10).take(3).collect(); // [1, 2, 3] // We saw this on the homework in the username problem! let skip_two: Vec<i32> = (1..=5).skip(2).collect(); // [3, 4, 5] }
What makes iterators "lazy"?
"Lazy" means iterators don't do work until consumed
1. Early termination - only process what you need
#![allow(unused)] fn main() { // Find first even number in a million items let first_even = (1..=1_000_000) .filter(|x| x % 2 == 0) .next(); // Stops after finding 2! Doesn't check all million items }
2. No intermediate collections
#![allow(unused)] fn main() { // Eager (bad - creates temp vectors): let temp1: Vec<i32> = numbers.iter().map(|x| x * 2).collect(); let temp2: Vec<i32> = temp1.iter().filter(|x| x > 5).collect(); let result: Vec<i32> = temp2.iter().map(|x| x + 1).collect(); // Lazy (good - one pass through, no temps): let result: Vec<i32> = numbers.iter() .map(|x| x * 2) .filter(|x| x > 5) .map(|x| x + 1) .collect(); // Only allocates final result! }
3. Works with infinite sequences
#![allow(unused)] fn main() { // This is fine - never actually creates infinite items! let first_10_evens: Vec<i32> = (0..) // Infinite range! .filter(|x| x % 2 == 0) .take(10) // Only generates 10 items .collect(); }
Iterator methods summary
Transforming iterators (return new iterators)
| Method | What it does | Example |
|---|---|---|
.map(f) | Transform each element | numbers.iter().map(\|x\| x * 2) |
.filter(f) | Keep elements that match | numbers.iter().filter(\|&&x\| x > 5) |
.take(n) | Take first n elements | (1..100).take(10) |
.skip(n) | Skip first n elements | (1..100).skip(10) |
.copied() | Copy &T to T (for Copy types) | .filter(...).copied() |
.cloned() | Clone &T to T (for Clone types) | .filter(...).cloned() |
Consuming iterators (produce final values)
| Method | What it does | Example |
|---|---|---|
.collect() | Build a collection | .collect::<Vec<i32>>() |
.sum() | Add all elements | numbers.iter().sum::<i32>() |
.product() | Multiply all elements | numbers.iter().product::<i32>() |
.count() | Count elements | .filter(...).count() |
.max() / .min() | Find largest/smallest | numbers.iter().max() |
.find(f) | First element matching | numbers.iter().find(\|&&x\| x > 5) |
.any(f) / .all(f) | Check if any/all match | numbers.iter().any(\|&x\| x > 10) |
Common pattern
#![allow(unused)] fn main() { collection.iter() // Create iterator .filter(...) // Transform/filter .map(...) // Transform/filter .collect() // Consume and produce output }
Demo: Converting a loop to iterators
Let's practice converting a loop to iterator methods together!
Given this loop:
#![allow(unused)] fn main() { let numbers = vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; let mut result = Vec::new(); for num in &numbers { if *num > 4 { result.push(num * 3); } } println!("result: {:?}", result); }
Step 1: What does this code do?
Step 2: Write iterator-pseudo-code
Step 3: Convert to iterators
Activity: From loops to iterators
See gradescope / our website for instructions
(Breaking at 5 of for some solutions)