JS疑难杂症之说说0.1+0.2为什么不等于0.3

565 阅读4分钟

前言

我们在使用 JavaScript 计算 0.1+0.2 会出现不等于0.3的情况,其实不只是 JavaScript 中会存在这个问题。只要是使用 IEEE 754 规范都会存在这个问题比如说 java 中也会存在这个问题。

IEEE 754

在 IEEE 754 规范中双精度浮点数使用 64 进行存储的。

对于64为双精度浮点数来说最高位为符号位 S 紧接着11位为指数位E最后剩余的52位为有效数字 M

计算公式:

注意以上的公式遵循科学计数法的规范,在十进制中 0<M<10,到二进制就是 0<M<2。也就是说整数部分只能是1,所以可以被舍去,只保留后面的小数部分。如 4.5 转成二进制就是 100.1,科学计数法表示是 1.001*2^2,舍去1后 M = 001。E是一个无符号整数,因为长度是11位,取值范围是 0~2047。但是科学计数法中的指数是可以为负数的,所以约定减去一个中间数 1023,[0,1022] 表示为负,[1024,2047] 表示为正。如 4.5 的指数 E = 1025,尾数 M = 00

最终的公式变成:

有效数字

在 IEEE 754 规定在计算机内部存储有效数字 M 时,默认这个的第一位为1,此时会被舍去只保存后面部分。比如说计算机在保存 110.001 会只保存10001。这也是为什么我们在看二进制需要先找到第一个 1 出现的位置。

指数

IEEE 754 规定在计算机内部存储指数 E 时,在双精度浮点数中E 11 位取值范围为 [0,2047] 。为了表示指数 E 的正负所以必须减去1023。其中 [0,1022]表示负数指数,[0,1024]表示正指数。比如说 110.001 的指数为 2 ,保存时 1023+210000000001

  • 当指数位中 11 位都为 1 时,有效数字为0时表示无穷 Infinity ,结合符号位就有了正无穷 Infinity 与负无穷之分 -Infinity
  • 当指数位中 11 位都为 1 时,有效数字不为0时表示 NAN

接下来,我们看看为什么0.1 + 0.2 !== 0.3

首先我们先来看一下十进制如何转换成二进制的,对于一个浮点数我们需要对整数与小数部分分别求值,整数部分除以2取余;小数部分乘以2取整。

我们在此举个例子,将十进制6.125转换为二进制过程如何下:

  1. 整数部分转换过程

1.1 6 / 2 = 3 余 0

1.2 3 / 2 = 1 余 1

1.3 1 / 2 = 0 余 1

整数部分从1.3 到 1.1 来取,最终将6转换为二进制之后结果为110

整体过程如下图所示:

  1. 小数部分转换过程

2.1 0.125 * 2 = 0.25 整 0

2.2 0.25 * 2 = 0.5 整 0

2.3 0.5 * 2 = 1 整 1

小数部分从2.1 到 2.3 来取,最终将0.125转换为二进制之后结果为0.001

整体过程如下图所示:

最终我们可以得到 6.125 的二进制位 110.001 即 1.10001 * 2^2 。在计算机中存储为:
0 10000000001 110001

了解完十进制转换成二进制的过程之后,我们再来看下0.1 + 0.2的计算过程

0.1 + 0.2 的计算过程

我们可以根据刚刚的规则,先看看转换0.1的值, 就能知道它转换为二进制之后的值为0.00011...(⽆限重复 0011)

再看看转换0.2的值为0.00011...(⽆限重复 0011)

分别将其转为科学计数法的结果为:

0.1 = 0.1100110011001100110011001100110011001100110011001101 * 2^-3
0.2 = 1.1001100110011001100110011001100110011001100110011010 * 2^-3

次方数不同不能直接进行计算我们需要将0.1转为-3次方,这边需要注意由于指数从-4转化为-3,此时有效位数已经超出52位由于超出部分为0直接舍去。

0.1 = 0.1100110011001100110011001100110011001100110011001101 * 2^-3
0.2 = 1.1001100110011001100110011001100110011001100110011010 * 2^-3

之后计算0.1+0.2会发生进位

10.110011001100110011001100110011001100110011001100111 * 2^-3

将-3次方转换为-2次方,这时有效数字位数大于52且被截取部分为1那么需要往前进1,转化得

1.011001100110011001100110011001100110011001100110100 * 2^-2

在计算机内存中表示

0  1111111101  1011001100110011001100110011001100110011001100110100

解决方案

方案1: 利用toPrecision(n) 和 toFixed(n)

toPrecision和toFixed它们都可以一定程度解决此类问题,但也有一些细微区别, 具体可自行查阅资料

/**
 * 精确加法
 */
function add(num1, num2, precision = 12) {
    return Number((num1 + num2).toPrecision(precision));
}

console.log(add(0.1, 0.2)); // 0.3

/**
 * 精确加法
 */
function addByToFixed(num1, num2, n = 1) {
    return Number((num1 + num2).toFixed(n));
}

console.log(addByToFixed(0.1, 0.2)); // 0.3

方案2: 小数转整数法

如果小数点为一位,则将其乘以 10(如果是两位则为 100),然后将其四舍五入,然后将四舍五入的值除以 10

/**
 * 精确加法
 */
function add(num1, num2) {
    const num1Digits = (num1.toString().split('.')[1] || '').length;
    const num2Digits = (num2.toString().split('.')[1] || '').length;
    const baseNum = Math.pow(10, Math.max(num1Digits, num2Digits));
    return (num1 * baseNum + num2 * baseNum) / baseNum;
}

console.log(add(0.1, 0.2)); // 0.3

方案3: 设置一个误差范围, Number.EPSILON

function numberEpsilon(a, b) {
    return Math.abs(a - b) < Number.EPSILON;
}

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

应用第三方库,如:Math.js、bigdecimal.js等

这里就不举例了,具体玩法参考其官方文档

个人建议,真实的开发业务中,如果对数据精度特别精确的,个人建议还是使用第三方库,比较它们经历有过大量的实践和测试