Learn With Me: Elixir - The Map Module Part 1 (#47)

It's once again time to get to know an Elixir module, and this time it's the Map module. There are enough functions in the Map module that I'm going to split the coverage into two parts.

Maps in Elixir aren't implemented as plain hash tables like dictionaries typically are in imperative languages. Instead, they are implemented as hash tries, also known as hash trees, which make any lookup by key have a O(log(n)) performance instead of O(1) performance that hash tables have. The reason for doing it this way is that this data structure is much more memory-efficient than a plain hash table in functional languages with immutable data structures. Most of the data in a hash trie data structure can be easily shared by multiple data structures, minimizing copying when a map is modified. Since all data is immutable, it makes sense to share the same data among multiple data structures for the pieces that are the same in those data structures.

O(log(n)) performance, being logarithmic, is still pretty good, and it is only slightly worse than O(1) even when the amount of data is huge. Take a look at the Big O cheatsheet to get a good visualization of the relative differences.

The point of all this is that any functions in this module that look up values in a map using a key are going to have O(log(n)) performance.

Since structs are so similar to maps, many of the Map functions can also be used on structs.

Map.delete/2

The Map.delete/2 function deletes a key-value pair that matches the given key. If the key doesn't exist, the original map is returned.

iex> Map.delete(%{name: "Vincenzo", age: 10}, :name)
%{age: 10}
iex> Map.delete(%{name: "Vincenzo", age: 10}, :birthday)
%{age: 10, name: "Vincenzo"}

Map.drop/2

The Map.drop/2 function deletes the given keys from a map, where the keys to be deleted are in a list. It's just like Map.delete/2, except that it can delete multiple keys at once.

iex> Map.drop(%{name: "Vincenzo", age: 10}, [:name, :age])
%{}
iex> Map.drop(%{name: "Vincenzo", age: 10}, [:name])
%{age: 10}
iex> Map.drop(%{name: "Vincenzo", age: 10}, [:name, :birthday])
%{age: 10}
iex> Map.drop(%{name: "Vincenzo", age: 10}, [])
%{age: 10, name: "Vincenzo"}

Map.equal?/2

The Map.equal?/2 function compares two maps to see if they are equal. Maps are equal when they contain the exact same key-value pairs (same key, same value). Although the documentation doesn't say, this is likely going to be a O(n) operation, since it has to compare all the keys and all the values in both maps.

iex> Map.equal?(%{name: "Mao", age: 54}, %{name: "Mao", age: 54})
true
iex> Map.equal?(%{name: "Mao", age: 54}, %{name: "Ike", age: 54})
false

Map.fetch!/2

The Map.fetch!/2 function retrieves the value associated with the given key. The "!" in the function name is an Elixir convention that indicates that this function can throw an error, and indeed that's what it does: if the key is not present in the map, the function throws a KeyError exception.

Use this function if you want to retrieve the value for a key that should be there. If that expected value is not there, an error of some kind has occurred, and you want the exception to be thrown and the current code to be aborted.

iex> person = %{name: "Mao", age: 54}
%{age: 54, name: "Mao"}
iex> Map.fetch!(person, :name)
"Mao"
iex> Map.fetch!(person, :birthdate)
** (KeyError) key :birthdate not found in: %{age: 54, name: "Mao"}
    (stdlib) :maps.get(:birthdate, %{age: 54, name: "Mao"})

Map.fetch/2

The Map.fetch/2 function works like Map.fetch!/2, except that it does not throw an exception. It returns a tuple with :ok and the value if the value was found, otherwise it returns :error.

Use this function if the key may not be there under normal circumstances and you want to take different actions depending on whether the key is there or not.

iex> Map.fetch(person, :name)
{:ok, "Mao"}
iex> Map.fetch(person, :birthdate)
:error

Map.from_struct/1

The Map.from_struct/1 function converts a struct to a generic map. There is actually very little difference between an instance of a struct and a generic map. The only difference is that an instance of a struct has a __struct__ property that identifies the type of struct it is. Map.from_struct/1 strips away this metadata, leaving the struct an ordinary map instance.

In the following example, I define a struct and then convert it to a map, stripping away the __struct__ metadata.

iex> defmodule Person do
...> defstruct name: "", age: 0
...> end
{:module, Person,
 <<70, 79, 82, 49, 0, 0, 5, 156, 66, 69, 65, 77, 65, 116, 85, 56, 0, 0, 0, 183,
   0, 0, 0, 18, 13, 69, 108, 105, 120, 105, 114, 46, 80, 101, 114, 115, 111,
   110, 8, 95, 95, 105, 110, 102, 111, 95, 95, ...>>, %Person{age: 0, name: ""}}
iex> person = %Person{name: "Mao", age: 54}
%Person{age: 54, name: "Mao"}
iex> person.__struct__
Person
iex> Map.from_struct(person)
%{age: 54, name: "Mao"}
iex> person_map = Map.from_struct(person)
%{age: 54, name: "Mao"}
iex> person_map.__struct__
** (KeyError) key :__struct__ not found in: %{age: 54, name: "Mao"}

Map.get/3

The Map.get/3 function retrieves the value associated with a key. This is a different flavor of key-value retrieval, similar to Map.fetch/2 and Map.fetch!/2, but with some differences. If the key is not present in the map, it returns a single value, which by default is nil. The third parameter is an optional parameter, defaulting to nil, which allows you to specify what the function returns if the key is not found.

iex> person = %{name: "Mao", age: 54}
%{age: 54, name: "Mao"}
iex> Map.get(person, :name)
"Mao"
iex> Map.get(person, :age)
54
iex> Map.get(person, :birthdate)
nil
iex> Map.get(person, :birthdate, :not_found)
:not_found

Map.get_lazy/3

The Map.get_lazy/3 function is similar to the Map.get/3 function except that you pass it a function that generates the default value that is returned when a key is not found. The name of this function contains "lazy" because the default value is not retrieved until it's actually needed, which is only when a key is not found. Unlike the third parameter in Map.get/3, the third parameter in Map.get_lazy/3 is required.

The documentation advises using this function when the default value is difficult to acquire or is expensive to compute. This way, the default value is only created when needed, and the system doesn't have to do a lot of work when it isn't necessary.

In the following example, I output some text to the screen when the default value is being calculated, so you can see when it is called. It doesn't actually get called until I attempt to get a value for a non-existent key.

iex> get_default_value = fn ->
...> IO.puts "Calculating the default value"
...> :not_found
...> end
#Function<20.99386804/0 in :erl_eval.expr/5>
iex> person = %{name: "Mao", age: 54}
%{age: 54, name: "Mao"}
iex> Map.get_lazy(person, :name, get_default_value)
"Mao"
iex> Map.get_lazy(person, :age, get_default_value)
54
iex> Map.get_lazy(person, :birthdate, get_default_value)
Calculating the default value
:not_found

Map.has_key?/2

The Map.has_key?/2 function indicates whether a map has a key or not. It's that simple: true if the key exists, otherwise false.

iex> person = %{name: "Mao", age: 54}
%{age: 54, name: "Mao"}
iex> Map.has_key?(person, :name)
true
iex> Map.has_key?(person, :age)
true
iex> Map.has_key?(person, :birthdate)
false

Map.keys/1

The Map.keys/1 function returns all the keys in the map in the form of a list. This particular operation is almost certainly a O(n) operation, since it has to access all the key-value pairs in order to get the keys.

iex> person = %{name: "Mao", age: 54}
%{age: 54, name: "Mao"}
iex> Map.keys(person)
[:age, :name]
iex> Map.keys(%{})
[]

Map.merge/2

The Map.merge/2 function merges two maps into a single map. All keys from both maps will be included in the final map. If both maps have any keys that are present in both maps, the value of the second map will override the value from the first map. This is particularly useful if you have a map that contains default values and you can merge it with a map that selectively overrides those values (and provides new key-value pairs).

The documentation warns us not to use this function in merging structs. It can cause new keys to be added that aren't supposed to be part of the struct, which will have unexpected consequences. I'm not sure exactly what would happen, but I imagine that some confusion and undesirable behavior could occur if unexpected keys suddenly showed up in a struct, which is only supposed to have its predefined keys.

iex> person = %{name: "Mao", age: 54}
%{age: 54, name: "Mao"}
iex> Map.merge(person, %{name: "Bob", status: :sleeping})
%{age: 54, name: "Bob", status: :sleeping}
iex> Map.merge(person, %{})
%{age: 54, name: "Mao"}

Map.merge/3

The Map.merge/3 function is similar to Map.merge/2 except you pass a function that resolves the situation where both maps have the same key. In Map.merge/2, the key-value pair from the second map is automatically used to resolve the situation, but in Map.merge/3 you can provide your own logic. In fact, I think that it's highly likely that Map.merge/2 calls Map.merge/3 with its own resolution functions.

The key conflict resolution function receives the key and both values as parameters and returns the value that is to be used in the merged map. You don't have to settle with just picking one of the values: you can do something crazy like combining the values, picking a random value, or returning something else totally unrelated.

Here's an example of resolving the duplicate key situation by picking the first value.

iex> resolve_conflict = fn (_key, value1, _value2) -> value1 end
#Function<18.99386804/3 in :erl_eval.expr/5>
iex> person = %{name: "Mao", age: 54}
%{age: 54, name: "Mao"}
iex> Map.merge(person, %{name: "Fuzzy", status: :eating}, resolve_conflict)
%{age: 54, name: "Mao", status: :eating}

Here's an example of resolving the duplicate key situation by randomly picking a value.

iex> resolve_conflict = fn (_key, value1, value2) ->
...> case Enum.random(1..2) do
...> 1 -> value1
...> 2 -> value2
...> end
...> end
#Function<18.99386804/3 in :erl_eval.expr/5>
iex> person = %{name: "Mao", age: 54}
%{age: 54, name: "Mao"}
iex> Map.merge(person, %{name: "Fuzzy", status: :eating}, resolve_conflict)
%{age: 54, name: "Mao", status: :eating}
iex> Map.merge(person, %{name: "Fuzzy", status: :eating}, resolve_conflict)
%{age: 54, name: "Mao", status: :eating}
iex> Map.merge(person, %{name: "Fuzzy", status: :eating}, resolve_conflict)
%{age: 54, name: "Fuzzy", status: :eating}
iex> Map.merge(person, %{name: "Fuzzy", status: :eating}, resolve_conflict)
%{age: 54, name: "Mao", status: :eating}
iex> Map.merge(person, %{name: "Fuzzy", status: :eating}, resolve_conflict)
%{age: 54, name: "Fuzzy", status: :eating}

Here's an example of resolving the duplicate key situation by combining both values in a tuple.

iex> resolve_conflict = fn (_key, value1, value2) -> {value1, value2} end
#Function<18.99386804/3 in :erl_eval.expr/5>
iex> person = %{name: "Mao", age: 54}
%{age: 54, name: "Mao"}
iex> Map.merge(person, %{name: "Fuzzy", status: :eating}, resolve_conflict)
%{age: 54, name: {"Mao", "Fuzzy"}, status: :eating}

Here's an example of resolving the duplicate key situation by returning an atom instead indicating that there was a conflict.

iex> resolve_conflict = fn (_, _, _) -> :merge_conflict end
#Function<18.99386804/3 in :erl_eval.expr/5>
iex> Map.merge(person, %{name: "Fuzzy", status: :eating}, resolve_conflict)
%{age: 54, name: :merge_conflict, status: :eating}

Since I also have access to the key, I could even merge in a different manner depending on what the key is, but I'll stop here. I'm having too much fun and I need to get to the next function before I bore you, which hopefully hasn't already happened.

Map.new/0

It's not often that I encounter a function in the Elixir standard library that has an arity of 0, but Map.new/0 is such a function. All it does is create and return an empty map. You could do the same thing using an empty map literal %{}, but if you need a function that does so (to pass into another higher-order function, for example), this is it.

iex> map = %{}
%{}
iex> map = Map.new()
%{}

Map.new/1

The Map.new/1 function creates a map from the contents of an enumerable. This will only work if the enumerable contains tuples of size 2, where the first element is the key and the second element is a value. This also describes a keyword list, so you can create a map from a keyword list with this function.

If a key is present in the enumerable more than once, the latest key-value pair overwrites the earlier key-value pairs.

#Create a map with a list of key-value tuples
iex> key_values = [{:name, "Jean-Pierre-Louis"}, {:age, 3}, {:status, :sleeping}]
[name: "Jean-Pierre-Louis", age: 3, status: :sleeping]
iex> Map.new(key_values)
%{age: 3, name: "Jean-Pierre-Louis", status: :sleeping}
#Create a map using a list of tuples with duplicate keys. The value from the last tuple wins.
iex> key_values = [{:name, "Jean-Pierre-Louis"}, {:age, 3}, {:age, 16}, {:status, :sleeping}, {:age, 8}]
[name: "Jean-Pierre-Louis", age: 3, age: 16, status: :sleeping, age: 8]
iex> Map.new(key_values)
%{age: 8, name: "Jean-Pierre-Louis", status: :sleeping}
#Attempt to create a map using an enumerable that doesn't contain tuples
iex> Map.new([1, 2, 3, 4, 5])
** (ArgumentError) argument error
    (stdlib) :maps.from_list([1, 2, 3, 4, 5])
    (elixir) lib/map.ex:174: Map.new/1
#Attempt to create a map using a list of tuples that aren't all of size 2
iex> key_values = [{:name, "Jean-Pierre-Louis", "XVII"}, {:age, 3}, {:age, 16}, {:status, :sleeping}, {:age, 8}]
[
  {:name, "Jean-Pierre-Louis", "XVII"},
  {:age, 3},
  {:age, 16},
  {:status, :sleeping},
  {:age, 8}
]
iex> Map.new(key_values)
** (ArgumentError) argument error
    (stdlib) :maps.from_list([{:name, "Jean-Pierre-Louis", "XVII"}, {:age, 3}, {:age, 16}, {:status, :sleeping}, {:age, 8}])
    (elixir) lib/map.ex:174: Map.new/1
#Create a map using string keys instead of atom keys
iex> key_values = [{"name", "Jean-Pierre-Louis"}, {"age", 3}]
[{"name", "Jean-Pierre-Louis"}, {"age", 3}]
iex> Map.new(key_values)
%{"age" => 3, "name" => "Jean-Pierre-Louis"}	

Map.new/2

If after reading about Map.new/1, you're sad because you can't create a map using a range or some other non-tuple enumerable, then Map.new/2 may cheer you up. It creates a map using an enumerable, but it can be any enumerable. The second parameter is a transformation function that transforms each element in the enumerable into a key-value tuple that can be used in creating the map. So you can specify how your arbitrary enumerable is to be transformed into a map. Now all the enumerables can join in on the fun.

The transformation function receives an enumerable element as a parameter and returns a key-value tuple, where the first element is the key and the second element is the value.

In the following example, I'm going to transform an enumerable containing numbers into a map, where the key is the number and the value is the number after it has been squared.

iex> Map.new(1..10, fn number -> {number, number * number} end)
%{
  1 => 1,
  2 => 4,
  3 => 9,
  4 => 16,
  5 => 25,
  6 => 36,
  7 => 49,
  8 => 64,
  9 => 81,
  10 => 100
}

It's that simple.

That's it for today. We'll cover the rest of the Map functions later on in Part 2.