沒有神話,剖析decimal的「障眼法」

0x00 前言

在上一篇文章《妥協與取捨,解構C#中的小數運算》的留言區域有不少朋友都不約而同的說道了C#中的decimal類型。事實上以前的那篇文章的立意主要在於聊聊使用二進制的計算機是如何處理小數的,無非我接觸最多的是在託管環境下運行的高級語言C#,所以順帶使用了C#做爲例子。一方面說明了計算機處理小數的本質,也起到了提醒各位更加關注本質而非高級語言表象的做用。固然,那篇文章中主要提到的是二進制浮點數double和float(即System.Double和System.Single,下文中使用double和float來分別指代這兩個類型)。不過既然說到障眼法,我以爲仍是有必要寫一篇文章專門來聊聊decimal類型,也算是對留言提到decimal的朋友的統一回復。html

0x01 先從0.1和二進制浮點數提及

私底下有一些朋友告訴我說在上一篇文章中若是隻是單純的說十進制中的0.1沒法使用二進制準確的表示,雖然理論上的確是這樣,但畢竟沒有經過直接觀察得到一個直觀的印象,因此在正式引出decimal以前,咱們先來看一看一個十進制的小數0.1爲什麼不能被二進制浮點數準確的表示出來吧。segmentfault

如同在十進制中,1/3是沒法被準確表示的,若是咱們要將1/3轉換成十進制小數的形式則是:數組

1/3 = 0.3333333....(3循環)spa

同理,十進制小數0.1也是沒法被二進制小數準確表示,若是咱們要將十進制的0.1轉換爲二進制小數則是:code

0.1 = 0.00011001100....(1100循環)htm

咱們能夠看到,若是要將十進制的0.1轉換爲二進制小數,則會出現1100循環的情況。所以根據我在上一篇文章中提到過的IEEE 754標準以及在上一篇文章中最後所舉的一個例子,咱們首先將0.00011001100....進行邏輯移位,使之小數點左邊第一位是1。那麼結果是1.10011001100...,共移動了4位,所以指數相應的應該是-4。因此,表示十進制0.1的float二進制浮點數的結果以下:blog

符號位:0(表示正數)圖片

指數部分:01111011(01111011換算成十進制是123,由於要減去-127故結果爲-4)ci

尾數部分:10011001100110011001101(即經過移位以後,舍掉小數點左側的1,留下的小數部分,保留23位)get

那麼這個用來「表示」十進制小數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。

這即是直接使用二進制來表示小數的方式,頗有可能會產生偏差。

0x02 decimal的障眼法

可是不少朋友都提到了使用decimal來避免上文中出現的偏差。的確,使用decimal是一個十分保險的措施。可是,爲何使用decimal類型,計算機忽然就可以很完美的計算十進制數了呢?難道是計算機在涉及到decimal類型的運算時,改變了本身內部最根本的二進制運算嗎?

固然不是。

我在上一篇文章中提到過

「衆所周知,計算機中使用的是0和1,即二進制,使用二進制表示整數是十分容易的一件事情」。

那麼是否有可能間接藉助整數來表示小數呢?由於二進制表示十進制整數是十分完美的。

答案的確如此。可是在咱們討論decimal的細節以前,我以爲有必要先簡單介紹一下decimal。

在這裏的decimal指的C#語言中的System.Decimal,雖然在C#語言規範中只提到了兩種浮點數float和double(二進制浮點數),可是若是咱們瞭解浮點數的定義,decimal顯然也是浮點數——只不過它的底數是10,所以它是十進制浮點數。

decimal的結構

一樣,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即可以更準確的來表示一個十進制小數了。這裏咱們就能夠看到,decimal的尾數部分事實上是一個整數,而尾數所表示的範圍也很明確了:0~2^96 - 1。換算爲十進制即是0~79228162514264337593543950335,一個29位的數字(固然,最高位的值最多到7)。

此時若是咱們對尾數部分進一步劃分結構的話,能夠將尾數當作是由三個部分的整數組成的:

1~4號字節(32位)表明了一個整數,表示的尾數的低位部分。

5~8號字節(32位)表明了一個整數,表示的尾數的中間部分。

9~12號字節(32位)表明了一個整數,表示尾數的高位部分。

這樣,咱們就將表示一個整數的decimal尾數又劃分紅了三個整數。

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內部的4個整數

咱們再回去看一眼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]);
        }
    }
}

我對這段代碼進行編譯並運行的結果以下圖:
此處輸入圖片的描述

0x03 如何才能避免「出錯」

經過上一段文字,我相信各位讀者應該已經發現了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. 把小數轉換成整數來計算:既然計算機使用二進制進行小數計算時可能會有偏差,可是計算整數時通常是沒有問題的。所以,進行小數計算時能夠暫時藉助整數,只不過把最後的結果使用小數來表示即可以了。

相關文章
相關標籤/搜索