数值精度丢失
JavaScript内部所有的数字都是以64位浮点数形式存储,即使整数也是如此。因为数字(Number)采用IEEE 754规范64位双精度浮点数编码。于是会看到下面的情况
0.1 + 0.2 != 0.3
// true
(0.3 - 0.2) === (0.2 - 0.1)
// false
0.3 / 0.1
// 2.9999999999999996
看到上面有些疑惑,于是搜罗一圈,总结一些理解过程
PS: 只要不是以5结尾的小数都会出现
前置知识点
十进制如何转换为二进制?
比如5.25转为二进制
整数部分除2取余,得到的商再除2,直到商为0,将余数倒序排列出来,得到就是二进制小数部分乘2,取结果的整数部分(不是1就是0),然后再用小数部分再乘2,直到小数部分为0,将取整的整数部分顺序排列出来,得到二进制。再合并整数部分和小数部分得到的二进制就是完整的二进制数
// 5.25 十进制转为二进制
// 整数部分 5
5 / 2 = 2 ... 1 // 取 1 |
2 / 2 = 1 ... 0 // 取 0 | 倒序读取
1 / 2 = 0 ... 1 // 取 1 |
// 二进制结果:101
// 小数部分 0.25
0.25 * 2 = 0.5 // 取 0 |
0.5 * 2 = 1.00 // 取 1 | 顺序读取
// 二进制结果:01
// 可以得出5.25的二进制表示:101.01
二进制如何转换为十进制?
二进制转为十进制不区分整数部分与小数部分。
需要说明,下面2的几次方那个次数是怎么确定的,比如从点往左数。
将二进制中的位数分别将下边对应的值相乘,然后相加得到的就是十进制
// 101.01 转为十进制
1 0 1 . 0 1
————————————————————————————
2^2 2^1 2^0 2^-1 2^-2
1 * 2^2 + 0 * 2^1 + 1 * 2^0 + 0 * 2^-1 + 1 * 2^-2 = 5.25
4 + 0 + 1 + 0 + 0.25 = 5.25
PS:
2^0任何除0以外的数的0次方都是1。如3的0次方是1,-1的0次方也是1,0的0次方没有意义
2^-12的-1次方就是1/2^1,2^-22的-2次方就是1/2^2
JavaScript是如何保存数字的?
JavaScript 数字(Number)采用IEEE 754规范64位双精度浮点数
- sign bit(符号位): 用来表示正负号,1位(0表示整数,1表示负数)
- exponent(指数): 决定数值的大小,11位(2~12位)
- mantissa(尾数):用来表示精度,52位(超出部分自动进1舍零)
下面来看看5.25十进制如何在JS中保存的
- 十进制
5.25转为二进制101.01 - 二进制
101.01可用二进制的科学计数法1.10101 * 2^3 1.10101 * 2^3的小数部分10101(二进制)就是mantissa(尾数)了,3(十进制)加上1023就是exponent(指数)了- 接下来指数
3要加上1023后转为二进制10000000010 5.25十进制数是一个正数,所以符号位二进制表示0- 最后把符号位、指数、尾数。三部分拼接到一起,二进制
0-10000000010-1010100000000000000000000000000000000000000000000000 - 为了方便查看符号位、指数、尾数以
-分割,其中尾数0101不足,用0补足52位
PS:
步骤2得出的科学计数中的整数部分1我们好像忘记,这里因为Javascript为了更最大限度的提高精确度,而省略了这个1,这样在我们我们本来只能保存(二进制)52位的尾数,实际是有(二进制)53位的
指数部分是11位,表示的范围是[0, 2047],由于科学计数中的指数可正可负,所以,
中间数为 1023,[0,1022] 表示为负,[1024,2047] 表示为正, 这也解释了为什么我们科学计数中的指数要加上1023进行存储了
JavaScript是如何读取数字的?
从5.25的二进制0-10000000010-1010100000000000000000000000000000000000000000000000读取
- 首先获取指数部分二进制
10000000010,转为十进制1026,1026减去1023就是实际的指数3 - 获取尾数部分
0-10000000010-1010100000000000000000000000000000000000000000000000实际就是0.10101(后面的0就不写了),然后加上我们忽略的1,得出1.10101 - 因为首位位
0,所以为正数,得出科学计数法1.10101 * 2^3,得出二进制101.01,再按照上面的二进制转十进制得出5.25
回到 0.1 + 0.2 != 0.3 看精度问题?
看懂前面的原理,这部分就变得好理解了
首先要计算0.1 + 0.2
- 0.1 转为64位二进制
- 先将0.1转化为二进制的整数部分为0,小数部分为
0001100110011001100110011001100110011...咦,这里居然进入了无限循环,那怎么办呢?暂时先不管 - 无限循环的二进制数用科学计数表示为
1.100110011001100110011001100110011... * 2^-4 - 指数位即是
-4 + 1023 = 1019,转化位11位二进制数01111111011 - 尾数位是无限循环的,但是双精度浮点数规定尾数位52位,于是超出52位的将被略去,保留
1001100110011001100110011001100110011001100110011010 - 最后得出0.1的64位二进制浮点数:
0-01111111011-1001100110011001100110011001100110011001100110011010
- 先将0.1转化为二进制的整数部分为0,小数部分为
- 0.2 转为64位二进制
0-01111111100-1001100110011001100110011001100110011001100110011010
- 将0.1的二进制相加
- 0.1二进制
0-01111111011-1001100110011001100110011001100110011001100110011010 - 0.2二进制
0-01111111100-1001100110011001100110011001100110011001100110011010
- 0.1二进制
PS: 二进制加法运算:1 + 0 = 0, 0 + 0 = 0, 1 + 1 = 10,相当于进一位
最后得出0.0100110011001100110011001100110011001100110011001100111转化为十进制数即为0.30000000000000004
精度缺失是在存储这一步就丢失了,后面的计算只是在不精准的值上进行的运算,所以就出现了误差
解决方案
对于整数,前端出现问题的几率可能比较低,毕竟很少有业务需要需要用到超大整数,只要运算结果不超过 Math.pow(2, 53) 就不会丢失精度
对于小数,前端出现问题的几率还是很多的,尤其在一些电商网站涉及到金额等数据。解决方式:把小数放到位整数(乘倍数),再缩小回原来倍数(除倍数)
// 0.1 + 0.2
(0.1*10 + 0.2*10) / 10 == 0.3 // true