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() 方法
  • 政令宣導時間:「算錢用浮點,遲早被人扁」。早晚複誦,永誌不忘~

Comments

# by ace

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

# by Jeffrey

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

# by 阿克

偶然看到這篇, 以前我也有困擾過, 我是用 ToString()後再Trim, x.ToString().TrimEnd('0', ' ')

# by 阿克

以前我也有遇過這困擾, 後來是用 ToString().Trim() x.ToString().TrimEnd('0', ' '), 去掉尾巴的 0

# by Jeffrey

to 阿克,感謝補充,Trim('0')有個風險,若decimal為整數時可能誤刪,例如:12300會變成123,應用時要留意這點。

# by Peter

如果把 decimal 轉型為 double 在 ToString() 應該就可以解決這個問題了吧!

# by Jeffrey

to Peter, decimal 轉成 double 的過程有可能損失部分精確度。(從 29 位縮減到 17 位)

# by ZSJ

範例 Code 中的 '0' 好像是 33個。

# by ZSJ

感謝分享!解決方式都很棒!

# by ZSJ

但方式都很棒!感謝分享!

# by Lina

請問: double : 193.25*4/3*3=773=773 decimal :193.25*4.0000/3.0000 *3.0=773.00000000000000000000000001 decimal 算出來怎麼有小數?有辦法解決嗎?

# by Jeffrey

to Lina,我寫了篇文章說明 https://blog.darkthread.net/blog/float-vs-decimal-case/

# by Feliz

請問黑大「奇妙的 Normalize() 方法」是除上 1.00000…(33個0),這有什麼緣故嗎?實測上33個0和28個0的結果似乎一樣。但不太確定Stackoverflow上的原作者用33個0的原因,謝謝

# by Jeffrey

to Feliz, 可參考該則回答下方的討論 https://stackoverflow.com/questions/4525854/remove-trailing-zeros#comment27100516_7983330 (請自己複製 #comment27100516_7983330 在 URL 後方),decimal 最多只能記住 29 位數,1 加小數點後方 28 個 0 是上限,多超過的部分存不進電腦不會產生影響,討論裡也有人提到大於 28 位就可以,多給無益也無害。

Post a comment