こんにちは。スタメンでiOSアプリを開発している @temoki です。
モバイルアプリ開発に限らずソフトウェアの実装においては必ずエラーハンドリングが必要になりますよね。iOSアプリを Swift で開発する場合、回復可能なエラーのハンドリングについては次のように do-catch ステートメントを用いることが基本となっています*1。
do { // `func functionThatCanCauseError() throws -> Int` let value = try functionThatCanCauseError() print(value) } catch let error { print(error) }
他には、Swift 5 で追加された Result 型 *2 を用いて次のように行うことも多いですね。
// `func functionThatCanCauseError() -> Result<Int, SomeError>` let result = functionThatCanCauseError() switch result { case .success(let value): print(value) case .failure(let error): print(error) }
さて、このどちらのケースでもエラーとして取り回されるのが Error
です。今回はこの Error
について深堀りし、巧く活用することを考えてみようと思います。
Swift のエラー型
Swift の Error
は次のように空のプロトコルとして定義されています。
public protocol Error { }
そして Foundation フレームワーク *3 にて、エラーのローカライズされた説明を取得するための localizedDescription
プロパティが拡張されています。
extension Error { /// Retrieve the localized description for this error. public var localizedDescription: String { get } }
さて、この Error
プロトコルに準拠したエラーを次のように定義してみます。
struct SomeError: Error { var localizedDescription: String { return "This is SomeError." } }
ここでクイズです。下記のコードのようにスローされた SomeError
の localizedDescription
を出力した結果はどうなるでしょうか?
do { // Call function that throws `SomeError`. try functionThrowsSomeError() } catch let error { print(error.localizedDescription) }
答えはこのようになります。
The operation couldn’t be completed. (__lldb_expr_61.SomeError error 1.)
なんと This is SomeError
ではありません!do-catch ステートメントでスローされたエラーはすべて Error
型として扱われますが、Error
の localizedDescription
には Protocol Extension によるデフォルト実装があり、そのデフォルト実装の方で処理されるためにこのような挙動となります。
これは Error
に準拠したカスタムエラーを作る場合に陥りやすい罠ですね。では、この例で意図していた結果を localizedDescription
で取得できるようにするにはどうすると良いでしょうか?
ローカライズされたエラー説明を提供可能なエラー型
このようにローカライズされたエラー説明を提供するエラーを定義するには LocalizedError
プロトコル *4 を使用します。LocalizedError
は Foundation フレームワークに次のように定義されています。
/// Describes an error that provides localized messages describing why /// an error occurred and provides more information about the error. public protocol LocalizedError : Error { /// A localized message describing what error occurred. var errorDescription: String? { get } /// A localized message describing the reason for the failure. var failureReason: String? { get } /// A localized message describing how one might recover from the failure. var recoverySuggestion: String? { get } /// A localized message providing "help" text if the user requests help. var helpAnchor: String? { get } }
プロトコルに定義されているプロパティのうち、 errorDescription
プロパティが localizedDescription
の結果として使われます。試しに先ほどの SomeError
を次のように変更してみましょう(すべてのプロパティにはデフォルト実装が提供されているため、実装を省略することができます)。
struct SomeError: LocalizedError { var errorDescription: String? { return "This is SomeError." } }
そうすると先ほどの結果は期待していた This is SomeError.
となりました!
思ったような結果が得られたところで、LocalizedError
の他のプロパティに注目してみてください。failureReason
, recoverySuggenstion
, helpAnchor
。これらの名前はどこかで見たことはないでしょうか?
Cocoa のエラー型
iOSやmacOSのアプリを開発したことがあれば必ず目にする Cocoa *5 のエラー NSError
*6 に同じような名前のプロパティが存在します。
localizedDescription
localizedFailureReason
localizedRecoverySuggestion
localizedHelpAnchor
このことから Swift の Error
, LocalizedError
はこの Cocoa のエラーとの関連性がありそうです。実際に Foundation フレームワークには Error
プロトコルを継承したエラー型として、この LocalizedError
の他に、RecoverableError
, CustomNSError
, NSError
が定義されており、Swift の Error
と Cocoa の NSError
には深く関係しています。
NSError
と Error
NSError
のインスタンスを生成するにはエラードメインとエラーコードの2つの要素が必須です。
Cocoa は Objective-C で実装されており、エラーを伴う Cocoa API は次のように引数で NSError
を受け取るように設計されています。*7
NSError *error = nil; BOOL success = [receiver someMessageWithError:&error]; if (!success) { NSLog(error.domain); }
この API は次のように Error
をスローする形式で Swift にインポートされます。
do { try receiver.someMessage() } catch let error as NSError { print(error.domain) }
ここで注目したいのが Error
から NSError
へのキャストが常に成功するという点です(error as! NSError
のように強制的にキャストしようとするとコンパイラで Forced cast from 'Error' to 'NSError' always succeeds
と警告されます)。上記のような Objective-C のエラーハンドリングを Swift にインポートするために、Swift の Error
から Cocoa の NSError
に変換される仕組みが Cocoa に用意されているようです。
Error
はどのように NSError
に変換されるのか?
それでは Error
はどのように NSError
に変換されるのでしょうか?これから Error
プロトコルに準拠した様々なエラーオブジェクトを NSError
に変換した時にどのように扱われるのかを試していきたいと思います。そのため、Error
を NSError
としてコンソールに出力する次のような関数を用意しました。
func printErrorAsNSError(_ error: Error) { print(String(describing: type(of: error))) let nsError = error as NSError print("domain ", nsError.domain) print("code ", nsError.code) print("userInfo ", nsError.userInfo) print("localizedDescription ", nsError.localizedDescription) print("localizedFailureReason ", nsError.localizedFailureReason ?? "(nil)") print("localizedRecoverySuggestion", nsError.localizedRecoverySuggestion ?? "(nil)") print("localizedRecoveryOptions ", nsError.localizedRecoveryOptions ?? "(nil)") print("recoveryAttempter ", nsError.recoveryAttempter ?? "(nil)") print("helpAnchor ", nsError.helpAnchor ?? "(nil)") }
Error
as NSError
Swift のドキュメントにある Error Handling の例では、Swift の列挙型はエラーの表現に適していると書かれていますので、まずは Error
プロトコルに準拠した列挙型で試してみます。以下、エラーの定義とそのインスタンスの出力結果です。
enum EnumError: Error { case case1 case case2 case case3 var localizedDescription: String { "EnumError.localizedDescription" } } printErrorAsNSError(EnumError.case3) /* EnumError domain __lldb_expr_7.EnumError code 2 userInfo [:] localizedDescription The operation couldn’t be completed. (__lldb_expr_7.EnumError error 2.) localizedFailureReason (nil) localizedRecoverySuggestion (nil) localizedRecoveryOptions (nil) recoveryAttempter (nil) helpAnchor (nil) */
エラードメインは型の名前になりましたね(これは Xcode Playground での実行結果ですので、__lldb_expr_*.
というプレフィックスがついていますが、ここはモジュール名となります)。エラーコードは 2 となっていますが、これは列挙されたケースが zero-based な番号で割り振られた値となっています。つまり case1
, case2
, case3
はそれぞれ 0, 1, 2 です。ちなみに Swift の列挙型は次のように RawRepresentable
*8 に準拠した型の値を割り当てることができます。RawRepresentable
が Int の場合は、各ケースの raw value が NSError
のエラーコードとなりますが、それ以外の場合は上記のとおりになります。
enum IntEnumError: Int, Error { case case1 = 123 // code = 123 case case2 = 234 // code = 234 case case3 = 345 // code = 345 } enum StringEnumError: String, Error { case case1 = "CASE1" // code = 0 case case2 = "CASE2" // code = 1 case case3 = "CASE3" // code = 2 }
列挙型をエラーとして使うことは、各ケースにエラーコードが割り当てられるという点でも相性が良さそうであることがわかりましたね。それではクラスや構造体の場合にエラーコードがどうなるのかが気になってきますのでやってみましょう。
struct StructError: Error { var localizedDescription: String { "StructError.localizedDescription" } } printErrorAsNSError(StructError()) /* StructError domain __lldb_expr_7.StructError code 1 userInfo [:] localizedDescription The operation couldn’t be completed. (__lldb_expr_7.StructError error 1.) localizedFailureReason (nil) localizedRecoverySuggestion (nil) localizedRecoveryOptions (nil) recoveryAttempter (nil) helpAnchor (nil) */
エラーコードは 1 となりました。エラーコードを決めるための情報が何もないので常に 1 となるようです。構造体を例にしましたがこれはクラスでも同様です。
引き続き、Foundation フレームワークの他のエラープロトコルについても見ていくことにします。
LocalizedError
as NSError
先ほど登場した LocalizedError
です。これはエラーの内容、理由、回復方法などの説明を含めることができます。
struct StructLocalizedError: LocalizedError { var errorDescription: String? { "StructLocalizedError.errorDescription" } var failureReason: String? { "StructLocalizedError.failureReason" } var recoverySuggestion: String? { "StructLocalizedError.recoverySuggestion" } var helpAnchor: String? { "StructLocalizedError.helpAnchor" } } printErrorAsNSError(StructLocalizedError()) /* StructLocalizedError domain __lldb_expr_7.StructLocalizedError code 1 userInfo [:] localizedDescription StructLocalizedError.errorDescription localizedFailureReason StructLocalizedError.failureReason localizedRecoverySuggestion StructLocalizedError.recoverySuggestion localizedRecoveryOptions (nil) recoveryAttempter (nil) helpAnchor StructLocalizedError.helpAnchor */
RecoverableError
as NSError
次の RecoverableError
は、エラーからの回復方法そのものも提供します。
struct StructRecoverableError: RecoverableError { var recoveryOptions: [String] { ["StructRecoverableError.recoveryOptions.1", "StructRecoverableError.recoveryOptions.2"] } func attemptRecovery(optionIndex recoveryOptionIndex: Int, resultHandler handler: @escaping (Bool) -> Void) { handler(true) } func attemptRecovery(optionIndex recoveryOptionIndex: Int) -> Bool { return true } } printErrorAsNSError(StructRecoverableError()) /* StructRecoverableError domain __lldb_expr_7.StructRecoverableError code 1 userInfo [:] localizedDescription The operation couldn’t be completed. (__lldb_expr_7.StructRecoverableError error 1.) localizedFailureReason (nil) localizedRecoverySuggestion (nil) localizedRecoveryOptions ["StructRecoverableError.recoveryOptions.1", "StructRecoverableError.recoveryOptions.2"] recoveryAttempter Foundation.__NSErrorRecoveryAttempter helpAnchor (nil) */
StructCustomNSError
as NSError
最後の CustomNSError
は NSError
に必須のエラードメインとエラーコード、そしてエラーの付帯情報となる userInfo
を明示することができます。Swift の Error
を NSError
として取り扱われることを想定する場合は、このプロトコルに準拠したエラーを定義すると良さそうです。
struct StructCustomNSError: CustomNSError { static var errorDomain: String { "StructCustomNSError.errorDomain" } var errorCode: Int { 123 } var errorUserInfo: [String : Any] { ["StructCustomNSError.UserInfo.Key1": 456, "StructCustomNSError.UserInfo.Key2": 789] } } printErrorAsNSError(StructCustomNSError()) /* StructCustomNSError domain StructCustomNSError.errorDomain code 123 userInfo ["StructCustomNSError.UserInfo.Key2": 789, "StructCustomNSError.UserInfo.Key1": 456] localizedDescription The operation couldn’t be completed. (StructCustomNSError.errorDomain error 123.) localizedFailureReason (nil) localizedRecoverySuggestion (nil) localizedRecoveryOptions (nil) recoveryAttempter (nil) helpAnchor (nil) */
Cocoa が提供するエラー表示と回復の仕組み
少し寄り道させてください。先ほど出てきた RecoverableError
にはエラーからの回復方法まで含まれていますが、iOSアプリの開発者には馴染みのないものですよね。実は Cocoa には macOS 限定となりますが NSError
によるエラーの表示と回復の仕組みが提供されており、RecoverableError
はこの仕組みに関するものです。興味がわいた方は Apple のドキュメント Error Handling Programming Guide *9 を読んでみてください。
また、このエラー回復の仕組みを iOS アプリの UIAlertController で実現するという記事 *10 も興味深いのでこちらもオススメです。
エラーログで活用する
iOSアプリを正常に動作させるためにはエラーハンドリングを適切にする必要がありますが、すべてのエラーパターンを想定することは難しいのが現実です。そこでエラーの発生をロギングすることでアプリの稼働状況を監視することも重要となります。例えば Firebase の Crashlytics *11 にはクラッシュレポートの他にも、エラーをロギングする仕組みがあります*12。次のように NSError
オブジェクトを指定するだけです。
Crashlytics.crashlytics().record(error: error)
Crashlytics はこの NSError
のエラードメインとエラーコードでグループ化し、エラーの一覧に表示してくれます。
各エラーの詳細情報を覗くと、NSError
のが保持する様々な詳細情報も確認することができます。ここまで記録されていると、エラー発生の原因を解決することもしやすくなりそうですね。
この図をよく見てみると NSLocalizedDescriptionKey
というキーがでてきます。実は NSError
の localizedDescription
や localizedFailureReason
といったプロパティは userInfo
に記録された特定のキーの値へのアクセスを簡易にするものです(そのため、これらのプロパティは読み込み専用です)。つまり、Foundation フレームワークの LocalizedError
などのエラープロトコルを利用し、各プロパティが適切な値を返すように実装しておくことでエラーログもより意味のあるものにできるのです。
以上から、Swift のエラーハンドリングに Error
を使う場合においても、Cocoa の NSError
に変換されることを想定して LocalizedError
や CustomNSError
を活用することで、有用なエラーログを蓄積することができるということがわかりました。
(オマケ)Error
の Undocumented な機能
Undocumented ですが Error
プロトコルに準拠したエラーに _domain
, _code
, _userInfo
というプロパティを定義することで、NSError
に変換した時に domain
, code
, userInfo
として動作するようです。_userInfo
は AnyObject?
であることに注意してください。
struct UndocumentedError: Error { var _domain: String { "UndocumentedError._domain" } var _code: Int { 123 } var _userInfo: AnyObject? { ["UndocumentedError.Key.1": 456, "UndocumentedError.Key.2": 789] as AnyObject } } printErrorAsNSError(UndocumentedError()) /* UndocumentedError domain UndocumentedError._domain code 123 userInfo ["UndocumentedError.Key.1": 456, "UndocumentedError.Key.2": 789] localizedDescription The operation couldn’t be completed. (UndocumentedError._domain error 123.) localizedFailureReason (nil) localizedRecoverySuggestion (nil) localizedRecoveryOptions (nil) recoveryAttempter (nil) helpAnchor (nil) */
最後に
iOSアプリ開発者であれば普段当たり前のように接している Error
について深掘りしてみましたが、いかがでしたでしょうか。
エラーハンドリングは地味な作業ですが、これをしっかりしておくことでアプリの信頼性はあがります。また、正しくエラーを把握して改善していくことができれば、その信頼性も向上させることもできます。今回の記事の内容を元に Swift の Error
と正しくつきあって、みなさんのアプリがよりよくなれば幸いです。
最後になりましたが、スタメンでは一緒にモバイルアプリを含む自社プロダクトの信頼性向上を牽引してくれる仲間を募集しています。興味を持ってくれた方は、ぜひぜ下記のエンジニア採用サイトをご覧ください。
*1:The Swift Programming Language / Error Handling
*2:Apple Developer Documentation / Result
*3:Apple Developer Documentation / Foundation
*4:Apple Developer Documentation / LocalizedError
*5:Apple Developer Documentation Archive/ Cocoa Cocoa は iOSやmacOSのアプリ開発環境を指します。Foundation フレームワークはこの Cocoa に含まれています。
*6:Apple Developer Documentation / NSError
*7:Apple Developer Documentation / Understand How Error Parameters Are Imported
*8:Apple Developer Documentation / RawRepresentable
*9:Apple Developer Documentation / Error Handling Programming Guide