深入理解JavaScript中的精度丟失

Nirvana-cn發表於2018-06-13

1.引子

眾所周知JavaScript僅有Number這個數值型別,而Number採用的時IEEE754規範中64位雙精度浮點數編碼。於是出現了經典的 0.1 + 0.2 === 0.30000000000000004 問題。

我們抱著知其然還要知其所以然的態度來推導一下 0.1 + 0.2 的計算過程。

2.進位制轉換

首先我們需要了解如何將十進位制小數轉為二進位制,方法如下:

對小數點以後的數乘以2,取結果的整數部分(不是1就是0),然後再用小數部分再乘以2,再取結果的整數部分……以此類推,直到小數部分為0或者位數已經夠了就OK了。然後把取的整數部分按先後次序排列

按照上面的方法,我們求取0.1的二進位制數,結果發現0.1轉換後的二進位制數為:

0.000110011001100110011(0011無限迴圈)……

所以說,精度丟失並不是語言的問題,而是浮點數儲存本身固有的缺陷。浮點數無法精確表示其數值範圍內的所有數值,只能精確表示可用科學計數法 m*2^e 表示的數值而已,比如0.5的科學計數法是2^(-1),則可被精確儲存;而0.1、0.2則無法被精確儲存。

那麼對這種無限迴圈的二進位制數應該怎樣儲存呢,總不能隨便取一個截斷長度吧。這個時候IEEE754規範的作用就體現出來了。

3.IEEE754規範

IEEE754對於浮點數表示方式給出了一種定義。格式如下:

(-1)^S * M * 2^E

各符號的意思如下:S,是符號位,決定正負,0時為正數,1時為負數。M,是指有效位數,大於1小於2。E,是指數位。

則0.1使用IEEE754規範表示就是:

(-1)^0 * 1.100110011(0011)…… * 2^-4

對於浮點數在計算機中的儲存,IEEE754規範提供了單精度浮點數編碼和雙精度浮點數編碼。

IEEE754規定,對於32位的單精度浮點數,最高的1位是符號位S,接著的8位是指數E,剩下的23位為有效數字M。

對於64位的雙精度浮點數,最高的1位是符號位S,接著的11位是指數E,剩下的52位為有效數字M。

位數 階數 有效數字/尾數
單精度浮點數 32 8 23
雙精度浮點數 64 11 52

我們以單精度浮點數為例,分析0.15625實際的儲存方式。

深入理解JavaScript中的精度丟失

0.15625轉換為二進位制數是0.00101,用科學計數法表示就是 1.01 * 2^(-3),所以符號位為0,表示該數為正。注意,接下來的8位並不直接儲存指數-3,而是儲存階數,階數定義如下:

階數 = 指數+偏置量

對於單精度型資料其規定偏置量為127,而對於雙精度來說,其規定的偏置量為1023。所以0.15625的階數為124,用8位二進位制數表示為01111100。

再注意,儲存有效數字時,將不會儲存小數點前面的1(因為二進位制有效數字的第一位肯定是1,省略),所以這裡儲存的是01,不足23位,餘下的用0補齊。

當然,這裡還有一個問題需要說明,對於0.1這種有效數字無限迴圈的數該如何截斷,IEEE754預設的舍入模式是:

Round to nearest, ties to even

也就是說舍入到最接近且可以表示的值,當存在兩個數一樣接近時,取偶數值。

4.回到 0.1 +0.2===0.30000000000000004 的問題

JavaScript是以64位雙精度浮點數儲存所有Number型別值,按照IEEE754規範,0.1的二進位制數只保留52位有效數字,即 1.100110011001100110011001100110011001100110011001101 * 2^(-4)。 我們以 - 來分割符號位、階數位和有效數字位,則0.1實際儲存時的位模式是0 - 01111111011 - 1001100110011001100110011001100110011001100110011010。

同理,0.2的二進位制數為1.100110011001100110011001100110011001100110011001101 * 2^(-3), 因此0.2實際儲存時的位模式是0 - 01111111100 - 1001100110011001100110011001100110011001100110011010。

將0.1和0.2按實際展開,末尾補零相加,結果如下:

 0.00011001100110011001100110011001100110011001100110011010
+0.00110011001100110011001100110011001100110011001100110100
------------------------------------------------------------
=0.01001100110011001100110011001100110011001100110011001110
複製程式碼

只保留52位有效數字,則(0.1 + 0.2)的結果的二進位制數為 1.001100110011001100110011001100110011001100110011010 * 2^(-2), 省略尾數最後的0,即 1.00110011001100110011001100110011001100110011001101 * 2^(-2), 因此(0.1+0.2)實際儲存時的位模式是 0 - 01111111101 - 0011001100110011001100110011001100110011001100110100。

(0.1 + 0.2)的結果的十進位制數為0.30000000000000004,至此推導完成。

我們可以在chrome上驗證我們的推導過程是否和瀏覽器一致。

菜鳥工具也提供了豐富的進位制轉換功能可以讓我們驗證結果的準確性。

(0.1).toString('2')
// "0.0001100110011001100110011001100110011001100110011001101"
(0.2).toString('2')
// "0.001100110011001100110011001100110011001100110011001101"
(0.1+0.2).toString('2')
// "0.0100110011001100110011001100110011001100110011001101"
(0.3).toString('2')
// "0.010011001100110011001100110011001100110011001100110011"
複製程式碼

5.解決精度丟失的問題

5.1類庫

NPM上有許多支援JavaScript和Node.js的數學庫,比如math.js,decimal.js,D.js等等

5.2 原生方法

toFixed()方法可把Number四捨五入為指定小數位數的數字。但並代表該方法是可靠的。chrome上測試如下:

1.35.toFixed(1) // 1.4 正確
1.335.toFixed(2) // 1.33 錯誤
1.3335.toFixed(3) // 1.333 錯誤
1.33335.toFixed(4) // 1.3334 正確
1.333335.toFixed(5)  // 1.33333 錯誤
1.3333335.toFixed(6) // 1.333333 錯誤
複製程式碼

我們可以把toFix重寫一下來解決。通過判斷最後一位是否大於等於5來決定需不需要進位,如果需要進位先把小數乘以倍數變為整數,加1之後,再除以倍數變為小數,這樣就不用一位一位的進行判斷。參考文章

5.3 ES6

ES6在Number物件上新增了一個極小的常量——Number.EPSILON

Number.EPSILON
// 2.220446049250313e-16
Number.EPSILON.toFixed(20)
// "0.00000000000000022204"
複製程式碼

引入一個這麼小的量,目的在於為浮點數計算設定一個誤差範圍,如果誤差能夠小於Number.EPSILON,我們就可以認為結果是可靠的。

誤差檢查函式(出自《ES6標準入門》-阮一峰)

function withinErrorMargin (left, right) {
    return Math.abs(left - right) < Number.EPSILON
}
withinErrorMargin(0.1+0.2, 0.3)
複製程式碼

相關文章