Elixir from the ground up

Maps: records with names

A map holds values under names, which makes it the record of an Elixir program. Maps are values, so changing one gives back a new map and leaves the old one alone.

Part 9 of 12

Advanced19 min readBack to Courses

Values under names

A map is written with braces and pairs: %{name: "Ada", age: 36, city: "London"}. The key is the atom before the colon, and the value is what follows. Two ways ask for something back: user.name when you are sure the key is there, and user[:name] which answers nil when it is not. Map.get(user, :key, "fallback") is the third, and the one to use when a missing key is normal. A map is a value like any other, so it can be a function argument, an element of a list, or the answer of a function; nothing about it is tied to a name.

Any value can be a key — strings, numbers, even tuples — but atoms are the convention for records, because writing user.name is shorter and reads like a field. The set of key names is the shape of the record, and it is the part you will match on when you take records apart.

Change makes a new map

There is no way to reach into a map and alter it. Map.put(user, :age, 37) gives back a new map with that one pair changed, and the map you passed in is exactly as it was. A name bound before the call still holds the old value afterwards, which is why a program can be followed line by line — nothing behind your back is changing.

For the common case of replacing a key that is already there, Elixir has a shorter update form: %{user | age: 37}. It insists that the key exists and raises if it does not, which is exactly right for a record: a typo in a field name becomes an error, not a second field. Map.update(user, :visits, 1, fn n -> n + 1 end) is the version that copes with a key that might be missing — the third argument is the value to start with when it is absent.

Counting with a map

Maps are how you count things. Walk a list with Enum.reduce, carrying an empty map, and use Map.update to add one to the count for each item: a key that is missing appears with a count of 1, and one that is there goes up by one. That five-line pattern is so common that the language ships it as a single call, Enum.group_by, which returns a map from the computed key to the list of items that produced it. The map that comes out is a value like any other, so it can be sorted, printed, or handed to another function without conversion.

One thing to leave at the door: a map has no order to rely on when you print it. When a report needs to read in a particular sequence, sort it — Enum.sort_by(groups, fn {town, _orders} -> town end) — rather than hoping the map will come out your way.

A record, changed and counted

book = %{title: "Dune", year: 1965, pages: 412}

IO.puts(book.title)
IO.inspect(book[:year])
IO.inspect(Map.get(book, :pages))
IO.inspect(Map.get(book, :author, "unknown"))

reprint = Map.put(book, :year, 1966)
IO.inspect(reprint.year)
IO.inspect(book.year)

IO.inspect(Map.update(reprint, :year, 1, fn y -> y + 1 end))
IO.inspect(Map.has_key?(book, :title))
IO.inspect(Enum.sort(Map.keys(book)))

counts =
  Enum.reduce(["x", "y", "x"], %{}, fn letter, acc ->
    Map.update(acc, letter, 1, fn n -> n + 1 end)
  end)

IO.inspect(counts)
IO.inspect(counts["x"])

A person, changed without being touched

Run it and check that the original map still says 36 after the copy says 37. Then add a field of your own, raise the count of one letter, and take two keys out with Map.take.

Output
Click "Run" to execute your code

Choosing between the two ways to read a key is choosing what happens when it is missing. The dot raises, so the mistake shows up where you made it. Map.get with a fallback keeps going, which is right for data from outside the program, where an absent field is normal.

Keys are compared exactly

A map compares keys the way === does, so 1 and 1.0 are different keys even though == calls them equal. Strings and atoms are different too: %{"name" => "Ada"} does not answer to map.name, because that dot asks for the atom :name. Pick one kind of key for a record and stay with it. The dot form is shorthand for an atom key, so map.name and map[:name] ask for the same key, while map["name"] asks for a different one.

Small maps and large maps are stored differently, and the runtime may switch representation as a map grows. The language hides that, but it is why the printed order is not part of the value. When order matters, convert the pairs with Map.to_list and sort them.

map.missing raises KeyError; use Map.get when a missing key is normal. %{map | key: value} only replaces an existing key and raises otherwise, while Map.put adds it. And a string key is not an atom key: map[:name] will not find %{"name" => "Ada"}.

Try these next

  • Build a map with three atom keys, read one with the dot and one with Map.get.
  • Change one key with Map.put and check that the original still holds the old value.
  • Count the letters in a word with Enum.reduce and Map.update.

A small inventory

Run it and check the stock before and after the changes. Then add a fourth item, set one count to zero with `Map.put`, and print the items that are out of stock.

Output
Click "Run" to execute your code

Check what you learned

One question at a time. A wrong answer explains itself — take another go.

Part 1 of 5

What does Map.put(user, :age, 37) leave in the variable user?