14. Modules and Structs
We've now seen lots of examples of functions throughout this book. We've seen how we can define anonymous functions (in Chapter 5):
iex> hello = fn (place) -> "Hello #{place}!" end
hello.("World")
"Hello World!"
And we've seen how we can call functions from already defined modules (in Chapter 8):
iex> String.downcase("HELLO BUT QUIETLY")
"hello but quietly"
But what we haven't seen yet is how to define our own modules, or even why we would want to do that. The why is the simple part: we define functions inside of modules to keep them separate from other functions; modules are a convenient way of grouping functions. Here's a refresher that recycles the description used in Chapter 8:
The functions that Elixir provides are separated into something akin to kitchen drawers or toolboxes, called modules. Whereas in your top kitchen drawer you might have forks, knives, sporks, and spoons (like every sensible person's kitchen does), and in another you might have measuring cups, and in another tea towels.
In Elixir, the functions to work with the different kinds of data are separated into different modules. This makes finding functions to work with particular kinds of data in Elixir very easy.
We haven't yet built a complex enough system to require us to put functions inside of modules, or to even want to write our own functions. That changes in this chapter! A rumbling occurs behind us, and slightly to our left. It's Izzy vibrating with anticipation. It's a weird and unearthly sound, but we'll roll with it.
This chapter will show you how to write new modules for the purpose of grouping together functions, and we'll also cover a special kind of map called a struct. Let's go!
Functions for the people
Way back in Chapter 4, we had maps that looked like this:
%{name: "Izzy", age: "30ish"}
In this chapter, we're going to be working with maps based off these ones, but they're going to be a little more complicated in shape:
iex> person = %{
first_name: "Izzy",
last_name: "Bell",
birthday: ~D[1987-12-04],
}
With our new map shape, what if we wanted to have a function that joined together the person's first and last name into a single string? Well, we could probably write it like this:
iex> full_name = fn (person) -> "#{person.first_name} #{person.last_name}" end
Then we can call this function with this code:
iex> full_name.(person)
"Izzy Bell"
This is wilfully ignoring the fact that some people have mononyms, as well as the falsehoods programmers believe about names. Ignoring both of those things for now, we have a single function that will let us display a combination of a first name and a last name. That's all well and good to have a function that does that.
But what if we had another function that also operated on these people maps? A function called
age
:
iex> age = fn (person) ->
...> days = Date.diff(Date.utc_today, person.birthday)
...> days / 365.25
...> end
This new function uses two new functions from Elixir's built-in Date
module:
Date.utc_today/0
and Date.diff/2
. The Date.utc_today/0
function returns the
current date in the UTC time zone. The
Date.diff/2
function allows us to figure out the difference (in days) between two dates. So we're
using this function to find the number of days between today and the person's birthday. We then take that number
of days and divide it by the average number of days in a year: 365.25
. This should give us a
close-enough approximation of somebody's age.
We can call this function with this code:
iex> age.(person)
31.972621492128678
Note that you'll get a different number to the one shown here because Date.utc_today
will return a
different day for you. This number was correct at the time of writing, I promise!
Now we have two functions that work on this same map. Wouldn't it be nice to have a place where we could group the
functions? Similar to how Date.diff/2
and Date.utc_today/0
functions. Those functions
are grouped together inside of a module called Date
. Let's look at how we can create our own module
for these full_name
and age
functions.
The Person module
Elixir comes with a bunch of built-in modules: List
, Map
, Enum
and
Date
are a few that we've seen so far. Creating modules is not just for Elixir itself, but we can
create our own modules too. The main reason to create a module is to group together functions, which is exactly
what we're going to do in this part of this chapter.
To define modules in Elixir we use defmodule
. We could write it out in iex
:
iex(1)> defmodule Person do
...> ...
...> end
But we're going to be changing this code a lot and so we should write the code in a file instead. Let's create a
new file called person.exs
inside a directory called ~/code/joy_of_elixir
and we'll
define the Person
module in this file:
defmodule Person do
end
Our module doesn't do very much at the moment. So let's change that by putting our functions inside it. Functions
defined in a module must start with the keyword def
:
defmodule Person do
def full_name(person) do
"#{person.first_name} #{person.last_name}"
end
def age(person) do
days = Date.diff(Date.utc_today, person.birthday)
days / 365.25
end
end
To use this module, we need to make sure that we're in the ~/code/joy_of_elixir
directory and then we
can run iex
. Once we're in iex
we can compile the module with:
iex> c "person.exs", "."
The c
helper's name is short for "compile". Using c
like this will load the code from
person.exs
into our iex
session, making the Person
module and its functions
available to us.
When we run c
, it outputs a list of the modules that were loaded by compiling the file, which we see
here as [Person]
, since there's only the Person
module in that file.
We're passing a second argument to c
here which is a path where our compiled module should go. The
dot (.
) here means "the current directory", so where we ran the iex
command from. When
we run this command, Elixir will compile our module to a file called Elixir.Person.beam
.
With this file compiled, we can start to use the Person
module. Let's try the
Person.age/1
function:
iex> person = %{
first_name: "Izzy",
last_name: "Bell",
birthday: ~D[1987-12-04],
}
iex> person |> Person.age
31.975359342915812
Great! That one works. Let's try the full_name
function too:
iex> person |> Person.full_name
"Izzy Bell"
Excellent. Both functions work!
One special thing to note here is that if we exit out of iex
and re-open it again, our module will
still be accessible. We will still be able to run the functions without first having to compile our module:
iex> person = %{
first_name: "Izzy",
last_name: "Bell",
birthday: ~D[1987-12-04],
}
iex> person |> Person.age
31.975359342915812
iex> person |> Person.full_name
"Izzy Bell"
This is because Elixir will load the Elixir.Person.beam
file automatically, as it is located in the
directory that we're running iex
in.
Now that we have got our module running, let's add another two functions to it. These functions will set a
person's location to either be "home" or "away". This location value will indicate if the person is at home, or if
they're away from home. The functions should go at the bottom of the module definition inside
person.exs
:
defmodule Person do
def full_name(person) do
"#{person.first_name} #{person.last_name}"
end
def age(person) do
days = Date.diff(Date.utc_today, person.birthday)
days / 365.25
end
def home(person) do
%{person | location: "home"}
end
def away(person) do
%{person | location: "away"}
end
If we attempt to use these functions right away, they will not work:
iex> person |> Person.away
** (UndefinedFunctionError) function Person.away/1 is undefined or private
This is happening because we have not yet re-compiled the Person
module. To do that, we need to use
the c
helper again:
c "person.exs", "."
With the module now compiled, we will be able to use this function:
iex> person |> Person.away
** (KeyError) key :location not found
person.exs:15: Person.away/1
Well, we thought we could. But this map doesn't have a location
key on it, and this means the
away
function is unable to set that key to a value. We can fix this by providing that key when we
define the initial map:
iex> person = %{
first_name: "Izzy",
last_name: "Bell",
birthday: ~D[1987-12-04],
location: "home",
}
iex> person |> Person.away
%{
birthday: ~D[1987-12-04],
first_name: "Izzy",
last_name: "Bell",
location: "away"
}
Okay, so what we needed to do here was to provide the location
key in the map and then it worked.
That's good to see! But how could we prevent this missing key being an issue again? The way to do that is with a
struct!
Structs
So far, we have been using maps to represent our people -- well, one person -- and then passing this map
through to the functions from the Person
module:
iex> person = %{
first_name: "Izzy",
last_name: "Bell",
birthday: ~D[1987-12-04],
location: "home",
}
iex> person |> Person.away
%{
birthday: ~D[1987-12-04],
first_name: "Izzy",
last_name: "Bell",
location: "away"
}
We're now going to take the time to look at a data type that is similar to a map, called a struct. The word "struct" is shorted for "structured data"; it's a map, but a particular type of map, where each map of the same type will share the same structure.
A struct has a set of key-value pairs just like a map, but a struct's keys are limited to only a certain set.
Let's look at how we would define a struct now within the Person
module. At the top of the module, we
can use the defstruct
keyword to define a struct:
defmodule Person do
defstruct [
first_name: nil,
last_name: nil,
birthday: nil,
location: "home"
]
...
end
Let's start iex
and compile our person.exs
file:
iex> c "person.exs"
To use this struct, we use a similar syntax to how we would create a new map:
person = %Person{}
The difference is here that we put the name of the module where the struct is defined between the %
and the opening curly bracket. In our defstruct
call, we have defined a default value for
location
: "home"
. So when we've now built this new struct with %Person{}
,
it will already have a :location
key set to "home"
.
This is one of the advantages of structs over maps: structs can have default values. This will avoid the issue
where our away
function was failing because there was no location
key in our map.
One extra thing that will help here is to match on the struct type in the away
and home
functions. This will ensure that we always are getting a Person
struct before we try to do
anything in these functions. To do this, we need to change these functions to this:
def home(%Person{} = person) do
%{person | location: "home"}
end
def away(%Person{} = person) do
%{person | location: "away"}
end
This change to these functions will make them always require a Person
struct as an argument.
They will no longer work with a map. Let's see this in action by re-compiling our module and trying agani:
iex> c "person.exs", "."
iex> person = %{
first_name: "Izzy",
last_name: "Bell",
birthday: ~D[1987-12-04],
location: "home",
}
iex> person |> Person.away
When we run this code, we'll see this error:
** (FunctionClauseError) no function clause matching in Person.away/1
The following arguments were given to Person.away/1:
# 1
%{
birthday: ~D[1987-12-04],
first_name: "Izzy",
last_name: "Bell",
location: "home"
}
Attempted function clauses (showing 1 out of 1):
def away(%Person{} = person)
person.exs:22: Person.away/1
This error is showing us that the function does not match. It shows us that we're passing a plain map to the
function as its first argument, but the function is expecting a Person
struct instead. So let's pass
one of those instead:
person = %Person{
first_name: "Izzy",
last_name: "Bell",
birthday: ~D[1987-12-04],
}
iex> person |> Person.away
%Person{
birthday: ~D[1987-12-04],
first_name: "Izzy",
last_name: "Bell",
location: "away"
}
That works a lot better! And did you notice that we didn't have to supply a location
for our Izzy
either? The struct will use the default value if we do not specify it.
By enforcing a Person
struct here in this away
function, we can be guaranteed that the
function will always receive a person
argument that is a Person
struct, and that means
it will always have a location key.
While we're here, we should also make the same changes to the age
and full_name
functions too, just to make sure that we receive structs for those functions too.
Our module will now look like this:
defmodule Person do
defstruct [
first_name: nil,
last_name: nil,
birthday: nil,
location: "home"
]
def full_name(%Person{} = person) do
"#{person.first_name} #{person.last_name}"
end
def age(%Person{} = person) do
days = Date.diff(Date.utc_today, person.birthday)
days / 365.25
end
def home(%Person{} = person) do
%{person | location: "home"}
end
def away(%Person{} = person) do
%{person | location: "away"}
end
end
One final thing to do here is to use pattern matching to pull out the values from the struct that we depend on. Let's change the two functions of full_name
and age
to this:
def full_name(%Person{first_name: first_name, last_name: last_name} = person) do
"#{first_name} #{last_name}"
end
def age(%Person{birthday: birthday} = person) do
days = Date.diff(Date.utc_today, birthday)
days / 365.25
end
Public and private functions
Before we move on from here, there's one extra concept I would like to share with you. That concept is about public and private functions in modules. Sometimes, we will have functions in modules that we will not want to share with the outside world. Those functions can be kept private so that only other functions inside the module know about it.
Let's say that instead of having two functions called home
and a away
, we instead wanted
to have a function called toggle_location
that toggled the person's location between "home" and
"away"?
Well, here's how we might write that function:
def toggle_location(%Person{location: "away"} = person) do
%{person | location: "home"}
end
def toggle_location(%Person{location: "home"} = person) do
%{person | location: "away"}
end
And now we can compile the module once again, and use this function:
iex> c "person.exs", "."
iex> person = %Person{
first_name: "Izzy",
last_name: "Bell",
birthday: ~D[1987-12-04],
}
iex> person |> Person.toggle_location
%Person{
birthday: ~D[1987-12-04],
first_name: "Izzy",
last_name: "Bell",
location: "away"
}
This function does exactly what our home
and away
functions do, and so we can remove
those functions.
But this toggle_location
function is public -- it's accessible outside of the module still -- and
weren't we talking about both public and private functions? You're right! We were. Let's get to that now.
The two function clauses of toggle_location
look remarkably similar. They both set a
location
key to a particular value. This is a clear opportunity for tidying up some of our code, and
it's a great opportunity to demonstrate private functions too.
Let's add a new function -- a private function to our module. We add private functions to the bottom
of our module, and define them with defp
, where the "p" stands for private.
defp set_location(%Person{} = person, location) do
%{person | location: location}
end
Now back up in toggle_location
, we can use this function to set the location:
def toggle_location(%Person{location: "away"} = person) do
person |> set_location("home")
end
def toggle_location(%Person{location: "home"} = person) do
person |> set_location("away")
end
This way, the code involved with setting the location can be shared across these toggle_location
functions, and any other functions that later on might also set a location. Perhaps there'll come a time where we
might want to announce what a particular person's location is each time it changes:
defp set_location(%Person{} = person, location) do
IO.puts "#{person |> full_name}'s location is now #{location}"
%{person | location: location}
end
The private function is an ideal place to put that code. It centralises the code in one simple place, and hides internal implementation details about how a location is set.
Structs are maps at heart
There's one last thing to cover on the topic of structs. Structs are maps when you get to the bottom of things.
Structs can be passed as the argument to any Map
function, like Map.get/2
for instance:
iex> %Person{} |> Map.get(:location)
"home"
This is because the underlying implementation of structs is based on maps. We can see this in action if we call
the Map.keys/1
function on a struct:
iex> %Person{} |> Map.keys
[:__struct__, :birthday, :first_name, :last_name, :location]
The Map.keys/1
function returns not just the four keys that we've defined with
defstruct
, but a fifth key called :__struct__
. This key contains the module name of the
struct. We can see this by asking the struct for its __struct__
key's value:
iex> %Person{}.__struct__
Person
Structs are, simply put, maps with one extra key: a :__struct__
key. We can even define a map
ourselves with such a key to prove this:
iex> %{__struct__: Person, first_name: "Izzy", last_name: "Bell", birthday: ~D[1987-12-04], location: "home"}
%Person{
birthday: ~D[1987-12-04],
first_name: "Izzy",
last_name: "Bell",
location: ""
}
Structs are maps at heart!
Structs and Protocols
There's been one other type of struct that we've been using a lot in this chapter, and we haven't even talked about it being a struct! It's this:
~D[1987-12-04]
Izzy exclaims: "That's not a struct! There's no percent sign!". Yup, there's not a single percent sign there. But
it's still a struct! How can we tell? We can use Map.keys/1
:
iex> ~D[1987-12-04] |> Map.keys
[:__struct__, :calendar, :day, :month, :year]
This function returns a list of keys, and that list contains :__struct__
, and that's how we know that
dates are structs under the hood.
Izzy is right that structs usually contain a percent sign. When we create a date, we don't use a percent
sign to create it. Instead, we use the ~D
sigil. Similarly, when a date is displayed (like in some
output for our terminal) it is not displayed like this:
%Calendar.Date{calendar: Calendar.ISO, day: 4, month: 12, year: 1987}
Instead, it is shown like this:
~D[1987-12-04]
This is due to some code within Elixir itself. This code sees that a date is about to be output, and instead displays it in this condensed format instead for readability.
This feature of Elixir is called a protocol, and in particular this is the Inspect
protocol
we're talking about here. When a date is output on the screen, Elixir checks if there is an inspect protocol
implemented for dates. There is, and so it gets used instead of the regular struct output.
That description is a little wordy, so let's see some code of our own in action! Let's go into our
person.exs
file and define an implementation for this Inspect
protocol:
defmodule Person do
# ...
# functions go here
# ...
defimpl Inspect do
def inspect(%Person{
first_name: first_name,
last_name: last_name,
location: location,
}, _) do
"Person[#{first_name} #{last_name}, #{location}]"
end
end
end
Then let's re-compile this module back in iex
:
iex> c "person.exs", "."
[Inspect.Person, Person]
Note that this has now compiled two modules: Inspect.Person
and Person
. The
Inspect.Person
module has been automatically generated and it will be used when a person struct is
inspected.
To inspect a person struct, all we need to do is to generate one and get the console to do the actual inspection:
iex> %Person{first_name: "Izzy", last_name: "Bell"}
Person[Izzy Bell, home]
Great! This is now working. This has allowed us to condense the information that is displayed when using a
Person
struct in the console. This can be useful if you want to limit the amount of data that is
shown in the terminal and just something I thought you should know about before we wrap up this chapter on modules
and structs!