Iterators and Closures in Rust

1. Overview

Closures and iterators are two tools that let you process data in a functional style in Rust while maintaining performance equivalent to hand-written loops. This article explains how closures capture variables from their environment and the three traits Fn, FnMut, and FnOnce. Then it covers the Iterator trait, common adapters, and how to write your own iterator. After reading, you’ll confidently write pipelines like filter → map → collect. You’ll also understand why the compiler sometimes rejects your code.

All examples use Rust 1.80+ (edition 2021) with only the standard library.

2. Closures Basics

2.1. Syntax

A closure is an anonymous function that can “see” variables in its enclosing scope:

fn main() {
    let discount = 10; // percent
    let apply_discount = |price: u32| price - price * discount / 100;

    println!("{}", apply_discount(250));
    println!("{}", apply_discount(1000));
}
225
900

Parameters are between the |...| pipes, and the closure body comes right after. The closure uses the discount variable even though it wasn’t passed as an argument. A regular fn function cannot do this.

Parameter types and return types are usually inferred automatically. You can write |price| ... if the compiler has enough information. However, types are inferred only once. If you first call the closure with u32 and later with i64, you’ll get a compile error.

2.2. Three ways to capture variables

Closures capture variables in the “lightest” way that the closure body needs. The preference order is immutable borrow, then mutable borrow, finally taking ownership.

fn main() {
    let mut cart = vec!["pen"];

    let print_cart = || println!("Cart: {:?}", cart); // borrows &cart
    print_cart();

    let mut add_item = |item| cart.push(item);        // borrows &mut cart
    add_item("notebook");
    add_item("ruler");

    println!("Final: {:?}", cart);
}
Cart: ["pen"]
Final: ["pen", "notebook", "ruler"]

Analysis of each closure:

  • print_cart only reads cart, so it holds a &cart.
  • add_item calls push, so it needs &mut cart. The closure variable must also be declared mut, because calling it modifies internal state.
  • The code compiles thanks to non-lexical lifetimes. print_cart is no longer used after its first call, so the immutable borrow ends before the mutable borrow begins.

When a closure needs to own data, add the move keyword. The typical case is passing a closure to another thread:

use std::thread;

fn main() {
    let customer = String::from("Lan");

    let handle = thread::spawn(move || {
        println!("Processing order for {customer}");
    });

    handle.join().unwrap();
}
Processing order for Lan

Without move, the compiler reports an error. The new thread might outlive main, so a reference to customer would be unsafe. With move, the String is moved into the closure. After that, you cannot use customer in main anymore.

3. The three traits Fn, FnMut, and FnOnce

Each closure automatically implements one or more of these three traits. Which trait gets implemented depends on what the closure does with captured variables:

Trait What the closure does with captured variables Can be called multiple times
Fn Only borrows Yes
FnMut Borrows mutably Yes
FnOnce Takes ownership (moves) Only once

These three traits have an inheritance relationship: every Fn is also FnMut, and every FnMut is also FnOnce. When writing a function that takes a closure, choose the loosest trait that your function needs. This gives callers the most flexibility.

fn run_twice<F: Fn()>(f: F) {
    f();
    f();
}

fn run_twice_mut<F: FnMut()>(mut f: F) {
    f();
    f();
}

fn run_once<F: FnOnce() -> String>(f: F) -> String {
    f()
}

fn main() {
    let name = String::from("Minh");
    run_twice(|| println!("Hello {name}"));

    let mut count = 0;
    run_twice_mut(|| count += 1);
    println!("count = {count}");

    let receipt = run_once(move || name + " - paid");
    println!("{receipt}");
}
Hello Minh
Hello Minh
count = 2
Minh - paid

The three calls correspond to the three traits:

  • The first closure only reads name, so it’s Fn.
  • The second closure increments count, so it’s FnMut. Passing it to run_twice would be an error.
  • The last closure uses name + "...". The + operator on String takes ownership of name, so the closure can only be called once and only implements FnOnce.

3.1. Returning a closure from a function

Each closure has its own unique, anonymous type. Therefore, you can’t write the type name directly. There are two ways to return a closure:

fn make_discount(percent: u32) -> impl Fn(u32) -> u32 {
    move |price| price - price * percent / 100
}

fn pick_rule(is_vip: bool) -> Box<dyn Fn(u32) -> u32> {
    if is_vip {
        Box::new(|price| price * 80 / 100)
    } else {
        Box::new(|price| price)
    }
}

fn main() {
    let summer_sale = make_discount(15);
    println!("{}", summer_sale(200));

    let rule = pick_rule(true);
    println!("{}", rule(500));
}
170
400
  • impl Fn(...) is used when the function always returns one fixed closure type. This approach has no heap allocation or dynamic call overhead.
  • Box<dyn Fn(...)> is used when different branches return different closures. The two closures in pick_rule have different types, so they must be unified into a trait object.
  • move in make_discount is required. Without it, the closure would borrow percent, but percent disappears when the function returns.

4. The Iterator trait

4.1. The core: the next method

Every iterator in Rust revolves around one very simple trait:

pub trait Iterator {
    type Item;
    fn next(&mut self) -> Option<Self::Item>;
    // ... dozens of provided methods like map, filter, sum ...
}

next returns Some(element) until data runs out, then returns None. The for loop is actually just syntactic sugar for calling next repeatedly.

4.2. iter, iter_mut, and into_iter

A collection can produce three different types of iterators:

fn main() {
    let mut prices = vec![100, 200, 300];

    for p in prices.iter() {          // p: &i32
        print!("{p} ");
    }
    println!();

    for p in prices.iter_mut() {      // p: &mut i32
        *p += 10;
    }

    for p in prices.into_iter() {     // p: i32, vector is moved
        print!("{p} ");
    }
    println!();
    // println!("{:?}", prices); // error: value borrowed after move
}
100 200 300 
110 210 310 
  • iter() provides immutable references. The collection is still usable afterward.
  • iter_mut() provides mutable references to modify elements in place.
  • into_iter() takes ownership. Writing for p in prices also calls into_iter() implicitly.

5. Adapters and consumers

Methods on iterators fall into two categories:

  • Adapters (map, filter, take, zip, enumerate…) transform one iterator into another. They are lazy: they do nothing until consumed.
  • Consumers (collect, sum, count, fold, for_each, find…) actually call next and produce a result.

Let’s use an Order struct as sample data:

#[derive(Debug)]
pub struct Order {
    pub id: u32,
    pub customer: String,
    pub total: u32,
    pub paid: bool,
}

pub fn unpaid_total(orders: &[Order]) -> u32 {
    orders.iter()
        .filter(|o| !o.paid)
        .map(|o| o.total)
        .sum()
}

pub fn big_customers(orders: &[Order], threshold: u32) -> Vec<&str> {
    orders.iter()
        .filter(|o| o.total >= threshold)
        .map(|o| o.customer.as_str())
        .collect()
}

fn main() {
    let orders = vec![
        Order { id: 1, customer: "An".into(),   total: 150_000, paid: true },
        Order { id: 2, customer: "Bình".into(), total: 80_000,  paid: false },
        Order { id: 3, customer: "Chi".into(),  total: 320_000, paid: false },
    ];

    println!("Unpaid: {}", unpaid_total(&orders));
    println!("Big customers: {:?}", big_customers(&orders, 100_000));

    let summary = orders.iter()
        .fold(String::new(), |acc, o| acc + &format!("#{} ", o.id));
    println!("Orders: {summary}");
}
Unpaid: 400000
Big customers: ["An", "Chi"]
Orders: #1 #2 #3 

Steps in the example:

  • filter(|o| !o.paid) takes a closure returning bool. The parameter o has type &&Order, because filter passes a reference to the element, and iter() already yields &Order. Auto-deref lets you write o.paid naturally.
  • map(|o| o.total) transforms each order into an amount. sum() adds them all up.
  • collect() gathers results into a collection. The target type comes from the function’s return type Vec<&str>.
  • fold takes an initial value and a closure (acc, element) -> new_acc. This is the most general consumer.

5.1. Laziness

The following snippet shows that adapters only run when consumed:

fn main() {
    let ids = vec![1, 2, 3];
    let doubled = ids.iter().map(|x| {
        println!("map {x}");
        x * 2
    });

    println!("Nothing runs yet");
    let result: Vec<i32> = doubled.collect();
    println!("{result:?}");
}
Nothing runs yet
map 1
map 2
map 3
[2, 4, 6]

Thanks to laziness, a pipeline of filter → map → sum traverses the data once and creates no intermediate collections. The compiler often optimizes such a pipeline into machine code equivalent to a hand-written loop. This is why iterators are called a zero-cost abstraction.

6. Writing your own iterator

You only need to implement next. All other adapters come for free. The following example generates invoice numbers in the format INV-0001:

pub struct InvoiceNumbers {
    current: u32,
    end: u32,
}

impl InvoiceNumbers {
    pub fn new(start: u32, end: u32) -> Self {
        Self { current: start, end }
    }
}

impl Iterator for InvoiceNumbers {
    type Item = String;

    fn next(&mut self) -> Option<Self::Item> {
        if self.current > self.end {
            return None;
        }
        let number = format!("INV-{:04}", self.current);
        self.current += 1;
        Some(number)
    }
}

fn main() {
    let customers = ["An", "Bình", "Chi"];

    for (invoice, customer) in InvoiceNumbers::new(7, 100).zip(customers) {
        println!("{invoice} -> {customer}");
    }
}
INV-0007 -> An
INV-0008 -> Bình
INV-0009 -> Chi

Explanation:

  • type Item = String declares the type of element the iterator yields.
  • next updates state via &mut self and returns None when exceeding end.
  • zip pairs two iterators and stops when the shorter one runs out. That’s why only three lines are printed, even though the range goes up to 100.

7. Writing tests

Rust has a built-in test framework. You place a test module in the same file as unpaid_total and InvoiceNumbers:

#[cfg(test)]
mod tests {
    use super::*;

    fn order(id: u32, total: u32, paid: bool) -> Order {
        Order { id, customer: format!("CUST{id}"), total, paid }
    }

    #[test]
    fn unpaid_total_only_counts_unpaid_orders() {
        let orders = vec![order(1, 100, true), order(2, 50, false), order(3, 70, false)];
        assert_eq!(unpaid_total(&orders), 120);
    }

    #[test]
    fn unpaid_total_of_empty_slice_is_zero() {
        assert_eq!(unpaid_total(&[]), 0);
    }

    #[test]
    fn invoice_numbers_stop_after_end() {
        let numbers: Vec<String> = InvoiceNumbers::new(9, 11).collect();
        assert_eq!(numbers, vec!["INV-0009", "INV-0010", "INV-0011"]);
    }
}
cargo test
running 3 tests
test tests::invoice_numbers_stop_after_end ... ok
test tests::unpaid_total_of_empty_slice_is_zero ... ok
test tests::unpaid_total_only_counts_unpaid_orders ... ok

test result: ok. 3 passed; 0 failed; 0 ignored

The second test checks a boundary case. sum() on an empty iterator returns 0, it doesn’t panic.

8. Common pitfalls and when not to use iterators

Note: Forgetting to consume is the most common mistake. The line orders.iter().map(|o| println!("{}", o.id)); prints nothing. The compiler warns: “iterators are lazy and do nothing unless consumed”. When you only need a side effect, use a for loop or for_each.

Some other errors you might encounter:

  • collect doesn’t know the target type. Writing let v = it.collect(); gives an error: “type annotations needed”. You need to specify let v: Vec<_> = ... or use turbofish collect::<Vec<_>>().
  • Using into_iter() then wanting to use the collection again. If you only need to read, use iter().
  • Closure holds a borrow too long. An FnMut closure capturing &mut cart prevents you from reading cart until after the last use of the closure. Narrow the closure’s scope or call it and then read.
  • Needing ? inside map. The ? operator inside a closure returns from the closure, not the enclosing function. Use collect::<Result<Vec<_>, _>>(). This stops at the first error and returns Err.

When you should not force everything into an iterator chain:

  • Logic has many branches, multiple break/continue points, or updates multiple variables at once. A plain for loop is clearer than a ten-line fold.
  • You need to return a closure from multiple branches but worry about performance. Box<dyn Fn> has allocation and dynamic call overhead. Consider using an enum or generics instead.

9. Conclusion

Closures in Rust capture variables in the lightest way possible: immutably, mutably, or via move. This makes closures automatically implement Fn, FnMut, or FnOnce. When writing a function that takes a closure, choose the loosest trait your function needs.

Iterators are based on a single next method. Adapters like map, filter, and zip are lazy, while consumers like collect, sum, and fold actually run the pipeline. By combining these two tools, you write concise, memory-safe code that runs as fast as a hand-written loop. You should also know when a simple for loop is the better choice.

rustiteratorclosurefunctional programming