前提
Zero における失敗は、別の並行する制御フローではありません。通常の制御フローの一部であり、関数のシグネチャに宣言され、すべての呼び出し箇所で認識されます。これを実現するピースは 2 つです。
raises——関数シグネチャ上で「この関数は失敗しうる」と宣言する。check——呼び出し箇所で「これが失敗したら、自分の関数を同じエラーで失敗させる」と認識する。
この組み合わせがあれば、ほとんどの言語が try/catch や Result 型で扱うことを表現できます。
失敗しうる関数を宣言する
戻り値の型のあとに raises を付けます。
fun validate(ok: Bool) -> i32 raises { InvalidInput } {
if ok == false {
raise InvalidInput
}
return 42
}
シグネチャの読み方は 2 通り。
- 「
validateはi32を返す か、InvalidInputを raise する。」 - 「可能な結果の集合は {
i32,InvalidInput}。」
どちらも正しい読み方です。コンパイラーは両方の可能性を追跡し、呼び出し側がそれぞれに対して明示的に何かをすることを求めます。
素の raises 句も書けます。
pub fun main(world: World) -> Void raises {
check world.out.write("hello\n")
}
エラーリストのない raises は「この関数は任意のエラーで失敗しうる」を意味します。main ではこれが慣用的な形で、何か問題が起きたらプログラムは非ゼロのステータスで終了し、ランタイムがエラーの表面化を引き受けます。
コールスタックの奥にある関数では、失敗モードを各層で文書化するために、明示形 raises { ErrorA, ErrorB } を使うのが望ましいです。
エラーを raise する
失敗しうる関数の中で、raise は指定されたエラーで関数を終了させます。
fun validate(ok: Bool) -> i32 raises { InvalidInput } {
if ok == false {
raise InvalidInput
}
return 42
}
raise InvalidInput はその行で InvalidInput エラーを発生させます。関数はそこから先には進まず、制御は呼び出し側に戻り、呼び出し側は i32 の代わりにエラーを受け取ります。raises 句に列挙されているのは、この関数が raise できる唯一のエラー型です。リストにないものを raise するとコンパイルエラーになります。
check でエラーを伝播させる
失敗しうる関数を呼ぶ側は、失敗の可能性を認識する必要があります。最も一般的な認識方法が check です。
fun run() -> Void raises { InvalidInput } {
check validate(true)
}
check validate(true) は 2 つのことを行います。
validate(true)を呼ぶ。validateがエラーを raise したら、それを上に伝播させる——runは同じエラーを その 呼び出し側に raise する。
伝播が許可されるためには、run 自身の raises 句が InvalidInput(あるいは互換性のあるもの)を raise できると宣言している必要があります。コンパイラーがこれを検査します。もし run のシグネチャが raises { OtherError } だったら、InvalidInput がセットに含まれないため、伝播はコンパイルに失敗します。
公式サンプルから取った完全な動作例——Run をクリックすると伝播が成功します。
エラー型は、関数シグネチャに乗ってコールスタックを上っていきます。main の素の raises は run が raise しうるものを受け入れるので、伝播は安全に着地します。
なぜ try/catch ではないのか
raises/check の背後にある設計規律は、呼び出し箇所で失敗が不可視にならない ことです。try/catch の言語では、例外は、自分が関与する可能性すら知らない関数の中を静かに通り抜けます。関数は純粋に見えるのに、本文のどこかの深い呼び出しがスローして、例外がそこを通って巻き戻されるのです。
これはスローするコードの書き手にとっては便利です。それ以外の人にとってはコストがあります。
- 読み手(やエージェント)は、関数が失敗パスに関わっているかをシグネチャから判断できません。
- リファクタリングが神経質になります——関数間で呼び出しを動かすと、到達可能な例外が変わります。
- リカバリーコードが、どうすべきかを知っている場所から遠くに住みます。
Zero は事前にコストを払います——失敗しうるすべての関数への注釈と、失敗しうるすべての呼び出しに対する check——、見返りに「関数が関与する失敗モードはシグネチャから可視である」という性質を得ます。これは人間にもエージェントにも頼れる性質です。
なぜ単に Result<T, E> ではないのか
同じことを choice——ok と err バリアントを持つ Result<T, E> 型——でも表現できます。Zero はそのパターンも提供しており、失敗を検査・保存・引き回す データ として扱いたいときに便利な道具です。
raises/check が加えるのは、よくあるケースのための構文レベルの慣習です。「これが失敗したら、自分の関数を同じ形で失敗させる」というやつ。これがないと、すべての呼び出しが match でラップされて、ほぼ毎回エラーを呼び出し側自身の Result に再パッケージすることになります。check はそのショートカットで、コンパイラーが伝播が正しく型付けされていることを保証します。
つまり、
Result<T, E>(choice)——失敗を値として検査したり持ち運んだりしたいとき。raises+check——失敗をコールスタックの上に伝播させたいだけのとき。
両方が使えます——それぞれ違うエルゴノミクスのニーズを満たします。
複数のエラー型
関数は複数種類のエラーを raise できます。
fun parse(input: String) -> i32 raises { Empty, Malformed } {
if std.mem.len(input) == 0 {
raise Empty
}
// ... パース処理 ...
raise Malformed
}
呼び出し側ができることは、
- 自身のシグネチャが
EmptyとMalformed(あるいはそのスーパーセット)を列挙していれば、check parse(input)で伝播させる。 matchや、目的のために言語が公開するtryスタイルの構文で、一方または両方のエラーを明示的にハンドリングする。
きめ細かいハンドリングの正確な構文(特定のエラー型に対するマッチ、他のエラーの伝播)は、pre-1.0 の Zero で動く可能性のある面のひとつです。コントラクト——関数が raise しうるすべてのエラー型はシグネチャに乗る——は安定した部分です。
メンタルモデル
Zero の失敗システムは、すべての命令型言語が持っているものの「厳密で正直な」バージョンです。
| 概念 | Try/Catch | Zero |
|---|---|---|
| 関数を失敗しうるとマークする | 何もしない(暗黙) | raises { ... } |
| エラーを raise する | throw e | raise E |
| 呼び出し側に伝播 | 不可視に伝播 | check call(...) |
| ローカルでハンドル | try { ... } catch(e) { ... } | 返ってきた Result への match、または型付きハンドリング構文 |
挙動の差は小さいです。注釈の差は大きい——そして意図的にそうしています。エフェクトは明示的に。失敗もエフェクトです。
スタイルメモ
checkは遠慮なく使う。この層で特に何もすることがないなら、それが正しいデフォルトです。- 内部ヘルパーの素の
raisesは避ける。エラーセットが狭いほど、シグネチャの情報量が増えます。 - 失敗しうる操作は、それが必要とするケイパビリティとセットにする。標準出力に書き込む
Worldを使う関数は、書き込みが失敗しうるので、ほぼ常にraisesが欲しくなります。
次回: JSON 診断
raises/check は Zero のコンパイラー診断と連動します——適切なエラーを宣言していない関数に対して check を書くと、コンパイラーは構造化された形で何が悪いかを正確に伝えてくれます。続くドキュメントでは JSON 診断 を扱います——エージェントがコードを修復するために読む、機械可読なフィードです。
よくある質問
Zero の raises の意味は?
raises の意味は?関数シグネチャに付ける raises は、その関数が失敗しうることを宣言します。素の raises は任意のエラー型を許容します。raises { InvalidInput } のような形は、列挙したエラー型でのみ失敗するよう制限します。呼び出し側は失敗の可能性を認識する必要があります——check か、別の明示的なハンドリング構文を使います。
check 演算子は何をしますか?
check 演算子は何をしますか?check expr は expr を評価し、エラーが出た場合はそれを現在の関数の呼び出し元へ伝播させます。「これを実行して、失敗したら同じエラーで自分の呼び出し元を失敗させる」と読めます。伝播が許可されるためには、呼び出し元自身が raises 句に互換性のあるエラーを宣言している必要があります。
Zero でエラーを raise するには?
raises 句にそのエラーを含む関数の中で、raise ErrorName を使います。例: if ok == false { raise InvalidInput }。関数はその時点で終了し、エラーが関数の結果となって、呼び出し側は check でそれに対処できます。
なぜ Zero は try/catch を使わないのですか?
try/catch では、関与を認識していない関数の中を例外が静かに通り抜けます。Zero の設計では、すべての関数のシグネチャが、自分が関与する失敗モードを認識する必要があります。隠れた制御フローはありません——関数が失敗しうるならシグネチャがそう述べ、すべての呼び出し側は check で明示的に認識します。
Zero では関数が複数のエラー型を raise できますか?
はい——raises { ... } 句にカンマ区切りで(または現行 Zero のエラーセット構文に従って)並べます。発生しうるエラーの集合は、パラメータや戻り値と同じく関数のコントラクトの一部です。呼び出し側はどのエラーが上がったかでパターンマッチすることも、check で伝播させるだけでも構いません。