Math.pow(1.1, Day)

浮点数的疑惑

前端开发中经常会涉及浮点数运算。有个经典的”Bug”就是0.1+0.2不等于0.3。为什么出现这种情况?脑中一直有个朦胧的答案,“ 浮点数精度。。。”,对于一个有节操的前端,更应该是寻根问底,搞清楚本质问题。于是查询了一些资料来理清头绪,并记录之。

###浮点数的二进制存储

如何在计算中以二进制的存储浮点数呢?在科学记数法中,一个数被写成一个1与10之间的实数与一个10的幂的积, 二进制的浮点数也可以写成类似十进制的科学计数的形式,只不过底数不是10,而是2。IEEE 754标准做出了详细解释。

####存储格式 根据IEEE754,浮点数V可以表示为:

V = (-1)^s * M * 2^E
  1. s表示符号位,s为0时,V是正数;s为1时,V是负数。
  2. M表示尾数,其值大于1,小于2
  3. E表示指数

对于32位的浮点数,最高的1位是符号位s,接着的8位是指数E,剩下的23位为有效数字M。

floating-point-32bit

对于64位的浮点数,最高的1位是符号位S,接着的11位是指数E,剩下的52位为有效数字M。

floating-point-64bit

####尾数M

M的取值范围:1 ≤M < 2,也就是说,M可以写成1.xxxxxx的形式,其中xxxxxx表示小数部分。IEEE 754规定,在计算机内部保存M时,默认这个数的第一位总是1,因此可以被舍去,只保存后面的xxxxxx部分。比如保存1.01的时候,只保存01,等到读取的时候,再把第一位的1加上去。这样做的目的,是节省1位有效数字。以32位浮点数为例,留给M只有23位,将第一位的1舍去以后,等于可以保存24位有效数字。

####指数E

首先,E为一个无符号整数(unsigned int)。这意味着,如果E为8位,它的取值范围为0~255;如果E为11位,它的取值范围为0~2047。但是,我们知道,科学计数法中的E是可以出现负数的,所以IEEE 754规定,在存储E值时,加上一个偏移值以保证E总位z正数。偏移值的取值是2^(E的位数)-1,所以对于8位的E,这个偏移值是2^8-1=127;对于11位的E,这个偏移值是2^12-1=1023。

然后,指数E还可以再分成三种情况:

  1. E不全为0或不全为1。这时,浮点数就采用上面的规则表示,即指数E的计算值减去127(或1023),得到真实值,再将有效数字M前加上第一位的1。
  2. E全为0。这时,浮点数的指数E等于1-127(或者1-1023),有效数字M不再加上第一位的1,而是还原为0.xxxxxx的小数。这样做是为了表示±0,以及接近于0的很小的数字。
  3. E全为1。这时,如果有效数字M全为0,表示±无穷大(正负取决于符号位s);如果有效数字M不全为0,表示这个数不是一个数(NaN)。

####舍入规则

浮点数转换成二进制的时候会出现无限循环的情况,而在计算所中用来存储的位总是有限的。数学中有“四舍五入”的法则,IEEE 754中也规定了四种舍入规则:

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

对于第一种舍入规则,有必要详细的说明下。 在十进制中比如0.45 按照第一个规则保留小数点后面一位小数进行舍入的话,就应该为0.4 因为0.45和0.4和0.5的距离一样,但是4为偶数,所以取0.4。

对于二进制数来说也一样,比如有这样的一个二进制表示的数 0.1011 , 如果想保留小数点后面一位,发现后一位之后为011,很显然0.1011距离0.1近,距离1.0远,所以后面的011果断舍去得到0.1 。但是如果需要保留小数点后面两位,发现两位之后是11,很显然0.1011距离0.11更近,所以果断进位,得到0.11 , 如果保留小数点后3位,这个时候问题来了,因为0.1011和0.101,0.110的距离相等,但是由于0.110末位为偶,所以得到0.110

###实例分析

再看看0.1+0.2!==0.3的问题 (这里按64位双进度来演示)

####0.1的存储

小数部分转化为二进制:乘2取整

0.1X2=0.2 0
0.2X2=0.4 0
0.4X2=0.8 0
0.8X2=1.6 1
0.6X2=1.2 1
0.2X2=0.4 0 //0011循环

所以0.1的二进制为:

0.0001100110011...(0011循环)

现在将其按IEEE 754 (套用公式V=(-1)^s * M * 2^E)来描述:

  • 0.1是正数,则 s=0
  • 0.00011001100110011…=1.100110011001…X2-4 , 则 E = -4 , 加上偏移值1023后值为1019,转换成二进制是01111111011
  • 剩下的就是尾数,去除首尾的整数部分是100110011…,末尾待舍去的是1001…,按照舍入规则,需要向前进一位

    //0.1以二进制存储 0 01111111011 1001100110011001100110011001100110011001100110011010

按上面的方式得到0.2的存储格式为

0 01111111100 1001100110011001100110011001100110011001100110011010

两个数相加(浮点数运算规则后面的链接有详细描述),这个时候发现指数不一致,所以需要对阶,一般情况下是向右移,因为最右边的即使溢出了,损失的精度远远小于左边溢出。所以这个时候指数的真正的值都为-3,然后0.1右移后的尾数与0.2的尾数进行相加:

 0.1100110011001100110011001100110011001100110011001101   // 对阶时使得尾数右移了一位
+1.1001100110011001100110011001100110011001100110011010
--------------------------------------------------------
10.0110011001100110011001100110011001100110011001100111

规格化之后,得:
1.0011001100110011001100110011001100110011001100110100X2^(-2)
// 之前的指数是-3,经过规格化后,小数点左移一位,指数加1,变为-2

计算结果的存储格式:

0011111111010011001100110011001100110011001100110011001100110100

通过工具(后面的链接有提到)转换成十六进制的表示为: 3FD3333333333334 . 转换成十进制是0.30000000000000004

而0.3在计算机中的格式为

0011111111010011001100110011001100110011001100110011001100110011

化成十进制为: 0.29999999999999999

So: 0.1+0.2!==0.3

###解决

简而言之,由于浮点数在计算机中以二进制形式存储运算、舍入差异导致精度丢失,从而导致了这种情况。前端侧的解决方案大多是: 先放大适当倍数计算,再缩小同样的倍数。稍微封装了下:

/**
 * FPOperation: 浮点数操作
 *
 * @param {Number} x 
 * @param {Number} y 
 * @param {String} operator  
 **/
function FPOperation(x, y , operator) {
    if (!operator) {
        return  ;
    }

    var _m = 0 ,
        xDigit = 0,
        yDigit = 0;

    try { xDigit = (x+'').split('.')[1].length; }catch (e){}
    try { yDigit = (y+'').split('.')[1].length; }catch (e){}
    _m = Math.pow(10, Math.max(xDigit, yDigit));

    var _x = x * _m,
        _y = y * _m ;

    var ret ;
    if (operator === '+') {
        ret = _x + _y ;
    }else if (operator === '-'){
        ret = _x - _y ;
    }else if (operator === '*'){
        ret = _x * _y ;
    }else if (operator ==='/'){
        ret = _x / _y ;
    }

    return ret/_m;
}

###References