Learn With Me: Elixir - Flow Control Structures (#40)
Flow control structures consist of looping and branching constructs. Flow control structures such as if
, while
, and for
are very common and essential in imperative code, and when learning imperative languages, they're one of the first things we learn. In Elixir, however, they are far less essential, and aren't necessary to learn at the very beginning.
Flow control structures aren't as essential in Elixir because they are used far less. Pattern matching, guard clauses, and multiple function clauses allow Elixir code to do a lot without using any flow control structures. What can be typically be done with a flow control structure in a function can also usually be done with multi-clause functions, which remove the need for these structures.
Elixir code, being functional, tends to declare what should happen rather than how it should happen, so flow control structures are used far less than in imperative programming. If you find yourself using flow control structures everywhere, you may be using an imperative way of thinking instead of a functional way of thinking. I know that it's certainly been difficult for me to break out of the imperative mindset that I've gotten used to over the years. So far in learning Elixir, I have not often missed having flow control structures available to me. It wasn't until I started learning about them did I even notice that I hadn't seen any so far.
So while flow control structures certainly aren't as essential in Elixir, they do exist, and they are used. They just aren't as common as in other languages. My recommendation based on the code I've seen so far is to not be afraid to use flow control structures, but don't overuse them. Overuse is typically a symptom that you're thinking imperatively rather than functionally.
Case
The case
structure provides flow control using pattern matching where different blocks of code are run depending on which pattern is matched.
Here's an example of a hypothetical file read.
result = case File.read("data_file.txt") do
{:error, reason} -> "The file could not be read",
{:ok, data} -> "#{length(data)} bytes were read from the file}
end
File.read/1
, which we'll cover in more detail when I eventually learn more about how file I/O works, returns a tuple. That tuple either contains the :error
atom and an explanation of what the error is or it contains the :ok
atom and the data from the file when the file was read successfully.
In the code above, when there's an error, the tuple matches the first line and the case expression evaluates to the corresponding error message. If the file was read successfully, the tuple matches the second line and the case expression evaluates to a message indicating the number of bytes that were read.
We could have also done the same thing without the case
expression by passing the result of File.read/1
to another function with two function clauses, which would be the purely functional way of doing this, but a case
expression is another way of doing the same thing. This works since the expression is very simple. If there were significantly more complexity, then that would best be done in another function rather than in a case
expression. It's a judgement call regarding what is more readable and maintainable.
The code to be run when a pattern is matched in a case
expression can be a single line or it can be multiple lines. However, if the case
expression gets too large, then it would be best to move the code to its own function rather than making it part of the case
expression.
Like with places in Elixir that allow pattern matching, guard clauses can be added as well. Here's an example.
result = case person_data do
{name, age} when age <= 30 ->
age_in_5_years = age + 5
{name, age_in_5_years}
{name, age} when age > 30 ->
age_5_years_before = age - 5
{name, age_5_years_before}
end
When the person's age is 30 or less, the age has a 5 added to it. When the age is above 30, the age has 5 subtracted from it.
Here's what it looks like in IEx
iex> person_data = {"Hans", 88}
{"Hans", 88}
iex> result = case person_data do
...> {name, age} when age <= 30 ->
...> age_in_5_years = age + 5
...> {name, age_in_5_years}
...> {name, age} when age > 30 ->
...> age_5_years_before = age - 5
...> {name, age_5_years_before}
...> end
{"Hans", 83}
However, if I were writing this code, I would probably have moved it into its own function with separate guard clauses.
def get_modified_age({name, age}) when age <= 30 do
age_in_5_years = age + 5
{name, age_in_5_years}
end
def get_modified_age({name, age}) when age > 30 do
age_5_years_before = age - 5
{name, age_5_years_before}
end
I think it's a lot easier to understand and maintain that way. If this code needed to be in any way reusable, a function would be the way to go. From what I understand, using function clauses are considered to be preferable to a case
expression.
What happens if there is no pattern match for a case
expression? Let's find out.
iex> number = 3
3
iex> case number do
...> 5 -> "five"
...> 10 -> "ten"
...> end
** (CaseClauseError) no case clause matching: 3
It turns out that it throws an error similar to the error when function clauses do not match.
If you want a case clause that is run if the pattern does not match any of the previous clauses (like an else if an if-else statement), then you just need to do the same thing you would do if there were function clauses: use the wildcard character.
iex> number = 3
3
iex> case number do
...> 5 -> "five"
...> 10 -> "ten"
...> _ -> "another number"
...> end
"another number"
The case statement is in many ways analagous to a function with multiple clauses. Like with pattern matching for functions, the clauses will be evaluated top to bottom, so more specific patterns should precede more generic patterns.
You could say that a case
expression is similar to a switch/case statement in some imperative languages, but with pattern matching instead of logical comparisons or constants.
I'm not yet experienced enough with Elixir to be able to have a strong sense of when to use a case
expression. All I can say is give preference to functions, pay attention to how it's used in existing Elixir code, and use your judgement.
Cond
The cond
expression is much like the case
expression, except that it uses logical comparisons instead of pattern matching. It looks a bit like a switch/case statement in imperative languages, but with boolean expressions instead of constants. It's also similar to an "if"-"else if"-"else" statement, but it's not exactly the same.
The cond
expression consists of one or more clauses containing boolean expressions and the code to run when an expression evaluates to true. The cond
expression evaluates the boolean expressions from top to bottom and runs the code associated with the first expression that evaluates to true
. Unlike a case
expression, there is no data in the first line of the expression that is being compared against the expressions below. A cond
expression just consists of a list of boolean expressions.
Here's an example.
speed_evaluation = cond do
car.speed <= 10 -> "crawling"
car.speed <= 30 -> "sluggish"
car.speed <= 60 -> "moderate"
car.speed <= 100 -> "speedy"
car.speed > 100 && car.speed < 200 -> "super fast"
true -> "either a car going in reverse or a jet"
end
If the speed of the car was 45, for example, the cond
expression would evaluate to "moderate". The true
clause is the equivalent of an else
in an if
statement or default:
for a switch
statement in C# and Javascript. It always evaluates to true, and will be run if none of the other expressions evaluate to true. In the example above, the true
clause would only be evaluated if the car's speed is greater than 200 or if it is negative.
If none of the expressions in a cond
expression evaluate to true
, Elixir will raise a CondClauseError
. So you can choose if you want to handle all possible cases or you can let the code fail with an error if an unexpected situation is encountered.
If
An if
expression is very similar to other languages, except that there is no equivalent of an "else if". It's just a binary decision rather than an N-nary decision.
Here is an example.
if age > 18 do
"adult"
else
"child"
end
Let's see the same thing in IEx
iex> age = 8
8
iex> if age > 18 do
...> "adult"
...> else
...> "child"
...> end
"child"
If you want "else if" functionality, use a cond
expression instead.
An if
expression can also be written on one line
iex> age = 40
40
iex> category = if age > 18, do: "adult", else: "child"
"adult"
Unless
An unless
expression looks just like an if
expression, except that the if
is replaced by unless
. Whereas if
will evaluate to the first expression if the boolean condition is true
, unless
evaluates to the first expression if the boolean expression is false
.
Here's the same example from above rewritten using unless
.
unless age > 18 do
"child"
else
"adult"
end
Here it is in IEx.
iex> age = 10
10
iex> unless age > 18 do
...> "child"
...> else
...> "adult"
...> end
An unless
with an else
tends to be harder to understand than the if
with an else
because it's inverted from the normal way of thinking, so it's more typically used without an else
.
For example, the following code will allow entrance to anyone who isn't named "Bob". Sorry, Bob, you're out of luck.
unless name == "Bob" do
allow_entrance()
end
Here's a demonstration in IEx.
iex> name = "Frederick"
"Frederick"
iex> unless name == "Bob" do
...> allow_entrance.()
...> end
"allowing entrance"
iex> name = "Bob"
"Bob"
iex> unless name == "Bob" do
...> allow_entrance.()
...> end
nil
Those are all the flow control statements Elixir has. Notice that there were no loop structures mentioned here. That's because Elixir does not have any: loops are an imperative concept. Elixir accomplishes the same thing through recursion.