儘管浮點數在日常生活中廣泛使用,但人們對它們的理解往往模糊不清,其行為也常令人感到困惑。本文旨在說明浮點數其實並不那麼複雜。
這篇文章是我最近推出的網站 float.exposed 的配套說明。除了利用當今頂級域名列表的荒謬性外,該網站旨在成為檢視浮點數的便利工具。雖然鼓勵大家多加嘗試,但其中許多元素的用途起初可能較為陌生。不過,當我們讀完本文後,希望這些元素都能變得熟悉。
技術上,本文所指的浮點數是指普遍使用的IEEE 754二進位浮點格式。half、float 和 double 分別對應 binary16、binary32 和 binary64。過去曾有其他格式,但你現在閱讀本文的裝置幾乎肯定使用IEEE 754。
正式說明完畢,我們從最淺顯的部分開始。
我們先從數字的基本書寫開始。這些初步步驟看似簡單,但從基本原理出發,有助於建立浮點數的運作模型。
以數字327.849為例,小數點左側的數字代表10的遞增次方,右側則代表10的遞減次方。
儘管這種表示法很自然,但有幾個缺點:
這裡所說的「小」與「大」是指數值大小,例如−4205的絕對值大於0.03,儘管在實數線上它在0.03的左側。
科學記號解決了這些問題。它將小數點移至第一個非零數字之後,並相應調整指數:
科學記號有三個主要部分:符號(+)、有效數字(3.27849)和指數(2)。正數時通常省略「+」號,但為了完整性我們保留。這裡的「10」表示使用的是十進位系統。上述缺點因此消失。
繼續以本節主角為例,若只關心最重要的4位數字,我們可以用多種捨入規則將數字四捨五入。
顯示的位數即為精度。例如8位數精度可表示為:
了解了十進位後,我們來看二進位數字。規則相同,只是基數是2而非10。小數點左側代表2的遞增次方,右側代表2的遞減次方。
為避免混淆,我會用2標示二進位數字。例如1000₂不是一千,而是2的3次方,即8。要將1001.0101₂轉換成十進位,只需將有位元的2的次方相加:8 + 1 + 0.25 + 0.0625,結果為9.3125。
二進位數也可用科學記號表示。將二進位小數點向右移三位,指數為3:
與十進位科學記號類似,我們將小數點移至第一個非零數字後。由於二進位系統中唯一非零數字是1,所有非零二進位科學記號皆以1開頭。
我們可以將數字四捨五入為較短形式,或以11位二進位數字表示更精確的值。
如果你理解了以上內容,恭喜你——你已經懂得浮點數的運作原理。
浮點數就是基於二進位科學記號的數字,並有以下兩個限制:
這幾乎就是浮點數的全部。
不同浮點數類型有不同的有效數字位數和指數範圍。例如,float 有24位二進位有效數字和指數範圍[−126, +127](包含端點)。以下是一個可用float表示的十進位數−616134.5625:
float的有效數字位數有限,因此某些實數無法被精確表示。十進位數0.2的二進位表示為:
上方的橫線(vinculum)表示無限循環。第25位及以後的有效數字無法放入float,必須透過捨入處理。完整有效數字為:
乘上指數後,結果的十進位值與完美的0.2不同:
若將完整有效數字向下捨入:
結果為:
無論如何,有限的有效數字位數限制了精確結果,這解釋了為何某些十進位數無法被精確表示為浮點數。
同理,指數範圍有限,許多極大或極小數無法用float表示:例如2的200次方和2的−300次方均超出[−126, +127]範圍。
了解有效數字位數與指數範圍後,我們可以開始將浮點數編碼為二進位表示。以−2343.53125為例,其二進位科學記號為:
符號位很簡單,1位表示正負。IEEE 754用0表示正數,1表示負數。此數為負,故符號位為1。
float的有效數字需24位,但二進位有效數字首位永遠是1,格式巧妙地省略此位以節省空間。計算時需記得它存在。我們複製剩餘23位,末尾補0:
省略的首位1稱為「隱含位」。
指數範圍254個值,需8位儲存。為避免負指數特別處理,我們加上偏移量127。
將指數11加127得138(二進位10001010),即為編碼指數:
依標準,先放符號位,再放指數位,最後放有效數字位。這順序是標準巧思之一。組合後形成32位的float:
整個編碼佔32位。可用LLDB驗證:
雖然C/C++標準不強制float/double必須用IEEE 754,但本文將合理假設如此。
幾乎任何數字都可用此方法編碼,但部分需特殊處理。
float指數範圍254值,加偏移127後,0與255兩值未用,分別用於特殊用途。
以下圖示每點代表唯一正float:
若有色盲可切換替代版本。注意指數與有效數字大量截斷,需橫向大量捲動才能看到所有值。
未標記點為正常float,接下來介紹特殊值。
指數為0且有效數字全0的float表示正或負0,符號位決定正負:
是的,浮點標準規定存在+0.0與−0.0。此概念有用,因為它告訴我們0是從哪個「方向」接近,代表存入float的值太小無法表示。例如−10e−30f / 10e30f無法表示,但結果是−0.0。
注意0.0 == -0.0為真,儘管編碼不同。且−0.0 + 0.0等於0.0,編譯器預設不會將+0.0優化成0,但可設定標誌放寬此限制。
指數最大值且有效數字全0的float表示正或負無限大,符號位決定正負:
無限大因捨入過大值產生。float中指數大於127的數皆為無限大。可直接使用INFINITY巨集。
正負零再次有用,正數除以+0.0得正無限大,除以−0.0得負無限大。
有限數與無限大運算定義良好,且保持無限大特性:
換言之,無限大極大,任何縮放不改變其無限大小,僅符號可能翻轉。但部分運算會破壞此規則。
指數最大值且有效數字非零的float表示NaN(非數):
最簡單取得NaN的方法是使用NAN巨集。實務中,NaN由以下運算產生:
若浮點變數未初始化,也可能含NaN。任何含NaN的運算結果皆為NaN,這是編譯器無法將a + (b - b)優化為a的原因之一,因為b若為NaN,整體結果必為NaN。
NaN不等於任何值,甚至不等於自己。isnan函數實作通常為return x != x;
值得一提的是NaN種類繁多,float可存超過八百萬種NaN,double則超過四千五百兆種。標準特意設計大量NaN用於「未初始化變數與類似算術增強」。Annie Cherkaev的「NaN的祕密生活」深入探討此議題,並介紹靜默與訊號NaN。
指數範圍限制了float可表示的最大與最小值。最大值為2^128 − 2^104(約3.40282347×10^38),偏指數為最大值減一,有效數字全為1:
最小正常float為2^−126(約1.17549435×10^−38),偏指數為1,有效數字全0:
C語言中可用FLT_MIN與FLT_MAX巨集取得最小正常值與最大值。FLT_MIN非float可存的最小值,還有更小的值。
討論二進位科學記號時,我們假設數字已正規化,即有效數字首位為1:
非正規數(subnormals或denormals)放寬此限制。偏指數為0時,指數解讀為−126(非−127),首位數字視為0:
編碼不變,計算時須記得隱含位為0而非1:
非正規數可存比最小正常值更小的數,但代價是精度降低。有效數字減少,歸一化後更明顯:
非正規數的經典例子是簡單算術。若兩浮點數相等:
則其差應為0:
若無非正規數,此假設不成立!以x為正常float數字:
兩數不同(觀察有效數字末位),差為:
超出正常float範圍(指數小於−126)。若無非正規數,捨入後差為0,暗示不相等數字相等。
歷史上,非正規數是IEEE 754標準化過程中爭議部分,詳見「浮點數老人的訪談」。
由於有效數字位數固定,浮點數無法存任意精度值。且指數部分使float值分布不均。下圖橫軸每格代表唯一float值:
注意2的冪次特別,定義了「塊狀」變化點。相鄰2的冪次間(2^n與2^{n+1})的float值間距固定,透過改變有效數字1位可跳躍。
指數越大,有效數字1位的「大小」越大。例如0.5指數為−1(2^−1=0.5),有效數字1位跳動為2^−24。1.0跳動為2^−23。1.0的跳動寬度稱為機器ε(machine epsilon),float可用FLT_EPSILON取得。
從2^23(8388608)開始,有效數字加1使float十進位值加1.0。2^24(16777216)是float可無遺漏存的整數上限。下一個float為16777218,16777217無法表示:
類型可處理更大整數,但2^24定義無間斷區間終點。
固定指數時,有效數字加1位跳躍等距float值,但格式還有妙用。考慮float值2097151.875:
暫忽三部分,將其視為32位無號整數:
實驗加1:
再將位元直接放回float格式:
得到2097152.0,即下一可表示float,類型無法表示此值與前者間其他數。
注意加1導致有效數字溢位,指數加1。指數在有效數字前的設計巧妙,讓我們可透過調整整數值輕鬆取得下一/前一可表示float(遠離/靠近零)。
最大float整數表示加1得無限大。最小float減1進入非正規數。最小非正規數減1得0。此技巧不會從+0.0跳到−0.0,且無限大「加1」成NaN,最後NaN加1成0。
迄今討論以float為主,但其較大表親double及較少見的half也值得關注。
double在二進位科學記號中有53位有效數字,指數範圍[−1022, +1023],編碼11位指數與52位有效數字,共64位:
half常用於電腦圖形。有效數字11位,指數範圍[−14, +15],編碼5位指數與10位有效數字,共16位:
half非常緊湊,但表示範圍小。指數僅5位,約1/32的half值為NaN。
IEEE 754規定128位浮點格式,但硬體支援有限。部分編譯器允許使用__float128,但運算多由軟體完成。
標準亦建議更高精度格式(如256位)指數與有效數字位數計算式,但實用性有限。
雖各類型長度不同,行為一致:
唯一差異在指數與有效數字位數。
實務中多數浮點運算使用同一類型,但類型轉換常見。例如JavaScript的Number是double,WebGL用float。轉換至更高或更低精度行為不同。
double有效數字與指數位數多於float,float多於half,轉換至更高精度類型會保留原值。
以half值234.125為例,其二進位表示:
同數字存float表示:
新格式有效數字位補0,指數位根據偏移調整。
以double值−282960.039306640625為例:
轉float時需考慮無法容納的有效數字位,預設採用四捨五入至最近偶數法。結果float表示為:
此float十進位值為−282960.03125,與double不同。轉half結果為:
因half最大指數為15,無法表示指數18,結果為−無限大。
轉換至較低精度類型會保留值,若無法容納的有效數字位皆為0且指數可表示。前述234.125從double轉float或half皆保留精確值。
日常生活常用的四捨五入法(若小數為.5則進位)有缺陷。假設調查結果為72.5%與27.5%,四捨五入後為73%與28%,總和101%。
四捨五入至最近偶數法解決此問題,72.5%捨為72%,27.5%捨為28%,總和恢復100%。
NaN與無限大不遵循一般規則,特殊規則為:NaN保持NaN,無限大保持無限大。
浮點數常需精確列印以便還原,每位元須保持不變。printf格式中%f與%e常用,但常無法維持足夠精度:
兩浮點數不同,f0為:
解決方法是手動指定最大精度,可用FLT_DECIMAL_DIG(值為9):
但此法即使簡單數字也會列印長格式,如3.0f列為3.000000000e+00。無法自動調整列印精度以精確表示。
幸好十六進位格式救援,使用%a格式列印最短且精確的十六進位浮點數:
十六進位常數可直接用於程式碼或scanf/strtof,且跨平台。用LLDB驗證:
十六進位格式精確且簡潔,每4位有效數字對應1位十六進位數字。無偏指數用p表示。詳見「十六進位浮點常數」。
九位數足以維持精確值,但遠不及完整十進位表示所需位數。
雖非所有十進位數可用浮點數表示(著名的0.1),每個浮點數都有精確十進位表示。以下以half為例,方法同float與double:
考慮half值3.142578125:
等價二進位科學記號為:
先將有效數字乘以1轉為整數:
取得整數乘以2的冪:
結合分數與指數部分:
再乘以巧妙寫法的1一次:
將每個2與5配對得:
組合後為兩整數乘積與10的冪次移位:
此乘以5^{-n}×5^{n}技巧也解釋了2的負冪次為5的冪次移位(如1/4=25/100,1/16=625/10000)。
雖然精確十進位表示存在,但常難用,小數在double中可有超過760位有效數字!
本文只是浮點數資源海洋中的一滴水。或許最詳盡的技術文獻是「每位電腦科學家應知的浮點算術」,雖全面但難讀。自我首次提及已近五年,仍多半略讀。
Bruce Dawson的精彩系列文章深入探討格式與行為,對經常處理浮點數的程式設計師必讀,若時間有限,推薦「2012版浮點數比較」。
Exploring Binary有多篇浮點格式詳細文章,示範float十進位最大有效數字為112,double則需767位。
Fabien Sanglard的「浮點數視覺化解釋」提供不同視角,將指數視為滑動視窗,有效數字為視窗偏移。
雖然本文結束,仍鼓勵繼續探索。上述資源將助你在浮點數廣闊領域發現更多。
我越了解IEEE 754,越感著迷。William Kahan與Jerome Coonen、Harold Stone共同創造了真正美麗且永恆的東西。
真心希望這趟浮點數細節之旅,讓它們不再神秘,並展現其中的美麗。