稍微接触前端比较久的同学,多多少少都听说过一个经典问题 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.1
和0.2
进行进制转换,具体的转换细节就不写了,这里使用Number
的toString
方法直接获取二进制。
1 | (0.1).toString(2); |
按照 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 | 0001100110011001100110011001100110011001100110011001101 |
得出结果为:0.0100110011001100110011001100110011001100110011001100111
目前 js 标准中没有转换二进制小数的方法,我们自己写一个实验结果。
1 | var binFraction = function(s, radix) { |
0.3 的实际二进制值为 0.010011001100110011001100110011001100110011001100110011,对比上面的结果,会发现我们相加得出的结果结尾多了一位 1。
分析
如果细心的观察了小数的二进制转换结果,我们会发现无论是0.1
还是0.2
的结果都是无限循环小数。如0.1
和0.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 | Number.MAX_VALUE === 1.7976931348623157e308; // true |
Number.MAX_VALUE
代表了 javascript 能代表的最大正值。
Number.MIN_VALUE
代表了 javascript 能代表的最小正值。
如果我们定义的值超过这个范围会怎么样呢?我们继续用代码测试。
首先测试最大值
1 | var biggerThanMax = 1.8e308; // Infinity |
会发现如果一个正数定义大于这个值,则为Infinity
。
接下来
1 | var smallerThanMin1 = 4.9e-324; |
最小值的测试有点出乎意料,当我们定义的正值小于Number.MIN_VALUE
的某个区间内,这个值仍然等于Number.MIN_VALUE
(通过控制台输出,可以看出这个值仍然为5e-324
),只有小过一定值后,这个值就变成 0 了。个人猜想可能和存储的精度有关系,有了解的同学欢迎补充。
MIN_VALUE 的值约为 5e-324。小于 MIN_VALUE (“underflow values”) 的值将会转换为 0
- MDN 文档是这样描述的。
解决问题
原生方法
javascript 原生提供了toFixed
方法,一般来说我们计算的小数位并不会太多,所以多数情况下它都是可靠的。
1 | (1.2234567).toFixed(3); // '1.223' |
需要注意的是toFixed
方法返回的是字符串,如果展示有问题时我们可以用Number(S: string)
方法将其重新转换成 number,后面多余的 000
也会消失。
类似的 Math.round
, Math.precition
方法也能解决大多数场景的问题。
数值加权
通过上文我们知道了精度丢失是因为小数的二进制无限循环导致的,而整数的二进制并不会有这种情况。
那么可以通过将小数变成整数,计算完成后再还原
1 | (0.1 * 10 + 0.2 * 10) / 10; // 0.3 |
但是这种方式并不完美,因为会影响能计算的值的范围,虽然绝大多数情况并不会用到最大值。
三方方案
使用 math.js
,decimal.js
等三方库。
参考文档
希望本文会对你有帮助,若有不足以及错误之处,欢迎交流指正。