Algebraic effects
Declare an effect, perform it at the call site, and handle it further up the stack - all without knowing the handler in advance.
Summary
Effects are declared with effect Name { fn op(args) -> RetType }. Code performs an operation with perform Name.op(args), which suspends until a handler intercepts it. Handlers use handle expr { Name.op(args) => resume(val) }.resume returns a value to the perform site and execution continues from there. Multi-shot resume lets a handler call resume more than once from a single perform, useful for backtracking and non-determinism. On the VM and JIT, a stack snapshot is captured at perform time and replayed for each resume.
Canonical
Effects let you perform an operation without knowing how it will be handled: the handler decides. Think of it as exceptions you can resume from.
-- declare an effect
effect Ask {
fn prompt(msg) -> str
}
-- perform an effect
fn greet() {
let name = perform Ask.prompt("name?")
return "Hello, {name}!"
}
-- handle the effect
let result = handle greet() {
Ask.prompt(msg) => resume("World")
}
println(result) -- Hello, World!resume returns a value to the perform site: execution continues from where it left off.
Effect with Accumulator
effect Log {
fn log(msg)
}
var logs = []
handle {
perform Log.log("first")
perform Log.log("second")
perform Log.log("third")
} {
Log.log(msg) => {
logs.push(msg)
resume(null)
}
}
println(logs) -- ["first", "second", "third"]The handle form can take a block as the computation (not just a function call).
Multi-Shot Resume
resume is multi-shot on every backend: an arm body can call it more than once and each invocation re-enters the captured continuation, returning the body's value back into the arm:
effect Choose { fn pick() }
fn run() {
let x = perform Choose.pick()
return x
}
var leaves = []
handle {
let r = run()
leaves.push(r)
} {
Choose.pick() => {
resume(1)
resume(2)
}
}
println(leaves) -- [1, 2]The VM and JIT capture a stack snapshot at perform time and replay it for each resume. The interpreter, lacking real continuations, gets the same observable result by re-evaluating the handle body with a per-call perform-override and unwinding the outer body via a delimited CF_HANDLE_DONE signal -- only triggered for arms that statically contain more than one resume call, so single-shot bodies keep their original cheap path.