【Swift】エラーハンドリングを学び直す! Swift2.2 から Swift3.0での変更点まで

  • 136
    いいね
  • 2
    コメント

2016/08/23 追記:Swift3.0でのErrorプロトコルについて(SE-0112)
2016/10/12 編集:@yusugaさんよりコメントをいただきPerfectの例についての記述を修正

はじめに

これまで、エラーハンドリングをしっかりしていなかったので改めて調べてみました。
備忘録として残し、「処理失敗したらとりあえずfalse返す」みたいなことは卒業します。

また、Swift3.0も近づいてきているということもあり、これまでのSwift2.2のエラーハンドリングを振り返り、最後にSwift3.0で変わった点を記していきます。

Swiftの公式リファレンスを基にしていますが、自分なりにまとめた内容なので、語弊や間違った解釈があるかもしれません。そのような点があればご指摘ください :raised_hands:

そもそもエラーハンドリングとは?

ググってみると「エラーが起きた時の対応処理」というような解釈がたくさん出てきますが、個人的にしっくりきたのは以下の文脈での使われ方でした。

ソフトウェアの中で発生するエラーは多岐にわたる。エラーには、代替策をとれば問題なく先へ進めるものや、プログラム自身が問題で修復できるものもある一方で、別のコンピュータへ役割を引き継いだり、人間の介入を待たなくてはならないものまで多種多様である。
~(中略)~
これらのエラーに適切に対処すること(エラーハンドリング)は非常に重要である。

引用:IPA ISEC セキュア・プログラミング講座:C/C++言語編 第6章 フェイルセーフ:体系だてたエラーハンドリング

つまり、正常な動作を阻害する挙動が生じた時に、それをエラーとし、そのエラーの内容に応じて適切にエラー状態を回復させる処理を行うことをエラーハンドリングというのだと思います。

また、Swift2.2の公式リファレンスでは、以下のように表現されています。

Error handling is the process of responding to and recovering from error conditions in your program.

訳すと、「プログラムのエラー状態に応じて、そのエラー状態を回復するプロセス」といった感じでしょうか?(間違っていたらご指摘ください、、)

エラーハンドリングのポイント

エラーハンドリングがどういうものかを確認したところで、
エラーハンドリングを行うにあたって、どのようなことが重要なのかが少し見えてきました。
(ポイントというか、前提みたいなものも含んでいますが、、)

  1. プログラマが、どこでどのようなエラーが発生しうるかを把握できていること
  2. 1を踏まえて、プログラム上でエラーの内容が事前に定義されている(判別可能な状態になっている)こと
  3. 2で判別可能な状態にされたエラーの内容に応じて、適切な処理が行われること

例えば、何か処理を行って、その処理が成功した時にはtrueを、失敗した時にはfalseを返すようなメソッドはよく見るかと思います。

このようなメソッドを呼んだ際に、falseが返ってきた時の処理が同一のもので対応出来る場合はこのような形でいいかと思います。

しかし、「処理が失敗した」という中にも、何が原因でどのように失敗したかによってリカバリーの処理を分けたい場合には、falseを返すだけではうまく対応できないでしょう。

このような時に以降で述べるような構文を使ってエラーハンドリングを行うと良さそうです。

ようやく本題ですが、Swiftでのエラーハンドリングを見ていきましょう。

Swift2.2でのエラーハンドリング

エラーを定義する

ErrorTypeプロトコル

Swiftでは、エラーの内容をErrorTypeというプロトコルに準拠した列挙型で定義することで便利にエラーハンドリングを行えるようです。

enum VendingMachineError: ErrorType {
    case InvalidSelection
    case InsufficientFunds(coinsNeeded: Int)
    case OutOfStock
}

このErrorTypeプロトコル自体には、何かプロパティやメソッドが定義されているわけではなく、空のプロトコルです。

空のプロトコルであるErrorTypeプロトコルにわざわざ準拠させる理由は、明示的なプロトコルの準拠を行うことでエラーハンドリングのための定義であるということを誰にでも明確に指し示すことができるためです。

エラーを投げる

throw

throw文を使うことで、エラーを投げることができます。

throw VendingMachineError.InsufficientFunds(coinsNeeded: 5)

エラーが発生しうるメソッドの定義

エラーを投げるメソッドを定義するときには、次のように、メソッドの()の後ろにthrowsを書きます。

func canThrowErrors() throws -> String

実装では、上記のthrow文と組み合わせて、次のようにエラーを投げることができます。
throwsでエラーを投げる可能性があることを宣言していても、必ずしもエラーを投げないといけないというわけではありません。

func canThrowErrors() throws -> String {
    if 条件文 {
        throw VendingMachineError.InsufficientFunds(coinsNeeded: 5)
    }
}

実装例

リファレンスの例を紹介します。


enum VendingMachineError: ErrorType {
    case InvalidSelection
    case InsufficientFunds(coinsNeeded: Int)
    case OutOfStock
}

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 dispenseSnack(snack: String) {
        print("Dispensing \(snack)")
    }

    func vend(itemNamed name: String) throws {
        guard let item = inventory[name] else {
            throw VendingMachineError.InvalidSelection
        }

        guard item.count > 0 else {
            throw VendingMachineError.OutOfStock
        }

        guard item.price <= coinsDeposited else {
            throw VendingMachineError.InsufficientFunds(coinsNeeded: item.price - coinsDeposited)
        }

        coinsDeposited -= item.price

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

        dispenseSnack(name)
    }
}

この例ではvendメソッドでエラーが発生する可能性があることを宣言しています。

func vend(itemNamed name: String) throws

そして、guard構文を使用して、列挙型で定義した3種類のエラーを投げています。

このvendメソッドは、以下のようにエラーを投げない実装も考えられますが、
この場合、失敗は一律でfalseが返ってくるので、どのguard文でfalseが返ったのか分からず、エラーに応じた処理ができなくなってしまいます。

エラーをthrowしない場合
func vend(itemNamed name: String) -> Bool {
    guard let item = inventory[name] else {
        return false
    }

    guard item.count > 0 else {
        return false
    }

    guard item.price <= coinsDeposited else {
        return false
    }

    coinsDeposited -= item.price

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

    dispenseSnack(name)
    return true
}

こういった対応が適切でない場合は、前述のようにthrowsをつけてエラーを投げるようにした方が良さそうです。

エラーの伝搬

先ほどの実装例でのvendメソッドを実行するメソッドを考えます。

let favoriteSnacks = [
    "Alice": "Chips",
    "Bob": "Licorice",
    "Eve": "Pretzels",
]
func buyFavoriteSnack(person: String, vendingMachine: VendingMachine) throws {
    let snackName = favoriteSnacks[person] ?? "Candy Bar"
    try vendingMachine.vend(itemNamed: snackName)
}

buyFavoriteSnackメソッドは、中でvendメソッドを実行しています。
このように、メソッド内の処理でエラーを投げるメソッドを実行する場合には、

  • 後述のtry!またはtry?を使用するなど、エラーに対応する処理をメソッド内で行う
  • tryのみを行って、エラーを伝播させる(そのメソッド自身も同じエラーを投げて、エラー処理を外側に任せる)

のどちらかのアプローチが必要になります。
上記例では、後者のアプローチを行っているため、buyFavoriteSnackメソッドもvendメソッド同様のエラーを投げます。

エラーを投げるInitializer

Initializerでも同様にエラーを投げることができます。

struct PurchasedSnack {
    let name: String
    init(name: String, vendingMachine: VendingMachine) throws {
        try vendingMachine.vend(itemNamed: name)
        self.name = name
    }
}

エラーを検知して処理をする

これまでは、エラーを投げる側の実装を見てきましたが、ここからはエラーを受けとる側の処理を見ていきます。

do-catch構文

エラーハンドリングを行うときは、doブロックの中でtryをつけてエラーを投げる可能性のあるメソッドを実行します。
そして、catchブロックでエラーのパターンマッチングを行い、それぞれにあった対応を行うことができます。

do {
    try canThrowErrors() // エラーを投げる可能性のあるメソッド
    // 成功した時の処理
} catch ErrorPattern { // エラーのパターンマッチング
    // エラーの処理
}

こちらも先ほどの例で見た方がわかりやすいかと思います。

var vendingMachine = VendingMachine()
vendingMachine.coinsDeposited = 8
do {
    try buyFavoriteSnack("Alice", vendingMachine: vendingMachine)
} 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.")
}

buyFavoriteSnackメソッドをdoブロック内でtryをつけて実行しています。
もしエラーが投げられた場合は、doブロックの下に続くcatchブロックで処理がされます。
例の場合、catchブロックが3つ設けられていますが、投げられたエラーにマッチするブロックの処理が実行されます。

その他の処理の仕方

上記のようにdo-catch構文を用いて、エラーの内容に沿った処理を行うこともできますが、それ以外に、以下のような処理も可能です。

try?

これは、tryでエラーが投げられた際に、エラーをオプショナルに変換して処理することができます。
次のxyは同じ結果が得られます。

func someThrowingFunction() throws -> Int {
    // ...
}

let x = try? someThrowingFunction()

let y: Int?
do {
    y = try someThrowingFunction()
} catch {
    y = nil
}

try!

try!文は、エラーの伝播を停止します。
確実にエラーが投げられないと分かっている場合には、わざわざエラーハンドリングをする必要がないので、try!文を使用して通常の処理のみを書いていくことができます。

ただし、try!文で実行されたメソッドでエラーが投げられた場合には、実行時エラーになってしまうので、気をつけて使用した方が良さそうです。

let photo = try! loadImage("./Resources/John Appleseed.jpg")

Swift3.0でのエラーハンドリング

Swift3.0では、エラーを定義した列挙型を準拠させていたErrorTypeプロトコルがErrorProtocolErrorプロトコルに変更されます。

SE-0006

==追記:2016/08/23=====
SE-0112でErrorProtocolではなく、Errorになっているようですね。
- https://github.com/apple/swift-evolution/blob/master/proposals/0112-nserror-bridging.md
- https://github.com/apple/swift/commit/823c24b355017e3797c665c656b48fc61f01bc85
==追記ここまで==========

また、エラーハンドリングとは直接的に関係ないですが、Swift3.0では、型とプロトコルの名前以外はlowerCamelCaseで書くようにガイドラインが作成されています。
そのため、Errorに準拠した列挙型を作成する際には、その点の注意も必要です。

前述の例をSwift3.0で書き直すと
enum VendingMachineError: Error {
    case invalidSelection
    case insufficientFunds(coinsNeeded: Int)
    case outOfStock
}

Swift3.0でErrorプロトコルを使用している例 (2016/10/12時点)

例えば、Server-Side SwiftのフレームワークであるPerfectでも、Swift3.0のコードとして、Errorプロトコルを使用しています。

https://github.com/PerfectlySoft/Perfect/blob/master/Sources/PerfectLib/JSONConvertible.swift#L100

終わりに

以上、エラーハンドリングについて調べてみました。
正しくエラーハンドリングを行ってより良いプロダクトを作っていきたいですね。

間違っている点などありましたら、是非ご指摘ください。 :pray:

参考