Swift Digest
SE-0140 | Swift Evolution

Warn when Optional converts to Any, and bridge Optional As Its Payload Or NSNull

Proposal
SE-0140
Authors
Joe Groff
Review Manager
Doug Gregor
Status
Implemented (Swift 3.0.1)

01 何が問題だったのか

Swift 3 では SE-0116 によって、Objective-C の id や無制約のコレクションが Swift 側では Any として取り込まれるようになりました。この変更により、StringArray のような 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)

これはたいていの場合バグですが、OptionalAny に入れて渡すこと自体が常に誤りというわけではありません。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 では TT? のサブタイプとして扱われ、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 の入れ子を Anyid 経由で往復させても値が保たれることを保証するための仕組みです。

既存コードへの影響

ソースレベルでの非互換はありません。挙動の変化は Objective-C ブリッジの実行時動作に限られ、これまで不透明なボックスとして渡っていた Optional が、Objective-C 側からは意味のあるオブジェクト(NSNull や中身のブリッジ結果)として見えるようになります。OptionalAnyidAny の往復と動的キャストでの取り出しも、引き続き行えます。