JS精度和大数处理
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。
位数 | 阶数 | 有效数字 / 尾数 | |
---|---|---|---|
单精度浮点数 | 32 | 8 | 23 |
双精度浮点数 | 64 | 11 | 52 |
我们以单精度浮点数为例,分析 0.15625 实际的存储方式。
[图片上传失败...(image-b1ea1d-1556249388187)]
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)
复制代码
原文地址:深入理解JavaScript中的精度丢失
网友评论