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 — farenheit? 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 farenheit 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
farenheit
(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:
- We define a variable called
forecast
, which is a map containing the days of the week and their expected temperatures. - We then take this
forecast
variable, and pass it as the first argument toEnum.map/2
. The second argument passed toEnum.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 farenheit. - We assign the result of this
Enum.map/2
call to a variable callednew_forecast
, and then immediately pass that intoEnum.into/2
as the first argument. The second argument given toEnum.into/2
is an empty map. This converts the output ofEnum.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:
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 farenheit:
%{
"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:
- We define a map containing the days of the week and their expected temperatures. We don't assign this to a variable.
-
And then we pipe this map using the pipe
operator (
|>
) intoEnum.map/2
. This makes the map the first argument ofEnum.map/2
, so this removes the need to define aforecast
variable and to pass it in as the first argument. The pipe operator gives the map to the function automatically. The only argument onEnum.map/2
here is just the function that we use to convert the temperature values. We still refer to this function asEnum.map/2
, because it is taking in two arguments: the piped-in value and the function. -
And then we pipe the result of this
Enum.map/2
call intoEnum.into/2
, and the result ofEnum.map/2
becomes the first argument ofEnum.into/2
. The second argument toEnum.into/2
is still an empty map. -
Finally, we pipe the result of
Enum.into/2
into a function we've not seen before calledIO.inspect/1
. This outputs the map in a human-friendly way. We're not usingIO.puts/1
here because it only works with strings, and what we have at this point of the code afterEnum.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!
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" => "Izzy", "name" => "Izzy"}
iex> person["name"]
"Izzy"
We compared it to a skilltester reaching in to pluck out the relevant item:
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:
- Get list of attendees
- Get the first from that list...
- 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:
- Get the attendees
- Then, get the value that the
"people"
key points to - 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 farenheit 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.