Open4

[Swift] エラー処理 do-catch, throw, try

🍤🍤

なぜエラー処理を行う必要があるのか。

  • 利用者が失敗に遭遇する機会を減少させる。
  • 失敗の内容を伝えることで利用者に復帰操作を促すことができる。

Swiftのエラー4分類

  • Swiftのエラーは、発生時どう対応させたいのかにより4つに区分することができる。
  1. Simple domain error (単純なドメインエラー)
  2. Recoverable error (リカバリーエラー)
  3. Universal error (ユニバーサルエラー)
  4. Logic failure (ロジックの失敗)

Simple domain error (単純なドメインエラー)

  • 処理が失敗したのかどうかだけわかればいい。
  • その原因は畢竟どうでもいい。というと語弊がありそう。。
  • どうでもいいというか、エラーが発生したとしてもその原因が明白なシーンで使われる。
  • 例えば、Optional型で戻り値がnilだったパターンとか、これに当てはまる。
Simple domain error の例
//発生の前提条件が明確なので、エラーが起こったという結果さえわかれば良い
guard let number = Int(string) else {
  print("整数を入力して下さい。")
  return
}

// `number` を使う処理

Recoverable error (リカバリーエラー)

  • エラーの発生+回復可能かどうかも考慮。
  • Simple domain errorとの違いは、原因にも気を配るかどうかというところ。
  • このように、操作に失敗した時・異常な状態が起こった際、その状況から回復する(回復できるかどうかはどのような事態が発生したかによる)上で、失敗の原因を掴むことが役に立つ。
  • ファイル入出力やネットワークのエラーのように、発生条件が明確でなく、どのように回復すべきか原因によって対応方法が異なる。
  • だから、原因によって適した回復手段を提供する必要がある
  • Swiftのエラーは、Errorプロトコルに準拠した型の値で示される。
  • 話がちょっと脱線するが、Recoverable errorとenum型は相性がいい。
  • enum型は、値によってエラーに対する追加情報を伝えることができる。
  • そのため、関連するエラーの状態をモデル化することに特に適している。
  • 以下は自動販売機を操作する際のエラー条件。
  • Swiftのエラーは、Errorプロトコルに準拠した型の値で示される。
enum型でエラーの状態を伝える。
enum VendingMachineError: Error {
    case invalidSelection  //無効な選択
    case insufficientFunds(coinsNeeded: Int)  //金額不足
    case outOfStock  //在庫切れ
}

Universal error (ユニバーサルエラー)

  • 異常な挙動が生じたらプログラムを停止して仕舞えばいい。利用者側で対応しようがないのでプログラムを停止すべき。と考えた場合活用する。
  • そもそも復帰方法が存在しないようなエラー、その操作が実行されること自体が想定外(考慮する必要がないことが明らか)な場合はこれを使う。
  • 使うべき状況としてはメモリ不足やスタックオーバーフローなどのエラー。
    (スタックオーバーフロー : 用意されているサイズ以上のスタック領域を使用してしまい、スタックが不足する状態。 プログラムのバグや設計ミス(スタック使用量の見積もりミス)などに起因)
  • 逆に、想定外の挙動が十分起こり得る余地がある場合(例えば外部のリソースに依存した操作など)は、回復する余地を含ませた実装が望ましい。
  • Universal error を発生させるために fatalError を使う。
Universal error
// <T, R>はジェネリクス。構造体やクラス名のあとに、<型引数> のフォーマットで書く。
// 型引数 が抽象化された型で、実際に使用するときに具体的な型を指定すると置き換える。
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
  )
//CountableRange<T>, initial: R もしrange.firstがnilでなかったら
}

reduce(0..<1000000, 0) { $0 + $1 } // スタックオーバーフローでクラッシュ

Logic failure (ロジックの失敗)

  • 条件を指定し、その条件が満たされなかった場合は実行時エラーとしてクラッシュさせる。
  • Logic failureは実行時に起こってはいけないことなので、生じた場合の挙動は未定義。
  • 事前条件を呼び出す責務は、その関数やメソッドを呼び出す側が負っている。(外部から与えられた値のチェックで使われる)(引き起こすのは操作する側)
  • Array のインデックスがはみ出てしまった場合や、 nil が入っている Optional に対して Forced unwrapping を実行してしまった場合など。
  • preconditionとかOptional型など使う
let array = [2, 3, 5]
print(array[3])

func foo(x: Int) {
  precondition(x >= 0)
  print(x) // `x` が 0 以上のときだけ実行される
}

foo(x: 42) // OK
foo(x: -1) // NG
  • 利用者に対し、「どのようにエラーに対応させたいか」によってエラーを分類する。
  • nilを返すのか、Errorをthrowするのか、それともクラッシュさせるのか。

僕が一番おもしろいと思い、感銘を受けたのが Logic failure です。どうして Array はインデックスが範囲外だった場合に Simple domain error としてnilを返さないのでしょうか。 Dictionary はキーに対応した値がなければ nil を返します。(中略) よく考えてみると、 Array の要素に subscript でアクセスするのにインデックスがはみ出してしまうようなケースは、ほとんどがコーディング時のミスが原因だとわかります。
試しに、インデックスが範囲外だった場合にエラー処理をして回復する必要がある具体的な処理を何か思い浮かべてみて下さい。僕が思い付いたのは番兵の代わりや畳み込み等の画像処理くらいです。コードに問題があるのであれば回復『不能』であり、実行時にエラーに対処することはできません。コードのバグを修正すべきであり、 Array の subscript のエラーを Logic failure として扱うのは理にかなっています。(中略)Array のインデックスが範囲外になるのはコードの問題( Logic failure )なのだから、実行時に範囲チェックする必要はないわけです。その上で、開発時( -Ounchecked でないとき)は素早くコードの誤りに気付けるようにチェックして停止させてくれるわけです。
https://qiita.com/koher/items/a7a12e7e18d2bb7d8c77

(コンパイル時の-Oオプションはコードの最適化。-Ouncheckedを加えてコンパイルすると、配列の添字の範囲チェック、整数のオーバーフローのチェックをしない高速な実行コードを生成する。)

  • out-of-bounds.を使うと、範囲超えててもとりあえず実行してくれるってことなんかな。
  • Ouncheckedを使ってないときは静的解析してくれて、コードの誤りを指摘してくれる。
  • Arrayの境界値超えエラーは、言語のそもそもの仕様としてコードの問題( Logic failure )とされている。
🍤🍤

Swiftのエラー処理には、4つの方法がある。

  1. 関数からその関数を呼び出すコードにエラーを伝播させる。
  2. do-catch 文を使用してエラーを処理する。
  3. オプションの値としてエラーを処理する。
  4. エラーが発生しないことを表明する。
  • 関数がエラーを投げると、プログラムの流れが変わってしまう。
  • そのため、コードの中でエラーを投げる可能性のある場所をすぐに特定できるようにしておくことが重要。
  • そのために、try, try?, try! というキーワードがある.
  • 使い方としては、tryキーワードを、エラーをthrowsする可能性のある関数、メソッド、またはイニシャライザを呼び出すコードの頭に記述するというもの。
  • 関数宣言でパラメータの後にthroesキーワードを付けられたものは、エラーを投げる。
  • throwsでマークされた関数はthrowing関数と呼ばれる。
  • もし戻り値の方を指定する場合は、throws キーワードを戻り値の矢印 (->) の前に記述する。
  • また、throwsする例外にはErrorプロトコルを実装しておく必要がある。
enum SomeError: Error {
  case unexpected(String)
}

// throws キーワードを宣言する
func method() throws -> Void {
  print("関数が呼びされました")
  throw SomeError.unexpected("予期せぬエラーです")
}
//呼び出す側は try キーワードを使って関数を呼び出し、エラーを投げることができる。
do {
  try method()
} catch SomeError.unexpected(let error) {
  print(error)
}

/* 実行結果 */
// 予期せぬエラーです

  • throw関数だけがエラーを伝播させることができる。
  • 以下のコードには、VendingMachine クラスに vend(itemNamed:) メソッドがある。
  • 要求された商品が入手できなかったり在庫切れだったり、 あるいは価格が現在の預託金額を上回ったりした場合に適切な VendingMachineError をスローしている。
struct Item {
    var price: Int
    var count: Int
}

class VendingMachine {
// 在庫
    var inventory = [
        "Candy Bar": Item(price: 12, count: 7),
        "Chips": Item(price: 10, count: 4),
        "Pretzels": Item(price: 7, count: 11)
    ]

//お金を預け入れ
    var coinsDeposited = 0

//販売する
    func vend(itemNamed name: String) throws {
        guard let item = inventory[name] else {
            throw VendingMachineError.invalidSelection
//もし販売されていない商品が選択されたら、無効な選択がthrowsされる。
        }

        guard item.count > 0 else {
            throw VendingMachineError.outOfStock
//もし品物が切れていたら、在庫切れがthrowsされる。
        }

        guard item.price <= coinsDeposited else {
            throw VendingMachineError.insufficientFunds(coinsNeeded: item.price - coinsDeposited)
//もし投入したお金が少なかったら、お金が足りないという意味のエラーを出す。
        }

        coinsDeposited -= item.price

        var newItem = item
        newItem.count -= 1
        inventory[name] = newItem

        print("Dispensing \(name)")
    }
}
  • vendメソッドの実装では、guard文を利用してメソッドを早期に終了させ、お菓子の購入条件が満たされていない場合は適切なエラーが返されるようにしている。
  • vend(itemNamed:) メソッドはスローしたエラーを伝播するため、このメソッドを呼び出すコードは do-catch 文、try?、try!などでエラーを処理するか、エラーを後続の処理に伝える必要がある。
  • 例えば、以下の例の buyFavoriteSnack(person:vendingMachine:) は throw 関数でもある。
  • vend(itemNamed:) メソッドが投げるエラーは buyFavoriteSnack(person:vendingMachine:) 関数が呼ばれる時点まで伝達される。
let favoriteSnacks = [
    "Alice": "Chips",
    "Bob": "Licorice",
    "Eve": "Pretzels",
]
//好きなお菓子を買うfunction
func buyFavoriteSnack(person: String, vendingMachine: VendingMachine) throws {
    let snackName = favoriteSnacks[person] ?? "Candy Bar"
    try vendingMachine.vend(itemNamed: snackName)
}

🍤🍤

do-catch文

  • 回復可能なエラーについては、do-catchステートメントを用いることが基本。
  • do節のコードでエラーが発生した場合、catch節と照合して、どの節がエラーを処理できるかを判断、エラー処理を行う。
  • do { try throws宣言された関数呼び出し } catch { ... }といった構文で例外をキャッチ
  • throws宣言された関数を呼び出すときには、頭にtry、try!、try?のいずれかをつける.
  • catch { ... }またはcatch let error { ... }ですべての例外をキャッチ(switchのdefaultみたいな)
  • enumのようにエラーの種類から網羅性はチェックされないので、網羅するにはすべての例外を補足するcatchが必須
do-catch
do {
    try expression
    statements
} catch pattern 1 {
    statements
} catch pattern 2 where condition {
    statements
} catch pattern 3, pattern 4 where condition {
    statements
} catch {
    statements
}
  • エラーが発生する可能性のある箇所にthrowsを設置して、投げて(throws) もらう。
  • 異常事態(throws)が発生したメソッドを呼び出した側のメソッドでは、投げられた例外を 捕らえる(catch) ことができる.
  • あるいは、さらに上位のメソッドに例外を投げて(throws) 上位メソッドに処理を委ねる場合もある。
  • catchの後にパターンを置いて、その節がどのようなエラーを扱えるのかを示す。
  • catch説にパターンがない場合その節はあらゆるエラーにマッチし、その節をerrorという名前のローカル関数にバインドする。
  • 例えば下記のコードはVendingMachineError 列挙型の 3 つのケースすべてに対してマッチする。
var vendingMachine = VendingMachine()

vendingMachine.coinsDeposited = 8
do {
    try buyFavoriteSnack(person: "Alice", vendingMachine: vendingMachine)
    print("Success! Yum.")
} catch VendingMachineError.invalidSelection {
    print("Invalid Selection.")
} catch VendingMachineError.outOfStock {
    print("Out of Stock.")
} catch VendingMachineError.insufficientFunds(let coinsNeeded) {
    print("Insufficient funds. Please insert an additional \(coinsNeeded) coins.")
} catch {
    print("Unexpected error: \(error).")
 //enumの関連値はパターンマッチングで全て拾うことができる
}
// Prints "Insufficient funds. Please insert an additional 2 coins."
  • エラーが投げられた場合、実行は直ちにcatch節に移行し、引き続き継続させるかどうかが決定される。

部分的に例外を補足するdo-chach

  • あるdo-catch節のパターンマッチングでは処理されないエラーがthrowsされた場合、エラーは周囲のスコープへさらにthrowsされる。
  • 以下実例。VendingMachineError 以外のエラーは呼び出し側の関数でキャッチするように書くこともできる。
//周辺のdo-chachにエラーを伝播させるので、throwsをつけている
func nourish(with item: String) throws {
    do {
        try vendingMachine.vend(itemNamed: item)
    } catch is VendingMachineError {
        print("Couldn't buy that from the vending machine.")
    }
}
//
do {
    try nourish(with: "Beet-Flavored Chips")
} catch {
    print("Unexpected non-vending-machine-related error: \(error)")
//VendingMachineError 以外を全部受け止めてくれるcatch。
}
// Prints "Couldn't buy that from the vending machine."

-もしvend(itemNamed:) が VendingMachineError 列挙型のいずれかのエラーを投げたら、 nourish(with:) はメッセージを表示してそのエラーを処理する。

  • そうでない場合nourish(with:) はそのエラーを呼び出し元サイトに伝播させる。
  • そのエラーは最終的には一般的な catch節で捕捉される。
func eat(item: String) throws {
//eat(item:) 関数は、catchすべき自動販売機のエラーをリストアップ
    do {
        try vendingMachine.vend(itemNamed: item)
    } catch VendingMachineError.invalidSelection, VendingMachineError.insufficientFunds, VendingMachineError.outOfStock {
        print("無効な選択、在庫切れ、お金が足りない")
//リストされた3つのエラーのいずれかがスローされた場合、このcatch節はメッセージを表示してそれらを処理する。
//それ以外のエラーは、後で追加されるかもしれない自動販売機のエラーも含めて、周囲のスコープに伝搬する。
    }
}

  • 周囲のdo-chachに伝播させるゆうても、実際使うとなるとややこしくなりそう。
func catchAllErrors() {
    do {
        try fatalError() // `throws`宣言された関数を呼ぶには`try`が必要
    } catch MyError.notFound { // エラーの種類を記述
        // ...
    } catch MyError.fail(let message) { // enumの関連値はパターンマッチで取得できる
        // ...
    } catch let error { // enumと違って網羅するにはこれが必須
        // ...
    }
}
  • 結局この形ばかり好んで使っちゃいそうな予感。。。
🍤🍤

例外を安全的に無視する(try?)

  • try?を使用すると、エラーをOptional型に変換して処理できるようになる。
  • 呼び出した関数に戻り値があり、そしてその関数がエラーを投げた場合、代入される値はnilになる。
enum SomeError: Error {
  case fail(Int)
}

func someThrowingFunction() throws -> Int {
        print("関数が呼びされました。エラーをthrowsします。")
        throw SomeError.fail(2)
}

let x = try? someThrowingFunction()  // 戻り値がない場合はセーフな呼び出し

let y: Int?
do {
    y = try someThrowingFunction()
} catch SomeError.fail{
    y = nil
}
print(x) //nil
print(y) //nil
  • someThrowingFunction()がエラーをthrowsした場合、x, yの値はnilになる。
  • それ以外の時は戻り値が代入される。
  • try?を利用すると、すべてのエラーを同じ手法で処理したい時に簡潔なコードを書くことができる。
  • 例えば、次のコードはデータを取得するために複数のアプローチを使用し、そのすべてが失敗した場合にnilを返す。
func fetchData() -> Data? {
    if let data = try? fetchDataFromDisk() { return data }
    if let data = try? fetchDataFromServer() { return data }
    return nil
}

エラー全般を強制的に無視する(try!)

  • 関数やメソッドを投げても、実際には実行時にエラーを投げないことが分かっている場合。エラーを投げない処理であるということを示したい場合に使用する。
  • Universal error (ユニバーサルエラー)に該当するエラー対応。
  • try!を利用すると例外が無視できる(do-chatch)
  • 万が一例外が発生した場合、アプリそのものがクラッシュする(強制アンラップと同じ挙動)
func forceCall() {
    try! notError() // 例外を無視する(が、発生したときはクラッシュする)
}
  • 例えば次のコードでは loadImage(atPath:) 関数を使用しており、指定したパスにある画像リソースをロードするか、画像をロードできない場合はエラーを投げている。
  • この場合画像はアプリケーションに同法されているので、実行時にエラーは発生しない。
let photo = try! loadImage(atPath: "./Resources/John Appleseed.jpg")