Powered by AppSignal & Oban Pro
Would you like to see your link here? Contact us

Comprehensions

reading/comprehensions.livemd

Comprehensions

Mix.install([
  {:jason, "~> 1.4"},
  {:kino, "~> 0.9", override: true},
  {:youtube, github: "brooklinjazz/youtube"},
  {:hidden_cell, github: "brooklinjazz/hidden_cell"}
])

Navigation

Home Report An Issue Non-EnumerablesPalindrome

Review Questions

Upon completing this lesson, a student should be able to answer the following questions.

  • When should we use comprehensions vs Enum?
  • How do we create a comprehension using generators, filters, and collectables?

Overview

Comprehensions

Comprehensions are a convenient way to create new lists, maps, or sets by iterating over an existing collection and applying a set of transformations. They are similar to the Enum.map/2, Enum.filter/2, and Enum.reduce/2 functions but with a more concise syntax.

Generators

A generator is an enumerable the comprehension will enumerate over to produce some output.

generator = 1..3

for element <- generator do
  element * 2
end

Filters

A filter is a true/false condition the generator uses to determine which elements should remain.

for element <- 1..3, element >= 2 do
  element
end

Collectable

By default, a comprehension collects the result into a list. However, we can specify the data structure to collect the result into using the :into collector.

for element <- 1..3, into: "" do
  "#{element}"
end

Comprehensions

Let’s try to comprehend comprehensions..sorry 🤦‍♂️.

But in all seriousness, you might mistake a comprehension for a for loop if you’re coming from another programming language. This is not the case! Notice below that the comprehension returns a list.

for each <- 1..100 do
  each * 2
end

Since the comprehension has a return value, it can be bound to a variable and used elsewhere.

my_comprehension =
  for each <- 1..10 do
    each * 2
  end

List.to_tuple(my_comprehension)

A comprehension can even be piped into another function. This isn’t usually idomatic, but it’s possible.

for each <- 1..10 do
  each * 2
end
|> List.to_tuple()

In an OOP language without immutability, it’s common to use for loops to mutate some variable. We don’t do that in Elixir! Notice below that sum is still 0.

sum = 0

for each <- 1..100 do
  sum = sum + each
end

sum

So what are comprehensions for? Currently, we can accomplish the same behavior with the Enum module.

Enum.map(1..100, fn each -> each * 2 end)

A comprehension is syntax sugar. That means that while it may look cleaner, it does not provide any additional behavior. The Enum module and comprehensions can accomplish the same behavior.

A comprehension is broken down into three parts: generators, filters, and collectables.

Generators

In the following example, the generator is n <- 1..100 which defines the collection to enumerate over.

for n <- 1..100 do
  n
end

We can also use pattern matching in the generator to ignore non-matching values.

for {:keep, value} <- [keep: 1, keep: 2, filter: 1, filter: 3] do
  value
end

Here’s where comprehensions get cool and stop looking like loops. You can use multiple comma-separated generators in a single comprehension.

The comprehension treats each additional generator like a nested loop. For each element in the first loop, it will enumerate through every element in the second loop.

for a <- 1..3, b <- 4..6 do
  {a, b}
end
flowchart
  subgraph D[generator 2]
    4a[4]
    5a[5]
    6a[6]
  end
  subgraph C[generator 2]
    4b[4]
    5b[5]
    6b[6]
  end
  subgraph B[generator 2]
    4c[4]
    5c[5]
    6c[6]
  end
  subgraph A[generator 1]
    1 --> B
    2 --> C
    3 --> D
  end

We can even use elements from one generator in the next generator.

for a <- 1..3, b <- a..0 do
  {a, b}
end
flowchart
  subgraph D[generator 2]
    1a[1]
    0a[0]
  end
  subgraph C[generator 2]
    2b[2]
    1b[1]
    0b[0]
  end
  subgraph B[generator 2]
    3c[3]
    2c[2]
    1c[1]
    0c[0]
  end
  subgraph A[generator 1]
    1 --> D
    2 --> C
    3 --> B
  end

There’s no real limit to the number of generators that you can use, but each generator creates another nested loop and therefore has a significant performance impact.

Your Turn

In the Elixir cell below, use a comprehension with a generator of 1..50 to create a list of even integers from 2 to 100.

for a <- 1..50, do: a * 2

Filters

We can use filters in a comprehension to filter out elements from the generator. For example, we could omit all values not divisible by 3.

The comprehension will filter out all values that do not return true for the filter function.

# Finds All Values In 1..100 Divisible By 3
for n <- 1..100, rem(n, 3) === 0 do
  n
end

We can use multiple comma-separated filters.

for a <- 1..100, rem(a, 3) === 0, rem(a, 5) === 0 do
  a
end

We can also use multiple comma-separated filters and generators. Filters and generators can go in any order. However, it’s likely more clear to group filters together after the generators.

for a <- 1..45, b <- 1..5, rem(a, 5) === 0, rem(b, 5) === 0 do
  [a, b]
end

That said, you can put the generators and filters in alternating order.

for a <- 1..45, rem(a, 5) === 0, b <- 1..5, rem(b, 5) === 0 do
  [a, b]
end

The generators must go in the order you want to nest them, otherwise, you change the behavior. The filters must also go after any generator whose variable they rely on. For example, b has not yet been defined here.

for a <- 1..45, rem(a, 5) === 0, rem(b, 5) === 0, b <- 1..5 do
  [a, b]
end

Your Turn

You are given three generators that are ranges of numbers from 1..7.

Find every trio of numbers in each range that when added together equals seven.

# Expected Output
[
  {1, 1, 5},
  {1, 2, 4},
  {1, 3, 3},
  {1, 4, 2},
  {1, 5, 1},
  {2, 1, 4},
  {2, 2, 3},
  {2, 3, 2},
  {2, 4, 1},
  {3, 1, 3},
  {3, 2, 2},
  {3, 3, 1},
  {4, 1, 2},
  {4, 2, 1},
  {5, 1, 1}
]

Example Solution

for a <- 1..7, b <- 1..7, c <- 1..7, a + b + c == 7 do
  {a, b, c}
end
for a <- 1..7, b <- 1..7, c <- 1..7, a + b + c == 7, do: {a, b, c}

Collectables

By default, the comprehension returns a list. However, we can accumulate the elements into any value with the :into option. Into works with any collection that you’re used to using with the Enum module.

Even without the :into option, we can create a comprehension that returns a keyword list by using tuples with an atom as the first element and any value as the second element.

for n <- 1..5 do
  {:"key_#{n}", n}
end

Now, since both maps and keyword lists are key-value tuples underneath, we can specify the :into option to instead return a map.

for n <- 1..5, into: %{} do
  {:"key_#{n}", n}
end

This map could have default values.

for n <- 1..5, into: %{default: "hello!"} do
  {:"key_#{n}", n}
end

And instead of a map, it can be any data type that implements the Collectable protocol.

This includes strings!

for n <- 1..10, do: "#{n}", into: ""

Using a collector can be useful when we’re using a comprehension with a non-list enumerable data type, for example when transforming maps.

for {key, value} <- %{a: 1, b: 2}, into: %{} do
  {key, value * 2}
end

Your Turn

Given the generator %{a: 1, b: 2}, use a comprehension with a collector to convert an atom-key map into a string key map.

# Input
%{a: 1, b: 2}
# Output
%{"a" => 1, "b" => 2}

Example Solution

for {key, value} <- %{a: 1, b: 2}, into: %{} do
  {"#{key}", value}
end
for {key, value} <- %{a: 1, b: 2}, into: %{}, do: {Atom.to_string(key), value}

Why Use A Comprehension?

Enum.map/2, Enum.reduce/2, and Enum.filter/2 can accomplish all of the same functionality as a comprehension. In general, the Enum is more broadly used than comprehensions.

Consider using comprehensions as a refactoring tool to improve the clarity of your code. They are particularely useful for building and generating collections of data. It’s typically non-idiomatic to use a comprehension when the Enum module would work instead.

Avoid over relying on comprehensions, especially if you’re from an Object Oriented Programming background and feel tempted to use comprehensions because they feel like for loops. Comprehensions are not for loops! Instead, consider using comprehensions to refactor and improve your code after you’ve already written it.

Your Turn

In the Elixir cell below, convert the following Enum.map into a comprehension.

Enum.map(1..5, fn each -> each * 2 end)
for n <- 1..5, do: n * 2

In the Elixir cell below, convert the following Enum.reduce into a comprehension.

Enum.reduce(["a", "b", "c"], fn each, acc -> acc <> each end)
for s <- ["a", "b", "c"], into: "", do: s

In the Elixir cell below, convert the following nested Enum.map into a comprehension.

Enum.map(1..3, fn a -> Enum.map(1..3, fn b -> {a, b} end) end)
Enum.map(1..3, fn a -> Enum.map(1..3, fn b -> {a, b} end) end)
for a <- 1..3, b <- 1..3, do: {a, b}

Commit Your Progress

DockYard Academy now recommends you use the latest Release rather than forking or cloning our repository.

Run git status to ensure there are no undesirable changes. Then run the following in your command line from the curriculum folder to commit your progress.

$ git add .
$ git commit -m "finish Comprehensions reading"
$ git push

We’re proud to offer our open-source curriculum free of charge for anyone to learn from at their own pace.

We also offer a paid course where you can learn from an instructor alongside a cohort of your peers. We will accept applications for the June-August 2023 cohort soon.

Navigation

Home Report An Issue Non-EnumerablesPalindrome