介紹 C# 15 的新功能:封閉階層(closed hierachies)。
假設你正在設計一套訂單 API,並以 OrderState 表示訂單狀態。程式庫裡目前有等待付款、已付款、已出貨和已取消四種狀態,像這樣:
public abstract record class OrderState;
public record class Pending : OrderState;
public record class Paid(DateTime PaidAt) : OrderState;
public record class Shipped(string TrackingNumber) : OrderState;
public record class Cancelled(string Reason) : OrderState;以後有需要時,你也想在程式庫內加入「已退款」(Refunded)狀態。但你不希望使用這套 API 的其他專案自行發明一個「暫停處理」(Suspended)狀態,然後傳進你的程式。因為你的訂單處理流程根本不知道該拿這個新狀態怎麼辦。
這個需求聽起來很合理:允許自己的程式庫定義子類別,但不開放外部專案任意擴充。 問題是,在 C# 15 以前,語言沒有直接表達這項設計的方法:
- 宣告為
abstract,外部專案也能繼承,開放程度太高。 - 宣告為
sealed,連程式庫自己的子類別也不能建立,又關得太緊。
我們需要的是介於兩者之間的選項。C# 15 新增的 closed 修飾詞,就是用來補上這個空缺。它把直接衍生型別限制在基底型別的宣告組件內,讓 API 作者保留繼承能力,同時讓編譯器知道所有可能的直接子類別。
closed 如何限制繼承範圍?
以下訂單狀態全部定義在同一個組件中:
public closed record class OrderState;
public record class Pending : OrderState;
public record class Paid(DateTime PaidAt) : OrderState;
public record class Shipped(string TrackingNumber) : OrderState;
public record class Cancelled(string Reason) : OrderState;closed 是語境關鍵字(contextual keyword),可套用在 class(包括 record class)。宣告為 closed 的型別會隱含成為抽象型別,因此不能同時使用 abstract、sealed 或 static。
什麼是語境關鍵字? 它只在特定的語法位置具有關鍵字的意義,在其他位置仍可作為識別字使用。例如,
closed出現在class宣告的修飾詞位置時是關鍵字;宣告變數時,則可以寫成bool closed = true;,直接把closed當作變數名稱。若要在可能被解析成關鍵字的位置把它當成識別字,可寫成@closed。
它和 sealed 的不同:
sealed表示任何地方都不能再繼承這個型別。closed允許繼承,但直接衍生型別必須位於同一個組件。
換句話說,closed 不是完全關掉繼承,而是把繼承範圍留在自己的程式庫裡。這正好符合剛才的訂單 API:程式庫仍能新增狀態,外部專案則不能直接替 OrderState 增加子類別。
讓 switch 知道「真的沒有別種狀態了」
編譯器知道 OrderState 的所有直接衍生型別後,便能判斷下列 switch 運算式已經涵蓋完整集合:
static string GetStatusText(OrderState state) => state switch
{
Pending => "等待付款中",
Paid paid => $"已於 {paid.PaidAt:yyyy-MM-dd} 付款",
Shipped shipped => $"已出貨,追蹤碼:{shipped.TrackingNumber}",
Cancelled cancelled => $"訂單已取消(原因:{cancelled.Reason})"
};這裡不需要 _ => throw new InvalidOperationException()。如果日後加入新的直接子類別 Refunded,尚未處理它的 switch 就不再具有窮盡性,編譯器會發出 CS8509 警告。若專案將警告視為錯誤,這項警告便會導致建置失敗。
這種窮盡性檢查特別適合狀態機、工作流程與領域事件。新增狀態時,重新編譯就能找出需要同步調整的處理邏輯。如果忽略警告,而且執行時真的傳入尚未處理的 Refunded,switch 運算式會擲回 SwitchExpressionException。
closed 只封住第一層:具體狀態仍需要 sealed
到這裡,外部組件已經不能直接繼承 OrderState。不過,封閉性不會自動沿著整棵繼承樹向下傳遞。由於目前的 Pending 並未封閉,外部組件仍可間接增加新的訂單狀態:
// 位於外部組件
public record class Suspended : Pending;這段程式碼可以通過編譯,而 Suspended 仍然是一種 OrderState。先前的 switch 也仍被視為具有窮盡性,因為 Suspended 繼承自 Pending,所以在 switch 中會由 Pending 分支處理。換句話說,編譯器認為 switch 已涵蓋所有情況,不代表外部組件完全無法擴充這棵繼承樹。
如果設計目標是不允許外部組件增加任何訂單狀態,就要把四個葉節點宣告為 sealed:
public closed record class OrderState;
public sealed record class Pending : OrderState;
public sealed record class Paid(DateTime PaidAt) : OrderState;
public sealed record class Shipped(string TrackingNumber) : OrderState;
public sealed record class Cancelled(string Reason) : OrderState;如此一來,closed OrderState 會禁止外部組件新增直接子型別,sealed 則禁止任何地方繼承這四個具體狀態。程式庫日後仍可在同一個組件內加入新的直接子型別,例如 Refunded,外部組件卻無法直接或間接增加狀態。
當然,未封閉的子型別不一定是漏洞。如果某個子型別本來就是刻意保留給其他組件使用的擴充點,就可以不加 sealed;如果只想讓同一個組件繼續細分,則可把中間節點也宣告為 closed。設計封閉階層時,應逐層決定哪些節點需要繼續擴充,哪些葉節點必須真正封閉。
internal 不也是限制在同一個組件嗎?
看到這裡,你可能會想到另一個同樣以組件為界線的關鍵字:internal。如果把 OrderState 宣告為 internal,外部組件不就同樣無法繼承了嗎?
internal abstract record class OrderState;確實如此,但 internal 限制的是型別的可存取範圍。一般的外部組件「看不見」組件內的 internal 型別,因此不能使用它,更別提繼承了。如此一來,OrderState 也不能出現在公開 API 的參數或回傳型別中:
internal abstract record class OrderState;
public sealed class OrderService
{
// 編譯錯誤 CS0050:回傳型別的存取範圍比方法小
public OrderState GetState() =>
throw new NotImplementedException();
}closed 限制的是直接繼承的位置,而不是「可見範圍」。就如前面範例展示過的,宣告型別的時候可以跟 public 一起搭配使用:
public closed record class OrderState;這樣外部組件仍能在程式碼中使用 OrderState,但不能直接從它衍生新的子類別。
這裡要特別注意:closed 並不會阻止外部組件透過「未宣告為 sealed 的子型別」間接擴充。這正是上一節仍須將具體訂單狀態(例如 Pending)宣告為 sealed 的原因。
簡單地說,internal 是在限定「誰可以使用這個型別」,closed 則是在限定「哪裡可以宣告直接子型別」。如果 OrderState 只供程式庫內部使用,internal 已經足夠;如果它是公開 API 的一部分,又要由程式庫掌握直接子型別的集合,就應使用 closed。
那麼,該用 closed 還是 union types?
封閉階層與聯合型別(union types)都能支援窮盡模式比對,但它們處理的設計起點不同。
- 已經有自然的共同基底型別,而且各子類別共享多型行為時,可考慮封閉階層。
- 幾個型別彼此不必有繼承關係,只想把它們組成一組固定選項時,聯合型別通常更直接。
- 希望第三方套件持續新增子類別時,兩者都不適合;應保留開放的介面或基底類別。
closed 的核心承諾是「直接子類別只能在這個組件中新增」。如果你的程式庫原本就把外部繼承視為擴充點,加上 closed 會改變使用者能做的事,不應只為了省略 switch 的預設分支而使用。
結語:補上 abstract 與 sealed 之間的選項
abstract 適合對外開放繼承,sealed 適合完全禁止繼承。過去若想讓自己的程式庫保留子類別,又不讓外部專案加入新成員,只能依靠文件、建構式存取範圍或其他設計技巧間接限制。
C# 15 的 closed 終於讓這項意圖可以直接寫在型別宣告上。當狀態集合由同一個組件控制,而且新增狀態時所有處理邏輯都應重新檢查,它既能保留繼承與多型,也能讓 switch 完成窮盡性檢查。
使用前要確認兩件事:外部組件是否確實不需要繼承,以及中間子類別是否也需要封閉。這兩點比少寫一個預設分支更重要。
可參閱微軟文件:What's new in C# 15:Closed hierarchies。
本文改寫自我的書《現代 C# 精要 第二版》第 6 章〈模式比對〉。
沒有留言: