My Favorite Things - Coding or die.

とある技術者の経験記録、的な。

Advanced Swift メモ - 8. Error Handling

8. Error Handling

  • Swiftにはいくつものエラーハンドリングの仕組みがある。
    • Optional、はシンプルだがエラー情報は返せない。
    • Assertion、はバグを早期発見するために利用できる。
    • 例外、はOptionalと違い詳細なエラー情報を持てる。
  • CollectionTypeのfirst/lastなどはOptionalだが、失敗の理由が「配列が空」しかないので適切。
  • ネットワークエラーなどは、失敗の理由を知りたいこともあるはずなのでErrorTypeが良い。

The Result Type - P.248

enum Result<A> {
    case Failure(ErrorType)
    case Success(A)
}
  • Swiftのエラーハンドリングは、殆どResultTypeと同じように実装されている。
  • throwsで宣言された関数を呼び出した時は、キャッチするか伝播させる必要がある。
  • try-catchは他の言語と似ているが、Swiftはランタイム上の処理コストは殆どかからない。
  • ResultTypeとの大きな違いは、エラーが型として定義されていないこと。
    • 厳格な型情報が欲しければResultTypeを使うと良い。
    • でも標準のtry-catchを使ったほうがシンプルではあるかも。

Errors and Objective-C Interface

  • NSErrorポインタを受けるようなObjective-Cの関数はthrowsに変換される。
    • エラーポインタの引数は除外され
    • 戻り値で成否を返すようなものはVoidになる。
- (NSString *)contentsOfFile(NSString*)fileName error:(NSError **)error;
func contentsOfFile(fileName: String) throws

Errors and Function Parameters - P.253

  • すべての要素が条件を満たすか判定するメソッドallは以下のように実装できる。
extension SequenceType {
    func all(@noescape check: Generator.Element -> Bool) -> Bool {
        for el in self {
            guard check(el) else { return false }
        }
        return true
    }
}
  • しかし上記のバージョンではthrows宣言された関数を渡すことが出来ない。
func isEven1(x: Int) -> Bool {
    return x % 2 == 0
}
func isEven2(x: Int) throws -> Bool {
    return x % 2 == 0
}

(1..<10).all(isEven1) // OK
(1..<10).all(isEven2) // Compile error
  • そこでmapなどの標準メソッドは以下のようにrethrowsで宣言されている。
extension SequenceType {
    func all(@noescape check: Generator.Element throws -> Bool) rethrows -> Bool {
        for el in self {
            guard try check(el) else { return false }
        }
        return true
    }
}

try! (1..<10).all(isEven2) // OK

Cleaning Up Using defer - P.255

guard let database = openDatabase(...) else { return }
defer { closeDatabase(database) }
guard let connection = openConnection(database) else { return }
defer { closeConnection(connection) }
guard let result = runQuery(connection, ...) else { return }
  • deferは他の言語のfinallyと似ているが少し異なる点もある。
  • try/doブロックに繋げる必要はなく、どこでも書くことが出来る。
  • deferを同一ブロックで複数宣言した場合、逆順に実行される(スタックのように)
  • セグメンテーションフォルトあるいはfatal errorなどの場合は実行されない(プログラムはクラッシュする)

Errors and Optionals - P.256

if let contents = try? parseFile("Hello.md") {
    print(contents)
}
  • エラーがthrowされなかった場合だけ処理したいときはtry?が使える。
  • Optionalがnilだった場合に、代わりの例外をthrowするような関数は以下のように書ける。
func optional<A>(value: A?, onError e: ErrorType) throws -> A {
    guard let x = value else { throw e }
    return x
}
let int = try optional(Int("42"), onError: ReadIntError.CouldNotRead)
  • try?キーワードは、エラーを無視するべきではないというSwiftの哲学とは矛盾しているようにも見える。
  • しかし、try?は明示的に書く必要がある。
  • あなたがエラーの詳細な内容について興味がないときには、これは便利である。

Chaining Errors - P.257

  • 例外がthrowされる可能性のあるメソッド呼び出しがチェーンしても、ifなどのようにネストしたりしない。
  • 例外が発生した場合は、catchにコントロールが移動して処理される。
func checkFilesAndFetchProcessID(filenames: [String]) -> Int {
    do {
        try filenames.all(checkFile)
        let contents = try contentsOfFile("Pidfile")
        return try optional(Int(contents),
            onError: ReadIntError.CouldNotRead)
    } catch {
        return 42
    }
}
  • これをResultで実装することも簡単に出来る。
  • optionalのように成功の場合は中身を取り出して適用し、失敗の場合は何もしない。
  • そうしたコードはとてもエレガントになる。
func checkFilesAndFetchProcessID(filenames: [String]) -> Int {
    return filenames
        .all(checkFile)
        .flatMap { _ in contentsOfFile("Pidfile") }
        .flatMap { contents in
            Int(contents).map(Result.Success) ?? .Failer(ReadIntError.CouldNotRead)
        }
}

Higher-Order Functions and Errors - P.258

  • この本を執筆している段階では、Swiftのエラーはコールバック関数に向いていない。
  • 単純なケースでは、コールバックの引数をoptionalにすることで解決できる。
    • nilが入っていた場合は失敗をみなす、というように。
func compute(callback: Int -> ()) // 結果をIntで返す
func compute(callback: Int? -> ()) // 失敗した場合はnil
  • 詳細なエラー情報を返すために、最初は以下のようにしてthrows宣言しようと思うかもしれない。
func compute(callback: Int throws -> ())
  • しかし、この宣言は完全な誤りで、型ではなく関数に修飾するthrowsの使い方としてはNG。
  • この宣言をResultで書きなおした場合どうなるかやってみると、間違いであることに気づく。
func compute(callback: Int -> Result<()>)
  • 我々が欲しいのは、以下のようにコールバック関数の引数がResultであるものだ。
func compute(callback: Result<Int> -> ())
  • throwsを使った、現在ではあまり分かりやすくない書き方もある。
func compute(f: (() throws -> Int) -> ())
  • これを利用する側はさらに複雑になる。
compute { (theResult: () throws -> Int) in
    do {
        let result = try theResult()
        print(result)
    } catch {
        print("An error happend: \(error)")
    }
}
  • なのでResultは非同期エラーハンドリングの選択としては良い。
  • しかし、同期関数でthrowsをすでに使用している場合、Resultを使用している箇所との食い違いで、APIが使いづらく鳴るかもしれない。
  • あなたが多くの非同期関数を書いている場合、トレードオフとしてResultを使う価値はある。
  • しかし、一つのコールバックしかないようであれば、先ほどのネストした関数を利用するのも良い選択肢だ。

Conclusion - P.260

  • AppleがSwift 2.0にエラーハンドリングを導入した時、多くのイケてないことが発生した。(訳間違ってるかも)
  • Swift 1.xでは、殆どResult型がエラー処理に使われていた。
  • 実際、型付けされていないthrowsは、嬉しいような、そうでもないような気持ちがあった。(意訳)
  • throwsの利点として、型シグネチャの宣言がシンプルであるという点がある。
  • 例えば、複数のエラーが発生する可能性のある関数の場合、以下のようになっていたかもしれない。
func checkFilesAndFetchProcessID(filenames: [String])
    throws ReadFileError, CheckFileError, MiscellaneousError -> Int // この書き方はしんどい
  • しかし、これは大きな欠点も残した。
    • どのエラーが発生したか明示できず、余分なボイラープレートコードを必要とした。
    • さらに、throwsが関数のみに作用するため、不必要な複雑さを生むことになった。(非同期コールバックのように)
  • Swiftが80%に最適化された、実用的な言語であると考えると、この欠点はシンプルな振る舞いから外れる。

    • 組み込みのエラーハンドリングを利用することで、関数を結果に包んだり、不必要な複雑さをもたらす。
    • そしてあなたが思っているように、我々は曖昧なエッジケースについてここでは言及していない。(非同期コールバックのみ)
  • もし特別なエラー情報が欲しい場合、Result型(エラーをジェネリック型で表現したもの)を使用することが出来る。

  • しかし、これは他の複雑さをあなたのコードに持ち込むことにもなる。
  • あなたが作っているものによっては、この複雑さを持ち込む価値はあるかもしれない。

  • このようにコード中の期待しない動作をハンドリングする仕組みは多くある。

    • 継続できないのであればfatalErrorまたはAssertion
    • エラーの種類に興味がないか、1種類に限定できるのであればOptional
    • エラーの種類が必要か、追加情報が欲しい場合、Swift標準の例外か、Result
    • 関数を引数に受け取る関数を宣言するときは、rethrowsを使うことで、throws宣言された関数も受け取れるようになる
    • そしてdeferは標準のエラーを扱うときにとても便利である
    • deferを使ったコードはスコープを抜けるときにクリーンアップとして必ず実行される(throwsあるいはreturnなど)