凶凶 發表於 2026-4-19 19:21:00

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

<h2 id="前言">前言</h2>
<p>Union 类型(联合类型)一直是 C# 社区呼声最高的特性之一。从最初的 discriminated unions 提案到今天,这个特性经历了多年的设计和讨论,终于在 C# 15 中正式落地。</p>
<p>Union 类型允许我们将一个值限定为一组封闭类型中的某一种,并且在针对 Union 值的 <code>switch</code> 表达式中获得穷尽性检查。编译器能帮你确认是否处理了所有 case 类型,很多时候就不再需要那个烦人的 <code>_</code> 兜底分支。</p>
<p>本文将介绍 C# 15 中 Union 类型的设计和用法。</p>
<h2 id="从一个实际问题出发">从一个实际问题出发</h2>
<p>假设我们要实现一个函数,它可能返回一个正常的结果,也可能返回一个错误。以前常见的做法是定义一个包装类:</p>
<pre><code class="language-csharp">public class Result&lt;T&gt;
{
    public T? Data { get; set; }
    public Exception? Error { get; set; }
    public bool IsSuccess =&gt; Error is null;
}
</code></pre>
<p>这种写法有一个很明显的问题:<code>Data</code> 和 <code>Error</code> 在类型上同时存在,编译器没法保证「成功时一定有 <code>Data</code>」或「失败时一定有 <code>Error</code>」。正确性全靠人为约定,而不是类型系统来保障。</p>
<p>有了 Union 类型,这个问题就迎刃而解了。</p>
<h2 id="union-声明">Union 声明</h2>
<p>C# 15 引入了全新的 <code>union</code> 关键字,可以用非常简洁的语法声明一个联合类型:</p>
<pre><code class="language-csharp">public union Pet(Cat, Dog, Bird);
</code></pre>
<p>就是这么简单!这一行声明了一个名为 <code>Pet</code> 的联合类型,它的值可以是 <code>Cat</code>、<code>Dog</code> 或 <code>Bird</code> 中的任何一种。</p>
<p>Union 声明会被编译器展开为一个结构体,内部用单个 <code>object</code> 引用来存储值:</p>
<pre><code class="language-csharp">// 编译器生成的等价代码
public struct Pet : IUnion
{
    public Pet(Cat value) =&gt; Value = value;
    public Pet(Dog value) =&gt; Value = value;
    public Pet(Bird value) =&gt; Value = value;
   
    public object? Value { get; }
}
</code></pre>
<p>也就是说,Union 声明就是一种简洁的结构体声明方式,编译器帮你生成了所有样板代码。</p>
<p>再来看一个更实用的例子,利用 Union 和已有类型组合来实现 <code>Option&lt;T&gt;</code>:</p>
<pre><code class="language-csharp">public record class None();
public record class Some&lt;T&gt;(T Value);
public union Option&lt;T&gt;(None, Some&lt;T&gt;);
</code></pre>
<p>还可以在 Union 中添加自定义方法:</p>
<pre><code class="language-csharp">public union OneOrMore&lt;T&gt;(T, IEnumerable&lt;T&gt;)
{
    public IEnumerable&lt;T&gt; AsEnumerable() =&gt; Value switch
    {
      IEnumerable&lt;T&gt; list =&gt; list,
      T value =&gt; ,
    };
}
</code></pre>
<p>这也很方便!</p>
<p>另外,case 类型并不只限于这里展示的具体类。按照提案,它们还可以是接口、类型参数、可空类型,甚至其他 Union,而且 case 之间允许重叠。</p>
<p>不过,Union 声明本身是一种有意“收紧”的声明形式。你可以添加方法之类的成员,但不能声明实例字段、自动属性或类字段事件;你也不能自己声明 public 的单参数构造函数,而你显式添加的构造函数必须通过 <code>this(...)</code> 委托到编译器生成的 case 构造函数之一。</p>
<h2 id="union-转换">Union 转换</h2>
<p>Union 类型支持从每个 case 类型到联合类型的隐式转换:</p>
<pre><code class="language-csharp">Cat cat = new Cat("小花");
Pet pet = cat; // 隐式 union 转换,不需要显式构造
</code></pre>
<p>编译器会将其转换为对构造函数的调用:</p>
<pre><code class="language-csharp">// 编译器实际生成的代码
Pet pet = new Pet(cat);
</code></pre>
<p>这意味着你不需要手动去包装值,直接赋值就行了。如果你之前有自定义的隐式转换运算符,那它的优先级会高于 union 转换,所以现有代码不会受到影响。</p>
<p>这里还有一个容易忽略的点:Union 转换只有隐式形式。即使某个 case 类型存在显式转换,也不代表因此就自动拥有到整个 Union 类型的显式转换。</p>
<h2 id="union-匹配">Union 匹配</h2>
<p>Union 类型真正的威力在于和模式匹配的配合。</p>
<p>当你对一个 Union 值进行模式匹配时,编译器会自动拆包内部的值:</p>
<pre><code class="language-csharp">Pet pet = GetPet();

if (pet is Dog dog)
{
    // dog 已经是 Dog 类型,直接使用
    dog.Bark();
}

// switch 表达式
string description = pet switch
{
    Dog dog =&gt; $"这是一只狗:{dog.Name}",
    Cat cat =&gt; $"这是一只猫:{cat.Name}",
    Bird bird =&gt; $"这是一只鸟:{bird.Name}",
};
</code></pre>
<p>注意最后一个分支后面没有 <code>_</code> 兜底!因为这是一个针对 Union 值的 <code>switch</code> 表达式,而编译器也知道 <code>Pet</code> 的 case 类型只有 <code>Dog</code>、<code>Cat</code> 和 <code>Bird</code>,所以它可以把这个表达式视为穷尽的。</p>
<p>这确实是一个非常实用的功能,不仅简化了代码,还在编译期帮你保证了安全性。假如以后你给 <code>Pet</code> 增加了一个新的 case 类型 <code>Fish</code>,那么所有没有处理 <code>Fish</code> 的 <code>switch</code> 表达式都会产生编译警告,避免了遗漏。</p>
<p>对于无条件的 <code>var</code> 和 <code>_</code> 模式,匹配的是 Union 值本身而不是内部值:</p>
<pre><code class="language-csharp">if (pet is var p) { ... } // p 是 Pet 类型,不是 object
</code></pre>
<p>这是有意为之。<code>var</code> 通常只是给当前值起个名字,保留 Union 类型比解出一个 <code>object?</code> 更实用。</p>
<p>这也意味着,<code>pet is Pet p</code> 并不等同于 <code>pet is var p</code>。在 <code>Pet p</code> 这样的类型模式里,<code>Pet</code> 会作用在拆包后的内部值上,而不是外层的 Union 值本身,所以这个模式通常不会成功。</p>
<p><code>null</code> 模式还有一个值得专门提醒的细节。对于基于 <code>class</code> 的 Union,<code>result is null</code> 在两种情况下都会成功:Union 对象本身是 <code>null</code>,或者它内部的 <code>Value</code> 是 <code>null</code>。对于 <code>U?</code> 这种“nullable 包裹 struct Union”的情况也类似:如果外层 nullable 没有值,或者内部 Union 的 <code>Value</code> 为 <code>null</code>,那么 <code>u is null</code> 都会成功。相对地,其他 Union 匹配模式只有在外层值本身存在时才会成功。</p>
<h2 id="union-穷尽性">Union 穷尽性</h2>
<p>刚才提到了穷尽性检查,这是 Union 类型最重要的能力之一。</p>
<pre><code class="language-csharp">union Result(int, string, Exception);

string Describe(Result r) =&gt; r switch
{
    int n =&gt; $"数字:{n}",
    string s =&gt; $"字符串:{s}",
    Exception e =&gt; $"错误:{e.Message}",
    // 编译器认为已穷尽,无需 _ 分支
};
</code></pre>
<p>但如果 Union 的值可能为 <code>null</code>(例如某个 case 类型是可空的),编译器会要求你处理 <code>null</code> 的情况:</p>
<pre><code class="language-csharp">Pet pet = GetNullableDog(); // pet.Value 可能是 null
var result = pet switch
{
    Dog dog =&gt; "汪",
    Cat cat =&gt; "喵",
    Bird bird =&gt; "啾",
    // 警告:未处理 null
};
</code></pre>
<h2 id="手动实现-union-模式">手动实现 Union 模式</h2>
<p>Union 声明虽然方便,但并不是获得 Union 行为的唯一方式。你完全可以在已有类型上手动实现 Union 模式,只需满足以下条件:</p>
<ol>
<li>类型标记 <code></code> 属性</li>
<li>提供对应每个 case 类型的单参数构造函数</li>
<li>提供一个 <code>object?</code> 类型的 <code>Value</code> 属性</li>
</ol>
<pre><code class="language-csharp">
public struct IntOrString
{
    private readonly object _value;

    public IntOrString(int value) =&gt; _value = value;
    public IntOrString(string value) =&gt; _value = value;

    public object? Value =&gt; _value;
}
</code></pre>
<p>这在需要适配已有类型,或者需要自定义存储策略时非常有用。</p>
<h3 id="union-成员提供者iunionmembers">Union 成员提供者(<code>IUnionMembers</code>)</h3>
<p>默认情况下,编译器通过 Union 类型自身的构造函数来识别 case 类型。但有些场景下你可能不想暴露公开构造函数,或者想用工厂方法来创建 Union 值。这时可以在 Union 类型内部声明一个名为 <code>IUnionMembers</code> 的接口,让它充当成员提供者。</p>
<pre><code class="language-csharp">
public record class Result&lt;T&gt; : Result&lt;T&gt;.IUnionMembers
{
    object? _value;

    public interface IUnionMembers
    {
      public static Result&lt;T&gt; Create(T value) =&gt; new() { _value = value };
      public static Result&lt;T&gt; Create(Exception value) =&gt; new() { _value = value };

      public object? Value { get; }
    }

    object? IUnionMembers.Value =&gt; _value;
}
</code></pre>
<p>当 Union 类型内部包含 <code>IUnionMembers</code> 接口声明时,编译器就不再从 Union 类型本身查找构造函数了,而是从这个接口上的 <code>Create</code> 工厂方法来确定 case 类型。<code>Value</code> 属性也改为在接口上声明。</p>
<p>这种模式有几个好处:</p>
<ul>
<li>可以对外隐藏构造函数,只通过工厂方法创建实例</li>
<li>适合 <code>class</code> 类型的 Union(Union 声明默认生成的是 <code>struct</code>)</li>
<li>可以灵活控制内部存储和初始化逻辑</li>
</ul>
<p>使用时,隐式转换会自动走工厂方法:</p>
<pre><code class="language-csharp">Result&lt;string&gt; result = "Hello";
// 等价于
Result&lt;string&gt; result = Result&lt;string&gt;.IUnionMembers.Create("Hello");
</code></pre>
<h3 id="non-boxing-访问模式">Non-boxing 访问模式</h3>
<p>默认的 Union 模式通过 <code>object?</code> 类型的 <code>Value</code> 属性访问内部值,这意味着值类型会产生装箱。如果你对性能有更高要求,可以额外实现 <code>HasValue</code> 和 <code>TryGetValue</code> 方法,让编译器在模式匹配时使用强类型的访问路径:</p>
<pre><code class="language-csharp">
public struct IntOrBool
{
    private bool _isBool;
    private int _value;

    public IntOrBool(int value) =&gt; (_isBool, _value) = (false, value);
    public IntOrBool(bool value) =&gt; (_isBool, _value) = (true, value ? 1 : 0);

    public object Value =&gt; _isBool ? (object)(_value == 1) : _value;

    // Non-boxing 访问模式
    public bool HasValue =&gt; true;
    public bool TryGetValue(out int value)
    {
      value = _value;
      return !_isBool;
    }
    public bool TryGetValue(out bool value)
    {
      value = _isBool &amp;&amp; _value == 1;
      return _isBool;
    }
}
</code></pre>
<p>这样编译器在进行模式匹配时,就不需要通过 <code>Value</code> 属性来装箱获取值了,而是直接调用对应的 <code>TryGetValue</code>,从而避免了装箱开销。</p>
<h2 id="result-模式的例子">Result 模式的例子</h2>
<p>让我们回到文章开头的问题,用 Union 来实现一个类型安全的 <code>Result&lt;T&gt;</code>:</p>
<pre><code class="language-csharp">public union Result&lt;T&gt;(T, Exception);
</code></pre>
<p>一行搞定。用起来是这样的:</p>
<pre><code class="language-csharp">Result&lt;int&gt; 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 =&gt; $"结果是 {value}",
    Exception ex =&gt; $"出错了:{ex.Message}",
};
</code></pre>
<p>不需要额外的包装类,不需要 <code>IsSuccess</code> 属性,类型系统保证了每种情况都被处理。比以前的做法优雅得多。</p>
<h2 id="union-与类型层次结构">Union 与类型层次结构</h2>
<p>值得一提的是,C# 的 Union 类型是类型的联合而不是带标签的联合。如果你需要更接近传统 discriminated unions 的效果(即每个分支有独立的名称和数据),可以用 <code>record</code> 作为 case 类型来组合:</p>
<pre><code class="language-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) =&gt; shape switch
{
    Circle c =&gt; Math.PI * c.Radius * c.Radius,
    Rectangle r =&gt; r.Width * r.Height,
    Triangle t =&gt; 0.5 * t.Base * t.Height,
};
</code></pre>
<p>如果不需要命名的分支,用现有的类型直接组合就好。两种方式各有适用场景,C# 在设计上给了充分的灵活性。</p>
<p>另外,如果你需要更加严格的封闭类型层次结构,还可以关注即将推出的 closed hierarchies 特性,它和 Union 类型是互补的关系。</p>
<h2 id="为什么不用类型擦除">为什么不用类型擦除?</h2>
<p>看到这里你可能会想:Union 值在运行时不就是一个 <code>object</code> 引用吗?那为什么不直接用 <code>object</code> 加上编译期的元数据信息来表示 Union 类型呢?也就是说,让 <code>union Pet(Cat, Dog)</code> 直接擦除成 <code>object</code>,编译器靠 attribute 之类的元数据来记住这里其实是 Pet,跟 <code>dynamic</code>、元组名和可空注解的处理方式一样。</p>
<p>这个思路在 C# 语言设计工作组中被认真讨论过,而且在类型联合提案中也确实有 Ad Hoc Union 这一类设计是基于擦除的。但最终它没有成为 C# 15 的实现方案,原因有几个:</p>
<p><strong>泛型场景下会破坏类型安全。</strong> 考虑这样一段代码:</p>
<pre><code class="language-csharp">public class MyCollection&lt;T&gt;
{
    public bool TryAdd(object o)
    {
      if (o is T t)
      {
            // 添加 t
            return true;
      }
      return false;
    }
}
</code></pre>
<p>如果用 <code>MyCollection&lt;(int or string)&gt;</code> 实例化,而 <code>(int or string)</code> 被擦除成了 <code>object</code>,那么 <code>o is T</code> 就变成了 <code>o is object</code>,永远成功。任何类型的值都能绕过检查被塞进集合,类型安全就彻底崩了。</p>
<p><strong>不擦除也有问题。</strong> 如果换成包装类型 <code>ValueUnion&lt;T1, T2&gt;</code> 来避免擦除,那 <code>(string or bool)</code> 和 <code>(bool or string)</code> 在运行时就是不同的类型。这对 ad hoc union 来说是无法接受的,因为用户自然会认为这两个同一组类型的联合应该可以互换使用。工作组曾调研过运行时层面的解决方案,但结论是不完美且代价巨大。</p>
<p><strong>包装类型方案在实用性上胜出。</strong> 语言设计工作组整理过一份 Trade Off Matrix,对比了三种可行路线:类层次结构、<code>object</code> 引用(擦除)和包装类型。最终选择的包装类型方案(即现在的 Nominal Type Unions)在向后兼容性、非 ABI 破坏性、可定制实现以及交付周期等维度上都有明显优势。擦除方案虽然在匿名语法和动态模式匹配方面更出色,但需要较大的运行时改造才能安全工作,短期内无法落地。</p>
<p>所以最终的设计是:Union 声明生成一个结构体包装,内部用 <code>object?</code> 引用存值。你可以把它理解为一个编译器帮你维护的包装类型,但它不是单纯的元数据注解,运行时确实存在这个结构体,<code>Value</code> 属性也是真实可访问的。这让 Union 在反射、序列化、跨程序集调用等场景下都能正确工作,而不只是一个编译器的错觉。</p>
<h2 id="结语">结语</h2>
<p>Union 类型的加入,是 C# 类型系统一次质的飞跃。它解决了长期以来用 C# 表达多选一类型时的尴尬:不再需要靠约定、靠运行时检查,而是让编译器从类型层面帮你把关。</p>
<p>简洁的 <code>union</code> 声明语法让大多数场景几行代码就搞定,而灵活的 Union 模式又允许在需要时完全自定义底层实现。这种简单场景简单做,复杂场景有出路的设计理念,非常符合 C# 一贯的风格。</p>
<p>期待 C# 15 和 .NET 11 的正式发布~</p><br><br>
来源:https://www.cnblogs.com/hez2010/p/19891530/union-types-in-csharp-15
頁: [1]
查看完整版本: C# 15 类型系统改进:Union Types