Skip to main content

浮點數的加減

1.335.toFixed(2) // '1.33' 這真是一個尷尬的錯誤

情境描述

用 Javascript 來做加減乘除,如果是有小數點的數字的話,跑出來的數字會非常奇特,例如:

const number1 = 5.1;
const number2 = 100.0;
console.log(number1 * number2);

// 509.99999999999994

原因

發現主要問題在於 Javascript 是用二進制來計算數值,之後再轉換成十進制,舉個例子,0.1 + 0.2,在 Javascript 會是這樣子計算的。

// 0.1 和 0.2 都轉化成二進位制後再進行運算
0.00011001100110011001100110011001100110011001100110011010
+
0.0011001100110011001100110011001100110011001100110011010
=
0.0100110011001100110011001100110011001100110011001100111

// 轉成十進位制正好是 0.30000000000000004

精度遺失 (Loss of Precision)

由於位元數有限,浮點數只能提供一定範圍的有效數字(Significant Digits)。

  • 單精度 (float): 大約 7 位有效數字。
  • 雙精度 (double): 大約 15-16 位有效數字。

超過這個範圍的數字會被丟棄或變得不準確。

大數吃小數 (Absorption / Loss of Significance)

當一個「非常大的數」與一個「非常小的數」進行加減運算時,小的數可能會完全消失。

  • 原理:浮點數運算時需要對齊指數(Exponent)。為了讓小數的指數與大數對齊,小數的尾數(Mantissa)會不斷右移,直到被移出有效位數範圍,變成 0。
  • 例子: $$1,000,000,000 + 0.000000001 = 1,000,000,000$$ (那個 $0.000000001$ 在運算過程中被忽略了)

嚴重相消 (Catastrophic Cancellation)

當兩個「非常接近」的大數相減時,有效位數會大幅減少,剩下的結果可能只是「雜訊」。

  • 例子:$A = 1.23456789$,$B = 1.23456788$ $A - B$ 的結果理應是 $0.00000001$。但在電腦內部,因為之前的捨入誤差,相減後的剩餘部分可能包含巨大的相對誤差。

結合律失效 (Non-Associativity)

在數學上,$(A + B) + C$ 等於 $A + (B + C)$。但在浮點數運算中,這不一定成立。

  • 原因:因為「大數吃小數」的問題,運算的順序會影響結果。
  • 建議:若要加總一系列數字,最好先將數字從小到大排序,先加總小的數字,最後再加大的數字(Kahan Summation Algorithm)。
note

永遠不要使用 == 來判斷兩個浮點數是否相等。 錯誤寫法:if (a == b) ... 原因:因為微小的捨入誤差,兩個理應相等的數字可能在二進位中差了最後一個 bit。

解決方法

  1. 外部套件

    引用 Math.js 或者 BigDecimal.js 等,均可以順利解決浮點數運算的問題。

  2. 自訂四則運算程式碼

    //除法
    function accDiv(arg1, arg2) {
    var t1 = 0,
    t2 = 0,
    r1,
    r2;
    try {
    t1 = arg1.toString().split(".")[1].length;
    } catch (e) {}
    try {
    t2 = arg2.toString().split(".")[1].length;
    } catch (e) {}
    with (Math) {
    r1 = Number(arg1.toString().replace(".", ""));
    r2 = Number(arg2.toString().replace(".", ""));
    return (r1 / r2) * pow(10, t2 - t1);
    }
    }

    //乘法
    function accMul(arg1, arg2) {
    var m = 0,
    s1 = arg1.toString(),
    s2 = arg2.toString();
    try {
    m += s1.split(".")[1].length;
    } catch (e) {}
    try {
    m += s2.split(".")[1].length;
    } catch (e) {}
    return (Number(s1.replace(".", "")) * Number(s2.replace(".", ""))) / Math.pow(10, m);
    }

    //加法
    function accAdd(arg1, arg2) {
    var r1, r2, m;
    try {
    r1 = arg1.toString().split(".")[1].length;
    } catch (e) {
    r1 = 0;
    }
    try {
    r2 = arg2.toString().split(".")[1].length;
    } catch (e) {
    r2 = 0;
    }
    m = Math.pow(10, Math.max(r1, r2));
    return (arg1 * m + arg2 * m) / m;
    }

    //減法
    function accSubtr(arg1, arg2) {
    var r1, r2, m, n;
    try {
    r1 = arg1.toString().split(".")[1].length;
    } catch (e) {
    r1 = 0;
    }
    try {
    r2 = arg2.toString().split(".")[1].length;
    } catch (e) {
    r2 = 0;
    }
    m = Math.pow(10, Math.max(r1, r2));
    n = r1 >= r2 ? r1 : r2;
    return ((arg1 * m - arg2 * m) / m).toFixed(n);
    }

一、前言

Javascript 中,常見的與浮點數相關的操作(運算)有:+-*/toFixedparseFloatMath.roundMath.floortoString… 這些操作(運算)的結果,有些情況下和數學上定義的結果是不同的,如果不加注意,會影響產品功能的正常運行,俗稱有 bug

  • 以下幾個表達式的計算結果是什麼樣子的?

    0.1 + 0.2 = ?
    0.1 + 0.2 === 0.3 ?
    0.3 - 0.1 = ?
    0.145 * 100 = ?
    0.15.toFixed(1) = ?

答案:

  1. 0.1 + 0.2 = ? 0.30000000000000004
  2. 0.1 + 0.2 === 0.3 ? false
  3. 0.3 - 0.1 = ? 0.19999999999999998
  4. 0.145 * 100 = ? 14.499999999999998
  5. 0.15.toFixed(1) = ? 0.1

二、why

2.1 JS 精度為何會丟失

機器本來如此,無法精確表達所有數學結果,只能近似表達。 不展開,上鏈接: 0.1 在內存中的樣子。 不同編程語言中會有近似處理,盡量讓結果準確。

2.2 toFixed 不是四捨五入

是 4 捨 6 入,捨棄位為 5 時,結果不穩定。

思考:在明確知道以上坑的情況下,如何避免寫 bug?

三、實戰

3.1 工作背景

  1. 難以避免計算結果出現小數的情況,比如價格計算、油耗計算等
  2. 因為各種原因,輸入數據是小數

3.2 實際場景

加油中:原單價 5.22 元/L(a),現價 5.11 元/L(b),加 100 塊錢(total),能加多少 L 油(v)?實際支付多少(actual),優惠多少(discount)?

挑戰:服務商會進行金額校驗,校驗不通過會導致下單失敗。

  1. 有中間步驟,每一步都(可能)有精度丟失

    v = total / a = 100 / 5.22 ≈ 19.16L (除法,toFixed)actual = v * b ≈ 97.91元 (乘法,toFixed,基於上一步保留兩位小數的結果)discount = total - actual = 2.09元 (減法,toFixed)
  2. 公式化簡,去除中間步驟,v 不參與實際價格的計算

    v = total / a = 100 / 5.22 ≈ 19.16L (除法,toFixed)actual = total / a * b ≈ 97.89元 (除法,乘法,toFixed)discount = total - actual ≈ 2.11元 (減法,toFixed)
  3. 盡量避免小數運算,化為整數單位後再計算,轉為整數時需要注意處理乘法帶來的精度問題,計算結果需要再按增大的倍數縮小回來

    // v 的計算只有一步,放大100倍計算後再縮小100,結果不變aCent = Math.round(a * 100) = 522(沒問題)bCent = Math.round(b * 100) = 511(沒問題)actual = total / aCent * bCent ≈ 97.89 (toFixed)這一步還是有坑(69.145.toFixed(2) = 69.14)discount = total - actual ≈ 2.11元(減法,toFixed)
  4. 所有計算,化為整數,且盡量都轉化為最小單位值,如(分,毫升)

    // v 的計算只有一步,放大100倍計算後再縮小100,結果不變,而且也不需要轉換為毫升aCent = Math.round(a * 100) = 522(沒問題)bCent = Math.round(b * 100) = 511(沒問題)totalCent = Math.round(toal * 100) = 10000(沒問題)actualCent = Math.round(totalCent / aCent * bCent) ≈ 9789(沒問題)discountCent = totalCent - actualCent = 211元(沒問題)actual = actualCent / 100 = 97.89(沒問題)?不放心?可以繼續做一次toFixed,然後再parseFloat回來discount = discountCent / 100 = 2.11(沒問題)

3.3 toFixed 結果如何變得符合數學預期?

單純的考慮,如何讓 toFixed 變得符合預期?

// return num.toFixed(keep)function customeToFixed(num: number, keep: number): number {}

思路:

  • 捨棄位為 5 時,特殊處理
  • 既然 Number.toFixed 是四捨六入,那就在捨入位上加 1 讓其成為 6
  • 又考慮到小數加法有精度丟失問題,捨入位加 1 後還是會出現捨入位為 5 的情況,所以應該加 2(0.1435+0.0001=0.14359999999999998,但是,實際上這時候 toFixed(3)結果是正確的,但是加 2 更穩妥)
/** * 四捨五入(支持保留n位小數,n>=0) * @param {any} x 原數字 *                如果n不是合法數字或者無法轉換為合法數字,round結果返回NaN * @param {any} n 保留幾位小數,默認0 *                如果n不是合法數字或者無法轉換為合法數字,round結果返回NaN *                如果n小於0,round結果返回NaN *                如果n的值包含小數部分,round處理時只關注n的整數部分值 * @return {number} 返回一個保留n位小數的數字,異常情況下可能是NaN */function round(x, n = 0) {  return parseFloat(roundStr(x, n, false));}/** * 四捨五入,並返回格式化的字符串 * 支持保留n位小數,n>=0,如 round(1.325, 2)=1.33 * 支持格式化字符串時取出末尾的0,如round(1.109, 2, true)=1.1 * @param {any} x 原數字 *                如果n不是合法數字或者無法轉換為合法數字,roundStr結果返回'' * @param {any} n 保留幾位小數,默認0 *                如果n不是合法數字或者無法轉換為合法數字,roundStr結果返回'' *                如果n小於0,roundStr結果返回'' *                如果n的值包含小數部分,roundStr處理時只關注n的整數部分值 * @param {boolean} removeTrailingZero 是否移除字符串末尾的無效數字0 * @return {string} 返回四捨五入後的字符串,異常情況下返回空字符串'' */function roundStr(x, n = 2, removeTrailingZero = false) {  let xNum = Number(x); // x轉換為數字  const nNum = Math.floor(Number(n)); // n轉換為數字,且只保留整數部分  // 異常情況,返回''  if (isNaN(xNum) || isNaN(nNum) || nNum < 0) return '';  // 僅保留整數的情況  if (nNum === 0) return Math.round(xNum);  // 保留n位小數的情況  const xStr = xNum.toString();  const rexExp = new RegExp(`\\.\\d{${nNum}}5`);  // 1. 大部分情況下,四捨五入使用Number.toFixed即可  // 2. 然而,Number.toFixed方法在某些情況下對第n+1位是5的四捨五入存在問題,如1.325保留  //     2位小數時結果為1.32(期望為1.33),對此種情況下,有兩種處理方式:  //    2.1 先擴大10^n倍,捨掉小數部分取整數部分,然後加1,最後縮小10^n倍。但此種情況下,  //         不能處理過大的數字,也不能處理保留小數位數過多的情況,會可能導致數字超過Infinity  //    2.2 Number.toFixed是四捨6入,對於第n+1位是5的情況,增加2*10^(-n-1),保證滿足  //         第n+1位>6。增加2*10^(-n-1)而不是增加1*10^(-n-1),是因為後者不能保證第n+1位>=6,  //         例如1.325+0.001=1.32599999...第n+1位仍然為5  // 此處,採用2.2方式,解決Number.toFixed的問題,又能避免2.1方式中數字超過Infinity的問題  if (rexExp.test(xStr)) { // 情況2,處理方式2.1:如果小數部分第n+1位是5,增加2*10^(-n-1)    xNum += 2 * (10 ** (-nNum - 1));  }  const str = xNum.toFixed(nNum);  if (!removeTrailingZero) return str;  // 去除末尾的0  if (/^\d+\.0*$/.test(str)) { // 小數部分全是0    return str.replace(/^(\d+)(\.0*)$/, (_m, s1) => s1);  }  return str.replace(/^(\d+\.\d*[1-9]{1})(0*)$/, (_m, s1) => s1);}

四、總結

4.1 經驗和提醒

  1. 根據數學邏輯進行化簡,避免中間步驟
  2. 將運算數處理為整數進行運算,最後將結果按比例還原為小數
  3. 根據實際場景,將運算數轉為關心的最小精度單位後再進行計算
  4. 四捨五入時,整數使用 Math.round,小數使用優化後的 toFixed

4.2 缺點/注意事項/思考

以上都只能是經驗和提醒,不是聖經,實操時要根據實際場景需求來調整

  1. 【注意】要不要中間步驟,需要根據實際情況來看。如果和合作方約定的協議需要有中間步驟,或者實際情況下不得不有中間步驟,則需要保留中間步驟
  2. 【缺點】化為整數過程中,能夠處理的最大最小數字有限,會有可能產生溢出
  3. 暫無
  4. 暫無
  5. 【思考】其他未知的坑,浮點數的 parseFloat、toString 一定安全嗎?
// 解決 toFixed 四捨六入的問題 REF: http://www.sfg.name/article/code/19.html
export function roundStr(x, n = 2, removeTrailingZero = false) {
let xNum = Number(x); // x轉換為數字
const nNum = Math.floor(Number(n)); // n轉換為數字,且只保留整數部分
// 異常情況,返回''
if (isNaN(xNum) || isNaN(nNum) || nNum < 0) return "";
// 僅保留整數的情況
if (nNum === 0) return Math.round(xNum);
// 保留n位小數的情況
const xStr = xNum.toString();
const rexExp = new RegExp(`\\.\\d{${nNum}}5`);
// 1. 大部分情況下,四捨五入使用Number.toFixed即可
// 2. 然而,Number.toFixed方法在某些情況下對第n+1位是5的四捨五入存在問題,如1.325保留
// 2位小數時結果為1.32(期望為1.33),對此種情況下,有兩種處理方式:
// 2.1 先擴大10^n倍,捨掉小數部分取整數部分,然後加1,最後縮小10^n倍。但此種情況下,
// 不能處理過大的數字,也不能處理保留小數位數過多的情況,會可能導致數字超過Infinity
// 2.2 Number.toFixed是四捨6入,對於第n+1位是5的情況,增加2*10^(-n-1),保證滿足
// 第n+1位>6。增加2*10^(-n-1)而不是增加1*10^(-n-1),是因為後者不能保證第n+1位>=6,
// 例如1.325+0.001=1.32599999...第n+1位仍然為5
// 此處,採用2.2方式,解決Number.toFixed的問題,又能避免2.1方式中數字超過Infinity的問題
if (rexExp.test(xStr)) {
// 情況2,處理方式2.1:如果小數部分第n+1位是5,增加2*10^(-n-1)
xNum += 2 * 10 ** (-nNum - 1);
}
const str = xNum.toFixed(nNum);
if (!removeTrailingZero) return str;
// 去除末尾的0
if (/^\d+\.0*$/.test(str)) {
// 小數部分全是0
return str.replace(/^(\d+)(\.0*)$/, (_m, s1) => s1);
}
return str.replace(/^(\d+\.\d*[1-9]{1})(0*)$/, (_m, s1) => s1);
}

參考文章: