Limiting @objc inference
01 何が問題だったのか
Swift には、宣言を Objective-C から見えるようにするための @objc 属性があります。明示的に書くこともできますが、Swift 3 までは Objective-C との相互運用の利便性のために、コンパイラがさまざまな場所で @objc を暗黙的に推論していました。この暗黙推論には複数の問題がありました。
推論ルールがわかりにくい
Swift 3 までの @objc 推論は次のような場面で行われていました。
@objcなメソッドの override@objcプロトコルの要件を満たす宣言@IBAction/@IBOutlet/@NSManagedが付いた宣言dynamic宣言NSObjectを継承したクラス内で、Objective-C に表現可能な宣言
これらの規則は複雑で、「この宣言は Objective-C に公開されるのか」をプログラマが即座に判断しづらく、予期せぬ Objective-C エントリポイントを生み出す温床になっていました。
Objective-C セレクタの衝突
NSObject 継承クラスで Swift 的な API を書くと、Objective-C セレクタが衝突しやすいという問題もありました。
class MyNumber : NSObject {
init(_ int: Int) { }
init(_ double: Double) { }
// error: initializer 'init' with Objective-C selector 'init:'
// conflicts with previous declaration with the same Objective-C selector
}
Swift API Design Guidelines に沿った命名は、Objective-C の Coding Guidelines に沿った命名とは噛み合わないことが多く、衝突を避けるには結局 @objc(initWithInteger:) のように明示的なセレクタ指定が必要でした。暗黙推論が有効でも、結局明示的に書き直す羽目になるケースが多かったのです。
バイナリサイズと起動時間のコスト
@objc が付くと、コンパイラは Objective-C の呼び出し規約から Swift の呼び出し規約へブリッジする「thunk」を生成し、Objective-C のメタデータにも登録します。Apple 社内の予備的な計測では、Cocoa / Cocoa Touch アプリのバイナリサイズの 6〜8% がこの thunk で占められており、その多くは使われていないものでした。起動時にもダイナミックリンカが Objective-C メタデータを処理するため、無駄なエントリポイントは起動時間にも影響します。
@objc プロトコルエクステンションに関する誤解
@objc プロトコルのエクステンションは Objective-C のエントリポイントを生成しません。しかし、NSObject サブクラスのエクステンションは(暗黙推論により)Objective-C のエントリポイントを生成するため、同じ「エクステンション」でも挙動が違うというわかりにくさがありました。
@objc protocol P { }
extension P {
func bar() { }
}
class C : NSObject, P { }
let c = C()
print(c.responds(to: Selector("bar"))) // prints "false"
P.bar() にも Objective-C エントリポイントがあるだろうという期待は、NSObject 継承クラスの挙動から類推して生じる誤解でした。
02 どのように解決されるのか
Swift 4 では、@objc の暗黙推論を「セマンティクス上どうしても必要な場合」だけに絞り込みます。そのうえで、クラス単位・エクステンション単位で推論を一括有効化・無効化するための属性を用意し、必要なケースで使い勝手を損なわないようにします。
推論が残るケース
次の場合は Swift 4 でも @objc が暗黙に付きます。プログラムのセマンティクスとして Objective-C エントリポイントが必須だからです。
@objc宣言の override(Objective-C 側からスーパークラス経由で呼ばれたときに、オーバーライドが効く必要があるため)@objcプロトコルの要件を満たす宣言(プロトコル経由の呼び出しが Objective-C のメッセージ送信として行われるため)@IBAction/@IBOutlet/@NSManagedが付いた宣言(Interface Builder や CoreData との連携が Objective-C ランタイム経由のため)@GKInspectable/@IBInspectableが付いた宣言(GameplayKit / Interface Builder が Objective-C ランタイム経由のため。これは Swift 4 で新たに推論対象になったケース)
class Super {
@objc func foo() { }
}
class Sub : Super {
// 暗黙に @objc(override 対象が @objc なので必須)
override func foo() { }
}
@objc protocol MyDelegate {
func bar()
}
class MyClass : MyDelegate {
// 暗黙に @objc(@objc プロトコルの要件を満たす必要があるため)
func bar() { }
}
推論が外れるケース
次の 2 つは Swift 3 では推論されていましたが、Swift 4 からは推論されなくなります。必要であれば @objc を明示的に書く必要があります。
dynamic 宣言: これまで dynamic は暗黙に @objc になっていましたが、Swift 4 からは @objc を明示しないとエラーになります。これは dynamic の現在の実装(Objective-C ランタイム経由のメッセージ送信)と、将来的に Swift 独自のランタイムで dynamic を実現する可能性を分離するための変更です。
class MyClass {
dynamic func foo() { } // error: 'dynamic' method must be '@objc'
@objc dynamic func bar() { } // okay
}
NSObject 継承クラス内の宣言: もっとも大きな変更点です。NSObject を継承していても、宣言が Objective-C に公開されるのは @objc が明示的に書かれた場合だけになります。
class MyClass : NSObject {
func foo() { } // Swift 4 では Objective-C に公開されない
}
この変更により、#selector や #keyPath、AnyObject 経由のメッセージ送信が効かなくなるコードが出てきますが、コンパイラが警告やエラーを出し、多くの場合は @objc を追加する Fix-It を提示します。Objective-C 側から呼ばれている場合も、Swift が生成する生成ヘッダに deprecation 属性が付き、Objective-C のコンパイラが警告を出します。
クラス単位で推論を復活させる @objcMembers
XCTest のように Objective-C ランタイムのイントロスペクション(XCTestCase のサブクラスからテストメソッドを探し出すなど)に依存するフレームワークのために、クラス単位で @objc 推論を復活させる @objcMembers 属性が導入されます。@objcMembers を付けたクラスは、それ自体・エクステンション・サブクラス・サブクラスのエクステンションまで含めて、Objective-C に表現可能な宣言が @objc になります。
@objcMembers
class MyClass : NSObject {
func foo() { } // 暗黙に @objc
func bar() -> (Int, Int) { // @objc にならない(タプル返り値は Objective-C で表現不可)
return (0, 0)
}
}
extension MyClass {
func baz() { } // 暗黙に @objc
}
class MySubClass : MyClass {
func wibble() { } // 暗黙に @objc
}
Objective-C 側には対応する swift_objc_members 属性が用意され、Objective-C で定義されたクラスを Swift にインポートする際に @objcMembers として扱うよう指示できます。たとえば XCTestCase は次のようにインポートされます。
__attribute__((swift_objc_members))
@interface XCTestCase : XCTest
/* ... */
@end
@objcMembers
class XCTestCase : XCTest { /* ... */ }
エクステンション単位で推論を切り替える
エクステンションに @objc または @nonobjc を付けると、そのエクステンション内のメンバーの既定の扱いを切り替えられます。個別のメンバーに @objc / @nonobjc が付いていれば、そちらが優先されます。
class SwiftClass { }
@objc extension SwiftClass {
func foo() { } // 暗黙に @objc
func bar() -> (Int, Int) { // error: tuple type (Int, Int) not
// expressible in @objc. add @nonobjc or move this method to fix the issue
return (0, 0)
}
}
@objcMembers
class MyClass : NSObject {
func wibble() { } // 暗黙に @objc
}
@nonobjc extension MyClass {
func wobble() { } // @objcMembers であっても @objc にならない
}
個別宣言の暗黙推論と違い、@objc extension は「このエクステンションのすべてを Objective-C に公開する」という意図を明示したものなので、公開できないメンバーがあればコンパイラがエラーで知らせてくれます。
移行のためのツール
Swift 3 → Swift 4 の移行には、段階的に対応するためのしくみが用意されています。
- Swift 4 モードでは、削除される推論に依存している
#selector/#keyPath/AnyObjectメッセージング / エクステンションメンバーの override などに警告やエラーを出します。 - Swift 3 互換モードでは、削除予定の推論で
@objcが付いた Objective-C エントリポイントに、ランタイムフックswift_objc_swift3ImplicitObjCEntrypointを通じて検出機構を仕込みます。環境変数SWIFT_DEBUG_IMPLICIT_OBJC_ENTRYPOINTを 1〜3 に設定すると、呼び出しのログ、バックトレースの出力、さらにはクラッシュを選べます。3 は自動テストで「もう Swift 4 に移行して問題ない」ことを確かめるのに使えます。 - マイグレータには「conservative」(Swift 3 で暗黙
@objcになっていた宣言すべてに明示的な@objcを足す安全側の既定)と「minimal」(本当に必要な箇所にだけ@objcを足す)の 2 モードが用意されます。
典型的なワークフローは次の通りです。
- Swift 4 モードで警告を潰す。
SWIFT_DEBUG_IMPLICIT_OBJC_ENTRYPOINTを設定してアプリを動かし、ランタイムで検出されるケースに@objcを足す。- 「minimal」マイグレーションで Swift 4 に移行する(この時点で追加される
@objcはdynamic宣言だけで済むはず)。