For comprehensions in Elixir

Beyond simple mapping

3 minute read, Posted on Wed, Apr 1, 2020

Comprehensions (for) are one of those parts of Elixir which I somewhat overlooked when I was initially learning the language. I thought of them as syntactic sugar for Enum.map/2. In other words, a simple mapping like shown below:

iex(1)> Enum.map([1,2,3], &(&1*2))
[2, 4, 6]

Can also be achieved using a comprehension:

iex(1)> for n <- [1,2,3] do
...(1)>   n * 2
...(1)> end
[2, 4, 6] 

It turns out the inconspicuous for is much more powerful that it might initially seem. Let’s have a brief look at other things beyond simple mapping that comprehensions can achieve.

Build maps instead of lists

By default, a for comprehension returns a list just like Enum.map/2 does. It’s very easy, however, to return a map instead:

iex(2)> for n <- [1,2,3], into: %{} do
...(2)>   {n, n * 2}
...(2)> end
%{1 => 2, 2 => 4, 3 => 6}

All you need to do is:

  • use the into option and pass it an empty map,
  • remember to return {key, value} tuples instead of individual values.

Simple!

In fact, the into: option isn’t limited to just maps. It accepts anything that implements the Collectable protocol.

Filter unneeded elements

Sometimes we want to ignore some elements whilst mapping. Instead of chaining Stream.filter/2 with Enum.map/2 we can use a single for comprehension too!

iex(3)> for n <- 1..10,
...(3)>     rem(n, 2) == 0,
...(3)>     into: %{} do
...(3)>   {n, n * n}
...(3)> end
%{2 => 4, 4 => 16, 6 => 36, 8 => 64, 10 => 100}

Here we’re calculating squares of even numbers from 1 to 10. This is done by filtering out the odd numbers with a filter rem(n, 2) == 0. Any item for which the filter returns a falsy value will be ignored.

It’s worth bearing in mind that we can also calculate intermediate values and filter on those, e.g.

iex(4)> for n <- 1..10,
...(4)>     rem = rem(n, 2),
...(4)>     rem == 0,
...(4)>     into: %{} do
...(4)>   {n, n * n}
...(4)> end
%{2 => 4, 4 => 16, 6 => 36, 8 => 64, 10 => 100}

It’s probably an overkill to introduce an intermediate variable here but it could come in handy in more complex scenarios.

Reduce

We’ve seen that for comprehensions can do quite a lot of things already but one can could think that they can’t replace the Swiss Army knife of the Enum module, Enum.reduce/3. It turns out, however, that there’s a reduce: option too!

iex(7)> for word <- ~w(ant bee cat ant bee ant), reduce: %{} do
...(7)>   acc -> Map.update(acc, word, 1, &(&1+1))
...(7)> end
%{"ant" => 3, "bee" => 2, "cat" => 1}

Here we use the reduce: option to calculate the number of times words occur in a list. When the reduce: option is used, the body of the comprehension needs to use the -> notation, e.g. just like a case statement does. The original value of the accumulator is whatever was given to reduce: and is updated for every consecutive element of the original list.

Keep the for comprehension in mind

It turns out there is more to comprehensions than meets the eye and they are capable of far more than simply mapping lists.

They can be easier on the eye too. The official Elixir guide summarises them as follows:

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

A word of caution though - whilst using comprehensions everywhere may be tempting, some lesser-known options, like reduce:, can throw people off at first and may require some getting used to.

Comprehensions are a useful tool. They’re worth keeping in mind and reaching for when you’re looking for conciseness and finding multiple chained calls to Enum functions too confusing.

comments powered by Disqus