Skip to content

Instantly share code, notes, and snippets.

@eamelink
Last active April 10, 2024 15:57
Show Gist options
  • Save eamelink/4466932a11d8d92a6b76e80364062250 to your computer and use it in GitHub Desktop.
Save eamelink/4466932a11d8d92a6b76e80364062250 to your computer and use it in GitHub Desktop.
Recursion and Trampolines in Scala

Recursion and Trampolines in Scala

Recursion is beautiful. As an example, let's consider this perfectly acceptable example of defining the functions even and odd in Scala, whose semantics you can guess:

def even(i: Int): Boolean = i match {
  case 0 => true
  case _ => odd(i - 1)
}

def odd(i: Int): Boolean = i match {
  case 0 => false
  case _ => even(i - 1)
}

We've defined even in a very natural way: an value is even when it's zero, or when it's odd after subtracting one. Odd is defined in a similar way.

Let's ignore the problem this code has with negative integers, and the fact that an O(n) algorithm for computing even and odd is not ideal, and focus on a single problem: this code breaks for large n:

scala> even(5000)
res3: Boolean = true

scala> even(50000)
java.lang.StackOverflowError
  at Trampolines$Stack$.odd(trampolines.scala:14)
  at Trampolines$Stack$.even(trampolines.scala:9)
  at Trampolines$Stack$.odd(trampolines.scala:14)
  at Trampolines$Stack$.even(trampolines.scala:9)
  at Trampolines$Stack$.odd(trampolines.scala:14)
  at Trampolines$Stack$.even(trampolines.scala:9)
  ...
  at Trampolines$Stack$.even(trampolines.scala:9)
  at Trampolines$Stack$.odd(trampolines.scala:14)

In the remainder of this post we look at various strategies for preventing recursive programs from running out of memory.

But what's the problem?

The problem manifests itself in the stack trace: even calls odd, then odd calls even, which calls odd again. Each of these invocations causes the creation of a stack frame, eating away the space that's reserved for them.

Keeping all the stack frames would not really be necessary: when even calls odd, that is the very last thing that the even function will ever do! When odd returns, even itself returns immediately with that same value.

When we compute even(500), after a bunch of recursive calls, the 501st stack frame will be created, for the invocation of even(0), which returns true, and the 500 method calls that are left on the stack will one after another all return that same value, until the whole stack is unwinded.

Some languages or runtimes are smart enough to figure out that if a calling function will simply return the value that a function it calls returns, it does not need to allocate a new stack frame for the callee, but can reuse the stack frame of the caller for that.

Unfortunately, the JVM does not support this.

And so Scala, as long as it sticks to JVM method calling semantics, also can not support this.

A bit of light at the end of the tunnel

Luckily, there is one special case of tail calls which Scala treats differently, and that is the case of tail recursion. If a method does a tail call to itself, it's called tail recursion, and Scala will rewrite the recursion into a loop that does not consume stack space.

Many recursive algorithms can be rewritten in a tail recursive way. For our even and odd problem, this is a possible solution:

def even(i: Int): Boolean = i match {
  case 0 => true
  case 1 => false
  case _ => even(i - 2)
}

def odd(i: Int): Boolean = !even(i)

We've rewritten even to be tail-recursive, and now Scala will optimize it for us:

scala> even(500000000)
res25: Boolean = true

it no longer blows up for large numbers.

The @tailrec annotation is useful in these cases. It does not change how the function will be compiled, but it does trigger an error if the function is not actually tail-recursive. So this will protect you from accidentally changing a function that you want to be tail-recursive function into something that's no longer tail recursive.

You can try this out by putting the @tailrec annotation on the first and the latest version of even that we've defined.

Trampolines

Another way of solving the problem of consuming too much stack space, is by moving the computation from the stack to the heap.

Instead of letting the even function recursively call odd, we could also let it return some recipe to it's caller, how to continue with the computation. Let's look at the following code:

sealed trait EvenOdd
case class Done(result: Boolean) extends EvenOdd
case class Even(value: Int) extends EvenOdd
case class Odd(value: Int) extends EvenOdd

def even(i: Int): EvenOdd = i match {
  case 0 => Done(true)
  case _ => Odd(i - 1)
}

def odd(i: Int): EvenOdd = i match {
  case 0 => Done(false)
  case _ => Even(i - 1)
}

Here we defined a little language, EvenOdd, that can express three things:

  • The computation is Done, and we have the result value
  • We need to compute whether a value is Even
  • We need to compute whether a value is Odd

Our even and odd functions no longer return a boolean, but return an expression in this little language.

Finally, we create an interpreter, that will recursively evaluate expressions in this language:

// Using Scala's self recursive tail call optimization
@tailrec
def run(evenOdd: EvenOdd): Boolean = evenOdd match {
  case Done(result) => result
  case Even(value) => run(even(value))
  case Odd(value) => run(odd(value))
} 

Now we have expressed even and odd in their natural, mutually recursive way, and we still have a stack safe interpreter:

scala> run(even(5000000))
res1: Boolean = true  

The disadvantage of this is that this is significantly slower. Unfortunately, we can't seem to have our cake and eat it too :(

This strategy is sometimes called trampolining, because instead of creating a big stack, we go up to even, then down to run, then up to odd, then down to run, then up to even, down to run, etcetera. The size of our stack keeps growing and shrinking by one frame for every step in the computation. This looks a lot like going up and down on a trampoline :)

Generalizing

There is no need to specialize our little language to computing even and odd. We can also make a little language that can express recursion in a general way:

sealed trait Computation[A]
class Continue[A](n: => Computation[A]) extends Computation[A] {
  lazy val next = n
}
case class Done[A](result: A) extends Computation[A]

def even(i: Int): Computation[Boolean] = i match {
  case 0 => Done(true)
  case _ => new Continue(odd(i - 1))
}

def odd(i: Int): Computation[Boolean] = i match {
  case 0 => Done(false)
  case _ => new Continue(even(i - 1))
}

@tailrec
def run[A](computation: Computation[A]): A = computation match {
  case Done(a) => a
  case c: Continue[A] => run(c.next)
}

Here our even and odd functions don't return domain specific values, but a general value that indicates whether the computation is done, or whether more steps are needed. The latter includes the next step as a by-name parameter, that the tail recursive runner function can call.

Note that our run function is no longer tied to computing even and odd, it can compute anything.

TailRec in the standard library

Something similar in spirit, but with a better implementation is also available in the Scala standard library:

import scala.util.control.TailCalls.{ TailRec, done, tailcall }

def even(i: Int): TailRec[Boolean] = i match {
  case 0     => done(true)
  case _ => tailcall(odd(i - 1))
} 

def odd(i: Int): TailRec[Boolean] = i match {
  case 0     => done(false)
  case _ => tailcall(even(i - 1))
}

even(3000).result

Comparing performance

I compared the performance of these solutions with JMH, and these are the results:

[info] Benchmark                                       Mode  Cnt       Score      Error  Units
[info] Trampolines.GeneralTrampolineRunner.bench      thrpt   30   44916.024 ±  388.202  ops/s
[info] Trampolines.ScalaTrampolineRunner.bench        thrpt   30   52106.426 ±  408.242  ops/s
[info] Trampolines.SpecializedTrampolineRunner.bench  thrpt   30   94002.234 ± 1584.913  ops/s
[info] Trampolines.StackRunner.bench                  thrpt   30  358382.321 ± 6622.659  ops/s

As expected, the version that runs on the stack is the fastest. But remember that this is the version that breaks for a large number of recursions.

The specialized trampolining version, with the EvenOdd domain specific language and a runner optimized for this particular problem, takes about a 4 times speed hit compared to the stack version.

The general trampoline version that we defined here is about 2 times slower than the specialized version, and about 8 times slower than the stack version.

The TailRec version from the Scala standard library is about 20% faster than our general trampoline, making it about 7 times slower than the stack version.

Source code

The source code of the benchmarks (and all the code), is available on https://github.com/eamelink/scala-trampolines

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment