Best practices of comprehensions in Elixir

Surprising examples of using for-comprehension in Elixir

Kamil Lelonek
Kamil Lelonek  - Software Engineer

--

After writing my previous article, which explains loops in Elixir:

I received very positive feedback and many questions about for concept. It looks simple at first glance, and it’s beneficial at the same time, so I decided to explain it comprehensively ( ͡° ͜ʖ ͡°).

What are the Elixir Comprehensions?

For comprehension provides a very elegant syntax to loop over collections and an effective way to transform the given enumerable.

A special for form gives you syntactic sugar to iterate, filter, and map the given structures. The entire expression consists of three parts:

  • generators — produce the data for comprehension. Usually, from an enumerable like a list, a map, a range, or even a bitstring. In the form of:
    element <- collection.
  • filters — select only particular elements from an enumerable based on the given condition. In the shape of a function, e.g. x > 2, any_function(element), or even annonymous_function.(element).
  • collectables — the result of comprehension. Data structures that implement the Collectable protocol, a list by default.

Comprehensions provide much more concise alternatives for the equivalent functions in the Enum module:

map

The simplest example may be to replace the following map function:

iex(1)> Enum.map([1, 2, 3], fn i -> i * 2 end)
[2, 4, 6]
# oriex(2)> Enum.map([1, 2, 3], & &1 * 2)
[2, 4, 6]

If you had a list of 3 elements [1, 2, 3], and you want to multiply each of them by 2, the corresponding for shorthand would be:

iex(3)> for x <- [1, 2, 3], do: x * 2
[2, 4, 6]

Keep in mind the return result from for is a list by default, so this operation iterates over the given list, capturing the current element as x and multiplying it by 2.

filter

The next feature you can achieve, thanks to filters, is discarding all items that don’t match the given condition. For example, you may want to reject all odd numbers so that you can leverage Integer module for that as follows:

iex(4)> require Integer
Integer
iex(5)> Enum.filter([4, 5, 6, 7], &Integer.is_even/1)
[4, 6]

As you’ve probably already guessed, you can achieve the same using for syntactic sugar here. Our filter will act as a guard in the comprehension:

iex(6)> for x <- [4, 5, 6, 7], Integer.is_even(x), do: x
[4, 6]

You can see that filter goes directly after a generator and before a do block, which produces the result — in our case, returns a filtered-out value.

A side note: you must require Integer before invoking the macro:
Integer.is_even/1.

transform

A common use case in Elixir is to convert string keys in maps into atoms. I will talk about a flat structure for the sake of example so that the map can be as simple as that:

iex(7)> map = %{"foo" => "bar", "hello" => :world}
%{"foo" => "bar", "hello" => :world}

How would you do that using theMap module? It’s pretty simple:

iex(8)> Map.new(map, fn {k, v} -> {String.to_atom(k), v} end)
%{foo: "bar", hello: :world}

You create a new map with a transformation function — it keeps the original value but turns its string keys into atoms — exactly like expected.

As you already know, by default, the result of comprehension is a list. What if you want to produce something else? You can use :into option that accepts any structure that implements the Collectable protocol — an empty map in our case:

iex(9)> for {key, val} <- map, into: %{}, do: {:"#{key}", val}
%{foo: "bar", hello: :world}

What has happened there? A single element of a map (or a keyword list) is a tuple {key, value}, so you match it like that. You use :into option to turn the list of tuples into a totally legitimate map. Once you have both key and value accessible, you turn the key into an atom and leave the value as is. The result you will have is a map with atom keys.

reduce

Back in the day, most of us wanted to calculate how many times each word occurs in the given text. Its graphical visualization is called histogram — an approximate representation of the distribution of numerical data.

Imagine you have a sentence like this:

iex(10)> sentence = "the sad truth is that the truth is sad"
"the sad truth is that the truth is sad"

If you want to count how often each word appears in the sentence, you will use, for example, Enum.reduce/3 function:

iex(11)> sentence
...(11)> |> String.split()
...(11)> |> Enum.reduce(%{}, fn word, acc ->
...(11)> Map.update(acc, word, 1, &(&1 + 1))
...(11)> end)
%{"is" => 2, "sad" => 2, "that" => 1, "the" => 2, "truth" => 2}

I won’t explain it here, but I guess you understand all the steps. I split the sentence into words; I create an empty map that stores entries in a form: word => count and I update it while traversing the list. The result is as you can see.

How to use comprehensions for the same operation? There’s a secret option :reduce that you can provide:

iex(12)> for word <- String.split(sentence), reduce: %{} do
...(12)> acc -> Map.update(acc, word, 1, &(&1+1))
...(12)> end
%{"is" => 2, "sad" => 2, "that" => 1, "the" => 2, "truth" => 2}

The output is the same! Thanks to the :reduce option, you can provide a reducer function with access to the current element — like in the Enum.reduce/3 function.

Generators

Generators produce values to be used in comprehension. Any enumerable can be given on the right side, and the left side is used to catch a single element.

There are a couple of useful tricks to use with generators. Let’s see some of them.

Pattern matching

You’ve probably noticed I used pattern matching to extract a key and a value from a single map element in the previous example. You can use pattern matching to match a specific structure of an item in the list too:

iex(23)> people = [{"john", 23, true}, {"jane", 17, false}, {"bob", 30, true}]
[{"john", 23, true}, {"jane", 17, false}, {"bob", 30, true}]
iex(22)> for {name, age, married} <- people do
...(22)> "#{name} is #{age} years old and " <> (if married, do: "", else: "not ") <> "married"
...(22)> end
["john is 23 years old and married", "jane is 17 years old and not married", "bob is 30 years old and married"]

skipping

You can also use pattern matching as filtering and reject values that don’t match there. However, you can also match every item but skip its inner element. For example, you can discard marital status from the previous list like that:

iex(24)> for {name, age, _married} <- people do
...(24)> "#{name}: #{age} years old"
...(24)> end
["john: 23 years old", "jane: 17 years old", "bob: 30 years old"]

ignoring

What if a pattern won’t match completely? Will it raise the… exception? Not in comprehensions — the value will be just ignored.

Have a look at the following example:

iex(25)> for {name, _, true} <- people do
...(25)> name <> " is married"
...(25)> end
["john is married", "bob is married"]

You can match only people that are married, so the 3rd element in its tuple is true. There’s no exception because comprehension ignores the entire element that doesn’t match. It’s handy, isn’t it?

more than one

Imagine the case when you need to combine two data sources and produce a result with values from both. This is also possible because you can have more than one generator:

iex(26)> clothes = ~w(shirt pants socks)
["shirt", "pants", "socks"]
iex(27)> sizes = ~w(S M L XL)a
[:S, :M, :L, :XL]

iex(28)> for part <- clothes, size <- sizes, do: {part, size}
[
{"shirt", :S},
{"shirt", :M},
{"shirt", :L},
{"shirt", :XL},
{"pants", :S},
{"pants", :M},
{"pants", :L},
{"pants", :XL},
{"socks", :S},
{"socks", :M},
{"socks", :L},
{"socks", :XL}
]

Multiple generators can be useful for building permutations. When you have some elements and combine one with another, you can leverage this feature of comprehension.

Summary

Leave your e-mail to get the latest content immediately:
https://tinyletter.com/KamilLelonek

Comprehensions generally provide a much more concise representation than using the equivalent functions from the Enum module.

This is exactly what the official docs say, and it’s pretty accurate. With Elixir comprehension, you have an elegant syntax, a concise form, and a useful shorthand for manipulating and building collections.

I hope you now have comprehensive knowledge about Elixir's comprehensions. Let me know if you find any other useful comprehension’s hints :)

--

--