理解了双精度浮点数的格式和含义 以及它在存储时是会损失精度 的,在此基础上,我们继续探索下编程语言是如何读取并显示内存中的 binary64 数据的,以 JavaScript 为例。
继续以十进制的 0.1 为例,我们知道了它在内存中存储的真实数值并不是 0.1 而是 0.100000000000000005551115123126,其 IEEE 754 binary64 的内存存储如下:
但是,在我们的日常开发中,0.1 是总会输出 0.1 的,而不是它在内存中的精确值,除非我们手动指定精度。代码如下:
Copy 0.1 ; // 0.1
Number ( 0.1 ) ; // 0.1
// 当手动指定精度时
Number ( 0.1 ) . toPrecision () ; // '0.1', 参数为空则调 toString()
Number ( 0.1 ) . toPrecision ( 16 ) ; // '0.1000000000000000' = 0.1
Number ( 0.1 ) . toPrecision ( 17 ) ; // '0.10000000000000001' > 0.1
Number ( 0.1 ) . toPrecision ( 18 ) ; // '0.100000000000000006'
Number ( 0.1 ) . toPrecision ( 19 ) ; // '0.1000000000000000056'
Number ( 0.1 ) . toPrecision ( 20 ) ; // '0.10000000000000000555'
Number ( 0.1 ) . toPrecision ( 21 ) ; // '0.100000000000000005551'
Number ( 0.1 ) . toPrecision ( 55 ) ; // '0.1000000000000000055511151231257827021181583404541015625'
Number ( 0.1 ) . toPrecision ( 70 ) ; // '0.1000000000000000055511151231257827021181583404541015625000000000000000'
/**
toPrecision() 方法返回的类型是字符串
(1)若精度参数为空或未定义,则会调用 Number.toString() 方法
(2)若精度不在 [1,100] 之间,则会抛出错误 RangeError
ECMA-262 只需要最多 21 位有效数字的精度
https://262.ecma-international.org/5.1/#sec-15.7.4.7
* */ 也就是说,在我们的日常使用中,编程语言会自动帮我们截取精度,以让显示的数值“看起来”是正确的。
那么,双精度浮点数是按照什么规则来截取精度值的呢?
维基百科arrow-up-right 中这样写道:
The 53-bit significand precision gives from 15 to 17 significant decimal digits precision (2 − 53 2^{−53} 2 − 53 ≈ 1.11 × 10 − 16 10^{−16} 1 0 − 16 ). If a decimal string with at most 15 significant digits is converted to IEEE 754 double-precision representation, and then converted back to a decimal string with the same number of digits, the final result should match the original string. If an IEEE 754 double-precision number is converted to a decimal string with at least 17 significant digits, and then converted back to double-precision representation, the final result must match the original number.
直译就是:IEEE 754 binary64 里的 53 位有效数精度能提供十进制数的 15 到 17 个有效数精度。
如果将一个最多有 15 位有效数字的十进制字符串转成 IEEE 754 双精度表示,然后再转回有相同位数的十进制字符串,则最终的结果应该(should)会和原始字符串相匹配。
如果将一个 IEEE 754 双精度数值转成一个至少有 17 位有效数字的十进制字符串,然后再将它转回双精度表示,则最终的结果一定(must)会和原始数值相匹配。
大意就是:
存十进制数时,如果它的有效数的位数 ≤15,那么计算机就能(should)保证其“存&取”一致。
读内存里的 IEEE 754 binary64 时,如果十进制有效数的位数 ≥17,那么计算机就能(must)保证其“取&存”一致。
这部分内容旨在讨论编程语言是如何读取并显示内存中的 binary64 数据的,所以我们只重点关注第 2 条,即:读内存中的数值时,只要十进制的有效数字是 17 个就能保证不出错(内存里存啥就显示啥)。
来看几个例子感受下。
那么,为什么“16”就能刚好让数值看起来是正确的,而“17”就是一个照妖镜呢?
在回答这个问题之前,我们先来看下二进制的 n 个有效位表示成十进制是什么样子的。
为了更清晰地描述问题,我们将整数部分和小数部分分开讨论,然后分别都从 1 个有效位开始,再逐渐增加有效位的个数,以便观察在这个过程中发生的变化。
2.1.1 有 1 个二进制有效位
当只有 1 个二进制有效位时 x:能表示 2 个十进制数,它们之间的差值是 1。如下:
2.1.2 有 2 个二进制有效位
当有 2 个二进制有效位时 xx:能表示 2 2 2^2 2 2 = 4 个十进制数,它们之间的差值是 1。如下:
其中,* 表示多了一个有效位之后新增的数,新增了 2 ( 10 ) 2_{(10)} 2 ( 10 ) 个,其高位均是 1 ( 2 ) 1_{(2)} 1 ( 2 ) 。
2.1.3 有 3 个二进制有效位
当有 3 个二进制有效位时 xxx:能表示 2 3 2^3 2 3 = 8 个十进制数,它们之间的差值是 1。如下:
其中,* 表示多了一个有效位之后新增的数,新增了 4 ( 10 ) 4_{(10)} 4 ( 10 ) = 2 2 2^2 2 2 个,其高位均是 1 ( 2 ) 1_{(2)} 1 ( 2 ) 。
综上,利用数学归纳法可得出以下结论:当整数部分有 n 个二进制有效位时
可以表示 2 n 2^n 2 n 个十进制数,且它们是个等差数列,差值是 1
每增加 1 个二进制有效位,就会新增 2 n − 1 2^n-1 2 n − 1 个十进制数(即高位为 1 的数值)
2.2.1 有 1 个二进制有效位
当只有 1 个二进制有效位时 0.x:能表示 2 个十进制数,它们之间的差值是 2 − 1 2^{-1} 2 − 1 = 0.5。如下:
2.2.2 有 2 个二进制有效位
当有 2 个二进制有效位时 0.xx:能表示 2 2 2^2 2 2 = 4 个十进制数,它们之间的差值是 2 − 2 2^{-2} 2 − 2 = 0.25。如下:
其中,* 表示多了一个有效位之后新增的数,新增了 2 ( 10 ) 2_{(10)} 2 ( 10 ) 个,其末位均是 1 ( 2 ) 1_{(2)} 1 ( 2 ) 。
2.2.3 有 3 个二进制有效位
当有 3 个二进制有效位时 0.xxx:能表示 2 3 2^3 2 3 = 8 个十进制数,它们之间的差值是 2 − 3 2^{-3} 2 − 3 = 0.125。如下:
其中,* 表示多了一个有效位之后新增的数,新增了 4 ( 10 ) 4_{(10)} 4 ( 10 ) = 2 2 2^2 2 2 个,其末位均是 1 ( 2 ) 1_{(2)} 1 ( 2 ) 。
2.2.4 有 4 个二进制有效位
当有 4 个二进制有效位时 0.xxxx:能表示 2 4 2^4 2 4 = 16 个十进制数,它们之间的差值是 2 − 4 2^{-4} 2 − 4 = 0.0625。如下:
其中,* 表示多了一个有效位之后新增的数,新增了 8 ( 10 ) 8_{(10)} 8 ( 10 ) = 2 3 2^3 2 3 个,其末位均是 1 ( 2 ) 1_{(2)} 1 ( 2 ) 。
综上,利用数学归纳法可得出结论:当小数部分有 n 个二进制有效位时
可以表示 2 n 2^n 2 n 个十进制数,且它们是个等差数列,差值是 2 − n 2^{-n} 2 − n ,数列范围是 [2 − n 2^{-n} 2 − n , 1)
与纯整数不同,二进制只能精准地表示一部分十进制小数 k 2 n \frac{k}{2^n} 2 n k ,其中 0 ≤ k < 2 n 2^n 2 n - 1,k 是整数
而其它小数只能是一个无限逼近真实值的二进制值,随着 n 越来越大时
每增加 1 个二进制有效位,就会新增 2 n − 1 2^{n-1} 2 n − 1 个十进制数(即末位为 1 的数值)
当有 n 个二进制有效位(可能同时包含整数部分和小数部分)时:
可以表示 2 n 2^n 2 n 个十进制数,且会形成一个等差数列
小数部分的差值是 2 − m 2^{-m} 2 − m ,小数将是“不连续”的(相对整数而言)
每增加 1 个二进制有效位,就会新增 2 n − 1 2^{n-1} 2 n − 1 个十进制数
以上结论,借鉴到 IEEE 754 双精度浮点数上就是:
IEEE 754 双精度浮点数能表示的小数们只有少部分是精准无误的,其它的都只能是一个无限逼近真实值的二进制数值。
IEEE 754 双精度浮点数的精度只和 53 个有效位有关,和指数无关。虽然小数点的位置可以随着指数的值向左/向右“无限”移动,但因为有效位只有 53 个,所以只能用 0 来补齐,反过来说就是我们没法弥补被舍弃位置本可以是 1 的情况。
IEEE 754 binary64 的存储格式就意味着:
指数决定了数值的量级,即最终值的绝对值是超级无敌小,还是超级无敌大
假设二进制指数的实际值是 e,最终的十进制数值为 x,那么:
当 e=0 时,|x| ∈ [1,2),有整数有小数
接下来,让我们看看当有 n 个二进制位时,它能表示多少个十进制数,以及十进制数的有效位的情况。
3. 二进制与十进制的对应关系
二进制的不同位数,能表示的最大十进制数。如下:
2 17 2^{17} 2 17 - 1 = 131071
从上表可以看出,二进制的 n 个有效位可以表示十进制的 m 个有效位,用公式表示就是:
10 m − 1 10^{m-1} 1 0 m − 1 < 2 n 2^n 2 n - 1 < 10 m 10^m 1 0 m ,即 m-1 < lg(2 n 2^n 2 n - 1) < m
所以,在 IEEE 754 binary64 里,当 53 个有效位均为整数部分时,lg(2 53 2^{53} 2 53 -1) < 16,即它能表示的十进制将最多有 16 个有效数字。
二进制的不同位数,能表示的十进制数列的差值。如下:
2 − 1 2^{-1} 2 − 1 = 0.5 = 5e-1
2 − 2 2^{-2} 2 − 2 = 0.25 = 2.5e-1
2 − 3 2^{-3} 2 − 3 = 0.125 = 1.25e-1
2 − 4 2^{-4} 2 − 4 = 0.0625 = 6.25e-2
2 − 5 2^{-5} 2 − 5 = 0.03125 = 3.125e-2
2 − 6 2^{-6} 2 − 6 = 0.015625 = 1.5625e-2
2 − 7 2^{-7} 2 − 7 = 0.0078125 = 7.8125e-3
2 − 8 2^{-8} 2 − 8 = 0.00390625 = 3.90625e-3
2 − 9 2^{-9} 2 − 9 = 0.001953125 = 1.953125e-3
2 − 10 2^{-10} 2 − 10 = 0.0009765625 = 9.765625e-4
从上表可以看出,二进制的 n 个有效位可以表示的十进制数列的差值的取值范围 10 − m 10^{-m} 1 0 − m < 2 − n 2^{-n} 2 − n ,也就是说至少需要 m 个十进制有效位。最多需要几个呢?答案是 n 个,因为差值的小数点后有几位,能表示的十进制数列的有效数字最多就有几位。
所以,在 IEEE 754 binary64 里,当 53 个有效位均为小数部分时,2 − 53 2^{-53} 2 − 53 ≈ -1.11e-16 > 10 − 16 10^{-16} 1 0 − 16 ,即它能表示的十进制小数最少要有 16 个有效数字。
而不连续的小数 ,需要再多 1 位有效数字来进行四舍五入,故小数部分需要 17 个有效数字。
这部分内容虽然篇幅较长,但其实就解释了一句话,就是:读内存中的 IEEE 754 binary64 数值时,只要十进制的有效数字是 17 个就能保证“内存里存啥就显示啥”。用代码表述就是 toPrecision(17) 能显示出内存中的真实值,而 toPrecision(16) 能让小数“看起来”是对的。
IEEE 754 binary64 里的 53 位有效数精度能提供十进制数的 15 到 17 个有效数精度。
整数部分,有效位最多 16 位。2 53 − 1 2^{53}-1 2 53 − 1 ≈ 9 ∗ 10 15 9*10^{15} 9 ∗ 1 0 15 < 10 16 10^{16} 1 0 16
小数部分,有效位最少 16 位,保险 17 位。10 − 16 10^{-16} 1 0 − 16 < 2 − 53 2^{-53} 2 − 53 ≈ 1.11e-16