Improved NSError Bridging
01 何が問題だったのか
Swift のエラー処理モデルは、Cocoa の NSError との相互運用を前提に設計されています。Objective-C 側で NSError ** 引数を取るメソッドは Swift の throws メソッドとして取り込まれ、Swift の enum など ErrorProtocol(当時の名称)に適合する型を throw すると、NSError のドメインとコードに対応付けられた状態で Objective-C に渡されます。しかし、この相互運用にはいくつかの隙間があり、Swift 側のコードがしばしば ErrorProtocol ベースと NSError ベースの間で不自然な書き分けを強いられていました。
NSError の userInfo に相当する情報を渡せない
Swift で独自のエラー型を定義して throw するだけでは、ローカライズされたエラーメッセージや復旧候補、復旧アクションなど、本来 NSError の userInfo に載せるべき情報を付与する手段がありません。たとえば次のような 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 を参照したい場合、catch を NSError で書き直す必要がありました。
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 であることを呼び出し側が知っていなければなりません。
NSError と ErrorProtocol の橋渡しが不揃い
throws と NSError ** の間は自動的に橋渡しされるのに対し、Objective-C メソッドが NSError * を通常の引数・戻り値・プロパティとして受け渡している箇所はそのまま NSError として Swift に露出していました。たとえば UIDocument の次のメソッドは、
- (void)handleError:(NSError *)error
userInteractionPermitted:(BOOL)userInteractionPermitted;
Swift 側に NSError 型のまま取り込まれていました。
func handleError(_ error: NSError, userInteractionPermitted: Bool)
本来であれば他の値型(Array、String、URL など)と同様に、Swift 側では統一的なエラー型として扱えることが望まれます。
02 どのように解決されるのか
この提案は、Swift のエラー型に NSError 相当の情報を載せる仕組みを導入し、Objective-C のエラー列挙を情報を失わない構造体として取り込み、NSError を一律に Swift のエラー型へ橋渡しすることで、上記の不揃いを解消します。また、これに合わせて ErrorProtocol を Error に改名します。以下では改名後の 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
}
これにより、コードによる catch と userInfo へのアクセスが両立します。
// コードで直接分岐(これまでと同じ感覚)
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)
これは Array や String と同様の値型橋渡しの延長で、実行時には既存の投げる/キャッチする際のブリッジ処理を再利用しています。橋渡しが循環しないよう、NSError が Error に適合することは取り下げられますが、明示的な変換(nsError as Error)は引き続き可能です。
Error に共通のローカライズされた説明
Foundation 側で、すべての Error から localizedDescription を取得できるようになります。
extension Error {
var localizedDescription: String {
return (self as NSError).localizedDescription
}
}
また、Cocoa のエラードメイン用の型 NSCocoaError(改名後)の拡張として、NSFilePathErrorKey や NSStringEncodingErrorKey、NSUnderlyingErrorKey、NSURLErrorKey といった、呼び出し側が実際に参照する userInfo キーに対して型付きのアクセサが提供されます。読み手はキー名や値の型を個別に覚える必要がなくなります。
ErrorProtocol から Error への改名
以上の橋渡しが整うことで、Swift における主なエラー型は Error となり、NSError はそこへ橋渡しされる値として位置付けられます。これに合わせて ErrorProtocol は Error に改名されます。
影響範囲
この変更は、NSError を受け渡す Objective-C API の Swift 上のシグネチャを NSError から Error に変えるため、macOS SDK だけで約400本、iOS SDK では500本近くの API に影響するソース非互換な変更です。規模が大きいため Swift 3 で一気に移行する方針が取られ、移行ツールによる自動書き換えも提供されました。
Future Directions
将来の展望として、LocalizedError に適合する enum で switch を何度も書く手間を減らすツーリングや、CustomNSError と _ObjectiveCBridgeableError(実装詳細のプロトコル)を組み合わせて Swift のエラーを userInfo に丸ごと畳み込み、Objective-C から再構成できるようにする仕組みが構想として示されています。いずれも将来的な方向性として言及されたもので、実現を約束するものではありません。