Iterator Adapters and Closures

How iterator adapters in Rust use closures to build lazy, composable data pipelines without sacrificing performance

Iterator adapters are methods on the Iterator trait that consume an iterator and return a new one with different behaviour. They do not process any items until a terminal method like collect or for_each demands the result. A closure — an anonymous function that can capture variables from its surrounding scope — is the argument most adapters need to decide how to transform, filter, or shape the stream.

What Iterator Adapters Do

When you write v.iter().map(|x| x * 2), you create a Map iterator that wraps the original and stores the closure. Calling .next() on the Map gets the next item from the underlying iterator, runs the closure on it, and returns the result. The chain builds up a stack of adapters, but at runtime they collapse into a single loop — there are no intermediate collections allocated.

This laziness is the core design choice. An adapter chain describes what computation should happen, not when. The execution starts only when a consumer forces the iterator to yield values.

Closures as Adapter Arguments

Every adapter that needs a function — map, filter, take_while, fold, etc. — expects a closure (or a function pointer) that implements a specific trait. For most non-modifying adapters, the required trait is FnMut. That means the closure can be called many times and is allowed to mutate its captured state.

let mut counter = 0;
let labelled: Vec<String> = ["a", "b", "c"]
    .iter()
    .map(|item| {
        counter += 1;
        format!("{}-{}", counter, item)
    })
    .collect();
println!("{:?}", labelled); // ["1-a", "2-b", "3-c"]

The closure increments counter on each call. The compiler infers that counter must be captured by mutable reference, and the adapter holds that mutable borrow for as long as the iterator lives. No extra ceremony is needed.

Function pointers are closures too:

You can pass a named function instead of a closure as long as it matches the expected signature. For example, v.iter().map(str::len) works because str::len has signature fn(&str) -> usize, which coincides with FnMut(&str) -> usize.

Core Adapters and How They Use Closures

The standard library provides several adapter categories. Each one asks a different question, and the closure answers it.

Transforming values with map

map runs a closure on each item and yields the return value. It’s the most straightforward adapter — the output iterator has the same number of items as the input.

let lengths: Vec<usize> = ["Rust", "is", "fast"]
    .iter()
    .map(|word| word.len())
    .collect();
// lengths = [4, 2, 4]

A beginner might expect map to modify the original collection. It does not. It produces a new iterator; the original data is untouched. Only when a consumer like collect runs does the closure actually execute.

Filtering with filter

filter keeps only the items for which the predicate closure returns true. The closure receives a reference to the item, not the item itself, because filter must avoid moving ownership in case the predicate rejects the value.

let numbers = [1, 2, 3, 4, 5, 6];
let evens: Vec<&i32> = numbers.iter().filter(|x| **x % 2 == 0).collect();
// evens = [&2, &4, &6]

The double dereference **x appears often and trips up newcomers. iter() yields &i32. The closure parameter x is therefore &&i32. One dereference gets you to &i32, the second to i32. Rust does not auto-deref through two levels of indirection in arithmetic expressions, so the explicit ** is necessary.

Don't forget the double dereference:

Writing |x| x % 2 == 0 inside filter on an iter() iterator gives a type error because x is &&i32 and the % operator is not implemented for &&i32. Use **x or switch to into_iter() if you can give up ownership.

Filtering and transforming in one pass with filter_map

When you need to discard some items and transform the rest simultaneously, filter_map takes a closure that returns Option<T>. Only Some(value) yields value; None is skipped.

let raw = ["42", "0", "not a number", "7"];
let parsed: Vec<i32> = raw
    .iter()
    .filter_map(|s| s.parse::<i32>().ok())
    .collect();
// parsed = [42, 0, 7]

This is cleaner than chaining filter and map separately, which would require unwrapping inside map and risk a panic if the earlier filter had a bug.

Index tracking with enumerate

enumerate wraps each item in a (index, value) pair, where index starts at zero. It does not need a closure; it’s an adapter that just adds positional information.

for (i, ch) in "abc".chars().enumerate() {
    println!("position {}: {}", i, ch);
}

Slicing the stream with take and skip

take(n) limits the output to the first n items. skip(n) discards the first n items and yields the rest. Both are useful for pagination or ignoring headers.

let numbers = 0..10;
let middle: Vec<i32> = numbers.skip(3).take(4).collect();
// middle = [3, 4, 5, 6]

Neither needs a closure; they’re range-based controls.

Conditional slicing with take_while and skip_while

These adapters use a predicate closure to decide when to stop taking or when to start yielding. take_while yields items until the predicate returns false, then permanently stops. skip_while drops items while the predicate is true, then yields everything else — including the first item that returned false.

let values = [2, 4, 7, 8, 10];
let until_odd: Vec<&i32> = values.iter().take_while(|x| **x % 2 == 0).collect();
// until_odd = [&2, &4] — stops before 7
let from_odd: Vec<&i32> = values.iter().skip_while(|x| **x % 2 == 0).collect();
// from_odd = [&7, &8, &10]

take_while does not resume after the first false:

Once take_while sees a false, it never yields again, even if later items would return true. This is different from filter, which checks every item. If you need to skip a few items but keep checking later, combine skip_while with a subsequent filter.

Combining iterators with chain and zip

chain appends one iterator to another end-to-end. zip pairs items from two iterators into tuples, stopping when the shorter one is exhausted. Neither inherently takes a closure, but they are frequently mixed with closures in the rest of the pipeline.

let first = [1, 2];
let second = [3, 4, 5];
let combined: Vec<i32> = first.iter().chain(second.iter()).copied().collect();
// combined = [1, 2, 3, 4, 5]
let pairs: Vec<(&i32, &i32)> = first.iter().zip(second.iter()).collect();
// pairs = [(&1, &3), (&2, &4)]

Flattening nested structures with flat_map and flatten

flat_map applies a closure that returns an IntoIterator for each item, then concatenates all the resulting iterators into one flat stream. flatten is similar but works when the items themselves are already iterators, without a mapping function.

let words = ["hello world", "rust rocks"];
let chars: Vec<char> = words
    .iter()
    .flat_map(|s| s.chars())
    .collect();
// chars = ['h','e','l','l','o',' ','w','o','r','l','d','r','u','s','t',' ','r','o','c','k','s']

flat_map is the go-to tool for processing one-to-many transformations without creating intermediate Vec containers.

Advanced transformations with map_while and scan

map_while acts like a streaming filter_map: the closure returns Option<T>, and the first None terminates the iterator permanently. It’s useful when parsing a stream where a sentinel indicates the end.

let raw = ["1", "2", "stop", "3"];
let numbers: Vec<i32> = raw
    .iter()
    .map_while(|s| s.parse::<i32>().ok())
    .collect();
// numbers = [1, 2] — parsing stops on "stop"

scan carries an internal mutable state across iterations. Its closure receives a mutable reference to that state plus the current item, and returns Option<T>. This lets you implement accumulators without consuming the iterator prematurely.

let items = [1, 2, 3, 4];
let running_total: Vec<i32> = items
    .iter()
    .scan(0, |state, &x| {
        *state += x;
        Some(*state)
    })
    .collect();
// running_total = [1, 3, 6, 10]

Debugging pipelines with inspect

inspect calls a closure on each item without changing the stream. It's designed for side effects like logging, and it’s especially helpful when you need to see values flowing through a chain without altering the pipeline logic.

let result: Vec<i32> = (1..=5)
    .inspect(|x| println!("before map: {}", x))
    .map(|x| x * 3)
    .inspect(|x| println!("after map: {}", x))
    .collect();

Closures That Capture the Environment

All adapters that accept closures let those closures capture variables from the enclosing scope. The compiler automatically selects the capture mode — by shared reference, by mutable reference, or by value — based on how the closure uses those variables.

let multiplier = 3;
let base = [1, 2, 3];
let scaled: Vec<i32> = base.iter().map(|x| x * multiplier).collect();
// multiplier is captured by shared reference

If the closure mutates a captured variable, the adapter holds a mutable borrow of that variable. This prevents other code from accessing the variable while the iterator exists.

let mut sum = 0;
let items = [1, 2, 3];
let _: Vec<()> = items.iter().map(|x| sum += x).collect();
// After collect, sum == 6

Sometimes you need the closure to own the captured data — for example, when the iterator is returned from a function or passed to another thread. The move keyword forces ownership transfer.

fn make_adder(base: i32) -> impl FnMut(i32) -> i32 {
    move |x| x + base
}
let add_five = make_adder(5);
let result: Vec<i32> = (1..=3).map(add_five).collect();
// result = [6, 7, 8]

move closures work naturally with adapters:

map and other adapters accept FnMut closures, which includes move closures. This makes it straightforward to inject configuration into a pipeline: create the configured closure, then pass it to the adapter.

Why This Combination Exists

Languages that treat functions as first-class values often ship a collection library full of higher-order functions — map, filter, reduce. Rust’s ownership system complicates the picture. You cannot simply give a map function a reference to a stack-allocated closure and call it a day: the lifetime of that closure must be tracked, and the iterator may outlive the stack frame.

Iterator adapters solve this by embedding the closure directly in the adapter struct. The closure’s type becomes part of the iterator’s type, the borrow checker verifies that captured references are valid for the entire iterator lifetime, and the whole construct compiles down to a single inline loop. This is the design that makes “zero-cost abstractions” a reality — you write functional-style code and the compiler produces assembly comparable to a hand-written for loop.

Chaining for Readable Data Processing

The real power of adapters shows when you chain several into a single expression. Tasks that would otherwise require nested loops and temporary vectors become a sequence of named operations.

#[derive(Debug)]
struct Student {
    name: String,
    grade: u8,
}
let students = vec![
    Student { name: "Alice".into(), grade: 85 },
    Student { name: "Bob".into(), grade: 72 },
    Student { name: "Carol".into(), grade: 91 },
    Student { name: "Dave".into(), grade: 64 },
];
let high_scorers: Vec<String> = students
    .iter()
    .filter(|s| s.grade >= 80)
    .map(|s| s.name.clone())
    .collect();
println!("{:?}", high_scorers); // ["Alice", "Carol"]

Reading left to right: take the students, keep those with grade >= 80, extract their names, and collect into a vector. The compiler fuses the filter and map into a single pass — no intermediate allocation of filtered students exists.

Collecting errors early

A common pattern uses the fact that FromIterator is implemented for Result<Vec<T>, E>. When you have an iterator of Result items, collect() can short-circuit on the first Err.

let inputs = ["42", "7", "invalid", "0"];
let numbers: Result<Vec<i32>, _> = inputs
    .iter()
    .map(|s| s.parse::<i32>())
    .collect();
match numbers {
    Ok(nums) => println!("Parsed: {:?}", nums),
    Err(e) => println!("Failed to parse: {}", e),
}

The adapter chain tries to parse every input. The moment parse returns an Err, the collection stops and returns that error. No further parsing happens. This replaces a hand-written loop with a manual ? inside each iteration.

Common Mistakes with Adapters and Closures

Forgetting that adapters are lazy

Declaring a variable with a chain of adapters does nothing. The computation runs only when a consumer is called. A compiler warning often alerts you if an iterator is created but never consumed, but it’s worth internalizing this rule early.

let nums = [1, 2, 3];
let doubled = nums.iter().map(|x| x * 2); // No work yet, 'doubled' is an iterator
// To actually double, you must consume it:
let result: Vec<i32> = doubled.collect();

Moving values out of a closure that borrows the iterator

If you consume an iterator inside a closure passed to an adapter of the same iterator, you create a self-referential borrow that Rust rejects. For example, calling collect() inside filter on the same iterator chain doesn’t work because it would mutably borrow the iterator while it’s already borrowed immutably. Instead, collect into a collection first, then iterate.

Overusing unwrap() inside adapters

It’s tempting to write .filter_map(|x| x.parse().ok()).map(|n| n * 2). This works, but if an item fails to parse, it is silently dropped. More robust code would separate validation from processing: use collect into a Result to catch errors, or handle None cases explicitly.

Mismatched dereferencing in filter predicates

As noted earlier, filter on an iter() iterator passes &&T to the closure. Newcomers often write |x| x > 0 and get a type error. Using into_iter() avoids the reference entirely, but sacrifices ownership. The double dereference **x is correct when you need to keep the original data accessible.

Use copied() or cloned() to reduce dereference noise:

If the items implement Copy, chaining .copied() after iter() yields an iterator of owned T, so subsequent closures work directly with values. This eliminates the need for double dereferences. For Clone types, use .cloned().

Deciding Between Adapters and Loops

A chain of adapters is often the clearest way to express a transformation because it separates the what (mapping, filtering) from the how (the loop body). However, when the logic inside each step involves complex branching or early exits that aren’t easily modeled by available adapters, an explicit for loop may be more readable. The performance is equivalent, so choose based on which form communicates intent better to the next person reading the code.


Summary

Iterator adapters turn a sequence of data into a pipeline. Closures provide the instructions at each stage — what to keep, what to transform, when to stop. Because the adapter holds the closure and the compiler inlines everything, the resulting code runs as a single fused loop with no heap allocations.

The most impactful pattern to take from this is the combination of map, filter, and collect with a Result type: it replaces entire error-prone parsing loops with a short chain that short-circuits on the first failure. Once you are comfortable with that, explore flat_map for nested data, scan for stateful transformations, and inspect for debugging. Iterators and closures together form the backbone of idiomatic Rust data processing.