JS浮點數也沒那麼複雜

前言

工作中經常會遇到浮點數的操作,所以對一些常見的"bug"比如浮點數的精度丟失,0.1+0.2!==0.3的問題也有所瞭解,但是都不深入,對於Number的靜態屬性MAX_SAFE_INTEGER知道它的存在,但是並不知道爲什麼這樣定義範圍。剛好最近有空就帶着這些疑惑深入的瞭解了一下,發現網上也有一些文章,有對這些知識的梳理,要麼是太晦澀,需要一定的基礎才能看懂,要麼就是太散,沒有全面的進行分析。所以想着,寫一篇這方面的文章,一是對自己學習結果的總結和檢驗,另一方面通過通俗易懂的方式分享給跟我一樣有困惑的同學,大家互相學習,共同進步,有問題歡迎指正。

本文首先會介紹一些概念,然後深入分析IEEE浮點數精度丟失的問題,最後解釋爲什麼最大安全數MAX_SAFE_INTEGER的取值是$2^{53} - 1$。

浮點數

首先來介紹一下浮點數,JavaScript中所有的數字,無論是整數還是小數都只有一種類型Number。遵循 IEEE 754 的標準,在程序內部Number類型實質是一個64位固定長度的浮點數,也就是標準的double雙精度浮點數。

IEEE浮點數格式使用科學計數法表示實數。科學計數法把數字表示爲尾數(mantissa),和指數 (exponent)兩部分。比如 25.92 可表示爲 $ 2.592\times10^1 $,其中2.592是尾數,值 $10^1$ 是指數。*指數的基數爲 10,指數位表示小數點移動多少位以生成尾數。每次小數點向前移動時,指數就遞增;每次小數點向後移動時,指數就遞減。再比如 $ 0.00172 $可表示爲 $1.72\times10^-3$。科學計數法對應到二進制裏也是一個意思。

計算機系統使用二進制浮點數,這種格式使用二進制科學計數法的格式表示數值。數字按照二進制格式表示,那麼尾數指數都是基於二進制的,而不是十進制,例如 $1.0101\times2^2$。 在二制裏表示,1.0101 左移兩位後,生成二進制值 101.01,這個值表示十進制整數 5,加上小數$(0\times2^{-1}+1\times2^{-2}=0.25)$,生成十進制值 5.25。

浮點數的組成

前面已經介紹了IEEE浮點數使用科學計數法表示實數,IEEE浮點數標準會把一個二進制串分成3部分,分別用來存儲浮點數的尾數階碼以及符號位。其中

  • 符號位S:第 1 位是正負數符號位(sign),0代表正數,1代表負數
  • 指數位E:中間的 11 位存儲指數(exponent),用來表示次方數
  • 尾數位M:最後的 52 位是尾數(mantissa),超出的部分自動進一舍零,二進制默認整數位爲1捨去
指數表示浮點數的指數部分,是一個無符號整數,因爲長度是11位,取值範圍是 0~2047。因爲指數值可以是正值,也可以是負值,所以需要通過一個偏差值對它進行置偏,即指數的真實值=指數部分的整數—偏差值。對於64位浮點數,取中間值,則偏差值=1023,[0,1022]表示爲負,[1024,2047] 表示爲正

通過公式計算來表示浮點數的值話,如下所示:

$$ \begin{gather} V = (-1)^S\times2^{E-1023}\times(1.M) \end{gather} $$

公式看起來可能還是有點抽象,那我們拿一個具體的十進制數字8.75來舉例,分析對應公式中各變量的值。首先將8.75轉成二進制,其中整數部分8對應的二進制爲1000。小數轉二進制具體步驟爲:將該數字乘以2,取出整數部分作爲二進制表示的第1位;然後再將小數部分乘以2,將得到的整數部分作爲二進制表示的第2位;以此類推,直到小數部分爲0。 故0.75轉二進制的過程如下:

0.75 * 2 = 1.5 // 記錄1
0.5 * 2 = 1 // 記錄1
// 0.75對應的二進制爲11

最終8.75對應的二進制爲1000.11,通過科學計數法表示爲$1.00011\times2^3$,其中捨去1後,M=00011E = 3。故E=3+1023=1026。最終的公式變成:$8.75 = (-1)^0\times2^{1026-1023}\times(1.00011)$。

在尾數的定義上,有一個概念超出的部分自動進一舍零不知道大家有沒有注意到,IEEE754浮點數的舍入規則與我們瞭解的四捨五入相似,但也存在一些區別。

IEEE754規範的舍入規則

IEEE754採用的浮點數舍入規則有時被稱爲最近偶數

  • 首先判斷精度損失(優先級最高),向上和向下都計算,精度損失最小者獲勝,也就是"最近"原則.
  • 如果距離相等(即精度損失相等),那麼將執行偶數判斷,偶數勝出.

我們來舉個例子,假定二進制小數1.01101,舍入到小數點後4位。首先往上和往下損失的精度都是0.00001(二進制),這時候根據第二條規則保證舍入後的最低有效位是偶數,所以執行向下舍入,結果爲1.0110。如果將其舍入到小數點後2位,則執行向上舍入,精度丟失0.00011,向下舍入,精度丟失0.00101,所以結果爲1.10。再來思考下看看下面的這些例子,原因後面會解釋。

 Math.pow(2,53) // 9007199254740992
 Math.pow(2,53) + 1 // 9007199254740992
 Math.pow(2,53) + 2 // 9007199254740994
 Math.pow(2,53) + 3 // 9007199254740996

瞭解了浮點數的組成,以及尾數的舍入規則後,我們就來看看爲什麼浮點數會存在精度丟失的問題。

精度丟失問題

通過浮點數的尾數接受,也許機智的你就已經發現了爲什麼會丟失精度。就是因爲舍入規則的存在,才導致了浮點數的精度丟失。

浮點數的組成部分,我們已經瞭解瞭如何將一個十進制的小數轉成二進制。不知道大家有沒有注意到我們只說了將該數字乘以2,取出整數部分作爲二進制表示的第1位,以此類推,直到小數部分爲0,但還存在另一種特殊情況就是小數部分出現循環,無法停止,這個時候用有限的二進制位就無法準確表示一個小數,這也就是精度丟失的原因了。

我們按照乘以 2 取整數位的方法,把 0.1 表示爲對應二進制:

// 0.1二進制演算過程如下
0.1 * 2 = 0.2 // 取整數位 記錄0
0.2 * 2 = 0.4 // 取整數位 記錄00
0.4 * 2 = 0.8 // 取整數位 記錄000
0.8 * 2 = 1.6 // 取整數位 記錄0001
0.6 * 2 = 1.2 // 取整數位 記錄00011
0.2 * 2 = 0.4 // 取整數位 記錄000110
0.2 * 2 = 0.4 // 取整數位 記錄0001100
0.4 * 2 = 0.8 // 取整數位 記錄00011000
0.8 * 2 = 1.6 // 取整數位 記錄000110001
0.6 * 2 = 1.2 // 取整數位 記錄0001100011
... // 如此循環下去
0.1 = 0.0001100110011001...

最終我們得到一個無限循環的二進制小數 0.0001100110011001...,按照浮點數的公式,$0.1=1.100110011001..\times2^{-4}$,$E=1023-4=1019$,捨去首位的1,通過舍入規則取52位M=00011001100...11010,轉化成十進制後爲 0.100000000000000005551115123126,因此就出現了精度丟失。同時通過上面的轉化過程可以看到0.2,0.4,0.6,0.8都無法精確表示,0.1 到 0.9 的 9 個小數中,只有 0.5 可以用二進制精確的表示。

讓我們繼續看個問題:

0.1 + 0.2 === 0.3 // false
var s = 0.3 
s === 0.3 // true

爲什麼0.3 === 0.3 而 0.1 + 0.2 !== 0.3

// 0.1 和 0.2 都轉化成二進制後再進行運算
0.00011001100110011001100110011001100110011001100110011010 +
0.0011001100110011001100110011001100110011001100110011010 =
0.0100110011001100110011001100110011001100110011001100111

// 轉成十進制正好是 0.30000000000000004

可以看出,因爲0.1和0.2都無法被精確表示,所以在進行加法運算之前,0.1和0.2的精度就已經丟失了。 浮點數的精度丟失在每一個表達式,而不僅僅是表達式的求值結果。

我們可以拿個簡單的數學加法來類比一下,計算1.7+1.6的結果,四捨五入保留整數:

1.7 + 1.6 = 3.3 = 3

換種方式,先進行四捨五入,再進行求值:

1.7 + 1.6 = 2 + 2 = 4

通過兩種運算,我們得到了兩個結果3 和4。同理,在我們的浮點數運算中,參與運算的兩個數 0.1 和 0.2 精度已經丟失了,所以他們求和的結果已經不是 0.3了。

既然0.3無法精確表示爲什麼又能得到0.3呢

let i = 0.3;
i === 0.3 // true

爲什麼x=0.3能得到0.3

首先,你看到的0.3並不是你認爲的0.3。因爲尾數的固定長度是 52 位,再加上省略的一位,最多可以表示的數是 $2^{53}=9007199254740992$,這與16個十進制位表示的精度十分接近。

例如,0.3000000000000000055與0.30000000000000000051是相同的都是0.1,這兩個數按照64位雙精度浮點格式存儲與0.1是一樣的。

0.3000000000000000055 === 0.3 // true
0.3000000000000000055 === 0.3000000000000000051 // true

由上面可以看到,在雙精度的浮點下,整數部分+小數部分的位數一共有 17 位。

當尾數長度是 16時,可以使用 toPrecision(16) 來做精度運算,超過的精度會自動做湊整處理。例如:

(0.10000000000000000555).toPrecision(16) // 返回 0.1

(0.1).toPrecision(21) // 0.100000000000000005551

爲什麼[-(2^53-1), 2^53-1]爲安全的整數區域

在JavaScript中Number有兩個靜態屬性MAX_SAFE_INTEGERMIN_SAFE_INTEGER,分別表示最大的安全的整數型數字 ($2^{53} - 1$)和最小的安全的整數型數字 ($-(2^{53} - 1)$)。

安全的整數意思就是說在此範圍內的整數和雙精度浮點數是一一對應的,不會存在一個整數有多個浮點數表示的情況,當然也不會存在一個浮點數對應多個整數的情況。那這兩個數值是怎麼來的呢?

我們先不考慮符號位和指數位,浮點數的尾數位爲52位,不包括省略的1,則可以表示的最大的二進制小數爲1.11111...(52個1),推算一下這個數的值,其中整數位爲1對應的十進制的值爲$2^0\times1=1$,小數位的值爲$1/2+1/4+1/8...$是一個公比爲$\frac{1}{2}$的等比數列,我們知道等比數列的求和公式爲(不會的回去翻翻高中課本)

$$ S_n = \frac{a_nq-a_1}{q-1},(q\neq1) $$

根據求和公式算出小數位的結果接近0.9999999999999998,加起來就是1.9999999999999998無限的接近2。

再來看指數位,前面已經說過指數位表示小數點移動多少位以生成尾數,每次小數點向前移動時,指數就遞增,當指數遞增到52時,這時取滿了小數位,對應的值爲2^52*(1.111111...(52個))對應的十進制整數數爲無限的接近$2\times2^{52}$即爲$2^{53} - 1$。

同時指數位爲23時也能明確的表明一個整數,對應的表達式爲$2^{53}\times1.0$,那最大的安全整數明明可以到$2^{53}$,不是上面所說的$2^{53} - 1$呀。不要着急,我們繼續往下看,我們來看看$2^{53} + 1$的值。首先將其轉成對應的二進制,這時的尾數爲1.000...(52個0)1,由於bit-64浮點數只能存儲52位尾數,最後一位1,根據IEEE浮點數舍入規則,向下舍入,此時丟失了精度。最後$2^{53}$和這兩個數$2^{53} + 1$按照64位雙精度浮點格式存儲結果是一樣的。

Math.pow(2,53) // 9007199254740992
Math.pow(2,53) === Math.pow(2,53) + 1  // true

前面說過安全的整數意思就是說在此範圍內的整數和雙精度浮點數是一一對應的,而此時不是一一對應的關係,故 $[-(2^{53} - 1), 2^{53} - 1]$爲安全的整數區域。

最後考慮符號位的話最小的安全整數就是$-(2^{53} - 1)$。

我們繼續,上面說的只是安全區域,並不代表浮點數能精確存儲的最大整數就是$-(2^{53} - 1)$,這是兩個概念。我們接下來看看$2^{53} + 2$的64位雙精度浮點格式存儲結果,這時的尾數是1.000..(51個0)1,可以完全存儲沒有丟失精度,繼續往下看$2^{53} + 3$,對應的二進制尾數爲1.00..(51個0)11,根據舍入規則,向上舍入,結果爲1.00..(50個0)10。也就對應了上面提到的結果:

Math.pow(2,53) + 1 // 9007199254740992
Math.pow(2,53) + 2 // 9007199254740994
Math.pow(2,53) + 3 // 9007199254740996

有興趣的話,還可以繼續研究,指數位爲54的情況,以此類推。由此可以看出,IEEE能表示的整數的最大值不止$2^{53} - 1$,超過這個值也可以表示,只是需要注意精度的問題,使用的時候需要小心。

後續

對於浮點數的缺陷和對應的解法,可以看看這篇文章JavaScript 浮點數陷阱及解法

附錄

JavaScript 浮點數陷阱及解法

代碼之謎

IEEE754規範的舍入方案

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章