C# 15 类型系统改进:Union Types

前言

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;
}

这种写法有一个很明显的问题:DataError 在类型上同时存在,编译器没法保证「成功时一定有 Data」或「失败时一定有 Error」。正确性全靠人为约定,而不是类型系统来保障。

有了 Union 类型,这个问题就迎刃而解了。

Union 声明

C# 15 引入了全新的 union 关键字,可以用非常简洁的语法声明一个联合类型:

csharp 复制代码
public union Pet(Cat, Dog, Bird);

就是这么简单!这一行声明了一个名为 Pet 的联合类型,它的值可以是 CatDogBird 中的任何一种。

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 类型只有 DogCatBird,所以它可以把这个表达式视为穷尽的。

这确实是一个非常实用的功能,不仅简化了代码,还在编译期帮你保证了安全性。假如以后你给 Pet 增加了一个新的 case 类型 Fish,那么所有没有处理 Fishswitch 表达式都会产生编译警告,避免了遗漏。

对于无条件的 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,或者它内部的 Valuenull。对于 U? 这种"nullable 包裹 struct Union"的情况也类似:如果外层 nullable 没有值,或者内部 Union 的 Valuenull,那么 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 模式,只需满足以下条件:

  1. 类型标记 [Union] 属性
  2. 提供对应每个 case 类型的单参数构造函数
  3. 提供一个 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 属性访问内部值,这意味着值类型会产生装箱。如果你对性能有更高要求,可以额外实现 HasValueTryGetValue 方法,让编译器在模式匹配时使用强类型的访问路径:

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 的正式发布~

相关推荐
FL16238631292 小时前
基于C#winform部署软前景分割DAViD算法的onnx模型实现前景分割
开发语言·算法·c#
C#程序员一枚3 小时前
高可用(High Availability, HA)
数据库·c#
weixin_520649874 小时前
C#进阶-特性全知识点总结
开发语言·c#
fengyehongWorld5 小时前
C# 创建vba用的类库
c#
澄澈青空~5 小时前
有一个叫R2C,也有一个叫G2C
java·数据库·人工智能·c#
PGFA7 小时前
深度剖析 C# LINQ 底层执行机制:别让你的应用内存莫名其妙“爆”掉!
c#·solr·linq
2601_949814699 小时前
如何使用C#与SQL Server数据库进行交互
数据库·c#·交互
CSharp精选营10 小时前
C#事务处理最佳实践:别再让“主表存了、明细丢了”的破事发生
c#·try-catch·事务处理·transactionscope
加号310 小时前
C# 基于MD5实现密码加密功能,附源码
开发语言·c#·密码加密