この投稿は 限定共有に設定されています。 投稿者本人及びURLを知っているユーザーのみアクセスすることができます。

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

  • 2
    コメント

あまり知られてませんが、エラー処理について、Swift 2.0設計時にCore Teamがまとめた"Error Handling Rationale and Proposal"というドキュメントがあります。
https://github.com/apple/swift/blob/master/docs/ErrorHandlingRationale.rst


このドキュメントは、僕が去年try! Swiftで発表した際にも参考文献にしました。 https://github.com/koher/try-swift-2016/blob/master/slides.md 長いし(僕にとっては)英語が難しいし、具体例も少ないしで読むのがとても大変でした。


その中でエラーの分類について記述があるんですが、当時ピンと来なかったのが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 {
  predondition(isPossibleToConvert(string))
  ...
}

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

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


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


そう考えると、『どのようにエラーに対応させたいか』によるエラー分類は特別なことではなく、関数・メソッドの設計時には誰もが当たり前にやっていることのはずです。しかし、僕らは一貫した適切なポリシーを持ってエラーを分類できているでしょうか。


もしそうでなければ、アプリやライブラリ全体としてポリシーのブレたわかりづらい設計になってしまっているはずです。"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でアクセスするのにインデックスがはみ出してしまうようなケースは、ほとんどがコーディング時のミスが原因だとわかります。


試しに、インデックスが範囲外だった場合にエラー処理をして回復する必要がある具体的な処理を何か思い浮かべてみて下さい。僕が思い付いたのは番兵 https://ja.wikipedia.org/wiki/%E7%95%AA%E5%85%B5 の代わりや畳み込み等の画像処理くらいです。


コードに問題があるのであれば回復『不能』であり、実行時にエラーに対処することはできません。コードのバグを修正すべきであり、Arrayのsubscriptのエラーを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"のエラー分類はよく考えれば当たり前のことです。しかし、改めて考え直すと、自分が設計した関数やメソッドでこれを徹底できていたと自信を持って言える人は少ないのではないでしょうか。


ところで、僕が大学生だった頃、ヒューマンインターフェース工学の授業で人間の特性8箇条というものを習いました。「人間は気まぐれである」、「人間はなまけものである」、「人間は不注意である」、「人間は根気がない」、「人間は単調をきらう」などです。


これは、何かを設計するときには人間はそのような欠点を持っているものと考えて、それでも問題が起こらないようにすべきだという指針です。しかし、当時僕が使っていたC言語はとてもそのように設計されているとは思えませんでした。


たとえば、C言語ではファイルを開くには次のようなコードを書きます。もし「不注意」でエラー処理を忘れてしまったら、fileを使おうとしてクラッシュするまで気付くことができません。

FILE *file = fopen("filename", "r");
if (file == NULL) { // ファイルを開くのに失敗したとき
  // エラー処理
}
// `file` を使う処理

人間は「気まぐれ」でエラー処理を書いたり書かなかったりします。異常系は起こらないことが多いので「なまけ」て省略してしまいがちです。省略しないと決めても「不注意」で簡単に忘れてしまいますし「根気がない」ので長続きしません。そして、エラー処理の大半は「単調な」作業です。


初めてJavaに触れて検査例外を知ったとき、素晴らしい仕組みだと思いました。エラー処理を忘れたらコンパイラが教えてくれます。ヒューマンエラーを防ぐ画期的な仕組みです。

try { // try-catch を書かないとコンパイルエラー
  RandomAccessFile file = new RandomAccessFile(new File("filename"), "r");
  // `file` を使う処理
} catch (FileNotFoundException e) { // ファイルを開くのに失敗したとき
  // エラー処理
}

しかし、Javaの検査例外は失敗とみなされ、C#やKotlinなどの後続言語には取り入れられませんでした。ヒューマンエラーを防ぐ素晴らしい仕組みだったのになぜでしょうか。


Javaの検査例外が良くないと言う場合には、Javaの検査例外の構文が良くないのか、検査例外という概念そのものが良くないのかを区別して考えなければいけません。僕の考えは前者です。現に、Swiftは検査例外に似たエラー処理機構を取り入れ、うまく機能しています。


検査例外には二つの性質があります。一つはエラーの種類を静的型で扱うこと、もう一つはエラー処理を強制することです。Swiftでサポートされているのは後者で、前者については議論が続いています。 https://github.com/apple/swift-evolution/pull/68


なので、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なら次の通りです。シンプルですね!絶対に起こらないエラーをLogic failure化するために、毎回Javaのようなコードが必要なら検査例外にうんざりすることでしょう。

let number = try! toInt(string)

しかし、今日の主題はエラーの分類です。"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が採用されており、「不注意」によるエラー処理忘れを静的に検出できる恩恵を受けづらい
  • エラー分類が適切でなければ安全かつ快適なエラー処理は実現できない