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

CategoryFunctionsClosures
ScopeCan't capture variables from outsideCan capture surrounding variables
ReuseCalled from many placesUsually one-time use
TypesExplicit type annotations requiredTypes inferred from usage
ReadabilityNamed, self-documentingConcise for obvious operations
Best forPublic APIs, helper functionsIterator 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 Iterator trait!

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:

  1. numbers.iter() produces Iterator<Item = &i32>
  2. .map(|x| x * x) takes x: &i32 in
  3. x * x auto-dereferences &i32 to i32 for arithmetic, outputting i32

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:

  1. numbers.iter() → produces Iterator<Item = &i32>
  2. .filter() takes Fn(&Item) -> bool, so it passes a reference to each item
  3. Closure receives x: &&i32 (reference to the &i32 iterator item)
  4. *x dereferences once: &&i32&i32
  5. *x % 2 auto-dereferences again: &i32i32 for the % operator
  6. .copied() converts Iterator<&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

MethodClosure receivesWhy?
.map(f)Item directlySignature: 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:

  1. Start with [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
  2. Filter evens: [2, 4, 6, 8, 10]
  3. Square: [4, 16, 36, 64, 100]
  4. 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)

MethodWhat it doesExample
.map(f)Transform each elementnumbers.iter().map(\|x\| x * 2)
.filter(f)Keep elements that matchnumbers.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)

MethodWhat it doesExample
.collect()Build a collection.collect::<Vec<i32>>()
.sum()Add all elementsnumbers.iter().sum::<i32>()
.product()Multiply all elementsnumbers.iter().product::<i32>()
.count()Count elements.filter(...).count()
.max() / .min()Find largest/smallestnumbers.iter().max()
.find(f)First element matchingnumbers.iter().find(\|&&x\| x > 5)
.any(f) / .all(f)Check if any/all matchnumbers.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

Link to Playground

Activity: From loops to iterators

See gradescope / our website for instructions

(Breaking at 5 of for some solutions)