在上一篇文章《妥協與取捨,解構C#中的小數運算》的留言區域有很多朋友都不約而同的說道了C#中的decimal類型。事實上之前的那篇文章的立意主要在於聊聊使用二進制的計算機是如何處理小數的,無非我接觸最多的是在托管環境下運行的高級語言C#,因此順帶使用了C#作為例子。一方面說明了計算機處理小數的本質,也起到了提醒各位更加關注本質而非高級語言表象的作用。當然,那篇文章中主要提到的是二進制浮點數double和float(即System.Double和System.Single,下文中使用double和float來分別指代這兩個類型)。不過既然說到障眼法,我覺得還是有必要寫一篇文章專門來聊聊decimal類型,也算是對留言提到decimal的朋友的統一回復。
私底下有一些朋友告訴我說在上一篇文章中如果只是單純的說十進制中的0.1無法使用二進制准確的表示,雖然理論上的確是這樣,但畢竟沒有通過直接觀察獲得一個直觀的印象,所以在正式引出decimal之前,我們先來看一看一個十進制的小數0.1為何不能被二進制浮點數准確的表示出來吧。
如同在十進制中,1/3是無法被准確表示的,如果我們要將1/3轉換成十進制小數的形式則是:
1/3 = 0.3333333....(3循環)
同理,十進制小數0.1也是無法被二進制小數准確表示,如果我們要將十進制的0.1轉換為二進制小數則是:
0.1 = 0.00011001100....(1100循環)
我們可以看到,如果要將十進制的0.1轉換為二進制小數,則會出現1100循環的狀況。因此根據我在上一篇文章中提到過的IEEE 754標准以及在上一篇文章中最後所舉的一個例子,我們首先將0.00011001100....進行邏輯移位,使之小數點左邊第一位是1。那麼結果是1.10011001100...,共移動了4位,因此指數相應的應該是-4。所以,表示十進制0.1的float二進制浮點數的結果如下:
符號位:0(表示正數)
指數部分:01111011(01111011換算成十進制是123,因為要減去-127故結果為-4)
尾數部分:10011001100110011001101(即通過移位之後,捨掉小數點左側的1,留下的小數部分,保留23位)
那麼這個用來“表示”十進制小數0.1的float二進制浮點數如果換算成十進制數到底是多少呢?它和0.1到底有多大的誤差呢?下面我們就來換算一下:
指數部分:2^(-4) = 1/16
尾數部分:1 + 1/2 + 1/16 + 1/32 + 1/256 + 1/512 + 1/4096 + 1/8192 + 1/65536 + 1/131072 + 1/1048576 + 1/2097152 + 1/8388608 = 1.60000002384185791015625 (在換算成float時會把小數點左側的1省略,這裡需要再次加回來)
那麼,換算之後實際的十進制數便是:1.60000002384185791015625 * 1/16 = 0.100000001490116119384765625
所以我們可以看到,二進制浮點數並不能准確的表示0.1這個十進制小數,它使用了0.100000001490116119384765625來代替0.1。
這便是直接使用二進制來表示小數的方式,很有可能會產生誤差。
但是很多朋友都提到了使用decimal來避免上文中出現的誤差。的確,使用decimal是一個十分保險的措施。但是,為什麼使用decimal類型,計算機突然就能夠很完美的計算十進制數了呢?難道是計算機在涉及到decimal類型的運算時,改變了自己內部最根本的二進制運算嗎?
當然不是。
我在上一篇文章中提到過,“眾所周知,計算機中使用的是0和1,即二進制,使用二進制表示整數是十分容易的一件事情”。那麼是否有可能間接借助整數來表示小數呢?因為二進制表示十進制整數是十分完美的。
答案的確如此。但是在我們討論decimal的細節之前,我覺得有必要先簡單介紹一下decimal。
在這裡的decimal指的C#語言中的System.Decimal,雖然在C#語言規范中只提到了兩種浮點數float和double(二進制浮點數),但是如果我們了解浮點數的定義,decimal顯然也是浮點數——只不過它的底數是10,因此它是十進制浮點數。
同樣,decimal和float以及double的組成也十分類似:符號位、指數部分以及尾數部分。
當然,decimal有更多的位,總共達到了128位,換句話說它又16個字節。如果我們把這16個字節劃分成4個部分,就可以一窺它的組成結構了。
下面使用m表示尾數部分、e表示指數部分、s表示符號位:
1~4號字節: mmmm mmmm mmmm mmmm mmmm mmmm mmmm mmmm
5~8號字節: mmmm mmmm mmmm mmmm mmmm mmmm mmmm mmmm
9~12號字節: mmmm mmmm mmmm mmmm mmmm mmmm mmmm mmmm
13~16號字節: 0000 0000 0000 0000 000e eeee 0000 000s
從它的組成結構,我們可以看到decimal的尾數部分有96位(12字節),而指數部分有效的只有5位,符號位自然只有1位。
現在讓我們把思路拉回本小節一開始的部分,如果通過借助整數來表示小數的方式,decimal便可以更准確的來表示一個十進制小數了。這裡我們就可以看到,decimal的尾數部分事實上是一個整數,而尾數所表示的范圍也很明確了:0~2^96 - 1。換算為十進制便是0~79228162514264337593543950335,一個29位的數字(當然,最高位的值最多到7)。
此時如果我們對尾數部分進一步劃分結構的話,可以將尾數看成是由三個部分的整數組成的:
1~4號字節(32位)代表了一個整數,表示的尾數的低位部分。
5~8號字節(32位)代表了一個整數,表示的尾數的中間部分。
9~12號字節(32位)代表了一個整數,表示尾數的高位部分。
這樣,我們就將表示一個整數的decimal尾數又劃分成了三個整數。
值得一提的還有指數部分,首先它也是一個整數,但是如果我們進一步觀察decimal的結構的話,還可以發現指數部分的形式(000e eeee)很奇怪只有5位是有效的,這是因為它的最大值只能到28。至於為何要這樣處理,原因其實很簡單,decimal指數部分的底數是10,而尾數部分表示的是一個29位或者28位的整數(之所以這樣說是由於最高位29的值其實只能到7,所以總共只有28位的值是可以任意設置的)。那麼就假設我們有一個28位的十進制整數,這28個位置上的值可以是0~9之中任何一個數,此時decimal的指數部分控制的便是我們要在這個28位整數的哪一位點上小數點。
當然,還需要提醒各位讀者注意的一點便是decimal的指數部分表示的負指數冪,也就是說decimal所表示的值其實是如下的樣子:
符號 * 尾數 / 10 ^指數
因此,decimal能正確表示的數字范圍位是-/+79228162514264337593543950335,但是也正是由於decimal可以表示的十進制數字的有效位數也在28或29(取決於最高位的值是否在7以內)的范圍內,因此在表示小數的時候,對小數的位數也是有限制的。
我們再回去看一眼decimal的結構,可以發現實際上128位中只有102位是必須的,除了這有意義的102位之外,其余的位的值是0。而這102位我們可以進一步把它分成4個整數,這便是我們在調用decimal.GetBits(value)方法時,返回的包含了4個元素的int型數組:
其中前3個int型整數在上文我已經說過,它們用來表示尾數的低位部分中間部分以及高位部分。
最後的1個int型整數用來表示指數和符號部分。該int型整數中的0~15位並沒有使用,而是全部設為0;16~23位用來表示指數,當然由於指數最大值是28因此只有其中的5位有效;24~30位同樣沒有使用,而是全部設為0;最後一位存放的便是符號位,0代表正數,1代表負數。
下面我就來給各位舉一個例子:
//獲取decimal的組成結構 using System; using System.Collections.Generic; class Test { static void Main() { decimal[] vals = {1.111111m, -1.111111m}; Console.WriteLine("{0,31} {1,10:X8}{2,10:X8}{3,10:X8}{4,10:X8}", "Argument", "Bits[3]", "Bits[2]", "Bits[1]", "Bits[0]" ); Console.WriteLine( "{0,31} {1,10:X8}{2,10:X8}{3,10:X8}{4,10:X8}", "--------", "-------", "-------", "-------", "-------" ); foreach(decimal val in vals) { int[] bits = decimal.GetBits(val); Console.WriteLine("{0,31} {1,10:X8}{2,10:X8}{3,10:X8}{4,10:X8}", val, bits[3], bits[2], bits[1], bits[0]); } } }
我對這段代碼進行編譯並運行的結果如下圖:
通過上一段文字,我相信各位讀者應該已經發現了decimal其實並不神秘。也因此更加堅定了采用decimal來進行小數計算時一定會得到正確答案的信心。但是正如我在上文中所說的,decimal雖然提高了計算的准確度,但是它的有效位數也是有限的。尤其是在表示小數時,如果位數超過了它的有效位數,那麼可能會得到“錯誤”的答案。
比如下面的這個小例子:
//沒有注意有效位數而產生的錯誤 using System; class Test { static void Main() { var input = 1.1111111111111111111111111111m; for (int i = 1; i < 10; i++) { decimal output = input * (decimal) i; Console.WriteLine(output); } } }
我們來編譯運行它:
可以發現7以內的結果都是正確的,而最後乘以8和乘以9的部分卻出現了錯誤。而產生這個結果的原因,其實我在上文中已經不止一次的提到過,那便是在29位有效數字情況下,最高位的值不能超過7才能獲得准確的值。而乘以8和乘以9顯然不符合這種要求。
因此,結合我的上一篇文章《妥協與取捨,解構C#中的小數運算》,我們可以總結一下計算機中用來減小小數誤差的策略無非以下兩個方面:
1.回避策略:即無視這些錯誤,根據程序目的的不同,有的時候一些誤差是可以接受的。這也是很好理解的,誤差在一個可以允許的范圍內也是普遍存在於日常生活的中的。
2.把小數轉換成整數來計算:既然計算機使用二進制進行小數計算時可能會有誤差,但是計算整數時一般是沒有問題的。因此,進行小數計算時可以暫時借助整數,只不過把最後的結果使用小數來表示便可以了。