scipio avatar

Learn Rust Series (#11) - Closures & the Iterator Trait

scipio

Published: 29 Jul 2026 › Updated: 29 Jul 2026Learn Rust Series (#11) - Closures & the Iterator Trait

Learn Rust Series (#11) - Closures & the Iterator Trait

Learn Rust Series (#11) - Closures & the Iterator Trait

rust-banner.png

What will I learn

  • You will learn what closures are and how they capture the surrounding environment;
  • the three closure traits Fn, FnMut, and FnOnce, and what actually decides which one you get;
  • how to take a closure as a parameter, and why that abstraction costs nothing at runtime;
  • how the Iterator trait works, and why adapters like map and filter are lazy;
  • the difference between adapters that build a pipeline and consumers like collect, sum, and fold that run it;
  • how to write your own iterator, and how all of this compares to the Python you probably already know.

Requirements

  • A working modern computer running macOS, Windows or Ubuntu;
  • An installed Rust toolchain (via rustup, from rustup.rs);
  • The previous ten episodes read, and especially ownership, borrowing and traits fresh in your head;
  • The ambition to learn systems programming from the ground up.

Difficulty

  • Beginner

Curriculum (of the Learn Rust Series):

Learn Rust Series (#11) - Closures & the Iterator Trait

Right, this is the episode where Rust stops feeling like a strict schoolmaster and starts feeling expressive. For ten episodes we have been building the spine of the language -- ownership, borrowing, structs, enums, traits, and last time the fine print on borrowing, lifetimes. All of that was foundation. Today we finally get to spend it on something that reads like a pleasure to write. Closures and iterators together give you a functional style for transforming data that is compact, readable, and -- this is the part that still delights me after years of Rust -- costs you absolutely nothing at runtime. A chain of map and filter compiles down to roughly the same machine code as the hand-written for loop you would have grumbled your way through in C. You get the elegance of Python and the speed of assembly, in the same three lines.

Having said that, there are quite some subtleties hiding under the pretty syntax, and if you skip past them you will hit confusing error messages later. So we take them one at a time. First, as always, last episode's homework.

Solutions to Episode 10's exercises

Episode 10 was lifetimes, the steepest idea in the fundamentals, so let us clear the three exercises with full code before we move on.

Exercise 1 asked for a wider function that returns whichever of two string slices has the fewer characters -- the mirror image of the longest function from that episode:

fn wider<'a>(a: &'a str, b: &'a str) -> &'a str {
    if a.len() < b.len() { a } else { b }
}

fn main() {
    let first = String::from("elephant");
    let second = String::from("cat");
    println!("{}", wider(first.as_str(), second.as_str())); // cat
}

The point of the exercise was to feel why the annotations are needed. Both inputs and the output all wear the same 'a, because at compile time the compiler cannot know which branch runs -- the returned reference might be a or it might be b. Tying all three to one lifetime is exactly the promise "the result is valid for as long as both inputs are". Drop either input too early and the borrow checker stops you, precisely as it did with longest.

Exercise 2 wanted a struct Tagged<'a> with one owned field and one borrowed field, plus a method that returns an owned String:

struct Tagged<'a> {
    id: u32,
    label: &'a str,
}

impl<'a> Tagged<'a> {
    fn describe(&self) -> String {
        format!("#{}: {}", self.id, self.label)
    }
}

fn main() {
    let text = String::from("important");
    let tag = Tagged { id: 7, label: text.as_str() };
    println!("{}", tag.describe()); // #7: important
}

The subtlety is the return type of describe. The struct borrows its label, so it carries a lifetime 'a -- but describe builds a brand new owned String with format!, and an owned String carries its data with it. It borrows from nothing, so no lifetime annotation appears on the method's output at all. Owned data sidesteps lifetimes entirely; that is the whole insight.

Exercise 3 was longest_word, returning the longest whitespace-separated word, and the trick was that it needs zero explicit lifetime annotations:

fn longest_word(sentence: &str) -> &str {
    let mut longest = "";
    for word in sentence.split_whitespace() {
        if word.len() > longest.len() {
            longest = word;
        }
    }
    longest
}

fn main() {
    let s = String::from("the quick brown fox jumped");
    println!("{}", longest_word(&s)); // jumped
}

One input reference, one output reference: elision rule 2 from last episode assigns the single input lifetime to the output automatically, so you write nothing. Try adding <'a> by hand and you will find it means exactly the same thing -- the compiler was just filling it in for you. Good. Now, closures!

Closures capture their environment

A closure is an anonymous function that can remember values from the scope where it was written. You spell it with vertical bars for the parameters, and inside the body it can reach out and use variables from around it without them being passed in as arguments:

fn main() {
    let factor = 3;
    let scale = |x: i32| x * factor; // captures `factor` from the environment
    println!("{}", scale(10)); // 30
    println!("{}", scale(7));  // 21
}

An ordinary fn multiply(x: i32) -> i32 cannot see factor -- a plain function has no environment to draw from, only its parameters. But the closure can. That capturing is the whole point: it lets you build tiny behaviours that carry a little context with them, which is exactly what iterator adapters need in a moment. If you know Python's lambda, this will feel instantly familiar -- lambda x: x * factor does the same thing. The difference is that Rust's closures are far more capable and, as we will see, compile down to something far faster than any interpreted lambda ever could.

Notice too that the closure's parameter and return types can usually be inferred. I wrote |x: i32| for clarity, but Rust is happy with |x| x * factor in most contexts, working the types out from how the closure is used. This is one of the few places Rust relaxes its "annotate everything" attitude, because closures are typically small, local, and used right where they are defined.

The three closure traits

Here is the first subtlety, and it is worth slowing down for. Closures capture in whatever way they actually use their captures, and Rust encodes that choice in three traits. A closure that only reads its captures implements Fn. One that mutates a capture implements FnMut. One that consumes a capture -- takes ownership of it -- implements FnOnce and, as the name warns, can be called only once.

You do not pick these; the compiler reads your closure body and works out the most permissive one that fits. The scale closure above only read factor, so it is an Fn. Here is a closure that mutates a counter it captured, which makes it FnMut:

fn main() {
    let mut count = 0;
    let mut bump = || { count += 1; count }; // mutates `count`, so FnMut
    println!("{}", bump()); // 1
    println!("{}", bump()); // 2
    println!("total is now {count}"); // 2
}

See that the closure itself has to be stored in a mut binding (let mut bump), because calling it changes captured state -- the borrow checker from episode 3 is still very much awake and watching. And here is FnOnce, forced with the move keyword, which hands ownership of the captured value into the closure:

fn main() {
    let name = String::from("Rust");
    let take = move || name; // moves `name` into the closure
    let owned = take();      // returns the String, consuming the closure
    println!("{owned}");
    // let again = take();   // would NOT compile: `take` was already consumed
}

Because take gives away the String it captured, calling it a second time is impossible -- there is nothing left to give. That commented-out line is the thing to internalise: FnOnce means once. You rarely name these three traits for everyday code, because the compiler infers them silently. But you need to understand them the moment you accept a closure as a parameter, which is the very next thing we do.

Passing closures into functions

To take a closure as an argument, you make the function generic over one of the closure traits. Use the Fn bound when you only need to call the closure (possibly many times), and the caller can then pass any closure that matches the signature:

fn apply_twice<F: Fn(i32) -> i32>(f: F, x: i32) -> i32 {
    f(f(x))
}

fn main() {
    let inc = |n| n + 1;
    println!("{}", apply_twice(inc, 10)); // 12
    println!("{}", apply_twice(|n| n * n, 3)); // 81
}

Look at the bound: <F: Fn(i32) -> i32>. That is the same generics machinery from episode 8, only the trait we are bounding on happens to be one of the closure traits. It reads as "F is some type that can be called with an i32 and gives back an i32". The two calls in main pass two different closures, and each produces a different concrete F.

That last sentence is the important one. Because F is a generic type resolved at compile time, each call site is monomorphized -- the compiler stamps out a specialised copy of apply_twice for each concrete closure type and inlines the call straight in. There is no function pointer being chased at runtime, no indirection, no vtable. The abstraction is genuinely free. This is the Rust promise you will hear me repeat until you are sick of it: zero-cost abstractions. In Python, passing a function around always goes through a dynamic call -- the interpreter looks up the callable and dispatches to it every single time. Here the compiler bakes the whole thing in and the overhead vanishes into thin air.

(There is another way to pass closures, using dyn Fn behind a pointer, which trades that inlining for flexibility -- but that involves dynamic dispatch and trait objects, and those get a proper episode of their own further down the line. For now, generics-over-Fn is the tool you want.)

The Iterator trait is really just one method

Now to the second half of today, and it dovetails beautifully with closures. An iterator is any type that implements the Iterator trait, and here is the thing that surprises people: that trait has exactly one required method. It is called next, and it hands back Some(item) for each element in turn, then None once the sequence is exhausted. Everything else -- all the dozens of adapters and consumers you will ever use -- is built on top of that single method as default methods on the trait.

That means you can implement Iterator yourself for any type, and it is genuinely easy:

struct Countdown {
    n: u32,
}

impl Iterator for Countdown {
    type Item = u32;
    fn next(&mut self) -> Option<u32> {
        if self.n == 0 {
            None
        } else {
            self.n -= 1;
            Some(self.n + 1)
        }
    }
}

fn main() {
    let launch = Countdown { n: 3 };
    for tick in launch {
        println!("{tick}"); // 3, 2, 1
    }
}

The type Item = u32; line is an associated type -- it says "the thing this iterator yields is a u32". We will look at associated types properly in a later episode; for now read it as "iterator of u32". The next method does the real work: each call decrements n and returns the value, until n hits zero and we return None to signal "done". And notice the for loop just works on our custom type. That is not magic -- a Rust for loop is literally sugar for repeatedly calling next until it returns None. Once next exists, your type slots into for loops, and it also gets map, filter, sum, collect, take and the whole rest of the toolbox for free, because they are default methods defined once on the trait. This is the trait system from episode 8 paying real, tangible dividends: implement one method, inherit a hundred.

Adapters are lazy

Here is the subtlety that trips up almost everyone coming from an eager language. Adapters like map and filter do no work at all when you call them. None. They return a new iterator that merely remembers what to do, and nothing actually runs until something starts pulling items through. This property is called laziness, and it is why you can chain adapters as freely as you like without ever paying for intermediate collections along the way:

fn main() {
    let nums = vec![1, 2, 3];
    let pipeline = nums.iter().map(|x| {
        println!("mapping {x}");
        x * 2
    });
    println!("built the pipeline, nothing has run yet");
    let doubled: Vec<i32> = pipeline.collect(); // NOW the mapping happens
    println!("{doubled:?}"); // [2, 4, 6]
}

Run this and read the output order carefully: "built the pipeline" prints before any "mapping" line appears. The closure inside map does not fire when you call map -- it fires only when collect starts pulling items through the chain, one at a time. If you have used Python generators, you already own this intuition: (x*2 for x in nums) builds a generator that sits there doing nothing until you iterate it. Rust iterators are the same idea, except again, compiled and fast rather than interpreted.

Laziness is not just a curiosity, it is what makes long chains efficient. Because no intermediate Vec is ever built between filter and map and the next adapter, a ten-stage pipeline still makes one pass over the data with zero heap allocations in the middle. Each item is drawn through the whole chain before the next item starts. That is the mechanical reason iterator chains match hand-written loops for speed.

Consumers run the pipeline

If adapters are lazy and build up a plan, something has to actually execute the plan. That something is a consumer: a method that drives the iterator to completion and produces a final result. collect gathers items into a collection, sum and product reduce everything to a single number, and fold is the general-purpose tool that threads an accumulator through every item:

fn main() {
    let nums = vec![1, 2, 3, 4, 5, 6];

    let evens_squared: Vec<i32> = nums.iter()
        .filter(|&&n| n % 2 == 0) // keep 2, 4, 6
        .map(|&n| n * n)          // 4, 16, 36
        .collect();
    println!("{evens_squared:?}"); // [4, 16, 36]

    let total: i32 = nums.iter().sum();
    println!("sum is {total}"); // 21

    let product = nums.iter().fold(1, |acc, &x| acc * x);
    println!("product is {product}"); // 720
}

Read that first pipeline top to bottom, in plain English: take references to the numbers, keep only the even ones, square each survivor, then collect the results into a Vec. It says exactly what it does, with no index bookkeeping, no manual push, and no off-by-one traps waiting to bite you -- and it compiles to a tight loop with no overhead. That combination of readability at no runtime cost is the reason experienced Rustaceans reach for iterators over manual loops almost every single time.

Two small things worth pointing out in that code. The filter closure takes |&&n| -- a double reference -- because iter() yields &i32 and filter hands the closure a reference to that, so we pattern-match both layers off to get a plain n. And fold(1, ...) starts its accumulator at 1 (the identity for multiplication) and multiplies each element in, which is how you build any reduction that sum or product do not already cover. fold is the swiss-army knife; once you see that sum is just fold(0, |a, x| a + x) under the hood, the whole family clicks into place.

iter, into_iter, and iter_mut

Collections give you three different ways to start iterating, and the only difference between them is ownership -- which brings episode 3 roaring back. iter yields shared references &T, iter_mut yields mutable references &mut T so you can edit elements in place, and into_iter consumes the collection and yields owned values T:

fn main() {
    let mut scores = vec![10, 20, 30];

    for s in scores.iter_mut() {
        *s += 5; // edit each element in place
    }
    println!("{scores:?}"); // [15, 25, 35]

    let doubled: Vec<i32> = scores.into_iter().map(|s| s * 2).collect();
    println!("{doubled:?}"); // [30, 50, 70]
    // scores is gone now: into_iter consumed it
}

Picking the right one is usually obvious once you ask what you intend to do. Read the elements? iter. Edit them in place? iter_mut (note the *s to write through the mutable reference). Done with the original collection and want to move its values out into something new? into_iter. That last one literally eats scores -- after the into_iter call the variable is gone, and trying to use it afterwards would not compile. The compiler will, as ever, keep you honest about which one you actually meant. This is ownership showing up in yet another corner of the language, consistently, so that once you understand it in one place you understand it everywhere.

Returning iterators and closures without boxing

One last pattern, and you will reach for it constantly the moment you start writing your own helpers: returning an iterator or a closure from a function without wrapping it in a heap allocation, using impl Trait. This hands back a real, concrete type that the caller cannot name (the iterator types Rust generates are genuinely unspeakable), dispatched statically at zero cost:

fn squares(upto: u32) -> impl Iterator<Item = u32> {
    (1..=upto).map(|x| x * x)
}

fn make_adder(n: i32) -> impl Fn(i32) -> i32 {
    move |x| x + n
}

fn main() {
    let v: Vec<u32> = squares(5).collect();
    println!("{v:?}"); // [1, 4, 9, 16, 25]

    let add10 = make_adder(10);
    println!("{}", add10(5)); // 15
}

The -> impl Iterator<Item = u32> return type is how you expose a lazy pipeline from a function without leaking the gnarly concrete iterator type into your public signature. And make_adder returns a closure the same way: the move keyword pulls n into the closure so it keeps living after make_adder returns, and impl Fn(i32) -> i32 hands that closure back without a heap allocation. In Python you would return a lambda and not think twice about it; in Rust you get the same ergonomics but the returned closure is a stack value with no indirection. It is one of those small things that quietly makes Rust libraries a genuine pleasure to use in stead of a chore.

So what did we actually cover?

  • A closure is an anonymous function that captures values from the scope where it is written, spelled with |params| body. Plain fn functions cannot capture; closures can, which is what makes iterator adapters possible.
  • Closures implement one of three traits depending on how they use their captures: Fn (reads), FnMut (mutates), FnOnce (consumes, callable once). The compiler infers which, and move forces ownership into the closure.
  • You take a closure as a parameter by making the function generic over Fn/FnMut/FnOnce. This is monomorphized and inlined, so passing closures around is a genuine zero-cost abstraction, unlike Python's always-dynamic calls.
  • The Iterator trait requires exactly one method, next, returning Some(item) until exhausted then None. Implement that one method and you inherit map, filter, sum, collect and the rest for free.
  • Adapters like map and filter are lazy -- they build a plan and do nothing until a consumer (collect, sum, fold) pulls items through. This means long chains make one allocation-free pass over the data.
  • iter / iter_mut / into_iter differ only in ownership: shared reference, mutable reference, owned value. And impl Trait lets you return iterators and closures without heap allocation.

That is a lot of power for one episode, but it all rests on the two foundations we spent ten episodes building: traits give iterators their shared toolbox, and ownership decides how closures capture and how the three iter methods behave. Nothing here is really new -- it is the earlier ideas finally clicking together into something expressive. From here on the language mostly gets more fun.

Next time we tackle what to do when a single owner is not enough -- when you need a value on the heap, or shared in ways plain borrowing simply cannot express. The tools for that have friendly names and a few sharp edges, and with ownership and traits both firmly in hand you are finally ready to meet them. But one thing at a time ;-)

Exercises

Three exercises, gentle to chewier as always. Full solutions open the next episode -- play with them first, the compiler is a patient teacher.

  1. Write fn evens(upto: u32) -> impl Iterator<Item = u32> that returns the even numbers from 0 up to and including upto (hint: a range plus filter). Then collect the result into a Vec<u32> in main and print it.
  2. Build a single iterator chain over 1..=100 that keeps the multiples of 3, squares each one, and sums the result into an i32 -- all in one expression, with no intermediate Vec. Print the total.
  3. Implement Iterator for a Fibonacci struct whose next returns the next Fibonacci number each time it is called. Then use Fibonacci::new().take(10).collect::<Vec<u64>>() to grab the first ten and print them. (The take adapter is a consumer's best friend on an otherwise infinite iterator.)

Bedankt voor het lezen, en tot de volgende keer! ;-)

scipio@scipio

Leave Learn Rust Series (#11) - Closures & the Iterator Trait to:

Written by

Does it matter who's right, or who's left?

Read more #stem posts


Best Posts From scipio

We have not curated any of scipio's posts yet. But you can encourage our curation team to review posts by visiting them regularly and by referring other readers. Because we give priority to frequently read content.

More Posts From scipio