揭祕 0.1 + 0.2 != 0.3
“0.1 + 0.2 = ?”,這道題如果給小學生,他會立馬告訴你答案是 0.3,但是交給一些程式去計算,結果就不是那麼簡單了。
事實上,不僅僅是 JS,在其他採用 IEEE754 浮點數標準的語言中,0.1 + 0.2 都不會等於 0.3,但是 0.2 + 0.3 卻等於 0.5,這是為何?想必這類問題也困擾著不少程式設計師。
IEEE754 浮點數的演算
我們知道,科學計數法中 30000 可以寫成 3x104,以 10 為底數 4 為指數的科學計數法。在 IEEE754 標準中是比較類似的,只不過它是二進位制數,底數也為 2。
IEEE 754 中最常用的浮點數值表示法是:單精確度(32位)和雙精確度(64位),JavaScript 採用的是後者。舉個例子,十進位制數 150,使用雙精度浮點數表示法,表示如下:
// D 表示十進位制,B 表示二進位制 150D = 2^8 * 0.10010110B // 後面省略了 46 個 0
可以通過短除法計算:
150 餘數位 ÷ 2 --------------- 75 0 ÷ 2 --------------- 37 1 ÷ 2 --------------- 18 1 ÷ 2 --------------- 9 0 ÷ 2 --------------- 4 1 ÷ 2 --------------- 2 0 ÷2 --------------- 1 0 ÷ 2 --------------- 0 1
上面是整數的表示法,而小數的表示法採用的是乘二取整,如 0.1,它的二進位制表示為:最後一個餘數為高位值,於是拿到 150 對應的二進位制數位 10010110
,也就等於 2^8 * 0.10010110
。
// (0011) 表示迴圈 0.1D = 2^-3 * 0.110011(0011)
其演算方法如下:
0.1 整數位 × 2 --------------- 0.2 0 × 2 --------------- 0.4 0 * ↓ ×2 --------------- 0.8 0 × 2 --------------- 1.6 1 × 2 --------------- 1.2 1 × 2 --------------- 0.4 0 * ↑ (0011迴圈)
如果一個數既包含整數部分,又包含小數部分,其表示法的計算,需要分拆為整數和小數兩部分,然後相加得到結果。與整數不同的是,第一個計算得到的整數位為最高位,故 0.1 對應的二進位制數為 0.000110011(0011)
,也就等於 2^-3 0.1100110011(0011)
。
IEEE754 浮點數精度丟失
IEEE754 浮點數表示法的資料格式如下圖:
// 下圖採用大端表示,高位在左,低位在右。 sign exponent fraction +---+----------+---------------------+ | 1 | 2~12 | 13~64 | +---+----------+---------------------+
- 從上面小數的乘二取整演算中可以看到,有些小數對應的二進位制數是無法寫全的,比如 0.1,而 fraction 尾數部分有要求,只允許 52 位,超過部分進一舍零。符號位:高位第 1 位,如圖 sign 部分
- 指數位:高位第 2~12 位,如圖 exponent 部分
- 尾數位:剩下的 fraction 部分
那麼,我們就可以得到:
0.1D = 2^-4 * 1.10011(0011)B = 2^-4 * 1.10011(0011 repeat 12 times)0011B // ← 最後一位為 1,進 1 = 2^-4 * 1.10011(0011 repeat 12 times)010B
揭祕 0.1 + 0.2
根據上面我們瞭解到的知識,我們可以很容易算出這些值:
0.1D = 2^-4 * 1.1001100110011001100110011001100110011001100110011010B 0.2D = 2^-3 * 1.1001100110011001100110011001100110011001100110011010B 0.3D = 2^-2 * 1.0011001100110011001100110011001100110011001100110011B
0.1 + 0.2
時,先將兩者指數統一為 -3,故 0.1 小數點向左移一位,於是:
0.1100110011001100110011001100110011001100110011001101B + 1.1001100110011001100110011001100110011001100110011010B ------------------------------------------------------------ = 10.0110011001100110011001100110011001100110011001100111B
得到的二進位制數為:
10.0110011001100110011001100110011001100110011001100111B
小數點往左移一位使得整數部分為 1,此時尾數部分為 53 位,進一舍零,於是得到最後的值是:
2^-2 * 1.0011001100110011001100110011001100110011001100110100
這個值轉化成真值,結果為:0.30000000000000004
。那麼 0.1 + 0.2 = 0.30000000000000004
的推演到這裡就結束了。
相關驗證
畢竟咱們手動計算可能存在筆誤,可以通過一個叫做 double-bits
的 npm 進行推演,我寫了一個小 demo,感興趣的可以玩耍下:
const db = require('double-bits'); const pad = require('pad'); // [lo, hi] where lo is a 32 bit integer and hi is a 20 bit integer. const base2Str = (n) => { const f = db.fraction(n); const s = db.sign(n) ? '-' : ''; const e = `2^${db.exponent(n) + 1}`; const t = `0.${pad(f[1].toString(2), 20, '0')}${pad(f[0].toString(2), 32, '0')}`; return `${s}${e} * ${t}`; }; console.log(base2Str(0.1).toString(2)); console.log(base2Str(0.2).toString(2)); console.log(base2Str(0.3).toString(2)); console.log(base2Str(1.2).toString(2));
上面輸出結果為:
2^-3 * 0.11001100110011001100110011001100110011001100110011010 2^-2 * 0.11001100110011001100110011001100110011001100110011010 2^-1 * 0.10011001100110011001111001100110011001100110011001100 2^1 * 0.10011001100110011001111001100110011001100110011001100
最後
為了按照計算機的思維,IEEE754 的標準來計算 0.1 + 0.2
,又重新複習了一遍大學計算機基礎的知識,原碼、反碼、補碼,以及除二取餘、乘二取整計演算法,最後能夠推演出來,也算是一個勝利吧~
更多閱讀
題圖:math by Roman Mager
筆耕不輟,歡迎關注微信公眾號小鬍子哥(barretlee_com),分享生活,分享技術,我在那裡等你。