你给一个计数器加了 1,显示却没变。第一章教我们先核对保存、读取和显示。现在把这些路径缩到最短:直接让浏览器算 (x + 1) - x。
预计用时:40 分钟。前置是加减法和指数表示:2 ** 53 表示 2 的 53 次方。
打开 数的缝隙。保持增加量为 1,把指数从 52 拖到 53,再拖到 54。先写预测:若只是显示舍掉末位,再减掉原来的大数,应当重新显出 1;若计算结果已经舍入,再相减也无法取回它。
页面同时给出起点、精确整数目标、实际计算结果和相减后的增量。目标用 JavaScript 的 BigInt 整数运算生成,实际路径使用 Number。两者的表示方式不同,这正是用来核对的条件。
在当前格式下,指数 52 时增量是 1;53 时增量变成 0。发生变化的不只是显示出来的位数。
常见浮点格式把数组织成符号、有效数字与尺度。可以先类比科学计数法:1.234 × 10^8 与 1.234 × 10^-8 共用同样多的有效数字,实际刻度却相差很远。binary64 使用二进制,并在正常数范围提供 53 位有效二进制精度。
在 2^e 右侧、尚未到 2^(e+1) 的范围内,相邻可表示数的间隔是 2^(e−52)。因此:
| 起点 | 右侧相邻间隔 | 加 1 的位置 |
|---|---|---|
2^52 | 1 | 正好到下一个可表示数 |
2^53 | 2 | 正好在两者中间 |
2^54 | 4 | 离原数更近 |
中间结果必须落到格式能够保存的值上。通常的最近舍入在正中间使用“取偶数”的规则,这里的 2^53 + 1 会落回 2^53。图画的是起点右侧;幂次边界左侧的间隔可能不同。
这也解释了为什么不能把“整数形式的 Number”直接当作任意大的精确整数。2^53 本身能精确表示,但附近的连续整数已经不能全部区分。
可以选择更大的固定格式或任意精度计算。但多出的位要占空间,运算可能需要更多指令,数据传输也会增加。某些任务还需要精确十进制语义,如按规则处理货币;整数最小单位、十进制类型及舍入规则会成为设计的一部分。
现代 CPU 与 GPU 对不同格式有不同支持。某种较低精度格式可能节省带宽或提供更高吞吐,但输出能否满足任务要求,要用误差与输入范围核对。硬件支持一种格式,不会自动替我们决定它是否合适。
先离开滑块,预测 2^53 + 2 与 2^53 + 3 的结果。可以在浏览器控制台运行以下完整小程序:
const x = 2 ** 53;
console.log((x + 1) - x);
console.log((x + 2) - x);
console.log((x + 3) - x);
参考:增量依次为 0、2、4。最后一项也是中点舍入,落到相邻可表示值中的另一侧。不能把本节记成“大数加小数总会丢掉小数”。
再提出一种任务,需要区分 9007199254740992 与 9007199254740993,例如记录编号。说明你会使用字符串、BigInt 或其他表示的理由,以及需要调整的接口。
本节可以在“表示格式决定哪些结果能留下”处停下。我们还没有讨论一串计算怎样累积误差,也没有完整展开溢出、非正规数、无穷与 NaN。下一节先追问最熟悉的 加法顺序。