妥協與取捨,解構C#中的小數運算

0x00 前言

慕容在生活和工作中常常會遇到一些十分迷信機器的人,他們之中很多人都相信機器是最理智的,沒有任何感情,是真正的鐵面無私,因此機器的運算所給出n的答案總是正確的,如果答案錯誤,那麼一定是操作機器的人的問題。但機器的運算就一定是正確的嗎?事實上,機器出現運算錯誤並不是一個罕見的情況,一個典n型的例子便是小數運算。下面就讓我們來聊一個相關的話題,在機器或者具體的說在C#語言中小數是如何被處理的?

0x01 先從一個「錯誤」的答案說起

既然要聊一聊機器是怎麼把算術題做錯的,那麼我們自然要先來看一個機器運算錯誤的小例子。

#include <stdio.h>nnvoid main(){n float num;n int i ;n num = 0;n for(i = 0; i < 100; i++)n {n num += 0.1;n }n printf("%fn", num);n}n

這是一份C語言寫成的小程序,邏輯十分簡單易懂,所要實現的結果無非是將0.1相加100次之後再輸出。我想不需要計算機來計算,我們自己心算就能立刻得出答案——10。那麼計算機會交給我們一份什麼樣的答案呢?下面我們將這份C代碼編譯並且運行一下。

答案一輸出,就讓人大吃一驚。怎麼計算機還不如人的心算嗎?如果按照慕容在前言中提到的那些朋友這時可能就開始糾結是否是代碼寫錯了,亦或者是慕容n的電腦出現了什麼問題。但事實上代碼是正確的,機器也是運行如常的。那麼究竟是為什麼計算機的運算輸給了人的心算呢?這就引出了下一個問題,計算機是如何n處理小數的呢?如果我們了解一下計算機處理小數的機制,那麼這一切的迷就能夠解開了。(當然如果有朋友用C#來對0.1加100次,之後的結果是10。但n那是C#在幕後為我們做的一些隱藏工作,在計算機處理小數的問題上,本質是一樣的)。

0x02 數字的格式

一個程序可以看做是現實世界的一個數字化的模型。現實世界中的一切都可以轉化為數字在計算機的世界中重新復活。因此,一個不得不解決的問題就是數字是如何在計算機中表達的。這也是數字格式出現的意義。

眾所周知,機器語言全部都是數字,但是本文自然不會關心全部的二進位格式。這裡我們只關心現實中有意義的數字是如何在計算機中被表示的。簡單而言,有意義的數字大體可以分為以下三種格式。

整數格式

我們在開發的過程中遇到的大部分的數字其實都是整數。而整數在計算機中也是最容易表示的一種。我們遇到的整數都可以使用32位有符號整數來表示(Int32)。當然,如果需要,還有有符號 64 位整數數據類型(Int64)可供選擇。至於和整數相對應的便是小數,而小數主要有兩種表示方式。

定點格式

所謂定點格式,即約定機器中所有數據的小數點位置是固定不變的。而定點小數的最常見的例子是SQL nServer中的money類型。事實上定點小數已經很不錯了,它顯然能夠適合很多需要處理小數的情況。但是它有一個與生俱來的缺點,那就是由於小數點的n位置固定,因此它能表示範圍是受限的。因此下面我們本文的主角就要出場了。

浮點格式

解決定點格式先天問題的方案便是浮點格式的出現。而浮點格式的組成則包括符號、尾數、基數和指數,通過這四部分來表示一個小數。由於計算機內部是二n進位的,因此基數自然而然是2(就如十進位的基數是10一樣)。因此計算機在數據中往往無需記錄基數(因為總是2),而是只用符號、尾數、指數這三部分來n表示。很多編程語言都至少提供了兩種使用浮點格式表示小數的數據類型,即我們常常能見到的雙精度浮點數double和單精度浮點數float。同樣,在我n們的C#語言中也存在著這兩種使用了浮點格式表示小數的數據類型——按照C#語言標準雙精度浮點數和單精度浮點數在C#中對應的是nSystem.Double和System.Single。但是事實上在C#語言中還存在著第三種使用了浮點格式表示小數的數據類型,那就是ndecimal類型——System.Decimal。需要注意的是,浮點格式的表示形式有很多,而在C#中遵循的是IEEE 754標準:

  • float單精度浮點數為32位。32位的構造為:符號部分1bit、指數部分8bit以及尾數部分23bit。
  • double雙精度浮點數為64位。64位的構造為:符號部分1bit、指數部分11bit以及尾數部分52bit。

0x03 表示範圍、精度和準確度

既然聊完了數字在計算機中的幾種表示形式,那麼接下來我們就不得不提一下在選擇數字格式時的一些指標。最常見的無非是這幾點:表示範圍、精度、準確度。

數字格式的表示範圍

顧名思義,數字格式的表示範圍指的就是這種數字格式所能表示的最小的值最大的值範圍。n例如一個16位有符號整數的表示範圍是從-32768到32767。如果要被表示的數字的值超出了這個範圍,那麼使用這種數字格式就不能正確的表示這個數n字了。當然在這個範圍內的數字也有可能無法被正確的表示,例如16位有符號整數是無法準確表示一個小數的,但是總有一個接近的值是可以用16為有符號整數n格式來表示的。

數字格式的精度

實話實說,精度和準確度讓很多人都有一種十分模糊的感覺,似乎是一樣的卻又有區別。但慕容需要提醒各位注意的是,精度和準確度是兩個有巨大差距的概念。

通俗的來講,數字格式的精度可以認為是該格式有多少信息用來表示一個數字。更高的精度通常意味著能夠表示更多的數字,一個最明顯的例子便是精度越高n那麼這種格式所能表示的數字就越接近真實的數字。例如我們知道1/3如果換算成小數0.3333....是無窮盡的,那麼它在五位精度的情況下可以寫成n0.3333,而在七位的情況下就又變成了0.333333(當然,如果用七位表示五位,那麼就是0.333300)。

數字格式的精度還會影響到計算的過程。舉一個簡單的例子,如果在計算中我們使用的是一位精度。那麼整個計算可能就變成了下面的這種情況:

0.5 * 0.5 + 0.5 * 0.5 = 0.25 + 0.25

= 0.2 + 0.2

=0.4

而如果我們使用的是兩位精度,那麼計算過程又會變成下面的情況。

0.5 * 0.5 + 0.5*0.5 = 0.25 + 0.25

           =0.5

對比兩種精度情況下的計算結果,一位精度情況下的計算結果和正確的結果差了0.1。而使用了兩位精度的情況則正常的計算出了結果。因此可以發現在計算的過程中保證精度是一件多麼有意義的事情。

數字格式的準確度

數字格式的表示範圍、精度都已經介紹完了,那麼接下來我們就來介紹一下數字格式的準確度。剛剛已經說過了,準確度和精度是一對經常讓人混淆的概念。

那麼我們再通俗的給準確度來個注釋,簡單的說它表示的是該數字格式(特定環境)所表示的數字和真實數字的誤差。準確度越高,則意味著數字格式所表示的數字和真實數字的值之間的誤差越小。準確度越低,則意味著數字格式所表示的數字和真實數字的值之間的誤差越大。

需要注意的一點是數字格式的精度和數字格式的準確度並沒有直接的關係,這一點也是很多朋友在概念上常常會混淆的地方。使用低精度的數字格式表示的數字,並不一定要比使用高精度的數字格式所表示的數字的準確度低。

舉一個簡單的例子:

Byte num = 0x05;nInt16 num1 = 0x0005;nInt32 num2 = 0x00000005;nSingle num3 = 5.000000f;nDouble num4 = 5.000000000000000;n

此時,我們分別使用5種不同的數字格式表示同一個數字5,雖然數字格式的精度(從8位到64位)不同,但是通過數字格式所表示出來的數和真實的數是一樣的。也就是說對於數字5,這5種數字格式的準確度相同。

0x04 取整誤差

了解了計算機中常見的幾種數字格式之後,現在我們再來聊一聊計算機是如何通過數字格式來表示現實世界中的數字的。眾所周知,計算機中使用的是0和n1,即二進位,使用二進位表示整數是十分容易的一件事情,不過在使用二進位表示小數時,我們往往會產生一些疑問。例如二進位小數1110.1101換算成n十進位是多少呢?第一眼看上去多了一個小數點,似乎讓人十分困惑。事實上它的處理和整數是一樣的,即將各個數位的數值和位權相乘結果求和。小數點前的位n權,大家都已經十分熟悉了,從右向左分別是0次冪、1次冪、2次冪以此遞增,因此小數點前的二進位換算為十進位便是:

1 * 8 + 1 * 4 + 1 * 2 + 0 = 14

而在小數點之後的位權,相應的從左向右分別是-1次冪、-2次冪依次遞減。因此小數點之後的二進位轉換為十進位便是:

1 * 0.5 + 1 * 0.25 + 0 * 0.125 + 1 * 0.0625 = 0.8125

因此1110.1101這個二進位小數轉換為十進位便是14.8125。

通過觀察小數點之後的二進位轉換為十進位的過程,各位看官是否發現了很有趣的一個事實呢?那就是小數點之後的二進位並不能表示所有的十進位數,換言n之有一些十進位數是無法轉換成二進位的。這個很好理解,因為小數點之後,二進位的位權按照除以2的節奏遞減,而十進位卻是按照除以10的節奏遞減。因此如n果小數點後4位用二進位表示,即從.0000~.1111這個範圍內連續的二進位數值事實上對應的十進位數是不連續的,所有可能的結果也不過是各個位權n(0.5、0.25、0.125以及0.0625)相加的組合而已。

因此,一個在十進位中十分簡單的數字如果用二進位來準確無誤的表示,所使用的位數可能會十分長甚至是無限的。一個很好的例子便是使用二進位浮點數來表示十進位中的0.1:

double x = 0.1d;n

事實上變數x中所保存的值並不是真正的十進位中的0.1,而是一個最接近十進位0.1的二進位浮點數。這是因為無論小數點之後有多少位二進位的數,2的負數次冪都無法相加得到0.1這個結果,因此0.1這個十進位數在二進位中會變成一個無限小數。

當然二進位有可能無法準確的表示一個十進位小數很好理解,因為這有點類似於在十進位中我們同樣無法準確表示1/3這樣的循環小數。

此時,我們便不得不和計算機妥協了。因為我們現在知道了在計算機中使用的數值可能並不等於真實世界中的數值,而是計算機使用某種數字格式表示的一個n十分接近原始數字的一個值。而在整個程序運行的過程中,我們的計算機就要一直使用這個僅僅是近似的數值來參與計算,我們假設真實的數值是n,而計算機事實n上會使用另一個數值n + e(當然e是一個可正可負且十分小的數)來參與計算機中的運算。此時,這個數值e便是取整誤差。

而這還僅僅是一個數字在計算機中使用近似值來表示,如果該數值參與到計算中去,那麼顯然會帶來更多誤差。這也是本文一開始那個c程序之所以計算錯誤n的原因,因為無法正確的表示參與計算的值,到最後都變成了近似值。當然C#語言相對而言要「高級」了很多,雖然在計算機中也是近似值,但是展示在大家眼前n的至少還是更加符合人們「預期」的值。不過在C#中,小數計算真的是不會出錯的嗎?畢竟,這一切似乎僅僅是障眼法。

0x05 取與舍,C#的小數

比比是否相等

不知道各位看官在使用一些關係運算符時,有沒有留意到直接使用等號比較兩個小數是否相等時是否會出現一些意想不到的問題。我身邊的朋友使用關係運算n符直接比較兩個小數大小的情況比較多,而直接比較兩個小數是否相等的情況卻不太多。同時我在此也想提醒各位最好不要輕易比較兩個小數是否相等,即便在C#n這種高級語言中仍然可能得到讓人感覺「錯誤」的答案,這是因為我們事實上比較的是兩個小數是否「接近」於相等,而不是兩個數是否是真正的相等。下面這個例n子可能會更好的說明這一點:

using System;nnclass Testn{ n static void Main()n {n double f = Sum (0.1d, 0.2d);n double g = 0.3d;n Console.WriteLine (f);n Console.WriteLine (f==g);n }n n static double Sum (double f1, double f2)n {n return f1+f2;n }n}n

我們編譯並且運行這段代碼,可以看到輸出了如下的內容:

比較這兩個小數的結果並不是true,這和我們的預期並不一樣。

浮點數的真模樣

我們知道,像上文中的那個二進位小數1110.1101事實上也是按照人類習慣表達出來的,但是計算機可是不能識別這種帶小數點的東西的哦。所以計n算機會使用之前介紹的數字格式來表示這樣一個數字,那麼一個二進位浮點數在計算機中到底是如何表現的呢?其實在上文介紹數字格式的部分已經介紹過了,但是n沒有實際看一眼終究是不能有一個直觀的認識,那在本文的最後,我們就來看一個二進位浮點數的在計算機中真實的樣子。

0100000001000111101101101101001001001000010101110011000100100011

這是一個64位的二進位數。如果把它作為一個雙精度浮點數,那麼它的各部分都分別表示了什麼呢?

按照上文介紹浮點數的部分,我們可以將它分成如下幾部分:

符號:0

指數部分:10000000100(二進位,可以轉換為十進位的1028)

尾數部分:0111101101101101001001001000010101110011000100100011

因此,將它轉換為一個用二進位表示的小數,則是:

(-1)^0 * 1.0111101101101101001001001000010101110011000100100011 x 2^(1028-1023)

= 1.0111101101101101001001001000010101110011000100100011 x 2^5

= 101111.01101101101001001001000010101110011000100100011

如果各位讀者觀察足夠仔細的話,是否發現了有趣的一點呢?那就是在這個在計算機中用來表示雙精度浮點數的64位數中,尾數部分的幾位數字是:0111101101101101001001001000010101110011000100100011

但是經過從計算機中的形式轉化成人類使用二進位表示小數的形式之後,數字卻變成了1.0111101101101101001001001000010101110011000100100011x 2^5,小數點之前為什麼會多出了一個1呢?

這是因為在尾數部分,為了將表現形式多樣的浮點數統一為同一種表示方式而規定要將小數點前的值固定為1。由於小數點前的數永遠是1,因此為了節省一個數據位,這個1在計算機中並不需要被保存。

那麼應該如何保證一個二進位小數的小數點前的值是1呢?這就需要對二進位小數進行邏輯移位了,通過左移或右移若干次後,將整數部分變為1。例如上文中的這個二進位小數:1110.1101,我們就來試試如何把它變成計算機可以識別的浮點數的尾數吧。

1110.1101(原始數據)——>0001.1101101(通過右移將整數部分變為1)——n>0001.11011010000000000000....(拓展位數,使之符合數字格式的規定)——n>11011010000000000000....(去掉整數部分,僅保留小數部分)

好了,到此關於C#或者計算機中的小數計算就寫得差不多了。歡迎各位交流。

-華麗的分割線-

歡迎大家關注我的公眾號慕容的遊戲編程:chenjd01

最後打個廣告,歡迎支持我的書《Unity 3D腳本編程》~

推薦閱讀:

怎樣考察有八年經驗程序猿的水平(C#)?
如何評價 Unity 2018?
如何解決.NET程序容易被反編譯的問題?
剖析並利用Visual Studio Code在Mac上編譯、調試c#程序
跟Unity學代碼優化

TAG:C# | Unity游戏引擎 | 编程语言 |