Code of the Day
IntermediateTypes & traits

Closures and iterators

Capture values from the environment with closures, then chain iterator adapters for expressive, zero-cost data transformations.

RustIntermediate13 min read
Recommended first
By the end of this lesson you will be able to:
  • Write closures that capture values by reference or by move
  • Distinguish Fn, FnMut, and FnOnce based on how the closure uses captured values
  • Chain iterator adapters including map, filter, flat_map, and collect
  • Implement the Iterator trait for a custom type

are anonymous functions that can capture values from their surrounding scope. are lazy sequences that produce values on demand. Together they form Rust's functional programming layer — expressive chains of transformations that compile down to tight loops with no overhead.

Closures

A closure is written with |params| body:

let add_one = |x: i32| x + 1;
println!("{}", add_one(5));   // 6

let greet = |name| format!("Hello, {}!", name);
println!("{}", greet("Ferris"));

Type annotations are optional — the compiler infers them from usage. Unlike functions, closures can capture variables from the enclosing scope:

let offset = 10;
let add_offset = |x| x + offset;   // captures `offset` by reference
println!("{}", add_offset(5));      // 15

Capture modes and Fn traits

How a closure captures its environment determines which Fn trait it implements:

TraitWhat the closure can do with captured values
FnBorrows captured values (can call many times)
FnMutMutably borrows (can call many times, but not concurrently)
FnOnceTakes ownership (can only call once)

Every closure implements at least FnOnce. If it doesn't consume or mutate captured values, it also implements Fn.

let s = String::from("hello");

// FnOnce — consumes s
let consume = || drop(s);
consume();  // fine
// consume();  // ERROR — s already moved into the first call

// FnMut — mutates captured counter
let mut count = 0;
let mut increment = || { count += 1; count };
println!("{}", increment());  // 1
println!("{}", increment());  // 2

move closures

Add move to force the closure to take ownership of everything it captures — required when the closure outlives the scope it was defined in (e.g., when passing it to a thread):

let name = String::from("Ada");
let greeting = move || format!("Hello, {}!", name);
// name is no longer accessible here — it was moved into the closure
println!("{}", greeting());

move closures are essential for threads. When you spawn a thread, the closure must own its data (or have 'static references) because the current scope may end before the thread does. The compiler will tell you when move is needed.

The Iterator trait

The Iterator trait requires one method — next() — that returns Option<Self::Item>:

pub trait Iterator {
    type Item;
    fn next(&mut self) -> Option<Self::Item>;
    // ... dozens of default methods
}

When next() returns Some(item), the iterator yields that item. When it returns None, the sequence is exhausted. All the adapter methods (map, filter, etc.) are default implementations built on top of next().

Iterator adapters

transform an iterator into another iterator. They are lazy — nothing is computed until you consume the iterator:

let v = vec![1, 2, 3, 4, 5];

// map — transform each element
let doubled: Vec<i32> = v.iter().map(|x| x * 2).collect();

// filter — keep only elements matching a predicate
let evens: Vec<&i32> = v.iter().filter(|x| **x % 2 == 0).collect();

// chain of adapters
let result: Vec<i32> = v.iter()
    .filter(|&&x| x % 2 != 0)  // odd numbers
    .map(|&x| x * x)            // square them
    .collect();
// result = [1, 9, 25]

The double-dereference **x in filter above appears because iter() yields &i32 references, and the closure parameter is then &&i32. Using into_iter() yields owned values; iter_mut() yields &mut i32. The right choice depends on whether you want ownership, shared access, or mutable access.

flat_map

flat_map applies a closure that returns an iterator and flattens the results:

let words = vec!["hello world", "foo bar"];
let letters: Vec<&str> = words.iter()
    .flat_map(|s| s.split_whitespace())
    .collect();
// ["hello", "world", "foo", "bar"]

collect and type inference

collect() consumes the iterator and assembles the results into a collection. The type of collection is usually inferred from context:

let squares: Vec<i32>               = (1..=5).map(|x| x * x).collect();
let unique: std::collections::HashSet<i32> = vec![1, 2, 2, 3].into_iter().collect();

If the compiler can't infer what to collect into, annotate the variable or use the turbofish: .collect::<Vec<_>>().

Implementing Iterator for a custom type

struct Counter {
    count: u32,
    max: u32,
}

impl Counter {
    fn new(max: u32) -> Self { Counter { count: 0, max } }
}

impl Iterator for Counter {
    type Item = u32;

    fn next(&mut self) -> Option<Self::Item> {
        if self.count < self.max {
            self.count += 1;
            Some(self.count)
        } else {
            None
        }
    }
}

// Counter now gets all iterator adapters for free:
let sum: u32 = Counter::new(5).filter(|x| x % 2 != 0).sum();
// sum = 1 + 3 + 5 = 9

Check your understanding

Knowledge check

  1. 1.
    A closure that reads a captured String value (without consuming or mutating it) implements which Fn trait?
  2. 2.
    Iterator adapters like map and filter do their work as soon as they are called.
  3. 3.
    What does flat_map do differently from map?

Do it yourself

Use iterator adapters to solve these without explicit loops:

  1. Given words: Vec<String>, produce a Vec<String> containing only words longer than 4 characters, uppercased.
  2. Given nums: Vec<i32>, compute the sum of squares of all odd numbers.
  3. Implement Iterator for a Fibonacci sequence type that yields the next Fibonacci number on each call.

Where to go next

Closures and iterators complete the intermediate track's core toolkit. The lab at the end of this module has scenario questions on all the topics covered. After that, the advanced track opens with concurrency — where closures, ownership, and Send/Sync combine to make data-race-free parallelism a compile-time guarantee.

Finished reading? Mark it complete to track your progress.

On this page