Warn when Optional converts to Any, and bridge Optional As Its Payload Or NSNull
01 何が問題だったのか
Swift 3 では SE-0116 によって、Objective-C の id や無制約のコレクションが Swift 側では Any として取り込まれるようになりました。この変更により、String や Array のような Swift の値型をそのまま Objective-C に渡すのが自然になった一方で、Any は文字通り何でも入れられてしまうため、Optional の値をアンラップせずに nonnull の id を期待する Objective-C API に渡せてしまうという問題が生じました。
// Objective-C
@interface ObjCClass : NSObject
- (void)imported:(id _Nonnull)value;
@end
let s1: String? = nil
let s2: String? = "hello"
// 警告も出ず、不透明なオブジェクトとして渡ってしまう
ObjCClass().imported(s1)
ObjCClass().imported(s2)
これはたいていの場合バグですが、Optional を Any に入れて渡すこと自体が常に誤りというわけではありません。Cocoa の API は Optional を多用するため、ある API から返ってきた Optional をアンラップし忘れたまま別の Any 受け取り API に渡してしまう、といった取り違えが非常に起こりやすくなっています。
さらに、Optional には Objective-C 向けの特別なブリッジが定義されていなかったため、Any 経由で id に渡した Optional は不透明なボックスオブジェクトとしてブリッジされ、Objective-C 側からはほとんどの API で扱えない値になっていました。some な値を渡しても、Objective-C 側では isKindOfClass: や respondsToSelector: が期待通りに動かず、none を渡しても Cocoa 慣用の NSNull としては解釈されません。
また、この問題は Any への昇格に限らず、String(describing:) のような無制約ジェネリック引数をとる API でも同様に発生しますが、本 Proposal の主眼は Cocoa ブリッジでもっとも影響の大きい Any への変換です。
02 どのように解決されるのか
Swift 3.0.1 では、次の 2 点をセットで導入することでこの問題に対処します。
OptionalからAnyへの暗黙変換に対してコンパイラが警告を出すOptionalを Objective-C にブリッジする際、someなら中身の値を、noneならNSNullなどのセンチネルを使う
Optional から Any への暗黙変換に警告
Optional をアンラップせずに Any に入れようとすると、次のように警告が出るようになります。
let x: Int? = 3
let y: Any = x // warning: Optional was put in an Any without being unwrapped
// print は Any を受け取る
print(x) // warning: Optional was passed as an argument of type Any
// without being unwrapped
// NSMutableArray の要素は Objective-C 側で id _Nonnull、
// Swift 側では Any として取り込まれる
let a = NSMutableArray()
a.add(x) // warning: Optional was passed as an argument of type Any
// without being unwrapped
意図的に Optional のまま渡したい場合は、as Any で明示的にキャストすることで警告を抑制できます。
let y: Any = x as Any
print(x as Any)
a.add(x as Any)
Optional のブリッジ動作
Optional が実際に Objective-C 側にブリッジされる際の挙動も変わります。
someの場合は、Optionalの中身の値をブリッジする。たとえばString?が"hello"を保持していれば、Objective-C 側にはNSStringの"hello"として渡る。noneの場合は、Cocoa 慣用のNSNull.nullにブリッジする。
これにより、[T?] のような Swift のコレクションは、NSNull をセンチネルとして含む NSArray として Objective-C 側に渡ります。NSNull を期待する Cocoa API(コレクションや JSON シリアライゼーションなど)とも自然に噛み合います。また、NSNull を想定していない API に none を渡してしまった場合も、不透明なボックスが黙って受理されるのではなく、NSNull does not respond to selector のような分かりやすいランタイムエラーとして現れやすくなります。
Swift 側から見ても、Any にくるまれた Optional の扱いが自然になります。Swift では T は T? のサブタイプとして扱われ、Any に入った Optional の中身を非 Optional 型にダウンキャストすれば(値を保持していれば)成功します。ブリッジ後の Objective-C 側でも、isKindOfClass: や respondsToSelector: がアンラップ済みのクラスに対して期待通りに答えるようになり、この挙動と整合します。
let a: Int? = 3
let b = a as Any
let c = a as! Int // '3' が取り出せる
入れ子の Optional
T?? のような入れ子の Optional は、単純に NSNull にブリッジしてしまうと .some(.none) と .none を区別できなくなるため、レベルごとに別のセンチネルを使って往復時に情報を保てるようにします。Cocoa に対応する慣用表現がないため、各ネストレベル専用の不透明なシングルトンオブジェクトが用いられます。
var x: String???
x = String?.none
x as AnyObject // NSNull(ネストなしの none)
x = String??.none
x as AnyObject // _SwiftNull(1)(2 重の none)
x = String???.none
x as AnyObject // _SwiftNull(2)(3 重の none)
これらは既定のブリッジで使われる _SwiftValue ボックスと同様に、id と互換な不透明シングルトンです。通常のコードで直接触れる対象ではありませんが、Optional の入れ子を Any や id 経由で往復させても値が保たれることを保証するための仕組みです。
既存コードへの影響
ソースレベルでの非互換はありません。挙動の変化は Objective-C ブリッジの実行時動作に限られ、これまで不透明なボックスとして渡っていた Optional が、Objective-C 側からは意味のあるオブジェクト(NSNull や中身のブリッジ結果)として見えるようになります。Optional → Any → id → Any の往復と動的キャストでの取り出しも、引き続き行えます。