3. 真实的值(读时)

编程语言是如何读取并显示内存中的 binary64 数据的

理解了双精度浮点数的格式和含义以及它在存储时是会损失精度的,在此基础上,我们继续探索下编程语言是如何读取并显示内存中的 binary64 数据的,以 JavaScript 为例。

继续以十进制的 0.1 为例,我们知道了它在内存中存储的真实数值并不是 0.1 而是 0.100000000000000005551115123126,其 IEEE 754 binary64 的内存存储如下:

但是,在我们的日常开发中,0.1 是总会输出 0.1 的,而不是它在内存中的精确值,除非我们手动指定精度。代码如下:

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
 **/

也就是说,在我们的日常使用中,编程语言会自动帮我们截取精度,以让显示的数值“看起来”是正确的。

那么,双精度浮点数是按照什么规则来截取精度值的呢?

1. 截取精度的规则

维基百科中这样写道:

The 53-bit significand precision gives from 15 to 17 significant decimal digits precision (2532^{−53} ≈ 1.11 × 101610^{−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 个有效数精度。

  1. 如果将一个最多有 15 位有效数字的十进制字符串转成 IEEE 754 双精度表示,然后再转回有相同位数的十进制字符串,则最终的结果应该(should)会和原始字符串相匹配。

  2. 如果将一个 IEEE 754 双精度数值转成一个至少有 17 位有效数字的十进制字符串,然后再将它转回双精度表示,则最终的结果一定(must)会和原始数值相匹配。

大意就是:

  1. 存十进制数时,如果它的有效数的位数 ≤15,那么计算机就能(should)保证其“存&取”一致。

  2. 读内存里的 IEEE 754 binary64 时,如果十进制有效数的位数 ≥17,那么计算机就能(must)保证其“取&存”一致。

这部分内容旨在讨论编程语言是如何读取并显示内存中的 binary64 数据的,所以我们只重点关注第 2 条,即:读内存中的数值时,只要十进制的有效数字是 17 个就能保证不出错(内存里存啥就显示啥)。

来看几个例子感受下。

Number(0.2); // 0.2
Number(0.2).toPrecision(16); // '0.2000000000000000' = 0.2
Number(0.2).toPrecision(17); // '0.20000000000000001' > 0.2

Number(1.005); // 1.005
Number(1.005).toPrecision(16); // '1.005000000000000' = 1.005
Number(1.005).toPrecision(17); // '1.0049999999999999' < 1.005

那么,为什么“16”就能刚好让数值看起来是正确的,而“17”就是一个照妖镜呢?

在回答这个问题之前,我们先来看下二进制的 n 个有效位表示成十进制是什么样子的。

2. 从二进制到十进制

为了更清晰地描述问题,我们将整数部分和小数部分分开讨论,然后分别都从 1 个有效位开始,再逐渐增加有效位的个数,以便观察在这个过程中发生的变化。

2.1 纯整数

2.1.1 有 1 个二进制有效位

当只有 1 个二进制有效位时 x:能表示 2 个十进制数,它们之间的差值是 1。如下:

二进制
十进制

0

0

1

1

2.1.2 有 2 个二进制有效位

当有 2 个二进制有效位时 xx:能表示 222^2 = 4 个十进制数,它们之间的差值是 1。如下:

二进制
十进制

00

0

01

1

10*

2*

11*

3*

其中,* 表示多了一个有效位之后新增的数,新增了 2(10)2_{(10)} 个,其高位均是 1(2)1_{(2)}

2.1.3 有 3 个二进制有效位

当有 3 个二进制有效位时 xxx:能表示 232^3 = 8 个十进制数,它们之间的差值是 1。如下:

二进制
十进制

000

0

001

1

010

2

011

3

100*

4*

101*

5*

110*

6*

111*

7*

其中,* 表示多了一个有效位之后新增的数,新增了 4(10)4_{(10)} = 222^2 个,其高位均是 1(2)1_{(2)}

2.1.4 小结

综上,利用数学归纳法可得出以下结论:当整数部分有 n 个二进制有效位时

  1. 可以表示 2n2^n 个十进制数,且它们是个等差数列,差值是 1

  2. 每增加 1 个二进制有效位,就会新增 2n12^n-1 个十进制数(即高位为 1 的数值)

2.2 纯小数

2.2.1 有 1 个二进制有效位

当只有 1 个二进制有效位时 0.x:能表示 2 个十进制数,它们之间的差值是 212^{-1} = 0.5。如下:

二进制
十进制

0.0

0

0.1

0.5

2.2.2 有 2 个二进制有效位

当有 2 个二进制有效位时 0.xx:能表示 222^2 = 4 个十进制数,它们之间的差值是 222^{-2} = 0.25。如下:

二进制
十进制

0.00

0

0.01*

0.25*

0.10

0.5

0.11*

0.75*

其中,* 表示多了一个有效位之后新增的数,新增了 2(10)2_{(10)} 个,其末位均是 1(2)1_{(2)}

2.2.3 有 3 个二进制有效位

当有 3 个二进制有效位时 0.xxx:能表示 232^3 = 8 个十进制数,它们之间的差值是 232^{-3} = 0.125。如下:

二进制
十进制

0.000

0

0.001*

0.125*

0.010

0.25

0.011*

0.375*

0.100

0.5

0.101*

0.625*

0.110

0.75

0.111*

0.875*

其中,* 表示多了一个有效位之后新增的数,新增了 4(10)4_{(10)} = 222^2 个,其末位均是 1(2)1_{(2)}

2.2.4 有 4 个二进制有效位

当有 4 个二进制有效位时 0.xxxx:能表示 242^4 = 16 个十进制数,它们之间的差值是 242^{-4} = 0.0625。如下:

二进制
十进制

0.0000

0

0.0001*

0.0625*

0.0010

0.125

0.0011*

0.1875*

0.0100

0.25

0.0101*

0.3125*

0.0110

0.375

0.0111*

0.4375*

0.1000

0.5

0.1001*

0.5625*

0.1010

0.625

0.1011*

0.6875*

0.1100

0.75

0.1101*

0.8125*

0.1110

0.875

0.1111*

0.9375*

其中,* 表示多了一个有效位之后新增的数,新增了 8(10)8_{(10)} = 232^3 个,其末位均是 1(2)1_{(2)}

2.2.5 小结

综上,利用数学归纳法可得出结论:当小数部分有 n 个二进制有效位时

  1. 可以表示 2n2^n 个十进制数,且它们是个等差数列,差值是 2n2^{-n},数列范围是 [2n2^{-n}, 1)

    • 与纯整数不同,二进制只能精准地表示一部分十进制小数 k2n\frac{k}{2^n},其中 0 ≤ k < 2n2^n - 1,k 是整数

    • 而其它小数只能是一个无限逼近真实值的二进制值,随着 n 越来越大时

  2. 每增加 1 个二进制有效位,就会新增 2n12^{n-1} 个十进制数(即末位为 1 的数值)

2.3 小结

当有 n 个二进制有效位(可能同时包含整数部分和小数部分)时:

  1. 可以表示 2n2^n 个十进制数,且会形成一个等差数列

    • 整数部分的差值是 1

    • 小数部分的差值是 2m2^{-m},小数将是“不连续”的(相对整数而言)

  2. 每增加 1 个二进制有效位,就会新增 2n12^{n-1} 个十进制数

2.4 双精度浮点数

以上结论,借鉴到 IEEE 754 双精度浮点数上就是:

  1. IEEE 754 双精度浮点数能表示的小数们只有少部分是精准无误的,其它的都只能是一个无限逼近真实值的二进制数值。

  2. IEEE 754 双精度浮点数的精度只和 53 个有效位有关,和指数无关。虽然小数点的位置可以随着指数的值向左/向右“无限”移动,但因为有效位只有 53 个,所以只能用 0 来补齐,反过来说就是我们没法弥补被舍弃位置本可以是 1 的情况。

IEEE 754 binary64 的存储格式就意味着:

  • 符号位决定了数值的正负

  • 指数决定了数值的量级,即最终值的绝对值是超级无敌小,还是超级无敌大

  • 有效位决定了数值有效数字的个数

假设二进制指数的实际值是 e,最终的十进制数值为 x,那么:

  • 当 e<0 时,|x| ∈ (0,1),纯小数

  • 当 e=0 时,|x| ∈ [1,2),有整数有小数

  • 当 e>0 时,|x| ≥ 2,有整数有小数

  • 当 e≥52 时,|x| 就是纯整数了

接下来,让我们看看当有 n 个二进制位时,它能表示多少个十进制数,以及十进制数的有效位的情况。

3. 二进制与十进制的对应关系

3.1 整数部分

二进制的不同位数,能表示的最大十进制数。如下:

二进制
十进制最大数
十进制数的范围

1

212^1 - 1 = 1

< 1e1

11

222^2 - 1 = 3

< 1e1

111

232^3 - 1 = 7

< 1e1

1111

242^4 - 1 = 15

< 1e1 < 1e2

11111

252^5 - 1 = 31

< 1e2

111111

262^6 - 1 = 63

< 1e2

1111111

272^7 - 1 = 127

< 1e2 < 1e3

11111111

282^8 - 1 = 255

< 1e3

111111111

292^9 - 1 = 511

< 1e3

1111111111

2102^{10} - 1 = 1023

< 1e3 < 1e4

11111111111

2112^{11} - 1 = 2047

< 1e4

111111111111

2122^{12} - 1 = 4095

< 1e4

1111111111111

2132^{13} - 1 = 8191

< 1e4

11111111111111

2142^{14} - 1 = 16383

< 1e4 < 1e5

111111111111111

2152^{15} - 1 = 32767

< 1e5

1111111111111111

2162^{16} - 1 = 65535

< 1e5

11111111111111111

2172^{17} - 1 = 131071

< 1e5 < 1e6

从上表可以看出,二进制的 n 个有效位可以表示十进制的 m 个有效位,用公式表示就是:

10m110^{m-1} < 2n2^n - 1 < 10m10^m,即 m-1 < lg(2n2^n - 1) < m

所以,在 IEEE 754 binary64 里,当 53 个有效位均为整数部分时,lg(2532^{53}-1) < 16,即它能表示的十进制将最多有 16 个有效数字。

2 ** 53 - 1; // 9007199254740991 ≈ 9e15 < 1e16
Math.log10(2 ** 53 - 1); // 15.954589770191003 < 16

3.2 小数部分

二进制的不同位数,能表示的十进制数列的差值。如下:

二进制
十进制差值
差值的范围

0.1

212^{-1} = 0.5 = 5e-1

> 0.1 = 1e-1

0.01

222^{-2} = 0.25 = 2.5e-1

> 0.1 = 1e-1

0.001

232^{-3} = 0.125 = 1.25e-1

> 0.1 = 1e-1

0.0001

242^{-4} = 0.0625 = 6.25e-2

> 0.01 = 1e-2

0.00001

252^{-5} = 0.03125 = 3.125e-2

> 0.01 = 1e-2

0.000001

262^{-6} = 0.015625 = 1.5625e-2

> 0.01 = 1e-2

0.0000001

272^{-7} = 0.0078125 = 7.8125e-3

> 0.001 = 1e-3

0.00000001

282^{-8} = 0.00390625 = 3.90625e-3

> 0.001 = 1e-3

0.000000001

292^{-9} = 0.001953125 = 1.953125e-3

> 0.001 = 1e-3

0.0000000001

2102^{-10} = 0.0009765625 = 9.765625e-4

> 0.0001 = 1e-4

从上表可以看出,二进制的 n 个有效位可以表示的十进制数列的差值的取值范围 10m10^{-m} < 2n2^{-n},也就是说至少需要 m 个十进制有效位。最多需要几个呢?答案是 n 个,因为差值的小数点后有几位,能表示的十进制数列的有效数字最多就有几位。

所以,在 IEEE 754 binary64 里,当 53 个有效位均为小数部分时,2532^{-53} ≈ -1.11e-16 > 101610^{-16},即它能表示的十进制小数最少要有 16 个有效数字。

2 ** -53; // 1.1102230246251565e-16

不连续的小数,需要再多 1 位有效数字来进行四舍五入,故小数部分需要 17 个有效数字。

4. 总结

这部分内容虽然篇幅较长,但其实就解释了一句话,就是:读内存中的 IEEE 754 binary64 数值时,只要十进制的有效数字是 17 个就能保证“内存里存啥就显示啥”。用代码表述就是 toPrecision(17) 能显示出内存中的真实值,而 toPrecision(16) 能让小数“看起来”是对的。

Number(0.1); // 0.1
Number(0.1).toPrecision(16); // '0.1000000000000000'  = 0.1
Number(0.1).toPrecision(17); // '0.10000000000000001' > 0.1

Number(0.2); // 0.2
Number(0.2).toPrecision(16); // '0.2000000000000000' = 0.2
Number(0.2).toPrecision(17); // '0.20000000000000001' > 0.2

Number(1.005); // 1.005
Number(1.005).toPrecision(16); // '1.005000000000000' = 1.005
Number(1.005).toPrecision(17); // '1.0049999999999999' < 1.005

IEEE 754 binary64 里的 53 位有效数精度能提供十进制数的 15 到 17 个有效数精度。

  • 整数部分,有效位最多 16 位。25312^{53}-1910159*10^{15} < 101610^{16}

  • 小数部分,有效位最少 16 位,保险 17 位。101610^{-16} < 2532^{-53} ≈ 1.11e-16

Last updated