Effects and handlers
Let’s start with a very general definition that helps guide our intuition
While pure expressions like
1 + 2can 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 or more handlers, starting with with,
followed by the name of the effect and definitions for each of the effect’s declared operations.
Notice that the handler for throw does not call resume. This is intentional:
not resuming is how you abort – the rest of the computation is simply discarded.
Resumptive effects
Besides exceptions, we can also emulate other useful mechanisms with effects. For example, a generator function 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) }
When an effect operation fires, the handler receives the rest of the program –
everything that would have run after do yield(a) – as an implicit argument called resume.
For an effect yield(x: A): Unit handled in a try block of type T, resume has type Unit => T.
You can hover over resume in the editor to always see its exact type.
There are three ways of using resume:
- Not calling
resumeaborts the producer, and the continuation is discarded (as withthrowabove). - Calling
resumeonce is the ordinary case seen in generators and state (as withyieldabove). The producer continues with the value passed toresume. - Calling
resumemultiple times runs the rest of the computation multiple times: this is how nondeterminism and backtracking work.
For more details on effect handlers as a concept, see the Effect Handlers page.
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 using the effect keyword:
effect tell(): Int
This is syntactic sugar: a singleton effect declaration is exactly equivalent to an interface with a single operation of the same name,
and the concise handler syntax with tell { () => ... } is shorthand for with tell { def tell() = ... }:
// These two are equivalent:
effect tell(): Int
interface tell {
def tell(): Int
}
The handler for a singleton effect uses the same concise syntax:
import bench
def tellTime[A] { prog: () => A / tell }: A =
try { prog() }
with tell { () =>
resume(bench::timestamp())
}
Here, prog is expected to be passed a computation (block) and not as a value.
You can read more about their distinction in the section about computations.
Using the function tellTime that handles the effect tell of prog, we can write the following program for measuring the execution time for computing the first 1500 Fibonacci numbers:
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")
}
Chaining handlers
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) }
with syntax
with f(args); is syntactic sugar: it passes the remainder of the current block as the last block argument to f.
This lets deeply nested calls (such as handler calls) be written as a flat, non-nested sequence.
The following two forms are exactly equivalent:
// Nested form:
collect(limit) {
filter { x => x.mod(2) == 0 } {
fib()
}
}
// Flat form using `with`:
with collect(limit)
with filter { x => x.mod(2) == 0 }
fib()