Comprehensions
Comprehensions provide a powerful mechanism for performing functions on a collection using conditional logic. They are useful for iterating over a collection and filtering the results, or mapping the values into another collection. Comprehensions aren't the only option for working with collections. Elixir also has the Enum
and Stream
modules, which can both iterate over collections, passing functions to be performed on each element. However, the benefit of using comprehensions is that the resulting code is easier to both read and write. Comprehensions are essentially syntactic sugar for working on collections. Before we begin working with comprehensions, let's review some general terminology we should be familiar with.
Comprehensions begin with the word for
and are followed by a generator and block. They can also include a filter, which defines some conditional logic to determine which elements in the collection should be passed to the block. The generator contains a temporary variable and the collection. The block is simply a function to be performed on each element within the collection.
Let's look at a comprehension that finds the square of each number in the list [1, 2, 3, 4].
Syntax
# ┌Generator ┌Block
# ┌────────┴──────────┐ ┌──────┴─────┐
iex> for num <- [1, 2, 3, 4], do: num * num
[1, 4, 9, 16]
We can read the previous example as, "for number in the list, [1, 2, 3, 4], square each number". The return value is what we would expect back, a list of each number squared. We can add a filter to this comprehension by specifying that we only want to perform the function block on even numbers.
# ┌Generator ┌Filter ┌Block
# ┌────────┴───────────┐ ┌─────────┴─────┐ ┌───────┴────┐
iex> for num <- [1, 2, 3, 4], rem(num, 2) == 0, do: num * num
[4, 16]
The addition rem(num, 2) ==0
states that our function block should only be applied to the elements when the element divided by two has a remainder of zero. Said another way, the function should be applied to even numbers only. This is true for two elements, 2 and 4. Therefore, our return value is a list containing the square of 2 and square of 4.
Pattern Matching
Pattern matching is a core mechanism of Elixir and therefore the underlying theme of this book. If we have a map of names and associated balances we can use a comprehension to pattern match on the balance. We do this in the generator:
iex> for {_name, balance} <- %{jim: 450, dwight: 600, pam: 1_000, angela: 400}, do: balance
[400, 600, 450, 1000]
Note how the name is prefixed with an underscore, telling Elixir to ignore that field.
Why use Comprehensions?
Up until this point you may be thinking, "why even use pattern matching at all?" If you're familiar with the Enum module you already know how to iterate over collections without using comprehensions. In fact, we can return to our first example of finding the square of each number in a list of [1, 2, 3, 4]. We can acheive the same result using the Enum module and map function:
Enum.map(1..4, &(&1 * &1))
[1, 4, 9, 16]
Remember that in Elixir we use functions by calling them with the syntax Module.function()
and passing in the correct number of arguments. This map function will iterate over the collection (first argument) performing the given function (second argument). The capture operator (&) indicates a function. The term &1 represents the first argument passed in. The English translation of the above function would be, "For each element in range 1-4, square each element". The result returned is the same as our first example comprehension, the square of each number 1-4 , which is [1, 4, 9, 16].
There are no performance implications when iterating over a collection with the Enum module or using comprehensions. Therefore, choosing a comprehension or function from the Enum module is a choice left to the preference of the developer. So, why do we even have comprehensions then?
In our example, we have a small collection and we want to perform a simple function. However, what about cases were we have more than one collection? If we need to perform an iteration over multiple collections and then combine those values into a new collection we are left with some nested code that is difficult to read and prone to error. This isn't an edge case. It's not uncommon to need to combine collections to perform some functions. For example, consider creating a deck of playing cards.
Comprehensions for Multiple Collections
Let's pretend we are creating some type of card game. One of the first things we'll need is a function that can create a deck of cards for us. In a standard deck of playing cards there are 13 different values and 4 different suits, resulting in a total of 52 cards. Knowing this, we can represent each card as a struct %{ }, for more information on structs please refer to the Structs chapter. Each card can be defined as a struct with a value and suit, like so: %Card{ :value, :suit}
. Given this information, we can start developing our game by creating a Deck module for our cards.
defmodule Game.Deck do
@moduledoc """
Provides a Deck context to hold the Card struct.
"""
defmodule Card do
defstruct [:value, :suit]
end
end
Here we have nested our Card module inside of the Deck module. Usually we wouldn't put two modules in a single file but in this case we only need to create the Card module in order to define our struct. Structs must be defined within a module. By doing it this way we now have a card struct with two attributes, value and suit, which belongs to the Deck module. This makes sense as it's a reflection of a how we would think of a deck of cards in real life.
Next, we need a function for creating a new deck of cards. This is where comprehensions shine! Our function will need to loop over a collection of values, loop over a collection of suits, and then return a new collection mapping each possible value to each possible suit. First, let's define the "values" and "suits" collections.
values = [
"Ace", "Two", "Three",
"Four", "Five", "Six",
"Seven", "Eight", "Nine", "Ten",
"Jack", "Queen", "King"
]
suits = ["Spades", "Clubs", "Hearts", "Diamonds"]
We create two temporary variables, values and suits. Now that we have these collections defined, let's finish the "create_deck" function by writing our comprehension.
defmodule Gofish.Deck do
# ... some code removed for brevity ...
def create_deck do
values = [
"Ace", "Two", "Three",
"Four", "Five", "Six",
"Seven", "Eight", "Nine", "Ten",
"Jack", "Queen", "King"
]
suits = ["Spades", "Clubs", "Hearts", "Diamonds"]
for value <- values, suit <- suits do
%Card{value: value, suit: suit}
end
end
end
Notice how easy it is to read and understand our comprehension. "For each value in the list of values and each suit in the list of suits, return a Card struct with a value and suit. Let's take a look at what is returned when we call the "create_deck" function.
[%Game.Deck.Card{suit: "Spades", value: "Ace"},
%Game.Deck.Card{suit: "Clubs", value: "Ace"},
%Game.Deck.Card{suit: "Hearts", value: "Ace"},
%Game.Deck.Card{suit: "Diamonds", value: "Ace"},
...
Elixir returns to us a list of structs that resembles a new deck of cards! All of the aces are together, followed by the twos, then the threes, and so on. This is exactly what we wanted and the code was easy to write (and even easier to read).
As a side note, the Enum module has a "shuffle" function that randomizes the elements in a collection. If we wanted to return a shuffled deck from our "create_deck" function we can simply pipe the return value of the comprehension into Enum.shuffle.
# the comprehension piped to Enum.shuffle
for value <- values, suit <- suits do
%Card{value: value, suit: suit}
end |> Enum.shuffle
# create_deck now returns
[%Gofish.Deck.Card{suit: "Diamonds", value: "Jack"},
%Gofish.Deck.Card{suit: "Diamonds", value: "Six"},
%Gofish.Deck.Card{suit: "Spades", value: "Queen"},
%Gofish.Deck.Card{suit: "Hearts", value: "Two"},
...
Now we have a valid deck of 52 playing cards, already shuffled for us. Notice how easy it was to build the comprehension and then transform that data into a shuffled deck of cards. Programming with Elixir is generally easier than with other languages. We can often write less code with cleaner syntax than C or Java.