Open anjia opened 2 years ago
关于指数,上面我们只重点介绍了它的“差值”,即 2k-1-1 = 211-1-1 = 210-1 = 1023,因为这和十进制数的存储和读取规则直接相关。
接下来,我们重点讨论下指数的编码。
指数的实际范围是从 -1022 到 +1023。如下:
11 位指数 e | 实际指数 | 表示 |
---|---|---|
00000000001(2) = 1(10) | 2e-1023 = 2-1022 | 最小指数 |
01111111111(2) = 1023(10) | 21023-1023 = 20 | 零偏移 |
11111111110(2) = 2046(10) | 22046-1023 = 21023 | 最大指数 |
对于所有的 IEEE 754 格式,指数的 emin = 1 − emax
当指数全 0 和全 1 时,是为特殊数字保留的。如下:
11 位指数 e | 实际指数 | 表示 |
---|---|---|
00000000000(2) | 2-1022 | ±0 或 次正规数 |
11111111111(2) | 无 | ±Infinity 或 NaN |
若 52 个有效位全是 0,则表示有符号的 ±0
。
若 52 个有效位不全是 0,则表示“次正规数”。此时会将有效数小数点前默认的 1 变成 0,然后实际指数按照指数的最小值(即 -1022)来解释。这么做的目的是通过调整指数来去除有效数的前导 1,以此来表示比最小的“正常数”(相对次正规数而言)更接近 0 的数字。
这样就能填补浮点运算中 0 附近的下溢间隙了,进而避免“即使两个数值不相等,减法 a - b 也会下溢并产生 0”的情况(达到下溢时,会丢弃所有有效数字,然后就突然变 0 了)。所以,次正规数有时也被称为逐渐下溢,因为它允许(值非常小的)计算结果慢慢地失去精度。
次正规数可以保证浮点数的加减法永远不会下溢,两个相邻的浮点数总有一个可以表示的非零差。
在 IEEE 754-2008 中,非正规数(denormal numbers)被重命名为“次正规数”(subnormal numbers)。
若 52 个有效位全是 0,则表示有符号的 ±Infinity
。
若 52 个有效位不全是 0,则表示 NaN
。
有效位全 0 | 有效位不全 0 | |
---|---|---|
指数全 0 | 表示 ±0 |
表示次正规数,此时: - 有效位的默认前导位由 1 变 0 - 指数按最小指数来解释,即 -1022 |
指数全 1 | 表示 ±Infinity |
表示 NaN |
所以,IEEE 754 binary64 真实值的完整情况应该是:
±0
±Infinity
NaN
00...00 表示显式存储的 52 个有效位全是 0
数字有精度损失(不能被精准表示),其实并不需要大惊小怪。
只是我们熟悉且默认了十进制世界里的“除不尽/无限小数”的表述方式,比如 1/3 = 0.333...3,π = 3.1415926535897931...,而到了二进制的世界里,第一反应却是“欸?”。
与其说我们是惊叹于“浮点数在内存中竟然会损失精度?!”,倒不如说是还不习惯二进制的世界。大约就是在说起“(十进制里的)这小数(到了二进制里)怎么就成无限的(不能被精准表示)了呢”时,会默认省掉括号里的进制数。
如果能稍微静下心来回忆下十进制里的“无限小数”,大概就能心平气和地接受二进制里的“不那么精准”了。
有理数 | 十进制小数 | 小数备注 |
---|---|---|
1/3 | 0.333...3 | 3 循环 |
1/6 | 0.1666...6 | 6 循环 |
1/7 | 0.142857...142857 | 142857 循环 |
1/9 | 0.111...1 | 1 循环 |
2/3 | 0.666...6 | 6 循环 |
2/7 | 0.2857142857...142857 | 142857 循环 |
2/9 | 0.222...2 | 2 循环 |
12/13 | 0.923076...923076 | 923076 循环 |
... |
实数和数轴上的点是一一对应的,我们可以将其直观地看成是有限小数和无限小数的集合,但却无法用枚举的方式来描述实数这个整体。实数分为有理数和无理数:有理数就是分数(整数即分母为 1 的分数),比如 0、1、0.5、1/5、1/3 等;不是有理数的实数都是无理数,无理数也称无限不循环小数,比如 π、欧拉数 e、黄金比例 φ、根号 2 等。
浮点数是有理数,因为它可以表示成两个整数相除的形式,比如 1.65 * 104 = (165 / 102) * 104,基数决定了分母的值。然而无论基数是多少,都无法避免有理数中出现“无限”循环小数的情况。比如 1/5 能用十进制精准表示,却不能被二进制浮点数精准表示:1/5 = 0.2(10) = 0.0011...0011(2)。1/3 既不能用十进制精准表示,也不能用二进制精准表示,但却能被三进制精准表示:1/3 = 0.33...33(10) = 0.01...01(2) = 0.1(3) 或 3-1。
目前,在计算机中表示“实数的近似值”的最常见的方式就是浮点表示。IEEE 754 标准提供了很多如何设计浮点数及其运算的规则,旨在既能为专家提供复杂的数值库,又能为程序员提供安全可靠的默认值。
当然,实数的表示也有其它替代方案。比如定点表示、对数系统、任意精度浮点运算、浮点扩展、Mathematica。比如将数字表示为具有整数分子和分母的分数形式,这样就能准确地表示任何有理数了(此时需要对单个整数使用 bignum 算法)。
下图是浮点数到实数的映射,中间有覆盖不到的小断层哦。
浮点数的范围是“线性地”取决于有效数的范围,“指数式”地取决于指数的范围(指数为数字附加了非常广的范围)。
在日常算术中,我们常常需要保留数值的 n 位有效数字或者是保留小数点后的 n 位数字。比如,将以下数值保留 4 位有效数字:
不知大家是否有注意到,在我们给数值“保留 4 位有效数字”的时候,有的就已经在丢失精度了!
不同的是,这里我们是有意识地“主动”丢掉精度,而在浮点数的表示里,是计算机在帮我们“默默地”裁剪精度,根据其特定的有效位个数(比如,IEEE 754 binary64 就只能保留 53 个有效数字)。
在浮点数里,损失精度的情况也类似。情况包括但不限于:
所以,就“损失精度”而言,二进制和十进制的本质是一样的,只是无限循环的有理数的集合变了而已。
之所以绕绕弯弯地用十进制来解释精度损失的情况,是因为它是浮点数表示和运算的基础。而且后面的什么上溢、下溢、大数危机、最大数和最大安全数等听起来比较唬人的概念,都和它有直接关系。
最重要的是,一旦我们用熟悉的十进制来理解这些,一切逻辑就都显得顺理成章自然而然了,理解起来会轻松很多。
IEEE 754 定义了 5 种基本的浮点异常,分别是:无效运算、除以零、上溢(指数太大)、下溢(指数太小)和不精确。如下:
IEEE 异常 | 原因 | 示例 | 缺省结果 |
---|---|---|---|
无效运算 | 对于将要执行的运算, 某个操作数无效 |
0 / 0 0 × ∞ ∞ / ∞ 负数的平方根 NaN 参与的任何运算无效转换 |
NaN |
除以零 | 针对有限操作数执行运算时 生成精确的无穷大结果 |
非零 x / 0 log(0) |
带正确符号的无穷大 |
上溢 | 正确舍入的结果的指数, 超过了能表示的最大指数值 |
双精度示例: 21024 e709.8 MAX_VALUE * 2 |
取决于舍入模式和 中间结果的符号 |
下溢 | 精确结果或正确舍入的结果 比能表示的“最小正规数”小 |
双精度示例: 2-1074 2-1075 |
返回次正规数或 0 |
不精确 | 有效运算的舍入结果 和无限精确结果不相等。 即精度被裁剪了 |
1/3 log(1.1) |
返回该运算的结果 (舍入/上溢/下溢) |
前三个(无效运算、除以零、上溢)一旦出现,很少被忽略。后两个(下溢、不精确)通常可以安全地忽略(尽管不总是)。实际上,大多数浮点运算都会导致“不精确”异常,详见第 3 部分 浮点数的精度损失/实数的近似值。
异常的优先顺序是:无效运算 > 上溢 > 除以零 > 下溢 > 不精确。能够在单个运算中同时发生的标准异常只有不精确的上溢和不精确的下溢这两种组合。
缺省结果: NaN
0 / 0; // NaN
0 / -0; // NaN
-0 / 0; // NaN
-0 / -0; // NaN
0 * Infinity; // NaN
0 * -Infinity; // NaN
-0 * Infinity; // NaN
-0 * -Infinity; // NaN
Infinity / Infinity; // NaN
Infinity / -Infinity; // NaN
-Infinity / Infinity; // NaN
-Infinity / -Infinity; // NaN
Math.sqrt(-1); // NaN
NaN + 1; // NaN
parseInt("hello") + 1; //NaN
parseInt("hello"); // NaN
parseInt(NaN); // NaN
parseInt(Infinity); // NaN
缺省结果:带正确符号的无穷大
1 / 0; // Infinity
1 / -0; // -Infinity
-1 / 0; // -Infinity
-1 / -0; // Infinity
Math.log(0); // -Infinity
Math.log(-0); // -Infinity
缺省结果:取决于舍入模式和中间结果的符号
四种舍入方向:
舍入模式 | 正 | 负 |
---|---|---|
最近 | +∞ | -∞ |
零 | +max | -max |
向上 | +∞ | -max |
向下 | +max | -∞ |
// 舍入模式是“最近”
Math.pow(2, 1024); // Infinity
Math.exp(709.8); // Infinity
Number.MAX_VALUE * 2; // Infinity
Number.MAX_VALUE * -2; // -Infinity
下溢,也称浮点下溢/算术下溢
在上文中我们有提到,IEEE 754 binary64(双精度浮点数)实际存储的有效数是经过规范化处理的。正规数,就是规范化处理后的有效位表示,其有效数的默认前导位是 1。
次正规数是介于最小正规数和零之间的数,它通过调整指数(将指数位全置为 0)来去除有效数的前导 1,以此来表示比最小的正规数更接近 0 的数字。这样就能填补浮点运算中 0 附近的下溢间隙了,进而避免“即使两个数值不相等,减法 a - b 也会下溢并产生 0”的情况(达到下溢时,会丢弃所有有效数字,然后就突然变 0 了)。所以,次正规数也被称为逐渐下溢,因为它允许(值非常小的)计算结果慢慢地失去精度。
最小的正“正规数”应该是:正数即符号位为 0,11 位指数是“全 0+1”,52 位有效数是“全 0”。如下:
最小的正“正规数”的 binary64 存储:0x0010000000000000
真实值就是:(-1)0 * 1.00...00(2) * 21-1023 = 1 * 2-1022 ≈ 2.2250738585072014e-308
2**-1022; // 2.2250738585072014e-308
最大“次正规数”应该是:正数即符号位为 0,11 位指数是“全 0”,52 位有效数是“全 1”。如下:
最大“次正规数”的 binary64 存储:0x000FFFFFFFFFFFFF
真实值就是:(-1)0 * 0.11...11(2) * 2-1022
Number(2**-1022 - 2**-1074); // 2.225073858507201e-308
Number(2**-1022 - 2**-1074).toPrecision(55); // '2.225073858507200889024586876085859887650423112240959465e-308'
最小“次正规数”应该是:正数即符号位为 0,11 位指数是“全 0”,52 位有效数是“全 0+1”。如下:
最小次正规数的 IEEE 754 binary64 存储:0x0000000000000001
真实值就是:(-1)0 * 0.00...01(2) * 2-1022
2**-1074; // 5e-324
Number(2**-1074).toPrecision(70); // '4.940656458412465441765687928682213723650598026143247644255856825006755e-324'
// 用最小正规数除以2,可以构造出次正规数
2**-1022 / 2; // 1.1125369292536007e-308 < 最大次正规数 2.225073858507201e-308
// 用最小次正规数除以2,可以构造出超出浮点数能表示范围的数,此时会返回 0
2**-1074 / 2; // 0
缺省结果:返回该运算的结果
/// 舍入
1/3; // 0.3333333333333333, 实际值是无限循环小数(有理数)
Math.log(1.1); // 0.09531017980432493
/// 上溢
2**1024; // Infinity,指数超出最大值了 [-1022, +1023]
/// 下溢
2**-1022 / 2; // 用最小正规数除以2,可以构造出次正规数
2**-1074 / 2; // 用最小次正规数除以2,可以构造出超出浮点数能表示范围的数,此时会返回 0
good
目录
双精度浮点格式,也称 FP64 或 float64,是计算机的一种数字格式。通常它在内存中占 64 位,且使用浮动的小数点来表示较宽的数值范围。
在 IEEE 754-2008 标准中,64 位 base-2 格式被正式称为 binary64。它在 IEEE 754-1985 中被称为 double。IEEE 754 也指定了其它浮点格式,包括 32 位 base-2 单精度和最近的 base-10 表示。
最早提供单/双精度浮点数据类型的编程语言之一是 Fortran。在广泛采用 IEEE 754-1985 之前,浮点数据类型的表示和属性取决于计算机制造商、计算机模型以及编程语言的实现者(比如 GW-BASIC 的双精度数据类型是 64 位 MBF 浮点格式)。
一. 双精度二进制浮点数
双精度二进制浮点数,即 binary64,通常也简称为 double。它是 PC 上的常用格式,其范围比单精度浮点数更宽,尽管它的性能和带宽成本都比单精度浮点数的高。
IEEE 754 标准规定 binary64 有:
其中,符号位决定了数字本身的符号。指数是偏差指数,需要减去差值 1023(详见下小节“偏差指数”)。有效数精度是 52 位显式存储加 1 个隐藏位(值固定是 1),所以能表示 53 位。
偏差指数
在 IEEE 754 浮点数中,指数在工程意义上是有偏差的,也称偏差指数(或偏置指数)。
之所以有偏差,是因为指数必须是“有符号”的值才能同时表示微小值和巨大值,但是二进制补码(通常表示有符号值)会使比较变得更加困难。为了解决这个问题,指数被存储为适合比较的“无符号值”,然后在解释的时候“减去偏差”就转成有符号范围内的指数了。
偏差的值是 2k-1-1,其中 k 是指数的位数。比如:
双精度浮点数的指数字段是 11 位无符号整数,值从 0 到 2047。由于全 0 和全 1 的指数值是为特殊数字保留的,所以可用的指数值是从 1 到 2046。减去指数偏差值 1023,就能得到指数的实际范围,即从 -1022 到 +1023。
科学记数法
科学记数法是一种记数的方法。比如:
当数字过大或者过小的时候,用十进制表示通常要写一长串数字,此时用科学记数法就比较方便了。科学家、数学家和工程师们通常使用十进制的科学记数法,部分原因是它可以简化某些算术运算。
在十进制的科学记数法里,非零数字的写法是:a * 10n,其中 1≤|a|<10,n 是整数。
在计算机存储中,更常见的是二进制的科学记数法,即 a * 2n,其中 a 是由 0 和 1 组成的二进制表示,n 是整数。比如:
binary64 的存储
综上,我们知道了给定的 64 位双精度数据在内存中的格式及其含义。
值得一提的是,实际存储的有效数位,是经过归一化或者规范化(normalization)处理的。考虑到有效数字的第一位必然不是 0,对二进制来说那就只能是 1 了,所以它不需要在内存中表示,这样还能让格式再多一位精度。这个规则被称为前导位约定、隐式位约定、隐藏位约定或假定位约定。正因如此,52 位的有效数字位能表示 53 位。
所以,IEEE 754 binary64 格式对应的真实数值就是:
接下来,和大家一起感受下,十进制数值是如何以“双精度二进制浮点数”的格式存储在内存中的。
举几个栗子
要想在计算机中存储十进制数字,需要:
符号(1位) + 指数(11位) + 有效数(52位)
的格式,将值依次填充在 64-bit 中即可精度损失
以 168 为例
第一步,将十进制转为二进制。168(10) = 1010 1000(2),逻辑如下:
第二步,将二进制写成科学记数法的形式。1010 1000(2) = 1.0101 000(2) * 27 = 1.0101(2) * 27。此时:
第三步,调整指数。7 + (211-1-1) = 7 + 1023 = 1030。转成二进制就是 1030(10) = 100 0000 0110(2),逻辑如下:
第四步,规范化有效数。1.0101 省略首位的 1 之后就变成了 0101。
第五步,用 0 补齐指数(11 位)和有效数(52 位),最后再依次拼接即可。
0
100 0000 0110
0101 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000
以 0.125 为例
第一步,将十进制转为二进制。0.125(10) = 0.001(2),逻辑如下:
第二步,将二进制写成科学记数法的形式。0.001(2) = 1(2) * 2-3。此时:
第三步,调整指数。-3 + (211-1-1) = -3 + 1023 = 1020。转成二进制就是 1020(10) = 11 1111 1100(2),逻辑如下:
第四步,规范化有效数。1 省略首位的 1 之后就变成了 0。
第五步,用 0 补齐指数(11 位)和有效数(52 位),最后再依次拼接即可。
0
011 1111 1100
0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000
以 0.1 为例
第一步,将十进制转为二进制。0.1(10) = 0.0 0011 0011 0011 ...(2),逻辑如下:
此时,我们发现出现了循环(0011),用小数部分乘以 2 之后永远也不可能得到小数部分是零的情况。这个时候,就要进行四舍五入了。由于二进制只有 0 和 1,所以就 0 舍 1 入。这个就是计算机在存储小数时会出现误差的原因所在了,但因为保留的位数很多,精度较高,所以在大部分情况下误差可以忽略不计。
第二步,将二进制写成科学记数法的形式。0.0 0011 0011 0011 ...(2) = 1.1001 1001 1001 ...(2) * 2-4。此时:
第三步,调整指数。-4 + (211-1-1) = -4 + 1023 = 1019。转成二进制就是 1019(10) = 11 1111 1011(2),逻辑如下:
第四步,规范化有效数。1.1001 1001 1001 ... 省略首位的 1 之后就变成了 1001 1001 1001 ...。
第五步,用 0 补齐指数(11 位)和有效数(52 位),最后再依次拼接即可。
0
011 1111 1011
1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1...
因为有循环,所以最后一位要四舍五入(0 舍 1 入),最终结果是1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1010
结束语
本文重点介绍了双精度二进制浮点数(即 binary64,也称 double)的二进制格式及三个字段的含义。
需要特别注意的是,当它在存储小数的时候,可能会有精度损失(比如上方 0.1 的例子)。
只要是符合 IEEE 754 标准的(如 Fortran 里的
real64
类型、Java / C# / C / C++ 中的double
类型、JavaScript 中的 Number 类型等),在存储小数的时候都有可能出现误差。这个在对精度要求比较高的场景下,是不能忽略的。常规的替代方案就是将小数转成整数(大数)进行存储和运算,最后显示的时候再恢复成小数形式。主要参考
补充两个在线小工具: