Iterators
Most likely, the first place where you get a whiff of the power of functional programming with Rust is when you want to iterate over a collection of items. In Rust, iterators are a powerful tool to work with collections. They are a way to perform some operation on each item in a collection, one at a time. This is a common pattern in programming, and Rust's iterators make it easy to do.
Rust's iterators are lazy, meaning they have no effect until you call methods that consume the iterator to produce a result. This is a powerful feature, as it allows you to chain together a series of operations on a collection without having to create intermediate collections.
Suppose we want to capitalize a list of cities. We could do this with a loop:
fn main() { let cities = vec!["rome", "barcelona", "berlin"]; let mut cities_caps = vec![]; for city in cities { cities_caps.push(city.to_uppercase()); } println!("{cities_caps:?}"); }
Although this works, it is not the most idiomatic way of accomplishing this task in Rust. Let's see how we can use iterators to achieve the same task in a more idiomatic way.
fn main() { let cities = vec!["rome", "barcelona", "berlin"]; let cities_caps: Vec<String> = cities.into_iter().map(|city| city.to_uppercase()).collect(); println!("{cities_caps:?}"); }
This code is more concise and idiomatic. It uses the map
method to transform each item in the vector to uppercase, and
the collect
method to collect the results into a new vector. The |city|
syntax is a closure, which is a way to
define a function inline. We'll cover closures in more detail in the next chapter.
A good place to get familiar with closures is the chapter on closures in the Rust book, Let's re-do the capitalize example from the previous chapter using a closure.
fn main() { let capitalize = |value: &str| value.to_uppercase(); let cities = vec!["rome", "barcelona", "berlin"]; let cities_caps: Vec<String> = cities.into_iter().map(capitalize).collect(); println!("{cities_caps:?}"); }
Let's break down the example.
As per the Rust documentation, the into_iter()
method creates a consuming iterator, that is, one that moves each value
out of the vector (from start to end). The vector cannot be used after calling this. The more commonly used iter()
method returns an iterator over the slice, while borrowing the values.
Iterators support methods like, map
, filter
, take
, skip
, for_each
and flatten
, that you may be familiar with
from map/reduce functions in other languages. We use the map()
function to apply the capitalize
operation to each
element in the vector.
The collect()
method is used to collect all the elements returned by the iterator and capture them in a new vector.
In Rust the performance of iterators is identical or better compared to loops. See Comparing Performance: Loops vs. Iterators
By using iterators we can be more expressive and concise in our code. This gives the compiler the opportunity to optimize the code better, and it makes the code easier to read and understand.
Operations during an iteration can be chained together. If we're only interested in cities with a "B" we can easily include a filter in the iteration.
fn main() { let capitalize = |value: &str| value.to_uppercase(); let cities = vec!["rome", "barcelona", "berlin"]; let cities_caps: Vec<String> = cities .into_iter() .filter(|c| c.starts_with("b")) .map(capitalize) .collect(); println!("{cities_caps:?}"); }
Notice that we can directly apply the filter logic within the iterator using the closure syntax.
It is a good practice to first reduce the dataset, before performing computations on the data. So if we are interested
in the second city starting with a "B", first skip
and take
, and then map
the result:
fn main() { let capitalize = |value: &str| value.to_uppercase(); let cities = vec!["rome", "barcelona", "berlin"]; let city_in_caps: Option<String> = cities .into_iter() .filter(|c| c.starts_with("b")) .skip(1) .take(1) .map(capitalize) .next(); println!("{city_in_caps:?}"); }
Notice that in this case we use the
next
method to get the first element of the iterator. Thenext
method returns anOption
type, which isSome
if there is an element, andNone
if there is no element.
If needed, you can combine iterators with 'regular' loops, like in this example:
fn main() { let capitalize = |value: &str| value.to_uppercase(); let cities = vec!["rome", "barcelona", "berlin"]; let cities_caps = cities .into_iter() .filter(|c| c.starts_with("b")) .map(capitalize); for city in cities_caps { println!("{city:?}"); } }
In such a model, it may be useful to have the index for the element while looping. You can obtain this with the
.enumerate()
method.
fn main() { let capitalize = |value: &str| value.to_uppercase(); let cities = vec!["rome", "barcelona", "berlin"]; let cities_caps = cities .into_iter() .filter(|c| c.starts_with("b")) .map(capitalize) .enumerate(); for (idx, city) in cities_caps { println!("{idx}: {city:?}"); } }
Iterators are lazy
What does this mean? It means that the iterator does not do anything until you call a method that consumes the iterator. Let's look at an example:
fn main() { let numbers_iter = [1, 2, 3, 4, 5].iter().map(|n| { print!("processing {n} -> "); n * 2 }); println!("numbers_iter created"); for n in numbers_iter { println!("{n}"); } }
When you run this code, you will see that the println!("numbers_iter created")
statement is executed before the
print!("processing {n} -> ")
statement. This is because the map
method is lazy, and does not do anything until the
iterator is consumed.
numbers_iter created
processing 1 -> 2
processing 2 -> 4
processing 3 -> 6
processing 4 -> 8
processing 5 -> 10