Blog 2023‐04‐01 - qnighy/umo GitHub Wiki
昨日は特に進捗はない。
近頃は Result<T, E>
のような型を作ってエラーハンドリングするのが流行っている。例外機構を持つ言語でもあえてResult型を自作する動きもあるくらいだ。
ではこれは最適な書き方なのだろうか?たとえば、新しいプログラミング言語を作るにあたっても、Rustのやり方を模倣するのが正しいのだろうか? 自分はそうは思わない。
そこでまずエラーハンドリングの方法論について整理してみよう。ここでは以下の形態を考える。
- 非検査例外
-
検査例外 -- Javaの
throws
修飾子 - Result型
またGoはResult型のようなそうでないような方法でエラーハンドリングを行うが、これはおそらくGoの諸々の事情にあわせて発生したものであって、特にエラーハンドリングのパラダイム単体で見たときの利点があまり見出せないのでここでは比較から外すことにする。
まずはじめに以下の2つの視点を分離したい。
- 例外機構が低レイヤでどのように実装されているか
- 言語の高レベルの視点での例外システムの整理
たとえばRustでは Result<T, E>
を通常の戻り値として扱う一方、パニック機構についてはC++の例外と同様の大域脱出システムを使って実装している。これは例外処理のパフォーマンス特性に影響を与えると言われているが、このような事情については今回は議論しない。
まず Result<T, E>
の特性として第一に語られるのは「エラー型が明示されること」だろう。議論を明確にするために、この点をさらに以下のように2つに分けたい。
- 関数ごとに異なるエラー型を指定できること。
- 異なるエラー型の間の変換がサブタイピングではなく明示的な合成によって行われていること。
さて、2は重要だろうか? 確かにRustなど一部の言語はサブタイピングを持たないため、 Result<_, E1>
から Result<_, E2>
への変換を明示的に行う必要がある。これによりエラーがどのように伝播されるのか意識できる利点もあるかもしれないが、あまり本質的な利点であるようには思えない。現にRustでは ?
演算子が暗黙的に From::from
を呼ぶようになっていて、どちらかといえばサブタイピングと同等の仕組みに近づける努力が行われているように思える。
そこで、1の性質に注目してみる。すると、確かに非検査例外はこの性質を満たさないが、検査例外もまた関数ごとに異なるエラー型を指定する仕組みであることがわかるだろう。
では、なぜ検査例外ではなく Result
を使うのだろうか。まずは本質的な違いに着目してみよう。それは、デフォルトの例外処理モードの違いである。
- 検査例外ではデフォルトで例外をrethrowする。
- Resultでデフォルトで例外を捨てる。
逆に、ここまでで説明した違いを除いてしまえば、Resultと検査例外は本質的に同じことをやっていると言えるだろう。try-catchはOkとErrに対するパターンマッチに他ならないからだ。
さて、この2つはどちらのほうが望ましいのだろうか。これについて、まずResult方式のデメリットについてはっきりさせたい。Result方式の場合、戻り値を使わない場合のrethrow忘れという問題が発生する。たとえばRustの場合
file.write_all(buf)?;
と書くべきところを
file.write_all(buf);
と書いても型検査に通ってしまう。それどころか、このコードの問題は異常系にのみ存在するため、一番都合の悪いときにいきなり問題が噴出してしまう悪性の問題であるとも考えられる。
これは通常、linterによる静的検査で対策される。Rustではそれで実際何とかなっている。しかし、linterはあくまで発見的な手法である。本当は、より根本的な解決が求められる問題だったりしないだろうか? このことは本記事の少し後でまた考えたい。
デフォルト例外モードが問題になりにくい場合もある。それは暗黙の副作用との混用がない場合、具体的にはHaskellの場合である。
Haskellの場合、暗黙の副作用がないモード (通常の式) と暗黙の副作用を持つモード (do式) が分けられているが、前者であれば IO
も ErrorT
も実行されないし、後者であれば IO
も ErrorT
も実行される。前者で実行忘れがあるときは他の副作用ともども実行忘れをしているときで、それは通常 let _ = write .. in
のように純粋関数の戻り値を丸ごと捨てるような形であらわれるはずだから、問題に気付くのはより容易である。
ではデフォルトrethrowの問題について考えてみよう。
例外が起きたときの挙動として、考えられるものは以下の3種類である。
- rethrowする
- (1-a) 例外を変換せずにrethrowする
- (1-b) 例外を変換してrethrowする
- 例外に対処 (または無視) し、正常系に戻る
このうち、2を正しく行えることはあまり多くない。ほとんど全てのプログラムには予想外の挙動をする余地が残されており、我々は不断の努力によってその「予想外の挙動」を例外側に倒そうとしている。それらをひとまとめにし正常系に戻していいのは基本的に「あとで報告する場合」に限られる。そしてもうひとつの可能性は、数多ある例外の中から「想定内の挙動」だけを切り出して正常系に戻す場合である。これは全く無いわけではなく、被呼び出し側では「例外」側に寄せるだけの十分な合理的な事情があるものが呼び出し側から見るとそうではないというケースは確かにないわけではないが、ほとんどの場合は「例外」に対する認識は呼び出し側のほうが広くなるので、まさしくこれは例外的な状況だと言えるのではないだろうか。
そして (1-b) はデフォルトにできないのだから、 (1-a) がデフォルトの挙動になるのは自然なように見える。これに何の問題が? これにはいくつかの議論の漏れがある。
まず第一に、「デフォルトの挙動はない」という選択肢を忘れている。全ての例外副作用を明示的にハンドルすることを求めるという選択肢だ。ただこれは現実的ではないだろう。
もうひとつは、 (1-a) をデフォルトにすること自体ではなく、デフォルトへの誘引力が強すぎることが問題だという可能性だ。
古典的な例外システムで使われているtry-catchは取り回しが悪い。以下のコードを考える。
file2.write_all(
&file1
.read_to_end()
.map_err(|e| MyError::ReadError(path1, e))?
)
.map_err(|e| MyError::WriteError(path2, e))?;
ここでは読み取りと書き込みの2つの操作に例外副作用があり、それをmap_errで変換してrethrowしている。これをtry-catchで書いてみよう。 (以下はTypeScriptとRustのあいのこくらいの擬似言語)
let buf;
try {
buf = file1.read_to_end();
} catch (e) {
throw new ReadError(path1, buf);
}
try {
file2.write_all(buf);
} catch (e) {
throw new WriteError(path2, e);
}
これにはいくつかの複合的な問題がある。
- try-catchはtryブロック内の全ての部分式の副作用を対象にしてしまう。これは
.map_err
などのResultヘルパがその直接の式の副作用を対象にするのとは対照的である。 - 古典的なtry-catchは式ではないため、結果を変数に入れるために余計なステップが必要になる。
- 古典的なtry-catchはドット記法のチェイニングを寸断してしまう。
これによりtry-catchによる例外のrethrowは正常系の記述を寸断してしまうため、どうしてもプログラマーに対して無変換のrethrowをするような圧力になってしまう。
Result + enumによるアプローチとの比較で考えると、このような記法レベルでの困難こそが実は検査例外再考や検査例外に対する批判などで論じられている問題に繋がる根本的な問題なのではないだろうか。
RustではResult型を導入しつつも、構文的なサポートによって正常系の記述を寸断しないrethrowを実現している。
-
?
演算子による簡潔なrethrow — ドット記法のチェーンを寸断せず、スコープが部分式の副作用に波及しない- これは別の副作用である
.await
の構文的デザインにも影響を与えている
- これは別の副作用である
-
.map_err()
メソッドによる簡潔なエラー変換 — ドット記法のチェーンを寸断しない
この方式に対してモードの逆転を行って、以下のようにすることで、より理想的なエラーハンドリングに近づけるのではないだろうか。
- デフォルトの関数・メソッド呼び出しでは自動的にrethrowが行われる。
- 特別な構文
.catch
によりエラーを取り出し、.catch<E>(|e| throw MyError(e))
のようにしてエラーを変換してrethrowできる。- 通常の関数では全ての副作用が再適用されてしまうので、そうではないことを明示するために専用の構文が必要になる
エラーハンドリングを値から副作用に分離しても、それを明示したResult型自体の価値は残り続ける。一時的にエラー副作用を値化する需要はあるからだ。
たとえばRustではパニックは一種の大域脱出副作用とみなせるが、これを catch_unwind
で取り出すとResultになる。取り出した例外は resume_unwind
なり何なりで消費すれば、エラーを握りつぶすことにはならない。