为什么 0.1 + 0.2 = 0.300000004
JavaScript 作为一门诞生自上个世纪 90 年代的编程语言[^1],从诞生之初就因为诡异的隐式类型转换等原因被黑,很多 JavaScript 的开发者还会吐槽浮点数加法的『奇葩』问题 — 为什么 0.1 + 0.2 在 JavaScript 中不等于 0.3,相信很多人都对这个问题的答案有一个大概的认识,但是都没有深入研究过,这个问题的答案让 William Kahan 在 1989 年获得图灵奖[^2]。
其实有上述问题的不止 JavaScript 一门编程语言,几乎所有现代的编程语言都会遇到上述问题,包括 Java、Ruby、Python、Swift 和 Go 等等,你可以在 https://0.30000000000000004.com/ 中找到常见的编程语言在计算上述表达式的结果[^3]。这不是因为它们在计算时出现了错误,而是因为浮点数计算标准的要求。floating-point-math 图 1 - 常见的浮点数『错误』从最开始接触 C 语言编程,作者就接触到了浮点数> 0.1 + 0.2
0.30000000000000004
float
,然而在很长一段时间中,作者都将编程中的浮点数和数学中的小数看做同一个东西,不过当我们重新审视它们时,会发现这两个概念的不同之处。- 编程中的浮点数的精度往往都是有限的,单精度的浮点数使用 32 位表示,而双精度的浮点数使用 64 位表示;
- 数学中的小数系统可以通过引入无限序列
...
可以任意的实数[^4];
- 二进制无法在有限的长度中精确地表示十进制中 0.1 和 0.2;
- 单精度浮点数、双精度浮点数的位数决定了它们能够表示的精度上限;
二进制与十进制
我们日常生活中使用的数字基本都是 10 进制的,然而计算机使用二进制的 0 和 1 表示整数和小数,所有有限的十进制整数都可以无损的转换成有限长度的二进制数字,但是要在二进制的计算机中表示十进制的小数相对就很麻烦了,我们以 0.375 为例介绍它在二进制下的表示[^6]:[^6]: Decimal to Binary converter https://www.rapidtables.com/convert/number/decimal-to-binary.html小数点后面的位数依次表示十进制中的 0.5、0.25、0.125 和 0.0625 等等,这个表示方法非常好理解,每一位都是前一位的一半。0.375 在二进制表示看来确实是『整数』。然而如下图所示,想要使用二进制表示十进制中的 0.1 和 0.2 是比较复杂的:decimals-binary-representation图 2 - 二进制表示的十进制小数无论是 0.1 还是 0.2,这两个数字都不是二进制中的『整数』,我们没有办法精确地表示它们,只能通过无限循环小数尝试接近它们的真实值;与之相似的是,它们相加的结果 0.3 也无法用有限长度的二进制表示:dot-three-binary-representation图 3 - 二进制表示的 0.3这三个不同的数字都会在最后的小数部分无限循环 1100 来趋近于真实值,如果计算机中的浮点数可以表示无限循环小数就有可能解决这个问题,但是事实的真相是浮点数只会表示有限小数,所有超过特定精度的数字都会做舍入处理。
精度上限
编程语言中的浮点数一般都是 32 位的单精度浮点数float
和 64 位的双精度浮点数 double
,部分语言会使用 float32
或者 float64
区分这两种不同精度的浮点数。想要使用有限的位数表示全部的实数是不可能的,不用说无限长度的小数和无理数,因为长度的限制,有限小数在浮点数中都无法精确的表示。float-and-double图 4 - 单精度与双精度浮点数- 单精度浮点数
float
总共包含 32 位,其中 1 位表示符号、8 位表示指数,最后 23 位表示小数; - 双精度浮点数
double
总共包含 64 位,其中 1 位表示符号,11 位表示指数,最后 52 位表示小数;
[0, 126]
表示 [-127, -1]
,而 [127, 255]
表示 [0, 128]
,二进制的 01111100 是十进制的 124,表示 ,最后的 23 位是二进制的小数 0.25:floating-number-example图 5 - 0.15625 的单精度浮点数表示通过上图中的公式 可以将浮点数的二进制表示转换成十进制的小数。0.15625 虽然还可以用单精度的浮点数精确表示,但是 0.1 和 0.2 只能使用浮点数表示近似的值:dot-one-dot-two-floating-number图 6 - 0.1 和 0.2 的单精度浮点数表示因为 0.2 和 0.1 只是指数稍有不同,所以上图中只展示了 0.1 对应的单精度浮点数,从上图的结果我们可以看出,0.1 和 0.2 在浮点数中只能用近似值来代替,精度十分有限,因为单精度浮点数的小数位为 23,双精度的小数位为 52,同时都隐式地包含首位的 1,所以它们的精度在十进制中分别是 和 位。
因为 0.1 和 0.2 使用单精度浮点数表示的实际值为 0.100000001490116119384765625 和 0.20000000298023223876953125[^7],所以它们在相加后就得到的结果与我们在一开始看到的非常相似:dot-three-floating-number图 7 - 0.1 加 0.2 的结果上图只是使用单精度浮点数表示的数字,如果使用双精度浮点数,最终结果中的 3 和 4 之间会有更多的 0,但是小数出现的顺序是非常相似的。浮点数的运算法则相对来说比较复杂,感兴趣的读者可以自行搜索相关的资料,我们在这里不展开介绍了。
总结
当我们在不同编程语言中看到 0.300000004 或者 0.30000000000000004 时不应该感到惊讶,这其实说明编程语言正确实现了 IEEE 754 标准中描述的浮点数系统,在使用单精度和双精度浮点数时也应该牢记它们只有 7 位和 15 位的有效位数。在交易系统或者科学计算的场景中,如果需要更高的精度小数,可以使用具有 28 个有效位数的 decimal 或者直接使用分数,不过这些表示方法的开销也随着有效位数的增加而提高,我们应该按照需要选择最合适的方法。重新回到今天的问题 — 0.1 和 0.2 相加不等于 0.3 的原因包括以下两个:- 使用二进制表达十进制的小数时,某些数字无法被有限位的二进制小数表示;
- 单精度和双精度的浮点数只包括 7 位或者 15 位的有效小数位,存储需要无限位表示的小数时只能存储近似值;
- 有哪些编程语言内置了高精度的浮点数或者小数?
- 如何实现一个可以精确表示所有实数(包括有理数和无理数)的系统?
有道无术,术可成;有术无道,止于术
欢迎大家关注Java之道公众号
好文章,我在看❤️
评论