Skip to content
Go back

C# 15 的 Union 类型:编译器帮你管住多类型变量

C# 长期以来缺少一个”这个变量只能是这几种类型之一”的原生表达方式。现在从 .NET 11 Preview 2 开始,C# 15 带来了 union 关键字,正式填上这个空缺。

之前的写法有什么问题

当一个方法需要返回几种可能的类型时,C# 以前有几条路:

Union 类型解决这些问题的方式很直接:声明一个封闭的 case 类型集合,类型之间不需要任何亲缘关系,集合之外的类型根本加不进来,编译器对 switch 表达式执行穷举检查,覆盖所有 case 才算完整。

基础语法

最简单的声明长这样:

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 是一个新类型,变量可以持有 CatDogBird 中的任意一个。编译器从每种 case 类型到 union 类型提供隐式转换,直接赋值就行:

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 不认识的类型,编译器直接报错。

对一个确定非空的 union 实例使用 switch 表达式,覆盖所有 case 类型就能满足穷举要求,不需要 discard _ 或 default 分支:

string name = pet switch
{
    Dog d => d.Name,
    Cat c => c.Name,
    Bird b => b.Name,
};

模式匹配作用于 union 的 Value 属性,这个”解包”是自动的——你写 Dog d,编译器替你检查 Valuevar_ 是两个例外,它们匹配 union 值本身。

如果之后给 Pet 增加第四种 case 类型,所有没有处理它的 switch 表达式都会产生编译器警告。这是 union 类型的核心价值之一:漏掉的 case 在构建期就暴露,不用等到运行时。

null 的处理

union 的默认值里 Value 是 null。如果 case 类型里有可空类型(比如 int?Bird?),所有对该 Pet 实例的 switch 表达式都需要一个 null 分支:

Pet pet = default;

var description = pet switch
{
    Dog d => d.Name,
    Cat c => c.Name,
    Bird b => b.Name,
    null => "no pet",
};
// description is "no pet"

实际场景:OneOrMore<T>

有些 API 既接受单个值,也接受集合。Union 可以带 body,在其中放辅助成员,就像给普通类型加方法一样:

public union OneOrMore<T>(T, IEnumerable<T>)
{
    public IEnumerable<T> AsEnumerable() => Value switch
    {
        T single => [single],
        IEnumerable<T> multiple => multiple,
        null => []
    };
}

AsEnumerable() 必须处理 null case——Value 属性的默认 null 状态是 maybe-null,这是为联合类型的数组或默认值场景提供正确警告所必需的规则。

调用方只管传哪种形式方便,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,每种 case 类型对应一个构造函数,Value 属性类型是 object?,值类型会装箱存储。大多数场景这样就够了。

但有些社区库已经有自己的 union 类实现,并且有特定的存储策略。这些库不需要改用 union 语法,只要给类或 struct 加 [System.Runtime.CompilerServices.Union] 特性,再满足基本约定——一个或多个公共的单参数构造函数(定义 case 类型)加一个公共 Value 属性——编译器就会把它识别为 union 类型。

对于含值类型 case 的性能敏感场景,库还可以实现无装箱访问模式:添加 HasValue 属性和 TryGetValue 方法,让编译器在做模式匹配时避开装箱。完整细节见 union 类型语言参考

配套提案:封闭层级与封闭枚举

Union 类型解决的是”对一组封闭类型进行穷举匹配”,与之相关的还有两个在规划中的提案:

这三个特性合在一起,构成 C# 完整的穷举性故事:union 针对封闭类型集合,closed hierarchies 针对密封类层级,closed enums 针对固定枚举值。后两个提案目前还未承诺进入某个具体版本,欢迎参与讨论和设计。

如何现在就试用

Union 类型从 .NET 11 Preview 2 开始可用,步骤:

  1. 下载安装 .NET 11 Preview SDK
  2. 创建或更新项目,目标框架设为 net11.0
  3. 在项目文件中加入 <LangVersion>preview</LangVersion>

早期预览版的注意事项:.NET 11 Preview 2 的运行时里还没有 UnionAttributeIUnion 接口,需要自己在项目里声明,或者从文档仓库拉取 RuntimePolyfill.cs

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}",
});

proposal specification 里的部分特性尚未实现(如 union 成员提供者),会在后续预览版中跟进。IDE 支持将在下一个 Visual Studio Insiders 版本中到位,当前的 C# DevKit Insiders 已包含支持。

试用之后,可以在 GitHub 上的 unions 讨论贴分享反馈,这个设计还在定稿过程中。

参考


Tags


Previous

把 LLM 当知识库编辑:Karpathy 的个人研究工作流

Next

EF Core 中的规格模式:告别 Repository 臃肿,实现灵活可复用的数据查询