深入理解JavaScript中的精度丢失

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 * M * 2^E

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

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

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

对于浮点数在计算机中的存储,IEEE754规范提供了单精度浮点数编码和双精度浮点数编码。

IEEE754规定,对于32位的单精度浮点数,最高的1位是符号位S,接着的8位是指数E,剩下的23位为有效数字M。

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

位数阶数有效数字/尾数
单精度浮点数32823
双精度浮点数641152

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

0.15625转换为二进制数是0.00101,用科学计数法表示就是 1.01 * 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===0.30000000000000004 的问题

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

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

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

 0.00011001100110011001100110011001100110011001100110011010
+0.00110011001100110011001100110011001100110011001100110100
------------------------------------------------------------
=0.01001100110011001100110011001100110011001100110011001110
复制代码

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

(0.1 + 0.2)的结果的十进制数为0.30000000000000004,至此推导完成。

我们可以在chrome上验证我们的推导过程是否和浏览器一致。

菜鸟工具也提供了丰富的进制转换功能可以让我们验证结果的准确性。

(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上有许多支持JavaScript和Node.js的数学库,比如math.js,decimal.js,D.js等等

5.2 原生方法

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

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之后,再除以倍数变为小数,这样就不用一位一位的进行判断。参考文章

5.3 ES6

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

Number.EPSILON
// 2.220446049250313e-16
Number.EPSILON.toFixed(20)
// "0.00000000000000022204"
复制代码

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

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

function withinErrorMargin (left, right) {
    return Math.abs(left - right) < Number.EPSILON
}
withinErrorMargin(0.1+0.2, 0.3)
复制代码

转载于:https://juejin.im/post/5b20cbb051882513ac20354f

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值