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 に乗りますが、これらにハンドラを 書くことはありません。実装はホストが持っていて、宣言するのは「使ってよい」 という権限です。それが次の章です。

次: ケーパビリティ。