vatt'ghern jaskier's ballads
本文 5 個互動圖表在手機上以重點摘要呈現,互動版請以桌面瀏覽器開啟。

寫 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

case records generated struct switch arm Windows (string Version) Linux (string Distro, …) MacOS (string Name, int) [Union] struct SupportedOS : IUnion object? Value = null 3 ctor overloads + implicit conv. ctor(MacOS) Windows w => $"Windows {w.Version}" Linux l => $"{l.Distro} {l.Version}" MacOS m => $"MacOS {m.Name}" implicit conversion 把 record 包進 Value——switch 再用 pattern match 拆回來

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 階段

union 全部發生在 compiler——runtime 看到的只是普通 struct 與 ctor 呼叫 ① parse 辨識 union 關鍵字 記下 N 個成員型別 ② generate struct : IUnion Value + ctor + [Union] ③ lower implicit conversion = new MacOS(…) → ctor ④ check switch arm 覆蓋全部 case? 缺 → 窮舉 warning ①②③ 是降解(lowering),④ 是 flow analysis——窮舉檢查靠的是 ① 記下的「只有 N 個 case」這項知識

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。

四個階段裡前三個是降解、第四個是 flow analysis。窮舉檢查能成立的唯一理由是 ① 已經把「這個型別只有 N 個 case」記進 compiler——這是 union 跟 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 一次」這條路徑上的差別擺在一起:

同一個 IntOrBool,預設生成(box)對上手寫 TryGetValue(不 box),比「建構一次 + switch 取值一次」這條路徑。
面向 預設生成 · object? Value 手寫 · TryGetValue<T>
storageobject(heap 指標)int _value + bool _isBool(stack)
建構 int casebox → heap alloc寫欄位 · 0 alloc
switch 取值unboxTryGetValue out · 0 alloc
GC 壓力每值一次無
switch 用哪個成員Value 屬性TryGetValue(compiler 自動挑)
作者負擔零——compiler 全包自己維護 discriminator + layout
適用場景一般程式碼、case 是 reference typehot path、case 是 value type
取捨很乾淨:預設零作者負擔但 value type 會 box;手寫零 allocation 但要自己管 storage。多數 union(case 是 record / class)落在第二欄沒問題那側,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 配置量

1,000,000

預設生成 · object? Value(box)

22.9MB / s
~ 2,930 次 Gen 0 GC / s
每值 24 B(boxed value type)

手寫 · TryGetValue<T>(不 box)

0MB / s
0 次 Gen 0 GC / s
值留在 stack / 內嵌 struct
預設版:每個 value-type case 建構時 box 一次,配 ~24 B(64-bit:16 B object header + 8 B payload)。手寫 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。