Skip to content

CJPLUK/effects-tutorial

Repository files navigation

Cangjie Effect Handlers Examples

These examples should be preceded by a careful reading of the Cangjie effect handlers tutorial

1. Simple Example

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

2. Producing a Value

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

3. Nested Effects and Dynamic Binding

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

4. Deferred Resumptions and Implementing Exceptions

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.

5. Default Handlers

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

6. Dependency Injection

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

7. Custom Concurrency

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

8. Memoization

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)
        }
    }
}

About

CJ effects tutorial

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Contributors 2

  •  
  •