08 — エフェクト (vibe の核)
前: 構造体・列挙・match
English version: 08_effects.vibe.md
vibe の関数は、型がそう言わない限り純粋です。計算以外にできること —
失敗する、表示する、ファイルを読む — はシグネチャの with 節に名前が
書かれ、その節は誰かが handle するまで呼び出し側へ伝播します。
仕組みはこれで全部。この章はそれがどう見えるかです。
失敗しうることを宣言する
失敗のためのエフェクトが Exception。throw しうる関数はそう宣言し、
呼び出し側はその義務を引き継ぎます。
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(...) => ... } が境界です。その内側では risky
は throw してよく、外側の main の row に Exception は現れません。義務が
そこで果たされたからです。
2章が使った綴り throw("...") は
perform Exception::Throw("...") の短縮形です — 同じ操作であり、1つの
ハンドラが両方を捕まえます。
Exception は中断的 (abortive) — throw は戻ってきません。ハンドラ腕の
値が handle 全体の値になり、だから safe は -1、fine は 25 に
なります。Exception の腕に resume はありません。
型引数なしの Exception は消去された形で、どの Exception[E] も受け取り
ます。payload を読む消去された腕には payload が文字列として届くので、
チェッカーはその腕に文字列だけを捕まえさせます。Exception::Throw(_) は何で
も捕まえます。エラー型を保ちたいときは Exception[E] と書きます — exception effect を参照。
自分のエフェクトを宣言する
エフェクト宣言は、実装を持たない操作の一覧です。実装はハンドラとして
呼び出し側が与えます。Exception はこの特別な場合にあたります。
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 はその数がどこから来るかを知りません。Ask::Value を perform
し、ハンドラが resume した値で続きを実行します — ここでは 41 なので
answer_of は 42 を返します。Exception と違い Ask は再開可能で、
resume(v) が v を perform の位置へ返し、関数はそこから続きます。
再開は one-shot かつ末尾再開 — ハンドラの腕は最後の動作として高々一度だけ
resume します。そして、それを明示しなければなりません: 腕のどの出口も
resume(v) で終わるか、return / throw で抜けます。値を返すだけの腕 —
Ask::Value(_q) => 41 — は拒否されます。41 で関数を続けるのか、そこで
止めるのかが腕のどこにも書かれていないからです。Unit を返す操作は
resume(()) で再開します。
自分のエフェクトを持ち出すのは、呼び出し側が本当に実装を差し替える必要が
あるときです — テストでの時計、値の別の供給元など。普通の失敗には
Exception を、ローカルな状態にはまず let mut を試すこと。判断基準は
Effects vs let mut にあります。
row は変数にできる
高階関数が「渡された関数がどのエフェクトを performするか」を知る必要は ありません。row を変数として書けば、来たものをそのまま運びます。
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 は f が純粋なら純粋、f が throw するなら Exception を
運びます。定義は一つ、両方の場合が検査されます。
handle についての唯一の規則
handle は、自分が覆うすべての perform を見えている必要があります。
handle された本体の各呼び出しについて、コンパイラはその呼び出しが何を
perform するか分かる必要があります。
ほとんどの呼び出しは見えます — トップレベルの fn、組み込み、束縛や引数が
effect row を持つクロージャ、handle された本体の中に書かれたクロージャ、
そして本体が純粋な計算だけの、row を持たず外で束縛されたクロージャ。
下の bump は何も perform しません。perform は handle の中に書かれた
ask_once() なので、handle はそれを見ます。
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
まだ見えないのは、見える本体が無い呼び出しです。即座に適用するクロージャや、 本体自身が perform する、あるいはこの判定が名前を追えない呼び出しをする ラムダです。そのプログラムは型検査を通り、それでも拒否されます。診断は 直し方を述べ、コンパイラのパス名は出しません。
handle しないエフェクト: ケーパビリティ
Fs・Env・Http・Console も同じ row に乗りますが、これらにハンドラを
書くことはありません。実装はホストが持っていて、宣言するのは「使ってよい」
という権限です。それが次の章です。
次: ケーパビリティ。