Number("18446744073709551615") 返回 18446744073709552000。
Number("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 - 1 和 2^64 相等的原因不是「两个都向上取了」:2^64 - 1 到 2^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^52 到 2^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_INTEGER 是 9007199254740991,也就是 2^53 - 1——区间里最后一个间距为 1 的整数。
4. 向偶数舍入:三个方向不同的样本
9007199254740993 正好在 9007199254740992 和 9007199254740994 的正中间,两边距离都是 1。IEEE 754 规定这种情况下舍到尾数最低位是 0 的那个。9007199254740992 是 2^53,尾数 1.000…0(偶);9007199254740994 是 2^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 五个字符。要看到完整数字,得用 BigInt:2n ** 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;
}
out 从 0n 起,每一步乘的是 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,不做任何修补:不删掉那个字符继续算,也不把 g 当 6。
12.5 返回 null 值得单独说一句。这个工具转的是整数,十进制里的 12.5 在这里不是一个「带小数的大数」,而是一个含非法字符的串。猜成 12 或 125 都是把决定权交给了转换程序,而不是给用的人。
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.5是0.1,但0.1在二进制里是无限循环小数——那是 IEEE 754 尾数截断那一层,跟进制转换无关。 - 浮点数转进制。输入一个 double,它本身就是个整数(只是那个十进制串不是它的值),所以输出的是 double 的确切整数值。想问「0.1 在二进制里是什么」,问的不是这个工具。
- 负数的补码表示。
-255转出来是-11111111,不是 32 位补码0xffffff01。工具不假装有位宽。 - 前缀以外的语法。
0xFF、0XFF、0X混合大小写都能吃(正则带i),但不支持0b1010_1010这种带下划线的字面量。
一个实用判据:输入的数字串如果超过 15 位,就别让它经过 Number()。15 位以内的十进制整数在双精度里都是精确的,parseInt 够用;过了 2^53 的 16 位整数开始有奇偶之分,再过到 20 位,每 2048 个整数塌成一个值。
这条线上有四个已知的坍缩点:2^53 之后奇数开始丢,2^53 + 1 之后 +1 变成空操作,1e21 之后显示切成科学记数法,2^64 之后间距是 4096。前三个都是「看起来没问题」,只有最后一个会主动打印一个不存在的整数。