Elixir is a very young programming language (emerged in 2011), but it is gaining popularity. I was initially interested in this language because when using it you can look at some common tasks programmers usually solve from a different angle. For instance, you can find out how to iterate over collections without the for
cycle, or how to organize your code without classes.
Elixir has some very interesting and powerful features that may be hard to get your head around if you came from the OOP world. However, after some time it all starts to make sense, and you see how expressive the functional code can be. Comprehensions are one such feature, and this article I will explain how to work with them.
Comprehensions and Mapping
Generally speaking, a list comprehension is a special construct that allows you to create a new list based on existing ones. This concept is found in languages like Haskell and Clojure. Erlang also presents it and, therefore, Elixir has comprehensions as well.
You might ask how comprehensions are different from the map/2 function, which also takes a collection and produces a new one? That would be a fair question! Well, in the simplest case, comprehensions do pretty much the same thing. Take a look at this example:
defmodule MyModule do def do_something(list) do list |> Enum.map(fn(el) -> el * 2 end) end end MyModule.do_something([1,2,3]) |> IO.inspect # => [2,4,6]
Here I am simply taking a list with three numbers and producing a new list with all the numbers multiplied by 2
. The map
call can be further simplified as Enum.map( &(&1 * 2) )
.
The do_something/1
function can now be rewritten using a comprehension:
def do_something(list) do for el <- list, do: el * 2 end
This is what a basic comprehension looks like and, in my opinion, the code is a bit more elegant than in the first example. Here, once again, we take each element from the list and multiply it by 2
. The el <- list
part is called a generator, and it explains how exactly you wish to extract the values from your collection.
Note that we are not forced to pass a list to the do_something/1
function—the code will work with anything that is enumerable:
defmodule MyModule do def do_something(collection) do for el <- collection, do: el * 2 end end MyModule.do_something((1..3)) |> IO.inspect
In this example, I am passing a range as an argument.
Comprehensions work with binstrings as well. The syntax is slightly different as you need to enclose your generator with <<
and >>
. Let's demonstrate this by crafting a very simple function to "decipher" a string protected with a Caesar cipher. The idea is simple: we replace each letter in the word with a letter a fixed number of positions down the alphabet. I'll shift by 1
position for simplicity:
defmodule MyModule do def decipher(cipher) do for << char <- cipher >>, do: char - 1 end end MyModule.decipher("fmjyjs") |> IO.inspect # => 'elixir'
This is looking pretty much the same as the previous example except for the <<
and >>
parts. We take a code of each character in a string, decrement it by one, and construct a string back. So the ciphered message was "elixir"!
But still, there is more than that. Another useful feature of comprehensions is the ability to filter out some elements.
Comprehensions and Filtering
Let's further extend our initial example. I am going to pass a range of integers from 1
to 20
, take only the elements that are even, and multiply them by 2
:
defmodule MyModule do require Integer def do_something(collection) do collection |> Stream.filter( &Integer.is_even/1 ) |> Enum.map( &(&1 * 2) ) end end MyModule.do_something( (1..20) ) |> IO.inspect
Here I had to require the Integer
module to be able to use the is_even/1
macro. Also, I am using Stream
to optimize the code a bit and prevent the iteration from being performed twice.
Now let's rewrite this example with a comprehension again:
def do_something(collection) do for el <- collection, Integer.is_even(el), do: el * 2 end
So, as you see, for
can accept an optional filter to skip some elements from the collection.
You are not limited to only one filter, so the following code is legit as well:
def do_something(collection) do for el <- collection, Integer.is_even(el), el < 10, do: el * 2 end
It will take all even numbers less than 10
. Just don't forget to delimit filters with commas.
The filters will be evaluated for each element of the collection, and if evaluation returns true
, the block is executed. Otherwise, a new element is taken. What's interesting is that generators can also be used to filter out elements by using when
:
def do_something(collection) do for el when el < 10 <- collection, Integer.is_even(el), do: el * 2 end
This is very similar to what we do when writing guard clauses:
def do_something(x) when is_number(x) do # ... end
Comprehensions With Multiple Collections
Now suppose we have not one but two collections at once, and we'd like to produce a new collection. For example, take all even numbers from the first collection and odd from the second one, and then multiply them:
defmodule MyModule do require Integer def do_something(collection1, collection2) do for el1 <- collection1, el2 <- collection2, Integer.is_even(el1), Integer.is_odd(el2), do: el1 * el2 end end MyModule.do_something( (1..20), (5..10) ) |> IO.inspect
This example illustrates that comprehensions may work with more than one collection at once. The first even number from collection1
will be taken and multiplied by each odd number from collection2
. Next, the second even integer from collection1
will be taken and multiplied, and so on. The result will be:
[10, 14, 18, 20, 28, 36, 30, 42, 54, 40, 56, 72, 50, 70, 90, 60, 84, 108, 70, 98, 126, 80, 112, 144, 90, 126, 162, 100, 140, 180]
What's more, the resulting values are not required to be integers. For instance, you may return a tuple containing integers from the first and the second collections:
defmodule MyModule do require Integer def do_something(collection1, collection2) do for el1 <- collection1, el2 <- collection2, Integer.is_even(el1), Integer.is_odd(el2), do: {el1,el2} end end MyModule.do_something( (1..20), (5..10) ) |> IO.inspect # => [{2, 5}, {2, 7}, {2, 9}, {4, 5}...]
Comprehensions With the "Into" Option
Up to this point, the final result of our comprehension was always a list. This is, actually, not mandatory either. You can specify an into
parameter that accepts a collection to contain the resulting value.
This parameter accepts any structure that implements the Collectable protocol, so for example we may generate a map like this:
defmodule MyModule do require Integer def do_something(collection1, collection2) do for el1 <- collection1, el2 <- collection2, Integer.is_even(el1), Integer.is_odd(el2), into: Map.new, do: {el1,el2} end end MyModule.do_something( (1..20), (5..10) ) |> IO.inspect # => %{2 => 9, 4 => 9, 6 => 9...}
Here I simply said into: Map.new
, which can be also replaced with into: %{}
. By returning the {el1, el2}
tuple, we basically set the first element as a key and the second as the value.
This example is not particularly useful, however, so let's generate a map with a number as a key and its square as a value:
defmodule MyModule do def do_something(collection) do for el <- collection, into: Map.new, do: {el, :math.sqrt(el)} end end squares = MyModule.do_something( (1..20) ) |> IO.inspect # => %{1 => 1.0, 2 => 1.4142135623730951, 3 => 1.7320508075688772,...} squares[3] |> IO.puts # => 1.7320508075688772
In this example I am using Erlang's :math
module directly, as, after all, all modules' names are atoms. Now you can easily find the square for any number from 1
to 20
.
Comprehensions and Pattern Matching
The last thing to mention is that you can perform pattern matching in comprehensions as well. In some cases it may come in pretty handy.
Suppose we have a map containing employees' names and their raw salaries:
%{"Joe" => 50, "Bill" => 40, "Alice" => 45, "Jim" => 30}
I want to generate a new map where the names are downcased and converted to atoms, and salaries are calculated using a tax rate:
defmodule MyModule do @tax 0.13 def format_employee_data(collection) do for {name, salary} <- collection, into: Map.new, do: {format_name(name), salary - salary * @tax} end defp format_name(name) do name |> String.downcase |> String.to_atom end end MyModule.format_employee_data( %{"Joe" => 50, "Bill" => 40, "Alice" => 45, "Jim" => 30} ) |> IO.inspect # => %{alice: 39.15, bill: 34.8, jim: 26.1, joe: 43.5}
In this example we define a module attribute @tax
with an arbitrary number. Then I deconstruct the data in the comprehension using {name, salary} <- collection
. Lastly, format the name and calculate the salary as needed, and store the result in the new map. Quite simple yet expressive.
Conclusion
In this article we have seen how to use Elixir comprehensions. You may need some time to get accustomed to them. This construct is really neat and in some situations can fit in much better than functions like map
and filter
. You can find some more examples in Elixir's official docs and the getting started guide.
Hopefully, you've found this tutorial useful and interesting! Thank you for staying with me, and see you soon.
No comments:
Post a Comment