iOSアプリ開発においてSwiftのErrorを巧く活用するには

f:id:temoki-ht:20200721110213p:plain
Error

こんにちは。スタメンで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."
    }
}

ここでクイズです。下記のコードのようにスローされた SomeErrorlocalizedDescription を出力した結果はどうなるでしょうか?

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 型として扱われますが、ErrorlocalizedDescription には 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 のエラー型

iOSmacOSのアプリを開発したことがあれば必ず目にする Cocoa *5 のエラー NSError *6 に同じような名前のプロパティが存在します。

  • localizedDescription
  • localizedFailureReason
  • localizedRecoverySuggestion
  • localizedHelpAnchor

このことから Swift の Error, LocalizedError はこの Cocoa のエラーとの関連性がありそうです。実際に Foundation フレームワークには Error プロトコルを継承したエラー型として、この LocalizedError の他に、RecoverableError, CustomNSError, NSError が定義されており、Swift の ErrorCocoaNSError には深く関係しています。

NSErrorError

NSErrorインスタンスを生成するにはエラードメインとエラーコードの2つの要素が必須です。

CocoaObjective-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 から CocoaNSError に変換される仕組みが Cocoa に用意されているようです。

Error はどのように NSError に変換されるのか?

それでは Errorはどのように NSError に変換されるのでしょうか?これから Error プロトコルに準拠した様々なエラーオブジェクトを NSError に変換した時にどのように扱われるのかを試していきたいと思います。そのため、ErrorNSError としてコンソールに出力する次のような関数を用意しました。

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 valueNSError のエラーコードとなりますが、それ以外の場合は上記のとおりになります。

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

最後の CustomNSErrorNSError に必須のエラードメインとエラーコード、そしてエラーの付帯情報となる userInfo を明示することができます。Swift の ErrorNSError として取り扱われることを想定する場合は、このプロトコルに準拠したエラーを定義すると良さそうです。

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 のエラードメインとエラーコードでグループ化し、エラーの一覧に表示してくれます。

f:id:temoki-ht:20200718181024p:plain
Crashlyitcs に記録された NSError

各エラーの詳細情報を覗くと、NSErrorのが保持する様々な詳細情報も確認することができます。ここまで記録されていると、エラー発生の原因を解決することもしやすくなりそうですね。

f:id:temoki-ht:20200718180640p:plain
Crashlytics に記録された NSError の詳細情報

この図をよく見てみると NSLocalizedDescriptionKey というキーがでてきます。実は NSErrorlocalizedDescriptionlocalizedFailureReason といったプロパティは userInfo に記録された特定のキーの値へのアクセスを簡易にするものです(そのため、これらのプロパティは読み込み専用です)。つまり、Foundation フレームワークLocalizedError などのエラープロトコルを利用し、各プロパティが適切な値を返すように実装しておくことでエラーログもより意味のあるものにできるのです。

以上から、Swift のエラーハンドリングに Error を使う場合においても、CocoaNSError に変換されることを想定して LocalizedErrorCustomNSError を活用することで、有用なエラーログを蓄積することができるということがわかりました。

(オマケ)Error の Undocumented な機能

Undocumented ですが Error プロトコルに準拠したエラーに _domain, _code, _userInfo というプロパティを定義することで、NSError に変換した時に domain, code, userInfo として動作するようです。_userInfoAnyObject? であることに注意してください。

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 と正しくつきあって、みなさんのアプリがよりよくなれば幸いです。

最後になりましたが、スタメンでは一緒にモバイルアプリを含む自社プロダクトの信頼性向上を牽引してくれる仲間を募集しています。興味を持ってくれた方は、ぜひぜ下記のエンジニア採用サイトをご覧ください。