Effects and handlers

Let’s start with a very general definition that helps guide our intuition

While pure expressions like 1 + 2 can be locally reduced to a value (e.g. 3), an effectful expression depends on or modifies its context.

Effect handlers in Effekt allow us to decouple the mere mentioning of an effect operation from its meaning. In short:

  • Effect Signatures declare which effect operations are available and their types
  • Effect Operations are a request to the context
  • Effect Handlers handle these requests and provide meaning to effect operations

Non-resumptive effects

For getting started with effects and handlers, we define the popular exception effect, consisting just of one effect operation throw:

interface Exception {
  def throw(msg: String): Nothing
}

We declare the effect signature as an interface (like those that objects implement). An interface starts with the interface keyword, followed by its name. Enclosed in curly braces, operations belonging to this interface are declared with their signature.

While calling a method on an object would require us to know the receiver (e.g. exc.throw("boom!")), every operation can also be used as an effect by omitting the receiver and prepending the keyword do:

def div(a: Double, b: Double) =
  if (b == 0.0) { do throw("division by zero") }
  else { a / b }

Using throw as an effect operation means that we do not know (and maybe do not care) who the receiver (and thus implementor) is. We simply express that we want to throw and leave it to the context to decide what to do.

The return type of non-recursive functions can be left out and is inferred. In our example, it is Double / { Exception }, that is, the return type Double is followed by a slash and the effects that need to be handled by the calling context.

Whereas operations introduce effects, handlers offer a way of discharging them:

def unsafeDiv(a: Double, b: Double): Double / {} =
  try {
    div(a, b)
  } with Exception {
    def throw(msg) = {
      panic(msg)
    }
  }
unsafeDiv(42.0, 0.0)

Each handler consists of a try block, followed by one more handlers, starting with with, followed by the name of the effect and definitions for each of the effect’s declared operations.

Resumptive effects

Besides exceptions, we can also emulate other useful mechanisms with effects. For example, a generator functions using the Yield effect.

interface Yield[A] {
  def yield(x: A): Unit
}

When invoking the yield operation, we pass a value to be yielded to the lexically nearest handler, discharging the Yield effect. Recall our fib functions from earlier. We may also write it as a generator that runs infinitely while yielding each Fibonacci number in the process.

// Infinite fibonacci sequence as a generator function
def fib(): Unit / { Yield[Int] } = {
  def inner(a: Int, b: Int): Unit = {
    do yield(a)
    inner(b, a + b)
  }
  inner(0, 1)
}

// generate all fibonacci numbers up until the given limit.
def genFibs(limit: Int): List[Int] / {} = {
  var count = 0
  var fibs = []
  try {
    fib()
  } with Yield[Int] {
    def yield(x) =
      if (count < limit) {
        count = count + 1
        fibs = Cons(x, fibs)
        resume(()) // <- we resume the computation where yield was invoked
      }
  }
  fibs
}
genFibs(15).foreach { x => println(x) }

Singleton operation

Often, we want to declare an interface that is entirely defined by just one operation (like a “single-abstract-method” in Java). In this case, you can declare it as a singleton operation:

effect tell(): Int

The handler for this interface uses a slightly different more concise syntax like it is used in the literature:

import bench

def tellTime[A] { prog: () => A / tell }: A =
  try { prog() }
  with tell { () =>
    resume(bench::timestamp())
  }
tellTime {
  val start = do tell()
  val fibs = genFibs(1500)
  val end = do tell()
  println("took " ++ show(end - start) ++ "ns to generate " ++ fibs.size.show ++ " numbers")
}

with handler

We can decompose genFibs into a more general helper function collect that collects the yielded values by abstracting over the generating program:

def collect[A](n: Int) { prog: () => Unit / Yield[A] }: List[A] = {
  var yielded: List[A] = []
  var count = 0
  try {
    prog()
  } with Yield[A] {
    def yield(x) =
      if (count < n) {
        yielded = Cons(x, yielded)
        count = count + 1
        resume(())
      }
  }
  yielded
}

Let us define another handler for Yield to filter values:

def filter[A] { keep: A => Bool } { prog: () => Unit / Yield[A] }: Unit / Yield[A] =
  try prog()
  with Yield[A] {
    def yield(x) = if (keep(x)) { do yield(x); resume(()) } else { resume(()) }
  }

We can avoid the nesting of higher-order function calls like collect[Int](limit) { filter { x => x.mod(2) == 0 } { fib() } } and write:

def evenFibs(limit: Int): List[Int] = {
  with collect(limit)
  with filter { x => x.mod(2) == 0 }
  fib()
}
evenFibs(15).foreach { x => println(x) }