js浮點數儲存精度丟失原理

changli2018發表於2018-06-30

前言

曾幾何時我們驚訝於在控制檯看到這樣的情況

0.1 + 0.2 === 0.3
false
複製程式碼

而我們也得出一個原因,因為精度丟失所致。下面我將一步一步地以最簡單的0.1為例告訴你們精度為什麼丟失,什麼時候開始丟失的,這裡沒有深奧的公式,也沒有晦澀的概念,只要你知道進位制轉換就能看懂了。

0.1在記憶體中的樣子

有一點我們是知道的,js中一般的數值是以64位浮點數儲存在記憶體中的,也就是這64個二進位制數字對映著一個具體的數字,具體是按照IEEE754 這個標準來的,這個標準權衡了精度和表示範圍,也就是如何有效利用這64個二進位制數字的前提下提出的。下面的所有流程都是按這個標準來的,其中把64位劃分出了3個區域

區域 S 符號位 用 1 位表示 0表示正數 1表示負數

區域 E 指數位 用 11 位表示 有正負範圍,臨界值是1023 後面看轉換過程就能看明白

區域 M 尾數位 用 52 位表示

S + E + M 剛好就等於64位 在開始前先看看 0.1 在記憶體中是長什麼樣子的

let bytes = new Float64Array(1);// 64位浮點數
bytes[0] = 0.1;// 填充0.1進去
let view = new DataView(bytes.buffer);
console.log(view.getUint8(0).toString(2));// 10011010
console.log(view.getUint8(1).toString(2));// 10011001
console.log(view.getUint8(2).toString(2));// 10011001
console.log(view.getUint8(3).toString(2));// 10011001
console.log(view.getUint8(4).toString(2));// 10011001
console.log(view.getUint8(5).toString(2));// 10011001
console.log(view.getUint8(6).toString(2));// 10111001
console.log(view.getUint8(7).toString(2));// 00111111 這裡補齊了8位
複製程式碼

這裡的bytes.buffer代表的就是一串記憶體空間,為了方便大家理解我使用 DataView用無符號8位的格式一個一個地讀取記憶體的資料再轉為二進位制格式。 由於讀取記憶體的順序會受位元組序的影響,可能在你們的電腦列印得到相反的順序 如果按SEM的排列,那麼其二進位制就像下面這樣子的

s(0)E(01111111011)M(1001100110011001100110011001100110011001100110011010)

現在已經知道了0.1在記憶體的樣子,下面就開始說說具體的轉化過程,也就是精度丟失的過程

0.1精度丟失過程

  1. 轉換為二進位制
    在轉換之前,首先看十進位制小數要如何轉化為二進位制數小數的,這也是理解精度丟失十分關鍵的步驟,這個網上也有很多資料,我下面簡單寫一下流程。
0.1 => 0.2 => 0.4 => 0.8 => 1.6 => 1.2 => 0.4 => 0.8 => 1.6 => 1.2 => 0.4 => 0.8 => 1.6 => 1.2 => 0.4 ..............
複製程式碼

就是小數部分不斷乘以2,並取整數部分的值,直到小數部分為0為止,應該也是很好理解的,可以看出這樣下去是一個無限迴圈的過程,轉化後是這樣子的

0.00011001100110011001100110011001100110011001100110011001100110011001.....
複製程式碼

有限空間傳入無限的數很明顯是不可能,那麼應該怎麼做呢

  1. 轉換為二進位制指數格式

    轉換為指數格式其實就是移動小數點,讓小數點前面出現的是第一個為1的值,不同的二進位制資料,可能是前移可能是右移,對應的是指數的正負範圍,轉換後是這樣子的

1.1001100110011001100110011001100110011001100110011001100110011001..... * 2 ^ -4
複製程式碼
  1. 提取資料,進行數值擷取,導致精度丟失

    這裡可以看到向右移動了4位,這個資料會儲存在指數區域E內,在沒有移位的情況下指數區域的值是1023,向左移動幾位就加幾位,向右移動幾位就減幾位,所以這裡是

1023 - 4 = 1019
1019 轉二進位制並補齊11位  01111111011
複製程式碼

也就是E為 01111111011 由於尾數位最多隻有52位,所以小數點後面的52位全部提取到尾數位,其中要注意的是,類似四捨五入,如果末位後是1會產生進位,這裡就產生了進位

1001100110011001100110011001100110011001100110011001100110011001.....
1001100110011001100110011001100110011001100110011001 100110011001.....
進位後擷取
1001100110011001100110011001100110011001100110011010
複製程式碼

也就是M為 1001100110011001100110011001100110011001100110011010

這裡由於丟掉了部分資料,所以導致精度丟失

由於0.1是正數,所以 S 為 0

到此整個js浮點數儲存過程就結束了,為了表示我不是忽悠大家的,大家可以對照第一部分輸出的資料值。下面將順便介紹一下怎麼轉回十進位制

丟失精度的資料轉回十進位制

  1. 提取尾數位資料
1001100110011001100110011001100110011001100110011010
複製程式碼
  1. 先前新增 1. 恢復為指數格式 並提取指數位
1.1001100110011001100110011001100110011001100110011010
複製程式碼
01111111011 => 1019
1019 - 1023 = -4
複製程式碼
1.1001100110011001100110011001100110011001100110011010 * 2 ^ -4
複製程式碼
  1. 移位
0.00011001100110011001100110011001100110011001100110011010
複製程式碼
  1. 二進位制轉化為十進位制 小數的二進位制轉化為十進位制網上的資料也有很多,我也簡單介紹一下過程,以0.0111為例子
 0.0111 小數點後一位 0 / 2^1   0
        小數點後2位 1 / 2^2    0.25
        小數點後3位 1 / 2^3    0.125
        小數點後4位 1 / 2^4    0.0625
        然後相加 0 + 0.25 + 0.125 + 0.0625 = 0.4375
複製程式碼

按以上方法進行裝換

0.00011001100110011001100110011001100110011001100110011010 =>
0.100000000000000005551
複製程式碼

關於最後這個輸出值其實也是不精確的,因為我就是用js計算的,如果大家有更準確的計算方法可以幫我算一下,精確的值末尾數應該是5才對。但是你試一下在控制檯中計算下面的表示式

0.1.toPrecision(21)
"0.100000000000000005551"
複製程式碼

這個也證明了上述的推理過程是正確的

總結

相信到這裡你已經知道為什麼精度會丟失了,很多人都說js做浮點數計算很坑,其實也只是遵守標準而已,如果是坑的話,這個坑就不止是js了。

相關文章