Arguably one of the main reasons the Rust language has become so popular is because the entire ecosystem builds upon a consistent experience. From toolchains to documentation and language specifications, almost every aspect of the language adheres to a certain level of consistency. Getting into the Rust mindset allows us, developers, to intuitively discover aspects of the language and gain the ability to write better and more consistent code.
What You Need To Know#
In order to follow along, you will need at least some familiarity with the Rust programming language. To make things easier, this article includes detailed code samples and links to the Rust playground where you can execute and study the provided code snippets.
If you are interested in getting started with Rust, the official Learn Rust guide provides excellent pointers to resources and getting started content.
What Will You Learn#
After reading this article, you will know:
How to make use of the Rust
.map()function when working with Iterators.
How to manipulate Vectors and collections using Iterators.
In which scenarios the Map function may produce unexpected results.
What is the Map() method?#
A core concept for Functional programming, Map is also commonly used as a tool for dealing with lists and collections in Rust and is one of the most essential methods for working with Iterators.
map method offers a way to apply a function (or a closure) on each element from a list. This is key in functional programming methodologies and offers a way to transform a collection of type A, to a collection of elements of type B. In Rust, the
map method is frequently used to interact with vectors and any other types which can be represented as an Iterator.
Let's illustrate this with an example. Imagine a list of numbers (a vector of numbers) and we would like to multiply each number by 10.
Let's review a breakdown of the steps
We declare a new variable called
numbersand use the macros
vec!in order to initialize a new vector with the provided number values.
We use the
.iter()method on the vector in order to obtain an iterator.
We use the
.map()method to execute a closure over each element yielded by the iterator. In other words, the closure is executed for each element in the vector of numbers.
You may be wondering why
.map() doesn't work directly on the vector of numbers.
The answer lies with the fact Rust makes it very easy to use iterators for almost everything. In the ergonomics of the language, it is generally preferred to use iterators instead of directly interacting with a vector.
In the example above we actually have two iterators at play - the first which we obtain through the use of
.iter() in order to get the elements of the vector and a second one which happens to be the return type of the
.map() also returns an iterator. Let's have a closer look at the syntax we need in order to use Map.
In the Rust documentation, we can find the exact signature of the Map method:
With this we can focus on several key takeaways.
Map works on iterators#
.map() can only be applied to iterators. This means that we first need to convert a collection type to an iterator before we can transform its elements.
To obtain an iterator from a vector, the standard library provides
Map returns an iterator#
The return type of
.map() is also an iterator. This can be very useful in cases when multiple transformations need to be chained one after the other.
Let's illustrate this by extending the example with a second transformation of the vector of numbers. After multiplying by 10, let's divide each element by 3:
Since the Map function returns an Iterator, the result of the first transformation
.map(|n| n * 10) will be used as input for the second transformation
.map(|n| n / 3).
You probably notice by now that despite the return type of Map being Iterator, we somehow manage to get back a vector of results.
This is possible thanks to the
.collect() method. It acts as a consumer to the iterator, acquiring all elements and storing them into a vector.
Map execution is not immediate#
The Map function is lazy, meaning the execution of the closure provided to Map is delayed until the values produced by the Map iterator are actually requested. To demonstrate this, let's modify the example so that we count the number of times the closure of the map method is invoked:
.map() runs, it first increments the counter and then applies the transformation of multiplying the number by 10, similar to what we did in the previous example. As expected, the counter equals 4 because this is the total number of elements on which the map function was applied.
Now let's modify this snippet by not calling
.collect() at the end:
If we print the contents of the
result variable, we see that what we get back is the actual Map iterator, the results of which are not materialized yet. We can prove this further by looking at the value of the counter variable which is now 0. This means the function inside the
.map() has not been executed yet.
The lazy nature of
.map() gives us the opportunity to control when the transformation will be executed. You can imagine this can be significant in cases of large data sets where performance is important.
Let's have a look at several examples in which the Map function can be a good solution.
Transform a vector of strings to lowercase #
Starting from a vector of string values (or "words"), we can use the Map function in order to get the lowercased equivalent of each word.
As before, we begin by accessing an iterator over the collection words. The
.iter() method produces an iterator which yields every element (that is, every word) of the collection.
Then we can use Map to define a closure which applies to_lowercase() on each element.
In the end, we don't forget to
.collect() the results into a new vector of String values.
Count the number of times a character occurs in a String#
Let's define a string value and use Map in order to determine the number of occurrences of each character within the string.
We expect an output giving the number of times each character is used, similar to the following:
The result looks like a dictionary where every key is a character and the value of that key is the number of occurrences. Let's use the
HashMap type to represent this value:
Here we use the chars method in order to obtain an iterator over every letter of the alphabet. Then we count how many times each letter matches the text input.
For example, how many times "a" appears in "hello from rust!", then how many times "b" appears in "hello from rust!" etc.
Finally, we collect the results from the mapping into a HashMap.
Map using Option or Result#
So far we've used examples where the mapping function always succeeds. In reality, we have to take into account the circumstances when this is not going to be the case. Such cases include transformations that have an optional return type like
Optionis an enumeration type with two possible values:
Some(value)which can be used to indicate if a given operation produces a value or not. In other languages, an empty output is usually expressed through special values like
Resultis an enumeration type with two possible values:
Errwhich can be used to indicate if a given operation has succeeded or failed in lieu of throwing an exception (or a panic).
Let's illustrate the case of using Map with an operation that can potentially fail.
For example, converting a vector of string values to a vector of numeric values of type
u32. Applying what we've seen so far, we may attempt to solve the problem with the following snippet:
For each element of the vector, we use the parse method in order to convert the string value to a number.
We realize quickly that this will not compile.
The reason lies with the fact that Parse returns a
Result<u32, std::num::ParseIntError> which indicates that the operation can either succeed returning a value of type
u32 or fail with a
map function is not equipped to deal with this situation directly.
One way to solve the problem is to use
.unwrap(), essentially forcing the fact that we are certain the parsing will never fail. If it does, the program will panic and exit:
This works because all string values in the source vector can be converted to a number.
But what if we have a vector where some elements can't be expressed as a number?
The unwrap fails and causes the program to panic.
Luckily, the standard library provides an alternative that can handle the situation where the Map function can produce one or zero results. Meet
flat_map uses an iterator over the result of the mapping and as a consequence, it will skip over elements for which the mapping closure returns empty or unsuccessful values (like
Err. In other words, we have a mechanism to skip over the failing elements and end up with a resulting vector of numbers for which the operation succeeded.
A note of caution regarding side effects when using the Map function.
Side effects refer to the fact that during the execution of a Map closure or a function, our program may modify state and variables which are external. We already saw an example of this, when we defined a counter which increments every time the Map function is executed:
While this produces exactly the result we expect in this situation, it may not always be the case. Let's modify the example so that instead of transforming the numbers in the vector, we map the order in which they are processed:
This results in the Map function to first be applied to the number 3 on position 1, followed by the number 6 on position 2, etc.
.map() returns an iterator, we could choose to modify its output. For example, let's reverse the order of the Map operation:
So the first number to be mapped was 12, followed by the number 9. The order in which the elements were mapped is no longer the same as the initial order in the vector.
In order to ensure we have better control of operations with side effects, it may be preferred to use a for loop instead:
After seeing the Map function in action, we can outline several important takeaways.
Iterators are a very powerful concept in Rust.
The Rust way of working with collections like vectors is through the use of Iterators.
The Map function is a prime tool for interacting with Iterators - it acts on iterators and also returns an iterator.
The Map function is not a universal solution, additional techniques like for loops are sometimes better suited to a given task.
You may also find it useful to explore the Map function specification in the documentation which reveals all available methods and additional sample use cases and code snippets.
When I need more information regarding an aspect of Rust, I find myself browsing the Rust Programming Language Book. I think it's an amazing resource for gaining further insight into concepts regarding Iterators, the Map method, or working with vectors.