Importing Objective-C Lightweight Generics
01 何が問題だったのか
Objective-C には lightweight generics という機能があり、クラスを型パラメータ付きで宣言できます。Foundation のコレクションクラスもこの機能を使っており、たとえば NSArray<NSString *> * は Swift に橋渡しされる際に [AnyObject] ではなく [String] として扱えるようになっています。
しかし、橋渡しされる組み込みコレクション(NSArray / NSDictionary / NSSet)以外の 型パラメータ付き Objective-C クラスは、Swift に import された時点で型パラメータを失っていました。たとえば次のような Objective-C 宣言があったとします。
@interface MySet<T : id<NSCopying>> : NSObject
-(MySet<T> *)unionWithSet:(MySet<T> *)otherSet;
@end
@interface MySomething : NSObject
- (MySet<NSValue *> *)valueSet;
@end
Swift 2 では MySet が非ジェネリックなクラスとして import されてしまい、valueSet() の戻り値が MySet<NSValue> なのか、それとも任意の要素を持つ MySet なのかを型から読み取れません。結果として、Cocoa / Cocoa Touch の中で lightweight generics を採用した API は、Objective-C で使うよりも Swift で使った方が型安全性が低くなる という逆転現象が起きていました。Swift から見える型情報が弱いぶん、コンパイラによる誤用検知もコード補完の質も落ちてしまいます。
02 どのように解決されるのか
型パラメータを持つ Objective-C クラスを、同じ数の型パラメータを持つ Swift のジェネリッククラス として import します。Objective-C 側の型パラメータに付けられた境界は、Swift 側のジェネリック要件に次のように変換されます。
- Swift 側の型パラメータは常にクラス拘束になります(
T : AnyObject相当の要件が暗黙に付きます)。 - 境界がクラス型の場合(たとえば
T : NSValue *)は、対応するスーパークラス要件(T : NSValue)になります。 - 境界がプロトコル適合(たとえば
T : id<NSCopying>)の場合は、そのプロトコルへの適合要件(T : NSCopying)になります。
先ほどの Objective-C の例は次のように import されます。
class MySet<T : NSCopying> : NSObject {
func unionWithSet(otherSet: MySet<T>) -> MySet<T>
}
class MySomething : NSObject {
func valueSet() -> MySet<NSValue>
}
型引数が省略された参照は境界で埋める
Objective-C 側で型引数を付けずに書かれた参照(いわゆる unspecialized な使用)は、Swift 側では 境界の型で型引数を埋めた形 として import されます。
@interface MySomething (ObjectSet)
- (MySet *)objectSet; // note: no type arguments to MySet
@end
extension MySomething {
func objectSet() -> MySet<NSCopying> // note: uses the type bound
}
Objective-C 由来のジェネリッククラスに対する制限
Swift のジェネリクスと Objective-C の lightweight generics は、見た目は似ていても意味論が根本的に異なります。Objective-C の lightweight generics は 型消去(type erasure) に基づいており、実行時のメタクラスからは型引数を取り出せません(MySet、MySet<NSString *>、MySet<NSNumber *> はすべて同じメタクラスを共有しています)。この性質上、Swift 側でも次の制限が課されます。
ダウンキャストは型引数をチェックしない
型引数付きの Objective-C クラスへの条件付きキャストは、型引数の正しさを実行時に検証できません。そのため、境界そのものへのキャスト以外は条件付きキャスト(as?)が許されず、強制キャスト(as!)で「安全であることをプログラマが保証する」形に限定されます。
let obj: AnyObject = ...
if let set1 = obj as? MySet<NSCopying> {
// OK: MySet は構築上必ず MySet<NSCopying> なので、
// これは単に「obj が MySet かどうか」を見ているだけ
}
if let set2 = obj as? MySet<NSNumber> {
// error: conditional cast to specialized Objective-C instance
// doesn't check type argument 'NSNumber'
}
let set3 = obj as! MySet<NSNumber> // OK: 安全性はプログラマが保証する
if let set4 = obj as? MySet<NSCopying> {
let set5 = set4 as! MySet<NSNumber> // MySet<NSNumber> を得たいときはこう書く
}
エクステンションから型パラメータを参照できない
Objective-C 由来のジェネリッククラスに対する Swift 側のエクステンションでは、型パラメータを参照することはできません。
extension MySet {
func someNewMethod(x: T) { ... } // error: cannot use `T`.
}
これは、Swift のエクステンションが本来フル機能のジェネリクスを前提としているのに対し、Objective-C 側は型消去されているため、T の正体を実行時に参照できないことによります。
Swift 側でサブクラスを作る場合
Swift から Objective-C のジェネリッククラスを継承してサブクラスを作る場合は、Swift コンパイラが (Swift の) 型メタデータとして完全な型情報を持っているため、通常の Swift ジェネリクスと同じように扱えます。型パラメータの扱いに制限はかかりません。
既存コードへの影響
Swift 2 まで非ジェネリックなクラスとして import されていた API が、ジェネリッククラスとして import されるようになるため、既存のコードは影響を受け得ます。型推論で吸収できるケースもあります。
let array: NSArray = ["hello", "world"] // OK: NSArray<NSString> と推論される
var mutArray = NSMutableArray() // error: 型引数が必要
マイグレータは境界をそのまま型引数に埋める(NSArray を NSArray<AnyObject> に置き換えるなど)ことで既存コードを動かせますが、多くの場合は利用側でより厳しい境界に書き換えた方が Swift らしいコードになります。