Number("18446744073709551615") 返回 18446744073709552000Number("18446744073709551616") 也返回 18446744073709552000

两个输入相差 1,输出相等。===true。这不是显示格式的问题——两个字符串解析成了同一个 64 位浮点数。


1. 2^64 附近,每 2048 个整数塌成一个 double

实测,Node 22,Number() 后立刻 BigInt() 反查,看值有没有真的丢:

整数                    与 2^64 的差    Number() 的结果             精确
18446744073709547521    -4095        18446744073709548000         false
18446744073709549567    -2049        18446744073709550000         false
18446744073709549568    -2048        18446744073709550000         true
18446744073709549569    -2047        18446744073709550000         false
18446744073709551615    -1           18446744073709552000         false
18446744073709551616    0            18446744073709552000         true
18446744073709553663    +2047        18446744073709552000         false
18446744073709553664    +2048        18446744073709552000         false
18446744073709553665    +2049        18446744073709556000         false
18446744073709555711    +4095        18446744073709556000         false

这一列 true 的位置就是这一带真正存在的 double。它们的确切整数值是:

18446744073709547520
18446744073709549568
18446744073709551616     ← 就是 2^64
18446744073709555712
18446744073709559808

所以 2^64 - 12^64 相等的原因不是「两个都向上取了」:2^64 - 12^64 只差 1,到下一个更小的 double 差 2047,它只能舍到 2^64 身上。而 2^64 本身是精确的。两者落在同一个 double 上,=== 才成立。

反过来,2^64 + 2048 也被舍到 2^64 上了——它到 2^64 差 2048,到 18446744073709555712 也差 2048,正好处在两个 double 的正中间。这一列写的是 false,因为被舍入后已经不是它自己了;但它是唯一一个「五五开」的样本,下一节单独看。


2. 打印出来的十进制串,不是那个整数值

上表第二列是 Number()字符串形式,不是整数值。

双精度浮点数的确切整数值    String() 打印的结果
18446744073709547520      18446744073709548000      差 480
18446744073709549568      18446744073709550000      差 432
18446744073709551616      18446744073709552000      差 384
18446744073709555712      18446744073709556000      差 288

String(2 ** 64) 打印 18446744073709552000,而 2 ** 64 是精确的 18446744073709551616。差 384。

这不是 bug。Number.prototype.toString() 的义务只是最短往返:打印出来的字符串被 Number() 读回来时必须是同一个 double。这两个 20 位十进制串读回来都是 2^64,因为间距是 4096,而它们只差 384。规范挑了后者。

所以读控制台里的 18446744073709552000 时,别把它当成整数 18446744073709552000——那个数在双精度里根本不存在。它只是 2^64 的一张名片。


3. 间距:为什么 2^53 是那条线

double 有 52 位尾数,加一位隐含前导 1,共 53 位有效位。值落在 [2^k, 2^(k+1)) 这个区间时,相邻两个 double 的距离是 2^(k-52)

区间              k     间距     2^(k-52)
[2^52, 2^53)      52    1        2^0
[2^53, 2^54)      53    2        2^1
[2^63, 2^64)      63    2048     2^11
[2^64, 2^65)      64    4096     2^12

2^522^53 之间,每个整数都是 double,间距 1。过了 2^53,间距变 2,奇数开始被丢掉。

9007199254740991    2^53 - 1     精确
9007199254740992    2^53          精确
9007199254740993    2^53 + 1      Number() = 9007199254740992     丢
9007199254740994    2^53 + 2      精确
9007199254740995    2^53 + 3      Number() = 9007199254740996     丢
9007199254740996    2^53 + 4      精确

偶数活下来,奇数没了。Number.MAX_SAFE_INTEGER9007199254740991,也就是 2^53 - 1——区间里最后一个间距为 1 的整数。


4. 向偶数舍入:三个方向不同的样本

9007199254740993 正好在 90071992547409929007199254740994 的正中间,两边距离都是 1。IEEE 754 规定这种情况下舍到尾数最低位是 0 的那个。90071992547409922^53,尾数 1.000…0(偶);90071992547409942^53 + 2,尾数 1.000…001(奇)。所以它向下

9007199254740995 同样是个五五开——离 9007199254740994 差 1,离 9007199254740996 也差 1——但这次尾数最低位为 0 的是 9007199254740996,于是向上

同一个「正好在中间」,一次向下一次向上,取决于哪个邻居的尾数是偶数。这就是「向偶数舍入」——它保证随机数据的误差长期互相抵消,不会单向累积。

2^64 + 2048 是同一个现象的大尺度版本:

18446744073709553664  距 2^64              = 2048
18446744073709553664  距 18446744073709555712 = 2048

2^64 的尾数是 1.000…0(偶),18446744073709555712 的尾数是 1.000…001(奇),所以舍到 2^64


5. 加法会静默停住

9007199254740991 + 1   = 9007199254740992    成功
9007199254740992 + 1   = 9007199254740992    什么都没发生

第二行不是报错,不是 NaN,结果就是 9007199254740992。循环里 i++ 到这一带之后,i 不再变化,条件表达式如果写得是 i <= limit,循环就永不结束。

这是最容易踩的一条:MAX_SAFE_INTEGER 是「能表示的最大整数」,不是「能加 1 的最大整数」。过了 2^53+1 直接是一个空操作。


6. 1e21 之后,连显示都不再是十进制

1e21            ->  1e+21
18446744073709551616  ->  1e+21        // 这是 2^64

Number.MAX_SAFE_INTEGER 是整数精确性的边界,1e21 是显示格式的边界,两者不是一回事。20 位十进制能容纳 2^64,但 toString()1e21 就切到科学记数法了。

所以「把大整数打印出来看看」这个调试动作,在 1e21 以上拿到的是 1e+21 五个字符。要看到完整数字,得用 BigInt2n ** 64n 打印 18446744073709551616


7. 全程走 BigInt:不在任何一步经过 Number

进制转换的整个链路里,Number 一次都没有出现。

解析端,逐字符累加进 bigint

function parseBigInt(value: string, base: number): bigint | null {
	const t = value.trim();
	if (!t) return null;
	const sign = t.startsWith('-') ? -1n : 1n;
	const body = (t.startsWith('-') || t.startsWith('+') ? t.slice(1) : t).toLowerCase();
	if (!body) return null;
	const chars = BASE_CHARS[base];
	if (!chars) return null;
	let out = 0n;
	for (const ch of body) {
		const idx = chars.indexOf(ch);
		if (idx === -1) return null;
		out = out * BigInt(base) + BigInt(idx);
	}
	return sign * out;
}

out0n 起,每一步乘的是 BigInt(base)、加的是 BigInt(idx)。整条链上没有任何一次除法或减法经过浮点,所以 18446744073709551615 进得来、原样出得去。

输出端也一样,用大整数取模:

function bigToBase(n: bigint, base: number): string {
	const chars = BASE_CHARS[base];
	if (!chars) return '—';
	if (n === 0n) return '0';
	const sign = n < 0n ? '-' : '';
	let out = '';
	let x = n < 0n ? -n : n;
	while (x > 0n) {
		out = chars[Number(x % BigInt(base))] + out;
		x /= BigInt(base);
	}
	return sign + out;
}

这里有一个 Number(),但只出现在 chars[Number(x % BigInt(base))]——把余数转成数组下标,余数最大是 15。这是全程唯一一次浮点运算,作用域是 0 到 15。

100 个 200 位随机整数做十进制往返,0 个失配。

几个边界值:

2^53 - 1    53 位二进制   16 位十进制   0x1fffffffffffff
2^53        54 位二进制   16 位十进制   0x20000000000000
2^64 - 1    64 位二进制   20 位十进制   0xffffffffffffffff
2^64        65 位二进制   20 位十进制   0x10000000000000000

8. 前缀按行覆盖:0x / 0b / 0o

选了「十六进制」作为源进制,但输入里粘了个 0b1010,按行判断:

const effectiveBase = (line: string): number => {
	const t = line.trim().toLowerCase();
	if (t.startsWith('0x')) return 16;
	if (t.startsWith('0b')) return 2;
	if (t.startsWith('0o')) return 8;
	return base;
};

const stripPrefix = (line: string): string => {
	const t = line.trim();
	return t.replace(/^[-+]?(0x|0b|0o)/i, (m) => m.replace(/0x|0b|0o/i, ''));
};

注释写得很直白:

A literal prefix (0x/0X, 0b/0B, 0o/0O) overrides the selected source base per line — pasting “0xff 0b1010” mixed works, and the batch table notes the per-line base.

两件事要一起做,而且顺序不能反:先按前缀定进制,再把前缀剥掉送去解析。先剥后定,0b1010 变成 1010,按十六进制读就是 4110,不是 10。

stripPrefix 的正则是 ^[-+]?(0x|0b|0o),符号在前缀之前。这一点下一节展开。


9. 符号和 0x 的位置:-0xff 不是 0x-ff

输入 "-ff",十六进制
parseBigInt("-ff", 16)    = -255
bigToBase(-255n, 16)      = -ff
hexPrefixed(-255n)        = -0xff
bigToBase(-255n, 2)       = -11111111

代码里单独有一个函数处理这个:

/** `0x` goes after the sign: `0x-ff` is not a hex literal, `-0xff` is. */
function hexPrefixed(n: bigint): string {
	const d = bigToBase(n, 16);
	return d.startsWith('-') ? '-' + '0x' + d.slice(1) : '0x' + d;
}

-0xff 是合法的字面量,0x-ff 不是。同理 stripPrefix 的正则把 [-+]? 放在 (0x|0b|0o) 前面,就是为了让 -0xff 里的符号不被当成正则的一部分剥掉。

还有一个反过来的坑,注释里记着:

The sign is preserved: parseBigInt keeps it, and dropping it would print -255 as the digits of 255 (0xff) - a different number entirely.

丢掉符号不会报错,只是把 -255 打印成 255 的数字串。输出看起来完全正常,值差了一个符号。


10. 非法字符返回 null:不猜

parseBigInt("g", 16)     => null     // g 不在 0-9a-f 里
parseBigInt("102", 2)    => null     // 2 不是二进制数字
parseBigInt("", 10)      => null     // 空串
parseBigInt("0x", 16)    => null     // 前缀剥完什么都没剩
parseBigInt("+", 10)     => null
parseBigInt("-", 10)     => null
parseBigInt("12.5", 10)  => null     // 小数点不是任何进制的数字
parseBigInt("ff", 16)    => 255n     // 对照组:合法

chars.indexOf(ch) === -1 就立刻返回 null,不做任何修补:不删掉那个字符继续算,也不把 g6

12.5 返回 null 值得单独说一句。这个工具转的是整数,十进制里的 12.5 在这里不是一个「带小数的大数」,而是一个含非法字符的串。猜成 12125 都是把决定权交给了转换程序,而不是给用的人。


11. 批量模式:一行坏了不拖累其余行

多行输入切批量表。文件头部的注释把理由写清了:

throws (or returns null) is marked ✗ WITHOUT aborting the batch — the whole point of batch mode is that one bad row must not cost you the other 200.

rows: lines.map((line) => {
	const n = parseBigInt(stripPrefix(line), effectiveBase(line));
	if (n === null) return [line, '—', '—', '—', '✗ invalid'];
	return [line, bigToBase(n, 2), bigToBase(n, 8), bigToBase(n, 10), hexPrefixed(n)];
}),

map 而不是 for + throw,坏行变成一行 ✗ invalid,其余照常。表里还会标出这一行的实际进制(按前缀定的那个),所以 0b1010 那行不会让人误以为它被按十六进制读了。

单行输入还是走原来的 rows 结果行;只有 lines.length > 1 才切批量。空行在 filter((s) => s.length > 0) 处被丢掉,不算一行。


12. 这套转换能覆盖什么,覆盖不了什么

能覆盖的:

  • 2、8、10、16 四个进制,任意位数的整数,负数
  • 全程 BigInt,从解析到输出不经过任何 64 位浮点运算(余数转数组下标那一次除外,作用域 0 到 15)
  • 前缀按行覆盖、符号位置正确、非法字符明确报错、批量模式一行坏不拖累其余

覆盖不了的:

  • 小数12.5 返回 null。二进制里 0.50.1,但 0.1 在二进制里是无限循环小数——那是 IEEE 754 尾数截断那一层,跟进制转换无关。
  • 浮点数转进制。输入一个 double,它本身就是个整数(只是那个十进制串不是它的值),所以输出的是 double 的确切整数值。想问「0.1 在二进制里是什么」,问的不是这个工具。
  • 负数的补码表示-255 转出来是 -11111111,不是 32 位补码 0xffffff01。工具不假装有位宽。
  • 前缀以外的语法0xFF0XFF0X 混合大小写都能吃(正则带 i),但不支持 0b1010_1010 这种带下划线的字面量。

一个实用判据:输入的数字串如果超过 15 位,就别让它经过 Number()。15 位以内的十进制整数在双精度里都是精确的,parseInt 够用;过了 2^53 的 16 位整数开始有奇偶之分,再过到 20 位,每 2048 个整数塌成一个值。

这条线上有四个已知的坍缩点:2^53 之后奇数开始丢,2^53 + 1 之后 +1 变成空操作,1e21 之后显示切成科学记数法,2^64 之后间距是 4096。前三个都是「看起来没问题」,只有最后一个会主动打印一个不存在的整数。