NSNumber bridging and Numeric types
01 何が問題だったのか
Objective-C / Cocoa の NSNumber は、整数・浮動小数点数・真偽値などのあらゆる数値を包み込むことのできる不透明な「箱」です。SwiftからObjective-C APIを呼び出すと、JSONのパース結果(JSONSerialization)、NSCoder によるアーカイブ、Core Dataのクエリ結果などで、結局 NSNumber としてしか受け取れない数値が頻繁に返ってきます。
このとき、NSNumber を具体的なSwiftの数値型に取り出すには、キャスト(as? / as!)やイニシャライザを使うことになりますが、Swift 3時点での NSNumber ブリッジングの挙動は直感に反しており、バグを誘発するものでした。
as? / is が型を保存する挙動
当時の NSNumber は「生成時に使われた型と、取り出そうとしている型が一致するか」を見るよう実装されていました。そのため、格納されている値そのものは取り出し先の型で表現可能であっても、キャストが失敗するケースが発生していました。逆に、値が入らないはずでもキャストが成功してしまうケースもありました。
let n = NSNumber(value: Int64.max)
// Int64.max は Int16 では表現できないのに is Int16 が true になってしまう
if n is Int16 {
// Swift 3時点ではここに入ってしまう
} else if n is Int64 {
// 本来こちらに入るべき
}
また、UInt32 として箱に入れた小さな値を Int16 として取り出そうとすると、値としては収まるにも関わらず「型が違う」としてキャストが失敗するという、逆方向の不整合もありました。
イニシャライザが暗黙に切り詰める
Int8(_:) などの数値型イニシャライザに NSNumber を渡すと、オーバーフローを静かに切り詰めた値が返ってきます。これはC由来の intValue 系アクセサの挙動を踏襲したものですが、Swiftの他の数値変換イニシャライザ(エラーになるかオプショナルを返す)とは一貫性がなく、データが壊れていることに気付きにくい設計でした。
let v = Int8(NSNumber(value: Int64.max))
// v は Int8(-1)。Int64.max は Int8 に収まらないが、静かに切り詰められる
実害が出やすい場面
この挙動は、次のようにフレームワーク内部で NSNumber が介在するコードで容易に不具合を生みます。
NSCoderでアーカイブしたInt16を復元する際、as! Int16が失敗する。JSONSerializationでJSONをパースしたとき、サーバ側が1487980252519を1487980252519.0と返すよう変更しただけで[String: Int]としてのキャストが失敗する(値は依然として整数であるにもかかわらず)。- Core Dataなどから返ってくる
NSNumberを扱うコードが、APIの実装言語(Swift / Objective-C)や内部の生成経路によって挙動が変わってしまう。
結局のところ、NSNumber に対する as? や is、数値型イニシャライザが何を意味するのかが曖昧だったことが問題の根本でした。
02 どのように解決されるのか
NSNumber を「数値を入れた不透明な箱」とみなし、as? / is の意味を 「この箱に入っている値を、目的の型で安全に(値を失うことなく)表現できるか?」 に統一します。あわせて、各数値型のイニシャライザを整理し、「厳密に一致するか」と「切り詰めてでも取り出すか」を呼び分けられるようにします。
as? / is の新しい意味
新しい挙動では、NSNumber を生成するときに使った型は問われません。格納されている値が目的の型で正確に表現できるならキャストは成功し、そうでなければ nil(as?)または false(is)になります。
// 入れたときと同じ型で取り出せるのは当然成功
let a = NSNumber(value: UInt32(543))
let a1 = a as? UInt32 // Optional(543)
// 値が収まる別の型でも成功する
let a2 = a as? Int16 // Optional(543)
// 値が収まらない型ではちゃんと失敗する
let a3 = a as? Int8 // nil
// 生成時の型にとらわれない
let b = NSNumber(value: Int64.max)
let b1 = b is Int16 // false(Int64.max は Int16 に収まらない)
let b2 = b is Int64 // true
整数と浮動小数点数の行き来も、値が正確に表現できる場合にのみ成功します。たとえば、NSNumber(value: 1.5) を Int として取り出すと nil になり、NSNumber(value: 2.0) を Int として取り出すと 2 になります。
イニシャライザの整理
各数値型(Int8 / UInt8 / Int16 / UInt16 / Int32 / UInt32 / Int64 / UInt64 / Int / UInt / Float / Double / CGFloat / Bool)に、意味が明確な二つのイニシャライザを追加します。
init?(exactly: NSNumber)— 値が目的の型で正確に表現できるときのみ値を返し、そうでなければnilを返すfailable initializer。init(truncating: NSNumber)— 必要に応じて切り詰める(C由来のintValueなどと同じ挙動の)イニシャライザ。値が収まらない場合も非オプショナルで値を返す。
従来の init(_ number: NSNumber) は切り詰める挙動だったものの、呼び出し側の字面からはそれが読み取れませんでした。これらは非推奨となり、意図に応じて exactly: / truncating: を使い分けます。
let big = NSNumber(value: Int64.max)
// 厳密に取りたい場合
let exact = Int8(exactly: big) // nil(収まらないので)
// 明示的に切り詰めたい場合
let truncated = Int8(truncating: big) // -1(切り詰めの結果であることが呼び出し側から明らか)
as? と init?(exactly:) は同じ「安全に表現できるか」という意味論で揃っており、どちらを使っても意味がぶれません。切り詰めが欲しいときだけ init(truncating:) を選ぶ、という使い分けになります。
まとめ
Swift 4以降、NSNumber を扱うときは次の指針で書けば安全です。
- 値を取り出したいだけなら
as?を使う。生成元の型を気にする必要はなく、値が収まらないときだけnilになる。 - 型を強調したい文脈では
Int8(exactly: n)のように書いても同じ意味になる。 - どうしてもC互換の切り詰め挙動が必要な場合のみ
Int8(truncating: n)を使う。切り詰めていることが字面から明らかになる。
これにより、JSONSerialization や NSCoder、Core Data などから返ってくる NSNumber を、サーバ側の表現の揺れ(整数↔小数)や生成経路の違いに惑わされずに扱えるようになります。