JS浮点数也没那么复杂

367 阅读11分钟

前言

工作中经常会遇到浮点数的操作,所以对一些常见的"bug"比如浮点数的精度丢失,0.1+0.2!==0.3的问题也有所了解,但是都不深入,对于Number的静态属性MAX_SAFE_INTEGER知道它的存在,但是并不知道为什么这样定义范围。刚好最近有空就带着这些疑惑深入的了解了一下,发现网上也有一些文章,有对这些知识的梳理,要么是太晦涩,需要一定的基础才能看懂,要么就是太散,没有全面的进行分析。所以想着,写一篇这方面的文章,一是对自己学习结果的总结和检验,另一方面通过通俗易懂的方式分享给跟我一样有困惑的同学,大家互相学习,共同进步,有问题欢迎指正。

本文首先会介绍一些概念,然后深入分析IEEE浮点数精度丢失的问题,最后解释为什么最大安全数MAX_SAFE_INTEGER的取值是2^{53} - 1

浮点数

首先来介绍一下浮点数,JavaScript中所有的数字,无论是整数还是小数都只有一种类型Number。遵循 IEEE 754 的标准,在程序内部Number类型实质是一个64位固定长度的浮点数,也就是标准的double双精度浮点数。

IEEE浮点数格式使用科学计数法表示实数。科学计数法把数字表示为尾数(mantissa),和**指数 (exponent)**两部分。比如 25.92 可表示为 2.592\times10^1,其中2.592是尾数,值 10^1 是指数。**指数的基数为 10,指数位表示小数点移动多少位以生成尾数。每次小数点向前移动时,指数就递增;每次小数点向后移动时,指数就递减。*再比如 0.00172可表示为 1.72\times10^-3。科学计数法对应到二进制里也是一个意思。

计算机系统使用二进制浮点数,这种格式使用二进制科学计数法的格式表示数值。数字按照二进制格式表示,那么尾数指数都是基于二进制的,而不是十进制,例如 1.0101\times2^2。 在二制里表示,1.0101 左移两位后,生成二进制值 101.01,这个值表示十进制整数 5,加上小数(0\times2^{-1}+1\times2^{-2}=0.25),生成十进制值 5.25。

浮点数的组成

前面已经介绍了IEEE浮点数使用科学计数法表示实数,IEEE浮点数标准会把一个二进制串分成3部分,分别用来存储浮点数的尾数阶码以及符号位。其中

  • 符号位S:第 1 位是正负数符号位(sign),0代表正数,1代表负数
  • 指数位E:中间的 11 位存储指数(exponent),用来表示次方数
  • 尾数位M:最后的 52 位是尾数(mantissa),超出的部分自动进一舍零,二进制默认整数位为1舍去

float

指数表示浮点数的指数部分,是一个无符号整数,因为长度是11位,取值范围是 0~2047。因为指数值可以是正值,也可以是负值,所以需要通过一个偏差值对它进行置偏,即指数的**真实值=指数部分的整数—偏差值。对于64位浮点数,取中间值,则偏差值=1023,[0,1022]表示为负,[1024,2047] 表示为正**。

通过公式计算来表示浮点数的值话,如下所示:

\begin{gather}
V = (-1)^S\times2^{E-1023}\times(1.M)
\end{gather}

公式看起来可能还是有点抽象,那我们拿一个具体的十进制数字8.75来举例,分析对应公式中各变量的值。首先将8.75转成二进制,其中整数部分8对应的二进制为1000。小数转二进制具体步骤为:将该数字乘以2,取出整数部分作为二进制表示的第1位;然后再将小数部分乘以2,将得到的整数部分作为二进制表示的第2位;以此类推,直到小数部分为0。 故0.75转二进制的过程如下:

0.75 * 2 = 1.5 // 记录1
0.5 * 2 = 1 // 记录1
// 0.75对应的二进制为11

最终8.75对应的二进制为1000.11,通过科学计数法表示为1.00011\times2^3,其中舍去1后,M=00011E = 3。故E=3+1023=1026。最终的公式变成:8.75 = (-1)^0\times2^{1026-1023}\times(1.00011)

在尾数的定义上,有一个概念超出的部分自动进一舍零不知道大家有没有注意到,IEEE754浮点数的舍入规则与我们了解的四舍五入相似,但也存在一些区别。

IEEE754规范的舍入规则

IEEE754采用的浮点数舍入规则有时被称为最近偶数

  • 首先判断精度损失(优先级最高),向上和向下都计算,精度损失最小者获胜,也就是"最近"原则.
  • 如果距离相等(即精度损失相等),那么将执行偶数判断,偶数胜出.

我们来举个例子,假定二进制小数1.01101,舍入到小数点后4位。首先往上和往下损失的精度都是0.00001(二进制),这时候根据第二条规则保证舍入后的最低有效位是偶数,所以执行向下舍入,结果为1.0110。如果将其舍入到小数点后2位,则执行向上舍入,精度丢失0.00011,向下舍入,精度丢失0.00101,所以结果为1.10。再来思考下看看下面的这些例子,原因后面会解释。

 Math.pow(2,53) // 9007199254740992
 Math.pow(2,53) + 1 // 9007199254740992
 Math.pow(2,53) + 2 // 9007199254740994
 Math.pow(2,53) + 3 // 9007199254740996

了解了浮点数的组成,以及尾数的舍入规则后,我们就来看看为什么浮点数会存在精度丢失的问题。

精度丢失问题

通过浮点数的尾数接受,也许机智的你就已经发现了为什么会丢失精度。就是因为舍入规则的存在,才导致了浮点数的精度丢失。

浮点数的组成部分,我们已经了解了如何将一个十进制的小数转成二进制。不知道大家有没有注意到我们只说了将该数字乘以2,取出整数部分作为二进制表示的第1位,以此类推,直到小数部分为0,但还存在另一种特殊情况就是小数部分出现循环,无法停止,这个时候用有限的二进制位就无法准确表示一个小数,这也就是精度丢失的原因了。

我们按照乘以 2 取整数位的方法,把 0.1 表示为对应二进制:

// 0.1二进制演算过程如下
0.1 * 2 = 0.2 // 取整数位 记录0
0.2 * 2 = 0.4 // 取整数位 记录00
0.4 * 2 = 0.8 // 取整数位 记录000
0.8 * 2 = 1.6 // 取整数位 记录0001
0.6 * 2 = 1.2 // 取整数位 记录00011
0.2 * 2 = 0.4 // 取整数位 记录000110
0.2 * 2 = 0.4 // 取整数位 记录0001100
0.4 * 2 = 0.8 // 取整数位 记录00011000
0.8 * 2 = 1.6 // 取整数位 记录000110001
0.6 * 2 = 1.2 // 取整数位 记录0001100011
... // 如此循环下去
0.1 = 0.0001100110011001...

最终我们得到一个无限循环的二进制小数 0.0001100110011001...,按照浮点数的公式,0.1=1.100110011001..\times2^{-4}E=1023-4=1019,舍去首位的1,通过舍入规则取52位M=00011001100...11010,转化成十进制后为 0.100000000000000005551115123126,因此就出现了精度丢失。同时通过上面的转化过程可以看到0.2,0.4,0.6,0.8都无法精确表示,0.1 到 0.9 的 9 个小数中,只有 0.5 可以用二进制精确的表示。

让我们继续看个问题:

0.1 + 0.2 === 0.3 // false
var s = 0.3 
s === 0.3 // true

为什么0.3 === 0.3 而 0.1 + 0.2 !== 0.3

// 0.1 和 0.2 都转化成二进制后再进行运算
0.00011001100110011001100110011001100110011001100110011010 +
0.0011001100110011001100110011001100110011001100110011010 =
0.0100110011001100110011001100110011001100110011001100111

// 转成十进制正好是 0.30000000000000004

可以看出,因为0.1和0.2都无法被精确表示,所以在进行加法运算之前,0.1和0.2的精度就已经丢失了。 浮点数的精度丢失在每一个表达式,而不仅仅是表达式的求值结果。

我们可以拿个简单的数学加法来类比一下,计算1.7+1.6的结果,四舍五入保留整数:

1.7 + 1.6 = 3.3 = 3

换种方式,先进行四舍五入,再进行求值:

1.7 + 1.6 = 2 + 2 = 4

通过两种运算,我们得到了两个结果3 和4。同理,在我们的浮点数运算中,参与运算的两个数 0.1 和 0.2 精度已经丢失了,所以他们求和的结果已经不是 0.3了。

既然0.3无法精确表示为什么又能得到0.3呢

let i = 0.3;
i === 0.3 // true

为什么x=0.3能得到0.3

首先,你看到的0.3并不是你认为的0.3。因为尾数的固定长度是 52 位,再加上省略的一位,最多可以表示的数是 2^{53}=9007199254740992,这与16个十进制位表示的精度十分接近。

例如,0.3000000000000000055与0.30000000000000000051是相同的都是0.1,这两个数按照64位双精度浮点格式存储与0.1是一样的。

0.3000000000000000055 === 0.3 // true
0.3000000000000000055 === 0.3000000000000000051 // true

由上面可以看到,在双精度的浮点下,整数部分+小数部分的位数一共有 17 位。

当尾数长度是 16时,可以使用 toPrecision(16) 来做精度运算,超过的精度会自动做凑整处理。例如:

(0.10000000000000000555).toPrecision(16) // 返回 0.1

(0.1).toPrecision(21) // 0.100000000000000005551

为什么[-(2^53-1), 2^53-1]为安全的整数区域

在JavaScript中Number有两个静态属性MAX_SAFE_INTEGERMIN_SAFE_INTEGER,分别表示最大的安全的整数型数字 (2^{53} - 1)和最小的安全的整数型数字 (-(2^{53} - 1))。

安全的整数意思就是说在此范围内的整数和双精度浮点数是一一对应的,不会存在一个整数有多个浮点数表示的情况,当然也不会存在一个浮点数对应多个整数的情况。那这两个数值是怎么来的呢?

我们先不考虑符号位和指数位,浮点数的尾数位为52位,不包括省略的1,则可以表示的最大的二进制小数为1.11111...(52个1),推算一下这个数的值,其中整数位为1对应的十进制的值为2^0\times1=1,小数位的值为1/2+1/4+1/8...是一个公比为\frac{1}{2}的等比数列,我们知道等比数列的求和公式为(不会的回去翻翻高中课本)

S_n = \frac{a_nq-a_1}{q-1},(q\neq1)

根据求和公式算出小数位的结果接近0.9999999999999998,加起来就是1.9999999999999998无限的接近2。

再来看指数位,前面已经说过指数位表示小数点移动多少位以生成尾数,每次小数点向前移动时,指数就递增,当指数递增到52时,这时取满了小数位,对应的值为2^52*(1.111111...(52个))对应的十进制整数数为无限的接近2\times2^{52}即为2^{53} - 1

同时指数位为23时也能明确的表明一个整数,对应的表达式为2^{53}\times1.0,那最大的安全整数明明可以到2^{53},不是上面所说的2^{53} - 1呀。不要着急,我们继续往下看,我们来看看2^{53} + 1的值。首先将其转成对应的二进制,这时的尾数为1.000...(52个0)1,由于bit-64浮点数只能存储52位尾数,最后一位1,根据IEEE浮点数舍入规则,向下舍入,此时丢失了精度。最后2^{53}和这两个数2^{53} + 1按照64位双精度浮点格式存储结果是一样的。

Math.pow(2,53) // 9007199254740992
Math.pow(2,53) === Math.pow(2,53) + 1  // true

前面说过安全的整数意思就是说在此范围内的整数和双精度浮点数是一一对应的,而此时不是一一对应的关系,故 [-(2^{53} - 1), 2^{53} - 1]为安全的整数区域。

最后考虑符号位的话最小的安全整数就是-(2^{53} - 1)

我们继续,上面说的只是安全区域,并不代表浮点数能精确存储的最大整数就是-(2^{53} - 1),这是两个概念。我们接下来看看2^{53} + 2的64位双精度浮点格式存储结果,这时的尾数是1.000..(51个0)1,可以完全存储没有丢失精度,继续往下看2^{53} + 3,对应的二进制尾数为1.00..(51个0)11,根据舍入规则,向上舍入,结果为1.00..(50个0)10。也就对应了上面提到的结果:

Math.pow(2,53) + 1 // 9007199254740992
Math.pow(2,53) + 2 // 9007199254740994
Math.pow(2,53) + 3 // 9007199254740996

有兴趣的话,还可以继续研究,指数位为54的情况,以此类推。由此可以看出,IEEE能表示的整数的最大值不止2^{53} - 1,超过这个值也可以表示,只是需要注意精度的问题,使用的时候需要小心。

后续

对于浮点数的缺陷和对应的解法,可以看看这篇文章JavaScript 浮点数陷阱及解法

附录

JavaScript 浮点数陷阱及解法

代码之谜

IEEE754规范的舍入方案