在JavaScript中浮点数计算存在精度问题,所以在日常开发中我们很少会直接使用浮点数计算进行比较。但大多人知其然不知其所以然,不知道为什么会存在精度问题。这篇文章就以0.1+0.2!=0.3入手,分析Javascript浮点数计算精度丢失的问题。
0.1+0.2.0.jpg
计算机中的数字都是以二进制存储的,所以要计算0.1+0.2的结果,计算机首先需要把 0.1和0.2从十进制转成二进制,然后再相加,最后把相加得到的结果再转为十进制。在这个过程中会发生两次精度丢失:存储转换精度丢失、计算精度丢失。

存储转换精度丢失

十进制小数转二进制,我们使用“乘基取余,正序排列”法。
0.1转换成二进制的算法:
0.1*2=0.2======取出整数部分0
0.2*2=0.4======取出整数部分0
0.4*2=0.8======取出整数部分0
0.8*2=1.6======取出整数部分1
0.6*2=1.2======取出整数部分1
接下来会无限循环
0.2*2=0.4======取出整数部分0
0.4*2=0.8======取出整数部分0
0.8*2=1.6======取出整数部分1
0.6*2=1.2======取出整数部分1
所以0.1转化成二进制是:0.0001 1001 1001 1001......(无限循环)
0.2转换成二进制的算法:
0.2*2=0.4======取出整数部分0
0.4*2=0.8======取出整数部分0
0.8*2=1.6======取出整数部分1
0.6*2=1.2======取出整数部分1
接下来会无限循环
0.2*2=0.4======取出整数部分0
0.4*2=0.8======取出整数部分0
0.8*2=1.6======取出整数部分1
0.6*2=1.2======取出整数部分1
所以0.2转化成二进制是:0.0011 0011 0011 0011......(无限循环)
我们注意到,0.1和0.2转换完二进制后,是无穷的。但碍于内存的限制,内存中不会存储这样无限循环的数据结构,因此会对数据进行截断。从哪里截断呢?这里就需要了解浮点数的存储方式,JavaScript使用IEEE 754标准定义了浮点数的表示和计算规则。在这种表示方式中,浮点数由符号位、指数位和尾数位组成。浮点数用1位表示符号位,11位表示指数位,52位表示小数位(即尾数位)。小数位的长度是固定的,是53位,注意前边说的是52位,这是因为数据在存储时会进行规格化,将整数第一位变为有效数字1,这一位规定不用存储在内存中,也就是隐藏的一位,所以加上隐藏的这一位就是53位。这里的长度53位,就是数据会被截断的起始位置。
另外,我们知道十进制保留精度是进行四舍五入,但二进制只有0和1, 于是变为0舍1入 。 因此,我们就得到了0.1和0.2在计算机里的二进制表示形式:

0.1 => 0.0001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1010 

注意后边的1010,规格化小数要左移4位,相应的表现是小数点右移4位,原本末尾的1001后边是1要舍去需要向前进一位,所以是1010。

0.2 => 0.0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 010 

注意后边的010,规格化小数要左移3位,相应的表现是小数点右移3位,末尾001后边是1要舍去需要向前进一位,所以是010。
用标准计数法表示如下:

0.1 => (−1)0 × 2^-4 × (1.1001100110011001100110011001100110011001100110011010)2
0.2 => (−1)0 × 2^-3 × (1.1001100110011001100110011001100110011001100110011010)2 

在计算浮点数相加时,需要先进行 “对阶”,将较小的指数转为较大的指数,并将小数部分相应右移。
0.1的阶码-4转换为-3,指数位加一位,尾数位右移一位,末尾的0直接舍去:

0.1 => (−1)0 × 2^-3 × (0.1100110011001100110011001100110011001100110011001101)2 

这里需要注意的是,对阶的原则是小阶对大阶。如果是大阶对小阶,则尾数的数值部分的高位需要移出,而小阶对大阶移出的是尾数的数值部分的低位,这样损失的精度更小。

计算精度丢失

现在我们来计算0.1+0.2的值:
0.1+0.2.jpg
上面的计算过程中,发生了进位,并且尾数超过了52。尾数规格化需要右移一位,因为最后一位是1,0舍1入,向前进一位,后边的0111变成了100,相应的阶码由于尾数的右移需要加一位,-3+1变为-2。最终0.1+0.2得到的结果可以表示为:

(−1)^0 * 2^-2 * (1.0011001100110011001100110011001100110011001100110100)2=0.30000000000000004

通过 JS 将这个二进制结果转化为十进制表示:

(-1)**0 * 2**-2 * (0b10011001100110011001100110011001100110011001100110100 * 2**-52); 
//0.30000000000000004

0.3.jpg

结语

这是一个典型的精度丢失案例,从上面的计算过程我们可以看出,0.1和0.2在转换为二进制时发生了一次精度丢失,而对于计算后的二进制又有一次精度丢失 。因此,得到的结果是不准确的。小小的计算背后隐藏着这样深层的计算机设计原理,如果不深入腠理,我们很难窥探到它的真实所在,在遇到问题时就显得非常的被动。