Bridge Numeric Types to NSNumber and Cocoa Structs to NSValue
01 何が問題だったのか
SE-0116 により、Objective-C の id や型のないコレクションは Swift 側では Any として import されるようになりました。これにより String や Array のような Swift の値型を Objective-C の API に自然に渡せるようになった反面、Objective-C オブジェクトとしてうまく橋渡しされない型を渡してしまう危険も生じました。特に問題だったのが数値型と Cocoa の構造体です。
数値型の一部しか NSNumber にならない
Swift 3 の時点では、Int / UInt / Double は自動的に NSNumber に橋渡しされていましたが、それ以外のビット幅の数値型(Int8 / Int16 / Int32 / Int64 / UInt8 / UInt16 / UInt32 / UInt64 / Float)は opaque な箱にくるまれるだけで、NSNumber としては扱われませんでした。
let i = 17
let plist = ["seventeen": i]
// OK
try! JSONSerialization.data(withJSONObject: plist)
let j: UInt8 = 38
let brokenPlist = ["thirty-eight": j]
// j が JSON で扱える型に橋渡しされず、実行時に例外が投げられる
try! JSONSerialization.data(withJSONObject: brokenPlist)
このように、同じ「数値を辞書に入れて JSON 化する」コードでも、型が Int か UInt8 かで動いたり動かなかったりする状態でした。歴史的には、Swift 1.x では Swift の値型と Objective-C オブジェクトの間で暗黙の双方向ブリッジ変換が許されていたため、NSNumber を経由してあらゆる数値型同士が暗黙に相互変換できてしまう問題を避けるために、全数値型の NSNumber 橋渡しが見送られていました。SE-0072 により暗黙ブリッジ変換が撤廃された今、この制約を残す理由はなくなっています。
Cocoa の構造体が意味のあるオブジェクトにならない
Core Animation など、NSArray や NSDictionary の要素として NSValue でボックス化された構造体を受け取る Cocoa API が多数あります。id-as-Any のもとでは、次のようなコードが動いてほしいところです。
anim.toValue = CGPoint.zero
しかし CGPoint のような Swift の構造体は、Objective-C 側から見て意味のあるオブジェクトにはなっていませんでした。コンパイルは通るのに実行時に Core Animation が正しく動かない、というように、ユーザから見れば原因を特定しにくい不具合を生みやすい状態でした。NSRange や CGRect、CMTime など、対応する NSValue ファクトリがすでにある構造体であっても、自動的に NSValue にボックス化されることはなかったのです。
02 どのように解決されるのか
Swift のすべての数値型を Objective-C オブジェクトとして渡したときに NSNumber へ、NSValue ファクトリが用意されている Cocoa の構造体を NSValue へ自動的に橋渡しするようにします。ソースコード上の書き方は変わらず、Any として Objective-C に渡ったときの動的な振る舞いだけが変わる変更です。
NSNumber に橋渡しされる数値型
次の Swift 数値型が、Objective-C 側でオブジェクトとして扱われるときに NSNumber に橋渡しされます。Int / UInt / Double に加えて、これまで対象外だったビット幅違いの整数型と Float が含まれます。
Int8/Int16/Int32/Int64UInt8/UInt16/UInt32/UInt64Float/Double
これにより、冒頭の JSONSerialization の例は UInt8 のままでも期待どおり動作するようになります。
let j: UInt8 = 38
let plist = ["thirty-eight": j]
// j が NSNumber として橋渡しされるので成功する
try! JSONSerialization.data(withJSONObject: plist)
NSValue に橋渡しされる構造体
対応する NSValue ファクトリとプロパティが存在する次の Cocoa 構造体が、Objective-C オブジェクトとして扱われるときに NSValue に自動でボックス化されます。
NSRangeCGPoint/CGVector/CGSize/CGRect/CGAffineTransformUIEdgeInsets/UIOffsetCATransform3DCMTime/CMTimeRange/CMTimeMappingMKCoordinate/MKCoordinateSpanSCNVector3/SCNVector4/SCNMatrix4
この結果、Core Animation への代入のようなコードが期待どおりに動きます。
// CGPoint が NSValue として橋渡しされるので Core Animation が正しく動く
anim.toValue = CGPoint.zero
元の型への復元(ダウンキャスト)
Any を経由して Objective-C に渡された値を、Swift 側で元の型に戻したい場面もあります。
NSValue の場合は、ボックス内の構造体の型情報が objCType プロパティに保持されており、キャスト時にこれを確認することで安全に元の型へ戻せます。実装は型ごとのファクトリ(valueWithRange: など)ではなく、汎用の valueWithBytes:objCType: / getValue: を使うようになっており、OS バージョンによる可用性の差の影響を受けにくくなっています。
NSNumber はもう少し複雑です。Swift から橋渡しして作られた NSNumber は内部的に専用のサブクラスを使って元の Swift 型を覚えているため、そのインスタンスは元の Swift 数値型に正確にキャストし直せます。一方、Cocoa 側で作られた NSNumber(元の型が分からないもの)は「その数値が対象の Swift 数値型で厳密に表現可能であればキャストに成功する」という規則で扱われます。たとえば Cocoa 由来の NSNumber が整数 3 を保持している場合、Int にも Int8 にも Double にもキャストできます。このため、Cocoa 由来の NSNumber については「最初に入れたときの正確な型」を後から区別することはできません。
既存コードへの影響
ソース互換性上の影響はなく、既存のコンパイル結果が変わることはありません。変わるのは Objective-C ブリッジの動的な振る舞いで、これまで opaque なオブジェクトとして渡っていた値が、意味のある NSNumber / NSValue として渡るようになります。Swift 側でも、具体的な数値型や構造体 → Any → id → Any → 元の型というラウンドトリップが動的キャストで成立することは維持されます。唯一失われるのは、前述のとおり Cocoa 由来の NSNumber から元の数値型を厳密に識別する能力です。