引言
我們知道,Microsoft .NET Framework 中的 System.Decimal 結構(在 C# 語言中等價於 decimal 關鍵字)用來表示十進位數,範圍從 -(296 - 1) 到 296 - 1,並且可以有 28 位小數。這就是說:
- decimal.MinValue = -79,228,162,514,264,337,593,543,950,335 = -(296 - 1)
- decimal.MaxValue = 79,228,162,514,264,337,593,543,950,335 = 296 - 1
- decimal.Epsilon = 0.0000000000000000000000000001 = 10-28
上面前兩個是 decimal 的靜態唯讀欄位。遺憾的是,第三個不屬於 decimal 結構。
decimal 內部使用 4 個 32-bit 的 System.Int32 來儲存,佔用 128 bits = 16 bytes。這 128 bits 分配如下:
- 96 bits 表示從 0 至 296 - 1 的整數,分布在 3 個 32-bit 的 System.Int32 中。
- 剩下的 1 個 32-bit 的 System.Int32 包括符號位和比例因素。
- 第 31 bit 是符號位,0 表示正數,1 表示負數。
- 第 16 至 23 bit 表示比例因素,必須包含一個 0 至 28 之間的指數,指示 10 的冪,即小數點的位置,也就是小數點右邊有幾位元字。
- 其實表示 0 至 28 之間的指數只需 5 bits 就夠了,而上面的第 16 至 23 bit 共 8 bits = 1 byte。也就是說剩下的 3 bits (第 21 至 23 bit) 一定是零。
- 其餘 bits (0 - 15 bit 和 24 - 30 bit)不被使用,必須為零。
decimal.GetBits 方法就返回上述 decimal 的內部表示。而 decimal (int[] bits) 建構函式就使用這個內部表示構造來構造 decimal 執行個體。一個 decimal 可能會有幾種不同的內部表示,所有這些內部表示均同樣有效,並且在數值上相等。
TinyDecimal 資料類型
為了更好地理解 decimal 結構,我們來構造一個只有 8 bits = 1 byte 的 TinyDecimal 結構:
- number: 第 0 至 5 bit (共 6 bits)表示從 0 至 26 - 1 的整數,共有 64 個。
- exp: 第 6 bit 表示比例因素,包含一個 0 至 1 之間的指數,指示 10 的冪,即小數點的位置。0 表示小數點在最右邊。
- sign: 第 7 bit 是符號位,0 表示正數,1 表示負數。
因此:
- TinyDecimal.MinValue = -63 = -(26 - 1)
- TinyDecimal.MaxValue = 63 = 26 - 1
- TinyDecimal.Epsilon = 0.1 = 10-1
也就是說,TinyDecimal 的表示範圍從 -63 至 63,並且可以有 1 位小數。
TinyDecimal 的正數有以下兩種情形:
- 當 exp = 1 時: 0.1, 0.2, ... , 0.9, 1.0, 1.1, ... , 6.2, 6.3 。共 63 個。
- 當 exp = 0 時:1, 2, ... , 63 。共 63 個,但前 6 個(1 = 1.0, 2 = 2.0, ... , 6 = 6.0)和上面的重複了。
所以 TinyDecimal 的正數共有 63 + (63 - 6) = 120 個。負數的情況是一樣的,也有 120 個。所以 TinyDecimal 有 241 個不同的值,即正數和負數各 120 個,加上一個零。注意,零有四種不同的表示:+0, -0, +0.0, -0.0。TinyDecimal 的正數順序排列如下:
- 0.1, 0.2, ... , 6.2, 6.3, 7, 8, 9, 10, 11, ... , 62, 63
注意,在 TinyDecimal 中,6.3 的下一個數就是 7,7 的下一個數就是 8,根據就不存在 6.4 和 7.1 之類的數。並且有以下運算例子:
- 6.3 + 0.1 = 6.3
- 6.3 + 0.3 = 7
- 7 + 0.4 = 7
- 7 + 0.6 = 8
- 63 + 1 = overflow
我們知道,1 byte 可以表示 28 = 256 個不同的值。而 TinyDecimal 有 241 個不同的值,計算如下:241 = 256 - 6 * 2 - 3 ,即需要扣除 6 * 2 個重複的正負數和 3 個重複的零。
測試程式
System.Decimal 結構就是以上 TinyDecimal 結構的放大版本。為了更好地理解以上內容,我寫了一個如下所測試程式:
1 using System; 2 3 static class DecimalTester 4 { 5 static void Main() 6 { 7 var epsilon = 0.0000000000000000000000000001m; 8 var a = decimal.MaxValue / 100; 9 var b = 7.1234567890123456789012345685m;10 Console.WriteLine("{0}: 1e-28", epsilon);11 Console.WriteLine("{0}: 1e-28 + 0.1", 0.1m + epsilon);12 Console.WriteLine("{0}: a", a);13 Console.WriteLine("{0,-30}: a + 0.004", a + 0.004m);14 Console.WriteLine("{0,-30}: a + 0.005", a + 0.005m);15 Console.WriteLine("{0,-30}: a + 0.01", a + 0.01m);16 Console.WriteLine("{0,-30}: a + 0.099", a + 0.099m);17 Console.WriteLine("{0,-30}: a + 0.1", a + 0.1m);18 Console.WriteLine("{0,-30}: (a + 0.1) + 1e-28", a + 0.1m + epsilon);19 Console.WriteLine("{0,-30}: a + (0.1 + 1e-28)", a + (0.1m + epsilon));20 Console.WriteLine("{0}: b", b);21 Console.WriteLine("{0,-30}: b + 1", b + 1);22 }23 }
這個程式第 7 行的 epsilon 就是引言中提到的 decimal.Epsilon,其值為 10-28,等於 decimal 能夠表示最小正數。
在 Linux 中編譯和運行
在 Arch Linux 64-bit 作業系統的 Mono 3.0.4 環境下編譯和運行:
work$ dmcs --versionMono C# compiler version 3.0.4.0work$ dmcs DecimalTester.cswork$ mono DecimalTester.exe0.0000000000000000000000000001: 1e-280.1000000000000000000000000001: 1e-28 + 0.1792281625142643375935439503.35: a792281625142643375935439503.35: a + 0.004792281625142643375935439503.4 : a + 0.005792281625142643375935439503.4 : a + 0.01792281625142643375935439503.4 : a + 0.099792281625142643375935439503.5 : a + 0.1792281625142643375935439503.5 : (a + 0.1) + 1e-28792281625142643375935439503.5 : a + (0.1 + 1e-28)7.1234567890123456789012345685: b8.123456789012345678901234569 : b + 1
上述運行結果各行對應如下:
- epsilon = 10-28 = 0.0000000000000000000000000001,這是 decimal 能夠表示的最小正數。
- epsilon + 0.1 = 10-28 + 0.1 = 0.1000000000000000000000000001 。
- a = decimal.Value / 100 = 79...3.35 。這個數有 29 位有效數字。
- a + 0.004 = 79...3.354,舍入至 79...3.350,就等於 a 。
- a + 0.005 = 79...3.355,舍入至 79...3.360,但這個數無法在 decimal 中表示,只好舍入至 79...3.400,這個數只有 28 位有效數字。
- a + 0.01 = 79...3.36,如上所述,這個數無法在 decimal 中表示,只好舍入至 79...3.40 。
- a + 0.099 = 79...3.449,舍入至 79...3.400 。
- a + 0.1 = 79...3.45,舍入至 79...3.50 。
- (a + 0.1) + 10-28,結果和上一行相同。
- a + (0.1 + 10-28),結果和上一行相同。
- b = 7.1234567890123456789012345685 。這個數有 29 位有效數字。
- b + 1 = 8.1...85 。但這個數無法在 decimal 中表示,只好舍入至 8.1...90 。
從上面的分析可以看出,在 Linux 的 Mono 環境中 decimal 的算術運算的舍入規則是四捨五入。
在 Windows 中編譯和運行
在 Windows 7 SP1 32-bit 作業系統的 Microsoft .NET Framework 4.5 環境下編譯和運行:
D:\work> csc DecimalSumTester.csMicrosoft(R) Visual C# 編譯器版本 4.0.30319.17929用於 Microsoft(R) .NET Framework 4.5著作權 (C) Microsoft Corporation。著作權所有,並保留一切權利。D:\work> DecimalTester0.0000000000000000000000000001: 1e-280.1000000000000000000000000001: 1e-28 + 0.1792281625142643375935439503.35: a792281625142643375935439503.35: a + 0.004792281625142643375935439503.4 : a + 0.005792281625142643375935439503.4 : a + 0.01792281625142643375935439503.4 : a + 0.099792281625142643375935439503.4 : a + 0.1792281625142643375935439503.4 : (a + 0.1) + 1e-28792281625142643375935439503.5 : a + (0.1 + 1e-28)7.1234567890123456789012345685: b8.123456789012345678901234568 : b + 1
上述運行結果各行對應如下:
- epsilon = 10-28 = 0.0000000000000000000000000001,這是 decimal 能夠表示的最小正數。
- epsilon + 0.1 = 10-28 + 0.1 = 0.1000000000000000000000000001 。
- a = decimal.Value / 100 = 79...3.35 。這個數有 29 位有效數字。
- a + 0.004 = 79...3.354,舍入至 79...3.350,就等於 a 。
- a + 0.005 = 79...3.355,舍入至 79...3.360,但這個數無法在 decimal 中表示,只好舍入至 79...3.400,這個數只有 28 位有效數字。
- a + 0.01 = 79...3.36,如上所述,這個數無法在 decimal 中表示,只好舍入至 79...3.40 。
- a + 0.099 = 79...3.449,舍入至 79...3.400 。
- a + 0.1 = 79...3.45,舍入至 79...3.40 。
- (a + 0.1) + 10-28,結果和上一行相同,等於 79...3.40 。因為 10-28 太小了,加上去也改變不了什麼。
- a + (0.1 + 10-28) = 79...3.4500000000000000000000000001,舍入至 79...3.50...0 。和上一行對比,發現加法不滿足結合律。
- b = 7.1234567890123456789012345685 。這個數有 29 位有效數字。
- b + 1 = 8.1...85 。但這個數無法在 decimal 中表示,只好舍入至 8.1...80 。
從上面的分析可以看出,在 Windows 的 .NET Framework 環境中 decimal 的算術運算的舍入規則是四捨六入五取偶。所以造成第 8 、9 和 12 行和 Linux 中的輸出不同。
由於 decimal 的精度是有限的,只能表示有限個分散的值,在進行一些特殊的算術運算步驟時,會產生非常出乎意料的結果。且聽下回分解。
參考資料
- MSDN: Decimal 結構 (System)
- MSDN: Decimal.GetBits 方法 (System)
- MSDN: Decimal 建構函式 (Int32[]) (System)