1. 程式人生 > >記一次 JavaScript 浮點型數字誤差引發的問題

記一次 JavaScript 浮點型數字誤差引發的問題

需求

車間的工人在生產出來產品後,需要完成初步的自檢,並通過手機上報。在實際生產中,使用者(工人)不方便進行數值的輸入,因而表單中的一些項設計成 picker 模式以供選取數值。數值的取值範圍,根據允許的誤差範圍生成。示例如下:

示例一
// 誤差
0.01mm ~ 0.06mm
// picker 展示的數值
0.01, 0.02, 0.03, 0.04, 0.05, 0.06

示例二
// 誤差
15mm ~ 18mm
// picker 展示的數值
15, 16, 17, 18

示例三
// 誤差
1.05mm ~ 1.1mm
// picker 展示的數值
1.05, 1.06, 1.07, 1.08, 1.09, 1.1

由以上例子可以得知,取值範圍的計算是根據誤差範圍的最小值的最小位數作為基數,從最小值(包含)逐步累加至最大值(包含)。

實現

首先,根據最小值獲取小數位的個數。

function getDecimalPlace(value) {
    // 先將 Number 轉換為 String
    value = value + '';
    // 查詢小數點的位置,加 1 是為了方便計算小數點的位數。
    var floatIndex = value.indexOf('.') + 1;
    // 返回的結果是小數位的個數
    return floatIndex ? value.length - floatIndex : 0;
}

用幾個實際的數值,測試一下這個方法。

getDecimalPlace(1); //0
getDecimalPlace('1.0'); //0
getDecimalPlace('1.5'); //1
getDecimalPlace('1.23'); //2

然後,根據小數位的個數計算累加的基數。

var min = 0.01;
var max = 0.06;

var decimal = getDecimalPlace(min);
// 基數
var radixValue = Math.pow(10, -decimal);

最後,根據誤差範圍和基數,迴圈生成取值範圍。

var value = min;
var range = [];

for (; value <= max; value += radixValue) {
    range.push(value);
}
console.log(range);
//結果:[0.01,0.02,0.03,0.04,0.05]

從結果來看,好像哪裡不對。沒錯,最大值 0.06 沒有出現在取值範圍中。

問題

JavaScript 採用了 IEEE-754 浮點數表示法。這是一種二進位制表示法,二進位制浮點數表示法並不能精確表示類似 0.1 這樣簡單的數字。

通過一個簡單的例子來驗證上面這段話。

var num1 = 0.2 - 0.1;
var num2 = 0.3 - 0.2;
console.log(num1 === num2); //false
console.log(num1 === 0.1); //true
console.log(num2 === 0.1); //false

由此可知,前面計算取值範圍的方法中,遇到了類似的問題。

var max = 0.06;
var value = 0.05;
console.log(value + 0.01 === max); //false

因為從 0.05 + 0.01 的結果並不等於 0.06,所以迴圈只執行了 5 次(而非預期的 6 次)就結束了。

在嘗試修復此問題之前,先把前面的程式碼封裝一下。

function getRange(min, max) {
    var decimal = getDecimalPlace(min);
    var radixValue = Math.pow(10, -decimal);

    var value = min;
    var range = [];

    for (; value <= max; value += radixValue) {
        range.push(value);
    }
    
    return range;
}

解決問題

最簡單粗暴的辦法,就是調整迴圈條件,在迴圈結束後再將最大值新增至陣列。

function getRange(min, max) {
    var decimal = getDecimalPlace(min);
    var radixValue = Math.pow(10, -decimal);

    var value = min;
    var range = [];

    for (; value < max; value += radixValue) {
        range.push(value);
    }
    range.push(max);
    
    return range;
}

再次使用之前的資料測試:

getRange(0.01, 0.06);
//結果:[0.01,0.02,0.03,0.04,0.05,0.06]

執行結果與預期一致,問題解決。

新的問題

然而,後續的測試中又出現了意外。

getRange(1.55, 1.65);
// 結果:[1.55,1.56,1.57,1.58,1.59,1.6,1.61,1.62,1.6300000000000001,1.6400000000000001,1.65]

1.6300000000000001 這樣的數值,顯然不是我們期望得到的。出現此現象,與之前的問題原因一致。

方案一

將參與計算的數值,先轉換為整型,再進行計算。

function getRange(min, max) {
    var decimal = getDecimalPlace(min);
    var radixValue = Math.pow(10, -decimal);

    var multi = Math.pow(10, decimal)

    var value = min * multi;
    var range = [];

    for (; value < max * multi; value += radixValue * multi) {
        range.push(value / multi);
    }
    range.push(max);

    return range;
}

注意事項:

  • 向陣列中新增數值時,需要再除以倍數,得到最終的數值。

方案二

使用 toFixed() 方法,對浮點型進行格式化。

function getRange(min, max) {
    var decimal = getDecimalPlace(min);
    var radixValue = Math.pow(10, -decimal);

    var value = min;
    var range = [];

    for (; value < max || +value.toFixed(decimal) === max; value += radixValue) {
        range.push(+value.toFixed(decimal));
    }

    return range;
}

注意事項:

  • toFixed() 方法返回的值是 String 型別,因此需要再轉換為 Number 型別。
  • 做了一點優化,調整迴圈條件後,移除了迴圈外 push() 最大值的語句。

最後

JavaScript 中浮點型精度的誤差,是非常基礎但是卻又經常不被重視的問題。文中分享的方案,足以覆蓋專案中的所有情況,但如果用在其它地方或專案中,在一些極端情況下可能會有問題。

參考資料

  • 《JavaScript 權威指南(第 6 版)》
  • Math.pow()
  • Number.prototype.toFixed()