商城首页欢迎来到中国正版软件门户

您的位置: 首页 > 文章列表 > 编程开发 > C# 15 类型系统改进Union Types详解

C# 15 类型系统改进Union Types详解

  发布于2026-07-14 阅读(0)

扫一扫,手机访问

前言

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

C# 15 类型系统改进Union Types详解

那么,这个让社区期待已久的新特性,具体怎么用,又解决了哪些实际问题?下面来详细看看。

从一个实际问题出发

以前,假如你想写一个既能返回正常结果又能返回错误的函数,最常见的做法是什么?定义一个包装类:

public class Result
{
    public T? Data { get; set; }
    public Exception? Error { get; set; }
    public bool IsSuccess => Error is null;
}

这种写法的问题很明显:DataError 同时存在于类里,编译器无法保证“成功时一定有 Data”或“失败时一定有 Error”。整个正确性靠的是人为约定,而不是类型系统的强制保障。

而 Union 类型的出现,恰好能从根本上解决这个尴尬局面。

Union 声明

C# 15 为我们带来了一种全新的声明方式——用 union 关键字,语法有多简洁呢?看下面这行:

public union Pet(Cat, Dog, Bird);

就这一行,定义了一个名为 Pet 的联合类型,它的值可以是 CatDogBird 中的任意一种。

实际上,编译器会把这个声明展开成一个结构体,内部用单个 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 转换

Union 类型支持从每个 case 类型到联合类型的隐式转换:

Cat cat = new Cat("小花");
Pet pet = cat; // 隐式 union 转换,不需要显式构造

编译器会自动把它转成对构造函数的调用:

// 编译器实际生成的代码
Pet pet = new Pet(cat);

也就是说,你不需要手动去包装值,直接赋值就行。但有个优先级问题——如果你之前有自定义的隐式转换运算符,它的优先级会高于 union 转换,所以现有代码不会受影响。

这里还有一个容易忽略的细节:Union 转换只有隐式形式。即便某个 case 类型存在显式转换,也不代表你因此就自动拥有了到整个 Union 类型的显式转换。

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 类型只有 DogCatBird,所以它可以把这个 switch 表达式视为穷尽的。这不仅简化了代码,还在编译期保证了安全性。假如以后你给 Pet 增加了一个 Fish,所有没处理 Fishswitch 都会产生编译警告,这就不会漏了。

对于无条件的 var_ 模式,匹配的是 Union 值本身而不是内部值:

if (pet is var p) { ... } // p 是 Pet 类型,不是 object

这个设计是刻意为之。var 通常只是给当前值起个名字,保留 Union 类型比解出一个 object? 更实用。

这也意味着,pet is Pet p 并不等同于 pet is var pPet p 这种类型模式,会对拆包后的内部值进行匹配,而不是外层的 Union 值本身,所以它通常不会成功。

null 模式里还有一个值得专门提醒的细节。对于基于 class 的 Union,result is null 在两种情况下都会成功:Union 对象本身是 null,或者它内部的 Valuenull。对于 U? 这种“nullable 包裹 struct Union”的情况也类似:如果外层 nullable 没有值,或者内部 Union 的 Valuenullu is null 都会成功。而其他 Union 匹配模式只有在外层值本身存在时才会成功。

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 行为的唯一方式。你完全可以自己动手实现,只需满足以下条件:

  1. 类型标记 [Union] 属性
  2. 提供对应每个 case 类型的单参数构造函数
  3. 提供一个 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 成员提供者(IUnionMembers)

默认情况下,编译器通过 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
  • 能灵活控制内部存储和初始化逻辑

使用时,隐式转换会自动走工厂方法:

Result result = "Hello";
// 等价于
Result result = Result.IUnionMembers.Create("Hello");

Non-boxing 访问模式

默认的 Union 模式通过 object? 类型的 Value 属性访问内部值,值类型会产生装箱。如果你对性能有更高要求,可以额外实现 HasValueTryGetValue 方法,让编译器在模式匹配时使用强类型的访问路径:

[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 就是一句话的事:

public union Result(T, Exception);

用起来是这样的:

Result 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 类型来组合:

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

本文转载于:https://www.jb51.net/program/362705dx6.htm 如有侵犯,请联系zhengruancom@outlook.com删除。
免责声明:正软商城发布此文仅为传递信息,不代表正软商城认同其观点或证实其描述。

热门关注