不老实的Number类型——精度丢失

2,064 阅读5分钟

前言

在我们写购物车组件结算金额的代码时可能会遇到带有小数金额计算不精确的问题,如0.1 + 0.2 = 0.30000000000000004,这时候我们查阅资料可以了解到这是精度丢失问题,之后可能会使用toFixed()方法或Math.js等外部库来解决这个问题,但是我们有没有想过为什么JavaScript会有精度丢失问题?精度丢失问题是不是JavaScript这门语言独有的呢?下面我们带着这些问题来深挖一下精度丢失背后的原理

计算机计算数据的原理

计算机中的数据是以二进制的形式存储的,当我们输入若干个十进制的数据,计算机会将其先转换为二进制,随后在进行二进制的计算,最后将计算结果又转换为十进制

精度丢失

上文说到计算机在处理十进制数据时会先将其转换为二进制所以我们先将0.1和0.2转换为二进制数

  • 将0.1和0.2转换为二进制

0.1: 0.000110011001100(1100循环)

0.2: 0.00110011001100(1100循环)

  • 浏览器将0.1和0.2转换为二进制

pic.png 我们发现两个数转换为二进制后小数点右边长度不一样,一个保留55位一个保留54位,为什么会出现这种情况呢?难道不应该是长度一样的吗?

在JavaScript中Number类型遵循IEEE 754标准,其实很多语言都遵循此标准,所以由此可以回答我们开头的问题并不是只有JavaScript存在精度丢失

  • IEEE 754

v2-441507575baadbf6fec51d35612778f4_b.jpg JavaScript采用的是64位双精度浮点数编码,符号位占一位,指数位占11位,尾数位占52位

v2-8479dec5d2bdeaedb098b08dd34d5ea9_b.jpg 下面我们拿0.1举例它在计算机中是如何存储的

  1. 首先我们通常看到的二进制其实是计算机存储的尾数位,而尾数位占52位,我们还需引入尾数位存储的相关知识

尾数位存储

  • 隐藏高位

high.png

  • 低位补零

low.png

  1. 将0.1转换为二进制
    0.1 -> 0.000110011001100... -> 1.10011001100... x 2^-4(先使用科学计数法表示)
    1.10011001100110011001100110011001100110011001100110011 x 2^-4  

我们写到了第53位,按照规则来说53位是不能存储的,所以我们遵循如果是1则向前进一位,如果是0则舍去的规则

    1.1001100110011001100110011001100110011001100110011010 x 2^-4
  1. 我们再将科学计数法写成正常的样子
    0.0001100110011001100110011001100110011001100110011001101

    这与我们代码打印出来的结果是一致的
  • 将浏览器转换的二进制数进行相加

    二进制数相加的规则为逢二进一

  0.0001100110011001100110011001100110011001100110011001101
+ 0.001100110011001100110011001100110011001100110011001101(0)  //补一位0
______________________________________________________________
  0.0100110011001100110011001100110011001100110011001100111      
  • 将得到的结果转换为遵循IEEE754的二进制数
  1. 以科学计数法表示
  1.00110011001100110011001100110011001100110011001100111(第53位) x 2^-253位为1,遇到1则进一

  1.0011001100110011001100110011001100110011001100110100 x 2^-2
  1. 最后正常表示为
  0.0100110011001100110011001100110011001100110011001101
  • 将0.3进行转换为二进制
  0.3: 0.010011001(1001循环)
  科学计数法表示:
  1.00110011001100110011001100110011001100110011001100110 x 2^-2
  第53位为0舍去
  1.00110011001100110011001100110011001100110011001100110 x 2^-2
  将其写为正常形式:
  0.010011001100110011001100110011001100110011001100110011
  • 将0.1 + 0.2 与 0.3进行比较

0.0100110011001100110011001100110011001100110011001101(0.1 + 0.2) 0.010011001100110011001100110011001100110011001100110011(0.3)

到此产生精度丢失的原因已经解释清楚了

解决精度丢失的方法

  • 将小数拆分成整数
function fix (num1, num2) {
  let num1Len = num1.toString().split(".")[1].length;
  let num2Len = num2.toString().split(".")[1].length;
  let maxLen = Math.pow(10, Math.max(num1Len, num2Len));
  return (num1 * maxLen + num2 * maxLen) / maxLen;
}
  • 引入外部库Math.js 和 BigNumber.js

  • toFixed()

最大安全数到底是2^53还是2^53 - 1

safe.png

从上图我们可以看到Javascript中最大安全数为2^53 - 1,超过了这个最大安全数的范围就会出现精度丢失

  • 什么叫做安全?

    安全”意思是说能够one-by-one表示的整数,也就是说在(-2^53, 2^53)范围内,双精度数表示和整数是一对一的,反过来说,在这个范围以内,所有的整数都有唯一的浮点数表示,这叫做安全整数

  • 为什么2^53 - 1是最大安全数?

    要说明白为什么2^53 - 1是最大安全数,我们又要从IEEE 754规范说起。上文说到我们看到的二进制是计算机存储的尾数位,所以我们先分别将2^53 - 1, 2^53, 2^53 + 1转换为二进制

2^53 : 1000000000...000000000(1后面53个0)

科学计数法: 1.000000...0000 x 2^52 -> 舍去第53位

2^53 + 1 : 1000000000...000000000(1后面53个0)

科学计数法: 1.000000...0000 x 2^52 -> 舍去第53位 -> 发生了精度丢失

2^53 - 1 : 1111111111...111111111(1后面52个1)

科学计数法: 1.111111...1111 x 2^52

2^53 - 2 : 1111111111...111111110(1后面51个1,1个0)

科学计数法: 1.111111...1110 x 2^52

我们可以看到当Number类型的值到达2^53 + 1时就会发生精度丢失,我们无法知道当前这个数到底是2^53还是2^53 + 1

而2^53每减1都有唯一一个浮点数与其对应,所以最大安全数应该是2^53 - 1

参考文章

# JavaScript 里最大的安全的整数为什么是2的53次方减一?

# IEEE-754单精度浮点类型详解(完结篇)

# 【JS 进阶】你真的掌握变量和类型了吗