Importing Forward Declared Objective-C Interfaces and Protocols
01 何が問題だったのか
Objective-Cでは、循環依存の解消やビルド時間短縮のために、型の完全な定義を取り込まず @class Foo; や @protocol Bar; といった前方宣言(forward declaration)で済ませることがよくあります。Objective-C同士ではこれは問題なく機能しますが、SwiftからObjective-C APIを利用する場面では深刻な障害になっていました。
ClangImporterは、前方宣言された型を参照する宣言を一切Swiftへインポートできず、そのまま落としてしまうためです。1つの型が前方宣言されているだけで、その型を引数や戻り値、プロパティなどに含むメソッドや関数がごっそり欠落し、Objective-CのAPIの大部分がSwiftからは存在しないかのように見えてしまいます。
たとえば次のようなObjective-Cヘッダは、
#import <Foundation/Foundation.h>
@class ForwardDeclaredInterface;
@protocol ForwardDeclaredProtocol;
@interface IncompleteTypeConsumer1 : NSObject
@property id<ForwardDeclaredProtocol> propertyUsingAForwardDeclaredProtocol1;
@property ForwardDeclaredInterface *propertyUsingAForwardDeclaredInterface1;
- (id)init;
- (ForwardDeclaredInterface *)methodReturningForwardDeclaredInterface1;
- (void)methodTakingAForwardDeclaredInterface1:(ForwardDeclaredInterface *)param;
@end
ForwardDeclaredInterface *CFunctionReturningAForwardDeclaredInterface1(void);
void CFunctionTakingAForwardDeclaredInterface1(ForwardDeclaredInterface *param);
Swiftから見ると、前方宣言の型に触れるメンバも関数もすべて消え、実質的に次のようなほぼ空のクラスしか見えません。
class IncompleteTypeConsumer1 : NSObject {
init!()
}
この問題を回避するには、Objective-Cヘッダ側で完全な定義を #import するか、利用する側のSwiftファイルで完全な定義を含むモジュールをインポートする必要があります。しかし、既存のObjective-C資産が大きいプロジェクトほど、このような手作業での対応はコストが高く、不要なインポートが増えてビルド時間も悪化します。SwiftからObjective-C APIを使う体験を、Objective-Cから使うときと同じくらい自然にしたい、という動機がありました。
02 どのように解決されるのか
ClangImporterが前方宣言された clang::ObjCInterfaceDecl や clang::ObjCProtocolDecl に出会ったとき、インポートを諦めるのではなく、プレースホルダ型(不透明なスタブ)を合成してインポートを続行するように変更します。合成される型は次のような形です。
// @class Foo は次のように変換されます
@available(*, unavailable, message: "This Objective-C class has only been forward declared; import its owning module to use it")
class Foo : NSObject {}
// @protocol Bar は次のように変換されます
@available(*, unavailable, message: "This Objective-C protocol has only been forward declared; import its owning module to use it")
protocol Bar : NSObjectProtocol {}
これにより、先ほどの空になっていたAPIは、Swiftから次のように見えるようになります。
@available(*, unavailable, message: "This Objective-C class has only been forward-declared; import its owning module to use it")
class ForwardDeclaredInterface {
}
@available(*, unavailable, message: "This Objective-C protocol has only been forward-declared; import its owning module to use it")
protocol ForwardDeclaredProtocol : NSObjectProtocol {
}
class IncompleteTypeConsumer1 : NSObject {
var propertyUsingAForwardDeclaredProtocol1: ForwardDeclaredProtocol!
var propertyUsingAForwardDeclaredInterface1: ForwardDeclaredInterface!
init!()
func methodReturningForwardDeclaredInterface1() -> ForwardDeclaredInterface!
func methodTakingAForwardDeclaredInterface1(_ param: ForwardDeclaredInterface!)
}
func CFunctionReturningAForwardDeclaredInterface1() -> ForwardDeclaredInterface!
func CFunctionTakingAForwardDeclaredInterface1(_ param: ForwardDeclaredInterface!)
プレースホルダ型に対して許される操作は意図的に絞られています。
- 前方宣言された型を引数・戻り値・プロパティなどで参照するObjective-C / Cの宣言を、Swiftからそのまま呼び出す
- 型のインスタンスをSwiftとObjective-Cの間で受け渡す
- インスタンスに対して
NSObject/NSObjectProtocolが提供するメソッドを呼ぶ
一方で、プレースホルダ型そのものをSwift側の宣言で使うことはできません。これは付与されている @available(*, unavailable, ...) によって禁止されます。具体的には、プレースホルダ型を継承したクラスを作ったり、プレースホルダ protocol に適合する型を宣言したり、Swift側で新しいインスタンスを作ったりといった操作はすべてコンパイルエラーになります。完全な定義が無いまま不健全な宣言が生まれることを防ぐためです。
また、完全な定義があると思い込んで存在しないメンバを呼んでしまった場合のために、専用の診断が追加されます。たとえば myFoo.sayHello() と書いた場合、通常の「メンバが存在しません」というエラーに加えて、「クラス Foo は前方宣言されたObjective-Cインターフェースのプレースホルダで、メンバが欠落している可能性がある」「完全な定義をインポートすれば全メンバを利用できる」というヒントと、元の @class Foo; の位置が併せて表示されます。
有効化の方法
この挙動は、Swift 5系では upcoming feature flag ImportObjcForwardDeclarations で有効化します。Swift 6以降の言語モードではデフォルトで有効です。
REPLでは無効
REPLでは、この機能は常に無効のままです。REPLではセッション中に追加で import を実行できるため、「最初は前方宣言しか見えていない状態でプレースホルダを合成し、あとから完全な定義を含むモジュールをインポートする」という状況が発生し得ます。ClangImporterはインポート結果をキャッシュするため、この順序で読み込むと、既にプレースホルダ版が使われている場所と、後からインポートされた完全版が別物として扱われてしまい、混乱した挙動になります。この問題に対する満足な解決策は見つかっていないため、REPLでは従来どおり完全な定義を直接インポートする運用にとどめられています。