浮点数的疑惑
- Time: 16 March 2014
- Categories:
- Frontend 27
- Tags:
- Javascript 21
前端开发中经常会涉及浮点数运算。有个经典的”Bug”就是0.1+0.2
不等于0.3
。为什么出现这种情况?脑中一直有个朦胧的答案,“
浮点数精度。。。”,对于一个有节操的前端,更应该是寻根问底,搞清楚本质问题。于是查询了一些资料来理清头绪,并记录之。
###浮点数的二进制存储
如何在计算中以二进制的存储浮点数呢?在科学记数法中,一个数被写成一个1与10之间的实数与一个10的幂的积, 二进制的浮点数也可以写成类似十进制的科学计数的形式,只不过底数不是10,而是2。IEEE 754标准做出了详细解释。
####存储格式 根据IEEE754,浮点数V可以表示为:
V = (-1)^s * M * 2^E
- s表示符号位,s为0时,V是正数;s为1时,V是负数。
- M表示尾数,其值大于1,小于2
- E表示指数
对于32位的浮点数,最高的1位是符号位s,接着的8位是指数E,剩下的23位为有效数字M。
对于64位的浮点数,最高的1位是符号位S,接着的11位是指数E,剩下的52位为有效数字M。
####尾数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还可以再分成三种情况:
- E不全为0或不全为1。这时,浮点数就采用上面的规则表示,即指数E的计算值减去127(或1023),得到真实值,再将有效数字M前加上第一位的1。
- E全为0。这时,浮点数的指数E等于1-127(或者1-1023),有效数字M不再加上第一位的1,而是还原为0.xxxxxx的小数。这样做是为了表示±0,以及接近于0的很小的数字。
- 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