# 浮点数运算

Source: https://codewiki.com/zh/foundations/floating-point-arithmetic/

> - **what**: 二进制浮点数用固定数量的有效位和二进制指数存储数值，因此范围很大，但多数十进制小数只能近似表示。
> - **trap**: 每次运算后都可能发生舍入，精度还会随数值量级变化；`NaN`、无穷和带符号零也会让普通比较产生误导。
> - **fix**: 根据领域约定选择表示方式，拒绝意外的非有限值，并用有依据的绝对容差和相对容差比较计算所得测量值。

## 是什么，为什么存在

二进制浮点数（binary floating-point）用符号、定宽有效数字和二的幂次表示有限数。它类似科学记数法，只是数字采用二进制。64 位格式因而可以覆盖极小到极大的量级，同时保持大致固定的有效数字精度。

这种格式是一套近似系统，不能保存任意实数。最简分数的分母只要含有二以外的质因数，其二进制展开就会无限循环。十进制 `0.1` 就是一个例子，因此运行时转换这个字面量时，必须选择附近某个可表示值。

算术运算也面临同样的选择。可以把过程理解成先求数学结果，再将它舍入为可表示值。一连串看似很小的舍入决定可能使预期等式不成立，改变运算顺序也会改变丢失的信息。

浮点数之所以存在，是因为固定存储空间不可能编码所有实数。它以精确性换取宽广范围、可预测的硬件运算，以及通常随数值量级缩放的误差。这种取舍适合测量、图形、模拟、统计和许多机器学习计算。

有些领域并未主动选择浮点数，却同样会遇到它。JavaScript 的 `Number` 类型对普通数值使用 IEEE 754 binary64 值集合，因此 JSON 数据、浏览器 API 和应用公式经常继承这些语义。下文示例使用 Node 24，可以直接观察这些规则。

近似不一定是缺陷。分辨率为 `0.01` 度的温度传感器本来就带有物理不确定性，远小于此的表示误差可能毫无影响。工程上的关键问题是：表示方式和累计误差是否满足明确的精度约定。

部分领域需要不同语义。账户余额通常使用整数最小货币单位，或使用带明确精度与舍入规则的十进制类型。标识符和计数器需要整数运算；符号计算或有理数计算则能以更高成本保留精确关系。

## 工作原理

### 有限编码

JavaScript `Number` 背后的 IEEE 754 binary64 格式把 64 位分成三个字段。小数字段存储 52 位，正规数还含有一个隐含的前导 `1`，所以精度为 53 位。

| 字段 | 位数 | 用途 |
| --- | ---: | --- |
| 符号 | 1 | 选择正值或负值 |
| 指数 | 11 | 用二的幂缩放有效数字 |
| 小数 | 52 | 存储有效二进制数字的尾部 |

对于正规有限值，这些字段描述 `(-1)^sign × (1.fraction) × 2^(exponent - 1023)`。该表示式说明了为何指数越大，相邻可表示值的距离越远。位模式并不携带固定的小数位数。

指数两端的模式具有特殊含义。全零指数表示零或次正规数，让数值能够逐步接近零。全一指数在小数字段为零时表示无穷，否则表示 `NaN`。

### 转换与舍入

解析 `0.1` 这样的源文本时，实现会按照语言的转换规则选择一个 binary64 值。存储值可能高于或低于对应的数学十进制值。输出通常选择能重新解析为相同二进制值的短十进制字符串，因此近似现象可能被隐藏。

基本 `Number` 算术会把结果舍入到最近的可表示 binary64 值；若结果恰好位于中点，则选择最低位为偶数的候选值。这种「就近舍入，中点取偶」规则可以避免大量中点持续向上或向下偏移。但精确结果位于两个可表示值之间时，误差仍然存在。

每次运算接收的操作数已经经过舍入。对于 `0.1 + 0.2`，两个输入都不是对应的精确十进制分数，它们存储值的精确和还要再次舍入。最终结果略高于字面量 `0.3` 所选择的 binary64 值。

普通结果的舍入误差可按相对量级限制，但它不是固定的十进制偏移。`Number.EPSILON` 是 `1` 与下一个更大 `Number` 的距离。在 `2` 附近，间距会增大一倍；在极小的正规数附近，间距则小得多。

### 精度、范围与间距

Binary64 可以精确表示从 `-2^53 + 1` 到 `2^53 - 1` 的每个整数。超过安全整数范围后，有些整数会被跳过。`2^53` 加 `1` 不会发生变化，因为下一个可表示值相距两个单位。

范围限制与精度限制并不相同。`Number.MAX_VALUE` 是最大的有限 `Number`；结果越过溢出边界后会舍入为无穷。`Number.MIN_VALUE` 是最小的正次正规数，除以二后会舍入为正零。

次正规数用有效数字精度换取渐进下溢。它们以等间距填充最小正规数量级与零之间的区间。算法接近这条边界时，即使结果尚未变成零，相对精度也可能已经下降。

### 运算不遵循实数代数

对于普通有限操作数，浮点加法和乘法具有交换性，但 `NaN` 与带符号零会带来一些细节。它们通常不具有结合性。`(a + b) + c` 与 `a + (b + c)` 的舍入点不同，可能产生不同值。

两个非常接近的值相减尤其危险。前面的有效数字相互抵消，结果中剩余的数字来自操作数精度较低的部分。这种灾难性消减并非减法本身出错，而是先前的近似现在主导了这个微小差值。

优化器、向量库、数据库和并行归约可能用不同方式组织运算。数值上有效的结果因而可能在不同执行计划或平台上出现末位差异。若需要可重现性，就必须明确规定算法，不能仅从相同的源表达式推断它。

### 比较约定

当约定保证表示方式相同时，精确相等比较很合适。例如，排除 `NaN` 后将值与自身比较、检查安全范围内解析出的整数，或检测由同一运算产生的精确哨兵值。精确比较并非在所有场景下都是错误。

计算得到的测量值通常需要容差。绝对容差保护接近零时的比较，相对容差则随操作数量级缩放。稳健的谓词通常允许差值不超过这两个界限中的较大者。

容差必须来自领域要求和算法误差预算。照搬 `Number.EPSILON` 只是在比较 `1` 附近的间距；把它乘以任意大常数，也只是掩盖缺失的要求。单位、传感器分辨率、迭代次数和可接受的业务误差才是更好的依据。

进入容差逻辑之前，要先定义特殊值策略。使用 `===` 时，`NaN` 不等于任何值，包括自身。正零与负零在 `===` 下相等，但 `Object.is()` 可以区分两者，它们的倒数也是符号相反的无穷。

| 值 | 常见来源 | 比较注意点 |
| --- | --- | --- |
| `Infinity` | 溢出或非零值除以零 | 它可以不抛异常，继续参与后续运算 |
| `NaN` | 无效运算或传入的 `NaN` | 所有有序比较都返回假 |
| `0` 与 `-0` | 精确零或极小结果舍入 | `===` 会合并符号，部分运算仍保留符号 |
| 次正规数 | 极小的非零结果 | 接近下溢时，相对精度降低 |

## 示例

下面的示例依次展示表示与比较、信息丢失和范围边界。所有输出都来自使用 Node `v24.14.0` 实际执行对应文件。

### 观察存储的近似值

这段税额计算先输出熟悉的短表示，再要求 17 位有效十进制数字。比较辅助函数结合相对界限与可选的绝对下限，并在执行减法前单独处理非有限值。

<!-- quick -->

```javascript
// file: representation.js
const computedTax = 0.1 + 0.2;
const declaredTax = 0.3;

function nearlyEqual(left, right, { absolute = 0, relative = 1e-12 } = {}) {
  if (!Number.isFinite(left) || !Number.isFinite(right)) return left === right;
  const difference = Math.abs(left - right);
  const scale = Math.max(Math.abs(left), Math.abs(right));
  return difference <= Math.max(absolute, relative * scale);
}

console.log(computedTax);
console.log(computedTax === declaredTax);
console.log(computedTax.toPrecision(17));
console.log(declaredTax.toPrecision(17));
console.log(nearlyEqual(computedTax, declaredTax));
```

```text
0.30000000000000004
false
0.30000000000000004
0.29999999999999999
true
```


<!-- /quick -->

精确比较为假，是因为两个表达式选择了相邻的 binary64 值。`toPrecision(17)` 暴露出足以区分它们的十进制数字。它不会直接显示位模式，但能让表示差异显现出来。

`1e-12` 相对容差只是一项示例策略，不是通用常数。生产环境的税务系统通常不应使用二进制浮点数保存金额，而要定义精确的十进制舍入。该辅助函数更适合可接受相对误差确实为 `1e-12` 的近似测量。

接近零时，只用相对容差会使界限缩向零，可能拒绝无害噪声。应根据领域中的最小有意义量级设置 `absolute`。非有限值要绕过减法，因为 `Infinity - Infinity` 会产生 `NaN`，导致原本允许相同无穷的策略意外失败。

### 观察加法丢失信息

这些调整值的数学总和为 `1`。如果先让两个大数相消，再加入小数，同样三个值会产生不同结果。

```javascript
// file: summation-order.js
const adjustments = [1e16, 1, -1e16];

function sum(values) {
  let total = 0;
  for (const value of values) total += value;
  return total;
}

const smallFirst = [...adjustments].sort(
  (left, right) => Math.abs(left) - Math.abs(right),
);
const cancelFirst = [adjustments[0], adjustments[2], adjustments[1]];

console.log(`input order: ${sum(adjustments)}`);
console.log(`small first: ${sum(smallFirst)}`);
console.log(`cancel first: ${sum(cancelFirst)}`);
console.log(`mathematical total: 1`);
```

```text
input order: 0
small first: 0
cancel first: 1
mathematical total: 1
```

在 `1e16` 量级，相邻 binary64 值之间的距离大于 `1`。把这个小调整值加到任一大数上，都会将其舍掉。按绝对值从小到大排序在这里也无法保住它，因为下一次运算仍要把部分和与 `1e16` 结合。

`cancelFirst` 顺序先让符号相反的大数形成精确零，再加入 `1`。这只是一个演示，并不是要求一般算法寻找抵消数对。应该根据数据量级和精度要求选择成对求和、补偿求和或更高精度的表示方式。

顺序敏感性会影响批处理和并行工作。把数据集拆成分块会改变部分和，合并部分和时还会形成另一棵舍入树。除非逐位可重现确实是接口承诺，否则测试应声明误差界限。

### 检查范围与特殊值

这个示例越过正数范围的两端，再检查特殊值的比较行为。控制台格式会隐藏零的符号，所以代码通过 `Object.is()` 和倒数把它显现出来。

```javascript
// file: range-boundaries.js
const overflowed = Number.MAX_VALUE * 2;
const underflowed = Number.MIN_VALUE / 2;
const invalidRatio = 0 / 0;
const signedZero = -1 / Infinity;

console.log(`overflow: ${overflowed}`);
console.log(`underflow: ${underflowed}`);
console.log(`invalid equals itself: ${invalidRatio === invalidRatio}`);
console.log(`invalid detected: ${Number.isNaN(invalidRatio)}`);
console.log(`finite overflow result: ${Number.isFinite(overflowed)}`);
console.log(`signed zero prints as: ${signedZero}`);
console.log(`signed zero preserved: ${Object.is(signedZero, -0)}`);
console.log(`reciprocal: ${1 / signedZero}`);
```

```text
overflow: Infinity
underflow: 0
invalid equals itself: false
invalid detected: true
finite overflow result: false
signed zero prints as: 0
signed zero preserved: true
reciprocal: -Infinity
```

JavaScript 浮点溢出和无效运算不一定抛出异常。如果输入或结果边界没有拒绝这些值，它们可以继续流过整个计算。API 只接受普通有限测量值时，应使用 `Number.isFinite()`。

`Number.isNaN()` 能直接表达 `NaN` 检查，也不会强制转换字符串。旧式 `value !== value` 写法之所以有效，是因为 `NaN` 是 JavaScript 中唯一不等于自身的值，但这种写法隐藏了意图。应用校验应优先使用命名谓词。

负零是否重要取决于领域。在数值工作中，它可以编码趋近方向；许多业务领域则希望在格式化、序列化或散列前将它规范化。应在对应边界上明确决定，不能因为输出为 `0` 就认为符号已经消失。

## 陷阱

### 把十进制输入当作精确值

> **陷阱:** 计算把十进制价格作为 `Number` 接收，并假定源数字会精确保留。反复相加、税额计算和中点舍入随后可能不符合领域的十进制规则。

**修复方法：** 当范围与固定精度允许时，使用整数最小货币单位；否则使用经过验证、带明确精度与舍入模式的十进制实现。在边界上解析并验证十进制文本；如果先转换为二进制浮点数，表示已经改变。

### 到处使用同一个 epsilon

> **陷阱:** 生成的比较代码经常检查 `Math.abs(a - b) < Number.EPSILON`。这个阈值描述的是 `1` 附近的间距，对许多大结果过于严格，在零附近也未必表达任何有意义的策略。

**修复方法：** 根据算法误差推导相对容差，根据领域分辨率推导绝对容差。单独处理非有限值，记录单位，并同时测试接近零与最大预期量级的值。

### 没有十进制约定就缩放舍入

> **陷阱:** `Math.round(value * 100) / 100` 看起来像通用的两位小数舍入，但乘法和除法本身也是二进制浮点运算。`1.005` 这样的值可能从意外的一侧到达中点。

**修复方法：** 先说明要求针对显示、存储还是结算，并指定中点规则。格式化只用于显示；精确十进制约定应使用整数或十进制算术，缩放前还要检查范围。

### 让非有限结果越过边界

> **陷阱:** 除以零、无效运算或溢出会产生 `Infinity` 或 `NaN`，后续算术会静默传播它。JSON 序列化器可能在远离原始故障的位置拒绝它，或按自身规则进行转换。

**修复方法：** 在领域边界用 `Number.isFinite()` 验证有限输入和输出。明确溢出时应拒绝、饱和还是切换表示方式，并在错误中附上运算与单位。

### 假定代数重排无害

> **陷阱:** 重构会重新结合求和、并行执行归约，或用代数等价形式替换稳定公式。实数代数认为表达式相等，但中间舍入、溢出或消减可能改变浮点结果。

**修复方法：** 重排数值代码前，检查中间量级与问题条件性。测试量级悬殊的对抗数据，在精度重要时保留稳定算法，并为输出规定容差或可重现性要求。

### 忽略整数精度边界

> **陷阱:** 时间戳、数据库标识符或字节计数没有小数部分，于是直接存入 `Number`。超过 `Number.MAX_SAFE_INTEGER` 后，相邻整数可能比较相等，递增也可能不再改变值。

**修复方法：** 对整数含义的 `Number` 输入执行 `Number.isSafeInteger()`。领域可能超过安全范围时，使用 `BigInt`、经过验证的十进制文本或其他整数表示，并避免经过 `Number` 的有损往返转换。

<!-- deep -->

## Binary64 的间距与边界行为

### 二进制指数区间与末位单位

可表示的正规数只在固定指数区间内均匀分布，这种区间有时称为 binade。在 `2^e` 与 `2^(e+1)` 之间，相邻 binary64 值的距离为 `2^(e-52)`。跨过二的幂边界后，间距会增大一倍。

末位单位（unit in the last place，ULP）是与最低存储位对应的局部间距。以 ULP 表示的误差描述结果在表示空间中的接近程度，相对误差则描述结果与数学值的接近程度。两者都不能替代应用可接受的误差。

`Number.EPSILON` 等于 `2^-52`，也就是 `1` 上方的间距。在 `2^53` 附近，间距为 `2`，因此无法容纳所有连续数学整数。在 `2^-1022` 附近，正规数间距为 `2^-1074`，与次正规数采用的绝对步长相同。

| 区域 | 代表值 | 相邻间距或结果 |
| --- | ---: | --- |
| 一附近 | `1` | `2^-52`，通过 `Number.EPSILON` 暴露 |
| 安全整数边界 | `2^53` | `2`，因此跳过相邻奇数 |
| 最小正规数 | `2^-1022` | `2^-1074` |
| 最小次正规数 | `2^-1074` | 它的一半舍入为零 |
| 最大有限值 | `(2 - 2^-52) × 2^1023` | 增长足够大时舍入为无穷 |

在二的整数次幂处，两个方向的间距并不相同。`1` 正下方的间距是正上方的一半，因为较小邻值属于前一个指数区间。用单一全局常数估计局部 ULP 的代码会遗漏这个边界细节。

### 保护位与中点决策

硬件通常会在基本运算期间保留足够的信息，用来判断哪个可表示结果最近。保护位、舍入位和粘滞信息可以概括被丢弃的低位。存储结果仍然只有 53 个有效位；额外内部位不会成为永久精度。

只有精确结果恰好位于两个可表示值的正中间时，「中点取偶」才会发挥作用。候选值中，有效数字最低位为偶数的一方胜出。多数令人意外的十进制示例并不是精确的二进制中点，而是因为十进制转换与之前的运算已经移动了操作数。

当一个值先舍入到某种精度，再舍入到更窄精度时，可能发生二次舍入。在特定中点关系下，它会与精确值直接舍入到最终精度的结果不同。如果协议或文件格式要求特定结果，就应避免未记录的中间转换。

### 渐进下溢

正规 binary64 值使用隐含前导 `1`，次正规数则使用前导 `0` 和最小指数尺度。这样可以填补下溢区间，而不是从最小正规值直接跳到零。因此，极小差值仍有机会保持非零。

代价是相对精度不断下降。所有次正规数使用同一个绝对步长，量级越接近零，剩余的有效位就越少。假定结果为正规数的相对误差论证不能原样用于这个区域。

部分硬件模式和专用加速器为了吞吐量，会把次正规输入或结果直接刷新为零。JavaScript 语言行为仍然规定 `Number` 结果，但跨语言原生组件可能引入这种边界。极小值重要时，可重现性检查必须覆盖实际执行路径。

### 消减与稳定公式

当两个近似操作数非常接近，而精确差值远小于任一操作数时，消减会造成危害。减法消除了它们共有的前导数字，使输入误差相对于小结果急剧放大。事后输出更多十进制数字无法恢复丢失的信息。

选择公式可以避免不必要的消减。例如，数值库会使用二次方程根的其他排列、在 `x` 接近零时使用 `log1p(x)`，并用 `hypot(x, y)` 控制中间范围。稳定形式取决于具体运算与输入区域。

求和存在多种取舍。对于许多数据集，成对归约比朴素长链更能限制误差增长，也适合并行执行。补偿算法会保留低位信息的修正量，但它不是万能方案：溢出、`NaN`、对抗数据和编译器转换仍需要明确策略。

### 容差并非等价关系

「相差不超过 `0.1`」这样的谓词可以满足自反性和对称性，却不具有传递性。`0.0` 可以接近 `0.09`，`0.09` 也可以接近 `0.18`，但 `0.0` 并不接近 `0.18`。因此，容差相等不能直接作为通用的哈希映射键关系或排序等价关系。

需要分桶时，应定义规范量化规则和边界策略，而不是对每一对值执行近似比较。需要排序时，应采用适合领域的全序，并明确规定 `NaN` 的位置。把「接近」的值比较为零，可能破坏排序算法依赖的约束。

迭代终止也需要单独约定。以 `next === current` 停止可以检测表示停滞，以残差容差停止则表达解的精度。两者回答不同问题，触发时机也可能不同。

### 精确与近似表示的替代方案

当精度固定且所有中间值都在范围内时，缩放整数可以实现精确加减。除法与转换仍然需要中点规则，混合货币或可变十进制精度还需要显式元数据。`BigInt` 扩展了整数范围，但不能直接与 `Number` 混合运算。

十进制浮点数使用十的幂作为指数来表示十进制分数。它能更自然地匹配财务与人工输入的十进制规则，但有限精度依然需要舍入和溢出策略。「十进制」并不意味着所有实数结果都精确。

有理数算术可以精确保留整数之比，直到平方根等运算离开有理数域。分子和分母可能快速增长，因此规范化和资源限制仍很重要。任意精度只是移动精度边界，不会消除计算成本，也不会免除最终舍入规则。

### 解析、格式化与传输

十进制字符串与 binary64 值是不同的表示。解析把字符串映射到一个二进制值，格式化则按照呈现规则选择十进制数字。最短往返格式化器输出足够的数字，使解析器恢复相同位模式，但不一定保留用户最初输入的数字。

固定小数格式化只为呈现执行舍入，并返回文本。再次解析该文本会创建新的二进制近似值，不能提高存储精度。如果那些精确数字就是业务数据，就要保留原始十进制文本或使用十进制表示。

数据格式还会增加自身策略。标准 JSON 数字语法没有 `NaN` 或无穷字面量，JavaScript 的 `JSON.stringify()` 会把对象属性中的非有限数值序列化为 `null`。序列化前应先验证，避免把计算故障伪装成缺失数据。

二进制交换必须声明宽度、字节序和异常值策略。把 `Number` 写入 32 位浮点字段时，有效数字会舍入为 24 位，即使 JavaScript 值原本采用 binary64。再次读入 `Number` 只会扩宽格式，无法恢复已丢弃的位。

文本协议还应规定最大指数和允许的写法。解析器可能接受语法正确的指数，但数值结果已经变成无穷。语法验证与有限范围验证是两个不同步骤。

用于数值诊断的日志要保留足够多的有效数字与上下文。除了数值，还应记录单位、算法版本和相关中间值。UI 格式化后的两位小数字符串无法区分表示问题与更早的输入或公式错误。

应把每个转换边界视为明确约定：

| 边界 | 跨越前的检查 | 保留或拒绝策略 |
| --- | --- | --- |
| 十进制文本到 binary64 | 语法、有限范围、允许精度 | 数字有意义时保留源文本 |
| Binary64 到 float32 | 预期舍入误差与范围 | 明确拒绝溢出或接受精度损失 |
| Binary64 到 JSON | 有限值策略 | 主动拒绝或编码异常值 |
| Binary64 到显示文本 | 单位、小数位数、中点规则 | 让格式化与存储值保持分离 |

### 测试数值约定

示例测试应覆盖每个决策边界的两侧。测试舍入时，要包含中点下方、可表示的中点及中点上方，并覆盖两个符号。测试范围时，要包含最大预期有限输入、会产生溢出的组合、正规数与次正规数边界，以及零。

属性测试可以检查不变量，而不必要求最后一位完全一致。距离应非负且对称，规范化概率向量的总和误差应有界，稳定算法与高精度预言机之间也应满足误差界限。要主动生成量级悬殊的数据，因为均匀的小输入很少触发消减。

如果格式化字符串就是公开约定，快照十进制输出是合理的。否则，应按声明的容差比较语义结果，并在失败时保留诊断值，包括操作数、中间量级、绝对误差、相对误差，以及可获得时的 ULP 距离。

跨运行时测试需要声明可重现性级别。「相同数学精度」「相同舍入十进制输出」和「相同 binary64 位模式」是逐渐增强的承诺。优化或并行化之前应先选择级别，因为最强级别会限制运算顺序与库选择。

<!-- /deep -->

[检查点: foundations/floating-point-arithmetic](https://codewiki.com/zh/foundations/floating-point-arithmetic/#checkpoint)

## 延伸阅读

- [ECMAScript 规范：`Number` 类型](https://tc39.es/ecma262/multipage/ecmascript-data-types-and-values.html#sec-ecmascript-language-types-number-type)
- [MDN Web Docs：`Number.EPSILON`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/EPSILON)
- [MDN Web Docs：`Number.isFinite()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/isFinite)
- [Oracle 数值计算指南：浮点算术基础](https://docs.oracle.com/cd/E19957-01/806-3568/ncg_goldberg.html)
- [IEEE 754-2019 标准](https://standards.ieee.org/ieee/754/6210/)
