17. Effects of your own: effect and with handle
!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()))
}
Open in playground
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)))
}
Open in playground
[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()))
}
Open in playground
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])))
}
Open in playground
[101, 102, 103]
The v1 boundary
- An arm is one ordinary call and its return value is the result (tail resumption). There is no storing the continuation to resume later and no resuming twice — those need continuation capture, which is not in this tier.
- For "the operation does not come back to the call site", use the failure machinery
that already exists:
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.
The full rules are in spec.en.md §6.5 and the design trade-offs in effects-design.md.
That is every core feature of Dawn. The deeper reference is spec.en.md and
the design trade-offs are in design.en.md. Those two are written in
Chinese and translated: they are living documents, edited in Chinese by every change to
the language, so the Chinese half is the original and the English half is registered
against it. The rest of docs/ is monolingual — its reader is the author, and prose that
has to be translated before it can be written is prose that does not get written.