发布于2026-07-14 阅读(0)
扫一扫,手机访问
C# 的 Union 类型(联合类型),这个特性从最初提案到正式落地,中间经过了多年的打磨,现在终于在 C# 15 中与大家见面了。它的核心价值在于,能把一个值限定为一组封闭类型中的某一种,并且在 switch 表达式中获得编译器的穷尽性检查。换句话说,编译器会帮你确认是否处理了所有情况,很多时候就不需要那个烦人的 _ 兜底分支了。

那么,这个让社区期待已久的新特性,具体怎么用,又解决了哪些实际问题?下面来详细看看。
以前,假如你想写一个既能返回正常结果又能返回错误的函数,最常见的做法是什么?定义一个包装类:
public class Result{ public T? Data { get; set; } public Exception? Error { get; set; } public bool IsSuccess => Error is null; }
这种写法的问题很明显:Data 和 Error 同时存在于类里,编译器无法保证“成功时一定有 Data”或“失败时一定有 Error”。整个正确性靠的是人为约定,而不是类型系统的强制保障。
而 Union 类型的出现,恰好能从根本上解决这个尴尬局面。
C# 15 为我们带来了一种全新的声明方式——用 union 关键字,语法有多简洁呢?看下面这行:
public union Pet(Cat, Dog, Bird);
就这一行,定义了一个名为 Pet 的联合类型,它的值可以是 Cat、Dog 或 Bird 中的任意一种。
实际上,编译器会把这个声明展开成一个结构体,内部用单个 object 引用来存储值:
// 编译器生成的等价代码
[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 和已有类型组合实现 Option:
public record class None(); public record class Some(T Value); public union Option (None, Some );
Union 里还能加自定义方法:
public union OneOrMore(T, IEnumerable ) { public IEnumerable AsEnumerable() => Value switch { IEnumerable list => list, T value => [value], }; }
这体验是不是很丝滑?
另外,case 类型不限于具体类,还可以是接口、类型参数、可空类型,甚至是其他 Union,case 之间也允许重叠。
不过要注意,Union 声明是一种刻意“收紧”的形式。你可以添加方法之类的成员,但不能声明实例字段、自动属性或类字段事件;也不能自己声明 public 的单参数构造函数,而你显式添加的构造函数必须通过 this(...) 委托到编译器生成的 case 构造函数之一。这些限制是为了保证一致性。
Union 类型支持从每个 case 类型到联合类型的隐式转换:
Cat cat = new Cat("小花");
Pet pet = cat; // 隐式 union 转换,不需要显式构造
编译器会自动把它转成对构造函数的调用:
// 编译器实际生成的代码 Pet pet = new Pet(cat);
也就是说,你不需要手动去包装值,直接赋值就行。但有个优先级问题——如果你之前有自定义的隐式转换运算符,它的优先级会高于 union 转换,所以现有代码不会受影响。
这里还有一个容易忽略的细节:Union 转换只有隐式形式。即便某个 case 类型存在显式转换,也不代表你因此就自动拥有了到整个 Union 类型的显式转换。
这才是 Union 类型真正的威力所在——和模式匹配的配合。当你对一个 Union 值进行模式匹配时,编译器会自动拆包内部的值:
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}",
};
注意最后那个分支,没有 _ 兜底!因为编译器知道 Pet 的 case 类型只有 Dog、Cat 和 Bird,所以它可以把这个 switch 表达式视为穷尽的。这不仅简化了代码,还在编译期保证了安全性。假如以后你给 Pet 增加了一个 Fish,所有没处理 Fish 的 switch 都会产生编译警告,这就不会漏了。
对于无条件的 var 和 _ 模式,匹配的是 Union 值本身而不是内部值:
if (pet is var p) { ... } // p 是 Pet 类型,不是 object
这个设计是刻意为之。var 通常只是给当前值起个名字,保留 Union 类型比解出一个 object? 更实用。
这也意味着,pet is Pet p 并不等同于 pet is var p。Pet p 这种类型模式,会对拆包后的内部值进行匹配,而不是外层的 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 Result(int, string, Exception);
string Describe(Result r) => r switch
{
int n => $"数字:{n}",
string s => $"字符串:{s}",
Exception e => $"错误:{e.Message}",
// 编译器认为已穷尽,无需 _ 分支
};
但有一类情况需要留意:如果 Union 的值可能为 null(比如某个 case 类型是可空的),编译器会要求你处理 null:
Pet pet = GetNullableDog(); // pet.Value 可能是 null
var result = pet switch
{
Dog dog => "汪",
Cat cat => "喵",
Bird bird => "啾",
// 警告:未处理 null
};
所以,多了解这一点能帮你避免不少坑。
Union 声明虽然方便,但并不是获得 Union 行为的唯一方式。你完全可以自己动手实现,只需满足以下条件:
[Union] 属性object? 类型的 Value 属性[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 类型自身的构造函数来识别 case 类型。但有些场景下你可能不想暴露公开构造函数,或者想用工厂方法来创建 Union 值。这时可以在 Union 类型内部声明一个名为 IUnionMembers 的接口,让它充当成员提供者。
[Union] public record class Result: Result .IUnionMembers { object? _value; public interface IUnionMembers { public static Result Create(T value) => new() { _value = value }; public static Result Create(Exception value) => new() { _value = value }; public object? Value { get; } } object? IUnionMembers.Value => _value; }
当 Union 类型内部包含 IUnionMembers 接口声明时,编译器就不再从 Union 类型本身查找构造函数了,而是从这个接口上的 Create 工厂方法来确定 case 类型。Value 属性也改在接口上声明。
这种模式的好处很明显:
class 类型的 Union(Union 声明默认生成的是 struct)使用时,隐式转换会自动走工厂方法:
Resultresult = "Hello"; // 等价于 Result result = Result .IUnionMembers.Create("Hello");
默认的 Union 模式通过 object? 类型的 Value 属性访问内部值,值类型会产生装箱。如果你对性能有更高要求,可以额外实现 HasValue 和 TryGetValue 方法,让编译器在模式匹配时使用强类型的访问路径:
[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,避免了装箱的开销。
回到文章开头的问题,现在用 Union 来实现一个类型安全的 Result 就是一句话的事:
public union Result(T, Exception);
用起来是这样的:
ResultDivide(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 属性,类型系统保证了每种情况都被处理。和以前的做法比起来,确实优雅得多。
值得注意的是,C# 的 Union 类型是“类型的联合”而不是“带标签的联合”。如果你需要更接近传统 discriminated unions 的效果(即每个分支有独立的名称和数据),可以用 record 作为 case 类型来组合:
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 类型呢?
这个思路在 C# 语言设计工作组中确实被认真讨论过,但最终没有成为 C# 15 的实现方案。原因有几个:
泛型场景下会破坏类型安全。 考虑这样一段代码:
public class MyCollection{ 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 来避免擦除,那 (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 的正式发布。
售后无忧
立即购买>office旗舰店
售后无忧
立即购买>office旗舰店
售后无忧
立即购买>office旗舰店
售后无忧
立即购买>office旗舰店
正版软件
正版软件
正版软件
正版软件
正版软件
1
2
3
7
8