.NET 11 Preview · 等了十年的 discriminated union · 编译期穷举检查 · 告别"枚举+switch"
编程中最常见的数据建模难题之一:一个值恰好是几种类型之一。
| 场景 | 可能的类型 | 你现在的做法 |
|---|---|---|
| 方法返回值 | 成功值 或 错误 | object? + is 检查⋯⋯编译器不帮你 |
| 消息分发 | OrderPlaced / PaymentReceived / ... | 继承层次 + switch——新增消息类型不警告 |
| AST 节点 | Literal / BinaryExpr / Variable / ... | 抽象基类——穷举检查全靠纪律 |
C# 过去有五种"绕路"——每一种都有真实痛点:
丢失类型信息——case 下面要强制转换。enum 值范围由你负责——忘记处理新值不会有警告。
正确的面向对象方案——但不是封闭的。新人加了一个子类,分散在 47 个 switch 里都不会亮警告。
编译器完全不帮你。类型安全全靠开发者记忆。if (x is string) ... else if (x is int) ... else throw 是样板地狱。
NuGet 上有 OneOf 包——但类型名不自然(OneOf<T0, T1>),没有编译期穷举检查,没有模式匹配集成。
F# 从 1.0 就有。在 F# 项目中用——但 C# 调用 F# union 很别扭,且不能直接在 C# 里定义。
x = 3 + y * 2 解析后是一棵由 Assignment、Variable、BinaryExpr、Literal 等不同节点类型组成的树。每个节点恰好是几种类型之一——这是 discriminated union 在编程语言领域最经典的应用场景。F#、Rust、Haskell、TypeScript 的编译器全这么写;C# 自己的 Roslyn 编译器也用继承层次建模 AST 节点。
核心矛盾:C# 缺少一个"这组类型到此为止——编译期帮我盯着"的语言构造。C# 15 回答了这个问题。
// 声明 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 而不是 record?public record class Cat(string Name) 和 public record Cat(string Name) 完全等价——前者是 C# 10 引入 record struct 后的显式写法。本课统一使用 record class 是意图明确的编码风格:在 union 语境中,case 是 class 还是 struct 直接影响存储策略(是否装箱,见 §3.3)。读者不需要猜。
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
new Pet(dog) 包装——编译器替你做了,就像 int[] 不需要 .AsSpan() 一样。
几乎任何能转为 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);
object 的类型都能放进去。public union IntOrString(int, string) 就是 struct + class 混用的例子。唯一的代价是 struct case 默认会装箱(见 §3.3),热路径上需要手写 tagged union(见 §4.3)。
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);
}
Value 属性管理。
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 的处理——这就是穷举安全的含义
InvalidOperationException。Union 让你在编译期就知道所有遗漏。
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 |
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
int),default 构造的 union 其 .Value 也是 null——default union 不代表任何 case。你要么用 null 分支处理,要么确保 union 总是被赋有效值。
这是理解 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 |
.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");
因为存储统一用 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 |
100 万个 boxed int ≈ 约 20-24 MB 堆分配。在 ~100ms 内触发多次 Gen0、一次 Gen1;如果持续分配,Gen2 也跑不掉:
| GC 代 | 触发条件 | 暂停时间 | 用户感知 |
|---|---|---|---|
| Gen0 | 分配达到 ~2MB 阈值 | 0.1-2ms | 无感知 |
| Gen1 | Gen0 幸存对象累积 | 1-10ms | 可能感知 |
| Gen2 | Gen1 幸存 → Gen2 满 | 10-100ms+ | 明显卡顿 |
Gen2 暂停超过 10ms 是人眼可感知的"卡顿"——这才是装箱在热路径上的真正代价。
public union X(int, string),别想太多。你什么时候担心过 List<int> 装箱?List<int> 是泛型所以不装箱——但日常业务里 object 转 int(拆箱)遍地都是,也没见谁因此重构系统。装箱在非热路径上是无害噪声。只有当 profiling 显示你的方法每秒构造数万次以上带值类型 case 的 union 且 GC 占比显著时,再去翻 §4.3 手写 tagged union。先写对,再写快——这是工程纪律,不是免责声明。
编译器生成的 union 总是 struct + object? 存储。但你可能需要:
// 满足"基本 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,
};
这才是高性能 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)——零装箱
};
public union Pet(Cat, Dog, Bird);)。只有当你 profiling 发现装箱是瓶颈时,再手写无装箱版本。先写对,再写快。
// 如果需要引用语义(如作为 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(简洁形式) | ✅ | ✅ 编译期 | ❌ 引用类型天然无装箱;值类型会装箱 | ✅ 一行 | 日常业务逻辑、API 返回值、消息分发 |
| Union(手写无装箱) | ✅ | ✅ 编译期 | ✅ | ❌ ~40 行样板 | 热路径、游戏循环、高频调用方法 |
| 继承层次 | ⚠️ 运行时检查 | ❌ 无 | ✅ | ✅ | 类型需要外部扩展(不同程序集加子类) |
| enum + switch | ❌ 手动转换 | ⚠️ 有警告但不强制 | ✅ | ✅ | 只区分状态、不携带数据 |
| OneOf 库 | ⚠️ 部分 | ❌ 无 | ⚠️ 视实现 | ✅ | 不想升 .NET 11 的过渡方案 |
| F# DU | ✅ | ✅ 编译期 | ✅ | ✅ | F# 项目内部——C# 调用别扭 |
closed(见下节课)。Union 的力量来自封闭——封闭让它能穷举。
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# 的设计思维。
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!"
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 => "用户不存在",
};
C# 15 还在 Preview——以下功能在提案中但目前尚未实现:
| 未实现 | 提案内容 | 影响 |
|---|---|---|
| Union 子类型关系 | 如果 union A(B, C) 且 union D(B, C, E),D 应可隐式转为 A(子集 → 超集) | 目前无法在不同 union 间转换 |
| 泛型约束上的 union | where T : union 限制 T 必须是 union 类型 | 无法写泛型 union 工具函数 |
| 纯值类型存储 | 编译器自动为 struct case 生成无装箱 tagged union 布局 | 值类型 case 默认装箱——见 §3.3 |
| 默认穷举 | 即使不用 switch 表达式,普通 if/else 也能触发穷举警告 | 目前只有 switch 表达式有穷举检查 |
<LangVersion>preview</LangVersion>。GA 前 API 可能微小调整。生产项目建议等 2026.11 GA 后再引入。
C# 15 的 union 编译器生成的是哪种底层类型?
以下关于 union 穷举检查的说法,哪个是正确的?
以下哪个模式不会穿透到 .Value,而是作用在 union 值本身?
使用编译器生成的简洁 union 形式,public union IntOrString(int, string); 创建 new IntOrString(42) 时发生了什么?
关于 C# 15 union,以下哪个说法正确?
| 要点 | 一句话 |
|---|---|
| 声明 | 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 解决的是"一组不相关的类型,值必须是其中之一"。但如果你的类型天然有继承关系(基类 + 派生类),你想获得同样的穷举检查怎么办?C# 15 的 Closed Hierarchies(closed class)就是答案——下节课。
closed class —— 继承关系中的穷举检查。Natural complement to unions.
[with(capacity: 100), .. items] —— 集合初始化小改进。
声明指针不再需要 unsafe 块——多版本演进第一步。
← L24: C# 14 小特性合集 · L26: Closed Hierarchies → · 🎯 回顾 Mission