C# 联合类型:受歧视联合终于来了
如果您已经编写 C# 相当长的时间,那么在尝试对可能是“多种事物之一”的事物进行建模时,您很可能会遇到困难。也许您需要一个方法来返回成功值或错误。也许您正在构建一个处理信用卡、银行转账和数字钱包的支付系统——每个系统都具有完全不同的数据。或者也许您只是看着 F# 或 Rust 并想,“为什么我不能在 C# 中使用它?”
等待即将结束。 受歧视的工会即将进入 C#。
这一直是多年来最受欢迎的语言功能之一,社区讨论可以追溯到 2017 年或更早。 C# 语言设计团队一直致力于一项提案,该提案引入了 union 关键字,用于定义具有详尽模式匹配的封闭类型层次结构。在这篇文章中,我想向您介绍什么是受歧视的联合,为什么它们如此重要,到目前为止我们如何伪造它们,以及提议的语法实际上是什么样子 - 每个都有真实的代码示例。
在我们深入讨论之前,请注意:在撰写本文时,联合类型功能仍处于提案和预览阶段。我在此描述的语法和行为基于最新公开的设计文档以及 C# 语言设计团队的讨论。在最终版本发布之前情况可能会发生变化。我会明确哪些内容已被确认,哪些内容仍在讨论中。
什么是受歧视工会?
从本质上讲,可区分联合(有时称为“标记联合”或“总和类型”)是一种可以保存一组固定可能值之一的类型,其中每个变体可以携带不同的数据。 “可区分”部分意味着运行时始终知道它是哪个变体 - 有一个标签可以识别它。
把它想象成一个 enum,但每个成员都可以携带自己的数据有效负载。
如果您使用过其他语言,您可能以前见过这个概念:
F# 从第一天起就存在受歧视的工会:
type Shape =
| Circle of radius: float
| Rectangle of width: float * height: float
| Triangle of base: float * height: float
Rust 使用 enum 实现相同的想法:
enum Shape {
Circle { radius: f64 },
Rectangle { width: f64, height: f64 },
Triangle { base: f64, height: f64 },
}
TypeScript 通过标记联合实现了类似的效果:
type Shape =
| { kind: "circle"; radius: number }
| { kind: "rectangle"; width: number; height: number }
| { kind: "triangle"; base: number; height: number };
在所有这些情况下,编译器知道每种可能的变体,并且可以强制您处理所有这些变体。这就是超能力:详尽检查。如果您添加新的变体,编译器会在您忘记处理它的任何地方告诉您。
C# 从未有过一流的方式来表达这一点。到目前为止。
今天我们如何模拟工会
多年来,C# 社区提出了多种解决方法,每种方法都有自己的优缺点。让我来介绍一下最常见的方法。
具有继承性的抽象记录
现代 C# 中最惯用的解决方法是使用具有密封派生类型的抽象记录:
public abstract record Shape
{
public sealed record Circle(double Radius) : Shape;
public sealed record Rectangle(double Width, double Height) : Shape;
public sealed record Triangle(double Base, double Height) : Shape;
private Shape() { } // Prevent external inheritance
}
这相当有效。您可以获得不变性、值相等,并且可以使用模式匹配:
double Area(Shape shape) => shape switch
{
Shape.Circle c => Math.PI * c.Radius * c.Radius,
Shape.Rectangle r => r.Width * r.Height,
Shape.Triangle t => 0.5 * t.Base * t.Height,
_ => throw new InvalidOperationException("Unknown shape")
};
```但也有明显的缺点。编译器不知道层次结构已关闭,因此您始终需要 `_` 丢弃臂,否则您会收到警告。如果你添加一个新的变体,编译器不会告诉你所有你忘记处理它的地方——丢弃会默默地吞掉它。这完全违背了目的。
### OneOf 图书馆
另一种流行的方法是 [OneOf](https://github.com/mcintyre321/OneOf) NuGet 包:
```csharp
public OneOf<Success<Order>, NotFound, ValidationError> ProcessOrder(OrderRequest request)
{
// ...
}
// Usage
var result = ProcessOrder(request);
result.Switch(
success => Console.WriteLine($"Order {success.Value.Id} processed"),
notFound => Console.WriteLine("Order not found"),
error => Console.WriteLine($"Validation failed: {error.Message}")
);
OneOf 通过其泛型类型参数在编译时提供详尽的检查,这非常棒。但它依赖于位置匹配(第一类型、第二类型等),通用签名很快变得笨拙,并且它不与语言的模式匹配集成。这是一个聪明的黑客,但它仍然是一个黑客。
手动枚举+数据模式
一些开发人员采用使用枚举标记和数据容器的经典路线:
public enum PaymentType { CreditCard, BankTransfer, DigitalWallet }
public class Payment
{
public PaymentType Type { get; init; }
public CreditCardInfo? CreditCard { get; init; }
public BankTransferInfo? BankTransfer { get; init; }
public DigitalWalletInfo? DigitalWallet { get; init; }
}
这是脆弱的。没有什么可以阻止您将 Type 设置为 CreditCard,但填充 BankTransfer 属性。编译器无法帮助您,最终您会遇到运行时错误和到处都是空检查。这是类型建模的“字符串类型”方法,并且无法扩展。
所有这些方法都有一个基本问题:**它们是在对抗语言而不是使用它。**编译器无法推理封闭的可能性集,因此您失去了可区分联合的最有价值的属性 - 详尽的检查。
C# 提案:union 关键字
C# 语言团队的提案引入了一个专用的 union 关键字,该关键字定义一组封闭的命名成员,每个成员可选地携带数据。这是提议的基本语法:
union Shape
{
Circle(double Radius),
Rectangle(double Width, double Height),
Triangle(double Base, double Height)
}
就是这样。干净、简洁、易于阅读。联合内的每个成员都用自己的数据定义一个不同的变体。编译器知道 Shape 只能是这三者之一。
在底层,编译器生成一个密封的类型层次结构 - 类似于您使用抽象记录手工编写的内容,但编译器完全了解类型的封闭性质。这意味着编译器可以强制模式匹配的详尽性,这是主要的好处。
仅限超值会员
工会成员不必携带数据。您可以将数据携带成员与简单值成员混合使用:
union Option<T>
{
Some(T Value),
None
}
这是函数式程序员多年来在 C# 中要求的经典 Option/Maybe 类型。 None 不携带任何数据——它只是一个标签。
通用联合
从上面的 Option<T> 示例中可以看出,联合支持泛型。这是一个更复杂的例子:
union Result<T, E>
{
Ok(T Value),
Error(E Err)
}
这开辟了一种完整的错误处理风格,不依赖于预期失败情况的异常——这是多年来 Rust 和函数式语言的标准做法。
方法联合
该提案还允许联合拥有方法、计算属性和实现接口,就像任何其他类型一样:```csharp union Shape { Circle(double Radius), Rectangle(double Width, double Height), Triangle(double Base, double Height);
public double Area => this switch
{
Circle(var r) => Math.PI * r * r,
Rectangle(var w, var h) => w * h,
Triangle(var b, var h) => 0.5 * b * h
};
public double Perimeter => this switch
{
Circle(var r) => 2 * Math.PI * r,
Rectangle(var w, var h) => 2 * (w + h),
Triangle(var b, var h) => b + h + Math.Sqrt(b * b + h * h)
};
}
请注意 `Area` 和 `Perimeter` 内的 `switch` 表达式不需要默认臂。编译器知道联合是详尽的——只有三种变体,并且所有三种变体都被处理。如果稍后添加第四个变体,编译器将标记每个不处理它的 `switch`。
## 模式匹配集成
自版本 7.0 以来,模式匹配在 C# 中不断发展,联合类型被设计为该系统的一等公民。
### 详尽的 Switch 表达式
最有影响力的功能是详尽的开关检查。通过联合,编译器**知道**所有可能的情况:
```csharp
string Describe(Shape shape) => shape switch
{
Circle(var r) => $"A circle with radius {r}",
Rectangle(var w, var h) => $"A {w}x{h} rectangle",
Triangle(var b, var h) => $"A triangle with base {b} and height {h}"
};
无丢弃臂。没有_ => throw new NotImplementedException()。如果您忘记了大小写,编译器会发出错误,而不是警告。这是安全性的根本性改进。
嵌套模式匹配
联合自然地与嵌套模式组合:
union Expr
{
Literal(double Value),
Add(Expr Left, Expr Right),
Multiply(Expr Left, Expr Right),
Negate(Expr Inner)
}
double Evaluate(Expr expr) => expr switch
{
Literal(var v) => v,
Add(var left, var right) => Evaluate(left) + Evaluate(right),
Multiply(var left, var right) => Evaluate(left) * Evaluate(right),
Negate(var inner) => -Evaluate(inner)
};
这种递归数据结构在编译器、解释器、规则引擎和数学建模中极为常见。如今,在 C# 中,您需要深层的类层次结构和访问者模式。通过联合,代码变得更加简单。
保护条款
正如您所期望的,与联合的模式匹配支持 when 保护:
string Classify(Shape shape) => shape switch
{
Circle(var r) when r > 100 => "Large circle",
Circle(var r) when r > 10 => "Medium circle",
Circle(_) => "Small circle",
Rectangle(var w, var h) when w == h => "Square",
Rectangle _ => "Rectangle",
Triangle _ => "Triangle"
};
实际例子
让我来看看一些现实场景,其中联合类型极大地改进了代码。
结果模式:替换预期错误的异常
现代应用程序开发中最常见的模式之一是表示在不使用控制流异常的情况下可以成功或失败的操作。异常应该是异常的——比如网络故障或内存不足的情况。验证错误或“未找到”结果是预期结果,而不是异常。
union Result<T, E>
{
Ok(T Value),
Error(E Err)
}
union OrderError
{
NotFound(Guid OrderId),
InsufficientStock(string ProductId, int Requested, int Available),
PaymentDeclined(string Reason),
ValidationFailed(IReadOnlyList<string> Errors)
}
Result<Order, OrderError> ProcessOrder(OrderRequest request)
{
if (!Validate(request, out var errors))
return new ValidationFailed(errors);
var product = catalog.Find(request.ProductId);
if (product is null)
return new NotFound(request.OrderId);
if (product.Stock < request.Quantity)
return new InsufficientStock(request.ProductId, request.Quantity, product.Stock);
var paymentResult = paymentGateway.Charge(request.Payment);
if (!paymentResult.Success)
return new PaymentDeclined(paymentResult.Message);
var order = CreateOrder(request, product);
return new Ok(order);
}
然后调用者被迫处理所有可能的结果:
var result = ProcessOrder(request);
var response = result switch
{
Ok(var order) => Results.Created($"/orders/{order.Id}", order),
Error(NotFound(var id)) => Results.NotFound($"Order {id} not found"),
Error(InsufficientStock(var pid, var req, var avail)) =>
Results.Conflict($"Product {pid}: requested {req}, only {avail} available"),
Error(PaymentDeclined(var reason)) =>
Results.UnprocessableEntity($"Payment declined: {reason}"),
Error(ValidationFailed(var errors)) =>
Results.BadRequest(new { Errors = errors })
};
没有 try-catch 块,没有被遗忘的异常类型,没有运行时意外。每个故障模式在类型签名中都是可见的,并由编译器强制执行。这是 API 可靠性的巨大改进。
领域建模:支付类型
这是一个现实世界的域建模示例 - 在电子商务系统中处理不同的支付方式:
union PaymentMethod
{
CreditCard(string CardNumber, string Expiry, string Cvv),
BankTransfer(string Iban, string Bic),
DigitalWallet(string Provider, string Token),
CashOnDelivery
}
decimal CalculateProcessingFee(PaymentMethod method, decimal amount) => method switch
{
CreditCard _ => amount * 0.029m + 0.30m,
BankTransfer _ => 1.50m,
DigitalWallet(provider: "PayPal", _) => amount * 0.034m + 0.35m,
DigitalWallet _ => amount * 0.025m,
CashOnDelivery => 4.99m
};
string FormatForReceipt(PaymentMethod method) => method switch
{
CreditCard(var num, _, _) => $"Credit Card ending in {num[^4..]}",
BankTransfer(var iban, _) => $"Bank Transfer ({iban[..4]}****)",
DigitalWallet(var provider, _) => $"Digital Wallet ({provider})",
CashOnDelivery => "Cash on Delivery"
};
将此与当前方法进行比较,在当前方法中,您将拥有一个接口或抽象类,其中五个不同的实现分布在五个文件中,并且顶部可能带有访问者模式。联合方法将数据定义和操作保持在一起,可读且经过彻底检查。
状态机
状态机在软件中无处不在——订单处理、工作流引擎、连接管理、UI 状态。工会使它们明确且安全:
union ConnectionState
{
Disconnected,
Connecting(string Host, int Port, DateTime StartedAt),
Connected(string Host, int Port, TcpClient Client),
Reconnecting(string Host, int Port, int Attempt, TimeSpan Delay),
Failed(string Host, Exception Error)
}
ConnectionState HandleEvent(ConnectionState state, ConnectionEvent evt) => (state, evt) switch
{
(Disconnected, Connect(var host, var port)) =>
new Connecting(host, port, DateTime.UtcNow),
(Connecting(var h, var p, _), ConnectionSucceeded(var client)) =>
new Connected(h, p, client),
(Connecting(var h, var p, _), ConnectionFailed(var ex)) =>
new Reconnecting(h, p, Attempt: 1, Delay: TimeSpan.FromSeconds(1)),
(Reconnecting(var h, var p, var attempt, _), ConnectionSucceeded(var client)) =>
new Connected(h, p, client),
(Reconnecting(var h, var p, var attempt, _), ConnectionFailed(var ex)) when attempt >= 5 =>
new Failed(h, ex),
(Reconnecting(var h, var p, var attempt, _), ConnectionFailed(_)) =>
new Reconnecting(h, p, attempt + 1, TimeSpan.FromSeconds(Math.Pow(2, attempt))),
(Connected(_, _, var client), Disconnect) =>
new Disconnected,
_ => state // Ignore events that don't apply to current state
};
每个状态都准确地携带与该状态相关的数据。当您处于 Connecting 状态时,您不会意外访问 TcpClient,因为该变体上不存在它。类型系统强制执行状态机的不变量。
序列化和互操作注意事项联合类型立即出现的实际问题之一是:如何序列化它们?如果您正在构建 API 或存储数据,则需要 JSON 序列化才能正常工作。
设计团队一直在讨论对联合类型的内置 System.Text.Json 支持。预期的方法涉及使用鉴别器属性进行序列化:
{
"$type": "Circle",
"radius": 5.0
}
{
"$type": "Rectangle",
"width": 10.0,
"height": 20.0
}
这与 .NET 7 中通过 JsonDerivedType 属性引入的现有多态序列化支持一致。期望联合体可以开箱即用地使用 System.Text.Json,默认情况下使用变体名称作为类型鉴别器。
对于 Entity Framework Core,可能的方法是使用鉴别器列存储联合值 - 类似于每个层次结构表 (TPH) 继承映射的工作方式。确切的 EF Core 集成仍在设计中,但处理封闭类型层次结构的基础设施已经存在。
值得注意的是,与其他 .NET 语言的互操作应该很顺利,因为联合将在底层编译为标准 IL 类层次结构。使用 C# 联合的 F# 代码会将其视为标准类型层次结构,反之亦然。
如何尝试
截至撰写本文时,联合类型已作为最新 .NET SDK 预览版中的预览功能提供。要试验建议的语法,您需要:
1.安装最新的.NET预览版SDK 2. 在项目文件中启用预览语言版本:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
</PropertyGroup>
</Project>
请记住,预览功能可能会发生变化。在最终版本发布之前,语法、行为和编译器诊断可能会发生显着变化。不要依赖预览语言功能来发布生产代码 - 但绝对要尝试它们并提供反馈。 C# 团队积极监控 csharplang 存储库讨论。
如果您想跟踪提案的进展,需要关注的关键地方是:
- 用于语言设计讨论的 dotnet/csharplang 存储库
- dotnet/roslyn 编译器实现进度存储库
- 官方公告的 .NET 博客
与现有方法的比较
让我进行一个快速比较,以便您可以了解不同方法的叠加效果:
| 特色 | 摘要记录 | OneOf<T1,T2> | 枚举 + 数据 | 联合类型 |
|---|---|---|---|---|
| 详尽性检查 | ❌ 否 | ✅ 是的 | ❌ 否 | ✅ 是的 |
| 模式匹配 | ✅ 是的 | ❌ 有限 | ❌ 手册 | ✅ 本地人 |
| 编译器强制关闭 | ❌ 否 | ✅ 是的 | ❌ 否 | ✅ 是的 |
| 每个变体的数据 | ✅ 是的 | ✅ 是的 | ⚠️ 脆弱 | ✅ 是的 |
| 可读性 | ⚠️ 详细 | ⚠️ 位置 | ❌ 穷 | ✅ 优秀 |
| 连载 | ✅ 手册 | ⚠️ 复杂 | ✅ 手册 | ✅ 内置 |
| 样板 | ⚠️ 中等 | ✅ 低 | ⚠️高 | ✅ 最小 |
| 没有外部依赖 | ✅ 是的 | ❌NuGet | ✅ 是的 | ✅ 是的 |
这对 .NET 生态系统意味着什么
受歧视工会的引入将波及整个 .NET 生态系统。这是我期望看到的:
库设计将会改进。 当前返回 null 以指示“未找到”或因验证失败引发异常的 API 将能够返回 Result<T, E> 类型。这使得类型签名中的故障模式变得明确——您可以通过查看方法签名来了解可能出现的问题,而不是通过阅读文档或源代码。
**领域建模变得更具表现力。**问题域和代码表示之间的差距急剧缩小。当您的领域专家说“付款可以是信用卡、银行转账或货到付款”时,您可以直接将其建模为联合,而不是将其转换为继承层次结构。
C# 开发人员可以接触到 F# 的想法。 许多 C# 开发人员从远处欣赏 F# 的类型系统,但无法在其组织中采用 F#。联合类型为 C# 带来了 F# 最强大的功能之一,这对整个 .NET 生态系统来说是一个胜利。
**更少的运行时错误。**单独的详尽检查就可以防止整个类别的错误。每次您向联合添加新变体时,编译器都会引导您到达代码库中需要更新的每个位置。不再有被遗忘的 switch 情况,默认分支中不再有 NotImplementedException 仅在生产中出现。
结论
近十年来,受歧视的工会一直位居 C# 社区愿望清单的首位,这是有充分理由的。它们解决了类型系统中的一个根本缺陷——对数据进行建模的能力,这些数据可以是具有编译器强制安全性的“多种事物之一”。
提议的 union 关键字带来了一种干净、简洁的语法,它与 C# 的模式匹配深度集成,与泛型一起使用,支持方法和接口,并支持在编译时而不是运行时捕获错误的详尽检查。
无论您是构建域模型、设计具有显式错误类型的 API、实现状态机,还是只是尝试用更自然的东西替换笨拙的继承层次结构,联合类型都将改变您编写 C# 的方式。
我们为此等待了很长时间。语法很优雅,与现有语言功能的集成经过深思熟虑,整个 .NET 生态系统都会感受到实际影响。
密切关注预览版本,尝试该功能,并向语言设计团队提供反馈。这是其中一项功能,一旦您使用过它,您就会想知道如果没有它您将如何生活。