はじめに
Xcodeのバージョンも 8になり、社内プロジェクトの方も Swift3化を進めているのですが、色々とトラブっています。特に困っているのが Objective-Cと Swiftが混在する環境でのエラーオブジェクト(NSError、Error)の扱いです。
時間がなくてちゃんと体系的にまとめきれていないのですが、自分自身の問題整理と覚書も兼ねて、現状でわかっていることをまとめてみました。
SwiftのErrorと、Objective-Cの NSError
少し前提を話します。
まず、Swiftの世界ではエラーは Error という抽象的な型(プロトコル)で扱います。このプロトコルは Swift2までは ErrorTypeという名前でしたが、Swift3で Error に名称が変更されました。より一般的な名前が与えられたということですので、役割が昇格したと言えます。
一方、Objective-C では言語レベルのエラーというものは特に定義されていませんが、iOSやMacのアプリ開発では Appleが提供する Foundationフレームワーク(Cocoa/Cocoa Touch)の NSErrorクラスを使ってエラーハンドリングを行うのが一般的です。実際のところ、Cocoaを使わないObjective-Cというのは非常にレアなので、以降、「NSErrorはObjective-Cのエラー」という書き方をしちゃいます。
Swift、Objective-C混在環境でのエラーハンドリング
iOSアプリ開発でもFoundationフレームワークを使用していて、FoundationフレームワークはObjective-Cで作られているので、エラーについても NSError が使われています。
例えばObjective-Cの世界で以下のような関数が定義されていたとします。
- (void)handleError:(NSError *)error type:(NSString*)type;
これを Swiftから使いたい場合を考えます。NSErrorクラスはあくまで NSObjectクラスの一種であって、また、Swiftは NSObjectを普通にクラスとして扱うことができますので、Swiftからは以下のような関数に見えれば良さそうです。
func handleError(_ error: NSError, type: String)
実際、Swift2まではこのような方法で NSErrorを扱っていました。ですが、これだとSwiftで書かれたコードのいたるところに「NSError」というObjective-Cの世界の異物が混入することになり、いつまでたっても Pure Swiftなコードを書くことができなくなってしまいます。
Swift 3における NSErrorの扱い
同様の問題はSwiftの Stringと Objective-Cの NSStringにもありますが、実は、NSStringは Swiftの世界では String として透過的に扱えるようになっているので、Swiftのコード上でNSStringという型を意識する必要はほぼありません。先ほどの handleError 関数の第二引数を見てもらうと、Objective-Cの世界で NSString だったものが Swiftの世界ではStringになっているのがわかります。
これと同じように、Objective-Cの NSErrorが Swiftの世界では Errorにブリッジされるようになると良さそうです。実際、Swiftの開発コミュニティーで以下のような提言がなされ、Swift3で採用されています。
端的にいうと、さきほどの handleError 関数は Swift3の世界だと以下のように見えます。
func handleError(_ error: Error, type: String)
このような改良がなされたことで、より Swiftネイティブに近い形でコードを書くことができるようになっています。
NSError ⇄ Error ブリッジ実例集
Swift3で導入された NSErrorと Errorの間のブリッジは割と良さそうに感じますが、実際に作り込んでいくと色々なトラブルに悩まされます。
以下に、実際にありそうなパターンと、それぞれの場合に私がどのように対応したかをメモとして書いていこうと思います。
もっといい方法があったり、Swiftのバージョンが変わることで使えなくなったり、問題なくなったりすることも出てくると思いますので、あくまで参考としてお使いください。
Swiftのエラーを Objective-Cに渡す
Swiftのエラーは例えばこんな感じで enumに Errorプロトコルを適用して定義します。
enum MySwiftError : Error {
case unknown
case error1
case error2
}
enumにしておけば、キャッチした時にエラー詳細の分岐を switchで書けるので、判定が楽にできるためです。
do {
try throwingFunc()
} catch let e as MySwiftError {
switch e {
case .unknown:
print("Unknown error")
case .error1:
print("type 1 error was occurred")
case .error100:
print("type 100 error was occurred")
}
} catch let e {
print("Other error")
}
なお、余談になりますが Swiftでは structを Errorとして扱うこともできます。
struct MySwiftStructError : Error {
}
面白いですね。
さて、先ほどの MySwiftErrorを Objective-Cのメソッドに渡してみます。
- (void)handleError:(NSError*)error {
NSLog(@"domain = %@", error.domain);
NSLog(@"code = %ld", error.code);
}
let error = MySwiftError.error1
objc.handleError(error)
実行結果は以下のようになります。
domain = my-app.MySwiftError
code = 1
これを見ると、ドメインにはエラーの型名が設定され、codeには enumの定義順に上から 0,1,2 と振られた連番が入るのがわかります。(MySwiftErrorの場合、定義の一つ目が .unknownで、二つ目が .error1なので codeが1になっている)
これがデフォルトの挙動ですが、_domain や _code というプロパティを定義することで挙動を変えることもできます。
extension MySwiftError {
var _domain: String {
return "MyDomain"
}
var _code: Int {
switch self {
case .unknown:
return 0
case .error1:
return 1111
case .error2:
return 2222
}
}
}
こうすると、実行結果は以下のように変わります。
domain = MyDomain
code = 1111
なお、codeを変える方法は _code を定義する以外に、enumの raw typeとして Intを指定する方法もあります。
enum MySwiftError : Int, Error {
case unknown = 0
case error1 = 1111
case error2 = 2222
}
こちらの方が一般的です。
Swiftのエラーを Objective-C で使う
上記の通り、Swiftの Errorはいかなる場合でも問題なく NSErrorにマッピングできるので、単にNSErrorとして渡したいだけならば何も考える必要はありません。
しかし、実際に Objective-C 側のコードでエラーハンドリングをしようと思うと、MySwiftErrorにどういうエラーが定義されているのかを見えないと困ります。
そのような場合は、enumの raw typeとして Intを指定した上で、さらに @objcを付加します。
@objc
enum MySwiftError : Int, Error {
case unknown = 0
case error1 = 1111
case error2 = 2222
}
こうすることで、Objective-C側からドメイン名やエラーコードの定義(定数)を参照できるようになります。(#importをお忘れなく)
# import "MyProject-Swift.h"
...
- (void)handleError:(NSError*)error {
if ([error.domain isEqualToString:MySwiftErrorDomain]) {
switch(error.code) {
case MySwiftErrorUnknown:
NSLog(@"");
break;
case MySwiftErrorError1:
NSLog(@"");
break;
case MySwiftErrorError2:
NSLog(@"");
break;
}
}
}
Objective-C上で生成したエラーを Swiftに渡す
今度は逆に、Objective-C側で生成したエラー、つまり NSErrorのインスタンスを Swiftに渡してみます。
NSError* error = [NSError errorWithDomain:@"MyObjcError" code:2 userInfo:nil];
SwiftClass* s = [SwiftClass new];
[s handleError:error];
@objc
class SwfitClass {
func handleError(_ error: Error) {
print("\(error)")
}
}
という感じで、渡すだけなら何も問題なくNSErrorを Swiftの Errorとして渡せます。しかし、Errorプロトコルにはエラーコードを取得するプロパティやメソッドなどは何も定義されていないので、具体的な Error型にキャストして詳細を調べる必要があります。
一番手っ取り早い方法は NSErrorにキャストする方法です。
@objc
class SwiftClass {
func handleError(_ error: Error) {
let nserror = error as NSError
if nserror.domain == "MyObjcError" {
switch nserror.code {
case 2:
print(...)
}
}
}
}
NSErrorと Errorは相互に変換可能なので as NSError で NSErrorとして扱うことができます。
しかし、もともとNSErrorだったものをSwiftの世界で Errorとして捉え、それをまた NSErrorに戻しているので、 一体自分は何をしているのだろうか という気持ちになってきます。
ちなみに、Foundationフレームワークをリンクしていると Errorに _domainと_codeというプロパティが拡張されるので、いちいち NSErrorにキャストしなくても以下のようにかけます。
@objc
class SwiftClass {
func handleError(_ error: Error) {
if error._domain == "MyObjcError" {
switch error._code {
case 2:
print(...)
}
}
}
まあ、ですが「Swiftっぽいコード」とは言い難いですね。もうちょっとスマートに扱う方法を考えてみましょう。
Objective-C 上で定義されたエラーを Swiftで使う
一番やりたいのは、Objective-C上で定義されたエラーを、Swift上の具体的な Error型として使えるようにすることです。いくつか方法を模索して見ました。
Objective-C上でエラーコードを enumとして定義し、それを Swiftで使う
まずは、"MyObjcError" や 2 という定数を直に書かずに、 Objective-Cと Swiftで共有してみます。
そのためには、Objective-C のヘッダに以下のような定義を書く必要があります。
extern NSString *const MyObjcErrorDomain;
typedef NS_ENUM(NSInteger, MyObjcError) {
MyObjcErrorUnknown = 0,
MyObjcErrorError1 = 1,
MyObjcErrorError2 = 2
};
Objective-Cの世界でも エラーの code値は enum (NS_ENUM)として定義するのが通例ですので、上のような定義の仕方になります。
このような定義をした 'MyObjcError.h' を Swiftの bridging headerに追加すれば、Objective-CとSwiftの双方で定数を共有することができます。
NSError* error = [NSError errorWithDomain: MyErrorDomain code: MyErrorError2 userInfo:nil];
SwiftClass* s = [SwiftClass new];
[s handleError:error];
@objc
class SwiftClass {
func handleError(_ error: Error) {
let nserror = error as NSError
if nserror.domain == MyObjcErrorDomain {
switch nserror.code {
case MyObjcError.unknown.rawValue:
print("")
case MyObjcError.error1.rawValue:
print("")
case MyObjcError.error2.rawValue:
print("")
}
}
}
Objective-Cで定義した MyObjcErrorという enumが Swiftでも見えているので、そのrawValueを使ってマッチングさせています。
うーん。まだイマイチですね。
Objective-C上でエラーコードを enumとして定義し、それを Swiftの Errorとして使う
よく考えると、Swiftの Error というのは、 enumに対して Errorというプロトコルをもたせたものでした。ということは、Objective-Cで定義された先ほどの enumに Errorプロトコルを実装すれば良いのでは?ということになります。
具体的にはこんな感じで、extensionを使って MyObjcErrorに Errorプロトコルを実装することができます。
extension MyObjcError : Error {
}
@objc
class SwiftClass {
func handleError(_ error: Error) {
if let error = error as? MyObjcError {
switch error {
case .unknown:
print("")
case .error1:
print("")
case .error2:
print("")
}
}
}
お、なんか良さそうですね?
ですが、この世界はそう単純ではありません。 このコード、コンパイルは通りますが、 error as? MyObjcError が nilになってしまうため期待通りには動きません。handleErrorに渡ってくる errorの実体は NSErrorのインスタンスであって、MyObjcErrorのインスタンスではないためです。
仕方がないのでこんな感じで asMyObjcError というコンバータを用意する方法を考えました。
extension Error {
var asMyObjcError: MyObjcError? {
if self._domain == MyObjcErrorDomain {
return MyObjcError(rawValue: self._code)
}
return nil
}
}
@objc
class SwiftClass {
func handleError(_ error: Error) {
if let error = error.asMyObjcError {
switch error {
case .unknown:
print("")
case .error1:
print("")
case .error2:
print("")
}
}
}
これも一つの方法ではあるのですが、以下のような問題があります。
-
思わず as? MyObjcError と書いてしまってもコンパイルエラーにはならずに期待と違う動作をするため、ミスを発見するのが非常に困難
-
do-try-catch で型を指定してキャッチするときには使えない
- 例えば以下のようなケース
do { try someObjcFuncThrows() // NSErrorとして MyObjcErrorを throwする } catch let e as MyObjcError { // マッチしない! }
as? MyObjcError が成功しないので当然ですが、これも非常に使いにくいです。
少し話がそれますが、 Swift上でErrorとして見えているものの実体が NSErrorなのか、それともSwift上で定義されたものなのかを is や as? で区別することはできません。なぜかって?なぜなら、 e is NSError や e as NSError は絶対に成功してしまうからです……。
ですので、「もしErrorの実体がNSErrorだったらxxxする」ようなコードで対処することはできないのです。
Objective-C上で作られた NSErrorを Swiftの対応する Error実装にキャスト可能にする
先ほど紹介した Swift開発コミュニティの投稿に、興味深い提案がありました。
「Mapping NSError types back into Swift」という章で紹介されている、_ObjectiveCBridgeableError というプロトコルです。これを実装すると、NSErrorから Swiftの具体的な Errorにキャスト可能になるというのです。
実際にやって見ましょう。MyObjcErrorがこのプロトコルに準拠するように、Swift上で以下の実装を追加します。
extension MyObjcError : _ObjectiveCBridgeableError {
public init?(_bridgedNSError error: NSError) {
guard error.domain == MyObjcErrorDomain else {
return nil
}
guard let e = MyObjcError(rawValue: error.code) else {
return nil
}
self = e
}
}
これを実装すると、さっきまで成功しなかった as? MyObjcError のキャストが成功するようになります!
また、do-try-catch も動作するようになるので、非常に自然なコードを書くことができます。
これはなかなか良い発見でした。
_ObjectiveCBridgeableError を使う場合の注意点
_ObjectiveCBridgeableError プロトコルを使うことで NSErrorと Errorの相互運用性が格段に上がりましたが、私が試したところ、いくつか注意が必要な点が見つかりました。
定義されていない code値を渡した時の挙動が不安定
先ほどの MyObjcErrorの例では、とりうる codeの値は 0,1,2 の3種類でした。ではObjective-Cの世界で codeに 100を与えた場合、Swiftからはどのように見えるでしょうか?
NSError* error = [NSError errorWithDomain:MyObjcErrorDomain code:100 userInfo:nil];
この errorを Swiftに渡し、as? MyObjcErrorDomain で変換すると何が起こるでしょうか?
先ほどの init? の実装を見ると、以下の場所で guard条件に引っかかり、キャストに失敗する(nilになる)ように思います。
// 定義されていない rawValueを渡したら nilになるはず
guard let e = MyObjcError(rawValue: error.code) else {
return nil
}
ところが、実際に動かして見ると、 codeに100という値を持った MyObjcErrorが生成されてしまいます! (Xcode 8.1 で確認)
Objective-Cと Swiftの間のブリッジ処理には細かな「抜け」があり、時々こういうおかしな挙動が起こりますので注意が必要です。
ありえない codeを持った Errorはその後かなり変な動きをします。例えば switch文にかけた場合は以下のようになります。
NSError* error = [NSError errorWithDomain:MyObjcErrorDomain code:100 userInfo:nil];
[swiftObjc handleError:error];
...
func handleError(_ error: Error) {
if let error as? MyObjcError {
// codeに 100 が入っているのにキャストに成功する
switch error {
case .unknown:
// なぜか必ず最初の case文にマッチする
print("u")
case .error1:
print("1")
case .error2:
print("2")
}
}
}
このエラーオブジェクトは、なぜかswitch文の最初に書かれている caseにマッチしてしまいます。
switch文は網羅的に書かれていて、プログラム上は .unknown、 .error1、 .error2 の3種類しかありえないはずです。そこに、考慮されていない code値を持つインスタンスが入ってくるわけですのでクラッシュしても良さそうなものですが、なぜか適当な case文にマッチして先に進んでしまいます。結構怖いですね。
Objective-C のコードを書く際に、定義されていない code値で NSErrorを生成しないように必ず定数を使うなど注意すれば良いのですが、別のドメインのエラーコードを設定してしまうミスなどもたまにやるので、100%防ぐのは難しそうです。もう少し堅牢なコードにしたいですね。
色々試行錯誤し、このようなミスにガードを入れる方法として、以下のような方法を思いつきました。
extension MyObjcError : _ObjectiveCBridgeableError {
public init?(_bridgedNSError error: NSError) {
guard error.domain == MyObjcErrorDomain else {
return nil
}
guard let e = MyObjcError(rawValue: error.code) else {
// rawValueのコンストラクタは必ず成功してしまうのでここには来ない
return nil
}
let assertCode = { (expected: MyObjcError, error:MyObjcError) -> MyObjcError in
guard expected.rawValue == error.rawValue else {
assertionFailure("Invalid error code. \(error.rawValue)")
return MyObjcError.unknown
}
return error
}
// 生成された MyObjcErrorが本来の code値を持っているか最終確認する
switch e {
case .unknown:
self = assertCode(MyObjcError.unknown, e)
case .error1:
self = assertCode(MyObjcError.error1, e)
case .error2:
self = assertCode(MyObjcError.error2, e)
}
}
}
上記のコンストラクタでは、rawValueから生成された MyObjcErrorが本当にあり得る code値(rawValue)を持っているかどうか再検査しています。(不正な場合に switchが適当なcaseにマッチしてしまうことを利用)
万が一おかしな code値を持っていた場合は assertionFailureとし、デバッグ実行時はクラッシュさせますが、リリースビルド時は MyObjcError.unknown に変換しています。もちろん、fatalError()で必ずクラッシュするようにしても構いません。
エラーケースが多い場合は switch が巨大になりますし、caseの値と assertCode()の第一引数が必ず同じになるように注意する必要があってこれはこれで怖いですが、ミスがあった場合はラインタイムエラーになるのでまだ良いかな?という感じです。
Objective-Cと Swiftの間で Errorを往復させた場合にドメインがずれることがある
上記例ではObjective-C側で上記ドメインで作成したNSErrorを_ObjectiveCBridgeableErrorを使ってSwiftのMyObjcErrorにキャスト可能にしました。
ここで、MyObjcErrorDomain に以下のような値が入っていたとします。
NSString* const MyObjcErrorDomain = @"MyObjcError";
さて、Swiftの世界にキャストされた MyObjcErrorのドメインにはどんな値が入っているでしょうか?期待するのは "MyObjcError" という文字列ですが、実際には違います。
Swiftの Error のドメイン名のデフォルト実装は、その型名になります。Swiftの型名にはモジュール名が含まれますので、 MyObjcError のドメイン名は "my-app.MyObjcError" のような文字列になります。すると、この Error をもう一度 Objective-C の世界に渡すと、当初のドメイン名とは値が変わってきてしまいます。
ちょっとややこしいので表にしてみます。
| Objective-C | →(キャスト)→ | Swift | → | Objective-C | |
|---|---|---|---|---|---|
| type | NSError | → | MyObjcError | → | NSError |
| domain | "MyObjcError" | → | "my-app.MyObjcError" | → | "my-app.MyObjcError" |
ドメイン名が変わってしまうと当然 Objective-C 上でのエラーハンドリングにも問題が起こりますし、さらにこの NSError がもう一度 Swiftの世界に戻ってきた場合に as? MyObjcError が成功しなくなってしまいます。このように、Objective-Cと Swiftの間でエラーオブジェクトを行ったり来たりすることがあると、問題が起こることがあるので注意が必要です。
この問題を回避するためには、Swiftの MyObjcError の extensionを定義する際に、以下のようにしてドメインの値が Objective-Cの世界にいた時と一致するようにします。
extension MyObjcError : _ObjectiveCBridgeableError {
public var _domain : String {
// Objective-Cの時と同じドメインになるようにする
return MyObjcErrorDomain
}
public init?(_bridgedNSError error: NSError) {
...
}
Swift上でMyObjcErrorにプロトコルを定義しても、NSErrorから直接キャストできない
_ObjectiveCBridgeableError を使うことで、実体がNSErrorである Errorを直接 Swiftの具体的な型にキャストできるようになりましたが、これでもまだ完璧ではありません。例えば以下のようなキャストは失敗します。
// このアプリで定義するエラーに共通して準拠させるプロトコル
protocol MyError {
// エラーダイアログに表示する文字列
var errorMessage: String { get }
}
// MyObjcErrorを NSErrorから直接キャスト可能にする
extension MyObjcError : _ObjectiveCBridgeableError {
// 略
}
// MyObjcErrorを MyErrorプロトコルに準拠させる
extension MyObjcError : MyError {
var errorMessage: String {
return "..."
}
}
// エラーハンドリング
func handleError(_ error: NSError) {
guard let error = error as? MyError {
// このアプリで定義したエラーでなければ何もしない
return
}
// 残念ながら、実体が NSErrorの状態で MyObjcErrorが渡されると
// 上の guard に引っかかってここに入ってこない!
showErrorDialog( error.errorMessage )
}
上記の例では、アプリが定義するエラーに共通のプロトコルMyErrorを用意しています。MyErrorプロトコルにはerrorMessageプロパティがあって、ユーザに見せるための専用のエラーメッセージが取得できるようになっています。
handleErrorでは、エラーがMyErrorに準拠していたら、errorMessageプロパティから取得された文字列をエラーダイアログに表示しています。
こんな感じでアプリ内のエラーに何らかの共通処理をもたせたいということは割とよくありそうですが、上記コードは errorの実体が NSError だった場合には MyError へのキャストは失敗してしまうため、うまくいきません!
そのため、必ず as? MyObjcError を呼び出す必要があるのですが、エラーの種類が多い場合(ドメインの種類が多い場合)は非常に見苦しいコードになってしまいます。
// エラーハンドリング
func handleError(_ error: Error) {
let myError: MyError
switch error {
case let e as MyObjcError:
myError = e
case let e as MyModelError:
myError = e
case let e as MyValidationError:
myError = e
default:
return
}
showErrorDialog( myError.errorMessage )
}
MyErrorに準拠したエラーを増やすたびに case を増やす必要があり、メンテナンス性が非常に悪いです。ですので、実体が NSError な Errorが混ざるようなケースでは共通プロトコルで何かをするのはやめておいた方が良いでしょう。
どうしてもという場合は、以下のようにして caseの考慮漏れを防ぐくらいはしておいた方が良さそうです。
// エラーハンドリング
func handleError(_ error: Error) {
guard error._domain.hasPrefix("MyApp.") else {
// アプリが定義したエラーのドメインに共通のプリフィックスをつけ、区別する
return
}
let myError: MyError
switch error {
case let e as MyObjcError:
myError = e
case let e as MyModelError:
myError = e
case let e as MyValidationError:
myError = e
default:
// case文の考慮漏れがあった場合 default に引っかかる
assertionFailure("case文の考慮漏れがあります")
// 公開後にクラッシュしないようにダミーのエラーをアサイン
myError = MyModelError.unknown
}
showErrorDialog( myError.errorMessage )
}
Errorから ローカライズされたエラーメッセージを取得する方法
そもそも上記例のように Errorからエラーメッセージを取得したいときは、localizedDescriptionプロパティを使う方法もあります。
このプロパティは NSErrorが定義しているプロパティなので、Swift のErrorが持っているプロパティではないのですが、Foundationフレームワークをリンクしていると、extensionによって Error であっても使えるようになっています。これも先ほど紹介した Improved NSError Bridging で紹介されていました。
これもうまく使えると面白そうなのですが、これはこれでいろいろな問題があるので、また別の機会にまとめたいと思います。
まとめ
まだ検証しきれていない部分もあるのですが、現時点でわかっている範囲のことをまとめて見ました。
_ObjectiveCBridgeableError については新しい発見だったのですが、これを積極的に使ってストアに公開するところまでいったわけではないので、利用される際はご注意ください。