没有什么比浮点运算更容易让经验丰富的程序员绊倒的了。新程序员通常甚至没有意识到他们正在使用浮点数,或者不知道浮点数是什么。在现代编程中,非整数通常只是浮点数。一个“正常工作”的黑匣子——直到它不起作用。
问题是浮点数的外观和行为几乎与数字一样我们都在学校学过。但是,“几乎”这个词隐藏了很多复杂性[1];浮点数导致层出不穷的奇怪行为,奇怪的边缘情况和一般问题。有完整的教科书和大学级别的课程介绍如何从浮点数中获得有用的结果。
浮点数最奇怪的地方就是浮点值“NaN”。“Not a Number”的缩写,甚至它的名字都是一个悖论。只有浮点值可以是 NaN,这意味着从类型系统的角度来看,只有数字可以是“非数字”。不过,NaN 的实际行为更加奇怪。最奇怪的一点是 NaN不等于它自己。例如,在 JavaScript 中,您可以创建这个惊人的片段:
这只是 NaN 不当行为的开始。NaN 和其他数量之间的数值比较自动是错误的。例如,这意味着NaN < 5
和NaN >= 5
都是错误的。测试 NaN 的唯一可靠方法是使用与语言相关的内置函数;表达式总是错误的。a === NaN
更糟糕的是,NaN 可能是一个非常严重的问题,因为它们具有传染性。除了少数例外,涉及 NaN 的数值计算会产生更多的 NaN,这意味着错误位置的单个 NaN 可以很快将大量有用的数字变成无用的 NaN。
毫无预兆地偶然发现 NaN 的程序员感到困惑也就不足为奇了。即使是有经验的程序员也可能认为它的存在是一个错误或它的行为有问题。真相更复杂。在浮点数是“完成这项工作的最佳工具”的情况下,NaN 是一种可怕的 hack,但它也是我们发现的最佳选择。在这种情况下,我们不太可能很快摆脱 NaN。
不过,怀疑论者确实有道理,因为 NaN是一个黑客。创建现代浮点数学的妥协是非常真实的,并且通常仍然相关,但并不总是适用。尽管如此,许多现代编程语言默认将所有非整数算术视为浮点算术。这会给毫无准备的程序员带来许多奇怪的错误和令人惊讶的行为。NaN 并不是唯一的例子(有符号的无穷大也表现得很奇怪),但它肯定是最壮观的例子。
为了更好地理解 NaN,让我们考虑一下为什么存在 NaN。我们还将讨论它的一些奇怪行为。有了这些信息,开发人员就可以很好地理解浮点运算,从而判断他们什么时候需要,什么时候不需要。我们还描述了在性能不太重要的情况下可能会更好地工作的浮点数学的替代方案。
从技术上讲,它们的行为最像实数。实数是大多数人在初等教育中学到的,通常被称为数字。
尽管 NaN 本身在技术上只能追溯到 1985 年首次发布的IEEE 754 浮点标准,但整个故事包括了浮点运算的整个历史。反过来,浮点运算可以追溯到电子计算的最开始[2]。
这个基本问题是电子计算机存在的核心。计算机的第一个用途是计算,即比人类更快、更可靠地进行算术运算。这意味着计算机必须以某种方式表示数字。表示整数过去和现在都相对简单,但是一旦你需要分数和无理数,事情就变得复杂了。
简而言之,你不能用数字方式准确地表示每一个有用的非整数;这将需要无限的记忆。即使是有限范围(例如 0.0 和 1.0 之间的所有数字)也是如此。这个问题有两种解决方案:对可以表示的数字设置硬性限制,或者允许内存使用量在运行时无限制地增长。即使在现代机器上,“无限增长”的方法也存在问题;在早期的计算机上,它非常昂贵。实际采用的解决方案是表示足够广泛的有理数,它们足够紧密地组合在一起,以便在大多数计算中“足够接近实际用途”。因此,浮点数和浮点算术诞生了。
浮点数的早期实现遇到了数量惊人的问题[3]。事实证明,浮点数学就像日期计算、国际化或加密:起初看起来很简单,但实际上充满了困难的边缘情况和奇怪的错误。不同的硬件经常使用不同的浮点二进制表示并没有帮助,这意味着相同的代码在不同的机器上运行时会产生不同的结果(或错误)。
为了解决这些问题,专家们齐心协力创建了后来的 IEEE 754 浮点标准。该标准将数十年的经验提炼成一种尽可能“正常工作”的设计。该标准已被广泛采用,以至于对于许多工作程序员来说,非整数计算机算术只是IEEE 754 的浮点数学版本。
IEEE 754 标准还引入了 NaN 以及我们在介绍中讨论的所有奇怪行为。它在以前的浮点实现中有一些先例,但这是它成为永久性的地方。NaN 绝非偶然,而是着眼于未来而刻意创建的。真正的问题是,为什么?为什么所有这些经历和工作会导致如此奇怪的事情?
计算机和整数也有一些惊喜,但与浮点数相比,这些都是次要的。大多数情况下,它们归结为不同风格的整数溢出。
我们知道浮点数可以追溯到计算机的诞生[2]。这意味着它们是为(按照我们的标准)极慢且内存受限的机器设计的。事实上,许多现代问题,如天气预报、机器学习和数字动画,仍将受益于更快的处理和更多的内存;在这些领域,我们的机器仍然受到时间和内存的限制。
这对浮点数有两个巨大的影响。首先是浮点数具有固定的二进制宽度。很难夸大每个数字具有固定二进制宽度所带来的性能提升。它会影响整个处理管道。从硬件加速到高效的数组访问,再到并行化到预测处理,固定的二进制大小让一切变得更快。
第二个效果更微妙:浮点标准优先使用可用位来表示更多数字。更具体地说,标准使用可用位以尽可能最统一的方式增加可用精度和可用范围。这个决定提高了性能,因为它极大地简化了执行操作和标准化舍入所需的逻辑。它还提高了可移植性,因为这意味着浮点数在大多数可用范围内的行为“相同”,因此具有非常不同要求的应用程序仍将“正常工作”。
Prev Chapter:客串一把技术面试官,直接刷掉开价低于平均水平的简历,你怎么看?
Next Chapter:高性能并行编程与优化 - 课件