12. Conditional code
In the last chapter, we have encountered a situation where our code can have different
outcomes. Outcomes that we cannot predict from within the code itself. As an example, when we call
File.read/1
we could have two main outcomes:
- The file exists, and so the file is read successfully. We get back an
{:ok, contents}
tuple. - The file does not exist, and so we get back a
{:error, :enoent}
tuple instead.
We have dealt with this in the past by making our program crash if we don't get an expected value. We do this by pattern matching on the outcome that we expect.
iex> {:ok, contents} = File.read("haiku.txt")
In this example, if File.read/1
succeeds then everyone is happy. We're expecting to get back a
tuple here with two elements: 1) an :ok
atom, and 2) the contents of the file.
But if our expectations aren't met because the haiku.txt
file is missing, then things aren't so great:
{:ok, contents} = File.read("haiku.txt")
** (MatchError) no match of right hand side value: {:error, :enoent}
Elixir tells us that something went wrong and will refuse to do anything more about it until we give it different instructions.
We need to cater for this sort of thing happening in the Elixir programs that we write. Sometimes, files are
missing. The way that we can handle this is by using some conditional code within Elixir. Elixir has four
main helpers for conditional code. They are: case
, cond
,if
, and
with
. Conditional code allows us to give Elixir different instructions depending on what happens during
the running of any Elixir program. Let's look at some examples.
case
As we just talked about, we saw a case where file reading could fail. When we call this function, it can have two outcomes:
- The file exists, and so the file is read successfully. We get back an
{:ok, contents}
tuple. - The file does not exist, and so we get back a
{:error, :enoent}
tuple instead.
In both cases, we get back a tuple, but what we can do with that tuple is dependent on what's inside. If we get
back {:ok, contents}
then we can carry on working with that file. But if we get
{:error, :enoent}
then we will have to make our program stop.
One of the ways to get Elixir to behave this way is to use case
:
iex> case File.read("haiku.txt") do
{:ok, contents} ->
contents
|> String.split("\n", trim: true)
|> Enum.map(&String.reverse/1)
|> Enum.join("\n")
{:error, :enoent} ->
IO.puts "Could not find haiku.txt"
end
This case statement uses much of the same code we saw in the previous chapter, but now goes different routes
depending on the output of File.read/1
. If File.read/1
returns something that matches
the pattern of {:ok, contents}
, then our file's code will be parsed and reversed correctly, resulting
in this output:
"I love Elixir\nIt is so easy to learn\nGreat functional code"
However, if that File.read/1
call results in something that matches {:error, :enoent}
,
then we will see an error message telling us that we couldn't find that file.
Could not find haiku.txt
These two "forks in the road" for this case
statement are referred to as clauses.
You might recognise this code as being similar to a function we defined back in Chapter 6:
iex> road = fn
"high" -> "You take the high road!"
"low" -> "I'll take the low road! (and I'll get there before you)"
_ -> "Take the 'high' road or the 'low' road, thanks!"
end
This is because it is the same underlying principle. We're using pattern matching inside the case
in
this chapter to determine what to do, just like we did in that function 6 chapters ago. Before the
->
we tell Elixir what we want to match. Then after that, we tell it what we want to do once that
match happens. In our case statement, we put that "after" code on separate lines and this is just to increase
readability of the code. We could've done the same in our function too:
iex> road = fn
"high" ->
"You take the high road!"
"low" ->
"I'll take the low road! (and I'll get there before you)"
_ ->
"Take the 'high' road or the 'low' road, thanks!"
end
You might notice that in this function block, we have the catch-all clause at the end (_ ->
). This is
the last-ditch effort for the function to do something. It's worth knowing that we could do the same thing in our
case
statements too:
iex> case File.read("haiku.txt") do
{:ok, contents} ->
contents
|> String.split("\n", trim: true)
|> Enum.map(&String.reverse/1)
|> Enum.join("\n")
{:error, :enoent} ->
IO.puts "Could not find haiku.txt"
_ ->
IO.puts "Something unexpected happened, please try again."
end
In this code, if Elixir sees something that is not known to the case
statement then it will give us a
completely different message. While we're on this topic of catch-all clauses, I want to show you one more precise
way of doing this too:
iex> case File.read("haiku.txt") do
{:ok, contents} ->
contents
|> String.split("\n", trim: true)
|> Enum.map(&String.reverse/1)
|> Enum.join("\n")
{:error, :enoent} ->
IO.puts "Could not find haiku.txt"
{:error, _} ->
IO.puts "Something unexpected happened, please try again."
end
This time, our last clause will not match everything and anything. It will only match tuples that have
exactly two items in them, and the first item must be :error
. As we can see here,
this is using the pattern matching feature in Elixir that we've seen a few times throughout this book
already. The tuple for the last clause isn't exactly {:error, _}
, but it is something that
is in the same pattern. This pattern matching is why the last clause would match any other error that may be
returned from File.read/1
.
This is a better approach, because it is clearer to anyone else seeing this code what we might expect when something unexpected happens.
Now we know Elixir has two places where these clauses are used: functions and case
blocks. We're
about to see another one.
cond
The case
statement is good to use if you want to compare the outcome of one particular action and do
different things depending on whatever that outcome is. In the last section, that outcome was the output of a
File.read/1
function call.
What we'll see in this section is that case
has a cousin called cond
which provides us a
way of checking multiple conditions (cond
is short for "condition"), and then running some code for
whatever clause is true. Here's a quick example:
iex> num = 50
50
iex> cond do
num < 50 -> IO.puts "Number is less than 50"
num > 50 -> IO.puts "Number is greater than 50"
num == 50 -> IO.puts "Number is exactly 50"
end
Number is exactly 50
:ok
Izzy asks: "What does <
, >
and ==
mean? We've never seen those
before!" Yes! This is the first time in twelve chapters that we've seen these things. Now is a great time to cover
what they do. <
, >
and ==
are ways to compare two values in Elixir.
You can probably guess from the code that <
means "less than", >
means "greater
than", and that ==
is "exactly equal to". But what is this code actually doing?
If we take the comparisons out of the cond
and run them individually, we'll have a clearer picture of
what this code is doing:
iex> num > 50
false
iex> num < 50
false
iex> num == 50
true
These comparisons are going to compare the two values and then tell us if those comparisons are true
or false
. This is our first exposure to code that outputs either true
or
false
. Think of it like this: if we were to ask the question of "is num
equal to 50",
what would the answer be? We would normally say "yes, it is equal". Elixir's version of an answer to this question
is true
.
When we use these comparisons in cond
, the first clause where the comparison results in
true
will execute that clause's code. Go ahead and change the number in the cond
code
above to see how it might react to those changes.
if, else and unless
Now that we've seen what case
and cond
can do, let's look at two more related
conditional statements: if
and unless
and their compatriot else
.
The cond
statement was really helpful if we had multiple conditions to compare against. In the
previous code, we wanted to check if the number was less than, greater than or exactly equal to 50:
iex> num = 50
50
iex> cond do
num < 50 -> IO.puts "Number is less than 50"
num > 50 -> IO.puts "Number is greater than 50"
num == 50 -> IO.puts "Number is exactly 50"
end
Number is exactly 50
:ok
But what if we only wanted to check if the number was exactly 50? Well, we could remove the first two clauses from
this cond
statement:
iex> cond do
num == 50 -> IO.puts "Number is exactly 50"
end
Number is exactly 50
:ok
This is one way of writing this code and it will work perfectly fine. However, if the number was not 50, then we would see an error come from this code:
iex> num = 10
10
iex> cond do
num == 50 -> IO.puts "Number is exactly 50"
end
** (CondClauseError) no cond clause evaluated to a true value
This is happening because cond
always requires at least one of its conditions to evaluate to a
true
value. In the code we've just attempted, num == 50
will be false
, not
true
, and because it is the only clause in this cond
we will see this error.
If we've got code like this in Elixir where we're running code conditionally and we don't want Elixir to show us
big scary error messages like this one, we should be using if
instead. Let's look at how we could
attempt the same code with if
:
iex> num = 10
10
iex> if num == 50 do
IO.puts "Number is exactly 50"
end
nil
Becuase the condition here is not true
, nothing happens. The way that Elixir represents nothing is
with nil
. We asked Elixir to only execute code if the condition is true, but it wasn't. So
nil
is the outcome.
unless
Now we've seen how to do something if a particular condition is true, but what happens if we want to do something
if it is false? For this, Elixir gives us unless
:
iex> num = 10
10
iex> unless num == 50 do
IO.puts "Number is not 50"
end
Number is not 50
:ok
In this short example, Elixir will output our "Number is not 50" message if the number is not 50. If the number is
50, then nothing (nil
) will be the result of this code.
If you're unsure of whether to use if
or unless
, try reading the code out loud. Does it
make more sense to say "unless the number is equal to 50"? In this case, yes it does. But let's try another
example:
iex> num = 10
10
iex> unless num != 50 do
IO.puts "Number is 50"
end
Number is 50
:ok
This time, the code reads in English as "unless the number is not equal (!=
) to 50". This sentence
contains a double negative with the use of "unless" and "not", and so using unless
in this example is
unsuitable. The code should use an if
instead:
iex> num = 50
50
iex> if num == 50 do
IO.puts "Number is 50"
end
Number is 50
:ok
Now the code reads as "if the number is equal to 50", and that makes a lot more sense!
else
We've now seen if
and its opposite unless
, but what if we wanted to do if
and unless
at the same time? What if we wanted Elixir to do some code if a particular
condition was true, but then do some other code if it wasn't true?
For this, Elixir gives us else
:
iex> num = 10
10
iex> if num == 50 do
IO.puts "Number is 50"
else
IO.puts "Number is not 50"
end
Number is not 50
:ok
This would read like: "if the number is 50, show 'Number is 50', otherwise, show 'Number is not 50'". In this code, our number is not 50, and so we see Elixir tell us that.
with
There's one more feature of Elixir to do with conditional code that I would love to show you before we finish off
this chapter. It is called with
. The with
feature allows us to chain together multiple
operations, and only continue if each of those operations is successful. Before we look at how to use
with
, let's look at the type of problem it is good at solving.
Let's say that we had somehow came across a map containing this data in our travels:
iex> file_data = %{name: "haiku.txt"}
This map is designed to tell us what file to read our haiku from. This map contains a single key which is the atom
:name
, and its value is the string "haiku.txt". So we could know by accessing the name key in this
map what file to read from. Here's one way we could do it:
iex> File.read(file_data["name"])
{:ok, "rixilE evol I..."}
But what would happen here if the map didn't contain this key, but instead a differently named key? Then our code would break:
iex> file_data = %{filename: "haiku.txt"}
%{filename: "haiku.txt"}
iex> File.read(file_data["name"])
{:error, :enoent}
Our programs need to be written in such a way to protect against this sort of thing. In this particular case, we
must make them expect to read a value from a key called :name
, not :filename
. Once it
has done that, then it can try to read that file.
One way to write this would be to use a case
statement inside another case
statement,
like this:
file_data = %{name: "haiku.txt"}
case Map.fetch(file_data, :name) do
{:ok, name} ->
case File.read(name) do
{:ok, contents} ->
contents
|> String.split("\n", trim: true)
|> Enum.map(&String.reverse/1)
|> Enum.join("\n")
{:error, :enoent} ->
IO.puts "Could not find a file called #{name}"
end
:error -> "No key called :name in file_data map"
end
In this code, we attempt to find the :name
key in file_data
with
Map.fetch/2
. This Map.fetch/2
function is new to us here, and so we'll quickly cover
what it does.
The way Map.fetch/2
works is that if there is a key in the map then Map.fetch/2
will
return {:ok, name}
. If there isn't, it will return the atom :error
.
We pattern match on either of these two outcomes during this case
statement. In this example, there
is going to be a :name
key, and so it will match the {:ok, name}
part of our case
statement. Then the second case
statement will come into effect.
This second case statement attempts to read a file using File.read/1
. If this file exists, then this
function will return {:ok, contents}
. If the file does not exist, {:error, :enoent}
will
be returned instead. In this case, we know that the file exists from our previous attempts at reading it, and so
this code will execute successfully.
This code is a little hard to read, as we need to really focus to keep our mind on where we are in the code. A
with
statement can simplify this code:
with {:ok, name} <- Map.fetch(file_data, :name),
{:ok, contents} <- File.read(name) do
contents
|> String.split("\n", trim: true)
|> Enum.map(&String.reverse/1)
|> Enum.join("\n")
|> IO.puts
else
:error -> ":name key missing in file_data"
{:error, :enoent} -> "Couldn't read file"
end
In Elixir, with
statements are to be read like a series of instructions of what to do when everything
goes right. In this example, if Map.fetch/2
succeeds and returns {:ok, name}
,
then Elixir will be able to use it in the next step, when it calls File.read/1
. After that, our code
will work as intended.
However, if Map.fetch/2
fails, then :error
will be returned. We handle that in the
else
inside this with
block, telling with
that if we see
:error
returned, that we want to see an error message saying that the :name
key was
missing. Then if File.read/1
fails and returns {:error, :enoent}
, then we want it to
tell us that it couldn't read the file.
This code is a little neater than the double-case
code because all of our code that deals with the
success of our program is grouped neatly at the top, and all the code that deals with the
failure
is grouped at the bottom.
I would encourage you to play around here with the with
statement to get a better understanding of
it. What happens if you change file_data
so that it doesn't have a :name
key? What
happens if that haiku.txt
file goes missing?