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_cartonly readscart, so it holds a&cart.add_itemcallspush, so it needs&mut cart. The closure variable must also be declaredmut, because calling it modifies internal state.- The code compiles thanks to non-lexical lifetimes.
print_cartis 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’sFn. - The second closure increments
count, so it’sFnMut. Passing it torun_twicewould be an error. - The last closure uses
name + "...". The+operator onStringtakes ownership ofname, so the closure can only be called once and only implementsFnOnce.
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 inpick_rulehave different types, so they must be unified into a trait object.moveinmake_discountis required. Without it, the closure would borrowpercent, butpercentdisappears 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. Writingfor p in pricesalso callsinto_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 callnextand 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 returningbool. The parameterohas type&&Order, becausefilterpasses a reference to the element, anditer()already yields&Order. Auto-deref lets you writeo.paidnaturally.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 typeVec<&str>.foldtakes 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 = Stringdeclares the type of element the iterator yields.nextupdates state via&mut selfand returnsNonewhen exceedingend.zippairs 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 aforloop orfor_each.
Some other errors you might encounter:
collectdoesn’t know the target type. Writinglet v = it.collect();gives an error: “type annotations needed”. You need to specifylet v: Vec<_> = ...or use turbofishcollect::<Vec<_>>().- Using
into_iter()then wanting to use the collection again. If you only need to read, useiter(). - Closure holds a borrow too long. An
FnMutclosure capturing&mut cartprevents you from readingcartuntil after the last use of the closure. Narrow the closure’s scope or call it and then read. - Needing
?insidemap. The?operator inside a closure returns from the closure, not the enclosing function. Usecollect::<Result<Vec<_>, _>>(). This stops at the first error and returnsErr.
When you should not force everything into an iterator chain:
- Logic has many branches, multiple
break/continuepoints, or updates multiple variables at once. A plainforloop is clearer than a ten-linefold. - You need to return a closure from multiple branches but worry about performance.
Box<dyn Fn>has allocation and dynamic call overhead. Consider using anenumor 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.