numericCast(_:)Mattt Bei Li 🚩🌱

每个人都曾将编程比喻成其他事物。

类比成木工、编织或者园艺。又或者可能类比成解决问题、讲故事或者制作艺术品。毫无疑问,编程与写作也很像;问题是更像诗歌还是散文。如果编程像音乐的话,不管怎么样它都应该是爵士乐。

或许对我们每天所做工作最近似的类比来自中东民间故事:打开任何版本的《一千零一夜 (أَلْف لَيْلَة وَلَيْلَة) 》,你会找到对一种被称作镇尼杰尼精灵或者 🧞 的神奇生物的描述。不管你怎么称呼它们,你一定熟悉它们实现愿望的习惯,和必然会引起的不幸。

从许多方面来看,电脑是抽象的愿望满足机的物理体现。像精灵一样,电脑会开心的执行任何你告诉它要做的事,而不会考虑你真正的意图是什么。之后当你意识到自己的错误时,就已经太晚了。

作为一个 Swift 开发者,很有可能你遇到过整数类型转换错误并想着「我希望这些警告赶紧消失,代码能编译通过」。

如果这听起来很熟悉,那你会对学习 numbericCast(_:) 感到高兴,它是 Swift Standard Libray 中一个小小的实用函数,有可能正是你所希望的。但是请小心提出你的愿望,它有可能马上会成真。


让我们从消除觉得 numericCast(_:) 有什么魔法开始,通过查看它的实现

public func numericCast<T : BinaryInteger, U : BinaryInteger>(_ x: T) -> U {
  return U(x)
}

(像从我们有关 Never 的文章里学到的一样,极小量的 Swift 代码也能有巨大的作用。)

Swift 4 推出的 BinaryInteger 协议,作为语言中整个数字实现的一部分。它提供了与整数工作的统一接口,包括有符号和无符号,还有所有的结构和大小。

当你将一个整数值转换为另一个类型时,另一个类型有可能无法表示这个值。这会发生在你尝试将一个有符号整数转换成一个无符号整数时(比如将 -42 转换为 UInt)或者数值超过了目标类型所能表示的范围时(比如 UInt8 只能表示 0255 之间的数字)。

BinaryInteger 为整数类型转换定义了四种策略,每一种在处理超出范围的值时都有不同行为:

正确的转换策略取决于使用时的情况。有些时候,希望能钳制数值到可表示的范围;有些时候,最好不要获取到任何值。对于 numbericCast(_:) 来说,它为了方便使用了范围检查转换。缺点就是使用超过范围的数值调用这个函数会导致运行时错误(具体来说,在 -O-Onone 时陷入溢出错误)。

更多有关 Swift 4 中数字实现改变的信息,请查阅 SE-0104: “Protocol-oriented integers”

这个主题也在《Swift 数字详解》中有更详细的讨论。

字面地思考,批判地思考

在更进一步之前,让我们先来谈论一下整数字面量。

我们在之前的文章讨论过,Swift 提供了一个方便且可扩展的方式来在源代码中表示值。当和语言中的类型推断一起使用时,它们通常「可以工作」……这样一切都很好,但是当它们「无法工作」时就非常令人困惑了。

考虑下面的例子,有符号整型数组和无符号整型数组使用同样的字面量初始化:

let arrayOfInt: [Int] = [1, 2, 3]
let arrayOfUInt: [UInt] = [1, 2, 3]

尽管它们好像是相等的,但我们不能做下面例子中的事情:

arrayOfInt as [UInt] // Error: Cannot convert value of type '[Int]' to type '[UInt]' in coercion

解决这个问题的一种方式是,将 numericCast 函数作为参数传入 map(_:)

arrayOfInt.map(numericCast) as [UInt]

这样等同于直接传入 UInt 范围检查构造器:

arrayOfInt.map(UInt.init)

让我们再看一次这个例子,这次使用稍微不同的数值:

let arrayOfNegativeInt: [Int] = [-1, -2, -3]
arrayOfNegativeInt.map(numericCast) as [UInt] // 🧞‍ Fatal error: Negative value is not representable

作为一个编译时类型功能的运行时近似物,numericCast(_:) 更像是 as! 而不是 asas?

将这个和传入精确转换构造器 init?(exactly:) 的结果相比:

let arrayOfNegativeInt: [Int] = [-1, -2, -3]
arrayOfNegativeInt.map(UInt.init(exactly:)) // [nil, nil, nil]

numericCast(_:),像它内在的范围检查转换一样,是一个钝器,当你决定使用它时,明白你在权衡什么是非常重要的。

正确的代价

在 Swift 中,通常指导是为整数值使用 Int(且为浮点值使用 Double),除非有非常好的理由来使用更具体的类型。尽管 Collectioncount 在定义上是非负的,但我们使用 Int 而不是 UInt。因为在与其他 API 交互时转换来转换去类型的代价要比更精确类型带来的好处要大。同样的原因,用 Int 来表示小数字几乎总是会更好,比如工作日数字,尽管它所有的可能值用一个 8 位整型存储都绰绰有余。

理解这个实践最好的方式就是在 Swift 里和 C API 对话几分钟。

古老且低级的 C API 里充斥着体系结构相关的类型定义和细微调整过的值存储空间。独立的来看,它们是可管理的。但从像头文件到指针这些互操作性麻烦上看,它们对某些问题可能会是一个断点(我不是在说调试中那种)。

当你看红色看到烦,只想要编译通过时,numericCast(_:) 就在那等着你。

编译的随机性

很多人应该会熟悉官方文档中的例子

SE-0202 之前,(在苹果的平台上)Swift 中生成随机数的标准实践需要引入 Darwin 框架然后调用 arc4random_uniform(3) 函数:

uint32_t arc4random_uniform(uint32_t __upper_bound)

在 Swift 中使用 arc4random 需要进行不止一次而是两次类型转换:一是上限参数(IntUInt32),二是返回值(UInt32Int):

import Darwin

func random(in range: Range<Int>) -> Int {
    return Int(arc4random_uniform(UInt32(range.count))) + range.lowerBound
}

真恶心。

通过使用 numericCast(_:),我们可以让代码更可读一些,尽管也会变长一点:

import Darwin

func random(in range: Range<Int>) -> Int {
    return numericCast(arc4random_uniform(numericCast(range.count))) + range.lowerBound
}

在这里 numericCast(_:) 没有做任何类型合适的构造器做不到的事情。它的作用是指明这个转换是敷衍的——为了让代码编译需要做的最少的事情。

不过从前言有关精灵的事情中学到,我们应该谨慎的对待我们的愿望。

经过仔细检查,上面对例子中对 numericCast(_:) 的使用有一个明显的缺陷:当值超过 UInt32.max 时会造成崩溃!

random(in: 0..<0x1_0000_0000) // 🧞‍ Fatal error: Not enough bits to represent the passed value

如果我们查看现在 Int.random(in: 0...10) 在 Swift Standard Library 中的实现,可以看到其使用了钳制转换而不是类型检查转换。并且从一个随机字节缓冲区中取值而不是委托给像 arc4random_uniform 这样的简便函数。


编译通过的代码和正确的代码是不一样的。但有时候需要通过前者来最终获得后者。审慎的使用,numericCast(_:) 会是一个方便且能快速解决问题的工具。和类型转换构造器相比它还有表明潜在异常行为的好处。

根本上来说,编程就是准确描述我们想要怎么样——通常伴随艰苦的细节。并没有一个和精灵似的「做正确的事情」 CPU 指令(就算有的话,我们能信赖它吗?)。幸好,Swift 可以让我们比其他很多语言更安全和简洁的做这些事情。老实说,谁还能要求更多呢?


除非另有声明,本文采用知识共享「署名-非商业性使用 3.0 中国大陆」许可协议授权。