Published on

为什么0.1 + 0.2!==0.3?计算机的浮点数问题

Authors
  • avatar
    Name
    Joy Peng
    Twitter

Introduction

当我们看到下面这段代码的时候,很容易想到如果调用addScore十次,应该就会输出You win!,但是实际上并不会。

let score = 0
function addScore() {
  score = score + 0.1
  if (score == 1) {
    console.log('You win!')
  }
}

类似地,如果我们把0.1 + 0.2的结果和0.3比较,也会发现结果是false。

一个简单的回答是:计算机使用的格式(binary-floating-point)不能准确地表示 0.10.2,当代码被编译或解释时,0.1是四舍五入到该格式中最接近的数字。 事实上,这不是JavaScript的问题,而是所有使用IEEE 754浮点数标准的编程语言都会遇到的问题。

IEEE 754标准

JavaScript中,整数和小数都只有一种类型,就是numberJavaScript使用IEEE 754标准来表示数字,这个标准定义了两种表示数字的方式:32-bit64-bit32-bit的叫做single precision64-bit的叫做double precisionJavaScript使用64-bit来表示数字。

这样的存储结构优点是可以归一化处理整数和小数,结构如下:

64bit
  • 蓝色部分:用来存储符号位(sign,简写S)用来区分正负数(0-正,1-负
  • 绿色部分:用来存储指数(exponent,简写E)占用11位;
  • 红色部分:用来存储小数(fraction,简写F)占用52位,多出的用0补齐

浮点数的二进制表示

以十进制数字521.125为例:

  • 整数部分521转为二进制:依次除以2取余数的逆序;
521 / 2 = 260 ...... 1
260 / 2 = 130 ...... 0
130 / 2 = 65  ...... 0
65  / 2 = 32  ...... 1
32  / 2 = 16  ...... 0
16  / 2 = 8   ...... 0
8   / 2 = 4   ...... 0
4   / 2 = 2   ...... 0
2   / 2 = 1   ...... 0
1   / 2 = 0   ...... 1 // 到0结束

对余数取逆序,所以整数部分二进制为:1000001001

  • 小数部分0.125转为二进制:小数部分依次乘以2取结果整数的正序;
0.125 * 2 = 0.25
0.25  * 2 = 0.5
0.5   * 2 = 1    // 到1结束

对三次运算的结果取整,所以小数部分二进制为001。

合并,所以结果为1000001001.001

浮点数的IEEE 754标准表示

接着上个例子,我们已经把十进制数字521.125转为二进制1000001001.001,接下来我们用IEEE 754标准表示这个数字。

首先将1000001001.001转为科学计数法,即1.000001001001 * 2^9,然后将9加上1023IEEE 754规定指数E的偏移量是1023),得到1032,转为二进制为10000001000,这部分就是IEEE 754标准中的指数部分。

对于Mantisssa部分,我们只需要取小数部分的9位,然后补052位,所以Mantisssa部分为000001001001

又因为521.125是正数,所以符号位为0

所以521.125IEEE 754表示为:

0 10000001000 000001001001

0.1 + 0.2 != 0.3

对于数字0.1来说,按下图步骤进行转换,图中1100是重复部分,它的二进制表示是0.00011001100110011001100110011001100110011001100110011010,这个二进制小数是无限循环的,所以JavaScript会四舍五入到最接近的64-bit浮点数。

0.1 * 2 = 0.2
0.2 * 2 = 0.4
0.4 * 2 = 0.8
-------------
0.8 * 2 = 1.6
0.6 * 2 = 1.2
0.2 * 2 = 0.4
0.4 * 2 = 0.8
-------------
0.8 * 2 = 1.6
0.6 * 2 = 1.2
0.2 * 2 = 0.4
0.4 * 2 = 0.8
...

用科学记数法表示为1.1001100110011001100110011001100110011001100110011010 * 2^-4,转为IEEE 754标准表示为:

0 01111011 1001100110011001100110011001100110011001100110011010

这里因为无限循环,所以只取52位,结果的精度丢失了。

同理,0.2IEEE 754表示为:

0 01111100 1001100110011001100110011001100110011001100110011010

0.3IEEE 754表示为:

0 01111101 0011001100110011001100110011001100110011001100110011

0.1+0.2的计算过程如下:

0 01111011 1001100110011001100110011001100110011001100110011010
0 01111100 1001100110011001100110011001100110011001100110011010
---------------------------------------------------------------
0 01111100 0011001100110011001100110011001100110011001100110010

可以看到,最后计算的结果和0.3IEEE 754表示不一样,所以0.1 + 0.2 != 0.3

如何解决这个问题?

对于开头我们用于引入的那个问题,

let score = 0
function addScore() {
    score = score + 0.1
+   if(score >=1) {
-   if(score == 1) {
        console.log('You win!')
    }
}

只需要把score == 1改为score >= 1,绕过score不会精确等于1的问题。

小数计算

  • 使用第三方库Math.jsnumber-precision.js,这两个库都提供了小数计算的方法,可以避免精度丢失的问题。
  • ES6提供的Number.EPSILON,可以用来比较两个浮点数是否相等。例如:
function isEqual(a, b) {
  return Math.abs(a - b) < Number.EPSILON
}

EPSLION,是一个极小的数,用来比较两个浮点数是否相等。

大数计算

  • 使用BigIntBigIntES10新增的数据类型,可以用来表示任意大的整数。例如:
let bigInt = BigInt(9007199254740991)
console.log(bigInt + BigInt(1)) // 9007199254740992n

结尾的n表示这是一个BigInt类型。

  • 使用第三方库big.jsbig.js是一个用来处理大数的库,可以用来处理大数的加减乘除等运算。 例如:
let bigNum = new Big(9007199254740991)
console.log(bigNum.plus(1).toString()) // 9007199254740992

参考资料