為什么0.1 + 0.2
不等于 0.3
?為什么16777216f
等于 16777217f
?為什么金錢計算都推薦用decimal
?本文主要學習了解一下數字背后不為人知的存儲秘密。
C#中的數字類型主要包含兩類,整數、小數,C#中的小數都為浮點(小)數。
| void Main() |
| { |
| int a1 = 100; |
| int a2 = 0x0f; |
| var b2 = 0b11; |
| var x1 = 1; |
| var y1 = 1.1; |
| Add(1, 2.3); |
| Add(1, 3); |
| } |
| private T Add<T>(T x, T y) where T : INumber<T> |
| { |
| return x + y * x; |
| } |
數值類型大多提供的成員:
🔸靜態字段 | 說明 |
---|
MaxValue | 最大值常量,Console.WriteLine(int.MaxValue); //2147483647 |
MinValue | 最小值常量 |
🔸靜態方法 | 說明 |
Parse、TryParse | 轉換為數值類型,是比較常用的類型轉換函數,參數NumberStyles 可定義解析的數字格式 |
Max、Min | 比較值的大小,返回最大、小的值,int.Max(1,100) //100 |
Abs | 計算絕對值 |
IsInfinity | 是否有效值,無窮值 |
IsInteger | 是否整數 |
IsNaN | 是否為NaN |
IsPositive | 是否零或正實數 |
IsNegative | 是否表示負實數 |
數值類型還有很多接口,如加、減、乘、除的操作符接口,作為泛型約束條件使用還是挺不錯的。
🔸操作符接口 | 說明 |
---|
IAdditionOperators | 加法 |
ISubtractionOperators | 減法 |
IMultiplyOperators | 乘法 |
IDivisionOperators | 除法 |
| public static T Power<T>(T v1, T v2) where T : INumber<T>, |
| IMultiplyOperators<T, T, T>, IAdditionOperators<T, T, T> |
| { |
| return v1 * v1 + v2 * v2; |
| } |
C#中的小數類型有float、double、decimal 都是浮點數,浮點 就是“ 浮動小數點位置”,小數位數不固定,小數部分、整數部分是共享數據存儲空間的。相應的,自然也有定點小數,固定小數位數,在很多數據庫中有定點小數,C#中并沒有。
在編碼中我們常用的浮點小數是float、double,經常會遇到精度問題,以及類似下面這些面試題。
❓ 為什么0.1 + 0.2
不等于 0.3
?
❓ 為什么浮點數無法準確的表示 0.1
?
❓ 為什么16777216f
等于 16777217f
?這里f
表示為float
。
❓ 為什么32
位float
可以最大表示3.402823E38
,64
位double
可以最大表示1.79*E308
,那么點位數根本存不下啊?
❓ 同樣是32位,float
的數據范圍遠超int
,為什么?
| Console.WriteLine(0.1 + 0.2 == 0.3); |
| Console.WriteLine(16777216f == 16777217f); |
| Console.WriteLine(double.MaxValue); |
| Console.WriteLine(int.MaxValue); |
| Console.WriteLine(sizeof(double)); |
float、double為浮點數,小數位數有限,比較容易損失精度。造成上面這些問題的根本原因是其存儲機制決定的,他們都遵循IEEE754格式規范,幾乎所有編程語言和處理器都支持該規范,因此大多數編程語言都有類似的問題。Decimal 為高精度浮點數,存儲機制與float、double不同,她采用十進制方式表示。
❗ 要搞懂float、double,就不得不了解IEEE754規范!
IEEE 754 (維基百科)是一個關于浮點數算術的國際標準,它定義了浮點數的表示格式、舍入規則、特殊值、浮點運算等規范。IEEE 754 標準最早發布與1985年,其中包括了四種精度規范,其中最常用的就兩種:單精度(float,4字節32位)和雙精度(double,8字節64位)。大多數編程語言、硬件處理器都支持這兩種浮點數據類型,因此float、double的知識幾乎是所有語言通用的,可以深入了解一下,不虧的!
IEEE 754 浮點數不像十進制字面量值那樣存儲,而是用下面的二進制方式來表示并存儲的,其實就是二進制的科學計數法。其二進制表示包含三個部分:符號位S、指數部分(階碼E,2為底的指數)和尾數部分M。
🔸符號位(Sign):占用1位,這是浮點數的最高位,用于表示數字的正負。0表示正數,1表示負數。
🔸指數部分(Exponent,階碼):表示為2位底的指數,這里使用了移碼,實際的指數e = E-127
,這樣省去了指數的符號位,計算也更方便。
float 的指數部分8位,2^8=256
偏移量(移碼)為127,表示十進制范圍為 [-127,128],其數據范圍就為 ±2^128
= ±3.4E38
。指數全是1即指數值為255時,表示為無效數字 ±infinity或NaN。
double 的指數部分11位,2^11=2048
偏移量(移碼)為1023,十進制值范圍[-1023,1024],因此數據范圍 ±2^1024 = ±1.79E308
。
🔸尾數部分(Mantissa):這部分表示數字的精確值(有效數字),包括整數和小數部分。尾數長度決定了精度,因為有效數字長度是有限的,因此就必然存在精度丟失的問題。
IEEE754浮點數都會被轉換為上述二進制形式:**符號*尾數*2^指數**
,如 2 = 1.0 * 2^1
,0.5 = 1.0 * 2^-1
,5 = 1.25* 2^2
。數據(整數、小數部分)先轉換為二進制形式,然后左移或右移小數點,轉換為1.M
形式,始終都是 “1”開頭,因此就只存儲小數部分即可。
🚩浮點數 =
十進制 2 就表示為 2 = 1.0* 2^1
。下圖來自 在線IEEE754轉換器計算:IEEE-754 Floating Point Converter。
十進制 0.75 表示為0.75 = 1.5* 2^-1
,指數為-1
,尾數為1.5
。
類型 | 單精度 float | 雙精度 double |
---|
CTS類型 | System.Single | System.Double |
長度 | 4字節32位 | 8字節64位 |
符號位S | 1 | 1 |
階碼(指數位T) | 8,[-127,128] | 11,[-1023,1024] |
尾數M | 23 | 52 |
階碼偏移量 | 127,e= E -127 | 1023,e= E -1023 |
精度(10進制) | **6~7 **,2^23=8388608 | 15~16,2^52 = 4503599627370496 |
范圍 | ±3.402823E38 ,2^128=3.4E38 | ±1.79*E308,2^1024=1.79E308 |
字面量表示(后綴) | f /F | d /D |
float只能用于 表示6~7個有效數字時,才不會損失精度。
| |
| Console.WriteLine(4234567f); |
| |
| Console.WriteLine(42345678f); |
| Console.WriteLine(42345671f); |
|
|
| |
| Console.WriteLine(0.2345678f); |
| |
| Console.WriteLine(2.12345678f); |
| Console.WriteLine(0.212345678f); |
對于整數轉換小數是非常容易理解的,計算機的二進制是天然支持整數存儲為二進制的。十進制整數轉成二進制通常采用 ”除 2 取余,逆序排列” 即可。
| Console.WriteLine($"{1:B4}"); |
| Console.WriteLine($"{2:B4}"); |
| Console.WriteLine($"{3:B4}"); |
| Console.WriteLine($"{4:B4}"); |
| Console.WriteLine($"{5:B4}"); |
| Console.WriteLine($"{8:B4}"); |
📢“B”格式只支持整數,更多格式化參考《String字符串全面了解>字符串格式化大全》
但小數則不同,采用的是 “乘2取整法”,小數部分循環迭代,直到小數部分=0
為止。:如下0.875
的十進制浮點數轉換為二進制格式為:0.111
。
0.111
,存儲為IEE754浮點數,轉換為1.M*2^E
結構,小數點右移一位,就是1.11*2^-1
。
十進制小數6.36
轉換為二進制,整數部分+小數部分分別轉換后合體:
二進制無法準確表示小數0.1
,是因為0.1
轉換為二進制后是無限循環的,0.0 0011 0011 0011...
,“0011”無限循環。就像十進制小數1/3 = 0.333
一樣。
轉換為1.M*2^E
結構,小數點右移4位,尾數就是1.1001 1001
,指數 E = -4 +127 = 123
。
計算機存儲整數很簡單,每個數字是確定的。但小數則不同,0到1之間的小數都無限種可能,計算機有限的空間無法存儲無限的小數。因此計算機將小數也當成“離散”的值,就像整數那樣,整數之間間隔始終為1。給小數一個間隔刻度,如下圖,用鐘表來舉例,小數刻度(步進)為0.234(十進制)。
這樣做的好處可以兼顧“所有”小數,小數的精度就取決于鐘表的“刻度”,刻度越小,精度越高,當然存儲時所需要的空間也就越大。
因此,這個精度本質上是由表盤間隔刻度(Gap)決定的,即使0.0012
的間隔刻度,精度達到了4位十進制數,也只能保障前2~3位小數是可靠的。0.001X、0.002X、0.003X,他始終無法表示0.0013、0.0025。
可通過提高刻度(Gap)來提高精度,但存儲長度是有限的,因此不管是那種浮點數都是有精度限制的。精度越高的數據類型,也需要更多的長度來存儲數據。
32位float
用了23位來存儲有效數字,十進制也就6~7位(2^23=8388608
)。在IEEE754規范中,小數的“刻度”并不是均勻分布的,而是越來越大,數值越大則精度越低。如下面的表盤和刻度尺的示意圖,其精度(Gap)的分布是不均勻的,0
附近數字的精度最高,然后精度就越來越低了,低到超過1。
看看 float 的間隔刻度(Gap)如下圖,來自官方IEEE_754文檔:
| |
| Console.WriteLine(8388608.1f == 8388608.4f); |
| |
| Console.WriteLine(16777216f == 16777217f); |
| Console.WriteLine(16777218f == 16777219f); |
| Console.WriteLine(16777219f == 16777220f); |
下圖是double的刻度表:小于8的數字都能有16位精度。
😂 怎么感覺float很雞肋呢?限制太多了!所以編程中浮點數多大都用的 double 居多,float比較少。
System.Decimal 是16字節(128位)的高精度十進制浮點數,不同于float、double 的二進制存儲機制,Decimal 采用10進制存儲,表示-7.9E28 到 +7.9E28之間的十進制數。Decimal 最大限度地減少了因舍入而導致的錯誤,比較適用于對精度要求高場景,如財務計算。
📢 Decimal并不屬于IEEE754規范,也不是處理器支持的類型,計算性能要差一點點(約 double 的 10%)。
| Console.WriteLine(1f / 3f * 3f); |
| Console.WriteLine(0.1 + 0.2 == 0.3); |
| |
| Console.WriteLine(1m / 3m * 3m); |
| Console.WriteLine(0.1m + 0.2m == 0.3m); |
Decimal可以準確的表示0.1
,Decimal 128位的存儲結構如下圖(圖來源):
96位存儲一個大整數,就是有效數字,Math.Pow(2,96) = 7.9E28
,最多28位有效數字,因此小數最多也就是28位(全是小數時)。
剩下的32位中,有一個符號位,0 表示正數,1 表示負數。其中有5
位(下圖中的第111位)表示10的指數部分(0到28的整數),可以理解為小數點的位置,其他位數沒有使用默認為0(有點浪費呢?)。
Decimal 表示小數其實是“障眼法”,內部有三個int (High、Mid、Low)來表示96位有效數字,還有一個int表示指數。可以通過 decimal.GetBits()
方法獲取他們的值。下圖來自 Decimal 源碼 Decimal.cs
在Decimal中就沒有 0.1+0.2
不等于0.3
的問題,因為她能準確表示0.1
。
其根本原因就是 Decimal 不會把小數轉換為二進制,而是就用十進制。把小數都轉為整數存儲,如 0.1
在Decimal 中會被表示為 1* 10^-1
,尾數為1,指數為-1
,指數就是小數點位置。
📢 Decimal值 =
| var arr = decimal.GetBits(0.1M); |
| Console.WriteLine($"尾數:{arr[2]}{arr[1]}{arr[0]}"); |
| Console.WriteLine($"指數:"+$"{arr[3]:B32}".Substring(0,16)); |
| |
| |
100.1024
存儲為1001024* 10^-4
。
| var arr = decimal.GetBits(100.1024M); |
| Console.WriteLine($"尾數:{arr[2]}{arr[1]}{arr[0]}"); |
| Console.WriteLine($"指數:"+$"{arr[3]:B32}".Substring(0,16)); |
| |
| |
如果是負數-100.1024
,則只有符號位為1
,其他一樣
| var arr = decimal.GetBits(-100.1024M); |
| Console.WriteLine($"尾數:{arr[2]}{arr[1]}{arr[0]}"); |
| Console.WriteLine($"指數:"+$"{arr[3]:B32}".Substring(0,16)); |
| |
| |
📢 所以 Decimal 值只要沒有超過28~29位有效數字,就沒有精度損失!是不是Very Nice!flaot、double 損失精度的根本原因是其存儲機制,必須把小數轉換為二進制值,再加上有限的精度位數。
類型 | 單精度 float | 雙精度 double | Decimal 高精度浮點數 |
---|
類型 | System.Single | System.Double | System.Decimal |
規范 | IEEE754 | IEEE754 | 無,.Net自定義類型 |
是否基元類型 | 是 | 是 | 是 |
長度 | 32位(4字節) | 64位(8字節) | 128位(16字節) |
內部表示 | 二進制,基數為2 | 二進制,基數為2 | 十進制,基數為10 |
字面量(后綴) | f /F | 后綴d /D | 后綴m /M |
最大精度 | 6~7 | 15~16 | 28~29位 |
范圍 | ±3.4E38 ,2^23=3.4E38 | 范圍很大,±1.7*E308 | -2^(96) 到 2^(96),±7.9E28 |
特殊值 | +0、-0、+∞、-∞、NaN | +0、-0、+∞、-∞、NaN | 無 |
速度 | 處理器原生支持,速度很快 | 處理器原生支持,速度很快 | 非原生支持,約double 的10% |
Decimal 雖然精度高,但長度也大,計算速度較慢,所以還是根據實際場景選擇。財務計算一般都用 Decimal 是因為他對精度要求較高,錢不能算錯,傳說算錯了要從程序員工資里扣😂😂。
對于精度要求高的場景不適合用浮點數(double、float),推薦decimal
,特別是價格、財務計算。
浮點數不適合直接相等比較,直接相等大多會出Bug。
在存儲比較大的數字時,需注意float、double 對于整數也有精度問題。
| var f1 = 0.1 + 0.2; |
| var f2 = 0.3; |
|
|
| Console.WriteLine(f1 == f2); |
| |
| Console.WriteLine(Math.Round(f1,6) == Math.Round(f2,6)); |
| |
| Console.WriteLine(Math.Abs(f1-f2)<1e-8); |
取整方式 | 說明/示例 |
---|
整數相除 10/4=2 | 拋棄余數,只留整數部分 |
強制轉換(int)2.9=2 | 直接截斷,只留整數部分,需要注意‼️ |
Convert轉換,四舍五入取整 | Convert.ToInt32(2.7) = 3; Convert.ToInt32(2.2) = 2; |
格式化截斷,四射五入 | 字符串格式化時的截斷,都是四舍五入, $"{2.7:F0}" = "3" |
Math.Ceiling() ,向上取整 | Math.Ceiling(2.3) = 3 ,⁉️注意負數Math.Ceiling(-2.3) = -2 |
Math.Floor() ,向下取整 | Math.Floor(2.3) = 2 ,⁉️注意負數Math.Floor(-2.3) = -3 |
Math.Truncate() ,截斷取整 | Math.Truncate(2.7) = 2 ,只保留整數部分,同強制轉換 |
Math.Round() ,四舍五入 | 可指定四舍五入精度,Math.Round(2.77,1) = 2.8 |
轉自https://www.cnblogs.com/anding/p/18221160 作者安木夕
該文章在 2024/6/26 11:37:48 編輯過