Elixir Syntax: Control Flow
Elixir Syntax Series
Elixir’s control flow includes if/else
, but it tends to leverage pattern matching, where case
, cond
, and function guards are often a better fit. These constructs allow for clearer and more concise handling of conditions, making the code more declarative and easier to maintain.
If-Else
In JavaScript, defensive coding and guard conditions are commonly used to ensure functions gracefully handle unexpected input.
function divide(a, b) {
if (typeof a !== 'number' || typeof b !== 'number') {
throw new Error("Invalid input, both arguments must be numbers");
}
if (b === 0) {
throw new Error("Division by zero");
}
return [Math.floor(a / b), a % b];
}
This divide
function guards against incorrect types and division by zero, returning the quotient and remainder in an array.
We can transcribe this function into Elixir with a few adjustments:
def divide(a, b) do
if !is_number(a) or !is_number(b) do
raise ArgumentError, "Invalid input, both arguments must be numbers"
else
if b == 0 do
raise ArithmeticError, "Division by zero"
else
{div(a, b), rem(a, b)}
end
end
end
In Elixir, the if
construct includes an implicit else
clause that returns nil
when the condition evaluates to false
and no explicit else
is provided. This means the guard pattern does not apply in the same way. Elixir provides more specific errors, such as ArgumentError
and ArithmeticError
, and utilizes tuples to offer a structured way to return the quotient and remainder.
Besides verifying that each argument is a number, the divide
function must also handle division by zero. In the code above, exceptions are raised to handle error conditions. However, in functional programming, it’s more common to avoid using exceptions for control flow, opting instead to return explicit success or error results.
Here is a more idiomatic functional approach:
def divide(a, b) do
if !is_number(a) or !is_number(b) do
{:error, "Invalid input, both arguments must be numbers"}
else
if b == 0 do
{:error, "Division by zero"}
else
{:ok, {div(a, b), rem(a, b)}}
end
end
end
This version avoids exceptions by explicitly returning a tuple with either {:ok, result}
in the case of success or {:error, reason}
in the case of failure. This pattern is common in functional programming, as it clearly separates successful and error states without resorting to exceptions.
Cyclomatic Complexity
The divide/2
function above has two if
statements, resulting in three code paths and a cyclomatic complexity score of 3.
In object-oriented programming languages like Java, cyclomatic complexity scores of 10 or even 20 may be acceptable. However, functional programming emphasizes simplicity, typically aiming for complexity scores under 10. Elixir encourages even lower scores, often limited complexity to below 3 or 4. Although the original if-else
solution has a complexity score of 3, it’s more idiomatic in Elixir to refactor this into smaller functions, each with a complexity of 1.
def divide(a, b) when is_number(a) and is_number(b) and b != 0 do
{:ok, {div(a, b), rem(a, b)}}
end
def divide(_, 0), do: {:error, "Division by zero"}
def divide(_, _), do: {:error, "Invalid input, both arguments must be numbers"}
Here, divide/2
is split into three function clauses:
- Valid Division: The first clause uses pattern matching and guards to ensure the arguments are valid numbers and the divisor is not zero.
- Division by Zero: The second clause explicitly handles the case where the divisor is zero.
- Invalid Input: The third clause catches any other invalid input cases.
By leveraging pattern matching and function guards, we eliminate the need for nested if
statements, reducing the cyclomatic complexity and making the code more idiomatic and easier to maintain.
Pattern Matching
With divide/2
returning an :ok
or :error
tuple, the client is responsible for handling potential errors. For instance, the client can choose to raise an error by matching on the result:
{:ok, result} = divide(10, 3)
# Success: the quotient is 3 and the remainder is 1
{:ok, result} = divide(10, 0)
# Throws a match error
Dividing 10 by 0 returns {:error, reason}
, and since the client is only matching on {:ok, result}
, it results in a pattern match error.
Case Switch
The client can use a case
statement to handle both success and error results:
def divide_result(a, b) do
case divide(a, b) do
{:ok, {quotient, remainder}} ->
"Division successful: Quotient = #{quotient}, Remainder = #{remainder}"
{:error, message} ->
"Division failed: #{message}"
end
end
divide_result(10, 3) # "Division successful: Quotient = 3, Remainder = 1"
divide_result(10, 0) # "Division failed: Division by zero"
divide_result(10, "a") # "Division failed: Invalid input, both arguments must be numbers"
The Cond Statement
Suppose we want to offer a 50% discount to children and a 70% discount to senior citizens. In JavaScript, we can solve this with guards and a switch
statement:
function calculateDiscountedPrice(age, price) {
if (typeof age !== 'number' || ! Number.isInteger(age) || typeof price !== 'number') {
throw new Error("Invalid input types: age must be an integer, price must be a number");
}
if (age < 0) {
throw new Error("Invalid age");
}
switch (true) {
case (age < 13):
return price * 0.5;
case (age >= 65):
return price * 0.7;
default:
return price;
}
}
In this code, we first validate that the age
is a positive integer and the price
is a number. If not, we throw an error. Then, using the switch
statement, we check the age and apply the appropriate discount. If no conditions are met, the default case returns the original price.
Now, let’s transcribe this into Elixir:
def calculate_discounted_price(age, price) do
if not is_integer(age) or not is_number(price) do
raise "Invalid input types: age must be an integer, price must be a number"
end
if age < 0 do
raise "Invalid age"
end
cond do
age < 13 ->
price * 0.5
age >= 65 ->
price * 0.7
true ->
price
end
end
Here, the logic is similar: we validate the inputs and use a cond
statement to check the age, applying discounts where appropriate. The true
case in cond
acts like the default case in JavaScript, returning the original price if no conditions are matched.
Next, let’s refactor this into more idiomatic Elixir:
def calculate_discounted_price(age, price) when is_integer(age) and is_number(price) and age >= 0 do
discounted_price =
cond do
age < 13 ->
price * 0.5
age >= 65 ->
price * 0.7
true ->
price
end
{:ok, discounted_price}
end
def calculate_discounted_price(_, _) do
{:error, "Invalid input: age must be a non-negative integer, and price must be a number"}
end
In this version, we use two calculate_discounted_price/2
function clauses. The first clause pattern-matches valid inputs and handles the logic, while the second handles invalid inputs by returning an error tuple, leaving error handling to the caller.
Erlang and Elixir embrace a “Let it crash” philosophy, which assumes that the caller is responsible for sending valid requests.
For example, below removes the defensive error handling and simply “lets it fail”:
def calculate_discounted_price(age, price) when is_integer(age) and is_number(price) and age >= 0 do
cond do
age < 13 ->
price * 0.5
age >= 65 ->
price * 0.7
true ->
price
end
end
The underlying theory is that in a complex system, it’s impractical to handle every transient error locally. Instead, it’s more effective to trust the callers and rely on the supervision tree to manage exceptions and recover gracefully.
Looping (Recursion)
In imperative languages like JavaScript, we often use loops such as for
, while
, or do...while
to perform repetitive tasks.
For example, summing numbers from 1 to n
might look like this:
function sumUpTo(n) {
if (typeof n !== 'number' || !Number.isInteger(n) || n < 0) {
throw new Error("Input must be a non-negative integer");
}
let sum = 0;
for (let i = 1; i <= n; i++) {
sum += i;
}
return sum;
}
sumUpTo(5); // Returns 15
sumUpTo(-1); // Raises an error
sumUpTo(3.5); // Raises an error
Elixir does not have traditional loop constructs. Instead, it uses recursion.
Here is that logic refactored from a loop to a recursive function:
function sumUpTo(n) {
if (typeof n !== 'number' || !Number.isInteger(n) || n < 0) {
throw new Error("Input must be a non-negative integer");
}
if (n === 0) {
return 0;
}
return n + sumUpTo(n - 1);
}
sumUpTo(5); // Returns 15
sumUpTo(-1); // Raises an error
sumUpTo(3.5); // Raises an error
Evaluates as:
sumUpTo(5)
5 + sumUpTo(4)
5 + 4 + sumUpTo(3)
5 + 4 + 3 + sumUpTo(2)
5 + 4 + 3 + 2 + sumUpTo(1)
5 + 4 + 3 + 2 + 1 + sumUpTo(0)
5 + 4 + 3 + 2 + 1 + 0 = 15
- Guard Conditions: Checks if the input is a non-negative integer.
- Base Case: If
n
is 0, the function returns 0, terminating the recursion. - Recursive Case: If
n
is greater than 0, the function calls itself withn - 1
and addsn
to the result, continuing the recursion until it reaches the base case.
Here is the equivalent code transposed into Elixir:
def sum_up_to(n) when is_integer(n) and n >= 0 do
if n == 0 do
0
else
n + sum_up_to(n - 1) # Recursive call is not in the tail position
end
end
sum_up_to(5) # Returns 15
sum_up_to(-1) # Raises an error
sum_up_to(3.5) # Raises an error
Evaluates as:
sum_up_to(5)
5 + sum_up_to(4)
5 + (4 + sum_up_to(3))
5 + (4 + (3 + sum_up_to(2)))
5 + (4 + (3 + (2 + sum_up_to(1))))
5 + (4 + (3 + (2 + (1 + sum_up_to(0)))))
5 + (4 + (3 + (2 + (1 + 0))))
5 + 4 + 3 + 2 + 1 + 0 = 15
Both the JavaScript and Elixir examples generate stack frames and evaluate the result at the end. For large inputs, this could cause a stack overflow.
Optimizing with Tail Recursion
Erlang and Elixir support tail-call optimization, allowing recursive functions to reuse stack frames when the recursive call is in the tail position. This prevents the call stack from growing with each recursive call, enabling the function to handle large inputs without risking a stack overflow.
Refactor to use tail recursion:
def sum_up_to(n) when is_integer(n) and n >= 0 do
do_sum_up_to(0, n)
end
defp do_sum_up_to(acc, 0), do: acc
defp do_sum_up_to(acc, curr), do: do_sum_up_to(acc + curr, curr - 1)
sum_up_to(5) # Returns 15
sum_up_to(-1) # Raises an error
sum_up_to(3.5) # Raises an error
-
Guard Conditions: The
sum_up_to/1
function uses guards to ensure that the input is a non-negative integer. This prevents invalid inputs from being processed. -
Base Case: The recursive function
do_sum_up_to/2
terminates whencurr
reaches0
. At this point, the accumulated sum (acc
) is returned as the result. -
Recursive Case: In each recursive call, the current value (
curr
) is added to the accumulator, and the current value is decremented by 1. This process continues until the base case is reached.
Evaluated as:
sum_up_to(5)
do_sum_up_to(0, 5)
do_sum_up_to(0 + 5, 4)
(acc = 5, curr = 4)do_sum_up_to(5 + 4, 3)
(acc = 9, curr = 3)do_sum_up_to(9 + 3, 2)
(acc = 12, curr = 2)do_sum_up_to(12 + 2, 1)
(acc = 14, curr = 1)do_sum_up_to(14 + 1, 0)
(acc = 15, curr = 0)15
(Base case: curr = 0, returns the accumulated valueacc = 15
)
This does not maintain intermediate state on stack frames, allowing the compiler to optimize and reuse stack frames making recursion efficient and safe for large inputs.
Conclusion
Elixir centers its control flow around pattern matching, which may initially be challenging for developers transitioning from other languages. However, it reduces complexity and improves maintainability, making it a strategic choice for building scalable and reliable systems.