查漏补缺数值精度丢失(0.1 + 0.2)

426 阅读7分钟

数值精度丢失

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^-1 2的-1次方就是1/2^1,2^-2 2的-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,转为十进制10261026减去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.2 转为64位二进制
    • 0-01111111100-1001100110011001100110011001100110011001100110011010
  • 将0.1的二进制相加
    • 0.1二进制0-01111111011-1001100110011001100110011001100110011001100110011010
    • 0.2二进制0-01111111100-1001100110011001100110011001100110011001100110011010

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