關於 Decimal 小數尾數零

C#的實數型別有三種:float、double、decimal。其中 float、double 為浮點數,本站的老讀者們一定知道-「算錢用浮點,遲早被人扁」的道理,因此只要涉及金額計算,我一律改用 decimal 型別。前幾天,踩到 decimal 小數尾數零地雷一枚。

以下程式為例,大家猜猜結果為何?

    class Program
    {
 
        static void Main(string[] args)
        {
            float flt = 1.2300F;
            Console.WriteLine(flt);
            double dbl = 1.2300D;
            Console.WriteLine(dbl);
            decimal dcm = 1.2300M;
            decimal cmp = 1.23M;
            Console.WriteLine(dcm == cmp);
            Console.WriteLine($"{dcm} vs {cmp}");
            Console.Read();
        }
    }

註:實數常值(Real Literal)後方需加上尾碼以明確宣告型別,F f 代表 float、D d 或不加尾碼代表 double、M m 則代表 decimal。

由結果可知,浮點數型別(float、double)不會保留小數尾數零,decimal 則可做到 1.23 與 1.2300 有別:進行 = 比對二者相等,但 ToString() 時小數尾數零可忠實還原。

事情發生在一段分別由 SQL 及 Oracle 取出對應資料比對的程式,目的在驗證兩邊資料庫是否一致。為簡化比對邏輯,我將所有欄位內容 ToString() 轉為字串,心想 SQL 與 Oracle 的 Schema 一致,欄位都是 DECIMAL(5,4) 且儲存數值相同,豈有寫入 decimal 後 ToString() 結果不同的道理?結果,真的被我遇上了。

用以下範例重現問題,在 SQL 與 Oracle 端分別將 1.23 存入 DECIMAL(5,4),再用 Dapper 讀取到 decimal:

        static void Main(string[] args)
        {
            using (var cn = new SqlConnection(csSql))
            {
                decimal d = cn.Query<decimal>(
                    @"
declare @d decimal(5,4);
set @d=1.23;
select @d;").Single();
                Console.WriteLine(d);
            }
            using (var cn = new OracleConnection(csOra)) 
            {
                cn.Open();
                cn.Execute(
@"create global temporary table decimal_test (d decimal(5,4)) on commit preserve rows");
                cn.Execute("insert into decimal_test values (1.23)");
 
                decimal d = cn.Query<decimal>("select d from decimal_test").First();
                Console.WriteLine(d);
                cn.Execute("truncate table decimal_test");
                cn.Execute("drop table decimal_test");
 
            }
            Console.Read();
        }

結果分別為 1.2300 與 1.23。SQL Client 讀取 DECIMAL(5,4) 轉入 decimal 時後方會補足 0 到精確位數,而 ODP.NET 不會,二者的行為差異造成讀取 decimal 的小數尾數零數目不同,不影響大於小於等於比對,遇到 ToString() 轉字串,便會得到不同結果。

至於要怎麼去除 decimal ToString() 夾帶的尾數零,有幾種做法

  1. 如果確定要保留小數位數上限,可以寫成 ToString("#.####")。缺點是若 # 個數小於實際小數位數會被四捨五入影響精確度。若求保險,小數點後寫上 28 個 # 肯定安全。(decimal 精確度上限為 29 位)
  2. 用 ToString("G29") 轉為科學記號,但要求 29 位精準位數,成為位數足又不會出現 E 的幾次方的偽科學計號。缺點是 0.00001 這類微小數會被轉成 1E-05。
  3. 在 Stackoverflow 看到奇妙解法,decimal 除上 1.00000…(29個0):
    public static decimal Normalize(this decimal value)
    {
        return value/1.000000000000000000000000000000000m;
    }
       
    寫成擴充方法,1.2300m.Normalize() 尾數零就會清光光。

簡要心得

  • decimal 會保存小數尾數零,float、double 等浮點型別不會
  • 小數尾數零不影響大於等於小於比對,但會影響 ToString() 結果
  • SQL Client 與 ODP.NET 讀取 DECIMAL(m, n) 欄位寫入 decimal 時對於小數尾數零的處理原則不同
  • 去除 decimal 小數尾數零有幾種做法:ToString("#.####")、ToString("G29") 以及奇妙的 Normalize() 方法
  • 政令宣導時間:「算錢用浮點,遲早被人扁」。早晚複誦,永誌不忘~
歡迎推文分享:
Published 11 December 2016 07:24 PM 由 Jeffrey
Filed under:
Views: 6,890



意見

# ace said on 12 December, 2016 12:45 AM

不好意思問個跟算錢沒關的問題

若是只要比對兩邊資料是否相同,且預期的資料為數字的話,為何不直接轉成數字相比即可確認 1.23 = 1.2300 是對等的數字資料,還是這樣會什麼潛在的地雷是我所沒發現的?

# Jeffrey said on 12 December, 2016 10:44 AM

to ace, 因一次要比對數十個欄位,嫌寫成 a.Col1 != b.Col1 || a.Col2 != b.Col2… 麻煩,我將 a 的所有欄位組成一個長字串(組字串函式用程式碼產生器產生),再跟 b 所有欄位組成的字串相比,字串不同即可判定二者不同,省去逐欄比對的工夫,算是一種取巧做法。

你的看法呢?

(必要的) 
(必要的) 
(選擇性的)
(必要的) 
(提醒: 因快取機制,您的留言幾分鐘後才會顯示在網站,請耐心稍候)

5 + 3 =

搜尋

Go

<December 2016>
SunMonTueWedThuFriSat
27282930123
45678910
11121314151617
18192021222324
25262728293031
1234567
 
RSS
創用 CC 授權條款
【廣告】
twMVC

Tags 分類檢視
關於作者

一個醉心技術又酷愛分享的Coding魔人,十年的IT職場生涯,寫過系統、管過專案, 也帶過團隊,最後還是無怨無悔地選擇了技術鑽研這條路,近年來則以做一個"有為的中年人"自許。

文章典藏
其他功能

這個部落格


Syndication