寫 SupportedOS os = new MacOS("Tahoe", 25); 這行,等號右邊是一個 record、左邊是一個還不存在的型別——C# 15 的 compiler 會偷偷把 MacOS 塞進一個它自動生成的 struct 的 object? Value 欄位裡,然後在你 switch 它時,幫你檢查有沒有漏掉任何一個 case。整個 discriminated union 機制就是這層「struct + IUnion + 編譯期窮舉檢查」的薄包裝,沒有新的 runtime、沒有新的 IL opcode。
C# 終於有 union types——.NET 11 把 discriminated union 收進型別系統
C# 缺 discriminated union 缺了二十年。要表達「一個值是 A 或 B 或 C,而且這三者彼此沒有繼承關係」這種需求——HTTP 回應是 Ok<T> 或 Error、解析結果是 Number 或 String 或 Null——過去只有三條難看的路:開一個 abstract base class 強行讓不相干的型別共用祖先、用 object 加手動 tag 然後到處 cast、或裝 OneOf 這個 NuGet 套件。.NET 11 preview 2 起,C# 15 把 union 收進語言本身。底下拆四層看它怎麼組起來:一行 union 宣告 compiler 看到什麼、生成的 struct 長什麼樣、switch 的窮舉檢查從哪來、value type 的 boxing 怎麼用 TryGetValue<T>() 繞過。先講清楚:這跟 5/22 那篇 unsafe / memory-safety 完全是兩件事,union 是純粹的型別系統與編譯期糖衣,runtime 一行沒動。
這篇假設你已經知道 F# 有 discriminated union、Rust 有 enum、TypeScript 有 tagged union——C# 是這個俱樂部裡最晚到的大型靜態語言。重點不在「終於有了」這個情緒,而在 C# team 選的實作路徑:他們沒有在 CLR 裡加 native sum type(那會是多年的 runtime 工程與相容性風險),而是讓 compiler 把 union 降解成一個你自己也能手寫的 struct。這個選擇有具體後果——可以 target net8.0、net48 這些老 runtime;預設會 box value type;而你想要的話,可以接管生成、自己寫一個不 box 的版本。下面第一個互動工具就是讓你親手挑一個 case,看 compiler 把它放進哪個欄位、哪個 constructor 被呼叫、switch 的哪一條 arm 接住它。
點任一個 case 方塊,看它流進 struct 的哪個 constructor、被哪條 switch arm 接住 · 3 個 case
selected case
Windows · 走過的路
寫 SupportedOS os = new Windows("11");,compiler 看到右邊型別是 union 的成員之一,套用生成的 implicit conversion——呼叫 SupportedOS(Windows value) 這個 ctor,把 Windows 實例 box 進 object? Value。
switch 端:os switch { Windows w => … } 這條 arm 用 type pattern 對 Value 做 is Windows 測試,命中後 unbox 成 w。其他兩條 arm 不會被執行。
Linux · 走過的路
new Linux("Arch", "rolling") 命中的是 SupportedOS(Linux value) ctor。三個 ctor overload 只差參數型別,靠 overload resolution 選對——這也是為什麼 union 的 case 型別必須兩兩相異,否則 ctor 簽章撞號。
關鍵:Value 在任何時刻只持有「一個」case 的實例,這就是 discriminated union 的 sum-type 語意——不是三個欄位都在,而是三選一。
MacOS · 走過的路
new MacOS("Tahoe", 25) 走 SupportedOS(MacOS value)。注意 MacOS 含一個 int Version——但整個 MacOS record 是 reference type,box 的是 record 本身、不是裡面的 int。真正會踩到 boxing 痛點的是 union IntOrBool(int, bool) 這種「case 本身就是 value type」的情形,下面第三節處理。
switch 端:MacOS m => … 命中。三條 arm 窮舉了所有 case,compiler 不要求也不建議 _ => catch-all。
record 是 union 的成員型別;中間是 compiler 生成的 struct SupportedOS : IUnion,唯一的狀態是 object? Value;右欄是 switch 的三條 arm。點不同 case 看 Value 持有什麼、哪個 ctor 被呼叫。左欄三個 record 是 union 的成員型別;中間是 compiler 生成的 struct Supported…
C# 15 union 把 case 包進 struct 的 object? Value;switch 拆回,漏 case 亮 warning。
一行 union 宣告:compiler 看到的東西
宣告語法刻意做得跟 positional record 一樣短。把成員型別列在括號裡,就是一個 union:
// 三個彼此不相干的 case 型別,先各自宣告成 record
public record Windows(string Version);
public record Linux(string Distro, string Version);
public record MacOS(string Name, int Version);
// 一行宣告 union:用 union 當關鍵字,括號裡列出它由哪些型別組成
public union SupportedOS(Windows, Linux, MacOS);
三個 record 之間沒有共同 base class、沒有共同 interface——它們唯一的關聯就是「同時出現在 SupportedOS 的成員列表裡」。這正是 discriminated union 相對於 class hierarchy 的核心差異:sum type 不要求成員共享祖先。Windows、Linux、MacOS 可以是來自三個不同 library、你完全無法修改的型別,照樣能組成一個 union。對照 abstract base class 的做法——你得能改這三個型別、讓它們繼承同一個 abstract class OperatingSystem,這對第三方型別根本辦不到。
建構一個 union 值有兩種寫法,多數時候你只會用到後者:
// 顯式:明確包一層
SupportedOS os = new SupportedOS(new MacOS("Tahoe", 25));
// 隱式轉換(慣用):直接賦值 case 型別,compiler 補上轉換
SupportedOS os = new MacOS("Tahoe", 25);
隱式那行才是重點。new MacOS(...) 的型別是 MacOS,但左邊宣告的是 SupportedOS——compiler 看到目標型別是個 union、而 MacOS 是它的成員,就自動套用生成的 implicit conversion operator。這條 implicit conversion 是由生成 struct 上的 [Union] attribute 驅動的;沒有那個 attribute,這行不會編譯。要強調的是這些都發生在 編譯期:IL 裡看到的就是一次普通的 struct ctor 呼叫,沒有任何新的 runtime 機制介入。union 完全是 compiler 的語法降解(lowering),CLR 對「union」這個概念一無所知。
版本與專案設定的門檻很低,但有幾個硬條件。最低要 .NET 11 SDK preview 2(功能在 preview 2 落地、但 preview 4+ 才比較穩,建議用後者);專案檔必須開 <LangVersion>preview</LangVersion>,因為這是 C# 15 的 preview 功能。值得注意的是:你可以用新 SDK 編譯、卻 target 舊 runtime——net8.0、甚至 net48 都行。原因正是 union 是純編譯期降解:生成的 struct 只用到 object 與一般 ctor,不依賴任何 .NET 11 才有的 runtime 型別。代價是 target 舊框架時,你得自己提供 UnionAttribute 與 IUnion 這兩個 helper 型別的定義(compiler 找不到就自己給),但這只是幾行樣板。
這個「helper 型別可自備」的細節值得多看一眼,因為它揭露了整個功能的依賴邊界:compiler 需要的不是某個 runtime 實作,而是兩個 符號——名為 UnionAttribute 的 attribute 與名為 IUnion 的 interface(帶一個 object? Value)。在 .NET 11 的 BCL 裡它們已內建,但對 net48 這種沒有的框架,你在自己的 assembly 補上同名同形狀的定義,compiler 一樣認得、一樣生成正確的降解。這跟 C# 過去幾個「pattern-based」功能(如 foreach 只要型別有 GetEnumerator、await 只要有 GetAwaiter)一脈相承:語言層綁的是形狀而非具體型別。對要在多 target 框架的 library 裡用 union 的人,這意味著你不被 .NET 11 綁死——但也意味著你得搞清楚自己 target 的每個框架是否需要補這段 polyfill。
窮舉檢查:switch 為什麼能少掉 _ => catch-all
union 真正改變日常寫法的地方在 switch 的消費端。因為 compiler 知道 SupportedOS 只可能是這三個 case 之一,它能在編譯期判斷你的 switch 是不是窮舉(exhaustive)了所有可能:
string GetDescription(SupportedOS os) => os switch
{
Windows windows => $"Windows {windows.Version}",
Linux linux => $"{linux.Distro} {linux.Version}",
MacOS macOS => $"MacOS {macOS.Name} ({macOS.Version})",
}; // 不需要 _ => 這條 catch-all
三條 arm 覆蓋了全部三個 case,於是不需要 _ => 那條 default arm。對照過去用 object 加 type pattern 的寫法——compiler 不知道 object 還可能是什麼,永遠逼你補一條 _ => throw new InvalidOperationException() 收尾,那條 arm 在邏輯上是 dead code 卻又不能刪:它存在的唯一理由是讓 compiler 閉嘴,但它永遠不會被執行(如果其他 arm 真的覆蓋了所有實際型別),於是它也永遠不會被測試覆蓋到——是那種「明明是 dead code、靜態分析卻無法替你證明、code review 看到也只能聳肩放過」的雜訊。union 把這條 dead arm 消掉了,而且更重要的是反過來:如果你 漏掉 一個 case,compiler 會發出 warning:
warning: The switch expression does not handle all possible
values of its input type (it is not exhaustive).
這是 union 相對於「object + 手動 tag」最值錢的一條保證。設想你日後給 SupportedOS 加了第四個 case FreeBSD——所有沒處理 FreeBSD 的 switch 會立刻在編譯時亮 warning,逐一指出哪裡要補。用 object 加 tag 的舊寫法,加 case 不會有任何編譯期提醒,只能靠 runtime 撞到 default arm 才發現漏處理。要留意的措辭:compiler 對漏 case 發的是 warning 不是 error——預設不會擋編譯。團隊若想要硬性窮舉,得把這條 warning 用 <WarningsAsErrors> 升級成 error,否則它跟其他 warning 一樣容易被忽略。
下面這個工具把「從 union 關鍵字到窮舉 warning」這條 compiler pipeline 拆成四個階段。每一階段點開看它負責什麼、不負責什麼——窮舉檢查發生在最後一步,靠的是前面三步建立起的「這個型別只有 N 個 case」這項編譯期知識。
點任一階段看它的責任邊界 · 4 個 compiler 階段
從 union 關鍵字到窮舉 warning:四個 compiler 階段
selected stage
① parse · 責任邊界
把 public union SupportedOS(Windows, Linux, MacOS); 解析成一個 union 宣告,登記成員型別清單。這一步建立了後續所有檢查的根據:「SupportedOS 恰好有三個 case」。
不負責:生成任何程式碼、檢查 switch。它只是把語法變成 symbol table 上的事實。
② generate · 責任邊界
合成出 struct SupportedOS : IUnion——一個 object? Value 屬性、每個 case 一個 ctor overload、貼上 [Union] attribute。這是你「本來可以自己手寫」的那段樣板,compiler 替你寫了。
不負責:決定 Value 怎麼存(box 與否)。預設實作就是 box;要繞過得自己接管(見下一節)。
③ lower · 責任邊界
把 SupportedOS os = new MacOS(...); 這種隱式轉換改寫成對 SupportedOS(MacOS) ctor 的呼叫。這條 implicit conversion 是 [Union] attribute 授權的;attribute 不在,轉換不存在,這行編不過。
不負責:runtime 行為——降解後就是一般 IL,CLR 不知道 union 是什麼。
④ check · 責任邊界
對每個消費 union 的 switch 做 flow analysis:arm 覆蓋了全部 case 嗎?覆蓋了就不要求 _ =>;漏了就發 not exhaustive warning。這一步把 ① 的「只有 N 個 case」兌現成編譯期保證。
不負責:強制你補齊——它發的是 warning 不是 error。要硬性窮舉得自己升 WarningsAsErrors。
object + 手動 tag 的根本分野。四個階段裡前三個是降解、第四個是 flow analysis
union 四降解階段(parse、generate struct、lower、窮舉 check)全在 compiler,CLR 對 union 概念一無所知。
boxing:預設會 box,TryGetValue<T>() 怎麼繞過
生成的 struct 把每個 case 存進 object? Value。當 case 是 reference type(像前面的 record)時這沒問題——record 本來就在 heap 上,Value 只是存一個 reference。但當 case 是 value type 時,存進 object 就觸發 boxing:value 被複製到 heap、配一塊記憶體、Value 指過去。最典型的例子是 union IntOrBool(int, bool)——int 跟 bool 都是 value type,預設實作每次建構都 box 一次、每次 switch 取值再 unbox 一次。在 hot path 上這是實打實的 heap allocation 與 GC 壓力。
C# team 對這個取捨的回答是:預設求方便(一律 box,實作最簡單),但把 struct 開放給你接管。union 降解出的那個 struct 你可以不讓 compiler 生成、改自己手寫——只要實作 IUnion、貼 [Union],再提供 TryGetValue<T>() overload,compiler 在 switch 時就會自動改用你的 TryGetValue 而不是 box 用的 Value 屬性。手寫版用一個 discriminator 欄位加一塊共用 storage,徹底避開 heap:
[Union]
public struct IntOrBool : IUnion
{
private readonly bool _isBool;
private readonly int _value; // int 與 bool 共用這塊 stack storage
public IntOrBool(int value) { _isBool = false; _value = value; }
public IntOrBool(bool value) { _isBool = true; _value = value ? 1 : 0; }
// compiler 在 switch 裡自動挑對應 T 的 overload,避開 boxing
public bool TryGetValue(out int value)
{
value = _value;
return !_isBool; // 只有當前真的是 int 時回 true
}
public bool TryGetValue(out bool value)
{
value = _isBool && _value is 1;
return _isBool;
}
// fallback:仍提供 Value,但走到這裡才會 box
public object Value => _isBool ? _value is 1 : _value;
}
機制的關鍵在最後一段註解:Value 屬性還在(IUnion 要求它),但只有「不走 switch、直接讀 Value」時才會 box。當你在 switch 裡寫 case int i => …,compiler 偵測到有 TryGetValue(out int),就生成「呼叫 TryGetValue、成功就用 out 出來的 i」而不是「讀 Value 再 unbox」。整個 hot path 的值都待在 stack 上,零 allocation。代價是你得自己維護 discriminator 與 storage 的正確性——int 與 bool 剛好都塞得進一個 int 欄位,換成 int 與 double 你就得想清楚 storage 布局(可能要用 explicit layout 或兩個欄位)。下表把預設與手寫兩種實作在「建構一次 + switch 一次」這條路徑上的差別擺在一起:
| 面向 | 預設生成 · object? Value |
手寫 · TryGetValue<T> |
|---|---|---|
| storage | object(heap 指標) | int _value + bool _isBool(stack) |
| 建構 int case | box → heap alloc | 寫欄位 · 0 alloc |
| switch 取值 | unbox | TryGetValue out · 0 alloc |
| GC 壓力 | 每值一次 | 無 |
| switch 用哪個成員 | Value 屬性 | TryGetValue(compiler 自動挑) |
| 作者負擔 | 零——compiler 全包 | 自己維護 discriminator + layout |
| 適用場景 | 一般程式碼、case 是 reference type | hot path、case 是 value type |
TryGetValue 只在「case 本身是 value type 且在 hot path」時才值得寫。取捨很乾淨:預設零作者負擔但 value type 會 box;手寫零 allocation 但要自己管 storage
value type case 預設每次 box(24 B heap);手寫 TryGetValue 讓值留在 stack,hot path 配置量為零。
這個設計的優雅之處在於 switch 端的程式碼完全不變——不管底下是預設 box 版還是手寫 TryGetValue 版,你寫的 os switch { int i => …, bool b => … } 一字不差。compiler 在編譯期看實作有沒有 TryGetValue 決定生成哪種取值路徑。這代表你可以先用預設版把功能寫對,profile 之後發現某個 union 在 hot path 上 box 成本顯著,再單獨把那個 union 換成手寫版,而所有消費端一行都不用改。這種「介面穩定、實作可替換」的性質,正是把 union 做成 struct + IUnion 而不是 CLR native type 換來的彈性。
boxing 的代價要量化才有感覺。每次 box 一個 value type,CLR 在 heap 上配一個 object——在 64-bit 上一個 boxed int 約 24 bytes(object header 16 bytes + 對齊後的 payload)。單看一次不痛,但 hot path 的特徵就是「同一段程式碼一秒跑幾十萬次」。假設一個解析器把每個 token 包成 union Token(Number, Op, Paren)、其中 Number 是 double,每秒解析一百萬個 token,預設 box 版就是每秒一百萬次 heap allocation——直接灌進 Gen 0,把 GC 觸發頻率往上推,p99 延遲被 GC pause 拉長。手寫 TryGetValue 版這個數字是零。下面這個工具讓你拖動「每秒建構的 union 值數量」,看兩種實作的 heap allocation 與粗估 Gen 0 GC 觸發次數怎麼隨之發散——刻意用同一條軸,凸顯「不是慢一點,是一邊有、一邊完全沒有」。
拖動「每秒建構的 union 值」,比預設 box 版與手寫 TryGetValue 版的 heap 配置量
預設生成 · object? Value(box)
手寫 · TryGetValue<T>(不 box)
TryGetValue 版值留在 struct 內嵌 storage,配置量恆為零。Gen 0 GC 次數以 ~8 MiB 名目預算粗估——實際視 GC 模式與存活率而定,這裡只為呈現量級差。預設版:每個 value-type case 建構時 box 一次,配 ~24 B(64-bit:16 B objec…
每秒百萬個 value-type union:預設 box 版需 22.9 MB heap 與 2930 次 Gen 0 GC;手寫版零 allocation。
把 slider 拉到一千萬,預設版每秒配掉幾百 MB、觸發數萬次 Gen 0 回收,手寫版仍是平的零。這正是「預設方便、必要時可接管」這個設計哲學的價值落點:絕大多數 union 的 case 是 record 或 class(reference type),Value 只存 reference、根本不 box,預設版就是對的;只有「case 是 value type 且 union 落在每秒百萬級的 hot path」這個窄但真實的交集裡,才值得花力氣寫手寫版。換句話說,C# team 沒有為了照顧 1% 的 hot path 而讓 99% 的一般程式碼承擔複雜度——預設求對、求簡單,把效能逃生口留給真的需要的人。
放回脈絡:跟 OneOf、F#、Rust、TypeScript 比
C# 不是發明 discriminated union,是補一門大型靜態語言該有卻遲到的特性。把同一個「Result 是 Ok<T> 或 Error」的需求放到各語言看,能看清 C# 這版實作的定位——它最像 OneOf(因為都是「在沒有 native sum type 的語言上用既有機制模擬」),但靠 compiler 內建拿到了 OneOf 拿不到的窮舉檢查與隱式轉換。下面切到各語言的對應寫法:
切換頁籤比較同一個 Result 在五種語言/方案的寫法 · 5 個頁籤
內建語言特性。窮舉由 compiler 保證、隱式轉換內建、可選擇手寫 struct 避開 boxing。case 型別無需共享祖先。
public union Result(Ok, Error);
public record Ok(string Value);
public record Error(int Code);
string Show(Result r) => r switch
{
Ok ok => ok.Value,
Error err => $"err {err.Code}",
}; // 漏 case → compiler warning
C# 過去最接近的方案:純 library,用泛型 OneOf<T0, T1> 加 .Match()。沒有 compiler 層的窮舉檢查——.Match() 要求你傳齊每個 handler 是靠泛型方法簽章逼出來的,不是 flow analysis。新版 OneOf 可以實作 IUnion + [Union] 來借用內建 switch。
OneOf<Ok, Error> r = new Ok("hi");
string s = r.Match(
ok => ok.Value,
err => $"err {err.Code}"
); // handler 漏傳 → 編不過,但靠的是方法簽章
F# 自誕生就有 discriminated union,是 .NET 生態裡 union 的原生範本。語法最簡潔,match 內建窮舉檢查。C# 15 的 union 某種意義上是把 F# 這個能力搬到 C# 側、讓兩語言能更自然互通。
type Result =
| Ok of string
| Error of int
let show r =
match r with
| Ok v -> v
| Error c -> sprintf "err %d" c
// 漏 case → compiler warning
Rust 的 enum 就是 sum type,且是 value type、不 box、layout 由 compiler 最佳化(niche optimization)。match 窮舉是 error 不是 warning——這是 C# 與 Rust 最大的態度差:Rust 強制窮舉,C# 預設只警告。
enum Result {
Ok(String),
Error(i32),
}
fn show(r: Result) -> String {
match r {
Result::Ok(v) => v,
Result::Error(c) => format!("err {c}"),
} // 漏 case → compile error
}
TypeScript 的 tagged / discriminated union 靠結構型別加一個共同的 literal tag 欄位。窮舉檢查靠 never 這個 trick——把漏掉的 case 餵給一個 never 參數的函式,漏了就型別不符。沒有 runtime 概念,純編譯期。
type Result =
| { tag: "ok"; value: string }
| { tag: "error"; code: number };
function show(r: Result): string {
switch (r.tag) {
case "ok": return r.value;
case "error": return `err ${r.code}`;
// 漏 case → 透過 never 檢查報錯
}
}
互動圖表
C# 15 compiler 窮舉超越 OneOf;Rust 漏 case 是 error,C# 只有 warning;F# DU 是 .NET 最早原生實作。
五種寫法擺在一起,C# 15 的座標就清楚了。它在「窮舉嚴格度」上比 Rust 鬆(warning 而非 error),這跟 C# 一向把 source compatibility 看得很重有關——把漏 case 設成 error 會讓「加一個 case」變成 breaking change,弄壞所有下游 switch。Rust 的 match 漏 case 是硬性 compile error,這逼得 Rust 加 enum variant 本身就是一個需要全 codebase 配合的動作;C# 選 warning,是把這個取捨的決定權交還給團隊,代價是預設下漏處理只會默默累積成 warning 噪音。它在「value type 效率」上靠手寫 TryGetValue 才能追上 Rust 的零成本(Rust 的 enum 預設就是不 box 的 value type、layout 由 compiler 做 niche optimization 壓到最小),但給了 reference-type case 預設零負擔的方便。它相對 OneOf 的決定性優勢是 compiler 內建的窮舉 flow analysis 與隱式轉換——OneOf 的 .Match() 要逼你寫全 handler、語法也更囉嗦,而且它的窮舉是靠泛型方法簽章硬湊的、不是真正的 flow analysis,型別推斷的錯誤訊息往往很難讀。而它跟 F# 的關係最微妙:F# 早就有 DU,C# 補上後兩者在 .NET 上能更自然地共享 union 型別、互相消費——一個 F# library 暴露的 DU,未來 C# 端有機會用內建 switch 而非笨拙的 Tag 屬性去拆解。
對下週要寫 code 的人,決策面是這樣:新專案若已在 .NET 11 preview 軌道、且有「值是數種不相干型別之一」的建模需求(解析器的 token、API 的 polymorphic response、狀態機的 state),union 直接取代你過去會開的 OneOf<...> 或 abstract base class——少一個 NuGet 依賴、多一層 compiler 窮舉保證。已在生產的專案先別急:這是 C# 15 preview 功能、要開 LangVersion preview,preview 功能的語法在正式版前仍可能變動,拿來探路可以、押在關鍵路徑上要謹慎。真正要做效能決策的只有一種情形——union 的 case 是 value type 且落在 hot path,那時才需要從預設版升級到手寫 TryGetValue 版,而升級不影響任何消費端程式碼。
What this enables:C# 第一次能在語言層面表達「一個值是這幾種不相干型別之一」,並把 compiler 的窮舉檢查綁上去——慣用的 Result<T> / Option<T> 不再需要 abstract base class 或 OneOf 套件,新增 case 時所有沒處理它的 switch 會立刻亮 warning,而這一切只是一個你自己也能手寫的 struct : IUnion 的薄包裝,連舊 runtime 都能 target。