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 allows 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.
Written without a type argument, Exception is erased: it accepts any
Exception[E]. An erased arm that reads its payload gets it as a string,
so the checker lets it catch only strings; Exception::Throw(_) catches
anything. 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 allows 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. And it has to say so: every way out of the arm ends
in resume(v), or leaves with return or throw. An arm that just produces
a value — Ask::Value(_q) => 41 — is refused, because nothing in it says
whether the function should carry on with 41 or stop there. A Unit
operation resumes with resume(()).
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 allows 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, a closure written inside the
handled body, and a rowless closure bound outside it whose body is only
pure computation. bump below performs nothing. The perform is
ask_once(), written in the handle, so the handle sees it:
effect Ask {
Once() -> Int
}
fn ask_once() -> Int with Ask {
perform Ask::Once()
}
fn main allows Console {
let bump = (x: Int) -> Int { x + 1 }
let n = handle { bump(ask_once()) } with {
Ask::Once() => resume(41)
}
println("\{n}")
}
42
What it still cannot see is a call with no visible body: an immediately-applied closure, or a lambda whose own body performs or calls something this pass cannot name. That program type-checks and is rejected. The diagnostic names the edit. It does not name a compiler pass.
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.