JS精度问题

499 阅读5分钟

我们大家都知道,JS有个很经典的浮点运算精度丢失问题,今天我们就来聊一聊这个问题产生的原因,以及该如何去解决它呢?

先来看下面的代码,0.1+0.2的结果不等于0.3,这是不是超出了我们之前的认知呢?毕竟0.1+0.2=0.3可是我们小学就已经学会了的东西,到这里怎么就不一样了呢?

0.1 + 0.2  //0.30000000000000004

下面让我们先来了解它是如何产生的,然后再去解决它.

为什么产生

首先,我们要知道数字在计算机中是如何存储和运行的?在计算机中,数字无论是定点数还是浮点数都是以多位二进制的方式进行存储.js采用IEEE 754的双精度标准进行存储,这是一种64位双精度浮点数储存方法.其中1位表示符号位,有正负,0为正,1为负;11位用来表示指数,剩下的52位表示尾数. 它的表示格式为: (s) * (m) * (2 ^ e) s为符号位,m为尾数,e为指数.

ES6在Number对象上新增了一个极小的常量Number.EPSILON.根据规格,它表示1与大于1的最小浮点数之间的差.我们在控制台打印出它的结果,可以看到

Number.EPSILON //2.220446049250313e-16

前面说到了64位浮点数中有52位是表示精度,那么比1大的最小浮点数应该就是1.000..001,这里小数点后面有51个0,然后1个1.这个数减去1的结果就是2的-52次方,也就是Math.pow(2,-52),所以下面的结果会输出true

Number.EPSILON === Math.pow(2,-52)  //true

所以,我们可以认为 Number.EPSILON是JS中能够表示的最小精度.当误差小于这个值的时候,就已经没有意义了,可以认为此时误差已经不存在了.即如果两个浮点数之间的差小于Number.EPSILON,则我们认为这两个浮点数是相等的. 回过头来,我们来计算一下0.1+0.2为啥不等于0.3? 将十进制的小数转换为二进制的小数,我们采用的是乘2取整法.即将小数部分乘以2,然后取整数部分,剩下的小数部分继续乘以2,然后取整数部分,剩下的小数部分又乘以2,一直取到小数部分为零为止.

我们先将0.1转换成二进制,其结果为0.000110011..001其中小数部分从第2位开始就是0011一直循环.我们看似有穷的一个小数0.1,其实在计算机中是无穷的.由于存储空间有限,计算机会舍弃掉后面的数值. 我们再将0.2转换成二进制,其结果为0.00110011..001其中小数部分从第1位开始就是0011一直循环. 在这里提供一个在线进制转换的网址戳我. 然后我们用js中的IEEE 754 双精度64位浮点数表示法来展示0.1和0.2,其结果为:

十进制小数 指数e 尾数m
0.1 -4 1.1001100110011001100110011001100110011001100110011010(52位)
0.2 -3 1.1001100110011001100110011001100110011001100110011010(52位)

然后,我们把他们相加,这里指数不一样的话,我们选择右移,因为损失的精度小.

十进制小数 指数e 尾数m
0.1 -3 0.1100110011001100110011001100110011001100110011001101(52位)
0.2 -3 1.1001100110011001100110011001100110011001100110011010(52位)
-3 10.0110011001100110011001100110011001100110011001100111 (52位)

e=-3;m=0.1100110011001100110011001100110011001100110011001101(52位)+ e=-3;m=1.1001100110011001100110011001100110011001100110011010(52位) 结果是: e=-3;m=10.0110011001100110011001100110011001100110011001100111 (52位) 即: e=-2;m=1.00110011001100110011001100110011001100110011001100111 (53位)

可以看到,这时尾数已经有53位了,我们采用一个叫round to nearest, tie to even 四舍五入的方式.它的意思就是接近哪个取哪个,一样的时候取偶数.举个例子:1.0101保留3位小数,那么它可以是1.010和1.011,此时取哪个,取偶数1.010.所以,这里e=-2;m=1.00110011001100110011001100110011001100110011001100111 (53位)转换为e=-2;m=1.0011001100110011001100110011001100110011001100110100 (52位),其二进制小数表示法就是0.010011001100110011001100110011001100110011001100110100,我们再将其展示为十进制的小数,结果为0.30000000000000004.此结果可以去上面提供的进制转换网站做验证.自此,我们就明白了为什么0.1+0.2 != 0.3.

怎么解决

既然已经知道了导致这个问题的原因,那么我们该如何解决呢?

使用函数库

常见的函数库,比如decimal.js等就可以解决这个问题

自己写函数

知道了问题出在哪儿,我们也就有了解决思路.通常我们的做法是将浮点数变成整数来计算,然后再确定小数点的位置,下面的加法函数就实现了我们想要的结果.

function add(num1, num2){
  let r1, r2, m;
  try{
    r1 = num1.toString().split('.')[1].length
  }catch(e){
    r1 = 0
  }
  try{
    r2 = num2.toString().split('.')[1].length
  }catch(e){
    r2 = 0
  }
  m = Math.pow(10, Math.max(r1, r2))
  return (num1 * m + num2 * m) / m
}

相类似的, 还有减乘除的函数,我们也一并展示在下面:

减法函数:

function sub(num1, num2){
  let r1, r2, m, n;
  try{
    r1 = num1.toString().split('.')[1].length
  }catch(e){
    r1 = 0
  }
  try{
    r2 = num2.toString().split('.')[1].length
  }catch(e){
    r2 = 0
  }
  n = Math.max(r1, r2)
  m = Math.pow(10, n)
  return Number(((num1 * m - num2 * m) / m).toFixed(n))
}

乘法函数:

function multiply(num1, num2){
  let m = 0,
  s1 = num1.toString(),
  s2 = num2.toString()
  try {
    m += s1.split('.')[1].length
  }catch(e){}
  try {
    m += s2.split('.')[1].length
  }catch(e){}
  return Number(s1.replace('.','')) * Number(s2.replace('.','')) / Math.pow(10,m)
}

除法函数:

function divide(num1, num2){
  let t1,t2,r1,r2;
  try {
    t1 = num1.toString().split('.')[1].length
  }catch(e){
    t1 = 0
  }
  try {
    t2 = num2.toString().split('.')[1].length
  }catch(e){
    t2 = 0
  }
  r1 = Number(num1.toString().replace('.',''))
  r2 = Number(num2.toString().replace('.',''))
  return (r1 / r2) * Math.pow(10, t2 - t1)
}

以上的这些函数分别可以解决加减乘除精度显示的问题.