介紹 C# 15 的新功能:Union Types
假設我們在設計一個付款功能時,寫了這樣的類別:
public enum PaymentKind { Cash, CreditCard, BankTransfer }
public class Payment
{
public PaymentKind Kind { get; set; }
public decimal Amount { get; set; }
public string? CardLast4 { get; set; }
public string? TransferReference { get; set; }
}Kind 區分現金、信用卡與銀行轉帳;CardLast4 記錄信用卡末四碼,TransferReference 記錄轉帳交易編號,供付款摘要使用。但這個類別也允許你建立下面這個物件:
var payment = new Payment
{
Kind = PaymentKind.CreditCard,
Amount = 1200m,
CardLast4 = null,
TransferReference = "TX-20261201-001"
};明明選了信用卡,卻沒有卡片資訊,反而填了轉帳交易編號。編譯器不會抱怨,因為這些屬性彼此獨立。要產生付款摘要,程式還得先檢查 Kind,再確認對應的欄位有沒有填對。
我們真正想表達的是:一筆付款是現金、信用卡或銀行轉帳的其中一種,而且每種方式都有自己的資料。 C# 15 的聯合型別(union types),就是用來表達這種需求。本文以 C# 15 與 .NET 11 為基礎,逐步把這個付款模型改寫得更清楚。
第一步,把各種付款方式需要的資料放進獨立型別:
public sealed record class Cash(decimal Amount);
public sealed record class CreditCard(string Last4, decimal Amount);
public sealed record class BankTransfer(string Reference, decimal Amount);如果你還不熟悉 record,這裡可以先把它理解為適合承載資料的型別。例如 CreditCard 的宣告會提供接受末四碼與金額的建構式,以及對應的 Last4、Amount 屬性。sealed 表示不允許其他類別繼承它。
現在,Cash 沒有卡片資訊,CreditCard 也沒有轉帳交易編號。原本混在一起的資料,已經依照付款方式分開了。
接下來,如果產生付款摘要的方法要接受「任何一種付款方式」,參數該用什麼型別?
使用 object 太寬鬆,連日期或字串都傳得進去;建立共同基底類別或介面,又得讓這三個型別建立繼承關係。這時就可以使用 union。
在剛才三個型別之外,加上這一行:
public union PaymentMethod(Cash, CreditCard, BankTransfer);括號內列出的型別稱為 case types,本文稱為「成員型別」。PaymentMethod 將它們組成一組封閉的選項;所承載的非 null 值必須符合這三種型別之一。
三個成員型別不必繼承 PaymentMethod,也不必實作共同介面。宣告完成後,就能直接指派:
PaymentMethod cash = new Cash(500m);
PaymentMethod card = new CreditCard("4242", 1200m);
PaymentMethod transfer = new BankTransfer("TX-20261201-001", 800m);
// ✘ 無法編譯:string 不是 PaymentMethod 的成員型別。
// PaymentMethod invalid = "現金";這是成員型別到聯合型別的隱含轉換,不需要手動包裝。同樣的轉換也能用在方法引數與回傳值上。語法與轉換規則可參閱微軟的 Union types 語言參考。
現在可以實作產生付款摘要的方法了:
static string Describe(PaymentMethod method) => method switch
{
Cash cash => $"現金:{cash.Amount:0.00} 元",
CreditCard card =>
$"信用卡末四碼 {card.Last4}:{card.Amount:0.00} 元",
BankTransfer bank =>
$"轉帳交易 {bank.Reference}:{bank.Amount:0.00} 元"
};這是 switch 運算式,每個 => 右邊的值就是該分支的結果。CreditCard card 是型別模式:當聯合型別裡裝的是 CreditCard,就把它取出來,交給變數 card。
因此,信用卡分支可以直接讀取 card.Last4,轉帳分支則讀取 bank.Reference。不需要先檢查 Kind,也不用猜測哪個欄位才適用;進入哪個分支,就拿到哪一種資料。
如果刪掉 BankTransfer 分支,編譯器會提出窮盡性檢查(exhaustiveness checking)的警告,指出尚未涵蓋銀行轉帳:
warning CS8509: The switch expression does not handle all possible values
of its input type (it is not exhaustive). For example, the pattern
'BankTransfer' is not covered.
這項檢查在擴充模型時尤其有用。假設以後加入 MobilePayment,並把它列入 PaymentMethod 的宣告,重新編譯時,沒有處理新型別的 switch 運算式就會出現警告,協助你找到需要調整的位置。
若希望每種付款方式都被明確處理,就逐一列出分支。加入 _ => "其他付款方式" 會把新型別一起接住,也就失去這項提醒。這裡的遺漏分支診斷是警告;專案設定將警告視為錯誤時,才會阻擋建置。
開頭的 Payment 把付款種類與所有欄位分開儲存,可能出現「選信用卡,卻填了轉帳資料」的組合。改寫後,每種付款方式都有自己的資料型別,摘要方法則透過 PaymentMethod 接受這些選項。
把剛才的宣告與方法合起來,就是以下完整的主控台程式。使用支援 C# 15 的 .NET 11 專案時,可以放進 Program.cs 執行:
using System;
Console.WriteLine(Describe(new Cash(500m)));
Console.WriteLine(Describe(new CreditCard("4242", 1200m)));
Console.WriteLine(Describe(new BankTransfer("TX-20261201-001", 800m)));
static string Describe(PaymentMethod method) => method switch
{
Cash cash => $"現金:{cash.Amount:0.00} 元",
CreditCard card =>
$"信用卡末四碼 {card.Last4}:{card.Amount:0.00} 元",
BankTransfer bank =>
$"轉帳交易 {bank.Reference}:{bank.Amount:0.00} 元"
};
public sealed record class Cash(decimal Amount);
public sealed record class CreditCard(string Last4, decimal Amount);
public sealed record class BankTransfer(string Reference, decimal Amount);
public union PaymentMethod(Cash, CreditCard, BankTransfer);在使用小數點的文化設定下,輸出如下:
現金:500.00 元
信用卡末四碼 4242:1200.00 元
轉帳交易 TX-20261201-001:800.00 元
呼叫端直接傳入具體的付款資料,方法的參數型別列明允許的選項,各分支則使用自己的專屬欄位。付款方式與資料不再靠一個獨立的 Kind 屬性維持對應關係。
當然,資料格式仍需要驗證:例如 new CreditCard("", -100m) 符合型別宣告,卻不符合業務規則。Union Types 限制的是資料如何組合,末四碼格式與金額是否合法,仍由驗證邏輯負責。
當你想表達「只能是這幾種情況之一,而且各自攜帶不同資料」,就可以考慮聯合型別。例如查詢結果的「找到資料/找不到資料」,或作業結果的「成功時帶訂單編號/失敗時帶錯誤原因」。
如果只是幾個沒有專屬資料的名稱或標記,enum 通常已經足夠。如果希望第三方持續新增實作,並透過共同契約呼叫行為,則適合使用介面或類別繼承。
這裡的「封閉」也不表示永遠不能改版,而是新增選項時,需要修改 union 宣告,再檢查相關處理邏輯。
Note:
union宣告產生的是結構型別;其default值的內部Value為null。本文範例都直接由成員實例建立付款值;若程式可能收到空值,仍需依情境加入null分支。詳見官方文件的 Null matching。
下次遇到一個類別塞滿可空欄位,需要根據 Kind 或 Status 判斷哪些欄位才有意義時,可以試著把各種情況拆成獨立型別,再以 union 組合。如此一來,方法能接受哪些型別,一看就清楚,也能讓編譯器協助檢查是否漏掉某種情況。
本文改寫自我的書《現代 C# 精要》第 4 章〈不可變設計〉。書中對於
record、不可變資料模型,以及模式比對如何運用在實際程式中,有更詳細的說明。
沒有留言: