これは Swift Tweets の発表をまとめたものです(次回開催はこちら)。イベントのスポンサーとして Qiita に許可をいただいた上で投稿しています。
ありがとうございました!Q&Aは他の人の発表中でも構わないのでリプを飛ばして下さい。
— koher (@koher) 2017年1月14日
続いては僕 @koher の発表で、タイトルは "Swiftのエラー4分類が素晴らしすぎるのでみんなに知ってほしい" です。 #swtws
第 1 部: Swift の 4 種類のエラーについて
あまり知られてませんが、エラー処理について、 Swift 2.0 設計時に Core Team がまとめた "Error Handling Rationale and Proposal" というドキュメントがあります。このドキュメントは、僕が去年 try! Swift で発表した際にも参考文献にしました。長いし(僕にとっては)英語が難しいし、具体例も少ないしで読むのがとても大変でした。
その中でエラーの分類について記述があるんですが、当時ピンと来なかったのが 1 年かけて逆に素晴らしいと思えてきたので、今日はそれについて発表します。本質的で重要なことだと思うので、もし1年前に戻れるなら try! Swift の発表テーマをこれに変更したいくらいです。
エラーの種類
それによると、エラー発生時プログラマに『どのように対応させたいか』によって、エラーは
- Simple domain error
- Recoverable error
- Universal error
- Logic failure
の4種類に分類されます。
『どのように対応させたいか』というところがキモです。 エラーの分類というと、エラーの内容によって分類してしまいそうですが、そうではなくエラーに『どのように対応させたいか』です。 どういうことでしょうか。
例
例として、文字列を整数に変換する関数 toInt
を考えてみます。この関数は、 "123"
のような文字列であれば整数に変換可能ですが、もし "xyz"
のような文字列が渡された場合は整数に変換することができず、エラーとなります。
Simple domain error
文字列→整数の変換に失敗するなんて原因は明白なんだから、エラーが起こったかどうかさえわかればいいんだ、と考える場合に採用します。エラーが発生したことだけを伝え、その原因についての情報は伝えません。
Swift なら、 Optional
を使って戻り値の型を Int?
とし、 nil
を返すことで Simple domain error を表現できます。
func toInt(_ string: String) -> Int? { ... }
guard let number = toInt(string) else {
print("整数を入力して下さい。")
return
}
// `number` を使う処理
Recoverable error
たとえ文字列→整数の変換であっても、原因によって処理の方法が異なるだろう、と考える場合に採用します。たとえば、文字列が整数だったけど Int
の範囲で表せない(オーバーフローする)場合とその他の場合を区別したいかもしれません。
Swift なら、 Error
型で Recoverable error を表現できます。 toInt
には throws
を付与し、 do
- try
- catch
を使ってエラー処理をします。
func toInt(_ string: String) throws -> String { ... }
do {
let number = try toInt(string)
// `number` を使う処理
} catch ConversionError.overflow {
print("\(Int.min)〜\(Int.max)の値を入力して下さい。")
return
} catch {
print("整数を入力して下さい。")
return
}
Recoverable (回復可能)というのは、残りの Universal error と Logic failure が回復可能 でない こととの対比です。 Simple domain error も回復可能なので Recoverable error という名前は微妙な気もします。
Universal error
整数に変換できないような不正な文字列が toInt
に渡されたらプログラムが停止すればいい、エラー処理をする必要はないと、考える場合に採用します。
Swift では、 Universal error を発生させるために fatalError
を使います。 Swift 3 時点では、 fatalError
を処理して回復することはできません。
func toInt(_ string: String) -> Int {
guard isPossibleToConvert(string) else {
fatalError()
}
...
}
let number = toInt(string) // 変換に失敗したらクラッシュ
// `number` を使う処理
Logic failure
整数に変換できない文字列を toInt
に渡していること自体がバグだ、コードを修正する必要がある、と考える場合に採用します。 Logic failure は実行時に起こってはいけないので、起こった場合の挙動は未定義です。
Swift では、 Logic failure を起こさないために満たすべき前提条件を precondition
で表します。 precondition
は -Ounchecked
でビルドすると無視され、実行時のオーバーヘッドをなくすことができます。
func toInt(_ string: String) -> Int {
precondition(isPossibleToConvert(string))
...
}
let number = toInt(string) // 変換に失敗したら未定義動作
// `number` を使う処理
『どのようにエラーに対応させたいか』によってエラーを分類する
このように、文字列→整数の変換エラーという内容ではなく、『どのようにエラーに対応させたいか』によってエラーを分類します。どうしてエラーの内容ではなく『どのようにエラーに対応させたいか』によってエラーを分類するのでしょうか。
当たり前ですが、僕らが関数やメソッドを設計するときには、利用者に『どのようにエラーに対応させたいか』を考えなければなりません。 nil
を返すのか、 Error
を throw
するのか、それともクラッシュさせるのか、必ず判断をしているはずです。そう考えると、『どのようにエラーに対応させたいか』によるエラー分類は特別なことではなく、関数・メソッドの設計時には誰もが当たり前にやっていることのはずです。
エラーの使い分け
しかし、僕らは一貫した適切なポリシーを持ってエラーを分類できているでしょうか。もしそうでなければ、アプリやライブラリ全体としてポリシーのブレたわかりづらい設計になってしまっているはずです。
例
"Error Handling Rationale and Proposal" には、4種類のエラーの使い分けの例も書かれています。それを紹介しましょう。
Simple domain error の例
整数→文字列の変換エラーが挙げられています。発生の前提条件が明確なので、エラーが起こったという結果さえわかれば良いという考えです。
guard let number = Int(string) else {
print("整数を入力して下さい。")
return
}
// `number` を使う処理
Recoverable error の例
ファイル入出力やネットワークのエラーが挙げられています。発生条件が明確でなく、どのように回復すべきか原因によって対応方法が異なるので、その手段を提供するべきという考えです。
// ※説明のために架空の `Data` の `init` を使っています。
do {
let data = try Data(at: path)
// `data` を使う処理
} catch FileError.noSuchFile {
// ファイルがない場合のエラー処理
} catch FileError.permissionDenied {
// パーミッションがない場合のエラー処理
} catch {
// その他の場合のエラー処理
}
Universal error の例
メモリ不足やスタックオーバーフローなどのエラーが挙げられています。利用者側で対応しようがないのでプログラムを停止すべきという考えです。
func reduce<T, R>(_ range: CountableRange<T>, _ initial: R, _ combine: (R, T) -> R) -> R {
guard let first = range.first else { return initial }
return reduce(
range.startIndex.advanced(by: 1) ..< range.endIndex,
combine(initial, first),
combine
)
}
reduce(0..<1000000, 0) { $0 + $1 } // スタックオーバーフローでクラッシュ
Logic failure の例
Array
のインデックスがはみ出てしまった場合や、 nil
が入っている Optional
に対して Forced unwrapping を実行してしまった場合などが挙げられています。
let array = [2, 3, 5]
print(array[3])
使い分けの考え方
注意してほしいのは、あくまでこの例は「多くの場合」に適しているだけで、絶対的な指針ではありません。繰り返しになりますが、同じエラーでも『どのようにエラーに対応させたいか』次第で、 4 種類のどれを採用することもあり得ます。
たとえば、 Universal error の例としてメモリ不足が挙げられていますが、巨大なメモリ領域を確保するようなケースでメモリが足りなければエラー処理をしたいでしょう。そのようなケースでは Simple domain error が適切です。
僕が一番おもしろいと思い、感銘を受けたのが Logic failure です。どうして Array
はインデックスが範囲外だった場合に Simple domain error としてnilを返さないのでしょうか。 Dictionary
はキーに対応した値がなければ nil
を返します。
僕が初めて Swift に触れたとき、これはパフォーマンス上の理由だと考えていました。インデックスが不正でないかチェックしようとするとその度にオーバーヘッドが発生します。画像処理などで繰り返し subscript
が呼ばれるときに、そのオーバーヘッドは無視できません。
もちろんそういう理由もあるでしょう。しかしよく考えてみると、 Array
の要素に subscript
でアクセスするのにインデックスがはみ出してしまうようなケースは、ほとんどがコーディング時のミスが原因だとわかります。
試しに、インデックスが範囲外だった場合にエラー処理をして回復する必要がある具体的な処理を何か思い浮かべてみて下さい。僕が思い付いたのは番兵の代わりや畳み込み等の画像処理くらいです。コードに問題があるのであれば回復『不能』であり、実行時にエラーに対処することはできません。コードのバグを修正すべきであり、 Array
の subscript
のエラーを Logic failure として扱うのは理にかなっています。
ところで、 Array
のインデックスがはみ出たときはクラッシュ、つまり Universal error なんじゃないかと思うかもしれません。次のコードを実行してみて下さい。
let array = [2, 3, 5]
print(array[3])
クラッシュしましたね。では、ファイル名を out-of-bounds.swift として、次のように -Ounchecked
で実行してみて下さい。実行する度に結果が変わる(未定義動作な)のが確認できるはずです。
swift -Ounchecked out-of-bounds.swift
Array
のインデックスが範囲外になるのはコードの問題( Logic failure )なのだから、実行時に範囲チェックする必要はないわけです。その上で、開発時( -Ounchecked
でないとき)は素早くコードの誤りに気付けるようにチェックして停止させてくれるわけです。
まとめ
- 『どのように対応させたいか』でエラー分類
- 前提条件が明確→ Simple domain error
- 原因ごとに対応→ Recoverable error
- 回復不能→ Universal error
- コードの誤り→ Logic failure
"Error Handling Rationale and Proposal"のエラー分類はよく考えれば当たり前のことです。しかし、改めて考え直すと、自分が設計した関数やメソッドでこれを徹底できていたと自信を持って言える人は少ないのではないでしょうか。
第 2 部: Java との比較からエラー分類の重要性を探る
ところで、僕が大学生だった頃、ヒューマンインターフェース工学の授業で人間の特性 8 箇条というものを習いました。「人間は気まぐれである」、「人間はなまけものである」、「人間は不注意である」、「人間は根気がない」、「人間は単調をきらう」などです。これは、何かを設計するときには人間はそのような欠点を持っているものと考えて、それでも問題が起こらないようにすべきだという指針です。
C 言語とエラー処理
しかし、当時僕が使っていた C 言語はとてもそのように設計されているとは思えませんでした。
たとえば、 C 言語ではファイルを開くには次のようなコードを書きます。もし「不注意」でエラー処理を忘れてしまったら、 file
を使おうとしてクラッシュするまで気付くことができません。
FILE *file = fopen("filename", "r");
if (file == NULL) { // ファイルを開くのに失敗したとき
// エラー処理
}
// `file` を使う処理
人間は「気まぐれ」でエラー処理を書いたり書かなかったりします。異常系は起こらないことが多いので「なまけ」て省略してしまいがちです。省略しないと決めても「不注意」で簡単に忘れてしまいますし「根気がない」ので長続きしません。そして、エラー処理の大半は「単調な」作業です。
Java と検査例外と Swift
初めてJavaに触れて検査例外を知ったとき、素晴らしい仕組みだと思いました。エラー処理を忘れたらコンパイラが教えてくれます。ヒューマンエラーを防ぐ画期的な仕組みです。
try { // try-catch を書かないとコンパイルエラー
RandomAccessFile file = new RandomAccessFile(new File("filename"), "r");
// `file` を使う処理
} catch (FileNotFoundException e) { // ファイルを開くのに失敗したとき
// エラー処理
}
しかし、 Java の検査例外は失敗とみなされ、 C# や Kotlin などの後続言語には取り入れられませんでした。ヒューマンエラーを防ぐ素晴らしい仕組みだったのになぜでしょうか。
Java の検査例外が良くないと言う場合には、 Java の検査例外の構文が良くないのか、検査例外という概念そのものが良くないのかを区別して考えなければいけません。僕の考えは前者です。現に、 Swift は検査例外に似たエラー処理機構を取り入れ、うまく機能しています。
検査例外には二つの性質があります。一つはエラーの種類を静的型で扱うこと、もう一つはエラー処理を強制することです。 Swift でサポートされているのは後者で、前者については議論が続いています。なので、 Swift で検査例外(的なもの)がうまく機能していると言っても、検査例外の『一部』がうまくいっているだけかもしれません。
しかし、僕は Swift で検査例外(的なもの)が機能している理由は静的型エラーをサポートしないからではない思います。 Swift で検査例外(的なもの)がうまくいっているのは、構文の改善によるところが大きいでしょう。
defer
によって finally
の煩わしさに対処し、ラムダ式・クロージャ式との相性の悪さを rethrows
が解消しました。また、関数に throws
が付与されている場合、呼び出し箇所に try
の記述を強制することで Implicit control flow problem を軽減しています。そして何より、 try!
の存在が大きいです。
静的なチェックは万能ではありません。たとえば、文字列→整数の変換は失敗する可能性がある処理ですが、 UI 側で入力できる文字が数字に限定されているかもしれません。そのような文字列は必ず整数に変換できますが、コンパイラはそこまで理解できません。そんなときに、 Simple domain error や Recoverable error を Logic failure として扱う簡単な構文が必要です。 !
や try!
がそれに当たります。これがないと、検査例外は時に不要なエラー処理を強制する煩わしいものと化してしまいます。
例えば、もし Java で try!
と同じことをしようとすると次のようになります( FormatException
を throw
し得る toInt
というメソッドがあるとします)。
int number;
try {
number = toInt(string);
} catch (FormatException e) {
throw new RuntimeException("Never reaches here.");
}
Swift なら次の通りです。シンプルですね!
let number = try! toInt(string)
絶対に起こらないエラーを Logic failure 化するために、毎回 Java のようなコードが必要なら検査例外にうんざりすることでしょう。
検査例外とエラーの分類
しかし、今日の主題はエラーの分類です。 "Error Handling Rationale and Proposal" のエラー分類を理解するにつれ、僕は Java とのエラー分類の違いが、 Swift で検査例外が機能する理由の一つではないかという仮説を持つようになりました。
Java で throw
できるものはすべて Throwable
のサブタイプです。 Throwable
のサブタイプには大きく分けて
Exception
RuntimeException
Error
の三つがあります。
Java 公式チュートリアルや必読書と言われる Effective Java によると
- 回復可能なエラー→
Exception
- プログラミングエラー→
RuntimeException
- 実行継続不能な致命的エラー→
Error
という使い分けが推奨されています。
言いかえれば、
-
Recoverable error →
Exception
-
Logic failure →
RuntimeException
-
Universal error →
Error
ということです。この使い分け自体は妥当だと思います。
しかし、 Java の設計当時、 Java の設計者たちはこの分類を明確に意識できていなかったのではないかと僕は疑っています。 Java 設計者の気持ちになって考えてみましょう。
今、検査例外という素晴らしい仕組みを導入し、エラー処理を強制することを考えています。しかし、すべてのエラー処理を強制しようとすると破綻します。たとえば、配列の要素にインデックスでアクセスするときにすべて try
- catch
を強制するのは非現実的です。 Java ではそのようなときに RuntimeException
のサブクラスである ArrayIndexOutOfBoundsException
が投げられます。 RuntimeException
は Logic failure なので Swift と同じです。何が問題なのでしょう?
僕は、設計当時 RuntimeException
は Logic failure ではなく「とても全部処理していられない頻度の高いエラー」と考えられていたのではないかと疑っています。全部処理するとコードがぐちゃぐちゃになってしまうので処理するかはプログラマに任せよう、と。
その証拠と僕が考えているのが、 RuntimeException
( Logic failure )が Exception
( Recoverable error )のサブクラスになっていることです。そのため、 catch(Exception e)
と書くと Exception
( Recoverable error )だけでなく、サブクラスである RuntimeException
( Logic failure
)まで捕まえてしまいます。
しかし、 Logic failure は最も回復『不能』なエラーです。決して実行時に処理してはいけません。 Effective Java にも RuntimeException
は catch
すべきでないと書かれています。それであれば RuntimeException
を Exception
のサブクラスにしたのは明確な誤りです。このエラー分類の曖昧さが標準ライブラリの設計に影響しているように思えます。
一番ひどいのは文字列→整数の変換です。このメソッド parseInt
が投げるのは RuntimeException
のサブクラスです。つまり、文字列→整数の変換失敗はcatchして処理してはいけないということです。
しかも、他に文字列を整数に変換できるかチェックするためのメソッドが提供されているわけでもありません。つまり、ユーザーが入力した不正文字列に対して「整数を入力して下さい」と表示するには、 Logic failure を catch
して回復処理を書くしかないのです。もし、初めから RuntimeException
を Logic failure だと考えていたら、このような設計はあり得なかったでしょう。 Logic failure はコードの誤りであり、実行時に回復してはいけないのですから。
この他にも、 Java の標準ライブラリには引数が不正だった場合に RuntimeException
を throw
するメソッドがたくさんあります。何せ、 IllegalArgumentException
という RuntimeException
のサブクラスがあるくらいです。
標準ライブラリの設計に引っ張られてか、 Java の世界ではこれらを Logic failure として扱うことを推奨しています。つまり、引数に前提条件があるメソッドに対して誤った値を渡すのは、前提条件のチェックを怠ったコードのバグだからコードを修正すべきだという主張です。
しかし、本当にそれらを Logic failure として扱うべきでしょうか。 Array
のインデックスがはみ出てしまうエラーが Logic failure として妥当なのは、前提条件(はみ出ていないか)をチェックして分岐するようなコードがほぼ不要だからです。前提条件のチェックが頻繁に行われるなら、「不注意」によってチェックを忘れてしまうこともあるでしょう。せっかく「不注意」を防ぐために検査例外が導入されたのに、その「不注意」が(コンパイル時ではなく)実行時まで検出できないのでは何のための検査例外なのでしょうか。
前提条件が明確なエラーの多くは Simple domain error として扱うのが適切です。 (→追記にて訂正) 最も回復が簡単な Simple domain error を回復不能な Logic failure にしてしまったエラー分類の曖昧さこそ Java の失敗だと僕は思います。そして、「不注意」によるエラー処理忘れを防げるという検査例外の大きな恩恵の一つを Simple domain error について捨ててしまったがために、 Java の検査例外を使っていてもありがたみよりも煩わしさを感じる人が多いのではないでしょうか。
Swift は構文が優れているのはもちろんのこと、エラー分類が適切だからこそ安全かつ快適なエラー処理を実現できているのだと思います。「不注意」な僕にとっては最高の言語です!
まとめ
- 人間は「不注意」なのでエラー処理忘れをコンパイラが検出できることは素晴らしい
- 静的なチェックは万能ではないので、 Simple domain error 、Recoverable error を Logic failure 化する
!
やtry!
が重要 - Java では Simple domain error が適切と思われるケースで Logic failure が採用されており、「不注意」によるエラー処理忘れを静的に検出できる恩恵を受けづらい
- エラー分類が適切でなければ安全かつ快適なエラー処理は実現できない
追記(2017-01-18)
id:sawat IllegalArgumentException が Logic failure じゃない場面なんてそんなに多くない気がするが…。
はてブでこのようなコメントをいただいて考えていたのですが、たしかにその通りなように思います。
Logic failure かそうでないかの境目は、エラーが発生したときにそれをハンドリングして何らかの処理を実行することがあるかどうかですが( Array
の index out of bounds を処理することが滅多にないので Logic failure としたのと同じように)、 前提条件が明確な IllegalArgumentException
(引数に渡された不正な値が原因のエラー)であっても、それをハンドリングする必要があるようなオペレーションは下記のケースを除いてほとんど思い付きませんでした。
Simple domain error となるのは次のようなケースです( removeLast
メソッド等は引数が不正なわけではないですが、第一引数にオブジェクト自身を渡す関数と読みかえれば引数の不正と同じ話です)。
- 変換(例:
parseInt
, JSON のデコード等) - 値の不在(例:
pop
,Iterator
のnext
等) - 計算(例: ゼロ除算(単位ベクトルや逆行列の演算)、負の数の平方根)
- 数を引数にとる処理(例: 配列の生成)
値の変換はプログラムの外から来た値がインプットとなる場合が多いので、コードレベルで不正値を防ぐことができないケースが多く、エラーをハンドリングしたいです。コレクションに対する操作も、 DB から取得するなど外部から来たものを操作することが多く同様です。
計算や数を引数にとる処理は、オペランドや引数が計算の結果として導かれた結果、簡単に値が範囲外になってしまいます。 sqrt(a - b)
の a - b
が負であるような場合に、事前チェックが必要なのであれば、それは Logic failure ではなく Simple domain error であるべきです。コレクションのケースも同様に、他のオペレーションの結果としてコレクションを得た場合には、 pop
する前に空でないことなどのチェックが必要となります。
これらに共通なのは、引数に渡される値をコードレベルでコントロールできない(コントロールするのが難しい)ことです。そのため、引数に不正な値が渡されるケースがコードの誤りとは限らず、 Logic failure ではなく Simple domain error として扱いたいわけです。
しかし、不正引数で引き起こされる他の多くのケースはエラーは捕まえてもどうしようもないことが多いように思います( MessageDigest
の getInstance
に未知のアルゴリズムを表す文字列が渡された場合など)。なので、
前提条件が明確なエラーの多くはSimple domain errorとして扱うのが適切です。
というのは 誤り だと言えそうです。訂正します。
ただ、 Simple domain error を発生させるような処理は利用頻度が高く、それらが安全にハンドリングできないことによるストレスは大きいとは思います。 Java には Simple domain error を安全かつ快適に扱う良い方法がなく( try-catch は大がかりすぎますし、 null
を返しても null
チェックを無視できてしまうので安全でありません。しかも、 null
はネストできないので、たとえば Iterator
の next
が null
でエラーを表すと、次の値が存在しないのか、次の値が null
なのかを区別できません。)、 Simple domain error であるべきエラーが Logic failure として扱われているという点自体は間違いではないので、全体的な論旨はおかしくないと思います。