JavaScript 的 Number 与 Python 的 float 是怎么存数字的
大多数静态类型语言,比如 Java 或 C,对数字有不同的数据类型。
例如,如果你要存一个落在 [-128;127] 范围内的整数,Java 里可以用 byte,C 里可以用 char,两者都只占 1 个字节。
如果要存更大的整数,可以用 int 或 long 类型,它们分别占 4 和 8 个字节。
还有专门用来存带小数部分的数的类型——占 4 字节的 float 和占 8 字节的 double。它们通常被称为浮点格式,后面我们会看到这个名字的由来。
JavaScript 和 Python 不走这套路子。JavaScript 长期以来只有一个通用类型 Number,用它处理所有数值——整数和小数一视同仁——后来才加入 BigInt 作为独立的任意精度整数类型。
Python 里对应的是 int(任意精度整数)和 float(小数值)。
你也许早就见过这种奇怪的行为——在 JavaScript 控制台里输入 0.1 + 0.2,返回的答案是 0.30000000000000004,而不是 0.3。关于这个话题有数不清的 StackOverflow 帖子、GitHub issue 和博客文章,几乎全都把责任推给 JavaScript。但同样的答案也会出现在 Python、Java、C 以及任何用 IEEE-754 binary64 作为浮点类型的语言里——那个 64 位双精度浮点格式,正是 JavaScript 的 Number 和 Python 的 float 共同的底座。
同一套底层机制还表现在另一些看起来像语言 bug、实际却不是的行为上——两个很大但也没那么大的整数比出相等,或者一个值不等于它自己:
> 9007199254740992 === 9007199254740993
true
> NaN === NaN
false同样的表达式放到 Python 里——同样的答案,一位不差:
>>> 9007199254740992.0 == 9007199254740993.0
True
>>> float('nan') == float('nan')
False本文将走一遍这 64 位按照 IEEE-754 究竟是怎么运作的,在两种语言里做同样的实验,看它们产出完全相同的位模式,并弄清哪些行为来自 float,哪些来自包在它外面的语言。
两种语言各自暴露的数字类型
在剖开这些位之前,先把两种语言在表层给你的东西摊开来看会有帮助。两者都有分开的整数类型和小数类型,只是名字不同、默认值不同:
| 语言 | 种类 | 类型 | 细节 |
|---|---|---|---|
| Python | 整数(无小数,精确) | int | 任意精度。CPython 会按需扩展底层存储,所以 2**1000 直接就能用,结果是精确的。 |
| 浮点(64 位 IEEE-754 double) | float | 本文余下部分要拆解的正是这个格式。Python 脚本里的字面量 0.5 就是 8 个字节。 | |
| JavaScript | 整数(无小数,精确) | BigInt | 任意精度整数,后来才作为独立类型加入。写法带 n 后缀(例如 5n)。最接近 Python 的 int。 |
| 浮点(64 位 IEEE-754 double) | Number | 与 Python 的 float 是同一个格式。JavaScript 用它同时表示整数和小数;默认没有单独的整数类型。 |
两种语言在你写下一个数字字面量时,默认选择的类型也不同。Python 默认取 int,只有字面量里出现小数点时才切到 float:
>>> type(5)
<class 'int'>
>>> type(5.2)
<class 'float'>
>>> type(5 + 0.0) # int + float 提升为 float
<class 'float'>JavaScript 的默认恰好相反——每个数字字面量都是 Number(也就是浮点类型),除非你给它加上 n 让它变成 BigInt。typeof 对整数和小数一律报告 'number':
> typeof 5
'number'
> typeof 1.5
'number'
> typeof 5n
'bigint'而一旦你拿到 BigInt,就不能在算术里把它和 Number 混用——引擎会直接拒绝,而不是悄悄做类型转换:
> 5n + 1
TypeError: Cannot mix BigInt and other types, use explicit conversions
> 5n + BigInt(1)
6n
> Number(5n) + 1 // 反方向也行,代价是丢精度
6「精确整数」和「默认浮点」这套分工在两种语言里大体是镜像的,但默认方向正好相反:
在 Python 里你得主动选择 float,办法是写一个小数点;在 JavaScript 里你得主动放弃 float,办法是给 BigInt 写一个 n。
从这里往后要讨论的一切都住在 float 这一侧——Python 的 float、JavaScript 的 Number,以及它们下面同一个 IEEE-754 binary64 格式。
用科学记数法表示数字
在开始谈浮点和 IEEE754 标准之前,我们得先弄清用科学记数法表示一个数意味着什么。你大概见过 (阿伏伽德罗常数)或 (代码中常用的 e 记数形式)这样的值——它们通常表示大到或小到无法一位一位写出来的量,而这正是科学记数法要解决的问题。
一般形式下,科学记数法中的数可以这样表示:
有效数字(significand)表示这个数的有效位——它也常被称为尾数(mantissa)或精度。
零不算有效位;它们只是占位。
基数指定数制的底,即十进制为 10,二进制为 2。
指数定义为了得到原数,小数点必须向左或向右移动多少位。
任何数都可以用科学记数法表示。例如数字 7 在十进制和二进制中可以这样表示:
指数为 0 只是告诉我们,为得到原数不需要做任何额外操作。再看另一个例子——数字 0.00000022。这里的有效数字是 22,所以我们把零去掉:
中间那行 看着像什么也没做,而这正是重点——它是个占位。紧接着的下一行,我们把 换成等价的表达式 ,它仍然等于 ,但换成了可以拆开的形式。随后 吸收掉 里那八次前导零的移位(把它变成整数 ),剩下的 就成了新的指数。这两个因子互为倒数,所以整个表达式的值没变——我们只是把它重排成了想要的形式。
上面的计算演示了为什么小数点向右移时基数的指数会减小。于是,通过做乘法,我们把原数精炼成只剩有效位的形式:
因为我们用了乘 8 位,就得用除法来补偿,负指数 8 正是从这里来的。同样的过程——只是这次用除法来取得有效位——也可以对数字 22300000 施行:
这一次小数点向左移动,于是指数增大。可以看到,科学记数法是一种轻松处理极大或极小数的办法。 取决于指数,有效数字可以表示一个整数,也可以表示一个带小数部分的数。 换算回原数时,负指数要求把小数点向左移。正指数要求向右移,通常表示较大的整数。
同样重要的是理解什么叫一个数的规格化形式。当一个数以科学记数法书写、且小数点前有一位非零数字时,它就是规格化的。所以如果把我们最初那两个数写成规格化形式,它们会是这样:
把数表示成规格化形式,就能方便地按数量级比较大小。
你大概猜到了,二进制数在小数点前永远是 1。
因为二进制只有两个数字——0 和 1——而规格化要求首位非零,所以规格化二进制里小数点前的那位永远是 1。
可以把科学记数法看作一个数的浮点表示。「浮点」这个词说的是:一个数的小数点可以「浮动」——它可以相对于该数的有效位放在任何地方。而正如我们学到的,原来的位置由指数指明。
IEEE754 眼中的浮点
IEEE 浮点算术标准(IEEE 754)定义了许多与浮点算术相关的东西,但就我们这次探究而言,只关心数字如何存储、舍入和相加。我写过一篇非常详细的文章讲如何对二进制数做舍入。舍入是很频繁的操作,当所选格式给不出足够的位来存一个数时就会发生。这是个重要的主题,所以要把它的机制搞扎实。现在我们来看数字是怎么存的。后面所有例子基本都是二进制的数。
数字是怎么存的
标准定义的格式中最常用的有两种——单精度和双精度。它们占用的位数不同,因而各自能存的数值范围也不同。把科学记数法形式的数转成 IEEE754 形式的做法对所有格式都一样,只有分配给尾数(有效位)和指数的位数不同。
IEEE754 浮点分配位来存储数的符号、尾数(有效位)和指数。下面是它在双精度格式(每个数 64 位)中分配这些位的方式,JavaScript 的 Number 和 Python 的 float 用的都是它:
63 62 52 51 0 ← 位的位置
0 00000000000 0000000000000000000000000000000000000000000000000000
│ │ │
sign(1) exponent(11) mantissa(52) (significand)符号位占 1 位,指数占 11 位,尾数(significand)分到 52 位。下面这张表列出各格式分配的位数:
| 名称 | 总位数 | 指数 | 有效数字 |
|---|---|---|---|
| 单精度 | 32 | 8 | 23 |
| 双精度 | 64 | 11 | 52 |
指数以偏移二进制格式存储。我写过一篇详细文章讲这种格式以及它与补码的区别。请花点时间理解这个主题,因为把数转成浮点格式时我会用到它。
整数是怎么存的示例
在逐位讨论具体情形之前,先给出整数 7(二进制 111)从头到尾的完整图景——原始位、规格化科学形式,以及最终打包好的 64 位 IEEE-754 布局:
111 → 1.11 × 2^2 → 0 10000000001 11 0...0
^^^ ^ ^^ ^ ^ ^^^^^^^^^^^ ^^ ^^^^^
│ │ │ │ │ │ │ └── 50 个填充零(尾数共 52 位)
│ │ │ │ │ │ └── 存储的尾数:「11」(前导 1 之后的位)
│ │ │ │ │ └── 存储的指数:2 + 1023(偏移)= 1025 = 10000000001
│ │ │ │ └── 符号位:0(正数)
│ │ │ └── 指数:小数点要移回去多远
│ │ └── 尾数(隐含前导 1 之后的位)
│ └── 隐含的前导 1(不存储)
└── 原始整数的位现在我们一步一步看整数 1 和 3 按同一套方案是怎么存的。
数字 1 在所有数制里都写作 1,所以不需要转换。它的科学形式可以写成:
这里我们有尾数 1 和指数 0。据此你可能会以为这个数的浮点表示是这样:
63 62 52 51 0 ← 位的位置
0 00000000000 0000000000000000000000000000000000000000000000000001
│ │ │
sign exponent mantissa (significand)我们来看看是不是真的如此。遗憾的是,JavaScript 和 Python 都没有内置函数用来打印所存浮点数的原始位。但在两种语言里写一个都很容易。下面是一个 JavaScript 辅助函数,它会替你处理好电脑的字节序——把浮点数写进 Float64Array,把底层内存当作字节来看,再倒着遍历它们拼出位串:
function to64bitFloat(number) {
var f = new Float64Array(1);
f[0] = number;
var view = new Uint8Array(f.buffer);
var i, result = "";
for (i = view.length - 1; i >= 0; i--) {
var bits = view[i].toString(2);
if (bits.length < 8) {
bits = new Array(8 - bits.length).fill('0').join("") + bits;
}
result += bits;
}
return result;
}Python 的等价实现用 struct 模块一行就够——把浮点数打包成 8 个大端字节(>d),再把这同样的字节解包成一个 64 位无符号整数(>Q),最后把该整数格式化为 64 个字符的二进制串:
import struct
def to64bit_float(x):
return f"{struct.unpack('>Q', struct.pack('>d', x))[0]:064b}"对同一个输入,两者产出同一个 64 字符的二进制串——JavaScript 里的 to64bitFloat(1) 和 Python 里的 to64bit_float(1.0) 给出完全相同的位。往后我们会交替使用它们。
那么,用其中任何一个都可以看到,数字 1 是这样存的:
63 62 52 51 0 ← 位的位置
0 01111111111 0000000000000000000000000000000000000000000000000000
│ │ │
sign exponent mantissa (significand)这和上面的假设完全不同。尾数里一位有效数字都没有,指数里却出现了一串 1。现在来看看为什么会这样。
关键的洞察在于:IEEE-754 并不直接存这个数——它先把数转成规格化科学形式(小数点前一位非零数字,其余在后),然后存这些部分。
而正如我们前面确立的,规格化二进制里小数点前的首位永远是 1——所以格式干脆不去存它。
这就是隐藏位技巧:每次读取该值时,硬件都会把这个隐含的 1 补回去,相当于近乎免费地多赚了一位精度。
具体到数字 1,它的规格化形式是 1.0 × 2^0——小数点后没有任何数字,而小数点前那位(隐含的 1)又不存。
于是尾数无可记录,这就是它全为零的原因。
现在看看指数里的那串 1 是从哪来的。我前面提过,指数以偏移二进制存储。如果算一下偏移量:
就能看到这正是我们在表示里看到的东西。也就是说在偏移二进制下,那里存的值其实是 0。如果不清楚偏移为什么给出 0,请读我关于偏移二进制的文章。
我们用上面学到的东西,试着把数字 3 表示成浮点形式。它的二进制是 11。如果你不记得为什么,可以看我那篇非常详细的十进制—二进制转换算法文章。规格化之后,数字 3 是这个形式(数字用二进制):
小数点后只有一位 1,它会被存进尾数。如前所述,小数点前的第一位不存储。另外,规格化还给了我们指数 1。
我们算一下它在偏移二进制里怎么表示,这样所需信息就齐了:
关于尾数有一点要记住:数字按它们在科学形式中出现的确切顺序存储——从小数点开始自左向右。记住这一点,我们把所有数字放进浮点表示:
63 62 52 51 0 ← 位的位置
0 10000000000 1000000000000000000000000000000000000000000000000000
│ │ │
sign exponent mantissa (significand)如果你运行上面任一个函数——JavaScript 里的 to64bitFloat(3) 或 Python 里的 to64bit_float(3.0)——就会看到我们得出的表示是正确的。
关于位序的说明
你可能注意到尾数的位是左对齐的——3 我们得到 1000…000,而 7(前面处理过的那个)会得到 1100…000。这可能让人觉得不对,因为同样这些数存成普通 8 位整数是 00000011 和 00000111——1 在右边。这两种格式并不是彼此的镜像;它们只是把位锚定在不同的参考点上。
- **整数存储:**位的位置从右往上依次表示 。整数的位挤在右边,因为最小的量级()住在那里。
- **浮点尾数:**位落在规格化形式中小数点之后,从小数点起依次向下表示 。最左边那个存储位是 ,下一个是 ,依此类推。所以 的尾数从左到右存
11,把第一个1放在 ,第二个放在 ——左对齐,因为最大的小数量级住在那里。
用十进制打个比方也是同一个道理:整数 123 是右对齐的(3 是个位)。小数 0.123 是左对齐的(1 是十分位)。两者仍然都把左边当作最高有效位——只是测量的参考点不同(右端 vs 小数点)。
为什么 0.1+0.2 不等于 0.3
既然我们已经知道数字是怎么存的,来看看这个常被引用的例子里发生了什么。简短的解释归结于:究竟哪些分数能在二进制里精确表示。
只有分母是 2 的幂的分数才能在二进制形式下有限表示。由于 0.1(1/10)和 0.2(1/5)的分母不是 2 的幂,这些数无法在二进制格式下有限表示。为了把它们存成 IEEE-754 浮点数,必须把它们舍入到尾数可用的位数——半精度 10 位,单精度 23 位,双精度 52 位。取决于可用的精度位数,0.1 和 0.2 的浮点近似值可能略小于或略大于它们对应的十进制表示,但永远不会相等。正因如此,你永远不会得到 0.1 + 0.2 == 0.3。
对某些开发者来说这个解释也许够了,但要看清底下究竟在发生什么,最好的办法是亲手把计算机做的全部计算都做一遍。我接下来就要这么干。
用浮点格式表示 0.1 和 0.2
我们看看 0.1 在浮点形式下的位模式。首先要做的是把 0.1 转成二进制。这可以用乘 2 算法完成。它的机制我在十进制—二进制转换算法一文中讲过。把 0.1 转成二进制,会得到一个无限小数:
0.1 · 2 = 0.2 0.0...
0.2 · 2 = 0.4 0.00...
0.4 · 2 = 0.8 0.000...
0.8 · 2 = 1.6 0.0001...
0.6 · 2 = 1.2 0.00011...
0.2 · 2 = 0.4 0.000110...下一步是把这个数写成规格化科学记数法:
由于尾数只能有 52 位,我们需要把这个无限的数舍入到小数点后 52 位。
按照 IEEE-754 标准定义、并在我关于二进制数舍入的文章中讲解过的舍入规则,我们需要把这个数向上舍入为:
最后剩下的就是算出指数在偏移二进制中的表示:
放进浮点格式表示后,数字 0.1 的位模式如下:
63 62 52 51 0 ← 位的位置
0 01111111011 1001100110011001100110011001100110011001100110011010
│ │ │
sign exponent mantissa (significand)我建议你自己算一遍 0.2 的浮点表示。你应该会得到下面这样的科学记数法与二进制表示:
63 62 52 51 0 ← 位的位置
0 01111111100 1001100110011001100110011001100110011001100110011010
│ │ │
sign exponent mantissa (significand)计算 0.1 + 0.2 的结果
如果把这两个数从浮点表示重新拼回科学形式,我们得到的是:
要做加法,两数的指数必须相等。规则说,我们要把指数较小的那个数调整到与较大的一致。那么,把第一个数的指数 -4 调整成和第二个数一样的 -3:
现在可以相加了:
0.1100110011001100110011001100110011001100110011001101
+ 1.1001100110011001100110011001100110011001100110011010
─────────────────────────────────────────────────────────
10.0110011001100110011001100110011001100110011001100111计算结果要存成浮点格式,所以我们得把结果规格化、必要时舍入,并算出偏移二进制中的指数。
规格化后的数正好落在两个舍入选项的中间,所以我们应用平局判定规则、向偶数舍入。这给出规格化科学形式下的结果数:
而转成用于存储的浮点格式后,它的位模式是:
63 62 52 51 0 ← 位的位置
0 01111111101 0011001100110011001100110011001100110011001100110100
│ │ │
sign exponent mantissa (significand)这正是你执行语句 0.1+0.2 时所存下的位模式。
为了得到它,计算机得舍入三次——两个数各一次,它们的和第三次。而当只是存 0.3 时,计算机只舍入一次。正是这些舍入操作,导致 0.1+0.2 与单独的 0.3 存下不同的位模式。
当语言把 0.1+0.2 与 0.3 相比较时——JavaScript 用 ===,Python 用 ==——比较的就是这些位模式,而既然它们不同,返回结果便是 false。如果存在某种格式,使得即便经过舍入位模式也相等,那么比较就会得出 true,尽管 0.1 和 0.2 在二进制中并非有限可表示。
试着用上面给出的函数查一下数字 0.3 的位——JavaScript 里的 to64bitFloat(0.3) 或 Python 里的 to64bit_float(0.3)。这个模式会和我们上面为 0.1+0.2 的结果算出的那个不同。
要还原这些位所代表的实际十进制值,取二进制科学形式(隐含的 1 后跟 52 位尾数,乘以 2 的真实指数次幂),移动小数点以吸收指数,再把得到的二进制小数转成十进制。对 0.1 + 0.2 做这套算术,得到 0.3000000000000000444089209850062616169452667236328125。对 0.3 做,得到 0.299999999999999988897769753748434595763683319091796875。两者很接近,但不相等——这正是比较返回 false 的原因。
语言分道扬镳之处:同一个 float 之上的外壳
float 本身是共用的,但两种语言并非在每个使用 float 的操作上都一致。这些分歧是语言层的策略选择,不是底层算术的差异。有几个值得知道:
除以零。 JavaScript 返回 Infinity。
> 1 / 0
Infinity
> -1 / 0
-InfinityPython 抛出 ZeroDivisionError。
>>> 1 / 0
Traceback (most recent call last):
...
ZeroDivisionError: division by zero要在 Python 里拿到 Infinity,你得显式要求:math.inf 或 float('inf')。浮点格式对无穷有一套完全好用的编码(下面就会看到)——Python 只是选择不从字面的 1/0 生产它。
负数取模。 JavaScript 的 % 跟随被除数的符号。
> -7 % 3
-1Python 的 % 跟随除数的符号。
>>> -7 % 3
2半整数舍入。 JavaScript 的 Math.round 把 0.5 向上舍入(朝 )。
> Math.round(2.5)
3
> Math.round(3.5)
4Python 内置的 round 用的是「银行家舍入」——平局取偶——这与 IEEE-754 的默认行为、以及 FPU 内部所用的规则一致。
>>> round(2.5)
2
>>> round(3.5)
4默认的十进制显示。 JavaScript 只显示刚好足以往回还原成同一个 float 的位数。Python 的 repr() 做同样的事。两者都会藏起 0.1 + 0.2 尾部的 …44089…,直到你用 .toFixed(20) 或格式说明符 f"{x:.20f}" 索要更多精度。
经验法则:当两种语言对一个数字的说法不一致时,先怀疑外壳,别怀疑 float。底下那 64 位是一样的。
这道边界——两种语言都有
把整数存进 float 会撞上一道硬性上限。尾数有 52 位,加上隐含的前导 1,共 53 位精度。所以直到 的每个整数都能被精确存储。再往上,尾数的位就不够编码每一个整数了——格式只能跳过一些。
JavaScript 把这道边界暴露成一个常量:
> Number.MAX_SAFE_INTEGER
9007199254740991 // 2^53 - 1
> 2 ** 53
9007199254740992
> 2 ** 53 + 1
9007199254740992 // 而不是 9007199254740993!
> 9007199254740992 === 9007199254740993
true表达式 2 ** 53 + 1 并不产出 9007199254740993。格式无法表示那个值,于是舍入到最近的可表示值——也就是 9007199254740992。
有必要说清 MAX_SAFE_INTEGER 到底是什么意思:它不是 JavaScript 能容纳的最大整数。JavaScript 能存的值大得多——Number.MAX_VALUE 一直够到 1.7976931348623157e+308,即最大的有限 double。MAX_SAFE_INTEGER 实际标记的是这样的最大整数 N:N 和 N + 1 都能被精确表示。过了它,缝隙就开始出现了:MAX_SAFE_INTEGER + 3(即 9007199254740994)没问题,但 MAX_SAFE_INTEGER + 2(即 9007199254740993)是格式第一个无法表示的整数——把它敲进控制台,你拿回的是 9007199254740992,被悄悄向下舍掉了 1。随着指数继续增长,缝隙越来越宽,所以等你逼近 MAX_VALUE 时,相邻的可表示值之间可能相隔极其巨大的距离。
如果你在 JavaScript 里真的需要越过 MAX_SAFE_INTEGER 的精确整数运算,那正是 BigInt 的用处——它是任意精度的,于是这道边界彻底消失:
> 9007199254740993n
9007199254740993n // BigInt,保持精确——n 后缀很关键
> 9007199254740993n === 9007199254740992n
false // 是不同的值,与 Number 不同
> 2n ** 53n + 1n
9007199254740993n // 每个 BigInt 算术运算都保持精确
> 2n ** 1000n // 而且没有上限
107150860718626732094842504906000181056140481170553360744375038837035105112493612249319837881569585812759467291755314682518714528569231404359845775746985748039345677748242309854210746050623711418779541821530464749835819412673987675591655439460770629145711964776865421676604298316526243868372056680693100n在 float 中精确存整数的这道边界在 Python 里同样存在,但除非你从 int 跨到 float,它是看不见的。
就像 JavaScript 的 BigInt 一样,Python 的 int 是任意精度的,所以整数运算是精确的:
>>> 2**53 + 1
9007199254740993 # 精确,因为两边都是 int
>>> 2**53 + 1 == 2**53
False但你一转成 float,同一个 64 位格式就生效了,同一道边界也随之出现:
>>> 2.0**53 + 1.0
9007199254740992.0
>>> 2.0**53 + 1 == 2.0**53
True
>>> float(9007199254740993) == float(9007199254740992)
True同样的位,同样的边界。JavaScript 把它暴露为顶层常量,是因为 JavaScript 里每个普通数字字面量都是 float——BigInt 是后来加的逃生门,还得显式写 n 后缀才能用上——所以语言给这道边界起个名字来警示你。Python 把默认反了过来:一个裸的整数字面量本来就是任意精度的 int,所以这道边界在你显式转成 float 之前是看不见的。这也是 MAX_SAFE_INTEGER 没有出现在 Python 标准库里的原因——在只用 int 的代码里它永远无关紧要。
为什么恰好是 ?
看看 9007199254740991(也就是 )的位模式。这是一个二进制表示为连续五十三个 1 的整数,规格化为
这把尾数完全填满了 1。为了存下一个整数————我们加 1,进位会一路穿过全部 52 位尾数(每个 1 都翻成 0),留给我们
重新规格化把小数点左移一位、指数加一,得到全零的尾数:
而对下一个整数 ,我们需要在第 53 位上有一个置位的尾数位——但尾数只有 52 位宽。没地方了。格式会悄悄舍入到最近的可表示值(视平局判定规则而定,或是 ,或是 ;对 +1 而言落在 )。
这件事发生的机械原因是:在指数为 52 时,尾数恰好能容纳直到 的每个整数。要存更大的东西,就得把指数抬上去——但把它抬到 53 意味着小数点向右移 53 位,而尾数仍然只给我们 52 个存储位,所以第 53 个位的位置永远隐含为 0。指数为 54 时格式在后面接两个零;55 时接三个;依此类推。
要具体看到这一点,看看 往后头几个整数的位位置。每个都是 54 位宽(从第 53 位到第 0 位),但格式只有 53 个槽位——隐含的前导 1 加 52 个存储尾数位。
最低有效位(最右边那个,第 0 位——就叫它 LSB)没有槽位,所以格式在物理上没法在那儿放一个 1。由此推出两条结论:
- 在这个范围里,只有 LSB=
0的整数(也就是偶数)是可表示的。 - 相邻可表示值相隔
2,所以+1的增量会舍回同一个值——要落到下一个可表示整数,至少得+2。(而+1恰好处在两个邻居的正中间,于是平局取偶规则会选尾数以0结尾的那个。)
2^53 = 9007199254740992 = 1 0000…0000 0 ← LSB=0 (偶) ✓ 可表示
2^53 + 1 = 9007199254740993 = 1 0000…0000 1 ← LSB=1 (奇) ✗ LSB 没有槽位
2^53 + 2 = 9007199254740994 = 1 0000…0001 0 ← LSB=0 (偶) ✓ 可表示
2^53 + 3 = 9007199254740995 = 1 0000…0001 1 ← LSB=1 (奇) ✗ LSB 没有槽位
↑ └─── 52 ───┘ ↑
│ mantissa │
│ bits │
│ └── LSB 在尾数中没有槽位
│ → 永远是 0 → 整数永远是偶数
└── 隐含的前导 1(不存储)推论令人惊讶:每个大于 MAX_SAFE_INTEGER 的整数都至少以一个隐含的零结尾,因此**MAX_SAFE_INTEGER 之上的奇数整数根本无法表示——只有偶数可以。**
继续往上爬指数,可表示的就只剩 4 的倍数,然后 8 的倍数,然后 16——每上一级缝隙翻倍。这在任一个 REPL 里都能直接看到:
2^53 → 9007199254740992
2^53 + 1 == 2^53 → true (这里缝隙是 2,+1 塌回去了)
2^53 + 2 > 2^53 → true (+2 才是下一个可表示值)
2^54 + 2 == 2^54 → true (缝隙 4,连 +2 都塌回去)
2^54 + 4 > 2^54 → true (+4 才是下一个)
2^55 + 4 == 2^55 → true (现在缝隙是 8)
2^55 + 8 > 2^55 → true (+8 才是下一个)每上一级缝隙翻倍。等你到了最大的有限 double(Number.MAX_VALUE 或 sys.float_info.max,两者都约为 ),相邻的可表示值之间会相隔极其巨大的距离。
for 循环陷阱
我们刚走过的缝隙加宽行为有一个著名的实际后果——一个看起来该终止、却永远不终止的循环。
我们用两种语言写出最简单的那种「递增直到倒数为零」的循环。
在 JavaScript 里跑它:
for (let i = 1; 1/i > 0; i++) {
console.log("Count is: " + i);
}或者在 Python 里:
i = 1.0
while 1/i > 0:
print(f"Count is: {i}")
i += 1循环永不停止。这个行为两种语言共有,因为两边的计数器都是受安全整数边界约束的 64 位浮点数。条件 1/i > 0 对任何有限正 i 都为真,所以要让循环停下,计数器就得达到 Infinity。但 i 一旦到了 ,i + 1 就舍回 ——到下一个可表示值的缝隙是 2,而 +1 落在正中间、被向下舍。计数器永远卡在 9007199254740992,1/i 始终是个微小的正数,循环就这么空转下去。
你可能觉得每次加 2 能躲过这个问题——有一阵子确实能。用 i += 2,计数器越过 并继续在可表示的偶数上前进(2^53、2^53 + 2、2^53 + 4、……)。但它一到 ,缝隙就翻倍成 4,于是 i + 2 舍回 i,循环又卡住了。改成 i += 4 只是把陷阱推迟到 ,那里缝隙变成 8,如此往复。根本问题不在 +1——而在于随着往上爬,相邻整数不再可表示,任何固定步长的计数器最终都会掉进一个比它步长更大的缝隙里。
要点:这个陷阱是 Number / float 的属性,不是语言的属性。任何用 IEEE-754 double 作计数器类型的语言都可能撞上它。
NaN 与 Infinity
格式保留了两个指数值用于特殊编码:
- 全零指数保留给
±0(当尾数也为零时)以及次正规数(当尾数非零时——那些小到无法用规格化形式表示的值)。 - 全一指数保留给
±Infinity(尾数为零)和 NaN(尾数非零)。
这就是为什么 JavaScript 里 typeof NaN 是 'number',Python 里 type(float('nan')) 是 <class 'float'>——NaN 是浮点格式一个完全合法的位模式,只不过指数是全一。
正无穷的位模式是
0 11111111111 0000000000000000000000000000000000000000000000000000而一个典型的 NaN 长这样
0 11111111111 1000000000000000000000000000000000000000000000000000可以看出,任何非零尾数配上全一指数都编码为一个 NaN——这意味着不同的 NaN 位模式相当多,而不是只有一个。
IEEE-754 的规则是:NaN 与一切都不相等,包括它自己。两种语言都遵守这一点。这是 JavaScript:
> NaN === NaN
false
> NaN > 0
false
> NaN < 0
false
> Number.isNaN(NaN)
truePython 里也是同样的行为:
>>> import math
>>> float('nan') == float('nan')
False
>>> float('nan') > 0
False
>>> float('nan') < 0
False
>>> math.isnan(float('nan'))
True两种语言都提供了专门的 isNaN / math.isnan,因为——按 IEEE-754 的规则——用 x == x 检测 NaN 是错误的方式:它对 NaN 返回 false、对其他一切返回 true,但这是个反向逻辑的小把戏,而不是清晰的 API。(其实 x !== x 确实是老 JS 代码里常见的惯用写法,正是因为它能唯一地识别出 NaN。)
Infinity 遵循普通的比较规则——它比任何有限数都大,只在相等这个意义上「不小于自己」——而且可以放进算术里而不会有意外(Infinity + 1 是 Infinity,1 / Infinity 是 0,Infinity - Infinity 是 NaN)。
> Infinity > 1e308
true
> Infinity + 1 === Infinity
true
> Infinity - Infinity
NaNPython 里也是同样的行为:
>>> math.inf > 1e308
True
>>> math.inf + 1 == math.inf
True
>>> math.inf - math.inf
nan该记住什么
- 几乎每种现代语言对小数都用同样的 64 位。 JavaScript 的
Number、Python 的float、Java 的double、C 的double——全都是 IEEE-754 binary64。同样的布局、同样的算术、同样的怪脾气。语言之间的差异住在外壳里,不在 float 里。 0.1 + 0.2 != 0.3是格式的属性。 之所以如此,是因为0.1和0.2在二进制里不是有限的,加法过程中累积了三次舍入误差;而直接存0.3只累积一次。位模式因此不同。- 「安全整数」边界在 。 越过它,整数值就无法全部被表示,相邻整数会塌到同一个浮点数上。JavaScript 把它暴露为
Number.MAX_SAFE_INTEGER,因为它没有别的整数类型;Python 用户只有在转成float之后才会看到它。 - NaN 和 Infinity 是真实的位模式,不是异常。 它们住在格式内部,全一指数就是为它们保留的。NaN「与一切都不相等,包括它自己」这条规则来自 IEEE-754,不是语言的选择。
知道这四件事,你就能预测 float 在任何使用它的语言里会怎么表现——而实际上,那就是所有语言。
想更深入了解四种基本运算(加、减、乘、除)究竟如何变换这些位——补码整数和 IEEE-754 浮点数都包括在内——请看二进制算术是怎么工作的:补码整数与 IEEE-754 浮点数。