Learn With Me: Elixir - Maps (#17)
Maps in Elixir store unordered key-value pairs, allowing them to serve as a dictionary. This is the equivalent to Dictionary<T>
in C# or an object in Javascript.
Maps offers good performance when reading and writing data. I've read that Elixir map implementations use a trie data structure, so the read-write performance is O(log(n)). This is worse than a tuple, but better than a list.
Unlike a list or a tuple, a map has no concept of ordering. The key-value pairs in a map can be iterated over, but there is no way to determine the order of those key-value pairs, since a map is an unordered data structure. The keys and values in a map can also be extracted separately as a list and iterated over separately.
Map Literals
Although anything can be used as a key in amap, maps often use atoms as keys. When you're using atoms as keys, you can use colon syntax when creating map literals.
iex> %{item1: 3, item2: "Bob"}
%{item1: 3, item2: "Bob"}
The above example creates a map with two key-value pairs. The key :item1
(an atom) is associated with the value 3
and the key :item2
is associated with the value "Bob"
.
If the keys are not all atoms, you will have to use arrow syntax.
iex> %{"item1" => 3.14, "item2" => "Dib"}
%{"item1" => 3.14, "item2" => "Dib"}
iex> %{{3, 4} => :ok, [1, 2, 3] => "Dib"}
%{{3, 4} => :ok, [1, 2, 3] => "Dib"}
With arrow syntax, anything can be used as a key: integers, floats, strings, atoms, tuples, lists, etc.
You can even have functions be keys in a map.
iex> %{&Enum.map/2 => "A map function", &Kernel.tuple_size/1 => "Returns the size of at tuple"}
%{
&Enum.map/2 => "A map function",
&:erlang.tuple_size/1 => "Returns the size of at tuple"
}
If you create a map with only atom keys, IEx will display it using colon syntax. If we add a non-atom key to the map, however, it will be displayed using arrow syntax.
iex> %{:item1 => :ok, :item2 => "Bob"}
%{item1: :ok, item2: "Bob"}
iex> %{:item1 => :ok, :item2 => "Bob", "string key" => 3}
%{:item1 => :ok, :item2 => "Bob", "string key" => 3}
I think Elixir has a special syntax for atom keys because they are used as keys much more than any other data type.
Atoms are used for keys defined as literals in code unless there's a particular reason to use another data type. Using atoms as keys is the standard convention for specific named properties of a map. However, since atoms are allocated when the program starts up and never deallocated, string keys are better under some conditions: when you have a lot (thousands) of keys, are using keys created at runtime, or are creating a map using data imported from an external source.
I tried combining arrow and colon syntax to see if it could be done. I found that it works only if the items with arrow syntax are placed at the beginning and items with colon syntax are placed afterward. So %{"first" => 4, 5.0 => 5, third: 3}
is valid, but %{"first" => 4, second: 2, 5.0 => 5, third: 3}
is not valid, since I use colon syntax before an item with arrow syntax. I have no idea why it behaves this way.
iex> %{"first" => 4, 5.0 => 5, third: 3}
%{5.0 => 5, :third => 3, "first" => 4}
iex> %{"first" => 4, second: 2, 5.0 => 5, third: 3}
** (SyntaxError) iex:8: syntax error before: "
Accessing and Manipulating Maps
Values in a map can be accessed using the brackets notation: map["key"]
. With this notation, you get a nil if the key is not present in the map.
iex> map = %{name: "Thermoclastic Regulator", status: :running}
%{name: "Thermoclastic Regulator", status: :running}
iex> map[:name]
"Thermoclastic Regulator"
iex> map[:status]
:running
iex> map[:usage]
nil
If keys are atoms, values in a map can be accessed using a dot operator: map.key
. With this notation, Elixir raises a KeyError if the key is not present in the map.
iex> map = %{name: "Thermoclastic Regulator", status: :running}
%{name: "Thermoclastic Regulator", status: :running}
iex> map.name
"Thermoclastic Regulator"
iex> map.status
:running
iex> map.usage
** (KeyError) key :usage not found in: %{name: "Thermoclastic Regulator", status: :running}
I've noticed that I haven't seen any examples so far of how code handles or recovers from such errors, I have no idea what a typical Elixir application does when an error is thrown. I imagine I'll run into that eventually, probably in the more advanced topics that I haven't reached yet.
Maps can be manipulated using functions in the Map module in the Elixir standard library. Here are a few examples.
Map.keys/1
will return a list of keys in a map.
iex> map = %{name: "Thermoclastic Regulator", status: :running}
%{name: "Thermoclastic Regulator", status: :running}
iex> Map.keys(map)
[:name, :status]
Map.values/1
will return a list of values in a map.
iex> map = %{name: "Thermoclastic Regulator", status: :running}
%{name: "Thermoclastic Regulator", status: :running}
iex> Map.values(map)
["Thermoclastic Regulator", :running]
Map.has_key?/2
will tell is if a key exists in a map.
iex> map = %{name: "Thermoclastic Regulator", status: :running}
%{name: "Thermoclastic Regulator", status: :running}
iex> Map.has_key?(map, :name)
true
iex> Map.has_key?(map, :status)
true
iex> Map.has_key?(map, :usage)
false
Map.drop?/2
will remove one or more key-value pairs from a map.
iex> map = %{name: "Thermoclastic Regulator", status: :running, usage: 23, maintained: true}
%{
maintained: true,
name: "Thermoclastic Regulator",
status: :running,
usage: 23
}
iex> Map.drop(map, [:status, :usage])
%{maintained: true, name: "Thermoclastic Regulator"}
Map.put/3
will update a value in a map.
iex> map = %{name: "Thermoclastic Regulator", status: :running}
%{name: "Thermoclastic Regulator", status: :running}
iex> Map.put(map, :status, :down_for_maintenance)
%{name: "Thermoclastic Regulator", status: :down_for_maintenance}
There is also some specialized syntax using the pipe character "|" to update a map with one or more key-value pairs.
iex> original_map = %{name: "Squib", age: 14, status: "Boring"}
%{age: 14, name: "Squib", status: "Boring"}
iex> modified_map = %{original_map | age: 20}
%{age: 20, name: "Squib", status: "Boring"}
The original map will be copied and updated with the keys and values on the right side of the pipe character "|". Any number of keys and values can be updated.
Let's look at example of a map that represents a 2x2 terrain map. We can update multiple map locations with a single statement.
iex> terrain_map = %{{0,0} => :forest, {0, 1} => :grassland, {1, 0} => :desert, {1, 1} => :hills}
%{{0, 0} => :forest, {0, 1} => :grassland, {1, 0} => :desert, {1, 1} => :hills}
iex> modified_map = %{terrain_map | {1, 1} => :mountains, {0, 1} => :forest}
%{{0, 0} => :forest, {0, 1} => :forest, {1, 0} => :desert, {1, 1} => :mountains}
Notice that the data in the above example would often be stored as a 2-dimensional array in other languages. There aren't any arrays in Elixir, so we can store the terrain map data in a map. We can put the map coordinates in a tuple and use it as a key, and the value will be the data associated with those map coordinates. This is better suited to the problem than a list if you want to be able to randomly access a particular map location. It's also better suited to the problem than a tuple because any modifications to a tuple will require the entire thing to be copied. With maps, the unmodified key-value pairs will be shared between the different versions of the map as it is updated in subsequent update operations.
This pipe syntax cannot add new keys to a map: it can only update the values of existing keys. If you want to add a key, you can use Map.put/3
, which can update one key at a time, or Map.merge/2
, which can update a map with another map of one or more key-value pairs, allowing you to add or update multiple key-value pairs at a time.
iex> original_map = %{name: "Bob", age: 66}
%{age: 66, name: "Bob"}
iex> modified_map = %{original_map | location: "home"}
** (KeyError) key :location not found in: %{age: 66, name: "Bob"}
(stdlib) :maps.update(:location, "home", %{age: 66, name: "Bob"})
(stdlib) erl_eval.erl:255: anonymous fn/2 in :erl_eval.expr/5
(stdlib) lists.erl:1263: :lists.foldl/3
iex> modified_map = Map.put(original_map, :location, "home")
%{age: 66, location: "home", name: "Bob"}
iex> modified_map = Map.merge(original_map, %{location: "home", name: "Professor Spork"})
%{age: 66, location: "home", name: "Professor Spork"}
I very much like the merge syntax.
It seems to me that a map is the most array-like when you need fast read and write access, although it is O(log(n) instead of O(1). That's still pretty good, especially for large amounts of data, but it is not constant. Like an array, a map can also be iterated over, although unlike an array, that data has no order. When you want to optimize for sequential access and iteration, use a list instead.
If you want to optimize for random read access using indexes (and rarely or never do updates), then tuples would be more suitable. A map offers cheaper modification than a tuple, since most of the original map structure can be reused in the modified map structure that is created. Tuples need to be recreated entirely every time they are modified.
A map is also very much like an object in Python and Javascript, in that it essentially a key-value table. If fact, maps can be used to create data structures in much the same way that Python and Javascript create data structures, which we will see when we talk about structs.