从计组课到前端深坑:IEEE 754双精度浮点数的那些事

温故而知新。

从 IEEE 754 标准说到经典的 0.1 + 0.2 = 0.30000000000000004

JavaScript 遵循 IEEE 754 规范中的双精度浮点数标准,在内存中使用 64bit 储存 Number 型数据,这样的存储结构优点是可以归一化处理整数和小数,节省存储空间。我们先来复习一下这个规范中的相关内容:

计组课 Recap

因为双精度浮点数涉及的都是二进制的运算,用二进制科学计数法表示浮点数。其中尾数 Mantissa也称有效数位,即对表达结果的精确程度产生重要影响的数。因使用了科学计数法,表达式实数部分永远等于1,意味着在双精度浮点数的表示里可以舍掉这一位,只保留后面的小数部分,换取更大的精度。

双精度浮点数的具体储存格式如下:

  • 最高位是符号位(sign bit) S,0为正,1为负
  • 指数偏移值部分 E,exponent = E - bias,其中 $bias = 2^{N - 1} - 1 = 1023$
  • 有效数(significand)的小数部分 M,默认省略了科学计数法实数部分的1,则 Mantissa = M + 1。

根据以上公式,我们可以从储存格式中直接得出该浮点数的十进制值:
$$(–1)^S \times 2^{E – bias} \times (M + 1)$$

那么我们如何来表示一个十进制浮点数如6.75呢?

$$
6.75
= 4 + 2 + 0.5 + 0.25
= 2^2 + 2^1 + 2^{-1} + 2^{-2}
= 110.11_2
= +1.1011_2 \times 2^2
$$

由上可得,符号 S = 0,E = exponent + bias = 2 + 1023 = 1025 = $100000000001_2$ , M = $1.1011_2$ - 1 = $.1011_2$ 在系统中二进制储存为

1
0 100000000001 1011000000000000000000000000000000000000000000000000

0.30000000000000004

俗话说得好,罗马不是一天建成的。由于64位精度限制,十进制中绝大部分的浮点数无法精确在遵循 IEEE 754 双精度浮点数标准下的平台储存。一旦储存过程发生了精度缺失,运算的过程中每一步的微小误不断积累,由近似值表示产生的四则运算也就无法保证正确的答案了。

我们来做个实验,借助IEEE 754 双精度浮点数可视化工具,依然以浏览器返回 0.1+0.2 的结果为例,0.30000000000000004 的 M 部分 是由内存中 0.1 的 M 部分与 0.2 的 M 部分进行二进制相加。

浮点数的精度丢失在每一个表达式,而不仅仅是表达式的求值结果。

Number.MAX_SAFE_INTEGER & Number.MAX_VALUE

在 JavaScript 中,无论是整数或者浮点数都是使用 Number 类储存,遵循 IEEE 754 规范中的双精度浮点数标准,因此一共可使用的 Mantissa 位数 = (M + 1) = 53,可表示的整数范围为 $-(2 ^ {53} - 1)$ ~ $2 ^ {53} - 1$,在 Number 类中 Number.MIN_SAFE_INTEGER 为 $-(2 ^ {53} - 1)$ 即 -9007199254740991,Number.MAX_SAFE_INTEGER 为 $2 ^ {53} - 1$ 即 9007199254740991。

那么 Number.MAX_VALUE 表示的值又是怎么的出来的呢?在标准中,需要指出特殊值:如果指数 = $2^{E}-1$ 并且尾数的小数部分是0,这个数是±∞(和符号位相关)。也就是说可表示最大浮点数数的二进制表现是指数 = $2^{E}-2$,小数部分 = $2^{M}-1$,转为十进制则为 1.7976931348623157e+308

那么,使用超过 Number.MAX_SAFE_INTEGER 的数会发生什么呢?

大数危机

涉及到超过 Number.MAX_SAFE_INTEGER 的运算如 $2 ^ {53} + 4$、
$2 ^ {60}$,得到的结果在系统中已经无法用52位有效数表示了,可表示的整数发生了溢出。我们以 9007199254740994 为例:

尾数发生溢出后,需要使用 53 位有效数表示科学计数法的小数位,则 E = exponent + bias = 53 + 1023 = 1076 = $10000110100_2$,而 M 因为发生了溢出,最后一位被截断,在有效数的表示中少了最后一位

1
2
3
4
5
// 本应展示的 M 
00000000000000000000000000000000000000000000000000010

// 发生溢出后被截断的 M
0000000000000000000000000000000000000000000000000001

因此,系统无法展现最后一位为1的数字(所有大于$2 ^ {53}$奇数),如:

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

同理,使用大于 Number.MAX_SAFE_INTEGER 的数时,当2的指数为 $N (N \geq 53)$,有效数的表示就截断 $N - 53 + 1$ 位,此时只能表达 $2^{N - 53 + 1}$ 的倍数。IEEE 754 双精度浮点数中可表示的浮点数是实数集的子集,下图很好地表示了他们之间的关系,量级越大,可表示的浮点数越少:

因此,当日常业务中涉及了大数时,我们尽量使用类似 bignumber.js 这样的类库来处理。截至19年4月,JavaScript 中关于原生 BigInt 类的提案已进入了 Stage 3,Chrome 67 和 Firefox 68 以上版本已支持,在工程中可使用 @babel/plugin-syntax-bigint 插件进行语法兼容。

JS表示的有理数究竟有多少位有效数字?

在前面我们看到了JS最大精确(安全)整数有16位有效数字,有些小伙伴就认为“JS 最多能表示的精度就是16位”,这样的说法是不准确的,因为在浏览器控制台里, 0.1 + 0.2 显示 0.30000000000000004, 小数点后却有17位;我们再来看一些其他随机的例子:

1
2
3
100.27 * 0.41 = 41.110699999999994  // 17位有效数字
0.95 * 1 / 3 = 0.31666666666666665 // 17位有效数字
300.73 - 300 = 0.7300000000000182 // 16位有效数字

事实上以上例子打印出的结果均是真实算数结果的近似值,然而却有不同的有效数字位;在浏览器控制台里输入 0.1 ,而我们也明白这个不是精确的值,而控制台依然显示的是 0.1,不显示 0.10000000000000001。很明显,在控制台输出的浮点数近似值都是经过一定的规则来截断的。同时这种表现不仅限于 JS,大家可以在 Terminal 中尝试一下,所有采用 IEEE 754 双精度浮点数标准的语言如 Node 和 Python 都有同样的表现。这就引出了这篇文章最初诞生的原因,源于小伙伴在群里提了这样一个问题:IEEE 754是按照什么规则来实现双精度浮点数的截断的?

在 IEEE 754 规范的Wiki页面里看到对浮点数和十进制字符串转换的描述有这么一段

The standard requires operations to convert between basic formats and external character sequence formats. Conversions to and from a decimal character format are required for all formats. Conversion to an external character sequence must be such that conversion back using round to even will recover the original number. There is no requirement to preserve the payload of a NaN or signaling NaN, and conversion from the external character sequence may turn a signaling NaN into a quiet NaN. The original binary value will be preserved by converting to decimal and back again using 17 decimal digits for binary64.

IEEE 754 规定,浮点数被转成十进制数字字符串,当这个字符串(使用 Round to Even 向偶舍入)转回浮点数时,必须要跟原来的数相同。对双精度浮点数来说,十进制字符串使用17位有效数字即可保存原始二进制值。

我们来做个实验,以 0.1 为例,它在内存中的二进制表示实际上可以计算为

$$
0.1
= (-1)^0 \times 2^{1019 - 1023} \times (1 + 2^{-1} + 2^{-4} + 2^{-5} + 2^{-8} + 2^{-9} + 2^{-12} + 2^{-13} + 2^{-16} + 2^{-17} + 2^{-20} + 2^{-21} + 2^{-24} + 2^{-25} + 2^{-28} + 2^{-29} + 2^{-32} + 2^{-33} + 2^{-36} + 2^{-37} + 2^{-40} + 2^{-41} + 2^{-44} + 2^{-45} + 2^{-48} + 2^{-49} + 2^{-51})
= 0.1000000000000000055511151231257827021181583404541015625
$$

当我们获取它的17位(经过舍入的)有效数为 0.10000000000000001,那为什么控制台不显示 0.10000000000000001 而显示 0.1?事实上,有许多不同的十进制数共享相同的最接近的近似二进制小数,在这个例子里,0.1、 0.10000000000000001、 0.1000000000000000055511151231257827021181583404541015625 分别在内存中的 64bit 都是完全相同的,在大多数系统上现在能够选择这些表示中最短的来展示,也就是0.1。因此可以推断出:截断判断的依据是截断后的数在console里打印成字符串,这个字符串再转回浮点数后,是否还是同一个数。

说人话就是:而console里打印出来的,就是可以表示这个浮点数的最短的字符串!

这就解释了为啥console里有些浮点数的计算得出17位有效位,有些只有16位,有些直接显示自己本身。

相关阅读