TC39JavaScript新語法數學浮點數精度

TC39 新語法深度解析(十一):Math.sumPrecise — 終結浮點數加法的精度噩夢

整理 TC39 Math.sumPrecise 提案(Stage 3):IEEE 754 浮點數加法為什麼在大數時出錯、正確的 Shewchuk 演算法,以及金融計算的實際應用場景。

· 3 分鐘閱讀

這是 TC39 新語法深度解析系列的第十一篇。先問一個問題:[1e20, 0.1, -1e20].reduce((a, b) => a + b, 0) 等於多少?答案不是 0.1——是 0。IEEE 754 浮點數在處理大數和小數同時存在的加減時,精度會丟失。Math.sumPrecise() 就是為了解決這個問題。


開場:IEEE 754 的精度噩夢

// 這個例子每個 JavaScript 開發者都應該知道
console.log(0.1 + 0.2);  // 0.30000000000000004

// 大數和小數同時存在時,更奇怪的問題:
const arr = [1e20, 0.1, -1e20];
const result = arr.reduce((a, b) => a + b, 0);
console.log(result);  // 0(錯誤!實際上應該是 0.1)

// 解釋:1e20 + 0.1 = 1e20(0.1 被忽略,因為相對精度不足)
// 1e20 + 0.1 - 1e20 = 0(錯誤!)

為什麼會這樣?IEEE 754 雙精度浮點數的有效位數只有 53 位。當 1e20(約 17 位有效數字)和 0.1(約 1 位有效數字)相加時,因為差距太大,0.1 實際上被捨入進了誤差。


Math.sumPrecise() 的解決方案

// Stage 3 提案:Math.sumPrecise
const arr = [1e20, 0.1, -1e20];

// 使用 reduce(錯誤)
console.log(arr.reduce((a, b) => a + b, 0));  // 0(錯)

// 使用 Math.sumPrecise(正確)
console.log(Math.sumPrecise(arr));  // 0.1(正確!)

API 設計

// 只接受 Iterable,不接受 variadic
Math.sumPrecise([1, 2, 3]);    // 6
Math.sumPrecise([1.1, 2.2, 3.3]);  // 6.6

// 空 Iterable 返回 0
Math.sumPrecise([]);  // 0

// NaN 或 Infinity 傳播
Math.sumPrecise([1, NaN]);  // NaN
Math.sumPrecise([1, Infinity]);  // Infinity

為什麼只接受 Iterable?

// 錯誤示範
Math.sumPrecise(1, 2, 3);  // TypeError

// 過去的做法(缺點:arguments 轉陣列有開銷)
function sum(...args) { return Math.sumPrecise(args); }

// 好處:可以處理任何大小的資料集,不需要先把所有數放到棧上

這個設計決策讓 Math.sumPrecise 可以處理非常大的陣列(數百萬筆)而不會爆棧。


空 Iterable 返回 -0

Math.sumPrecise([]);  // 返回 -0(並非 0)
// 這是數學上的一致性:空集合的加法單位元是 0
// 而在 JavaScript 中,-0 和 0 在大多數情況下是相等的
console.log(Math.sumPrecise([]) === 0);  // true

演算法:Shewchuk 1996

Math.sumPrecise 使用的是 Shewchuk 1996 提出的自適應精度加法演算法:

// 演算法的核心思想:
// 不斷追蹤計算過程中的「誤差」,並在最後加回去

// 概念上的示意(實際實作可能不同)
function sumPrecise(numbers) {
  let sum = 0;
  let compensation = 0;  // 追蹤誤差

  for (const x of numbers) {
    const adjusted = x + compensation;
    const newSum = sum + adjusted;
    // 補償:這次加法的誤差
    compensation = adjusted - (newSum - sum);
    sum = newSum;
  }

  return sum + compensation;  // 最後加回補償
}

實際應用場景

金融計算:訂單金額加總

// 過去(錯誤)
const amounts = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0];
const total = amounts.reduce((a, b) => a + b, 0);
console.log(total);  // 可能是 4.999999999999999(錯!)

// Math.sumPrecise(正確)
const total = Math.sumPrecise(amounts);
console.log(total);  // 5.5(正確!)

科學計算:感測器資料聚合

// 感測器資料可能從極小到極大
const sensorData = [0.000001, 1000000, -999999.999999, 0.999998];

// reduce 會嚴重丟失精度
const wrongSum = sensorData.reduce((a, b) => a + b, 0);
// 可能是 1.000001(嚴重錯誤)

// Math.sumPrecise 給出正確答案
const correctSum = Math.sumPrecise(sensorData);
console.log(correctSum);  // 1.000001

目前狀態:Stage 3

Math.sumPrecise 目前是 Stage 3,還沒有瀏覽器原生支援,但可以使用 proposal-math-sum 的 reference implementation 作為 polyfill:

// 作為 polyfill
import 'math-sum-precise';
console.log(Math.sumPrecise([1e20, 0.1, -1e20]));  // 0.1

總結

Math.sumPrecise 解決了一個所有 JavaScript 開發者都踩過的坑——浮點數加法的精度問題:

// 以後金融計算
const balance = Math.sumPrecise(transactions.map(t => t.amount));

// 大資料集科學計算
const total = Math.sumPrecise(sensorReadings);

這是 TC39 新語法深度解析系列的第十一篇。TC39 新語法深度解析系列到此結束。


延伸閱讀


本文基於 TC39 Math.sumPrecise 提案(Stage 3)整理。