JavaScript 精度問題以及JavaScript 浮點數陷阱及解決方案


閱讀完本文可以了解到 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,對應的二進制為 01111111100M 為 10011001100110011001100110011001100110011001100110100.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) + // 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) 只是最中間非常小的一部分,越往兩邊越稀疏越不精確。
fig1.jpg

在淘寶早期的訂單系統中把訂單號當作數字處理,后來隨意訂單號暴增,已經超過了
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時,數字會強制轉為科學計數法形式顯示)時還需要特別處理一下。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM