同事分享的浮點運算案例。

這些年大家都已知道「算錢用浮點,遲早被人扁」,差 0.01 都會鬧出人命的應用,在 C# 乖乖用 decimal 就對了,但說來奇怪。JavaScript 這些年紅透半邊天,不管前端、後端、行動裝置 App、物聯網裝置什麼平台都能沾上邊,偏偏在數字型別方面沒半點長進,還是只有 Number 雙精度浮點數(IEEE 754標準)一種可用,於是用 JavaScript 算帳總是充滿凶險。

同事寫了四捨五入到指定小數位數的函式,初步測試感覺良好:

function myRound(v, n = 0) {
    let t = Math.pow(10, n);
    return Math.round(v*t)/t;
}
function test(v) {
  console.log(myRound(v, 3));
  console.log(myRound(v, 2));
  console.log(myRound(v, 1));
  console.log(myRound(v, 0));
}
test(12.34567);
test(12.51234);

不過,再跑多點測試就發現問題,這個寫法遇上惡名昭彰的 999999 結尾會出現非預期的結果,例如:45.12 + 0.3 + 0.08,應等於 45.5,若四捨五入到整數應該等於 46。但在 JavaScript,45.12 + 0.3 + 0.08 = 45.49999999999999,四捨五入取 1 - 3 位小數沒問題,若四捨五入到整數,會會因 0.49999999999999 不到 0.5 會被捨去,答案是 45 而不是 46。

(順手也測了 C#,用 float/double 計算結果為 46,倒沒遇到 JavaScript 的問題。注意:C# 的 Math.Round 預設採「四捨六入五成雙」進位,若要「四捨五入」不要忘記加上 MidpointRounding.AwayFromZero 參數)

[2021-11-08 更新]以下解法在某些案例(例如:1.005)會產生誤差,請使用改良版

我想到的解法是偵測當數字含小數達到精準度上限(整數加小數共 16 位)時,小數部分先四捨五入減少一位,已知問題是萬一該精確度極限數字非浮點運算誤差產生時,將喪失一位精確度,強迫四捨五入到 15 位,但評估實際上遇到的機率不高。

function safeRound(v, n = 0) {
    // 含小數達16位數時,小數部分四捨五入減少一位
    // 注意:若該精確度極限數字非浮點運算產生時會被喪失一位精確度
    let p = v.toString().split('.');
    if (p.length == 2) {
      let intLen = p[0].length;
      let decLen = p[1].length;
      if (intLen + decLen >= 16) {
        let tt = Math.pow(10, decLen - 1);
        v = Math.round(v * tt) / tt;
      }
    }
    let t = Math.pow(10, n);
    return Math.round(v * t) / t;
}
function test(v) {
  console.log(safeRound(v, 2));
  console.log(safeRound(v, 1));
  console.log(safeRound(v, 0));
}
test(12.34567);
test(12.51234);
test(12.468);
var f = 45.12 + 0.3 + 0.08;
console.log(f);
test(f);
f = 45.4999999999999; // 15位
console.log(f);
test(f);

實測看來 OK,大家如果有其他點子也歡迎提供。 實測發現以上做法有些問題,請使用改良版

補充:有個開源程式庫 decimal.js提供更精準及多位數的數字計算功能,但加減乘除需寫成 a = Decimal.add(x, y); b = new Decimal(x).plus(y); a.equals(b); 有點繁瑣,但在 JavaScript 內建支援 Decimal 型別前,也沒有其他更好的方法。參考:【筆記】Javascript 大數字與浮點數的計算處理 (decimal.js) by JS Ying

Tips of how to deal with float difference issue while Math.round().


Comments

# by citypig

我有個疑問,f = 45.12 + 0.3 + 0.08,應等於 45.5。 既然是因為浮點數計算有誤 (f=45.49999999999999),難道不是應該先修正錯誤再丟給四捨五入的函數去做計算嗎? 對於修改精確度為 15 位數的方法,我可能會這樣寫: var f = 45.12 + 0.3 + 0.08; //45.49999999999999 if(f % 1 !== 0){ // 判斷是否為浮點數 f = parseFloat(f.toPrecision(15)); } console.log(f); // 45.5

# by Jeffrey

to citypig, 謝謝分享,你的寫法更優雅。學到兩招 % 1、toPrecision()。

# by Niten

提供另一個作法 consloe.log((0.2 + 0.1).toFixed(2));

Post a comment