原文链接:https://devblogs.microsoft.com/dotnet/csharp-15-union-types/
Union 类型一直是 C# 社区呼声最高的功能之一,如今它终于来了。从 .NET 11 Preview 2 开始,C# 15 引入了 union 关键字。union 关键字声明一个值恰好是某个固定类型集合中的一种,并由编译器强制执行穷尽模式匹配。如果你曾在 F# 中用过 discriminated unions,或在其他语言中接触过类似特性,会有似曾相识的感觉。不过 C# 的 union 专为 C# 原生体验而设计:它们是类型联合(type union),将已有类型组合在一起,融入你已经熟悉的模式匹配体系,与语言的其余部分无缝协作。
什么是 union 类型?
在 C# 15 之前,当一个方法需要返回几种可能类型之一时,你只能选择一些并不完美的方案。使用 object 对实际存储的类型没有任何约束——任何类型都可能出现在那里,调用方不得不为意外值编写防御性逻辑。接口和抽象基类标记稍好一些,因为它们限制了类型的范围,但依然无法"封闭"——任何人都可以实现这个接口或继承这个基类,编译器永远无法认为这个集合是完整的。而且这两种方案都要求类型之间存在共同祖先,当你想要组合 string 和 Exception,或者 int 和 IEnumerable<T> 这样没有亲缘关系的类型时就无能为力了。
Union 类型解决了这些问题。一个 union 声明了一个封闭的类型集合——这些类型之间不需要有任何关联,也不能再添加其他类型。编译器保证处理该 union 的 switch 表达式必须穷尽所有类可能类型,无需丢弃模式 _ 或 default 分支。但这还不止于此:union 能够实现传统类层次结构无法表达的设计,将任意现有类型的组合包装成一个经编译器验证的合约。
最简单的声明如下:
public record class Cat(string Name);
public record class Dog(string Name);
public record class Bird(string Name);
public union Pet(Cat, Dog, Bird);这一行声明将 Pet 定义为一个新类型,其变量可以持有 Cat、Dog 或 Bird。编译器为每个类型提供隐式转换,因此可以直接赋值:
Pet pet = new Dog("Rex");
Console.WriteLine(pet.Value); // Dog { Name = Rex }
Pet pet2 = new Cat("Whiskers");
Console.WriteLine(pet2.Value); // Cat { Name = Whiskers }如果你把一个不属于可能类型的实例赋值给 Pet 对象,编译器会报错。
当你使用一个已知不为 null 的 union 类型实例时,编译器了解完整的可能类型集合,因此覆盖所有 case 的 switch 表达式即视为穷尽——无需丢弃模式:
string name = pet switch
{
Dog d => d.Name,
Cat c => c.Name,
Bird b => b.Name,
};Dog、Cat 和 Bird 都是不可空类型。pet 变量在前面的代码片段中已被赋值,因此可以确定它不为 null,这个 switch 表达式也就无需检查 null。但如果任何类型是可空的,例如 int? 或 Bird?,那么针对 Pet 实例的所有 switch 表达式都需要一个 null 分支才算穷尽。如果你后续给 Pet 添加了第四个类型,每一处未处理它的 switch 表达式都会产生编译器警告。这正是核心价值之一:编译期就能发现遗漏的分支,而不是等到运行时。
模式匹配作用于 union 的 Value 属性,而非 union struct 本身。这种"解包"是自动完成的——你写 Dog d,编译器会替你检查 Value。唯有 var 和 _ 例外,它们作用于 union 值本身,用于捕获或忽略整个 union。
对于 union 类型,null 模式检查的是 Value 是否为 null。union struct 的 default 值其 Value 为 null:
Pet pet = default;
var description = pet switch
{
Dog d => d.Name,
Cat c => c.Name,
Bird b => b.Name,
null => "no pet",
};
// description 为 "no pet"Pet 示例展示了基本语法。下面让我们来看看 union 类型的实际应用场景。
OneOrMore<T> —— 单个值或集合
API 有时需要同时接受单个元素或一个集合。带有主体的 union 允许你在所有类型之外添加辅助成员。OneOrMore<T> 声明在 union 主体中直接包含了一个 AsEnumerable() 方法——就像给任何类型添加方法一样:
public union OneOrMore<T>(T, IEnumerable<T>)
{
public IEnumerable<T> AsEnumerable() => Value switch
{
T single => [single],
IEnumerable<T> multiple => multiple,
null => []
};
}注意 AsEnumerable 方法必须处理 Value 为 null 的情况。这是因为 Value 属性的默认 null 状态是可能为 null。这条规则是为了在 union 类型数组或 union struct 默认值的场景下提供正确的警告。
调用方传入任何方便的形式,AsEnumerable() 会将其统一处理:
OneOrMore<string> tags = "dotnet";
OneOrMore<string> moreTags = new[] { "csharp", "unions", "preview" };
foreach (var tag in tags.AsEnumerable())
Console.Write($"[{tag}] ");
// [dotnet]
foreach (var tag in moreTags.AsEnumerable())
Console.Write($"[{tag}] ");
// [csharp] [unions] [preview]面向现有库的自定义 union
union 声明是一种有主见的简写形式。编译器会生成一个 struct,其中包含每个类型对应的构造函数,以及一个类型为 object? 的 Value 属性用于持有底层值。这些构造函数使得从任意类型到 union 类型的隐式转换成为可能。union 实例始终将内容存储为单个 object? 引用,对值类型进行装箱(boxing)。这种方式能够干净地覆盖绝大多数使用场景。
然而,社区中已有不少库提供了类似 union 的类型,并采用了自己的存储策略。这些库无需切换到 union 语法也能享受 C# 15 带来的好处。任何带有 [System.Runtime.CompilerServices.Union] 特性的类或结构体,只要遵循基本 union 模式——一个或多个公共单参数构造函数(定义 case 类型)以及一个公共 Value 属性——就会被识别为 union 类型。
对于类型包含值类型的性能敏感场景,库还可以通过添加 HasValue 属性和 TryGetValue 方法来实现非装箱访问模式,让编译器在模式匹配时无需装箱即可完成。
关于创建自定义 union 类型和非装箱访问模式的完整详情,请参阅 union 类型语言参考文档[1]。
相关提案
Union 类型为你提供了一种可以持有封闭类型集合之一的类型。另外两个提案为类型层次结构和枚举提供了相关功能。你可以通过阅读功能规范来了解这两个提案及其与 union 的关系:
• 封闭层次结构(Closed hierarchies)[2]:在类上使用 closed修饰符,可防止在定义程序集之外声明派生类。• 封闭枚举(Closed enums)[3]: closed枚举防止创建已声明成员之外的值。
这三个特性共同为 C# 构建了完整的穷尽性方案:
• Union 类型 —— 对封闭类型集合的穷尽匹配 • 封闭层次结构 —— 对密封类层次结构的穷尽匹配 • 封闭枚举 —— 对固定枚举值集合的穷尽匹配
Union 类型现已以 Preview 形式提供。在评估它们时,请将这份更宏观的路线图纳入考量。这些提案目前仍在积极推进中,但尚未正式承诺发布时间。欢迎加入讨论,共同参与设计与实现。
亲自试用
Union 类型从 .NET 11 Preview 2 开始提供。上手步骤如下:
1. 安装 .NET 11 Preview SDK[4]。 2. 创建或更新一个以 net11.0为目标框架的项目。3. 在项目文件中设置 <LangVersion>preview</LangVersion>。
Visual Studio 中的 IDE 支持将在下一个 Visual Studio Insiders 构建中提供,最新的 C# DevKit Insiders 构建中已包含此支持。
早期预览:需自行声明运行时类型
在 .NET 11 Preview 2 中,UnionAttribute 和 IUnion 接口尚未包含在运行时中,需要在项目中手动声明。后续的 Preview 版本会将这些运行时类型纳入。
请将以下代码添加到你的项目(或从文档仓库获取 RuntimePolyfill.cs[5]):
namespace System.Runtime.CompilerServices
{
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct,
AllowMultiple = false)]
public sealed class UnionAttribute : Attribute;
public interface IUnion
{
object? Value { get; }
}
}完成上述步骤后,即可声明和使用 union 类型:
public record class Cat(string Name);
public record class Dog(string Name);
public union Pet(Cat, Dog);
Pet pet = new Cat("Whiskers");
Console.WriteLine(pet switch
{
Cat c => $"Cat: {c.Name}",
Dog d => $"Dog: {d.Name}",
});完整提案规范[6]中的部分功能尚未实现,包括 union member providers,这些将在后续 Preview 中陆续落地。
分享你的反馈
Union 类型目前处于 Preview 阶段,你的反馈将直接影响最终设计。请在你的项目中尝试它们,探索边界情况,告诉我们哪些地方好用、哪些地方还需改进。
在 GitHub 上参与 union 类型讨论[7]