Elixir Syntax Series

  1. Data Types and Collections
  2. Variables
  3. Control Flow
  4. Functions

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:

  1. Valid Division: The first clause uses pattern matching and guards to ensure the arguments are valid numbers and the divisor is not zero.
  2. Division by Zero: The second clause explicitly handles the case where the divisor is zero.
  3. 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
  1. Guard Conditions: Checks if the input is a non-negative integer.
  2. Base Case: If n is 0, the function returns 0, terminating the recursion.
  3. Recursive Case: If n is greater than 0, the function calls itself with n - 1 and adds n 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 when curr reaches 0. 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 value acc = 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.