Learning Objectives
- 說明十進位小數換成二進位時,為什麼有些數(例如 0.1)寫不完
- 看懂 IEEE 754 浮點數的三個欄位:符號、指數(含偏移值)、尾數(含隱藏位元)
- 認識特殊值:正負零、無限大、NaN 與次正規數
- 解釋 0.1 + 0.2 != 0.3 的原因,寫程式時用容許誤差、整數或 Decimal 處理小數
0.1 + 0.2 是多少
在 Python 裡輸入 0.1 + 0.2,得到的不是 0.3:
>>> 0.1 + 0.2
0.30000000000000004
>>> 0.1 + 0.2 == 0.3
False
這不是 Python 的錯,JavaScript、Java、C、試算表軟體在底層都會遇到同樣的事;只是有些軟體顯示時先四捨五入,你看不出來。
原因在於電腦用二進位存小數,而且每個數只有固定的位元數(常見的是 32 或 64 個位元)。這一課要回答三個問題:
- 十進位的 0.1 換成二進位,為什麼寫不完?
- 電腦用什麼格式存小數?這個格式叫做浮點數(floating-point number),現在幾乎所有電腦都照 IEEE 754 標準。
- 寫程式處理小數(尤其是金額)時要注意什麼?
二進位的小數
十進位小數點後每一位的權重是 1/10、1/100、1/1000…;二進位小數點後的權重則是 1/2、1/4、1/8、1/16…:
| 二進位 | 計算 | 十進位 |
|---|---|---|
| 0.1₂ | 1/2 | 0.5 |
| 0.01₂ | 1/4 | 0.25 |
| 0.101₂ | 1/2 + 1/8 | 0.625 |
| 0.11₂ | 1/2 + 1/4 | 0.75 |
反過來,要把十進位小數換成二進位,可以一直乘以 2、取整數部分:0.625 × 2 = 1.25(取 1),0.25 × 2 = 0.5(取 0),0.5 × 2 = 1.0(取 1),小數部分變成 0 就結束,所以 0.625 = 0.101₂。
十進位的 1/3 寫成 0.3333… 永遠寫不完,因為 3 不是 10 的因數。二進位也一樣:只有分母是 2 的次方的分數(1/2、3/4、5/8…)能用有限位數寫完。0.1 = 1/10,分母有因數 5,所以換成二進位會無限循環:
0.1 = 0.0001100110011001100110011…₂(0011 一直重複)
電腦的位元數有限,只能存到某一位就截斷(其實是捨入到最接近的值),所以存進去的 0.1 本來就不是剛好 0.1。
一直乘以 2:把 0.625 與 0.1 換成二進位
為了讓計算完全精確,程式用分數表示小數:num / den(0.625 是 5/8、0.1 是 1/10)。每一圈把小數乘以 2,整數部分就是下一位二進位數字。注意上方格子裡 bits 怎麼一位一位長出來:0.625 三圈就結束,0.1 算滿 12 位還停不下來。
IEEE 754:符號、指數、尾數
十進位的科學記號把 6,020,000 寫成 6.02 × 10⁶。浮點數用的是二進位的科學記號:
值 = (±) 1.xxxxx₂ × 2^E
小數點的位置由指數決定,可以「浮動」,所以叫浮點數。IEEE 754 把一個數分成三個欄位:
| 欄位 | 32 位元(單精度) | 64 位元(雙精度) | 存什麼 |
|---|---|---|---|
| 符號(sign) | 1 位元 | 1 位元 | 0 是正、1 是負 |
| 指數(exponent) | 8 位元 | 11 位元 | E 加上偏移值(32 位元是 127,64 位元是 1023)之後存成無號整數,這樣負的指數也不需要另外的符號 |
| 尾數(fraction) | 23 位元 | 52 位元 | 1.xxxxx 小數點後面的 xxxxx;最前面的 1 一定存在,所以不用存(隱藏位元),等於多賺了一個位元的精度 |
例如 3.0 = 1.1₂ × 2¹:符號 0;指數 1 + 127 = 128 = 10000000;尾數 1000…0(1.1 小數點後面的 1)。
常見語言的對應:Python 的 float 與 JavaScript 的數字都是 64 位元;C、Java 的 float 是 32 位元、double 是 64 位元。
IEEE 754 的第一版在 1985 年發布;2008 年的修訂版加入了十進位浮點數與 16 位元的半精度等格式;2019 年再做一次小幅修訂。
特殊值、範圍與精度
指數欄位全 0 與全 1 保留給特殊值:
| 指數欄位 | 尾數 | 代表 |
|---|---|---|
| 全 0 | 全 0 | ±0(有正零與負零,兩者相等) |
| 全 0 | 不是 0 | 次正規數:小數點前改成 0.,用來表示比最小正規數更接近 0 的數,讓數值慢慢變小到 0,而不是突然跳到 0 |
| 全 1 | 全 0 | ±∞(無限大,例如太大的數溢位) |
| 全 1 | 不是 0 | NaN(Not a Number),例如 0/0、∞ − ∞;NaN 和任何數比較(包括自己)都不相等 |
位元數決定了範圍與精度:
| 32 位元 | 64 位元 | |
|---|---|---|
| 最大的數 | 約 3.4 × 10³⁸ | 約 1.8 × 10³⁰⁸ |
| 最小的正規正數 | 約 1.2 × 10⁻³⁸ | 約 2.2 × 10⁻³⁰⁸ |
| 有效位數(含隱藏位元) | 24 個二進位位數,大約 7 位十進位數 | 53 個二進位位數,大約 15~16 位十進位數 |
精度有限的另一個結果:數字很大時,相鄰兩個能表示的數之間差距也很大。64 位元浮點數在 2⁵³ 之後就連相鄰的整數都分不開了。
想一想:看位元算數值
一個 32 位元浮點數:符號 0、指數欄位 10000001(129)、尾數全 0。它的值是多少?
下列哪一個十進位數,可以被二進位浮點數剛好存下來,沒有誤差?
指數欄位全部是 1、尾數不是 0,這個浮點數代表什麼?
在程式裡比較小數
看 Python 怎麼印出 0.1 + 0.2,以及幾種比較小數的寫法。f'{0.1:.20f}' 讓 Python 印出小數點後 20 位,可以看到 0.1 實際存的值。最後兩種寫法完全避開二進位小數的誤差。
寫程式處理小數的建議
| 情況 | 建議 |
|---|---|
| 比較兩個浮點數 | 不要用 ==,改用 math.isclose(a, b) 或 abs(a - b) < 容許誤差 |
| 金額、點數這類需要「剛好」的數 | 用整數存最小單位(分、角),或用 decimal.Decimal;資料庫有 DECIMAL 型別 |
| 大量累加 | 誤差會一點一點累積;可以用 math.fsum() 這類專門的加總方法 |
| 顯示給使用者看 | f'{x:.2f}' 只改變顯示的位數,存的值不變 |
浮點數不是「不準」,而是用固定的位元數換取很大的範圍與很快的計算速度。科學計算、繪圖、機器學習都大量使用浮點數;只要知道誤差從哪裡來,就能選對工具。
想一想:程式會印出什麼
下面的程式會印出什麼?
print(0.5 + 0.25 == 0.75)
下面的程式會印出什麼?
total = 0
for i in range(10):
total += 0.1
print(total == 1.0)
下面的程式會印出什麼?(2⁵³ = 9007199254740992)
print(float(2**53 + 1) == 2**53)
重點整理
- 二進位小數的位權是 1/2、1/4、1/8…;只有分母是 2 的次方的分數能用有限位數寫完,0.1、0.2、0.3 都會無限循環。
- IEEE 754 浮點數 =(符號)1.尾數₂ × 2^(指數欄位 − 偏移值);32 位元是 1/8/23 位元、偏移 127,64 位元是 1/11/52 位元、偏移 1023。
- 尾數最前面的 1 不用存(隱藏位元);指數全 0、全 1 保留給 ±0、次正規數、±∞ 與 NaN。
- 0.1 + 0.2 != 0.3:兩個數本來就存得不準,相加後再捨入一次,和「最接近 0.3 的浮點數」差了一個最小刻度。
- 比較浮點數用
math.isclose()或容許誤差;金額用整數或Decimal。
下一課:數字講完了,換文字。電腦怎麼把「中」這個字存成位元組?為什麼打開檔案有時候會看到「䏿–‡」這種亂碼?
想親手寫更多 Python,可以接著上 Python 軌。