用了一天时间,我终于彻底搞懂了 0.1+0.2 是否等于 0.3!

5,129 阅读7分钟

「这是我参与2022首次更文挑战的第8天,活动详情查看:2022首次更文挑战

本文中涉及到二进制转换和 IEEE 754 标准,所以有时你会看到一堆 000111 或者一些计算机术语,对于非科班的小伙伴可能会有些不友好,但属实都是必要的东西,而且在本文中都进行了相应的讲解。相信我,耐心读完,你一定会有收获的!😀

现象

话不多说,直接上图!

0.1+0.2-1.png

通过上图我们知道,答案是不相等,而且还有一个很神奇的问题,0.1+1-1 也不等于 0.1,并且先加后减和先减后加的结果竟然还不一样!

岳云鹏震惊.jpeg

JavaScript 到底背着我们干了什么?!

原因

其实这个问题不能完全怪 JavaScript,导致这样的问题是因为 JavaScript 中使用基于 IEEE 754 标准的浮点数运算,所以会产生舍入误差。
也就是说所有遵循 IEEE 754 标准的语言进行浮点数运算的时候,都会有这个问题。

产生误差过程

接下来我们来揭秘产生误差的过程。
首先这里我们需要知道,浮点数运算的时候需要先转成二进制,然后再进行运算。那么十进制浮点数是如何转二进制的呢?

浮点数转二进制的过程如下:
1.整数部分采用 /2 取余法

3 => 3/2 = 11  
1 => 1/2 = 01  
所以 3(十进制)= 11(二进制)
4 => 4/2 = 20  
2 => 2/2 = 10  
1 => 1/2 = 01  
所以 4(十进制)= 100(二进制) 

2.小数部分采用 *2 取整法

0.5 => 0.5*2 = 1 取整 1
0.5(十进制)= 0.1(二进制)
0.1 => 0.1*2 = 0.2 取整 0
0.2 => 0.2*2 = 0.4 取整 0
0.4 => 0.4*2 = 0.8 取整 0
0.8 => 0.8*2 = 1.6 取整 1
0.6 => 0.6*2 = 1.2 取整 1
0.2 => 0.2*2 = 0.4 取整 0
0.4 => 0.4*2 = 0.8 取整 0
0.8 => 0.8*2 = 1.6 取整 1
0.6 => 0.6*2 = 1.2 取整 1
...发生循环
得到结果 0.1(十进制)= 00011001100110011001100110011... (0011)循环(二进制)

同理,既有整数又有小数的数值进行二进制转换,就是分别对整数和小数部分进行二进制转换,再相加即可。

上面的例子中可以看到 0.1 转二进制会发生无限循环,而 IEEE 754 标准中的尾数位只能保存 52 位 有效数字(具体原因我们稍后讲解),所以 0.1 转二进制就会发生舍入,所以就产生了误差。

在讲解运算过程之前,我们需要 2 个前置知识:

  1. 十进制浮点数转换二进制后尾数的 52 位 有效数字是从第一个 1 开始向后保留 52 位 有效数字,所以接下来你会发现 0.10.2 保留 52 位 尾数后长度会不同。
  2. 在舍入的过程中,遵循 0 舍 1 入 的规则。
  3. 下面的过程中为了方便大家理解,我对所有的保留 52 位 尾数后后面没有 52 位 的情况进行了补零,对部分数字为了方便运算进行了超过 52 位 的补充和转换(比如 1)。

接下来我们一起看一下示例中的运算过程:

0.1
转二进制
0.0001100110011001100110011001100110011001100110011001100110011
保留52位尾数
0.00011001100110011001100110011001100110011001100110011010

0.2
转二进制
0.001100110011001100110011001100110011001100110011001100110011
保留52位尾数
0.0011001100110011001100110011001100110011001100110011010

进行相加
0.00011001100110011001100110011001100110011001100110011010
0.0011001100110011001100110011001100110011001100110011010
----------------------------------------------------------
0.01001100110011001100110011001100110011001100110011001110
相加后的结果保留52位尾数
0.010011001100110011001100110011001100110011001100110100
转十进制
0.30000000000000004

接下来是 0.1+1-1 的运算过程:

0.1
转二进制
0.0001100110011001100110011001100110011001100110011001100110011
保留52位尾数
0.00011001100110011001100110011001100110011001100110011010

1
转二进制并保留52位尾数
1.0000000000000000000000000000000000000000000000000000

进行相加
0.00011001100110011001100110011001100110011001100110011010
1.0000000000000000000000000000000000000000000000000000
----------------------------------------------------------
1.00011001100110011001100110011001100110011001100110011010
相加后的结果保留52位尾数
1.0001100110011001100110011001100110011001100110011010
再减1
0.00011001100110011001100110011001100110011001100110100000
转十进制
0.10000000000000009

接下来是 0.1-1+1 的运算过程:

0.1
转二进制
0.0001100110011001100110011001100110011001100110011001100110011
保留52位尾数
0.00011001100110011001100110011001100110011001100110011010

1
转二进制并保留52位尾数
1.0000000000000000000000000000000000000000000000000000

进行相减,这里其实等价于 1-0.1 转负数
为了方便相减,我们将 1 的二进制进行转换和补零
0.11111111111111111111111111111111111111111111111111111120
0.00011001100110011001100110011001100110011001100110011010
----------------------------------------------------------
0.11100110011001100110011001100110011001100110011001100110 这里是一个接近 0.9 的负数
相减后的结果保留52位尾数
0.11100110011001100110011001100110011001100110011001101

此时 -0.9+1 等价于 1-0.9
同样,为了方便相减,我们将 1 的二进制进行转换和补零
0.11111111111111111111111111111111111111111111111111112
0.11100110011001100110011001100110011001100110011001101
-------------------------------------------------------
0.00011001100110011001100110011001100110011001100110011
相减后的结果保留52位尾数
0.00011001100110011001100110011001100110011001100110011000
转十进制
0.09999999999999998

至此,我们就搞清楚了示例中的运算过程,从而知道了出现这些情况的原因,接下来,我们聊一下 IEEE 754,从而解开:

  1. 什么是尾数位
  2. 为什么是 52 位尾数位
  3. 为什么 0 舍 1 入 以及更多的浮点数神秘面纱~

IEEE 754

IEEE 754 中双精度浮点数使用 64 bit 来进行存储:

  • 第一位存储符号表示正负号 0 正 1 负
  • 2-12位存储指数表示次方数
  • 13-64位存储尾数表示精确度

符号位没有什么可说的,就是用来表示正负数的。
指数位表示次方数,这里的次方数是以当前的进制数为底,比如次方数为 5

  • 如果当前为十进制,就是 105 次方
  • 如果当前为二进制,就是 25 次方
    尾数位储存尾数表示精确度,用来表示一个大于等于 1 小于 2 的数值

综上所述,如果我们以 s 表示正负号,h 表示进制数,e 表示次方数,f 表示尾数,则浮点数 value 可以表示为:

value=sfhevalue = s*f*h^e

相信到了这一步,小伙伴们对指数位和尾数位的理解会更清楚一点,也解释了前两个问题。

  1. 尾数位就是 64 bit 浮点数存储尾数的部分,可以表示数值的精确度
  2. 52 位 是在 IEEE 754 标准制度的时候规定如此 而我们上面直接转二进制运算的情况下,实际上是糅合了指数位和尾数位的一个结果,所以我们保留 52 位,是在第一个 1 后面保留 52 位 有效数字。
    不知道你有没有发现一个问题: 尾数位只有 52 位,但是我们现在在第一个 1 后面保留 52 位 有效数字,那再加上前面的 1 不就是 53 位 位了吗?
    这是因为,尾数部分的整数部分一定是一个 1,那为了充分利用 52 位 空间表示更高的精确度,可以把一定等于 1 的整数部分省略,52 位 都用来表示小数。

最大安全整数

同理,因为只有 52 位 尾数,所以 JavaScript 中的最大安全整数是 2^53-1,其中 5352 位 尾数加上前面省略的 1,而 -1 是因为 2^53 已经是一个边界值了,大于它的值会和它相等,所以最大的安全整数是 2^53-1

最大安全整数.png

舍入规则

IEEE 754 标准列出4种不同的方法:

  • 舍入到最接近:舍入到最接近,在一样接近的情况下偶数优先(Ties To Even,这是默认的舍入方式):会将结果舍入为最接近且可以表示的值,但是当存在两个数一样接近的时候,则取其中的偶数(在二进制中是以0结尾的)。
  • 朝+∞方向舍入:会将结果朝正无限大的方向舍入。
  • 朝-∞方向舍入:会将结果朝负无限大的方向舍入。
  • 朝0方向舍入:会将结果朝0的方向舍入。

第一种规则(也就是默认的舍入方式)可以简单理解为我们常用的 四舍五入,而转化到我们这里的二进制浮点数运算,就是 0 舍 1 入

解决方案

  1. 使用 JavaScript 提供的最小精度值判断误差是否在该值范围内
    Math.abs(0.1 + 0.2 - 0.3) <= Number.EPSILON
  2. 转为整数计算,计算后再转回小数
  3. 保留几位小数 比如金额,只需要精确到分即可
  4. 使用别人的轮子,例如:math.js
  5. 转成字符串相加(效率较低)

参考文档及工具网站

百度百科
维基百科
十进制转 IEEE 754 浮点数二进制
进制转换工具

PS

关于指数计算,科学计数法等,因为和本题不是强相关的知识,为了减少小伙伴们的阅读成本,这里没有介绍。

如有任何问题或建议,欢迎留言讨论!👏🏻👏🏻👏🏻