読者です 読者をやめる 読者になる 読者になる

cockscomblog?

cockscomb on hatena blog

Swift 2 Error Handling in Practice #swift2symposium

Swift 2 から新たに導入されたエラーハンドリングに関する機能を実際のアプリで利用しようとすると、いくつか悩みどころがあることに気付く。これらの問題について議論を深め、実践的な解を求めていきたいと思う。

Which is better? — Optional type or throws

func parseInt(string: String) -> Int?

func parseInt(string: String) throws -> Int

func parseInt(string: String) throws -> Int?

文字列を整数型としてパースする関数のシグネチャ

文字列を整数型にパースする関数があったとして、そのシグネチャはどのようにあるべきだろうか。明らかに、nil を返すかまたはエラーを throw するような、つまり一番下のシグネチャは不適切である。残るふたつ、文字列が整数としてパースできないとき、nil を返すべきか、あるいは何らかのエラーを throw するべきか。整数に nil という状態はあり得ないので nil が返るのは好ましいとは言えない、文字列を整数値にパースしようとしてできないのは明らかな異常系である、といった理由で throw するべきという意見。あるいは、文字列を整数値にするような取るに足りない処理でいちいちエラーハンドリングを強制するのは過剰である、こういったことでエラーが投げられることは相対的にエラーの重みを軽くしてしまう、などの意見。

func parseJSON(data: NSData) -> JSONObject?

func parseJSON(data: NSData) throws -> JSONObject

func parseJSON(data: NSData) throws -> JSONObject?

バイナリを JSON のオブジェクトとしてパースする関数のシグネチャ

一方で、バイナリを JSON としてパースする関数はどうだろう。これについても一番下のシグネチャは明らかに適切でない。JSON がパースできなかった場合はその原因がわかった方がよいだろうから、throws する真ん中のシグネチャがよいのではないか。

このように、正しい値を返せない場合に nil を返すようにするのか、あるいはエラーを throw するのか、どちらのインターフェースが好ましいのだろう。現実的にはコンテキストに依存することもあるだろうし、現在のところ一定した結論はない。

try? 構文の追加によって、インターフェースのユーザーから見れば throws 関数であってもエラーを無視するのが容易になった。このためインターフェースを作る側は気軽に throws にできるようになったこともまたこの議論に影響する。

ErrorType を丁寧に作る

enum JSONDecodeError: ErrorType {
    case MissingRequiredKey(String)
    case UnexpectedType(key: String, expected: Any.Type, actual: Any.Type)
}

struct JSONObject {
    let raw: [String : AnyObject]
    func getValue<T>(key: String) throws -> T {
        guard let value = raw[key] else {
            throw JSONDecodeError.MissingRequiredKey(key)
        }
        guard let typedValue = value as? T else {
            throw JSONDecodeError.UnexpectedType(key: key, expected: T.self, actual: value.dynamicType)
        }
        return typedValue
    }
}

JSON のオブジェクトから何らかの型の値にマッピングしようというときに投げられるエラー

JSON のオブジェクトから何らかの型の値にマッピングするとき、本来存在するべきキーが存在しなかったり、予期しているものと違う型の値が入っている場合に、それをエラーとしてレポートしたい。どういった ErrorType を定義するのが良いのだろうか。

エラーを見たときすぐに問題に気付けるように、ErrorType は丁寧に作られるべきである。加えて CustomDebugStringConvertible などを実装し、エラーを読みやすくする工夫もあると良いだろう。

enum JSONDecodeError: ErrorType, CustomDebugStringConvertible {
    case MissingRequiredKey(String)
    case UnexpectedType(key: String, expected: Any.Type, actual: Any.Type)

    var debugDescription: String {
        switch self {
        case .MissingRequiredKey(let key):
            return "Required key '\(key)' missing"
        case let .UnexpectedType(key: key, expected: expected, actual: actual):
            return "Unexpected type '\(actual)' was supplied for '\(key): \(expected)'"
        }
    }
}

CustomDebugStringConvertible に準拠することで debugPrint した際の可読性を改善できる

できることは他にもある。エラーを投げる関数の側に、ドキュメンテーションコメントを利用してどのようなエラーが投げられ得るか記述しておくことができる。これはその関数の利用者にとって有益な情報になるはずだ。

struct JSONObject {
    let raw: [NSString : AnyObject]

    /**
    Get typed value for the key

    - parameters:
      - key: JSON key
    - returns: Typed value
    - throws: JSONDecodeError
    */
    func getValue<T>(key: String) throws -> T {
        guard let value = raw[key] else {
            throw JSONDecodeError.MissingRequiredKey(key)
        }
    ...

ドキュメンテーションコメントの throws セクションで投げられるエラーの種類を明示できる

ErrorType と NSError

enum MyError: ErrorType {
    case SuperError
}

let error = MyError.SuperError as NSError

print(error.localizedDescription)
// The operation couldn’t be completed.
       (Module.MyError error 0.)

ErrorTypeNSError 型にキャストすることができる。NSErrorErrorType に準拠することになっているので、この逆のキャストは自然であるが、ErrorType から NSError へのキャストができるのは不自然である。おそらく暗黙的な変換が内部で行われている。

NSErrorErrorType よりも役割の広い型であった。localizedDescriptionlocalizedRecoverySuggestion といった、ユーザーインターフェイスに表示されることを意図した機能が NSError には存在している。ErrorType はそのような機能を規定しておらず、つまり ErrorTypeNSError にキャストした場合には、localizedDescription などに NSError のデフォルトの実装が使われる。しかしデフォルトの実装はユーザーインターフェイスとの連携にあまり適さず、これまでのようにエラーをユーザーインターフェイスに反映させるのが難しくなった。

NSErroriOS 9 や OS X 10.11 から新たに、public class func setUserInfoValueProviderForDomain(errorDomain: String, provider: ((NSError, String) -> AnyObject?)?) というクラスメソッドを持つ。これは localizedDescription などに対応するキーが userInfo 辞書に存在しない場合に利用される。つまり、事前に NSError にユーザーインターフェイス向けの情報が設定されていなくても、オンデマンドにそれらを設定できるのである。しかし、ErrorTypeNSError になったとき(内部的に _SwiftNativeNSError になっていて)、本来 ErrorType であったときに持っていた情報は全て失われ、domaincode だけが残っている。この状態から元のエラーの原因を識別し、意味のある情報を得るのは難しい場合も多い。

これらのことから、ErrorTypeNSError として取り扱うのはあまり筋が良くない。元々持っていた情報や機能が失われてしまう。

議論の結果、例えば ErrorDescriptable のような protocol を定義し、var localizedDescription: String { get } のようなプロパティから情報を得られるようにしておき、ErrorDescriptable の protocol extension に var toNSError: NSError { get } といった機能をつけるのが良いのではないか。これを利用して NSError にキャストせずに直接 NSError を作ることで、有用な情報を保ったまま NSError オブジェクトを得られるのではないか。


Swift の言語機能を実際のソフトウェアの中でどのように活かすかというのは、Swift でソフトウェアを書こうとする私たちが直面する大きなテーマである。エラーハンドリングだけを取り上げたとしても、ここまでに述べたような多様な問題や選択肢が存在している(それに加えて非同期のエラーハンドリングというさらに大きな問題がある)。こういったテーマについて考え、それを実践し、そしてさらなる議論を深めていくことで、Swift 時代における iOS アプリ開発の定石を発見していきたい。

この記事は Swift 2シンポジウム #2 - 2015/08/30(日) - dots. [ドッツ] で発表し、議論した内容を簡単にまとめたものです。