Swift Digest
SE-0160 | Swift Evolution

Limiting @objc inference

Proposal
SE-0160
Authors
Doug Gregor
Review Manager
Chris Lattner
Status
Implemented (Swift 4.0)

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#keyPathAnyObject 経由のメッセージ送信が効かなくなるコードが出てきますが、コンパイラが警告やエラーを出し、多くの場合は @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 モードが用意されます。

典型的なワークフローは次の通りです。

  1. Swift 4 モードで警告を潰す。
  2. SWIFT_DEBUG_IMPLICIT_OBJC_ENTRYPOINT を設定してアプリを動かし、ランタイムで検出されるケースに @objc を足す。
  3. 「minimal」マイグレーションで Swift 4 に移行する(この時点で追加される @objcdynamic 宣言だけで済むはず)。