前言
Union 类型(联合类型)一直是 C# 社区呼声最高的特性之一。从最初的 discriminated unions 提案到今天,这个特性经历了多年的设计和讨论,终于在 C# 15 中正式落地。
Union 类型允许我们将一个值限定为一组封闭类型中的某一种,并且在针对 Union 值的 switch 表达式中获得穷尽性检查。编译器能帮你确认是否处理了所有 case 类型,很多时候就不再需要那个烦人的 _ 兜底分支。
本文将介绍 C# 15 中 Union 类型的设计和用法。
从一个实际问题出发
假设我们要实现一个函数,它可能返回一个正常的结果,也可能返回一个错误。以前常见的做法是定义一个包装类:
csharp
public class Result<T>
{
public T? Data { get; set; }
public Exception? Error { get; set; }
public bool IsSuccess => Error is null;
}
这种写法有一个很明显的问题:Data 和 Error 在类型上同时存在,编译器没法保证「成功时一定有 Data」或「失败时一定有 Error」。正确性全靠人为约定,而不是类型系统来保障。
有了 Union 类型,这个问题就迎刃而解了。
Union 声明
C# 15 引入了全新的 union 关键字,可以用非常简洁的语法声明一个联合类型:
csharp
public union Pet(Cat, Dog, Bird);
就是这么简单!这一行声明了一个名为 Pet 的联合类型,它的值可以是 Cat、Dog 或 Bird 中的任何一种。
Union 声明会被编译器展开为一个结构体,内部用单个 object 引用来存储值:
csharp
// 编译器生成的等价代码
[Union] public struct Pet : IUnion
{
public Pet(Cat value) => Value = value;
public Pet(Dog value) => Value = value;
public Pet(Bird value) => Value = value;
public object? Value { get; }
}
也就是说,Union 声明就是一种简洁的结构体声明方式,编译器帮你生成了所有样板代码。
再来看一个更实用的例子,利用 Union 和已有类型组合来实现 Option<T>:
csharp
public record class None();
public record class Some<T>(T Value);
public union Option<T>(None, Some<T>);
还可以在 Union 中添加自定义方法:
csharp
public union OneOrMore<T>(T, IEnumerable<T>)
{
public IEnumerable<T> AsEnumerable() => Value switch
{
IEnumerable<T> list => list,
T value => [value],
};
}
这也很方便!
另外,case 类型并不只限于这里展示的具体类。按照提案,它们还可以是接口、类型参数、可空类型,甚至其他 Union,而且 case 之间允许重叠。
不过,Union 声明本身是一种有意"收紧"的声明形式。你可以添加方法之类的成员,但不能声明实例字段、自动属性或类字段事件;你也不能自己声明 public 的单参数构造函数,而你显式添加的构造函数必须通过 this(...) 委托到编译器生成的 case 构造函数之一。
Union 转换
Union 类型支持从每个 case 类型到联合类型的隐式转换:
csharp
Cat cat = new Cat("小花");
Pet pet = cat; // 隐式 union 转换,不需要显式构造
编译器会将其转换为对构造函数的调用:
csharp
// 编译器实际生成的代码
Pet pet = new Pet(cat);
这意味着你不需要手动去包装值,直接赋值就行了。如果你之前有自定义的隐式转换运算符,那它的优先级会高于 union 转换,所以现有代码不会受到影响。
这里还有一个容易忽略的点:Union 转换只有隐式形式。即使某个 case 类型存在显式转换,也不代表因此就自动拥有到整个 Union 类型的显式转换。
Union 匹配
Union 类型真正的威力在于和模式匹配的配合。
当你对一个 Union 值进行模式匹配时,编译器会自动拆包内部的值:
csharp
Pet pet = GetPet();
if (pet is Dog dog)
{
// dog 已经是 Dog 类型,直接使用
dog.Bark();
}
// switch 表达式
string description = pet switch
{
Dog dog => $"这是一只狗:{dog.Name}",
Cat cat => $"这是一只猫:{cat.Name}",
Bird bird => $"这是一只鸟:{bird.Name}",
};
注意最后一个分支后面没有 _ 兜底!因为这是一个针对 Union 值的 switch 表达式,而编译器也知道 Pet 的 case 类型只有 Dog、Cat 和 Bird,所以它可以把这个表达式视为穷尽的。
这确实是一个非常实用的功能,不仅简化了代码,还在编译期帮你保证了安全性。假如以后你给 Pet 增加了一个新的 case 类型 Fish,那么所有没有处理 Fish 的 switch 表达式都会产生编译警告,避免了遗漏。
对于无条件的 var 和 _ 模式,匹配的是 Union 值本身而不是内部值:
csharp
if (pet is var p) { ... } // p 是 Pet 类型,不是 object
这是有意为之。var 通常只是给当前值起个名字,保留 Union 类型比解出一个 object? 更实用。
这也意味着,pet is Pet p 并不等同于 pet is var p。在 Pet p 这样的类型模式里,Pet 会作用在拆包后的内部值上,而不是外层的 Union 值本身,所以这个模式通常不会成功。
null 模式还有一个值得专门提醒的细节。对于基于 class 的 Union,result is null 在两种情况下都会成功:Union 对象本身是 null,或者它内部的 Value 是 null。对于 U? 这种"nullable 包裹 struct Union"的情况也类似:如果外层 nullable 没有值,或者内部 Union 的 Value 为 null,那么 u is null 都会成功。相对地,其他 Union 匹配模式只有在外层值本身存在时才会成功。
Union 穷尽性
刚才提到了穷尽性检查,这是 Union 类型最重要的能力之一。
csharp
union Result(int, string, Exception);
string Describe(Result r) => r switch
{
int n => $"数字:{n}",
string s => $"字符串:{s}",
Exception e => $"错误:{e.Message}",
// 编译器认为已穷尽,无需 _ 分支
};
但如果 Union 的值可能为 null(例如某个 case 类型是可空的),编译器会要求你处理 null 的情况:
csharp
Pet pet = GetNullableDog(); // pet.Value 可能是 null
var result = pet switch
{
Dog dog => "汪",
Cat cat => "喵",
Bird bird => "啾",
// 警告:未处理 null
};
手动实现 Union 模式
Union 声明虽然方便,但并不是获得 Union 行为的唯一方式。你完全可以在已有类型上手动实现 Union 模式,只需满足以下条件:
- 类型标记
[Union]属性 - 提供对应每个 case 类型的单参数构造函数
- 提供一个
object?类型的Value属性
csharp
[Union]
public struct IntOrString
{
private readonly object _value;
public IntOrString(int value) => _value = value;
public IntOrString(string value) => _value = value;
public object? Value => _value;
}
这在需要适配已有类型,或者需要自定义存储策略时非常有用。
Union 成员提供者(IUnionMembers)
默认情况下,编译器通过 Union 类型自身的构造函数来识别 case 类型。但有些场景下你可能不想暴露公开构造函数,或者想用工厂方法来创建 Union 值。这时可以在 Union 类型内部声明一个名为 IUnionMembers 的接口,让它充当成员提供者。
csharp
[Union]
public record class Result<T> : Result<T>.IUnionMembers
{
object? _value;
public interface IUnionMembers
{
public static Result<T> Create(T value) => new() { _value = value };
public static Result<T> Create(Exception value) => new() { _value = value };
public object? Value { get; }
}
object? IUnionMembers.Value => _value;
}
当 Union 类型内部包含 IUnionMembers 接口声明时,编译器就不再从 Union 类型本身查找构造函数了,而是从这个接口上的 Create 工厂方法来确定 case 类型。Value 属性也改为在接口上声明。
这种模式有几个好处:
- 可以对外隐藏构造函数,只通过工厂方法创建实例
- 适合
class类型的 Union(Union 声明默认生成的是struct) - 可以灵活控制内部存储和初始化逻辑
使用时,隐式转换会自动走工厂方法:
csharp
Result<string> result = "Hello";
// 等价于
Result<string> result = Result<string>.IUnionMembers.Create("Hello");
Non-boxing 访问模式
默认的 Union 模式通过 object? 类型的 Value 属性访问内部值,这意味着值类型会产生装箱。如果你对性能有更高要求,可以额外实现 HasValue 和 TryGetValue 方法,让编译器在模式匹配时使用强类型的访问路径:
csharp
[Union]
public struct IntOrBool
{
private bool _isBool;
private int _value;
public IntOrBool(int value) => (_isBool, _value) = (false, value);
public IntOrBool(bool value) => (_isBool, _value) = (true, value ? 1 : 0);
public object Value => _isBool ? (object)(_value == 1) : _value;
// Non-boxing 访问模式
public bool HasValue => true;
public bool TryGetValue(out int value)
{
value = _value;
return !_isBool;
}
public bool TryGetValue(out bool value)
{
value = _isBool && _value == 1;
return _isBool;
}
}
这样编译器在进行模式匹配时,就不需要通过 Value 属性来装箱获取值了,而是直接调用对应的 TryGetValue,从而避免了装箱开销。
Result 模式的实战
让我们回到文章开头的问题,用 Union 来实现一个类型安全的 Result<T>:
csharp
public union Result<T>(T, Exception);
一行搞定。用起来是这样的:
csharp
Result<int> Divide(int a, int b)
{
if (b == 0) return new DivideByZeroException();
return a / b;
}
var result = Divide(10, 3);
var message = result switch
{
int value => $"结果是 {value}",
Exception ex => $"出错了:{ex.Message}",
};
不需要额外的包装类,不需要 IsSuccess 属性,类型系统保证了每种情况都被处理。比以前的做法优雅得多。
Union 与类型层次结构
值得一提的是,C# 的 Union 类型是类型的联合而不是带标签的联合。如果你需要更接近传统 discriminated unions 的效果(即每个分支有独立的名称和数据),可以用 record 作为 case 类型来组合:
csharp
public record class Circle(double Radius);
public record class Rectangle(double Width, double Height);
public record class Triangle(double Base, double Height);
public union Shape(Circle, Rectangle, Triangle);
double Area(Shape shape) => shape switch
{
Circle c => Math.PI * c.Radius * c.Radius,
Rectangle r => r.Width * r.Height,
Triangle t => 0.5 * t.Base * t.Height,
};
如果不需要命名的分支,用现有的类型直接组合就好。两种方式各有适用场景,C# 在设计上给了充分的灵活性。
另外,如果你需要更加严格的封闭类型层次结构,还可以关注即将推出的 closed hierarchies 特性,它和 Union 类型是互补的关系。
为什么不用类型擦除?
看到这里你可能会想:Union 值在运行时不就是一个 object 引用吗?那为什么不直接用 object 加上编译期的元数据信息来表示 Union 类型呢?也就是说,让 union Pet(Cat, Dog) 直接擦除成 object,编译器靠 attribute 之类的元数据来记住这里其实是 Pet,跟 dynamic、元组名和可空注解的处理方式一样。
这个思路在 C# 语言设计工作组中被认真讨论过,而且在类型联合提案中也确实有 Ad Hoc Union 这一类设计是基于擦除的。但最终它没有成为 C# 15 的实现方案,原因有几个:
泛型场景下会破坏类型安全。 考虑这样一段代码:
csharp
public class MyCollection<T>
{
public bool TryAdd(object o)
{
if (o is T t)
{
// 添加 t
return true;
}
return false;
}
}
如果用 MyCollection<(int or string)> 实例化,而 (int or string) 被擦除成了 object,那么 o is T 就变成了 o is object,永远成功。任何类型的值都能绕过检查被塞进集合,类型安全就彻底崩了。
不擦除也有问题。 如果换成包装类型 ValueUnion<T1, T2> 来避免擦除,那 (string or bool) 和 (bool or string) 在运行时就是不同的类型。这对 ad hoc union 来说是无法接受的,因为用户自然会认为这两个同一组类型的联合应该可以互换使用。工作组曾调研过运行时层面的解决方案,但结论是不完美且代价巨大。
包装类型方案在实用性上胜出。 语言设计工作组整理过一份 Trade Off Matrix,对比了三种可行路线:类层次结构、object 引用(擦除)和包装类型。最终选择的包装类型方案(即现在的 Nominal Type Unions)在向后兼容性、非 ABI 破坏性、可定制实现以及交付周期等维度上都有明显优势。擦除方案虽然在匿名语法和动态模式匹配方面更出色,但需要较大的运行时改造才能安全工作,短期内无法落地。
所以最终的设计是:Union 声明生成一个结构体包装,内部用 object? 引用存值。你可以把它理解为一个编译器帮你维护的包装类型,但它不是单纯的元数据注解,运行时确实存在这个结构体,Value 属性也是真实可访问的。这让 Union 在反射、序列化、跨程序集调用等场景下都能正确工作,而不只是一个编译器的错觉。
结语
Union 类型的加入,是 C# 类型系统一次质的飞跃。它解决了长期以来用 C# 表达多选一类型时的尴尬:不再需要靠约定、靠运行时检查,而是让编译器从类型层面帮你把关。
简洁的 union 声明语法让大多数场景几行代码就搞定,而灵活的 Union 模式又允许在需要时完全自定义底层实现。这种简单场景简单做,复杂场景有出路的设计理念,非常符合 C# 一贯的风格。
期待 C# 15 和 .NET 11 的正式发布~