JS 为什么会丢失精度

在前面这个文章的时候说 按位运算,是基于32位二进制补码进行运算。这里在继续补充下js浮点运算丢失精度的问题。

丢失精度

丢失精度场景

还是先从下面这个经典的例子开始说起,如下这个例子比较常见,通常为了规避这类问题在一些电商类网站处理价格的场景都需要做特殊处理。

1
2
3
// 0.30000000000000004
0.1 + 0.2 !== 0.3
console.log(0.1 + 0.2);

如上浮点常见解决方案都是通过转为整数,然后基于整数计算出结果,再转回小数,简单例子如下:

1
2
3
4
5
// 0.1 + 0.2
// 但这里也需要保证转换后的数小于 2^53,大数依然会出问题。
const base = 10;
const res = (0.1 * base + 0.2 * base ) / base;
console.log(`0.1 + 0.2 = ${res}` );

为什么丢失精度? 在分析这个问题之前,要先了解下 IEEE 754IEEE二进制浮点数算术标准)标准,JavaScript 遵循 IEEE 754 规范,Number 采用双精度存储(double precision)。

浮点数转二进制

这里要先看下计算机是如何对浮点数进行二进制和十进制转换的。

  • 整数部分直接十进制转为二进制即可
  • 小数部分乘以 2,取得到的整数部分记下来,然后小数部分继续乘以 2,直到小数部分为0,或者达到储存位上限。

举个例子,比如转换十进制数 12.3

整数部分:12,转换二进制为:

1100

小数部分:0.3,转换二进制为:

0.3 * 2 = 0.6 取出 0,结果不为0,小数部分继续乘2
0.6 * 2 = 1.2 取出 1,结果不为0,小数部分继续乘2
0.2 * 2 = 0.4 取出 0,结果不为0,小数部分继续乘2
0.4 * 2 = 0.8 取出 0,结果不为0,小数部分继续乘2
0.8 * 2 = 1.6 取出 1,结果不为0,小数部分继续乘2
0.6 * 2 = 1.2 取出 1,结果不为0,小数部分继续乘2
无限循环,乘不尽 …

最终转换结果为:

1100.01 0011 0011... (无限个0011)

这里在十进制和二进制的转换中已经丢失了精度,用有限的长度存储无限的循环。

二进制科学计数法

1100.01 0011 0011... (无限个0011) 通过科学计数法表示,小数点像左移 3 位。

得到:

1.10001 0011 0011… * 2^3

这里 3 就是称为指数值
1.10001 0011 0011... 为小数部分。

清楚这部分之后,下面再来看 IEEE 754 如何对浮点数的存储的定义的。


IEEE 754

首先一个浮点数值的表示:Value = sign * exponent * fraction

也就是浮点数的实际值,等于符号位(sign bit)乘以指数偏移值(exponent bias)再乘以分数值(fraction)。

IEEE 754 对浮点数格式的描述呈现如下:

img

二进制浮点数是以符号数值表示法的格式存储,包括:符号位指数位尾数位,JavaScript 64位双精度如下:

  • 符号位

    其中第 1 位表示 符号位 (sign bit),0正 1负

  • 指数位

    接下来 11 位表示 指数位,存储 指数偏移值(exponent bias),决定了数字存储的范围。

    指数偏移值 = 指数实际大小 + 偏正值 (IEEE 754标准规定该偏正值为固定值等于 2^(e-1) - 1,e 为存储指数的比特的长度,即为这里的 11,结果为 1023)。

    因为,指数的值可能为正也可能为负,如果采用补码表示的话,在本身符号位的基础上,指数位也要有自己的符号位导致不能简单的进行大小比较。正因为如此,指数部分通常采用一个无符号的正数值存储。
    双精度的指数部分是值为:(−1022 ~ +1023) + 1023,指数值的大小范围就在 [1~2046](0和2047为特殊值)

    所以在浮点小数计算时,指数偏移值 减去 偏正值 就得到了实际的指数大小(也被称之为 阶码)。

  • 尾数位

    最后的 52 表示 尾数位,存储 有效数字(fraction),决定了数字存储的最高精度。

    存储有效数字将不会存储小数点前面的1(因为二进制有效数字的第一位肯定是1,省略,所以这里实际的尾数最高精度是53位),另外如果有效数字的长度超过了最大存储位数,IEEE754规定舍入到最接近且可以表示的值,这里就是 0舍1入。

    取值范围 >=1 fraction < 2

结合上面数字 12.31.10001 0011 0011... * 2^3)分析下如何存储:

  • 符号位 1位,正数,即: 0
  • 指数位 11位,3 + 1023 = 1026 即:10000000010
  • 尾数位 52位,省略 1. 即: 10001 0011 0011... 52位舍入

实践

上面介绍了 JavaScript 如何存储浮点数了,了解之后,我们再来看看下面这些具体的问题就容易理解多了。

浮点运算丢失精度

这里还是 0.1 + 0.2

存储

0.1 储存

1
2
3
4
5
6
7
8
9
# 二进制
0.000 1100 1100 1100 1100...

# 科学计数,最后一位进1
1.1001100110011001100110011001100110011001100110011010 * 2^(-4)

# 计算机存储
# 指数位:1023 - 4 = 1019 ,二进制:01111111011
0 + 01111111011 + 1001100110011001100110011001100110011001100110011010

0.2 储存

1
2
3
4
5
6
7
8
9
# 二进制
0.0011 0011 0011 0011.....

# 科学计数,最后一位进1
1.1001100110011001100110011001100110011001100110011010 * 2^(-3)

# 计算机存储
# 指数位:1023 - 3 = 1020 ,二进制:1111111100
0 + 01111111011 + 1001100110011001100110011001100110011001100110011010

也可以使用 这个网站 查看浮点数在计算机中的存储。

这里可以分析出 0.10.2 在存储的时候进度都已经发生了丢失。

计算

计算要经过下面这几步:

  • 对阶 指数位数不相同,运算时需要对阶运算,小阶向大阶看齐 的方式, 不影响数据大小,但也会影响精度
  • 尾数求和 对阶后的尾数定点求和
  • 规格化 提高浮点数的表示精度
  • 舍入 为了尽可能减小误差,就需要考虑舍入
  • 溢出判断 判断结果是否溢出

    计算过程详细的我也介绍不了,因为我不会 ….Orz.

经过上面计算,结果为

1
0.0100110011001100110011001100110011001100110011001100

转换成十进制:0.30000000000000004

所以在存储和计算的过程中都可能导致精度的丢失,从而可能导致如上计算结果的异常。

最大数/最大安全数

最大安全整数

最大安全整数是由 尾数位 的最高存储决定,64位双精度尾数 52 位,再加上省略为 1 的位,所以结果是:

1
2
3
const maxSafeInt = 2 ** 53 - 1;
console.log(maxSafeInt);
console.log(maxSafeInt === Number.MAX_SAFE_INTEGER);

最大数

数字的存储的范围由 指数偏移值 决定,64位双精度最大为 1023,同时 尾数位 取最大值无限接近 21.9999999999999998,符号位取

1
2
3
const maxNum = 2 ** 1023 * 1.9999999999999998;
console.log(maxNum);
console.log(maxNum === Number.MAX_VALUE);

直接读取0.1为何不丢失精度

前面已经说了 js最大精度为 2 ** 53 - 1 = 9007199254740991,长度为 16,所以使用了 toPrecision(16) 来做精度运算,结果刚好等于了本身。

1
2
3
console.log(0.1.toPrecision(16));

console.log(0.1.toPrecision(22));

运行如上代码,发现 22 位的时候已经转不回去了,这里其实也再一次说明了在存储的时候已经丢失了精度。

toFixed丢失精度

toFixed 产生精度问题的原因也是一样的,不同的是解决方案不能通过乘到整数做运算,而需要自己判断下一位是否需要四舍五入了。

以上 存在不正确的地方欢迎指正。

参考

https://zh.wikipedia.org/wiki/IEEE_754
https://juejin.im/post/6844903680362151950