Math.sumPrecise 进入 Stage 4:reduce 求和的精度问题与它的解法
项目信息
- 提案仓库:https://github.com/tc39/proposal-math-sum
- 规范文档:https://tc39.es/proposal-math-sum/
- MDN:https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/sumPrecise
- core-js 入口:https://core-js.io/v4/docs/features/proposals/math-sumprecise
- 实现状态跟踪:https://github.com/tc39/proposal-math-sum/issues/19
- test262:https://github.com/tc39/test262/pull/4049
- Python
math.fsum(同源算法):https://docs.python.org/3/library/math.html#math.fsum - Radford Neal superaccumulator 论文:https://arxiv.org/abs/1505.05571,代码:https://gitlab.com/radfordneal/xsum
提案作者/Champion 是 Kevin Gibbons。2025-07-28 进入 Stage 4,规范落在 ECMAScript 2027 的 #sec-math.sumprecise(normative PR:tc39/ecma262#3654)。MDN 标注为 Baseline 2026,2026 年 4 月起浏览器可用。可用的实现:仓库里的 polyfill/polyfill.mjs、core-js 的 proposals/math-sum 入口,以及 es-shims 的 polyfill;Boa 已实现(boa-dev/boa#4383)。
动机
对一个列表求和很常见,也是 Array.prototype.reduce 剩下的少数主要用途之一。但 .reduce((a, b) => a + b, 0) 这种朴素写法会丢精度,而知道还有更精确算法的 JS 程序员并不多。
let values = [1e20, 0.1, -1e20];
values.reduce((a, b) => a + b, 0); // 0
Math.sumPrecise(values); // 0.1
关于设计的几个问题
算法是哪个? 规范不指定实现算法,只要求给出「最大化正确」的结果:等价于先用精确的数学值求和,再舍入到最近的 64 位浮点数。polyfill 用的是 Shewchuk '96 的 grow-expansion(并额外处理了中间溢出);Python 的 math.fsum 用的是同一算法,但不带溢出处理。更新的做法是 Radford Neal 的 small/large superaccumulator。
为什么是 iterable,不是变参? 按 Math.max 的先例,变参更符合直觉,但几万元素以上的列表会把栈撑爆,抛 RangeError。所以只接受可迭代对象。
为什么叫 sumPrecise 而不叫 Math.sum? Math.sum 太顺口,反而会掩盖它更慢、算法也不同的事实,加 Precise 是为了提示这一点。
要不要做类型转换? 不做。非 Number 的元素直接抛错,这一点和 Math.max 的隐式转换先例相反。
空列表返回 0 还是 -0? 返回 -0,它是浮点加法的单位元,这样能保证 sumPrecise([]) + sumPrecise(foo) === sumPrecise(foo)。
支持 BigInt 吗? 不支持。因为空集返回 -0,5n + Math.sumPrecise(bigints) 在空集时会直接抛错。独立的 BigInt.sum / BigInt.sumFrom 不在本提案范围内,另在 proposal-bigint-math#23 讨论。
有 product 吗? 没有提出。
行为细节
Math.sumPrecise(numbers: iterable of numbers) → number。
- 传入的不是可迭代对象,或任一元素不是 number 类型,抛
TypeError;不会调用对象的valueOf/toString。 - 空的可迭代对象返回
-0,不是0。 - 结果等同于用精确数学值求和后转换到最近的 64 位浮点。
- 它并不能解决
0.1 + 0.2:Math.sumPrecise([0.1, 0.2]) === 0.30000000000000004,因为字面量本身表示的值就已经大于 0.1 / 0.2 了。
console.log(Math.sumPrecise([1, 2, 3])); // 6
console.log(Math.sumPrecise([1e20, 0.1, -1e20])); // 0.1
console.log(Math.sumPrecise([0.1, 0.2])); // 0.30000000000000004
规范算法(sec-math.sumprecise)
先 RequireObjectCoercible(items),然后取同步迭代器。初始状态是 minus-zero,sum = 0,count = 0。
循环里每一步取 IteratorStepValue:
- 如果
count >= 2^53 - 1,抛RangeError,并关闭迭代器。 - 如果取到的值不是 Number,抛
TypeError,并关闭迭代器。 - 否则走状态机:值为 NaN 时状态变为 not-a-number;
+∞时状态变为 plus-infinity(若当前是 minus-infinity 则变为 NaN);-∞时状态变为 minus-infinity(若当前是 plus-infinity 则变为 NaN);其余情况,如果n不是-0且当前状态为 minus-zero 或 finite,则把状态置为 finite 并执行sum += ℝ(n)。
收尾映射:not-a-number → NaN,plus-infinity → +∞,minus-infinity → -∞,minus-zero → -0,其余情况 → 𝔽(sum)。
有一点值得注意:迭代器会一直跑完,即使中途已经进入 NaN 或 ±∞ 的终态。