Control Flow


Sequential execution

Code is executed from top to bottom. This is an important consideration when building modules. Review the following multi-clause function.

defmodule Greeter do

  def hello(:jill) do
    IO.puts "Hello Jill"
  end

  def hello(:bob) do
    IO.puts "Hello Bob"
  end

  def hello(_) do
    IO.puts "Hello anonymous"
  end
end

The Greeter module defines two personalized greetings via the hello function. Both Jill and Bob will get a greeting that uses their name. Any other name passed in will get the "Hello anonymous" greeting. This works because we defined the catch-all signature last. If we moved the function signature with the underscore ( _ ) to the top of the file, then it would match all names passed as arguments. This includes ":jill" and ":bob". The order in which we define our functions is important. Equally important is the order of execution within our functions. We need ways of writing functions based on logical conditions. The remainder of this chapter is dedicated to controlling the flow of functions.

Control flow

Control flow is the use of conditional statements to determine when, if at all, some particular code should execute. It can be a difficult topic to teach but is most certainly an easy topic to learn. Most beginners should be able quickly understand and start implementing the concepts right away. However, control flow statements in general are often the source of suboptimal code, even bugs. If at all possible, we should focus on using writing short, single-purposed functions that utilize pattern matching and guard clauses. These are great options to reach for when writing idiomatic Elixir. However, there are several additional language constructs we can add to our tool belt.

If/else

Conditional statements check the current value or state of something in order to make a decision. The "something" is usually the value of a variable or result of a function. For example, "if true do this, else do that". Almost all programming languages implement a similar form of if. Although not required, the "if" statement usually contains an "else" clause. It should be noted that each "if" statement will require a do/end block, unless using the single line syntax.

  # if statement without else
  if true do  
    IO.puts "true"  
  end

  # if statement with else
  if true do
    IO.puts "true"
  else
    print "false"
  end

  # if statement on a single line
  if true, do: 1 + 1

The single line syntax isn't specific to conditional statements. It works the same when defining functions.

# single line statements should have a comma after the arguments and a colon after do
def hello(), do: IO.puts "hi"

It's important to understand that if the condition is true, the first line of code executes. If the condition is false, the "else" clause executes. We used the boolean value true in our examples for demonstration purposes. However, this is not something you'll encounter in the real world. To get a better understanding of "if", we'll create a new module with a few examples of "if" and its counterpart "unless". In the example below, the Logic module will implement two functions. The first will determine if we need to study more, the second if we'll receive a perfect score. This module could be used while studying for an upcoming exam.

defmodule Logic do
  # preparedness is measured on a scale from 1-10
  # with 1 being the least prepared

  def study_more?(preparedness) do
    if preparedness < 6 do
      "Keep studying!"
    else
      "Take a break, you've earned it!"
    end
  end

  def perfect_score(preparedness) do
    unless preparedness == 10, do: "I wouldn't be so sure :/"
  end
end

# start an iex session with the Logic module in memory
$ iex logic.ex

iex(1)> Logic.study_more?(3)
"Keep studying!"

iex(2)> Logic.perfect_score(7)
"I wouldn't be so sure :/"

iex(3)> Logic.study_more?(8)
"Take a break, you've earned it!"

When calling the Logic module with a preparedness level of 3, we're told we need to study more. After some time studying, our preparedness level increases to 8, resulting in a new return value from study_more. The if/else clauses are useful when choosing between two alternatives. It is possible to include an if statement within an if statement, otherwise known as a "nested if statement", although this pattern is frowned upon. Nested if statements often add unnecessary complexity to a function. They are also difficult to read and reason about. Instead, when faced with multiple conditions, reach for the cond statement.

Cond

The cond statement is useful when there are multiple conditions to check. It evaluates the expression for the first condition that evaluates to a truthy value. The syntax for cond is as follows:

# the word 'cond' followed by a do/end block
cond do
 condition -> return
 condition -> return
 condition -> return
end

If the condition is true, the code following the arrow ( -> ) is evaluated. If the condition is not true, the code to the right of the arrow is skipped and the next condition is checked. Here is a very simple example:

  cond do
      kind == :unique -> Enum.uniq(keys)
      true -> keys
  end

This snippet was taken from absinthe, a GraphQL toolkit for Elixir. The variable "kind" was bound earlier in the function and the purpose of the cond statement is to check if it is unique. It should be noted that if none of the conditions match an error is raised. To avoid this, it's common to use true as the last condition. This will act like an else clause and always match. Looking at our example, if the first condition is true Enum.uniq is used to return a list of unique keys. Otherwise, the true condition will match and return the keys.

Next, let's define a function that has three conditions. The wallet function expects one argument, an integer that represents the amount of dollars a person has in their wallet.

defmodule Check do
  def wallet(amt) do
    cond do
      amt <= 99 -> "The best things in life are free..."
      amt <= 400 -> "Let's go shopping!"
      amt > 400 -> "You baller! Get a bank account!"
    end
  end
end

In this example, there are 3 possible paths of execution. Which line of code executes is dependent on the value of "amt" .

iex(1)> Check.wallet(401)
"You baller! Get a bank account!"
iex(2)> Check.wallet(201)
"Let's go shopping!"
iex(3)> Check.wallet(99)
"The best things in life are free..."

Remember the condition to be evaluated goes on the left of the -> and the code to execute goes on the right of ->. The condition is not restricted to boolean values but rather Elixir's concepts of truthy and falsey values. This means that any guard clause can be used on the left side of the arrow ->. If you need a refresher on valid guard statements, refer to list here.

Truthy/Falsy

In Elixir, everything except nil and false is truthy. This means that any condition that is non-nil and non-false is considered truthy. We can test this by opening an iex session:

iex(1)> cond do
...(1)> false -> "false here"
...(1)> nil -> "i hope i get printed!"
...(1)> "Erlang" -> "Hello, my name is Erlang"
...(1)> true -> "I am the truth..."
...(1)> end
"Hello, my name is Erlang"

We are not limited in the number of conditions we wish to supply. Here we have 4 clauses. The first clause to evaluate to a truthy value is "Erlang", and thus the code to the right of -> is executed.

Case

The case statement is similar to the "switch" statement in other languages. It expects a condition to evaluate and compare to each clause head. The body of the matching clause is then executed. If none of the clauses match, an error is raised. To prevent this, a catch-all should be provided as the last clause, similar to how true is used as the last condition cond statements.

Here is a snippet from a hex package, bitcoin_price. The package is used as a simple wrapper for providing bitcoin info, including the current price. Below is the current() function which uses a case statement to return the current bitcoin price.

  # the module attribute @api_url is defined at the top of the module

  def current() do
    case HTTPoison.get(@api_url) do
      {:ok, %{status_code: 200, body: body}} -> format_price(body) 
      {:ok, %{status_code: 404}} -> "Price not found"# 404 Not Found Error
      {:error, %HTTPoison.Error{reason: reason}} -> IO.inspect reason
    end
  end

Unlike the cond statement, the case statement expects an expression. The expression will be evaluated and the result will be compared with the head of each clause. Upon finding a match, the body of the clause is executed. The expression in this example is making a call to a third party library HTTPoison. Let's review how HTTPoison works so we know what type of response we should expect to receive.

HTTPoison is an http client for Elixir powered by hackney. It's a very popular http library for making requests and parsing responses. The get method expects a valid url and returns a HTTPoison response. The response is either {:ok, data} or {:error, reason} and so we can pattern match the response in the head of our clauses. The response is easy to work with as it's an Elixir map. A successful response looks something like this:

 {:ok,
 %HTTPoison.Response{
   body: "{\"USD\":8215.57}",
   headers: [
     {"Server", "nginx/1.4.6 (Ubuntu)"},
     {"Date", "Thu, 19 Apr 2018 06:12:32 GMT"},
     {"Content-Type", "application/json; charset=UTF-8"},
     {"Transfer-Encoding", "chunked"},
     {"Connection", "keep-alive"},
     {"Vary", "Accept-Encoding"},
     {"Access-Control-Allow-Origin", "*"},
     {"Access-Control-Allow-Methods", "GET"},
     {"Access-Control-Allow-Headers", "Content-Type"},
     {"Cache-Control", "public, max-age=29"},
     {"CryptoCompare-Cache-HIT", "true"}
   ],
   request_url: "https://min-api.cryptocompare.com/data/price?fsym=BTC&tsyms=USD",
   status_code: 200
 }}

The response contains a great deal of information, more than we need. At the moment we are only interested in the "body", specifically the value of the "USD" key. We are going to use pattern matching to identify that we have a successful response and to bind the data we are interested in to a variable.

In our case statement, we are matching against a tuple where the first element is either ":ok" or ":error". If we get an error, we print the error to the console along with the reason. If the first element is ":ok", then we must check the status code of the response. A successful response has a key, status_code and the value of this key is 200. We don't need to worry about the extra data such as the "headers" or "request url". We only need to match a response that contains a status code of 200, then the value of the key body should be bound to the variable "body". Upon a successful response, we call the private function "format price", passing in the data we just extracted from the response map's "body" key. Here is the first line of our case statement once again:

# our case statement will this on a successful response
{:ok, %{status_code: 200, body: body}} -> format_price(body)

Comparing the head of the clause to the response:

{:ok,                          # match on :ok
%HTTPoison.Response{        
body: "{\"USD\":8215.57}",     # save the body in the variable body
...
status_code: 200               # match on status_code: 200
}}

We can ignore the fields we don't care about and match on the ones we do. We use the head of our clause to simultaneously match a successful response with the expected field value and bind the data we're interested in to a variable for use later. This once again reenforces the power of pattern matching, especially with respect to native Elixir data structures.

A quick aside

The flow of control is generally dictated by mechanisms like pattern matching, recursion, and if necessary supervision. It's important to reach for the right tool for the job. Start with multi-clause functions, pattern matching, guard clauses, and recursion. When necessary use the proper control structures. Embrace the features of Elixir by embracing the mindset of the functional paradigm. Once you've reached Functional Nirvana, you'll notice that writing code and solving computational problems has become much easier.

With

Elixir's with statement is a control flow construct that can be used in a similar manner to |> pipelines. It is known as a Kernel.SpecialForm and defined as being "used to combine matching clauses". The example in the Elixir documentation is as follows:

# taken from the Elixir docs
iex> opts = %{width: 10, height: 15}
iex> with {:ok, width} <- Map.fetch(opts, :width),
...>      {:ok, height} <- Map.fetch(opts, :height),
...>      do: {:ok, width * height}
{:ok, 150}

The important note is that the do block is executed only if all the clauses match. The result of the do block is the returned value. If one clause does not match, the execution of the with statement stops and the do block is not executed.

Let's review the syntax of the case statement:

# the case statement expects a condition and set of clauses
1) case condition do            # 1
2) notcondition -> body         # 2
3) notcondition -> body         # 3
4) condition - > body           # 4
5) end                          # 5

The English equivalent of the case statement, per line:

  1. evaluate this condition
  2. compare the condition to the head of the first clause - if it matches execute the body, otherwise move on
  3. compare the condition to the head of the second clause - if it matches execute the body, otherwise move on
  4. the condition matches the head of this clause - execute the body
  5. the end

The case statement evaluates a condition and executes the case that matches. Let's contrast this with the syntax of the with statement.

# with statement
with pattern1 <- expression1,      # 1
     pattern2 <- expression2,      # 2
     pattern3 <- expression3,      # 3
do                                 # 4
     #... code to execute ...#     # 5
end                                # 6

The English equivalent of the with statement, per line:

  1. assuming pattern1 is the result of expression1 - if not, return
  2. assuming pattern2 is the result of expression2 - if not, return
  3. assuming pattern3 is the result of expression3 - if not, return
  4. execute the following code
  5. code is executed, result of last expression returned
  6. the end

The with statement is significantly different compared to other control flow mechanisms. In terms of the case statement, we don't know the result of the condition and so we define multiple possibilities with the code that should execute. When we use the with statement, we already know the return values we expect. We use with to chain together expressions, ensuring each expression returns the desired pattern before defining additional code. By utilizing pattern matching inside of the with statement, we can define more complex interactions in a very succinct and readable manner.

results matching ""

    No results matching ""