🌀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:
In this example, we define a closure which is called my_closure
. This closure includes:
|x: i32|
: This part establishes the closure's parameters.x
is the parameter name andi32
is 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
Closures in Rust typically do not require type annotations for their parameters or return values, unlike fn
functions. This is because closures are used within a narrow context, and the compiler can usually infer their types.
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 implementFnOnce
and 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
Fn
traits. 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 theFn
traits.
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
Trait💡 The Iterator
trait in Rust defines a sequence of elements and a way to iterate over them.
Item
is the associated type that represents the type of elements the iterator will produce.next
is the core method of theIterator
trait. 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
next
method are immutable references to the elements in the vector. This is because theiter
method 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
method 💡 The collect
method converts an iterator into a collection, like a Vec
, HashMap
, or other types that implement FromIterator
.
In 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
method💡 The sum
method consumes the iterator and returns the sum of the elements.
In 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
method💡 The for_each
method applies a function to each element of the iterator.
In 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
method 💡 We can use map
method to transform each element of an iterator using a custom function that is passed in the map as an argument.
In this example, we’re using the map
method to produce a new iterator that multiplies each element by 2.
The filter
method
filter
method 💡 The filter
produces a new iterator that includes only the elements that satisfy a given predicate.
In this example, we’re using the filter
method to produce a new iterator that only contains even numbers.
The enumerate
method
enumerate
method💡 The enumerate
method produces an iterator of pairs, where each pair contains the index
and the value
of the element.
In this example, we’re using the enumerate
method to produce an iterator that yields (index, value)
pairs.
The take
method
take
method 💡 The take
method produces an iterator that yields only the first n
elements of the original iterator.
In this example, we’re using the take
method to produce an iterator that returns only the first 3 elements.
The skip
method
skip
method 💡 The skip
method produces an iterator that skips the first n
elements and yields the rest.
In 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
💡 The powerful feature of producing an adapter is that it allows us to combine multiple iterator adapters to create a complex data transformation. This process is called chaining-producing adapters.
💡 By chaining these adapters, you can perform multiple operations on a data stream without creating intermediate collections.
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