Sitemap

0.1 + 0.2 不等於 0.3?

電腦科學基礎知識之不是 JS 的鍋

17 min readMay 7, 2025

--

今天來討論一個很基礎的問題,也許有的面試也蠻愛問的?剛好前陣子跟朋友討論到這件事,就順手記錄一下。一樣警語,以下是我個人的理解與筆記,有錯誤的話歡迎勘誤,謝謝。

文章開始之前,先提一件事:之前在看 JavaScript 的討論時,大家很愛用 0.1 + 0.2 不等於 0.3 這件事來嘲笑 JavaScript 很奇怪,但我反而覺得疑惑,因為會發生這件事是因為 IEEE 754 的規範,其實只要使用 IEEE 754 浮點數標準的程式語言都會發生這個現象,還有人為此做了一個網站 https://0.30000000000000004.com/ 列出了不同程式語言的計算結果。

所以 JavaScript 並不特別,為什麼大家特別愛用這點嘲笑 JavaScript 呢?為此,我特別去請教了 ChatGPT,等分享完緣由後,再來整理 ChatGPT 給我的回覆,這邊先分享一下 ChatGPT 的結語:

Press enter or click to view image in full size
From ChatGPT

為什麼要知道 0.1 + 0.2 !== 0.3?

軟體開發工程師,最主要的一個工作就是在處理「記憶體」,無論你用哪種程式語言,也不管是 primitive type (Integer, Float, Boolean, Char…) 又或是 reference type (Array, Object, …),都會使用到某一塊記憶體空間。如果能知道我們的程式在運行時,記憶體是怎麼被分配的、怎麼被複製的、怎麼被釋放的…等等,那你會對整個程式語言有更深的了解,會比較少犯錯,能寫出更有效率的程式,甚至,你會開始感覺到,不同的程式語言,對於記憶體會有不同的處理哲學,也許大部分可能相似,但有一些細節不一樣,這些不一樣的地方,就是這些程式語言設計者,想要去解決的問題,或是他們的喜好。

往實務面講,如果你需要處理精確的數字運算,例如金融交易系統中的金額等,能真的懂為什麼 0.1 + 0.2 !== 0.3,你也能知道有什麼方法可以解決(或者說避開)這件事。

所以,記憶體到底怎麼儲存數字?

我們先從比較簡單的「正整數」開始看起,假設一個整數我們用 8 bits 來儲存好了,比較好說明。

8 bits 的意思就是有 8 個格子,每個格子可以填上 0 或 1,那最小的數字就是全都填上 0,最大的數字就是全填上 1:

00000000 => 0
11111111 => 2⁰ + 2¹ + … + 2⁷ = 2⁸-1 = 255

這是正整數的情況,也就是所謂的 unsigned,以 C++ 來說,會有 unsigned char, unsigned short int, unsigned int, unsigned long int,…不曉得大家在初學程式語言的時候,對於「型別」這件事有沒有感到疑惑過,這到底是什麼?其實「型別」就是在告訴程式語言這個值需要多少記憶體、要怎麼編碼/解碼它等。

例如 char 就是要 1 個 byte (也就是 8 bits),而 short int 就是要用到 2 bytes (16 bits),int 就是會用到 4 bytes。也許你也曾看過不同型別能放的數值的最大值跟最小值,為什麼不同型別的最大、最小值不同?就是因為他們佔用的 byte/bit 數量不同,因此能表達的數字大小不同,而前文提到的 unsigned 就是把這些 bit 全都用來表達數字本身。

以 unsigned short int 來說,有 2 bytes,也就是 16 bits,unsigned,所以全部 16 個格字都可以用來表達數字本身。最小還是 0,這個很好理解,那最大呢?全部都放 1?這樣是多少呢? 2¹⁶-1 = 65536–1=65535。這樣應該可以很清楚地理解型別跟最大/最小值的限制了吧。

如果超過了呢?

在討論負數之前,先來討論一下這個情境:

上面的例子中,我們以 8 bits 來儲存正整數,最小會是 0、最大會是 255,那如果分別有兩個數字,例如 s1 = 100、s2 = 200,大小確實是在 0 ~ 255 之間,這時候我們把它們相加: s1 + s2 = 100 + 200 = 300 => 超過 255 了,這時候會發生什麼事呢?

100 用 8 bit 來儲存會是 01100100
200 用 8 bit 來儲存會是 11001000
相加起來會是 100101100,咦,用到 9 個格子了?

如果結果仍然是用 8 bits 來儲存,那電腦只會保留最後的 8 位,也就是 00101100,換算成 10 進位就是 44,而不是我們預期中的 300。這種情況就稱之為溢位(overflow)。

如果儲存的數值是允許負數的,也就是接下來討論的情況,還可能會發生兩個正整數相加的結果是負數喔!

那負數呢?

電腦科學裡,雖然我們信仰乖乖(?),但其實很多東西都不是平白無故地出現的,從來都不是變魔術,以負數為例,我們需要知道這塊記憶體空間記住的是一個負數,那就一定會有地方在記錄它,不然電腦不會通靈知道這塊記憶體是負數多少。

如果這個變數不是 unsigned,也就是 signed,是允許儲存負數的,那會用一種叫做 2 的補數(Two’s Complement)的方式來表示。一樣我們用 8 bits 來舉例,現在有 8 個格子,我們用最左邊的那個格子來記錄這個數字是正數還是負數,會把它稱為「符號位」,既然最左邊的格子用來當作符號位了,那能表達數字本身的就只剩下 7 個格子 => 可以推理知道能表達的正數字範圍就比較小了

0–0000000 => 最小的正整數,最左邊 0 表示這是正數,後面 7 個 0,所以是 0
0–1111111 => 最大的正整數,最左邊 0 表示這是正數,後面 7 個 1,所以是 2⁷-1 = 128–1=127

那負數呢?以 -5 為例,先計算 5 的二進位: 00000101,把它取補數,也就是 0 變 1、1 變 0,那就變成 11111010,再把它加上 1 => 11111011,這個就是 -5 的二進位了。那要怎麼把 11111011 算會來變成 -5?

  1. 最左邊是 1,所以是負數
  2. 取補數: 也就是 0 變 1、1 變 0 => 00000100
  3. +1 => 00000101 => 5
  4. 因為符號位是 1,所以是負數,那就是 -5

這過程不知道大家有沒有發現到,補數的補數會回到原始值,是不是超酷的!但,這是有例外的…

我們先來看看極端值:

1–0000000: 最左邊的符號位是 1,所以是負數
10000000 取補數 => 01111111
加上 1 => 10000000 => 128 => 咦,又回到 10000000,這個補數的例外就是回到自己(但要說這是原始值,好像也對?)
因為符號位是 1,所以是 -128 => 最小負數

1–1111111: 最左邊的符號位是 1,所以是負數
11111111 取補數 => 00000000
加上 1 => 00000001 => 1
因為符號位是 1,所以是 -1 => 最大負數

到目前為止,我們整理一下:

Press enter or click to view image in full size

讓我們回到型別這件事上,現在有一塊 8 bits 的記憶體,儲存著這樣的內容 10010001,那這樣到底是什麼值?

剛剛提到型別時,有提到「型別」就是在告訴程式語言這個值要怎麼編碼/解碼它等。以 10010001 為例,如果我們的程式是用 unsigned 去解讀它,那他就是 2⁰+2⁴+2⁷=145,但如果是以 signed 去解讀時,那就是 -111,更甚者,如果是用 C/C++ 中的 char 來解讀的話,會是 ASCII 表的第 145 號字元,通常是無法直接顯示的特殊字元。

再以 01000001 為例,以 char 來表示是英文字母 A,但如果是以 unsigned 數值來解讀,那就會是 65。舉例到這邊,不知道大家對型別有沒有更有感覺一點?

那轉型呢?這邊以 Java 為例:

// 測試 1:
int i = 32767;
short s = (short)i;
System.out.println(s); // 會是 32767

// 測試 2:
int i = 32768;
short s = (short)i;
System.out.println(s); // 會是 -32768

為什麼測試 1 的 s 印出來跟 i 一樣,但測試 2 就不同了呢?

在 Java,short 會使用 2 bytes,也就是 short 的最小值是 -32768,最大值是 32767 (2¹⁵-1)。但 int 可以用到 4 bytes,所以 int 的 32767 在記憶裡會是這樣表達:

Press enter or click to view image in full size
備註: 這邊實際上要看是 Little Endian 還是 Big Endian,這邊以 Bit Endian 為例

當我們把 int 轉成 short 時,會擷取低位的 16 bits,換算會來還是 32767。

Press enter or click to view image in full size

那如果是 int 32768 呢?那就會如下圖這樣,截完後最左邊的 1 變成 short 的符號位,所以就變成 -32768 了。

Press enter or click to view image in full size

再給一個例子:

int i = 75548;
short s = (short)i;
System.out.println(s); // 會是 10012
Press enter or click to view image in full size

0010 0111 0001 1100 是 2²+2³+2⁴+2⁸+2⁹+2¹⁰+2¹³=10012,如同程式執行的結果。

藉由上述的例子也可以知道,如果要從使用比較多記憶體的型別轉型成為佔用記憶體較小的型別時,有可能會發生資料丟失而導致資料錯誤,這個稱為 type truncation(截斷)。

另外,我們也來看一下 signed 的溢位的情況:

short s1 = 32000;
short s2 = 32004;
short s3 = (short) (s1 + s2);
System.out.println(s3); // -1532

32000 = 01111101 00000000
32004 = 01111101 00000100
相加後變成 11111010 00000100
補數 => 00000101 11111011
加 1 => 00000101 11111100
換算成 10 進位即為 1532
又符號位是1,所以是 -1532

是不是就發生了前文所述的,兩個正整數相加,結果卻變成負數的情況!

那小數呢?

到目前為止,不知道大家有沒有發現,所有的整數都可以用二進位表示?這點有廢話啦,畢竟就只是不同的進位系統,底數不同而已。但小數呢?

這邊為了方便講解,我們一樣假設只有 8 bits 可以用,那這 8 bits 怎麼用呢?這時候就是看「格式規範」,例如我們「規定」這 8 個格子中,最左邊的一位是符號位(S, Sign),中間三位是指數(E, Exponent),最後四位是尾數(M, Fraction):

由於 E 只有 3 個格子,又需要儲存正負數(後面就會知道為什麼),所以設計了一個 bias = 2^(3–1)-1 = ²²-1=3,所以 E 中要儲存的不是最後計算出來的 指數,而是要把指數加上這個 bias。

接下來就是開始計算了,首先要把數字轉成二進位:以二進位來說,2 的 -1 次方是 0.5,會表示為 0.1(二進位),2 的 -2 次方是 0.25,會表示為 0.01(二進位),…以此類推。

所以當我們要表示 2.875 的話:
.整數部分的 2 以二進位表示就是 10(二進位)
.0.875 = 0.5 + 0.25 + 0.125 => 所以是 2 的 -1 次方 + 2 的 -2 次方 + 2 的 -3 次方
=> 組合起來就是 10.111(二進位),是不是沒有整數那麼直覺了 😅

再來是把這個二進位的小數以科學記號來表示:
10.111(二進位)= 1.0111 x 2¹

這時候會拿到三個數字:

  • 符號位 S (Sign): 是正數,所以是 0
  • 指數 E (Exponent): 因為是 2¹,所以是 1,這時候要加上剛剛算出來的 bias 3 => 1 + 3 = 4 => 100
  • 尾數 M (Fraction): 0111,也就是 1.0111 中的 0.0111 中的 0111

按照這個結果填進去格子裡: 01000111

這邊我們再來練習一個 -0.625:
.整數部分的 2 以二進位表示就是 0
.0.625 = 0.5 + 0.125 => 所以是 2 的 -1 次方 + 2 的 -3 次方
=> 組合起來就是 0.101(二進位)

再以科學記號表示: 0.101 = 1.01 x 2^(-1)

  • 符號位 S (Sign): 是負數,所以是 1
  • 指數 E (Exponent): 因為是 -1,加上 bias 3 => -1 + 3 = 2 => 換成二進位 10
    => 這邊有沒有開始感覺到為什麼要設計這個 bias 了?有了這個 bias,就可以讓正數指數和負數指數都用「無號(unsigned)整數」的方式來表達,是不是很酷!
  • 尾數 M (Fraction): 01,也就是 1.01 中的 0.01 中的 01

按照上述計算的結果填進去格子裡就會是:

IEEE 754

上述舉例時,我們為了便於講解,用 8 bits 來舉例,並且我們自行規定了最左邊的一位是符號位(S, Sign),中間三位是指數(E, Exponent),最後四位是尾數(M, Fraction),這是我規定的,那實際上是怎麼樣呢?

目前大部分的主流程式語言都是遵循 IEEE 754 這個國際標準,它定義了如何在電腦中表示浮點數(小數)、如何進行浮點數的運算(加減乘除)、如何處理無窮大/NaN(非數值)及怎麼實作誤差、例外處理與精度控制等。

IEEE 754 將格式分為:

  • 單精度 (float): 使用 32 bit(1 bit 符號 S+ 8 bit 指數 E+ 23 bit 尾數 M)
    指數 E 是 8 bits,所以 bias 是 2^(8–1) -1 = 127
  • 雙精度 (double): 使用 64bit(1 bit 符號 S+ 11bit 指數 E + 52 bit 尾數 M)

由上所述,應該可以知道單精度(float)比較節省記憶體空間,但雙精度(double)因為用到的格子更多,所以可以更精確。

回到我們原本的例子: 2.875,我們已經知道他的二進位科學記號表示是 1.0111 x 2¹:

這時候拿到的三個數字會是:

  • 符號位 S (Sign): 是正數,所以是 0
  • 指數 E (Exponent): 因為是 2¹,所以是 1,這時候要加上剛剛算出來的 bias 127=> 1 + 127 = 128 => 1000 0000
  • 尾數 M (Fraction): 0111,也就是 1.0111 中的 0.0111 中的 0111

如果以單精度的規範來填寫,那填進去格子裡後就會是: 0 10000000 01110000000000000000000

其他例子就留給大家自己練習嚕,這邊有個網站,可以讓大家對答案: https://www.h-schmidt.net/FloatConverter/IEEE754.html

那 0.1 + 0.2 !== 0.3?

到目前為止,我們說明了正整數、負數、小數在記憶體中會被怎麼儲存,那到底為什麼 0.1 + 0.2 會無法等於 0.3?

我們來看一下十進位的 0.1 怎麼用二進位表示?
0.1 = 0.0625 (2 的 -4 次方) + 0.03125 (2 的 -5 次方) + … 一直計算下去會發現,它會無限循環:
0.1 = 0.0001100110011001100110011001100110011001100110011001101…

0.2 則為 0.001100110011001100110011001100110011001100110011001101…

也就是,並不是所有的小數都可以用二進位來完整表達的(不像整數)。

現在把這兩者相加…看來不會有什麼好結果…加上我們前面在討論整數時已經可以看到,不同型別所用到的記憶體大小不同,但無論使用多大的記憶體空間,即便是 unsigned、全部格子都放 1,還是會有一個最大值的上限,也就是記憶體空間大小會限制了我們的表達能力。

以下是 ChatGPT 的計算:

0.1 + 0.2 = 0.30000000000000004440…
0.3 的二進位 0.29999999999999998889…

到這邊,大家應該已經知道為什麼 0.1 + 0.2 會無法等於 0.3 了,應該也能理解,這不是 JavaScript 特有的現象,只要是按照 IEEE 754 的規範實作,就會有這個問題。

備註: 根據 MDN,JavaScript 中的 Number 是雙精度,也就是 double-precision 64-bit binary format。

如何避免?

以 JavaScript 來說,如果要避開這個問題,以下幾種方法給大家參考:

  1. 使用計算誤差
function isApproximatelyEqual(a, b) {
return Math.abs(a - b) < Number.EPSILON;
}

console.log(isApproximatelyEqual(0.1 + 0.2, 0.3)); // true

Number.EPSILON 是 JavaScript 中最小可以被判斷為不同的兩個 number 差值。

console.log(Number.EPSILON);
// 1 2.220446049250313e-16

2. 自己定義誤差

function almostEqual(a, b, tolerance = 1e-10) {
return Math.abs(a - b) < tolerance;
}

almostEqual(0.1 + 0.2, 0.3); // true

或是 1 跟 2 結合起來: 預設用 Number.EPSILON,但也可以自定義:

function nearlyEqual(a, b, epsilon = Number.EPSILON) {
return Math.abs(a - b) < epsilon;
}

console.log(nearlyEqual(0.1 + 0.2, 0.3)); // ✅ true

3. 先換算成整數再計算

這個是我過去在金融業時蠻常看到的作法,特別是一些金融交易商品,本身就是有規範到小數點後幾位的,所以我們可以很清楚地知道要乘上多少,例如某個金融商品的交易價格「規定」就是小數點後兩位,那我們就乘上 100 後再計算,甚至在資料庫中直接就是存整數,再搭配一個欄位紀錄乘數是多少。

4. 使用外部函式庫,例如 decimal.js 等。

最後,讓我們回到為什麼 JavaScript 特別容易被嘲笑這件事,根據 ChatGPT 的回答:

  • JavaScript 的數字只有 Number,其實就是 double,沒有明確區分整數、浮點數等
  • 不是強型別語言,不會有什麼警告
  • 學習跟使用的人多,所以這個 bug 更容易被看見、被截圖、被轉貼…(反正就是跟 PHP 競爭鄙視鏈底層?)
  • JavaScript 歷史包袱太多,黑歷史多…

不知道大家是否同意 ChatGPT 的解釋呢?我個人作為 JS 的熱愛者,它確實黑歷史多,但…我總覺得每個程式語言都有它自己的「特色」,通常是學習跟使用的人沒有用好,當然也是有人會說,這個程式語言特別容易害人…要這樣說也可以啦,但,JavaScript 目前就是唯一支援 JavaScript 嘛,要用的話就得去適應它。

最後,希望大家能從這篇文章中知道為什麼 0.1 + 0.2 不等於 0.3,而且這是所有遵循 IEEE 754 的程式語言都會有的問題,不止 JavaScript。

除此之外,也能從這個問題去認識型別與記憶體之間的關係,這跟我們使用哪種程式語言沒有關係,即使你是使用像 JavaScript 這樣的程式語言,也可以了解一下其他有區分 short, int, long, float…等型別的程式語言對於記憶體的處理,回頭對自己使用 JavaScript 也能有更深入的理解跟想像。

不要限制自己,有碰到就順道了解,很好玩的!

--

--

Azole (小賴)
Azole (小賴)

Written by Azole (小賴)

As a passionate software engineer and dedicated technical instructor, I have a particular fondness for container technologies and AWS.

No responses yet