浮點型在記憶體中的儲存分佈方式因機器平臺而異,完全理解所有機器平臺中的浮點型儲存無疑是一件相當麻煩的事。幸運的是,大多機器平臺都遵守 IEEE-754 標準,很可能讀者和我使用的平臺正是使用的 IEEE-754 標準。
IEEE-754浮點(32位)或雙精度(64位)有三個部分(在IEEE-854下也有類似的96位擴充套件精度格式):符號位,表示數字是正的還是負的;指數位;以及指定實際數字的尾數位。以C語言中的單精度浮點數為例,下面是某位浮點數的位佈局:
該浮點數的值等於尾數乘以 2^x。讀者應該注意,上圖是二進位制分數,因此 0.1表示 1/2。為了方便理解,我們可以將其與十進位制的小數對應起來:十進位制的 0.1 等於 1*10^-1,所以二進位制的 0.1 等於1*2^-1,也即 1/2。
“尾數+指數”模式儲存浮點數可能有一點問題,例如:2x10^-1=0.2x10^0=0.02x10^1,依此類推。同樣一個數字可能有多種“尾數+指數”的表示方法,而同時兼顧多種表示方法勢必會造成巨大的浪費(也可能使在硬體中實現數學操作變得困難和緩慢)。
所以,“尾數+指數”的儲存模式需要一個統一的標準。事實上,IEEE-754 確實已經有標準了:假設給定一個二進位制的浮點數,那麼除非這個數是 0,否則總有某個位是 1。將小數點移到第一個 1 之後,調整指數位,這樣一來,“尾數+指數”的唯一儲存方式就固定下來了,也即“1.m x 2^n”形式。
但是如果數字是零呢?IEEE Standards Committee 透過將零作為一種特殊情況來解決這一問題:如果數字的每一位都為零,那麼數字就被認為是零。
現在讀者可能又有疑問了,因為 1.0 =1.0×2^0,上述儲存模式不儲存小數點前的 1,也即尾數和指數部分都為 0,而“如果數字的每一位都為零,那麼數字就被認為是零”,這樣看來,1.0 似乎是沒有辦法儲存的。
當然可以儲存 1.0。單精度浮點數的指數部分是“shift-127”編碼的,也即實際的指數等於 eeeeee 減去 127,所以 1.0 的表示方法實際上是 1.0×2^127。同樣的道理,最小值本應該是 2^-127,按照“shift-127”編碼指數部分,也即 2^0,可是這樣又變成“指數部分和尾數部分都為零”了,因此在該標準下的最小值,實際上的寫法是 2^1,也即 2^-126。
零不是唯一的“特殊情況”。對於正無窮大和負無窮大,非數字(NaN),以及沒有數學意義的結果(例如,非實數,或無窮大乘以零之類的計算結果)也有表示:如果指數的每一位都等於1,那麼這個數字是無窮大,如果指數的每一位都等於1,並且尾數位也都等於1,那麼這個數字就是NaN。符號位仍然區分+/-inf和+/-nan。
現在,讀者應該明白IEEE-754浮點數的表示方法了,下面是幾個數字的表示方法:
作為程式設計師,瞭解浮點表示的某些特性是很重要的,下標列出了單精度和雙精度IEEE浮點數的示例值:
在C語言程式開發中,數值的處理是一門值得深究的科學。本文不可能將複雜的數值演算法以及相關的C語言程式開發經驗一一列出。事實上,討論如何以理想的數值精度進行計算,就和討論如何編寫最快的C語言程式,如何設計一款優秀的軟體一樣,主要取決於程式設計師本身的綜合素質。
鑑於此,這裡將嘗試介紹一些基礎的,我認為每個C語言程式設計師都應該知道的內容。
首先,我們應該明白C語言程式開發中的兩個浮點數何時相等。可能讀者並不覺得難,因為似乎C語言中的 == 運算子就能判斷兩個浮點數是否完全相等。
然而實際上,C語言中的 == 運算子是逐位比較兩個運算元的,而兩個浮點數的精度總是有限的,在這種場景下,== 運算子的實際使用意義就沒有那麼大了。
讀者應該已經明白,計算機儲存浮點數時,很有可能是需要捨棄一些位的(如果該浮點數過長),如果 CPU 或者相應的程式沒有按照預期四捨五入,那麼使用 == 運算子判斷兩個浮點數是否相等可能會失敗。
例如,標準C語言函式庫三角函式 cos() 的實現其實只是一種多項式近似,也就是說,我們並不能指望 cos(π/2) 結果的每一個位都為零。在C語言程式開發中,我們甚至不能準確的表示 π。
看到這裡,讀者應該思考“相等到底是什麼意思呢?”,對於大多數情況來說,兩個數“相等”意味著這兩個數“足夠接近”。本著這種精神,在實際的C語言程式開發中,程式設計師通常定義一個很小的值模擬“足夠接近”,並以此判斷兩個浮點數是否“足夠接近到相等”,例如:
宏 flt_equals(a, b) 正是透過判斷 a 和 b 的距離是否小於 EPSILON(10的-7次方),來斷定 a 和 b 是否可以被認為“相等”的。這樣的近似模擬技術有時候是有用的,有時候卻可能導致錯誤結果,讀者應該自行判斷它是否符合自己的程式。
在本例中,EPSILON 可以看作是一種用於說明程式精度的標尺。應該明白,衡量精度的應該是有效數字,糾結 EPSILON 的具體大小並無意義,下面是一個例子。
假設在某段C語言程式中有兩個數字 1.25e-20 和 2.25e-20,它倆的差值是 1e-20,遠小於 EPSILON,但是顯然它倆並不相等。但是如果這兩個數字是 1.2500000e-20和1.2500001e-20,那麼就可以認為它們是相等的。也就是說,兩個數字距離足夠接近時,我們還需要關注需要匹配多少有效數字。
計算機儲存空間總是有限的,因此數值溢位是C語言程式設計師最關心的問題之一。讀者應該已經知道,如果向C語言中的最大無符號整數加一,該整數將歸零,令人崩潰的是,我們並不能只通過看這個數字的方式獲知是否有溢位發生,歸零的整數看起來和標準零一模一樣。
當溢位發生時,實際上大多數 CPU 是會設定一個標誌位的,如果讀者懂得彙編,可以透過檢查該標誌位獲知是否有溢位發生。
float 浮點數溢位時,我們可以方便的使用 +/- inf(無窮)。+inf(正無窮)大於任何數字,-inf(負無窮)小於任何數字,inf+1 等於 inf ,依此類推。因此在C語言程式開發中,一個小技巧是,將整數轉換為浮點數,這樣就方便判斷後續處理是否會造成溢位了。處理完畢後,再將該數轉換回整數即可。
不過,將整數轉換為浮點數判斷是否溢位也是要付出代價的,因為浮點數可能沒有足夠的精度來儲存整個整數。32 位的整數可以表示任何 9 位十進位制數,但是 32 位的浮點數最多隻能表示 7 位的十進位制數。所以,如果將一個很大的整數轉換為浮點數,可能不會得到期望的結果。
此外,在C語言程式開發中,int 與 float 之間的數值型別轉換,包括 float 與 double 之間的數值型別轉換,實際上是會帶來一定的效能開銷的。
讀者應該明白,在C語言程式開發中,不管是否使用整數,都應該小心避免數值溢位的發生,不僅僅是最開始和最終結果數值可能溢位,在一些計算的中間過程,可能會產生一些更大的值。一個經典的例子是“C語言數字配方”計算複數的幅度問題,極可能造成數值溢位的C語言實現是下面這樣的:
假設該複數的實部 re 和虛部 im 都等於 1e200,那麼它們的幅度約為 1.414e200,這的確在雙精度的允許範圍內。但是,上述C語言程式碼的中間過程將產生 1e200 的平方值,也即 1e400,這超出了 inf 的範圍,此時上面的實現函式計算的平方根將仍然是無窮大。
因此,magnitude() 函式的更佳C語言實現如下:
應該明白,上述C語言程式碼為了避免數值溢位,給出的實現實際上是一種近似。例如 im 等於 1e200,re 等於 1,那麼 im/re 的平方仍然能夠達到 1e400,這會造成數值溢位。但是平方 re/im 卻是可以的,因為 1e-400 會被四捨五入到零,足夠接近得到正確的答案。
前面我們提到,1.m 的尾數形式確保小數點前總是 1(非零時),既然如此,我們沒有必要再花費一個位的空間用於儲存 1。此時,即使尾數只有最後一位為 1,其他位都為 0,那麼它的有效數字也是全部的,因為最前方有個“隱藏的 1”。但是,如果兩個比較接近的浮點數相減,這個“隱藏的1”就會被抵消,最終得到的答案可能只剩下一位有效數字的精度。
幸運的是,就像上面求複數幅度避免出現數值溢位一樣,避免兩個接近的數字相減出現精度損失的方法也是有的,但是並沒有一個通用的方法。這裡給出一個簡單的例項就是使用 1/x 的函式代替 x 的函式,這對於處理二次運算很有效。我的建議是,如果讀者發現自己的C語言程式給出了令人懷疑的數值,就應該檢查一下相應的減法運算了。
看到這裡,相信讀者應該想到C語言程式中的加法可能也有同樣的問題:假設有數字 1.0,現在將其與 1e-20 相加。程式很可能認為 1e-20 很小,小於 EPSILON,於是忽略它,得到答案 1.0。這實際上也是一種精度損失。
要完全規避C語言程式中的浮點數可能帶來的問題,工作量無疑是巨大的。為了簡化問題,我們通常認為浮點數帶來的精度問題是這樣的:對浮點數的操作越多,損失的精度也會越多。
正如前文舉的例子 cos(π/2),C語言它的實現實際上是一種近似,得到的答案並不是 0,而是 6.12303E-17。不過就這個答案本身而言,它已經足夠小,認為等於 0 也沒什麼大問題。但是如果我們下一步計算是除以 1e-17,那麼得到的答案就約是 6 了,這與預期的零相差甚遠。
最後,在C語言程式開發中,並不是只有浮點數才重要的,整數同樣重要,它的精確性是一個有用的工具。有時程式需要跟蹤變化的分數(例如比例因子)。在這種情況下,既然浮點數受各種因素影響,那麼我們完全可以將該分數儲存為整數分子和分母來避免問題。在需要使用浮點數時,隨時再做一次除法運算就可以了。
浮點型在記憶體中的儲存分佈方式因機器平臺而異,完全理解所有機器平臺中的浮點型儲存無疑是一件相當麻煩的事。幸運的是,大多機器平臺都遵守 IEEE-754 標準,很可能讀者和我使用的平臺正是使用的 IEEE-754 標準。
IEEE-754是如何儲存浮點數的?IEEE-754浮點(32位)或雙精度(64位)有三個部分(在IEEE-854下也有類似的96位擴充套件精度格式):符號位,表示數字是正的還是負的;指數位;以及指定實際數字的尾數位。以C語言中的單精度浮點數為例,下面是某位浮點數的位佈局:
該浮點數的值等於尾數乘以 2^x。讀者應該注意,上圖是二進位制分數,因此 0.1表示 1/2。為了方便理解,我們可以將其與十進位制的小數對應起來:十進位制的 0.1 等於 1*10^-1,所以二進位制的 0.1 等於1*2^-1,也即 1/2。
“尾數+指數”模式儲存浮點數可能有一點問題,例如:2x10^-1=0.2x10^0=0.02x10^1,依此類推。同樣一個數字可能有多種“尾數+指數”的表示方法,而同時兼顧多種表示方法勢必會造成巨大的浪費(也可能使在硬體中實現數學操作變得困難和緩慢)。
所以,“尾數+指數”的儲存模式需要一個統一的標準。事實上,IEEE-754 確實已經有標準了:假設給定一個二進位制的浮點數,那麼除非這個數是 0,否則總有某個位是 1。將小數點移到第一個 1 之後,調整指數位,這樣一來,“尾數+指數”的唯一儲存方式就固定下來了,也即“1.m x 2^n”形式。
既然小數點前總是 1,那麼上述標準下的“尾數+指數”的儲存模式甚至都不需要再花費空間儲存小數點前的 1.但是如果數字是零呢?IEEE Standards Committee 透過將零作為一種特殊情況來解決這一問題:如果數字的每一位都為零,那麼數字就被認為是零。
現在讀者可能又有疑問了,因為 1.0 =1.0×2^0,上述儲存模式不儲存小數點前的 1,也即尾數和指數部分都為 0,而“如果數字的每一位都為零,那麼數字就被認為是零”,這樣看來,1.0 似乎是沒有辦法儲存的。
當然可以儲存 1.0。單精度浮點數的指數部分是“shift-127”編碼的,也即實際的指數等於 eeeeee 減去 127,所以 1.0 的表示方法實際上是 1.0×2^127。同樣的道理,最小值本應該是 2^-127,按照“shift-127”編碼指數部分,也即 2^0,可是這樣又變成“指數部分和尾數部分都為零”了,因此在該標準下的最小值,實際上的寫法是 2^1,也即 2^-126。
在我看來,為了表示 0 和 1,捨棄最小值(2^-127)是非常可取的做法。零不是唯一的“特殊情況”。對於正無窮大和負無窮大,非數字(NaN),以及沒有數學意義的結果(例如,非實數,或無窮大乘以零之類的計算結果)也有表示:如果指數的每一位都等於1,那麼這個數字是無窮大,如果指數的每一位都等於1,並且尾數位也都等於1,那麼這個數字就是NaN。符號位仍然區分+/-inf和+/-nan。
現在,讀者應該明白IEEE-754浮點數的表示方法了,下面是幾個數字的表示方法:
作為程式設計師,瞭解浮點表示的某些特性是很重要的,下標列出了單精度和雙精度IEEE浮點數的示例值:
注意,本文中的所有數字都假定為單精度浮點數;上面包含雙精度浮點數用於參考和比較。在C語言程式開發中,數值的處理是一門值得深究的科學。本文不可能將複雜的數值演算法以及相關的C語言程式開發經驗一一列出。事實上,討論如何以理想的數值精度進行計算,就和討論如何編寫最快的C語言程式,如何設計一款優秀的軟體一樣,主要取決於程式設計師本身的綜合素質。
鑑於此,這裡將嘗試介紹一些基礎的,我認為每個C語言程式設計師都應該知道的內容。
相等首先,我們應該明白C語言程式開發中的兩個浮點數何時相等。可能讀者並不覺得難,因為似乎C語言中的 == 運算子就能判斷兩個浮點數是否完全相等。
然而實際上,C語言中的 == 運算子是逐位比較兩個運算元的,而兩個浮點數的精度總是有限的,在這種場景下,== 運算子的實際使用意義就沒有那麼大了。
讀者應該已經明白,計算機儲存浮點數時,很有可能是需要捨棄一些位的(如果該浮點數過長),如果 CPU 或者相應的程式沒有按照預期四捨五入,那麼使用 == 運算子判斷兩個浮點數是否相等可能會失敗。
例如,標準C語言函式庫三角函式 cos() 的實現其實只是一種多項式近似,也就是說,我們並不能指望 cos(π/2) 結果的每一個位都為零。在C語言程式開發中,我們甚至不能準確的表示 π。
看到這裡,讀者應該思考“相等到底是什麼意思呢?”,對於大多數情況來說,兩個數“相等”意味著這兩個數“足夠接近”。本著這種精神,在實際的C語言程式開發中,程式設計師通常定義一個很小的值模擬“足夠接近”,並以此判斷兩個浮點數是否“足夠接近到相等”,例如:
宏 flt_equals(a, b) 正是透過判斷 a 和 b 的距離是否小於 EPSILON(10的-7次方),來斷定 a 和 b 是否可以被認為“相等”的。這樣的近似模擬技術有時候是有用的,有時候卻可能導致錯誤結果,讀者應該自行判斷它是否符合自己的程式。
在本例中,EPSILON 可以看作是一種用於說明程式精度的標尺。應該明白,衡量精度的應該是有效數字,糾結 EPSILON 的具體大小並無意義,下面是一個例子。
假設在某段C語言程式中有兩個數字 1.25e-20 和 2.25e-20,它倆的差值是 1e-20,遠小於 EPSILON,但是顯然它倆並不相等。但是如果這兩個數字是 1.2500000e-20和1.2500001e-20,那麼就可以認為它們是相等的。也就是說,兩個數字距離足夠接近時,我們還需要關注需要匹配多少有效數字。
溢位計算機儲存空間總是有限的,因此數值溢位是C語言程式設計師最關心的問題之一。讀者應該已經知道,如果向C語言中的最大無符號整數加一,該整數將歸零,令人崩潰的是,我們並不能只通過看這個數字的方式獲知是否有溢位發生,歸零的整數看起來和標準零一模一樣。
當溢位發生時,實際上大多數 CPU 是會設定一個標誌位的,如果讀者懂得彙編,可以透過檢查該標誌位獲知是否有溢位發生。
float 浮點數溢位時,我們可以方便的使用 +/- inf(無窮)。+inf(正無窮)大於任何數字,-inf(負無窮)小於任何數字,inf+1 等於 inf ,依此類推。因此在C語言程式開發中,一個小技巧是,將整數轉換為浮點數,這樣就方便判斷後續處理是否會造成溢位了。處理完畢後,再將該數轉換回整數即可。
不過,將整數轉換為浮點數判斷是否溢位也是要付出代價的,因為浮點數可能沒有足夠的精度來儲存整個整數。32 位的整數可以表示任何 9 位十進位制數,但是 32 位的浮點數最多隻能表示 7 位的十進位制數。所以,如果將一個很大的整數轉換為浮點數,可能不會得到期望的結果。
此外,在C語言程式開發中,int 與 float 之間的數值型別轉換,包括 float 與 double 之間的數值型別轉換,實際上是會帶來一定的效能開銷的。
讀者應該明白,在C語言程式開發中,不管是否使用整數,都應該小心避免數值溢位的發生,不僅僅是最開始和最終結果數值可能溢位,在一些計算的中間過程,可能會產生一些更大的值。一個經典的例子是“C語言數字配方”計算複數的幅度問題,極可能造成數值溢位的C語言實現是下面這樣的:
假設該複數的實部 re 和虛部 im 都等於 1e200,那麼它們的幅度約為 1.414e200,這的確在雙精度的允許範圍內。但是,上述C語言程式碼的中間過程將產生 1e200 的平方值,也即 1e400,這超出了 inf 的範圍,此時上面的實現函式計算的平方根將仍然是無窮大。
因此,magnitude() 函式的更佳C語言實現如下:
應該明白,上述C語言程式碼為了避免數值溢位,給出的實現實際上是一種近似。例如 im 等於 1e200,re 等於 1,那麼 im/re 的平方仍然能夠達到 1e400,這會造成數值溢位。但是平方 re/im 卻是可以的,因為 1e-400 會被四捨五入到零,足夠接近得到正確的答案。
前面我們提到,1.m 的尾數形式確保小數點前總是 1(非零時),既然如此,我們沒有必要再花費一個位的空間用於儲存 1。此時,即使尾數只有最後一位為 1,其他位都為 0,那麼它的有效數字也是全部的,因為最前方有個“隱藏的 1”。但是,如果兩個比較接近的浮點數相減,這個“隱藏的1”就會被抵消,最終得到的答案可能只剩下一位有效數字的精度。
幸運的是,就像上面求複數幅度避免出現數值溢位一樣,避免兩個接近的數字相減出現精度損失的方法也是有的,但是並沒有一個通用的方法。這裡給出一個簡單的例項就是使用 1/x 的函式代替 x 的函式,這對於處理二次運算很有效。我的建議是,如果讀者發現自己的C語言程式給出了令人懷疑的數值,就應該檢查一下相應的減法運算了。
看到這裡,相信讀者應該想到C語言程式中的加法可能也有同樣的問題:假設有數字 1.0,現在將其與 1e-20 相加。程式很可能認為 1e-20 很小,小於 EPSILON,於是忽略它,得到答案 1.0。這實際上也是一種精度損失。
經驗法則要完全規避C語言程式中的浮點數可能帶來的問題,工作量無疑是巨大的。為了簡化問題,我們通常認為浮點數帶來的精度問題是這樣的:對浮點數的操作越多,損失的精度也會越多。
正如前文舉的例子 cos(π/2),C語言它的實現實際上是一種近似,得到的答案並不是 0,而是 6.12303E-17。不過就這個答案本身而言,它已經足夠小,認為等於 0 也沒什麼大問題。但是如果我們下一步計算是除以 1e-17,那麼得到的答案就約是 6 了,這與預期的零相差甚遠。
不要忘記整數最後,在C語言程式開發中,並不是只有浮點數才重要的,整數同樣重要,它的精確性是一個有用的工具。有時程式需要跟蹤變化的分數(例如比例因子)。在這種情況下,既然浮點數受各種因素影響,那麼我們完全可以將該分數儲存為整數分子和分母來避免問題。在需要使用浮點數時,隨時再做一次除法運算就可以了。