那個坐不住的數
到現在你已能讀懂一個 二進位或十六進位數,也見過同一個 位元樣式會因讀法不同而代表不同的東西——一個無號整數、一個二的補數有號整數,諸如此類。整數很整齊:範圍內的每個值都恰好對應一個樣式,算術也完全精確。所以當電腦第一次面不改色地告訴你 0.1 + 0.2 等於 0.30000000000000004 時,你會嚇一跳。沒有任何東西壞掉。機器完全誠實——它只是沒有用你以為的那套數字系統。
原因和 1/3 在我們日常的十進位裡看起來很醜,是同一個道理。我們把 1/3 寫成 0.3333……,三永遠停不下來;十就是沒有一個有限的小數能表示三分之一。電腦用二進位儲存分數,而在二進位裡,反而是 0.1 沒有有限的結尾。把它展開,你會得到 0.0001100110011001100……無止盡地循環。機器只有固定數目的位元,所以它必須在某處把那條無盡的尾巴砍掉——而它真正保留的值,與你輸入的那個 0.1 差了一根頭髮。
用位元寫的科學記號
為了儲存從原子大小到星系大小、橫跨如此巨大範圍的數,電腦借用了科學記號的點子。在十進位裡,你把一個很大或很小的數寫成一個有效部分乘以十的某次方:6.022 x 10^23,或 1.6 x 10^-19。浮點數在二進位裡做的正是同一件事。每個值被拆成一個正負號(正或負)、一個尾數(有效數字,也稱 significand),以及一個指數,後者指出要把二進位小數點向左或向右滑多遠。那個滑動的小數點,字面上就是「浮動(floating)」的意思。
確切的版面由大家共同遵守的標準 IEEE 754 規定,所以在一台機器上寫出的浮點數,在另一台上讀起來一模一樣。32 位元的形式(C 的 `float`)用 1 個位元當正負號、8 個位元當指數、23 個位元當尾數。64 位元的形式(C 的 `double`,也是多數語言預設的數字型別)則是 1 + 11 + 52。尾數位元越多,精確的有效位數就越多;指數位元越多,能表示的數量級範圍就越廣。就像你已經見過的整數編碼一樣,浮點數也只是一個位元樣式,加上一條讀它的約定規則。
double (IEEE 754, 64 bits): S | exponent (11) | mantissa (52) | +-+-----------------------+------------------------------------------+ |1| biased | fraction after the implied leading 1 | +-+-----------------------+------------------------------------------+ value = (-1)^S x 1.mantissa x 2^(exponent - 1023)
兩個雖小卻巧妙的細節讓這套機制更有效率。第一,在正規化的科學形式裡,尾數的開頭位元永遠是 1(就像十進位尾數總是從 1 到 9 開頭、絕不會是 0),所以 IEEE 754 乾脆不存它——你用 52 個存下來的位元,換到了第 53 個免費的精確位元。第二,指數以偏移(biased)的形式儲存:那 11 位元的原始欄位存的是真正的指數加上 1023,於是該欄位永遠是個單純的無號數,比較兩個正浮點數時幾乎就像比較整數。正負號位元坐在最頂端,正好就是有號整數裡正負號位元所在的位置。
追蹤 0.1 在哪裡走樣
讓我們親手把這個著名的例子釘死,就用你對整數做進位轉換時那套仔細的方法——只是這次我們轉換的是小數部分。處理小數部分的訣竅是反覆乘以二:每一次,你越過的整數部分(0 或 1)就是二進位小數點後的下一個位元。
- 從 0.1 開始。乘以 2:得 0.2,整數部分為 0。小數點後第一個位元:0。
- 0.2 x 2 = 0.4,整數部分 0,下一位元 0。接著 0.4 x 2 = 0.8,位元 0。再 0.8 x 2 = 1.6,位元 1,並把 0.6 帶往下一步。
- 0.6 x 2 = 1.2,位元 1;保留 0.2。但我們之前見過 0.2 了!從這裡開始,0011 這串會無止盡地循環:0.1 在二進位裡就是 0.0001100110011001100……
- 一個 double 只有 52 個尾數位元,所以它必須把這條無盡的尾巴捨入到它能容納的最近值。存下來的數比 0.1 略大一點點——大約是 0.1000000000000000055511151231257827。
現在是關鍵。你輸入的 0.2 同樣被捨入了,0.3 也是。當你把略大一點的 0.1 加上略大一點的 0.2,這兩個微小的誤差合起來,使總和在捨入到最近的 double 之後,恰好落在捨入後的 0.3 之外一點——完整印出來便揭露 0.30000000000000004。每一步都完美地遵守了捨入至最近值。這結果正是誠實的二進位算術必然產生的;過程中根本不存在任何需要修正的失序時刻。
無限大、NaN,與數線的邊緣
IEEE 754 做了一件整數從不做的事:它保留了少數幾種指數樣式,用來表示並非尋常數字的特殊值。當指數欄位全為 1 時,該值就不再是有限的數。若尾數為零,你得到無限大——依正負號位元而為正或負——這正是 1.0 / 0.0 不崩潰、反而產生的東西。若尾數非零,你得到 NaN,意即「不是一個數(not a number)」,是像 0.0 / 0.0 或對負數開根號這類無意義運算的結果。
NaN 有個讓每個人都中招一次的性質:它不等於任何東西,連自己都不等於。對任何正常數而言,`x == x` 為真,但當 x 是 NaN 時卻為假,這正是偵測 NaN 的標準訣竅。所以無限大與 NaN 並不是偷渡進數字裡的錯誤碼——它們是貨真價實、定義良好的值,算術會穿過它們流動,讓一長串計算得以跑完、最後再回報「這部分爆掉了」,而不是在中途停擺。
誠實地與浮點數共處
第一條實用規則,從上述一切直接掉出來:絕不要用精確相等去比較兩個浮點運算結果。在任何算術之後寫 `if (a == b)` 都很脆弱,因為每次運算都可能讓結果偏移一小步捨入。改為檢查它們是否夠接近——也就是兩者的絕對差小於某個你為手邊問題所選的微小容差。對浮點數來說,「相等」是錯的問題;「夠接近」才是對的問題。
第二個誠實的提醒:精確度在數線上各處並不相同。一個 double 大約帶有 15 到 16 個有效十進位位數,但這些位數是相對的——可表示值之間的間隙在零附近極小,越接近範圍頂端就變得越大。把一個小數加到一個極大的數上,小數可能整個消失,被吞沒在最後一個位元之下。這也是為何金錢通常不用浮點數儲存:分必須精確,所以財務程式碼改用整數(以整數個分計數)或一個專門的十進位型別。
這一切都不代表浮點數不好——它是一套出色而標準化的方法,能把驚人廣大範圍的實數塞進固定的少數幾個位元組裡,而且每一秒都在驅動物理引擎、繪圖與機器學習。重點是尊重它的本質。它用一點點精確換來巨大的範圍,它在每一步都捨入,並在邊緣處有定義良好的無限大與 NaN。理解了這些取捨,0.30000000000000004 便不再是驚嚇,而成了一個署名——證明機器精準、忠實地做了二進位所要求的事。