理解javascript的数值精度

稍微接触前端比较久的同学,多多少少都听说过一个经典问题 0.1 + 0.2 !== 0.3,即计算精度丢失。其实不光 javascript,其他很多语言都有这个问题。造成这个问题的原因,还要从一些计算机知识开始说起。

存储格式

总所周知,在计算机中,所有数据都是以二进制的形式保存的,数值也不例外。

例如:

8的二进制是1000

0.125的二进制是0.001

进制转换的方法请参考这里

而根据 ECMAScript 标准,JavaScript 中只有一种数字类型:基于 IEEE 754标准的双精度 64 位二进制格式的值。

一个浮点数是由符号位(sign)乘以指数偏移值(exponent)再乘以分数值(fraction)得出的公式如下:

Value=sign * exponent * fraction

存储的三个域如下图:

双精度 64 位浮点数使用 64 位存储,包括 1 位符号位,11 位指数值,以及 52 位小数值。由于指数可以为负数,为了方便存储,指数部分使用偏正值(exponent basis)进行表示,即加入一个固定的偏移量,使指数值永远为正数,然后计算时减去固定的偏移量,得出实际的指数。因为有符号 11 位二进制的最小值为-1023,所以 64 位双精度的偏移值为1023,而 11 位二进制的正整数区间为0~2047,除去 2 个特殊值 0 和 2047,得出指数部分的区间为-1022~+2013

1 11 52 位长
S Exp Fraction
63 62 至 52 偏正值(实际指数大小+1023) 51 至 0 位编号(从右边开始为 0)

规约数和非规约数

上面提到了 2 个特殊的指数域 0 和 2047,一个二进制全为 0,一个二进制全为 1。

  • 当指数域为 0,分数部分也为 0,则代表 0。
  • 当指数域二进制全为 1 时,并且分数部分为非 0,则这个值为 NaN。
  • 当指数域二进制全为 1 时,并且分数部分为 0,代表这个数为无穷大(Infinity)。
  • 当指数域为 0,并且分数部分不为 0 时,我们称之为非规约形式,这时的值的小数部分值大于 0 小于 1,而当指数域不为 0 时且不为 2017 时,称之为规约形式,这时的值的小数部分大于 1 小于 2。

为什么会有这种设计呢?

因为如果使用规约形式,根据定义,能代表的最小正数值为 2-1022,而这时的分数部分全为 0,根据规约数的约定代表 1.0000000000…,实际上这些位数是浪费了。为了让所有分数位被充分利用,使其能代表的数更加接近 0,于是定义了非规约数,在非规约数的条件下,能代表的最小正数值为 2-1022 * 2-52 = 2-1074,这样一来,非规约数能代表的小数会比规约数多分数值位数个数量级。

计算

上面我们已经知道了 javascript 存储数值的方法

首先把0.10.2进行进制转换,具体的转换细节就不写了,这里使用NumbertoString方法直接获取二进制。

1
2
3
4
(0.1).toString(2);
// '0.0001100110011001100110011001100110011001100110011001101'
(0.2).toString(2);
// '0.001100110011001100110011001100110011001100110011001101'

按照 IEEE 754 规范,小数部分最多为 52 位。则0.1的表示为 1.100110011001100110011001100110011001100110011001101 * 2-4,-4 加上偏移值 1023,偏正值为 1019,同样偏正值得出二进制为 1111111011。

所以得出 0.1 存储为

符号位 偏正值 小数位
0 1111111011 100110011001100110011001100110011001100110011001101

同理可得 0.2 表示为 1.100110011001100110011001100110011001100110011001101 * 2-3,偏移值为 1020,偏移值二进制位 1111111100 存储为

符号位 偏正值 小数位
0 1111111100 100110011001100110011001100110011001100110011001101

最后通过二进制来计算 0.1 + 0.2,注意这里使用的是实际的二进制进行相加,而不是存储的格式。

1
2
3
4
  0001100110011001100110011001100110011001100110011001101
+ 0011001100110011001100110011001100110011001100110011010 // 末尾补0。
-----------------------------------------------------------
0100110011001100110011001100110011001100110011001100111

得出结果为:0.0100110011001100110011001100110011001100110011001100111

目前 js 标准中没有转换二进制小数的方法,我们自己写一个实验结果。

1
2
3
4
5
6
7
8
9
10
11
12
var binFraction = function(s, radix) {
radix = radix || 2;
var t = s.split('.');
var answer = parseInt(t[0], radix);
var d = t[1].split('');
for (var i = 0, div = radix; i < d.length; i++, div = div * radix) {
answer = answer + d[i] / div;
}
return answer;
};

binFraction('0.0100110011001100110011001100110011001100110011001100111'); // 0.30000000000000004

0.3 的实际二进制值为 0.010011001100110011001100110011001100110011001100110011,对比上面的结果,会发现我们相加得出的结果结尾多了一位 1。

分析

如果细心的观察了小数的二进制转换结果,我们会发现无论是0.1还是0.2的结果都是无限循环小数。如0.10.2的二进制都是0011的循环,这是因为小数二进制转换是通过2 取余的方式推导的,如果小数本身无法通过 2 变成整数,那么一定会是无限循环的小数。

二进制是无限长度的,但是计算机能存储的位数是有限的,IEEE-754 规定了小数部分最多只有 52 位,所以当小数的二进制长度超过了 52 位,计算机就会将其后面的位数截断,从而不可避免的丢失了精度。

实际上你会发现,无论是 0.1,0.2,还是任意数值通过toString转换成二进制后,得出的结果用 IEEE-754 标准表示后,小数位最多只有 52 位,”最多”是因为尾数如果为 0 时被省略了。

进阶

我们知道了 64 位双精度的位数是有限的,那么能表达的最大值是否也有上限呢?当然。

根据计算规则可以得出最大值的计算公式:

±(2 - 2-小数位) * 2指数值

我们用 js 代码来测试

1
const max = (2 - Math.pow(2, -52)) * Math.pow(2, 1023);

得出max的值为1.7976931348623157e+308

同理可得最小值的计算公式为

±2-小数位 * 2指数值

1
const min = Math.pow(2, -52) * Math.pow(2, -1022);

得出min的值为5e-324

这里我们忽略了符号位与值 0 的情况,只讨论正数的情况

汇总到表格:

最大值 最小值
1.7976931348623157e+308 5e-324

实际上这 2 个值在Number上的静态属性上已有定义。

1
2
Number.MAX_VALUE === 1.7976931348623157e308; // true
Number.MIN_VALUE === 5e-324; // true

Number.MAX_VALUE 代表了 javascript 能代表的最大正值。

Number.MIN_VALUE 代表了 javascript 能代表的最小正值。

如果我们定义的值超过这个范围会怎么样呢?我们继续用代码测试。

首先测试最大值

1
var biggerThanMax = 1.8e308; // Infinity

会发现如果一个正数定义大于这个值,则为Infinity

接下来

1
2
3
4
5
6
7
8
9
10
11
12
var smallerThanMin1 = 4.9e-324;
var smallerThanMin2 = 3.9e-324;
var smallerThanMin3 = 2.9e-324;
var smallerThanMin4 = 2.5e-324;
var smallerThanMin5 = 2.4e-324;

smallerThanMin1 === Number.MIN_VALUE; // true
smallerThanMin2 === Number.MIN_VALUE; // true
smallerThanMin3 === Number.MIN_VALUE; // true
smallerThanMin4 === Number.MIN_VALUE; // true
smallerThanMin5 === Number.MIN_VALUE; // false
smallerThanMin5 === 0; // true

最小值的测试有点出乎意料,当我们定义的正值小于Number.MIN_VALUE的某个区间内,这个值仍然等于Number.MIN_VALUE(通过控制台输出,可以看出这个值仍然为5e-324),只有小过一定值后,这个值就变成 0 了。个人猜想可能和存储的精度有关系,有了解的同学欢迎补充。

MIN_VALUE 的值约为 5e-324。小于 MIN_VALUE (“underflow values”) 的值将会转换为 0

- MDN 文档是这样描述的。

解决问题

原生方法

javascript 原生提供了toFixed方法,一般来说我们计算的小数位并不会太多,所以多数情况下它都是可靠的。

1
2
3
4
5
6
(1.2234567).toFixed(3); // '1.223'
(1.2234567).toFixed(4); // '1.2235'
(1.2234567).toFixed(5); // '1.22346'
(1.2234567).toFixed(10); // '1.2234567000'

(0.1 + 0.2).toFixed(2) == 0.3; // true

需要注意的是toFixed方法返回的是字符串,如果展示有问题时我们可以用Number(S: string)方法将其重新转换成 number,后面多余的 000 也会消失。

类似的 Math.round, Math.precition方法也能解决大多数场景的问题。

数值加权

通过上文我们知道了精度丢失是因为小数的二进制无限循环导致的,而整数的二进制并不会有这种情况。

那么可以通过将小数变成整数,计算完成后再还原

1
(0.1 * 10 + 0.2 * 10) / 10; // 0.3

但是这种方式并不完美,因为会影响能计算的值的范围,虽然绝大多数情况并不会用到最大值。

三方方案

使用 math.jsdecimal.js等三方库。

参考文档

希望本文会对你有帮助,若有不足以及错误之处,欢迎交流指正。