Safety and Efficiency with Rust

Closures and Iterator Methods


Learning Objectives

  • You know what closures are and know how to use them in Rust.
  • You can use iterator methods in Rust to transform, filter, and combine iterators.

Closures

As we’re looking into iterators, it’s a good time to introduce closures. A closure is an expression that you can invoke similarly to regular functions. To create a closure, you define parameters within pipes | and follow them with the closure’s body.

Comparing functions and closures

Unlike regular functions, closures have access to variables in the scope where they are defined. In other words, closures capture their surrounding environment, which gives them an advantage over ordinary functions. In the example above, notice that we did not need to specify the types of the parameters a and b or the return type of the closure. Rust compiler infers these from the context and the closure’s body, removing the need for explicit annotations.

Here are various ways to define a function versus a closure in Rust:

// Regular function with mandatory type annotations and braces
fn add(a: i32, b: i32) -> i32 {
    a + b
}

// Closure with optional type annotations
let add = |a: i32, b: i32| -> i32 { a + b };

// Closure with optional braces
let add = |a, b| { a + b };

// Most concise closure without type annotations or braces
let add = |a, b| a + b;

Capturing and enclosing scope

Because closures capture their surrounding environment, you can use variables from the enclosing scope within the closure’s body.

Above, the closure with_file_extension captures the base variable from its surrounding scope, allowing it to create new strings with different file extensions.

Closures and ownership

When closures capture variables, they must adhere to Rust’s ownership rules, just as with regular variables. A closure can capture variables in three ways:

  1. Immutable Borrow: The closure borrows the variable immutably, allowing it to read the value without modifying it.
  2. Mutable Borrow: The closure borrows the variable mutably, permitting it to modify the value.
  3. By Move: The closure takes ownership of the variable, moving it into the closure.

The Rust compiler automatically determines how to capture each variable based on how the closure uses them. Think of a closure as a smart function that decides whether to take an immutable or mutable reference to a variable, or to take ownership, depending on its needs. However, this automatic inference doesn’t solve all problems, especially in more complex scenarios.

Consider the following example where we attempt to double the value of a variable i from the enclosing scope within a closure:

The above closure usage is equivalent to the following code without closures:

fn main() {
  let mut i = 2;
  let j = &mut i;  // Mutable borrow `i`
  *j *= 2;         // Modify `i` through the mutable borrow
  println!("{i}");  // Immutable borrow `i`
  let j = &mut i;  // Another mutable borrow `i`
  *j *= 2;         // Modify `i` again
}

Making closures mutable or moving ownership

By default, closures are immutable and cannot modify variables from the enclosing scope unless specified. To allow a closure to modify captured variables, you must declare it as mut:

Additionally, you can force a closure to take ownership of the captured variables using the move keyword. This is particularly useful in concurrent or parallel programming where closures may outlive the scope they were created in:

If the closure ownership handling feels like a lot to handle, no worries! We’ll mainly be using closures without capturing variables from the enclosing scope, so there goes that problem.

Iterator methods

Iterator methods can be separated into adapters and consumers. Adapters take an iterator and return another iterator, while consumers consume an iterator to produce a final result. In Rust, adapters and consumers are implemented as methods on the Iterator trait, and they are lazily evaluated.

Lazy evaluation means that the iterator methods are only executed when the result is needed. This is in contrast to eager evaluation, where all the elements are computed immediately.

We only visit some of the most common iterator methods here. For a full list, see the Rust documentation.

Adapters

Common adapter methods include map, filter, enumerate, take_while, skip_while, and zip. These methods are used to transform, filter, and combine iterators.

Map

Map transforms each item in the iterator by applying a given function or a closure — closures are anonymous functions that can capture variables from the surrounding scope.

Above, the closure |x| x * 2 is applied to each element in the iterator, doubling each number. The method iter() yields references to the elements in the vector, so we would need to dereference x to get the actual value. However, when multiplying by 2, the compiler automatically dereferences the value.

The type of the doubled_vec is inferred from the type of the iterator, so we don’t need to specify it explicitly (i.e., using Vec<_>).

Filter

Filter keeps only the items for which a given predicate function returns true. When filtering, the closure should return a boolean value.

Similarly to map, the closure |x| **x % 2 == 0 is applied to each element in the iterator. However, now, the values are not implicitly dereferenced, so we need to dereference x twice — once when getting the reference and once when getting the value.

Note that if you wish to use a specific type for the resulting vector, you can specify it explicitly, e.g., Vec<i32>. In this case, you might need to also clone the values. Try the following program without the cloned iterator to see what happens.


Loading Exercise...

Enumerate

Enumerate returns an iterator of (index, item) tuples. This is useful when you need to know the index of the item in the iterator.


Loading Exercise...

Take while and skip while

The method take_while takes elements from the iterator while the predicate function returns true. Similarly to filter, the closure should return a boolean value, and the value needs to be dereferenced twice.

Skip while is the opposite of take while — it skips elements from the iterator while the predicate function returns true.

Zip

Zips two iterators into a single iterator of pairs.

Consumers

Consumers use an iterator to produce a result. Once an iterator has been consumed, it cannot be used again. Common consumer methods include collect, find, count, sum, and fold. From these, we’ve already used collect in the above examples.

Find

The method find is used to find the first element in the iterator that satisfies the predicate. It returns an Option containing the first element that satisfies the predicate.

Count

The method count returns the number of elements in the iterator.

Sum

The method sum returns the sum of all elements in the iterator. The iterator should yield values that can be added together.

Fold

The method fold is used to accumulate a value over the elements of the iterator. It takes an initial value and a closure that takes an accumulator and an element, and returns the new accumulator.


Loading Exercise...

Iterator pipelines

Iterators are commonly used in pipelines, where multiple iterator methods are chained together. As an example, we can use iterator pipelines to solve a classic programming problem called “the rainfall problem”:

Write a program that processes an input consisting of daily rainfall measurements (non-negative integers) until it encounters the integer 99999. The program should output the average of the numbers encountered before 99999.

To solve the problem, we can use the take_while method to take elements from the iterator until we encounter 99999, and then use the fold method to calculate the sum and count of the elements. Interestingly, with fold, we can also work with tuples to accumulate multiple values — in this case, the sum and count.

Finally, when we have the sum and the count, we can calculate the average.

The above program would not fully solve the rainfall problem, as it does not e.g. filter out negative values or handle the case where there are no values in the input. Some of these requirements are implicit — as e.g. discussed in the article Do we know how difficult the rainfall problem is?.

Loading Exercise...