cio
is an (untyped) structural effects-and-handlers library for Racket.
This was primarily a learning project for me to understand how effects-and-handlers and call/cc
and the like slot together. If you want to use it, go ahead! The implementation is fairly minimal. Be warned it is not well-tested, and not guaranteed to behave with other continuation-based libraries (though it should).
(define (write msg)
(suspend `(write ,msg)))
(define computation
(λ () (begin (write "hello") (write "world!"))))
(try (computation)
[`(write ,msg)
(println msg) (resume)])
(try (computation)
[`(write ,msg)
(println (format "I see you tried to print '~a'. Not so fast!" msg)) (resume)])
(define (read)
(suspend `(read)))
(define computation-ii
(λ () (begin (write "hello--") (write (string-append "hello " (read))))))
(try (computation-ii)
[`(write ,msg)
(println msg) (resume)]
[`(read)
(println "(what's your name)")
(resume (read-line))])
The design of cio
is primarily modelled after Effekt: though lacking the types, of course. cio
provides four primitives: try
, suspend
, resume
, and resume/suspend
.
try
steps a computation to a value. If in that process, an effect is raised by suspend
, and an appropriate handler (case) is provided for it within the try
body, the effect is handled there and the computation is (possibly) resumed.
There is a distinction between deep and shallow try
handlers. cio
supports both - deep handlers are used by default, but shallow handlers can be used by inserting the #:shallow
parameter immediately following try
. Shallow handlers only trigger once, while deep handlers will handle future effects after any resumption. Deep handlers are considerably more useful and intuitive, and combined with mutable state can easily model shallow handlers - thus they are the default.
suspend
takes a value, treated as an effect, and pops it up the call stack to the nearest try
handler. It should be noted that suspend takes a value. cio
is structural, and effect handlers do no more than match upon values. If the user wishes to distinguish between different kinds of effects (which they probably should), the value in suspend
must be wrapped in a tag corresponding to a tag in a handler.
resume
resumes a computation, optionally with a value. resume/suspend
resumes a computation with an effect. They are only usable within the handlers of a try
block. When an effect is raised from the computation of a try
block, and caught by a handler, a continuation to the rest of the computation is bound to a hidden continuation variable, which resume
operates upon. This means that it can be difficult to operate on an external try
block within another nested try
block - however, resume
is expanded before packing it up in a function, meaning this can be worked around with mutable state, Effekt-style.
Resumption is an extremely powerful feature. Suspension alone only allows the implementation of exceptions. Suspension and resumption without a value allows the implementation of generators. Suspension and resumption with a value allows the implementation of async/await. Suspension and resumption with another effect allows the implementation of bidirectional control flow. This makes for a very powerful - possibly too powerful - abstraction over all non-local control flow. Whether this can be done in a controlled and well-typed fashion is an active area of research.
A lot of wonderful papers on effects-and-handlers have been published, most of them in the last couple of years. If you are interested in learning more, I recommend reading the following papers / listening to the following talks, in order: