Swift Digest
SE-0112 | Swift Evolution

Improved NSError Bridging

Proposal
SE-0112
Authors
Doug Gregor, Charles Srstka
Review Manager
Chris Lattner
Status
Implemented (Swift 3.0)

01 何が問題だったのか

Swift のエラー処理モデルは、Cocoa の NSError との相互運用を前提に設計されています。Objective-C 側で NSError ** 引数を取るメソッドは Swift の throws メソッドとして取り込まれ、Swift の enum など ErrorProtocol(当時の名称)に適合する型を throw すると、NSError のドメインとコードに対応付けられた状態で Objective-C に渡されます。しかし、この相互運用にはいくつかの隙間があり、Swift 側のコードがしばしば ErrorProtocol ベースと NSError ベースの間で不自然な書き分けを強いられていました。

NSErroruserInfo に相当する情報を渡せない

Swift で独自のエラー型を定義して throw するだけでは、ローカライズされたエラーメッセージや復旧候補、復旧アクションなど、本来 NSErroruserInfo に載せるべき情報を付与する手段がありません。たとえば次のような enum を定義しても、ローカライズされた説明文を付けて Objective-C 側に伝えることはできません。

enum HomeworkError: Int, ErrorProtocol {
    case forgotten
    case lost
    case dogAteIt
}

必要な情報を添えようとすると、Swift 側で直接 NSError を組み立てて throw するしかなく、せっかくの型付きエラーの良さが失われます。

throw NSError(
    code: HomeworkError.dogAteIt.rawValue,
    domain: HomeworkError._domain,
    userInfo: [NSLocalizedDescriptionKey: "the dog ate it"]
)

Objective-C 由来のエラーを型安全に扱えない

AVError のように Objective-C のエラーコード定数から取り込まれたエラーは、Swift 上では単純な enum に過ぎず、NSError が持っていた userInfo へのアクセスが失われていました。したがって、特定のコードに対してのみ userInfo を参照したい場合、catchNSError で書き直す必要がありました。

catch let error as AVError where error == .diskFull {
    // enum の値しか取れず、userInfo にアクセスできません。
}

catch let error as NSError
    where error._domain == AVFoundationErrorDomain
        && error._code == AVError.diskFull.rawValue {
    // userInfo は取れますが、キーごとに型も分からず扱いが面倒です。
    if let time = error.userInfo[AVErrorTimeKey] as? CMTime {
        // ...
    }
}

userInfo は事実上キー名と値の型を手作業で覚える世界で、AVErrorTimeKey の値が CMTime であることを呼び出し側が知っていなければなりません。

NSErrorErrorProtocol の橋渡しが不揃い

throwsNSError ** の間は自動的に橋渡しされるのに対し、Objective-C メソッドが NSError * を通常の引数・戻り値・プロパティとして受け渡している箇所はそのまま NSError として Swift に露出していました。たとえば UIDocument の次のメソッドは、

- (void)handleError:(NSError *)error
    userInteractionPermitted:(BOOL)userInteractionPermitted;

Swift 側に NSError 型のまま取り込まれていました。

func handleError(_ error: NSError, userInteractionPermitted: Bool)

本来であれば他の値型(ArrayStringURL など)と同様に、Swift 側では統一的なエラー型として扱えることが望まれます。

02 どのように解決されるのか

この提案は、Swift のエラー型に NSError 相当の情報を載せる仕組みを導入し、Objective-C のエラー列挙を情報を失わない構造体として取り込み、NSError を一律に Swift のエラー型へ橋渡しすることで、上記の不揃いを解消します。また、これに合わせて ErrorProtocolError に改名します。以下では改名後の Error で説明します。

エラー情報を記述する3つのプロトコル

ローカライズメッセージ・復旧手段・NSError 互換情報をそれぞれ記述するためのプロトコルが導入されます。必要なものだけに適合すればよく、要件はすべてデフォルト実装を持ちます。

LocalizedError はエンドユーザー向けのローカライズメッセージを提供するためのプロトコルです。

protocol LocalizedError: Error {
    var errorDescription: String? { get }
    var failureReason: String? { get }
    var recoverySuggestion: String? { get }
    var helpAnchor: String? { get }
}

たとえば先ほどの HomeworkError にローカライズされた説明を加えるには、次のように書けます。

extension HomeworkError: LocalizedError {
    var errorDescription: String? {
        switch self {
        case .forgotten: return NSLocalizedString("I forgot it", comment: "")
        case .lost:      return NSLocalizedString("I lost it", comment: "")
        case .dogAteIt:  return NSLocalizedString("The dog ate it", comment: "")
        }
    }
}

RecoverableError は、ユーザーに提示する復旧候補と、選ばれた候補に対する復旧処理を記述するためのプロトコルです。ドキュメント単位の非同期復旧と、アプリケーション全体を止める同期復旧の2形式が用意されます。

protocol RecoverableError: Error {
    var recoveryOptions: [String] { get }
    func attemptRecovery(optionIndex recoveryOptionIndex: Int,
                         resultHandler handler: (_ recovered: Bool) -> Void)
    func attemptRecovery(optionIndex recoveryOptionIndex: Int) -> Bool
}

CustomNSError は、NSError のドメイン・コード・userInfo を自分で決めたい場合に適合するプロトコルです。他のプロトコルでカバーされない独自キーを userInfo に載せたいときに使います。

protocol CustomNSError: Error {
    var errorDomain: String { get }
    var errorCode: Int { get }
    var errorUserInfo: [String: Any] { get }
}

これらのプロトコルに適合した Swift のエラーを throw すると、NSError へ橋渡しされる際にランタイムが適合をチェックし、userInfo が適切に埋められます。実装面では NSError.setUserInfoValueProvider(forDomain:provider:) を使って遅延評価する設計で、都度必要なキーだけが Swift 側から取り出されるようになっています。

Objective-C のエラー列挙を構造体として取り込む

Objective-C 側では、エラードメイン定数とエラーコードの列挙を紐付ける新しいマクロ NS_ERROR_ENUM を用意します。

extern NSString *const AVFoundationErrorDomain;

typedef NS_ERROR_ENUM(NSInteger, AVError, AVFoundationErrorDomain) {
    AVErrorUnknown                           = -11800,
    AVErrorOutOfMemory                       = -11801,
    AVErrorSessionNotRunning                 = -11803,
    AVErrorDeviceAlreadyUsedByAnotherSession = -11804,
    // ...
};

このマクロを使ったエラーは、Swift では NSError を内部に保持する構造体として取り込まれます。元の列挙は入れ子型 Code として残り、userInfo への直接アクセスも失われません。

struct AVError {
    enum Code: Int {
        case unknown                           = -11800
        case outOfMemory                       = -11801
        case sessionNotRunning                 = -11803
        case deviceAlreadyUsedByAnotherSession = -11804
    }

    var code: Code { ... }
    var userInfo: [String: Any] { ... }

    init(_ code: Code, userInfo: [String: Any] = [:]) { ... }

    // 型名を介さずコードを参照できるショートカット
    static let unknown: Code = .unknown
    static let outOfMemory: Code = .outOfMemory
    static let sessionNotRunning: Code = .sessionNotRunning
    static let deviceAlreadyUsedByAnotherSession: Code = .deviceAlreadyUsedByAnotherSession
}

これにより、コードによる catchuserInfo へのアクセスが両立します。

// コードで直接分岐(これまでと同じ感覚)
catch AVError.outOfMemory {
    // ...
}

// 値として受け取り userInfo を参照
catch let error as AVError where error.code == .sessionNotRunning {
    // error.userInfo にアクセスできます。
}

さらに、userInfo に載っている既知のキーは、AVError の拡張として型付きプロパティで公開できます。

extension AVError {
    var time: CMTime? {
        return userInfo[AVErrorTimeKey] as? CMTime
    }
}

catch let error as AVError {
    if let time = error.time {
        // ...
    }
}

エラー生成側も、コードと(必要なら)userInfo をまとめて指定できます。

throw AVError(.sessionNotRunning)
throw AVError(.sessionNotRunning, userInfo: [/* ... */])

NSError を一律に Error として橋渡しする

Objective-C 側で NSError * を取る/返す/保持する API は、Swift 側ではすべて Error として取り込まれるようになります。先ほどの UIDocument のメソッドは、

- (void)handleError:(NSError *)error
    userInteractionPermitted:(BOOL)userInteractionPermitted;

Swift では次のように現れます。

func handleError(_ error: Error, userInteractionPermitted: Bool)

これは ArrayString と同様の値型橋渡しの延長で、実行時には既存の投げる/キャッチする際のブリッジ処理を再利用しています。橋渡しが循環しないよう、NSErrorError に適合することは取り下げられますが、明示的な変換(nsError as Error)は引き続き可能です。

Error に共通のローカライズされた説明

Foundation 側で、すべての Error から localizedDescription を取得できるようになります。

extension Error {
    var localizedDescription: String {
        return (self as NSError).localizedDescription
    }
}

また、Cocoa のエラードメイン用の型 NSCocoaError(改名後)の拡張として、NSFilePathErrorKeyNSStringEncodingErrorKeyNSUnderlyingErrorKeyNSURLErrorKey といった、呼び出し側が実際に参照する userInfo キーに対して型付きのアクセサが提供されます。読み手はキー名や値の型を個別に覚える必要がなくなります。

ErrorProtocol から Error への改名

以上の橋渡しが整うことで、Swift における主なエラー型は Error となり、NSError はそこへ橋渡しされる値として位置付けられます。これに合わせて ErrorProtocolError に改名されます。

影響範囲

この変更は、NSError を受け渡す Objective-C API の Swift 上のシグネチャを NSError から Error に変えるため、macOS SDK だけで約400本、iOS SDK では500本近くの API に影響するソース非互換な変更です。規模が大きいため Swift 3 で一気に移行する方針が取られ、移行ツールによる自動書き換えも提供されました。

Future Directions

将来の展望として、LocalizedError に適合する enumswitch を何度も書く手間を減らすツーリングや、CustomNSError_ObjectiveCBridgeableError(実装詳細のプロトコル)を組み合わせて Swift のエラーを userInfo に丸ごと畳み込み、Objective-C から再構成できるようにする仕組みが構想として示されています。いずれも将来的な方向性として言及されたもので、実現を約束するものではありません。