Our team at oVice has been putting a lot of effort into learning Elixir. When learning a new programming language, one must come to an understanding of how that language utilizes data structures. We feel comfortable with how to use the tools Elixir has provided. However, we felt the need to go deeper and understand the why as well. For instance, why use user[:name] instead of user.name? Let’s dig in!
Maps
“Maps are the ‘go to’ key-value data structure in Elixir.”
If you’ve been using Elixir, I’m sure you’ve utilized Maps in one way or the other. While their implementations internally are different, they act much like Ruby’s hash or Python’s dictionary data structures. We’ll showcase some simple functions that Elixir has provided for us. Additionally, when using these tools, it’s important to consider Elixir’s treatment of a map when a key exists vs when it doesn’t.
Adding Keys
Here are two easy ways of adding a key to a map.
Map.put(map, key, value) — Puts the given value under key in map.
iex> user = %{name: “Alice”}
%{name: “Alice”}iex> Map.put(user, :age, 27)
%{age: 27, name: “Alice”}
Map.put_new(map, key, value) — Puts the given value under key unless the entry key already exists in map.
iex> user = %{name: “Alice”}
%{name: “Alice”}iex> user = Map.put_new(user, :age, 27)
%{age: 27, name: “Alice”}iex> user = Map.put_new(user, :age, 28)
%{age: 27, name: “Alice”}
user’s :age remains 27 as that key already exists
However, notice the difference between the two funcitons below:
iex> user = Map.put_new(user, :age, 27)
%{age: 27, name: “Alice”}iex> user = Map.put(user, :age, 28)
%{age: 28, name: “Alice”}
Map.put provides a map with the new age while Map.put_new does not!
Updating Keys
Updating a map is also very easy!
Map.update(map, key, default, fun) — If the key is not present in a map, default is inserted as the value of key. The default value will not be passed through the update function. This allows for updates of maps without the necessary key.
iex> user = %{name: “Alice”, age: 27}
%{age: 27, name: “Alice”}iex> Map.update(user, :age, 5, fn current_age -> current_age + 1 end)
%{age: 28, name: “Alice”}
Because the age key exists for Alice, it’s passed to the given function and updates the key’s value.
iex> user = %{name: “John”}
%{name: “John”}iex> Map.update(user, :age, 5, fn current_age -> current_age + 1 end)
%{age: 5, name: “John”}
As shown, because the age key doesn’t exist on John, the default value was not passed to the given function and John’s age becomes the default value.
Map.update!(map, key, fun) — If the key is not present in a map, a KeyError exception is raised. This is more useful when you want to ensure a key exists before updating.
iex> user = %{age: 27, name: “Alice”}
%{age: 27, name: “Alice”}iex> Map.update!(user, :age, fn current_age -> current_age + 1 end)
%{age: 28, name: “Alice”}
However, if the key doesn’t exist:
iex> user = %{name: “Alice”}
%{name: “Alice”}iex> Map.update!(user, :age, fn current_age -> current_age + 1 end)
** (KeyError) key :age not found in: %{name: “Alice”}
And a final syntactic sugar function:
%{map | key_to_update: value}
iex> user = %{age: 27, name: “Alice”}
%{age: 27, name: “Alice”}iex> user = %{user | age: 28}
%{age: 28, name: “Alice”}
Note, that this function is for updating only and not adding a key. Trying to add a key will result in an error:
iex> user = %{user | occupation: “City Worker”}
** (KeyError) key :occupation not found in: %{age: 27, name: “Alice”}
And it does not play well with the pipe operator (|>
)
iex> user |> %{name: “Alice”} ** (ArgumentError) cannot pipe user into %{name: “Alice”}, can only pipe into local calls foo(), remote calls Foo.bar() or anonymous function calls foo.()
Mixing and Matching Keys
Another interesting aspect of Elixir is the atom type. This basic type is different from a string, but both can be used as keys within a map. In fact, anything can be used as a key for a map. However, the syntax is different if atoms are used.
The :
or =>
syntax can be used for atoms
iex> user = %{age: 27, name: “Alice”}
%{age: 27, name: “Alice”}
or
iex> user = %{:age => 27, :name => “Alice”}
%{age: 27, name: “Alice”}
However, for everything else, only =>
can be used
Strings
iex> %{“coolkey” => “String!”}
%{“coolkey” => “String!”}
Tuples
iex> %{{:ok, :success} => “Tuple!”}
%{{:ok, :success} => “Tuple!”}
Functions
iex>%{&is_atom/1 => “Atom Function”}
%{&:erlang.is_atom/1 => “Atom Function”}
and if mixing and matching types, usage of syntax must be specific
iex> user = %{:name => Alice, “age”=> 27}
%{:name => Alice, “age” => 27}
Using :
and =>
together, :
must come last in the map definition
iex> user = %{name: Alice, “age”=> 27}
** (SyntaxError) iex:50:20: unexpected expression after keyword list. Keyword lists must always come last in lists and maps.iex> user = %{“age” => 27, name: “Alice”}
%{:name => “Alice”, “age” => 27}
Strict Vs. Dynamic Access
Elixir provides two syntaxes for accessing values. These are referred to as “Dynamic” and “Static” lookups. For Maps, typically, the key-values are not set in stone and can change. Due to this attribute, Maps are considered “dynamic structures.” As such, both “Dynamic” and “Static” lookups are provided for Maps up front.
Say we have the following map
iex> user = %{name: “Alice”, age: 27}
%{name: “Alice”, age: 27}
We can access it using Strict Lookup
iex> user.name
“Alice”
or Dynamic Lookup
iex> user[:name]
“Alice”
Both of these methods have pros and cons. Strict lookup will raise an error if that key does not exist, while Dynamic lookup will return nil.
Strict Lookup
iex> user.occupation
** (KeyError) key :occupation not found in: %{:name => Alice, “age” => 27}
Dynamic Lookup
iex> user[:occupation]
Nil
José, from his blog post at https://dashbit.co/blog/writing-assertive-code-with-elixir, recommends to use strict when possible, but their usage is up to you! Say you want to fail fast to catch errors early in development and make debugging easier? Or, maybe you’re dealing with a database model that has its fields set and won’t change? Strict access might be better suited for your situation! However, say you just want to quickly iterate over a map where the key-values are unknown? What if you want to verify a key exists within a map without returning an error? Dynamic access may be just what you need. This discussion of access segues into another implementation of a Map.
Structs
“Structs are extensions built on top of maps that provide compile-time checks and default values.” Or, more importantly: “structs are bare maps with a fixed set of fields.”
Strict Vs. Dynamic Access Continued
As was mentioned, Structs are bare maps. What does this mean? To quote the documentation: “Notice that we referred to structs as bare maps because none of the protocols implemented for maps are available for structs.” Therefore, you cannot enumerate or access a struct dynamically. To begin, you have to define a module with a corresponding struct
iex> defmodule User do
…> defstruct name: “Alice”, age: 27
…> end
{:module, User, <<70, 79, 82, 49, 0, 0, 6, 200, 66, 69, 65, 77, 65, 116, 85, 56, 0, 0, 0, 193, 0, 0, 0, 19, 11, 69, 108, 105, 120, 105, 114, 46, 85, 115, 101, 114, 8, 95, 95, 105, 110, 102, 111, 95, 95, 10, 97, …>>, %User{age: 27, name: “Alice”}}
then you can create your struct
iex> user = %User{}
%User{age: 27, name: “Alice”}
Keep this in mind as you cannot create a struct if it has not been defined.
iex> email = %Email{}
** (CompileError) iex:10: Email.__struct__/1 is undefined, cannot expand struct Email. Make sure the struct name is correct. If the struct name exists and is correct but it still cannot be found, you likely have cyclic module usage in your code
That leads us into actually utilizing the struct itself. As stated, we can’t dynamically access a struct but we can strictly access them. This is due to the Elixir design decision that structs are “static structures” where their fields do not change.
Strict Lookup
iex> user.age
27
Dynamic Lookup
iex> user[:age]
** (UndefinedFunctionError) function User.fetch/2 is undefined (User does not implement the Access behaviour) User.fetch(%User{age: 27, name: “Alice”}, :age) (elixir 1.13.2) lib/access.ex:285: Access.get/3
Additionally, we cannot enumerate over a struct
iex> Enum.each(user, fn {key, _value} -> IO.puts(key) end)** (Protocol.UndefinedError) protocol Enumerable not implemented for %User{age: 27, name: “Alice”} of type User (a struct)
(elixir 1.13.2) lib/enum.ex:1: Enumerable.impl_for!/1
(elixir 1.13.2) lib/enum.ex:143: Enumerable.reduce/3
(elixir 1.13.2) lib/enum.ex:4144: Enum.each/2
Map Module
Although structs are bare maps, you can use the Map module for some additional functionality.
iex> user = Map.put(user, :occupation, “City Worker”)
%{__struct__: User, age: 27, name: “Alice”, occupation: “City Worker”}iex> Map.keys(user)
[:__struct__, :age, :name, :occupation]
You can also convert a struct to a map and get all of the standard functionality back using the Map.from_struct function
iex> user = Map.from_struct(user)
%{age: 27, name: “Alice”, occupation: “City Worker”}iex> user[:name]
“Alice”
Summary
Maps are very versatile and have a lot of functionality baked in. When in doubt, take a look at https://hexdocs.pm/elixir/1.12/Map.html#summary and I’m sure you’ll find a function that meets your needs!
Sources:
https://dashbit.co/blog/writing-assertive-code-with-elixir
https://hexdocs.pm/elixir/1.5.0-rc.1/Access.html
https://hexdocs.pm/elixir/1.12/Map.html
https://hexdocs.pm/elixir/1.13/Kernel.html#struct/2
https://elixir-lang.org/getting-started/keywords-and-maps.html