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<T>
{
public T? Data { get; set; }
public Exception? Error { get; set; }
public bool IsSuccess => 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) => Value = value;
public Pet(Dog value) => Value = value;
public Pet(Bird value) => Value = value;
public object? Value { get; }
}
</code></pre>
<p>也就是说,Union 声明就是一种简洁的结构体声明方式,编译器帮你生成了所有样板代码。</p>
<p>再来看一个更实用的例子,利用 Union 和已有类型组合来实现 <code>Option<T></code>:</p>
<pre><code class="language-csharp">public record class None();
public record class Some<T>(T Value);
public union Option<T>(None, Some<T>);
</code></pre>
<p>还可以在 Union 中添加自定义方法:</p>
<pre><code class="language-csharp">public union OneOrMore<T>(T, IEnumerable<T>)
{
public IEnumerable<T> AsEnumerable() => Value switch
{
IEnumerable<T> list => list,
T value => ,
};
}
</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 => $"这是一只狗:{dog.Name}",
Cat cat => $"这是一只猫:{cat.Name}",
Bird bird => $"这是一只鸟:{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) => r switch
{
int n => $"数字:{n}",
string s => $"字符串:{s}",
Exception e => $"错误:{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 => "汪",
Cat cat => "喵",
Bird bird => "啾",
// 警告:未处理 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) => _value = value;
public IntOrString(string value) => _value = value;
public object? Value => _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<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;
}
</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<string> result = "Hello";
// 等价于
Result<string> result = Result<string>.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) => (_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;
}
}
</code></pre>
<p>这样编译器在进行模式匹配时,就不需要通过 <code>Value</code> 属性来装箱获取值了,而是直接调用对应的 <code>TryGetValue</code>,从而避免了装箱开销。</p>
<h2 id="result-模式的例子">Result 模式的例子</h2>
<p>让我们回到文章开头的问题,用 Union 来实现一个类型安全的 <code>Result<T></code>:</p>
<pre><code class="language-csharp">public union Result<T>(T, Exception);
</code></pre>
<p>一行搞定。用起来是这样的:</p>
<pre><code class="language-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}",
};
</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) => shape switch
{
Circle c => Math.PI * c.Radius * c.Radius,
Rectangle r => r.Width * r.Height,
Triangle t => 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<T>
{
public bool TryAdd(object o)
{
if (o is T t)
{
// 添加 t
return true;
}
return false;
}
}
</code></pre>
<p>如果用 <code>MyCollection<(int or string)></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<T1, T2></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]