閱讀完本文可以了解到 0.1 + 0.2
為什么等於 0.30000000000000004
以及 JavaScript 中最大安全數是如何來的。
十進制小數轉為二進制小數方法
拿 173.8125 舉例如何將之轉化為二進制小數。
①. 針對整數部分 173,采取除 2 取余,逆序排列
;
173 / 2 = 86 ... 1 86 / 2 = 43 ... 0 43 / 2 = 21 ... 1 ↑ 21 / 2 = 10 ... 1 | 逆序排列 10 / 2 = 5 ... 0 | 5 / 2 = 2 ... 1 | 2 / 2 = 1 ... 0 1 / 2 = 0 ... 1
得整數部分的二進制為 10101101
。
②. 針對小數部分 0.8125,采用乘 2 取整,順序排列
;
0.8125 * 2 = 1.625 | 0.625 * 2 = 1.25 | 順序排列 0.25 * 2 = 0.5 | 0.5 * 2 = 1 ↓
得小數部分的二進制為 1101
。
③. 將前面兩部的結果相加,結果為 10101101.1101
;
小心,二進制小數丟失了精度!
根據上面的知識,將十進制小數 0.1
轉為二進制:
0.1 * 2 = 0.2 0.2 * 2 = 0.4 // 注意這里 0.4 * 2 = 0.8 0.8 * 2 = 1.6 0.6 * 2 = 1.2 0.2 * 2 = 0.4 // 注意這里,循環開始 0.4 * 2 = 0.8 0.8 * 2 = 1.6 0.6 * 2 = 1.2 ...
可以發現有限十進制小數 0.1
卻轉化成了無限二進制小數 0.00011001100...
,可以看到精度在轉化過程中丟失了!
能被轉化為有限二進制小數的十進制小數的最后一位必然以 5 結尾(因為只有 0.5 * 2 才能變為整數)。所以十進制中一位小數 0.1 ~ 0.9
當中除了 0.5
之外的值在轉化成二進制的過程中都丟失了精度。
推導 0.1 + 0.2 為何等於 0.30000000000000004
在 JavaScript 中所有數值都以 IEEE-754 標准的 64 bit
雙精度浮點數進行存儲的。先來了解下 IEEE-754 標准下的雙精度浮點數。
這幅圖很關鍵,可以從圖中看到 IEEE-754 標准下雙精度浮點數由三部分組成,分別如下:
- sign(符號): 占 1 bit, 表示正負;
- exponent(指數): 占 11 bit,表示范圍;
- mantissa(尾數): 占 52 bit,表示精度,多出的末尾如果是 1 需要進位;
注意以上的公式遵循科學計數法的規范,在十進制是為0<M<10,到二進行就是0<M<2。也就是說整數部分只能是1,所以可以被舍去,只保留后面的小數部分。如 4.5 轉換成二進制就是 100.1,科學計數法表示是 1.001*2^2,舍去1后 M = 001
。E是一個無符號整數,因為長度是11位,取值范圍是 0~2047。但是科學計數法中的指數是可以為負數的,所以再減去一個中間數 1023,[0,1022]表示為負,[1024,2047] 表示為正。如4.5 的指數E = 1025
,尾數M為 001。
最終的公式變成:
所以 4.5
最終表示為(M=001、E=1025):
下面再以 0.1
例解釋浮點誤差的原因, 0.1
轉成二進制表示為 0.0001100110011001100
(1100循環),1.100110011001100x2^-4
,所以 E=-4+1023=1019
;M 舍去首位的1,得到 100110011...
。最終就是:
轉化成十進制后為 0.100000000000000005551115123126
,因此就出現了浮點誤差。
精度位總共是 53 bit,因為用科學計數法表示,所以首位固定的 1 就沒有占用空間。即公式中 (M + 1) 里的 1。另外公式里的 1023 是 2^11 的一半。小於 1023 的用來表示小數,大於 1023 的用來表示整數。指數可以控制到 2^1024 - 1,而精度最大只達到 2^53 - 1,兩者相比可以得出 JavaScript 實際可以精確表示的數字其實很少。
0.1
轉化為二進制為 0.0001100110011...
,用科學計數法表示為 1.100110011... x 2^(-4)
,根據上述公式,S
為 0
(1 bit),E
為 -4 + 1023
,對應的二進制為 01111111011
(11 bit),M
為 1001100110011001100110011001100110011001100110011010
(52 bit,另外注意末尾的進位),0.1
的存儲示意圖如下:
同理,0.2
轉化為二進制為 0.001100110011...
,用科學計數法表示為 1.100110011... x 2^(-3)
,根據上述公式,E
為 -3 + 1023
,對應的二進制為 01111111100
, M
為 1001100110011001100110011001100110011001100110011010
, 0.2
的存儲示意圖如下:
0.1 + 0.2
即 2^(-4) x 1.1001100110011001100110011001100110011001100110011010 與 2^(-3) x 1.1001100110011001100110011001100110011001100110011010 之和
// 計算過程 0.00011001100110011001100110011001100110011001100110011010 0.0011001100110011001100110011001100110011001100110011010 // 相加得 0.01001100110011001100110011001100110011001100110011001110
0.01001100110011001100110011001100110011001100110011001110
轉化為十進制就是 0.30000000000000004
。驗證完成!
JavaScript 的最大安全數是如何來的
根據雙精度浮點數的構成,精度位數是 53 bit
。安全數的意思是在 -2^53 ~ 2^53
內的整數(不包括邊界)與唯一的雙精度浮點數互相對應。舉個例子比較好理解:
Math.pow(2, 53) === Math.pow(2, 53) + 1 // true
Math.pow(2, 53)
竟然與 Math.pow(2, 53) + 1
相等!這是因為 Math.pow(2, 53) + 1 已經超過了尾數的精度限制(53 bit),在這個例子中 Math.pow(2, 53)
和 Math.pow(2, 53) + 1
對應了同一個雙精度浮點數。所以 Math.pow(2, 53)
就不是安全數了。
最大的安全數為Math.pow(2, 53) - 1
,即9007199254740991
。
業務中碰到的精度問題以及解決方案
了解 JavaScript 精度問題對我們業務有什么幫助呢?舉個業務場景:比如有個訂單號后端 Java 同學定義的是 long 類型,但是當這個訂單號轉換成 JavaScript 的 Number 類型時候精度會丟失了,那沒有以上知識鋪墊那就理解不了精度為什么會丟失。
為什么 0.1+0.2=0.30000000000000004
?
計算步驟為:
// 0.1 和 0.2 都轉化成二進制后再進行運算 0.00011001100110011001100110011001100110011001100110011010 + 0.0011001100110011001100110011001100110011001100110011010 = 0.0100110011001100110011001100110011001100110011001100111 // 轉成十進制正好是 0.30000000000000004
為什么 x=0.1
能得到 0.1
?
恭喜你到了看山不是山的境界。因為 mantissa 固定長度是 52 位,再加上省略的一位,最多可以表示的數是 2^53=9007199254740992
,對應科學計數尾數是 9.007199254740992
,這也是 JS 最多能表示的精度。它的長度是 16,所以可以使用 toPrecision(16)
來做精度運算,超過的精度會自動做湊整處理。於是就有:
0.10000000000000000555.toPrecision(16) // 返回 0.1000000000000000,去掉末尾的零后正好為 0.1 // 但你看到的 `0.1` 實際上並不是 `0.1`。不信你可用更高的精度試試: 0.1.toPrecision(21) = 0.100000000000000005551
大數危機
可能你已經隱約感覺到了,如果整數大於 9007199254740992 會出現什么情況呢?
由於 E 最大值是 1023,所以最大可以表示的整數是 2^1024 - 1
,這就是能表示的最大整數。但你並不能這樣計算這個數字,因為從 2^1024
開始就變成了 Infinity
> Math.pow(2, 1023) 8.98846567431158e+307 > Math.pow(2, 1024) Infinity
那么對於 (2^53, 2^63)
之間的數會出現什么情況呢?
(2^53, 2^54)
之間的數會兩個選一個,只能精確表示偶數(2^54, 2^55)
之間的數會四個選一個,只能精確表示4個倍數- ... 依次跳過更多2的倍數
下面這張圖能很好的表示 JavaScript 中浮點數和實數(Real Number)之間的對應關系。我們常用的 (-2^53, 2^53)
只是最中間非常小的一部分,越往兩邊越稀疏越不精確。
在淘寶早期的訂單系統中把訂單號當作數字處理,后來隨意訂單號暴增,已經超過了9007199254740992
,最終的解法是把訂單號改成字符串處理。
要想解決大數的問題你可以引用第三方庫 bignumber.js,原理是把所有數字當作字符串,重新實現了計算邏輯,缺點是性能比原生的差很多。所以原生支持大數就很有必要了,現在 TC39 已經有一個 Stage 3 的提案 proposal bigint,大數問題有望徹底解決。在瀏覽器正式支持前,可以使用 Babel 7.0 來實現,它的內部是自動轉換成 big-integer 來計算,要注意的是這樣能保持精度但運算效率會降低。
toPrecision
vs toFixed
數據處理時,這兩個函數很容易混淆。它們的共同點是把數字轉成字符串供展示使用。注意在計算的中間過程不要使用,只用於最終結果。
不同點就需要注意一下:
toPrecision
是處理精度,精度是從左至右第一個不為0的數開始數起。toFixed
是小數點后指定位數取整,從小數點開始數起。
兩者都能對多余數字做湊整處理,也有些人用 toFixed
來做四舍五入,但一定要知道它是有 Bug 的。
如:1.005.toFixed(2)
返回的是 1.00
而不是 1.01
。
原因: 1.005
實際對應的數字是 1.00499999999999989
,在四舍五入時全部被舍去!
解法:使用專業的四舍五入函數 Math.round()
來處理。但 Math.round(1.005 * 100) / 100
還是不行,因為 1.005 * 100 = 100.49999999999999
。還需要把乘法和除法精度誤差都解決后再使用 Math.round
。可以使用后面介紹的 number-precision#round
方法來解決。
解決方案
回到最關心的問題:如何解決浮點誤差。首先,理論上用有限的空間來存儲無限的小數是不可能保證精確的,但我們可以處理一下得到我們期望的結果。
數據展示類
當你拿到 1.4000000000000001
這樣的數據要展示時,建議使用 toPrecision
湊整並 parseFloat
轉成數字后再顯示,如下:
parseFloat(1.4000000000000001.toPrecision(12)) === 1.4 // True
封裝成方法就是:
//字符串轉換成數字並處理精度問題 function parseNums(ratio) { if(ratio===null)return ""; if(ratio==="")return ""; return parseFloat((ratio*100).toPrecision(12)); }
為什么選擇 12
做為默認精度?這是一個經驗的選擇,一般選12就能解決掉大部分0001和0009問題,而且大部分情況下也夠用了,如果你需要更精確可以調高。
數據運算類
對於運算類操作,如 +-*/
,就不能使用 toPrecision
了。正確的做法是把小數轉成整數后再運算。以加法為例:
/** * 精確加法 */ function add(num1, num2) { const num1Digits = (num1.toString().split('.')[1] || '').length; const num2Digits = (num2.toString().split('.')[1] || '').length; const baseNum = Math.pow(10, Math.max(num1Digits, num2Digits)); return (num1 * baseNum + num2 * baseNum) / baseNum; }
以上方法能適用於大部分場景。遇到科學計數法如 2.3e+1
(當數字精度大於21時,數字會強制轉為科學計數法形式顯示)時還需要特別處理一下。