深入JavaScript中的精度丢失

发布 : 2018-06-13 分类 : 前端,每天学一点 浏览 :

1. 引子

众所周知JavaScript仅有Number这个数值类型,而Number采用的时IEEE754规范中64位双精度浮点数编码。于是出现了经典的 0.1 + 0.2 === 0.30000000000000004 问题。

我们抱着知其然还要知其所以然的态度来推导一下 0.1 + 0.2 的计算过程。

2. 进制转换

首先我们需要了解如何将十进制小数转为二进制,方法如下:

对小数点以后的数乘以2,取结果的整数部分(不是1就是0),然后再用小数部分再乘以2,再取结果的整数部分……以此类推,直到小数部分为0或者位数已经够了就OK了。然后把取的整数部分按先后次序排列。

按照上面的方法,我们求取0.1的二进制数,结果发现0.1转换后的二进制数为:

0.000110011001100110011(0011无限循环)……

所以说,精度丢失并不是语言的问题,而是浮点数存储本身固有的缺陷。浮点数无法精确表示其数值范围内的所有数值,只能精确表示可用科学计数法 m*2^e表示的数值而已,比如0.5的科学计数法是2^(-1),则可被精确存储;而0.1、0.2则无法被精确存储。

那么对这种无限循环的二进制数应该怎样存储呢,总不能随便取一个截断长度吧。这个时候IEEE754规范的作用就体现出来了。

3. IEEE754规范

IEEE754对于浮点数表示方式给出了一种定义。格式如下:

(-1)^S x M x 2^E

各符号的意思如下:S,是符号位,决定正负,0时为正数,1时为负数。M,是指有效位数,大于1小于2。E,是指数位。

则0.1使用IEEE754规范表示就是:

(-1)^0 x 1.100110011(0011)…… x 2^-4

对于浮点数在计算机中的存储,IEEE754规范提供了单精度浮点数编码和双精度浮点数编码。IEEE754规定,对于32位的单精度浮点数,最高的1位是符号位S,接着的8位是指数E,剩下的23位为有效数字M。对于64位的双精度浮点数,最高的1位是符号位S,接着的11位是指数E,剩下的52位为有效数字M。

位数 阶数 有效数字/尾数
单精度浮点数 32 8 23
双精度浮点数 64 11 52

我们以单精度浮点数为例,分析0.15625实际的存储方式。

0.15625转换为二进制数是0.00101,用科学计数法表示就是 1.01 x 2^(-3),所以符号位为0,表示该数为正。注意,接下来的8位并不直接存储指数-3,而是存储阶数,阶数定义如下:

阶数 = 指数+偏置量

对于单精度型数据其规定偏置量为127,而对于双精度来说,其规定的偏置量为1023。所以0.15625的阶数为124,用8位二进制数表示为01111100。再注意,存储有效数字时,将不会存储小数点前面的1(因为二进制有效数字的第一位肯定是1,省略),所以这里存储的是01,不足23位,余下的用0补齐。

当然,这里还有一个问题需要说明,对于0.1这种有效数字无限循环的数该如何截断,IEEE754默认的舍入模式是:

Round to nearest, ties to even

也就是说舍入到最接近且可以表示的值,当存在两个数一样接近时,取偶数值。

4. 回到 0.1 + 0.2 的问题

JavaScript是以64位双精度浮点数存储所有Number类型值,按照IEEE754规范,0.1的二进制数只保留52位有效数字,即 1.100110011001100110011001100110011001100110011001101 x 2^(-4)
我们以 - 来分割符号位、阶数位和有效数字位,则0.1实际存储时的位模式是0 - 01111111011 - 1001100110011001100110011001100110011001100110011010

同理,0.2的二进制数为1.100110011001100110011001100110011001100110011001101 x 2^(-3),因此0.2实际存储时的位模式是0 - 01111111100 - 1001100110011001100110011001100110011001100110011010

将0.1和0.2按实际展开,末尾补零相加,结果如下:

1
2
3
4
 0.00011001100110011001100110011001100110011001100110011010
+0.00110011001100110011001100110011001100110011001100110100
------------------------------------------------------------
=0.01001100110011001100110011001100110011001100110011001110

只保留52位有效数字,则(0.1 + 0.2)的结果的二进制数为 1.001100110011001100110011001100110011001100110011010 x 2^(-2),省略尾数最后的0,即 1.00110011001100110011001100110011001100110011001101 x 2^(-2),因此(0.1+0.2)实际存储时的位模式是 0 - 01111111101 - 0011001100110011001100110011001100110011001100110100

(0.1 + 0.2)的结果的十进制数为0.30000000000000004,至此推导完成。我们可以在chrome上验证我们的推导过程是否和浏览器一致。菜鸟工具也提供了丰富的进制转换功能可以让我们验证结果的准确性。

1
2
3
4
5
6
7
8
9
10
11
(0.1).toString('2')
// "0.0001100110011001100110011001100110011001100110011001101"

(0.2).toString('2')
// "0.001100110011001100110011001100110011001100110011001101"

(0.1+0.2).toString('2')
// "0.0100110011001100110011001100110011001100110011001101"

(0.3).toString('2')
// "0.010011001100110011001100110011001100110011001100110011"

5. 解决精度丢失的问题

5.1 类库

NPM上有许多支持JavaScriptNode.js的数学库,比如math.js, decimal.js, D.js等等。

5.2 原生方法

toFixed()方法可把Number四舍五入为指定小数位数的数字。但并代表该方法是可靠的。chrome上测试如下:

1
2
3
4
5
6
1.35.toFixed(1) // 1.4 正确
1.335.toFixed(2) // 1.33 错误
1.3335.toFixed(3) // 1.333 错误
1.33335.toFixed(4) // 1.3334 正确
1.333335.toFixed(5) // 1.33333 错误
1.3333335.toFixed(6) // 1.333333 错误

我们可以把toFix重写一下来解决。通过判断最后一位是否大于等于5来决定需不需要进位,如果需要进位先把小数乘以倍数变为整数,加1之后,再除以倍数变为小数,这样就不用一位一位的进行判断。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
Number.prototype.toFixed = function(len){
if(len>20 || len<0){
throw new RangeError('toFixed() digits argument must be between 0 and 20');
}
// .123转为0.123
var number = Number(this);
if (isNaN(number) || number >= Math.pow(10, 21)) {
return number.toString();
}
if (typeof (len) == 'undefined' || len == 0) {
return (Math.round(number)).toString();
}
var result = number.toString(),
numberArr = result.split('.');

if(numberArr.length<2){
//整数的情况
return padNum(result);
}
var intNum = numberArr[0], //整数部分
deciNum = numberArr[1],//小数部分
lastNum = deciNum.substr(len, 1);//最后一个数字

if(deciNum.length == len){
//需要截取的长度等于当前长度
return result;
}
if(deciNum.length < len){
//需要截取的长度大于当前长度 1.3.toFixed(2)
return padNum(result)
}
//需要截取的长度小于当前长度,需要判断最后一位数字
result = intNum + '.' + deciNum.substr(0, len);
if(parseInt(lastNum, 10)>=5){
//最后一位数字大于5,要进位
var times = Math.pow(10, len); //需要放大的倍数
var changedInt = Number(result.replace('.',''));//截取后转为整数
changedInt++;//整数进位
changedInt /= times;//整数转为小数,注:有可能还是整数
result = padNum(changedInt+'');
}
return result;
//对数字末尾加0
function padNum(num){
var dotPos = num.indexOf('.');
if(dotPos === -1){
//整数的情况
num += '.';
for(var i = 0;i<len;i++){
num += '0';
}
return num;
} else {
//小数的情况
var need = len - (num.length - dotPos - 1);
for(var j = 0;j<need;j++){
num += '0';
}
return num;
}
}
}

5.3 ES6

ES6Number对象上新增了一个极小的常量——Number.EPSILON

1
2
3
4
Number.EPSILON
// 2.220446049250313e-16
Number.EPSILON.toFixed(20)
// "0.00000000000000022204"

引入一个这么小的量,目的在于为浮点数计算设置一个误差范围,如果误差能够小于Number.EPSILON,我们就可以认为结果是可靠的。

误差检查函数(出自《ES6标准入门》-阮一峰 p.52)

1
2
3
4
function withinErrorMargin (left, right) {
return Math.abs(left - right) < Number.EPSILON
}
withinErrorMargin(0.1+0.2, 0.3)
本文作者 : 冰比冰水冰
原文链接 : http://iceiceice.top/2018/06/13/loss-of-precision/
版权声明 : 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明出处!
留下足迹