◆

JavaScript 0.1+0.2 不等于 0.3 的原因与解决

2023-02-02 01:370

看如下代码,最后执行的结果为 false,但在数学角度来说,他们应该是相等的

console.log(0.1 + 0.2 === 0.3); // false

为什么会出现上述的问题

如图,我们在浏览器上执行会发现 0.1 + 0.2 是等于 0.30000000000000004

下图是在浏览器控制台里的实际输出,可以看到末尾多出来的那串数字。

浏览器控制台中 0.1 + 0.2 的实际输出结果

问题的原因在于,二进制浮点数中的 0.1 和 0.2 并不是十分精确,它们相加的结果并非刚好等于 0.3,而是一个比较接近的数字 0.30000000000000004,所以条件判断结果为 false。

往下再挖一层。JS 里的数字只有一种类型,遵循 IEEE 754 双精度浮点数标准,用 64 位存储:1 位符号、11 位指数、52 位尾数。存储的形式是二进制的科学计数法。

关键在于十进制小数转二进制这一步。整数部分总能精确表示,小数部分则不一定。0.5 可以,因为它正好是二分之一;0.25、0.75 也可以。但 0.1 转成二进制是 0.0001100110011...,0011 无限循环,跟十进制里三分之一写成 0.333... 是同一回事。

而尾数只有 52 位,无限循环的部分必须在某一位截断并做舍入。所以从写下 0.1 这个字面量的那一刻起,内存里存的就已经不是精确的 0.1,而是最接近它的那个可表示的值。0.2 同理。两个都带误差的近似值相加,误差累积后落到了一个恰好无法舍入回 0.3 的位置上,于是打印出了 0.30000000000000004。

这不是 JS 的缺陷。任何用二进制浮点数的语言都一样,Java、Python、C 里做同样的运算结果相同。JS 显得突出只是因为它没有整数类型和十进制类型可以退守,写业务代码时很容易撞上。

顺带说一个相关现象:0.1 + 0.2 有误差,但 0.1 + 0.4 打印出来正好是 0.5。不是因为它没误差,而是那个误差在舍入到最短可打印表示时刚好被吸收掉了。所以「有时候对有时候错」不是随机的,而是取决于误差是否跨过了舍入边界。这也说明靠随手试几个数来判断有没有精度问题是不可靠的。

如何解决

思路是放弃精确相等,改判「足够接近」。

如下代码如果两个数之间的差值小于 Number.EPSILON,我们就认为这两个值是相等的

function numbersCloseEnoughToEqual(n1, n2) {
  return Math.abs(n1 - n2) < Number.EPSILON;
}

console.log(numbersCloseEnoughToEqual(0.1 + 0.2, 0.3)); // true;

Number.EPSILON 是 1 和大于 1 的最小可表示浮点数之间的差值,约等于 2.22e-16,代表了双精度浮点在 1 附近能分辨的最小刻度。用它当误差容限,语义上就是「两数之差小于机器精度,视为相等」。

但这个写法有个重要局限,值得注意:Number.EPSILON 是针对 1 附近的绝对精度。浮点数的精度是相对的,数值越大,相邻两个可表示值之间的间隔也越大。所以在比较很大的数时,这个固定的容限会显得太小,两个实际上「一样」的大数会被判为不相等。反过来比较极小的数时它又太宽松了。

更稳妥的做法是用相对误差,把容限按被比较数的量级缩放,例如 Math.abs(a - b) <= Number.EPSILON * Math.max(Math.abs(a), Math.abs(b))。

除了容差比较,实际项目里还有两条更常用的路子。

一是涉及金额的场景,把单位降到最小不可分割的粒度,用整数运算。金额以「分」为单位存成整数,算完再除以一百显示。只要没超出安全整数范围,整数运算是完全精确的。这是最推荐的做法,比事后修正误差可靠。

二是需要精确小数运算时用专门的库,比如 decimal.js、big.js 这类。它们内部用十进制而不是二进制表示,代价是性能和体积,但结算、计费这类不容许误差的场景值得。

至于 toFixed,它只能用于显示层的格式化,不解决计算精度问题。而且它自身的舍入行为也受浮点表示影响,不同数值下的舍入方向可能跟直觉不一致,不要用它做业务判断。

BigInt 能精确表示任意大的整数,但它不支持小数,所以处理不了这类问题,别搞混。

💬留言板0

还没有留言,来发表第一条吧~

PAGE LOAD / LIVEJavaScript 0.1+0.2 不等于 0.3 的原因与解决加载中