!io is the one effect the compiler knows about. You can declare your own: a set of
operations whose implementation the caller picks at the point of use.
effect Ask {
fn ask() -> Int
}
fn sum_three() -> Int !Ask = ask() + ask() + ask()
pub fn main() -> Unit !io = {
with handle Ask { ask() => 42 }
println(to_string(sum_three()))
}
126
Three things are happening there:
effect Ask { ... }declares the effect and its operations. An operation is a function signature with no body — the body comes from the handler.sum_threecallsask()directly and writes!Askinto its signature. Leaving it out is an error, and the error names the two ways out.with handle Ask { ask() => 42 }installs the handler: the whole of the block after that line is inside its scope. The armask() => 42is a closure, callingask()calls it, and its return value is the value ofask().- That rest of the block is itself a closure, so it captures by value: a
vardeclared before thewith handlecan be neither read nor assigned after it, while avardeclared after it is fine. The diagnostic says so and suggests the two ways out, binding aletsnapshot before thewith handleor passing the value in as a parameter.
More than one operation, and operations with parameters#
An effect may have several operations, and a handler has to answer every one of them — no more and no fewer:
effect Log {
fn note(msg: String) -> Unit
fn level() -> Int
}
fn work(n: Int) -> Int !Log = {
note("working on ${n}")
n * level()
}
pub fn main() -> Unit !io = {
with handle Log {
note(m) => println("[log] ${m}")
level() => 3
}
println(to_string(work(7)))
}
[log] working on 7
21
An arm's body may do anything, io included. That counts against the block that
installed the handler — which is why main above is !io — and not against work,
which emitted the operation: work owes only !Log.
Who answers: the nearest one lexically#
A handler is found by where it is written, not by the runtime stack. An inner one shadows an outer one:
effect Ask {
fn ask() -> Int
}
fn twice() -> Int !Ask = ask() + ask()
pub fn main() -> Unit !io = {
with handle Ask { ask() => 1 }
println(to_string(twice()))
with handle Ask { ask() => 10 }
println(to_string(twice()))
}
2
20
An arm that emits its own effect again finds the outer handler — a handler does
not answer itself — so with handle Ask { ask() => ask() * 10 } means "take the answer
from outside and multiply it by ten", not an infinite loop.
A closure does not keep the handler it was written under. Carry it out of the block
and it carries the label with it: its row still says !Ask, and the handler that answers
is the one in scope where the closure is finally called. The type says who has to
supply a handler, not what any particular arm would have done, so a handler with pure arms
leaves you an !Ask closure just as an io one does.
A function type may therefore name an effect, and it means exactly what it reads as.
fn(f: fn() -> Int !Ask) says "whoever calls f supplies the Ask handler", which is
how the call really works. fn(f: fn() -> Int !e) is the other spelling rather than a
workaround: an effect variable takes a closure with any row at all and forwards that row
into your own.
Higher-order functions need no change#
An effect variable (!e) forwards a named effect along with everything else, so map,
fold and for loops carry on as before:
effect Ask {
fn ask() -> Int
}
fn shifted(xs: List[Int]) -> List[Int] !Ask = map(xs, x => x + ask())
pub fn main() -> Unit !io = {
with handle Ask { ask() => 100 }
println(to_string(shifted([1, 2, 3])))
}
[101, 102, 103]
Handler state: a var in the arm table#
An arm table may open with var declarations. Each one is a cell: mutable state that
belongs to this one installation of the handler. The arms read and write it, and the block
after the with handle reads it afterwards, which is how a handler that accumulates
something hands what it accumulated back to the code that installed it.
effect Spend {
fn spend(item: String, n: Int) -> Bool
}
## No budget parameter, no running total: `shop` only knows that it asks.
fn shop(orders: List[(String, Int)]) -> List[String] !Spend = {
var bought: List[String] = []
for o in orders {
let (item, cost) = o
if spend(item, cost) {
bought = bought ++ [item]
}
}
bought
}
pub fn main() -> Unit !io = {
with handle Spend {
var left: Int = 10
var skipped: List[String] = []
spend(item, n) =>
if n <= left {
left = left - n
true
} else {
skipped = skipped ++ [item]
false
}
}
let bought = shop([("bread", 3), ("cheese", 12), ("apples", 4)])
println("${bought} left=${left} skipped=${skipped}")
}
["bread", "apples"] left=3 skipped=["cheese"]
shop has no budget parameter, no accumulator and no return value carrying one. The
budget is the handler's, the two cells are where it is kept, and the last line of main
is the block after the with handle reading them. There is no return arm; the cells are
the only surface a stateful handler hands anything back through.
Cells must be declared before the arms, and their type annotation is not optional: inside the arm table there is no context to infer it from.
Two rules keep a cell apart from an ordinary var:
- An arm is a closure, so it cannot write to an enclosing
var(that is the rule from the start of this section). Its own cells are the exception: they are this installation's state rather than a binding captured from outside it. - A cell cannot leave. A lambda written inside an arm may not capture one, and a cell has no type a program can write down, so no second name reaches it.
Each installation gets its own cells. A nested with handle for the same effect keeps a
separate set, and what the inner one accumulates does not reach the outer one.
The standard library's effects, and a file system that is a table#
std/io declares effects of the kind this chapter has been writing for the parts of the
outside world a program usually just calls: Fs (files), Env (the working directory
and environment variables), Proc (running another program), Exit (ending the process)
and Console (writing to stdout and stderr). io.read_file and the rest of the file
functions are !Fs, io.getenv and io.cwd are !Env, io.run is !Proc. So a
signature says which part of the world a function touches, and a test can answer that
effect with a fake.
std ships one fake ready-made: std/memfs answers Fs from a table in memory.
memfs.with_fs(tree, body) runs body against tree and hands back the result together
with the tree as body left it. Env has two operations, and a fake for it is a
with handle of two lines:
use std/io
use std/io.{Fs, Env}
use std/memfs
use std/str
# The signature says it all: this touches files and nothing else.
fn archive(path: String) -> Result[Int, ForeignError] !Fs = {
let text = io.read_file(path)?
io.write_file(path ++ ".bak", text)?
Ok(len(str.split(text, "\n")))
}
# And this reads the environment, and nothing else.
fn greeting() -> String !Env =
match io.getenv("USER") {
Some(name) -> "hello, ${name}"
None -> "hello, stranger"
}
pub fn main() -> Unit !io = {
let tree = memfs.put(memfs.empty(memfs.BASE), "notes.txt", "one\ntwo\nthree")
let (lines, after) = memfs.with_fs(tree, () => archive("notes.txt"))
println("${lines}")
println("${memfs.file_paths(after)}")
println("${memfs.text(after, "notes.txt.bak")}")
with handle Env {
env_cwd() => "/home/ada"
env_get(name) => if name == "USER" { Some("ada") } else { None }
}
println(greeting())
}
Ok(3)
["/dawn-memfs/notes.txt", "/dawn-memfs/notes.txt.bak"]
Some("one\ntwo\nthree")
hello, ada
archive read one file and wrote another, and nothing touched a disk. memfs.with_fs is
pure, so the same lines work in a test block with no temporary directory to make or
clean up. A program meets the real world by installing the production handlers once, at
the top: io.with_fs_real(() => ...), and likewise with_env_real, with_proc_real,
with_exit_real and with_console_real, each of them !io. (println itself is still
plain !io; Console is there for code whose output a test wants to read back.)
Control arms: ctl, resume k and discard#
Every arm so far answers its operation and lets the caller carry on. An effect declared
ctl may also have control arms, which take hold of the rest of the computation
instead: op(args) resume k => ... binds k, a function that resumes the code after the
operation with the value you pass it. The arm's value is the value of the whole
with handle block, so an arm that does not call k ends the block early, with an
answer of its own.
ctl effect Check {
fn check(ok: Bool, why: String) -> Unit
}
fn validate(age: Int) -> String !Check = {
check(age >= 0, "negative")
check(age < 150, "too large")
"age ${age}"
}
fn close(name: String) -> Unit !io = println("closed ${name}")
fn admit(age: Int) -> String !io = {
with handle Check {
check(ok, why) resume k =>
if ok {
k(())
} else {
discard(k)
"rejected: ${why}"
}
}
with log <- bracket("log", close)
"${log}: ${validate(age)}"
}
pub fn main() -> Unit !io = {
println(admit(30))
println(admit(-1))
println(admit(200))
}
closed log
log: age 30
closed log
rejected: negative
closed log
rejected: too large
For admit(-1), validate asks check(false, "negative"), and the arm does not resume:
the second check and the "age ..." line never run, and "rejected: negative" is the
answer of admit's whole block. For admit(30) the arm calls k(()) at each check, and
what the rest of the block finally produces comes back out of k as the arm's value.
A continuation is one-shot: resuming it twice is a panic. One you will never resume is
abandoned with discard(k), and that is what runs the cleanups between the operation and
the handler; here it is the bracket that prints closed log, which ran on all three
paths. Just dropping k runs nothing, on purpose: a cleanup that ran whenever a
collector got round to it would behave differently on the two backends. k is an
ordinary function value, so it may also be stored and resumed later. std/io's Exit
is a ctl effect for the reason this section starts from: a test answers io.exit(1)
with an arm that does not resume and hands the status back as a value, where the
production arm ends the process.
The v1 boundary#
- For "the operation does not come back to the call site" the failure machinery is
usually the answer:
Result+?,catch_fault/catch_panic/bracket. - An effect takes no type parameters (there is no
effect Yield[T]). - comptime and const initialisers raise no named effect and cannot install a handler.
Trait and impl methods do take labels, and so does any written function type (an
aliastarget, a record field, a parameter); what an impl still owes is a row whose labels match the trait's exactly, because each label is one of the method's hidden parameters. - A labelled function can be passed as a function value: the label goes into the value's
type, and the call site supplies the handler. Only an operation cannot, since there
is no function symbol behind it; wrap it in a lambda (
() => ask()). - An arm is a closure, so it cannot write to an enclosing
varand cannotreturnorbreakits way out. Its own cells are the exception, and they are the section above.
The full rules are in spec.en.md §6.5 and the design trade-offs in effects-design.md.
See also: spec.en.md §6.5, §11