πClosures & Iterators
An comprehensive overview explanation of iterators and closures in Rust.
Closures
What is Closure?
π‘ Closures in Rust are anonymous functions that capture and use variables from their enclosing scope. They are often referred to as lambda expressions or anonymous functions in other programming languages.
π‘ Rustβs closures allow you to store them in variables, pass them as arguments to other functions, and return them from functions. Furthermore, you can create closures in one place and then call them elsewhere to evaluate them in different contexts.
Closures in Rust are defined using vertical bars | to enclose their parameters, followed by the body of the closure:
let closure = |param1, param2| {
// closure body
};fn main() {
let my_closure = |x: i32| -> i32 { x + 1 };
let x = my_closure(12);
println!("x: {x}") // x: 13
}In this example, we define a closure which is called my_closure. This closure includes:
|x: i32|: This part establishes the closure's parameters.xis the parameter name andi32is its type.> i32: This specifies the return type of the closure.x + 1: This is the closure's body.
We call the closure with the x parameter of 12. The result should be 12 + 1 = 13.
This example also illustrates that a variable can bind to a closure definition, and we can later call the closure by using the variable name and parentheses (my_closure()) similar to when we call a function.
Capturing References or Moving Ownership
Closures can capture variables from their environment (enclosing scope) in three ways:
By reference (immutable): The closure borrows the variable immutably.
By reference (mutable): The closure borrows the variable mutably.
By value: The closure takes ownership of the variable.
Closure Type Inference and Annotation
Type annotations are necessary for functions because they define an explicit interface, ensuring that all users agree on the types of values a function uses and returns. However, closures are usually short-lived and used in specific, limited contexts, making type inference sufficient in most cases.
In some situations, you may choose to add type annotations to closures for clarity and explicitness, even though it increases verbosity.
There are some variants of the closure syntax:
Moving Captured Values Out of Closures and the Fn Traits
Closures and the Fn Traitsπ‘ Once a closure captures a reference or takes ownership of a value from its defining environment, the code within the closure's body determines how these references or values are used when the closure is executed. This influences what, if anything, is moved into or out of the closure during its execution.
π‘ A closure body can perform various actions: it can move a captured value out of the closure, mutate the captured value, leave the value unchanged, or not capture anything from the environment at all.
Rust offers three closure traits to distinguish how captured values are handled:
FnOnce: This is the base trait for all closures. It indicates that the closure can be called only once. If the closure moves captured values, it will only implementFnOnceand not the other traits.FnMut: This trait is implemented for closures that don't move captured values out of their body but might mutate them. This allows the closure to be called multiple times with side effects.Fn: This trait is for closures that donβt move or mutate captured values. They can be called multiple times without affecting the captured environment
Functions can also implement all three of the
Fntraits. If we don't need to capture a value from the environment, we can use a function name instead of a closure in contexts that require an implementation of one of theFntraits.
Fn Trait
Fn TraitFnMut Trait
FnMut TraitFnOnce Trait
FnOnce TraitIterators
What is an Iterator?
π‘ The Iterator is a trait that allows you to sequentially access the elements of a collection or sequence. It provides a way to process a sequence of elements, one element at a time, and allows us to apply operations such as mapping, filtering, and folding over the sequence.
Rust provides several iterator traits to handle different scenarios:
Iterator: The base trait for all iterators. It defines thenext()method to retrieve the next element.IntoIterator: Allows converting a value into an iterator.FromIterator: Allows creating a collection from an iterator.
For example, Rust provides a convenient way to iterate over iterators using the for loop:
π‘ Iterators are lazy, meaning that they donβt perform any work until they are consumed such as by for loop or collect method. Once an iterator is consumed, it canβt be used again, meaning that you canβt reuse it to iterate over the same element again.
In this example, the first while loop consumes the iterator by iterating over all elements.
The second while loop tries to iterate again but immediately exits because the iterator is empty.
The Iterator Trait
Iterator TraitItemis the associated type that represents the type of elements the iterator will produce.nextis the core method of theIteratortrait. It advances the iterator and returns the next item in the sequence wrapped in anOption. If there are no more items, it returnsNone.
Many collections in Rust, like arrays, vectors, and ranges, implement the Iterator trait, allowing you to create iterators from them.
You can manually use the next() method to iterate over each element of the iterator at a time.
Each time we call next method, it consumes an element from the iterator. We didnβt need to make iter mutable when we used a for loop because the loop took ownership of iter and made it mutable behind the scenes.
It's important to note that the values returned by the
nextmethod are immutable references to the elements in the vector. This is because theitermethod creates an iterator that yields immutable references.
Consuming Iterators
π‘ Methods that call next are known as consuming adapters because they will consume the iterator and produce a final value.
Essentially, each time you call one of these methods, you're advancing through the iterator and consuming its elements one at a time. Once an element is consumed, it's gone, and the iterator moves on to the next one.
Some of the common methods that consume the iterator such as: collect(), sum(), for_each(), etc.
The collect method
collect methodIn this example, weβre calling the map method and applying the closure |x| x * 2 to each element and doubling each number.
After that, we call the collect method to consume the iterator and collect its element into a new vector named doubled.
The sum method
sum methodIn this example, weβre using the sum method to iterate over each element and accumulate the sum of each element in the vector.
The for_each method
for_each methodIn this example, weβre using the for_each method to iterate over each element and print out each element in the vector.
Producing Iterators
π‘ Iterator adapter is a method provided by the Iterator trait that transforms one iterator into another iterator. These adapters produce new iterators that can be further chained, filtered, or modified without consuming the original iterator or producing a final value immediately. This process is also known as lazy evaluation.
Some of the common methods that produce the iterator such as: map(), filter(), enumerate(), take(), skip(), etc.
The map method
map methodIn this example, weβre using the map method to produce a new iterator that multiplies each element by 2.
The filter method
filter methodIn this example, weβre using the filter method to produce a new iterator that only contains even numbers.
The enumerate method
enumerate methodIn this example, weβre using the enumerate method to produce an iterator that yields (index, value) pairs.
The take method
take methodIn this example, weβre using the take method to produce an iterator that returns only the first 3 elements.
The skip method
skip methodIn this example, weβre using the skip method to produce an iterator that skips the first two elements and returns the rest.
Chaining Producing Adapters
Letβs see an example of chaining multiple producing adapters by combining all of the methods that weβve been through in the previous section.
We create a vector named v which contains numbers from 1 to 6.
We call the iter() method on the vector v, creating an iterator over its elements. This iterator yields references to the elements of the vector.
In the first stage, we want to filter out to keep only even numbers. The filter method is applied to the iterator, creating a new iterator that yields only the elements that satisfy the given predicate, in this case is the even numbers.
In the second stage, we want to multiply each element by 10. The map method is applied to the filtered iterator, creating a new iterator that multiplies each element by 10.
In the third stage, we only want to take the first 2 elements from the previous result. The take method is applied to the iterator, creating a new iterator that yields at most 2 elements.
Finally, we call the collect method to consume the iterator and collect its elements into a new vector.
We can see that each adapter creates a new iterator, transforming the data in a specific way using the result produced by the previous method.
Last updated