Joy of Elixir

10. Working with maps

In the last chapter we looked at some functions that we could use to work with lists. We looked at some functions from the List module, but we really spent most of the chapter on the Enum module. In this chapter, we'll spend some time talking about how to use Enum.each/2 and Enum.map/2 on maps instead of lists. Then we'll cover some functions from the Map module.

We spoke in that last chapter of using the Enum.each/2 function to work with maps:

Similar to this, we could enumerate through a map. If we were to take one of our maps from earlier...

%{name: "Izzy", age: "30ish", gender: "Female"}

...and write each key and value pair down, they may look something like this:

Name
Izzy
Age
30ish
Gender
Female

But we never got around to actually showing you how this was done! Well, enough dilly-dallying, here's an example:

iex> person = %{name: "Izzy", age: "30ish", gender: "Female"}
 %{age: "30ish", gender: "Female", name: "Izzy"}
iex> Enum.each(person, fn ({key, value}) -> IO.puts value end)

Before I can go any further, Izzy interrupts. "Hey, those curly braces inside the function passed to each are weird! It looks like a map but it's not a map because it doesn't have the % before it. And it's not a list because the curliness of the brackets; a list has square brackets. So what is it?". Izzy is calling out this code specifically:

fn ({key, value}) ...

The {key, value} here indicates a type of data in Elixir that we haven't seen before called a tuple. Tuples work like lists in that the order of the elements inside them matters, but you can't enumerate through them like you can with lists; said another way: tuples are not enumerable. Tuples are used to represent ordered, linked data. In this example, each tuple represents a key and its linked value.

In this function passed to each, a tuple is used instead of two function arguments (i.e. fn(key, value) ->) to indicate that the key and the value share a connection. This key and value are not represented as a list (i.e. fn([key, value]) ->) because of this connection. (Also: Elixir in its own "brain" thinks of maps as a list of tuples, and so this is another reason why they're tuples, and not lists!)

Let's take another look at how we used Enum.each/2 and what the output was:

iex> Enum.each(person, fn ({key, value}) -> IO.puts value end)
30ish
Female
Izzy
:ok

Our use of Enum.each/2 goes through each key and value, ignores the key and outputs the value with IO.puts/1. This is almost identical to our previous example of Enum.each/2, where we used a list of cities with the IO.puts/1 function:

iex> Enum.each(cities, &IO.puts/1)

In the list example, we were able to use &IO.puts/1. In the map example though, we had to pull out the value by using the tuple, and then we needed to use the expanded version of IO.puts/1 (IO.puts(value)) to output that value. Let's look at what happens if we don't use that expanded version.

iex> Enum.each(person, &IO.puts/1)
** (Protocol.UndefinedError) protocol String.Chars not implemented for {"age", "30ish"}

Uh oh, the computer is mad at us again. This cryptic error message is a complicated way of saying that IO.puts/1 doesn't know how to output a tuple. We just need to be careful that we pass IO.puts/1 only things it can handle. If we give something that IO.puts/1 can't handle, it won't know how to output it, and so it will show this error.

We've now looked at Enum.each/2 for maps, just like how we looked at it in the last chapter for lists. In that last chapter we also looked at Enum.map/2 and Enum.reduce/2 and so it's only fair that we cover them here too.

Mapping over maps

I wonder what map we could use this time. We've used the person map quite a few times already:

iex> person = %{name: "Izzy", age: "30ish", gender: "Female"}

We should try to use another map for this section. Hmmmm. How about a map with the days of the week and their expected temperatures? Here's what the week ahead looks like in Melbourne at this current point of writing:

%{
  "Monday" => 28,
  "Tuesday" => 29,
  "Wednesday" => 29,
  "Thursday" => 24,
  "Friday" => 16,
  "Saturday" => 16,
  "Sunday" => 20
}

These expected temperatures aren't just random numbers. That's just Melbourne for you. It just can't make up its mind. Now what if for our American friends we represented these numbers in their familiar — but wacky — fahrenheit? Otherwise they might read the numbers and think that Melbourne is a frigid hellscape, which it most certainly is not.

So we want to convert these temperatures from their celsius amounts into the fahrenheit equivalents, turning the map into this:

%{
  "Monday" => 82.4,
  "Tuesday" => 84.2,
  "Wednesday" => 84.2,
  "Thursday" => 75.2,
  "Friday" => 60.8,
  "Saturday" => 60.8,
  "Sunday" => 68
}

We know already how to convert these numbers if it was simply a list of those numbers, without the days of the week, like this:

[28, 29, 29, 24, 16, 16, 20]

We would use Enum.map/2 to run a function on each of these numbers to convert the numbers to fahrenheit (using that function that we saw back in Chapter 5:

iex> forecast = [28, 29, 29, 24, 16, 16, 20]
  [28, 29, 29, 24, 16, 16, 20]
  iex> Enum.map(forecast, fn (temp) -> temp * 1.8 + 32 end)
  [82.4, 84.2, 84.2, 75.2, 60.8, 60.8, 68.0]

So what if we just tried using Enum.map/2 on our forecast map instead of on a list? What would that look like? Let's see:

iex> forecast = %{
  "Monday" => 28,
  "Tuesday" => 29,
  "Wednesday" => 29,
  "Thursday" => 24,
  "Friday" => 16,
  "Saturday" => 16,
  "Sunday" => 20
}
%{"Friday" => 16, ...}
iex> Enum.map(forecast, fn ({day, temp}) -> {day, temp * 1.8 + 32} end)
[
  {"Friday", 60.8},
  {"Monday", 82.4},
  {"Saturday", 60.8},
  {"Sunday", 68.0},
  {"Thursday", 75.2},
  {"Tuesday", 84.2},
  {"Wednesday", 84.2}
]

"Hey, that's no map! That's a list!", Izzy exclaims, seemingly adapting a quote from a relatively niche space opera series. That's right, Izzy. When we call Enum.map/2 on either a list or a map we're going to get back a list regardless. This has to do with how Elixir thinks of maps: it thinks of them as a list of key-value tuples, rather than the friendly %{} syntax that we know and love. To get this data back into the format that we're familiar with, we're going to need to use another function from the Enum toolbox, called Enum.into/2.

Let's run our Enum.map/2 line again, but this time we'll assign the output to a variable.

iex> new_forecast = Enum.map(forecast, fn ({day, temp}) -> {day, temp * 1.8 + 32} end)
[
  {"Friday", 60.8},
  {"Monday", 82.4},
  {"Saturday", 60.8},
  {"Sunday", 68.0},
  {"Thursday", 75.2},
  {"Tuesday", 84.2},
  {"Wednesday", 84.2}
]

Then we can use Enum.into/2 to convert this list of tuples back into a map:

iex> Enum.into(new_forecast, %{})
%{"Friday" => 60.8, "Monday" => 82.4, "Saturday" => 60.8, "Sunday" => 68.0,
  "Thursday" => 75.2, "Tuesday" => 84.2, "Wednesday" => 84.2}

That's better! Our data is back into the shape of a map... but the days of the week have lost their ordering. But that's okay because we don't care about ordering in maps: we access values in our maps by their matching key, not by their position.

So here we've seen how to use the Enum.map/2 and Enum.into/2 functions to iterate through a map's elements to run a function on each of those elements.

However, there is a cleaner way of writing the above code and that's with the help of one of the best Elixir features: the pipe operator. Let's spend a little while looking at the pipe operator now.

The wonderful pipe operator

I've just shown you one way to use Enum.map/2 to go over a map's values and to apply a function to each of those values. Here it is again, in case you missed it:

iex> forecast = %{
  "Monday" => 28,
  "Tuesday" => 29,
  "Wednesday" => 29,
  "Thursday" => 24,
  "Friday" => 16,
  "Saturday" => 16,
  "Sunday" => 20
}
%{"Friday" => 16, ...}
iex> new_forecast = Enum.map(forecast, fn ({day, temp}) -> {day, temp * 1.8 + 32} end)
  [
    {"Friday", 60.8},
    {"Monday", 82.4},
    {"Saturday", 60.8},
    {"Sunday", 68.0},
    {"Thursday", 75.2},
    {"Tuesday", 84.2},
    {"Wednesday", 84.2}
  ]
iex> Enum.into(new_forecast, %{})
%{
  "Friday" => 60.8,
  "Monday" => 82.4,
  "Saturday" => 60.8,
  "Sunday" => 68.0,
  "Thursday" => 75.2,
  "Tuesday" => 84.2,
  "Wednesday" => 84.2
}

What we're doing here can be broken down into a few steps:

  1. We define a variable called forecast, which is a map containing the days of the week and their expected temperatures.
  2. We then take this forecast variable, and pass it as the first argument to Enum.map/2. The second argument passed to Enum.map/2 is a function which runs on each key-value pair in the map. This function takes the temperature -- the value -- and converts it to fahrenheit.
  3. We assign the result of this Enum.map/2 call to a variable called new_forecast, and then immediately pass that into Enum.into/2 as the first argument. The second argument given to Enum.into/2 is an empty map. This converts the output of Enum.map/2 -- a list of key-value tuples -- into a friendly map.

Elixir has a way of writing this code in a shorter way that I would like to show you now as I believe it can lead to shorter, cleaner Elixir code. We might find more uses for it as we go along too, and so this seems like a pretty good time to introduce this particular concept.

The way to write shorter code here is by using the pipe operator, |>. The pipe operator allows us to chain together actions within Elixir, passing the result of one function on to the next. It's called the pipe operator because it connects the functions together in a linear fashion, with data flowing from one function to the next; like water through a pipe. Unfortunately, unlike Tony Hawk's Pro Skater, we do not get thousands of points for chaining together sick combos. But we still do get the great sense of satisfaction when it all works together!

Let's look at a small example of the pipe operator using functions from the String module first before we use it on this forecast data.

iex> "hello pipe operator" |> String.upcase() |> String.reverse()
"ROTAREPO EPIP OLLEH"

We can think of the pipe operator as "and then". In the above example, we:

  • Have a string
  • and then we call String.upcase()
  • and then we call String.reverse()

Here's an illustrated version:

Pipe operator on strings
Figure 10.1: How data flows from one function to another

This code takes the string, and then passes it to String.upcase(). The result of that operation is "HELLO PIPE OPERATOR". Then, we take that uppercased string and pass it to String.reverse(), which will then turn it into "ROTAREPO EPIP OLLEH".

Without the pipe operator, we would have to write this code as:

iex> String.reverse(String.upcase("hello pipe operator"))

This code is harder to read and understand, because we have to read it from the inside out. With the pipe operator, we read the code as we read this sentence: from left to right. We can more easily understand the transformations our data goes through by using the pipe operator in Elixir.

Let's now use this pipe operator on our forecast data. Here's some code that uses the pipe operator ("and then") that is functionally identical to the code from the previous section, where we have a forecast in celsius and we convert it to fahrenheit:

%{
  "Monday" => 28,
  "Tuesday" => 29,
  "Wednesday" => 29,
  "Thursday" => 24,
  "Friday" => 16,
  "Saturday" => 16,
  "Sunday" => 20
}
|> Enum.map(fn ({day, temp}) -> {day, temp * 1.8 + 32} end)
|> Enum.into(%{})
|> IO.inspect

Go ahead and put this code in a file called forecast.exs and run it with elixir forecast.exs. This is what you'll see as the output:

%{
  "Friday" => 60.8,
  "Monday" => 82.4,
  "Saturday" => 60.8,
  "Sunday" => 68.0,
  "Thursday" => 75.2,
  "Tuesday" => 84.2,
  "Wednesday" => 84.2
}

I've put each key and value onto its own line, but you can see here that the output is the same as before in our iex console, and the code is neater to boot! "Amazing", utters Izzy. So what's happened here? Let's run through it:

  1. We define a map containing the days of the week and their expected temperatures. We don't assign this to a variable.
  2. And then we pipe this map using the pipe operator (|>) into Enum.map/2. This makes the map the first argument of Enum.map/2, so this removes the need to define a forecast variable and to pass it in as the first argument. The pipe operator gives the map to the function automatically. The only argument on Enum.map/2 here is just the function that we use to convert the temperature values. We still refer to this function as Enum.map/2, because it is taking in two arguments: the piped-in value and the function.
  3. And then we pipe the result of this Enum.map/2 call into Enum.into/2, and the result of Enum.map/2 becomes the first argument of Enum.into/2. The second argument to Enum.into/2 is still an empty map.
  4. Finally, we pipe the result of Enum.into/2 into a function we've not seen before called IO.inspect/1. This outputs the map in a human-friendly way. We're not using IO.puts/1 here because it only works with strings, and what we have at this point of the code after Enum.into/2 is a map, not a string. IO.inspect/1 will work with any form of data that we give it.

"Wow, that's a lot of text!", says Izzy, looking a little stunned. "Do you have an image that I could look at instead?". Sure I do!

a flow chart from a Map datum to Enum.map to Enum.into to IO.inspect
Figure 10.2: How data flows from one function to another

You can think of the pipe operator not only as an "and then" operator, but also like the stages of a waterfall -- or "datafall" -- with data flowing from one function to the next.

Each operation that we're doing in forecast.exs passes its result onto the next line, in a pipeline of operations. Elixir allows us to chain these operations together seamlessly with this pipe operator. Everything just flows from one line to the next until we reach the end. There is no need for storing data into temporary variables like new_forecast; you just simply pipe the output from one function to the next.

So that's the wonderful pipe operator! How handy!

We started this chapter by making some promises, though. We've already fulfilled one promise—we've talked about the Enum module's functions each/2 and map/2. But we also promised to talk about some functions from the Map module. Let's get into it. We might even find another excuse to use the pipe operator.

The marvellous Map module and its fantastic contraptions

Rightio, so we've spent a lot of the last few pages looking at two modules: the List and Enum module. It's time for a breath of fresh air -- time to break out of our Enum comfort zone that we've wiggled into and time to get uncomfortable again! Let's look at a brand-new-to-us module: the Map module.

If you want to do nearly anything with maps that doesn't involve enumerating over them, you would use this Map module. Let's look at a few common actions that you might take on a map.

Finding a key's value

In Chapter 4: Marvellous Maps we saw that we could fetch the value of a particular key by using square brackets.

iex> person = %{"gender" => "Female", "age" => "30ish", "name" => "Izzy"}
%{"age" => "30ish", "gender" => "Female", "name" => "Izzy"}
iex> person["name"]
"Izzy"

We compared it to a skilltester reaching in to pluck out the relevant item:

skilltester

The Map module provides us another way to get that value out: Map.get/2.

iex> Map.get(person, "name")

Now this might not seem very useful. After all, why not just use the old fashioned way: person["name"]? Well, one place where this might come in handy is if you want to find a particular key in the middle of a pipe chain. Imagine you have a variable called get_attendees that gives you data that looks like this:

%{
  "people" => ["Izzy", "The Author"],
  "robots" => ["Roboto", "TARS"]
}

Then from this data you wanted to get the first person from the list of people. You could write this as:


  attendees = get_attendees()
  List.first(attendees["people"])
  

But then this code is confusing. The order looks like:

  1. Get list of attendees
  2. Get the first from that list...
  3. That list of attendees["people"]

Both Elixir and our brains need to interpret what's inside the brackets of List.first/1 first, then interpret List.first/1. That requires us to jump around a little in the code because the code is not executed in the order that you would read it.

If we were to use the pipe operator (|>) and Map.get/2, we could really clean this code up and make it read a lot better. Here we go:

get_attendees() |> Map.get("people") |> List.first

That's much neater and it reads in the exact order of operations:

  1. Get the attendees
  2. Then, get the value that the "people" key points to
  3. Then, run List.first/1 on that value

So Map.get/2 has its uses when pipe chaining. However, if you just wanted to access a value in a map regularly, I'd still say stick with code like people["name"].

I want to show you three more functions from Map which I think are as important to know as Map.get/2 and those are Map.put/3, Map.merge/2 and Map.delete/2.

put

The Map.put/3 function takes three arguments: a map, a key and a value. It will then return an updated version of the original map. How that map gets updated depends on what the key was: if it was an existing key, only the value for that key is updated. If it's a new key, then both the key and value are added. Let's look at some examples to get a clearer understanding.

You might want to use this function if you've got a map containing data that you want to update. For instance, like the data that we have on Izzy so far:

iex> izzy = %{"name" => "Izzy", "age" => "30ish", "gender" => "Female"}

Recently, we've found out that Izzy is from Australia. That would explain the accent and the cork hat. We've also found out that Izzy is "40ish" rather than "30ish". This is surprising to us, considering her youthful visage. Based on this new information, we'll add a new key to our map called country and update the age key with the more correct value. We could just type it all out again:

iex> izzy = %{
  "name" => "Izzy",
  "age" => "40ish",
  "gender" => "Female",
  "country" => "Australia",
}

But that seems like an awful lot of typing just to update one key. Let's look at how we could update the age key and add in the country key by using Map.put/3:

iex> izzy = Map.put(izzy, "age", "40ish")
%{"age" => "40ish", "gender" => "Female", "name" => "Izzy"}

Ah that's so much better than writing the whole map from scratch! We can now update the "age" key without having to rewrite the whole map.

Now let's say that we wanted to update this age key and the country key at the same time. We can use Map.put/2 and the pipe operator to chain the two operations together:

izzy |> Map.put("age", "40ish") |> Map.put("country", "Australia")
%{"age" => "40ish", "country" => "Australia", "gender" => "Female", "name" => "Izzy"}

This time, we've updated the value under the age key, and we've also added a new key here called country. It's important to note here that the original izzy map isn't changed at all:

izzy
%{"age" => "30ish", "gender" => "Female", "name" => "Izzy"}

This is because of that immutability thing we talked about a few chapters ago. Data in Elixir never changes unless we re-assign the variable it is associated with. In the above Map.put/3 code, izzy stays the same, even though the functions run. If we wanted to update the izzy variable with new data, we could re-assign that variable:

izzy = izzy |> Map.put("age", "40ish") |> Map.put("country", "Australia")
%{"age" => "40ish", "country" => "Australia", "gender" => "Female", "name" => "Izzy"}

From that point onwards, our code would know that Izzy is 40ish and from Australia. Phew.

How we've used Map.put/3 here is another great example of the pipe operator in action, but we can write the code in a shorter fashion with the next Map function: merge/2.

merge

Let's change the data that we have on Izzy back to what it was at the start of this chapter:

izzy = %{"name" => "Izzy", "age" => "30ish", "gender" => "Female"}

This'll make it easier to show what Map.merge/2 does. Map.merge/2 takes two maps and merges them together. Let's take our last Map.put/3 example and rewrite it with Map.merge/2 to demonstrate this:

izzy = Map.merge(izzy, %{"age" => "40ish", "country" => "Australia"})
%{
  "age" => "40ish",
  "country" => "Australia",
  "gender" => "Female",
  "name" => "Izzy"
}

This results in the same map as our Map.put/3 example, except we only have to call one function once instead of twice. The Map.merge/2 function takes two maps as arguments, and then:

  • Takes the keys from the second map that are not present in the first map adds them to the first map
  • Overwrites keys in the first map that are present in the second map

In this example, it has replaced the "old" age value ("30ish") with the "new" one ("40ish"), and it has added a key called country to this map.

Pipe Merging

While we can use Map.merge/2 to combine two maps to update an existing map, there is also a shorter way, which we'll call pipe merging. The syntax goes like this:

iex> izzy = %{"name" => "Izzy", "age" => "30ish", "gender" => "Female"}
%{"age" => "30ish", "gender" => "Female", "name" => "Izzy"}
iex> %{izzy | "age" => "40ish", "name" => "Isadora"}
%{"age" => "40ish", "gender" => "Female", "name" => "Isadora"}

This syntax looks like a map, but it's split in two. On the left hand side, inside the map syntax, we have a variable which represents an existing map. On the right hand side, we have keys we would like to update in that map, along with their new values. As we can see, the map's "age" and "name" keys change, taking on their values from the right hand side.

However, it's important to note that if we try to add a new key to this map, it will not work with this syntax:

iex> izzy = %{"name" => "Izzy", "age" => "30ish", "gender" => "Female"}
%{"age" => "30ish", "gender" => "Female", "name" => "Izzy"}
iex> %{izzy | "country" => "Australia"}

** (KeyError) key "country" not found in: %{"age" => "30ish", "gender" => "Female", "name" => "Izzy"}
    (stdlib) :maps.update("country", "Australia", %{"age" => "30ish", "gender" => "Female", "name" => "Izzy"})

This pipe merging feature will only work with existing keys in the map, and this is an important difference to notice from Map.merge/2, which will work with both kinds of keys: new and pre-existing.

delete

We've now looked at how to add new keys or update keys in a map. The only thing left to look at is how to delete them. We can delete keys in a map by using the Map.delete/2 function. Let's say we didn't really care what country Izzy came from. We can delete that data, but keep everything else, by using Map.delete/2

izzy = Map.delete(izzy, "country")
%{"age" => "40ish", "gender" => "Female", "name" => "Izzy"}

This function takes a map as its first argument, and then a key as its second argument. When the function acts, it removes that key from the map.

Summary

In this chapter, we've seen a couple of ways of working with maps. We saw that we could go through each key-value pair in the map with Enum.each/2. We also used Enum.map/2 and Enum.into/2 to change the values inside the maps to other values, converting temperature numbers to fahrenheit ones.

When we looked at Enum.map/2 and Enum.into/2, we discovered that we could write the code in a shorter way with the pipe operator: |>. This operator allows us to pass the result of one function call to the next, allowing us to write cleaner Elixir code.

We rounded out the chapter by looking at some common functions from the Map module.

We saw Map.get/2 which allowed us to pick out a particular value based on a key's name. We knew we could already do this with code like person["name"], but, as we saw, Map.get/2 is useful if we're in the middle of a piping operation and need to pick out a value from a map.

We then looked at Map.put/3 which gave us the ability to create a new map with new keys from an existing map. This function took a map, a key and a value and then gave us back a new map with the changes we asked for.

If we were applying multiple changes with Map.put/3 at the same time, we learned that it wasn't smart to do that and so we then learned about Map.merge/2. We saw that this function takes two maps and merges them together, with the 2nd maps keys taking precedence over the first map's keys.

The last thing we saw in this chapter was how to get back a map without certain keys with Map.delete/2. It's a good way of removing data from a map if we don't need it anymore.

We're now moving out of the Enum, List and Map woods into something brand new to us: using Elixir to interact with files on our computer.

Exercises

  • Use your newfound knowledge of the pipe operator to re-write your solution to Chapter 8's first exercise.