Jul 04, 2016
Table of contents:
The first time I saw a comprehension in Elixir I was a bit confused as to what the point of it was. Initially I didn’t understand why you would write a comprehension when you could achieve the same result using the existing tools that Elixir provides.
It was only after digging into some Elixir code that I realised that comprehensions just make a common action a bit nicer to read and write.
In today’s tutorial we will be looking at using comprehensions in Elixir.
A couple of weeks ago we looked at Enumerables in Elixir. The Enum
module provides a number of very useful functions for working with enumerable data structures.
For example, imagine if you wanted to take each number in a range and multiple it by 2. You could do that using the Enum.map/2
function:
Enum.map(1..3, &(&1 * 2))
In this example I’m passing a function as the second argument. Each item in the range will be passed into the function, and then a new list will be returned containing the new values. We looked at this in functions as first class citizens in Elixir.
Mapping, filtering, and transforming are very common actions in Elixir and so there is a slightly different way of achieving the same result as the previous example:
for n <- 1..3, do: n * 2
If you run both examples in iex
you will see that they produce the same result.
The second example is a comprehension, and as you can probably see, it’s simply syntactic sugar for what you could also achieve if you were to use the Enum.map/2
function.
When we looked at the choice between using the Enum
module or the Stream
module, we saw that both modules have the same functions with the same signatures. The difference is the Enum
module will act on the data eagerly, whereas the Stream
module will act on the data lazily. This will make a big difference if you are working with a large dataset.
However, there are no real benefits to using a comprehension over a function from the Enum
module in terms of performance.
So whilst there’s no benefit other than the syntactic sugar, comprehensions are still very important to learn about because you will see them in other people’s Elixir code.
In the previous example I passed a range of 1..3
into the comprehension:
n <- 1..3
This chunk of the comprehension is known as the generator because it is generating values to be passed into the comprehension. In this example I’m passing a range into the right side of the generator.
In just the same in which you would use the functions of the Enum
module, you can pass any enumerable data structure into the right side of the generator. For example, here is an example of getting the message from a keyword list of responses:
responses = [ok: "Hello World", error: "Server Error", ok: "What up"]
for {code, msg} <- responses, do: msg
As you can see from this example, we deconstruct each tuple of the keyword list into code
and msg
elements using pattern matching.
We can also use pattern matching to only choose certain elements of the data. For example, here I’m using pattern matching to only return the ok
responses:
for {:ok, msg} <- responses, do: msg
You can also use multiple generators that act like nested loops. For example, if we have the following two lists:
one = [1, 2, 3]
two = [4, 5, 6]
We can combine them into a list of tuples like this:
for a <- one, b <- two, do: [a, b]
This will produce the following list:
[{1, 4}, {1, 5}, {1, 6}, {2, 4}, {2, 5}, {2, 6}, {3, 4}, {3, 5}, {3, 6}]
Here you can see we iterate through the one
list and then iterate through the two
list for each element.
A couple of weeks ago we looked at using pattern matching with functions. In that tutorial we saw how guards can be used to provide greater control.
If pattern matching doesn’t cut it when using a comprehension, you could also use a filter. A filter is basically the same as a guard.
For example, if you had the following list:
items = [:ok, 123, "hello world"]
We could create a new list by using the is_atom
function:
for n <- items, is_atom(n), do: n
This will produce a new list containing only the :ok
atom.
You can also pass in your own functions to be used as a filter. Here we have a function that checks to see if a number is divisible by 5:
divisible_by_5? = fn n -> rem(n, 5) == 0 end
We can pass this function as a filter just like we did in the previous example:
for n <- 1..100, divisible_by_5?.(n), do: n
This will produce the following list:
[5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60, 65, 70, 75, 80, 85, 90, 95, 100]
You can also use multiple filters. For example, here I’ve added the is_even/1
function from the Integer
module to also filter out any non-even numbers:
import Integer
for n <- 1..100, divisible_by_5?.(n), is_even(n), do: n
This will produce the following list:
[10, 20, 30, 40, 50, 60, 70, 80, 90, 100]
In each of the examples we’ve looked at so far the comprehension has always returned a new list. If you want to return a different type of data structure you can do that using the :into
option. The only requirement is that the data structure must implement the Collectable
protocol (What are Elixir Protocols?).
For example, here I’ve got a map where I want to convert each value to begin with an uppercase character:
me = %{first_name: "philip", last_name: "brown"}
I could achieve this using a comprehension:
for {k, v} <- me, into: %{}, do: {k, String.capitalize(v)}
This will produce the following map:
%{first_name: "Philip", last_name: "Brown"}
Comprehensions are another way of working enumerables. They provide some nice syntactic sugar, but there is no real difference in terms of performance.
Comprehensions can use multiple generators for nested loops, pattern matching, and filters. You can also produce anything that is “collectable”.
I think the main benefit of comprehensions is readability. Whilst we’ve only looked at simple examples in this tutorial, you will likely see better examples in the wild that benefit from being written as a comprehension, especially when using mutliple generators or filters.