May 30, 2016
Table of contents:
One of the very first things you typically learn when getting started with programming is branching and conditional logic. This usually involves using if
and else
, case
statements, and while
loops.
These early steps of the curriculum are usually very useful for languages such as Ruby, Python, PHP, and Javascript, but in Elixir, there is a better way.
More often than not in Elixir, these constructs can be replaced with multi-clause functions. We looked a multi-clause functions in Multi-clause Functions with Pattern Matching and Guards in Elixir.
If you are coming from another programming language, it might even surprise you when you hear that there are no classical loop constructs in Elixir.
In today’s tutorial we will be looking at branching and conditional logic in Elixir.
The really interesting part of Elixir’s branching and conditional functionality is multi-clause functions. However with that being said, there is still a time and place for the more traditional conditional and branching constructs that you will probably be familiar with if you already have experience with another programming language.
Before we look at how we can replace these constructs with multi-clause functions, first we will take a whirlwind tour of what is available in Elixir.
The first bit of branching logic that you probably came across as a newbie programmer was the humble if
statement. In Elixir this works exactly how you would expect it to work:
if true do
IO.puts("Hello")
end
If the condition evaluates to true
the block of code is run. If this isn’t second nature to you, Elixir is probably not the right language to be learning right now.
You can also have an else
block that will be run if the condition does not evaluate to true:
if 0 > 1 do
IO.puts("0 is greater than 1")
else
IO.puts("O is not greater than 1")
end
If the code you are writing is really short and concise, you could also write it as a one-liner:
if is_binary("Hello"), do: IO.puts("Binary!"), else: IO.puts("Not binary")
Elixir also has the unless
construct which will run the first branch if the condition evaluates to false
:
unless false, do: IO.puts("false"), else: IO.puts("true")
In this example the first block will be executed because the condition is false
.
If you have more than 2 branches that could be chosen, it often makes sense to write the branching logic using the cond
construct:
defmodule MyNumber do
def size(number) do
cond do
number <= 3 -> "small"
number > 3 < 20 -> "medium"
number >= 20 -> "big"
end
end
end
MyNumber.size(23) |> IO.puts()
In this example I’m returning a string based upon the “size” of the number.
If you want to provide a default clause that will be executed if none of the previous conditions were met you can provide a true
branch.
defmodule WhatIsIt do
def check(value) do
cond do
is_binary(value) -> "a binary"
is_number(value) -> "a number"
true -> "unknown"
end
end
end
IO.puts("It's a #{WhatIsIt.check(:atom)}")
In this example I’m providing an Atom (Understanding the Types in Elixir) and so the default statement will be executed.
An important thing to note is, Elixir will check each branch from top to bottom, so if a branch matches it will execute that branch without checking the remaining branches:
defmodule WhatIsIt do
def check(value) do
cond do
true -> "unknown"
is_binary(value) -> "a binary"
is_number(value) -> "a number"
end
end
end
IO.puts("It's a #{WhatIsIt.check("hello")}")
In this example the default branch will be matched first, and so the more specific is_binary/1
will not be executed.
This is just something to bare in mind when writing these kind of statements.
Finally we have the case
expression, which is also our first exposure to pattern matching for branching logic:
defmodule DidItWork do
def check(atom) do
case atom do
:ok -> "it worked!"
:error -> "it didn't work"
_ -> "I'm not sure if it worked"
end
end
end
IO.puts(DidItWork.check(:ok))
IO.puts(DidItWork.check(:error))
IO.puts(DidItWork.check(:unknown))
In a case
construct each branch is pattern matched against the value. If the pattern matches the branch will be executed.
If none of the branches match the pattern we can use the anonymous variable to match anything and therefore provide a default value.
If you are already familiar with another programming language, none of the above examples were probably that mind blowing for you. This kind of logic is found in a lot of programming languages and it’s usually one of the very first things you learn.
Whilst you could write this type of code, there is often a better, more declarative way using multi-clause functions.
Here is what the case
example looks like using multi-clause functions:
defmodule DidItWork do
def check(:ok) do
"it worked"
end
def check(:error) do
"it didn't work"
end
def check(_) do
"I'm not sure if it worked"
end
end
IO.puts(DidItWork.check(:ok))
IO.puts(DidItWork.check(:error))
IO.puts(DidItWork.check(:unknown))
In this example we’ve rewritten the case
construct into three separate functions. When the DidItWork.check/1
function is invoked, the argument will be pattern matched against the function definitions to choose which definition to run.
This is exactly the same as what happened when it was written as a case
construct.
However, the benefit of this approach is that each function only deals with one branch of logic. We can use the power of Elixir’s pattern matching to remove the need for an additional construct.
In this example I wanted to show you how the case
example could be translated into a multi-clause function example.
But to be honest, this isn’t such a great example because it’s so simple. It’s hard to see the benefit of using multi-clause functions over traditional branching logic.
Lets instead look at a more complicated example.
Imagine we need to get the contents of a webpage from a web service. However, the web service is pretty flakey and so often we won’t get the response we’re looking for.
For illustrative purposes, here is the code to get the response from the web service:
:random.seed(:erlang.timestamp())
defmodule WebService do
def response do
Enum.random([
{:ok, 200, "<p>Hello World</p>"},
{:error, 500, "Server Error!"}
])
end
end
When we call WebService.response/0
we will get a random tuple from the list that either represents a successful response or an unsuccessful response.
After we have received the response we need to either return the contents of the response or return a message to tell the user to try again later.
First we can decompose the elements of the response tuple:
{status, _, content} = WebService.response()
Next we can use a case
to get the contents of the response or return an error message:
response =
case status do
:ok -> content
:error -> "Please try again later"
end
And finally we can print the response:
IO.puts(response)
Here is the code in full:
:random.seed(:erlang.timestamp())
defmodule WebService do
def response do
Enum.random([
{:ok, 200, "<p>Hello World</p>"},
{:error, 500, "Server Error!"}
])
end
end
{status, _, content} = WebService.response()
response =
case status do
:ok -> content
:error -> "Please try again later"
end
IO.puts(response)
This is fine because it works as it’s supposed to, but is there a way we can rewrite it to get rid of the imperative conditional code that is spilling out?
:random.seed(:erlang.timestamp())
defmodule WebService do
def response do
Enum.random([
{:ok, 200, "<p>Hello World</p>"},
{:error, 500, "Server Error!"}
])
end
end
defmodule ResponseReader do
def read({:ok, 200, contents}) do
contents
end
def read({:error, 500, _}) do
"Please try again later"
end
end
WebService.response()
|> ResponseReader.read()
|> IO.puts()
In this example I’ve encapsulate the functionality of reading the response into a module with two definitions of the read/1
function.
If the response is successful, the first definition will be matched and the contents
from the response will be returned.
If the request is not successful we can return the message to try again later.
As you can see, whilst this is still a pretty simple example, it shows how powerful multi-clause functions can be. We can deal with each branch of logic as it’s own single responsibility function.
And the client code does not have to deal with conditional or branching logic depending on the return value of a function. This makes it much easier to write elegant, declarative code that is much nicer to read and understand.
If you have worked with large or complicated codebases, you’ve probably experienced the pain of opening a new file and seeing a mess of branching and conditional logic.
This kind of code is ugly and often leaks important domain logic out into the rest of the code.
Elixir makes it really easy to write beautiful, declarative code using pattern matching and multiclause functions.
I personally really hate imperative branching logic so the realisation that I didn’t have to write it in Elixir was a revelation to me.