.NET 中的二進制浮點類型
大多數人會對他們在.NET中的算術的"出錯"首先感到驚訝。使用一些稱為”浮點”算術來表示非整型數字不是.NET 相比其他大多數語言/平臺特殊的地方。在.NET 內部是沒問題的,但是你需要知道一些底層正在發生什么,否則你將會對一些結果感到驚訝。
我在這個事情上不是一個專家這不重要。雖然寫了這篇文章,我也發現了另外一篇 - 這次是一個真正的專家寫的,杰弗里 薩克斯(Jeffrey Sax)。我強烈建議你也同時讀他的浮點文章。
什么是浮點數?
計算機總是需要一些表示數據的方式,最終這些表示數據的方式總是歸結為二進制(0,1組合)。整數很容易表示(對負數有合適的轉換,有確定好的范圍可以知道表示從多大開始)但是非整數有一些復雜。不管你想出什么方法,總是有一個問題。例如,使用我們自己的十進制方式寫數字: 仍然(在十進制內部)不能表達三分之一,你只是在一個3循環中結束。無論你使用多少進制,一些數字都會產生同樣的問題 - 特別的,“無理數”的數字(那些不能用以分數表示的數字)如常量PI(音: pai)和e(指數e)總是有一些問題。
你可以將所有有理數用精確的兩個整型數表示,第一個數被第二個數除的結果 - 但是即便是一個非常”簡單”的操作整數都可以增長的非常大且非常快,平方根操作也會趨向產生無理數。有很多其他的因素會導致導致,但是最常用的解決問題的方式就是使用一種格式或其他格式的浮點類型。思想就是基礎有可以用來擴展表達的一些數字(尾數),另外(指數)用來表示規模是多大,以“小數點要去哪里”的形式表示。例如,34.5可以用”十進制浮點類型”3.45加上一個指數1來表示,同樣的3450也可以有同樣的尾數和一個指數3(34.5是3.45x101,3450是 3.45x103)來表示。現在,為了簡單起見例子使用十進制表示,但是大多數浮點類型是二進制表示的。例如,二進制尾數1.1加上尾數-1將意味著十進制0.75(二進制1.1==十進制1.5,在二進制中指數-1意味著”被2除”,十進制同樣的指數-1表示”被10除”,二進制1.1==20.2-1==1.5(譯者注)).
理解在同樣的方式你不能通過一個十進制擴充(無限)來精確表達三分之一是很重要的,有很多數字在十進制形式看起來很簡單,但是在二進制表示中卻有長的或者無限的擴展。這意味著(舉例)一個二進制浮點變量不能有精確的十進制值0.1。相反,假設你又一些如下代碼:
double x = 0.1d;
變量x實際上將存儲最接近那個值的double型值。一旦你腦子里可以轉過彎兒,那么為什么一起計算結果看起來是”錯誤”的將會變得很明顯。如果你被要求計算1/3 + 1/3,這兩個數相加的結果是0.666,而不是0.667(更接近兩個1/3 的和)。一個二進制浮點類型的表達式是3.65d+0.05d != 3.7d(盡管在一些情況下它顯示成3.7)。
.NET 中的浮點類型是什么樣子的?
C#標準僅列出double和float作為可用的浮點類型(這些是C#中System.Double和System.Single的速記表示),但是decimal類型(速記表示為System.Decimal)實際上也是一個浮點類型 - 它僅是十進制浮點類型,但是指數的范圍很有趣。decimal類型在另外一篇文章中描述,所以這篇文章不會做任何深入探討 - 我們關注double和float.這兩個都是二進制浮點類型,參照IEEE 754(一個多種浮點類型的標準定義)。float是一個32位類型(1個符號位, 23位的尾數和8位指數), double是一個64位類型(1個符號位, 52位尾數和11位指數)。
結果不是我期望的是不好的結果嗎?
好吧,那取決于情況。如果你在寫財務軟件,你可能要非常嚴格的定義處理錯誤的方式,數量也是直覺上用10進制表示 - 在這種情況decimal類型更加與float或者double類型相似。如果,然而,如果你在寫一個科學應用程序,使用十進制浮點表示法可能會有一點弱,你也可能想要開始處理一些低精度的數目(一美元就是一美元,但是如果你在測量一個單位是米的長度,你可能開始有一些不精確。)
比較浮點數字
所有這些可以得出一個推論,你應該非常,非常少的去直接比較浮點數間是否相等。通常比較大于或者小于會好些,但是當你對相等感興趣時你應該總是考慮是否你實際上想要的接近相等:一個數字總是與另外一個相同。做這個的一個簡單的方式是用一個數減去另外一個數,使用Math.Abs來找到絕對值的不同,然后檢查是否這個誤差是否低到可以忍受的級別。
也有一些情況是病理的,這些是由于JIT優化導致。查看下面的代碼:
using System; class Test { static float f; static void Main(string[] args) { f = Sum (0.1f, 0.2f); float g = Sum (0.1f, 0.2f); Console.WriteLine (f==g);
//g = g + 1;
} static float Sum (float f1, float f2) { return f1+f2; } }
它應該總是打印True, 對不?錯,很不幸。當在debug模式下運行時,JIT不能像正常那樣做一些優化處理,它將打印True.當正常運行時JIT可以將sum 的結果存儲的比一個float可以實際表示的數更加精確 。
它可以使用默認x86 80位表示,例如,對sum 本身,返回值和本地變量。查看ECMA CLI 規范,第一部分, 12.1.3 章節來獲得更多細節。取消上面的注釋,讓JIT的行為稍微謹慎一些 - 結果將會是True - 盡管在當前的實現可以讓結果是True,但是不應該被信賴.(在上面語句中將g強制轉換成float也可以有同樣的效果,盡管它看起來像一個空操作(no-op).)
這是另外的避免對浮點數做相等比較的原因,盡管你非常確定結果應該是一樣的。
(譯者注: .NET 平臺的運行結果總是True. Java 平臺沒有自己做過測試,別人的測試也是True)
.NET 是如何格式化浮點數的?
在.NET中沒有查看一個浮點數的精確十進制值的內建方式,盡管你可以通過一些工作來完成。(查看這篇文章的末尾的一些可以實現這個功能的代碼。)默認情況下,.NET將一個double類型數格式化成15個十進制位置,將一個float類型數格式化成7個十進制位置。(在一些情況將使用科學計數法;查看MSDN標準數字格式字符串頁來獲得更多內容。)如果你使用往返模式規范(“r”),它會將數字格式化成最短格式,當截取(成同樣類型)時,將會變成初始數字。如果你以字符串存儲浮點數字而且精確的值對你來說很重要,你應該定義使用往返模式規范,否則你非常可能丟失數據。
一個浮點數在內存中看起來究竟是什么樣子的?
正如上面所說的,一個浮點數基本有一個符號位,一個指數和一個尾數。所有這些都是整數,它們三個的聯合精確的確定數字的表示形式。有很多浮點數類別: 規范數,低于正常數,無窮數和非數字(NaN, not a number).大多數數字是規范化的,意味著二進制尾數位的第一位是1,也意味著你實際上不需要存儲它。例如,二進制數1.01101可以僅用.01101表示 - 開始的1是假設的,如果是0將會使用一個不同的指數。那個技術只有當數字在可以選擇適合的指數范圍時才可以工作。不在那個范圍中的數字(非常,非常小的數字)被稱為非正常數字,并假設沒有開始位。”不是一個數字”(NaN, not a number)是像指0/0的結果之類的,等等。NaN有很多不同的類別,也有一些老的行為。非正常數字有時候也稱作非規范數。
符號位,指數和尾數在比特級別的表示方法都是一個無符號整數,存儲的值按順序先是符號位,然后是指數位,最后是尾數。”真實的”指數是有偏移值的 - 例如,一個double型數,指數是1023偏移,所以當你回來計算出實際值時,一個存儲指數值為1026的值就變成3。下面的表顯示了符號位,指數和尾數的每種組合的意思,使用double作為一個例子。相同的原則也適用于float,僅有一些不同值(比如偏移值不同)。注意這里給出的指數值是指存儲的指數,在偏移值應用之前。(那就是為什么偏移值顯示在”值”列。)
符號位(s, 1位) |
存儲的指數(e, 11位) |
尾數(m, 52位) |
數字類型 |
值 |
任意 | 非零 | 任意 | 正常 | (-1)s x 1.m (二進制) x 2e-1023 |
0 | 0 | 0 | 0 | +0 |
1 | 0 | 0 | 0 | +0 |
0 | 2047 | 0 | 無窮大 | 正無窮大 |
1 | 2047 | 0 | 無窮大 | 負無窮大 |
0 | 2047 | 非零 | 非數字 | n/a |
可以工作的例子
考慮下面的64位二進制數:
0100000001000111001101101101001001001000010101110011000100100011
作為一個double型數,可以被拆分成:
符號位: 0
指數位: 10000000100 二進制=1028 十進制
尾數位: 0111001101101101001001001000010101110011000100100011
這是因此一個正常數的值
(-1)0 x 10111001101101101001001001000010101110011000100100011 (binary) x 21028-1023
也可以更簡單的表示為
1.0111001101101101001001001000010101110011000100100011 (binary) x 25
或者
101110.01101101101001001001000010101110011000100100011
在十進制,這是46.42829231507700882275457843206822872161865234375,但是.NET 將會默認顯示46.428292315077 或者使用”往返”格式規范表示為46.428292315077009.
NaNs
NaNs 是奇獸。有兩種類型的NaNs - 信號和安靜(signalling and quiet, 譯意可能不準確)或者簡短表示為SNan和QNaN。在位模式概念中,一個安靜的NaN有高位尾數, 而一個信號NaN將它清除了。安靜NaNs用來標記精確操作是未定義的,而信號NaNs用來定義其他的(操作是非法的,而不是僅有一個不確定輸出)。
大多數人想知道的最奇怪的事情時NaNs不等于它們自己。例如,Double.NaN==Double.NaN 結果是false.相反,你需要使用Double.NaNs來檢查是否一個值不是一個數字。幸運的是,大多數人不可能遇到NaNs除了在這篇文章里。
結論
只要你知道發生了什么并且不期望你在你的程序中輸入的十進制數就是十進制數值,并且不期望設計二進制浮點數的計算必須生成精確結果,那么二進制浮點算術是很好的。盡管兩個數字都被你正在使用的類型精確表示,涉及這兩個數的操作結果將不會必須精確表示。這個可以很簡單的通過除法操作(例如1/10 不是精確表示的,但1 和10都是精確表示的)看出來但是它可以在任何操作中發生 - 盡管看起來不可能發生的如加法和減法操作。
如果你特別需要精確十進制數字,考慮使用decimal類型來代替 - 但是這樣做要考慮到付出性能的代價。(一個非常快設計的測試顯示doubles類型數的乘法比decimals類型的乘法快40倍;不要為這個情況花費額外的注意,但是要將在當前硬件環境里二進制浮點運算比十進制浮點運算快很多作為一個提示看待。)
以我的經驗來看,大多數商業應用可能有很多種類的用十進制浮點數比二進制浮點更好的值。特別的,幾乎任何要與錢相關的數字都更適合使用decimal表示。