Loops in Elixir

Introduction

In the following post we will look at a few ways to interate over the first one hundred integers (0 through 99) in Elixir calling FizzBuzz on each.

Elixir lacks the familiar for, while, until language control-flow constructs that exist in other languages, instead encouraging use of the enumeration capabilities available in the collection modules within the Elixir standard library.

The core requirements of a FizzBuzz, when passed an integer as a parameter, is to;

  • Return the string "FizzBuzz" when that integer divided by both three and five has a remainder of zero,

  • Return the string "Fizz" when that integer has a remainder of zero when divided by three,

  • Return the string "Buzz" when that integer has a remainder of zero when divided by five,

  • Otherwise, return the integer

This implementation will return the results as a list instead of simply printing to standard out.

FizzBuzz in Elixir

Implementing FizzBuzz in idiomatic Elixir is to use the pattern matching capabilities [1] inherent to the language.

In the following example, we do away with the switch/if/else language constructs by using a combination of anonymous function and pattern matching.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
def fb(n) do
  f = fn
    0, 0, _ -> "FizzBuzz" (2)
    0, _, _ -> "Fizz" (3)
    _, 0, _ -> "Buzz" (4)
    _, _, n -> n (5)
  end

  f.(rem(n, 3), rem(n, 5), n) (1)
end
1 In the body of fb we call an anonymous function that accepts three arguments, passing the remainder of n % 3, n % 5, and n respectively.
2 This clause matches when both n % 3 and n % 5 return zero. _ means that we don’t care about that parameter.
3 This clause matches when only n % 3 returns zero.
4 This clause matches when only n % 5 returns zero.
5 This final clause matches when neither n % 3 or n % 5 return zero.

The following example uses named functions to similar effect.

1
2
3
4
5
def fb(0, 0, _), do: "FizzBuzz"
def fb(0, _, _), do: "Fizz"
def fb(_, 0, _), do: "Buzz"
def fb(_, _, n), do: n
def fb(n), do: fb(rem(n, 3), rem(n, 5), n)

Of course, it is possible to write C in any language as shown in the following example.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
def fb(n) do
  cond do
    rem(n, 3) == 0 and rem(n, 5) == 0 ->
      "Fizzbuzz"

    rem(n, 3) == 0 ->
      "Fizz"

    rem(n, 5) == 0 ->
      "Buzz"

    n ->
      n
  end
end

Looping using Enum.map

We can use Enum.map to return a new collection containing the results of invoking our fb function 100 times. The number of times we wish to iterate on the FizzBuzz function we define as a range 0..n, and pass this range to Enum.map as the first parameter. Enum.map calls our fb function on each item in range.

In Elixir, a range is stored as a struct (start and end values) and is not expanded into, in our example, a collection of 100 individual integers. So using a range is space efficient.

1
2
3
def fizzbuzz(n) do
  Enum.map(0..n, &fb/1)
end

Looping using Pipes

Rewriting our fizzbuzz function to use a pipe (|>) may be easier to read. The range is "piped" into Enum.map as the first parameter.

1
2
3
4
def fizzbuzz(n) do
  0..n
  |> Enum.map(&fb/1)
end

Looping using the for special form

The for special form iterates over each item in a collection (the range 0..n in our example), and assigns each item to num, which is then passed to fb in the body of the for.

1
2
3
def fizzbuzz(n) do
  for num <- 0..n, do: fb(num)
end

Attentive readers may have noticed that in the Introduction I mentioned that Elixir does not have a for looping construct, and yet, here it is. The mechanics of for in Elixir are quite different than what you may be used to coming from other languages, even though in this contrived example the results are the same. In Elixir the thing on the left of the is pattern matched to the item in the collection on the right. Whereas in languages like Golang, a variable is assigned the value.

1
2
3
for i := 0; i < n; i++ {
    fb(i)
}

Recursion

An initial recursive algorithm to calculate FizzBuzz for the first 100 integers is defined below.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
def fizzbuzz_helper(0, _) do (1)
  [fb(0)]
end

def fizzbuzz_helper(n, list) do
  [fb(n) | fizzbuzz_helper(n - 1, list)] (2)
end

def fizzbuzz(n) do
  fizzbuzz_helper(n, [])
  |> Enum.reverse() (3)
end
1 Our base case that terminates recursion.
2 Prepend to the collection as the stack is unwound.
3 Reverse the collection so items are in ascending order.

This algorithm is correct but it is very inefficient. Our algorithm is O(3N), at least three times slower than Enum.map:

  • O(N) to iterate to the base case,

  • O(N) to unwind the stack, creating the collection as we go,

  • O(N) to reverse the collection.

For starters, we need to reverse the collection to return the items in ascending order which is a O(N) operation. We add new items to the front of a list and reverse the list as the last operation as this more efficient that adding items to the end of the list. Pre-pending an item to a list is always a O(1) cost, while appending an item is O(N) which would make our algorithm O(N^2) — polynomial time. Much, much slower than O(3N).

Computational complexity is O(N^2) as we will have to traverse the collection each time to add an item to the end, which has O(N) cost, in each call to fb, which itself is called N times (except the last). So O(N x N) is O(N^2).

Tail Recursion

But even worse, we have not implemented our algorithm in a way that is Tail Recursive.[2] The compiler must build a chain of stack frames on successive recursive calls containing the deferred operations [3] that will prepend the result of fb to the collection returned by fizzbuzz_helper, shown here [fb(n) | fizzbuzz_helper(n - 1, list)].

And not being Tail Recursive means that a large enough loop will overflow the stack and cause a runtime error. The BEAM virtual machine must allocate memory for each stack frame created, and if the number of recursion calls is too great, eventually the pool of memory allocated to the stack will be exhausted.

Lisps generally support tail call optimization.[4]. Racket and the Schemes, Common Lisp (most implementations), and Elixir all do. Clojure does not (a limitation of the JVM), and thus requires use of the Trampoline pattern.[5]. When recursion is implemented as a tail call — in other words, the recursive call is performed as the very last action in the function — the compiler is able to transform the recursive procedure into an iterative process (a GOTO). No stack frames are created and thus there is no danger of blowing the stack even for a very large number of iterations. In effect, recursion becomes as efficient as a for loop.

In contrasting iteration and recursion, we must be careful not to confuse the notion of a recursive process with the notion of a recursive procedure. When we describe a procedure as recursive, we are referring to the syntactic fact that the procedure definition refers (either directly or indirectly) to the procedure itself. But when we describe a process as following a pattern that is, say, linearly recursive, we are speaking about how the process evolves, not about the syntax of how a procedure is written.

— SICP (Structure and Interpretation of Computer Programs)

The changes in the following example allow for tail call optimization. We add the item to the collection before re-cursing, not after. An additional benefit, we no longer need to reverse the collection as items are added to the collection in ascending order.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
def fizzbuzz_helper(0, list) do
  [fb(0) | list]
end

def fizzbuzz_helper(n, list) do
  fizzbuzz_helper(n - 1, [fb(n) | list]) (1)
end

def fizzbuzz(n) do
  fizzbuzz_helper(n, [])
end
1 The result of fb is added to the list before recursion.

With these changes our algorithm becomes O(N).

In Conclusion

  • Use the capabilities of the standard library unless precluded by the use case. Otherwise for anyone reading your code, valuable thought cycles will be lost trying to understand why the wheel needed to be re-implemented.

  • Try to implement any recursive procedure in a way that is Tail recursive to allow the compiler to change this to an iterative process.

Tail recursion is not required for algorithms having a low computation complexity cost, for example calculating GCD recursively is N(log N), where the problem space is halved on each pass.

1
2
3
4
defmodule Gcd do
  def gcd(a, 0), do: a
  def gcd(a, b), do: gcd(b, rem(a, b))
end