C# 的类型系统在过去几年经历了显著演进。从 C# 8.0 引入的可空引用类型(Nullable Reference Types)到 C# 9.0 的记录类型(Records),语言设计团队一直在增强类型安全性和表达能力。然而,与 F#、Rust 等语言相比,C# 长期缺乏原生的 Discriminated Unions(可辨识联合类型)支持,这迫使开发者通过多种模式来模拟这一功能。本文将深入分析当前 C# 模拟 Union Types 的技术方案、编译器层面的实现挑战,以及未来可能引入的原生支持。
当前实现方案的技术对比
在缺乏原生支持的情况下,C# 社区发展出了两种主流的 Union Types 模拟方案:基于继承的密封记录层次结构,以及基于泛型结构的库方案。
方案一:Sealed Record Hierarchies
利用 C# 9.0 引入的 record 类型和模式匹配,开发者可以构建一个抽象基类配合密封派生类的层次结构:
abstract record Vehicle;
record Car(string Model, int Year) : Vehicle;
record Truck(string Model, int Year, float Payload) : Vehicle;
record Bicycle(string Model) : Vehicle;
这种方案的优势在于语法简洁,与 C# 现有的面向对象特性无缝集成。然而,它存在一个根本性的缺陷:C# 的类型系统无法强制要求模式匹配的穷尽性(exhaustiveness)。当使用 switch 表达式处理 Vehicle 类型时,如果遗漏了某个派生类型的处理分支,编译器不会发出警告,除非显式添加丢弃模式 _ =>。这与 F# 等语言的编译器行为形成鲜明对比 —— 后者能够在编译期检测未处理的联合类型分支。
方案二:OneOf<> 泛型结构
OneOf 库采用了一种截然不同的实现策略。它通过定义包含 N 个泛型字段的 struct 来存储联合类型的各个可能值:
public readonly struct OneOf<T0, T1, T2>
{
readonly T0 _value0;
readonly T1 _value1;
readonly T2 _value2;
readonly int _index;
// ...
}
这种设计的核心优势在于强制穷尽处理。Match 方法要求调用者为所有可能的类型提供处理函数,任何类型变更都会导致编译错误,从而确保代码的健壮性。此外,由于使用值类型而非引用类型,该方案避免了堆内存分配,减轻了 GC 压力。
然而,这种实现也带来了性能权衡。当联合类型包含较多分支时,结构体的大小会相应增长,导致值拷贝开销增加。根据 NDepend 的技术分析,OneOf 在处理值类型时避免了装箱操作,这一点优于 F# 编译器生成的 IL 代码 —— 后者在将值类型纳入联合类型时会生成包装类。
编译器层面的核心挑战
实现原生的 Union Types 支持并非简单的语法糖添加,而是涉及类型系统、运行时和内存模型的深层设计。
穷尽检查的语义分析
Discriminated Unions 的核心价值之一是编译期穷尽检查。要实现这一点,编译器需要维护一个 "封闭类型集合" 的语义概念 —— 即明确知道某个联合类型包含的确切分支集合。C# 当前的类型系统基于开放继承原则,任何类都可以被继承(除非标记为 sealed)。引入 Union Types 意味着需要新的类型约束机制,确保联合类型的分支集合在定义时即被固定。
值类型的内存布局优化
C# 团队负责人 Mads Torgersen 在 2023 年 9 月的技术讨论中提出了一个关键问题:如何在运行时高效表示 Union Types?与 OneOf 库的多字段结构不同,提案考虑使用单一 object 字段配合少量内联存储空间(如 8 字节)来处理小型值类型。这种设计避免了每次传递 Union 时的多字段拷贝,同时减少了值类型的装箱操作。ValuePrototype 项目正在探索这种混合存储方案的可行性。
与现有类型系统的兼容性
Union Types 需要与 C# 现有的可空引用类型、泛型、协变 / 逆变等特性协同工作。特别是当涉及到 null 值的处理时,类型系统需要明确区分 "联合类型的某个分支为 null" 与 "联合类型本身为 null" 的语义差异。
Type Union 提案的设计方向
根据目前公开的语言提案,C# 的 Union Types 将采用 union 关键字,支持结构体和类两种形式:
public union struct Result<TValue>
{
Success(TValue value);
Failure(ErrorCode errorCode);
}
这种设计允许开发者定义封闭的类型联合,编译器将自动生成必要的匹配方法和类型检查。与 F# 的 Discriminated Unions 不同,C# 的提案使用隐式鉴别器 —— 编译器通过类型信息而非显式标签来区分联合的分支。
提案还考虑了 Ad-hoc Unions(临时联合类型),允许在类型签名中直接声明联合关系,如 string | int,这对于 API 设计和错误处理场景尤为有用。
工程实践建议
在原生 Union Types 支持到来之前,开发者可以根据具体场景选择合适的模拟方案:
优先使用 OneOf 库的场景:
- 需要强制穷尽检查的业务逻辑(如状态机、结果处理)
- 性能敏感的代码路径(利用值类型避免 GC)
- 领域模型中稳定的联合类型定义
优先使用 Sealed Records 的场景:
- 需要与现有面向对象代码深度集成
- 联合类型分支可能频繁扩展
- 团队对函数式编程模式不熟悉
迁移策略: 考虑到 Type Union 提案仍处于设计阶段,建议将 Union Types 的模拟实现封装在领域层内部,避免在公共 API 中暴露具体实现细节。这样可以在原生支持到来时平滑迁移,只需替换内部实现而无需修改接口契约。
结语
C# Union Types 的演进反映了语言设计团队在类型安全、性能和使用便利性之间的持续权衡。从 OneOf 库的工程实践到 Type Union 提案的语言级支持,这一特性正在逐步成熟。对于开发者而言,理解现有模拟方案的实现机制与局限性,有助于在原生支持到来前做出合理的技术选型,并为未来的迁移做好准备。
参考来源
- NDepend Blog: "C# Discriminated Union: What's Driving the C# Community's Inquiries?" (2024)
- Peter Ritchie's Blog: "What Are the Proposed C# Type Unions and How Do They Relate to Discriminated Unions"
内容声明:本文无广告投放、无付费植入。
如有事实性问题,欢迎发送勘误至 i@hotdrydog.com。