08 — Effects (the heart of vibe)
Previous: Structs, enums, and match
日本語版: 08_effects.vibe.md
A function in vibe is pure unless its type says otherwise. Anything it
can do besides compute — fail, print, read a file — is named in a with
clause on the signature, and that clause propagates to every caller
until someone handles it.
That is the whole mechanism. This chapter is what it looks like.
Declaring that a function can fail
Exception is the effect for failure. A function that may throw says so,
and callers inherit the obligation:
fn risky(x: Int) -> Int with Exception {
if x == 0 {
perform Exception::Throw("division by zero")
}
100 / x
}
fn main with Console {
let safe = handle {
risky(0)
} with {
Exception::Throw(message) => {
println("exception: \{message}")
(-1)
}
}
let fine = handle {
risky(4)
} with {
Exception::Throw(_) => -1
}
println("safe = \{safe}")
println("fine = \{fine}")
}
exception: division by zero
safe = -1
fine = 25
handle { body } with { Exception::Throw(...) => ... } is the boundary. Inside it,
risky may throw; outside it, main has no Exception in its row,
because the obligation was discharged.
throw("...") — the spelling chapter 2
used — is the short form of perform Exception::Throw("..."): the same
operation, and one handler catches both.
Exception is abortive: a throw does not come back. The handler arm's
value becomes the value of the whole handle — that is why safe is
-1 and fine is 25. There is no resume in an Exception arm.
One spelling note about that first arm: it ends in (-1) because a
line that starts with an operator continues the previous line — a
bare -1 after the println would parse as println(...) - 1
(#2206). An arm whose whole body is the value, like Throw(_) => -1,
needs no parentheses. Pitfalls has the rule.
Written without a type argument, Exception is erased: it accepts any
Exception[E], and its payload arrives as a string. When you want the
error type preserved, write Exception[E] — see
the exception effect.
Declaring your own effect
An effect declaration is a list of operations with no implementation.
The caller supplies the implementation, in the handler. This is the part
Exception is a special case of:
effect Ask {
Value(String) -> Int
}
fn answer_of(q: String) -> Int with Ask {
perform Ask::Value(q) + 1
}
fn main with Console {
let v = handle {
answer_of("life")
} with {
Ask::Value(_q) => resume(41)
}
println("v = \{v}")
}
v = 42
answer_of does not know where the number comes from. It performs
Ask::Value and continues with whatever the handler resumes — here 41,
so answer_of returns 42. Unlike Exception, Ask is resumable:
resume(v) sends v back to the perform site and the function carries
on.
Resumption is one-shot and tail-resumptive: a handler arm resumes at most once, as its last act.
Reach for your own effect when the caller genuinely has to swap the
implementation — a clock in tests, a different source for a value. For
ordinary failure use Exception; for local state try let mut first.
Effects vs let mut has the
criteria.
Rows can be variables
A higher-order function should not have to know which effects the function it was handed performs. Write the row as a variable and it carries whatever arrives:
fn apply_twice(f~: (Int) -> Int with e, x~: Int) -> Int with e {
f(f(x))
}
fn main with Console {
println("apply_twice = \{apply_twice(f=(n) -> n * 2, x=10)}")
}
apply_twice = 40
apply_twice is pure when f is pure, and carries Exception when f
throws. One definition, both cases, checked.
The one rule about handle
A handle has to be able to see every perform it covers. For each call
in the handled body, the compiler needs to know what that call performs.
Most calls it can see: a top-level fn, a builtin, a closure whose
binding or parameter carries an effect row, and a closure written inside
the handled body. The shape it cannot see is a rowless closure bound
outside the handled body — there is no definition to look at and no row
to read. It type-checks and is still rejected:
// skip: this is the rejected shape, shown for the diagnostic it produces
effect Ask {
Once() -> Int
}
fn ask_once() -> Int with Ask {
perform Ask::Once()
}
fn main() -> Int {
let bump = (x: Int) -> Int { x + 1 }
handle { bump(ask_once()) } with {
Ask::Once() => resume(41)
}
}
handle of effect 'Ask' cannot be compiled here: this handle cannot see what
one call in its body performs (here: the call to 'bump'). Make that call
visible -- declare 'bump' as a top-level `fn`, give the binding or parameter
it arrives through an effect row (`with Ask`), or move its `let` inside the
handled body. Moving the `handle` into the function that performs works too.
(ADR-0076 evidence-passing migration.)
The message lists four repairs and any one of them works; here the
smallest is to make bump a top-level fn. The ADR reference at the end
is a maintainer's note — the four repairs are the part addressed to you.
Effects you do not handle: capabilities
Fs, Env, Http and Console ride the same row, but you do not write
handlers for them — the host provides them, and what you declare is
permission to use them. That is the next chapter.
Next: Capabilities.