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 がパースできなかった場合はその原因がわかった方がよいだろうから、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 のオブジェクトから何らかの型の値にマッピングするとき、本来存在するべきキーが存在しなかったり、予期しているものと違う型の値が入っている場合に、それをエラーとしてレポートしたい。どういった 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.)
ErrorType
は NSError
型にキャストすることができる。NSError
は ErrorType
に準拠することになっているので、この逆のキャストは自然であるが、ErrorType
から NSError
へのキャストができるのは不自然である。おそらく暗黙的な変換が内部で行われている。
NSError
は ErrorType
よりも役割の広い型であった。localizedDescription
や localizedRecoverySuggestion
といった、ユーザーインターフェイスに表示されることを意図した機能が NSError
には存在している。ErrorType
はそのような機能を規定しておらず、つまり ErrorType
を NSError
にキャストした場合には、localizedDescription
などに NSError
のデフォルトの実装が使われる。しかしデフォルトの実装はユーザーインターフェイスとの連携にあまり適さず、これまでのようにエラーをユーザーインターフェイスに反映させるのが難しくなった。
NSError
は iOS 9 や OS X 10.11 から新たに、public class func setUserInfoValueProviderForDomain(errorDomain: String, provider: ((NSError, String) -> AnyObject?)?)
というクラスメソッドを持つ。これは localizedDescription
などに対応するキーが userInfo
辞書に存在しない場合に利用される。つまり、事前に NSError
にユーザーインターフェイス向けの情報が設定されていなくても、オンデマンドにそれらを設定できるのである。しかし、ErrorType
が NSError
になったとき(内部的に _SwiftNativeNSError
になっていて)、本来 ErrorType
であったときに持っていた情報は全て失われ、domain
と code
だけが残っている。この状態から元のエラーの原因を識別し、意味のある情報を得るのは難しい場合も多い。
これらのことから、ErrorType
を NSError
として取り扱うのはあまり筋が良くない。元々持っていた情報や機能が失われてしまう。
議論の結果、例えば 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. [ドッツ] で発表し、議論した内容を簡単にまとめたものです。