Lesson 25: C# 15 — Union 类型 Union Types

.NET 11 Preview · 等了十年的 discriminated union · 编译期穷举检查 · 告别"枚举+switch"

前置:已完成 Lesson 24(C# 14 小特性合集)。本课是 C# 15(.NET 11)系列的旗舰特性——C# 世界等了十年终于等到的可区分联合。
状态:当前以 .NET 11 Preview 5 Preview 发布。部分特性仍在开发中,API 可能在 GA(预计 2026.11)前变化。
阅读:C# 15 新增功能 · Union 类型语言参考 · Union 类型设计提案

〇、一个无处不在的烦恼 The One-Of Problem

编程中最常见的数据建模难题之一:一个值恰好是几种类型之一

场景可能的类型你现在的做法
方法返回值成功值 或 错误object? + is 检查⋯⋯编译器不帮你
消息分发OrderPlaced / PaymentReceived / ...继承层次 + switch——新增消息类型不警告
AST 节点Literal / BinaryExpr / Variable / ...抽象基类——穷举检查全靠纪律

C# 过去有五种"绕路"——每一种都有真实痛点:

① enum + switch

丢失类型信息——case 下面要强制转换。enum 值范围由你负责——忘记处理新值不会有警告。

② 继承层次

正确的面向对象方案——但不是封闭的。新人加了一个子类,分散在 47 个 switch 里都不会亮警告。

③ object? + is

编译器完全不帮你。类型安全全靠开发者记忆。if (x is string) ... else if (x is int) ... else throw 是样板地狱。

④ OneOf 库

NuGet 上有 OneOf 包——但类型名不自然(OneOf<T0, T1>),没有编译期穷举检查,没有模式匹配集成。

⑤ F# discriminated union

F# 从 1.0 就有。在 F# 项目中用——但 C# 调用 F# union 很别扭,且不能直接在 C# 里定义。

💡 插播:AST 节点是什么?AST = Abstract Syntax Tree(抽象语法树)。编译器解析源码后,在内存中构建一棵树——每个结点("AST 节点")代表一个语法构造。比如 x = 3 + y * 2 解析后是一棵由 Assignment、Variable、BinaryExpr、Literal 等不同节点类型组成的树。每个节点恰好是几种类型之一——这是 discriminated union 在编程语言领域最经典的应用场景。F#、Rust、Haskell、TypeScript 的编译器全这么写;C# 自己的 Roslyn 编译器也用继承层次建模 AST 节点。

核心矛盾:C# 缺少一个"这组类型到此为止——编译期帮我盯着"的语言构造。C# 15 回答了这个问题。

一、Union 语法 Union Declaration

1.1 基本声明

// 声明 case 类型——它们本身是普通 record/class/struct
public record class Cat(string Name);
public record class Dog(string Name);
public record class Bird(string Name);

// 🆕 union 声明——只有一行
public union Pet(Cat, Dog, Bird);

union 是一个类型声明,不是泛型约束、不是特性——是全新的一等公民类型。三行 record class + 一行 union,你有了一个编译期保证穷举的类型。

为什么写 record class 而不是 recordpublic record class Cat(string Name)public record Cat(string Name) 完全等价——前者是 C# 10 引入 record struct 后的显式写法。本课统一使用 record class意图明确的编码风格:在 union 语境中,case 是 class 还是 struct 直接影响存储策略(是否装箱,见 §3.3)。读者不需要猜。

1.2 隐式转换——每个 case 自动能赋值给 union

Pet pet = new Dog("Rex");          // Dog → Pet 隐式转换
Pet pet2 = new Cat("Whiskers");    // Cat → Pet 隐式转换

Console.WriteLine(pet.Value);            // Dog { Name = Rex }

// 方法参数也是隐式的:
void Describe(Pet pet) { ... }

Describe(new Dog("Buddy"));           // 直接传 Dog——不需要包装
Describe(new Cat("Luna"));            // 直接传 Cat
和 C# 14 Span 隐式转换的内在同一逻辑:让正确的事变得不费力气。你不需要 new Pet(dog) 包装——编译器替你做了,就像 int[] 不需要 .AsSpan() 一样。

1.3 case 类型可以是什么?

几乎任何能转为 object 的类型都可以:

// 引用类型
public union Shape(Circle, Rectangle, Triangle);

// 值类型——struct case 在默认形式下会装箱
public union IntOrString(int, string);

// 泛型——经典 Option<T>
public record class None;
public record class Some<T>(T Value);
public union Option<T>(None, Some<T>);

// 嵌套——union 本身也可以是另一个 union 的 case
public union Success<T>(T Value);
public union Failure(Exception Error);
public union Result<T>(Success<T>, Failure);
record、class、struct 可以混用吗?完全可以。Union 不关心 case 是 record class、普通 class、record struct 还是原始 struct——凡是能转为 object 的类型都能放进去。public union IntOrString(int, string) 就是 struct + class 混用的例子。唯一的代价是 struct case 默认会装箱(见 §3.3),热路径上需要手写 tagged union(见 §4.3)。

1.4 Union 可以有自己的成员

public record class Meters(double Value);
public record class Feet(double Value);

public union Length(Meters, Feet)
{
    // 基于模式匹配计算属性
    public double TotalMeters => this switch
    {
        Meters m => m.Value,
        Feet f => f.Value * 0.3048,
    };

    // 返回 union 类型的方法
    public Length Add(Length other) => new Meters(TotalMeters + other.TotalMeters);
}
限制:union 体不能有实例字段自动属性字段式事件,也不能声明单参数 public 构造(编译器为每个 case 生成构造)。这些限制保证 union 的数据存储完全由生成的 Value 属性管理。

二、模式匹配——编译器替你盯住每一种可能 Exhaustive Pattern Matching

2.1 穷举检查:不加 default 才是正确的

Pet pet = new Dog("Rex");

string name = pet switch
{
    Dog d => d.Name,
    Cat c => c.Name,
    Bird b => b.Name,
};  // ✅ 编译器通过了——三个 case 全部覆盖
    // 不需要 _ 兜底——编译器知道集合是封闭的

这才是关键——当你加了一个新 case 类型,每个 switch 都自动亮警告

// 后来有人加了一个新宠物类型:
public record class Fish(string Name, double Depth);

// 改 union 声明:
public union Pet(Cat, Dog, Bird, Fish);   // ← 加了 Fish

// 💡 编译器立即在所有 switch 上亮警告:
// "The switch expression does not handle Fish"
// 你必须去每个 switch 加 Fish 的处理——这就是穷举安全的含义
这是 union 相比于继承层次的根本优势。继承层次中加子类,switch 不会警告——你只能在运行时发现 InvalidOperationException。Union 让你在编译期就知道所有遗漏。

2.2 "透明解包"——模式匹配直接作用在 Value 上

Union 的模式匹配有一个重要的设计决策:模式默认作用在 .Value 上,而不是 union 本身。这叫"透明解包"——你写 pattern match 时感觉就像在直接匹配 case 类型:

Pet pet = new Cat("Whiskers");

// 你写的代码——直接匹配 case 类型
var result = pet switch
{
    Cat c => "猫: " + c.Name,     // c 是 Cat 类型——不是 Pet
    Dog d => "狗: " + d.Name,
    Bird b => "鸟: " + b.Name,
};

// 等价于编译器隐式做的:
// pet.Value switch { Cat c => ..., Dog d => ..., Bird b => ... }

三个例外——以下模式作用在 union 值本身(不透明),不穿透到 .Value

模式行为场景
_(弃元)匹配 union 值本身兜底——但实际上穷举够了一般不需要
var x捕获 union 值本身拿到 Pet? 而非 .Value
not作用在 union 值本身not null and var value 中的 not null

2.3 Null 处理

Union 是 struct——本身不能是 null。但 .Value 可以是 null(当 default(Pet) 时)。编译器跟踪 null 状态:

Pet pet = default;   // .Value 是 null
Console.WriteLine(pet.Value is null);  // True

var desc = pet switch
{
    Dog d => d.Name,
    Cat c => c.Name,
    Bird b => b.Name,
    null => "没有宠物",   // null 分支处理 default 情况
};

// Nullable union:Pet? 也可用
Pet? maybePet = null;   // Nullable<Pet>——整个 union 为 null
注意:如果 union case 中有值类型(如 int),default 构造的 union 其 .Value 也是 null——default union 不代表任何 case。你要么用 null 分支处理,要么确保 union 总是被赋有效值。

三、编译器生成了什么? Compiler Lowering

3.1 一行 union 展开为一个 struct

这是理解 union 本质最重要的部分。你写:

public union Pet(Cat, Dog, Bird);

编译器把它降低为:

[System.Runtime.CompilerServices.Union]
public struct Pet : IUnion
{
    // 每个 case 一个构造
    public Pet(Cat value) => Value = value;
    public Pet(Dog value) => Value = value;
    public Pet(Bird value) => Value = value;

    // 存储——统一用 object?
    public object? Value { get; }

    // 每个 case 一个隐式转换
    public static implicit operator Pet(Cat value) => new(value);
    public static implicit operator Pet(Dog value) => new(value);
    public static implicit operator Pet(Bird value) => new(value);
}
你写的编译器生成的
public union Pet(Cat, Dog, Bird);一个 struct + [Union] attribute + : IUnion + 每个 case 的构造器 + 隐式转换 + Value 属性
Pet pet = new Dog("Rex");Pet pet = new Pet(new Dog("Rex")); —— 构造调用
pet switch { Dog d => ... }pet.Value switch { Dog d => ... } —— 模式穿透到 .Value

3.2 运行时支持:UnionAttribute + IUnion

.NET 11 Preview 5 开始在 BCL 中内置两个运行时类型:

namespace System.Runtime.CompilerServices;

[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = false)]
public sealed class UnionAttribute : Attribute;

public interface IUnion
{
    object? Value { get; }
}

IUnion 让你在运行时检查一个值是不是 union 类型:

if (value is IUnion { Value: null } u)
    Console.WriteLine("这是一个 Value 为 null 的 union");

3.3 默认形式的代价:值类型装箱

因为存储统一用 object?,struct case 在默认生成的 union 中会装箱

public union IntOrString(int, string);

var u = new IntOrString(42);  // int 42 被装箱为 object——堆分配!

装箱到底发生了什么?

一次装箱——运行时做三件事:

// int x = 42;
// object o = x;   ← 装箱

// 步骤 1: 堆分配——CLR 在 GC 堆上找空闲空间(~20-30 字节)  ~10-20 CPU 周期
// 步骤 2: 内存拷贝——把值从栈拷到堆(int 就 4 字节)         ~数 CPU 周期
// 步骤 3: GC 记账——这个 boxed 对象从此进入 GC 引用链         隐性成本——累积才显现
栈分配 (struct)装箱 (box)
指令数~1 (ESP -= sizeof)~50-100 (分配 + 拷贝 + 写入 MethodTable 指针)
单次耗时~1-2 CPU 周期~50-100 CPU 周期
GC 压力零——栈自动回收每个 boxed 对象进入 GC 引用链

单次装箱约是栈分配的 50-100 倍。但这不代表你的程序会慢 100 倍——关键在于调用频率。

什么时候你需要在乎?

调用频率典型场景建议
冷路径(< 1000 次/秒)HTTP 请求返回值、DB 查询结果包装、API 响应✅ 默认形式完全够用——一次请求本身 ~10ms + DB ~50ms,多花 0.0001ms 装箱是噪声
温路径(1000-10000 次/秒)UI 数据绑定、消息分发、事件处理⚠️ 通常仍可接受,但值得在 profiling 中看一眼 GC 占比
热路径(> 100000 次/秒)游戏循环、高频交易、实时数据流处理🔴 必须手写 tagged union(§4.3)——100 万次装箱 ≈ 100ms 纯分配 + 触发 GC Gen0

真正的杀手不是单次分配,是 GC

100 万个 boxed int ≈ 约 20-24 MB 堆分配。在 ~100ms 内触发多次 Gen0、一次 Gen1;如果持续分配,Gen2 也跑不掉:

GC 代触发条件暂停时间用户感知
Gen0分配达到 ~2MB 阈值0.1-2ms无感知
Gen1Gen0 幸存对象累积1-10ms可能感知
Gen2Gen1 幸存 → Gen2 满10-100ms+明显卡顿

Gen2 暂停超过 10ms 是人眼可感知的"卡顿"——这才是装箱在热路径上的真正代价。

一句话决策:90% 的场景直接写 public union X(int, string),别想太多。你什么时候担心过 List<int> 装箱?List<int> 是泛型所以不装箱——但日常业务里 objectint(拆箱)遍地都是,也没见谁因此重构系统。装箱在非热路径上是无害噪声。只有当 profiling 显示你的方法每秒构造数万次以上带值类型 case 的 union GC 占比显著时,再去翻 §4.3 手写 tagged union。先写对,再写快——这是工程纪律,不是免责声明。
这一点和 F# discriminated union 不同。F# DU 是 tagged union——编译器为每个 case 分配 tag + 字段的联合内存,值类型不装箱。C# 15 的默认形式选择了简单(统一 object? 存储)而非极致性能。未来版本可能会优化默认形式的存储策略。

四、自定义 Union——当你需要不同行为 Custom Union Types

4.1 为什么需要手写?

编译器生成的 union 总是 struct + object? 存储。但你可能需要:

4.2 手写 union 的最低要求

// 满足"基本 union 模式"的最低要求:
// ① [Union] attribute  ② 每个 case 一个单参数 public 构造  ③ 公开的 Value 属性

[System.Runtime.CompilerServices.Union]
public struct Shape : System.Runtime.CompilerServices.IUnion
{
    private readonly object? _value;

    public Shape(Circle value)  => _value = value;
    public Shape(Rectangle value) => _value = value;

    public object? Value => _value;
}

public record class Circle(double Radius);
public record class Rectangle(double Width, double Height);

// 使用——和编译器生成的 union 完全一样
Shape shape = new Shape(new Circle(5.0));
var area = shape switch
{
    Circle c => Math.PI * c.Radius * c.Radius,
    Rectangle r => r.Width * r.Height,
};

4.3 无装箱访问模式 Non-Boxing Access Pattern

这才是高性能 union 的精华——用 tagged union 布局,模式匹配不装箱:

[System.Runtime.CompilerServices.Union]
public struct IntOrBool : System.Runtime.CompilerServices.IUnion
{
    private readonly int _intValue;
    private readonly bool _boolValue;
    private readonly byte _tag;  // 0 = 无值, 1 = int, 2 = bool

    public IntOrBool(int? value)
    {
        if (value.HasValue) { _intValue = value.Value; _tag = 1; }
    }
    public IntOrBool(bool? value)
    {
        if (value.HasValue) { _boolValue = value.Value; _tag = 2; }
    }

    public object? Value => _tag switch
    {
        1 => _intValue,
        2 => _boolValue,
        _ => null
    };

    // HasValue —— 非装箱访问模式的关键
    public bool HasValue => _tag != 0;

    // 每个 case 一个 TryGetValue —— 编译器首选这个而非 Value
    public bool TryGetValue(out int value)
    {
        value = _intValue;
        return _tag == 1;
    }
    public bool TryGetValue(out bool value)
    {
        value = _boolValue;
        return _tag == 2;
    }
}

TryGetValue 存在时,编译器优先用它做模式匹配——值类型 case 不需要装箱,直接从 tag 判断类型,从对应字段读取值:

IntOrBool val = new IntOrBool((int?)42);

var desc = val switch
{
    int i => $"整数: {i}",      // 编译器用 TryGetValue(out int)——零装箱
    bool b => $"布尔: {b}",       // 编译器用 TryGetValue(out bool)——零装箱
};
日常使用建议:90% 的场景用编译器生成的简洁形式(一行 public union Pet(Cat, Dog, Bird);)。只有当你 profiling 发现装箱是瓶颈时,再手写无装箱版本。先写对,再写快。

4.4 class 版 union

// 如果需要引用语义(如作为 EF Core 的 owned entity)
[System.Runtime.CompilerServices.Union]
public class Result<T> : System.Runtime.CompilerServices.IUnion
{
    private readonly object? _value;

    public Result(T? value)         => _value = value;
    public Result(Exception? value) => _value = value;

    public object? Value => _value;
}

// 使用——模式匹配和 struct union 完全一样
Result<string> ok = new Result<string>("success");
Result<string> err = new Result<string>(new InvalidOperationException("failed"));

var msg = ok switch
{
    string s => $"OK: {s}",
    Exception e => $"Error: {e.Message}",
    null => "null",
};

五、Union vs 一切——选型指南 When to Use What

方案类型安全穷举检查无装箱简洁声明适用场景
Union(简洁形式)✅ 编译期❌ 引用类型天然无装箱;值类型会装箱✅ 一行日常业务逻辑、API 返回值、消息分发
Union(手写无装箱)✅ 编译期❌ ~40 行样板热路径、游戏循环、高频调用方法
继承层次⚠️ 运行时检查❌ 无类型需要外部扩展(不同程序集加子类)
enum + switch❌ 手动转换⚠️ 有警告但不强制只区分状态、不携带数据
OneOf 库⚠️ 部分❌ 无⚠️ 视实现不想升 .NET 11 的过渡方案
F# DU✅ 编译期F# 项目内部——C# 调用别扭
简单决策规则:如果"这组类型到此为止,以后不加了"→ union。如果"未来可能有新的子类型来自其他程序集"→ 继承层次 + closed(见下节课)。Union 的力量来自封闭——封闭让它能穷举。

六、三个实战场景 Real-World Examples

6.1 Result 模式——告别 "out 参数 + bool 返回"

public record class Success<T>(T Value);
public record class Error(string Message, Exception? Inner = null);

public union Result<T>(Success<T>, Error)
{
    // 方便构造的静态工厂
    public static Result<T> Ok(T value) => new Success<T>(value);
    public static Result<T> Fail(string msg, Exception? ex = null) => new Error(msg, ex);

    // 便捷解构
    public TOut Match<TOut>(Func<T, TOut> onSuccess, Func<Error, TOut> onError)
        => this switch
        {
            Success<T> s => onSuccess(s.Value),
            Error e => onError(e),
        };
}

// ──── 调用方 ────
Result<int> Divide(int a, int b)
    => b == 0
        ? Result<int>.Fail("除数不能为零")
        : Result<int>.Ok(a / b);

// 使用——编译器强迫你处理两种结果
var msg = Divide(10, 2).Match(
    onSuccess: val => $"结果: {val}",
    onError: err => $"失败: {err.Message}"
);
面试加分项:大多开发者还在用 bool TryDivide(int a, int b, out int result)(int? Result, string? Error)。你用 union 表达的 Result——类型安全、穷举检查、扩展新 case 自动亮警告——体现的是现代 C# 的设计思维。

6.2 消息/命令分发——新加消息自动提醒

public record class OrderPlaced(int OrderId, decimal Amount);
public record class PaymentReceived(int OrderId);
public record class OrderCancelled(int OrderId, string Reason);

public union OrderEvent(OrderPlaced, PaymentReceived, OrderCancelled);

// ──── 消息处理器 ────
void Handle(OrderEvent e)
{
    switch (e)
    {
        case OrderPlaced p:
            Console.WriteLine($"新订单 {p.OrderId},金额 {p.Amount}");
            break;
        case PaymentReceived r:
            Console.WriteLine($"订单 {r.OrderId} 已付款");
            break;
        case OrderCancelled c:
            Console.WriteLine($"订单 {c.OrderId} 已取消: {c.Reason}");
            break;
    }
}

// 🆕 六个月后,有人加了新消息类型:
// public record class OrderRefunded(int OrderId, decimal Amount);
// 改 union: public union OrderEvent(OrderPlaced, PaymentReceived, OrderCancelled, OrderRefunded);
// → 💡 Handle() 立刻亮编译警告——"你没处理 OrderRefunded!"

6.3 Option<T>——值可能存在也可能不存在

public record class None;
public record class Some<T>(T Value);

public union Option<T>(None, Some<T>)
{
    public static Option<T> From(T? value)
        => value is null ? new None() : new Some<T>(value);
}

// 比 nullable 更强力——引用类型也能有 "没有值" 的状态
var found = FindUser("alice");
var greeting = found switch
{
    Some<User> s => $"你好,{s.Value.Name}",
    None => "用户不存在",
};

七、当前限制与未来 Current Limitations & Roadmap

C# 15 还在 Preview——以下功能在提案中但目前尚未实现

未实现提案内容影响
Union 子类型关系如果 union A(B, C)union D(B, C, E),D 应可隐式转为 A(子集 → 超集)目前无法在不同 union 间转换
泛型约束上的 unionwhere T : union 限制 T 必须是 union 类型无法写泛型 union 工具函数
纯值类型存储编译器自动为 struct case 生成无装箱 tagged union 布局值类型 case 默认装箱——见 §3.3
默认穷举即使不用 switch 表达式,普通 if/else 也能触发穷举警告目前只有 switch 表达式有穷举检查
使用 Preview 的代价:安装 .NET 11 Preview SDK,项目里设 <LangVersion>preview</LangVersion>。GA 前 API 可能微小调整。生产项目建议等 2026.11 GA 后再引入。

八、测验 Quiz

1/5 · Union 是什么

C# 15 的 union 编译器生成的是哪种底层类型?

2/5 · 穷举检查

以下关于 union 穷举检查的说法,哪个是正确的?

3/5 · 透明解包

以下哪个模式不会穿透到 .Value,而是作用在 union 值本身?

4/5 · 值类型装箱

使用编译器生成的简洁 union 形式,public union IntOrString(int, string); 创建 new IntOrString(42) 时发生了什么?

5/5 · 综合判断

关于 C# 15 union,以下哪个说法正确

九、总结 Summary

要点一句话
声明public union Pet(Cat, Dog, Bird); —— 一行
隐式转换每个 case 类型自动能赋值给 union——不需要包装
穷举检查switch 覆盖所有 case = 通过;加新 case = 全部 switch 亮警告
透明解包模式匹配默认穿透到 .Value——你感觉在直接匹配 case 类型
编译器降低struct + [Union] + IUnion + 每个 case 一个构造/隐式转换
默认存储object? —— struct case 会装箱;用自定义 union 可避免
手写 union满足基本 union 模式即可——编译器识别 [Union] + 单参数构造 + Value
状态.NET 11 Preview 5——GA 预计 2026.11
最值得记住的一件事:Union 让你把"这组类型到此为止"写进代码——编译器帮你盯着。加一个新 case 而忘记更新某个 switch?编译失败。这在继承层次中是一个静默的运行时 bug。这是 union 存在的终极理由。

十、下一步 Next: Closed Hierarchies

Union 解决的是"一组不相关的类型,值必须是其中之一"。但如果你的类型天然有继承关系(基类 + 派生类),你想获得同样的穷举检查怎么办?C# 15 的 Closed Hierarchiesclosed class)就是答案——下节课。

🔗 Closed Hierarchies

closed class —— 继承关系中的穷举检查。Natural complement to unions.

📦 Collection Expression Args

[with(capacity: 100), .. items] —— 集合初始化小改进。

🔒 Memory Safety

声明指针不再需要 unsafe 块——多版本演进第一步。

← L24: C# 14 小特性合集 · L26: Closed Hierarchies → · 🎯 回顾 Mission