エラーハンドリング
TODO:
Universal error について掘り下げてみたい
概要
エラーハンドリングについて、自分の考え等をまとめておきたい。このノートでは以下に着目したい。 エラーの伝播手段にはどのようなものがあるか?メリット、デメリットは何か?
様々あるエラーの伝播手段をどのように使い分ければ良いか?
以下については、別途まとめる。
エラーハンドリングの内容について
そもそもどのような場合にエラーを伝播させるべき?とか
その他前提条件。
Swift 界隈から持ってきた話が多いので、偏見があるかもしれない エラーとは何か
プログラムはその実行中に様々なエラー状態に陥る可能性がある。プログラムにとってのエラー状態とは、プログラムが、何らかの理由で要求されたタスクを完遂できなかった状態であると言える。
エラーをその発生原因で分類すると、大体以下に分類できる。
ユーザからの入力が悪い
ユーザが、不正な入力を与えた場合
文字列が長すぎる
不正なフォーマットで入力する
数値なのに文字列を入力している
悪意のある第三者による攻撃があった場合
DDoS 攻撃
brute-force 攻撃
外部サービスが悪い
アプリケーションが連携している外部サービスが落ちている場合
Twitter や Facebook が落ちている
回線の問題でうまく通信できない
システム内部が悪い
プログラムのバグ
ライブラリのバグ
メモリ/容量等のリソースの不足
エラーの伝播方法
プログラマは、プログラムがエラー状態に陥ってしまった場合に、それをどうハンドリングするか?を考えなくてはならない。プログラムがエラー状態に陥った場合、通常はエラー状態に陥った場所 (関数, モジュール, ライブラリ等) から他の場所へ、エラー状態を伝播させる。この時の他の場所というのは、そのプログラムの利用者側ということになる。プログラムの利用者は、発生したエラーにしたがって適切なロジックを実行できる必要がある。
この時、エラー状態を安易に握りつぶすのは、実装のミスに気づくのを遅らせ、バグがどこで混入したのかわからなくなってしまうため、避けるべき (例外設計における大罪 の 隠蔽 を参照)。 利用側にエラーを伝える方法については、よく使われている手法がいくつか存在し、それぞれメリット, デメリットがある。
Null を返す
Tuple を返す
直和型を返す
例外を投げる
それぞれ見ていく。
Null を返す
Nullable な型を返し、その値でエラーが発生したか判定する方法。非常に単純で、複雑な言語機能に頼る必要がない。一方で、エラーを無視あるいはハンドリングを忘れてしまう (コンパイラは Null チェック漏れを判断できない)、エラーの情報がわたしにくい等の問題がある。
また、Java のように Null を返すのか返さないのかを型レベルで表現しないことができると、Null が返るかどうかわかりにくく、エラーが無視されても気づきにくい (Kotlin や Swift のように null安全 であれば、この問題はないはず)。 code:c
int *numbers = (int *)malloc(sizeof(int) * 42);
if (numbers == NULL) {
// エラーハンドリング
}
code:swift
let nullableId = user.getId()
guard let userId = nullableId else {
// エラーハンドリング
}
Tuple を返す
正常時の値とエラー時の値のタプルを返す方法。Go でよく見られる。Null を返す場合と違いエラーの情報を返すことができるし、同様に単純。だが、まだ無視はしやすい。また、エラー時の戻り値, 正常時のエラー値が各々必要になるため、予期せぬタプルが返ってきたときにどうハンドリングすれば良いのかわからなくなってしまう恐れがある。 (value, nil ) なら成功
(nil , error) ならエラー
(value, error) の場合は??
(nil , nil ) の場合は??
code:go
r, err := os.Open(src)
if err != nil {
return err
}
直和型を返す
直和型とは、「複数の型のうちいずれかを返す」型。Scala だと、「型 A もしくは 何もない を返す型」は Option[A]、「型 A もしくは型 B を返す型」は Either[A, B] と表現できる。パターンマッチと合わせて網羅的な分岐が行えることが多い。 code:scala
// Option型. Some(正常時の値) or None を返す.
val s: OptionString = Some("hoge") val result = s match {
case Some(str) => str
case None => "not matched"
}
// Either型. Rightで正常時の値, Leftでエラー時の値を返す.
object LoginService {
}
LoginService.login(name = "user", password = "password") match {
case Right(user) => println(s"id: ${user.id}")
case Left(InvalidPassword) => println(s"Invalid Password!")
}
code:swift
// Result型. Successの場合とFailureの場合でパターンマッチできる. Either と性質的には似ている.
func readFile(at path: String) -> Result<Data, Error>
switch readFile(at: path) {
case .success(let data):
// ...
case .failure(let error):
// ...
}
Either と似たような表現に Union Type がある。Union Type はその値が 2 つの型のどちらかであることを示すことができる型だが、微妙に Either と異なる。例えば、Either では正常時の値と異常時の値を同じ型にできるが、Union Type ではそれができない。
code:typescript
let result: Int | Error // これはできる
let result: Int | Int // これはできない
code:scala
例外を投げる
例外は大抵、エラーが発生すると、そこからハンドリング箇所にジャンプして、エラーハンドリングを行う。例外の送出も簡単に行えるし、エラーが発生しえる複数の処理におけるハンドリングをまとめて行える等の利点がある。 非同期プログラミングとの相性が悪い
例外は、送出されたら catch されるまでコールスタックを遡る
別のイベントループや別スレッドで発生すると、catch できない
コントロールフローがわかりにくい
catch 漏れが起きたり
どこで catch しているかわからなくなったり
型チェックができない
検査例外がないと、どんな例外かどうかが型として表現されない 頼りすぎると、静的型付け言語の利点が損なわれる
どの伝播方法を利用すべきか
伝播方法の分類
Manual propagation
エラーを通常の値と同じように扱い、通常の制御構文を用いて取り扱う
エラーを何らかの戻り値として返した場合
Automatic propagation
専用の構文を用いて、エラー箇所からハンドリング箇所へ暗黙的にジャンプする
今回列挙した 4 つの伝播方法だと、例外が Automatic propagation にあたり、それ以外は Manual propagation になると言える。
Manual ptopagation vs Automatic propagaion
Automatic propagation を利用すると、Manual propagation よりも簡潔にコードを書くことができる。例えば、エラーが発生しえる処理がいくつか続く場合には、各々を Manual propagation でハンドリングするよりも、Automatic propagation を利用する方が簡潔に書ける。
一方で、Automatic propagation は、エラーの伝播時に即座にハンドリング箇所へジャンプするため、ハンドリングが強制されてしまう。Manual propagation は伝播されたエラーを手動でハンドリングするため、この制約がなく、エラーを値のまま引き回すことが可能になる。このような性質から、Automatic propagation は処理結果を値として引き回したい非同期処理と相性が悪い。
どの伝播方法を選択すべきか
上述の Swift ドキュメントにて、エラーに対してどう反応すべきか, させたいか に基づいて、採用すべき伝播方法が以下のように示されている。
Simple Domain errors
失敗の原因が明確であり、エラーが起こったかどうかさえ分かれば良い場合
シンプルに nil を返してしまえばよい
code:swift
// 文字列から整数への変換が失敗しえるのは明白なので、エラーが起こったかどうかさえ分かれば良い
func toInt(_ string: String) -> Int?
let x = toInt("123") // x == 123
let y = toInt("xyz") // x == nil
Recoverable errors
原因によって回復手段が異なると考えられる場合
Error を throw して原因によって分岐させる
code:swift
func validate() throws -> String
do {
let msg = try validate()
} catch ValidationError.invalidFormat {
// ...
return
} catch {
// ...
return
}
Universal errors
利用者側で回復は行えず、プログラムを停止させるべきであり、fatalError() する
「そのエラーが非常に多くの異なる原因によって発生しえる可能性がある」場合、そのようなエラーは Universal (= 普遍的) であり、プログラマーがそのエラーの全ての原因に直接対応するのがほぼ不可能
Logic failures
呼ぶ側のバグだとして、precondition を使って正しい事前条件を明示する
事前条件を満たさずに利用された場合。プログラマのミスであるため、動的に回復するのではなく、コードを修正すべき
例えば、Array の範囲外アクセスや、nil の forced unwrap 等
直和型はいつ使うべきか?
上記の分類では、直和型についての言及はなかった。直和型の特徴は、Manual propagation なので Automatic propagation の非同期処理が苦手という弱点を克服できている点と、利用側に返す型を明示できる点の2つがある。
前者については、特に TypeScript で非同期処理を書いている時なんかは何度も Either 型が欲しいなんて思ったりした。そのため、非同期処理で型のついたエラー情報を引き回したい時には有用のはず。 また、後者について、単純にエラーに型をつけて扱いたい場合にも有用かもしれないが、これについてはオススメしないという話もあった。
そのような場合にエラー型を指定するために Result を使うべきかと言うと、僕はオススメできません。エラー型を指定したいというのは前述の Manual Propagation が必要な場合には該当しません。
また、 Typed Throws ( throws の後ろにエラー型を指定できる構文)も Swift Evolution で議論され続けています。将来的にそのような構文が追加される可能性もあり、前述の JSON のデコードのようなケースは Typed Throws で対応すべきものだと思います。現状ではエラー型を指定できない状態の throws で実装しておき、 Typed Throws が追加されたときにエラー型を指定するのが良いでしょう。
ただ、これは Swift 言語の仕様としての Result をどう扱うべきか?という文脈も含んでのことだとは思っている。 主な参考資料
iOSアプリ設計パターン入門
関連ノート