.NET(即C#)迎来联合类型

Hacker News Top 新闻

摘要

.NET 11 预览版在 C# 15 中引入了联合类型,这是一个期待已久的功能,用于处理可以是多种类型之一的数据,并新增了 'union' 关键字和模式匹配。

暂无内容
查看原文
查看缓存全文

缓存时间: 2026/05/23 21:33

# .NET(好吧,C#)终于迎来了联合类型🎉:探索 .NET 11 预览版 - 第二部分 来源:https://andrewlock.net/exploring-the-dotnet-11-preview-2-dotnet-gets-union-types/ 2026年5月19日 · 约10分钟阅读 探索 .NET 11 预览版 - 第二部分 (https://andrewlock.net/series/exploring-the-dotnet-11-preview/) 这是系列文章“探索 .NET 11 预览版”的第二篇 (https://andrewlock.net/series/exploring-the-dotnet-11-preview/)。 1. 第一部分 – 在 Blazor 中使用 Web Worker 运行后台任务 (https://andrewlock.net/exploring-the-dotnet-11-preview-1-running-background-tasks-in-blazor-with-web-workers/) 2. 第二部分 – .NET(好吧,其实是 C#)终于迎来了联合类型🎉(本文) 联合类型是多年来一直被要求的功能之一,而在 .NET 11(或者更准确地说,C# 15)中,它们*终于*来了。在本篇文章中,我将描述这种支持的具体形式、如何使用它们、它们的实现方式,以及如何实现你自己的自定义类型。 > 本文基于 .NET 11 预览版 4 中可用的功能编写。在 .NET 11 最终发布之前,许多内容可能会发生变化。 ## 什么是联合类型? (https://andrewlock.net/exploring-the-dotnet-11-preview-2-dotnet-gets-union-types/#what-are-union-types-) 联合类型是函数式编程世界中经常使用的基本数据结构之一;它们在 F#、TypeScript、Rust……几乎任何以函数式为先的语言中都有支持。联合类型有很多不同的*种类*,但它们的核心是允许一个类型表示两种不同的东西。一些最简单的联合类型是 `Option` 和 `Result`。虽然没有“标准”版本,但自定义实现*非常*常见。`Result<>` 是最容易解释的类型之一,因为它可以处于两种状态之一: - 成功 – 在这种情况下,`Result<>` 对象包含一个 `TSuccess` 值,表示一个成功操作的结果。 - 错误 – 在这种情况下,`Result<>` 对象包含一个 `TError` 值,表示一个失败操作的错误。 你从方法中返回一个 `Result<>` 对象,然后调用者必须*显式地*处理这两种情况,而不是假设成功。 > 这种模式通常被称为 Result 模式,在 C# 中既有利也有弊。我写过一系列关于使用这种模式的文章,并在此处讨论了是否值得使用它 (https://andrewlock.net/series/working-with-the-result-pattern/)。 联合类型不必像这样完全是泛型形式。它们也可以用来表示任意组合的类型集合。 ## C# 15 中使用 `union` 关键字的联合类型 (https://andrewlock.net/exploring-the-dotnet-11-preview-2-dotnet-gets-union-types/#union-types-in-c-15-with-the-union-keyword) 上一节我用经典的 `Result<>` 类型作为联合类型的例子,但联合类型的用途远不止于此。当你需要处理可能是几种潜在无关类型之一的数据时,它们非常理想。例如,假设我们有三种不同的 `record` 类型,包含不同的属性,代表操作系统: ```csharp public record Windows(string Version); public record Linux(string Distro, string Version); public record MacOS(string Name, int Version); ``` 注意这些类型*没有*任何共同的字段。在 C# 15 之前,处理一个可能是 `Windows`*或*`Linux`*或*`MacOS` 对象的主要选择是: - 尝试创建一个所有类型都派生的基类。这*也许*可行,但如果你无法控制这些类型(因为它们来自库)该怎么办? - 将类型存储在 `object` 实例中。这可行,但你会失去使用类型时的所有安全性。 - 使用某种“标记”值来跟踪你的对象包含哪种类型,例如使用 `enum` 来跟踪。 在 C# 15 中,我们通过 `union` 关键字直接支持这种场景,如下所示: ```csharp // 👇 使用 `union` 作为类型 public union SupportedOS(Windows, Linux, MacOS); // 👆 列出属于联合类型的类型 ``` 你可以通过两种方式创建 `SupportedOS` 的实例: ```csharp // 可以调用 new 并传入一个实例 SupportedOS os = new SupportedOS(new MacOS("Tahoe", 25)); // 或者使用隐式转换(在幕后调用 new()) SupportedOS os = new MacOS("Tahoe", 25); ``` 生成的 `union` 类型实现了 `IUnion` 接口: ```csharp public interface IUnion { object? Value { get; } } ``` 所以如果你需要,可以随时将“内部”值提取为 `object?`: ```csharp // 你可以使用 `.Value` 访问存储的“内部”对象 Console.WriteLine(os.Value); // MacOS { Name = Tahoe, Version = 25 } ``` 然而,使用联合类型的标准方式是使用 `switch` 表达式: ```csharp string GetDescription(SupportedOS os) => os switch { Windows windows => $"Windows {windows.Version}", Linux linux => $"{linux.Distro} {linux.Version}", MacOS macOS => $"MacOS {macOS.Name} ({macOS.Version})", }; // 注意:不需要 _ 废弃分支 ``` `switch` 表达式会自动提取内部 case 类型,一个非常巧妙的地方是,你*不需要*包含 `_ =>`“废弃”分支:编译器强制你检查所有允许的值,但你*只需要*检查这些值。如果你忘记了一个,你会收到一个警告: ``` warning CS8509: The switch expression does not handle all possible values of its input type (it is not exhaustive). For example, the pattern 'MacOS' is not covered. ``` > 注意,如果你的某个 case 类型是可空的,例如 `MacOS?`,那么你也需要在 `switch` 表达式中处理 `null`。 回到起点,我们或许可以实现 `Result<>` 类型如下(只是一个例子,我们可以选择很多不同的实现!): ```csharp public union Result(T, Exception); ``` 或者展示另一个经典类型,`Option`: ```csharp public record class None; public union Option(None, T); ``` 以上就是 C# 15 中 `union` 类型的基础内容,接下来我们将看看如何在实际中使用它们,然后再深入了解它们的实现方式。 ## 在 .NET 11 中使用 `union` 类型 (https://andrewlock.net/exploring-the-dotnet-11-preview-2-dotnet-gets-union-types/#using-union-types-in-net-11) 要使用 `union` 类型,你需要做两件事: - 安装 .NET 11 预览版 2+ SDK。最初的 `union` 支持在预览版 2 中添加,但如果你安装预览版 4+,体验会更流畅。 - 在 .csproj 文件中启用预览版语言支持,添加 `preview` 标记。 ```xml <Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <OutputType>Exe</OutputType> <TargetFrameworks>net11.0;net8.0;net48</TargetFrameworks> <LangVersion>preview</LangVersion> <ImplicitUsings>enable</ImplicitUsings> <Nullable>enable</Nullable> </PropertyGroup> </Project> ``` 注意,尽管你需要使用 .NET 11 SDK,但*可以*针对早期版本的运行时,比如上面 .csproj 文件中我这样做的。`union` 支持是作为编译器功能实现的,因此在早期运行时上也可用(即使从技术上讲,它们可能不被官方支持 (https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/language-versioning))。然而,如果你针对的是早期运行时(或者你使用的是 .NET 11 预览版 2 或预览版 3),那么你还需要在项目中添加一些辅助类型: ```csharp #if !NET11_0_OR_GREATER namespace System.Runtime.CompilerServices; [AttributeUsage(Class | Struct, AllowMultiple = false, Inherited = false)] public sealed class UnionAttribute : Attribute; public interface IUnion { object? Value { get; } } ``` 这些内容已添加 (https://github.com/dotnet/runtime/pull/127001) 到 .NET 11 预览版 4 中,因此如果你使用的是较新的 SDK,它们会自动可用;但如果你针对的是早期运行时,无论如何都需要包含它们。正如你可能猜到的,编译器在创建 `union` 类型时会使用这个属性并实现这个接口。在下一节中,我们将看看生成的代码是什么样的,以理解 `union` 类型是如何实现的。 就 IDE 支持而言,如果你使用的是 Visual Studio 预览版或 VS Code 的 C# DevKit 内测版,那么你应该有初步支持。对 JetBrains Rider 的支持仍在待定中 (https://youtrack.jetbrains.com/projects/RIDER/issues/RIDER-135866/ETA-for-net11-preview-1-support)。 ## `union` 类型是如何实现的? (https://andrewlock.net/exploring-the-dotnet-11-preview-2-dotnet-gets-union-types/#how-are-union-types-implemented) 你可以在[此处](https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/proposals/unions)查看 `union` 类型的完整规范,但标准的生成代码实际上非常简单: ```csharp using System.Runtime.CompilerServices; [Union] public struct SupportedOS : IUnion { public object? Value { get; } // 每个 case 类型的构造函数 public SupportedOS(Windows value) => this.Value = (object) value; public SupportedOS(Linux value) => this.Value = (object) value; public SupportedOS(MacOS value) => this.Value = (object) value; } ``` 可以看出,生成的 `SupportedOS` 类型: - 是一个 `struct`,并带有 `[Union]` 属性。 - 有一个只读的 `object? Value` 属性,实现了 `IUnion` 接口。 - 为它所支持的每个 case 类型都有一个构造函数。 我有点惊讶地发现,从 case 类型到 `SupportedOS` 类型并没有隐式转换,尽管我们可以写出这样的代码: ```csharp SupportedOS os = new MacOS("Tahoe", 25); ``` 然而,看起来编译器只是将其重写为使用 `[Union]` 构造函数: ```csharp // SupportedOS os = new MacOS("Tahoe", 25); // 编译器发出的代码看起来像这样: SupportedOS os = new SupportedOS(new MacOS("Tahoe", 25)); ``` 这种隐式转换完全由 `[Union]` 属性驱动。如果我们重写我们的示例,*不*使用 `union` 关键字,而是使用前面显示的实现代码,但“忘记”包含 `[Union]` 属性,就可以看到这一点: ```csharp using System.Runtime.CompilerServices; SupportedOS os = new MacOS("Tahoe", 25); // Cannot implicitly convert type 'MacOS' to 'SupportedOS' var description = os switch { Windows windows => $"Windows {windows.Version}", // An expression of type 'SupportedOS' cannot be handled by a pattern of type 'Windows' Linux linux => $"{linux.Distro} {linux.Version}", // An expression of type 'SupportedOS' cannot be handled by a pattern of type 'Linux' MacOS macOS => $"MacOS {macOS.Name} ({macOS.Version})", // An expression of type 'SupportedOS' cannot be handled by a pattern of type 'MacOS' }; public record Windows(string Version); public record Linux(string Distro, string Version); public record MacOS(string Name, int Version); // 👇 此属性对于成为有效的 Union 类型是必需的, // 但此处仅为演示目的删除 // [Union] public struct SupportedOS : IUnion { public object? Value { get; } public SupportedOS(Windows value) => this.Value = (object) value; public SupportedOS(Linux value) => this.Value = (object) value; public SupportedOS(MacOS value) => this.Value = (object) value; } ``` 上述代码无法编译,错误如下,展示了 `[Union]` 属性如何驱动隐式转换和 `switch` 表达式: ``` error CS0029: Cannot implicitly convert type 'MacOS' to 'SupportedOS' error CS8121: An expression of type 'SupportedOS' cannot be handled by a pattern of type 'Windows'. error CS8121: An expression of type 'SupportedOS' cannot be handled by a pattern of type 'Linux'. error CS8121: An expression of type 'SupportedOS' cannot be handled by a pattern of type 'MacOS'. ``` 如果你恢复 `[Union]` 属性,一切都可以正常编译和运行,这展示了如何创建你自己的*自定义*联合类型。 ## 通过自定义 Union 实现避免装箱 (https://andrewlock.net/exploring-the-dotnet-11-preview-2-dotnet-gets-union-types/#avoiding-boxing-with-custom-union-implementations) 既然我们*刚刚*获得了对 `union` 类型的支持,为什么你会想要创建*自定义*的 `Union` 类型呢?一个原因是,你可能*已经*在使用自定义的联合类型,比如由 OneOf (https://www.nuget.org/packages/OneOf) 或 Sasa (https://www.nuget.org/packages/Sasa) 提供的类型(我过去使用过这两个包)。在这种情况下,这些库可以通过简单地实现 `IUnion` 接口并添加 `[Union]` 属性,从而受益于内置的语言支持(例如 `switch` 表达式支持)。 另一种情况是,当“将 case 类型存储在 `object` 实例中”对你来说还不够好时。生成的联合类型*总是*一个带有单个 `object` 字段的 `struct`。这意味着如果你创建了一个包含多个 `struct` 类型的 `union`,这些类型将会被装箱到托管堆上。例如,假设你需要这个 `union`,它可以表示一个 `int` 或一个 `bool`: ```csharp public union IntOrBool(int, bool); ``` 问题是,传递给 `IntOrBool` 构造函数的 `int` 或 `bool` 会立即被装箱为 `object` 并存储在 `Value` 属性中: ```csharp [Union] public struct IntOrBool : IUnion { public object? Value { get; } // 结构体参数总是被装箱,在堆上分配 public IntOrBool(int value) => this.Value = (object) value; public IntOrBool(bool value) => this.Value = (object) value; } ``` 这会在堆上分配,通常是不希望的,因为 `union` 类型旨在在性能方面基本透明。任何使用此实现的 `switch` 表达式同样会使用 `Value` 属性。例如,使用基本的内置 `union` 实现,以下表达式: ```csharp IntOrBool intOrBool; var description = intOrBool switch { int i => "integer", bool b => "bool", }; ``` 会降级为类似于以下的代码: ```csharp IntOrBool unmatchedValue = new IntOrBool(23); object obj = unmatchedValue.Value; // 👈 访问装箱的值 string str; if (obj is int _) { str = "integer"; } else if (obj is bool _) { str = "bool"; } else { ThrowSwitchExpressionException((object) unmatchedValue); // 不会发生,但已处理 } ``` 在许多情况下,装箱分配并不会带来太大影响,但在其他情况下,比如热点路径中,装箱是不希望的。为了解决这个问题,`union` 功能允许一种“非装箱”实现,使用 `TryGetValue` 模式。这要求你实现: - `bool HasValue { get; }` – 如果存储的值非 `null`,则返回 `true` - 对于每个 case 类型 `T`,实现 `bool TryGetValue(out T value)` 例如,以下是上面 `IntOrBool` 类型的一个[可能实现](https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/proposals/unions#examples-of-union-types),避免了装箱: ```csharp [Union] public struct IntOrBool : IUnion { private readonly bool _isBool; private readonly int _value; public IntOrBool(int value) { _isBool = false; _value = value; } public IntOrBool(bool value) { _isBool = true; _value = value ? 1 : 0; } public bool HasValue => true; // 这些值永远不会为空 public bool TryGetValue(out int value) // 无需装箱获取 int 值 { value = _value; return !_isBool; } public bool TryGetValue(out bool value) // 无需装箱获取 bool 值 { value = _isBool && _value is 1; return _isBool; } // 👇 必须实现此属性以符合 IUnion, // 但它仍然会装箱,不过默认情况下不会被使用。 public object Value => _isBool ? _value is 1 : _value; } ``` 当你实现 `TryGetValue()` 方法时,编译器会自动在 `switch` 表达式中使用它们,而不是使用 `Value` 属性,因此上面的 switch 表达式会变为以下代码: ```csharp IntOrBool unmatchedValue = new IntOrBool(23); string str; // 👇 调用 TryGetValue 而不是使用装箱的 Value 属性 if (unmatchedValue.TryGetValue(out int _)) { str = "integer"; } else if (unmatchedValue.TryGetValue(out bool _)) { str = "bool"; } else { ThrowSwitchExpressionException((object) unmatchedValue); // 不会发生,但已处理 } ``` 根据你的代码路径和用例,创建这样的自定义非装箱实现是否值得,取决于你在代码库中使用 `union` 类型的目的。 ## 还有哪些功能尚未到来?

相似文章

TypeScript 如何分配联合类型

Lobsters Hottest

一篇深度文章,解释 TypeScript 如何在重载函数、方法接收者和条件类型中分配联合类型,并包含示例和解决方法。

dotnet/skills

GitHub Trending (daily)

.NET 团队为编码代理精心策划的核心技能与自定义代理集合,涵盖 .NET 编码、数据访问、诊断、MSBuild、NuGet、升级、MAUI、AI、模板、测试、ASP.NET 以及 .NET 11。

改进 C# 内存安全

Hacker News Top

微软宣布对 C# 16 中的 unsafe 关键字进行重新设计,以强制执行内存安全契约,使 unsafe 操作变得可见并由编译器强制执行,预览版将在 .NET 11 中发布,正式版在 .NET 12 中发布。