JavaScript 的 Number 与 Python 的 float 是怎么存数字的

许多静态类型语言提供多种数值类型。Java 的 byte、int、long 固定为 8、32、64 位,float 和 double 固定为 32、64 位。C 的类型大小取决于实现:普通 char 可能有符号或无符号,long 也不一定是 64 位。本文讨论 JavaScript 的 Number 和常见平台上 Python 的 float 使用的 IEEE-754 binary64。

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)floatbinary64 数值本身占 8 字节;Python 的 float 对象还需要额外空间存放对象元数据。
JavaScript整数(无小数,精确)BigInt任意精度整数,后来才作为独立类型加入。写法带 n 后缀(例如 5n)。最接近 Python 的 int。
浮点(64 位 IEEE-754 double)Number与 Python 的 float 是同一个格式。JavaScript 用它同时表示整数和小数;默认没有单独的整数类型。

Python 的整数字面量(例如 5)产生 int。包含小数点或指数标记时则是浮点字面量:5.2 和 5e0 都产生 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 的整数字面量默认为 int,JavaScript 无后缀的数值字面量默认为 Number。接下来讨论浮点部分:Python 的 float、JavaScript 的 Number 及其 binary64 表示。

用科学记数法表示数字

在开始谈浮点和 IEEE754 标准之前,我们得先弄清用科学记数法表示一个数意味着什么。你大概见过 6.022×10236.022 \times 10^{23}(阿伏伽德罗常数)或 2.5e-72.5\text{e-}7(代码中常用的 e 记数形式)这样的值——它们通常表示大到或小到无法一位一位写出来的量,而这正是科学记数法要解决的问题。

一般形式下,科学记数法中的数可以这样表示:

有效数字×基数指数\text{有效数字} \times \text{基数}^{\text{指数}}

有效数是与基数的指数幂相乘的数值因子;精度则是可用的有效数字或位数。“尾数”常用于指有效数,下文也用它非正式地指存储的小数字段。有效数中的零也可能重要:1.01 与 1.1 并不相同。十进制的基数为 10,二进制为 2,指数决定缩放比例。

任何数都可以用科学记数法表示。例如数字 7 在十进制和二进制中可以这样表示:

710=7⋅1001112=111⋅20\begin{aligned} 7_{10} &= 7 \cdot 10^{0} \\ 111_{2} &= 111 \cdot 2^{0} \end{aligned}

指数为 0 只是告诉我们,为得到原数不需要做任何额外操作。再看另一个例子——数字 0.00000022。这里的有效数字是 22,所以我们把零去掉:

0.00000022=0.00000022⋅100=0.00000022⋅100⋅1=0.00000022⋅100⋅(10−8⋅108)=(0.00000022⋅108)⋅(100⋅10−8)=22⋅10−8\begin{aligned} 0.00000022 &= 0.00000022 \cdot 10^{0} \\ &= 0.00000022 \cdot 10^{0} \cdot 1 \\ &= 0.00000022 \cdot 10^{0} \cdot (10^{-8} \cdot 10^{8}) \\ &= (0.00000022 \cdot 10^{8}) \cdot (10^{0} \cdot 10^{-8}) \\ &= 22 \cdot 10^{-8} \end{aligned}

乘以 1=10−8⋅1081 = 10^{-8} \cdot 10^8 不会改变数值。将 0.000000220.00000022 乘以 10810^8,相当于把小数点向右移动八位,得到 2222。剩下的因子 10−810^{-8} 抵消了这次移动。

上面的计算演示了为什么小数点向右移时基数的指数会减小。于是,通过做乘法,我们把原数精炼成只剩有效位的形式:

0.00000022  =  22⏟有效数字×10⏟基数 −8⏞指数0.00000022 \;=\; \underbrace{22}_{\text{有效数字}} \times \underbrace{10}_{\text{基数}}{}^{\,\overbrace{-8}^{\text{指数}}}

前面乘以了 10810^8,所以要用 10−810^{-8} 抵消。对 22300000,除以 10510^5 则得到有效数 223:

22,300,000=223⋅10522{,}300{,}000 = 223 \cdot 10^{5}

这一次小数点向左移动,于是指数增大。可以看到,科学记数法是一种轻松处理极大或极小数的办法。 取决于指数,有效数字可以表示一个整数,也可以表示一个带小数部分的数。 换算回原数时,负指数要求把小数点向左移。正指数要求向右移,通常表示较大的整数。

同样重要的是理解什么叫一个数的规格化形式。当一个数以科学记数法书写、且小数点前有一位非零数字时,它就是规格化的。所以如果把我们最初那两个数写成规格化形式,它们会是这样:

0.00000022=2.2⋅10−722,300,000=2.23⋅107\begin{aligned} 0.00000022 &= 2.2 \cdot 10^{-7} \\ 22{,}300{,}000 &= 2.23 \cdot 10^{7} \end{aligned}

把数表示成规格化形式,就能方便地按数量级比较大小。

你大概猜到了,二进制数在小数点前永远是 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 位。下面这张表列出各格式分配的位数:

名称总位数指数存储的小数位数
单精度32823
双精度641152

指数以偏移二进制格式存储。我写过一篇详细文章讲这种格式以及它与补码的区别。请花点时间理解这个主题,因为把数转成浮点格式时我会用到它。

整数是怎么存的示例

在逐位讨论具体情形之前,先给出整数 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⋅201.0 \cdot 2^{0}

这里我们有尾数 1 和指数 0。据此你可能会以为这个数的浮点表示是这样:

 63 62        52 51                                                 0    ← 位的位置
 0  00000000000  0000000000000000000000000000000000000000000000000001
 │       │                                  │
sign  exponent                  mantissa (significand)

来查看实际位模式。这个 JavaScript 辅助函数通过 DataView 按大端字节序写入数值,再依次格式化字节。显式指定字节序使它不依赖主机的端序:

function to64bitFloat(number) {
    const buffer = new ArrayBuffer(8);
    new DataView(buffer).setFloat64(0, number, false);
    return Array.from(new Uint8Array(buffer),
        byte => byte.toString(2).padStart(8, '0')).join('');
}

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 是从哪来的。我前面提过,指数以偏移二进制存储。如果算一下偏移量:

K=2n−1−1=102310=011111111112K = 2^{n-1} - 1 = 1023_{10} = 01111111111_{2}

就能看到这正是我们在表示里看到的东西。也就是说在偏移二进制下,那里存的值其实是 0。如果不清楚偏移为什么给出 0,请读我关于偏移二进制的文章。

我们用上面学到的东西,试着把数字 3 表示成浮点形式。它的二进制是 11。如果你不记得为什么,可以看我那篇非常详细的十进制—二进制转换算法文章。规格化之后,数字 3 是这个形式(数字用二进制):

11⋅100=1.1⋅10111 \cdot 10^{0} = 1.1 \cdot 10^{1}

小数点后只有一位 1,它会被存进尾数。如前所述,小数点前的第一位不存储。另外,规格化还给了我们指数 1。 我们算一下它在偏移二进制里怎么表示,这样所需信息就齐了:

K=2n−1−1=10231+1023=1024102410=100000000002\begin{aligned} K &= 2^{n-1} - 1 = 1023 \\ 1 + 1023 &= 1024 \\ 1024_{10} &= 10000000000_{2} \end{aligned}

关于尾数有一点要记住:数字按它们在科学形式中出现的确切顺序存储——从小数点开始自左向右。记住这一点,我们把所有数字放进浮点表示:

 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 在右边。这两种格式并不是彼此的镜像;它们只是把位锚定在不同的参考点上。

  • 整数存储:位的位置从右往左依次表示 20,21,22,…2^{0}, 2^{1}, 2^{2}, \ldots。整数的位挤在右边,因为最低的位权(20=12^{0} = 1)在那里。
  • 浮点尾数:位落在规格化形式中小数点之后,从小数点起依次向下表示 2−1,2−2,2−3,…2^{-1}, 2^{-2}, 2^{-3}, \ldots。最左边那个存储位是 2−12^{-1},下一个是 2−22^{-2},依此类推。所以 1.1121.11_{2} 的尾数从左到右存 11,把第一个 1 放在 2−12^{-1},第二个放在 2−22^{-2}——左对齐,因为小数部分最高的位权在那里。

用十进制打个比方也是同一个道理:整数 123 是右对齐的(3 是个位)。小数 0.123 是左对齐的(1 是十分位)。两者仍然都把左边当作最高有效位——只是测量的参考点不同(右端 vs 小数点)。

为什么 0.1+0.2 不等于 0.3

既然我们已经知道数字是怎么存的,来看看这个常被引用的例子里发生了什么。简短的解释归结于:究竟哪些分数能在二进制里精确表示。

约分到最简形式后,分数当且仅当分母为 2 的幂时才有有限二进制展开。因此,二进制浮点数必须近似表示 0.1 和 0.2。在采用中点取偶舍入的 binary64 中,它们的和不同于存储的 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...
0.110=0.0001100110011…2=0.00011‾0.1_{10} = 0.0001100110011\ldots_{2} = 0.0\overline{0011}

下一步是把这个数写成规格化科学记数法:

0.110=0.00011‾2⋅20=1.10011‾2⋅2−40.1_{10} = 0.0\overline{0011}_{2} \cdot 2^{0} = 1.1\overline{0011}_{2} \cdot 2^{-4}

由于尾数只能有 52 位,我们需要把这个数的无限二进制展开舍入到小数点后 52 位。

1.10011‾⋅2−4  =  1.1001 1001 … 1001 1001⏟52 位 10011‾…⋅2−41.1\overline{0011} \cdot 2^{-4} \;=\; 1.\underbrace{1001\,1001\,\ldots\,1001\,1001}_{52\text{ 位}}\,1\overline{0011}\ldots \cdot 2^{-4}

按照 IEEE-754 标准定义、并在我关于二进制数舍入的文章中讲解过的舍入规则,我们需要把这个数向上舍入为:

1.1001 1001 … 1001 1010⏟52 位⋅2−41.\underbrace{1001\,1001\,\ldots\,1001\,1010}_{52\text{ 位}} \cdot 2^{-4}

最后剩下的就是算出指数在偏移二进制中的表示:

K=211−1−1=1023−4+1023=101910101910=011111110112\begin{aligned} K &= 2^{11-1} - 1 = 1023 \\ -4 + 1023 &= 1019_{10} \\ 1019_{10} &= 01111111011_{2} \end{aligned}

放进浮点格式表示后,数字 0.1 的位模式如下:

 63 62        52 51                                                 0    ← 位的位置
 0  01111111011  1001100110011001100110011001100110011001100110011010
 │       │                                  │
sign  exponent                  mantissa (significand)

我建议你自己算一遍 0.2 的浮点表示。你应该会得到下面这样的科学记数法与二进制表示:

0.210=1.10011‾2⋅2−30.2_{10} = 1.1\overline{0011}_{2} \cdot 2^{-3}
 63 62        52 51                                                 0    ← 位的位置
 0  01111111100  1001100110011001100110011001100110011001100110011010
 │       │                                  │
sign  exponent                  mantissa (significand)

计算 0.1 + 0.2 的结果

如果把这两个数从浮点表示重新拼回科学形式,我们得到的是:

0.110≈1.1001100110011001100110011001100110011001100110011010⋅2−40.210≈1.1001100110011001100110011001100110011001100110011010⋅2−3\begin{aligned} 0.1_{10} &\approx 1.1001100110011001100110011001100110011001100110011010 \cdot 2^{-4} \\ 0.2_{10} &\approx 1.1001100110011001100110011001100110011001100110011010 \cdot 2^{-3} \end{aligned}

要做加法,两数的指数必须相等。规则说,我们要把指数较小的那个数调整到与较大的一致。那么,把第一个数的指数 -4 调整成和第二个数一样的 -3:

0.110≈0.11001100110011001100110011001100110011001100110011010⋅2−30.1_{10} \approx 0.11001100110011001100110011001100110011001100110011010 \cdot 2^{-3}

现在可以相加了:

   0.1100110011001100110011001100110011001100110011001101
+  1.1001100110011001100110011001100110011001100110011010
   ─────────────────────────────────────────────────────────
  10.0110011001100110011001100110011001100110011001100111

计算结果要存成浮点格式,所以我们得把结果规格化、必要时舍入,并算出偏移二进制中的指数。

10.0110011001100110011001100110011001100110011001100111⋅2−3=1.00110011001100110011001100110011001100110011001100111⏟53⋅2−2\begin{aligned} &10.0110011001100110011001100110011001100110011001100111 \cdot 2^{-3} = \\ &1.\underbrace{00110011001100110011001100110011001100110011001100111}_{53} \cdot 2^{-2} \end{aligned}

规格化后的数正好落在两个舍入选项的中间,所以我们应用中点取偶规则进行舍入。这给出规格化科学形式下的结果数:

1.0011001100110011001100110011001100110011001100110100⏟52⋅2−21.\underbrace{0011001100110011001100110011001100110011001100110100}_{52} \cdot 2^{-2}

而转成用于存储的浮点格式后,它的位模式是:

 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 的操作上都一致。这些分歧是语言层的策略选择,不是底层算术的差异。有几个值得知道:

除以零。 JavaScript 返回 Infinity。

> 1 / 0
Infinity
> -1 / 0
-Infinity

Python 抛出 ZeroDivisionError。

>>> 1 / 0
Traceback (most recent call last):
  ...
ZeroDivisionError: division by zero

要在 Python 里拿到 Infinity,你得显式要求:math.inf 或 float('inf')。浮点格式对无穷有一套完全好用的编码(下面就会看到)——Python 只是选择不从字面的 1/0 生产它。

负数取模。 JavaScript 的 % 跟随被除数的符号。

> -7 % 3
-1

Python 的 % 跟随除数的符号。

>>> -7 % 3
2

半整数舍入。 JavaScript 的 Math.round 把两个整数正中间的值向上舍入(朝 +∞+\infty)。

> Math.round(2.5)
3
> Math.round(3.5)
4

Python 内置的 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}" 索要更多精度。

经验法则:当两种语言对一个数字的说法不一致时,先检查语言规则。底下那 64 位是一样的。

2532^{53} 这道边界——两种语言都有

把整数存进 float 会撞上一道硬性上限。尾数有 52 位,加上隐含的前导 1,共 53 位精度。所以直到 2532^{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            // 整数加法结果精确
> 2n ** 1000n                // 大小受资源和实现限制
10715086071862673209484250490600018105614048117055336074437503883703510511249361224931983788156958581275946729175531468251871452856923140435984577574698574803934567774824230985421074605062371141877954182153046474983581941267398767559165543946077062914571196477686542167660429831652624386837205668069376n

在 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 的代码里它永远无关紧要。

为什么恰好是 2532^{53}?

看看 9007199254740991(也就是 253−12^{53} - 1)的位模式。这是一个二进制表示为连续五十三个 1 的整数,规格化为

1.11…11⏟52 个一×2521.\underbrace{11\ldots11}_{52 \text{ 个一}} \times 2^{52}

这把尾数完全填满了 1。为了存下一个整数——2532^{53}——我们加 1,进位会一路穿过全部 52 位尾数(每个 1 都翻成 0),留给我们

10.00…00⏟52 个零×25210.\underbrace{00\ldots00}_{52 \text{ 个零}} \times 2^{52}

重新规格化把小数点左移一位、指数加一,得到全零的尾数:

1.00…00⏟52 个零×253=9,007,199,254,740,9921.\underbrace{00\ldots00}_{52 \text{ 个零}} \times 2^{53} = 9{,}007{,}199{,}254{,}740{,}992

而对下一个整数 253+12^{53} + 1,我们需要在第 53 位上有一个置位的尾数位——但尾数只有 52 位宽。没地方了。格式会悄悄舍入到最近的可表示值(视中点舍入规则而定,或是 2532^{53},或是 253+22^{53} + 2;对 +1 而言落在 2532^{53})。

这件事发生的机械原因是:在指数为 52 时,尾数恰好能容纳直到 2532^{53} 的每个整数。要存更大的东西,就得把指数抬上去——但把它抬到 53 意味着小数点向右移 53 位,而尾数仍然只给我们 52 个存储位,所以第 53 个位的位置永远隐含为 0。指数为 54 时格式在后面接两个零;55 时接三个;依此类推。

要具体看到这一点,看看 2532^{53} 往后头几个整数的位位置。每个都是 54 位宽(从第 53 位到第 0 位),但格式只有 53 个槽位——隐含的前导 1 加 52 个存储尾数位。 最低有效位(最右边那个,第 0 位——就叫它 LSB)没有槽位,所以格式在物理上没法在那儿放一个 1。由此推出两条结论:

  1. 这个范围内只能表示偶数。
  2. 相邻可表示值相差 2。加 1 正好位于中点:根据最后保留位,可能舍回原值,也可能舍入到下一个偶数。例如 (2 ** 53 + 2) + 1 会向上舍入到 2 ** 53 + 4。
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 里都能直接看到:这里的 ^ 是数学记号,表示乘方。在 JavaScript 中请使用 **;在 Python 中请使用浮点底数,例如 2.0 ** 53,才能观察浮点舍入。

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,两者都约为 1.8×103081.8 \times 10^{308}),相邻的可表示值之间会相隔极其巨大的距离。

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 一旦到了 2532^{53},i + 1 就舍回 2532^{53}——到下一个可表示值的缝隙是 2,而 +1 落在正中间、被向下舍。计数器永远卡在 9007199254740992,1/i 始终是个微小的正数,循环就这么空转下去。

你可能觉得每次加 2 能躲过这个问题——有一阵子确实能。用 i += 2,计数器越过 2532^{53} 并继续在可表示的偶数上前进(2^53、2^53 + 2、2^53 + 4、……)。但它一到 2542^{54},缝隙就翻倍成 4,于是 i + 2 舍回 i,循环又卡住了。改成 i += 4 只是把陷阱推迟到 2552^{55},那里缝隙变成 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)
true

Python 里也是同样的行为:

>>> import math
>>> float('nan') == float('nan')
False
>>> float('nan') > 0
False
>>> float('nan') < 0
False
>>> math.isnan(float('nan'))
True

在 JavaScript 中可用 Number.isNaN(x),在 Python 中可用 math.isnan(x) 检查浮点值。JavaScript 较早的全局函数 isNaN 还会把参数转换为数值,因此也可能对字符串返回 true。检查值是否不等于自身(JavaScript 的 x !== x、Python 浮点值的 x != x)同样能识别 NaN;这种写法正确,只是意图不如专用函数明确。

正 Infinity 大于任何有限数,并且等于自身。它的算术存在特殊情况:Infinity + 1 为 Infinity,1 / Infinity 为 0,Infinity - Infinity 为 NaN。

> Infinity > 1e308
true
> Infinity + 1 === Infinity
true
> Infinity - Infinity
NaN

Python 里也是同样的行为:

>>> math.inf > 1e308
True
>>> math.inf + 1 == math.inf
True
>>> math.inf - math.inf
nan

该记住什么

  1. JavaScript 的 Number 与常见 Python 实现的 float 使用 binary64,正规数具有 53 位有效精度。
  2. 在该格式中,对 0.1、0.2 及其和的舍入,与直接舍入 0.3 得到不同结果。
  3. Binary64 可以表示从 −253-2^{53} 到 2532^{53} 的所有整数,但 JavaScript 安全整数范围的绝对值上限为 253−12^{53}-1。更大的精确整数应使用 BigInt 或 Python int。
  4. NaN 和无穷具有保留编码,但语言仍可能对除以零等运算采用不同处理方式。

了解这四点,就能解释使用 binary64 浮点数时遇到的许多常见现象。

想更深入了解四种基本运算(加、减、乘、除)究竟如何变换这些位——补码整数和 IEEE-754 浮点数都包括在内——请看二进制算术是怎么工作的:补码整数与 IEEE-754 浮点数。