为什么0.1+0.2+0.3 != 0.6 ?

2,292 阅读8分钟

1. 业务遇到的问题

最近在解决业务需求时,遇到如下的场景:产品希望对一系列奖品进行奖励,希望系列奖品之和为100%,并希望精确度保持6位(百分比4位),例如如下的例子:

image.png

这业务场景太简单,我按照如下的代码实现,通过forEach计算得到所有商品的概率之和。

 let sp = 0;
 items.forEach(r => {
     sp += r.p || 0;
 });
 return sp;

使用整数概率(10/20/30/40、50/50)是没问题的,我们切换到小数时(10.2345、20.3456)时,发现计算出的sp存在小数,产生类似于99.9999、100.0001的场景。这考虑到时精度丢失的问题,趋势改成取整法去处理这样的求和运算。

 let sp = 0;
 items.forEach(r => {
     sp += Math.floo(r.p * 10000) | 0;
 });
 return sp / 10000;

经过多个小数值的尝试,其表现基本符合业务需求,就按照这样的实现提测,继续去干下一个需求了。QA测试突然提了一个BUG,发现(44.2726 + 24.2000 + 28.8889 + 2.1053 + 0.4040 + .0769 + 0.0333 + 0.0095 + 0.0095 = 99.9999)。这是为什么呢,经过一段时间的定位,发现出现了类似的场景, 为了快速解决问题,将Math.floor => Math.round,计算就变得正常了。 44.2726 * 10000 => 442725.99999999994 => Math.floor => 442725

为什么会如此呢,带着这样的疑问,查看大学时的计算机教材,重新学习下我们的浮点数运算。

2. JS之Number

JS的Number类型是什么样的,我们可以看ECMA Number value, Number类型采用的是64位的IEEE 754-2019标准,理解了IEEE 754的浮点数表示、进位和运算法则,我们就能更好的编写业务代码了。

image.png

2.1 编码方式

IEES 754标准虽然一直在变化,但其核心的规则是没有变化的,我们先看下浮点数的基本编码规(下文关于浮点数的介绍来源于深入理解计算机系统(原书第3版) (豆瓣))。 IEEE浮点数的的数的表示形式为V=(1)sM2EV = (-1) ^s \ast M \ast 2^E, 其参数定义为:

  • 符号(sign)。ss决定数是正数还是负数。
  • 尾数(mantissa)。M是二进制小数,它的范围12ε1\sim2-\varepsilon,或者是01ε0\sim1-\varepsilon
  • 阶码(exponent)。E的作用是对浮点数加权,其值为2的E次幂。 对于IEEE754 64位浮点数,其符号、尾数、阶码的结构如下: image.png 根据阶码的值,我们可以将浮点数分为三类: 规格化、非规格化和特殊化。

image.png

2.1.1 规格化

规格化值的阶码,exp为e10e9e8e7e6e5e4e3e2e1e0e_{10}e_9e_8e_7e_6e_5e_4e_3e_2e_1e_0的无符号数,其表示的范围1-2046。但在这种场景下,阶码的实际值需要减去偏置的值E=ebiasE = e - bias。偏置值的计算公示为bias=2k11=21111=1023bias = 2^{k-1} - 1 = 2^{11 - 1} - 1 = 1023,k为阶码的位数,IEEE 754 64位的偏置值为1023. 按照规格化的阶码计算方式,我们可以得到如下的表格,真实EE的表示范围为-1022 - +1023。

image.png 小数字段被表述成的0.f51...f00.f_{51}...f_{0}小数值ff, 其中0<=f<10 <= f < 1。浮点数的尾数表示成 M=1+fM = 1 + f的形式,隐含以1开头,因此可以把M看成1.f51...f01.f_{51}...f_{0}的形式。因此,浮点数会调整阶码,让尾数M在范围1<=M<21 <= M < 2之中。

我们以10.2345的表示来给大家展现浮点数的表示方式,如下图所示,为数据的真实表示,我们得到10.2345的表示位10100011....的形式。

image.png 按照M的表示方式方法,我们得到1.0100011...的形式,其向右移动了三位,E = 3,exp = 8 + bias = 3 + 1023 = 1026, 我们得到最终的浮点数表示为如下

image.png

由于10.2345是个无法表示的精确值,这里还发生了进位的取舍,这个在后面会分析到,此时这个浮点数的真实值是:10.2345000000000005968558980385。

其实涉及到一个思想的转变,现实是连续的、计算机是离散的。我们活在连续的世界,但机器活在离散的世界里。对于我们,距离、重量、大小都是连续的,但对于计算机,无论它的内存、表示有多严谨,它始终是离散的,它能表示的数量是固定的。IEEE 754就是将连续的实数转变为离散的浮点数。如下图的例子,IEEE 754浮点数表示法,实际上就是将现实的连续实数映射到离散的浮点数中。

image.png

2.1.2 非规格化

阶码为全0时,表示的数就是非规划化的数。在这种场景下,阶码为E=1bias=11023=1022E = 1 - bias = 1 - 1023 = -1022, 尾数为M=fM = f。非规格化可以表示0的形式,标志位的不同,可以表示+0/-0。

2.1.3 特殊值

阶码为全1时,表示的数就是特殊的值。当小小数域为0,得到的值是无穷大,当s=0时是正无穷,当s=1是负无穷。大数相乘或者除以0时,无穷就是表示溢出的结果。 阶码为全1时,小数域不为0时,结果值称为"NaN"。一些运算的结果不是实数、无穷等异常时,就会返回NaN的值。 以下为IEEE754 64位浮点数的一些表示,我们可以看看它的表示

image.png

2.2 舍入

IEEE 浮点数定义了四种舍入方式,向偶数舍入、向零舍入、向上舍入、向下舍入,其基本的舍入方式,如下例所示,展示了几种舍入方式的运算形式:

image.png

IEEE浮点数选择的计算方式是什么呢?向偶数舍入,即对计算出的值舍入时,向离它最近的偶数进行舍入。 对于浮点数(此处的内容来源于www.pianshen.com/article/697…, 定义最低位0为偶数位、1为奇数位。除此之外,浮点数还定义了三个概念, 保留位(Guard bit)、近似位(Round bit)和粘滞位(Sticky bit)。例如我们要保留2位小数,小数点右边第二位就是保留位(Guard bit),小数点右边第三位就是近似位(Round bit),小数点右边第四位开始一直向右的所有小数位或起来构成粘滞位(Sticky bit)。下图为CMU的课件,更能说明他们的概念。

image.png 中间值的概念,对于舍入运算,我们定义XXX.YYYY100...0为舍入的中间值,其中最右侧的Y为舍入的位置。例如对于11.11011, 舍入小数位置为2时,它的中间值是11.11100....。 向偶数舍入有两条计算规则:

  • 如果最接近的值唯一,则直接向最接近的值舍入;
  • 如果是处在“中间值”,那么要看保留位(Guard bit)是否是偶数,如果是偶数则直接舍去后面的数不进位,如果是奇数则进位后再舍去后面的数。 下面考虑舍入小数二位时,下面数据的舍入值,可以帮助更好的理解向偶数舍入的运算方式,

image.png

对于2.1中的10.2345,来看看它的舍入计算是什么样的,它的实际值如下:

1010. 0011 1100 0000 1000 0011 0001 0010 0110 1110 1001 0111 1000 1100 1...

按照尾数的计算规则,我们需要将其转换为如下的形式:

image.png IEEE 754 64位的尾数有51位,保留位0,近似位1,粘滞位1001...。其向010 0011 1100 0000 1000 0011 0001 0010 0110 1110 1001 0111 1001 的值更接近,且不符合“中间值”的规则,因此舍入值为010 0011 1100 0000 1000 0011 0001 0010 0110 1110 1001 0111 1001。因此10.2345的实际浮点值为10.2345000000000005968558980385。

2.3 运算规则

浮点数的加减法、乘除法运算规则,可参考如下的文档浮点数处理。我们这里探讨下,浮点数的加法和乘法运算规则。 加减法的交换律还适用浮点数吗,a+b+c?=a+(b+c)a + b + c ?= a + (b + c),从浮点数的编码和舍入来看,是不适用的。比如3.14 + 1e10 - 1e10的值为0,而3.14 + (1e10 - 1e10)=3.14. 乘法的分配律也是类似的问题,由于舍入的问题,乘法分配律也会发生丢失精度的问题,从而导致异常的效果。 因此,我们在编码时,要考虑浮点数的运算规则,一些加减法、乘除法的运算规则是失效的。

3. 如何解决业务问题

回到我们的业务场景,6位精度规则下的运算应该如何处理,浮点数的向偶数舍入计算规则,一定会导致精度的丢失,从而导致我们使用Math.floor得到错误的值。对于我们取整的运算规则,我们考虑采用类似的形式进行处理:

let sp = 0;
// 方案1: 添加+0.1偏置值,Math.floor取得正确值
items.forEach(r => {
    sp += Math.floo(r.p * 10000 + 0.1) | 0;
});
// 方案2: Math.round 也可以取得正确值
items.forEach(r => {
    sp += Math.round(r.p * 10000) | 0;
});
return sp / 10000;

因此,再遇到浮点数的运算场景时,尤其涉及到精确计算时,我们要考虑到浮点数的编码、舍入规则,从而写出争取的代码。回到我们的标题里,我想大家应该已经知道0.1+0.2+0.3为什么不等于0.6了。

4. 参考文档