Swiftのエラー4分類が素晴らしすぎるのでみんなに知ってほしい

  • 610
    いいね
  • 11
    コメント

これは Swift Tweets の発表をまとめたものです(次回開催はこちら)。イベントのスポンサーとして Qiita に許可をいただいた上で投稿しています。

第 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 errorLogic 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 {
  predondition(isPossibleToConvert(string))
  ...
}

let number = toInt(string) // 変換に失敗したら未定義動作
// `number` を使う処理

『どのようにエラーに対応させたいか』によってエラーを分類する

このように、文字列→整数の変換エラーという内容ではなく、『どのようにエラーに対応させたいか』によってエラーを分類します。どうしてエラーの内容ではなく『どのようにエラーに対応させたいか』によってエラーを分類するのでしょうか。

当たり前ですが、僕らが関数やメソッドを設計するときには、利用者に『どのようにエラーに対応させたいか』を考えなければなりません。 nil を返すのか、 Errorthrow するのか、それともクラッシュさせるのか、必ず判断をしているはずです。そう考えると、『どのようにエラーに対応させたいか』によるエラー分類は特別なことではなく、関数・メソッドの設計時には誰もが当たり前にやっていることのはずです。

エラーの使い分け

しかし、僕らは一貫した適切なポリシーを持ってエラーを分類できているでしょうか。もしそうでなければ、アプリやライブラリ全体としてポリシーのブレたわかりづらい設計になってしまっているはずです。

"Error Handling Rationale and Proposal" には、4種類のエラーの使い分けの例も書かれています。それを紹介しましょう。

Simple domain error の例

整数→文字列の変換エラーが挙げられています。発生の前提条件が明確なので、エラーが起こったという結果さえわかれば良いという考えです。

simple-domain-error.swift
guard let number = Int(string) else {
  print("整数を入力して下さい。")
  return
}

// `number` を使う処理

Recoverable error の例

ファイル入出力やネットワークのエラーが挙げられています。発生条件が明確でなく、どのように回復すべきか原因によって対応方法が異なるので、その手段を提供するべきという考えです。

recoverable-error.swift
// ※説明のために架空の `Data` の `init` を使っています。
do {
  let data = try Data(at: path)
  // `data` を使う処理
} catch FileError.noSuchFile {
  // ファイルがない場合のエラー処理
} catch FileError.permissionDenied {
  // パーミッションがない場合のエラー処理
} catch {
  // その他の場合のエラー処理
}

Universal error の例

メモリ不足やスタックオーバーフローなどのエラーが挙げられています。利用者側で対応しようがないのでプログラムを停止すべきという考えです。

universal-error.swift
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 を実行してしまった場合などが挙げられています。

logic-failure.swift
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 でアクセスするのにインデックスがはみ出してしまうようなケースは、ほとんどがコーディング時のミスが原因だとわかります。

試しに、インデックスが範囲外だった場合にエラー処理をして回復する必要がある具体的な処理を何か思い浮かべてみて下さい。僕が思い付いたのは番兵の代わりや畳み込み等の画像処理くらいです。コードに問題があるのであれば回復『不能』であり、実行時にエラーに対処することはできません。コードのバグを修正すべきであり、 Arraysubscript のエラーを Logic failure として扱うのは理にかなっています。

ところで、 Array のインデックスがはみ出たときはクラッシュ、つまり Universal error なんじゃないかと思うかもしれません。次のコードを実行してみて下さい。

out-of-bounds.swift
let array = [2, 3, 5]
print(array[3])

クラッシュしましたね。では、ファイル名を out-of-bounds.swift として、次のように -Ounchecked で実行してみて下さい。実行する度に結果が変わる(未定義動作な)のが確認できるはずです。

run-out-of-bounds.sh
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 errorRecoverable errorLogic failure として扱う簡単な構文が必要です。 !try! がそれに当たります。これがないと、検査例外は時に不要なエラー処理を強制する煩わしいものと化してしまいます。

例えば、もし Java で try! と同じことをしようとすると次のようになります( FormatExceptionthrow し得る 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 errorException
  • Logic failureRuntimeException
  • Universal errorError

ということです。この使い分け自体は妥当だと思います。

しかし、 Java の設計当時、 Java の設計者たちはこの分類を明確に意識できていなかったのではないかと僕は疑っています。 Java 設計者の気持ちになって考えてみましょう。

今、検査例外という素晴らしい仕組みを導入し、エラー処理を強制することを考えています。しかし、すべてのエラー処理を強制しようとすると破綻します。たとえば、配列の要素にインデックスでアクセスするときにすべて try - catch を強制するのは非現実的です。 Java ではそのようなときに RuntimeException のサブクラスである ArrayIndexOutOfBoundsException が投げられます。 RuntimeExceptionLogic failure なので Swift と同じです。何が問題なのでしょう?

僕は、設計当時 RuntimeExceptionLogic failure ではなく「とても全部処理していられない頻度の高いエラー」と考えられていたのではないかと疑っています。全部処理するとコードがぐちゃぐちゃになってしまうので処理するかはプログラマに任せよう、と。

その証拠と僕が考えているのが、 RuntimeExceptionLogic failure )が ExceptionRecoverable error )のサブクラスになっていることです。そのため、 catch(Exception e) と書くと ExceptionRecoverable error )だけでなく、サブクラスである RuntimeExceptionLogic failure )まで捕まえてしまいます。

しかし、 Logic failure は最も回復『不能』なエラーです。決して実行時に処理してはいけません。 Effective Java にも RuntimeExceptioncatch すべきでないと書かれています。それであれば RuntimeExceptionException のサブクラスにしたのは明確な誤りです。このエラー分類の曖昧さが標準ライブラリの設計に影響しているように思えます。

一番ひどいのは文字列→整数の変換です。このメソッド parseInt が投げるのは RuntimeException のサブクラスです。つまり、文字列→整数の変換失敗はcatchして処理してはいけないということです。

しかも、他に文字列を整数に変換できるかチェックするためのメソッドが提供されているわけでもありません。つまり、ユーザーが入力した不正文字列に対して「整数を入力して下さい」と表示するには、 Logic failurecatch して回復処理を書くしかないのです。もし、初めから RuntimeExceptionLogic failure だと考えていたら、このような設計はあり得なかったでしょう。 Logic failure はコードの誤りであり、実行時に回復してはいけないのですから。

この他にも、 Java の標準ライブラリには引数が不正だった場合に RuntimeExceptionthrow するメソッドがたくさんあります。何せ、 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 errorRecoverable errorLogic 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, Iteratornext 等)
  • 計算(例: ゼロ除算(単位ベクトルや逆行列の演算)、負の数の平方根)
  • 数を引数にとる処理(例: 配列の生成)

値の変換はプログラムの外から来た値がインプットとなる場合が多いので、コードレベルで不正値を防ぐことができないケースが多く、エラーをハンドリングしたいです。コレクションに対する操作も、 DB から取得するなど外部から来たものを操作することが多く同様です。

計算や数を引数にとる処理は、オペランドや引数が計算の結果として導かれた結果、簡単に値が範囲外になってしまいます。 sqrt(a - b)a - b が負であるような場合に、事前チェックが必要なのであれば、それは Logic failure ではなく Simple domain error であるべきです。コレクションのケースも同様に、他のオペレーションの結果としてコレクションを得た場合には、 pop する前に空でないことなどのチェックが必要となります。

これらに共通なのは、引数に渡される値をコードレベルでコントロールできない(コントロールするのが難しい)ことです。そのため、引数に不正な値が渡されるケースがコードの誤りとは限らず、 Logic failure ではなく Simple domain error として扱いたいわけです。

しかし、不正引数で引き起こされる他の多くのケースはエラーは捕まえてもどうしようもないことが多いように思います( MessageDigestgetInstance に未知のアルゴリズムを表す文字列が渡された場合など)。なので、

前提条件が明確なエラーの多くはSimple domain errorとして扱うのが適切です。

というのは 誤り だと言えそうです。訂正します。

ただ、 Simple domain error を発生させるような処理は利用頻度が高く、それらが安全にハンドリングできないことによるストレスは大きいとは思います。 Java には Simple domain error を安全かつ快適に扱う良い方法がなく( try-catch は大がかりすぎますし、 null を返しても null チェックを無視できてしまうので安全でありません。しかも、 null はネストできないので、たとえば Iteratornextnull でエラーを表すと、次の値が存在しないのか、次の値が null なのかを区別できません。)、 Simple domain error であるべきエラーが Logic failure として扱われているという点自体は間違いではないので、全体的な論旨はおかしくないと思います。