double 的問題不是溢位,而是近似值
double 使用二進位浮點數。它很適合科學計算、座標、統計資料,以及可以接受些微誤差的數值。
但十進位的 0.1 沒辦法用有限的二進位位數完整表示。程式實際保存的,只是非常接近 0.1 的值。
用一個最常見的例子就能看出差異:
double first = 0.1;
double second = 0.2;
double result = first + second;
Console.WriteLine(result.ToString("R"));
Console.WriteLine(result == 0.3);結果是:
0.30000000000000004
False人看起來明明就是 0.1 + 0.2 = 0.3,程式比較後卻得到 false。
單次誤差很小,小到一般畫面顯示時可能根本看不出來。可是付款系統還會計算折扣、稅額、手續費,也可能累加大量交易。這時候「差一點點」就不再只是顯示問題。
再看一個累加範例:
double total = 0;
for (var index = 0; index < 10; index++)
{
total += 0.1;
}
Console.WriteLine(total.ToString("R"));結果可能是:
0.9999999999999999如果這個數值被拿去判斷餘額是否等於 1,程式就可能走到錯誤的分支。
C# 金額通常使用 decimal
decimal 採用十進位方式表示數值,比較符合我們日常使用的金額格式。
同樣的計算改成 decimal:
decimal first = 0.1m;
decimal second = 0.2m;
decimal result = first + second;
Console.WriteLine(result);
Console.WriteLine(result == 0.3m);結果是:
0.3
True程式中的 m 不能省略。它表示這個常值是 decimal:
decimal amount = 1200.50m;如果是訂單的 API Request,目前可以先這樣定義:
public sealed class CreateOrderRequest
{
public decimal Amount { get; init; }
public string Currency { get; init; } = string.Empty;
}Amount 與 Currency 要一起出現。只有數字,並不能說明它是新臺幣、美元還是日圓。
等到 Domain 模型逐漸完整後,也可以把兩者包成一個 Money:
public sealed record Money(
decimal Amount,
string Currency);不過 decimal 只解決數值表示的問題,沒有替我們決定四捨五入規則。
例如:
decimal average = 100m / 3m;
Console.WriteLine(average);會得到很多位小數:
33.333333333333333333333333333最後保留幾位、在哪個步驟取捨,仍然要由業務規則決定:
decimal rounded = decimal.Round(
average,
2,
MidpointRounding.AwayFromZero);有些系統使用 AwayFromZero,有些帳務規則使用 ToEven。這不能只憑工程師習慣決定。
SQL Server 的 MONEY 並不是浮點數
MONEY 和 double 的問題不一樣。
SQL Server 的 MONEY 是固定四位小數的貨幣型別,不會發生 double 那種二進位浮點誤差。像下面這個數值可以正確保存:
DECLARE @Amount MONEY = 1200.1234;既然如此,為什麼很多系統仍然使用 DECIMAL?
因為 MONEY 的小數位固定為四位。這個設定不一定符合實際需求。
訂單金額可能只需要兩位小數,匯率可能需要八位,稅率和手續費也可能各有不同精度。全部塞進 MONEY,資料表看不出每個欄位真正允許的範圍。
使用 DECIMAL 時,可以直接把規則寫進欄位定義:
Amount DECIMAL(19, 2)19 是總位數,2 是小數位數。換句話說,整數部分最多有 17 位。
匯率則可以使用不同的精度:
ExchangeRate DECIMAL(19, 8)看 Schema 就知道金額與匯率不是同一種精度需求。
MONEY 進行計算時也可能太早失去小數
假設要把 10 元平均分成三份:
DECLARE @Amount MONEY = 10.0000;
SELECT @Amount / 3;MONEY 只能保留四位小數,因此得到的結果類似:
3.3333如果這只是最後顯示的金額,可能沒有問題。但若後面還要乘上匯率、計算稅額或手續費,小數在中間步驟就已經被縮減。
改用較高精度的 DECIMAL:
DECLARE @Amount DECIMAL(19, 8) = 10;
SELECT @Amount / 3;中間運算可以保留更多位數,等到真正產生應收金額時,再按照幣別及帳務規則處理。
所以 MONEY 不是不能用。當系統確定只需要四位小數,而且不會進行複雜的中間計算時,它仍然能正常工作。只是付款系統通常希望精度規則更明確,DECIMAL(p, s) 會比較容易控制。
在應用程式與資料庫之間怎麼搭配?
如果系統要保存一般訂單金額,可能會看到這樣的組合。
C#:
public decimal Amount { get; init; }
public string Currency { get; init; } = string.Empty;SQL Server:
Amount DECIMAL(19, 4) NOT NULL,
Currency CHAR(3) NOT NULL,
CONSTRAINT CK_Order_Amount_Positive CHECK (Amount > 0)這裡的 DECIMAL(19, 4) 只是範例,不是付款系統的固定答案。
如果只接受新臺幣整數金額,也可能使用零位小數;若要支援匯率或更細的計價單位,則可能需要四位以上。真正的型別應該根據支援的幣別與計算方式決定。
還有一種做法,是把金額換算成最小貨幣單位後使用整數儲存:
long amountInMinorUnits = 1050;這可能代表 10.50 美元,也就是 1050 cents。但日圓、新臺幣和其他幣別的小數規則不同,API 契約必須先把單位講清楚,否則整數一樣會造成誤解。
以目前的練習來說,C# 先使用 decimal,並讓金額永遠伴隨 Currency。資料庫還沒有進入實作階段,不需要現在就決定使用 MONEY、DECIMAL(19, 2) 或最小貨幣單位。先把 API 的輸入、驗證與回應定義清楚,之後才有足夠資訊決定資料要怎麼存。
