これは Swift Tweets の発表をまとめたものです。インベントのスポンサーとして Qiita に許可をいただいた上で、このような形(ツイートの引用)で投稿しています。
あまり知られてませんが、エラー処理について、Swift 2.0設計時にCore Teamがまとめた"Error Handling Rationale and Proposal"というドキュメントがあります。 https://github.com/apple/swift/blob/master/docs/ErrorHandlingRationale.rst #swtws
— @koher
このドキュメントは、僕が去年try! Swiftで発表した際にも参考文献にしました。 https://github.com/koher/try-swift-2016/blob/master/slides.md 長いし(僕にとっては)英語が難しいし、具体例も少ないしで読むのがとても大変でした。 #swtws
— @koher
その中でエラーの分類について記述があるんですが、当時ピンと来なかったのが1年かけて逆に素晴らしいと思えてきたので、今日はそれについて発表します。本質的で重要なことだと思うので、もし1年前に戻れるならtry! Swiftの発表テーマをこれに変更したいくらいです。 #swtws
— @koher
それによると、エラー発生時プログラマに『どのように対応させたいか』によって、エラーは - Simple domain error - Recoverable error - Universal error - Logic failure の4種類に分類されます。 #swtws
— @koher
『どのように対応させたいか』というところがキモです。エラーの分類というと、エラーの内容によって分類してしまいそうですが、そうではなくエラーに『どのように対応させたいか』です。どういうことでしょうか。 #swtws
— @koher
例として、文字列を整数に変換する関数toIntを考えてみます。この関数は、"123"のような文字列であれば整数に変換可能ですが、もし"xyz"のような文字列が渡された場合は整数に変換することができず、エラーとなります。 #swtws
— @koher
Simple domain error: 文字列→整数の変換に失敗するなんて原因は明白なんだから、エラーが起こったかどうかさえわかればいいんだ、と考える場合に採用します。エラーが発生したことだけを伝え、その原因についての情報は伝えません。 #swtws
— @koher
Swiftなら、Optionalを使って戻り値の型をInt?とし、nilを返すことでSimple domain errorを表現できます。 #swtws https://gist.github.com/202040312034c5542eb1a651b77d59d7
— @koher
Recoverable error: たとえ文字列→整数の変換であっても、原因によって処理の方法が異なるだろう、と考える場合に採用します。たとえば、文字列が整数だったけどIntの範囲で表せない(オーバーフローする)場合とその他の場合を区別したいかもしれません。 #swtws
— @koher
Swiftなら、Error型でRecoverable errorを表現できます。toIntにはthrowsを付与し、do-try-catchを使ってエラー処理をします。 #swtws https://gist.github.com/ffaf4a558ec229175b4aee022fa224b2
— @koher
Recoverable(回復可能)というのは、残りのUniversal errorとLogic failureが回復可能『でない』こととの対比です。Simple domain errorも回復可能なのでRecoverable errorという名前は微妙な気もします。 #swtws
— @koher
Universal error: 整数に変換できないような不正な文字列がtoIntに渡されたらプログラムが停止すればいい、エラー処理をする必要はないと、考える場合に採用します。 #swtws
— @koher
Swiftでは、Universal errorを発生させるためにfatalErrorを使います。Swift 3時点では、fatalErrorを処理して回復することはできません。 #swtws https://gist.github.com/ea12472ba8b746dc452a7bd937929579
— @koher
Logic failure: 整数に変換できない文字列をtoIntに渡していること自体がバグだ、コードを修正する必要がある、と考える場合に採用します。Logic failureは実行時に起こってはいけないので、起こった場合の挙動は未定義です。 #swtws
— @koher
Swiftでは、Logic failureを表すためにpreconditionを使います。preconditionは-Ouncheckedでビルドすると無視され、実行時のオーバーヘッドをなくすことができます。 #swtws https://gist.github.com/a231287ba79e32ee8c7744654a71fdfd
— @koher
このように、文字列→整数の変換エラーという内容ではなく、『どのようにエラーに対応させたいか』によってエラーを分類します。どうしてエラーの内容ではなく『どのようにエラーに対応させたいか』によってエラーを分類するのでしょうか。 #swtws
— @koher
当たり前ですが、僕らが関数やメソッドを設計するときには、利用者に『どのようにエラーに対応させたいか』を考えなければなりません。nilを返すのか、Errorをthrowするのか、それともクラッシュさせるのか、必ず判断をしているはずです。 #swtws
— @koher
そう考えると、『どのようにエラーに対応させたいか』によるエラー分類は特別なことではなく、関数・メソッドの設計時には誰もが当たり前にやっていることのはずです。しかし、僕らは一貫した適切なポリシーを持ってエラーを分類できているでしょうか。 #swtws
— @koher
もしそうでなければ、アプリやライブラリ全体としてポリシーのブレたわかりづらい設計になってしまっているはずです。"Error Handling Rationale and Proposal"には、4種類のエラーの使い分けの例も書かれています。それを紹介しましょう。 #swtws
— @koher
Simple domain errorの例: 整数→文字列の変換エラーが挙げられています。発生の前提条件が明確なので、エラーが起こったという結果さえわかれば良いという考えです。 #swtws https://gist.github.com/b1729f2636704ff8ba791a12f5a02486
— @koher
Recoverable errorの例: ファイル入出力やネットワークのエラーが挙げられています。発生条件が明確でなく、どのように回復すべきか原因によって対応方法が異なるので、その手段を提供するべきという考えです。 #swtws https://gist.github.com/0ee07344990c55818cc86fe9c68a341b
— @koher
Universal errorの例: メモリ不足やスタックオーバーフローなどのエラーが挙げられています。利用者側で対応しようがないのでプログラムを停止すべきという考えです。 #swtws https://gist.github.com/f12c25ac029bd1a60f5403ee07d45c68
— @koher
Logic failureの例: Arrayのインデックスがはみ出てしまった場合や、nilが入っているOptionalに対して Forced unwrappingを実行してしまった場合などが挙げられています。 #swtws https://gist.github.com/9468f3fd76be3ecf978ddbecf5ff5982
— @koher
注意してほしいのは、あくまでこの例は「多くの場合」に適しているだけで、絶対的な指針ではありません。繰り返しになりますが、同じエラーでも『どのようにエラーに対応させたいか』次第で、4種類のどれを採用することもあり得ます。 #swtws
— @koher
たとえば、Universal errorの例としてメモリ不足が挙げられていますが、巨大なメモリ領域を確保するようなケースでメモリが足りなければエラー処理をしたいでしょう。そのようなケースではSimple domain errorが適切です。 #swtws
— @koher
僕が一番おもしろいと思い、感銘を受けたのがLogic failureです。どうしてArrayはインデックスが範囲外だった場合にSimple domain errorとしてnilを返さないのでしょうか。Dictionaryはキーに対応した値がなければnilを返します。 #swtws
— @koher
僕が初めてSwiftに触れたとき、これはパフォーマンス上の理由だと考えていました。インデックスが不正でないかチェックしようとするとその度にオーバーヘッドが発生します。画像処理などで繰り返しsubscriptが呼ばれるときに、そのオーバーヘッドは無視できません。 #swtws
— @koher
もちろんそういう理由もあるでしょう。しかしよく考えてみると、Arrayの要素にsubscriptでアクセスするのにインデックスがはみ出してしまうようなケースは、ほとんどがコーディング時のミスが原因だとわかります。 #swtws
— @koher
試しに、インデックスが範囲外だった場合にエラー処理をして回復する必要がある具体的な処理を何か思い浮かべてみて下さい。僕が思い付いたのは番兵 https://ja.wikipedia.org/wiki/%E7%95%AA%E5%85%B5 の代わりや畳み込み等の画像処理くらいです。 #swtws
— @koher
コードに問題があるのであれば回復『不能』であり、実行時にエラーに対処することはできません。コードのバグを修正すべきであり、ArrayのsubscriptのエラーをLogic failureとして扱うのは理にかなっています。 #swtws
— @koher
ところで、Arrayのインデックスがはみ出たときはクラッシュ、つまりUniversal errorなんじゃないかと思うかもしれません。次のコードを実行してみて下さい。 #swtws https://gist.github.com/7a595096f69dc47e0766be908696eedc
— @koher
クラッシュしましたね。では、ファイル名をout-of-bounds.swiftとして、次のように-Ouncheckedで実行してみて下さい。実行する度に結果が変わる(未定義動作な)のが確認できるはずです。 #swtws https://gist.github.com/f7a0f05e9a37d7c744331fb08811b7e0
— @koher
Arrayのインデックスが範囲外になるのはコードの問題(Logic failure)なのだから、実行時に範囲チェックする必要はないわけです。その上で、開発時(-Ouncheckedでないとき)は素早くコードの誤りに気付けるようにチェックして停止させてくれるわけです。 #swtws
— @koher
まとめ: - 『どのように対応させたいか』でエラー分類 - 前提条件が明確→Simple domain error - 原因ごとに対応→Recoverable error - 回復不能→Universal error - コードの誤り→Logic failure #swtws
— @koher
"Error Handling Rationale and Proposal"のエラー分類はよく考えれば当たり前のことです。しかし、改めて考え直すと、自分が設計した関数やメソッドでこれを徹底できていたと自信を持って言える人は少ないのではないでしょうか。 #swtws
— @koher
ところで、僕が大学生だった頃、ヒューマンインターフェース工学の授業で人間の特性8箇条というものを習いました。「人間は気まぐれである」、「人間はなまけものである」、「人間は不注意である」、「人間は根気がない」、「人間は単調をきらう」などです。 #swtws
— @koher
これは、何かを設計するときには人間はそのような欠点を持っているものと考えて、それでも問題が起こらないようにすべきだという指針です。しかし、当時僕が使っていたC言語はとてもそのように設計されているとは思えませんでした。 #swtws
— @koher
たとえば、C言語ではファイルを開くには次のようなコードを書きます。もし「不注意」でエラー処理を忘れてしまったら、fileを使おうとしてクラッシュするまで気付くことができません。 #swtws https://gist.github.com/aab6dfa1e01e1a88344caa11b75c6846
— @koher
人間は「気まぐれ」でエラー処理を書いたり書かなかったりします。異常系は起こらないことが多いので「なまけ」て省略してしまいがちです。省略しないと決めても「不注意」で簡単に忘れてしまいますし「根気がない」ので長続きしません。そして、エラー処理の大半は「単調な」作業です。 #swtws
— @koher
初めてJavaに触れて検査例外を知ったとき、素晴らしい仕組みだと思いました。エラー処理を忘れたらコンパイラが教えてくれます。ヒューマンエラーを防ぐ画期的な仕組みです。 #swtws https://gist.github.com/d5e30c8a16501ec126bc15fe600f4d59
— @koher
しかし、Javaの検査例外は失敗とみなされ、C#やKotlinなどの後続言語には取り入れられませんでした。ヒューマンエラーを防ぐ素晴らしい仕組みだったのになぜでしょうか。 #swtws
— @koher
Javaの検査例外が良くないと言う場合には、Javaの検査例外の構文が良くないのか、検査例外という概念そのものが良くないのかを区別して考えなければいけません。僕の考えは前者です。現に、Swiftは検査例外に似たエラー処理機構を取り入れ、うまく機能しています。 #swtws
— @koher
検査例外には二つの性質があります。一つはエラーの種類を静的型で扱うこと、もう一つはエラー処理を強制することです。Swiftでサポートされているのは後者で、前者については議論が続いています。 https://github.com/apple/swift-evolution/pull/68 #swtws
— @koher
なので、Swiftで検査例外(的なもの)がうまく機能していると言っても、検査例外の『一部』がうまくいっているだけかもしれません。しかし、僕はSwiftで検査例外(的なもの)が機能している理由は静的型エラーをサポートしないからではない思います。 #swtws
— @koher
Swiftで検査例外(的なもの)がうまくいっているのは、構文の改善によるところが大きいでしょう。deferによってfinallyの煩わしさに対処し、ラムダ式・クロージャ式との相性の悪さをrethrowsが解消しました。 #swtws
— @koher
また、関数にthrowsが付与されている場合、呼び出し箇所にtryの記述を強制することでImplicit control flow problemを軽減しています。そして何より、try!の存在が大きいです。 #swtws
— @koher
静的なチェックは万能ではありません。たとえば、文字列→整数の変換は失敗する可能性がある処理ですが、UI側で入力できる文字が数字に限定されているかもしれません。そのような文字列は必ず整数に変換できますが、コンパイラはそこまで理解できません。 #swtws
— @koher
そんなときに、Simple domain errorやRecoverable errorをLogic failureとして扱う簡単な構文が必要です。!やtry!がそれに当たります。これがないと、検査例外は時に不要なエラー処理を強制する煩わしいものと化してしまいます。 #swtws
— @koher
例えば、もしJavaでtry!と同じことをしようとすると次のようになります(FormatExceptionをthrowし得るtoIntというメソッドがあるとします)。 #swtws https://gist.github.com/0a2359e7465b2c3f66e7171af2e8f3c8
— @koher
Swiftなら次の通りです。シンプルですね!絶対に起こらないエラーをLogic failure化するために、毎回Javaのようなコードが必要なら検査例外にうんざりすることでしょう。 #swtws https://gist.github.com/05e539cd4c0c182f6a79c500ccb22813
— @koher
しかし、今日の主題はエラーの分類です。"Error Handling Rationale and Proposal"のエラー分類を理解するにつれ、僕はJavaとのエラー分類の違いが、Swiftで検査例外が機能する理由の一つではないかという仮説を持つようになりました。 #swtws
— @koher
JavaでthrowできるものはすべてThrowableのサブタイプです。Throwableのサブタイプには大きく分けて - Exception - RuntimeException - Error の三つがあります。 #swtws
— @koher
Java公式チュートリアルや必読書と言われるEffective Javaによると - 回復可能なエラー→Exception - プログラミングエラー→RuntimeException - 実行継続不能な致命的エラー→Error という使い分けが推奨されています。 #swtws
— @koher
言いかえれば、 - Recoverable error→Exception - Logic failure→RuntimeException - Universal error→Error ということです。この使い分け自体は妥当だと思います。 #swtws
— @koher
しかし、Javaの設計当時、Javaの設計者たちはこの分類を明確に意識できていなかったのではないかと僕は疑っています。Java設計者の気持ちになって考えてみましょう。 #swtws
— @koher
今、検査例外という素晴らしい仕組みを導入し、エラー処理を強制することを考えています。しかし、すべてのエラー処理を強制しようとすると破綻します。たとえば、配列の要素にインデックスでアクセスするときにすべてtry-catchを強制するのは非現実的です。 #swtws
— @koher
JavaではそのようなときにRuntimeExceptionのサブクラスであるArrayIndexOutOfBoundsExceptionが投げられます。RuntimeExceptionはLogic failureなのでSwiftと同じです。何が問題なのでしょう? #swtws
— @koher
僕は、設計当時RuntimeExceptionはLogic failureではなく「とても全部処理していられない頻度の高いエラー」と考えられていたのではないかと疑っています。全部処理するとコードがぐちゃぐちゃになってしまうので処理するかはプログラマに任せよう、と。 #swtws
— @koher
その証拠と僕が考えているのが、RuntimeException(Logic failure)がException(Recoverable error)のサブクラスになっていることです。 #swtws
— @koher
そのため、catch(Exception e)と書くとException(Recoverable error)だけでなく、サブクラスであるRuntimeException(Logic failure)まで捕まえてしまいます。 #swtws
— @koher
しかし、Logic failureは最も回復『不能』なエラーです。決して実行時に処理してはいけません。Effective JavaにもRuntimeExceptionはcatchすべきでないと書かれています。 #swtws
— @koher
それであればRuntimeExceptionをExceptionのサブクラスにしたのは明確な誤りです。このエラー分類の曖昧さが標準ライブラリの設計に影響しているように思えます。 #swtws
— @koher
一番ひどいのは文字列→整数の変換です。このメソッドparseIntが投げるのはRuntimeExceptionのサブクラスです。つまり、文字列→整数の変換失敗はcatchして処理してはいけないということです。 #swtws
— @koher
しかも、他に文字列を整数に変換できるかチェックするためのメソッドが提供されているわけでもありません。つまり、ユーザーが入力した不正文字列に対して「整数を入力して下さい」と表示するには、Logic failureをcatchして回復処理を書くしかないのです。 #swtws
— @koher
もし、初めからRuntimeExceptionをLogic failureだと考えていたら、このような設計はあり得なかったでしょう。Logic failureはコードの誤りであり、実行時に回復してはいけないのですから。 #swtws
— @koher
この他にも、Javaの標準ライブラリには引数が不正だった場合にRuntimeExceptionをthrowするメソッドがたくさんあります。何せ、IllegalArgumentExceptionというRuntimeExceptionのサブクラスがあるくらいです。 #swtws
— @koher
標準ライブラリの設計に引っ張られてか、Javaの世界ではこれらをLogic failureとして扱うことを推奨しています。つまり、引数に前提条件があるメソッドに対して誤った値を渡すのは、前提条件のチェックを怠ったコードのバグだからコードを修正すべきだという主張です。 #swtws
— @koher
しかし、本当にそれらをLogic failureとして扱うべきでしょうか。Arrayのインデックスがはみ出てしまうエラーがLogic failureとして妥当なのは、前提条件(はみ出ていないか)をチェックして分岐するようなコードがほぼ不要だからです。 #swtws
— @koher
前提条件のチェックが頻繁に行われるなら、「不注意」によってチェックを忘れてしまうこともあるでしょう。せっかく「不注意」を防ぐために検査例外が導入されたのに、その「不注意」が(コンパイル時ではなく)実行時まで検出できないのでは何のための検査例外なのでしょうか。 #swtws
— @koher
前提条件が明確なエラーの多くはSimple domain errorとして扱うのが適切です。最も回復が簡単なSimple domain errorを回復不能なLogic failureにしてしまったエラー分類の曖昧さこそJavaの失敗だと僕は思います。 #swtws
— @koher
そして、「不注意」によるエラー処理忘れを防げるという検査例外の大きな恩恵の一つをSimple domain errorについて捨ててしまったがために、Javaの検査例外を使っていてもありがたみよりも煩わしさを感じる人が多いのではないでしょうか。 #swtws
— @koher
Swiftは構文が優れているのはもちろんのこと、エラー分類が適切だからこそ安全かつ快適なエラー処理を実現できているのだと思います。「不注意」な僕にとっては最高の言語です! #swtws
— @koher
まとめ: - 人間は「不注意」なのでエラー処理忘れをコンパイラが検出できることは素晴らしい - 静的なチェックは万能ではないので、Simple domain error、Recoverable errorをLogic failure化する!やtry!が重要 続く #swtws
— @koher
- JavaではSimple domain errorが適切と思われるケースでLogic failureが採用されており、「不注意」によるエラー処理忘れを静的に検出できる恩恵を受けづらい - エラー分類が適切でなければ安全かつ快適なエラー処理は実現できない #swtws
— @koher