C-10-编程指南-全-

C#10 编程指南(全)

原文:zh.annas-archive.org/md5/f6bf98ae10aa686be15d58fe9358e0e2

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

C#现在已经存在大约二十年。它在强大性和规模上稳步增长,但微软始终保持了其基本特性的完整性。每一个新的能力都设计成与其余部分清晰集成,增强语言而不将其变成杂乱无章的特性集合。

虽然 C#在其核心上仍然是一种相当简单的语言,但现在关于它的内容远远超过了它的首次出现。由于需要涵盖如此广泛的内容,本书期望读者具备一定的技术能力。

本书的受众

我写这本书是为了有经验的开发者——我多年来一直在编程,并且我决定把这本书设计成我如果在其他语言上有这种经验,并且今天正在学习 C#时想要阅读的书籍。尽管早期版本解释了一些基本概念,如类、多态性和集合,但我假设读者已经知道这些是什么。早期章节仍然描述 C#如何呈现这些常见概念,但重点是特定于 C#的细节,而不是广泛的概念。

本书使用的约定

本书使用以下排版约定:

斜体

指示新术语,URL,电子邮件地址,文件名和文件扩展名。

Constant width

用于程序清单,以及在段落中引用程序元素,例如变量或函数名称,数据库,数据类型,环境变量,语句和关键字。

Constant width bold

显示用户应直接输入的命令或其他文本。在示例中,突出显示特别感兴趣的代码。

Constant width italic

显示应替换为用户提供的值或由上下文确定的值的文本。

提示

这个元素表示一个提示或建议。

注意

这个元素表示一般提示。

警告

这个元素表示一个警告或注意事项。

使用代码示例

可下载的补充材料(代码示例、练习等)位于https://oreil.ly/prog-cs-10-repo

如果您有技术问题或使用代码示例遇到问题,请发送电子邮件至bookquestions@oreilly.com

本书旨在帮助您完成工作。通常情况下,如果本书提供了示例代码,您可以在您的程序和文档中使用它。除非您复制了大量代码,否则无需联系我们请求许可。例如,编写一个使用本书中几个代码片段的程序不需要许可。销售或分发 O'Reilly 书籍中的示例代码需要许可。引用本书并引用示例代码回答问题不需要许可。将本书中大量示例代码整合到您产品的文档中需要许可。

我们感谢您的支持,但通常不要求署名。一般的署名包括标题、作者、出版商和 ISBN。例如:“Programming C# 10 by Ian Griffiths (O’Reilly). Copyright 2022 by Ian Griffiths, 978-1-098-11781-8.”

如果您认为您对代码示例的使用超出了合理使用范围或上述许可,请随时通过permissions@oreilly.com与我们联系。

O’Reilly 在线学习

注意

超过 40 年来,O’Reilly为公司提供技术和商业培训、知识和见解,帮助它们取得成功。

我们独特的专家和创新者网络通过书籍、文章、会议和我们的在线学习平台分享他们的知识和专长。O’Reilly 的在线学习平台为您提供按需访问的实时培训课程、深度学习路径、交互式编码环境,以及来自 O’Reilly 和其他 200 多家出版商的大量文本和视频。更多信息,请访问http://oreilly.com

如何联系我们

关于本书的评论和问题,请联系出版商:

  • O’Reilly Media, Inc.

  • 1005 Gravenstein Highway North

  • CA 95472,Sebastopol

  • 800-998-9938 (美国或加拿大)

  • 707-829-0515 (国际或本地)

  • 707-829-0104 (传真)

我们为这本书建立了一个网页,列出勘误、示例和任何额外信息。您可以访问https://oreil.ly/prgrmg-c-10获取更多信息。

通过bookquestions@oreilly.com向我们发送评论或技术问题的电子邮件。

获取关于我们的书籍和课程的新闻和信息,请访问https://oreilly.com

在 LinkedIn 上找到我们:https://linkedin.com/company/oreilly-media

关注我们的 Twitter 账号:https://twitter.com/oreillymedia

在 YouTube 上关注我们:https://youtube.com/oreillymedia

致谢

衷心感谢本书的官方技术审稿人:Stephen Toub、Howard van Rooijen 和 Glyn Griffiths。我还要特别感谢那些审阅单独章节或以其他方式提供帮助或信息以改进本书的人:Brian Rasmussen、Eric Lippert、Andrew Kennedy、Daniel Sinclair、Brian Randell、Mike Woodring、Mike Taulty、Bart De Smet、Matthew Adams、Jess Panni、Jonathan George、Mike Larah、Carmel Eve、Ed Freeman、Elisenda Gascon、Jessica Hill、Liam Mooney、Nehemiah Campbell 和 Shahryar Saljoughi。特别感谢 endjin,不仅允许我抽出时间写这本书,还为创造这样一个优秀的工作环境而致谢。

感谢 O’Reilly 公司的所有人员,他们的工作使这本书得以问世。特别感谢 Corbin Collins 在推动这本书问世方面的支持,以及 Amanda Quinn 在启动这个项目方面的支持。还要感谢 Elizabeth Faerm、Cassandra Furtado、Ron Bilodeau、Nick Adams、Kate Dullea、Karen Montgomery 和 Kristen Brown,在完成这项工作中的帮助。进一步感谢 Sue Klefstad 和 WordCo Indexing Services, Inc. 对索引的工作。也要感谢 Kim Cofer 进行了彻底而周到的编辑工作,以及 Kim Sandoval 的勤奋校对工作。最后,感谢 John Osborn,在我写第一本书时成为 O’Reilly 的作者。

第一章:介绍 C#

C# 编程语言(发音为 “see sharp”)用于许多类型的应用程序,包括网站、基于云的系统、物联网设备、机器学习、桌面应用程序、嵌入式控制器、移动应用程序、游戏和命令行实用程序。C# 和相关的运行时、库和工具被称为 .NET,已经在 Windows 开发者中心舞台上超过 20 年。如今,.NET 是跨平台和开源的,使得用 C# 编写的应用程序和服务可以在包括 Android、iOS、macOS 和 Linux 在内的操作系统上运行,以及 Windows。

C# 10.0 的发布及其对应的运行时 .NET 6.0 标志着一个重要的里程碑:C# 成为完全跨平台、开源语言的旅程现已完成。尽管在 C# 的大部分历史中都存在开源实现,但在 2016 年,微软发布了 .NET Core 1.0,这是第一个由微软全面支持在 Linux 和 macOS 以及 Windows 上运行 C# 的平台。最初 .NET Core 的库和工具支持并不完善,因此微软继续发布其较旧的运行时版本,即仅限 Windows 的封闭源 .NET Framework,但六年后,这个旧运行时版本实际上已经退出,¹ 现在跨平台版本全面超越了它。.NET 5.0 删除了其名称中的 “Core”,表明它现在是主要版本,但是在 .NET 6.0 中,跨平台版本才真正到来,因为这个版本享有完整的 长期支持 (LTS) 状态。首次,这个与平台无关的 C# 和 .NET 版本已经取代了旧的 .NET Framework。

C# 和 .NET 是开源项目,尽管最初并非如此。在 C# 的早期历史中,微软严格保护其所有源代码,但在 2014 年创建了 .NET Foundation 来促进 .NET 世界中开源项目的发展。现在,微软许多重要的 C# 和 .NET 项目都在该基金会的管理下(除了许多非微软项目)。这包括 微软的 C# 编译器.NET 运行时与库。如今,几乎围绕 C# 的所有内容都是在公开开发的,欢迎外部人员贡献代码。新的语言特性提案在 GitHub 上进行管理,从最早的阶段就能够进行社区参与。

为什么选择 C#?

尽管可以使用多种方式使用 C#,其他语言始终是一个选择。为什么你会选择 C#而不是其他语言?这取决于您需要做什么,以及您在编程语言中喜欢和不喜欢的方面。我发现 C#提供了相当大的力量、灵活性和性能,并且以足够高的抽象级别工作,以至于我不会在程序试图解决的问题的细节上花费大量精力。

C#的强大之处在于它支持的多种编程技术。例如,它提供面向对象的特性、泛型和函数式编程。它支持动态和静态类型。由于语言集成查询(LINQ),它提供了强大的列表和集合操作功能。它还具有异步编程的内在支持。此外,支持 C#的各种开发环境都提供了广泛的增强生产力的功能。

C#提供了在开发便捷性与性能之间取得平衡的选项。运行时一直提供垃圾回收器(GC),使开发人员不必过多地处理程序不再使用的内存回收工作。GC 在现代编程语言中是一项常见功能,虽然对大多数程序有益,但在某些特定场景下其性能影响可能成问题,因此 C#支持更显式的内存管理方式,使您可以在不丧失类型安全性的前提下,在开发便捷性与运行时性能之间进行权衡。这使得 C#适用于多年来一直是较不安全语言(如 C 和 C++)所独有的某些对性能要求极高的应用场景。

编程语言并非孤立存在,具备广泛特性的高质量库至关重要。一些优雅而学术美观的语言在处理平凡任务时仍显光辉,例如与数据库交互或确定用户设置存储位置。无论语言提供了多么强大的编程习语,它还需要提供对底层平台服务的完整便捷访问。在这方面,C#表现非常强大,归功于其运行时、内置类库以及广泛的第三方库支持。

.NET 包括 C#程序使用的运行时和主要类库。运行时部分称为公共语言运行时(通常缩写为 CLR),因为它不仅支持 C#,还支持任何.NET 语言。例如,Microsoft 还提供 Visual Basic、F#以及 C++的.NET 扩展。CLR 具有公共类型系统(CTS),它使来自多种语言的代码可以自由互操作,这意味着.NET 库通常可以从任何.NET 语言中使用——F#可以使用用 C#编写的库,C#可以使用 Visual Basic 库,等等。

在 .NET 中内置了一套庞大的类库集合。多年来,这些类库曾用过几个名称,包括基础类库 (BCL)、框架类库和框架库,但是微软现在似乎已经将 运行时类库 定为 .NET 这一部分的名称。这些类库为许多底层操作系统 (OS) 功能提供了包装器,同时它们本身也提供了大量的功能,例如集合类和 JSON 处理等。

.NET 运行时类库并非全部内容——许多其他系统也提供了它们自己的 .NET 类库。例如,有些类库使得 C# 程序可以使用流行的云服务。正如你所预期的那样,Microsoft 提供了全面的 .NET 类库,用于与其 Azure 云平台上的服务进行交互。同样,亚马逊提供了一个功能完备的开发工具包,供使用 C# 和其他 .NET 语言访问 Amazon Web Services (AWS)。并且,类库并不一定要与特定服务相关联。有一个庞大的 .NET 类库生态系统,其中既有商业产品,也有免费产品,包括数学工具、解析类库以及用户界面 (UI) 组件等等。即使你不幸需要使用一个没有任何 .NET 类库包装器的操作系统特性,C# 也提供了各种机制,用于与其他类型的 API 进行交互,例如在 Win32、macOS 和 Linux 上可用的 C 风格 API,或者在 Windows 上基于组件对象模型 (COM) 的 API。

除了类库之外,还有许多应用框架。.NET 内置了用于创建 Web 应用程序和 Web API、桌面应用程序以及移动应用程序的框架。还有针对各种分布式系统开发风格的开源框架,例如高容量事件处理的 Reaqtor 或者全球分布式高可用系统的 Orleans

最后,随着 .NET 已经存在了二十多年,许多组织已经大量投资于基于这一平台构建的技术。因此,C# 往往是获得这些投资回报的自然选择。

总之,使用 C# 我们得到了一组内置的强大抽象,一个强大的运行时,以及轻松访问大量的类库和平台功能。

托管代码和 CLR

C# 是第一种旨在成为 CLR 世界中本地语言的语言。这赋予了 C# 独特的感觉。这也意味着,如果你想理解 C#,你需要了解 CLR 及其运行代码的方式。

多年来,编译器处理源代码并生成可由计算机 CPU 直接执行的输出形式,一直是最常见的工作方式。编译器会生成机器码 ——符合计算机 CPU 所需的二进制格式的一系列指令。许多编译器仍然采用这种方式工作,但 C# 编译器不是这样。它使用一种称为托管代码的模型。

在托管代码中,编译器不会生成 CPU 执行的机器码。相反,编译器生成一种称为中间语言(IL)的二进制代码形式。可执行二进制通常在运行时生成,虽然不总是如此。使用 IL 使得在传统模型下难以或者甚至不可能提供的功能成为可能。

可能托管模型最明显的好处是,编译器的输出不与单一的 CPU 架构绑定。例如,大多数现代计算机使用的 CPU 支持 32 位和 64 位指令集(分别因历史原因而称为x86x64)。在旧模型下将源代码编译成机器语言时,您需要选择要支持的指令集之一,并且在需要目标多个指令集时,需要构建多个版本的组件。但是在 .NET 中,您可以构建一个单一的组件,无需修改即可在 32 位或 64 位进程中运行。同一组件甚至可以在完全不同的架构上运行,例如 ARM(一种广泛用于手机、较新的 Mac 和树莓派等小型设备的处理器架构)。如果使用直接编译为机器码的语言,则需要为每种架构构建不同的二进制文件,或者在某些情况下,可能会构建一个包含多个代码副本的单一文件,每个副本针对每种支持的架构。在 .NET 中,您可以编译一个只包含一个代码版本的单一组件,它可以在任何架构上运行。即使在编译代码时未支持的平台未来提供了合适的运行时,这些组件也可以本地运行,而不依赖于通常用于使旧代码在新处理器上工作的Rosetta翻译技术。更一般地说,CLR 代码生成的任何改进——无论是对新 CPU 架构的支持还是对现有架构的性能改进——都会立即使所有 .NET 语言受益。例如,早期版本的 CLR 没有利用现代 x86 和 x64 处理器上可用的向量处理扩展,但当前版本通常在生成循环代码时会利用这些扩展。所有运行在当前 .NET 版本上的代码都从中受益,包括在此增强功能添加之前构建的组件。

CLR 生成可执行机器代码的确切时机可能会有所不同。通常情况下,它使用一种称为即时(JIT)编译的方法,即每个单独函数的机器代码在第一次运行时生成。但它不一定非得这样工作。运行时实现之一称为 Mono,能够直接解释 IL,而不必将其转换为可运行的机器语言,这在诸如 iOS 这样的平台上非常有用,因为法律约束可能阻止 JIT 编译。.NET 软件开发工具包(SDK)还提供了一个名为crossgen的工具,它使你能够在 IL 旁边构建预编译代码。这种提前编译(AoT)可以提高应用程序的启动时间。还有一个完全独立的运行时称为.NET Native,它仅支持预编译,并且被用于为通用 Windows 平台(UWP)构建的 Windows Store 应用程序。(请注意,微软已宣布 Windows 专用的.NET Native 运行时可能会被其跨平台后继者 NativeAOT 所取代,逐步淘汰。)

即使使用 crossgen 预编译代码,仍然可能在运行时生成可执行代码。CLR 的分层编译功能可以选择动态重新编译方法,以优化其在运行时的使用方式,无论您使用 JIT 还是 AoT,它都可以做到这一点。²

托管代码具有普遍存在的类型信息。.NET 运行时需要这些信息存在,因为它启用了某些运行时特性。例如,.NET 提供各种自动序列化服务,可以将对象转换为其状态的二进制或文本表示,并且稍后可以将这些表示再转换回对象,甚至可能在不同的计算机上。这种服务依赖于对象结构的完整和准确描述,在托管代码中是有保证的。类型信息还可以用于其他方面。例如,单元测试框架可以使用它来检查测试项目中的代码,并发现你编写的所有单元测试。这依赖于 CLR 的反射服务,这是第十三章的主题。

尽管 C#与运行时的紧密连接是其主要的定义特征之一,但这并不是唯一的特征。C#的设计背后有一定的哲学支持。

C#更偏向于泛化而不是特化

C#更倾向于通用语言特性而不是专用特性。C#现在已经是其第 10 个主要版本,并且每次发布时,语言的设计者都在设计新功能时考虑了特定的场景。然而,他们始终努力确保每个添加的元素在超出这些主要场景时也是有用的。

例如,几年前,C# 语言设计师决定向 C# 添加功能,使数据库访问与语言紧密集成。由此产生的技术,即语言集成查询(LINQ,详见 第十章),确实支持了这一目标,但他们在不向语言直接添加数据访问支持的情况下实现了这一点。设计团队引入了一系列看似差异很大的能力,包括更好地支持函数式编程习惯用法,能够在不使用继承的情况下向现有类型添加新方法,支持匿名类型,能够获取表示表达式结构的对象模型,并引入了查询语法。其中最后一个与数据访问有明显的关联,但其他的与当前任务的关联则较为困难。尽管如此,这些能力可以集体使用,显著简化某些数据访问任务。这些功能在其自身权威上都很有用,因此除了支持数据访问外,它们还能够支持更广泛的场景。例如,这些添加使得处理列表、集合和其他对象组变得更加容易,因为新功能适用于来自任何来源的事物集合,而不仅仅是数据库。

这种通用性哲学的一个例证是为 C# 原型化但最终设计师们选择不继续推进的语言功能。该功能将允许您直接在源代码中编写 XML,在运行时嵌入表达式以计算特定内容的值。该原型将其编译为在运行时生成完成的 XML 的代码。微软研究部门公开展示了这一功能,但这一特性最终没有进入 C#,尽管它后来在另一种 .NET 语言 Visual Basic 中推出,并为从 XML 文档中提取信息提供了一些专门的查询功能。嵌入式 XML 表达式是一个相对狭窄的功能,只在创建 XML 文档时有用。至于查询 XML 文档,C# 通过其通用的 LINQ 功能支持此功能,而无需任何特定于 XML 的语言功能。自从提出这个语言概念以来,XML 的星光已经逐渐黯淡,在许多情况下已被 JSON 取代(毫无疑问,这些年后将被其他东西所取代)。如果嵌入式 XML 最终进入了 C#,那么现在它可能会感觉像一个略显过时的奇特现象。

在后续版本的 C# 中添加的新功能继续沿着同样的思路发展。例如,跨过去几个版本添加的解构和模式匹配功能旨在以微妙但有用的方式简化生活,并且不限于任何特定的应用领域。

C# 标准与实现

在我们可以开始编写实际代码之前,我们需要知道我们正在目标化哪个 C# 实现和运行时。Ecma 标准化机构编写了定义 C# 语言和运行时行为的规范(分别是 ECMA-334 和 ECMA-335)。这使得多个 C# 实现和运行时得以出现。目前,广泛使用的有四种:Mono、.NET Native、.NET(之前称为 .NET Core)和 .NET Framework。有些令人困惑的是,微软背后支持了所有这些项目,尽管最初并非如此。

许多 .NET 实现

Mono 项目于 2001 年启动,并非起源于微软。(这就是为什么它的名字中没有 .NET,它可以使用 C# 这个名称,因为标准称该语言为 C#,但在 .NET 基金会成立前,.NET 品牌专门由微软使用。)Mono 最初的目标是在 Linux 上支持使用 C# 进行桌面应用程序开发,但后来它增加了对 iOS 和 Android 的支持。这一重要举措帮助 Mono 找到了自己的市场定位,因为它现在主要用于开发跨平台移动设备应用程序的 C#。现在,Mono 还支持目标 WebAssembly(也称为 WASM),并包括一个可以在任何符合标准的 Web 浏览器中运行的 CLR 实现,使得 C# 代码能够在 Web 应用程序的客户端上运行。这通常与一个名为 Blazor 的 .NET 应用程序框架一起使用,Blazor 允许您构建基于 HTML 的用户界面,同时使用 C# 实现行为。Blazor 与 WASM 的组合还使得 C# 成为与 Electron 等使用 Web 客户端技术创建跨平台桌面应用程序的平台合作的一种可行语言。(Blazor 不需要 WASM,它也可以使用正常编译的 C# 代码在 .NET 运行时上运行;这是 .NET 的多平台应用程序用户界面(MAUI)的基础,它使得编写可以在 Android、iOS、macOS 和 Windows 上运行的单一应用程序成为可能。)

Mono 从一开始就是开源的,并且在其存在的整个过程中得到了多家公司的支持。2016 年,微软收购了拥有 Mono 管理权的公司:Xamarin。目前,微软将 Xamarin 作为一个独立的品牌保留下来,并将其定位为编写可在移动设备上运行的跨平台 C#应用程序的方式。Mono 的核心技术已经并入了微软的.NET 运行时代码库。这是多年融合的终点,其中 Mono 逐渐与.NET 共享越来越多的共同点。最初,Mono 提供了自己的一套实现:C#编译器、库和 CLR。但是当微软发布了其自己的开源编译器时,Mono 工具就转移到了那里。Mono 曾经有自己完整的.NET 运行时库实现,但自从微软首次发布开源.NET Core 以来,Mono 越来越依赖于它。如今,Mono 实际上是主要.NET 运行时库中两个 CLR 实现之一,支持移动和 WebAssembly 运行时环境。

其他三种实现是什么情况呢?它们似乎都被称为.NET?其中之一是.NET Native,用于 UWP 应用程序,正如前文所述,这是.NET 的一种专门版本,仅支持 AoT 编译。然而,.NET Native 计划被 NativeAOT 取代,后者将有效地成为.NET 的一个特性,而不是完全独立的实现,因此在实际应用中,我们现在只有两个当前的、非注定失败的版本:.NET Framework(仅限 Windows,闭源)和.NET(跨平台,开源;以前称为.NET Core)。然而,正如前面提到的,微软不打算向仅限 Windows 的.NET Framework 添加任何新功能,因此这使得.NET 6.0 实际上是唯一的当前版本。

.NET 6 的一个主要目标是回归到一个主要的当前版本,这使得它成为一个特别重要的版本。然而,了解其他版本也是有用的,因为你可能会遇到继续在这些版本上运行的实时系统。.NET Framework 继续流行的一个原因是它可以做一些.NET 6.0 无法做到的事情。.NET Framework 仅在 Windows 上运行,而.NET 6.0 支持 Windows、macOS 和 Linux,尽管这使得.NET Framework 的可用性较小,但它可以支持一些 Windows 特定的功能。例如,.NET Framework 类库中有一个部分专门用于与 COM+组件服务一起工作,这是一个用于托管与 Microsoft 事务服务器集成的组件的 Windows 特性。这在新的跨平台.NET 版本上是不可能的,因为代码可能在 Linux 上运行,那里的等效功能要么不存在,要么与通过相同的.NET API 呈现的方式有太大不同。

在过去几个版本中,仅限于.NET Framework 的特性数量已经大幅减少,因为微软一直致力于使即使是仅限 Windows 的应用程序也能使用最新版本的.NET 6.0。例如,System.Speech .NET 库过去仅在.NET Framework 上可用,因为它提供对 Windows 特定的语音识别和合成功能的访问,但现在有了.NET 6.0 版本的这个库。该库仅在 Windows 上工作,但其可用性意味着依赖它的应用程序开发人员现在可以自由地从.NET Framework 转移到.NET。未能迁移的剩余.NET Framework 特性是那些使用不足以证明工程投入的特性。COM+支持不仅仅是一个库——它对 CLR 执行代码的方式有影响,因此在现代.NET 中支持它会带来难以接受的成本,这现在已经是一个很少使用的功能了。

跨平台的.NET 是过去几年中大部分.NET 新开发发生的地方。.NET Framework 仍然得到支持,但已经落后了一段时间。例如,微软的 Web 应用程序框架 ASP.NET Core 在 2019 年就停止了对.NET Framework 的支持。因此,.NET Framework 的退役和.NET 6.0 作为唯一真正的.NET 的到来,是一个已经进行了几年的过程的不可避免的结论。

发布周期和长期支持

微软目前每年发布一个新版本的.NET,通常在 11 月或 12 月左右发布,但并非所有版本都是平等的。备用版本会得到长期支持(LTS),这意味着微软承诺至少支持该版本三年。在此期间,工具、库和运行时将定期更新以提供安全补丁。.NET 6.0 在 2021 年 11 月发布,是一个 LTS 版本。之前的 LTS 版本是.NET Core 3.1,于 2019 年 12 月发布,因此支持将持续到 2022 年 12 月;再早之前的 LTS 版本是.NET Core 2.1,在 2021 年 8 月停止支持。

那么非 LTS 版本呢?这些版本在发布时得到支持,但在下一个 LTS 版本发布六个月后就会停止支持。例如,.NET 5.0 在 2020 年 12 月发布时得到了支持,但在.NET 6.0 发布后的 2022 年 5 月支持就结束了。当然,微软可以选择延长支持,但为了规划目的,假设非 LTS 版本在大约 18 个月内基本上就无法使用了是明智的。

生态系统通常需要几个月的时间才能跟上新版本的发布。实际上,在发布当天可能还不能使用新版本的 .NET,因为你的云平台提供商可能还不支持,或者可能存在你需要使用的库的不兼容性。这显著缩短了非 LTS 版本的有效使用寿命,并可能导致在下一个 LTS 版本出现时,升级的时间窗口非常狭窄而令人不安。如果工具、平台和依赖的库需要几个月才能与新版本对齐,那么在它退出支持之前,你将有很少的时间可以升级。在极端情况下,这个升级的机会甚至可能不存在:.NET Core 2.2 在 Azure Functions 完全支持 .NET Core 3.0 或 3.1 之前已经到了支持结束的生命周期,因此那些在 Azure Functions 上使用非 LTS .NET Core 2.2 的开发者发现自己处于一个最新支持版本实际上倒退的情况:他们不得不选择要么回退到 .NET Core 2.1,要么在生产中使用不支持的运行时几个月。因此,一些开发者把非 LTS 版本看作预览版本:你可以试验性地针对新功能,预期它们会在 LTS 版本中使用。

使用 .NET Standard 针对多个 .NET 版本的目标

长期以来,每个运行时版本的多样性,每个都有其自己不同的运行时库版本,对于希望将其 C# 代码提供给其他开发者的人来说一直是一个挑战。尽管我们最终看到的 .NET 6.0 的收敛可以减少这种问题,但想要继续支持运行在旧 .NET Framework 上的系统将是常见的。这意味着,为了可预见的未来,生产目标多个 .NET 运行时的组件将是有用的。有一个.NET 组件的包存储库,微软发布所有不属于 .NET 本身的 .NET 库的地方,也是大多数 .NET 开发者发布他们想要分享的库的地方。但是,你应该为哪个版本构建呢?这是一个二维的问题:有运行时实现(.NET、.NET Framework)和版本(例如,.NET Core 3.1 或 .NET 6.0;.NET Framework 4.7.2 或 4.8)。许多通过 NuGet 分发的热门开源软件包的作者支持多个新旧版本。

组件作者过去常常通过构建多个库的变体来支持多个运行时。当通过 NuGet 分发 .NET 库时,你可以在包中嵌入多组二进制文件,每组针对不同的 .NET 变体。然而,其中一个主要问题是,随着多年来出现了新形式的 .NET,现有库可能无法在所有新的运行时上运行。为 .NET Framework 4.0 编写的组件将适用于所有后续版本的 .NET Framework,但不适用于比如说 .NET 6.0。即使组件的源代码与较新的运行时完全兼容,你也需要编译一个针对该平台的单独版本。如果你使用的库的作者没有为 .NET 提供明确的支持,这将阻止你使用它。这对每个人都是不利的。多年来出现了各种版本的 .NET(比如 Silverlight 和几个 Windows Phone 变体),这意味着组件作者发现自己不得不不断推出其组件的新变体,并且因为这依赖于那些作者是否有这样做的意愿和时间,组件的消费者可能会发现并非所有他们想要使用的组件都在他们选择的平台上可用。

为了避免这种情况,微软推出了 .NET Standard,它定义了 .NET 运行时库 API 表面的常见子集。如果一个 NuGet 包的目标是,比如说,.NET Standard 1.0,这就保证它能在 .NET Framework 版本 4.5 或更高版本、.NET Core 1.0 或更高版本、.NET 5.0 及更高版本,或者 Mono 4.6 及更高版本上运行。至关重要的是,如果出现了另一个 .NET 的变种,只要它也支持 .NET Standard 1.0,现有的组件就能够在不需修改的情况下运行,即使在编写这些组件时,那个新平台还不存在。

今天,.NET Standard 2.0 很可能是希望支持广泛平台的组件作者的最佳选择,因为所有最近发布的 .NET 版本都支持它,并且它提供了非常广泛的功能集。然而,微软今天支持的 .NET 变体数量远低于 .NET Standard 首次推出时的水平,因此 .NET Standard 的重要性可能不如过去。如今,将代码目标设置为 .NET Standard 的主要好处是你的代码将在 .NET Framework 以及 .NET Core 和 .NET 上运行。如果你不需要支持 .NET Framework,将代码目标设置为 .NET Core 3.1 或 .NET 6.0 可能更合理。第十二章 详细描述了围绕 .NET Standard 的一些考虑。

微软不仅提供语言和各种运行时及其相关的类库,还提供可以帮助你编写、测试、调试和维护代码的开发环境。

Visual Studio、Visual Studio Code 和 JetBrains Rider

微软提供了三种桌面开发环境:Visual Studio Code、Visual Studio 和 Visual Studio for Mac。这三款产品都提供了基本功能,如文本编辑器、构建工具和调试器,但是 Visual Studio 为开发 C#应用程序提供了最全面的支持,无论这些应用程序是在 Windows 还是其他平台上运行。Visual Studio 已经存在很长时间——从 C#诞生之时起,因此它来自于开源之前的时代,并继续作为闭源产品存在。各种可用的版本从免费到价格高昂都有。微软并不是唯一的选择:开发者生产力公司 JetBrains 销售一款名为 Rider 的完整的.NET IDE,它能在 Windows、Linux 和 macOS 上运行。

Visual Studio 是一种集成开发环境(IDE),因此采用“一切包含”的方式。除了功能齐全的文本编辑器外,它还提供了用于 UI 可视化编辑的工具。它与 Git 等源代码控制系统以及提供源代码库、问题跟踪和其他应用生命周期管理(ALM)功能的在线系统(例如 GitHub 和 Microsoft 的 Azure DevOps 系统)深度集成。Visual Studio 提供内置的性能监控和诊断工具。它具有多种特性,用于处理开发和部署到 Microsoft 的 Azure 云平台的应用程序。它是这三个 Microsoft 环境中拥有最广泛重构功能集的产品之一。请注意,Visual Studio 仅在 Windows 上运行。

2017 年,微软发布了适用于 Mac 的 Visual Studio。这不是 Windows 版本的简单移植。它起源于一个名为 Xamarin 的平台,这是一个专门用于在 Mac 上构建运行在 Mono 运行时上的 C#移动应用程序的开发环境。Xamarin 最初是一项独立技术,但在微软收购了开发它的公司后,微软将 Windows 版 Visual Studio 的各种功能整合到了这个产品中,并将其纳入 Visual Studio 品牌。

JetBrains Rider IDE 是一款能在三个操作系统上运行的单一产品。它比 Visual Studio 更专注,因为它专门设计用于支持.NET 应用程序开发(Visual Studio 也支持 C++)。它采用了类似的“一切包含”方式,并提供了特别强大的重构工具。

Visual Studio Code(通常缩写为 VS Code)于 2015 年首次发布。它是开源且跨平台的,支持 Linux 以及 Windows 和 Mac 操作系统。它基于 Electron 平台,并主要使用 TypeScript 编写。(这意味着与 Visual Studio 不同,VS Code 在所有操作系统上确实是同一个程序。)VS Code 比 Visual Studio 更加轻量级:基本安装仅支持文本编辑。然而,当您打开文件时,它会发现可下载的扩展程序,如果选择安装,可以为 C#、F#、TypeScript、PowerShell、Python 和许多其他语言添加支持。(扩展机制是开放的,因此任何愿意的人都可以发布扩展。)因此,尽管在初始形式上它更像是一个简单的文本编辑器而不是一个集成开发环境(IDE),其可扩展性模型使其非常强大。广泛的扩展程序范围使得 VS Code 在微软语言以外的世界中非常流行,进而促进了扩展程序范围更大的增长的良性循环。

Visual Studio 和 JetBrains Rider 提供了最简单的路径来开始使用 C# - 您无需安装任何扩展程序或修改任何配置即可启动并运行。但是,由于 Visual Studio Code 面向更广泛的受众,因此我将在接下来的快速介绍中使用它来进行 C#的工作。尽管如此,所有环境都适用于相同的基本概念,因此如果您将使用 Visual Studio 或 Rider,则我在这里描述的大部分内容仍然适用。

提示

您可以免费下载Visual Studio Code。您还需要安装.NET SDK

如果您使用 Windows 并希望使用 Visual Studio,您可以下载免费版本的 Visual Studio,称为Visual Studio Community。在安装期间,只要选择至少一个.NET 工作负载,它将为您安装.NET SDK。

任何非平凡的 C#项目都将具有多个源代码文件,并且这些文件将属于一个项目。每个项目构建一个单一的输出,或称为目标。构建目标可能会很简单,比如一个单文件 - 例如,一个 C#项目可以生成可执行文件或库 - 但某些项目会生成更复杂的输出。例如,某些项目类型会构建网站。网站通常包含多个文件,但总体而言,这些文件代表一个单一的实体:一个网站。每个项目的输出将作为一个单元部署,即使它由多个文件组成。

注意

在 Windows 上,可执行文件通常具有*.exe文件扩展名,而库使用.dll*(历史上简称为动态链接库)。然而,使用.NET,所有的代码都放在*.dll文件中。SDK 还可以生成引导执行文件(在 Windows 上具有.exe扩展名),但这只是启动运行时,然后加载包含主要编译输出的.dll文件。(如果你的目标是.NET Framework,则稍有不同:它会将应用程序直接编译为自我引导的.exe*,而不是分开的*.dll*。)无论如何,应用程序的主要编译输出和库的唯一区别在于前者指定了应用程序的入口点。这两种文件类型都可以导出供其他组件消费的功能。这些都是程序集的例子,是第十二章的主题。

C#项目文件使用*.csproj扩展名,如果你使用文本编辑器查看这些文件,你会发现它们包含 XML。.csproj*文件描述了项目的内容并配置了项目的构建方式。这些文件可以被 Visual Studio 和 VS Code 的.NET 扩展识别。它们也可以被各种命令行构建工具识别,例如.NET SDK 安装的dotnet命令行工具,以及微软的旧版 MSBuild 工具。(MSBuild 支持多种语言和目标,不仅仅是.NET。实际上,当你使用.NET SDK 的dotnet build命令构建 C#项目时,它实际上是 MSBuild 的一个包装。)

通常情况下,你会希望处理一组项目。例如,为你的代码编写测试是一个良好的实践,但大多数测试代码不需要作为应用程序的一部分部署,因此你通常会将自动化测试放入单独的项目中。你可能也想因其他原因拆分代码。也许你正在构建的系统有一个桌面应用程序和一个网站,而你希望在这两个应用程序中使用相同的通用代码。在这种情况下,你需要一个项目来构建包含通用代码的库,另一个生成桌面应用程序可执行文件,另一个构建网站,以及另外三个项目分别包含每个主项目的测试。

理解.NET 的构建工具和 IDE 帮助你通过所谓的解决方案来处理多个相关项目。解决方案是一个带有*.sln*扩展名的文件,定义了一组项目。尽管解决方案中的项目通常是相关的,但它们不一定非要相关。

如果你正在使用 Visual Studio,请注意,即使只有一个项目,它也要求项目属于一个解决方案。Visual Studio Code 可以愉快地打开单个项目,但其.NET 扩展也可以识别解决方案。

一个项目可以属于多个解决方案。在一个大型代码库中,通常会有多个带有不同项目组合的*.sln*文件。你通常会有一个包含每个项目的主解决方案,但并非所有开发人员都希望一直处理所有代码。在我们的假设示例中,处理桌面应用程序的人还会想要共享库,但可能对加载 Web 项目不感兴趣。

我将展示如何创建一个新项目,在 Visual Studio Code 中打开它并运行它。然后我将逐步介绍一个新的 C#项目的各种特性,作为语言介绍的一部分。我还会展示如何添加一个单元测试项目,以及如何创建一个包含两者的解决方案。

简单程序的解剖

一旦你安装了.NET 6.0 SDK,可以直接安装或者通过安装一个 IDE 来创建一个新的.NET 程序。首先在计算机上创建一个名为HelloWorld的新目录来保存代码。打开命令提示符,并确保当前目录设置为该目录,然后运行以下命令:

dotnet new console

这通过创建两个文件创建了一个新的 C#控制台应用程序。它创建一个基于父目录命名的项目文件:在这种情况下是HelloWorld.csproj。还会有一个包含代码的Program.cs文件。如果你在文本编辑器中打开这个文件,你会看到它非常简单,正如示例 1-1 所示。

示例 1-1. 我们的第一个程序
// See https://aka.ms/new-console-template for more information
Console.WriteLine("Hello, World!");

你可以使用以下命令编译并运行此程序:

dotnet run

正如你可能已经猜到的那样,这将显示文本Hello, World!作为输出。

如果你已经有一些 C#经验,并且正在阅读本书以了解 C# 10.0 中的新内容,这个例子可能会让你感到惊讶。在语言的早期版本中,所有编程书籍必须以经典的“Hello, World!”示例开头,而它要大得多。这看起来如此不同,以至于.NET SDK 的作者们认为有必要提供一个解释——这个例子的一半以上只是一个带有链接到网页的注释,解释其余代码的位置。这里的第二行就是你所需的全部内容。

这展示了 C# 10.0 引入的变化之一:它旨在通过减少样板代码的数量使应用程序直奔主题。样板代码是指需要存在以满足某些规则或约定的代码,但在任何项目中看起来多少都是一样的。例如,C#要求代码在方法内定义,而方法必须始终在类型内定义。你可以在示例 1-1 中看到这些规则的证据。为了产生输出,它依赖于.NET 运行时显示文本的能力,这体现在一个名为WriteLine的方法中。但我们不只是说WriteLine,因为 C#方法总是属于类型,这就是为什么代码将其标记为Console.WriteLine的原因。

当然,我们编写的任何 C# 代码都受到规则的约束,因此我们调用Console.WriteLine方法的代码本身必须存在于一个类型内的方法中。在大多数 C# 代码中,这是显式的:在大多数情况下,您将看到类似示例 1-2 的代码。

示例 1-2. 可见样板的“Hello, World!”
using System;

internal class Program
{
    private static void Main(string[] args)
    {
        Console.WriteLine("Hello, World!");
    }
}

在这里仍然只有一行定义应用程序行为的代码,与示例 1-1 中相同。第一个示例的明显优势在于它让我们集中精力在程序实际做什么上,尽管缺点是很多东西都会变得看不见。在示例 1-2 中采用显式风格,没有任何隐藏。在示例 1-1 中,编译器仍然会将代码放在一个名为Program的类型内定义的方法中;只是从代码中看不出来而已。在示例 1-2 中,方法和类型都是清晰可见的。

实际上,大多数 C# 代码看起来更像示例 1-2 而不是示例 1-1,因为 C# 10.0 的大部分样板减少措施只是为了程序入口点。当您编写希望在程序启动时执行的代码时,您不需要定义一个包含类或方法。但是一个程序只有一个入口点,对于其他所有内容,您仍然需要详细说明。

由于实际项目涉及多个文件,通常还涉及多个项目,让我们进入一个稍微现实的例子。我将创建一个计算一些数字平均值(确切地说是算术平均值)的程序。我还将创建第二个项目来自动测试我们的第一个项目。由于我有两个项目,这次我将需要一个解决方案。我将创建一个名为Averages的新目录。如果您在跟着做,无论放在哪里都没有关系,尽管最好不要将其放在第一个项目的目录内。我将在该目录中打开命令提示符并运行以下命令:

dotnet new sln

这将创建一个名为Averages.sln的新解决方案文件。(默认情况下,dotnet new通常根据其包含目录的名称命名新项目和解决方案,尽管您可以指定其他名称。)现在我将使用以下两个命令添加我需要的两个项目:

dotnet new console -o Averages
dotnet new mstest -o Averages.Tests

这里的-o选项(缩写为output)表示我希望每个新项目都在新的子目录中创建——当您有多个项目时,每个项目都需要其自己的目录。

现在我需要将它们添加到解决方案中:

dotnet sln add ./Averages/Averages.csproj
dotnet sln add ./Averages.Tests/Averages.Tests.csproj

我将使用第二个项目来定义一些测试,检查第一个项目中的代码(这就是为什么我指定了mstest项目类型——这个项目将使用微软的单元测试框架)。为了使其工作,第二个项目将需要访问第一个项目中的代码。为了实现这一点,我运行以下命令:

dotnet add ./Averages.Tests/Averages.Tests.csproj reference
./Averages/Averages.csproj

(我把它分成两行以便适应,但需要作为单个命令运行。) 最后,为了编辑项目,我可以使用以下命令在当前目录中启动 VS Code:

code .

如果你在跟着做,并且这是你第一次运行 VS Code,它会要求你做一些决策,比如选择一个配色方案。你可能会忽略它的问题,但此时它提供的其中一个选项是安装语言支持的扩展。人们使用 VS Code 来处理各种语言,安装程序不会假设你将使用哪种语言,所以你必须安装一个扩展来获取 C# 支持。但是如果你按照 VS Code 的指示浏览语言扩展,它会提供微软的 C# 扩展。如果 VS Code 没有提供这样做,请不要惊慌。也许你已经安装了它,所以它不再询问这些入门问题,或者自从我写这篇文章以来,Code 的首次运行行为发生了变化。你仍然可以非常容易地找到这个扩展。点击左侧栏上的 Extensions 图标,它将显示一组它认为可能相关的扩展。如果你在一个包含 .csproj 文件的目录中打开了 VS Code,这将包括 C# 扩展。如果其他方法都失败了,你可以搜索你需要的扩展。图 1-1 显示了 VS Code 的扩展面板——你可以通过点击左侧栏上的图标进入这个面板。这里底部显示的是四个方块的那一个。

Visual Studio Code 的 C# 扩展

图 1-1. Visual Studio Code 的 C# 扩展

正如你所见,我在顶部的搜索框中输入了C#,这里的第一个结果是微软的 C#扩展。还有几个其他结果也显示出来。如果你在跟着做,请确保选择正确的结果。如果你点击搜索结果,它将显示更详细的信息,其中应该显示其全名为“C# for Visual Studio Code (powered by OmniSharp)”,并且显示“Microsoft”作为发布者。点击安装按钮来安装这个扩展。

安装 C# 扩展可能需要几分钟时间,但一旦完成,窗口左下角的状态栏应该类似于 图 1-2,显示解决方案文件的名称和一个火焰图标,表示 OmniSharp 已准备好,这是在 VS Code 中提供 C# 支持的系统。可能会在窗口顶部出现一个项目选择器——C# 扩展已扫描解决方案目录并找到两个 C# 项目及其所在的解决方案。通常它会直接打开解决方案文件,但根据你的系统配置,它可能会询问你想使用哪个。我将在解决方案的两个项目中进行工作,所以我将选择 Averages.sln 条目。

Visual Studio Code 的状态栏显示 OmniSharp 图标和解决方案文件的名称

Figure 1-2. Visual Studio Code 状态栏

现在 C# 扩展将检查解决方案中所有项目的所有源代码。显然,这些项目中目前没有太多内容,但随着我输入代码,它将继续分析,帮助我识别问题并提出建议。在此过程中,它会注意到尚未为项目配置构建和调试设置。如 Figure 1-3 所示,它会在窗口右下角显示一个对话框,提供添加这些设置的选项。建议点击“是”按钮,并在询问你要启动哪个项目时选择主程序 Averages.csproj,以便 VS Code 在运行或调试代码时知道要使用哪一个。

对话框显示以下文本:构建和调试所需的资源在 Averages 中缺失。是否添加?

Figure 1-3. C# 扩展提供添加构建和调试资产的选项

我可以通过切换到资源管理器视图来查看代码,方法是点击左侧工具栏顶部的按钮。正如 Figure 1-4 所示,它显示目录和文件。我已展开 Averages.Test 目录并选择了其 UnitTest1.cs 文件。

Visual Studio Code 的资源管理器,展开了 Averages.Test 项目,并选择了 UnitTest1.cs 文件

Figure 1-4. Visual Studio Code 的资源管理器
小贴士

如果你在资源管理器面板中单击文件,VS Code 将在预览标签中显示它,这意味着它不会长时间保持打开状态:一旦你单击其他文件,它就会被替换。这样设计是为了避免打开数百个标签页,但如果你需要频繁在两个文件之间切换,这可能有些烦人。你可以通过双击文件来避免这种情况,这样会打开一个非预览标签,直到你有意关闭它为止。另外,如果你已经在预览标签中打开了一个文件,你可以双击标签将其转换为普通标签。VS Code 在预览标签中以斜体显示文件名,当你双击时,它将变为非斜体。

你可能会想知道为什么我展开了 Averages.Tests 目录。这个测试项目的目的是确保主项目的功能正常。我偏好在编写代码之前编写测试的开发风格,因此我会从测试项目开始。

编写单元测试

当我之前运行命令创建这个项目时,我指定了一个mstest项目类型。这个项目模板为我提供了一个测试类来启动我的工作,在一个名为UnitTest1.cs的文件中。我想选择一个更具信息性的名称。有多种关于如何组织单元测试的思路。一些开发人员主张为每个要测试的类编写一个测试类,但我喜欢的风格是为您想要测试特定类的每个场景编写一个类,并为该场景中您的代码应该正确的每个事物编写一个方法。此程序只有一种行为:计算其输入的算术平均值。因此,我将UnitTest1.cs源文件重命名为WhenCalculatingAverages.cs。(您可以通过右键单击 VS Code 的 Explorer 面板中的文件,并选择 Rename 条目来重命名文件。)此测试应验证我们对几个代表性输入得到了预期结果。示例 1-3 展示了一个完成此任务的完整源文件;这里有两个测试,用粗体显示。

示例 1-3. 我们第一个程序的单元测试类
using Microsoft.VisualStudio.TestTools.UnitTesting;

namespace Averages.Tests;
 [TestClass]
public class WhenCalculatingAverages
{
    `[TestMethod]`
    `public` `void` `SingleInputShouldProduceSameValueAsResult``(``)`
    `{`
        `string``[``]` `inputs` `=` `{` `"1"` `}``;`
        `double` `result` `=` `AverageCalculator``.``ArithmeticMean``(``inputs``)``;`
        `Assert``.``AreEqual``(``1.0``,` `result``,` `1E-14``)``;`
    `}`
 `[TestMethod]`
    `public` `void` `MultipleInputsShouldProduceAverageAsResult``(``)`
    `{`
        `string``[``]` `inputs` `=` `{` `"1"``,` `"2"``,` `"3"` `}``;`
        `double` `result` `=` `AverageCalculator``.``ArithmeticMean``(``inputs``)``;`
        `Assert``.``AreEqual``(``2.0``,` `result``,` `1E-14``)``;`
    `}`
}

一旦展示了程序本身,我将解释该文件中的每个特性。目前,这个示例中最有趣的部分是两个方法。首先是SingleInputShouldProduceSameValueAsResult方法,它检查我们的程序是否正确处理只有一个输入的情况。此方法内的第一行描述了输入——一个数字。(有点令人惊讶的是,这个测试将数字表示为字符串。这是因为我们的输入最终将作为命令行参数,所以我们的测试需要反映这一点。)第二行执行了待测试的代码(实际上我还没有写)。第三行说明计算出的平均值应该等于唯一的输入。如果不是,则此测试将报告失败。第二个方法MultipleInputsShouldProduceAverageAsResult检查了稍微复杂一些的情况,其中有三个输入,但基本形状与第一个相同。

注意

这里我们使用了 C#的double类型,即双精度浮点数,以便能够处理不是整数的结果。在下一章中,我将更详细地描述 C#的内置数据类型,但要注意,与大多数编程语言一样,C#中的浮点运算精度有限。我在这里使用的Assert.AreEqual方法考虑到了这一点,并允许我指定最大的误差容限。每种情况下的最后一个参数1E-14表示数字 1 除以 10 的 14 次方,因此这些测试表明结果需要正确到小数点后 14 位。

让我们关注这些测试中的一个特定行:运行我想测试的代码的那一行。示例 1-4 显示了从 示例 1-3 中相关的行。这是在 C# 中调用返回结果的方法。这行代码首先声明一个变量来保存结果(double 表示数据类型,result 是变量的名称)。所有的 C# 方法都需要在一个类型内定义,就像我们之前在 Console.WriteLine 示例中看到的一样,在这里也是相同的形式:类型名称,然后是一个句点,然后是方法名称。然后在括号内是方法的输入。

示例 1-4. 调用一个方法
double result = AverageCalculator.ArithmeticMean(inputs);

如果你正在阅读时同时输入代码,首先:做得好。但是第二,如果你查看这行代码出现的两个地方(每个测试方法中一次),你可能会注意到 VS Code 在 AverageCalculator 下面画了一条波浪线。将鼠标悬停在这种波浪线上会显示一个错误消息,就像 图 1-5 所示的那样。

Visual Studio Code 显示 AverageCalculator 符号下划线,并显示一个错误弹出窗口包含这段文字:当前上下文中不存在 AverageCalculator 名称 Averages.Tests

图 1-5. 一个未识别的类型

这告诉我们一些我们已经知道的事情:我还没有编写这个测试的代码。让我们解决这个问题。我需要添加一个新文件,在 VS Code 的资源管理器视图中,通过点击 Averages 目录,然后在选择了它之后,点击资源管理器顶部附近的最左侧按钮。当你将鼠标悬停在此按钮上时,会显示一个工具提示确认其用途。点击后,我可以输入 AverageCalculator.cs 作为新文件的名称。

Visual Studio Code 的资源管理器视图,高亮显示“新建文件”按钮,并显示工具提示说“新建文件”

图 1-6. 添加一个新文件

VS Code 将创建一个新的空文件。我将添加尽可能少的代码来修复 图 1-5 中报告的错误。示例 1-5 将满足 C# 编译器。它还不完整——它还没有执行必要的计算,但我们会解决这个问题。

示例 1-5. 一个简单的类
namespace Averages;

public static class AverageCalculator
{
    public static double ArithmeticMean(string[] args)
    {
        return 1.0;
    }
}

由于现在代码可以编译,我可以用以下命令运行测试:

dotnet test

这将产生以下输出:

  Failed MultipleInputsShouldProduceAverageAsResult [291 ms]
  Error Message:
   Assert.AreEqual failed. Expected a difference no greater than <1E-14>
 between expected value <2> and actual value <1>.
  Stack Trace:
     at Averages.Tests.WhenCalculatingAverages.
MultipleInputsShouldProduceAverageAsResult() in
C:\book\Averages\Averages.Tests\WhenCalculatingAverages.cs:line 21

Failed!  - Failed:     1, Passed:     1, Skipped:     0, Total:     2,
Duration: 364 ms - Averages.Tests.dll (net6.0)

正如预期的那样,由于我还没有编写一个合适的实现,我们会得到失败的结果。但首先,我想逐个解释 示例 1-5 的每个元素,因为它对 C# 语法和结构的一些重要元素提供了一个有用的介绍。这个文件的第一件事就是一个 命名空间声明

命名空间

命名空间为本应混乱不堪的事物带来了秩序和结构。.NET 运行时库包含大量类型,还有许多第三方库中的类型,更不用说你自己编写的类了。在处理这么多命名实体时会出现两个问题。首先,保证唯一性变得困难。其次,在未经组织的数万个事物中找到你需要的 API 可能会变得具有挑战性;除非你知道或能猜出正确的名称,否则很难找到所需的内容。命名空间解决了这两个问题。

大多数.NET 类型都定义在一个命名空间中。关于命名空间有一些约定,你会经常看到。例如,.NET 运行时库中的类型在以System开头的命名空间中。此外,微软提供了许多有用的库,虽然它们不是.NET 核心的一部分,但通常以Microsoft开头;或者,如果仅用于某些特定技术,它们可能以此命名。例如,有一些用于使用微软 Azure 云平台的库,它们在以Azure开头的命名空间中定义类型。来自其他供应商的库通常以公司名称或产品名称开头,而开源库通常使用其项目名称。你不必把自己的类型放入命名空间中,但建议这样做。C#并不把System作为特殊的命名空间,所以没有什么能阻止你将其用于自己的类型,但除非你正在编写将作为拉取请求提交给.NET 运行时源代码库的.NET 运行时库贡献,否则这是一个坏主意,因为它会导致其他开发者混淆。你应该为自己的代码选择更具有特色的名称,比如你的公司或项目名称。正如你可以从示例 1-5 的第一行看到的那样,我选择在名为Averages的命名空间中定义我们的AverageCalculator类,与我们的项目名称相匹配。

在 示例 1-5 中展示的命名空间声明风格是 C# 10.0 的新特性。如今,你可能会遇到的大多数代码都采用稍显冗长的旧式风格,如 示例 1-6 所示。两者的区别在于命名空间声明后跟着大括号 ({}),其作用仅限于大括号内的内容。这使得单个文件可以包含多个命名空间声明。但实际上,绝大多数 C# 文件只包含一个命名空间声明。在旧语法中,这意味着每个文件的绝大部分内容必须位于一对大括号内,缩进一个制表符。而 示例 1-5 中展示的新风格适用于文件中声明的所有类型,无需显式包裹。这是 C# 10.0 旨在减少源文件中无效冗余的一部分。

示例 1-6. C# 10.0 之前的命名空间声明
namespace Averages
{
    public static class AverageCalculator
    {
        ...as before...
    }
}

命名空间通常提示类型的用途。例如,所有与文件处理相关的运行库类型都可以在 System.IO 命名空间中找到,而与网络相关的则位于 System.Net 下。命名空间可以形成层次结构。因此,框架的 System 命名空间包含类型以及其他命名空间,如 System.Net,而这些通常还包含更多的命名空间,如 System.Net.SocketsSystem.Net.Mail。这些示例显示,命名空间充当一种描述,有助于你浏览库。例如,如果你在寻找正则表达式处理功能,你可能会浏览可用的命名空间,并注意到 System.Text 命名空间。在那里查找,你会找到一个 System.Text.RegularExpressions 命名空间,这时你会相当有信心你找对了地方。

命名空间还提供了确保唯一性的一种方式。类型定义所在的命名空间是其完整名称的一部分。这使得库可以为事物使用短小的简单名称。例如,正则表达式 API 包含一个 Capture 类,用于表示正则表达式捕获的结果。如果你正在开发处理图像的软件,术语 capture 通常用于表示获取某些图像数据,你可能认为在你自己的代码中 Capture 是描述最为准确的类名。如果你的图像获取代码根本不使用正则表达式,意味着你根本没有打算使用现有的 Capture 类型,那么因为最佳名称已经被使用而不得不选择其他名称会很令人恼火。

但事实上,这样也没问题。这两种类型都可以称为Capture,它们仍然会有不同的名称。正则表达式Capture类的完整名称实际上是System.Text.RegularExpressions.Capture,同样地,您类的完整名称将包括其所在的命名空间(例如,Spi⁠ffi⁠ngS⁠oft⁠wor⁠ks.​Ima⁠gin⁠g.Ca⁠ptu⁠re)。

如果确实希望,您可以每次使用类型时都写出完全限定的名称,但大多数开发人员不想做这样单调乏味的事情,这就是我们在示例 1-2 和 1-3 开头看到的using指令的用处。在每个源文件的顶部看到一列指令是很常见的,它声明了该文件意图使用的类型的命名空间。在此示例中,dotnet命令行工具在创建测试项目时添加了using Microsoft.VisualStudio.TestTools.UnitTesting;。您会在不同的上下文中看到不同的集合。例如,如果添加一个代表 UI 元素的类,Visual Studio 会在列表中包含各种与 UI 相关的命名空间。

针对 C# 10.0 或更高版本的项目通常比您在为旧版本编写的项目中看到的using指令要少,这是因为有了一个新的语言特性:全局 using 指令。如果我们在指令前加上global关键字,如示例 1-7 所示,该指令适用于项目中的所有文件。然后,.NET SDK 进一步采取了措施,在您的项目中生成了一个隐藏文件,并使用一组这些global using指令来确保常用的命名空间,例如SystemSystem.Collections.Generic可用。(隐式全局导入的确切命名空间集合因项目类型而异——例如,Web 项目会额外获取几个。如果您想知道为什么单元测试项目不会像示例 1-7 那样自动进行全局 using 指令,原因是.NET SDK 没有针对测试项目的特定项目类型——它认为它们只是一种类库。)

示例 1-7. 全局using指令
global using Microsoft.VisualStudio.TestTools.UnitTesting;

使用这样的using声明(可以是每个文件或全局),您可以仅使用类的简短、未限定的名称。使得示例 1-1 中的代码行能够发挥作用的代码行使用了System.Console类,但由于 SDK 为System命名空间添加了一个隐式的global using指令,因此它可以简称为Console

注意

之前,我使用dotnet CLI 从我们的Averages.Tests项目向我们的Averages项目添加了一个引用。你可能会认为引用是多余的 — 编译器不能从命名空间中推断出我们正在使用的外部库吗?如果命名空间与库或包直接对应,那么它可能可以,但实际上并非如此。有时候会有表面上的关联 — 流行的Newtonsoft.Json NuGet 包含一个Newtonsoft.Json.dll文件,其中包含Newtonsoft.Json命名空间的类,例如。但通常情况下并没有这样的对应关系 — .NET 运行时库包括一个Sys⁠tem.​Pri⁠vat⁠e.C⁠ore⁠Lib⁠.dll文件,但却没有System.Private.CoreLib命名空间。因此,有必要告诉编译器你的项目依赖哪些库,以及使用了哪些命名空间。我们将在第十二章中更详细地讨论库文件的性质和结构。

即使使用了命名空间,仍然存在潜在的歧义。单个源文件可能使用两个命名空间,这两个命名空间恰好都定义了同名的类。如果要使用这个类,就需要显式地引用它的完整名称。如果在文件中经常需要使用这些类,你仍然可以节省些打字:只需使用完整名称一次,因为你可以定义一个别名。示例 1-8 使用别名来解决我遇到过几次的冲突:.NET 的桌面 UI 框架,Windows Presentation Foundation(WPF),定义了一个用于处理贝塞尔曲线、多边形和其他形状的Path类,但也有一个用于处理文件系统路径的Path类,你可能想要同时使用这两种类型来生成文件内容的图形表示。如果不加任何using指令直接使用这两个命名空间,简单名称Path会存在歧义。但正如示例 1-8 所示,你可以为每个类定义不同的别名。

示例 1-8. 使用别名消除歧义
using System.IO;
using System.Windows.Shapes;
`using` `IoPath` `=` `System``.``IO``.``Path``;`
`using` `WpfPath` `=` `System``.``Windows``.``Shapes``.``Path``;`

有了这些别名,你可以使用IoPath作为文件相关的Path类的同义词,而使用WpfPath作为图形化类的同义词。

顺便说一句,你可以在自己的命名空间中引用类型而无需限定符,也不需要using指令。这就是为什么示例 1-3 中的测试代码没有using Averages;指令的原因。不过,也许你会想知道这是如何工作的,因为测试代码声明了一个不同的命名空间Averages.Tests。要理解这一点,我们需要看看命名空间的嵌套。

嵌套命名空间

正如你已经看到的,.NET 运行时库会对其命名空间进行嵌套,有时候相当深入,你也经常会想要做同样的事情。你可以通过两种方式来实现这一点。你可以像示例 1-9 所示那样嵌套命名空间声明。

示例 1-9. 嵌套命名空间声明
namespace MyApp
{
    namespace Storage
    {
        ...
    }
}

或者,您可以在单个声明中指定完整的命名空间,正如 示例 1-10 所示。这是更常用的风格。这种单一声明样式适用于新的 C# 10.0 样式声明或使用大括号的旧样式。

示例 1-10. 单个声明的嵌套命名空间
namespace MyApp.Storage;

任何您在嵌套命名空间中编写的代码都可以使用不仅来自该命名空间的类型,还可以使用其包含命名空间的类型而无需限定符。示例 1-9 或 1-10 中的代码不需要显式限定或 using 指令来使用 MyApp.Storage 命名空间或 MyApp 命名空间中的类型。这就是为什么在 示例 1-3 中,我不需要添加 using Averages; 指令来访问 Averages 命名空间中的 AverageCalculator:测试被声明在 Averages.Tests 命名空间中,因此自动具有对该外部命名空间的访问权限。

当您定义嵌套命名空间时,惯例是创建匹配的目录层次结构。一些工具期望如此。虽然 VS Code 目前在此方面没有特别的期望,但 Visual Studio 遵循此惯例。如果您的项目叫做 MyApp,那么当您向项目中添加新类时,它们将放在 MyApp 命名空间中。但如果您在项目中创建一个名为 Storage 的新目录,Visual Studio 将把您创建的任何新类放入 MyApp.Storage 命名空间中。再次强调,您并不需要保持这一点 —— Visual Studio 在创建文件时只是添加一个命名空间声明,您可以自由更改它。编译器不需要命名空间与目录层次结构匹配。但由于许多工具(包括 Visual Studio)支持这种约定,如果您遵循这种约定,生活会更轻松。

在命名空间声明之后,我们的 AverageCalculator.cs 文件定义了一个 class。 示例 1-11 展示了文件的这一部分。它以 public 关键字开始,这使得该类可以被其他组件访问。接下来是 static 关键字,表明此类不应该被实例化 —— 它仅提供类级别的操作而没有每个实例的特性。然后是 class 关键字,后跟名称,当然,该类型的完整名称实际上是 Averages.AverageCalculator,因为有了命名空间声明。正如您所见,C# 使用大括号()来界定各种内容 —— 我们已经在旧的(但仍广泛使用的)命名空间声明语法中看到了这一点,这里您可以看到类似的情况,还有它包含的方法。

示例 1-11. 带有方法的类
public static class AverageCalculator
{
    public static double ArithmeticMean(string[] args)
    {
        return 1.0;
    }
}

类是 C#中定义结合状态和行为实体的机制,这是一种常见的面向对象习语。但这个类只包含一个方法。C#不支持全局方法——所有代码都必须作为某种类型的成员编写。因此,这个特定的类并不是很有趣——它的唯一作用是作为执行实际工作的方法的容器。在第三章中,我们将看到一些更有趣的类的用法。

与类一样,我将方法标记为public,以便从其他组件访问。我还声明了这是一个静态方法,这意味着不需要创建包含类型(在本例中为AverageCalculator)的实例即可调用该方法。后面跟随的double关键字表示此方法返回的数据类型是双精度浮点数。

方法声明后跟着方法体,例如本例中包含返回占位值的代码,因此剩下的工作就是修改方法体边界括号内的代码。示例 1-12 展示了计算平均值的代码,而不仅仅返回 1.0。

示例 1-12. 计算平均值
return args.Select(numText => double.Parse(numText)).Average();

这依赖于处理集合的库函数,这些函数是作为 LINQ 功能集的一部分,即语言集成查询的一部分。这是第十章的主题。但是,简单描述一下这里发生的情况,Select方法允许我们对集合中的每个项应用操作,在这种情况下,我应用的操作是double.Parse方法,它是一个.NET 运行时库函数,用于将包含数字的文本字符串转换为本机双精度浮点类型。然后我们通过Average方法将这些转换后的结果推送,该方法为我们执行计算。

设置好这些之后,如果再次运行dotnet test,它将报告所有测试都已通过。因此,显然代码是有效的。但是,如果我试图通过运行程序来非正式验证它,我会遇到一个问题,我可以用这个命令来执行:

./Averages/bin/Debug/net6.0/Averages 1 2 3 4 5

这只是将Hello, World!输出到屏幕上。我已经编写并测试了执行所需计算的代码,但尚未将其连接到程序的入口点。程序启动时运行的代码位于Program.cs中,尽管该文件名并不特殊。程序入口点可以位于任何文件中。在较早版本的 C#中,您通过定义一个名为Mainstatic方法来表示入口点,就像示例 1-2 所示。但从 C# 10.0 开始,您可以添加一个包含可执行语句的文件,而无需显式将它们放在类型的方法中,C#编译器将其视为入口点。(您只允许在项目中有一个以这种方式编写的文件,因为程序只能有一个入口点。)如果我用示例 1-13 中显示的代码替换Program.cs的整个内容,它将产生预期的效果。

示例 1-13. 带参数的程序入口点
using Averages;

Console.WriteLine(AverageCalculator.ArithmeticMean(args));

请注意,当您使用 C# 10.0 的新简化程序入口点语法时,该文件中的代码默认不属于任何命名空间,因此我需要声明我想要使用在Averages命名空间中定义的类。之后,这段代码调用我之前编写的方法,并将args作为参数传递,然后调用Console.WriteLine来显示结果。当您使用这种程序入口点样式时,args是一个特殊名称——它实际上是一个隐式定义的本地变量,提供对命令行参数的访问。这将是一个字符串数组,每个参数对应一个条目。如果您希望再次使用相同的参数运行程序,请先运行dotnet build命令重新构建它。

提示

一些 C 家族语言将程序本身的文件名作为第一个参数包含在内,因为它是用户在命令提示符下键入的一部分。C#不遵循这种约定。如果程序在没有参数的情况下启动,数组的长度将为 0。您可能已经注意到,该代码在这种情况下处理得不好。请随意添加一个定义相关行为的新测试场景,并修改程序以匹配。

单元测试

现在程序已经运行正常,我想回到测试,因为它们展示了一些主程序中没有的 C#特性。如果你回顾一下示例 1-3,它从一个相当普通的方式开始:我们有一个using指令,然后是一个命名空间声明,这次是为Averages.Tests,与测试项目名称匹配。但这个类看起来有些不同。示例 1-14 展示了示例 1-3 中相关的部分。

Example 1-14. 具有属性的测试类
[TestClass]
public class WhenCalculatingAverages
{

在类声明之前的文本是 [TestClass]。这是一个 属性。属性是你可以应用于类、方法和代码的其他特性的注解。它们中的大多数本身什么都不做——编译器只会在编译输出中记录属性的存在。属性只有在某些情况下才有用,因此它们倾向于被框架使用。在这种情况下,我使用的是微软的单元测试框架,它会寻找带有 TestClass 属性的类。它会忽略没有此注解的类。属性通常特定于特定的框架,你也可以定义自己的属性,正如我们将在 第十四章 中看到的。

类中的两个方法也标有属性。示例 1-15 展示了来自 示例 1-3 的相关摘录。测试运行器将执行任何标记有 [TestMethod] 属性的方法。

示例 1-15. 标记方法
[TestMethod]
public void SingleInputShouldProduceSameValueAsResult()
...

[TestMethod]
public void MultipleInputsShouldProduceAverageAsResult()
...

我们已经检查了程序的每个元素以及验证其正常工作的测试项目。

摘要

现在你已经看到了 C# 程序的基本结构。我创建了一个包含两个项目的解决方案,一个用于测试,一个用于程序本身。这是一个简单的例子,因此每个项目只有一个或两个感兴趣的源文件。必要时,这些文件以 using 指令开头,指示文件使用的类型。程序的入口点使用了 C# 10.0 的新精简样式,但另外两个项目使用了更传统的结构,包含一个声明命名空间的命名空间声明,以及包含一个或多个方法或其他成员(如字段)的类。

我们将在 第三章 中更详细地讨论类型及其成员,但首先,第二章 将处理位于方法内部的代码,其中我们表达了我们的程序想要做什么。

¹ 旧的 .NET Framework 将继续得到支持很多年,但微软已经表示它将不会获得任何新功能。

² .NET Native 和 NativeAOT 并不这样做:它们专为避免任何运行时 JIT 而设计,因此它们不提供分层编译。

³ 如果你想知道这些版本号和日期如何与年度交替发布相符,当前的时间表是从 .NET Core 3.1 开始介绍的,没有 .NET Core 4。当 .NET Core 被重新命名为纯粹的 .NET 时,它从 3.1 跳到了 5.0,以强调这一点是从 .NET Framework 转移,其最新版本为 4.8。

⁴ 或者 .NET Core。这里的名称变更可能会引起混淆。支持 .NET Core 3.1 的组件将在 .NET 5.0 和 .NET 6.0 上运行,因为它们是同一运行时的更新版本;在 .NET 5.0 发布时,它只是去掉了 Core 这个词,并跳过了一个版本号。

第二章:C# 基本编码

所有编程语言都必须提供一定的功能。我们必须能够表达代码应执行的计算和操作。程序需要能够根据输入做出决策。有时我们需要重复执行任务。这些基本功能是编程的基础,本章将展示这些功能在 C# 中的工作原理。

根据你的背景,本章的部分内容可能非常熟悉。C# 被称为“C 家族”语言的一员。C 是一种极具影响力的编程语言,许多语言借鉴了其语法。有直接的后继者,如 C++ 和 Objective-C。还有更远的关联语言,包括 Java、JavaScript 和 C# 本身,它们没有与 C 的兼容性,但仍然复制了其语法的许多方面。如果你熟悉这些语言中的任何一种,你将会认识到我们即将探讨的许多语言特性。

我们在第一章中看到了程序的基本要素。在本章中,我们将仅关注方法内的代码。正如你所见,C# 需要一定的结构:代码由位于方法内的语句组成,该方法属于一个类型,通常位于一个命名空间内,所有这些都在一个项目的文件中,通常包含在一个解决方案中。(在程序入口点的特殊情况下,由于 C# 10.0 的简化特性,包含的方法和类型可能会隐藏起来,但在大多数文件中它们是可见的。)为了清晰起见,本章的大多数示例将单独显示感兴趣的代码,例如示例 2-1。

示例 2-1. 代码及其余无余地
Console.WriteLine("Hello, World!");

虽然 C# 10.0 接受更短的示例作为程序的全部内容,但任何大于单个文件的程序(即几乎所有有用的程序)都需要明确包含其他元素。因此,除非我另有说明,这种摘录是为了在合适结构化的文件内显示上下文中的代码。例如,像示例 2-1 这样的示例相当于更像示例 2-2。

示例 2-2. 整段代码
using System;

internal class MyType
{
    private static void SomeMethod()
    {
        Console.WriteLine("Hello, World!");
    }
}

虽然我会在本节介绍语言的基本要素,但这本书是给那些已经熟悉至少一种编程语言的人看的,所以我会相对简短地介绍语言的最常见特性,并会更详细地讲解那些特别适用于 C# 的方面。

局部变量

不可避免的“Hello, World!”示例缺少一个重要元素:它实际上并未处理信息。有用的程序通常会获取、处理和生成数据,因此定义和标识数据的能力是语言中最重要的功能之一。与大多数语言一样,C# 允许您定义本地变量,这些是方法内的命名元素,每个都包含一部分信息。

注意

在 C# 规范中,术语变量可以指本地变量,也可以指对象中的字段和数组元素。本节完全涉及本地变量,但是继续阅读本地前缀会有点累。因此,在本节中,变量指的是本地变量。

C# 是一种静态类型语言,这意味着代码中任何代表或产生信息的元素(如变量或表达式)在编译时都有确定的数据类型。这与动态类型语言(如 JavaScript)不同,后者在运行时确定类型。¹

看到 C# 的静态类型在简单变量声明中的实际运行方式最简单的方法是,例如在示例 2-3 中的简单变量声明。每个变量声明以数据类型开头,前两个变量是string类型,接着两个是int类型。这些类型分别表示文本字符串和 32 位有符号整数。

示例 2-3. 变量声明
string part1 = "the ultimate question";
string part2 = "of something";
int theAnswer = 42;
int andAnotherThing;

数据类型紧跟变量名之后。变量名必须以字母或下划线开头,后面可以跟任意字母、十进制数字和下划线的组合。(至少在 ASCII 码情况下是这样。C# 支持 Unicode,因此如果您以 UTF-8 或 UTF-16 格式保存文件,标识符中第一个字符后面的字符可以是 Unicode 规范“标识符和模式语法”附录中描述的任何字符。这包括各种重音符号、变音符号和许多标点符号,但只有 Unicode 标识为用于单词内部的字符可以用于分隔单词的字符不能用。)这些规则同样适用于 C# 中任何用户定义实体的合法标识符,如类或方法。

示例 2-3 显示了几种变量声明的形式。前三个变量包括一个初始化器,提供变量的初始值,但是如最后一个变量所示,这是可选的。这是因为您可以在任何时候将新值赋给变量。示例 2-4 继续自示例 2-3,展示了不管变量是否有初始值,都可以向变量赋新值。

示例 2-4. 为先前声明的变量赋值
part2 = " of life, the universe, and everything";
andAnotherThing = 123;

因为变量具有静态类型,所以编译器将拒绝尝试分配错误类型的数据。因此,如果我们从示例 2-3 继续使用示例 2-5 中的代码,编译器将会抱怨。它知道名为theAnswer的变量具有int类型,这是一个数值类型,因此如果我们尝试将文本字符串分配给它,它将报告一个错误。

示例 2-5。一个错误:错误的类型
theAnswer = "The compiler will reject this";

在 JavaScript 等动态语言中,您允许这样做,因为在这些语言中,变量没有自己的类型 - 所有的一切都取决于它包含的值的类型,并且随着代码运行,它可以改变。在 C#中,可以通过声明具有类型dynamicobject的变量来执行类似的操作(稍后在“动态”和“对象”中描述)。但是,在 C#中最常见的做法是使变量具有更具体的类型。

注意

静态类型并不能始终提供完整的图片,多亏了继承。我会在第六章中讨论这个问题,但现在知道一些类型可以通过继承来扩展就足够了,如果一个变量使用了这样的类型,那么它可能引用从变量的静态类型派生的类型的某些对象。接口,在第三章中描述,提供了一种类似的灵活性。但是,静态类型总是决定您可以对变量执行哪些操作。如果您想使用一些特定派生类型的附加成员,您将无法通过基础类型的变量来执行。

您不必明确声明变量类型。您可以使用关键字var代替数据类型,让编译器为您完成。示例 2-6 显示了来自示例 2-3 的前三个变量声明,但使用var代替显式数据类型。

示例 2-6。使用var关键字的隐式变量类型
var part1 = "the ultimate question";
var part2 = "of something";
var theAnswer = 40 + 2;

这段代码经常会误导那些了解一些 JavaScript 的人,因为 JavaScript 中也有一个var关键字,可以以类似的方式使用。但是var在 C#中的工作方式与 JavaScript 不同:这些变量仍然都是静态类型的。改变的只是我们没有说类型是什么 - 我们让编译器为我们推断。它查看初始化程序,并可以看到前两个变量是字符串,而第三个是整数。(这就是为什么我从示例 2-3 中省略了第四个变量andAnotherThing。它没有初始化程序,所以编译器无法推断其类型。如果尝试在没有初始化程序的情况下使用var关键字,会收到编译器错误。)

你可以证明使用 var 声明的变量是静态类型的,通过尝试将不同类型的东西分配给它们。我们可以重复在 示例 2-5 中尝试的相同事情,但这次使用 var 样式的变量。示例 2-7 这样做,它会产生完全相同的编译器错误,因为这是相同的错误——我们试图将文本字符串分配给不兼容类型的变量。这里的变量 theAnswer 在这里的类型是 int,尽管我们没有明确说明。

示例 2-7. 错误:错误的类型(再次)
var theAnswer = 42;
theAnswer = "The compiler will reject this";

对于何时以及如何使用 var 关键字,意见分歧很大,后面的边栏 “To var, or Not to var?” 描述了这一点。

声明的最后一个值得知道的是,你可以在一行中声明并选择性地初始化多个变量。如果你需要多个相同类型的变量,这可能会减少代码的混乱。在 示例 2-8 中,它声明了三个相同类型的变量,并初始化了其中的两个。

示例 2-8. 单次声明中的多个变量
double a, b = 2.5, c = -3;

无论你如何声明它,变量都保存特定类型的某些信息,并且编译器会阻止我们将不兼容类型的数据放入该变量中。变量之所以有用,仅仅是因为我们稍后可以在代码中引用它们。示例 2-9 从我们之前看到的变量声明开始,然后继续使用这些变量的值来初始化更多变量,并显示结果。

示例 2-9. 使用变量
string part1 = "the ultimate question";
string part2 = "of something";
int theAnswer = 42;

part2 = "of life, the universe, and everything";

string questionText = "What is the answer to " + part1 + ", " + part2 + "?";
string answerText = "The answer to " + part1 + ", " +
                       part2 + ", is: " + theAnswer;

Console.WriteLine(questionText);
Console.WriteLine(answerText);

顺便说一句,这段代码依赖于 C# 对 + 运算符的几种含义的定义,当它与字符串一起使用时。首先,当你将两个字符串“相加”在一起时,它们会连接起来。其次,当你将不是字符串的东西添加到字符串的末尾(正如 answerText 的初始化器所做的那样——它添加了一个数字 theAnswer),C# 会生成将值转换为字符串然后附加它的代码。因此,示例 2-9 会产生如下输出:

What is the answer to the ultimate question, of life, the universe, and everythi
ng?
The answer to the ultimate question, of life, the universe, and everything, is:
42
注意

在本书中,超过 80 个字符的文本会被换行以适应页面。如果您尝试这些示例,如果您的控制台窗口配置为不同的宽度,则它们将看起来不同。

当你使用一个变量时,它的值就是你最后分配给它的值。如果你尝试在分配值之前使用一个变量,正如 示例 2-10 所做的那样,C# 编译器将报告一个错误。

示例 2-10. 错误:使用未赋值的变量
int willNotWork;
Console.WriteLine(willNotWork);

编译后,第二行会产生以下错误:

error CS0165: Use of unassigned local variable 'willNotWork'

编译器使用一种稍微悲观的系统(称为明确的赋值规则)来确定变量是否已经有值。在每种可能的情况下都无法创建能够确定这些事情的算法。² 由于编译器必须谨慎处理,有些情况下变量在执行相关代码时已经有值,但编译器仍会抱怨。解决方案是编写一个初始化器,以便变量始终包含某些内容,例如对于数值使用0,对于布尔变量使用false。在第三章中,我将介绍引用类型,顾名思义,这种类型的变量可以保存对类型实例的引用。如果需要在有东西可以引用之前初始化这样的变量,可以使用关键字null,表示一个指向无内容的引用。或者,您可以使用关键字default来初始化任何类型的变量,它表示零、falsenull的值。

明确的赋值规则决定了编译器认为变量包含有效值的代码部分,并因此允许您从中读取。写入变量的操作不受限制,但正如您可能预料的那样,任何给定变量只能从代码的某些部分访问。让我们看看控制这些规则的细则。

范围

变量的作用域是您可以通过其名称引用该变量的代码范围。变量并非唯一具有作用域的事物。方法、属性、类型,实际上,所有具有名称的东西都有作用域。这些需要扩展作用域的定义:它是您可以在代码中通过名称引用实体而无需额外限定的部分。当我写Console.WriteLine时,我是通过其名称(WriteLine)引用方法,但我需要使用类名(Console)加以限定,因为该方法不在作用域内。但对于局部变量,作用域是绝对的:要么可以无需限定就可以访问,要么根本无法访问。

广义上讲,变量的作用域从其声明开始,到其所在的结束。(某些语句,如循环,通过将变量声明放在其所在作用域之前来使此过程复杂化。)块是由一对大括号()界定的代码区域。方法体就是一个块,因此在一个方法中定义的变量在另一个方法中是不可见的,因为它超出了作用域。如果尝试编译示例 2-11,将会收到一个错误,指出当前上下文中不存在名称'thisWillNotWork'

Example 2-11. 错误:超出范围
static void SomeMethod()
{
    int thisWillNotWork = 42;
}

static void AnUncompilableMethod()
{
    Console.WriteLine(thisWillNotWork);
}

方法通常包含嵌套块,特别是当你使用本章稍后将介绍的循环和流控制结构时。在嵌套块开始的地方,外部块中的所有作用域继续在该嵌套块内部有效。示例 2-12 声明了一个名为 someValue 的变量,然后在 if 语句的一部分中引入了一个嵌套块。此块内的代码能够访问在包含块中声明的该变量。

示例 2-12. 在块外声明变量,在块内使用
int someValue = GetValue();
if (someValue > 100)
{
    Console.WriteLine(someValue);
}

逆否命题并不成立。如果你在嵌套块中声明一个变量,其作用域不会延伸到该块外部。因此,示例 2-13 编译将失败,因为 willNotWork 变量只在嵌套块内部有效。由于试图在该块外部使用该变量,最后一行代码将产生编译器错误。

示例 2-13. 错误:尝试使用不在作用域内的变量
int someValue = GetValue();
if (someValue > 100)
{
    int willNotWork = someValue - 100;
}
Console.WriteLine(willNotWork);

这可能看起来相当简单,但当涉及到潜在的命名冲突时情况会变得更加复杂。在这里,C#有时会让人感到意外。

变量名称的歧义

考虑 示例 2-14 中的代码。这里声明了一个名为 anotherValue 的变量在一个嵌套块内。正如你所知,该变量仅在该嵌套块的末尾处于作用域内。在该块结束后,我们尝试声明另一个同名变量。

示例 2-14. 错误:令人惊讶的名称冲突
int someValue = GetValue();
if (someValue > 100)
{
    int anotherValue = someValue - 100;  // Compiler error
    Console.WriteLine(anotherValue);
}

int anotherValue = 123;

这导致了在第一行声明 anotherValue 时的编译器错误:

error CS0136: A local or parameter named 'anotherValue' cannot be declared in
 this scope because that name is used in an enclosing local scope to define a
 local or parameter

这似乎有些奇怪。在最后一行,所谓的冲突早期声明已经不在作用域内,因为我们已经超出了它所声明的嵌套块。此外,第二个声明在该嵌套块内也不在作用域内,因为声明发生在块之后。尽管作用域不重叠,但尽管如此,我们仍然在处理 C#避免名称冲突规则时遇到问题。要了解为什么此示例失败,首先需要看一个不那么令人意外的示例。

C# 试图通过不允许一个名称可能指代多个东西的代码来避免歧义。示例 2-15 展示了它旨在避免的问题类型。这里我们有一个名为 errorCount 的变量,并且代码在进展过程中开始修改它,³ 但在中途,它在一个嵌套块中引入了一个新的同名变量,也叫 errorCount。可以想象一种允许这种情况的语言——你可以有一个规则,即当多个同名项处于作用域内时,只选择最后声明的那个。

示例 2-15. 错误:隐藏一个变量
int errorCount = 0;
if (problem1)
{
    errorCount += 1;

    if (problem2)
    {
        errorCount += 1;
    }

    // Imagine that in a real program there was a big
    // chunk of code here before the following lines.

    int errorCount = GetErrors();  // Compiler error
    if (problem3)
    {
        errorCount += 1;
    }
}

C#选择不允许这种情况,因为这样的代码很容易误解。这是一个人为缩短的方法,因为它是书中的一个假设示例,所以很容易看到重复的名称,但如果代码再长一点,很容易忽略嵌套变量声明。那么,在方法结束时,我们可能意识不到errorCount的含义与之前不同。C#简单地禁止这种情况以避免误解。

但是为什么示例 2-14 会失败呢?这两个变量的作用域并不重叠。嗯,事实证明,禁止示例 2-15 的规则并不基于作用域。它基于一个微妙不同的概念,叫做声明空间。声明空间是代码中的一个区域,在这个区域中,一个名称不能指代两个不同的实体。每个方法为变量引入一个声明空间。嵌套块也会引入声明空间,而在嵌套声明空间中声明与其父声明空间中同名变量是非法的。这就是我们在这里违反的规则——示例 2-15 中最外层的声明空间包含一个名为errorCount的变量,而嵌套块的声明空间试图引入另一个同名变量。

如果这一切看起来有点枯燥或武断,了解为什么有一个完全独立的名称冲突规则集合可能会有所帮助,而不是基于作用域。声明空间规则的意图大部分情况下不应受到声明放置位置的影响。如果你将一个块中的所有变量声明移动到该块的开头——某些组织有规范要求这种布局——这些规则的理念就是这不应该改变代码的含义。如果示例 2-15 是合法的,这将是不可能的。这也解释了为什么示例 2-14 是非法的。虽然作用域不重叠,但如果你将所有变量声明移动到包含块的顶部,它们将会重叠。

局部变量实例

变量是源代码的特征,因此每个特定变量都有一个独特的身份:它在源代码中只声明一次,并且在一个明确定义的地方超出作用域。但这并不意味着它对应于内存中的单个存储位置。通过递归、多线程或异步执行,可能会同时存在单个方法的多个调用。

每次方法运行时,它都会获得一组独特的存储位置来保存局部变量的值。这使得多个线程可以同时执行同一个方法而不会出现问题,因为每个线程都有自己的局部变量集合。同样,在递归代码中,每个嵌套调用都会获得自己的局部变量集合,不会干扰任何调用它的方法。对于同一方法的多个并发调用也是如此。严格来说,每个特定作用域的执行都有自己的变量集合。当您使用匿名函数时,这一区别很重要,详见第九章中的描述。作为优化,C# 在可能的情况下会重用存储位置,因此只有在真正需要时才会为每个作用域的执行分配新内存。(例如,除非您将其置于必须这样做的情况中,否则不会为循环体内声明的变量每次迭代分配新内存。)但其效果就像每次都分配了新空间一样。

请注意,C# 编译器对于变量存放位置并没有特别的保证(除了一些特殊情况,我们将在第十八章中看到)。它们可能存在于堆栈上,但有时并非如此。当我们在后面的章节中看匿名函数时,您将看到有时变量需要超出声明它们的方法的生存期,因为它们在嵌套方法中仍处于作用域中,这些嵌套方法将在包含方法返回后作为回调运行。

顺便说一句,在我们继续之前,请注意,变量不是唯一具有作用域的事物,还有适用于声明空间的规则的其他语言特性,我们稍后会看到,包括类、方法和属性等。

语句和表达式

变量为我们提供了一个存放代码处理信息的地方,但要对这些变量进行任何操作,我们需要编写一些代码。这意味着编写语句表达式

语句

当我们编写一个 C# 方法时,我们实际上是在编写一系列语句。非正式地说,方法中的语句描述了我们希望方法执行的操作。示例 2-16 中的每一行都是一个语句。也许有些诱人的想法认为语句是一个指令(例如初始化变量或调用方法)。或者您可能采用更词法化的观点,认为任何以分号结尾的东西都是语句。(顺便说一句,这里重要的是分号,而不是换行。我本可以将其写成一行长代码,它的意义完全相同。)然而,这两种描述都过于简单化,尽管它们在这个特定示例中恰好是正确的。

示例 2-16. 一些语句
int a = 19;
int b = 23;
int c;
c = a + b;
Console.WriteLine(c);

C#识别许多不同类型的语句。 示例 2-16 的前三行是声明语句,用于声明并可选地初始化变量。第四和第五行是表达式语句。但是有些语句比这个例子中的更有结构。

当你编写一个循环时,那是一个迭代语句。当你在本章后面描述的使用ifswitch机制来选择各种可能操作时,那些是选择语句。实际上,C#规范区分了 13 种语句类别。大多数都可以广泛地归类为描述代码接下来应该做什么,或者对于循环或条件语句等功能,描述如何决定接下来该做什么。第二类语句通常包含一个或多个嵌入语句,描述在循环中执行的操作,或者在if语句的条件满足时执行的操作。

有一种特殊情况。块是一种语句。这使得诸如循环之类的语句比通常更有用,因为循环只是迭代一个嵌入的语句。那个语句可以是一个块,而由于块本身是一系列语句(用大括号分隔),这使得循环可以包含多于一个语句。

这解释了为什么前面提到的两种简单观点——“语句是行动”和“语句是以分号结尾的东西”——都是错误的。比较示例 2-16 和 2-17。两者做的事情是一样的,因为我们想要执行的各种操作保持完全一样,并且两者都包含五个分号。然而,示例 2-17 包含了一个额外的语句。前两个语句是相同的,但后面跟着一个第三个语句,一个块,其中包含了来自 示例 2-16 的最后三个语句。这个额外的语句,即块,既不以分号结尾,也不执行任何操作。在这个特定的例子中,它是无意义的,但有时候引入这样一个嵌套块可以避免名称歧义错误。因此,语句可以是结构性的,而不是在运行时引起任何事情发生。

示例 2-17. 一个块
int a = 19;
int b = 23;
{
    int c;
    c = a + b;
    Console.WriteLine(c);
}

虽然你的代码将包含多种类型的语句,但最终至少会包含一些表达式语句。表达式语句是由合适的表达式后跟一个分号组成的语句。什么是合适的表达式?实际上什么是表达式?在回到组成语句的有效表达式之前,我最好先回答第二个问题。

表达式

微软对 C# expression的官方定义相当枯燥:“一系列操作符和操作数。”尽管如此,语言规范往往是这样的,但除了这种形式化的散文外,C#规范还包含一些非常可读的非正式解释更正式表达的想法。 (例如,在说明语句作为表达程序操作的手段之前,它描述了表达式的含义,然后用不太接近但技术上更精确的语言来确定。)本段开头的引语来自表达式的正式定义,所以我们可能希望在引言中的非正式解释将更有帮助。没那么幸运:它说表达式“是从操作数和操作符构造的。”这当然不如其他定义那么精确,但理解起来也不容易。问题在于,有几种类型的表达式,它们执行不同的工作,所以没有单一的、通用的、非正式的描述。

描述一个表达式为产生值的代码是很诱人的。对于所有表达式来说并非如此,但你将写的大多数表达式都符合这个描述,所以我现在将重点放在这一点上,并稍后提到例外情况。

最简单的表达式是literals,我们只需写出我们想要的值,比如"Hello, World!"42。你也可以使用变量的名称作为一个表达式。表达式可以涉及运算符,描述进行的计算或其他计算。运算符有固定数量的输入,称为operands。有些运算符只需要一个操作数。例如,你可以通过在数字前面加一个减号来对数字取反。有些运算符需要两个操作数:+运算符允许你形成一个表达式,将两侧操作数的结果相加。

注意

一些符号在不同的上下文中有不同的作用。减号不仅仅用于取反。如果它出现在两个表达式之间,则充当双操作数减法运算符。

通常,操作数也是表达式。所以,当我们写2 + 2时,这是一个包含两个更多表达式的表达式——+符号两侧的一对"2" literals。这意味着我们可以通过在表达式内部嵌套表达式来编写任意复杂的表达式。示例 2-18 利用这一点来评估二次方程的解(解二次方程的标准方法)。

示例 2-18。表达式内的表达式
double a = 1, b = 2.5, c = -3;
`double` `x` `=` `(``-``b` `+` `Math``.``Sqrt``(``b` `*` `b` `-` `4` `*` `a` `*` `c``)``)` `/` `(``2` `*` `a``)``;`
Console.WriteLine(x);

看看第二行的声明语句。其初始化表达式的整体结构是一个除法操作。但是该除法运算符的两个操作数也是表达式。其左操作数是一个括号表达式,告诉编译器我希望整个表达式 (-b + Math.Sqrt(b * b - 4 * a * c)) 成为除法的第一个操作数。这个子表达式包含一个加法,其左操作数是一个否定表达式,其单个操作数是变量 b。加法的右操作数则对另一个更复杂的表达式进行平方根运算。而除法的右操作数是另一个括号表达式,其中包含一个乘法。图 2-1 展示了表达式的完整结构。

表达式的结构

图 2-1. 表达式的结构

最后一个示例的一个重要细节是,方法调用是一种表达式。在示例 2-18 中使用的 Math.Sqrt 方法是一个 .NET 运行时库函数,用于计算其输入的平方根并返回结果。也许更令人惊讶的是,像 Console.WriteLine 这样不返回值的方法调用,从技术上讲也是表达式。还有一些其他不产生值但仍被视为表达式的结构,包括对类型的引用(例如 Console.WriteLine 中的 Console)或对命名空间的引用。这些构造利用了一套通用规则(例如作用域、如何解析名称引用等),因此被视为表达式。然而,所有不生成值的表达式只能在特定情况下使用(例如,不能将一个表达式用作另一个表达式的操作数)。因此,虽然从技术上讲定义表达式为生成值的代码片段并不完全正确,但我们在描述代码要执行的计算时确实使用这些表达式。

现在我们可以回到一个问题,即在表达式语句中可以放什么?粗略来说,表达式必须执行某些操作;它不能只计算一个值。因此,虽然 2 + 2 是一个有效的表达式,但如果您试图在其末尾添加分号将其转换为表达式语句,您将会得到一个错误。这个表达式计算了某些东西,但没有对结果做任何事情。更准确地说,您可以将以下类型的表达式用作语句:方法调用、赋值、增量、减量以及新对象的创建。我们将在本章后面讨论增量和减量,后续章节还会讨论对象,因此留下了调用和赋值两种情况。

因此,方法调用允许作为表达式语句。这可能涉及其他类型的嵌套表达式,但整个表达式必须是一个方法调用。示例 2-19 展示了一些有效的例子。请注意,C# 编译器并不检查方法调用是否真正产生了任何持久效果——Math.Sqrt 函数是一个纯函数,它仅仅根据其输入返回一个值。因此调用它然后不对结果做任何操作实际上什么都没做——这不比表达式2 + 2更有作用。但就 C# 编译器而言,任何方法调用都允许作为表达式语句。

示例 2-19. 方法调用表达式作为语句
Console.WriteLine("Hello, World!");
Console.WriteLine(12 + 30);
Console.ReadKey();
Math.Sqrt(4);
注意

如果在 VS Code 中运行此示例,则ReadKey的调用可能会失败,因为调试器默认会重定向输入和输出。文档说明了在调试需要读取控制台输入的程序时如何避免此问题。

C# 禁止我们将加法表达式用作语句,而允许Math.Sqrt,看起来是不一致的。这两者都执行计算并产生结果,因此在这种方式下使用它们是毫无意义的。如果 C# 只允许调用不返回任何内容的方法用作表达式语句,那么这会更一致吗?这将排除示例 2-19 的最后一行,因为这段代码并不执行任何有用的操作。这也与2 + 2不能形成表达式语句的事实一致。不幸的是,有时您希望忽略返回值。示例 2-19 调用Console.ReadKey(),它等待按键并返回一个值,指示按下了哪个键。如果我的程序行为依赖于用户按下的特定键,我需要检查方法的返回值,但如果我只是想等待任何键,忽略返回值就可以了。如果 C# 不允许具有返回值的方法用作表达式语句,那么我将无法这样做。编译器无法区分哪些方法会导致毫无意义的语句,因为它们没有副作用(比如Math.Sqrt),哪些可能是好的候选(比如Console.ReadKey),因此它允许任何方法。

要使表达式成为有效的表达式语句,仅仅包含方法调用是不够的。示例 2-20 展示了一些调用方法并将其用作加法表达式一部分的表达式。虽然这些是有效的表达式,但它们不是有效的表达式语句,因此会导致编译器错误。关键在于最外层的表达式。在这两行中,最外层都是加法表达式,这就是为什么这些是不允许的原因。

Example 2-20. 错误:一些不作为语句工作的表达式
Console.ReadKey().KeyChar + "!";
Math.Sqrt(4) + 1;

之前我说过我们可以将作为语句使用的一种表达式是赋值。赋值作为表达式并不明显,但确实如此,并且它们会产生一个值:赋值表达式的结果是分配给变量的值。这意味着可以在示例 2-21 中编写这样的代码是合法的。这里的第二行使用赋值表达式作为方法调用的参数,展示了该表达式的值。前两个WriteLine调用都显示123

Example 2-21. 赋值是表达式
int number;
`Console``.``WriteLine``(``number` `=` `123``)``;`
Console.WriteLine(number);

int x, y;
`x` `=` `y` `=` `0``;`
Console.WriteLine(x);
Console.WriteLine(y);

本例的第二部分通过利用赋值为两个变量同时分配一个值,说明了赋值作为表达式的事实——它将y = 0表达式的值(评估为0)分配给了x

这表明评估表达式不仅仅是产生一个值。一些表达式具有副作用。我们刚刚看到赋值是一个表达式,当然它有改变变量内容的效果。方法调用也是表达式,尽管可以编写仅从其输入计算结果的纯函数,比如Math.Sqrt,但许多方法会有一些持久的效果,例如向屏幕写入数据,更新数据库或触发建筑物的拆除。这意味着我们可能关心表达式的操作数评估顺序。

表达式的结构对操作符完成工作的顺序施加了一些约束。例如,我可以使用括号强制执行顺序。表达式10 + (8 / 2)的值为 14,而表达式(10 + 8) / 2的值为 9,尽管它们都有完全相同的文字操作数和算术运算符。这里的括号决定了除法是在减法之前还是之后执行。⁴

然而,虽然表达式的结构对操作数的评估顺序施加了一些约束,但仍然有一些余地:虽然加法的两个操作数在执行加法之前必须先评估,但加法运算符不关心我们先评估哪个操作数。但如果操作数是具有副作用的表达式,顺序可能很重要。对于这些简单的表达式,这并不重要,因为我使用了文字,所以我们无法真正知道它们何时被评估。但是,如果操作数调用了某些方法的表达式呢?示例 2-22 包含这种类型的代码。

Example 2-22. 操作数的评估顺序
static int X(string label, int i)
{
    Console.Write(label);
    return i;
}

Console.WriteLine(X("a", 1) + X("b", 1) + X("c", 1) + X("d", 1));

这定义了一个方法,X,它接受两个参数。它显示第一个参数,并返回第二个参数。然后,我在表达式中多次使用了这个方法,这样我们就可以确切地看到调用 X 的操作数何时被评估。一些语言选择不定义此顺序,使得这样的程序的行为变得不可预测,但是在 C# 中这里是有规定的。规则是在任何表达式内部,操作数按照它们在源代码中出现的顺序进行评估。因此,当 示例 2-22 中的 Console.WriteLine 运行时,它会多次调用 X,每次调用 X 都会调用 Console.Write,因此我们会看到这样的输出:abcd4

然而,这忽略了一个重要的微妙之处:当嵌套发生时,我们在说表达式的顺序时到底是什么意思?Console.WriteLine 的整个参数是一个大的加法表达式,其中第一个操作数是 X("a", 1),第二个操作数是另一个加法表达式,它又以 X("b", 1) 作为第一个操作数,并且有一个第二操作数,它又是另一个加法表达式,其操作数分别是 X("c", 1)X("d", 1)。考虑这些加法表达式中的第一个,它构成了传递给 Console.WriteLine 的整个参数,现在问这个加法表达式的第一个操作数究竟是在其第一个操作数之前还是之后,这是否有意义?在词法上,最外层的加法表达式从其第一个操作数开始的确切点开始,并在其第二操作数结束的点结束(这也恰好是最终的 X("d", 1) 结束的地方)。在这种特定情况下,真正重要的是评估顺序的唯一可观察效果是调用 X 方法时产生的输出。没有一个调用 X 的表达式是嵌套在另一个表达式中的,因此我们可以有意义地说这些表达式的顺序,并且我们看到的输出与该顺序匹配。然而,在某些情况下,如 示例 2-23,嵌套表达式的重叠可能会产生可见的影响。

示例 2-23. 带有嵌套表达式的操作数评估顺序
Console.WriteLine(
    X("a", 1) +
    X("b", (X("c", 1) + X("d", 1) + X("e", 1))) +
    X("f", 1));

这里,Console.WriteLine 的参数添加了三次调用 X 的结果;然而,这三次 X 的调用中,第二次调用(第一个参数为"b")的第二个参数是一个表达式,该表达式又添加了三次调用 X 的结果(参数分别为"c""d""e")。通过最后一次调用 X(传递"f"),在该语句中我们总共有六个调用 X 的表达式。C# 按照表达式出现的顺序来评估表达式的规则始终适用,但由于存在重叠,结果一开始会令人惊讶。尽管字母按照字母表顺序出现在源代码中,输出却是"acdebf5"。如果你想知道这是如何与按顺序评估表达式保持一致,请考虑代码从表达式开始评估的顺序,以及在表达式完成评估时的顺序,这两者是不同的排序方式。特别是,使用"b"调用 X 的表达式开始其评估比使用"c""d""e" 调用 X 的表达式开始评估更早,但在它们之后完成其评估。我们在输出中看到的正是这种后续排序。如果你在本例中找到每个与调用 X 相对应的闭合括号,你会发现调用顺序与显示的内容完全一致。

注释与空白

大多数编程语言允许源文件包含编译器忽略的文本,C#也不例外。与大多数 C 家族语言一样,它支持两种用于此目的的注释风格。有单行注释,如示例 2-24 中所示,其中写入两个/字符,从而使得从这里到行尾的所有内容都将被编译器忽略。

示例 2-24. 单行注释
Console.WriteLine("Say");        // This text will be ignored, but the code on
Console.WriteLine("Anything");   // the left is still compiled as usual.

C# 也支持定界注释。你可以使用/*开始这种类型的注释,编译器将忽略直到遇到第一个*/字符序列的所有内容。如果你不希望注释一直持续到行尾,这将会很有用,正如示例 2-25 的第一行所示。本例还展示了定界注释可以跨越多行。

示例 2-25. 定界注释
Console.WriteLine(/* Has side effects */ GetLog());

/* Some developers like to use delimited comments for big blocks of text,
 * where they need to explain something particularly complex or odd in the
 * code. The column of asterisks on the left is for decoration - asterisks
 * are necessary only at the start and end of the comment.
 */

使用定界注释可能会遇到一个小问题;即使注释在单行内,也可能会发生,但更常见的是在多行注释中出现。示例 2-26 展示了从第一行中间开始到第四行末尾的注释问题。

示例 2-26. 多行注释
Console.WriteLine("This will run");   /* This comment includes not just the
Console.WriteLine("This won't");       * text on the right but also the text
Console.WriteLine("Nor will this");   /* on the left except the first and last
Console.WriteLine("Nor this");         * lines. */
Console.WriteLine("This will also run");

注意,在本示例中/*字符序列出现了两次。当此序列出现在注释中间时,它什么特别操作也不会执行——注释不会嵌套。尽管我们看到了两个/*序列,但第一个*/就足以结束注释。这有时令人沮丧,但对于 C 家族语言来说,这是常态。

有时临时禁用一段代码并且轻松恢复是非常有用的。将代码转换为注释是一个常见的方法,虽然一个分隔的注释看起来似乎是一个显而易见的选择,但如果你注释掉的区域恰好包含另一个分隔的注释,那么它会变得很笨拙。由于没有支持嵌套,你需要在内部注释的闭合*/后添加一个/*来确保你注释掉了整个范围。因此,通常使用单行注释来实现这一目的。(你还可以使用下一节中描述的#if指令。)

注意

Visual Studio 和 VS Code 都可以帮助你注释掉代码区域。如果你选择了几行文本并按下 Ctrl-K,然后立即按下 Ctrl-C,它会在选择的每一行开头添加 //。而取消注释则是通过 Ctrl-K,Ctrl-U 来实现。(在安装 Visual Studio 时,如果你选择了除了 C# 以外的首选语言,这些操作可能绑定了不同的键序列,但它们也可以在“编辑”→“高级”菜单中找到,并且在默认情况下会显示在文本编辑器工具栏中,这是 Visual Studio 显示的标准工具栏之一。)

谈到忽略的文本,C# 在大多数情况下忽略额外的空白。并非所有的空白都是无关紧要的,因为你至少需要一些空间来分隔完全由字母数字符号组成的标记。例如,你不能将staticvoid作为方法声明的开头—你需要至少一个空格(或制表符、换行符或其他类似的空格字符)来分隔staticvoid。但是对于非字母数字符号,空格是可选的,并且在大多数情况下,单个空格等同于任意数量的空白和换行符。这意味着 示例 2-27 中的三个语句都是等效的。

示例 2-27. 无关紧要的空白
Console.WriteLine("Testing");
Console . WriteLine(   "Testing");
Console.
    WriteLine ("Testing" )
  ;

有几种情况下,C# 对空白更为敏感。在字符串文字内部,空格是有意义的,因为你写入的空格将出现在字符串值中。此外,虽然 C# 大多数情况下不关心你是否将每个元素放在自己的一行中,或者将所有代码放在一个大行中,或者(似乎更可能的是)介于两者之间,但有一个例外:预处理指令必须单独出现在它们自己的行上。

预处理指令

如果你熟悉 C 语言或其直接后代,你可能会想知道 C# 是否有预处理器。它没有单独的预处理阶段,也不提供宏。但是,它确实有少数与 C 预处理器提供的指令类似的指令,尽管选择非常有限。即使 C# 没有像 C 那样的完整预处理阶段,这些仍然被称为预处理指令。

编译符号

C# 提供了一个#define指令,允许你定义一个编译符号。这些符号通常与#if指令一起使用,根据不同情况编译代码。例如,你可能希望某些代码仅在调试版本中存在,或者可能需要在不同平台上使用不同的代码以达到特定效果。通常情况下,你不会直接使用#define指令,而是通过编译器的构建设置定义编译符号。你可以打开*.csproj*文件,在任何<PropertyGroup><DefineConstants>元素中定义你想要的值。另外,Visual Studio 也可以帮助你完成这些操作:右键单击解决方案资源管理器中的项目节点,选择属性,在打开的属性页中转到“生成”部分。该界面允许你为每个构建配置配置不同的符号值(通过向包含这些设置的<PropertyGroup>添加像Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'"这样的属性)。

注意

.NET SDK 默认定义了某些符号。它支持两种配置,Debug 和 Release。在 Debug 配置中定义了一个DEBUG编译符号,而 Release 则会定义RELEASE。它在两种配置中都定义了一个名为TRACE的符号。某些项目类型会获得额外的符号。一个面向.NET Standard 的库将定义NETSTANDARD,以及一个特定版本的符号,比如NETSTANDARD2_0。目标为.NET 6.0 的项目会得到一个NET6_0符号。

编译符号通常与#if#else#elif#endif指令一起使用(#elifelse if的简写)。示例 2-28 使用了其中一些指令,以确保只在调试版本中编译某些代码行。(你也可以写#if false来完全阻止某些代码段的编译。这通常只是临时措施,是一种避免注释嵌套问题的替代方法。)

示例 2-28. 条件编译
#if DEBUG
    Console.WriteLine("Starting work");
#endif
    DoWork();
#if DEBUG
    Console.WriteLine("Finished work");
#endif

C# 提供了一种更微妙的机制来支持这种情况,称为条件方法。编译器识别运行时库定义的一个称为ConditionalAttribute的特性,为其提供特殊的编译时行为。你可以使用这个特性注解任何方法。示例 2-29 使用它来指示只有在定义了DEBUG编译符号时才应使用注解方法。

示例 2-29. 条件方法
[System.Diagnostics.Conditional("DEBUG")]
static void ShowDebugInfo(object o)
{
    Console.WriteLine(o);
}

如果您编写调用以这种方式注释的方法的代码,C# 编译器将在不定义相关符号的构建中省略该调用。因此,如果您编写调用 ShowDebugInfo 方法的代码,编译器将在非调试构建中剥离所有这些调用。这意味着您可以获得与 示例 2-28 相同的效果,但不会用指令使代码混乱。

运行时库的 System.Diagnostics 命名空间中的 DebugTrace 类使用了这个特性。Debug 类提供了各种方法来生成诊断输出,这些方法在 DEBUG 编译符号条件下才会生效,而 Trace 类的方法则在 TRACE 条件下才会生效。如果保留新的 C# 项目的默认设置,通过 Trace 类产生的任何诊断输出将在调试和发布构建中都可用,但调用 Debug 类上的方法的任何代码将不会编译到发布构建中。

警告

Debug 类的 Assert 方法在 DEBUG 条件下才会生效,这有时会让开发人员感到困惑。Assert 允许您指定必须在运行时为真的条件,如果条件为假,则会抛出异常。C# 初学者经常错误地将两件事放入 Debug.Assert 中:实际上应该在所有构建中发生的检查,以及代码其余部分依赖的具有副作用的表达式。这会导致错误,因为编译器会在非调试构建中剥离此代码。

#error 和 #warning

C# 允许您使用 #error#warning 指令生成编译器错误或警告。这些通常用于条件区域内,就像 示例 2-30 所示的那样,尽管无条件的 #warning 可能会作为提醒自己尚未编写某些特别重要的代码的一种方式。

示例 2-30. 生成编译器错误
#if NETSTANDARD
  #error .NET Standard is not a supported target for this source file
#endif

#line

#line 指令在生成的代码中很有用。当编译器产生错误或警告时,它会说明问题发生的位置,提供文件名、行号和该行内的偏移量。但是,如果所讨论的代码是使用其他文件自动生成的,并且如果该其他文件包含问题的根本原因,那么将错误报告在输入文件中可能更有用,而不是在生成的文件中。#line 指令可以指示 C# 编译器表现得好像错误发生在指定的行号,并且可选地,好像错误发生在完全不同的文件中。示例 2-31 展示了如何使用它。指令后的错误将被报告好像来自名为 Foo.cs 的文件的第 123 行。您可以通过编写 #line default 来告诉编译器恢复报告警告和错误而不进行伪造。

示例 2-31. #line 指令和故意错误
#line 123 "Foo.cs"
    intt x;

此指令还影响调试。当编译器生成调试信息时,它会考虑#line指令。这意味着在调试器中逐步执行代码时,你将看到#line引用的位置。

文件名部分是可选的,这使你可以伪造行号。相反,此编译指示还接受更复杂的形式,在其中可以提供列和范围信息,用于生成的代码与输入之间没有直接的行对行关系的情况。ASP.NET Core Web 框架使用此功能:它包括一个名为 Razor 的功能,允许将 C# 表达式与 HTML 混合。Razor 通过生成 C# 文件工作,但它使用#line指令,以便调试器显示开发人员在 Razor 文件中编写的原始代码,而不是生成的代码。

这个指令还有另外一个用法。不需要行号(和可选的文件名),你可以写#line hidden。这只影响调试器的行为:在单步调试时,Visual Studio 将直接运行所有这种指令之后的代码,直到遇到非hidden #line指令(通常是#line default)为止。

#pragma

#pragma指令提供了两个功能:它可用于禁用选定的编译器警告,也可用于覆盖编译器放入包含调试信息的*.pdb*文件中的校验和值。这两者主要设计用于代码生成场景,尽管偶尔在普通代码中禁用警告可能也有用。示例 2-32 展示了如何使用#pragma防止编译器在你声明了但未使用的变量时发出的警告。

示例 2-32. 禁用编译器警告
#pragma warning disable CS0168
    int a;

通常应避免禁用警告。此功能在生成的代码中很有用,因为代码生成通常会创建未始终使用的项,而编译器指令可能是获得干净编译的唯一途径。但当你手动编写代码时,通常应该能够避免首先出现正常的编译器警告。

话虽如此,如果您选择了额外的诊断,禁用特定警告可能会很有用。NuGet 上的一些组件提供代码分析器,这些组件连接到 C# 编译器 API 并有机会检查代码并生成自己的诊断消息。(这发生在构建时,在 Visual Studio 中编辑时也会提供实时诊断,即您键入时。如果安装了 OmniSharp C# 扩展并启用了 omn⁠ish⁠arp⁠.en⁠ab⁠leRos⁠lyn​Ana⁠lyz⁠ers 设置,它们也会在 Visual Studio Code 中实时工作。).NET SDK 还包括内置的分析器,可以检查代码的各个方面,如遵守命名约定或常见安全错误的存在。您可以使用 AnalysisMode 设置在项目级别配置这些内容,但与编译器警告一样,可能希望在特定情况下禁用分析器警告。您可以使用 #pragma warning 指令来控制来自代码分析器的警告,而不仅仅是来自 C# 编译器的警告。分析器通常在其警告编号前加上一些字母以便您区分它们——例如,编译器警告全部以 CS 开头,而来自.NET SDK 分析器的警告以 CA 开头。

C# 的未来版本可能基于 #pragma 添加其他功能。当编译器遇到它不理解的 #pragma 时,它会生成一个警告而不是错误,因为未识别的 #pragma 可能对未来的编译器版本或其他供应商的编译器有效。

#nullable

#nullable 指令允许对可为空注解上下文和可为空警告上下文进行精细控制。这是可为空引用功能的一部分。第三章 更详细地描述了 #nullable 指令。

#region#endregion

最后,我们有两个什么也不做的预处理指令。如果您写 #region 指令,编译器唯一做的就是确保它们有相应的 #endregion 指令。不匹配会导致编译器错误,但编译器会忽略正确配对的 #region#endregion 指令。区域可以是嵌套的。

这些指令完全是为了那些选择识别它们的文本编辑器而存在。Visual Studio、VS Code 和 Rider 使用它们来提供将代码段折叠到屏幕上单行的能力。C# 编辑器自动允许某些特性扩展和折叠,例如类定义、方法和代码块(一种称为大纲的功能)。如果你使用这两个指令定义区域,它也将允许这些区域进行扩展和折叠。这允许在编辑器自动提供的细粒度(例如单个块内)和粗粒度(例如多个相关方法)的大纲之间进行大纲化。

如果你在 Visual Studio 中将鼠标悬停在折叠区域上,它会显示一个工具提示,显示该区域的内容。你可以在#region标记后面放置文本。当 IDE 显示一个折叠区域时,它将此文本显示在剩下的单行上。虽然可以省略此文本,但通常最好包含一些描述性文本,以便人们可以大致了解他们如果展开将会看到什么。

一些人喜欢将类的整个内容放入不同的区域,因为通过折叠所有区域,你可以一目了然地看到文件的结构。由于区域被缩减为单行,甚至整个文件可能会一次性显示在屏幕上。另一方面,一些人讨厌折叠区域,因为它们在查看代码时会造成阻碍,并且还会鼓励人们将过多的源代码放入一个文件中。

基础数据类型

.NET 在其运行库中定义了数千种类型,你可以编写自己的类型,因此 C#可以处理无限数量的数据类型。然而,一些类型从编译器中获得特殊处理。你之前在示例 2-9 中看到过,如果你有一个字符串,并尝试将数字添加到它,编译后的代码将把该数字转换为字符串并附加到第一个字符串上。事实上,行为比那更一般——它不仅限于数字。编译后的代码通过调用String.Concat方法工作,如果向其传递任何非字符串参数,它将在执行附加操作之前调用它们的ToString方法。所有类型都提供ToString方法,因此这意味着你可以将任何类型的值附加到字符串上。

这很方便,但它之所以有效,是因为 C#编译器了解字符串并为其提供特殊服务。(C#规范的一部分定义了+运算符的唯一字符串处理方式。)C#不仅为字符串提供各种特殊服务,还为某些数值数据类型、布尔值、一系列称为元组的类型以及两种特定类型——dynamicobject提供特殊服务。这些大多数不仅对 C#特有,而且对运行时也是特有的——几乎所有数值类型在中间语言(IL)中都得到直接支持,而boolstringobject类型也被运行时本质上理解。

数值类型

C#支持整数和浮点数算术运算。有符号和无符号整数类型,它们有各种不同的大小,如表 2-1 所示。最常用的整数类型是int,因为它足够大,可以表示广泛的值范围,而且在支持.NET 的所有 CPU 上工作效率也很高。(较大的数据类型可能不会被 CPU 原生支持,并且在多线程代码中可能具有不良特性:32 位类型的读取和写入是原子的⁵,但对于较大的类型可能不是。)

表 2-1. 整数类型

C# 类型 CLR 名称 有符号 位大小 包含范围
byte System.Byte 8 0 到 255
sbyte System.SByte 8 −128 到 127
ushort System.UInt16 16 0 到 65,535
short System.Int16 16 −32,768 到 32,767
uint System.UInt32 32 0 到 4,294,967,295
int System.Int32 32 −2,147,483,648 到 2,147,483,647
ulong System.UInt64 64 0 到 18,446,744,073,709,551,615
long System.Int64 64 −9,223,372,036,854,775,808 到 9,223,372,036,854,775,807
nint System.IntPtr 取决于 取决于
nuint System.UIntPtr 取决于 取决于

表 2-1 中的第二列显示了 CLR 中类型的名称。不同的语言有不同的命名约定,C# 使用其 C 家族根源的名称用于数值类型,但这些名称不符合 .NET 对其数据类型的命名约定。对于运行时来说,第二列中的名称是真正的名称——有各种 API 可以在运行时报告类型信息,它们报告这些 CLR 名称,而不是 C# 的名称。除了最后两项外,在 C# 源代码中,名称在语义上是同义词,因此您可以自由地使用运行时名称,但 C# 的名称在风格上更合适——C 家族语言的关键字均为小写。由于编译器处理这些类型的方式与其他类型不同,因此让它们显眼可能是个好主意。

nintnuint 类型在这里是特例。这些是本地大小整数类型(因此有 n 前缀),用于需要直接处理内存中数据地址的低级代码。这就是它们没有固定大小的原因——在 32 位进程中它们是 32 位宽,在 64 位进程中是 64 位宽。与表 2-1 中的所有其他类型不同,根据使用 C# 名称或 CLR 名称的方式,可用的特性也不同:C# 当前不允许在使用 System.IntPtrSystem.UIntPtr 时进行算术运算,但它支持 nintnuint,并且还添加了来自其他整数类型的各种隐式转换。这些是非常专业的类型,通常仅在为非 .NET 库编写包装器时使用,并且我仅出于完整性将它们包含在这个表格中。

警告

并非所有的.NET 语言都支持无符号数,因此.NET 运行库倾向于避免使用它们。支持多种语言的运行时(如 CLR)面临着在提供足够丰富的类型系统以涵盖大多数语言需求之间的权衡,同时又不会强加过于复杂的类型系统于简单的语言上。为解决这个问题,.NET 的类型系统 CTS 相对而言是相当全面的,但语言并不必须支持其全部。.NET 还定义了公共语言规范(CLS),它确定了所有语言应支持的相对较小的 CTS 子集。有符号整数在 CLS 中,但无符号整数不在其中。这解释了一些看起来令人惊讶的类型选择,比如数组的Length属性是int(而不是uint),尽管它永远不会返回负值。

C# 也支持浮点数。有两种类型:floatdouble,分别是 32 位和 64 位的数字,符合标准的IEEE 754 格式,如表 2-2 中的 CLR 名称所示,这些通常被称为单精度双精度数。浮点数值的工作方式与整数不同,因此这张表格与整数类型表格有所不同。浮点数存储值和指数(在概念上类似于科学计数法,但是使用二进制而不是十进制)。精度列显示了值部分有多少位可用,然后范围被表示为可以表示的最小非零值和最大值(这些可以是正数或负数)。

表 2-2. 浮点数类型

C# 类型 CLR 名称 位大小 精度 范围(数量级)
float System.Single 32 23 位(约 7 个十进制数字) 1.5 × 10^(−45) 到 3.4 × 10³⁸
double System.Double 64 52 位(约 15 个十进制数字) 5.0 × 10^(−324) 到 1.7 × 10³⁰⁸

C#识别了第三种非整数数值表示,称为decimal(或 CLR 中的Sys⁠tem.​Dec⁠im⁠al)。这是一个 128 位的值,因此它可以提供比其他格式更高的精度,但它并不仅仅是double的扩展版本。它设计用于需要可预测处理小数部分的计算,这是floatdouble都无法提供的。如果你编写了这样的代码:将类型为float的变量初始化为 0,然后连续九次加上 0.1,你可能期望得到一个值为 0.9,但实际上你会得到大约是 0.9000001。这是因为 IEEE 754 标准将数字存储为二进制,无法表示所有的十进制小数。它可以处理一些情况,比如十进制的 0.5 在二进制中表示为 0.1。但是十进制的 0.1 在二进制中会变成一个循环数(具体来说,是 0.0 后跟一个循环序列 0011)。这意味着floatdouble只能表示十进制值 0.1 的近似值,更广义地说,只有少数小数可以被完全准确地表示。这并不总是立即显而易见,因为当浮点数转换为文本时,它们会被舍入为一个可以掩盖差异的十进制近似值。但在多次计算中,这种不准确性往往会累积,最终产生看似令人惊讶的结果。

对于某些类型的计算,这并不重要;例如在模拟或信号处理中,预期会有一些噪声和误差。但是会计师和金融监管者往往不太宽容——这种小的差异可能会让人觉得钱似乎神奇地消失或出现了。我们需要涉及金钱的计算绝对精确,这使得二进制浮点数对于这样的工作来说是一个糟糕的选择。这就是为什么 C#提供decimal类型,它提供了一个明确定义的十进制精度水平。

注意

大多数整数类型可以由 CPU 本地处理。(在 64 位进程中运行时,它们全部可以处理。)同样,许多 CPU 可以直接处理floatdouble的表示。然而,没有一个 CPU 有内置的支持decimal,这意味着即使是简单的操作,如加法,也需要多个 CPU 指令。这意味着使用decimal进行算术运算比迄今为止展示的其他数值类型要慢得多。

decimal将数字存储为符号位(正或负)和一对整数。有一个 96 位整数,而decimal的值是这第一个整数(如果符号位表示如此,则取负数)除以 10 的第二整数次方,这是 0 到 28 之间的一个数(并不是所有的 29 位数,但有一些是)。 因此,第二个整数——表示第一个整数除以的 10 的幂——有效地决定了小数点的位置。这种格式使得能够精确表示任何具有 28 个或更少数字的十进制数。

当您编写字面数值时,可以选择类型,也可以让编译器为您选择合适的类型。如果您写一个普通整数,比如 123,其类型将为 intuintlongulong — 编译器将从这些范围包含该值的第一个类型中进行选择(所以 123 将是 int3000000000 将是 uint5000000000 将是 long 等)。如果您写一个带有小数点的数字,例如 1.23,其类型是 double

如果您处理大数,很容易搞错零的数量。这通常是不好的,可能会非常昂贵或危险,具体取决于您的应用领域。C#通过允许在数字文字中的任何位置添加下划线来提供一些缓解,可以根据您的需求将数字分割开来。这类似于大多数讲英语的国家中常见的用逗号将零分组成三组的常见做法。例如,大多数以英语为母语的人不会写 5000000000,而会写成 5,000,000,000,这样一来,您能够立即看到这是 50 亿而不是 500 亿或 500 百万。 (很多以英语为母语的人不知道的是,世界上有几个国家使用句号,而不是逗号。这些国家会把 5,000,000,000 写成 5.000.000.000,而把逗号放在大多数以英语为母语的人会把小数点放的位置。要理解一个像€100.000 这样的值,您需要知道正在使用哪个国家的惯例,以免犯灾难性的金融计算错误。不过,我岔开了话题。) 在 C#中,我们可以通过将数字文字写成 5_000_000_000 来做类似的事情。

通过添加后缀,您可以告诉编译器您需要的特定类型。因此,123Uuint123Llong,而 123UL 则是 ulong。后缀字母不区分大小写和顺序,所以您可以写成 123UL,也可以写成 123Lu123uL 或任何其他排列组合。对于 doublefloatdecimal,分别使用后缀 DFM

这些最后三种类型都支持大数字的十进制指数字面量格式,其中您先放置一个小数点,然后是字母E,后跟一个整数。该值是第一个数字乘以 10 的第二个数字次方。例如,字面值1.5E-20是值 1.5 乘以 10^(−20)的结果。(这恰好是double类型,因为这是具有小数点的数字的默认类型,无论其是否处于指数格式中。您可以写1.5E-20F1.5E-20M来表示等效值的floatdecimal常量。

在十六进制中写入整数字面量通常很有用,因为数字在运行时使用的二进制表示中更好地映射到数字。当数字的不同位范围表示不同事物时,这一点尤为重要。例如,您可能需要处理来自 Windows 系统调用的数值错误代码——这些错误偶尔会出现在异常中。在某些情况下,这些代码使用最高位来指示成功或失败,接下来的几位表示错误的起源,剩余的位用于标识具体的错误。例如,COM 错误代码 E_ACCESSDENIED 的值为−2,147,024,891. 在十进制中很难看到结构,但在十六进制中更容易:80070005. 数字 8 表示这是一个错误,接下来的 007 表示这原本是一个普通的 Win32 错误,已经转换为 COM 错误。剩余的位表示 Win32 错误代码为 5(ERROR_ACCESS_DENIED)。在这种情况下,C#允许您以十六进制编写整数字面量,以便更清晰地阅读。只需在数字前加上0x;因此,在这种情况下,您会写成0x80070005

您还可以使用0b前缀编写二进制字面量。在十六进制和二进制中可以像在十进制中一样使用数字分隔符,虽然在四位一组地分组二进制数字比十六进制更常见,如此:0b_0010_1010。显然,这比十六进制使数字中的任何二进制结构更加明显,但 32 位二进制字面量的长度令人不便,这就是为什么我们经常使用十六进制的原因。

数字转换

每种内置的数字类型在内存中存储数字时使用不同的表示。从一种形式转换为另一种形式需要一些工作,即使数字 1 在查看其二进制表示作为floatintdecimal时看起来差异很大。然而,C#能够生成代码来在各种格式之间进行转换,并且通常会自动执行这些转换。示例 2-33 展示了一些会发生这种情况的案例。

示例 2-33. 隐式转换
int i = 42;
double di = i;
Console.WriteLine(i / 5);
Console.WriteLine(di / 5);
Console.WriteLine(i / 5.0);

第二行将一个int变量的值赋给一个double变量。C#编译器会生成必要的代码,将整数值转换为其等效的浮点值。更微妙的是,最后两行将执行类似的转换,正如我们从代码的输出中可以看到的那样:

8
8.4
8.4

这表明第一次除法产生了一个整数结果——将整数变量i除以整数文字 5 导致编译器生成执行整数除法的代码,因此结果为 8。但另外两个除法产生了浮点结果。在第二种情况下,我们将double变量di除以整数文字 5。在执行除法之前,C#将该 5 转换为浮点数。 (作为优化,在这种特定情况下,编译器恰好在编译时执行了该转换,因此它为该表达式生成了与我们写了di / 5.0相同的代码。)而在最后一行中,我们将整数变量除以浮点文字。这次是变量的值在执行除法之前从整数转换为浮点值。(由于i是一个变量,而不是常量,因此编译器会生成在运行时执行该转换的代码。)

一般情况下,当您执行包含不同数值类型混合的算术运算时,C#会选择具有最大范围的类型,并在执行计算之前将具有较窄范围的类型的值提升为该较大类型。 (算术运算符通常要求所有操作数具有相同的类型,因此如果您提供具有不同类型的操作数,则某种类型必须在任何特定的运算符中“获胜”。)例如,double可以表示int可以表示的任何值,以及许多int无法表示的值,因此double是更具表现力的类型。⁷

在 C#中,当转换是升级(即目标类型比源类型范围更广)时,C#会隐式执行数值转换,因为不存在转换失败的可能性。然而,在另一个方向上,它不会隐式转换。示例 2-34 的第二和第三行将无法编译通过,因为它们试图将double类型的表达式分配给int,这是一种缩小转换,意味着源类型可能包含超出目标范围的值。

示例 2-34. 错误:隐式转换不可用
int i = 42;
int willFail = 42.0;
int willAlsoFail = i / 1.0;

在这个方向上是可以转换的,只是不能隐式转换。你可以使用强制转换,在括号中指定要转换为的类型的名称。示例 2-35 展示了示例 2-34 的修改版本,我们明确表示我们要转换为int,并且要么不介意这个转换可能不正确,要么有理由相信,在这种特定情况下,值将在范围内。请注意,在最后一行中,我在强制转换后的表达式周围加上了括号。这使得强制转换应用于整个表达式;否则,C#的优先规则意味着它只适用于i变量,而由于那已经是一个int,它将没有效果。

示例 2-35. 使用强制转换进行显式转换
int i = 42;
int i2 = (int) 42.0;
int i3 = (int) (i / 1.0);

因此,缩小转换需要显式转换,而不能丢失信息的转换会隐式发生。然而,对于某些类型的组合,两者都不严格比另一个更具表现力。如果尝试将int加到uint,或者将int加到float会发生什么?这些类型都是 32 位大小,因此它们都不可能提供超过 2³²个不同的值,但它们具有不同的范围,这意味着每种类型都有它可以表示的值,其他类型无法表示。例如,你可以在uint中表示值 3000000001,但对于int来说太大了,只能在float中近似表示。随着浮点数变得更大,可以表示的值之间的距离变得更远——float可以表示 3000000000 和 3000001024,但中间没有任何值。因此,对于值 3000000001,uint似乎比float更好。但是-1 呢?那是一个负数,所以uint无法处理。然后有一些非常大的数字,float可以表示,但对于intuint来说超出范围。每种类型都有其优势和劣势,说其中一种通常比其他类型更好是没有意义的。

令人惊讶的是,即使在这些潜在的有损情况下,C#也允许一些隐式转换。规则只考虑范围,而不考虑精度:如果目标类型的范围完全包含源类型的范围,则允许隐式转换。因此,你可以从intuint转换为float,因为虽然float无法精确表示某些值,但至少没有intuint值它无法至少近似表示。但是,不允许在另一个方向进行隐式转换,因为有些float值太大了——与float不同,整数类型无法为更大的数字提供近似值。

当你强制将一个数值转换为int类型时,可能会想知道在超出范围的情况下会发生什么,就像示例 2-35 中所做的那样。答案取决于你要转换的类型。从一个整数类型到另一个整数类型的转换与从浮点数到整数的转换有所不同。事实上,C#规范并未定义如何将过大的浮点数转换为整数类型——结果可能是任何值。但是当在不同大小的整数类型之间进行转换时,结果是明确定义的。如果两种类型的大小不同,二进制数据将会被截断或填充零(或者如果源类型是有符号的且值为负,则填充为一),以使其成为目标类型的正确大小,然后这些位将被视为目标类型的位。这有时很有用,但更可能会产生令人惊讶的结果,因此你可以通过将其设置为checked转换来选择任何超出范围的转换的替代行为。

checked上下文

C#定义了checked关键字,你可以将其放在块语句或表达式前面,使其成为checked上下文。这意味着某些算术操作,包括转换,会在运行时检查是否发生了范围溢出。如果在checked上下文中将一个值转换为整数类型,且该值过高或过低以至于无法容纳,将会导致错误——代码将抛出System.OverflowException

除了检查转换外,checked上下文还将检测普通算术中的范围溢出。加法、减法和其他操作可能使一个值超出其数据类型的范围。对于整数来说,当未经检查时,这会导致数值“溢出”,因此将最大值加 1 会产生最小值,反之亦然。有时这种环绕操作可能很有用。例如,如果你想确定代码中两个时间点之间经过了多少时间,一种方法是使用Environment.TickCount属性。⁸(这比使用当前日期和时间更可靠,因为后者可能会因时钟调整或时区切换而改变。Tick count 会以稳定的速率不断增加。尽管如此,在实际代码中,你可能会使用运行时库的Stopwatch类。)示例 2-36 展示了一种实现这一点的方法。

示例 2-36. 利用未检查的整数溢出
int start = Environment.TickCount;
DoSomeWork();
int end = Environment.TickCount;

int totalTicks = end - start;
Console.WriteLine(totalTicks);

Environment.TickCount的棘手之处在于它偶尔会“环绕”。它计算自系统上次启动以来的毫秒数,由于其类型为int,最终会超出范围。25 天的时间跨度是 21.6 亿毫秒,这个数字对于int来说太大了。(可以通过使用TickCount64属性来避免这种情况,它可以支持近 3 亿年的时间。但在.NET Framework 或任何当前的.NET 标准中都不可用。)假设时刻数为 2,147,483,637,比int的最大值少 10。你希望它在 100 毫秒后是多少?它不能比之前的值高 100(2,147,483,727),因为那对于int来说太大了。我们期望它在 10 毫秒后达到最大可能值,因此在 11 毫秒后,它将会回到最小值;因此,在 100 毫秒后,我们预期时刻数将比最小值高 89(即−2,147,483,559)。

警告

实际上,时刻数并不一定精确到最近的毫秒。在跳跃前,它通常会静止几毫秒的时间,然后以 10 毫秒、15 毫秒或更多的增量向前跳跃。然而,这个值仍然会溢出——你可能无法观察到它的每一个可能的时刻值在溢出时的情况。

有趣的是,示例 2-36 完美地处理了这个问题。如果 start 中的时刻数在计数环绕之前获得,而 end 中的时刻数在之后获得,end 将包含一个比 start 低得多的值,这似乎有些反常,它们之间的差异将会很大——大于一个 int 的范围。然而,当我们从 start 中减去 end 时,溢出会以与时刻数溢出完全匹配的方式发生,这意味着我们最终会得到正确的结果。例如,如果 start 包含从溢出前 10 毫秒获得的时刻数,而 end 是从之后 90 毫秒获得的,减去相关的时刻数(即减去−2,147,483,558 从 2,147,483,627),看起来应该产生 4,294,967,185 的结果。但由于减法溢出的方式,我们实际上得到了一个结果为 100,这对应于 100 毫秒的经过时间。

但在大多数情况下,这种整数溢出是不可取的。这意味着在处理大数时,可能会得到完全不正确的结果。通常情况下,这不是一个大问题,因为你将处理的是相当小的数字,但如果你的计算可能会遇到溢出的可能性,你可能希望使用 checked 上下文。在表达式中使用 checked 运算符可以请求这一点,就像示例 2-37 所示的那样。括号内的所有内容将在 checked 上下文中进行评估,所以如果将 ab 相加时发生溢出,你将看到 OverflowException。这里 checked 关键字并不适用于整个语句,因此如果由于添加 c 而导致溢出,则不会引发异常。

示例 2-37. Checked 表达式
int result = checked(a + b) + c;

你也可以通过 checked 语句来为整个代码块开启检查,这是一个以 checked 关键字开头的块,如示例 2-38 所示。checked 语句总是涉及一个块 —— 你不能只在示例 2-37 中的 int 关键字前面添加 checked 关键字就将其转换为 checked 语句。你还需要将代码放在大括号中。

示例 2-38. Checked 语句
checked
{
    int r1 = a + b;
    int r2 = r1 - (int) c;
}
警告

checked 块只影响块内的代码行。如果代码调用任何方法,这些方法不会受到 checked 关键字的影响——CPU 中没有某种 checked 位在 checked 块内启用当前线程。(换句话说,此关键字的范围是词法作用域,而非动态作用域。)

C# 还有一个 unchecked 关键字。你可以在 checked 块内使用它来指示特定表达式或嵌套块不应处于 checked 上下文中。如果你希望除了一个特定表达式外的所有内容都要检查,而不是将所有内容标记为 checked,你可以将所有代码放入 checked 块中,然后排除希望允许溢出而无错误的部分。

您可以配置 C#编译器,使其默认将所有内容放入检查上下文中,以便仅显式unchecked表达式和语句才能在溢出时静默失败。在 Visual Studio 中,您可以通过打开项目属性,转到“生成”选项卡,然后单击“高级”按钮来配置此设置。或者您可以编辑*.csproj*文件,在<PropertyGroup>中添加<CheckForOverflowUnderflow>true</CheckFor​Ove⁠rfl⁠owU⁠nde⁠rfl⁠ow>。请注意,这样做会有显著的成本——检查可能会使单个整数操作变慢数倍。整体上对应用程序的影响将较小,因为程序不会花费全部时间执行算术运算,但成本可能仍然不容忽视。当然,与任何性能问题一样,您应该测量实际影响。您可能会发现,性能成本是为了保证发现意外溢出而支付的可接受代价。

BigInteger

有一个最后一个值得注意的数字类型:BigInteger。它是运行时库的一部分,并且不会受到 C#编译器的特殊认可,因此严格来说不属于本书的这一部分。然而,它定义了算术运算符和转换,这意味着你可以像使用内置数据类型一样使用它。它将编译为稍微不那么紧凑的代码格式——.NET 程序的编译格式可以原生地表示整数和浮点值,但是BigInteger必须依赖于普通类库类型使用的更通用的机制。从理论上讲,它可能会慢得多,尽管在大量代码中,你对小整数进行基本算术运算的速度并不是限制因素,所以你可能不会注意到这一点。至于编程模型,它在你的代码中看起来和感觉像是正常的数字类型。

顾名思义,BigInteger代表一个整数。它的独特卖点是它将根据需要增长以容纳值。因此,与内置数值类型不同,它在范围上没有理论限制。示例 2-39 使用它来计算斐波那契数列中的值,并显示每 10 万个值。这很快产生了远远超出其他整数类型范围的数字。我展示了此示例的完整源代码,包括using指令,以说明此类型定义在System.Numerics命名空间中。

示例 2-39. 使用BigInteger
using System.Numerics;

BigInteger i1 = 1;
BigInteger i2 = 1;
Console.WriteLine(i1);
int count = 0;
while (true)
{
    // The % operator returns the remainder of dividing its 1st operand by its
    // 2nd, so this displays the number only when count is divisible by 100000.
    if (count++ % 100000 == 0)
    {
        Console.WriteLine(i2);
    }
    BigInteger next = i1 + i2;
    i1 = i2;
    i2 = next;
}

尽管BigInteger没有固定的限制,但存在实际限制。例如,您可能会生成一个超出可用内存范围的数字。或者更有可能的是,数字可能会增长到足以使即使是基本算术所需的 CPU 时间变得不可接受的程度。但在耗尽内存或耐心之前,BigInteger将会增长以容纳任意大的数字。

布尔值

C#定义了一个叫做 bool 的类型,或者在运行时称之为 System.Boolean。它只提供了两个值: truefalse。而某些 C 语言家族允许数字类型代表布尔值,例如约定使用 0 表示假和其他任何值表示真,C#不会接受数字。它要求用 bool 表示真或假,并且任何数字类型都不能转换为 bool。例如,在 if 语句中,你不能写 if (someNumber) 来仅在 someNumber 非零时运行某些代码。如果你想要这样做,你需要明确地写成 if (someNumber != 0)

字符串和字符

string 类型(与 CLR System.String 类型同义)代表文本。字符串是一系列 char 类型的值(或 CLR 称之为 System.Char),每个 char 是一个表示单个 UTF-16 代码单元 的 16 位值。

一个常见的错误是认为每个 char 表示一个字符。(类型的名称要为此负责的一部分。)这通常是正确的,但并非总是如此。需要记住两个因素:首先,我们可能认为是单个字符的东西可以由多个 Unicode 代码点 组成。(代码点是 Unicode 的核心概念,至少在英语中,每个字符都由一个单独的代码点表示,但某些语言更复杂。)示例 2-40 使用 Unicode 的 0301“组合重音符号”在字母上添加重音以形成文本 cafés

示例 2-40. 字符与 char
char[] chars = { 'c', 'a', 'f', 'e', (char) 0x301, 's' };
string text = new string(chars);

因此,这个字符串是六个 char 值的序列,但它代表的文本看起来只包含五个字符。还有其他方法可以实现这一点——我可以使用代码点 00E9“拉丁小写带重音的 e”来表示该重音字符作为单个代码点。但任何一种方法都是有效的,并且在某些场景中,创建所需确切字符的唯一方法是使用这种组合字符机制。这意味着对字符串中的 char 值执行某些操作可能会产生令人惊讶的结果——如果你颠倒值的顺序,结果字符串看起来不会像文本的颠倒版本——重音符号现在会应用于 s,导致 śefac!(如果我使用 00E9 而不是组合 e 和 0301,颠倒字符将产生不那么令人惊讶的 séfac。)

尽管 Unicode 的组合标记,还有第二个因素需要考虑。Unicode 标准定义的代码点数量多于可以用单个 16 位值表示的数量。(我们在 2001 年超过了这一点,当时 Unicode 3.1 定义了 94,205 个代码点。)UTF-16 将任何值大于 65,535 的代码点表示为一对 UTF-16 代码单元,称为代理对。Unicode 标准定义了将代码点映射到代理对的规则,以便生成的代码单元的值在 0xD800 到 0xDFFF 的范围内,这是一个保留范围,永远不会定义任何代码点。(例如,代码点 10C48,“古代突厥文字母 ORKHON BASH”,看起来像 ,将变成 0xD803,后跟 0xDC48。)

总之,用户视为单个字符的项可能用多个 Unicode 代码点表示,而某些单个代码点可能表示为多个代码单元。因此,操作构成字符串的单个 char 值是一项您应该谨慎对待的工作。通常情况下,简单的方法已经足够——例如,如果您想要搜索一个字符串以查找某些适合单个代码单元(如 /)的特定字符,一个简单的基于 char 的搜索就足够了。但是,如果您有一个更复杂的场景需要正确检测所有多代码单元序列,运行时库在这里提供了一些帮助。

string 类型提供了一个 EnumerateRunes 方法,有效地将代理对组合成它们所表示的代码点的值。它将字符串呈现为 Rune 类型值的序列,如果一个字符串包含刚刚描述的 0xD803, 0xDC48 序列,这对 char 值将被呈现为一个值为 0x10C48 的单个 RuneRune 类型仍然在单个代码点的级别上操作,因此它不能帮助您处理组合字符,但如果您需要进一步,运行时库在 Sys⁠tem.​Glo⁠bal⁠iza⁠ti⁠on 命名空间中定义了一个 StringInfo 类。它将字符串解释为“文本元素”的序列,在像 cafés 这样的情况下,它将 é 报告为一个单一的文本元素,即使它是使用组合字符机制形成的两个代码点。

字符串的不可变性

.NET 字符串是不可变的。有许多操作听起来似乎会修改字符串,比如连接操作,或者 ToUpperToLower 方法,但每个操作都会生成一个新的字符串,原始字符串保持不变。这意味着,如果你将字符串作为参数传递,即使是给你没有编写的代码,你也可以确保它不能改变你的字符串。

不可变性的缺点在于字符串处理可能效率低下。如果需要对字符串进行一系列修改的工作,比如逐字符构建字符串,你将会分配大量内存,因为每次修改都会生成一个新的字符串。这会给.NET 的垃圾收集器增加很多额外工作,导致程序使用比必要更多的 CPU 时间。在这些情况下,你可以使用一种叫做StringBuilder的类型。(与string不同,这种类型在 C#编译器中并未特别认可。)这在概念上类似于string——它是一系列char值,并提供各种有用的字符串操作方法——但是它是可修改的。或者,在极其性能敏感的场景中,你可能会使用第十八章中展示的技术。

字符串操作方法

string类型具有许多实例方法来处理字符串。我已经提到了ToUpperToLower,但还有用于在字符串中查找文本的方法,包括IndexOfLastIndexOfStartsWithEndsWith返回一个bool值,指示字符串是否以特定字符或字符串开头或结尾。Split接受一个或多个分隔符字符(例如逗号或空格),并返回一个数组,其中包含分隔符之间的每个子字符串。例如,"One,two,three".Split(',')返回一个包含三个字符串"One""two""three"的数组。Substring接受一个起始位置和可选长度,并返回一个新字符串,其中包含从起始位置开始到字符串末尾或指定长度的所有字符;Remove则相反:它通过删除Substring将返回的原始字符串的一部分形成一个新字符串。Insert通过在另一个字符串的中间插入一个字符串来形成一个新字符串。Replace返回一个通过将特定字符或字符串的所有实例替换为另一个字符或字符串而形成的新字符串。Trim可用于删除不需要的前导和尾随字符,如空格。

格式化字符串中的数据

C# 提供了一种语法,使得可以轻松生成包含固定文本和运行时确定信息的字符串。(这种特性的官方名称是字符串插值。)例如,如果你有名为nameage的局部变量,你可以在字符串中使用它们,就像示例 2-41 所示。

示例 2-41. 字符串中的表达式
string message = $"{name} is {age} years old";

当你在字符串字面量前面加上$符号时,C#编译器会查找由大括号分隔的嵌入表达式,并生成将表达式的文本表示插入到字符串中的代码。 (因此,如果nameage分别是Ian48,则字符串的值将是"Ian is 48 years old"。)嵌入表达式可以比变量名更复杂,正如示例 2-42 所示。

示例 2-42. 字符串中的更复杂表达式
double width = 3, height = 4;
string info = $"Hypotenuse: {Math.Sqrt(width * width + height * height)}";

如果你想使用字符串插值,但又希望生成的字符串包含开放或关闭的大括号,则将它们加倍。当插值字符串包含{{}}时,编译器不会将它们解释为嵌入表达式的分隔符,而只会在输出中生成单个{}。例如,$"Brace: {{, braces: {{}}, width: {width}, braced width: {{{width}}}"评估为Brace: {, braces: {}, width: 3, braced width: {3}(假设width3)。

运行时库提供了另一种将值插入字符串的机制。string类的Format方法接受一个带有形如{0}{1}的编号占位符的字符串,后跟一系列提供这些占位符值的参数。示例 2-43 使用这种方法实现了与示例 2-41 和 2-42 相同的效果。

示例 2-43. 使用string.Format
string message = string.Format("{0} is {1} years old", name, age);
string info = string.Format(
    "Hypotenuse: {0}",
    Math.Sqrt(width * width + height * height));

这种编号占位符机制较旧,自 C# 1.0 起就存在,而字符串插值是在 C# 6.0 中引入的,因此你会在许多地方看到它的身影。例如,Console.WriteLine支持它。它确实比字符串插值有一个优点:如果你想将大量表达式组合成一个字符串,或者如果你要使用的任何表达式很大,则插值字符串语法可能变得笨拙;像示例 2-43 那样将一个长的成分表达式放在自己的一行上有时可以提高可读性。但是,字符串插值要少出错得多——string.Format使用基于位置的占位符,很容易将表达式放在错误的位置。对于阅读代码的任何人来说,尝试弄清编号占位符与后续参数的关系尤其是在表达式数量增加时是很乏味的。插值字符串通常更容易阅读。

插值字符串有时可以提供性能优势。string.Format 总是在运行时组装字符串,但是使用字符串插值时,编译器可能能够执行编译时优化。例如,如果插值字符串中的表达式是一个const字符串(第三章描述了const关键字),编译器将在编译时将其值插入到字符串中。此外,C# 10.0 允许库表明它们希望参与插值过程,从而可以避免在不使用该字符串的情况下创建字符串。何时可能编写一个不会使用的插值字符串?请参考 示例 2-44。

示例 2-44. 潜在未使用的插值字符串
Debug.Assert(everythingIsOk, $"Everything is *not* OK: {myApplicationModel}");

这里使用了 Debug.Assert,这是一个诊断方法,您可以将其添加到代码中,以检测应用程序是否进入了某些意外状态。Debug.Assert 检查其第一个参数,如果为false,它将停止程序,并显示作为第二个参数传递的消息。但是,如果参数为true,它将在不使用第二个参数的情况下继续执行。在本例中,如果在插值字符串中调用 My⁠App⁠lic⁠ati⁠on​Mod⁠elToString() 方法很昂贵,那么即使在一切正常的情况下也会很不好——我们的程序可能正在做大量工作来创建一个最终会被丢弃的字符串。但是,.NET 6.0 添加了 Debug.Assert 的新重载,利用了 C# 10.0 中的新字符串插值特性,以一种避免在不使用时创建该字符串的方式。此机制也可以被日志记录框架使用,其中代码通常可以生成大量字符串以提供发生情况的详细描述,但在未启用详细日志记录的典型情况下将不会使用这些字符串。

对于某些数据类型,它们的文本表示方式有所选择余地。例如,对于浮点数,您可能希望限制小数位数,或者强制使用指数表示法。(例如,1e6代替1000000。)在.NET 中,我们通过格式说明符来控制这一点,它是一个描述如何将某些数据转换为字符串的字符串。某些数据类型只有一个合理的字符串表示形式,因此它们不支持此功能,但对于具有多个字符串形式的类型,您可以将格式说明符作为参数传递给ToString方法。例如,System.Math.PI.ToString("f4")PI常量(类型为double)格式化为四位小数("3.1416")。数字有九种内置格式,如果没有一种适合您的要求,还有一种用于定义自定义格式的小语言。此外,不同类型使用不同的格式字符串——例如,日期与数字的工作方式大不相同——因此,这里列出的可用格式的范围太大了。Microsoft 提供了详尽的文档说明。

在使用string.Format时,您可以在占位符中包含格式说明符;例如,{0:f3} 表示第一个表达式应格式化为小数点后三位数。您也可以以类似的方式在字符串插值中包含格式说明符。示例 2-45 展示了带有小数点后一位数的年龄。

示例 2-45. 格式说明符
string message = $"{name} is {age:f1} years old";

这里有一个细微的问题:对于许多数据类型,转换为字符串的过程是与文化相关的。例如,如前所述,在美国和英国,小数通常用句点分隔整数部分和小数部分,您可能使用逗号来分组数字以提高可读性,但一些欧洲国家则颠倒此习惯:他们使用句点分组数字,而逗号表示小数部分的开始。因此,在一个国家中写成 1,000.2,在另一个国家可能写成 1.000,2。

就源代码中的数字文字而言,这是一个无关紧要的问题:C#使用下划线进行数字分组,并始终使用句点作为小数点。但是在运行时处理数字时怎么办?默认情况下,您将获得由当前线程文化确定的约定,并且除非您已更改,否则它将使用计算机的区域设置。有时这很有用——它可以意味着数字、日期等按照程序运行的任何区域设置正确格式化。但这可能会有问题:如果您的代码依赖于字符串以特定方式格式化(例如,序列化将通过网络传输的数据),则可能需要应用特定的约定集。因此,您可以向string.Format方法传递格式提供程序,这是一个控制格式约定的对象。同样,依赖于区域设置的数据类型接受其ToString方法中的可选格式提供程序参数。但是,在使用字符串插值时如何控制这一点呢?没有地方可以放置格式提供程序。您可以通过string类型的Create方法解决此问题,如示例 2-46 所示。

Example 2-46. 使用不变文化的格式规范
decimal v = 1234567.654m;
string i = string.Create(CultureInfo.InvariantCulture, $"Quantity {v:N}");
string f = string.Create(new CultureInfo("fr"), $"Quantity {v:N}");
string frc = string.Create(new CultureInfo("fr-FR"), $"Quantity {v:C}");
string cac = string.Create(new CultureInfo("fr-CA"), $"Quantity {v:C}");

这里将不同的格式提供程序传递给string.Create方法,但每次使用相同的插值字符串。请注意,它在前两行变量名后面加上:N。这要求普通数字格式,包括数字分隔符。第一次调用使用不变文化,这保证了无论代码在何种区域设置中运行,格式始终一致,导致i得到值"Quantity 1,234,567.654"。第三行使用构造参数为"fr"CultureInfo对象。这告诉它我们希望以法语文化为代表的方式格式化字符串,所以变量f得到值"Quantity 1.234.567,654"。最后两行使用:C,表示我们希望以货币形式显示值。我分别传递了代表法国和加拿大法语区域的文化,结果分别为欧元和美元符号。

这可能看起来很奇怪:通常,方法参数在传递到方法之前会先进行评估,因此您可能希望插值字符串在调用string.Create之前变成普通字符串,这意味着应用指定的格式提供程序已经太晚了。但正如我之前所说,方法可以表明它们希望参与字符串插值过程。string.Create方法正是这样做的,使其能够控制该过程,这就是它能够应用格式提供程序的方式。

原始字符串字面量

C# 支持一种更方便的表示字符串值的方式:你可以在字符串字面量前加上 @ 符号,如 @"Hello"。这种形式的字符串被称为逐字字符串字面量。它们有两个优点:一是能提高包含反斜杠的字符串的可读性,二是能够编写多行字符串字面量。

在普通字符串字面量中,编译器将反斜杠视为转义字符,使得可以包含各种特殊值。例如,在字面量 "Hello\tWorld!" 中,\t 表示单个制表符(代码点 9)。这是在 C 系列语言中表达控制字符的常见方式。你还可以使用反斜杠在字符串中包含双引号——反斜杠可以阻止编译器将字符解释为字符串结束。尽管如此,这使得在字符串中包含反斜杠有些麻烦:你必须写两个反斜杠。由于 Windows 在路径中使用反斜杠,这可能会变得很丑陋:"C:\\Windows\\System32\\"。在这种情况下,逐字字符串字面量非常有用,因为它会逐字处理反斜杠,使你可以仅写 @"C:\Windows\System32"。 (你仍然可以在逐字字面量中包含双引号:只需连续写两个双引号。例如,@"Hello ""World""" 会产生字符串值 Hello "World"。)

提示

你可以在插值字符串前面使用 @ 符号。这样做既结合了逐字字面量的好处——直接使用反斜杠和换行符,又支持嵌入表达式。

逐字字符串字面量还允许值跨多行。在普通字符串字面量中,如果结束的双引号不在同一行上,编译器将报错。但在逐字字符串字面量中,字符串可以跨越源代码的任意行数。

结果字符串将使用源代码使用的换行符约定。假如你还没遇到这种情况,那么计算机历史上的一个不幸意外是不同系统使用不同的字符序列来表示换行符。互联网协议中主导的系统使用一对控制码表示每行结尾:无论是 Unicode 还是 ASCII,我们使用代码点 13 和 10,分别表示回车换行,通常简称为 CR LF。这是计算机屏幕出现之前的过时遗物,开始新行意味着将电传打印机的打印头移回起始位置(回车),然后将纸向上移动一行(换行)。时代背景下,HTTP 规范和多种流行的电子邮件标准如 SMTP、POP3 和 IMAP 要求使用这种表示法。这也是 Windows 的标准约定。不幸的是,Unix 操作系统及其大多数衍生产品如 macOS 和 Linux 的约定不同——这些系统的约定是仅使用单个换行字符。C#编译器接受任意一种约定,并且即使单个源文件混合使用了这两种约定,它也不会抱怨。这给多行字符串字面量引入了潜在问题,特别是如果你正在使用一个为你转换换行符的源代码控制系统。例如,Git是一个非常流行的源代码控制系统,由于它的起源(由 Linux 的创始人 Linus Torvalds 创建),它的仓库中广泛使用 Unix 风格的换行符约定。然而,在 Windows 上可以配置它将文件的工作副本转换为 CR LF 表示法,在提交更改时再将其转换回 LF。这意味着,文件看起来会因为在 Windows 系统或 Unix 系统上查看它们而使用不同的换行符约定。(甚至从一个 Windows 系统到另一个 Windows 系统也可能不同,因为默认的换行符处理是可配置的。个别用户可以配置机器范围内的默认设置,也可以为他们本地克隆的任何仓库设置配置,如果该仓库未指定该设置。)这反过来意味着,在 Windows 系统上编译包含多行字面量字符串的文件可能会产生与在 Unix 系统上看到的完全相同文件产生微妙不同的行为,如果启用了自动换行符转换(在大多数 Windows 安装的 Git 上默认是这样)。这可能没问题——在 Windows 上运行时通常需要 CR LF,在 Unix 上运行时需要 LF——但如果将代码部署到与构建代码的操作系统不同的机器上可能会有意外情况发生。因此,在你的仓库中提供一个*.gitattributes文件是非常重要的,以便指定所需的行为,而不是依赖于可变的本地设置。如果需要在字符串字面量中依赖特定的换行符,最好在.gitattributes*中禁用换行符转换。

元组

元组让你将多个值组合成一个值。元组这个名称(与许多提供类似功能的编程语言共享)意味着它是诸如doubletriplequadruple等单词的泛化版本,但即使在我们不需要泛化的情况下,我们通常也称它们为元组。例如,即使我们在讨论一个包含两个项目的元组时,我们仍然称其为元组,而不是双。示例 2-47 创建一个包含两个int值的元组,然后显示它们。

示例 2-47. 创建和使用元组
(int X, int Y) point = (10, 5);
Console.WriteLine($"X: {point.X}, Y: {point.Y}");

那第一行是一个带有初始化器的变量声明。值得详细解释一下,因为元组的语法使得声明看起来比我们到目前为止见到的稍微复杂一些。记住,这种形式语句的一般模式如下:

*type identifier* = *initial-value*;

这意味着在示例 2-47 中,类型为(int X, int Y)。因此,我们说我们的变量point是一个包含两个int类型值的元组,我们希望将它们称为XY。这里的初始化器是(10, 5)。因此,当我们运行这个示例时,它产生以下输出:

X: 10, Y: 5

如果你喜欢使用var,你会高兴地知道,你可以使用在示例 2-48 中展示的语法在初始化器中指定名称,从而使用var而不是显式类型。这相当于示例 2-47。

示例 2-48. 在初始化器中命名元组成员
var point = (X: 10, Y: 5);
Console.WriteLine($"X: {point.X}, Y: {point.Y}");

如果你从现有变量初始化一个元组并且没有指定名称,编译器会假定你想使用那些变量的名称,正如示例 2-49 所示。

示例 2-49. 从变量推断元组成员名称
int x = 10, y = 5;
var point = (x, y);
Console.WriteLine($"X: {point.x}, Y: {point.y}");

这引出了一个风格上的问题:元组成员的名称应该以小写还是大写字母开头?这些成员在性质上类似于属性,我们将在第三章中讨论,按照传统,这些属性通常以大写字母开头。因此,许多人认为元组成员的名称也应该是大写的。对于一个经验丰富的 .NET 开发者来说,示例 2-49 中的point.x看起来很奇怪。然而,另一个 .NET 的惯例是局部变量通常以小写字母开头命名。如果你遵循这两个惯例,元组名称推断看起来并不是很有用。许多开发者选择接受在纯粹用于局部变量的元组中使用小写元组成员名称,因为这样做可以使用方便的名称推断功能,仅在暴露给方法外部的元组中使用这种大小写风格。

可能这并不重要,因为元组成员名称实际上只存在于观察者的眼中。首先,它们是可选的。正如示例 2-50 所示,省略它们是完全合法的。这些名称默认为Item1Item2等。

示例 2-50. 默认元组成员名称
(int, int) point = (10, 5);
Console.WriteLine($"X: {point.Item1}, Y: {point.Item2}");

其次,名称仅用于使用元组的代码的便利性,并不对运行时可见。您可能已经注意到,在示例 2-47 中,我使用了与示例 2-50 中相同的初始化表达式(10, 5),因为它没有指定名称,所以表达式的类型是(int, int),这与在示例 2-47 中的(int X, int Y)匹配。这是因为名称本质上是无关紧要的—在底层它们都是相同的东西。(正如我们将在第四章 中看到的那样,在运行时,它们都表示为类型为ValueTuple<int, int>的实例。)C#编译器会跟踪我们选择使用的名称,但是对于 CLR 来说,所有这些元组都只有称为Item1Item2的成员。由此产生的结果是,我们可以将任何元组分配给具有相同形状的任何变量,正如示例 2-51 所示。

示例 2-51. 元组的结构等价性
(int X, int Y) point = (46, 3);
(int Width, int Height) dimensions = point;
(int Age, int NumberOfChildren) person = point;

这种灵活性是一把双刃剑。在示例 2-51 中的赋值看起来相当草率。将代表位置的值赋予代表大小的值可能是可以接受的某些情况。但是将同样的值赋予看似表示某人年龄和子女数量的值似乎是错误的。尽管如此,编译器不会阻止我们,因为它认为所有包含一对int值的元组都具有相同的类型。(这与将一个名为ageint变量赋值给一个名为heightint变量的情况没有什么不同。它们都是int类型。)

如果要强制进行语义区分,最好根据第三章 中描述的方式定义自定义类型。元组的真正设计目的是在不真正需要的情况下方便地将几个值打包在一起。

C#确实要求元组具有适当的形状。您不能将(int, int)赋值给(int, string),也不能赋值给(int, int, int)。然而,在“数字转换”中的所有隐式转换都是有效的,因此您可以将具有(int, int)形状的任何内容赋给(int, double)(double, long)。因此,元组实际上就像是在另一个变量中整齐地包含了一些变量。

元组支持比较,因此您可以在本章后面描述的==!=关系运算符中使用它们。为了被视为相等,两个元组必须具有相同的形状,并且第一个元组中的每个值必须等于其在第二个元组中的对应值。

元组解构

有时候你会想要将一个元组分解为其组成部分。最直接的方法是按顺序访问每个项的名称(或者作为Item1Item2等,如果你没有指定名称),但是 C#提供了另一种机制,称为解构。示例 2-52 声明并初始化了两个元组,然后展示了两种不同的解构方式。

示例 2-52. 构造后解构元组
(int X, int Y) point1 = (40, 6);
(int X, int Y) point2 = (12, 34);

`(``int` `x``,` `int` `y``)` `=` `point1``;`
Console.WriteLine($"1: {x}, {y}");
`(``x``,` `y``)` `=` `point2``;`
Console.WriteLine($"2: {x}, {y}");

在定义了point1point2之后,这将point1解构为两个变量,xy。这种特定形式的解构还声明了元组被解构到的变量。在我们解构point2时展示了另一种形式 —— 在这里,我们将其解构为两个已经存在的变量,因此不需要声明它们。

直到你习惯了这种语法,第一个解构示例可能会令人困惑地类似于前几行,在那里我们声明并初始化了新的元组。在那些最初的几行中,(int X, int Y)文本表示一个具有两个名为XYint值的元组类型,但在解构行中当我们写(int x, int y)时,我们实际上声明了两个类型为int的变量。唯一显著的区别是,在构造新元组的行中,在=符号之前有一个变量名。(此外,在那里我们使用大写名称,但这只是一种约定。完全合法的是写(int x, int y) point3 = point1;。那将声明一个名为point3的新元组,其中包含两个名为xyint值,初始化为与point1中相同的值。同样,我们可以写(int X, int Y) = point1;。那将把point1解构为两个名为XY的局部变量。)

从 C# 10.0 开始,你可以混合使用两种解构形式。在此之前,元组的任何单一解构都必须为目标的每个部分声明一个新变量,或者每个目标都必须是一个已存在的变量。但是正如示例 2-53 所示,现在单个解构可以包含目标类型的混合。

示例 2-53. 在元组解构中混合声明和现有变量
int u;
(u, int v) = point1;

如果你不需要元组的每个元素,你可以使用下划线,正如示例 2-54 所示。这被称为废弃

示例 2-54. 使用废弃的元组解构
(_, int h) = point1;

下划线字符可以出现在目标的任何位置,并告诉编译器我们不需要将元组的该部分提取到变量中。

动态类型

C#定义了一种名为dynamic的类型。这个类型并不直接对应 CLR 中的任何类型 —— 当我们在 C#中使用dynamic时,编译器将其呈现给运行时作为object,这在下一节中有描述。然而从 C#代码的角度来看,dynamic是一种独特的类型,它启用了一些特殊的行为。

使用 dynamic 时,编译器在编译时不会尝试检查代码执行的操作是否可能成功。换句话说,它有效地禁用了我们通常在 C# 中得到的静态类型行为。你可以自由地在 dynamic 变量上尝试几乎任何操作 —— 你可以使用算术运算符,可以尝试调用其方法,可以尝试将其分配到其他类型的变量中,并且可以尝试获取或设置其属性。当你这样做时,编译器生成的代码试图在运行时理解你要求它做的事情。

如果你从一种这种行为是常态的语言(如 JavaScript)转到 C#,你可能会倾向于因为它符合你习惯的工作方式而将 dynamic 用于一切。然而,你应该意识到它存在一些问题。首先,它是为特定场景设计的:与某些早期 .NET Windows 组件的互操作性。Windows 中的组件对象模型(COM)是自动化 Microsoft Office 套件及许多其他应用程序的基础,而 Office 内置的脚本语言是动态的。由此带来的一个结果是,从 C# 中使用许多 Office 的自动化 API 曾经是一项艰苦的工作。将 dynamic 添加到语言中的一个主要动机之一是希望改进这一点。

和所有 C# 特性一样,它的设计考虑了更广泛的适用性,而不仅仅是作为 Office 互操作功能。但由于这是该功能最重要的应用场景,你可能会发现它支持你从动态语言熟悉的习惯用法的能力令人失望。还需要注意的第二个问题是,它并不是语言中正在进行大量新工作的领域。在引入它时,微软竭尽全力确保所有动态行为尽可能与编译器在编译时知道你将使用的类型时所见到的行为一致。

这意味着支持 dynamic 的基础架构(称为动态语言运行时或 DLR)必须复制 C# 行为的重要部分。然而,自从 dynamic 在 2010 年的 C# 4.0 中添加以来,DLR 并没有得到太多更新,尽管语言自那时以来引入了许多新功能。当然,dynamic 仍然可用,但其功能反映了大约十年前的语言外观。

尽管 dynamic 一开始出现时就存在一些限制。C# 的某些方面依赖于静态类型信息的可用性,这意味着 dynamic 一直在处理委托以及 LINQ 方面存在问题。因此,从一开始,与按照预期使用 C# 作为静态类型语言相比,它确实处于某种劣势。

Object

C# 编译器最后一个特别认可的数据类型是object(或 CLR 称之为System.Object)。这是几乎所有 C# 类型的基类。类型为object的变量能够引用任何派生自object的类型的值。这包括所有数值类型、boolstring类型,以及你可以使用下一章将介绍的关键字定义的任何自定义类型,例如classrecordstruct。同时也包括运行时库定义的所有类型,除了某些只能存储在堆栈上并在第十八章中描述的特定类型。

因此,object是终极通用容器。你可以用object变量引用几乎任何东西。我们在第六章中讨论继承时将回到这一点。

运算符

你之前看到表达式是运算符和操作数的序列。我展示了一些可用作操作数的类型,现在是时候看看 C# 提供了哪些运算符了。表 2-3 展示了支持常见算术操作的运算符。

表 2-3. 基本算术运算符

名称 示例
一元加号(不起作用) +x
取反(一元负号) -x
后增量 x++
后减量 x--
前增量 ++x
前减量 --x
加法 x + y
减法 x - y
乘法 x * y
除法 x / y
取余 x % y

如果你有其他 C 家族语言的经验,所有这些都应该很熟悉。如果不熟悉,可能最奇特的是增量和减量运算符。它们有副作用:对应用于的变量加一或减一(这意味着它们只能应用于变量)。对于后增量和后减量,尽管变量被修改,但包含的表达式最终获取原始值。因此,如果x是一个包含值为 5 的变量,则x++的值也是 5,尽管在计算x++表达式后,x变量将具有值 6。前缀形式返回修改后的值,因此如果x最初是 5,++x生成值 6,这也是在评估表达式后x的值。

尽管表 2-3 中的运算符用于算术运算,某些非数值类型也可以使用。如前所述,当处理字符串时,+符号表示连接,如在第九章中所示,加法和减法运算符也用于组合和删除委托。

C# 还提供了一些运算符,在构成值的位上执行某些二进制操作,如表 2-4 所示。这些运算符不适用于浮点类型。

表 2-4. 二进制整数运算符

名称 示例
按位取反 ~x
按位 AND x & y
按位 OR x &#124; y
按位异或 x ^ y
左移 x << y
右移 x >> y

按位取反运算符反转整数中的所有位数 —— 任何值为 1 的二进制数字变为 0,反之亦然。移位运算符将所有二进制位数左移或右移,移动的位数由第二个操作数指定。左移将底部数字设为 0。对于无符号整数,右移将填充顶部数字为 0,而对于有符号整数的右移则保留顶部数字不变(即负数保持负数,因为它们保持其顶部位设置,而正数将其顶部位设为 0,因此保持正数)。

按位 AND、OR 和 XOR(异或)运算符在整数上执行每个位的布尔逻辑运算。当操作数为 bool 类型时,这三个运算符也可用。(实际上,这些运算符将 bool 视为一位二进制数。)还有一些额外的 bool 值运算符,详见 表 2-5。! 运算符对 bool 执行与 ~ 运算符对整数位的操作相同。

表 2-5. bool 类型运算符

名称 示例
逻辑取反(也称为 NOT) !x
条件 AND x && y
条件 OR x &#124;&#124; y

如果您没有使用其他 C 家族语言,AND 和 OR 运算符的条件版本可能对您来说是新的。它们仅在必要时评估其第二个操作数。例如,在评估 (a && b) 时,如果表达式 afalse,编译器生成的代码甚至不会尝试评估 b,因为无论 b 的值如何,结果都将是 false。相反,如果第一个操作数是 true,条件 OR 运算符则不会费心评估其第二个操作数,因为无论第二个操作数的值如何,结果都将是 true。如果第二个操作数的表达式包含具有副作用(例如方法调用)或可能产生错误的元素,则这一点非常重要。例如,您经常会看到类似 示例 2-55 的代码。

例 2-55. 条件 AND 运算符
if (s != null && s.Length > 10)
...

此代码检查变量 s 是否包含特殊值 null,即它当前不引用任何值。此处使用 && 运算符很重要,因为如果 snull,评估表达式 s.Length 将导致运行时错误。如果我们使用了 & 运算符,编译器将生成代码,总是评估两个操作数,这意味着如果 snull,运行时将会看到 NullReferenceException。通过使用条件 AND 运算符,我们避免了这种情况,因为第二个操作数 s.Length > 10 仅在 s 不是 null 时才会被评估。

注意

尽管像示例 2-55 中所示的代码曾经很常见,但由于引入了 C# 6.0 中的一个特性——null-conditional operators,它已逐渐变得越来越少见。如果你写 s?.Length 而不是仅仅 s.Length,编译器会生成代码来首先检查 s 是否为 null,从而避免 NullReferenceException。这意味着检查可以简化为 if (s?.Length > 10)。此外,C# 的可选可空引用类型(一个相对较新的特性,在第 3 章中讨论)可以帮助减少对 null 测试的需求。

示例 2-55 通过使用 > 运算符测试一个属性是否大于 10。这是几种关系运算符之一,允许我们比较值。它们都接受两个操作数,并产生一个 bool 结果。表格 2-6 显示了这些运算符,支持所有数值类型。某些运算符也适用于其他一些类型。例如,你可以使用 ==!= 运算符比较字符串值。(对于 string,其他关系运算符没有内置的排序含义,因为不同国家对字符串排序顺序有不同的理解。如果你想进行有序的字符串比较,.NET 提供了 StringComparer 类,允许你选择排序规则。)

表格 2-6. 关系运算符

名称 示例
小于 x < y
大于 x > y
小于或等于 x <= y
大于或等于 x >= y
等于 x == y
不等于 x != y

与 C 语言家族语言一样,等号运算符是一对等号。这是因为单个等号表示其他东西:它是一个赋值操作,而赋值也是表达式。这可能导致一个不幸的问题:在某些 C 语言家族语言中,当你打算写 if (x == y) 时却误写成 if (x = y)。幸运的是,在 C# 中这通常会产生编译器错误,因为 C# 有一个专门的类型来表示布尔值。在允许数字代表布尔值的语言中,即使 xy 是数字,这两段代码都是合法的。(第一段意味着将 y 的值赋给 x,然后如果该值非零,则执行 if 语句的主体。这与第二段代码非常不同,它并不改变任何东西的值,并且仅在 xy 相等时执行 if 语句的主体。)但在 C# 中,第一个示例仅在 xy 都是 bool 类型时才有意义。¹⁰

C 家族常见的另一个特性是条件运算符。(有时也称为三元运算符,因为它是语言中唯一接受三个操作数的运算符。)它在两个表达式之间进行选择。更确切地说,它评估其第一个操作数,该操作数必须是布尔表达式,然后根据第一个操作数的值是true还是false返回第二个或第三个操作数的值,分别是。(这只是举例说明。在实践中,您通常会使用.NET 的Math.Max方法,其效果相同但更易读。Math.Max还有一个好处,即如果使用具有副作用的表达式,它将仅评估每个表达式一次,这是您无法使用示例 2-56 中显示的方法做到的,因为我们最终会写两次每个表达式。)

示例 2-56。条件运算符
int max = (x > y) ? x : y;

这说明了为什么 C 及其后继者以简洁的语法而闻名。如果您熟悉此类家族的任何语言,示例 2-56 将很容易阅读,但如果您不熟悉,则其含义可能不会立即清晰。这将评估?符号之前的表达式,在本例中是(x > y),并且它要求是产生bool值的表达式。(括号是可选的。我添加它们是为了使代码更易于阅读。)如果这是true,则使用?:符号之间的表达式(在本例中是x);否则,使用:符号之后的表达式(在这里是y)。

条件运算符类似于条件 AND 和 OR 运算符,因为它只评估必须的操作数。它总是评估其第一个操作数,但永远不会同时评估第二个和第三个操作数。这意味着您可以通过编写类似于示例 2-57 的代码来处理null值。这不会因为snull而导致NullReferenceException的风险,因为它只有在s不为null时才会评估第三个操作数。

示例 2-57。利用条件评估
int characterCount = s == null ? 0 : s.Length;

但在某些情况下,处理null值的方法更简单。假设您有一个string变量,如果它为null,则希望使用空字符串代替。您可以写(s == null ? "" : s)。但您也可以直接使用空值合并运算符,因为它专门设计用于此任务。这个运算符显示在示例 2-58 中(它是??符号),它评估其第一个操作数,如果第一个操作数非空,则结果是该表达式的结果。如果第一个操作数为null,则评估其第二个操作数并使用它代替。

示例 2-58。空值合并运算符
string neverNull = s ?? "";

我们可以将空值条件运算符与空值合并运算符结合起来,以提供比示例 2-57 更简洁的替代方案,如示例 2-59 所示。

示例 2-59. 空值条件和空值合并运算符
int characterCount = s?.Length ?? 0;

条件、空值条件和空值合并运算符提供的主要好处之一是,它们通常允许您在需要编写大量代码的情况下仅编写单个表达式。如果您将该表达式作为方法的参数使用,这将特别有用,例如在示例 2-60 中。

示例 2-60. 条件表达式作为方法参数
FadeVolume(gateOpen ? MaxVolume : 0.0, FadeDuration, FadeCurve.Linear);

与如果条件运算符不存在时需要编写的代码进行比较。您将需要一个 if 语句。(我会在下一节讨论 if 语句,但由于本书不是给初学者的,我假设您对大致概念很熟悉。)您还需要引入一个本地变量,如示例 2-61 所示,或者在 if/else 的两个分支中复制方法调用,并只更改第一个参数。因此,尽管条件和空值合并运算符很简洁,但它们可以从您的代码中移除大量混乱。

示例 2-61. 没有条件运算符的生活
double targetVolume;
if (gateOpen)
{
    targetVolume = MaxVolume;
}
else
{
    targetVolume = 0.0;
}
FadeVolume(targetVolume, FadeDuration, FadeCurve.Linear);

还有最后一组要看的运算符:复合赋值 运算符。这些结合赋值和其他某些操作,并适用于 +, -, *, /, %, <<, >>, &, ^, |, 和 ?? 运算符。它们使您无需编写像示例 2-62 中显示的代码。

示例 2-62. 赋值和加法
x = x + 1;

我们可以将此赋值语句更简洁地写成 示例 2-63 中的代码。所有复合赋值运算符都采用这种形式——您只需在原始运算符的末尾加上 =

示例 2-63. 复合赋值(加法)
x += 1;

这是一种独特的语法,非常清楚地表明我们正在以某种特定方式修改变量的值。因此,尽管这两个片段执行相同的工作,许多开发人员发现第二种习惯上更可取。

这并不是运算符的全面列表。还有一些更专业的运算符,我会在我们看过为其定义的语言区域后再介绍。 (有些与类和其他类型相关,有些与继承相关,有些与集合相关,有些与委托相关。接下来的章节将讲解所有这些内容。)顺便说一句,虽然我一直在描述哪些运算符适用于哪些类型,但也可以编写自定义类型,为大多数这些运算符定义自己的含义。这就是 .NET 的 BigInteger 类型如何支持与内置数值类型相同的算术运算的方式。我将展示如何在 Chapter 3 中实现这一点。

流程控制

到目前为止,我们所检查的大部分代码按照编写顺序执行语句,并在到达末尾时停止。如果这是代码执行流动的唯一可能方式,那么 C# 将没有多大用处。因此,正如你所期望的那样,它有多种结构来编写循环并根据输入来决定执行哪些代码。

使用 if 语句进行布尔决策

if 语句根据 bool 表达式的值决定是否运行特定语句。例如,在 Example 2-64 中的 if 语句将仅在 age 变量的值小于 18 时执行显示消息的块语句。

Example 2-64. 简单的 if 语句
if (age < 18)
{
    Console.WriteLine("You are too young to buy alcohol in a bar in the UK.");
}

使用 if 语句时不一定需要使用块语句。你可以使用任何类型的语句作为主体。只有当你希望 if 语句控制多个语句的执行时才需要块。然而,一些编程风格指南建议在所有情况下都使用块。这部分是为了保持一致性,同时也是因为在以后修改代码时避免可能的错误:如果你的 if 语句的主体是非块语句,然后你在后面添加另一个语句,打算让它成为相同主体的一部分,很容易忘记在这两个语句周围添加块,导致像 Example 2-65 中那样的代码。缩进表明开发人员希望最后一个语句是 if 语句主体的一部分,但 C# 忽略缩进,因此最后一个语句将始终运行。如果你习惯于始终使用块,你就不会犯这种错误。

Example 2-65. 可能不是预期的结果
if (authenticationCodesCorrect)
    SendTransferConfirmation();
    TransferFunds();

if 语句还可以选择包含一个 else 部分,后面跟着另一个语句,仅在 if 语句的表达式求值为 false 时运行。所以 Example 2-66 将根据 optimistic 变量是 true 还是 false 写入第一条或第二条消息。

Example 2-66. ifelse
if (optimistic)
{
    Console.WriteLine("Glass half full");
}
else
{
    Console.WriteLine("Glass half empty");
}

else关键字后可以跟随任何语句,通常这是一个代码块。但是,有一种情况大多数开发者不会为else部分使用代码块,那就是它们使用另一个if语句时。示例 2-67 展示了这一点——它的第一个if语句有一个else部分,该部分的主体是另一个if语句。

示例 2-67. 选择多个可能性
if (temperatureInCelsius < 52)
{
    Console.WriteLine("Too cold");
}
else if (temperatureInCelsius > 58)
{
    Console.WriteLine("Too hot");
}
else
{
    Console.WriteLine("Just right");
}

尽管代码看起来仍然像是为第一个else使用了代码块,但该代码块实际上是第二个if语句的主体。第二个if语句才是else的主体。如果我们要严格遵循给每个ifelse主体分配自己的代码块的规则,我们会将示例 2-67 重写为示例 2-68。这似乎过于繁琐,因为我们试图通过使用代码块来避免的主要风险在示例 2-67 中并不真正适用。

示例 2-68. 过度使用代码块
if (temperatureInCelsius < 52)
{
    Console.WriteLine("Too cold");
}
else
{
    if (temperatureInCelsius > 58)
    {
        Console.WriteLine("Too hot");
    }
    else
    {
        Console.WriteLine("Just right");
    }
}

尽管我们可以像示例 2-67 中展示的那样将if语句链接在一起,但 C#提供了一种更专门的语句,有时可能更易于阅读。

switch语句进行多选

switch语句定义了多个语句组,并根据输入表达式的值运行其中一个组或者什么都不做。正如示例 2-69 所示,你将表达式放在switch关键字后的括号内,然后是由大括号界定的区域,其中包含一系列case部分,为表达式的每个预期值定义行为。

示例 2-69. 使用字符串的switch语句
switch (workStatus)
{
case "ManagerInRoom":
    WorkDiligently();
    break;

case "HaveNonUrgentDeadline":
case "HaveImminentDeadline":
    CheckTwitter();
    CheckEmail();
    CheckTwitter();
    ContemplateGettingOnWithSomeWork();
    CheckTwitter();
    CheckTwitter();
    break;

case "DeadlineOvershot":
    WorkFuriously();
    break;

default:
    CheckTwitter();
    CheckEmail();
    break;
}

如你所见,单个部分可以服务于多个可能性——你可以在部分的开头放置多个不同的case标签,如果任何一个情况适用,该部分中的语句将会运行。你也可以编写一个default部分,如果没有case匹配表达式的值,则运行该部分。switch语句不必是全面的,因此如果没有与表达式值匹配的case,也没有default部分,则switch语句不执行任何操作。

不像if语句需要将主体包裹在一个代码块中,case后面可以跟随多个语句而无需包裹它们。示例 2-69 中的各个部分由break语句界定,这会导致执行跳到switch语句的结尾。这并非结束部分的唯一方式——严格来说,C#编译器规定每个case语句列表的结束点不能可达,因此任何导致执行离开switch语句的方式都是可接受的。你可以使用return语句,或者抛出异常,甚至可以使用goto语句。

一些 C 家族语言(例如 C)允许穿透,意味着如果执行允许达到case部分语句的末尾,它将继续执行下一个。示例 2-70 展示了这种风格,但在 C#中不允许,因为该规则要求case语句列表的末尾不可到达。

示例 2-70. C 风格的穿透,在 C#中是非法的。
switch (x)
{
case "One":
    Console.WriteLine("One");
case "Two":  // This line will not compile
    Console.WriteLine("One or two");
    break;
}

C#禁止这种做法,因为绝大多数case部分不会穿透,而在允许它的语言中,当开发者忘记写break语句(或者其他中断switch的语句)时,通常会导致错误。意外的穿透可能会产生不希望的行为,因此 C#要求不仅仅是省略了break:如果你想要穿透,必须明确请求。正如示例 2-71 所示,我们使用不被喜爱的goto关键字来表达我们确实希望一个case穿透到下一个case

示例 2-71. C#中的穿透。
switch (x)
{
case "One":
    Console.WriteLine("One");
    `goto` `case` `"Two"``;`
case "Two":
    Console.WriteLine("One or two");
    break;
}

这在技术上不是goto语句。这是一个goto case语句,只能在switch块内部使用。C#还支持更一般的goto语句——您可以在代码中添加标签,并在方法内部跳转。但是goto被严重反对,因此goto case语句提供的穿透形式似乎是这个关键字唯一被认为是现代社会可接受的用法。

所有这些示例都使用了字符串。你还可以在整数类型、char 类型以及任何枚举(在下一章节中讨论的一种类型)上使用switch。但是case标签不一定要是常量:你还可以使用模式,这将在本章后面讨论。

循环:whiledo

C#支持通常的 C 家族循环机制。示例 2-72 展示了一个while循环。它采用一个bool表达式。评估该表达式,如果结果为true,它将执行后续的语句。到目前为止,这与if语句完全相同,但不同之处在于一旦嵌套语句完成,它将再次评估表达式,如果再次为true,它将再次执行嵌套语句。它将继续执行,直到表达式评估为false。与if语句一样,循环体不需要是一个块,但通常会是。

示例 2-72. 一个while循环。
while (!reader.EndOfStream)
{
    Console.WriteLine(reader.ReadLine());
}

循环体可能会使用break语句提前结束循环。while表达式是truefalse并不重要——执行break语句总是会终止循环。

C# 还提供了continue语句。像break语句一样,它终止当前迭代,但与break不同的是,它然后重新评估while表达式,因此迭代可以继续。continuebreak都直接跳转到循环的结尾,但你可以认为continue直接跳转到循环结束}之前的点,而break则跳转到之后的点。顺便说一下,continuebreak也适用于我即将展示的所有其他循环样式。

因为while语句在每次迭代之前评估其表达式,所以while循环有可能根本不运行其主体。有时,您可能希望编写一个至少运行一次的循环,仅在第一次迭代后评估bool表达式。这就是do循环的目的,如示例 2-73 所示。

示例 2-73. 一个do循环
char k;
do
{
    Console.WriteLine("Press x to exit");
    k = Console.ReadKey().KeyChar;
}
while (k != 'x');

注意,示例 2-73 以分号结尾,表示语句结束。与包含while关键字的示例 2-72 中的行进行比较,后者尽管看起来非常相似,但没有分号。这看起来可能不一致,但这不是打字错误。在示例 2-72 中带有while关键字的行末尾放置分号是合法的,但这会改变其含义——它将指示我们希望while循环的主体是一个空语句。随后的代码块将被视为一个全新的语句,在循环完成后执行。代码将陷入无限循环,除非读者已经在流的末尾。(顺便说一句,编译器会发出“可能是误写的空语句”警告。)

C 风格的for循环

C# 继承自 C 的另一种循环方式是for循环。这类似于while,但它为循环的bool表达式添加了两个特性:提供了一个声明和/或初始化一个或多个变量的位置,这些变量在循环运行期间保持在作用域内,并且提供了一个在每次循环时执行某些操作的位置(除了形成循环体的语句)。因此,for循环的结构如下:

for (*`initializer`*; *`condition`*; *`iterator`*) *`body`*

这种循环的一个非常常见的应用是对数组中的所有元素执行某些操作。示例 2-74 展示了一个for循环,它将数组中的每个元素乘以 2。条件部分的工作方式与while循环完全相同——它确定嵌入语句形成的循环体是否运行,并且在每次迭代之前对其进行评估。再次强调,循环体不一定严格要求是一个块,但通常是。

示例 2-74. 使用for循环修改数组元素
for (int i = 0; i < myArray.Length; i++)
{
    myArray[i] *= 2;
}

此示例中的初始化器声明了一个名为i的变量,并将其初始化为 0。此初始化仅发生一次——如果它每次循环都重置变量为 0,这将毫无用处,因为循环永远不会结束。该变量的生命周期实际上是在循环开始之前开始,并在循环结束时结束。初始化器不需要是变量声明——您可以使用任何表达式语句。

示例 2-74 中的迭代器仅将循环计数器加 1。它在每次循环迭代结束时运行,在主体运行之后和条件重新评估之前运行。(因此,如果条件最初为假,则不仅主体不运行,迭代器也永远不会被评估。)C#不对迭代器表达式的结果执行任何操作——它仅用于其副作用。因此,无论您是写i++++ii += 1,甚至i = i + 1,都没有关系。

for循环不允许您做任何通过编写while循环并在循环之前放置初始化代码以及在循环体末尾放置迭代器来实现的事情。¹¹ 但是,可能存在可读性的好处。for语句将定义如何循环的代码放在一个地方,与定义每次循环时做什么的代码分开,这可能有助于阅读代码的人理解它的功能。他们不必扫描长循环到底部以找到迭代器语句(尽管长循环体跨越代码页通常被认为是不良实践,因此最后一个好处有些可疑)。

如示例 2-75 所示,初始化器和迭代器都可以包含列表,尽管在这种特定情况下并不是非常有用——因为所有迭代器每次循环都会运行,ij将始终具有相同的值。

示例 2-75. 多个初始化器和迭代器
for (int i = 0, j = 0; i < myArray.Length; i++, j++)
...

您不能编写一个单独的for循环来执行多维迭代。如果需要,您可以像示例 2-76 中所示一样将一个循环嵌套在另一个循环中。

示例 2-76. 嵌套for循环
for (int j = 0; j < height; ++j)
{
    for (int i = 0; i < width; ++i)
    {
        ...
    }
}

尽管示例 2-74 展示了一个足够常见的遍历数组的习惯用法,您通常会使用不同、更专业的构造。

使用 foreach 循环进行集合迭代

C#提供了一种不在 C 语系语言中通用的循环风格。foreach循环专门用于迭代集合。foreach循环符合以下模式:

foreach (*`item``-``type` `iteration``-``variable`* in *`collection`*) *`body`*

集合 是一个表达式,其类型必须与编译器识别的特定模式匹配。运行时库的IEnumerable<T>接口,我们将在第五章中看到,符合这种模式,尽管编译器实际上并不需要实现该接口——它只需要集合有一个类似该接口定义的GetEnumerator方法。示例 2-77 使用foreach显示数组中的所有字符串(所有数组都提供foreach所需的方法)。

示例 2-77. 使用foreach遍历集合
string[] messages = GetMessagesFromSomewhere();
foreach (string message in messages)
{
    Console.WriteLine(message);
}

此循环将为数组中的每个项目运行一次主体。迭代变量(在本例中为message)每次循环都不同,并且将引用当前迭代的项目。

从某种角度来看,这比示例 2-74 中显示的基于for的循环更不灵活:foreach循环无法修改其迭代的集合。这是因为并非所有集合都支持修改。IEnumerable<T>对其集合要求非常少——它不要求可修改性、随机访问,甚至不要求在前面知道集合提供的项目数量。事实上,IEnumerable<T>能够支持永不结束的集合。例如,可以完全合法地编写一个实现,它将返回随机数,只要你愿意继续获取值即可。

foreachfor提供了两个优势。一个优势是主观的,因此存在争议:它更可读。但显著的是,它也更通用。如果您正在编写对集合执行操作的方法,那么如果使用foreach而不是for,这些方法将更广泛适用,因为您将能够接受一个IEnumerable<T>。示例 2-78 可以处理包含字符串的任何集合,而不仅仅限于数组。

示例 2-78. 通用集合迭代
public static void ShowMessages(IEnumerable<string> messages)
{
    foreach (string message in messages)
    {
        Console.WriteLine(message);
    }
}

此代码可以处理不支持随机访问的集合类型,例如第五章中描述的LinkedList<T>类。它还可以处理决定按需生成项目的惰性集合,包括迭代器函数生成的集合,同样显示在第五章中,以及某些 LINQ 查询生成的集合,如第十章中描述的那样。

模式

C#中还有一个最后一个重要的机制要看一看:模式。模式描述了一个值可以根据其进行测试的一个或多个条件。你已经在某些简单模式中看到了它们的作用:switch中的每个case指定了一个模式。但正如我们将要看到的,有许多种类的模式,它们不仅仅适用于switch语句。

之前的 switch 示例,例如 示例 2-69,都使用了最简单的模式类型之一:它们都是常量模式。对于这些模式,只需指定一个常量值,如果表达式具有该值,则匹配此模式。示例 2-79 展示了更有趣的模式类型:它使用了声明模式。如果表达式具有指定类型,则匹配声明模式。正如你在 “Object” 中看到的那样,某些变量能够保存各种不同类型的值。类型为 object 的变量是这种情况的极端案例,因为它们几乎可以保存任何类型的值。语言特性如接口(在 第三章 中讨论)、泛型(在 第四章 中)和继承(在 第六章 中)可能导致变量的静态类型提供了比任意类型 object 更多的信息,但仍为运行时的各种可能类型留下了余地。在这些情况下,声明模式可以非常有用。

示例 2-79. 声明模式
switch (o)
{
case string s:
    Console.WriteLine($"A piece of string is {s.Length} long");
    break;

case int i:
    Console.WriteLine($"That's numberwang! {i}");
    break;
}

声明模式有一个有趣的特性,常量模式没有:除了所有模式共有的布尔匹配/不匹配之外,声明模式会产生额外的输出。示例 2-79 中的每个 case 引入一个变量,然后该 case 的代码继续使用该变量。这个输出只是输入,但是复制到具有指定静态类型的变量中。因此,如果 o 最终是 string,那么第一个 case 将匹配,并且我们可以通过 s 变量访问它(这就是为什么 s.Length 表达式编译正确;如果 o 的类型是 objecto.Length 就不会编译通过)。

有时,实际上并不需要声明模式的输出结果——知道输入是否匹配模式就足够了。处理这些情况的一种方法是使用丢弃:如果在通常用于输出变量名称的位置放置一个下划线 (_),那告诉 C# 编译器你只关心值是否匹配类型。C# 9.0 引入了一个更简洁的替代方案:类型模式。类型模式看起来和工作方式类似于声明模式,但没有变量 —— 如 示例 2-80 所示,模式仅由类型名称组成。

示例 2-80. 类型模式
switch (o)
{
case string:
    Console.WriteLine("This is a piece of string");
    break;

case int:
    Console.WriteLine("That's numberwang!");
    break;
}

有些模式需要更多工作来产生它们的输出。例如,示例 2-81 展示了一个位置模式,它匹配任何包含一对 int 值的元组,并将这些值提取到两个变量 xy 中。

示例 2-81. 位置模式
case (int x, int y):
    Console.WriteLine($"I know where it's at: {x}, {y}");
    break;

位置模式是递归模式的一个示例:它们是包含其他模式的模式。在这种情况下,这个位置模式包含声明模式作为它的每一个子模式。但正如示例 2-82 展示的那样,我们可以在每个位置使用常量值来匹配具有特定值的元组。

示例 2-82. 带有常量值的位置模式
switch (p)
{
case (0, 0):
    Console.WriteLine("How original");
    break;

case (0, 1):
case (1, 0):
    Console.WriteLine("What an absolute unit");
    break;

case (1, 1):
    Console.WriteLine("Be there and be square");
    break;
}

我们可以混合使用,因为位置模式可以在每个位置包含不同的模式类型。示例 2-83 展示了一个在第一个位置使用常量模式,在第二个位置使用声明模式的位置模式。

示例 2-83. 带有常量和声明模式的位置模式
case (0, int y):
    Console.WriteLine($"This is on the X axis, at height {y}");
    break;

如果你是var的粉丝,你可能会想知道是否可以像示例 2-84 那样编写。这是可行的,这里的xy变量的静态类型取决于模式输入表达式的类型。如果编译器可以确定表达式如何解构(例如,如果switch语句输入的静态类型是(int, int)元组),那么它将使用这些信息来确定输出变量的静态类型。在未知情况下,但仍然可以想象此模式可能匹配的情况下(例如,如果输入是object),那么这里的xy也将具有类型object

示例 2-84. 带有var的位置模式
case (var x, var y):
    Console.WriteLine($"I know where it's at: {x}, {y}");
    break;
注意

编译器将拒绝那些它能够确定匹配不可能发生的模式。例如,如果它知道输入类型是 (string, int, bool) 元组,它不可能匹配只有两个子模式的位置模式,所以 C# 不会允许你尝试。

示例 2-84 展示了一个不寻常的情况,即在某些情况下,使用 var 而不是显式类型可能会引入显著的行为变化。这些 var 模式 与 示例 2-81 中的 声明模式 在一个重要方面有所不同:var 模式 总是匹配其输入,而 声明模式 则检查其输入的类型以确定在运行时是否匹配。实际上,这种检查可能会被优化掉——有些情况下,声明模式将始终匹配,因为其输入类型在编译时已知。但在代码中表达的唯一方法,以确保在位置模式中的子模式不执行运行时检查,是使用 var。因此,尽管一个包含声明模式的位置模式与 示例 2-52 中显示的解构语法非常相似,其行为却大不相同。示例 2-81 实际上执行了三个运行时测试:值是否为 2 元组,第一个值是否为 int,第二个值是否为 int。(因此,它适用于静态类型为 (object, object) 的元组,只要每个值在运行时为 int 即可。)这其实不应该让人感到意外:模式的目的是在运行时测试值是否具有某些特征。然而,在某些递归模式中,您可能希望表达运行时匹配的混合(例如,这个东西是 string 吗?)与静态类型的解构(例如,如果这是 string,我想提取其 Length 属性,我相信它的类型是 int,如果这种信念被证明错误,我希望编译器报错)。模式并不设计用于这样做,所以最好不要试图以这种方式使用它们。

如果我们不需要使用元组中的所有项怎么办?您已经知道一种处理方法。由于我们可以在每个位置使用任何模式,我们可以使用一个声明模式,在第二个位置丢弃其结果:(int x, int _)。或者我们可以使用类型模式:(int x, int)。然而,示例 2-85 显示了一个更简短的替代方案:与其使用类型模式,我们可以只使用一个孤立的下划线。这是一个 丢弃模式。您可以在需要模式但希望指示该特定位置可以使用任何内容且您不需要知道其内容的任何位置使用它。

示例 2-85. 带有丢弃模式的位置模式
case (int x, _):
    Console.WriteLine($"At X: {x}. As for Y, who knows?");
    break;

这与丢弃声明模式或类型模式的语义略有不同:这些模式将在运行时检查要丢弃的值是否具有指定的类型,并且仅当此检查成功时,模式才会匹配。但是丢弃模式总是匹配的,因此它将匹配 (10, 20)(10, "Foo")(10, (20, 30)),例如。

位置模式不是唯一的递归模式:您还可以编写属性模式。我们将在下一章详细讨论属性,但现在只需知道它们是一种类型的成员,提供某种信息,例如string类型的Length属性,返回一个int,告诉您字符串包含多少个代码单元。示例 2-86 显示了检查此Length属性的属性模式。

示例 2-86. 属性模式
case string { Length: 0 }:
    Console.WriteLine("How long is a piece of string? Not very!");
    break;

此属性模式以类型名称开头,因此它有效地包含了类型模式的行为,除了其基于属性的测试之外。(在类型模式的输入类型已足够具体以识别属性的情况下,可以省略此内容。例如,在这种情况下,如果输入已经是类型为string的静态内容,则可以省略此内容。)然后,跟随一个花括号中的部分,列出模式想要检查的每个属性及其应用的模式。(这些子模式是使其成为另一个递归模式的内容。)因此,此示例首先检查输入是否为string。如果是,然后将常量模式应用于字符串的Length,因此仅当输入为具有长度为 0 的string时,此模式匹配。

属性模式可以选择指定输出。示例 2-86 没有这样做。示例 2-87 显示了语法,尽管在这种特定情况下,这并不是非常有用,因为此模式将确保s仅指向空字符串。

示例 2-87. 具有输出的属性模式
case string { Length: 0 } s:
    Console.WriteLine($"How long is a piece of string? This long: {s.Length}");
    break;

由于属性模式中的每个属性都包含一个嵌套模式,因此这些模式也可以产生输出,如示例 2-88 所示。

示例 2-88. 具有输出的嵌套模式的属性模式
case string { Length: int length }:
    Console.WriteLine($"How long is a piece of string? This long: {length}");
    break;

您可以在属性模式内嵌套属性模式。示例 2-89 使用这种方式来检查由Environment.OSVersion报告的操作系统版本,测试其主要版本是否等于 10。

示例 2-89. 具有嵌套属性模式的属性模式
switch (Environment.OSVersion)
{
    case { Version: { Major: 10 } }:
        Console.WriteLine("Windows 10, 11, or later");
        break;
}

C# 10.0 添加了一种更简洁的语法来表达相同的内容。您可以用示例 2-90 替换示例 2-89 中的case。它具有完全相同的效果,但是表达意图更为紧凑,且可以认为更易读。

示例 2-90. 扩展属性模式
case { Version.Major: 10 }:
    Console.WriteLine("Windows 10, 11, or later");
    break;

组合和否定模式

C# 提供了三种用于模式匹配的逻辑操作:and(与)、or(或)和 not(非)。其中最简单的是 not,它可以反转模式的含义。示例 2-91 使用 not 来确保仅在变量非空时运行特定代码。这将 not 应用于一个常量模式:这里的 null 被解释为一个常量模式。如果我们仅写 null,那么当值为 null 时该模式匹配,但使用 not null 时,该模式在值非 null 时匹配。

示例 2-91. 使用模式非空检测非空性
case not null:
    Console.WriteLine($"User's middle name is: {middleName}");
    break;

我们可以使用 andor 来组合两个模式。(这些被官方称为 合取析取 模式;显然 C# 的语言设计者们是形式逻辑的粉丝。)如果我们使用 and 来组合两个模式,结果是一个仅在两个组成模式都匹配时才匹配的模式。例如,如果你想编写代码反对我的中间名,你可以使用 示例 2-92 中展示的方法。这也展示了你可以混合使用这些逻辑操作:它同时使用了 andnot

示例 2-92. 使用模式合取 (and) 和非 (not)
case not null and not "David":
    Console.WriteLine($"User's middle name is: {middleName}");
    break;

我们可以类似地使用 or,其效果是当其组成模式中任何一个匹配时,该模式匹配其输入。通过重复使用 and 和/或 or 可以构建更大的组合。

关系模式

当模式的类型支持比较时,模式可以使用 <<=>=> 操作符。示例 2-93 展示了一个包含两个 关系模式switch 语句,这些基于这些操作符的模式称为关系模式。

示例 2-93. 关系模式
switch (value)
{
case > 0: Console.WriteLine("Positive"); break;
case < 0: Console.WriteLine("Negative"); break;
default: Console.WriteLine("Neither strictly positive nor negative"); break;
};

你可以在任何其他模式可以使用的位置使用关系模式。因此它们可以出现在位置模式内(例如,如果你想匹配 Y 轴上方 X 轴上的点,你可以写 (0, > 0))。示例 2-94 使用两个关系模式作为合取的组成部分,以表达一个值在特定范围内的要求。

示例 2-94. 在合取中使用关系模式
case >= 168 and <= 189:
    Console.WriteLine("Is within inner 90 percentiles");
    break;

关系模式仅支持与常量的比较。你不能用变量替换前面示例中的数字。

更具体的 when 使用

有时,内置的模式类型不能提供您所需的精度级别。例如,使用位置模式,我们已经看到如何编写匹配任何值对、任何数字对或第一个数字具有特定值的数字对的模式。但是,如果您想匹配第一个数字大于第二个数字的数字对怎么办?这不是一个大的概念性跳跃,但是没有内置支持--关系模式无法做到这一点,因为它们只能与常量进行比较。当然,我们可以用if语句检测条件,但是要重构我们的代码从switch到一系列ifelse语句似乎有点可惜,仅仅为了迈出这一小步。幸运的是,我们不必这样做。

case标签中的任何模式都可以通过添加when子句来限定。它允许包含一个布尔表达式。如果值与模式的主体部分匹配,则将评估此表达式,并且仅当when子句为真时,该值才会作为整体模式匹配。示例 2-95 展示了一个带有when子句的位置模式,该模式匹配第一个数字大于第二个数字的对。

示例 2-95. 带有when子句的模式
case (int w, int h) when w > h:
    Console.WriteLine("Landscape");
    break;

表达式中的模式

所有到目前为止我展示的模式都出现在switch语句的case标签中。这不是使用模式的唯一方式。它们也可以出现在表达式中。要了解这如何有用,请先看看示例 2-96 中的switch语句。这里的意图是返回一个由输入确定的单个值,但有点笨拙:我不得不写四个单独的return语句来表达这一点。

示例 2-96. 模式,但不在表达式中
switch (shape)
{
    case (int w, int h) when w < h: return "Portrait";
    case (int w, int h) when w > h: return "Landscape";
    case (int _, int _): return "Square";
    default: return "Unknown";
}

示例 2-97 展示了执行相同任务的代码,但重写为使用switch 表达式。与switch语句一样,switch表达式包含一系列模式。区别在于,switch语句中的标签后面跟着一系列语句,而在switch表达式中,每个模式后面跟着一个单一表达式。switch表达式的值是与第一个匹配模式相关联的表达式的结果。

示例 2-97. 一个switch表达式
return shape switch
{
    (int w, int h) when w < h => "Portrait",
    (int w, int h) when w > h => "Landscape",
    (int _, int _) => "Square",
    _ => "Unknown"
};

switch 表达式看起来与 switch 语句有很大不同,因为它们不使用 case 关键字。相反,它们直接使用模式,然后在模式和相应表达式之间使用 =>。这样做有几个原因。首先,它使 switch 表达式更加紧凑。通常在其他东西内部使用表达式——在这种情况下,switch 表达式是 return 语句的值,但您也可以在方法参数或任何允许表达式的地方使用它们——因此我们通常希望它们简洁明了。其次,在这里使用 case 可能会导致混淆,因为对于 switch 语句和 switch 表达式,跟随每个 case 的规则是不同的:在 switch 语句中,每个 case 标签后面跟随一个或多个语句,但在 switch 表达式中,每个模式后必须跟随一个单一的表达式。最后,尽管 switch 表达式是在 C# 的 8.0 版本中添加的,但这种构造形式在其他语言中已经存在多年了。C# 的版本更接近于其他语言中的等价物,而不是使用 case 关键字时可能会有所不同。

注意,在 示例 2-97 中,最后的模式是一个丢弃模式。它会匹配任何内容,并且用于确保模式是穷尽的,即覆盖了所有可能的情况。它与 switch 语句中的 default 部分有类似的效果。不同于 switch 语句,其中无匹配是可以接受的,switch 表达式必须产生一个结果,因此如果您的模式不处理输入类型的所有可能情况,编译器会发出警告。如果我们移除了最后一个情况(假设 shape 的类型是 object),在这种情况下编译器会抱怨。反之,如果 shape 的类型是 (int, int),那么我们必须移除最后一个情况,因为前三个情况实际上覆盖了该类型的所有可能值,编译器会提示最后的模式永远不会应用。如果忽略此警告,然后在运行时评估一个 switch 表达式,传入一个无法匹配的值,它将抛出 SwitchExpressionException。异常在 第 8 章 中有描述。

还有一种方法可以在表达式中使用模式,那就是使用 is 关键字。它将任何模式转换为布尔表达式。示例 2-98 展示了一个简单的例子,用于确定一个值是否是包含两个整数的元组。

示例 2-98. 一个 is 表达式
bool isPoint = value is (int, int);

这也提供了一种在继续之前确保值非空的方法。示例 2-99 结合了否定和常量模式测试 null

示例 2-99. 使用 is 进行非空性测试
if (s is not null)
{
    Console.WriteLine(s.Length);
}

你可能会想为什么我们不直接写s != null。在大多数情况下,这样做是有效的,但它存在一个潜在问题:类型可以自定义比较运算符(如!=)的行为。示例 2-99 中的方法的优势在于,即使类型已经自定义了!===的行为,它也会始终执行与null的简单比较。(肯定形式is null也具有相同的优势。)

switch语句或表达式中的模式一样,is表达式中的模式可以从其源中提取值。就像示例 2-98 中的模式一样,示例 2-100 中的模式测试一个值是否是包含两个整数的元组,但继续使用元组中的两个值。

示例 2-100。使用is表达式中的模式的值
if (value is (int x, int y))
{
    Console.WriteLine($"X: {x}, Y: {y}");
}

通过is表达式以这种方式引入的新变量在其包含语句之后仍然在作用域内。因此,在这两个示例中,xy将一直在作用域内,直到包含块的末尾。由于示例 2-100 中的模式在if语句的条件表达式中,这意味着这些变量在主体块之后仍然在作用域内。然而,如果您尝试在主体之外使用它们,您会发现编译器的明确赋值规则会告诉您它们未初始化。它允许示例 2-100,因为它知道if语句的主体只有在模式匹配时才会运行,因此在这种情况下,xy将已经初始化并且可以安全使用。

is表达式中的模式不能包含when子句。这是多余的:结果是一个布尔表达式,因此您可以使用正常的布尔运算符添加任何所需的限定条件,就像示例 2-101 所示。

示例 2-101。is表达式中的模式不需要when
if (value is (int w, int h) && w < h)
{
    Console.WriteLine($"(Portrait) Width: {w}, Height: {h}");
}

摘要

在本章中,我展示了 C#代码的基本要素——变量、语句、表达式、基本数据类型、运算符、流控制和模式。现在是时候看看程序的更广泛结构了。C#程序中的所有代码必须属于一个类型,而类型将是下一章的主题。

¹ C#确实提供了动态类型作为一个选项,使用dynamic关键字,但它采取了一个略微不同寻常的步骤,将其纳入静态类型的观点中:动态变量的静态类型为dynamic

² 有关计算的详细信息,请参阅艾伦·图灵的开创性工作。查尔斯·佩兹尔德的《图灵注释》(约翰·威利和儿子)是相关论文的优秀指南。

³ 如果您对 C 系列语言不熟悉,+=运算符可能会让您感到陌生。它是一个复合赋值运算符,在本章后面进行了描述。我在这里使用它来将errorCount增加一。

⁴ 在没有括号的情况下,C# 有 优先级 规则来确定操作符的求值顺序。有关完整且不是很有趣的细节,请参阅文档。在这个例子中,因为除法比加法具有更高的优先级,所以在没有括号的情况下,表达式将求值为 14。

⁵ 严格来说,这仅对正确对齐的 32 位类型保证。然而,C# 默认正确对齐它们,只有当你的代码需要调用非托管代码时,你才会遇到数据错位的情况。

⁶ 因此,十进制数并没有使用其全部的 128 位。如果使其更小会导致对齐困难,而将额外的位用于提高精度则会对性能产生显著影响,因为长度是 32 位的整数对大多数 CPU 来说更容易处理。

⁷ 升级实际上并不是 C# 的一个特性。有一个更通用的机制:转换操作符。C# 为内置数据类型定义了内置的隐式转换操作符。这里讨论的升级是由编译器根据其通常的转换规则而发生的。

属性 是类型的成员,代表可以读取或修改或两者都可以的值。第三章 详细描述了属性。

⁹ 存在一些特殊的例外情况,比如指针类型。

¹⁰ 语言专家会注意到,在存在自定义的隐式转换到 bool 的情况下,它在某些情况下也会有意义。我们将在 第三章 中讨论自定义转换。

¹¹ continue 语句使事情变得复杂,因为它提供了一种在不完全执行循环体的情况下进入下一次迭代的方式。即使如此,在使用 continue 语句时也可以复制迭代器的效果,只是需要更多的工作。

第三章:类型

C# 不限制我们使用第二章中展示的内置数据类型。你可以定义自己的类型。实际上,你别无选择:如果要编写任何代码,C# 要求该代码必须在一个类型内。我们编写的所有内容,以及我们从 .NET 运行库(或任何其他 .NET 库)中消耗的任何功能,都将属于某一类型。

C# 识别多种类型。我将从最重要的类型开始介绍。

在 C# 中,你处理的大多数类型将是。类可以同时包含代码和数据,可以选择公开一些特性同时保持其他特性仅在类内可访问。因此,类提供了一种封装机制——它们可以为其他人定义一个清晰的公共编程接口,同时保持内部实现细节不可访问。

如果你熟悉面向对象的语言,这一切看起来非常普通。如果不熟悉,你可能需要先阅读一本更入门级的书籍,因为本书不旨在教授编程。我将只描述特定于 C# 类的细节。

我已经在早期章节中展示了类的示例,但让我们更详细地查看结构。示例 3-1 展示了一个简单的类。(参见侧边栏 “命名约定” 以获取有关类型及其成员名称的信息。)

示例 3-1. 一个简单的类
public class Counter
{
    private int _count;

    public int GetNextValue()
    {
        _count += 1;
        return _count;
    }
}

类定义总是包含关键字 class,后跟类的名称。C# 不要求名称与包含文件匹配,也不限制文件中只能有一个类。尽管如此,大多数 C# 项目按约定使类名和文件名匹配。无论如何,类名必须遵循第二章中描述的标识符基本规则,例如,它们不能以数字开头。

示例 3-1 的第一行包含了额外的关键字:public。类定义可以选择指定可访问性,确定其他代码是否可以使用该类。普通类在这里只有两个选择:publicinternal,后者是默认值。(稍后我将展示,你可以将类嵌套在其他类型内,嵌套类的可访问性选项稍宽泛。)内部类仅可在定义它的组件中使用。因此,如果你正在编写一个类库,可以自由定义作为库实现一部分的类:通过将它们标记为 internal,你可以阻止外部世界使用它们。

注意

您可以选择将内部类型对选定的外部组件可见。微软有时会在其库中使用此功能。运行库分布在许多 DLL 中,每个 DLL 定义许多内部类型,但某些内部功能由库中的其他 DLL 使用。通过使用 [assembly: Int⁠ern⁠als​Vis⁠ibl⁠eTo("*name*")] 属性对组件进行注释,指定希望共享的组件名称。(第 14 章 更详细地描述了此内容。)例如,您可能希望使应用程序中的每个类对测试项目可见,以便可以编写针对不打算公开的代码的单元测试。

示例 3-1 中的 Counter 类选择了 public,但这并不意味着它必须使所有内容都可访问。它定义了两个成员——一个名为 _count 的字段,保存一个 int,以及一个名为 GetNextValue 的方法,操作该字段中的信息。(当创建 Counter 时,CLR 将自动将此字段初始化为 0。)如您所见,这两个成员都有可访问性修饰符。正如面向对象编程非常常见的那样,该类选择将数据成员设为私有,并通过方法公开公共功能。

成员的可访问性修饰符对于成员是可选的,就像对于类一样,如果未指定,则默认为最严格的选项:在这种情况下是 private。所以我本可以在 示例 3-1 中省略 private 关键字而不改变其含义,但我更喜欢明确指出。(如果您不指定,阅读您代码的人可能会想知道这是故意还是偶然。)

字段保存数据。它们是一种变量,但不像局部变量那样,其作用域和生命周期由其所在的方法决定,字段与其所在的类型相关联。示例 3-1 能够通过其未限定名称引用 _count 字段,因为字段在其定义的类中是可见的。但是生命周期如何呢?我们知道,每次方法调用都会得到其自己的一组局部变量。一个类的字段有多少组?这取决于如何定义字段,在这种情况下,每个实例一个。示例 3-2 使用 示例 3-1 中的 Counter 类来说明这一点。

示例 3-2. 使用自定义类
var c1 = new Counter();
var c2 = new Counter();
Console.WriteLine("c1: " + c1.GetNextValue());
Console.WriteLine("c1: " + c1.GetNextValue());
Console.WriteLine("c1: " + c1.GetNextValue());

Console.WriteLine("c2: " + c2.GetNextValue());

Console.WriteLine("c1: " + c1.GetNextValue());

使用 new 运算符创建我的类的新实例。由于我使用 new 两次,所以我得到两个 Counter 对象,每个对象都有自己的 _count 字段。因此,正如程序的输出所示,我们得到两个独立的计数:

c1: 1
c1: 2
c1: 3
c2: 1
c1: 4

如您所预期的那样,它开始计数,然后在我们切换到第二个计数器时,新的序列从 1 开始。但当我们回到第一个计数器时,它会从中断处继续。这表明每个实例都有自己的 _count。但如果我们不希望如此呢?有时,您会希望跟踪与任何单个对象无关的信息。

静态成员

static 关键字允许我们声明成员不与类的任何特定实例相关联。示例 3-3 显示了从示例 3-1 修改的 Counter 类的修改版本。我添加了两个新成员,均为静态成员,用于跟踪和报告所有实例的计数。

示例 3-3. 具有静态成员的类
public class Counter
{
    private int _count;
    `private` `static` `int` `_totalCount``;`

    public int GetNextValue()
    {
        _count += 1;
        `_totalCount` `+``=` `1``;`
        return _count;
    }

    `public` `static` `int` `TotalCount` `=``>` `_totalCount``;`
}

TotalCount 只报告计数,但它并不执行任何操作 —— 它只是返回类保持更新的一个值。正如我将在“属性”中解释的那样,这使它成为一个理想的属性候选项,而不是一个方法。静态字段 _totalCount 跟踪调用 GetNextValue 的总次数,与非静态 _count 不同,后者只跟踪当前实例的调用次数。

注意

=> 语法在 TotalCount 属性中让我们使用单个表达式定义属性 —— 在这种情况下,每当代码读取 Counter.TotalCount 属性时,结果将是 _totalCount 字段的值。正如我们将在后面看到的,有更复杂的属性编写方法,但这是简单只读属性的常见方法。

请注意,我可以在 GetNextValue 中以与非静态 _count 相同的方式使用该静态字段。如果我在 示例 3-2 的代码末尾添加所示的代码行到 示例 3-4,则行为差异显而易见。

示例 3-4. 使用静态属性
Console.WriteLine(Counter.TotalCount);

此行显示 5,即两个计数的总和。要访问静态成员,我只需写 *ClassName*.*MemberName*。事实上,示例 3-4 使用了两个静态成员 —— 除了我的类的 TotalCount 属性,还使用了 Console 类的静态 WriteLine 方法。

因为我将 TotalCount 声明为静态属性,所以它包含的代码只能访问其他静态成员。如果它尝试使用非静态 _count 字段或调用非静态 GetNextValue 方法,编译器会抱怨。将 TotalCount 属性中的 _totalCount 替换为 _count 将导致此错误:

error CS0120: An object reference is required for the non-static field, method,
 or property Counter._count'

由于非静态字段与类的特定实例相关联,C#需要知道使用哪个实例。对于非静态方法或属性,将是调用该方法或属性的实例。因此,在示例 3-2 中,我写了c1.GetNextValue()c2.GetNextValue()以选择使用我两个对象中的哪一个。C#将存储在c1c2中的引用作为隐含的隐藏第一个参数传递。您可以通过使用this关键字从类内部的代码获取该引用。示例 3-5 展示了我们可以如何以替代方式编写来自示例 3-3 的GetNextValue方法的第一行,显式指示我们相信_count是在调用GetNextValue方法的实例中的成员。

示例 3-5. this关键字
this._count += 1;

有时由于名称冲突,需要通过this进行显式成员访问。尽管类的所有成员对同一类中的任何代码都在范围内,但方法中的代码与类不共享声明空间。从第二章中记住,声明空间是一个代码区域,在其中单个名称不能引用两个不同的实体,因为方法不与包含类共享它们的空间,您可以声明与类成员名称相同的局部变量和方法参数。如果不使用下划线前缀等约定,这很容易发生。在这种情况下,您不会收到错误提示——局部变量和参数只是隐藏类成员。但您仍然可以通过使用this限定访问来访问类成员。

静态方法不能使用this关键字,因为它们不与任何特定实例相关联。

静态类

有些类仅提供静态成员。在Sys⁠tem.​Thr⁠ead⁠ing命名空间中有几个示例,其中包含提供多线程工具的各种类。例如,Interlocked类提供原子、无锁、读取-修改-写入操作;LazyInitializer类提供在保证避免多线程环境中的双重初始化的情况下执行延迟初始化的辅助方法。这些类仅通过静态方法提供服务。创建这些类型的实例没有意义,因为它们无法保存有用的每个实例信息。

通过在class关键字前加上static关键字,您可以声明您的类意在以此方式使用。这种编译方式会阻止对其进行实例化。任何试图构造此类实例的人显然不理解其功能,因此编译器错误将是对文档的有用提示。

您可以声明希望能够在某些类上调用静态方法,而无需每次都命名类。如果您正在编写大量使用特定类型提供的静态方法的代码,则这可能会很有用。(顺便说一句,这不仅限于静态类。您可以将此技术用于具有静态成员的任何类,但对其成员全为静态的类最有用。)示例 3-6 使用了Math类(位于System命名空间)的静态方法(Sin)和静态属性(PI)。它还使用了Console类的静态WriteLine方法。(我在这个例子和下一个例子中展示了整个源文件,因为using指令特别重要。第一个例子不需要using System;,因为默认的隐式全局using使其在任何地方都可用。)

示例 3-6. 正常使用静态成员
public static class Normal
{
    public static void UseStatics()
    {
        Console.WriteLine(Math.Sin(Math.PI / 4));
    }
}

示例 3-7 完全等效,但调用三个静态成员的行没有使用其定义类的名称限定。

Example 3-7. 使用静态成员而不进行显式限定
using static System.Console;
using static System.Math;

public static class WithoutQualification
{
    public static void UseStatics()
    {
        WriteLine(Sin(PI / 4));
    }
}

要使用这种更简洁的替代方法,必须使用using static指令声明要以这种方式使用的类。虽然using指令通常指定一个命名空间,使该命名空间中的类型无需限定即可使用,但using static指令指定一个类,使其静态成员无需限定即可使用。顺便说一句,正如您在第一章中看到的,C# 10.0 允许您在using指令中添加global关键字。这对using static指令也有效,因此,如果您希望Math类型的静态成员在项目中的任何文件中无需限定即可使用,只需在一个文件中编写global using static System.Math;,它将适用于所有文件。

记录

尽管封装是软件开发中管理复杂性的强大工具,有时仅使用保存信息的类型可能也很有用。例如,我们可能希望表示通过网络发送的消息,或者数据库中表中的一行。专为此设计的类型有时被称为POD 类型,其中 POD 代表简单旧数据。我们可以尝试通过编写一个只包含公共字段的类来实现这一点,正如示例 3-8 所示。

示例 3-8. 简单旧数据,使用公共字段
public class Person
{
    public string? Name;
    public string? FavoriteColor;
}

这里可能会有些开发人员对缺乏封装性感到恐惧。没有阻止任何人直接访问Person实例并改变字段的方法——哦,天哪!在做任何不仅仅是保存一些数据的类型时,这确实可能会引发问题。类型的方法可能包含依赖于这些字段以特定方式使用的代码,而将字段公开的问题在于任何东西都可能改变它们,使得难以知道它们将处于什么状态。但是这种类型没有代码——它的唯一工作就是保存一些数据,所以这并不会是世界末日。话虽如此,这个例子确实带来了一个问题:这些字段包含字符串,但我必须在类型名称后面加上一个?。这表示这些字段可能包含特殊值null。如果我不添加这些?修饰符,编译器会警告我,告诉我没有做任何事情来确保这些字段被适当初始化,因此我不应该声称它们肯定包含字符串。如果我想要确保这些字段始终具有非空值,我需要控制类型如何初始化,这可以通过编写构造函数来实现。稍后在本章节中我会更详细地描述这些内容,但是示例 3-9 展示了一个简单的示例,确保这些字段被初始化,使我们能够移除?修饰符。

示例 3-9. 通过构造函数强制初始化字段
public class Person
{
    public string Name;
    public string FavoriteColor;

    public Person(string name, string favoriteColor)
    {
        this.Name = name;
        this.FavoriteColor = favoriteColor;
    }
}

看起来现在有些啰嗦了。记录类型提供了编写普通数据类型的更简单方式,就像示例 3-10 所示。

示例 3-10. 一种具有位置语法的记录类型
public record Person(string Name, string FavoriteColor);

示例 3-11 展示了我们如何使用这种记录类型。如果我们有一个变量指向一个Person,比如ShowPerson方法中的p参数,我们可以写p.Namep.FavoriteColor来访问它包含的数据,就像在示例 3-8 或 3-9 中定义Person一样。(我的记录类型并非完全等价。之前的示例都定义了公共字段,但是示例 3-11 更符合正常的 .NET 实践,因为它将NameFavoriteColor定义为属性。我稍后在本章节将更详细地描述属性。)如您所见,我们使用new关键字创建记录类型的实例,就像创建类一样。当记录类型定义方式如示例 3-10 所示时,我们必须按正确的顺序将所有属性传递给构造函数。这种定义记录的方式称为位置语法

示例 3-11. 使用记录类型
void ShowPerson(Person p)
{
    Console.WriteLine($"{p.Name}'s favorite color is {p.FavoriteColor}");
}

var ian = new Person("Ian", "Blue");
var deborah = new Person("Deborah", "Green");
ShowPerson(ian);
ShowPerson(deborah);

当您使用示例 3-10 中的语法时,生成的记录类型是不可变的:如果您编写了试图修改现有Person的任一属性的代码,编译器将报告错误。不可变数据类型可以使代码分析变得更加容易,尤其是多线程代码,因为您可以确保它们在您脚下不会改变。这也是 .NET 中字符串不可变的原因之一。然而,在引入记录类型之前,在 C# 中使用不可变自定义类型通常是不方便的。例如,如果您需要生成某个新值,它是现有值的修改版本,那么您可能需要进行大量乏味的工作。而内置的string类型提供了许多方法来生成由现有字符串构建的新字符串(例如子字符串,或转换为小写或大写),而在编写类时,您就需要自己实现这些方法。

例如,假设您正在编写一个应用程序,其中定义了一种数据类型,表示某人在特定时间点的支付账户状态。如果将其定义为不可变类型,则在处理新交易时,您需要创建一个除当前余额外完全相同的副本。在 C# 中,以往的做法意味着在创建新实例时需要编写代码来复制任何未更改的数据。记录类型的主要目的之一是更容易地定义和使用不可变数据类型,因此它们提供了一种简便的方法来创建具有某些属性修改的现有实例的副本,正如示例 3-12 所示,您可以在记录表达式之后写上with,然后是一对大括号括起来的您想要更改的属性列表。

示例 3-12. 创建一个不可变记录的修改副本
var startingRecord = new Person("Ian", "Blue");
var modifiedCopy = startingRecord with
{
    FavoriteColor = "Green"
};

在这种特定情况下,我们的类型只有两个属性,所以与仅写new Person(startingRecord.Name, "Green")相比,并没有显著的改进。然而,对于具有更多属性的记录而言,这种语法比每次重新构建整个对象要方便得多。

虽然记录类型使得创建和使用不可变数据类型变得更加容易,但它们并不一定非要是不可变的。如示例 3-13 所示,Person记录允许在构建后修改属性。({ get; set; }语法表示这些是自动实现的属性。我稍后会更详细地描述它们,但它们基本上只是简单的读/写属性。)

示例 3-13. 具有可修改属性的记录类型
public record Person
{
    public Person(string name, string favoriteColor)
    {
        this.Name = name;
        this.FavoriteColor = favoriteColor;
    }

    public string Name { get; set; }
    public string FavoriteColor { get; set; }
}

此时,我们几乎回到了示例 3-9 中的情况,唯一的区别是NameFavoriteColor现在是属性而不是字段。在这个示例中,我们只需将record关键字替换为class,代码仍然可以编译。那么,当我们将其定义为record时究竟发生了什么呢?

尽管记录的主要目的是简化构建不可变数据类型,record关键字还增加了一些有用的特性。除了用于构建修改副本的with语法外,记录还获得了内置的相等性测试支持和一个ToString实现,该实现报告所有属性值。相等性测试使您能够使用==运算符比较两个记录,只要它们的所有属性值相同,它们就被视为相等。通过Equals方法也可以实现相同的功能。所有类型都提供一个Equals方法(稍后我会更详细地描述),记录安排了这个方法来提供基于值的比较。您可能想知道为什么记录类型在这方面是特殊的——Equals对所有类型都起作用吗?并非如此。看看示例 3-14。

示例 3-14. 比较两个类型的实例
var p1 = new Person("Ian", "Blue");
var p2 = new Person("Ian", "Blue");
if (p1 == p2)
{
    Console.WriteLine("Equal");
}

如果您对早期示例中定义的任何Person类型之一运行此代码,作为record类型,它将显示文本Equal。但是,如果您使用示例 3-9 中Person的定义(它定义为class),则不会显示该消息。即使所有属性都具有相同的值,Equals也会报告它们在这种情况下不相等。这是因为类的默认比较行为是基于标识的:只有当两个变量引用完全相同的对象时它们才相等。当变量引用两个不同的对象时,即使这些对象具有完全相同的类型和所有相同的属性和字段值,它们仍然是不同的,Equals反映了这一点。您可以在编写类时更改此行为,但必须编写自己的Equals方法。对于record,编译器会为您生成这个方法。

记录还提供的另一种行为是专门的ToString实现。.NET 中的所有类型都提供ToString方法,您可以直接调用它,也可以通过某些隐式调用它的机制,比如字符串插值。对于不提供自己的ToString的类型,默认实现只是返回类型名称,因此如果您在示例 3-9 中定义的类上调用ToString,它将始终返回"Person",无论成员的值是什么。类型可以提供自己的ToString,编译器会为任何记录类型为您执行此操作。因此,如果您在示例 3-14 中创建的任何一个Person实例上调用ToString,它将返回"Person { Name = Ian, FavoriteColor = Blue }"

您可以定义具有其类型也是记录类型的属性的记录。示例 3-15 定义了一个Person记录类型,还定义了一个Relation记录类型,用于指示两个人之间的某种关系。

示例 3-15. 嵌套记录类型
public record Person(string Name, string FavoriteColor);
public record Relation(Person Subject, Person Other, string RelationshipType);

当你有这种复合结构——记录内部的记录——EqualsToString 都会遍历到嵌套记录中。示例 3-16 演示了这一点。

示例 3-16. 使用嵌套记录类型
var ian = new Person("Ian", "Blue");
var gina = new Person("Gina", "Green");
var ian2 = new Person("Ian", "Blue");
var gina2 = new Person("Gina", "Green");
var r1 = new Relation(ian, gina, "Sister");
var r2 = new Relation(gina, ian, "Brother");
var r3 = new Relation(ian2, gina2, "Sister");

Console.WriteLine(r1);
Console.WriteLine(r2);
Console.WriteLine(r3);
Console.WriteLine(r1 == r2);
Console.WriteLine(r1 == r3);
Console.WriteLine(r2 == r3);

运行此示例将产生以下输出(为了适应页面而将行拆分):

Relation { Subject = Person { Name = Ian, FavoriteColor = Blue },
 Other = Person { Name = Gina, FavoriteColor = Green },
 RelationshipType = Sister }
Relation { Subject = Person { Name = Gina, FavoriteColor = Green },
 Other = Person { Name = Ian, FavoriteColor = Blue },
 RelationshipType = Brother }
Relation { Subject = Person { Name = Ian, FavoriteColor = Blue },
 Other = Person { Name = Gina, FavoriteColor = Green },
 RelationshipType = Sister }
False
True
False

如你所见,Relation 类型的 ToString 方法展示了每个嵌套 Person 记录的所有属性(以及 RelationshipType 属性,它只是一个普通的 string)。同样,比较逻辑也适用于嵌套记录。 这里没有什么特别的情况——记录类型逐个属性进行比较,通过对比每个属性的值调用该属性的 Equals 方法,传递与其比较的记录相应的属性。因此,当它碰巧到达记录类型属性时,它会像对待任何其他属性一样调用其 Equals 方法,在这一点上,该记录类型自己的 Equals 实现将执行,逐个比较每个嵌套属性。

我描述的 record 关键字的特性,你都可以手动实现。编写相应的 ToStringEquals 的等效实现虽然很繁琐,但并不复杂。 (编译器还提供了 ==!= 操作符的实现以及名为 GetHashCodeDeconstruct 的方法,稍后我将描述这些方法。但你也可以手动编写所有这些内容。)就 .NET 运行时而言,记录类型没有什么特别之处——它只是将它们视为普通类。

记录类型是一种语言级别的特性。 C# 编译器以这种方式生成这些类型,以便它可以识别外部库中声明为记录的类型,¹ 但它们本质上只是编译器为其生成了一些额外成员的类。 实际上,你可以通过将类型声明为 record class 而不仅仅是 record 来明确此点——这两种语法是等效的。

引用和空值

使用 class 关键字定义的任何类型都将是引用类型(同样适用于声明为 record 或等效的 record class)。任何引用类型的变量都不会包含构成类型实例的数据;相反,它可以包含对类型实例的引用。因此,赋值操作不会复制对象,只会复制引用。示例 3-17 几乎包含与 示例 3-2 相同的代码,但是不是使用 new 关键字初始化 c2 变量,而是用 c1 的副本初始化它。

示例 3-17. 复制引用
Counter c1 = new Counter();
`var` `c2` `=` `c1``;`
Console.WriteLine("c1: " + c1.GetNextValue());
Console.WriteLine("c1: " + c1.GetNextValue());
Console.WriteLine("c1: " + c1.GetNextValue());

Console.WriteLine("c2: " + c2.GetNextValue());

Console.WriteLine("c1: " + c1.GetNextValue());

因为此示例仅使用了一次 new,所以只有一个 Counter 实例,并且两个变量都引用同一个实例。因此,我们得到不同的输出:

c1: 1
c1: 2
c1: 3
c2: 4
c1: 5

不仅是局部变量会这样—如果您为任何其他类型的变量使用引用类型,例如字段或属性,赋值方式也是相同的,复制的是引用而不是整个对象。这是引用类型的定义特征,与我们在 第二章 中看到的内置数值类型的行为不同。对于内置数值类型,每个变量包含一个值而不是值的引用,因此赋值必然涉及到复制该值。(这种值复制行为对于大多数引用类型是不可用的—请参见下一个侧边栏,“复制实例”。)

我们可以编写代码来检测两个引用是否指向同一个东西。 示例 3-18 安排了三个变量引用两个具有相同计数的计数器,然后比较它们的标识。默认情况下,当其操作数为引用类型时,== 运算符确实执行这种对象标识比较。然而,类型允许重新定义 == 运算符。 string 类型更改 == 以执行值比较,因此如果将两个不同的字符串对象作为 == 的操作数传递,如果它们包含相同的文本,则结果将为真。如果要强制进行对象标识比较,可以使用静态的 object.ReferenceEquals 方法。

示例 3-18. 比较引用
var c1 = new Counter();
c1.GetNextValue();
Counter c2 = c1;
var c3 = new Counter();
c3.GetNextValue();

Console.WriteLine(c1.Count);
Console.WriteLine(c2.Count);
Console.WriteLine(c3.Count);
Console.WriteLine(c1 == c2);
Console.WriteLine(c1 == c3);
Console.WriteLine(c2 == c3);
Console.WriteLine(object.ReferenceEquals(c1, c2));
Console.WriteLine(object.ReferenceEquals(c1, c3));
Console.WriteLine(object.ReferenceEquals(c2, c3));

输出的前三行证实了所有三个变量都引用具有相同计数的计数器:

1
1
1
True
False
False
True
False
False

它还说明了,尽管它们都有相同的计数,只有 c1c2 被认为是同一件事。这是因为我们将 c1 赋值给了 c2,这意味着 c1c2 都将引用同一个对象,这就是为什么第一个比较成功的原因。但是 c3 引用的是完全不同的对象(即使它恰好具有相同的值),这就是第二个比较失败的原因。(我在这里同时使用了 ==object.ReferenceEquals 比较来说明它们在这种情况下是一样的,因为 Counter 没有定义 == 的自定义含义。)

我们可以尝试用 int 而不是 Counter 做同样的事情,正如 示例 3-19 所示。(这样做是为了尽可能地模仿 示例 3-18 的初始化变量方式。)

示例 3-19. 比较值
int c1 = new int();
c1++;
int c2 = c1;
int c3 = new int();
c3++;

Console.WriteLine(c1);
Console.WriteLine(c2);
Console.WriteLine(c3);
Console.WriteLine(c1 == c2);
Console.WriteLine(c1 == c3);
Console.WriteLine(c2 == c3);
Console.WriteLine(object.ReferenceEquals(c1, c2));
Console.WriteLine(object.ReferenceEquals(c1, c3));
Console.WriteLine(object.ReferenceEquals(c2, c3));
Console.WriteLine(object.ReferenceEquals(c1, c1));

与之前一样,我们可以看到所有三个变量具有相同的值:

1
1
1
True
True
True
False
False
False
False

这也说明了int类型为==定义了一种特殊含义。对于int,此运算符比较值,因此这三个比较都成功了。但对于值类型,object.ReferenceEquals永远不会对值类型成功—事实上,在这里我添加了一个额外的第四个比较,将c1与自身进行比较,甚至那个也失败了!这个令人惊讶的结果是因为使用int进行引用比较是没有意义的—它不是引用类型。编译器必须对示例 3-19 中的最后四行执行从intobject的隐式转换:它对object.ReferenceEquals的每个参数进行了称为装箱的操作,我们将在第七章中详细讨论这一点。每个参数都获得了一个独立的装箱,这就是为什么即使最后的比较也失败的原因。

引用类型和int等类型之间还有另一个区别。默认情况下,任何引用类型变量都可以包含特殊值null,这意味着变量根本不引用任何对象。您无法将此值分配给任何内置数值类型(尽管请参见下一侧边栏,“Nullable<T>”)。

用非可空引用消除空值

程序设计语言中广泛存在空引用的可能性可以追溯到 1965 年,当时计算机科学家 Tony Hoare 将其添加到具有高度影响力的 ALGOL 语言中。他后来为此发明道歉,称这是他的“十亿美元错误”。引用类型变量可能包含null的可能性使得难以确定是否安全地尝试使用该变量执行操作。(在 C# 程序中,如果尝试这样做,将抛出NullReferenceException,通常会导致程序崩溃。第八章讨论了异常。)一些现代编程语言通过类型系统中的显式选择性机制,避免了默认允许引用为空的做法,而是提供某种可选值系统。事实上,就像你在Nullable<T>中看到的那样,对于内置数值类型(以及我们将看到的任何自定义值类型),这已经是事实,但直到最近,对于所有引用类型变量来说,空性并不是可选的。

C# 8.0 引入了一个重要的新功能,可以扩展语言的类型系统,以区分可能为空的引用和不得为空的引用。在 C# 10.0 之前,默认情况下此功能是禁用的,但现在创建新项目时将启用此功能。这个功能被称为可空引用,这个名字似乎有些奇怪,因为自 C# 1.0 起引用就能包含null。然而,这个名字指的是启用此功能后,空引用成为一种选择性功能:一个引用不会包含null,除非明确定义为可空引用。至少,这是理论上的说法。

警告

让类型系统能够区分可空和非可空引用始终是一件棘手的事情,特别是在语言已有近二十年历史的情况下。因此,事实上 C# 不能始终保证非可空引用永远不会包含 null。然而,如果满足某些约束条件,它可以做出这种保证,而且更一般地说,即使在无法绝对排除这种情况的情况下,它也将显著减少遇到 NullReferenceException 的机会。

启用非空性是一项重大变革,这也是为什么直到最近,该功能默认情况下是关闭的,直到您明确启用它为止。(即使现在,使用 C# 10.0,新创建的 .csproj 文件包含打开此功能的设置。没有该设置,该功能仍然默认关闭。)打开它可能会对现有代码产生显著影响,因此可以在更细粒度的级别控制此功能,以在旧世界和新的可空引用感知世界之间实现渐进过渡。

C# 提供了两个控制维度,它称之为可空注解上下文可空警告上下文。C# 程序中的每行代码都与这两种上下文中的一种相关联。默认情况下,所有代码都处于禁用的可空注解上下文和禁用的可空警告上下文中。您可以在项目级别更改这些默认设置(新创建的 C# 10.0 项目将会这样做)。您还可以使用 #nullable 指令在更精细的级别更改任一可空注解上下文,如果需要的话,甚至可以每行代码都不同。那么这两个上下文是如何工作的呢?

可空注解上下文确定我们是否可以声明使用引用类型的特定变量的可空性。(我在这里使用 C# 更广泛的变量定义,不仅包括局部变量,还包括字段、参数和属性。)在禁用的注解上下文中(默认情况下),我们无法表达这一点,所有引用都被隐式地视为可空。官方分类将这些描述为对空性无视,将其与您故意注释为可空的引用区分开来。然而,在启用的注解上下文中,我们可以选择。示例 3-20 展示了如何实现这一点。

示例 3-20. 指定可空性
string cannotBeNull = "Text";
string? mayBeNull = null;

这与内置数值类型和自定义值类型的空值语法相呼应。如果您只写类型名称,则表示非可空。如果您希望其可空,则附加一个 ?

这里最重要的一点是,在启用的可空注解上下文中,旧语法将获得新行为,如果您需要旧行为,则需要使用新语法。这意味着,如果您将原本没有任何空值感知的现有代码放入启用的注解上下文中,所有 引用类型变量现在实际上被标注为非可空,这与编译器在之前对完全相同代码的处理方式相反。

将代码放入启用的可空注解上下文的最直接方式是使用 #nullable enable annotations 指令。您可以将其放在源文件的顶部以启用整个文件,或者可以更局部地使用它,然后跟随 #nullable restore annotations 来恢复项目范围的默认设置。单独使用此指令不会产生任何可见变化。如果禁用了可空警告上下文(默认情况下是禁用的),编译器不会对这些注解进行处理。您可以使用 #nullable enable warnings 在本地启用它(#nullable restore warnings 可以恢复为项目范围的默认设置)。您可以通过在 .csproj 文件中添加 <Nullable> 属性来控制项目范围的默认设置。示例 3-21 将默认设置为启用的可空警告上下文和启用的可空注解上下文。您将在任何新创建的 C# 10.0 项目中找到此类设置(无论是从 Visual Studio 创建还是使用命令行的 dotnet new)。

示例 示例 3-21. 指定作为项目范围默认启用的可空警告和注解上下文
<PropertyGroup>
  <Nullable>enable</Nullable>
</PropertyGroup>

这意味着所有代码将位于启用的可空警告上下文和启用的可空注解上下文中,除非明确选择退出。其他项目范围的设置包括 disable(与不设置 <Nullable> 具有相同效果)、warnings(启用警告但不启用注解)和 annotations(启用注解但不启用警告)。

如果您在项目级别指定了启用的注解上下文,可以使用 #nullable disable annotations 来在单个文件中退出。同样,如果您在项目级别指定了启用的警告上下文,可以使用 #nullable disable warnings 来退出。

我们拥有这些精细的控制功能,使得在现有代码中启用非空性变得更加容易。如果你一步到位地为整个项目完全启用了这一功能,可能会遇到大量警告。实际操作中,更有意义的做法可能是将项目中所有代码放在启用警告的上下文中,但不要一开始就启用任何注解。因为你的所有引用都将对空值检查视而不见,你所看到的唯一警告将与库的使用有关。在这个阶段,任何警告很可能都表明存在潜在问题,例如,缺少空值的测试。一旦解决了这些问题,你可以开始逐个文件(或者更小的块,如果你喜欢的话)将自己的代码移到启用注解的上下文中,并进行必要的更改。

随着时间的推移,目标是使所有代码达到可以在项目级别完全启用非空支持的程度。对于新创建的项目,最好从一开始就启用可空引用,以防止问题的空值处理进入你的代码——这就是为什么新项目默认启用此功能的原因。

在我们完全启用了非空性支持的代码中,编译器对我们做了什么?我们主要得到了两个东西。首先,编译器使用类似于确定赋值规则的规则,以确保我们在尝试解引用变量之前先检查它是否为空。示例 3-22 展示了一些编译器将接受的情况,以及在启用了可空性警告上下文的情况下会导致警告的情况,假设 mayBeNull 在启用了可空性注解上下文中声明为可空。

示例 3-22. 解引用可空引用
if (mayBeNull is not null)
{
    // Allowed because we can only get here if mayBeNull is not null
    Console.WriteLine(mayBeNull.Length);
}

// Allowed because it checks for null and handles it
Console.WriteLine(mayBeNull?.Length ?? 0);

// The compiler will warn about this in an enabled nullable warning context
Console.WriteLine(mayBeNull.Length);

其次,除了检查解引用(使用 . 访问成员)是否安全外,编译器还会在你尝试将可能为空的引用分配给需要非空引用的位置时发出警告,或者当你将一个可能为 null 的引用作为参数传递给方法时,该方法的相应参数声明为非空时也会发出警告。

有时,在将所有代码移入完全启用的空安全上下文的道路上,您会遇到障碍。也许您依赖于一些组件,这些组件在可预见的将来不太可能被升级为带有空安全注解,或者可能存在一些情况,C# 的保守安全规则错误地认定某些代码不安全。在这些情况下,您可以怎么做?您不希望为整个项目禁用警告,并且在代码中留下#nullable指令将会很烦人。虽然您可以通过添加对null的显式检查来防止警告,但在您确信这些检查是不必要的情况下,这种方式并不理想。还有一种替代方案:您可以告诉 C# 编译器您知道一些它不知道的事情。如果您有一个引用,编译器假定它可能为 null,但您有充分理由相信它永远不会为 null,您可以通过使用空值宽容运算符来告知编译器,您可以在示例 3-23 的第二行末尾看到它。它有时被非正式地称为该死的运算符,因为它看起来像是一种略带恼怒的断言。

示例 3-23. 空值宽容运算符
string? referenceFromLegacyComponent = legacy.GetReferenceWeKnowWontBeNull();
string nonNullableReferenceFromLegacyComponent = referenceFromLegacyComponent!;

在任何启用了可空注解的上下文中,您可以使用空值宽容运算符。它的作用是将可空引用转换为非可空引用。然后,您可以继续对该非可空引用进行解引用或在不允许可空引用的地方使用它,而不会引发任何编译器警告。

警告

空值宽容运算符不会检查其输入。如果您在运行时应用于值为 null 的情况,它将不会检测到这一点。相反,在您尝试使用引用的位置时,将会在运行时出现错误。

虽然空值宽容运算符在可空感知代码与您无法控制的旧代码之间的边界处很有用,但还有另一种方法可以告诉编译器,明显可为空的表达式实际上不会为 null:可空属性。 .NET 定义了几个属性,您可以使用它们来注释代码,描述它们何时会或不会返回 null 值。考虑示例 3-24 中的代码。如果您没有启用可空引用类型功能,这将运行正常,但如果您启用它们,您将收到警告。(这使用了一个字典,这是一种在第五章中详细描述的集合类型。)

示例 3-24. 空安全性与尝试模式——在可空引用类型之前
public static string Get(IDictionary<int, string> d)
{
    if (d.TryGetValue(42, out string s))
    {
        return s;
    }

    return "Not found";
}

当完全启用了可空性时,编译器会抱怨 out string s。它会正确地告诉你,TryGetValue 可能通过 out 参数传递 null。 (这种参数稍后会讨论;它提供了一种在主返回值之外返回额外值的方式。)此函数检查字典是否包含指定键的条目。如果有,它将返回 true 并将相关值放入 out 参数中,但如果没有,则返回 false 并将 out 参数设置为 null。我们可以修改我们的代码以反映这一事实,方法是在 out string 后面加一个 ?。示例 3-25 显示了这个修改。

示例 3-25. 使用 Try 模式时注意可空性
public static string Get(IDictionary<int, string> d)
{
    if (d.TryGetValue(42, out string? s))
    {
        return s;
    }

    return "Not found";
}

你可能会期待这会带来一个新问题。我们的 Get 方法返回一个 string,而不是 string?,那么 return s 怎么可能正确呢?我们刚刚修改了代码以指示 s 可能为 null,那么当我们试图从声明不返回 null 的方法中返回这个可能为 null 的值时,编译器难道不会抱怨吗?但事实上,这个是可以编译通过的。编译器接受这个是因为它知道 TryGetValue 只会在返回 false 时将 out 参数设置为 null。这意味着编译器知道虽然 s 变量的类型是 string?,但在 if 语句体内它不会是 null。它知道这一点是因为在 TryGetValue 方法的定义中应用了一个可空属性。(属性在第十四章中描述。)示例 3-26 显示了该方法声明中的属性。(这个方法是泛型类型的一部分,这就是为什么我们看到这里的是 TKeyTValue 而不是我例子中使用的 intstring 类型。第四章详细讨论了这种方法。在手头的例子中,TKeyTValue 实际上是 intstring。)

示例 3-26. 可空属性示例
public bool TryGetValue(TKey key, [MaybeNullWhen(false)] out TValue value)

这个注解告诉 C# 如果 TryGetValue 返回 false,值可能是 null,但如果返回 true,则不会是 null。如果没有这个属性,即使启用了可空警告,示例 3-24 也会成功编译,因为我使用 IDictionary<int, string> 而不是 IDictionary<int, string?> 表示我的字典不允许空值。因此,通常情况下,C# 会假设当方法从字典返回值时,它也会产生一个 string。但是 TryGetValue 有时没有值可返回,这就是为什么它需要这个注解。表 3-1 描述了可以应用的各种属性,以便向 C# 编译器提供有关可能是 null 或不是 null 的更多信息。

表 3-1. 可空属性

类型 用法
AllowNull 即使类型不可空,代码也允许提供 null
DisallowNull 即使类型可为空,代码也不能提供 null
MaybeNull 代码应准备好即使类型为非可空也可能返回 null 值。
MaybeNullWhen 仅与 outref 参数一起使用;如果方法返回指定的 bool 值,则输出可能为 null
NotNull 用于参数。如果方法返回而没有错误,则参数不是null。(对于带有 outref 参数,这通常意味着方法确保设置它们;对于仅入站参数,这意味着方法检查值并且仅在其不是 null 时返回而没有错误。)
NotNullWhen 仅与 outref 参数一起使用;如果方法返回指定的 bool 值,则输出可能不为 null
NotNullIfNotNull 如果将非 null 值作为此属性命名的参数的参数传递,则此属性目标返回的值不会为 null

这些属性已经适用于大多数 .NET 运行时库,以减少采用可空引用时涉及的摩擦。

将代码移到已启用的可空警告和注释上下文中可以显著提高代码质量。许多将现有代码库迁移的开发人员通常在此过程中发现一些潜在的错误,这要归功于编译器执行的额外检查。然而,它并非完美。需要注意的是有两个盲点,因为从一开始就没有在类型系统中包含可空性。首先是遗留代码引入的问题,即使您的所有代码都在启用了可空注释上下文中,如果使用的 API 不是,则从中获取的引用将对可空性视而不见。如果需要使用空值宽容操作符来使编译器满意,那么总有可能出错,到那时您将会在一个应该是非空变量的地方得到一个 null。第二个问题更为恼人,即使您从一开始就完全启用了此功能,某些 .NET 中的存储位置在初始化时会填充零值。如果这些位置是引用类型,则最终将以 null 值开始,目前 C# 编译器无法强制执行其非空性。数组存在此问题。参见 例子 3-27。

例 3-27. 数组和可空性
var nullableStrings = new string?[10];
var nonNullableStrings = new string[10];

这段代码声明了两个字符串数组。第一个使用了string?,因此允许可空引用。第二个则不允许。然而,在.NET 中,您必须先创建数组,然后才能放置任何内容,并且新创建的数组的内存始终是零初始化的。这意味着我们的nonNullableStrings数组在初始时会被填满null。由于.NET 中数组的工作方式,无法避免这一点。减轻这个问题的一种方法是避免直接使用数组。如果使用List<string>代替(参见第五章),它将仅包含您添加的项 —— 不像数组,List<T>不提供一种初始化它的空槽的方法。但是,并不总是可以用List<T>替代数组。有时您只需确保初始化数组的所有元素。

在值类型的字段中存在类似问题,在下一节中进行了描述。如果它们具有引用类型的字段,则存在无法防止其初始化为null的情况。因此,可空引用功能并不完美。尽管如此,它仍然非常有用。已经对现有项目进行了必要的更改以使用它的团队报告称,这个过程往往会发现许多以前未发现的错误。这是提高代码质量的重要工具。

尽管非空引用减少了引用类型和内置数值类型之间的某些区别,但仍然存在重要差异。int类型的变量不是指向int的引用。它包含int的值 —— 没有间接引用。在某些语言中,引用类型和值类型的行为选择取决于类型的使用方式,但在 C#中,这是类型的固定特性。任何特定类型要么是引用类型,要么是值类型。所有内置数值类型都是值类型,如bool,而class始终是引用类型。但这并不是内置类型和自定义类型之间的区别。您可以编写自定义值类型。

结构体

有时,对于自定义类型来说,使其具有与内置值类型相同的行为是合适的。最明显的例子是自定义数值类型。虽然 CLR 提供各种内置数值类型,但某些类型的计算需要比它们提供的更多结构。例如,许多科学和工程计算使用复数。运行时不定义这些的内在表示,但运行时库通过Complex类型支持它们。如果像这样的数值类型表现与内置类型显著不同,这将是没有帮助的。幸运的是,它并没有,因为它是一个值类型。编写自定义值类型的方法是使用struct关键字,而不是class

一个结构体可以拥有与类几乎相同的特性;它可以包含方法、字段、属性、构造函数和类支持的任何其他成员类型(在“Members”中描述),我们可以使用相同的访问权限关键字,如publicinternal。有一些限制,但是对于我之前编写的简单Counter类型,我可能只需将class关键字替换为struct。然而,这不会是一个有用的转换。记住,引用类型(类)和值类型之间的主要区别之一是前者具有身份:我可能需要创建多个Counter对象,以便可以计算不同类型的事物。但是对于值类型(内置的或自定义的结构体),假设它们可以自由复制。如果我有一个int类型的实例(例如 4),并且将其存储在几个字段中,没有期望这个值具有自己的生命周期:数字 4 的一个实例与另一个实例无法区分。持有值的变量有它们自己的身份和生命周期,但它们持有的值没有。这与引用类型的工作方式不同:不仅引用它们的变量具有身份和生命周期,它们引用的对象也有自己的独立身份和生命周期,与任何特定变量无关。

如果我将int值 4 加 1,结果将是一个完全不同的int值。如果我在Counter上调用GetNextValue(),它的计数会增加 1,但它仍然是同一个Counter实例。因此,虽然在示例 3-3 中用struct替换class会编译通过,但我们真的不希望我们的Counter类型变成一个结构体。示例 3-28 展示了一个更好的候选项。

示例 3-28. 一个简单的结构体
public struct Point
{
    private double _x;
    private double _y;
    public Point(double x, double y)
    {
        _x = x;
        _y = y;
    }

    public double X => _x;
    public double Y => _y;
}

这代表二维空间中的一个点。虽然可以想象希望能够表示具有自己标识的特定点的能力(在这种情况下,我们会想要一个class),但希望有一个类似值的类型来表示点的位置是完全合理的。

虽然示例 3-28 仅限于目前的内容,但常见的值支持比较。如前所述,C#为引用类型定义了==运算符的默认含义:它等同于object.ReferenceEquals,用于比较标识。这对值类型没有意义,因此 C#不会自动支持==用于struct。你不一定需要提供定义,但内置的值类型都这样做了,所以如果我们试图创建一个具有类似特性的类型,我们应该这样做。如果你单独添加了==运算符,编译器会提醒你必须定义一个匹配的!=运算符。你可能会认为 C#会将!=定义为==的反义,因为它们似乎意味着相反的含义。然而,某些类型对于某些操作数对将同时返回false,因此 C#要求我们独立定义两者。正如示例 3-29 所示,要定义运算符的自定义含义,我们使用operator关键字,后跟我们想要自定义的运算符。本例为我们简单类型定义了==!=的行为,非常直接。(由于本例中的所有新方法都只返回单个表达式的值,我使用了=>语法来实现它们,就像在之前的示例中用于各种属性一样。)

示例 3-29. 支持自定义比较
public struct Point : IEquatable<Point>
{
    private double _x;
    private double _y;
    public Point(double x, double y)
    {
        _x = x;
        _y = y;
    }

    public double X => _x;
    public double Y => _y;

    public override bool Equals(object? o) => o is Point p && this.Equals(p);
    public bool Equals(Point o) => this.X == o.X && this.Y == o.Y;
    public override int GetHashCode() => HashCode.Combine(X, Y);

    public static bool operator ==(Point a, Point b) => a.Equals(b);
    public static bool operator !=(Point a, Point b) => !(a == b);
}

如果你只是添加了==!=运算符,你会发现编译器会生成警告,建议你定义两个名为EqualsGetHashCode的方法。Equals是所有.NET 类型上都可用的标准方法,如果你为==定义了自定义含义,应确保Equals也执行同样的操作。示例 3-29 做到了这一点,正如你所见,它包含与==运算符相同的逻辑,但需要做一些额外的工作。Equals方法允许与任何类型进行比较,因此我们首先检查我们的Point是否与另一个Point进行比较。我使用了声明模式来执行此检查,并在模式匹配的情况下将传入的obj参数转换为Point类型的变量。事实上,它实现了两个版本的Equals:接受任何object的标准方法和仅允许与其他Point值进行比较的更专业的方法。这通过避免装箱(在第七章中描述)实现了更高效的比较,并且如常见做法一样,在提供这第二种形式的Equals时,我声明了对IEquatable<Point>接口的支持;我将在“接口”中描述接口。示例 3-29 还实现了GetHashCode,如果我们实现了Equals,我们就必须这样做。有关详细信息,请参阅下一节边栏GetHashCode

使用 示例 3-29 中的 Point 版本,我们可以运行几个测试。示例 3-30 的工作方式类似于示例 3-18 和 3-19。

示例 3-30. 比较结构实例
var p1 = new Point(40, 2);
Point p2 = p1;
var p3 = new Point(40, 2);

Console.WriteLine($"{p1.X}, {p1.Y}");
Console.WriteLine($"{p2.X}, {p2.Y}");
Console.WriteLine($"{p3.X}, {p3.Y}");
Console.WriteLine(p1 == p2);
Console.WriteLine(p1 == p3);
Console.WriteLine(p2 == p3);
Console.WriteLine(object.ReferenceEquals(p1, p2));
Console.WriteLine(object.ReferenceEquals(p1, p3));
Console.WriteLine(object.ReferenceEquals(p2, p3));
Console.WriteLine(object.ReferenceEquals(p1, p1));

运行该代码会产生以下输出:

40, 2
40, 2
40, 2
True
True
True
False
False
False
False

所有三个实例具有相同的值。对于 p2,这是因为我通过将 p1 分配给它进行了初始化,而对于 p3,我从头开始构建它,但使用相同的参数。然后我们有前三个比较,记住,它们使用 ==。由于 示例 3-29 定义了一个自定义实现来比较值,所有比较都成功了。而所有 object.ReferenceEquals 的值都失败了,因为这是一个值类型,就像 int 一样。事实上,这与我们在 示例 3-19 中看到的行为相同,那里使用的是 int 而不是 Counter。(同样,编译器在这里生成了隐式转换,产生了箱子,我们将在 第七章 中看到。)因此,我们已经实现了定义一种与内置值类型(如 int)具有类似行为的类型的目标。

编写值类型的时机

我展示了引用类型(classrecord)与struct之间在可观察行为上的一些差异,但尽管我辩称为什么Counter不适合作为struct,我还没有完全解释什么才是一个好的struct。简短的答案是,只有两种情况下你应该编写值类型。首先,如果你需要表示类似值的东西,比如数字,struct可能是理想的选择。其次,如果你已经确定在你将使用该类型的场景中,struct具有更好的性能特征,那么struct可能并不是理想的,但仍然可能是一个不错的选择。但值得更详细地了解其利弊。我还将解释一个关于值类型令人意外地持久的神话。

对于引用类型,对象与引用它的变量是不同的。这可能非常有用,因为我们经常使用对象作为具有自己身份的真实事物的模型。但这对性能有一些影响。对象的生命周期不一定直接与引用它的变量的生命周期相关联。你可以创建一个新对象,将其引用存储在局部变量中,然后稍后将该引用复制到静态字段中。最初创建对象的方法可能会返回,因此最初引用对象的局部变量不再存在,但对象需要保持活动状态,因为仍然可以通过其他方式访问它。

CLR 会极力确保对象占用的内存不会过早回收,而是在对象不再使用时最终释放。这是一个相当复杂的过程(在第七章中详细描述),.NET 应用程序可能会导致 CLR 消耗大量 CPU 时间来跟踪对象,以确定它们何时不再使用。创建大量对象会增加这种开销。在某些方式中增加复杂性也会增加对象跟踪的成本——如果某个对象之所以保持活跃是因为它通过一些非常复杂的路径可达,CLR 可能需要每次尝试确定仍在使用的内存时都跟随该路径。你添加的每个间接层都会产生额外的工作。引用本质上是间接的,因此每个引用类型变量都会为 CLR 创建工作量。

值类型通常可以以更简单的方式处理。例如,考虑数组。如果你声明了某个引用类型的数组,最终你得到的是一个引用的数组。这是非常灵活的——元素可以是空的,而且你也可以自由地有多个不同的元素都引用同一个项目。但如果你实际上需要的是一个简单的顺序项目集合,这种灵活性只是额外的开销。一个包含 1,000 个引用类型实例的集合需要 1,001 个内存块:一个用于保存引用数组的块,然后 1,000 个用于这些引用的对象。但是对于值类型来说,一个单独的块就可以容纳所有的值。这简化了内存管理的目的——数组要么仍在使用,要么就不在使用,CLR 不需要单独检查这 1,000 个元素。

不仅仅是数组可以从这种效率中受益。字段也有优势。考虑一个包含 10 个 int 类型字段的类。用于保存这些字段值所需的 40 个字节可以直接存在包含类实例的内存中。与之相比,如果有 10 个某种引用类型的字段。虽然这些引用可以存储在对象实例的内存中,但它们所引用的对象将是独立的实体,因此如果字段都是非空并且都引用不同的对象,你现在将有 11 个内存块——一个用于包含所有字段的实例,然后每个字段引用的对象各有一个。图 3-1 说明了这些在数组和对象方面引用与值之间的差异(以较小的示例来说明,因为即使在少数实例中也适用相同的原则)。

值类型有时还可以简化生命周期处理。通常情况下,为局部变量分配的内存可以在方法返回时立即释放(尽管如我们将在第九章中看到的,匿名函数意味着情况并不总是那么简单)。这意味着局部变量的内存通常可以存放在堆栈上,而堆栈的开销通常比堆小得多。对于引用类型来说,变量的内存只是故事的一部分——它所引用的对象并不那么容易处理,因为该对象在方法退出后可能仍然可通过其他路径访问到。

引用与值

图 3-1. 引用与值

实际上,一个值的内存可能在方法返回之前就被回收。新值实例通常会覆盖旧实例。例如,C#通常可以只使用一块内存来表示一个变量,不管你往里面放多少不同的值。创建一个值类型的新实例并不一定意味着分配更多内存,而对于引用类型来说,新实例意味着一个新的堆块。这就是为什么每次我们使用值类型进行操作——比如整数加法或减法——都会产生一个新实例。

关于值类型的一个最持久的误解之一是认为值是分配在堆栈上的,而不像对象。事实上,对象总是存在于堆上,但值类型并不总是存在于堆栈上²,即使在它们存在于堆栈上的情况下,这只是一种实现细节,而不是 C#的基本特性。图 3-1 展示了两个反例。在类型为int[]的数组内部的一个int值并不位于堆栈上,它存在于数组的堆块内部。同样地,如果一个类声明了一个非静态的int字段,该int的值存在于其所包含的对象实例的堆块内部。甚至值类型的局部变量并不一定最终位于堆栈上。例如,优化可能使得局部变量的值完全存在于 CPU 的寄存器内,而不需要放在堆栈上。正如你将在第九章和 17 章看到的,局部变量有时也可以存在于堆上。

你可能会诱惑将前面几段总结为“有一些复杂的细节,但本质上,值类型更有效率”。但那是一个错误。在某些情况下,值类型会显著更昂贵。请记住,值类型的一个定义特征是在赋值时会复制数值。如果值类型很大,那么复制将会相对昂贵。例如,运行时库定义了Guid类型来表示 Windows 中许多地方出现的 16 字节的全局唯一标识符。这是一个struct,所以涉及Guid的任何赋值语句都会请求复制一个 16 字节的数据结构。这可能比复制引用更昂贵,因为 CLR 对引用使用基于指针的实现;一个指针通常占据 4 或 8 字节,但更重要的是,它将自然适合单个 CPU 寄存器。

不仅赋值会导致值被复制。将值类型参数传递给方法可能需要复制。事实上,通过方法调用,实际上可以传递一个值的引用,尽管我们稍后会看到,这是一种稍微有限的引用类型,它所施加的限制有时是不希望的,因此您可能最终决定复制的成本更可取。这就是为什么微软的设计指南建议,除非“实例大小小于 16 字节”,否则不应将类型定义为struct(技术上Guid类型违反了这一指南,因为它的确刚好是 16 字节大小)。但这不是一个死板的规则——这实际上取决于您将如何使用它,而且由于 C#的更新版本提供了更多间接使用值类型的灵活性,因此在性能敏感的代码中忽略这一限制并致力于最小化复制现在变得越来越普遍。

值类型并不自动比引用类型更有效率,所以在大多数情况下,您的选择应该由您需要的行为驱动。最重要的问题是:实例的身份对您是否重要?换句话说,一个实例与另一个实例之间的区别是否重要?对于我们的Counter示例,答案是肯定的:如果我们想要一个来为我们计数的东西,最简单的方法就是将该计数器作为一个具有自己身份的独立物体。但对于我们的Point类型,答案是否定的,所以它是作为值类型的一个合理候选。

一个重要且相关的问题是:你的类型的实例是否包含随时间变化的状态?可修改的值类型往往是有问题的,因为很容易会意外地使用某个值的副本而不是你想要的实例。(稍后我会展示一个关于这个问题的重要例子,在“属性和可变值类型”中,并且在描述List<T>时我会在第五章中再次描述。)因此,对于值类型来说,通常最好是不可变的。这并不意味着这些类型的变量不能被修改;它只意味着要修改变量,你必须完全用不同的值替换其内容。对于像int这样简单的东西,这似乎是纠缠细节,但是在包含多个字段的结构体中这一区别是重要的,例如.NET 的Complex类型,它表示一个由实部和虚部组成的数。你不能改变现有Complex实例的RealImaginary属性,因为该类型是不可变的。之前展示的Point类型也是一样的。如果你手头的值不是你想要的值,不可变性意味着你需要创建一个新的值,因为你不能调整现有的实例。

不可变性并不一定意味着你应该编写一个结构体——内置的string类型是不可变的,这是一个类。³ 然而,因为在 C#中通常不需要为了保存值类型的新实例而分配新的内存,值类型在需要创建大量新值的场景(例如循环中)比类更有效地支持不可变性。对于结构体来说,不可变性并非绝对要求——在.NET 运行时库中存在一些不幸的例外情况。但通常应该使值类型是不可变的,因此对可变性的需求通常是你应该选择类而不是结构体的一个良好迹象。

一个类型只有在它代表的东西与其他值类型非常相似时才应该是一个结构体。(在大多数情况下,它还应该相当小,因为通过值传递大型类型是昂贵的。)例如,在运行时库中,Complex是一个结构体,这并不奇怪,因为它是一个数值类型,而所有内置数值类型都是值类型。TimeSpan也是一个值类型,这是合理的,因为它实际上只是一个表示时间长度的数字。在 UI 框架 WPF 中,用于简单几何数据的类型,如PointRect,都是结构体。但如果不确定的话,就编写一个类。

保证不可变性

可以通过在 struct 前添加 readonly 关键字来声明将结构体设置为只读,就像示例 3-31 所示。这类似于示例 3-28 中显示的 Point 类型,但我进行了一些其他修改。除了添加 readonly 限定符外,我还使用了只读自动属性来减少混乱——编译器将为这些属性生成与先前示例相当的代码。出于即将明确的原因,我还添加了一个成员函数。与早期版本类似,这个版本有一个构造函数,在这种情况下是强制性的:构造函数提供了为 Point 属性提供值的唯一机会,因此没有构造函数的话,这种类型将毫无用处。

示例 3-31. 只读结构体
public readonly struct Point
{
    public Point(double x, double y)
    {
        X = x;
        Y = y;
    }

    public double X { get; }
    public double Y { get; }
    public double DistanceFromOrigin() => Math.Sqrt(X * X + Y * Y);
}

readonly 关键字应用于 struct 具有两个效果。首先,C# 编译器会确保你的诚实,防止外部或内部的修改。如果声明了任何字段,除非这些字段也标记为 readonly,否则编译器将生成错误。类似地,如果尝试定义可设置的自动属性(包括 set;get;),编译器也会产生错误。

第二,只读结构体享有某些优化。如果在某些其他类型中声明一个 readonly 字段(直接或间接使用只读自动属性),其类型为 readonly struct,当某些内容使用该字段时,编译器可以避免复制数据。考虑示例 3-32 中的类。

示例 3-32. 只读结构体在只读属性中
public class LocationData
{
    public LocationData(string label, Point location)
    {
        Label = label;
        Location = location;
    }

    public string Label { get; }
    public Point Location { get; }
}

假设您有一个变量 r,其中包含对 LocationData 的引用。如果写入表达式 r.Location.DistanceFromOrigin(),会发生什么呢?从逻辑上讲,我们正在请求 r.Location 检索 Point,由于 Point 是值类型,这将涉及复制值。通常情况下,C# 会生成确实进行复制的代码,因为通常情况下它无法知道调用 struct 的某些成员是否会修改它。这些称为防御性复制,它们确保像这样的表达式不会引起意外,比如更改看似只读的属性或字段的值。然而,由于 Pointreadonly struct,编译器可以知道在这里不需要创建防御性复制。在这种情况下,C# 编译器或 JIT 编译器(或 AoT 代码生成器)可以通过直接在 LocationData 中存储的值上调用 DistanceFromOrigin 来优化这段代码,而无需首先创建复制品。

提示

如果愿意,可以在可写字段和属性中使用 readonly structreadonly 关键字仅保证此类型的任何特定值不会更改。如果想要用完全不同的值覆盖现有值,完全由你决定。

记录结构体

当您看到示例 3-29 时,您可能会想到,这似乎很像编译器可以为我们在record类型中执行的工作。我们是否可以用struct做同样的工作?从 C# 10.0 开始,我们可以声明一个record struct类型,它添加了与基于classrecord相同的比较行为 - 编译器将为您编写GetHashCode和两种形式的Equals方法,以及==!=运算符。

除了已经描述的类和值类型之间的常规区别之外,recordrecord struct类型之间还有一些其他更微妙的区别。例如,由于struct类型有一种明确声明它们是不可变的方式(使用readonly修饰符),当您在record struct中使用位置语法时,编译器假设如果您想要一个只读类型,您将通过声明readonly record struct来表明。因此,尽管使用位置语法定义的属性在readonly record struct上是不可变的(就像在record上一样),但在record struct上是可修改的。因此,虽然您不能在构造后修改PointRecord类型的XY属性(见示例 3-33),但您可以更改PointStructRecord的属性。但是Poi⁠ntR⁠ead⁠on⁠ly​Str⁠uct⁠Rec⁠ord获取到的是不可变属性,就像PointRecord一样。

示例 3-33. 一个只读的record,一个可变的record struct和一个readonly record struct
public record PointRecord(int X, int Y);
public record struct PointStructRecord(int X, int Y);
public readonly record struct PointReadonlyStructRecord(int X, int Y);

record struct还在构造函数周围有一些微妙的差异,我将在“构造函数”中描述。

类、结构、记录或元组?

正如您现在所看到的,C# 提供了许多定义类型的方式。我们应该如何在它们之间进行选择?假设您的代码需要处理表示二维空间中位置的坐标对?在 C#中应该如何表示这个?

最简单的答案可能是声明两个double类型的变量,一个表示每个维度。这当然可以工作,但是您的代码将无法捕获到一个重要的东西:这两个值不是两个独立的东西。如果您选择的类型不能表示这两个数字是单个实体的事实,这将会导致问题。当您想编写以位置作为参数的方法时,这是不方便的 - 您最终会需要两个参数。如果您意外地传递一个坐标对的 X 值和另一个坐标对的 Y 值,编译器将无法知道这是错误的。如果您想要一个函数返回一个位置,使用两个独立的值尤其麻烦,因为 C#方法只能返回单个值。

元组在第二章中描述,可以解决我刚刚描述的问题,因为单个值可以包含一对数字:(1.0, 2.0)。虽然这确实是一个改进,但元组的问题在于它们无法区分具有相同结构的不同类型的数据。这并非元组特有:内置类型也有相同的问题。一个表示英尺距离的double具有与表示米距离的double相同的 C#类型,尽管它们在含义上有显著差异。(NASA 在 1999 年因为对相同类型但不同单位的值混淆而丢失了一枚太空探测器。)但这些问题超出了不匹配的单位。假设你有一个元组(X: 10.0, Y: 10.0)表示矩形在米中的位置,另一个(Width: 2.0, Height: 1.0)表示其大小,同样也是以米为单位。这里单位相同,但位置和大小是完全不同的概念,然而这两个元组恰好具有完全相同的类型。当元组成员具有不同名称时,这可能特别令人惊讶——第一个有XY,但第二个有WidthHeight。然而,正如你在前一章中看到的,这些元组成员名称是 C#编译器为我们方便提供的虚构名称。真正的名称是Item1Item2

鉴于元组的局限性,可能更合适的问题是:何时才会选择使用元组而不是专用类型,例如记录?我发现在私有实现细节中,元组非常有用,特别是在概念上不相关的元组类型的结构等价性几乎不会引起问题的地方。例如,在使用第五章中描述的Dictionary<TKey, TValue>容器类型时,有时将字典键组合成多个值是有用的。元组非常适合这种复合键的情况。它们还可以在方法需要返回多个相关数据的情况下非常有用,而定义全新类型似乎有些过头。例如,如果方法是一个私有方法,在只调用一两次的情况下,定义一个完整的类型真的值得吗?

记录类型将比元组更适合于我们结构上类似但概念上不同的位置和尺寸示例:如果我们定义public record Position(double X, double Y)public record Dimensions(double Width, double Height),现在我们有两种不同的类型来表示这两种不同的数据。如果我们不小心在需要尺寸时使用位置,编译器将指出错误。此外,与我们可以为元组成员给出的局部定义名称不同,记录属性的名称是真实的,因此使用Dimensions的代码将始终引用其成员为WidthHeight。记录类型自动实现相等比较和哈希码,因此它们在字典中作为复合键的工作与元组一样出色。您可能选择元组而不是记录只有两个原因。一种是当您实际上希望结构上的等价性时——有些情况下,故意对类型模糊提供额外的灵活性可能会证明可以接受安全性可能降低的情况下。第二种情况是在定义类型似乎过于繁琐时(例如,在仅在一个方法内部使用的字典的复合键)。

由于记录类型是完整的 .NET 类型,它们可以包含不仅仅是属性,还可以包含以下部分描述的任何其他成员类型。例如,我们的Dimensions记录类型可以包括一个计算面积的方法。而且我们可以自由选择使用record或者record struct来定义引用类型或值类型。

什么时候我们会使用类(或结构体)而不是记录?一个原因可能是您不想要等式逻辑。如果您的应用程序有具有自己标识的实体——也许某些对象对应于人或特定设备——为记录类型生成的基于值的比较逻辑将不合适,因为即使两个项目具有相同的特征,它们也可能是不同的。 (想象一下代表绘图程序中形状的对象。如果您克隆一个形状,您将有两个相同的对象,但重要的是它们仍被认为是不同的,因为克隆的项目可能会继续移动或以其他方式修改。)所以您可能想问:您的类型是否代表一种事物,还是仅仅包含一些信息?如果它代表一些信息,记录类型可能是一个不错的选择,但是对于表示某个真实实体的实体,类可能是更好的选择,特别是如果类型的实例具有自己的行为。例如,在构建用户界面时,像按钮这样的交互元素最好建模为class而不是record。并不是说记录类型不能工作——它们可以做与普通类和结构体几乎一样的事情;只是它们可能不太合适。

成员

无论您是编写类、结构体还是记录,您都可以在自定义类型中放置几种不同类型的成员。我们已经看到了一些示例,但让我们更仔细和全面地查看一下。

可访问性

您可以为大多数类和结构成员指定访问权限。就像类型可以是publicinternal一样,每个成员也可以是这样。成员也可以声明为private,这样它们只能在类型内部的代码中访问,这是默认的访问权限。正如我们将在第六章中看到的那样,继承为成员添加了另外三个可访问性级别:protectedprotected internalprotected private

字段

您已经看到字段是命名存储位置,根据其类型可以保存值或引用。默认情况下,每个类型的实例都有其自己的一组字段,但是如果您希望一个字段是唯一的,而不是每个实例都有一个,则可以使用static关键字。您还可以将readonly关键字应用于字段,该关键字表示它只能在初始化期间设置,并且之后不能更改。

警告

readonly关键字并不提供绝对保证。有一些机制可以通过它们来设法改变readonly字段的值。本章的反射机制提供了一种方法,而不安全代码则允许您直接使用原始指针,提供了另一种方法。编译器将阻止您意外修改字段,但是如果有足够的决心,您可以绕过此保护。即使没有这种诡计,readonly字段在构造过程中仍然可以自由更改。

C#提供了一个关键字,表面上看似乎类似:您可以定义一个const字段。然而,这是为了一个稍有不同的目的而设计的。readonly字段在初始化后就不会再改变,而const字段定义的值始终不变。readonly字段更加灵活:它可以是任何类型,并且其值可以在运行时计算,这意味着您可以将其定义为每个实例或者静态字段。const字段的值在编译时确定,这意味着它是在类级别定义的(因为没有办法使单个实例具有不同的值)。这也限制了可用的类型。对于大多数引用类型,唯一支持的const值是null,因此在实践中,通常仅将const与编译器本身支持的类型一起使用(具体来说,如果要使用除null之外的值,const的类型必须是内置的数值类型、boolstring或枚举类型,如本章后面所述)。

这使得const字段比readonly字段更加受限,因此你可能会合理地问:意义何在?嗯,尽管const字段不太灵活,但它确实强烈表明了值的不变性。例如,.NET 的Math类定义了一个double类型的const字段,称为PI,它包含尽可能接近数学常量π的近似值,这是一个永远固定的值,因此在非常强的意义上是一个常量。

当涉及到不太固定的值时,你需要对const字段稍加小心;C#规范允许编译器假设该值确实永远不会改变。当代码读取readonly字段的值时,它会在运行时从包含字段的内存中获取值。但当你使用const字段时,编译器可以在编译时读取值,并将其复制到 IL 中,就好像它是一个字面常量。因此,如果你编写一个声明了const字段的库组件,并且稍后更改其值,除非重新编译该代码,否则使用您的库的代码可能不会立即反映这一变化。

const字段的一个好处是,在某些readonly字段不可用的情况下,它可以在特定的上下文中使用。例如,如果你想要在switch语句的case标签中使用常量模式(引入自第二章的模式),你指定的值必须在编译时固定。因此,常量模式不能引用readonly字段,但可以使用适当类型的const字段。你还可以在定义另一个const字段的值的表达式中使用const字段(只要不引入循环引用)。

const字段需要包含定义其值的表达式,例如示例 3-34 中所示的表达式。

示例 3-34. 一个const字段
const double kilometersPerMile = 1.609344;

尽管对于一个const来说,这个初始化表达式是必需的,但对于类的普通和readonly⁴字段来说是可选的。如果你省略了初始化表达式,字段将自动初始化为默认值。(对于数值,这是 0,对于其他类型如falsenull等则是相应的等价值。)

实例字段初始化器作为构造的一部分运行,即当你使用new关键字(或某些等效机制,如通过反射构造实例,如第十三章中所述)时。这意味着你应该小心使用值类型的字段初始化器。struct可以隐式初始化,此时它的实例字段设置为 0(或false等)。从 C# 10.0 开始,你可以在struct中编写实例字段初始化器,但这些只会在显式初始化struct时运行。如果你创建一个数组,其元素是带有字段初始化器的某个值类型,则数组中所有元素的所有字段将以值 0 开始;如果你希望字段初始化器运行,你需要编写一个循环,使用new初始化数组中的每个元素。同样,当你将struct类型用作字段时,它将被零初始化,并且只有在使用new关键字显式初始化字段时,其字段初始化器才会运行。(class中的实例字段初始化器也仅在构造该class时运行,但主要区别在于,不可能在不运行其构造函数之一的情况下获取class的实例。⁵ 有常见情况下,你将能够使用隐式零初始化的struct实例。)对于非实例字段(即conststatic字段),初始化器始终对struct执行。

如果你为非const字段提供了初始化表达式,它不需要在编译时可评估,因此它可以进行运行时工作,比如调用方法或读取属性。当然,这种代码可能具有副作用,因此重要的是要注意初始化器运行的顺序。

非静态字段初始化器会在每个创建的实例中运行,并且它们按照文件中出现的顺序执行,在构造函数运行之前立即执行。静态字段初始化器最多只执行一次,无论你创建该类型的实例多少个。它们也按照声明的顺序执行,但很难确定它们确切的运行时间。如果你的类没有静态构造函数,C#保证在访问类中的字段之前运行字段初始化器,但不一定等到最后一刻——它保留在喜欢的任何时间运行字段初始化器的权利。(这一切发生的确切时机在不同版本的.NET 中有所不同。)但是如果存在静态构造函数,情况稍微清晰些:静态字段初始化器会在静态构造函数运行之前立即执行,但这只是提出了一个问题:什么是静态构造函数,以及它何时运行?所以我们最好看一下构造函数。

构造函数

新创建的对象可能需要一些信息来完成其工作。例如,System命名空间中的Uri类表示统一资源标识符(URI),如 URL。由于它的整个目的是包含并提供关于 URI 的信息,因此如果一个Uri对象不知道其 URI 是什么,那么创建它就没有多大意义。因此,不提供 URI 的情况下是不可能创建一个Uri对象的。如果尝试运行示例 3-35,将会得到编译器错误。

示例 3-35. 错误:未提供带有其 URI 的Uri
Uri oops = new Uri();  // Will not compile

Uri类定义了多个构造函数,这些成员包含初始化类型的新实例的代码。如果某个类需要特定信息才能工作,你可以通过构造函数来强制执行这一要求。几乎总是在创建一个类的实例时会使用构造函数,因此如果你定义的所有构造函数都要求某些信息,那么开发人员在想要使用你的类时就必须提供这些信息。因此,Uri类的所有构造函数都需要以某种形式提供 URI。

要定义一个构造函数,首先指定可访问性(publicprivateinternal等),然后是包含类型的名称。然后是括号中的参数列表(可以为空)。示例 3-36 展示了一个定义了单个构造函数的类,该构造函数需要两个参数:一个是decimal类型,另一个是string类型。参数列表后面是包含代码的块。因此,构造函数看起来很像方法,但其返回类型和方法名称的位置由包含类型名称代替。

示例 3-36. 具有一个构造函数的类
public class Item
{
    public Item(decimal price, string name)
    {
        _price = price;
        _name = name;
    }
    private readonly decimal _price;
    private readonly string _name;
}

这个构造函数非常简单:它只是将其参数复制到字段中。许多构造函数不做更多的事情。你可以在其中放置任意数量的代码,但是按照惯例,开发人员通常希望构造函数做的事情很少——其主要工作是确保对象处于有效的初始状态。这可能涉及检查参数并在出现问题时抛出异常,但不会做其他太多事情。如果你编写了一个执行非平凡操作的构造函数,比如向数据库添加数据或通过网络发送消息,可能会让使用你的类的开发人员感到惊讶。

示例 3-37 展示了如何使用示例 3-36 定义的构造函数。我们只需使用new运算符,并适当地传递类型匹配的值作为参数。

示例 3-37. 使用构造函数
var item1 = new Item(9.99M, "Hammer");

你可以定义多个构造函数,但必须能够区分它们:不能定义两个构造函数,它们都接受相同数量和类型的参数,因为new关键字无法知道你指的是哪一个。

早些时候,我展示了记录类型如何要求某些属性必须存在。示例 3-38 展示了一个类似于示例 3-36 中Item类的record(尽管这会使相关数据公开)。这样做实际上是在定义一个构造函数。

示例 3-38. 使用编译器生成的构造函数的记录类型
public record Item(decimal Price, string Name);

使用此示例,编译器将生成一个接受decimalstring参数的构造函数。生成的构造函数将使用这些参数来初始化PriceName属性。正如您之前在示例 3-13 中看到的,如果需要,您可以自己提供构造函数,但在编译器生成的构造函数能够满足需求的情况下,这非常方便。这并不是编译器为您可以生成的唯一类型的构造函数。

默认构造函数和零参数构造函数

如果您根本不定义任何构造函数,C#将提供一个默认构造函数,它相当于一个不带参数的空构造函数。如果您编写的是结构体,即使您定义了其他构造函数,也会得到这个默认构造函数。

注意

尽管 C#规范明确定义了默认构造函数是由编译器为您生成的构造函数,但请注意还有另一种广泛使用的含义。您经常会看到术语默认构造函数用于指任何公共的无参数构造函数,不管它是由编译器生成的还是显式定义的。从这个角度来看,这也有一些逻辑——当使用一个类时,无法区分编译器生成的构造函数和显式的零参数构造函数,因此,如果术语默认构造函数要从这个角度上有任何有用的含义,它只能指一个公共的不带参数的构造函数。然而,这并不是 C#规范定义的术语含义。

编译器生成的默认构造函数除了对字段进行零初始化外,什么也不做,这是所有新对象的起始点。然而,在某些情况下,编写自己的无参数构造函数是必要的。您可能需要构造函数来执行一些代码。示例 3-39 根据一个静态字段设置了_id字段,该字段在每个新对象中递增,以使每个实例具有不同的 ID。这不需要传入任何参数,但确实涉及运行一些代码。

示例 3-39. 非空的零参数构造函数
public class ItemWithId
{
    private static int _lastId;
    private int _id;

    public ItemWithId()
    {
        _id = ++_lastId;
    }
}

还有一种方法可以实现与示例 3-39 相同的效果。我本可以编写一个名为GetNextId的静态方法,并在_id字段的初始化器中使用它。那么我就不需要编写这个构造函数了。然而,将代码放在构造函数中有一个优势:字段初始化器不允许调用对象自身的非静态方法,但构造函数可以。这是因为在字段初始化期间,对象处于不完全状态,调用其非静态方法可能是危险的——它们可能依赖于字段具有有效值。但对象允许在构造函数中调用自己的非静态方法,因为尽管对象仍未完全构建,但它更接近完成状态,因此风险减少了。

编写自己的零参数构造函数还有其他原因。如果你为一个类定义了至少一个构造函数,这将禁用默认的构造函数生成。如果你的类需要提供带参数的构造,但仍希望提供一个无参数的构造函数,即使是空的,你也需要编写一个。或者,如果你想编写一个类,其唯一的构造函数是一个空的、零参数的构造函数,但访问级别不是默认的public,比如你可能想将其设置为private,以便只有你的代码可以创建实例,那么即使它是空的,你也需要显式地编写这个构造函数以便指定访问级别。

注意

有些框架只能使用提供公共的、零参数构造函数的类。例如,如果你用 Windows Presentation Foundation (WPF) 构建用户界面,那些可以作为自定义 UI 元素的类通常需要这样的构造函数。

对于结构体,零参数构造函数的工作方式略有不同,因为值类型需要支持隐式初始化。当值类型用作其他类型的字段或数组的元素类型时,保存值的内存是包含对象的一部分,当你创建一个新的对象或数组时,CLR 总是将其内存填充为零。这意味着始终可以初始化一个值而不需要传递任何构造函数参数。因此,尽管在类中添加了带参数的构造函数会移除默认构造函数,但对于结构体(包括record struct类型),C# 并不会这样做——即使它隐藏了它,你仍然可以间接调用这种隐式初始化,例如通过创建该类型的单元素数组:MyStruct s = (new MyStruct[1])[0];。由于结构体始终支持隐式初始化,编译器隐藏相应的构造函数就没有意义了。

直到 C# 10.0,你不允许为struct编写零参数构造函数,因为存在许多情况下该构造函数不会运行。(这与struct以前不支持实例字段初始化器的原因相同:本质上是相同的问题,因为字段初始化器作为构造的一部分运行。)现在你可以为struct编写零参数构造函数了,但与字段初始化器一样,要注意它只会在你的代码明确调用构造函数的情况下运行。CLR 的零初始化在大多数情况下被使用。

还有一种重要的由编译器生成的构造函数类型需要注意:当你编写一个recordrecord class时,编译器生成一个构造函数,用于在使用示例 3-12 中展示的with语法创建副本时使用。(这被称为拷贝构造函数,尽管如果你熟悉 C++,不要被误导:这仅在record类型内部使用,不是通用的拷贝机制。C#不支持在普通类中使用拷贝构造函数。)它默认执行浅拷贝,就像复制struct时一样,但如果你愿意,你可以写自己的实现,就像示例 3-40 展示的那样。

示例 3-40. 带有自定义拷贝构造函数的记录类型
public record ValueWithId(int Value, int Id)
{
    public ValueWithId(ValueWithId source)
    {
        Value = source.Value;
        Id = source.Id + 1;
    }
}

这可以防止编译器生成通常的拷贝构造函数。你的构造函数将在with语法导致创建类型副本时使用。

编译器不会为record struct生成拷贝构造函数。这是没有必要的,因为所有struct类型都是内部可复制的。虽然你可以写一个类似示例 3-40 中的构造函数给record struct,但编译器不会使用它。

链接构造函数

如果你编写的类型提供了多个构造函数,你可能会发现它们有一些共同之处——通常都有一些初始化任务需要执行。在示例 3-39 中的类在其构造函数中为每个对象计算一个数值标识符,如果它提供多个构造函数,它们可能都需要执行相同的工作。将这项工作移到字段初始化程序中是一种解决方法,但如果只有一些构造函数想要执行它呢?你可能有一些对大多数构造函数通用的工作,但你可能希望通过允许某个构造函数允许指定 ID 而不是计算来进行例外。字段初始化程序的方法就不再合适了,因为你希望各个构造函数可以选择加入或退出。示例 3-41 展示了从示例 3-39 修改后的代码,定义了两个额外的构造函数。

示例 3-41. 构造函数的可选链接
public class ItemWithId
{
    private static int _lastId;
    private int _id;
    private string? _name;

    public ItemWithId()
    {
        _id = ++_lastId;
    }

    `public` `ItemWithId``(``string` `name``)`
        `:` `this``(``)`
    {
        _name = name;
    }

    public ItemWithId(string name, int id)
    {
        _name = name;
        _id = id;
    }
}

如果你查看示例 3-41 中的第二个构造函数,其参数列表后面跟着一个冒号,然后是this(),它调用第一个构造函数。构造函数可以以这种方式调用任何其他构造函数。示例 3-42 展示了不同的结构化所有三个构造函数的方式,演示了如何传递参数。

示例 3-42. 链式构造函数参数
public ItemWithId()
    : this(null)
{
}

public ItemWithId(string? name)
    : this(name, ++_lastId)
{
}

private ItemWithId(string? name, int id)
{
    _name = name;
    _id = id;
}

这里的两参数构造函数现在是唯一实际执行任何工作的构造函数。其他构造函数只是为该主构造函数选择合适的参数。这可以说是比前面的例子更清晰的解决方案,因为初始化字段的工作只在一个地方完成,而不是每个构造函数都执行自己的字段初始化。

注意,我已经将示例 3-42 中的两参数构造函数设为private。乍一看,定义一个创建类实例的方法然后将其设为不可访问可能看起来有些奇怪,但在链式构造函数中这是完全合理的。而且,在其他场景中私有构造函数可能也很有用 —— 我们可能想写一个方法来创建一个现有的ItemWithId的克隆,在这种情况下该构造函数将很有用,但通过将其设为私有,我们保留了如何创建新对象的控制权。有时甚至将类型的所有构造函数都设为private也可能很有用,强制类型的用户通过所谓的工厂方法(一个创建对象的static方法)获取实例。这样做的两个常见原因是:一是如果对象的完全初始化需要额外的工作,这种工作不适合在构造函数中执行(例如,如果需要使用第十七章描述的异步语言特性进行缓慢的工作,那么不能将该代码放在构造函数中)。另一个原因是如果想要使用继承(见第六章)为类型提供多个变体,但希望在运行时决定返回哪个具体类型。

静态构造函数

到目前为止我们看过的构造函数在创建对象的新实例时运行。类和结构体也可以定义静态构造函数。这在应用程序的生命周期中最多运行一次。你不需要显式调用它 —— C# 会在你首次使用类之前的某个时刻自动运行它。因此,不像实例构造函数,没有传递参数的机会。由于静态构造函数不能接受参数,每个类只能有一个静态构造函数。而且,因为这些永远不会被显式访问,所以不需要为静态构造函数声明任何类型的可访问性。示例 3-43 展示了一个带有静态构造函数的类。

示例 3-43. 带有静态构造函数的类
public class Bar
{
    private static DateTime _firstUsed;
    static Bar()
    {
        Console.WriteLine("Bar's static constructor");
        _firstUsed = DateTime.Now;
    }
}

就像实例构造函数将实例置于有用的初始状态一样,静态构造函数提供了初始化任何静态字段的机会。

顺便说一句,你不必确保构造函数(静态或实例)初始化每个字段。当创建类的新实例时,实例字段最初都设置为 0(或等效值,如falsenull)。同样,类型的静态字段在类首次使用之前都被清零。与局部变量不同,你只需要初始化字段,如果你希望将它们设置为与默认的零值不同的值。

即便如此,你可能并不需要构造函数。字段初始化程序可能已经足够了。然而,了解构造函数和字段初始化程序运行的确切时间是很有用的。我之前提到过行为因构造函数的存在而有所不同,所以现在我们稍微详细看过构造函数之后,我终于可以展示初始化的更完整的图片了。(还会有更多内容—正如第六章所描述的,继承添加了另一个维度。)

在运行时,类型的静态字段将首先设置为 0(或等效值)。接下来,字段初始化程序按照它们在源文件中编写的顺序运行。如果一个字段的初始化程序引用另一个字段,这种顺序很重要。在示例 3-44 中,字段ac都有相同的初始化表达式,但由于初始化程序运行的顺序不同,它们最终具有不同的值(分别是 1 和 42)。

示例 3-44. 静态字段的重要顺序
private static int a = b + 1;
private static int b = 41;
private static int c = b + 1;

静态字段初始化程序确切的运行时刻取决于是否有静态构造函数。如前所述,如果没有,那么时机未定义—C#保证在访问类型的字段之前运行它们,但保留任意早地运行它们的权利。静态构造函数的存在改变了情况:在这种情况下,静态字段初始化程序会在构造函数之前立即运行。那么构造函数何时运行呢?它将由两个事件中的任意一个触发,哪个事件先发生:创建一个实例或访问类的任何静态成员。

对于非静态字段,情况类似:字段首先全部初始化为 0(或等效值),然后字段初始化程序按照它们在源文件中出现的顺序运行,这发生在构造函数运行之前。不同之处在于实例构造函数是显式调用的,因此清楚这种初始化发生的时机。

我编写了一个名为 InitializationTestClass 的类,旨在说明这种构造行为,显示在 示例 3-45 中。该类既有静态字段也有非静态字段,它们的初始化器都调用一个名为 GetValue 的方法。该方法始终返回相同的值 1,但会打印出一条消息,以便我们能够看到它何时被调用。该类还定义了一个无参数的实例构造函数和一个静态构造函数,它们都打印出消息。

示例 3-45. 初始化顺序
public class InitializationTestClass
{
    public InitializationTestClass()
    {
        Console.WriteLine("Constructor");
    }

    static InitializationTestClass()
    {
        Console.WriteLine("Static constructor");
    }

    public static int s1 = GetValue("Static field 1");
    public int ns1 = GetValue("Non-static field 1");
    public static int s2 = GetValue("Static field 2");
    public int ns2 = GetValue("Non-static field 2");

    private static int GetValue(string message)
    {
        Console.WriteLine(message);
        return 1;
    }

    public static void Foo()
    {
        Console.WriteLine("Static method");
    }
}

class Program
{
    static void Main()
    {
        Console.WriteLine("Main");
        InitializationTestClass.Foo();
        Console.WriteLine("Constructing 1");
        var i = new InitializationTestClass();
        Console.WriteLine("Constructing 2");
        i = new InitializationTestClass();
    }
}

Main 方法打印出一个消息,调用由 Ini⁠tia⁠liz⁠ati⁠on​Tes⁠tCla⁠ss 定义的静态方法,然后构造几个实例。运行程序时,我看到以下输出:

Main
Static field 1
Static field 2
Static constructor
Static method
Constructing 1
Non-static field 1
Non-static field 2
Constructor
Constructing 2
Non-static field 1
Non-static field 2
Constructor

注意,静态字段的初始化器和静态构造函数都会在调用静态方法(Foo)之前运行。字段的初始化器在静态构造函数之前运行,而且按照它们在源文件中出现的顺序运行,这是预期的行为。因为这个类包含一个静态构造函数,我们知道静态初始化将在何时开始——它是由该类型的第一次使用触发的,例如本例中当我们的 Main 方法调用 InitializationTestClass.Foo 时。你可以看到它恰好在这一点之前发生,而且不会更早,因为我们的 Main 方法成功打印出了它的第一个消息,然后静态初始化才发生。如果这个示例没有静态构造函数,只有静态字段的初始化器,那么不能保证静态初始化将在完全相同的点发生;C# 规范允许初始化发生得更早。

在静态初始化期间运行的代码需要小心:它可能比你预期的要早。例如,假设您的程序使用某种诊断日志记录机制,并且您需要在程序启动时配置它以便将消息记录到正确的位置。静态初始化期间运行的代码有可能在您成功执行此操作之前就开始执行,这意味着诊断日志记录可能还不起作用。这可能会使得调试这段代码变得困难。即使通过提供静态构造函数来缩小 C# 的选项,也很容易比您打算的更早运行它。使用类的任何静态成员都会触发其初始化,您可能会发现自己处于一个情况中,其中您的静态构造函数是由其他没有静态构造函数的类中的静态字段初始化器触发的——这可能发生在您的 Main 方法开始之前。

您可以尝试通过将其自己的静态初始化代码初始化为日志记录代码来修复此问题。因为 C# 保证在类型的第一次使用之前运行初始化,您可能会认为这将确保在任何使用日志记录系统的代码的静态初始化完成之前,日志记录初始化将完成。然而,这里有一个潜在的问题:C# 仅保证何时开始对任何特定类的静态初始化。它不保证等待它完成。它无法作出这样的保证,因为如果这样做,像异常的英国示例 3-46 这样的代码会使其处于不可能的情况中。

示例 3-46. 循环静态依赖
public class AfterYou
{
    static AfterYou()
    {
        Console.WriteLine("AfterYou static constructor starting");
        Console.WriteLine("AfterYou: NoAfterYou.Value = " + NoAfterYou.Value);
        Value = 123;
        Console.WriteLine("AfterYou static constructor ending");
    }

    public static int Value = 42;
}

public class NoAfterYou
{
    static NoAfterYou()
    {
        Console.WriteLine("NoAfterYou static constructor starting");
        Console.WriteLine("NoAfterYou: AfterYou.Value: = " + AfterYou.Value);
        Value = 456;
        Console.WriteLine("NoAfterYou static constructor ending");
    }

    public static int Value = 42;
}

在这个例子中,这两种类型之间存在循环依赖关系:它们都有试图使用另一个类定义的静态字段的静态构造函数。行为将取决于程序首先尝试使用这两个类中的哪一个。在一个首先使用 AfterYou 的程序中,我看到以下输出:

AfterYou static constructor starting
NoAfterYou static constructor starting
NoAfterYou: AfterYou.Value: = 42
NoAfterYou static constructor ending
AfterYou: NoAfterYou.Value = 456
AfterYou static constructor ending

正如你所预期的那样,AfterYou 的静态构造函数首先运行,因为这是我的程序要使用的类。它打印出第一条消息,但然后它尝试使用 NoAfterYou.Value 字段。这意味着现在必须开始 NoAfterYou 的静态初始化,因此我们看到它的静态构造函数的第一条消息。然后它继续检索 AfterYou.Value 字段,尽管 AfterYou 的静态构造函数还没有完成。(它检索了字段初始化器设置的值 42,并不是静态构造函数设置的值 123。)这是允许的,因为排序规则只有在触发静态初始化时才有效,并且它们不保证何时完成。如果它们试图保证完整初始化,这段代码将无法继续进行——NoAfterYou 的静态构造函数无法继续前进,因为 AfterYou 的静态构造尚未完成,但这不能前进,因为它将等待 NoAfterYou 的静态初始化完成。

这个故事的寓意是,您不应该对静态初始化期间尝试实现的目标过于雄心勃勃。很难预测事情发生的确切顺序。

小贴士

Microsoft.Extensions.Hosting NuGet 包通过其 HostBuilder 类提供了处理初始化问题的更好方法。这超出了本章的范围,但是找到并探索它是非常值得的。

解构器

在第二章中,我们看到了如何将元组解构为其组件部分,但解构不仅适用于元组。通过添加适当的 Deconstruct 成员,您可以为任何您编写的类型启用解构,如示例 3-47 所示。

示例 3-47. 启用解构
public readonly struct Size
{
    public Size(double w, double h)
    {
        W = w;
        H = h;
    }

    public void Deconstruct(out double w, out double h)
    {
        w = W;
        h = H;
    }

    public double W { get; }
    public double H { get; }
}

C#识别了具有out参数列表的名为Deconstruct的方法的约定(下一节将更详细描述out),并使您能够像对元组一样使用相同的解构语法。Example 3-48 使用这一特性提取Size的组件值,以使其能够简洁地表达其执行的计算。

Example 3-48. 使用自定义解构器
static double DiagonalLength(Size s)
{
    (double w, double h) = s;
    return Math.Sqrt(w * w + h * h);
}

具有解构器的类型还可以使用位置模式匹配。Chapter 2 展示了如何使用与解构非常相似的语法,在模式中匹配元组。任何具有自定义解构器的类型都可以使用相同的语法。Example 3-49 使用Size类型的自定义解构器在 switch 表达式中定义Size的各种模式。

Example 3-49. 使用自定义解构器的位置模式
static string DescribeSize(Size s) => s switch
{
    (0, 0) => "Empty",
    (0, _) => "Extremely narrow",
    (double w, 0) => $"Extremely short, and this wide: {w}",
    _ => "Normal"
};

回想一下 Chapter 2,位置模式是递归的:模式中的每个位置包含一个嵌套模式。由于Size解构为两个元素,因此每个位置模式都有两个位置用于放置子模式。Example 3-49 使用了常量模式、弃置模式和声明模式。

要在模式中使用解构器,C#需要在编译时知道要解构的类型。这在 Example 3-49 中起作用,因为 switch 表达式的输入类型为Size。如果位置模式的输入类型为object,除非您显式命名类型,否则编译器会假定您正在尝试匹配元组,正如 Example 3-50 所示。

Example 3-50. 具有显式类型的位置模式
static string Describe(object o) => o switch
{
    Size (0, 0) => "Empty",
    Size (0, _) => "Extremely narrow",
    Size (double w, 0) => $"Extremely short, and this wide: {w}",
    Size _ => "Normal shape",
    _ => "Not a shape"
};

如果您编写一个record类型(无论是基于类的还是record struct),并且使用位置语法,即需要在初始化时提供特定属性,例如 Example 3-51,编译器会为您生成一个Deconstruct方法。因此,就像元组一样,以这种方式定义的任何record都可以自动解构。

Example 3-51. 使用位置语法的record struct
public readonly record struct Size(double W, double H);

尽管编译器为这些示例依赖的Deconstruct成员提供了特殊处理,但从运行时的角度来看,这只是一个普通方法。因此,现在是更详细地查看方法的好时机。

方法

方法是可以选择返回结果并且可能接受参数的命名代码块。C#做了一个非常普遍的区分,即参数参数值:方法定义了它所期望的输入列表——参数——而方法内的代码则按名称引用这些参数。代码看到的值在每次调用方法时可能不同,术语参数值指的是特定调用中为参数提供的特定值。

正如您已经看到的那样,当存在诸如 publicprivate 这样的可访问性修饰符时,它出现在方法声明的开头。接下来是可选的 static 关键字(如果存在)。然后,方法声明指定返回类型。与许多 C 家族语言一样,您可以编写不返回任何内容的方法,并通过将 void 关键字放在返回类型的位置来指示这一点。在方法内部,您使用 return 关键字后跟一个表达式来指定方法返回的值。对于 void 方法,如果您的代码决定提前退出,可以使用 return 关键字而不带表达式来终止方法,尽管这是可选的,因为当执行到 void 方法的末尾时,它会自动终止。通常情况下,只有在 void 方法中,如果您的代码决定提前退出,才会使用 return

通过引用传递参数

在 C# 中,方法只能直接返回一个项。如果要返回多个值,可以将该项作为元组返回。或者,您可以将参数指定为输出而不是输入。示例 3-52 返回两个值,都是整数除法产生的。主要返回值是商,但还通过其最后一个带有 out 关键字的参数返回余数。

示例 3-52. 使用 out 返回多个值
public static int Divide(int x, int y, out int remainder)
{
    remainder = x % y;
    return x / y;
}

因为元组仅在 C# 7 中引入,而 out 参数从一开始就存在,所以在类库中,out 在元组可能更简单的场景中经常出现。例如,您会看到许多方法遵循类似于 int.TryParse 的模式,其中返回类型是一个 bool,指示成功或失败,实际结果通过 out 参数传递。

示例 3-53 展示了使用 out 参数调用方法的一种方式。与正常参数的参数化表达式不同,我们使用 out 关键字后跟一个变量声明。这引入了一个新变量,成为此 out 参数的参数。因此,在这种情况下,我们得到一个新的变量 r,初始化为 1(除法运算的余数)。

示例 3-53. 将 out 参数的结果放入新变量中
int q = Divide(10, 3, out int r);

out 参数中声明的变量遵循通常的作用域规则,因此在 示例 3-53 中,r 的作用域将与 q 一样长。更不明显的是,r 在表达式的其余部分也是可用的。示例 3-54 利用这一点尝试将一些文本解析为整数,如果解析成功,则返回解析结果,否则返回–1 作为回退值。

示例 3-54. 在同一表达式中使用 out 参数的结果
int value = int.TryParse(text, out int x) ? x : -1;

当传递一个 out 参数时,这是通过传递对局部变量的引用来实现的。当 示例 3-53 调用 Divide 方法时,当该方法将值分配给 remainder 时,实际上是将其分配给调用者的 r 变量。这是一个值类型 int,通常不会按引用传递,这种引用与引用类型的能力相比受到限制。⁶ 例如,你不能声明一个类中的字段来保存这种引用,因为局部变量 r 在作用域结束时将会消失,而类的实例可以在堆块中永久存在。C# 必须确保你不能在可能超出变量生存期的地方放置对局部变量的引用。

警告

async 关键字标注的方法(在 第十七章 中描述)不能有任何 out 参数。这是因为异步方法在完成之前可能会隐式返回给调用者,稍后再继续执行。这意味着调用者在 async 方法再次运行时可能已经返回,此时传递的引用变量可能已经不存在了。匿名函数(在 第九章 中描述)也适用相同的限制。这两种方法允许将 out 参数传递给它们调用的方法。

并非每次都想为每个 out 参数声明一个新变量。如 示例 3-55 所示,你只需写上 out 后面跟现有变量的名称即可。(曾经这是使用 out 参数的唯一方式,所以有时你会看到在使用之前另外声明一个新变量的代码,尽管 示例 3-53 中的形式会更简单。)

示例 3-55. 将 out 参数的结果放入现有变量中
int r, q;
q = Divide(10, 3, out r);
Console.WriteLine($"3: {q}, {r}");
q = Divide(10, 4, out r);
Console.WriteLine($"4: {q}, {r}");
注意

当调用带有 out 参数的方法时,我们必须明确指出我们知道该方法如何使用这个参数。无论我们使用现有变量还是声明一个新变量,我们在调用处以及声明处都必须使用 out 关键字。

有时你可能希望调用一个具有 out 参数的方法,而你却不需要它——也许你只需要主返回值。如 示例 3-56 所示,你可以在 out 关键字后面直接放一个下划线。这告诉 C# 丢弃这个结果。

示例 3-56. 丢弃 out 参数的结果
int q = Divide(10, 3, out _);
提示

在 C#中应避免使用_(单个下划线)作为某物的名称,因为它可能会阻止编译器将其解释为丢弃。如果在范围内有名为_的局部变量,那么自 C# 1.0 以来,写入out _表明你希望将out结果分配给该变量,因此为了向后兼容,当前版本的 C#必须保留这种行为。只有在范围内没有名为_的符号时,才能使用这种形式的丢弃。

out引用需要信息从方法返回到调用者,因此如果尝试编写一个方法,在其所有out参数中都未分配内容的情况下返回,将会得到编译错误。C#使用第二章中提到的“明确分配”规则来检查这一点。(如果方法抛出异常而不是返回,则不适用此要求。)还有一个相关的关键字ref,具有类似的引用语义,但允许信息双向流动。对于ref参数,就好像方法直接访问调用者传入的变量一样——我们可以读取其当前值,也可以修改它。(调用者必须确保在调用之前,用ref传递的变量包含一个值,因此在这种情况下,方法在返回前不需要设置它。)如果使用ref而不是out参数调用方法,必须在调用点清楚地表明你想要传递一个变量的引用作为参数,就像示例 3-57 展示的那样。

示例 3-57. 使用ref参数调用方法
long x = 41;
Interlocked.Increment(ref x);

有第三种方法可以为参数增加一层间接性:可以使用in关键字。out仅允许信息从方法中流出,而in则只允许信息流入。这类似于ref参数,但调用的方法不允许修改参数所引用的变量。这看起来可能有些多余:如果没有办法通过参数返回信息,为什么要通过引用传递呢?in int参数听起来并没有和普通的int参数有实质上的不同。实际上,你不会将in用于int。它只用于比较大的类型。如你所知,值类型通常是按值传递的,这意味着在传递值作为参数时需要进行复制。in关键字使我们可以避免通过引用而不是复制来传递参数。过去,有时候人们使用ref关键字来避免复制数据,但这会增加方法可能修改值的风险,而调用方可能不希望如此。C# 7.2 引入了in,使我们可以通过这种方式实现仅限输入的语义,与通过常规方式传递值时获得的相同效果,但有可能提高效率,因为不需要传递整个值。

你应该只在大于指针的类型上使用in。这就是为什么in int没有用处。一个int是 32 位长,所以通过引用传递int并没有节省任何东西。在 32 位进程中,该引用将是一个 32 位指针,因此我们什么也没节省,而且通过引用间接使用值会导致轻微的额外低效性。在 64 位进程中,该引用将是一个 64 位指针,因此我们最终必须传入比直接传递int更多的数据到方法中!(有时 CLR 可以内联方法并避免创建指针的成本,但这意味着最好情况下in int的成本与int相同。由于in完全是关于性能的,这就是为什么对于像int这样的小类型,in没有用处。)

示例 3-58 定义了一个相当大的值类型。它包含四个double值,每个值大小为 8 字节,因此该类型的每个实例占用 32 字节。.NET 设计指南一直建议避免使值类型变得如此庞大,主要原因是将它们作为参数传递效率低下。早期版本的 C#不支持in关键字的这种用法,使得这一指南更为重要,但现在in可以减少这些成本,在某些情况下定义这么大的struct可能是有意义的。

Example 3-58. 一个大值类型
public readonly record struct Rect(double X, double Y, double Width, double Height);

示例 3-59 展示了一个计算矩形面积的方法,该矩形由示例 3-58 中定义的Rect类型表示。我们真的不想复制所有 32 字节来调用这个非常简单的方法,特别是因为它只使用了Rect中的一半数据。由于此方法用in标注其参数,因此不会发生此类复制:参数将通过引用传递,实际上意味着只需传递一个指针——无论代码是在 32 位还是 64 位进程中运行,其大小分别为 4 或 8 字节。

Example 3-59. 具有in参数的方法
public static double GetArea(in Rect r) => r.Width * r.Height;

你可能会期望调用具有in参数的方法需要调用方指示它知道参数将通过引用传递,就像我们需要在调用点写outref来进行另外两种引用样式一样。正如示例 3-60 所示,你可以这样做,但这是可选的。如果你想明确表达通过引用调用,你可以这样做,但与refout不同,如果不添加in,编译器仍然会通过引用传递参数。

Example 3-60. 调用具有in参数的方法
var r = new Rect(10, 20, 100, 100);
double area = GetArea(in r);
double area2 = GetArea(r);

in 关键字在调用站点是可选的,因为将参数定义为 in 只是一种性能优化,不会改变行为,不像 outref。微软希望开发人员能够引入源级兼容的更改,在现有方法的参数中添加 in。这在二进制级别是一个破坏性变更,但在您可以确保需要重新编译的情况下(例如,当所有代码都在您的控制下时),出于性能原因引入这样的更改可能很有用。当然,像所有这样的增强功能一样,您应该在更改前后测量性能,以确定其是否产生预期的效果。

虽然刚才展示的示例能正常工作,但 in 对于不注意的人来说是个陷阱。它之所以工作,仅因为我将 Example 3-58 中的 struct 标记为 readonly。如果我没有自己定义 Rect,而是使用了 System.Windows 命名空间(WPF UI 框架的一部分)中同名的非常相似的 struct,那么 Example 3-60 将无法避免复制。它会编译并在运行时产生正确的结果,但不会提供任何性能优势。这是因为 System.Windows.Rect 不是只读的。早些时候,我讨论了当您使用包含可变值类型的 readonly 字段时,C# 所做的防御性拷贝。同样的原理也适用于这里,因为 in 参数实际上是只读的:传递参数的代码期望它们不会被修改,除非显式标记为 outref。因此,编译器必须确保 in 参数不被修改,即使被调用的方法具有对调用者变量的引用。当涉及的类型已经是只读时,编译器不需要额外的工作。但如果它是一个可变的值类型,那么如果传递该参数的方法反过来调用该值的方法,编译器会生成使得拷贝并在其上调用方法的代码,因为它不能知道方法是否会修改该值。您可能认为编译器可以通过阻止具有 in 参数的方法执行可能修改值的任何操作来强制执行这一点,但实际上这意味着阻止它调用该值上的任何方法——通常编译器无法确定任何特定方法调用是否可能修改值。(即使今天不会,也可能在定义类型的库的未来版本中会这样做。)由于属性本质上是方法,因此在与可变类型一起使用时,in 参数或多或少是无用的。

提示

您应该仅在 readonly 值类型中使用 in,因为可变值类型可能会消除性能优势。(通常来说,可变值类型本身就不是一个好主意。)

C# 提供了一个功能,可以稍微放宽这个约束。它允许在成员上应用readonly关键字,以便它们声明不会修改它们所属的值。这样可以避免在可变值上进行这些防御性拷贝。

您也可以将outref关键字用于引用类型。这听起来可能多余,但它确实有用。它提供了双重间接性——方法接收一个指向保存引用的变量的引用。当您将引用类型参数传递给方法时,该方法可以访问您选择传递的任何对象。虽然方法可以使用该对象的成员,但通常不能用不同的对象替换它。但是,如果您用ref标记引用类型参数,方法就可以访问您的变量,因此它可以用指向完全不同对象的引用替换它。

构造函数也可以有outref参数,尽管这很少见。此外,请明确,outref限定符是方法或构造函数签名的一部分。只有在参数声明为out(或ref)时,调用者才能传递out(或ref)参数。调用者不能单方面决定将参数按引用传递给不期望它的方法。

引用变量和返回值

现在您已经看到了传递方法值的引用(或引用的引用)的各种方法,也许您想知道是否可以以其他方式获取这些引用。正如示例 3-61 所示,您可以,但存在一些限制。

示例 3-61. 本地ref变量
string rose = null;
ref string rosaIndica = ref rose;
rosaIndica = "smell as sweet";
Console.WriteLine($"A rose by any other name would {rose}");

此示例声明了一个名为rose的变量。然后它声明了一个类型为ref string的新变量。这里的ref与方法参数上的效果完全相同:它表示此变量是对另一个变量的引用。由于代码用ref rose初始化它,变量rosaIndica是对该rose变量的引用。因此,当代码向rosaIndica赋值时,该值将存入rosa引用的rose变量中。当最后一行读取rose变量的值时,它将看到前一行写入的值。

那么约束条件是什么呢?C#必须确保您不能将对局部变量的引用放入可能超出其所引用的变量生存期的地方。因此,您不能在字段上使用此关键字。静态字段的生存期与其定义的类型加载时间一样长(通常直到进程退出),类的成员字段存储在堆上,使它们能够比任何特定方法调用更长寿。(大多数结构体也是如此。但是对于ref struct并非如此,即使这些结构体目前也不支持在字段上使用ref关键字。)即使在您认为生命周期不是问题的情况下(例如,引用的目标本身是对象中的字段),结果表明运行时根本不支持将这种引用存储在字段中或作为数组中的元素类型。更隐晦的是,这也意味着您不能在 C#会将变量存储在类中的上下文中使用ref局部变量。这排除了它们在async方法和迭代器中的使用,也防止它们被匿名函数捕获(分别在第十七章、5 章和 9 章中描述)。

虽然类型不能定义具有ref的字段,但它们可以定义返回ref样式引用的方法(并且因为属性是伪装的方法,所以属性 getter 也可以返回引用)。像往常一样,C#编译器必须确保引用不能超出其所引用的内容,因此它将阻止在无法确定能够强制执行此规则的情况下使用此特性。例子 3-62 展示了ref返回类型的各种用法,编译器接受了其中的一些用法,而另一些则没有。

Example 3-62. ref 返回值的有效和无效用法
public class Referable
{
    private int i;
    private int[] items = new int[10];

    public ref int FieldRef => ref i;

    public ref int GetArrayElementRef(int index) => ref items[index];

    public ref int GetBackSameRef(ref int arg) => ref arg;

    public ref int WillNotCompile()
    {
        int v = 42;
        return ref v;
    }

    public ref int WillAlsoNotCompile()
    {
        int i = 42;
        return ref GetBackSameRef(ref i);
    }

    public ref int WillCompile(ref int i)
    {
        return ref GetBackSameRef(ref i);
    }
}

允许返回指向堆上对象内部的ref样式引用的方法返回一个int字段或数组中的元素,因为ref样式引用始终可以引用堆上对象内部的项(它们只是不能生存于其中)。堆对象可以在需要时存在(并且垃圾收集器,在第七章中讨论过,知道这些引用类型并确保保持引用其内部的堆对象存活)。方法可以返回其任何ref参数,因为调用者已经要求它们在调用期间保持有效。但是,方法不能返回对其局部变量之一的引用,因为在这些变量最终存在于堆栈上的情况下,当方法返回时,堆栈帧将不再存在。如果方法可以返回对现在已废弃的堆栈帧中变量的引用,那将是个问题。

当涉及返回从其他方法获取的引用时,规则变得更加微妙。示例 3-62 中的最后两种方法都试图返回由GetBackSameRef返回的引用。一种有效,而另一种无效。结果是有道理的:WillAlsoNotCompile需要被拒绝,原因与WillNotCompile相同:两者都试图返回对局部变量的引用,并且WillAlsoNotCompile仅仅通过另一个方法GetBackSameRef进行了伪装。在这些情况下,C#编译器做出了保守的假设,即任何返回ref并且还接受一个或多个ref参数的方法,可能会选择返回其中一个参数的引用。因此,编译器不允许在WillAlsoNotCompile中调用GetBackSameRef,理由是它可能返回对通过引用传递的同一局部变量的引用(在这种情况下,编译器是正确的。但即使方法返回对完全不同东西的引用,它也会拒绝这种形式的任何调用)。但它允许WillCompile返回GetBackSameRef返回的ref,因为在这种情况下,我们传递的引用是可以直接返回的。

in参数一样,使用ref返回值的主要原因是它们可以通过避免拷贝来实现更大的运行时效率。这类方法不会返回整个值,而是可以返回指向现有值的指针。这还可以使调用者能够修改所引用的内容。例如,在示例 3-62 中,我可以将一个值赋给FieldRef属性,尽管该属性看起来是只读的。在这种情况下,缺少 setter 并不重要,因为其类型为ref int,可以作为赋值的目标。因此,通过编写r.FieldRef = 42;(其中r的类型是Referable),我可以修改i字段。同样,可以使用GetArrayElementRef返回的引用来修改数组中的相关元素。如果这不是你的意图,可以将返回类型改为ref readonly而不是仅仅是ref。在这种情况下,编译器将不允许将结果引用用作赋值的目标。

提示

只应在readonly struct中使用ref readonly返回值,否则会遇到我们之前见过的防御性拷贝问题。

可选参数

通过定义默认值,可以使非out、非ref参数变为可选。在示例 3-63 中的方法指定了如果调用者未提供参数时参数应该具有的值。

示例 3-63. 带可选参数的方法
public static void Blame(string perpetrator = "the youth of today",
    string problem = "the downfall of society")
{
     Console.WriteLine($"I blame {perpetrator} for {problem}.");
}

此方法可以不带参数、带一个参数或两个参数调用。示例 3-64 只提供第一个参数,采用problem参数的默认值。

示例 3-64. 省略一个参数
Blame("mischievous gnomes");

通常,在调用方法时,你按顺序指定参数。但是,如果你想调用 示例 3-63 中的方法,但只想为第二个参数提供一个值,而第一个参数使用默认值呢?你不能简单地将第一个参数留空——如果尝试写 Blame( , "everything"),会导致编译器错误。相反,你可以指定想要提供的参数的名称,使用 示例 3-65 中显示的语法。C# 将使用指定的默认值填充你省略的参数。

示例 3-65. 指定参数名称
Blame(problem: "everything");

显然,只有在调用定义了默认参数值的方法时,你才能像这样省略参数。但是,你可以在调用任何方法时指定参数名称——有时即使没有省略任何参数,这样做也很有用,因为它可以在阅读代码时更容易看到参数的含义。如果你面对一个接受 bool 类型参数的 API,并且它不立即清楚它们的含义,这将特别有帮助。示例 3-66 创建了一个 StreamReader 和一个 StreamWriter(在 第 15 章 中描述),分别使用了多个参数的构造函数。显然,streamfilepathEncoding.UTF8 参数的含义相当明确,但对于其他参数,除非读者恰好将所有 13 个 StreamReader 和 10 个 StreamWriter 构造函数重载都记在心中,否则可能会是个谜。(此处显示的 using declaration 语法在 第 7 章 中有描述。)

示例 3-66. 参数不清晰
using var r = new StreamReader(stream, Encoding.UTF8, true, 8192, false);
using var w = new StreamWriter(filepath, true, Encoding.UTF8);

虽然此处不需要参数名称,但我们可以通过包含一些参数名称使代码更易于理解。如 示例 3-67 所示,我们可以自由地命名那些比较神秘的参数,只要我们为所有参数提供参数。

示例 3-67. 通过命名参数来提高清晰度
using var r = new StreamReader(stream, Encoding.UTF8,
  detectEncodingFromByteOrderMarks: true, bufferSize: 8192, leaveOpen: false);
using var w = new StreamWriter(filepath, append: true, Encoding.UTF8);

了解 C# 如何实现默认参数值非常重要,因为它对库的设计演进有影响。当你调用一个方法但未提供所有参数时,就像 示例 3-65 一样,编译器会生成代码,按照正常方式传递一整套参数。它实际上重写了你的代码,添加了你省略的参数。这一点的重要性在于,如果你编写了定义此类默认参数值的库,如果你更改了默认值,就会遇到问题。使用旧版本库编译的代码将把旧的默认值复制到调用位置,并且不会在不重新编译的情况下获取新值。

过载

有时您会看到另一种允许省略参数的机制的替代方法,它避免在调用点将默认值固定到其中:重载。这是一个略显煽情的术语,用来描述一个单一的名称或符号可以被赋予多个含义的相当平凡的想法。实际上,我们在构造函数中已经看到了这种技术——在示例 3-42 中,我定义了一个主要构造函数来完成实际工作,然后定义了另外两个调用该构造函数的构造函数。我们可以在方法中使用同样的技巧,正如示例 3-68 所示。

示例 3-68. 方法重载
public static void Blame(string perpetrator, string problem)
{
     Console.WriteLine($"I blame {perpetrator} for {problem}.");
}

public static void Blame(string perpetrator)
{
    Blame(perpetrator, "the downfall of society");
}

public static void Blame()
{
    Blame("the youth of today", "the downfall of society");
}

在某种意义上,这比默认参数值略显不灵活,因为调用Blame方法的代码不再有任何方法可以为problem参数指定一个值,同时获取默认的perpetrator(尽管通过添加一个不同名称的方法很容易解决)。另一方面,方法重载提供了两个潜在的优势:它允许您在必要时在运行时决定默认值,并且还提供了一种使outref参数变为可选的方法。这些参数需要对局部变量的引用,因此无法定义默认值,但是如果需要,您总是可以提供有和没有这些参数的重载。而且您可以同时使用这两种技术——您可以主要依赖于可选参数,仅在需要时使用重载来使outref参数被省略。

使用params关键字的可变参数数量

有些方法需要能够根据不同情况接受不同数量的数据。例如,我在本书中多次使用的显示信息的机制。在大多数情况下,我向Console.WriteLine传递一个简单的字符串,当我想要格式化和显示其他信息时,我使用字符串插值将表达式嵌入到字符串中。然而,正如你在第二章中记得的那样,在我们想要将大量表达式嵌入到字符串中时,字符串插值可能变得笨拙,可能更适合使用较旧的string.Format方法,如示例 3-69 所示。

示例 3-69. 字符串格式化
var r = new Random();
Console.WriteLine(string.Format(
    "{0}, {1}, {2}, {3}",
    r.Next(10), r.Next(10), r.Next(10), r.Next(10)));

如果你查看string.Format的文档,你会发现它提供了几种不同数量参数的重载。重载的数量必须是有限的,但是如果你尝试一下,你会发现这仍然是一个开放式的安排。你可以在字符串后面传递任意多的参数,并且占位符中的数字可以随需要而高到引用这些参数。在示例 3-69 的最后一行,在字符串后面传递了四个参数,即使string类没有定义接受那么多参数的重载,它也能正常工作。

string.Format 方法的一个特定重载在您传递字符串后的参数数量超过一定数量(实际上是超过三个)时接管操作。这个重载只接受两个参数:一个 string 和一个 object[] 数组。编译器为调用该方法创建的代码会构建一个数组来容纳字符串后的所有参数,并传递该数组。因此,示例 3-69 的最终语句实际上等效于 示例 3-70 中的代码。(第 5 章 描述了数组。)

示例 3-70. 明确传递多个参数作为数组
Console.WriteLine(string.Format(
    "{0}, {1}, {2}, {3}",
    new object[] { r.Next(10), r.Next(10), r.Next(10), r.Next(10) }));

编译器只会对用 params 关键字注释的参数执行此操作。示例 3-71 显示了相关 string.Format 方法声明的样子。

示例 3-71. params 关键字
public static string Format(string format, params object[] args)

params 关键字只能出现在方法的最后一个参数上,并且该参数的类型必须是数组。在这种情况下,它是 object[],这意味着我们可以传递任何类型的对象,但您可以更具体地限制可以传递的内容。

注意

当方法被重载时,C# 编译器会寻找最匹配提供的参数的方法。只有在没有更具体的匹配时,它才会考虑使用带有 params 参数的方法。

您可能会想知道为什么 string 类要提供接受一个、两个或三个 object 参数的重载。这个 params 版本的存在似乎使它们变得多余 —— 它允许您在字符串后传递任意数量的参数,那么接受特定数量参数的重载有什么意义呢?这些重载存在的目的是为了避免分配数组。这并不意味着数组特别昂贵;它们的成本不比相同大小的其他对象更高。然而,分配内存并非免费。您分配的每个对象最终都必须由垃圾收集器释放(除了整个程序生命周期内都存在的对象),因此减少分配次数通常有利于性能。因此,运行库中大多数接受通过 params 变量数量参数的 API 也提供了不需要分配数组即可传递少量参数的重载。

本地函数

您可以在其他方法内部定义方法。这些称为本地函数,示例 3-72 定义了其中的两个。(您还可以将它们放在其他类似方法的功能中,如构造函数或属性访问器中。)

示例 3-72. 本地函数
static double GetAverageDistanceFrom(
    (double X, double Y) referencePoint,
    (double X, double Y)[] points)
{
    double total = 0;
    for (int i = 0; i < points.Length; ++i)
    {
        total += GetDistanceFromReference(points[i]);
    }
    return total / points.Length;

    double GetDistanceFromReference((double X, double Y) p)
    {
        return GetDistance(p, referencePoint);
    }

    static double GetDistance((double X, double Y) p1, (double X, double Y) p2)
    {
        double dx = p1.X - p2.X;
        double dy = p1.Y - p2.Y;
        return Math.Sqrt(dx * dx + dy * dy);
    }
}

使用局部函数的一个原因是它们可以通过将步骤移到命名方法中使代码更易于阅读——通过调用GetDistance方法来看到发生了什么比在内联计算时更容易。请注意可能存在开销,尽管在这个特定的例子中,当我在.NET 6.0 上运行此代码的发布版本时,JIT 编译器足够聪明,可以内联这里的两个局部调用,因此这两个局部函数消失了,而Get⁠Ave⁠ra⁠ge​Dis⁠tan⁠ceF⁠rom最终只是一个方法。所以我们在这里没有付出任何代价,但是对于更复杂的嵌套函数,JIT 编译器可能决定不内联。当这种情况发生时,了解 C#编译器如何使这段代码工作是很有用的。

这里的GetDistanceFromReference方法接受一个元组参数,但它使用由其包含方法定义的referencePoint变量。为了使其工作,C#编译器将该变量移动到一个生成的struct中,并作为隐藏参数传递给GetDistanceFromReference方法。这就是单个局部变量可以被两个方法访问的方式。由于这个生成的struct是通过引用传递的,所以在这个例子中,referencePoint变量仍然可以保留在堆栈上。然而,如果获取指向本地方法的委托,以这种方式共享的任何变量必须移动到一个存在于垃圾回收堆上的class中,这将产生更高的开销。(详见第七章和第九章了解更多详情。)如果你想避免这种开销,你可以始终不在内外方法之间共享任何变量。你可以通过将static关键字应用于局部函数来告诉编译器这是你的意图,就像示例 3-72 中的GetDistance所做的那样。这将导致如果方法尝试使用其包含方法的变量,编译器将报告错误。

除了提供一种分割方法以提升可读性的方式外,局部函数有时也用于解决一些迭代器的限制(见第五章)和async方法(见第十七章)。这些是可能在执行过程中返回部分结果然后稍后继续的方法,这意味着编译器需要安排将它们的所有局部变量存储在一个对象中,该对象存储在堆上,以便这些变量可以存活所需的时间。这阻止这些方法声明某些类型的变量,例如引用变量或Span<T>(在第十八章中描述)。在需要同时使用asyncSpan<T>的情况下,将使用后者的代码移动到位于async函数内部的局部非async函数是常见的。这使得局部函数能够使用这些受限制类型的局部变量。

表达式主体方法

如果您编写的方法足够简单,仅由单个返回语句组成,您可以使用更简洁的语法。示例 3-73 展示了从示例 3-72 的GetDistanceFromReference方法的另一种编写方式。(如果您按顺序阅读本书,您可能已经注意到我在其他一些示例中已经使用了这种方式。)顺便说一句,我不能为GetDistance这个方法做同样的事情,因为它包含多个语句。

示例 3-73. 表达式主体方法
double GetDistanceFromReference((double X, double Y) p)
    => GetDistance(p, referencePoint);

您可以写=>,后面跟随本来应该跟在return关键字后面的表达式,而不是方法体。这种=>语法故意类似于您可以用于编写内联函数和构建表达式树的 lambda 语法。这些在第九章中讨论。但是,在使用=>编写表达式主体成员时,它只是一种方便的缩写。代码的工作方式完全就像您编写了一个只包含return语句的完整方法一样。

扩展方法

C#允许您编写看起来像是现有类型的新成员的方法。扩展方法就是这样的方法,看起来像普通的静态方法,但在第一个参数之前加了this关键字。您只能在静态类中定义扩展方法。示例 3-74 添加了一个名为Show的不太有用的扩展方法到string中。

示例 3-74. 扩展方法
namespace MyApplication;

public static class StringExtensions
{
    public static void Show(this string s)
    {
        Console.WriteLine(s);
    }
}

我在这个例子中展示了命名空间声明,因为命名空间很重要:只有在为扩展定义的命名空间编写了using指令,或者您正在编写的代码与扩展方法定义在同一个命名空间中时,扩展方法才可用。在既不做这些事情的代码中,string类看起来将很正常,并且不会获得示例 3-74 定义的Show方法。然而,像示例 3-75 这样的代码,定义在与扩展方法相同的命名空间中,将会发现该方法是可用的。

示例 3-75. 由于命名空间声明可用的扩展方法
`namespace` `MyApplication``;`

internal class Showy
{
    public static void Greet()
    {
        `"Hello"``.``Show``(``)``;`
    }
}

在示例 3-76 中的代码位于不同的命名空间,但也可以访问扩展方法,这要归功于using指令。

示例 3-76. 由于using指令可用的扩展方法
`using` `MyApplication``;`

namespace Other;

internal class Vocal
{
    public static void Hail()
    {
        "Hello".Show();
    }
}

扩展方法并不真正成为它们所定义的类的成员——在这些示例中,string类并没有真正获得额外的方法。这只是 C#编译器维护的一种错觉,即使在隐式方法调用发生的情况下也是如此。这在需要特定方法的 C#功能中特别有用。在第二章中,您看到foreach循环依赖于GetEnumerator方法。我们将在第十章中看到的许多 LINQ 功能也依赖于特定方法的存在,正如在第十七章中描述的异步语言功能一样。在所有这些情况下,您可以通过编写适当的扩展方法为不直接支持它们的类型启用这些语言功能。

属性

类和结构体可以定义属性,实质上它们只是伪装成方法。要访问属性,您使用看起来像是访问字段的语法,但实际上会调用一个方法。属性可以用于传达意图。当某物被公开为属性时,暗示它代表对象的信息,而不是对象执行的操作,因此读取属性通常是廉价的,并且不应该具有重大的副作用。另一方面,方法更有可能导致对象执行某些操作。

当然,由于属性只是一种方法,没有任何强制规定这一点。您可以自由地编写一个需要运行数小时并且在读取其值时会显著更改应用程序状态的属性,但这将是设计代码的一种相当糟糕的方式。

属性通常提供一对方法:一个用于获取值,另一个用于设置值。示例 3-77 展示了一个非常常见的模式:一个具有getset方法的属性,用于访问字段。为什么不直接将字段设为公共的?通常不建议这样做,因为这样会使外部代码有可能在对象不知情的情况下改变其状态。可能在代码的未来版本中,对象需要在每次值更改时执行某些操作——比如更新 UI。无论如何,由于属性包含代码,它们比公共字段更灵活。例如,您可能希望以与属性返回值不同的格式存储数据,或者甚至可以实现一个属性,它根据其他属性计算其值。使用属性的另一个原因是一些系统要求这样做——例如,一些 UI 数据绑定系统只能接受属性。此外,一些类型不支持实例字段;本章后面将展示如何使用接口定义抽象类型,接口可以包含属性但不能包含实例字段。

示例 3-77. 具有简单属性的类
public class HasProperty
{
    private int _x;
    `public` `int` `X`
    `{`
        `get`
        `{`
            `return` `_x``;`
        `}`
        `set`
        `{`
            `_x` `=` `value``;`
        `}`
    `}`
}
注意

set访问器内部,value具有特殊含义。它是上下文关键字—在某些上下文中,语言将其视为关键字。在属性外部,您可以将value用作标识符,但在属性内部,它代表调用者希望分配给属性的值。

在整个get体仅为一个返回语句的情况下,或者set是一个单一表达式语句的情况下,您可以使用示例 3-78 中显示的表达式体成员语法。(这与示例 3-73 中显示的方法语法非常相似。)

示例 3-78. 表达式体getset
public class HasProperty
{
    private int _x;
    public int X
    {
        get => _x;
        set => _x = value;
    }
}

示例 3-77 和 3-78 中的模式非常常见,以至于 C#可以为您大部分写好。 示例 3-79 或多或少是等效的——编译器为我们生成一个字段,并生成getset方法,用于检索和修改与示例 3-77 中相同的值。唯一的区别是,同一类中的其他代码不能直接访问示例 3-79 中的字段,因为编译器将其隐藏。在语言规范中,官方名称称为自动实现属性,但通常简称为自动属性

示例 3-79. 自动属性
public class HasProperty
{
    public int X { get; set; }
}

无论您使用显式还是自动属性,这只是一对方法的花哨语法。 get方法返回属性声明类型的值—在本例中是int—而 setter 通过隐式的value参数接收该类型的单个参数。 示例 3-77 利用该参数更新字段。当然,您并不强制将值存储在字段中。事实上,没有任何东西甚至要求您以任何方式使getset方法相关联—您可以编写一个返回随机值的 getter 和一个完全忽略您提供的值的 setter。但是,仅仅因为您能够这样做,并不意味着您应该这样做。实际上,任何使用您的类的人都希望属性能够记住它们被赋予的值,至少因为在使用中,属性看起来就像字段一样,如示例 3-80 所示。

Example 3-80. 使用属性
var o = new HasProperty();
o.X = 123;
o.X += 432;
Console.WriteLine(o.X);

如果您使用完整语法(如示例 3-77 所示)或表达式体形式(如示例 3-78 所示)来实现属性,则可以省略setget中的任一项,以创建只读或只写属性。只读属性对于对象生命周期内固定的方面(如标识符或从其他属性计算而来的属性)非常有用。只写属性则不太常见,尽管它们可能在依赖注入系统中出现。使用示例 3-79 中显示的自动属性语法无法创建只写属性,因为您无法对设置的值执行有用的操作。

只读属性有两种变体。有时,拥有公共只读属性但是您的类可以自由更改其值是很有用的。您可以定义一个属性,其中 getter 是公共的,但 setter 不是(或者对于写入属性而言反之亦然)。您可以使用完整语法或自动语法来实现此目的。示例 3-81 展示了后者的外观。

示例 3-81. 带私有 setter 的自动属性
public int X { get; private set; }

如果希望属性在构造后其值不再改变(即只读属性),则可以在使用自动属性语法时完全省略 setter,如示例 3-82 所示。

示例 3-82. 没有 setter 的自动属性
public int X { get; }

没有 setter 且没有直接访问的字段时,您可能会想知道如何设置此类属性的值。答案是,在对象的构造函数内部,该属性看起来是可设置的(如果省略set,实际上并没有 setter —— 当您在构造函数中“设置”属性时,编译器生成的代码只是直接设置后备字段)。只读自动属性实际上等效于包装在普通只读属性中的readonly字段。与字段一样,您还可以编写初始化程序来提供初始值。示例 3-83 使用了这两种风格;如果使用不带参数的构造函数,则属性的值将为 42;如果使用其他构造函数,则属性将具有您提供的任何值。

示例 3-83. 使用没有 setter 的自动属性初始化
public class WithAutos
{
    public int X { get; } = 42;

    public WithAutos()
    {
    }

    public WithAutos(int val)
    {
        X = val;
    }
}

顺便说一下,这种初始化程序语法适用于可读写属性。您还可以在希望使用位置语法但希望属性可写入的record类型中使用它,正如示例 3-84 所示。这略有不同,因为记录类型提供的功能主要旨在更轻松地定义不可变数据类型。但是支持可变性,并且在要求某些属性在初始化时必须初始化为非空引用类型时,可设置的功能可能会很有用。

Example 3-84. 要求初始值但允许稍后修改的记录
public record EnforcedInitButMutable(string Name, string FavoriteColor)
{
    public string Name { get; set; } = Name;
    public string FavoriteColor { get; set; } = FavoriteColor;
}

由于位置语法最终只是定义构造函数的一种方式,在像示例 3-84 这样的情况下,您可能会诱使使用更常规的大小写名称来命名构造函数参数,例如,namefavoriteColor。但是,这样做的效果将是创建一个具有四个属性的记录:nameNamefavoriteColorFavoriteColor。如果您使用位置语法,则记录类型将具有该语法中命名的所有属性。这里看起来我们定义了相同的属性两次,但实际上重复的名称是 C#知道我们只是在说我们希望在此处有一些不同于正常生成的属性。

在示例 3-85 中展示了只读属性主题的变体。在set的位置,我们使用了 init 关键字。(顺便说一下,这是由于位置语法而生成的属性在您没有自定义它们时的样子。)

Example 3-85. 具有只读属性初始化的类
public class WithInit
{
    public int X { get; init; }
}

这与只读属性几乎完全相同:它指示对象初始化后不得修改属性。但是,有一个重要的区别:当您使用此语法时,编译器会生成一个公共设置器。在对象初始化程序中尝试修改属性后,编译器会拒绝编译代码,因此对于大多数场景,它的行为就像只读属性一样,但这使得一个关键场景成为可能:它允许您在对象初始化程序中设置属性。稍后我将详细描述对象初始化程序,但示例 3-86 显示了一个简单的示例。

Example 3-86. 设置只读属性初始化
var x = new WithInit
{
    X = 42
};

这是您可以使用只读属性初始化的另一个额外位置—除此之外,您只能在可以设置只读属性的地方设置它们。

警告

仅编译器强制执行对只读属性的限制。从 CLR 的角度来看,它们是可读写的属性,这意味着如果你从某些不识别这个 init-only 特性(这在 C# 9.0 中是新功能)的语言中使用此类属性,或者使用间接手段如反射(见第十三章),它可以在任何时候设置属性,而不仅仅是在初始化期间。

Init-only 属性提供了一种方法,使得不可变的 struct 类型可以使用与记录类型相同的 with 语法。示例 3-87 展示了在之前各种示例中使用的 Point 类型的另一种变体,这次特色是 init-only 属性。

示例 3-87. 带有 init-only 属性的 readonly struct
public readonly struct Point
{
    public Point(double x, double y)
    {
        X = x;
        Y = y;
    }

    public double X { get; init; }
    public double Y { get; init; }
}

这定义了属性的设置器,通常不允许在 readonly struct 中使用,但因为它们只能在初始化期间设置,所以在这里不会引起问题。它们使得像示例 3-88 这样的代码得以实现。

示例 3-88. 在非记录 readonly struct 上使用 with 语法
Point p1 = new(0, 10);
Point p2 = p1 with { X = 20 };
注意

因为你可以在非记录 struct 上使用 with 语法,你可能想知道它是否也适用于非记录 class。不适用。with 关键字依赖于能够创建现有实例的副本。这对于 struct 类型并不是问题——它们的定义特性是可以被复制的。但是对于 class 类型的实例,没有一种可靠的通用方法来克隆实例,因此对于引用类型,with 只在记录类型上起作用,因为记录类型是可靠可克隆的。

有时候,编写一个只读属性,其值完全是基于其他属性计算而得,是很有用的。例如,如果你已经编写了一个代表向量的类型,拥有名为XY的属性,你可以添加一个属性,返回这个向量的大小,从这两个属性计算而得,就像在示例 3-89 中展示的那样。

示例 3-89. 一个计算属性
public double Magnitude
{
    get
    {
        return Math.Sqrt(X * X + Y * Y);
    }
}

有一种更简洁的写法。我们可以使用在示例 3-78 中展示的表达式主体语法,但对于只读属性,我们可以再进一步:你可以直接在属性名后面放置 => 和表达式。(这使我们可以省略大括号和 get 关键字。)示例 3-90 与示例 3-89 完全等效。

示例 3-90. 表达式主体只读属性
public double Magnitude => Math.Sqrt(X * X + Y * Y);

谈到只读属性,有一个需要注意的重要问题,涉及属性、值类型和不可变性。

属性和可变值类型

如前所述,如果值类型是不可变的,它们通常会更加简单直接,但这不是必需的。避免修改可修改的值类型的一个原因是,你可能会意外修改值的副本,而不是你打算修改的那个,如果你定义一个使用可修改值类型的属性,这个问题就会显现出来。位于System.Windows命名空间中的Point结构体是可修改的,因此我们可以用它来说明这个问题。示例 3-91 定义了这种类型的Location属性。

示例 3-91. 使用可修改值类型的属性
using System.Windows;

public class Item
{
    public Point Location { get; set; }
}

Point类型定义了名为XY的读/写属性,因此,给定类型为Point的变量,可以设置这些属性。但是,如果你试图通过另一个属性设置这些属性中的任何一个,代码将无法编译通过。示例 3-92 尝试了这一点——它试图修改从Item对象的Location属性检索到的PointX属性。

示例 3-92. 错误:不能修改值类型属性的属性
var item = new Item();
item.Location.X = 123;  // Will not compile

此示例会产生以下错误:

error CS1612: Cannot modify the return value of 'Item.Location' because it is
not a variable

C#将字段视为变量,以及局部变量和方法参数,因此,如果我们修改示例 3-91,使Location成为一个公共字段而不是属性,示例 3-92 将会编译通过,并按预期工作。但为什么它不适用于属性呢?请记住,属性只是方法,因此示例 3-91 与示例 3-93 基本等效。

示例 3-93. 用方法替换属性
using System.Windows;

public class Item
{
    private Point _location;
    public Point get_Location()
    {
        return _location;
    }
    public void set_Location(Point value)
    {
        _location = value;
    }
}

由于Point是一个值类型,get_Location返回一个副本。你可能会想知道我们是否可以使用前面描述的ref返回特性。对于普通方法,我们当然可以,但是在属性中做这些有一些限制。首先,你不能使用ref类型定义自动属性。其次,你不能使用ref类型定义可写属性。但是,正如示例 3-94 所示,你可以定义一个只读的ref属性。

示例 3-94. 返回引用的属性
using System.Windows;

public class Item
{
    private Point _location;

    public ref Point Location => ref _location;
}

有了Item的这种实现,示例 3-92 中的代码现在可以正常工作了。(具有讽刺意味的是,为了使属性可修改,我们不得不将其变成只读属性。)

在 C# 7.0 中添加 ref 返回之前,没有办法使其工作。 所有属性的所有可能实现最终都会返回属性值的副本,因此如果编译器确实允许 示例 3-92 编译,我们将设置由属性返回的副本上的 X 属性,而不是属性代表的 Item 对象中的实际值。 示例 3-95 显式地进行了此操作,并且实际上会编译—如果我们非常清楚地表明我们确实想要这样做,编译器将允许我们自食其果。 并且通过这个代码版本,很明显这不会修改 Item 对象中的值。

示例 3-95. 明确复制
var item = new Item();
Point location = item.Location;
location.X = 123;

但是,在 示例 3-94 中的属性实现中, 示例 3-92 中的代码确实编译,并且行为类似于 示例 3-96 中显示的代码。 在这里,我们可以看到我们获取了对 Point 的引用,因此当我们设置其 X 属性时,我们实际上是在操作其引用的内容(在这种情况下是 Item 中的 _location 字段),而不是本地副本。

示例 3-96. 明确引用
var item = new Item();
ref Point location = ref item.Location;
location.X = 123;

多亏了语言的相对较新的增强功能,现在可以使其工作,尽管在这里可以说封装性有所减少:行为现在更或多或少等同于定义一个公共字段。 并且很容易搞错。 幸运的是,大多数值类型是不可变的,这个问题只会在可变值类型中出现。

注意

不可变性并不能完全解决问题——您仍然无法编写可能想要的代码,例如 item.Location.X = 123。 但至少不可变结构不会让您误以为您应该能够这样做。

由于所有属性实际上只是方法(通常成对出现),理论上除了 set 方法使用的隐式 value 参数之外,它们可以接受更多参数。 CLR 允许这样做,但是 C# 除了一种特殊类型的属性——索引器之外不支持。

索引器

索引器 是一个接受一个或多个参数的属性,并且使用与数组相同的语法进行访问。 当您编写包含对象集合的类时,这非常有用。 运行库提供的一个集合类示例 示例 3-97 使用 List<T>。 它本质上是一个可变长度的数组,并且由于其索引器,在第二和第三行使用。 (我将在 第五章 中详细描述数组和集合类型。 以及我将在 第四章 中描述泛型类型,其中 List<T> 是一个示例。)

示例 3-97. 使用索引器
var numbers = new List<int> { 1, 2, 1, 4 };
`numbers``[``2``]` `+``=` `numbers``[``1``]``;`
`Console``.``WriteLine``(``numbers``[``0``]``)``;`

从 CLR 的角度来看,索引器是与任何其他属性一样的属性,只是它被指定为默认属性。这个概念是从旧版基于 COM 的 Visual Basic 中保留下来,并且被带入到.NET 中,尽管 C#大部分情况下忽略了它。索引器是唯一一种将默认属性视为特殊的 C#特性。如果一个类指定某个属性为默认属性,并且该属性至少接受一个参数,C#将允许您通过索引器语法使用该属性。

声明索引器的语法有些特殊。示例 3-98 展示了一个只读索引器。您可以添加一个set访问器使其变为读/写,就像处理任何其他属性一样。⁷

示例 3-98. 带有索引器的类
public class Indexed
{
    public string this[int index]
    {
        get => index < 5 ? "Foo" : "bar";
    }
}

C#支持多维索引器。这些是具有多个参数的索引器—因为属性实际上只是方法,您可以为参数定义任意数量的索引器。索引器还支持重载,因此您可以定义任意数量的索引器,只要每个索引器使用一组不同的参数类型。

正如您可能从第二章中记得的那样,C#提供了空值条件运算符。在那一章中,我们看到它被用于访问属性和字段,例如,myString?.Length的类型将是int?—如果myStringnull,其值将为null,否则为Length属性的值。还有一种空值条件运算符的形式,可以用于索引器,如示例 3-99 所示。

示例 3-99. 空值条件索引访问
string? s = objectWithIndexer?[2];

与空值条件字段或属性访问类似,这会生成代码来检查左侧部分(在本例中是objectWithIndexer)是否为 null。如果是,整个表达式将评估为 null;只有在表达式的左侧部分不为 null 时才会调用索引器。这实际上相当于在示例 3-100 中展示的代码。

示例 3-100. 等效于空值条件索引访问的代码
string? s = objectWithIndexer == null ? null : objectWithIndexer[2];

这种空值条件索引语法也适用于数组。

初始化器语法

您通常希望在创建对象时设置某些属性,因为可能无法通过构造函数参数提供所有相关信息。这在表示控制某些操作的设置的对象中特别常见。例如,ProcessStartInfo类型使您能够配置新创建的操作系统进程的许多不同方面。它有 16 个属性,但在任何特定场景中,您通常只需设置其中的几个。即使假设运行的文件名应始终存在,仍然存在 32768 种可能的属性组合。您不希望为其中每一个组合编写构造函数。

在实践中,一个类可能为一些特别常见的组合提供构造函数,但对于其他一切,您只需在构造之后设置属性。C# 提供了一种简洁的方式来创建对象并在单个表达式中设置其一些属性。示例 3-101 使用了这种 对象初始化器 语法。这也适用于字段,尽管具有可写公共字段相对较不常见。

示例 3-101. 使用对象初始化器
Process.Start(new ProcessStartInfo
{
    FileName = "cmd.exe",
    UseShellExecute = true,
    WindowStyle = ProcessWindowStyle.Maximized,
});

您也可以提供构造函数参数。示例 3-102 与 示例 3-101 具有相同的效果,但选择将文件名作为构造函数参数提供。(这是 ProcessStartInfo 允许以这种方式提供的少数属性之一。)

示例 3-102. 使用构造函数和对象初始化器
Process.Start(new ProcessStartInfo("cmd.exe")
{
    UseShellExecute = true,
    WindowStyle = ProcessWindowStyle.Maximized,
});

对象初始化器语法可以消除在设置所需属性时引用对象的单独变量的需要。正如示例 3-101 和 3-102 所示,您可以直接将以这种方式初始化的对象作为参数传递给方法。更一般地说,这种初始化样式可以完全包含在单个表达式中。在使用表达式树的场景中尤为重要,我们将在 第九章 中探讨这一点。初始化器的另一个重要优点是,它们可以使用 init 访问器——当属性定义了 init 访问器,并且没有可用于设置该属性的构造函数重载时,对象初始化器语法是设置该属性的唯一机制。

有一种变体的对象初始化器语法,可以在对象初始化器中为索引器提供值。示例 3-103 使用此语法来初始化字典。(第五章 详细描述了字典和其他集合类型。)

示例 3-103. 在对象初始化器中使用索引器
var d = new Dictionary<string, int>
{
 ["One"] = 1,
 ["Two"] = 2,
 ["Three"] = 3
};

运算符

类和结构体可以为运算符定义自定义含义。我之前展示了一些自定义运算符:示例 3-29 提供了==!=的定义。类或结构体可以支持在第二章中引入的几乎所有算术、逻辑和关系运算符。在表 2-3、2-4、2-5 和 2-6 中展示的运算符中,除了条件 AND (&&) 和条件 OR (||) 运算符外,你可以为其他所有运算符定义自定义含义。但是这些运算符是基于其他运算符进行评估的,因此通过定义逻辑 AND (&)、逻辑 OR (|)以及逻辑truefalse运算符(稍后描述),你可以控制&&||对于你的类型的工作方式,尽管你不能直接实现它们。

所有自定义运算符实现都遵循一定的模式。它们看起来像静态方法,但在通常期望方法名的位置上,你会看到operator关键字,后跟你想要为其定义自定义含义的运算符。之后是一个参数列表,其中参数的数量由运算符所需的操作数数量决定。示例 3-104 展示了如何为本章前面定义的Counter类实现二元+运算符。

示例 3-104. 实现+运算符
public static Counter operator +(Counter x, Counter y)
{
    return new Counter { _count = x._count + y._count };
}

虽然参数数量必须与运算符所需的操作数数量匹配,但只有一个参数必须与定义类型相同。示例 3-105 利用这一点允许将Counter类添加到int中。

示例 3-105. 支持其他操作数类型
public static Counter operator +(Counter x, int y)
{
    return new Counter { _count = x._count + y };
}

public static Counter operator +(int x, Counter y)
{
    return new Counter { _count = x + y._count };
}

在 C#中,某些运算符必须成对定义。我们已经看到了==!=运算符的情况——定义一个而不定义另一个是不合法的。同样,如果你为你的类型定义了>运算符,那么你必须同时定义<运算符,反之亦然。对于>=<=也是如此。(还有一对运算符是truefalse,但它们稍有不同;我稍后会详细说明。)

当你为存在复合赋值运算符的运算符重载时,实际上你定义了两者的行为。例如,如果你为+运算符定义了自定义行为,那么+=运算符也会自动工作。

operator关键字还可以定义自定义转换——将你的类型转换为其他类型或从其他类型转换。例如,如果我们希望能够将Counter对象转换为int并且反之亦然,我们可以将示例 3-106 中的两个方法添加到该类中。

示例 3-106. 转换运算符
public static explicit operator int(Counter value)
{
    return value._count;
}

public static explicit operator Counter(int value)
{
    return new Counter { _count = value };
}

这里我使用了explicit关键字,这意味着这些转换可以使用强制类型转换的语法,正如示例 3-107 所示。

示例 3-107. 使用显式转换运算符
var c = (Counter) 123;
var v = (int) c;

如果你使用implicit关键字而不是explicit,你的转换可以在不需要转换的情况下进行。在第二章中我们看到,某些转换是隐式进行的:在特定情况下,C#会自动提升数值类型。例如,你可以在期望long的地方使用int,比如作为方法的参数或赋值。从intlong的转换总是成功的,不会丢失信息,所以编译器会自动生成代码来执行转换,而不需要显式的类型转换。如果你编写implicit转换运算符,C#编译器将会静默地以完全相同的方式使用它们,使得你的自定义类型可以在期望其他类型的地方使用。(事实上,C#规范定义了如从intlong的数值提升作为内置的隐式转换。)

隐式转换运算符并不是你经常需要编写的东西。只有当你可以达到与内置提升相同的标准时才应该这样做:转换必须始终可能,并且不应抛出异常。此外,转换应该是不令人意外的——implicit转换有点狡猾,因为它们允许你在看起来不像是调用方法的代码中调用方法。因此,除非你打算混淆其他开发人员,否则你应该只在似乎毫无疑问的地方编写隐式转换。

C#还识别出两个运算符:truefalse。如果你定义了其中一个,你必须同时定义另一个。这两个运算符有点奇怪,因为虽然 C#规范将它们定义为一元运算符重载,但它们并不直接对应于你可以在表达式中编写的任何运算符。它们在两种情况下起作用。

如果你没有定义一个隐式转换到bool的方法,但是你定义了truefalse运算符,那么当你把你的类型作为if语句、dowhile循环的表达式,或者作为for循环的条件表达式时,C#将使用true运算符。然而,编译器更倾向于隐式的bool运算符,所以这并不是truefalse运算符存在的主要原因。

truefalse 操作符的主要场景是允许你的自定义类型作为条件布尔运算符(&&||)的操作数之一。请记住,这些操作符仅在第一个结果不能完全确定结果时才会评估其第二个操作数。如果您想定制这些操作符的行为,则不能直接实现它们。相反,您必须定义操作符的非条件版本(&|),并且还必须定义 truefalse 操作符。在评估 && 时,C# 将在第一个操作数上使用您的 false 操作符,如果这表明第一个操作数是假的,则不会评估第二个操作数。如果第一个操作数不是假的,则将评估第二个操作数,然后将两者传递到您的自定义 & 操作符中。|| 操作符的工作方式类似,但使用 true| 操作符分别。

或许你会想为什么我们需要特别的 truefalse 操作符 —— 难道我们不能简单地定义到 bool 类型的隐式转换吗?事实上我们可以,如果我们这样做而不是提供 &|truefalse,C# 将使用它来实现我们类型的 &&||。然而,有些类型可能希望表示既非真也非假的值 —— 可能存在一个第三个值,表示未知状态。true 操作符允许 C# 提出问题:“这绝对是真吗?” 并且对象可以回答 “不是” 而不意味着它绝对是假的。转换到 bool 不支持这一点。

注意

truefalse 操作符自 C# 的第一个版本以来就存在,并且它们的主要应用是使得支持可空布尔值的类型可以具有与许多数据库提供的语义类似的实现。C# 2.0 中添加的可空类型支持提供了更好的解决方案,因此这些操作符不再特别有用,但仍然有一些旧的运行库部分依赖于它们。

不能重载任何其他操作符。例如,不能为用于访问方法成员的 . 操作符或条件运算符 (? :) 或空值合并运算符 (??) 定义自定义含义。

事件

结构体和类可以声明事件。这种成员类型使得类型在发生有趣的事情时能够提供通知,采用订阅模型。例如,表示按钮的 UI 对象可能定义一个 Click 事件,您可以编写代码订阅该事件。

事件依赖于委托,而由于第九章专门讨论了这些主题,我在这里不会详细介绍。我仅提到它们是因为否则这一类型成员部分会显得不完整。

嵌套类型

在类、结构体或记录中可以定义的最终成员类型是嵌套类型。你可以在本章后面描述的任何类型中定义嵌套类、结构体或任何其他类型。嵌套类型可以做任何其正常对应部分可以做的事情,但它还有几个额外的功能。

当一个类型被嵌套时,你对于可访问性有更多的选择。在全局范围定义的类型只能是public或者internal,因为private没有意义,因为私有仅使得某些东西仅从其包含的类型内部访问,而在定义在全局范围内的东西时没有包含的类型。但是嵌套类型确实有一个包含类型,因此如果你定义一个嵌套类型并将其设为private,那么该类型只能在它被嵌套的类型内部使用。示例 3-108 展示了一个私有的类。

示例 3-108. 一个私有的嵌套类
internal static class FileSorter
{
    public static string[] GetByNameLength(string path)
    {
        string[] files = Directory.GetFiles(path);
        var comparer = new LengthComparer();
        Array.Sort(files, comparer);
        return files;
    }

    `private` `class` `LengthComparer` `:` `IComparer``<``string``>`
    `{`
        `public` `int` `Compare``(``string?` `x``,` `string?` `y``)`
        `{`
            `int` `diff` `=` `(``x``?``.``Length` `?``?` `0``)` `-` `(``y``?``.``Length` `?``?` `0``)``;`
            `return` `diff` `=``=` `0`
                `?` `StringComparer``.``OrdinalIgnoreCase``.``Compare``(``x``,` `y``)`
                `:` `diff``;`
        `}`
    `}`
}

私有类在这种情况下可能很有用,例如当你使用一个需要实现特定接口的 API,而你不想把该接口作为你的类型的一部分,或者像在这种情况下一样,即使你想也不能。(我的FileSorter类型是static的,所以我不能创建它的实例来传递给Array.Sort。)在这种情况下,我调用Array.Sort来按文件名长度排序文件列表。(虽然这没什么用,但看起来不错。)我提供自定义排序顺序,形式是实现了IComparer<string>接口的对象。接口的详细信息我将在下一节详细描述,但这个接口仅仅是描述了Array.Sort方法需要我们提供的内容。我编写了一个自定义类来实现这个接口。这个类仅仅是我代码的一个实现细节,所以我不想把它设为公共的。一个嵌套的私有类正是我需要的。

嵌套类型中的代码可以使用其包含类型的非公共成员。然而,嵌套类型的实例并不会自动获取对其包含类型实例的引用。如果您需要嵌套实例引用它们的容器,则需要声明一个字段来保存它,并安排进行初始化;这将与希望保留对另一个对象引用的任何对象完全相同。显然,这只适用于外部类型是引用类型的情况。

到目前为止,我们只看过类、记录和结构体,但在 C#中定义自定义类型还有其他几种方法。其中一种足够复杂,值得单独章节讨论,但还有几种较简单的方法我会在这里讨论。

接口

C#的interface关键字定义了一个编程接口。接口往往完全没有实现,尽管您可以为某些或所有方法定义默认实现。您还可以定义嵌套类型和静态字段。(但接口不能定义非静态字段。)类可以选择实现接口。如果您编写的代码是基于一个接口,它将能够与实现该接口的任何内容一起工作,而不限于与一个特定类型一起工作。

例如,.NET 运行库包括一个名为IEnumerable<T>的接口,它定义了一个表示值序列的最小成员集。(它是一个泛型接口,因此可以表示任何类型的序列。例如,IE⁠num⁠era⁠ble​<st⁠rin⁠g>是一个字符串序列。泛型类型在第四章中讨论。)如果一个方法具有类型为IEnumerable<string>的参数,您可以将其传递给任何实现该接口的类型的实例的引用,这意味着单个方法可以与数组、.NET 运行库提供的各种集合类、某些 LINQ 特性和许多其他内容一起工作。

接口声明方法、属性和事件,但不必定义它们的实现,就像示例 3-109 展示的那样。属性指示是否应存在 getter 和/或 setter,但我们用分号代替实现体。接口实际上是一个列表,列出了一个类型在想要实现接口时需要提供的成员。请注意,在.NET Framework 上,这些方法类成员是接口可以拥有的唯一类型的成员。稍后我将讨论在.NET Core 和.NET 上可用的其他成员类型,但今天你可能会遇到的大多数接口只包含这些成员类型。

示例 3-109. 一个接口
public interface IDoStuff
{
    string this[int i] { get; set; }
    string Name { get; set; }
    int Id { get; }
    int SomeMethod(string arg);
    event EventHandler? Click;
}

单独的方法成员不允许使用访问修饰符——它们的访问权限由接口本身控制。(与类一样,接口要么是public,要么是internal,除非它们是嵌套的,在这种情况下,它们可以有任何访问权限。)接口不能声明构造函数——接口只能在对象构造后说对象应该提供哪些服务。

顺便说一句,在.NET 中,大多数接口遵循以下约定:它们的名称以大写字母I开头,后面跟着一个或多个 PascalCasing 风格的单词。

类在类名后面的冒号后面的列表中声明它实现的接口,就像示例 3-110 所示的那样。它必须提供接口中列出的所有成员的实现。如果您遗漏了任何一个成员,编译器将会报错。记录类型也可以实现接口,使用类似的语法。如果记录类型使用位置语法,冒号和接口列表将在位置参数列表后出现。

示例 3-110. 实现一个接口
public class DoStuff : IDoStuff
{
    public string this[int i] { get { return i.ToString(); } set { } }
    public string Name { get; set; }
    ...etc
}

在 C#中实现接口时,通常将该接口的每个方法定义为类的公共成员。然而,有时你可能希望避免这样做。偶尔,某些 API 可能要求你实现一个污染了类 API 纯度的接口。或者更平淡地说,你可能已经定义了与接口要求的同名同签名成员执行不同操作的成员。或者更糟的是,你可能需要实现两个不同的接口,这两个接口都定义了具有相同名称和签名但需要不同行为的成员。你可以通过称为显式实现的技术解决这些问题,来定义实现特定接口成员的成员,而无需公开。示例 3-111 展示了此语法,包括从示例 3-109 中接口方法的实现。使用显式实现时,你不需要指定可访问性,并且需要用接口名称作为成员名称的前缀。

示例 3-111. 显式实现接口成员
int IDoStuff.SomeMethod(string arg)
{
    ...
}

当类型使用显式接口实现时,这些成员不能通过类型本身的引用使用。它们只有在通过接口类型的表达式引用对象时才可见。

当类实现接口时,它变成了隐式可转换为该接口类型。因此,你可以将来自示例 3-110 的DoStuff类型的任何表达式作为IDoStuff类型的方法参数传递,例如。

接口是引用类型。尽管如此,你可以在类和结构上实现接口。但是,在对结构进行此类操作时需要小心,因为当你获取一个结构的接口类型引用时,它实际上是对的引用,这是一种以可以通过引用引用的方式保存结构的对象。我们将在第七章中讨论装箱。

默认接口实现

接口定义可以包含一些实现细节。这个相对较新的特性(在 C# 8.0 中添加)依赖于运行时支持,因此仅适用于目标为.NET Core 3.1 或更高版本,或者.NET Standard 2.1 或更高版本的代码,因此无法在.NET Framework 上使用。但只要你使用合适的运行时,你的接口定义可以提供静态字段、嵌套类型,以及方法、属性访问器的主体,以及事件的addremove方法(我将在第九章中描述)。示例 3-112 展示了如何使用它来定义属性的默认实现。

示例 3-112. 带有默认属性实现的接口
public interface INamed
{
    int Id { get; }
    string Name => $"{this.GetType()}: {this.Id}";
}

如果一个类选择实现 INamed 接口,它只需要为这个接口的 Id 属性提供实现。它也可以提供一个 Name 属性,但这是可选的。如果类没有定义自己的 Name,则会使用接口中的定义。

默认接口实现为接口长期存在的一个局限性提供了部分解决方案:如果你定义了一个接口,然后让其他代码使用它(例如通过类库),那么向该接口添加新成员可能会对已存在使用它的代码造成问题。调用接口方法的代码不会有问题,因为它并不知道新成员的存在,但是任何实现你接口的类,在 C# 8.0 之前,如果你添加了新成员,就会出现问题。具体类需要提供其实现的接口所有成员,因此如果接口添加了新成员,原本完整的实现现在将变得不完整。除非你有办法联系到所有实现你接口的类型的作者,并让他们添加缺失的成员,否则如果他们升级到新版本,将会给他们带来问题。

你可能会认为这只有当使用接口的代码作者有意升级到包含更新接口的库时才会成为问题,这时他们会有机会修复问题。然而,有时候库的升级可能会被强制进行。如果你编写一个应用程序,使用了多个库,每个库都是针对某些共同库的不同版本构建的,那么其中至少一个在运行时将会获取到与其编译版本不同的共同库的版本。(这种情况的典型例子是 Json.NET 库用于解析 JSON。它被广泛使用,并且有许多版本发布,因此一个应用程序通常会使用多个库,每个库依赖于 Json.NET 的不同版本。在运行时只能使用一个版本,因此它们无法满足所有的期望。)这意味着,即使你使用了语义版本控制这样的方案,在这种方案中,破坏性变更总是伴随着组件主版本号的更改,这可能并不足以避免麻烦:你可能会发现自己需要同时使用两个组件,其中一个需要某个接口的 v1.0 版本,而另一个则需要 v2.0 版本。

结果是接口基本上被冻结了:即使跨大版本更改,你也不能随时间添加新成员。但是默认接口实现放宽了这一限制:如果为新添加的成员提供了默认实现,你可以向现有接口添加新成员。这样,已实现旧版本的类型可以提供更新定义的完整实现,因为它们自动获取新添加成员的默认实现,无需任何修改。(然而,这个方法仍有一些小缺陷,使得在某些情况下仍然更倾向于使用旧的解决方案,即抽象基类。Chapter 6 描述了这些问题。因此,尽量避免修改已发布的接口,尽管默认接口实现可以提供有用的逃生口。)

除了为向后兼容性提供额外的灵活性外,默认接口实现功能还增加了三个功能:接口现在可以定义常量、静态字段和类型。 Example 3-113 显示了一个包含嵌套常量和类型的接口。

Example 3-113. 包含 const 和嵌套类型的接口
public interface IContainMultitudes
{
    public const string TheMagicWord = "Please";

    public enum Outcome
    {
        Yes,
        No
    }

    Outcome MayI(string request)
    {
        return request == TheMagicWord ? Outcome.Yes : Outcome.No;
    }
}

对于诸如这些非方法样式成员,我们需要指定其可访问性,因为在某些情况下,你可能只想为了默认方法实现而引入这些嵌套成员,在这种情况下,你希望它们是 private 的。在这种情况下,我希望相关成员对所有人都可访问,因为它们是该接口定义的一部分 API,所以我将它们标记为 public。你可能正在看这个嵌套的 Outcome 类型并想知道发生了什么。不用再纳闷了。

枚举

enum 关键字声明了一种非常简单的类型,用于定义一组命名值。 Example 3-114 显示了一个定义了一组互斥选择的 enum。你可以说这个 列举 了选项,这就是 enum 关键字得名的由来。

Example 3-114. 具有互斥选项的 enum
public enum PorridgeTemperature
{
    TooHot,
    TooCold,
    JustRight
}

enum 可以在大多数情况下使用,就像你可能使用其他任何类型一样——它可以是局部变量、字段或方法参数的类型,例如。但是使用 enum 最常见的一种方式是在 switch 语句中,正如 Example 3-115 所示。

Example 3-115. 使用 enum 进行切换
switch (porridge.Temperature)
{
case PorridgeTemperature.TooHot:
    GoOutsideForABit();
    break;

case PorridgeTemperature.TooCold:
    MicrowaveMyBreakfast();
    break;

case PorridgeTemperature.JustRight:
    NomNomNom();
    break;
}

正如这个例子所示,要引用枚举成员,必须使用类型名称限定它们。事实上,enum 只是定义一堆 const 字段的一种花哨方式。这些成员在底层都是 int 值。你甚至可以像 Example 3-116 所示一样显式指定这些值。

Example 3-116. 显式 enum
[System.Flags]
public enum Ingredients
{
    Eggs           =        0b1,
    Bacon          =       0b10,
    Sausages       =      0b100,
    Mushrooms      =     0b1000,
    Tomato         =   0b1_0000,
    BlackPudding   =  0b10_0000,
    BakedBeans     = 0b100_0000,
    TheFullEnglish = 0b111_1111
}

此示例还展示了使用enum的另一种方法。示例 3-116 中的选项不是互斥的。我在这里使用了二进制常量,这样你可以看到每个值对应于特定的位位置被设置为 1。这使得它们易于组合——EggsBacon将是 3(二进制中的 11),而EggsBaconSausagesBlackPuddingBakedBeans(我偏爱的组合)将是 103(二进制中的 1100111,或者 16 进制中的 0x67)。

注意

在组合基于标志的枚举值时,通常使用按位 OR 运算符。例如,你可以写Ing⁠red⁠ien⁠ts.​Eg⁠gs|⁠Ing⁠red⁠ien⁠ts.⁠Ba⁠con。这不仅比使用数值更容易阅读,而且在 IDE 中的搜索工具中也很有效——你可以通过右键单击其定义并选择“查找所有引用”或“转到引用”来查找使用特定符号的所有位置。你可能会遇到使用+而不是|的代码。这对某些组合有效;然而,Ingredients.TheFullEnglish + Ingredients.Eggs将是一个值为 128 的值,这不对应于任何东西,因此最好还是坚持使用|

当你声明一个设计为可以这样组合的enum时,应该使用Flags属性进行注释,该属性定义在System命名空间中。(第十四章将详细描述属性。) 示例 3-116 就是这样做的,尽管在实际应用中,如果你忘记了也不会有太大问题,因为 C#编译器并不在乎,实际上很少有工具会注意到这一点。主要的好处是,如果在enum值上调用ToString时存在Flags属性,它会注意到。对于Ingredients类型,ToString会将值 3 转换为字符串Eggs, Bacon,这也是调试器显示值的方式,而没有Flags属性,则会被视为未识别的值,你只会得到一个包含数字3的字符串。

使用这种标志样式的枚举,你很快就会用完位。默认情况下,enum使用int表示值,对于一系列互斥的值,这通常是足够的。只有在单个枚举类型中需要数十亿不同值的情况下,才会变得相当复杂。然而,每个标志位 1 位,int提供的空间只能容纳 32 个标志。幸运的是,你可以获得更多的空间,因为你可以指定不同的底层类型——你可以使用任何内置整数类型,这意味着你可以使用最多 64 位。正如示例 3-117 所示,你可以在enum类型名称后面的冒号后指定底层类型。

示例 3-117. 64 位enum
[Flags]
public enum TooManyChoices : long
{
    ...
}

所有的enum类型都是值类型,顺便说一句,就像内置的数值类型或任何结构体一样。但它们的功能非常有限。你不能定义除了常量值以外的任何成员—例如,没有方法或属性。

枚举类型有时可以提升代码的可读性。许多 API 接受一个bool来控制其行为的某些方面,但可能更好地使用enum。考虑示例 3-118 中的代码。它构造了一个StreamReader,这是一个用于处理包含文本数据流的类。第二个构造函数参数是一个bool

示例 3-118. 不合理使用bool类型
using var rdr = new StreamReader(stream, true);

很难看出第二个参数的作用。如果你熟悉StreamReader,你可能知道这个参数确定是否应该从代码中显式设置多字节文本编码的字节顺序,或者应该从流的开头的前导部分确定。 (在这里使用命名参数语法会有所帮助。)如果你有非常好的记忆力,你甚至可能知道true选择了哪个选项。但是大多数普通开发者可能需要借助 IntelliSense 或者文档来弄清楚这个参数的作用。将这种经验与示例 3-119 进行比较,它展示了不同的类型。

示例 3-119. 通过enum来提高清晰度
using var fs = new FileStream(path, FileMode.Append);

此构造函数的第二个参数使用了枚举类型,这样可以使代码更加清晰易懂。不需要超强记忆力就能理解,这段代码的意图是将数据追加到现有文件中。

由于这个特定的 API 有超过两个选项,所以它不能使用bool。因此,FileMode确实必须是一个enum。但这些示例说明,即使在只有两个选择时,考虑定义一个enum来进行选择也是非常值得的,这样当你查看代码时就完全明了了。

其他类型

我们几乎完成了对类型及其内容的调查。直到第九章之前,我将不讨论一种类型:委托。当我们需要引用函数时,我们使用委托,但细节有些复杂。

我也没有提到指针。C#支持指针,其工作方式与 C 风格指针非常相似,包括指针算术。 (如果你对这些不熟悉,它们提供了对内存中特定位置的引用。)这些有点奇怪,因为它们略微超出了其余类型系统的范围。例如,在第二章中,我提到类型为object的变量可以引用“几乎任何东西”。我必须说明的原因是指针是两个例外之一—object可以与除了指针或ref struct之外的任何 C# 数据类型一起使用。(第十八章讨论了后者。)

但现在我们真的完成了。C#中的某些类型是特殊的,包括第二章中讨论的基本类型以及刚刚描述的记录、结构、接口、枚举、委托和指针,但其他所有内容看起来都像一个类。在某些情况下,有几个类会有特殊处理,特别是属性类(第十四章)和异常类(第八章),但除了某些特殊情况外,它们都是完全普通的类。尽管我们已经看过 C#支持的所有类型,但还有一种定义类的方式我还没有展示过。

匿名类型

C#提供了两种将少数值组合在一起的机制。你已经看过元组,这些在第二章中描述过。它们在 C# 7.0 中引入,但自 C# 3.0 以来就有另一种可用的替代方案:示例 3-120 展示了如何创建匿名类型的实例以及如何使用它。

示例 3-120. 匿名类型
var x = new { Title = "Lord", Surname = "Voldemort" };

Console.WriteLine($"Welcome, {x.Title} {x.Surname}");

正如你所见,我们在不指定类型名称的情况下使用new关键字。相反,我们只使用对象初始化器语法。C#编译器将为每个初始化器中的条目提供一个只读属性的类型。因此,在示例 3-120 中,变量x将引用一个具有两个属性的对象,TitleSurname,均为string类型。(在匿名类型中,你不需要显式声明属性类型。编译器会根据初始化表达式推断每个属性的类型,就像对var关键字一样。)由于这些只是普通的属性,我们可以使用通常的语法访问它们,正如示例的最后一行所示。

小贴士

对于记录类型和struct类型,也可以使用with语法,这也适用于匿名类型。with不适用于所有引用类型的原因是缺乏通用的克隆机制,但这对于匿名类型来说不是问题。它们总是由编译器生成,因此编译器清楚地知道如何复制它们。

编译器为每个匿名类型生成一个相当普通的类定义。它是不可变的,因为所有属性都是只读的。就像记录一样,它重写了Equals以便您可以按值比较实例,并且还提供了相应的GetHashCode实现。生成类的唯一不寻常之处在于,在 C#中无法通过名称引用该类型。在调试器中运行示例 3-120,我发现编译器选择了名称<>f__AnonymousType0'2。由于这些尖括号(<>)的存在,这不是 C#中的合法标识符。当 C#希望创建不会与您自己代码中的任何标识符冲突的东西,或者想要防止您直接使用时,会使用这种类型的名称。这种标识符被称为不可言之名,相当壮观。

由于无法写出匿名类型的名称,方法不能声明其返回一个匿名类型,也不能要求将其作为参数传递(除非您将匿名类型用作推断的泛型类型参数,我们将在第四章中看到)。当然,类型为object的表达式可以引用匿名类型的实例,但只有定义类型的方法才能使用其属性(除非您使用了第二章中描述的dynamic类型)。因此,匿名类型的价值有所限制。它们被添加到语言中以用于 LINQ 的好处:它们使查询能够从某些源集合中选择特定列或属性,并定义自定义分组条件,正如您将在第十章中看到的那样。

这些限制提供了一个线索,说明为什么 Microsoft 觉得在 C# 7.0 中需要添加元组,尽管语言已经有了一个看起来非常相似的特性。然而,如果无法将匿名类型用作参数或返回类型是唯一的问题,显而易见的解决方案可能是引入一种语法来使它们能够被识别。引用元组的语法可能起作用——我们现在可以写(string Name, double Age)来引用元组类型,但为什么要引入一个全新的概念呢?为什么不只是使用那个语法来命名匿名类型呢?(显然,我们将不能再称它们为匿名类型,但至少我们不会最终得到两个令人困惑的相似语言特性。)然而,没有名称并不是匿名类型的唯一问题。

随着 C#在越来越多的应用程序中被使用,并跨越更广泛的硬件范围,效率变得更加重要。在匿名类型最初引入的数据库访问场景中,对象分配的成本可能只是一个相对较小的部分,但其基本概念——一小组值——在更广泛的场景中可能非常有用,其中一些场景更加注重性能。然而,匿名类型都是引用类型,虽然在许多情况下这并不是问题,但在某些超高性能敏感的场景中可能会排除它们。另一方面,元组都是值类型,使得它们在试图最小化分配数量的代码中也是可行的。 (详见第七章了解有关内存管理和垃圾回收的更多细节,以及第十八章了解有关一些旨在实现更高效内存使用的新语言特性的信息。) 另外,由于元组都是基于一组通用类型底层实现的,它们可能会减少在跟踪加载的类型所需的运行时开销:使用匿名类型时,可能会加载更多不同的类型。出于相关原因,匿名类型在组件边界之间的兼容性可能会存在问题。

这是否意味着匿名类型不再有任何用处呢?事实上,它们仍然提供了一些优势。最重要的优势是你无法在将转换为表达式树的 lambda 表达式中使用元组。这个问题在第九章中有详细描述,但实际上的结果是,你不能在之前提到的支持匿名类型的 LINQ 查询中使用元组。

更微妙的是,对于元组而言,属性名称是一个方便的虚构,而对于匿名类型来说,它们是真实存在的。这有两个结果。一个关于等价性:元组(X: 10, Y:20)(W:10, H:20)被视为可互换,任何能够容纳一个的变量也能容纳另一个。但对于匿名类型并非如此:new { X = 10, Y = 20 }new { W = 10, H = 20 }有不同的类型,尝试将其中一个传递给期望另一个的代码将导致编译器错误。这种差异使得元组可能更加方便,但也可能更容易出错,因为编译器只关注数据的形状来判断是否使用了正确的类型。匿名类型仍然可能导致错误:如果有两个类型具有完全相同的属性名称和类型但在语义上不同,那么用匿名类型无法表达这一点。(实际上,您可能只需定义两种记录类型来处理这种情况。)匿名类型提供真正的属性的第二个结果是,您可以将它们传递给检查对象属性的代码。许多反射驱动的功能,如某些序列化框架或 UI 框架数据绑定,依赖于能够通过反射在运行时发现属性(参见 第十三章)。与元组相比,匿名类型可能在这些框架中表现更好,因为它们的属性真实名称类似于Item1Item2等。

部分类型和方法

关于类型,我想讨论的最后一个话题是部分类型声明。C# 支持所谓的部分类型声明。这是一个非常简单的概念:它意味着类型声明可能跨多个文件。如果在类型声明中添加partial关键字,C# 将不会在另一个文件定义相同的类型时报错——它会简单地假定这两个文件定义的所有成员都像在一个文件中单独声明的一样。

此功能存在是为了更轻松地编写代码生成工具。Visual Studio 中的各种功能可以为您生成类的各个部分。这在 UI 中特别常见。UI 应用程序通常有标记,定义 UI 各部分的布局和内容,您可以选择让某些 UI 元素在代码中可访问。通常通过向与标记文件关联的类添加字段来实现这一点。为了保持简单,所有 Visual Studio 生成的类部分都放在与您编写的部分分开的文件中。这意味着生成的部分可以在需要时从头开始重新制作,而不会有任何覆盖您编写的代码的风险。在引入部分类型到 C# 之前,所有类的代码都必须在一个文件中,有时,代码生成工具会混淆,导致代码丢失。

注意

部分类不仅限于代码生成场景,因此您当然可以使用它来将自己的类定义分割到多个文件中。然而,如果您编写了一个如此庞大和复杂的类,以至于您感觉需要将其分割成多个源文件来保持可管理性,那可能是该类过于复杂的迹象。对这个问题的更好响应可能是改变您的设计。然而,如果您需要维护针对不同目标平台以不同方式构建的代码,使用部分类将非常有用:您可以使用部分类将特定于目标的部分放入单独的文件中。

部分方法也是为代码生成场景设计的,但它们稍微复杂一些。它们允许一个文件(通常是生成的文件)声明一个方法,另一个文件实现该方法。(严格来说,声明和实现可以在同一个文件中,但通常不会。)这听起来像是接口和实现该接口的类之间的关系,但并不完全相同。使用部分方法,声明和实现在同一个类中,它们之所以在不同文件中仅因为类已分割成多个文件。

如果您没有提供部分方法的实现,则只要方法定义不指定任何可访问性,具有void返回类型,并且没有out参数,编译器会表现得好像方法根本不存在,任何调用该方法的代码在编译时都将被忽略。这样做的主要原因是支持能够提供多种通知的代码生成机制,但您不需要的通知在运行时不产生任何开销。部分方法通过允许代码生成器为其提供的每种通知声明一个部分方法,并生成在必要时调用所有这些部分方法的代码来实现这一点。对于您未编写处理程序方法的通知相关的所有代码将在编译时被剥离。

这是一个特殊的机制,但它是由提供极其细粒度通知和扩展点的框架驱动的。还有一些更明显的运行时技术您可以使用,比如接口,或者我稍后将介绍的特性,比如回调或虚方法。然而,这些方法中的任何一个都将对未使用的特性施加相对较高的成本。未使用的部分方法在编译时被剥离,将不需要使用的位的成本降至零,这是一个相当大的改进。

直到最近,局部方法要求不指定它们的可访问性并且不返回任何数据。C# 9.0 放宽了这一点,以支持额外的代码生成场景,在这些场景中,开发人员编写局部方法,期望代码生成工具提供实现。当局部方法指定了可访问性(即使是private并且不返回数据),如果未实现该方法,则会产生错误。

Summary

现在你已经看到了你可以在 C#中编写的大多数类型以及它们支持的成员类型。类是最广泛使用的,但如果需要赋值和参数的值类型语义,结构体也是有用的;两者都支持相同的成员类型,包括字段、构造函数、方法、属性、索引器、事件、自定义运算符和嵌套类型。记录提供了更方便的语法来定义主要由属性组成的类型,特别是如果你希望能够比较这些类型的值。虽然它们不必是不可变的,但记录类型使得定义和处理不可变数据变得更容易。接口是抽象的,因此在实例级别上只支持方法、属性、索引器和事件。它们还可以提供静态字段、嵌套类型和其他成员的默认实现。而枚举非常有限,只提供一组已知的值。

C#类型系统的另一个特性使得编写非常灵活的类型成为可能,称为泛型类型。我们将在下一章节中介绍这些内容。

¹ 具体来说,它生成一个带有特殊名称<Clone>$的方法。这个名称在 C#中是非法标识符,因此这个方法实际上被隐藏在你的代码中,但如果你使用with语法来构建记录的修改副本,则间接使用它。

² 有一些例外情况,在第十八章中有描述。

³ 你不希望它成为值类型,因为字符串可能很大,因此按值传递它们将是昂贵的。无论如何,它不能是结构体,因为字符串的长度不同。然而,在 C#中你无法自己编写可变长度的数据类型,只有字符串和数组类型具有可变大小。

⁴ 如果省略了readonly字段的初始化程序,则应在构造函数或属性的init访问器中设置它;否则它没有什么用处。

⁵ 有两个例外情况。如果一个类支持 CLR 特性称为serialization,那么该类型的对象可以直接从数据流反序列化,而不经过构造函数。但即便如此,你也可以规定所需的数据。并且在第六章中描述了MemberwiseClone方法。

⁶ CLR 将这种引用称为托管指针,以区别于指向堆上对象的引用。不幸的是,C# 的术语并不那么清晰:它都称这些为引用

⁷ 顺便提一下,默认属性有一个名称,因为所有属性都必须有。C# 将索引器属性称为Item,并自动添加注释以指示它是默认属性。通常情况下不会通过名称来引用索引器,但在某些工具中可以看到这个名称。.NET 文档将索引器列在Item下,尽管在代码中很少使用这个名称。

第四章:泛型

在 第三章 中,我展示了如何编写类型,并描述了它们可以包含的各种成员。然而,关于类、结构体、接口和方法还有一个额外的维度我没有展示。它们可以定义类型参数,这些占位符让你在编译时可以插入不同的类型。这使得你只需编写一个类型,然后就可以生成多个版本。执行此操作的类型称为泛型类型。例如,运行时库定义了一个名为 List<T> 的泛型类,它充当可变长度数组。这里的 T 是一个类型参数,你可以几乎使用任何类型作为参数,因此 List<int> 是一个整数列表,List<string> 是一个字符串列表,依此类推。¹ 你也可以编写泛型方法,它是一种具有自己类型参数的方法,与其包含的类型是否是泛型无关。

泛型类型和方法在视觉上是有区别的,因为它们的名称后面总是有尖括号 (<>)。这些尖括号包含一个逗号分隔的参数或参数列表。与方法一样,这里也有参数/参数列表的区分:声明时指定参数列表,然后在使用方法或类型时为这些参数提供参数。因此,List<T> 定义了一个单一的类型参数 T,而 List<int> 提供了一个类型参数 int

对于类型参数,你可以使用任何喜欢的名称,只需符合 C#中标识符的通常约束即可,但有一些流行的约定。当只有一个参数时,通常(但不是普遍)使用 T。对于多参数泛型,你倾向于看到稍微更具描述性的名称。例如,运行时库定义了 Dictionary<TKey, TValue> 集合类。有时即使只有一个参数,你也会看到像这样的描述性名称,但无论如何,你通常会看到以 T 为前缀,这样在你的代码中使用它们时类型参数就会显眼。

泛型类型

类、结构体、记录和接口都可以是泛型,委托也可以,我们将在 第九章 中详细讨论它们。 示例 4-1 展示了如何定义一个泛型类。

示例 4-1. 定义泛型类
public class NamedContainer<T>
{
    public NamedContainer(T item, string name)
    {
        Item = item;
        Name = name;
    }

    public T Item { get; }
    public string Name { get; }
}

structs(结构体)、records(记录)和 interfaces(接口)的语法基本相同:类型名称紧跟着类型参数列表。 示例 4-2 展示了如何编写类似于 示例 4-1 中类的泛型记录。

示例 4-2. 定义泛型记录
public record NamedContainer<T>(T Item, string Name);

在通用类型的定义内部,我可以在通常会看到类型名称的任何地方使用类型参数T。在第一个示例中,我已将其用作构造函数参数的类型,在两个示例中都用作Item属性的类型。我也可以定义类型为T的字段。(实际上我已经这样做了,尽管不是显式地。自动属性会生成隐藏字段,因此我的Item属性将有一个关联的类型为T的隐藏字段。)你还可以定义类型为T的局部变量。并且你可以自由地将类型参数作为其他通用类型的参数。例如,我的NamedContainer<T>可以声明类型为List<T>的成员。

示例 4-1 和 4-2 定义的类型,像任何通用类型一样,都不是完整的类型。通用类型声明是未绑定的,意味着必须填入类型参数才能生成完整的类型。基本问题,例如NamedContainer<T>实例需要多少内存,无法在不知道T是什么的情况下回答——如果Tint,那么Item属性的隐藏字段将需要 4 字节,但如果是decimal,则需要 16 字节。如果 CLR 不知道如何安排内存中的内容,就不能为类型生成可执行代码。因此,为了使用这个或任何其他通用类型,我们必须提供类型参数。示例 4-3 展示了如何做到这一点。当提供类型参数时,结果有时被称为构造类型。(这与构造函数无关,我们在第三章中看过的一种特殊的成员类型。事实上,示例 4-3 也使用了它们——它调用了几个构造类型的构造函数。)

示例 4-3. 使用通用类
var a = new NamedContainer<int>(42, "The answer");
var b = new NamedContainer<int>(99, "Number of red balloons");
var c = new NamedContainer<string>("Programming C#", "Book title");

你可以在任何普通类型可以使用的地方使用构造的通用类型。例如,你可以将它们用作方法参数和返回值的类型,属性或字段的类型。你甚至可以将一个作为另一个通用类型的类型参数,就像示例 4-4 所示的那样。

示例 4-4. 作为类型参数的构造通用类型
// ...where a, and b come from Example 4-3. var namedInts = new List<NamedContainer<int>>() { a, b };
var namedNamedItem = new NamedContainer<NamedContainer<int>>(a, "Wrapped");

每次我向NamedContainer<T>提供不同的类型作为参数,都会构造一个不同的类型。(对于具有多个类型参数的通用类型,每个不同的类型参数组合将构造一个不同的类型。)这意味着NamedContainer<int>是一种不同于NamedContainer<string>的类型。这就是为什么在将NamedContainer<int>用作另一个NamedContainer的类型参数时不存在冲突的原因,正如示例 4-4 的最后一行所示——这里没有无限递归。

因为每组不同的类型参数产生不同的类型,所以在大多数情况下,同一通用类型的不同形式之间并没有隐含的兼容性。你不能将NamedContainer<int>赋给类型为Nam⁠ed​Con⁠tai⁠ner⁠<str⁠ing>的变量,反之亦然。这两种类型不兼容是有道理的,因为intstring是完全不同的类型。但如果我们使用object作为类型参数呢?正如第二章所述,几乎可以将任何东西放入一个object变量中。如果你写了一个参数类型为object的方法,传递一个string是可以的,因此你可能期望一个接受NamedContainer<object>的方法也接受NamedContainer<string>。然而这是行不通的,但某些通用类型(特别是接口和委托)可以声明它们希望具有这种兼容关系。支持这一点的机制(称为协变逆变)与类型系统的继承机制密切相关。第六章详细讨论了继承和类型兼容性的这一方面,因此我将在那里讨论通用类型的这一方面。

类型参数的数量构成了未绑定通用类型的一部分身份。这使得可以引入具有相同名称但具有不同类型参数数量的多个类型。(类型参数数量的技术术语是arity。)

因此,你可以定义一个名为,比如说,Operation<T>的通用类,然后是另一个类,Operation<T1, T2>,还有Operation<T1, T2, T3>等等,都在同一命名空间中,而不会引入任何歧义。当你使用这些类型时,通过参数数量清楚地表明了使用的是哪种类型——例如,Operation<int>明显使用第一个,而Operation<string, double>使用第二个。出于同样的原因,一个非通用的Operation类会与具有相同名称的通用类型不同。

我的NamedContainer<T>示例对其类型参数T的实例不做任何操作——它从不调用任何方法或使用任何属性或其他成员的T。它所做的只是接受一个T作为构造函数参数,并将其存储以便稍后检索。运行时库中许多通用类型也是如此——我提到过一些集合类,它们都是对包含数据以便稍后检索这一主题的变体。

这是有原因的:通用类能够处理任何类型,因此对其类型参数的假设很少。然而,并非只能这样。你可以为你的类型参数指定约束

约束

C#允许您声明类型实参必须满足某些要求。例如,假设您希望能够根据需要创建类型的新实例。示例 4-5 展示了一个简单的类,提供了延迟构造的功能——它通过静态属性提供了一个实例,但只有在第一次读取属性时才会尝试构造该实例。

示例 4-5. 创建参数化类型的新实例
// For illustration only. Consider using Lazy<T> in a real program. public static class Deferred<T>
    `where` `T` `:` `new``(``)`
{
    private static T? _instance;

    public static T Instance
    {
        get
        {
            if (_instance == null)
            {
                `_instance` `=` `new` `T``(``)``;`
            }
            return _instance;
        }
    }
}
警告

在实践中,您不会编写这样的类,因为运行时库提供了Lazy<T>,它能够以更灵活的方式完成相同的工作。Lazy<T>可以在多线程代码中正确工作,而示例 4-5 则不行。示例 4-5 只是为了说明约束的工作原理。不要使用它!

为了使这个类能够完成它的工作,它需要能够构造一个供T类型实参使用的实例。get访问器使用了new关键字,由于没有传递参数,显然需要T提供无参构造函数。但并非所有类型都提供这样的构造函数,那么如果我们尝试使用一个没有适当构造函数的类型作为Deferred<T>的实参会发生什么?

编译器会拒绝这样做,因为它违反了这个泛型类型为T声明的约束条件。约束条件出现在类的开放大括号之前,并以where关键字开始。在示例 4-5 中的new()约束声明了T必须提供一个无参数的构造函数。

如果没有这个约束条件,示例 4-5 中的类将无法编译——在尝试构造T的新实例时会出错。泛型类型(或方法)只能使用通过约束指定的类型参数的特性,或者基本object类型定义的特性。(例如,object类型定义了ToString方法,因此您可以在任何类型的实例上调用它,而无需指定约束。)

C#仅提供了一套非常有限的约束条件。例如,您不能要求带有参数的构造函数。事实上,C#仅支持六种类型参数的约束:类型约束、引用类型约束、值类型约束、notnullunmanaged以及new()约束。我们刚刚看到了最后一种,现在让我们来看看其他几种。

类型约束

您可以约束类型参数的实参与特定类型兼容。例如,您可以使用这个特性要求实参类型实现特定接口。示例 4-6 展示了相应的语法。

示例 4-6. 使用类型约束
public class GenericComparer<T> : IComparer<T>
    `where` `T` `:` `IComparable``<``T``>`
{
    public int Compare(T? x, T? y)
    {
        if (x == null) { return y == null ? 0 : -1; }
        return x.CompareTo(y);
    }
}

在描述如何利用类型约束之前,我将简要解释此示例的目的。这个类提供了.NET 中两种值比较风格之间的桥梁。某些数据类型提供它们自己的比较逻辑,但有时将比较作为独立函数实现在其自己的类中可能更有用。这两种风格分别由IComparable<T>IComparer<T>接口代表,它们都是运行时库的一部分(分别位于SystemSystem.Collections.Generic命名空间中)。我在第三章展示了IComparer<T>——这个接口的实现可以比较两个类型为T的对象或值。该接口定义了一个Compare方法,接受两个参数,根据第一个参数分别小于、等于或大于第二个参数时返回负数、0 或正数。IComparable<T>非常相似,但其CompareTo方法只接受一个参数,因为使用此接口时,你要求一个实例比较它自己与另一个实例。

运行时库的某些集合类要求你提供一个IComparer<T>来支持排序等操作。它们使用这种模式,其中一个单独的对象执行比较,因为这比IComparable<T>模式有两个优点。首先,它使你能够使用不实现IComparable<T>的数据类型。其次,它允许你插入不同的排序顺序。(例如,假设你希望使用不区分大小写的顺序对一些字符串进行排序。string类型实现了IComparable<string>,但它提供了区分大小写、具有特定区域设置的顺序。)因此,IComparer<T>是更灵活的模式。但是,如果你使用实现了IComparable<T>的数据类型,并且你对其提供的顺序非常满意,那么当你在使用要求IComparer<T>的 API 时,你会怎么做呢?

实际上,答案是你可能只需使用.NET 专为这种情况设计的功能:Comparer<T>.Default。如果T实现了IComparable<T>,该属性将返回一个精确满足你需求的IComparer<T>。因此,在实践中,你不需要编写示例 4-6 中的代码,因为 Microsoft 已经为你编写了。然而,看到如何编写自己版本仍然很有教育意义,因为它展示了如何使用类型约束。

where关键字开头的那行声明,说明这个泛型类要求其类型参数T实现IComparable<T>。如果没有这个限制,Compare方法将无法编译——它在类型为T的参数上调用CompareTo方法。该方法并不适用于所有对象,C#编译器之所以允许这样做,只是因为我们约束T必须实现提供这种方法的接口。

接口约束有点奇怪:乍一看,似乎我们真的不应该需要它们。如果一个方法需要特定的参数来实现特定的接口,你通常会将该接口作为参数的类型。然而,示例 4-6 无法做到这一点。你可以通过尝试示例 4-7 来证明这一点。它将无法编译通过。

示例 4-7. 编译失败:未实现接口
public class GenericComparer<T> : IComparer<T>
{
    public int Compare(IComparable<T>? x, T? y)
    {
        if (x == null) { return y == null ? 0 : -1; }
        return x.CompareTo(y);
    }
}

编译器会抱怨我没有实现IComparer<T>接口的Compare方法。示例 4-7 有一个Compare方法,但其签名是错误的——第一个参数应该是T。我也可以尝试不指定约束的正确签名,就像示例 4-8 所示。

示例 4-8. 编译失败:缺少约束
public class GenericComparer<T> : IComparer<T>
{
    public int Compare(T? x, T? y)
    {
        if (x == null) { return y == null ? 0 : -1; }
        return x.CompareTo(y);
    }
}

这也无法通过编译,因为编译器找不到我试图使用的CompareTo方法。在示例 4-6 中对T的约束使编译器能够了解该方法的真正含义。

顺便说一句,类型约束不一定是接口。你可以使用任何类型。例如,你可以要求特定类型参数派生自特定基类。更微妙的是,你还可以根据另一个类型参数来定义一个参数的约束。例如,示例 4-9 要求第一个类型参数派生自第二个类型参数。

示例 4-9. 将一个参数约束为从另一个派生
public class Foo<T1, T2>
    where T1 : T2
...

类型约束非常具体——它们要求特定的继承关系或实现某些接口。然而,你可以定义稍微不那么具体的约束。

引用类型约束

可以将类型参数约束为引用类型。如示例 4-10 所示,这看起来类似于类型约束。你只需使用关键字class而不是类型名称。如果你处于启用的可空注解上下文中,此注解的含义会发生变化:它要求类型参数为非空引用类型。如果指定class?,则允许类型参数为可空或非空引用类型。

示例 4-10. 要求引用类型的约束
public class Bar<T>
    where T : class
...

这个约束会阻止将值类型,如 intdouble 或任何 struct,用作类型参数。其存在使得你的代码能够做三件否则不可能做到的事情。首先,它意味着你可以编写测试相关类型变量是否为 null 的代码。² 如果你没有将类型约束为引用类型,它始终有可能是值类型,而这些类型不能有 null 值。第二个能力是你可以将其用作 as 运算符的目标类型,我们将在第六章中讨论这一点。这实际上只是第一个特性的变体——as 关键字需要一个引用类型,因为它可能产生一个 null 结果。

注意

class 约束会阻止使用可空类型,如 int?(或 CLR 中称为 Nullable<int>)。虽然你可以测试 int? 是否为 null 并使用 as 运算符,但编译器对可空类型和引用类型在这两个操作上生成的代码完全不同。如果你使用这些特性,它就不能编译一个可以处理引用类型和可空类型的单个方法。

引用类型约束的第三个功能是能够使用某些其他泛型类型。对于泛型代码来说,将其中一个类型参数用作另一个泛型类型的参数通常很方便,如果另一个类型指定了约束,你需要在自己的类型参数上放置相同的约束。因此,如果其他某个类型指定了类约束,这可能要求你以相同的方式约束自己的某个参数。

当然,这确实引出了为什么你正在使用的类型首先需要这个约束的问题。也许它只是想要测试 null 或使用 as 运算符,但应用这个约束还有另一个原因。有时候,你只需要一个类型参数是引用类型——有些泛型方法可能能够在没有 class 约束的情况下编译,但如果与值类型一起使用,它将无法正确工作。为了说明这一点,我将描述我有时发现自己需要使用这种约束的场景。

我经常编写测试,创建被测试类的实例,并且也需要一个或多个虚拟对象来替代真实对象,以便与被测试对象交互。使用这些替身减少了每个单独测试需要执行的代码量,并且可以更轻松地验证被测试对象的行为。例如,我的测试可能需要验证我的代码在正确的时机向服务器发送消息,但我不想在单元测试期间运行真实服务器,因此我提供了一个对象,它实现了与负责传输消息的类相同的接口,但实际上并不会发送消息。被测试对象加上一个虚拟对象的组合是一种常见的模式,可能有助于将代码放入可重用的基类中。使用泛型意味着该类可以适用于被测试的类型和虚拟类型的任意组合。示例 4-11 展示了我在这些情况下有时编写的一种辅助类的简化版本。

示例 4-11. 受另一个约束的限制
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Moq;

public class TestBase<TSubject, TFake>
    where TSubject : new()
    where TFake : class
{
    public TSubject? Subject { get; private set; }
    public Mock<TFake>? Fake { get; private set; }

 [TestInitialize]
    public void Initialize()
    {
        Subject = new TSubject();
        Fake = new Mock<TFake>();
    }
}

有多种方法可以构建用于测试目的的虚拟对象。您可以编写实现与您的真实对象相同接口的新类,但也有第三方库可以生成它们。一个这样的库称为 Moq(一个 免费提供的开源项目),Mock<T> 类就是来自于这里,在 示例 4-11 中。它能够生成任何接口或任何未密封类的虚拟实现。(第六章 描述了 sealed 关键字。) 它默认提供所有成员的空实现,并且如果需要,您还可以配置更有趣的行为。您还可以验证代码在使用虚拟对象时是否按预期进行了使用。

这与约束有什么关系?Mock<T> 类在其自身类型参数 T 上指定了引用类型约束。这是因为它在运行时创建类型的动态实现的方式;这种技术仅适用于引用类型。Moq 在运行时生成类型,如果 T 是一个接口,则生成的类型将实现它;而如果 T 是一个类,则生成的类型将从它派生。³ 如果 T 是一个结构体,它将无法执行任何有用的操作,因为不能从值类型派生。这意味着当我在 示例 4-11 中使用 Mock<T> 时,我需要确保传递的任何类型参数不是结构体(即必须是引用类型)。但是,我使用的类型参数是我的类的类型参数之一:TFake。因此,我不知道那将是什么类型——这将取决于谁在使用我的类。

为了使我的类能够编译而不报错,我必须确保已满足我使用的任何泛型类型的约束。我必须保证Mock<TFake>是有效的,而唯一的方法就是在自己的类型上添加一个要求TFake为引用类型的约束。这就是在类定义的第三行中做的事情,在示例 4-11 中。如果没有这个,编译器会在引用Mock<TFake>的两行上报错。

总的来说,如果你想使用自己的类型参数作为泛型的类型参数,并指定一个约束条件,你需要在自己的类型参数上也指定相同的约束条件。

值类型约束

就像你可以约束一个类型参数为引用类型一样,你也可以约束它为值类型。如示例 4-12 所示,语法与引用类型约束类似,但使用struct关键字。

示例 4-12. 要求值类型的约束
public class Quux<T>
    where T : struct
...

到目前为止,我们只在自定义值类型的上下文中看到过struct关键字,但尽管它的外观如此,此约束允许boolenum类型以及任何内置数值类型(如int),以及自定义结构体。

.NET 的Nullable<T>类型施加了这个约束。从第三章回忆起,Nullable<T>为值类型提供了一个包装器,允许变量既可以持有一个值,也可以没有值。(通常我们使用 C#提供的特殊语法,例如,我们会写int?而不是Nullable<int>。)这种类型存在的唯一理由是为不能持有 null 值的类型提供可空性。因此,只有将此类型用于值类型才有意义——引用类型变量已经可以被设置为null而不需要这个包装器。值类型约束阻止你将Nullable<T>用于不需要它的类型。

使用非托管约束实现全面的值类型

你可以指定unmanaged作为约束条件,这要求类型参数是一个值类型,但也要求它不包含引用。该类型的所有字段必须是值类型,如果任何字段不是内置基元类型,则其类型必须进一步仅包含值类型字段,以此类推直到底部。实际上,这意味着所有实际数据必须是固定集合中的一种内置类型(基本上是所有数值类型、bool或指针)或enum类型。这主要在互操作场景中非常重要,因为符合unmanaged约束的类型可以安全高效地传递给非托管代码。

非空约束

如果使用了第三章描述的可空引用特性(在创建新项目时默认启用),则可以指定notnull约束。这允许值类型或非可空引用类型,但不允许可空引用类型。

其他特殊类型约束

第三章描述了各种特殊类型,包括枚举类型(enum)和委托类型(在第九章中详细介绍)。有时将类型参数约束为这些类型之一是很有用的。不过,这没有什么特别的技巧:你只需使用类型约束即可。所有委托类型都派生自System.Delegate,所有枚举类型都派生自System.Enum。正如示例 4-13 所示,你可以编写一个约束类型参数必须派生自其中任何一个的类型约束。

示例 4-13. 要求委托和enum类型的约束
public class RequireDelegate<T>
    where T : Delegate
{
}

public class RequireEnum<T>
    where T : Enum
{
}

多重约束

如果你希望对单个类型参数施加多重约束,可以将它们放在一个列表中,正如示例 4-14 所示。有一些限制。你不能结合使用classstructnotnullunmanaged约束 —— 这些是互斥的。如果使用了其中一个关键字,必须将其放在列表的最前面。如果存在new()约束,则必须放在最后。

示例 4-14. 多重约束
public class Spong<T>
    where T : IEnumerable<T>, IDisposable, new()
...

当你的类型具有多个类型参数时,需要为每个想要约束的类型参数编写一个where子句。实际上,我们在前面看到了这一点 —— 示例 4-11 为其两个参数定义了约束。

零值类似的值

所有类型都支持的某些特性,因此不需要约束。这包括由object基类定义的方法集,详见第三章和第六章。但在泛型代码中有时可以使用更基本的特性。

任何类型的变量都可以初始化为默认值。正如在前面章节中所看到的,有些情况下 CLR 会为我们做这件事。例如,新构造对象中的所有字段将具有已知值,即使我们没有编写字段初始化器并且没有在构造函数中提供值。同样,任何类型的新数组将所有元素初始化为已知值。CLR 通过填充相关内存区域为零来完成此操作。这的确切含义取决于数据类型。对于任何内置数值类型,该值将几乎肯定是数字0,但对于非数值类型,情况则不同。对于bool,默认值是false,对于引用类型,则为null

有时,对于通用代码而言,能够将变量设置为这种初始默认的零值可能非常有用。但在大多数情况下,您无法使用文字表达式来完成这一点。您不能将null赋给一个由类型参数指定的变量,除非该参数已被约束为引用类型。并且您也不能将字面量0赋给任何此类变量,因为当前没有一种方法来约束类型参数为数值类型。

相反,您可以使用default关键字请求任何类型的零值。(这与我们在第二章中在switch语句内部看到的相同关键字,但用法完全不同。C#保持了 C 家族传统,为每个关键字定义多个不相关的含义。)如果您编写default(*SomeType*),其中*SomeType*可以是特定类型或类型参数,则会获得该类型的默认初始值:如果是数值类型,则为0,对于任何其他类型,则为其等效值。例如,表达式default(int)的值为0default(bool)falsedefault(string)null。您可以将其与泛型类型参数一起使用,以获取相应类型参数的默认值,如示例 4-15 所示。

示例 4-15. 获取类型参数的默认(类似零值)值
static void ShowDefault<T>()
{
    Console.WriteLine(default(T));
}

在定义类型参数T的泛型类型或方法内部,表达式default(T)将产生类型T的默认零值——无论T是什么——而无需约束。因此,您可以使用示例 4-15 中的泛型方法来验证我所述的intboolstring的默认值。

注意

当启用了可空引用功能(在第三章中描述)时,编译器将考虑default(T)作为一个可能为空的值,除非您通过应用struct约束来排除引用类型的使用。

在编译器能够推断所需类型的情况下,您可以使用更简单的形式。而不是编写default(T),您只需编写default。这在示例 4-15 中是行不通的,因为Console.WriteLine几乎可以接受任何东西,所以编译器无法缩小到一个选项,但在示例 4-16 中可以正常工作,因为编译器可以看到泛型方法的返回类型是T,所以这必须需要一个default(T)。由于它可以推断出来,我们只需写default

示例 4-16. 获取推断类型的默认(类似零值)
static T? GetDefault<T>() => default;

而且,既然我刚刚向您展示了一个示例,这似乎是一个谈论泛型方法的好时机。

泛型方法

除了泛型类型,C# 还支持泛型方法。在这种情况下,泛型类型参数列表位于方法名之后,并且在方法的普通参数列表之前。示例 4-17 展示了一个具有单个类型参数的方法。它将该参数用作其返回类型,并将其用作将传递给方法的数组的元素类型。该方法返回数组中的最后一个元素,并且因为它是泛型的,所以对于任何数组元素类型都有效。

示例 4-17. 泛型方法
public static T GetLast<T>(T[] items) => items[¹];
注意

您可以在泛型类型或非泛型类型中定义泛型方法。如果泛型方法是泛型类型的成员,则包含类型的所有类型参数都在方法内部有效,以及方法特定的类型参数。

与泛型类型类似,您可以通过指定方法名和类型参数来使用泛型方法,如示例 4-18 所示。

示例 4-18. 调用泛型方法
int[] values = { 1, 2, 3 };
int last = GetLast<int>(values);

泛型方法与泛型类型类似,但类型参数仅在方法声明和方法体内有效。您可以像处理泛型类型那样指定约束条件。如示例 4-19 所示,约束条件出现在方法参数列表后和方法体前。

示例 4-19. 具有约束条件的泛型方法
public static T MakeFake<T>()
    where T : class
{
    return new Mock<T>().Object;
}

然而,泛型方法与泛型类型有一个显著的区别:您不必总是显式指定泛型方法的类型参数。

类型推断

C# 编译器通常能够推断出泛型方法的类型参数。例如,我可以通过从方法调用中移除类型参数列表来修改示例 4-18,如示例 4-20 所示。这并不改变代码的含义。

示例 4-20. 泛型方法类型参数推断
int[] values = { 1, 2, 3 };
int last = GetLast(values);

当遇到这种普通的方法调用时,如果没有同名的非泛型方法可用,编译器会开始寻找合适的泛型方法。如果示例 4-17 中的方法在作用域内,则它将是一个候选项,并且编译器将尝试推断类型参数。这是一个相当简单的情况。该方法期望一个类型为T的数组,而我们传递了一个元素类型为int的数组,因此很容易推断出这段代码应该被视为对GetLast<int>的调用。

随着更复杂的案例的出现,情况变得更加复杂。C# 规范大约有六页专门讨论类型推断算法,但它的目标始终是:在类型参数冗余时让您可以省略类型参数。顺便说一句,类型推断始终在编译时进行,因此它基于方法参数的静态类型。

对于广泛使用泛型的 API(例如 LINQ,这是第 10 章的主题),显式列出每个类型参数可能会使代码非常难以理解,因此通常依赖类型推断。如果使用匿名类型,则类型参数推断变得至关重要,因为无法显式提供类型参数。

泛型和元组

C# 的轻量级元组具有独特的语法,但在运行时看来,它们并没有什么特别之处。它们都只是一组通用类型的实例。看看示例 4-21。这里使用 (int, int) 作为局部变量的类型,表示它是一个包含两个 int 值的元组。

示例 4-21. 正常方式声明元组变量
(int, int) p = (42, 99);

现在看看示例 4-22。这里使用了位于 System 命名空间中的 ValueTuple<int, int> 类型。但这与示例 4-21 中的声明完全等效。在 Visual Studio 或 VS Code 中,如果你将鼠标悬停在 p2 变量上,它将报告其类型为 (int, int)

示例 4-22. 声明带有其底层类型的元组变量
ValueTuple<int, int> p2 = (42, 99);

C# 的特殊语法允许给元组元素命名,这是其特有的一点。ValueTuple 系列为其元素命名为 Item1Item2Item3 等,但在 C# 中我们可以选择其他名称。当你声明一个带有命名元组元素的局部变量时,这些名称在 C# 中实际上是虚构的——在运行时完全没有表现。但是,当一个方法返回一个元组时,例如在示例 4-23 中,情况就不同了:这些名称需要可见,以便消费此方法的代码可以使用相同的名称。即使此方法位于我代码已引用的某个库组件中,我也希望能够编写 Pos().X,而不是必须使用 Pos().Item1

示例 4-23. 返回一个元组
public static (int X, int Y) Pos() => (10, 20);

为了使这一点实现,编译器将一个名为 TupleElementNames 的属性应用于方法的返回值,其中包含一个列出要使用的属性名称的数组。(第 14 章描述了属性。) 你实际上不能自己编写能够执行此操作的代码:如果你编写一个返回 ValueTuple<int, int> 的方法,并尝试将 TupleElementNamesAttribute 作为 return 属性应用,编译器将生成错误消息,告诉你不要直接使用此属性,而是使用元组语法。但是编译器正是通过该属性来报告元组元素的名称。

请注意,运行库中还有另一组元组类型,Tuple<T>Tuple<T1, T2>等。这些几乎与ValueTuple系列看起来相同。不同之处在于,Tuple系列的泛型类型都是类,而所有ValueTuple类型都是结构体。C#的轻量级元组语法仅使用ValueTuple系列。尽管如此,Tuple系列在运行库中已经存在很长时间了,因此在旧代码中经常看到它们,这些代码需要将一组值捆绑在一起而不需要为此定义新类型。

在泛型内部

如果你熟悉 C++模板,现在应该已经注意到 C#泛型与模板有很大不同。表面上看,它们有一些相似之处,并且可以用类似的方式使用——例如,都适用于实现集合类。然而,有些基于模板的技术在 C#中根本行不通,比如示例 4-24 中的代码。

示例 4-24. C# 泛型中无法工作的模板技术
public static T Add<T>(T x, T y)
{
    return x + y;  // Will not compile
}

在 C模板中可以做这种事情,但在 C#中不行,并且无法通过约束完全修复。你可以添加一个类型约束,要求T从某个类型派生或实现某个定义了自定义+运算符的接口,这样就可以编译通过,但这相当有限——它只适用于从该基类型派生的类型。在 C中,你可以编写一个模板,它将任何支持加法的类型的两个项相加在一起,无论是内置类型还是自定义类型。此外,C++模板不需要约束;编译器能够自行判断特定类型是否适用作为模板参数。

这个问题并非特定于算术运算。根本问题在于,由于泛型代码依赖于约束来确定其类型参数上可用的操作,它只能使用作为接口成员或共享基类的特性。如果.NET 中的算术运算是基于接口的,那么可以定义一个需要该接口的约束。但是操作符都是静态方法,尽管接口可以包含静态成员,⁴ 但没有支持的方法让各个类型提供自己的实现——允许每个类型提供自己接口实现的动态调度机制仅适用于实例成员。⁵

C# 泛型的限制是由其设计原理决定的,因此理解其机制非常有用。(顺便说一下,这些限制并不特定于任何特定的 CLR 实现。它们是泛型如何融入.NET 运行时设计的必然结果。)

通用方法和类型在编译时并不知道将用作参数的具体类型。这是 C#泛型与 C模板之间的根本区别——在 C中,编译器可以看到模板的每个实例化。但在 C#中,你可以在编译代码很久之后,实例化泛型类型,而无需访问任何相关源代码。毕竟,微软多年前就写了通用的List<T>类,但你今天完全可以写一个全新的类,并作为类型参数嵌入其中。 (你可能会指出 C标准库的std::vector存在更久。然而,C编译器可以访问定义类的源文件,而对于 C#和List<T>来说则不然。C#只看到已编译的库。)

结果是,C#编译器需要足够的信息来在编译泛型代码时生成类型安全的代码。看看示例 4-24。它无法知道这里的+运算符具体是什么意思,因为对于不同的类型它可能是不同的。对于内置数值类型,该代码需要编译为执行加法的特定中间语言(IL)指令。如果该代码位于检查上下文中(即使用第 2 章中显示的checked关键字),我们可能已经遇到问题,因为使用溢出检查的整数加法代码会为有符号和无符号整数使用不同的 IL 操作码。此外,由于这是一个泛型方法,我们可能根本不处理内置数值类型——也许我们正在处理定义了自定义+运算符的类型,在这种情况下,编译器需要生成一个方法调用。(自定义运算符实际上就是方法。)或者如果相关类型不支持加法操作,编译器应该生成一个错误。

对于编译简单的加法表达式,存在几种可能的结果,这取决于实际涉及的类型。当编译器知道类型时,这很好,但它必须在不知道将用作参数的类型的情况下编译泛型类型和方法的代码。

或许你会认为微软可以支持一种类似于泛型代码的暂定半编译格式,从某种意义上说,它确实做到了。在引入泛型时,微软修改了类型系统、文件格式和 IL 指令,允许泛型代码使用代表类型参数的占位符,以便在类型完全构造时填充。那么为什么不扩展以处理运算符?为什么不让编译器在编译试图使用泛型类型的代码时生成错误,而不是坚持在编译泛型代码本身时生成错误呢?好吧,事实证明,你可以在运行时插入新的类型参数集合——我们将在第十三章看到的反射 API 允许你构造泛型类型。在明显出现错误的时间点可能没有可用的编译器,因为并非所有 .NET 版本都提供了 C# 编译器的副本。无论如何,如果一个泛型类是用 C# 编写的,但被完全不同的语言消费,也许这种语言不支持操作符重载,那么该使用哪种语言的规则来决定对+操作符的处理呢?应该是编写泛型代码的语言还是编写类型参数的语言呢?(如果有多个类型参数,并且每个参数使用不同语言编写的类型,那又该怎么办呢?)或者规则应该来自于决定将类型参数插入泛型类型或方法的语言,但是如果一段泛型代码将其参数传递给其他泛型实体呢?即使你能决定哪种方法最好,这也假设在运行时确定一行代码的含义所使用的规则是可用的,这一假设再次因为运行代码的机器上可能没有相关的编译器而遇到困难。

.NET 泛型通过在泛型代码编译时使用编写泛型代码的语言的规则,要求完全定义泛型代码的含义来解决这个问题。如果泛型代码涉及使用方法或其他成员,它们必须在编译时静态解析(即这些成员的标识必须在编译时精确确定)。关键在于,这意味着泛型代码本身的编译时间,而不是消费泛型代码的代码的编译时间。这些要求解释了为什么 C# 泛型不像 C++ 使用的消费者编译时替换模型那样灵活。回报是,你可以将泛型编译成二进制形式的库,并且它们可以被支持泛型的任何 .NET 语言使用,具有完全可预测的行为。

摘要

泛型使我们能够编写带有类型参数的类型和方法,在编译时可以填充这些参数,从而生成适用于特定类型的不同版本的类型或方法。在它们首次引入时,泛型的最重要用例之一是使得编写类型安全的集合类成为可能,比如List<T>。我们将在下一章节中查看一些这样的集合类型。

¹ 在说泛型类型名称时,惯例是使用“of”这个词,比如“List of T”或“List of int”。

² 即使在启用了可空注解上下文中使用了普通的class约束,也是允许的。可空引用特性并不能完全保证非空性,因此允许与null进行比较。

³ Moq 依赖于 Castle 项目的动态代理功能来生成这种类型。如果您想在您的代码中使用类似的东西,可以在Castle 项目找到它。

⁴ 静态接口成员在.NET Framework 中不可用。

⁵ 已经有一个提案用于为静态接口成员添加动态调度。尽管它不是官方的 C# 10.0 的一部分,但.NET 6.0 SDK 包含了一个预览实现。您可以通过将EnablePreviewFeatures项目属性设置为 true 来尝试它。如果在未来版本中得到支持,也许我们会看到一个IAddable<T>

第五章:集合

大多数程序需要处理多个数据片段。例如,你的代码可能需要迭代一些交易来计算账户的余额,或者在社交媒体 Web 应用程序中显示最近的消息,或者更新游戏中角色的位置。在大多数应用程序中,处理信息集合的能力可能是非常重要的。

C# 提供了一种简单的集合类型,称为数组。CLR 的类型系统本身支持数组,因此它们很高效,但对于某些场景来说可能太基础了,因此运行库在数组提供的基础服务上构建了更强大和灵活的集合类型。我会从数组开始讲起,因为它们是大多数集合类的基础。

数组

数组是一个包含特定类型多个元素的对象。每个元素都是一个类似于字段的存储位置,但不同于字段的是,数组元素仅仅是按数字编号。数组的元素数量在其生命周期内是固定的,因此在创建数组时必须指定大小。示例 5-1 展示了创建新数组的语法。

示例 5-1. 创建数组
int[] numbers = new int[10];
string[] strings = new string[numbers.Length];

与所有对象一样,我们使用 new 关键字和类型名称构造数组,但是与构造函数参数用括号不同的是,我们使用包含数组大小的方括号。正如示例所示,定义大小的表达式可以是一个常量,但不必如此——第二个数组的大小将通过在运行时评估 numbers.Length 来确定。在这种情况下,第二个数组将有 10 个元素,因为我们使用了第一个数组的 Length 属性。所有数组都有这个只读属性,它返回数组中的总元素数。

Length 属性的类型是 int,这意味着它可以处理多达约 21 亿个元素的数组。在 32 位进程中,数组大小的限制因素可能是可用地址空间,但在.NET 支持 64 位进程后,可以使用更大的数组,因此还有一个 LongLength 属性,类型为 long。然而,你不经常看到它被使用,因为运行时当前不支持创建超过 2,147,483,591 (0x7FFFFFC7) 个元素的数组。因此,只有矩形多维数组(本章后面描述)可以包含比 Length 报告的更多元素。甚至这些数组在当前版本的.NET 上也有上限,为 4,294,967,295 (0xFFFFFFFF) 个元素。

注意

如果你使用的是.NET Framework,你将首先遇到另一个限制:单个数组通常不能占用超过 2 GB 的内存。(这是任何单个对象大小的上限。实际上,通常只有数组会遇到这个限制,尽管你可能会用特别长的字符串达到这个限制。)你可以通过在项目的App.config文件的<runtime>部分内添加<gcAllowVeryLargeObjects enabled="true" />元素来克服这一限制。前面段落中的限制仍然适用,另外还有一个额外的限制:元素类型不是byte的数组不能超过 0x7FFEFFFF 个元素。即便如此,这些限制要比 2 GB 的上限宽松得多。

在示例 5-1 中,我打破了避免变量声明中多余类型名称的常规规则。初始化表达式清楚地表明变量分别是intstring数组,所以我通常会对这种代码使用var,但我在这里做了一个例外,以便展示如何写出数组类型的名称。数组类型在其自身的权利中是不同的类型,如果我们想引用特定元素类型的单维数组类型,我们将在元素类型名称之后放置[]

所有的数组类型都派生自一个名为System.Array的共同基类。这个类定义了LengthLongLength属性以及其他我们接下来会看到的成员。你可以在所有可以使用其他类型的地方使用数组类型。所以你可以声明一个类型为string[]的字段或者方法参数。你也可以将数组类型用作泛型类型参数。例如,IEnumerable<int[]>将会是一个整数数组的序列(每个数组可能大小不同)。

无论元素类型如何,数组类型始终是引用类型。尽管如此,在引用类型和值类型元素之间的选择在数组的行为上有重大差异。正如在第三章中讨论的,当对象具有值类型字段时,该值本身存在于为对象分配的内存中。对于数组也是如此——当元素为值类型时,值存在于数组元素本身,但对于引用类型,元素只包含引用。每个引用类型的实例都有其自己的标识,由于多个变量可能最终都引用该实例,CLR 需要独立管理其生存周期,因此它将拥有自己独立的内存块。因此,虽然包含 1,000 个int值的数组可以全部存在于一个连续的内存块中,但对于引用类型,数组只包含引用,而不包含实际实例。包含 1,000 个不同字符串的数组将需要 1,001 个堆块——一个用于数组本身,每个字符串一个。

在使用引用类型元素时,你不必让引用数组中的每个元素都引用不同的对象。你可以将任意数量的元素设置为null,而且还可以自由地使多个元素引用同一个对象。这只是数组元素中引用工作方式的另一种变化,它与局部变量和字段中的引用工作方式基本相同。

要访问数组中的元素,我们使用包含我们想要使用的元素索引的方括号。索引是从零开始的。示例 5-2 展示了一些示例。

示例 5-2. 访问数组元素
// Continued from Example 5-1
numbers[0] = 42;
numbers[1] = numbers.Length;
numbers[2] = numbers[0] + numbers[1];
numbers[numbers.Length - 1] = 99;

与数组大小在构建时一样,数组索引可以是一个常量,但也可以是在运行时计算的更复杂的表达式。实际上,直接位于开放括号之前的部分也是如此。在示例 5-2 中,我只是使用了一个变量名来引用一个数组,但是你可以在任何数组类型的表达式之后使用括号。示例 5-3 检索由方法调用返回的数组的第一个元素。(示例的细节并不严格相关,但如果你在想,它找到与定义对象类型的组件相关联的版权信息。例如,如果你将一个string传递给方法,它将返回“© Microsoft Corporation. All rights reserved.” 这使用了反射 API 和自定义属性,这些是第 13 和第十四章的主题。)

示例 5-3. 复杂的数组访问
public static string GetCopyrightForType(object o)
{
    Assembly asm = o.GetType().Assembly;
    var copyrightAttribute = (AssemblyCopyrightAttribute)
        `asm``.``GetCustomAttributes``(``typeof``(``AssemblyCopyrightAttribute``)``,` `true``)``[``0``]``;`
    return copyrightAttribute.Copyright;
}

涉及数组元素访问的表达式是特殊的,因为 C#将它们视为一种变量。这意味着与局部变量和字段一样,无论是简单的表达式(如示例 5-2 中的表达式)还是更复杂的表达式(如示例 5-3 中的表达式),你都可以将它们用作赋值语句的左操作数。你还可以使用ref关键字(如第 3 章所述)将特定元素的引用传递给方法,将其存储在ref局部变量中,或者将其作为具有ref返回类型的方法的返回值。

CLR 始终检查索引与数组大小是否匹配。如果尝试使用负索引或大于或等于数组长度的索引,运行时将抛出IndexOutOfRangeException异常。

尽管数组的大小固定不变,但其内容始终可修改——并不存在只读数组。 (正如我们将在“ReadOnlyCollection”中看到的,.NET 提供了一个可以作为数组的只读外观的类。)当然,您可以创建一个具有不可变元素类型的数组,这将阻止您在原地修改元素。因此,使用.NET 提供的不可变Complex值类型的示例 5-4 将无法编译。

示例 5-4. 如何不修改具有不可变元素的数组
var values = new Complex[10];
// These lines both cause compiler errors:
values[0].Real = 10;
values[0].Imaginary = 1;

编译器抱怨因为RealImaginary属性是只读的;Complex不提供任何修改其值的方法。尽管如此,您仍然可以修改数组:即使无法就地修改现有元素,您始终可以通过提供不同的值来覆盖它,正如示例 5-5 所示。

示例 5-5. 修改具有不可变元素的数组
var values = new Complex[10];
values[0] = new Complex(10, 1);

无论如何,只读数组在任何情况下都没有什么用,因为所有数组最初都填充了默认值,您无法指定。CLR 会用零填充新数组的内存,因此您将看到0nullfalse,具体取决于数组的元素类型。

警告

C# 10.0 添加了为struct编写零参数构造函数的能力。您可能期望数组创建自动调用此类构造函数。事实并非如此。

对于某些应用程序来说,全零(或等效)内容可能是数组的有用初始状态,但在某些情况下,您可能需要在开始工作之前设置一些其他内容。

数组初始化

初始化数组最直接的方法是依次为每个元素分配值。示例 5-6 创建了一个string数组,由于string是引用类型,创建五个元素的数组并不会创建五个字符串。我们的数组最初有五个空值。(即使您已启用 C#的可空引用功能,如第三章所述。不幸的是,数组初始化是使该功能无法提供绝对非空性保证的漏洞之一。)因此,示例继续为每个数组元素填充了对字符串的引用。

示例 5-6. 繁琐的数组初始化
var workingWeekDayNames = new string[5];
workingWeekDayNames[0] = "Monday";
workingWeekDayNames[1] = "Tuesday";
workingWeekDayNames[2] = "Wednesday";
workingWeekDayNames[3] = "Thursday";
workingWeekDayNames[4] = "Friday";

这种方法虽然可行,但冗长了。C#支持一种更简洁的语法,可以实现相同的效果,详见示例 5-7。编译器将其转换为与示例 5-6 类似的代码。

示例 5-7. 数组初始化语法
var workingWeekDayNames = new string[]
    { "Monday", "Tuesday", "Wednesday", "Thursday", "Friday" };

你可以更进一步。示例 5-8 显示,如果在变量声明中明确指定类型,你可以只写初始化列表,省略 new 关键字。顺便说一句,这只在初始化表达式中有效;在其他表达式中(如赋值或方法参数),你不能使用这种语法创建数组。(在 示例 5-7 中更详细的初始化表达式在所有这些上下文中都有效。)

示例 5-8. 更短的数组初始化语法
string[] workingWeekDayNames =
    { "Monday", "Tuesday", "Wednesday", "Thursday", "Friday" };

我们还可以更进一步:如果数组初始化列表内的所有表达式都是相同类型,编译器可以推断出数组类型,因此我们可以只写 new[] 而不需要显式元素类型。示例 5-9 就是这样做的。

示例 5-9. 元素类型推断的数组初始化语法
var workingWeekDayNames = new[]
    { "Monday", "Tuesday", "Wednesday", "Thursday", "Friday" };

实际上,这比 示例 5-8 稍微长一些。但是,与 示例 5-7 一样,这种风格并不局限于变量初始化。例如,在需要将数组作为参数传递给方法时,也可以使用它。如果你创建的数组只会被传递到方法中并且不再被引用,你可能不想声明一个变量来引用它。直接在参数列表中写数组可能更加简洁。示例 5-10 就是使用这种技术将字符串数组传递给方法的示例。

示例 5-10. 作为参数的数组
SetHeaders(new[] { "Monday", "Tuesday", "Wednesday", "Thursday", "Friday" });

搜索和排序

有时候,你可能不知道需要的数组元素的索引。例如,假设你正在编写一个显示最近使用文件列表的应用程序。每次用户在你的应用程序中打开一个文件时,你都希望将该文件移动到列表顶部,并且你需要检测文件是否已经在列表中,以避免出现多次显示。如果用户恰好使用你的最近文件列表打开文件,你已经知道它在列表中且位于哪个偏移量。但是如果用户以其他方式打开文件呢?在这种情况下,你有一个文件名,需要找出它在列表中的位置,如果存在的话。

数组可以帮助你在这种情况下找到你想要的项。有些方法会逐个检查每个元素,停在第一个匹配项上,还有一些方法可以在特定顺序存储其元素的数组中更快地工作。为了帮助处理这种情况,还有一些方法可以对数组内容进行排序,按照你需要的任何顺序排序。

静态的 Array.IndexOf 方法提供了搜索元素的最简单方法。它不需要你的数组元素处于任何特定顺序:你只需传递要搜索的数组和你要查找的值,它将遍历元素直到找到与你想要的值相等的元素。它返回找到的第一个匹配元素的索引,如果在数组末尾没有找到匹配项则返回 −1。示例 5-11 展示了如何在更新最近打开文件列表的逻辑中使用此方法。

示例 5-11. 使用 IndexOf 进行搜索
int recentFileListIndex = Array.IndexOf(myRecentFiles, openedFile);
if (recentFileListIndex < 0)
{
    AddNewRecentEntry(openedFile);
}
else
{
    MoveExistingRecentEntryToTop(recentFileListIndex);
}

该示例从数组的开头开始搜索,但你也有其他选项。IndexOf 方法是重载的,你可以传递一个起始搜索的索引,还可以选择传递第二个数字,表示在放弃搜索之前要查看的元素数。还有一个 LastIndexOf 方法,它是反向工作的。如果你不指定索引,它将从数组的末尾开始向前工作。与 IndexOf 类似,你可以提供一个或两个额外的参数,指示你想要开始搜索的偏移量以及要检查的元素数。

如果你确切知道你要查找的值,这些方法都很好用。但通常情况下,你可能需要更加灵活:你可能想找到符合某些特定条件的第一个(或最后一个)元素。例如,假设你有一个表示直方图中箱子值的数组。找到第一个非空箱子可能是有用的。因此,你不是在寻找特定值,而是想找到第一个值不为零的元素。示例 5-12 展示了如何使用 FindIndex 方法来定位第一个符合条件的条目。

示例 5-12. 使用 FindIndex 进行搜索
public static int GetIndexOfFirstNonEmptyBin(int[] bins)
    => Array.FindIndex(bins, IsNonZero);

private static bool IsNonZero(int value) => value != 0;

我的 IsNonZero 方法包含决定任何特定元素是否匹配的逻辑,并将该方法作为参数传递给 FindIndex。你可以传递任何具有合适签名的方法 —— FindIndex 需要一个接受数组元素类型的实例并返回 bool 的方法。(严格来说,它接受一个 Predicate<T>,这是一种委托,我将在第九章讨论。)由于任何具有适当签名的方法都可以,我们可以使我们的搜索条件简单或者复杂,随心所欲。

顺便提一句,这个特定示例的逻辑如此简单,以至于为条件单独编写一个方法可能有些大材小用。对于这类简单情况,你几乎肯定会使用 lambda 语法(使用 => 表示表达式代表内联函数),而不是单独编写方法。这也是我将在 第九章 中讨论的内容,所以这有些超前,但我只是展示一下它的样子,因为更为简洁。示例 5-13 的效果与 示例 5-12 完全相同,但不需要我们显式声明和编写一个完整的额外方法。(并且在撰写本文时,它也更高效,因为使用 lambda,编译器生成代码以重用它创建的 Predicate<T> 对象,而 示例 5-12 每次都会构造一个新的对象。)

示例 5-13. 使用 lambda 和 FindIndex
public static int GetIndexOfFirstNonEmptyBin(int[] bins)
    => Array.FindIndex(bins, value => value != 0);

IndexOf 类似,FindIndex 提供了重载,允许您指定开始搜索的偏移量和在放弃之前检查的元素数量。Array 类还提供了 FindLastIndex,它向后工作,对应于 LastIndexOf,就像 FindIndex 对应于 IndexOf 一样。

当您搜索满足某些特定条件的数组条目时,您可能并不那么关心匹配元素的索引,您可能只需要知道第一个匹配的值。显然,获得这个值非常容易:您可以结合数组索引语法使用 FindIndex 返回的值。然而,您并不需要这样做,因为 Array 类提供了 FindFindLast 方法,以完全相同的方式进行搜索,但返回第一个或最后一个匹配的值,而不是返回找到该值的索引。

数组可能包含多个满足您条件的项,您可能希望找到它们全部。您可以编写一个循环调用 FindIndex,将前一个匹配项的索引加一作为下一个搜索的起点,重复此过程,直到达到数组的末尾或得到一个结果为 -1,表示没有找到更多的匹配项。如果您只对知道所有匹配值感兴趣,而不需要准确知道这些值在数组中的位置,您可以使用 示例 5-14 中展示的 FindAll 方法来完成所有工作。

示例 5-14. 使用 FindAll 查找多个项
public static T[] GetNonNullItems<T>(T[] items) where T : class
    => Array.FindAll(items, value => value != null);

这个方法接受任何包含引用类型元素的数组,并返回一个仅包含该数组中非空元素的数组。

到目前为止,我展示的所有搜索方法都是按顺序遍历数组的元素,逐个测试每个元素。这种方法已经足够有效,但对于大型数组来说,可能会显得不必要地昂贵,特别是在比较相对复杂的情况下。即使是简单的比较,如果你需要处理数百万个元素的数组,这种搜索方式也可能耗费足够长的时间以至于引入明显的延迟。但是,我们可以做得更好。例如,如果给定一个按升序排序的值数组,二分查找 的性能可以提高数个数量级。示例 5-15 展示了两种方法。首先,Sort 方法将数字数组按升序排序。然后,如果我们有这样一个已排序的数组,我们可以将其传递给 Find 方法,该方法使用 Array.BinarySearch 方法。

示例 5-15. 排序数组和 BinarySearch
void Sort(int[] numbers)
{
    Array.Sort(numbers);
}

int Find(int[] numbers, int searchFor)
{
    return Array.BinarySearch(numbers, searchFor);
}

二分查找是一种广泛使用的算法,利用输入已排序的事实,能够在每一步排除一半的数组。它从数组中间开始。如果恰好这个值就是我们需要的值,搜索可以停止,否则,根据它找到的值是高于还是低于我们想要的值,它可以立即知道值会在数组的哪一半(如果存在的话)。然后它跳到剩余一半的中间,如果那不是正确的值,再次可以确定哪一部分将包含目标。在每一步中,它通过一半来缩小搜索范围,几次减半后,搜索将缩小到单个项。如果这不是它正在寻找的值,那么所需的项就不存在。

提示

BinarySearch 在未找到值时会生成负数。在这些情况下,这个二分搜索过程将会在最接近我们正在寻找的值处结束,并且这可能是有用的信息。因此,负数仍然告诉我们搜索失败,但这个数是最接近匹配的索引的负数。

二分查找比简单的线性搜索更复杂,但对于大数组来说,它效果很明显,因为需要的迭代次数大大减少。给定一个包含 100,000,000 个元素的数组,它只需执行 27 步,而不是 100,000,000 步。显然,对于较小的数组,改进有限,而二分查找的相对复杂性达到了一定的最小数组大小,超过这个最小数组大小时,线性搜索可能更快。但对于包含 100,000,000 个int元素的数组,二分查找肯定是明显的胜利者。需要最多工作的情况是它找不到匹配项(产生负结果),在这些情况下,BinarySearchArray.IndexOf执行的线性搜索快了超过 19,000 倍。但是,你需要注意:二分查找仅适用于已经排序的数据,将数据排序的成本可能会超过改进搜索速度的好处。对于包含 100,000,000 个int的数组,你需要在成本超过改进搜索速度之前进行大约 500 次搜索,并且,当然,只有在这期间没有任何强制您重新排序的变化时才有效。在性能调整中,查看整体场景而不仅仅是微基准测试非常重要。

顺便说一下,Array.BinarySearch提供了用于在数组某个子段内搜索的重载,类似于我们看到的其他搜索方法。它还允许您自定义比较逻辑。这与我在早期章节展示的比较接口一起工作。默认情况下,它将使用数组元素自身提供的IComparable<T>实现,但您可以提供自定义的IComparer<T>。我用来对元素进行排序的Array.Sort方法也支持缩小范围和使用自定义比较逻辑。

除了Array类本身提供的搜索和排序方法之外,还有其他的搜索和排序方法。所有的数组都实现了IEnumerable<T>(其中T是数组的元素类型),这意味着你也可以使用.NET 的LINQ to Objects功能提供的任何操作。这为搜索、排序、分组、过滤以及一般对象集合处理提供了更广泛的功能;第十章将详细描述这些功能。数组在.NET 中存在的时间比 LINQ 更长,这是功能重叠的一个原因,但数组提供了自己的标准 LINQ 操作符等价物,有时会更高效,因为 LINQ 是一个更通用的解决方案。

多维数组

到目前为止,我展示的数组都是一维的,但 C#支持两种多维形式:交错数组矩形数组

交错数组

不规则数组简单地是数组的数组。这种类型的数组的存在是数组类型与其元素类型不同的自然结果。因为int[]是一种类型,您可以将其用作另一个数组的元素类型。示例 5-16 展示了几乎毫不意外的语法。

示例 5-16. 创建不规则数组
int[][] arrays = new int[5][]
{
    new[] { 1, 2 },
    new[] { 1, 2, 3, 4, 5, 6 },
    new[] { 1, 2, 4 },
    new[] { 1 },
    new[] { 1, 2, 3, 4, 5 }
};

再次,我打破了通常的变量声明规则 —— 通常我会在第一行使用var,因为类型从初始化器中就能明确,但我想展示声明变量和构造数组的语法。在示例 5-16 中还有第二个冗余之处:使用数组初始化器语法时,不必明确指定大小,因为编译器会自动计算。我已经利用了这一点来处理嵌套数组,但为了显示大小(5)出现的位置,我明确为外部数组设置了大小,因为这可能不是您期望的位置。

不规则数组的类型名称足够简单。一般而言,数组类型的形式是*ElementType*[],因此如果元素类型是int[],我们期望结果数组类型应写成int[][],这也是我们看到的。构造函数的语法稍微奇特一些。它声明了一个包含五个数组的数组,乍一看,new int[5][]似乎是表达这种意图的完全合理的方式。对于不规则数组的数组索引语法保持一致;我们可以写arrays[1][3],它获取这五个数组中的第二个数组,然后从该第二个数组中检索第四个元素。(顺便说一句,这不是专门的语法 —— 这里没有需要特别处理的地方,因为任何求值为数组的表达式都可以跟随方括号中的索引。表达式arrays[1]求值为一个int[]数组,所以我们可以跟随[3]。)

然而,new关键字确实会特殊对待不规则数组。它使它们看起来与数组元素访问语法一致,但必须稍微扭曲一下才能做到这一点。对于一维数组,构造新数组的模式是new *ElementType*[*length*],因此创建五个元素的数组,您希望写成new *ElementType*[5]。如果您要创建的是int数组,您是否期望看到int[]代替*ElementType*?这将意味着语法应该是new int[][5]

这看起来是逻辑的,但似乎是错误的,这是因为数组类型的语法本身实际上是反向的。数组是构造类型,就像泛型一样。对于泛型,我们从中构造实际类型的泛型类型名称在类型参数之前(例如,List<int> 使用泛型 List<T> 类型,并用 int 类型参数构造它)。如果数组具有类似泛型的语法,我们可能会期望看到 array<int> 表示一维数组,array<array<int>> 表示二维数组,依此类推——元素类型会在表示我们想要数组的部分之后出现。但数组类型反其道而行——数组性由 [] 字符表示,因此元素类型首先出现。这就是为什么数组构造的假设逻辑正确的语法看起来很奇怪。C# 避免了这种奇怪感,不过于强调逻辑,在大多数人期望的地方放置尺寸而不是应该放置的地方。

注意

语法可以显而易见地扩展——例如,int[][][] 表示类型,new int[5][][] 表示构造。C# 不定义维度数量的特定限制,但存在一些特定于实现的运行时限制。(微软的编译器在我要求创建一个 5000 维 jagged 数组时毫不畏惧,但 CLR 拒绝加载生成的程序。事实上,它不会加载超过 1166 维的任何东西。)

示例 5-16 用五个一维 int[] 数组初始化数组。代码的布局应该很清楚地说明为什么这种类型的数组被称为 jagged:每一行长度不同。对于数组的数组,没有要求是矩形布局。我可以进一步讲解。数组是引用类型,所以我可以将一些行设置为 null。如果我放弃数组初始化器语法,逐个初始化数组元素,我可以决定让一些一维 int[] 数组出现在多行中。

因为这个 jagged 数组中的每一行都包含一个数组,所以这里我最终有了六个对象——五个 int[] 数组,然后是包含对它们引用的 int[][] 数组。如果引入更多维度,将会得到更多数组。对于某些工作来说,非矩形和大量对象可能会成为问题,这就是为什么 C# 支持另一种多维数组的原因。

矩形数组

矩形数组是支持多维索引的单个数组对象。如果 C#没有提供多维数组,我们可以按照惯例构建类似它们的东西。如果您想要一个包含 10 行和 5 列的数组,您可以构造一个具有 50 个元素的一维数组,然后使用像myArray[i + (5 * j)]这样的代码来访问它,其中i是列索引,j是行索引。那将是一个您选择将其视为二维的数组,尽管它实际上只是一个大的连续块。矩形数组本质上是相同的概念,但是在其中 C#为您做了这项工作。 示例 5-17 展示了如何声明和构造矩形数组。

注意

矩形数组不仅仅是便利性问题。还有一个类型安全的方面:int[,]是与int[]int[,,]不同的类型,因此如果您编写一个期望二维矩形数组的方法,C#将不允许传递其他类型。

示例 5-17. 矩形数组
int[,] grid = new int[5, 10];
var smallerGrid = new int[,]
{
    { 1, 2, 3, 4 },
    { 2, 3, 4, 5 },
    { 3, 4, 5, 6 }
};

矩形数组类型名称仅使用一对方括号,无论它们有多少维。括号内部逗号的数量表示维度的数量,因此这些具有一个逗号的示例是二维的。与不规则数组相比,运行时似乎对矩形数组的维度数设置了一个更低的限制。在.NET 6.0 中,试图使用超过 32 个维度的矩形数组的程序将无法加载。

初始化语法与多维数组非常相似(参见示例 5-16),但我没有像那样用new[]来开始每一行,因为这是一个大数组,而不是数组的数组。 示例 5-17 中的数字形成了一个明显是矩形的形状,如果您尝试使事情变得不规则(使用不同的行大小),编译器将报告错误。这种情况也适用于更高的维度。如果您想要一个三维的“矩形”数组,它将需要是一个cuboid。 示例 5-18 展示了一个 cuboid 数组。您可以将初始化器视为由两个矩形切片组成的 cuboid 的列表。而且您可以升级,使用hypercuboid数组(尽管无论您使用多少维度,它们仍然被称为矩形数组)。

示例 5-18. 一个 2 × 3 × 5 的 cuboid“矩形”数组
var cuboid = new int[,,]
{
    {
        { 1, 2, 3, 4, 5 },
        { 2, 3, 4, 5, 6 },
        { 3, 4, 5, 6, 7 }
    },
    {
        { 2, 3, 4, 5, 6 },
        { 3, 4, 5, 6, 7 },
        { 4, 5, 6, 7, 8 }
    }
};

访问矩形数组的语法足够可预测。如果来自示例 5-17 的第二个变量在作用域内,我们可以写smallerGrid[2, 3]来访问数组中的最后一项;与单维数组一样,索引是从零开始的,因此这指的是第三行的第四个项目。

请记住,数组的 Length 属性返回数组中的元素总数。由于矩形数组将所有元素存储在单个数组中(而不是引用其他数组),因此它将返回所有维度大小的乘积。例如,一个具有 5 行和 10 列的矩形数组的 Length 为 50。如果你想在运行时发现特定维度的大小,请使用 GetLength 方法,该方法接受一个 int 参数,指示你想知道大小的维度。

复制和调整大小

有时你会希望在数组中移动数据块。也许你想在数组的中间插入一个项目,将其后的项目向上移动一个位置(并丢失最后一个元素,因为数组大小是固定的)。或者你可能想要将数据从一个数组移动到另一个数组,也许它们大小不同。

静态 Array.Copy 方法接受两个数组的引用,以及一个指示要复制多少个元素的数字。它提供了多个重载,以便你可以指定在两个数组中开始复制的位置。(更简单的重载从每个数组的第一个元素开始。)你可以将源数组和目标数组作为同一个数组传递,并且它会正确处理重叠:复制动作就像首先将所有元素复制到临时位置,然后开始将它们写入目标位置。

警告

除了静态的 Copy 方法外,Array 类还定义了非静态的 CopyTo 方法,它将整个数组复制到目标数组中,从指定的偏移位置开始。此方法存在的原因是因为所有数组实现了某些集合接口,包括 ICollection<T>(其中 T 是数组的元素类型),该接口定义了这个 CopyTo 方法。它比 Copy 方法不够灵活 —— CopyTo 不能复制数组的子范围。在两种方法都能使用的情况下,文档建议使用 Array.Copy —— CopyTo 只是为了通用代码的利益,可以与任何集合接口的实现一起使用。

当需要处理可变数量的数据时,将元素从一个数组复制到另一个数组可能是必要的。通常情况下,你会分配一个比最初需要的更大的数组,如果最终填满了,你将需要一个新的更大数组,并且需要将旧数组的内容复制到新数组中。事实上,Array 类可以通过其 Resize 方法为一维数组执行此操作。方法名有些误导,因为数组无法调整大小,所以它会分配一个新的数组,并将旧数组的数据复制到其中。Resize 可以构建一个更大或更小的数组,如果你要求一个更小的数组,它只会复制尽可能多的元素。

当我谈论复制数组数据的方法时,我应该提到Reverse,它简单地颠倒数组元素的顺序。还有,虽然这不严格属于复制的范畴,但Array.Clear方法在处理需要频繁变换数组大小的场景时非常有用——它允许你将数组的某个范围重置为初始的零值状态。

这些在数组内部移动数据的方法对于在基本数组服务的基础上构建更灵活的数据结构非常有用。但通常你自己不需要使用它们,因为运行时库提供了几个有用的集合类来代替这些工作。

List<T>

List<T>类定义在System.Collections.Generic命名空间中,包含类型为T的元素的可变长度序列。它提供了一个索引器,允许你按编号获取和设置元素,因此List<T>表现得像一个可调整大小的数组。它并非完全可互换—你不能将List<T>作为期望T[]数组的参数传递—但数组和List<T>都实现了各种常见的泛型集合接口,我们稍后将会讨论这些接口。例如,如果你编写一个接受IList<T>的方法,它将能够与数组或List<T>一起使用。

虽然使用索引器的代码看起来像是访问数组元素,但实际上并非完全相同。索引器是一种属性,因此它在可变值类型方面与我在第三章中讨论过的问题相同。给定类型为List<Point>(其中PointSystem.Windows命名空间中的可变值类型)的变量pointList,你不能编写pointList[2].X = 2,因为pointList[2]返回的是值的副本,而这段代码实际上是要求修改那个临时副本。这会导致更新丢失,因此 C#禁止这样做。但对于数组来说,这是可行的。如果pointArray的类型是Point[]pointArray[2]不是获取元素,而是标识元素,这使得通过写pointArray[2].X = 2可以直接修改数组元素的值。尽管在 C#中添加了ref返回值后,可以编写按此方式工作的索引器,但List<T>IList<T>是在此之前创建的。对于像Complex这样的不可变值类型,这种区别是无关紧要的,因为无论是使用数组还是列表,你都不能直接修改它们的值—你必须用新值覆盖元素。

不像数组,List<T> 提供了可以改变其大小的方法。Add 方法将一个新元素追加到列表的末尾,而 AddRange 可以添加多个元素。InsertInsertRange 在列表的任意位置添加元素,将插入点后的所有元素向下移动以腾出空间。这四种方法都使列表变长,但是 List<T> 也提供了 Remove 方法,用于移除指定值的第一个实例;RemoveAt 方法用于移除特定索引处的元素;以及 RemoveRange 方法,用于从特定索引开始移除多个元素。所有这些方法都会将元素向下移动,以关闭被移除元素或元素留下的空隙,从而使列表变短。

注意

List<T> 在内部使用数组来存储其元素。这意味着所有元素都存储在单个内存块中,并且它们是连续存储的。这使得正常的元素访问非常高效,但也是为什么插入需要将元素向上移动以腾出空间,并且移除需要将元素向下移动以关闭空隙的原因。

示例 5-19 展示了如何创建一个 List<T>。它只是一个类,因此我们使用常规的构造函数语法。它展示了如何添加和移除条目,以及如何使用类似数组的索引器语法访问元素。这还显示了 List<T> 通过 Count 属性提供其大小。这个名字可能看起来和数组提供的 Length 有些不同,但原因是:这个属性是由 ICollection<T> 定义的,而 List<T> 实现了它。并非所有的 ICollection<T> 实现都是序列,因此在某些情况下 Length 可能不合适。(恰好,数组也提供 Count,因为它们也实现了 ICollectionICollection<T>。然而,它们使用显式接口实现,这意味着只能通过这些接口类型的引用看到数组的 Count 属性。)

示例 5-19. 使用 List<T>
var numbers = new List<int>();
numbers.Add(123);
numbers.Add(99);
numbers.Add(42);
Console.WriteLine(numbers.Count);
Console.WriteLine($"{numbers[0]}, {numbers[1]}, {numbers[2]}");

numbers[1] += 1;
Console.WriteLine(numbers[1]);

numbers.RemoveAt(1);
Console.WriteLine(numbers.Count);
Console.WriteLine($"{numbers[0]}, {numbers[1]}");

因为 List<T> 可以根据需要增长和收缩,所以在构造时不需要指定其大小。但是,如果需要的话,可以指定其容量。列表的容量是它当前可用于存储元素的空间量,这通常与它包含的元素数量不同。为了避免在每次添加或移除元素时都分配一个新的内部数组,它会独立跟踪使用的元素数量,而不是数组的大小。当需要更多空间时,它会过度分配,创建一个比需要的大的新数组,过度分配的量与列表当前大小成比例。这意味着,如果程序重复向列表添加项目,列表越大,它需要分配新数组的频率就越低,但每次重新分配后剩余容量的比例将保持大致相同。

If you know up front that you will eventually store a specific number of elements in a list, you can pass that number to the constructor, and it will allocate exactly that much capacity, meaning that no further reallocation will be required. If you get this wrong, it won’t cause an error—you’re just requesting an initial capacity, and it’s OK to change your mind later.

If the idea of unused memory going to waste in a list offends you, but you don’t know exactly how much space will be required before you start, you could call the TrimExcess method once you know the list is complete. This reallocates the internal storage to be exactly large enough to hold the list’s current contents, eliminating waste. This will not always be a win. To ensure that it is using exactly the right amount of space, TrimExcess has to create a new array of the right size, leaving the old, oversized one to be reclaimed by the garbage collector later on, and in some scenarios, the overhead of forcing an extra allocation just to trim things down to size may be higher than the overhead of having some unused capacity.

Lists have a third constructor. Besides the default constructor, and the one that takes a capacity, you can also pass in a collection of data with which to initialize the list. You can pass any IEnumerable<T>.

You can provide initial content for lists with syntax similar to an array initializer. Example 5-20 loads the same three values into the new list as at the start of Example 5-19.

Example 5-20. List initializer
var numbers = new List<int> { 123, 99, 42 };

If you’re not using var, you can omit the type name after the new keyword, as Example 5-21 shows. But in contrast to arrays, you cannot omit the new keyword entirely. Nor will the compiler infer the type argument, so whereas with an array you can write just new[] followed by an initializer, you cannot write new List<>.

Example 5-21. List initializer with target-typed new
List<int> numbers = new() { 123, 99, 42 };

Examples 5-20 and 5-21 are equivalent, and each compile into code that calls Add once for each item in the list. You can use this syntax with any type that has a suitable Add method and implements the IEnumerable interface. This works even if Add is an extension method. (So if some type implements IEnumerable, but does not supply an Add method, you are free to use this initializer syntax if you provide your own Add.)

List<T> provides IndexOf, LastIndexOf, Find, FindLast, FindAll, Sort, and Bin⁠ary​Sea⁠rch methods for finding and sorting list elements. These provide the same services as their array namesakes, although List<T> chooses to provide these as instance methods rather than statics.

我们现在已经看到了两种表示值列表的方式:数组和列表。幸运的是,接口使得可以编写既可以与数组又可以与列表一起工作的代码,因此如果想支持这两种情况,您不需要编写两套函数。

列表和序列接口

运行时库定义了几个代表集合的接口。其中三个与简单线性序列相关,可以存储在数组或列表中:IList<T>ICollection<T>IEnumerable<T>,全部位于 Sys⁠tem.​Col⁠lec⁠tio⁠ns.⁠Gen⁠eri⁠cs 命名空间。这里有三个接口,因为不同的代码有不同的要求。有些方法需要对集合中的任何编号元素进行随机访问,但并非所有情况都需要,也不是所有集合都能支持这样做——有些序列会逐渐产生元素,可能没有办法直接跳到第 n 个元素。例如,考虑表示按键的序列——每个项目只会在用户按下下一个键时出现。如果选择较少要求的接口,您的代码可以与更广泛的数据源一起工作。

IEnumerable<T> 是集合接口中最通用的一个,因为它对其实现者的要求最少。我已经多次提到它了,因为它是一个经常出现的重要接口,但直到现在我还没有展示其定义。正如示例 5-22 所示,它只声明了一个方法。

示例 5-22. IEnumerable<T>IEnumerable
public interface IEnumerable<out T> : IEnumerable
{
    IEnumerator<T> GetEnumerator();
}

public interface IEnumerable
{
    IEnumerator GetEnumerator();
}

使用继承,IEnumerable<T> 要求其实现者同时实现 IEnumerable,后者几乎与前者相同。它是 IEnumerable<T> 的非泛型版本,其 GetEnumerator 方法通常不会做更多事情,只是调用泛型实现。之所以存在这两种形式,是因为在 .NET 1.0 中就有非泛型的 IEnumerable,但该版本不支持泛型。在 .NET 2.0 中引入泛型后,可以更精确地表达 IEnumerable 的意图,但为了保持兼容性,旧接口仍然存在。因此,这两个接口实际上要求相同的内容:一个返回枚举器的方法。什么是枚举器?示例 5-23 显示了泛型和非泛型接口。

示例 5-23. IEnumerator<T>IEnumerator
public interface IEnumerator<out T> : IDisposable, IEnumerator
{
    T Current { get; }
}

public interface IEnumerator
{
    bool MoveNext();
    object Current { get; }
    void Reset();
}

对于 IEnumerable<T>(以及 IEnumerable),使用模型是调用 GetEnumerator 来获取枚举器,该枚举器可用于遍历集合中的所有项目。您调用枚举器的 MoveNext() 方法;如果它返回 false,则表示集合为空。否则,Current 属性现在将提供集合中的第一个项目。然后,再次调用 MoveNext() 来移动到下一个项目,并且只要它继续返回 trueCurrent 将提供下一个项目。(Reset 方法是一种历史遗留物,用于帮助与 COM(Windows 中的 .NET 之前的跨语言对象模型)兼容。文档允许实现从 Reset 抛出 NotSupportedException,因此您通常不会使用此方法。)

注意

请注意,IEnumerator<T> 的实现必须实现 IDisposable。完成枚举后,您必须调用枚举器的 Dispose 方法,因为其中许多依赖于此。

在 C# 中,foreach 循环会为您完成遍历可枚举集合所需的所有工作[¹],包括生成调用 Dispose 的代码,即使循环由于 break 语句、错误或者(不可思议的)goto 语句而提前终止。第七章将更详细地描述 IDisposable 的用法。

IEnumerable<T> 是 LINQ to Objects 的核心,在第十章中将进行讨论。LINQ 操作符可用于实现此接口的任何对象。运行时库定义了一个相关的接口 IAsyncEnumerable<T>。从概念上讲,它与 IEnumerable<T> 相同:它表示提供项目序列的能力。不同之处在于,它允许异步枚举项目。正如示例 5-24 所示,此接口及其对应的 IAsyncEnumerator<T>IEnumerable<T>IEnumerator<T> 类似。主要区别在于使用异步编程功能 ValueTask<T>CancellationToken,将在第十六章中描述。还有一些小的区别:这些接口没有泛型版本,也没有重置现有异步枚举器的功能(尽管如前所述,许多同步枚举器在调用 Reset 时会抛出 NotSupportedException)。

示例 5-24. IAsyncEnumerable<T>IAsyncEnumerator<T> 的用法模型
public interface IAsyncEnumerable<out T>
{
    IAsyncEnumerator<T> GetAsyncEnumerator(
        CancellationToken cancellationToken = default);
}

public interface IAsyncEnumerator<out T> : IAsyncDisposable
{
    T Current { get; }

    ValueTask<bool> MoveNextAsync();
}

您可以使用特殊形式的 foreach 循环消耗 IAsyncEnumerable<T>,在这种情况下,您需要在前面加上 await 关键字。这只能在使用 async 关键字标记的方法中使用。第十七章详细描述了 asyncawait 关键字,以及 await foreach 的用法。

虽然IEnumerable<T>非常重要且被广泛使用,但它相当受限制。你只能依次要求它一个项,并且它会按照它认为合适的顺序分发它们。它不提供修改集合的方法,甚至没有办法在不迭代整个集合的情况下找出集合包含的项数。对于这些工作,我们有ICollection<T>,它在示例 5-25 中展示。

示例 5-25. ICollection<T>
public interface ICollection<T> : IEnumerable<T>, IEnumerable
{
    void Add(T item);
    void Clear();
    bool Contains(T item);
    void CopyTo(T[] array, int arrayIndex);
    bool Remove(T item);

    int Count { get; }
    bool IsReadOnly { get; }
}

这要求实现者还必须提供IEnumerable<T>,但注意这个接口并不继承非泛型的ICollection。确实有这样一个接口,但它代表了一个不同的抽象:它除了CopyTo方法外没有任何方法。在引入泛型时,微软审查了旧的非泛型集合类型的使用方式,并得出结论,旧的ICollection增加的那一个额外方法并没有使它比IEnumerable更加有用。更糟糕的是,它还包含了一个名为SyncRoot的属性,旨在帮助管理某些多线程场景,但实际上证明这是一个解决该问题的不良方案。因此,ICollection所代表的抽象并没有得到泛型等价物,并且并没有被深切怀念。在审查过程中,微软还发现缺少一个通用的可修改集合的接口是一个问题,因此制定了ICollection<T>以解决这个问题。将这个旧名称附加到不同的抽象上固然不完全有助于问题的解决,但由于几乎没有人在使用旧的非泛型ICollection,这似乎并没有造成太大麻烦。

第三个顺序集合的接口是IList<T>,所有实现它的类型都必须实现ICollection<T>,因此也必须实现IEnumerable<T>。正如你所预料的那样,List<T>实现了IList<T>。数组也实现了它,使用它们的元素类型作为T的参数。示例 5-26 展示了接口的样子。

示例 5-26. IList<T>
public interface IList<T> : ICollection<T>, IEnumerable<T>, IEnumerable
{
    int IndexOf(T item);
    void Insert(int index, T item);
    void RemoveAt(int index);

    T this[int index] { get; set; }
}

虽然有一个非泛型的IList,但这个接口与它没有直接关系,尽管两个接口表示类似的概念——非泛型的IListIList<T>成员的对应物,它还包括了几乎所有ICollection<T>的成员,包括ICollection缺失的所有成员。因此,要求IList<T>的实现同时实现IList本来是可能的,但这将会强制实现者提供大多数成员的两个版本,一个使用类型参数T,另一个使用object,因为旧的非泛型接口就是这样使用的。这也将迫使集合提供没有用处的SyncRoot属性。这些不便之处远不值得好处,因此IList<T>的实现不需要实现IList。它们可以选择这样做,List<T>选择了,但这取决于各个集合类。

由于这三个泛型接口的关系方式,不幸的是它们没有提供一个表示只读索引集合的抽象,甚至没有提供固定大小的抽象。虽然IEnumerable<T>是一个只读的抽象,但它是一个按顺序的抽象,没有直接访问第n个值的方法。IList<T>提供了索引访问,但它还定义了插入和索引移除的方法,并且要求实现ICollection<T>,其包括添加和基于值的移除方法。所以你可能会想知道为什么数组可以实现这些接口,因为所有的数组都是固定大小的。

数组通过使用显式接口实现来缓解这个问题,隐藏可以改变列表长度的IList<T>方法,阻止你尝试使用它们。(正如你在第三章看到的,这种技术使你能够提供接口的完整实现,但可以选择性地使某些成员直接可见。)然而,你可以将数组的引用存储在类型为IList<T>的变量中,从而使这些方法可见——示例 5-27 使用此方法调用数组的IList<T>.Add方法。然而,这将导致运行时错误。

示例 5-27. 尝试(并失败)扩展数组
IList<int> array = new[] { 1, 2, 3 };
array.Add(4);  // Will throw an exception

Add方法抛出一个NotSupportedException,错误消息表明集合的大小是固定的。如果你查看IList<T>ICollection<T>的文档,你会看到所有可能修改集合的成员都可以抛出此错误。你可以使用ICollection<T>接口的IsReadOnly属性在运行时发现是否会发生这种情况。然而,这并不能帮助你事先发现集合只允许某些更改。(例如,数组的大小是固定的,但你仍然可以修改元素。)

这造成了一个令人恼火的问题:如果你在编写确实需要可修改集合的代码,却无法声明这一事实。如果一个方法接受IList<T>,很难知道该方法是否会尝试调整列表的大小。不匹配会导致运行时异常,这些异常很可能出现在并没有做错事情的代码中,而错误——传递了错误类型的集合——是调用者的问题。这些问题并不是致命错误;在动态类型语言中,这种编译时不确定性实际上是常态,并且不会妨碍你编写良好的代码。

这里有一个ReadOnlyCollection<T>类,但正如我们稍后将看到的,它解决的是不同的问题——它是一个包装类,而不是一个接口,因此有很多固定大小的集合并不提供ReadOnlyCollection<T>。如果您要编写一个参数类型为ReadOnlyCollection<T>的方法,它将无法直接与某些类型的集合(包括数组)一起工作。无论如何,它甚至不是相同的抽象——只读比固定大小的限制更严格。

.NET 定义了IReadOnlyList<T>,这是一个更好的解决方案,用于表示只读索引集合(尽管它仍然无法处理像数组这样的可修改的固定大小集合)。像IList<T>一样,它要求实现IEnumerable<T>,但不需要ICollection<T>。它定义了两个成员:Count,返回集合的大小(就像ICollection<T>.Count一样),以及一个只读的索引器。这解决了使用IList<T>处理只读集合时遇到的大部分问题。一个小问题是,由于它比我在此处描述的大多数其他接口都要新,因此并不是普遍受支持的。(它在 2012 年的.NET 4.5 中推出,比IList<T>晚了七年。)因此,如果遇到要求IReadOnlyList<T>的 API,您可以确信它不会尝试修改集合,但如果一个 API 要求IList<T>,那么很难知道这是因为它打算修改集合,还是仅仅是因为它是在IReadOnlyList<T>被发明之前编写的。

注意

集合并不需要是只读的才能实现IReadOnlyList<T>——一个可修改的列表可以很容易地呈现一个只读的外观。因此,所有数组和List<T>都实现了这个接口。

我刚刚讨论的问题和接口引发了一个问题:在编写与集合工作的代码或类时,应该使用什么类型?如果你的 API 要求能够处理最少具体类型的需求,通常会得到最大的灵活性。例如,如果IEnumerable<T>适合你的需求,就不要要求一个IList<T>。同样,接口通常比具体类型更好,所以你应该优先选择IList<T>而不是List<T>T[]。偶尔可能会有性能优化的争论,如果你有一个关键循环对应用程序整体性能至关重要,通过集合内容工作时,如果仅使用数组类型可能会使代码运行更快,因为 CLR 在知道期望的情况下可以执行更好的优化。但在许多情况下,差异可能太小而无法测量,并且不足以证明被绑定到特定实现的不便,因此在没有测量任务的性能之前,不应采取此类步骤。 (如果您正在考虑这样的性能导向变更,您还应该查看第十八章中描述的技术。) 如果您发现有可能提高性能,但正在编写共享库,希望同时提供灵活性和最佳性能,有几种同时满足两者的选项。您可以提供重载,以便调用者可以传递接口或特定类型。或者,您可以编写一个单一的公共方法,接受接口但测试已知类型,并根据调用者传递的内容选择不同的内部代码路径。

我们刚刚查看的接口并不是唯一的通用集合接口,因为简单的线性列表并不是唯一的集合类型。但在转向其他接口之前,我想展示一下可枚举和列表的另一面:我们如何实现这些接口?

实现列表和序列

IEnumerable<T>IList<T>的形式提供信息通常很有用。前者尤其重要,因为.NET 提供了一个强大的工具包,用于处理序列,即 LINQ to Objects,我将在第十章中展示。 LINQ to Objects 提供了各种操作符,所有这些操作符都以IEnumerable<T>的形式工作。 IList<T>在任何需要通过索引随机访问任何元素的地方都是一个有用的抽象。某些框架期望一个IList<T>。例如,如果你想将一组对象绑定到某种列表控件,一些 UI 框架将期望一个IListIList<T>

你可以手动实现这些接口,因为它们都不是特别复杂。然而,C# 和运行时库可以提供帮助。直接支持在语言级别实现IEnumerable<T>,而运行时库则为通用和非通用列表接口提供支持。

使用迭代器实现 IEnumerable

C# 支持一种称为迭代器的特殊方法。迭代器是使用yield关键字生成可枚举序列的方法。 示例 5-28 展示了一个简单的迭代器及其使用的一些代码。这将显示从 5 到 1 倒数的数字。

示例 5-28. 一个简单的迭代器
public static IEnumerable<int> Countdown(int start, int end)
{
    for (int i = start; i >= end; --i)
    {
        yield return i;
    }
}

private static void Main(string[] args)
{
    foreach (int i in Countdown(5, 1))
    {
        Console.WriteLine(i);
    }
}

迭代器看起来很像任何普通方法,但它返回值的方式不同。示例 5-28 中的迭代器具有IEnumerable<int>的返回类型,但看起来并未返回任何该类型的内容。它不是使用普通的return语句,而是使用yield return语句,该语句返回单个int,而不是一个集合。迭代器通过yield return语句逐个产生值,并且与普通的return不同,方法在返回值后可以继续执行 —— 只有当方法运行到结尾或通过yield break语句或抛出异常提前结束时,它才算完成。 示例 5-29 更明显地展示了这一点。每个yield return导致从序列中发出一个值,因此这个迭代器将产生数字 1 到 3。

示例 5-29. 一个非常简单的迭代器
public static IEnumerable<int> ThreeNumbers()
{
    yield return 1;
    yield return 2;
    yield return 3;
}

尽管这在概念上相当简单,但它的工作方式有些复杂,因为迭代器中的代码不像其他代码那样运行。记住,对于IEnumerable<T>,调用者负责何时检索下一个值;foreach循环将获取一个枚举器,然后重复调用MoveNext()直到返回false,并期望Current属性提供当前值。那么示例 5-28 和 5-29 如何适应这个模型呢?也许你会认为,也许 C# 在一个List<T>中存储迭代器产生的所有值,并在迭代器完成后返回它,但很容易通过编写一个永不完成的迭代器(如 示例 5-30 中的迭代器)来证明这并不正确。

示例 5-30. 一个无限迭代器
public static IEnumerable<BigInteger> Fibonacci()
{
    BigInteger v1 = 1;
    BigInteger v2 = 1;

    while (true)
    {
        yield return v1;
        var tmp = v2;
        v2 = v1 + v2;
        v1 = tmp;
    }
}

此迭代器运行无限期;它有一个带有true条件的while循环,并且不包含break语句,因此它永远不会自愿停止。如果 C#试图在返回任何内容之前完成迭代器的运行,它将在此处卡住。 (数字会增长,因此如果运行时间足够长,该方法最终会通过抛出OutOfMemoryException而终止。) 但是如果你尝试这样做,你会发现它立即开始从斐波那契序列中返回值,并且只要你继续迭代其输出,它将继续这样做。 显然,C#并非简单地在返回之前运行整个方法。

C#对您的代码进行了一些严肃的手术以使其工作。 如果您使用像 ILDASM(与.NET SDK 一起提供的.NET 代码反汇编器)这样的工具检查迭代器的编译器输出,您会发现它生成了一个作为方法返回的IEnumerable<T>的实现以及IEnumerable<T>GetEnumerator方法返回的IEnumerator<T>的私有嵌套类。 您的迭代器方法的代码最终位于此类的MoveNext方法内部,但几乎无法识别,因为编译器以一种方式将其拆分,使得每次yield return都能返回给调用者,但在下次调用MoveNext时继续执行。 在必要时,它将存储局部变量在此生成的类中,以便它们的值可以在多次调用MoveNext时保持不变。 或许了解 C#在编译迭代器时所需做的工作的最简单方法就是手动编写等效代码。 示例 5-31 提供了与示例 5-30 相同的斐波那契序列,但没有使用迭代器的帮助。 它不完全是编译器所做的,但它展示了其中的一些挑战。

示例 5-31. 手动实现IEnumerable<T>
public class FibonacciEnumerable :
    IEnumerable<BigInteger>, IEnumerator<BigInteger>
{
    private BigInteger v1;
    private BigInteger v2;
    private bool first = true;

    public BigInteger Current => v1;

    public void Dispose() { }

    object IEnumerator.Current => Current;

    public bool MoveNext()
    {
        if (first)
        {
            v1 = 1;
            v2 = 1;
            first = false;
        }
        else
        {
            var tmp = v2;
            v2 = v1 + v2;
            v1 = tmp;
        }

        return true;
    }

    public void Reset()
    {
        first = true;
    }

    public IEnumerator<BigInteger> GetEnumerator() =>
        new FibonacciEnumerable();

    IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}

这不是一个特别复杂的例子,因为它的枚举器基本上处于两种状态之一——要么是第一次运行,因此需要运行循环之前的代码,要么是在循环内部。 即便如此,这段代码比示例 5-30 要难读得多,因为支持枚举的机制掩盖了基本逻辑的本质。

如果我们需要处理异常,代码会变得更加复杂。 您可以编写using块和finally块,这使得您的代码能够在面对错误时正确运行,正如我将在第 7 和 8 章中展示的那样,编译器最终可能会为了保留这些正确的语义而做很多工作,当方法的执行在多个迭代之间分割时。² 在您手动编写多个枚举之前,您可能会感谢 C#可以为您完成这些工作的方式。

顺便说一下,迭代器方法并不一定要返回 IEnumerable<T>。如果你愿意,你可以返回 IEnumerator<T>。而且,正如你之前看到的,实现这些接口的对象也总是实现它们的非泛型版本,因此如果你需要一个普通的 IEnumerableIEnumerator,你不需要额外的工作——你可以将一个 IEnumerable<T> 传递给任何期望普通 IEnumerable 的地方,对于枚举器也是一样。如果出于某种原因你想要提供其中一个非泛型接口,并且你不想提供泛型版本,你可以直接编写返回非泛型形式的迭代器。

迭代器需要小心的一点是,直到调用者第一次调用 MoveNext 方法时,它们才会执行非常少的代码。因此,如果你逐步执行调用 示例 5-30 中的 Fibonacci 方法的代码,该方法调用似乎根本不会做任何事情。如果你尝试在调用时步入方法,在方法运行时将不会执行任何代码。只有当迭代开始时,你才会看到迭代器的主体执行。这有几个后果。

首先要记住的是,如果你的迭代器方法接受参数,并且你想要验证这些参数,你可能需要做一些额外的工作。默认情况下,验证将在迭代开始时才会发生,因此错误可能会比预期晚发生。如果你想立即验证参数,你需要编写一个包装器。示例 5-32 展示了一个例子——它提供了一个名为 Fibonacci 的普通方法,不使用 yield return,因此不会得到迭代器的特殊编译器行为。这个普通方法在调用嵌套的迭代器方法之前验证其参数。(这也说明了局部方法可以使用 yield return。)

示例 5-32. 迭代器参数验证
public static IEnumerable<BigInteger> Fibonacci(int count)
{
    if (count < 0)
    {
        throw new ArgumentOutOfRangeException(nameof(count));
    }
    return Core(count);

    static IEnumerable<BigInteger> Core(int count)
    {
        BigInteger v1 = 1;
        BigInteger v2 = 1;

        for (int i = 0; i < count; ++i)
        {
            yield return v1;
            var tmp = v2;
            v2 = v1 + v2;
            v1 = tmp;
        }
    }
}

第二点需要记住的是,迭代器可能会执行多次。IEnumerable<T> 提供了一个 GetEnumerator 方法,可以被多次调用,而你的迭代器体每次都会从头开始运行。所以即使你的迭代器方法可能只被调用了一次,它也可能会运行多次。

Collection

如果你查看运行时库中的类型,你会发现当它们提供暴露 IList<T> 实现的属性时,通常是间接的。与接口不同,属性通常提供某种具体类型,尽管通常不是 List<T>List<T> 被设计为你代码的实现细节,如果直接暴露它,可能会给你的类的用户过多的控制权。你希望他们能修改列表吗?即使你希望如此,你的代码是否需要知道这种情况发生的时机呢?

运行时库提供了一个Collection<T>类,旨在作为类型公开的集合的基类使用。它类似于List<T>,但有两个显著的区别。首先,它的 API 更小——它提供了IndexOf,但所有其他适用于List<T>的搜索和排序方法都不包括,并且它不提供独立于其大小的方式来发现或更改其容量。其次,它为派生类提供了一种发现添加或移除项时机制的方法。List<T>则没有这样的机制,因为它是你的列表,所以你应该知道何时添加和移除项。通知机制并不是免费的,所以List<T>通过不提供它们来避免不必要的开销。但Collection<T>假设外部代码将访问你的集合,并且你因此不能控制每一次添加和移除,这正当了提供一种让你发现列表何时被修改的开销。 (这仅适用于从Collection<T>派生的代码。如果你希望使用你的集合的代码能够检测到变化,ObservableCollection<T>类型就是为这种情况设计的。例如,如果你在桌面和移动 UI 框架(如 WPF、UWP、MAUI 和 Xamarin)中将此类型用作列表的源,它们将能够在修改集合时自动显示列表。)

你通常从Collection<T>派生一个类,并且你可以重写它定义的虚方法来发现集合的变化。(第六章将讨论继承和重写。)Collection<T>实现了IListIList<T>,因此你可以通过接口类型的属性展示基于Collection<T>的集合,但通常会将派生的集合类型公开并使用它而不是接口作为属性类型。

ReadOnlyCollection

如果你想提供一个不可修改的集合,那么你可以使用ReadOnlyCollection<T>而不是使用Collection<T>。顺便说一下,这比数组施加的限制更进一步:不仅你不能添加、移除或插入项目,而且你甚至不能替换元素。这个类实现了IList<T>,它要求一个带有getset的索引器,但是set会抛出异常。(当然,它也实现了IReadOnlyCollection<T>。)

如果你集合的元素类型是引用类型,将集合设为只读并不能防止元素引用的对象被修改。例如,我可以从只读集合中检索第 12 个元素,并且它会返回给我一个引用。获取引用算是一个只读操作,但现在我已经得到了那个引用,集合对象已经不再受限制,我可以随心所欲地对那个引用进行操作。由于 C# 并没有提供任何类似于 C++ const 引用的概念,因此要展示一个真正只读的集合的唯一方法是与不可变类型结合使用 ReadOnlyCollection<T>

使用 ReadOnlyCollection<T> 有两种方法。你可以直接将它用作现有列表的包装器——它的构造函数接受一个 IList<T>,并且会提供对其的只读访问。(顺便说一句,List<T> 提供了一个名为 AsReadOnly 的方法,用于为你构造一个只读包装器。)或者,你可以从它派生一个类。与 Collection<T> 一样,一些类为希望通过属性公开的集合执行此操作,通常是因为它们希望定义与集合用途相关的附加方法。即使从这个类派生,你仍然会使用它来包装一个底层列表,因为它提供的唯一构造函数就是接受列表的构造函数。

警告

ReadOnlyCollection<T> 通常不适合自动映射对象模型和外部表示之间的场景。例如,在作为数据传输对象(DTOs)使用的类型中,它会在转换为和从通过网络连接发送的 JSON 消息中引起问题,并且在通过对象关系映射系统通过对象模型呈现数据库内容时也会出现问题。这些场景的框架需要能够实例化你的类型并将其填充数据,因此,尽管只读集合可能是你的模型某些部分的理想匹配,但它可能与这些映射框架期望初始化对象的方式不符。

使用索引和范围语法访问元素

无论是使用数组、List<T>IList<T> 还是前面讨论的各种相关类型和接口,我们都使用简单的示例来识别元素,例如 items[0],以及更一般的形式为 *arrayOrListExpression*[*indexExpression*] 的表达式。到目前为止,所有示例都使用了 int 类型的表达式作为索引,但这并不是唯一的选择。示例 5-33 使用了另一种语法来访问数组的最后一个元素。

示例 5-33. 使用端相对索引访问数组的最后一个元素
char[] letters = { 'a', 'b', 'c', 'd' };
char lastLetter = letters[¹];

这展示了用于索引器的两个运算符之一:^运算符和范围运算符。后者在示例 5-34 中展示,是一对点号(..),用于标识数组、字符串或任何实现特定模式的可索引类型的子范围。

示例 5-34. 使用范围运算符获取数组的子范围
int[] numbers = { 1, 2, 3, 4, 5, 6, 7 };
// Gets 4th and 5th (but not the 3rd or 6th, for reasons explained shortly) int[] theFourthTheFifth = `numbers``[``3..5``]``;`

使用^..运算符的表达式分别是IndexRange类型。这些类型在.NET Standard 2.1 中可用,意味着它们内置于.NET Core 3.1 和.NET 5.0 或更高版本中。然而,在.NET Framework 上这些类型不可用,这意味着你只能在较新的运行时中使用这些语言特性。

System.Index

你可以将^运算符放在任何int表达式的前面。它产生一个System.Index,这是一个值类型,表示一个位置。当你用^创建一个索引时,它是结束相对的,但你也可以创建起始相对索引。没有特殊的运算符,但由于Index提供从int的隐式转换,你可以直接将int值分配到Index类型的变量中,正如示例 5-35 所示。你也可以显式地构造一个索引,就像var行所示。最后的bool参数是可选的——默认为false——但我展示它来说明Index如何知道你想要哪种类型。

示例 5-35. 一些起始相对和结束相对的Index
Index first = 0;
Index second = 1;
Index third = 2;
var fourth = new Index(3, fromEnd: false);

Index antePenultimate = ³;
Index penultimate = ²;
Index last = ¹;
Index directlyAfterTheLast = ⁰;

如示例 5-35 所示,结束相对索引存在于任何特定集合之外。(在内部,Index将结束相对索引存储为负数。这意味着Indexint大小相同。这也意味着负的起始或结束相对值是非法的——如果尝试创建一个,你会得到一个异常。)C#会生成代码,在使用索引时确定实际元素位置。如果smallbig分别是包含 3 和 30 个元素的数组,small[last]将返回第三个元素,而big[last]将返回第 30 个元素。C#将把这些转换为small[last.GetOffset(small.Length)]big[last.GetOffset(big.Length)]

人们常说,计算机科学中三大难题是为事物命名和一错再错的错误。乍一看,示例 5-35 使人觉得Index可能在加剧这些问题。第三个项目的索引是二而不是三可能会使人困惑,但这至少与 C#中数组的工作方式一致,并且对于任何零基索引系统都是正常的。但鉴于零基准约定,为什么结束相对索引看起来是一基的呢?我们用0表示第一个元素,但用¹表示最后一个元素!

这样做有一些很好的理由。其核心洞见是,在 C#中,索引始终指定距离。当编程语言设计者选择零基索引系统时,并非真的决定将第一个元素称为 0:而是决定将索引解释为从数组开始的距离。由此产生的一个结果是,索引并不真正指代一个项。图 5-1 展示了一个包含四个元素的集合,并指示了在该集合中各种索引值指向的位置。注意,所有的索引都指向各个项之间的边界。这可能看起来有些吹毛求疵,但这是理解所有零基索引系统的关键,也是示例 5-35 中显露的表面不一致的背后原因。

索引位置

图 5-1. Index值指向的位置

当你通过索引访问集合的元素时,你要求的是从索引指示的位置开始的元素。因此,array[0]检索的是从数组开头开始的单个元素,填充索引 0 和 1 之间的空间的元素。同样,array[1]检索的是索引 1 和 2 之间的元素。那么array[⁰]意味着什么?³ 这将尝试获取从数组末尾开始的元素。由于元素都占据一定的空间,从数组末尾开始的元素必然会在数组末尾之后一个位置结束。在这个四元素数组中,array[⁰]相当于array[4],因此我们要求的是占据从开头计算起四个元素开始并结束在开头五个元素的空间的元素。由于这是一个四元素数组,显然是行不通的。

表面上的差异——即array[0]获取第一个元素,但我们需要写array[¹]来获取最后一个元素——是因为元素位于两个索引之间,而数组索引器总是检索指定索引和其后索引之间的元素。即使指定了一个末尾相关的索引,它们也会这样做,这就是为什么这些看起来是基于一的原因。这种语言特性本可以设计得不同:你可以想象一种规则,即末尾相关的索引始终访问从末尾指定距离结束并从这之前一位置开始的元素。这样设计本应更对称,因为这会使得array[⁰]指向最后一个元素,但这样做带来的问题比解决的问题更多。

使索引器在两种不同方式下解释索引会很令人困惑——这意味着两个不同的索引可能指向同一个位置,但提取不同的元素。无论如何,C# 开发人员已经习惯了这种工作方式。正如 Example 5-36 所示,在 ^ 索引运算符之前,访问数组的最后一个元素的方法是使用从长度中减去一计算出的索引。如果想要倒数第二个元素,则从长度中减去两个,依此类推。正如你所见,新的结束相对语法与长期存在的现有实践完全一致。

示例 5-36. 结束相对索引和 Index 前等价物
int lastOld = numbers[numbers.Length - 1];
int lastNew = numbers[¹];

int penultimateOld = numbers[numbers.Length - 2];
int penultimateNew = numbers[²];

还有一种思考方法是想象如果我们通过指定范围来访问数组会是什么样子。第一个元素在范围 0–1 中,最后一个元素在范围 ¹–⁰ 中。以这种方式表达,起始相对和结束相对形式之间显然存在对称性。说到范围……

System.Range

正如我之前所说,C# 有两个对处理数组和其他可索引类型非常有用的运算符。我们刚刚看过 ^ 和对应的 Index 类型。另一个称为 范围运算符,它在 System 命名空间中也有相应的类型 RangeRange 是一对 Index 值,通过 StartEnd 属性提供。Range 提供了一个接受两个 Index 值的构造函数,但在 C# 中,创建它的习惯方式是使用范围运算符,正如 Example 5-37 所示。

示例 5-37. 不同的范围
Range everything = 0..⁰;
Range alsoEverything = 0..;
Range everythingAgain = ..⁰;
Range everythingOneMoreTime = ..;
var yetAnotherWayToSayEverything = Range.All;

Range firstThreeItems = 0..3;
Range alsoFirstThreeItems = ..3;

Range allButTheFirstThree = 3..⁰;
Range alsoAllButTheFirstThree = 3..;

Range allButTheLastThree = 0..³;
Range alsoAllButTheLastThree = ..³;

Range lastThreeItems = ³..⁰;
Range alsoLastThreeItems = ³..;

正如你所见,如果在 .. 前面没有放置起始索引,默认为 0;如果省略结束索引,默认为 (即最开始和最后,分别)。示例还显示,起始索引可以是起始相对的,也可以是结束相对的,结束索引也是如此。

警告

Range 的默认值——在未显式初始化的字段或数组元素中得到的值——是 0..0。这表示一个空范围。虽然这是由于值类型默认总是初始化为类似于零的值所导致的自然结果,但这可能与你期望的不同,因为 .. 等同于 Range.All

由于 Range 是基于 Index 工作的,起始和结束表示偏移量,而不是元素。例如,考虑范围 1..3 对应于 Figure 5-1 中显示的元素的含义。在这种情况下,两个索引都是起始相对的。起始索引 1 是第一个和第二个元素(ab)之间的边界,结束索引 3 是第三个和第四个元素(cd)之间的边界。因此,这是一个从 b 的开头到 c 的结尾的范围,正如 Figure 5-2 所示。因此,这确定了一个包含两个元素 bc 的范围。

范围 1..3

Figure 5-2. 一个范围

数组范围的解释有时会让人感到惊讶,当他们第一次看到时:有些人期望1..3表示第一、第二和第三个元素(或者,如果考虑到 C# 的从零开始索引,可能是第二、第三和第四个元素)。起始索引看起来是包含的,而结束索引是排除的,这一点一开始可能显得不一致。但是一旦你记住索引指的不是项目而是偏移量,因此是两个项目之间的边界,这一切就都说得通了。如果你画出范围索引表示的位置,就像图 5-2 那样,就会完全明白1..3范围只覆盖了两个元素。

那么我们可以用Range做什么呢?正如示例 5-34 所示,我们可以使用它来获取数组的子范围。这会创建一个相应大小的新数组,并将范围内的值复制到其中。同样的语法也适用于获取子字符串,正如示例 5-38 所示。

示例 5-38. 使用范围获取子字符串
string t1 = "dysfunctional";
string t2 = t1[3..6];
Console.WriteLine($"Putting the {t2} in {t1}");

你还可以在ArraySegment<T>中使用Range,这是一个值类型,用于引用数组中的一段元素。示例 5-39 对示例 5-34 稍作修改。它不是将范围传递给数组的索引器,而是首先创建一个表示整个数组的Ar⁠ray​Seg⁠men⁠t<i⁠nt>,然后使用范围获取第四和第五个元素的第二个ArraySegment<int>。这样做的好处是不需要分配新的数组——两个ArraySegment<int>值引用相同的基础数组;它们只是指向它的不同部分,并且由于ArraySegment<int>是值类型,这可以避免分配新的堆块。(顺便说一句,ArraySegment<int>没有直接支持范围。编译器会将此转换为调用段的Slice方法。)

示例 5-39. 使用范围运算符获取ArraySegment<T>的子范围
int[] numbers = { 1, 2, 3, 4, 5, 6, 7 };
ArraySegment<int> wholeArrayAsSegment = numbers;
ArraySegment<int> theFourthTheFifth = wholeArrayAsSegment[3..5];

自 .NET 2.0 起(并在 .NET Standard 1.0 中存在),ArraySegment<T> 类型是一种避免额外分配的有用方式,但它有限制:它只适用于数组。那么字符串呢?所有当前版本的 .NET 都支持提供这个概念更一般化的类型,即 Span<T>ReadOnlySpan<T>。(在 .NET Framework 中,通过 System.Memory NuGet 包可用。它们内置于其他 .NET 版本中。)与 ArraySegment<T> 类似,Span<T> 表示其他某物中的子序列,但关于这个“其他某物”它更加灵活。它可以是一个数组,也可以是字符串,堆栈帧中的内存,或者完全在 .NET 之外由某个库或系统调用分配的内存。关于 Span<T>ReadOnlySpan<T> 类型的更详细讨论见第十八章,但现在,示例 5-40 展示了它们的基本用法。

示例 5-40. 使用范围运算符获取跨度的子范围
int[] numbers = { 1, 2, 3, 4, 5, 6, 7 };
Span<int> wholeArrayAsSpan = numbers;
Span<int> theFourthTheFifth = wholeArrayAsSpan[3..5];
ReadOnlySpan<char> textSpan = "dysfunctional".AsSpan();
ReadOnlySpan<char> such = textSpan[3..6];

这些与前面的示例在逻辑上具有相同的含义,但它们避免了复制底层数据。

我们已经看到可以在几种类型上使用范围:数组、字符串、Arr⁠ay​Seg⁠men⁠t<T>Span<T>ReadOnlySpan<T>。这引发了一个问题:C# 是否有一个特殊处理的类型列表,或者我们可以在我们自己的类型中支持索引器和范围?答案分别是肯定的。C# 对数组和字符串有一些内建处理:它知道调用特定的运行时库方法以生成子数组和子字符串。然而,对于数组段或跨度没有特殊的范围处理:它们之所以有效是因为它们符合一种模式。支持使用 Index 也有一种模式。如果你支持相同的模式,你可以让 IndexRange 在你自己的类型中工作。

支持在自定义类型中使用索引和范围

数组类型并没有定义接受 Index 类型参数的索引器。在本章早些时候展示的任何泛型数组样式类型也没有 —— 它们都只有普通的基于 int 的索引器;然而,你仍然可以与它们一起使用 Index。正如我之前解释的那样,形如 col[index] 的代码将展开为 col[index.GetOffset(a.Length)]。⁴ 因此,你只需要一个基于 int 的索引器和一个名为 LengthCountint 类型属性。示例 5-41 展示了使你的类型的索引器能接受 Index 参数的最小化实现。它不是一个非常有用的实现,但足以让 C# 快乐。

示例 5-41. 最小化启用 Index
public class Indexable
{
    public char this[int index] => (char)('0' + index);

    public int Length => 10;
}
Tip

有一个更简单的方法:只需定义一个接受Index类型参数的索引器即可。但是,大多数可索引类型都提供了基于int的索引器,因此在实践中,你会重载你的索引器,提供这两种形式。这并不简单,但它可以使你的代码区分起始和结束相对索引。如果我们在示例 5-41 中使用1,无论哪种情况,其索引器都会看到 1,因为 C#会生成将Index转换为基于起始的int的代码,但如果你编写一个接受Index参数的索引器,C#会直接传递Index。如果你重载索引器以使intIndex形式都可用,它将永远不会生成将Index转换为int的代码以调用int索引器:只有在没有Index特定索引器可用时才会出现这种模式。

IList<T>符合模式的要求(例如实现它的List<T>类型),因此你可以将Index传递给任何实现此接口的内容的索引器。它提供Count属性而不是Length,但是模式接受任何一种。这是一个广泛实现的接口,因此在实践中,许多类型在Index引入之前就自动获得了对Index的支持。这是一个模式化支持Index的例子,即使是面向较旧.NET 版本(如.NET Standard 2.0)的库也可以定义在新版本.NET 中使用Index的类型。

支持Range的模式不同:如果你的类型提供了一个接受两个整数参数的实例方法Slice,C#允许代码将Range作为索引器参数。示例 5-42 展示了使类型最少支持Range的方式,尽管这不是一个非常有用的实现。(与Index类似,你也可以直接定义一个接受Range的索引器重载。但是,模式方法的优势在于你可以在针对较旧版本(如不支持RangeIndex类型的.NET Standard 2.0)时使用它,同时仍支持针对新版本的代码的范围。)

示例 5-42. 最小化启用Range
public class Rangeable
{
    public int Length => 10;

    public Rangeable Slice(int offset, int length) => this;
}

你可能已经注意到,这种类型并没有定义索引器。这是因为基于模式的支持形式x[1..¹]不需要索引器。它看起来像是在使用索引器,但实际上只是调用了Slice方法。(同样,先前的使用string和数组的范围示例会编译成方法调用。)你需要Length属性(或Count),因为编译器生成的代码依赖于此来解析范围的索引。示例 5-43 大致展示了编译器如何使用支持此模式的类型。

示例 5-43. 范围索引的扩展方式
Rangeable r1 = new();
Range r = 2..²;

Rangeable r2;

r2 = r1[r];
// is equivalent to
int startIndex = r.Start.GetOffset(r1.Length);
int endIndex = r.End.GetOffset(r1.Length);
r2 = r1.Slice(startIndex, endIndex - startIndex);

到目前为止,我们看到的所有集合都是线性的:我只展示了一些对象或值的简单序列,其中一些提供了索引访问。但是,.NET 提供了其他类型的集合。

字典

最有用的一种集合之一是字典。.NET 提供了Dictionary<TKey, TValue>类,还有一个相应的接口称为IDictionary<TKey, TValue>,以及一个只读版本IReadOnlyDictionary<TKey, TValue>。这些表示键/值对的集合,它们最重要的功能是根据键查找值,使字典在表示关联时非常有用。

假设您正在为支持在线讨论的应用程序编写用户界面。在显示消息时,您可能希望显示发送消息的用户的某些信息,例如他们的姓名和图片,并且您可能希望避免每次从持久存储获取这些详细信息;如果用户正在与几个朋友进行对话,那么同样的人将会重复出现,因此您需要某种缓存来避免重复查找。您可以在此缓存的一部分中使用字典。示例 5-44 展示了这种方法的概要(省略了实际获取数据的应用程序特定细节以及何时从内存中删除旧数据)。

示例 5-44. 将字典用作缓存的一部分
public class UserCache
{
    private readonly Dictionary<string, UserInfo> _cachedUserInfo = new();

    public UserInfo GetInfo(string userHandle)
    {
        RemoveStaleCacheEntries();
        if (!_cachedUserInfo.TryGetValue(userHandle, out UserInfo? info))
        {
            info = FetchUserInfo(userHandle);
            _cachedUserInfo.Add(userHandle, info);
        }
        return info;
    }

    private UserInfo FetchUserInfo(string userHandle)
    {
        // fetch info...
    }

    private void RemoveStaleCacheEntries()
    {
        // application-specific logic deciding when to remove old entries...
    }
}

public class UserInfo
{
    // application-specific user information...
}

第一个类型参数,TKey,用于查找,本例中我使用的是某种方式标识用户的字符串。TValue参数是与键相关联的值的类型,在这种情况下是先前为用户获取并在UserInfo实例中本地缓存的信息。GetInfo方法使用TryGetValue在字典中查找与用户句柄关联的数据。还有一种更简单的方法来检索值。正如示例 5-45 所示,字典提供了一个索引器。但是,如果指定的键没有条目,它会抛出KeyNotFoundException。如果您的代码始终期望找到它正在查找的内容,那么这没问题,但在我们的情况下,对于任何数据不在缓存中的用户,键将丢失。这可能会经常发生,这就是为什么我使用TryGetValue。作为替代方案,我们可以使用ContainsKey方法来查看条目是否存在,但如果值存在,则效率低下——字典将在调用ContainsKey时两次查找条目,然后在使用索引器时再次查找。TryGetValue将测试和查找作为单个操作执行。

示例 5-45. 使用索引器进行字典查找
UserInfo info = _cachedUserInfo[userHandle];

正如你所预期的那样,我们也可以使用索引器来设置与键相关联的值。在示例 5-44 中,我并没有这样做。相反,我使用了Add方法,因为它具有微妙的不同语义:通过调用Add,你表明你认为不存在具有指定键的任何条目。而字典的索引器如果存在相同键的条目则会悄无声息地覆盖它,如果你尝试使用已存在的键,Add会抛出异常。在存在已有键可能意味着有问题的情况下,最好调用Add,这样问题就不会被忽视。

IDictionary<TKey, TValue>接口要求其实现也提供ICollection<KeyValuePair<TKey, TValue>>接口,因此也提供IEnumerable<KeyValuePair<TKey, TValue>>。只读对应接口要求后者但不要求前者。这些接口依赖于泛型结构KeyValuePair<TKey, TValue>,它是一个简单的容器,将键和值包装在单个实例中。这意味着你可以使用foreach遍历字典,并依次返回每个键值对。

存在IEnumerable<T>Add方法意味着我们可以使用集合初始化器语法。这与简单列表不完全相同,因为字典的Add方法接受两个参数:键和值。但集合初始化器语法可以处理多参数的Add方法。你需要将每组参数包裹在嵌套的大括号中,就像示例 5-46 所示。

示例 5-46. 使用字典的集合初始化器语法
var textToNumber = new Dictionary<string, int>
{
    { "One", 1 },
    { "Two", 2 },
    { "Three", 3 },
};

如你在第三章中所看到的,有一种替代方式来填充字典:不使用集合初始化器,而是使用对象初始化器语法。你可能还记得,这种语法允许你在新创建的对象上设置属性。这是初始化匿名类型属性的唯一方法,但你可以在任何类型上使用它。索引器只是一种特殊的属性,因此能够使用对象初始化器设置它们是有道理的。尽管第三章已经展示了这一点,但将对象初始化器与集合初始化器进行比较仍是值得的,因此示例 5-47 展示了初始化字典的替代方式。

示例 5-47. 使用字典的对象初始化器语法
var textToNumber = new Dictionary<string, int>
{
 ["One"] = 1,
 ["Two"] = 2,
 ["Three"] = 3
};

尽管此处效果与示例 5-46 和 5-47 相同,但编译器对每种情况生成的代码略有不同。对于 示例 5-46,它通过调用 Add 来填充集合,而 示例 5-47 使用索引器。对于 Dictionary<TKey, TValue>,结果是相同的,因此没有客观理由选择其中之一,但对于某些类来说,这种差异可能很重要。例如,如果你正在使用一个具有索引器但没有 Add 方法的类,那么只有基于索引的代码才能工作。另外,使用对象初始化语法,可以在支持此操作的类型上设置索引值和属性(尽管你不能在 Dictionary<TKey, TValue> 上这样做,因为它除了索引器之外没有可写的属性)。

Dictionary<TKey, TValue> 集合类依赖哈希来提供快速查找。第 3 章 描述了 GetHashCode 方法,你应确保作为键使用的任何类型都提供了良好的哈希实现。string 类的工作效果良好。对于其他类型,只有当类型的不同实例始终被视为具有不同值时,默认的 GetHashCode 方法才可行,但对于这种情况,对键类型本身提供的 GetHashCodeEquals 实现而言,字典类提供了接受 IEqualityComparer<TKey> 的构造函数。示例 5-48 使用此功能制作了 示例 5-46 的不区分大小写版本。

示例 5-48. 不区分大小写的字典
var textToNumber =
    new Dictionary<string, int>(StringComparer.InvariantCultureIgnoreCase)
{
    { "One", 1 },
    { "Two", 2 },
    { "Three", 3 },
};

这里使用了 StringComparer 类,它提供了 IComparer<string>IEqualityComparer<string> 的各种实现,提供不同的比较规则。在这里,我选择了一个忽略大小写并且忽略配置的区域设置的排序,以确保在不同区域中表现一致。如果我要使用字符串进行显示,我可能会使用其中一种支持文化感知的比较方式。

排序字典

因为 Dictionary<TKey, TValue> 使用基于哈希的查找,当你遍历其内容时返回元素的顺序很难预测并且没有什么用处。它通常与添加内容的顺序无关,并且与内容本身也没有明显的关系。(顺序通常看起来是随机的,尽管实际上与哈希码有关。)

有时,能够以某种有意义的顺序检索字典的内容是很有用的。您可以将内容放入数组中然后进行排序,但是System.Collections.Generic命名空间包含两个更实用的IDictionary<TKey, TValue>接口实现,它们会保持其内容永久有序。这就是SortedDictionary<TKey, TValue>和更令人困惑的SortedList<TKey, TValue>,尽管名字相似,但实现了IDictionary<TKey, TValue>接口并且并没有直接实现IList<T>

这些类不使用哈希码。它们仍然提供相对快速的查找,但是通过保持其内容排序来实现。每次添加新条目时,它们都会保持顺序,这使得这两个类的添加速度比基于哈希的字典慢,但这意味着当您遍历内容时,它们会按顺序输出。与数组和列表排序一样,您可以指定自定义比较逻辑,但如果您不提供它,这些字典要求键类型实现IComparable<T>接口。

SortedDictionary<TKey, TValue>维护的顺序只有在使用其枚举支持(例如,通过foreach)时才显现出来。SortedList<TKey, TValue>也按顺序枚举其内容,但它还额外提供了对键和值的数值索引访问。这不是通过对象的索引器来完成的——它期望像任何字典一样传递一个键。相反,排序列表字典定义了两个属性,KeysValues,分别提供所有键和值作为IList<TKey>IList<TValue>,并按升序排序键。(Values也按键的顺序排序,就像Keys一样。)

对排序列表进行插入和删除对象操作相对较昂贵,因为它必须上移或下移键和值列表的内容。(这意味着单个插入具有*O(n)的复杂度。)另一方面,排序字典使用树数据结构来保持其内容排序。具体细节未指定,但插入和删除性能被记录为具有O(log n)*的复杂度,这比排序列表好得多。⁵ 然而,这种更复杂的数据结构使得排序字典的内存占用显著增加。这意味着两者都没有绝对更快或更好的选择——这完全取决于使用模式,这也是为什么运行时库同时提供了这两种。

在大多数情况下,基于哈希的Dictionary<TKey, Value>比排序字典在插入、删除和查找性能上都更好,并且比SortedDictionary<TKey, TValue>具有更低的内存消耗,因此只有在需要按顺序访问字典内容时才应使用这些排序字典集合。

集合

命名空间System.Collections.Generic定义了ISet<T>接口。这提供了一个简单的模型:特定值要么是集合的成员,要么不是。您可以添加或删除项目,但集合不会跟踪您添加项目的次数,而且ISet<T>不要求项目以任何特定顺序存储。

所有集合类型都实现了ICollection<T>,它提供了添加和删除项目的方法。事实上,它还定义了用于确定成员资格的方法:虽然我现在还没有引起注意,但您可以在示例 5-25 中看到,ICollection<T>定义了一个Contains方法。这个方法接受一个值,并在集合中返回true,如果该值在集合中。

鉴于ICollection<T>已经为集合提供了定义操作,您可能会想知道为什么我们还需要ISet<T>。但它确实增加了一些东西。虽然ICollection<T>定义了一个Add方法,但ISet<T>定义了自己略有不同的版本,它返回一个bool,因此您可以找出刚刚添加的项是否已经在集合中。示例 5-49 使用这个功能在显示其输入中的每个字符串时检测重复项。(这展示了使用方法,但实际上使用在第十章描述的Distinct LINQ 运算符会更简单。)

示例 5-49. 使用集合来确定新内容
public static void ShowEachDistinctString(IEnumerable<string> strings)
{
    var shown = new HashSet<string>();  // Implements ISet<T>
    foreach (string s in strings)
    {
        if (shown.Add(s))
        {
            Console.WriteLine(s);
        }
    }
}

ISet<T>还定义了一些用于合并集合的操作。UnionWith方法接受一个IEnumerable<T>,并将该序列中之前不在集合中的所有值添加到集合中。ExceptWith方法从集合中删除也在您传递的序列中的项目。IntersectWith方法从集合中删除不在您传递的序列中的项目。而SymmetricExceptWith还接受一个序列,并从集合中删除序列中的元素,但还将序列中以前不在集合中的值添加到集合中。

还有一些用于比较集合的方法。同样,这些方法都接受一个IEnumerable<T>参数,表示要执行比较的另一个集合。IsSubsetOfIsProperSubsetOf允许您检查调用方法的集合是否仅包含也存在于序列中的元素,后者方法还要求序列至少包含一个不在集合中的项。IsSupersetOfIsProperSupersetOf在相反的方向执行相同的测试。Overlaps方法告诉您这两个集合是否至少共享一个公共元素。

数学集合不为其内容定义顺序,因此引用集合的第 1 个、第 10 个或第 n 个元素是没有意义的——你只能询问元素是否在集合中。为了符合数学集合的这一特性,.NET 集合不支持索引访问,因此 ISet<T> 不要求支持 IList<T>。集合可以按照它们喜欢的任何顺序生成其成员在其 IEnumerable<T> 实现中。

运行时库提供了两个类来提供这个接口,采用不同的实现策略:HashSetSortedSet。从名称可以猜到,这两个内置的集合实现中的一个确实选择保持其元素的顺序;SortedSet 始终保持其内容排序,并通过其 IEnumerable<T> 实现以此顺序呈现项目。文档没有描述用于维护顺序的确切策略,但似乎使用了平衡二叉树来支持高效的插入和删除,并在尝试确定特定值是否已在列表中时提供快速查找。

另一种实现方式,HashSet,更像是 Dictionary<TKey, TValue>。它使用基于哈希的查找,这通常比有序方法更快,但如果你用 foreach 枚举集合,结果将不会按任何有用的顺序排列。(因此,HashSetSortedSet 之间的关系与基于哈希的字典与有序字典之间的关系非常类似。)

队列和栈

队列 是一个只能在列表末尾添加项目,并且只能移除第一个项目(此时,如果有第二个项目,则成为新的第一个项目)的列表。这种列表风格通常称为先进先出(FIFO)列表。这使得它比 List<T> 更不方便,因为你可以在 List<T> 中的任何位置读取、写入、插入或删除项目。然而,这些限制使得可以实现具有更好插入和删除性能特征的队列。从 List<T> 中移除项目时,必须将被移除项目后的所有项目移动到前面来填补空隙,插入需要类似的移动。在 List<T> 的末尾进行插入和删除是高效的,但如果需要 FIFO 语义,不能完全在末尾工作,而是需要在开始时进行插入或移除操作,使得 List<T> 不是一个好的选择。Queue<T> 可以使用更高效的策略,因为它只需要支持队列语义。(它在内部使用一个循环缓冲区,尽管这是一个未记录的实现细节。)

要向队列末尾添加新项,请调用 Enqueue 方法。要移除队列头部的项,请调用 Dequeue 方法,或者使用 Peek 方法查看项而不移除它。如果队列为空,这两种操作都会抛出 InvalidOperationException 异常。你可以通过 Count 属性查看队列中的项数。

虽然你无法在列表中间插入、移除或更改项,但可以检查整个队列,因为 Queue<T> 实现了 IEnumerable<T>,并且提供了 ToArray 方法,返回包含当前队列内容副本的数组。

类似于队列,但你从插入的同一端检索项,所以这是一个后进先出(LIFO)列表。Stack<T> 看起来与 Queue<T> 非常相似,但是添加和移除项的方法使用了传统的栈操作名称:PushPop(其他方法如 PeekToArray 等保持不变)。

运行时库不提供双端队列。然而,链表可以提供该功能的超集。

链表

LinkedList<T> 类提供了经典的双向链表数据结构的实现,在此结构中,序列中的每个项都被包装在一个对象中(类型为 LinkedListNode<T>),该对象提供对其前驱和后继的引用。链表的优势在于插入和删除操作成本低廉,不需要在数组中移动元素,也不需要重新平衡二叉树。它只需要交换几个引用。缺点是链表在内存开销上相对较高,每个集合中的项都需要额外的堆对象,并且获取第 n 个项对 CPU 来说比较昂贵,因为你必须从开头开始遍历 n 个节点。

LinkedList<T> 中的第一个和最后一个节点可以通过可预见的 FirstLast 属性访问。你可以使用 AddFirstAddLast 在列表的开头或结尾插入项。要在列表中间添加项,请调用 AddBeforeAddAfter 方法,传入要在其前面或后面添加新项的 LinkedListNode<T>

该列表还提供了 RemoveFirstRemoveLast 方法,以及两个重载的 Remove 方法,允许你移除具有特定值的第一个节点或特定的 LinkedListNode<T>

LinkedListNode<T> 本身提供了一个 Value 属性,类型为 T,包含序列中该节点点的实际项。其 List 属性引用回包含它的 LinkedList<T>PreviousNext 属性允许你找到前一个或后一个节点。

要遍历链表的内容,你可以从First属性检索第一个节点,然后按照每个节点的Next属性依次遍历,直到遇到null为止。但是,LinkedList<T>实现了IEnumerable<T>,所以更容易的方法是使用foreach循环。如果想要逆序获取元素,从Last开始,按照每个节点的Previous遍历。如果链表为空,FirstLast将都是null

并发集合

到目前为止描述的集合类都是为单线程使用设计的。你可以在不同的线程上同时使用不同的实例,但任何这些类型的特定实例在任一时刻只能由一个线程使用。⁶ 但是有些类型设计成可以同时被多个线程使用,而不需要使用在第十六章讨论过的同步机制。这些类型位于System.Collections.Concurrent命名空间中。

并发集合并不为每种非并发集合类型提供等价物。某些类是为了解决特定的并发编程问题而设计的。即使是那些有非并发对应物的集合,由于需要在不锁定的情况下并发使用,它们的 API 可能与任何普通集合类略有不同。

ConcurrentQueue<T>ConcurrentStack<T>类看起来最像我们已经见过的非并发集合,尽管它们并非完全相同。队列的DequeuePeek方法被TryDequeueTryPeek替代,因为在并发世界中,无法可靠地预知尝试从队列获取项是否成功(你可以检查队列的Count,但即使这个值非零,其他线程也可能在你检查计数和尝试检索项之间清空队列)。因此,获取项的操作必须与检查项是否可用的操作原子化,因此引入了可能失败而不抛出异常的Try形式。同样,并发栈提供了TryPopTryPeek方法。

ConcurrentDictionary<TKey, TValue>看起来与其非并发版本相似,但它添加了一些额外的方法,以提供并发世界所需的原子性:TryAdd方法结合了对键是否存在的测试和新条目的添加;GetOrAdd方法在同一原子操作中执行相同的操作,并返回已存在的值(如果有的话)。

没有并发列表,因为在并发世界中,为了成功使用有序的、索引的列表,你往往需要更粗粒度的同步。但如果你只想要一堆对象,可以使用ConcurrentBag<T>,它不保持任何特定的顺序。

还有一个BlockingCollection<T>,它的作用类似于队列,但允许想要从队列中取出项目的线程选择阻塞,直到有可用的项目为止。您还可以设置有限的容量,并使将项目放入队列的线程在队列当前已满时阻塞,直到空间可用为止。

不可变集合

Microsoft 提供了一组保证不可变性的集合类,并提供一种轻量级方法来生成集合的修改版本,而无需制作完整的新副本。(这些内置在 .NET Core 和 .NET 中,但在 .NET Framework 中,您需要引用 System.Collections.Immutable NuGet 包才能使用这些功能。)

在多线程环境中,不可变性可以是一个非常有用的特性,因为如果您知道正在使用的数据不会更改,那么您就不需要采取特殊预防措施来同步对其的访问。(这比仅防止修改集合的 IReadOnlyList<T> 提供了更强的保证;它可能只是一个外观,覆盖了其他线程能够修改的集合。)但是,如果您的数据偶尔需要更新,该怎么办?在您预期冲突很少的情况下,放弃不可变性并承担传统多线程同步的开销似乎有些可惜。

一种低技术手段是每次数据发生变化时(例如,当您想要向集合添加项目时),构建所有数据的新副本(创建一个包含所有旧元素和新元素副本的新集合,并从那时起使用该新集合)。这种方法有效,但可能效率极低。然而,存在技术可以有效地重用现有集合的部分。其基本原则是,如果要向集合添加项目,则构建一个新集合,该集合仅指向已有数据,并附加一些额外信息以表明发生了什么变化。实际上要复杂得多,但关键点在于,已经建立了可以实现各种集合的方式,因此您可以高效地构建看起来像原始数据的完整独立副本,并应用一些小的修改,而无需修改原始数据或完全构建新副本的集合。不可变集合为您完成所有这些工作,并将工作封装在一些简单的接口背后。

这使得一种模型成为可能,即在不影响正在使用当前数据版本的代码的情况下更新应用程序的模型。因此,您无需在读取数据时保持锁定状态——在获取数据的最新版本时可能需要一些同步,但此后,您可以在不考虑并发问题的情况下处理数据。编写多线程代码时,这一点尤为有用。微软的 C#编译器基础.NET 编译器平台(通常以其代号 Roslyn 而闻名)使用这种技术来实现有效利用多个 CPU 核心进行编译。

System.Collections.Immutable 命名空间定义了其自己的接口—— IImmutableList<T>IImmutableDictionary<TKey, TValue>IImmutableQueue<T>IImutableStack<T>IImutableSet<T>。这是必要的,因为所有修改集合的操作都需要返回一个新的集合。示例 5-50 展示了向字典添加条目的意义。

示例 5-50. 创建不可变字典
IImmutableDictionary<int, string> d = ImmutableDictionary.Create<int, string>();
d = d.Add(1, "One");
d = d.Add(2, "Two");
d = d.Add(3, "Three");

不可变类型的整个要点在于,使用现有对象的代码可以确保不会发生任何变化,因此添加、删除或修改必然意味着创建一个新对象,该对象看起来与旧对象完全相同,但应用了修改。(内置的 string 类型也是不可变的,工作方式完全相同——诸如 Trim 这类看起来会改变值的方法实际上返回一个新的字符串。)因此,在 示例 5-50 中,变量 d 连续引用四个不同的不可变字典:一个空字典,一个包含一个值的字典,一个包含两个值的字典,最后一个包含所有三个值的字典。

如果您像这样添加一系列值,并且不会将中间结果提供给其他代码,那么通过单个操作添加多个值更为高效,因为它不必为每个添加的条目生成单独的 IIm⁠mut⁠ab⁠le​Dic⁠tio⁠nar⁠y<T⁠Key⁠, TValue>。(您可以将不可变集合视为类似源代码控制系统的工作方式,其中每个更改对应一个提交——对于每个提交,将存在一个版本的集合,该版本代表其在该更改之后的内容。)将一组相关的更改批量处理为单个“版本”更为高效,因此所有集合都具有 AddRange 方法,可让您一次添加多个项。

当你从头开始构建一个新集合时,同样的原则适用:如果你将所有初始内容放入集合的第一个版本中,而不是逐个添加项目,那么效率会更高。每种不可变集合类型都提供了一个嵌套的Builder类,使这一过程更加简单,使你能够逐个添加项目,但在完成后延迟实际集合的创建。示例 5-51 展示了如何做到这一点。

示例 5-51. 使用构建器创建不可变字典
ImmutableDictionary<int, string>.Builder b =
    ImmutableDictionary.CreateBuilder<int, string>();
b.Add(1, "One");
b.Add(2, "Two");
b.Add(3, "Three");
IImmutableDictionary<int, string> d = b.ToImmutable();

构建器对象不是不可变的。与StringBuilder类似,它是一个可变对象,提供了一种有效的方式来构建不可变对象的描述。

除了不可变列表、字典、队列、栈和集合类型之外,还有一种不同于其他的不可变集合类:ImmutableArray<T>。这本质上是一个包装器,在数组周围提供了一个不可变的外观。它实现了IImmutableList<T>,这意味着它提供了与不可变列表相同的服务,但性能特征有着显著的不同。

当你在不可变列表上调用Add方法时,它会尝试重用已经存在的大部分数据,因此如果你的列表中有一百万个项目,“新”列表由Add返回的并不包含这些项目的新副本,而是大部分重用了已经存在的数据。然而,为了实现这一点,ImmutableList<T>在内部使用了一种相当复杂的树数据结构。结果是,在ImmutableList<T>中通过索引查找值的效率远不如使用数组(或List<T>)。ImmutableList<T>的索引器具有*O(log n)*的复杂度。

对于读取操作来说,ImmutableArray<T>要高效得多——作为数组的包装器,它具有*O(1)*的复杂度,即获取条目的时间是常数,无论集合大小如何。然而,代价是对于构建修改版本的所有IImmutableList<T>方法(AddRemoveInsertSetItem等)来说,都需要构建一个完整的新数组,包括需要传递的任何数据的新副本。(换句话说,与所有其他不可变集合类型不同,ImmutableArray<T>采用了我之前描述的低技术方法来实现不可变性。)这使得修改变得非常昂贵,但如果你有一些数据在创建数组后不期望修改,这是一个很好的权衡,因为你只会构建一个数组的副本。如果偶尔需要进行修改,每次变更的高成本总体来看可能仍然是值得的。

摘要

在本章中,我们看到了运行时提供的数组的固有支持,以及.NET 在需要更多于固定大小项目列表时提供的各种集合类。接下来,我们将看一个更高级的主题:继承。

¹ 令人惊讶的是,foreach并不需要任何特定的接口;它将使用任何具有返回提供MoveNext方法和Current属性的对象的GetEnumerator方法的类型。在泛型出现之前,这是通过值类型元素集合进行迭代的唯一方法,而不会对每个项进行装箱。第七章描述了装箱。尽管泛型已经解决了这个问题,但基于非接口的枚举继续很有用,因为它使集合类能够提供额外的GetEnumerator方法,返回一个struct,在foreach循环开始时避免额外的堆分配。List<T>就是这样做的。

² 在调用Dispose时,部分清理工作已经完成。请记住,所有IEnumerator<T>的实现都实现了IDisposable接口。foreach关键字在遍历集合后会调用Dispose(即使遍历由于错误而提前终止)。如果不使用foreach,而是手动进行迭代,则必须牢记调用Dispose的重要性。

³ 由于终点相对索引存储为负数,你可能会想知道是否合法,因为int类型不区分正零和负零。它是允许的,因为正如你很快就会看到的那样,当使用范围时,Index中是有用的,所以Index能够进行区分。

⁴ 在直接针对int类型的数组索引器使用^的情况下(例如,a[^i],其中i是一个int),编译器会生成稍微简单的代码。它不会将i转换为Index,然后调用GetOffset,而是会生成等效于a[a.Length - i]的代码。

⁵ 常规的复杂性分析警告适用于小型集合,更简单的数据结构可能表现更好,其理论优势只在处理较大集合时才能显现。

⁶ 这个规则有一个例外:只要没有线程尝试修改它,你可以在多个线程中使用同一个集合。

第六章:继承

C#类支持继承,这是一种流行的面向对象代码重用机制。当你编写一个类时,可以选择性地指定一个基类。你的类将从这个基类派生,这意味着基类中的所有内容将出现在你的类中,以及你添加的任何成员。

类和基于类的记录类型仅支持单一继承(因此只能指定一个基类)。接口提供了一种多重继承的形式。值类型,包括record struct类型,根本不支持继承。其中一个原因是值类型通常不通过引用使用,这就移除了继承的主要好处之一:运行时多态性。继承与值类型的行为不一定不兼容——某些语言可以处理它——但通常存在问题。例如,将某个派生类型的值赋给其基类型的变量将导致丢失派生类型添加的所有字段,这是一个被称为slicing的问题。C#通过将继承限制为引用类型来避免这个问题。当你将某个派生类型的变量赋给基类型的变量时,你复制的是一个引用,而不是对象本身,因此对象保持完整。Slicing 仅在基类提供了克隆对象的方法且未提供派生类扩展它的方法时出现问题(或者提供了,但某个派生类未扩展它)。

类使用在示例 6-1 中展示的语法来指定基类——基类型出现在紧随类名后的冒号之后。本示例假设在项目的其他地方或其使用的库中已经定义了一个名为SomeClass的类。

示例 6-1 指定一个基类
public class Derived : SomeClass
{
}

public class AlsoDerived : SomeClass, IDisposable
{
    public void Dispose() { }
}

如同你在第三章看到的那样,如果类实现了任何接口,这些接口也会在冒号后列出。如果你想要从一个类派生,并且还想实现接口,基类必须首先出现,正如示例 6-1 所示。

你可以从一个又从另一个类派生的类派生。示例 6-2 中的MoreDerived类派生自Derived,后者又派生自Base

示例 6-2 继承链
public class Base
{
}

public class Derived : Base
{
}

public class MoreDerived : Derived
{
}

这意味着MoreDerived从技术上讲具有多个基类:它直接从Derived派生,间接从Base派生(通过Derived)。这不是多重继承,因为只有一个继承链——任何单个类最多直接从一个基类派生。(所有类都直接或间接地从object派生,如果你没有指定基类,则默认为基类。)

由于派生类继承了基类的所有内容——包括所有字段、方法和其他成员,无论是公有的还是私有的——因此,派生类的一个实例可以执行基类实例可以执行的所有操作。这是许多语言中继承所暗示的经典的是一个关系。任何MoreDerived的实例都是Derived的实例,也是Base的实例。C#的类型系统认识到这种关系。

继承与转换

C# 提供了各种内置的隐式转换。在第二章中,我们看到数字类型的转换,但引用类型也有转换。如果某个类型DB派生(直接或间接),那么类型D的引用可以隐式地转换为类型B的引用。这是基于我在前一节中描述的是一个关系——任何D的实例都是B。这种隐式转换使多态成为可能:编写的针对B的代码将能够与任何从B派生的类型一起工作。

隐式引用转换是特殊的。与其他转换不同,它们不会以任何方式改变值。(所有内置的隐式数字转换都会从其输入创建一个新值,通常涉及表示的变化。例如,整数 1 的二进制表示对于floatint类型是不同的。)实际上,它们转换的是引用的解释,而不是引用本身或它所引用的对象。正如你将在本章后面看到的,CLR 在考虑隐式引用转换的可用性时,会考虑到这些地方,但不会考虑其他形式的转换。

警告

两个引用类型之间的自定义隐式转换不算作这些目的的隐式引用转换,因为需要调用方法来实现这样的转换。在隐式引用转换的特殊情况中,依赖于“转换”在运行时无需工作这一事实。

反向没有隐式转换——虽然一个类型为B的变量可以引用类型为D的对象,但不能保证它会这样做。可能有任意数量从B派生的类型,一个B变量可以引用它们中的任何一个实例。然而,有时你可能希望尝试将引用从基类型转换为派生类型,这种操作有时称为下转型。也许你知道某个变量确实持有某种类型的引用。或者你不确定,希望你的代码为特定类型提供额外的服务。C# 提供了三种方法来执行此操作。

我们可以使用强制转换语法尝试进行下转型。这是我们用于执行非隐式数字转换的相同语法,如示例 6-3 所示。

示例 6-3. 感觉下转型
public static void UseAsDerived(Base baseArg)
{
    var d = (Derived) baseArg;

    // ...go on to do something with d
}

因此,这种转换不能保证成功——这就是为什么我们不能使用隐式转换。如果在baseArg参数引用的对象既不是Derived的实例,也不是Derived的派生类时尝试这样做,转换将失败,抛出InvalidCastException(异常在第 8 章中描述)。

只有当你确信对象确实是你期望的类型时,才适合使用转换。如果对象类型不符合预期,你将认为这是一个错误。当 API 接受一个稍后将返回给你的对象时,这很有用。许多异步 API 会这样做,因为在同时启动多个操作的情况下,当你收到完成通知时,需要一种方法来确定哪个操作已经完成(尽管正如我们将在后面的章节中看到的,有各种方法来解决这个问题)。由于这些 API 不知道你将要关联到操作的数据类型,它们通常只接受object类型的引用,当引用最终交还给你时,你通常会使用转换将其转换回所需类型的引用。

有时,你不能确定一个对象是否具有特定类型。在这种情况下,你可以使用as运算符,如示例 6-4 所示。这允许你尝试转换而不会引发异常。如果转换失败,该运算符将返回null

示例 6-4. as运算符
public static void MightUseAsDerived(Base b)
{
    var d = b as Derived;

    if (d != null)
    {
        // ...go on to do something with d
    }
}

尽管这种技术在现有代码中非常常见,但在 C# 7.0 中引入的模式提供了一种更简洁的替代方法。示例 6-5 与示例 6-4 具有相同的效果:只有在b引用Derived的实例时,if语句的主体才会执行,并且可以通过变量d访问它。此处的is关键字表示我们想要对b进行模式测试。在这种情况下,我们使用的是声明模式,它执行与as运算符相同的运行时类型测试。应用带有is的模式的表达式生成一个bool,指示模式是否匹配。我们可以将此用作if语句的条件表达式,无需与null进行比较。由于声明模式包含变量声明和初始化,示例 6-4 中需要两个语句的工作都可以合并到示例 6-5 的if语句中。

示例 6-5. 带有声明模式的is运算符
public static void MightUseAsDerived(Base b)
{
    if (b is Derived d)
    {
        // ...go on to do something with d
    }
}

除了更紧凑外,is运算符还有一个好处,即在as不起作用的一个情况下也可以起作用:您可以测试类型为object的引用是否引用值类型的实例,例如int。(这可能看起来有些矛盾——您怎么可能有一个指向不是引用类型的东西的引用?第七章将展示这是可能的。)as运算符不起作用,因为当实例不是指定类型时它返回null,但是当然它不能对值类型执行此操作——没有int类型的null。由于声明模式消除了对null的测试的需要——我们只使用is运算符生成的bool结果——我们可以自由使用值类型。

提示

偶尔您可能希望检测特定类型是否存在,而无需执行转换。由于is可以跟随任何模式,因此您可以使用类型模式,例如,is Derived。这执行与声明模式相同的测试,而无需引入新变量。

使用刚才描述的技术进行转换时,不一定需要指定确切的类型。只要对象的真实类型到您要查找的类型之间存在隐式引用转换,这些操作就会成功。例如,假设您有一个类型为Base的变量,当前包含对MoreDerived实例的引用。显然,您可以将引用转换为MoreDerived(对于该类型,asis也会成功),但正如您可能期望的那样,转换为Derived也可以工作。

这四种机制也适用于接口。当您尝试将引用转换为接口类型的引用(或使用类型模式测试接口类型)时,如果所引用的对象实现了相关接口,则转换将成功。

接口继承

接口支持继承,但与类继承并不完全相同。语法类似,但正如示例 6-6 所示,接口可以指定多个基接口。虽然.NET 仅提供单一实现继承,但这种限制不适用于接口,因为大多数可能导致多重继承的复杂性和潜在歧义都不适用于纯抽象类型。最棘手的问题围绕着字段的处理,这意味着即使具有默认实现的接口也支持多重继承,因为这些接口不能向实现类型添加字段或公共成员。(当类使用成员的默认实现时,该成员只能通过接口类型的引用访问。)

示例 6-6. 接口继承
interface IBase1
{
    void Base1Method();
}

interface IBase2
{
    void Base2Method();
}

interface IBoth : IBase1, IBase2
{
    void Method3();
}

尽管 接口继承 是这个特性的官方名称,但这是一个误称——尽管派生类从它们的基类继承所有成员,派生接口却不是这样。看起来它们似乎是这样的——给定类型为 IBoth 的变量,你可以调用由其基类定义的 Base1MethodBase2Method 方法。然而,接口继承的真正含义是,实现一个接口的任何类型都有义务实现所有继承的接口。因此,实现 IBoth 的类必须同时实现 IBase1IBase2。这是一个微妙的区别,特别是因为 C# 不要求你显式列出基接口。在 示例 6-7 中的类仅声明它实现了 IBoth。然而,如果你使用 .NET 的反射 API 来检查类型定义,你会发现编译器已将 IBase1IBase2 添加到类实现的接口列表中,以及显式声明的 IBoth

示例 6-7. 实现一个派生接口
public class Impl : IBoth
{
    public void Base1Method()
    {
    }

    public void Base2Method()
    {
    }

    public void Method3()
    {
    }
}

由于派生接口的实现必须实现所有基接口,C# 允许你通过派生类型的引用直接访问基类的成员,因此类型为 IBoth 的变量提供了对 Base1MethodBase2Method 的访问,以及该接口自身的 Method3。从派生接口类型到它们的基类存在隐式引用转换。例如,类型为 IBoth 的引用可以分配给类型为 IBase1IBase2 的变量。

泛型

如果你从一个泛型类派生,你必须提供它所需的类型参数。如果你的派生类型也是泛型的,你可以选择使用自己的类型参数作为参数,只要它们符合基类定义的任何约束。示例 6-8 展示了这两种技术,并且还说明了当从具有多个类型参数的类派生时,你可以使用混合方法,直接指定一个类型参数,对另一个类型参数则放任不管。

示例 6-8. 从泛型基类派生
public class GenericBase1<T>
{
    public T? Item { get; set; }
}

public class GenericBase2<TKey, TValue>
    where TValue : class
{
    public TKey? Key { get; set; }
    public TValue? Value { get; set; }
}

public class NonGenericDerived : GenericBase1<string>
{
}

public class GenericDerived<T> : GenericBase1<T>
{
}

public class MixedDerived<T> : GenericBase2<string, T>
    where T : class
{
}

尽管你可以自由地将任何类型参数用作基类的类型参数,但你不能从类型参数派生。如果你习惯于允许这样做的语言,这可能有点令人失望,但是 C# 语言规范明确禁止这样做。然而,你可以使用自己的类型作为基类的类型参数。你还可以为类型参数指定约束,要求它必须从你自己的类型派生。示例 6-9 展示了这些情况。

示例 6-9. 自引用类型参数
public class SelfAsTypeArgument : IComparable<SelfAsTypeArgument>
{
    // ...implementation removed for clarity
}

public class Curious<T>
    where T : Curious<T>
{
}

协变性和逆变性

在 第四章 中,我提到泛型类型有特殊的类型兼容规则,称为 协变性逆变性。这些规则确定了当类型参数之间存在隐式转换时,某些泛型类型的引用是否可以相互隐式转换。

注:

协变和逆变仅适用于接口和委托的泛型类型参数。(委托在第九章中描述。)你不能定义协变或逆变的类、结构体或记录。

考虑前面示例 6-2 中展示的简单的 BaseDerived 类,并查看接受任何 Base 的方法示例 6-10。 (它对它什么都不做,但这里重要的是它的签名说它可以使用什么。)

示例 6-10. 接受任何 Base 的方法
public static void UseBase(Base b)
{
}

我们已经知道,除了接受任何 Base 的引用外,这也可以接受任何从 Base 派生的类型的实例,比如 Derived。考虑一下示例 6-11 中的方法。

示例 6-11. 接受任何 IEnumerable<Base> 的方法
public static void AllYourBase(IEnumerable<Base> bases)
{
}

这需要一个实现了第五章中描述的 IEnumerable<T> 泛型接口的对象,其中 TBase。如果我们尝试传递一个未实现 IEnumerable<Base> 但实现了 IEnumerable<Derived> 的对象,你认为会发生什么?示例 6-12 就这样做了,并且编译通过。

示例 6-12. 传递一个派生类型的 IEnumerable<T>
IEnumerable<Derived> derivedItems = new[] { new Derived(), new Derived() };
AllYourBase(derivedItems);

从直觉上讲,这是有道理的。AllYourBase 方法期望一个能够提供类型为 Base 的对象序列的对象。IEnumerable<Derived> 符合要求,因为它提供了 Derived 对象的序列,而任何 Derived 对象也都是 Base。但是,关于示例 6-13 中的代码呢?

示例 6-13. 接受任何 ICollection<Base> 的方法
public static void AddBase(ICollection<Base> bases)
{
    bases.Add(new Base());
}

回想一下第五章中提到的 ICollection<T> 派生自 IEnumerable<T>,并且它添加了以某些方式修改集合的能力。这个特定的方法通过向集合中添加一个新的 Base 对象来利用这一点。对于示例 6-14 中的代码来说,这将是个麻烦。

示例 6-14. 错误:尝试传递一个带有派生类型的 ICollection<T>
ICollection<Derived> derivedList = new List<Derived>();
AddBase(derivedList);  // Will not compile

使用 derivedList 变量的代码将期望该列表中的每个对象都是 Derived 类型(或者从中派生的类型,比如示例 6-2 中的 MoreDerived 类)。但是 示例 6-13 中的 AddBase 方法尝试添加一个普通的 Base 实例。这是不正确的,编译器也不允许这样做。调用 AddBase 将产生编译器错误,指出 ICollection<Derived> 类型的引用不能隐式转换为 ICollection<Base> 类型的引用。

编译器是如何知道这是不允许的,而非常相似的从IEnumerable<Derived>IEnumerable<Base>的转换却是允许的?顺便说一句,并不是因为示例 6-13 包含可能引起问题的代码。即使AddBase方法完全为空,你仍然会得到相同的编译器错误。之所以在示例 6-12 中没有错误,是因为IEnumerable<T>接口将其类型参数T声明为协变。你在第五章中看到了这种语法,但我并没有特别强调,因此示例 6-15 再次展示了该接口定义中相关的部分。

示例 6-15. 协变类型参数
public interface IEnumerable<out T> : IEnumerable

那个out关键字完成了任务。(同样,C#延续了 C 家族传统,为每个关键字赋予多种功能——我们首次在方法参数返回信息给调用者的情境中见到此关键字。直观地说,将类型参数T描述为out是有意义的,因为IEnumerable<T>接口只提供T—它并不定义任何接受T的成员。(该接口仅在一个地方使用了这个类型参数:它的只读Current属性。

将其与ICollection<T>进行比较。这个接口从IEnumerable<T>派生,因此显然可以从中获取T,但也可以将T传递给其Add方法。因此,ICollection<T>不能使用out注释其类型参数。(如果您尝试编写自己的类似接口,如果您声明类型参数为协变,编译器将产生错误。它不仅仅是凭您的话,而是检查确实不能在任何地方传递T。)

编译器拒绝示例 6-14 中的代码,因为ICollection<T>中的T不是协变的。术语协变逆变来自数学中的范畴论分支。类似IEnumerable<T>T行为的参数被称为协变,因为泛型类型的隐式引用转换与类型参数的转换方向相同:Derived可以隐式转换为Base,并且由于IEnumerable<T>中的T是协变的,IEnumerable<Derived>隐式转换为IEnumerable<Base>

逆变工作方式相反,并且你可能会猜到,我们用in关键字表示它。使用类型成员的代码最容易看到它的实际作用,因此示例 6-16 展示了一对稍微有趣的类,比之前的示例稍有不同。

示例 6-16. 带有实际成员的类层次结构
public class Shape
{
    public Rect BoundingBox { get; set; }
}

public class RoundedRectangle : Shape
{
    public double CornerRadius { get; set; }
}

示例 6-17 定义了两个使用这些形状类型的类。它们都实现了我在第四章中介绍的 IComparer<T>BoxAreaComparer 根据其边界框的面积比较两个形状 —— 边界框覆盖面积较大的形状将被认为比较大。另一方面,CornerSharpnessComparer 比较圆角矩形,看它们的角有多尖。

示例 6-17. 比较形状
public class BoxAreaComparer : IComparer<Shape>
{
    public int Compare(Shape? x, Shape? y)
    {
        if (x is null)
        {
            return y is null ? 0 : -1;
        }
        if (y is null)
        {
            return 1;
        }

        double xArea = x.BoundingBox.Width * x.BoundingBox.Height;
        double yArea = y.BoundingBox.Width * y.BoundingBox.Height;

        return Math.Sign(xArea - yArea);
    }
}

public class CornerSharpnessComparer : IComparer<RoundedRectangle>
{
    public int Compare(RoundedRectangle? x, RoundedRectangle? y)
    {
        if (x is null)
        {
            return y is null ? 0 : -1;
        }
        if (y is null)
        {
            return 1;
        }

        // Smaller corners are sharper, so smaller radius is "greater" for
        // the purpose of this comparison, hence the backward subtraction.
        return Math.Sign(y.CornerRadius - x.CornerRadius);
    }
}

RoundedRectangle 类型的引用隐式转换为 Shape,那么IComparer<T>呢?我们的 BoxAreaComparer 可以比较任何形状,并通过实现 IComparer<Shape> 声明了这一点。比较器的类型参数 T 只在 Compare 方法中使用,它可以接受任何 Shape。如果我们传递一对 RoundedRectangle 引用,它也不会感到困惑,因此我们的类完全可以作为 IComparer<RoundedRectangle>。因此,从 IComparer<Shape>IComparer<RoundedRectangle> 的隐式转换是有意义的,并且实际上是允许的。然而,CornerSharpnessComparer 更挑剔。它使用 CornerRadius 属性,该属性仅在圆角矩形上可用,而不是在任何旧的 Shape 上。因此,从 IComparer<RoundedRectangle>IComparer<Shape> 不存在隐式转换。

这与我们在IEnumerable<T>中看到的情况正好相反。当存在从 T1T2 的隐式引用转换时,IEnumerable<T1>IEnumerable<T2> 之间可以进行隐式转换。但是在 IComparer<T>IComparer<T2> 之间的隐式转换则是在另一个方向上存在隐式引用转换:从 T2T1。这种反向关系称为逆变。示例 6-18 是 IComparer<T> 的定义摘录,显示了这种逆变类型参数。

示例 6-18. 逆变类型参数
public interface IComparer<in T>

大多数泛型类型参数既不是协变也不是逆变(它们是不变的)。ICollection<T> 不能是变体,因为它包含一些接受 T 的成员和一些返回 T 的成员。ICollection<Shape> 可能包含不是 RoundedRectangles 的形状,所以你不能将它传递给一个期望 ICollection<RoundedRectangle> 的方法,因为这样的方法会期望从集合检索到的每个对象都是圆角矩形。相反,ICollection<RoundedRectangle> 不能期望允许添加除了圆角矩形之外的形状,所以你不能将 ICollection<RoundedRectangle> 传递给一个期望 ICollection<Shape> 的方法,因为该方法可能尝试添加其他类型的形状。

数组是协变的,就像IEnumerable<T>一样。这很奇怪,因为我们可以编写像示例 6-19 中的方法一样。

示例 6-19. 修改数组中的元素
public static void UseBaseArray(Base[] bases)
{
    bases[0] = new Base();
}

如果我试图使用 Example 6-20 中的代码调用此方法,我将犯与 Example 6-14 相同的错误,那里我试图将ICollection<Derived>传递给一个试图将不是Derived的东西放入集合中的方法。但是,虽然 Example 6-14 不能编译,Example 6-20 却可以,这归因于数组的令人惊讶的协变性。

Example 6-20. 传递一个具有派生元素类型的数组
Derived[] derivedBases = { new Derived(), new Derived() };
UseBaseArray(derivedBases);

这使得看起来我们可以偷偷地让这个数组接受一个不是数组元素类型实例的引用——在这种情况下,将一个Base的引用放入Derived[]中。但这将违反类型系统。这是否意味着天要塌下来了?

实际上,C#正确禁止这种违规行为,但依赖 CLR 在运行时执行此操作。尽管类型为Derived[]的数组的引用可以隐式转换为类型为Base[]的引用,但任何试图将不符合类型系统的数组元素设置的尝试都会抛出ArrayTypeMismatchException异常。因此,当试图将一个Base的引用分配给Derived[]数组时,Example 6-19 会抛出该异常。

运行时检查确保了类型安全的维护,并且这使得一个便利特性得以实现。如果我们编写一个仅从数组中读取的方法,我们可以传递一些派生元素类型的数组。但缺点是 CLR 在运行时需要额外工作,当修改数组元素时,以确保没有类型不匹配。它可以优化代码以避免每次赋值都进行检查,但仍然会有一些开销,这意味着数组并不像可能的那么高效。

这种有些奇特的安排可以追溯到.NET 在正式化协变和逆变的概念之前的时期——这些概念是随着泛型引入.NET 2.0 而引入的。也许如果从一开始就有泛型,数组将不会如此奇怪,尽管说了这些,即使在.NET 2.0 之后,它们特有的协变形式多年来仍是框架中唯一内建的通过索引读取集合协变传递到方法的机制。直到.NET 4.5 引入了IReadOnlyList<T>(其中T是协变的),框架中才有只读索引集合接口,因此没有带有协变类型参数的标准索引集合接口(IList<T>是读/写的,因此像ICollection<T>一样,它不能提供协变)。

在我们讨论类型兼容性和继承带来的隐式引用转换时,还有一个类型需要我们关注:object

System.Object

在 C#中,System.Object类型,或者我们通常称之为object,非常有用,因为它可以充当一种通用容器:这种类型的变量可以持有几乎任何东西的引用。我之前提到过这一点,但我还没有解释为什么它是真的。这能行得通的原因是几乎所有东西都从object派生。

当你在编写一个类或记录时没有指定基类时,C# 编译器会自动将object作为基类。稍后我们会看到,对于某些类型,如结构体,它会选择不同的基类,但即使这些类型间接地从object派生。(作为例外,指针类型是一个例外——它们不从object派生。)

接口和对象之间的关系稍微复杂一些。接口不从object派生,因为接口只能指定其他接口作为其基类。然而,任何接口类型的引用都可以隐式转换为object类型的引用。这种转换总是有效的,因为能够实现接口的所有类型最终都从object派生。此外,即使严格来说,这些方法并不是接口的成员,C# 选择通过接口引用使object类的成员可用。这意味着任何类型的引用始终提供了由object定义的以下方法:ToStringEqualsGetHashCodeGetType

System.Object 的普遍方法

我已经在几个例子中使用了ToString。默认实现返回对象的类型名称,但许多类型提供了它们自己的ToString实现,返回对象当前值的更有用的文本表示。例如,数值类型返回其值的十进制表示,而bool返回"True""False"

我在 第三章 中讨论了 EqualsGetHashCode 方法,但我会在这里简要回顾一下。Equals 允许将一个对象与任何其他对象进行比较。默认实现只执行标识比较,即只有当对象与自身比较时返回 true。许多类型提供了一个 Equals 方法,执行类似值的比较——例如,两个不同的 string 对象可能包含相同的文本,这种情况下它们将报告彼此相等。(如果需要对提供值比较的对象执行基于标识的比较,可以使用 object 类的静态 ReferenceEquals 方法。)顺便说一句,object 还定义了一个接受两个参数的静态版本的 Equals 方法。这检查参数是否为 null,如果两个参数都为 null 则返回 true,如果只有一个参数为 null 则返回 false;否则,它将委托给第一个参数的 Equals 方法。正如在 第三章 中讨论的那样,GetHashCode 返回一个整数,它是对象值的简化表示,被哈希机制(例如 Dictionary<TKey, TValue> 集合类)使用。任何两个 Equals 返回 true 的对象必须返回相同的哈希码。

GetType 方法提供了一种发现对象类型信息的方式。它返回一个 Type 类型的引用。这是反射 API 的一部分,是 第十三章 的主题。

除了这些可以通过任何引用访问的公共成员外,object 还定义了另外两个不是普遍可访问的成员。对象只能在自身上访问这些成员。它们是 FinalizeMemberwiseClone。CLR 调用 Finalize 方法来通知你的对象不再使用,并且它占用的内存即将被回收。在 C# 中,我们通常不直接使用 Finalize 方法,因为 C# 通过析构函数(我将在 第七章 中展示)来呈现这一机制。MemberwiseClone 创建一个与你的对象相同类型的新实例,其初始化为你的对象所有字段的副本。如果需要一种方式来创建对象的克隆,这可能比手动复制所有内容的代码更简单,尽管它不是非常快速。

最后两个方法之所以只能从对象内部访问,是因为你可能不希望其他人克隆你的对象,而且如果外部代码能调用 Finalize 方法,让你的对象误以为即将释放内存,这将毫无帮助。object 类限制了这些成员的可访问性。但它们不是私有的——这意味着只有 object 类本身才能访问它们,因为私有成员甚至对派生类也不可见。相反,object 将这些成员设置为 protected,这是为继承场景设计的访问限定符。

访问权限和继承

到目前为止,你应该已经熟悉了大多数可用于类型及其成员的访问级别。标记为 public 的元素对所有人可见,private 成员仅能从声明它们的类型内部访问,而 internal 成员对同一组件中定义的代码可见。¹ 但是通过继承,我们还可以获得其他三种访问权限选项。

protected 标记的成员在定义它的类型内部和任何派生类型内部都可用。但是对于使用你的类型实例的代码而言,protected 成员是不可访问的,就像 private 成员一样。

类型成员的下一个保护级别是 protected internal。(如果你喜欢,也可以写成 internal protected;顺序没有影响。)这使得成员比单独的 protectedinternal 更容易访问:成员将对所有派生类型和共享同一个程序集的所有代码可见。

继承增加的第三个保护级别是 protected private。使用此标记的成员(或等效的 private protected)仅对从定义类型派生且位于同一组件中的类型可用。

你可以使用 protectedprotected internalprotected private 来修饰类型的任何成员,而不仅仅是方法。你甚至可以使用这些访问权限修饰符来定义嵌套类型。

尽管 protectedprotected internal(尽管不包括 protected private)成员不能通过定义类型的普通变量访问,它们仍然是类型公共 API 的一部分,这意味着任何有权访问你的类的人都能够使用这些成员。与大多数支持类似机制的语言一样,C# 中的 protected 成员通常用于提供派生类可能找到有用的服务。如果你编写了一个支持继承的 public 类,那么任何人都可以从它派生,并且能够访问其 protected 成员。因此,删除或更改 protected 成员会像删除或更改 public 成员一样,可能会破坏依赖于你的类的代码。

当你从一个类派生时,不能使你的类比其基类更可见。例如,如果你从一个 internal 类派生,你不能将你的类声明为 public。你的基类形成了你的类 API 的一部分,因此任何希望使用你的类的人实际上也在使用其基类;这意味着如果基类不可访问,你的类也将不可访问,这就是为什么 C# 不允许一个类比其基类更可见的原因。如果你从一个 protected 的嵌套类派生,你的派生类可以是 protectedprivateprotected private,但不能是 publicinternalprotected internal

这个限制不适用于你实现的接口。public类可以自由实现internalprivate接口。但是,它适用于接口的基接口:public接口不能从internal接口派生。

在定义方法时,还有另一个关键字可以为派生类型增加效果:virtual

虚拟方法

虚方法是派生类型可以替换的方法。object定义的几种方法都是虚拟的:ToStringEqualsGetHashCodeFinalize方法都被设计为可替换的。用于生成对象值的有用文本表示所需的代码会因类型不同而大不相同,判断相等性和生成哈希码所需的逻辑也会不同。类型通常仅在需要在其不再使用时执行一些专门的清理工作时定义终结器。

并非所有方法都是虚方法。事实上,C#默认情况下使方法为非虚方法。object类的GetType方法不是虚方法,因此您可以始终信任它返回的信息,因为您知道您调用的是.NET 提供的GetType方法,而不是某种特定类型的替代品,旨在愚弄您。要声明方法应为虚方法,请使用virtual关键字,如示例 6-21 所示。

示例 6-21. 带有虚方法的类
public class BaseWithVirtual
{
    `public` `virtual` `void` `ShowMessage``(``)`
    {
        Console.WriteLine("Hello from BaseWithVirtual");
    }
}
注意

你也可以将virtual关键字应用于属性。属性在底层只是方法,因此这会使访问器方法变为虚方法。事件也是如此,这在第 9 章中有讨论。

调用虚方法的语法并无特殊之处。如示例 6-22 所示,它看起来就像调用任何其他方法一样。

示例 6-22. 使用虚方法
public static void CallVirtualMethod(BaseWithVirtual o)
{
    o.ShowMessage();
}

虚方法调用与非虚方法调用的区别在于,虚方法调用会在运行时决定调用哪个方法。在示例 6-22 中的代码实际上会检查传入的对象,如果对象的类型提供了自己的ShowMessage实现,那么会调用那个实现,而不是在BaseWithVirtual中定义的实现。方法的选择基于目标对象在运行时的实际类型,而不是在编译时确定的表达式的静态类型。

注意

由于虚方法调用是基于调用方法的对象的类型选择方法,静态方法不能是虚方法。

派生类型不必替换虚方法。示例 6-23 展示了两个从示例 6-21 派生的类。第一个保留了基类对ShowMessage的实现。第二个对其进行了重写。请注意override关键字——C#要求我们明确声明我们打算重写虚方法。

示例 6-23. 重写虚方法
public class DeriveWithoutOverride : BaseWithVirtual
{
}

public class DeriveAndOverride : BaseWithVirtual
{
    public override void ShowMessage()
    {
        Console.WriteLine("This is an override");
    }
}

我们可以将这些类型与示例 6-22 中的方法一起使用。示例 6-24 调用它三次,每次传入不同类型的对象。

示例 6-24. 利用虚方法
CallVirtualMethod(new BaseWithVirtual());
CallVirtualMethod(new DeriveWithoutOverride());
CallVirtualMethod(new DeriveAndOverride());

这产生了以下输出:

Hello from BaseWithVirtual
Hello from BaseWithVirtual
This is an override

显然,当我们传递基类的实例时,我们会得到基类的ShowMessage方法的输出。对于未提供重写的派生类,我们也会得到相同的输出。只有最终重写了该方法的类才会产生不同的输出。这表明虚方法提供了编写多态代码的一种方式:示例 6-22 可以使用多种类型。

当重写一个方法时,方法名和其参数类型必须完全匹配。在大多数情况下,返回类型也会相同,但并非总是如此。如果virtual方法的返回类型不是void,并且不是ref返回,则重写方法的返回类型可以不同,只要存在从该类型到virtual方法返回类型的隐式引用转换。简而言之,重写方法允许在返回类型上更为具体。这意味着像示例 6-25 这样的例子是合法的。

示例 6-25. 缩小返回类型的重写
public class Product { }
public class Book : Product { }

public class ProductSourceBase
{
    public virtual Product Get() { return new Product(); }
}

public class BookSource : ProductSourceBase
{
    public override Book Get() { return new Book(); }
}

注意,Get的重写的返回类型是Book,即使它重写的virtual方法返回Product也是如此。这是可以接受的,因为通过ProductSourceBase类型的引用调用此方法的任何东西都将期望得到一个Product类型的引用,并且由于继承关系,BookProduct的一种。因此,ProductSourceBase类型的用户将不会察觉到或受到此更改的影响。在直接处理派生类型的代码需要知道将返回的具体类型的情况下,此功能有时会很有用。

也许你会想知道为什么我们需要虚方法,考虑到接口也能实现多态代码。在 C# 8.0 之前,虚方法相对于接口的一个主要优势是,基类可以提供一个默认实现,派生类将默认获取这个实现,并仅在真正需要不同实现时提供自己的实现。语言中添加默认接口实现的功能意味着接口现在也可以做到这一点,尽管默认接口成员实现不能定义或访问非静态字段,因此与定义虚函数的类相比受到一定限制。 (由于默认接口实现需要运行时支持,对于需要在.NET Framework 上运行的代码是不可用的,这包括任何目标为.NET Standard 2.0 或更早版本的库。)但是,虚方法还有一个更微妙的优势,但在我们能够看到它之前,我们需要探索虚方法的一个特性,即乍一看更像接口工作方式的东西。

抽象方法

你可以定义一个虚方法而不提供默认实现。在 C#中,这称为抽象方法。如果一个类包含一个或多个抽象方法,则该类是不完整的,因为它未提供所有定义的方法。这种类也被描述为抽象类,无法创建抽象类的实例;尝试使用new运算符与抽象类将导致编译器错误。有时在讨论类时,明确某个特定类不是抽象类是有用的,我们通常使用术语具体类

如果你从抽象类派生,那么除非你为所有抽象方法提供实现,否则你的派生类也将是抽象的。你必须使用abstract关键字声明你要写的抽象类;如果一个类有未实现的抽象方法(无论是自己定义的还是继承的),而没有使用abstract关键字声明为抽象类,C#编译器将报错。示例 6-26 展示了定义单个抽象方法的抽象类。抽象方法在定义上是虚的;如果没有办法让派生类提供方法体,定义一个没有方法体的方法也就没有多大用处。

示例 6-26. 一个抽象类
public abstract class AbstractBase
{
    public abstract void ShowMessage();
}

抽象方法声明只定义了签名,不包含方法体。与接口不同的是,每个抽象成员都有自己的可访问性——可以将抽象方法声明为publicinternalprotected internalprotected privateprotected。(将抽象或虚方法声明为private没有意义,因为该方法对派生类型是不可访问的,因此无法重写。)

注意

虽然包含抽象方法的类必须是抽象的,但反之并非如此。尽管不寻常,将一个本来可以作为具体类的类定义为抽象是合法的。这样可以防止该类被实例化。从这种类派生的类将是具体类,而无需重写任何抽象方法。

抽象类有选择地声明它们实现接口,而无需提供完整的实现。但是,不能只省略未实现的成员。您必须显式声明其所有成员,并将您希望保留未实现的任何成员标记为抽象,就像示例 6-27 所示的那样。这迫使具体派生类型提供实现。

示例 6-27. 抽象接口实现
public abstract class MustBeComparable : IComparable<string>
{
    public abstract int CompareTo(string? other);
}

抽象类和接口之间显然存在一些重叠。两者都提供了一种定义抽象类型的方式,使得代码在运行时无需知道确切的类型即可使用。每种选项都有其利弊。接口的优势在于单个类型可以实现多个接口,而类只能指定一个基类。但是抽象类可以定义字段,并且可以在其提供的任何默认成员实现中使用这些字段,并且它们还提供了一种在.NET Framework 上提供默认实现的方式。然而,在发布多个版本的库时,虚方法可以发挥出更为微妙的优势。

继承和库版本控制

想象一下,如果您编写并发布了一个定义了一些公共接口和抽象类的库,并且在该库的第二个版本中,您决定向其中一个接口添加一些新成员,会发生什么情况。这可能对使用您的代码的客户不会造成问题。当然,他们在使用接口类型的引用的任何地方都不会受到新功能添加的影响。但是,如果您的某些客户已编写了实现您接口的类型,会怎么样呢?例如,假设在未来的.NET 版本中,Microsoft 决定向IEnumerable<T>接口添加一个新成员。

如果接口不为新成员提供默认实现,那将是一场灾难。这个接口被广泛使用,但也被广泛实现。已经实现IEnumerable<T>的类将变得无效,因为它们不会提供这个新成员,因此旧代码将无法编译,已经编译的代码将在运行时抛出MissingMethodException错误。C# 对接口中的默认成员实现的支持可以减轻这一问题:如果微软确实向IEnumerable<T>添加了新成员,它可以提供一个默认实现以防止这些错误。这对于使用 .NET Framework 的人没有帮助,因为它不支持这个功能,但对于较新的运行时环境,这使得修改现有接口定义似乎是可行的。然而,还有一个更微妙的问题。一些类可能碰巧已经具有与新添加方法相同名称和签名的成员。如果该代码针对新的接口定义重新编译,编译器将将该现有成员视为接口实现的一部分,即使编写该方法的开发人员并没有这样的意图。因此,除非现有代码碰巧确实执行了新成员所需的操作,否则我们将遇到问题,并且我们不会收到编译器错误或警告来提醒我们。

因此,广泛接受的规则是一旦接口发布后就不要更改接口。如果您完全控制使用和实现接口的所有代码,那么您可以修改接口,因为您可以对受影响的代码进行任何必要的修改。但一旦接口可用于您无法控制的代码库中——也就是说,一旦它被发布——就不可能在不冒破坏其他人代码风险的情况下更改它。默认接口实现可以减轻这种风险,但它们无法消除现有方法在重新编译时被错误解释的问题。

抽象基类不必遭受这个问题的困扰。显然,引入新的抽象成员将导致完全相同的MissingMethodException失败,但引入新的虚拟方法则不会。(而且自从 C# v1 开始就有虚拟方法,这使您可以针对 .NET Framework,其中不支持默认接口实现支持。)

但是,如果在发布版本 1.0 的组件之后,在版本 1.1 中添加了一个新的虚拟方法,结果发现该方法与某个客户恰好在派生类中添加的方法具有相同的名称和签名呢?也许在版本 1.0 中,您的组件定义了示例 Example 6-28 中显示的相当无趣的基类。

示例 6-28. 基类版本 1.0
public class LibraryBase
{
}

如果您发布此库,可能会在NuGet 软件包管理网站上,或作为应用程序的某个软件开发工具包(SDK)的一部分。客户可能会编写一个派生类型,比如示例 6-29 中的一个。他们编写的Start方法显然不意图覆盖基类中的任何内容。

示例 6-29. 从版本 1.0 基础派生的类
public class CustomerDerived : LibraryBase
{
    public void Start()
    {
        Console.WriteLine("Derived type's Start method");
    }
}

由于您可能无法看到客户编写的每一行代码,因此您可能不知道这个Start方法。因此,在您组件的 1.1 版本中,您可能决定添加一个新的虚方法,也叫做Start,正如示例 6-30 所示。

示例 6-30. 基础类型版本 1.1
public class LibraryBase
{
    public virtual void Start() { }
}

想象一下,您的系统作为引入 v1.1 版本初始化过程的一部分调用了这个方法。您定义了一个默认的空实现,这样从LibraryBase派生的类型如果不需要参与该过程,则无需执行任何操作。希望参与的类型将覆盖此方法。但是在示例 6-29 中的类会发生什么呢?显然,编写此代码的开发人员并不打算参与您的新初始化机制,因为在编写代码时它并不存在。如果您的代码调用了CustomerDerived类的Start方法,则可能会出现问题,因为开发人员可能期望仅在他们的代码决定调用时才调用它。幸运的是,编译器会检测到此问题。如果客户尝试使用版本 1.1 的库编译示例 6-29,编译器会警告他们存在某些问题:

warning CS0114: 'CustomerDerived.Start()' hides inherited member
'LibraryBase.Start()'. To make the current member override that implementation,
add the override keyword. Otherwise add the new keyword.

这就是为什么当我们替换虚拟方法时,C#编译器要求使用override关键字的原因。它想知道我们是否打算覆盖现有的方法,以便如果我们没有打算这样做,它可以警告我们可能出现的命名冲突。(没有任何等效的关键字表示意图实现接口成员,这也是编译器无法检测到默认接口实现问题的原因。而这种缺失的原因是在 C# 8.0 之前不存在默认接口实现。)

我们得到了一个警告而不是错误,因为编译器在这种由于库的新版本发布而产生的情况下提供了一个可能是安全的行为。编译器推测——在这种情况下是正确的——编写CustomerDerived类型的开发人员并不打算覆盖LibraryBase类的Start方法。因此,与其让CustomerDerived类型的Start方法覆盖基类的虚方法,它隐藏了它。当派生类型引入一个与基类同名的新成员时,称为派生类型隐藏基类成员。

隐藏方法与重写方法有很大不同。当发生隐藏时,基础方法不会被替换。示例 6-31 展示了如何保留隐藏的Start方法。它创建了一个CustomerDerived对象,并将该对象的引用放入两个不同类型的变量中:一个是CustomerDerived类型,另一个是LibraryBase类型。然后通过每个变量分别调用Start方法。

示例 6-31. 隐藏与虚方法
var d = new CustomerDerived();
LibraryBase b = d;

d.Start();
b.Start();

当我们使用变量d时,调用Start方法实际上调用了派生类型的Start方法,即隐藏了基类成员的方法。但变量b的类型是LibraryBase,所以会调用基类的Start方法。如果CustomerDerived类重写了基类的Start方法而不是隐藏它,那么这两个方法调用都会调用重写的方法。

当由于新库版本而发生名称冲突时,通常隐藏行为是正确的做法。如果客户代码有一个CustomerDerived类型的变量,则该代码将希望调用特定于该派生类型的Start方法。然而,编译器会产生警告,因为它不能确定这是否是问题的原因。可能是你确实想要重写方法,只是忘记写override关键字了。

像许多开发人员一样,我不喜欢看到编译器警告,并且尽量避免提交产生警告的代码。但是如果新的库版本让你处于这种情况下,你应该怎么办?最好的长期解决方案可能是在派生类中更改方法的名称,以避免与新版本库中的方法冲突。然而,如果你面临截止日期,可能需要一个更快的解决方案。因此,C#允许您声明您知道存在名称冲突,并且您绝对想要隐藏基类成员,而不是重写它。正如示例 6-32 所示,您可以使用new关键字声明您已经意识到这个问题,并且绝对要隐藏基类成员。代码仍将以相同的方式工作,但您将不再收到警告,因为您已向编译器保证您知道发生了什么。但这是您应该在某个时候解决的问题,因为 sooner or later,在同一类型上存在两个意义不同但名称相同的方法很可能会导致混淆。

示例 6-32. 隐藏成员时避免警告
public class CustomerDerived : LibraryBase
{
    `public` `new` `void` `Start``(``)`
    {
        Console.WriteLine("Derived type's Start method");
    }
}
注意

C#不允许你使用new关键字来处理默认接口实现带来的等价问题。没有办法保留接口提供的默认实现并声明具有相同签名的公共方法。这稍微让人沮丧,因为在二进制级别是可能的:如果在添加新成员并未重新编译实现接口的代码时,这就是你会得到的行为。你仍然可以拥有ILibrary.StartCustomerDerived.Start的单独实现,但必须使用显式接口实现。

偶尔你可能会看到new关键字以这种方式使用,而不是处理库版本问题。例如,我在第五章中展示的ISet<T>接口使用它来引入一个新的Add方法。ISet<T>派生自ICollection<T>,一个已经提供了接受T实例并具有void返回类型的Add方法的接口。ISet<T>对此进行了微妙的更改,如示例 6-33 所示。

示例 6-33. 隐藏以更改签名
public interface ISet<T> : ICollection<T>
{
    new bool Add(T item);
    // ...other members omitted for clarity
}

ISet<T>接口的Add方法告诉你刚刚添加的项目是否已经存在于集合中,而基本的ICollection<T>接口的Add方法不支持这一点。ISet<T>需要其Add方法具有不同的返回类型——bool而不是void——因此它使用new关键字来隐藏ICollection<T>的方法。这两种方法仍然可用——如果你有两个变量,一个类型为ICollection<T>,另一个类型为ISet<T>,两者都引用同一个对象,你将能够通过前者访问void Add,通过后者访问bool Add

微软本可以不这样做。它本可以将新的Add方法命名为其他名称,比如AddIfNotPresent。但只有一个方法名称用于向集合中添加事物可能会更少引起混淆,尤其是当你可以忽略返回值时,此时新的Add看起来与旧的Add无法区分。而大多数ISet<T>实现将通过直接调用ISet<T>.Add方法来实现ICollection<T>.​Add方法,因此它们具有相同的名称是有道理的。

除了上述示例之外,到目前为止,我只讨论了在编译旧代码以针对新版本库的情况下进行方法隐藏。如果您有已经编译但最终在新版本下运行的旧代码呢?当涉及的库是.NET 运行时库时,这是一个您极有可能遇到的情景。假设您正在使用第三方组件,这些组件只有二进制形式(例如,您从不提供源代码的公司购买了许可的组件)。供应商将这些组件构建为使用某个特定版本的.NET。如果您升级应用程序以与新版本的.NET 一起运行,您可能无法获取到更新的第三方组件版本——也许供应商尚未发布它们,或者可能已经停业。

如果您使用的组件是为.NET Standard 1.2 编译的,而您将它们用于为.NET 6.0 构建的项目中,所有这些旧组件将最终使用.NET 6.0 版本的运行时库。.NET 有一个版本策略,确保特定程序使用的所有组件都使用相同版本的运行时库,而不管每个组件可能是为哪个版本构建的。因此,完全有可能某些组件,如OldControls.dll,包含从.NET Standard 1.2 派生的类,并定义与.NET 6.0 中新增成员名称相冲突的成员。

这基本上与我之前描述的情景相同,只是针对旧版本库编写的代码不会重新编译。我们不会收到关于隐藏方法的编译器警告,因为那需要运行编译器,而我们只有相关组件的二进制文件。现在会发生什么呢?

幸运的是,我们不需要重新编译旧组件。C#编译器会为每个编译的方法设置各种标志,指示诸如方法是否虚拟,方法是否打算覆盖基类中的某个方法等内容。当您在方法上放置new关键字时,编译器会设置一个标志,指示该方法不打算覆盖任何东西。CLR 将其称为newslot标志。当 C#编译一个像示例 6-29 中的方法时,该方法既不指定override也不指定new,编译器也会为该方法设置相同的 newslot 标志,因为在编译该方法时,基类上没有同名方法。对于开发人员和编译器而言,CustomerDerived类的Start方法就像是一个全新的方法,与基类上的任何内容都没有关联。

当旧组件与定义基类的新版本库一起加载时,CLR 可以看到作者在 CustomerDerived 类中的意图——即使没有重新编译,CLR 也可以看到作者认为 Start 并不意味着要覆盖任何东西。因此,它将 CustomerDerived.Start 视为与 LibraryBase.Start 不同的方法——就像我们重新编译时一样隐藏基础方法。

顺便说一句,我提到的关于虚方法的一切也适用于属性,因为属性的访问器只是方法。因此,您可以定义虚属性,并且派生类可以以与方法完全相同的方式重写或隐藏这些属性。在 第九章 中我不会讲事件,但它们也是方法的一种形式,所以它们也可以是虚的。

偶尔,您可能希望编写一个类来重写虚方法,然后阻止派生类再次重写它。对此,C# 定义了 sealed 关键字,事实上,不仅仅是方法可以被标记为 sealed。

Sealed Methods and Classes

虚方法故意通过继承来修改。而 sealed 方法则是相反的——它是一个不可重写的方法。在 C# 中,默认情况下方法是 sealed 的:方法不能被重写,除非声明为 virtual。但是当您重写虚方法时,可以使用 sealed 关键字将其封闭,阻止进一步修改。示例 6-34 使用这种技术提供了一个自定义的 ToString 实现,不能被派生类进一步重写。

示例 6-34. 一个 sealed 方法
public class FixedToString
{
    public sealed override string ToString() => "Arf arf!";
}

您还可以封闭整个类,防止任何人从中派生。示例 6-35 展示了一个不仅仅是什么都不做,还防止任何人扩展它以做一些有用事情的类。(通常您只会封闭做某些事情的类。这个例子只是为了说明关键字的使用位置。)

示例 6-35. 一个 sealed 类
public sealed class EndOfTheLine
{
}

一些类型本质上是 sealed 的。例如,值类型不支持继承,因此结构体、记录结构体和枚举实际上是 sealed。内置的 string 类也是 sealed 的。

密封类或方法通常有两个正常的原因。一是你希望保证某个特定不变量,如果你将类型开放以进行修改,将无法保证该不变量。例如,string类型的实例是不可变的。string类型本身不提供修改实例值的方法,因此由于无法从string派生,你可以保证如果你有一个string类型的引用,你就拥有一个不可变对象的引用。这使得在你不希望值改变的场景中使用它变得安全——例如,当你将对象作为字典的键(或任何依赖哈希码的其他东西)时,你需要值不变,因为如果在项目作为键使用时哈希码发生变化,容器将发生故障。

留下事物密封的另一个通常原因是,设计能够通过继承成功修改的类型很难,特别是如果你的类型将在你自己组织之外使用。简单地打开事物以进行修改是不够的——如果你决定使所有方法都虚拟化,这可能会使使用你的类型的人修改其行为变得容易,但当你维护基类时,你将会给自己找麻烦。除非你控制所有从你的类派生的代码,否则几乎不可能更改基类中的任何内容,因为你永远不会知道哪些方法可能已在派生类中被覆盖,这使得难以确保你类的内部状态始终一致。编写派生类型的开发人员无疑会尽力避免破坏事物,但他们将不可避免地依赖于未记录的你类行为的某些方面。因此,在通过继承开放你类的每个方面以供修改时,你剥夺了自己改变类的自由。

对于哪些方法(如果有的话)你应该非常谨慎地选择使其虚拟化。你还应该记录调用者是否允许完全替换方法,或者是否要求调用基本实现作为其覆盖的一部分。说到这一点,你该如何做到呢?

访问基类成员

在基类中范围内的所有内容,只要不是私有的,也将在派生类型中范围内,并且可访问。如果你想访问基类的某个成员,通常只需像访问自己类的普通成员一样访问即可。你可以通过this引用访问成员,或者直接按名称访问,而无需限定符。

然而,有些情况下你需要明确表示你打算引用基类成员。特别是,如果你重写了一个方法,用名称调用该方法将递归调用你的重写。如果你想调用原始的你重写的方法,有一个特殊的关键字用于这个,如 Example 6-36 中所示。

Example 6-36. 覆盖后调用基类方法
public class CustomerDerived : LibraryBase
{
    public override void Start()
    {
        Console.WriteLine("Derived type's Start method");
        `base``.``Start``(``)``;`
    }
}

使用base关键字,我们选择了不使用正常的虚方法分发机制。如果我们只写了Start(),那将是一个递归调用,在这里是不希望的。通过写base.Start(),我们获得了在基类实例上可用的方法,也就是我们重写的方法。

如果继承链更深呢?假设CustomerDerived派生自IntermediateBase,而IntermediateBase又派生自LibraryBase并重写了Start方法。在这种情况下,在我们的Cus⁠tom⁠er​Der⁠iv⁠ed类型中写base.Start()将调用IntermediateBase定义的重写。没有办法绕过这一点直接调用原始的LibraryBase.Start

在这个例子中,我在完成我的工作后调用了基类的实现。C#并不关心你什么时候调用基类——你可以在方法开始时调用它,在最后调用它,或者在方法中间的任何地方调用它。你甚至可以多次调用它,或者根本不调用它。调用基类方法的时间由基类的作者来决定,他需要文档化方法的重写是否应该调用基类实现。

你也可以对其他成员使用base关键字,如属性和事件。但是,对基类构造函数的访问方式略有不同。

继承和构造

尽管派生类继承了其基类的所有成员,但这对构造函数的意义与其他所有成员并不相同。对于其他成员,如果它们在基类中是公共的,它们也将成为派生类的公共成员,可供任何使用你的派生类的人访问。但构造函数是特殊的,因为使用你的类的人无法通过使用基类定义的构造函数来构造它。

这有一个很简单的原因:如果你想要一个某种类型D的实例,那么你会希望它是一个完整的D,包含了所有适当初始化的内容。假设D派生自B。如果你能直接使用B的其中一个构造函数,它将不会对D特有的部分进行任何操作。基类的构造函数不会知道任何由派生类定义的字段,因此无法对其进行初始化。如果你想要一个D,你只能使用知道如何初始化D的构造函数。因此,对于派生类,你只能使用由该派生类提供的构造函数,不管基类提供了哪些构造函数。

在本章中我展示的示例中,我之所以能忽略这一点,是因为 C#提供的默认构造函数。如你在第三章中看到的,如果你不写一个构造函数,C#会为你写一个不带参数的构造函数。对于派生类也是如此,生成的构造函数将调用基类的无参数构造函数。但是如果我开始编写自己的构造函数,情况就会改变。示例 6-37 定义了一对类,其中基类定义了一个显式的无参数构造函数,派生类定义了一个需要参数的构造函数。

示例 6-37. 派生类中没有默认构造函数
public class BaseWithZeroArgCtor
{
    public BaseWithZeroArgCtor()
    {
        Console.WriteLine("Base constructor");
    }
}

public class DerivedNoDefaultCtor : BaseWithZeroArgCtor
{
    public DerivedNoDefaultCtor(int i)
    {
        Console.WriteLine("Derived constructor");
    }
}

因为基类有一个零参数构造函数,我可以用new BaseWithZeroArgCtor()来构造它。但是对于派生类型,我不能这样做:我只能通过传递一个参数来构造它——例如,new DerivedNoDefaultCtor(123)。因此,就DerivedNoDefaultCtor的公开可见 API 而言,派生类似乎没有继承其基类的构造函数。

然而,实际上它确实已经继承了它,你可以通过构造派生类型的实例来看到得到的输出:

Base constructor
Derived constructor

在构造DerivedNoDefaultCtor的实例时,基类的构造函数会立即在派生类的构造函数之前运行。由于基类构造函数已运行,显然它是存在的。所有基类的构造函数都对派生类型可用,但只能由派生类中的构造函数调用。示例 6-37 隐式调用了基类构造函数:所有构造函数都要求在其基类上调用一个构造函数,如果你没有指定调用哪一个,编译器将为你调用基类的零参数构造函数。

如果基类没有定义一个无参构造函数怎么办?在这种情况下,如果你派生一个不指定调用哪个构造函数的类,你将会得到一个编译器错误。示例 6-38 展示了一个没有零参数构造函数的基类。(显式构造函数的存在禁用了编译器正常生成默认构造函数的机制,因此,这个基类只提供了一个带参数的构造函数。)它同时展示了一个派生类有两个构造函数,它们都使用base关键字显式调用基类的构造函数。

示例 6-38. 显式调用基类构造函数
public class BaseNoDefaultCtor
{
    public BaseNoDefaultCtor(int i)
    {
        Console.WriteLine("Base constructor: " + i);
    }
}

public class DerivedCallingBaseCtor : BaseNoDefaultCtor
{
    public DerivedCallingBaseCtor()
        `:` `base``(``123``)`
    {
        Console.WriteLine("Derived constructor (default)");
    }

    public DerivedCallingBaseCtor(int i)
        `:` `base``(``i``)`
    {
        Console.WriteLine("Derived constructor: " + i);
    }
}

这里的派生类决定提供一个无参构造函数,尽管基类没有这样的构造函数——它为基类需要的参数提供了一个常量值。第二个则直接将其参数传递给基类。

注意

这里有一个经常被问到的问题:如何提供与我的基类完全相同的所有构造函数,只需直接传递参数? 答案是:手动编写所有构造函数。没有办法让 C#编译器生成一个看起来与基类提供的构造函数完全相同的构造函数集。您需要用比较冗长的方式来完成。

至少 Visual Studio、VS Code 或 JetBrains Rider 可以为您生成代码——如果您点击类声明,然后点击出现的快速操作图标,它将提供生成与基类中任何非私有构造函数具有相同参数的构造函数的选项,并自动为您传递所有参数。

正如第三章所示,类的字段初始化器在其构造函数之前运行。一旦涉及继承,情况就变得更加复杂,因为涉及多个类和多个构造函数。预测将会发生什么最简单的方法是理解,尽管实例字段初始化器和构造函数具有不同的语法,但 C#最终将所有特定类的初始化代码编译到构造函数中。此代码执行以下步骤:首先,运行特定于此类的字段初始化器(因此此步骤不包括基类字段初始化器——基类将自己照顾好);接下来,调用基类构造函数;最后,运行构造函数体。这意味着在派生类中,您的实例字段初始化器将在基类构造之前运行——不仅仅是在基类构造函数体之前,甚至在基类的实例字段初始化之前。示例 6-39 说明了这一点。

示例 6-39。探索构造顺序
public class BaseInit
{
    protected static int Init(string message)
    {
        Console.WriteLine(message);
        return 1;
    }

    private int b1 = Init("Base field b1");

    public BaseInit()
    {
        Init("Base constructor");
    }

    private int b2 = Init("Base field b2");
}

public class DerivedInit : BaseInit
{
    private int d1 = Init("Derived field d1");

    public DerivedInit()
    {
        Init("Derived constructor");
    }

    private int d2 = Init("Derived field d2");
}

我把字段初始化放在构造函数的两侧,只是为了表明它们相对于非字段成员的位置无关紧要。字段的顺序很重要,但只涉及彼此。构造DerivedInit类的实例会产生以下输出:

Derived field d1
Derived field d2
Base field b1
Base field b2
Base constructor
Derived constructor

这证实了派生类型的字段初始化器首先运行,然后是基类的字段初始化器,接着是基类构造函数,最后是派生类构造函数。换句话说,虽然构造函数体始于基类,但实例字段的初始化是反向进行的。

这就是为什么你不能在字段初始化器中调用实例方法的原因。静态方法是可用的,但实例方法不是,因为类远未准备就绪。如果派生类型的一个字段初始化器能够在基类上调用方法,可能会有问题,因为此时基类根本没有进行任何初始化——不仅其构造函数体尚未运行,而且其字段初始化器也尚未运行。如果实例方法在此阶段可用,我们将不得不编写所有的代码非常谨慎,因为我们不能假设我们的字段包含任何有用的内容。

正如你所见,构造函数体在进程中运行较晚,这就是我们可以在其中调用方法的原因。但这里仍然存在潜在的危险。如果基类定义了一个虚方法,并在其构造函数中调用该方法,如果派生类型覆盖了该方法,我们将在派生类型的构造函数体运行之前调用该方法。(在那时,它的字段初始化器将已经运行。实际上,这是字段初始化器以看似相反顺序运行的主要原因——这意味着派生类有一种在基类构造函数调用虚方法之前执行一些初始化的方式。)如果你熟悉 C++,你可能会猜想,当基类构造函数调用虚方法时,它将运行基本实现。但 C#的做法不同:基类的构造函数将在这种情况下调用派生类的重写方法。这不一定是问题,而且偶尔会很有用,但这意味着如果你希望你的对象在构造过程中调用自身的虚方法,你需要仔细思考并清楚地记录你的假设。

记录类型

当你定义一个record类型(或者你使用更明确但功能上相同的record class语法),从运行时的角度来看,生成的记录类型仍然是一个类。记录类型可以做大多数普通类能做的事情——尽管它们通常关注属性,你还可以添加其他成员,如方法和构造函数。事实证明,基于类的记录类型也支持继承。(自然地,由于record struct类型是值类型,它们不支持继承。)

记录类型存在一些继承约束。普通类不允许从记录类型继承——只有记录类型可以从记录类型派生。同样,记录类型只能从另一个记录类型或通常的object基类型继承。但在这些约束条件下,记录类型的继承工作方式与类相似。示例 6-40 展示了一个基本记录和几个派生类型。

示例 6-40. 记录继承
public abstract record OptionallyLabeled
{
    public string? Label { get; init; }
}

public record OptionallyLabeledItem : OptionallyLabeled;

public record Product(string Name) : OptionallyLabeled;

正如这显示的,我们可以将记录类型定义为abstract。当记录不使用位置语法时,我们从基类型(抽象或非抽象)继承的方式与类的方式相同:如OptionallyLabeledItem所示,我们在类型名称后放置一个冒号,后跟基类型名称。如果我们的派生类型想使用位置语法,则在参数列表后放置冒号和基类型,如Product类型所示。示例 6-41 展示了如何实例化在示例 6-40 中定义的两种派生类型。

示例 6-41. 实例化派生记录类型
var unlabeled = new OptionallyLabeledItem();
var labeled = new OptionallyLabeledItem
{
    Label = "New, improved!"
};

var unlabeledProduct = new Product("Book");
var labeledProduct = new Product("Shirt")
{
    Label = "Half price"
};

由于基类的Label属性不需要设置,我们可以自由地构造两种派生类型中的任意一种而不设置它。但是如果我们确实想设置它,我们使用的对象初始化语法与如果Label属性直接由OptionallyLabeledItemProduct定义的方式完全相同。但是如果基类型使用位置语法定义非可选属性,那么怎么办?正如示例 6-42 所示,记录继承语法允许我们向基类提供参数列表。

示例 6-42. 从位置记录派生
public abstract record Colorful(string Color);

public record LightBulb(string Color, int Lumens) : Colorful(Color);

LightBulb本身使用位置语法,并使用其两个构造参数之一作为基类要求的Color属性的值。但在某些情况下,您可能不想这样传递值:有时派生类型将知道要传递给基记录类型的值,就像示例 6-43 所示的那样。

示例 6-43. 将常量传递给位置基记录
public record FordModelT() : Colorful("Black");

因此,在这种情况下,尽管基Colorful记录使用位置语法,要求提供Color属性,但这个派生类型不传递该要求。流行的故事是,福特早期的汽车 Model T 只有一种颜色可供选择,因此这个特定的派生类型可以直接设置Color本身。FordModelT记录的用户无需提供Color,尽管这对于基Colorful类型是强制性参数。书呆子们现在可能渴望指出,这种油漆约束只适用于 Model T 生产的 19 年中的 12 年。我想吸引他们注意示例 6-44,它显示了尽管FordModelT类型在构建过程中不需要传递Color属性,但仍可以使用对象初始化设置。因此,这种记录类型使得可以像早期和晚期 Model T 一样指定颜色,但默认与这种汽车绝大多数确实是黑色的事实保持一致。

示例 6-44. 使用一个将强制基属性变为可选的派生记录
var commonModelT = new FordModelT();
var lateModelT = new FordModelT { Color = "Green" };

要能够使用示例 6-42 和 6-43 中显示的语法,在记录本身必须使用位置语法。 如果您仔细观察示例 6-43,您会发现在 FordModelT 类型名称之后有一个空参数列表。 虽然这可能看起来多余,但在这种情况下,需要将其放在这里,因为如果没有它,我们将不允许在冒号后直接写 Colorful("Black")

还有其他方法可以将参数传递给位置基础记录。 正如第 3 章所述,当我们使用位置语法时,我们只是定义一个构造函数,因此另一种方法是使用常规语法来调用基类构造函数,正如示例 6-45 所示。

示例 6-45. 通过普通构造函数传递位置基础记录参数
public record RedDelicious : Colorful
{
    public RedDelicious() : base("Red")
    { }
}

最近几个示例处理了基类使用位置语法但派生类型不使用的情况。 但如果反过来,基类型不是位置的,而派生类型想要是位置的怎么办? 如果派生类型只想添加一个或多个自己的属性,这很简单。 实际上,我们已经看到了产品类型在示例 6-40 中确实如此。 但是,如果基类型定义了一个可选属性(例如 OptionallyLabeled.Label),而派生类型想要将其变为强制性的,您可以这样做,但不能使用位置语法。 您必须像示例 6-46 所示一样完全编写构造函数。

示例 6-46. 使可选基本属性类位置性
public record LabeledDemographic : OptionallyLabeled
{
    public LabeledDemographic(string label)
    {
        Label = label;
    }

    public void Deconstruct(out string? label) => label = Label;
}

尽管这不使用位置语法,但它具有类似的效果,因为位置语法通过定义构造函数起作用。在示例 6-46 中构造函数的存在将阻止编译器生成默认的零参数构造函数,这意味着使用 LabeledDemographic 的代码在构造时必须提供 Label 属性,就像使用位置语法一样。在使用位置语法时,自动获得析构函数,但我这里不得不自己写。当试图对非位置记录派生类型强加位置行为时,编译器不生成析构函数会导致析构有点奇怪。基类将 Label 定义为可选,尽管我们定义了需要非空参数的构造函数,但在构造函数后可以使用对象初始化程序将其设置回 null。 (这看起来很奇怪但不违法)。所以我们的析构函数最终与构造函数不完全匹配。

记录、继承和with关键字

第三章展示了如何使用with表达式创建记录类型的修改副本。这会构建一个新实例,该实例除了在with关键字后面的大括号中指定的新属性值外,其他所有属性与原始实例相同。这种机制考虑了继承:with关键字生成的实例总是与其输入具有相同的类型,即使代码是以基础类型编写的,如示例 6-47。

示例 6-47. 在基础记录类型上使用with
OptionallyLabeled Discount(OptionallyLabeled item)
{
    return item with
    {
        Label = "60% off!"
    };
}

这里使用了从示例 6-40 中的抽象OptionallyLabeled记录类型。我们可以传递任何从该抽象基类派生的具体类型。示例 6-48 两次调用它,并传入两种不同的类型。

示例 6-48. 测试with如何与继承交互
Console.WriteLine(Discount(new OptionallyLabeledItem()));
Console.WriteLine(Discount(new Product("Sweater")));

运行该代码会产生以下输出:

OptionallyLabeledItem { Label = 60% off! }
Product { Label = 60% off!, Name = Sweater }

Console.WriteLine在其输入上调用ToString,而记录类型通过报告其名称及其属性值来实现此方法。因此,您可以从中看到,当Discount方法生成其输入的修改副本时,它成功地保留了类型信息。因此,即使DiscountProduct记录类型或其Name属性一无所知,当它创建一个带有新Label值的副本时,Name属性也被正确地保留了下来。

这是由编译器为记录类型生成的代码所能够实现的。我已经在第三章中描述了复制构造函数,但光是这个还不够——Discount方法并不知道OptionallyLabeledItemProduct类型,因此它不会调用它们的复制构造函数。因此,记录类型还会得到一个隐藏的virtual方法,名为<Clone>$。在示例 6-47 中的with表达式会调用这个方法(然后继续设置Label属性)。由编译器生成的<Clone>$方法会调用自己的复制构造函数。由于派生的记录类型会重写<Clone>$,所以with表达式无论输入的类型如何,都会获得一个完全复制的记录。

特殊的基础类型

.NET 运行时库在 C#中定义了几种具有特殊意义的基础类型。其中最明显的是System.Object,我已经对其进行了详细描述。

还有System.ValueType。这是所有值类型的抽象基类型,因此你定义的任何structrecord struct,以及所有内置的值类型,如intbool,都派生自ValueType。讽刺的是,ValueType本身是一个引用类型;只有从ValueType派生的类型才是值类型。像大多数类型一样,ValueType也派生自System.Object。在这里存在一个明显的概念上的困难:通常情况下,派生类包含其基类的所有功能,以及它们添加的任何功能。因此,考虑到objectValueType都是引用类型,从ValueType派生的类型不是值类型似乎有些奇怪。而且,一个object变量如何能够持有一个不是引用类型的实例的引用,这也不是很明显。我将在第七章中解决所有这些问题。

在 C#中,不允许你编写一个明确从ValueType派生的类型。如果你想要编写一个从ValueType派生的类型,struct关键字就是为此而设计的。你可以声明一个ValueType类型的变量,但由于该类型未定义任何公共成员,ValueType引用不允许你做任何object引用做不到的事情。唯一显著的区别是,使用该类型的变量可以分配任何值类型的实例,但不能分配引用类型的实例。除此之外,它与object完全相同。因此,在 C#代码中明确提到ValueType相对比较少见。

所有枚举类型也都派生自一个共同的抽象基类型:System.Enum。由于枚举是值类型,你不会感到意外的是,Enum派生自ValueType。与ValueType类似,你永远不会明确从Enum派生——你使用enum关键字来定义枚举类型。与ValueType不同的是,Enum添加了一些有用的成员。例如,它的静态方法GetValues返回该枚举所有值的数组,而GetNames返回将所有值转换为字符串的数组。它还提供了Parse方法,用于从字符串表示转换回枚举值。

如第五章所述,所有的数组都源于一个共同的基类,System.Array,你已经看到了它所提供的特性。

System.Exception基类非常特殊:当你抛出异常时,C#要求抛出的对象必须是这种类型或者从它派生的类型。(异常是第八章的主题。)

委托类型都源于一个共同的基类型,System.MulticastDelegate,后者又从System.Delegate派生。我将在第九章中讨论这些内容。

这些都是 CTS 视为特殊的基本类型。还有一种基本类型被 C#编译器赋予特殊意义,那就是Sys⁠tem.​Att⁠rib⁠ute。在第一章中,我为方法和类应用了某些注解,以告诉单元测试框架将它们视为特殊处理。这些属性都对应于类型,因此当我将[TestClass]属性应用于一个类时,我使用了名为TestClassAttribute的类型。设计用作属性的类型都需要派生自System.Attribute。其中一些被编译器识别—例如,有些控制编译器将其生成的 EXE 和 DLL 文件的文件头中的版本号。我将在第十四章中展示所有这些内容。

摘要

C#支持单一实现继承,仅限于类或引用类型记录—你无法从结构体派生。然而,接口可以声明多个基类,类可以实现多个接口。从派生类型到基类型存在隐式引用转换,并且泛型接口和委托可以选择使用协变或逆变来提供额外的隐式引用转换。所有类型都派生自System.Object,确保所有变量都可用某些标准成员。我们看到虚方法如何允许派生类修改其基类的选定成员,以及如何使用封闭禁用该功能。我们还探讨了派生类型在访问成员时与其基类之间的关系,特别是构造函数。

我们对继承的探索已经完成,但是它引发了一些新问题,比如值类型和引用之间的关系以及终结器的作用。因此,在下一章中,我将讨论引用与对象生命周期之间的关系,以及 CLR 如何弥合引用和值类型之间的差距。

¹ 更准确地说,同一个程序集,还有友元程序集。第十二章描述了程序集。

第七章:对象生命周期

.NET 托管执行模型的一个好处是运行时可以自动化大部分应用程序的内存管理。我已经展示了许多使用new关键字创建对象的示例,没有一个显式释放这些对象消耗的内存。

在大多数情况下,您无需采取任何措施来回收内存。运行时提供了一个垃圾收集器(GC),¹,一种自动发现对象不再使用并回收它们占用的内存的机制,以便可以用于新对象。然而,某些使用模式可能会导致性能问题,甚至完全失效 GC,因此了解其工作原理是很有用的。这在运行时间可能长达数天的长时间进程中尤为重要(短暂进程可能能够容忍一些内存泄漏)。

GC 旨在高效管理内存,但内存并不是您可能需要处理的唯一有限资源。有些东西在 CLR 中的内存占用很小,但代表相对昂贵的东西,例如数据库连接或来自 OS API 的句柄。GC 并不总是有效处理这些情况,因此我将解释IDisposable,这是专门设计用于处理比内存更紧急需要释放的东西的接口。

值类型通常有完全不同的生命周期规则——例如,一些局部变量值仅在其包含的方法运行期间存在。尽管如此,值类型有时会表现得像引用类型,并由 GC 管理。我将讨论为何这可能很有用,并解释使其成为可能的装箱机制。

垃圾收集

CLR 维护着一个,这是一个为由 GC 管理生命周期的对象和值提供内存的服务。每次使用new构造类的实例或创建新的数组对象时,CLR 都会分配一个新的堆块。GC 决定何时释放该块。

注意

如果您正在编写一个在 Android 设备上运行使用.NET 的 Xamarin 工具的.NET 应用程序,则会有两个垃圾收集堆:一个用于.NET,一个用于 Java。在 Xamarin 应用程序中正常的 C#活动使用.NET 堆,因此只有当您编写使用 Xamarin 服务处理 Java 对象的 C#代码时,Java 堆才会出现。这是一本.NET 书籍,所以我将专注于.NET GC。

堆块包含对象的所有非静态字段,或者如果是数组,则包含所有元素。CLR 还会添加一个头部,该头部对于程序来说不是直接可见的。这包括指向描述对象类型的结构体的指针。这支持依赖于对象的真实类型的操作。例如,如果在引用上调用GetType,运行时会使用此指针来查找类型。(类型通常不完全由引用的静态类型决定,静态类型可以是接口类型或实际类型的基类。)它还用于确定在调用虚拟方法或接口成员时应使用哪个方法。CLR 还使用此信息来知道堆块的大小——头部不包括块大小,因为运行时可以从对象的类型推断出来。(大多数类型都是固定大小。只有两个例外,字符串和数组,CLR 将其作为特殊情况处理。)头部包含另一个字段,用于各种不同的目的,包括多线程同步和默认哈希码生成。堆块头部只是一个实现细节,不同的运行时可能会选择不同的策略。² 但是,了解开销是有用的。在 32 位系统上,头部长度为 8 字节;在 64 位进程中运行时,长度为 16 字节。因此,一个仅包含一个double类型字段的对象在 32 位进程中将消耗 16 字节,在 64 位进程中将消耗 24 字节。

尽管对象(即类的实例)始终位于堆上,值类型的实例却有所不同:一些位于堆上,而另一些则不是。³ 例如,CLR 将一些值类型的局部变量存储在堆栈上,但如果该值是类的实例字段,则类实例将位于堆上,因此该值将驻留在堆上的该对象内部。在某些情况下,一个值将拥有自己的整个堆块。

如果你通过引用类型变量使用某物,则正在访问堆上的内容。非常重要的一点是要明确我所说的引用类型变量的含义,因为遗憾的是,这里的术语有点混乱:在 C# 中,引用 这个术语描述了两种完全不同的东西。在本讨论中,引用是指你可以存储在派生自object类型(但不是ValueType)或接口类型的变量中的内容。这并不包括每个in-、out-或ref-风格的方法参数,也不包括ref变量或返回值。虽然它们也是某种形式的引用,但ref int参数是对值类型的引用,这与引用类型并不相同。(CLR 实际上使用与 C# 不同的术语来支持refinout的机制:它称这些为托管指针,明确表明它们与对象引用有着不同。)

C# 使用的托管执行模型(以及所有 .NET 语言)意味着 CLR 知道您的代码创建的每个堆块,还知道程序存储引用的每个字段、变量和数组元素。这些信息使运行时能够随时确定哪些对象是可达的——即程序可能访问以使用其字段和其他成员的对象。如果一个对象不可达,则根据定义,程序将永远无法再次使用它。为了说明 CLR 如何确定可达性,我编写了一个简单的方法,从我的雇主网站获取网页,如示例 7-1 所示。

示例 7-1. 使用和丢弃对象
public static string FetchUrl(string relativeUri)
{
    var baseUri = new Uri("https://endjin.com/");
    var fullUri = new Uri(baseUri, relativeUri);
    var w = new HttpClient();
    HttpResponseMessage response = w.Send(
        new HttpRequestMessage(HttpMethod.Get, fullUri));
    return new StreamReader(response.Content.ReadAsStream()).ReadToEnd();
}

CLR 分析我们使用局部变量和方法参数的方式。例如,虽然relativeUri参数在整个方法中都是作用域内的,但我们只在构造第二个Uri时使用了一次作为参数,然后再也没有使用它。变量从接收值的第一个点到最后使用的最后点称为活跃。方法参数从方法开始直到最后使用,除非它们未被使用,否则它们将永远不活跃。局部变量稍后才会活跃;baseUri在分配初始值后变为活跃,然后在此示例中与relativeUri的最后使用同时停止活跃。活跃性是确定特定对象是否仍在使用的重要属性。

要了解活跃性的作用,请假设在示例 7-1 达到构造HttpClient行时,CLR 没有足够的空闲内存来容纳新对象。此时,CLR 可以向操作系统请求更多内存,但也可以选择尝试从不再使用的对象中释放内存,这意味着我们的程序不需要消耗比它已经使用的内存更多。⁴ 接下来的部分描述了当 CLR 选择第二个选项时的过程。

确定可达性

.NET 的基本方法是确定堆上哪些对象是可达的。如果程序无法获取某个对象,那么可以安全地丢弃它。CLR 首先确定程序中所有的根引用。根引用是指存储位置,例如局部变量,可能包含引用并已知已初始化,并且您的程序在将来某个时候可以使用它,而无需通过其他对象引用。并非所有存储位置都被视为根引用。如果对象包含某个引用类型的实例字段,则该字段不是根引用,因为在使用它之前,您需要获取对包含对象的引用,并且该对象本身可能不可达。但是,引用类型的静态字段是根引用,因为程序可以随时读取该字段的值——该字段将在组件定义该类型的组件卸载时变得不可访问,这在大多数情况下将是在程序退出时。

局部变量和方法参数更加有趣。有时它们是根引用,但有时并非如此。这取决于当前执行的方法的确切部分。只有在执行流程当前位于变量或参数活跃的区域内时,局部变量或参数才能成为根引用。因此,在示例 7-1 中,只有在baseUri获得其初始值并在构造第二个Uri之前,它才是根引用的。fullUri变量的根引用时间略长一些,因为它在接收到初始值后变为活跃,并在下一行构造HttpClient期间继续保持活跃;只有在调用HttpRequestMessage构造函数后,其生命周期才会结束。

注意

当一个变量的最后一次使用是作为方法或构造函数调用的参数时,当方法调用开始时,它就不再是活跃的。在那一点上,被调用的方法接管—它自己的参数在开始时是活跃的(除了它不使用的参数)。然而,它们通常会在方法返回之前不再是活跃的。这意味着在示例 7-1 中,由 fullUri 引用的对象在 HttpRequestMessage 构造函数返回之前可能会因根引用的消失而无法访问。

由于程序执行时活跃变量集合会变化,根引用集合也会随之演变。为了确保在这一移动目标面前的正确行为,CLR 可以在垃圾回收时必要时暂停所有正在运行托管代码的线程。

活跃变量和静态字段并不是唯一的根引用种类。作为评估表达式结果所创建的临时对象需要在完成评估所需的时间内保持活跃,因此可能存在一些根引用,并不直接对应代码中的任何命名实体。还有其他类型的根。例如,GCHandle 类允许您显式创建新的根引用,在互操作场景中非常有用,以便让一些非托管代码访问特定对象。还有一些情况下根引用是隐式创建的。某些类型的应用程序可以与非.NET 基于对象的系统进行互操作(例如,在 Windows 应用程序中的 COM,或者在 Android 上的 Java),这些系统可以在不显式使用 GCHandle 的情况下建立根引用。如果 CLR 需要生成一个包装器,使您的某个.NET 对象对其他运行时可用,那么该包装器实际上将是一个根引用。调用非托管代码时可能还涉及传递指向堆上内存的指针,这意味着在调用的整个过程中相关堆块需要被视为可达。总体原则是,根引用将存在于必要的地方,以确保仍在使用中的对象保持可达。

在为所有线程建立了当前根引用的完整列表后,GC 确定哪些对象可以从这些引用中访问到。它依次检查每个引用,如果非空,GC 就知道它所引用的对象是可达的。可能会有重复的引用——多个根引用可能指向同一个对象,因此 GC 要追踪它已经看过的对象。对于每个新发现的对象,GC 将该对象中的所有引用类型的实例字段添加到它需要检查的引用列表中,并再次丢弃重复项(包括编译器生成的隐藏字段,例如自动属性中描述的那些,我在第三章中有描述)。对于它发现的任何引用类型数组的每个元素,它都会执行相同的操作。这意味着如果一个对象是可达的,它所引用的所有对象也都是可达的。GC 重复这个过程,直到没有新的引用需要检查为止。GC 没有发现可达的对象就意味着这些对象是不可达的,因为 GC 所做的只是程序做的事情:程序只能使用直接或间接通过其变量、临时本地存储、静态字段和其他根引用可访问的对象。

回到示例 7-1,如果 CLR 在构造HttpClient时决定运行 GC,那会意味着什么?fullUri变量仍然是活动的,所以它引用的Uri是可达的,但是baseUri不再活动。我们将baseUri的副本传递给第二个Uri的构造函数,如果它在字段中存储了引用的副本,那么baseUri不再活动也没关系;只要通过根引用开始就能访问到对象,那么对象就是可达的。但实际上,第二个Uri不会这样做,因此示例分配的第一个Uri将被视为不可达,CLR 将可以回收它所使用的内存。

如何确定可达性的一个重要结果是,GC 不会被循环引用搞糊涂。这就是.NET 使用 GC 而不是引用计数的一个原因(引用计数是另一种流行的自动内存管理方法)。如果你有两个相互引用的对象,引用计数方案会认为两个对象都在使用中,因为每个对象至少被引用了一次。但是这些对象可能是不可达的——如果没有其他引用指向它们,应用程序将无法使用它们。引用计数无法检测到这一点,因此可能导致内存泄漏;但 CLR 的 GC 方案不会受到它们相互引用的影响——GC 不会处理这两个对象中的任何一个,因此它会正确地确定它们不再使用。

意外地挫败了垃圾回收器

尽管垃圾回收器可以发现程序如何达到一个对象,但它无法证明它必然会这样做。拿示例 7-2 中那令人印象深刻的愚蠢代码来说吧。虽然你不会写出这么糟糕的代码,但它却犯了一个常见的错误。这个问题通常以更微妙的方式出现,但我想先用一个更明显的例子来展示它。一旦我展示了它如何阻止 GC 释放我们将不再使用的对象,我会描述一个不太直接但更现实的场景,这种问题经常发生在其中。

示例 7-2. 一个效率极低的代码片段
static void Main()
{
    var numbers = new List<string>();
    long total = 0;
    for (int i = 1; i < 100_000; ++i)
    {
        numbers.Add(i.ToString());
        total += i;
    }
    Console.WriteLine("Total: {total}, average: {total / numbers.Count}");
}

这里将从 1 加到 100,000 的数字相加,然后显示它们的平均值。这里的第一个错误是,我们甚至不需要在循环中进行加法,因为对于这种求和,有一个简单且非常有名的封闭形式解:n*(n+1)/2,在这种情况下n为 100,000。尽管存在这个数学错误,但这段代码做了更愚蠢的事情:它建立了一个包含它添加的每个数字的列表,但它所做的一切只是在最后检索它的Count属性以计算平均值。更糟糕的是,代码在将每个数字放入列表之前将其转换为字符串。它实际上从未使用过这些字符串。(我在这里展示了Main方法的声明,以明确说明numbers后来没有被使用。)

显然,这是一个刻意构造的例子,虽然我希望我能说在真实程序中从未遇到过这种令人困惑的毫无意义的事情。可悲的是,我遇到过至少和这个糟糕的例子一样糟糕的真实例子,尽管它们都更加隐晦——当你在野外遇到这种情况时,通常需要半个小时左右才能确定它确实在做如此惊人地毫无意义的事情。然而,我这里的重点并不是为软件开发标准叹息。这个例子的目的是展示你如何遇到 GC 的一个限制。

假设示例 7-2 中的循环已经运行了一段时间——也许是在第 90,000 次迭代,并且正在尝试向numbers列表添加一个条目。假设List<string>已经使用完了它的备用容量,因此Add方法将需要分配一个新的、更大的内部数组。CLR 此时可能会决定运行 GC,看看能否释放一些空间。会发生什么?

示例 7-2 创建了三种对象:在开始时构造了一个List<string>,在循环中每次调用intToString()方法创建一个新的string,还有更微妙的是,List<string>将分配一个string[]来保存对这些字符串的引用。因为我们不断添加新的项,它将不得不分配越来越大的数组。(这个数组是List<string>的实现细节,所以我们不能直接看到它。)因此问题是:GC 可以丢弃哪些对象来为Add调用中的更大数组腾出空间?

我们的numbers变量保持活动状态直到程序的最后一条语句,并且我们正在看代码中的较早部分,因此它引用的List<string>对象是可达的。它目前使用的string[]数组对象也必须是可达的:它正在分配一个更新、更大的数组,但它将需要复制旧数组的内容到新数组中,因此列表必须仍然有一个对当前数组的引用存储在其一个字段中。由于该数组仍然是可达的,数组引用的每个字符串也将是可达的。到目前为止,我们的程序已经创建了 90,000 个字符串,GC 将通过从我们的numbers变量开始,查看List<string>对象的字段,然后查看列表的一个私有字段引用的数组中的每个元素来找到所有这些字符串。

GC 可能能够收集的唯一分配的项目是List<string>在列表较小时创建的旧string[]数组,它现在不再有引用。当我们添加了 90,000 个项时,列表可能已经调整了自身大小多次。因此,取决于上次 GC 运行的时间,它可能能够找到一些现在未使用的数组。但更有趣的是这里它无法释放的内容。

程序永远不会使用它创建的 90,000 个字符串中的任何一个,因此理想情况下,我们希望垃圾收集器可以释放它们占用的内存 —— 它们将占用几兆字节。我们可以很容易地看出这些字符串没有被使用,因为这是一个如此简短的程序。但是垃圾收集器不知道这一点;它基于可达性做出决策,并且它正确地确定这 90,000 个字符串都是可达的,从numbers变量开始。对于垃圾收集器来说,列表的Count属性可能会在循环结束后查看列表的内容。我们知道它不会这样做,因为它不需要,但这是因为我们知道Count属性的含义。为了让垃圾收集器推断我们的程序永远不会直接或间接使用列表的任何元素,它需要了解List<string>在其AddCount方法内部的工作方式。这意味着需要进行比我描述的机制更为详细的分析,这可能使得垃圾收集器的成本显著增加。此外,即使在需要严格复杂的步骤来检测此示例永远不会使用的可达对象的情况下,更现实的场景中,垃圾收集器也不太可能能够做出明显优于仅依赖可达性的预测。

例如,在缓存中更有可能遇到这个问题。如果你编写一个类来缓存获取或计算昂贵的数据,想象一下如果你的代码只是向缓存中添加项而不移除它们,会发生什么。只要缓存对象本身可达,所有缓存的数据都将是可达的。问题在于,你的缓存将占用越来越多的空间,除非你的计算机有足够的内存来容纳程序可能需要使用的每一个数据片段,否则最终会耗尽内存。

一个天真的开发者可能会抱怨,这应该是垃圾收集器的问题。垃圾收集的整点在于我不需要考虑内存管理,为什么突然间就内存不足了呢?但是,问题在于垃圾收集器无法知道哪些对象是安全可移除的。它并非能预见未来,因此无法准确预测你的程序将来可能需要哪些缓存项——如果代码在服务器上运行,未来的缓存使用可能取决于服务器收到的请求,而这是垃圾收集器无法预测的。因此,虽然我们可以想象到足够智能的内存管理可以分析像示例 7-2 这样简单的东西,但通常情况下,这不是垃圾收集器能解决的问题。因此,如果你将对象添加到集合中并保持这些集合可达,垃圾收集器将把这些集合中的所有东西都视为可达。你需要决定何时删除这些项。

集合不是唯一可以欺骗 GC 的情况。正如我将在 第九章 中展示的那样,存在一种常见的情况,即对事件的不慎使用可能导致内存泄漏。更一般地说,如果你的程序使得某个对象可以被访问到,GC 无法确定你是否会再次使用该对象,因此它必须保守处理。

话虽如此,有一种技术可以在一定程度上通过 GC 的帮助来缓解这个问题。

弱引用

尽管 GC 会跟踪可达对象字段中的普通引用,但也可能存在弱引用。GC 不会跟踪弱引用,因此如果通过弱引用是唯一可达对象的方式,GC 会将其视为不可达对象并将其移除。弱引用提供了一种告诉 CLR 的方式:“不要因为我而保留这个对象,但只要其他地方需要它,我希望能够访问它。” 示例 7-3 展示了使用 WeakReference<T> 的缓存。

示例 7-3. 在缓存中使用弱引用
public class WeakCache<TKey, TValue>
    where TKey : notnull
    where TValue : class
{
    private readonly Dictionary<TKey, WeakReference<TValue>> _cache = new ();

    public void Add(TKey key, TValue value)
    {
        _cache.Add(key, new WeakReference<TValue>(value));
    }

    public bool TryGetValue(
        TKey key, [NotNullWhen(true)] out TValue? cachedItem)
    {
        if (_cache.TryGetValue(key, out WeakReference<TValue>? entry))
        {
            bool isAlive = entry.TryGetTarget(out cachedItem);
            if (!isAlive)
            {
                _cache.Remove(key);
            }
            return isAlive;
        }
        else
        {
            cachedItem = null;
            return false;
        }
    }
}

这个缓存通过 WeakReference<T> 存储所有值。它的 Add 方法将希望作为弱引用的对象作为新 WeakReference<T> 的构造函数参数。TryGetValue 方法尝试检索之前使用 Add 存储的值。首先检查字典是否包含相关条目。如果包含,则该条目的值将是我们之前创建的 WeakReference<T>。我的代码调用该弱引用的 TryGetTarget 方法,如果对象仍然可用,则返回 true,否则返回 false

注意

可用性并不一定意味着可达性。自最近的 GC 以来,对象可能已经变得不可达。或者自对象分配以来可能根本没有进行 GC。TryGet​Tar⁠get 只能告诉你 GC 是否已经检测到它符合回收的条件。

如果对象可用,TryGetTarget将通过out参数提供它,这将是一个强引用。因此,如果此方法返回true,我们无需担心对象随后变得不可达的竞争条件——事实上,我们现在将该引用存储在通过cachedItem参数由调用方提供的变量中,将保持目标活动。如果TryGetTarget返回false,我的代码将从字典中删除相关条目,因为它代表一个不再存在的对象。这很重要,因为虽然弱引用不会保持其目标的活动状态,但WeakReference<T>本身是一个对象,GC 在我从字典中移除它之前无法释放它。示例 7-4 尝试运行此代码,强制进行了几次垃圾回收,以便我们可以看到它的运行情况。(这将每个阶段分成独立的方法,禁用内联,否则.NET 的 JIT 编译器将内联这些方法,这样会创建隐藏的临时变量,可能会使数组保持可达的时间比预期长,从而扭曲此测试的结果。)

示例 7-4. 练习弱缓存
internal class Program
{
    private static WeakCache<string, byte[]> cache = new ();
    private static byte[]? data = new byte[100];

    private static void Main(string[] args)
    {
        AddData();
        CheckStillAvailable();

        GC.Collect();
        CheckStillAvailable();

        SetOnlyRootToNull();
        GC.Collect();
        CheckNoLongerAvailable();
    }

 [MethodImpl(MethodImplOptions.NoInlining)]
    private static void AddData()
    {
        cache.Add("d", data!);
    }

 [MethodImpl(MethodImplOptions.NoInlining)]
    private static void CheckStillAvailable()
    {
        Console.WriteLine("Retrieval: " +
            cache.TryGetValue("d", out byte[]? fromCache));
        Console.WriteLine("Same ref?  " +
            object.ReferenceEquals(data, fromCache));
    }

 [MethodImpl(MethodImplOptions.NoInlining)]
    private static void SetOnlyRootToNull()
    {
        data = null;
    }

 [MethodImpl(MethodImplOptions.NoInlining)]
    private static void CheckNoLongerAvailable()
    {
        byte[]? fromCache;
        Console.WriteLine("Retrieval: " + cache.TryGetValue("d", out fromCache));
        Console.WriteLine("Null?  " + (fromCache == null));
    }
}

首先创建我的缓存类的实例,然后将一个 100 字节数组的引用添加到缓存中。它还将同一个数组的引用存储在名为data的静态字段中,保持其可达性,直到代码调用SetOnlyRootToNull,将其值设置为null。示例尝试在添加后立即从缓存中检索该值,并使用object.ReferenceEquals检查我们获取的值确实是指向我们放入的同一个对象。然后我强制进行垃圾回收,并再次尝试。(这种人为的测试代码是少数情况之一,您需要执行此操作,请参阅“强制垃圾回收”一节了解详情。)由于data字段仍然持有数组的引用,因此数组仍然是可达的,因此我们期望从缓存中仍然可以获取该值。接下来,我将data设置为null,因此我的代码不再保持该数组可达。唯一剩余的引用是一个弱引用,因此当我强制进行另一次 GC 时,我们期望该数组被收集,并且在缓存中的最终查找失败。为了验证这一点,我检查返回值和通过out参数返回的值,预期值为falsenull。当我运行程序时,确实发生了这种情况,如您所见:

Retrieval: True
Same ref?  True
Retrieval: True
Same ref?  True
Retrieval: False
Null?  True

编写用于说明 GC 行为的代码意味着进入危险的领域。操作原理保持不变,但小示例的确切行为随时间变化,通常是由于 JIT 编译期间执行的优化。完全有可能,如果您尝试这些示例,由于运行时的更改,您可能会看到不同的行为。

后面我会描述终结,这会通过引入一个暮光区域使情况变得更加复杂,其中对象被确定为不可达但尚未消失。处于此状态的对象通常没有多大用处,因此默认情况下,弱引用将把等待终结的对象视为已经消失。这称为短弱引用。如果出于某种原因,您需要知道对象是否确实已经消失(而不仅仅是正在逐渐移除),WeakReference<T> 类的构造函数具有多个重载,其中一些可以创建长弱引用,即使在不可达性和最终移除之间的这个区域中,也可以访问对象。

回收内存

到目前为止,我描述了 CLR 如何确定哪些对象不再使用,但还没有描述接下来会发生什么。在确定了垃圾之后,运行时必须进行收集。CLR 对小对象和大对象使用不同的策略。(默认情况下,.NET CLR 将大对象定义为大于 85,000 字节。Mono 将此标准设定为 8,000 字节以下。)大多数分配涉及小对象,因此我将首先介绍这些对象。

CLR 尝试保持堆的空闲空间连续。当应用程序刚启动时,这很容易,因为只有空闲空间,可以通过为每个新对象直接分配内存来保持连续。但是在第一次 GC 后,堆看起来可能不再那么整齐。大多数对象的生命周期很短,通常在任何一个 GC 后分配的大多数对象在下次 GC 运行时都不可达。然而,还是会有一些对象在使用中。应用程序不时会创建长时间存在的对象,GC 运行时可能正在使用一些对象,因此最近分配的堆块可能仍在使用中。这意味着堆的末尾可能看起来像图 7-1,灰色矩形表示可达块,白色矩形表示不再使用的块。

图 7-1. 堆中部分可达对象的部分

一种可能的分配策略是在需要新内存时开始使用这些空块,但这种方法存在几个问题。首先,它往往是浪费的,因为应用程序需要的块可能不会精确地适应可用的空洞。其次,在许多间隙存在且尝试选择能最小化浪费的空块时,找到合适的空块可能会有些昂贵。当然,并非不可能实现——许多堆都是这样工作的——但比起最初的情况,每个新块都可以直接分配到上一个块之后,因为所有的空闲空间都是连续的,这种堆碎片化的代价是相当昂贵的,因此 CLR 通常会尝试将堆恢复到自由空间连续的状态。如图 7-2 所示,它将所有可达对象向堆的起始位置移动,以便所有的空闲空间位于末尾,从而使其重新处于有利的状态,能够在连续的空闲空间块中一个接一个地分配新的堆块。

图 7-2. 堆在压缩后的部分

在这些重新定位的块移动后,运行时必须确保对这些块的引用仍然有效。CLR 偶然将引用实现为指针(尽管没有什么需要这样做——引用只是标识堆上某个特定实例的值)。它已经知道任何特定块的所有引用位置,因为它必须找到它们以发现哪些块是可达的。它在移动块时调整所有这些指针。

除了使堆块分配成本相对廉价外,压缩还提供了另一个性能优势。因为块被分配到连续的空闲空间区域中,快速创建的对象通常会在堆中彼此紧邻。这是很重要的,因为现代 CPU 中的高速缓存倾向于局部性(即当相关数据片段存储在一起时表现最佳)。

分配的低成本和良好局部性的高可能性有时意味着,垃圾收集堆比需要程序显式释放内存的传统堆提供更好的性能。这可能令人惊讶,因为 GC 似乎在非收集堆中做了很多额外的无用工作。然而,其中一些“额外”工作实际上并非如此——必须有东西来跟踪哪些对象正在使用,并且传统堆只是将这些管理开销推到我们的代码中。然而,重新定位现有内存块是有代价的,因此 CLR 使用一些技巧来最小化它需要做的复制量。

对于 CLR 来说,一个对象的年龄越长,一旦最终变得不可达时,压缩堆的成本就会越高。如果在 GC 运行时最近分配的对象是不可达的,那么对于该对象来说,压缩是免费的:它后面没有更多的对象,所以不需要移动任何东西。与你的程序分配的第一个对象相比——如果那个对象变得不可达,压缩意味着需要移动堆上的每个可达对象。更一般地说,一个对象的年龄越长,它后面放置的对象就越多,因此需要移动的数据量就越大才能压缩堆。复制 20 MB 的数据来节省 20 字节并不像是一个很好的权衡。因此,CLR 经常会推迟对堆中较老部分的压缩。

为了确定什么是“老”的,.NET 运行时将堆划分为。⁵ 每次 GC 时,代之间的边界会移动,因为代是根据一个对象经历了多少次 GC 来定义的。在最近的 GC 之后分配的任何对象都在第 0 代中,因为它还没有经历任何收集。当下次 GC 运行时,仍然可达的第 0 代对象将按需移动以压缩堆,并被认为是在第 1 代中。

第 1 代中的对象还不被认为是老对象。GC 通常会在代码正在执行的过程中运行——毕竟,当堆上的空间被使用完时,它才会运行,如果程序处于空闲状态,这种情况就不会发生。因此,有很大的机会,一些最近分配的对象代表正在进行的工作,尽管它们当前是可达的,但它们很快就会变得不可达。第 1 代充当一种持有区,我们等待看看哪些对象是短命的,哪些是长寿的。

随着程序继续执行,GC 会不时运行,将新生的幸存对象提升到第 1 代。第 1 代中的一些对象将变得不可达。然而,GC 不一定会立即压缩堆的这一部分——它可能允许几次第 0 代的收集和压缩,然后才进行一次第 1 代的压缩,但最终还是会发生的。在此阶段幸存下来的对象将被移到第 2 代,这是最老的一代。

CLR 尝试从第 2 代中较不频繁地回收内存。研究显示,在大多数应用程序中,进入第 2 代的对象很可能会保持可访问状态很长时间,因此当其中一个对象最终变得不可达时,它很可能已经非常老了,周围的对象也是如此。这意味着为了回收内存而压缩堆的这一部分代价高昂,有两个原因:不仅可能是因为这个老对象后面跟着大量的其他对象(需要复制大量数据),而且它所占用的内存可能已经很长时间没有使用,意味着它可能不再位于 CPU 的缓存中,进一步减慢了复制的速度。而且,在收集之后,缓存成本会持续存在,因为如果 CPU 不得不在堆的旧区域移动数兆字节的数据,这可能会导致将其他数据从 CPU 的缓存中冲出。缓存的大小可以从低功耗、低成本端的 512 KB 开始,到高端、服务器导向芯片的超过 90 MB,但在中端,2 MB 到 16 MB 的缓存是典型的,并且许多 .NET 应用程序的堆将比这更大。应用程序之前使用的大部分数据将在第 2 代 GC 之前一直存在于缓存中,但是一旦 GC 完成,这些数据就会消失。因此,当 GC 完成并且正常执行恢复时,代码将会在一段时间内以慢动作运行,直到应用程序需要的数据重新加载到缓存中。

第 0 代和第 1 代有时被称为短暂代,因为它们主要包含存在时间很短的对象。(Mono 堆的这一部分通常称为nursery,因为它是为年轻对象而设的。)堆的这些部分的内容通常会在 CPU 的缓存中,因为它们最近已经被访问过,所以对于这些区域来说,压缩并不特别昂贵。此外,由于大多数对象的生命周期很短,GC 能够收集的大部分内存将来自这前两代对象,因此这些对象很可能会以消耗的 CPU 时间为代价提供最大的回报(即内存回收)。因此,在繁忙的程序中,每秒钟可能会看到几次短暂的收集,但在连续的第二代收集之间可能也常见几分钟的间隔。

CLR 对第 2 代对象还有另一个小技巧。它们通常变化不大,因此在 GC 的第一阶段中——即运行时检测可达对象的阶段——有很高的可能性会重复一些早期完成的工作,因为它将完全遵循相同的引用并对堆的显著部分产生相同的结果。因此,CLR 有时会使用操作系统的内存保护服务来检测旧的堆块何时被修改。这使得它能够依赖于早期 GC 操作的总结结果,而无需每次都重新执行所有工作。

GC 如何决定仅从第 0 代回收,还是从第 1 或甚至第 2 代回收?所有三代的回收都是通过消耗一定量的内存来触发的。因此,对于第 0 代的分配,一旦自上次 GC 以来分配了一些特定字节数,将会发生新的 GC。幸存下来的对象将移入第 1 代,CLR 会跟踪自上次第 1 代回收以来添加到第 1 代的字节数;如果该数字超过阈值,也会回收第 1 代。第 2 代的工作方式相同。这些阈值未记录,事实上它们甚至不是常量;CLR 监视您的分配模式,并修改这些阈值,以尝试找到在内存高效利用、最小化在 GC 中的 CPU 时间以及避免 CLR 在集合之间等待时间过长时产生的过度延迟之间的良好平衡。

注意

这解释了为什么 CLR 不一定等到内存实际耗尽才触发 GC,正如前面提到的那样。提早运行 GC 可能更有效。

你可能会想知道前面信息的实际意义有多大。毕竟,底线似乎是 CLR 确保堆块在可访问时保持,一旦它们变得不可访问,它将最终回收它们的内存,并且它采用一种旨在高效执行此操作的策略。这种分代优化方案的细节对开发者有影响吗?它们告诉我们某些编码实践可能比其他实践更高效。

这个过程最显而易见的结果是,您分配的对象越多,GC 就越难工作。但即使不了解实现方式,您也可能猜到这一点。更微妙的是,较大的对象会导致 GC 工作更加艰难——每代的回收都是由应用程序使用的内存量触发的。因此,更大的对象不仅增加了内存压力,它们还由于触发更频繁的 GC 而消耗了更多的 CPU 周期。

或许从理解收集器的代性质中得出的最重要的事实是,对象的生存期对 GC 的工作量有影响。生存时间非常短的对象能够得到有效处理,因为它们使用的内存在第 0 代或第 1 代的收集中将很快被回收,并且需要移动以压缩堆的数据量很小。而生存时间非常长的对象也没问题,因为它们最终会进入第二代。它们不会经常被移动,因为对该堆部分的收集是不频繁的。此外,CLR 可能能够利用操作系统内存管理器的写入检测功能来更有效地管理老对象的可达性发现。然而,虽然生存时间非常短和非常长的对象都能得到有效处理,但是那些存活到第二代但又不久的对象则是一个问题。微软有时将这种情况描述为中年危机

如果你的应用程序经常创建大量进入第二代但最终成为不可达的对象,CLR 将需要比通常更频繁地在第二代执行回收(实际上,第二代仅在全局回收期间进行回收,这也会回收之前由大对象使用的空闲空间)。这些通常比其他回收显著昂贵。压缩需要更多处理较老对象的工作,但同时在破坏第二代堆时也需要更多的清理工作。CLR 在堆的这一部分建立的关于可达性的图像可能需要重建,并且在压缩堆时,GC 将需要禁用用于启用写入检测的检测,这会带来成本。此外,这一部分堆中的大部分内容很可能也不会位于 CPU 的缓存中,因此处理它可能会很慢。

全局垃圾回收(Full GC)消耗的 CPU 时间明显多于短暂代的回收。在 UI 应用中,这可能导致用户遭遇到足以引起不适的延迟,尤其是如果堆的某些部分已被操作系统分页出去。在服务器应用中,全局回收可能导致服务请求处理时间显著波动。这些问题并非世界末日,正如我后面将描述的那样,CLR 提供了一些机制来减轻这些问题。即便如此,在设计将有趣数据缓存到内存中的代码时,最小化对象存活到第二代的数量对性能是有益的。在这一过程中,你需要考虑到垃圾回收行为的缓存老化策略可能会表现出低效,如果你不了解中年对象的危险,很难弄清楚原因。而且,正如我将在本章后面展示的那样,中年危机问题是你可能希望尽量避免使用 C#析构函数的一个原因。

顺便说一句,我没有提到一些堆操作的详细信息。 例如,我没有讨论 GC 通常如何将地址空间的部分专用于以固定大小的块分配内存,也没有详细讨论它如何提交和释放内存。 尽管这些机制很有趣,但与您如何设计代码有关的假设性 GC 对典型对象生命周期的了解要比意识更重要。 它们也往往会发生变化- .NET 6.0 在这个领域做出了重大修改以提高性能。

在讨论从不可达对象中收集内存的主题时,还有一件事要说。 正如前面提到的,大对象的工作方式不同。 有一个名为大对象堆(LOH)的单独堆,.NET 运行时会将大于 85,000 字节的对象放入其中;⁶ Mono 运行时使用 8,000 字节的阈值,因为它经常用于内存受限的环境。 这仅仅是对象本身,而不是对象在构建过程中分配的所有内存总和。 在示例 7-5 中的GreedyObject类的一个实例将非常小 - 它只需要足够的空间来存储单个引用,再加上堆块的开销。 在 32 位进程中,引用将占用 4 字节,开销将占用 8 字节,在 64 位进程中,这个空间将是两倍。 然而,它所引用的数组长度为 400,000 字节,因此会放在 LOH 中,而GreedyObject本身会放在普通堆中。

Example 7-5. 一个带有大数组的小对象
public class GreedyObject
{
    public int[] MyData = new int[100_000];
}

在技术上,可以创建一个需要 LOH 的实例的类,但在生成的代码或高度构造的示例之外,这种情况不太可能发生。 实际上,大多数 LOH 堆块将包含数组和可能是字符串。

LOH 与普通堆的最大区别在于,GC 通常不会压缩 LOH,因为复制大对象很昂贵。(应用程序可以请求在下一个完整的 GC 时压缩 LOH。 但在当前 CLR 实现中,没有明确请求此操作的应用程序将永远不会使其 LOH 被压缩。) 它更像传统的 C 堆:CLR 维护一个空闲块列表,并根据请求的大小决定使用哪个块。 然而,空闲块列表是由与堆的其余部分使用相同的不可达性机制填充的。

垃圾收集器模式

尽管 .NET 运行时将在运行时调整触发每一代收集的阈值等方面调整 GC 的某些行为,它还提供了可配置的选择,以适应不同类型的应用程序。这些可分为两大类别——工作站和服务器,在每个类别中,您可以选择使用后台或非并发收集。后台收集默认开启,但默认的顶层模式取决于项目类型:对于控制台应用程序和使用 WPF 等 GUI 框架的应用程序,GC 运行在工作站模式下,但 ASP.NET Core Web 应用程序将其更改为服务器模式。您可以通过在您的 .csproj 文件中定义一个属性来显式控制 GC 模式,如 示例 7-6 所示。这可以放在根 Project 元素的任何位置。

示例 7-6. 在 .NET Core 应用程序项目文件中启用服务器 GC
<PropertyGroup>
  <ServerGarbageCollection>true</ServerGarbageCollection>
</PropertyGroup>
注意

ServerGarbageCollection 属性使构建系统在生成您的应用程序的 YourApplication.runtimeconfig.json 文件时添加一个设置。这个文件包含一个 configProperties 部分,其中可以包含一个或多个 CLR 主机配置开关。在项目文件中启用服务器 GC 将在此配置文件中将 Sys⁠tem.​GC.⁠Ser⁠ver 开关设置为 true。所有 GC 设置也通过配置开关控制,如 JIT 编译器模式等 CLR 行为。

工作站模式是为客户端代码通常必须处理的工作负载设计的,在这种情况下,进程通常在任何时间都在处理单个任务或少量任务。工作站模式提供两种变体:非并发和后台。

在后台模式(默认模式)下,GC 尽量减少在 GC 期间挂起线程的时间。在某些 GC 阶段,CLR 需要暂停执行以确保一致性。对于短暂代的收集,线程将在操作的大部分时间内被挂起。这通常没问题,因为这些收集通常运行非常快速,它们花费的时间与未引起任何磁盘活动的页面错误相似。(这些非阻塞页面错误发生频率相当高,并且足够快,以至于许多开发人员甚至不知道它们发生过。)完整收集是问题所在,而后台模式处理这些情况有所不同。并非所有在收集中完成的工作都需要使一切停顿,后台模式利用这一点,使得完整(第二代)收集可以在后台线程上进行,而不强制其他线程阻塞,直到该收集完成。这对于具有 UI 的应用程序特别有用,因为它减少了由于 GC 而导致应用程序变得不响应的可能性。

非并发模式旨在优化单处理器单核心上的吞吐量。相比非并发 GC,后台 GC 在任何特定工作负载下使用的内存和 CPU 周期略多,但换取更低的延迟,可能更高效。对于某些工作负载,如果在项目文件中将ConcurrentGarbageCollection属性设置为false,则可能会发现代码运行更快。对于大多数客户端代码,最大的关注点是避免用户可见的延迟。用户对非响应更为敏感,而对次优平均 CPU 利用率的感知性较低,因此在交互式应用程序中,为了改善感知性能而多消耗一些内存和 CPU 周期通常是一个不错的权衡。

服务器模式与工作站模式显著不同。仅当您拥有多个硬件线程时才可用,例如,多核 CPU 或多个物理 CPU。(如果您已启用服务器 GC 但您的代码最终在单核机器上运行,它将退回到使用工作站 GC。)它的可用性与您运行的操作系统无关,例如,如果您拥有适当的硬件,不管您运行哪个 Windows 版本(包括非服务器和服务器版本),服务器模式都可用,而工作站模式始终可用。在服务器模式下,每个处理器核心都有其自己的堆部分,因此当一个线程独立于进程的其余部分工作时,它可以以最小的争用分配堆块。在服务器模式下,CLR 创建几个专用于 GC 的线程,每个逻辑 CPU 都有一个。这些线程比普通线程具有更高的优先级,因此当 GC 发生时,所有可用的 CPU 核心都会处理自己的堆,这可以在具有大堆的情况下提供比工作站模式更好的吞吐量。

由一个线程创建的对象仍然可以被其他线程访问——从逻辑上讲,堆仍然是一个统一的服务。服务器模式只是一种针对大部分情况下每个线程独立工作的工作负载优化的实现策略。请注意,如果所有作业具有类似的堆分配模式,它的效果最佳。

在服务器模式下可能会出现一些问题。当机器上只有一个进程使用此模式时,效果最佳,因为它设置为在收集期间尝试同时使用所有 CPU 核心。它还倾向于使用比工作站模式更多的内存。如果单个服务器托管多个 .NET 进程并且所有进程都这样做,资源争用可能会降低效率。服务器 GC 的另一个问题是它更偏重于吞吐量而非响应时间。特别是,收集发生得较少,因为这倾向于增加多 CPU 收集能够提供的吞吐量优势,但也意味着每个单独的收集时间更长。

与工作站 GC 一样,服务器 GC 默认使用后台收集。在某些情况下,禁用它可能会提高吞吐量,但要注意可能引起的问题。例如,在非并发服务器模式下进行完整收集可能会导致网站响应严重延迟,特别是如果堆很大的情况下。您可以通过几种方式来缓解这个问题。您可以在收集发生之前请求通知(使用System.GC类的RegisterForFullGCNotificationWaitForFullGCApproachWaitForFullGCComplete方法),如果您有服务器群,则运行完整 GC 的服务器可能会要求负载均衡器避免在 GC 完成之前传递请求给它。更简单的选择是保留后台收集功能。由于后台收集允许应用程序线程继续运行,甚至可以在后台进行 0 代和 1 代收集,因此它显著提高了应用程序在收集期间的响应时间,同时仍然提供服务器模式的吞吐量优势。

暂时挂起垃圾回收

可以要求.NET 在特定代码段运行时禁止 GC。如果您正在执行时间敏感的工作,这很有用。Windows、macOS 和 Linux 不是实时操作系统,因此从来没有任何保证,但是在关键时刻暂时排除 GC 可能仍然有助于减少事情在最糟糕的时刻变慢的机会。请注意,此机制通过提前执行可能在相关代码段中本来会发生的任何 GC 工作,因此这可能会导致 GC 相关的延迟比预期更早地发生。它只保证一旦您指定的代码区域开始运行,如果您满足某些要求,将不会再有进一步的 GC 发生——实际上,在时间关键工作开始之前,它会将必要的延迟排除在外。

GC类提供了TryStartNoGCRegion方法,您可以调用该方法指示您要开始一些需要不受 GC 中断影响的工作。您必须传入一个值,指示在此工作期间您将需要多少内存,它将尝试确保在继续之前至少有这么多内存可用(如果需要,执行 GC 以释放该空间)。如果该方法指示成功,则只要您不使用比请求的内存更多的内存,您的代码将不会被 GC 中断。在完成时间关键工作后,您应该调用EndNoGCRegion,使 GC 可以恢复其正常操作。如果在调用EndNoGCRegion之前,您的代码使用的内存超过了请求的量,CLR 可能会执行 GC,但只有在绝对不能避免直到调用EndNoGCRegion之前时才会执行。

虽然TryStartNoGCRegion的单参数形式会在必要时执行完整的 GC 以满足您的请求,但某些重载采用bool,使您能够告诉它,如果需要完整的阻塞 GC 来释放必要的空间,您更愿意中止。还有一些重载,您可以在其中分别指定普通堆和大对象堆的内存需求。

意外地破坏压缩

堆压缩是 CLR 的 GC 的重要特性,因为它对性能有显著积极影响。某些操作可能会阻止压缩,这是您希望尽量减少的事情,因为碎片化可能会增加内存使用并显著降低性能。

要能够压缩堆,CLR 需要能够移动堆块。通常情况下,它可以做到这一点,因为它知道应用程序引用堆块的所有位置,并且在重新定位块时可以调整所有引用。但是,如果您调用直接使用您提供的内存的操作系统 API 呢?例如,如果您从文件或网络套接字读取数据,那么这如何与 GC 交互?

如果使用读取或写入数据的系统调用,使用诸如硬盘或网络接口这样的设备,这些通常直接使用您应用程序的内存。如果您从磁盘读取数据,则操作系统可能会指示磁盘控制器将字节直接放入您应用程序传递给 API 的内存中。操作系统将执行必要的计算,以将虚拟地址转换为物理地址。(使用虚拟内存时,您的应用程序在指针中放置的值只间接相关于计算机 RAM 中的实际地址。)操作系统将在 I/O 请求期间锁定页面,以确保物理地址保持有效。然后,它将向磁盘系统提供该地址。这使得磁盘控制器可以将数据直接从磁盘复制到内存中,无需 CPU 进一步参与。这非常高效,但在遇到紧凑的堆时会遇到问题。如果内存块是堆上的byte[]数组怎么办?假设我们请求读取数据和磁盘能够提供数据之间发生了 GC。(机械硬盘的旋转盘片可能需要 10 毫秒或更长时间才能开始提供数据,从 CPU 的角度来看这是一个时代。)如果 GC 决定重新定位我们的byte[]数组以压缩堆,则操作系统提供给磁盘控制器的物理内存地址将过时,因此当控制器开始将数据放入内存时,它将写入错误的位置。

CLR 处理这个问题有三种方式。一种是让 GC 等待——在 I/O 操作进行期间,堆重定位可以暂停。但这是行不通的;一个忙碌的服务器可以连续运行数天,而没有进入没有 I/O 操作正在进行的状态。事实上,服务器甚至不需要忙碌。它可能会分配几个 byte[] 数组来容纳接下来的几个入站网络请求,并通常会尝试避免进入没有至少一个这样的缓冲区可用的状态。操作系统将拥有所有这些的指针,并且很可能已经为网络卡提供了相应的物理地址,以便它可以在数据开始到达时立即开始工作。因此,即使是空闲的服务器也有某些不能被重定位的缓冲区。

CLR 另一种选择是为这类操作提供一个单独的非移动堆。也许我们可以为 I/O 操作分配一个固定的内存块,然后在 I/O 完成后将结果复制到 byte[] 数组中的 GC 堆。但这也不是一个明智的解决方案。复制数据是昂贵的——你复制的入站或出站数据越多,服务器运行速度就越慢,因此你确实希望网络和磁盘硬件直接将数据复制到其自然位置或从其自然位置复制。如果这个假设的固定堆不仅仅是 CLR 的一个实现细节——如果它可以供应用程序代码直接使用以最小化复制,那可能会打开 GC 应该消除的所有内存管理错误的大门。

因此,CLR 使用第三种方法:有选择地防止堆块重定位。GC 在 I/O 操作进行期间可以自由运行,但某些堆块可以被固定。固定一个块会设置一个标志,告诉 GC 当前不能移动该块。因此,如果 GC 遇到这样的块,它将简单地将其留在原地,但会尝试重新定位其周围的所有内容。

通常有五种方式 C# 代码导致堆块被固定。你可以使用 fixed 关键字显式地这样做。这允许你获取一个指向存储位置(如字段或数组元素)的原始指针,编译器将生成确保固定指针在作用域内时,它引用的堆块将被固定的代码。固定块的更常见方式是通过互操作(即调用非托管代码,如操作系统 API)。如果你调用一个需要指向某物的指针的 API,CLR 将检测到指向堆块的情况,并自动固定该块。默认情况下,CLR 在方法返回时会自动取消固定。如果你调用一个异步 API,在返回后将继续使用内存,你可以使用前面提到的 GCHandle 类来固定一个堆块,直到你明确取消固定;这是第三种固定技术。

固定堆块的第四种和最常见的方法也是最不直接的:许多运行时库 API 会代表你调用非托管代码,并且会固定作为结果传递的数组。例如,运行时库定义了一个代表字节流的Stream类。这个抽象类有几个实现。一些流完全在内存中工作,但一些包装了 I/O 机制,提供对文件或通过网络套接字发送或接收的数据的访问。抽象的Stream基类定义了通过byte[]数组读取和写入数据的方法,而基于 I/O 的流实现通常会在必要时固定包含这些数组的堆块。

第五种方法是使用GC类的AllocateArray<T>方法。与其写new byte[4096],你可以写GC.AllocateArray<byte>(4096, pinned: true)。通过将第二个参数设置为true,你告诉 CLR 你希望这个数组永久固定。CLR 为此目的维护了一个额外的堆,称为固定对象堆(POH)。与 LOH 一样,POH 中的数组不会被移动,避免了固定可能造成的开销。

注意

POH 在.NET Framework 或 Mono 上不可用。它是在.NET 5.0 中引入的,因此在.NET Core 3.1 上也不可用(将完全支持直到 2022 年 12 月)。因此,AllocateArray<T>在这些较旧的.NET 版本上不可用。

如果你正在编写一个频繁进行固定操作的应用程序(例如大量的网络 I/O),你可能需要仔细考虑如何分配这些被固定的数组。固定对于最近分配的对象造成的损害最大,因为这些对象存在于堆的紧凑活动最频繁的区域。固定最近分配的块往往会导致堆的短暂部分碎片化。通常几乎立即恢复的内存现在必须等待块解固,因此当收集器能够访问这些块时,已经分配了更多其他块,这意味着需要更多工作来恢复内存。

如果固定导致你的应用程序出现问题,将会有一些常见的症状。在 GC 中花费的 CPU 时间百分比将相对较高——超过 10%被认为是不好的。但这并不一定说明固定是罪魁祸首——可能是中年对象导致了太多的全收集。因此,你可以监控堆上固定块的数量⁸,看看这是否是特定的罪魁祸首。如果看起来过度固定正在给你带来痛苦,那么如果你能使用.NET 5.0 或更高版本,你可以使用GC.AllocateArray<T>在 POH 上分配相关的块。

如果你需要支持没有 POH 的 .NET 版本,仍然有两种方法可以避免固定的开销。其中一种方法是设计你的应用程序,以便只固定在 LOH 上的块。记住,默认情况下 LOH 不会被压缩,因此固定不会产生任何成本 —— GC 无论如何都不会移动块。这样做的挑战在于它强制你只能使用至少 85,000 字节长的数组进行所有 I/O。这不一定是个问题,因为大多数 I/O API 可以告诉它们只使用数组的一部分。因此,如果你实际上想要处理 4,096 字节块,你可以创建一个足够大的数组来容纳至少 21 个这样的块。你需要编写一些代码来跟踪数组中使用的槽位,但如果它修复了性能问题,那可能是值得努力的。

警告

如果你选择通过尝试使用 LOH 来减少固定,你需要记住它是一个实现细节。未来的 .NET 版本有可能完全删除 LOH。因此,你需要针对每个新版本的 .NET 重新审视你设计的这一方面。

讨论的 Span<T>Memory<T> 类型在 第十八章 中可以使数组处理变得更加容易。它们不仅使得处理不存储在 GC 堆上的内存变得比以前容易得多,而且可以完全避免固定。事实上,处理固定的最佳策略通常是仅仅使用 MemoryPool<T>。在没有 POH 的运行时,它会采取措施为你减少固定的开销,而在 .NET 5.0 或更高版本中,默认情况下将内存分配到 POH 中。

减少固定影响的另一种方法是确保大部分固定只发生在第 2 代对象上。如果你为应用程序分配了一组缓冲区并在应用程序的整个生命周期内重用它们,这将意味着你正在固定 GC 几乎不太可能移动的块,使得临时代随时可以进行压缩。越早分配缓冲区越好,因为对象越老,GC 移动的可能性就越小,所以如果可能的话,在应用程序启动期间使用这种方法会更好。

强制垃圾收集

System.GC 类提供了一个 Collect 方法,允许你强制进行 GC。你可以传递一个表示你想收集的代数的数字,不带参数的重载执行完全收集。你很少会有充分的理由去调用 GC.Collect。我在这里提到它是因为它在网络上经常出现,这可能会让它看起来比实际更有用。

强制触发垃圾回收(GC)可能会导致问题。GC 监控自身的性能,并根据应用程序的分配模式调整其行为。但要做到这一点,它需要允许足够的时间进行收集,以便准确评估当前设置的效果。如果你过于频繁地强制进行收集,它将无法进行自我调整,结果将是双重的:GC 将运行比必要更频繁,并且当运行时,其行为将是次优的。这两个问题都可能增加在 GC 中消耗的 CPU 时间。

那么什么时候会强制进行收集?如果你知道你的应用程序刚刚完成了一些工作,并且即将进入空闲状态,那么考虑强制进行收集可能是值得的。GC 通常是由活动触发的,因此如果你知道你的应用程序即将进入休眠状态——也许它是一个刚刚完成了批处理作业并且在接下来的几个小时内不会再做任何工作的服务——你知道它不会分配新对象,因此不会自动触发 GC。因此,在应用程序进入休眠状态之前强制进行 GC 可以在应用程序进入休眠状态之前为操作系统释放内存提供机会。尽管如此,如果这是你的情况,也许值得考虑那些能够使你的进程完全退出的机制——当它们不活动时,只需要偶尔执行的作业或服务可以在完全不活动时完全卸载。但如果由于某些原因这种技术不适用——也许你的进程具有很高的启动成本或需要保持运行以接收传入的网络请求——那么强制进行完全的收集可能是下一个最佳选项。

值得注意的是,有一种情况下 GC 可能会在没有应用程序需要做任何事情的情况下被触发。当系统内存不足时,Windows 向所有运行中的进程广播消息。CLR 会处理此消息,并在发生时强制进行 GC。因此,即使你的应用程序不主动尝试释放内存,如果系统中其他部分需要内存,内存最终可能会被回收。

析构函数和终结

CLR 为了我们的利益而努力工作,以找出何时不再使用我们的对象。它可以通知你这一点——而不是简单地删除不可达对象,CLR 可以首先告知一个对象即将被删除。CLR 称之为终结,但在 C#中通过特殊语法来表达:要利用终结,你必须编写一个析构函数。

警告

如果你的背景是 C++,不要被名称或类似的语法所误导。正如你将看到的,C#中的析构函数在某些重要方面与 C++中的析构函数是不同的。

示例 7-7 展示了一个析构函数。这段代码编译成了一个名为 Finalize 的方法的覆盖,正如第六章所提到的,这是由 object 基类定义的一个特殊方法。Finalizer 必须总是调用它们所覆盖的 Finalize 的基类实现。C# 为我们生成了这个调用,以防止我们违反这个规则,这也是为什么我们不能直接编写 Finalize 方法。你不能编写调用 finalizer 的代码——它们由 CLR 调用,因此我们不指定析构函数的可访问性级别。

示例 7-7. 带析构函数的类
public class LetMeKnowMineEnd
{
    ~LetMeKnowMineEnd()
    {
        Console.WriteLine("Goodbye, cruel world");
    }
}

CLR 不保证按任何特定的时间表运行 finalizer。首先,它需要检测到对象已变为不可达,这要等到 GC 运行才会发生。如果你的程序空闲,可能会很长一段时间不会发生;GC 通常只会在程序在执行某些操作时,或者系统范围的内存压力导致 GC 开始运行时才会运行。完全可能会在对象变为不可达与 CLR 注意到它已不可达之间经过几分钟、几小时,甚至几天的时间。

即使 CLR 确实检测到不可达性,它仍不保证会立即调用 finalizer。Finalizer 在专用线程上运行。因为当前版本的 CLR 只有一个 finalization 线程(无论你选择哪种 GC 模式),一个慢速的 finalizer 将会导致其他 finalizer 等待。

在大多数情况下,CLR 甚至不保证会运行所有的 finalizer。当一个进程退出时,如果 finalization 线程还没来得及运行所有尚存的 finalizer,它将会立即退出,而不会等待它们全部完成。

总之,如果你的程序既空闲又繁忙,finalizer 可能会被无限期地延迟,并且不能保证会运行。更糟糕的是——在 finalizer 中实际上无法做太多有用的事情。

你可能会认为 finalizer 是确保某些工作得以完全完成的好地方。例如,如果你的对象将数据写入文件但缓冲了数据以便能够写入少量大块而不是小而散的写入(因为大块写入通常更有效率),你可能会认为 finalization 是确保缓冲区中的数据已安全刷新到磁盘的明显场所。但请再次考虑。

在终结期间,一个对象不能信任其引用的其他对象。如果你的对象的析构函数运行了,你的对象必须已经变得不可达。这意味着你的对象引用的任何其他对象也很可能已经变得不可达。CLR 可能会同时发现相关对象组的不可达性——如果你的对象创建了三四个对象来帮助它完成工作,那么这些对象都将在同一时间变得不可达。CLR 不保证按任何顺序运行终结器。这意味着可能在你的析构函数运行时,你使用的所有对象都已经被终结。因此,如果它们执行任何最后的清理工作,现在已经为时过晚。例如,派生自Stream并提供对文件访问的FileStream类,在其析构函数中关闭其文件句柄。因此,如果你希望将数据刷新到FileStream中,现在已经为时过晚。

注意

说实话,事情比我之前描述的要稍微好一些。尽管 CLR 不能保证运行大多数终结器,但实际上它通常会运行它们。缺乏保证仅在相对极端的情况下才有影响。即便如此,这并不能减轻一个事实,即通常不能依赖于析构函数中的其他对象。

由于析构函数似乎用处极小——也就是说,你不知道它们何时会运行,也不能在析构函数中使用其他对象——那么它们有什么用呢?

终结存在的主要原因是使得可以编写.NET 类型,这些类型是传统上由句柄表示的实体的包装器,例如文件和套接字。这些类型在 CLR 之外创建和管理——文件和套接字需要操作系统分配资源;库也可能提供基于句柄的 API,并且它们通常会在自己的私有堆上分配内存来存储有关句柄表示的信息。CLR 看不到这些活动——它只看到一个包含整数字段的.NET 对象,并不知道这个整数是 CLR 之外某些资源的句柄。因此,CLR 不知道当对象不再使用时关闭句柄的重要性。这就是终结器的作用:它们是放置代码的地方,告诉 CLR 之外的某些东西,由句柄表示的实体不再使用。在这种情况下,不能使用其他对象并不是问题。

注意

如果你正在编写包装句柄的代码,通常应该使用从SafeHandle派生的内置类之一,或者在绝对必要的情况下,派生自己的类。这个基类通过一些面向句柄的辅助函数扩展了基本的终结机制。此外,它从互操作层获得特殊处理,以避免资源过早释放。

尽管前面已经讨论了其不可预测性和不可靠性,但还有一些其他用途需要最终化,这意味着它对你的帮助是有限的。有些类包含一个仅检查对象是否处于未完成工作状态的终结器。例如,如果你编写了一个在将数据缓冲到文件之前进行缓冲的类(如前所述),你需要定义一些方法,调用者在完成对象使用时应该使用这些方法(例如FlushClose),然后你可以编写一个终结器来检查对象是否在被抛弃之前被放入了安全状态,如果没有,则引发错误。这将提供一种方式来发现程序是否忘记正确清理事物。

如果你编写了一个终结器,当你的对象处于不再需要最终化的状态时,你应该禁用它,因为最终化有其代价。如果你提供了一个CloseFlush方法,一旦这些方法被调用,最终化就不再需要了,所以你应该调用System.GC类的SuppressFinalize方法,让 GC 知道你的对象不再需要最终化。如果你的对象状态随后发生变化,你可以调用ReRegisterForFinalize方法来重新启用它。

最终化的最大成本是保证你的对象至少会存活到第一代,甚至可能更久。请记住,所有从第 0 代存活下来的对象都会进入第 1 代。如果你的对象有一个终结器,并且你没有通过调用SuppressFinalize来禁用它,CLR 不能在运行其终结器之前摆脱你的对象。由于终结器在单独的线程上异步运行,即使对象已被发现为不可达,它也必须保持活动状态。因此,尽管它是不可达的,但对象还不可收集。因此,它会继续存在到第 1 代。通常情况下,它将很快被最终化,这意味着对象随后会变成空间的浪费,直到进行第 1 代收集为止。这些收集比第 0 代收集频率低。如果你的对象在变得不可达之前已经进入第 1 代,那么终结器会增加在对象即将不再使用之前进入第 2 代的机会。因此,一个已最终化的对象对内存的使用效率不高,这是要避免最终化的原因,也是在确实需要最终化的对象中尽可能禁用它的原因。

警告

即使 SuppressFinalize 可以避免大部分终结的昂贵开销,但是使用这种技术的对象仍然比完全没有终结器的对象有更高的开销。CLR 在构造可终结对象时会做一些额外的工作,以跟踪那些尚未终结的对象(调用 SuppressFinalize 只是将对象从这个跟踪列表中移除)。因此,尽管抑制终结比让它发生要好得多,但如果一开始就不要求它的话,会更好。

终结的一个稍微奇怪的后果是,GC 发现的一个不可达的对象可以使自身重新变得可达。可以编写一个析构函数,将 this 引用存储在根引用中,或者存储在通过根引用可达的集合中。没有任何限制阻止你这样做,对象将继续工作(尽管如果对象再次变得不可达,则其终结器不会第二次运行),但这是一件奇怪的事情。这被称为复活,但仅仅因为你能做到并不意味着你应该这样做。最好避免这种情况。

希望到现在为止,我已经说服你析构函数并不提供一种通用的机制来清理对象。它们主要只对处理那些在 CLR 控制范围之外的句柄有用,并且最好避免依赖它们。如果你需要及时、可靠地清理资源,还有更好的机制。

IDisposable

运行时库定义了一个名为 IDisposable 的接口。CLR 不会特别对待这个接口,但是 C# 对其有一些内置的支持。IDisposable 是一个简单的抽象;如示例 7-8 所示,它仅定义了一个成员,即 Dispose 方法。

示例 7-8. IDisposable 接口
public interface IDisposable
{
    void Dispose();
}

IDisposable 背后的理念很简单。如果你的代码创建了一个实现了这个接口的对象,在你使用完该对象之后,应该调用 Dispose 方法(有时候会有例外,参见“可选的释放”)。这样可以让对象有机会释放它可能已经分配的资源。如果被处理的对象使用的是由句柄表示的资源,它通常会立即关闭这些句柄,而不是等待终结发生(同时应该抑制终结)。如果对象正在以有状态的方式使用某个远程机器上的服务——例如保持打开到服务器的连接以便能够发出请求——它会立即通知远程系统它不再需要这些服务,以任何必要的方式(例如关闭连接)。

有一个持续存在的谬误,即调用 Dispose 会导致 GC 执行某些操作。您可能在网上看到 Dispose 会终结对象,甚至导致对象被垃圾回收。这纯属无稽之谈。CLR 并不会对 IDisposableDispose 进行特殊处理,与其他接口或方法无异。

IDisposable 很重要,因为一个对象可能占用的内存很少,但却绑定了一些昂贵的资源。例如,考虑一个代表与数据库连接的对象。这样的对象可能不需要很多字段——甚至可能只有一个包含表示连接的句柄的字段。从 CLR 的角度来看,这是一个相当便宜的对象,我们甚至可以分配成百上千个而不触发 GC。但在数据库服务器中情况可能不同——它可能需要为每个传入的连接分配大量内存。连接甚至可能受到许可条款的严格限制。(这说明了“资源”是一个相当广泛的概念——它几乎意味着任何可能耗尽的东西。)

依赖 GC 注意到数据库连接对象不再使用很可能是一个糟糕的策略。CLR 将知道我们已经分配了,比如说,50 个东西,但如果总共只消耗了几百字节,它将看不到运行 GC 的理由。然而我们的应用程序可能即将停滞——如果我们只有 50 个数据库连接许可证,下一个尝试创建连接将失败。即使没有许可限制,我们仍可能通过打开比需要更多的连接而对数据库资源使用效率极低。

我们必须尽快关闭连接对象,而不是等待 GC 告诉我们哪些对象不再使用。这就是 IDisposable 的作用所在。当然,它不仅仅适用于数据库连接。对于任何代表生活在 CLR 之外的东西的对象,如文件或网络连接,它至关重要。即使对于不受特别限制的资源,IDisposable 也提供了一种告知对象我们已经完成使用它们的方法,以便它们可以干净地关闭,解决了之前描述的对于执行内部缓冲的对象的问题。

注意

如果资源创建成本高昂,可能希望重复使用它。数据库连接经常是这种情况,因此通常的做法是维护一个连接池。在完成使用连接后,不关闭连接,而是将其返回到池中,使其可以重新使用。(.NET 的许多数据访问提供程序可以为您执行此操作。)在这里仍然很有用的是 IDisposable 模型。当您向资源池请求资源时,通常会提供一个围绕真实资源的包装器,当您处置该包装器时,它会将资源返回到池中,而不是释放它。因此,调用 Dispose 实际上只是表示:“我不再需要这个对象了”,由 IDisposable 实现决定接下来如何处理它所代表的资源。

IDisposable 的实现必须能够容忍对 Dispose 的多次调用。尽管这意味着消费者可以多次调用 Dispose 而不会有害,但是在对象被处理后不应再试图使用它。事实上,运行库为此定义了一个特殊的异常,如果以这种方式误用对象,它们可以抛出:ObjectDisposedException。(我将在 第 8 章 中讨论异常。)

当然,您可以直接调用 Dispose,但是 C# 还支持三种方式使用 IDisposableforeach 循环,using 语句和 using 声明。using 语句是一种确保一旦完成对实现 IDisposable 的对象的使用就可靠地释放它的方式。示例 7-9 展示了如何使用它。

示例 7-9. 一个 using 语句
using (StreamReader reader = File.OpenText(@"C:\temp\File.txt"))
{
    Console.WriteLine(reader.ReadToEnd());
}

这相当于 示例 7-10 中的代码。tryfinally 关键字是 C# 异常处理系统的一部分,我将在 第 8 章 中详细讨论它们。在这种情况下,它们被用于确保在 try 块内的代码出现问题时,finally 块内的 Dispose 调用仍能执行。这也确保了如果在块的中间执行 return 语句,Dispose 也会被调用。(即使使用 goto 语句跳出块也是如此。)

示例 7-10. using 语句的扩展方式
{
    StreamReader reader = File.OpenText(@"C:\temp\File.txt");
    try
    {
        Console.WriteLine(reader.ReadToEnd());
    }
    finally
    {
        if (reader != null)
        {
            ((IDisposable) reader).Dispose();
        }
    }
}

如果 using 语句中声明的变量类型是值类型,C# 将不会生成检查 null 的代码,而直接调用 Dispose

C# 支持一个更简单的替代方案,即使用声明,如 示例 7-11 所示。区别在于我们不需要提供一个块。使用声明在变量超出范围时释放其变量。它仍然生成 tryfinally 块,因此在使用语句的块恰好完成于其他块的末尾的情况下(例如,它在方法的末尾完成),可以改为使用声明而不改变行为。这减少了嵌套块的数量,使您的代码更易读。(另一方面,对于普通的使用块,可能更容易看到对象何时不再使用。因此,每种样式都有其利弊。)

示例 7-11. 使用声明
using StreamReader reader = File.OpenText(@"C:\temp\File.txt");
Console.WriteLine(reader.ReadToEnd());

如果您需要在同一作用域内使用多个可释放资源,并且希望使用使用语句而不是声明(例如,因为您希望尽快释放资源而不是等待相关变量超出范围),您可以嵌套它们,但如果您在一个单独的块前堆叠多个使用语句可能更易于阅读。示例 7-12 使用此方法将一个文件的内容复制到另一个文件中。

示例 7-12. 堆叠使用语句
using (Stream source = File.OpenRead(@"C:\temp\File.txt"))
using (Stream copy = File.Create(@"C:\temp\Copy.txt"))
{
    source.CopyTo(copy);
}

堆叠使用语句不是一种特殊语法;这只是一个事实的结果,即使用语句总是后跟一个单独的嵌入语句,在调用 Dispose 之前将执行该语句。通常,该语句是一个块,但在 示例 7-12 中,第一个使用语句的嵌入语句是第二个使用语句。如果您使用使用声明而不是,堆叠是不必要的,因为这些没有相关的嵌入语句。

如果枚举器实现了 IDisposableforeach 循环将生成使用 IDisposable 的代码。示例 7-13 展示了使用这种枚举器的 foreach 循环。

示例 7-13. foreach 循环
foreach (string file in Directory.EnumerateFiles(@"C:\temp"))
{
    Console.WriteLine(file);
}

Directory 类的 EnumerateFiles 方法返回一个 IEnumerable<string>。正如您在 第五章 中看到的,它有一个 GetEnumerator 方法返回一个 IEnumer⁠ator​<string>,这是继承自 IDisposable 的接口。因此,C# 编译器将生成与 示例 7-14 等效的代码。

示例 7-14. foreach 循环如何展开
{
    IEnumerator<string> e =
        Directory.EnumerateFiles(@"C:\temp").GetEnumerator();
    try
    {
        while (e.MoveNext())
        {
            string file = e.Current;
            Console.WriteLine(file);
        }
    }
    finally
    {
        if (e != null)
        {
            ((IDisposable) e).Dispose();
        }
    }
}

编译器可以生成几种变体,取决于集合的枚举器类型。如果它是实现了 IDisposable 的值类型,编译器在 finally 块中不会生成对 null 的检查(就像在 using 语句中一样)。如果枚举器的静态类型不实现 IDisposable,则结果取决于类型是否对继承开放。如果它是密封的,或者如果它是值类型,编译器将不会生成尝试调用 Dispose 的代码。如果它没有被密封,编译器将在 finally 块中生成代码,在运行时测试枚举器是否实现了 IDisposable,如果是,则调用 Dispose,否则不执行任何操作。

IDisposable 接口在使用起来最简单的情况是,在同一个方法中获取资源并在使用完毕后释放它,因为你可以编写一个 using 语句(或在适当的情况下,一个 foreach 循环)来确保调用 Dispose。但有时,你会编写一个创建可释放对象并将其引用放入字段的类,因为它需要在较长时间内使用该对象。例如,你可能会编写一个日志记录类,如果日志记录器对象将数据写入文件,则可能会保留 StreamWriter 对象。在这种情况下,C# 不会提供自动帮助,因此你需要确保任何包含的对象都被释放。你将编写自己的 IDisposable 实现来释放其他对象,就像示例 7-15 所示。请注意,此示例将 _file 设置为 null,因此不会尝试两次释放文件。这并非绝对必要,因为 StreamWriter 可以容忍对 Dispose 的多次调用。但这确实为 Logger 对象提供了一种简单的方法来知道它处于已释放状态,因此如果我们添加了一些真正的方法,我们可以检查 _file,如果为 null,则抛出 ObjectDisposedException

示例 7-15. 释放包含的实例
public sealed class Logger : IDisposable
{
    private StreamWriter? _file;

    public Logger(string filePath)
    {
        _file = File.CreateText(filePath);
    }

    public void Dispose()
    {
        if (_file != null)
        {
            _file.Dispose();
            _file = null;
        }
    }
    // A real class would go on to do something with the StreamWriter, of course
}

此示例避开了一个重要的问题。该类是密封的,这避免了如何处理继承的问题。如果你编写一个未密封的类并实现了 IDisposable,你应该提供一种方法,让派生类添加自己的清理逻辑。最直接的解决方案是将 Dispose 声明为虚方法,以便派生类可以重写它,在调用基类实现的同时执行自己的清理。然而,在 .NET 中有时会看到更复杂的模式。

有些对象实现了IDisposable并且还有一个终结器。自从引入了SafeHandle及其相关类以来,一个类需要同时提供这两者的情况就相对不常见了(除非它是从SafeHandle派生而来)。通常只有处理句柄的包装器才需要终结器,而现在通常使用句柄的类会推迟到SafeHandle提供这个功能,而不是自己实现终结器。不过,也有例外情况,一些库类型实现了一种模式,旨在支持终结和IDisposable,使你能够在派生类中为两者提供自定义行为。例如,Stream基类就是这样工作的。

警告

这种模式被称为dispose 模式,但不要认为在实现IDisposable时通常应该使用它。相反,几乎不需要这种模式。即使在它被发明时,也只有少数类需要它,而现在有了SafeHandle之后,几乎从不需要了(SafeHandle在.NET 2.0 中引入,所以自从 dispose 模式广泛有用以来已经很长时间了)。不幸的是,一些人误解了这种模式的狭窄实用性,所以你会找到一些善意但完全错误的建议告诉你应该对所有IDisposable实现使用它。请忽略这些建议。这种模式今天的主要相关性在于你有时会在旧类型(如Stream)中遇到它。

这种模式是定义一个受保护的Dispose重载,它接受一个bool参数。基类从其公共Dispose方法以及析构函数中调用此方法,分别传递truefalse。这样,你只需重写一个方法,即受保护的Dispose方法。它可以包含对终结和处理通用的逻辑,比如关闭句柄,但你也可以执行任何特定于处理或终结的逻辑,因为参数告诉你正在执行哪种类型的清理。示例 7-16 展示了这种模式可能的样子。(这仅用于示例,MyCustomLibraryInteropWrapper类是为这个例子而虚构的。)

示例 7-16. 自定义终结和处理逻辑
public class MyFunkyStream : Stream
{
    // For illustration purposes only. Usually better to avoid this whole
    // pattern and to use some type derived from SafeHandle instead.
    private IntPtr _myCustomLibraryHandle;
    private Logger? _log;

    protected override void Dispose(bool disposing)
    {
        base.Dispose(disposing);

        if (_myCustomLibraryHandle != IntPtr.Zero)
        {
            MyCustomLibraryInteropWrapper.Close(_myCustomLibraryHandle);
            _myCustomLibraryHandle = IntPtr.Zero;
        }
        if (disposing)
        {
            if (_log != null)
            {
                _log.Dispose();
                _log = null;
            }
        }
    }

    // ...overloads of Stream's abstract methods would go here
}

这个假设性示例是对Stream抽象的自定义实现,它使用了一些外部非.NET 库,该库提供基于句柄的资源访问。我们更倾向于在公共Dispose方法调用时关闭句柄,但如果在我们的终结器运行时还没有发生这种情况,我们希望在那时关闭句柄。因此,代码检查句柄是否仍然打开,并在必要时关闭它,无论调用Dispose(bool)重载是否因显式释放对象或终结器运行而发生,我们都需要确保句柄在任一情况下都被关闭。然而,这个类似乎也使用了来自示例 7-15 的Logger类的实例。因为那是一个普通对象,我们不应在终结器中尝试使用它,所以我们只在对象被释放时尝试处理它。如果我们正在进行终结处理,那么尽管Logger本身不可终结,它使用的FileStream是可终结的;而且很可能FileStream的终结器已经在我们的MyFunkyStream类的终结器运行时运行过,因此在Logger上调用方法会是个坏主意。

当基类提供了这种虚拟的受保护的Dispose形式时,应该在其公共Dispose方法中调用GC.SuppressFinalizationStream基类就是这样做的。更一般地说,如果你发现自己编写了一个既提供了Dispose又提供了终结器的类,那么无论你选择是否支持继承这种模式,当调用Dispose时,你都应该抑制终结处理。

既然我建议避免这种模式,那么像示例 7-15 这样的代码在不接受使用sealed的情况下应该怎么办?答案很简单:如果你正在编写一个实现了IDisposable的类,并且希望该类可以被继承(即不是sealed),请将你的Dispose方法设为virtual。这样,派生类型可以重写它以添加它们自己的处理逻辑(而这些重写应始终调用基类的Dispose)。

可选的处理

尽管你应该在大多数实现了IDisposable接口的对象上的某个时刻调用Dispose,但也有少数例外情况。例如,.NET 的反应式扩展(在 第十一章 中描述)提供了表示事件流订阅的IDisposable对象。你可以调用Dispose来取消订阅,但有些事件源会自然结束,自动关闭任何订阅。如果发生这种情况,你就不需要调用Dispose。此外,广泛与异步编程技术结合使用的Task类型(在 第十七章 中描述)实现了IDisposable,但除非你引起它分配一个WaitHandle,在正常使用中是不会发生的。Task通常的使用方式使得在它上面找到一个合适的时间调用Dispose特别麻烦,所以幸运的是通常情况下不需要这样做。

HttpClient 类是另一个例外,但方式不同。我们很少对这种类型的实例调用Dispose,这是因为我们被鼓励重用实例。如果每次需要时构造、使用和处理一个HttpClient,你会破坏其重用现有连接的能力,当多次向同一服务器发送请求时。这可能会导致两个问题。首先,打开 HTTP 连接有时可能比发送请求和接收响应更耗时,因此阻止HttpClient重用连接以随时间发送多个请求可能会引起显著的性能问题。只有重用HttpClient才能使连接重用起效果。⁹ 其次,TCP 协议(HTTP 的基础)具有的特性意味着操作系统不能总是立即回收与连接相关的所有资源:它可能需要保留连接的 TCP 端口相当长的时间(可能几分钟),即使你已告诉操作系统关闭了连接,也可能耗尽端口,阻止所有进一步的通信。

这样的例外情况并不常见。仅当你使用的类的文档明确说明不需要调用Dispose时,才可以安全地省略调用Dispose

装箱

当我讨论 GC 和对象生命周期时,还有一个话题我应该在这一章节中讲述:装箱。装箱是使得类型为object的变量能够引用值类型的过程。一个object变量只能持有对堆上某物的引用,那么它如何能引用一个int呢?当代码在 示例 7-17 中运行时会发生什么?

示例 7-17. 使用int作为对象
static void Show(object o)
{
    Console.WriteLine(o.ToString());
}

int num = 42;
Show(num);

Show方法期望一个对象,而我正在传递num,这是一个值类型int的局部变量。在这些情况下,C#会生成一个箱子,这实质上是一个值的引用类型包装器。CLR 可以自动为任何值类型提供一个箱子,尽管如果它没有提供,你可以编写自己的类来执行类似的操作。示例 7-18 展示了一个手工构建的箱子。

示例 7-18. 实际上不是箱子的工作原理
// Not a real box but similar in effect.
public class Box<T>
    where T : struct
{
    public readonly T Value;
    public Box(T v)
    {
        Value = v;
    }

    public override string? ToString() => Value.ToString();
    public override bool Equals(object? obj) => Value.Equals(obj);
    public override int GetHashCode() => Value.GetHashCode();
}

这是一个包含一个值类型实例作为唯一字段的相当普通的类。如果你在箱子上调用object的标准成员,这个类的重写使它看起来好像你直接调用了字段本身。因此,如果我将new Box<int>(num)作为参数传递给示例 7-17 中的ShowShow将接收到该箱子的引用。当Show调用ToString时,箱子将调用int字段的ToString,所以你会期望程序显示 42。

我们不需要编写示例 7-18,因为 CLR 将为我们构建这个箱子。它将在堆上创建一个包含装箱值副本的对象,并将标准object方法转发给装箱值。它还做了一些我们无法做到的事情。如果调用一个装箱的intGetType方法来获取其类型,它将返回与直接调用int变量的GetType方法相同的Type对象。对于我的自定义Box<T>,我无法这样做,因为GetType不是虚拟的。此外,与手工构建的箱子相比,获取底层值更容易,因为解箱是 CLR 的内置特性。

如果你有一个类型为object的引用,并将其转换为int,CLR 将检查该引用是否确实指向一个装箱的int;如果是,CLR 将返回装箱值的副本。(如果不是,它会抛出InvalidCastException异常。)因此,在示例 7-17 的Show方法中,我可以写(int) o来获取原始值的副本,而如果我在示例 7-18 中使用该类,我将需要更复杂的((Box<int>) o).Value

我还可以使用模式匹配来提取一个装箱值。示例 7-19 使用声明模式来检测变量o是否包含一个装箱的int的引用,如果是,则将其提取到局部变量i中。正如我们在第二章中看到的那样,当你像这样使用is操作符与模式时,如果模式匹配,则结果表达式评估为true,如果不匹配则为false。因此,仅当那里确实有一个int值需要解箱时,才会运行此if语句的主体。

示例 7-19. 使用类型模式进行拆箱
if (o is int i)
{
    Console.WriteLine(i * 2);
}

所有结构体都自动支持装箱,¹⁰ 不仅仅是内置的值类型。如果结构体实现了任何接口,该装箱将提供相同的所有接口。 (这是 示例 7-18 无法执行的另一个技巧。)

一些隐式转换会导致装箱。你可以在 示例 7-17 中看到这一点。我传递了一个 int 类型的表达式到需要 object 的地方,而不需要显式转换。隐式转换也存在于值与其类型实现的任何接口之间。例如,你可以将类型为 int 的值分配给类型为 IComparable<int> 的变量(或将其作为该类型的方法参数传递),而不需要进行转换。这将创建一个装箱,因为任何接口类型的变量都类似于 object 类型的变量,它们只能保存对堆上项目的引用。

注意

隐式装箱转换不等同于隐式引用转换。这意味着它们在协变或逆变中不起作用。例如,IEnumerable<int>IEnumerable<object> 不兼容,尽管从 intobject 存在隐式转换,因为这不是隐式引用转换。

隐式装箱偶尔可能会因两个原因之一而引起问题。首先,它会导致 GC 需要额外的工作。CLR 不会尝试缓存装箱,因此如果你编写一个执行 100,000 次的循环,并且该循环包含使用隐式装箱转换的表达式,你将会生成 100,000 个装箱,最终 GC 将不得不像清理堆上的其他任何内容一样清理它们。其次,每个装箱操作(和每个拆箱操作)都会复制值,这可能不会提供您预期的语义。示例 7-20 展示了一些可能令人惊讶的行为。

示例 7-20. 阐明可变结构的潜在问题
static void CallDispose(IDisposable o)
{
    o.Dispose();
}

DisposableValue dv = new ();
Console.WriteLine("Passing value variable:");
CallDispose(dv);
CallDispose(dv);
CallDispose(dv);

IDisposable id = dv;
Console.WriteLine("Passing interface variable:");
CallDispose(id);
CallDispose(id);
CallDispose(id);

Console.WriteLine("Calling Dispose directly on value variable:");
dv.Dispose();
dv.Dispose();
dv.Dispose();

Console.WriteLine("Passing value variable:");
CallDispose(dv);
CallDispose(dv);
CallDispose(dv);

public struct DisposableValue : IDisposable
{
    private bool _disposedYet;

    public void Dispose()
    {
        if (!_disposedYet)
        {
            Console.WriteLine("Disposing for first time");
            _disposedYet = true;
        }
        else
        {
            Console.WriteLine("Was already disposed");
        }
    }
}

DisposableValue 结构实现了我们之前看到的 IDisposable 接口。它跟踪它是否已被处理。程序包含一个 CallDispose 方法,该方法在任何 IDisposable 实例上调用 Dispose。程序声明了一个类型为 DisposableValue 的单一变量,并将其传递给 CallDispose 三次。以下是程序该部分的输出:

Passing value variable:
Disposing for first time
Disposing for first time
Disposing for first time

在所有三个场景中,该结构体似乎认为这是我们首次调用其 Dispose 方法。这是因为每次调用 CallDispose 都创建了一个新的装箱——我们实际上并没有传递 dv 变量;每次都传递了一个新的装箱副本,因此 CallDispose 方法每次都在不同的结构体实例上工作。这与值类型通常的工作方式一致——即使没有装箱,当你将其作为参数传递时,你得到的是一个副本(除非使用 refin 关键字)。

程序的下一部分最终只生成了一个装箱——它将值分配给另一个类型为IDisposable的局部变量。这使用了与我们直接将变量作为参数传递时相同的隐式转换,因此这创建了另一个装箱,但是仅仅是一次。然后我们将同一个引用传递给这个特定装箱的三次调用,这解释了为什么程序这一阶段的输出看起来不同:

Passing interface variable:
Disposing for first time
Was already disposed
Was already disposed

这三次对CallDispose的调用都使用了同一个装箱,其中包含我们结构体的一个实例,所以在第一次调用后,它就记住它已经被处理了。接下来,我们的程序直接在局部变量上调用Dispose,生成了这个输出:

Calling Dispose directly on value variable:
Disposing for first time
Was already disposed
Was already disposed

这里完全没有涉及装箱,所以我们正在修改局部变量的状态。只看了一眼代码的人可能没有预料到这个输出——我们已经将dv变量传递给一个调用其参数Dispose的方法,因此在第一次执行时,它可能会认为它尚未被处理。但是一旦你理解了CallDispose需要一个引用,因此不能直接使用值,那么在这一点之前每次调用Dispose操作的都是某个装箱副本,而不是局部变量本身,一切就显而易见了。

最后,我们再次进行三次调用,直接将dv传递给CallDispose。这正是我们在代码开头所做的,所以这些调用产生了更多的装箱副本。但这一次,我们复制的是一个已经处于已处理状态的值,因此我们看到了不同的输出:

Passing value variable:
Was already disposed
Was already disposed
Was already disposed

当你理解发生了什么时,这些行为都很简单,但是这要求你注意你正在处理一个值类型,并且理解何时装箱会导致隐式复制。这就是微软建议开发人员不要编写可能改变其状态的值类型的一个原因——如果一个值不能改变,那么该类型的装箱值也不能改变。无论你处理的是原始值还是装箱副本,影响都较小,因此避免性能损失时,理解何时会发生装箱仍然很有用。

在早期的.NET 版本中,装箱在集合类中十分普遍,直到.NET 2.0 引入泛型之前。集合类都是以object为基础工作的,所以如果你想要一个可变大小的整数列表,列表中的每个int都会被装箱。泛型集合类不会导致装箱——List<int>能够直接存储未装箱的值。

装箱 Nullable

第三章描述了Nullable<T>类型,这是一个包装器,为任何值类型添加了空值支持。请记住,C#为此有特殊的语法,在值类型名称末尾加上一个问号,所以我们通常会写int?而不是Nullable<int>。当涉及到装箱时,CLR 对Nullable<T>有特殊支持。

Nullable<T>本身是一个值类型,因此如果您尝试获取对它的引用,编译器将生成试图将其装箱的代码,就像处理任何其他值类型一样。然而,在运行时,CLR 不会生成包含Nullable<T>本身副本的装箱。相反,它会检查值是否处于空状态(即其HasValue属性返回false),如果是,则返回null。否则,它将装箱包含的值。例如,如果Nullable<int>有一个值,将其装箱将产生类型为int的箱。这与您从普通int值开始时得到的箱无法区分。(其中一个结果是,示例 7-19 中显示的模式匹配无论最初装箱的变量类型是int还是int?,都可以使用int在声明模式中。)

您可以将装箱的int解包为int?int类型的变量。因此,示例 7-21 中的所有三个解包操作都将成功。如果将第一行修改为从未处于空状态的Nullable<int>初始化boxed变量,则它们也将成功。 (如果您从处于空状态的Nullable<int>初始化boxed,那将产生与将其初始化为null相同的效果,此示例的最后一行将抛出NullReferenceException。)

示例 7-21. 将int解包成可空和非可空变量
object boxed = 42;
int? nv = boxed as int?;
int? nv2 = (int?) boxed;
int v = (int) boxed;

这是一个运行时特性,而不仅仅是编译器的聪明。IL box指令(这是 C#在想要装箱值时生成的内容)检测到Nulla⁠ble​<T>值;unboxunbox.any IL 指令能够从null或引用基础类型的装箱值产生Nulla⁠ble​<T>值。因此,如果您编写自己的看起来像Nullable<T>的包装类型,它不会表现出相同的行为;如果您将您的类型的值分配给一个object,它将像处理任何其他值一样对您的整个包装进行装箱。只有因为 CLR 知道Nullable<T>的存在,它才会表现出不同的行为。

总结

在本章中,我描述了运行时提供的堆。我展示了 CLR 用于确定哪些堆对象仍可被你的代码访问的策略,以及它用于回收不再使用的对象所占用内存的基于代的机制。GC 并非能预见,因此如果你的程序保持了一个对象的可访问性,GC 必须假设你将来可能会使用该对象。这意味着有时你需要小心确保不会因为意外保留对象太长时间而导致内存泄漏。我们看了最终化机制及其各种限制和性能问题,并且我们还看了IDisposable,它是清理非内存资源的首选系统。最后,我们看到了值类型如何因装箱而表现得像引用类型。

下一章中,我将展示 C#如何呈现 CLR 的错误处理机制。

¹ 本章中“GC”缩写用来指代垃圾收集器机制以及垃圾收集,即垃圾收集器的功能。

² Mono 运行时的 GC 与.NET GC 没有共享代码,尽管它们现在都驻留在同一个 GitHub 仓库中。尽管如此,它们在这里使用相同的方法。

³ 使用ref struct定义的值类型是一个例外:它们总是存在于堆栈上。第十八章讨论了这些内容。

⁴ CLR 并不总是等到内存用尽才进行垃圾回收。稍后我会详细讨论这些细节。目前,重要的是时不时地它会尝试释放一些空间。

⁵ Mono 运行时使用了稍微简化的方案,但仍然依赖于将新旧对象区分对待的基本原则。

⁶ .NET 提供了一个配置设置,允许你更改这个阈值。

⁷ 虽然单核 CPU 如今已经很少见,但在虚拟机上运行,将只有一个核心呈现给它们托管的代码仍然很常见。例如,如果你的应用程序在使用按消耗计费的云托管服务。

⁸ 你可以使用一个名为 PerfView 的免费 Microsoft 工具来完成这个操作。另外,免费的 BenchmarkDotNet 工具具有内存诊断功能。

⁹ 严格来说,需要重复使用的是底层的MessageHandler。如果你从IHttpClientFactory获取一个HttpClient,释放它是无害的,因为工厂会持有处理程序并在多个HttpClient实例中重用它。

¹⁰ 除了ref struct类型,因为它们总是存在于堆栈上。

第八章:异常

有些操作可能会失败。如果你的程序从存储在外部驱动器上的文件读取数据,可能会有人断开驱动器。您的应用程序可能尝试构造数组,但发现系统没有足够的空闲内存。间歇性的无线网络连接问题可能会导致网络请求失败。程序发现这些故障的一个广泛使用的方法是每个 API 返回一个值,指示操作是否成功。这要求开发人员保持警惕,以便检测所有的错误,因为程序必须检查每个操作的返回值。这确实是一种可行的策略,但它可能会使代码变得难以理解;当没有问题时执行的工作的逻辑顺序可能会被所有的错误检查淹没,使得代码难以维护。C# 支持另一种流行的错误处理机制,可以缓解这个问题:异常

当 API 报告异常导致操作失败时,会打断正常的执行流程,直接跳转到最近的适当错误处理代码。这种方式使得错误处理逻辑与尝试执行任务的代码分离,这样做可以使得代码更易读和维护,尽管其缺点是可能较难看出代码可能执行的所有可能路径。

异常还可以报告操作问题,而这些问题可能不适合使用返回码进行处理。例如,运行时可以检测和报告基本操作的问题,甚至简单到使用引用。引用类型变量可能包含null,如果尝试在空引用上调用方法,则会失败。运行时会用异常报告这种情况。

.NET 中的大多数错误都表示为异常。但是,一些 API 提供了返回代码和异常之间的选择。例如,int 类型具有一个 Parse 方法,该方法接受一个字符串并尝试解释其内容为数字,如果传递了一些非数字文本(例如 "Hello"),它将通过抛出 FormatException 来指示失败。如果您不喜欢这样,可以改用 TryParse,它完全执行相同的任务,但如果输入非数字,则返回 false 而不是抛出异常。(由于方法的返回值负责报告成功或失败,该方法通过 out 参数提供整数结果。)数字解析并不是唯一使用此模式的操作,在这种情况下,一对方法(在本例中为 ParseTryParse)提供了异常和返回值之间的选择。正如您在 第五章 中看到的那样,字典也提供了类似的选择。索引器如果使用不在字典中的键,则会抛出异常,但您还可以使用 TryGetValue 查找值,如果失败,则返回 false,就像 TryParse 一样。尽管此模式在几个地方出现,但对于大多数 API 来说,异常是唯一的选择。

如果您设计一个可能会失败的 API,应如何报告失败?应该使用异常、返回值还是两者兼有?微软的类库设计指南包含似乎毫不含糊的说明:

不要返回错误代码。在框架中,异常是报告错误的主要手段。

.NET Framework 设计指南

但是这与存在 int.TryParse 的事实如何相符呢?指南中有关异常性能考虑的部分如下所述:

考虑在常见情况下可能会抛出异常的成员使用“尝试-解析”模式,以避免与异常相关的性能问题。

.NET Framework 设计指南

解析数字失败不一定是错误。例如,您可能希望应用程序允许以数字或文本指定月份。因此,在操作可能失败的常见情况下,还有另一个标准:它建议在“极其性能敏感的 API”中使用 TryParse 方法,因此只有在操作速度比抛出和处理异常的时间快时,您才应该提供这种方法。

异常通常可以在几毫秒内抛出和处理,因此它们并不是非常慢的——例如,并不像读取网络连接上的数据那么慢——但它们也不是极快的。我发现在我的电脑上,使用.NET 6.0,单线程可以以约每秒大约 8000 万个字符串的速率解析五位数字字符串,并且如果我使用TryParse,它能够以类似的速度拒绝非数字字符串。Parse方法处理数字字符串同样快,但在拒绝非数字字符串方面大约慢 400 倍,这要归因于异常的成本。当然,将字符串转换为整数是一种非常快速的操作,所以这使得异常看起来特别糟糕,但这也是为什么这种模式在自然快速的操作中最为常见。

调试时,异常可能特别慢。部分原因是调试器必须决定是否中断,但特别是在程序首次遇到未处理的异常时尤为明显。这可能给人一种异常的成本远高于实际情况的印象。上述段落中的数字基于观察到的运行时行为,没有考虑调试开销。尽管如此,这些数字略微低估了成本,因为处理异常往往会导致 CLR 运行代码片段并访问否则不需要使用的数据结构,这可能会导致有用的数据被推出 CPU 的缓存,使代码在异常处理后的短时间内运行更慢,直到非异常代码和数据重新进入缓存。我的示例的简单性减少了这种影响。

大多数 API 不提供Try*Xxx*形式,并且会将所有失败报告为异常,即使在失败可能很常见的情况下也是如此。例如,文件 API 不提供一种在文件丢失时打开现有文件进行读取而不抛出异常的方式。(您可以使用不同的 API 先测试文件是否存在,但这并不能保证成功。总是可能会有其他进程在您询问文件是否存在和尝试打开它之间删除该文件。)由于文件系统操作本质上是慢速的,即使在这里Try*Xxx*模式也不会提供值得的性能提升,尽管逻辑上可能有意义。

警告

如果您使用Try*Xxx*模式,请注意,如果操作可能会失败的原因有多种,false返回值通常只表示一种特定类型的失败。因此,这种类型的方法在某些失败模式下仍可能抛出异常。

异常来源

类库 API 不是异常的唯一来源。它们可以在以下任何场景中抛出:

  • 您自己的代码检测到了一个问题。

  • 您的程序使用了一个类库 API,它检测到了一个问题。

  • 运行时检测到操作失败(例如,在检查上下文中发生算术溢出,或者尝试使用空引用,或者尝试为没有足够内存的对象分配内存)。

  • 运行时检测到影响您代码的情况,这些情况不在您的控制之内(例如,运行时尝试为某些内部目的分配内存,却发现没有足够的可用内存)。

尽管所有这些情况都使用相同的异常处理机制,但异常发生的位置各不相同。当您自己的代码抛出异常时(我稍后会告诉您如何操作),您将知道导致它发生的条件,但这些其他场景何时会产生异常呢?我将在接下来的部分描述在哪里期望每种类型的异常。

来自 API 的异常

使用 API 调用时,可能会出现几种导致异常的问题。您可能提供了毫无意义的参数,比如需要非空引用而提供了 null 引用,或者期望文件名而提供了空字符串。或者这些参数在单独看起来是合理的,但在集体使用时却不行。例如,您可能调用了一个将数据复制到数组的 API,请求它复制超过数组容量的数据。您可以将这些错误描述为“那绝对行不通”的错误类型,通常是由于代码中的错误而导致的。(一个曾在 C# 编译器团队工作过的开发者将这些称为 愚蠢的 异常。)

另一类问题是,参数看起来都合理,但基于当前世界状态,操作实际上不可能进行。例如,您可能要求打开某个特定的文件,但该文件可能不存在;或者它存在,但某个其他程序已经打开并要求独占访问该文件。还有另一种情况是,事情可能一开始顺利,但情况可以改变,例如您成功打开了一个文件并且已经读取了一段时间的数据,但随后该文件变得不可访问。如前所述,可能是有人拔掉了磁盘,或者驱动器由于过热或老化而失败。

与外部服务通过网络通信的软件需要考虑,异常并不一定表示真的有什么问题—有时请求因某些临时条件而失败,您可能只需重试操作。这在云环境中特别常见,在那里单个服务器作为负载平衡的一部分会频繁上下线—因此偶尔会有几次操作由于没有明确的原因而失败。

小贴士

当使用库通过服务时,你应该弄清楚它是否已经为你处理了这些问题。例如,Azure 存储库默认会自动执行重试,并且只有在你禁用此行为或者在多次尝试后问题仍然存在时才会抛出异常。你通常不应该为这种类型的错误在已经处理了这一问题的库周围添加自己的异常处理和重试循环。

异步编程还增加了另一种变化。在第十六章和 17 章中,我将展示各种异步 API——其中工作可以在启动它的方法返回后继续进行。异步运行的工作也可能会异步失败,在这种情况下,库可能必须等到你的代码下次调用它之前才能报告错误。

尽管情况各异,在所有这些情况中,异常都将来自你的代码调用的某个 API。(即使异步操作失败,异常也会在你尝试收集操作结果时或显式询问是否发生错误时产生。)示例 8-1 展示了可能出现这类异常的一些代码。

示例 8-1. 从库调用中获取异常
static void Main(string[] args)
{
    using (var r = new StreamReader(@"C:\Temp\File.txt"))
    {
        while (!r.EndOfStream)
        {
            Console.WriteLine(r.ReadLine());
        }
    }
}

没有什么绝对错误的这段代码,所以我们不会得到任何关于参数明显错误的异常。(在非正式术语中,它不会犯愚蠢的错误。)如果你的电脑 C: 驱动器有一个 Temp 文件夹,并且其中包含一个 File.txt 文件,而且运行程序的用户有权限读取该文件,并且计算机上没有其他内容已经独占了该文件,并且没有问题——比如磁盘损坏——可能导致文件的任何部分不可访问,并且在程序运行时没有新问题(比如驱动器着火),这段代码就会完美地工作:它将显示文件中的每一行文本。但是这里有很多 如果

如果没有这样的文件,StreamReader 构造函数将无法完成。相反,它会抛出一个异常。这个程序没有尝试处理这种情况,所以应用程序会终止。如果你在 Visual Studio 的调试器外运行程序,你会看到以下输出:

Unhandled exception. System.IO.FileNotFoundException: Could not find file 'C:\Te
mp\File.txt'.
File name: 'C:\Temp\File.txt'
   at Microsoft.Win32.SafeHandles.SafeFileHandle.CreateFile(String fullPath, Fil
eMode mode, FileAccess access, FileShare share, FileOptions options)
   at Microsoft.Win32.SafeHandles.SafeFileHandle.Open(String fullPath, FileMode
mode, FileAccess access, FileShare share, FileOptions options, Int64 preallocati
onSize)
   at System.IO.Strategies.OSFileStreamStrategy..ctor(String path, FileMode mode
, FileAccess access, FileShare share, FileOptions options, Int64 preallocationSi
ze)
   at System.IO.Strategies.FileStreamHelpers.ChooseStrategyCore(String path, Fil
eMode mode, FileAccess access, FileShare share, FileOptions options, Int64 preal
ocationSize)
   at System.IO.Strategies.FileStreamHelpers.ChooseStrategy(FileStream fileStrea
m, String path, FileMode mode, FileAccess access, FileShare share, Int32 bufferS
ize, FileOptions options, Int64 preallocationSize)
   at System.IO.StreamReader.ValidateArgsAndOpenPath(String path, Encoding encod
ing, Int32 bufferSize)
   at System.IO.StreamReader..ctor(String path)
   at Exceptional.Program.Main(String[] args) in c:\Examples\Ch08\Example1\Progr
am.cs:line 10

这告诉我们发生了什么错误,并显示了程序在问题发生时的完整调用堆栈。在 Windows 上,系统级错误处理也会介入,所以根据计算机的配置,你可能会看到其错误报告对话框,甚至可能会向 Microsoft 的错误报告服务报告崩溃情况。如果你在调试器外运行相同的程序,它会告诉你有关异常,并突出显示发生错误的代码行,就像 图 8-1 所示。

Visual Studio 报告异常

图 8-1. Visual Studio 报告异常

我们在这里看到的是程序在不处理异常的情况下的默认行为:如果附加了调试器,它将中断,否则程序就会崩溃。不久我将展示如何处理异常,但这说明了你不能简单地忽略它们。

顺便说一下,在示例 8-1 中,对 StreamReader 构造函数的调用并不是唯一可能引发异常的行。代码多次调用 ReadLine,其中任何调用都可能失败。一般来说,任何成员访问都可能导致异常,甚至仅仅是读取属性,尽管类库设计者通常试图最小化属性引发异常的情况。如果犯了“那绝对行不通”的错误(愚蠢的错误),那么属性可能会引发异常,但通常不是“这个特定操作失败”的错误。例如,文档说明了在示例 8-1 中使用的 EndOfStream 属性,如果在对 StreamReader 对象调用 Dispose 后尝试读取它,将引发异常——这是一个明显的编码错误,但如果在读取文件时出现问题,StreamReader 仅会从方法或构造函数中抛出异常。

运行时检测到的失败

另一个异常源是当 CLR 自身检测到某些操作失败时。示例 8-2 展示了可能发生这种情况的方法。与示例 8-1 类似,这段代码本质上没有问题(除了不是很有用)。完全可以在不引起问题的情况下使用它。但是,如果有人将第二个参数传入 0,那么代码将尝试执行非法操作。

示例 8-2. 可能的运行时检测到的失败
static int Divide(int x, int y)
{
    return x / y;
}

CLR 将检测到此除法操作试图除以零并将引发 DivideByZeroException。这将与来自 API 调用的异常具有相同的效果:如果程序未尝试处理异常,则会崩溃,或者调试器将中断。

注意

在 C# 中,除零操作并非总是非法的。浮点类型支持表示正无穷大和负无穷大的特殊值,这是在将正数或负数除以零时得到的值;如果除以零,则得到特殊的非数值。整数类型不支持这些特殊值,因此整数除以零总是错误的。

我之前描述的异常的最终来源也是运行时检测到某些失败的地方,但它们的工作方式略有不同。它们不一定直接由线程上的代码触发。这些有时被称为异步异常,理论上可以在代码的任何地方抛出,这使得确保正确处理它们变得困难。然而,它们往往只在相当灾难性的情况下抛出,通常是在程序即将关闭时,因此通常无法有用地处理它们。例如,Sta⁠ckO⁠ver⁠flow​Exc⁠ept⁠ionOutOfMemoryException理论上可以在任何时候抛出(因为 CLR 可能需要为自己的目的分配内存,即使您的代码并未明确尝试这样做)。

我已经描述了异常被抛出的常见情况,您也看到了默认行为,但如果您希望程序执行与崩溃不同的操作怎么办?

处理异常

当抛出异常时,CLR 会寻找处理异常的代码。只有在整个调用堆栈上没有合适的处理程序时,默认的异常处理行为才会起作用。为了提供处理程序,我们使用 C#的trycatch关键字,正如示例 8-3 所示。

示例 8-3. 处理异常
try
{
    using (var r = new StreamReader(@"C:\Temp\File.txt"))
    {
        while (!r.EndOfStream)
        {
            Console.WriteLine(r.ReadLine());
        }
    }
}
catch (FileNotFoundException)
{
    Console.WriteLine("Couldn't find the file");
}

紧跟在try关键字之后的块通常称为try 块,如果程序在此类块内部抛出异常,CLR 会寻找匹配的catch 块。示例 8-3 只有一个单独的catch块,在catch关键字后的括号中,您可以看到此特定块旨在处理FileNotFoundException类型的异常。

如前所示,如果没有C:\Temp\File.txt文件,StreamReader构造函数会抛出FileNotFoundException。在示例 8-1 中,这导致我们的程序崩溃,但因为示例 8-3 中有一个catch块来处理该异常,CLR 将运行该catch块。此时,它会认为异常已经被处理,因此程序不会崩溃。我们的catch块可以自由地执行任何操作,在这种情况下,我的代码只是显示一个消息,指示找不到该文件。

异常处理程序不需要位于引发异常的方法中。CLR 会沿着调用堆栈向上查找,直到找到合适的处理程序。如果失败的StreamReader构造函数调用位于从示例 8-3 的try块内部调用的其他方法中,我们的catch块仍然会运行(除非该方法为相同异常提供了自己的处理程序)。

异常对象

异常是对象,其类型派生自Exception基类。¹ 这定义了提供异常信息的属性,一些派生类型还添加了特定于它们所代表问题的属性。如果需要了解出了什么问题,你的catch块可以获取异常的引用。示例 8-4 显示了来自示例 8-3 的catch块的修改。在catch关键字后的括号中,除了指定异常类型,我们还提供了一个标识符(x),用于catch块中的代码引用异常对象。这使得代码能够读取特定于FileNotFoundException类的属性:FileName

示例 8-4. 在catch块中使用异常
try
{
    // ...same code as Example 8-3...
}
catch (FileNotFoundException x)
{
    Console.WriteLine($"File '{x.FileName}' is missing");
}

这将显示找不到的文件的名称。通过这个简单的程序,我们已经知道我们试图打开哪个文件,但是在处理多个文件的更复杂程序中,这个属性可能会有所帮助。

基类Exception定义的通用成员包括Message属性,它返回包含问题文本描述的字符串。控制台应用程序的默认错误处理显示这个信息。当我们首次运行示例 8-1 时看到的文本Could not find file 'C:\Temp\File.txt'来自Message属性。在诊断意外异常时,这个属性非常重要。

警告

Message属性用于人类阅读,因此 API 可能会本地化这些消息。因此,试图通过检查Message属性来解释异常是一个不好的主意,因为当你的代码在配置为运行在语言与你不同的区域的计算机上运行时,这可能会失败。 (并且微软不将异常消息更改视为破坏性更改,因此即使在同一区域内,文本也可能会更改。)最好依赖实际的异常类型,尽管某些异常如IOException在模棱两可的情况下会被使用。因此,有时需要检查HResult属性,该属性将设置为操作系统中的错误代码。

Exception 还定义了一个 InnerException 属性。通常情况下这是 null,但当一个操作由于其他失败而失败时,它会变得有用。有时,在库的深层中发生的异常如果允许一直传播到调用者可能会让人感到困惑。例如,.NET 提供了一个用于解析 XAML 文件的库。(XAML——可扩展应用程序标记语言——被各种 .NET UI 框架使用,包括 WPF。)XAML 是可扩展的,因此您的代码(或者第三方代码)可能会作为加载 XAML 文件过程的一部分而运行,而这些扩展代码可能会失败——假设您的代码中存在错误导致在访问数组元素时抛出 IndexOutOfRangeException。如果这种异常从 XAML API 中出现,那将有些费解,因此无论失败的根本原因是什么,库都会抛出 XamlParseException。这意味着,如果您想要处理加载 XAML 文件失败的情况,您可以确切地知道要处理的异常,但失败的根本原因不会丢失:当其他异常导致失败时,它将在 InnerException 中。

所有异常都包含关于异常抛出位置的信息。StackTrace 属性提供了调用堆栈的字符串表示。正如您已经看到的,默认的控制台应用程序异常处理程序会显示这些信息。还有一个 TargetSite 属性,告诉您正在执行的方法。它返回反射 API 的 MethodBase 类的实例。详细信息请参见第十三章关于反射的部分。

多个 catch 块

try 块后面可以跟多个 catch 块。如果第一个 catch 不匹配抛出的异常,CLR 将查看下一个,依此类推。示例 8-5 提供了对 FileNotFoundExceptionDirectoryNotFoundExceptionIOException 的处理程序。

示例 8-5. 处理多个异常类型
try
{
    using (var r = new StreamReader(@"C:\Temp\File.txt"))
    {
        while (!r.EndOfStream)
        {
            Console.WriteLine(r.ReadLine());
        }
    }
}
catch (FileNotFoundException x)
{
    Console.WriteLine($"File '{x.FileName}' is missing");
}
catch (DirectoryNotFoundException)
{
    Console.WriteLine($"The containing directory does not exist.");
}
catch (IOException x)
{
    Console.WriteLine($"IO error: '{x.Message}'");
}

这个示例的一个有趣特性是 FileNotFoundExceptionDirectoryNotFoundException 都派生自 IOException。我可以移除前两个 catch 块,这仍然可以正确处理这些异常(只是显示的消息会更少具体),因为 CLR 会认为 catch 块匹配异常的基类型时也是有效的。因此,示例 8-5 为 FileNotFoundException 提供了两个可行的处理程序,并且为 DirectoryNotFoundException 也提供了两个可行的处理程序。(第三个处理程序仍然有用,因为文档告诉我们,对于某些类型的失败,StreamReader 将抛出 IOException,而不是更特定的类型。)在这些情况下,C# 要求更具体的处理程序首先出现。如果我将 IOException 处理程序移动到其他处理程序的上方,那么对于每个更具体的处理程序,编译器将会报错:

error CS0160: A previous catch clause already catches all exceptions of this or
of a super type ('IOException')

如果为Exception基类型编写catch块,它将捕获所有异常。在大多数情况下,这是不正确的做法。虽然处理你可以预期的异常是好的,但如果你不知道异常代表什么,通常应该让它继续传播。否则,你可能会掩盖问题。如果让异常继续传播,它更有可能被注意到,增加了在某个时刻正确修复问题的机会。如果你打算将所有异常都包装在另一个异常中并抛出,就像前面描述的XamlParseException一样,那么一个捕获所有异常的处理程序可能是适当的。如果在异常只能由系统提供的默认处理方式处理的地方,捕获所有异常并将细节写入日志文件或类似的诊断机制也许是合适的。即便如此,在记录日志之后,你可能仍然希望重新抛出异常,就像本章后面描述的那样,甚至终止具有非零退出代码的进程。

警告

对于非常重要的服务,你可能会考虑编写代码来吞噬异常,以便你的应用程序可以继续运行。这是一个坏主意。如果发生了你没有预料到的异常,你的应用程序内部状态可能不再可信,因为在故障发生时,你的代码可能已经进行到一半的操作。如果你不能承担应用程序离线的代价,最好的方法是安排它在故障后自动重启。例如,可以配置 Windows 服务自动执行此操作。

异常过滤器

你可以使catch块有条件地执行:如果为catch块提供了一个异常过滤器,那么只有在过滤器条件为真时才会捕获异常。 示例 8-6 展示了这样做的实用性。它使用了 Azure 表存储的客户端 API,这是 Microsoft Azure 云计算平台的一部分提供的 NoSQL 存储服务。该 API 的TableClient类有一个AddEntity方法,如果出现问题就会抛出RequestFailedException。问题是,“出现问题”非常广泛,涵盖了不仅仅是连接和身份验证失败。在某些乐观并发模型中,尝试插入具有相同键的另一行时,也会看到此异常。这不一定是错误,有时在正常使用中会出现。

示例 8-6. catch块的异常过滤器
public static bool InsertIfDoesNotExist(MyEntity item, TableClient table)
{
    try
    {
        table.AddEntity(item);
        return true;
    }
    catch (RequestFailedException x)
    `when` `(``x``.``Status` `=``=` `409``)`
    {
        return false;
    }
}

示例 8-6 查找特定的失败案例,并在异常继续向上传播之前返回false。它使用包含过滤器的when子句来实现这一点,该过滤器必须是bool类型的表达式。如果Execute方法抛出的StorageException不符合过滤条件,则异常将像没有catch块一样传播。

提示

在使用异常过滤器时,单个try块可以有多个针对同一异常的catch块。通常情况下,这会导致编译器错误,因为只有第一个这样的catch块会起作用,但是使用过滤器时,情况并非一定如此,因此编译器允许这样做。甚至可以为特定异常类型的一个未经过滤的catch块与同一类型的过滤catch块共存,但未经过滤的必须出现在最后。

异常过滤器必须是生成bool的表达式。如果需要,它可以调用外部方法。示例 8-6 只是获取一个属性并执行比较,但您可以自由地在表达式中调用任何方法。² 但是,应注意避免在过滤器中执行可能引发另一个异常的操作。如果发生这种情况,第二个异常将丢失。

嵌套的 try 块

如果在try块中发生异常,而没有提供适当的处理程序,则 CLR 将继续查找。必要时会沿着堆栈向上走,但是可以通过将一个try/catch嵌套在另一个try块中,在单个方法中嵌套多组处理程序,就像示例 8-7 所示。ShowFirstLineLength在另一个try/catch对的try块内部嵌套了一个try/catch对。也可以跨方法进行嵌套——Main方法将捕获从ShowFirstLineLength方法抛出的任何NullReferenceException(如果文件完全为空,则调用ReadLine将返回null)。

示例 8-7. 嵌套异常处理
static void Main(string[] args)
{
    try
    {
        ShowFirstLineLength(@"C:\Temp\File.txt");
    }
    catch (NullReferenceException)
    {
        Console.WriteLine("NullReferenceException");
    }
}

static void ShowFirstLineLength(string fileName)
{
    try
    {
        using (var r = new StreamReader(fileName))
        {
            try
            {
                Console.WriteLine(r.ReadLine()!.Length);
            }
            catch (IOException x)
            {
                Console.WriteLine("Error while reading file: {0}",
                    x.Message);
            }
        }
    }
    catch (FileNotFoundException x)
    {
        Console.WriteLine("Couldn't find the file '{0}'", x.FileName);
    }
}

我在这里嵌套了IOException处理程序,使其仅适用于工作的某个特定部分:它仅处理在成功打开文件后读取时发生的错误。有时,对此情况作出不同响应可能比对导致无法打开文件的错误响应更有用。

此处的跨方法处理有些刻意。可以通过测试ReadLine的返回值是否为null来避免NullReference​Excep⁠tion。然而,这里展示的 CLR 底层机制非常重要。特定的try块可以定义仅对其知道如何处理的那些异常的catch块,允许其他异常逃逸到更高级别。

让异常继续向上堆栈传播通常是正确的做法。除非你的方法能够在发现错误时采取一些有用的措施,否则它将需要告知其调用者存在问题,所以除非你想用另一种异常包装异常,否则你可以让异常自由传播。

注意

如果你熟悉 Java,你可能想知道 C#是否有等效于已检查异常的东西。它没有。方法不会正式声明它们可能抛出的异常,因此编译器无法告诉你是否未能处理它们或声明你的方法可能反过来抛出它们。

你也可以将一个try块嵌套在一个catch块内。如果你的错误处理程序本身可能失败,这一点很重要。例如,如果你的异常处理程序将有关磁盘故障的信息记录到磁盘上,那么如果磁盘出现问题,它可能会失败。

有些 try 块永远不会捕获任何东西。编写不紧跟着catch块的try块是非法的,但那个东西不必是一个catch块:它可以是一个finally 块

finally 块

一个finally块包含在其关联的try块完成后始终运行的代码。它无论是通过达到结尾、从中间返回或抛出异常离开try块,都会运行。即使你使用goto语句直接跳出块,finally块也会运行。示例 8-8 展示了finally块的使用。

示例 8-8. 一个finally
using Microsoft.Office.Interop.PowerPoint;

...

[STAThread]
static void Main(string[] args)
{
    var pptApp = new Application();
    Presentation pres = pptApp.Presentations.Open(args[0]);
    try
    {
        ProcessSlides(pres);
    }
    finally
    {
        pres.Close();
    }
}

这是我编写的用于处理 Microsoft Office PowerPoint 文件内容的实用程序的摘录。这只显示了最外层的代码;我省略了实际的详细处理代码,因为这里并不重要(尽管如果你好奇的话,本书的可下载示例的完整版本将动画幻灯片导出为视频剪辑)。我展示它是因为它使用了finally。这个示例使用 COM 互操作来控制 PowerPoint 应用程序。这个示例在完成后关闭文档,我将该代码放在finally块中的原因是,如果程序在中途出现问题,我不希望它留下未关闭的东西。这是因为 COM 自动化的工作方式。这不像打开文件,操作系统在进程终止时会自动关闭所有内容。如果程序突然退出,PowerPoint 不会关闭已经打开的任何东西,它只是假设你是要保留打开的。 (当创建用户将编辑的新文档时,你可能会故意这样做。)我不希望这样,将文件在finally块中关闭是避免这种情况的可靠方法。

通常,您会为此类事物编写using语句,但是 PowerPoint 的基于 COM 的自动化 API 不支持.NET 的IDisposable接口。实际上,正如我们在上一章中看到的那样,using语句在内部使用finally块工作,foreach也是如此,因此即使在编写using语句和foreach循环时,您也依赖于异常处理系统的finally机制。

注意

当异常块嵌套时,finally块会正确运行。如果某个方法抛出异常,由调用堆栈中较高级别的方法处理,而中间某些方法位于using语句、foreach循环或带有关联finally块的try块中,则所有这些中间finally块(无论是显式声明的还是编译器隐式生成的)都会在处理程序运行之前执行。

处理异常当然只是问题的一半。您的代码可能会检测到问题,并且异常可能是适当的报告机制。

抛出异常

抛出异常非常直接。只需构造适当类型的异常对象,然后使用throw关键字。当position参数超出合理范围时,示例 8-9 会这样做。

示例 8-9. 抛出异常
public static string GetCommaSeparatedEntry(string text, int position)
{
    string[] parts = text.Split(',');
    if (position < 0 || position >= parts.Length)
    {
        `throw` `new` `ArgumentOutOfRangeException``(``nameof``(``position``)``)``;`
    }
    return parts[position];
}

CLR 为我们完成了所有工作。它捕获了异常所需的信息,以便能够通过StackTraceTargetSite属性报告其位置。(它不计算它们的最终值,因为这些值相对昂贵。它只是确保它具有生成这些值所需的信息,以备查询。)然后它寻找合适的try/catch块,如果需要运行任何finally块,它将执行它们。

示例 8-9 展示了在抛出报告方法参数问题的异常时常用的一种技术。诸如ArgumentNull​Excep⁠tionArgumentOutOfRangeException及其基类ArgumentException等异常都可以报告有问题的参数的名称。(这是可选的,因为有时需要报告多个参数之间的不一致性,此时没有单个参数需要命名。)使用 C#的nameof运算符是个不错的主意。您可以将其与任何引用命名项的表达式一起使用,例如参数、变量、属性或方法。它编译为包含该项名称的字符串。

在这里,我本可以简单地使用字符串字面量"position",但nameof的优点在于它可以避免愚蠢的错误(如果我输入positon而不是position,编译器会告诉我找不到这样的符号),并且可以帮助避免由于重命名符号时引起的问题。如果我在示例 8-9 中重命名position参数,很容易忘记更改以匹配的字符串字面量。但是通过使用nameof(position),如果我更改参数名称为pos而没有同时更改nameof(position),编译器会报告找不到名为position的标识符。如果我请求一个了解 C# 的 IDE(例如 Visual Studio 或 JetBrains Rider)重命名参数,它将自动更新代码中使用该符号的所有地方,因此它将为我替换异常的构造函数参数为nameof(input)

我们可以使用类似的技术处理ArgumentNullException,但是.NET 6.0 添加了一个可以简化抛出此特定异常的帮助函数。正如示例 8-10 所示,与其编写一个测试输入的if语句,其主体抛出标识正确参数名称的异常,我们可以直接调用ArgumentNullException.ThrowIfNull

示例 8-10. 抛出ArgumentNullException
public static int CountCommas(string text)
{
    `ArgumentNullException``.``ThrowIfNull``(``text``)``;`
    return text.Count(ch => ch == ',');
}

此方法测试传递的任何参数,并在其为 null 时抛出ArgumentNullException。但是如何正确设置参数名称呢?此ThrowIfNull方法利用了新的 C# 10.0 功能:它带有CallerArgument​Ex⁠pression属性的注释。正如第十四章所述,此属性使ThrowIfNull助手能够发现调用方用作参数的表达式文本。由于我们将我们的text参数传递给此助手,它将传递一个额外的隐藏参数,字符串"text"。因此,这与使用其他参数异常的nameof具有相同的所有好处,但它还为我们执行相关的测试。

警告

许多异常类型提供了一个构造函数重载,允许您设置Message文本。更专业的消息可能会使问题更容易诊断,但要小心一件事。异常消息经常出现在诊断日志中,并且可能也会通过监控系统自动发送电子邮件。因此,请注意您在这些消息中放入的信息。如果您的软件将在有数据保护法的国家使用,这一点尤为重要——在异常消息中放入任何与特定用户有关的信息有时可能违反这些法律。

重新抛出异常

有时编写一个catch块以响应错误并允许该错误在完成工作后继续是有用的。这有一种明显但错误的方法,例如示例 8-11 中所示。

示例 8-11. 如何不重新抛出异常
try
{
    DoSomething();
}
catch (IOException x)
{
    LogIOError(x);
    // This next line is BAD!
    throw x;  // Do not do this
}

这将编译而不会出错,甚至看起来可以工作,但它有一个严重的问题:它丢失了最初引发异常的上下文。CLR 将其视为全新的异常(即使您正在重用异常对象),并将重置位置信息:StackTraceTargetSite将报告错误的源自于catch块内部。这可能会导致诊断问题变得困难,因为您将无法看到其最初抛出的位置。示例 8-12 展示了如何避免此问题。

示例 8-12. 不丢失上下文而重新抛出
try
{
    DoSomething();
}
catch (IOException x)
{
    LogIOError(x);
    `throw``;`
}

除了删除警告评论之外,这与示例 8-11 的唯一区别在于,我使用throw关键字而没有指定要用作异常的对象。您只能在catch块内执行此操作,并且它会重新抛出catch块正在处理的任何异常。这意味着报告异常原始抛出位置的Exception属性仍将指向原始的抛出位置,而不是重新抛出位置。

警告

在.NET Framework 上(即,如果您不使用.NET 或.NET Core),示例 8-12 并未完全解决此问题。虽然将异常抛出的点(在此示例中发生在DoSomething方法内部的某处)将被保留,但在堆栈跟踪中显示示例 8-12 方法达到的部分将不会。而是将指示它位于包含throw的行。这有点奇怪的效果是,堆栈跟踪看起来好像是DoSomething方法被throw关键字调用。.NET Core 3.1 及更高版本不会出现此问题。

处理异常时还需要注意另一个与上下文相关的问题,可能需要重新抛出异常,这与 CLR 向 Windows 错误报告(3)(WER)提供信息的方式有关。在 Windows 上应用程序崩溃时,WER 可能会显示崩溃对话框,其中可以提供包括重新启动应用程序、向 Microsoft 报告崩溃、调试应用程序或仅终止应用程序在内的选项。除此之外,当 Windows 应用程序崩溃时,WER 会捕获多个信息片段来确定崩溃位置。对于.NET 应用程序,这包括组件失败的名称、版本和时间戳,抛出的异常类型以及异常抛出位置的信息。这些信息有时被称为bucket值。如果应用程序以相同的值崩溃两次,这两次崩溃会进入同一个 bucket 中,这意味着它们在某种意义上被认为是相同的崩溃。

从 Windows 事件日志中检索这些信息对于在您控制的计算机上运行的代码来说是很好的(或者您可能更喜欢使用更直接的方法来监视此类应用程序,例如使用 Microsoft 的应用程序洞察来收集遥测数据,此时 WER 就不是很有趣了)。WER 变得更重要的地方是那些可能在您控制之外的其他计算机上运行的应用程序,例如完全本地运行的带有 UI 的应用程序或控制台应用程序。计算机可以配置为将崩溃报告上传到错误报告服务,通常只发送 bucket 值,尽管服务可以在最终用户同意的情况下请求额外的数据。在决定如何优先修复 bug 时,bucket 分析非常有用:从最大的 bucket 开始是有意义的,因为这是您的用户最常见的崩溃情况。(或者,至少,这是由于用户未禁用崩溃报告而最经常见到的情况。我总是在我的计算机上启用这个功能,因为我希望程序中遇到的 bug 能够尽快修复。)

注意

获取累积崩溃 bucket 数据的方法取决于您正在编写的应用程序类型。对于仅在您企业内部运行的业务应用程序,您可能希望运行自己的错误报告服务器,但如果应用程序在您的管理之外运行,则可以使用 Microsoft 自己的崩溃服务器。有一个基于证书的验证过程,用于验证您有权访问数据,但一旦您通过相关的程序,Microsoft 将显示所有应用程序的已报告崩溃,按 bucket 大小排序。

某些异常处理策略可能会破坏崩溃桶系统。如果编写通用的错误处理代码,涉及所有异常,有风险,WER 将认为您的应用程序只会在该通用处理程序内部崩溃,这意味着所有类型的崩溃将进入同一个桶中。这并非不可避免,但要避免这种情况,您需要了解您的异常处理代码如何影响 WER 崩溃桶数据。

如果一个异常在未被处理时到达堆栈的顶部,WER 将准确地了解崩溃发生的确切位置,但如果在最终允许它(或其他异常)继续上升堆栈之前捕获异常,可能会出现问题。有点令人惊讶的是,即使使用示例 8-11 中显示的错误方法,.NET 也会成功保留 WER 的位置(仅从应用程序内部的.NET 视角来看,这会丢失异常上下文——StackTrace将显示重新抛出位置。因此,WER 不一定报告与.NET 代码中异常对象中看到的相同的崩溃位置)。当您将异常包装为新异常的InnerException时,情况类似:.NET 将使用该内部异常的位置作为崩溃桶值的位置。

这意味着相对容易保留 WER 桶。丢失原始上下文的唯一方法是完全处理异常(即不崩溃),或者编写一个catch块,处理异常,然后抛出一个新异常而不将原异常作为InnerException传递。

尽管示例 8-12 保留了原始上下文,但这种方法有一个限制:只能在捕获异常的块内部重新抛出异常。随着异步编程越来越普遍,异常越来越可能在某个随机工作线程上发生。我们需要一种可靠的方法来捕获异常的完整上下文,并能在随后的任意时间点重新抛出异常,可能是从不同的线程。

ExceptionDispatchInfo 类解决了这些问题。如果从catch块中调用它的静态Capture方法,并传入当前异常,它会捕获完整的上下文,包括 WER 需要的信息。Capture方法返回一个ExceptionDispatchInfo的实例。当你准备重新抛出异常时,可以调用这个对象的Throw方法,CLR 将以原始上下文完全不变地重新抛出异常。与示例 8-12 中显示的机制不同,重新抛出时不需要在catch块内部。甚至不需要在最初引发异常的线程上。

注意

如果你使用了在第 17 章中描述的asyncawait关键字,它们为你使用ExceptionDispatchInfo来确保异常上下文被正确保存。

快速失败

有些情况需要采取激烈的行动。如果检测到你的应用程序处于无法挽回的腐败状态,抛出异常可能不足够,因为总是有可能有些东西会处理它,然后试图继续。这会冒着损坏持久状态的风险——也许无效的内存状态可能会导致你的程序将错误数据写入数据库。在造成任何持久性损坏之前,最好立即退出。

Environment类提供了一个FailFast方法。如果调用此方法,CLR 将终止你的应用程序。(如果你在 Windows 上运行,它还将向 Windows 事件日志写入消息,并向 WER 提供详细信息。)你可以传递一个字符串以包含在事件日志条目中,并且还可以传递一个异常,在这种情况下,在 Windows 上将写入异常的详细信息,包括异常抛出时的 WER 桶值。

异常类型

当你的代码检测到问题并抛出异常时,你需要选择抛出哪种类型的异常。你可以定义自己的异常类型,但运行时库定义了大量的异常类型,因此在许多情况下,你可以选择已有的类型。有数百种异常类型,因此在这里列出完整列表是不合适的;如果你想看到完整的集合,可以查看Exception类的在线文档列出的派生类型。然而,有一些异常类型是重要的需要了解的。

运行时库定义了一个ArgumentException类,它是几个异常的基类,用于指示方法使用了错误的参数。示例 8-9 使用了ArgumentOutOfRangeException,而示例 8-10 间接地抛出了ArgumentNullException。基类ArgumentException定义了一个ParamName属性,其中包含提供了错误参数的名称。这对于多参数方法很重要,因为调用方需要知道哪个参数出错了。所有这些异常类型都有构造函数,允许你指定参数名,你可以在示例 8-9 中看到其中之一的使用。基类ArgumentException是一个具体类,因此如果参数以未被派生类型覆盖的方式错误,你可以直接抛出基本异常,提供问题的文本描述。

除了刚才描述的通用类型外,一些 API 定义了更专门的派生参数异常。例如,System.Globalization命名空间定义了一个称为CultureNotFoundException的异常类型,它派生自ArgumentException。你也可以做类似的事情,而有两个理由你可能想这么做。如果你可以提供关于为什么参数无效的额外信息,你将需要一个自定义异常类型,以便将该信息附加到异常上(CultureNotFoundException提供了描述其搜索文化信息方面的三个属性)。或者,可能某种形式的参数错误可以被调用者特别处理。通常,参数异常仅表示编程错误,但在可能表示环境或配置问题的情况下(例如,未安装正确的语言包),开发人员可能希望以不同方式处理该特定问题。在这种情况下使用基本的ArgumentException将不会有帮助,因为很难区分他们想要处理的特定失败和参数的任何其他问题。

有些方法可能会执行可能会产生多个错误的工作。也许你正在运行某种批处理作业,如果批处理中的某些单个任务失败,你希望中止这些任务但继续执行其余任务,并在最后报告所有失败。对于这些场景,了解AggregateException是值得的。它扩展了基本ExceptionInnerException概念,添加了一个InnerExceptions属性,返回一个异常集合。

提示

如果嵌套了可能会产生AggregateException的工作(例如,在一个批处理中运行另一个批处理),那么你可能会得到一些内部异常也是AggregateException类型。这个异常提供了一个Flatten方法,它递归地遍历任何这样的嵌套异常,并生成一个扁平的异常列表。它返回一个AggregateException,其InnerExceptions是该列表。

另一个常用的类型是InvalidOperationException。如果某人试图在其当前状态下对对象进行不支持的操作,则会抛出此异常。例如,假设你编写了一个表示可以发送到服务器的请求的类。你可能会设计成每个实例只能使用一次,因此如果请求已经发送,尝试进一步修改请求将是一个错误,这时抛出此异常就是合适的。另一个重要的例子是,如果你的类型实现了IDisposable接口,并且在被释放后有人试图使用实例,那么有一个从InvalidOperationException派生的专门类型叫做ObjectDisposedException

您应该注意NotImplementedException与听起来相似但在语义上不同的NotSupportedException之间的区别。当接口要求时,后者应该被抛出。例如,IList<T>接口定义了修改集合的方法,但不要求集合可修改——相反,它表示只读集合应该从会修改集合的成员抛出NotSupportedExceptionIList<T>的实现可以抛出这个异常并被认为是完整的,而NotImplementedException意味着有东西缺失。您最常见到的是 IDE 生成的代码——如果您要求它们生成接口实现或提供事件处理程序,它们可以创建存根方法。它们生成这些代码以便您不必输入完整的方法声明,但仍然需要您实现方法的主体,因此生成的方法将抛出此异常,以免您意外地保留空方法。

在发布之前,您通常会希望删除所有抛出NotImplementedException的代码,并替换为适当的实现。然而,有一种情况可能需要抛出它。假设您编写了一个包含抽象基类的库,并且您的客户编写了从这个基类派生的类。当您发布库的新版本时,您可以向该基类添加新方法。现在想象一下,您想要为该库添加一个新的特性,似乎应该向基类添加一个新的抽象方法。这将是一个破坏性变更——成功从旧版本类派生的现有代码将不再工作。您可以通过提供虚方法而不是抽象方法来避免这个问题,但如果您无法提供有用的默认实现怎么办?在这种情况下,您可以编写一个基本实现来抛出NotImplementedException。构建于旧版本库的代码将不会尝试使用新功能,因此永远不会尝试调用该方法。但如果客户尝试在其类中使用新库功能而没有覆盖相关方法,则会收到此异常。换句话说,这提供了一种强制要求的方式:如果您想要使用它表示的功能,则必须覆盖此方法。(当向接口添加新成员并提供默认实现时,您可以使用相同的方法。)

当然,在框架中还有其他更专业的异常情况,你应该始终尝试找到与你想要报告的问题相匹配的异常。然而,有时您需要报告的错误是运行时库没有提供合适异常的情况。在这种情况下,您将需要编写自己的异常类。

自定义异常

自定义异常类型的最低要求是它应该从Exception(直接或间接)派生。但是,还有一些设计准则。首先要考虑的是直接基类:如果你查看内置的异常类型,你会注意到其中许多只间接地通过ApplicationExceptionSystemExceptionException派生。你应该避免使用这两者。它们最初是引入的,目的是区分应用程序产生的异常和.NET 产生的异常。然而,这种区分并没有证明是有用的。某些异常在不同的场景下可能由应用程序和系统抛出,而且通常情况下,编写一个捕获所有应用程序异常但不捕获所有系统异常的处理程序是没有用的,反之亦然。类库设计准则现在告诉你不要使用这两个基础类型。

自定义异常类通常直接从Exception派生,除非它们代表某种现有异常的专门形式。例如,我们已经看到ObjectDisposedExceptionInvalidOperationException的一个特例,运行时库定义了几个更专门的派生类,如用于网络代码的ProtocolViolationException。如果你希望你的代码报告的问题明显是某种现有异常类型的例子,但仍然有必要定义一个更专门的类型,那么你应该从该现有类型派生。

虽然Exception基类有一个无参数的构造函数,但通常不应该使用它。异常应该提供有用的错误文本描述,因此你自定义异常的构造函数应该调用一个接受字符串参数的Exception构造函数。你可以在派生类中硬编码消息字符串⁴,或定义一个接受消息的构造函数,并将其传递给基类;异常类型通常提供这两种方式,尽管如果你的代码只使用其中一个构造函数,这可能是一种浪费。这取决于你的异常是否可能被其他代码抛出,还是仅仅是你自己的代码。

通常也会提供一个接受另一个异常作为参数的构造函数,这将成为InnerException属性的值。再次强调,如果你编写的异常仅供自己的代码使用,那么在需要之前添加这个构造函数没有太多意义;但如果你的异常是可重复使用的库的一部分,这是一个常见的特性。示例 8-13 展示了一个假设性的示例,提供了各种构造函数以及由异常添加的属性使用的枚举类型。

示例 8-13. 自定义异常
public class DeviceNotReadyException : InvalidOperationException
{
    public DeviceNotReadyException(DeviceStatus status)
        : this("Device status must be Ready", status)
    {
    }

    public DeviceNotReadyException(string message, DeviceStatus status)
        : base(message)
    {
        Status = status;
    }

    public DeviceNotReadyException(string message, DeviceStatus status,
                                   Exception innerException)
        : base(message, innerException)
    {
        Status = status;
    }

    public DeviceStatus Status { get; }
}

public enum DeviceStatus
{
    Disconnected,
    Initializing,
    Failed,
    Ready
}

在这里选择自定义异常的理由是,这个特定错误除了告诉我们某些东西不处于适当状态外,还提供了关于对象在操作失败时刻状态的信息。

.NET Framework 设计指南曾建议异常应该是可序列化的。从历史上看,这是为了使它们能够在应用程序域之间传递。应用程序域是一个隔离的执行上下文;然而,它们现在已被弃用,因为它们只在.NET Framework 中受支持,而在.NET Core 或.NET 中则不支持。尽管如此,在一些应用程序类型中,异常序列化仍然是有趣的,特别是基于微服务的体系结构,例如在运行于Akka.NET或 Microsoft Service Fabric 上的体系结构,在这些体系结构中,单个应用程序跨多个进程运行,通常分布在许多不同的机器上。通过使异常可序列化,你使得异常能够跨进程边界传递——原始异常对象不能直接在边界上使用,但序列化使得可以在目标进程中构建异常的副本。

因此,尽管不再建议对所有异常类型进行序列化,但对于可能在这些多进程环境中使用的异常来说,它是有用的。出于这个原因,.NET Core 和 .NET 中的大多数异常类型继续支持序列化。如果你不需要支持这一点,你的异常就不必被设计为可序列化,但由于这种情况相当常见,我将描述你需要进行的更改。首先,你需要在类声明前添加[Serializable]属性。然后,你需要重写Exception定义的一个处理序列化的方法。最后,你必须提供一个特殊的构造函数,在反序列化你的类型时使用。示例 8-14 显示了你需要添加的成员,以使示例 8-13 中的自定义异常支持序列化。GetObjectData方法简单地将异常的Status属性的当前值存储在在序列化过程中提供的名称/值容器中。它在反序列化期间调用的构造函数中检索此值。

示例 8-14. 添加序列化支持
public override void GetObjectData(SerializationInfo info,
                                   StreamingContext context)
{
    base.GetObjectData(info, context);
    info.AddValue("Status", Status);
}

protected DeviceNotReadyException(SerializationInfo info,
                               StreamingContext context)
    : base(info, context)
{
    Status = (DeviceStatus) info.GetValue("Status", typeof(DeviceStatus))!;
}

未处理的异常

早些时候,你看到控制台应用程序在你的应用程序抛出它无法处理的异常时展示的默认行为。它显示异常的类型、消息和堆栈跟踪,然后终止进程。这无论异常是在主线程上未处理,还是你明确创建的线程上未处理,甚至是 CLR 为你创建的线程池线程上未处理,都会发生这种情况。

请注意,多年来未处理异常的行为已经发生了一些变化,这些变化仍然具有一定的相关性,因为您可以选择重新启用旧的行为。在 .NET 2.0 之前,CLR 为您创建的线程会吞噬异常而不报告它们或崩溃。您偶尔可能会遇到仍依赖此行为的旧应用程序:如果应用程序有一个包含 legacyUnhandledExceptionPolicy 元素和 enabled="1" 属性的 .NET Framework 风格配置文件,则旧的 .NET 1 行为将返回,意味着未处理的异常可能会悄然消失。在 .NET 4.5 中,某一功能朝相反方向移动了一步。如果您使用 Task 类(在 第十六章 中描述)来运行并发工作,而不是直接使用线程或线程池,任何任务内的未处理异常曾经会终止进程,但自 .NET 4.5 起,默认不再如此。您可以通过配置文件恢复到旧的行为。(详见 第十六章。)

CLR 提供了一种方法来发现当未处理异常到达堆栈顶部时的情况。AppDomain 类提供了一个 UnhandledException 事件,在任何线程上发生这种情况时 CLR 将引发此事件。⁵ 我将在 第九章 中描述事件,但稍微超前一点,示例 8-15 展示了如何处理此事件。它还抛出一个未处理的异常以测试处理程序。

示例 8-15. 未处理异常通知
static void Main(string[] args)
{
    AppDomain.CurrentDomain.UnhandledException += OnUnhandledException;

    // Crash deliberately to illustrate the UnhandledException event
    throw new InvalidOperationException();
}

private static void OnUnhandledException(object sender,
    UnhandledExceptionEventArgs e)
{
    Console.WriteLine($"An exception went unhandled: {e.ExceptionObject}");
}

当处理程序收到通知时,要阻止异常已经为时过晚——CLR 在调用处理程序后不久将终止进程。这个事件存在的主要原因是提供一个放置日志代码的地方,以便您可以记录一些有关故障的信息用于诊断目的。原则上,您还可以尝试存储任何未保存的数据,以便在程序重新启动时进行恢复,但您应当小心:如果您的未处理异常处理程序被调用,则您的程序处于可疑状态,因此保存的任何数据可能无效。

一些应用程序框架提供了它们自己的处理未处理异常的方法。例如,UI 框架(如 Windows Forms 或 WPF)为 Windows 桌面应用程序做到了这一点,部分原因是默认的写入控制台的行为对不显示控制台窗口的应用程序而言并不是很有用。这些应用程序需要运行一个消息循环来响应用户输入和系统消息。它检查每个消息并可能决定调用你代码中的一个或多个方法,在这种情况下,它会将每个调用包装在try块中,以便捕获你的代码可能抛出的任何异常。框架可能会在窗口中显示错误信息。Web 框架(如 ASP.NET Core)需要不同的机制:至少,它们应该生成一个响应,指示服务器端错误的方式符合 HTTP 规范的推荐方法。

这意味着,在你的代码中出现未处理异常并逃逸时,示例 8-15 使用的UnhandledException事件可能不会被触发,因为它可能被框架捕获了。如果你正在使用应用程序框架,应检查是否提供了处理未处理异常的机制。例如,ASP.NET Core 应用程序可以在应用程序启动期间提供一个名为Use​Ex⁠ceptionHandler的方法的回调。WPF 有其自己的Application类,其DispatcherUnhandledException事件是应用的一部分。同样,Windows Forms 提供了一个Application类和一个ThreadException成员。

即使在使用这些框架时,它们的未处理异常机制也仅处理框架控制的线程上发生的异常。如果你创建一个新线程并在其上抛出一个未处理异常,它将显示在AppDomain类的UnhandledException事件中,因为框架无法控制整个 CLR。

摘要

在.NET 中,错误通常通过异常报告,除了在某些预计失败是常见且异常成本可能高于正在处理的工作成本的情况下。异常允许将错误处理代码与执行工作的代码分开。它们还使得难以忽略错误——意外错误会向上传播并最终导致程序终止并生成错误报告。catch块允许我们处理那些我们可以预期的异常。(你也可以用它们来无差别地捕获所有异常,但那通常是一个坏主意——如果你不知道为什么发生了特定的异常,你无法确定如何安全地从中恢复。)finally块提供了一种无论代码成功执行还是遇到异常都可以安全执行清理的方法。运行时库定义了许多有用的异常类型,但如果必要,我们也可以编写自己的异常类型。

在迄今为止的章节中,我们已经看过代码、类和其他自定义类型、集合以及错误处理的基本元素。还有 C# 类型系统的最后一个特性需要注意:一种特殊的对象称为 委托

¹ 严格来说,CLR 允许任何类型作为异常。但是,C# 只能抛出派生自 Exception 的类型。有些语言允许抛出其他类型的异常,但这是强烈不推荐的。C# 可以处理任何类型的异常,尽管这是因为编译器自动在其生成的每个组件上设置了 RuntimeCompatibility 属性,请求 CLR 将不派生自 Exception 的异常包装在 RuntimeWrappedException 中。

² 异常过滤器不能使用 await 关键字,关于这一点可以在第十七章中找到讨论。

³ 有些人称 WER 为一个旧的 Windows 崩溃报告机制的名字:Dr. Watson。

⁴ 您还可以考虑使用 System.Resources 命名空间中的设施查找本地化字符串,而不是将其硬编码。运行时库中的异常都这样做了。这不是强制性的,因为并非所有程序在多个地区运行,即使对于那些运行的程序,异常消息也不一定会显示给最终用户。

⁵ 虽然 .NET Core 和 .NET 不支持创建新的应用程序域,但它们仍提供 AppDomain 类,因为它公开了某些重要的特性,例如此事件。它将通过 AppDomain.CurrentDomain 提供单一实例。

第九章:委托、Lambda 和事件

使用 API 的最常见方法是调用其类提供的方法和属性,但有时需要反向操作——API 可能需要调用您的代码,这种操作通常称为回调。在第五章中,我展示了数组和列表提供的搜索功能。为了使用这些功能,我编写了一个方法,在其参数满足我的条件时返回true,相关的 API 会为每个检查的项调用我的方法。并非所有的回调都是如此即时的。异步 API 在长时间运行的工作完成时可以调用我们代码中的方法。在客户端应用程序中,我希望我的代码在用户以特定方式与某些视觉元素交互时运行,例如点击按钮。

接口和虚方法可以实现回调。在第四章中,我展示了IComparer<T>接口,它定义了一个CompareTo方法。像Array.Sort这样的方法在我们需要定制排序顺序时会调用它。您可以想象一个 UI 框架,它定义了一个IClickHandler接口,具有一个Click方法,可能还有DoubleClick。如果我们希望被通知按钮点击,框架可以要求我们实现此接口。

实际上,没有.NET 的 UI 框架使用基于接口的方法,因为当你需要多种类型的回调时,这种方法变得很麻烦。单击和双击只是用户交互的冰山一角——在 WPF 应用程序中,每个 UI 元素可以提供超过 100 种通知方式。大多数时候,您只需要处理来自任何特定元素的一个或两个事件,所以一个有 100 个方法需要实现的接口会很烦人。

将通知分散到多个接口可能会减少这种不便。默认接口实现可能会有所帮助,因为它可以提供所有回调的默认空实现,这意味着我们只需要覆盖我们感兴趣的那些。(.NET Standard 2.0 和 .NET Framework 都不支持这种语言特性,但一个针对这些目标的库可以提供一个带有虚方法的基类。) 但即使有了这些改进,这种面向对象的方法仍然存在严重的缺点。想象一个 UI 有四个按钮。在一个使用我刚才描述的方法的假设 UI 框架中,如果你希望每个按钮都有自己的点击处理程序,你需要四个不同的IClickHandler接口的实现类。一个类只能实现特定接口一次,所以你需要编写四个类。当我们真正想要做的是告诉一个按钮在点击时调用特定的方法时,这似乎非常麻烦。

C#提供了一个更简单的解决方案,即 委托,它是对方法的引用。如果你希望库为任何原因调用你的代码,通常你只需传递一个委托引用到你想让它调用的方法。我在第五章中展示了一个例子,我在示例 9-1 中重现了它。这个例子找到了一个int[]数组中第一个大于零的元素的索引。

示例 9-1. 使用委托搜索数组
public static int GetIndexOfFirstNonEmptyBin(int[] bins) =>
    Array.FindIndex(bins, IsGreaterThanZero);

private static bool IsGreaterThanZero(int value) => value > 0;

乍一看,这似乎非常简单:Array.FindIndex的第二个参数需要一个方法,它可以调用以询问特定元素是否匹配,因此我传递了我的IsGreaterThanZero方法作为参数。但是传递方法真正意味着什么,以及它如何与.NET 的类型系统,CTS 结合在一起?

委托类型

示例 9-2 显示了在示例 9-1 中使用的FindIndex方法的声明。第一个参数是要搜索的数组,但我们感兴趣的是第二个参数,那就是我传递了一个方法。

示例 9-2. 带有委托参数的方法
public static int FindIndex<T>(
      T[] array,
      `Predicate``<``T``>` `match`)

方法的第二个参数的类型是Predicate<T>,其中T是数组元素的类型,因为示例 9-1 使用了int[],所以这将是一个Predicate<int>。(如果你对形式逻辑或计算机科学没有背景的话,这种类型使用 predicate 这个词表示一个函数,用来确定某件事是真还是假。例如,你可以有一个判断一个数是否为偶数的 predicate。这些谓词经常在这种过滤操作中使用。)示例 9-3 展示了如何定义这种类型。这是整个定义,不是摘录;如果你想写一个等同于Predicate<T>的类型,那么你只需要写这些。

示例 9-3. Predicate<T>委托类型
public delegate bool Predicate<in T>(T obj);

分解示例 9-3,我们像往常一样从可访问性开始,我们可以使用所有其他类型的关键字,例如publicinternal。(像任何类型一样,委托类型可以选择地嵌套在其他类型中,在这种情况下,你也可以使用privateprotected。)接下来是delegate关键字,告诉 C#编译器我们正在定义一个委托类型。定义的其余部分看起来不偶然,就像一个方法声明。我们有一个bool的返回类型。你把委托类型的名称放在你通常看到方法名称的地方。尖括号表示这是一个具有单个类型参数T的泛型类型,并且in关键字指示T是逆变的。最后,方法签名有一个该类型的单个参数。

提示

在这里使用逆变性使您能够使用比通常所需更一般的谓词。例如,因为所有string类型的值都与object类型兼容,所以所有Predicate<object>类型的值也与Predicate<string>类型兼容。或者简单地说,如果一个 API 需要检查一个string的方法,那么如果您传递一个能够检查任何object的方法,它也能完美运行。第六章详细描述了逆变性。

委托类型在.NET 中是特殊的,并且它们的工作方式与类或结构完全不同。编译器生成一个表面上看起来正常的类型定义,其中包含各种我们稍后将详细讨论的成员,但是所有这些成员都是空的——C#不会为任何这些成员生成 IL。CLR 在运行时提供实现。

委托类型的实例通常称为委托,并且它们引用方法。如果方法的签名匹配,那么该方法与(即可由特定委托类型的实例引用)特定委托类型兼容。示例 9-1 中的IsGreaterThanZero方法接受一个int并返回一个bool,因此它与Predicate<int>兼容。匹配不必精确。如果参数类型可以进行隐式引用转换,则可以使用更一般的方法。(尽管这听起来与T逆变性的要点非常相似,但这是一个微妙不同的问题。在Predicate<T>中,T的逆变性确定了现有的Predicate<T>实例可以被转换成哪些类型。这与您是否可以从特定方法构造某个特定类型的新委托的规则是分开的:我现在描述的签名匹配规则即适用于非泛型委托,也适用于具有不变类型参数的泛型委托。)例如,一个返回类型为bool,单个参数类型为object的方法将与Predicate<object>兼容,但因为这样的方法可以接受string参数,它也将与Predicate<string>兼容。(它不会与Predicate<int>兼容,因为从intobject没有隐式引用转换。有一个隐式转换,但这是一个装箱转换,而不是引用转换。)

创建委托

创建委托的最简单方法是仅编写方法名称。示例 9-4 声明了一个变量p,并使用示例 9-1 中的IsGreaterThanZero方法对其进行初始化。(此代码要求IsGreaterThanZero在作用域内,因此我们只能在同一个类中编写此代码。)

示例 9-4. 创建委托
var p = IsGreaterThanZero;

这个示例没有提到特定需要的委托类型,这导致编译器从我将在本章后面描述的几组泛型类型中选择一个。¹ 在您无法使用这些类型的罕见情况下,它会为您定义一个类型。在这种情况下,它将使用Func<int, bool>,反映了IsGreaterThanZero是一个接受int并返回bool的方法。这是一个合理的选择,但如果我想使用Predicate<int>类型,因为我打算将其传递给Array.FindIndex,如示例 9-1 中所示,如果您不想使用编译器的默认选择,可以使用new关键字,正如示例 9-5 所示。这允许您声明类型,并在通常传递构造函数参数的地方,您可以提供兼容方法的名称。

示例 9-5. 构造委托
var p = new Predicate<int>(IsGreaterThanZero);

在实际应用中,我们很少对委托使用new关键字。只有在编译器无法推断出正确的委托类型时才是必需的。通常情况下,编译器可以从上下文中推断出正确的类型。示例 9-6 声明了一个带有显式类型的变量,因此编译器知道需要一个Predicate<int>类型 —— 我们不需要在这里使用new关键字。这将编译成与示例 9-5 相同的代码。

示例 9-6. 隐式委托构造
Predicate<int> p = IsGreaterThanZero;

这仍然明确提到了委托类型的名称,但通常我们甚至不需要这样做。示例 9-1 正确确定IsGreaterThanZero需要转换为Predicate<int>,而无需我们明确说明。编译器知道FindIndex的第二个参数是Predicate<T>,并且因为我们提供了类型为int[]的第一个参数,它推断出Tint,因此知道第二个参数的完整类型是Predicate<int>。在解决了这个问题后,它使用相同的内置隐式转换规则来构造委托,就像示例 9-6 一样。因此,当您将委托传递给方法时,编译器通常会自动确定正确的类型。

当代码像这样按名称引用方法时,该名称在技术上称为方法组,因为一个名称可能存在多个重载。编译器通过查找最佳匹配来缩小范围,类似于调用方法时如何选择重载。与方法调用一样,可能不存在匹配项或存在多个同样好的匹配项,在这些情况下,编译器会产生错误。

方法组可以采用几种形式。在迄今为止的示例中,我使用了未限定的方法名,这仅在所讨论方法在范围内时有效。如果要引用另一个类中定义的静态方法,则需要使用类名限定它,正如 Example 9-7 所示。

Example 9-7. 委托引用另一个类中的方法
internal class Program
{
    static void Main(string[] args)
    {
        `Predicate``<``int``>` `p1` `=` `Tests``.``IsGreaterThanZero``;`
        `Predicate``<``int``>` `p2` `=` `Tests``.``IsLessThanZero``;`
    }
}

internal class Tests
{
    public static bool IsGreaterThanZero(int value) => value > 0;

    public static bool IsLessThanZero(int value) => value < 0;
}

委托不必引用静态方法。它们可以引用实例方法。有几种方法可以实现这一点。一种方法是简单地从处于该方法范围内的上下文中按名称引用实例方法。Example 9-8 中的GetIsGreaterThanPredicate方法返回引用IsGreaterThan的委托。两者都是实例方法,因此只能与对象引用一起使用,但GetIsGreaterThanPredicate具有隐式的this引用,并且编译器会自动将其提供给隐式创建的委托。

Example 9-8. 隐式实例委托
public class ThresholdComparer
{
    public int Threshold { get; set; }

    public bool IsGreaterThan(int value) => value > Threshold;

    public Predicate<int> GetIsGreaterThanPredicate() => IsGreaterThan;
}

或者,您可以明确指定您想要的实例。Example 9-9 从 Example 9-8 创建了ThresholdComparer类的三个实例,然后为IsGreaterThan方法创建了三个委托,每个实例一个。

Example 9-9. 显式实例委托
var zeroThreshold = new ThresholdComparer { Threshold = 0 };
var tenThreshold = new ThresholdComparer { Threshold = 10 };
var hundredThreshold = new ThresholdComparer { Threshold = 100 };

Predicate<int> greaterThanZero = zeroThreshold.IsGreaterThan;
Predicate<int> greaterThanTen = tenThreshold.IsGreaterThan;
Predicate<int> greaterThanOneHundred = hundredThreshold.IsGreaterThan;

您不必局限于形式为*variableName*.*MethodName*的简单表达式。您可以取任何评估为对象引用的表达式,然后只需附加.*MethodName*;如果对象具有一个或多个名为*MethodName*的方法,则将其视为有效的方法组。

到目前为止,我只展示了单参数委托,但您可以定义带有任意数量参数的委托类型。例如,运行时库定义了Comparison<T>,它比较两个项目,因此需要两个参数(均为类型T)。

C#不允许您创建引用实例方法的委托,而不指定您想要的实例(隐式或显式),并且它将始终使用该实例初始化委托。

当您将委托传递给其他代码时,该代码无需知道委托的目标是静态方法还是实例方法。对于实例方法,使用委托的代码不会提供实例。引用实例方法的委托始终知道它们引用的实例以及方法。

有另一种创建委托的方式,如果你在运行时并不一定知道要使用哪个方法或对象,这种方式可能会很有用:你可以使用反射 API(我将在第十三章中详细解释)。首先,你获取一个MethodInfo,这是表示特定方法的对象。然后调用它的CreateDelegate方法,传递委托类型和必要时的目标对象。(如果你要创建一个引用静态方法的委托,就没有目标对象,因此有一个只接受委托类型的重载。)这将创建一个引用MethodInfo实例所标识的任何方法的委托。示例 9-10 使用了这种技术。它获取一个Type对象(也是反射 API 的一部分;它是引用特定类型的一种方式),表示ThresholdComparer类。接下来,它要求该对象获取表示IsGreaterThan方法的MethodInfo。然后调用它上面的Create​Dele⁠gate重载,传递委托类型和目标实例。

示例 9-10. CreateDelegate
MethodInfo m = typeof(ThresholdComparer).GetMethod("IsGreaterThan")!;
var greaterThanZero = (Predicate<int>) m.CreateDelegate(
    typeof(Predicate<int>), zeroThreshold);

还有另一种执行相同工作的方式:Delegate类型有一个静态的CreateDelegate方法,它避免了获取MethodInfo的需要。你传递两个Type对象——委托类型和定义目标方法的类型——还有方法名。如果你已经有了MethodInfo,那么最好直接使用它,但如果只有方法名,这种替代方式更加方便。

总结到目前为止,委托标识特定的函数,如果这是一个实例函数,委托还包含一个对象引用。但有些委托可以做更多的事情。

多播委托

如果你用像 ILDASM 这样的反向工程工具查看任何委托类型,² 你会看到无论是运行库提供的类型还是你自己定义的类型,它们都派生自一个称为MulticastDelegate的基类型。顾名思义,这意味着委托可以引用多个方法。这主要在通知场景中很有用,当某个事件发生时可能需要调用多个方法。然而,所有委托都支持这一点,无论你是否需要。

即使具有非void返回类型的委托也派生自MulticastDelegate。这通常没有太多意义。例如,需要Predicate<T>的代码通常会检查返回值。Array.FindIndex 使用它来判断元素是否符合搜索条件。如果单个委托引用多个方法,FindIndex应该如何处理多个返回值?事实上,它将执行所有方法,但只会返回最后一个方法的返回值。(可以编写代码为多播委托提供特殊处理,但FindIndex并未如此。)

多播功能可通过 Delegate 类的静态 Combine 方法使用。它接受任何两个委托并返回单个委托。当调用结果委托时,就像您依次调用两个原始委托一样。即使您传递给 Combine 的委托已经引用多个方法,也可以将其链接在一起形成越来越大的多播委托。如果两个参数中都引用了相同的方法,则生成的组合委托将调用它两次。

注意

委托的组合总是产生一个新的委托。而 Combine 方法不会修改您传递的任何一个委托。

实际上,我们很少显式调用 Delegate.Combine,因为 C# 内置支持组合委托。您可以使用 ++= 运算符。示例 9-11 展示了将 示例 9-9 中的三个委托组合成一个多播委托的两种方式。两个结果委托是等效的——这只是展示了两种编写相同内容的方式。这两种情况都编译成对 Delegate.Combine 的几次调用。

示例 9-11. 组合委托
Predicate<int> megaPredicate1 =
    greaterThanZero + greaterThanTen + greaterThanOneHundred;

Predicate<int> megaPredicate2 = greaterThanZero;
megaPredicate2 += greaterThanTen;
megaPredicate2 += greaterThanOneHundred;

您还可以使用 --= 运算符,这将产生一个新的委托,它是第一个操作数的副本,但是其对第二个操作数引用的方法的最后引用已被移除。正如您可能猜到的那样,这将转换为对 Delegate.Remove 的调用。

调用委托

到目前为止,我展示了如何创建一个委托,但是如果您正在编写需要调用来自调用者提供的方法的自己的 API 呢?首先,您需要选择一个委托类型。您可以使用运行时库提供的一个,或者必要时,您可以定义自己的委托类型。然后,您可以将这个委托类型用作方法参数或属性。示例 9-12 展示了当您想要调用委托引用的方法(或方法)时该怎么做。

示例 9-12. 调用委托
public static void CallMeRightBack(Predicate<int> userCallback)
{
    `bool` `result` `=` `userCallback``(``42``)``;`
    Console.WriteLine(result);
}

正如这个并不十分现实的示例所示,您可以像使用函数一样使用委托类型的参数。这也适用于局部变量、字段和属性。事实上,任何产生委托的表达式后面都可以跟随括号中的参数列表。编译器将生成调用委托的代码。如果委托具有非 void 返回类型,则调用表达式的值将是底层方法返回的值(或者,在委托引用多个方法的情况下,将是最终方法返回的值)。

尽管委托是具有运行时生成代码的特殊类型,但调用它们并没有什么神奇之处。调用单目标方法的委托的效果就好像你的代码以传统方式调用目标方法一样。调用多播委托就像依次调用其每个目标方法一样。无论哪种情况,调用都发生在同一个线程上,并且异常以与直接调用方法时完全相同的方式传播出来。

如果你想从多播委托中获取所有返回值,可以控制调用过程。委托提供了GetInvocationList方法,该方法返回一个数组,数组中包含每个原始多播委托所引用的单方法委托。如果在普通的、非多播委托上调用此方法,则该列表将只包含一个委托;但如果正在利用多播特性,则可以循环遍历数组,依次调用每个委托。

还有一种偶尔有用的调用委托的方法。基类Delegate提供了DynamicInvoke方法。你可以在任何类型的委托上调用它,而无需在编译时精确知道需要哪些参数。它接受一个object[]类型的params数组,因此你可以传递任意数量的参数。它将在运行时验证参数的数量和类型。这可以实现某些后期绑定的场景。通过dynamic关键字(在第二章中讨论)启用的内在语言特性更为全面,但由于额外的灵活性,稍微更为复杂,所以如果DynamicInvoke正好符合你的需求,那么它是更好的选择。

常见委托类型

运行时库提供了几种有用的委托类型,通常情况下你可以使用这些类型而不需要定义自己的委托。例如,有一组名为Action的泛型委托,其类型参数数量各不相同。所有这些委托都遵循一个共同的模式:对于每个类型参数,都有一个相应类型的方法参数。示例 9-13 展示了前四个委托,包括零参数形式。

示例 9-13. 前几个Action委托
public delegate void Action();
public delegate void Action<in T1>(T1 arg1);
public delegate void Action<in T1, in T2 >(T1 arg1, T2 arg2);
public delegate void Action<in T1, in T2, in T3>(T1 arg1, T2 arg2, T3 arg3);

尽管这显然是一个开放性的概念——你可以想象具有任意数量参数的这种形式的委托——但 CTS 不提供一种将此类类型定义为模式的方法,因此运行时库必须将每种形式定义为单独的类型。因此,没有Action的 200 参数形式。最大的形式有 16 个参数。

Action的明显限制是这些类型都有void返回类型,因此无法引用返回值的方法。但是有一类类似的委托类型,Func,它允许任何返回类型。示例 9-14 展示了这个家族中的前几个委托,正如你所见,它们与Action非常相似。它们只是多了一个额外的最终类型参数,TResult,用于指定返回类型。与Action<T>类似,这些委托可以有多达 16 个参数。

示例 9-14. 前几个Func委托
public delegate TResult Func<out TResult>();
public delegate TResult Func<in T1, out TResult>(T1 arg1);
public delegate TResult Func<in T1, in T2, out TResult>(T1 arg1, T2 arg2);
public delegate TResult Func<in T1, in T2, in T3, out TResult>(
    T1 arg1, T2 arg2, T3 arg3);

这些ActionFunc类型是 C#在可能的情况下用作委托表达式的自然类型。你之前在示例 9-4 中看到过这一点,在没有其他指定时,编译器选择了Func<int, bool>。它将使用Action家族来处理返回类型为void的方法。

这两类委托看起来已经涵盖了大多数需求。除非你在编写超过 16 个参数的大型方法,否则你几乎不会需要其他东西。然而,有些情况无法用泛型类型参数来表达。例如,如果你需要一个可以使用refinout参数的委托,你不能简单地写Func<bool, string, out int>。这是因为在.NET 中并没有out int这样的类型。out关键字确切地说明了参数如何传递给方法。泛型类型参数只能指定类型,无法完全传达inoutref参数之间的区别。³ 因此,在这些情况下,你必须编写一个匹配的委托类型。

定义自定义委托类型的另一个原因是你不能将ref struct作为泛型类型参数使用。(第十八章讨论了这些类型。) 因此,如果你尝试使用ref struct类型Span<int>实例化泛型Action<T>类型,例如写Action<Span<int>>,你将会得到一个编译器错误。这种限制存在是因为ref struct类型只能在某些情况下使用(它们必须始终存在于堆栈上),而无法确定任何特定的泛型类型或方法是否仅在允许的方式中使用其类型参数。(你可以想象一种新的类型参数约束来表达这一点,但在撰写本文时,还没有这样的约束存在。)因此,如果你需要一个能够引用接受ref struct参数的方法的委托类型,它必须是一个专用的非泛型委托。

注意

如果你依赖编译器确定委托表达式的自然类型(例如,你写了var m = SomeMethod;),那么这些不能使用FuncAction委托的情况就是编译器为你生成委托类型的情况。

这些限制中没有一个可以解释为什么运行库定义了一个单独的 Predicate<T> 委托类型。Func<T, bool> 完全可以很好地工作。有时这种专门的委托类型的存在是历史的偶然:许多委托类型早在添加这些通用的 ActionFunc 类型之前就存在了。但这并不是唯一的原因——即使现在也在不断添加新的委托类型。主要原因是有时定义一个专门的委托类型以指示特定语义是很有用的。

如果你有一个 Func<T, bool>,你只知道有一个接受 T 并返回 bool 的方法。但是对于 Predicate<T>,有一个暗示的含义:它对该 T 实例做出决策,并相应地返回 truefalse;并非所有接受单个参数并返回 bool 的方法都适合这种模式。通过提供 Predicate<T>,你不仅仅是说你有一个具有特定签名的方法;你在说你有一个服务于特定目的的方法。例如,HashSet<T>(在 第五章 中描述)有一个 Add 方法,接受单个参数并返回 bool,因此与 Predicate<T> 的签名匹配,但不符合语义。Add 的主要工作是执行带有副作用的操作,并返回执行信息,而断言只是告诉你关于值或对象的一些信息。

运行库定义了许多委托类型,其中大多数比 Predicate<T> 更专门化。例如,System.IO 命名空间及其派生类定义了几个与特定事件相关的委托类型,例如 SerialPinChangedEventHandler,仅在处理老式串行端口(如一度无处不在的 RS232 接口)时使用。

类型兼容性

委托类型之间不会相互派生。在 C# 中定义的任何委托类型都会直接派生自MulticastDelegate,就像运行库中的所有委托类型一样。然而,类型系统通过协变和逆变支持某些泛型委托类型的隐式引用转换。这些规则与接口的规则非常相似。正如示例 9-3 中的 in 关键字所示,Predicate<T> 中的类型参数 T 是逆变的,这意味着如果两个类型 AB 之间存在隐式引用转换,那么类型 Predicate<B>Predicate<A> 之间也存在隐式引用转换。示例 9-15 展示了由此启用的隐式转换。

示例 9-15. 委托协变性
public static bool IsLongString(object o)
{
    return o is string s && s.Length > 20;
}

static void Main(string[] args)
{
    Predicate<object> po = IsLongString;
    `Predicate``<``string``>` `ps` `=` `po``;`
    Console.WriteLine(ps("Too short"));
}

Main 方法首先创建一个引用 IsLongString 方法的 Predicate<object>。该谓词类型的任何目标方法都能检查任何类型的 object,因此,显然它能够满足需要检查字符串的代码的需求,因此隐式转换为 Predicate<string> 应该成功 —— 这得益于逆变性。协变也与接口的工作方式相同,因此通常与委托的返回类型相关联。我们使用 out 关键字表示协变类型参数。所有内置的 Func 委托类型都具有协变类型参数 TResult,表示函数的返回类型。函数参数的类型参数都是逆变的,所有 Action 委托类型的类型参数也是如此。

注意

基于变异的委托转换是隐式引用转换。这意味着当你转换引用时,结果仍然指向同一个委托实例。(所有隐式引用转换都具有这个特性,但并非所有隐式转换都是这样工作的。隐式数值转换会创建目标类型的新实例;隐式装箱转换会在堆上创建一个新的装箱。)因此,在 Example 9-15 中,pops 引用堆上的同一个委托。这与将 IsLongString 分配给两个变量的方式略有不同 —— 那会创建两个不同类型的委托。

你可能也期望看起来相同的委托是兼容的。例如,Predicate<int> 可以引用任何 Func<int, bool> 可以使用的方法,反之亦然,因此你可能期望这两种类型之间存在隐式转换。你可能会受到 C# 规范中“委托兼容性”部分的鼓励,该部分指出具有相同参数列表和返回类型的委托是兼容的(事实上,它进一步指出允许某些差异,例如,我之前提到的参数类型可能不同,只要有特定的隐式引用转换可用)。然而,如果你尝试在 Example 9-16 中的代码,它不会工作。

Example 9-16. 非法委托转换
Predicate<string> pred = IsLongString;
Func<string, bool> f = pred;  // Will fail with compiler error

添加显式强制转换也不行 —— 它会移除编译器错误,但你只会得到一个运行时错误。CTS 认为这些是不兼容的类型,因此使用一个委托类型声明的变量不能持有指向不同委托类型的引用,即使它们的方法签名是兼容的(除非涉及到两个委托类型基于相同泛型委托类型并且由于协变或逆变而兼容)。这不是 C# 委托兼容性规则设计的情况 —— 它们主要用于确定特定方法是否可以作为特定委托类型的目标。

“兼容”委托类型之间的类型不兼容可能看起来有些奇怪,但结构上相同的委托类型不一定具有相同的语义,正如我们在Predicate<T>Func<T,bool>中已经看到的。如果你发现自己需要执行这种类型的转换,这可能表明你的代码设计有些问题。⁴

语法背后

尽管只需一行代码即可定义委托类型(正如示例 9-3 所示),但编译器将其转换为定义了三个方法和一个构造函数的类型。当然,该类型还继承自其基类的成员。所有委托都派生自MulticastDelegate,尽管所有有趣的实例成员都来自其基类Delegate。(Delegate继承自object,因此委托也都具有普遍存在的object方法。)甚至GetInvocationList,一个明显面向多播的特性,也是由Delegate基类定义的。

注意

DelegateMulticastDelegate之间的分割是历史意外的毫无意义和任意结果。最初的计划是支持多播和单播委托,但在.NET 1.0 的预发布期末期间放弃了这种区分,现在所有委托类型都支持多播实例。这件事情发生得相当晚,以至于微软认为将两个基类合并为一个太过冒险,因此尽管没有任何实际目的,这种分割仍然存在。

我已经描述了Delegate定义的一些公共实例成员:DynamicInvokeGetInvocationList方法。还有两个:Method属性返回表示目标方法的MethodInfo。(第十三章描述了MethodInfo类型。)Target属性返回将作为目标方法的隐式this参数传递的对象;如果委托引用静态方法,则Target将返回null。示例 9-17 展示了委托类型的编译器生成构造函数和方法的签名。具体细节因类型而异;这些是Predicate<T>类型的生成成员。

示例 9-17. 委托类型的成员
public Predicate(object target, IntPtr method);

public bool Invoke(T obj);

public IAsyncResult BeginInvoke(T obj, AsyncCallback callback, object state);
public bool EndInvoke(IAsyncResult result);

你定义的任何委托类型都会有四个相似的成员。编译后,它们都还没有实现体。编译器只生成它们的声明,因为 CLR 会在运行时提供它们的实现。

构造函数接受目标对象(对于静态方法为null)和标识方法的IntPtr。⁵ 请注意,这不是由Method属性返回的MethodInfo。相反,这是一个函数标记,用于表示目标方法的不透明二进制标识符。CLR 可以为所有成员和类型提供二进制元数据标记,但在 C#中没有用于处理它们的语法,因此我们通常看不到它们。当你构造委托类型的新实例时,编译器会自动生成检索函数标记的 IL。委托在内部使用标记的原因是,它们比使用反射 API 类型如MethodInfo更高效。

Invoke方法是调用委托的目标方法(或方法)的方法。你可以从 C#显式地使用它,就像示例 9-18 展示的那样。它几乎与示例 9-12 完全相同,唯一的区别是委托变量后面跟着.Invoke。这生成的代码与示例 9-12 完全相同,所以是使用Invoke还是像将委托标识符视为方法名使用的语法风格问题。作为一名以前的 C++开发者,我一直觉得示例 9-12 的语法很熟悉,因为它类似于在那种语言中使用函数指针,但有人认为显式写出Invoke可以更容易地看出代码正在使用委托。

示例 9-18. 显式使用Invoke
public static void CallMeRightBack(Predicate<int> userCallback)
{
    bool result = userCallback.Invoke(42);
    Console.WriteLine(result);
}

这种显式形式的一个好处是,你可以使用空值条件运算符来处理委托变量为null的情况。示例 9-19 使用这种方法仅在提供非空参数时尝试调用。

示例 9-19. 使用空值条件运算符调用Invoke
public static void CallMeMaybe(Action<int>? userCallback)
{
    userCallback?.Invoke(42);
}

Invoke方法是委托类型方法签名的所在地。当你定义委托类型时,这是你指定的返回类型和参数列表的地方。当编译器需要检查一个特定方法是否与委托类型兼容时(例如,当你创建该类型的新委托时),编译器将Invoke方法与你提供的方法进行比较。

如 示例 9-17 所示,所有委托类型都有BeginInvokeEndInvoke方法。这些方法曾经提供了一种使用线程池的方式,但它们已被弃用,并且在当前版本的.NET 上不起作用(如果调用任一方法将导致PlatformNotSupportedException)。它们仍然在.NET Framework 上工作,但已经过时。您应该忽略这些过时的方法,而是使用 第十六章 中描述的技术。这些方法曾经流行的主要原因是它们提供了一种从一个线程传递一组值到另一个线程的简单方法 - 您可以将您需要的任何东西作为委托的参数传递。但是,C#现在有了解决这个问题的更好方式:匿名函数。

匿名函数

C#允许您创建委托而无需显式定义单独的方法。您可以编写一个特殊类型的表达式,其值为一个方法。您可以将它们视为方法表达式函数表达式,但官方名称是匿名函数。表达式可以直接作为参数传递或直接分配给变量,因此这些表达式产生的方法没有名称。 (至少在 C#中是这样。运行时要求所有方法都有名称,因此 C#为这些东西生成了隐藏的名称,但从 C#语言的角度来看,它们是匿名的。)

对于简单的方法,内联表达式的能力可以消除大量的混乱。正如我们将在 “捕获变量” 中看到的那样,编译器利用了委托不仅仅是方法的引用这一事实,以便为匿名函数提供对包含方法中作用域的任何变量的访问。

由于历史原因,C# 提供了两种定义匿名函数的方式。较旧的方式涉及delegate关键字,并在 示例 9-20 中展示。这种形式被称为匿名方法。⁶ 我将FindIndex的每个参数放在单独的行上,以突出显示匿名函数(作为第二个参数),但 C#并不要求这样做。

示例 9-20. 匿名方法语法
public static int GetIndexOfFirstNonEmptyBin(int[] bins)
{
    return Array.FindIndex(
        bins,
        `delegate` `(``int` `value``)` `{` `return` `value` `>` `0``;` `}`
    );
}

在某些方面,这类似于定义方法的普通语法。参数列表出现在括号内,后面跟着包含方法体的块(顺便说一句,它可以包含任意数量的代码块,局部变量,循环和任何其他可以放入正常方法的内容)。但是,我们没有方法名,而是关键字delegate。编译器推断返回类型。在这种情况下,FindIndex方法的签名声明第二个参数为Predicate<T>,告诉编译器返回类型必须是bool

实际上,编译器不仅仅知道返回类型。我已经传递了一个int[]数组给FindIndex,因此编译器会推断类型参数Tint,使得第二个参数成为Predicate<int>。这意味着在示例 9-20 中,我必须提供信息——委托参数的类型——而编译器已经知道。C#的后续版本引入了更紧凑的匿名函数语法,更好地利用了编译器的推断能力,如示例 9-21 所示。

示例 9-21. Lambda 语法
public static int GetIndexOfFirstNonEmptyBin(int[] bins)
{
    return Array.FindIndex(
        bins,
        `value` `=``>` `value` `>` `0`
    );
}

这种形式的匿名函数称为lambda 表达式,它是一种基于函数的计算模型的数学分支的名称。选择希腊字母 lambda (λ) 没有特别的意义。这是 1930 年代印刷技术限制的意外结果。lambda 演算的发明者 Alonzo Church 最初希望有一个不同的符号,但当他首次发表有关该主题的论文时,排版机操作员决定打印 λ,因为这是机器能产生的最接近 Church 符号的符号。尽管起源不佳,这个任意选择的术语已经变得无处不在。LISP,一个早期和有影响力的编程语言,用 lambda 来表示函数表达式,从那时起,许多语言都效仿,包括 C#。

示例 9-21 与示例 9-20 完全等价;我只是能够省略掉各种东西。=>符号明确标记这是一个 lambda 表达式,因此编译器不需要那个笨重且丑陋的delegate关键字来识别这是一个匿名函数。编译器从周围的上下文知道方法必须接受一个int,因此不需要指定参数的类型;我只提供了参数的名称:value。对于只包含单个表达式的简单方法,lambda 语法允许你省略块和return语句。这些都使得 lambda 变得非常紧凑,但在某些情况下,你可能不想省略那么多,正如示例 9-22 所示,这里有各种可选的特性。本示例中的每个 lambda 都是等效的。

示例 9-22. Lambda 变体
Predicate<int> p1 = value => value > 0;
Predicate<int> p2 = (value) => value > 0;
Predicate<int> p3 = (int value) => value > 0;
Predicate<int> p4 = value => { return value > 0; };
Predicate<int> p5 = (value) => { return value > 0; };
Predicate<int> p6 = (int value) => { return value > 0; };
Predicate<int> p7 = bool (value) => value > 0;
Predicate<int> p8 = bool (int value) => value > 0;
Predicate<int> p9 = bool (value) => { return value > 0; };
Predicate<int> pA = bool (int value) => { return value > 0; };

第一种变体是你可以在参数周围加括号。对于单个参数来说是可选的,但是对于多参数 lambda 是强制的。你还可以显式地指定参数的类型(在这种情况下,即使只有一个参数,也需要括号)。如果 lambda 返回一个值并且你喜欢的话,你还可以使用一个块而不是单个表达式,此时如果 lambda 返回一个值,你还必须使用 return 关键字。使用块的正常理由是如果你想在方法内部编写多个语句。C# 10.0 添加的最后四行展示了一种新能力:你可以显式地指定返回类型,尽管只有在参数列表在括号内时才允许这样做。

也许你会想知道为什么有这么多不同的形式——为什么不只有一种语法形式就行了呢?尽管示例 9-22 的最后一行显示了最一般的形式,但比起第一行,它也更加凌乱。由于 lambda 的目标之一是提供一个比匿名方法更简洁的替代方案,C#支持这些可以在没有歧义的情况下使用的较短形式。

你也可以编写一个不带参数的 lambda。就像示例 9-23 展示的那样,我们只需在 => 符号前面放置一个空括号对即可。(正如这个示例还展示的那样,使用大于等于运算符 >= 的 lambda 看起来可能有些奇怪,因为 =>>= 之间的无意义相似性。)

示例 9-23. 零参数 lambda
Func<bool> isAfternoon = () => DateTime.Now.Hour >= 12;

灵活而简洁的语法意味着 lambda 函数几乎取代了较老的匿名方法语法。然而,旧语法有一个优点:它允许你完全省略参数列表。在一些情况下,当你提供一个回调时,你只需要知道你等待的事情现在已经发生了。这在使用本章后面描述的标准事件模式时尤为常见,因为这要求事件处理程序即使在没有作用的情况下也接受参数。例如,当点击按钮时,除了点击了这一事实之外,没有其他太多要说的了,但是在.NET 的各种 UI 框架中,所有按钮类型都会向事件处理程序传递两个参数。示例 9-24 通过使用省略参数列表的匿名方法成功地忽略了这一点。

示例 9-24. 忽略匿名方法中的参数
EventHandler clickHandler = delegate { Debug.WriteLine("Clicked!"); };

EventHandler 是一个委托类型,要求其目标方法接受两个参数,类型分别为 objectEventArgs。如果我们的处理程序需要访问其中任何一个,当然可以添加参数列表,但匿名方法语法允许我们想省略就省略。lambda 则无法做到这一点。尽管如此,C# 10.0 增加了一个新功能,使忽略参数稍微不那么繁琐,示例 9-25 就展示了这一点。

示例 9-25. 一个丢弃其参数的 lambda
EventHandler clickHandler = (_, _) => Debug.WriteLine("Clicked!");

这与示例 9-24 具有完全相同的效果,但使用了 lambda 语法。我在括号中提供了参数列表,但因为我不想使用任何参数,所以在每个位置放置了一个下划线。这表示一个丢弃。您在早期章节的模式中看到过_字符,其意义在这里基本相似:它表明我们知道有一个可用的值;只是我们不关心它是什么,也不打算使用它。

提示

在 C# 10.0 引入对此废弃语法的支持之前,人们经常使用类似的约定。下划线符号是一个有效的标识符,因此对于单参数 lambda,没有什么可以阻止您定义一个名为_的参数并选择不引用它。对于多个参数,情况会变得奇怪,因为您不能为两个参数使用相同的名称,这意味着示例 9-25 在旧版本的 C#中无法编译。为了解决这个问题,人们只是使用多个下划线,因此您可能会看到一个以(_, __, ___) =>开头的 lambda。幸运的是,C# 10.0 允许我们在整个过程中只使用一个_

捕获的变量

虽然匿名函数在源代码中通常比完整的普通方法占用更少的空间,但它们不仅仅是简洁。C#编译器利用委托不仅能够引用方法,还能引用一些额外上下文的能力,提供了一个极其有用的功能:它可以使包含方法中的变量对匿名函数可用。示例 9-26 展示了一个返回Predicate<int>的方法。它使用一个 lambda 创建这个,该 lambda 使用包含方法中的参数。

示例 9-26. 使用包含方法中的变量
public static Predicate<int> IsGreaterThan(int threshold)
{
    return value => value > threshold;
}

这提供了与示例 9-8 中的ThresholdComparer类相同的功能,但我们只需编写一个简单的方法,而不是整个类。通过使用表达式主体方法,可以使其更加紧凑,正如示例 9-27 所示。(这可能有点过于简洁——在>附近使用两个不同的=>并排,不会为可读性赢得任何奖项。)

示例 9-27. 使用包含方法中的变量(表达式主体)
public static Predicate<int> IsGreaterThan(int threshold) =>
    value => value > threshold;

无论是哪种形式,代码都几乎看似简单至极,因此值得仔细查看其作用。IsGreaterThan方法返回一个委托实例。该委托的目标方法执行简单的比较——它评估value > threshold表达式并返回结果。该表达式中的value变量只是委托的参数——由调用IsGreaterThan返回的Predicate<int>的代码传递的int。示例 9-28 的第二行调用该代码,并将 200 作为value参数传入。

示例 9-28. value 参数的来源
Predicate<int> greaterThanTen = IsGreaterThan(10);
bool result = greaterThanTen(200);

表达式中的 threshold 变量比较棘手。这不是匿名函数的参数。它是 IsGreaterThan 的参数,而 示例 9-28 将 10 作为 threshold 参数传递。但是,在我们调用它返回的委托之前,IsGreaterThan 必须返回。由于该方法的参数已经返回,你可能会认为变量在调用委托时不再可用。事实上,这没问题,因为编译器为我们做了一些工作。如果匿名函数使用了包含方法声明的局部变量,或者使用了该方法的参数,编译器会生成一个类来保存这些变量,以便它们可以超越创建它们的方法的生命周期。编译器会在包含方法中生成代码来创建这个类的实例。(记住,每个块的调用都有自己的一组局部变量,因此如果任何局部变量被推入对象以延长它们的生命周期,每个调用都将需要一个新对象。)这也是流行神话的原因之一,该神话声称值类型的局部变量总是存储在堆栈上是不正确的——在这种情况下,编译器将传入的 threshold 参数的值复制到堆上对象的字段中,并且使用 threshold 变量的代码最终使用该字段。示例 9-29 显示了编译器为 示例 9-26 中的匿名函数生成的代码。

示例 9-29. 为匿名函数生成的代码
[CompilerGenerated]
private sealed class <>c__DisplayClass0_0
{
    public int threshold;

    public bool <IsGreaterThan>b__0(int value)
    {
        return (value > this.threshold);
    }
}

所有类和方法的名称都以 C# 标识符中非法的字符开头,以确保这些编译器生成的代码不会与我们编写的任何内容冲突——这在技术上称为 不可言说的名称。(顺便说一句,确切的名称并不固定,如果你尝试的话可能会发现它们略有不同。)这些生成的代码与 示例 9-8 中的 ThresholdComparer 类非常相似,这并不奇怪,因为目标是一样的:委托需要某种可以引用的方法,而该方法的行为取决于一个不固定的值。匿名函数不是运行时类型系统的一个特性,因此编译器必须生成一个类来提供这种行为,超越 CLR 基本委托功能的范围。

注意

局部函数(在第三章描述)也可以访问其包含方法的局部变量。通常情况下,这不会改变这些变量的生命周期,因为局部函数在其包含方法之外是不可访问的。但是,如果你创建一个引用局部函数的委托,这意味着它可能在包含方法返回后被调用,因此编译器会执行与匿名函数相同的技巧,使变量能够在外部方法返回后继续存在。

一旦你了解到在编写匿名函数时实际发生的情况,自然而然地就能知道内部方法不仅能读取变量,还能修改它。这个变量只是一个对象中的字段,两个方法——匿名函数和包含方法——都可以访问到。示例 9-30 利用这一点来维护一个从匿名函数更新的计数。

示例 9-30. 修改被捕获的变量
static void Calculate(int[] nums)
{
    int zeroEntryCount = 0;
    int[] nonZeroNums = Array.FindAll(
        nums,
        v =>
        {
            if (v == 0)
            {
                zeroEntryCount += 1;
                return false;
            }
            else
            {
                return true;
            }
        });
    Console.WriteLine($"Number of zero entries: {zeroEntryCount}");
    Console.WriteLine($"First non-zero entry: {nonZeroNums[0]}");
}

对于包含方法的所有作用域内的内容,匿名函数也同样适用。如果包含方法是一个实例方法,这还包括类型的任何实例成员,因此你的匿名函数可以访问字段、属性和方法。(编译器通过在生成的类中添加一个字段来持有 this 引用的副本来支持这一点。)编译器只在需要时将生成的类中的内容添加到类中,如示例 9-29 所示,如果不使用包含作用域的变量或实例成员,则可能生成静态方法。

前面示例中的 FindAll 方法在返回后不会保留委托——任何回调将在 FindAll 运行时发生。然而,并非所有情况都是这样的。一些 API 执行异步工作,并且将在将来的某个时刻调用你,到那时包含方法可能已经返回了。这意味着任何被匿名函数捕获的变量将比包含方法的生存周期更长。总体来说,这是可以接受的,因为所有被捕获的变量都存储在堆上的对象中,因此匿名函数并不依赖于不再存在的堆栈帧。但有一件事需要特别注意,在回调完成之前一定要显式释放资源。示例 9-31 展示了一个容易犯的错误。它使用了一个异步、基于回调的 API 来通过 HTTP 下载特定 URL 的资源。(这在 HttpClient.GetStreamAsync 返回的 Task<Stream> 上调用 ContinueWith 方法,传递一个委托,该委托将在 HTTP 响应返回后调用。这个方法是第十六章描述的任务并行库的一部分。)

示例 9-31. 过早释放
HttpClient http = GetHttpClient();
using (FileStream file = File.OpenWrite(@"c:\temp\page.txt"))
{
    http.GetStreamAsync("https://endjin.com/")
        .ContinueWith((Task<Stream> t) => t.Result.CopyToAsync(file));
} // Will probably dispose FileStream before callback runs

在此示例中的using语句将在外部方法的作用域中的file变量离开范围的地方立即处置FileStream。问题在于,这个file变量也被用在一个匿名函数中,这很可能会在执行该外部方法的线程离开该using语句的块之后运行。编译器不知道内部块将何时运行——它不知道这是否像Array.FindAll使用的同步回调或异步回调。因此,在这里它无法做任何特殊处理——它只是在块的结尾调用Dispose,因为这是我们的代码告诉它要做的事情。

注意

讨论的异步语言特性见第十七章,可以帮助避免这种问题。当您使用这些特性来消耗展现这种Task-based 模式的 API 时,编译器可以确切地知道事物保持在作用域中的时间。这使得编译器可以为您生成继续回调,并且作为其中的一部分,它可以安排一个using语句在正确的时刻调用Dispose

在性能关键的代码中,你可能需要考虑匿名函数的成本。如果匿名函数使用外部作用域的变量,那么除了创建用于引用匿名函数的委托对象之外,您可能还会创建另一个对象:用于保存共享局部变量的生成类的实例。编译器在可以时会重用这些变量持有者——例如,如果一个方法包含两个匿名函数,它们可能能够共享一个对象。即使有了这种优化,您仍然在创建额外的对象,增加了垃圾回收的压力。(而且在某些情况下,即使您从未触发创建委托的代码路径,也可能会创建此对象。)这并不是特别昂贵——通常这些对象很小——但是如果您面临特别严峻的性能问题,通过以更加冗长的方式编写来减少对象分配的数量,您可能能够稍微改善一些性能。

注意

本地函数并不总是产生相同的开销。当本地函数使用其外部方法的变量时,它并不延长其生命周期。因此,编译器不需要在堆上创建对象来保存共享变量。它仍然会创建一个类型来保存所有共享变量,但将其定义为struct,作为隐藏的in参数传递引用,从而避免了对堆块的需求。(如果创建一个引用本地函数的委托,它就不能使用此优化,而是恢复到使用匿名函数时使用的相同策略,将共享变量放在堆上的对象中。)

更微妙的是,在匿名函数中使用外部范围的局部变量将延长这些变量的生存期,这可能意味着 GC 在检测这些变量引用的对象不再使用时需要更长时间。正如您可能从第 7 章中记得的那样,CLR 分析您的代码以确定何时使用变量,以便它可以在等待引用它们的变量超出范围之前释放对象。这使得某些对象使用的内存可以显著提前回收,特别是在需要长时间完成的方法中。但是,活跃性分析仅适用于传统的局部变量。它不能应用于在匿名函数中使用的变量,因为编译器会将这些变量转换为字段。(从 CLR 的角度来看,它们根本不是局部变量。)由于 C#通常将特定范围的所有这些转换变量放入单个对象中,您会发现在方法完成并且包含这些变量的对象变得不可访问之前,这些变量引用的对象都无法被回收。这意味着在某些情况下,当您完成后使用null设置一个局部变量,可能会使得特定对象的内存在下次 GC 时被回收。 (通常,这是一个不好的建议,即使对于匿名函数也可能没有实际上有用的效果。只有在性能测试显示明显优势的情况下才应该这样做。但是,在您看到与 GC 相关的性能问题,并且您大量使用长时间运行的匿名函数的情况下,进行调查是值得的。)

避免在匿名函数中出现这些潜在的性能问题非常简单:不要使用捕获的变量。如果一个匿名函数从未尝试使用其包含范围中的任何内容,C#编译器将不会启用相应的机制,完全避免所有开销。您可以通过使用static关键字来告知编译器,您打算避免捕获变量,如示例 9-32 所示。正如普通的static方法没有对其定义类型的实例的隐式访问一样,static匿名函数也无法访问其包含范围。使用static不会改变代码生成方式 —— 任何不依赖于捕获的匿名函数都会避免所有与捕获相关的开销,无论是否标记为static。这只是要求编译器在您意外尝试使用函数包含范围中的变量时报告错误。

示例 9-32. 使用static退出变量捕获
public static Predicate<int> IsGreaterThan10() => static value => value > 10;

变量捕获有时也可能导致错误,特别是由于for循环中与子范围相关的微妙问题。(foreach循环不会出现这个问题。)示例 9-33 遇到了这个问题。

Example 9-33. for 循环中的问题变量捕获
public static void Caught()
{
    var greaterThanN = new Predicate<int>[10];
    for (int i = 0; i < greaterThanN.Length; ++i)
    {
        greaterThanN[i] = value => value > i; // Bad use of i
    }

    Console.WriteLine(greaterThanN5);
    Console.WriteLine(greaterThanN5);
}

本示例初始化了一个 Predicate<int> 委托数组,其中每个委托测试值是否大于某个数字。(顺便说一句,您不必使用数组来看到我即将描述的问题。您的循环可以将其创建的委托传递给 第十六章 中描述的某种机制,该机制通过在多个线程上运行代码来实现并行处理。但数组使得更容易展示问题。)具体来说,它将值与循环计数器 i 比较,后者决定数组中每个委托的位置,因此您可能期望索引为 5 的元素引用与 5 进行比较的方法。如果是这样,此代码将显示两次 True。实际上,它显示 True 然后是 False。结果发现,Example 9-33 生成了一个委托数组,其中每个元素都将其参数与 10 进行比较。

当人们遇到这种情况时,通常会感到惊讶。事后来看,当您知道 C# 编译器如何使匿名函数能够使用其包含作用域的变量时,很容易理解为什么会发生这种情况。for 循环声明了变量 i,因为它不仅被包含的 Caught 方法使用,还被循环创建的每个委托使用,所以编译器将会生成一个类似于 Example 9-29 中的类,并且该变量将存在于该类的一个字段中。由于变量在循环开始时进入作用域,并在循环的整个过程中保持在作用域中,编译器将创建一个该生成类的实例,并且这个实例将被所有委托共享。因此,当循环增加 i 时,这会修改所有委托的行为,因为它们都使用相同的 i 变量。

从根本上说,问题在于这里只有一个 i 变量。您可以通过在循环内部引入一个新变量来修复代码。Example 9-34 将 i 的值复制到另一个本地变量 current 中,该变量在迭代开始时才进入作用域,并在每次迭代结束时退出作用域。因此,尽管只有一个 i 变量,该变量在循环运行期间持续存在,但我们实际上在每次循环中都得到一个新的 current 变量。由于每个委托都有自己独特的 current 变量,这种修改意味着数组中的每个委托将其参数与特定迭代时循环计数器的值进行比较。

Example 9-34. 修改循环以捕获当前值
for (int i = 0; i < greaterThanN.Length; ++i)
{
    `int` `current` `=` `i``;`
    greaterThanN[i] = value => value > current;
}

编译器仍然会生成类似于 Example 9-29 中的类,用于保存内联方法和包含方法共享的 current 变量,但这一次,它会在每次循环时创建该类的新实例,以便为每个匿名函数提供该变量的不同实例。(当使用 foreach 循环时,作用域规则略有不同:其迭代变量的作用域是每次迭代的,这意味着每次循环逻辑上是变量的不同实例,因此不需要像在 for 循环中那样在循环内部添加额外变量。)

或许你会想知道,如果编写一个使用多个作用域变量的匿名函数会发生什么。Example 9-35 声明了一个名为 offset 的变量,在循环之前,并且 lambda 同时使用了那个变量以及只在一次迭代中存在的变量。

Example 9-35. 在不同作用域捕获变量
`int` `offset` `=` `10``;`
for (int i = 0; i < greaterThanN.Length; ++i)
{
    int current = i;
    `greaterThanN``[``i``]` `=` `value` `=``>` `value` `>` `(``current` `+` `offset``)``;`
}

在这种情况下,编译器会生成两个类,一个用于保存每次迭代共享变量(例如本例中的 current),另一个用于保存整个循环范围的变量(例如本例中的 offset)。每个委托的目标对象都包含内部作用域变量,并且该作用域变量包含对外部作用域的引用。

Figure 9-1 大致展示了这种工作方式,尽管它已经简化只展示了前五个项目。greaterThanN 变量包含一个对数组的引用。每个数组元素包含对委托的引用。每个委托引用同一个方法,但每个委托都有不同的目标对象,这就是每个委托如何捕获不同实例的 current 变量。每个目标对象都引用一个包含从循环外部捕获的 offset 变量的单一对象。

Figure 9-1. 委托和捕获作用域

Lambdas and Expression Trees

Lambdas 除了提供委托之外,还有一个额外的小技巧。某些 lambda 会生成表示代码的数据结构。当你在需要 Expression<T> 的上下文中使用 lambda 语法时,就会发生这种情况,其中 T 是委托类型。Expression<T> 本身不是委托类型;它是运行时库中的特殊类型(位于 System.Linq.Expressions 命名空间),触发编译器对 lambda 的替代处理。Example 9-36 就使用了这种类型。

Example 9-36. 一个 lambda 表达式
Expression<Func<int, bool>> greaterThanZero = value => value > 0;

此示例看起来与本章节中已展示的一些 lambda 和委托很相似,但编译器处理方式完全不同。它不会生成一个方法——不会有编译后的 IL 代码表示 lambda 的主体。相反,编译器会生成类似于 Example 9-37 中的代码。

示例 9-37. 编译器对 lambda 表达式的处理
ParameterExpression valueParam = Expression.Parameter(typeof(int), "value");
ConstantExpression constantZero = Expression.Constant(0);
BinaryExpression comparison = Expression.GreaterThan(valueParam, constantZero);
Expression<Func<int, bool>> greaterThanZero =
    Expression.Lambda<Func<int, bool>>(comparison, valueParam);

这段代码调用Expression类提供的各种工厂函数,为 lambda 中的每个子表达式生成一个对象。从简单的操作数开始——value参数和常量值0。这些被输入一个代表“大于”比较表达式的对象中,进而成为代表整个 lambda 表达式的对象的主体。

能够为表达式生成对象模型使得编写一个 API 成为可能,其行为由表达式的结构和内容控制。例如,某些数据访问 API 可以接受类似于示例 9-36 和 9-37 生成的表达式,并用它来生成数据库查询的一部分。我将在第十章中讨论 C#的集成查询特性,但示例 9-38 展示了 lambda 表达式如何被用作查询的基础。

示例 9-38. 表达式和数据库查询
var expensiveProducts = dbContext.Products.Where(p => p.ListPrice > 3000);

此示例恰好使用了一个名为 Entity Framework 的 Microsoft 库,但是其他各种数据访问技术也支持相同的方法。在此示例中,Where方法接受一个类型为Expression<Func<Product,bool>>的参数。⁷ Product是一个对应数据库实体的类,但这里重要的是使用了Expression<T>。这意味着编译器将生成代码,创建一个对象树,其结构对应于 lambda 表达式。Where方法处理这个表达式树,生成包含此子句的 SQL 查询:WHERE [Extent1].[ListPrice] > cast(3000 as decimal(18))。因此,尽管我将查询编写为 C#表达式,但查找匹配对象的所有工作都将在我的数据库服务器上完成。

表达式树被添加到 C#中,以作为 LINQ 的一部分来处理此类查询(在第十章中讨论)。但是,与大多数与 LINQ 相关的功能一样,也可以用于其他用途。例如,用于自动化测试的流行.NET 库称为Moq就利用了这一点。它创建接口的假实现用于测试目的,并使用 lambda 表达式提供一个简单的 API 来配置这些假实现的行为。示例 9-39 使用 Moq 的Mock<T>类创建.NET 的IEqualityComparer<string>接口的假实现。代码调用Setup方法,该方法接受一个表达式,指示我们想要为其定义特殊处理的特定调用——在本例中,如果假实现的IEqualityComparer<string>.Equals"Color""Colour"作为参数被调用,则希望它返回true

示例 9-39. Moq 库使用 lambda 表达式的例子
var fakeComparer = new Mock<IEqualityComparer<string>>();
fakeComparer
    .Setup(c => c.Equals("Color", "Colour"))
    .Returns(true);

如果Setup的参数只是一个委托,Moq 将无法检查它。但因为它是一个表达式树,Moq 能够深入其中并找出我们所要求的内容。

警告

不幸的是,表达式树是 C#中落后于语言其余部分的一个领域。它们在 C# 3.0 中引入,自那以后增加的各种语言特性,如对元组和异步表达式的支持,无法在表达式树中使用,因为对象模型无法表示它们。

事件

有时候,对象能够在有趣的事情发生时提供通知是很有用的——在客户端 UI 框架中,例如,你会想知道用户何时点击应用程序的按钮。委托提供了通知所需的基本回调机制,但你可以用许多方法来使用它们。委托应该作为方法参数传递、构造函数参数传递,还是作为属性传递?你应该如何支持取消订阅通知?CTS 通过一种特殊的类成员——事件来正式回答这些问题,并且 C#有与事件一起工作的语法。示例 9-40 展示了一个带有事件成员的类。

示例 9-40. 一个带有事件的类
public class Eventful
{
    `public` `event` `Action``<``string``>``?` `Announcement``;`

    public void Announce(string message)
    {
        Announcement?.Invoke(message);
    }
}

和所有成员一样,你可以从一个可访问性限定符开始,如果你省略了它,它将默认为private。接下来,event关键字将其单独标识为事件。然后是事件的类型,可以是任何委托类型。我使用了Action<string>,尽管你很快会看到,这是一个不正统的选择。最后,我们放置成员名称,所以这个例子定义了一个名为Announcement的事件。

要处理一个事件,你必须提供一个正确类型的委托,并且你必须使用+=语法将该委托附加为处理程序。示例 9-41 使用了一个 lambda 表达式,但你可以使用任何产生或隐式转换为事件所需类型的委托的表达式。

示例 9-41. 处理事件
var source = new Eventful();
source.Announcement += m => Console.WriteLine("Announcement: " + m);

除了定义事件之外,示例 9-40 还展示了如何引发事件——也就是说,如何调用已附加到事件的所有处理程序。它的Announce使用了相同的语法,如果Announcement是一个包含我们想要调用的委托的字段,我们将使用这个语法。实际上,就类内部代码而言,事件看起来确实像是一个字段。我选择在这里显式地使用委托的Invoke成员,而不是写Announcement(message),因为这让我可以使用空值条件运算符(?.)。这会导致编译器只在委托不为 null 时才生成调用代码。否则,我必须编写一个if语句来验证字段不为 null 才能调用它。

那么为什么我们需要一种特殊的成员类型,如果这看起来只是一个字段?好吧,它只从定义类的内部看起来像一个字段。类外的代码无法引发事件,所以在 示例 9-42 中显示的代码将无法编译。

示例 9-42. 如何不引发事件
var source = new Eventful();
source.Announcement("Will this work?"); // No, this will not even compile

从外部看,你只能对事件做两件事:使用 += 添加处理程序和使用 -= 删除处理程序。添加和删除事件处理程序的语法是不寻常的,因为这是 C# 中唯一可以使用 +=-= 而没有相应独立的 +- 运算符的情况。+=-= 对事件的操作最终都是伪装成方法调用。就像属性实际上是具有特殊语法的方法对一样,事件也是如此。它们在概念上类似于 示例 9-43 中显示的代码。(实际代码包括一些相当复杂的无锁、线程安全代码。我没有显示这些代码,因为多线程会模糊其基本意图。)这不会产生完全相同的效果,因为 event 关键字向类型添加了标识方法为事件的元数据,因此这只是用于说明的示例。

示例 9-43. 声明事件的近似效果
private Action<string>? Announcement;

// Not the actual code.
// The real code is more complex, to tolerate concurrent calls.
public void add_Announcement(Action<string> handler)
{
    Announcement += handler;
}
public void remove_Announcement(Action<string> handler)
{
    Announcement -= handler;
}

与属性类似,事件主要存在是为了提供一种方便且独特的语法,并使工具更容易知道如何呈现类提供的特性。事件对于 UI 元素尤为重要。在大多数 UI 框架中,表示交互元素的对象通常可以触发多种事件,对应不同形式的输入,例如键盘、鼠标或触摸。通常还会有与特定控件行为相关的事件,比如在列表中选择新项目。因为 CTS 定义了一种标准习语,使元素可以公开事件,因此视觉 UI 设计工具(例如内置于 Visual Studio 中的工具)可以显示可用事件并为您生成处理程序。

标准事件委托模式

示例 9-40 中的事件使用 Action<T> 委托类型,这是不寻常的,因为几乎所有事件实际上都使用符合特定模式的委托类型。该模式要求委托的方法签名具有两个参数。第一个参数的类型是 object,第二个参数的类型要么是 EventArgs,要么是从 EventArgs 派生的某种类型。示例 9-44 展示了 System 命名空间中的 EventHandler 委托类型,这是这种模式中最简单且最广泛使用的例子。

示例 9-44. EventHandler 委托类型
public delegate void EventHandler(object sender, EventArgs e);

第一个参数通常称为sender,因为事件源会将自身的引用传递给此参数。这意味着,如果你将单个委托附加到多个事件源,处理程序始终可以知道哪个源引发了特定的通知。

第二个参数提供了一个放置特定事件信息的地方。例如,WPF UI 元素定义了各种处理鼠标输入的事件,使用更专门的委托类型,例如MouseButtonEventHandler,其签名指定了一个相应的专用事件参数,提供关于事件的详细信息。例如,MouseButtonEventArgs定义了一个GetPosition方法,告诉你鼠标在按钮点击时的位置,它还定义了各种其他属性,包括ClickCountTimestamp

无论第二个参数的专用类型是什么,它始终会派生自基本的EventArgs类型。这个基本类型并不是很有趣——它除了object提供的标准成员外没有添加任何成员。然而,它确实使得可以编写一个通用方法,可以附加到使用这种模式的任何事件上。委托兼容性的规则意味着,即使委托类型指定了第二个参数类型为MouseButtonEventArgs,一个第二个参数类型为EventArgs的方法也是一个可以接受的目标。这在代码生成或其他基础设施场景中偶尔是有用的。然而,标准事件模式的主要好处仅仅是熟悉性——有经验的 C#开发人员通常期望事件能够以这种方式工作。

自定义添加和删除方法

有时,你可能不想使用 C#编译器生成的默认事件实现。例如,一个类可能定义了大量事件,其中大多数在大多数实例上都不会被使用。UI 框架经常具有这种特性。WPF UI 可以有成千上万的元素,每个元素都提供超过 100 个事件,但通常你只会给少数几个元素附加处理程序,甚至对于这些元素,你也只处理提供的少数事件。在这种情况下,让每个元素都为每个可用事件分配一个字段是低效的。

对于大量很少使用的事件,默认的基于字段的实现可能会为 UI 中的每个元素增加数百字节的占用空间,这可能会对性能产生可察觉的影响。(在典型的 WPF 应用程序中,这可能会累积到几十万字节。虽然在现代计算机的内存容量下这听起来不多,但它可能使你的代码无法有效利用 CPU 缓存,导致应用响应速度急剧下降。即使缓存的大小为几兆字节,但最快速的部分通常要小得多,而在关键数据结构中浪费几百千字节可能会对性能造成重大影响。)

如果你要避免使用默认的编译器生成的事件实现,另一个原因是你可能希望在引发事件时拥有更复杂的语义。例如,WPF 支持事件冒泡:如果一个 UI 元素不处理某些事件,这些事件将会被传递给父元素,然后是父元素的父元素,依此类推直到找到一个处理程序或达到顶部。虽然在 C#提供的标准事件实现中可以实现这种方案,但当事件处理程序相对稀少时,采用更高效的策略是可能的。

为了支持这些场景,C#允许你为事件提供自己的 add 和 remove 方法。从外部看,它看起来像一个普通的事件——使用你的类的任何人都将使用相同的+=-=语法来添加和移除处理程序——并且不可能知道它提供了自定义实现。示例 9-45 展示了一个具有两个事件的类,并使用一个共享的字典跟踪哪些对象处理了哪些事件。该方法可扩展到更多事件——字典使用对象对作为键,因此每个条目代表特定的(源,事件)对。(顺便说一句,这不是生产质量的代码。在多线程使用时不安全,当仍附有事件处理程序的ScarceEventSource实例不再使用时还会泄露内存。这个例子只是展示了自定义事件处理程序的外观;它不是一个完全工程化的解决方案。)

示例 9-45. 自定义addremove用于稀疏事件
public class ScarceEventSource
{
    // One dictionary shared by all instances of this class,
    // tracking all handlers for all events.
    // Beware of memory leaks - this code is for illustration only.
    private static readonly
     Dictionary<(ScarceEventSource, object), EventHandler> _eventHandlers
      = new();

    // Objects used as keys to identify particular events in the dictionary.
    private static readonly object EventOneId = new();
    private static readonly object EventTwoId = new();

    public event EventHandler EventOne
    {
        add
        {
            AddEvent(EventOneId, value);
        }
        remove
        {
            RemoveEvent(EventOneId, value);
        }
    }

    public event EventHandler EventTwo
    {
        add
        {
            AddEvent(EventTwoId, value);
        }
        remove
        {
            RemoveEvent(EventTwoId, value);
        }
    }

    public void RaiseBoth()
    {
        RaiseEvent(EventOneId, EventArgs.Empty);
        RaiseEvent(EventTwoId, EventArgs.Empty);
    }

    private (ScarceEventSource, object) MakeKey(object eventId) => (this, eventId);

    private void AddEvent(object eventId, EventHandler handler)
    {
        var key = MakeKey(eventId);
        _eventHandlers.TryGetValue(key, out EventHandler? entry);
        entry += handler;
        _eventHandlers[key] = entry;
    }

    private void RemoveEvent(object eventId, EventHandler handler)
    {
        var key = MakeKey(eventId);
        EventHandler? entry = _eventHandlers[key];
        entry -= handler;
        if (entry == null)
        {
            _eventHandlers.Remove(key);
        }
        else
        {
            _eventHandlers[key] = entry;
        }
    }

    private void RaiseEvent(object eventId, EventArgs e)
    {
        var key = MakeKey(eventId);
        if (_eventHandlers.TryGetValue(key, out EventHandler? handler))
        {
            handler(this, e);
        }
    }
}

自定义事件的语法与完整属性语法类似:在成员声明后添加一个块,其中包含两个成员,虽然它们称为addremove而不是getset。(与属性不同的是,你必须始终提供这两种方法。)这会禁用通常会保存事件的字段的生成,这意味着ScarceEventSource类根本没有实例字段——这种类型的实例尽可能小。

这种小内存占用的代价是复杂性显著增加;我编写的代码行数大约是使用编译器生成事件所需的 16 倍,而且为了修复前面描述的缺陷,我们可能还需要更多。此外,只有在大多数情况下事件确实没有被处理时,这种技术才会提供改进——如果我为该类的每个实例都附加了这两个事件的处理程序,那么基于字典的存储将消耗比每个类实例中简单拥有一个字段更多的内存。因此,只有在你需要非标准事件触发行为或非常确定你确实会节省内存并且节省是值得的情况下,你应该考虑这种自定义事件处理方式。

事件与垃圾回收器

就 GC 而言,委托和任何其他普通对象一样。如果 GC 发现委托实例是可达的,那么它将检查 Target 属性,以及该属性所引用的任何对象也将被视为可达,以及该对象再次引用的任何对象。虽然这没有什么显著之处,但是在某些情况下,保留事件处理程序可能导致对象在内存中持续存在,而你可能希望它们被 GC 收集。

关于委托和事件本身没有任何固有的特性使它们异常可能导致 GC 失败。如果你确实遇到与事件相关的内存泄漏,它的结构与任何其他 .NET 内存泄漏相同:从根引用开始,会有一些引用链使得对象在使用完毕后仍然可达。尽管如此,事件通常因为它们经常用于可能导致问题的方式而特别受到内存泄漏的责备。

例如,假设你的应用程序维护一些表示其状态的对象模型,而你的 UI 代码位于一个单独的层中,利用该底层模型,使其适应屏幕上的展示。通常建议采用这种分层方式——将处理用户交互的代码与实现应用逻辑的代码混合在一起是一个不好的主意。但是如果底层模型广播状态变化,UI 需要反映这些变化,则可能会出现问题。如果这些变化是通过事件广播的,那么你的 UI 代码通常会将处理程序附加到这些事件上。

现在想象有人关闭你应用程序的一个窗口。你希望表示该窗口 UI 的对象在下次 GC 运行时都被检测为不可达。UI 框架很可能已经尝试使这成为可能。例如,WPF 确保其每个 Window 类的实例在相应窗口打开时都是可达的,但一旦窗口关闭,它就停止保持对窗口的引用,以便能够收集该窗口的所有 UI 对象。

然而,如果你在主应用程序模型中处理事件,并在 Window 派生类中的方法中未显式删除该处理程序,那么你将会遇到问题。只要你的应用程序仍在运行,可能会有某个地方保持你的应用程序的底层模型可达。这意味着任何被应用程序模型的委托所持有的目标对象(例如作为事件处理程序添加的委托)将继续可达,阻止 GC 释放它们。因此,如果一个现在关闭的窗口的 Window 派生对象仍在处理来自你的应用程序模型的事件,那么该窗口及其包含的所有 UI 元素仍将可达,并且不会被垃圾回收。

注意

有一种持久的错误观念认为,这种基于事件的内存泄漏与循环引用有关。事实上,GC 完全可以处理循环引用。确实,在这些场景中通常存在循环引用,但它们不是问题的根源。问题是在你不再需要它们之后,意外地保持对象的可达性。无论是否存在循环引用,这样做都会导致问题。

你可以通过确保,如果你的 UI 层附加处理程序到长时间保持活跃的对象上,当相关的 UI 元素不再使用时,移除这些处理程序来处理这个问题。或者,你可以使用弱引用来确保,如果你的事件源是唯一持有目标引用的东西,它不会保持其活跃性。WPF 可以帮助你处理这个问题——它提供了一个 WeakEventManager 类,允许你以一种使处理对象能够在不需要取消订阅事件的情况下被垃圾回收的方式处理事件。WPF 在将 UI 数据绑定到提供属性更改通知事件的数据源时,就使用了这种技术。

注意

虽然事件相关的泄漏通常出现在用户界面中,但它们可能发生在任何地方。只要事件源仍然可达,所有附加的处理程序也将保持可达。

事件与委托

一些 API 通过事件提供通知,而其他一些直接使用委托。你应该如何决定使用哪种方法?在某些情况下,决策可能已经为你做出,因为你想支持某种特定习惯用语。例如,如果你希望你的 API 支持 C# 中的异步特性,你将需要实现 第十七章 中描述的模式,该模式使用委托而不是事件作为完成回调。另一方面,事件提供了明确的订阅和取消订阅的方式,在某些情况下将使它们成为更好的选择。约定是另一个考虑因素:如果你正在编写一个 UI 元素,事件很可能是合适的,因为那是主要的习惯用语。

在约束或惯例无法提供答案的情况下,您需要考虑回调的使用方式。如果通知会有多个订阅者,事件可能是最佳选择。这并非绝对必要,因为任何委托都能够支持多播行为,但按照惯例,这种行为通常通过事件提供。如果您的类的用户将需要在某个时候移除处理程序,事件也可能是一个不错的选择。尽管如此,如果需要更高级的功能,则IObservable接口也支持多播和取消订阅,并且可能是一个更好的选择。此接口是.NET 的响应式扩展的一部分,并在第十一章中描述。

如果只有一个目标方法才能实现,通常将委托作为方法或构造函数的参数传递。例如,如果委托类型具有非void返回值,并且 API 依赖于它(例如传递给Array.FindAll的谓词返回的bool),那么具有多个目标或零个目标是没有意义的。在这里,事件的用法不正确,因为它的订阅模型认为无论是附加零个处理程序还是多个处理程序都是完全正常的。

偶尔会出现一些场景,可能希望有零个处理程序或一个处理程序,但从不超过一个处理程序。例如,WPF 的CollectionView类可以对集合中的数据进行排序、分组和过滤。通过提供Predicate<object>来配置过滤。这不是作为构造函数参数传递的,因为过滤是可选的,所以类定义了一个Filter属性。在这里使用事件是不合适的,部分原因是Predicate<object>不符合通常的事件委托模式,但主要是因为类需要一个明确的是或否的答案,所以不希望支持多个目标。(当然,所有委托类型都支持多播,这意味着仍然可以提供多个目标。但使用属性而不是事件的决定表明在此尝试提供多个回调并不有用。)

委托与接口

在本章的开头,我提到委托比接口提供了一个更不繁琐的回调和通知机制。那么为什么一些 API 要求调用者实现接口来启用回调呢?为什么我们有IComparer<T>而不是委托?实际上,我们两者都有 —— 有一个委托类型称为Comparison<T>,许多接受IComparer<T>的 API 也支持它作为替代。数组和List<T>有重载的Sort方法,可以接受任一种类型。

在某些情况下,面向对象的方法可能比使用委托更可取。实现IComparer<T>的对象可以提供属性来调整比较的方式(例如,选择不同的排序标准)。您可能希望跨多个回调收集和汇总信息,尽管您可以通过捕获变量来实现这一点,但如果通过对象的属性在最后再次获取信息会更容易。

这实际上是由编写被调用代码的人决定的问题,而不是由编写调用代码的开发者决定的。委托更灵活,因为它允许 API 的消费者决定如何组织他们的代码,而接口则强加了约束。然而,如果接口恰好与您想要的抽象一致,委托可能会显得像是一个令人恼火的额外细节。这就是为什么一些 API 提供两种选择的原因,例如接受IComparer<T>Comparison<T>的排序 API。

如果你需要提供多个相关的回调,接口可能比委托更可取。.NET 的响应式扩展定义了一个通知的抽象,包括在事件序列结束或出现错误时知道的能力,因此在该模型中,订阅者实现一个包含三个方法的接口——OnNextOnCompletedOnError。使用接口是有道理的,因为这三种方法通常需要一起使用才能完成订阅。

总结

委托是提供对方法引用的对象,可以是静态方法或实例方法。对于实例方法,委托还保存对目标对象的引用,因此调用委托的代码不需要提供目标。委托还可以引用多个方法,尽管如果委托的返回类型不是void,这会使事情复杂化。虽然委托类型在 CLR 中得到特殊处理,但它们仍然只是引用类型,这意味着可以将委托的引用作为参数传递、从方法中返回并存储在字段、变量或属性中。委托类型为目标方法定义了一个签名。这通过类型的Invoke方法表示,但 C# 可以隐藏这一点,提供一个语法,可以直接调用委托表达式,而不必显式引用Invoke。您可以构造一个委托,引用任何具有兼容签名的方法。您还可以让 C# 为您做更多的工作——如果您使用 lambda 语法创建一个匿名函数,C# 将为您提供一个合适的声明,并且可以在幕后为内部方法使包含方法中的变量可用。委托是事件的基础,它为通知提供了一个正式的发布/订阅模型。

C# 中特别广泛使用委托的一个特性是 LINQ,这将在下一章讨论。

¹ 在 C# 10.0 之前,编译器不会为你选择,而且这个例子会产生编译器错误。如果你遇到的代码费力地指定了编译器本来会选择的委托类型,那么它很可能是在 C# 10.0 发布之前编写的。

² ILDASM 随 Visual Studio 一起提供。在撰写本文时,微软并未提供跨平台版本,但你可以使用开源项目 ILSpy

³ 你可能记得泛型类型定义可以使用inout关键字,但那是不同的。它指示泛型类型中的类型参数是反变还是协变。当你为类型参数提供具体的参数时,你不能使用inout

⁴ 或者,你可能只是自然界动态语言的爱好者之一,对通过静态类型表达语义感到过敏。如果是这样的话,C# 可能不是适合你的语言。

IntPtr 是一个通常用于不透明句柄值的值类型。在与互操作方案中有时你也会看到它 —— 在.NET 中,如果你看到一个来自操作系统 API 的原始句柄,它可能被表示为IntPtr,尽管在许多情况下,这已被SafeHandle取代。

⁶ 不幸的是,有两个相似的术语,它们几乎但不完全意味着同一件事情。C# 文档将匿名函数作为这两种方法表达式的通用术语。匿名方法可能更合适一些,因为并不是所有这些东西严格上都是函数 —— 它们可以有一个void返回值 —— 但在微软需要一个通用术语来指代这些东西时,那个名字已经被使用了。

⁷ 在这里看到Func<Product,bool>而不是Predicate<Product>可能会让你感到惊讶。Where方法是一个名为 LINQ 的 .NET 功能的一部分,该功能广泛使用委托。为了避免定义大量新的委托类型,LINQ 使用Func类型,并且为了 API 的一致性,即使其他标准类型也适用,它也更喜欢使用Func

第十章:LINQ

语言集成查询(LINQ)是一组强大的 C# 语言功能,用于处理信息集合。它在任何需要处理多个数据片段的应用程序中都很有用(即几乎所有应用程序)。尽管其最初的目标之一是提供对关系数据库的简单访问,但 LINQ 适用于许多类型的信息。例如,它还可以与内存中的对象模型、基于 HTTP 的信息服务、JSON 和 XML 文档一起使用。正如我们将在 第十一章 中看到的那样,它还可以与实时数据流一起使用。

LINQ 不是单一的功能。它依赖于多个语言元素共同工作。与 LINQ 相关的最显著的语言特性是查询表达式,这是一种类似数据库查询的表达形式,但可以用来对任何支持的源执行查询,包括普通的旧对象。正如你将看到的那样,查询表达式在很大程度上依赖于一些其他语言特性,例如 lambda 表达式、扩展方法和表达式对象模型。

语言支持只是故事的一半。LINQ 需要类库来实现一组查询原语,称为LINQ 操作符。每种不同类型的数据都需要其自己的实现,而某种类型信息的一组操作符被称为LINQ 提供者。(顺便说一句,这些也可以从 Visual Basic 和 F# 中使用,因为这些语言也支持 LINQ。)Microsoft 提供了几个提供者,其中一些内置于运行库中,一些作为独立的 NuGet 包提供。例如,Entity Framework Core 就提供了一个 LINQ 提供者,这是一个用于与数据库交互的对象/关系映射系统。Cosmos DB 云数据库(Microsoft Azure 的一个特性)也提供了一个 LINQ 提供者。还有在 .NET 反应式扩展 中描述的反应式扩展,提供了对数据实时流的 LINQ 支持。简而言之,LINQ 是.NET 中广泛支持的习语,并且它是可扩展的,因此你也会发现开源和其他第三方提供者。

本章大部分示例使用 LINQ to Objects。这部分是因为它避免了示例中的额外细节,例如数据库或服务连接,但还有一个更重要的原因。LINQ 的引入在 2007 年显著改变了我编写 C# 代码的方式,这完全是由于 LINQ to Objects。尽管 LINQ 的查询语法使它看起来主要是一种数据访问技术,但我发现它远比那更有价值。在任何对象集合上都可以使用 LINQ 的服务使其在代码的每个部分中都非常有用。

查询表达式

LINQ 最显著的特点是查询表达式语法。这并非最重要——我们稍后会看到,完全可以在不编写查询表达式的情况下有效地使用 LINQ。但是,对于许多类型的查询来说,这是一种非常自然的语法。

乍一看,查询表达式大致类似于关系数据库查询,但语法适用于任何 LINQ 提供程序。示例 10-1 展示了一个使用 LINQ to Objects 搜索特定CultureInfo对象的查询表达式。(CultureInfo对象提供了一组特定于文化的信息,如本地货币使用的符号、使用的语言等。有些系统称其为区域设置。)这个特定的查询查看了表示英文中所谓的小数点的字符。许多国家实际上使用逗号而不是句点,而在这些国家中,100,000 意味着将数字 100 写成三位小数;在英语文化中,我们通常将其写作 100.000. 查询表达式搜索系统中所有已知的文化,并返回那些使用逗号作为小数分隔符的文化。

示例 10-1. LINQ 查询表达式
`IEnumerable``<``CultureInfo``>` `commaCultures` `=`
    `from` `culture` `in` `CultureInfo``.``GetCultures``(``CultureTypes``.``AllCultures``)`
    `where` `culture``.``NumberFormat``.``NumberDecimalSeparator` `=``=` `","`
    `select` `culture``;`

foreach (CultureInfo culture in commaCultures)
{
    Console.WriteLine(culture.Name);
}

在这个例子中,foreach循环显示了查询的结果。在我的系统上,这列出了 354 个文化的名称,表明大约 813 个可用文化中有一半使用逗号而不是小数点。当然,我完全可以不使用 LINQ 来轻松实现这一点。示例 10-2 将产生相同的结果。

示例 10-2. 非 LINQ 等效方法
CultureInfo[] allCultures = CultureInfo.GetCultures(CultureTypes.AllCultures);
foreach (CultureInfo culture in allCultures)
{
    if (culture.NumberFormat.NumberDecimalSeparator == ",")
    {
        Console.WriteLine(culture.Name);
    }
}

两个示例都有八行非空代码,尽管如果忽略只包含括号的行,示例 10-2 只包含四行,比示例 10-1 少两行。另一方面,如果我们计算语句,LINQ 示例只有三个,而循环示例有四个。因此很难有说服力地论证哪种方法比另一种更简单。

然而,示例 10-1 有一个显著的优势:决定选择哪些项的代码与决定如何处理这些项的代码有很好的分离。示例 10-2 把这两个问题搞混了:选择对象的代码一半在循环外部,一半在循环内部。

另一个区别是,示例 10-1 具有更声明性的风格:它关注我们想要的内容,而不是如何获得它。查询表达式描述了我们想要的项,而不强制规定以任何特定方式实现。对于这个非常简单的例子,这并不重要,但对于更复杂的例子,特别是在使用 LINQ 提供程序进行数据库访问时,允许提供程序自由决定如何执行查询非常有用。示例 10-2 的方法遍历foreach循环中的所有内容并选择它想要的项,如果我们在与数据库交互,这种做法通常是不好的——通常希望服务器来处理这种过滤工作。

示例 10-1 中的查询有三个部分。所有查询表达式都必须以 from 子句开头,该子句指定查询的源。在本例中,源是由 CultureInfo 类的 GetCultures 方法返回的 CultureInfo[] 类型的数组。除了为查询定义源外,from 子句还包含一个名称,在这里是 culture。这称为范围变量,我们可以在查询的其余部分中使用它来代表源中的单个项目。子句可以多次运行——在 示例 10-1 中的 where 子句会针对集合中的每个项运行一次,因此范围变量每次都会有不同的值。这与 foreach 循环中的迭代变量类似。事实上,from 子句的整体结构相似——我们有一个将代表集合中的项目的变量,然后是 in 关键字,然后是该变量将代表单个项的源。正如 foreach 循环中的迭代变量仅在循环内部有效一样,范围变量 culture 仅在此查询表达式内部有效。

注意

尽管与 foreach 的类比有助于理解 LINQ 查询的意图,但不应该过于字面化。例如,并非所有的提供程序直接执行查询中的表达式。一些 LINQ 提供程序将查询表达式转换为数据库查询,在这种情况下,查询中各种表达式中的 C# 代码并不以传统意义上的方式运行。因此,虽然可以说范围变量表示源中的单个值,但并不总是可以说各个子句会针对其处理的每个项执行一次,并且范围值取该项的值。这在 示例 10-1 中是正确的,因为它使用的是 LINQ to Objects,但并非所有提供程序都是如此。

查询的第二部分在 示例 10-1 中是一个 where 子句。此子句是可选的,或者您也可以在一个查询中有多个。where 子句用于过滤结果,在本示例中说明,我只希望具有指示小数分隔符为逗号的 NumberFormatCultureInfo 对象。

查询的最后部分是 select 子句。所有查询表达式都以 select 子句或 group 子句结尾。这确定查询的最终输出。这个示例表明,我们希望保留未被查询过滤掉的每个 CultureInfo 对象。在 示例 10-1 中展示查询结果的 foreach 循环仅使用 Name 属性,因此我可以编写一个仅提取该属性的查询。如 示例 10-3 所示,如果我这样做,还需要更改循环,因为生成的查询现在会生成字符串而不是 CultureInfo 对象。

示例 10-3. 在查询中提取单个属性
IEnumerable<string> commaCultures =
    from culture in CultureInfo.GetCultures(CultureTypes.AllCultures)
    where culture.NumberFormat.NumberDecimalSeparator == ","
    `select` `culture``.``Name``;`

foreach (string cultureName in commaCultures)
{
    Console.WriteLine(cultureName);
}

这引出了一个问题:一般来说,查询表达式有什么类型?在示例 10-1 中,commaCulturesIEnumerable<CultureInfo>;在示例 10-3 中,它是IEnumerable<string>。输出项的类型由查询的最后一个子句确定——select或者在某些情况下是group子句。然而,并非所有的查询表达式都会得到IEnumerable<T>。这取决于你使用的 LINQ 提供程序——我最终得到了IEnumerable<T>,因为我使用的是 LINQ to Objects。

注意

在声明用于保存 LINQ 查询的变量时,通常使用var关键字是非常常见的。如果select子句生成匿名类型的实例,则这是必需的,因为没有办法编写结果查询类型的名称。即使不涉及匿名类型,var仍然被广泛使用,有两个原因。一个原因是一致性问题:一些人认为,因为你必须对某些 LINQ 查询使用var,所以应该对所有 LINQ 查询都使用它。另一个论点是,LINQ 查询类型通常具有冗长和丑陋的名称,而var可以使代码更清晰。在书籍布局的严格限制环境中,这可能是一个特别迫切的问题,因此在本章的许多示例中,我离开了我通常偏爱显式类型的偏好,而是使用了var来使事物更协调。

C#如何知道我想要使用 LINQ to Objects?这是因为我在from子句中使用了数组作为源。更普遍地说,当你指定任何IEnumerable<T>作为源时,LINQ to Objects 将被使用,除非存在更专门的提供程序。然而,这并不能真正解释 C#如何首先发现提供程序的存在以及如何在它们之间进行选择。要理解这一点,你需要知道编译器如何处理查询表达式。

查询表达式的扩展方式

编译器将所有的查询表达式转换成一个或多个方法调用。一旦这样做了,LINQ 提供程序通过与 C#用于任何其他方法调用相同的机制来选择。编译器没有任何内置的概念来定义什么是 LINQ 提供程序。它仅仅依赖于约定。示例 10-4 展示了编译器如何处理示例 10-3 中的查询表达式。

示例 10-4. 查询表达式的影响
IEnumerable<string> commaCultures =
    CultureInfo.GetCultures(CultureTypes.AllCultures)
    .Where(culture => culture.NumberFormat.NumberDecimalSeparator == ",")
    .Select(culture => culture.Name);

WhereSelect方法是 LINQ 操作符的示例。LINQ 操作符实际上就是符合标准模式的方法。我稍后将在“标准 LINQ 操作符”中描述这些模式。

示例 10-4 中的代码都是一个语句,并且我正在链接方法调用——我在 GetCultures 的返回值上调用 Where 方法,并在 Where 的返回值上调用 Select 方法。格式看起来有点奇怪,但它太长了,无法一行展示;尽管这不是特别优雅,但我更喜欢在多行上拆分链式调用时在每一行的开头放置 .,因为这样更容易看出每一行是从上一行的哪里继续的。把句点留在前一行的末尾看起来更整洁,但也更容易误读代码。

编译器已将 whereselect 子句的表达式转换为 lambda 表达式。注意,范围变量最终成为每个 lambda 的参数。这是一个例子,说明为什么不应过于字面地将查询表达式与 foreach 循环类比。与 foreach 迭代变量不同,范围变量并不存在作为单个常规变量。在查询中,它只是一个表示源中项的标识符,在扩展查询为方法调用时,C# 可能会为单个范围变量创建多个实际变量,就像在这里为两个分离的 lambda 的参数创建的那样。

所有查询表达式归结为这种形式——带有 lambda 的链式方法调用。(这就是为什么我们不严格需要查询表达式语法——你可以使用方法调用来写任何查询。)有些比其他的更复杂。尽管看起来几乎相同,示例 10-1 中的表达式最终具有更简单的结构。示例 10-5 展示了它是如何展开的。原来,当查询的 select 子句直接传递范围变量时,编译器解释为我们希望直接传递前一个子句的结果,而无需进一步处理,因此不会添加 Select 调用。(这有一个例外:如果编写一个查询表达式,其中只包含 fromselect 子句,它将生成一个 Select 调用,即使 select 子句是微不足道的。)

示例 10-5. select 子句如何展开
IEnumerable<CultureInfo> commaCultures =
    CultureInfo.GetCultures(CultureTypes.AllCultures)
    .Where(culture => culture.NumberFormat.NumberDecimalSeparator == ",");

如果在查询的范围内引入多个变量,编译器将需要更多工作。可以使用 let 子句来实现这一点。示例 10-6 执行与 示例 10-3 相同的作业,但我引入了一个名为 numFormat 的新变量来引用数字格式。这使得我的 where 子句更短更易读,在需要多次引用该格式对象的更复杂查询中,这种技术可以减少很多混乱。

示例 10-6. 带有 let 子句的查询
IEnumerable<string> commaCultures =
    from culture in CultureInfo.GetCultures(CultureTypes.AllCultures)
    `let` `numFormat` `=` `culture``.``NumberFormat`
    where numFormat.NumberDecimalSeparator == ","
    select culture.Name;

当你编写引入额外变量的查询时,编译器会自动生成一个隐藏类,为每个变量创建一个字段,以便在每个阶段都能使它们可用。为了在普通的方法调用中达到同样的效果,我们需要做类似的事情,引入一个匿名类型来包含它们,就像 示例 10-7 中展示的那样。

示例 10-7. 多变量查询表达式如何扩展(近似)
IEnumerable<string> commaCultures =
    CultureInfo.GetCultures(CultureTypes.AllCultures)
    .Select(culture => new { culture, numFormat = culture.NumberFormat })
    .Where(vars => vars.numFormat.NumberDecimalSeparator == ",")
    .Select(vars => vars.culture.Name);

无论查询表达式多么简单或复杂,它们都不过是方法调用的一种特殊语法。这表明了我们编写自定义查询表达式源的方法。

支持查询表达式

因为 C# 编译器只是将查询表达式的各个子句转换为方法调用,所以我们可以编写一个类型来参与这些表达式,定义一些合适的方法。为了说明 C# 编译器实际上并不关心这些方法做了什么,示例 10-8 展示了一个完全没有意义的类,但在从查询表达式中使用时,仍然能让 C# 保持愉快。编译器只是机械地将查询表达式转换为一系列方法调用,因此如果存在合适的方法,代码将成功编译。

示例 10-8. 毫无意义的 WhereSelect
public class SillyLinqProvider
{
    public SillyLinqProvider Where(Func<string, int> pred)
    {
        Console.WriteLine("Where invoked");
        return this;
    }

    public string Select<T>(Func<DateTime, T> map)
    {
        Console.WriteLine($"Select invoked, with type argument {typeof(T)}");
        return "This operator makes no sense";
    }
}

我可以使用这个类的实例作为查询表达式的源。这太疯狂了,因为这个类根本不代表数据的集合,但编译器不关心。它只需要某些方法存在,所以如果我在 示例 10-9 中编写代码,尽管代码毫无意义,编译器仍会完全满意。

示例 10-9. 一个无意义的查询
var q = from x in new SillyLinqProvider()
        where int.Parse(x)
        select x.Hour;

编译器将这些内容转换为方法调用的方式与示例 10-1 中更合理的查询完全相同。示例 10-10 展示了结果。如果你注意到了,你会发现我的范围变量在中间实际上改变了类型——我的 Where 方法需要一个接受字符串的委托,所以在第一个 Lambda 中,x 的类型是 string。但是我的 Select 方法要求它的委托接受一个 DateTime,所以在那个 Lambda 中,x 的类型就是 DateTime。 (而这些都不重要,因为我的 WhereSelect 方法甚至根本不使用这些 Lambda。)再次强调,这是无意义的,但它展示了 C# 编译器如何机械地将查询转换为方法调用。

示例 10-10. 编译器如何转换这个无意义的查询
var q = new SillyLinqProvider().Where(x => int.Parse(x)).Select(x => x.Hour);

显然,编写毫无意义的代码是没有用的。我展示这个的原因是为了演示查询表达式语法不了解语义——编译器对其调用的任何方法都没有特定的期望。它所要求的只是它们接受 lambda 作为参数并返回非void

明显,真正的工作是在别处进行的。正是 LINQ 提供程序自己使事情发生。现在我将概述,如果没有 LINQ to Objects,我们需要编写什么来使我在前面的几个示例中显示的查询工作。

你已经看到了 LINQ 查询如何转换为像示例 10-4 中显示的代码,但这并不是全部故事。where 子句变成了对 Where 方法的调用,但我们是在 CultureInfo[] 类型的数组上调用它,这种类型实际上没有 Where 方法。这只能工作,因为 LINQ to Objects 定义了一个适当的扩展方法。就像我在第 3 章中展示的那样,可以向现有类型添加新方法,LINQ to Objects 就是为 IEnumerable<T> 定义这些方法的。(由于大多数集合实现了 IEnumerable<T>,这意味着几乎可以在任何类型的集合上使用 LINQ to Objects。)要使用这些扩展方法,您需要为 System.Linq 命名空间添加一个 using 指令;在 .NET 6.0 中,新创建的项目启用了隐式全局 using功能(在“命名空间”中描述),它会自动生成适合的全局 using 指令,因此,除非您禁用了该功能,或者您的项目是在 .NET 6.0 之前创建的并且之后未启用该设置,否则您不需要自己编写指令。(顺便说一下,所有这些扩展方法都由该命名空间中名为 Enumerable 的静态类定义。)如果您尝试在没有该指令的情况下使用 LINQ,编译器会为 示例 10-1 或 示例 10-3 的查询表达式生成以下错误:

error CS1935: Could not find an implementation of the query pattern for source
type 'CultureInfo[]'.  'Where' not found.  Are you missing required assembly
references or a using directive for 'System.Linq'?

一般来说,该错误消息的建议可能很有帮助,但在这种情况下,我想编写自己的 LINQ 实现。示例 10-11 就是这样做的,我展示了整个源文件,因为扩展方法对命名空间和using指令的使用很敏感。(如果你下载这些示例,你会发现我没有为这个特定项目启用隐式全局using,这样就完全清楚发生了什么。)Main方法的内容应该看起来很熟悉——这类似于示例 10-3,但这一次,它将使用我的CustomLinqProvider类的扩展方法,而不是使用 LINQ 到对象提供程序。(通常情况下,你可以通过using指令使扩展方法可用,但由于CustomLinqProviderProgram类在同一个命名空间中,它的所有扩展方法都会自动对Main可用。)

警告

虽然示例 10-11 表现如预期,但你不应将其视为 LINQ 提供程序通常执行其查询的示例。这确实展示了 LINQ 提供程序如何置身事外,但正如我稍后将展示的那样,这段代码在执行查询时存在一些问题。而且,它相当简约——LINQ 不仅仅是WhereSelect,大多数真实的提供程序提供的不仅仅是这两个操作符。

示例 10-11. 一个用于CultureInfo[]的自定义 LINQ 提供程序
using System;
using System.Globalization;

namespace CustomLinqExample;

public static class CustomLinqProvider
{
    public static CultureInfo[] Where(this CultureInfo[] cultures,
                                        Predicate<CultureInfo> filter)
    {
        return Array.FindAll(cultures, filter);
    }

    public static T[] Select<T>(this CultureInfo[] cultures,
                                Func<CultureInfo, T> map)
    {
        var result = new T[cultures.Length];
        for (int i = 0; i < cultures.Length; ++i)
        {
            result[i] = map(cultures[i]);
        }
        return result;
    }
}

class Program
{
    static void Main(string[] args)
    {
        var commaCultures =
            from culture in CultureInfo.GetCultures(CultureTypes.AllCultures)
            where culture.NumberFormat.NumberDecimalSeparator == ","
            select culture.Name;

        foreach (string cultureName in commaCultures)
        {
            Console.WriteLine(cultureName);
        }
    }
}

正如你现在很清楚的那样,在Main中的查询表达式将首先在源上调用Where,然后在Where的返回值上调用Select。与之前一样,源是GetCultures的返回值,它是一个CultureInfo[]类型的数组。这是CustomLinqProvider定义的扩展方法的类型,因此这将调用CustomLinqProvider.Where。它使用Array类的FindAll方法来查找源数组中与谓词匹配的所有元素。Where方法将自己的参数直接传递给FindAll作为谓词,正如你所知道的,当 C#编译器调用Where时,它会传递一个基于 LINQ 查询中where子句表达式的 lambda 表达式。该谓词将匹配使用逗号作为其小数分隔符的区域设置,因此Where子句返回一个仅包含这些区域设置的CultureInfo[]类型的数组。

接下来,编译器为查询创建的代码将在Where返回的CultureInfo[]数组上调用Select。数组没有Select方法,因此将使用CustomLinqProvider中的扩展方法。我的Select方法是泛型的,因此编译器将需要推断类型参数,它可以从select子句中的表达式中推断出来。

首先,编译器将其转换为一个 lambda 表达式:culture => culture.Name。因为这成为 Select 的第二个参数,编译器知道我们需要一个 Func<CultureInfo, T>,因此它知道 culture 参数必须是 CultureInfo 类型。这使它能够推断 T 必须是 string,因为 lambda 返回 culture.Name,而 Name 属性的类型是 string。所以编译器知道它正在调用 CustomLinqProvider.Select<string>。(顺便说一句,我刚刚描述的推断并不特定于查询表达式。类型推断发生在查询被转换为方法调用之后。如果我们从 Example 10-4 中的代码开始,编译器将经历完全相同的过程。)

现在,Select 方法将生成一个 string[] 类型的数组(因为这里的 Tstring)。它通过迭代传入的 CultureInfo[] 中的元素,将每个 CultureInfo 作为参数传递给提取 Name 属性的 lambda 表达式来填充该数组。因此,我们最终得到一个包含每个使用逗号作为其十进制分隔符的文化的名称的字符串数组。

这个例子比我的 SillyLinqProvider 稍微现实一些,因为它现在提供了预期的行为。然而,虽然查询产生了与使用真正的 LINQ to Objects 提供程序时相同的字符串,但它所采用的机制有些不同。我的 CustomLinqProvider 立即执行了每个操作——WhereSelect 方法都返回完全填充的数组。LINQ to Objects 做了完全不同的事情。实际上,大多数 LINQ 提供程序也是如此。

延迟评估

如果 LINQ to Objects 的工作方式与我在 Example 10-11 中的自定义提供程序相同,它将无法很好地处理 Example 10-12。这有一个 Fibonacci 方法,返回一个永无止境的序列——只要代码继续请求,它将继续提供斐波那契数列中的数字。我已经使用这个方法返回的 IEnumerable<BigInteger> 作为查询表达式的源。由于我们在开头附近放置了 System.Linqusing 指令,我现在回到使用 LINQ to Objects。 (在可下载的示例中,我已禁用了该项目的隐式全局 using 指令,以清楚地了解使用了哪些命名空间。)

Example 10-12. 具有无限源序列的查询
using System;
using System.Collections.Generic;
using System.Linq;
using System.Numerics;

static IEnumerable<BigInteger> Fibonacci()
{
    BigInteger n1 = 1;
    BigInteger n2 = 1;
    yield return n1;
    while (true)
    {
        yield return n2;
        BigInteger t = n1 + n2;
        n1 = n2;
        n2 = t;
    }
}

var evenFib = from n in Fibonacci()
              where n % 2 == 0
              select n;

foreach (BigInteger n in evenFib)
{
    Console.WriteLine(n);
}

这里将使用 LINQ to Objects 为 IEnumerable<T> 提供的 Where 扩展方法。如果它的工作方式与我在 示例 10-11 中为 CultureInfo[] 编写的 CustomLinqExtension 类的 Where 方法相同,那么这个程序将永远无法显示单个数字。我的 Where 方法在未过滤其整个输入并生成完全填充的数组作为输出之前不会返回。如果 LINQ to Objects 的 Where 方法尝试对我的无限斐波那契枚举器进行相同的操作,它将永远无法完成。

实际上,示例 10-12 运行完美——它产生了一系列由 2 整除的斐波那契数输出。这意味着在我们调用 Where 时它不会尝试执行所有的过滤工作。相反,它的 Where 方法返回一个 IEnumerable<T>,按需过滤项目。它不会尝试从输入序列获取任何内容,直到某些东西要求一个值,此时它将从源中一个接一个地检索值,直到过滤委托说找到匹配项。然后它会生成该匹配项,并在被要求下一个项目之前不会再从源中检索任何内容。示例 10-13 展示了如何利用 C# 的 yield return 特性实现这种行为。

示例 10-13. 自定义延迟 Where 操作符
public static class CustomDeferredLinqProvider
{
    public static IEnumerable<T> Where<T>(this IEnumerable<T> src,
                                          Func<T, bool> filter)
    {
        foreach (T item in src)
        {
            if (filter(item))
            {
                yield return item;
            }
        }
    }
}

真正的 LINQ to Objects 的 Where 实现要复杂一些。它检测到某些特殊情况,比如数组和列表,并以略微比通用实现更高效的方式处理它们。然而,对于 Where 和所有其他操作符来说,原则都是一样的:这些方法不执行指定的工作。相反,它们返回将按需执行工作的对象。只有在尝试检索查询结果时才会真正发生任何事情。这被称为延迟评估,有时也称为惰性评估

延迟评估的好处是在需要时才执行工作,并且可以处理无限序列。然而,它也有缺点。您可能需要小心避免多次评估查询。示例 10-14 犯了这个错误,导致执行比必要做的工作多得多。这段代码循环遍历几个不同的数字,并使用每个使用逗号作为小数分隔符的文化的货币格式写出每一个数字。

注意

如果您在 Windows 上运行此代码,则可能会发现大多数行显示的是包含?字符的行,表示控制台无法显示大多数货币符号。实际上,它可以——只是需要权限。默认情况下,Windows 控制台出于向后兼容性的原因使用 8 位代码页。如果您从命令提示符运行命令chcp 65001,它将将该控制台窗口切换到 UTF-8 代码页,从而使其能够显示您选择的控制台字体支持的任何 Unicode 字符。您可能希望配置控制台使用具有对不常见字符全面支持的字体——例如 Consolas 或 Lucida Console,以充分利用它。

示例 10-14. 延迟查询的意外重新评估
var commaCultures =
    from culture in CultureInfo.GetCultures(CultureTypes.AllCultures)
    where culture.NumberFormat.NumberDecimalSeparator == ","
    select culture;

object[] numbers = { 1, 100, 100.2, 10000.2 };

foreach (object number in numbers)
{
    foreach (CultureInfo culture in commaCultures)
    {
        Console.WriteLine(string.Format(culture, "{0}: {1:c}",
                          culture.Name, number));
    }
}

此代码的问题在于,即使commaCultures变量在数字循环外部初始化,我们也会为每个数字迭代它。由于 LINQ to Objects 使用延迟评估,这意味着每次外部循环周围都会重新执行查询的实际工作。因此,而不是为每种文化(在我的系统上为 813 次)评估一次where子句,它最终为每种文化执行四次(3,252 次),因为整个查询针对numbers数组的四个项之一每次都会评估。这并不是灾难性的——代码仍然能正常工作。但是,如果您在运行在负载较重的服务器上的程序中执行此操作,它将损害您的吞吐量。

如果您知道您将需要多次迭代查询的结果,请考虑使用 LINQ to Objects 提供的ToListToArray扩展方法之一。这些方法立即评估整个查询一次,分别生成IList<T>T[]数组(因此显然不应在无限序列上使用这些方法)。然后,您可以随意多次迭代它,而不会产生进一步的成本(除了读取数组或列表元素所固有的最小成本)。但在只迭代一次查询的情况下,通常最好不要使用这些方法,因为它们将比必要的消耗更多内存。

LINQ、泛型和 IQueryable

大多数 LINQ 提供程序使用泛型类型。虽然没有强制要求这样做,但这种做法非常普遍。LINQ to Objects 使用IEnumerable<T>。几个数据库提供程序使用称为IQueryable<T>的类型。更广泛地说,模式是具有某种泛型类型*Source*<T>,其中*Source*表示某些项目的来源,而T是单个项目的类型。具有 LINQ 支持的源类型使得操作方法在*Source*<T>上对任何T都可用,并且这些操作符通常还返回*Source*<TResult>,其中TResult可能与T不同。

IQueryable<T>很有趣,因为它设计用于多个提供程序使用。在示例 10-15 中显示了这个接口、它的基本IQueryable和相关的IQueryProvider

示例 10-15. IQueryableIQueryable<T>
public interface IQueryable : IEnumerable
{
    Type ElementType { get; }
    Expression Expression { get; }
    IQueryProvider Provider { get; }
}

public interface IQueryable<out T> : IEnumerable<T>, IQueryable
{
}

public interface IQueryProvider
{
    IQueryable CreateQuery(Expression expression);
    IQueryable<TElement> CreateQuery<TElement>(Expression expression);
    object? Execute(Expression expression);
    TResult Execute<TResult>(Expression expression);
}

IQueryable<T> 最明显的特点是,它不向其基类添加任何成员。这是因为它完全通过扩展方法来使用。Sys⁠tem.​Li⁠nq 命名空间定义了所有标准 LINQ 操作符,这些操作符是由 Queryable 类提供的扩展方法,适用于 IQueryable<T>。然而,所有这些操作符都简单地推迟到由 IQueryable 基类定义的 Provider 属性。因此,与 LINQ to Objects 不同,在那里,IEnumerable<T> 上的扩展方法定义了行为,IQueryable<T> 的实现能够决定如何处理查询,因为它可以提供执行实际工作的 IQueryProvider

然而,所有基于 IQueryable<T> 的 LINQ 提供程序有一个共同点:它们将 lambda 解释为表达式对象,而不是委托。Example 10-16 展示了为 IEnumerable<T>IQueryable<T> 定义的 Where 扩展方法的声明。比较 predicate 参数。

示例 10-16. Enumerable versus Queryable
public static class Enumerable
{
    public static IEnumerable<TSource> Where<TSource>(
        this IEnumerable<TSource> source,
        `Func``<``TSource``,` `bool``>` `predicate``)`
    ...
}

public static class Queryable
{
    public static IQueryable<TSource> Where<TSource>(
        this IQueryable<TSource> source,
        `Expression``<``Func``<``TSource``,` `bool``>``>` `predicate``)`
    ...
}

IEnumerable<T>Where 扩展方法(LINQ to Objects)接受 Func<TSource, bool>,如您在 Chapter 9 中所见,这是一种委托类型。但是 IQueryable<T>Where 扩展方法(许多 LINQ 提供程序使用)接受 Exp⁠res⁠sion​<Fu⁠nc<T⁠Sou⁠rce,⁠ bool>>,正如您在 Chapter 9 中也看到的,这会导致编译器构建表达式的对象模型并将其作为参数传递。

如果 LINQ 提供程序需要这些表达式树,通常会使用 IQueryable<T>。这通常是因为它将检查您的查询并将其转换为其他形式,例如 SQL 查询。

在 LINQ 中还有一些其他常见的泛型类型。一些 LINQ 特性保证按特定顺序生成项目,而另一些则不保证。更微妙的是,一些运算符生成的项目顺序取决于其输入的顺序。这可以反映在定义运算符的类型和它们返回的类型中。LINQ to Objects 定义了 IOrderedEnumerable<T> 来表示有序数据,而对于基于 IQueryable<T> 的提供程序,则有相应的 IOrderedQueryable<T> 类型。(使用自己类型的提供程序往往会做类似的事情——例如,Parallel LINQ 在 Chapter 16 中定义了 Ord⁠ered​Par⁠all⁠elQ⁠uery<T>。)这些接口从它们的无序对应接口派生,如 IEnumerable<T>IQueryable<T>,因此所有常见的运算符都是可用的,但它们使得定义需要考虑其输入顺序的运算符或其他方法成为可能。例如,在 “Ordering” 中,我将展示一个称为 ThenBy 的 LINQ 运算符,该运算符仅在已经有序的源上可用。

在查看 LINQ to Objects 时,有序/无序的区分可能看起来是不必要的,因为 IEnumerable<T> 总是以某种顺序生成项目。但某些提供程序并不一定以任何特定的顺序进行操作,也许是因为它们并行执行查询,或者因为它们让数据库为它们执行查询,并且在某些情况下,数据库保留在启用更有效地工作时干预顺序的权利。

标准 LINQ 操作符

在本节中,我将描述 LINQ 提供程序可以提供的标准操作符。在适用的情况下,我还将描述查询表达式的等效形式,尽管许多操作符没有相应的查询表达式形式。一些 LINQ 功能只能通过显式方法调用来使用。即使在某些可以在查询表达式中使用的操作符中也是如此,因为大多数操作符都是重载的,而查询表达式无法使用一些更高级的重载形式。

注意

LINQ 操作符不是通常 C# 中的符号运算符,它们不是如 +&& 的符号。LINQ 有自己的术语,对于本章而言,操作符是 LINQ 提供程序提供的查询功能。在 C# 中,它看起来像是一个方法。

所有这些操作符都有一个共同点:它们都设计用于支持组合。这意味着您几乎可以以任何方式组合它们,从而能够从简单元素构建复杂查询。为了实现这一点,操作符不仅接受某种类型的项目集合(例如 IEnumerable<T>)作为它们的输入,而且大多数操作符还返回某种代表项目集合的结果。如前所述,项目类型并不总是相同的——一个操作符可能会以某种 IEnumerable<T> 作为输入,并生成 IEnumerable<TResult> 作为输出,其中 TResult 不必与 T 相同。尽管如此,您仍然可以以任意数量的方式将它们链接在一起。这样做的部分原因是 LINQ 操作符类似于数学函数,它们不会修改它们的输入;相反,它们会基于它们的操作数产生一个新的结果。 (函数式编程语言通常具有相同的特征。)这意味着不仅您可以自由地以任意组合方式连接操作符,而且您还可以自由地将同一源用作多个查询的输入,因为没有任何 LINQ 查询会修改其输入。每个操作符都返回基于其输入的新查询。

没有任何东西强制执行这种函数式风格。就像您在我的 SillyLinqProvider 中看到的那样,编译器并不关心表示 LINQ 操作符的方法做了什么。但是,约定是操作符应该是函数式的,以支持组合。内置的 LINQ 提供程序都是这样工作的。

并非所有提供商都全面支持所有操作符。微软提供的主要支持包括 LINQ to Objects 或 Entity Framework Core 和 Rx 中的 LINQ 支持,尽可能全面,但某些情况下,某些操作符可能无意义。

为了演示操作符的作用,我需要一些源数据。以下部分的许多示例将使用 示例 10-17 中的代码。

示例 10-17. LINQ 查询的示例输入数据
public record Course(
    string Title,
    string Category,
    int Number,
    DateOnly PublicationDate,
    TimeSpan Duration)
{
    public static readonly Course[] Catalog =
    {
            new Course(
                Title: "Elements of Geometry",
                Category: "MAT", Number: 101, Duration: TimeSpan.FromHours(3),
                PublicationDate: new DateOnly(2009, 5, 20)),
            new Course(
                Title: "Squaring the Circle",
                Category: "MAT", Number: 102, Duration: TimeSpan.FromHours(7),
                PublicationDate: new DateOnly(2009, 4, 1)),
            new Course(
                Title: "Recreational Organ Transplantation",
                Category: "BIO", Number: 305, Duration: TimeSpan.FromHours(4),
                PublicationDate: new DateOnly(2002, 7, 19)),
            new Course(
                Title: "Hyperbolic Geometry",
                Category: "MAT", Number: 207, Duration: TimeSpan.FromHours(5),
                PublicationDate: new DateOnly(2007, 10, 5)),
            new Course(
                Title: "Oversimplified Data Structures for Demos",
                Category: "CSE", Number: 104, Duration: TimeSpan.FromHours(2),
                PublicationDate: new DateOnly(2021, 11, 8)),
            new Course(
                Title: "Introduction to Human Anatomy and Physiology",
                Category: "BIO", Number: 201, Duration: TimeSpan.FromHours(12),
                PublicationDate: new DateOnly(2001, 4, 11)),
        };
}

过滤器

最简单的操作符之一是 Where,它用于过滤其输入。你提供一个谓词,即一个接受单个项目并返回 bool 的函数。Where 返回一个表示输入中谓词为 true 的项目的对象。(从概念上讲,这与 List<T> 和数组类型上可用的 FindAll 方法非常相似,但使用延迟执行。)

正如你已经看到的,查询表达式使用 where 子句表示这一点。然而,Where 操作符有一种重载,提供了一个从查询表达式中无法访问的额外功能。你可以编写一个过滤器 lambda,它接受两个参数:输入中的一个项目和表示该项目在源中位置的索引。示例 10-18 使用此形式从输入中移除每隔一个数字,并且还移除长度小于三小时的课程。

示例 10-18. 带索引的 Where 操作符
IEnumerable<Course> q = Course.Catalog.Where(
    (course, index) => (index % 2 == 0) && course.Duration.TotalHours >= 3);

带索引的过滤只对有序数据有意义。它在 LINQ to Objects 中总是有效,因为它使用的是 IEnumerable<T>,会一个接一个地生成项目,但并非所有 LINQ 提供程序都按顺序处理项目。例如,使用 Entity Framework Core (EF Core),你在 C# 中编写的 LINQ 查询将在数据库上处理。除非查询明确要求特定顺序,否则数据库通常可以自由地按照它认为合适的顺序处理项目,甚至可能并行处理。在某些情况下,数据库可能有优化策略,使其能够使用与原始查询极为不同的过程生成查询所需的结果。因此,说“由 WHERE 子句处理的第 14 个项目”可能并无意义。因此,如果你像 示例 10-18 类似的查询在 EF Core 中执行,将会引发异常,指出索引的 Where 操作符不适用。如果你想知道为什么提供程序不支持却还存在重载,因为 EF Core 使用 IQueryable<T>,所以所有标准操作符在编译时都是可用的;选择使用 IQueryable<T> 的提供程序只能在运行时报告操作符的不可用性。

注意

实现部分或全部查询逻辑在服务器端的 LINQ 提供程序通常限制您可以在查询的 Lambda 中执行的操作。相反,LINQ 到对象在进程中运行查询,因此允许您在过滤 Lambda 中调用任何方法—如果您想在谓词中调用Console.WriteLine或从文件中读取数据,LINQ 到对象无法阻止您。但是数据库提供程序仅提供非常有限的方法选择。这些提供程序需要能够将您的 Lambda 表达式转换为服务器可以处理的内容,并且会拒绝尝试调用没有服务器端等效方法的表达式。

尽管如此,您可能期望异常在调用Where时出现,而不是在尝试执行查询时(即当您首次尝试检索一个或多个项目时)。然而,将 LINQ 查询转换为其他形式(例如 SQL 查询)的提供程序通常会推迟所有验证直到您执行查询。这是因为某些操作符可能仅在特定情况下有效,这意味着提供程序可能不知道任何特定操作符是否有效,直到您完成整个查询的构建。如果由于不可行查询而导致的错误有时在构建查询时出现,有时在执行时出现,这将是不一致的,因此即使在提供程序可以较早确定特定操作符将失败的情况下,通常也会等到执行查询时才告知您。

您提供给Where操作符的过滤 Lambda 必须接受项目类型的参数(例如IEnumerable<T>中的T),并且必须返回bool类型。您可能还记得第九章中运行时库定义了一个称为Predicate<T>的适当委托类型,但我在该章节中还提到 LINQ 避免使用它,现在我们可以看到原因了。Where操作符的索引版本不能使用Predicate<T>,因为有额外的参数,因此该重载使用Func<T, int, bool>。没有什么阻止非索引形式的Where使用Predicate<T>,但 LINQ 提供程序倾向于全面使用Func以确保具有类似意义的操作符具有类似的签名。因此,大多数提供程序使用Func<T, bool>,以与索引版本保持一致。(C#不在乎您使用哪个—如果提供程序使用Predicate<T>,查询表达式仍然有效,如我在示例 10-11 中展示的自定义Where操作符,但微软的提供程序没有这样做。)

警告

C# 编译器的空值分析并不理解 LINQ 运算符。如果你有一个 IEnumerable<string?>,你可以写 xs.Where(s => s is not null) 来移除任何空值,但是 Where 仍然会返回一个 IEnumerable<string?>。编译器对 Where 的行为没有预期,因此它不理解输出实际上是一个 IEnumerable<string>。可以说让编译器做这样的推断可能是一个错误:就像 示例 10-8 中展示的那样,可以提供一个违反预期的 Where

LINQ 定义了另一个过滤运算符:OfType<T>。如果您的源包含不同类型的项目混合——可能源是 IEnumerable<object>,您想将其过滤为仅为 string 类型的项目。 示例 10-19 展示了 OfType<T> 运算符如何实现这一点。

示例 10-19. OfType<T> 运算符
static void ShowAllStrings(IEnumerable<object> src)
{
    foreach (string s in src.OfType<string>())
    {
        Console.WriteLine(s);
    }
}

当你使用 OfType<T> 运算符与引用类型一起使用时,它将过滤掉任何 null 值。如果你启用了可空引用类型,OfType 避免了 Where(s => s is not null) 遇到的问题:如果你在 IEnumerable<string?> 上调用 OfType<string>,结果类型将是 IEnumerable<string>。但这并不是因为 OfType 被设计时考虑了可空引用类型。相反,它在使用引用类型作为类型参数时有效地忽略了空值。它之所以在这种情况下做我们想要的事情,是因为它总是寻找积极的匹配(它实际上执行与 o is string 类似的测试)。令人惊讶的推论是 OfType<string?> 也会过滤掉 null 项,稍微奇怪的是,它返回一个 IEnumerable<string?>,但永远不会产生 null

如果源中没有任何对象符合要求,WhereOfType<T> 都会产生空序列。这不被视为错误——在 LINQ 中,空序列非常正常。许多运算符可以产生它们作为输出,大多数运算符可以处理它们作为输入。

Select

在编写查询时,我们可能只想从源项目中提取特定的数据片段。大多数查询末尾的 select 子句允许我们提供一个 lambda 表达式,用于生成最终的输出项,我们可能希望使我们的 select 子句不仅仅是直接传递每个项。我们可能只想从每个项中挑选一个特定的信息片段,或者我们可能希望将其转换为完全不同的东西。

您已经看到了几个 select 子句,并且我在 Example 10-3 中展示了编译器如何将它们转换为对 Select 的调用。然而,与许多 LINQ 操作符一样,通过查询表达式访问的版本并不是唯一的选择。还有另一种重载形式,不仅提供用于生成输出项的输入项,还提供该项的索引。Example 10-20 使用这一点生成了一个课程标题的编号列表。

示例 10-20. 带有索引的 Select 操作符
IEnumerable<string> nonIntro = Course.Catalog.Select((course, index) =>
      $"Course {index}: {course.Title}");

请注意,传入 lambda 表达式的从零开始的索引将基于进入 Select 操作符的内容,并且不一定代表底层数据源中项的原始位置。这可能不会产生您在诸如 Example 10-21 中编写的代码中所期望的结果。

示例 10-21. Where 操作符下游的索引化 Select
IEnumerable<string> nonIntro = Course.Catalog
    .Where(c => c.Number >= 200)
    .Select((course, index) => $"Course {index}: {course.Title}");

此代码将选择在Course.Catalog数组中分别位于索引 2、3 和 5 的课程,因为这些课程的Number属性满足Where表达式。然而,此查询将会将这三门课程编号为 0、1 和 2,因为Select操作符只能看到Where子句允许通过的项目。就它而言,只有三个项目,因为Select子句从未访问过原始来源。如果您希望相对于原始集合提取索引,您需要在Where子句上游进行提取,就像 Example 10-22 中展示的那样。

示例 10-22. Where 操作符上游的索引化 Select
IEnumerable<string> nonIntro = Course.Catalog
    `.``Select``(``(``course``,` `index``)` `=``>` `new` `{` `course``,` `index` `}``)`
    .Where(vars => vars.course.Number >= 200)
    .Select(vars => $"Course {vars.index}: {vars.course.Title}");

你可能会想为什么我在这里使用了匿名类型而不是元组。我可以用(course, index)替换new { course, index },代码同样可以工作。 (甚至可能更有效,因为元组是值类型,而匿名类型是引用类型。元组在这里会给 GC 带来更少的工作)。然而,一般来说,在 LINQ 中元组并不总是有效的。轻量级元组语法是在 C# 7.0 引入的,因此在 C# 3.0 引入表达式树时它们并不存在。表达式对象模型尚未更新以支持此语言特性,因此如果您尝试在基于IQueryable<T>的 LINQ 提供程序中使用元组,您将收到编译器错误 CS8143,提示An expression tree may not contain a tuple literal。¹ 因此,在这一章中我倾向于使用匿名类型,因为它们与基于查询的提供程序兼容。但是,如果您使用的是纯本地 LINQ 提供程序(例如 Rx 或 LINQ 到对象),请随意使用元组。

索引化的 Select 操作符与索引化的 Where 操作符类似。因此,正如您可能期望的那样,并非所有的 LINQ 提供程序都在所有情况下都支持它。

数据塑形和匿名类型

如果你正在使用 LINQ 提供程序来访问数据库,Select 运算符可以提供一个减少获取数据量的机会,这可能会减少服务器的负载。当你使用像 EF Core 这样的数据访问技术来执行返回表示持久化实体集合的查询时,存在着一种在一开始做过多工作和需要执行大量额外延迟工作之间的权衡。这些框架是否应完全填充与各个数据库表中列对应的对象属性?它们是否还应加载相关对象?通常来说,不获取你不会使用的数据更有效率,而未在一开始获取的数据随时可以后续按需加载。然而,如果你在初始请求中过于节约,最终可能会导致需要大量额外请求来填补空白,这可能会抵消避免不必要工作带来的任何好处。

当涉及到相关实体时,EF Core 允许你配置哪些相关实体应预取,哪些应按需加载,但对于获取的任何特定实体,通常会完全填充与列相关的所有属性。这意味着请求整个实体的查询最终会获取它们所触及的任何行的所有列。

如果你只需要使用一两列,获取它们所有都是相对昂贵的。示例 10-23 使用了这种效率较低的方法。它展示了一个相当典型的 EF Core 查询。

示例 10-23. 获取比所需更多的数据
var pq = from product in dbCtx.Product
         where product.ListPrice > 3000
         select product;
foreach (var prod in pq)
{
    Console.WriteLine($"{prod.Name} ({prod.Size}): {prod.ListPrice}");
}

这个 LINQ 提供程序将 where 子句翻译为一个高效的 SQL 等价物。然而,SQL SELECT 子句从表中检索所有列。与 示例 10-24 对比一下。这只修改了查询的一部分:LINQ select 子句现在返回一个匿名类型的实例,该实例仅包含我们需要的那些属性。(随后的循环仍然可以保持不变。它使用 var 作为其迭代变量,这对于匿名类型来说可以正常工作,因为匿名类型提供了循环所需的三个属性。)

示例 10-24. 匿名类型的 select 子句
var pq = from product in dbCtx.Product
         where product.ListPrice > 3000
         `select` `new` `{` `product``.``Name``,` `product``.``ListPrice``,` `product``.``Size` `}``;`

代码产生了完全相同的结果,但生成了一个更加紧凑的 SQL 查询,仅请求NameListPriceSize 列。如果你正在使用具有许多列的表,这将产生一个显著较小的响应,因为它不再被我们不需要的数据所主导。这减少了与数据库服务器的网络连接负载,并且由于数据到达时间更短,还会导致更快的处理。这种技术称为数据整形

这种方法并不总是一种改进。首先,这意味着你直接在数据库中使用数据,而不是使用实体对象。这可能意味着你在抽象级别上工作的比使用实体类型时更低,这可能会增加开发成本。另外,在某些环境中,数据库管理员不允许使用即席查询,强制你使用存储过程,在这种情况下,你将无法使用这种技术来获得灵活性。

将查询结果投影到匿名类型并不限于数据库查询。你可以在任何 LINQ 提供程序(如 LINQ to Objects)中自由使用这个功能。有时这是一种在不需要专门定义类的情况下从查询中获取结构化信息的有用方式。(正如我在 第三章 中提到的,匿名类型可以在 LINQ 之外使用,但这是它们设计的主要场景之一。按复合键分组是另一个场景,我将在 “分组” 中描述。)

投影和映射

Select 操作符有时被称为投影,它与许多语言称为映射的操作相同,提供了一种略有不同的看待 Select 操作符的方式。到目前为止,我已经介绍了 Select 作为选择查询结果的一种方式,但你也可以把它看作是将变换应用到源中的每个项的一种方式。示例 10-25 使用 Select 生成修改后的数字列表。它分别将数字加倍、求平方,并将它们转换为字符串。

示例 10-25. 使用 Select 转换数字
int[] numbers = { 0, 1, 2, 3, 4, 5 };

IEnumerable<int> doubled = numbers.Select(x => 2 * x);
IEnumerable<int> squared = numbers.Select(x => x * x);
IEnumerable<string> numberText = numbers.Select(x => x.ToString());

SelectMany

SelectMany LINQ 操作符用于具有多个 from 子句的查询表达式。它之所以被称为 SelectMany,是因为它不是为每个输入项选择单个输出项,而是为每个输入项提供一个生成整个集合的 lambda 表达式。生成的查询将来自所有这些集合的对象,就好像你的 lambda 返回的所有集合被合并成一个一样。(这不会移除重复项。在 LINQ 中,序列可以包含重复项。你可以使用 “集合操作” 中描述的 Distinct 操作符来移除它们。)有几种思考这个操作符的方式。一种是它提供了将两个层次结构(集合的集合)展平为单一级别的方法。另一种看待它的方式是作为笛卡尔积——即从一些输入集合中生成每一种可能的组合的方法。

示例 10-26 展示了如何在查询表达式中使用此运算符。此代码突出显示了类似于笛卡尔积的行为。它显示了字母 A、B 和 C 与数字 1 到 5 的每个组合,即 A1、B1、C1、A2、B2、C2 等(如果您对这两个输入序列的表现不兼容感到疑惑,此查询的select子句依赖于一个事实,即如果您使用+运算符将一个string和某种其他类型相加,C#会为您生成调用非 string 操作数的ToString的代码)。

示例 10-26. 使用查询表达式中的SelectMany
int[] numbers = { 1, 2, 3, 4, 5 };
string[] letters = { "A", "B", "C" };

IEnumerable<string> combined = from number in numbers
                               from letter in letters
                               select letter + number;
foreach (string s in combined)
{
    Console.WriteLine(s);
}

示例 10-27 展示了如何直接调用运算符。这相当于示例 10-26 中的查询表达式。

示例 10-27. SelectMany运算符
IEnumerable<string> combined = numbers.SelectMany(
    number => letters,
    (number, letter) => letter + number);

示例 10-26 使用了两个固定集合——第二个from子句每次返回相同的letters集合。然而,您可以使第二个from子句中的表达式基于第一个from子句的当前项返回一个值。您可以在示例 10-27 中看到,SelectMany的第一个 Lambda 表达式(实际上对应第二个from子句的最终表达式)通过其number参数接收第一个集合的当前项,因此您可以用它来选择每个第一个集合项的不同集合。我可以利用这一点来利用SelectMany的展平行为。

我已经从示例 5-16 在第 5 章中复制了一个嵌套数组到示例 10-28,然后使用包含两个from子句的查询处理它。请注意,第二个from子句中的表达式现在是row,即第一个from子句的范围变量。

示例 10-28. 展平嵌套数组
int[][] arrays =
{
    new[] { 1, 2 },
    new[] { 1, 2, 3, 4, 5, 6 },
    new[] { 1, 2, 4 },
    new[] { 1 },
    new[] { 1, 2, 3, 4, 5 }
};

IEnumerable<int> flattened = from row in arrays
                             from number in row
                             select number;

第一个from子句要求迭代顶层数组中的每个项。这些项中的每一个也是一个数组,第二个from子句要求迭代每个嵌套数组。这个嵌套数组的类型是int[],因此第二个from子句的范围变量number表示来自该嵌套数组的一个intselect子句只是返回每个这些int值。

结果序列依次提供数组中的每个数字。它将嵌套数组展平为简单的线性数字序列。这种行为在概念上类似于编写一个嵌套的循环对,一个循环遍历外部int[][]数组,另一个内部循环遍历每个单独的int[]数组的内容。

编译器对于 示例 10-28 和 示例 10-27 使用相同的 SelectMany 重载,但在这种情况下存在另一种选择。在 示例 10-28 中,最终的 select 子句更简单—它仅传递来自第二个集合的项目,这意味着 示例 10-29 中显示的更简单的重载同样能胜任。使用这种重载,我们只需提供一个 lambda,它选择 SelectMany 将为输入集合中的每个项目扩展的集合。

示例 10-29. 没有项目投影的 SelectMany
var flattened = arrays.SelectMany(row => row);

这是一段略显简洁的代码,因此如果不太清楚它如何最终展平数组,示例 10-30 展示了如果必须自己编写的话,您可能如何为 IEnumerable<T> 实现 SelectMany

示例 10-30. SelectMany 的一个实现
static IEnumerable<T2> MySelectMany<T, T2>(
    this IEnumerable<T> src, Func<T, IEnumerable<T2>> getInner)
{
    foreach (T itemFromOuterCollection in src)
    {
        IEnumerable<T2> innerCollection = getInner(itemFromOuterCollection);
        foreach (T2 itemFromInnerCollection in innerCollection)
        {
            yield return itemFromInnerCollection;
        }
    }
}

编译器为什么不使用在 示例 10-29 中展示的更简单的选项?C# 语言规范定义了查询表达式如何转换为方法调用,并且仅提到了 示例 10-26 中显示的重载。也许规范之所以没有提及更简单的重载,是为了减少 C# 对想要支持这种双 from 查询形式的类型的要求—您只需编写一个方法即可启用此语法。然而,.NET 的各种 LINQ 提供程序更为慷慨,为选择直接使用运算符的开发人员提供了这种更简单的重载。事实上,一些提供程序定义了另外两个重载版本:迄今为止我们看到的 SelectMany 的这两种形式也会将项目索引传递给第一个 lambda。当然,对于索引运算符,通常的警告仍然适用。

虽然 示例 10-30 给出了 LINQ to Objects 在 SelectMany 中的一个合理想法,但这并不是确切的实现。对于特殊情况,存在优化。此外,其他提供程序可能使用非常不同的策略。数据库通常内置支持笛卡尔积,因此某些提供程序可能会基于此实现 SelectMany

Chunking

SelectMany 将多个序列展平为一个,LINQ 的 Chunk 操作符(在 .NET 6.0 中添加)则朝相反方向工作,将单个序列转换为一系列固定大小的序列。在涉及到 I/O 的情况下,这可能更有效,因为写入数据到磁盘或通过网络发送数据通常具有固定的最低成本,这往往意味着写入或发送单个记录的成本仅比写入或发送 10 条记录稍微低一点。

示例 10-31 使用 Range 方法(稍后在 “序列生成” 中描述)创建了一个从 1 到 50 的数字序列,然后要求 Chunk 将其分成每个包含 15 个数字的块。虽然 Range 生成了一个 IEnumerable<int>—一系列 int 值—Chunk 返回了一个 数组 序列,类型为 int[]

示例 10-31. 使用 Chunk 将序列分成批次
IEnumerable<int> lotsOfNumbers = Enumerable.Range(1, 50);

IEnumerable<int[]> chunked = lotsOfNumbers.Chunk(15);
foreach(int[] chunk in chunked)
{
    Console.WriteLine(
        $"Chunk (length {chunk.Length}): {String.Join(", ", chunk)}");
}

查看 示例 10-31 的输出,我们可以看到 Chunk 将所有数字按顺序分割成了块:

Chunk (length 15): 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15
Chunk (length 15): 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30
Chunk (length 15): 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45
Chunk (length 5): 46, 47, 48, 49, 50

在本例中,源序列的长度不是块大小的精确倍数。Chunk 通过使最后一个块更小来处理这个问题。

注意

一些 LINQ 提供程序使用不同的名称来表示此操作符:Buffer。在约 10 年前,Rx 库在介绍这种类型的操作符时选择了这个名称。.NET 6.0 选择了 Chunk 这个名称,但在此之前编写的库通常遵循了 Rx 的先例,将其版本的这个操作符称为 Buffer

排序

一般来说,LINQ 查询不保证按任何特定顺序生成项目,除非您明确定义所需的顺序。您可以在查询表达式中使用 orderby 子句来实现这一点。正如 示例 10-32 所示,您可以指定定义项目顺序及其方向的表达式—因此,这将生成按发布日期升序排列的课程集合。恰巧的是,默认情况下是 ascending,因此您可以省略该限定词而不改变含义。正如您可能已经猜到的那样,您可以指定 descending 来反转顺序。

示例 10-32. 带有 orderby 子句的查询表达式
var q = from course in Course.Catalog
        orderby course.PublicationDate ascending
        select course;

编译器将 示例 10-32 中的 orderby 子句转换为对 OrderBy 方法的调用,如果指定了 descending 排序顺序,它将使用 OrderByDescending。对于那些区分有序和无序集合的源类型,这些操作符返回有序类型(例如,LINQ to Objects 返回 IOrderedEnumerable<T>,而基于 IQueryable<T> 的提供程序返回 IOrderedQueryable<T>)。

警告

对于 LINQ to Objects,这些操作符必须从输入中检索每个元素,然后才能生成任何输出元素。升序的 OrderBy 只有在找到最低项之后才能确定要返回哪个项,并且直到看到所有项之后才能确定最低项。它仍然使用延迟评估—直到您请求第一个项目时才会执行任何操作。但是一旦您请求了某些内容,它必须立即完成所有工作。某些提供程序可能对数据具有额外的知识,从而能够实现更有效的策略(例如,数据库可以使用索引按照所需的顺序返回值)。

LINQ to Objects 的OrderByOrderByDescending运算符各有两个重载,其中只有一个可以从查询表达式中使用。如果直接调用这些方法,您可以提供一个额外的IComparer<TKey>类型的参数,其中TKey是正在排序的表达式的类型。如果您基于string属性进行排序,这可能很重要,因为文本有几种不同的排序方式,您可能需要根据应用程序的区域设置选择其中一种,或者您可能希望指定一个与文化无关的排序方式,以确保在所有环境中保持一致性。

示例 10-32 中确定顺序的表达式非常简单—它只是从源项目中检索PublicationDate属性。如果您愿意,可以编写更复杂的表达式。如果您使用将 LINQ 查询转换为其他内容的提供程序,可能会有一些限制。如果查询在数据库上运行,您可能可以引用其他表—提供程序可能能够将诸如product.ProductCategory.Name之类的表达式转换为适当的连接。但是,您无法在该表达式中运行任何旧代码,因为它必须是数据库可以执行的内容。但是,LINQ to Objects 只是为每个对象调用一次表达式,因此您确实可以在其中放入任何代码。

您可能希望按多个标准进行排序。您不应该通过编写多个orderby子句来实现这一点。示例 10-33 犯了这个错误。

示例 10-33. 如何不应用多个排序标准
var q = from course in Course.Catalog
        orderby course.PublicationDate ascending
        orderby course.Duration descending // BAD! Could discard previous order
        select course;

此代码按发布日期和持续时间对项目进行排序,但是将其作为两个独立且不相关的步骤进行。第二个orderby子句仅保证结果将按照该子句中指定的顺序排列,并不保证保留有关元素原始顺序的任何信息。如果您实际上想要项目按发布日期顺序排列,并且具有相同发布日期的项目按持续时间降序排列,您需要编写示例 10-34 中的查询。

示例 10-34. 查询表达式中的多个排序标准
var q = from course in Course.Catalog
        orderby course.PublicationDate ascending, course.Duration descending
        select course;

LINQ 为这种多级排序定义了单独的运算符:ThenByThe⁠nBy​Des⁠cen⁠ding。示例 10-35 展示了如何通过直接调用 LINQ 运算符来实现与示例 10-34 中查询表达式相同的效果。对于那些类型区分有序和无序集合的 LINQ 提供程序,ThenByThenByDescending运算符仅在有序形式上可用,例如IOrderedQueryable<T>IOrderedEnumerable<T>。如果您尝试直接在Course.Catalog上调用ThenBy,编译器将报告错误。

示例 10-35. 使用 LINQ 操作符进行多重排序标准
var q = Course.Catalog
    .OrderBy(course => course.PublicationDate)
    .ThenByDescending(course => course.Duration);

您会发现,即使您没有要求,一些 LINQ 操作符也会保留某些排序方面。例如,LINQ to Objects 通常会按照它们在输入中出现的顺序生成项目,除非您编写了一个引起顺序改变的查询。但这只是 LINQ to Objects 工作方式的副产品,您不应该普遍依赖它。事实上,即使在使用该特定 LINQ 提供程序时,您也应该查看文档,以了解您得到的顺序是有保证的还是仅仅是实现的偶然。在大多数情况下,如果您关心顺序,应该编写一个明确表达这一点的查询。

包含性测试

LINQ 定义了各种用于发现集合内容的标准操作符。一些提供程序可能能够实现这些操作符而无需检查每个项目。(例如,基于数据库的提供程序可能使用 WHERE 子句,数据库可以使用索引来评估,而无需查看每个元素。)但没有限制 - 您可以按自己的喜好使用这些操作符,以及提供程序发现是否可以利用快捷方式。

注意

不同于大多数 LINQ 操作符,在大多数提供程序中,它们既不返回集合也不返回输入中的项目。它们通常只返回 truefalse,或者在某些情况下返回一个计数。Rx 是一个显著的例外:它这些操作符的实现将 boolint 包装在产生结果的单元素 IObservable<T> 中。它这样做是为了保持 Rx 中处理的响应式特性。

最简单的操作符是 Contains。您传递一个项目,一些提供程序(包括 LINQ to Objects)提供了一个重载,还接受一个 IEqualityComparer<T>,以便您可以自定义操作符如何确定源中的项目与指定项目是否相同。Contains 如果源包含指定的项目则返回 true,如果不包含则返回 false。如果您使用具有 ICollection<T> 实现的集合的单参数版本(其中包括所有 IList<T>ISet<T> 实现),LINQ to Objects 将检测到这一点,并且其 Contains 的实现只是将其延迟到集合。如果您使用非 ICollection<T> 集合,或者提供自定义的相等性比较器,它将不得不检查集合中的每个项目。

如果你不是在寻找特定的值,而是想知道一个集合是否包含任何满足某些特定条件的值,你可以使用Any运算符。它接受一个谓词,并且在源中至少有一个项满足谓词时返回true。如果你想知道有多少项满足某些条件,你可以使用Count运算符。它也接受一个谓词,而不是返回bool,而是返回一个int。如果你正在处理非常大的集合,int的范围可能不够,这时你可以使用LongCount运算符,它返回一个 64 位的计数。(在大多数 LINQ to Objects 应用程序中,这可能过于复杂,但在集合存在于数据库中时可能很重要。)

AnyCountLongCount运算符有些重载不接受任何参数。对于Any来说,这告诉你源序列是否至少包含一个元素,而对于CountLongCount,这些重载告诉你源序列包含多少元素。

你应该警惕像if (q.Count() > 0)这样的代码。计算确切的计数可能需要评估整个源查询(在这种情况下是q),而且通常比简单地回答“它是空的吗?”更耗费功夫。如果q是 LINQ 查询,写成if (q.Any())可能更有效率。话虽如此,在 LINQ 之外,对于类似列表的集合来说,并不是这样,那里获取元素计数是廉价的,而且实际上可能比Any运算符更有效率。

有些情况下,你可能只希望在能够高效计算时才使用计数。(例如,用户界面可能希望在可以轻松确定的情况下显示可用项的总数,但在计算成本太高时可能选择不显示。)对于这些情况,.NET 6.0 添加了一个新的TryGetNonEnumeratedCount方法。它将在可以在不必迭代整个集合的情况下确定计数时返回true,否则返回false。当它返回true时,通过out int类型的单个参数将计数传递回去。

Any运算符密切相关的是All运算符。这个运算符没有重载——它接受一个谓词,并且仅当源序列不包含任何不匹配谓词的项时返回true。在上述句子中我使用了尴尬的双重否定是有原因的:All应用于空序列时返回true,因为空序列显然不包含任何元素无法匹配谓词,简单来说,它根本没有任何元素。

这可能看起来像一种顽固的逻辑形式。这让人想起了一个孩子的情形,当问到“你吃了你的蔬菜吗?”时,他无助地回答道:“我吃了我放在盘子上的所有蔬菜”,却忽略了他根本没把任何蔬菜放在盘子上这一事实。从技术上讲,这并不是不真实的,但它未能提供父母寻找的信息。尽管如此,这些运算符之所以工作如此,是有其原因的:它们对应一些标准的数学逻辑运算符。Any存在量词,通常写作倒立的 E (∃),读作“存在”,而All全称量词,通常写作倒置的 A (∀),读作“对于所有”。数学家们很久以前就对适用于空集的全称量词陈述达成了一致的约定。例如,定义𝕍为所有蔬菜的集合,我可以断言 ∀{v : (v ∈ 𝕍) ∧ putOnPlateByMe(v)} eatenByMe(v),或者用英语说,“对于我放在盘子上的每一个蔬菜,我吃了那个蔬菜。” 如果我放在盘子上的蔬菜集合是空的,这个陈述被认为是真实的。(也许数学家也不喜欢蔬菜。)令人愉悦的是,这种陈述的正式术语是空真

特定项目和子范围

编写仅产生单个项目的查询可能很有用。也许你正在寻找满足某些条件的列表中的第一个对象,或者你想通过特定键标识的数据库获取信息。LINQ 定义了几个可以实现此目的的运算符,以及一些处理查询可能返回的子范围的相关运算符。

使用Single运算符时,你认为应该只生成一个结果的查询。示例 10-36 展示了这样的一个查询—它通过类别和编号查找课程,在我的样本数据中,这唯一确定了一个课程。

示例 10-36. 将Single运算符应用于查询
var q = from course in Course.Catalog
        where course.Category == "MAT" && course.Number == 101
        select course;

Course geometry = q.Single();

因为 LINQ 查询是通过链接操作符构建的,我们可以取出由查询表达式构建的查询,然后添加另一个运算符—在这种情况下是Single运算符。虽然大多数运算符会返回代表另一个查询的对象(这里是IEnumerable<T>,因为我们使用 LINQ 来处理对象),但Single不同。像ToArrayToList一样,Single运算符立即评估查询,然后返回查询产生的唯一对象。如果查询未能产生正好一个对象—可能没有生成任何项,或者生成了两个—这将引发InvalidOperationException。(由于这是另一个立即产生结果的运算符,一些提供程序提供了SingleAsync,如侧边栏“即时评估和异步”中所述。)

Single 操作符还有一个带有谓词的重载。正如 示例 10-37 所示,这使我们能够更紧凑地表达与 示例 10-36 整体相同的逻辑。(与 Where 操作符一样,本节中所有基于谓词的操作符都使用 Func<T, bool>,而不是 Predicate<T>。)

示例 10-37. 带有谓词的 Single 操作符
Course geometry = Course.Catalog.Single(
    course => course.Category == "MAT" && course.Number == 101);

Single 操作符是严格的:如果你的查询没有精确返回一个项,它会抛出异常。还有一个略微更灵活的变体叫做 SingleOrDefault,允许查询返回一个或零个项。如果查询没有结果,这个方法会返回该项类型的默认值(比如引用类型返回 null,数值类型返回 0 等)。多个匹配项仍会引发异常。和 Single 一样,它们有两个重载:一个不带参数,用于你认为源中不会有多个对象的情况;另一个带有谓词 lambda。

LINQ 定义了两个相关的操作符 FirstFirstOrDefault,它们分别提供了不带参数或带有谓词的重载。对于包含零个或一个匹配项的序列,它们的行为与 SingleSingleOrDefault 完全相同:如果存在一个项,则返回该项;如果没有,First 会抛出异常,而 FirstOrDefault 会返回 null 或等效值。然而,当存在多个结果时,这些操作符的响应不同——它们会选择第一个结果并返回,忽略其余结果。如果你想从列表中找出最昂贵的物品,这可能会很有用——你可以按价格降序排序查询,然后选择第一个结果。示例 10-38 使用了类似的技术来从我的示例数据中选择最长的课程。

示例 10-38. 使用 First 选择最长的课程
var q = from course in Course.Catalog
        orderby course.Duration descending
        select course;
Course longest = q.First();

如果你的查询结果没有特定的顺序保证,这些操作符会任意选择一个项。

提示

不要使用 FirstFirstOrDefault,除非你期望有多个匹配项并且只想处理其中一个。有些开发者在期望只有一个匹配项时也使用这些操作符。当然,这些操作符可以工作,但 SingleSingleOrDefault 操作符更准确地表达了你的期望。它们会在有多个匹配项时抛出异常,让你知道你的期望是错误的。如果你的代码存在错误的假设,通常最好是知道而不是无视它们继续执行。

FirstFirstOrDefault 的存在引出了一个明显的问题:我能选出最后一项吗?答案是肯定的;还有 LastLastOrDefault 操作符,同样,每个都提供两个重载——一个不带参数,一个带有谓词。

.NET 6.0 对SingleOrDefaultFirstOrDefaultLastOrDefault进行了优化。这些方法新增了重载,使你能够提供一个返回默认值的值,而不是通常的零值。如果你有一个包含整数元素的集合,其中零是有效值,这可能非常有用。示例 10-39 展示了如何使用新的SingleOrDefault重载,在列表为空时获取一个值为-1 的结果。这样可以区分空列表和只包含单个零值的列表。当然,如果你的应用程序中所有可能的整数值都是有效的,这就不起作用了,你需要用其他方式检测空集合。但是,在你可以指定一些特殊值来表示“不在这里”的情况时(例如,在这种情况下是-1),这些新的重载是一个有用的补充。

示例 10-39. 使用显式默认值的SingleOrDefault
int valueOrNegative = numbers.SingleOrDefault(-1);

下一个显而易见的问题是:如果我想要一个既不是第一个也不是最后一个的特定元素怎么办?在这种情况下,LINQ 的指令非常实用,因为它提供了ElementAtElementAtOrDefault操作符,两者都只接受一个索引。这提供了一种通过索引访问任何IEnumerable<T>元素的方式。你可以指定索引为一个int。另外,.NET 6.0 添加了使用Index的重载,正如你可能从“使用索引和范围语法访问元素”了解到的那样,它允许使用相对末尾的位置。例如,²表示倒数第二个元素。(奇怪的是,ElementAtOrDefault没有新增重载来指定默认值,不像上一段讨论的三个操作符。)

你需要小心使用ElementAtElementAtOrDefault,因为它们可能会出乎意料地昂贵。如果你要求第 10,000 个元素,这些操作符可能需要请求并丢弃前 9,999 个元素才能到达那里。如果你通过写source.ElementAt(⁵⁰⁰)来指定一个相对末尾的位置,操作符可能需要读取每一个元素才能找到最后一个元素,并且对于这个特定的示例,它还可能需要保持已经看到的最后 500 个元素,因为直到到达末尾时,它才知道最终需要返回哪个元素。

正如情况所示,LINQ to Objects 会检测源对象是否实现了IList<T>接口,如果是,则直接使用索引器来直接获取元素,而不是绕一个慢速的方式。但并不是所有的IEnumerable<T>实现都支持随机访问,因此这些操作符可能会非常慢。特别是,即使你的源实现了IList<T>,一旦你对其应用了一个或多个 LINQ 操作符,这些操作符的输出通常就不支持索引访问了。因此,在像示例 10-40 中展示的循环中使用ElementAt将会特别灾难性。

示例 10-40. 不正确使用ElementAt的例子
var mathsCourses = Course.Catalog.Where(c => c.Category == "MAT");
for (int i = 0; i < mathsCourses.Count(); ++i)
{
    // Never do this!
    Course c = mathsCourses.ElementAt(i);
    Console.WriteLine(c.Title);
}

尽管Course.Catalog是一个数组,我已经用Where运算符过滤了它的内容,返回了一个类型为IEnumerable<Course>的查询,该类型不实现IList<Course>接口。第一次迭代不会太糟糕——我将ElementAt的索引设为0,因此它只返回第一个匹配项,在我的样本数据中,Where检查的第一个项目将匹配。但是在循环的第二次迭代中,我们再次调用ElementAtmathsCourses引用的查询并不跟踪我们在上一个循环中的位置——它是一个IEnumerable<T>,而不是IEnumerator<T>——因此这将重新开始。ElementAt会要求该查询返回第一个项目,它会立即丢弃它,然后请求下一个项目,这将成为返回值。因此,Where查询现在已经执行了两次——第一次,ElementAt只要求它返回一个项目,然后第二次它要求它返回两个项目,因此它现在已经处理了第一个课程两次。第三次循环(也是最后一次),我们再次重复这一过程,但这次,ElementAt将丢弃前两个匹配项,并返回第三个匹配项,因此现在它已经查看了第一个课程三次,第二个课程两次,第三和第四个课程各一次。(在我的样本数据中,第三个课程不属于MAT类别,因此当要求第三个项目时,Where查询会跳过它。)因此,为了检索三个项目,我已经评估了Where查询三次,导致它评估我的过滤 lambda 函数七次。

事实上,情况比这更糟,因为for循环每次还会调用Count方法,而对于像Where返回的非可索引源,Count必须评估整个序列——Where运算符告诉你有多少项匹配的唯一方法就是查看所有这些项。因此,这段代码除了ElementAt进行的三次部分评估外,还完全评估了Where返回的查询三次。在这里我们得以侥幸,因为集合很小,但如果我有一个包含 1,000 个元素的数组,所有元素都匹配过滤器,我们将完全评估Where查询 1,000 次,并进行另外 1,000 次部分评估。每次完全评估都会调用过滤器谓词 1,000 次,而这里的部分评估平均会这样做 500 次,因此代码最终会执行 1,500,000 次过滤。通过foreach循环迭代Where查询只会评估一次查询,执行 1,000 次过滤表达式,结果将会是一样的。

因此,在使用CountElementAt时要小心。如果你在迭代调用它们的集合的循环中使用它们,结果代码的复杂度将会是 O(n²)(即,运行代码的成本与项目数量的平方成正比增长)。

所有我刚刚描述的操作符都从源中返回单个项目。还有四个操作符也会有选择地使用项目,但可以返回多个项目:SkipTakeSkipLastTakeLast。这些操作符每个接受一个 int 参数。顾名思义,Skip 丢弃序列开头指定数量的元素,然后返回源中的所有其他元素。Take 从序列开头返回指定数量的元素,然后丢弃其余部分(因此类似于 SQL 中的 TOP)。SkipLastTakeLast 作用于序列末尾,例如,您可以使用 TakeLast 获取源中的最后 5 个项目,或者使用 SkipLast 跳过最后 5 个项目。

.NET 6.0 添加了一个重载的 Take 方法,接受一个 Range,使得可以使用在“使用索引和范围语法访问元素”中描述的范围语法。例如,source.Take(10..¹⁰) 跳过了前 10 个和最后 10 个项目(因此等效于 source.Skip(10).SkipLast(10))。由于范围语法允许您在范围的起始和结束位置使用起始或结束相对索引,我们可以使用这个 Take 的重载来表示其他组合。例如,source.Take(10..20) 的效果与 source.Skip(10).Take(10) 相同;source.Take(¹⁰..²) 相当于 source.TakeLast(10).SkipLast(2)

还有基于条件的版本,SkipWhileTakeWhileSkipWhile 将丢弃序列中的项目,直到找到与谓词匹配的项目,此时它将返回该项目及其后续项目直至序列结束(无论剩余项目是否匹配谓词)。相反,TakeWhile 将返回项目,直到遇到第一个不匹配谓词的项目,此时它将丢弃该项目及其后续序列。

虽然 SkipTakeSkipLastTakeLastSkipWhileTakeWhile 显然都是有序敏感的,但它们并不限于仅限于有序类型,比如 IOr⁠der⁠ed​Enu⁠mer⁠abl⁠e<T>。它们也适用于 IEnumerable<T>,这是合理的,因为即使没有特定的顺序保证,IEnumerable<T> 总是以某种顺序产生元素。(你可以从 IEnumerable<T> 中逐个提取项,因此总会有一种顺序,即使是任意的。每次枚举项时可能不会相同,但对于单个评估,项必须以某种顺序出现。)此外,IOrderedEnumerable<T> 在 LINQ 之外并没有广泛实现,因此通常有些不了解 LINQ 的对象,虽然它们以已知的顺序产生项目,但仅实现了 IEnumerable<T>。这些运算符在这些场景中非常有用,因此限制得以放宽。更令人惊讶的是,IQueryable<T> 也支持这些操作,但这与许多数据库支持对无序查询应用 TOP(大致相当于 Take)是一致的。正如以往一样,单个提供程序可能选择不支持某些操作,因此在没有这些运算符合理解释的情况下,它们将引发异常。

聚合

SumAverage 运算符将所有源项的值相加。Sum 返回总和,Average 返回总和除以项数。通常支持这些运算符的 LINQ 提供程序会使它们适用于这些数值类型的项目集合:decimaldoublefloatintlong。还有一些重载版本,与 lambda 表达式一起工作,该 lambda 接受一个项目并返回其中一个这些数值类型,这使得我们可以编写像 示例 10-41 这样的代码,它处理 Course 对象集合,并计算从对象中提取的特定值的平均值:课程时长(以小时计算)。

示例 10-41. 带有投影的 Average 运算符
Console.WriteLine("Average course length in hours: {0}",
    Course.Catalog.Average(course => course.Duration.TotalHours));

LINQ 还定义了 MinMax 运算符。你可以将它们应用于任何类型的序列,尽管不能保证一定成功——你使用的特定提供程序可能在不知道如何比较你使用的类型时报告错误。例如,LINQ to Objects 要求序列中的对象实现 IComparable

MinMax 都有重载版本,接受一个从源项目获取值的 lambda 表达式。示例 10-42 使用这一特性来找出最近发布的课程的日期。

示例 10-42. Max 与投影
DateOnly m = mathsCourses.Max(c => c.PublicationDate);

注意,此方法并不返回最近发布日期的课程;它返回的是该课程的发布日期。如果您想选择某个属性具有最大值的对象,可以使用MaxBy。示例 10-43 将找到具有最高PublicationDate的课程,但与示例 10-42 不同,它返回相关课程而不是日期。(正如您所预料的那样,还有一个MinBy。)

示例 10-43. 用于标准的投影Max但不用于结果
Course? mostRecentlyPublished = mathsCourses.MaxBy(c => c.PublicationDate);

您可能已经在示例中注意到了?,表示MaxBy可能返回一个null结果。在输入集合为空且输出类型是引用类型或其支持的支持的数值类型的可空形式(例如int?double?)的情况下,MaxMaxBy会发生这种情况。当输出是非空的结构(例如DateOnly,如示例 10-42)时,这些运算符无法返回null,并且会抛出InvalidOperationException。如果您使用的是引用类型,并且希望像值类型输出那样在输入为空时引发异常,唯一的方法是自行检查是否存在null结果并抛出异常。示例 10-44 展示了一种实现方式。

示例 10-44. 用于标准的投影Max但不用于结果,输入为空时出错
Course mostRecentlyPublished = mathsCourses.MaxBy(c => c.PublicationDate)
    ?? throw new InvalidOperationException("Collection must not be empty");

LINQ to Objects 为返回与SumAverage处理相同数值类型的特定序列的MinMax定义了专用重载(即decimaldoublefloatintlong及其可空形式)。它还为使用 lambda 表达式的形式定义了类似的专用化。这些重载存在是为了通过避免装箱来提高性能。通用形式依赖于IComparable,并且获取一个值的接口类型引用总是涉及装箱该值。对于大集合,装箱每个值会对 GC 造成相当大的额外压力。

LINQ 定义了一个称为Aggregate的运算符,它泛化了MinMaxSumAverage所使用的模式,即使用涉及考虑每个源项的过程来生成单个结果。可以通过Aggregate来实现这四个运算符(及其...By对应运算符)。示例 10-45 使用Sum运算符计算所有课程的总持续时间,然后展示如何使用Aggregate运算符执行完全相同的计算。

示例 10-45. SumAggregate 的等效形式
double t1 = Course.Catalog.Sum(course => course.Duration.TotalHours);
double t2 = Course.Catalog.Aggregate(
    0.0, (hours, course) => hours + course.Duration.TotalHours);

聚合通过建立一个值来表示到目前为止检查过的所有项目的知识,称为累加器。我们使用的类型取决于我们要累积的知识。在这里,我只是将所有数字相加,所以我使用了一个double(因为TimeSpan类型的TotalHours属性也是一个double)。

最初我们没有知识,因为我们还没有查看任何项目。我们需要提供一个累加器值来表示这个起始点,因此Aggregate运算符的第一个参数是seed,累加器的初始值。在 Example 10-45 中,累加器只是一个运行总数,因此种子是0.0

第二个参数是一个 lambda 表达式,描述如何更新累加器以包含单个项目的信息。由于我这里的目标只是计算总时间,所以我只是将当前课程的持续时间添加到运行总数中。

一旦Aggregate查看了每个项目,这个特定的重载将直接返回累加器。在这种情况下,它将是所有课程中的总小时数。如果我们使用不同的累积策略,我们可以实现Max。而不是维护一个运行总数,表示到目前为止关于数据的所有知识的值只是看到的最高值。Example 10-46 显示了与 Example 10-42 的大致等价物。(它不完全相同,因为 Example 10-46 没有尝试检测空源。如果此源为空,Max会抛出异常,但这只会返回日期 0/0/0000。)

Example 10-46. 使用Aggregate实现Max
DateOnly m = mathsCourses.Aggregate(
    new DateOnly(),
    (date, c) => date > c.PublicationDate ? date : c.PublicationDate);

这说明了Aggregate并不对累积知识的值强加任何单一含义——你使用它的方式取决于你要做什么。一些操作需要一个稍微有结构的累加器。Example 10-47 使用Aggregate计算了平均课程持续时间。

Example 10-47. 使用Aggregate实现Average
double average = Course.Catalog.Aggregate(
    new { TotalHours = 0.0, Count = 0 },
    (totals, course) => new
    {
        TotalHours = totals.TotalHours + course.Duration.TotalHours,
        Count = totals.Count + 1
    },
    totals => totals.Count > 0
        ? totals.TotalHours / totals.Count
        : throw new InvalidOperationException("Sequence was empty"));

平均持续时间要求我们知道两件事:总持续时间和项目数。因此,在这个例子中,我的累加器使用了一个可以包含两个值的类型,一个用来保存总和,一个用来保存项目计数。我使用了匿名类型,因为正如前面提到的,在 LINQ 中有时这是唯一的选择,并且我想展示最一般的情况。然而,值得一提的是,在这种特定情况下,元组可能更好。它会起作用,因为这是 LINQ 到对象,而轻量级元组是值类型,而匿名类型是引用类型,元组会减少被分配的对象数量。

注意

示例 10-47 基于同一组件中的两个独立方法创建两个结构相同的匿名类型实例时的事实,编译器会生成一个用于两者的单一类型。种子生成了一个由TotalHoursdouble)和Countint)组成的匿名类型实例。累加 lambda 也返回了一个具有相同成员名称和类型的匿名类型实例,并且顺序也相同。C# 编译器认为这些将是相同的类型,这很重要,因为Aggregate要求 lambda 接受并返回累加器类型的实例。

示例 10-47 使用了与先前示例不同的重载。它采用了额外的 lambda 函数,用于从累加器中提取返回值—累加器积累了我需要生成结果所需的信息,但在这个示例中,累加器本身不是结果。

当然,如果你只想计算总和、最大值或平均值,你不会使用Aggregate—你会使用专门设计用于执行这些任务的操作符。它们不仅更简单,而且通常更高效。 (例如,数据库的 LINQ 提供程序可能能够生成一个查询,使用数据库的内置功能计算最小或最大值。)我只是想展示灵活性,使用易于理解的例子。但现在我已经做到了,示例 10-48 展示了一个特别简洁的Aggregate示例,它不对应任何其他内置操作符。它接受一个矩形集合并返回包含所有这些矩形的边界框。

示例 10-48. 聚合边界框
public static Rect GetBounds(IEnumerable<Rect> rects) =>
    rects.Aggregate(Rect.Union);

本示例中的Rect结构来自System.Windows命名空间。这是 WPF 的一部分,它是一个非常简单的数据结构,只包含四个数字—XYWidthHeight—因此,即使你喜欢,你也可以在非 WPF 应用中使用它。² 示例 10-48 使用了Rect类型的静态Union方法,它接受两个Rect参数并返回一个包含两个输入矩形的边界框的单个Rect(即包含两个输入矩形的最小矩形)。

我在这里使用Aggregate的最简单重载。它与我在示例 10-45 中使用的方法相同,但它不需要我提供一个种子——它只使用列表中的第一项。示例 10-49 相当于示例 10-48,但使步骤更明确。我已经提供了序列中第一个Rect作为显式种子值,使用Skip来聚合除了第一个元素之外的所有内容。我还编写了一个 lambda 来调用该方法,而不是直接传递方法本身。如果你使用这种 lambda,它只是将其参数直接传递给 LINQ 到对象的现有方法,你可以直接传递方法名称,它将直接调用目标方法,而不经过你的 lambda。(你不能在基于表达式的提供程序中这样做,因为它们要求一个 lambda。)

直接使用该方法更为简洁和略微更有效,但也会导致代码略显晦涩,这就是为什么我在示例 10-49 中详细解释它的原因。

示例 10-49. 更详细和不那么晦涩的边界框聚合
public static Rect GetBounds(IEnumerable<Rect> rects)
{
    IEnumerable<Rect> theRest = rects.Skip(1);
    return theRest.Aggregate(rects.First(), (r1, r2) => Rect.Union(r1, r2));
}

这两个示例的工作方式相同。它们以第一个矩形作为种子。对于列表中的下一个项,Aggregate将调用Rect.Union,传递种子和第二个矩形。结果——前两个矩形的边界框——成为新的累加器值。然后将其与第三个矩形一起传递给Union,依此类推。示例 10-50 展示了在四个Rect值的集合上执行此Aggregate操作的效果。(我在这里表示四个值为r1r2r3r4。要将它们传递给Aggregate,它们需要在像数组这样的集合内。)

示例 10-50. Aggregate的效果
Rect bounds = Rect.Union(Rect.Union(Rect.Union(r1, r2), r3), r4);

Aggregate是 LINQ 中对其他一些语言称为reduce的操作的称呼。有时你也会看到它被称为fold。LINQ 选择使用Aggregate这个名字的原因与其将投影运算符称为Select而不是map(函数式编程语言中更常见的名称)相同:LINQ 的术语更多受到 SQL 的影响,而不是函数式编程语言。

集合操作

LINQ 定义了三个运算符,使用一些常见的集合操作来合并两个源。Intersect生成一个结果,其中包含仅存在于两个输入源中的项。Except包含仅来自第一个输入源中不在第二个输入源中的项。Union³的输出包含存在于任一(或两者)输入源中的项。

虽然 LINQ 定义了这些集合操作,大多数 LINQ 源类型并不直接对应集合的抽象。在数学集合中,任何特定项都要么属于集合,要么不属于,没有固有的顺序概念或特定项在集合中出现的次数。IEnumerable<T> 不是这样的——它是一系列项,因此可能存在重复项,IQueryable<T> 也是如此。这并不一定是问题,因为有些集合永远不会处于包含重复项的情况中,而且在某些情况下,重复项的存在也不会导致问题。但有时,将包含重复项的集合转换为不包含重复项可能很有用。为此,LINQ 定义了 Distinct 操作符,用于删除重复项。示例 10-51 包含一个查询,从所有课程中提取类别名称,并将其传递给 Distinct 操作符,以确保每个唯一的类别名称只出现一次。

示例 10-51. 使用 Distinct 删除重复项
var categories = Course.Catalog.Select(c => c.Category).Distinct();

所有这些集合操作符都有两种形式可用,因为你可以选择向其中任何一个传递一个 IEqualityComparer<T>。这允许你定制操作符如何决定两个项是否相同。

.NET 6.0 添加了 IntersectByExceptByUnionByDistinctBy 操作符。它们的基本目的与 IntersectExceptUnionDistinct 相同,但用于确定等效性的机制不同。你可以提供一个 lambda,它接受源集合中的一个元素作为输入,并产生任何你想要的输出。如果这个 lambda 对两个项产生相同的结果,则认为它们是相同的。(例如,你可以编写 courses.DistinctBy(c => c.Title),如果两个课程具有相同的 Title,则它们被视为相同。)你也可以通过编写自定义的 IEq⁠ual⁠ity​Com⁠par⁠er⁠<T> 来实现相同的效果,但使用投影通常更简单。(这四种方法的所有重载还接受一个 IEqualityComparer<T>。如果你的投影产生一个字符串,并且你想指定字符串比较机制,这可能很有用。)

整个序列、保持顺序的操作

LINQ 定义了一些操作符,它们的输出包括源中的每个项,并保留或者反转顺序。并非所有集合都一定有顺序,因此这些操作符并不总是被支持。不过,LINQ 对对象支持它们全部。最简单的是 Reverse,它反转了元素的顺序。

Concat操作符组合两个序列。它返回一个序列,该序列产生第一个序列中所有元素(以该序列返回它们的任何顺序),然后是第二个序列中所有元素(再次保持顺序)。在需要仅将单个元素添加到第一个序列末尾的情况下,可以使用Append。还有Prepend,它在开头添加单个项目。Repeat操作符有效地连接源的指定数量的副本。

DefaultIfEmpty操作符返回其源的所有元素。但是,如果源为空,它将返回单个元素。这个方法有两个重载版本:您可以指定源为空时返回的默认值,或者如果不传递参数,则使用元素类型的默认值,类似于零。

Zip操作符也可以组合两个序列,但不是依次返回每个元素,它是逐对元素进行操作。因此,它返回的第一个项目将基于第一个序列和第二个序列的第一个项目。zipped 序列中的第二个项目将基于每个序列的第二个项目,依此类推。名称Zip旨在让人联想到服装上拉链的作用,将两个物品完美对齐在一起。(这并不是一个精确的类比。当拉链将两部分连接时,两半部分的齿会交错连接。但Zip操作符不会像物理拉链的齿那样交错处理其输入。它将两个源的项目成对组合在一起。)

我们需要告诉Zip如何组合项目。它接受一个带有两个参数的 lambda 函数,将来自两个源的项目对作为这些参数传递,并生成你的 lambda 函数返回的输出项目。示例 10-52 使用一个选择器,通过字符串连接组合每对项目。

示例 10-52. 使用Zip组合列表
string[] firstNames = { "Elisenda", "Jessica", "Liam" };
string[] lastNames = { "Gascon", "Hill", "Mooney" };
`IEnumerable``<``string``>` `fullNames` `=` `firstNames``.``Zip``(``lastNames``,`
    `(``first``,` `last``)` `=``>` `first` `+` `" "` `+` `last``)``;`
foreach (string name in fullNames)
{
    Console.WriteLine(name);
}

此示例中Zip组合在一起的两个列表包含名字和姓氏。输出如下:

Elisenda Gascon
Jessica Hill
Liam Mooney

如果输入源包含不同数量的项目,Zip将在达到较短集合的末尾时停止,并且不会尝试从较长的集合中获取更多项目。它不会将不匹配的长度视为错误。

Zip还有一些不需要 lambda 函数的重载。这些重载只返回一个元组序列。有两个版本:一个是组合一对序列,产生 2 元组的版本,另一个是接受三个序列,将它们组合成 3 元组的版本。(没有对应的三个输入 lambda-based Zip。)

SequenceEqual 运算符与 Zip 类似,它对两个序列进行操作,并处理两个序列中在相同位置上找到的项目对。但是,SequenceEqual 只是比较每对项目是否相等,而不是将它们传递给 lambda 表达式进行组合。如果比较过程发现两个源包含相同数量的项,并且对于每一对,两个项目都相等,则返回 true。如果源长度不同,或者仅有一对项目不相等,则返回 falseSequenceEqual 有两个重载,一个只接受用于与源比较的列表,另一个还接受一个 IEqualityComparer<T> 以自定义相等的含义。

分组

有时候,你会想要将具有共同特点的所有项目作为一组进行处理。示例 10-53 使用查询按类别对课程进行分组,在列出该类别下的所有课程之前写出每个类别的标题。

示例 10-53. 分组查询表达式
var subjectGroups = from course in Course.Catalog
                    group course by course.Category;

foreach (var group in subjectGroups)
{
    Console.WriteLine("Category: " + group.Key);
    Console.WriteLine();

    foreach (var course in group)
    {
        Console.WriteLine(course.Title);
    }
    Console.WriteLine();
}

group 子句接受一个表达式,用于确定组成员资格——在本例中,任何返回相同值的 Category 属性的课程都将被视为同一组的成员。 group 子句生成一个集合,其中每个项实现表示组的类型。由于我正在使用 LINQ 对象,且按类别字符串进行分组,在 示例 10-53 中 subjectGroup 变量的类型将为 IEnumerable<IGrouping<string, Course>>。此特定示例生成了三个组对象,如 图 10-1 所示。

每个 IGrouping<string, Course> 项都有一个 Key 属性,由于查询通过课程的 Category 属性对项进行分组,每个键包含来自该属性的字符串值。在 示例 10-17 中的示例数据中有三个不同的类别名称:MATBIOCSE,因此这些是三个组的 Key 值。

IGrouping<TKey, TItem> 接口派生自 IEnumerable<TItem>,因此可以枚举每个组对象以查找它包含的项。因此,在 示例 10-53 中,外部 foreach 循环遍历查询返回的三个组,然后内部 foreach 循环遍历每个组中的 Course 对象。

图 10-1. 评估分组查询的结果

查询表达式变成了 示例 10-54 中的代码。

示例 10-54. 扩展简单分组查询
var subjectGroups = Course.Catalog.GroupBy(course => course.Category);

查询表达式在分组主题上提供了一些变体。通过对原始查询进行轻微修改,我们可以安排每个组中的项目不再是原始的Course对象。在示例 10-55 中,我已将group关键字后面的表达式从course改为了course.Title

示例 10-55. 使用项目投影的分组查询
var subjectGroups = from course in Course.Catalog
                    group course.Title by course.Category;

这仍然具有相同的分组表达式course.Category,因此仍然会生成三个组,但现在它的类型是IGrouping<string, string>。如果您迭代其中一个组的内容,您会发现每个组提供了一个字符串序列,其中包含课程名称。正如示例 10-56 所示,编译器会将此查询扩展为GroupBy操作符的另一个重载。

示例 10-56. 使用项目投影扩展的分组查询
var subjectGroups = Course.Catalog
    .GroupBy(course => course.Category, course => course.Title);

查询表达式要求其最后一个子句必须是selectgroup之一。然而,如果一个查询包含group子句,它不必是最后一个子句。在示例 10-55 中,我修改了查询如何表示每个组内的每个项目(即图 10-1 右侧的框),但我也可以自定义表示每个组的对象(左侧的项目)。默认情况下,我会得到IGrouping<TKey, TItem>对象(或者对于查询使用的任何 LINQ 提供程序的等效对象),但我可以更改这一点。示例 10-57 在其group子句中使用了可选的into关键字。这引入了一个新的范围变量,可以遍历组对象,我可以继续在查询的其余部分使用它。我可以跟随其他子句类型,比如orderbywhere,但在这种情况下,我选择使用了一个select子句。

示例 10-57. 使用组投影的分组查询
var subjectGroups =
    from course in Course.Catalog
    group course by course.Category into category
    select $"Category '{category.Key}' contains {category.Count()} courses";

此查询的结果是一个IEnumerable<string>,如果显示它生成的所有字符串,会得到以下内容:

Category 'MAT' contains 3 courses
Category 'BIO' contains 2 courses
Category 'CSE' contains 1 courses

如示例 10-58 所示,这会扩展为调用与示例 10-54 相同的GroupBy重载,然后在最后一个子句中使用普通的Select操作符。

示例 10-58. 扩展的组投影分组查询
IEnumerable<string> subjectGroups = Course.Catalog
    .GroupBy(course => course.Category)
    .Select(category =>
        $"Category '{category.Key}' contains {category.Count()} courses");

LINQ to Objects 定义了一些更多的GroupBy操作符重载,这些重载不能从查询语法中访问。示例 10-59 展示了一个提供稍微更直接等效于示例 10-57 的重载。

示例 10-59. 使用键和组投影的GroupBy
IEnumerable<string> subjectGroups = Course.Catalog.GroupBy(
    course => course.Category,
    (category, courses) =>
        $"Category '{category}' contains {courses.Count()} courses");

此重载采用两个 lambda 表达式。第一个是用于分组项目的表达式。第二个用于生成每个组对象。与之前的示例不同,这不使用IGrouping<TKey, TItem>接口。相反,最后一个 lambda 接收关键字作为一个参数,然后作为第二个参数接收组中项目的集合。这与IGrouping<TKey, TItem>封装的信息完全相同,但因为此操作符的此形式可以将它们作为单独的参数传递,所以它消除了需要创建用于表示组的对象的必要性。

在示例 10-60 中还展示了该操作符的另一个版本。它结合了所有其他变体的功能。

示例 10-60. 带有键、项目和组投影的GroupBy操作符
IEnumerable<string> subjectGroups = Course.Catalog.GroupBy(
    course => course.Category,
    course => course.Title,
    (category, titles) =>
         $"Category '{category}' contains {titles.Count()} courses: " +
             string.Join(", ", titles));

此重载采用三个 lambda 表达式。第一个是用于分组项目的表达式。第二个确定如何表示组内各个项目——这次我选择提取课程标题。第三个 lambda 用于生成每个组对象,就像示例 10-59 一样,最后一个 lambda 将关键字作为一个参数传递,并将其它参数作为组项目传递,由第二个 lambda 转换。因此,第二个参数不再是原始的Course项目,而是包含课程标题的IEnumerable<string>,因为这是本示例中第二个 lambda 请求的内容。GroupBy操作符的结果再次是一个字符串集合,但现在看起来像这样:

Category 'MAT' contains 3 courses: Elements of Geometry, Squaring the Circle, Hy
perbolic Geometry
Category 'BIO' contains 2 courses: Recreational Organ Transplantation, Introduct
ion to Human Anatomy and Physiology
Category 'CSE' contains 1 courses: Oversimplified Data Structures for Demos

我展示了GroupBy操作符的四个版本。所有四个版本都接受一个 lambda,用于选择用于分组的键,而最简单的重载仅接受键本身。其他版本让您控制组内各个项目的表示形式,或者每个组的表示形式,或者两者兼而有之。这个操作符还有另外四个版本,它们提供了与我已展示的四个版本完全相同的功能,但还接受一个IEqualityComparer<T>,让您可以自定义用于分组目的的逻辑来确定两个键是否被视为相同。

有时按多个值分组很有用。例如,假设您想按类别和出版年份分组课程。您可以链接操作符,首先按类别分组,然后按类别内的年份分组(或反之)。但您可能不希望这种嵌套水平——您可能希望将课程分组到每个唯一的Category和出版年份组合下。做法很简单,只需将两个值放入键中,可以通过使用匿名类型实现,如示例 10-61 所示。

示例 10-61. 复合组键
var bySubjectAndYear =
    from course in Course.Catalog
    group course by new { course.Category, course.PublicationDate.Year };
foreach (var group in bySubjectAndYear)
{
    Console.WriteLine($"{group.Key.Category} ({group.Key.Year})");
    foreach (var course in group)
    {
        Console.WriteLine(course.Title);
    }
}

这利用了匿名类型为我们实现了 EqualsGetHashCode 的事实。它适用于所有形式的 GroupBy 操作符。对于不将它们的 lambda 表达式视为表达式的 LINQ 提供程序(例如 LINQ to Objects),您可以改用元组,这样会更加简洁,但效果相同。

还有另一个分组输出的运算符称为 GroupJoin,但它作为联接操作的一部分执行,我们将先看一些更简单的联接。

联接操作

LINQ 定义了一个 Join 操作符,使得查询可以使用来自其他源的相关数据,就像数据库查询可以将一张表中的信息与另一张表中的数据联接一样。假设我们的应用程序存储了哪些学生报名了哪些课程的列表。如果将该信息存储在文件中,您不希望将课程或学生的完整详细信息复制到每一行中,而是只希望有足够的信息来识别学生和特定的课程。在我的示例数据中,课程通过类别和编号的组合唯一标识。为了跟踪谁报名了什么课程,我们需要记录包含三个信息:课程类别、课程编号以及用于识别学生的某些信息。在 示例 10-62 中的记录类型显示了我们可以如何在内存中表示这种关联。

Example 10-62. 记录类型关联学生和课程
public record CourseChoice(int StudentId, string Category, int Number);

一旦我们的应用程序将这些信息加载到内存中,我们可能希望访问 Course 对象,而不仅仅是识别课程的信息。我们可以通过 join 子句实现这一点,如 示例 10-63 所示(它还使用 CourseChoice 类提供了一些额外的示例数据,以便查询有可用的数据)。

Example 10-63. 使用 join 子句查询
CourseChoice[] choices =
{
    new CourseChoice(StudentId: 1, Category: "MAT", Number: 101),
    new CourseChoice(StudentId: 1, Category: "MAT", Number: 102),
    new CourseChoice(StudentId: 1, Category: "MAT", Number: 207),
    new CourseChoice(StudentId: 2, Category: "MAT", Number: 101),
    new CourseChoice(StudentId: 2, Category: "BIO", Number: 201),
};

var studentsAndCourses = from choice in choices
                         `join` `course` `in` `Course``.``Catalog`
                           `on` `new` `{` `choice``.``Category``,` `choice``.``Number` `}`
                           `equals` `new` `{` `course``.``Category``,` `course``.``Number` `}`
                         select new { choice.StudentId, Course = course };

foreach (var item in studentsAndCourses)
{
    Console.WriteLine(
        $"Student {item.StudentId} will attend {item.Course.Title}");
}

这显示了 choices 数组中每个条目的一行。它显示了每门课程的标题,因为尽管在输入集合中未提供该信息,但 join 子句定位了课程目录中的相关条目。示例 10-64 展示了编译器如何将 示例 10-63 中的查询转换。

Example 10-64. 直接使用 Join 操作符
var studentsAndCourses = choices.Join(
    Course.Catalog,
    choice => new { choice.Category, choice.Number },
    course => new { course.Category, course.Number },
    (choice, course) => new { choice.StudentId, Course = course });

Join操作符的作用是查找第二个序列中与第一个项目对应的项目。这种对应关系由前两个 lambda 表达式决定;如果这两个 lambda 返回的值相等,则来自两个源的项目将被视为相互对应。本示例使用匿名类型,并依赖于同一程序集中两个结构上相同的匿名类型实例具有相同类型的事实。换句话说,这两个 lambda 都生成具有相同类型的对象。编译器为任何匿名类型生成一个Equals方法,逐个比较每个成员,因此该代码的效果是,如果它们的CategoryNumber属性相等,则认为两行相对应。(再次强调,对于基于IQueryable<T>的提供程序,我们必须使用匿名类型,而不是元组,因为这些 lambda 将转换为表达式树。但由于此示例使用非表达式的提供程序,LINQ 到对象,您可以稍微简化此代码,改用元组。)

我已经设置了这个示例,以便只能有一个匹配项,但如果课程类别和编号由于某种原因不能唯一标识课程会发生什么?如果任何单个输入行有多个匹配项,Join操作符将为每个匹配项生成一个输出项,因此在这种情况下,输出项数量将超过choices数组中的条目数。相反,如果第一个源中的项目在第二个集合中没有对应的项目,Join将不会为该项目生成任何输出项——它实际上会忽略该输入项。

LINQ 提供了一种替代的连接类型,用于处理具有零个或多个相应行的输入行,其方式与Join操作符不同。示例 10-65 展示了修改后的查询表达式。(区别在于join子句末尾添加了into courses,最终的select子句引用该子句而不是course范围变量。)这会以不同的形式生成输出,因此我还修改了编写结果的代码。

示例 10-65. 分组连接
var studentsAndCourses =
    from choice in choices
    join course in Course.Catalog
      on new { choice.Category, choice.Number }
      equals new { course.Category, course.Number }
      `into` `courses`
    select new { choice.StudentId, Courses = courses };

foreach (var item in studentsAndCourses)
{
    Console.WriteLine($"Student {item.StudentId} will attend " +
        string.Join(",", item.Courses.Select(course => course.Title)));
}

如示例 10-66 所示,这导致编译器生成对GroupJoin操作符的调用,而不是Join

示例 10-66. GroupJoin操作符
var studentsAndCourses = choices.GroupJoin(
    Course.Catalog,
    choice => new { choice.Category, choice.Number },
    course => new { course.Category, course.Number },
    (choice, courses) => new { choice.StudentId, Courses = courses });

这种连接形式通过调用最终的 lambda 为输入集合中的每个项目生成一个结果。它的第一个参数是输入项,第二个参数将是来自第二个集合的所有相应对象的集合。(与Join相比,后者为每个匹配项调用最终的 lambda 一次,逐个传递相应的项目。)这提供了一种表示第二个集合中没有相应项目的输入项的方法:操作符可以简单地传递一个空集合。

JoinGroupJoin还有重载,接受IEqualityComparer<T>以便您可以为前两个 lambda 返回的值定义自定义的相等性意义。

转换

有时您需要将一个类型的查询转换为另一种类型。例如,您可能已经得到一个集合,其中类型参数指定了某个基本类型(例如object),但您有充分理由相信该集合实际上包含某些更具体类型的项目(例如Course)。在处理单个对象时,您可以使用 C#的转型语法将引用转换为您认为正在处理的类型。不幸的是,这对于诸如IEnumerable<T>IQueryable<T>之类的类型并不适用。

虽然协变意味着IEnumerable<Course>可以隐式转换为IEnumerable<object>,但即使使用显式向下转换也不能反向转换。如果您有一个类型为IEnumerable<object>的引用,试图将其转换为IEnumerable<Course>将仅在对象实现IEnumerable<Course>时成功。很可能最终得到一个完全由Course对象组成但不实现IEnumerable<Course>的序列。注意,示例 10-67 创建了这样的序列,当试图转换为IEnumerable<Course>时将抛出异常。

示例 10-67. 如何避免对序列进行强制转换
IEnumerable<object> sequence = Course.Catalog.Select(c => (object) c);
var courseSequence = (IEnumerable<Course>) sequence; // InvalidCastException

当然,这是一个刻意设计的示例。我通过将Select lambda 的返回类型强制转换为object来强制创建一个IEnumerable<object>。然而,在稍微复杂的情况下,很容易陷入这种情况。幸运的是,有一个简单的解决方案。您可以使用Cast<T>运算符,如示例 10-68 所示。

示例 10-68. 如何对序列进行强制转换
var courseSequence = sequence.Cast<Course>();

这返回一个查询,按顺序产生其来源中的每个项目,但在执行此操作时将每个项目转换为指定的目标类型。这意味着尽管最初的Cast<T>可能成功,但在稍后尝试从序列中提取值时可能会得到InvalidCastException。毕竟,通常来说,Cast<T>运算符能够验证您提供的序列确实只产生类型为T的值的唯一方法是提取所有这些值并尝试转换它们。它无法预先评估整个序列,因为您可能提供了一个无限序列。如果您的序列首次产生的十亿个项目是正确类型的,但之后返回一个不兼容类型的项目,那么Cast<T>发现这一点的唯一方法就是逐个尝试转换项目。

小贴士

Cast<T>OfType<T> 看起来相似,有时开发人员在应该使用另一个时使用了一个(通常是因为他们不知道两者都存在)。OfType<T> 几乎与 Cast<T> 做的事情一样,但它会静默地过滤掉任何错误类型的项目,而不是抛出异常。如果您期望并希望忽略错误类型的项目,请使用 OfType<T>。如果您不期望错误类型的项目出现,请使用 Cast<T>,因为如果您错了,它会通过抛出异常来告诉您,减少隐藏潜在 bug 的风险。

LINQ to Objects 定义了一个 AsEnumerable<T> 运算符。它只是返回源而没有任何修改——在运行时没有任何效果。其目的是即使处理可能由不同的 LINQ 提供程序处理的内容,也强制使用 LINQ to Objects。例如,假设您有一个实现了 IQueryable<T> 的东西。该接口从 IEnumerable<T> 派生,但是适用于 IQueryable<T> 的扩展方法将优先于 LINQ to Objects。如果您的意图是在数据库上执行特定查询,然后使用 LINQ to Objects 进行进一步的客户端处理结果,您可以使用 AsEnumerable<T> 来划定界限,表示“这是我们将事务移到客户端的地方”。

相反,还有 AsQueryable<T>。它设计用于具有静态类型 IEnumerable<T> 变量的场景,您认为该变量可能包含对也实现 IQueryable<T> 的对象的引用,并且您希望任何创建的查询都使用该引用而不是 LINQ to Objects。如果您在一个实际上不实现 IQueryable<T> 的源上使用此运算符,则返回一个实现 IQueryable<T> 的包装器,但在内部使用 LINQ to Objects。

另一个选择不同 LINQ 口味的运算符是 AsParallel。它返回 ParallelQuery<T>,允许您构建由并行 LINQ 执行的查询,这是一个能够利用多个 CPU 核心并行执行某些操作以提高性能的 LINQ 提供程序。

有一些运算符可以将查询转换为其他类型,同时也会立即执行查询,而不是在先前查询的基础上构建新的查询链。ToArrayToListToHashSet 分别返回包含执行输入查询完整结果的数组、列表或哈希集合。ToDictionaryToLookup 同样如此,但它们不是产生简单的项目列表,而是生成支持关联查找的结果。ToDictionary 返回 Dictionary<TKey, TValue>,因此适用于键对应于唯一值的场景。ToLookup 设计用于键可能关联多个值的场景,因此返回不同类型 ILookup<TKey, TValue>

我在 Chapter 5 中没有提及此查找接口,因为它特定于 LINQ。它与只读字典接口本质上相同,只是索引器返回 IEnumerable<TValue> 而不是单个 TValue

数组和列表转换不需要参数,但字典和查找转换需要告诉每个源项要使用的键值。正如 Example 10-69 所示,通过传递 lambda 来告诉它们。这里使用课程的 Category 属性作为键。

Example 10-69. 创建查找
ILookup<string, Course> categoryLookup =
    Course.Catalog.ToLookup(course => course.Category);
foreach (Course c in categoryLookup["MAT"])
{
    Console.WriteLine(c.Title);
}

ToDictionary 操作符提供了一个重载,参数与 ToLookup 相同,但返回字典而不是查找表。如果你像在 Example 10-69 中调用 ToLookup 一样调用它,会抛出异常,因为多个课程对象共享类别,它们会映射到相同的键。ToDictionary 要求每个对象具有唯一的键。要从课程目录生成字典,你需要首先按类别分组数据,并使每个字典条目引用整个组,或者需要一个 lambda 返回基于课程类别和编号的复合键,因为该组合对于课程是唯一的。

这两个操作符还提供了一个重载,接受一对 lambda 表达式——一个提取键,另一个选择用作相应值的内容(您无需使用源项作为值)。最后,还有接受 IEqualityComparer<T> 的重载。

现在您已经看到所有标准 LINQ 操作符,但由于这占据了相当多的页面,您可能会发现具有简洁摘要很有用。Table 10-1 列出了这些操作符,并简要描述了每个操作符的用途。

Table 10-1. LINQ 操作符摘要

操作符 目的
Aggregate 通过用户提供的函数组合所有项以产生单个结果。
All 如果对所有项条件均不满足,返回true
Any 如果对至少一个项满足条件,返回true
Append 返回具有所有输入序列中的项及末尾添加的一项的序列。
AsEnumerable 将序列作为 IEnumerable<T> 返回。(强制使用 LINQ to Objects 很有用。)
AsParallel 返回用于并行查询执行的ParallelQuery<T>
AsQueryable 确保在可用时使用 IQueryable<T> 处理。
Average 计算项的算术平均值。
Cast 将序列中的每个项转换为指定类型。
Chunk 将序列分割成相等大小的批次。
Concat 通过连接两个序列形成一个新序列。
Contains 如果序列中包含指定的项,则返回true
Count, LongCount 返回序列中的项数。
DefaultIfEmpty 生成源序列的元素,除非没有元素,此时生成一个具有默认值的单个元素。
Distinct 删除重复值。
DistinctBy 删除投影生成重复值的值。
ElementAt 返回指定位置的元素(如果超出范围则抛出异常)。
ElementAtOrDefault 返回指定位置的元素(如果超出范围则生成元素类型的默认值)。
Except 过滤掉在另一个提供的集合中的项目。
First 返回第一个项目,如果没有项目则抛出异常。
FirstOrDefault 如果没有项目,则返回第一个项目或默认值。
GroupBy 将项目分组。
GroupJoin 根据它们与输入序列中项目的关系,对另一个序列中的项目进行分组。
Intersect 过滤掉不在另一个提供的集合中的项目。
IntersectBy 使用投影进行比较的Intersect
Join 对两个输入序列中每个匹配对的项目生成一个项目。
Last 返回最后一个项目,如果没有项目则抛出异常。
LastOrDefault 如果没有项目,则返回最后一个项目或默认值。
Max 返回最高值。
MaxBy 返回投影产生最高值的项目。
Min 返回最低值。
MinBy 返回投影产生最低值的项目。
OfType 过滤掉不是指定类型的项目。
OrderBy 以升序生成项目。
OrderByDescending 以降序生成项目。
Prepend 返回以指定单个项目开始,后跟其输入序列中所有项目的序列。
Reverse 以与输入相反的顺序生成项目。
Select 通过函数对每个项目进行投影。
SelectMany 将多个集合合并为一个。
SequenceEqual 仅当所有项目与另一个提供的序列中的项目相等时返回true
Single 返回唯一项目,如果没有项目或多于一个项目则抛出异常。
SingleOrDefault 返回唯一项目或默认值(如果没有项目则抛出异常);如果存在多个项目则抛出异常。
Skip 从开头过滤指定数量的项目。
SkipLast 从末尾过滤掉指定数量的项目。
SkipWhile 从开头开始过滤项目,直到项目不匹配为止。
Sum 返回所有项目相加的结果。
Take 生成指定数量或范围的项目,丢弃其余项目。
TakeLast 从输入的末尾生成指定数量的项目(丢弃之前的所有项目)。
TakeWhile 只要项目匹配谓词,就生成项目;一旦有一个项目不匹配,就丢弃其余序列。
ToArray 返回包含所有项目的数组。
ToDictionary 返回包含所有项目的字典。
ToHashSet 返回包含所有项目的HashSet<T>
ToList 返回包含所有项目的List<T>
ToLookup 返回包含所有项目的多值关联查找。
Union 生成位于任一输入中或两者中的所有项目。
UnionBy Union相同,但使用投影进行比较。
Where 过滤掉不符合提供的谓词的项目。
Zip 将来自两个或三个输入的相同位置的项目组合在一起。

序列生成

Enumerable类定义了扩展方法,用于IEnumerable<T>,构成了 LINQ to Objects。它还提供了一些额外的(非扩展)静态方法,用于创建新的序列。Enumerable.Range接受两个int参数,并返回一个IEnumerable<int>,产生从第一个参数值开始的连续递增数字系列,包含第二个参数指定的数字个数。例如,Enumerable.Range(15, 10)生成包含数字 15 到 24(包括)的序列。

Enumerable.Repeat<T>接受类型为T的值和计数。它返回一个序列,该序列将产生指定次数的该值。

Enumerable.Empty<T>返回一个不包含任何元素的IEnumerable<T>。这听起来可能不是很有用,因为有一个更简洁的替代方案。你可以写new T[0],它创建一个不包含任何元素的数组(类型为T的数组实现了IEnumerable<T>)。然而,Enumerable.Empty<T>的优点在于,对于任何给定的T,它每次返回相同的实例。这意味着如果由于任何原因你需要在执行许多迭代的循环中重复使用空序列,Enumerable.Empty<T>更高效,因为它对 GC 的压力较小。

其他 LINQ 实现

本章中大多数我展示的示例都使用了 LINQ to Objects,除了少数几个引用了 EF Core。在这最后一节中,我将快速描述一些其他基于 LINQ 的技术。这并不是一个详尽的列表,因为任何人都可以编写 LINQ 提供程序。

实体框架核心

我展示的数据库示例使用了 Entity Framework Core(EF Core)的 LINQ 提供程序。EF Core 是一种数据访问技术,以 NuGet 包Microsoft.EntityFrameworkCore的形式提供。(EF Core 的前身,Entity Framework,仍内置于.NET Framework 中,但不包括在较新版本的.NET 中。)EF Core 可以在数据库和对象层之间进行映射。它支持多个数据库供应商。

EF Core 依赖于 IQueryable<T>。对于数据模型中的每个持久化实体类型,EF 可以提供一个实现 IQueryable<T> 的对象,作为构建检索该类型和相关类型实体查询的起点。由于 IQueryable<T> 不仅仅适用于 EF,您将使用 System.Linq 命名空间中提供的标准扩展方法集,但该机制设计用于允许每个提供程序插入其自己的行为。

因为 IQueryable<T> 将 LINQ 操作符定义为接受 Expression<T> 参数的方法,而不是普通的委托类型,所以您在查询表达式或作为底层操作符方法的 lambda 参数中编写的任何表达式都将转换为由编译器生成的代码,创建表示表达式结构的对象树。EF 依赖于此能力来生成检索所需数据的数据库查询。这意味着您必须使用 lambda;与 LINQ to Objects 不同,您不能在 EF 查询中使用匿名方法或委托。

警告

因为 IQueryable<T> 派生自 IEnumerable<T>,所以可以在任何 EF 源上使用 LINQ to Objects 操作符。您可以通过 AsEnumerable<T> 操作符明确地执行此操作,但如果使用的重载支持 LINQ to Objects 而不支持 IQueryable<T>,也可能会发生意外情况。例如,如果尝试使用委托而不是 lambda 作为 Where 操作符的谓词,这将回退到 LINQ to Objects。这里的要点是,EF 最终会下载整个表的内容,然后在客户端上评估 Where 操作符。这不太可能是一个好主意。

并行 LINQ(PLINQ)

并行 LINQ 与 LINQ to Objects 相似,因为它基于对象和委托,而不是表达式树和查询翻译。但是,当您开始从查询请求结果时,它将尽可能使用多线程评估,利用线程池来有效地使用可用的 CPU 资源。第十六章 将展示多线程操作的实际效果。

XML 的 LINQ

LINQ to XML 不是一个 LINQ 提供程序。我在这里提到它,因为它的名称听起来像一个。它真正是一个用于创建和解析 XML 文档的 API。它被称为LINQ to XML,因为它旨在通过 .NET 对象模型轻松执行对 XML 文档的 LINQ 查询,但它通过 .NET 对象模型来呈现 XML 文档来实现这一点。运行库提供了两个单独的 API 来实现这一点:除了 LINQ to XML 外,它还提供了 XML 文档对象模型(DOM)。DOM 基于一个平台无关的标准,因此与 .NET 习惯用法不太匹配,并且与大多数运行库相比感觉不必要地古怪。LINQ to XML 纯粹是为 .NET 设计的,因此它与普通的 C# 技术集成得更好。这包括与 LINQ 良好地配合工作,它通过提供从文档中提取特性的方法来推迟到 LINQ to Objects 来定义和执行查询。

IAsyncEnumerable<T>

正如第五章所述,.NET 定义了IAsyncEnumerable<T>接口,这是IEnumerable<T>的异步等价物。第十七章将描述语言特性,使您能够使用这个接口。虽然 .NET 运行库中没有内置完整的 LINQ 操作符集,但它们在一个名为System.Linq.Async的 NuGet 包中可用。

响应式扩展

.NET 的响应式扩展(或简称为 Rx)是下一章的主题,因此我不会在这里过多介绍它们,但它们很好地说明了 LINQ 操作符如何在各种类型上工作。Rx 反转了本章展示的模型,我们可以在准备好并且需要数据时调用一个 Rx 源,而不是编写一个foreach循环来迭代查询,或者调用诸如ToArraySingleOrDefault等评估查询的运算符。

尽管如此,Rx 有一个 LINQ 提供程序,支持大多数标准的 LINQ 操作符。

总结

在本章中,我展示了支持一些最常用的 LINQ 特性的查询语法。这使我们能够在 C# 中编写类似于数据库查询的查询,但可以查询任何 LINQ 提供程序,包括 LINQ to Objects,使我们能够针对我们的对象模型运行查询。我展示了用于查询的标准 LINQ 操作符,所有这些操作符都可以在 LINQ to Objects 中使用,大多数可以在数据库提供程序中使用。我还提供了一些常见的 .NET 应用程序的 LINQ 提供程序的快速概述。

我提到的最后一个提供程序是 Rx。但在我们查看 Rx 的 LINQ 提供程序之前,下一章将从如何使用 Rx 本身开始。

¹ 当我写这篇文章时,.NET 7.0 的初步功能集包括修复这个问题,因此有一些希望这可能会得到改善。

² 如果你这样做,请注意不要将其与另一种 WPF 类型Rectangle混淆。那是一个更为复杂的实体,支持动画、样式、布局、用户输入、数据绑定以及其他各种 WPF 功能。请勿在 WPF 应用程序之外尝试使用Rectangle

³ 这与在前面示例中使用的Rect.Union方法无关。

第十一章:反应式扩展

.NET 反应式扩展(通常简称为Rx)专为处理异步和基于事件的信息源而设计。Rx 提供了帮助您编排和同步代码对这些类型数据反应的服务。我们已经看到如何在 第九章 中定义和订阅事件,但 Rx 提供的远不止这些基本功能。它提供了一个比事件更陡峭的事件源抽象,但却配备了一组强大的操作符,使得组合和管理多个事件流比使用委托和 .NET 事件提供的自由组合更加容易。微软还推出了一个名为 Reaqtor 的相关库,它基于 Rx 的基础提供了一个可靠、有状态、分布式、可扩展、高性能的事件处理服务框架。

Rx 的基本抽象是 IObservable<T>,它表示一个项的序列,其操作符定义为此接口的扩展方法。这听起来很像 LINQ to Objects,它们确实有相似之处 —— 不仅 IObservable<T>IEnumerable<T> 有很多共同之处,而且 Rx 也支持几乎所有标准的 LINQ 操作符。如果您熟悉 LINQ to Objects,那么您在 Rx 中也会感到如鱼得水。区别在于,在 Rx 中,序列不那么被动。与 IEnumerable<T> 不同,Rx 源不等待请求其项,消费者也不能要求提供下一个项。相反,Rx 使用一种推送模型,在此模型中,源在项可用时通知其接收者。

举例来说,如果您正在编写一个处理实时金融信息(例如股市价格数据)的应用程序,IObservable<T> 模型比 IEnumerable<T> 更为自然。因为 Rx 实现了标准的 LINQ 操作符,您可以对实时数据源编写查询 —— 您可以通过 where 子句筛选事件流,或者按股票代码分组。Rx 不仅限于标准的 LINQ,它还添加了自己的操作符,考虑了实时事件源的时间性质。例如,您可以编写一个查询,仅提供更频繁变动价格的股票数据。

Rx 的推送式方法使其比IEnumerable<T>更适合类似事件的源。但为什么不直接使用事件,甚至普通的委托呢?Rx 解决了这些替代方案的四个缺点。首先,它定义了源报告错误的标准方式。其次,在涉及多个源的多线程场景中,它能够以明确定义的顺序传递项。第三,Rx 提供了清晰的方法来信号化没有更多项的时候。第四,因为传统事件是特殊类型的成员,而不是正常的对象,所以对于事件的使用有显著的限制——你不能将.NET 事件作为参数传递给方法,存储在字段中或在属性中提供。你可以使用委托来处理事件,但这并不相同——委托可以处理事件,但不能表示它们的源。没有办法编写一个订阅某个.NET 事件的方法,并将其作为参数传递,因为你不能传递实际的事件本身。Rx 通过将事件源表示为对象而不是类型系统中不像其他任何东西的特殊的独特元素来修复了这一点。

当然,在IEnumerable<T>的世界中,我们可以免费获得这四个特性。集合在枚举其内容时可能会抛出异常,但使用回调时,何时何地传递异常就不那么明显了。IEnumerable<T>让消费者逐个检索项,所以排序是明确的,但使用普通事件和委托时,并没有强制执行这一点。而IEnumerable<T>告诉消费者集合已经结束时,使用简单回调时,并不一定清楚何时发出了最后一次调用。IObservable<T>处理了所有这些情况,将我们在IEnumerable<T>中可以理所当然的事情带入了事件的世界。

通过提供一个统一的抽象来解决这些问题,Rx 能够将 LINQ 的所有优势带入事件驱动的场景中。如果 Rx 能够替代事件的话,我就不会在第九章中专门提到它们了。事实上,Rx 可以与事件集成。它可以在其自身的抽象和其他几种抽象之间架起桥梁,不仅仅是普通事件,还有IEnumerable<T>和各种异步编程模型。远非淘汰事件,Rx 将它们的功能提升到了一个新的水平。理解 Rx 要比理解事件难得多,但一旦理解了,它提供的能力就远超过后者。

Rx 的核心是两个接口。通过这个模型展示项的源实现了IObservable<T>。订阅者需要提供一个实现了IObserver<T>的对象。这两个接口内置于.NET 中。Rx 的其他部分包含在System.Reactive NuGet 包中。

基本接口

Rx 中最重要的两种类型是 IObservable<T>IObserver<T> 接口。它们足够重要,以至于位于 System 命名空间中。示例 11-1 显示了它们的定义。

示例 11-1. IObservable<T>IObserver<T>
public interface IObservable<out T>
{
    IDisposable Subscribe(IObserver<T> observer);
}

public interface IObserver<in T>
{
    void OnCompleted();
    void OnError(Exception error);
    void OnNext(T value);
}

Rx 中的基本抽象 IObservable<T> 由事件源实现。它模拟事件作为项目序列,而不是使用 event 关键字。IObservable<T> 根据准备好提供项目时为订阅者提供项目。

正如您所看到的,IObservable<T> 的类型参数是协变的,这意味着如果您有一个类型 Base 是另一个类型 Derived 的基类型,那么就像您可以将 Derived 传递给任何期望 Base 的方法一样,您可以将 IObservable<Derived> 传递给任何期望 IObservable<Base> 的东西。直观地看,这里使用 out 关键字是有道理的,因为像 IEnumerable<T> 一样,这是信息的来源——项目从中出来。相反,项目进入订阅者的 IObserver<T> 实现,因此它具有 in 关键字,表示逆变性——您可以将 IObserver<Base> 传递给任何期望 IObserver<Derived> 的东西。(我在 第六章 中描述了变体。)

我们可以通过将 IObserver<T> 的实现传递给 Subscribe 方法来订阅源。当源希望报告事件时,它将调用 OnNext,并且可以调用 OnCompleted 来指示不再有进一步的活动。如果源希望报告错误,它可以调用 OnErrorOnCompletedOnError 都表示流的结束——在此之后,观察者上的任何方法都不应再调用。

警告

如果您违反此规则,不一定会立即收到异常。在某些情况下会收到异常——如果您使用 NuGet 的 System.Reactive 库来帮助实现和消费这些接口,则某些情况下它可以检测到此类错误。但通常情况下,调用这些方法的代码负责遵守这个规则。

表示 Rx 活动的视觉约定有一个视觉约定。有时称为 弹珠图,因为它主要由看起来有点像弹珠的小圆圈组成。图 11-1 使用这种约定来表示两个事件序列。水平线表示对源的订阅,左侧的竖线表示订阅开始,水平位置表示事件发生的时间(从左到右的经过时间)。圆圈表示对 OnNext 的调用(即源报告的事件)。右端的箭头表示订阅在图表表示的时间结束时仍然活动。右侧的竖线表示订阅结束——由于调用 OnErrorOnCompleted 或订阅者取消订阅。

图 11-1. 简单的弹珠图

当您在可观察对象上调用 Subscribe 时,它会返回一个实现 IDisposable 接口的对象,提供取消订阅的方法。如果调用 Dispose,可观察对象将不再向您的观察者发送任何通知。这比取消事件的机制更方便;要取消事件,您必须传入与用于订阅的委托相等的委托。如果您使用匿名方法,这可能会让人感到令人惊讶的笨拙,因为通常唯一的方法是保留对原始委托的引用。使用 Rx,对源的任何订阅都表示为 IDisposable,使其更容易以统一的方式处理。事实上,通常您根本不需要取消订阅 —— 这只有在希望在源完成之前停止接收通知时才是必需的(这是 .NET 中相对不常见的事情的一个示例:可选的可释放性)。

IObserver

如您所见,在实践中,我们通常不直接调用源的 Subscribe 方法,也不通常需要自己实现 IObserver<T>。相反,通常使用 Rx 提供的基于委托的扩展方法,该方法附加了一个 Rx 提供的实现。然而,这些扩展方法不是 Rx 的基本类型的一部分,所以现在我将展示如果这些接口是您唯一拥有的内容,您需要编写什么。示例 11-2 展示了一个简单但完整的观察者。

示例 11-2. 简单的 IObserver<T> 实现
class MySubscriber<T> : IObserver<T>
{
    public void OnNext(T value) => Console.WriteLine("Received: " + value);
    public void OnCompleted() => Console.WriteLine("Complete");
    public void OnError(Exception ex) => Console.WriteLine("Error: " + ex);
}

Rx 源(即 IObservable<T> 的实现)必须对如何调用观察者方法作出某些保证。调用发生在特定顺序中:对于源提供的每个项目,都会调用 OnNext 方法,我已经提到,一旦调用 OnCompletedOnError 中的任何一个,观察者就知道不会再调用这三种方法中的任何一个。这两种方法中的任何一种信号序列的结束。

另外,不允许调用重叠 —— 当可观察源调用我们观察者的方法之一时,必须等待该方法返回后再调用。多线程可观察源必须小心协调其调用,即使在单线程世界中,递归的可能性也可能需要源检测和防止重入调用。

这让观察者的生活变得简单。因为 Rx 将事件作为一个序列提供,我的代码不需要处理并发调用的可能性。调用方法的正确顺序取决于源。因此,尽管 IObservable<T> 界面看起来更简单,只有一个方法,但实际上更难以实现。稍后您会看到,让 Rx 库为您实现这一点通常是最简单的,但了解可观察源如何工作仍然很重要,因此我将从头开始手动实现它。

IObservable

Rx 区分可观察源。热可观察源会在有趣的事情发生时产生每个值,并且如果此时没有订阅者附加,那么该值将丢失。热可观察源通常代表实时事件,例如鼠标输入、按键或传感器报告的数据,因此它们生成的值独立于附加的订阅者数量。热源通常具有类似广播的行为—它们将每个项目发送给所有订阅者。这些可能是更复杂的源的实现方式,因此我将先讨论冷源。

实现冷源

热源根据自己的意愿报告项目,而冷可观察源的工作方式有所不同。它们在观察者订阅时开始推送值,并且将值分别提供给每个订阅者,而不是广播。这意味着订阅者不会因为太迟而错过任何内容,因为源在你订阅时开始提供项目。示例 11-3 展示了一个非常简单的冷源。

示例 11-3. 一个简单的冷可观察源
public class SimpleColdSource : IObservable<string>
{
    public IDisposable Subscribe(IObserver<string> observer)
    {
        observer.OnNext("Hello,");
        observer.OnNext("World!");
        observer.OnCompleted();
        return NullDisposable.Instance;
    }

    private class NullDisposable : IDisposable
    {
        public readonly static NullDisposable Instance = new();
        public void Dispose() { }
    }
}

一旦观察者订阅,这个源将提供两个值,字符串 "Hello,""World!",然后通过调用 OnCompleted 表示序列结束。它在 Subscribe 中完成所有这些操作,所以这实际上看起来不像是一个订阅—在 Subscribe 返回时序列已经结束,所以支持取消订阅没有任何实际意义。这就是为什么这返回一个 IDisposable 的微不足道的实现。(我选择了一个极其简单的示例来展示基础知识。真实的源会更复杂。)

要展示这个过程,我们需要创建一个 SimpleColdSource 的实例,还需要从 示例 11-2 中创建一个我的观察者类的实例,并使用它订阅源,就像 示例 11-4 所做的那样。

示例 11-4. 将观察者附加到可观察源
var source = new SimpleColdSource();
var sub = new MySubscriber<string>();
source.Subscribe(sub);

预计,这将产生以下输出:

Received: Hello,
Received: World!
Complete

一般来说,冷观察者将可以访问某些底层信息源,它可以按需推送给订阅者。在 示例 11-3 中,那个“源”只是两个硬编码的值。示例 11-5 展示了一个稍微有趣的冷可观察源,它读取文件中的行并将它们提供给订阅者。

示例 11-5. 一个表示文件内容的冷可观察源
public class FilePusher : IObservable<string>
{
    private readonly string _path;
    public FilePusher(string path)
    {
        _path = path;
    }

    public IDisposable Subscribe(IObserver<string> observer)
    {
        using (var sr = new StreamReader(_path))
        {
            while (!sr.EndOfStream)
            {
                string? line = sr.ReadLine();
                if (line is not null)
                {
                    observer.OnNext(line);
                }
            }
        }
        observer.OnCompleted();
        return NullDisposable.Instance;
    }

    private class NullDisposable : IDisposable
    {
        public static NullDisposable Instance = new();
        public void Dispose() { }
    }
}

与之前一样,这并不表示事件的实时源,它只有在有订阅发生时才会启动,但比示例 11-3 更加有趣。每当从文件中检索到每一行时,它会调用观察者,因此虽然它开始工作的时间由订阅者确定,但这个源控制它提供值的速率。就像示例 11-3 一样,在Subscribe调用内部,它将所有项传递给调用者线程上的观察者,但从示例 11-5 到一个从文件读取数据时运行在单独线程或使用异步技术(例如第十七章中描述的)的概念跨度可能相对较小,从而使Subscribe在工作完成之前返回(在这种情况下,您需要编写一个更有趣的IDisposable实现来允许调用者取消订阅)。这仍然是一个冷源,因为它代表一些基础数据集,可以从开始为每个订阅者的利益枚举。

示例 11-5 还不完整——它未能处理从文件读取时发生的错误。我们需要捕获这些错误并调用观察者的OnError方法。不幸的是,简单地将整个循环放在try块中并不那么简单,因为这也会捕获来自观察者的OnNext方法的异常。如果OnNext抛出异常,我们应该允许它继续向上堆栈传播——我们应该只处理我们代码中预期的位置出现的异常。不幸的是,这使代码变得相当复杂。示例 11-6 将使用FileStream的所有代码放在try块内,但将允许观察者抛出的任何异常向上传播,因为我们无权处理这些异常。

示例 11-6. 处理文件系统错误但不处理观察者错误
public IDisposable Subscribe(IObserver<string> observer)
{
    StreamReader? sr = null;
    string? line = null;
    bool failed = false;

    try
    {
        while (true)
        {
            try
            {
                if (sr == null)
                {
                    sr = new StreamReader(_path);
                }
                if (sr.EndOfStream)
                {
                    break;
                }
                line = sr.ReadLine();
            }
            catch (IOException x)
            {
                observer.OnError(x);
                failed = true;
                break;
            }

            if (line is not null)
            {
                observer.OnNext(line);
            }
            else
            {
                break;
            }
        }
    }
    finally
    {
        if (sr != null)
        {
            sr.Dispose();
        }
    }
    if (!failed)
    {
        observer.OnCompleted();
    }
    return NullDisposable.Instance;
}

如果在从文件读取时发生 I/O 异常,则会报告给观察者的OnError方法——因此,此源使用IObserver<T>的所有三个方法。

实现热源

热源在数值可用时通知所有当前订阅者。这意味着任何热 observable 必须跟踪当前已订阅的观察者。在热源中,订阅和通知被分开处理,这种方式通常在冷源中是不会出现的。

示例 11-7 是一个可观察源,每次按键报告一个项目,作为热源,它非常简单。它是单线程的,因此不需要采取任何特殊措施来避免重叠调用。它不报告错误,因此从不需要调用观察者的OnError方法。而且它永不停止,因此也不需要调用OnCompleted。即便如此,它也相当复杂。(一旦我介绍 Rx 库支持,情况将会简单得多——目前,我只坚持使用两个基本接口,所以这个示例相对复杂。)

示例 11-7. 用于监控按键的IObservable<T>
public class KeyWatcher : IObservable<char>
{
    private readonly List<Subscription> _subscriptions = new();

    public IDisposable Subscribe(IObserver<char> observer)
    {
        var sub = new Subscription(this, observer);
        _subscriptions.Add(sub);
        return sub;
    }

    public void Run()
    {
        while (true)
        {
            // Passing true here stops the console from showing the character
            char c = Console.ReadKey(true).KeyChar;

            // ToArray duplicates the list, enabling us to iterate over a
            // snapshot of our subscribers. This handles the case where an
            // observer unsubscribes from inside its OnNext method.
            foreach (Subscription sub in _subscriptions.ToArray())
            {
                sub.Observer.OnNext(c);
            }
        }
    }

    private void RemoveSubscription(Subscription sub)
    {
        _subscriptions.Remove(sub);
    }

    private class Subscription : IDisposable
    {
        private KeyWatcher? _parent;
        public Subscription(KeyWatcher parent, IObserver<char> observer)
        {
            _parent = parent;
            Observer = observer;
        }

        public IObserver<char> Observer { get; }

        public void Dispose()
        {
            if (_parent is not null)
            {
                _parent.RemoveSubscription(this);
                _parent = null;
            }
        }
    }
}

这定义了一个名为Subscription的嵌套类,用于跟踪每个订阅的观察者,并提供了我们的Subscribe方法需要返回的IDisposable的实现。Observable 在Subscribe期间创建此嵌套类的新实例,并将其添加到当前订阅者列表中;如果调用了Dispose,则从该列表中移除自身。

作为.NET 的一般规则,在使用完代表您的资源分配的任何IDisposable资源时,应调用Dispose。但在 Rx 中,通常不会处理表示订阅的对象的释放,因此,如果您实现了这样的对象,则不应指望其被处理。这通常是不必要的,因为 Rx 可以为您清理。与普通的.NET 事件或委托不同,可观察对象可以明确地结束,此时分配给订阅者的任何资源都可以释放。(某些会无限期运行,但在这种情况下,订阅通常会保持活动状态直到程序生命周期结束。)承认,到目前为止我展示的例子并没有自动清理,因为我提供了自己的实现,这些实现足够简单,不需要这样做,但是如果使用 Rx 库的源和订阅者实现,Rx 库会这样做。在 Rx 中,通常只有在您希望在源完成之前取消订阅时,才会处理订阅。

注意

订阅者无需确保通过Subscribe返回的object仍然可访问。如果您不需要早期取消订阅的能力,则可以忽略它,并且如果垃圾收集器释放了对象,则不会有任何影响,因为 Rx 提供的代表订阅的IDisposable实现中没有任何终结器。(虽然通常不会自己实现这些——我在这里只是为了说明它是如何工作的——如果您决定编写自己的实现,请采用相同的方法:不要在代表订阅的类上实现终结器。)

在 示例 11-7 中,KeyWatcher 类有一个 Run 方法。这不是标准的 Rx 特性;它只是一个循环,坐等键盘输入——这个可观察对象实际上不会产生任何通知,除非有东西调用该方法。每次这个循环接收到一个键时,它会在每个当前订阅的观察者上调用 OnNext 方法。请注意,我正在构建订阅者列表的副本(通过调用 ToArray —— 这是让 List<T> 复制其内容的简单方法),因为有可能订阅者在调用 OnNext 过程中选择取消订阅。如果我直接将订阅者列表传递给 foreach,在这种情况下会抛出异常,因为列表不允许在迭代过程中添加和删除项目。

警告

这个例子仅防止在同一线程上重新进入调用;处理多线程取消订阅将会更加复杂。事实上,甚至构建一个副本也不够谨慎。我确实应该检查我的快照中的每个观察者在调用其 OnNext 之前当前是否仍在订阅,因为有可能一个观察者可能选择取消其他观察者的订阅。这也不尝试处理来自另一个线程的取消订阅。稍后,我将用 Rx 库中更加健壮的实现来替换所有这些。

在使用中,这个热源与我的冷源非常相似。我们需要创建一个 KeyWatcher 类的实例,并且还需要另一个观察者类的实例(这次使用 char 类型参数,因为这个源产生的是字符而不是字符串)。因为这个源在其监控循环运行之前不会生成项目,所以我需要调用 Run 来启动它,就像 示例 11-8 那样。

示例 11-8. 将观察者附加到可观察对象
var source = new KeyWatcher();
var sub = new MySubscriber<char>();
source.Subscribe(sub);
source.Run();

运行该代码时,应用程序将等待键盘输入,如果您按下,比如说,m 键,观察者(示例 11-2)将显示消息 Received: m。(由于我的源永不停息,Run 方法将永远不会返回。)

您可能需要处理混合的热和冷可观察对象。此外,一些冷源具有某些热特性。例如,您可以想象一个表示警报消息的源,可能有意义的是以这样一种方式实现它,即存储警报,以确保您不会错过在创建源和附加订阅者之间发生的任何事件。因此,它将是一个冷源——任何新的订阅者都会获得到目前为止的所有事件——但是一旦订阅者赶上了,持续的行为看起来更像是一个热源,因为任何新事件都将被广播给所有当前的订阅者。正如您将看到的,Rx 库提供了各种方法来混合和适应这两种类型的源。

虽然了解观察者和可观察对象需要做什么很有用,但如果让 Rx 来处理这些繁重的工作会更高效。现在我将展示如果你使用 System.Reactive NuGet 库而不仅仅是两个基本接口,你将如何编写源和订阅者。

使用委托发布和订阅

如果你使用 System.Reactive NuGet 包,就不需要直接实现 IObservable<T>IObserver<T>。该库提供了多种实现。其中一些是适配器,用于在 Rx 和其他异步生成序列表示之间桥接。有些是包装现有的可观察流。但这些助手不仅仅用于适配现有内容。它们还可以帮助你编写生成新项或作为最终目标的代码。其中最简单的助手提供了基于委托的 API 来创建和消费可观察流。

使用委托创建可观察源

正如你在之前的一些示例中看到的那样,虽然 IObservable<T> 是一个简单的接口,但实现它的源可能需要做相当多的工作来跟踪订阅者。而且我们还没有看到全部的故事。正如你将在 “Schedulers” 中看到的那样,源经常需要采取额外的措施来确保它与 Rx 的线程机制良好集成。幸运的是,Rx 库可以为我们完成部分工作。示例 11-9 展示了如何使用 Observable 类的静态 Create 方法来实现一个冷源。(每次调用 GetFilePusher 都会创建一个新的源,因此这实际上是一个工厂方法。)

示例 11-9. 基于委托的可观察源
public static IObservable<string> GetFilePusher(string path)
{
    return Observable.Create<string>(observer =>
    {
        using (var sr = new StreamReader(path))
        {
            while (!sr.EndOfStream)
            {
                string? line = sr.ReadLine();
                if (line is not null)
                {
                    observer.OnNext(line);
                }
                else
                {
                    break;
                }
            }
        }
        observer.OnCompleted();
        return () => { };
    });
}

这与 例子 11-5 的目的相同——它提供了一个可观察源,逐行向订阅者提供文件中的每一行。(与 例子 11-5 一样,出于清晰起见,我省略了错误处理。在实践中,你需要像 例子 11-6 那样报告错误。)代码的核心部分是相同的,但我只需要编写一个方法而不是整个类,因为现在 Rx 提供了 IObservable<T> 的实现。每当观察者订阅该可观察对象时,Rx 就会调用我传递给 Create 的回调函数。因此,我所需要做的就是编写提供这些项的代码。除了不需要外部实现 IObservable<T> 的类之外,我还能够省略实现 IDisposable 的嵌套类——Create 方法允许我们返回一个 Action 委托而不是对象,并且如果订阅者选择取消订阅,它将调用该委托。因为我的方法在生成项目后才会返回,所以我只是返回了一个空方法。

我写的代码比示例 11-5 少得多,但是除了简化我的实现外,Observable.Create 对我们还做了两件稍微微妙的事情,这些事情并不立即从代码中显现出来。

首先,如果订阅者提前取消订阅,这段代码现在会正确停止发送项目给它,尽管我没有编写处理这种情况的代码。当观察者订阅这种类型的源时,Rx 不会直接将 IObserver<T> 传递给我们的回调。示例 11-9 中嵌套方法中的 observer 参数指的是一个由 Rx 提供的包装器。如果底层观察者取消订阅,该包装器会自动停止转发通知。我的循环会在订阅者停止监听后继续运行文件,这是浪费的,但至少订阅者在要求停止后不再收到项目。 (也许你会想知道,订阅者如何有机会取消订阅,因为我的代码直到完成才返回。它可以在其 OnNext 方法中执行此操作。)

你可以结合 C# 的异步语言特性(具体来说,是asyncawait关键字)使用 Rx 来实现示例 11-9 的一个版本,这不仅可以更有效地处理取消订阅,还可以异步地从文件中读取数据,意味着订阅不需要阻塞。这显著提升了效率,但代码几乎没有改变。我不会在第十七章介绍异步语言特性,所以这可能现在还不完全明白,但如果你感兴趣,示例 11-10 展示了其实现方式。修改的行已用粗体标出。(再次强调,这是没有错误处理的版本。异步方法可以像同步方法一样处理异常,所以你可以用与示例 11-6 相同的方式处理错误。)

示例 11-10. 异步源
public static IObservable<string> GetFilePusher(string path)
{
    `return` `Observable``.``Create``<``string``>``(``async` `(``observer``,` `cancel``)` `=``>`
    {
        using (var sr = new StreamReader(path))
        {
            `while` `(``!``sr``.``EndOfStream` `&``&` `!``cancel``.``IsCancellationRequested``)`
            {
                `string?` `line` `=` `await` `sr``.``ReadLineAsync``(``)``;`
                if (line is not null)
                {
                    observer.OnNext(line);
                }
                else
                {
                    break;
                }
            }
        }
        observer.OnCompleted();
    });
}

Observable.Create 在幕后为我们做的第二件事,在某些情况下,它将使用 Rx 的调度系统通过工作队列调用我们的代码,而不是直接调用它。这样做可以避免在链式多个 observable 的情况下可能出现的死锁。我将在本章稍后描述调度器。

这种技术适用于冷源,比如示例 11-9。热源的工作方式不同,它将实时事件广播给所有订阅者,Observable.Create 不直接支持它们,因为它每个订阅者只调用一次你传递的委托。不过,Rx 库仍然可以提供帮助。

Rx 为任何 IObservable<T> 提供了一个 Publish 扩展方法,由 System.Reactive.Linq 命名空间中的 Observable 类定义。该方法旨在包装一个仅支持一次运行的订阅方法的源(即您传递给 Observa⁠ble​.Create 的委托),但您希望附加多个订阅者—它为您处理多播逻辑。严格来说,仅支持单个订阅的源是退化的,但只要您将其隐藏在 Publish 后面,这并不重要,您可以将其用作实现热源的方法。Example 11-11 展示了如何创建一个提供与 Example 11-7 中的 KeyWatcher 相同功能的源。我还连接了两个订阅者,仅仅是为了说明这支持多个订阅者的点。

Example 11-11. 基于委托的热源
IObservable<char> singularHotSource = Observable.Create(
    (Func<IObserver<char>, IDisposable>) (obs =>
    {
        while (true)
        {
            obs.OnNext(Console.ReadKey(true).KeyChar);
        }
    }));

IConnectableObservable<char> keySource = singularHotSource.Publish();

keySource.Subscribe(new MySubscriber<char>());
keySource.Subscribe(new MySubscriber<char>());

keySource.Connect();

Publish 方法不会立即在源上调用 Subscribe。当您首次将订阅器附加到返回的源时,也不会立即调用。我必须告诉已发布的源何时启动。请注意,Publish 返回一个 IConnectableObservable<T>。这从 IObservable<T> 派生,并添加了一个额外的方法 Connect。这个接口表示一个在被告知之前不会启动的源,设计用于让您在设置其运行之前连接所有需要的订阅器。在由 Publish 返回的源上调用 Connect 导致它订阅我的原始源,调用我传递给 Observable.Create 的订阅回调并运行我的循环。这使得 Connect 方法具有与在我的原始 Example 11-7 上调用 Run 相同的效果。

Connect 返回一个 IDisposable。这提供了一种在稍后断开连接的方式—即从底层源取消订阅。(如果您不调用此方法,则由 Publish 返回的可连接的可观察对象将保持订阅到您的源,即使您每个单独的下游订阅都 Dispose。)在这个特定的例子中,对 Connect 的调用将永远不会返回,因为我传递给 Observable.Create 的代码也永远不会返回。大多数可观察源不会这样做。通常,它们通过使用异步或基于调度程序的技术来避免这种情况,我将在本章后面展示。

基于委托的 Observable.Create 结合 Publish 提供的多播功能,使我能够丢弃 Example 11-7 中除了实际生成项的循环之外的所有内容,甚至这个循环也变得更简单了。能够删除大约 80% 的代码并不是全部故事。这将工作得更好—Publish 让 Rx 处理我的订阅者,这些订阅者将正确处理在通知期间取消订阅的尴尬情况。

当然,Rx 库不仅有助于实现数据源,还可以简化订阅者。

使用委托订阅可观察源

就像你不必实现IObservable<T>一样,也不必提供IObserver<T>的实现。你并不总是关心这三种方法中的全部——示例 11-7 中的KeyWatcher可观察对象甚至从未调用OnCompletedOnError方法,因为它运行时间无限,并且没有错误检测。即使你需要提供所有三种方法,你也不一定想要编写一个完全独立的类型来提供它们。因此,Rx 库提供了扩展方法来简化订阅,由System命名空间中的ObservableExtensions类定义。大多数 C# 源文件包含using System;指令,或者在一个隐式全局using指令的项目中,对System的引用通常都是可用的,因此它提供的扩展方法也通常可用于任何IObservable<T>。示例 11-12 使用其中一个。

示例 11-12. 在不实现IObserver<T>的情况下订阅
var source = new KeyWatcher();
`source``.``Subscribe``(``value` `=``>` `Console``.``WriteLine``(``"Received: "` `+` `value``)``)``;`
source.Run();

这个示例与示例 11-8 具有相同的效果。然而,通过使用这种方法,我们不再需要像示例 11-2 那样编写一个完整实现IObserver<T>的类。使用这个Subscribe扩展方法,Rx 为我们提供了IObserver<T>的实现,我们只需为我们想要的通知提供方法。

示例 11-12 使用的Subscribe重载接受一个Action<T>,其中TIObservable<T>的项类型,在本例中为char。我的源代码不提供错误通知,也不使用OnCompleted来指示项目结束,但许多源会这样做,因此有三个Subscribe重载来处理这种情况。其中一个接受一个额外的Action<Exception>委托来处理错误。另一个接受一个类型为Action(即不带参数的委托)的第二个委托来处理完成通知。第三个重载接受三个委托——与所有项相关的回调相同,然后是一个异常处理程序和一个完成处理程序。

注意

如果在使用基于委托的订阅时没有提供异常处理程序,但源调用 OnError,Rx 提供的 IObserver<T> 将抛出异常以防止错误被忽略。例子 11-5 在处理 I/O 异常的 catch 块中调用 OnError,如果使用 例子 11-12 中的技术订阅,你会发现调用 OnError 会将 IOException 再次抛出——相同的异常连续抛出两次,一次是由 StreamReader 抛出,然后再由 Rx 提供的 IObserver<T> 实现抛出。由于这时我们已经在 例子 11-5 的 catch 块中(而不是 try 块),这第二次抛出会导致异常从 Subscribe 方法中出现,要么被更高层次处理,要么导致应用程序崩溃。

Subscribe 扩展方法还有一个不带参数的重载。这会订阅一个源,然后对接收到的项不做任何处理。(它会将任何错误抛回给源,就像那些不带错误回调的其他重载一样。)如果你有一个源在订阅时执行了一些重要的副作用,这会很有用,尽管最好避免必须这样设计。

序列生成器

Rx 定义了几种方法,可以从头开始创建新的序列,而无需自定义类型或回调。这些设计用于某些简单的场景,例如单元素序列、空序列或特定模式。这些都是由 Observable 类定义的静态方法。

Observable.Empty<T> 方法类似于 LINQ 到对象中的 Enumerable.Empty<T> 方法,我在第十章中展示过它:它生成一个空序列。(当然,不同之处在于它实现了 IObservable<T> 而不是 IEnumera⁠ble​<T>。)与 LINQ 到对象方法一样,当你需要与要求可观察源的 API 一起工作但没有要提供的项目时,这是非常有用的。

任何订阅 Observable.Empty<T> 序列的观察者都会立即调用其 OnCompleted 方法。

从不

Observable.Never<T> 方法生成一个永不执行任何操作的序列——它不生成任何项目,并且不像空序列那样甚至不会完成。(Rx 团队考虑将其称为 Infinite<T>,以强调除了永不生成任何内容外,它也永不结束。)在 LINQ to Objects 中没有对应物。如果要编写 NeverIEnumerable<T> 等效版本,它将在首次尝试检索项目时无限期地阻塞。在基于拉取的 LINQ to Objects 世界中,这将毫不有用——它将导致调用线程在进程的生命周期内冻结。(IAsyncEnumerable<T> 等效版本将从首次调用 MoveNextAsync 开始返回一个永不完成的 ValueTask<bool>。这不需要阻塞线程,但你仍然会得到一个永远不会完成的逻辑操作。)但在 Rx 的响应式世界中,源不会因为它们处于当前不生成项目的状态而阻塞进度,因此 Never 是一个不那么灾难性的想法。它对我后面将展示的一些运算符可能有所帮助,这些运算符可以使用 IObservable<T> 表示持续时间。Never 可以表示你希望无限期运行的活动。

Return

Observable.Return<T> 方法接受一个单一参数,并返回一个 observable 序列,立即产生该值,然后完成。就像 Empty 在需要序列但没有项目时很有用一样,当需要序列且只有一个项目时,这也很有用。这是一个冷源——你可以订阅任意次数,每个订阅者都会收到相同的值。在 LINQ to Objects 中没有确切的等效物,尽管 Rx 团队提供了一个名为交互扩展(Interactive Extensions for .NET,或简称 Ix,在 System.Interactive NuGet 包中可用)的库,其中包括本章描述的此类和其他几个运算符的 IEnumerable<T> 版本,这些运算符在 Rx 中有但在 LINQ to Objects 中没有。

Throw

Observable.Throw<T> 方法接受一个 Exception 类型的单一参数,并返回一个 observable 序列,立即将该异常传递给任何订阅者的 OnError。与 Return 类似,这也是一个冷源,可以订阅任意次数,并且每个订阅者都将执行相同的操作。

Range

Observable.Range 方法生成一个数字序列。(它总是返回一个 IObservable<int>,这就是为什么它不需要类型参数。)类似于 Enumerable.Range 方法,它接受一个起始数字和一个计数。这是一个冷源,每个订阅者都将产生整个范围。

Repeat

Observable.Repeat<T> 方法接受一个输入并产生一个重复产生该输入的序列。输入可以是单个值,但也可以是另一个可观察序列,在这种情况下,它将转发项目直到输入完成,然后重新订阅以重复整个序列。(这意味着只有在传递一个冷可观察序列时,数据才会真正重复。)

如果你没有传递其他参数,生成的序列将无限产生值,唯一停止的方法是取消订阅。你还可以传递一个计数,表示你希望输入重复多少次。

生成

Observable.Generate<TState, TResult> 方法可以生成比我刚刚描述的其他方法更复杂的序列。你提供给 Generate 一个表示生成器初始状态的对象或值。这可以是任何你喜欢的类型——它是方法的泛型类型参数之一。你还必须提供三个函数:一个检查当前状态以决定序列是否已经完成的函数,一个在准备产生下一个项目时推进状态的函数,以及一个确定当前状态下要产生的值的函数。示例 11-13 使用这些函数创建一个源,该源生成随机数,直到所有生成的数字的总和超过 10,000。

示例 11-13. 生成项目
IObservable<int> src = Observable.Generate(
    (Current: 0, Total: 0, Random: new Random()),
    state => state.Total <= 10000,
    state =>
    {
        int value = state.Random.Next(1000);
        return (value, state.Total + value, state.Random);
    },
    state => state.Current);

这总是作为第一个项目产生0,说明 Generate 在首次调用用于确定当前值的函数(在示例 11-13 中的最后一个 lambda 表达式)之前,会调用用于迭代状态的函数。

你可以通过使用 Observable.Create 和一个循环来实现与这个示例相同的效果。但是,Generate 反转了控制流:你的代码不再在循环中告诉 Rx 何时产生下一个项目,而是 Rx 要求你的函数提供下一个项目。这使得 Rx 在调度工作时具有更大的灵活性。例如,它使 Generate 能够提供带有定时功能的重载版本。示例 11-14 以类似的方式产生项目,但是通过传递一个额外的函数作为最后一个参数告诉 Rx 延迟每个项目的传递。

示例 11-14. 生成定时项目
IObservable<int> src = Observable.Generate(
    (Current: 0, Total: 0, Random: new Random()),
    state => state.Total < 10000,
    state =>
    {
        int value = state.Random.Next(1000);
        return (value, state.Total + value, state.Random);
    },
    state => state.Current,
    state => TimeSpan.FromMilliseconds(state.Random.Next(1000)));

为了使这个方法工作,Rx 需要能够安排未来某个时间点发生的工作。我将在“调度器”中解释这是如何工作的。

LINQ 查询

使用 Rx 的最大好处之一是它有一个 LINQ 实现,使你能够编写查询来处理诸如事件之类的异步项目流。示例 11-15 说明了这一点。它首先生成一个表示来自 UI 元素的 MouseMove 事件的可观察源。我将在 “适应” 中更详细地讨论这种技术,但现在知道 Rx 可以将任何 .NET 事件包装为可观察源就足够了。每个事件产生一个项目,其中包含两个属性,这些属性包含通常作为参数传递给事件处理程序的值(即发送者和事件参数)。

示例 11-15. 使用 LINQ 查询过滤项目
IObservable<EventPattern<MouseEventArgs>> mouseMoves =
    Observable.FromEventPattern<MouseEventArgs>(
        background, nameof(background.MouseMove));

`IObservable``<``Point``>` `dragPositions` `=`
    `from` `move` `in` `mouseMoves`
    `where` `Mouse``.``Captured` `=``=` `background`
    `select` `move``.``EventArgs``.``GetPosition``(``background``)``;`

dragPositions.Subscribe(point => { line.Points.Add(point); });

LINQ 查询中的 where 子句过滤事件,以便我们只处理在特定 UI 元素(background)捕获鼠标时引发的事件。这个特定示例基于 WPF,但一般来说,希望支持拖动的 Windows 桌面应用程序在鼠标按钮按下时捕获鼠标,并在之后释放它。这确保捕获元素在拖动进行时接收鼠标移动事件,即使鼠标移动到其他 UI 元素上也是如此。通常,当鼠标位于 UI 元素上时,即使它们没有捕获鼠标,它们也会接收鼠标移动事件。因此,我需要在 示例 11-15 中的 where 子句中忽略那些事件,只留下在拖动进行时发生的鼠标移动。因此,为了使 示例 11-15 中的代码工作,你需要将事件处理程序附加到相关元素的 MouseDownMouseUp 事件,就像 示例 11-16 中的那样。

示例 11-16. 捕获鼠标
private void OnBackgroundMouseDown(object sender, MouseButtonEventArgs e)
{
    background.CaptureMouse();
}

private void OnBackgroundMouseUp(object sender, MouseButtonEventArgs e)
{
    if (Mouse.Captured == background)
    {
        background.ReleaseMouseCapture();
    }
}

在 示例 11-15 中的 select 子句在 Rx 中的工作方式与 LINQ to Objects 中的工作方式相同,或者与任何其他 LINQ 提供程序一样。它允许我们从源项目中提取信息以用作输出。在这种情况下,mouseMoves 是一个 EventPattern<MouseEventArgs> 对象的可观察序列,但我真正想要的是一个鼠标位置的可观察序列。因此,在 示例 11-15 中的 select 子句要求相对于特定 UI 元素的位置。

这个查询的要点是,dragPositions 指的是一个 Point 值的可观察序列,它将报告每次发生鼠标位置变化的情况,而这发生在我的应用程序中某个特定 UI 元素捕获鼠标时。这是一个热源,因为它代表着正在实时发生的事情:鼠标输入。LINQ 的过滤和投影操作符不会改变源的性质,因此如果你将它们应用于一个热源,得到的查询结果也将是热的,如果源是冷的,过滤后的结果也将是冷的。

警告

运算符不会检测源的热度。WhereSelect运算符只是直接传递这个方面。每当你订阅由Select运算符生成的最终查询时,它将订阅它的输入。在本例中,输入是由Where运算符返回的可观察对象,它将依次订阅由适应鼠标移动事件产生的源。如果你第二次订阅,你将得到第二个订阅链。热事件源将把每个事件广播到这两个链,因此每个项目将通过过滤和投影过程两次。因此,请注意,将多个订阅者附加到热源的复杂查询可能会工作,但可能会带来不必要的开销。如果需要这样做,最好在查询上调用Publish,正如你所看到的,它可以对其输入进行单一订阅,然后将每个项目广播给所有订阅者。

示例 11-15 的最后一行订阅了过滤和投影后的源,并将其生成的每个Point值添加到另一个名为line的 UI 元素的Points集合中。这是一个Polyline元素,这里没有显示,¹这样做的结果是你可以在应用程序窗口上用鼠标涂鸦。(如果你长时间进行过 Windows 开发,你可能还记得 Scribble 示例,这里的效果大致相同。)

Rx 提供了大部分在第 10 章中描述的标准查询运算符。²这些运算符在 Rx 中的工作方式与其他 LINQ 实现完全相同。然而,有些运算符的工作方式可能乍一看会稍有些令人惊讶,我将在接下来的几节中描述。

分组运算符

标准的分组运算符GroupBy生成一个序列的序列。在 LINQ to Objects 中,它返回IEnumerable<IGrouping<TKey, TSource>>,正如你在第 10 章中看到的,IGrouping<TKey, TSource>本身从IEnumerable<T>派生而来。GroupJoin在概念上类似:虽然它返回一个普通的IEnumerable<T>,但T是一个投影函数的结果,该函数将序列作为输入。因此,在任一情况下,你得到的都是逻辑上的序列的序列。

在 Rx 的世界中,分组会生成一个可观察序列的可观察序列。这是完全一致的,但可能会有些令人惊讶,因为 Rx 引入了时间方面:表示所有组的可观察源在发现每个新组时生成一个新项目(一个新的可观察源)。示例 11-17 通过监听文件系统中的变化并根据每个发生的文件夹形成组来说明这一点。对于每个组,我们得到一个IGroupedObservable<TKey, TSource>,这是IGrouping<TKey, TSource>的 Rx 等效物。

示例 11-17. 事件分组
string path = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments);
var w = new FileSystemWatcher(path);
IObservable<EventPattern<FileSystemEventArgs>> changes =
    Observable.FromEventPattern<FileSystemEventHandler, FileSystemEventArgs>(
        h => w.Changed += h, h => w.Changed -= h);
w.IncludeSubdirectories = true;
w.EnableRaisingEvents = true;

IObservable<IGroupedObservable<string, string>> folders =
    from change in changes
    group Path.GetFileName(change.EventArgs.FullPath)
       by Path.GetDirectoryName(change.EventArgs.FullPath);

folders.Subscribe(f =>
{
    Console.WriteLine("New folder ({0})", f.Key);
    f.Subscribe(file =>
        Console.WriteLine("File changed in folder {0}, {1}", f.Key, file));
});

订阅到分组源 folders 的 lambda 订阅到源产生的每个组。事件可能来自的文件夹数量是无限的,因为在程序运行时可以添加新的文件夹。因此,当它检测到以前未见过的文件夹发生变化时,folders observable 将产生一个新的 observable 源,正如 图 11-2 所示。

生产新组并不意味着任何先前的组现在已完成,这与我们通常在 LINQ to Objects 中消费组的方式不同。当你在 IEnumerable<T> 上运行分组查询时,它会产生每个组,你可以在移动到下一个组之前完全枚举其内容。但在 Rx 中你做不到这一点,因为每个组被表示为一个 observable,而 observables 直到它们告诉你它们完成之前都不算完成——相反,每个组的订阅保持活动状态。在 示例 11-17 中,一个已经开始的组对应的文件夹可能会在其他文件夹活动时长时间处于休眠状态,直到稍后重新启动。而且更一般地说,Rx 的分组操作符必须准备好处理任何源中发生这种情况的情况。

Rx 分组操作符

图 11-2. 将 IObservable<T> 拆分为组

连接操作符

Rx 提供了标准的 JoinGroupJoin 操作符。然而,它们与 LINQ to Objects 或大多数数据库 LINQ 提供者处理连接的方式略有不同。在这些世界中,两个输入集的项目通常基于具有一些共同值进行连接。

在数据库中,当连接两个表时,一个非常常见的示例是连接具有相同值的一个表中行的外键列和另一个表中行的主键列。然而,Rx 并不是基于值进行连接。相反,如果它们的持续时间重叠,那么项目会被连接。

不过稍等一下。一个项目的持续时间究竟是什么?Rx 处理瞬时事件;生成一个项目,报告一个错误以及完成一个流,都是发生在特定时刻的事情。因此,连接操作符使用一个约定:对于每个源项目,你可以提供一个返回 IObservable<T> 的函数。

该源项目的持续时间从其生成时开始,并在相应的 IObservable<T> 第一次响应时结束(即它完成或生成一个项目或错误)。图 11-3 阐明了这个想法。顶部是一个 observable 源,在其下是一系列定义每个项目持续时间的源。底部展示了每个项目 observables 为其源项目建立的持续时间。

图 11-3. 为每个源项目使用 IObservable<T> 定义持续时间

虽然您可以为每个源项目使用不同的 IObservable<T>,就像 Figure 11-3 展示的那样,但您并不需要这样做——每次使用相同的源也是有效的。例如,如果您将组操作应用于代表MouseDown事件流的IObservable<T>,然后再使用另一个代表MouseUp事件流的IObservable<T>来定义每个项目的持续时间,这将导致 Rx 将每个MouseDown事件的“持续时间”视为持续到下一个MouseUp事件。图 11-4 描述了这种安排,您可以看到在底部显示的每个MouseDown事件的有效持续时间由MouseDownMouseUp事件对界定。

图 11-4. 使用一对事件流定义持续时间

源甚至可以定义自己的持续时间。例如,如果您提供一个表示MouseDown事件的可观察源,您可能希望每个项目的持续时间在下一个项目开始时结束。这意味着项目具有连续的持续时间——在第一个项目到达后,总是有一个当前项目,它是最后发生的项目。图 11-5 阐明了这一点。

图 11-5. 相邻项目持续时间

项目的持续时间可以重叠。如果您愿意,您可以提供一个定义持续时间的IObservable<T>,表明输入项目的持续时间在下一个项目开始后一段时间结束。

现在我们知道 Rx 如何决定一个项目的持续时间以进行连接,那么它如何使用这些信息呢?请记住,连接运算符结合了两个输入。(定义持续时间的源不算是输入。它们提供有关其中一个输入的额外信息。)Rx 认为来自两个输入流的项目对是相关的,如果它们的持续时间重叠。它展示相关项目的方式取决于您是使用Join还是GroupJoin运算符。Join运算符的输出是一个包含每对相关项目的流。(您提供一个投影函数,该函数将传递每对项目,并由您决定如何处理它们。这个函数决定连接流的输出项目类型。)Figure 11-6 展示了基于事件MouseDownMouseMove的两个输入流(分别由MouseUpMouseMove定义持续时间)。这与图示中的源类似于图 11-4 和 11-5,但我添加了字母和数字,以便更容易引用每个流中的每个项目。在图的底部是Join运算符将为这两个流产生的可观察对象。

图 11-6. Join 运算符

正如您所看到的,任何两个输入流项目的持续时间重叠的地方,我们都会得到一个结合两个输入的输出项目。如果重叠的项目在不同的时间开始(这通常是情况),则输出项目将在两个输入中后开始的时间产生。MouseDown事件AMouseMove事件1之前开始,因此结果输出A1发生在重叠开始时(即MouseMove事件1发生时)。但事件3在事件B之前发生,因此连接的输出B3发生在B开始时。

事件5的持续时间不与任何MouseDown项的持续时间重叠,因此在输出流中看不到任何该项。相反,MouseMove事件可能会出现在多个输出项目中(就像每个MouseDown事件一样)。如果没有3事件,事件2的持续时间会从A内部开始,并在B内部完成,因此除了图 11-6 中显示的A2外,还会在B开始时出现B2事件。

Example 11-18 显示了执行图 11-6 中所示的连接的代码,使用查询表达式。正如您在第 10 章中看到的,编译器会将查询表达式转换为一系列方法调用,而 Example 11-19 显示了与 Example 11-18 中查询的基于方法的等效形式。

Example 11-18. 使用连接进行查询表达式
IObservable<EventPattern<MouseEventArgs>> downs =
    Observable.FromEventPattern<MouseEventArgs>(
        background, nameof(background.MouseDown));
IObservable<EventPattern<MouseEventArgs>> ups =
    Observable.FromEventPattern<MouseEventArgs>(
        background, nameof(background.MouseUp));
IObservable<EventPattern<MouseEventArgs>> allMoves =
    Observable.FromEventPattern<MouseEventArgs>(
        background, nameof(background.MouseMove));

IObservable<Point> dragPositions =
    from down in downs
    join move in allMoves
      on ups equals allMoves
    select move.EventArgs.GetPosition(background);
Example 11-19. 加入代码
IObservable<Point> dragPositions = downs.Join(
    allMoves,
    down => ups,
    move => allMoves,
    (down, move) => move.EventArgs.GetPosition(background));

我们可以使用任何这些示例生成的dragPositions可观察源来替换 Example 11-15 中的源。我们不再需要基于background元素是否捕获鼠标来进行过滤,因为现在 Rx 仅为我们提供了持续时间与鼠标按下事件重叠的移动事件。发生在鼠标按下之间的任何移动都将被忽略,或者如果它们是最后一次在鼠标按下之前发生的移动,则在鼠标按钮按下的瞬间我们将接收到该位置。

GroupJoin以类似的方式组合项目,但不是生成单个可观察输出,而是生成一个可观察的可观察对象。对于当前的示例,这意味着其输出会为每个MouseDown输入生成一个新的可观察源。这将包含包含该输入的所有配对,并且它的持续时间与该输入相同。图 11-7 显示了此运算符与与图 11-6 相同的输入事件的运行情况。我在输出序列的端点放置了竖线,以澄清它们何时调用其观察者的OnComplete方法。这些可观察对象的起始和结束与相应输入的持续时间完全对齐,因此它们产生其最终输出项目和完成时间之间通常存在显著差异。

图 11-7. GroupJoin运算符

一般来说,使用 LINQ,GroupJoin运算符能够产生空组,因此与Join运算符不同,即使第二个流中没有相应的项目,每个来自第一个输入的项目也会产生一个输出。Rx 的GroupJoin也是这样工作的,增加了时间方面的考虑。每个输出组从相应的输入事件发生时开始(在本例中为MouseDown),并在该事件被认为已经结束时结束(这里是下一个MouseUp);如果在此期间没有移动,该可观察对象将不会生成任何项目。因为这里的移动事件持续连续,这只能在接收到第一个移动之前发生。但在第二个输入项目具有不连续持续时间的连接中,空组更有可能发生。

在允许用户用鼠标在窗口中涂鸦的示例应用程序背景下,这种分组输出非常有用,因为它将每个单独的拖动呈现为一个独立的对象。这意味着我可以为每次拖动创建一条新线,而不是将点添加到越来越长的同一线上。使用示例 11-15 中的代码,每次新的拖动操作将从上一个拖动结束的地方到新位置画一条线,这样就无法绘制出分离的形状。但是分组输出使得分离变得容易。示例 11-20 订阅分组输出,并为每个新组(代表一个新的拖动操作)创建一个新的Polyline来渲染涂鸦,然后订阅组中的项目以填充该单独的线条。

示例 11-20. 为每次拖动操作添加新线
var dragPointSets = from mouseDown in downs
                    join move in allMoves
                      on ups equals allMoves into m
                    select m.Select(e => e.EventArgs.GetPosition(background));

dragPointSets.Subscribe(dragPoints =>
{
    var currentLine = new Polyline { Stroke = Brushes.Black, StrokeThickness = 2 };
    background.Children.Add(currentLine);

    dragPoints.Subscribe(point =>
    {
        currentLine.Points.Add(point);
    });
});

仅需明确,所有这些即使在连接运算符下也可以实时运行 - 这些都是热源。在示例 11-20 中由GroupJoin返回的IObservable<IObservable<Point>>会在按下鼠标按钮时立即产生一个新组。该组中的IObservable<Point>将会为每个MouseMove事件立即产生一个新的Point。总之,当用户拖动鼠标时,用户会立即看到直线出现并增长。

SelectMany运算符

正如你在第十章中所看到的,SelectMany 操作符有效地将集合的集合展平为单个集合。当查询表达式具有多个from子句时,将使用此操作符,在 LINQ to Objects 中,其操作类似于嵌套的foreach循环。在 Rx 中,它仍然具有这种展平效果 - 它允许你获取一个可观察的源,其中每个生成的项目也是一个可观察的源(或者可以用来生成一个),SelectMany 操作符的结果将是一个包含所有子源中所有项目的单个可观察序列。然而,与分组一样,在 LINQ to Objects 中,情况可能不那么有序。Rx 的推送驱动特性,以及其潜在的异步操作,使得所有涉及的可观察源都有可能同时推送新项目,包括用作嵌套源的原始源。(该操作符仍然确保一次只传递一个事件 - 当调用OnNext时,它会等待返回后再进行下一个调用。混乱的可能性仅限于事件传递的顺序混乱。)

当使用 LINQ to Objects 遍历嵌套数组时,一切都按照直观的顺序进行。它会检索第一个嵌套数组,然后遍历该数组中的所有元素,然后转到下一个嵌套数组,并遍历该数组,依此类推。但是,这种有序的展平仅因为使用IEnumerable<T>时,项目的消费者可以控制何时检索哪些项目。使用 Rx 时,订阅者在源提供项目时接收它们。

尽管有这种自由度,行为还是足够直接的:由SelectMany产生的输出流只是在源提供它们时提供项目。

聚合和其他单值操作符

几个标准的 LINQ 操作符将整个值序列减少为单个值。这些包括聚合操作符,如MinSumAggregate;量词符AnyAll;以及Count操作符。它还包括选择性操作符,如ElementAt。这些在 Rx 中也是可用的,但与大多数 LINQ 实现不同,Rx 实现不会返回普通的单个值。它们都返回一个IObservable<T>,就像产生序列输出的操作符一样。

注意

FirstLastFirstOrDefaultLastOrDefaultSingleSingleOrDefault 操作符应该都是以相同的方式工作,但出于历史原因,它们并非如此。在 Rx 的 v1 中引入,它们返回的单个值并不包装在IObserva⁠ble​<T>中,这意味着它们会阻塞直到源提供所需的内容。这与推送模型不太匹配,并且可能导致死锁,因此这些操作符现在已被弃用,并且有了新的异步版本,工作方式与 Rx 中的其他单值操作符相同。所有这些操作符的新版本只需在原始操作符名称后附加Async即可(例如FirstAsyncLastAsync等)。

这些操作符每个仍然产生单个值,但它们都将该值呈现为可观察源。原因是与 LINQ to Objects 不同,Rx 不能枚举其输入以计算聚合值或查找所选值。源控制着流程,因此这些操作符的 Rx 版本必须等待源提供其值——就像所有操作符一样,单值操作符必须是被动反应的,而不是主动的。诸如Average这样需要看到每个值的操作符,在源表示已完成之前,不能生成其结果。即使像FirstAsyncElementAt这样不需要等待输入的末尾的操作符,也要等到源决定提供操作符正在等待的值之前才能执行任何操作。一旦单值操作符能够提供值,它就会这样做,然后完成。

ToArrayToListToDictionaryToLookup 操作符的工作方式类似。虽然它们都生成源的全部内容,但作为单个输出对象,被包装为单项可观察源。

如果你真的想等待任何这些项的值,可以使用Wait操作符,这是 Rx 中的一个非标准操作符,适用于任何IObserva⁠ble​<T>。这个阻塞操作符等待源完成,然后返回最终元素,因此弃用的FirstLast等操作符的“等待”行为仍然可用;只是不再是默认行为了。或者,你可以使用 C# 的异步语言特性——将await关键字用于可观察源。从逻辑上讲,它与Wait做的事情是一样的,但它是通过高效的非阻塞异步等待实现的,这种等待方式在 第十七章 中有描述。

Concat 操作符

Rx 的 Concat 操作符与其他 LINQ 实现共享相同的概念:它将两个输入序列组合起来,生成一个序列,该序列首先生成其第一个输入的每个项,然后生成其第二个输入的每个项。 (事实上,Rx 比一些 LINQ 提供程序更进一步,可以接受一组输入,并将它们全部连接起来。)只有在第一个流最终完成时,这才有用 — 当然,在 LINQ to Objects 中也是如此,但在 Rx 中无限源更为常见。此外,请注意,此操作符在第一个流完成之前不会订阅第二个流。这是因为冷流通常在订阅时开始生成项,并且 Concat 操作符不希望在等待第一个流完成时缓冲第二个源的项。这意味着在与热源一起使用时,Concat 可能会产生非确定性结果。(如果你想要一个包含来自两个热源的所有项的可观察源,请使用 Merge,我马上会描述。)

Rx 并不仅仅满足于提供标准的 LINQ 操作符。它定义了更多自己的操作符。

Rx 查询操作符

Rx 的主要目标之一是简化与多个潜在独立的异步产生项的可观察源的工作。Rx 的设计者有时候会提到“编排和同步”,意思是你的系统可能同时进行许多事情,但你需要确保应用程序对事件的反应是某种程度上协调一致的。Rx 的许多操作符都是基于这个目标设计的。

注意

并非本节的所有内容都是由 Rx 的独特要求驱动的。Rx 的一些非标准操作符(例如 Scan)在其他 LINQ 提供程序中也是非常合理的。并且这些操作的许多版本在 .NET 的交互扩展(Ix)中也有提供,可以在 System.Interactive NuGet 包中找到,正如前面提到的。

Rx 拥有如此丰富的操作符库,以至于要对它们全都公正地介绍将会使本章的长度大致增加四倍,而本章已经偏长了。由于这不是一本关于 Rx 的书籍,并且因为一些操作符非常专业化,我只会挑选一些最有用的来介绍。我建议浏览 Rx 的文档或者 源代码 来探索它所提供的完整和非常全面的操作符集合。

合并

Merge 操作符将两个或多个可观察序列中的所有元素合并为一个单一的可观察序列。我可以使用它来解决在示例 11-15、11-18 和 11-20 中出现的问题。这些示例都处理鼠标输入,如果你在 Windows UI 编程中做了很多工作,你会知道并不一定会得到与按下和释放鼠标按钮对应的鼠标移动通知。这些按钮事件的通知包含鼠标位置信息,因此 Windows 没有必要发送单独的鼠标移动消息来提供这些位置,因为这只会将相同的信息发送两次。这是完全合理的,但也相当恼人。³ 在这些示例中,起始和结束位置不在表示鼠标位置的可观察源中。我可以通过合并所有三个事件的位置来解决这个问题。示例 11-21 展示了如何修复 示例 11-15。

示例 11-21. 合并可观察对象
IObservable<EventPattern<MouseEventArgs>> downs =
    Observable.FromEventPattern<MouseEventArgs>(
        background, nameof(background.MouseDown));
IObservable<EventPattern<MouseEventArgs>> ups =
    Observable.FromEventPattern<MouseEventArgs>(
        background, nameof(background.MouseUp));
IObservable<EventPattern<MouseEventArgs>> allMoves =
    Observable.FromEventPattern<MouseEventArgs>(
        background, nameof(background.MouseMove));

IObservable<EventPattern<MouseEventArgs>> dragMoves =
    from move in allMoves
    where Mouse.Captured == background
    select move;

`IObservable``<``EventPattern``<``MouseEventArgs``>``>` `allDragPositionEvents` `=`
    `Observable``.``Merge``(``downs``,` `ups``,` `dragMoves``)``;`

IObservable<Point> dragPositions =
    from move in allDragPositionEvents
    select move.EventArgs.GetPosition(background);

我已经创建了三个可观察对象来表示三个相关事件:MouseDownMouseUpMouseMove。因为这三个事件都需要共享同一个投影(select 子句),但只有一个需要过滤事件,所以我稍微重构了一下。只有鼠标移动需要过滤,所以我为此编写了单独的查询。然后我使用了 Observable.Merge 方法将所有三个事件流合并为一个。

注意

Merge 既可以作为扩展方法使用,也可以作为非扩展的 static 方法使用。如果你在单个可观察对象上使用可用的扩展方法,那么唯一可用的 Merge 重载是将其与单个其他源合并(可选指定调度程序)。在这种情况下,我有三个源,所以我使用了非扩展方法形式。然而,如果你有一个表达式,它是一个可观察源的可枚举或一个可观察源的可观察源,你会发现这些情况下也有 Merge 扩展方法。因此,我本可以写成 new[] { downs, ups, dragMoves }.Merge()

我的 allDragPositionEvents 变量指的是一个单一的可观察流,将报告我所需的所有鼠标移动。最后,我通过投影运行这些数据,以提取每个项目的鼠标位置。同样,结果是一个热流。与之前一样,只要 background 元素捕获了鼠标,它就会在鼠标移动时生成一个位置,但同时也会在 MouseDownMouseUp 事件发生时生成一个位置。我可以使用与 示例 11-15 最后一行显示的相同调用订阅此事件,这次我不会错过起始和结束位置。

在我刚刚展示的例子中,所有源都是无限的,但并非总是如此。当其中一个输入停止时,合并的可观察应该怎么做?如果其中一个因错误而停止,该错误将通过合并的可观察传递,此时它将变为完成状态—在报告错误后,可观察的对象不允许继续生成项。然而,虽然输入可以单方面地通过错误终止输出,但如果输入正常完成,直到所有输入都完成,合并的可观察才会完成。

窗口操作符

Rx 定义了两个操作符,BufferWindow,它们都生成一个可观察的输出,其中每个项基于源中的多个相邻项。(顺便说一句,Window 的名称与 UI 无关。)Figure 11-8 展示了使用 Buffer 操作符的三种方式。我已经标记了代表输入项的圆圈,并在其下方展示了代表由 Buffer 产生的可观察源的项的形状和数字,其中线条和数字表示与每个输出项相关联的输入项。很快你会看到,Window 的工作方式非常相似。

图 11-8. 使用Buffer操作符的滑动窗口

在第一个案例中,我传递了 (2, 2) 的参数,表明我希望每个输出项对应于两个输入项,并且我希望在每第二个输入项上启动一个新的缓冲区。这听起来像是用两种不同的方式说同一件事情,直到你看到 Figure 11-8 中的第二个例子,在这个例子中,(3, 2) 的参数表明每个输出项对应于输入的三个项,但我仍然希望在每第二个输入上开始缓冲。这意味着每个窗口—用于构建输出项的输入项集合—与其邻居重叠。当第二个参数,跳过,小于窗口大小时,这种情况将发生。第一个输出项的窗口包含第一个、第二和第三个输入。第二个输出项的窗口包含第三、第四和第五个,因此第三个项出现在两者中。

图中的最后一个示例显示了窗口大小为三,但这次我要求跳过一个大小为一的间隔—因此,在这种情况下,窗口每次只移动一个输入项,但每次都包含源中的三个项。我也可以指定一个大于窗口的跳过大小,在这种情况下,落在窗口之间的输入项将被简单地忽略。

BufferWindow 操作符往往会引入一定的延迟。在第二和第三种情况下,窗口大小为三意味着输入可观测对象需要生成其第三个值,然后才能为输出项提供整个窗口。对于 Buffer 来说,这总是意味着窗口大小的延迟,但正如你将看到的那样,使用 Window 操作符时,每个窗口在完全填充之前就可以开始处理。

注意

Buffer 还提供了一个重载,接受一个数字,其效果与两次传递相同数字相同。 (例如,而不是 Buffer(2, 2),你可以简单地写成 Buffer(2)。)这在逻辑上等同于 LINQ to Objects 的 Chunk 操作符。正如 第十章 中讨论的那样,Rx 没有使用相同的名称的主要原因是,Rx 在大约十年前发明了 Buffer,而 LINQ to Objects 添加了 Chunk

BufferWindow 操作符之间的区别在于它们呈现窗口化项的方式。Buffer 是最直接的。它提供一个 IObservable<IList<T>>,其中 T 是输入项的类型。换句话说,如果你订阅 Buffer 的输出,对于每个生成的窗口,订阅者将收到一个包含窗口中所有项的列表。 示例 11-22 使用此方法生成了来自 示例 11-15 的鼠标位置的平滑版本。

示例 11-22. 使用 Buffer 对输入进行平滑处理
IObservable<Point> smoothed = from points in dragPositions.Buffer(5, 2)
                              let x = points.Average(p => p.X)
                              let y = points.Average(p => p.Y)
                              select new Point(x, y);

此查询的第一行指定我要查看五个连续鼠标位置的组,并且我希望每隔一个输入生成一个组。查询的其余部分计算窗口内的平均鼠标位置,并将其作为输出项。图 11-9 显示了效果。顶部线条是使用原始鼠标位置的结果。紧接着它下面的线条使用了相同输入产生的 示例 11-22 中的平滑点。正如你所看到的,顶部线条有些崎岖不平,而底部线条则平滑了许多突起。

图 11-9. 平滑效果展示

示例 11-22 使用了 LINQ to Objects 和 Rx 的混合实现。查询表达式本身使用了 Rx,但是范围变量 points 的类型是 IList<Point>(因为在这个示例中 Buffer 返回一个 IObservable<IList<Point>>)。因此,对 points 调用 Average 操作符的嵌套查询将得到 LINQ to Objects 的实现。

如果Buffer运算符的输入是热的,则它将产生一个热可观测对象作为结果。因此,您可以订阅示例 11-22smoothed变量中的可观测对象,类似于示例 11-15的最后一行代码,它将在您拖动鼠标时实时显示平滑的线条。正如讨论的那样,当然会有一些延迟 —— 代码指定了跳过两个项目,因此它仅对每两次鼠标事件更新一次屏幕。对最后五个点进行平均处理也会增加鼠标指针与线条末端之间的差距。在这些参数下,差异很小,不会太分散注意力,但如果使用更激进的平滑处理,可能会变得令人不快。

Window运算符与Buffer运算符非常相似,但不同之处在于它不将每个窗口呈现为IList<T>,而是提供一个IObservable<T>。如果您在示例 11-22中对dragPositions使用Window,结果将是IObservable<IObservable<Point>>图 11-10展示了Window运算符在图 11-8中的最后一种情景中的工作方式,正如您所见,它可以更早地开始每个窗口。它不必等到窗口中的所有项目都可用才开始;它提供的每个输出项目都是一个IObservable<T>,该对象将根据项目的可用性逐个产生窗口的项目。Window生成的每个可观测对象在提供最后一个项目后立即完成(即与Buffer提供整个窗口的时刻相同)。因此,如果您的处理依赖于整个窗口的可用性,Window无法更快地将其提供给您,因为它最终受到输入项到达速率的限制,但它将更早地开始提供值。

Window在这个例子中生成的可观测对象的一个潜在令人惊讶的特征是它们的起始时间。尽管它们在生成最后一个项目后立即结束,但它们在生成第一个项目之前并不立即开始。代表第一个窗口的可观测对象立即开始 —— 一旦您订阅运算符返回的可观测对象的可观测对象,您将立即收到该可观测对象。因此,第一个窗口将立即可用,即使Window运算符的输入尚未执行任何操作。然后,每个新窗口在接收到需要跳过的所有输入项后立即开始。在这个例子中,我使用的是一个跳过计数为一,因此第二个窗口在输入产生一个项目后开始,第三个在产生两个项目后开始,依此类推。

正如您稍后在本节中将看到的,以及在“定时操作”中也看到的,WindowBuffer支持一些其他定义窗口何时开始和停止的方式。一般模式是,一旦Window操作符到达一个点,源中的新项将进入新窗口,操作符就创建该窗口,预期窗口的第一个项,而不是等待它(见图 11-10)。

图 11-10. Window操作符
注意

如果输入完成,所有当前打开的窗口也会完成。这意味着可能会看到空窗口。(事实上,如果跳过大小为一,如果源完成,你保证会得到一个空窗口。)在图 11-10 中,底部的一个窗口已经开始但尚未产生任何项。如果输入在不再产生任何项的情况下完成,仍在进行中的三个可观测源也会完成,包括那个尚未产生任何内容的最后一个。

因为Window操作符会在源提供项时立即将项投放到窗口中,这可能会使您能够更早地开始处理,比使用Buffer更能提高整体响应性。Window的缺点是它往往更复杂——您的订阅者将在相应的输入窗口的所有项都可用之前开始接收输出值。而Buffer提供您一个列表,您可以随时检查,而使用Window,您将需要继续在 Rx 的序列世界中工作,只有当它们准备好时才会产生项。要执行与示例 11-22 相同的平滑操作,使用Window需要在示例 11-23 中的代码。

示例 11-23. 使用Window进行平滑操作
IObservable<Point> smoothed =
    from points in dragPositions.Window(5, 2)
    from totals in points.Aggregate(
      new { X = 0.0, Y = 0.0, Count = 0 },
      (acc, point) => new
          { X = acc.X + point.X, Y = acc.Y + point.Y, Count = acc.Count + 1 })
    where totals.Count > 0
    select new Point(totals.X / totals.Count, totals.Y / totals.Count);

这有点复杂,因为我无法使用Average操作符,由于需要应对空窗口的可能性。(严格来说,在我有一个不断变长的Polyline的情况下,这并不重要。但是,如果我像示例 11-20 那样按拖动操作分组点,每个单独的可观测点源将在拖动结束时完成,迫使我处理任何空窗口。)如果你向Average操作符提供一个空序列,它会产生错误,所以我改用了Aggregate操作符,它让我添加一个where子句来过滤掉空窗口而不是崩溃。但这不是更复杂的唯一方面。

正如我之前提到的,Rx 的所有聚合操作符——AggregateMinMax 等——与大多数 LINQ 提供程序的操作方式不同。LINQ 要求这些操作符将流减少为单个值,因此它们通常返回单个值。例如,如果我使用示例 11-23 中显示的参数调用 LINQ to Objects 版本的 Aggregate,它将返回我用作累加器的匿名类型的单个值。但在 Rx 中,返回类型是 IObservable<T>(在这种情况下 T 是累加器类型)。它仍然生成单个值,但通过可观测源呈现该值。与 LINQ to Objects 不同,它可以枚举其输入以计算平均值,Rx 操作符必须等待源提供其值,因此它不能在源说它已完成之前产生这些值的聚合。

因为 Aggregate 操作符返回一个 IObservable<T>,我不得不使用第二个 from 子句。这将源传递给 SelectMany 操作符,该操作符提取所有值并使它们出现在最终流中——在本例中,每个窗口只有一个值,因此 SelectMany 实际上是从其单一项流中展开平均点。

示例 11-23 中的代码比 示例 11-22 复杂一些,我认为理解其工作原理要困难得多。更糟糕的是,它甚至没有提供任何好处。Aggregate 操作符在可用输入时就开始工作,但代码在看到窗口中的每个点之前无法生成最终结果——平均值。如果我必须等到窗口结束才能更新 UI,那么我可能还是坚持使用 Buffer。因此,在这种特殊情况下,Window 是为了没有好处而做了更多的工作。然而,如果在窗口中处理的项目不那么琐碎,或者涉及的数据量非常大,以至于不希望在开始处理整个窗口之前缓冲整个窗口,那么额外的复杂性可能值得,因为可以开始聚合过程而无需等待整个输入窗口可用。

使用可观测对象划分窗口

WindowBuffer 操作符提供了一些定义窗口何时开始和结束的其他方式。就像连接操作符可以用可观测对象指定持续时间一样,你可以提供一个返回每个窗口定义持续时间的函数。示例 11-24 使用此方法将键盘输入分解为单词。本示例中的 keySource 变量来自 示例 11-11。它是一个生成每次按键的可观测序列。

示例 11-24. 使用窗口将文本分解为单词
IObservable<IObservable<char>> wordWindows = keySource.Window(
    () => keySource.FirstAsync(char.IsWhiteSpace));

IObservable<string> words = from wordWindow in wordWindows
                            from chars in wordWindow.ToArray()
                            select new string(chars).Trim();

words.Subscribe(word => Console.WriteLine("Word: " + word));

Window运算符将立即在此示例中创建一个新窗口,并且还将调用我提供的 lambda 来确定窗口的结束时间。它会保持窗口处于打开状态,直到我提供的可观察源的 lambda 返回一个值或完成。当发生这种情况时,Window会立即打开下一个窗口,并再次调用我的 lambda 以获取另一个可观察对象来确定第二个窗口的长度,依此类推。此处的 lambda 会从键盘产生下一个空格字符,因此窗口将在下一个空格处关闭。换句话说,这将输入序列分割成一系列窗口,其中每个窗口包含零个或多个非空格字符,后跟一个空格字符。

Window运算符返回的可观察序列将每个窗口呈现为IObservable<char>。Example 11-24 中的第二个语句是一个查询,将每个窗口转换为一个字符串。(如果输入包含多个相邻的空格字符,这将产生空字符串。这与string类型的Split方法的行为一致,该方法执行了与此分区相对应的拉取导向的操作。如果你不喜欢这种行为,你可以通过where子句来过滤掉空白字符。)

由于 Example 11-24 使用了Window,它将会在用户键入每个单词时立即使字符可用。但由于我的查询在窗口上调用了ToArray,它将等待窗口完成之后才会产生任何内容。这意味着如果使用Buffer同样有效,而且更简单。正如 Example 11-25 所示,如果使用Buffer,我不需要第二个from子句来收集完成的窗口,因为它仅在窗口完成后提供窗口。

Example 11-25. 使用Buffer进行单词拆分
IObservable<IList<char>> wordWindows = keySource.Buffer(
    () => keySource.FirstAsync(char.IsWhiteSpace));

IObservable<string> words = from wordWindow in wordWindows
                            select new string(wordWindow.ToArray()).Trim();

Scan 运算符

Scan运算符与标准的Aggregate运算符非常相似,只有一个区别。它不会在源完成后产生单个结果,而是产生一个序列,其中依次包含每个累加器的值。为了说明这一点,我将首先介绍一个记录类型,它将作为一个非常简单的股票交易模型。这种类型在 Example 11-26 中显示,并定义了一个静态方法,用于提供测试目的的随机生成交易流。

Example 11-26. 使用测试流进行简单股票交易
public record Trade(string StockName, decimal UnitPrice, int Number)
{
    public static IObservable<Trade> TestStream()
    {
        return Observable.Create<Trade>(obs =>
        {
            string[] names = { "MSFT", "GOOGL", "AAPL" };
            var r = new Random(0);
            for (int i = 0; i < 100; ++i)
            {
                var t = new Trade(
                    StockName: names[r.Next(names.Length)],
                    UnitPrice: r.Next(1, 100),
                    Number: r.Next(10, 1000));
                obs.OnNext(t);
            }
            obs.OnCompleted();
            return Disposable.Empty;
        });
    }
}

Example 11-27 展示了使用普通Aggregate运算符计算所有交易股票的总数,方法是将每个交易的Number属性相加。(当然,你通常会直接使用Sum运算符,但为了与Scan进行比较,我这里展示了这种方法。)

Example 11-27. 使用Aggregate进行求和
IObservable<Trade> trades = Trade.TestStream();

IObservable<long> tradeVolume = trades.Aggregate(
    0L, (total, trade) => total + trade.Number);
tradeVolume.Subscribe(Console.WriteLine);

这将显示一个单一的数字,因为由 Aggregate 生成的可观测对象只提供一个单一的值。示例 11-28 几乎完全展示了相同的代码,但是使用了 Scan 替代。

示例 11-28. 使用 Scan 运行总和
IObservable<Trade> trades = Trade.TestStream();

IObservable<long> tradeVolume = trades.Scan(
    0L, (total, trade) => total + trade.Number);
tradeVolume.Subscribe(Console.WriteLine);

这不是产生单一输出值,而是为每个输入产生一个输出项目,这是源迄今为止所有项目的累计总和。如果你需要在无限流中实现类似聚合的行为(例如基于事件源),Scan 就特别有用。在这种情况下,Aggregate 无法使用,因为如果其输入永远不完成,它将不会产生任何内容。

Amb 操作符

Rx 定义了一个名为 Amb 的操作符,其名称有些神秘。(请参见下一个侧边栏,“Why Amb?”)它接受任意数量的可观测序列,并等待看哪一个先执行操作。(文档讨论了输入中哪一个“首先反应”。这意味着它调用了任意三个 IObserver<T> 方法中的任何一个。)首先行动的输入有效成为 Amb 操作符的输出——它立即取消订阅其他流并转发所选择流的所有内容。(如果任何其他流在第一个流之后但操作符尚未取消订阅之前产生元素,这些元素将被忽略。)

使用这个操作符可以通过向服务器池中的多台机器发送请求并使用最先响应的结果来优化系统的响应时间。(当然,这种技术存在一些风险,其中最大的风险之一是可能会显著增加系统的总负载,导致整体速度变慢,而不是加快任何事情的速度。然而,在某些场景中,谨慎应用这种技术可以取得成功。)

DistinctUntilChanged

我要在本节中描述的最后一个操作符非常简单但相当有用。DistinctUntilChanged 操作符删除相邻的重复项。假设你有一个可观测源,它定期产生项目,但往往连续多次产生相同的值。你可能只在出现不同值时需要采取行动。DistinctUntilChanged 正好适用于这种场景——当其输入产生一个项目时,只有在与上一个项目不同(或者是第一个项目)时才会传递该项目。

我还没有展示我想介绍的所有 Rx 操作符。然而,剩下的那些我将在“Timed Operations”中讨论,它们都是时间敏感的。在我展示它们之前,我需要描述 Rx 如何处理时间。

调度器

Rx 通过 调度器 执行特定的工作。调度器是提供三项服务的对象。首先是决定何时执行特定的工作。例如,当观察者订阅冷源时,应立即将源的项目传递给订阅者,还是应该推迟该工作?第二项服务是在特定上下文中运行工作。例如,调度器可能决定始终在特定线程上执行工作。第三项工作是跟踪时间。某些 Rx 操作是时间相关的;为了确保可预测的行为并启用测试,调度器为时间提供了虚拟化模型,因此 Rx 代码不必依赖 .NET 的 DateTimeOffset 类报告的当前时间。

调度器的前两个角色有时是相互依赖的。例如,Rx 为 UI 应用程序提供了几个调度器。Windows Store 应用程序有一个 CoreDispatcherScheduler,WPF 应用程序有一个 DispatcherScheduler,Windows Forms 程序有一个 Control​Sched⁠uler,还有一个更通用的称为 SynchronizationContextScheduler,它将在所有 .NET UI 框架中工作,尽管对比特定于框架的调度器,它的细节控制稍逊一筹。所有这些调度器都有一个共同的特点:它们确保工作在适合访问 UI 对象的上下文中执行,通常意味着在特定线程上运行工作。如果调度工作的代码运行在其他线程上,则调度程序可能别无选择,只能推迟工作,因为它无法在 UI 框架准备好之前运行工作。这可能意味着等待特定线程完成其正在执行的任务。在这种情况下,正确上下文中运行工作也必然会影响工作的执行时间。

尽管如此,并非总是如此。Rx 提供了两个使用当前线程的调度器。其中一个是 ImmediateScheduler,非常简单:它在调度时立即运行工作。当您给这个调度器一些工作时,它不会返回,直到工作完成为止。另一个是 CurrentThreadScheduler,它维护一个工作队列,这使它在排序上具有一定的灵活性。例如,如果在执行某个其他工作的中间调度了一些工作,它可以允许正在进行的工作项完成后再开始下一个。如果没有排队或正在进行的工作项,CurrentThreadScheduler 就像 Immediate​Sched⁠uler 一样立即运行工作。当它调用完成一个工作项时,Current​Th⁠read​Sched⁠uler 检查队列,并在队列不为空时调用下一个工作项。因此,它试图尽快完成所有工作项,但与 ImmediateScheduler 不同的是,它不会在前一个工作项完成之前开始处理新的工作项。

指定调度器

Rx 操作通常不经过调度程序。许多可观察源直接调用其订阅者的方法。通常可以生成大量项目的源是一个例外。例如,用于创建序列的 RangeRepeat 方法使用调度程序来控制它们向新订阅者提供项目的速率。您可以传递显式调度程序或让它们选择默认调度程序。即使使用不接受调度程序作为参数的源,您也可以显式地涉及调度程序。

ObserveOn

指定调度程序的常见方式是使用 System.Reactive.Linq 命名空间中各种静态类定义的 ObserveOn 扩展方法。⁴ 即使事件可能来自其他地方,这在想要在特定上下文(如 UI 线程)处理事件时非常有用。

您可以在任何 IObservable<T> 上调用 ObserveOn,传递一个 IScheduler,它会返回另一个 IObservable<T>。如果订阅返回的可观察对象,则您的观察者的 OnNextOnCompletedOnError 方法将通过您指定的调度程序调用。 Example 11-29 使用此功能确保在项目处理程序回调中更新 UI 是安全的。

示例 11-29. ObserveOn 特定调度程序
IObservable<Trade> trades = GetTradeStream();
IObservable<Trade> tradesInUiContext =
    `trades``.``ObserveOn``(``DispatcherScheduler``.``Current``)``;`
tradesInUiContext.Subscribe(t =>
{
    tradeInfoTextBox.AppendText(
        $"{t.StockName}: {t.Number} at {t.UnitPrice}\r\n");
});

在此示例中,我使用了 DispatcherScheduler 类的静态 Current 属性,该属性返回通过当前线程的 Dispatcher 执行工作的调度程序。 (Dispatcher 是在 WPF 应用程序中管理 UI 消息循环的类。) Rx 的 DispatcherObservable 类定义了各种提供 WPF 特定重载的扩展方法,而不是传递调度程序,我可以调用 ObserveOn 只传递一个 Dispatcher 对象。我可以在代码中使用此方法,例如在 Example 11-30 中。

示例 11-30. ObserveOn WPF Dispatcher
IObservable<Trade> tradesInUiContext = trades.ObserveOn(this.Dispatcher);

这种重载的优点在于,在调用 ObserveOn 的时候我不需要处于 UI 线程上。在 Example 11-29 中使用的 Current 属性只有在您所需的调度程序的线程上时才有效。如果我已经在该线程上,可以更简单地设置它。我可以使用 ObserveOnDispatcher 扩展方法,该方法获取当前线程的调度程序的 DispatcherScheduler,如 Example 11-31 所示。

示例 11-31. 在当前调度程序上观察
IObservable<Trade> tradesInUiContext = trades.ObserveOnDispatcher();

SubscribeOn

大多数各种ObserveOn扩展方法都有相应的SubscribeOn方法。(还有SubscribeOnDispatcher,它是ObserveOnDispatcher的对应物。)SubscribeOn不是为了通过调度程序安排每次对观察者方法的调用,而是通过调度程序执行源可观察对象的Subscribe方法的调用。如果通过调用Dispose取消订阅,那也会通过调度程序传递。对于冷源来说,这可能很重要,因为很多在其Subscribe方法中执行重要工作,有些甚至会立即传递所有项目。

注意

一般来说,订阅源的上下文与生成的项目将传递给订阅者的上下文之间没有任何对应关系的保证。某些源会在其订阅上下文中通知您,但很多则不会。如果您需要在特定上下文中接收通知,那么除非源提供某种方式来指定调度程序,否则使用ObserveOn

明确传递调度程序

一些操作接受调度程序作为参数。您通常会在可以生成多个项目的操作中找到这些调度程序。生成数字序列的Observable.Range方法可以选择在最后一个参数中接受一个调度程序,以控制生成这些数字的上下文。这也适用于适应其他源(例如IEnumerable<T>)到可观察源的 API,如“适配”中所述。

另一个通常可以提供调度程序的场景是使用合并输入的可观察对象。前面提到过,Merge操作符可以合并多个序列的输出。您可以提供一个调度程序来告诉操作符从特定上下文订阅源。

最后,所有定时操作都依赖于调度程序。我将在“定时操作”中展示其中一些。

内置调度程序

我已经描述了四个面向 UI 的调度程序,DispatcherScheduler(用于 WPF)、CoreDispatcherScheduler(用于 Windows Store 应用)、ControlScheduler(用于 Windows Forms)和SynchronizationContextScheduler,以及两个在当前线程上运行工作的调度程序,CurrentThreadSchedulerImmediateScheduler。但还有一些其他值得注意的调度程序。

EventLoopScheduler在特定线程上运行所有工作项。它可以为您创建一个新线程,或者您可以为其提供一个回调方法,在需要您创建线程时它会调用该方法。您可以在 UI 应用程序中使用它来处理传入数据。它允许您将工作从 UI 线程移出,以保持应用程序的响应性,但确保所有处理都在单个线程上进行,这可以简化并发问题。

NewThreadScheduler 为每个顶级工作项创建一个新线程。(如果该工作项生成更多工作项,则这些工作项将在同一线程上运行,而不是创建新线程。)只有在每个项需要大量工作时才适用,因为在 Windows 中线程的启动和关闭成本相对较高。如果需要并发处理工作项,通常最好使用线程池。

TaskPoolScheduler 使用任务并行库(TPL)的线程池。TPL 在第十六章中描述,提供了一个高效的线程池,可以重用单个线程来处理多个工作项,从而分摊了创建线程的启动成本。

ThreadPoolScheduler 使用 CLR 的线程池来运行工作。这在概念上类似于 TPL 线程池,但技术上稍显陈旧。(TPL 是在.NET 4.0 中引入的,但 CLR 线程池自 v1.0 起就存在。)在某些场景下效率较低。Rx 引入了这个调度器,因为早期的 Rx 版本支持没有 TPL 的旧版.NET。出于向后兼容的原因保留了它。

HistoricalScheduler 在你需要测试依赖于时间的代码,但又不想实时执行测试时非常有用。所有调度器都提供时间服务,但HistoricalScheduler让你可以决定调度器以多快的速度模拟时间流逝。因此,如果你需要测试等待 30 秒后会发生什么,你可以告诉HistoricalScheduler模拟 30 秒已过去,而无需真正等待。

Subjects

Rx 定义了各种subjects,这些类实现了IObserver<T>IObservable<T>接口。如果需要 Rx 提供这些接口的强大实现,但通常的Observable.CreateSubscribe方法不方便时,这些类有时会很有用。例如,也许你需要提供一个可观察的源,而你的代码中有几个不同的地方需要为该源提供值来生成。这很难适配到Create方法的订阅回调模型中,使用 subject 会更容易处理。某些 subject 类型提供额外的行为,但我将从最简单的Subject<T>开始。

Subject<T>

Subject<T> 类的 IObserver<T> 实现只是通过其 IObservable<T> 接口转发给所有已订阅的观察者。因此,如果你订阅了一个或多个 Subject<T>,然后调用 OnNext,该主题将调用其所有订阅者的 OnNext 方法。其他方法 OnCompletedOnError 也是如此。这种多播转发与我在 示例 11-11 中使用的 Publish 操作符⁵ 提供的功能非常相似,因此这为我从 KeyWatcher 源中移除所有跟踪订阅者代码提供了另一种选择,结果如 示例 11-32 中所示。这比 示例 11-7 中的原始方法简单得多,尽管不像 示例 11-11 中的基于委托的版本那样简单。

示例 11-32. 使用 Subject<T> 实现 IObservable<T>
public class KeyWatcher : IObservable<char>
{
    private readonly Subject<char> _subject = new();

    public IDisposable Subscribe(IObserver<char> observer)
    {
        return _subject.Subscribe(observer);
    }

    public void Run()
    {
        while (true)
        {
            _subject.OnNext(Console.ReadKey(true).KeyChar);
        }
    }
}

在其 Subscribe 方法中,它转发到了一个 Subject<char>,所以试图订阅这个 KeyWatcher 的一切最终都会订阅到该主题。然后,我的循环只需调用主题的 OnNext 方法,它会负责将其广播给所有订阅者。

实际上,我可以通过将可观察对象公开为单独的属性来进一步简化事情,而不是使整个类型都成为可观察的,正如 示例 11-33 所示。这不仅使代码稍微简单了些,而且意味着我的 KeyWatcher 现在可以提供多个源。

示例 11-33. 提供一个 IObservable<T> 作为属性
public class KeyWatcher
{
    private readonly Subject<char> _subject = new();

    public IObservable<char> Keys => _subject;

    public void Run()
    {
        while (true)
        {
            _subject.OnNext(Console.ReadKey(true).KeyChar);
        }
    }
}

这仍然不像我在 示例 11-11 中使用的 Observable.CreatePublish 操作符的组合那样简单,但它确实提供了两个优点。首先,现在更容易看到生成键盘按键通知的循环何时运行。在 示例 11-11 中我控制了这个过程,但对于不太熟悉 Publish 工作原理的人来说,这可能并不明显。我觉得 示例 11-33 较少神秘。其次,如果我愿意,我可以从 KeyWatcher 类的任何地方使用这个主题,而在 示例 11-11 中,我只能在由 Observable.Create 调用的回调函数内部提供一个项。在这个例子中,我并不需要这种灵活性,但在需要时,Subject<T> 很可能比回调方法更好。

BehaviorSubject

BehaviorSubject<T> 看起来几乎和 Subject<T> 一模一样,除了一点:当任何观察者第一次订阅时,只要你还没有通过调用 OnComplete 完成主题,它就保证会立即接收一个值。(如果你已经完成了主题,它将立即在任何进一步的订阅者上调用 OnComplete。)它会记住它传递的最后一项,并将其提供给新的订阅者。当你构造一个 BehaviorSubject<T> 时,你必须提供一个初始值,它将提供给新的订阅者,直到第一次调用 OnNext

有一种方法可以将此主题视为 Rx 版本的变量。它是一种具有可以随时检索的值的东西,其值也可以随时间改变。但是由于是响应式的,你必须订阅它才能检索其值,并且在你取消订阅之前,观察者将被通知任何进一步的更改。

此主题具有热和冷特性的混合。它将立即向任何订阅者提供一个值,使其看起来像一个冷源,但一旦发生这种情况,它会像热源一样向所有订阅者广播新值。还有另一个主题具有类似的混合特性,但更进一步地采用了冷侧。

ReplaySubject<T>

ReplaySubject<T> 可以记录它从任何你订阅的源接收到的每个值。(或者,如果你直接调用其方法,它会记住你通过 OnNext 提供的每个值。)对此主题的每个新订阅者将接收到 ReplaySubject<T> 到目前为止所看到的每个项目。因此,这更像是一个普通的冷主题——与从 BehaviorSubject<T> 获取最近值不同,你会得到一个完整的项目集。然而,一旦 ReplaySubject<T> 向特定的订阅者提供了所有已记录的项目,对于该订阅者,它会转变为更像热主题的行为,因为它将继续提供新接收到的项目。

因此,长期以来,对 ReplaySubject<T> 的每个订阅者默认情况下都会看到它从其源接收到的每个项目,无论该订阅者是何时订阅的主题。

在其默认配置中,ReplaySubject<T> 将消耗越来越多的内存,只要它订阅到一个源。无法告诉它不会再有新的订阅者,并且现在可以丢弃已经分发给所有现有订阅者的旧项。因此,你不应该无限期地将其订阅到一个无限源。但是,你可以限制 ReplaySubject<T> 缓冲的数量。它提供了各种构造函数重载,其中一些让你可以指定回放的项目数量的上限或者持续保留项目的时间的上限。显然,如果你这样做,新的订阅者将无法依赖于获取到以前接收到的所有项目。

AsyncSubject<T>

AsyncSubject<T>只会记住其来源的一个值,但与BehaviorSubject<T>不同,后者记住最近的值,AsyncSubject<T>会等待其来源完成。然后,它将产生最终项作为其输出。如果来源在不提供任何值的情况下完成,AsyncSubject<T>将立即完成所有新的订阅者。

如果在其来源完成之前订阅了AsyncSubject<T>,则AsyncSubject<T>不会对观察者执行任何操作。但一旦来源完成,AsyncSubject<T>将作为冷源,提供单个值,除非来源在不提供值的情况下完成,否则此主题将立即完成所有新的订阅者。

适应

尽管 Rx 很有趣且强大,但如果它存在于真空中,则几乎没有用处。如果您正在处理异步通知,可能会有 API 提供它们,但不支持 Rx。虽然IObservable<T>IObserver<T>已经存在很长时间(自 2010 年发布的.NET 4.0 以来),但并非每个支持这些接口的 API 都能够支持。此外,由于 Rx 的基本抽象是一系列项目,因此很可能在某个时候需要在 Rx 的推送式IObservable<T>和拉取式等效的IEnumerable<T>IAsyncEnumerable<T>之间进行转换。Rx 提供了将这些和其他类型的来源适应为IObservable<T>的方法,并且在某些情况下,可以在两者之间进行适应。

IEnumerable和 IAsyncEnumerable

任何IEnumerable<T>都可以轻松地进入 Rx 的世界,这要归功于ToObservable扩展方法。这些方法由System.Reactive.Linq命名空间中的Observable静态类定义。示例 11-34 展示了最简单的形式,不带任何参数。

示例 11-34. 将IEnumerable<T>转换为IObservable<T>
public static void ShowAll(IEnumerable<string> source)
{
    `IObservable``<``string``>` `observableSource` `=` `source``.``ToObservable``(``)``;`
    observableSource.Subscribe(Console.WriteLine);
}

ToObservable方法本身不会枚举其输入——它只返回一个实现IObservable<T>的包装器。此包装器是一个冷源,每当您向其订阅观察者时,它才会迭代输入,将每个项目传递给观察者的OnNext方法,并在最后调用OnCompleted。如果源引发异常,此适配器将调用OnError。示例 11-35 展示了如果不需要使用调度程序,ToObservable可能的工作方式。

示例 11-35. 没有调度程序支持时ToObservable的实现
public static IObservable<T> MyToObservable<T>(this IEnumerable<T> input)
{
    return Observable.Create((IObserver<T> observer) =>
        {
            bool inObserver = false;
            try
            {
                foreach (T item in input)
                {
                    inObserver = true;
                    observer.OnNext(item);
                    inObserver = false;
                }
                inObserver = true;
                observer.OnCompleted();
            }
            catch (Exception x)
            {
                if (inObserver)
                {
                    throw;
                }
                observer.OnError(x);
            }
            return () => { };
        });
}

这并不是它真正的工作方式,因为示例 11-35 无法使用调度程序。(完整的实现将会更难阅读,从而违背了示例的目的,即显示ToObservable背后的基本思想。)真实的方法使用调度程序来管理迭代过程,如果需要,允许异步订阅。它还支持在观察者的订阅被提前取消时停止工作。有一个以IScheduler类型的单一参数作为重载,允许您告诉它使用特定的调度程序;如果您不提供一个,它将使用CurrentThreadScheduler

当需要进行相反方向的操作时,即当您有一个IObservable<T>但希望将其视为IEnumerable<T>时,您可以调用由Observable类提供的ToEnumerable扩展方法。示例 11-36 将IObservable<string>封装为IEnumerable<string>,以便可以使用普通的foreach循环迭代源中的项。

示例 11-36. 将IObservable<T>用作IEnumerable<T>
public static void ShowAll(IObservable<string> source)
{
    foreach (string s in source.ToEnumerable())
    {
        Console.WriteLine(s);
    }
}

包装器代表您订阅了源。如果源提供的项比您迭代它们的速度更快,包装器将把这些项存储在队列中,以便您可以随时检索它们。如果源提供的项不如您检索它们的速度快,包装器将等待直到有可用的项。

接口IAsyncEnumerable<T>提供了与IEnumerable<T>相同的模型,但通过使用第十七章中讨论的技术,以一种能够进行有效异步操作的方式实现。Rx 提供了ToObservable扩展方法,以及IObservable<T>ToAsyncEnumerable方法扩展方法。这两者都来自AsyncEnumerable类,如果要使用它们,您将需要引用一个名为System.Linq.Async的单独 NuGet 包。

.NET 事件

Rx 可以使用Observable类的静态FromEventPattern方法将.NET 事件包装为IObservable<T>。此前在示例 11-17 中,我使用了FileSystemWatcher,这是System.IO命名空间中的一个类,在特定文件夹中添加、删除、重命名或以其他方式修改文件时触发各种事件。示例 11-37 复制了该示例的第一部分,这是上次我略过的部分。此代码使用Observable.FromEventPattern静态方法生成一个表示监视器Created事件的可观察源。(如果要处理静态事件,您可以将Type对象作为第一个参数传递。第十三章描述了Type类。)

示例 11-37. 将事件包装为IObservable<T>
string path = Environment.GetFolderPath(Environment.SpecialFolder.MyPictures);
var watcher = new FileSystemWatcher(path);
watcher.EnableRaisingEvents = true;

IObservable<EventPattern<FileSystemEventArgs>> changes =
    Observable.FromEventPattern<FileSystemEventArgs>(
        watcher, nameof(watcher.Created));
changes.Subscribe(evt => Console.WriteLine(evt.EventArgs.FullPath));

表面上看,这似乎比在 第九章 中显示的正常订阅事件要复杂得多,而且没有明显的优势。在这种特定示例中,使用传统方法会更好。但是,使用 Rx 的一个好处是,如果您正在编写 UI 应用程序,您可以使用适当的调度程序与 ObserveOn 一起确保您的处理程序始终在正确的线程上调用,无论哪个线程引发事件。当然,另一个好处 —— 通常的原因 —— 是您可以使用 Rx 的任何查询操作符来处理事件。(这就是为什么原始的 Example 11-17 这样做的原因。)

Example 11-37 产生的可观察源的元素类型是 Event​Pat⁠tern<FileSystemEventArgs>。泛型 EventPattern<T> 是 Rx 特别为表示事件触发而定义的类型,其中事件的委托类型符合 第九章 中描述的标准模式(即接受两个参数,第一个是 object 类型,表示引发事件的对象,第二个是从 EventArgs 派生的某种类型,包含有关事件的信息)。EventPattern<T> 有两个属性,SenderEventArgs,对应于事件处理程序将接收的两个参数。实际上,这是一个表示通常会对事件处理程序进行方法调用的对象。

Example 11-37 的一个令人惊讶的特性是,FromEvent​Pat⁠tern 的第二个参数是一个包含事件名称的字符串。Rx 在运行时将其解析为真实的事件成员。这在某些方面不太理想,原因有几个。首先,这意味着如果您输入错误的名称,编译器不会注意到(尽管使用 nameof 操作符可以缓解这个问题)。其次,这意味着编译器无法帮助您确定类型 —— 如果直接使用 lambda 表达式处理 .NET 事件,编译器可以从事件定义推断参数类型,但在这里,因为我们将事件名称作为字符串传递,编译器不知道我使用的是哪个事件(甚至不知道我是否使用了事件),因此我必须明确地为方法指定泛型类型参数。而且,如果我搞错了,编译器不会察觉 —— 这将在运行时检查。

这种基于字符串的方法源于事件的缺陷:您不能将事件作为参数传递。事实上,事件是非常有限的成员。您不能从定义它的类外部对事件执行任何操作,除了添加或移除处理程序。这是 Rx 在事件方面改进的一种方式——一旦进入 Rx 的世界,事件源和订阅者都表示为对象(分别实现 IObservable<T>IObserver<T>),使得将它们作为参数传递到方法中变得简单。但这并不帮助我们处理尚未进入 Rx 的事件的情况。

Rx 确实提供了一个重载,不需要使用字符串——您可以传递用于 Rx 添加和移除处理程序的委托,如 示例 11-38 所示。

示例 11-38. 基于委托的事件包装
IObservable<EventPattern<FileSystemEventArgs>> changes =
    Observable.FromEventPattern<FileSystemEventHandler, FileSystemEventArgs>(
    h => watcher.Created += h, h => watcher.Created -= h);

这相对冗长一些,因为它需要一个泛型类型参数来指定处理程序委托类型以及事件参数类型。基于字符串的版本在运行时自行发现处理程序类型,但因为使用 示例 11-38 的一般原因是为了获得编译时类型检查,编译器需要知道你使用的是哪些类型,而该示例中的 Lambda 表达式并没有为编译器提供足够的信息来自动推断所有类型参数。

除了将事件作为可观察源进行包装之外,还可以反过来进行。Rx 定义了一个名为 ToEventPattern<T>IObservable<EventPattern<T>> 操作符。(请注意,这不适用于任何旧的可观察源——它必须是 EventPattern<T> 的可观察序列。)如果调用此方法,它将返回一个实现了 IEventPatternSource<T> 接口的对象。它定义了一个名为 OnNext 的单一事件,类型为 EventHandler<T>,允许您以普通的 .NET 方式将事件处理程序连接到可观察源。

异步 API

.NET 支持各种异步模式,在第十六章和第十七章中会详细描述。最早在 .NET 中引入的是异步编程模型(APM)。然而,这种模式并不直接被新的 C# 异步语言特性支持,所以现在大多数 .NET API 使用 TPL,对于旧的 API,TPL 提供了适配器,可以为基于 APM 的 API 提供基于任务的包装器。Rx 可以将任何 TPL 任务表示为可观察源。

所有.NET 异步模式的基本模型是,你启动一些工作,最终会完成,可选地生成一个结果。因此,将其翻译成 Rx 可能看起来有些奇怪,因为 Rx 的基本抽象是一个项目序列,而不是单个结果。事实上,理解 Rx 和 TPL 之间的区别的一种有用方法是,IObservable<T>类似于IEnumerable<T>,而Task<T>类似于类型为T的属性。与使用IEnumerable<T>和属性时,调用者决定何时从源获取信息不同,使用IObservable<T>Task<T>时,源在准备好时提供信息。决定何时提供信息的选择与信息是单个还是项目序列是分开的。因此,将单个异步 API 映射到IObservable<T>似乎有些不匹配。但是我们在非异步世界中也可以跨越类似的界限——正如你在第十章中看到的,LINQ 定义了各种标准运算符,用于从序列生成单个项目,如FirstLast。Rx 支持这些运算符,但它还支持另一种方式:将单个异步源带入类似流的世界。其结果是一个IObservable<T>源,只产生一个项目(或者如果操作失败,则报告错误)。在非异步世界中的类比可以是将单个值包装在数组中,以便您可以将其传递给需要IEnumerable<T>的 API。

示例 11-39 使用此功能生成一个IObservable<string>,它将生成包含从特定 URL 下载的文本的单个值,或在下载失败时报告故障。

示例 11-39. 将Task<T>包装为IObservable<T>
public static IObservable<string> GetWebPageAsObservable(
    Uri pageUrl, IHttpClientFactory cf)
{
    HttpClient web = cf.CreateClient();
    Task<string> getPageTask = web.GetStringAsync(pageUrl);
    `return` `getPageTask``.``ToObservable``(``)``;`
}

在这个示例中使用的ToObservable方法是 Rx 为Task定义的扩展方法。为了使其可用,你需要确保System.Reactive.Threading.Tasks命名空间在作用域内。

示例 11-39 的一个潜在的不满意特性是,它只会尝试下载一次,无论多少观察者订阅源。根据您的需求,这可能没问题,但在某些情况下,每次尝试下载新副本可能更合理。如果您需要这样做,更好的方法是使用Observable.FromAsync方法,因为您可以将一个 lambda 传递给它,每当新的观察者订阅时它都会调用这个 lambda。您的 lambda 返回一个任务,然后将其作为可观察源进行包装。示例 11-40 使用这种方法为每个订阅者启动一个新的下载。

示例 11-40. 为每个订阅者创建一个新任务
public static IObservable<string> GetWebPageAsObservable(
    Uri pageUrl, IHttpClientFactory cf)
{
    return Observable.FromAsync(() =>
        {
            HttpClient web = cf.CreateClient();
            return web.GetStringAsync(pageUrl);
        });
}

如果你有多个订阅者,这可能不是最佳选择。另一方面,当没有任何尝试订阅时,这更有效率。示例 11-39 立即开始异步工作,甚至不等待任何订阅者。这可能是一件好事——如果流肯定会有订阅者,那么在等待第一个订阅者之前开始缓慢的工作将减少整体延迟。然而,如果你正在编写一个库中呈现多个可观察源的类,可能最好推迟工作直到第一次订阅。

定时操作

由于 Rx 可以处理实时信息流,你可能需要以时间敏感的方式处理项目。例如,项目到达的速率可能很重要,或者你可能希望根据提供的时间对项目进行分组。在最后的这一节中,我将描述 Rx 提供的一些基于时间的操作符。

间隔

Observable.Interval 方法返回一个序列,该序列定期以 TimeSpan 类型的参数指定的间隔产生值。示例 11-41 创建并订阅了一个每秒产生一个值的源。

示例 11-41. 使用 Interval 进行定期项目处理
IObservable<long> src = Observable.Interval(TimeSpan.FromSeconds(1));
src.Subscribe(i => Console.WriteLine($"Event {i} at {DateTime.Now:T}"));

Interval 产生的项目的类型是 long。它生成值为零、一、二等。

Interval 独立处理每个订阅者(即它是一个冷源)。为了演示这一点,在 示例 11-41 的代码之后添加 示例 11-42 中的代码,稍等一会儿,然后创建第二个订阅。

示例 11-42. 对 Interval 源的第二个订阅者
Thread.Sleep(2500);
src.Subscribe(i => Console.WriteLine(
    $"Event {i} at {DateTime.Now:T} (2nd subscriber)"));

第二个订阅者在第一个订阅者之后订阅了两秒半,所以这将产生以下输出:

Event 0 at 09:46:58
Event 1 at 09:46:59
Event 2 at 09:47:00
Event 0 at 09:47:00 (2nd subscriber)
Event 3 at 09:47:01
Event 1 at 09:47:01 (2nd subscriber)
Event 4 at 09:47:02
Event 2 at 09:47:02 (2nd subscriber)
Event 5 at 09:47:03
Event 3 at 09:47:03 (2nd subscriber)

你可以看到第二个订阅者的值从零开始,这是因为它有自己的序列。如果你希望这些定时项目的单个集合供多个订阅者使用,可以使用前面描述的 Publish 操作符。

你可以将 Interval 源与组连接一起作为一种根据到达时间分割项目的方法。(这不是唯一的方法——BufferWindow 有多种重载可以做同样的事情。)示例 11-43 将定时器与代表用户输入的可观察序列结合起来。(第二个序列在 words 变量中,来自 示例 11-25。)

示例 11-43. 计算每分钟的字数
IObservable<long> ticks = Observable.Interval(TimeSpan.FromSeconds(6));
IObservable<int> wordGroupCounts = from tick in ticks
                                   join word in words
                                     on ticks equals words into wordsInTick
                                   from count in wordsInTick.Count()
                                   select count * 10;

wordGroupCounts.Subscribe(c => Console.WriteLine($"Words per minute: {c}"));

将单词根据来自Interval源的事件分组后,此查询继续计算每个组中的项目数。由于组在时间上均匀间隔,这可以用来计算用户输入单词的近似速率。我每 6 秒形成一组,所以我们可以将组中的单词数乘以 10 来估算每分钟的单词数。

结果并不完全准确,因为 Rx 将会合并两个项目,如果它们的持续时间重叠。这将导致单词在此处被多次计数。在一个间隔的末尾的最后一个单词也将成为下一个间隔开始时的第一个单词。在这种情况下,测量结果相当粗略,所以我不太担心,但如果您希望得到更精确的结果,您需要考虑重叠如何影响这种操作。WindowBuffer可能提供更好的解决方案。

定时器

Observable.Timer方法可以创建一个产生确切一个项目的序列。它在产生该项目之前等待由TimeSpan参数指定的持续时间。它看起来非常类似于Observable.Interval,因为它不仅接受相同的参数,而且甚至返回相同类型的序列:IObservable<long>。因此,我可以几乎以与间隔序列完全相同的方式订阅此类源,正如示例 11-44 所示。

示例 11-44. 使用Timer的单个项目
IObservable<long> src = Observable.Timer(TimeSpan.FromSeconds(1));
src.Subscribe(i => Console.WriteLine($"Event {i} at {DateTime.Now:T}"));

效果与在产生其第一个项目后停止的Interval相同,因此您将始终获得零值。还有接受额外TimeSpan的重载,它将像Interval一样重复生成值。实际上,Interval在内部使用Timer——它只是一个提供更简单 API 的包装器。

时间戳

在前两节中,我在写出消息时使用DateTime.Now来指示源产生项目的时间。这样做的一个潜在问题是它告诉我们处理程序处理消息的时间,这不总是准确反映消息接收的时间。例如,如果您使用ObserveOn确保处理程序始终在 UI 线程上运行,那么在项目生成和您的代码处理它之间可能会有显著延迟,因为 UI 线程可能在执行其他任务。您可以使用Timestamp运算符来减轻这一问题,该运算符适用于任何IObservable<T>。示例 11-45 使用这种方式作为显示Interval生成其项目的时间的替代方法。

示例 11-45. 带时间戳的项目
IObservable<Timestamped<long>> src =
    Observable.Interval(TimeSpan.FromSeconds(1)).Timestamp();
src.Subscribe(i => Console.WriteLine(
    $"Event {i.Value} at {i.Timestamp.ToLocalTime():T}"));

如果源可观察对象的项目类型为某种类型T,此运算符将生成一个Timestamped<T>项目的可观察对象。这定义了一个包含来自源可观察对象的原始值的Value属性,以及一个指示值何时通过Timestamp运算符的Timestamp属性。

注意

Timestamp属性是一个DateTimeOffset,并选择了零时区偏移(即它在 UTC 时间)。这通过移除程序运行时进出夏令时的可能性,为定时提供了稳定的基础。然而,如果你想向最终用户显示时间戳,你可能需要调整它,这就是为什么示例 11-45 在其上调用了 ToLocalTime 的原因。

你应该直接将这个操作符应用到你想要加时间戳的可观察对象上,而不是将它留在链条的后面。写成 src.ObserveOn(sched).Timestamp() 会失去意义,因为你会在调度程序传递给 ObserveOn 后对项进行计时。你应该写成 src.Timestamp().ObserveOn(sched),以确保在将项馈送到可能引入延迟的处理链之前获取时间戳。

TimeInterval

Timestamp记录产生项时的当前时间,它的相对对应物TimeInterval记录连续项之间的时间。示例 11-46 在由Observable.Interval产生的可观察序列上使用了这个操作符,所以我们期望这些项间隔相对均匀。

示例 11-46. 测量间隔
IObservable<long> ticks = Observable.Interval(TimeSpan.FromSeconds(0.75));
IObservable<TimeInterval<long>> timed = ticks.TimeInterval();
timed.Subscribe(x => Console.WriteLine(
    $"Event {x.Value} took {x.Interval.TotalSeconds:F3}"));

Timestamp操作符生成的Timestamped<T>项提供一个Timestamp属性,而由TimeInterval操作符生成的TimeInterval<T>项则定义了一个Interval属性。这是一个TimeSpan,而不是DateTimeOffset。我选择显示每个项之间的秒数,保留三位小数。当我在我的计算机上运行时,这是我看到的一些内容:

Event 0 took 0.760
Event 1 took 0.757
Event 2 took 0.743
Event 3 took 0.751
Event 4 took 0.749
Event 5 took 0.750

这显示的间隔可能比我要求的要差 10 毫秒,但这是相当典型的。Windows 不是一个实时操作系统。

Throttle

Throttle操作符允许你限制处理项的速率。你传递一个TimeSpan,指定任意两个项之间的最小时间间隔。如果底层源产生的项比这更快,Throttle会直接丢弃它们。如果源比指定速率慢,Throttle会直接传递所有内容。

惊讶的是(或者至少我发现这很惊讶),一旦源超过指定速率,Throttle会将所有东西都丢弃,直到速率再次降到指定水平以下。因此,如果你指定每秒处理 10 个项目,而源每秒产生 100 个项目,它不会简单地返回每第 10 个项目,而是直到源减速才会返回任何东西。

Sample

Sample操作符根据其TimeSpan参数指定的间隔从其输入中产生项,而不管输入可观察对象生成项的速率如何。如果底层源产生的项比所选速率快,Sample会丢弃项以限制速率。然而,如果源运行较慢,Sample操作符会重复最后一个值,以确保持续供应通知。

Timeout

Timeout 操作符从其源可观察对象中传递所有内容,除非源在订阅时间和第一个项目之间或两次调用观察者之间留下了太大的间隙。你可以用 TimeSpan 参数指定最小可接受的间隙。如果在该时间内没有任何活动发生,Timeout 操作符将通过向 OnError 报告 TimeoutException 来完成。

窗口操作符

我之前描述了 BufferWindow 操作符,但没有展示它们基于时间的重载。除了能够指定窗口大小和跳过计数,或者使用辅助的可观察源标记窗口边界外,你还可以指定基于时间的窗口。

如果只传递一个 TimeSpan,这两个操作符都会在指定的间隔时间内将输入拆分为相邻窗口。这提供了一种比 示例 11-43 更简单的估算每分钟单词数的方法。示例 11-47 展示了如何使用定时窗口的 Buffer 操作符实现相同效果。

示例 11-47. 使用 Buffer 的定时窗口
IObservable<int> wordGroupCounts =
    from wordGroup in words.Buffer(TimeSpan.FromSeconds(6))
    select wordGroup.Count * 10;
wordGroupCounts.Subscribe(c => Console.WriteLine("Words per minute: " + c));

还有接受 TimeSpanint 两个参数的重载,允许你在指定的间隔时间内关闭当前窗口(从而启动下一个窗口),或者在项数超过阈值时关闭窗口。此外,还有接受两个 TimeSpan 参数的重载。这些重载支持窗口大小和跳过计数的时间等效组合。第一个 TimeSpan 参数指定窗口持续时间,而第二个参数指定开始新窗口的间隔。这意味着窗口不需要严格相邻,它们可以有间隙,或者可以重叠。示例 11-48 使用这种方法提供更频繁的单词速率估算,同时仍然使用六秒窗口。

示例 11-48. 重叠的定时窗口
IObservable<int> wordGroupCounts =
    from wordGroup in words.Buffer(TimeSpan.FromSeconds(6),
                                   TimeSpan.FromSeconds(1))
    select wordGroup.Count * 10;

与我在 示例 11-43 中展示的基于连接的分块不同,WindowBuffer 不会因为它们不基于重叠持续时间的概念而重复计数项。它们将项目到达视为瞬时事件,要么在给定窗口内,要么在外部。因此,我刚刚展示的示例将提供稍微更准确的速率测量。

延迟

Delay 操作符允许你对可观察的源进行时间偏移。你可以传递一个 TimeSpan,在这种情况下,操作符将延迟所有内容指定的时间量,或者你可以传递一个 DateTimeOffset,指示你希望它开始重新播放其输入的特定时间。另外,你也可以传递一个可观察对象,当该可观察对象首次产生值或完成时,Delay 操作符将开始产生它存储的值。

不论时间偏移持续时间如何确定,在所有情况下,Delay 操作符都试图保持输入之间的相同间隔。因此,如果底层源立即生成一个项目,然后三秒后生成另一个项目,再过一分钟生成第三个项目,Delay 生成的可观察对象将按照相同的时间间隔生成项目。

显然,如果你的源开始以惊人的速度生成项目——也许是每秒万个项目 — Delay 要复制项目的确切时间安排的保真度存在限制,但它会尽力而为。准确性的限制不是固定的。它们将由你使用的调度器的性质和机器上的可用 CPU 容量确定。例如,如果你使用其中一个基于 UI 的调度器,它将受制于 UI 线程的可用性以及其能够分派工作的速率。(与所有基于时间的运算符一样,Delay 将为你选择一个默认调度器,但它提供了可以传递调度器的重载。)

DelaySubscription

DelaySubscription 操作符提供了一组与 Delay 操作符类似的重载,但它尝试实现延迟的方式不同。当你订阅由 Delay 生成的可观察源时,它将立即订阅底层源并开始缓冲项目,在所需的延迟时间过去后才转发每个项目。DelaySubscription 采用的策略只是延迟对底层源的订阅,然后立即转发每个项目。

对于冷源,DelaySubscription 通常会满足你的需求,因为延迟冷源的工作开始通常会使整个过程发生时间偏移。但对于热源,DelaySubscription 会导致你错过延迟期间发生的任何事件,并且之后,你将开始获取没有时间偏移的事件。

Delay 操作符更可靠——通过单独对每个项目进行时间偏移,它适用于热源和冷源。然而,它需要更多的工作——它需要为延迟持续时间内接收到的所有内容进行缓冲。对于繁忙的源或长时间的延迟,这可能会消耗大量内存。并且,试图通过时间偏移来复制原始时间安排比直接传递项目要复杂得多。因此,在适用的场景中,DelaySubscription 更有效率。

Reaqtor — Rx 作为服务

在 2021 年 5 月,微软开源了 Reaqtor,这是一组组件,使得能够在服务中托管长时间运行的 Rx 查询成为可能。微软在其多种在线服务中内部使用了这些组件,包括必应搜索引擎和 Office 的在线版本,为其提供事件驱动功能。例如,它使得设置提醒成为可能,告诉你根据当前交通情况需要何时离开以准时赴约。Reaqtor 已经被证明能够维护数百万活跃查询的记录。使这一切成为可能的核心库的代码托管在 Reaqtor 源代码仓库,你可以在 文档和支持信息 找到相关内容。

Reaqtor 借用了 Rx 的编程模型——可观察序列、主题和操作符,并利用了 .NET 的表达式树功能(详见 第九章)来实现可以存储或发送网络传输的查询。它还提供了标准 LINQ 操作符的版本,能够持久化它们的状态,使得带有状态操作符的查询(如 AggregateDistinctUntilChanged 或其他需要记住已见数据的操作符)可以在任何单个进程生命周期之外继续存在。这使得一个应用程序能够定义一个 LINQ 查询到某个数据源的可观察对象,并设置一个订阅该查询的服务器池,其寿命可以任意长。Reaqtor 的设计旨在提供与数据库相同的耐久性,因此微软的一些应用程序中有 Rx 查询已经连续运行数年未中断。

Rx 和 Reaqtor 之间的关系与 LINQ to Objects 和 Entity Framework (EF) Core 之间的关系非常相似。正如您在 第十章 中看到的那样,LINQ to Objects 是建立在 IEnumerable<T> 上的,并且完全在内存中工作,没有持久性或跨进程能力。EF Core 使用相同的基本概念,并提供大多数相同的操作符,但是通过基于表达树的 IQueryable<T> 来构建,EF Core 能够将应用程序的查询表示发送到数据库服务器,以便远程执行这些查询。EF Core 将 LINQ 带入了持久性耐久性和分布式执行的世界。类似地,Rx 是建立在 IObservable<T> 上的,并且完全在内存中运行,而 Reaqtor 则使用基于表达树的接口 IQbservable<T>。(请注意,Q 代替了 O,表示其与 IQueryable<T> 在概念上的相似性。)IQbservable<T> 看起来与 IObservable<T> 非常相似,并提供所有相同的操作符,但是因为它在表达树的世界中工作,Reaqtor 能够将查询转换为可以发送到服务器群的形式,然后服务器群内部能够重新构建可运行版本的这些查询。它利用可序列化性来存储查询,使得这些查询能够在服务器群内的不同机器之间迁移,提供了在单个服务器故障面前的持久性和耐久性。Reaqtor 将 Rx 带入了持久性耐久性和分布式执行的世界。

在撰写本文时,还没有现成的免费可托管的 Reaqtor 版本,因此从 Reaqtor 库构建真实应用需要相当多的工作。但是我已经在雇主那里基于此构建了几个应用程序,所以我可以自信地说,这绝对是可行的。

摘要

正如你现在所见,Reactive Extensions for .NET 提供了大量功能。Rx 的基本概念是一种对项序列的良好抽象,其中源决定何时提供每个项,以及一个相关的表示订阅者的抽象。通过将这两个概念表示为对象,事件源和订阅者都成为一流实体,这意味着你可以将它们作为参数传递,存储在字段中,并且通常可以像处理其他任何数据类型一样处理它们。虽然你也可以使用委托完成所有这些操作,但.NET 事件不是一流的。此外,Rx 提供了一种明确定义的机制来通知订阅者错误,而委托和事件都不擅长处理这一点。除了定义事件源的一流表示外,Rx 还定义了一个全面的 LINQ 实现,这就是为什么有时将 Rx 描述为 LINQ 到事件的原因。事实上,它远远超出了标准 LINQ 运算符的范围,添加了许多运算符,这些运算符利用和帮助管理事件驱动系统所处的实时和潜在时间敏感的世界。Rx 还提供各种服务,用于在其基本抽象和其他世界(包括标准的.NET 事件、IEnumerable<T> 和各种异步模型)之间进行桥接。

¹ 你可以下载完整的 WPF 示例,作为本书示例的一部分。

² 在推送式的世界中,缺少了OrderByThenBy操作符,因为在看到所有输入项之前,它们不能生成任何项。

³ 像一些开发者。

⁴ 这些重载分布在多个类中,因为一些扩展方法是特定于技术的。例如,WPF 获取与其Dispatcher类直接配合而不是ISchedulerObserveOn重载。

⁵ 实际上,Publish在当前版本的 Rx 中内部使用Subject<T>

第十二章:程序集

到目前为止,在本书中,我使用术语组件来描述库或可执行文件。现在是时候更仔细地看看这究竟意味着什么了。在.NET 中,软件组件的部署单位称为程序集,通常是一个*.dll.exe*文件。由于每个类型不仅由其名称和命名空间标识,还由其包含的程序集标识,程序集对类型系统是一个重要的方面。由于internal可访问性限定符在程序集级别工作,程序集提供了一种比单个类型更大尺度的封装。

运行时提供了一个程序集加载器,它会自动查找并加载程序需要的程序集。为了确保加载器能够找到正确的组件,程序集有结构化的名称,包括版本信息,并且可以选择性地包含一个全局唯一的元素,以防止歧义。

Visual Studio 的“创建新项目”对话框中的大多数 C#项目类型以及通过dotnet new命令行可用的大多数项目模板都会生成一个单独的程序集作为它们的主要输出。当你构建一个项目时,通常也会将额外的文件放在输出文件夹中,例如任何你的代码依赖但未内置到.NET 运行时中的程序集的副本,以及应用程序需要的其他文件。(例如,网站项目通常需要生成 CSS 和脚本文件,除了服务器端代码。)但通常会有一个特定的程序集作为项目的构建目标,其中包含所有项目定义的类型以及这些类型包含的代码。

程序集的解剖学

程序集使用 Win32 可移植可执行文件(PE)格式,这与现代 Windows 版本中的可执行文件(EXE)和动态链接库(DLL)使用的格式相同。¹ 它的“可移植性”体现在同一基本文件格式在不同 CPU 架构上的使用。非.NET PE 文件通常是特定于架构的,但.NET 程序集通常不是。即使在 Linux 或 macOS 上运行.NET,它仍然使用这种基于 Windows 的格式——大多数.NET 程序集可以在所有支持的操作系统上运行,因此我们在所有地方都使用相同的文件格式。

C# 编译器生成的输出是一个扩展名为 .dll.exe 的程序集。理解 PE 文件格式的工具会将 .NET 程序集识别为有效但相对单调的 PE 文件。CLR 实质上将 PE 文件用作包含 .NET 特定数据格式的容器,所以对于经典的 Win32 工具来说,C# DLL 不会显示为导出任何 API。请记住,C# 编译为一种二进制中间语言(IL),这种语言不能直接执行。Windows 中用于加载和运行可执行文件或 DLL 的正常机制无法处理 IL,因为只有 CLR 能够运行它。类似地,.NET 定义了自己的格式来编码元数据,并不使用 PE 格式的原生能力来导出入口点或导入其他 DLL 的服务。

注意

.NET SDK 中的提前编译(AoT)工具可以在构建过程后期向你的程序集添加本机可执行代码,但是对于 Ready to Run 程序集(这些 AoT 工具的输出称为),即使是嵌入的本机代码也是在 CLR 控制下加载和执行的,并且只能由托管代码直接访问。

在大多数情况下,你不会创建扩展名为 .exe 的 .NET 程序集。即使是生成直接可运行输出的项目类型(例如控制台或 WPF 应用程序),它们的主要输出也是一个 .dll。它们也会生成一个可执行文件,但不是 .NET 程序集。它只是一个引导程序,启动运行时,然后加载和执行你的应用程序的主程序集。默认情况下,引导程序的类型取决于你构建的操作系统,例如在 Windows 上构建时,你将得到一个 Windows 的 .exe 引导程序,而在 Linux 上则会得到一个 ELF 格式的可执行文件。²(唯一的例外是当你针对 .NET Framework 时。由于它仅支持 Windows,因此不需要为不同操作系统生成不同的引导程序,因此这些项目将生成一个扩展名为 .exe 的 .NET 程序集,并包含引导程序。)

.NET 元数据

除了包含编译的 IL 外,程序集还包含 元数据,它提供了所定义的所有类型(无论是公共的还是私有的)的完整描述。CLR 需要完全了解代码使用的所有类型,以便能够理解 IL 并将其转换为可运行的代码——IL 的二进制格式经常引用包含程序集的元数据,并且没有元数据 IL 是无意义的。反射 API,即 第十三章 的主题,使元数据中的信息可用于你的代码。

资源

你可以在 DLL 中与代码和元数据一起嵌入二进制资源。例如,客户端应用程序可以这样做来处理位图。要嵌入文件,可以将其添加到项目中,在解决方案资源管理器中选择它,然后使用属性面板将其构建操作设置为嵌入的资源。这样会将整个文件的副本嵌入到组件中。在运行时提取资源时,可以使用 Assembly 类的 GetManifestResourceStream 方法,该方法是反射 API 的一部分,详见 第十三章。然而,在实践中,通常不会直接使用这个功能——大多数应用程序通过本地化机制使用嵌入资源,我将在本章后面描述。

因此,总结来说,一个程序集包含了描述其定义的所有类型的全面元数据集合;它保存了所有这些类型方法的 IL,并且可以选择性地嵌入任意数量的二进制流。这通常被打包成一个单独的 PE 文件。然而,这并不总是故事的全部。

多文件程序集

老式(但仍受支持的)仅限 Windows 的 .NET Framework 允许一个程序集跨多个文件。你可以将代码和元数据分割到多个 模块 中,并且还可以将逻辑上嵌入到程序集中的某些二进制流放置在单独的文件中。这个特性很少被使用,并且 .NET Core 及其后续版本(包括当前版本的 .NET)不支持它。然而,有必要了解它,因为一些后果仍然存在。特别是,反射 API 的设计的某些部分(见 第十三章)在不了解这个特性的情况下是没有意义的。

在多文件程序集中,总是有一个代表程序集的主文件。这将是一个 PE 文件,并包含元数据的一个特定元素,称为 程序集清单。这不应与大多数可执行文件包含的 Win32 风格清单混淆。程序集清单只是对程序集内容的描述,包括任何外部模块或其他外部文件的列表;在多模块程序集中,清单描述了哪些类型定义在哪些文件中。当编写直接使用程序集中类型的代码时,通常不需要关心它是否跨多个模块,因为运行时会检查清单并自动加载所需的模块。多模块通常只对使用反射检查组件结构的代码是一个问题。

其他 PE 特性

尽管 C# 不使用经典的 Win32 机制来表示代码或在 EXE 和 DLL 中导出 API,但程序集仍然可以使用 PE 格式的几个老式特性。

Win32 风格资源

.NET 定义了自己的二进制资源嵌入机制,并在此基础上构建了本地化 API,因此在大多数情况下,它不使用 PE 文件格式固有的嵌入资源支持。并没有什么阻止你将经典的 Win32 风格资源放入 .NET 组件中——C# 编译器提供了各种命令行开关来实现这一点。然而,从你的应用程序中运行时访问这些资源没有 .NET API,这就是为什么你通常会使用 .NET 自己的资源系统。但也有一些例外情况。

Windows 期望在可执行文件中找到某些资源。例如,它定义了一种将版本信息作为非托管资源嵌入的方法。C# 程序集通常会这样做,但你不需要显式定义版本资源。编译器可以为你生成一个,就像我在 “Version” 中展示的那样。这确保了如果最终用户在 Windows 文件资源管理器中查看你的程序集属性,他们将能够看到版本号。(按照惯例,.NET 程序集通常包含这种 Win32 风格的版本信息,无论它们是否仅针对 Windows 或可以在任何平台上运行。)

Windows .exe 文件通常包含两个额外的 Win32 资源。你可能希望为你的应用程序定义一个自定义图标,以控制它在任务栏或 Windows 文件资源管理器中的显示方式。这需要你以 Win32 的方式嵌入图标,因为文件资源管理器不知道如何提取 .NET 资源。你可以通过在你的 .csproj 文件中添加一个 <ApplicationIcon> 属性来实现这一点。如果你使用 Visual Studio,它提供了一种通过项目属性页来设置这一点的方式。另外,如果你正在编写经典的 Windows 桌面应用程序或控制台应用程序(无论是否使用 .NET 编写),它应该提供一个应用程序清单。没有这个清单,Windows 将假定你的应用程序是在 2006 年之前编写的³,并会修改或禁用某些向后兼容性功能。如果你正在编写一个桌面应用程序并且希望它通过某些 Microsoft 认证要求,那么这种清单也必须存在并作为 Win32 资源嵌入。默认情况下,.NET SDK 会添加一个具有默认设置的清单,但如果需要自定义(例如,因为你正在编写一个需要以提升权限运行的控制台应用程序),你可以在你的 .csproj 文件中指定一个具有 <ApplicationManifest> 属性的清单(或者再次通过 Visual Studio 的项目属性页)。

请记住,在.NET 和.NET Core 中,主要的程序集是一个*.dll*,即使是用于 Windows 桌面应用程序,当你目标是 Windows 时,构建过程也会生成一个单独的*.exe*文件,它启动.NET 运行时,然后加载该程序集。在 Windows 看来,这个引导程序就是你的应用程序,因此图标和清单资源将最终出现在这个引导程序集中。但如果你的目标是.NET Framework,就不会有单独的引导程序,因此这些资源最终会出现在主要的程序集中。

控制台与 GUI

Windows 对控制台应用程序和 Windows 应用程序进行了区分。准确地说,PE 格式要求*.exe文件指定一个子系统*,在 Windows NT 早期的日子里,这使得可以使用多个操作系统子系统,例如早期版本包括 POSIX 子系统。因此,如今的 PE 文件只针对三个子系统之一,其中之一用于内核模式设备驱动程序。今天使用的两个用户模式选项在 Windows 图形用户界面(GUI)和 Windows 控制台应用程序之间进行选择。主要区别在于当运行后者时 Windows 将显示控制台窗口(或者如果从命令提示符运行它,则只使用现有的控制台窗口),但 Windows GUI 应用程序不会获得控制台窗口。

你可以通过在项目文件中设置<OutputType>属性为ExeWinExe来在这些子系统之间进行选择,或者在 Visual Studio 中你可以在项目属性的“输出类型”下拉列表中进行选择。(输出类型默认为Library,或者在 Visual Studio 的 UI 中为“类库”。这会构建一个 DLL,但由于子系统是在进程启动时确定的,因此 DLL 是否目标是 Windows 控制台或 Windows GUI 子系统是没有区别的。Library设置总是针对前者。)如果你的目标是.NET Framework,这个子系统设置将应用于作为你的应用程序主要程序集构建的*.exe文件,而对于较新版本的.NET,则会应用于引导程序.exe*。(碰巧的是,它也会应用于引导程序加载的主程序集*.dll*,但这不会产生影响,因为子系统是根据启动进程的*.exe*确定的。)

类型标识

作为一名 C#开发者,你通常会首先接触到的是程序集(assemblies)构成类型标识的一部分这一事实。当你编写一个类时,它将成为一个程序集的一部分。当你使用来自运行时库或其他库的类型时,你的项目需要引用包含该类型的程序集才能使用它。

当使用系统类型时,这并不总是显而易见的。构建系统会自动添加对各种运行时库程序集的引用,因此大多数情况下,您在使用运行时库类型之前不需要添加引用,并且由于通常不会在源代码中明确引用类型的程序集,因此很难立即意识到程序集是确定类型所需的必要部分。但尽管在代码中并没有明确指定,程序集必须作为类型身份的一部分,因为没有任何东西会阻止您或其他任何人定义与现有类型同名的新类型。例如,您可以在项目中定义一个名为System.String的类。这是一个不好的主意,编译器会警告您这样会引入歧义,但不会阻止您这样做。尽管您的类将具有与内置字符串类型完全相同的完全限定名称,编译器和运行时仍然可以区分这些类型。

每当您使用一个类型时,无论是通过显式名称(例如在变量或参数声明中)还是通过表达式隐式使用,C#编译器都确切地知道您所引用的类型,这意味着它知道哪个程序集定义了该类型。因此,它能够区分.NET 内置的System.String和您自己组件中不明智定义的System.String。C#的作用域规则意味着对System.String的显式引用将标识出您在自己项目中定义的类型,因为局部类型有效地隐藏了外部程序集中同名的类型。如果使用string关键字,那总是指向内置类型。当您使用字符串字面量或调用返回字符串的 API 时,也将使用内置类型。示例 12-1 展示了这一点——它定义了自己的System.String,然后使用了一个显示传递给它的参数的静态类型和程序集名称的通用方法。(这使用了反射 API,详见第十三章。)

示例 12-1. 一个字符串是什么类型?
using System;

// Never do this!
namespace System
{
    public class String
    {
    }
}

class Program
{
    static void Main(string[] args)
    {
        System.String? s = null;
        ShowStaticTypeNameAndAssembly(s);
        string? s2 = null;
        ShowStaticTypeNameAndAssembly(s2);
        ShowStaticTypeNameAndAssembly("String literal");
        ShowStaticTypeNameAndAssembly(Environment.OSVersion.VersionString);
    }

    static void ShowStaticTypeNameAndAssembly<T>(T item)
    {
        Type t = typeof(T);
        Console.WriteLine(
            $"Type: {t.FullName}. Assembly {t.Assembly.FullName}.");
    }
}

在本示例中的Main方法尝试了我刚刚描述的每种处理字符串的方法,并写出了以下内容:

Type: System.String. Assembly TypeIdentity, Version=1.0.0.0, Culture=neutral,
 PublicKeyToken=null.
Type: System.String. Assembly System.Private.CoreLib, Version=6.0.0.0,
 Culture=neutral, PublicKeyToken=7cec85d7bea7798e.
Type: System.String. Assembly System.Private.CoreLib, Version=6.0.0.0,
 Culture=neutral, PublicKeyToken=7cec85d7bea7798e.
Type: System.String. Assembly System.Private.CoreLib, Version=6.0.0.0,
 Culture=neutral, PublicKeyToken=7cec85d7bea7798e.

明确使用 System.String 最终导致了我的类型,并且其余部分都使用了系统定义的字符串类型。这表明 C# 编译器能够处理具有相同名称的多个类型。这也显示了 IL 能够进行区分。IL 的二进制格式确保对类型的每个引用都标识了包含程序集。但仅仅因为你可以创建和使用多个同名类型,并不意味着你应该这样做。因为在 C# 中通常不会显式命名包含程序集,所以通过定义自己的 System.String 类来引入无意义的冲突是一个特别糟糕的主意。(恰好,在必要时你可以解决这种冲突——详见边栏 “外部别名” ——但最好避免这样做。)

顺便说一句,如果你在 .NET Framework 上运行 示例 12-1,你会看到 mscorlib 而不是 System.Private.CoreLib。.NET Core 改变了许多运行库类型所在的程序集。你可能会想知道这如何与 .NET Standard 兼容,它使你能够编写一个单独的 DLL,在 .NET Framework、.NET Core 和 .NET 上都可以运行。一个 .NET Standard 组件如何能正确识别在不同目标上位于不同程序集中的类型呢?答案是 .NET 具有一种类型转发特性,其中对一个程序集中类型的引用可以在运行时重定向到其他程序集中。(类型转发器只是描述真实类型定义所在位置的一个程序集级别属性。属性是 第十四章 的主题。).NET Standard 组件既不引用 mscorlib 也不引用 System.Private.CoreLib ——它们的构建就好像运行库类型定义在一个名为 netstandard 的程序集中一样。每个 .NET 运行时都提供了一个 netstandard 实现,在运行时将转发到相应的类型。事实上,即使是直接为 .NET Core 或 .NET 构建的代码,最终也会使用类型转发。如果你检查编译输出,你会发现它期望大多数运行库类型在名为 System.Runtime 的程序集中定义,并且仅通过类型转发才能使用 System.Private.CoreLib 中的类型。

如果在同一名称下有多个类型是个坏主意,那为什么 .NET 一开始就允许这种可能性呢?事实上,支持名称冲突并不是目标;这只是 .NET 将程序集作为类型的一部分的副作用。程序集需要成为类型定义的一部分,这样 CLR 在你首次使用该类型的功能时就能知道要加载哪个程序集。

加载程序集

当我说构建系统自动向目标框架添加所有可用的运行库组件的引用时,你可能会感到惊讶。也许你会想知道如何在效率名义上删除其中一些。就运行时开销而言,你无需担心。C# 编译器会有效地忽略你的项目从未使用过的内置程序集的任何引用,因此不会加载你不需要的 DLL 的风险。然而,删除对未构建为 .NET 的未使用组件的引用是值得的——这样在部署应用程序时可以避免复制不需要的 DLL,毕竟不必使部署变得比必要时更大。但对于已作为 .NET 的一部分安装的未使用 DLL 的引用则毫无成本。

即使在编译时 C# 没有剥离未使用的引用,也不会存在加载未使用的 DLL 的风险。CLR 不会在应用程序首次需要它们之前加载程序集。大多数应用程序在每次执行时并不会涵盖所有可能的代码路径,因此你的应用程序中有相当大部分的代码可能并不会运行。你的程序甚至可能在完成工作时留下整个未使用的类——也许这些类只在出现异常错误条件时才会参与。如果你只在这类方法内部使用某个程序集,那么这个程序集就不会被加载。

CLR 对于决定“使用”特定程序集有一些自主权。如果一个方法包含任何引用特定类型的代码(例如声明该类型的变量或包含隐式使用该类型的表达式),那么当该方法首次运行时 CLR 可能会认为该类型已被使用,即使你并未真正使用它。参见 示例 12-2。

示例 12-2. 类型加载和条件执行
static IComparer<string> GetComparer(bool useStandardOrdering)
{
    if (useStandardOrdering)
    {
        return StringComparer.CurrentCulture;
    }
    else
    {
        return new MyCustomComparer();
    }
}

根据其参数不同,此函数会返回运行库提供的StringComparer对象,或构造一个类型为MyCustom​Com⁠parer的新对象。StringComparer类型在与核心类型(如intstring)相同的程序集中定义,因此在我们的程序启动时已加载。但假设另一类型MyCustomComparer定义在与我的应用程序分离的名为ComparerLib的程序集中。显然,如果以false作为参数调用此GetComparer方法,CLR 将需要在未加载时加载ComparerLib。但更令人惊讶的是,即使参数为true,CLR 也可能在首次调用此方法时加载ComparerLib。为了能够 JIT 编译此GetComparer方法,CLR 需要访问MyCustomComparer类型定义,因为它需要检查该类型确实有一个无参数构造函数。(显然,如果如此,Example 12-2 将无法编译,但可能的情况是该代码针对的是与运行时不同版本的ComparerLib。)JIT 编译器的操作是实现细节,因此并未完全记录,且可能因版本而异,但似乎是逐个方法操作。因此,仅调用此方法很可能足以触发ComparerLib程序集的加载。

这引出了.NET 如何找到程序集的问题。如果程序集可以作为运行方法的结果隐式加载,我们未必有机会告诉运行时在哪里找到它们。因此,.NET 有一种机制来处理这个问题。

程序集解析

当运行时需要加载一个程序集时,它会经历一个称为程序集解析的过程。在某些情况下,你会告诉.NET 加载特定的程序集(例如,当你首次运行一个应用程序时),但大部分是隐式加载的。具体的机制取决于几个因素:你是否针对.NET/.NET Core 或旧版.NET Framework,以及如果是前者,你的应用程序是否是自包含的。

.NET(及其前身 .NET Core)支持应用程序的两种部署选项:自包含依赖于框架。当你发布一个自包含的应用程序时,它会包括运行时和运行时库的完整副本。示例 12-3 显示了以这种方式构建应用程序的命令行——如果你从包含 .csproj 文件的文件夹运行此命令,它将编译项目,然后生成一个 publish 文件夹,其中包含编译代码和适当版本 .NET 的完整副本。(版本将取决于项目配置的目标框架。通常,项目文件会指定主要和次要版本,例如 net6.0,然后 SDK 将复制安装在您的计算机上的最新修补版本。可用版本将由您安装的 .NET SDK 版本决定。)-r 开关指定要构建的平台和处理器架构。Linux 的 CLR 与 Windows 的不同,而 macOS 的则又不同。此外,对于每个支持的操作系统,都有适用于多个 CPU 架构的 .NET 运行时可用。所有三个操作系统都支持 64 位 Intel 和 64 位 ARM。Windows 和 Linux 还额外获取面向 32 位 Intel 架构 CPU 和 32 位 ARM CPU 的 .NET 运行时。包含本地可执行二进制代码的 .NET 运行时部分在每种情况下都不同,因此当您要求自包含部署时,构建系统需要知道要复制哪一个。-r 开关使用称为 Runtime Identifier(RID)的东西来指定这一点。示例 12-3 选择了适用于运行 Windows 的 64 位 Intel 架构 CPU 的运行时。(RID 可能更详细,以指示您的应用程序具有最低版本要求。例如,第一部分可以是 win10 而不仅仅是 win;对于 macOS,我们可以使用 osx-x64,但我们可以更具体,例如 osx.10.15-x64。)

示例 12-3. 发布自包含应用程序
dotnet publish -c Release -r win-x64 --self-contained true

当你以这种方式构建时,程序集解析变得非常简单,因为所有内容——你的应用程序自身的程序集、你依赖的任何外部库、所有内置于 .NET 中的系统程序集以及 CLR 本身——都会结束在一个文件夹中。(在撰写本文时,对于 .NET 6.0 的目标架构的一个简单的“Hello, World!”控制台应用程序,总计约为 68 MB 左右。)

自包含部署有两个主要优点。首先,在目标机器上不需要安装 .NET——应用程序可以直接运行,因为它包含自己的 .NET 副本。其次,您知道确切运行的 .NET 版本和所有 DLL 的版本。微软非常努力确保向后兼容性与新版本,但有时可能会发生破坏性更改,如果在更新 .NET 后发现应用程序停止工作,自包含部署可能是一个出路。通过自包含部署,除非应用程序指示 CLR 在其他地方查找,否则一切都将从应用程序文件夹加载,包括所有内置到 .NET 的程序集。

但是,如果您不想将整个 .NET 复制到生成的输出中呢?应用程序的默认构建行为是创建一个依赖框架的可执行文件。(有一个称为依赖框架部署的变体,几乎相同,只是省略了启动器可执行文件。要运行依赖框架部署,您需要使用dotnet命令行工具启动运行时,然后运行您的应用程序。这样做的好处是完全与平台无关;依赖框架可执行部署中的启动器始终特定于操作系统。但这样做不太方便——您不能在没有dotnet工具的情况下运行生成的输出。)在这种情况下,您的代码依赖于机器上已安装合适版本的 .NET。生成的输出将包含您自己的应用程序程序集,并可能包含您的应用程序依赖的程序集,但不会包含任何内置到 .NET 的库。

依赖框架应用程序必然使用比自包含应用程序更复杂的解析机制。当这样的应用程序启动时,它首先确定要运行的 .NET 版本。这不一定是您的应用程序构建的版本,并且有各种选项可以配置确切选择的版本。默认情况下,如果可用相同的*Major*.*Minor*版本,将使用该版本。例如,如果为 .NET Core 5.0 构建的依赖框架应用程序在安装了 .NET Core 版本为 3.1.205.0.116.0.0 的机器上运行,则会运行在 5.0.11 上。在找不到这样的匹配的情况下,但是有一个主要版本号匹配的情况下,通常会向前滚动到那个版本;例如,如果应用程序目标为 3.0,而机器上只有 3.1.20,则会在 3.1.20 上运行。也可以通过配置显式请求运行高于应用程序构建的主要版本号的更高版本号(例如,为 3.1 构建但在 6.0 上运行),但是只有通过配置显式请求才能做到这一点。

所选的运行时版本不仅选择 CLR,还包括构成内置于.NET 中的运行时库部分的程序集。你通常可以在 Windows 的C:\Program Files\dotnet\shared\Micro⁠soft.NET​Core.App*文件夹中找到所有已安装的运行时版本,在 macOS 上为/usr/local/share/dotnet/shared/Microsoft​.NET⁠Core.App*,在 Linux 上为*/usr/share/dotnet/shared/Microsoft.NETCore.App*,其子文件夹基于版本命名,比如6.0.0。(你不应依赖这些路径——文件在未来版本的.NET 中可能会移动。)程序集解析过程将查找这个特定版本的文件夹,这就是框架相关应用程序如何使用内置的.NET 程序集。

如果你在这些文件夹里查找,你可能会注意到shared下面的其他文件夹,比如Microsoft.AspNetCore.App。事实证明,这种机制不仅适用于内置于.NET 中的运行时库——也可以安装整个框架的程序集。.NET 应用程序声明它们使用特定的应用程序框架。(构建工具会自动在构建输出中生成一个名为YourApp.runtimeconfig.json的文件,声明你正在使用的框架。控制台应用程序指定Microsoft.NETCore.App,而 Web 应用程序则指定Microsoft.AspNetCore.App。)这使得针对特定 Microsoft 框架的应用程序不必包含所有框架 DLL 的完整副本,即使该框架并非.NET 本身的一部分。

如果你安装了纯粹的.NET 运行时,你将只会得到Microsoft.NETCore.App,而没有任何应用程序框架。因此,如果以默认方式构建的应用程序目标框架是如 ASP.NET Core 或 WPF 等,它们将无法运行,因为这假定这些框架将预安装在目标机器上,而程序集解析过程将无法找到特定框架组件。.NET SDK 会安装这些额外的框架组件,因此在开发机器上你不会遇到这个问题,但在部署时可能会遇到。你可以告诉构建工具包含框架的组件,但这通常是不必要的。如果在公共云服务(如 Azure)上运行你的应用程序,这些服务通常会预安装相关的框架组件,因此实际上你通常只会在自己配置服务器或部署桌面应用程序时遇到这种情况。对于这些情况,微软提供了包含 Web 或桌面框架组件的.NET 运行时安装程序。

dotnet 安装文件夹中的shared 文件夹不是你应该自行修改的目录。它仅用于微软自己的框架。但是,如果你愿意,可以安装额外的系统范围组件,因为.NET 还支持称为运行时包存储的东西。这是一个额外的目录,结构与刚才描述的shared 文件夹类似。你可以使用 dotnet store 命令构建一个合适的目录布局,如果设置了 DOTNET_SHARED_STORE 环境变量,CLR 将在程序集解析时查找其中的内容。这使你能够像使用微软的框架一样进行操作:你可以构建依赖于一组组件的应用程序,而无需将它们包含在构建输出中,只要你已经安排在目标机器上预安装这些组件。

除了在这两个位置查找常见的框架之外,CLR 在程序自己的目录中进行程序集解析时,也会进行查找,就像对于自包含应用程序一样。此外,CLR 还有一些机制可以进行更新的启用。例如,在 Windows 上,微软可以通过 Windows 更新向 .NET 组件推送关键更新。

但总体来说,面向框架依赖型应用程序的程序集解析的基本过程是,隐式程序集加载会从你的应用程序目录或安装在机器上的共享组件集合中进行。这对于运行在较旧的 .NET Framework 上的应用程序也是适用的,尽管机制略有不同。它有一种称为全局程序集缓存(GAC)的东西,它有效地结合了.NET 中两个共享存储提供的功能。这种方式不太灵活,因为存储位置是固定的;.NET 使用环境变量开放了为不同应用程序提供不同共享存储的可能性。

显式加载

尽管 CLR 可以自动加载程序集,你也可以显式加载它们。例如,如果你正在创建一个支持插件的应用程序,在开发过程中你可能不知道在运行时会加载哪些组件。插件系统的整个重点在于它是可扩展的,因此你可能希望加载特定文件夹中的所有 DLL 文件。(你需要使用反射来发现和利用这些 DLL 中的类型,正如第十三章所描述的。)

警告

在某些场景下,动态加载受到限制。例如,使用 UWP 构建的应用程序并从微软商店安装的应用程序只能运行作为应用程序一部分提供的组件。这是因为微软对这些商店应用程序运行各种测试,旨在避免安全和稳定性问题,为此他们需要访问你应用程序的所有代码。下载和运行外部代码的能力将会打破这些检查。

如果您知道程序集的完整路径,加载它非常简单:您可以调用Assembly类的静态LoadFrom方法,并传递文件的路径。路径可以是相对于当前目录的,也可以是绝对的。这个静态方法返回Assembly类的一个实例,它是反射 API 的一部分。它提供了发现和使用程序集定义的类型的方式。

偶尔,您可能希望显式加载一个组件(例如,通过反射使用它),而不想指定路径。例如,您可能希望从运行时库中加载特定的程序集。永远不要硬编码系统组件的位置 —— 它们往往会从一个 .NET 版本移动到下一个版本。如果您的项目引用了相关程序集并且知道它定义的类型的名称,您可以写typeof(TheType).Assembly。但如果这不是一个选择,您应该使用Assembly.Load方法,并传递程序集的名称。

Assembly.Load使用的是与隐式触发加载完全相同的机制。因此,您可以引用安装在应用程序旁边的组件或系统组件。无论哪种情况,您都应该指定一个完整的名称,其中必须包含名称和版本信息,例如,ComparerLib, Version=1.0.0.0, Cul⁠ture=neutral, PublicKeyToken=null

.NET Framework 版本的 CLR 记得使用LoadFrom加载了哪些程序集。如果以这种方式加载的程序集触发了隐式加载其他程序集,CLR 将搜索从该程序集加载的位置。这意味着,如果您的应用程序将插件放在一个 CLR 通常不会查找的单独文件夹中,那么这些插件可以在同一插件文件夹中安装它们依赖的其他组件。CLR 将能够在没有进一步调用LoadFrom的情况下找到它们,尽管它通常不会在那个文件夹中查找触发的加载。然而,.NET 和 .NET Core 不支持这种行为。它们提供了支持插件场景的不同机制。

使用 AssemblyLoadContext 进行隔离和插件化

.NET Core 引入了一种称为AssemblyLoadContext的类型。它允许在单个应用程序内部的程序集组之间实现一定程度的隔离。⁴ 这解决了在支持插件模型的应用程序中可能出现的问题。

如果一个插件依赖于主机应用程序也使用的某些组件,但每个组件想要不同的版本,如果使用上一节描述的简单机制可能会导致问题。通常,.NET 运行时会统一这些引用,只加载单个版本。在这种情况下,如果共享组件中的类型是插件接口的一部分,这正是你需要的:如果一个应用程序要求插件实现某些依赖于Newtonsoft.Json库的接口类型,重要的是应用程序和插件都同意正在使用的库的版本。

但是,统一可能会对用作实现细节而不是应用程序与其插件之间 API 的组件造成问题。如果主机应用程序在内部使用Microsoft.Extensions.Logging的 v3.1 版本,而插件使用相同组件的 v6.0 版本,没有必要在运行时将其统一为单个版本选择——如果应用程序和插件各自使用它们需要的版本,这不会有任何问题。统一可能会引起问题:强制插件使用 v3.1 将导致运行时异常,如果它尝试使用只存在于 v6.0 中的功能。强制应用程序使用 v6.0 也可能会导致问题,因为主要版本号更改通常意味着引入了破坏性更改。

为了避免这类问题,你可以引入自定义的程序集加载上下文。你可以编写一个从AssemblyLoadContext派生的类,每个实例化的上下文都由.NET 运行时创建,支持加载与应用程序可能已加载的不同版本的程序集。通过重载Load方法,你可以定义所需的确切策略,就像示例 12-4 中展示的那样。

示例 12-4. 一个用于插件的自定义AssemblyLoadContext
using System.Reflection;
using System.Runtime.Loader;

namespace HostApp;

public class PlugInLoadContext : AssemblyLoadContext
{
    private readonly AssemblyDependencyResolver _resolver;
    private readonly ICollection<string> _plugInApiAssemblyNames;

    public PlugInLoadContext(
        string pluginPath,
        ICollection<string> plugInApiAssemblies)
    {
        _resolver = new AssemblyDependencyResolver(pluginPath);
        _plugInApiAssemblyNames = plugInApiAssemblies;
    }

    protected override Assembly Load(AssemblyName assemblyName)
    {
        if (!_plugInApiAssemblyNames.Contains(assemblyName.Name!))
        {
            string? assemblyPath = _resolver.ResolveAssemblyToPath(assemblyName);
            if (assemblyPath != null)
            {
                return LoadFromAssemblyPath(assemblyPath);
            }
        }

        return AssemblyLoadContext.Default.LoadFromAssemblyName(
            assemblyName);
    }
}

这段代码需要插件 DLL 的位置,以及任何特殊程序集的名称列表,插件需要与主机应用程序使用相同版本的程序集(例如定义插件接口中使用的类型的接口)。您不需要包含作为.NET 本身一部分的程序集——即使您使用自定义加载上下文,这些程序集也始终是统一的。运行时将在每次在此上下文中加载程序集时调用此类的Load方法。此代码检查正在加载的程序集是否是那些必须对插件和主机应用程序通用的特殊程序集之一。如果不是,则在插件文件夹中查找插件是否提供了该程序集的自己版本。在不使用插件文件夹中的程序集的情况下(要么因为插件没有提供特定的程序集,要么因为它是特殊的程序集之一),此上下文将延迟至AssemblyLoadContext.Default,这意味着应用程序主机和插件在这些情况下使用相同的程序集。示例 12-5 展示了此用法。

示例 12-5. 使用插件加载上下文
Assembly[] plugInApiAssemblies =
{
    typeof(IPlugIn).Assembly,
    typeof(JsonReader).Assembly
};
var plugInAssemblyNames = new HashSet<string>(
    plugInApiAssemblies.Select(a => a.GetName().Name!));

var ctx = new PlugInLoadContext(plugInDllPath, plugInAssemblyNames);
Assembly plugInAssembly = ctx.LoadFromAssemblyPath(plugInDllPath);

此处构建了插件和应用程序必须共享的程序集列表,并将它们的名称与插件 DLL 的路径一起传递到插件上下文中。任何插件依赖的 DLL 并且被复制到与插件相同的文件夹中的 DLL 将被加载,除非它们在该列表中,在这种情况下,插件将使用与主机应用程序本身相同的程序集。

程序集名称

程序集名称是结构化的。它们总是包含一个简单名称,这是您通常用来引用 DLL 的名称,例如MyLibrarySystem.Runtime。这通常与文件名相同,但没有扩展名。从技术上讲,这并不是必须的,⁵,但是程序集解析机制假定它是。程序集名称始终包括版本号。还有一些可选组件,包括公钥标记,一个十六进制数字字符串,使得可以为程序集指定唯一名称。

强名称

如果程序集的名称包含公钥标记,则称为强名称。Microsoft 建议任何针对.NET Framework 并发布供共享使用的.NET 组件(例如通过 NuGet 提供)应具有强名称。但是,如果您正在编写仅在.NET Core 或.NET 上运行的新组件,则强命名没有任何好处,因为这些较新的运行时基本上忽略公钥标记。

由于强命名的目的是使名称唯一,您可能会想知道为什么程序集不简单地使用全局唯一标识符(GUID)。答案是,从历史上看,强名称还承担了另一个任务:它们旨在提供一定程度的保证,即程序集未被篡改。早期的.NET 版本在运行时检查强命名程序集是否被篡改,但由于这些检查带来了相当大的运行时开销,通常几乎没有好处,因此这些检查已被移除。微软的文档现在明确建议不将强名称视为安全功能。但是,为了理解和使用强名称,您需要了解它们最初的工作方式。

正如术语所示,程序集名称的公钥标记与密码学有关。它是公钥的 64 位哈希的十六进制表示。要求强名称的程序集包含生成哈希的完整公钥的副本。程序集文件格式还提供了用相应私钥生成的数字签名空间。

强名称的唯一性依赖于密钥生成系统使用密码学安全的随机数生成器,以及两个人生成具有相同公钥标记的两个密钥对的机会非常小。程序集未被篡改的保证来自于强命名程序集必须被签名,只有拥有私钥的人才能生成有效的签名。任何在签名后修改程序集的尝试都将使签名无效。

注意

强名称相关联的签名与 Windows 中较早的代码签名机制 Authenticode 独立。它们具有不同的目的。Authenticode 提供可追溯性,因为公钥包含在证书中,告诉您代码的来源。而强名称的公钥标记仅仅是一个数字,所以除非您碰巧知道谁拥有该标记,否则它不会告诉您任何信息。Authenticode 允许您询问:“这个组件来自哪里?”而公钥标记允许您说:“这就是我想要的组件。”一个单独的.NET 组件通常同时使用这两种机制。

如果一个程序集的私钥变为公共知识,任何人都可以生成具有相应密钥标记的看似有效的程序集。一些开源项目故意发布两个密钥,以便任何人都可以从源代码构建组件。这完全放弃了密钥标记可能提供的任何安全性,但这没关系,因为微软现在建议我们不把强名称视为安全功能。发布你的强命名私钥的做法认识到,即使没有真实性的保证,拥有一个唯一的名称也是有用的。.NET Core(因此.NET)更进一步,使组件可以拥有强名称而无需使用私钥。为了与微软采用开源开发的做法一致,这意味着你现在可以构建和使用具有相同强名称的自己版本的微软编写的组件,尽管微软并未发布其私钥。请参阅下一个侧边栏,"强名称密钥和公共签名",了解如何处理密钥的信息。

微软在运行时库中的大多数程序集上使用相同的标记。(微软的许多组织生产.NET 组件,因此该标记仅用于.NET 的组件,而不适用于整个微软。)这里是mscorlib的完整名称,这是一个系统程序集,提供了诸如System.String等各种核心类型的定义:

mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089

顺便说一下,在撰写本文时,这是即使在最新的.NET 版本中也是正确的名称。Version4.0.0.0,即使.NET Framework 现在是 v4.8,.NET 是 6.0 版。(在.NET 和.NET Core 中,mscorlib只包含类型转发器,因为相关类型大多数已移至System.Private.CoreLib。虽然这些类型的真正家园现在是版本6.0.0.0,但mscorlib的版本号仍然相同。)程序集版本号具有技术上的重要意义,因此微软并不总是随着市场版本号更新库组件名称中的版本号 —— 这些版本号甚至在主要版本号上也不一定匹配。例如,.NET 3.5 版的mscorlib版本号为2.0.0.0

尽管公共密钥令牌是程序集名称的可选部分,版本是强制的。

版本

所有的程序集名称都包含一个四部分的版本号。当一个程序集名称被表示为一个字符串(例如,当你将其作为参数传递给Assembly.Load时),版本号由四个用点分隔的十进制整数组成(例如,4.0.0.0)。IL 用于程序集名称和引用的二进制格式限制了这些数字的范围 —— 每个部分必须适合于一个 16 位无符号整数(即ushort),而版本部分中允许的最大值实际上比适合的最大值小 1,因此最高合法版本号是65534.65534.65534.65534

每个版本号的四个部分都有名称。从左到右,它们是主版本次版本构建修订。然而,这些名称没有特定的意义。一些开发人员使用某些约定,但没有任何检查或强制执行。一个常见的约定是,公共 API 的任何更改都需要更改主版本或次版本号,而可能会破坏现有代码的更改应该涉及主版本号的更改。(市场营销是进行主版本更改的另一个流行原因。)如果更新不打算对行为进行任何可见更改(除非是修复 bug),则更改构建号就足够了。修订号可以用来区分你认为是针对同一源构建但不是同时构建的两个组件。另外,有些人将版本号与源代码控制中的分支联系起来,因此仅更改修订号可能表示对长期停止获得主要更新的版本应用的修补程序。然而,你可以自由地制定自己的含义。就 CLR 而言,你真正能做的有趣事情只有一个,那就是将其与其他版本号进行比较——它们要么匹配,要么其中一个比另一个更高。

注意

NuGet 包也有版本号,并且这些版本号不需要以任何方式与程序集版本连接。许多包作者按照约定使它们相似,但这并非普遍规则。NuGet 确实 将包版本号的组件视为具有特定意义:它采用了广泛使用的语义化版本规则。这种规则使用由三个部分组成的版本号,分别称为主版本、次版本和修订版本。

运行时库程序集名称中的版本号忽略了我刚才描述的所有约定。在四个主要更新中,大多数组件的版本号都是相同的(2.0.0.0)。随着 .NET 4.0 的推出,所有东西都变成了4.0.0.0,这在撰写本文时仍然是 .NET Framework(4.8 版本)的最新版本。.NET Core 3.1 也使用 4 作为其大多数运行时库组件的主版本。在 .NET 6.0 中,许多这些组件现在的主版本号都是 6,但正如你在它的 mscorlib 副本中看到的那样,并非普遍适用。

通常,你通过在*.csproj*文件的<PropertyGroup>中添加一个<Version>元素来指定版本号。(Visual Studio 也为此提供了一个 UI:如果你打开项目的属性页面,其“Package”部分允许你配置各种与命名相关的设置。“Package version”字段设置版本号。)构建系统以两种方式使用这个版本号:它在程序集上设置版本号,但如果你为项目生成 NuGet 包,则默认情况下也会将此相同的版本号用于包,并且由于 NuGet 版本号有三部分,你通常只在这里指定三个数字,程序集版本的第四部分将默认为零。(如果你真的想指定所有四位数,请参阅如何分别设置程序集和 NuGet 版本的文档。)

通过程序集级别的属性,构建系统告诉编译器在程序集名称中使用哪个版本号。我将在第十四章更详细地描述属性,但这个属性相当直接。如果你想找到它,构建系统通常会在项目的obj文件夹的子文件夹中生成一个名为ProjectName.AssemblyInfo.cs的文件。其中包含各种描述程序集详细信息的属性,包括一个AssemblyVersion属性,例如在示例 12-6 中所示。

示例 12-6. 指定程序集的版本
[assembly: System.Reflection.AssemblyVersion("1.0.0.0")]

C#编译器对此属性提供特殊处理——它不像大多数属性那样盲目应用它。它解析版本号并将其嵌入到.NET 元数据格式所需的方式中。它还检查字符串是否符合预期格式,并检查数字是否在允许的范围内。

顺便说一下,组成程序集名称一部分的版本与使用标准的 Win32 机制嵌入版本存储的版本是不同的。大多数 .NET 文件包含这两种版本。默认情况下,构建系统将使用 <Version> 设置两者,但文件版本更频繁地更改是很常见的。这在 .NET Framework 中特别重要,因为一次只能安装一个主要版本的 .NET Framework —— 如果一台计算机安装了 .NET Framework 4.7.2,你安装了 .NET Framework 4.8,那么将替换版本 4.7.2。(.NET 和 .NET Core 不会这样做 —— 你可以在单台计算机上并排安装任意数量的版本。)这种原地更新与微软倾向于在各版本中保持程序集版本相同结合在一起,可能会使确定安装了哪个版本变得困难,此时文件版本变得重要。在安装了 .NET Framework 4.0 sp1 的计算机上,其 mscorlib.dll 的 Win32 版本号是 4.0.30319.239,但如果安装了 .NET 4.8,这将变为 4.8.4420.0,但程序集版本仍保持为 4.0.0.0。(随着发布服务包和其他更新,最后一部分将不断上升。)

默认情况下,构建系统将同时使用 <Version> 设置程序集和 Windows 文件版本,但如果你想分别设置文件版本,可以在项目文件中添加 <FileVersion>。(Visual Studio 的项目属性包部分也允许你设置这个。)在内部,这与另一个属性一起工作,编译器会特别处理,AssemblyFileVersion。它会导致编译器在文件中嵌入一个 Win32 版本资源,因此这是用户在 Windows Explorer 中右键单击程序集时看到的版本号。

这个文件版本通常是放置标识构建来源的版本号的更合适的地方,而不是放置在程序集名称中的版本号。后者实际上是支持的 API 版本的声明,任何设计为完全向后兼容的更新可能应该保持不变,只改变文件版本。

版本号和程序集加载

由于版本号是程序集名称(因此是其标识)的一部分,它们也最终是类型标识的一部分。mscorlibSystem.String 在版本 2.0.0.0 的不是与版本 4.0.0.0 的相同类型。

处理程序集版本号在.NET Core 中有所改变。在.NET Framework 中,当你通过名称加载一个强命名程序集(无论是隐式地使用它定义的类型还是显式地使用Assembly.Load),CLR 要求版本号必须完全匹配。⁶ .NET Core 对此进行了放宽,因此如果磁盘上的版本号等于或高于请求的版本号,它将使用它。这一变化背后有两个因素。首先,.NET 开发生态系统已经开始依赖 NuGet(在.NET 存在的第一个十年中甚至不存在),这意味着依赖于大量外部组件变得越来越普遍。其次,变化的速度增加了——在早期,我们通常需要等待多年才能看到.NET 组件的新版本发布。(安全补丁和其他错误修复可能会更频繁地出现,但新功能往往会缓慢地以大块的形式作为整个运行时、框架和开发工具更新的一部分出现。)但如今,一个应用程序在一个月内没有某个组件的版本发生变化是很少见的。.NET Framework 的严格版本策略现在看起来并不实用。(事实上,构建系统的某些部分专门负责浏览您的 NuGet 依赖项,计算出您正在使用的每个组件的具体版本,并自动生成包含大量版本替换规则的配置文件,告诉 CLR 使用这些版本,而不管任何单个程序集说它想要的版本是什么。因此,即使您面向.NET Framework,构建系统默认情况下也会有效地禁用严格版本控制。)

另一个变化是,.NET Framework 仅对强命名程序集考虑程序集版本。.NET Core 和.NET 检查磁盘上程序集的版本号是否等于或大于所需版本,而不管目标程序集是否强命名。

文化

到目前为止,我们已经看到程序集名称包括简单名称、版本号,以及可选的公钥标记。它们还有一个文化组件。(文化代表语言和一组约定,如货币、拼写变体和日期格式。)这不是可选的,尽管最常见的值是默认值:neutral,表示程序集不包含特定于文化的代码或数据。程序集的文化通常仅在包含特定于文化资源的程序集上设置为其他值。程序集名称的文化旨在支持资源(如图像和字符串)的本地化。为了说明这一点,我需要解释使用它的本地化机制。

所有程序集都可以包含嵌入的二进制流。(当然,你也可以在这些流中放置文本。你只需选择合适的编码。)反射 API 中的 Assembly 类提供了直接处理这些流的方法,但更常见的是使用 System.Resources 命名空间中的 ResourceManager 类。这比直接处理原始二进制流方便得多,因为 ResourceManager 定义了一个容器格式,允许单个流容纳任意数量的字符串、图像、声音文件和其他二进制项目,并且 Visual Studio 内置了编辑器以处理这种容器格式。我在本节中提到这一切的原因是,ResourceManager 还提供本地化支持,而程序集名称的文化特性是其机制的一部分。为了演示这是如何工作的,我将通过一个快速示例来向你展示。

使用 ResourceManager 的最简单方式是向项目中添加一个 .resx 格式的资源文件。(这不是运行时使用的格式。它是一种 XML 格式,会被编译成 ResourceManager 所需的二进制格式。在大多数源代码控制系统中,使用文本比使用二进制更容易。如果你的编辑器不支持该格式,则也可以使用这些文件。)要从 Visual Studio 的“添加新项”对话框中添加一个此类文件,请选择“Visual C#→通用”类别,然后选择“资源文件”。我将其命名为 MyResources.resx。Visual Studio 将显示其资源编辑器,以字符串编辑模式打开,正如图 12-1 所示。你可以看到,我定义了一个名为 ColString 的字符串,其值为 Color

字符串模式下的资源文件编辑器

图 12-1. 字符串模式下的资源文件编辑器

我可以在运行时检索此值。构建系统为你添加的每个 .resx 文件生成一个包装类,每个定义的资源都有一个静态属性。这使得查找字符串资源变得非常简单,正如示例 12-7 所示。

示例 12-7. 使用包装类检索资源
string colText = MyResources.ColString;

包装类隐藏了细节,通常这很方便。但在这种情况下,细节是我演示资源文件的全部原因,因此我展示了如何直接使用 ResourceManager,如示例 12-8 所示。我包含了整个文件的源代码,因为命名空间在这里很重要——构建工具会在嵌入资源流的名称前加上项目的默认命名空间,所以我要求使用 ResourceExample.MyResources 而不仅仅是 MyResources。(如果我将资源放在子文件夹中,工具也会在资源流名称中包含该文件夹的名称。)

示例 12-8. 在运行时检索资源
using System.Resources;

namespace ResourceExample;

class Program
{
    static void Main(string[] args)
    {
        `var` `rm` `=` `new` `ResourceManager``(`
            `"ResourceExample.MyResources"``,` `typeof``(``Program``)``.``Assembly``)``;`
        `string` `colText` `=` `rm``.``GetString``(``"ColString"``)``!``;`
        Console.WriteLine("And now in " + colText);
    }
}

到目前为止,这只是一个比较冗长的方法来获取字符串"Color"。然而,既然我们牵涉到了ResourceManager,我可以定义一些本地化资源了。作为英国人,我对拼写单词color的正确方式有强烈的看法。它们与奥莱利的编辑政策不一致,无论如何,我乐意为我的主要读者群(主要是美国人)适应我的工作。但一个程序可以做得更好——它应该能够为不同的受众提供不同的拼写方式。(更进一步,它应该能够在某些英语不是主要语言的国家完全改变语言。)事实上,我的程序已经包含了支持单词color本地化拼写所需的所有代码。我只需提供替代文本即可。

我可以通过添加一个精心选择的第二个资源文件来完成这个任务:MyResources.en-GB.resx。这个名字几乎与原始文件名相同,只是在*.resx扩展名之前多了一个.en-GB*。这个简称代表英国英语,虽然在政治上有点不敏感,但却是我所在地文化的标准化名称。(用于美国英语的文化名称是en-US。)在项目中添加了这样一个文件之后,我可以像之前那样添加一个名为ColString的字符串条目,但这次的值是正确的(我所在位置的值⁷),即Colour。如果你在配置了英国区域设置的计算机上运行应用程序,它将使用英国拼写。你的计算机很可能没有配置这个区域设置,所以如果你想尝试这个功能,你可以在示例 12-8 中的Main方法的开头添加示例 12-9 中的代码,强制.NET 在查找资源时使用英国文化。

示例 12-9. 强制使用非默认文化
Thread.CurrentThread.CurrentUICulture =
    new System.Globalization.CultureInfo("en-GB");

这与程序集有什么关系?如果你查看编译输出,你会看到除了通常的可执行文件和相关的调试文件外,构建过程还创建了一个名为en-GB的子目录,其中包含一个名为ResourceExample.resources.dll的程序集文件。(ResourceExample是我的项目名称。如果你创建了一个名为SomethingElse的项目,你会看到SomethingElse.resources.dll。)该程序集的名称看起来像这样:

ResourceExample.resources, Version=1.0.0.0, Culture=en-GB, PublicKeyToken=null

版本号和公钥标记将与主项目相匹配——在我的示例中,我保留了默认版本号,并且没有为我的程序集命名。但请注意Culture。我没有使用通常的neutral值,而是使用了en-GB,这与我添加的第二个资源文件的文件名中指定的文化字符串相同。如果你添加了更多带有其他文化名称的资源文件,你将会得到一个包含每种指定文化的专用程序集的文件夹。这些被称为卫星资源程序集

当您首次请求ResourceManager的资源时,它将查找一个具有与线程当前 UI 文化相同文化的卫星资源装配体。因此,它会尝试使用几段前显示的名称加载一个装配体。如果找不到,它会尝试一个更通用的文化名称——如果找不到en-GB资源,它将尝试寻找一个名为en的文化,表示没有指定特定地区的英语语言。只有当两者都找不到(或者找到匹配的装配体,但它们不包含所查找的资源)时,它才会退回到主装配体中的中性资源。

当指定非中性文化时,CLR 的装配体加载器会查找不同的位置。它会查找一个以该文化命名的子目录。这就是为什么构建过程将我的卫星资源装配体放置在一个en-GB文件夹中的原因。

为特定文化搜索资源会产生一些运行时成本。这些成本并不大,但如果您正在编写一个永远不会本地化的应用程序,您可能希望避免为您不使用的功能付出代价。然而,您可能仍希望使用ResourceManager——它比直接使用装配体清单资源流更方便地嵌入资源。避免这些成本的方法是告诉.NET,直接构建到您的主装配体中的资源适合特定文化。您可以使用示例 12-10 中显示的装配体级属性来实现这一点。

示例 12-10. 指定内置资源的文化
[assembly: NeutralResourcesLanguage("en-US")]

当带有该属性的应用程序在通常的美国区域设置的计算机上运行时,ResourceManager将不会尝试搜索资源。它将直接寻找编译到您的主装配体中的资源。

保护

在第三章中,我描述了可以应用于类型及其成员的一些可访问性限定符,例如privatepublic。在第六章中,我展示了在使用继承时可用的一些附加机制。快速回顾这些功能是值得的,因为装配体发挥了作用。

同样在第三章,我介绍了internal关键字,并说具有此可访问性的类和方法仅在组件内部可用,这是一个稍微模糊的术语,因为我还没有介绍装配体。现在已经清楚了什么是装配体,我可以安全地说internal关键字的更精确描述是指示成员或类型仅对同一装配体中的代码可访问。⁸ 同样,protected internal成员可供派生类型的代码访问,也可供同一装配体中定义的代码访问,而protected private保护级别更为严格,使成员仅对位于同一装配体中定义的派生类型中的代码可用。

目标框架和 .NET Standard

在构建每个程序集时,您需要做出的一个决定是选择目标框架或框架。每个 .csproj 文件都会有一个 <TargetFramework> 元素指示目标,或者一个包含框架列表的 <TargetFrameworks> 元素。特定的目标由 目标框架代号(TFM)指示。例如,netcoreapp3.1 标识 .NET Core 3.1,然后随着 .NET 5.0 命名规范的变化,我们有 net5.0net6.0 分别用于 .NET 5.0 和 .NET 6.0。对于 .NET Framework 4.6.2、4.7.2 和 4.8,TFMs 分别是 net462net472net48。当列出多个目标框架时,在构建时将会得到多个程序集,每个程序集位于以 TFM 命名的自己的子文件夹中。SDK 会有效地多次构建项目。

如果您需要为每个目标平台提供不同的代码(也许是因为只能在更新的目标版本上实现某些功能),您可能需要使用条件编译(在 “编译符号” 中描述)。但在同一份代码适用于所有目标的情况下,将代码构建到单个目标,如 .NET Standard,可能是有意义的。正如我在 第一章 中描述的那样,各个版本的 .NET Standard 定义了跨多个 .NET 版本可用的 .NET 运行时库的共同子集。我说过,如果您需要同时面向 .NET(或 .NET Core)和 .NET Framework,今天的最佳选择通常是 .NET Standard 2.0(其 TFM 是 netstandard2.0)。然而,了解其他选项也是值得的,特别是如果您希望将组件提供给尽可能广泛的受众。

在 NuGet 上发布的 .NET 库可能会决定以它们能够支持的最低 .NET Standard 版本为目标,以确保最广泛的覆盖范围。从版本 1.1 到 1.6,逐步增加了更多功能,作为支持更小范围目标的交换条件。(例如,如果你想在 .NET Framework 上使用一个 .NET Standard 1.3 组件,需要 .NET Framework 4.6 或更高版本;而目标为 .NET Standard 1.4 需要 .NET Framework 4.61 或更高版本。).NET Standard 2.0 是一个更大的进步,标志着 .NET Standard 进化的重要里程碑:根据微软目前的计划,这将是能在 .NET Framework 上运行的最高版本号。从 .NET Framework 4.7.2 开始的版本完全支持它,但 .NET Standard 2.1 将不会在任何现在或未来的 .NET Framework 版本上运行。它将在 .NET Core 3.0、3.1 以及 .NET 5.0 及以后版本上运行。Mono v6.4 及更高版本也支持它。但这是经典 .NET Framework 的终结。实际上,目前 .NET Standard 2.0 是组件作者当前流行的选择,因为它使组件能够在所有最近发布的 .NET 版本上运行,同时提供广泛的功能集。

所有这些都导致了一定程度的混乱,你可能会高兴地知道,.NET 6.0 带来的统一简化了事情。如果你不需要支持 .NET Framework,可以直接目标 .NET 6.0,无需考虑 .NET Standard。Mono 可以运行针对 .NET 6.0 的组件,而 .NET NativeAot 也计划支持它,因此目标 .NET 6.0 将覆盖大多数运行时。

对于 C# 开发人员,这一切意味着什么?如果你正在编写的代码永远不会在特定项目之外使用,通常会直接目标最新版本的 .NET,除非你需要一些它不提供的特定于 Windows 的功能,此时你可能会目标 .NET Framework。无论哪种方式,你都可以使用任何目标 .NET Standard 的 NuGet 包,包括 v2.0(这意味着 NuGet 上的绝大多数内容对你都是可用的)。

如果您正在编写计划共享的库,并且希望您的组件可以提供给尽可能广泛的受众,那么您应该针对 .NET Standard 进行开发,除非您绝对需要某个特定运行时中独有的功能。.NET Standard 2.0 是一个合理的选择——通过降低版本,您可以扩大库的受众范围,但是今天,支持 .NET Standard 2.0 的 .NET 版本已经广泛可用,所以只有在需要支持仍在使用旧 .NET Framework 的开发人员时,才会考虑针对旧版本进行目标定位。(微软在其大多数 NuGet 库中都这样做,但您并不一定要将自己与支持旧版本的同一体系绑定在一起。)微软提供了一个有用的指南,说明各种 .NET 实现版本支持各种 .NET Standard 版本。如果您想使用某些新特性(例如第十八章 中描述的节省内存的类型),可能需要针对更近期的 .NET Standard 版本进行目标定位,目前的最新版本是 2.1,但请注意,这将排除在 .NET Framework 上运行。在那种情况下,您最好直接将目标设定为 .NET Core 3.1 或更高版本的 .NET,因为 .NET Standard 在新统一的后 .NET Framework 世界中提供的功能有限。无论如何,开发工具将确保您只使用您声明支持的 .NET 或 .NET Standard 版本中可用的 API。

总结

程序集是一个可部署的单元,几乎总是一个单独的文件,通常使用 .dll.exe 扩展名。它是类型和代码的容器。每种类型都属于且仅属于一个程序集,而该程序集形成了类型的一部分标识——如果它们在不同的程序集中定义,.NET 运行时可以区分相同命名空间中具有相同名称的两种类型。程序集有一个复合名称,包括简单的文本名称、一个四部分的版本号、一个文化字符串,以及可选的公钥标记。带有公钥标记的程序集被称为强命名程序集,使它们具有全局唯一的名称。程序集可以与使用它们的应用程序一起部署,也可以存储在机器范围的存储库中。(在 .NET Framework 中,该存储库是全局程序集缓存,必须强命名才能使用。.NET 和 .NET Core 提供了内置程序集的共享副本,根据您如何安装这些更新的运行时,它们也可能具有共享的框架副本,如 ASP.NET Core 和 WPF。您还可以选择设置一个单独的运行时包存储库,其中包含其他共享程序集,以避免将它们包含在应用程序文件夹中。)

运行时可以根据需要自动加载程序集,通常是在第一次运行包含某些依赖于相关程序集中定义类型的代码的方法时发生。如果需要,你也可以显式加载程序集。

正如我之前提到的,每个程序集都包含详细的元数据描述其包含的类型。在下一章中,我将展示如何在运行时访问这些元数据。

¹ 在此,我广义上使用现代一词——Windows NT 于 1993 年引入了 PE 支持。

² 通过适当的构建设置,无论你在哪个操作系统上构建,都可以生成所有支持目标的引导程序。

³ 这是 Windows Vista 发布的年份。应用程序清单在此之前就存在,但这是 Windows 首次将其缺失视为传统代码的标志。

⁴ 这在.NET Framework 或.NET Standard 中不可用。在.NET Framework 中通常使用应用程序域进行隔离,这是一个不支持.NET 或.NET Core 的旧机制。

⁵ 如果你使用Assembly.LoadFrom,CLR 并不关心文件名是否与简单名称匹配。

⁶ 可以配置 CLR 以替换特定的不同版本,但即使如此,加载的程序集也必须与配置指定的确切版本匹配。

⁷ 英格兰,霍夫。

⁸ 内部项目也适用于友元程序集,这意味着任何在InternalsVisibleTo属性中引用的程序集,如第十四章所述。

第十三章:反射

CLR 对我们程序定义和使用的类型了如指掌。它要求所有程序集提供详细的元数据,描述每个类型的每个成员,包括私有实现细节。它依赖于此信息来执行关键功能,如 JIT 编译和垃圾回收。然而,它不会将这些知识私藏起来。反射 API 授予访问这些详细类型信息的权限,使得您的代码可以发现运行时能够看到的一切。此外,您还可以使用反射来实现一些事情。例如,代表方法的反射对象不仅描述方法的名称和签名,还允许您调用该方法。您甚至可以进一步,在运行时生成代码。

反射在可扩展框架中特别有用,因为它们可以使用它来根据代码结构在运行时调整其行为。例如,Visual Studio 的属性面板使用反射来发现组件提供的公共属性,因此,如果您编写一个可以出现在设计表面上的组件(如 UI 元素),您无需采取任何特殊措施使其属性可供编辑——Visual Studio 将自动找到它们。

注意

许多基于反射的框架可以自动发现它们需要了解的内容,同时允许组件显式地丰富这些信息。例如,尽管您无需采取任何特殊措施来支持在“属性”面板中进行编辑,但如果您希望,您可以自定义分类、描述和编辑机制。这通常通过属性来实现,它们是第十四章的主题。

反射类型

反射 API 定义了System.Reflection命名空间中的各种类。这些类具有与程序集和类型系统工作方式相对应的结构关系。例如,类型的包含程序集是其标识的一部分,因此代表类型的反射类(Type¹)具有一个Assembly属性,返回其包含的Assembly对象。而且您可以在这两个方向上导航这种关系——您可以从Assembly类的DefinedTypes属性中发现程序集中的所有类型。可以通过加载插件 DLL 来扩展的应用程序通常会使用这种方法来查找每个插件提供的类型。图 13-1 展示了与.NET 类型对应的反射类型、它们的成员以及包含它们的组件。箭头表示包含关系。(与程序集和类型一样,这些关系也都是可导航的。)

图 13-1. 反射包含层次

图 13-2 展示了这些类型的继承层次结构。其中还显示了几个额外的抽象类型,如MemberInfoMethodBase,它们被各种反射类共享,这些类具有一定的共同点。例如,构造函数和方法都有参数列表,而检查这些内容的机制则由它们共享的基类MethodBase提供。类型的所有成员都具有某些共同的特征,比如可访问性,因此反射中表示类型成员的对象都是MemberInfo派生的。

图 13-2. 反射继承层次结构

程序集

Assembly类可可预测地表示单个程序集。如果你正在编写插件系统或其他需要加载用户提供的 DLL 并使用它们的框架(例如单元测试运行器),Assembly类型将是你的起点。正如第十二章所示,静态Assembly.Load方法接受一个程序集名称,并返回该程序集的对象。(如果必要,该方法将加载程序集,但如果已加载,则只返回相关Assembly对象的引用。)但还有其他获取此类对象的方式。

Assembly类定义了三个上下文相关的静态方法,每个方法都返回一个Assembly对象。GetEntryAssembly方法返回表示包含程序Main方法的 EXE 文件的对象。GetExecutingAssembly方法返回包含调用它的方法的程序集。GetCallingAssembly方法上溯堆栈一级,并返回调用调用GetCallingAssembly的方法的程序集。

注意

JIT(即时编译器)的优化有时会在使用GetExecutingAssemblyGetCallingAssembly方法时产生意外的结果。方法内联和尾调用优化都可能导致这些方法返回比预期更深一层的堆栈帧的程序集。你可以通过为方法添加MethodImpl​Attribute并传递MethodImpl​Options枚举中的NoInlining标志来防止内联优化。(有关属性的描述,请参见第十四章。)目前没有明确的方法来禁用尾调用优化,但只有在特定方法调用是方法返回前的最后一件事时才会应用这些优化。

GetCallingAssembly 在诊断日志记录中很有用,因为它提供了调用你方法的代码的信息。GetExecutingAssembly 方法则不太有用:你可能已经知道代码将在哪个程序集中,因为你是编写代码的开发者。尽管如此,获取你正在编写的组件的 Assembly 对象可能仍然有用,但也有其他方法。下一节介绍的 Type 对象提供了一个 Assembly 属性。示例 13-1 使用这种方法通过包含类获取 Assembly。根据经验,这种方法似乎更快,这并不完全令人惊讶,因为它的工作量更少——这两种技术都需要检索反射对象,但其中一种还必须检查堆栈。

示例 13-1. 通过 Type 获得自己的 Assembly
class Program
{
    static void Main()
    {
        `Assembly` `me` `=` `typeof``(``Program``)``.``Assembly``;`
        Console.WriteLine(me.FullName);
    }
}

如果想从磁盘上的特定位置使用一个程序集,可以使用 第十二章 中描述的 LoadFrom 方法。或者,可以使用 System​.Reflec⁠tion.MetadataLoadContext NuGet 包的 MetadataLoadContext 类。这种方法加载程序集的方式使你可以检查其类型信息,但不会执行程序集中的任何代码,也不会自动加载它依赖的任何程序集。如果你正在编写一个工具来显示或处理关于组件的信息,但不想运行其代码,那么这是加载程序集的适当方式。有几个理由可以避免使用传统方式加载程序集来处理此类工具。加载程序集并检查其类型有时可能触发该程序集中的代码执行(如静态构造函数)。另外,如果仅用于反射目的加载程序集,则处理器架构并不重要,因此你可以将仅支持 32 位的 DLL 加载到 64 位进程中,或者在 x86 进程中检查 ARM-only 程序集。

从任何上述机制获取了一个 Assembly 后,可以发现它的各种信息。例如,FullName 属性提供显示名称。或者你可以调用 GetName,它返回一个 AssemblyName 对象,轻松地以编程方式访问程序集名称的所有组件。

要获取特定 Assembly 依赖的其他所有 Assembly 列表,可以调用 GetReferencedAssemblies。如果你在自己编写的程序集上调用此方法,它不一定会返回在 Visual Studio 解决方案资源管理器中“依赖项”节点中看到的所有程序集,因为 C# 编译器会剔除未使用的引用。

程序集包含类型,因此你可以通过调用 Assembly 对象的 GetType 方法找到表示这些类型的 Type 对象,传递你需要的类型的名称,包括其命名空间。如果未找到该类型,这将返回 null,除非你调用其中一个额外接受 bool 参数的重载版本 —— 使用这些,如果未找到类型,传递 true 将产生异常。还有一个重载版本,接受两个 bool 参数,其中第二个允许你传递 true 来请求大小写不敏感搜索。所有这些方法将返回 publicinternal 类型。你还可以请求一个嵌套类型,通过指定包含类型的名称,然后是一个 + 符号,然后是嵌套类型名称。 Example 13-2 获取了一个名为 Inside 的类型的 Type 对象,该类型嵌套在命名空间为 MyLib 的类型 ContainingType 内部。即使嵌套类型是私有的,这也能正常工作。

示例 13-2. 从程序集获取嵌套类型
Type? nt = someAssembly.GetType("MyLib.ContainingType+Inside");

Assembly 类还提供了 DefinedTypes 属性,返回一个集合,包含程序集定义的每个类型(顶层或嵌套)的 TypeInfo 对象,以及 ExportedTypes,仅返回公共类型,它返回 Type 对象而不是完整的 TypeInfo 对象。(“Type 和 TypeInfo” 中描述了 TypeInfoType 的区别。)这也包括任何公共的嵌套类型。它不包括位于公共类型内部的 protected 类型,这也许有点令人惊讶,因为这些类型虽然只能被派生自包含类型的类访问,但却可以从程序集外部访问。

除了返回类型外,Assembly 还可以使用 CreateInstance 方法创建它们的新实例。如果你只传递类型的完全限定名称作为字符串,这将创建一个实例,前提是该类型是公共的并且具有无参数构造函数。还有一个重载版本,让你可以处理非公共类型和需要参数的构造函数的类型;但是,这个使用起来更加复杂,因为它还接受指定类型名称是否不区分大小写的参数,以及定义用于不区分大小写比较的 CultureInfo 对象的参数 —— 不同的国家对这种比较有不同的看法。它还有用于控制更高级场景的参数。然而,对于大多数情况,你可以传递 null,正如 Example 13-3 所示。

示例 13-3. 动态构造
object? o = asm.CreateInstance(
    "MyApp.WithConstructor",
    false,
    BindingFlags.Public | BindingFlags.Instance,
    null,
    new object[] { "Constructor argument" },
    null,
    null);

asm 引用的程序集中,创建一个名为 WithConstructor 的类型的实例,该类型位于 MyApp 命名空间中。false 参数指示我们要在名称上进行精确匹配,而不是不区分大小写的比较。BindingFlags 指示我们正在寻找公共实例构造函数。(参见侧边栏 “Bind⁠ing​Flags”。)第一个 null 参数是你可以传递 Binder 对象的位置,它允许你在提供的参数与所需参数的类型不完全匹配时自定义行为。通过省略此参数,我表明我希望我提供的参数完全匹配。(如果它们不匹配,我会得到一个异常。)object[] 参数包含我想要传递给构造函数的参数列表——在本例中是一个字符串。倒数第二个 null 是我将要传递的地方,如果我使用不区分大小写的比较或数字类型与字符串之间的自动转换,但由于我都不使用,我可以省略它。最后一个参数曾支持已被弃用的场景,因此应始终为 null

模块

图 13-1 显示 Assembly 作为包含 Module 对象的容器。.NET Framework 支持将一个程序集的内容分割到多个文件(模块)中,但这个很少使用的特性在 .NET Core 或 .NET 中不被支持。在大多数情况下,你可以忽略 Module 类型——你通常可以使用反射 API 中的其他类型完成所有需要的操作。唯一的例外是,运行时生成代码的 API 需要你标识哪个模块应包含生成的代码,即使你只创建一个模块。(.NET 的运行时生成代码的 API 超出了本书的范围。)

Module 类提供另外一个服务:令人惊讶的是,它定义了 GetFieldGetFieldsGetMethodGetMethods 属性。这些属性提供对全局作用域的方法和字段的访问。在 C# 中你从未见过这些,因为该语言要求所有字段和方法都必须在类型内定义,但 CLR 允许全局作用域的方法和字段存在,所以反射 API 必须能够呈现它们。这些通过 Module 暴露出来,而不是 Assembly,因此即使在现代 .NET 的每个程序集一个模块的世界中,你也只能通过 Module 类访问它们。你可以从 Assembly 对象的 Modules 属性中检索它们,或者你可以使用从 MemberInfo 派生的以下部分中描述的任何 API 类型。(图 13-2 显示了哪些类型这样做。) 这定义了一个 Module 属性,返回定义了相关成员的 Module

MemberInfo

像本节描述的所有类一样,MemberInfo是抽象的。但与其余类不同的是,它不对应类型系统中的一个特定功能。它是一个共享的基类,为所有表示可以成为其他类型成员的项目的类型提供通用功能。因此,它是ConstructorInfoMethodInfoFieldInfoPropertyInfoEventInfoType的基类,因为所有这些都可以成为其他类型的成员。实际上,在 C#中,除了Type,所有这些都必须是某些其他类型的成员(尽管,正如您刚刚在前一节中看到的,某些语言允许方法和字段被作用域到模块而不是类型)。

MemberInfo定义了所有类型成员都必须具有的常见属性。当然,有一个Name属性,还有一个DeclaringType属性,它引用项目所在类型的Type对象;对于非嵌套类型和模块范围的方法和字段,这将返回nullMemberInfo还定义了一个Module属性,它引用包含模块,无论所讨论的项目是模块范围的还是类型的成员。

除了DeclaringType之外,MemberInfo还定义了一个ReflectedType,表示从中检索MemberInfo的类型。这些通常是相同的,但在涉及继承时可能会不同。示例 13-4 显示了区别。

示例 13-4. DeclaringTypeReflectedType
class Base
{
    public void Foo()
    {
    }
}

class Derived : Base
{
}

class Program
{
    static void Main(string[] args)
    {
        MemberInfo bf = typeof(Base).GetMethod("Foo")!;
        MemberInfo df = typeof(Derived).GetMethod("Foo")!;

        Console.WriteLine("Base    Declaring: {0}, Reflected: {1}",
                          bf.DeclaringType, bf.ReflectedType);
        Console.WriteLine("Derived Declaring: {0}, Reflected: {1}",
                          df.DeclaringType, df.ReflectedType);
    }
}

获取Base.FooDerived.Foo方法的MethodInfo。(MethodInfo派生自MemberInfo。)这只是描述同一方法的不同方式—Derived没有定义自己的Foo,所以它继承了Base定义的方法。程序产生以下输出:

Base    Declaring: Base, Reflected: Base
Derived Declaring: Base, Reflected: Derived

通过Base类的Type对象检索Foo信息时,DeclaringTypeReflectedType都显然是Base。然而,当我们通过Derived类型检索Foo方法的信息时,DeclaringType告诉我们该方法由Base定义,而ReflectedType告诉我们我们通过Derived类型获取了此方法。

警告

因为MemberInfo记住了您从哪种类型检索它,所以比较两个MemberInfo对象并不是检测它们是否引用相同事物的可靠方法。在示例 13-4 中使用==运算符或它们的Equals方法比较bfdf会返回false,尽管它们都引用Base.Foo。如果您不知道ReflectedType属性,您可能不会预期到这种行为。

令人稍感惊讶的是,MemberInfo 并不提供描述其可见性的任何信息。这可能看起来很奇怪,因为在 C# 中,所有对应于从 MemberInfo 派生的类型的构造函数、方法或属性的结构都可以加上 publicprivate 等修饰符。反射 API 确实提供了这些信息,但不是通过 MemberInfo 基类。这是因为 CLR 对某些成员类型的可见性处理方式与 C# 展示的方式略有不同。从 CLR 的角度来看,属性和事件并没有自己的访问性。相反,它们的访问性是在单个方法的级别上管理的。这使得属性的getset可以具有不同的访问级别,事件的访问器也是如此。当然,如果我们希望的话,我们可以在 C# 中独立地控制属性访问器的访问级别。C# 误导我们的地方在于,它让我们为整个属性指定一个单一的访问级别。但这只是设置两个访问器为相同级别的一种简写。令人困惑的是,它让我们为属性指定访问级别,然后为其中一个成员指定不同的访问级别,就像示例 13-5 中那样。

示例 13-5. 属性访问器的访问性
public int Count
{
    get;
    private set;
}

这有点误导性,因为尽管看起来如此,public 访问性并不适用于整个属性。这个属性级别的访问性只是告诉编译器在未指定访问级别的访问器时该使用什么。C# 的第一个版本要求属性访问器都具有相同的访问级别,因此为整个属性指定访问级别是有道理的。(对于事件仍然有类似的限制。)但这是一个任意的限制——CLR 一直允许每个访问器具有不同的访问级别。现在的 C# 支持这一点,但由于历史原因,利用这一点的语法看起来是不对称的。从 CLR 的角度来看,示例 13-5 只是指定get publicset private。示例 13-6 可能更好地表示实际发生的情况。

示例 13-6. CLR 如何看待属性的访问性
// Won't compile but arguably should
int Count
{
    public get;
    private set;
}

但是我们不能这样写,因为 C# 要求更显眼的两个访问器之一的可访问性在属性级别声明。这样做使得当两个属性具有相同的可访问性时语法更简单,但当它们不同时会显得有些奇怪。此外,在 示例 13-5 中(即编译器实际支持的语法)看起来我们应该能够在三个地方指定可访问性:属性和两个访问器。CLR 不支持这样做,所以如果你试图为两个访问器指定可访问性,编译器会报错。因此,属性或事件本身没有可访问性。(想象一下如果有的话——如果一个属性具有 public 可访问性,但其 getinternal,而 setprivate,那会是什么意思?)因此,并非从 MemberInfo 派生的所有内容都具有特定的可访问性,因此反射 API 提供了表示类层次结构中更深层次的可访问性的属性。

类型和 TypeInfo

Type 类表示特定的类型。它比本章中的任何其他类都更广泛使用,这就是为什么它单独位于 System 命名空间中,而其他类都定义在 System.Reflection 中的原因。它是最容易获取的,因为 C# 专门为此设计了一个操作符:typeof。我已经在几个示例中展示过了,但是 示例 13-7 单独展示了它。正如您所见,您可以使用内置名称,例如 string,或普通类型名称,例如 IDisposable。您还可以包括命名空间,但是当类型的命名空间在范围内时,这并不是必需的。

示例 13-7. 使用 typeof 获取 Type
Type stringType = typeof(string);
Type disposableType = typeof(IDisposable);

此外,正如我在 第六章 中提到的,System.Object 类型(或者在 C# 中我们通常写为 object)提供了一个 GetType 实例方法,不接受任何参数。您可以在任何引用类型变量上调用它,以检索该变量引用的对象的类型。这可能不会与变量本身的类型相同,因为变量可能引用派生类型的实例。您还可以在任何值类型变量上调用此方法,由于值类型不支持继承,它将始终返回变量静态类型的 Type 对象。

因此,您所需要的只是一个对象、一个值或一个类型标识符(例如 string),获取一个 Type 对象就非常简单。而且,Type 对象可以来自许多其他地方。

除了Type之外,我们还有TypeInfo。这在早期的.NET Core 版本中引入,旨在使Type仅作为轻量级标识符,并且TypeInfo作为反射类型的机制。这与.NET Framework 中Type一直以来的工作方式不同,后者同时扮演着这两个角色。这种双重角色可以说是一个错误,因为如果你只需要一个标识符,那么Type就显得不必要地笨重。最初,.NET Core 被构想为与.NET Framework 有着完全独立的存在,不需要严格的兼容性,因此看似提供了修复历史设计问题的机会。然而,一旦微软决定.NET Core 将成为所有未来版本的.NET 的基础,就有必要将其重新调整为.NET Framework 一直工作的方式。然而,到了这个时候,.NET Framework 也引入了TypeInfo,一段时间内,新的类型级别反射功能被添加到其中,而不是Type,以最小化与.NET Core 1 的不兼容性。.NET Core 2.0 重新与.NET Framework 对齐,但这意味着TypeTypeInfo之间的功能拆分现在只是添加时的结果。TypeInfo包含在其引入和决定恢复旧方式之间的短暂时期内添加的成员。在你有一个Type但你需要使用特定于TypeInfo的功能的情况下,你可以通过调用GetTypeInfo从一个Type获取它。

正如你已经看到的,你可以从一个Assembly中检索Type对象,无论是按名称还是作为一个全面的列表。派生自MemberInfo的反射类型也通过DeclaringType提供了对其包含类型的引用。(Type派生自MemberInfo,因此当处理嵌套类型时,这个属性也是相关的。)

你还可以调用Type类自己的静态GetType方法。如果你只传递了一个命名空间限定的字符串,它将在名为mscorlib的系统程序集中搜索命名的类型,并且还会在你调用该方法的程序集中搜索。然而,你可以传递一个程序集限定名称,它结合了程序集名称和类型名称。这种形式的名称以命名空间限定的类型名称开头,后跟逗号和程序集名称。例如,这是.NET Framework 4.8 中System.String类的程序集限定名称(分成两行以适应本书):

System.String, mscorlib, Version=4.0.0.0, Culture=neutral,
 PublicKeyToken=b77a5c561934e089

你可以通过Type.Assembly​Quali⁠fiedName属性发现类型的程序集限定名称。请注意,这不总是与您要求的相匹配。如果你将前述类型名称传递给.NET 6.0 中的Type.GetType,它将起作用,但如果你然后询问返回的TypeAssemblyQualifiedName,它将返回这个而不是:

System.String, System.Private.CoreLib, Version=6.0.0.0, Culture=neutral,
 PublicKeyToken=7cec85d7bea7798e

当您传递第一个字符串或只传递System.String时仍然有效,这是因为mscorlib仍然存在以支持向后兼容性。我在前一章节中描述了这一点,但总结起来,在.NET Framework 中,mscorlib程序集包含运行时库的核心类型,但在.NET Core 和.NET 5.0 或更高版本中,代码已经迁移到其他位置。mscorlib仍然存在,但它只包含类型转发条目,指示每个类现在位于哪个程序集中。例如,它将System.String转发到其新位置,即在撰写本文时位于System.Private.CoreLib程序集中。

除了标准的MemberInfo属性(例如ModuleName)之外,TypeTypeInfo类还添加了各种自己的属性。继承的Name属性包含未经修饰的名称,因此Type添加了一个Namespace属性。所有类型都作用于一个程序集,因此Type定义了一个Assembly属性。(当然,您可以通过Module.Assembly获取它,但使用Assembly属性更为方便。)它还定义了一个BaseType属性,尽管对于某些类型(例如非派生接口和System.Object类的类型对象),该属性将为null

由于Type可以表示各种类型,因此有一些属性可以用来确定确切的类型:IsArrayIsClassIsEnumIsInterfaceIsPointerIsValueType。(在互操作场景中,您还可以获取非.NET 类型的Type对象,因此还有一个IsCOMObject属性。)如果它表示一个类,有一些属性可以告诉您更多关于您所拥有的类的信息:IsAbstractIsSealedIsNested。最后一个属性不仅适用于类,还适用于值类型。

Type 还定义了许多属性,提供关于类型可见性的信息。对于非嵌套类型,IsPublic告诉您它是public还是internal,但对于嵌套类型,情况更为复杂。IsNestedAssembly表示内部嵌套类型,而IsNestedPublicIsNestedPrivate表示publicprivate的嵌套类型。CLR 不使用传统的 C 家族protected术语,而是使用family术语,因此我们有IsNestedFamily代表protectedIsNestedFamOR​As⁠sem代表protected internal,以及IsNestedFamANDAssem代表protected private

注意

没有IsRecord属性。在运行时看来,记录类型是类或结构。记录是 C#类型系统的一个特性,但不是.NET 运行时类型系统(CTS)的一部分。反射是运行时特性,因此它呈现了 CTS 的视角。

TypeInfo类还提供了一些方法来发现相关的反射对象。(本段中的属性都是在TypeInfo上定义的,而不是Type。如前所述,这只是定义时间的偶然。)这些大多数方法都有两种形式:一种是您需要指定特定类型所有项目的完整列表,另一种是您已知要查找的项目名称。例如,我们有DeclaredConstructorsDeclaredEventsDeclaredFieldsDeclaredMethodsDeclaredNestedTypesDeclaredProperties及其对应的GetDeclaredConstructorGetDeclaredEventGetDeclaredFieldGetDeclaredMethodGetDeclaredNestedTypeGetDeclaredProperty

Type类允许您发现类型兼容性关系。您可以通过调用类型的IsSubclassOf方法来询问一个类型是否派生自另一个类型。继承不是一个类型可能与不同类型的引用兼容的唯一原因——一个类型为接口的变量可以引用任何实现该接口的类型的实例,而不管其基类是什么。因此,Type类还提供了一个更通用的方法,称为IsAssignableFrom,如 Example 13-8 所示,它告诉您是否存在隐式引用转换。

Example 13-8. 测试类型兼容性
Type stringType = typeof(string);
Type objectType = typeof(object);
Console.WriteLine(stringType.IsAssignableFrom(objectType));
Console.WriteLine(objectType.IsAssignableFrom(stringType));

这显示False,然后是True,因为您无法将类型为object的实例的引用赋给类型为string的变量,但您可以将类型为string的实例的引用赋给类型为object的变量。

除了告诉您有关类型及其与其他类型的关系的信息之外,Type类还提供了在运行时使用类型成员的能力。它定义了一个InvokeMember方法,其确切含义取决于您调用的成员类型——例如,它可以意味着调用方法,或者获取或设置属性或字段。由于某些成员类型支持多种调用方式(例如获取和设置),您需要指定您想要的特定操作。Example 13-9 使用InvokeMember来调用由其名称(member字符串参数)标识的方法,该方法在动态实例化的类型实例上。这说明了反射如何用于处理在运行时才知道其标识的类型和成员。

Example 13-9. 使用InvokeMember调用方法
public static object? CreateAndInvokeMethod(
  string typeName, string member, params object[] args)
{
    Type t = Type.GetType(typeName)
        ?? throw new ArgumentException(
            $"Type {typeName} not found", nameof(typeName));
    object instance = Activator.CreateInstance(t)!;
    return t.InvokeMember(
      member,
      BindingFlags.Instance | BindingFlags.Public | BindingFlags.InvokeMethod,
      null,
      instance,
      args);
}

这个示例首先创建了指定类型的一个实例,这里采用了与我之前展示的Assembly.CreateInstance稍有不同的动态创建方法。这里我使用Type.GetType来查找类型,然后使用了一个我之前未提及的类Activator。这个类的作用是在运行时创建具有已确定类型的新对象实例。它的功能在某种程度上与Assembly.CreateInstance有重叠,但在这种情况下,这是从Type到该类型的新实例的最便捷方式。然后我使用了Type对象的InvokeMember来调用指定的方法。与示例 13-3 一样,我不得不指定绑定标志以指示我要查找的成员的类型,以及如何处理它们——这里我要调用一个方法(而不是设置属性值)。null参数就像示例 13-3 一样,如果我想支持方法参数类型的自动强制转换,我会在这里指定一个Binder

泛型类型

.NET 对泛型的支持使得Type类的角色变得更加复杂。除了表示普通的非泛型类型外,Type还可以表示泛型类型的特定实例(例如,List<int>),还可以表示未绑定的泛型类型(例如,List<>,尽管在除了一个非常特定的场景外,这是一个非法的类型标识符)。示例 13-10 展示了如何获取这两种类型的Type对象。

示例 13-10. 泛型类型的Type对象
Type bound = typeof(List<int>);
Type unbound = typeof(List<>);

typeof运算符是在 C# 中唯一可以使用未绑定泛型类型标识符的地方——在所有其他上下文中,如果不提供类型参数,这将是一个错误。顺便说一下,如果类型有多个类型参数,你必须提供逗号,例如,typeof(Dictionary<,>)。这是为了避免当有多个同名泛型类型,只有类型参数数量(也称为泛型的度)不同时的歧义——例如,typeof(Func<,>)typeof(Func<,,,>)。你不能指定部分绑定的泛型类型。例如,typeof(Dictionary<string,>)将无法编译通过。

你可以通过IsGenericType属性来判断一个Type对象是否引用一个泛型类型——对于来自示例 13-10 的boundunbound,该属性都将返回true。你还可以通过IsGenericTypeDefinition属性来确定是否提供了类型参数,对于boundunbound,它分别返回falsetrue。如果你有一个已绑定的泛型类型,并且想获取其构造的未绑定类型,你可以使用GetGenericType​Defini⁠tion方法——在bound上调用该方法将返回与unbound引用的相同类型对象。

给定一个 Type 对象,其 IsGenericTypeDefinition 属性返回 true,你可以通过调用 MakeGenericType 方法构造该类型的新的绑定版本,传递一个 Type 对象的数组,每个类型参数一个。

如果你有一个泛型类型,可以从 GenericTypeArguments 属性中检索其类型参数。或许令人惊讶的是,即使对于未绑定的类型,这也适用,尽管其行为与绑定类型不同。如果你从 示例 13-10 的 bound 获取 GenericTypeArguments,它将返回一个包含单个 Type 对象的数组,该对象与 typeof(int) 返回的对象相同。如果你从 unbound.GenericTypeArguments 获取,你将得到一个包含单个 Type 对象的数组,但这次它将是一个不代表特定类型的 Type 对象 —— 其 IsGenericParameter 属性将为 true,表明这代表一个占位符。在这种情况下,其名称将是 T。一般来说,名称将对应于泛型类型选择的任何占位符名称。例如,使用 typeof(Dictionary<,>),你将分别得到两个名为 TKeyTValueType 对象。如果你使用反射 API 查找泛型类型的成员,你将遇到类似的泛型参数占位符类型。例如,如果你检索未绑定的 List<> 类型的 Add 方法的 MethodInfo,你将发现它接受一个名为 T 的类型参数,其 IsGenericParameter 属性返回 true

当一个 Type 对象表示一个未绑定的泛型参数时,你可以通过其 GenericParameterAttributes 方法了解该参数是协变的、逆变的(或两者都不是)。

MethodBase, ConstructorInfo, and MethodInfo

构造函数和方法在很多方面都有很大的共同之处。这两种成员都具有相同的可访问性选项,它们都有参数列表,并且它们都可以包含代码。因此,MethodInfoConstructorInfo 反射类型共享一个基类,即 MethodBase,该类定义了处理这些共同方面的属性和方法。

要获取 MethodInfoConstructorInfo,除了使用我之前提到的 Type 类属性之外,还可以调用 MethodBase 类的静态 GetCurrentMethod 方法。这将检查调用代码,看它是构造函数还是普通方法,并相应地返回 MethodInfoConstructorInfo

除了从 MemberInfo 继承的成员之外,MethodBase 还定义了指定成员可访问性的属性。这些与我之前为类型描述的概念类似,但名称略有不同,因为与 Type 不同,MethodBase 并未定义区分嵌套和非嵌套成员的可访问性属性。因此,对于 publicprivateinternalprotectedprotected internalprotected private,在 MethodBase 中我们找到了 IsPublicIsPrivateIsAssemblyIsFamilyIsFamilyOrAssemblyIsFamilyAndAssembly

除了与可访问性相关的属性之外,MethodBase 还定义了一些属性,用于告知方法的各个方面,例如 IsStaticIsAbstractIsVirtualIsFinalIsConstructor

还有处理泛型方法的属性。IsGenericMethodIsGenericMethodDefinition 是方法级别的等价物,对应于类型级别的 IsGenericTypeIsGenericTypeDefinition 属性。与 Type 类似,有一个 GetGenericMethodDefinition 方法用于从绑定的泛型方法获取未绑定的泛型方法,以及一个 MakeGenericMethod 方法用于从未绑定的泛型方法生成绑定的泛型方法。您可以通过调用 GetGenericArguments 获取类型参数,并且与泛型类型一样,当在绑定方法上调用时,它将返回具体类型;而在未绑定方法上调用时,则返回占位符类型。

您可以通过调用 GetMethodBody 检查方法的实现。这将返回一个 MethodBody 对象,该对象提供对 IL(作为字节数组)以及方法使用的局部变量定义的访问权限。

MethodInfo 类从 MethodBase 派生,仅表示方法(而不是构造函数)。它添加了一个 ReturnType 属性,该属性提供一个 Type 对象,指示方法的返回类型。(有一个特殊的系统类型 System.Void,其 Type 对象在方法返回空时使用。)

ConstructorInfo 类并未添加任何除了从 MethodBase 继承的属性以外的属性。但它确实定义了两个只读的静态字段:ConstructorNameTypeConstructorName。它们分别包含字符串 ".ctor"".cctor",这些是在 ConstructorInfo 对象的 Name 属性中找到的值,用于实例和静态构造函数。就 CLR 而言,这些是真实的名称——尽管在 C#中构造函数看起来与其包含的类型同名,但这只在您的 C#源文件中成立,在运行时则不是。

您可以通过调用 Invoke 方法来调用由 MethodInfoConstructorInfo 表示的方法或构造函数。这与 Type.InvokeMember 做相同的事情——示例 13-9 使用它来调用方法。但是,因为 Invoke 专门用于处理方法和构造函数,因此使用起来更简单。对于 ConstructorInfo,您只需传递一个参数数组。对于 MethodInfo,您还需要传递要调用方法的对象,或者如果要调用静态方法,则为 null。示例 13-11 执行与 示例 13-9 相同的任务,但使用 MethodInfo

示例 13-11. 调用方法
public static object? CreateAndInvokeMethod(
  string typeName, string member, params object[] args)
{
    Type t = Type.GetType(typeName)
        ?? throw new ArgumentException(
            $"Type {typeName} not found", nameof(typeName));
    object instance = Activator.CreateInstance(t)!;
    `MethodInfo` `m` `=` `t``.``GetMethod``(``member``)`
        ?? throw new ArgumentException(
            $"Method {member} not found", nameof(member));
    `return` `m``.``Invoke``(``instance``,` `args``)``;`
}

对于方法或构造函数,您可以调用 GetParameters 方法,该方法返回表示方法参数的 ParameterInfo 对象数组。

ParameterInfo

ParameterInfo 类表示方法或构造函数的参数。其 ParameterTypeName 属性提供了查看方法签名时的基本信息。它还定义了一个 Member 属性,该属性指回该参数所属的方法或构造函数。HasDefaultValue 属性将告诉您参数是否是可选的;如果是,DefaultValue 将提供在省略参数时使用的值。

如果您正在处理由非绑定泛型类型定义的成员,或者使用非绑定泛型方法,请注意 ParameterInfoParameterType 可能指的是泛型类型参数,而不是真实类型。这也适用于反射对象的 Type 对象,描述了接下来三个部分中所述的对象。

FieldInfo

FieldInfo 表示类型中的一个字段。通常使用 Type 对象的 GetFieldGetFields 方法获取它,或者如果您使用的是支持全局字段的语言编写的代码,可以从包含的 Module 中检索这些字段。

FieldInfo 定义了一组表示可访问性的属性。这些属性看起来与 MethodBase 定义的属性非常相似。此外,还有 FieldType 属性,表示字段可以包含的类型。 (正如在非绑定泛型类型中所属成员一样,这可能是指类型参数而不是特定类型。)还有一些属性提供有关字段的进一步信息,包括 IsStaticIsInitOnlyIsLiteral。这些属性分别对应于 C# 中的 staticreadonlyconst。 (表示枚举类型中的值的字段也将从 IsLiteral 返回 true。)

FieldInfo 定义了 GetValueSetValue 方法,让您可以读取和写入字段的值。这些方法接受一个参数,指定要使用的实例,或者如果字段是静态的,则为 null。与 MethodBase 类的 Invoke 方法一样,这些方法并不会执行 Type 类的 InvokeMember 所不能做的事情,但通常更为方便。

PropertyInfo

PropertyInfo类型表示一个属性。可以通过包含的Type对象的GetPropertyGetProperties方法获取这些属性。如前所述,PropertyInfo不定义任何可访问性属性,因为可访问性是在单独的获取和设置方法级别确定的。可以使用GetGetMethodGetSetMethod方法获取这些方法,这两者都返回MethodInfo对象。

就像FieldInfo一样,PropertyInfo类定义了GetValueSetValue方法,用于读取和写入值。属性允许带参数——例如,C#索引器就是带参数的属性。因此,有多个接受参数数组的GetValueSetValue的重载方法。此外,还有一个GetIndexParameters方法,返回一个ParameterInfo对象数组,表示使用属性所需的参数。通过PropertyType属性可以获取属性的类型。

EventInfo

事件由EventInfo对象表示,这些对象由Type类的GetEventGetEvents方法返回。与PropertyInfo类似,这些对象没有可访问性属性,因为事件的添加和移除方法分别定义了它们自己的可访问性。可以使用GetAddMethodGetRemoveMethod获取这些方法,这两者都返回MethodInfoEventInfo定义了一个EventHandlerType,返回事件处理程序需要提供的委托类型。

可以通过调用AddEventHandlerRemove​EventHandler方法附加和移除处理程序。与所有其他动态调用一样,这些方法只是Type类的InvokeMember方法的更便捷的替代品。

反射上下文

.NET 拥有一种称为反射上下文的功能。这些使得反射能够提供类型系统的虚拟化视图。通过编写自定义反射上下文,您可以修改类型的外观——可以使类型看起来具有额外的属性,或者可以增加成员和参数似乎提供的属性集。(第十四章将描述属性。)

反射上下文非常有用,因为它们使得可能编写基于反射驱动的框架,使得各个类型可以自定义它们的处理方式,而不需要强制每个类型提供显式支持。在.NET 4.5 之前,这是通过各种临时系统处理的。例如,考虑 Visual Studio 中的属性面板。这可以自动显示任何.NET 对象定义的公共属性,这些对象最终出现在设计表面(例如,您编写的任何 UI 组件)。即使对于不提供任何显式处理的组件,自动编辑支持也非常好,但组件应有机会在设计时自定义其行为。

因为属性面板早于.NET 4.5,它使用了一种临时解决方案之一:TypeDescriptor类。这是反射的一个封装,允许任何类通过实现ICustomTypeDescriptor来增强其设计时行为,使类能够定制其用于编辑的属性集,并控制它们的展示方式,甚至提供自定义的编辑 UI。这种方法灵活,但它的缺点是将设计时代码与运行时代码耦合在一起——使用这种模型的组件不能轻易地在没有提供设计时代码的情况下进行发布。因此,Visual Studio 引入了自己的虚拟化机制来分离这两者。

为了避免每个框架都定义自己的虚拟化系统,自定义的反射上下文直接将虚拟化功能添加到反射 API 中。如果您想编写能够使用反射提供的类型信息但也支持设计时增强或修改该信息的代码,现在不再需要使用某种包装层。您可以使用本章前面描述的通常的反射类型,但现在可以要求反射为您提供这些类型的不同实现,提供不同的虚拟化视图。

你可以通过编写自定义的反射上下文来实现这一点,描述你希望如何修改反射提供的视图。示例 13-12 展示了一个特别无聊的类型,随后是一个自定义的反射上下文,使该类型看起来像有一个属性。

示例 13-12. 一个简单类型,通过反射上下文增强
class NotVeryInteresting
{
}

class MyReflectionContext : CustomReflectionContext
{
    protected override IEnumerable<PropertyInfo> AddProperties(Type type)
    {
        if (type == typeof(NotVeryInteresting))
        {
            var fakeProp = CreateProperty(
                MapType(typeof(string).GetTypeInfo()),
                "FakeProperty",
                o => "FakeValue",
                (o, v) => Console.WriteLine($"Setting value: {v}"));

            return new[] { fakeProp };
        }
        else
        {
            return base.AddProperties(type);
        }
    }
}

直接使用反射 API 的代码将直接看到NotVeryInteresting类型,它没有任何属性。然而,我们可以通过MyReflectionContext映射该类型,就像示例 13-13 所示的那样。

示例 13-13. 使用自定义的反射上下文
var ctx = new MyReflectionContext();
TypeInfo mappedType = ctx.MapType(typeof(NotVeryInteresting).GetTypeInfo());

foreach (PropertyInfo prop in mappedType.DeclaredProperties)
{
    Console.WriteLine($"{prop.Name} ({prop.PropertyType.Name})");
}

变量mappedType保存了映射后的类型的引用。它看起来仍然像是普通的反射TypeInfo对象,我们可以通过DeclaredProperties以通常的方式迭代其属性,但因为我们通过自定义的反射上下文映射了类型,所以看到的是修改后的版本。这段代码的输出将显示该类型似乎定义了一个名为FakePropertystring类型属性。

概要

反射 API 使得能够编写其行为基于其所处理的类型结构的代码成为可能。这可能涉及根据对象提供的属性来决定在 UI 网格中显示哪些值,或者根据特定类型选择定义的成员来修改框架的行为。例如,ASP.NET Core Web 框架的部分将检测您的代码是否使用同步或异步编程技术,并相应地进行调整。这些技术需要能够在运行时检查代码,这正是反射所实现的。类型系统所需程序集中的所有信息都可以通过我们的代码访问。此外,通过编写自定义反射上下文,可以通过虚拟视图呈现这些信息,从而使得定制反射驱动代码的行为成为可能。

代码检查类型结构以驱动其行为的需要通常需要额外的信息。例如,System.Text.Json 命名空间包含在第十五章中描述的类型,可以在 .NET 对象和 JSON 文档之间进行转换。这些依赖于反射,但是通过在属性形式中提供额外信息,你可以更精确地控制其目的。这些将是下一章的主题。

¹ 基于后面讨论的历史原因,部分功能的子集在名为 TypeInfo 的派生类型中。但是基类 Type 类是你经常遇到的类型。

第十四章:属性

在.NET 中,您可以使用属性为组件、类型及其成员添加注解。属性的目的是控制或修改框架、工具、编译器或 CLR 的行为。例如,在 第一章 中,我展示了一个使用 [TestClass] 属性进行注释的类。这告诉单元测试框架,被注释的类包含一些作为测试套件一部分运行的测试。

属性是信息的被动容器,在自身不执行任何操作。可以通过与物理世界的类比来理解,如果您打印包含地址和跟踪信息的运输标签,并将其附加到包裹上,该标签本身不会导致包裹自行送达目的地。这样的标签只有在包裹交到运输公司手中时才有用。当公司取走您的包裹时,它会期望找到标签,并使用它来确定如何路由您的包裹。因此,标签很重要,但最终它的唯一工作是提供某些系统所需的信息。.NET 属性的工作方式与此类似——它们只有在某些东西正在寻找它们时才会发挥作用。某些属性由 CLR 或编译器处理,但这些只是少数。大多数属性被框架、库、工具(如单元测试运行器)或您自己的代码消耗。

应用属性

为了避免在类型系统中引入额外的概念,.NET 将属性建模为.NET 类型的实例。要作为属性使用,类型必须派生自 System.Attribute 类,但除此之外,它可以是完全普通的。要应用属性,您将类型名称放在方括号中,这通常直接放在属性的目标之前。(由于 C#大多数情况下忽略空白字符,当目标是类型或成员时,属性不必位于单独的行上,但这是约定俗成的。)示例 14-1 展示了来自微软测试框架的一些属性。我已经将一个应用到类上,以指示这个类包含我希望运行的测试,并且还将属性应用到单独的方法上,告知测试框架哪些方法代表测试,哪些包含要在每次测试前运行的初始化代码。

示例 14-1 单元测试类中的属性
using Microsoft.VisualStudio.TestTools.UnitTesting;

namespace ImageManagement.Tests;

`[TestClass]`
public class WhenPropertiesRetrieved
{
    private ImageMetadataReader? _reader;

    `[TestInitialize]`
    public void Initialize()
    {
        _reader = new ImageMetadataReader(TestFiles.GetImage());
    }

    `[TestMethod]`
    public void ReportsCameraMaker()
    {
        Assert.AreEqual(_reader!.CameraManufacturer, "Fabrikam");
    }

    `[TestMethod]`
    public void ReportsCameraModel()
    {
        Assert.AreEqual(_reader!.CameraModel, "Fabrikam F450D");
    }
}

如果您查看大多数属性的文档,您会发现它们的实际名称以 Attribute 结尾。如果在括号中指定的名称没有对应的类,C# 编译器会尝试添加 Attribute,所以 示例 14-1 中的 [TestClass] 属性指的是 TestClassAttribute 类。如果您确实希望如此,可以完整拼写类名,例如 [TestClassAttribute],但更常见的是使用缩写版本。

如果你想应用多个属性,有两种选择。你可以提供多组括号,或者将多个属性放在单一对括号内,用逗号分隔。

一些属性类型可以接受构造函数参数。例如,微软的测试框架包含一个TestCategoryAttribute。在运行测试时,您可以选择仅执行某个类别中的测试。此属性要求您将类别名称作为构造函数参数传递,因为如果不指定名称,则应用此属性没有意义。正如 Example 14-2 所示,指定属性的构造函数参数的语法并不令人意外。

Example 14-2. 带构造函数参数的属性
`[TestCategory("Property Handling")]`
[TestMethod]
public void ReportsCameraMaker()
{
    ...

你也可以指定属性或字段的值。一些属性具有只能通过属性或字段控制的特性,而不是构造函数参数。(如果一个属性有很多可选设置,通常更容易将这些设置呈现为属性或字段,而不是为每种可能的设置组合定义构造函数重载。)其语法是在构造函数参数之后写一个或多个*PropertyOrFieldName*=*Value*条目(如果没有构造函数参数,则是代替它们)。Example 14-3 展示了另一个在单元测试中使用的属性,ExpectedExceptionAttribute,它允许你指定在测试运行时期望它抛出特定异常。异常类型是必需的,因此我们将其作为构造函数参数传递,但这个属性还允许你指定测试运行器是否接受从指定类型派生的异常。 (默认情况下,它只接受完全匹配。)这由AllowDerivedTypes属性控制。

Example 14-3. 使用属性指定可选的属性设置
`[ExpectedException(typeof(ArgumentException), AllowDerivedTypes = true)]`
[TestMethod]
public void ThrowsWhenNameMalformed()
{
    ...

应用属性并不会导致它们被实例化。当你应用一个属性时,你所做的只是提供关于如何创建和初始化该属性的指令,如果有需要的话。(有一种常见的误解认为方法属性在方法运行时会被实例化,其实不然。)当编译器为程序集构建元数据时,它会包含关于已应用到哪些项上的属性的信息,包括构造函数参数和属性值的列表,CLR 只有在有需要的时候才会提取出来使用。例如,当你要求 Visual Studio 运行你的单元测试时,它会加载你的测试程序集,然后对每个公共类型,它会向 CLR 查询任何与测试相关的属性。这就是属性被构造的时机。如果你只是简单地加载程序集,比如从另一个项目添加引用然后使用它包含的一些类型,属性就不会存在——它们只会保留在元数据中,作为一组冻结在程序集元数据中的构建指令。

属性目标

属性可以应用于许多不同类型的目标。你可以在反射 API 中展示的类型系统特性的任何地方放置属性,就像我在第十三章中展示的那样。具体来说,你可以将属性应用于程序集、模块、类型、方法、方法参数、构造函数、字段、属性、事件和泛型类型参数。此外,你还可以提供目标为方法返回值的属性。

对于大多数情况,你只需在目标前面放置属性即可标识目标。但对于程序集或模块来说不是这样,因为在你的源代码中没有单个特性代表它们——你项目中的所有内容都会进入它生成的程序集中,并且模块同样是一个集合体(通常构成整个程序集,就像我在第十二章中描述的那样)。因此,对于这些情况,我们必须在属性开头明确说明目标。你经常会在像GlobalSuppressions.cs文件中看到类似示例 14-4 所示的程序集级别属性。Visual Studio 有时会建议修改你的代码,如果你选择抑制这些警告,它就会使用程序集级别的属性来实现。

示例 14-4. 程序集级别属性
[assembly: System.Diagnostics.CodeAnalysis.SuppressMessage(
 "Style",
 "IDE0060:Remove unused parameter",
 Justification = "This is just some example code from a book",
 Scope = "member",
 Target = "~M:Idg.Examples.SomeMethod")]

你可以在任何文件中放置程序集级别的属性。唯一的限制是它们必须出现在任何命名空间或类型定义之前。程序集级别属性之前应该出现的只有你需要的using指令、注释和空白(这些都是可选的)。

模块级别的属性遵循相同的模式,尽管它们非常少见,主要是因为多模块程序集非常罕见,并且在最新版本的.NET 中不受支持——它们仅适用于.NET Framework。示例 14-5 展示了如何配置特定模块的调试性,如果您希望多模块程序集中的一个模块易于调试,但其余模块带有完全优化的 JIT 编译,则可以使用此功能。(这是一个假设的场景,我只是为了展示语法。实际上,您不太可能需要这样做。)我稍后会讨论DebuggableAttribute,在“JIT 编译”中。

示例 14-5. 模块级别属性
using System.Diagnostics;

[module: Debuggable(DebuggableAttribute.DebuggingModes.DisableOptimizations)]

另一种需要资格证书的目标是编译器生成的字段。当您在不为 getter 或 setter 提供代码的属性中使用时,以及在没有显式的addremove实现的event成员中使用时,您会得到这些字段。示例 14-6 中的属性应用于保存属性值的字段和事件委托;如果没有field:限定符,这些位置的属性将适用于属性或事件本身。

示例 14-6. 编译器生成的属性和事件字段的属性
[field: NonSerialized]
public int DynamicId { get; set; }

[field: NonSerialized]
public event EventHandler? Frazzled;

方法的返回值可以进行注释,这也需要资格证书,因为返回值属性放在方法前面,与适用于方法本身的属性位于同一位置。(参数的属性不需要资格证书,因为这些属性与参数一起出现在括号内。)示例 14-7 展示了一个同时应用于方法和返回类型的属性的方法。(本示例中的属性是支持互操作服务的一部分,这些服务使.NET 代码能够调用外部代码,例如操作系统 API。此示例导入了来自 Win32 DLL 的函数,使您能够从 C#中使用它。非托管代码中存在几种不同的布尔值表示形式,因此我在此处用MarshalAsAttribute对返回类型进行了注释,以说明 CLR 应该期望哪个特定形式。)

示例 14-7. 方法和返回值属性
[DllImport("User32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
static extern bool IsWindowVisible(HandleRef hWnd);

那么对于我们不显式编写方法声明的情况呢?正如您在第九章中看到的那样,Lambda 语法允许我们编写一个值为委托的表达式。编译器会生成一个正常的方法来保存代码(通常是在一个隐藏的类中),我们可能希望将该方法传递给使用属性来控制其功能的框架,例如 ASP.NET Core Web 框架。示例 14-8 展示了在使用 Lambda 时如何指定这些属性。

示例 14-8. 带属性的 Lambda
app.MapGet(
    "/items/{id}",
 [Authorize] ([FromRoute] int id) => $"Item {id} requested");

MapGet 方法告诉 ASP.NET Core 框架应该在收到与特定模式匹配的 URL 的 GET 请求时如何行为。第一个参数指定模式,第二个是定义行为的委托。我在这里使用了 lambda 语法,并应用了一些属性。

第一个属性是 [Authorize]。它出现在参数列表之前,因此其目标是整个方法。(您还可以在此位置使用 return: 属性。)这会导致 ASP.NET Core 阻止未经身份验证的请求匹配此 URL 模式。[FromRoute] 属性位于参数列表的括号内,因此它适用于 id 参数,并告诉 ASP.NET Core 我们希望从 URL 模式中同名表达式获取该特定参数的值。因此,如果请求 https://myserver/items/42 进来,ASP.NET Core 首先会检查请求是否符合应用程序配置的身份验证和授权要求,如果符合,然后会调用我的 lambda,并将 42 作为 id 参数传递。

注意

例子 9-22 在 第九章 中展示了在某些情况下可以省略细节。参数列表周围的括号通常对于单参数 lambda 是可选的。但是,如果您要将属性应用于 lambda,则括号 必须 存在。要看清楚原因,请想象如果 例子 14-8 在参数列表周围没有括号:那么不清楚属性是应用于方法还是参数。

编译器处理的属性

C# 编译器识别特定的属性类型,并以特殊方式处理它们。例如,程序集的名称和版本是通过属性设置的,还有一些关于您的程序集的相关信息。正如第十二章所述,在现代 .NET 项目中,构建过程会为您生成一个隐藏的源文件,其中包含这些信息。如果您感兴趣,它通常会出现在项目的 obj\Debugobj\Release 文件夹中,并且名称通常类似于 YourProject.AssemblyInfo.cs。例子 14-9 展示了一个典型的示例。

例 14-9. 具有程序集级属性的典型生成文件
//------------------------------------------------------------------------------
// <auto-generated>
//     This code was generated by a tool.
//     Runtime Version:4.0.30319.42000
//
//     Changes to this file may cause incorrect behavior and will be lost if
//     the code is regenerated.
// </auto-generated>
//------------------------------------------------------------------------------

using System;
using System.Reflection;

[assembly: System.Reflection.AssemblyCompanyAttribute("MyCompany")]
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0")]
[assembly: System.Reflection.AssemblyProductAttribute("MyApp")]
[assembly: System.Reflection.AssemblyTitleAttribute("MyApp")]
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]

// Generated by the MSBuild WriteCodeFragment class.

旧版本的.NET Framework SDK 在构建时未生成此文件,因此如果您处理旧项目,您经常会在名为AssemblyInfo.cs的文件中找到这些属性。(默认情况下,Visual Studio 将其隐藏在解决方案资源管理器中项目的属性节点中,但它仍然只是一个普通的源文件。)现代项目中使用的文件生成优势在于,名称不太可能不同步。例如,默认情况下,程序集产品和标题将与项目文件名相同。如果重新命名项目文件,则生成的YourRenamedProject.AssemblyInfo.cs将相应更改(除非您向项目文件添加了<Product><AssemblyTitle>属性,在这种情况下它将使用这些属性),而旧的AssemblyInfo.cs方法则可能会意外导致名称不匹配。同样,如果从项目构建 NuGet 包,某些属性最终会出现在 NuGet 包和编译的程序集中。当这些都是从项目文件中的信息生成时,更容易保持一致性。

即使您只间接控制这些属性,理解它们也很有用,因为它们会影响编译器输出。

名称和版本

正如您在第十二章看到的,程序集具有复合名称。简单名称通常与文件名相同,但不包括*.exe.dll*扩展名,作为项目设置的一部分进行配置。名称还包括版本号,并且通过属性控制,如示例 14-10 所示。

示例 14-10. 版本属性
[assembly: AssemblyVersion("1.0.0.0")]
[assembly: AssemblyFileVersion("1.0.0.0")]

正如您可能从第十二章回忆起的,第一个集合设置了程序集名称的版本部分。第二个与.NET 无关——编译器使用它来生成 Win32 风格的版本资源。这是最终用户在 Windows 资源管理器中选择您的程序集并打开属性窗口时看到的版本号。

区域性也是程序集名称的一部分。如果您正在使用第十二章描述的卫星资源程序集机制,通常会自动设置这一点。您可以使用AssemblyCulture属性显式设置它,但对于非资源程序集,通常不应设置区域性。(您通常会显式指定的唯一与文化相关的程序集级属性是我在第十二章展示的NeutralResourcesLanguageAttribute。)

强名称程序集在其名称中有一个额外的组件:公钥标记。在 Visual Studio 中设置强名称的最简单方法是使用项目属性页中“强名称”部分(位于“生成”部分内)。如果您正在使用 VS Code 或其他编辑器,您可以简单地在您的 .csproj 文件中添加两个属性:SignAssembly 设置为 True,并且 AssemblyOriginatorKeyFile 设置为您密钥文件的路径。然而,您也可以通过源代码管理强命名,因为编译器识别一些特殊的属性用于此目的。AssemblyKeyFileAttribute 接受包含密钥的文件名。或者,您可以将密钥安装到计算机的密钥存储区(这是 Windows 加密系统的一部分)。如果您想这样做,您可以使用 AssemblyKeyNameAttribute。这两个属性中的任何一个都会导致编译器将公钥嵌入程序集并将该密钥的哈希值作为强名称的公钥标记包含在其中。如果密钥文件包含私钥,编译器还会为您的程序集签名。如果不包含私钥,则编译器将无法编译,除非您还启用了延迟签名或公共签名。您可以通过应用带有参数trueAssemblyDelaySignAttribute来启用延迟签名。或者,您可以将<DelaySign>true</DelaySign><PublicSign>true</PublicSign>添加到您的 .csproj 文件中。

警告

尽管与密钥相关的属性触发编译器的特殊处理,但它们仍然像普通属性一样嵌入到元数据中。因此,如果您使用AssemblyKeyFileAttribute,则您密钥文件的路径将显示在最终编译输出中。这并不一定是问题,但您可能更喜欢不公开这些细节,因此使用项目级别的强名称配置可能比基于属性的方法更好。

描述及相关资源

AssemblyFileVersion属性生成的版本资源并非 C# 编译器可以嵌入到 Win32 样式资源中的唯一信息。还有几个其他属性提供版权信息和其他描述性文本。示例 14-11 展示了典型的选择。

Example 14-11. 典型的程序集描述属性
[assembly: AssemblyTitle("ExamplePlugin")]
[assembly: AssemblyDescription("An example plug-in DLL")]
[assembly: AssemblyConfiguration("Retail")]
[assembly: AssemblyCompany("Endjin Ltd.")]
[assembly: AssemblyProduct("ExamplePlugin")]
[assembly: AssemblyCopyright("Copyright © 2022 Endjin Ltd.")]
[assembly: AssemblyTrademark("")]

与文件版本一样,所有这些信息都可以在文件的属性窗口的详细信息选项卡中看到,这是 Windows Explorer 可以显示的。对于所有这些属性,您都可以通过编辑项目文件来生成它们。

调用者信息属性

有一些编译器处理的属性专为您的方法需要有关其被调用上下文信息的场景而设计。这在某些诊断日志记录或错误处理场景中很有用,当然,在实现通常在 UI 代码中使用的特定接口时也非常有帮助。

示例 14-12 说明了如何在日志记录代码中使用这些属性。如果你用这三个属性中的任何一个注释方法参数,编译器在调用者省略参数时会进行一些特殊处理。我们可以请求调用带有注释方法的成员(方法或属性)的名称,调用该方法的包含代码的文件名,或者调用发生的行号。示例 14-12 请求了这三者,但你可以更有选择地使用。

注意

这些属性仅允许用于可选参数。可选参数需要指定默认值,但是当这些属性存在时,C#会始终替换为不同的值,因此你指定的默认值在从 C#(或支持这些属性的 Visual Basic)调用方法时不会被使用。尽管如此,你必须提供一个默认值,因为没有默认值时,参数就不是可选的,所以我们通常使用空字符串、null或数字0

示例 14-12. 将调用者信息属性应用于方法参数
public static void Log(
    string message,
 [CallerMemberName] string callingMethod = "",
 [CallerFilePath] string callingFile = "",
 [CallerLineNumber] int callingLineNumber = 0)
{
    Console.WriteLine("Message {0}, called from {1} in file '{2}', line {3}",
        message, callingMethod, callingFile, callingLineNumber);
}

如果在调用此方法时提供了所有参数,那么不会发生任何异常。但是如果省略了任何可选参数,C#会生成代码来提供关于调用该方法的位置的信息。在示例 14-12 中,三个可选参数的默认值将是调用此Log方法的方法或属性的名称,包含调用方法的源代码的完整路径,以及调用Log的行号。

CallerMemberName属性与我们在第八章中看到的nameof运算符有表面上的相似之处。两者都导致编译器创建包含代码某些特性名称的字符串,但它们的工作方式完全不同。使用nameof,你总是知道会得到什么字符串,因为它由你提供的表达式决定(例如,在示例 14-12 中的Log中写入nameof(message),它将始终评估为"message")。但是CallerMemberName改变了应用它们的方法被编译器调用的方式——cal⁠lin⁠g​Met⁠hod有该属性,其值不是固定的。它将取决于调用此方法的位置。

注意

你可以另一种方式发现调用方法:System.Diagnostics命名空间中的StackTraceStackFrame类可以报告调用堆栈中的上层方法的信息。然而,这些方法在运行时开销较高——调用者信息属性在编译时计算值,因此运行时开销非常低(nameof也是如此)。此外,StackFrame只能在存在调试符号时确定文件名和行号。

虽然诊断日志记录是显而易见的应用,我还提到了大多数.NET UI 开发人员熟悉的某种场景。运行时库定义了一个称为INotifyPropertyChanged的接口。正如示例 14-13 所示,这是一个非常简单的接口,只有一个成员,一个称为PropertyChanged的事件。

示例 14-13. INotifyPropertyChanged
public interface INotifyPropertyChanged
{
    event PropertyChangedEventHandler? PropertyChanged;
}

实现此接口的类型在其属性更改时引发PropertyChanged事件。PropertyChangedEventArgument提供一个字符串,其中包含刚刚更改的属性的名称。这些更改通知在 UI 中非常有用,因为它们使对象能够与数据绑定技术(例如.NET 的 WPF UI 框架提供的技术)一起使用,该技术可以在属性更改时自动更新 UI。数据绑定可以帮助您实现 UI 类型直接处理的代码与包含决定应用程序如何响应用户输入的逻辑的代码之间的清晰分离。

实现INotifyPropertyChanged可能既繁琐又容易出错。因为PropertyChanged事件以字符串形式指示哪个属性已更改,所以很容易拼写错误属性名称,或者在复制和粘贴实现时意外使用错误的名称。另外,如果重命名属性,很容易忘记更改事件的文本,这意味着以前正确的代码现在在引发PropertyChanged事件时会提供错误的名称。nameof运算符有助于避免拼写错误,并有助于重命名,但无法始终检测到复制和粘贴错误。(例如,在同一类的属性之间粘贴代码时,它不会注意到您未更新名称的情况。)

调用者信息属性可以帮助减少实现此接口时的错误。您可以参考示例 14-14,该示例显示了一个实现INotifyPropertyChanged的基类,提供了一个在利用这些属性之一时引发更改通知的帮助程序。(它还使用了空值条件运算符?.,以确保仅在委托非空时才调用事件的委托。顺便说一下,当您以这种方式使用运算符时,C#会生成只在委托非空时才评估委托的Invoke方法参数的代码。因此,它会跳过当委托为空时的调用Invoke,还会避免构造将作为参数传递的Pro⁠per⁠ty​Cha⁠nge⁠dEv⁠ent⁠Args。)此代码还会检测值是否真的已更改,仅在这种情况下引发事件,并且其返回值指示是否发生了更改,以防调用者可能会发现这有用。

示例 14-14. 可重用的INotifyPropertyChanged实现
public class NotifyPropertyChanged : INotifyPropertyChanged
{
    public event PropertyChangedEventHandler? PropertyChanged;

    protected bool SetProperty<T>(
        ref T field,
        T value,
 [CallerMemberName] string propertyName = "")
    {
        if (Equals(field, value))
        {
            return false;
        }

        field = value;

        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
        return true;
    }
}

存在[CallerMemberName]属性意味着从此类型派生的类如果在属性设置器内部调用SetProperty,则无需指定属性名称,如 Example 14-15 所示。

Example 14-15. 触发属性更改事件
public class MyViewModel : NotifyPropertyChanged
{
    private string? _name;

    public string? Name
    {
        get => _name;
        set => SetProperty(ref _name, value);
    }
}

即使有了新的属性,实现INotifyPropertyChanged显然比自动属性更费力,其中您只需编写{ get; set; }并让编译器为您完成工作。但它只比显式实现的简单字段支持属性复杂一点点,并且比没有[CallerMemberName]时简单,因为我能够在请求基类触发事件时省略属性名称。更重要的是,它更少容易出错:现在我可以确信每次都会使用正确的名称,即使将来某个时候重命名属性。

.NET 6.0 添加了一个新的调用者信息属性:CallerArgumentExpression。Example 14-16 展示了运行时库的ArgumentNullException类的摘录。它声明了一个使用此属性的ThrowIfNull方法。

Example 14-16. 在ArgumentNullException.ThrowIfNull中的CallerArgumentExpressionAttribute
public class ArgumentNullException
{
    public static void ThrowIfNull(
 [NotNull] object? argument,
 [CallerArgumentExpression("argument")] string? paramName =  null)
    {
...

正如您所见,CallerArgumentExpression属性接受一个字符串参数。这必须是同一方法中另一个参数的名称——在本例中只有一个叫做argument,因此必须引用它。其效果是,如果调用此方法而没有为注释的paramName参数提供值,C#编译器将传递包含该属性标识的参数的精确表达式的字符串。Example 14-17 展示了ThrowIfNull方法的典型调用方式。

Example 14-17. 调用使用CallerArgumentExpressionAttribute的方法
static void Greet(string greetingRecipient)
{
    ArgumentNullException.ThrowIfNull(greetingRecipient);
    Console.WriteLine($"Hello, {greetingRecipient}");
}

Greet("world");
Greet(null!);

Greet方法需要greetingRecipient不为 null,因此调用Arg⁠ume⁠nt​Nul⁠lEx⁠cep⁠tio⁠n.T⁠hro⁠wIf⁠Null,传入greetingRecipient。因为这段代码没有为ThrowIfNull提供第二个参数,编译器将提供我们用于第一个参数的表达式的完整文本。在这种情况下,即是"gre⁠eti⁠ng​Rec⁠ipi⁠ent"。因此,当运行此程序时,效果是抛出一个带有此消息的Arg⁠ume⁠nt​Nul⁠lEx⁠cep⁠tio⁠n

Value cannot be null. (Parameter 'greetingRecipient')

在 C# 10.0 之前,我们通常会使用nameof(greetingRecipient)来告诉ArgumentNullException有问题的参数名称。这种新技术防止了一种特定的错误:以前在抛出异常时很容易传递错误的参数名称。(如果您需要检查多个参数是否为 null,复制和粘贴相关检查会提供大量机会来犯这种错误。)

此属性支持的一个场景是改进断言消息。例如,单元测试库通常提供机制,用于在测试代码执行后断言某些条件是否为真。其思想是,如果您的测试包含类似 Assert.IsTrue(answer == 42); 的代码,测试库可以使用 [CallerArgumentExpression] 来在失败时报告确切的表达式 (answer == 42)。

您可能期望运行时库中的 Debug.Assert 方法以类似的方式使用此属性。但是,要使用 CallerArgumentExpressionAttribute,您必须添加一个参数来接收表达式文本(除了接收表达式值的现有参数之外),因此这不是一个二进制兼容的更改。新的 ThrowIfNull 方法是.NET 6.0 运行时库唯一使用此属性的地方,在我写这篇文章时,微软的测试框架的 NuGet 包尚未使用此属性。但是很可能测试框架会在未来采用这一特性。

CLR-Handled Attributes

CLR 在运行时会对某些属性进行特殊处理。目前还没有官方的全面属性列表,所以在接下来的几节中,我将简要描述一些最常用的例子。

InternalsVisibleToAttribute

可以将 InternalsVisibleToAttribute 应用于一个程序集,以声明其定义的任何 internal 类型或成员应该对一个或多个其他程序集可见。这种属性的一个常见用途是启用内部类型的单元测试。正如 示例 14-18 所示,您可以将程序集的名称作为构造函数参数传递。

注意

强名称会使事情变得更复杂。强名称的程序集无法将其内部成员对不强命名的程序集可见,反之亦然。当一个强命名的程序集将其内部成员对另一个强命名的程序集可见时,它必须指定不仅仅是简单名称,还包括要授予访问权限的程序集的公钥。这不仅仅是我在 第十二章 中描述的公钥令牌——它是整个公钥的十六进制表示,可能有数百位数字。您可以使用 .NET SDK 的 sn.exe 实用程序,使用 -Tp 开关,后跟程序集的路径,来发现程序集的完整公钥。

示例 14-18. InternalsVisibleToAttribute
[assembly:InternalsVisibleTo("ImageManagement.Tests")]
[assembly:InternalsVisibleTo("ImageServices.Tests")]

这表明您可以通过多次应用该属性并每次使用不同的程序集名称,使类型对多个程序集可见。

CLR 负责执行访问规则。通常,如果您尝试从另一个程序集使用内部类,您将在运行时收到错误。(C# 甚至不允许您编译这样的代码,但是有可能欺骗编译器。或者您可以直接编写 IL。IL 汇编器 ILASM 会执行您告诉它的操作,并且比 C# 实施的限制少得多。一旦您通过了编译时的限制,然后您将遇到运行时限制。) 但是,当存在此属性时,CLR 放宽了对您列出的程序集的规则。编译器也理解此属性,并允许尝试使用外部定义的内部类型的代码编译,只要外部库在 InternalsVisibleToAttribute 中命名了您的程序集。

除了在单元测试场景中很有用外,这个属性还可以在您想要将代码分散到多个程序集的情况下帮助您。如果您编写了一个大型类库,可能不想将其放入一个庞大的 DLL 中。如果它有几个区域是您的客户可能想要单独使用的,那么将它分割开来以便他们可以部署他们需要的部分就有意义了。但是,尽管您可能可以对库的公共 API 进行分区,但实现可能并不容易分割,特别是如果您的代码库执行大量重用。您可能有许多不设计为公共消费的类,但却在整个代码中使用。

如果没有InternalsVisibleToAttribute,跨程序集重用共享实现细节将会很尴尬。每个程序集都需要包含其相关类的副本,或者您需要在某个公共程序集中将它们作为公共类型。第二种技术的问题在于,将类型公开实际上邀请人们使用它们。您的文档可能说明这些类型是框架内部使用的,不应该使用,但这并不能阻止某些人。

幸运的是,您不必将它们设为 public。任何只是实现细节的类型可以保持 internal,并且您可以使用 InternalsVisibleToAttribute 将它们对所有程序集可见,同时使它们对其他人不可访问。

JIT 编译

影响 JIT 编译器生成代码的几个属性。您可以将MethodImplAttribute应用于方法,并传递Met⁠hod​Imp⁠lOp⁠tions枚举中的值。其NoInlining值确保每当其他方法调用您的方法时,它将成为完整的方法调用。如果没有这个选项,JIT 编译器有时会直接将方法的代码复制到调用代码中。

一般来说,您应该保留内联功能。JIT 编译器仅内联小方法,对于诸如属性访问器之类的简单小方法尤为重要。对于基于字段的简单属性,通过普通函数调用调用访问器通常需要更多代码,而内联优化则可能产生更小、更快的代码。即使代码大小没有减小,它仍然可能更快,因为函数调用可能会意外地昂贵。现代 CPU 倾向于更有效地处理长顺序指令流,而不是跳跃执行位置的代码。然而,内联是一个带有可观测副作用的优化——内联方法不会得到自己的堆栈帧。之前我提到过一些诊断 API,您可以使用它们来检查堆栈,内联将改变报告的堆栈帧数量。如果您只想询问“是哪个方法在调用我?”,则前面描述的调用者信息属性提供了一种更有效的发现方法,不会受内联的影响,但如果您有检查堆栈的代码,有时可能会因内联而混淆。因此,偶尔禁用它是有用的。

相反,您可以指定 AggressiveInlining,这鼓励 JIT 编译器内联那些通常作为普通方法调用的内容。如果您确定某个方法对性能非常敏感,尝试这个设置可能会产生不同效果,尽管请注意它可能使代码变慢或变快——这取决于具体情况。相反,您可以使用 NoOptimization 选项禁用所有优化(尽管文档暗示这更多是为了微软的 CLR 团队而不是为了消费者,因为它是为了“调试可能的代码生成问题”)。

另一个对优化产生影响的属性是 DebuggableAttribute。在调试版本中,C# 编译器会自动将此属性应用到您的程序集中。该属性告诉 CLR 在某些优化方面要更加谨慎,特别是那些影响变量生命周期和代码执行顺序的优化。通常情况下,编译器可以自由更改这些内容,只要最终代码的结果相同即可,但是如果您在调试器中打断点,可能会引起混乱。这个属性确保在这种情况下变量值和执行流程容易跟踪。

STAThread 和 MTAThread

只运行在 Windows 上并呈现 UI 的应用程序(例如使用 .NET 的 WPF 或 Windows Forms 框架的任何应用程序)通常在它们的 Main 方法上有 [STAThread] 属性(尽管您不总是看到它,因为入口点通常由这些应用程序的构建系统生成)。这是对 CLR 的互操作服务的一条指令,用于组件对象模型 (COM),但它有更广泛的影响:如果您希望主线程托管 UI 元素,则需要在 Main 方法上放置此属性。

各种 Windows UI 功能都依赖于 COM。例如,剪贴板使用它,某些类型的控件也是如此。COM 有几种线程模型,只有一种与 UI 线程兼容。其中一个主要原因是 UI 元素具有线程关联性,因此 COM 需要确保在正确的线程上执行某些工作。此外,如果 UI 线程不定期检查消息并处理它们,则可能发生死锁。如果您不告诉 COM 特定线程是 UI 线程,它将省略这些检查,您将遇到问题。

注意

即使你不编写 UI 代码,某些互操作场景需要 [STAThread] 属性,因为某些 COM 组件在没有它的情况下无法工作。不过,UI 工作是看到它的最常见原因。

由于 CLR 为您管理 COM,CLR 需要知道它应告诉 COM 特定线程需要作为 UI 线程处理。当您显式地使用 Chapter 16 中显示的技术创建新线程时,可以配置其 COM 线程模式,但主线程是一个特例 —— CLR 在应用程序启动时为您创建它,当您的代码运行时,配置线程已经太迟了。在 Main 方法上放置 [STAThread] 属性告诉 CLR 您的主线程应初始化为兼容 UI 的 COM 行为。

STA 是 单线程单元 的缩写。参与 COM 的线程总是属于 STA 或 多线程单元 (MTA) 中的一种。还有其他类型的单元,但线程只能在其中暂时成员;当线程开始使用 COM 时,必须选择 STA 或 MTA 模式。因此,也有一个 [MTAThread] 属性。

互操作

CLR 的互操作服务定义了许多属性。其中大多数由 CLR 直接处理,因为互操作是运行时的内在特性。由于这些属性只在它们支持的机制上下文中有意义,并且数量众多,我在这里不会详细描述它们,但 Example 14-19 展示了它们可以做的事情类型。

Example 14-19. 互操作属性
[DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true,
 EntryPoint = "LookupPrivilegeValueW")]
internal static extern bool LookupPrivilegeValue(
 [MarshalAs(UnmanagedType.LPWStr)] string lpSystemName,
 [MarshalAs(UnmanagedType.LPWStr)] string lpName,
    out LUID lpLuid);

这里使用了我们之前在示例 14-7 中看到的两个互操作属性,但使用方式稍显复杂。这调用了advapi32.dll中的一个函数,它是 Win32 API 的一部分。DllImport 属性的第一个参数告诉我们这一点,但与之前的示例不同,这还提供了互操作层的额外信息。这个 API 处理字符串,所以互操作需要知道使用的字符表示法。这个特定的 API 使用了一个常见的 Win32 习惯:它返回一个布尔值以指示成功或失败,但在失败情况下它还使用了 Windows 的 SetLastError API 提供更多信息。属性的 SetLastError 属性告诉互操作层在调用此 API 后立即检索这些信息,以便 .NET 代码在必要时进行检查。EntryPoint 属性处理了 Win32 API 可能有两种形式的字符串参数的事实,可以使用 8 位或 16 位字符(Windows 95 仅支持 8 位文本以节省内存),而我们希望调用Wide形式(因此使用 W 后缀)。然后,在两个字符串参数上使用 MarshalAs 来告诉互操作层,非托管代码中的许多不同字符串表示中,这个特定的 API 需要哪一种。

定义和使用属性

绝大多数属性并非运行时或编译器本身固有的,而是由类库定义的,并且只有在使用相关的类库或框架时才会起作用。在你自己的代码中,你可以自由地做完全相同的事情——你可以定义自己的属性类型。因为属性本身不会自行执行任何操作——除非有什么东西要求查看它们,它们甚至不会被实例化——所以通常只在你编写某种类型的框架时定义属性类型才有用,尤其是那些受反射驱动的框架。

例如,单元测试框架经常通过反射发现你编写的测试类,并允许你使用属性控制测试运行程序的行为。另一个例子是 Visual Studio 如何利用反射发现设计表面上可编辑对象(如 UI 控件)的属性,并且它将查找某些属性,这些属性使你可以自定义编辑行为。属性的另一个应用是选择性地排除静态代码分析工具应用的规则。(.NET SDK 包含用于检测代码潜在问题的内置工具。这是一个可扩展的系统,NuGet 包可以添加扩展分析器,扩展这些工具,从而检测特定库可能出现的常见错误。)有时这些工具会出错,你可以通过用属性标注代码来抑制它们的警告。

这里的共同主题是,某些工具或框架会检查你的代码,并根据发现的内容决定要做什么。这种情况正是属性非常适合的场景。例如,如果你编写一个允许最终用户扩展的应用程序,属性可能会很有用。你可能支持加载扩展你应用程序行为的外部程序集——这通常被称为插件模型。定义一个允许插件提供有关自身描述信息的属性可能会很有用。使用属性并不是绝对必要的——你可能会定义至少一个接口,所有插件都必须实现该接口,并且可以在该接口中定义用于检索必要信息的成员。然而,使用属性的一个优点是,你不需要创建插件的实例来检索描述信息。这样可以在加载插件之前向用户显示插件的详细信息,如果构建插件可能会产生用户不想要的副作用,这可能很重要。

属性类型

示例 14-20 展示了一个包含有关插件信息的属性可能是什么样子。

示例 14-20. 一个属性类型
[AttributeUsage(AttributeTargets.Class)]
public class PluginInformationAttribute : Attribute
{
    public PluginInformationAttribute(string name, string author)
    {
        Name = name;
        Author = author;
    }

    public string Name { get; }

    public string Author { get; }

    public string? Description { get; set; }
}

要作为属性,类型必须派生自Attribute基类。虽然Attribute定义了各种静态方法来发现和检索属性,但对于实例来说并没有提供太多有趣的内容。我们并不是从中派生出任何特定功能;我们这样做是因为编译器不会让你使用一个类型作为属性,除非它派生自Attribute

注意到我的类型名称以Attribute一词结尾。这不是绝对要求,但这是一个被广泛使用的约定。正如你之前看到的,即使在应用属性时忘记添加Attribute后缀,编译器也会自动添加。因此,通常没有理由不遵循这个约定。

我已经用一个属性注释了我的属性类型。大多数属性类型都使用AttributeUsageAttribute进行注释,指示属性可以有用地应用到哪些目标上。C#编译器将强制执行这一点。由于我在示例 14-20 中的属性声明只能应用于类,如果有人尝试将其应用于其他任何东西,编译器将生成错误。

注意

正如您所见,有时候当我们应用一个属性时,我们需要说明它的目标。例如,当一个属性出现在方法之前时,它的目标就是该方法,除非您用return:前缀加以限定。您可能希望在使用只能针对特定成员的属性时,能够省略这些前缀。例如,如果一个属性只能应用于程序集,您真的需要assembly:限定符吗?然而,C# 不允许您省略它。它仅使用AttributeUsageAttribute来验证属性未被错误应用。

我的属性只定义了一个构造函数,因此任何使用它的代码都必须传递构造函数所需的参数,就像示例 14-21 所示。

Example 14-21. 应用属性
[PluginInformation("Reporting", "Endjin Ltd.")]
public class ReportingPlugin
{
    ...
}

属性类可以自由地定义多个构造函数重载,以支持不同的信息集。它们还可以定义属性作为支持可选信息的一种方式。我的属性定义了一个Description属性,这不是必需的,因为构造函数不要求为其提供值,但我可以使用本章前面描述的语法来设置它。示例 14-22 展示了我属性的样子。

Example 14-22. 为属性提供可选的属性值
[PluginInformation("Reporting", "Endjin Ltd.", `Description = "Automated report generation")]`
public class ReportingPlugin
{
    ...
}

到目前为止,我展示的内容不会创建Plu⁠gin⁠Inf⁠orm⁠ation​Att⁠rib⁠ute类型的实例。这些注解只是指示,告诉属性在需要时应如何初始化。因此,如果要使用此属性,我需要编写一些代码来查找它。

获取属性

您可以使用反射 API 来发现特定类型的属性是否已经应用,并且反射 API 也可以为您实例化该属性。在第十三章中,我展示了表示各种属性目标的反射类型,例如MethodInfoTypePropertyInfo。它们都实现了一个名为ICustomAttributeProvider的接口,如示例 14-23 所示。

Example 14-23. ICustomAttributeProvider
public interface ICustomAttributeProvider
{
    object[] GetCustomAttributes(bool inherit);
    object[] GetCustomAttributes(Type attributeType, bool inherit);
    bool IsDefined(Type attributeType, bool inherit);
}

IsDefined方法仅告诉您特定属性类型是否存在,而不会实例化它。两个GetCustomAttributes重载会创建属性并返回它们。(这是属性被构造以及设置任何注解指定的属性的时机。)第一个重载返回应用于目标的所有属性,而第二个重载允许您仅请求特定类型的属性。

所有这些方法都接受一个bool参数,让您可以指定是否仅希望获取直接应用于您正在检查的目标的属性,或者还包括应用于基类型或类型的属性。

这个接口在 .NET 1.0 中引入,因此它不使用泛型,这意味着您需要对返回的对象进行强制转换。幸运的是,Cus⁠tom⁠Att⁠rib⁠ute​Ext⁠ens⁠ions 静态类定义了几个扩展方法。它并不是为 ICustomAttributeProvider 接口定义这些方法,而是扩展了提供属性的反射类。例如,如果您有一个 Type 类型的变量,您可以在其上调用 GetCustomAttribute<PluginInformationAttribute>() 方法,这将构造并返回插件信息属性,如果该属性不存在,则返回 null。示例 14-24 使用此方法显示了特定文件夹中所有 DLL 的所有插件信息。

示例 14-24. 显示插件信息
static void ShowPluginInformation(string pluginFolder)
{
    var dir = new DirectoryInfo(pluginFolder);
    foreach (FileInfo file in dir.GetFiles("*.dll"))
    {
        Assembly pluginAssembly = Assembly.LoadFrom(file.FullName);
        var plugins =
             from type in pluginAssembly.ExportedTypes
             `let` `info` `=` `type``.``GetCustomAttribute``<``PluginInformationAttribute``>``(``)`
             where info != null
             select new { type, info };

        foreach (var plugin in plugins)
        {
            Console.WriteLine($"Plugin type: {plugin.type.Name}");
            Console.WriteLine(
                $"Name: {plugin.info.Name}, written by {plugin.info.Author}");
            Console.WriteLine($"Description: {plugin.info.Description}");
        }
    }
}

这样做可能会存在一个潜在问题。我之前说过属性的一个好处是,可以在不实例化它们的目标类型的情况下检索它们。在这里是正确的 —— 我没有在 示例 14-24 中构造任何插件。然而,我正在加载插件程序集,枚举插件的一个可能副作用是运行插件 DLL 中的静态构造函数。因此,虽然我没有故意在这些 DLL 中运行任何代码,但我不能保证这些 DLL 中的代码不会运行。如果我的目标是向用户呈现插件列表,并且仅加载和运行明确选择的插件,那么我已经失败了,因为我给插件代码一个运行的机会。不过,我们可以解决这个问题。

仅加载元数据(Metadata-Only Load)

您不需要完全加载一个程序集才能检索属性信息。正如我在 第十三章 中讨论的那样,您可以仅为反射目的加载程序集,使用 MetadataLoadContext 类来实现。这样可以防止程序集中的任何代码运行,但可以检查其包含的类型。不过,这对属性造成了挑战。通常检查属性的属性的方法是通过调用 GetCustomAttributes 或相关的扩展方法来实例化它。由于这涉及构造属性 —— 也就是运行某些代码 —— 对于由 MetadataLoadContext 加载的程序集是不支持的(即使涉及的属性类型是在以正常方式完全加载的不同程序集中定义的)。如果我修改了 示例 14-24 来使用 Met⁠ada⁠ta​Loa⁠dCo⁠ntext 加载程序集,调用 Get⁠Cus⁠tom⁠Att⁠rib⁠ute⁠<Pl⁠ugi⁠nIn⁠for⁠mat⁠ion​Att⁠rib⁠ute> 将会抛出异常。

当仅加载元数据时,您必须使用GetCustomAttributesData方法。这不会为您实例化属性,而是返回存储在元数据中的信息——用于创建属性的指令。Example 14-25 展示了从 Example 14-24 修改为使用这种方式的相关代码版本。(还包括初始化MetadataLoadContext所需的代码。)

Example 14-25. 使用MetadataLoadContext检索属性
string[] runtimeAssemblies = Directory.GetFiles(
    RuntimeEnvironment.GetRuntimeDirectory(), "*.dll");
var paths = new List<string>(runtimeAssemblies);
paths.Add(file.FullName);

var resolver = new PathAssemblyResolver(paths);
var mlc = new MetadataLoadContext(resolver);

Assembly pluginAssembly = mlc.LoadFromAssemblyPath(file.FullName);
var plugins =
     from type in pluginAssembly.ExportedTypes
     `let` `info` `=` `type``.``GetCustomAttributesData``(``)``.``SingleOrDefault``(``attrData` `=``>`
            `attrData``.``AttributeType``.``FullName` `=``=` `pluginAttributeType``.``FullName``)`
     where info != null
     let description = info.NamedArguments
                           .SingleOrDefault(a => a.MemberName == "Description")
     select new
     {
         type,
         Name = (string) info.ConstructorArguments[0].Value,
         Author = (string) info.ConstructorArguments[1].Value,
         Description =
             description == null ? null : description.TypedValue.Value
     };

foreach (var plugin in plugins)
{
    Console.WriteLine($"Plugin type: {plugin.type.Name}");
    Console.WriteLine($"Name: {plugin.Name}, written by {plugin.Author}");
    Console.WriteLine($"Description: {plugin.Description}");
}

代码非常繁琐,因为我们没有得到属性的实例。GetCustomAttributesData返回一个CustomAttributeData对象的集合。Example 14-25 使用 LINQ 的SingleOrDefault操作符来查找PluginInformationAttribute的条目,如果存在,查询中的info变量将持有与相关CustomAttributeData对象的引用。然后,代码通过ConstructorArgumentsNamedArguments属性逐步选择构造函数参数和属性值,从而能够检索嵌入在属性中的三个描述性文本值。

正如这个例子所示,MetadataLoadContext增加了复杂性,因此只有在需要它提供的好处时才应该使用它。其中一个好处是它不会运行您加载的任何程序集。它还可以加载通常会被拒绝加载的程序集(例如,因为它们针对的特定处理器架构与您的进程不匹配)。但是,如果您不需要仅仅是元数据选项,直接访问属性,如 Example 14-24 所示,会更加方便。

总结

属性为将自定义数据嵌入程序集元数据提供了一种方法。您可以将属性应用于类型、类型的任何成员、参数、返回值,甚至整个程序集或其模块之一。一些属性由 CLR 特别处理,还有一些控制编译器功能,但大多数属性没有固有行为,仅充当被动信息容器。除非有人要求查看它们,否则属性甚至不会被实例化。所有这些使得属性在反射驱动行为的系统中最为有用——如果您已经有了反射 API 对象,比如ParameterInfoType,您可以直接向其询问属性。因此,您最常见到的是在检查您的代码的框架中使用属性,比如单元测试框架、序列化框架、像 Visual Studio 属性面板这样的数据驱动 UI 元素,或者插件框架。如果您使用这类框架,通常可以通过用框架识别的属性注解代码来配置其行为。如果您正在编写这种类型的框架,那么定义自己的属性类型可能是有意义的。

第十五章:文件和流

到目前为止,在本书中我展示的大多数技术都围绕着存在于对象和变量中的信息。这种状态存储在特定进程的内存中,但为了实用,程序必须与更广泛的世界进行交互。这可能通过 UI 框架实现,但有一种特定的抽象可以用于与外部世界的许多种交互:

流在计算中被如此广泛使用,以至于你无疑已经对它们非常熟悉了。在大多数其他编程系统中,.NET 流与它们基本相同:它只是一系列字节。这使得流对于许多常见功能非常有用,比如磁盘上的文件或 HTTP 响应的主体。控制台应用程序使用流来表示其输入和输出。如果你以交互方式运行这样的程序,用户在键盘上输入的文本成为程序的输入流,程序写入其输出流的任何内容都会显示在屏幕上。但程序不一定知道它的输入或输出类型——你可以重定向这些流以用于控制台程序。例如,输入流实际上可以提供磁盘上文件的内容,或者甚至可以是其他程序的输出。

注意

并非所有的 I/O API 都是基于流的。例如,除了输入流之外,Console 类还提供了一个 ReadKey 方法,可以准确地返回哪个按键被按下,但这仅在输入来自键盘时有效。因此,虽然你可以编写不关心输入是交互式还是来自文件的程序,但有些程序更为挑剔。

流 API 向你提供原始的字节数据。然而,你也可以在不同的层次上操作。例如,有些面向文本的 API 可以包装底层流,这样你可以处理字符或字符串,而不是原始字节。还有各种序列化机制,允许你将.NET 对象转换为流表示,稍后可以将其转换回对象,从而可以持久保存对象的状态或将其状态发送到网络上。我稍后会展示这些更高级的 API,但首先让我们看看流抽象本身。

Stream 类

Stream类定义在System.IO命名空间中。它是一个抽象基类,具有具体派生类型,如FileStreamGZipStream,代表特定类型的流。示例 15-1 展示了Stream类的三个最重要成员。它还有其他几个成员,但这些是抽象的核心。(稍后您将看到,还有ReadWrite的异步版本。 .NET Core 3.1 和.NET 还提供了使用第十八章中描述的span类型之一替代数组的重载版本。本节中关于这些方法的所有内容也适用于异步和基于 span 的形式。)

Example 15-1. Stream的最重要成员
public abstract int Read(byte[] buffer, int offset, int count);
public abstract void Write(byte[] buffer, int offset, int count);
public abstract long Position { get; set; }

一些流是只读的。例如,当控制台应用程序的输入流表示键盘或其他程序的输出时,程序无法向该流写入有意义的内容。(即使使用输入重定向运行具有文件输入的控制台应用程序,输入流也是只读的。)一些流是只写的,例如控制台应用程序的输出流。如果在只写流上调用Read方法或在只读流上调用Write方法,则这些方法会抛出NotSupportedException异常。

Tip

Stream类定义了各种描述流能力的bool属性,因此您不必等到出现异常才能知道流的类型。您可以检查CanReadCanWrite属性。

ReadWrite都以byte[]数组作为它们的第一个参数,并分别将数据复制到该数组中或从该数组中复制数据出来。随后的offsetcount参数指示从数组的哪个元素开始,以及要读取或写入的字节数;您不必使用整个数组。请注意,没有参数指定要在流中的偏移量处读取或写入。这由Position属性管理 —— 它从零开始,但每次读取或写入时,位置会根据处理的字节数前进。

注意,Read方法返回一个int值。这告诉您从流中读取了多少字节 —— 该方法不保证提供您请求的数据量。这样做的一个明显原因是您可能已经达到流的末尾,因此即使您要求将 100 个字节读入数组中,当前Position和流的末尾之间可能只剩下 30 个字节的数据。然而,并不是这唯一可能导致您获取少于请求数据的原因,这经常会让人摸不着头脑,因此为了那些快速浏览本章节的人,我会做出一个警告。

Warning

如果你一次请求多个字节,Stream 可以出于任何原因返回少于你从 Read 请求的数据。你不应该假设调用 Read 返回了它所能返回的所有数据量,即使你有足够的理由知道你请求的数据量是可用的。

Read 稍微复杂的原因在于,某些流是实时的,代表程序运行时逐渐产生数据的信息源。例如,如果一个控制台应用程序在交互运行,其输入流只能在用户键入时提供数据;表示通过网络连接接收的数据的流只能在数据到达时提供数据。如果你调用 Read 并请求的数据超过当前可用的数据量,流可能会等待直到它有你请求的那么多数据,但它不必这样做——它可能会立即返回任何它当前已经有的数据。(它在返回之前必须等待的唯一情况是,如果它当前没有任何数据但尚未到达流的末尾。它必须返回至少一个字节,因为返回值 0 表示流的末尾。)如果你想确保读取特定数量的字节,你需要检查 Read 是否返回少于你想要的字节数,并在必要时继续调用它,直到你得到所需的数据。示例 15-2 展示了如何做到这一点。

示例 15-2. 读取特定数量的字节
static int ReadAll(Stream s, byte[] buffer, int offset, int length)
{
    if ((offset + length) > buffer.Length)
    {
        throw new ArgumentException("Buffer too small to hold requested data");
    }

    int bytesReadSoFar = 0;
    while (bytesReadSoFar < length)
    {
        int bytes = s.Read(
            buffer, offset + bytesReadSoFar, length - bytesReadSoFar);
        if (bytes == 0)
        {
            break;
        }
        bytesReadSoFar += bytes;
    }

    return bytesReadSoFar;
}

注意,这段代码检查了从 Read 返回的 0 值以检测流的末尾。如果没有这样的检查,如果在读取了被请求的数据量之前达到了流的末尾,它将永远循环下去。这意味着如果我们确实到达了流的末尾,这个方法将必须提供比调用者请求的数据少的数据量,因此这似乎并没有真正解决问题。然而,这确实排除了在没有到达流的末尾的情况下获得少于请求的数据量的情况。(你可以更改方法,使其在到达流的末尾之前提供指定数量的字节时抛出异常。这样,如果方法返回,它保证返回的字节数正好是所请求的数量。)

Stream 提供了一个更简单的读取方法。ReadByte 方法返回一个单字节,除非你已达到流的末尾,在这种情况下它返回值 -1。(它的返回类型是 int,允许它返回任何可能的 byte 值以及负值。)这避免了只返回部分请求数据的问题,因为如果你得到任何返回,你总是得到确切的一个字节。然而,如果你想读取更大的数据块,这并不特别方便或高效。

Write方法没有任何这些问题。如果成功,它始终接受您提供的所有数据。当然,它可能会失败——可能会因为错误(例如,磁盘空间不足或网络连接丢失)而在成功写入所有数据之前抛出异常。

位置和寻址

每次读取或写入时,流会自动更新其当前位置。如您在示例 15-1 中所见,Position属性可以设置,因此您可以尝试直接移动到特定位置。这并不保证能够成功,因为并非总是可能支持它。例如,代表通过 TCP 网络连接接收的数据的Stream可以无限产生数据——只要连接保持打开且另一端继续发送数据,流将继续响应对Read的调用。连接可能保持打开多天,并在此期间接收到数 TB 的数据。如果这样的流允许您设置其Position属性,使您的代码能够返回并重新读取先前接收到的数据,则流必须找到存储每个接收到的字节的地方,以防万一代码希望再次查看它。由于这可能涉及存储比磁盘上有空间更多的数据,显然是不切实际的,因此某些流在尝试设置Position属性时会抛出NotSupportedException。(有一个CanSeek属性,您可以使用它来发现特定流是否支持更改位置,因此就像只读和只写流一样,您不必等到出现异常才能找出它是否有效。)

除了Position属性外,Stream还定义了一个Seek方法,其签名如示例 15-3 所示。这使您可以相对于流的当前位置指定所需的位置。(对于不支持寻址的流,这也会抛出NotSupportedException。)

示例 15-3. Seek方法
public abstract long Seek(long offset, SeekOrigin origin);

如果将SeekOrigin.Current作为第二个参数传递,它将通过将第一个参数添加到当前位置来设置位置。如果要向后移动,可以传递负的offset。还可以传递SeekOrigin.End将位置设置为距离流末尾指定的字节数。传递Seek​Ori⁠gin.Begin与仅设置Position具有相同的逻辑效果——它将相对于流的起始位置设置位置。

刷新

与其他编程系统上许多流 API 一样,在Stream中写入数据不一定会立即使数据到达目的地。当调用Write时,你只知道它已经将你的数据复制到某个地方;但那可能是内存中的缓冲区,而不是最终的目标。例如,如果你向代表存储设备上文件的流写入单个字节,流对象通常会推迟将其写入驱动器,直到有足够的字节使得这样做值得。存储设备是基于块的,意味着写入以固定大小的块发生,通常是几千字节大小,因此等到有足够的数据来填充一个块再进行写入是有意义的。

这种缓冲通常是件好事——它提高了写入性能,同时使你可以忽略磁盘工作的细节。然而,缺点是如果你偶尔写入数据(例如将错误消息写入日志文件时),你可能会在程序写入数据到流和数据到达磁盘之间遇到长时间的延迟。对于试图通过查看当前正在运行的程序日志文件来诊断问题的人来说,这可能会令人困惑。更隐秘的是,如果你的程序崩溃,流缓冲区中尚未到达存储设备的任何数据可能会丢失。

因此,Stream类提供了Flush方法。这让你告诉流,你希望它执行任何必要的工作,以确保任何缓冲数据都被写入到其目标,即使这意味着对缓冲区的使用不是最优的情况。

警告

使用FileStream时,Flush方法并不一定保证刷新的数据已经写入磁盘。它只是让流将数据传递给操作系统。在调用Flush之前,操作系统甚至还没有看到数据,因此如果突然终止进程,数据将会丢失。在Flush返回后,操作系统已经拥有你的代码已经写入的所有数据,因此可以在没有数据丢失的情况下终止进程。然而,操作系统可能会执行自己的额外缓冲,因此如果在操作系统开始将所有数据写入磁盘之前电源失败,数据仍可能丢失。如果需要确保数据已经持久写入(而不仅仅是确保你已经将数据交给操作系统),你还需要使用WriteThrough标志,描述在"FileStream Class"中,或者调用带有bool参数的Flush重载,传递true以强制刷新到存储设备。

调用 Dispose 时,流会自动刷新其内容。只有在希望在写出缓冲数据后保持流打开状态时,才需要使用 Flush。如果流在打开但不活动期间会有较长时间,这一点尤其重要。(如果流代表网络连接,并且如果您的应用程序依赖于及时数据传递 —— 例如在线聊天应用程序或游戏 —— 即使您期望只有相对短暂的不活动期间,也应调用 Flush。)

复制

有时将所有数据从一个流复制到另一个流会很有用。你可以轻易地编写一个循环来做到这一点,但你不必这样做,因为 Stream 类的 CopyTo 方法(或等效的 CopyToAsync)已经为你实现了这一功能。关于它,没有太多可以说的。我提到它的主要原因是,开发人员通常因为不知道 Stream 中内置了这个功能,所以会自己编写这个方法的版本。

长度

一些流能够通过名为 Length 的属性报告它们的长度。与 Position 一样,该属性的类型为 long —— Stream 使用 64 位数值,因为流通常需要比 2GB 更大的容量,如果使用 int 表示大小和位置,则会受到上限的限制。

Stream 还定义了一个 SetLength 方法,用于在支持的情况下定义流的长度。在向文件写入大量数据时,可以考虑使用此方法,以确保有足够的空间来容纳所有希望写入的数据 —— 最好在开始之前遇到 IOException,而不是浪费时间进行注定失败的操作,并可能通过使用所有可用的空闲空间来引起系统范围的问题。然而,许多文件系统支持稀疏文件,允许创建远远大于可用空间的文件,因此在实践中,您可能直到开始写入非零数据时才会看到任何错误。即使如此,如果指定的长度超出文件系统支持的长度,SetLength 将抛出 ArgumentException

并非所有流支持长度操作。Stream 类文档指出,Length 属性仅在支持 CanSeek 的流上可用。这是因为支持寻址的流通常是那些整个流内容在开始时就已知并且可访问的流。在内容在运行时生成的流上(例如代表用户输入的输入流或代表网络接收数据的流),通常也不会预先知道长度。至于 SetLength,文档说明仅在既支持写入又支持寻址的流上受支持。(与所有表示可选功能的成员一样,如果在不支持它们的流上尝试使用这些成员,LengthSetLength 将抛出 NotSupportedException。)

处置

一些流表示.NET 运行时外部的资源。例如,FileStream提供对文件内容的流访问,因此它需要从操作系统获取文件句柄。当你使用完毕后,关闭句柄非常重要;否则可能会阻止其他应用程序使用该文件。因此,Stream类实现了IDisposable接口(在第七章中描述),以便在必要时进行处理。正如我之前提到的,缓冲流如FileStream在调用Dispose之前会刷新它们的缓冲区,然后关闭句柄。

并非所有流类型都依赖于调用Dispose:例如,MemoryStream完全在内存中工作,因此 GC 可以负责处理它。但通常情况下,如果你创建了一个流,当你不再需要它时应该调用Dispose

注意

有些情况下,你会得到一个流,但并不是你的工作来处理它。例如,ASP.NET Core 可以提供流来表示 HTTP 请求和响应中的数据。它会为你创建这些流,并在你使用完之后进行处理,因此你不应该调用Dispose来处理它们。

令人困惑的是,Stream类也有一个Close方法。这是历史的偶然。在.NET 1.0 的第一个公共测试版发布时,并没有定义IDisposable,并且 C#也没有using语句——这个关键字仅用于using指令,用于将命名空间引入作用域。Stream类需要一种方式来知道何时清理其资源,但当时还没有一个标准的方法,所以它发明了自己的习惯用语。它定义了一个Close方法,这与其他编程系统中许多基于流的 API 使用的术语是一致的。在.NET 1.0 正式发布之前添加了IDisposable,并且Stream类增加了对其的支持,但保留了Close方法;如果移除它,将会影响到许多早期采用者,因为他们一直在使用测试版。但是,Close是多余的,并且文档明确建议不要使用它。文档说应该使用Dispose(通过using语句如果方便的话)。调用Close没有害处——它与Dispose之间没有实际区别——但Dispose是更常见的习惯用语,因此更为推荐。

异步操作

Stream 类提供了 ReadWrite 的异步版本。请注意有两种形式。Stream 首次出现在 .NET 1.0 中,因此它支持当时的标准异步机制,即异步编程模型(APM,见第十六章)。通过 BeginReadEndReadBeginWriteEndWrite 方法。此模型现已弃用,并已被较新的基于任务的异步模式(或 TAP,也见第十六章)所取代。Stream 通过其 ReadAsyncWriteAsync 方法支持此模式。还有两个操作最初没有任何异步形式,现在有了 TAP 版本:FlushAsyncCopyToAsync。(这些仅支持 TAP,因为 APM 在 Microsoft 添加这些方法时已经弃用。)

警告

避免使用基于旧的 APM 的 Begin/End 形式的 ReadWrite。在 .NET Core 的早期版本和 .NET Standard 2.0 之前,它们根本不存在。它们重新出现是为了更容易地将现有代码从 .NET Framework 迁移到 .NET Core,因此仅支持传统场景。

一些流类型利用非常高效的技术实现异步操作,直接对应于底层操作系统的异步能力。(FileStream 就是这样做的,以及.NET 可以提供的各种用于表示网络连接内容的流。) 您可能会遇到具有自定义流类型的库,这些流类型不会这样做,但即使如此,异步方法也会可用,因为基本的 Stream 类可以退回到使用多线程技术。

在使用异步读取和写入时需要注意的一点是,流只有一个 Position 属性。读取和写入取决于当前的 Position,并在完成时更新它,因此通常必须避免在已经开始的操作完成之前启动新的操作。但是,如果您希望从特定文件执行多个并发读取或写入操作,FileStream 对此有特殊处理。如果告诉它您将在异步模式下使用文件,则操作使用操作开始时 Position 的值,一旦异步读取或写入开始,您可以更改 Position 并启动另一个操作,而无需等待所有先前的操作完成。但这仅适用于 FileStream,且仅在文件以异步模式打开时。或者,可以使用稍后在本章描述的新 RandomAccess 类,而不是使用 FileStream

.NET Core 3.1 和 .NET 5.0 及更高版本提供了 IAsyncDisposable,这是 Dispose 的异步形式。Stream 类实现了这一点,因为处置通常涉及刷新,这是一种潜在的缓慢操作。

具体的流类型

Stream 类是抽象的,因此要使用流,你需要一个具体的派生类型。在某些情况下,这些类型将由框架提供给你——例如,ASP.NET Core Web 框架提供了表示 HTTP 请求和响应主体的流对象,客户端的 HttpClient 类会执行类似操作。但有时你需要自己创建一个流对象。本节描述了一些常用的从 Stream 派生的类型。

FileStream 类表示文件系统上的文件。我将在“文件和目录”中描述这一点。

MemoryStream 允许你在 byte[] 数组之上创建一个流。你可以取一个现有的 byte[] 并将其包装在 MemoryStream 中,或者你可以创建一个 MemoryStream,然后通过调用 Write(或异步等效)来填充数据。完成后,你可以通过调用 ToArrayGetBuffer 来检索填充的 byte[]。(ToArray 分配一个基于实际写入的字节数的新数组。GetBuffer 更有效率,因为它返回 MemoryStream 正在使用的底层数组,但除非写入恰好完全填满它,否则返回的数组通常会超过实际使用的部分,在末尾有一些未使用的空间。) 当你需要与需要流的 API 一起工作但由于某些原因没有流时,此类非常有用。例如,本章后面描述的大多数序列化 API 都与流一起工作,但你可能希望将其与某些以 byte[] 为单位的其他 API 结合使用。MemoryStream 允许你在这两种表示之间建立桥梁。

Windows 和 Unix 都定义了一种进程间通信(IPC)机制,通过流连接两个进程。Windows 将其称为 命名管道。Unix 也有一个同名的机制,但完全不同;不过它确实提供了类似于 Windows 命名管道的机制:域套接字。虽然 Windows 命名管道和 Unix 域套接字的具体细节不同,但在 .NET 中,从 PipeStream 派生的各种类提供了对两者的共同抽象。

BufferedStream 是从 Stream 派生出来的,但在其构造函数中还接受一个 Stream。它添加了一个缓冲层,如果你想在设计为更大操作最佳的流上执行小读取或写入操作,这将非常有用。(对于 FileStream,你不需要使用这个,因为它有其自己内置的缓冲机制。)

有各种流类型可以以某种方式转换其他流的内容。例如,DeflateStreamGZipStreamBrotliStream 实现了三种广泛使用的压缩算法。你可以将它们包装在其他流周围,以压缩写入底层流的数据,或者解压读取自底层流的数据。(这些只提供了最低级别的压缩服务。如果你想处理流行的 ZIP 格式,用于压缩文件包,请使用 ZipArchive 类。)还有一个称为 CryptoStream 的类,可以使用.NET 支持的各种加密机制之一加密或解密其他流的内容。

一个类型,多种行为

正如你现在所见,抽象基类 Stream 在各种场景中被广泛使用。可以说,这是一个有点过于泛化的抽象。例如,像 CanSeek 这样的属性告诉你,你所拥有的特定 Stream 是否可以以某种方式使用,这可能是一个潜在问题的症状,是一种被称为 代码异味 的示例。.NET 流并不是发明这种一刀切的方法——它早在 Unix 和 C 编程语言的标准库中就很流行了。问题在于,当编写处理 Stream 的代码时,你可能不知道正在处理的是什么类型的东西。

有许多不同的方法可以使用 Stream,但经常遇到三种使用样式:

  • 字节序列的顺序访问

  • 随机访问,假定具有高效的缓存机制

  • 访问设备或系统某些底层能力

正如你所知,不是所有的Stream实现都支持这三种模型——如果CanSeek返回false,那就排除了中间选项。但不太明显的是,即使这些属性表明某种能力可用,也不是所有流都同样高效地支持所有使用模型。

例如,我曾经参与过一个项目,该项目使用了一个库来访问云托管存储服务中的文件,并能用Stream对象表示这些文件。这看起来很方便,因为你可以将它们传递给任何可以处理Stream的 API。然而,它的设计非常适合前述列表中的第三种使用方式:每次调用Read(或ReadAsync)都会导致该库向存储服务发出 HTTP 请求。最初我们希望能够将其与另一个能够解析 Parquet 文件(一种广泛用于大容量数据处理的二进制表格数据存储格式)的库一起使用。然而,事实证明该库期望的是支持第二种访问方式的流:它会在文件中前后跳跃,进行大量相对较小的读取。它与我稍后将要描述的FileStream类型完美配合,因为后者很好地支持了前两种使用模式。(对于第二种模式,它依赖于操作系统进行缓存。)但是,直接将来自存储服务库的Stream直接插入 Parquet 解析库将会导致性能灾难。

当你遇到这种类型的不匹配时,往往并不明显。在这个例子中,像CanSeek这样的属性报告能力并没有暗示会有问题。而使用 Parquet 文件的应用程序通常使用某种远程存储服务,而不是本地文件系统,因此没有明显的理由认为该库会假设任何Stream都会提供类似本地文件系统的缓存。当我们尝试时,技术上它确实能够工作:存储库的Stream努力做到了一切所需,并且代码最终是正确的......但是要记住,每当你使用Stream时,确保你完全理解它将被应用的访问模式及其对这些模式的高效支持是非常重要的。

在某些情况下,你可能能够弥合这种差距。BufferedStream类通常可以接受仅设计用于前述第三种使用方式的Stream并使其适应第一种使用方式。然而,在运行库中没有任何内容可以为不本能地支持第二种使用方式的Stream添加对其的支持。(通常只有代表已完全在内存中或包装某些本地 API(例如操作系统文件系统 API)进行缓存的流才可用。)在这些情况下,你将需要重新考虑你的设计(例如,制作Stream内容的本地副本),更改Stream的使用方式,或编写某种自定义缓存适配器。(最终,我们编写了一个适配器,通过增加BufferedStream的能力,仅添加了足够的随机访问缓存来解决性能问题。)

无须Stream的随机访问和分散/聚集 I/O

.NET 6.0System.IO 命名空间中新增了一个新类:RandomAccess。它可以在不使用 Stream 的情况下进行文件读写操作。它可以简化需要从单个文件执行多个并发读取的场景。它还可以执行跨越不是单个连续内存块的数据的单个读取或写入操作,利用底层操作系统处理此类读写的高效能力。

要使用 RandomAccess,必须使用 .NET 6.0 中新增的 File 类的 OpenHandle 方法打开文件,该方法返回 SafeFileHandle,它是围绕操作系统文件句柄的一次性包装。可以将其传递给 RandomAccess 提供的各种 ReadReadAsyncWriteWriteAsync 静态方法。所有的读取和写入方法都要求你传递文件内的偏移量,这与 Stream 不同,后者会为你记住当前的 Position。每个方法都传递偏移量的优势在于,它避免了执行多个并发操作时出现的问题,正如前面描述的。示例 15-4 使用此方法直接从 Windows 的 .exe 文件中读取数据。注意,与 Stream 类似,读取操作可能获取比请求的数据量少,因此在需要读取特定字节数的情况下,你需要编写一个循环,以确保获取所需量的数据。

示例 15-4. 使用 RandomAccess 从文件中读取数据
static void ReadAll(SafeFileHandle fh, Span<byte> buffer, long offset)
{
    int soFar = 0;
    do
    {
        int read = RandomAccess.Read(fh, buffer[soFar..], offset + soFar);
        if (read == 0)
        {
            throw new InvalidOperationException(
                "Reached end of file before filling buffer");
        }
        soFar += read;
    } while (soFar < buffer.Length);
}

var stubSignature = new byte[2];
ReadAll(fh, stubSignature, 0);
if (stubSignature[0] != (byte)'M' || stubSignature[1] != (byte)'Z')
{
    Console.WriteLine("No 'MZ' at start of file - not an EXE file");
}

此示例仅执行单个读取以说明用法,但更复杂的示例可以自由执行多个并发读取,可以在多个线程上或使用 ReadAsync 方法。

这里的缓冲区以 Span<byte> 形式传递;Write 方法使用 ReadOnlySpan<byte>。异步形式则分别接受 Memory<byte>ReadOnlyMemory<byte>。这些类型表示内存区域——通常是数组,但不一定。它们在第十八章中均有详细描述。

每个方法还提供了接受相关类型列表(例如IReadOnlyList<Memory<byte>>IReadOnlyList<ReadOnlyMemory<byte>>等)的重载,以支持scatter/gather 读取或写入。这些操作中,单次读取或写入跨越多个内存块。如果要写出到文件的数据分布在多个内存区域中(例如因为所涉及的数据是通过对外部服务进行多次请求获得的),你可以执行单个写入操作,传入所有要写入的内存块列表。这比执行多个单独的写入要高效得多——操作系统可以直接处理这种 I/O,而在许多情况下,底层磁盘控制器硬件能够将散布在内存中的数据聚合成单个磁盘操作——它将散落在内存中的数据汇总起来,因此得名。同样的操作也适用于读取:你可以从文件中读取一块数据,并将其分布到多个目标缓冲区中。

文本导向类型

StreamRandomAccess 类是面向字节的,但通常需要处理包含文本的文件。如果要处理存储在文件中(或通过网络接收到的)文本,使用基于字节的 API 是很麻烦的,因为这会迫使你显式处理所有可能发生的变化。例如,有多种约定来表示行的结束——Windows 通常使用值为1310的两个字节,许多互联网标准如 HTTP 也是如此,但类 Unix 系统通常只使用值为10的单个字节。

目前也有多种流行的字符编码。有些文件每个字符使用一个字节,有些使用两个字节,还有一些使用可变长度编码。也有许多不同的单字节编码,因此,如果在文本文件中遇到字节值,例如163,除非知道使用的编码方式,否则无法知道其含义。

在使用单字节 Windows-1252 编码的文件中,值163代表英镑符号:£。¹ 但如果文件采用 ISO/IEC 8859-5 编码(设计用于使用西里尔字母的地区),同样的代码表示西里尔大写字母 DJE:Ђ。而如果文件使用 UTF-8 编码,值163只能作为多字节序列的一部分,代表一个单一字符。

当然,了解这些问题是任何开发者技能集的重要组成部分,但这并不意味着每次遇到文本都要处理每一个小细节。因此,.NET 定义了专门的抽象来处理文本。

TextReader 和 TextWriter

抽象的 TextReaderTextWriter 类将数据表示为一系列 char 值。从逻辑上讲,这些类类似于流,但序列中的每个元素是一个 char 而不是一个 byte。然而,在细节上有一些区别。首先,有读和写的分离抽象。Stream 结合了这些功能,因为通常希望对单个实体进行读/写访问,特别是如果流表示磁盘上的文件。对于面向字节的随机访问,这是有意义的,但对于文本来说,这是一个问题的抽象。

变长编码使得支持随机写访问变得棘手(即能够在序列的任何点更改值)。考虑一下将一个 1 GB 的 UTF-8 文本文件的第一个字符 $ 替换为 £ 的意义。在 UTF-8 中,$ 字符只需要一个字节,但 £ 需要两个字节,因此更改第一个字符将需要在文件开头插入一个额外的字节。这意味着需要将剩余的文件内容——几乎 1 GB 的数据——向后移动一个字节。

即使只是只读的随机访问也相对昂贵。在一个 UTF-8 文件中找到第一百万个字符需要读取前 999,999 个字符,因为没有这样做,您无法知道其中包含的单字节和多字节字符的混合情况。第一百万个字符可能从第一百万个字节开始,但也可能从第四百万个字节开始,或者介于两者之间的任何位置。由于支持带有可变长度文本编码的随机访问是昂贵的,特别是对于可写数据,因此这些基于文本的类型不提供此功能。没有随机访问,将读者和写者合并为一个类型没有真正的好处。另外,将读者和写者类型分开消除了检查 CanWrite 属性的需要——您知道可以写入,因为您有一个 TextWriter

TextReader 提供了几种读取数据的方式。最简单的是零参数重载的 Read 方法,它返回一个 int。如果已经到达输入的末尾,它将返回 −1,否则将返回一个字符值。(在确认非负后,您需要将其转换为 char。)此外,还有两种看起来类似于 Stream 类的 Read 方法的方法,正如 示例 15-5 所示。

示例 15-5. TextReader 块读取方法
public virtual int Read(char[] buffer, int index, int count) {...}
public virtual int ReadBlock(char[] buffer, int index, int count) {...}

就像Stream.Read一样,这些方法接受一个数组,以及数组中的索引和计数,并尝试读取指定数量的值。与Stream最明显的区别是,这些方法使用char而不是byte。但是ReadReadBlock有什么区别呢?嗯,ReadBlock解决了我在 Example 15-2 中必须为Stream手动解决的问题:虽然Read可能返回比请求的字符数少,但ReadBlock不会在达到请求的字符数或到达内容结尾之前返回。

处理文本输入的挑战之一是处理各种行结束的约定,而TextReader可以使你免受这些影响。它的ReadLine方法读取整行输入并将其作为一个string返回。该字符串不包括行尾的字符。

注意

TextReader并不假定特定的行结束约定。它接受回车符(字符值13,在字符串字面量中写作\r)或换行符(10,或\n)。如果这两个字符相邻出现,该字符对被视为单个行结束,尽管实际是两个字符。此处理仅在使用ReadLineRead​Li⁠neAsync时发生。如果直接使用ReadReadBlock在字符级别操作,你将看到行结束字符的确切形式。

TextReader还提供了ReadToEnd方法,它会将输入完全读取并作为一个单独的string返回。最后,还有Peek方法,与单参数的Read方法相同,但不会改变阅读器的状态。它允许你查看下一个字符而不消耗它,所以下次调用PeekRead时,它将再次返回相同的字符。

至于TextWriter,它提供了两个重载方法用于写入:WriteWriteLine。每个方法都为所有内置的值类型(boolintfloat等)提供了重载。从功能上讲,该类本可以只使用一个接受object参数的重载方法,因为它可以直接调用参数的ToString方法,但是这些专门的重载方法使得可以避免装箱。TextWriter还提供了一个Flush方法,原因与Stream提供的相同。

默认情况下,TextWriter将使用操作系统的默认行结束序列。在 Windows 上是\r\n序列(先13,然后10)。在 Linux 上,每行末尾只有一个\n。你可以通过设置写入器的NewLine属性来更改这一行为。

这两个抽象类都实现了IDisposable接口,因为一些具体的派生文本阅读器和写入器类型是对其他可释放资源的包装。

Stream 一样,这些类提供了其方法的异步版本。与 Stream 不同的是,这是一个相当近期的增加,因此它们仅支持在 第十六章 中描述的基于任务的模式,可以使用 第十七章 中描述的 await 关键字消费。

具体的读取器和写入器类型

Stream 类似,.NET 中的各种 API 将向您提供 TextReaderTextWriter 对象。例如,Console 类定义了 InOut 属性,用于提供对进程输入和输出流的文本访问。虽然我之前没有描述过这些,但我们已经在隐式地使用它们——Console.WriteLine 方法的重载只是为您调用 Out.WriteLine 的包装器。同样,Console 类的 ReadReadLine 方法只是简单地转发到 In.ReadIn.ReadLine。还有一个 Error,另一个用于将输出写入标准错误输出流的 TextWriter。但是,有一些直接派生自 TextReaderTextWriter 的具体类,您可能希望直接实例化它们。

StreamReaderStreamWriter

可能最有用的具体文本读取器和写入器类型是 StreamReaderStreamWriter,它们包装了一个 Stream 对象。您可以将 Stream 作为构造函数参数传递,或者只需传递包含文件路径的字符串,它们将自动为您构造一个 FileStream 然后包装它。示例 15-6 使用此技术向文件写入一些文本。

示例 15-6. 使用 StreamWriter 向文件写入文本
using (var fw = new StreamWriter(@"c:\temp\out.txt"))
{
    fw.WriteLine($"Writing to a file at {DateTime.Now}");
}

提供了多种构造函数重载,以提供更精细的控制。当传递一个字符串以便使用 StreamWriter(而不是您已经获得的某个 Stream)时,可以选择性地传递一个 bool,指示是否从头开始或者追加到已存在的文件(传递 true 启用追加)。如果不传递此参数,则不使用追加,并且写入将从头开始。您还可以指定编码。默认情况下,StreamWriter 将使用没有字节顺序标记(BOM)的 UTF-8,但可以传递从 Encoding 类派生的任何类型,该类在 “编码” 中描述。

StreamReader 类似,可以通过传递 Stream 或包含文件路径的 string 来构造它,还可以选择性地指定编码。然而,如果不指定编码,其行为与 StreamWriter 稍有不同。StreamWriter 默认使用 UTF-8,而 StreamReader 则尝试从流内容中检测编码。它会查看前几个字节,并寻找一些特征,这些特征通常是确定特定编码正在使用的好迹象。如果编码的文本以 Unicode BOM 开头,这将极大地提高确定编码的准确性。

StringReaderStringWriter

StringReaderStringWriter 类与 MemoryStream 的作用类似:当你需要与要求 TextReaderTextWriter 的 API 一起工作,但希望完全在内存中操作时,它们非常有用。MemoryStreambyte[] 数组上提供了 Stream API,StringReader 则将 string 包装为 TextReader,而 StringWriterStringBuilder 上提供了 TextWriter API。

.NET 提供的用于处理 XML 的 API 之一,XmlReader,需要一个 StreamTextReader。假设你有一个存储在 string 中的 XML 内容。如果在创建新的 XmlReader 时传递一个 string,它会将其解释为用于获取内容的 URI,而不是内容本身。接受一个 stringStringReader 构造函数会将该字符串包装为读取器的内容,我们可以将其传递给需要 TextReaderXmlReader.Create 重载方法,如 示例 15-7 所示。(这行代码用粗体标记,接下来的代码仅使用 XmlReader 读取内容以展示其按预期工作。)

示例 15-7. 将字符串包装在 StringReader
string xmlContent =
    "<message><text>Hello</text><recipient>world</recipient></message>";
`var` `xmlReader` `=` `XmlReader``.``Create``(``new` `StringReader``(``xmlContent``)``)``;`
while (xmlReader.Read())
{
    if (xmlReader.NodeType == XmlNodeType.Text)
    {
        Console.WriteLine(xmlReader.Value);
    }
}

StringWriter 更为简单:你可以不带任何参数地构造它。在写入完成后,你可以调用 ToStringGetStringBuilder 来提取所有已写入的文本。

编码

正如前面提到的,如果使用 StreamReaderStreamWriter,它们需要知道底层流使用的字符编码,以便能够正确地在流中的字节和 .NET 的 charstring 类型之间进行转换。为了管理这一点,System.Text 命名空间定义了一个抽象的 Encoding 类,具有各种具体的编码特定公共派生类型,包括 ASCIIEncodingUTF7EncodingUTF8EncodingUTF32EncodingUnicodeEncoding

大多数这些类型名称都是不言自明的,因为它们命名自它们代表的标准字符编码,比如 ASCII 或 UTF-8。需要稍作解释的是 UnicodeEncoding —— 毕竟,UTF-7、UTF-8 和 UTF-32 都是 Unicode 编码,那么这个UnicodeEncoding又是什么呢?当 Windows 在第一个 Windows NT 版本中引入对 Unicode 的支持时,采用了一个有点不太恰当的约定:在文档和各种 API 名称中,“Unicode”一词被用来指代一种 2 字节的小端字符编码,这只是众多可能的编码方案中的一种,它们都可以正确地描述为某种形式的“Unicode”。

UnicodeEncoding 类的命名是为了与这个历史约定保持一致,尽管即便如此,仍然有些令人困惑。在 Win32 API 中,“Unicode”所指的编码实际上是 UTF-16LE,但 UnicodeEncoding 类也能支持大端的 UTF-16BE。

基础的Encoding类定义了静态属性,返回我提到的所有编码类型的实例,因此如果需要表示特定编码的对象,通常只需写Encoding.ASCIIEncoding.UTF8等,而不是构造新对象。有两个类型为UnicodeEncoding的属性:Unicode属性返回一个配置为 UTF-16LE 的实例,BigEndianUnicode返回一个 UTF-16BE 的实例。

对于各种 Unicode 编码,这些属性将返回编码对象,告诉StreamWriter在输出开头生成 BOM。BOM 的主要目的是使读取编码文本的软件能够自动检测编码是大端序还是小端序(你也可以用它来识别 UTF-8,因为其编码 BOM 与其他编码不同)。如果知道将使用特定字节顺序的编码(例如 UTF-16LE),则 BOM 是不必要的,因为你已经知道顺序,但 Unicode 规范定义了可以通过以 BOM 开头的编码字节来广告正在使用的顺序的可适应格式。其 16 位版本称为 UTF-16,可以通过查看其是否以 0xFE、0xFF 或 0xFF、0xFE 开始来判断任何特定的 UTF-16 编码字节集是大端序还是小端序。

警告

尽管 Unicode 定义了允许检测字节顺序的编码方案,但无法创建按此方式工作的Encoding对象——它总是具有特定的字节顺序。因此,尽管Encoding指定在写入数据时是否写入 BOM,但这不会影响读取数据的行为——它总是假定在构造Encoding时指定的字节顺序。这意味着Encoding.UTF32属性可能名字起得不太准确——它总是将数据解释为小端序,尽管 Unicode 规范允许 UTF-32 使用大端或小端序。Encoding.UTF32实际上是 UTF-32LE。

如前所述,在创建StreamWriter时未指定编码时,默认为无 BOM 的 UTF-8 编码,这与Encoding.UTF8不同,后者会生成 BOM。而StreamReader更有趣:如果未指定编码,它将尝试检测编码。因此,.NET 能够根据 Unicode 规范对 UTF-16 和 UTF-32 自动检测字节顺序;不过做法是在构造StreamReader指定特定编码。它会查找 BOM,如果找到,则使用适当的 Unicode 编码;否则假定为 UTF-8 编码。

UTF-8 是一种流行的编码方式。如果你的主要语言是英语,这是一种特别方便的表示方法,因为如果你只使用 ASCII 中可用的字符,每个字符将占据一个字节,并且编码后的文本将与 ASCII 编码具有相同的字节值。但不同于 ASCII,你不受限于 7 位字符集。所有 Unicode 代码点都可用;你只需对 ASCII 范围外的内容使用多字节表示。然而,尽管它非常广泛使用,UTF-8 并不是唯一流行的 8 位编码。

代码页编码

Windows,如同之前的 DOS 一样,长期支持扩展 ASCII 的 8 位编码。ASCII 是一个 7 位编码,意味着使用 8 位字节,你有 128 个“多余”的值可用于其他字符。这远远不足以覆盖每个区域的每个字符,但在特定国家内,通常足够应付(尽管并非总是如此——许多远东国家需要超过 8 位每字符的编码)。但每个国家往往希望有一套不同的非 ASCII 字符集,这取决于该地区流行的重音字符以及是否需要非罗马字母表。因此,为不同地区存在各种代码页。例如,代码页 1253 使用 193–254 范围内的值来定义希腊字母字符(用其余的非 ASCII 值填充有用字符,如非美元货币符号)。代码页 1255 定义希伯来字符,而 1256 则定义阿拉伯字符在上部范围内(这些特定代码页也有一些共同点,例如使用 128 表示欧元符号€,163 表示英镑符号£)。

最常见的代码页之一是 1252,因为它是英语环境下的 Windows 默认设置。这并不定义非罗马字母表;相反,它使用上部字符范围来放置有用的符号以及各种罗马字母的重音版本,使得广泛的西欧语言得到适当的表示。

你可以通过调用Encoding.GetEncoding方法,传入代码页号来创建代码页的编码。(你得到的对象的具体类型通常不是我之前列出的那些。这个方法可能会返回从Encoding派生的非公共类型。)示例 15-8 使用此方法将包含英镑符号的文本写入文件,使用代码页 1252。

示例 15-8. 使用 Windows 1252 代码页写入
using (var sw = new StreamWriter("Text.txt", false,
                                 Encoding.GetEncoding(1252)))
{
    sw.Write("£100");
}

这将把£符号编码为单字节,其值为163。使用默认的 UTF-8 编码,则会以两个字节编码,其值分别为194163

直接使用编码

TextReaderTextWriter并不是使用编码的唯一方式。代表编码的对象(如Encoding.UTF8)定义了各种成员。例如,GetBytes方法将string直接转换为byte[]数组,而GetString方法则进行相反的转换。

你还可以了解这些转换会产生多少数据。GetByteCount告诉你为给定字符串调用GetBytes将产生多大的数组,而GetCharCount告诉你解码特定数组将生成多少字符。你还可以找到在不知道确切文本情况下所需空间的上限,通过GetMaxByteCount方法。这个方法接受一个数字而不是一个string,它将其解释为字符串长度;由于.NET 字符串使用 UTF-16,这意味着这个 API 回答的问题是:“如果我有这么多 UTF-16 代码单元,那么在目标编码中表示相同文本可能需要的最大代码单元数是多少?”对于可变长度编码,这可能会产生显著的高估。例如,对于 UTF-8,GetMaxByteCount将输入字符串的长度乘以三³,并额外添加 3 个字节来处理可能出现的代理字符边缘情况。它生成了可能情况的正确描述,但是包含不需要在 UTF-8 中占用 3 个字节的任何字符(即英语或任何使用拉丁字母表的其他语言,以及使用希腊文、西里尔字母、希伯来文或阿拉伯文写作系统的任何文本)将需要比GetMaxByteCount预测的空间少得多。

有些编码可以提供一个preamble,即一系列独特的字节序列,如果在某些编码文本的开头找到它,表明你很可能正在查看使用该编码的内容。当你不知道正在使用哪种编码时,这可能非常有用。各种 Unicode 编码都会返回它们的 BOM 编码作为 preamble,你可以通过GetPreamble方法获取它。

Encoding类定义了实例属性,提供关于编码的信息。EncodingName返回编码的人类可读名称,但还有两个可用的名称。WebName属性返回与 Internet 分配号码管理局(IANA)注册的编码标准名称,该局管理互联网上的标准名称和编号,例如 MIME 类型。一些协议(如 HTTP)有时会将编码名称放入头部,这就是在该情况下应使用的文本。另外两个名称,BodyNameHeaderName,相对更为晦涩,仅用于互联网电子邮件——有不同的约定来表示某些编码在电子邮件正文和标题中的表示方式。

文件和目录

到目前为止,在本章中展示的抽象概念非常通用——您可以编写使用 Stream 的代码,而无需知道其中包含的字节来自何处或将要去哪里;同样,TextReaderTextWriter 不要求其数据有任何特定的起源或目的地。这很有用,因为它使得能够编写可应用于各种情境的代码成为可能。例如,基于流的 GZipStream 可以从文件、网络连接或任何其他流中压缩或解压缩数据。但是,有时您知道自己将处理文件并希望访问特定于文件的功能。本节描述了用于处理文件和文件系统的类。

FileStream 类

FileStream 类继承自 Stream 类,表示文件系统中的文件。我已经偶尔使用过它几次了。相比基类,它只添加了相对较少的成员。LockUnlock 方法提供了在多个进程中使用单个文件时获取特定字节范围的独占访问的方式。Name 属性告诉你文件名。

FileStream 在其构造函数中提供了极大的灵活性——忽略带有 [Obsolete] 属性标记的构造函数,[⁴] 总共有不少于 10 个构造函数重载。创建 FileStream 的方法可分为两组:一种是已经有操作系统文件句柄的情况,另一种是没有文件句柄的情况。如果你已经从某处获得了句柄,你需要告诉 FileStream 该句柄提供了对文件的读、写或读/写访问权限,这可以通过传递 FileAccess 枚举值来实现。其他重载可选地允许你指定在读取或写入时要使用的缓冲区大小,以及一个指示句柄是否已为重叠 I/O(一种支持异步操作的 Win32 机制)打开的标志。(不带该标志的构造函数假定在创建文件句柄时未请求重叠 I/O。)

更常见的是使用其他构造函数,其中 FileStream 使用操作系统 API 代表您创建文件句柄。您可以提供不同级别的详细信息来指定希望如何完成这些操作。至少,您必须指定文件的路径和 FileMode 枚举值。Table 15-1 显示了此枚举定义的值,并描述了 FileStream 构造函数在已命名文件存在和不存在的情况下将如何处理每个值的情况。

表 15-1. FileMode 枚举

文件存在时的行为 文件不存在时的行为
CreateNew 抛出 IOException 创建新文件
Create 替换现有文件 创建新文件
Open 打开现有文件 抛出 FileNotFoundException
OpenOrCreate 打开现有文件 创建新文件
Truncate 替换现有文件 抛出FileNotFoundException
Append 打开现有文件,将Position设置为文件末尾 创建新文件

您也可以选择指定FileAccess。如果不指定,FileStream将使用FileAccess.ReadWrite,除非您选择了FileMode.Append。以追加模式打开的文件只能进行写入操作,因此在这种情况下,FileStream会选择Write。(如果在打开追加模式时传递显式的FileAccess请求除Write之外的任何值,构造函数会抛出ArgumentException。)

顺便说一下,在本节描述每个额外构造函数参数时,相关重载将还会接受之前描述过的所有参数(但useAsync参数除外,该参数仅出现在一个构造函数中)。正如示例 15-9 所示,大多数这些构造函数看起来都和前一个构造函数类似,只是多了一个额外参数。

示例 15-9. 使用路径的FileStream构造函数
public FileStream(string path, FileMode mode)
public FileStream(string path, FileMode mode, FileAccess access)
public FileStream(string path, FileMode mode, FileAccess access,
                  FileShare share)
public FileStream(string path, FileMode mode, FileAccess access,
                  FileShare share, int bufferSize)
public FileStream(string path, FileMode mode, FileAccess access,
                  FileShare share, int bufferSize, bool useAsync)
public FileStream(string path, FileMode mode, FileAccess access,
                  FileShare share, int bufferSize, FileOptions options)

那些接受FileShare类型参数的重载允许您指示是否需要独占文件访问权。如果传递FileShare.None,那么如果文件已在其他地方打开,构造函数将抛出IOException,如果成功打开,则在您完成使用文件之前,没有其他代码能够打开该文件。如果您愿意允许其他进程(或同一进程中的其他代码)同时打开文件,您可以指示您的代码是否能够容忍其他用户同时拥有文件的读取访问权、写入访问权或两者兼有。FileShare是一个类似标志的枚举,因此您可以指定FileShare.Read|FileShare.Write,但由于这是一个常见的组合,FileShare定义了一个预组合的ReadWrite值。

那些不显式指定FileShare的构造函数重载都使用FileShare.Read,这表示您的代码允许其他代码同时打开文件以进行读取,但不允许写入。例如,如果您正在向日志文件写入条目,那么您很可能会使用FileMode.AppendFileShare.Read,这意味着只有您的代码能够追加日志条目,但其他代码仍然可以使用FileAccess.Read打开文件以读取日志。如果两个程序尝试同时以写入访问方式打开同一日志文件,并且两者都指定了FileShare.Read(显式或作为隐式默认值),那么谁先进入就会成功,但第二个尝试时构造函数会抛出IOException,因为尝试打开文件进行写入与文件已经以没有FileShare.Write的方式打开的事实相冲突。在这种情况下,这是您想要的行为——如果两个程序尝试同时向同一文件末尾追加内容,结果将会非常混乱,因为每个程序都会有自己关于文件末尾位置的想法。

如果您尝试打开某个其他代码(可能是另一个进程或应用程序内的其他位置的代码)已经打开的文件,只有在您指定的 FileAccessFileShare 与先前使用该文件的代码所使用的 FileShare 兼容时才会成功。同样地,如果您的代码已经打开了一个文件,那么在那一点上选择的 FileShare 决定了在您使用文件时其他代码可以成功应用的 FileAccessFileShare 组合。例如,如果您想要读取一个其他进程正在写入的日志文件,那么如果那个进程指定了 FileShare.Read,您将需要使用 FileAccess.Read。 (那些未指定 FileAccess 的构造函数默认为 FileAccess.ReadWrite,在这种情况下将失败,因为如果某物已经使用 FileShare.Read 打开文件,则无法获得写访问权限。)但是您还需要指定 FileShare.ReadWrite。这在只想要读取的代码中可能看起来令人惊讶,但它是有道理的:它声明了我们不介意在我们读取时其他代码试图修改文件。FileShare.Read 的默认值表明我们在使用文件时不希望文件发生更改,但这对于从日志文件中读取是错误的选择——如果我们设法使用 FileShare.Read 打开日志文件,那将阻止主应用程序打开日志文件进行写入。

单独指定 FileShare.Write(而不与 FileShare.Read 结合)是合法的,但有点奇怪。它容忍同时存在具有写访问权限的其他句柄,但不允许读取者。您还可以传递 Delete(单独或与 Read 和/或 Write 结合使用),表示您不介意在您打开文件时有人尝试删除它。显然,如果尝试在文件被删除后使用文件,将会收到 I/O 异常,因此您需要为此做好准备,但有时这样做可能值得努力;否则,尝试删除文件时将会被阻止,而您已经打开了文件。

警告

Unix 的文件锁机制比 Windows 少,因此这些锁定语义通常会在这些环境中映射为更简单的东西。此外,在 Unix 中,文件锁是建议性的,这意味着进程可以选择忽略它们。

我们可以传递的下一个信息片段是缓冲区大小。这控制了 FileStream 从文件系统读取和写入时使用的块大小。它的默认值为 4,096 字节。在大多数情况下,这个值都可以很好地工作,但是如果您正在处理非常高的数据量,较大的缓冲区大小可能会提供更好的吞吐量。然而,与所有性能问题一样,您应该测量此类更改的影响,看看是否值得——在某些情况下,您可能看不到数据吞吐量的任何差异,只会使用比必要更多一点的内存。

useAsync 标志允许你确定文件句柄是否以优化大型异步读取和写入的方式打开。(在 Windows 上,这会打开文件进行重叠 I/O,这是支持异步操作的 Win32 特性。)如果你以相对较大的块读取数据,并使用流的异步 API,通常通过设置此标志可以获得更好的性能。但是,如果每次读取几个字节,这种模式实际上会增加开销。如果访问文件的代码对性能特别敏感,值得尝试两种设置,看看哪种对你的工作负载更有效。正如前面提到的,这也使得可以在单个 FileStream 上执行多个并发操作。

下一个参数可以添加的类型是FileOptions。如果你非常注意的话,你会注意到在示例 15-9 中,到目前为止我们看过的每一个重载都添加了一个新参数,但是在这个中,FileOptions 参数替换了 bool useAsync 参数。这是因为你可以用 FileOptions 指定的选项之一是异步访问。FileOptions 是一个标志枚举,所以你可以指定它提供的任何组合标志,这些标志在表 15-2 中有描述。

表 15-2. FileOptions 标志

标志 含义
WriteThrough 禁用操作系统写入缓冲,数据直接传递到存储设备当你刷新流时
Asynchronous 指定使用异步 I/O
RandomAccess 提示文件系统缓存,你将会进行查找,而不是按顺序读取或写入数据
SequentialScan 提示文件系统缓存,你将按顺序读取或写入数据
DeleteOnClose 告诉 FileStream 在调用 Dispose 时删除文件
Encrypted 加密文件,以防其他用户读取其内容

要注意WriteThrough 标志。虽然它按照预期工作,但可能没有预期的效果,因为某些硬盘会延迟写入以提高性能(许多硬盘有自己的 RAM,能够非常快速地接收来自计算机的数据,并在真正存储数据之前报告写入操作已完成)。WriteThrough 标志将确保当你释放或刷新流时,你写入的所有数据都已传送到驱动器,但驱动器不一定已将该数据持久写入,因此如果电源故障,你仍可能丢失数据。确切的行为将取决于你如何告诉操作系统配置驱动器。

.NET 6.0 添加了一个新的重载,接受两个参数:一个string(文件的路径)和一个FileStreamOptionsFileStreamOptions定义了我们刚刚讨论的每个设置的属性。因此,它具有ModeAccessShareOptionsBufferSize。它还添加了一个新的设置,新添加到.NET 6.0 中,PreallocationSize,允许应用程序指示文件预计需要占用的空间大小。这使得操作系统可以检测到当空间不足时,并且可以预留空间,减少由于磁盘空间不足而导致的操作失败的可能性。FileStreamOptions的重载使得只设置那些不想要默认值的方面变得更容易——您只需设置相关属性。这意味着,如果没有一个FileStream构造函数重载正好符合您需要的参数组合,那也不再不方便。

虽然FileStream可以控制文件的内容,但有些操作可能非常繁琐,或者FileStream根本不支持。例如,您可以使用这个类复制文件,但这并不像可能的那么简单,并且FileStream没有提供任何删除文件的方法。因此,运行时库包含了一个专门的类来处理这类操作。

文件类

静态类File提供了各种文件操作的方法。Delete方法从文件系统中删除指定的文件。Move方法可以移动或重命名文件。还有一些方法用于检索文件系统存储的关于每个文件的信息和属性,如GetCreationTimeGetLastAccessTimeGetLastWriteTime⁵和GetAttributes。(最后一个返回一个FileAttributes值,这是一个标志枚举类型,告诉您文件是否为只读、隐藏文件、系统文件等等。)

Encrypt方法在某种程度上与FileStream重叠——正如您之前看到的,您可以在创建文件时请求以加密方式存储它。然而,Encrypt能够处理已经创建但未加密的文件——它会在原地对其进行加密。(这仅在 Windows 上支持,在文件系统支持的驱动器上有效。在其他操作系统上会抛出PlatformNotSupportedException异常,在 Windows 上如果指定的文件不支持加密也会抛出NotSupportedException异常。这与通过 Windows 文件资源管理器中的文件属性窗口启用加密具有相同的效果。)您还可以通过调用Decrypt将加密文件转换回未加密状态。

注意

在读取加密文件之前,不需要先调用Decrypt。在以加密文件的相同用户账户登录时,可以像平常一样读取其内容——加密文件看起来就像普通文件,因为 Windows 在读取时会自动解密内容。这种特定的加密机制的目的是,如果其他用户设法获取文件访问权限(例如,如果它在被盗的外部驱动器上),文件内容将会看起来像随机垃圾。Decrypt移除了这种加密,这意味着任何能够访问文件的人都能查看其内容。

File提供的其他方法只是提供了更方便的方式来完成可以用FileStream手动完成的事情。Copy方法复制文件,虽然你可以使用FileStreamCopyTo方法来完成这个操作,但Copy方法会处理一些棘手的细节。例如,它确保目标文件保留诸如是否只读和是否启用加密等属性。

Exists方法允许你在尝试打开文件之前发现文件是否存在。虽然在尝试打开不存在的文件时,FileStream会抛出FileNotFound异常,但是Exists在你只需确定文件是否存在而不需要进行其他操作时很有用。如果你打算无论如何都要打开文件,并且只是想避免异常,那么你应该谨慎使用这个方法;仅仅因为Exists返回true并不意味着你不会收到FileNotFound异常。总是有可能在你检查文件存在性和尝试打开它之间,另一个进程删除了文件。或者,文件可能位于网络共享中,你可能会失去网络连接。因此,即使你试图避免引发异常,也应该时刻准备处理文件访问时可能出现的异常。

File提供了许多辅助方法来简化打开或创建文件的过程。Create方法简单地为你构造一个FileStream,传入适当的FileModeFileAccessFileShare值。示例 15-10 展示了如何使用它,同时展示了如果不使用Create辅助方法,等效的代码会是什么样子。Create方法提供了重载,允许你指定缓冲区大小、FileOptionsFileSecurity,但仍然为你提供了其他参数。

示例 15-10. File.Create versus 新建 FileStream
using (FileStream fs = File.Create("foo.bar"))
{
   ...
}

// Equivalent code without using File class
using (var fs = new FileStream("foo.bar", FileMode.Create,
                               FileAccess.ReadWrite, FileShare.None))
{
    ...
}

File 类的 OpenReadOpenWrite 方法为当你想要打开现有文件以供读取或打开或创建文件以供写入时提供了类似的简化功能。还有一个需要传递 FileModeOpen 方法。这种方法的效用较低——它与也接受路径和模式参数的 FileStream 构造函数重载非常相似,自动提供适当的其他设置。它们的某种任意的区别在于,虽然 FileStream 构造函数默认为 FileShare.Read,但 File.Open 方法默认为 FileShare.None

File 还提供了几个面向文本的辅助方法。最简单的方法是 OpenText,用于打开一个文本读取文件,但其价值有限,因为它与接受单个字符串参数的 StreamReader 构造函数的功能完全相同。只有在你偏好它使你的代码看起来更加整洁时才会使用它——如果你的代码大量使用 File 辅助方法,你可能会选择出于惯用性一致性的考虑使用它。

File 类暴露的几种方法都是面向文本的。这些方法使我们能够改进类似 示例 15-11 中所示的代码。它向日志文件追加一行文本。

示例 15-11. 使用 StreamWriter 向文件追加
static void Log(string message)
{
    using (var sw = new StreamWriter(@"c:\temp\log.txt", true))
    {
        sw.WriteLine(message);
    }
}

其中一个问题是一眼看不出 StreamWriter 是如何被打开的——true 参数的含义是什么?事实上,这告诉 StreamWriter 我们希望它在追加模式下创建底层的 FileStream。示例 15-12 也具有相同的效果——它使用 File.AppendText,为我们调用完全相同的 FileStream 构造函数。尽管我之前对于 File.OpenText 的评价有些苛刻,认为它提供的价值较低,但我认为 File.AppendText 曾经确实在可读性方面提供了真正有用的改进,而 File.OpenText 并没有。相比之下,通过 C# 添加了对命名参数的支持后,AppendText 看起来不再那么有用了——我们可以在 示例 15-11 中为 append 参数命名以达到类似的可读性改进。

示例 15-12. 使用 File.AppendText 创建一个追加的 StreamWriter
static void Log(string message)
{
    using (StreamWriter sw = File.AppendText(@"c:\temp\log.txt"))
    {
        sw.WriteLine(message);
    }
}

如果你只想将一些文本附加到文件并立即关闭它,有一种更简单的方法。正如 示例 15-13 所示,我们可以使用 AppendAllText 辅助方法进一步简化事情。

示例 15-13. 将单个字符串附加到文件末尾
static void Log(string message)
{
    File.AppendAllText(@"c:\temp\log.txt", message);
}

要小心哦。这与示例 15-12 并不完全相同。该示例使用了WriteLine来追加文本,但示例 15-13 相当于只使用了Write。因此,如果你在多次调用示例 15-13 中的Log方法,除非你使用的字符串恰好包含行尾字符,否则你最终会在输出文件中得到一行长文本。如果你想逐行处理,可以使用AppendAllLines方法,该方法接受一个字符串集合,并将每个字符串作为新行追加到文件末尾。示例 15-14 使用此方法在每次调用时追加一整行。

示例 15-14. 向文件追加单行
static void Log(string message)
{
    File.AppendAllLines(@"c:\temp\log.txt", new[] { message });
}

由于 AppendAllLines 接受 IEnumerable<string>,因此您可以使用它来追加任意数量的行。但是如果您只想追加一行,它也完全可以胜任。File 还定义了 WriteAllTextWriteAllLines 方法,它们的工作方式非常相似,但如果指定路径处已经存在文件,它们将替换它而不是追加到它。

还有一些用于读取文件内容的相关文本方法。ReadAllText执行的是构造一个 StreamReader,然后调用其 ReadToEnd 方法的等效操作——它将整个文件内容作为一个单独的 string 返回。ReadAllBytes将整个文件读入一个 byte[] 数组。ReadAllLines将整个文件作为一个 string[] 数组读取,每行一个元素。ReadLines看起来非常相似。它以 IEnumerable<string> 的形式提供对整个文件的访问,每行一个条目,但不同之处在于它是懒加载的——与我在本段描述的所有其他方法不同,它不会一次性将整个文件读入内存,因此对于非常大的文件来说,ReadLines是更好的选择。它不仅消耗更少的内存,而且使您的代码能够更快地启动——只要从磁盘读取到第一行数据,您就可以开始处理数据,而其他方法在读取整个文件之前都不会返回。

Directory 类

就像 File 是一个静态类,提供用于执行文件操作的方法一样,Directory 也是一个静态类,提供用于执行目录操作的方法。其中一些方法与 File 提供的方法非常相似——例如,有方法来获取和设置创建时间、最后访问时间和最后写入时间,还有 MoveExistsDelete 方法。与 File 不同,Directory.Delete 有两个重载。一个只接受路径,只有在目录为空时才起作用。另一个还接受一个 bool 参数,如果为 true,将递归删除文件夹中的所有内容,包括嵌套的文件夹和它们包含的文件。请谨慎使用这个方法。

当然,还有专用于目录的方法。GetFiles接受一个目录路径,并返回包含该目录中每个文件的完整路径的string[]数组。还有一个重载方法,允许您指定一个模式来过滤结果,并且第三个重载方法接受一个模式,并且还可以使用一个标志来请求递归搜索所有子文件夹。示例 15-15 使用它来查找我的Pictures文件夹中所有具有*.jpg*扩展名的文件。(除非您也叫伊恩,否则您需要更改该路径以匹配您的帐户名称,以使其在您的计算机上起作用。)在实际应用程序中,您应该使用“已知文件夹”中显示的技术获取此路径。

示例 15-15. 递归搜索特定类型的文件
foreach (string file in Directory.GetFiles(@"c:\users\ian\Pictures",
                                           "*.jpg",
                                           SearchOption.AllDirectories))
{
    Console.WriteLine(file);
}

还有一个类似的GetDirectories方法,提供相同的三个重载,它返回指定目录中的目录而不是文件。还有一个GetFileSystemEntries方法,同样有三个重载,它返回文件和文件夹。

还有一些称为EnumerateFilesEnumerateDirectoriesEnumerateFileSystemEntries的方法,与三个GetXxx方法完全相同,但它们返回IEnumerable<string>。这是一种延迟枚举,因此您可以立即开始处理结果,而不是等待所有结果作为一个大数组。

Directory类提供与进程当前目录(每次调用文件 API 而不指定完整路径时使用的目录)相关的方法。GetCurrentDirectory返回路径,SetCurrentDirectory设置它。

您也可以创建新目录。CreateDirectory方法接受一个路径,并尝试创建尽可能多的目录,以确保路径存在。因此,如果您传递C:\new\dir\here,并且没有C:\new目录,它将创建三个新目录:首先它将创建C:\new,然后C:\new\dir,然后C:\new\dir\here。如果您请求的文件夹已经存在,它不会将其视为错误;它只是返回而不执行任何操作。

GetDirectoryRoot将目录路径剥离为驱动器名称或其他根目录,例如网络共享名称。例如,在 Windows 上,如果您传递C:\temp\logs,它将返回C:*;如果您传递\someserver\myshare\dir\test*,它将返回*\someserver\myshare*。这种字符串分割操作,即将路径拆分为其组成部分,是一个非常常见的需求,因此有一个专门的类来处理这类操作。

路径类

Path 类提供了一些有用的工具函数,用于处理包含文件名的字符串。其中一些函数用于从文件路径中提取片段,比如包含的文件夹名称或文件扩展名。还有一些函数用于组合字符串,生成新的文件路径。这些大多数方法仅执行特定的字符串处理,不需要路径所指代的文件或目录真实存在。然而,也有一些方法超出了字符串操作的范畴。例如,Path.GetFullPath 方法会考虑当前目录,如果传入的参数不是绝对路径的话。但只有需要使用真实位置的方法才会这样做。

Path.Combine 方法处理了在组合文件夹和文件名时遇到的繁琐问题。如果你有一个文件夹名 C:\temp 和一个文件名 log.txt,将它们同时传给 Path.Combine 方法会返回 C:\temp\log.txt。如果将 *C:\temp* 作为第一个参数传入,它也会正常工作,因此其中一个处理的问题是确定是否需要提供额外的 \ 字符。如果第二个路径是绝对路径,它会检测并简单地忽略第一个路径,因此如果你传入 C:\tempC:\logs\log.txt,结果将会是 C:\logs\log.txt。虽然这些问题可能看起来微不足道,但如果尝试通过字符串连接自己做文件路径的组合,很容易出错,因此你应该始终避免这样做,而是使用 Path.Combine 方法。

当涉及路径时,.NET Core 和 .NET 在不同平台上表现不同。在类 Unix 系统上,只使用 / 作为目录分隔符,因此 Path 类中期望路径包含目录的各种方法会在这些系统上将 / 视为唯一的分隔符。Windows 使用 \ 作为分隔符,尽管在 Windows 上也普遍容忍使用 / 作为替代,Path 类也支持这一点。因此,Path​.Com⁠bine("/x/y", "/z.txt") 在 Windows 和 Linux 上会产生相同的结果,但 Path.Combine(@"\x\y", @"\z.txt") 则不会。此外,在 Windows 上,如果路径以驱动器号开头,则是绝对路径,但 Unix 不认识驱动器号。在上文中的例子中,如果在 Linux 或 macOS 上移除驱动器号并将 \ 替换为 /,结果将会是你所期望的。

给定文件路径,GetDirectoryName 方法会移除文件名部分,仅返回目录。这个方法很好地说明了为什么你需要记住大多数 Path 类的成员不会查看文件系统。如果你没有考虑这一点,你可能会期望当你传递给 GetDirectoryName 一个目录名,比如 C:\Program Files,它会检测到这是一个目录并返回相同的字符串,但事实上它将仅返回 *C:*。名称 Program Files 对于文件或目录来说都是一个完全合法的名称,由于 GetDirectoryName 不会检查磁盘,并且它期望传递的路径包括文件名,因此在这种情况下它会认为这是一个文件。(可以说,即使它意识到 C:\Program Files 是一个目录,*C:* 也将是正确的结果,因为那是包含 Program Files 目录的目录。)该方法有效地查找最后的 /\ 字符,并返回其前面的所有内容。(因此,如果你传递一个带有尾部 \ 的目录名,比如 *C:\Program Files*,它将返回 C:\Program Files。然而,这个 API 的整个目的是从文件的完整路径中移除文件名。如果你已经有一个只有目录名的字符串,你不应该调用这个 API。)

GetFileName 方法返回文件名(包括扩展名,如果有)。和 GetDirectoryName 一样,它也查找最后的目录分隔符字符,但返回的是它后面的文本,而不是前面的文本。同样,它不查看文件系统——这完全通过字符串操作完成(尽管和所有这些操作一样,它考虑了本地系统对于目录分隔符或绝对路径的规则)。GetFileNameWithoutExtension 类似,但如果存在扩展名(如 .txt.jpg),它会从名称的末尾移除扩展名。相反,GetExtension 返回扩展名而不返回其他内容。

如果您需要创建临时文件来执行一些工作,Path 提供了三个有用的方法。GetRandomFileName 使用随机数生成器创建一个您可以用于随机文件或文件夹的名称。这个随机数是密码强度的,具有两个有用的属性:名称将是唯一且难以猜测的。(如果攻击者能够预测临时文件的名称或位置,系统安全的某些攻击可能变得可能。)这个方法实际上不会在文件系统上创建任何内容,它只是返回一个合适的名称。另一方面,GetTempFileName 将在操作系统为临时文件提供的位置创建一个文件。这个文件将是空的,并且该方法返回其路径作为一个字符串。然后您可以打开文件并修改它。(这并不保证使用加密来选择一个真正随机的名称,因此不应依赖于此类文件位置的不可猜测性。它将是唯一的,但仅此而已。)您应该在完成对其的操作后删除由 GetTempFileName 创建的任何文件。最后,GetTempPath 返回 GetTempFileName 将使用的文件夹的路径;这并不会创建任何内容,但您可以与 GetRandomFileName 返回的名称(与 Path.Combine 结合使用)一起使用它来选择一个位置来创建您自己的临时文件。

FileInfo、DirectoryInfo 和 FileSystemInfo

虽然 FileFolder 类提供了访问信息的方式——例如文件的创建时间以及它是系统文件还是只读文件——但如果您需要访问多个信息,这些类存在问题。使用单独的调用收集每个数据位不是很高效,因为可以通过更少的步骤从底层操作系统获取信息。此外,有时传递包含您需要的所有数据的单个对象可能更容易,而不是找到放置许多单独项目的地方。因此,System.IO 命名空间定义了包含有关文件或目录信息的 FileInfoDirectoryInfo 类。由于存在一定的共同点,这些类型都派生自基类 FileSystemInfo

要构造这些类的实例,您需要传递您想要的文件或文件夹的路径,就像 示例 15-16 中所示。顺便说一句,如果稍后您认为文件可能已被其他程序更改,并且您想要更新 FileInfoDirectoryInfo 返回的信息,您可以调用 Refresh,它将重新加载来自文件系统的信息。

示例 15-16. 使用 FileInfo 显示有关文件的信息
var fi = new FileInfo(@"c:\temp\log.txt");
Console.WriteLine(
    $"{fi.FullName} ({fi.Length} bytes) last modified on {fi.LastWriteTime}");

除了提供对应于各种FileDirectory方法获取信息(CreationTimeAttributes等)的属性外,这些信息类还提供了实例方法,这些方法对应于FileDirectory的许多静态方法。例如,如果你有一个FileInfo,它提供了DeleteEncryptDecrypt等方法,这些方法的工作方式与它们的File名称相同,只是你不需要传递路径参数。还有一个名为MoveTo的对应方法,尽管名字不同,但功能相同。

FileInfo还提供了与使用StreamFileStream打开文件的各种辅助方法的等效方法,例如AppendTextOpenReadOpenText。也许更令人惊讶的是,CreateCreateText也是可用的。事实证明,你可以为尚不存在的文件构造一个FileInfo,然后使用这些辅助方法创建它。它不会尝试填充描述文件的任何属性,直到你尝试读取它们的第一次,因此它会推迟在那一点抛出FileNotFound​Ex⁠ception,以防你创建FileInfo是为了创建新文件。

如你所料,DirectoryInfo也提供了实例方法,这些方法对应于Directory定义的各种静态辅助方法。

已知文件夹

桌面应用程序有时需要使用特定的文件夹。例如,应用程序的设置通常存储在用户配置文件夹的特定文件夹中。有一个专门用于系统范围应用程序设置的文件夹。在 Windows 上,这些通常位于用户的AppData文件夹和C:\ProgramData文件夹中。Windows 还定义了图片、视频、音乐和文档的标准位置,还有代表特殊外壳功能的文件夹,例如桌面和用户的“收藏夹”。

尽管这些文件夹在不同系统中通常位于相同位置,但你不应假设它们会出现在你期望的位置。(因此,在真实代码中,你不应像示例 15-15 那样做。)许多这些文件夹在 Windows 的本地化版本中有不同的名称。甚至在特定语言中,也不能保证这些文件夹会出现在通常的位置——有些文件夹是可以移动的,并且它们的位置在不同版本的 Windows 中并不固定。

因此,如果需要访问特定的标准文件夹,应使用Environment类的GetFolderPath方法,如示例 15-17 中所示。这个方法接受来自嵌套的Environment.SpecialFolder枚举类型的成员,该枚举定义了 Windows 中所有已知文件夹类型的值。

示例 15-17. 发现存储设置的位置
string appSettingsRoot =
    Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
string myAppSettingsFolder =
    Path.Combine(appSettingsRoot, @"Endjin\FrobnicatorPro");
注意

在非 Windows 系统上,对于大多数此枚举条目,GetFolderPath 返回空字符串,因为没有本地等效项。但是,有些项确实有效,比如 MyDocumentsCommon​Ap⁠plicationDataUserProfile

ApplicationData 文件夹位于用户配置文件的漫游部分。不需要在用户使用的所有计算机上复制的信息(例如,如果需要可以重建的缓存),应放在本地部分,可以使用 LocalApplicationData 枚举项获取。

序列化

StreamTextReaderTextWriter 类型提供了在文件、网络或其他类似流的任何东西中读取和写入数据的能力,只要提供一个适当的具体类。但这些抽象仅支持字节或文本数据。假设您有一个具有多个类型属性的对象,包括一些数值类型,可能还包括对其他对象的引用,其中一些可能是集合。如果您希望将该对象的所有信息写入文件或通过网络连接发送,以便稍后在同一类型的对象和相同属性值的计算机上或连接的另一端重新构建该对象,该怎么办?

您可以使用本章中显示的抽象来完成此操作,但这需要相当多的工作。您需要编写代码来读取每个属性并将其值写入到 StreamTextWriter 中,并且需要将值转换为二进制或文本。您还需要决定您的表示方式——您只是按固定顺序写入值,还是会设计一种方案来写入名称/值对,以便在日后需要添加更多属性时不受限制?您还需要想出处理集合和对其他对象的引用的方法,并且需要决定在面对循环引用时应采取的措施——如果两个对象互相引用,简单的代码可能会陷入无限循环。

.NET 提供了几种解决此问题的方案,每种方案在能够支持的场景复杂性、版本控制的处理能力以及与其他平台的互操作性方面存在不同的权衡。这些技术都属于广义上的序列化(因为它们涉及将对象的状态按顺序写入某种形式的数据存储中——序列化——例如 Stream)。多年来在 .NET 中引入了许多不同的机制,所以我不会涵盖所有内容。我只会介绍最能代表特定方法处理该问题的几种方式。

BinaryReader、BinaryWriter 和 BinaryPrimitives

虽然它们不严格属于序列化形式,但任何关于此领域的讨论都不完整,没有涵盖BinaryReaderBinaryWriter类,因为它们解决了任何序列化和反序列化对象尝试必须处理的基本问题:它们可以将 CLR 的内置类型转换为字节流,BinaryPrimitives也做同样的事情,但它能够处理Span<byte>和相关类型,这些在第十八章中讨论过内存效率。

BinaryWriter是围绕可写的Stream的包装器。它提供了一个Write方法,支持除了object类型以外的所有内置类型的重载。因此,它可以接受任何数值类型、stringcharbool类型的值,并将该值的二进制表示写入Stream中。它还可以写入bytechar类型的数组。

BinaryReader是围绕可读的Stream的包装器,提供了各种用于读取数据的方法,每种方法对应于BinaryWriter提供的Write的重载。例如,您有ReadDoubleReadInt32ReadString等方法。

要使用这些类型,当您想要序列化一些数据时,可以创建一个BinaryWriter,并写入每个要存储的值。稍后当您想要反序列化该数据时,可以围绕包含写入的数据的流创建一个BinaryReader,并按照与首次写出数据时完全相同的顺序调用相关的读取方法。

BinaryPrimitives的工作方式略有不同。它专为需要最小化堆分配数量的代码设计,因此它不是一个包装器类型,而是一个静态类,提供了广泛的方法,如ReadInt32LittleEndianWriteUInt16BigEndian。这些方法分别接受ReadOnlySpan<byte>Span<byte>参数,因为它设计为直接处理内存中的数据(不一定是包装在Stream中)。然而,其基本原理是相同的:它在字节序列与基本的.NET 类型之间进行转换。(另外,字符串处理相对复杂:没有ReadString方法,因为返回string的任何方法都会在堆上创建一个新的字符串对象,除非有一个预先分配并反复分配的固定字符串集。有关详细信息,请参见第十八章。)

这些类仅解决了如何以二进制形式表示各种内置类型的问题。您仍然需要解决如何表示整个对象以及如何处理对象之间的引用等更复杂的结构。

CLR 序列化

CLR 序列化如其名称所示,是内置到运行时本身的特性——它不仅仅是一个库功能。虽然它自.NET Framework 1.0 起就存在,但在最初几个版本的.NET Core 中并未支持,但微软最终以一种较简化的形式将其重新添加回去,以便更轻松地从.NET Framework 迁移应用程序。微软不鼓励使用它,但在某些场景中它仍然很受欢迎。在微服务环境中,它被广泛用于跨服务边界发送异常和相对简单的数据结构。.NET Core 和.NET 提供的有限支持针对这些场景,因此你不能对任意的.NET 对象进行序列化。

CLR 序列化最有趣的方面是它直接处理对象引用。如果你序列化一个List<SomeType>,其中列表中的多个条目引用同一个对象,CLR 序列化会检测到这一点,只存储该对象的一个副本,在反序列化时会重新创建这个一个对象多个引用的结构。(基于非常广泛使用的 JSON 格式的序列化系统通常不会这样做。)

类型需要选择 CLR 序列化。.NET 定义了一个[Serializable]属性,必须存在,一旦你添加了这个属性,CLR 就可以为你处理所有细节。序列化直接使用对象的字段。它使用反射来访问所有字段,无论是公共的还是私有的。BinaryFormatter类型(位于System.Runtime.Serialization.Formatters.Binary命名空间中)提供了一个Serialize方法,可以将任何可序列化类型的实例写入流中。它正确地检测到循环引用,在流中仅存储每个对象的一个副本。当我们将生成的流传递给Deserialize方法时,它将正确地恢复任何这样的结构。

因此这非常强大——通过添加一个属性,我可以将整个对象图写出来。但也有缺点:如果我改变了任何被序列化类型的实现,那么当新版本的代码尝试反序列化旧版本生成的流时,我会遇到问题。因此,这并不适合将应用程序的设置写入磁盘,因为这些设置可能会随着每个新版本而演变。实际上,你可以定制序列化的方式,这样可以支持版本控制,但到了这一步,你又要手工完成大部分工作。(实际上使用BinaryReaderBinaryWriter可能更容易。)此外,使用这种序列化方式很容易引入安全问题:控制反序列化流的人基本上可以完全控制你对象的所有字段。文档指出,BinaryFormatter“不安全且无法安全使用”,当你尝试使用它时会看到弃用警告。因此,我在这里只是描述 CLR 序列化,因为尽管微软试图终止它,但它仍然在使用,而且因为它的存在意味着你可能对对象创建有所假设——特别是引用类型只能通过其构造函数或通过MemberwiseClone创建,但事实证明这并非正确。

CLR 序列化的另一个问题是它生成的二进制流是.NET 特定的格式。如果需要处理该流的代码仅在.NET 上运行,那么这不是问题,但你可能希望生成更广泛受众使用的流。除了 CLR 序列化外,还有其他的序列化机制,这些机制可以生成其他系统更容易消费的流。

JSON

JSON(JavaScript 对象表示法)是一种非常广泛使用的序列化格式,.NET 运行时库提供了在System.Text.Json命名空间中处理它的支持。⁶ 它提供了三种处理 JSON 数据的方式。

Utf8JsonReaderUtf8JsonWriter类型是类似流的抽象,它们将 JSON 数据内容表示为一系列元素。如果需要处理太大无法一次性加载到内存中的 JSON 文档,它们非常有用。它们构建在第十八章描述的内存高效机制上,该章包括一个完整示例,展示了如何使用这些类型处理 JSON。这是一个非常高性能的选择,但使用起来并不是最容易的。

注意

正如名称所示,这些类型使用 UTF-8 编码来读取和写入 JSON。这是发送和存储 JSON 数据最广泛使用的编码方式,因此所有 System.Text.Json 都针对其进行了优化。因此,性能敏感的代码通常应避免在 .NET string 中获取 JSON 文档,因为这将使用 UTF-16 编码,需要在您可以使用这些 API 之前转换为 UTF-8。

还有 JsonSerializer 类,它在整个 .NET 对象和 JSON 之间进行转换。它要求您定义的类结构与 JSON 对应。

最后,System.Text.Json 提供了可以提供 JSON 文档结构描述的类型。当您在开发时不确定 JSON 数据结构的确切形式时,这些类型非常有用,因为它们提供了一个灵活的对象模型,可以适应任何形状的 JSON 数据。事实上,有两种方法可以实现这种方法。我们有 JsonDocumentJsonElement 和相关类型,提供了一种高效的只读机制,用于检查 JSON 文档,以及更灵活但略显低效的 JsonNode,它是可写的,使您可以从头开始构建 JSON 的描述,或者读入一些 JSON 然后修改它。

JsonSerializer

JsonSerializer 提供了一种基于属性的序列化模型,您可以在其中定义一个或多个类,反映您需要处理的 JSON 数据的结构,然后可以在这些模型之间进行 JSON 数据的转换。

示例 15-18 展示了一个简单的模型,适合与 JsonSerializer 一起使用。正如您所见,我不必使用任何特定的基类,也没有强制要求的属性。

示例 15-18. 简单的 JSON 序列化模型
public class SimpleData
{
    public int Id { get; set; }
    public IList<string>? Names { get; set; }
    public NestedData? Location { get; set; }
    public IDictionary<string, int>? Map { get; set; }
}

public class NestedData
{
    public string? LocationName { get; set; }
    public double Latitude { get; set; }
    public double Longitude { get; set; }
}

示例 15-19 创建了此模型的一个实例,然后使用 JsonConvert 类的 Serialize 方法将其序列化为字符串。

示例 15-19. 使用 JsonSerializer 序列化数据
var model = new SimpleData
{
    Id = 42,
    Names = new[] { "Bell", "Stacey", "her", "Jane" },
    Location = new NestedData
    {
        LocationName = "London",
        Latitude = 51.503209,
        Longitude = -0.119145
    },
    Map = new Dictionary<string, int>
    {
        { "Answer", 42 },
        { "FirstPrime", 2 }
    }
};

string json = JsonSerializer.Serialize(
    model,
    new JsonSerializerOptions { WriteIndented = true });
Console.WriteLine(json);

Serialize 的第二个参数是可选的。我在这里使用它来缩进 JSON,使其更易于阅读。(默认情况下,JsonSerializer 将使用更高效的布局,没有不必要的空格,但这样更难阅读。)结果如下所示:

{
  "Id": 42,
  "Names": [
    "Bell",
    "Stacey",
    "her",
    "Jane"
  ],
  "Location": {
    "LocationName": "London",
    "Latitude": 51.503209,
    "Longitude": -0.119145
  },
  "Map": {
    "Answer": 42,
    "FirstPrime": 2
  }
}

正如您所见,每个 .NET 对象都变成了一个 JSON 对象,其中名称/值对应于模型中的属性。数字和字符串的表示与您的预期完全一致。IList<string> 变成了一个 JSON 数组,而 IDictionary<string, int> 则变成了另一个 JSON 字典。我在这些集合中使用了接口,但您也可以使用具体的 List<T>Dictio⁠nary​<TKey,TValue> 类型。如果您喜欢,也可以使用普通的数组来表示列表。我倾向于使用接口,因为这样可以自由地使用任何集合类型(例如,示例 15-19 使用字符串数组初始化了 Names 属性,但也可以使用 List<string> 而不更改模型类型)。

将序列化后的 JSON 转换回模型同样简单,正如 示例 15-20 所示。

示例 15-20. 使用 JsonSerializer 反序列化数据
var deserialized = JsonSerializer.Deserialize<SimpleData>(json);

尽管如此简单的模型通常就足够了,但有时您可能需要控制序列化的某些方面,特别是在使用外部定义的 JSON 格式时。例如,您可能需要与使用与 .NET 不同的命名约定的 JSON API 一起工作——驼峰命名法很受欢迎,但与 .NET 属性的帕斯卡命名法冲突。解决此问题的一种方法是使用 JsonPropertyName 属性来指定在 JSON 中使用的名称,如 示例 15-21 所示。

示例 15-21. 使用 JsonPropertyName 属性控制 JSON
public class NestedData
{
 [JsonPropertyName("locationName")]
    public string? LocationName { get; set; }

 [JsonPropertyName("latitude")]
    public double Latitude { get; set; }

 [JsonPropertyName("longitude")]
    public double Longitude { get; set; }
}

在序列化时,JsonSerializer 会使用 JsonPropertyName 中指定的名称,并在反序列化时寻找这些名称。这种方法完全控制了 .NET 和 JSON 属性的命名,但在特定情况下也有更简单的解决方案。这种仅更改首字母大小写的重命名是如此常见,以至于可以让 JsonSerializer 来完成。传递给 JsonSerializer.SerializeJsonSerializationOptions 可以选择使用 JsonSerializerDefaults 类型的可选构造函数参数,如果像 示例 15-22 中那样传递 JsonSeri⁠ali⁠zerDefaults.Web,将会得到驼峰样式的命名,而无需使用任何属性。

示例 15-22. 使用 JsonSerializerDefaults 来获取驼峰式属性名称
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web)
{
    WriteIndented = true
};
string json = JsonSerializer.Serialize(
    model,
    options);

JsonSerializerOptions 还提供了处理循环引用的方法。假设你要序列化 SelfRef 类型的对象,如 示例 15-23 所示。

示例 15-23. 支持循环引用的类型
public class SelfRef
{
    public string? Name { get; set; }
    public SelfRef? Next { get; set; }
}

默认情况下,如果试图序列化直接或间接引用彼此的对象,将会收到 JsonException 报告可能存在循环。它说“可能”是因为默认情况下它不直接检测循环,而是 JsonSerializer 对任何对象图的深度都有一个限制。这可以通过 JsonSerializerOptions.MaxDepth 属性进行配置,但默认情况下,如果超过 64 个对象的深度,序列化器将报告错误。但是,可以通过设置 ReferenceHandler 来更改其行为。示例 15-24 将此设置为 ReferenceHandler.Preserve,使其能够序列化相互引用的 SelfRef 实例对。

示例 15-24. 序列化支持循环引用的类型
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web)
{
    WriteIndented = true,
    ReferenceHandler = ReferenceHandler.Preserve
};
var circle = new SelfRef
{
    Name = "Top",
    Next = new SelfRef
    {
        Name = "Bottom",
    }
};
circle.Next.Next = circle;
string json = JsonSerializer.Serialize(circle, options);

为实现此目的,JsonSerializer 通过添加一个 $id 属性为对象分配标识符:

{
  "$id": "1",
  "name": "Top",
  "next": {
    "$id": "2",
    "name": "Bottom",
    "next": {
      "$ref": "1"
    }
  }
}

这使得序列化器在遇到循环引用时能够避免问题。每当它需要序列化属性时,它会检查该属性是否引用了已经写出的对象(或正在写出的对象)。如果是这样,而不是尝试再次写出对象(这将导致无限循环,因为它会不断遇到循环引用),序列化器会发出一个 JSON 对象,其中包含一个特殊名称为 $ref 的属性,指向相关的 $id。这不是 JSON 的普遍支持形式,因此 ID 生成默认情况下未启用。

你可以使用 JsonSerializerOptions 控制序列化的许多其他方面,例如为数据类型定义自定义序列化机制。例如,你可能想在 C# 代码中表示某些内容为 DateTimeOffset,但希望在 JSON 中将其变为带有特定日期时间格式的字符串。详细信息可以在 System.Text.Json 文档中找到。

JSON 文档对象模型

JsonSerializer 要求你定义一个或多个类型来表示你想处理的 JSON 结构,而 System.Text.Json 则提供了一组固定类型,支持更动态的方法。你可以构建一个文档对象模型(DOM),其中诸如 JsonElementJsonNode 的类型实例表示 JSON 的结构。

System.Text.Json 提供了两种构建 DOM 的方式。如果你已经有 JSON 格式的数据,可以使用 JsonDocument 类获取 JSON 的只读模型,其中每个对象、值和数组都表示为 JsonElement,而对象中的每个属性则表示为 JsonProperty。示例 15-25 使用 JsonDocument 调用 RootElement.EnumerateObject() 来发现 JSON 根对象中的所有属性。这返回一个 JsonProperty 结构的集合。

示例 15-25. 使用 JsonDocumentJsonElement 进行动态 JSON 检查
using (JsonDocument document = JsonDocument.Parse(json))
{
    foreach (JsonProperty property in document.RootElement.EnumerateObject())
    {
        Console.WriteLine($"Property: {property.Name} ({property.Value.ValueKind})");
    }
}

运行这段代码,处理早期示例生成的序列化文档后,会产生以下输出:

Property: id (Number)
Property: names (Array)
Property: location (Object)
Property: map (Object)

正如所示,我们能够在运行时发现存在的属性。JsonProperty.Value 返回一个 JsonElement 结构,可以检查其 ValueKind 来确定其所表示的 JSON 值类型。如果是数组,可以通过调用 EnumerateArray 枚举其内容;如果是字符串值,可以通过调用 GetString 读取其值。Example 15-26 使用这些方法展示了 names 属性中的所有字符串。

Example 15-26. 使用 JsonDocumentJsonElement 动态枚举 JSON 数组
JsonElement namesElement = document.RootElement.GetProperty("names");
foreach (JsonElement name in namesElement.EnumerateArray())
{
    Console.WriteLine($"Name: {name.GetString()}");
}

正如本例还展示的那样,如果事先知道某个属性肯定存在,就无需使用 EnumerateObject 来查找它:可以直接调用 GetProperty。对于可选属性,还有 TryGetProperty 方法。Example 15-27 使用了两者:将根对象的 location 属性视为可选,但如果存在,还需要 locationNamelatitudelongitude 属性。

Example 15-27. 使用 JsonElement 读取 JSON 属性
if (root.TryGetProperty("location", out JsonElement locationElement))
{
    JsonElement nameElement = locationElement.GetProperty("locationName");
    JsonElement latitudeElement = locationElement.GetProperty("latitude");
    JsonElement longitudeElement = locationElement.GetProperty("longitude");
    string locationName = nameElement.GetString()!;
    double latitude = latitudeElement.GetDouble();
    double longitude = longitudeElement.GetDouble();
    Console.WriteLine($"Location: {locationName}: {latitude},{longitude}");
}

除了结构元素、对象和数组之外,在 JSON 规范 中的数据模型还识别四种基本数据类型:字符串、数字、布尔和 null。正如您所见,可以使用 Kind 属性确定 JsonElement 所表示的基本数据类型之一。如果是基本数据类型之一,可以使用相应的 Get 方法。最后两个示例都使用了 GetString,第二个示例还使用了 GetDouble。可以使用多种方法来获取数字:如果预期是整数,可以调用 GetSByteGetInt16GetInt32GetInt64(也有无符号版本),具体取决于预期的值范围。还有 GetDecimal 方法。

JsonElement 还提供了读取特定格式字符串属性的方法:GetGuidGetDateTimeGetDateTimeOffsetGetBytesFromBase64

所有的 Get 方法如果数值不符合所需格式,都会抛出 InvalidOperationException 异常。它们都有对应的 TryGet 形式,可以在数据无法按预期方式解析时进行检测,而无需触发异常。

这些类型试图最小化分配的内存量。JsonElementJsonProperty都是结构体,因此您可以在不引起额外堆分配的情况下获取它们。JsonDocument通过 UTF-8 格式保存底层数据,并且JsonElementJsonProperty实例只是引用该数据,避免了分配相关数据的副本的需要。显然,底层数据确实需要存放在某处,并且根据您如何加载 JSON 到JsonDocument中的方式,可能需要分配一些内存来存放它。(例如,您可以传递一个Stream,由于并非所有流都可重播,JsonDocument可能需要复制流的内容。)JsonDocument使用.NET 运行库中可用的缓冲池特性来管理这些数据,这意味着如果应用程序解析许多 JSON 文档,它可能能够重用内存,减少垃圾收集器(GC)的压力。但这也意味着JsonDocument需要知道您何时完成对 JSON 的处理,以便可以将缓冲区返回到池中。这就是在使用JsonDocument时为什么要使用using语句的原因。

警告

请注意,JsonElement.GetString比所有其他Get方法都更昂贵,因为它必须在堆上创建一个新的.NET 字符串。其他Get方法都返回值类型,因此它们不会导致堆分配。

我之前提到有两种处理 JSON DOM 的方式。JsonDocument提供了一个只读模型,允许您检查现有的 JSON。但还有JsonNode,它是可读/写的。您可以使用它的方式是JsonDocument不支持的。您可以从头开始建立一个对象模型来创建一个新的 JSON 文档。或者,您可以像使用JsonDocument一样解析现有的 JSON 到对象模型中,但是当您使用JsonNode时,结果模型是可修改的。因此,您可以使用它来加载一些 JSON 并对其进行修改,正如示例 15-28 所示。

示例 15-28. 使用JsonNode修改 JSON
JsonNode rootNode = JsonNode.Parse(json)!;
JsonNode mapNode = rootNode["map"]!;
mapNode["iceCream"] = 99;

这会将json中的 JSON 文本加载到JsonNode中,然后检索map属性。(此示例期望与前面示例中使用的 JSON 形式相同,其中属性名称为驼峰式命名。)到目前为止,这并没有做任何我们不能用JsonDocument做的事情。但最后一行在map对象中添加了一个新条目。正是这种修改文档的能力使JsonNode更加强大。那么如果JsonNode更强大,为什么还需要JsonDocument呢?这种能力是有代价的:JsonNode效率较低,因此如果不需要额外的灵活性,就不应使用它。

使用只读的JsonDocumentJsonElement或可写的JsonNode的主要优势在于,你无需定义任何类型来建模数据。它们还能更轻松地编写以数据结构驱动行为的代码,因为这些 API 能描述它们所找到的内容。只读形式通常比JsonSerializer更高效,因为在从 JSON 文档读取数据时可能能减少对象分配。

概要

Stream类是表示数据的字节序列的抽象。流可以支持读取、写入或两者,并且可以支持定位到任意偏移量以及直接的顺序访问。TextReaderTextWriter提供严格的字符数据顺序读取和写入,抽象化字符编码。这些类型可以位于文件、网络连接、内存之上,或者你可以实现自己版本的这些抽象类。FileStream类还提供一些其他的文件系统访问功能,但为了完全控制,我们还有FileDirectory类。当字节和字符串不足时,.NET 提供各种序列化机制,可以自动映射对象在内存中的状态与可以写入磁盘或发送到网络或任何其他类似流目标的表示之间的关系;这种表示后来可以转换回相同类型的对象,并且具有等效的状态。

正如你所看到的,一些文件和流 API 提供了异步形式,可以帮助提升性能,特别是在高并发系统中。下一章将讨论并发性、并行性以及这些 API 的异步形式所使用的基于任务的模式。

¹ 你可能认为井号是#,但如果像我一样是英国人,那就不对了。这就像有人坚持把@称为美元符号一样。Unicode 对#的官方名称是number sign,它还允许使用我偏爱的选项hash,以及octothorpecrosshatch,以及遗憾的是英镑符号

² 以防你还不知道这个术语,在小端表示中,多字节值以低位字节开始,因此在 16 位小端中,值 0x1234 将是 0x34、0x12,而大端版本将是 0x12、0x34。小端看起来是颠倒的,但它是 Intel 处理器的本地格式。

³ 一些 Unicode 字符在 UTF-8 中可以占据最多 4 个字节,因此乘以三似乎可能低估了。然而,所有这些字符在 UTF-16 中都需要两个代码单元。在.NET 中的任何单个char在 UTF-8 中最多只需要 3 个字节。

⁴ 当 .NET 2.0 引入了一种新的操作系统句柄表示方式时,四种重载方法变得过时了。那时接受 IntPtr 的重载方法被弃用,取而代之的是接受 SafeFileHandle 的新方法。

⁵ 这些方法都返回一个相对于计算机当前时区的 DateTime。每个方法都有一个相应的等效方法,返回相对于时区零的时间(例如 GetCreationTimeUtc)。

⁶ 这在 .NET Framework 上不可用。在那里,开源的 JSON.NET 项目,在 Newtonsoft 网站 或通过 NuGet 的 Newtonsoft.Json,是一个受欢迎的选择。

第十六章:多线程

多线程使应用程序能够同时执行多个代码片段。这样做的两个常见原因之一是利用计算机的并行处理能力——多核 CPU 现在几乎无处不在,为了充分发挥性能潜力,您需要提供多个工作流以让所有核心有些有用的事情做。编写多线程代码的另一个常见原因是防止在执行缓慢操作(例如从磁盘读取)时进展停滞。

解决第二个问题的方式并不只有多线程——异步技术可能更可取。C# 提供了支持异步工作的特性。异步执行不一定意味着多线程,但实际上两者通常相关,我将在本章节描述一些异步编程模型。然而,本章节侧重于线程的基础知识。我将在第十七章中描述语言级别支持异步代码的特性。

线程

所有能运行 .NET 的操作系统都允许每个进程包含多个线程(尽管如果构建到 Web Assembly 并在浏览器中运行代码,当前特定环境不支持创建新线程)。每个线程都有自己的堆栈,操作系统呈现的假象是线程获得整个 CPU 硬件线程 用于自己。 (见下一个侧边栏,“处理器、核心和硬件线程”)您可以创建比计算机提供的硬件线程数量更多的操作系统线程,因为操作系统虚拟化 CPU,从一个线程切换到另一个线程。我写这篇文章时使用的计算机有 16 个硬件线程,这是一个相当慷慨的数量,但比机器上运行的各种进程当前活动的 8,893 个线程还远远不够。

CLR 在操作系统线程之上提供自己的线程抽象。在 .NET Core 和 .NET 中,将始终存在直接关系——每个Thread对象直接对应于某个特定的底层操作系统线程。在 .NET Framework 中,这种关系并不保证存在——使用 CLR 的非托管托管 API 来自定义 CLR 和其包含进程之间的关系的应用程序理论上可以导致 CLR 线程在不同的操作系统线程之间移动。实际上,这种能力极少被使用,因此即使在 .NET Framework 中,在实践中每个 CLR 线程通常也会对应一个操作系统线程。

我将很快介绍Thread类,但在编写多线程代码之前,您需要了解在使用多个线程时管理状态的基本规则¹。

线程、变量和共享状态

每个 CLR 线程都拥有各自的线程特定资源,比如调用栈(保存方法参数和一些局部变量)。因为每个线程都有自己的栈,最终存储在其中的局部变量将仅属于该线程。每次调用方法时,都会得到一个新的局部变量集合。递归依赖于此特性,但在多线程代码中同样重要,因为对多个线程可访问的数据进行处理需要更多的注意,特别是如果数据发生变化的情况下。协调对共享数据的访问是复杂的。我将在“同步”章节中描述一些技术,但在可能的情况下最好避免这个问题,而栈的线程局部特性能够极大地帮助解决问题。

举例来说,考虑一个基于 Web 的应用程序。繁忙的站点必须同时处理来自多个用户的请求,因此您很可能会遇到这样一种情况:某个特定的代码(例如您站点首页的代码)同时在多个不同的线程上执行—ASP.NET Core 使用多线程能够为多个用户提供相同的逻辑页面。(网站通常不只是简单地提供相同的内容,因为页面通常根据特定用户进行定制,所以如果有 1,000 个用户请求查看主页,它将执行生成该页面的代码 1,000 次。)ASP.NET Core 提供了各种您的代码需要使用的对象,但其中大多数都是特定于特定请求的。因此,如果您的代码能够完全使用这些对象和局部变量,每个线程可以完全独立运行。如果需要共享状态(例如对多个线程可见的对象,可能通过静态字段或属性),生活将变得更加困难,但局部变量通常是比较简单的。

为什么只是“通常”?如果使用 lambda 表达式或匿名函数,情况会变得更加复杂,因为它们允许在包含方法中声明变量,然后在内部方法中使用该变量。现在这个变量对两个或更多方法都是可用的,并且在多线程情况下,这些方法可能会并发执行。(就 CLR 而言,它不再是真正的局部变量,而是编译器生成类中的字段。)在多个方法之间共享局部变量会导致局部性的保证丧失,因此您需要像对待更明显共享的项目(如静态属性和字段)那样谨慎对待这些变量。

在多线程环境中,另一个需要记住的重要点是变量和它所引用的对象之间的区别。(这仅涉及引用类型变量的问题。)尽管局部变量仅在其声明方法内部可访问,但该变量可能并不是唯一引用特定对象的变量。有时候它可能是——如果你在方法内部创建对象并且从未将其存储在任何可以使其对更广泛的受众可访问的地方,那么你就无需担心。示例 16-1 创建的StringBuilder仅在创建它的方法内部使用。

示例 16-1. 对象可见性和方法
public static string FormatDictionary<TKey, TValue>(
    IDictionary<TKey, TValue> input)
{
    var sb = new StringBuilder();
    foreach (var item in input)
    {
        sb.AppendFormat("{0}: {1}", item.Key, item.Value);
        sb.AppendLine();
    }

    return sb.ToString();
}

此代码无需担心其他线程可能试图修改StringBuilder。这里没有嵌套方法,因此sb变量确实是局部的,而这是唯一包含对StringBuilder的引用的内容。(这依赖于StringBuilder并未在其他线程可能看到的任何地方秘密存储其this引用的事实。)

但是input参数呢?它也是方法的局部变量,但它引用的对象却不是:调用FormatDictionary的代码得决定input引用的是什么。单看示例 16-1,无法确定它所引用的字典对象是否正在被其他线程使用。调用代码可能创建一个字典,并创建两个线程,其中一个修改字典,而另一个调用此FormatDictionary方法。这会造成问题:大多数字典实现不支持在一个线程修改字典的同时另一个线程使用它。即使你正在使用一个设计用于处理并发使用的集合,通常也不允许在枚举其内容的同时修改集合(例如foreach循环)。

你可能会认为任何设计用于同时从多个线程使用的集合(你可以说是线程安全集合)应该允许一个线程在修改其内容的同时另一个线程迭代其内容。如果不允许这样做,那么它如何是线程安全的呢?实际上,在此场景中,线程安全集合与非线程安全集合的主要区别在于可预测性:当它检测到这种情况发生时,线程安全集合可能会抛出异常,而非线程安全集合则不能保证会执行任何特定的操作。它可能会崩溃,或者你可能会从迭代中得到令人困惑的结果,例如单个条目多次出现。它可能会做任何事情,因为你正在不支持的方式中使用它。有时候,线程安全意味着失败以明确定义和可预测的方式发生。

事实上,System.Collection.Concurrent 命名空间中的各种集合确实支持在进行枚举时进行更改而不抛出异常。然而,它们大多数具有与其他集合类不同的 API,专门支持并发,因此它们通常不能直接替换。

没有任何方法可以确保 示例 16-1 在多线程环境中安全地使用其 input 参数,因为它完全取决于其调用者。并发危害需要在更高的级别处理。事实上,“线程安全”这个术语可能是误导性的,因为它暗示了一般情况下不可能的事情。经验不足的开发人员经常陷入这样的陷阱,认为只要确保他们使用的所有对象都是线程安全的,他们就免于思考其代码中的线程问题责任。但这通常行不通,因为虽然单个线程安全对象会维护其自身的完整性,但这并不能保证你的应用程序状态作为一个整体是一致的。

为了说明这一点,示例 16-2 使用了 System.Collections.Concurrent 命名空间中的 ConcurrentDictionary<TKey, TValue> 类。该类定义的每个操作在某种意义上都是线程安全的,因为每个操作都会使对象保持一致的状态,并且会在调用前产生预期的结果。然而,这个例子却构造出了一个非线程安全的使用方式。

示例 16-2. 非线程安全的线程安全集合使用
static string UseDictionary(ConcurrentDictionary<int, string> cd)
{
    cd[1] = "One";
    return cd[1];
}

看起来这似乎不会失败。(这也似乎毫无意义;这只是为了展示即使是一个非常简单的代码片段也可能出错。)但是如果字典实例被多个线程使用(考虑到我们选择的是专为多线程使用而设计的类型),完全有可能在设置键 1 的值并尝试检索它之间,某些其他线程已经删除了该条目。如果我将这段代码放入一个程序中,该程序在多个线程上重复运行此方法,但也有几个其他线程忙于删除相同的条目,我最终会看到 KeyNotFoundException

并发系统需要一种自上而下的策略来确保系统范围内的一致性。(这就是为什么数据库管理系统通常使用事务的原因,事务将一组操作组合在一起作为原子工作单元,要么完全成功,要么完全不影响。这种原子分组是事务帮助确保系统范围内状态一致性的关键部分。)查看 示例 16-1,这意味着调用 FormatDictionary 的代码负责确保字典在方法执行期间可以自由使用。

警告

虽然调用代码应确保它传递的任何对象在方法调用期间都是安全使用的,但通常不能假设可以保存对参数的引用以供将来使用。匿名函数和委托使得意外地这样做变得容易——如果嵌套方法引用其包含方法的参数,并且如果该嵌套方法在包含方法返回后运行,则不能再安全地假设您被允许访问参数所引用的对象。如果需要这样做,您需要记录您对何时可以使用对象的假设,并检查调用方法的任何代码以确保这些假设是有效的。

线程本地存储

有时在比单个方法更广泛的范围内维护线程本地状态可能很有用。运行时库的各个部分都在做这件事。例如,System.Transactions 命名空间定义了一个用于与数据库、消息队列和任何支持它们的资源管理器使用事务的 API。它提供了一个隐式模型,您可以在其中启动环境事务,并且任何支持此事务的操作将自动加入其中,而无需传递任何显式的与事务相关的参数。(它还支持显式模型,如果您更喜欢的话。)Transaction 类的静态 Current 属性返回当前线程的环境事务,如果当前线程没有正在进行的环境事务,则返回 null

为了支持这种每线程状态,.NET 提供了 ThreadLocal<T> 类。示例 16-3 使用它来为委托提供包装,该委托仅允许在任一线程上任一时间只有一个对委托的调用进行中。

使用 ThreadLocal<T> 的示例 16-3
class Notifier
{
    private readonly Action _callback;
    private readonly ThreadLocal<bool> _isCallbackInProgress = new();

    public Notifier(Action callback)
    {
        _callback = callback;
    }

    public void Notify()
    {
        if (_isCallbackInProgress.Value)
        {
            throw new InvalidOperationException(
                "Notification already in progress on this thread");
        }

        try
        {
            _isCallbackInProgress.Value = true;
            _callback();
        }
        finally
        {
            _isCallbackInProgress.Value = false;
        }
    }
}

如果 Notify 回调的方法尝试再次调用 Notify,这将通过抛出异常来阻止递归尝试。但是,因为它使用 ThreadLocal<bool> 来跟踪是否正在进行调用,这将允许同时调用,只要每次调用发生在不同的线程上。

您可以通过 Value 属性获取和设置 ThreadLocal<T> 为当前线程保存的值。构造函数是重载的,您可以传递一个 Func<T>,每次新线程首次尝试检索值时都会调用它以创建默认初始值。(初始化是惰性的——回调不会在每次新线程启动时都运行。ThreadLocal<T> 仅在新线程首次尝试使用值时调用回调。)您可以创建的 ThreadLocal<T> 对象数量没有固定限制。

ThreadLocal<T>还为跨线程通信提供了一些支持。如果你向接受布尔值的某个构造函数重载传递true参数,该对象将维护一个报告每个线程存储的最新值的集合,可以通过其Values属性获取。仅在构造对象时请求此服务时,它才提供此服务,因为这需要额外的管理工作。此外,如果你使用引用类型作为类型参数,启用跟踪可能意味着对象的存活时间会更长。通常情况下,线程在ThreadLocal<T>中存储的任何引用在线程终止时将不再存在,如果该引用是使对象可达的唯一引用,垃圾回收器将能够回收其内存。但如果启用跟踪,所有这些引用将在ThreadLocal<T>实例本身可达期间保持可达,因为Values即使对于已终止的线程也会报告值。

关于线程本地存储,有一件事需要特别注意。如果你为每个线程创建一个新对象,请注意应用程序可能在其生命周期内创建大量线程,特别是如果你使用线程池(稍后将详细描述)。如果你创建的每个线程对象很昂贵,这可能会引起问题。此外,如果存在任何一次性的线程本地资源,你不一定知道何时线程终止;线程池会定期创建和销毁线程,而无需告知你。

如果你不需要每次新线程首次使用线程本地存储时自动创建对象,你可以简单地使用[ThreadStatic]属性标注静态字段。这由 CLR 处理:这意味着每个访问此字段的线程都会得到自己独立的字段。这可以减少需要分配的对象数量。但要小心:对于这些字段可以定义字段初始化器,但初始化器仅在第一个访问该字段的线程运行时执行。对于使用相同[ThreadStatic]的其他线程,字段最初将包含该字段类型的默认零值。

最后需要注意的一点是:如果你计划使用 第十七章 中描述的异步语言特性,那么要谨慎使用线程本地存储(以及基于它的任何机制),因为这些特性使得单个方法的调用可以在进展过程中使用多个不同的线程。对于这种类型的方法来说使用环境事务或依赖于线程本地状态的任何其他事物将是一个不好的主意。许多.NET 功能可能会使用线程本地存储(例如,ASP.NET Core 框架的静态 HttpContext.Current 属性,它返回与当前线程处理的 HTTP 请求相关的对象),实际上是与称为执行上下文的东西关联的信息。执行上下文更加灵活,因为它可以在需要时跨线程跳转。我稍后会进行描述。

要使我刚刚讨论的问题变得相关,我们需要使用多个线程。有四种主要方法可以使用多线程。一种方法是在你的代表创建多个线程的框架中运行代码,例如 ASP.NET Core。另一种方法是使用某些类型的基于回调的 API。有关此的一些常见模式描述在 “任务” 和 “其他异步模式” 中。但是使用线程的两种最直接的方法是显式创建新线程或使用.NET 线程池。

线程类

正如我之前提到的,Thread 类(定义在 System.Threading 命名空间中)表示一个 CLR 线程。你可以通过 Thread.CurrentThread 属性获得一个代表执行你的代码的线程的 Thread 对象的引用,但是如果你想要引入一些多线程,你可以构造一个新的 Thread 对象。

当一个新线程开始时,它需要知道它应该运行哪些代码,因此你必须提供一个委托,线程将在开始时调用委托引用的方法。线程会运行直到该方法正常返回,或允许异常传播到堆栈顶部(或线程通过任何操作系统机制被强制终止或杀死其包含的进程)。示例 16-4 创建了三个线程同时下载三个网页的内容。

示例 16-4. 创建线程
internal static class Program
{
    private static readonly HttpClient http = new();

    private static void Main(string[] args)
    {
        Thread t1 = new(MyThreadEntryPoint);
        Thread t2 = new(MyThreadEntryPoint);
        Thread t3 = new(MyThreadEntryPoint);

        t1.Start("https://endjin.com/");
        t2.Start("https://oreilly.com/");
        t3.Start("https://dotnet.microsoft.com/");
    }

    private static void MyThreadEntryPoint(object? arg)
    {
        string url = (string)arg!;

        Console.WriteLine($"Downloading {url}");
        var response = http.Send(new HttpRequestMessage(HttpMethod.Get, url));
        using StreamReader r = new(response.Content.ReadAsStream());
        string page = r.ReadToEnd();
        Console.WriteLine($"Downloaded {url}, length {page.Length}");
    }
}

Thread构造函数有重载,并接受两种委托类型。ThreadStart委托需要一个不带参数且不返回值的方法,但在示例 16-4 中,MyThreadEntryPoint方法接受一个object参数,这与另一个委托类型ParameterizedThreadStart匹配。这提供了一种方法来向每个线程传递参数,这在多个不同线程调用相同方法时非常有用,就像这个示例所做的那样。线程在调用Start之前不会运行,并且如果使用ParameterizedThreadStart委托类型,则必须调用接受单个object参数的重载。我正在使用这个功能让每个线程从不同的 URL 下载。

Thread构造函数还有两个重载,每个在委托参数之后添加一个int参数。这个int指定线程的堆栈大小。当前的.NET 实现要求堆栈在内存中是连续的,因此需要预分配堆栈的地址空间。如果线程耗尽了这个空间,CLR 会抛出StackOverflowException。(通常只有在错误导致无限递归时才会看到这些异常。)如果没有提供这个参数,CLR 将使用进程的默认堆栈大小。(这取决于操作系统;在 Windows 上通常为 1 MB。您可以通过设置DOTNET_DefaultStackSize环境变量来更改它。请注意,它将该值解释为十六进制数。)通常情况下不需要更改这个设置,但也不是不可能的。如果您有产生非常深堆栈的递归代码,可能需要在具有较大堆栈的线程上运行它。相反,如果您创建大量线程,可能希望减少堆栈大小以节省资源,因为默认的 1 MB 通常远远超过实际需要的量。但是,通常不建议创建如此大量的线程。因此,在大多数情况下,您将仅创建适度数量的线程,并使用使用默认堆栈大小的构造函数。

注意,在示例 16-4 中的Main方法在启动三个线程后立即返回。尽管如此,应用程序会继续运行——直到所有线程都完成为止。CLR 会保持进程处于活动状态,直到没有正在运行的前台线程为止,其中前台线程指的是未明确指定为后台线程的任何线程。如果要阻止特定线程继续运行进程,请将其IsBackground属性设置为true。(这意味着后台线程可能会在执行过程中被终止,因此在这些线程上执行的工作需要小心。)

直接创建线程并非唯一选择。线程池提供了一个常用的替代方案。

线程池

在大多数操作系统中,创建和关闭线程相对昂贵。如果需要执行一段相对较短的工作(例如提供一个网页或类似的简短操作),创建一个线程来完成这项工作,并在完成后将其关闭是一个不好的主意。这个策略有两个严重问题:首先,你可能会在启动和关闭成本上消耗更多资源,而不是在有用的工作上;其次,如果你不断创建新线程以应对更多的工作,系统在负载下可能会变得停滞不前——在重负载情况下,创建越来越多的线程往往会降低吞吐量。这是因为,除了基本的每个线程的开销,如堆栈所需的内存外,操作系统需要定期在可运行的线程之间切换,以使它们都能进展,而这种切换本身也有开销。

为避免这些问题,.NET 提供了一个线程池。你可以提供一个委托,运行时将调用线程池中的一个线程。如果有必要,它将创建一个新线程,但在可能的情况下,它将重用之前创建的线程,如果所有创建的线程都在忙,它可能会将你的工作等待在队列中。在方法运行后,CLR 通常不会终止线程;相反,线程将留在池中,等待其他工作项在多个工作项之间摊销创建线程的成本。如果有必要,它会创建新线程,但它尝试将线程数保持在一个水平,以使可运行线程的数量匹配硬件线程的数量,以最小化切换成本。

警告

线程池始终创建后台线程,因此,如果线程池在你的进程中最后一个前台线程退出时正在执行某些操作,工作将不会完成,因为所有后台线程将在此时终止。如果需要确保线程池上的工作完成,你必须在允许所有前台线程完成之前等待其完成。

使用 Task 启动线程池工作

使用线程池的常用方法是通过 Task 类。这是任务并行库的一部分(在“任务”中有更详细的讨论),但其基本使用非常简单,如示例 16-5 所示。

示例 16-5。使用 Task 在线程池上运行代码
Task.Run(() => MyThreadEntryPoint("https://oreilly.com/"));

这将 lambda 排队等待在线程池上执行(当它运行时,只调用来自示例 16-4 的 MyThreadEntryPoint 方法)。如果有线程可用,它将立即开始运行;否则,它将等待在队列中,直到有线程可用(要么是因为其他正在进行的工作项完成,要么是因为线程池决定向池中添加新线程)。

还有其他使用线程池的方法,其中最明显的是通过ThreadPool类。其QueueUserWorkItem方法的工作方式与Start类似——你传递一个委托,它将方法排队等待执行。这是一个较低级别的 API,它不提供任何直接处理工作完成的方式,也不能链式操作,所以在大多数情况下,Task类更可取。

线程创建启发式方法

运行时根据你提供的工作负载调整线程数量。它使用的启发式方法没有记录并且在.NET 的不同版本中已经改变,因此你不应依赖于我即将描述的确切行为;然而,大致了解可以预期发生的事情仍然很有用。

如果你只给线程池 CPU 绑定的工作,即你要求它执行的每个方法都花费全部时间进行计算,并且从不阻塞等待 I/O 完成,你可能会得到与系统中每个硬件线程相对应的一个线程池线程(尽管如果单个工作项目耗时足够长,线程池可能会决定分配更多线程)。例如,在我写这篇文章时使用的八核双路超线程计算机上,首先排队一堆 CPU 密集型工作项目会导致 CLR 创建 16 个线程池线程,并且只要工作项目大约每秒完成一次,线程数量大部分时候都保持在这个水平(偶尔会超过,因为运行时会尝试不时添加额外的线程以查看其对吞吐量的影响,然后再次降回来)。但是,如果程序处理项目的速度下降,CLR 会逐渐增加线程计数。

如果线程池线程被阻塞(例如,它们正在等待来自磁盘的数据或者从服务器上的网络响应),CLR 会更快地增加线程池线程的数量。同样,它从每个硬件线程开始,但是当慢工作项目几乎不消耗处理器时间时,它可以每秒添加两次线程。

无论哪种情况,CLR 最终会停止添加线程。在 32 位进程中,确切的默认限制因 .NET 版本而异,通常约为 1,000 个线程。在 64 位模式下,默认值似乎是 32,767。您可以更改此限制——ThreadPool 类具有 SetMaxThreads 方法,允许您为进程配置不同的限制。您可能会遇到其他限制,从而导致更低的实际限制。例如,每个线程都有自己的堆栈,必须占用虚拟地址空间的连续范围。默认情况下,每个线程获得 1 MB 的进程地址空间用于其堆栈,因此当您有 1,000 个线程时,仅用于堆栈的地址空间就将达到 1 GB。32 位进程仅有 4 GB 的地址空间,因此您可能没有足够的空间来请求的线程数量。无论如何,1,000 个线程通常比有用的更多,因此如果达到这么高,这可能是您应该调查的一些潜在问题的症状。因此,如果调用 SetMaxThreads,通常会是为了指定一个较低的限制——您可能会发现,在某些工作负载下,通过限制线程数量来减少对系统资源的争用程度,从而提高吞吐量。

ThreadPool 还具有 SetMinThreads 方法。这使您可以确保线程数不会低于某个数字。这对于那些希望在最小数量的线程下能够立即以最大速度运行,而不必等待线程池的启发式算法调整线程计数的应用程序非常有用。

线程亲和性和同步上下文

有些对象要求您只能从特定的线程中使用它们。这在 UI 代码中特别常见——WPF 和 Windows Forms UI 框架要求 UI 对象必须从创建它们的线程上使用。这被称为线程亲和性,虽然它通常是一个 UI 的关注点,但也可能在互操作性场景中出现——一些 COM 对象具有线程亲和性。

如果您想编写多线程代码,线程亲和性可能会让生活变得尴尬。假设您已经精心实现了一个多线程算法,可以利用最终用户计算机上的所有硬件线程,在多核 CPU 上运行时显著提高性能,与单线程算法相比。一旦算法完成,您可能希望向最终用户呈现结果。UI 对象的线程亲和性要求您在特定线程上执行最后一步操作,但您的多线程代码可能会在其他线程上生成最终结果。(事实上,为了确保 UI 在进行工作时保持响应,您可能完全避免使用 UI 线程进行 CPU 密集型工作。)如果您试图从某个随机的工作线程更新 UI,则 UI 框架将抛出异常,指责您违反了其线程亲和性要求。您需要想办法将消息传回 UI 线程,以便它可以显示结果。

运行时库提供了SynchronizationContext类来帮助处理这些情况。其Current静态属性返回一个SynchronizationContext类的实例,表示当前代码运行的上下文。例如,在 WPF 应用程序中,如果在 UI 线程上检索此属性,它将返回与该线程关联的对象。您可以存储Current返回的对象,并在任何时候从任何线程使用它来执行进一步的 UI 线程工作。示例 16-6 正是这样做的,以便它可以在线程池线程上执行一些潜在的缓慢工作,然后在 UI 线程上更新 UI。

示例 16-6. 使用线程池然后SynchronizationContext
private void findButton_Click(object sender, RoutedEventArgs e)
{
    `SynchronizationContext` `uiContext` `=` `SynchronizationContext``.``Current``!``;`

    Task.Run(() =>
    {
        string pictures =
            Environment.GetFolderPath(Environment.SpecialFolder.MyPictures);
        var folder = new DirectoryInfo(pictures);
        FileInfo[] allFiles =
            folder.GetFiles("*.jpg", SearchOption.AllDirectories);
        FileInfo? largest =
            allFiles.OrderByDescending(f => f.Length).FirstOrDefault();

        if (largest is not null)
        {
            `uiContext``.``Post``(``_` `=``>`
            {
                long sizeMB = largest.Length / (1024 * 1024);
                outputTextBox.Text =
                    $"Largest file ({sizeMB}MB) is {largest.FullName}";
            },
            null);
        }
    });
}

此代码处理按钮的Click事件。(它恰好是一个 WPF 应用程序,但SynchronizationContext在其他桌面 UI 框架(如 Windows Forms)中的工作方式完全相同。)UI 元素在 UI 线程上引发其事件,因此当点击处理程序的第一行检索当前的SynchronizationContext时,它将获取 UI 线程的上下文。然后,代码通过Task类在线程池线程上运行一些工作。该代码查看用户“图片”文件夹中的每张图片,搜索最大的文件,因此可能需要一段时间。在 UI 线程上执行缓慢的工作是一个坏主意——属于该线程的 UI 元素在 UI 线程忙于其他事情时无法响应用户输入。因此将其推入线程池是个好主意。

在这里使用线程池的问题在于,一旦工作完成,我们就在错误的线程上更新 UI。此代码更新文本框的 Text 属性,如果我们尝试从线程池线程中执行此操作,将会抛出异常。因此,当工作完成时,它使用先前检索的 SynchronizationContext 对象,并调用其 Post 方法。该方法接受一个委托,并安排在 UI 线程上调用它。在幕后,它向 Windows 消息队列发布一个自定义消息,当 UI 线程的主消息处理循环接收到该消息时,将调用委托。

提示

Post 方法不等待工作完成。有一个名为 Send 的方法可以等待,但我建议不要使用它。使工作线程在等待 UI 线程执行某些操作时阻塞可能存在风险,因为如果 UI 线程当前正在等待工作线程执行某些操作,应用程序将出现死锁。Post 通过允许工作线程与 UI 线程并发运行来避免此问题。

示例 16-6 在开始线程池工作之前,仍然在 UI 线程上检索 SynchronizationContext.Current。这很重要,因为这个静态属性是上下文敏感的——只有在 UI 线程上时,它才返回 UI 线程的上下文。事实上,在 WPF 中,每个窗口可能都有自己的 UI 线程,因此不可能有一个返回 the UI 线程的 API——可能会有多个。如果你从线程池线程读取此属性,它返回的上下文对象将无法将工作发布到 UI 线程上。

SynchronizationContext 机制是可扩展的,因此如果需要,你可以从中派生自己的类型,并可以调用其静态方法 SetSynchronizationContext 将你的上下文设为当前线程的上下文。在单元测试场景中这非常有用——它使你能够编写测试来验证对象是否正确地与 SynchronizationContext 交互,而无需创建真正的 UI。

执行上下文

SynchronizationContext 类有一个类似的伙伴,叫做 ExecutionContext。它提供了类似的服务,允许你捕获当前的上下文,然后稍后在同一上下文中运行委托,但有两点不同。首先,它捕获不同的内容。其次,它使用不同的方法重新建立上下文。SynchronizationContext 通常会在特定的线程上运行你的工作,而 ExecutionContext 则总是使用你的线程,并确保所有捕获的上下文信息都可在该线程上使用。区分这两者的一种思路是,SynchronizationContext 在现有上下文中完成工作,而 ExecutionContext 则将上下文信息带给你。

警告

有点令人困惑的是,在 .NET Framework 上,ExecutionContext 的实现捕获当前的 SynchonizationContext,因此从某种意义上讲,ExecutionContextSynchronizationContext 的超集。然而,ExecutionContext 在调用委托时不使用捕获的 SynchronizationContext。它所做的只是确保,如果通过 ExecutionContext 执行的代码读取 SynchonizationContext.Current 属性,它将获取在捕获 ExecutionContext 时当前的 SynchronizationContext 属性。这不一定是当前线程正在运行的 SynchonizationContext!这个设计缺陷在 .NET Core 中已经修复。

调用 ExecutionContext.Capture 方法可以检索当前的上下文。执行上下文不会捕获线程局部存储,但会包括当前的逻辑调用上下文中的任何信息。您可以通过 CallContext 类访问这些信息,该类提供 LogicalSetDataLogicalGetData 方法来存储和检索名称/值对,或通过更高级的包装器 Async​Lo⁠cal<T> 访问。这些信息通常与当前线程相关联,但是如果在捕获的执行上下文中运行代码,即使该代码在完全不同的线程上运行,逻辑上下文中的信息也将可用。

.NET 在一些异步模式中描述的情况下,当长时间运行的工作从一个线程开始,最终在另一个线程上继续时(这在本章的某些异步模式中会发生),会在内部使用 ExecutionContext 类。如果您编写接受稍后将调用的回调函数的任何代码,您可能希望以类似的方式使用执行上下文。为此,您调用 Capture 方法来获取当前的上下文,稍后可以将其传递给 Run 方法以调用委托。示例 16-7 展示了 ExecutionContext 的工作方式。

示例 16-7. 使用 ExecutionContext
public class Defer
{
    private readonly Action _callback;
    private readonly ExecutionContext? _context;

    public Defer(Action callback)
    {
        _callback = callback;
        _context = ExecutionContext.Capture()!;
    }

    public void Run()
    {
        if (_context is null) { _callback(); return; }
        // When ExecutionContext.Run invokes the lambda we supply as the 2nd
        // argument, it passes that lambda the value we supplied as the 3rd
        // argument to Run. Here we're passing _callback, so the lambda has
        // access to the Action we want to invoke. It would have been simpler
        // to write "_ => _callback()", but the lambda would then need to
        // capture 'this' to be able to access _callback, and that capture
        // would cause an additional allocation.
        ExecutionContext.Run(_context, (cb) => ((Action)cb!)(), _callback);
    }
}

在 .NET Framework 中,单个捕获的 ExecutionContext 不能同时在多个线程上使用。有时您可能需要在特定上下文中调用多个不同的方法,在多线程环境中,您可能无法保证上一个方法在调用下一个方法之前已经返回。对于这种情况,ExecutionContext 提供了一个 CreateCopy 方法,生成上下文的副本,使您能够通过等效的上下文进行多个并发调用。在 .NET Core 和 .NET 中,ExecutionContext 是不可变的,这意味着不再受此限制,CreateCopy 方法只返回其自身引用。

同步

有时候,您可能需要编写多线程代码,其中多个线程可以访问相同的状态。例如,在第五章中,我建议服务器可以使用 Dictionary<TKey, TValue> 作为缓存的一部分,以避免在接收到多个类似请求时重复工作。虽然这种缓存在某些场景下可以提供显著的性能优势,但在多线程环境中却是一个挑战。(如果您正在处理具有严格性能要求的服务器代码,很可能需要多个线程来处理请求。)Dictionary<TKey, TValue> 类的文档中的线程安全部分指出:

Dictionary<TKey, TValue> 可以支持多个读取器同时操作,只要不修改集合。即便如此,枚举集合本质上不是线程安全的过程。在枚举与写访问竞争的罕见情况下,必须在整个枚举过程中锁定集合。要允许集合被多个线程同时读取和写入,必须实现自己的同步。

这比我们所期望的要好——运行库中绝大多数类型根本不支持实例的多线程使用。大多数类型在类级别支持多线程使用,但是每个实例必须一次使用一个线程。 Dictionary<TKey, TValue> 更为宽松:它明确支持多个并发读取器,这对我们的缓存场景听起来很有利。然而,在修改集合时,我们不仅必须确保不会尝试同时从多个线程修改它,还必须确保在此期间没有正在进行的读取操作。

其他通用的集合类也提供类似的保证(不同于库中的大多数其他类)。例如,List<T>Queue<T>Stack<T>SortedDictionary<TKey, TValue>HashSet<T>SortedSet<T> 都支持并发的只读使用。(同样,如果您修改了这些集合的任何实例,必须确保没有其他线程同时修改或读取同一个实例。)当然,在尝试多线程使用任何类型之前,您应该始终检查文档。² 请注意,通用集合接口类型不提供线程安全保证——尽管 List<T> 支持并发读取,但并非所有 IList<T> 的实现都会如此。 (例如,想象一个包装潜在缓慢内容的实现,比如文件内容。这种包装可能会缓存数据以提高读取操作的速度。从这样的列表中读取项目可能会改变其内部状态,因此如果代码没有采取保护措施,同时从多个线程进行读取可能会导致读取失败。)

如果你可以安排在多线程代码使用数据结构时永远不修改数据结构,则许多集合类提供的并发访问支持可能已经足够。但如果某些线程需要修改共享状态,则需要协调对该状态的访问。为此,.NET 提供了各种同步机制,可以确保在必要时线程轮流访问共享对象。在本节中,我将描述最常用的几种。

监视器和 lock 关键字

用于同步多线程共享状态的首选选项是 Monitor 类。这很受欢迎,因为它高效且提供了直观的模型,而且 C# 提供了直接的语言支持,使用起来非常简单。示例 16-8 展示了一个使用 lock 关键字(其实使用 Monitor 类)的类,每当它读取或修改其内部状态时都会使用它。这确保了只有一个线程会同时访问该状态。

示例 16-8. 使用 lock 保护状态
public class SaleLog
{
    private readonly object _sync = new();

    private decimal _total;

    private readonly List<string> _saleDetails = new();

    public decimal Total
    {
        get
        {
            lock (_sync)
            {
                return _total;
            }
        }
    }

    public void AddSale(string item, decimal price)
    {
        string details = $"{item} sold at {price}";
        lock (_sync)
        {
            _total += price;
            _saleDetails.Add(details);
        }
    }

    public string[] GetDetails(out decimal total)
    {
        lock (_sync)
        {
            total = _total;
            return _saleDetails.ToArray();
        }
    }
}

要使用 lock 关键字,你需要提供一个对象引用和一段代码块。C# 编译器生成的代码将导致 CLR 确保任何时候一个对象的 lock 块内不会有多于一个线程。假设你创建了 SaleLog 类的单个实例,并且在一个线程上调用了 AddSale 方法,而在另一个线程上同时调用了 GetDetails。两个线程都会达到 lock 语句,传入相同的 _sync 字段。无论哪个线程先到达,都将被允许运行 lock 后面的代码块。另一个线程将被阻塞,直到第一个线程离开其 lock 块为止。

SaleLog 类仅在使用 _sync 参数的 lock 块内部使用其字段。这确保所有对字段的访问都是串行化的(在并发意义上——即线程一次只能访问一个字段,而不是同时进入)。当 GetDetails 方法从 _total_saleDetails 字段读取时,可以确信它得到的是一致的视图——总数将与销售详情列表的当前内容保持一致,因为修改这两个数据的代码都在单个 lock 块内执行。这意味着从使用 _sync 的任何其他 lock 块的视角来看,更新将看起来是原子的。

即使是用于返回总数的get访问器,使用lock块可能看起来有些过度。然而,decimal是一个 128 位的值,因此对于这种类型的数据访问并不是固有地原子的——如果没有那个lock,返回的值可能由_total在不同时间点上具有的两个或更多值的混合组成。(例如,底部 64 位可能来自比顶部 64 位更旧的值。)这经常被描述为破碎读。CLR 仅对大小不超过 4 字节的数据类型和引用保证原子读写,即使在引用大于 4 字节的平台上也是如此。(它仅对自然对齐字段保证这一点,但在 C#中,字段总是对齐的,除非你故意为了互操作目的而使它们错位。)

示例 16-8 的一个微妙但重要的细节是,每当它返回关于其内部状态的信息时,它都会返回一个副本。Total属性的类型是decimal,这是一个值类型,值总是作为副本返回。但是当涉及到条目列表时,GetDetails方法调用ToArray,它将构建一个包含列表当前内容副本的新数组。直接返回_saleDetails中的引用将是一个错误,因为这将使得SalesLog类外部的代码能够访问和修改集合而不使用lock。我们需要确保对该集合的所有访问都是同步的,如果我们的类向外部提供对其内部状态的引用,我们将失去这种能力。

提示

如果你编写的代码执行一些多线程工作,最终停止,那么在工作停止后共享对状态的引用是可以的。但是,如果对象正在进行多线程修改,你需要确保对该对象状态的所有使用都受到保护。

lock关键字接受任何对象引用,所以你可能会想知道为什么我专门创建了一个对象——不能直接传递this吗?那确实可以工作,但问题在于你的this引用不是私有的——它是外部代码使用你的对象的同一个引用。使用你对象的公开可见特性来同步访问私有状态是不明智的;其他代码可能会决定将你的对象的引用用作某些完全不相关的lock块的参数。在这种情况下,可能不会造成问题,但在更复杂的代码中,它可能会以一种可能导致性能问题甚至死锁的方式将概念上不相关的并发行为联系在一起。因此,通常最好以防御性编程,并使用只有你的代码可以访问的东西作为lock参数。当然,我可以使用_saleDetails字段,因为它引用了只有我的类可以访问的对象。然而,即使你进行防御性编程,也不应假设其他开发人员会这样做,因此一般来说,最安全的做法是避免使用你没有编写的类的实例作为lock的参数,因为你无法确定它是否在使用它自己的this引用进行自身的锁定目的。

你可以使用任何对象引用这一事实在任何情况下都有些奇怪。大多数.NET 的同步机制使用某种不同类型的实例作为同步的参考点。(例如,如果你想要读者/写者锁定语义,你会使用ReaderWriterLockSlim类的实例,而不仅仅是任意对象。)Monitor类(即lock使用的类)是一个例外,它可以追溯到与 Java 的某种程度兼容性的旧需求。(Java 有类似的锁原语。)这与现代.NET 开发无关,因此这个特性现在只是一个历史上的特殊情况。使用一个专门作为lock参数的独特对象,与锁定的成本相比增加了最小的开销,并且倾向于使同步管理变得更加清晰。

注意

你不能将值类型用作lock的参数——C#会阻止这样做,这是有道理的。编译器会在lock参数上执行隐式转换为object,对于引用类型来说,在运行时不需要 CLR 做任何事情。但是当你将值类型转换为object类型的引用时,需要创建一个装箱。那个装箱将成为lock的参数,这将是一个问题,因为每次将值转换为object引用时,都会得到一个新的装箱。因此,每次运行lock时,它会得到一个不同的对象,这意味着实际上没有同步。这就是为什么编译器阻止你尝试这样做的原因。

如何扩展 lock 关键字

每个 lock 块转换为代码,执行三件事情:首先,调用 Monitor.Enter,传递给 lock 的参数。然后尝试运行块中的代码。最后,一般情况下,一旦块完成,将调用 Monitor.Exit。但由于异常,情况并不完全简单。如果您在块中放置的代码引发异常,代码仍然会调用 Monitor.Exit,但需要处理 Monitor.Enter 本身引发异常的可能性,这意味着线程没有获取锁,因此不应调用 Monitor.Exit。示例 16-9 展示了编译器在 示例 16-8 中的 GetDetails 方法中 lock 块的处理方式。

示例 16-9. lock 块的展开方式
bool lockWasTaken = false;
object temp = _sync;
try
{
    Monitor.Enter(temp, ref lockWasTaken);
    {
        total = _total;
        return _saleDetails.ToArray();
    }
}
finally
{
    if (lockWasTaken)
    {
        Monitor.Exit(temp);
    }
}

Monitor.Enter 是一个 API,它负责发现是否有其他线程已经拥有锁,并在这种情况下使当前线程等待。如果此操作返回,通常意味着获取锁成功。(可能会发生死锁,这种情况下它将永远不会返回。)由于内存耗尽等异常情况的发生,有可能会出现获取失败的小概率情况。虽然这种情况不太常见,但生成的代码仍会考虑这一点——这就是对 lockWasTaken 变量进行稍微绕远的代码的目的。(实际上,编译器会将其作为一个无法访问名称的隐藏变量。顺便说一句,我已经命名它以显示这里发生了什么。)Monitor.Enter 方法确保获取锁与更新指示锁是否被获取的标志是原子性的,这样 finally 块将仅在成功获取锁时尝试调用 Exit

Monitor.Exit 告诉 CLR 我们不再需要对我们同步访问的任何资源进行独占访问,如果其他任何线程在对象内的 Monitor.Enter 中等待,则允许其中一个线程继续执行。编译器将此放置在 finally 块中,以确保无论您通过运行到末尾、从中间返回还是抛出异常退出块,锁都将被释放。

lock 块在异常发生时调用 Monitor.Exit,这是一把双刃剑。一方面,通过确保在失败时释放锁,它减少了死锁的可能性。另一方面,如果在修改某些共享状态时发生异常,系统可能处于不一致的状态;释放锁将允许其他线程访问该状态,可能导致进一步的问题。在某些情况下,如果异常发生时保持锁定状态可能更好——一个死锁的进程可能比在损坏状态下继续运行造成的危害小。更健壮的策略是编写能够在异常情况下保证一致性的代码,方法可以是如果异常阻止了完整的更新集,则回滚任何已进行的更改;或通过以原子方式改变状态(例如,将新状态放入一个全新对象,并仅在更新对象完全初始化后将其替换为先前的对象)。但这已经超出了编译器能自动处理的范围。

等待和通知

Monitor 类不仅仅用于确保线程轮流执行。它还提供了一种方法,让线程等待来自其他线程的通知。如果一个线程已经获取了特定对象的监视器,它可以调用 Monitor.Wait 并传入该对象。这有两个效果:释放监视器并使线程阻塞。线程将阻塞,直到其他线程为相同的对象调用 Monitor.PulsePulseAll。调用这些方法时,线程必须持有监视器。(Wait, PulsePulseAll 在没有持有相关监视器时会抛出异常。)

如果一个线程调用 Pulse,则允许一个等待在 Wait 中的线程唤醒。调用 PulseAll 则允许所有等待在该对象监视器上的线程运行。无论哪种情况,Monitor.Wait 在返回前都会重新获取监视器,因此即使调用 PulseAll,线程也会逐个唤醒——第二个线程在第一个线程释放监视器之前无法从 Wait 返回。事实上,直到调用 PulsePulseAll 的线程释放锁,没有线程能够从 Wait 返回。

示例 16-10 使用 WaitPulse 包装了一个 Queue<T>,使得从队列中检索项目的线程在队列为空时等待。(这只是为了说明,如果你需要这种类型的队列,不必自己编写,可以使用内置的 BlockingCollection<T>System.Thread⁠ing​.Channels 中的类型。)

示例 16-10. WaitPulse
public class MessageQueue<T>
{
    private readonly object _sync = new();

    private readonly Queue<T> _queue = new();

    public void Post(T message)
    {
        lock (_sync)
        {
            bool wasEmpty = _queue.Count == 0;
            _queue.Enqueue(message);
            if (wasEmpty)
            {
                Monitor.Pulse(_sync);
            }
        }
    }

    public T Get()
    {
        lock (_sync)
        {
            while (_queue.Count == 0)
            {
                Monitor.Wait(_sync);
            }
            return _queue.Dequeue();
        }
    }
}

本示例以两种方式使用监视器。它通过 lock 关键字确保一次只有一个线程使用保存排队项的 Queue<T>。但它还使用等待和通知使消费项的线程在队列为空时能够有效地阻塞,并使任何添加新项到队列的线程能唤醒被阻塞的读取线程。

超时

无论是等待通知还是尝试获取锁定,都可以指定超时,表示如果操作在指定时间内未成功,则希望放弃。对于锁的获取,使用不同的方法 TryEnter,但在等待通知时,只需使用不同的重载。(没有编译器支持这一点,因此你将无法使用 lock 关键字。)在两种情况下,你可以传递一个表示最大等待时间(以毫秒为单位)的 intTimeSpan 值。两者都返回一个指示操作是否成功的 bool

你可以使用这个方法来避免进程死锁,但如果你的代码在超时内未能获取锁,那么你就面临着如何处理这个问题的困扰。如果你的应用程序无法获取需要的锁,那么它不能简单地无视原本要做的工作。终止进程可能是唯一现实的选择,因为死锁通常是 bug 的症状,所以如果发生了,你的进程可能已经处于受损状态。尽管如此,一些开发人员对锁的获取可能不那么严格,可能认为死锁是正常的情况。在这种情况下,可能放弃你原本尝试的操作,稍后重试工作,或者只是记录一个失败,放弃这个特定的操作,并继续进行进程的其他工作,可能是一个可行的策略。但这可能是一种风险策略。

自旋锁

SpinLock 提供了与 Monitor 类的 EnterExit 方法类似的逻辑模型。(它不支持等待和通知。)它是一个值类型,因此在某些情况下,它可以减少需要分配以支持锁定的对象数量——Monitor 需要基于堆的对象。然而,它也更简单:它仅使用一种策略来处理争用,而 Monitor 从相同的策略开始,然后在一段时间后将切换到具有更高初始开销但如果涉及长时间等待则更有效的策略。

当调用 Enter 方法(无论是 Monitor 还是 SpinLock)时,如果锁可用,则会非常快地获取该锁——成本通常是少量的 CPU 指令。如果锁已被另一个线程持有,CLR 将在一个轮询锁的循环中等待(即自旋),直到锁被释放。如果锁仅被持有很短的时间,这可以是一种非常高效的策略,因为它避免了操作系统介入,并且在锁可用的情况下非常快速。即使存在争用,自旋在多核或多 CPU 系统上也可以是最有效的策略,因为如果锁仅被持有很短的时间(例如只需执行加法运算两个 decimal 的时间),线程在锁变得可用之前不必自旋很长时间。

MonitorSpinLock 的区别在于,Monitor 最终会放弃自旋,转而使用操作系统的调度器。这将产生相当于执行许多千甚至百万次 CPU 指令的成本,这就是为什么 Monitor 开始时使用与 SpinLock 类似的方法。然而,如果锁长时间不可用,自旋效率低下——即使只自旋几毫秒,现代 CPU 上会涉及数百万次自旋,在这种情况下,执行成千上万条指令以有效地挂起线程看起来更好一些。(自旋在单核系统上也存在问题,因为自旋依赖于持有锁的线程能够取得进展。^(3)

SpinLock 没有后备策略。与 Monitor 不同,它会自旋,直到成功获取锁或超时(如果指定了超时)。因此,文档建议,如果在持有锁期间执行某些操作(例如等待 I/O 完成或调用可能阻塞的其他代码),不应使用 SpinLock。它还建议不要通过接口、虚方法或委托调用方法,或者分配内存。如果在做任何较为复杂的事情,最好还是使用 Monitor。然而,对于访问 decimalSpinLock 可能是一种适当的保护方式,正如 Example 16-11 所示。

Example 16-11. 使用 SpinLock 保护 decimal 的访问
public class DecimalTotal
{
    private decimal _total;

    private SpinLock _lock;

    public decimal Total
    {
        get
        {
            bool acquiredLock = false;
            try
            {
                _lock.Enter(ref acquiredLock);
                return _total;
            }
            finally
            {
                if (acquiredLock)
                {
                    _lock.Exit();
                }
            }
        }
    }

    public void Add(decimal value)
    {
        bool acquiredLock = false;
        try
        {
            _lock.Enter(ref acquiredLock);
            _total += value;
        }
        finally
        {
            if (acquiredLock)
            {
                _lock.Exit();
            }
        }
    }
}

由于缺乏编译器支持,我们必须编写比使用lock更多的代码。也许这样做并不值得——因为Monitor在开始时会自旋,所以性能可能相似,因此这里唯一的好处是我们避免了为执行锁定而分配额外的堆对象。(SpinLock是一个struct,所以它存在于DecimalTotal对象的堆块内。)只有在通过性能分析证明在实际工作负载下它比监视器表现更好时,才应该使用SpinLock

读者/写者锁

ReaderWriterLockSlim类提供了一种不同的锁定模型,与MonitorSpinLock呈现的模型不同。使用ReaderWriterLockSlim时,获取锁时需指定自己是读取器还是写入器。该锁允许多个线程同时成为读取器。但是,当一个线程请求以写入器身份获取锁时,该锁会暂时阻止任何试图读取的线程,并等待所有已经在读取的线程释放其锁,然后才授予想要写入的线程访问权限。一旦写入器释放其锁,所有等待读取的线程就可以重新进入。这使得写入线程可以获得独占访问,但这也意味着当没有写入发生时,所有读取者可以并行进行。

警告

还有一个ReaderWriterLock类。不应使用它,因为即使没有锁争用,它也存在性能问题,并且当读取器和写入器线程都在等待获取锁时,它也会做出次优选择。较新的ReaderWriterLockSlim类已经存在很长时间(自.NET 3.5 起),并建议在所有场景中使用它而不是旧类。旧类仅保留用于向后兼容。

这听起来可能适合.NET 内置的许多集合类。正如我之前描述的,它们通常支持多个并发的读取线程,但要求修改必须由一个线程独占完成,并且在进行修改时没有读取器活动。然而,并不是在你偶尔同时有读者和写者的情况下就一定要选择这种锁。

尽管“slim”锁相较于其前身有了性能改进,但是获取该锁的时间仍比进入监视器要长。如果计划仅短时间持有该锁,可能更好直接使用监视器——通过更大的并发性提供的理论改进可能会被获取锁所需的额外工作所抵消。即使持有锁的时间较长,只有在更新偶尔发生时,读者/写者锁才会带来好处。如果有一连串的线程都想修改数据,你不太可能看到任何性能改进。

与所有性能驱动的选择一样,如果你考虑使用 Reader​Wri⁠terLockSlim 而不是普通监视器的简单替代方案,请在实际工作负载下用这两种选择来测量性能,看看这种变化是否有任何影响。

事件对象

Windows 的本机 API,Win32,一直提供了称为 事件 的同步原语。从 .NET 的角度来看,这个名称有点不幸,因为它定义了这个术语的完全不同含义,正如 第九章 中讨论的那样。在本节中,当我提到事件时,我指的是同步原语,除非我明确将其作为 .NET 事件进行限定。

ManualResetEvent 类提供了一种机制,其中一个线程可以等待另一个线程的通知。这与 Monitor 类的 WaitPulse 不同。首先,你不需要拥有监视器或其他锁定来等待或发出事件信号。其次,Monitor 类的脉冲方法只有在至少有一个其他线程在 Monitor.Wait 中阻塞在该对象上时才会起作用——如果没有任何等待,那么脉冲就好像从未发生过一样。但是 ManualResetEvent 记住它的状态——一旦发出信号,除非你通过调用 Reset 手动将其重置(因此得名),它将不会返回到未发出信号的状态。这使其在某些场景中非常有用,例如某个线程 A 无法继续直到另一个线程 B 完成了一些需要不可预测时间的工作。线程 A 可能需要等待,但当 A 检查时,线程 B 可能已经完成了工作。示例 16-12 使用了这种技术来执行一些重叠的工作。

示例 16-12. 使用 ManualResetEvent 等待工作完成
static void LogFailure(string message, string mailServer)
{
    var email = new SmtpClient(mailServer);

    `using` `(``var` `emailSent` `=` `new` `ManualResetEvent``(``false``)``)`
    {
        object sync = new();
        bool tooLate = false; // Prevent call to Set after a timeout
        `email``.``SendCompleted` `+``=` `(``_``,` `_``)` `=``>` `// (Event arguments unused here) `        `{`
            `lock``(``sync``)`
            `{`
                `if` `(``!``tooLate``)` `{` `emailSent``.``Set``(``)``;` `}`
            `}`
        `}``;`
        email.SendAsync("logger@example.com", "sysadmin@example.com",
            "Failure Report", "An error occurred: " + message, null);

        LogPersistently(message);

        `if` `(``!``emailSent``.``WaitOne``(``TimeSpan``.``FromMinutes``(``1``)``)``)`
        {
            LogPersistently("Timeout sending email for error: " + message);
        }

        lock (sync)
        {
            tooLate = true;
        }
    }
}

此方法使用 System.Net.Mail 命名空间中的 SmtpClient 类通过电子邮件向系统管理员发送错误报告。它还调用一个未在此处显示的内部方法 LogPersistently 将失败记录在本地日志机制中。由于这些都是可能需要一些时间的操作,代码会异步发送电子邮件——SendAsync 方法会立即返回,类会在电子邮件发送完成后引发一个 .NET 事件。这使得代码可以在发送电子邮件的同时继续执行 LogPersistently 方法。

记录了消息后,该方法在返回之前等待电子邮件发送完成,这就是 ManualResetEvent 的用武之地。通过将 false 传递给构造函数,我将事件置于初始未发出信号状态。但在处理电子邮件 SendCompleted .NET 事件的处理程序中,我调用同步事件的 Set 方法,这将使其进入发出信号状态。(在生产代码中,我还会检查 .NET 事件处理程序的参数,看看是否有错误,但这里我省略了,因为它与我要说明的点无关。)

最后,我调用WaitOne,它会阻塞直到事件被标记为已信号。SmtpClient可能完成工作得很快,以至于在我调用LogPersistently返回之前邮件已经发送出去了。但没关系——在这种情况下,WaitOne会立即返回,因为一旦调用SetManualResetEvent就会保持信号状态。所以不管哪个工作先完成——持久化日志还是发送邮件,WaitOne都会在邮件发送后让线程继续。关于这个方法奇怪名称的背景,请参见下一个侧边栏,“WaitHandle”。

还有一个AutoResetEvent。一旦单个线程从等待此类事件返回,它会自动恢复到未标记状态。因此,在此事件上调用Set将最多允许一个线程通过。如果在没有线程等待时调用一次Set,事件将保持设置状态,所以不像Monitor.Pulse,通知不会丢失。但该事件不会维护等待设置的数量——如果在没有线程等待事件的情况下调用两次Set,它仍然只允许第一个线程通过,并立即重置。

这两种事件类型只间接地继承自WaitHandle,通过EventWaitHandle基类。你可以直接使用它,并且可以通过构造函数参数指定手动或自动重置。但更有趣的是EventWaitHandle允许你跨进程边界工作(仅限于 Windows)。底层的 Win32 事件对象可以被命名,如果你知道另一个进程创建的事件的名称,你可以在构造EventWaitHandle时传递该名称来打开它。(如果还不存在你指定名称的事件,则你的进程将创建它。)在 Unix 上不存在与命名事件的等效物,因此如果尝试在这些环境中创建一个,将会得到PlatformNotSupportedException异常,尽管支持单进程使用,因此你可以自由使用这些类型,只要不尝试指定名称。

还有一个ManualResetEventSlim类。但与非精简的读取/写入器不同,ManualResetEvent并未被其精简后继者取代,因为只有旧类型支持跨进程使用。ManualResetEventSlim类的主要优点是,如果你的代码只需等待很短的时间,它可能更高效,因为它会像SpinLock一样轮询一段时间。这样可以避免使用相对昂贵的 OS 调度服务。但最终它会放弃并回退到更重的机制。(即使在这种情况下,它也稍微更高效,因为它不需要支持跨进程操作,因此使用更轻量级的机制。)自动事件没有精简版本,因为自动重置事件并不广泛使用。

障碍

在前面的章节中,我展示了如何使用事件来协调并发工作,使得一个线程在继续之前等待某些事件发生。运行时库提供了一个类来处理类似的协调,但语义略有不同。Barrier 类可以处理多个参与者,并且还可以支持多个阶段,这意味着线程可以在工作进行过程中多次等待彼此。Barrier 是对称的——在 示例 16-12 中,事件处理程序调用 Set 而另一个线程调用 WaitOne,而使用 Barrier,所有参与者都调用 SignalAndWait 方法,这实际上将设置和等待组合成一个操作。

当参与者调用 SignalAndWait 时,方法会阻塞,直到所有参与者都调用它为止,此时它们都将解除阻塞并且可以继续。因为你在构造函数参数中传递了计数值,所以 Barrier 知道要期望多少参与者。

多阶段操作只是简单地再来一次。一旦最后一个参与者调用 SignalAndWait 并释放其他线程,如果有任何线程第二次调用 SignalAndWait,它将像以前一样被阻塞,直到所有其他线程第二次调用它。CurrentPhaseNumber 告诉你到目前为止这种情况发生了多少次。

这种对称性使得 Barrier 不如 示例 16-12 中的 ManualResetEvent 适合,因为在后者中,只有一个线程真正需要等待。让 SendComplete 事件处理程序等待持久日志更新完成没有任何好处——只有一个参与者关心工作何时完成。ManualResetEvent 只支持单个参与者,但这并不一定是使用 Barrier 的理由。如果你想要带有多个参与者的事件风格的不对称性,还有另一种方法:倒计时。

CountdownEvent

CountdownEvent 类类似于事件,但它允许你指定在允许等待线程通过之前必须被信号量标记的次数。构造函数接受一个初始计数参数,你可以随时通过调用 AddCount 增加计数。调用 Signal 方法来减少计数;默认情况下,它会减少一个,但有一种重载可以让你减少指定数量。

Wait 方法会阻塞,直到计数器达到零。如果你想查看当前计数以了解还有多少工作要做,可以读取 CurrentCount 属性。

信号量

另一个在并发系统中广泛使用的基于计数的系统被称为信号量。Windows 对此有原生支持,而.NET 的Semaphore类最初设计为其包装器。与事件包装器类似,Semaphore派生自WaitHandle,在非 Windows 平台上会模拟其行为。CountdownEvent在计数达到零后才允许等待线程通过,而Semaphore则在计数为零时开始阻塞线程。如果你希望确保不超过特定数量的线程同时执行某些工作,可以使用它。

因为Semaphore派生自WaitHandle,所以调用WaitOne方法来等待。只有在计数已经为零时才会阻塞。它在返回时将计数减一。通过调用Release来增加计数。您必须在构造函数参数中指定初始计数,并且还必须提供一个最大计数——如果调用Release尝试将计数设置为超过最大值,它将引发异常。

与事件类似,Windows 支持信号量的跨进程使用,因此可以选择将信号量名称作为构造函数参数传递。这将打开现有的信号量,如果尚未存在具有指定名称的信号量,则创建一个新的信号量。

还有一个SemaphoreSlim类。与ManualResetEventSlim类似,在线程通常不必长时间阻塞的场景中提供了性能优势。SemaphoreSlim提供了两种递减计数的方式。其Wait方法与Semaphore类的WaitOne方法类似,但它还提供了WaitAsync,它返回一个Task,一旦计数为非零就完成(并在完成任务时递减计数)。这意味着您无需阻塞线程等待信号量可用。此外,这意味着您可以使用第十七章中描述的await关键字来递减信号量。

互斥体

Windows 定义了一个名为互斥体的同步原语,为此.NET 提供了一个包装类Mutex。名称简称为“互斥”,因为一次只能有一个线程拥有互斥体——如果线程 A 拥有了互斥体,线程 B 就不能拥有,反之亦然。这也正是lock关键字通过Monitor类为我们所做的,但Mutex提供了两个优点。它支持跨进程:与其他跨进程同步原语一样,在构造互斥体时可以传递一个名称。(而且与其他所有类型不同,在 Unix 平台上也支持命名。)使用Mutex还可以在单个操作中等待多个对象。

注意

ThreadPool.RegisterWaitForSingleObject方法不适用于互斥体,因为 Win32 要求互斥体所有权与特定线程相关联,而线程池的内部工作意味着RegisterWaitForSingleObject无法确定哪个线程池线程处理具有互斥体的回调。

通过调用WaitOne获取互斥体,如果在那时某个其他线程拥有互斥体,则WaitOne将阻塞,直到该线程调用ReleaseMutex。一旦WaitOne成功返回,你就拥有了互斥体。你必须在获取互斥体的同一线程上释放互斥体。

Mutex类没有“slim”版本。我们已经有了低开销的等价物,因为所有.NET 对象都具有通过Monitorlock关键字提供轻量级互斥的天然能力。

Interlocked

Interlocked类与本节到目前为止描述的其他类型有些不同。它支持对共享数据的并发访问,但不是同步原语。相反,它定义了静态方法,提供各种简单操作的原子形式。

例如,它提供了IncrementDecrementAdd方法,支持intlong值的重载。(这些操作类似——增加或减少只是加 1 或-1。)加法涉及从某个存储位置读取值,计算修改后的值,并将其存回同一存储位置,如果使用普通的 C#运算符进行此操作,如果多个线程尝试同时修改同一位置,可能会出现问题。如果值最初为0,某个线程读取该值,然后另一个线程也读取该值,如果两者都加 1 并将结果存回,则它们最终都将写回1——两个线程尝试增加值,但实际上只增加了一个。使用Interlocked形式的这些操作可以防止这种重叠发生。

Interlocked还提供了用于交换值的各种方法。Exchange方法接受两个参数:一个值的引用和一个值。它返回当前在第一个参数引用的位置的值,并用作第二个参数提供的值覆盖该位置,并且将这两个步骤作为单个原子操作执行。它支持intuintlongulongobjectfloatdouble,以及一种称为IntPtr的类型,表示非托管指针。还有一个泛型的Exchange<T>,其中T可以是任何引用类型。

还支持条件交换,使用CompareExchange方法。它接受三个值——与Exchange一样,它接受一个对要修改的某个变量的引用,以及要替换它的值,但还接受第三个参数:您认为已经在存储位置中的值。如果存储位置中的值与预期值不匹配,则此方法不会更改存储位置。(它仍然返回存储位置中的任何值,无论它是否修改了它。)实际上,可以根据这个方法来实现我描述的其他Interlocked操作。示例 16-13 使用它来实现一个交错增量操作。

示例 16-13. 使用CompareExchange
static int InterlockedIncrement(ref int target)
{
    int current, newValue;
    do
    {
        current = target;
        newValue = current + 1;
    }
    while (Interlocked.CompareExchange(ref target, newValue, current)
            != current);
    return newValue;
}

对于其他操作,模式是相同的:读取当前值,计算要替换它的值,然后仅在该值在此期间似乎未更改时替换它。如果在获取当前值和替换它之间值发生更改,则再次尝试。在这里需要稍微小心——即使CompareExchange成功,其他线程在您读取值和更新值之间可能两次修改该值,第二次更新将事情恢复到第一次更新之前。对于加法和减法,这并不重要,因为它不影响结果,但一般来说,您不应太过于假设成功更新表示什么。如果您有疑问,通常最好坚持使用更重的同步机制之一。

最简单的Interlocked操作是Read方法。它接受一个ref long并原子地读取该值,与通过Interlocked执行的同一变量上的任何其他操作相关。这使您可以安全地读取 64 位值——一般来说,CLR 不保证 64 位读取是原子的(在 64 位进程中,它们通常是,但如果您需要在 32 位架构上保证原子性,则需要使用Interlocked.Read)。没有 32 位值的重载,因为对它们的读写总是原子的。

Interlocked支持的操作对应于大多数 CPU 可以直接支持的原子操作。(一些 CPU 架构本能地支持所有这些操作,而其他一些则仅支持比较和交换,并通过这种方式构建其他所有操作。但无论如何,这些操作最多只是几条指令。)这意味着它们相对高效。与使用普通代码执行等效的非原子操作相比,它们成本要高得多,因为原子 CPU 指令需要在所有 CPU 核心(以及在安装了多个物理上分离的 CPU 的计算机中,所有 CPU 芯片)之间协调以保证原子性。尽管如此,它们的成本远低于lock语句在操作系统级别上阻塞线程时所付出的代价的一小部分。

这类操作有时被描述为无锁操作。这种说法并不完全准确——计算机在硬件的相对低层级上会非常短暂地获取锁。原子读-修改-写操作实际上会在计算机的内存上占用独占锁定,持续两个总线周期。然而,不会获取操作系统的锁,调度程序也不需要介入,而且这些锁持有的时间极短——通常仅仅是一个机器码指令。更重要的是,这里使用的高度专门化和低级别的锁定形式不允许在等待获取另一个锁时保持一个锁的持有状态——代码每次只能锁定一件事情。这意味着这种操作不会发生死锁。然而,排除死锁的简单性也有其两面性。

互锁操作的缺点在于原子性仅适用于极其简单的操作。仅使用Interlocked在多线程环境中构建更复杂的逻辑非常困难。相比之下,使用高级别的同步原语更容易且风险较小,因为这些原语使得保护更复杂的操作变得相对容易,而不仅仅是单个计算。通常情况下,你只会在对性能要求极高的工作中使用Interlocked,即使如此,你也应该仔细测量以验证它是否产生了你期望的效果——例如示例 16-13 中的代码在理论上可以循环任意次数才最终完成,因此它可能比你预期的成本更高。

在使用低级原子操作编写正确代码时的最大挑战之一是,您可能会遇到由 CPU 缓存工作方式引起的问题。一个线程执行的工作可能不会立即对其他线程可见,并且在某些情况下,内存访问可能不会按照代码指定的顺序发生。使用更高级别的同步原语可以通过强制执行某些顺序约束来避免这些问题,但如果您决定使用Interlocked来构建自己的同步机制,您需要理解.NET 为多个线程同时访问同一内存时定义的内存模型,并且通常需要使用Interlocked类定义的MemoryBarrier方法或Volatile类定义的各种方法来确保正确性。这超出了本书的范围,也是编写看起来工作正常但在重载时(即在这可能最为重要的时候)实际上出错的代码的一个很好的方法,因此这类技术很少值得成本。除非您真的别无选择,否则请坚持我在本章讨论过的其他机制。

延迟初始化

当您需要一个对象能够从多个线程访问时,如果该对象可能是不可变的(即,其字段在构造后不会更改),通常可以避免需要同步。多个线程同时从同一位置读取数据始终是安全的——只有在数据需要更改时才会出现问题。然而,这里有一个挑战:何时以及如何初始化共享对象?一种解决方法可能是将对象的引用存储在静态字段中,并从静态构造函数或字段初始化程序初始化该静态字段——CLR 保证对任何类的静态初始化仅运行一次。然而,这可能会导致对象比您想要的更早地被创建。如果在静态初始化中执行了太多工作,则可能会对应用程序启动所需的时间产生不利影响。

在初始化对象之前,您可能希望等到第一次需要该对象。这被称为延迟初始化。这并不特别难实现——您可以检查字段是否为null,如果不是,则初始化它,并使用lock确保只有一个线程可以构造该值。然而,开发人员似乎对展示自己有多聪明有着 remarkable 的食欲,这可能会有一个潜在的不良结果,即显示他们并不像他们认为的那么聪明。

lock 关键字虽然效率相当高,但通过使用 Interlocked 可能会更好。然而,在多处理器系统上的内存访问重排序的微妙之处使得编写代码既快速又聪明,但并非总是有效。为了避免这种反复出现的问题,.NET 提供了两个类来执行延迟初始化,而无需使用 lock 或其他潜在昂贵的同步原语。其中最简单的是 Lazy<T>

Lazy

Lazy<T> 类提供了一个 Value 属性,类型为 T,并且在首次读取该属性之前不会创建 Value 返回的实例。默认情况下,Lazy<T> 将使用 T 的无参数构造函数,但您可以提供自己的方法来创建该实例。

Lazy<T> 能够为您处理竞态条件。实际上,您可以配置所需的多线程保护级别。由于延迟初始化在单线程环境中也可能很有用,因此您可以通过将 falseLazyThreadSafetyMode.None 作为构造函数参数来完全禁用多线程支持。但对于多线程环境,您可以在 LazyThreadSafetyMode 枚举中选择其他两种模式之一。

这些决定了如果多个线程几乎同时尝试首次读取 Value 属性时会发生什么。PublicationOnly 并不尝试确保只有一个线程创建对象 - 它仅在线程完成对象创建时应用任何同步。首个完成构造或初始化的线程将提供对象,其他已启动初始化的线程生成的对象均被丢弃。一旦值可用,所有进一步尝试读取 Value 的操作将直接返回该值。

如果选择 ExecutionAndPublication,则只允许单个线程尝试构造。这可能看起来不太浪费,但 PublicationOnly 提供了一个潜在的优势:因为它在初始化过程中避免了持有任何锁,所以在初始化代码本身尝试获取任何锁时,您不太可能引入死锁 bug。PublicationOnly 还会以不同的方式处理错误。如果第一次初始化尝试引发异常,则其他开始构造尝试的线程将有机会完成,而对于 ExecutionAndPublication,如果唯一的初始化尝试失败,则会保留异常,并且每次读取 Value 时都会抛出异常。

LazyInitializer

支持延迟初始化的另一个类是 LazyInitializer。这是一个静态类,您完全通过其静态泛型方法使用它。与 Lazy<T> 相比稍微复杂一些,但它避免了除所需的惰性分配实例之外的额外对象的分配。示例 16-14 展示了如何使用它。

示例 16-14. 使用 LazyInitializer
public class Cache<T>
{
    private static Dictionary<string, T>? _d;

    public static IDictionary<string, T> Dictionary =>
        LazyInitializer.EnsureInitialized(ref _d);
}

如果字段为空,则EnsureInitialized方法会构造参数类型的一个实例——在本例中为Dictionary<string, T>。否则,它将返回字段中已有的值。还有一些其他重载方式。您可以像对Lazy<T>一样传递回调。您还可以传递一个ref bool参数,它将检查以发现初始化是否已经发生(并在执行初始化时将其设置为true)。

静态字段初始化程序会给我们带来相同的一次性初始化,但可能会在进程的生命周期中运行得更早。在具有多个字段的更复杂类中,静态初始化甚至可能导致不必要的工作,因为它适用于整个类,所以您可能会构造不会被使用的对象。这可能增加应用程序启动所需的时间。LazyInitializer允许您在首次使用时初始化各个字段,确保只做必要的工作。

其他类库并发支持

System.Collections.Concurrent命名空间定义了各种集合,在多线程环境中提供了比通常的集合更慷慨的保证,这意味着您可以在不需要任何其他同步原语的情况下使用它们。但要小心,尽管单个操作在多线程世界中可能具有良好定义的行为,但如果您需要执行的操作涉及多个步骤,这并不一定会帮助您。您可能仍然需要在更广泛的范围内进行协调以确保一致性。但在某些情况下,并发集合可能是您所需要的全部内容。

与非并发集合不同,ConcurrentDictionaryConcurrentBagConcurrentStackConcurrentQueue都支持在枚举(例如使用foreach循环)这些内容进行的同时修改它们的内容。字典提供了一个实时枚举器,这意味着如果在枚举过程中添加或删除了值,枚举器可能会显示一些已添加的项,但可能不会显示已删除的项。它不提供明确的保证,主要是因为在多线程代码中,当两个事情发生在两个不同的线程上时,不总是完全清楚哪个事件发生得更早——相对论的法则意味着这可能取决于您的观点。

这意味着,枚举器在从字典中删除该项之后似乎仍返回该项是可能的。袋子、堆栈和队列采取了不同的方法:它们的枚举器都会拍摄快照并在其上进行迭代,因此foreach循环将看到一组内容,这组内容与过去某个时间点集合中的内容一致,即使该集合此后可能已发生变化。

正如我在第五章中已经提到的,并发集合提供的 API 与其非并发对应物相似,但增加了一些成员以支持原子添加和删除项目。例如,ConcurrentDictionary提供了一个GetOrAdd方法,如果已存在条目则返回现有条目,否则添加一个新条目。

运行库的另一部分,可以帮助您处理并发而无需显式使用同步原语,就是 Rx(这是第十一章的主题)。它提供各种运算符,可以将多个异步流组合成单一流。这些操作管理并发问题,记住每个单一的可观察对象都会一次为观察者提供一个项目。

Rx 采取必要的步骤来确保即使是从许多个体流合并输入,这些流都在同时生成项目,它也能遵守这些规则。只要所有源都遵循规则,Rx 就不会要求观察者一次处理多个事物。

System.Threading.Channels NuGet 包提供了支持生产者/消费者模式的类型,其中一个或多个线程生成数据,而其他线程消费这些数据。您可以选择通道是否缓冲,使生产者可以超过消费者,以及超过多少。 (System.Collections.Concurrent中的BlockingCollection<T>也提供这种服务。但是它不太灵活,不支持第十七章中描述的await关键字。)

最后,在多线程场景中,值得考虑的是不可变集合类,我在第五章中有描述。这些集合支持任意数量线程的并发访问,并且因为它们是不可变的,所以从不会出现如何处理并发写访问的问题。显然,不可变性带来了很大的约束,但如果能找到一种方法与这些类型一起工作(记住,内置的string类型是不可变的,因此你已经有了一些使用不可变数据的经验),它们在某些并发场景中非常有用。

任务

在本章的前面部分,我展示了如何使用Task类在线程池中启动工作。这个类不仅仅是线程池的一个包装器。Task及其相关类型构成的任务并行库(TPL)可以处理更广泛的场景。任务特别重要,因为 C#的异步语言特性(这是第十七章的主题)能够直接与其一起工作。运行库中许多 API 都提供基于任务的异步操作。

虽然任务是使用线程池的首选方式,但它们不仅仅是关于多线程的。基本的抽象比那更加灵活。

TaskTask<T>

TPL 的核心有两个类:Task 和从它派生的类 Task<T>Task 基类表示可能需要一些时间才能完成的工作。Task<T> 则扩展此功能以表示完成时会产生结果(类型为 T)的工作。(非泛型 Task 不产生任何结果。它是异步版本的 void 返回类型。)注意,这些不一定涉及线程的概念。

大多数 I/O 操作可能需要一段时间才能完成,在大多数情况下,运行时库为它们提供了基于任务的 API。示例 16-15 使用异步方法作为字符串获取网页内容。由于它无法立即返回字符串 —— 可能需要一些时间来下载页面 —— 因此它返回一个任务。

示例 16-15. 基于任务的网络下载
var w = new HttpClient();
string url = "https://endjin.com/";
Task<string> webGetTask = w.GetStringAsync(url);
注意

大多数基于任务的 API 遵循一种命名约定,即它们以 Async 结尾,如果有相应的同步 API,则该 API 的名称不带 Async 后缀。例如,System.IO 中的 Stream 类,提供对字节流的访问,具有 Write 方法用于将字节写入流,该方法是同步的(即它在完成工作之前会等待)。它还提供 WriteAsync 方法。它与 Write 做的事情相同,但因为它是异步的,所以返回而不等待工作完成。它返回一个 Task 来表示工作;这种约定称为 基于任务的异步模式(TAP)。

GetStringAsync 方法不等待下载完成,因此几乎立即返回。要执行下载,计算机必须向相关服务器发送消息,然后必须等待响应。一旦请求启动,CPU 在大部分请求进程中无需执行任何工作,这意味着此操作大部分时间无需涉及线程。因此,此方法不需要在调用 Task.Run 时包装某些基础同步 API 的调用。事实上,HttpClient 甚至没有大多数操作的同步版本。对于同时提供 I/O API 的类,如 Stream,同步版本通常是对基本异步实现的包装:当您调用阻塞 API 执行 I/O 时,它通常会在内部执行异步操作,然后只是阻塞调用线程,直到该工作完成。即使在完全非异步的情况下,例如,FileStream 可以使用非异步操作系统文件 API 实现 ReadWrite —— OS 内核中的 I/O 通常是异步的。

因此,尽管 TaskTask<T> 类很容易生成通过在线程池线程运行方法的任务,它们也能够表示在大部分时间内不需要使用线程的基本异步操作。虽然这不是官方术语的一部分,我将这种操作描述为无线程任务,以区分它们与完全在线程池线程上运行的任务。

ValueTaskValueTask<T>

TaskTask<T> 非常灵活,不仅因为它们可以表示基于线程和无线程的操作。正如你将看到的,它们提供了多种机制来发现它们所代表的工作何时完成,包括将多个任务组合为一个任务的能力。多个线程可以同时等待同一个任务。你可以编写缓存机制,重复地分配同一个任务,即使在任务完成之后很长时间仍然如此。这一切都非常方便,但也意味着这些任务类型也具有一些开销。对于更受限的情况,.NET 定义了更少灵活的 ValueTaskValueTask<T> 类型,在某些情况下效率更高。

这些类型与它们的普通对应类型之间最重要的区别在于 ValueTaskValueTask<T> 都是值类型。在性能敏感的代码中,这一点非常重要,因为它可以减少代码分配的对象数量,从而减少应用程序执行垃圾回收工作的时间。你可能会认为,通常涉及并发工作的上下文切换成本可能很高,以至于在处理异步操作时,对象分配的成本将是你最不用担心的问题之一。虽然这通常是正确的,但有一个非常重要的场景,Task<T> 的垃圾回收开销可能会成为一个问题:有时运行缓慢但通常不会的操作。

对于 I/O API 来说,执行缓冲以减少对操作系统的调用是非常常见的。如果你向 Stream 写入少量字节,它通常会将这些字节放入缓冲区,并等待,直到要么你写入足够的数据使其值得将其发送到操作系统,要么你显式调用 Flush。读取时也常常进行缓冲——如果你从文件中读取一个字节,操作系统通常会从驱动器中读取整个扇区(通常至少为 4 KB),并且该数据通常会保存在内存中,因此当你请求第二个字节时,不需要再进行 I/O 操作。实际上,如果你编写一个循环,以相对较小的块(例如一次一行文本)从文件中读取数据,那么大多数读取操作将立即完成,因为要读取的数据已经被提前获取。

在这些情况下,大多数对异步 API 的调用会立即完成,创建任务对象的 GC 开销可能会变得显著。这就是为什么引入了 ValueTaskValueTask<T>。(这些是内置于 .NET Core、.NET 和 .NET Standard 2.1 中的。在 .NET Framework 中,你可以通过 System.Threading.Tasks.Extensions NuGet 包获取它们。)这些类型使得可能的是潜在的异步操作可以在不需要分配任何对象的情况下立即完成。在无法立即完成的情况下,这些类型最终会成为 TaskTask<T> 对象的包装器,此时开销会返回,但在只有少数调用需要这样做的情况下,这些类型可以在使用了低分配技术的代码中提供显著的性能提升,尤其是在 第十八章 中描述的代码中。

非泛型的 ValueTask 很少被使用,因为产生无结果的异步操作可以直接返回 Task.CompletedTask 静态属性,它提供了一个可重复使用的任务,已经处于完成状态,避免了任何 GC 开销。但需要生成结果的任务通常不能重用现有任务。(也有一些例外情况:运行时库通常会为 Task<bool> 使用缓存的预完成任务,因为只有两种可能的结果。但对于 Task<int>,没有实际的方法来维护每个可能结果的预完成任务列表。)

这些值任务类型有一些限制。它们是单次使用的:与 TaskTask<T> 不同,你不应该将这些类型存储在字典或 Lazy<T> 中以提供缓存的异步值。在完成之前尝试检索 ValueTask<T>Result 是错误的。多次检索 Result 也是错误的。一般来说,你应该使用 ValueTaskValueTask<T> 进行一次 await 操作(如 第十七章 中所述),然后再也不要使用它们了。(或者,如果必要,可以通过调用其 AsTask 方法来获取完整的 TaskTask<T>,带有所有对应的开销,此时你不应再对值任务进行任何操作。)

因为值类型任务是在 TPL 出现多年后引入的,类库经常使用 Task<T>,而你可能期望看到的是 ValueTask<T>。例如,Stream 类的 ReadAsync 方法都是主要候选项,但因为大多数这些方法在 ValueTask<T> 存在之前就定义好了,所以它们大多返回 Task<T>。不过,最近添加的重载版本接受 Memory<byte> 而不是 byte[],确实返回 ValueTask<T>,而且更一般地说,在增加对 第十八章 中描述的新内存高效技术支持的 API 中,这些方法通常会返回 ValueTask<T>。如果你处于对任务的 GC 开销非常敏感的环境中,你可能会希望无论如何都使用这些技术。

任务创建选项

你可以通过使用 Task.FactoryTask<T>.FactoryStartNew 方法创建一个基于线程的任务,而不是使用 Task.Run,这样可以更好地控制新任务的某些方面。 StartNew 的一些重载接受 enum 类型 TaskCreationOptions 的参数,这提供了对 TPL 如何调度任务的一些控制。

PreferFairness 标志请求在已经调度的任务之后运行该任务。默认情况下,线程池通常先运行最近添加的任务(即后进先出,或者 LIFO 策略),因为这样更有效地利用 CPU 缓存。

LongRunning 标志警告 TPL 任务可能会运行很长时间。默认情况下,TPL 的调度器优化相对较短的工作项 —— 即多达几秒钟的任何工作。此标志表明工作可能需要更长时间,这种情况下,TPL 可能会修改其调度。如果有太多长时间运行的任务,它们可能会使用完所有线程,即使某些排队的工作项可能要短得多,它们仍然需要等待在缓慢工作的后面才能开始。但如果 TPL 知道哪些项目可能快速运行,哪些可能较慢,它可以以不同的优先级进行调度,以避免这些问题。

其他 TaskCreationOptions 设置涉及父/子任务关系和调度器,稍后我将进行描述。

任务状态

任务在其生命周期中经历多个状态,你可以使用 Task 类的 Status 属性来发现它所处的位置。这返回 enum 类型 TaskStatus 的值。如果任务成功完成,则该属性将返回枚举的 RanToCompletion 值。如果任务失败,则为 Faulted。如果使用 “Cancellation” 中显示的技术取消任务,则状态将为 Canceled

在“进行中”主题上有几种变体,其中Running是最明显的——表示某个线程当前正在执行任务。代表 I/O 的任务在进行时通常不需要线程,因此它永远不会进入该状态——它始于WaitingForActivation状态,然后通常直接转换为三种最终状态之一(RanToCompletionFaultedCanceled)。基于线程的任务也可以处于WaitingForActivation状态,但只有在某些情况下才会阻止其运行,这通常发生在您设置任务仅在某些其他任务完成时运行时(我稍后将展示如何做到)。基于线程的任务也可能处于WaitingToRun状态,这意味着它在队列中等待线程池线程变得可用。可以在任务之间建立父/子关系,已经完成的父任务创建了一些尚未完成的子任务将处于WaitingForChildrenToComplete状态。

最后,还有Created状态。您很少见到它,因为它表示您已创建但尚未请求运行的基于线程的任务。使用任务工厂的StartNew方法或Task.Run创建的任务中永远不会看到这一点,但如果直接构造新的Task,则会看到这一点。

大多数情况下,TaskStatus属性中的详细级别可能过于复杂,因此Task类定义了各种更简单的bool属性。如果只想知道任务是否没有更多工作要做(并且不关心它成功、失败还是被取消),可以使用IsCompleted属性。如果想检查失败或取消,可以使用IsFaultedIsCanceled

检索结果

假设您有一个Task<T>,可以通过提供一个 API 或创建返回值的基于线程的任务获取它。如果任务成功完成,您可能希望检索其结果,您可以从Result属性中获取。因此,由示例 16-15 创建的任务使网页内容在webGetTask.Result中可用。

如果尝试在任务完成之前读取Result属性,则会阻塞您的线程,直到结果可用。(如果有一个普通的Task,它不返回结果,并且您想要等待其完成,可以直接调用Wait。)如果操作失败,则Result会抛出异常(Wait也会如此),尽管这并不像您可能期望的那样直接,我将在“错误处理”中讨论。

警告

你应该避免在未完成的任务上使用Result。在某些情况下,这可能会导致死锁。这在桌面应用程序中特别常见,因为某些工作需要在特定线程上进行,如果通过读取未完成任务的Result来阻塞线程,可能会阻止任务完成。即使不会发生死锁,通过阻塞Result可能会导致性能问题,因为它会占用线程池线程,这些线程本来可以继续进行有用的工作。在未完成的ValueTask<T>中读取Result是不允许的。

在大多数情况下,最好使用 C#的异步语言特性来检索结果。这将是下一章的主题,但作为一个预览,示例 16-16 展示了你如何使用它来获取获取网页的任务结果。(你需要在方法声明前面应用async关键字才能使用await关键字。)

示例 16-16. 使用await获取任务结果
string pageContent = await webGetTask;

这看起来可能并不像是简单地写webGetTask.Result这样的改进,但正如我在第十七章中所展示的,这段代码并不是看上去的那样——C#编译器会将这个语句重组成一个回调驱动的状态机,使你能够在不阻塞调用线程的情况下获取结果。(如果操作尚未完成,线程会返回给调用者,当操作完成时,方法的其余部分稍后运行。)

但是异步语言特性是如何使这个工作的——代码如何发现任务何时完成?ResultWait让你坐下等待这种情况发生,阻塞线程,但这实际上违背了使用异步 API 的初衷。通常情况下,你希望在任务完成时收到通知,你可以通过继续来实现这一点。

继续

任务提供了名为ContinueWith的方法的各种重载。这将创建一个新的基于线程的任务,在你调用ContinueWith的任务完成时执行(无论是成功完成、失败还是取消)。示例 16-17 在示例 16-15 中创建的任务上使用了这个方法。

示例 16-17. 一个继续
webGetTask.ContinueWith(t =>
{
    string webContent = t.Result;
    Console.WriteLine("Web page length: " + webContent.Length);
});

一个继续任务始终是一个基于线程的任务(无论其前置任务是基于线程、基于 I/O 还是其他什么)。当你调用ContinueWith时,任务会立即创建,但在其前置任务完成之前不会变为可运行状态。(它最初处于WaitingForActivation状态。)

注意

继续是一个独立的任务——ContinueWith返回一个Task<T>Task,取决于你提供的委托是否返回结果。如果你想要链接一系列操作,你可以为一个继续设置一个继续。

您为继续任务提供的方法(例如在 示例 16-17 中的 lambda 表达式)将其前置任务作为其参数,并且我已经使用它来检索结果。我也可以使用包含方法中的 webGetTask 变量,因为它引用相同的任务。但是,通过使用参数,示例 16-17 中的 lambda 表达式不使用其包含方法的任何变量,这使得编译器可以生成稍微更高效的代码——它不需要创建对象来保存共享变量,并且它可以重用创建的委托实例,因为它不必为每个调用创建特定于上下文的委托实例。这意味着如果我认为这样做会使代码更易读,我也可以轻松地将其分离为普通的非内联方法。

您可能会认为在 示例 16-17 中存在一个可能的问题:如果下载完成得非常快,以至于 webGetTask 已经在代码管理附加继续任务之前完成了怎么办?实际上,这并不重要——如果您在已经完成的任务上调用 ContinueWith,它仍然会运行继续任务。它只是立即安排它。您可以附加任意数量的继续任务。在任务完成之前附加的所有继续任务将在其完成时安排执行。而在任务完成后附加的继续任务将立即安排执行。

默认情况下,继续任务将像任何其他任务一样在线程池上安排执行。然而,有些事情可以改变它的运行方式。

一些 ContinueWith 的重载接受一个 enum 类型的参数 TaskContinua⁠tionOptions,它控制任务如何(以及是否)被安排。这包括与 TaskCreationOptions 可用的所有选项相同的选项,但添加了一些特定于继续任务的选项。

您可以指定继续任务仅在特定情况下运行。例如,OnlyOnRanToCompletion 标志将确保继续任务仅在前置任务成功时运行。还有类似的 OnlyOnFaultedOnlyOnCan⁠celed 标志。或者,您可以指定 NotOnRanToCompletion,这意味着继续任务仅在任务故障或取消时运行。

注意

您可以为单个任务创建多个继续任务。因此,您可以设置一个处理成功情况,另一个处理失败情况。

您还可以指定ExecuteSynchronously。这表示连续性不应作为单独的工作项进行调度。通常,当任务完成时,该任务的任何连续性将被调度执行,并且必须等待直到正常的线程池机制从队列中选择工作项并执行它们。(如果使用默认选项,这不会花费太多时间——除非指定了PreferFairness,线程池用于任务的 LIFO 操作意味着最近调度的项目先运行。)然而,如果您的完成仅需非常少量的工作,将其调度为完全独立的项目的开销可能过大。因此,ExecuteSynchronously 允许您在同一个线程池工作项上挂载完成任务——TPL 将在前驱完成后立即运行这种类型的连续性,然后将线程返回给池。只有在连续性将快速运行时才应使用此选项。

LazyCancellation 选项处理了一种棘手的情况,如果您使任务可取消(如后文所述的“Cancellation”),并且使用了连续性,那么可能会出现问题。如果取消了一个任务,默认情况下任何连续性会立即变为可运行状态。如果被取消的任务本身设置为另一个尚未完成的任务的连续性,并且有自己的连续性,正如示例 16-18 所示,这可能会产生一种轻微令人惊讶的效果。

示例 16-18. 取消和链式连续性
private static void ShowContinuations()
{
    Task op = Task.Run(DoSomething);
    var cs = new CancellationTokenSource();
    Task onDone = op.ContinueWith(
        _ => Console.WriteLine("Never runs"),
        cs.Token);
    Task andAnotherThing = onDone.ContinueWith(
        _ => Console.WriteLine("Continuation's continuation"));
    cs.Cancel();
}

static void DoSomething()
{
    Thread.Sleep(1000);
    Console.WriteLine("Initial task finishing");
}

这将创建一个任务,将调用DoSomething,然后是该任务的可取消连续性(onDone中的Task),然后是作为第一个连续性的最终任务(andAnotherThing)。此代码几乎立即被取消,几乎可以肯定会在第一个任务完成之前发生。其效果是最终任务在第一个任务完成之前运行。当onDone完成时,最终的andAnotherThing任务变为可运行状态,即使该完成是由于取消了onDone。由于这里存在一条链——andAnotherThingonDone的连续性,而onDoneop的连续性——andAnotherThingop完成之前运行有些奇怪。LazyCancellation 改变了行为,使得第一个连续性不会被视为完成,直到其前驱完成,这意味着最终的连续性只有在第一个任务完成后才会运行。

还有另一种控制任务执行方式的机制:您可以指定调度程序。

调度程序

所有基于线程的任务都由TaskScheduler执行。默认情况下,您将得到 TPL 提供的通过线程池运行工作项的调度程序。然而,还有其他类型的调度程序,甚至可以自己编写。

选择非默认调度程序最常见的原因是处理线程关联性要求。TaskScheduler类的静态FromCurrentSynchroniza⁠tion​Context方法基于调用该方法的当前同步上下文返回调度程序。该调度程序将通过该同步上下文执行所有工作。因此,如果您从 UI 线程调用FromCurrentSynchronizationContext,则生成的调度程序可用于运行可以安全更新 UI 的任务。通常,您会在后续操作中使用此功能——可以运行一些基于任务的异步工作,然后连接一个后续操作,在完成该工作时更新 UI。示例 16-19 展示了在 WPF 应用程序窗口的代码后台文件中使用此技术。

示例 16-19. 在 UI 线程上安排后续操作
public partial class MainWindow : Window
{
    public MainWindow()
    {
        InitializeComponent();
    }

    private static readonly HttpClient w = new();
    `private` `readonly` `TaskScheduler` `_uiScheduler` `=`
        `TaskScheduler``.``FromCurrentSynchronizationContext``(``)``;`

    private void FetchButtonClicked(object sender, RoutedEventArgs e)
    {
        string url = "https://endjin.com/";
        Task<string> webGetTask = w.GetStringAsync(url);

        webGetTask.ContinueWith(t =>
        {
            string webContent = t.Result;
            outputTextBox.Text = webContent;
        },
        `_uiScheduler``)``;`
    }
}

这使用字段初始化程序获取调度程序——UI 元素的构造函数在 UI 线程上运行,因此这将获取一个用于 UI 线程同步上下文的调度程序。然后,单击处理程序使用HttpClient类的GetStringAsync下载网页。这将异步运行,因此不会阻塞 UI 线程,这意味着在下载进行时应用程序仍然响应。该方法设置了一个使用ContinueWith的重载形式来设置任务的后续操作。这确保了当获取内容的任务完成时,传递给ContinueWith的 lambda 表达式在 UI 线程上运行,因此可以安全地访问 UI 元素。

提示

虽然这样做完全有效,但在下一章中描述的await关键字提供了这个特定问题的更简单的解决方案。

运行时库提供了三种内置调度程序。有一个使用线程池的默认调度程序,还有一个使用同步上下文的调度程序,正如我刚才展示的那样。第三个由名为ConcurrentExclusiveSchedulerPair的类提供,并且正如其名称所示,它提供了两个调度程序,通过属性可用。ConcurrentScheduler属性返回一个类似于默认调度程序的并发运行任务的调度程序。ExclusiveScheduler属性返回一个用于逐个运行任务的调度程序,并在这样做时暂时挂起另一个调度程序(这让我想起了本章前面描述的读者/写者同步语义——它允许在需要时排他性,但其余时间并发运行)。

错误处理

Task对象在其工作失败时通过进入Faulted状态来指示。失败时总会至少有一个异常与之关联,但 TPL 允许复合任务——包含多个子任务的任务。这使得可能发生多个失败,并且根任务将报告它们所有。Task定义了一个Exception属性,其类型为AggregateException。你可能还记得第八章中提到的,除了从基类Exception类型继承的InnerException属性外,AggregateException还定义了一个InnerExceptions属性,返回一个异常集合。在这里你将找到导致任务失败的所有异常的完整集合。(如果任务不是复合任务,则通常只会有一个。)

如果尝试获取Result属性或在故障任务上调用Wait,它将抛出与从Exception属性返回的相同的AggregateException。故障任务会记住你是否使用了这些成员中的至少一个,如果你尚未这样做,它将考虑异常为未观察到。TPL 使用终结来跟踪具有未观察异常的故障任务,如果允许这样的任务变得不可达,TaskScheduler将引发其静态的UnobservedTaskException事件。这给了你最后一次机会来处理异常,之后它将丢失。

自定义无线程任务

许多基于 I/O 的 API 返回无线程任务。如果你希望,你也可以这样做。TaskCompletionSource<T>类提供了一种创建Task<T>的方式,它不具有在线程池上运行的相关方法,而是在你告诉它完成时完成。没有非泛型的TaskCompletionSource,但也不需要。Task<T>派生自Task,因此你可以随意选择任何类型参数。按照惯例,大多数开发人员在不需要提供返回值时使用TaskCompletionSource<object?>

假设你正在使用一个不提供基于任务的 API 的类,并且希望添加一个基于任务的包装器。我在示例 16-12 中使用的SmtpClient类支持旧的基于事件的异步模式,但不支持基于任务的模式。示例 16-20 使用该 API 与TaskCompletionSource<object?>结合提供了一个基于任务的包装器。(是的,在那里有Canceled/Cancelled的两种拼写。TPL 一致使用Canceled,但旧 API 展示了更多的变化。)

示例 16-20. 使用TaskCompletionSource<T>
public static class SmtpAsyncExtensions
{
    public static Task SendTaskAsync(this SmtpClient mailClient, string from,
                                string recipients, string subject, string body)
    {
        var tcs = new TaskCompletionSource<object?>();

        void CompletionHandler(object s, AsyncCompletedEventArgs e)
        {
            // Check this is the notification for our SendAsync.
            if (!object.ReferenceEquals(e.UserState, tcs)) { return; }
            mailClient.SendCompleted -= CompletionHandler;
            if (e.Canceled)
            {
                tcs.SetCanceled();
            }
            else if (e.Error != null)
            {
                tcs.SetException(e.Error);
            }
            else
            {
                tcs.SetResult(null);
            }
        };

        mailClient.SendCompleted += CompletionHandler;
        mailClient.SendAsync(from, recipients, subject, body, tcs);

        return tcs.Task;
    }
}

SmtpClient通过引发事件来通知我们操作已完成。此事件的处理程序首先检查事件是否对应于我们对SendAsync的调用,而不是可能已经在进行的其他操作。然后,它会分离自身(以防止在后续使用相同SmtpClient进行工作时再次运行)。接着,它检测操作是成功、取消还是失败,并在TaskCompletionSource<object>上分别调用SetResultSetCanceledSetException方法。这将导致任务转换为相应状态,并负责运行任何附加到该任务的后续操作。完成源通过其Task属性使其创建的无关线程Task对象可用,并且此方法返回该对象。

父/子关系

如果基于线程的任务方法创建一个新的基于线程的任务,默认情况下,这些任务之间没有特定的关系。然而,TaskCreationOptions标志之一是AttachedToParent,如果设置了这个标志,新创建的任务将作为当前执行任务的子任务。这意味着父任务直到所有子任务完成后才报告完成(当然,其自身的方法也需要完成)。如果任何子任务出现故障,父任务也将失败,并且将所有子任务的异常包含在自己的AggregateException中。

您还可以为继续任务指定AttachedToParent标志。请注意,这并不使其成为其先前任务的子任务。它将成为在调用ContinueWith创建继续任务时正在运行的任何任务的子任务。

注意

线程无关任务(例如,大多数代表 I/O 的任务)通常不能作为其他任务的子任务。如果通过TaskCompletionSource<T>自行创建一个,那么可以做到,因为该类有一个构造函数重载接受TaskCreationOptions。然而,大多数 .NET API 返回的任务没有提供请求将任务设为子任务的方法。

父/子关系并不是创建基于多个其他项目结果的任务的唯一方式。

复合任务

Task类具有静态的WhenAllWhenAny方法。每个方法都有重载,接受任务集合或Task<T>对象集合作为唯一参数。WhenAll方法返回一个TaskTask<T[]>,仅当提供的所有任务完成时才完成(在后一种情况下,复合任务生成包含每个单独任务结果的数组)。WhenAny方法返回一个Task<Task>Task<Task<T>>,只要第一个任务完成就完成,并将该任务作为结果返回。

与父任务一样,如果WhenAll生成的任务中的任何任务失败,那么所有失败任务的异常将在组合任务的AggregateException中可用。(WhenAny不报告错误。它在第一个任务完成时就完成了,您必须检查它以发现是否失败。)

您可以将继续任务附加到这些任务上,但还有一个稍微更直接的路线。而不是使用WhenAllWhenAny创建复合任务,然后在结果上调用ContinueWith,您可以直接调用任务工厂的ContinueWhenAllContinue​WhenAny方法。同样,这些方法接受一个TaskTask<T>的集合,但它们还接受一个要作为继续调用的方法。

其他异步模式

尽管 TPL 提供了公开异步 API 的首选机制,但在其添加之前,.NET 已经存在了将近十年,因此您可能会遇到较旧的方法。最长建立的形式是异步编程模型(APM)。这是在.NET 1.0 中引入的,因此广泛实现,但现在不鼓励使用。按照这种模式,方法成对出现:一个用于启动工作,另一个用于在完成时收集结果。示例 16-21 展示了System.IO命名空间中Stream类中的这样一对方法,同时显示了相应的同步方法。(今天编写的代码应该使用基于任务的WriteAsync。)

示例 16-21. APM 对及其相应的同步方法
public virtual IAsyncResult BeginWrite(byte[] buffer, int offset, int count,
    AsyncCallback callback, object state)...
public virtual void EndWrite(IAsyncResult asyncResult)...

public abstract void Write(byte[] buffer, int offset, int count)...

注意,BeginWrite方法的前三个参数与Write方法的参数相同。在 APM 中,Begin*Xxx*方法接受所有输入(即任何普通参数和任何ref参数,但不是out参数,如果有的话)。End*Xxx*方法提供任何输出,这意味着返回值,任何ref参数(因为这些可以传递信息进入或退出),以及任何out参数。

Begin*Xxx*方法还接受两个额外的参数:类型为AsyncCallback的委托,当操作完成时将调用它,以及类型为object的参数,接受您希望与操作关联的任何对象(或者如果您不需要则为null)。此方法还返回一个IAsync​Result,表示异步操作。

当调用完成回调时,您可以调用End*Xxx*方法,传入与Begin*Xxx*方法返回的相同的IAsyncResult对象,这将提供返回值(如果有的话)。如果操作失败,End*Xxx*方法将引发异常。

你可以用一个Task封装使用 APM 的 API。TaskTask<T>提供的TaskFactory对象提供了FromAsync方法,你可以向其传递一对委托,用于Begin*Xxx*End*Xxx*方法,并传递Begin*Xxx*方法需要的任何参数。这将返回代表操作的TaskTask<T>

另一个常见的旧模式是事件驱动的异步模式(EAP)。本章中你已经见过一个示例——SmtpClient使用了这种模式。使用此模式,一个类提供启动操作的方法和操作完成时引发的相应事件。方法和事件通常具有相关的名称,如SendAsyncSendCompleted。此模式的一个重要特点是方法捕获同步上下文并使用它来引发事件,这意味着如果在 UI 代码中使用支持此模式的对象,它有效地呈现了单线程异步模型。这使得它比 APM 更容易使用,因为在异步工作完成时,你无需编写额外的代码以返回到 UI 线程。

没有自动化机制可以将 EAP 包装在任务中,但如我在示例 16-20 中所示,这并不特别难。

异步代码中还有一种常见模式:由 C#异步语言特性(asyncawait关键字)支持的可等待模式。如我在示例 16-16 中展示的,你可以直接使用这些特性消耗 TPL 任务,但语言不会直接识别Task,而且可以等待的东西不限于任务。你可以用await关键字与实现特定模式的任何东西一起使用。我将在第十七章中展示这一点。

取消

.NET 定义了一种用于取消慢操作的标准机制。可取消操作接受类型为CancellationToken的参数,如果将其设置为取消状态,则操作将尽早停止而不是运行到完成。

CancellationToken类型本身不提供任何方法来启动取消操作——API 设计为你可以告诉操作何时取消,而不给予它们取消与同一CancellationToken相关联的其他操作的权力。取消操作通过单独的对象CancellationTokenSource管理。顾名思义,你可以使用它来获取任意数量的CancellationToken实例。如果调用CancellationTokenSource对象的Cancel方法,它将设置所有相关联的CancellationToken实例为取消状态。

一些我之前描述过的同步机制可以接收CancellationToken。(从WaitHandle派生的那些机制不能,因为底层的 Windows 原语不支持.NET 的取消模型。Monitor也不支持取消,但许多较新的 API 支持。)任务型 API 通常也会接收取消标记,而 TPL 本身也提供了带有取消标记的StartNewContinueWith方法的重载版本。如果任务已经开始运行,TPL 无法取消它,但如果在任务开始运行之前取消任务,TPL 会将其从预定任务队列中移除。如果希望在任务开始运行后能取消任务,就需要在任务体内编写代码来检查CancellationToken,并在其IsCancellationRequested属性为true时放弃工作。

取消支持并不普遍,因为并非总是可能取消一些操作。例如,一旦消息已经通过网络发送出去,就无法取消发送。一些操作允许在达到某个不可逆转的点之前取消工作。(例如,如果消息已排队等待发送但实际上尚未发送,则可能取消还为时不晚。)这意味着即使提供了取消功能,它也可能不起作用。因此,在使用取消功能时,需要做好它可能无法正常工作的准备。

并行性

运行时库包括一些类,可以在多个线程上并发地处理数据集合。有三种方法可以做到这一点:Parallel类、并行 LINQ 和 TPL 数据流。

并行类

Parallel类提供了四个静态方法:ForForEachForEachAsyncInvoke。最后一个方法接收一个委托数组并执行它们所有,可能并行执行。(它决定是否使用并行取决于各种因素,如计算机的硬件线程数量、系统的负载情况以及要处理的项数。)ForForEach方法模仿了同名的 C#循环结构,但它们也可能并行执行迭代。ForEachAsync是.NET 6.0 中新增的,也模仿了foreach,但提供了更好的异步操作支持,包括能够与IAsyncEnumerable<T>(如await foreach)一起工作或让每个迭代执行异步操作(相当于在foreach循环体中使用await)。

示例 16-22 展示了在执行两组样本卷积的代码中使用Parallel.For。这是一种在信号处理中常用的高度重复的操作。(实际上,快速傅里叶变换提供了更有效的执行方式,除非卷积核很小,但那段代码的复杂性将会掩盖这里的主要主题,即Parallel类。)它为每个输入样本产生一个输出样本。每个输出样本是通过计算两个输入的一系列值对的乘积之和来产生的。对于大数据集,这可能会很耗时,因此这是您可能希望通过在多处理器上分布执行来加速的工作类型。每个单独的输出样本值都可以独立计算,因此它是并行化的一个很好的候选对象。

示例 16-22。并行卷积
static float[] ParallelConvolution(float[] input, float[] kernel)
{
    float[] output = new float[input.Length];
    Parallel.For(0, input.Length, i =>
    {
        float total = 0;
        for (int k = 0; k < Math.Min(kernel.Length, i + 1); ++k)
        {
            total += input[i - k] * kernel[k];
        }
        output[i] = total;
    });

    return output;
}

这段代码的基本结构与一对嵌套的for循环非常相似。我只是用Parallel.For替换了外层的for循环。(我没有尝试并行化内部循环 - 如果每个单独的步骤都很简单,Parallel.For将会在执行代码之外花费更多时间来处理内务工作。)

第一个参数0设置了循环计数器的初始值,第二个参数设置了上限。最后一个参数是一个委托,将为循环计数器的每个值调用一次,并且如果Parallel类的启发式算法告诉它这可能会产生加速效果,则调用将同时发生。在多核机器上使用大数据集运行此方法将导致所有可用的硬件线程充分利用。

可能通过更友好的方式将工作分区以获得更好的性能 - 幼稚的并行化可能会给人以高性能的印象,因为它可以利用所有 CPU 核心,但交付的吞吐量却不够优化。然而,在复杂性和性能之间存在一种权衡,而Parallel类的简单性通常可以在相对较少的工作量下提供可观的收益。

并行 LINQ

并行 LINQ 是一个与内存中的信息一起工作的 LINQ 提供程序,类似于 LINQ 到对象。System.Linq命名空间通过名为AsParallel的扩展方法为任何IEnumerable<T>(由ParallelEnumerable类定义)提供了这一功能。这将返回一个ParallelQuery<T>,支持通常的 LINQ 操作符。

以这种方式构建的任何 LINQ 查询都提供了一个ForAll方法,该方法接受一个委托。当您调用此方法时,它会为查询生成的所有项目并行调用委托,在可能的情况下使用多个线程。

TPL 数据流

TPL Dataflow 是一个运行时库特性,允许您构建一个对象图,这些对象在信息流经它们时执行某种处理。您可以告诉 TPL 哪些节点需要按顺序处理信息,哪些可以同时处理多个数据块。您将数据推入图中,TPL 将管理每个节点处理块的过程,并尝试优化并行级别以匹配计算机上可用的资源。

数据流 API 位于System.Threading.Tasks.Dataflow命名空间中(它内置于 .NET Core 和 .NET 中;在 .NET Framework 中,您需要添加对 NuGet 包的引用,也称为System.Threading.Tasks.Dataflow)。它非常庞大和复杂,可以单独占据一整章。不幸的是,这超出了本书的范围。我提到它是因为对于某些工作来说,了解它是值得的。

摘要

线程提供同时执行多段代码的能力。在具有多个 CPU 执行单元(即多个硬件线程)的计算机上,您可以通过使用多个软件线程利用这种并行潜力。您可以使用Thread类显式创建新的软件线程,或者您可以使用线程池或并行化机制(如Parallel类或 Parallel LINQ)自动确定要使用多少线程来运行应用程序提供的工作。如果多个线程需要使用和修改共享数据结构,则需要使用 .NET 提供的同步机制来确保线程可以正确协调它们的工作。

线程也可以提供一种执行多个并发操作的方式,这些操作不需要整个时间都占用 CPU(例如,等待外部服务的响应),但通常使用异步 API(如果可用的话)执行这类工作更为高效。任务并行库(TPL)提供了适用于这两种并发方式的抽象。它可以管理线程池中的多个工作项,支持组合多个操作和处理可能复杂的错误场景,其Task抽象也可以表示固有的异步操作。下一章将介绍 C# 语言特性,大大简化了与任务的工作。

¹ 在此处广泛使用“状态”一词。我只是指存储在变量和对象中的信息。

² 在撰写本文时,文档并未为HashSet<T>SortedSet<T>提供只读线程安全性保证。尽管如此,微软已经向我保证这些结构也支持并发读取。

³ 在只有一个硬件线程的机器上,当SpinLock进入其循环时,它告诉操作系统调度程序它希望让出 CPU 的控制权,以便其他线程(希望包括当前持有锁的线程)可以取得进展。即使在多核系统上,SpinLock有时也会这样做,以避免过多的自旋可能导致的一些微妙问题。

第十七章:异步语言特性

C# 提供了语言级别的支持来使用和实现异步方法。使用异步 API 通常是使用某些服务的最有效方式。例如,大多数 I/O 在操作系统内核中是异步处理的,因为大多数外设(如磁盘控制器或网络适配器)能够自主完成大部分工作。它们只需要 CPU 在每个操作的开始和结束时介入。

尽管操作系统提供的许多服务本质上是异步的,开发者通常选择通过同步 API 使用它们(即在工作完成前不返回)。这可能会浪费资源,因为它们会阻塞线程直到 I/O 完成。线程会带来额外开销,如果你的目标是在高并发应用程序(例如为大量用户提供服务的 Web 应用)中获得最佳性能,通常最好只使用相对较少的 OS 线程。理想情况下,你的应用程序 OS 线程数量不应该超过硬件线程数量,但只有在确保线程只在没有未完成工作时阻塞时才是最佳的。《第十六章》描述了操作系统线程和硬件线程之间的区别。在性能敏感的代码中,异步 API 很有用,因为它们不会浪费资源,不会强制线程等待 I/O 完成,而是可以在此期间启动其他有用的工作。

异步 API 的问题在于,它们使用起来可能比同步 API 复杂得多,特别是如果你需要协调多个相关操作并处理错误的话。这也是为什么在主流编程语言提供内置支持之前,开发者通常选择效率较低的同步替代方案的原因。2012 年,C# 和 Visual Basic 将这些特性从研究实验室带到了实际应用中,此后许多其他流行的语言也添加了类似的特性(尤其是 JavaScript,在 2016 年也采用了非常相似的语法)。C# 中的异步特性使得我们可以编写使用高效异步 API 的代码,同时保留了使用简单同步 API 时的大部分简洁性。

这些语言特性在一些场景中也非常有用,其中最大化吞吐量并非主要性能目标。使用客户端代码时,避免阻塞 UI 线程以保持响应性是很重要的,而异步 API 提供了一种实现方式。语言对异步代码的支持可以处理线程亲和性问题,大大简化了编写高度响应式 UI 代码的工作。

异步关键字:async 和 await

C# 通过两个关键字来支持异步代码:asyncawait。前者不能单独使用。你需要将 async 关键字放在方法的声明中,这告诉编译器你打算在方法中使用异步特性。如果没有这个关键字,你就不能使用 await 关键字。

这可能会显得多余——如果试图在不使用 async 的情况下使用 await,编译器会产生错误。它知道方法体何时尝试使用异步特性,为什么我们还需要显式告诉它呢?有两个原因。首先,正如你将看到的,这些特性显著改变了编译器生成的代码行为,因此对于阅读代码的人来说,清楚地指示方法异步行为是有用的。其次,await 在 C# 中并不总是关键字,因此开发人员曾可以自由将其用作标识符。或许微软可以设计 await 的语法,使其仅在非常特定的上下文中充当关键字,从而使你能够继续在所有其他情况下将其用作标识符,但是 C# 团队决定采取稍微粗粒度的方法:你不能在 async 方法内部将 await 用作标识符,但在其他任何地方它都是有效的标识符。

注意

async 关键字不会改变方法的签名。它决定了方法如何编译,而不是如何使用。

程序的入口点是一个有趣的情况。通常,Main 方法要么返回 void,要么返回 int,但你也可以返回 TaskTask<int>。.NET 运行时不支持异步入口点,因此如果你使用这些任务返回类型之一,C# 编译器会生成一个隐藏方法作为真正的入口点,该方法调用你的异步 Main 方法,然后阻塞直到返回的任务完成。这使得可以将 C# 程序的 Main 方法设为 async(即使在使用这些返回类型时,编译器也会生成包装器,即使你没有将方法设为 async)。如果你使用 C# 10.0 的顶级语句来避免显式声明 Main,那么就没有地方放置 async 关键字或返回类型,因此这是唯一一种情况,编译器根据你是否使用 await 推断方法是否异步。它基于程序入口点的返回类型来确定你是否返回任何内容。

因此,async关键字只是声明你打算使用await关键字。尽管不能在不使用async的情况下使用await,但将async关键字应用于不使用await的方法不会报错。然而,这样做没有任何意义,所以如果你这样做,编译器会生成警告。示例 17-1 显示了一个相当典型的例子。它使用HttpClient类仅请求特定资源的头部(使用 HTTP 协议为此目的定义的标准HEAD动词)。然后将结果显示在 UI 控件中——这种方法是 UI 的代码后端的一部分,其中包括一个名为headerListTextBoxTextBox

示例 17-1. 使用asyncawait来获取 HTTP 头
// Note: as you'll see later, async methods usually should not be void private async void FetchAndShowHeaders(string url, IHttpClientFactory cf)
{
    using (HttpClient w = cf.CreateClient())
    {
        var req = new HttpRequestMessage(HttpMethod.Head, url);
        HttpResponseMessage response =
            `await` `w``.``SendAsync``(``req``,` `HttpCompletionOption``.``ResponseHeadersRead``)``;`

        headerListTextBox.Text = response.Headers.ToString();
    }
}

此代码包含一个粗体显示的单个await表达式。你可以在可能需要一些时间来产生结果的表达式中使用await关键字,它表示在该操作完成之前,方法的其余部分不应执行。这听起来很像阻塞同步 API 所做的事情,但不同之处在于await表达式不会阻塞线程——这段代码并不完全是看上去的样子。

HttpClient类的SendAsync方法返回一个Task<HttpResponseMessage>,你可能会想为什么我们不直接使用其Result属性。正如你在第十六章中看到的,如果任务未完成,此属性将阻塞线程,直到结果可用(或任务失败,这种情况下它会抛出异常)。然而,在 UI 应用程序中这样做是很危险的:如果你尝试读取不完整任务的Result来阻塞 UI 线程,那么将阻止任何需要在该线程上运行的操作的进展。由于 UI 应用程序需要在 UI 线程上执行大量工作,以这种方式阻塞该线程几乎可以保证迟早会导致死锁,从而导致应用程序冻结。所以不要这样做!

尽管示例 17-1 中的await表达式在逻辑上类似于读取Result,但其工作方式截然不同。如果任务的结果不立即可用,await关键字并不会使线程等待,尽管其名称暗示了这一点。相反,它会导致包含的方法立即返回。你可以使用调试器来验证FetchAndShowHeaders立即返回。例如,如果我从示例 17-2 中显示的按钮点击事件处理程序中调用该方法,我可以在该处理程序中的Debug.WriteLine调用上设置断点,并在示例 17-1 中更新headerListTextBox.Text属性的代码处设置另一个断点。

示例 17-2. 调用异步方法
private void fetchHeadersButton_Click(object sender, RoutedEventArgs e)
{
    FetchAndShowHeaders("https://endjin.com/", this.clientFactory);
    Debug.WriteLine("Method returned");
}

在调试器中运行时,我发现代码在示例 17-2 的最后语句上的断点命中,然后才命中示例 17-1 最终语句上的断点。换句话说,示例 17-1 中跟随await表达式的部分在方法已返回给其调用者后运行。显然,编译器以某种方式安排方法的剩余部分通过回调运行,一旦异步操作完成。

注意

Visual Studio 的调试器在调试异步方法时会进行一些技巧,使您能够像调试普通方法一样逐步执行它们。这通常很有帮助,但有时会掩盖执行的真实本质。我刚描述的调试步骤是人为设计的,旨在打败 Visual Studio 的聪明尝试,而是揭示实际发生的情况。

注意,示例 17-1 中的代码期望在 UI 线程上运行,因为它朝着结束修改了文本框的Text属性。异步 API 不一定保证在您启动工作的同一线程上通知您完成,事实上,大多数情况下都不会。尽管如此,示例 17-1 按预期工作,因此除了将方法的一半转换为回调外,await关键字还为我们处理了线程关联性问题。

显然,每次使用await关键字时,C#编译器都会对您的代码进行一些重大的修改。在较早的 C#版本中,如果您想使用此异步 API 然后更新 UI,您需要编写类似于示例 17-3 的内容。这使用了我在第十六章中展示的技术:它为SendAsync返回的任务设置了一个继续项,使用TaskScheduler确保继续项的主体在 UI 线程上运行。

示例 17-3. 手动异步编码
private void OldSchoolFetchHeaders(string url, IHttpClientFactory cf)
{
    HttpClient w = cf.CreateClient();
    var req = new HttpRequestMessage(HttpMethod.Head, url);

    var uiScheduler = TaskScheduler.FromCurrentSynchronizationContext();
    w.SendAsync(req, HttpCompletionOption.ResponseHeadersRead)
        .ContinueWith(sendTask =>
        {
            try
            {
                HttpResponseMessage response = sendTask.Result;
                headerListTextBox.Text = response.Headers.ToString();
            }
            finally
            {
                w.Dispose();
            }
        },
        uiScheduler);
}

这是直接使用 TPL 的一个合理方式,并且与示例 17-1 有类似的效果,尽管它不完全代表 C#编译器如何转换代码。正如我稍后将展示的,await使用的模式是由TaskTask<T>支持的,但不是必需的。它还生成处理早期完成(即任务在您准备等待它之前已经完成)的代码,比示例 17-3 要高效得多。但在展示编译器的具体操作之前,我想说明它为您解决的一些问题,最好的方法是展示在此语言功能出现之前可能编写的代码类型。

我当前的示例相当简单,因为它仅涉及一个异步操作,但除了我已经讨论过的两个步骤——设置某种完成回调并确保它在正确的线程上运行之外——我还不得不处理位于示例 17-1 中的using语句。示例 17-3 不能使用using关键字,因为我们希望在完成后才处理HttpClient对象的释放。¹ 在外部方法返回之前不久调用Dispose是行不通的,因为我们需要在继续运行时使用该对象,而这通常会晚一些。因此,我需要在一个方法(外部方法)中创建对象,然后在另一个方法(嵌套方法)中处理释放。而且因为我手动调用Dispose,所以现在需要处理异常,因此我不得不用try块包装我移入回调的所有代码,并在finally块中调用Dispose。(事实上,我甚至没有做完整的工作——如果HttpRequestMessage构造函数或检索任务调度程序的调用抛出异常,HttpClient将不会被处理。我只处理了 HTTP 操作本身失败的情况。)

示例 17-3 已经使用了任务调度程序来安排继续通过启动时的SynchronizationContext运行。这确保了回调在正确的线程上发生以更新 UI。await关键字可以替我们处理这些。

执行和同步上下文

当程序的执行达到一个await表达式时,如果这个操作不会立即完成,为该await生成的代码将确保当前的执行上下文已被捕获。(如果这不是在该方法中第一个阻塞的await,并且如果自上次捕获以来上下文未更改,则已经捕获。)当异步操作完成时,方法的剩余部分将通过执行上下文执行。²

正如我在第十六章中描述的那样,执行上下文处理某些需要在一个方法调用另一个方法时流动的上下文信息(即使间接调用也是如此)。但在写 UI 代码时,还有另一种上下文可能会引起我们的兴趣:同步上下文(也在第十六章中有描述)。

尽管所有的 await 表达式都会捕获执行上下文,但是否流动同步上下文取决于被等待的类型。如果你等待一个 Task,同步上下文默认也会被捕获。任务并不是你可以 await 的唯一东西,我将在 “等待模式” 小节中描述类型如何支持 await

有时候,你可能希望避免涉及同步上下文。如果你希望从 UI 线程开始执行异步工作,但又没有特别需要保持在该线程上,通过同步上下文调度每一个继续操作就是不必要的开销。如果异步操作是一个 TaskTask<T>(或者等效的值类型,如 ValueTaskValueTask<T>),你可以通过调用 ConfigureAwait 方法并传递 false 来声明不需要这样做。这会返回异步操作的另一种表示,如果你 await 这个新的表示而不是原始任务,它将忽略当前的 Syn⁠chr⁠oni⁠zat⁠ion​Con⁠text(如果有的话)。(没有相应的机制来选择退出执行上下文。)示例 17-4 展示了如何使用它。

示例 17-4. ConfigureAwait
private async void OnFetchButtonClick(object sender, RoutedEventArgs e)
{
    using (HttpClient w = this.clientFactory.CreateClient())
    using (Stream f = File.Create(fileTextBox.Text))
    {
        Task<Stream> getStreamTask = w.GetStreamAsync(urlTextBox.Text);
        `Stream` `getStream` `=` `await` `getStreamTask``.``ConfigureAwait``(``false``)``;`

        Task copyTask = getStream.CopyToAsync(f);
        `await` `copyTask``.``ConfigureAwait``(``false``)``;`
    }
}

这段代码是按钮的点击处理程序,因此最初在 UI 线程上运行。它从两个文本框中检索 Text 属性。然后启动一些异步工作——获取 URL 的内容并将数据复制到文件中。在获取这两个 Text 属性之后,它不再使用任何 UI 元素,因此在每个 await 之后不必要地涉及 UI 线程无关紧要。通过向 ConfigureAwait 传递 false 并等待它返回的值,我们告诉 TPL 可以使用任何方便的线程来通知我们完成,这在本例中很可能是线程池线程。这将使工作完成更有效率和更快速,因为它避免了在每个 await 之后不必要地涉及 UI 线程。

并非所有的异步 API 都返回 TaskTask<T>。例如,作为 UWP 的一部分引入到 Windows 的各种异步 API 返回的是 IAsyncOperation<T> 而不是 Task<T>。这是因为 UWP 不是特定于 .NET 的,它有自己独立于运行时的异步操作表示,可以从 C++ 和 JavaScript 中使用。这个接口在概念上类似于 TPL 任务,并支持 await 模式,这意味着你可以在这些 API 上使用 await。然而,它不提供 Con⁠fig⁠ure​Awa⁠it。如果你想对其中一个这些 API 做类似于 示例 17-4 的事情,可以使用 AsTask 扩展方法,将 IAsyncOperation<T> 包装为 Task<T>,然后在该任务上调用 ConfigureAwait

提示

如果你正在编写库,那么在使用await的任何地方都应该调用ConfigureAwait(false)。这是因为通过同步上下文继续可能会很昂贵,并且在某些情况下可能会引入死锁的可能性。唯一的例外是当你做一些积极需要保留同步上下文的操作,或者你确信你的库只会在不设置同步上下文的应用程序框架中使用时。 (例如,ASP.NET Core 应用程序不使用同步上下文,因此通常无论是否在这些应用程序中调用ConfigureAwait(false)都无关紧要。)

示例 17-1 中仅包含一个await表达式,即使这个表达式在经典的 TPL 编程中也相当复杂。示例 17-4 包含两个await表达式,如果没有await关键字的帮助,要实现相同的行为就需要更多的代码,因为异常可能会在第一个await之前、第二个await之后或两者之间发生,我们需要在这些情况下调用HttpClientStreamDispose方法(以及在没有抛出异常的情况下)。然而,一旦涉及流控制,事情可能会变得更加复杂。

多个操作和循环

假设我想处理响应体中的数据,而不是仅仅获取标头或将 HTTP 响应体复制到文件中。如果响应体很大,检索它可能是一个需要多个缓慢步骤的操作。示例 17-5 逐步获取一个网页。

示例 17-5. 多个异步操作
private async void FetchAndShowBody(string url, IHttpClientFactory cf)
{
    using (HttpClient w = cf.CreateClient())
    {
        `Stream` `body` `=` `await` `w``.``GetStreamAsync``(``url``)``;`
        using (var bodyTextReader = new StreamReader(body))
        {
            while (!bodyTextReader.EndOfStream)
            {
                `string?` `line` `=` `await` `bodyTextReader``.``ReadLineAsync``(``)``;`
                bodyTextBox.AppendText(line);
                bodyTextBox.AppendText(Environment.NewLine);
                `await` `Task``.``Delay``(``TimeSpan``.``FromMilliseconds``(``10``)``)``;`
            }
        }
    }
}

现在这段代码包含了三个await表达式。第一个发起了一个 HTTP GET 请求,该操作将在收到响应的第一部分时完成,但响应可能尚未完全接收——可能还有几兆字节的内容未到达。此代码假定内容将是文本,因此将返回的Stream对象封装在StreamReader中,该对象将字节流展示为文本[³]。然后它使用该封装对象的异步ReadLineAsync方法逐行读取响应中的文本。因为数据通常是分块到达的,读取第一行可能需要一些时间,但接下来几次调用该方法可能会立即完成,因为每个网络数据包通常包含多行。但是,如果代码读取速度快于网络数据到达速度,最终它将消耗完首个数据包中的所有行,然后在下一行可用之前将需要一些时间。因此,ReadLineAsync的调用将返回一些慢和一些立即完成的任务。第三个异步操作是对Task.Delay的调用。我加入了这个操作来减慢速度,以便逐步看到数据在 UI 中到达。Task.Delay返回一个在指定延迟后完成的Task,因此它提供了Thread.Sleep的异步等效方式(Thread.Sleep会阻塞调用线程,而await Task.Delay引入延迟但不会阻塞线程)。

注意

我已经将每个await表达式放在单独的语句中,但这不是必需的。写成(await t1) + (await t2)这种形式的表达式是完全合法的(如果你愿意,可以省略括号,因为await比加法运算符具有更高的优先级;但我个人喜欢在这里使用括号来提供视觉上的强调)。

我不打算展示 Example 17-5 的完整的非async等效版本,因为它将非常庞大,但我会描述一些问题。首先,我们有一个循环,其主体包含两个await块。要使用Task和回调构建等效的内容意味着需要构建自己的循环结构,因为循环的代码最终会分散在三个方法中:开始运行循环的那个方法(这将是作为GetStreamAsync的连续回调的嵌套方法)以及处理ReadLineAsyncTask.Delay完成的两个回调方法。可以通过创建一个在两个地方都调用的本地方法来解决这个问题:你希望开始循环的地方以及在Task.Delay的继续中再次启动下一个迭代。Example 17-6 展示了这种技术,但它只说明了我们希望编译器为我们完成的某些方面;它并非 Example 17-5 的完整替代方案。

示例 17-6. 一个不完整的手动异步循环
private void IncompleteOldSchoolFetchAndShowBody(
    string url, IHttpClientFactory cf)
{
    HttpClient w = cf.CreateClient();
    var uiScheduler = TaskScheduler.FromCurrentSynchronizationContext();
    w.GetStreamAsync(url).ContinueWith(getStreamTask =>
    {
        Stream body = getStreamTask.Result;
        var bodyTextReader = new StreamReader(body);

        StartNextIteration();

        void StartNextIteration()
        {
            if (!bodyTextReader.EndOfStream)
            {
                bodyTextReader.ReadLineAsync().ContinueWith(readLineTask =>
                    {
                        string? line = readLineTask.Result;

                        bodyTextBox.AppendText(line);
                        bodyTextBox.AppendText(Environment.NewLine);

                        Task.Delay(TimeSpan.FromMilliseconds(10))
                            .ContinueWith(
                                _ => StartNextIteration(), uiScheduler);
                    },
                uiScheduler);
            }
        };
    },
        uiScheduler);
}

这段代码勉强能够运行,但它甚至没有尝试释放它所使用的任何资源。存在多处可能出现故障的地方,因此我们不能只是简单地放置一个 using 块或 try/finally 对来清理事物。即使没有额外的复杂性,这段代码也几乎无法被识别出来 —— 它并不明显地表明这是试图执行与 示例 17-5 相同基本操作的代码。在实际应用中,也许完全采用不同的方法会更容易,比如编写一个实现状态机的类来跟踪工作进度。这可能会使得编写正确操作的代码更容易,但对于阅读你的代码的人来说,理解他们看到的内容实际上只是一个循环,这并不会变得更加容易。

众所周知,许多开发人员过去更喜欢同步 API。但是,C# 允许我们编写几乎与同步等效的异步代码结构,从而在不带来痛苦的情况下获得所有异步代码的性能和响应优势。这就是 asyncawait 的主要好处。

消费和生成异步序列

示例 17-5 展示了一个 while 循环,正如你所期望的那样,在 async 方法中你可以自由使用其他类型的循环,比如 forforeach。然而,foreach 可能会引入一个微妙的问题:如果你遍历的集合需要执行缓慢的操作会怎么样?对于像数组或 HashSet<T> 这样的集合类型,所有集合项都已经在内存中,这个问题并不会出现,但是对于 File.ReadLines 返回的 IEnumerable<string> 呢?显然,这是一个适合异步操作的明显候选,但在实践中,每次需要等待更多数据从存储中到达时,它却会阻塞您的线程。这是因为 foreach 期望的模式不支持异步操作。问题的核心在于 foreach 将调用移动到下一项的方法 —— 它期望枚举器(通常,但并非总是 IEnumerator<T> 的实现之一)提供一个类似于 示例 17-7 中所示的 MoveNext 方法的签名。

示例 17-7. 不友好的非异步 IEnumerator.MoveNext
bool MoveNext();

如果有更多的项即将到来但尚未可用,集合将别无选择,只能阻塞线程,直到数据到达为止。幸运的是,C#识别了这种模式的变体。运行时库定义了一对类型,⁴在示例 17-8(首次引入于第五章)中展示,体现了这种新模式。与同步的IEnumerable<T>一样,foreach并不严格要求这些确切的类型。任何提供相同签名成员的东西都可以工作。

示例 17-8. IAsyncEnumerable<T>IAsyncEnumerator<T>
public interface IAsyncEnumerable<out T>
{
    IAsyncEnumerator<T> GetAsyncEnumerator(
        CancellationToken cancellationToken = default);
}

public interface IAsyncEnumerator<out T> : IAsyncDisposable
{
    T Current { get; }

    ValueTask<bool> MoveNextAsync();
}

从概念上讲,这与同步模式完全相同:异步的foreach会向集合对象请求一个枚举器,并将重复要求其前进到下一项,每次执行循环体时使用Current返回的值,直到枚举器指示没有更多项为止。主要区别在于同步的MoveNext已被MoveNextAsync取代,后者返回一个可等待的ValueTask<T>。(IAsyncEnumerable<T>接口还支持传递取消令牌。异步的foreach不会直接使用,但可以通过IAsyncEnumerable<T>WithCancellation扩展方法间接使用。)

要消费实现此模式的可枚举源,您必须在foreach前面加上await关键字。C#还可以帮助您实现此模式:第五章展示了如何在迭代器方法中使用yield关键字来实现IEnumerable<T>,但也可以返回一个IAs⁠ync​Enu⁠mer⁠abl⁠e<T>。示例 17-9 展示了IAsyncEnumerable<T>的实现和消费示例。

示例 17-9. 消费和生成异步枚举
await foreach (string line in ReadLinesAsync(args[0]))
{
    Console.WriteLine(line);
}

static async IAsyncEnumerable<string> ReadLinesAsync(string path)
{
    using (var bodyTextReader = new StreamReader(path))
    {
        while (!bodyTextReader.EndOfStream)
        {
            string? line = await bodyTextReader.ReadLineAsync();
            if (line is not null) { yield return line; }
        }
    }
}

由于这种语言支持使得创建和使用IAsyncEnumerable<T>非常类似于使用IEnumerable<T>,您可能会想知道是否存在异步版本的描述在第十章中描述的各种 LINQ 运算符。与 LINQ to Objects 不同,IAsyncEnumerable<T>的实现不在构建到.NET 或.NET Standard 的运行库的部分中,但 Microsoft 提供了一个合适的 NuGet 包。如果您引用了System.Linq.Async包,通常的using System.Linq;声明将使得所有 LINQ 运算符在IAsyncEnumerable<T>表达式上可用。

当我们查看广泛实现的类型的异步等效时,我们应该看看IAsyncDisposable

异步处理

正如第七章所述,IDisposable接口由需要立即执行某种清理操作的类型实现,例如关闭打开的句柄,并且语言支持使用using语句。但是,如果清理涉及潜在的缓慢工作,例如刷新数据到磁盘?.NET Core 3.1、.NET 和.NET Standard 2.1 为这种情况提供了IAsyncDisposable接口。正如示例 17-10 所示,您可以在using语句前面放置await关键字以使用异步可释放资源。(您也可以在using声明前面放置await关键字。)

示例 17-10. 使用和实现IAsyncDisposable
await using (DiagnosticWriter w = new(@"c:\temp\log.txt"))
{
    await w.LogAsync("Test");
}

class DiagnosticWriter : IAsyncDisposable
{
    private StreamWriter? _sw;

    public DiagnosticWriter(string path)
    {
        _sw = new StreamWriter(path);
    }

    public Task LogAsync(string message)
    {
        if (_sw is null)
        { throw new ObjectDisposedException(nameof(DiagnosticWriter)); }
        return _sw.WriteLineAsync(message);
    }

    public async ValueTask DisposeAsync()
    {
        if (_sw != null)
        {
            await LogAsync("Done");
            await _sw.DisposeAsync();
            _sw = null;
        }
    }
}
注意

尽管await关键字出现在using语句前面,但它等待的潜在缓慢操作发生在执行离开using语句块时。这是不可避免的,因为using语句和声明有效地隐藏了对Dispose的调用。

示例 17-10 还展示了如何实现IAsyncDisposable接口。与同步的IDisposable接口定义了单一的Dispose方法不同,它的异步版本定义了一个返回ValueTaskDisposeAsync方法。这使我们能够在方法上标记为async。使用await using语句将确保DisposeAsync返回的任务在其块的末尾完成后才继续执行。您可能已经注意到,我们对async方法使用了几种不同的返回类型。迭代器在同步代码中也是特例,但是对于这些返回不同任务类型的方法,又有什么不同呢?

返回一个 Task

任何使用await的方法本身可能需要一定的运行时间,因此除了能够调用异步 API 之外,通常还希望呈现一个异步的公共界面。C#编译器允许用async关键字标记的方法返回表示异步工作进展的对象。您可以返回Task,也可以返回Task<T>,其中T是任何类型。这使调用者可以发现您的方法执行的工作状态,附加连续操作,以及如果使用Task<T>,获取结果的方法。或者,您可以返回值类型的等价物,ValueTaskValueTask<T>。返回任何这些类型意味着,如果您的方法从另一个async方法调用,它可以使用await等待您的方法完成,并在适用时收集其结果。

当使用 async 时,几乎总是比 void 返回类型更可取的是返回一个任务,因为对于 void 返回类型,调用者无法知道你的方法何时真正完成,或者发生异常时如何处理。(异步方法可以在返回后继续运行—事实上,这正是其全部意义—因此在抛出异常时,原始调用者可能已经不在堆栈上。)通过返回任务对象,你为编译器提供了一种使异常可用,并在适用时提供结果的方法。

返回任务非常简单,几乎没有理由不这样做。要修改示例 17-5 中的方法以返回任务,我只需要进行一个简单的更改。我将返回类型改为 Task 而不是 void,如示例 17-11 所示,其余代码完全可以保持不变。

示例 17-11. 返回 Task
private async Task FetchAndShowBody(string url, IHttpClientFactory cf)
// ...as before

编译器会自动生成所需代码来生成 Task 对象(或 ValueTask,如果你将其用作返回类型),并在方法返回或抛出异常时将其设置为已完成或故障状态。Task 类型的返回类型是异步版本的 void,因为当其完成时 Task 不产生任何结果(这就是为什么我们不需要在此方法中添加 return 语句,即使它现在的返回类型是 Task)。如果你想要从任务中返回结果,这也很容易。将返回类型设为 Task<T>ValueTask<T>,其中 T 是你的结果类型,然后你可以使用 return 关键字,就像你的方法是正常的非异步方法一样,如示例 17-12 所示。

示例 17-12. 返回 Task<T>
public static async Task<string?> GetServerHeader(
    string url, IHttpClientFactory cf)
{
    using (HttpClient w = cf.CreateClient())
    {
        var request = new HttpRequestMessage(HttpMethod.Head, url);
        HttpResponseMessage response = await w.SendAsync(
            request, HttpCompletionOption.ResponseHeadersRead);

        string? result = null;
        IEnumerable<string>? values;
        if (response.Headers.TryGetValues("Server", out values))
        {
            result = values.FirstOrDefault();
        }
        `return` `result``;`
    }
}

此方法异步获取 HTTP 头部,方式与示例 17-1 相同,但不显示结果,而是挑选第一个 Server: 头部的值,并将其作为此方法返回的 Task<string?> 的结果。(需要是可空字符串,因为可能不存在该头部。)正如你所见,return 语句只是返回一个 string?,即使方法的返回类型是 Task<string?>。编译器生成的代码完成任务,并安排该字符串成为结果。无论是 Task 还是 Task<T> 返回类型,生成的代码都生成类似于使用 TaskCompletionSource<T> 所得到的任务,如第十六章中所述。

注意

就像await关键字可以使用符合特定模式的任何异步方法一样(稍后描述),C#在实现异步方法时也提供了同样的灵活性。你不仅限于TaskTask<T>ValueTaskValueTask<T>。你可以返回任何符合两个条件的类型:它必须标注有AsyncMethodBuilder属性,标识编译器用于管理任务进度和完成的类,并且它还必须提供一个GetAwaiter方法,返回一个实现了ICriticalNotifyCompletion接口的类型。

返回内置任务类型几乎没有什么坏处。调用者不必对其做任何事情,因此你的方法使用起来与void方法一样简单,但增加了一个优势,即想要的调用者可以获得一个任务。唯一的返回void的理由可能是某些外部约束强制你的方法具有特定的签名。例如,大多数事件处理程序要求返回类型为void,这就是我之前一些示例这样做的原因。但除非你被迫使用它,否则不推荐将void作为异步方法的返回类型。

将异步应用于嵌套方法

到目前为止展示的示例中,我已经将async关键字应用于普通方法。你也可以将它用于匿名函数(匿名方法或 Lambda 表达式)和局部函数。例如,如果你正在编写一个以编程方式创建 UI 元素的程序,你可能会发现将写成 Lambda 的事件处理程序作为异步的会很方便,就像示例 17-13 那样。

示例 17-13. 一个异步 Lambda
okButton.Click += async (s, e) =>
{
    using (HttpClient w = this.clientFactory.CreateClient())
    {
        infoTextBlock.Text = await w.GetStringAsync(uriTextBox.Text);
    }
};
注意

这与异步委托调用无关,这是我在第九章中提到的现在已经弃用的使用线程池的技术,在匿名方法和 TPL 提供更好替代之前曾经流行过。

等待模式

大多数支持await关键字的异步 API 将返回某种 TPL 任务。然而,C#并不绝对要求这样做。它会await任何实现特定模式的东西。此外,虽然Task支持此模式,但它的工作方式使得编译器使用任务与直接使用 TPL 时略有不同——这也是我之前说过展示基于任务的异步等价于await基础代码并不完全代表编译器所做的原因之一。在本节中,我将展示编译器如何使用任务和支持await的其他类型,以更好地说明它的实际工作方式。

我将创建一个await模式的自定义实现来展示 C#编译器的期望。示例 17-14 展示了一个异步方法UseCustomAsync,它使用了这个自定义实现。它将await表达式的结果赋值给一个string,因此明显期待异步操作以string形式输出。它调用了一个方法CustomAsync,该方法返回我们模式的实现(稍后将在示例 17-15 中展示)。如您所见,这不是一个Task<string>

示例 17-14. 调用自定义等待实现
static async Task UseCustomAsync()
{
    string result = await CustomAsync();
    Console.WriteLine(result);
}

public static MyAwaitableType CustomAsync()
{
    return new MyAwaitableType();
}

编译器期望await关键字的操作数是提供名为GetAwaiter方法的类型。这可以是普通的实例成员或者扩展方法。(因此,可以通过定义合适的扩展方法使不本能支持await的类型也能够使用它。)这个方法必须返回一个对象或值,称为等待器,它需要执行三件事情。

首先,等待器必须提供一个名为IsCompletedbool属性。编译器生成的await代码使用它来判断操作是否已经完成。在不需要执行耗时操作的情况下(例如,在Stream上调用ReadAsync时可以立即使用流中已有的缓冲区数据),设置回调将是一种浪费。因此,如果IsCompleted属性返回trueawait将避免创建不必要的委托,直接继续执行方法的剩余部分。

编译器还需要一种在工作完成后获取结果的方式,因此等待器必须有一个GetResult方法。其返回类型定义了操作的结果类型——它将是await表达式的类型。(如果没有结果,返回类型是voidGetResult仍然需要存在,因为它负责在操作失败时抛出异常。)由于示例 17-14 将await的结果赋值给了类型为string的变量,因此MyAwaitableType类的GetAwaiter返回的等待器的GetResult方法必须是string类型(或者某种隐式转换为string的类型)。

最后,编译器需要能够提供回调。如果IsCompleted返回false,表示操作尚未完成,await表达式生成的代码将创建一个委托,该委托将运行方法的其余部分。它需要能够将该委托传递给等待器。(这类似于将委托传递给任务的ContinueWith方法。)为此,编译器不仅需要一个方法,还需要一个接口。您需要实现INotifyCompletion,并且建议您在可能的情况下也实现一个可选接口,称为ICriticalNotifyCompletion。这两者功能类似:每个定义一个接受单个Action委托的方法(分别是OnCompletedUnsafeOnCompleted),等待器在操作完成后必须调用此委托。这两个接口及其相应方法的区别在于,前者要求等待器将当前执行上下文流到目标方法,而后者则不需要。C#编译器用于构建异步方法的.NET 运行时库总是为您流动执行上下文,因此生成的代码通常在可能时调用UnsafeOnCompleted,以避免重复流动。 (如果编译器使用OnCompleted,等待器将再次流动上下文。)然而,在.NET Framework 上,您会发现安全约束可能会阻止使用UnsafeOnCompleted。(.NET Framework 有一个不受信任代码的概念。来自可能不可信来源的代码—例如从互联网下载的代码—将受到各种约束。这个概念在.NET Core 中被放弃,但各种遗留物仍然存在,例如这种异步操作的设计细节。)因为UnsafeOnCompleted不流动执行上下文,不受信任的代码不应允许调用它,因为这将提供一种绕过某些安全机制的方式。各种任务类型的.NET Framework 实现中提供的UnsafeOnCompleted标有SecurityCriticalAttribute,这意味着只有完全信任的代码才能调用它。我们需要OnCompleted,以便部分受信任的代码能够使用等待器。

示例 17-15 展示了等待器模式的最小可行实现。这个示例过于简化,因为它总是同步完成,所以它的OnCompleted方法什么也不做。如果你在My​Awa⁠ita⁠ble⁠Type的实例上使用await关键字,由 C#编译器生成的代码永远不会调用OnCompletedawait模式要求只有在IsCompleted返回false时才调用OnCompleted,而在这个例子中,IsCompleted始终返回true。这就是为什么我让OnCompleted抛出异常。然而,尽管这个例子过于简单,它将说明await的作用。

Example 17-15. 过于简单的 await 模式实现
public class MyAwaitableType
{
    public MinimalAwaiter GetAwaiter()
    {
        return new MinimalAwaiter();
    }

    public class MinimalAwaiter : INotifyCompletion
    {
        public bool IsCompleted => true;

        public string GetResult() => "This is a result";

        public void OnCompleted(Action continuation)
        {
            throw new NotImplementedException();
        }
    }
}

有了这段代码,我们可以看到 示例 17-14 将会做什么。它会在 CustomAsync 方法返回的 MyAwaitableType 实例上调用 Get​Awai⁠ter。然后它将测试 awaiter 的 IsCompleted 属性,如果为 true(在这种情况下确实是),将立即运行方法的其余部分。编译器不知道在这种情况下 IsCompleted 总是 true,因此它生成代码来处理 false 的情况。这将创建一个委托,当调用时,将运行方法的其余部分,并将该委托传递给 waiter 的 OnCompleted 方法。(我这里没有提供 UnsafeOnCompleted,所以它被强制使用 OnCompleted。)示例 17-16 展示了完成所有这些操作的代码。

Example 17-16. await 大致的粗略近似
static void ManualUseCustomAsync()
{
    var awaiter = CustomAsync().GetAwaiter();
    if (awaiter.IsCompleted)
    {
        TheRest(awaiter);
    }
    else
    {
        awaiter.OnCompleted(() => TheRest(awaiter));
    }
}

private static void TheRest(MyAwaitableType.MinimalAwaiter awaiter)
{
    string result = awaiter.GetResult();
    Console.WriteLine(result);
}

我将方法分成两部分,因为 C# 编译器避免在 IsCompletedtrue 的情况下创建委托,而我也想做同样的事情。然而,这并不完全是 C# 编译器所做的——它还设法避免为每个 await 语句创建额外的方法,但这意味着它必须创建更复杂的代码。事实上,对于仅包含单个 await 的方法,它引入的开销比 示例 17-16 要大得多。然而,一旦 await 表达式的数量开始增加,复杂性就会得到回报,因为编译器不需要添加任何进一步的方法。示例 17-17 展示了接近编译器所做的事情。

Example 17-17. 更接近 await 工作方式的稍微近似
private class ManualUseCustomAsyncState
{
    private int state;
    private MyAwaitableType.MinimalAwaiter? awaiter;

    public void MoveNext()
    {
        if (state == 0)
        {
            awaiter = CustomAsync().GetAwaiter();
            if (!awaiter.IsCompleted)
            {
                state = 1;
                awaiter.OnCompleted(MoveNext);
                return;
            }
        }
        string result = awaiter!.GetResult();
        Console.WriteLine(result);
    }
}

static void ManualUseCustomAsync()
{
    var s = new ManualUseCustomAsyncState();
    s.MoveNext();
}

这仍然比实际代码简单,但它展示了基本策略:编译器生成一个充当状态机的嵌套类型。它有一个字段 (state) 来跟踪方法到目前为止的位置,并且还包含与方法的局部变量对应的字段(在本例中仅有 awaiter 变量)。当异步操作不阻塞时(即其 IsCompleted 立即返回 true),方法可以继续到下一部分,但一旦遇到需要一些时间的操作,它会更新 state 变量以记住当前位置,然后使用相关的 awaiter 的 OnCompleted 方法。注意它请求在完成时调用的方法是已经运行的相同方法:MoveNext。无论需要执行多少次 await 阻塞,每次完成回调都会调用同一个方法;该类只需记住它已经进行到哪里,方法就会从那里继续。这样,即使 await 阻塞多少次,也永远不需要创建超过一个委托。

我不会展示真正生成的代码。它几乎无法阅读,因为包含了许多难以言说的标识符。(从第 3 章记得,当 C#编译器需要生成带有标识符的项目,这些标识符不能与或直接可见于我们的代码,它会创建一个在运行时被认为是合法的但在 C#中不合法的名称;这被称为难以言说的名称。)此外,编译器生成的代码使用了来自System.Runtime.CompilerServices命名空间的各种辅助类,这些类仅用于异步方法以管理诸如确定等待者支持哪些完成接口以及处理相关执行上下文流的事务。此外,如果方法返回一个任务,那么还有额外的辅助程序来创建和更新它。但是,当涉及到理解可等待类型与编译器为await表达式生成的代码之间关系的本质时,示例 17-17 提供了一个公正的印象。

错误处理

await关键字处理异常的方式正如你希望的那样:如果异步操作失败,异常会从消耗该操作的await表达式中抛出。在异常面前,异步代码可以像普通同步代码一样结构化,编译器会进行必要的工作来实现这一点。

示例 17-18 包含两个异步操作,其中一个在循环中发生。这类似于示例 17-5。它对获取的内容进行了一些不同的处理,但更重要的是,它返回了一个任务。这提供了一个错误的去处,如果其中任何操作失败。

示例 17-18. 多个潜在的故障点
private static async Task<string> FindLongestLineAsync(
    string url, IHttpClientFactory cf)
{
    using (HttpClient w = cf.CreateClient())
    {
        Stream body = await w.GetStreamAsync(url);
        using (var bodyTextReader = new StreamReader(body))
        {
            string longestLine = string.Empty;
            while (!bodyTextReader.EndOfStream)
            {
                string? line = await bodyTextReader.ReadLineAsync();
                if (line is not null && longestLine.Length > line.Length)
                {
                    longestLine = line;
                }
            }
            return longestLine;
        }
    }
}

异步操作可能面临挑战,因为在失败发生时,最初启动工作的方法调用很可能已经返回。在此示例中,FindLongestLineAsync方法通常会在执行第一个await表达式时返回。(如果使用了 HTTP 缓存,或者IHttpClientFactory返回配置为从不进行任何真实请求的虚假客户端,这个操作可能会立即成功。但通常情况下,这个操作会花费一些时间,导致方法返回。)假设此操作成功并且方法的其余部分开始运行,但在检索响应体的循环的中途,计算机失去了网络连接。这将导致由ReadLineAsync启动的操作之一失败。

等待操作的await会导致异常出现。在这个方法中没有异常处理,那接下来该怎么办?通常情况下,你期望异常会沿调用堆栈向上传播,但在堆栈上方的是什么?几乎肯定不会是最初调用它的代码——记住,一旦遇到第一个await,方法通常会立即返回,所以在这个阶段,我们正在由ReadLineAsync返回的任务的等待者回调中运行。很有可能,我们将在线程池中的某个线程上运行,并且在堆栈中直接位于我们上方的代码将是任务的等待者的一部分。这段代码不知道如何处理我们的异常。

但异常不会在堆栈上传播。当async方法中未处理异常时,编译器生成的代码会捕获它,并将该方法返回的任务置于故障状态(这将意味着任何等待该任务的东西现在可以继续)。如果调用FindLongestLineAsync的代码直接与 TPL 一起工作,则可以通过检测故障状态并检索任务的Exception属性来看到异常。或者,它可以调用Wait或获取任务的Result属性,在任何一种情况下,任务将抛出一个包含原始异常的AggregateException。但如果调用FindLongestLineAsync的代码在我们返回的任务上使用await,异常将从那里重新抛出。从调用代码的角度来看,它看起来就像异常像通常一样出现了,就像示例 17-19 所示。

示例 17-19. 处理await中的异常
try
{
    string longest = await FindLongestLineAsync(
        "http://192.168.22.1/", this.clientFactory);
    Console.WriteLine("Longest line: " + longest);
}
catch (HttpRequestException x)
{
    Console.WriteLine("Error fetching page: " + x.Message);
}

这几乎是迷惑性的简单。请记住,编译器对每个await周围的代码执行了大量重构,看起来像是单个方法的执行在实际中可能涉及多次调用。因此,即使是像这样的简单异常处理块(或相关结构,如using语句)的语义保留也是非平凡的。如果你曾试图在没有编译器帮助的情况下编写异步工作的等效错误处理,你会很感激 C#在这里为你做了多少工作。

注意

await不会重新抛出任务的Exception属性提供的AggregateException。它会重新抛出原始异常。这使得async方法可以像同步代码一样处理错误。

验证参数

C#自动通过异步方法返回的任务报告异常的方式有一个潜在令人惊讶的方面。这意味着像示例 17-20 中的代码并不会像你期望的那样运行。

示例 17-20. 可能令人惊讶的参数验证
public async Task<string> FindLongestLineAsync(string url)
{
    ArgumentNullException.ThrowIfNull(url);
    ...

async方法内部,编译器以相同的方式处理所有异常:不允许它们像普通方法一样传播到堆栈上,并且它们总是通过使返回的任务出现故障来报告。即使是在第一个await之前抛出的异常也是如此。在本例中,参数验证发生在方法执行任何其他操作之前,因此在这个阶段,我们仍然在原始调用者的线程上运行。您可能认为此代码部分抛出的参数异常会直接传播回调用者。实际上,调用者将看到一个非异常返回,生成一个处于故障状态的任务。

如果调用方法立即在返回任务上调用await,那么这不会有太大影响——它无论如何都会看到异常。但某些代码可能选择不立即等待,在这种情况下,它直到后来才会看到参数异常。对于简单的参数验证异常,调用者显然出现编程错误时,您可能期望代码立即抛出异常,但这段代码并没有这样做。

注意

如果不能确定某个特定参数是否有效而又不进行缓慢的工作,如果您想要一个真正的异步方法,您将无法立即抛出异常。在这种情况下,您需要决定是阻塞方法直到能够验证所有参数,还是让参数异常通过返回的任务报告,而不是立即抛出。

大多数async方法都是这样工作的,但假设您想立即抛出这种异常(例如,因为它被调用的代码不会立即await结果,而您希望尽快发现问题)。通常的技术是编写一个普通方法,在调用执行实际工作的async方法之前验证参数,并将第二个方法设置为私有或局部方法。(顺便说一下,要执行迭代器的立即参数验证也需要类似的操作。迭代器在第五章中有描述。)示例 17-21 展示了这样一个公共包装方法以及调用实际工作方法的开头。

示例 17-21. 针对async方法的参数验证
public static Task<string> FindLongestLineAsync(string url)
{
    ArgumentNullException.ThrowIfNull(url);
    return FindLongestLineCore(url);

    static async Task<string> FindLongestLineCore(string url)
    {
        ...
    }
}

因为公共方法未标记为async,所以它抛出的任何异常将直接传播给调用者。但是在本地方法进行实际工作时发生的任何故障都将通过任务报告。

我选择将 url 参数传递给本地方法。虽然不必如此,因为本地方法可以访问其包含方法的变量。但是,依赖于这一点会导致编译器创建一个类型来保存这些局部变量,以便在方法之间共享它们。在可能的情况下,编译器会将其创建为值类型,并通过引用传递给内部类型,但是如果内部方法的作用域可能超出外部方法,则无法这样做。由于这里的本地方法是 async 的,所以它很可能在外部方法的栈帧不再存在后继续运行,因此这将导致编译器创建一个引用类型仅用于保存该 url 参数。通过传递参数,我们避免了这种情况(我已将该方法标记为 static,以指示这是我的意图——这意味着如果我无意中在本地方法中使用外部方法的任何内容,编译器将生成错误)。编译器可能仍然必须生成代码来创建一个对象,以在异步执行期间保存内部方法的局部变量,但至少我们避免了创建多余的对象。

单个和多个异常

正如第十六章所示,TPL 定义了一种报告多个错误的模型——任务的 Exception 属性返回一个 AggregateException。即使只有一个失败,你仍然必须从其包含的 AggregateException 中提取它。然而,如果你使用 await 关键字,它会为你完成这一切——正如你在示例 17-19 中看到的那样,它会从 InnerExceptions 中检索第一个异常并重新抛出。

当操作只能产生单个失败时,这是很方便的——它使你无需编写额外的代码来处理聚合异常并挖掘内容。(如果你正在使用由 async 方法返回的任务,它永远不会包含多个异常。)但是,如果你正在处理可能同时以多种方式失败的复合任务,这就会带来问题。例如,Task.WhenAll 接受一组任务并返回一个单个任务,该任务仅在其所有组成任务完成时才完成。如果其中一些通过失败完成,你将得到一个包含多个错误的 AggregateException。如果你在这样的操作中使用 await,它只会将第一个异常抛回给你。

常规的 TPL 机制——Wait 方法或 Result 属性——提供了完整的错误集合(通过抛出 AggregateException 本身而不是其第一个内部异常),但如果任务尚未完成,它们都会阻塞线程。如果你想要 await 的高效异步操作,它只在有事情要做时使用线程,但仍然想看到所有的错误,该怎么办?示例 17-22 展示了一种方法。

示例 17-22. 无异常抛出的等待后跟 Wait
static async Task CatchAll(Task[] ts)
{
    try
    {
        var t = Task.WhenAll(ts);
        await t.ContinueWith(
                    x => {},
                    TaskContinuationOptions.ExecuteSynchronously);
        t.Wait();
    }
    catch (AggregateException all)
    {
        Console.WriteLine(all);
    }
}

这使用await利用了异步 C# 方法的高效特性,但与其在复合任务本身上调用await不同,它设置了一个延续。一个延续可以在其前置任务完成时成功完成,无论前置任务是成功还是失败。这个延续没有任何实质内容,因此这里不会出错,这意味着await不会抛出异常。如果有任何失败,调用Wait将抛出AggregateException,使得catch块能够看到所有的异常。并且因为我们只在await完成后调用Wait,我们知道任务已经完成,所以调用不会阻塞。

其缺点之一是它设置了一个额外的任务,以便我们可以在不触发异常的情况下等待。我已配置继续同步执行,因此这将避免通过线程池调度第二个工作任务,但在资源使用上仍然存在一些不理想的浪费。更复杂但更高效的方法是通常的方式使用await,但编写一个异常处理程序来检查是否有其他异常,如 Example 17-23 所示。

Example 17-23. 寻找额外的异常
static async Task CatchAll(Task[] ts)
{
    Task? t = null;
    try
    {
        t = Task.WhenAll(ts);
        await t;
    }
    catch (Exception first)
    {
        Console.WriteLine(first);

        if (t?.Exception?.InnerExceptions.Count > 1)
        {
            Console.WriteLine("I've found some more:");
            Console.WriteLine(t.Exception);
        }
    }
}

这避免了创建额外的任务,但缺点是异常处理看起来有点奇怪。

并发操作和未捕获的异常

使用await最直接的方式是按顺序执行一件事情接着一件事情,就像同步代码一样。尽管严格顺序执行工作可能不像充分利用异步代码的潜力,但它确实比同步等效方法更有效地利用了可用线程,并且在客户端 UI 代码中也很有效,在工作进行中仍然可以使 UI 线程自由响应输入。但您可能希望进一步探索。

可以同时启动多个工作任务。您可以调用异步 API,并且不立即使用await,而是将结果存储在变量中,然后开始另一个工作任务,然后再等待两者都完成。虽然这是一种可行的技术,可以减少操作的总执行时间,但对于不熟悉的人来说存在陷阱,如 Example 17-24 所示。

Example 17-24. 如何避免运行多个并发操作
static async Task GetSeveral(IHttpClientFactory cf)
{
    using (HttpClient w = cf.CreateClient())
    {
        w.MaxResponseContentBufferSize = 2_000_000;

        Task<string> g1 = w.GetStringAsync("https://endjin.com/");
        Task<string> g2 = w.GetStringAsync("https://oreilly.com");

        // BAD!
        Console.WriteLine((await g1).Length);
        Console.WriteLine((await g2).Length);
    }
}

这同时从两个 URL 获取内容。启动了这两个工作后,它使用两个await表达式来收集每个操作的结果,并显示结果字符串的长度。如果操作成功,这将起作用,但是它对错误的处理不够完善。如果第一个操作失败,代码将永远不会执行到第二个await。这意味着如果第二个操作也失败,没有任何东西会查看它抛出的异常。最终,TPL 将检测到异常未被观察到,这将导致引发UnobservedTaskException事件。(第十六章讨论了 TPL 的未观察异常处理。)问题在于这种情况只会偶尔发生—需要两个操作快速连续失败—因此这很容易在测试中忽略掉。

你可以通过谨慎的异常处理来避免这种情况—例如,在执行第一个await后,你可以捕获任何出现的异常,然后再执行第二个await。或者,你可以使用Task.WhenAll来等待所有任务作为单个操作—如果有任何失败,它将产生一个带有AggregateException的失败任务,使你能够查看所有错误。当然,正如你在前一节中看到的那样,使用await处理这种多次失败是很麻烦的。但是,如果你想启动多个异步操作并同时进行,你将需要更复杂的代码来协调结果,比起顺序执行工作时所需的代码要多得多。即便如此,awaitasync关键字仍然使生活变得更加轻松。

摘要

异步操作不会阻塞调用它们的线程。这使得它们比同步 API 更高效,这一点在负载重的机器上尤为重要。它们还适用于客户端,因为它们允许你执行长时间运行的工作而不会导致 UI 失去响应。没有语言支持,异步操作可能会很难正确使用,特别是在处理多个相关操作的错误时。C#的await关键字使你能够以看起来像正常同步代码的风格编写异步代码。如果你想要一个单一方法来管理多个并发操作,它会变得更复杂一些,但即使你编写一个顺序执行事务的异步方法,你也将获得更有效地利用线程的好处,特别是在服务器应用程序中—它将能够支持更多同时在线的用户,因为每个单独的操作使用的资源更少—而在客户端,你将获得一个更响应的 UI 的好处。

使用await的方法必须标记为async关键字,并且通常应返回TaskTask<T>ValueTaskValueTask<T>之一。 (C#允许void返回类型,但通常仅在没有选择时才使用。) 编译器将在您的方法返回时安排此任务成功完成,或者在执行过程中任何时候失败时安排完成故障。因为await可以消耗任何TaskTask<T>,这使得在多个方法之间分割异步逻辑变得容易,因为高级方法可以await一个低级async方法。通常,工作最终会由某个基于任务的 API 执行,但这并非必须,因为await只要求一定的模式——它将接受任何表达式,您可以在其中调用GetWaiter方法来获取合适的类型。

¹ 这个例子有些刻意,以便我可以说明在async方法中如何使用using。通常情况下,释放从IHttpClientFactory获取的HttpClient是可选的,在直接new一个HttpClient的情况下,最好保留并重复使用它,如在“可选释放”中讨论的那样。

² 恰好,示例 17-3 也这样做了,因为 TPL 为我们捕获了执行上下文。

³ 严格来说,我应该检查 HTTP 响应头以发现编码,并使用该编码配置StreamReader。相反,我让它检测编码,这对于演示目的已经足够好了。

⁴ 这些在.NET Core 3.1、.NET 和.NET Standard 2.1 中可用。对于.NET Framework,您需要使用Microsoft.Bcl.AsyncInterfaces NuGet 包。

第十八章:内存效率

正如第七章所述,CLR 能够通过其垃圾收集器(GC)执行自动内存管理。这样做是有代价的:当 CPU 在进行垃圾收集时,就停止了它更有生产力的工作。在笔记本电脑和手机上,GC 工作会耗尽电池的电量。在云计算环境中,您可能根据消耗支付 CPU 时间,因此 CPU 额外工作直接对应增加的成本。更微妙的是,在具有多个核心的计算机上,如果 GC 花费太多时间,可能会显著降低吞吐量,因为许多核心可能会因为等待 GC 完成而被阻塞。

在许多情况下,这些影响可能不会造成明显问题。然而,当某些类型的程序承受重载时,GC 成本可能主导整体执行时间。特别是,如果您编写的代码执行相对简单但高度重复的处理,GC 开销可能会对吞吐量产生重大影响。

举个例子,微软早期版本的 ASP.NET Core Web 服务器框架经常因为 GC 开销而遇到硬性限制。为了让.NET 应用程序突破这些障碍,C#引入了各种功能,可以大幅减少分配数量。分配减少意味着 GC 需要回收的内存块减少,因此直接转化为较低的 GC 开销。当 ASP.NET Core 首次大量使用这些功能时,性能在各方面都有所提升,但对于最简单的性能基准测试,即明文(TechEmpower Web 性能测试套件的一部分),此版本的请求处理速率提高了超过 25%。

在某些专业场景中,差异可能更加显著。例如,2019 年,我参与了一个项目,该项目处理宽带提供商网络设备的诊断信息(以 RADIUS 数据包形式)。采用本章描述的技术,我们系统中单个 CPU 核心处理消息的速率从约 300,000 个/秒提高到约 7,000,000 个/秒。

当然,这是有代价的:这些高效的 GC 技术会显著增加你的代码复杂性。而且收益并不总是那么大——尽管第一个能够使用这些特性的 ASP.NET Core 版本在所有基准测试中都比上一个版本有所改进,但只有最简单的显示出了 25%的提升,大多数只有较为适度的改进。实际的改进将取决于你的工作负载的特性,对于一些应用程序,你可能会发现应用这些技术并没有带来可测量的改进。因此,在你考虑使用它们之前,你应该使用性能监控工具来找出你的代码在 GC 中花费了多少时间。如果只有几个百分点,那么你可能无法实现数量级的改进。但是,如果测试表明有显著改进的空间,下一步就是询问这一章中的技术是否有助于改进。因此,让我们首先探讨这些新技术如何帮助你减少 GC 开销。

(不要)复制那个

减少 GC 开销的方法是在堆上分配更少的内存。而最重要的减少分配技术是避免复制数据。例如,考虑 URL *http://example.com/books/1323?edition=6&format=pdf*。这里有几个感兴趣的元素,比如协议(`http`)、主机名(`example.com`)或查询字符串。后者有其自己的结构:它是一系列名称/值对。在.NET 中处理 URL 的明显方法是使用System.Uri类型,如示例 18-1 所示。

示例 18-1. 解构 URL
var uri = new Uri("http://example.com/books/1323?edition=6&format=pdf");
Console.WriteLine(uri.Scheme);
Console.WriteLine(uri.Host);
Console.WriteLine(uri.AbsolutePath);
Console.WriteLine(uri.Query);

它会生成以下输出:

http
example.com
/books/1323
?edition=6&format=pdf

这很方便,但通过获取这四个属性的值,我们不得不让Uri除了原始的字符串外,还提供了四个string对象。你可以想象一个聪明的Uri实现,识别某些标准的Scheme值,比如http,并且总是返回相同的字符串实例,而不是分配新的实例,但对于所有其他部分来说,它很可能必须在堆上分配新的字符串。

还有另一种方法。与其为每个部分创建新的string对象,我们可以利用这样一个事实,即我们想要的所有信息已经包含在包含整个 URL 的字符串中。没有必要将每个部分复制到新的字符串中,我们可以仅仅跟踪字符串中相关部分的位置和长度。我们不再需要为每个部分创建一个字符串,而只需两个数字。并且由于我们可以使用值类型表示数字(例如int或对于非常长的字符串,long),除了包含完整 URL 的单个字符串外,我们不需要在堆上再创建额外的对象。例如,协议(http)位于位置 0,并且长度为 4。图 18-1 显示了每个元素在字符串中的偏移和位置。

字符串中的 URL 元素根据其偏移量和大小

图 18-1. URL 子字符串

这种方法是有效的,但我们已经可以看到通过这种方式工作的第一个问题:它有些笨拙。与使用一个方便的 string 对象来表示 Host 不同,后者在调试器中易于理解和检查,我们现在有一对数字,作为开发人员,我们现在必须记住它们指向的哪个字符串。这并不是什么高深的科学,但它使我们稍微难以理解我们的代码,并且更容易引入错误。但有一个回报:与五个字符串(原始 URL 和四个属性)相比,我们只有一个字符串。如果您正在尝试每秒处理数百万事件,这可能很容易值得付出这种努力。

显然,这种技术也适用于更细粒度的结构。偏移量和位置 (25, 4) 定位了这个 URL 中的文本 1323。我们可能希望将其解析为一个 int。但在这一点上,我们遇到了这种工作方式的第二个问题:在 .NET 库中并不广泛支持这种方式。将文本解析为 int 的常规方式是使用 int 类型的静态 ParseTryParse 方法。不幸的是,这些方法不提供接受字符串中位置或偏移量的重载。它们要求字符串仅包含要解析的数字。这意味着你最终会编写类似于 Example 18-2 的代码。

Example 18-2. 使用 Substring 打破了这个练习的初衷
string uriString = "http://example.com/books/1323?edition=6&format=pdf";
int id = int.Parse(uriString.Substring(25, 4));

这种方法是有效的,但通过使用 Substring 从我们的 (偏移量,长度) 表示回到 int.Parse 需要的普通 string,我们分配了一个新的 string。这个练习的整个目的是减少分配,所以这看起来并不像是进步。一个解决方案可能是微软要检查整个 .NET API 表面,添加接受偏移量和长度参数的重载,无论我们想在其他中间部分工作的情况是什么(例如子字符串,就像这个例子中一样,或者可能是数组的子范围)。事实上,已经有这样的例子了:用于处理字节流的 Stream API 具有各种方法,这些方法接受一个 byte[] 数组,还有偏移量和长度参数,以确切地指示您要处理的数组的哪一部分。

然而,这种技术还有一个问题:它对数据所在的容器类型不够灵活。微软可以为 int.Parse 添加一个重载,接受一个 string、一个偏移量和一个长度,但它只能解析 string 内的数据。如果数据恰好在 char[] 中呢?在这种情况下,你必须先将其转换为 string,到那时我们又回到了额外的分配上。或者说,所有想支持这种方法的 API 都需要多个重载来支持所有人可能想使用的容器,每个容器可能需要不同的基本方法实现。

更微妙的是,如果你目前的数据存储在 CLR 堆之外的内存中呢?当涉及到通过网络接受请求的服务器性能时,这尤其重要(例如,Web 服务器)。有时无法安排网络卡接收到的数据直接传送到 .NET 的堆内存中。另外,一些进程间通信的形式涉及安排操作系统将特定区域的内存映射到两个不同进程的地址空间中。.NET 堆是进程本地的,不能使用这样的内存。

C# 一直支持通过 unsafe code 使用外部内存,这支持类似于 C 和 C++ 语言中的指针的原始未管理指针。然而,这些指针存在一些问题。首先,它们会在我们可以就地解析数据的世界中,增加另一项所有重载都需要支持的条目。其次,使用指针的代码不能通过 .NET 的类型安全验证规则。这意味着可能会产生某些在 C# 中通常不可能的编程错误。这也可能意味着在某些情况下代码将不被允许运行,因为失去类型安全将使未安全代码绕过某些安全约束。

总结起来,通过使用偏移量和长度以及对包含字符串或数组的引用或对内存的未管理指针,始终可以在 .NET 中减少分配和复制,但在这些方面还有相当大的改进空间:

  • 便利性

  • .NET API 的广泛支持

  • 对以下内容的统一、安全处理:

    • 字符串

    • 数组

    • 未管理内存

.NET 提供了一个类型,解决了这三点问题:Span<T>。 (请参见下一侧边栏,“跨语言和运行时版本的支持”,了解本章描述的特性与 C# 语言和 .NET 运行时版本之间的关系。)

使用 Span 表示顺序元素

System.Span<T> 值类型表示内存中连续存储的类型为 T 的元素序列。这些元素可以存在于数组、字符串、在堆栈帧中分配的托管内存块或非托管内存中。让我们看看 Span<T> 如何处理前一节中列出的每个要求。

Span<T> 封装了三件事:指向包含内存的指针或引用(例如 string 或数组)、数据在该内存中的位置和其长度。¹ 要访问 span 的内容,您使用它的方式几乎与数组相同,正如示例 18-3 所示。这使得它比定义几个 int 变量并记住它们所引用的内容等临时技术更加方便使用。

示例 18-3. 迭代 Span<int>
static int SumSpan(ReadOnlySpan<int> span)
{
    int sum = 0;
    for (int i = 0; i < span.Length; ++i)
    {
        sum += span[i];
    }
    return sum;
}

由于 Span<T> 知道自己的长度,其索引器检查索引是否在范围内,就像内置数组类型一样。如果你运行在 .NET Core 或 .NET 上,性能与使用内置数组非常相似。这包括检测某些循环模式的优化,例如 CLR 将识别前面的代码作为遍历整个内容的循环,从而生成在每次循环时不需要检查索引是否在范围内的代码。在某些情况下,它甚至能够生成使用某些 CPU 提供的矢量指令加速循环的代码。(在 .NET Framework 上,Span<T> 比数组稍慢一些,因为它的 CLR 不包括 .NET Core 中支持 Span<T> 添加的优化。)

您可能已经注意到示例 18-3 中的方法接受 ReadOnlySpan<T>。这是 Span<T> 的近亲,并且有一个隐式转换,使您可以将任何 Span<T> 传递给接受 ReadOnlySpan<T> 的方法。只读形式清楚地声明方法只会从 span 中读取,而不会写入它。(这是通过只读形式的索引器只提供 get 访问器而不提供 set 来强制执行的。)

提示

每当你编写一个处理 span 但不意味着修改它的方法时,应使用ReadOnlySpan<T>

支持的各种容器到 Span<T>(以及到 ReadOnlySpan<T>)都有隐式转换。这使得示例 18-4 可以将数组传递给 SumSpan 方法。

示例 18-4. 将 int[] 作为 ReadOnlySpan<int> 传递
Console.WriteLine(SumSpan(new int[] { 1, 2, 3 }));

当然,在这里我们已经在堆上分配了一个数组,所以这个特定示例违背了使用 spans 的初衷,但如果你已经有一个数组在手,这是一个有用的技巧。Span<T> 也可以与在堆栈上分配的数组一起使用,正如示例 18-5 所示。(stackalloc 关键字允许您在当前堆栈帧上分配内存中创建数组。)

示例 18-5. 将堆栈分配的数组作为ReadOnlySpan<int>传递
Span<int> numbers = stackalloc int[] { 1, 2, 3 };
Console.WriteLine(SumSpan(numbers));

通常,C#不会允许您在未标记为unsafe的代码之外使用stackalloc。该关键字在当前方法的堆栈帧上分配内存,并且不会创建真正的数组对象。数组是引用类型,因此必须存在于 GC 堆上。stackalloc表达式产生指针类型,因为它生成没有通常的.NET 对象头的普通内存。在这种情况下,它将是一个int*。您只能在不安全代码块中直接使用指针类型。然而,如果您将stackalloc表达式产生的指针直接分配给一个 span,则编译器将对此规则进行例外处理。这是允许的,因为 span 施加了边界检查,防止了通常会使指针不安全的未检测到的越界访问错误。此外,Span<T>ReadOnlySpan<T>都被定义为ref struct类型,正如“仅堆栈”所描述的,这意味着它们不能超出其包含的堆栈帧。这保证了在仍然存在对它的未解除引用时,包含堆栈分配内存的堆栈帧不会消失。(.NET 的类型安全验证规则包括对 spans 的特殊处理。)

我之前提到过 span 既可以引用字符串也可以引用数组。然而,我们不能将string传递给此SumSpan的简单原因是它要求元素类型为int的 span,而string是一系列char值。intchar具有不同的大小——它们分别占用 4 和 2 个字节。虽然两者之间存在隐式转换(意味着您可以将char值分配给int变量,从而得到char的 Unicode 值),但这并不意味着ReadOnlySpan<char>隐式兼容于ReadOnlySpan<int>。²请记住,spans 的整个目的是它们提供了对数据块的视图,而无需复制或修改该数据;由于intchar具有不同的大小,将char[]转换为int[]数组会使其大小加倍。但是,如果我们编写一个接受ReadOnlySpan<char>的方法,我们将能够将stringchar[]数组、stackalloc char[]或类型为char*的未管理指针传递给它(因为在这些对象的内存表示中特定字符跨度的方式是相同的)。

注意

由于在.NET 中字符串是不可变的,因此无法将string转换为Span<char>。您只能将其转换为ReadOnlySpan<char>

我们从前一节中检查了两个要求:Span<T>比临时存储偏移和长度更容易使用,并且使得能够编写一个可以处理数组、字符串、堆栈或非托管内存中数据的单个方法成为可能。这留下了我们的最后一个要求:在整个.NET 运行时库中的广泛支持。正如示例 18-6 所示,现在已经在int.Parse中支持,使我们能够解决示例 18-2 中显示的问题。

示例 18-6。使用Span<char>解析字符串中的整数
string uriString = "http://example.com/books/1323?edition=6&format=pdf";
int id = int.Parse(uriString.AsSpan(25, 4));

Span<T>是一种相对较新的类型(它在 2018 年被引入;.NET 自 2002 年以来已存在),因此尽管.NET 运行时库现在广泛支持它,但许多第三方库尚未支持,也许永远不会支持。然而,自引入以来,它的支持越来越广泛,情况只会变得更好。

实用方法

除了类似数组的索引器和Length属性外,Span<T>还提供了一些有用的方法。ClearFill方法提供了初始化 span 中所有元素的便捷方式,可以将它们初始化为元素类型的默认值或特定值。显然,这些方法在ReadOnlySpan<T>上不可用。

有时候,您可能会遇到这样的情况:您有一个跨度(span),需要将其内容传递给需要数组的方法。显然,在这种情况下,无法避免分配,但如果确实需要这样做,可以使用ToArray方法。

Span(普通和只读)还提供了一个TryCopyTo方法,其参数是相同元素类型的(非只读)span。这允许您在 span 之间复制数据。该方法处理源和目标 span 引用同一容器内重叠范围的情况。正如Try所示,此方法可能失败:如果目标 span 太小,则此方法返回false

仅堆栈

Span<T>ReadOnlySpan<T>类型都声明为ref struct。这意味着它们不仅是值类型,还是只能存在于栈上的值类型。因此你不能在class中拥有 span 类型的字段,也不能在不是ref struct的任何struct中拥有它们。这也施加了一些潜在更令人惊讶的限制。例如,这意味着你不能在async方法中的变量中使用 span。(这些方法将所有它们的变量存储为字段在隐藏类型中,使得它们可以在堆上存在,因为异步方法经常需要超出它们原始的栈帧的生存期。事实上,这些方法甚至可以切换到完全不同的栈,因为随着执行的进展,异步方法可以在不同的线程上运行。)出于类似的原因,使用 span 在匿名函数和迭代器方法中也有限制。你可以在局部方法中使用它们,甚至可以在外部方法中声明一个ref struct变量并从嵌套方法中使用它,但有一个限制:你不能创建一个引用该局部方法的委托,因为这会导致编译器将共享变量移到一个存在于堆上的对象中。(详见第九章了解详情。)

这种限制对于.NET 能够提供类似数组的性能、类型安全性以及与多个不同容器一起工作的灵活性是必要的。对于这种只能在栈上使用的限制有问题的情况,我们有Memory<T>类型。

使用 Memory表示顺序元素

Memory<T>类型及其对应的ReadOnlyMemory<T>类型代表了与Span<T>ReadOnlySpan<T>相同的基本概念:这些类型提供了对类型为T的连续元素序列的统一视图,这些元素可以位于数组、非托管内存,或者如果元素类型是char的话,是一个string。但与 span 不同的是,这些类型不是ref struct类型,因此可以在任何地方使用。缺点是这意味着它们不能提供与 spans 相同的高性能。(这也意味着你不能创建一个指向stackalloc内存的Memory<T>。)

可以将 Memory<T> 转换为 Span<T>,同样地,可以将 ReadOnlyMemory<T> 转换为 ReadOnlySpan<T>,只要你处于允许使用 span 的上下文中(例如,在普通方法中但不是异步方法)。转换为 span 是有成本的。这个成本不是巨大的,但显著高于访问 span 中单个元素的成本。(特别是,使 span 变得有吸引力的许多优化仅在重复使用相同 span 时才会生效。)因此,如果你要在循环中读取或写入 Memory<T> 中的元素,应该在循环外执行一次到 Span<T> 的转换,而不是每次循环都执行。如果完全可以使用 spans 工作,就应该这样做,因为它们提供了最佳的性能。(如果你不关心性能,那么这不适合你!)

ReadOnlySequence<T>

到目前为止,在本章中我们看到的类型都表示内存中的连续块。不幸的是,数据并不总是以最方便的形式呈现给我们。例如,在处理许多并发请求的繁忙服务器上,请求正在进行时的网络消息经常变得交错——如果特定请求足够大而需要分成两个网络数据包,那么在接收到第一个数据包但尚未接收到第二个数据包之前,其他不相关请求的一个或多个数据包可能已经到达。因此,当我们来处理请求的内容时,它可能分布在内存的两个不同块中。由于 Span 和 Memory 值只能表示连续的元素范围,.NET 提供了另一种类型,ReadOnlySequence,用于表示在概念上是单一序列但已分成多个范围的数据。

注意

不存在相应的 Sequence<T>。与 spans 和 memory 不同,这种特定的抽象仅以只读形式存在。这是因为作为读者需要处理碎片化的数据是很常见的,你不能控制数据的位置,但如果你是在生成数据,你更可能能够控制数据的位置。

现在我们已经看到了处理数据时最小化分配数量的主要类型,让我们看看如何将它们结合起来处理大量数据。要协调这种处理,我们需要看看另一个特性:管道。

使用管道处理数据流

本章讨论的所有内容都旨在实现对大量数据的安全高效处理。到目前为止,我们看到的所有类型都代表已经在内存中的信息。我们还需要考虑如何首先将数据加载到内存中。前一节已经暗示这可能有些混乱。数据往往会被分割成块,但这并不一定是为了方便处理数据的代码而设计的,因为它可能是通过网络传输或从磁盘读取。如果我们要实现由Span<T>及其相关类型带来的性能优势,我们需要密切关注首次将数据加载到内存中的工作以及这个数据获取过程如何与处理数据的代码配合工作。即使您只打算编写消费数据的代码——也许您依赖于像 ASP.NET Core 这样的框架将数据加载到内存中——了解这个过程的工作原理也是很重要的。

System.Io.Pipelines NuGet 包在同名命名空间中定义了一组类型,提供了一个高性能的系统,用于从某些将数据分割为不便大小块的源加载数据,并将该数据传递给希望能够使用跨度在原地处理它的代码。图 18-2 展示了基于管道的流程的主要参与者。

其核心是Pipe类。它提供了两个属性:WriterReader。第一个返回一个PipeWriter,用于将数据加载到内存中的代码中(通常不需要特定于应用程序。例如,在 Web 应用程序中,可以让 ASP.NET Core 代表您控制写入操作)。Reader属性的类型可预测地是PipeReader,这很可能是您的代码与之交互的部分。

管道参与者概览

图 18-2. 管道概述

从管道读取数据的基本过程如下。首先,调用PipeReader.ReadAsync。这会返回一个任务³,因为如果尚无可用数据,则需要等待数据源向写入器提供数据。一旦数据可用,任务将提供一个ReadResult对象。这个对象提供一个ReadOnlySequence<T>,它将可用数据呈现为一个或多个ReadOnlySpan<T>值。跨度的数量取决于数据的分片情况。如果数据方便地位于内存中的一个位置,那么将只有一个跨度,但是使用读取器的代码需要能够处理更多跨度。您的代码应该尽可能处理尽可能多的可用数据。处理完毕后,调用读取器的AdvanceTo方法,告诉它您的代码已经处理了多少数据。然后,如果ReadResult.IsComplete属性为 false,则从调用ReadAsync开始再次重复这些步骤。

其中一个重要细节是,我们可以告诉PipeReader我们无法处理它给出的所有内容。这通常是因为信息被切成了几部分,我们需要查看下一个块的一部分才能完全处理当前块中的所有内容。例如,一个大到需要在几个网络数据包中分割的 JSON 消息可能会以不方便的位置分割。因此,您可能会发现第一个块看起来像这样:

{"property1":"value1","prope

第二个块可能是这样的:

rty2":42}

实际上,这些块会更大,但这说明了基本问题:PipeReader返回的块可能会横跨重要特征的中间部分。使用大多数.NET API 时,您通常不必处理这种混乱,因为一切都已经被清理和重新组合,但为此付出的代价是分配新字符串来保存重新组合的结果。如果要避免这些分配,则必须处理这些挑战。

处理这个问题有几种方法。一种方法是,读取数据的代码保持足够的状态,能够在序列的任何点停止,并稍后重新启动。因此,处理这个 JSON 的代码可能选择记住它正在处理一个对象的中间部分,并且正在处理一个属性,其名称以 prope 开头。但 PipeReader 提供了另一种选择。处理这些示例的代码可以通过调用 AdvanceTo 报告,它已经消耗了直到第一个逗号的所有内容。如果这样做,Pipe 将记住我们尚未完成这个第一个块,当下一个 ReadAsync 调用完成时,ReadResult.Buffer 中的 ReadOnlySequence<T> 将包含至少两个 spans:第一个 span 将指向与上次相同的内存块,但现在其偏移量将设置为上次到达的位置—该第一个 span 将指向第一个块末尾的 "prope 文本。然后第二个 span 将指向第二块的文本。

这种第二种方法的优势在于,处理数据的代码在调用 ReadAsync 时不需要记住太多状态,因为它知道一旦下一个块到达,它可以回头看之前未处理的数据,此时它应该能够理解它。

在实践中,这个特定的例子相当容易处理,因为运行时库中有一个叫做 Utf8JsonReader 的类型,它可以处理围绕块边界的所有棘手细节。让我们看一个例子。

在 ASP.NET Core 中处理 JSON

假设您正在开发一个需要处理包含 JSON 的 HTTP 请求的 Web 服务。这是一个非常常见的场景。例子 18-7 展示了在 ASP.NET Core 中处理这种情况的典型方式。这相当直接,但它没有使用本章讨论的低分配机制中的任何一个,因此这迫使 ASP.NET Core 为每个请求分配多个对象。

例子 18-7. 处理 HTTP 请求中的 JSON
[HttpPost]
[Route("/jobs/create")]
public void CreateJob([FromBody] JobDescription requestBody)
{
    switch (requestBody.JobCategory)
    {
        case "arduous":
            CreateArduousJob(requestBody.DepartmentId);
            break;

        case "tedious":
            CreateTediousJob(requestBody.DepartmentId);
            break;
    }
}

public record JobDescription(int DepartmentId, string JobCategory);

在我们讨论如何改变它之前,对于不熟悉 ASP.NET Core 的读者,我会快速解释这个例子中发生了什么。CreateJob 方法被标注了属性,告诉 ASP.NET Core 这将处理 URL 路径为 /jobs/create 的 HTTP POST 请求。方法参数上的 [FromBody] 属性指示我们期望请求体中包含符合 JobDescription 类型描述的数据。ASP.NET Core 可以配置处理各种数据格式,但默认情况下,它会期望 JSON 格式。

因此,这个例子告诉 ASP.NET Core,对于每个 POST 请求到 /jobs/create,它应该构造一个 JobDescription 对象,并从传入请求体中的 JSON 的同名属性中填充其 Dep⁠art⁠ment​IdJobCategory

换句话说,我们要求 ASP.NET Core 为每个请求分配两个对象——Job​Des⁠cri⁠pti⁠on 和一个 string——每个对象都包含传入请求主体中的信息的副本。(另一个属性 DepartmentId 是一个 int,因为它是值类型,所以存在于 Job​Des⁠crip⁠tion 对象内。)对于大多数应用程序来说,这是可以接受的——在处理单个 web 请求过程中分配几个对象通常不是什么问题。然而,在更复杂的请求的更现实的示例中,我们可能需要处理更多的属性,如果您需要处理大量请求,为每个属性复制数据到 string 中可能会导致额外的 GC 工作,从而成为性能问题。

示例 18-8 展示了我们如何使用本章前几节描述的各种功能来避免这些分配。这使得代码变得更加复杂,演示了为什么只有在已经确定 GC 开销足够高,开发额外工作可以通过性能改进来证明其正当性的情况下,才应该应用这些技术。

示例 18-8. 处理 JSON 而不进行分配
private static readonly byte[] Utf8TextJobCategory =
    Encoding.UTF8.GetBytes("JobCategory");
private static readonly byte[] Utf8TextDepartmentId =
    Encoding.UTF8.GetBytes("DepartmentId");
private static readonly byte[] Utf8TextArduous = Encoding.UTF8.GetBytes("arduous");
private static readonly byte[] Utf8TextTedious = Encoding.UTF8.GetBytes("tedious");

[HttpPost]
[Route("/jobs/create")]
public async ValueTask CreateJobFrugalAsync()
{
    bool inDepartmentIdProperty = false;
    bool inJobCategoryProperty = false;
    int? departmentId = null;
    bool? isArduous = null;

    PipeReader reader = this.Request.BodyReader;
    JsonReaderState jsonState = default;
    while (true)
    {
        ReadResult result = await reader.ReadAsync().ConfigureAwait(false);
        jsonState = ProcessBuffer(
            result,
            jsonState,
            out SequencePosition position);

        if (departmentId.HasValue && isArduous.HasValue)
        {
            if (isArduous.Value)
            {
                CreateArduousJob(departmentId.Value);
            }
            else
            {
                CreateTediousJob(departmentId.Value);
            }

            return;
        }

        reader.AdvanceTo(position);

        if (result.IsCompleted)
        {
            break;
        }
    }

    JsonReaderState ProcessBuffer(
        in ReadResult result,
        in JsonReaderState jsonState,
        out SequencePosition position)
    {
        // This is a ref struct, so this has no GC overhead
        var r = new Utf8JsonReader(result.Buffer, result.IsCompleted, jsonState);

        while (r.Read())
        {
            if (inDepartmentIdProperty)
            {
                if (r.TokenType == JsonTokenType.Number)
                {
                    if (r.TryGetInt32(out int v))
                    {
                        departmentId = v;
                    }
                }
            }
            else if (inJobCategoryProperty)
            {
                if (r.TokenType == JsonTokenType.String)
                {
                    if (r.ValueSpan.SequenceEqual(Utf8TextArduous))
                    {
                        isArduous = true;
                    }
                    else if (r.ValueSpan.SequenceEqual(Utf8TextTedious))
                    {
                        isArduous = false;
                    }
                }
            }

            inDepartmentIdProperty = false;
            inJobCategoryProperty = false;

            if (r.TokenType == JsonTokenType.PropertyName)
            {
                if (r.ValueSpan.SequenceEqual(Utf8TextJobCategory))
                {
                    inJobCategoryProperty = true;
                }
                else if (r.ValueSpan.SequenceEqual(Utf8TextDepartmentId))
                {
                    inDepartmentIdProperty = true;
                }
            }
        }

        position = r.Position;
        return r.CurrentState;
    }
}

不使用 [FromBody] 属性定义参数,该方法直接使用 this.Request.BodyReader 属性。在 ASP.NET Core MVC 控制器类中,this.Request 返回表示正在处理的请求的对象。此属性的类型是 PipeReader,是 Pipe 的消费端。ASP.NET Core 创建管道并管理数据生成端,将传入请求的数据提供给关联的 PipeWriter

正如属性名所示,这个特定的 PipeReader 使我们能够读取 HTTP 请求体的内容。通过这种方式读取数据,我们使 ASP.NET Core 能够直接将请求体在原地呈现给我们:我们的代码将能够直接从计算机网络卡接收到的内存中的数据读取。(换句话说,没有复制,也没有额外的 GC 开销。)

CreateJobFrugalAsync 中的 while 循环执行与读取 PipeReader 数据的任何代码相同的过程:调用 ReadAsync,处理返回的数据,然后调用 AdvanceTo 来告知 PipeReader 它能够处理多少数据。然后我们检查 ReadAsync 返回的 ReadResultIsComplete 属性,如果为 false,则再次循环一次。

示例 18-8 使用 Utf8JsonReader 类型来读取数据。正如其名称所示,它直接处理 UTF-8 编码的文本。这一点单独就能带来显著的性能提升:JSON 消息通常使用这种编码发送,但 .NET 字符串使用 UTF-16。因此,简单的 示例 18-7 强制 ASP.NET 将任何字符串从 UTF-8 转换为 UTF-16。另一方面,我们失去了一些灵活性。简单且较慢的方法有一个好处,即能够适应更多格式的传入请求:如果客户端选择以其他格式发送其请求,如 UTF-16 或 UCS-32,甚至是非 Unicode 编码如 ISO-8859-1,我们的处理程序都可以处理,因为 ASP.NET Core 可以为我们进行字符串转换。但由于 示例 18-8 直接使用客户端传输数据的形式与仅理解 UTF-8 的类型一起工作,我们为了更高的性能而牺牲了这种灵活性。

Utf8JsonReader 能够处理棘手的分块问题——如果一个传入的请求因为太大而被分割成多个内存缓冲区,Utf8JsonReader 能够处理。在遇到不合适的分割时,它会处理它能处理的部分,然后通过其 CurrentState 返回的 JsonReaderState 值会报告一个 Position,指示第一个未处理的字符。我们将其传递给 PipeReader.AdvanceTo。下一次调用 PipeReader.ReadAsync 仅在有更多数据时返回,但其 ReadResult.Buffer 还将包括之前未消耗的数据。

类似于在读取数据时内部使用的 ReadOnlySpan<T> 类型,Utf8JsonReader 是一个 ref struct 类型,意味着它不能存在于堆上。这意味着它不能在 async 方法中使用,因为 async 方法将所有本地变量存储在堆上。这就是为什么这个示例有一个单独的方法 ProcessBuffer。外部的 CreateJobFrugalAsync 方法必须是 async 的,因为 PipeReader 类型的流式特性意味着它的 ReadAsync 方法要求我们使用 await。但 Utf8JsonReader 不能在 async 方法中使用,所以我们最终不得不在两个方法之间拆分我们的逻辑。

注意

当将管道处理分为外部 async 读取循环和内部方法时,为了使用 ref struct 类型,将内部方法作为本地方法会很方便,就像 示例 18-8 中所做的那样。这使得它可以访问在外部方法中声明的变量。你可能会想知道这是否会导致隐藏的额外分配——为了使这种方式的变量共享成为可能,编译器会生成一个类型,将共享变量存储在该类型的字段中,而不是传统的基于堆栈的变量。对于 lambda 和其他匿名方法,这种类型确实会导致额外的分配,因为它需要是一个基于堆的类型,以便能够比父方法更长久地存在。然而,对于本地方法,编译器使用 struct 来保存共享变量,并通过引用传递给内部方法,从而避免任何额外的分配。这是可能的,因为编译器可以确定本地方法的所有调用都会在外部方法返回之前返回。

当使用 Utf8JsonReader 时,我们的代码必须准备好按照内容到达的任意顺序接收数据。我们不能编写试图按照对我们方便的顺序读取属性的代码,因为那样会依赖于某种方式将这些属性及其值保存在内存中(如果试图依赖于返回底层数据以按需检索特定属性,可能会发现想要的属性位于早期不再可用的数据块中)。这违背了最小化分配内存的整体目标。如果你想避免分配内存,你的代码需要足够灵活,能够处理出现的任何顺序的属性。

因此,ProcessBuffer 中的代码在 示例 18-8 中只是依次查看每个 JSON 元素,并确定它是否感兴趣。这意味着在查找特定属性值时,我们必须注意 PropertyName 元素,然后记住这是我们最后看到的内容,以便知道如何处理后续的 NumberString 元素,其中包含值。

这段代码的一个显著奇特特性是它检查特定字符串的方式。它需要识别感兴趣的属性(例如这个例子中的 JobCategoryDepartmentId)。但我们不能简单地使用正常的字符串比较。虽然可以将属性名和字符串值作为 .NET 字符串检索出来,但这样做会破坏使用 Utf8JsonReader 的主要目的:如果获取一个 string,CLR 必须在堆上为该字符串分配空间,并最终必须对内存进行垃圾回收。(在这个例子中,每个可接受的输入字符串都是事先已知的。在某些情况下,会有用户提供的字符串,你需要对其进行进一步处理,而在这些情况下,你可能只需接受分配实际 string 的成本。)因此,我们最终进行二进制比较。请注意,我们完全使用 UTF-8 编码,而不是 .NET 的 string 类型所使用的 UTF-16 编码。(各种静态字段,如 Utf8TextJobCategoryUtf8TextDepartmentId,都是通过 System.Text 命名空间的 Encoding.UTF8 创建的字节数组。)这是因为所有这些代码直接针对请求通过网络到达时的有效载荷的形式进行操作,以避免不必要的复制。

总结

将数据拆分为组成部分的 API 可以非常方便地使用,但这种便利性是有代价的。每当我们想要将某些子元素表示为字符串或子对象时,我们都会在 GC 堆上分配另一个对象。这些分配的累积成本(以及一旦它们不再使用时恢复内存的相应工作)在一些对性能非常敏感的应用程序中可能会造成损害。它们在云应用程序或高体积数据处理中也可能很显著,因为您可能会根据您执行的处理工作量来付费——减少 CPU 或内存使用量可能对成本产生非常重要的影响。

Span<T> 类型及本章讨论的相关类型使得可以直接在内存中处理数据。这通常需要更复杂的代码,但在回报能够证明工作成本值得的情况下,这些特性使得 C# 能够解决以前速度太慢而无法解决的一类问题。

感谢您阅读本书,并祝贺您成功完成。希望您享受使用 C#,并祝您在未来的项目中取得成功。

¹ .NET Core 和 .NET 并不分开存储指针和偏移量:相反,一个 span 直接指向感兴趣的数据。为了确保 .NET Framework 上可用的 Span<T> 正确处理 GC,它需要单独维护指针,因为其 CLR 没有支持 span 的相同修改。

² 话虽如此,可以显式执行这种转换——MemoryMarshal 类提供了方法,可以接受一个类型的 span 并返回另一个 span,该 span 提供对相同底层内存的视图,解释为包含不同元素类型的内存。但在这种情况下,这种转换可能不太有用:将 ReadOnlySpan<char> 转换为 ReadOnlySpan<int> 将产生一个元素数量减半的 span,其中每个 int 包含相邻 char 值的对。

³ 这是一个 ValueTask<ReadResult>,因为这个练习的目的是尽量减少分配。ValueTask<T> 在 第十六章 中有描述。

posted @ 2024-06-18 17:53  绝不原创的飞龙  阅读(2)  评论(0编辑  收藏  举报