為什麼0.1 + 0.2 === 0.30000000000000004

為什麼0.1 + 0.2 === 0.30000000000000004

一天有個朋友問我“JS 中計算 0。7 * 180 怎麼會等於 125。99999999998,坑也太多了吧!”那時我猜測是二進位制表示數值時發生 round-off error 所導致,但並不清楚具體是如何導致,並且有什麼方法去規避。於是用了 3 周時間靜下心把這個問題搞懂,在學習的過程中還發現不僅

0.7 * 180==125.99999999998

,還有以下的坑

1. 著名的 0.1 + 0.2 === 0.30000000000000004

2. 1000000000000000128 === 1000000000000000129

IEEE 754 Floating-point

眾所周知 JS 僅有 Number 這個數值型別,而 Number 採用的時 IEEE 754 64 位雙精度浮點數編碼。而浮點數表示方式具有以下特點:

浮點數可表示的值範圍比同等位數的整數表示方式的值範圍要大得多;

浮點數無法精確表示其值範圍內的所有數值,而有符號和無符號整數則是精確表示其值範圍內的每個數值;

浮點數只能精確表示 m*2e 的數值;

當 biased-exponent 為 2e-1-1 時,浮點數能精確表示該範圍內的各整數值;

當 biased-exponent 不為 2e-1-1 時,浮點數不能精確表示該範圍內的各整數值。

由於部分數值無法精確表示(儲存),於是在運算統計後偏差會愈見明顯。

想了解更多浮點數的知識可參考以下文章:

《基礎野:細說原碼、反碼和補碼》

《基礎野:細說無符號整數》

《基礎野:細說有符號整數》

《基礎野:細說浮點數》

Why 0.1 + 0.2 === 0.30000000000000004?

在浮點數運算中產生誤差值的示例中,最出名應該是 0。1 + 0。2 === 0。30000000000000004 了,到底有多有名?看看這個網站就知道了(http://0。30000000000000004。com/)。

也就是說不僅是 JavaScript 會產生這種問題,只要是採用 IEEE 754 Floating-point 的浮點數編碼方式來表示浮點數時,則會產生這類問題。

下面我們來分析整個運算過程。

0。1 的二進位制表示為 1。1001100110011001100110011001100110011001100110011001 1(0011)+ * 2^-4;

當 64bit 的儲存空間無法儲存完整的無限迴圈小數,而 IEEE 754 Floating-point 採用 round to nearest, tie to even 的舍入模式,因此 0。1 實際儲存時的位模式是 0-01111111011-1001100110011001100110011001100110011001100110011010;

0。2 的二進位制表示為 1。1001100110011001100110011001100110011001100110011001 1(0011)+ * 2^-3;

當 64bit 的儲存空間無法儲存完整的無限迴圈小數,而 IEEE 754 Floating-point 採用 round to nearest, tie to even 的舍入模式,因此 0。2 實際儲存時的位模式是 0-01111111100-1001100110011001100110011001100110011001100110011010;

實際儲存的位模式作為運算元進行浮點數加法,得到 0-01111111101-0011001100110011001100110011001100110011001100110100。轉換為十進位制即為 0。30000000000000004。

Why 0.7 * 180===125.99999999998?

0。7 實際儲存時的位模式是 0-01111111110-0110011001100110011001100110011001100110011001100110;

180 實際儲存時的位模式是 0-10000000110-0110100000000000000000000000000000000000000000000000;

實際儲存的位模式作為運算元進行浮點數乘法,得到 0-10000000101-1111011111111111111111111111111111111111101010000001。轉換為十進位制即為 125。99999999998。

Why 1000000000000000128 === 1000000000000000129?

1000000000000000128 實際儲存時的位模式是 0-10000111010-1011110000010110110101100111010011101100100000000001;

1000000000000000129 實際儲存時的位模式是 0-10000111010-1011110000010110110101100111010011101100100000000001;

因此 1000000000000000128和1000000000000000129 的實際儲存的位模式是一樣的。

解決方案

到這裡我們都理解只要採取 IEEE 754 FP 的浮點數編碼的語言均會出現上述問題,只是它們的標準類庫已經為我們提供瞭解決方案而已。而JS呢?顯然沒有。壞處自然是掉坑了,而好處恰恰也是掉坑了:)

針對不同的應用需求,我們有不同的實現方式。

解決方案

0x00 -

簡單實現

對於小數和小整數的簡單運算可用如下方式

為什麼0.1 + 0.2 === 0.30000000000000004

解決方案

0x01 - math.js

若需要複雜且全面的運算功能那必須上 math。js,其內部引用了 decimal。js 和 fraction。js。功能異常強大,用於生產環境上妥妥的!

解決方案

0x02 - D.js

D。js 算是我的練手專案吧,截止本文發表時 D。js 版本為 V0。2。0,僅實現了加、減、乘和整除運算而已,bug 是一堆堆的,但至少解決了 0。1+0。2 的問題了。

為什麼0.1 + 0.2 === 0.30000000000000004

解題思路:

由於僅位於 Number。MIN_SAFE_INTEGER 和 Number。MAX_SAFE_INTEGER 間的整數才能被精準地表示,也就是隻要保證運算過程的運算元和結果均落在這個閥值內,那麼運算結果就是精準無誤的;

問題的關鍵落在如何將小數和極大數轉換或拆分為 Number。MIN_SAFE_INTEGER 至 Number。MAX_SAFE_INTEGER 閥值間的數了;

小數轉換為整數,自然就是透過科學計數法表示,並透過右移小數點,減小冪的方式處理;(如 0。000123 等價於 123 * 10-6)

而極大數則需要拆分,拆分的規則是多樣的。

按因式拆分:假設對 12345 進行拆分得到 5 * 2469;

按位拆分:假設以3個數值為一組對12345進行拆分得到345和12,而實際值為 12*1000 + 345。

就我而言,1 的拆分規則結構不穩定,而且不直觀;而 2 的規則直觀,且拆分和恢復的公式固定。

餘數由符號位、分子和分母組成,而符號與整數部分一致,因此只需考慮如何表示分子和分母即可。

無限迴圈數則僅需考慮如何表示迴圈數段即可。(如 10。2343434 則分成 10。23 和迴圈數 34 和 34 的權重即可)

得到編碼規則後,那就剩下基於指定編碼如何實現各種運算的問題了。

基於上述的數值編碼規則如何實現加、減運算呢?

基於上述的數值編碼規則如何實現乘、除運算呢?(其實只要加、減運算解決了,乘除必然可解,就是效率問題而已)

基於上述的數值編碼規則如何實現其它如 sin、tan、% 等數學運算呢?

另外由於涉及數學運算,那麼將作為add、sub、mul和div等入參的變數保持如同數學公式運算數般純淨(Persistent/Immutable Data Structure)是必須的,那是否還要引入 immutable。js 呢?(D。js 現在採用按需生成副本的方式,可預見隨著程式碼量的增加,這種方式會導致整體程式碼無法維護)