These examples should be preceded by a careful reading of the Cangjie effect handlers tutorial
To use effects, we must first define a type that inherits from Command<T>:
class Example <: Command<Unit> {}
We can peform this effect by passing an instance in a perform expression:
perform Example()
To fully use effects, we have to use a try-handle expression:
try {
println("one")
perform Example()
println("three")
} handle (e: Example) {
println("two")
resume
}
println("four")
The control flow of this can be explained by looking at the output of the program:
one
two
three
four
The full listing can be found in 01basic.cj
In addition to changing control flow, perform can return a value and
resume can take an argument:
try {
println("one")
var foo = perform Example()
println("three: ${foo}")
} handle (e: Example) {
println("two")
resume with 99
}
println("four")
This time, the output will be:
one
two
three: 99
four
The full listing is in 02value.cj
Effect handlers have similar behaviour to exceptions when it comes to
handler resolution. When we perform an effect, the runtime searches up
the stack for the first handler than handles the type of effect we have
performed. However, the resume command returns the flow of control at
the point where the effect was performed rather than exit the handler
as is the case with exceptions:
try {
perform One()
try {
perform One()
perform Two()
} handle (e: One) {
println("One inner")
resume
} handle (e: Two) {
println("Two")
resume
}
perform Two()
} handle(e: One) {
println("One")
resume
}
The resulting output will be
One
One inner
Two
An exception has occurred:
UnhandledCommand: Unhandled command
The full listing is in 03nested.cj
So far, all the examples we have shown have resumed immediately after handling the effect. However, we do have the option of not resuming. If we want to ignore the resumption, or store it for later use, we can used the "deferred" handle block as shown below:
handle(e: Effect, res: Resumption<Unit>) {
...
}
We can use this to implement behaviour equivalent to classical exceptions, as shown in 04exceptions.cj:
class EffException <: Command<Unit> {
public EffException(let msg: String) {}
}
main() {
try {
println("one")
perform EffException("error")
println("three")
} handle(e: EffException, _: Resumption<Unit>) {
println("two: ${e.msg}")
}
println("four")
}
The output of this program will be:
one
two: error
four
the line
println("three")
will never execute.
We have the option of defining a default handler for a Command<T>,
meaning that an unhandled effect will be handled by a global handler for
that type.
A default handler is defined by defining the defaultImpl instance method:
class Default <: Command<Unit> {
public func defaultImpl() {
println("default")
}
}
Running the following program
println("one")
perform Default()
println("two")
will result in
one
default
two
with no errors produced
The full listing is in 05default.cj
Note: This example is a design pattern, in the sense that it can be adapted to deal with a large number of realistic and useful scenarios.
Dependency inject is widely used in real-world applications. This is often provided by a heavyweight framework.
However, effect handlers can provide dependency injection as a built-in language feature.
Given an Alert effect:
class Alert <: Command<Unit> {
Alert(let message: String) {}
}
We then abstract alert notifications from our application:
func app() {
var error = true
if (error) {
perform Alert("error")
}
}
Then we define different handlers that inject the dependencies and run the application:
func stdOutHandler(fn: () -> Unit) {
try {
fn()
} handle(e: Alert) {
println(e.message)
resume
}
}
func popUpHandler(fn: () -> Unit) {
try {
fn()
} handle(e: Alert) {
makePopup(e.message)
resume
}
}
main() {
stdOutHandler {
app()
}
// or
popUpHandler {
app()
}
}
Note the trailing closure syntax:
popUpHandler {
application()
}
is equivalent to
popUpHandler({ => application() })
The full listing can be found in 06dependencyinjection.cj
Note: this example is a design pattern, i.e. it can be adapted and customized to provide a recipe for many important practical scenarios.
One of the most powerful features of effect handlers is the ability to implement multiple types lightweight concurrency.
07concurrency.cj implements a very basic round-robin scheduler that allows two "threads" to execute and interleave cooperatively
func rrScheduler(one: () -> Unit, two: () -> Unit): Unit {
try {
one()
} handle(_: Yield, r: Resumption<Unit, Unit>) {
rrScheduler(u, { => resume r})
}
}
func ping() {
println("Ping")
perform Yield()
println("Ping")
perform Yield()
}
func pong() {
println("Pong")
perform Yield()
println("Pong")
perform Yield()
}
main() {
rrScheduler(ping, pong)
}
This will output
Ping
Pong
Ping
Pong
Note: This more complex example is based on the dependency injection design pattern, also using global handlers to achive a challenging programming task, memoizing recursive functions.
08memoisation.cj shows an example of using effect handlers to cache results from recursive fibonacci calls.
We start by defining an effect that represents a fibonacci computation:
struct Fibonacci <: Command<Int64> & Hashable & Equatable<Fibonacci> {
Fibonacci(let n: Int64) {}
public override func defaultImpl() {
if (n <= 1) {
1
} else {
fibonacci(n-1) + fibonacci(n-2)
}
}
public override operator func ==(other: Fibonacci) { n == other.n }
public override operator func !=(other: Fibonacci) { n != other.n }
public override func hashCode() { n.hashCode() }
}
This uses default handlers to compute a traditional recursive fibonacci computation.
We wrap this to expose a normal API to the user:
func fibonacci(n: Int64) {
perform Fibonacci(n)
}
Calling this in a loop like below will be very slow, due to repeated exponential complexity function calls.
main() {
for (i in 0 .. 100) {
fibonacci(30)
}
}
However, we can see that this can be optimised by memoizing previously computed result:
func cache<Cmd, Result, Return>(fn: () -> Return): Return where Cmd <: Hashable & Equatable<Cmd> & Command<Result> {
let map = HashMap<Cmd, Result>()
try {
fn()
} handle(cmd: Cmd) {
let result = match (map.get(cmd)) {
case None =>
let result = perform cmd
map.add(cmd, result)
result
case Some(cached) =>
cached
}
resume with result
}
}
This function runs code in a try-handle block and handles generic
commands. If the command has been cached, it returns the previous
result; otherwise, it re-performs the effect. In the case of our
Fibonacci effect, this will run the default handler.
When we re-run the function with our cache handler, the performance will now be linear.
main() {
cache<Fibonacci, Int64, Unit> {
for (i in 0 .. 100) {
fibonacci(30)
}
}
}