Math.sumPrecise 能精确到哪一步
在浏览器控制台试 Math.sumPrecise([0.1, 0.2]),结果还是 0.30000000000000004。看到函数名里的 Precise,很容易以为这次终于能得到 0.3。但把输入换成一个大数、一个小数,再加上那个大数的相反数,结果就变了。
const values = [1e20, 0.1, -1e20];
console.log(values.reduce((total, value) => total + value, 0)); // 0
console.log(Math.sumPrecise(values)); // 0.1
console.log(Math.sumPrecise([0.1, 0.2])); // 0.30000000000000004
两组输入看起来都在问精度,区别在于精度在哪一步丢了。第一组的小数在累加过程中被舍掉。第二组的十进制数在调用开始前就已经转换成了二进制浮点数。
先存下每个加数,最后只舍入一次
1e20 附近的两个相邻 Number 间隔比 0.1 大得多。1e20 + 0.1 当场舍入回 1e20,接下来再减去 1e20,就只剩 0。如果先让正负两个大数抵消,再加 0.1,普通加法也能得到 0.1。差异出在每一步加法都要把中间值放回 Number。
有限数先按它们实际代表的数学值相加,最后把总和舍入为一个 Number。这是 ECMAScript 2026 对 Math.sumPrecise 的定义。
参数可以是数组或其他可迭代对象。元素必须是 Number,遇到字符串或 BigInt 会抛出 TypeError。空输入返回 -0,可以用 Object.is(Math.sumPrecise([]), -0) 验证。正负无穷同时出现时结果为 NaN。
V8 如何保住中间的小数
如果每轮只用一个 double 保存总和,C++ 实现也会遇到同样的中间舍入。V8 在引入该内建方法的实现里,先把普通有限值交给 Xsum,在有限结果分支里才调用 Round。下面是这两个分支中的关键语句,中间省略了特殊值处理和遍历逻辑。
xsum_.Add(n);
// …
return *isolate->factory()->NewNumber(xsum_.Round());
Xsum::Add1NoCarry 和 Xsum::Round 的实现来自 Radford M. Neal 的 superaccumulator 算法。可以把它想成一把跨越很多二进制位的尺子。实现拆开浮点数的符号、指数和有效数,用指数定位相应的块,把有效数拆到相邻的整数块里累加。块之间有重叠位,可以暂存进位,等需要时再传播。最后归一化这些块,按最接近且中点取偶的规则生成一个双精度结果。
V8 这版用了 67 个 64 位整数块,块之间重叠 32 位。下面是 Add1NoCarry 的正数分支,chunk_it 指向指数选中的块。
*chunk_it += split_mantissa[0];
*(chunk_it + 1) += split_mantissa[1];
大数落在高位、小数落在低位。即使大数抵消,低位也留在累加器里。到了 Round 的正数舍入分支,代码会检查被舍弃的位。若恰好在中点,就看保留位的奇偶决定是否进位。其他引擎可以采用不同算法,只要达到相同的舍入结果。
为什么两个加数还是得不到 0.3
Number 采用二进制双精度。像 1/2、1/8 这样的分数可以用有限个二进制位表示。1/10 的分母约分后还有因子 5,所以它只能存成相邻的某个二进制值。控制台把这个值打印成能还原它的短十进制字符串,才让人看起来像是存进了精确的 0.1。
把三个相关值写成同一个分母,就能看见最后一次舍入到底选了谁。表里用的是 Number 实际代表的有理数。
| 值 | 用分母 2⁵⁵ 表示时的分子 |
|---|---|
字面量 0.1 |
3,602,879,701,896,397 |
字面量 0.2 |
7,205,759,403,792,794 |
| 上面两个二进制值的精确和 | 10,808,639,105,689,191 |
较低的相邻 Number,显示为 0.3 |
10,808,639,105,689,190 |
较高的相邻 Number,显示为 0.30000000000000004 |
10,808,639,105,689,192 |
精确和刚好夹在两个可表示值中间。这个数量级的相邻 Number 在表里每次跨两个单位。把上下两个候选都写成以 2⁵⁴ 为分母的分数,较低一侧的分子是奇数,较高一侧是偶数。按规范的中点取偶规则,返回较高的一侧。普通加法在这个例子中本来也只做了一次有效舍入,所以两者碰巧返回同一个值。
如果要得到 0.3,先看结果拿来做什么
字符串 '0.3' 可以直接展示。记账或继续做十进制运算时,输入和中间结果也得保留相应的精度。
页面只展示一位小数
用格式化把显示规则放到输出边界。下面拿到的是字符串,后续运算仍应使用原来的数值。
const total = 0.1 + 0.2;
console.log(total.toFixed(1)); // '0.3'
账本按固定最小单位记账
从输入开始就用整数表示最小单位。例如金额以分存储,10 分加 20 分等于 30 分。数据量可能超过安全整数范围时,用 BigInt 存储和累加。百分比、税费或汇率引入更小单位时,另行定义舍入时机与舍入规则。
const totalCents = 10n + 20n;
console.log(totalCents); // 30n
业务需要保留十进制运算
从原始十进制字符串构造 decimal.js 的值,把十进制语义保留到输出,按业务配置有效数字精度与舍入模式。如果上游已经算出 0.30000000000000004,再转 Decimal 也无法知道原来的业务输入是什么。
import Decimal from 'decimal.js';
const total = new Decimal('0.1').plus('0.2');
console.log(total.toString()); // '0.3'
在目标运行时验证
V8 的发布记录确认 Chrome 147 默认启用。Node.js 26.12.0 上执行 node -p "typeof Math.sumPrecise" 得到 undefined。上面的两组结果可以用该版本的 V8 实验开关复现。
node --js-sum-precise -p "Math.sumPrecise([0.1, 0.2])"
node --js-sum-precise -p "Math.sumPrecise([1e20, 0.1, -1e20])"
Node.js 27 的首个 alpha已经把它列为默认启用的特性。对浏览器与 SSR 共用的代码,应按两个实际运行时分别检查可用性。需要兼容实现时,逐项加法无法保留相同的数值保证。
总结
Math.sumPrecise 精确累计已读入的二进制浮点值,然后把总和舍入一次。测量数据里,小量容易在普通累加中被大数吞掉,这正是它能解决的事。展示、记账和十进制计算各有不同的输出契约,对应格式化、最小单位整数和十进制运算三种做法。