0.1 + 0.2为什么不等于0.3?

·  阅读 2067

问题背景


这是一个面试中大概率会被问到的问题,经过网上查阅各种资料、文章等,都有对这个问题的描述。但是其中的细节和一些关键点还需要自己细品和琢磨才能明白。在弄清楚这个问题的过程中经历了不少的思想斗争,因此还是有必要把自己的感悟记录并分享出来。

理论基础


首先,要明确程序中的所有计算,转到计算机底层都是二进制计算,而且,二进制计算是没有减法的概念的,减法会转成加一个负数进行计算。

还需要注意一点,0.1+0.2不等于0.3这个问题不仅仅在JavaScript中存在,在其他遵循IEEE 754规范的编程语言中也都存在这个问题。JavaScript 中所有数字包括整数和小数都只有一种类型 — Number。它的实现遵循 IEEE 754 标准,使用 64 位固定长度来表示,也就是标准的 double 双精度浮点数(相关的还有float 32位单精度)。

具体实现


整个计算过程要经历以下几个步骤:

  1. 十进制转二进制。
  2. 二进制转科学记数法。
  3. 内存对科学记数法表示的数据进行解析、存储。
  4. 对阶运算。
  5. 二进制加法运算。
  6. 舍入运算。
  7. 二进制转十进制。

十进制转二进制

回到问题本身,我们先来阐述如何把0.1转换成计算机底层存储的样子,0.2的转换的思路也就清楚了。关于如何把十进制小数转换成二进制的方法,参考菜鸟教程即可,此处不再赘述。0.1转换为二进制的过程是这样的:

image.png

到后面你会发现,这个处理过程是一个无限循环的状态。先不用管它,将其先记录下来:

0.00011(0011)…

二进制转科学记数法

接下来是应该处理无限循环了吗?并不是,我们知道,在日常的十进制运算中,我们为了表示尽可能大的数据,会采用科学记数法。计算机底层也是同样的道理,前面我们提到,JavaScript中以双精度存储Number类型,因此如果将数据实打实地转换成二进制后直接存储,那么它能存储的最大数是263(第一位是符号位),在数据量级发达的今天,这个可计算的数值范围是相当有限的。所以,二进制的数据也要转换成科学记数法,然后对科学记数法中进行分部存储:

1.1(0011)… * 2-4(小数点向右移4位,二进制中底数为2)

对科学记数法数据的二进制表示

OK,接下来的问题是,计算机的64位是怎样来存储科学记数法的呢?

image.png

这张图标示了64位二进制不同部分存储的数据标示。首先,最高一位为符号位,因此它无法参与存储数据的重任,其后的11位(指数部分)用于存储科学记数法中指数的二进制数,剩余的52位(尾数部分)用于存储科学记数法中尾数小数点后52位。这三部分中的符号位和尾数部分都很好明确,符号位0表示正数,1表示负数;尾数部分就是刚刚科学记数法中尾数的小数部分前52位;指数部分的确定又要复杂一点。

内存中给出了11位二进制给指数,因此,11位二进制转换成十进制的话,能存储的数据范围就是:[0, 211],即[0, 2048]。但是还有个问题,指数还有可能是负数!负数怎么表示?难道11位又要让出一位来表示符号位?并不是,在这部分的处理中,IEEE754标准将指数为0时的基数定为1023(以1023作为正负数的分界线),相当于能存储[-1023, 1024]这个范围的数。因此,以0.1转换为科学记数法后的指数为例,指数-4会被转换成1023 - 4 = 1019,然后再转换成二进制: image.png

1019转换成二进制后为:1111111011。因此,0.1最终二进制的前12位为:

001111111011

接下来,尾数部分只保留52位,无限循环怎么处理?此时要进行舍入运算,遵循0舍1入的原则(类似于十进制中的四舍五入的意思),这也是本问题的关键点所在,舍入运算使数据丢失了些许精度。

你也许会疑惑,一个简单的计算都能导致计算结果的偏差,那么稍微复杂一点的运算结果还可靠么?当然可靠。其一,像这种转换时出现无限循环的场景并不多见,即使有,计算机提供的双精度存储也能够保证数据运算在允许的误差范围内;其二,计算机底层有对精度误差运算的处理机制,不会允许计算在偏差的路上越走越远。

0.1最终的二进制形式是这样的:

0 01111111011 1001100110011001100110011001100110011001100110011010

0.2的二进制存储为:

0 01111111100 1001100110011001100110011001100110011001100110011010

对阶运算

接下来就是做加法运算,二进制加法并不难,和十进制类似。然而事情并不简单,在我们将0.1和0.2转为科学记数法时,我们发现0.1的指数是-4,0.2的指数是-3。要想将他们运算的结果也采用科学记数法的方法表示,就得将指数统一然后提取公因数进行计算。这里就涉及到一个对阶运算,为了尽可能减小精度损失,需要遵守小阶对大阶(即将较小的指数转换为较大的指数)的原则。在这个问题中,我们要将指数统一成-3。因此,0.1在经过对阶操作后的二进制,是这样的:

0 01111111100 (0.)1100110011001100110011001100110011001100110011001101。

尾数需要向右移一位,右移超出的部分进行舍入运算。默认省略的整数部分的1被移到小数部分了,因此整数部分变成了0。

二进制加法运算

接下来做加法的时候,要将尾数前面省略的整数部分补全,因为存在进位的时候,整数部分是有可能变化的,这也是为了方便后续调整运算结果。

尾数相加后的结果为:

10.0110011001100110011001100110011001100110011001100111

image.png

舍入运算

这个结果有两个问题:

  1. 不符合科学记数法的规则。
  2. 尾数部分存在超出位数的情况。

因此要对结果做出调整,首先将结果变为“1.”开头的,即小数点向左移一位,变成:

1.00110011001100110011001100110011001100110011001100111

同时,要将指数加1:变成:

01111111101

最后,依然根据0舍1入的原则,将尾数部分超出52位以外的部分做舍入运算,结果为:

1.0011001100110011001100110011001100110011001100110100

因此,最终的完整结果为:

0 11111111101 (1.)0011001100110011001100110011001100110011001100110100

二进制转十进制

转换为十进制即为:

0.30000000000000004

至此,这个问题的所有步骤和详细分析就告一段落了。

总结


  1. JavaScript小数在计算机底层以双精度(即64位)的方式存储。
  2. 整个过程存在多处精度损失:对阶运算、加法运算。
  3. 精度损失实质上舍入运算的结果,涉及到舍入运算都要遵循0舍1入的原则。
分类:
前端
标签:
分类:
前端
标签:
收藏成功!
已添加到「」, 点击更改