C-12-技术手册-全-
C#12 技术手册(全)
原文:
zh.annas-archive.org/md5/e2c84fd09097e50aedbc4e5989f32a85译者:飞龙
前言
C# 12 代表了微软旗舰编程语言的第九个重大更新,将 C#定位为一种具有非同寻常灵活性和广度的语言。一方面,它提供了高级抽象,如查询表达式和异步继续,而另一方面,它通过自定义值类型和可选指针等构造允许低级效率。
这种增长的代价是需要学习的内容比以往任何时候都多。尽管诸如微软的 IntelliSense 和在线参考等工具在帮助您处理工作中的问题方面非常出色,但它们假设您具备一定的概念知识图谱。本书正是以简洁统一的风格提供了这样一张知识地图——没有冗长的介绍和混乱的内容。
与过去七个版本一样,《C# 12 简明手册》围绕概念和用例组织,使其既适合顺序阅读,又适合随机浏览。它还深入到重要的深度,同时仅假设基本的背景知识,因此对中级和高级读者都很容易理解。
本书涵盖了 C#、公共语言运行时(CLR)以及.NET 8 基础类库(BCL)。我们选择这个重点,以便为难度较大和高级主题留出空间,而不影响深度或可读性。最近添加到 C#的功能已经标记,这样您也可以将本书作为 C# 11 和 C# 10 的参考。
目标读者
本书面向中高级读者。不需要事先了解 C#,但需要一些通用的编程经验。对于初学者,本书是编程教程风格介绍的补充,而非替代。
本书是任何专注于应用技术(如 ASP.NET Core 或 Windows Presentation Foundation(WPF))的大量图书的理想伴侣。《C# 12 简明手册》涵盖了这些书籍所忽略的语言和.NET 的领域,反之亦然。
如果您寻找一本涵盖每一个.NET 技术的书籍,那么这本书不适合您。如果您想要了解特定于移动设备开发的 API,则本书也不适合。
本书的组织结构
第二章到第四章完全集中于 C#,从语法、类型和变量的基础开始,到诸如不安全代码和预处理器指令等高级主题。如果您是这门语言的新手,应该按顺序阅读这些章节。
剩余章节专注于 .NET 8 的基础类库,涵盖语言集成查询(LINQ)、XML、集合、并发、I/O 和网络、内存管理、反射、动态编程、属性、加密和本地互操作性等主题。您可以随机阅读大多数章节,除了第 5 和第六章,这两章为后续主题奠定基础。最好按顺序阅读关于 LINQ 的三章,有些章节假设您具备一些并发知识,我们在 第十四章 中进行讨论。
使用本书所需条件
本书示例需要 .NET 8. 您还会发现 Microsoft 的 .NET 文档对查找单个类型和成员(可在线访问)非常有用。
虽然可以在简单的文本编辑器中编写源代码,并从命令行构建程序,但使用 代码临时记事本 可以更快速地测试代码片段,再加上集成开发环境(IDE)可以更高效地生成可执行文件和库。
对于 Windows 代码临时记事本,请从 www.linqpad.net 下载 LINQPad 8(免费)。LINQPad 完全支持 C# 12,并由作者维护。
对于 Windows IDE,请下载 Visual Studio 2022:任何版本都适用于本书教授的内容。对于跨平台 IDE,请下载 Visual Studio Code。
注意事项
所有章节的代码清单均作为交互式(可编辑)LINQPad 示例提供。您可以一键下载所有示例:在左下角点击 LINQPad 的“Samples”选项卡,点击“Download more samples”,然后选择“C# 12 in a Nutshell”。
本书使用的约定
本书使用基本的 UML 符号来说明类型之间的关系,如 图 P-1 所示。倾斜的矩形表示抽象类;圆圈表示接口。带有空心三角形的线表示继承,三角形指向基类型。带有箭头的线表示单向关联;没有箭头的线表示双向关联。

图 P-1. 示例图
本书使用以下排版约定:
斜体
表示新术语、URI、文件名和目录
等宽字体
表示 C# 代码、关键字和标识符以及程序输出
**等宽字体加粗**
显示代码的突出部分
*等宽字体斜体*
显示应由用户提供值替换的文本
使用代码示例
补充材料(代码示例、练习等)可在 http://www.albahari.com/nutshell 下载。
本书旨在帮助您完成工作。通常情况下,您可以在您的程序和文档中使用本书中的代码,无需联系我们获得许可。但如果您要复制本书的大部分代码,则需要许可。例如,编写一个使用本书中几个代码块的程序不需要许可。销售或分发 O’Reilly 图书的示例需要许可。引用本书并引用示例代码来回答问题不需要许可(尽管我们感谢署名)。将本书的大量示例代码整合到产品文档中需要许可。
我们欣赏,但通常不要求署名。署名通常包括标题、作者、出版商和 ISBN。例如:“C# 12 in a Nutshell by Joseph Albahari (O’Reilly)。Copyright 2024 Joseph Albahari, 978-1-098-14744-0.”
如果您认为您使用的代码示例超出了公平使用或这里给出的许可,请随时通过 permissions@oreilly.com 与我们联系。
如何联系我们
请将关于本书的评论和问题发送给出版商:
-
O’Reilly Media, Inc.
-
1005 Gravenstein Highway North
-
加利福尼亚州塞巴斯托波尔 95472
-
800-889-8969(美国或加拿大境内)
-
707-829-7019(国际或本地)
-
707-829-0104(传真)
-
support@oreilly.com
我们为本书设有网页,列出勘误、示例及其他信息。您可以访问https://oreil.ly/c-sharp-nutshell-12查看此页面。
代码清单和其他资源请参阅:
获取有关我们的图书和课程的新闻和信息,请访问https://oreilly.com。
在 LinkedIn 上找到我们:https://linkedin.com/company/oreilly-media
在 Twitter 上关注我们:https://twitter.com/oreillymedia
在 YouTube 上观看我们:https://youtube.com/oreillymedia
致谢
Joseph Albahari
自 2007 年首次问世以来,本书依赖于一些出色的技术审阅者的意见。对于他们在最近版本中的贡献,我特别感谢 Stephen Toub、Paulo Morgado、Fred Silberberg、Vitek Karas、Aaron Robinson、Jan Vorlicek、Sam Gentile、Rod Stephens、Jared Parsons、Matthew Groves、Dixin Yan、Lee Coward、Bonnie DeWitt、Wonseok Chae、Lori Lalonde 和 James Montemagno。
我特别感谢埃里克·利珀特(Eric Lippert)、乔恩·斯基特(Jon Skeet)、史蒂芬·托布(Stephen Toub)、尼古拉斯·帕尔迪诺(Nicholas Paldino)、克里斯·伯罗斯(Chris Burrows)、肖恩·法卡斯(Shawn Farkas)、布莱恩·格伦克迈耶(Brian Grunkemeyer)、莫妮·斯蒂芬斯(Maoni Stephens)、大卫·德温特(David DeWinter)、迈克·巴内特(Mike Barnett)、梅丽塔·安德森(Melitta Andersen)、米奇·韦特(Mitch Wheat)、布莱恩·皮克(Brian Peek)、克日什托夫·瓦利纳(Krzysztof Cwalina)、马特·沃伦(Matt Warren)、乔尔·波巴尔(Joel Pobar)、格林·格里菲斯(Glyn Griffiths)、伊昂·瓦西里安(Ion Vasilian)、布拉德·艾布拉姆斯(Brad Abrams)和亚当·内森(Adam Nathan)的早期贡献。
我感谢微软的许多技术审阅者都是杰出的个人,特别感谢你们花时间将本书提升到下一个质量水平。
我要感谢本·阿尔巴哈里(Ben Albahari)和埃里克·约翰森(Eric Johannsen),他们对之前版本有所贡献,以及 O'Reilly 团队,特别是我高效负责的编辑科尔宾·科林斯(Corbin Collins)。最后,我深深感谢我的美妙妻子李·阿尔巴哈里(Li Albahari),在整个项目期间她的存在使我保持愉快。
第一章:介绍 C#和.NET
C#是一种通用、类型安全的面向对象编程语言。语言的目标是提高程序员的生产力。为此,C#平衡了简单性、表达能力和性能。自从第一个版本以来,语言的首席架构师是 Anders Hejlsberg(Turbo Pascal 的创造者和 Delphi 的架构师)。C#语言是平台中立的,并与一系列特定于平台的运行时配合工作。
面向对象
C# 是面向对象范式的丰富实现,包括封装、继承和多态。封装意味着在对象周围创建一个边界,以分隔其外部(公共)行为和内部(私有)实现细节。以下是从面向对象的角度看 C#的独特特性:
统一类型系统
C# 中的基本构建块是一个称为类型的封装单元,其中所有类型最终都共享一个共同的基类型。这意味着所有类型,无论是代表业务对象还是诸如数字之类的基本类型,都共享相同的基本功能。例如,任何类型的实例都可以通过调用其ToString方法转换为字符串。
类和接口
在传统的面向对象范式中,类型的唯一种类是类。在 C#中,还有几种其他类型,其中一种是接口。接口类似于一个不能持有数据的类。这意味着它只能定义行为(而不是状态),这允许多重继承以及规范与实现的分离。
属性、方法和事件
在纯粹的面向对象范式中,所有函数都是方法。在 C#中,方法只是函数成员的一种,其中还包括属性和事件(还有其他类型)。属性是封装对象状态的一部分的函数成员,例如按钮的颜色或标签的文本。事件是简化对象状态更改处理的函数成员。
虽然 C# 主要是面向对象的语言,但它也借鉴了函数式编程范式,具体来说:
函数可以被视为值
使用委托,C#允许将函数作为值传递给其他函数,并从其他函数返回。
C# 支持纯度模式
函数式编程的核心是避免使用值会变化的变量,而是采用声明式模式。C#具有关键功能来帮助这些模式,包括能够即时编写“捕获”变量的未命名函数(lambda 表达式),以及通过查询表达式执行列表或响应式编程。C#还提供了记录,使得编写不可变(只读)类型变得更加容易。
类型安全
C#主要是一种类型安全的语言,意味着类型的实例只能通过它们定义的协议进行交互,从而确保每种类型的内部一致性。例如,C#会阻止你像操作整数类型一样操作字符串类型。
具体来说,C#支持静态类型,这意味着语言在编译时强制实施类型安全性。这是对运行时类型安全性的补充。
静态类型化甚至在程序运行之前就消除了大量错误。它将负担从运行时单元测试转移到编译器,以验证程序中所有类型的正确匹配。这使得大型程序更易管理,更可预测,更健壮。此外,静态类型化允许工具如 Visual Studio 中的智能感知帮助编程,因为它知道给定变量的类型,从而知道可以在该变量上调用哪些方法。此类工具还可以识别程序中使用变量、类型或方法的所有地方,从而支持可靠的重构。
注意
C#还允许你的代码的部分通过dynamic关键字进行动态类型化。然而,C#仍然是一种主要静态类型的语言。
C# 也被称为强类型语言,因为其类型规则严格执行(无论是静态还是运行时)。例如,你不能用浮点数直接调用设计为接受整数的函数,除非你首先显式将浮点数转换为整数。这有助于防止错误。
内存管理
C#依赖运行时执行自动内存管理。公共语言运行时具有作为程序一部分执行的垃圾收集器,回收不再引用的对象的内存。这使得程序员无需显式地为对象释放内存,消除了在 C++等语言中遇到的指针错误问题。
C#并未消除指针:它仅使它们对大多数编程任务不必要。对于性能关键的热点和互操作性,可以在标记为unsafe的块中使用指针和显式内存分配。
平台支持
C#具有支持以下平台的运行时:
-
Windows 7+桌面(用于富客户端、Web、服务器和命令行应用程序)
-
macOS(用于 Web 和命令行应用程序,以及通过 Mac Catalyst 的富客户端应用程序)
-
Linux(用于 Web 和命令行应用程序)
-
Android 和 iOS(用于移动应用程序)
-
Windows 10 设备(Xbox、Surface Hub 和 HoloLens)通过 UWP
还有一种称为Blazor的技术,可以将 C#编译为在浏览器中运行的 WebAssembly。
CLR、BCL 和运行时
C# 程序的运行时支持包括 公共语言运行时 和 基础类库。运行时还可以包括一个更高级的 应用层,其中包含用于开发富客户端、移动或 Web 应用程序的库(见 图 1-1)。存在不同的运行时以支持不同类型的应用程序和不同的平台。

图 1-1. 运行时架构
公共语言运行时
公共语言运行时(CLR)提供了自动内存管理和异常处理等重要的运行时服务。(“公共”一词指的是同一个运行时可以被其他托管编程语言共享,如 F#、Visual Basic 和 Managed C++。)
C# 被称为 托管语言,因为它将源代码编译为托管代码,这些代码以 中间语言(IL)表示。CLR 将 IL 转换为机器的本机代码,如 X64 或 X86,通常在执行前进行。这称为即时(JIT)编译。还可以提供预编译来改善大型程序集或资源受限设备的启动时间(以及在开发移动应用程序时满足 iOS 应用商店规则)。
托管代码的容器称为 程序集。一个程序集不仅包含 IL,还包含类型信息(元数据)。有了元数据,程序集可以引用其他程序集中的类型,而无需额外的文件。
注意
使用 Microsoft 的 ildasm 工具可以检查和分解汇编内容。而使用 ILSpy 或 JetBrain 的 dotPeek 等工具,可以进一步反编译 IL 到 C#。因为 IL 比本机机器码更高级,所以反编译器可以相当好地重建原始的 C#。
程序可以查询其自身的元数据(反射),甚至在运行时生成新的 IL(反射.emit)。
基础类库
CLR 总是随附一组称为 基础类库(BCL)的程序集。BCL 为程序员提供核心功能,例如集合、输入/输出、文本处理、XML/JSON 处理、网络、加密、互操作、并发和并行编程。
BCL 还实现了 C# 语言本身需要的类型(例如枚举、查询、异步等功能),并允许您显式访问 CLR 的功能,如反射和内存管理。
运行时
运行时(也称为框架)是一个可部署的单元,您可以下载并安装。运行时包括一个 CLR(及其 BCL),以及一个特定于您正在编写的应用程序类型的可选的 应用层 —— Web、移动、富客户端等。(如果您正在编写命令行控制台应用程序或非 UI 库,则不需要应用层。)
在编写应用程序时,您针对特定运行时,这意味着您的应用程序使用并依赖运行时提供的功能。您的运行时选择还决定了应用程序将支持哪些平台。
下表列出了主要的运行时选项:
| 应用层 | CLR/BCL | 程序类型 | 运行于... | |
|---|---|---|---|---|
| ASP.NET | .NET 8 | Web | Windows、Linux、macOS | |
| Windows Desktop | .NET 8 | Windows | Windows 10+ | |
| WinUI 3 | .NET 8 | Windows | Windows 10+ | |
| MAUI | .NET 8 | 移动、桌面 | iOS、Android、macOS、Windows 10+ | |
| .NET Framework | .NET Framework | Web, Windows | Windows 7+ |
图 1-2 在图形上显示了这些信息,同时也作为本书内容的指南。

图 1-2. C# 运行时
.NET 8
.NET 8 是微软的旗舰开源运行时。您可以编写运行在 Windows、Linux 和 macOS 上的 Web 和控制台应用程序;运行在 Windows 10+ 和 macOS 上的富客户端应用程序;以及运行在 iOS 和 Android 上的移动应用程序。本书重点介绍 .NET 8 的 CLR 和 BCL。
与 .NET Framework 不同,.NET 8 未预装在 Windows 机器上。如果尝试在没有正确运行时的情况下运行 .NET 8 应用程序,将出现消息引导您访问网页下载运行时。您可以通过创建自包含部署来避免这种情况,该部署包括应用程序所需的运行时部分。
注
.NET 的更新历史如下:.NET Core 1.x → .NET Core 2.x → .NET Core 3.x → .NET 5 → .NET 6 → .NET 7 → .NET 8. 在 .NET Core 3 之后,Microsoft 删除了名称中的“Core”,并跳过了版本 4,以避免与* .NET Framework* 4.x 混淆,后者是所有前述运行时的先行版本但仍得到支持并广泛使用。
这意味着在 .NET Core 1.x → .NET 7 下编译的程序集在大多数情况下可以在 .NET 8 下运行而无需修改。相比之下,在任何版本的 .NET Framework 下编译的程序集通常与 .NET 8 不兼容。
Windows 桌面和 WinUI 3
为了编写在 Windows 10 及更高版本上运行的富客户端应用程序,您可以选择经典的 Windows 桌面 API(Windows Forms 和 WPF)和 WinUI 3. Windows 桌面 API 是 .NET 桌面运行时的一部分,而 WinUI 3 则属于Windows 应用程序 SDK(需要单独下载)。
经典的 Windows 桌面 API 自 2006 年以来存在,并且享有出色的第三方库支持,以及在诸如 StackOverflow 等网站上提供大量问题解答。WinUI 3 在 2022 年发布,旨在编写现代沉浸式应用程序,具备最新的 Windows 10+ 控件。它是Universal Windows Platform(UWP)的后继者。
MAUI
MAUI(多平台应用程序 UI)主要设计用于创建 iOS 和 Android 的移动应用程序,尽管也可用于通过 Mac Catalyst 和 WinUI 3 在 macOS 和 Windows 上运行的桌面应用程序。MAUI 是 Xamarin 的演变,允许单个项目目标多个平台。
注意
对于跨平台桌面应用程序,第三方库 Avalonia 提供了一个 MAUI 的替代方案。Avalonia 还可以在 Linux 上运行,结构比 MAUI 更简单(因为它在没有 Catalyst/WinUI 间接层的情况下运行)。Avalonia 的 API 类似于 WPF,并且还提供一个名为 XPF 的商业附加组件,几乎完全兼容 WPF。
.NET Framework
.NET Framework 是微软最初仅限于 Windows 的运行时,用于编写运行于 Windows 桌面/服务器上的 Web 和丰富客户端应用程序。尽管没有计划推出主要新版本,但由于现有应用程序的丰富性,微软将继续支持和维护当前的 4.8 版本。
在.NET Framework 中,CLR/BCL 与应用程序层集成。在.NET 8 下重新编译.NET Framework 编写的应用程序通常需要一些修改。.NET Framework 的一些功能在.NET 8 中不存在(反之亦然)。
.NET Framework 与 Windows 预装,并通过 Windows 更新自动打补丁。当你的目标是.NET Framework 4.8 时,你可以使用 C# 7.3 及更早版本的功能。(你可以通过在项目文件中指定更高语言版本来覆盖此设置,这将解锁所有最新语言功能,除了需要新运行时支持的那些功能。)
注意
单词“.NET”长期以来被用作涵盖包括“.NET”在内的任何技术的总称(.NET Framework、.NET Core、.NET Standard 等)。
这意味着微软将.NET Core 重命名为.NET,造成了不幸的歧义。在本书中,当出现歧义时,我们将把新的.NET 称为.NET 5+。为了指代.NET Core 及其后继版本,我们将使用短语“.NET Core 和.NET 5+”。
为增加混淆,.NET (5+) 是一个框架,但与.NET Framework截然不同。因此,在可能的情况下,我们将更倾向于使用术语运行时而非框架。
专用运行时
还有以下专用运行时:
-
Unity 是一个游戏开发平台,允许用 C#编写游戏逻辑。
-
Universal Windows Platform(UWP)旨在编写在 Windows 10+桌面和设备上运行的触摸优先应用程序,包括 Xbox、Surface Hub 和 HoloLens。UWP 应用程序是沙盒化的,并通过 Windows Store 发布。UWP 使用.NET Core 2.2 CLR/BCL 的一个版本,不太可能更新这种依赖关系;相反,微软建议用户切换到其现代替代品 WinUI 3。但由于 WinUI 3 仅支持 Windows 桌面,UWP 仍然在定位 Xbox、Surface Hub 和 HoloLens 时具有专用应用程序的市场。
-
.NET Micro Framework 用于在资源极为有限的嵌入式设备上运行 .NET 代码(不到一兆字节)。
在 SQL Server 中还可以运行托管代码。通过 SQL Server CLR 集成,你可以用 C# 编写自定义函数、存储过程和聚合函数,然后从 SQL 中调用它们。这与 .NET Framework 和一个特殊的“托管”CLR 结合使用,强制实施沙箱以保护 SQL Server 进程的完整性。
C# 简史
下面是每个 C# 版本中新功能的逆时代顺序,以便读者了解老版本语言的好处。
C# 12 中的新功能
C# 12 随 Visual Studio 2022 发布,并且当你的目标是 .NET 8 时使用。
集合表达式
而不是像下面这样初始化数组:
char[] vowels = {'a','e','i','o','u'};
现在可以使用方括号(一个 集合表达式):
char[] vowels = ['a','e','i','o','u'];
集合表达式有两个主要优点。首先,相同的语法也适用于其他集合类型,例如列表和集合(甚至低级别的 span 类型):
List<char> list = ['a','e','i','o','u'];
HashSet<char> set = ['a','e','i','o','u'];
ReadOnlySpan<char> span = ['a','e','i','o','u'];
第二,它们是 目标类型推断,这意味着在编译器可以推断出类型的其他情况下,可以省略类型,例如在调用方法时:
Foo (['a','e','i','o','u']);
void Foo (char[] letters) { ... }
更多详情请参见 “集合初始化器和集合表达式”。
类和结构体中的主要构造函数
从 C# 12 开始,你可以直接在类(或结构体)声明后包含一个参数列表:
class Person (string firstName, string lastName)
{
public void Print() => Console.WriteLine (firstName + " " + lastName);
}
这指示编译器自动构建一个 主要构造函数,允许以下操作:
Person p = new Person ("Alice", "Jones");
p.Print(); // Alice Jones
这个特性自 C# 9 开始存在于记录(records)中——在那里它们的行为稍有不同。对于记录,编译器(默认情况下)为每个主要构造函数参数生成一个公共的只读属性。对于类和结构体来说并非如此;要达到相同的结果,必须显式定义这些属性:
class Person (string firstName, string lastName)
{
public string FirstName { get; set; } = firstName;
public string LastName { get; set; } = lastName;
}
主要构造函数在简单场景中运行良好。我们在 “主要构造函数(C# 12)” 中描述了它们的细微差别和限制。
默认的 Lambda 参数
就像普通方法可以定义带有默认值的参数一样:
void Print (string message = "") => Console.WriteLine (message);
因此,lambda 表达式也可以:
var print = (string message = "") => Console.WriteLine (message);
print ("Hello");
print ();
这个特性在诸如 ASP.NET Minimal API 等库中非常有用。
给任何类型取别名
C# 一直允许你通过 using 指令给一个简单或通用类型取别名:
using ListOfInt = System.Collections.Generic.List<int>;
var list = new ListOfInt();
从 C# 12 开始,这种方法也适用于其他类型,例如数组和元组:
using NumberList = double[];
using Point = (int X, int Y);
NumberList numbers = { 2.5, 3.5 };
Point p = (3, 4);
其他新特性
C# 12 还支持 内联数组,通过 [System.Runtime.CompilerServices.InlineArray] 属性。这允许在结构体中创建固定大小的数组而无需在不安全的上下文中进行,并且主要用于运行时 API 中。
C# 11 中的新功能
C# 11 随 Visual Studio 2022 发布,并且当你的目标是 .NET 7 时默认使用。
原始字符串字面量
用三个或更多引号字符包裹字符串创建原始字符串字面量,它几乎可以包含任何字符序列,无需转义或重复:
string raw = """<file path="c:\temp\test.txt"></file>""";
原始字符串字面量可以是多行的,并且可以通过$前缀进行插值:
string multiLineRaw = $"""
Line 1
Line 2
The date and time is {DateTime.Now}
""";
在原始字符串字面量前使用两个(或更多)$字符可以改变插值序列,从单个大括号变为两个(或更多)大括号,允许你在字符串本身中包含大括号:
Console.WriteLine ($$"""{ "TimeStamp": "{{DateTime.Now}}" }""");
// Output: *{ "TimeStamp": "01/01/2024 12:13:25 PM" }*
我们在“原始字符串字面量(C# 11)”和“字符串插值”章节中涵盖了此功能的细微差别。
UTF-8 字符串
使用u8后缀,你可以创建使用 UTF-8 编码而不是 UTF-16 编码的字符串字面量。此功能适用于高级场景,例如在性能热点中低级处理 JSON 文本:
ReadOnlySpan<byte> utf8 = "ab→cd"u8; // Arrow symbol consumes 3 bytes
Console.WriteLine (utf8.Length); // 7
底层类型是ReadOnlySpan<byte>(第二十三章),您可以通过调用其ToArray()方法将其转换为字节数组。
列表模式
列表模式匹配方括号中的一系列元素,并与任何可计数的集合类型一起使用(具有Count或Length属性以及int类型或System.Index类型的索引器):
int[] numbers = { 0, 1, 2, 3, 4 };
Console.WriteLine (numbers is [0, 1, 2, 3, 4]); // True
下划线匹配任意值的单个元素,而两个点匹配零个或多个元素(切片):
Console.WriteLine (numbers is [_, 1, .., 4]); // True
切片后可以跟随var模式—详见“列表模式”。
必需成员
将required修饰符应用于字段或属性会强制类或结构的使用者在构造时通过对象初始化器填充该成员:
Asset a1 = new Asset { Name = "House" }; // OK
Asset a2 = new Asset(); // Error: will not compile!
class Asset { public required string Name; }
使用此功能,您可以避免编写具有长参数列表的构造函数,从而简化子类化。如果您希望编写构造函数,可以通过在其上应用[SetsRequiredMembers]属性来绕过该构造函数的必需成员限制—详见“必需成员(C# 11)”。
静态虚拟/抽象接口成员
从 C# 11 开始,接口可以将成员声明为static virtual或static abstract:
public interface IParsable<TSelf>
{
static abstract TSelf Parse (string s);
}
这些成员在类或结构中以静态函数实现,并可以通过约束类型参数进行多态调用。
T ParseAny<T> (string s) where T : IParsable<T> => T.Parse (s);
运算符函数也可以声明为static virtual或static abstract。
更多细节,请参见“静态虚拟/抽象接口成员”和“静态多态性”。我们还描述了如何通过反射调用静态抽象成员的方法,详见“调用静态虚拟/抽象接口成员”。
泛型数学
System.Numerics.INumber<TSelf> 接口(在 .NET 7 中新增)统一了所有数值类型的算术操作,允许编写如下泛型方法:
T Sum<T> (T[] numbers) where T : INumber<T>
{
T total = T.Zero;
foreach (T n in numbers)
total += n; // Invokes addition operator for any numeric type
return total;
}
int intSum = Sum (3, 5, 7);
double doubleSum = Sum (3.2, 5.3, 7.1);
decimal decimalSum = Sum (3.2m, 5.3m, 7.1m);
INumber<TSelf> 被所有实数和整数数字类型(以及char)实现,并包含多个接口,包括以下静态抽象操作符定义:
static abstract TResult operator + (TSelf left, TOther right);
我们在“多态运算符”和“通用数学”中进行了讨论。
其他新特性
具有file访问修饰符的类型只能从同一文件中访问,并且旨在在源生成器内使用:
file class Foo { ... }
C# 11 还引入了检查运算符(参见“检查运算符”),用于定义在checked块内调用的运算符函数(这是实现通用数学的完整实现所需的)。C# 11 还放宽了结构体构造函数中必须填充每个字段的要求(参见“结构构造语义”)。
此外,C# 11 在面向 .NET 7 或更高版本时增强了在运行时(32 或 64 位)匹配进程地址空间的nint和nuint本机大小整数类型,这些类型在 C# 9 中引入时与其底层运行时类型(IntPtr 和 UIntPtr)之间的编译时区别已经消失。详见“本机大小整数”以获取详细讨论。
C# 10 中的新特性
C# 10 随 Visual Studio 2022 发布,并在目标 .NET 6 时使用。
文件范围命名空间
在常见情况下,文件中的所有类型都定义在单个命名空间中,C# 10 中的文件范围命名空间声明可以减少混乱并消除不必要的缩进级别:
namespace MyNamespace; // Applies to everything that follows in the file.
class Class1 {} // inside MyNamespace
class Class2 {} // inside MyNamespace
全局 using 指令
当您在using指令前加上global关键字时,它会将该指令应用于项目中的所有文件:
global using System;
global using System.Collection.Generic;
这使您可以避免在每个文件中重复相同的指令。global using 指令与 using static 兼容。
此外,.NET 6 项目现在支持隐式全局 using 指令:如果项目文件中的ImplicitUsings元素设置为 true,则会自动导入最常用的命名空间(基于 SDK 项目类型)。详见“全局 using 指令”获取更多细节。
对于匿名类型的非破坏性突变
C# 9 引入了with关键字,用于对记录执行非破坏性突变。在 C# 10 中,with关键字也适用于匿名类型:
var a1 = new { A = 1, B = 2, C = 3, D = 4, E = 5 };
var a2 = a1 with { E = 10 };
Console.WriteLine (a2); // { A = 1, B = 2, C = 3, D = 4, E = 10 }
新的解构语法
C# 7 引入了元组(或任何具有Deconstruct方法的类型)的解构语法。C# 10 深化了此语法,允许在同一解构中混合赋值和声明:
var point = (3, 4);
double x = 0;
(x, double y) = point;
结构中的字段初始化器和无参数构造函数
从 C# 10 开始,您可以在结构体中包含字段初始化程序和无参数构造函数(参见“结构体”)。这些仅在显式调用构造函数时执行,因此可以轻松地通过default关键字绕过。此功能主要为结构记录的利益而引入。
记录结构体
记录最早在 C# 9 中引入,作为增强编译类。在 C# 10 中,记录还可以是结构体:
record struct Point (int X, int Y);
否则规则相似:记录结构体与类结构体具有几乎相同的特性(参见“记录”)。唯一的例外是记录结构体上的编译器生成的属性是可写的,除非您在记录声明前加上readonly关键字。
Lambda 表达式增强
对 lambda 表达式的语法进行了多方面增强。首先,允许隐式类型化(var):
var greeter = () => "Hello, world";
lambda 表达式的隐式类型为Action或Func委托,在这种情况下,greeter的类型为Func<string>。必须明确声明任何参数类型:
var square = (int x) => x * x;
其次,lambda 表达式可以指定返回类型:
var sqr = int (int x) => x;
这主要是为了改善复杂嵌套 lambda 的编译器性能。
第三,您可以将 lambda 表达式传递给object、Delegate或Expression类型的方法参数:
M1 (() => "test"); // Implicitly typed to Func<string>
M2 (() => "test"); // Implicitly typed to Func<string>
M3 (() => "test"); // Implicitly typed to Expression<Func<string>>
void M1 (object x) {}
void M2 (Delegate x) {}
void M3 (Expression x) {}
最后,您可以将属性应用于 lambda 表达式的编译生成目标方法(以及其参数和返回值):
Action a = [Description("test")] () => { };
参见“将属性应用于 Lambda 表达式”以获取更多详细信息。
嵌套属性模式
在 C# 10 中,可以使用以下简化的语法进行嵌套属性模式匹配(参见“属性模式”):
var obj = new Uri ("https://www.linqpad.net");
if (obj is Uri { Scheme.Length: 5 }) ...
这相当于:
if (obj is Uri { Scheme: { Length: 5 }}) ...
CallerArgumentExpression
将[CallerArgumentExpression]属性应用于方法参数,可以从调用站点捕获参数表达式:
Print (Math.PI * 2);
void Print (double number,
[CallerArgumentExpression("number")] string expr = null)
=> Console.WriteLine (expr);
// Output: Math.PI * 2
此功能主要用于验证和断言库(参见“CallerArgumentExpression”)。
其他新功能
C# 10 中的#line指令已增强,允许指定列和范围。
C# 10 中的插值字符串可以是常量,只要插入的值是常量即可。
记录可以在 C# 10 中封闭ToString()方法。
C#的明确赋值分析已得到改进,使得以下表达式等均可工作:
if (foo?.TryParse ("123", out var number) ?? false)
Console.WriteLine (number);
(在 C# 10 之前,编译器会生成错误:“使用未分配的局部变量‘number’。”)
C# 9.0 中的新功能
C# 9.0 与Visual Studio 2019一同发布,并在您的目标为.NET 5 时使用。
顶级语句
通过顶级语句(参见“顶级语句”),您可以编写一个程序,而无需Main方法和Program类的包袱:
using System;
Console.WriteLine ("Hello, world");
顶层语句可以包含方法(作为本地方法)。您还可以通过“magic” args 变量访问命令行参数,并将值返回给调用者。顶层语句后面可以跟随类型和命名空间声明。
仅初始化的设置器
在属性声明中,仅初始化的设置器(见“仅初始化的设置器”)使用 init 关键字而不是 set 关键字:
class Foo { public int ID { get; init; } }
这表现得像一个只读属性,但也可以通过对象初始化程序进行设置:
var foo = new Foo { ID = 123 };
这使得可以创建可通过对象初始化程序而不是构造函数填充的不可变(只读)类型,并有助于避免接受大量可选参数的构造函数的反模式。仅初始化的设置器还允许在记录中使用时进行非破坏性变异。
记录
记录(见“记录”)是一种特殊类型的类,旨在与不可变数据很好地配合。其最特别的功能是通过新关键字(with)支持非破坏性变异:
Point p1 = new Point (2, 3);
Point p2 = p1 with { Y = 4 }; // p2 is a copy of p1, but with Y set to 4
Console.WriteLine (p2); // Point { X = 2, Y = 4 }
record Point
{
public Point (double x, double y) => (X, Y) = (x, y);
public double X { get; init; }
public double Y { get; init; }
}
在简单情况下,记录还可以消除定义属性和编写构造函数和析构函数的样板代码。我们可以用以下方式替换我们的 Point 记录定义,而不会丧失功能:
record Point (double X, double Y);
像元组一样,默认情况下,记录展示结构相等性。记录可以子类化其他记录,并且可以包含类可以包含的相同结构。编译器在运行时将记录实现为类。
模式匹配改进
关系模式(见“模式”)允许在模式中出现 <, >, <=, 和 >= 操作符:
string GetWeightCategory (decimal bmi) => bmi switch {
< 18.5m => "underweight",
< 25m => "normal",
< 30m => "overweight",
_ => "obese" };
使用模式组合器,您可以通过三个新关键字(and, or, 和 not)组合模式:
bool IsVowel (char c) => c is 'a' or 'e' or 'i' or 'o' or 'u';
bool IsLetter (char c) => c is >= 'a' and <= 'z'
or >= 'A' and <= 'Z';
与 && 和 || 操作符一样,and 的优先级高于 or。您可以用括号覆盖这一点。
not 组合器可以与类型模式一起使用,以测试对象是否为(非)类型:
if (obj is not string) ...
靶向类型的新表达式
在构造对象时,C# 9 允许您在编译器可以明确推断类型名称时省略类型名称:
System.Text.StringBuilder sb1 = new();
System.Text.StringBuilder sb2 = new ("Test");
当变量声明和初始化位于代码的不同部分时,这尤其有用:
class Foo
{
System.Text.StringBuilder sb;
public Foo (string initialValue) => sb = new (initialValue);
}
并且在以下场景中:
MyMethod (new ("test"));
void MyMethod (System.Text.StringBuilder sb) { ... }
更多信息,请参阅“靶向类型的新表达式”。
互操作性改进
C# 9 引入函数指针(见“函数指针”和“使用函数指针进行回调”)。它们的主要目的是允许非托管代码调用 C#中的静态方法,而无需委托实例的开销,并且可以绕过 P/Invoke 层,当参数和返回类型是可直接传送(在每一侧都表示相同)时。
C# 9 还引入了nint和nuint本机大小的整数类型(参见“本机大小的整数”),在运行时映射到System.IntPtr和System.UIntPtr。在编译时,它们表现得像支持算术运算的数值类型。
其他新特性
此外,C# 9 现在还允许你:
-
重写方法或只读属性,使其返回更派生的类型(参见“协变返回类型”)。
-
对本地函数应用属性(参见“属性”)。
-
对 lambda 表达式或本地函数应用
static关键字,以确保不会意外捕获本地或实例变量(参见“静态 lambda”)。 -
通过编写
GetEnumerator扩展方法,使任何类型与foreach语句一起工作。 -
定义模块初始化器方法,该方法在装配体首次加载时执行,通过在(静态无参数)方法上应用
[ModuleInitializer]属性。 -
将“丢弃”(下划线符号)作为 lambda 表达式参数。
-
编写扩展部分方法,这些方法是强制实现的,可用于场景,如 Roslyn 的新源生成器(参见“扩展部分方法”)。
-
将属性应用于方法、类型或模块,以防止本地变量在运行时被初始化(参见“[SkipLocalsInit]”)。
C# 8.0 的新功能
C# 8.0 首次与Visual Studio 2019一起发布,并在今天仍在使用,当你的目标是.NET Core 3 或.NET Standard 2.1 时。
索引和范围
索引和范围简化了与数组的元素或部分(或底层类型Span<T>和ReadOnlySpan<T>)的工作。
索引允许你通过使用^运算符相对于数组的末尾引用元素。¹指的是最后一个元素,²指的是倒数第二个元素,依此类推:
char[] vowels = new char[] {'a','e','i','o','u'};
char lastElement = vowels [¹]; // 'u'
char secondToLast = vowels [²]; // 'o'
范围允许你使用..运算符“切片”数组:
char[] firstTwo = vowels [..2]; // 'a', 'e'
char[] lastThree = vowels [2..]; // 'i', 'o', 'u'
char[] middleOne = vowels [2..3] // 'i'
char[] lastTwo = vowels [²..]; // 'o', 'u'
C#通过Index和Range类型实现索引和范围:
Index last = ¹;
Range firstTwoRange = 0..2;
char[] firstTwo = vowels [firstTwoRange]; // 'a', 'e'
你可以通过定义带有Index或Range参数类型的索引器来支持你自己的类:
class Sentence
{
string[] words = "The quick brown fox".Split();
public string this [Index index] => words [index];
public string[] this [Range range] => words [range];
}
欲了解更多信息,请参见“索引和范围”。
空值合并赋值
??=运算符仅在变量为 null 时才分配变量。而不是
if (s == null) s = "Hello, world";
现在你可以这样写:
s ??= "Hello, world";
使用声明
如果省略using语句后的括号和语句块,则成为using 声明。当执行超出封闭语句块时,资源将被释放:
if (File.Exists ("file.txt"))
{
using var reader = File.OpenText ("file.txt");
Console.WriteLine (reader.ReadLine());
...
}
在这种情况下,当执行超出if语句块时,reader将被释放。
只读成员
C# 8 允许你对结构体的函数应用readonly修饰符,确保如果函数试图修改任何字段,则会生成编译时错误:
struct Point
{
public int X, Y;
public readonly void ResetX() => X = 0; // Error!
}
如果 readonly 函数调用非 readonly 函数,则编译器会生成警告(并防御性地复制结构体以避免可能的突变)。
静态局部方法
将 static 修饰符添加到局部方法可以防止其访问封闭方法的局部变量和参数。这有助于减少耦合,并使局部方法能够随意声明变量,而无需担心与包含方法中的变量冲突。
默认接口成员
在 C# 8 中,您可以为接口成员添加默认实现,从而使得实现变为可选:
interface ILogger
{
void Log (string text) => Console.WriteLine (text);
}
这意味着您可以向接口添加成员而不会破坏现有实现。必须显式通过接口调用默认实现:
((ILogger)new Logger()).Log ("message");
接口还可以定义静态成员(包括字段),可以从默认实现内部的代码中访问:
interface ILogger
{
void Log (string text) => Console.WriteLine (Prefix + text);
static string Prefix = "";
}
或者从接口外部,除非在静态接口成员上通过可访问性修饰符(如 private、protected 或 internal)进行限制:
ILogger.Prefix = "File log: ";
实例字段是被禁止的。更多细节,请参见 “默认接口成员”。
Switch 表达式
自 C# 8 开始,您可以在 表达式 上下文中使用 switch:
string cardName = cardNumber switch // assuming cardNumber is an int
{
13 => "King",
12 => "Queen",
11 => "Jack",
_ => "Pip card" // equivalent to 'default'
};
更多示例,请参见 “Switch 表达式”。
元组、位置和属性模式
C# 8 支持三种新模式,主要用于增强 switch 语句/表达式的功能(请参见 “模式”)。元组模式 允许您在多个值上进行 switch:
int cardNumber = 12; string suite = "spades";
string cardName = (cardNumber, suite) switch
{
(13, "spades") => "King of spades",
(13, "clubs") => "King of clubs",
...
};
位置模式 允许为公开解构器的对象使用类似的语法,属性模式 允许您匹配对象的属性。您可以在 switch 和 is 运算符中同时使用所有模式。以下示例使用 属性模式 来测试 obj 是否为具有长度为 4 的字符串:
if (obj is string { Length:4 }) ...
可空引用类型
而 可空值类型 将 nullability 带给值类型,可空引用类型 则相反,并为引用类型带来(某种程度的)非空值性,旨在帮助避免 NullReferenceException。可空引用类型通过编译器纯粹形式的警告或错误引入了一定的安全级别,用于检测代码是否有可能生成 NullReferenceException。
可空引用类型可以在项目级别启用(通过 .csproj 项目文件中的 Nullable 元素)或在代码中启用(通过 #nullable 指令)。启用后,编译器将非空值性设置为默认值:如果要使引用类型接受 null 值,必须应用 ? 后缀以指示 可空引用类型:
#nullable enable // Enable nullable reference types from this point on
string s1 = null; // Generates a compiler warning! (s1 is non-nullable)
string? s2 = null; // OK: s2 is *nullable reference type*
如果类型未标记为可为空,未初始化的字段会生成警告,还有可能发生 NullReferenceException 的可空引用类型解引用也会如此:
void Foo (string? s) => Console.Write (s.Length); // Warning (.Length)
要消除警告,您可以使用 null-forgiving operator (!):
void Foo (string? s) => Console.Write (s!.Length);
有关全面讨论,请参见“可为空引用类型”。
异步流
之前的 C# 8 中,你可以使用yield return来编写迭代器,或者使用await来编写异步函数。但不能既编写迭代器又编写等待的异步函数。C# 8 通过引入异步流来解决这个问题:
async IAsyncEnumerable<int> RangeAsync (
int start, int count, int delay)
{
for (int i = start; i < start + count; i++)
{
await Task.Delay (delay);
yield return i;
}
}
await foreach语句消耗一个异步流:
await foreach (var number in RangeAsync (0, 10, 100))
Console.WriteLine (number);
更多信息,请参见“异步流”。
C# 7.x 新功能概述
C# 7.x 首次发布时可以在 Visual Studio 2017 中使用。C# 7.3 仍然被 Visual Studio 2019 使用,当您针对.NET Core 2、.NET Framework 4.6 到 4.8 或.NET Standard 2.0 进行定位时。
C# 7.3
C# 7.3 对现有功能进行了微小的改进,例如使得可以在元组上使用等值运算符,改进的重载解析以及能够将属性应用于自动属性的支持:
[field:NonSerialized]
public int MyProperty { get; set; }
C# 7.3 还基于 C# 7.2 的高级低分配编程特性,具备重新分配引用局部变量的能力,不需要在索引fixed字段时固定内存,以及使用stackalloc支持字段初始化:
int* pointer = stackalloc int[] {1, 2, 3};
Span<int> arr = stackalloc [] {1, 2, 3};
注意,栈分配的内存可以直接分配给一个Span<T>。我们在第二十三章中描述了 Span 及其用途。
C# 7.2
C# 7.2 增加了一个新的private protected修饰符(internal和protected的交集),以及在调用方法时跟随命名参数使用位置参数的能力,以及readonly结构。readonly结构强制所有字段为readonly,以帮助声明意图,并允许编译器更大的优化自由度:
readonly struct Point
{
public readonly int X, Y; // X and Y must be readonly
}
C# 7.2 还增加了专门的功能,以帮助进行微优化和低分配编程:请参见“in 修饰符”、“引用局部变量”、“引用返回”和“引用结构”。
C# 7.1
从 C# 7.1 开始,当使用default关键字时,如果类型可以被推断,你可以省略类型:
decimal number = default; // number is decimal
C# 7.1 还放宽了 switch 语句的规则(使得你可以在泛型类型参数上模式匹配),允许程序的Main方法是异步的,并允许推断元组元素的名称:
var now = DateTime.Now;
var tuple = (now.Hour, now.Minute, now.Second);
数字文字改进
C# 7 中的数字文字可以包括下划线,以提高可读性。这些被称为数字分隔符,在编译器中会被忽略:
int million = 1_000_000;
二进制文字可以使用0b前缀指定:
var b = 0b1010_1011_1100_1101_1110_1111;
出参变量和丢弃
C# 7 使得调用包含out参数的方法变得更简单。首先,你现在可以在不声明out 变量的情况下快速创建out 变量(请参见“out 变量和丢弃”):
bool successful = int.TryParse ("123", out int result);
Console.WriteLine (result);
当调用具有多个out参数的方法时,你可以用下划线字符丢弃不感兴趣的参数:
SomeBigMethod (out _, out _, out _, out int x, out _, out _, out _);
Console.WriteLine (x);
类型模式和模式变量
您还可以使用is运算符即时引入变量。这些称为模式变量(参见“引入模式变量”):
void Foo (object x)
{
if (x is string s)
Console.WriteLine (s.Length);
}
switch语句还支持类型模式,因此你可以根据类型以及常量进行切换(参见“类型切换”)。你可以使用when子句指定条件,并在null值上进行切换:
switch (x)
{
case int i:
Console.WriteLine ("It's an int!");
break;
case string s:
Console.WriteLine (s.Length); // We can use the s variable
break;
case bool b when b == true: // Matches only when b is true
Console.WriteLine ("True");
break;
case null:
Console.WriteLine ("Nothing");
break;
}
局部方法
局部方法是在另一个函数内声明的方法(参见“局部方法”):
void WriteCubes()
{
Console.WriteLine (Cube (3));
Console.WriteLine (Cube (4));
Console.WriteLine (Cube (5));
int Cube (int value) => value * value * value;
}
局部方法仅对包含函数可见,并且可以像 lambda 表达式一样捕获局部变量。
更多的表达式主体成员
C# 6 引入了方法、只读属性、运算符和索引器的表达式主体“胖箭头”语法。C# 7 将其扩展到构造函数、读写属性和终结器:
public class Person
{
string name;
public Person (string name) => Name = name;
public string Name
{
get => name;
set => name = value ?? "";
}
~Person () => Console.WriteLine ("finalize");
}
解构函数
C# 7 引入了解构模式(参见“解构函数”)。构造函数通常接受一组值(作为参数)并将它们分配给字段,而解构则相反,将字段分配回一组变量。我们可以为前面示例中的Person类编写如下的解构函数(除了异常处理):
public void Deconstruct (out string firstName, out string lastName)
{
int spacePos = name.IndexOf (' ');
firstName = name.Substring (0, spacePos);
lastName = name.Substring (spacePos + 1);
}
解构函数使用以下特殊语法调用:
var joe = new Person ("Joe Bloggs");
var (first, last) = joe; // Deconstruction
Console.WriteLine (first); // Joe
Console.WriteLine (last); // Bloggs
元组
可能是 C# 7 最显著的改进是对显式tuple支持(参见“元组”)。元组提供了一种简单的方式来存储一组相关的值:
var bob = ("Bob", 23);
Console.WriteLine (bob.Item1); // Bob
Console.WriteLine (bob.Item2); // 23
C#的新元组是使用System.ValueTuple<…>泛型结构的语法糖。但由于编译器的魔法,元组元素可以被命名:
var tuple = (name:"Bob", age:23);
Console.WriteLine (tuple.name); // Bob
Console.WriteLine (tuple.age); // 23
使用元组,函数可以返回多个值,而无需使用out参数或额外的类型包装:
static (int row, int column) GetFilePosition() => (3, 10);
static void Main()
{
var pos = GetFilePosition();
Console.WriteLine (pos.row); // 3
Console.WriteLine (pos.column); // 10
}
元组隐式支持解构模式,因此你可以轻松地将它们解构为单独的变量:
static void Main()
{
(int row, int column) = GetFilePosition(); // Creates 2 local variables
Console.WriteLine (row); // 3
Console.WriteLine (column); // 10
}
throw 表达式
在 C# 7 之前,throw始终是一个语句。现在它也可以出现在表达式主体的函数中作为一个表达式:
public string Foo() => throw new NotImplementedException();
throw表达式也可以出现在三元条件表达式中:
string Capitalize (string value) =>
value == null ? throw new ArgumentException ("value") :
value == "" ? "" :
char.ToUpper (value[0]) + value.Substring (1);
C# 6.0 新特性
C# 6.0 随Visual Studio 2015一同发布,具有新一代完全用 C#编写的编译器。被称为项目“Roslyn”的新编译器通过库公开了整个编译流水线,允许您对任意源代码执行代码分析。编译器本身是开源的,源代码位于https://github.com/dotnet/roslyn。
此外,C# 6.0 还引入了几个次要但重要的增强功能,主要旨在减少代码混乱。
空值条件 (“Elvis”) 运算符 (见 “空值运算符”) 避免在调用方法或访问类型成员之前显式检查 null。在下面的例子中,result 评估为 null 而不是抛出 NullReferenceException:
System.Text.StringBuilder sb = null;
string result = sb?.ToString(); // result is null
表达式体函数 (见 “方法”) 允许将由单个表达式组成的方法、属性、运算符和索引器以更简洁的方式编写,类似于 lambda 表达式的风格:
public int TimesTwo (int x) => x * 2;
public string SomeProperty => "Property value";
属性初始化器 (第三章) 允许您为自动属性分配初始值:
public DateTime TimeCreated { get; set; } = DateTime.Now;
初始化属性也可以是只读的:
public DateTime TimeCreated { get; } = DateTime.Now;
只读属性也可以在构造函数中设置,这样更容易创建不可变(只读)类型。
索引初始化器 (第四章) 允许对任何公开索引器的类型进行单步初始化:
var dict = new Dictionary<int,string>()
{
[3] = "three",
[10] = "ten"
};
字符串插值 (见 “字符串类型”) 提供了 string.Format 的简洁替代方法:
string s = $"It is {DateTime.Now.DayOfWeek} today";
异常过滤器 (见 “try 语句和异常”) 允许您对 catch 块应用条件:
string html;
try
{
html = await new HttpClient().GetStringAsync ("http://asef");
}
catch (WebException ex) when (ex.Status == WebExceptionStatus.Timeout)
{
...
}
using static (见 “命名空间”) 指令允许您导入类型的所有静态成员,以便可以不加限定地使用这些成员:
using static System.Console;
...
WriteLine ("Hello, world"); // WriteLine instead of Console.WriteLine
nameof (第三章) 运算符返回变量、类型或其他符号的名称作为字符串。这样可以避免在 Visual Studio 中重命名符号时破坏代码:
int capacity = 123;
string x = nameof (capacity); // x is "capacity"
string y = nameof (Uri.Host); // y is "Host"
最后,您现在可以在 catch 和 finally 块内部使用 await。
C# 5.0 新特性
C# 5.0 的主要新功能是通过两个新关键字 async 和 await 支持 异步函数。异步函数使 异步继续 更容易编写响应式和线程安全的富客户端应用程序。它们还使编写高并发和高效的 I/O 绑定应用程序变得轻松,而不会占用每个操作的线程资源。我们在 第十四章 中详细介绍了异步函数。
C# 4.0 新特性
C# 4.0 引入了四个重大改进:
动态绑定 (第 4 和 19 章节) 将 绑定 —— 解析类型和成员的过程 —— 从编译时延迟到运行时,并且在需要复杂反射代码的场景下非常有用。动态绑定在与动态语言和 COM 组件互操作时也非常有用。
可选参数 (第二章) 允许函数指定默认参数值,以便调用者可以省略参数,并且 命名参数 允许函数调用者通过名称而非位置标识参数。
在 C# 4.0 中放宽了类型变异规则(第 3 和 4 章),使得泛型接口和泛型委托中的类型参数可以标记为协变或逆变,从而允许更自然的类型转换。
COM 互操作性(第二十四章)在 C# 4.0 中通过三种方式进行了增强。首先,参数可以在不使用 ref 关键字的情况下按引用传递(特别在与可选参数结合时非常有用)。其次,包含 COM 互操作类型的程序集可以进行链接而不是引用。链接的互操作类型支持类型等价性,避免了对主互操作程序集的需求,从而消除了版本控制和部署问题的头痛。第三,从链接的互操作类型返回 COM 变体类型的函数被映射为 dynamic 而不是 object,消除了类型转换的需求。
C# 3.0 中的新特性
添加到 C# 3.0 的特性主要集中在语言集成查询(LINQ)功能上。LINQ 允许直接在 C# 程序中编写查询,并在静态上下文中检查其正确性,可以查询本地集合(如列表或 XML 文档)或远程数据源(如数据库)。支持 LINQ 的 C# 3.0 特性包括隐式类型本地变量、匿名类型、对象初始化程序、Lambda 表达式、扩展方法、查询表达式和表达式树。
隐式类型本地变量(var 关键字,第二章)允许您在声明语句中省略变量类型,让编译器推断类型。这不仅减少了冗余,还允许匿名类型(第四章),这是在最终 LINQ 查询输出中常用的简单类。您还可以隐式类型化数组(第二章)。
对象初始化程序(第三章)通过允许您在构造函数调用后内联设置属性来简化对象构建。对象初始化程序适用于命名类型和匿名类型。
Lambda 表达式(第四章)是编译器即时创建的迷你函数;它们在“流畅”的 LINQ 查询中尤其有用(第八章)。
扩展方法(第四章)通过向现有类型添加新方法(而不更改类型的定义)来扩展类型,使静态方法感觉像实例方法。LINQ 的查询操作符就是以扩展方法实现的。
查询表达式(第八章)提供了一种更高级的语法,用于编写 LINQ 查询,当处理多个序列或范围变量时可以大幅简化操作。
表达式树(第八章)是描述分配给特殊类型Expression<TDelegate>的 lambda 表达式的迷你代码文档对象模型(DOM)。表达式树使得 LINQ 查询可以在远程执行(例如在数据库服务器上),因为它们可以在运行时进行内省和翻译(例如转换成 SQL 语句)。
C# 3.0 还添加了自动属性和部分方法。
自动属性(第三章)减少了编写仅get/set私有后备字段的属性所需的工作量,编译器会自动完成这些工作。部分方法(第三章)允许自动生成的部分类为手动编写提供可定制的钩子,如果未使用则会自动“消失”。
C# 2.0 的新特性
C# 2 中的重要新功能包括泛型(第三章)、可空值类型(第四章)、迭代器(第四章)和匿名方法(lambda 表达式的前身)。这些功能为 C# 3 中 LINQ 的引入铺平了道路。
C# 2 还增加了对部分类、静态类以及一系列较小和杂项功能的支持,如命名空间别名限定符、友元程序集和固定大小缓冲区。
泛型的引入要求一个新的 CLR(CLR 2.0),因为泛型在运行时保持完整的类型保真度。
第二章:C#语言基础
在本章中,我们介绍了 C#语言的基础知识。
注意
本书中的几乎所有代码清单都可以在 LINQPad 中作为交互式示例使用。通过与书籍一起使用这些示例,可以加速学习,因为您可以编辑示例并立即查看结果,而不需要在 Visual Studio 中设置项目和解决方案。
要下载示例,请在 LINQPad 中点击“示例”选项卡,然后点击“下载更多示例”。LINQPad 是免费的—请访问 *www.linqpad.net*。
第一个 C#程序
以下是一个程序,它将 12 乘以 30 并将结果 360 打印到屏幕上。双斜杠表示行的余下部分是一个 注释:
int x = 12 * 30; // Statement 1
System.Console.WriteLine (x); // Statement 2
我们的程序由两个 语句 组成。C# 中的语句按顺序执行,并以分号结束。第一个语句计算 表达式 12 * 30 并将结果存储在一个名为 x 的 变量 中,其类型为 32 位整数(int)。第二个语句调用名为 WriteLine 的 方法,在名为 Console 的 类 上调用,该类在名为 System 的 命名空间 中定义。这将把变量 x 打印到屏幕上的文本窗口中。
一个方法执行一个功能;一个类将功能成员和数据成员组合起来形成一个面向对象的构建块。Console类组合了处理命令行输入/输出(I/O)功能的成员,比如WriteLine方法。类是一种 类型,我们在 “类型基础” 中进行讨论。
在最外层级别,类型被组织成 命名空间。许多常用的类型—包括 Console 类—位于 System 命名空间中。.NET 库被组织成嵌套的命名空间。例如,System.Text 命名空间包含用于处理文本的类型,而 System.IO 包含用于输入/输出的类型。
在每次使用时用 System 命名空间限定 Console 类会增加混乱。using 指令允许您通过 导入 命名空间来避免此混乱:
using System; // Import the System namespace
int x = 12 * 30;
Console.WriteLine (x); // No need to specify System.
代码重用的一种基本形式是编写调用低级函数的高级函数。我们可以使用可重用的 方法 FeetToInches 重构我们的程序,该方法将整数乘以 12,如下所示:
using System;
Console.WriteLine (FeetToInches (30)); // 360
Console.WriteLine (FeetToInches (100)); // 1200
int FeetToInches (int feet)
{
int inches = feet * 12;
return inches;
}
我们的方法包含一系列语句,这些语句被一对大括号包围。这被称为 语句块。
方法可以通过指定 参数 从调用者那里接收 输入 数据,并通过指定 返回类型 将 输出 数据返回给调用者。我们的 FeetToInches 方法有一个用于输入英尺的参数,以及一个用于输出英寸的返回类型:
int FeetToInches (int feet)
...
字面量 30 和 100 是传递给FeetToInches方法的 参数。
如果一个方法不接收输入,使用空括号。如果它不返回任何内容,使用void关键字:
using System;
SayHello();
void SayHello()
{
Console.WriteLine ("Hello, world");
}
方法是 C# 中几种函数的一种。我们在示例程序中使用的另一种函数是执行乘法的 * 运算符。还有 构造函数、属性、事件、索引器 和 终结器。
编译
C# 编译器将源代码(具有 .cs 扩展名的一组文件)编译成一个 程序集。程序集是 .NET 中的打包和部署单元。程序集可以是 应用程序 或 库。普通的控制台或 Windows 应用程序有一个 入口点,而库则没有。库的目的是被应用程序或其他库调用(引用)。.NET 本身是一组库(以及运行时环境)。
前面部分的每个程序直接以一系列语句(称为 顶级语句)开始。顶级语句的存在隐含地创建了一个控制台或 Windows 应用程序的入口点。(没有顶级语句时,Main 方法 表示应用程序的入口点,请参阅 “自定义类型”。)
注意
不同于 .NET Framework,.NET 8 程序集从不使用 .exe 扩展名。在构建 .NET 8 应用程序后看到的 .exe 是一个特定于平台的本机加载器,负责启动您的应用程序的 .dll 程序集。
.NET 8 还允许您创建自包含部署,其中包括加载器、您的程序集以及所需的 .NET 运行时部分,全部打包在单个 .exe 文件中。.NET 8 还支持提前(AOT)编译,可使可执行文件包含预编译的本机代码,从而加快启动速度并减少内存消耗。
dotnet 工具(在 Windows 上为 dotnet.exe)可帮助您从命令行管理 .NET 源代码和二进制文件。您可以使用它来构建和运行程序,作为使用集成开发环境(如 Visual Studio 或 Visual Studio Code)的替代方案。
您可以通过安装 .NET 8 SDK 或安装 Visual Studio 来获取 dotnet 工具。它在 Windows 上的默认位置为 %ProgramFiles%\dotnet,在 Ubuntu Linux 上为 /usr/bin/dotnet。
要编译一个应用程序,dotnet 工具需要一个 项目文件 以及一个或多个 C# 文件。以下命令创建一个新的控制台项目(创建其基本结构):
dotnet new Console -n MyFirstProgram
这将创建一个名为 MyFirstProgram 的子文件夹,其中包含一个名为 MyFirstProgram.csproj 的项目文件和一个名为 Program.cs 的 C# 文件,该文件打印出“Hello world”。
要从 MyFirstProgram 文件夹构建和运行程序,请运行以下命令:
dotnet run MyFirstProgram
或者,如果您只想构建而不运行:
dotnet build MyFirstProgram.csproj
输出的程序集将被写入到 bin\debug 子目录下。
我们在 第十七章 中详细解释了程序集。
语法
C# 语法受到 C 和 C++ 语法的启发。在本节中,我们使用以下程序描述了 C# 的语法元素:
using System;
int x = 12 * 30;
Console.WriteLine (x);
标识符和关键字
标识符是程序员为类、方法、变量等选择的名称。以下是我们示例程序中的标识符,按照它们出现的顺序列出:
System x Console WriteLine
标识符必须是一个完整的词,基本上由以字母或下划线开头的 Unicode 字符组成。C#标识符区分大小写。按照惯例,参数、局部变量和私有字段应该使用驼峰命名法(例如,myVariable),而其他所有标识符应该使用帕斯卡命名法(例如,MyMethod)。
关键字是编译器特殊意义的名称。在我们的示例程序中有两个关键字:using和int。
大多数关键字是保留字,这意味着你不能将它们用作标识符。以下是 C#保留关键字的完整列表:
| abstract as
base
bool
break
byte
case
catch
char
checked
class
const
continue
decimal
default
delegate | do double
else
enum
event
explicit
extern
false
finally
fixed
float
for
foreach
goto
if
implicit | in int
interface
internal
is
lock
long
namespace
new
null
object
operator
out
override
params
private | protected public
readonly
record
ref
return
sbyte
sealed
short
sizeof
stackalloc
static
string
struct
switch
this | throw true
try
typeof
uint
ulong
unchecked
unsafe
ushort
using
virtual
void
volatile
while |
如果你确实想要使用与保留关键字冲突的标识符,可以通过使用@前缀来完成。例如:
int using = 123; // Illegal
int @using = 123; // Legal
@符号本身不属于标识符的一部分。因此,@myVariable与myVariable是相同的。
上下文关键字
一些关键字是上下文相关的,这意味着你也可以将它们作为标识符使用,无需使用@符号:
| add alias
and
ascending
async
await
by
descending | dynamic equals
file
from
get
global
group
init | into join
let
managed
nameof
nint
not
notnull | nuint on
or
orderby
partial
remove
required
select | set unmanaged
value
var
with
when
where
yield |
在使用上下文关键字的上下文中,不会产生歧义。
字面量、标点符号和运算符
字面量是程序中词法嵌入的原始数据片段。在我们的示例程序中使用的字面量包括12和30。
标点符号有助于标明程序的结构。例如,分号用于结束语句。语句可以跨多行:
Console.WriteLine
(1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + 9 + 10);
运算符可以转换和组合表达式。C#中大多数运算符用符号表示,比如乘法运算符*。我们在示例程序中使用的运算符如下:
= * . ()
句点表示某物的成员(或数字文字中的小数点)。在声明或调用方法时使用括号;当方法不接受参数时使用空括号。(括号还有其他目的,稍后在本章中你会看到。)等号执行赋值。(双等号 == 执行相等比较,稍后会看到。)
注释
C# 提供两种不同风格的源代码文档:单行注释和多行注释。单行注释以双斜杠开头,并持续到行尾;例如:
int x = 3; // Comment about assigning 3 to x
多行注释以 /* 开始,以 */ 结束;例如:
int x = 3; /* This is a comment that
spans two lines */
注释可以嵌入 XML 文档标签,我们在 “XML 文档” 中详细解释。
类型基础
类型定义了一个值的蓝图。在这个例子中,我们使用了两个类型为 int、值为 12 和 30 的字面量。我们还声明了一个名为 x 的类型为 int 的变量:
int x = 12 * 30;
Console.WriteLine (x);
注意
因为本书中大部分代码清单需要 System 命名空间中的类型,所以我们将从现在开始省略“using System”,除非我们在说明与命名空间相关的概念。
变量表示随时间可以包含不同值的存储位置。相比之下,常量始终表示相同的值(稍后详细说明):
const int y = 360;
在 C# 中,所有的值都是类型的实例。一个值的含义以及变量可能具有的可能值集由其类型确定。
预定义类型示例
预定义类型是编译器特别支持的类型。int 类型是表示适合于 32 位内存的整数集的预定义类型,范围从 −2³¹ 到 2³¹−1,并且是此范围内数字文字的默认类型。您可以对 int 类型的实例执行算术操作,如下所示:
int x = 12 * 30;
另一个预定义的 C# 类型是 string。string 类型表示字符序列,例如 “.NET” 或 http://oreilly.com。您可以通过调用它们上面的函数来处理字符串,如下所示:
string message = "Hello world";
string upperMessage = message.ToUpper();
Console.WriteLine (upperMessage); // HELLO WORLD
int x = 2022;
message = message + x.ToString();
Console.WriteLine (message); // Hello world2022
在这个例子中,我们调用 x.ToString() 来获取整数 x 的字符串表示。你几乎可以在任何类型的变量上调用 ToString()。
预定义的 bool 类型只有两个可能的值:true 和 false。bool 类型通常与 if 语句一起用于有条件地分支执行流程:
bool simpleVar = false;
if (simpleVar)
Console.WriteLine ("This will not print");
int x = 5000;
bool lessThanAMile = x < 5280;
if (lessThanAMile)
Console.WriteLine ("This will print");
注意
在 C# 中,预定义类型(也称为内置类型)由 C# 关键字识别。在 .NET 中,System 命名空间包含许多重要的类型,这些类型不是由 C# 预定义的(例如 DateTime)。
自定义类型
就像我们可以编写自己的方法一样,我们也可以编写自己的类型。在下一个示例中,我们定义了一个名为 UnitConverter 的自定义类型——一个作为单位转换蓝图的类:
UnitConverter feetToInchesConverter = new UnitConverter (12);
UnitConverter milesToFeetConverter = new UnitConverter (5280);
Console.WriteLine (feetToInchesConverter.Convert(30)); // 360
Console.WriteLine (feetToInchesConverter.Convert(100)); // 1200
Console.WriteLine (feetToInchesConverter.Convert(
milesToFeetConverter.Convert(1))); // 63360
public class UnitConverter
{
int ratio; // Field
public UnitConverter (int unitRatio) // Constructor
{
ratio = unitRatio;
}
public int Convert (int unit) // Method
{
return unit * ratio;
}
}
注意
在这个例子中,我们的类定义出现在与顶层语句相同的文件中。这是合法的——只要顶层语句出现在前面——在编写小型测试程序时也是可以接受的。对于较大的程序,标准做法是将类定义放在单独的文件中,如UnitConverter.cs。
类的成员
类包含数据成员和函数成员。UnitConverter的数据成员是称为比率的字段。UnitConverter的函数成员包括Convert方法和UnitConverter的构造函数。
预定义类型和自定义类型的对称性
C#的一个美妙之处在于预定义类型和自定义类型几乎没有差别。预定义的int类型用作整数的蓝图。它保存数据——32 位——并提供使用该数据的函数成员,如ToString。同样,我们的自定义UnitConverter类型作为单位转换的蓝图。它保存数据——比率——并提供使用该数据的函数成员。
构造函数和实例化
数据是通过实例化类型来创建的。预定义类型可以通过字面量(如12或"Hello world")简单实例化。new运算符创建自定义类型的实例。我们使用以下语句创建和声明了UnitConverter类型的一个实例:
UnitConverter feetToInchesConverter = new UnitConverter (12);
new运算符实例化对象后,会调用对象的构造函数进行初始化。构造函数定义类似于方法,但方法名称和返回类型缩减为封闭类型的名称:
public UnitConverter (int unitRatio) { ratio = unitRatio; }
实例成员与静态成员的区别
实例类型的数据成员和函数成员称为实例成员。UnitConverter的Convert方法和int的ToString方法就是实例成员的例子。默认情况下,成员都是实例成员。
不操作类型实例的数据成员和函数成员可以标记为static。要从其类型外部引用静态成员,需指定其类型名称而不是实例。例如,Console类的WriteLine方法。因为这是静态的,我们调用Console.WriteLine()而不是new Console().WriteLine()。
(Console类实际上声明为静态类,这意味着所有它的成员都是静态的,你永远无法创建Console的实例。)
在以下代码中,Name实例字段属于特定Panda的实例,而Population则属于所有Panda实例的集合。我们创建了两个Panda的实例,打印它们的名称,然后打印总人口数量:
Panda p1 = new Panda ("Pan Dee");
Panda p2 = new Panda ("Pan Dah");
Console.WriteLine (p1.Name); // Pan Dee
Console.WriteLine (p2.Name); // Pan Dah
Console.WriteLine (Panda.Population); // 2
public class Panda
{
public string Name; // Instance field
public static int Population; // Static field
public Panda (string n) // Constructor
{
Name = n; // Assign the instance field
Population = Population + 1; // Increment the static Population field
}
}
尝试评估p1.Population或Panda.Name将生成编译时错误。
公共关键字
public关键字将成员暴露给其他类。在本例中,如果Panda中的Name字段未标记为public,则将是私有的,无法从类外部访问。将成员标记为public是类型通信的方式:“这是我希望其他类型看到的东西——其他都是我自己的私有实现细节。”从面向对象的角度来看,我们说公共成员封装了类的私有成员。
定义命名空间
特别是在较大的程序中,将类型组织到命名空间中是有意义的。以下是如何在名为Animals的命名空间内定义Panda类:
using System;
using Animals;
Panda p = new Panda ("Pan Dee");
Console.WriteLine (p.Name);
namespace Animals
{
public class Panda
{
...
}
}
在这个例子中,我们还导入了Animals命名空间,以便我们的顶级语句可以无需限定地访问其类型。如果没有这个导入,我们需要这样做:
Animals.Panda p = new Animals.Panda ("Pan Dee");
我们在本章末尾详细介绍命名空间(请参阅“命名空间”)。
定义主方法
到目前为止,我们所有的示例都使用了顶级语句(这是 C# 9 引入的一个功能)。
没有顶级语句,简单的控制台或 Windows 应用程序如下所示:
using System;
class Program
{
static void Main() // Program entry point
{
int x = 12 * 30;
Console.WriteLine (x);
}
}
在没有顶级语句的情况下,C#会寻找名为Main的静态方法,这成为入口点。Main方法可以定义在任何类内部(只能存在一个Main方法)。
Main方法可以选择性地返回整数(而不是void),以向执行环境返回一个值(其中非零值通常表示错误)。Main方法还可以选择性地接受字符串数组作为参数(该参数将填充为传递给可执行文件的任何参数)。例如:
static int Main (string[] args) {...}
注意
数组(如string[])表示特定类型的固定数量的元素。数组通过在元素类型后面放置方括号来指定。我们在“数组”中描述它们。
(Main方法还可以声明为async并返回Task或Task<int>,以支持异步编程,我们在第十四章中介绍。)
类型和转换
C#可以在兼容类型的实例之间进行转换。转换总是从现有值创建新值。转换可以是隐式或显式:隐式转换会自动发生,显式转换需要强制转换。在以下示例中,我们隐式将int转换为具有两倍int位容量的long类型,并显式将int转换为具有int位容量一半的short类型:
int x = 12345; // int is a 32-bit integer
long y = x; // Implicit conversion to 64-bit integer
short z = (short)x; // Explicit conversion to 16-bit integer
当满足以下两个条件时,允许隐式转换:
-
编译器可以保证它们将始终成功。
-
转换过程中不会丢失信息。¹
相反,当满足以下条件之一时,需要显式转换:
-
编译器无法保证它们将始终成功。
-
转换过程中可能会丢失信息。
(如果编译器可以确定转换将始终失败,则两种类型的转换都被禁止。涉及泛型的转换在某些条件下也可能失败 —— 见“类型参数和转换”。)
注意
我们刚刚看到的数值转换是语言内置的。C#还支持引用转换和装箱转换(参见第 3 章),以及自定义转换(参见“运算符重载”)。编译器不会对自定义转换强制执行上述规则,因此设计不良的类型可能会有不同的行为。
值类型与引用类型对比
所有 C#类型都属于以下类别:
-
值类型
-
引用类型
-
泛型类型参数
-
指针类型
注意
在本节中,我们描述了值类型和引用类型。我们在“泛型”中介绍了泛型类型参数,以及在“不安全代码和指针”中介绍了指针类型。
值类型 包括大多数内置类型(具体来说是所有数值类型、char类型和bool类型),以及自定义的struct和enum类型。
引用类型 包括所有类、数组、委托和接口类型。(这包括预定义的string类型。)
值类型和引用类型的根本区别在于它们在内存中的处理方式。
值类型
值类型变量或常量的内容只是一个值。例如,内置的值类型int的内容是 32 位的数据。
您可以使用struct关键字定义自定义值类型(见图 2-1):
public struct Point { public int X; public int Y; }
或更简洁地说:
public struct Point { public int X, Y; }

图 2-1. 内存中的值类型实例
值类型实例的赋值总是会复制实例;例如:
Point p1 = new Point();
p1.X = 7;
Point p2 = p1; // Assignment causes copy
Console.WriteLine (p1.X); // 7
Console.WriteLine (p2.X); // 7
p1.X = 9; // Change p1.X
Console.WriteLine (p1.X); // 9
Console.WriteLine (p2.X); // 7
图 2-2 显示了p1和p2有独立的存储。

图 2-2. 分配复制值类型实例
引用类型
引用类型比值类型更复杂,包含两个部分:对象和指向该对象的引用。引用类型变量或常量的内容是指向包含值的对象的引用。这里是我们之前示例中的Point类型,它被重写为一个类而不是struct(如图 2-3 所示):
public class Point { public int X, Y; }

图 2-3. 内存中的引用类型实例
分配引用类型变量会复制引用而非对象实例。这允许多个变量引用同一个对象,这在值类型中通常是不可能的。如果我们重复之前的例子,但现在Point是一个类,对p1的操作会影响到p2:
Point p1 = new Point();
p1.X = 7;
Point p2 = p1; // Copies p1 reference
Console.WriteLine (p1.X); // 7
Console.WriteLine (p2.X); // 7
p1.X = 9; // Change p1.X
Console.WriteLine (p1.X); // 9
Console.WriteLine (p2.X); // 9
图 2-4 显示 p1 和 p2 是指向同一对象的两个引用。

图 2-4. 分配复制一个引用
空值
可以将引用分配为字面量 null,表示引用指向没有对象:
Point p = null;
Console.WriteLine (p == null); // True
// The following line generates a runtime error
// (a NullReferenceException is thrown):
Console.WriteLine (p.X);
class Point {...}
注意
在 “可空引用类型” 中,我们描述了 C#的一个特性,有助于减少意外的 NullReferenceException 错误。
相比之下,值类型通常不能有空值:
Point p = null; // Compile-time error
int x = null; // Compile-time error
struct Point {...}
注意
C# 还有一种称为可空值类型的构造,用于表示值类型的空值。有关更多信息,请参见 “可空值类型”。
存储开销
值类型实例占用精确存储其字段所需的内存。在这个例子中,Point 占用 8 字节的内存:
struct Point
{
int x; // 4 bytes
int y; // 4 bytes
}
注意
从技术上讲,CLR 将类型内的字段定位到地址,该地址是字段大小的倍数(最多为 8 字节)。因此,以下实际上会消耗 16 字节的内存(第一个字段后面的 7 字节“浪费”):
struct A { byte b; long l; }
您可以通过应用 StructLayout 属性来覆盖此行为(参见 “将结构映射到非托管内存”)。
引用类型需要单独分配内存来存储引用和对象。对象消耗的字节数与其字段一样多,再加上额外的管理开销。精确的开销是.NET 运行时实现的私有信息,但至少为 8 字节,用于存储对象类型的键以及临时信息,例如其在多线程中的锁定状态和指示其是否已由垃圾收集器固定的标志。每个对象的引用需要额外的 4 或 8 字节,具体取决于.NET 运行时是否运行在 32 位或 64 位平台上。
预定义类型分类
C# 中的预定义类型如下:
值类型
-
数值
-
符号整数 (
sbyte,short,int,long) -
无符号整数 (
byte,ushort,uint,ulong) -
实数 (
float,double,decimal)
-
-
逻辑 (
bool) -
字符 (
char)
引用类型
-
字符串 (
string) -
对象 (
object)
C# 中的预定义类型别名 .NET 类型位于 System 命名空间中。这两个语句之间只有语法上的差异:
int i = 5;
System.Int32 i = 5;
CLR 中,预定义的值类型不包括 decimal,它们被称为 CLR 中的基元类型。之所以称为基元类型,是因为它们直接通过编译后的指令支持,在底层处理器上通常会直接翻译为支持;例如:
// Underlying hexadecimal representation
int i = 7; // 0x7
bool b = true; // 0x1
char c = 'A'; // 0x41
float f = 0.5f; // uses IEEE floating-point encoding
System.IntPtr 和 System.UIntPtr 类型也是基元类型(参见 第二十四章)。
数值类型
C# 中有如下所示的预定义数值类型表 表 2-1。
表 2-1. C#中的预定义数值类型
| C# 类型 | 系统类型 | 后缀 | 大小 | 范围 |
|---|---|---|---|---|
| 整数—有符号 | ||||
sbyte |
SByte |
8 位 | –2⁷ 到 2⁷–1 | |
short |
Int16 |
16 位 | –2¹⁵ 到 2¹⁵–1 | |
int |
Int32 |
32 位 | –2³¹ 到 2³¹–1 | |
long |
Int64 |
L |
64 位 | –2⁶³ 到 2⁶³–1 |
nint |
IntPtr |
32/64 位 | ||
| 整数—无符号 | ||||
byte |
Byte |
8 位 | 0 到 2⁸–1 | |
ushort |
UInt16 |
16 位 | 0 到 2¹⁶–1 | |
uint |
UInt32 |
U |
32 位 | 0 到 2³²–1 |
ulong |
UInt64 |
UL |
64 位 | 0 到 2⁶⁴–1 |
nuint |
UIntPtr |
32/64 位 | ||
| 实数 | ||||
float |
Single |
F |
32 位 | ± (~10^(–45) 到 10³⁸) |
double |
Double |
D |
64 位 | ± (~10^(–324) 到 10³⁰⁸) |
decimal |
Decimal |
M |
128 位 | ± (~10^(–28) 到 10²⁸) |
在整数类型中,int 和 long 是头等公民,并且被 C# 和运行时青睐。其他整数类型通常用于互操作性或空间效率至关重要时。nint 和 nuint 本地大小的整数类型在处理指针时非常有用,因此我们将在后面的章节中描述它们(参见 “本地大小整数”)。
在实数类型中,float 和 double 被称为 浮点类型²,通常用于科学和图形计算。decimal 类型通常用于财务计算,其中需要基于十进制的精确算术和高精度。
注意
.NET 还用几种特殊的数值类型补充了此列表,包括用于有符号和无符号 128 位整数的 Int128 和 UInt128,用于任意大整数的 BigInteger,以及用于 16 位浮点数的 Half。Half 主要用于与图形处理器的互操作,并且在大多数 CPU 中没有本地支持,因此在一般用途中,float 和 double 是更好的选择。
数字文字
整数类型文字 可以使用十进制或十六进制表示法;十六进制用 0x 前缀表示。例如:
int x = 127;
long y = 0x7F;
你可以在数字文字中的任何位置插入下划线,以提高其可读性:
int million = 1_000_000;
您可以使用 0b 前缀指定二进制数字:
var b = 0b1010_1011_1100_1101_1110_1111;
实数文字 可以使用十进制和/或指数表示法:
double d = 1.5;
double million = 1E06;
数字文字类型推断
默认情况下,编译器会 推断 数字文字为 double 或整数类型之一:
-
如果文字包含小数点或指数符号 (
E),则为double。 -
否则,文字的类型是此列表中可以容纳文字值的第一个类型:
int、uint、long和ulong。
例如:
Console.WriteLine ( 1.0.GetType()); // Double *(double)*
Console.WriteLine ( 1E06.GetType()); // Double *(double)*
Console.WriteLine ( 1.GetType()); // Int32 *(int)*
Console.WriteLine ( 0xF0000000.GetType()); // UInt32 *(uint)*
Console.WriteLine (0x100000000.GetType()); // Int64 *(long)*
数字后缀
数字后缀 明确定义了文字的类型。后缀可以是小写或大写,如下所示:
| 类别 | C# 类型 | 示例 |
|---|---|---|
F |
float |
float f = 1.0F; |
D |
double |
double d = 1D; |
M |
decimal |
decimal d = 1.0M; |
U |
uint |
uint i = 1U; |
L |
long |
long i = 1L; |
UL |
ulong |
ulong i = 1UL; |
后缀 U 和 L 很少需要,因为 uint,long 和 ulong 类型几乎总是可以从 int 推断 或隐式转换而来:
long i = 5; // Implicit lossless conversion from int literal to long
D 后缀在技术上是多余的,因为所有带小数点的字面量都被推断为 double。而且你总是可以向数字字面量添加一个小数点:
double x = 4.0;
F 和 M 后缀是最有用的,当指定 float 或 decimal 字面量时应始终添加。如果没有 F 后缀,下面的行将无法编译,因为 4.5 将被推断为 double 类型,而 double 类型没有到 float 类型的隐式转换:
float f = 4.5F;
十进制字面量也适用同样的原则:
decimal d = -1.23M; // Will not compile without the M suffix.
我们将在下一节详细描述数值转换的语义。
数字转换
整数类型之间的转换
当目标类型能够表示源类型的每一个可能值时,整数类型转换是隐式的。否则,需要进行显式转换;例如:
int x = 12345; // int is a 32-bit integer
long y = x; // Implicit conversion to 64-bit integral type
short z = (short)x; // Explicit conversion to 16-bit integral type
浮点类型之间的转换
给定 double 可以表示 float 的每一个可能值,float 可以隐式转换为 double。反向转换必须是显式的。
浮点类型和整数类型之间的转换
所有整数类型可以隐式转换为所有浮点类型:
int i = 1;
float f = i;
反向转换必须是显式的:
int i2 = (int)f;
注意
当你从浮点数转换为整数类型时,任何小数部分都会被截断;不进行四舍五入。静态类System.Convert提供了在各种数值类型之间进行转换时进行四舍五入的方法(见第六章)。
将大整数类型隐式转换为浮点类型会保留幅度,但有时可能会丢失精度。这是因为浮点类型始终具有比整数类型更大的幅度,但可能具有较少的精度。通过使用一个较大的数字来重新编写我们的示例来演示这一点:
int i1 = 100000001;
float f = i1; // Magnitude preserved, precision lost
int i2 = (int)f; // 100000000
十进制转换
所有整数类型可以隐式转换为十进制类型,前提是十进制可以表示所有可能的 C# 整数类型值。对于十进制类型的所有其他数值转换必须是显式的,因为它们可能导致值超出范围或精度丢失的可能性。
算术运算符
算术运算符(+,-,*,/,%)适用于所有数值类型,但不适用于 8 位和 16 位整数类型:
+ Addition
- Subtraction
* Multiplication
/ Division
% Remainder after division
自增和自减运算符
自增和自减运算符(++,--,分别)通过 1 增加和减少数值类型。运算符可以跟随或在变量之前,具体取决于您希望其值在增加/减少之前还是之后;例如:
int x = 0, y = 0;
Console.WriteLine (x++); // Outputs 0; x is now 1
Console.WriteLine (++y); // Outputs 1; y is now 1
整数类型的专用操作
整数类型包括int、uint、long、ulong、short、ushort、byte和sbyte。
除法
对整数类型的除法操作总是消除余数(向零舍入)。除以值为零的变量会生成运行时错误(DivideByZeroException):
int a = 2 / 3; // 0
int b = 0;
int c = 5 / b; // throws DivideByZeroException
除以文字或常量 0 会生成编译时错误。
溢出
在运行时,整数类型的算术操作可能会溢出。默认情况下,这种情况发生时是静默的——不会抛出异常,并且结果表现为“环绕”行为,就像在更大的整数类型上进行计算并丢弃额外的有效位一样。例如,将最小可能的int值递减结果为最大可能的int值:
int a = int.MinValue;
a--;
Console.WriteLine (a == int.MaxValue); // True
溢出检查运算符
checked运算符指示运行时在整数类型表达式或语句超出算术限制时生成OverflowException,而不是静默溢出。checked运算符影响具有++、−−、+、−(二元和一元)、*、/和整数类型之间的显式转换运算符的表达式。溢出检查会带来小的性能成本。
注意
checked运算符对double和float类型无效(它们溢出到特殊的“无穷”值,稍后您将看到),并且对decimal类型无效(它总是检查的)。
您可以在表达式或语句块周围使用checked:
int a = 1000000;
int b = 1000000;
int c = checked (a * b); // Checks just the expression.
checked // Checks all expressions
{ // in statement block.
...
c = a * b;
...
}
您可以通过选择项目级别的“checked”选项(在 Visual Studio 中,转到高级构建设置)使算术溢出检查成为程序中所有表达式的默认值。然后,如果需要仅为特定表达式或语句禁用溢出检查,可以使用unchecked运算符。例如,以下代码不会抛出异常——即使选择了项目的“checked”选项:
int x = int.MaxValue;
int y = unchecked (x + 1);
unchecked { int z = x + 1; }
常量表达式的溢出检查
无论“checked”项目设置如何,在编译时评估的表达式总是进行溢出检查——除非您应用unchecked运算符:
int x = int.MaxValue + 1; // Compile-time error
int y = unchecked (int.MaxValue + 1); // No errors
位操作符
C#支持以下位操作符:
| 操作符 | 含义 | 示例表达式 | 结果 |
|---|---|---|---|
~ |
补码 | ~0xfU |
0xfffffff0U |
& |
与 | 0xf0 & 0x33 |
0x30 |
| |
或 | 0xf0 | 0x33 |
0xf3 |
^ |
异或 | 0xff00 ^ 0x0ff0 |
0xf0f0 |
<< |
左移 | 0x20 << 2 |
0x80 |
>> |
右移 | 0x20 >> 1 |
0x10 |
>>> |
无符号右移 | int.MinValue >>> 1 |
0x40000000 |
右移操作符>>在对有符号整数进行操作时复制高阶位,而无符号右移操作符(>>>)则不会。
注意
附加的位操作通过名为BitOperations的类在System.Numerics命名空间中公开(参见“位操作”)。
8 位和 16 位整数类型
8 位和 16 位整数类型是 byte、sbyte、short 和 ushort。这些类型缺少自己的算术运算符,因此 C# 根据需要隐式将它们转换为较大的类型。当尝试将结果分配回小整数类型时,这可能会导致编译时错误:
short x = 1, y = 1;
short z = x + y; // Compile-time error
在这种情况下,x 和 y 被隐式转换为 int 以执行加法。这意味着结果也是一个 int,不能隐式地转回 short(因为可能导致数据丢失)。为了使其编译通过,必须添加显式转换:
short z = (short) (x + y); // OK
特殊浮点和双精度值
不像整数类型,浮点类型具有某些操作对待特殊的值。这些特殊值包括 NaN(不是一个数字)、+∞、−∞ 和 −0. 类 float 和 double 有 NaN、+∞ 和 −∞ 的常量,以及其他值(MaxValue、MinValue 和 Epsilon);例如:
Console.WriteLine (double.NegativeInfinity); // -Infinity
表示 double 和 float 特殊值的常量如下:
| 特殊值 | Double 常量 | Float 常量 |
|---|---|---|
| NaN | double.NaN |
float.NaN |
| +∞ | double.PositiveInfinity |
float.PositiveInfinity |
| −∞ | double.NegativeInfinity |
float.NegativeInfinity |
| −0 | −0.0 |
−0.0f |
将非零数除以零会得到无限值:
Console.WriteLine ( 1.0 / 0.0); // Infinity
Console.WriteLine (−1.0 / 0.0); // -Infinity
Console.WriteLine ( 1.0 / −0.0); // -Infinity
Console.WriteLine (−1.0 / −0.0); // Infinity
将零除以零或从无穷大中减去无穷大会得到 NaN:
Console.WriteLine ( 0.0 / 0.0); // NaN
Console.WriteLine ((1.0 / 0.0) − (1.0 / 0.0)); // NaN
当使用 == 时,NaN 值永远不等于另一个值,即使是另一个 NaN 值:
Console.WriteLine (0.0 / 0.0 == double.NaN); // False
要测试一个值是否为 NaN,必须使用 float.IsNaN 或 double.IsNaN 方法:
Console.WriteLine (double.IsNaN (0.0 / 0.0)); // True
当使用 object.Equals 时,两个 NaN 值是相等的:
Console.WriteLine (object.Equals (0.0 / 0.0, double.NaN)); // True
注:
NaNs 有时用于表示特殊值。在 Windows Presentation Foundation (WPF) 中,double.NaN 表示值为“自动”的测量。表示这种值的另一种方式是使用可空类型(第四章);另一种方式是使用包装数值类型并添加附加字段的自定义结构体(第三章)。
float 和 double 遵循 IEEE 754 格式类型的规范,几乎所有处理器都原生支持。您可以在 http://www.ieee.org 上找到有关这些类型行为的详细信息。
double 与 decimal
double 对于科学计算(如计算空间坐标)很有用。decimal 对于金融计算和制造而非实际测量结果的值很有用。以下是两者差异的摘要。
| 类别 | double | decimal |
|---|---|---|
| 内部表示 | Base 2 | Base 10 |
| 十进制精度 | 15–16 有效数字 | 28–29 有效数字 |
| 范围 | ±(~10^(−324) 到 ~10³⁰⁸) | ±(~10^(−28) 到 ~10²⁸) |
| 特殊值 | +0、−0、+∞、−∞ 和 NaN | 无 |
| 速度 | 本地处理器原生 | 非本地处理器(大约比 double 慢 10 倍) |
实数舍入误差
float和double在内部以 2 进制表示数字。因此,只有能够用 2 进制表示的数字才能被精确表示。实际上,这意味着大多数带有小数部分的字面量(以 10 进制表示)将不能被精确表示;例如:
float x = 0.1f; // Not quite 0.1
Console.WriteLine (x + x + x + x + x + x + x + x + x + x); // 1.0000001
这就是为什么float和double在财务计算中表现不佳。相比之下,decimal以 10 进制工作,因此可以精确表示以 10 进制表示的数字(以及它的因子,即 2 进制和 5 进制)。由于实数字面量是以 10 进制表示的,decimal可以精确表示诸如 0.1 这样的数字。然而,无论是double还是decimal都无法精确表示其 10 进制表示为循环的分数:
decimal m = 1M / 6M; // 0.1666666666666666666666666667M
double d = 1.0 / 6.0; // 0.16666666666666666
这导致了累积的舍入误差:
decimal notQuiteWholeM = m+m+m+m+m+m; // 1.0000000000000000000000000002M
double notQuiteWholeD = d+d+d+d+d+d; // 0.99999999999999989
打破等式和比较运算的操作:
Console.WriteLine (notQuiteWholeM == 1M); // False
Console.WriteLine (notQuiteWholeD < 1.0); // True
布尔类型和运算符
C#的bool类型(别名System.Boolean类型)是一个逻辑值,可以赋值为字面量true或false。
尽管布尔值只需要存储一个比特,但运行时会使用一个字节的内存,因为这是运行时和处理器能有效处理的最小块。为了避免在数组情况下的空间效率低下,.NET 在System.Collections命名空间中提供了一个BitArray类,设计用于每个布尔值只使用一个比特。
布尔类型转换
不能从bool类型进行数值类型或反之的强制转换。
等式和比较运算符
==和!=测试任何类型的相等和不等,但始终返回一个bool值。³ 值类型通常具有非常简单的相等概念:
int x = 1;
int y = 2;
int z = 1;
Console.WriteLine (x == y); // False
Console.WriteLine (x == z); // True
对于引用类型,默认情况下,等式是基于引用而不是底层对象的值(在第六章中详细介绍):
Dude d1 = new Dude ("John");
Dude d2 = new Dude ("John");
Console.WriteLine (d1 == d2); // False
Dude d3 = d1;
Console.WriteLine (d1 == d3); // True
public class Dude
{
public string Name;
public Dude (string n) { Name = n; }
}
等式和比较运算符==、!=、<、>、>=和<=适用于所有数值类型,但在使用实数时应谨慎(如我们在“实数舍入误差”中看到的)。比较运算符也适用于enum类型成员,通过比较它们的底层整数类型值进行比较。我们在“枚举”中描述了这一点。
我们在“运算符重载”,“等式比较”和“顺序比较”中更详细地解释了等式和比较运算符。
条件运算符
&&和||运算符测试与和或条件。它们经常与!运算符一起使用,表示非。在以下示例中,如果天气雨天或晴天(用来遮挡雨水或阳光),UseUmbrella方法将返回true,只要不是多风的情况(风中使用伞是无效的):
static bool UseUmbrella (bool rainy, bool sunny, bool windy)
{
return !windy && (rainy || sunny);
}
&&和||运算符在可能时短路评估。在上述示例中,如果有风,表达式(rainy || sunny)甚至不会被评估。短路在允许如下表达式运行而不抛出NullReferenceException时是至关重要的:
if (sb != null && sb.Length > 0) ...
&和|运算符还测试and和or条件:
return !windy & (rainy | sunny);
不同之处在于它们不进行短路。因此,它们很少用于替代条件运算符。
注意
与 C 和 C++不同,当应用于bool表达式时,&和|运算符执行(非短路)布尔比较。当应用于数字时,&和|运算符只执行位操作。
条件运算符(三元运算符)
条件运算符(更常称为三元运算符,因为它是唯一接受三个操作数的运算符)的形式为q ? a : b;因此,如果条件q为真,则评估a;否则评估b:
static int Max (int a, int b)
{
return (a > b) ? a : b;
}
条件运算符在语言集成查询(LINQ)表达式中特别有用(第八章)。
字符串和字符
C#的char类型(别名System.Char类型)表示一个 Unicode 字符,占据 2 个字节(UTF-16)。char字面量在单引号内指定:
char c = 'A'; // Simple character
转义序列表示不能以字面或直接方式表示或解释的字符。转义序列是一个反斜杠后跟具有特殊含义的字符;例如:
char newLine = '\n';
char backSlash = '\\';
表 2-2 显示了转义序列字符。
表 2-2. 转义序列字符
| Char | 含义 | 值 |
|---|---|---|
\' |
单引号 | 0x0027 |
\" |
双引号 | 0x0022 |
\\ |
反斜杠 | 0x005C |
\0 |
空字符 | 0x0000 |
\a |
警报 | 0x0007 |
\b |
退格 | 0x0008 |
\f |
换页符 | 0x000C |
\n |
换行符 | 0x000A |
\r |
回车符 | 0x000D |
\t |
水平制表符 | 0x0009 |
\v |
垂直制表符 | 0x000B |
\u(或\x)转义序列允许您通过其四位十六进制代码指定任何 Unicode 字符:
char copyrightSymbol = '\u00A9';
char omegaSymbol = '\u03A9';
char newLine = '\u000A';
字符转换
从char到数值类型的隐式转换适用于可以容纳无符号short的数值类型。对于其他数值类型,需要显式转换。
字符串类型
C#的字符串类型(别名System.String类型,在第六章深入讨论)表示一种不可变(不可修改)的 Unicode 字符序列。字符串字面量在双引号内指定:
string a = "Heat";
注意
string是引用类型而不是值类型。然而,其相等运算符遵循值类型语义:
string a = "test";
string b = "test";
Console.Write (a == b); // True
对于char字面量有效的转义序列也适用于字符串内部:
string a = "Here's a tab:\t";
代价是每当需要字面反斜杠时,必须写两次:
string a1 = "\\\\server\\fileshare\\helloworld.cs";
为避免此问题,C#允许verbatim字符串直接文字。verbatim 字符串直接文字以@为前缀,不支持转义序列。以下 verbatim 字符串与前述字符串相同:
string a2 = @"\\server\fileshare\helloworld.cs";
verbatim 字符串直接文字也可以跨多行:
string escaped = "First Line\r\nSecond Line";
string verbatim = @"First Line
Second Line";
// True if your text editor uses CR-LF line separators:
Console.WriteLine (escaped == verbatim);
你可以通过将其写两次来在直接文字文本中包含双引号字符:
string xml = @"<customer id=""123""></customer>";
原始字符串直接文字(C# 11)
用三个或更多引号字符(""")包裹字符串会创建一个原始字符串直接文字。原始字符串直接文字可以包含几乎任何字符序列,无需转义或重复:
string raw = """<file path="c:\temp\test.txt"></file>""";
原始字符串直接文字使得表示 JSON、XML 和 HTML 直接文字、正则表达式和源代码变得容易。如果需要在字符串本身中包含三个(或更多)引号字符,可以通过将字符串包装在四个(或更多)引号字符中来实现:
string raw = """"The """ sequence denotes raw string literals."""";
多行原始字符串直接文字受特殊规则约束。我们可以将字符串"Line 1\r\nLine 2"表示如下:
string multiLineRaw = """
Line 1
Line 2
""";
注意,开头和结尾的引号必须在不同的行上。另外:
-
忽略opening
"""(在同一行上)之后的空白。 -
在同一行上,closing
"""之前的空白被视为common indentation并从字符串的每一行中移除。这使您可以包含用于源代码可读性的缩进,而不将该缩进作为字符串的一部分。
下面是另一个示例,以说明多行原始字符串直接文字的规则:
if (true)
Console.WriteLine ("""
{
"Name" : "Joe"
}
""");
输出如下:
{
"Name" : "Joe"
}
如果多行原始字符串直接文字中的每一行未以关闭引号指定的公共缩进为前缀,则编译器将生成错误。
原始字符串直接文字可以被插值,受“字符串插值”描述的特殊规则约束。
字符串连接
+运算符连接两个字符串:
string s = "a" + "b";
运算符的一个操作数可能是非字符串值,在这种情况下,将对该值调用ToString:
string s = "a" + 5; // a5
反复使用+运算符来构建字符串是低效的:更好的解决方案是使用System.Text.StringBuilder类型(在第六章中描述)。
字符串插值
以$字符为前缀的字符串称为插值字符串。插值字符串可以包含用大括号括起来的表达式:
int x = 4;
Console.Write ($"A square has {x} sides"); // Prints: A square has 4 sides
任何类型的有效 C#表达式都可以出现在大括号内,并且 C#将通过调用其ToString方法或等效方法将表达式转换为字符串。您可以通过追加表达式和冒号以及格式字符串来更改格式(格式字符串在“String.Format 和组合格式字符串”中描述):
string s = $"255 in hex is {byte.MaxValue:X2}"; // X2 = 2-digit hexadecimal
// Evaluates to "255 in hex is FF"
如果需要为其他目的使用冒号(例如三元条件运算符,我们稍后会讨论),必须将整个表达式包装在括号中:
bool b = true;
Console.WriteLine ($"The answer in binary is {(b ? 1 : 0)}");
从 C# 10 开始,插值字符串可以是常量,只要插值的值是常量:
const string greeting = "Hello";
const string message = $"{greeting}, world";
从 C# 11 开始,允许插值字符串跨多行(无论是标准还是文本):
string s = $"this interpolation spans {1 +
1} lines";
原始字符串字面量(从 C# 11 开始)也可以插值:
string s = $"""The date and time is {DateTime.Now}""";
要在插值字符串中包含大括号字面量:
-
使用标准和文本字符串字面量时,重复所需的大括号字符。
-
使用原始字符串字面量时,通过重复
$前缀改变插值序列。
在原始字符串字面量前使用两个(或更多)$字符会改变插值序列,从一个大括号变为两个(或更多)大括号:
Console.WriteLine ($$"""{ "TimeStamp": "{{DateTime.Now}}" }""");
// Output: { "TimeStamp": "01/01/2024 12:13:25 PM" }
这保留了将文本复制粘贴到原始字符串字面量中而无需修改字符串的能力。
字符串比较
要使用==运算符(或string的Equals方法之一)执行相等比较,必须使用字符串的CompareTo方法进行顺序比较;不支持<和>运算符。我们在“比较字符串”中详细描述了相等性和顺序比较。
UTF-8 字符串
从 C# 11 开始,可以使用u8后缀创建以 UTF-8 编码而不是 UTF-16 编码的字符串字面量。此功能适用于高级场景,例如在性能热点处低级处理 JSON 文本:
ReadOnlySpan<byte> utf8 = "ab→cd"u8; // Arrow symbol consumes 3 bytes
Console.WriteLine (utf8.Length); // 7
底层类型是ReadOnlySpan<byte>,我们在第二十三章中介绍了它。您可以调用ToArray()方法将其转换为数组。
数组
数组表示特定类型的固定数量变量(称为元素)。数组中的元素总是存储在连续的内存块中,提供高效的访问。
数组在元素类型后用方括号表示:
char[] vowels = new char[5]; // Declare an array of 5 characters
方括号还用于索引数组,通过位置访问特定元素。
vowels[0] = 'a';
vowels[1] = 'e';
vowels[2] = 'i';
vowels[3] = 'o';
vowels[4] = 'u';
Console.WriteLine (vowels[1]); // e
这会打印“e”,因为数组索引从 0 开始。您可以使用for循环语句迭代数组中的每个元素。在此示例中,for循环从整数i循环到4:
for (int i = 0; i < vowels.Length; i++)
Console.Write (vowels[i]); // aeiou
数组的Length属性返回数组中的元素数量。创建数组后,无法更改其长度。System.Collection命名空间及其子命名空间提供了更高级的数据结构,例如动态大小的数组和字典。
数组初始化表达式允许您在单个步骤中声明和填充数组:
char[] vowels = new char[] {'a','e','i','o','u'};
或简单地说:
char[] vowels = {'a','e','i','o','u'};
注意
从 C# 12 开始,可以使用方括号代替花括号:
char[] vowels = ['a','e','i','o','u'];
这被称为集合表达式,其优点在于在调用方法时也可以使用:
Foo (['a','e','i','o','u']);
void Foo (char[] letters) { ... }
集合表达式还适用于其他集合类型,如列表和集合——参见“集合初始化器和集合表达式”。
所有数组都继承自System.Array类,为所有数组提供共享服务。这些成员包括无论数组类型如何都能获取和设置元素的方法。我们在“数组类”中描述了它们。
默认元素初始化
创建数组时,总是使用默认值预初始化元素。类型的默认值是内存的比特位清零结果。例如,考虑创建整数数组。因为int是值类型,这将在内存中分配 1,000 个整数,连续分配的内存块中每个元素的默认值为 0:
int[] a = new int[1000];
Console.Write (a[123]); // 0
值类型与引用类型
数组元素类型是值类型还是引用类型对性能有重要影响。当元素类型是值类型时,每个元素值作为数组的一部分分配,如下所示:
Point[] a = new Point[1000];
int x = a[500].X; // 0
public struct Point { public int X, Y; }
如果Point是一个类,创建数组只会分配 1,000 个空引用:
Point[] a = new Point[1000];
int x = a[500].X; // Runtime error, NullReferenceException
public class Point { public int X, Y; }
要避免此错误,我们必须在实例化数组后显式实例化 1,000 个Point:
Point[] a = new Point[1000];
for (int i = 0; i < a.Length; i++) // Iterate i from 0 to 999
a[i] = new Point(); // Set array element i with new point
一个数组 本身 总是一个引用类型对象,无论元素类型如何。例如,以下操作是合法的:
int[] a = null;
索引和范围
索引和范围(在 C# 8 中引入)简化了处理数组元素或部分的工作。
注意
索引和范围也适用于 CLR 类型Span<T>和ReadOnlySpan<T>(参见第二十三章)。
您还可以通过定义Index或Range类型的索引器,使自定义类型与索引和范围一起工作(参见“索引器”)。
索引
索引允许您使用^运算符相对于数组的末尾引用元素。¹引用最后一个元素,²引用倒数第二个元素,依此类推:
char[] vowels = new char[] {'a','e','i','o','u'};
char lastElement = vowels [¹]; // 'u'
char secondToLast = vowels [²]; // 'o'
(⁰等于数组的长度,因此vowels[⁰]将生成错误。)
C# 使用Index类型实现索引,因此您也可以执行以下操作:
Index first = 0;
Index last = ¹;
char firstElement = vowels [first]; // 'a'
char lastElement = vowels [last]; // 'u'
范围
范围允许您通过使用..运算符来“切片”数组:
char[] firstTwo = vowels [..2]; // 'a', 'e'
char[] lastThree = vowels [2..]; // 'i', 'o', 'u'
char[] middleOne = vowels [2..3]; // 'i'
范围中的第二个数字是排除的,因此..2返回vowels[2]之前的元素。
您还可以在范围中使用^符号。以下返回最后两个字符:
char[] lastTwo = vowels [²..]; // 'o', 'u'
C# 使用Range类型实现范围,因此您也可以执行以下操作:
Range firstTwoRange = 0..2;
char[] firstTwo = vowels [firstTwoRange]; // 'a', 'e'
多维数组
多维数组有两种类型:矩形和嵌套。矩形数组表示一个n维内存块,而嵌套数组是数组的数组。
矩形数组
使用逗号分隔每个维度声明矩形数组。以下声明了维度为 3 乘 3 的矩形二维数组:
int[,] matrix = new int[3,3];
数组的GetLength方法返回给定维度(从 0 开始)的长度:
for (int i = 0; i < matrix.GetLength(0); i++)
for (int j = 0; j < matrix.GetLength(1); j++)
matrix[i,j] = i * 3 + j;
您可以使用显式值初始化矩形数组。以下代码创建与前面示例相同的数组:
int[,] matrix = new int[,]
{
{0,1,2},
{3,4,5},
{6,7,8}
};
嵌套数组
声明嵌套数组时,使用连续的方括号来表示每个维度。以下是声明外部维度为 3 的嵌套二维数组的示例:
int[][] matrix = new int[3][];
注意
有趣的是,这是new int[3][]而不是new int[][3]。Eric Lippert 在这篇优秀的文章中详细解释了为什么会这样。
声明中未指定内部维度,因为与矩形数组不同,每个内部数组可以是任意长度。每个内部数组隐式初始化为 null,而不是空数组。您必须手动创建每个内部数组:
for (int i = 0; i < matrix.Length; i++)
{
matrix[i] = new int[3]; // Create inner array
for (int j = 0; j < matrix[i].Length; j++)
matrix[i][j] = i * 3 + j;
}
您可以使用显式值初始化锯齿数组。以下代码创建了一个与上一个示例相同的数组,并在末尾添加了一个额外的元素:
int[][] matrix = new int[][]
{
new int[] {0,1,2},
new int[] {3,4,5},
new int[] {6,7,8,9}
};
简化的数组初始化表达式
缩短数组初始化表达式有两种方法。第一种是省略new运算符和类型限定符:
char[] vowels = {'a','e','i','o','u'};
int[,] rectangularMatrix =
{
{0,1,2},
{3,4,5},
{6,7,8}
};
int[][] jaggedMatrix =
{
new int[] {0,1,2},
new int[] {3,4,5},
new int[] {6,7,8,9}
};
(从 C# 12 开始,您可以在单维数组中使用方括号而不是大括号。)
第二种方法是使用var关键字,它指示编译器隐式地为局部变量赋予类型。以下是简单的示例:
var i = 3; // i is implicitly of type int
var s = "sausage"; // s is implicitly of type string
同样的原则也适用于数组,只是可以进一步进行。通过在new关键字后省略类型限定符,编译器推断出数组类型:
var vowels = new[] {'a','e','i','o','u'}; // Compiler infers char[]
下面是如何将其应用于多维数组的方法:
var rectMatrix = new[,] // rectMatrix is implicitly of type int[,]
{
{0,1,2},
{3,4,5},
{6,7,8}
};
var jaggedMat = new int[][] // jaggedMat is implicitly of type int[][]
{
new[] {0,1,2},
new[] {3,4,5},
new[] {6,7,8,9}
};
为了使其工作,所有元素都必须隐式转换为单一类型(至少一个元素必须是该类型,并且必须有一个最佳类型),如下例所示:
var x = new[] {1,10000000000}; // all convertible to long
边界检查
运行时对所有数组索引进行边界检查。如果使用无效索引,则会抛出IndexOutOfRangeException:
int[] arr = new int[3];
arr[3] = 1; // IndexOutOfRangeException thrown
数组边界检查对类型安全性和简化调试是必要的。
注意
通常,边界检查带来的性能损失很小,即时(JIT)编译器可以执行优化,例如在进入循环之前预先确定所有索引是否安全,从而避免每次迭代都进行检查。此外,C#提供了可以显式绕过边界检查的“不安全”代码(参见“不安全代码和指针”)。
变量和参数
变量表示具有可修改值的存储位置。变量可以是局部变量、参数(值、ref、out或in)、字段(实例或静态)或数组元素。
栈和堆
栈和堆是变量驻留的地方。它们具有非常不同的生存周期语义。
栈
栈是用于存储局部变量和参数的内存块。栈在进入和退出方法或函数时逻辑增长和收缩。考虑以下方法(为了避免分散注意力,忽略了输入参数检查):
static int Factorial (int x)
{
if (x == 0) return 1;
return x * Factorial (x-1);
}
此方法是递归的,意味着它调用自身。每次进入方法时,在堆栈上分配一个新的int,每次退出方法时,int都会被释放。
堆
堆是 对象(即引用类型实例)所驻留的内存。每当创建新对象时,它都会被分配到堆上,并返回对该对象的引用。程序执行期间,堆会随着新对象的创建而填充。运行时有一个垃圾收集器定期从堆中释放对象,以确保程序不会耗尽内存。一个对象在不再被任何“活动”的引用引用时,就有资格被回收。
在下面的示例中,我们首先创建一个由变量 ref1 引用的 StringBuilder 对象,然后输出其内容。由于后续没有任何使用它的操作,这个 StringBuilder 对象随即成为垃圾回收的对象。
接着,我们创建另一个由变量 ref2 引用的 StringBuilder,并将该引用复制给 ref3。尽管此后未再使用 ref2,但 ref3 保持对同一 StringBuilder 对象的引用,确保在我们完成对 ref3 的使用之前,它不会成为回收对象:
using System;
using System.Text;
StringBuilder ref1 = new StringBuilder ("object1");
Console.WriteLine (ref1);
// The StringBuilder referenced by ref1 is now eligible for GC.
StringBuilder ref2 = new StringBuilder ("object2");
StringBuilder ref3 = ref2;
// The StringBuilder referenced by ref2 is NOT yet eligible for GC.
Console.WriteLine (ref3); // object2
值类型实例(以及对象引用)存在于变量声明的位置。如果实例被声明为类类型的字段或数组元素,则该实例存在于堆上。
注意
在 C# 中,你无法像在 C++ 中那样显式地删除对象。一个未被引用的对象最终会被垃圾收集器收集。
堆还存储静态字段。与分配在堆上的对象不同(可以进行垃圾回收),这些字段一直存在,直到进程结束。
明确赋值
C# 强制执行明确赋值策略。在实践中,这意味着在 unsafe 或互操作上下文之外,你不能意外地访问未初始化的内存。明确赋值有三个影响:
-
局部变量在使用之前必须被赋予一个值。
-
在调用方法时,必须提供函数参数(除非标记为可选;参见 “可选参数”)。
-
其他所有变量(如字段和数组元素)都会由运行时自动初始化。
例如,以下代码会导致编译时错误:
int x;
Console.WriteLine (x); // Compile-time error
字段和数组元素会自动使用其类型的默认值进行初始化。以下代码输出 0,因为数组元素会隐式地赋值为它们的默认值:
int[] ints = new int[2];
Console.WriteLine (ints[0]); // 0
以下代码输出 0,因为字段会隐式地被赋予一个默认值(无论是实例字段还是静态字段):
Console.WriteLine (Test.X); // 0
class Test { public static int X; } // field
默认值
所有类型实例都有一个默认值。预定义类型的默认值是对内存的比特位清零的结果:
| 类型 | 默认值 |
|---|---|
| 引用类型(以及可空值类型) | null |
| 数值和枚举类型 | 0 |
char 类型 |
'\0' |
bool 类型 |
false |
你可以通过 default 关键字获取任何类型的默认值:
Console.WriteLine (default (decimal)); // 0
当类型可以被推断时,可以选择省略类型声明:
decimal d = default;
自定义值类型(即 struct)中的默认值与定义的每个字段的默认值相同。
参数
一个方法可以有一系列参数。参数定义了必须为该方法提供的参数集。在以下示例中,方法 Foo 有一个名为 p 的参数,类型为 int:
Foo (8); // 8 is an argument
static void Foo (int p) {...} // p is a parameter
您可以使用 ref、in 和 out 修饰符来控制参数的传递方式:
| 参数修饰符 | 传递方式 | 变量必须有明确的赋值 |
|---|---|---|
| (无) | 值 | 传递进去 |
ref |
引用 | 传递进去 |
in |
引用(只读) | 传递进去 |
out |
引用 | 传递出去 |
按值传递参数
默认情况下,C# 中的参数是按值传递的,这是最常见的情况。这意味着传递到方法时会创建值的副本:
int x = 8;
Foo (x); // Make a copy of x
Console.WriteLine (x); // x will still be 8
static void Foo (int p)
{
p = p + 1; // Increment p by 1
Console.WriteLine (p); // Write p to screen
}
将 p 赋予一个新值不会改变 x 的内容,因为 p 和 x 存在于不同的内存位置。
按引用类型参数传递的参数按值复制引用但不复制对象。在以下示例中,Foo 看到我们实例化的同一个 StringBuilder 对象(sb),但是具有独立的引用。换句话说,sb 和 fooSB 是引用同一个 StringBuilder 对象的不同变量:
StringBuilder sb = new StringBuilder();
Foo (sb);
Console.WriteLine (sb.ToString()); // test
static void Foo (StringBuilder fooSB)
{
fooSB.Append ("test");
fooSB = null;
}
因为 fooSB 是引用的副本,将其设置为 null 不会使 sb 变为 null。(但是,如果 fooSB 声明并使用了 ref 修饰符,sb 会 变为 null。)
ref 修饰符
要按引用传递,C# 提供了 ref 参数修饰符。在以下示例中,p 和 x 指向相同的内存位置:
int x = 8;
Foo (ref x); // Ask Foo to deal directly with x
Console.WriteLine (x); // x is now 9
static void Foo (ref int p)
{
p = p + 1; // Increment p by 1
Console.WriteLine (p); // Write p to screen
}
现在将 p 赋予一个新值会改变 x 的内容。请注意,在编写和调用方法时都需要 ref 修饰符。⁴ 这使得发生的事情非常清楚。
ref 修饰符在实现交换方法(在“泛型”中,我们展示了如何编写适用于任何类型的交换方法)中至关重要:
string x = "Penn";
string y = "Teller";
Swap (ref x, ref y);
Console.WriteLine (x); // Teller
Console.WriteLine (y); // Penn
static void Swap (ref string a, ref string b)
{
string temp = a;
a = b;
b = temp;
}
注意
参数可以按引用或按值传递,无论参数类型是引用类型还是值类型。
out 修饰符
out 参数与 ref 参数类似,除了以下情况:
-
进入函数之前不需要对其进行赋值。
-
在离开函数之前必须为其分配一个值。
out 修饰符最常用于从方法中获取多个返回值;例如:
string a, b;
Split ("Stevie Ray Vaughn", out a, out b);
Console.WriteLine (a); // Stevie Ray
Console.WriteLine (b); // Vaughn
void Split (string name, out string firstNames, out string lastName)
{
int i = name.LastIndexOf (' ');
firstNames = name.Substring (0, i);
lastName = name.Substring (i + 1);
}
像 ref 参数一样,out 参数也是按引用传递的。
Out 变量和丢弃
在调用具有 out 参数的方法时,您可以在调用时临时声明变量。我们可以用以下方式替换我们前面示例的前两行:
Split ("Stevie Ray Vaughan", out string a, out string b);
在调用具有多个 out 参数的方法时,有时您对其中一些参数的值不感兴趣。在这种情况下,您可以使用下划线“丢弃”您不感兴趣的参数:
Split ("Stevie Ray Vaughan", out string a, out _); // Discard 2nd param
Console.WriteLine (a);
在这种情况下,编译器将下划线视为一个特殊符号,称为丢弃。你可以在单个调用中包含多个丢弃。假设SomeBigMethod已经定义了七个**out**参数,我们可以忽略除了第四个之外的所有参数,如下所示:
SomeBigMethod (out _, out _, out _, out int x, out _, out _, out _);
为了向后兼容,如果实际下划线变量在作用域中,则不会生效:
string _;
Split ("Stevie Ray Vaughan", out string a, out _);
Console.WriteLine (_); // Vaughan
传递引用的影响
当您通过引用传递参数时,您将现有变量的存储位置别名化,而不是创建一个新的存储位置。在以下示例中,变量x和y表示同一个实例:
class Test
{
static int x;
static void Main() { Foo (out x); }
static void Foo (out int y)
{
Console.WriteLine (x); // x is 0
y = 1; // Mutate y
Console.WriteLine (x); // x is 1
}
}
in 修饰符
in参数类似于ref参数,但是方法不能修改参数的值(这样做会生成编译时错误)。当将大的值类型传递给方法时,这个修饰符非常有用,因为它允许编译器在传递参数之前避免复制参数的开销,同时仍然保护原始值不被修改。
仅仅基于in的存在进行重载是允许的:
void Foo ( SomeBigStruct a) { ... }
void Foo (in SomeBigStruct a) { ... }
要调用第二个重载,调用者必须使用in修饰符:
SomeBigStruct x = ...;
Foo (x); // Calls the first overload
Foo (in x); // Calls the second overload
当没有歧义时
void Bar (in SomeBigStruct a) { ... }
对于调用者来说,使用in修饰符是可选的:
Bar (x); // OK (calls the 'in' overload)
Bar (in x); // OK (calls the 'in' overload)
要使这个例子有意义,SomeBigStruct应该被定义为一个结构体(参见“结构体”)。
params 修饰符
如果params修饰符应用于方法的最后一个参数,则该方法可以接受特定类型的任意数量的参数。参数类型必须声明为(单维)数组,如下例所示:
int total = Sum (1, 2, 3, 4);
Console.WriteLine (total); // 10
// The call to Sum above is equivalent to:
int total2 = Sum (new int[] { 1, 2, 3, 4 });
int Sum (params int[] ints)
{
int sum = 0;
for (int i = 0; i < ints.Length; i++)
sum += ints [i]; // Increase sum by ints[i]
return sum;
}
如果在params位置没有参数,则创建一个长度为零的数组。
您还可以将params参数作为普通数组提供。我们示例中的第一行在语义上等同于这个:
int total = Sum (new int[] { 1, 2, 3, 4 } );
可选参数
方法、构造函数和索引器(见第三章)可以声明可选参数。如果参数在声明中指定了默认值,则该参数是可选的:
void Foo (int x = 23) { Console.WriteLine (x); }
调用方法时可以省略可选参数:
Foo(); // 23
默认参数的23实际上传递给了可选参数x——编译器将值23嵌入编译代码中的调用端。前面对Foo的调用在语义上等同于:
Foo (23);
因为编译器简单地在使用时替换可选参数的默认值。
警告
添加一个可选参数到一个从另一个程序集调用的公共方法需要重新编译两个程序集——就像这个参数是必须的一样。
可选参数的默认值必须由常量表达式、值类型的无参数构造函数或default表达式指定。可选参数不能标记为ref或out。
强制参数必须在方法声明和方法调用中之前的可选参数(例外是 params 参数,它们始终位于最后)。在以下示例中,显式值 1 被传递给 x,默认值 0 被传递给 y:
Foo (1); // 1, 0
void Foo (int x = 0, int y = 0) { Console.WriteLine (x + ", " + y); }
你可以通过将可选参数与命名参数结合使用来做相反的操作(向 x 传递默认值,向 y 传递显式值)。
命名参数
而不是按位置标识参数,你可以按名称标识参数:
Foo (x:1, y:2); // 1, 2
void Foo (int x, int y) { Console.WriteLine (x + ", " + y); }
命名参数可以按任何顺序出现。以下对 Foo 的调用在语义上是相同的:
Foo (x:1, y:2);
Foo (y:2, x:1);
注意
一个微妙的差异是参数表达式在调用现场按出现顺序进行评估。通常,这只在互相关联的具有副作用的表达式(例如下面写出 0, 1)中有所不同。
int a = 0;
Foo (y: ++a, x: --a); // ++a is evaluated first
当然,在实践中几乎肯定会避免编写这样的代码!
你可以混合使用命名和位置参数:
Foo (1, y:2);
但是有一个限制:位置参数必须在命名参数之前,除非它们在正确的位置使用。因此,你可以像这样调用 Foo:
Foo (x:1, 2); // OK. Arguments in the declared positions
但不是这样:
Foo (y:2, 1); // Compile-time error. y isn't in the first position
命名参数在与可选参数结合使用时特别有用。例如,考虑以下方法:
void Bar (int a = 0, int b = 0, int c = 0, int d = 0) { ... }
你可以只为 d 提供一个值进行调用,如下所示:
Bar (d:3);
这在调用 COM API 时特别有用,我们在第二十四章中详细讨论。
Ref Locals
C# 的一个相对生僻的特性是,你可以定义一个局部变量,引用数组中的元素或对象中的字段(从 C# 7 开始):
int[] numbers = { 0, 1, 2, 3, 4 };
ref int numRef = ref numbers [2];
在此示例中,numRef 是对 numbers[2] 的引用。当我们修改 numRef 时,我们修改了数组元素:
numRef *= 10;
Console.WriteLine (numRef); // 20
Console.WriteLine (numbers [2]); // 20
ref local 的目标必须是数组元素、字段或局部变量;不能是属性(见第三章)。Ref locals 用于专门的微优化场景,通常与ref returns一起使用。
Ref 返回
注意
我们在第二十三章中描述的 Span<T> 和 ReadOnlySpan<T> 类型使用 ref 返回来实现高效的索引器。除此类场景外,ref 返回并不常用,你可以将其视为微优化特性。
你可以从方法中返回一个ref local。这称为ref return:
class Program
{
static string x = "Old Value";
static ref string GetX() => ref x; // This method returns a ref
static void Main()
{
ref string xRef = ref GetX(); // Assign result to a ref local
xRef = "New Value";
Console.WriteLine (x); // New Value
}
}
如果在调用端省略了 ref 修饰符,则会回归到返回普通值:
string localX = GetX(); // Legal: localX is an ordinary non-ref variable.
当定义属性或索引器时,也可以使用 ref 返回:
static ref string Prop => ref x;
尽管没有 set 访问器,这样的属性在隐式上是可写的:
Prop = "New Value";
你可以通过使用 ref readonly 来防止这种修改:
static ref readonly string Prop => ref x;
ref readonly修饰符防止修改,同时仍然允许通过引用返回以获得性能提升。在这种情况下,性能提升非常小,因为x是string类型(引用类型):无论字符串有多长,你希望避免的唯一低效性只是单个 32 位或 64 位引用的复制。
尝试在ref 返回属性或索引器上定义显式的set访问器是非法的。
var—隐式类型局部变量
经常情况下,你会在一步内声明并初始化一个变量。如果编译器能够从初始化表达式推断出类型,你可以使用关键字var替代类型声明;例如:
var x = "hello";
var y = new System.Text.StringBuilder();
var z = (float)Math.PI;
这等效于以下内容:
string x = "hello";
System.Text.StringBuilder y = new System.Text.StringBuilder();
float z = (float)Math.PI;
因为这种直接等价性,隐式类型变量是静态类型的。例如,以下代码会生成编译时错误:
var x = 5;
x = "hello"; // Compile-time error; x is of type int
注意
当你无法仅通过查看变量声明来推断类型时,var可能会降低代码的可读性。例如:
Random r = new Random();
var x = r.Next();
x的类型是什么?
在“匿名类型”中,我们将描述一种必须使用var的场景。
目标类型化的新表达式
另一种减少词汇重复的方式是使用目标类型化的new 表达式(从 C# 9 开始):
System.Text.StringBuilder sb1 = new();
System.Text.StringBuilder sb2 = new ("Test");
这等效于:
System.Text.StringBuilder sb1 = new System.Text.StringBuilder();
System.Text.StringBuilder sb2 = new System.Text.StringBuilder ("Test");
原则是,如果编译器能够明确推断,可以在不指定类型名称的情况下调用new。目标类型化的new表达式特别适用于变量声明和初始化位于代码不同部分的情况。一个常见的例子是在构造函数中初始化字段时:
class Foo
{
System.Text.StringBuilder sb;
public Foo (string initialValue)
{
sb = new (initialValue);
}
}
目标类型化的new表达式在以下场景中也非常有用:
MyMethod (new ("test"));
void MyMethod (System.Text.StringBuilder sb) { ... }
表达式和操作符
一个表达式本质上表示一个值。最简单的表达式类型是常量和变量。表达式可以通过操作符进行转换和组合。操作符接受一个或多个输入操作数以生成一个新的表达式。
以下是一个常量表达式的示例:
12
我们可以使用*操作符结合两个操作数(字面表达式12和30),如下所示:
12 * 30
我们可以构建复杂的表达式,因为操作数本身可以是一个表达式,例如以下示例中的操作数(12 * 30):
1 + (12 * 30)
C#中的操作符可以被分类为一元、二元或三元,取决于它们操作的操作数数量(一个、两个或三个)。二元操作符总是使用中缀表示法,其中操作符被放置在两个操作数之间。
主表达式
主表达式包括由语言基本结构内在操作符组成的表达式。以下是一个示例:
Math.Log (1)
该表达式由两个主表达式组成。第一个表达式执行成员查找(使用.运算符),第二个表达式执行方法调用(使用()运算符)。
无值表达式
无值表达式是指没有值的表达式,比如这个:
Console.WriteLine (1)
因为它没有值,您不能将无值表达式用作操作数来构建更复杂的表达式:
1 + Console.WriteLine (1) // Compile-time error
赋值表达式
赋值表达式使用=运算符将另一个表达式的结果分配给变量;例如:
x = x * 5
赋值表达式不是无值表达式——它具有被分配的值,因此可以并入另一个表达式。在以下示例中,该表达式将2赋给x,将10赋给y:
y = 5 * (x = 2)
您可以使用这种表达式样式来初始化多个值:
a = b = c = d = 0
复合赋值运算符是将赋值与另一个运算符结合的语法快捷方式:
x *= 2 // equivalent to x = x * 2
x <<= 1 // equivalent to x = x << 1
(对这一规则的一个微妙例外是关于事件的描述,在第四章中:这里的+=和-=运算符被特殊对待,并映射到事件的add和remove访问器。)
运算符优先级和结合性
当一个表达式包含多个运算符时,优先级和结合性决定它们评估的顺序。具有较高优先级的运算符在低优先级运算符之前执行。如果运算符具有相同的优先级,则运算符的结合性决定评估的顺序。
优先级
以下表达式
1 + 2 * 3
因为*的优先级高于+,所以它被解释如下:
1 + (2 * 3)
左结合运算符
除了赋值、lambda 和空合并运算符之外,二元运算符(除了赋值、lambda 和空合并运算符)都是左结合的;换句话说,它们从左到右进行评估。例如,以下表达式
8 / 4 / 2
被解释如下:
( 8 / 4 ) / 2 // 1
您可以插入括号来改变实际的评估顺序:
8 / ( 4 / 2 ) // 4
右结合运算符
赋值运算符以及 lambda、空合并和条件运算符是右结合的;换句话说,它们从右到左进行评估。
右结合性允许多次分配,例如以下的编译:
x = y = 3;
首先将3赋给y,然后将该表达式的结果(3)赋给x。
运算符表
表格 2-3 按优先级顺序列出了 C#的运算符。同一类别中的运算符具有相同的优先级。
我们在“运算符重载”中解释了可用户重载的运算符。
表 2-3. C#运算符(按优先级顺序的类别)
| 类别 | 运算符符号 | 运算符名称 | 示例 | 可用户重载 | |
|---|---|---|---|---|---|
| 主要 | . |
成员访问 | x.y |
否 | |
?. 和 ?[] |
空条件 | x?.y 或 x?[0] |
否 | ||
!(后缀) |
空值前缀 | x!.y 或 x![0] |
否 | ||
->(不安全) |
指向结构的指针 | x->y |
否 | ||
() |
函数调用 | x() |
否 | ||
[] |
数组/索引 | a[x] |
通过索引器 | ||
++ |
后增 | x++ |
是 | ||
−− |
后减 | x−− |
是 | ||
new |
创建实例 | new Foo() |
否 | ||
stackalloc |
栈分配 | stackalloc(10) |
否 | ||
typeof |
根据标识符获取类型 | typeof(int) |
否 | ||
nameof |
获取标识符的名称 | nameof(x) |
否 | ||
checked |
整数溢出检查 | checked(x) |
否 | ||
unchecked |
整数溢出检查关闭 | unchecked(x) |
否 | ||
default |
默认值 | default(char) |
否 | ||
| 一元 | await |
等待 | await myTask |
否 | |
sizeof |
获取结构体大小 | sizeof(int) |
否 | ||
+ |
正值 | +x |
是 | ||
− |
负值 | −x |
是 | ||
! |
非 | !x |
是 | ||
~ |
按位补码 | ~x |
是 | ||
++ |
前增 | ++x |
是 | ||
−− |
前减 | −−x |
是 | ||
() |
强制转换 | (int)x |
否 | ||
^ |
从末尾索引 | array[¹] |
否 | ||
*(不安全) |
地址的值 | *x |
否 | ||
&(不安全) |
值的地址 | &x |
否 | ||
| 范围 | .. ..^ |
索引范围 | x..y x..^y |
否 |
| Switch 和 with | switch | Switch 表达式 | num switch { 1 => true,
_ => false
} | 否 |
with |
With 表达式 | rec with { X = 123 } |
否 | |
|---|---|---|---|---|
| 乘法 | * |
乘法 | x * y |
是 |
/ |
除 | x / y |
是 | |
% |
余数 | x % y |
是 | |
| 加法 | + |
加 | x + y |
是 |
− |
减 | x − y |
是 | |
| 移位 | << |
左移 | x << 1 |
是 |
>> |
右移 | x >> 1 |
是 | |
>>> |
无符号右移 | x >>> 1 |
是 | |
| 关系 | < |
小于 | x < y |
是 |
> |
大于 | x > y |
是 | |
<= |
小于或等于 | x <= y |
是 | |
>= |
大于或等于 | x >= y |
是 | |
is |
类型是或是子类 | x is y |
否 | |
as |
类型转换 | x as y |
否 | |
| 相等性 | == |
等于 | x == y |
是 |
!= |
不等于 | x != y |
是 | |
| 位与 | & |
与 | x & y |
是 |
| 位异或 | ^ |
异或 | x ^ y |
是 |
| 位或 | | |
或 | x | y |
是 |
| 条件与 | && |
条件与 | x && y |
通过 & |
| 条件或 | || |
条件或 | x || y |
通过 | |
| 空值合并 | ?? |
空值合并 | x ?? y |
否 |
| 条件 | ?: |
条件运算符 | isTrue ? thenThis : elseThis |
否 |
| 赋值和 Lambda | = |
赋值 | x = y |
否 |
*= |
自乘 | x *= 2 |
通过 * |
|
/= |
自除 | x /= 2 |
通过 / |
|
%= |
余数并赋值 | x %= 2 |
||
+= |
自增 | x += 2 |
通过 + |
|
−= |
自减赋值 | x −= 2 |
通过 − |
|
<<= |
左移赋值 | x <<= 2 |
通过 << |
|
>>= |
右移赋值 | x >>= 2 |
通过 >> |
|
>>>= |
无符号右移赋值 | x >>>= 2 |
通过 >>> |
|
&= |
按位与赋值 | x &= 2 |
通过 & |
|
^= |
按位异或赋值 | x ^= 2 |
通过 ^ |
|
|= |
按位或赋值 | x |= 2 |
通过 | |
|
??= |
空值合并赋值 | x ??= 0 |
否 | |
=> |
Lambda | x => x + 1 |
否 |
空操作符
C# 提供了三个操作符来更轻松地处理 null:空值合并运算符、空值合并赋值运算符和空值条件运算符。
空值合并运算符
?? 运算符是空值合并运算符。它表示,“如果左边的操作数非 null,则给我;否则,给我另一个值。”例如:
string s1 = null;
string s2 = s1 ?? "nothing"; // s2 evaluates to "nothing"
如果左侧表达式非 null,则不会评估右侧表达式。空值合并运算符也适用于可空值类型(参见“可空值类型”)。
空值合并赋值运算符
??= 运算符(在 C# 8 中引入)是空值合并赋值运算符。它表示,“如果左边的操作数为 null,则将右边的操作数赋给左操作数。”考虑以下情况:
myVariable ??= someDefault;
这等同于:
if (myVariable == null) myVariable = someDefault;
??= 运算符在实现延迟计算属性时特别有用。我们稍后将在“计算字段和延迟评估”中介绍这个主题。
空值条件运算符
?. 运算符是空值条件或“Elvis”运算符(以 Elvis 表情命名)。它允许您调用方法和访问成员,就像标准点运算符一样,除非左边的操作数为 null,否则表达式将计算为 null,而不是抛出 NullReferenceException:
System.Text.StringBuilder sb = null;
string s = sb?.ToString(); // No error; s instead evaluates to null
最后一行等同于以下内容:
string s = (sb == null ? null : sb.ToString());
空值条件表达式也适用于索引器:
string[] words = null;
string word = words?[1]; // word is null
遇到 null 时,Elvis 运算符将短路表达式的其余部分。在以下示例中,s 计算为 null,即使在 ToString() 和 ToUpper() 之间使用标准点运算符:
System.Text.StringBuilder sb = null;
string s = sb?.ToString().ToUpper(); // s evaluates to null without error
仅在左侧的操作数可能为 null 时才需要重复使用 Elvis。以下表达式对 x 为 null 和 x.y 为 null 都是健壮的:
x?.y?.z
它等同于以下内容(除了只计算 x.y 一次):
x == null ? null
: (x.y == null ? null : x.y.z)
最终表达式必须能够接受 null。以下是非法的:
System.Text.StringBuilder sb = null;
int length = sb?.ToString().Length; // Illegal : int cannot be null
我们可以通过使用可空值类型来解决此问题(参见“可空值类型”)。如果您已经熟悉可空值类型,这里是一个预览:
int? length = sb?.ToString().Length; // OK: int? can be null
您还可以使用空值条件运算符调用空方法:
someObject?.SomeVoidMethod();
如果 someObject 是 null,这将成为“无操作”,而不是抛出 NullReferenceException。
你可以使用空值条件运算符与我们在第三章中描述的常用类型成员,包括方法、字段、属性和索引器。它还可以很好地与空值合并运算符结合使用:
System.Text.StringBuilder sb = null;
string s = sb?.ToString() ?? "nothing"; // s evaluates to "nothing"
语句
函数由按照它们出现的文本顺序依次执行的语句组成。语句块是出现在大括号({})之间的一系列语句。
声明语句
变量声明引入一个新变量,并可选择用表达式进行初始化。你可以在逗号分隔的列表中声明多个相同类型的变量:
string someWord = "rosebud";
int someNumber = 42;
bool rich = true, famous = false;
常量声明类似于变量声明,但在声明后不能更改,并且必须在声明时进行初始化(参见“常量”):
const double c = 2.99792458E08;
c += 10; // Compile-time Error
局部变量
局部变量或局部常量的作用域在整个当前块中延伸。你不能在当前块或任何嵌套块中声明另一个同名的局部变量:
int x;
{
int y;
int x; // Error - x already defined
}
{
int y; // OK - y not in scope
}
Console.Write (y); // Error - y is out of scope
注意
变量的作用域在其代码块中向两个方向延伸。这意味着,如果我们将x的初始声明移动到方法底部,我们会得到相同的错误。这与 C++不同,并且有些特别,因为在声明之前引用变量或常量是不合法的。
表达式语句
表达式语句是有效的表达式,同时也是有效的语句。表达式语句必须改变状态或调用可能改变状态的内容。改变状态实质上意味着改变一个变量。以下是可能的表达式语句:
-
赋值表达式(包括增量和减量表达式)
-
方法调用表达式(无论是 void 还是非 void)
-
对象实例化表达式
这里有一些例子:
// Declare variables with declaration statements:
string s;
int x, y;
System.Text.StringBuilder sb;
// Expression statements
x = 1 + 2; // Assignment expression
x++; // Increment expression
y = Math.Max (x, 5); // Assignment expression
Console.WriteLine (y); // Method call expression
sb = new StringBuilder(); // Assignment expression
new StringBuilder(); // Object instantiation expression
当你调用一个构造函数或返回值的方法时,你不一定要使用这个结果。然而,除非构造函数或方法改变状态,否则这个语句完全没有用处:
new StringBuilder(); // Legal, but useless
new string ('c', 3); // Legal, but useless
x.Equals (y); // Legal, but useless
选择语句
C#有以下机制来有条件地控制程序执行流程:
-
选择语句(
if,switch) -
条件运算符(
?:) -
循环语句(
while,do-while,for,foreach)
本节涵盖了最简单的两个结构:if语句和switch语句。
if 语句
如果一个bool表达式为真,则if语句执行一个语句:
if (5 < 2 * 3)
Console.WriteLine ("true"); // true
语句可以是一个代码块:
if (5 < 2 * 3)
{
Console.WriteLine ("true");
Console.WriteLine ("Let’s move on!");
}
else 子句
if 语句可以选择包含一个else子句:
if (2 + 2 == 5)
Console.WriteLine ("Does not compute");
else
Console.WriteLine ("False"); // False
在else子句中,你可以嵌套另一个if语句:
if (2 + 2 == 5)
Console.WriteLine ("Does not compute");
else
if (2 + 2 == 4)
Console.WriteLine ("Computes"); // Computes
用括号改变执行流程
else子句始终应用于语句块中的上一个if语句:
if (true)
if (false)
Console.WriteLine();
else
Console.WriteLine ("executes");
这在语义上与以下内容相同:
if (true)
{
if (false)
Console.WriteLine();
else
Console.WriteLine ("executes");
}
我们可以通过移动括号来改变执行流程:
if (true)
{
if (false)
Console.WriteLine();
}
else
Console.WriteLine ("does not execute");
使用大括号,您明确说明了您的意图。这可以改善嵌套if语句的可读性,即使编译器不要求。一个显著的例外是以下模式:
void TellMeWhatICanDo (int age)
{
if (age >= 35)
Console.WriteLine ("You can be president!");
else if (age >= 21)
Console.WriteLine ("You can drink!");
else if (age >= 18)
Console.WriteLine ("You can vote!");
else
Console.WriteLine ("You can wait!");
}
在这里,我们已经安排了if和else语句,以模仿其他语言的“elseif”构造(以及 C#的#elif预处理器指令)。Visual Studio 的自动格式化识别此模式并保留缩进。但从语义上讲,每个跟在else语句后的if语句在功能上都是嵌套在else子句中。
switch 语句
switch语句允许您根据变量可能具有的一组可能值来分支程序执行。switch语句可能会比多个if语句生成更清晰的代码,因为switch语句只需要评估一次表达式:
void ShowCard (int cardNumber)
{
switch (cardNumber)
{
case 13:
Console.WriteLine ("King");
break;
case 12:
Console.WriteLine ("Queen");
break;
case 11:
Console.WriteLine ("Jack");
break;
case -1: // Joker is -1
goto case 12; // In this game joker counts as queen
default: // Executes for any other cardNumber
Console.WriteLine (cardNumber);
break;
}
}
此示例演示了最常见的情况,即切换到常量。当您指定常量时,您受限于内置数值类型和bool,char,string和enum类型。
在每个case子句的末尾,必须明确指定下一步执行的位置,使用某种跳转语句(除非您的代码以无限循环结束)。以下是选项:
-
break(跳转到switch语句的结尾) -
goto case *x*(跳转到另一个case子句) -
goto default(跳转到default子句) -
任何其他跳转语句——即
return,throw,continue或goto *label*
当多个值应执行相同的代码时,您可以按顺序列出通用case:
switch (cardNumber)
{
case 13:
case 12:
case 11:
Console.WriteLine ("Face card");
break;
default:
Console.WriteLine ("Plain card");
break;
}
switch语句的这个特性在生成比多个if-else语句更干净的代码方面至关重要。
切换类型
注意
切换类型是切换到模式的特殊情况。最近版本的 C#中引入了许多其他模式,请参阅“模式”进行全面讨论。
你还可以从 C# 7 中类型(来自 C# 7)切换:
TellMeTheType (12);
TellMeTheType ("hello");
TellMeTheType (true);
void TellMeTheType (object x) // object allows any type.
{
switch (x)
{
case int i:
Console.WriteLine ("It's an int!");
Console.WriteLine ($"The square of {i} is {i * i}");
break;
case string s:
Console.WriteLine ("It's a string");
Console.WriteLine ($"The length of {s} is {s.Length}");
break;
case DateTime:
Console.WriteLine ("It's a DateTime");
break;
default:
Console.WriteLine ("I don't know what x is");
break;
}
}
(object类型允许任何类型的变量;我们在“继承”和“object 类型”中对此进行了全面讨论。)
每个case子句指定要匹配的类型,以及如果匹配成功则要分配的变量(“模式”变量)。与常量不同,您可以使用任何类型,没有限制。
您可以使用when关键字对case进行断言:
switch (x)
{
case bool b when b == true: // Fires only when b is true
Console.WriteLine ("True!");
break;
case bool b:
Console.WriteLine ("False!");
break;
}
当切换到类型时,case 子句的顺序可能很重要(与切换到常量不同)。如果我们反转两个 case,此示例将产生不同的结果(事实上,它甚至无法编译,因为编译器将确定第二个 case 是不可达的)。这个规则的一个例外是default子句,它始终在最后执行,无论其出现在何处。
您可以堆叠多个 case 子句。下面代码中的Console.WriteLine将对任何大于 1,000 的浮点类型执行:
switch (x)
{
case float f when f > 1000:
case double d when d > 1000:
case decimal m when m > 1000:
Console.WriteLine ("We can refer to x here but not f or d or m");
break;
}
在本例中,编译器允许我们仅在 when 子句中使用模式变量 f、d 和 m。当调用 Console.WriteLine 时,未知哪一个变量将被赋值,因此编译器将它们全部超出范围。
你可以在同一个开关语句中混合使用常量和模式。你也可以针对 null 值进行开关:
case null:
Console.WriteLine ("Nothing here");
break;
开关表达式
自 C# 8 开始,你可以在 表达式 上下文中使用 switch。假设 cardNumber 是 int 类型,以下示例演示了其用法:
string cardName = cardNumber switch
{
13 => "King",
12 => "Queen",
11 => "Jack",
_ => "Pip card" // equivalent to 'default'
};
注意,switch 关键字出现在变量名之后,并且 case 子句是表达式(以逗号终止),而不是语句。开关表达式比其开关语句对应物更紧凑,并且可以在 LINQ 查询中使用(参见第八章)。
如果你省略了默认表达式(_)并且开关未匹配成功,将抛出异常。
你还可以针对多个值进行开关(元组 模式):
int cardNumber = 12;
string suite = "spades";
string cardName = (cardNumber, suite) switch
{
(13, "spades") => "King of spades",
(13, "clubs") => "King of clubs",
...
};
通过使用 模式 可以实现更多选项(详见“模式”)。
迭代语句
C# 允许一系列语句通过 while、do-while、for 和 foreach 语句重复执行。
while 和 do-while 循环
while 循环在 bool 表达式为 true 时重复执行代码体。在执行循环体之前测试表达式。例如,以下代码将输出 012:
int i = 0;
while (i < 3)
{
Console.Write (i);
i++;
}
do-while 循环在功能上与 while 循环只有一个不同点,即它在执行语句块之后测试表达式(确保语句块至少执行一次)。以下是使用 do-while 循环重写的前面示例:
int i = 0;
do
{
Console.WriteLine (i);
i++;
}
while (i < 3);
for 循环
for 循环与 while 循环类似,具有用于 初始化 和 迭代 循环变量的特殊子句。for 循环包含如下三个子句:
for (*initialization-clause*; *condition-clause*; *iteration-clause*)
*statement-or-statement-block*
每个子句的作用如下:
初始化子句
在循环开始之前执行;用于初始化一个或多个 迭代 变量
条件子句
一个 bool 表达式,在为 true 时执行循环体
迭代子句
在每次迭代语句块之后执行;通常用于更新迭代变量
例如,以下打印出数字 0 到 2:
for (int i = 0; i < 3; i++)
Console.WriteLine (i);
以下打印出前 10 个斐波那契数(其中每个数是前两个数的和):
for (int i = 0, prevFib = 1, curFib = 1; i < 10; i++)
{
Console.WriteLine (prevFib);
int newFib = prevFib + curFib;
prevFib = curFib; curFib = newFib;
}
for 语句的三个部分都可以省略。你可以实现类似以下的无限循环(尽管可以使用 while(true) 替代):
for (;;)
Console.WriteLine ("interrupt me");
foreach 循环
foreach 语句在可枚举对象中迭代每个元素。大多数表示元素集合或列表的 .NET 类型都是可枚举的。例如,数组和字符串都是可枚举的。以下是枚举字符串中字符的示例,从第一个字符到最后一个字符:
foreach (char c in "beer") // c is the *iteration variable*
Console.WriteLine (c);
这里是输出:
b
e
e
r
我们在 “枚举和迭代器” 中定义可枚举对象。
跳转语句
C# 跳转语句包括 break、continue、goto、return 和 throw。
注意
跳转语句遵守 try 语句的可靠性规则(参见 “try 语句和异常”)。这意味着:
-
从
try块跳出总是在达到跳转目标之前执行try的finally块。 -
不能从
finally块的内部跳到外部(除非通过throw)。
break 语句
break 语句结束循环体或 switch 语句的执行:
int x = 0;
while (true)
{
if (x++ > 5)
break; // break from the loop
}
// execution continues here after break
...
继续语句
continue 语句放弃循环中的剩余语句,并提前开始下一次迭代。以下循环跳过偶数:
for (int i = 0; i < 10; i++)
{
if ((i % 2) == 0) // If i is even,
continue; // continue with next iteration
Console.Write (i + " ");
}
OUTPUT: 1 3 5 7 9
goto 语句
goto 语句将执行转移到语句块内的另一个标签。其形式如下:
goto *statement-label*;
或者,当在 switch 语句中使用时:
goto case *case-constant*; // (Only works with constants, not patterns)
标签是代码块中语句之前的占位符,用冒号后缀表示。以下迭代 1 到 5 的数字,模拟 for 循环:
int i = 1;
startLoop:
if (i <= 5)
{
Console.Write (i + " ");
i++;
goto startLoop;
}
OUTPUT: 1 2 3 4 5
goto case *case-constant* 将执行转移到 switch 块中的另一个 case(参见 “switch 语句”)。
返回语句
return 语句退出方法,如果方法是非 void 类型,则必须返回方法返回类型的表达式:
decimal AsPercentage (decimal d)
{
decimal p = d * 100m;
return p; // Return to the calling method with value
}
return 语句可以出现在方法的任何位置(除了 finally 块),并且可以多次使用。
抛出语句
throw 语句抛出异常,指示发生错误(参见 “try 语句和异常”):
if (w == null)
throw new ArgumentNullException (...);
杂项语句
using 语句提供了一种优雅的语法,用于在对象实现 IDisposable 时调用 Dispose,在 finally 块中(参见 “try 语句和异常” 和 “IDisposable、Dispose 和 Close”)。
注意
C# 重载 using 关键字,以在不同的上下文中具有独立的含义。具体来说,using 指令 与 using 语句 不同。
lock 语句是调用 Monitor 类的 Enter 和 Exit 方法的快捷方式(参见第 14 和 23 章节)。
命名空间
命名空间是类型名称的域。类型通常组织到分层命名空间中,使其更易于查找并避免冲突。例如,处理公钥加密的 RSA 类型定义在以下命名空间中:
System.Security.Cryptography
命名空间是类型名称的一个组成部分。以下代码调用 RSA 的 Create 方法:
System.Security.Cryptography.RSA rsa =
System.Security.Cryptography.RSA.Create();
注意
命名空间与程序集独立,程序集是作为部署单元的 .dll 文件(详见第十七章)。
命名空间对成员可见性没有影响——public、internal、private 等。
namespace 关键字为该块内部的类型定义了一个命名空间;例如:
namespace Outer.Middle.Inner
{
class Class1 {}
class Class2 {}
}
命名空间中的点表示嵌套命名空间的层次结构。接下来的代码在语义上与前面的示例相同:
namespace Outer
{
namespace Middle
{
namespace Inner
{
class Class1 {}
class Class2 {}
}
}
}
可以使用完全限定名称引用类型,该名称包括从最外层到最内层的所有命名空间。例如,我们可以在前面的示例中将 Class1 称为 Outer.Middle.Inner.Class1。
没有定义在任何命名空间中的类型称为全局命名空间。全局命名空间还包括顶层命名空间,例如我们示例中的 Outer。
文件范围命名空间
通常情况下,你会希望文件中的所有类型都定义在同一个命名空间中:
namespace MyNamespace
{
class Class1 {}
class Class2 {}
}
从 C# 10 开始,你可以通过文件范围命名空间来实现这一点:
namespace MyNamespace; // Applies to everything that follows in the file.
class Class1 {} // inside MyNamespace
class Class2 {} // inside MyNamespace
文件范围的命名空间减少了混乱,并消除了不必要的缩进级别。
using 指令
using 指令导入一个命名空间,允许你引用类型而无需完全限定其名称。以下导入了前面示例的 Outer.Middle.Inner 命名空间:
using Outer.Middle.Inner;
Class1 c; // Don’t need fully qualified name
注意
定义相同类型名称在不同命名空间中是合法的(通常也是可取的)。但是,你通常只会在不太可能同时导入两个命名空间的情况下这样做。一个很好的例子是 TextBox 类,它在 System.Windows.Controls(WPF)和 System.Windows.Forms(Windows Forms)中都有定义。
using 指令可以嵌套在命名空间本身内部,以限制指令的范围。
全局 using 指令
从 C# 10 开始,如果使用 global 关键字前缀 using 指令,则该指令将应用于项目或编译单元中的所有文件:
global using System;
global using System.Collection.Generic;
这使得你可以集中常见导入并避免在每个文件中重复相同的指令。
global using 指令必须位于非全局指令之前,并且不能出现在命名空间声明内部。全局指令可以与 using static 一起使用。
隐式全局导入
从 .NET 6 开始,项目文件允许隐式 global using 指令。如果项目文件中的 ImplicitUsings 元素设置为 true(新项目的默认设置),则会自动导入以下命名空间:
System
System.Collections.Generic
System.IO
System.Linq
System.Net.Http
System.Threading
System.Threading.Tasks
根据项目 SDK(Web、Windows Forms、WPF 等),还会导入其他命名空间。
using static
using static 指令导入一个类型而不是命名空间。然后可以无需限定符使用导入类型的所有静态成员。在下面的示例中,我们调用 Console 类的静态 WriteLine 方法,无需引用类型:
using static System.Console;
WriteLine ("Hello");
using static指令导入类型的所有可访问静态成员,包括字段、属性和嵌套类型(第三章)。也可以将此指令应用于枚举类型(第三章),在这种情况下将导入其成员。因此,如果我们导入以下枚举类型:
using static System.Windows.Visibility;
我们可以指定Hidden而不是Visibility.Hidden:
var textBox = new TextBox { Visibility = Hidden }; // XAML-style
如果多个静态导入之间存在歧义,C#编译器无法从上下文中推断出正确的类型,并将生成错误。
命名空间内的规则
名称作用域
在内部命名空间中声明的名称可以在其中的内部命名空间中不带限定地使用。在此示例中,Inner内部不需要在Class1中进行限定:
namespace Outer
{
class Class1 {}
namespace Inner
{
class Class2 : Class1 {}
}
}
如果要引用命名空间层次结构中不同分支中的类型,可以使用部分限定名称。在以下示例中,我们将SalesReport基于Common.ReportBase:
namespace MyTradingCompany
{
namespace Common
{
class ReportBase {}
}
namespace ManagementReporting
{
class SalesReport : Common.ReportBase {}
}
}
名称隐藏
如果相同的类型名称同时出现在内部和外部命名空间中,则内部名称优先。要引用外部命名空间中的类型,必须限定其名称:
namespace Outer
{
class Foo { }
namespace Inner
{
class Foo { }
class Test
{
Foo f1; // = Outer.Inner.Foo
Outer.Foo f2; // = Outer.Foo
}
}
}
注意
所有类型名称在编译时转换为完全限定名称。中间语言(IL)代码不包含未限定或部分限定的名称。
重复的命名空间
您可以重复命名空间声明,只要命名空间中的类型名称不冲突:
namespace Outer.Middle.Inner
{
class Class1 {}
}
namespace Outer.Middle.Inner
{
class Class2 {}
}
我们甚至可以将示例分解为两个源文件,以便将每个类编译到不同的程序集中。
源文件 1:
namespace Outer.Middle.Inner
{
class Class1 {}
}
源文件 2:
namespace Outer.Middle.Inner
{
class Class2 {}
}
嵌套的 using 指令
在命名空间中可以嵌套using指令。这样可以在命名空间声明内部作用域限定using指令。在下面的示例中,Class1在一个作用域内可见,但在另一个作用域内不可见:
namespace N1
{
class Class1 {}
}
namespace N2
{
using N1;
class Class2 : Class1 {}
}
namespace N2
{
class Class3 : Class1 {} // Compile-time error
}
别名类型和命名空间
导入命名空间可能导致类型名称冲突。与其导入整个命名空间,你可以只导入需要的特定类型,并为每个类型指定别名:
using PropertyInfo2 = System.Reflection.PropertyInfo;
class Program { PropertyInfo2 p; }
可以对整个命名空间进行别名,如下所示:
using R = System.Reflection;
class Program { R.PropertyInfo p; }
别名任何类型(C# 12)
从 C# 12 开始,using指令可以为任何类型(例如数组)设置别名:
using NumberList = double[];
NumberList numbers = { 2.5, 3.5 };
您还可以为元组设置别名-我们将在“别名元组(C# 12)”中讨论此问题。
高级命名空间功能
Extern
外部别名允许您的程序引用具有相同完全限定名称的两种类型(即命名空间和类型名称相同)。这是一个不寻常的情况,只有当这两种类型来自不同的程序集时才会发生。考虑以下示例。
库 1,编译到Widgets1.dll:
namespace Widgets
{
public class Widget {}
}
库 2,编译到Widgets2.dll:
namespace Widgets
{
public class Widget {}
}
应用程序,引用Widgets1.dll和Widgets2.dll:
using Widgets;
Widget w = new Widget();
应用程序无法编译,因为Widget存在歧义。外部别名可以解决这种歧义。第一步是修改应用程序的.csproj文件,为每个引用分配唯一别名:
<ItemGroup>
<Reference Include="Widgets1">
<Aliases>W1</Aliases>
</Reference>
<Reference Include="Widgets2">
<Aliases>W2</Aliases>
</Reference>
</ItemGroup>
第二步是使用 extern alias 指令:
extern alias W1;
extern alias W2;
W1.Widgets.Widget w1 = new W1.Widgets.Widget();
W2.Widgets.Widget w2 = new W2.Widgets.Widget();
命名空间别名限定符
正如我们之前提到的,内部命名空间中的名称会隐藏外部命名空间中的名称。然而,有时即使使用完全限定的类型名称也无法解决冲突。请考虑以下示例:
namespace N
{
class A
{
static void Main() => new A.B(); // Instantiate class B
public class B {} // Nested type
}
}
namespace A
{
class B {}
}
Main 方法可以实例化嵌套类 B,或者命名空间 A 中的类 B。编译器总是优先考虑当前命名空间中的标识符(在本例中是嵌套类 B)。
要解决此类冲突,可以对命名空间名称进行限定,相对于以下之一:
-
全局命名空间——所有命名空间的根(用上下文关键字
global标识) -
外部别名集合
:: 符号执行命名空间别名限定。在此示例中,我们使用全局命名空间进行限定(这在自动生成的代码中最常见,用于避免名称冲突):
namespace N
{
class A
{
static void Main()
{
System.Console.WriteLine (new A.B());
System.Console.WriteLine (new global::A.B());
}
public class B {}
}
}
namespace A
{
class B {}
}
这里有一个使用别名进行限定的示例(改编自 “Extern” 中的例子):
extern alias W1;
extern alias W2;
W1::Widgets.Widget w1 = new W1::Widgets.Widget();
W2::Widgets.Widget w2 = new W2::Widgets.Widget();
¹ 一个小的注意事项是,非常大的 long 值在转换为 double 时会失去一些精度。
² 从技术上讲,decimal 也是一种浮点类型,尽管在 C# 语言规范中没有这样称呼。
³ 可以重载这些运算符(参见第四章),使其返回非bool类型,但实际上这几乎从不会这样做。
⁴ 这个规则的一个例外是调用组件对象模型 (COM) 方法时。我们在第二十五章中讨论过这个问题。
第三章:在 C# 中创建类型
在本章中,我们深入探讨了类型和类型成员。
类
类是最常见的引用类型。最简单的类声明如下:
class YourClassName
{
}
更复杂的类可选包括以下内容:
在关键字 class 前面 |
属性 和 类修饰符。非嵌套类修饰符包括 public、internal、abstract、sealed、static、unsafe 和 partial。 |
|---|---|
在 YourClassName 之后 |
泛型类型参数 和 约束,一个 基类 和 接口。 |
| 在大括号内 | 类成员(这些是 方法、属性、索引器、事件、字段、构造函数、重载操作符、嵌套类型 和 终结器)。 |
本章覆盖了所有这些结构,除了属性、操作符函数和 unsafe 关键字,这些在 第四章 中详细讨论。以下各节列举了每个类成员。
字段
字段 是类或结构的成员变量;例如:
class Octopus
{
string name;
public int Age = 10;
}
字段允许以下修饰符:
| 静态修饰符 | static |
|---|---|
| 访问修饰符 | public internal private protected |
| 继承修饰符 | new |
| 不安全代码修饰符 | unsafe |
| 只读修饰符 | readonly |
| 线程修饰符 | volatile |
私有字段有两种常用的命名约定:小驼峰式(例如,firstName),以及带下划线的小驼峰式(_firstName)。后一种约定使你能够立即区分私有字段和参数以及局部变量。
只读修饰符
readonly 修饰符防止字段在构造后被修改。只读字段只能在其声明中或在封闭类型的构造函数中赋值。
字段初始化
字段初始化是可选的。未初始化的字段具有默认值(0、'\0'、null、false)。字段初始化器在构造函数之前运行:
public int Age = 10;
字段初始化器可以包含表达式并调用方法:
static readonly string TempFolder = System.IO.Path.GetTempPath();
声明多个字段一起
为了方便起见,你可以在逗号分隔的列表中声明多个相同类型的字段。这是所有字段共享相同属性和字段修饰符的便捷方式:
static readonly int legs = 8,
eyes = 2;
常量
常量 在编译时静态评估,并且编译器在使用时字面上替换它的值(有点像 C++ 中的宏)。常量可以是 bool、char、string,任何内置数值类型,或者枚举类型。
使用 const 关键字声明常量,并且必须用一个值进行初始化。例如:
public class Test
{
public const string Message = "Hello World";
}
常量可以类似于 static readonly 字段,但限制更多——包括可以使用的类型以及字段初始化语义。常量与 static readonly 字段的不同之处在于常量的评估发生在编译时;因此
public static double Circumference (double radius)
{
return 2 * System.Math.PI * radius;
}
编译为
public static double Circumference (double radius)
{
return 6.2831853071795862 * radius;
}
将PI定义为常量是有意义的,因为其值在编译时确定。相比之下,static readonly字段的值可能每次程序运行时都有所不同:
static readonly DateTime StartupTime = DateTime.Now;
注意
当向其他装配件暴露可能在后续版本中更改的值时,static readonly字段也很有优势。例如,假设装配件X将常量暴露如下:
public const decimal ProgramVersion = 2.3;
如果装配件Y引用X并使用此常量,则编译时装配件Y将使用值2.3。这意味着如果稍后使用常量重新编译X为 2.4,则Y仍将使用旧值 2.3,直到重新编译Y。使用static readonly字段可以避免此问题。
另一种看待这个问题的方式是,任何可能在将来改变的值从定义上来说都不是常量;因此,不应该将其表示为常量。
常量也可以声明为方法的局部变量:
void Test()
{
const double twoPI = 2 * System.Math.PI;
...
}
非局部常量允许以下修饰符:
| 访问修饰符 | public internal private protected |
|---|---|
| 继承修饰符 | new |
方法
方法通过一系列语句执行操作。方法可以通过指定参数和返回类型从调用者那里接收输入数据,并将输出数据返回给调用者。方法可以指定void返回类型,表示不向其调用者返回任何值。方法还可以通过ref/out参数向调用者输出数据。
方法的签名在类型内必须是唯一的。方法的签名由其名称和参数类型按顺序组成(但不包括参数名称,也不包括返回类型)。
方法允许以下修饰符:
| 静态修饰符 | static |
|---|---|
| 访问修饰符 | public internal private protected |
| 继承修饰符 | new virtual abstract override sealed |
| 部分方法修饰符 | partial |
| 未管理代码修饰符 | unsafe extern |
| 异步代码修饰符 | async |
表达式主体方法
方法可以由单个表达式组成,例如
int Foo (int x) { return x * 2; }
可以更简洁地编写为表达式主体方法。一个箭头符号替代大括号和return关键字:
int Foo (int x) => x * 2;
表达式主体函数也可以具有void返回类型:
void Foo (int x) => Console.WriteLine (x);
局部方法
可在另一个方法内定义方法:
void WriteCubes()
{
Console.WriteLine (Cube (3));
Console.WriteLine (Cube (4));
Console.WriteLine (Cube (5));
int Cube (int value) => value * value * value;
}
局部方法(本例中的Cube)仅对封闭方法(WriteCubes)可见。这简化了包含类型,并立即向查看代码的任何人表明Cube没有其他用途。局部方法的另一个好处是可以访问封闭方法的局部变量和参数。我们将详细描述这些的几个后果,见“捕获外部变量”。
局部方法可以出现在其他函数类型中,例如属性访问器、构造函数等。甚至可以将局部方法放在其他局部方法和使用语句块的 lambda 表达式中(第四章)。局部方法可以是迭代器(第四章)或异步的(第十四章)。
静态局部方法
将static修饰符添加到局部方法(从 C# 8 开始)可以防止其访问封闭方法的局部变量和参数。这有助于减少耦合并防止局部方法意外引用包含方法中的变量。
局部方法和顶层语句
在顶层语句中声明的任何方法都视为局部方法。这意味着(除非标记为static),它们可以访问顶层语句中的变量:
int x = 3;
Foo();
void Foo() => Console.WriteLine (x);
方法重载
警告
局部方法不能被重载。这意味着在顶层语句中声明的方法(视为局部方法)不能被重载。
类型可以重载方法(定义多个具有相同名称的方法),只要签名不同。例如,以下方法可以在同一类型中并存:
void Foo (int x) {...}
void Foo (double x) {...}
void Foo (int x, float y) {...}
void Foo (float x, int y) {...}
但是,以下方法对不能在同一类型中并存,因为返回类型和params修饰符不是方法签名的一部分:
void Foo (int x) {...}
float Foo (int x) {...} // Compile-time error
void Goo (int[] x) {...}
void Goo (params int[] x) {...} // Compile-time error
是否参数是传值还是传引用也是签名的一部分。例如,Foo(int)可以与Foo(ref int)或Foo(out int)并存。但是,Foo(ref int)和Foo(out int)不能共存:
void Foo (int x) {...}
void Foo (ref int x) {...} // OK so far
void Foo (out int x) {...} // Compile-time error
实例构造函数
构造函数在类或结构体上运行初始化代码。构造函数的定义类似于方法,但方法名称和返回类型缩减为封闭类型的名称:
Panda p = new Panda ("Petey"); // Call constructor
public class Panda
{
string name; // Define field
public Panda (string n) // Define constructor
{
name = n; // Initialization code (set up field)
}
}
实例构造函数允许以下修饰符:
| 访问修饰符 | public internal private protected |
|---|---|
| 非托管代码修饰符 | unsafe extern |
单语句构造函数也可以写为表达式主体成员:
public Panda (string n) => name = n;
注意
如果参数名称(或者任何变量名称)与字段名称冲突,您可以通过在字段前加上this引用来消除歧义:
public Panda (string name) => this.name = name;
构造函数重载
类或结构体可以重载构造函数。为了避免代码重复,一个构造函数可以调用另一个,使用this关键字:
public class Wine
{
public decimal Price;
public int Year;
public Wine (decimal price) => Price = price;
public Wine (decimal price, int year) : this (price) => Year = year;
}
当一个构造函数调用另一个构造函数时,被调用的构造函数先执行。
您可以将一个表达式传递到另一个构造函数中,如下所示:
public Wine (decimal price, DateTime year) : this (price, year.Year) { }
表达式可以访问类的静态成员,但不能访问实例成员。(这是强制执行的,因为在此阶段对象尚未通过构造函数进行初始化,因此调用它的任何方法可能会失败。)
注意
这个特定的例子最好用一个具有year作为可选参数的单一构造函数来实现:
public Wine (decimal price, int year = 0)
{
Price = price; Year = year;
}
我们将在稍后的 “对象初始化器” 中提供另一个解决方案。
隐式无参构造函数
对于类,只有在不定义任何构造函数时,C# 编译器才会自动生成一个无参公共构造函数。但是,一旦您定义了至少一个构造函数,无参构造函数将不再自动生成。
构造函数和字段初始化顺序
我们先前看到字段可以在其声明中使用默认值进行初始化:
class Player
{
int shields = 50; // Initialized first
int health = 100; // Initialized second
}
字段初始化发生在构造函数执行之前,并按字段的声明顺序进行。
非公共构造函数
构造函数不必是公共的。具有非公共构造函数的常见原因是通过静态方法调用来控制实例创建。静态方法可以用于从池中返回对象,而不是创建新对象,或者根据输入参数返回各种子类:
public class Class1
{
Class1() {} // Private constructor
public static Class1 Create (...)
{
// Perform custom logic here to return an instance of Class1
...
}
}
解构器
解构方法(也称为解构方法)充当构造函数的近似对立面:构造函数通常接受一组值(作为参数)并将它们分配给字段,而解构方法则反之,将字段分配回一组变量。
解构方法必须被称为 Deconstruct,并且必须有一个或多个 out 参数,例如以下类:
class Rectangle
{
public readonly float Width, Height;
public Rectangle (float width, float height)
{
Width = width;
Height = height;
}
public void Deconstruct (out float width, out float height)
{
width = Width;
height = Height;
}
}
以下特殊语法调用解构方法:
var rect = new Rectangle (3, 4);
(float width, float height) = rect; // Deconstruction
Console.WriteLine (width + " " + height); // 3 4
第二行是解构调用。它创建两个局部变量,然后调用 Deconstruct 方法。我们的解构调用等效于以下内容:
float width, height;
rect.Deconstruct (out width, out height);
或者:
rect.Deconstruct (out var width, out var height);
解构调用允许隐式类型推断,因此我们可以将我们的调用简写为这样:
(var width, var height) = rect;
或者简单地这样:
var (width, height) = rect;
注意
如果您对一个或多个变量不感兴趣,可以使用 C# 的丢弃符号(_):
var (_, height) = rect;
这比声明一个从未使用的变量更能表明您的意图。
如果您解构的变量已经定义,可以完全省略类型:
float width, height;
(width, height) = rect;
这称为解构赋值。您可以使用解构赋值来简化类的构造函数:
public Rectangle (float width, float height) =>
(Width, Height) = (width, height);
通过重载 Deconstruct 方法,您可以为调用者提供一系列解构选项。
注意
Deconstruct 方法可以是一个扩展方法(参见 “扩展方法”)。如果要解构您未编写的类型,这是一个有用的技巧。
从 C# 10 开始,在解构时可以混合使用现有变量和新变量:
double x1 = 0;
(x1, double y2) = rect;
对象初始化器
为了简化对象初始化,可以直接在构造之后通过对象初始化器设置对象的任何可访问字段或属性。例如,请考虑以下类:
public class Bunny
{
public string Name;
public bool LikesCarrots, LikesHumans;
public Bunny () {}
public Bunny (string n) => Name = n;
}
使用对象初始化器,您可以像以下方式实例化 Bunny 对象:
// Note parameterless constructors can omit empty parentheses
Bunny b1 = new Bunny { Name="Bo", LikesCarrots=true, LikesHumans=false };
Bunny b2 = new Bunny ("Bo") { LikesCarrots=true, LikesHumans=false };
构造 b1 和 b2 的代码与以下完全等效:
Bunny *temp1* = new Bunny(); // *temp1* is a compiler-generated name
*temp1*.Name = "Bo";
*temp1*.LikesCarrots = true;
*temp1*.LikesHumans = false;
Bunny b1 = *temp1*;
Bunny *temp2* = new Bunny ("Bo");
*temp2*.LikesCarrots = true;
*temp2*.LikesHumans = false;
Bunny b2 = *temp2*;
临时变量的作用是确保在初始化过程中抛出异常时,不会得到一个半初始化的对象。
this 引用
this 引用指的是实例本身。在以下示例中,Marry方法使用this来设置partner的mate字段:
public class Panda
{
public Panda Mate;
public void Marry (Panda partner)
{
Mate = partner;
partner.Mate = this;
}
}
this 引用还可以消除局部变量或参数与字段之间的歧义;例如:
public class Test
{
string name;
public Test (string name) => this.name = name;
}
this引用仅在类或结构的非静态成员中有效。
属性
从外部看,属性看起来像字段,但在内部它们包含逻辑,就像方法一样。例如,通过查看以下代码,您无法确定CurrentPrice是字段还是属性:
Stock msft = new Stock();
msft.CurrentPrice = 30;
msft.CurrentPrice -= 3;
Console.WriteLine (msft.CurrentPrice);
属性声明与字段类似,但添加了get/set块。以下是如何将CurrentPrice实现为属性的示例:
public class Stock
{
decimal currentPrice; // The private "backing" field
public decimal CurrentPrice // The public property
{
get { return currentPrice; }
set { currentPrice = value; }
}
}
get和set表示属性的访问器。当读取属性时,get访问器运行。它必须返回属性类型的值。当分配属性时,set访问器运行。它有一个名为value的隐式参数,类型为属性的类型,通常将其分配给私有字段(在本例中为currentPrice)。
虽然属性的访问方式与字段相同,但它们的不同之处在于,它们使实现者完全控制获取和设置其值。此控制使实现者能够选择所需的任何内部表示方式,而不会向属性的用户公开内部细节。在此示例中,如果value超出有效值范围,则set方法可能会引发异常。
注意
在本书中,我们广泛使用公共字段,以保持示例的干净。在实际应用中,您通常会更倾向于使用公共属性而不是公共字段,以促进封装。
属性允许以下修饰符:
| 静态修饰符 | static |
|---|---|
| 访问修饰符 | public internal private protected |
| 继承修饰符 | new virtual abstract override sealed |
| 无管理代码修饰符 | unsafe extern |
只读和计算属性
如果属性仅指定了get访问器,则它是只读的;如果属性仅指定了set访问器,则它是只写的。很少使用只写属性。
属性通常具有专用的后备字段来存储基础数据。但是,属性也可以从其他数据计算得出:
decimal currentPrice, sharesOwned;
public decimal Worth
{
get { return currentPrice * sharesOwned; }
}
表达式主体属性
您可以将只读属性(例如前面示例中的属性)更简洁地声明为表达式主体属性。一个粗箭头替换了所有大括号、get和return关键字:
public decimal Worth => currentPrice * sharesOwned;
加上一些额外的语法,set访问器也可以是表达式主体的:
public decimal Worth
{
get => currentPrice * sharesOwned;
set => sharesOwned = value / currentPrice;
}
自动属性
属性的最常见实现是仅读取和写入与属性相同类型的私有字段的 getter 和/或 setter。自动属性声明指示编译器提供此实现。我们可以通过将CurrentPrice声明为自动属性来改进本节中的第一个示例:
public class Stock
{
...
public decimal CurrentPrice { get; set; }
}
编译器会自动生成一个私有后备字段,其名称为编译器生成的名称,不可引用。如果要将set访问器标记为private或protected,则可以将属性公开为其他类型的只读。自动属性是在 C# 3.0 中引入的。
属性初始化器
您可以像字段一样为自动属性添加属性初始化器:
public decimal CurrentPrice { get; set; } = 123;
这使得CurrentPrice的初始值为123。具有初始化程序的属性可以是只读的:
public int Maximum { get; } = 999;
与只读字段一样,只读自动属性也可以在类型的构造函数中分配。这在创建不可变(只读)类型时非常有用。
获取和设置的可访问性
get和set访问器可以具有不同的访问级别。此的典型用例是在setter上具有internal或private访问修饰符的public属性:
public class Foo
{
private decimal x;
public decimal X
{
get { return x; }
private set { x = Math.Round (value, 2); }
}
}
注意,您声明属性本身的访问级别更宽松(在本例中为public),并将修饰符添加到您希望较不访问的访问器。
仅初始化的设置器
从 C# 9 开始,可以使用init而不是set声明属性访问器:
public class Note
{
public int Pitch { get; init; } = 20; // “Init-only” property
public int Duration { get; init; } = 100; // “Init-only” property
}
这些仅初始化属性的作用类似于只读属性,但也可以通过对象初始化器进行设置:
var note = new Note { Pitch = 50 };
之后,属性无法更改:
note.Pitch = 200; // Error – init-only setter!
除了通过属性初始化器、构造函数或另一个仅初始化访问器,仅初始化属性甚至不能从其类内部设置。
仅初始化属性的替代方法是通过构造函数填充的只读属性:
public class Note
{
public int Pitch { get; }
public int Duration { get; }
public Note (int pitch = 20, int duration = 100)
{
Pitch = pitch; Duration = duration;
}
}
如果类是公共库的一部分,则此方法使得后期在构造函数中添加可选参数时版本控制变得困难,因为这会破坏与消费者的二进制兼容性(而添加新的仅初始化属性则不会破坏任何内容)。
注意
仅初始化属性还有另一个重要优势,即在与记录结合使用时允许非破坏性变异(参见“记录”)。
与普通set访问器一样,仅初始化访问器也可以提供一个实现:
public class Note
{
readonly int _pitch;
public int Pitch { get => _pitch; init => _pitch = value; }
...
注意,_pitch字段是只读的:仅初始化的设置器允许修改其自身类中的readonly字段。(如果没有此功能,_pitch将需要可写,并且类将无法内部实现不可变性。)
警告
将属性的访问器从init更改为set(或反之)是二进制破坏性更改:任何引用您程序集的人都需要重新编译他们的程序集。
在创建完全不可变类型时,这应该不是问题,因为您的类型永远不需要具有(可写)set访问器的属性。
CLR 属性实现
C# 属性访问器在内部编译为称为get_*XXX*和set_*XXX*的方法:
public decimal get_CurrentPrice {...}
public void set_CurrentPrice (decimal value) {...}
init 访问器处理类似于 set 访问器,但是在 set 访问器的“modreq”元数据中编码了一个额外的标志(请参阅 “仅初始化属性”)。
Just-In-Time (JIT) 编译器通过内联简单的非虚拟属性访问器,消除了访问属性和字段之间的任何性能差异。内联是一种优化技术,其中方法调用被该方法的主体替换。
索引器
Indexers 提供了一种自然的语法,用于访问封装了值列表或字典的类或结构体中的元素。Indexers 类似于属性,但通过索引参数访问,而不是属性名。string 类具有一个索引器,允许您通过 int 索引访问其每个 char 值:
string s = "hello";
Console.WriteLine (s[0]); // 'h'
Console.WriteLine (s[3]); // 'l'
使用索引器的语法类似于使用数组,不同之处在于索引参数可以是任何类型。
索引器具有与属性相同的修饰符(请参阅 “属性”)并且可以通过在方括号前插入问号来在空安全方式下调用(请参阅 “Null 操作符”):
string s = null;
Console.WriteLine (s?[0]); // Writes nothing; no error.
实现一个索引器
要编写一个索引器,定义一个名为 this 的属性,指定方括号中的参数:
class Sentence
{
string[] words = "The quick brown fox".Split();
public string this [int wordNum] // indexer
{
get { return words [wordNum]; }
set { words [wordNum] = value; }
}
}
下面是我们如何使用这个索引器的方法:
Sentence s = new Sentence();
Console.WriteLine (s[3]); // fox
s[3] = "kangaroo";
Console.WriteLine (s[3]); // kangaroo
类型可以声明多个索引器,每个索引器具有不同类型的参数。索引器还可以接受多个参数:
public string this [int arg1, string arg2]
{
get { ... } set { ... }
}
如果省略 set 访问器,则索引器变为只读,并且可以使用表达式主体语法来缩短其定义:
public string this [int wordNum] => words [wordNum];
CLR 索引器实现
索引器在内部编译为名为 get_Item 和 set_Item 的方法,如下所示:
public string get_Item (int wordNum) {...}
public void set_Item (int wordNum, string value) {...}
使用索引和范围与索引器
您可以通过定义一个具有 Index 或 Range 参数类型的索引器来在自己的类中支持索引和范围(请参阅 “索引和范围”)。我们可以通过向 Sentence 类添加以下索引器来扩展我们之前的示例:
public string this [Index index] => words [index];
public string[] this [Range range] => words [range];
然后可以启用以下功能:
Sentence s = new Sentence();
Console.WriteLine (s [¹]); // fox
string[] firstTwoWords = s [..2]; // (The, quick)
主构造函数 (C# 12)
从 C# 12 开始,您可以在类(或结构体)声明之后直接包含一个参数列表:
class Person (string firstName, string lastName)
{
public void Print() => Console.WriteLine (firstName + " " + lastName);
}
这指示编译器使用 主构造函数参数(firstName 和 lastName)自动构建一个 主构造函数,以便我们可以按以下方式实例化我们的类:
Person p = new Person ("Alice", "Jones");
p.Print(); // Alice Jones
主构造函数对于原型设计和其他简单场景非常有用。另一种选择是定义字段并显式编写构造函数:
class Person // (without primary constructors)
{
string firstName, lastName; // Field declarations
public Person (string firstName, string lastName) // Constructor
{
this.firstName = firstName; // Assign field
this.lastName = lastName; // Assign field
}
public void Print() => Console.WriteLine (firstName + " " + lastName);
}
C# 构建的构造函数被称为主构造函数,因为您选择(显式)编写的任何其他构造函数必须调用它:
class Person (string firstName, string lastName)
{
public Person (string firstName, string lastName, int age)
: this (firstName, lastName) // Must call the primary constructor
{
...
}
}
这确保主构造函数参数始终被 始终填充。
注意
C# 还提供了 records,我们在 “Records” 中介绍。记录也支持主构造函数;然而,编译器对记录采取额外步骤,并生成(默认情况下)每个主构造函数参数的公共 init-only 属性。如果需要此行为,请考虑改用记录。
主构造函数最适合简单场景,因为以下限制:
-
不能向主构造函数添加额外的初始化代码。
-
虽然很容易将主构造函数参数公开为公共属性,但除非属性是只读的,否则你无法轻松地集成验证逻辑。
主构造函数替换了 C# 否则会生成的默认无参数构造函数。
主构造函数语义
要理解主构造函数的工作原理,请考虑普通构造函数的行为:
class Person
{
public Person (string firstName, string lastName)
{
* ... do something with firstName, lastName*
}
}
当此构造函数内的代码执行完毕时,参数firstName和lastName将超出作用域,并且不能随后访问。相反,主构造函数的参数不会超出作用域,并且可以在类内的任何地方访问对象的生命周期内。
注意
主构造函数参数是特殊的 C# 结构,不是字段,尽管编译器在幕后确实会生成隐藏字段来存储它们的值(如果需要的话)。
主构造函数和字段/属性初始化器
主构造函数参数的可访问性延伸到字段和属性初始化器。在以下示例中,我们使用字段和属性初始化器将firstName分配给公共字段,并将lastName分配给公共属性:
class Person (string firstName, string lastName)
{
public readonly string FirstName = firstName; // Field
public string LastName { get; } = lastName; // Property
}
掩盖主构造函数参数
字段(或属性)可以重用主构造函数参数名称:
class Person (string firstName, string lastName)
{
readonly string firstName = firstName;
readonly string lastName = lastName;
public void Print() => Console.WriteLine (firstName + " " + lastName);
}
在此场景中,字段或属性优先,从而掩盖主构造函数参数,除非在字段和属性初始化器的右侧(以粗体显示)。
注意
就像普通参数一样,主构造函数参数是可写的。使用同名的readonly字段(如我们的示例中所示),有效地保护它们免受后续修改。
验证主构造函数参数
有时在字段初始化器中执行计算是有用的:
new Person ("Alice", "Jones").Print(); // Alice Jones
class Person (string firstName, string lastName)
{
public readonly string FullName = firstName + " " + lastName;
public void Print() => Console.WriteLine (FullName);
}
在下一个示例中,我们将lastName的大写版本保存到同名字段中(掩盖原始值):
new Person ("Alice", "Jones").Print(); // Alice JONES
class Person (string firstName, string lastName)
{
readonly string lastName = lastName.ToUpper();
public void Print() => Console.WriteLine (firstName + " " + lastName);
}
在 “throw expressions” 中,我们描述了在遇到无效数据等情况时如何抛出异常。以下是一个预览,演示如何使用主构造函数验证lastName在构造时不为 null:
new Person ("Alice", null); // throws ArgumentNullException
class Person (string firstName, string lastName)
{
readonly string lastName = (lastName == null)
? throw new ArgumentNullException ("lastName")
: lastName;
}
(请记住,在对象构造时执行字段或属性初始化器中的代码,而不是在访问字段或属性时执行。)在下一个示例中,我们将主构造函数参数公开为读/写属性:
class Person (string firstName, string lastName)
{
public string LastName { get; set; } = lastName;
}
在这个例子中添加验证并不简单,因为你必须在两个地方验证:在(手动实现的)属性 set 访问器中和属性初始化器中。 (如果属性定义为仅初始化,则存在相同的问题。)在这一点上,放弃主要构造函数的捷径并显式定义构造函数和后备字段更容易。
静态构造函数
静态构造函数每个类型只执行一次,而不是每个实例。类型只能定义一个静态构造函数,它必须是无参数的,并且与类型同名:
class Test
{
static Test() { Console.WriteLine ("Type Initialized"); }
}
运行时会在类型被使用之前自动调用静态构造函数。两件事会触发这一点:
-
实例化类型
-
访问类型中的静态成员
静态构造函数允许的唯一修饰符是 unsafe 和 extern。
警告
如果静态构造函数抛出未处理的异常(第四章),该类型将在应用程序的整个生命周期中变得不可用。
注意
从 C# 9 开始,您还可以定义模块初始化器,这些初始化器在程序集每次加载时执行一次。要定义模块初始化器,请编写一个静态 void 方法,然后将 [ModuleInitializer] 属性应用于该方法:
[System.Runtime.CompilerServices.ModuleInitializer]
internal static void InitAssembly()
{
...
}
静态构造函数和字段初始化顺序
静态字段初始化器在调用静态构造函数之前运行。如果类型没有静态构造函数,则静态字段初始化器将在类型被使用之前执行——或者在运行时任意更早的时间执行。
静态字段初始化器按照字段声明的顺序运行。以下示例说明了这一点。X 被初始化为 0,而 Y 被初始化为 3:
class Foo
{
public static int X = Y; // 0
public static int Y = 3; // 3
}
如果我们调换两个字段初始化器的顺序,两个字段都将初始化为 3。下一个示例将打印出 0,然后是 3,因为在将 X 初始化为 3 之前实例化 Foo 的字段初始化器先执行:
Console.WriteLine (Foo.X); // 3
class Foo
{
public static Foo Instance = new Foo();
public static int X = 3;
Foo() => Console.WriteLine (X); // 0
}
如果我们交换粗体部分的两行,则示例将打印出 3 后面跟着 3。
静态类
标记为static的类不能实例化或派生,并且必须仅由静态成员组成。 System.Console 和 System.Math 类是静态类的良好示例。
终结器
终结器是仅限于类的方法,在垃圾收集器回收未引用对象的内存之前执行。终结器的语法是类名前缀为 ~ 符号:
class Class1
{
~Class1()
{
...
}
}
这实际上是 C# 中覆盖 Object 的 Finalize 方法的语法,编译器将其扩展为以下方法声明:
protected override void Finalize()
{
...
base.Finalize();
}
我们在第十二章中全面讨论了垃圾收集和终结器。
您可以使用表达式体语法编写单语句终结器:
~Class1() => Console.WriteLine ("Finalizing");
部分类型和方法
部分类型允许将类型定义分割-通常跨多个文件。常见的情况是从其他来源(如 Visual Studio 模板或设计者)自动生成部分类,并且该类用额外的手动编写的方法增强:
// PaymentFormGen.cs - auto-generated
partial class PaymentForm { ... }
// PaymentForm.cs - hand-authored
partial class PaymentForm { ... }
每个参与者必须有partial声明;以下内容是非法的:
partial class PaymentForm {}
class PaymentForm {}
参与者不能有冲突的成员。例如,具有相同参数的构造函数不能重复。部分类型完全由编译器解析,这意味着每个参与者在编译时必须可用,并且必须驻留在同一个程序集中。
您可以在一个或多个部分类声明上指定一个基类,只要基类(如果指定)相同即可。此外,每个参与者可以独立指定要实现的接口。我们在“继承”和“接口”中介绍基类和接口。
编译器不保证部分类型声明之间的字段初始化顺序。
部分方法
部分类型可以包含部分方法。这些方法允许自动生成的部分类型为手动编写提供可定制的钩子;例如:
partial class PaymentForm // In auto-generated file
{
...
partial void ValidatePayment (decimal amount);
}
partial class PaymentForm // In hand-authored file
{
...
partial void ValidatePayment (decimal amount)
{
if (amount > 100)
...
}
}
部分方法由两部分组成:定义和实现。定义通常由代码生成器编写,实现通常由手动编写。如果未提供实现,则部分方法的定义将被编译器删除(调用它的代码也会被删除)。这使得自动生成的代码可以自由地提供钩子,而不必担心膨胀。部分方法必须是void,并且隐式为private。它们不能包含out参数。
扩展的部分方法
扩展的部分方法(来自 C# 9)旨在用于反向代码生成场景,其中程序员定义代码生成器实现的钩子。这种情况的示例是源生成器,这是 Roslyn 的一个功能,允许您向编译器提供一个自动生成代码部分的程序集。
如果部分方法声明以访问修饰符开头,则称为扩展部分方法。
public partial class Test
{
public partial void M1(); // Extended partial method
private partial void M2(); // Extended partial method
}
访问修饰符的存在不仅影响可访问性:它告诉编译器以不同的方式处理声明。
扩展的部分方法必须有实现;如果未实现,它们不会消失。在这个例子中,M1和M2必须都有实现,因为它们各自指定了访问修饰符(public和private)。
由于它们不会消失,扩展的部分方法可以返回任何类型,并且可以包含out参数:
public partial class Test
{
public partial bool IsValid (string identifier);
internal partial bool TryParse (string number, out int result);
}
nameof 运算符
nameof运算符返回任何符号(类型、成员、变量等)的名称作为字符串:
int count = 123;
string name = nameof (count); // name is "count"
与仅指定字符串相比,它的优势在于静态类型检查。像 Visual Studio 这样的工具可以理解符号引用,因此如果您重命名该符号,所有引用也将被重命名。
要指定类型成员(如字段或属性)的名称,请同时包含类型。这适用于静态和实例成员:
string name = nameof (StringBuilder.Length);
这个求值结果是Length。要返回StringBuilder.Length,你可以这样做:
nameof (StringBuilder) + "." + nameof (StringBuilder.Length);
继承
一个类可以从另一个类继承,以扩展或定制原始类。从类继承可以重用该类中的功能,而不是从头开始构建。一个类只能从一个类继承,但可以被多个类继承,从而形成一个类层次结构。在这个例子中,我们首先定义了一个名为Asset的类:
public class Asset
{
public string Name;
}
接下来,我们定义了名为Stock和House的类,它们将从Asset继承。Stock和House获得Asset拥有的一切,以及它们自己定义的任何额外成员:
public class Stock : Asset // inherits from Asset
{
public long SharesOwned;
}
public class House : Asset // inherits from Asset
{
public decimal Mortgage;
}
下面是如何使用这些类的示例:
Stock msft = new Stock { Name="MSFT",
SharesOwned=1000 };
Console.WriteLine (msft.Name); // MSFT
Console.WriteLine (msft.SharesOwned); // 1000
House mansion = new House { Name="Mansion",
Mortgage=250000 };
Console.WriteLine (mansion.Name); // Mansion
Console.WriteLine (mansion.Mortgage); // 250000
派生类Stock和House从基类Asset继承了Name字段。
注意
派生类也称为子类。
基类也称为超类。
多态性
引用是多态的。这意味着类型为x的变量可以引用一个子类x的对象。例如,考虑以下方法:
public static void Display (Asset asset)
{
System.Console.WriteLine (asset.Name);
}
此方法可以显示Stock和House,因为它们都是Asset:
Stock msft = new Stock ... ;
House mansion = new House ... ;
Display (msft);
Display (mansion);
多态性基于子类(Stock和House)拥有其基类(Asset)的所有特征。反之则不然。如果修改Display以接受House,则无法传递Asset:
Display (new Asset()); // Compile-time error
public static void Display (House house) // Will not accept Asset
{
System.Console.WriteLine (house.Mortgage);
}
引用类型转换和引用转换
对象引用可以是:
-
隐式地向上转型到基类引用
-
显式地向下转型到子类引用
在兼容的引用类型之间进行向上转型和向下转型执行引用转换:一个新的引用(逻辑上)被创建,指向同一个对象。向上转型总是成功的;向下转型仅在对象适当类型化时成功。
向上转型
向上转型操作会从子类引用创建一个基类引用:
Stock msft = new Stock();
Asset a = msft; // Upcast
在向上转型后,变量a仍然引用与变量msft相同的Stock对象。被引用的对象本身不会被改变或转换:
Console.WriteLine (a == msft); // True
尽管a和msft引用相同的对象,但a对该对象有更严格的视图:
Console.WriteLine (a.Name); // OK
Console.WriteLine (a.SharesOwned); // Compile-time error
最后一行生成了编译时错误,因为变量a的类型是Asset,即使它引用了类型为Stock的对象。要访问其SharesOwned字段,必须将Asset向下转型为Stock。
向下转型
向下转型操作会从基类引用创建一个子类引用:
Stock msft = new Stock();
Asset a = msft; // Upcast
Stock s = (Stock)a; // Downcast
Console.WriteLine (s.SharesOwned); // <No error>
Console.WriteLine (s == a); // True
Console.WriteLine (s == msft); // True
与向上转型一样,只影响引用,而不是底层对象。向下转型需要显式转换,因为它在运行时可能失败:
House h = new House();
Asset a = h; // Upcast always succeeds
Stock s = (Stock)a; // Downcast fails: a is not a Stock
如果向下转换失败,将抛出InvalidCastException。这是运行时类型检查的一个例子(我们在“静态和运行时类型检查”中详细阐述此概念)。
as 运算符
as运算符执行一个向下转换,如果转换失败则返回null(而不是抛出异常):
Asset a = new Asset();
Stock s = a as Stock; // s is null; no exception thrown
当你随后要测试结果是否为null时,这非常有用:
if (s != null) Console.WriteLine (s.SharesOwned);
注意
没有这样的测试,强制转换更有优势,因为如果失败,会抛出一个更有帮助的异常。我们可以通过比较以下两行代码来说明:
long shares = ((Stock)a).SharesOwned; // Approach #1
long shares = (a as Stock).SharesOwned; // Approach #2
如果a不是Stock,第一行将抛出InvalidCastException,这准确描述了出错的原因。第二行抛出NullReferenceException,这是含糊不清的。a不是Stock,还是a是空的?
另一种看待它的方式是,使用转换运算符时,你在告诉编译器:“我确定这个值的类型;如果我错了,那么我的代码有 bug,所以抛出异常!”而使用as运算符时,你不确定它的类型,并希望根据运行时的结果进行分支。
as运算符无法执行自定义转换(参见“运算符重载”),也不能执行数值转换:
long x = 3 as long; // Compile-time error
注意
as和转换运算符也会执行向上转换,尽管这并不是非常有用,因为隐式转换可以完成工作。
is 运算符
is运算符测试变量是否匹配模式。C#支持多种模式,最重要的是类型模式,其中类型名跟在is关键字后面。
在这个上下文中,is运算符测试引用转换是否会成功——换句话说,对象是否从指定的类(或实现接口)派生。通常用于在进行向下转换之前进行测试:
if (a is Stock)
Console.WriteLine (((Stock)a).SharesOwned);
is运算符还在拆箱转换成功时返回 true(参见“对象类型”)。但它不考虑自定义或数值转换。
注意
is运算符与 C#最近版本引入的许多其他模式一起使用。有关详细讨论,请参见“模式”。
引入模式变量
当你使用is运算符时,可以引入一个变量:
if (a is Stock s)
Console.WriteLine (s.SharesOwned);
这等效于以下内容:
Stock s;
if (a is Stock)
{
s = (Stock) a;
Console.WriteLine (s.SharesOwned);
}
引入的变量可以“立即”使用,因此以下是合法的:
if (a is Stock s && s.SharesOwned > 100000)
Console.WriteLine ("Wealthy");
并且它在is表达式之外仍然处于作用域中,允许这样:
if (a is Stock s && s.SharesOwned > 100000)
Console.WriteLine ("Wealthy");
else
s = new Stock(); // s is in scope
Console.WriteLine (s.SharesOwned); // Still in scope
虚函数成员
标记为virtual的函数可以被子类重写,以提供专门的实现。方法、属性、索引器和事件都可以声明为virtual:
public class Asset
{
public string Name;
public virtual decimal Liability => 0; // Expression-bodied property
}
(Liability => 0是{ get { return 0; } }的一种快捷方式。有关此语法的详细信息,请参见“表达式体属性”。)
子类通过应用override修饰符来覆盖虚方法:
public class Stock : Asset
{
public long SharesOwned;
}
public class House : Asset
{
public decimal Mortgage;
public override decimal Liability => Mortgage;
}
默认情况下,Asset的Liability为0。Stock不需要专门化此行为。然而,House专门化Liability属性以返回Mortgage的值:
House mansion = new House { Name="McMansion", Mortgage=250000 };
Asset a = mansion;
Console.WriteLine (mansion.Liability); // 250000
Console.WriteLine (a.Liability); // 250000
虚方法和重写方法的签名、返回类型和可访问性必须相同。重写方法可以通过base关键字调用其基类实现(我们在“基类关键字”中讨论此问题)。
警告
从构造函数调用虚方法可能是危险的,因为子类的作者在覆盖方法时可能不知道它们正在处理的是部分初始化的对象。换句话说,覆盖方法可能会访问尚未由构造函数初始化的字段的方法或属性。
协变返回类型
从 C# 9 开始,您可以覆盖一个方法(或属性的get访问器),使其返回一个更具体(子类化)的类型。例如:
public class Asset
{
public string Name;
public virtual Asset Clone() => new Asset { Name = Name };
}
public class House : Asset
{
public decimal Mortgage;
public override House Clone() => new House
{ Name = Name, Mortgage = Mortgage };
}
这是允许的,因为它不违反Clone必须返回Asset的约定:它返回一个House,是一个Asset(更多)。
在 C# 9 之前,您必须重写具有相同返回类型的方法:
public override Asset Clone() => new House { ... }
这依然有效,因为重写的Clone方法实例化了一个House而不是Asset。然而,要将返回的对象视为House,您必须执行向下转换:
House mansion1 = new House { Name="McMansion", Mortgage=250000 };
House mansion2 = (House) mansion1.Clone();
抽象类和抽象成员
声明为抽象的类永远不能被实例化。相反,只能实例化它的具体子类。
抽象类能够定义抽象成员。抽象成员类似于虚成员,但它们不提供默认实现。除非该子类也声明为抽象,否则该实现必须由子类提供:
public abstract class Asset
{
// Note empty implementation
public abstract decimal NetValue { get; }
}
public class Stock : Asset
{
public long SharesOwned;
public decimal CurrentPrice;
// Override like a virtual method.
public override decimal NetValue => CurrentPrice * SharesOwned;
}
隐藏继承成员
基类和子类可以定义相同的成员。例如:
public class A { public int Counter = 1; }
public class B : A { public int Counter = 2; }
类B中的Counter字段被称为隐藏类A中的Counter字段。通常情况下,当一个成员被添加到基类型之后,意外地在子类型中添加了相同的成员时,就会发生这种情况。因此,编译器会生成警告,然后按以下方式解决歧义:
-
对
A的引用(在编译时)绑定到A.Counter。 -
对
B的引用(在编译时)绑定到B.Counter。
偶尔,您希望有意隐藏一个成员,在这种情况下,您可以在子类中对成员应用new修饰符。new修饰符仅仅是为了抑制编译器警告:
public class A { public int Counter = 1; }
public class B : A { public new int Counter = 2; }
new修饰符向编译器和其他程序员传达了您的意图,即重复成员不是偶然。
注意
C#在不同上下文中重载new关键字以获得独立的含义。具体来说,new 运算符与new 成员修饰符是不同的。
new 与 override
考虑以下类层次结构:
public class BaseClass
{
public virtual void Foo() { Console.WriteLine ("BaseClass.Foo"); }
}
public class Overrider : BaseClass
{
public override void Foo() { Console.WriteLine ("Overrider.Foo"); }
}
public class Hider : BaseClass
{
public new void Foo() { Console.WriteLine ("Hider.Foo"); }
}
Overrider和Hider之间的行为差异在以下代码中有所体现:
Overrider over = new Overrider();
BaseClass b1 = over;
over.Foo(); // Overrider.Foo
b1.Foo(); // Overrider.Foo
Hider h = new Hider();
BaseClass b2 = h;
h.Foo(); // Hider.Foo
b2.Foo(); // BaseClass.Foo
封闭函数和类
重写的函数成员可以使用sealed关键字封闭其实现,防止进一步的子类重写它。在我们早期的虚函数成员示例中,我们可以封闭House对Liability的实现,防止从House派生的类重写Liability,如下所示:
public sealed override decimal Liability { get { return Mortgage; } }
您还可以将sealed修饰符应用于类本身,以防止其被子类化。封闭类比封闭函数成员更常见。
尽管您可以防止重写函数成员,但不能防止成员被隐藏。
base关键字
base关键字类似于this关键字。它有两个基本用途:
-
从子类访问重写的函数成员
-
调用基类构造函数(请参见下一节)
在这个例子中,House使用base关键字访问Asset的Liability实现:
public class House : Asset
{
...
public override decimal Liability => base.Liability + Mortgage;
}
使用base关键字,我们可以非虚拟地访问Asset类的Liability属性。这意味着我们总是访问Asset类的这个属性版本,而不管实例的实际运行时类型如何。
如果Liability被隐藏而不是重写,同样的方法也适用。(您还可以通过在调用函数之前将类型转换为基类来访问隐藏成员。)
构造函数与继承
子类必须声明自己的构造函数。基类的构造函数对派生类是可访问的,但从不会自动继承。例如,如果我们定义Baseclass和Subclass如下:
public class Baseclass
{
public int X;
public Baseclass () { }
public Baseclass (int x) => X = x;
}
public class Subclass : Baseclass { }
下面的做法是非法的:
Subclass s = new Subclass (123);
因此,Subclass必须“重新定义”想要公开的任何构造函数。不过,在这样做时,它可以通过base关键字调用任何基类的构造函数:
public class Subclass : Baseclass
{
public Subclass (int x) : base (x) { }
}
base关键字的工作方式类似于this关键字,不同之处在于它调用基类中的构造函数。
基类构造函数始终先执行;这确保了基本初始化发生在专业化初始化之前。
隐式调用无参数基类构造函数
如果子类的构造函数省略了base关键字,则隐式调用基类型的无参数构造函数:
public class Baseclass
{
public int X;
public Baseclass() { X = 1; }
}
public class Subclass : Baseclass
{
public Subclass() { Console.WriteLine (X); } // 1
}
如果基类没有可访问的无参数构造函数,则子类被迫在其构造函数中使用base关键字。这意味着基类只有一个多参数构造函数时,子类会负担调用它的责任:
class Baseclass
{
public Baseclass (int x, int y, int z, string s, DateTime d) { ... }
}
public class Subclass : Baseclass
{
public Subclass (int x, int y, int z, string s, DateTime d)
: base (x, y, z, s, d) { ... }
}
必需成员(C# 11)
如果在大型类层次结构中存在许多具有许多参数的构造函数,则要求子类调用基类中的构造函数可能会变得繁琐。有时,最好的解决方案是完全避免构造函数,并仅依赖对象初始化器在构造期间设置字段或属性。为了帮助解决这个问题,您可以将字段或属性标记为required(来自 C# 11):
public class Asset
{
public required string Name;
}
必需成员在构造时必须通过对象初始化器填充:
Asset a1 = new Asset { Name="House" }; // OK
Asset a2 = new Asset(); // Error: will not compile!
如果你希望也写一个构造器,你可以应用[SetsRequiredMembers]属性,以绕过该构造器的必需成员限制:
public class Asset
{
public required string Name;
public Asset() { }
[System.Diagnostics.CodeAnalysis.SetsRequiredMembers]
public Asset (string n) => Name = n;
}
消费者现在可以享受到那个构造器的便利,而没有任何的权衡:
Asset a1 = new Asset { Name = "House" }; // OK
Asset a2 = new Asset ("House"); // OK
Asset a3 = new Asset(); // Error!
请注意,我们还定义了一个无参数构造函数(用于对象初始化器)。其存在还确保了子类不需要重现任何构造函数。在以下示例中,House类选择不实现便利构造函数:
public class House : Asset { } // No constructor, no worries!
House h1 = new House { Name = "House" }; // OK
House h2 = new House(); // Error!
构造函数和字段初始化顺序
当对象被实例化时,按以下顺序进行初始化:
-
从子类到基类:
-
字段被初始化。
-
调用基类构造函数的参数被评估。
-
-
从基类到子类:
- 构造函数体执行。
例如:
public class B
{
int x = 1; // Executes 3rd
public B (int x)
{
... // Executes 4th
}
}
public class D : B
{
int y = 1; // Executes 1st
public D (int x)
: base (x + 1) // Executes 2nd
{
... // Executes 5th
}
}
使用主构造函数的继承
具有主构造函数的类可以使用以下语法进行子类化:
public class Baseclass (int x) { ... }
public class Subclass (int x, int y) : Baseclass (x) { ... }
在以下示例中,对Baseclass(x)的调用等同于调用base(x):
public class Subclass : Baseclass
{
public Subclass (int x, int y) : base (x) { ... }
}
重载与解析
继承对方法重载有着有趣的影响。考虑以下两个重载:
static void Foo (Asset a) { }
static void Foo (House h) { }
当调用重载时,最具体的类型优先:
House h = new House (...);
Foo(h); // Calls Foo(House)
调用具体的重载是在静态(编译时)而不是在运行时确定的。以下代码调用Foo(Asset),即使a的运行时类型是House:
Asset a = new House (...);
Foo(a); // Calls Foo(Asset)
注意
如果你将Asset转换为dynamic(第四章),决定调用哪个重载将被推迟到运行时,并且基于对象的实际类型进行:
Asset a = new House (...);
Foo ((dynamic)a); // Calls Foo(House)
对象类型
object(System.Object)是所有类型的最终基类。任何类型都可以向上转型为object。
为了说明这是如何有用的,考虑一个通用的堆栈。堆栈是一种基于“后进先出(LIFO)”原则的数据结构。堆栈有两个操作:push(将对象推入堆栈)和pop(从堆栈弹出对象)。以下是一个可以容纳最多 10 个对象的简单实现:
public class Stack
{
int position;
object[] data = new object[10];
public void Push (object obj) { data[position++] = obj; }
public object Pop() { return data[--position]; }
}
因为Stack与对象类型一起工作,我们可以向Stack推送和弹出任何类型的实例:
Stack stack = new Stack();
stack.Push ("sausage");
string s = (string) stack.Pop(); // Downcast, so explicit cast is needed
Console.WriteLine (s); // sausage
object是引用类型,因为它是一个类。尽管如此,值类型,如int,也可以被转换为object并且被添加到我们的堆栈中。C#的这一特性称为类型统一,这里进行了演示:
stack.Push (3);
int three = (int) stack.Pop();
当你在值类型和object之间进行类型转换时,CLR 必须执行一些特殊工作以弥合值类型和引用类型之间的语义差异。这个过程称为装箱和拆箱。
注意
在“泛型”中,我们描述了如何改进我们的Stack类以更好地处理具有相同类型元素的堆栈。
装箱与拆箱
装箱是将值类型实例转换为引用类型实例的过程。引用类型可以是 object 类或接口(稍后在本章讨论)。¹ 在此示例中,我们将 int 装箱为对象:
int x = 9;
object obj = x; // Box the int
拆箱通过将对象强制转换回原始值类型来反转操作:
int y = (int)obj; // Unbox the int
拆箱需要显式转换。运行时检查所述值类型是否与实际对象类型匹配,如果检查失败,则抛出 InvalidCastException 异常。例如,以下代码因 long 与 int 不完全匹配而引发异常:
object obj = 9; // 9 is inferred to be of type int
long x = (long) obj; // InvalidCastException
以下操作成功:
object obj = 9;
long x = (int) obj;
如下所示:
object obj = 3.5; // 3.5 is inferred to be of type double
int x = (int) (double) obj; // x is now 3
在最后一个示例中,(double) 执行拆箱操作,然后 (int) 执行数值转换。
注意
装箱转换在提供统一类型系统方面至关重要。然而,该系统并非完美:我们将在“泛型”中看到,数组和泛型的协变仅支持引用转换,而不支持装箱转换:
object[] a1 = new string[3]; // Legal
object[] a2 = new int[3]; // Error
装箱和拆箱的复制语义
装箱会将值类型实例复制到新对象中,而拆箱会将对象内容复制回值类型实例。在以下示例中,更改 i 的值不会更改其先前装箱的副本:
int i = 3;
object boxed = i;
i = 5;
Console.WriteLine (boxed); // 3
静态和运行时类型检查
C# 程序在静态(编译时)和运行时(由 CLR 进行)都进行类型检查。
静态类型检查使编译器能够在不运行程序的情况下验证其正确性。以下代码将因编译器强制执行静态类型而失败:
int x = "5";
CLR 在通过引用转换或拆箱向下转换时执行运行时类型检查:
object y = "5";
int z = (int) y; // Runtime error, downcast failed
运行时类型检查之所以可行,是因为堆上的每个对象内部都存储有一个小的类型标记。通过调用 object 的 GetType 方法可以检索此标记。
GetType 方法和 typeof 运算符
在运行时,所有 C#类型都用 System.Type 的实例表示。获取 System.Type 对象有两种基本方式:
-
在实例上调用
GetType -
在编译时使用
typeof运算符对类型名进行操作
GetType 在运行时评估;typeof 在编译时静态评估(涉及泛型类型参数时,由 JIT 编译器解析)。
System.Type 具有诸如类型名称、程序集、基类型等属性:
Point p = new Point();
Console.WriteLine (p.GetType().Name); // Point
Console.WriteLine (typeof (Point).Name); // Point
Console.WriteLine (p.GetType() == typeof(Point)); // True
Console.WriteLine (p.X.GetType().Name); // Int32
Console.WriteLine (p.Y.GetType().FullName); // System.Int32
public class Point { public int X, Y; }
System.Type 还具有作为运行时反射模型入口的方法,详见第十八章。
ToString 方法
ToString 方法返回类型实例的默认文本表示。所有内置类型都重写了此方法。以下是使用 int 类型的 ToString 方法的示例:
int x = 1;
string s = x.ToString(); // s is "1"
可以重写自定义类型的 ToString 方法如下:
Panda p = new Panda { Name = "Petey" };
Console.WriteLine (p); // Petey
public class Panda
{
public string Name;
public override string ToString() => Name;
}
如果不重写 ToString,该方法将返回类型名称。
注意
当你直接调用值类型上的重写的object成员,比如ToString时,不会发生装箱。只有在你进行强制类型转换时才会发生装箱:
int x = 1;
string s1 = x.ToString(); // Calling on nonboxed value
object box = x;
string s2 = box.ToString(); // Calling on boxed value
对象成员列表
下面是所有object的成员:
public class Object
{
public Object();
public extern Type GetType();
public virtual bool Equals (object obj);
public static bool Equals (object objA, object objB);
public static bool ReferenceEquals (object objA, object objB);
public virtual int GetHashCode();
public virtual string ToString();
protected virtual void Finalize();
protected extern object MemberwiseClone();
}
我们在“相等比较”中描述了Equals、ReferenceEquals和GetHashCode方法。
结构体
结构体与类似,但以下是其关键区别:
-
结构体是值类型,而类是引用类型。
-
结构体不支持继承(除了隐式从
object派生,或更确切地说,System.ValueType)。
一个结构体可以拥有所有类的成员,除了析构函数。并且因为它不能被子类化,成员不能标记为虚拟的、抽象的或受保护的。
警告
在 C# 10 之前,结构体进一步禁止定义字段初始化器和无参数构造函数。尽管这个禁令现在已经放宽,主要是为了支持记录结构体(见“记录”),但在定义这些结构之前,仍值得仔细考虑,因为它们可能导致混乱的行为,我们将在“结构体构造语义”中描述。
当需要值类型语义时,结构体是合适的。结构体的良好示例是数值类型,在这些类型中,赋值复制值而不是引用更为自然。因为结构体是值类型,每个实例不需要在堆上实例化对象;这在创建多个类型实例时可以节省内存。例如,创建值类型元素的数组只需要单个堆分配。
因为结构体是值类型,实例不能为 null。结构的默认值是一个空实例,所有字段都为空(设置为它们的默认值)。
结构体构造语义
注意
在 C# 11 之前,结构体中的每个字段都必须在构造函数(或字段初始化器)中显式赋值。这个限制现在已经放宽。
默认构造函数
除了你定义的任何构造函数外,结构体始终有一个隐式的无参数构造函数,执行其字段的比特零化(将它们设置为它们的默认值):
Point p = new Point(); // p.x and p.y will be 0
struct Point { int x, y; }
即使你定义了自己的无参数构造函数,隐式的无参数构造函数仍然存在,并且可以通过default关键字访问:
Point p1 = new Point(); // p1.x and p1.y will be 1
Point p2 = default; // p2.x and p2.y will be 0
struct Point
{
int x = 1;
int y;
public Point() => y = 1;
}
在这个例子中,我们通过字段初始化器将x初始化为 1,通过无参数构造函数将y初始化为 1。然而,使用default关键字,我们仍然能够创建一个绕过这两个初始化的Point。默认构造函数也可以通过其他方式访问,正如以下例子所示:
var points = new Point[10]; // Each point in the array will be (0,0)
var test = new Test(); // test.p will be (0,0)
class Test { Point p; }
注意
拥有两个无参数构造函数可能会导致混淆,这可能是避免在结构体中定义字段初始化器和显式的无参数构造函数的一个好理由。
结构体的一个好策略是设计它们的 default 值是一个有效状态,从而使初始化变得多余。例如,而不是初始化属性如下:
public string Protocol { get; set; } = "https";
考虑以下内容:
struct WebOptions
{
string protocol;
public string Protocol { get => protocol ?? "https";
set => protocol = value; }
}
只读结构体和函数
您可以将 readonly 修饰符应用于结构体,以强制所有字段都是 readonly 的;这有助于声明意图,并为编译器提供更多的优化自由度:
readonly struct Point
{
public readonly int X, Y; // X and Y must be readonly
}
如果需要在更细粒度的级别应用 readonly,可以将 readonly 修饰符(从 C# 8 开始)应用于结构体的 函数。这确保如果函数尝试修改任何字段,则会生成编译时错误:
struct Point
{
public int X, Y;
public readonly void ResetX() => X = 0; // Error!
}
如果一个 readonly 函数调用一个非 readonly 函数,编译器会生成警告(并为了避免可能的突变而进行防御性地复制结构体)。
Ref 结构体
注意
在 C# 7.2 中引入了 ref 结构体作为一个主要为了 Span<T> 和 ReadOnlySpan<T> 结构体的利益而引入的特性。我们在 第二十三章 中描述了这些结构体(以及高度优化的 Utf8JsonReader 在 第十一章 中的描述)。这些结构体有助于一种微优化技术,旨在减少内存分配。
不像引用类型,其实例总是存在于堆上,值类型则在 原地(变量声明的地方)生存。如果值类型出现为参数或局部变量,它将驻留在堆栈上:
void SomeMethod()
{
Point p; // p will reside on the stack
}
struct Point { public int X, Y; }
但是,如果一个值类型作为类的字段出现,它将驻留在堆上:
class MyClass
{
Point p; // Lives on heap, because MyClass instances live on the heap
}
类似地,结构体数组存在于堆上,而装箱一个结构体会将其发送到堆上。
向结构体的声明添加 ref 修饰符确保它只能驻留在堆栈上。试图使用 ref 结构体 的方式使其可能驻留在堆上会生成编译时错误:
var points = new Point [100]; // Error: will not compile!
ref struct Point { public int X, Y; }
class MyClass { Point P; } // Error: will not compile!
Ref 结构体主要为了 Span<T> 和 ReadOnlySpan<T> 结构体的利益而引入。因为 Span<T> 和 ReadOnlySpan<T> 实例只能存在于堆栈上,所以它们可以安全地包装堆栈分配的内存。
Ref 结构体不能参与任何直接或间接导致存在于堆上的 C# 功能。这包括我们在 第四章 中描述的许多高级 C# 功能,即 lambda 表达式、迭代器和异步函数(因为在幕后,这些功能都会创建带有字段的隐藏类)。此外,ref 结构体不能出现在非 ref 结构体内部,也不能实现接口(因为这可能导致装箱)。
访问修饰符
为了促进封装,类型或类型成员可以通过在声明中添加 访问修饰符 来限制其对其他类型和其他程序集的 可访问性:
public
完全可访问。这是枚举或接口成员的隐式可访问性。
internal
仅在包含程序集或友元程序集中可访问。这是非嵌套类型的默认可访问性。
private
仅在包含类型内部可访问。这是类或结构的成员的默认可访问性。
protected
仅在包含类型或子类中可访问。
protected internal
protected 和 internal 可访问性的并集。具有 protected internal 访问性的成员可以通过两种方式访问。
private protected
protected 和 internal 可访问性的交集。具有 private protected 访问性的成员仅在包含类型内部可访问,或者来自同一程序集中的子类(使其比单独的 protected 或 internal 更不可访问)。
file(来自 C# 11)
仅在同一文件内部可访问。用于源生成器的使用(请参阅“扩展部分方法”)。此修饰符仅适用于类型声明。
示例
Class2 可从其程序集外部访问;Class1 则不行:
class Class1 {} // Class1 is internal (default)
public class Class2 {}
ClassB 将字段 x 公开给同一程序集中的其他类型;ClassA 则不公开:
class ClassA { int x; } // x is private (default)
class ClassB { internal int x; }
Subclass 中的函数可以调用 Bar,但不能调用 Foo:
class BaseClass
{
void Foo() {} // Foo is private (default)
protected void Bar() {}
}
class Subclass : BaseClass
{
void Test1() { Foo(); } // Error - cannot access Foo
void Test2() { Bar(); } // OK
}
友元程序集
您可以通过添加System.Runtime.CompilerServices.InternalsVisibleTo程序集属性,指定友元程序集的名称,来向其他友元程序集公开internal成员,如下所示:
[assembly: InternalsVisibleTo ("Friend")]
如果友元程序集具有强名称(请参阅第十七章),则必须指定其完整的 160 字节公钥:
[assembly: InternalsVisibleTo ("StrongFriend, PublicKey=0024f000048c...")]
您可以使用 LINQ 查询从强命名程序集中提取完整的公钥(我们在第八章详细解释 LINQ):
string key = string.Join ("",
Assembly.GetExecutingAssembly().GetName().GetPublicKey()
.Select (b => b.ToString ("x2")));
注意
在 LINQPad 中的伴侣示例邀请您浏览程序集,然后将程序集的完整公钥复制到剪贴板。
访问性限制
类型限制其声明成员的可访问性。最常见的限制示例是具有 public 成员的 internal 类型。例如,请考虑以下情况:
class C { public void Foo() {} }
C 的(默认)internal 可访问性限制了 Foo 的访问性,实际上使 Foo 变为 internal。将 Foo 标记为 public 的常见原因是,如果以后将 C 更改为 public,则易于重构。
访问修饰符的限制
当重写基类函数时,重写函数的可访问性必须与被重写函数相同;例如:
class BaseClass { protected virtual void Foo() {} }
class Subclass1 : BaseClass { protected override void Foo() {} } // OK
class Subclass2 : BaseClass { public override void Foo() {} } // Error
(例外情况是在另一个程序集中重写 protected internal 方法时,此时重写必须简单地是 protected。)
编译器阻止任何不一致使用访问修饰符的情况。例如,子类本身可以比基类更不可访问,但不能更多:
internal class A {}
public class B : A {} // Error
接口
接口类似于类,但仅指定行为,而不保存状态(数据)。因此:
-
接口只能定义函数而不能定义字段。
-
接口成员是隐式抽象的。(有一些例外情况,我们将在“默认接口成员”和“静态接口成员”中描述。)
-
一个类(或结构)可以实现多个接口。相比之下,一个类只能继承一个类,而结构体则根本不能继承(除了从
System.ValueType派生)。
接口声明类似于类声明,但它(通常)不为其成员提供实现,因为其成员隐式为抽象。这些成员将由实现接口的类和结构体实现。接口只能包含函数,即方法、属性、事件和索引器(这些恰好是类的成员可以是抽象的成员)。
这是定义在System.Collections中的IEnumerator接口的定义:
public interface IEnumerator
{
bool MoveNext();
object Current { get; }
void Reset();
}
接口成员始终是隐式公共的,不能声明访问修饰符。实现接口意味着为其所有成员提供public实现:
internal class Countdown : IEnumerator
{
int count = 11;
public bool MoveNext() => count-- > 0;
public object Current => count;
public void Reset() { throw new NotSupportedException(); }
}
您可以将对象隐式转换为其实现的任何接口:
IEnumerator e = new Countdown();
while (e.MoveNext())
Console.Write (e.Current); // 109876543210
注意
即使Countdown是一个内部类,也可以通过将Countdown的实例转换为IEnumerator来公开调用其实现的成员。例如,如果同一程序集中的公共类型定义了以下方法:
public static class Util
{
public static object GetCountDown() => new CountDown();
}
来自另一个程序集的调用者可以这样做:
IEnumerator e = (IEnumerator) Util.GetCountDown();
e.MoveNext();
如果IEnumerator本身被定义为internal,这是不可能的。
扩展一个接口
接口可以从其他接口派生;例如:
public interface IUndoable { void Undo(); }
public interface IRedoable : IUndoable { void Redo(); }
IRedoable“继承”了IUndoable的所有成员。换句话说,实现IRedoable的类型也必须实现IUndoable的成员。
显式接口实现
实现多个接口有时会导致成员签名冲突。您可以通过显式实现接口成员来解决这类冲突。请考虑以下示例:
interface I1 { void Foo(); }
interface I2 { int Foo(); }
public class Widget : I1, I2
{
public void Foo()
{
Console.WriteLine ("Widget's implementation of I1.Foo");
}
int I2.Foo()
{
Console.WriteLine ("Widget's implementation of I2.Foo");
return 42;
}
}
因为I1和I2具有冲突的Foo签名,Widget显式实现了I2的Foo方法。这使得两个方法可以在同一个类中共存。调用显式实现的成员的唯一方法是将其转换为其接口:
Widget w = new Widget();
w.Foo(); // Widget's implementation of I1.Foo
((I1)w).Foo(); // Widget's implementation of I1.Foo
((I2)w).Foo(); // Widget's implementation of I2.Foo
另一个显式实现接口成员的原因是隐藏那些对类型的正常使用案例高度专业化和分散注意力的成员。例如,实现ISerializable的类型通常希望在没有显式转换为该接口的情况下避免展示其ISerializable成员。
虚拟实现接口成员
默认情况下,隐式实现的接口成员是封闭的。必须在基类中标记为virtual或abstract才能被重写:
public interface IUndoable { void Undo(); }
public class TextBox : IUndoable
{
public virtual void Undo() => Console.WriteLine ("TextBox.Undo");
}
public class RichTextBox : TextBox
{
public override void Undo() => Console.WriteLine ("RichTextBox.Undo");
}
通过基类或接口调用接口成员将调用子类的实现:
RichTextBox r = new RichTextBox();
r.Undo(); // RichTextBox.Undo
((IUndoable)r).Undo(); // RichTextBox.Undo
((TextBox)r).Undo(); // RichTextBox.Undo
显式实现的接口成员不能标记为virtual,也不能以通常的方式被重写。但是可以重新实现它。
在子类中重新实现接口
子类可以重新实现基类已经实现的任何接口成员。当通过接口调用时,重新实现会接管成员的实现,无论基类中成员是否为virtual都可以工作。它还适用于成员是隐式实现还是显式实现的情况——尽管在后者的情况下效果最佳,我们将会展示。
在以下示例中,TextBox显式实现了IUndoable.Undo,因此它不能标记为virtual。要“覆盖”它,RichTextBox必须重新实现IUndoable的Undo方法:
public interface IUndoable { void Undo(); }
public class TextBox : IUndoable
{
void IUndoable.Undo() => Console.WriteLine ("TextBox.Undo");
}
public class RichTextBox : TextBox, IUndoable
{
public void Undo() => Console.WriteLine ("RichTextBox.Undo");
}
通过接口调用重新实现的成员会调用子类的实现:
RichTextBox r = new RichTextBox();
r.Undo(); // RichTextBox.Undo Case 1
((IUndoable)r).Undo(); // RichTextBox.Undo Case 2
假设相同的RichTextBox定义,假设TextBox隐式实现了Undo:
public class TextBox : IUndoable
{
public void Undo() => Console.WriteLine ("TextBox.Undo");
}
这将为我们提供另一种调用Undo的方式,这将“破坏”系统,如情况 3 所示:
RichTextBox r = new RichTextBox();
r.Undo(); // RichTextBox.Undo Case 1
((IUndoable)r).Undo(); // RichTextBox.Undo Case 2
((TextBox)r).Undo(); // TextBox.Undo Case 3
情况 3 表明,重新实现仅在通过接口调用成员时有效,而不是通过基类。通常情况下这是不希望出现的,因为它可能导致不一致的语义。这使得重新实现最适合作为覆盖显式实现的接口成员的策略。
接口重新实现的替代方案
即使有显式成员实现,接口的重新实现也因为几个原因而存在问题:
-
子类没有办法调用基类的方法。
-
基类作者可能没有预料到某个方法会被重新实现,也可能没有考虑到潜在的后果。
当子类化没有被预料到时,重新实现可以作为一种最后的手段。然而,更好的选择是设计一个基类,使得永远不需要重新实现。有两种方法可以实现这一点:
-
当隐式实现成员时,如果适合,将其标记为
virtual。 -
在显式实现成员时,如果预期子类可能需要覆盖任何逻辑,请使用以下模式:
public class TextBox : IUndoable
{
void IUndoable.Undo() => Undo(); // Calls method below
protected virtual void Undo() => Console.WriteLine ("TextBox.Undo");
}
public class RichTextBox : TextBox
{
protected override void Undo() => Console.WriteLine("RichTextBox.Undo");
}
如果你不预期有任何子类化,可以将类标记为sealed以预防接口的重新实现。
接口和装箱
将结构转换为接口会导致装箱。在结构上调用隐式实现的成员不会导致装箱:
interface I { void Foo(); }
struct S : I { public void Foo() {} }
...
S s = new S();
s.Foo(); // No boxing.
I i = s; // Box occurs when casting to interface.
i.Foo();
默认接口成员
从 C# 8 开始,你可以向接口成员添加默认实现,使其成为可选实现:
interface ILogger
{
void Log (string text) => Console.WriteLine (text);
}
如果你想向一个流行库中定义的接口添加成员而不破坏(可能有成千上万的)实现,这是有利的。
默认实现始终是显式的,因此如果实现ILogger的类未定义Log方法,则仅通过接口调用它的唯一方法:
class Logger : ILogger { }
...
((ILogger)new Logger()).Log ("message");
这可以防止多重实现继承的问题:如果一个类实现了两个接口,并且这两个接口都添加了相同的默认成员,则不会出现调用哪个成员的歧义。
静态接口成员
一个接口也可以声明静态成员。有两种静态接口成员:
-
静态非虚拟接口成员
-
静态虚拟/抽象接口成员
注意
与实例成员相反,接口上的静态成员默认为非虚拟的。要使静态接口成员虚拟化,必须将其标记为static abstract或static virtual。
静态非虚拟接口成员
静态非虚拟接口成员主要用于帮助编写默认接口成员。它们不是由类或结构实现的;而是直接被使用。除了方法、属性、事件和索引器外,静态非虚拟成员还允许字段,这些字段通常从默认成员实现内部访问:
interface ILogger
{
void Log (string text) =>
Console.WriteLine (Prefix + text);
static string Prefix = "";
}
静态非虚拟接口成员默认为公共的,因此可以从外部访问:
ILogger.Prefix = "File log: ";
您可以通过添加可访问性修饰符(如private、protected或internal)来限制此内容。
实例字段(仍然)是禁止的。这与接口的原则一致,即定义行为而非状态。
静态虚拟/抽象接口成员
静态虚拟/抽象接口成员(从 C# 11 起)支持静态多态,这是我们将在第四章中讨论的高级功能。静态虚拟接口成员标记为static abstract或static virtual:
interface ITypeDescribable
{
static abstract string Description { get; }
static virtual string Category => null;
}
实现类或结构必须实现静态抽象成员,并可以选择实现静态虚拟成员:
class CustomerTest : ITypeDescribable
{
public static string Description => "Customer tests"; // Mandatory
public static string Category => "Unit testing"; // Optional
}
除了方法、属性和事件外,运算符和转换也是静态虚拟接口成员的合法目标(参见“运算符重载”)。静态虚拟接口成员通过约束类型参数调用;我们将在本章后面的“静态多态”和“泛型数学”中进行演示。
枚举
枚举是一种特殊的值类型,允许您指定一组命名的数值常量。例如:
public enum BorderSide { Left, Right, Top, Bottom }
我们可以如下使用此枚举类型:
BorderSide topSide = BorderSide.Top;
bool isTop = (topSide == BorderSide.Top); // true
每个枚举成员都有一个基础的整数值。这些默认为:
-
基础值的类型为
int。 -
常量
0、1、2...会自动分配,按照枚举成员的声明顺序。
您可以按以下方式指定替代整数类型:
public enum BorderSide : byte { Left, Right, Top, Bottom }
您还可以为每个枚举成员指定显式基础值:
public enum BorderSide : byte { Left=1, Right=2, Top=10, Bottom=11 }
注意
编译器还允许您显式分配部分枚举成员。未分配的枚举成员会从最后一个显式值递增。前面的示例等同于以下内容:
public enum BorderSide : byte
{ Left=1, Right, Top=10, Bottom }
枚举转换
您可以使用显式强制转换将枚举实例转换为其基础整数值,反之亦然:
int i = (int) BorderSide.Left;
BorderSide side = (BorderSide) i;
bool leftOrRight = (int) side <= 2;
您还可以将一个枚举类型显式转换为另一个枚举类型。假设 HorizontalAlignment 定义如下:
public enum HorizontalAlignment
{
Left = BorderSide.Left,
Right = BorderSide.Right,
Center
}
枚举类型之间的转换使用底层整数值:
HorizontalAlignment h = (HorizontalAlignment) BorderSide.Right;
// same as:
HorizontalAlignment h = (HorizontalAlignment) (int) BorderSide.Right;
数字文字 0 在枚举表达式中由编译器特别处理,不需要显式转换:
BorderSide b = 0; // No cast required
if (b == 0) ...
0 有两个特殊处理原因:
-
枚举的第一个成员通常用作“默认”值。
-
对于 组合枚举 类型,
0表示“无标志”。
标记枚举
您可以组合枚举成员。为防止歧义,组合枚举的成员需要显式分配值,通常是二的幂:
[Flags]
enum BorderSides { None=0, Left=1, Right=2, Top=4, Bottom=8 }
或者:
enum BorderSides { None=0, Left=1, Right=1<<1, Top=1<<2, Bottom=1<<3 }
要处理组合枚举值,可以使用位运算符(如 | 和 &)。这些运算符作用于底层整数值:
BorderSides leftRight = BorderSides.Left | BorderSides.Right;
if ((leftRight & BorderSides.Left) != 0)
Console.WriteLine ("Includes Left"); // Includes Left
string formatted = leftRight.ToString(); // "Left, Right"
BorderSides s = BorderSides.Left;
s |= BorderSides.Right;
Console.WriteLine (s == leftRight); // True
s ^= BorderSides.Right; // Toggles BorderSides.Right
Console.WriteLine (s); // Left
按照惯例,当枚举类型的成员可以组合时,应始终将 Flags 属性应用于枚举类型。如果在不带 Flags 属性的情况下声明这样的 enum,仍然可以组合成员,但在枚举实例上调用 ToString 时将输出数字而不是一系列名称。
按照惯例,可组合的枚举类型使用复数而不是单数名称。
为了方便起见,您可以在枚举声明本身中包含组合成员:
[Flags]
enum BorderSides
{
None=0,
Left=1, Right=1<<1, Top=1<<2, Bottom=1<<3,
LeftRight = Left | Right,
TopBottom = Top | Bottom,
All = LeftRight | TopBottom
}
枚举运算符
与枚举一起使用的运算符有:
= == != < > <= >= + - ^ & | ˜
+= -= ++ -- sizeof
位运算、算术和比较运算符返回处理底层整数值的结果。可以在枚举和整数类型之间进行加法运算,但不能在两个枚举之间进行加法运算。
类型安全问题
考虑以下枚举:
public enum BorderSide { Left, Right, Top, Bottom }
因为枚举可以在其基础整数类型之间进行强制转换,所以它可以具有的实际值可能超出合法枚举成员的范围:
BorderSide b = (BorderSide) 12345;
Console.WriteLine (b); // 12345
位运算和算术运算符可能产生类似的无效值:
BorderSide b = BorderSide.Bottom;
b++; // No errors
无效的 BorderSide 将破坏以下代码:
void Draw (BorderSide side)
{
if (side == BorderSide.Left) {...}
else if (side == BorderSide.Right) {...}
else if (side == BorderSide.Top) {...}
else {...} // Assume BorderSide.Bottom
}
一个解决方法是添加另一个 else 子句:
...
else if (side == BorderSide.Bottom) ...
else throw new ArgumentException ("Invalid BorderSide: " + side, "side");
另一个解决方法是显式检查枚举值的有效性。静态的 Enum.IsDefined 方法完成这项工作:
BorderSide side = (BorderSide) 12345;
Console.WriteLine (Enum.IsDefined (typeof (BorderSide), side)); // False
不幸的是,Enum.IsDefined 对于标记的枚举不起作用。但是,以下辅助方法(依赖于 Enum.ToString() 的行为)如果给定的标记枚举有效则返回 true:
for (int i = 0; i <= 16; i++)
{
BorderSides side = (BorderSides)i;
Console.WriteLine (IsFlagDefined (side) + " " + side);
}
bool IsFlagDefined (Enum e)
{
decimal d;
return !decimal.TryParse(e.ToString(), out d);
}
[Flags]
public enum BorderSides { Left=1, Right=2, Top=4, Bottom=8 }
嵌套类型
嵌套类型 是在另一个类型的范围内声明的:
public class TopLevel
{
public class Nested { } // Nested class
public enum Color { Red, Blue, Tan } // Nested enum
}
嵌套类型具有以下特点:
-
它可以访问封闭类型的私有成员以及封闭类型可以访问的其他所有内容。
-
您可以使用全范围的访问修饰符声明它,而不仅仅是
public和internal。 -
嵌套类型的默认访问权限是
private而不是internal。 -
访问封闭类型外部的嵌套类型需要使用封闭类型的名称进行限定(类似访问静态成员时的方式)。
例如,要从外部访问我们的 TopLevel 类中的 Color.Red,我们需要这样做:
TopLevel.Color color = TopLevel.Color.Red;
所有类型(类、结构体、接口、委托和枚举)都可以嵌套在类或结构体内部。
这里是从嵌套类型访问类型的私有成员的示例:
public class TopLevel
{
static int x;
class Nested
{
static void Foo() { Console.WriteLine (TopLevel.x); }
}
}
下面是将protected访问修饰符应用于嵌套类型的示例:
public class TopLevel
{
protected class Nested { }
}
public class SubTopLevel : TopLevel
{
static void Foo() { new TopLevel.Nested(); }
}
这里是从封闭类型外部引用嵌套类型的示例:
public class TopLevel
{
public class Nested { }
}
class Test
{
TopLevel.Nested n;
}
编译器本身在生成私有类时广泛使用嵌套类型,这些类为迭代器和匿名方法等构造捕获状态。
注意
如果使用嵌套类型的唯一原因是避免在命名空间中拥有太多类型,考虑使用嵌套命名空间。应当使用嵌套类型是因为它更强的访问控制限制,或者当嵌套类必须访问包含类的私有成员时。
泛型
C# 有两种分离的机制用于编写可在不同类型之间重复使用的代码:继承 和 泛型。继承通过基类型表达可重用性,而泛型通过包含“占位符”类型的“模板”表达可重用性。与继承相比,泛型可以增加类型安全性 和 减少强制转换和装箱。
注意
C# 的泛型和 C++ 的模板是类似的概念,但它们的工作方式不同。我们在 “C# Generics Versus C++ Templates” 中解释了这种区别。
泛型类型
泛型类型声明类型参数 ——由泛型类型的使用者提供类型参数填充的占位类型。这里是一个泛型类型Stack<T>的例子,设计用于堆叠T类型的实例。Stack<T>声明了一个单一的类型参数T:
public class Stack<T>
{
int position;
T[] data = new T[100];
public void Push (T obj) => data[position++] = obj;
public T Pop() => data[--position];
}
我们可以像这样使用Stack<T>:
var stack = new Stack<int>();
stack.Push (5);
stack.Push (10);
int x = stack.Pop(); // x is 10
int y = stack.Pop(); // y is 5
Stack<int>用类型参数T隐式地创建了一个类型(合成发生在运行时)。然而,试图将字符串推入我们的Stack<int>将会产生编译时错误。Stack<int>实际上有以下定义(为避免混淆,类名以粗体标出):
public class ###
{
int position;
int[] data = new int[100];
public void Push (int obj) => data[position++] = obj;
public int Pop() => data[--position];
}
技术上,我们称Stack<T>为开放类型,而Stack<int>为闭合类型。在运行时,所有泛型类型实例都是闭合的,即用具体的类型参数填充了占位符类型。这意味着以下语句是非法的:
var stack = new Stack<T>(); // Illegal: What is T?
然而,如果它在一个自身定义了T作为类型参数的类或方法内,这是合法的:
public class Stack<T>
{
...
public Stack<T> Clone()
{
Stack<T> clone = new Stack<T>(); // Legal
...
}
}
泛型的存在原因
泛型存在的目的是编写可在不同类型之间重复使用的代码。假设我们需要一个整数堆栈,但没有泛型类型。一种解决方案是为每种所需的元素类型硬编码一个单独的版本(例如,IntStack、StringStack等)。显然,这将导致大量的代码重复。另一种解决方案是编写一个使用object作为元素类型泛化的堆栈:
public class ObjectStack
{
int position;
object[] data = new object[10];
public void Push (object obj) => data[position++] = obj;
public object Pop() => data[--position];
}
然而,ObjectStack 并不像硬编码的 IntStack 那样适用于专门堆叠整数。ObjectStack 需要装箱和向下转型,这些在编译时无法检查:
// Suppose we just want to store integers here:
ObjectStack stack = new ObjectStack();
stack.Push ("s"); // Wrong type, but no error!
int i = (int)stack.Pop(); // Downcast - runtime error
我们需要的是一个通用的堆栈实现,可以适用于所有元素类型,并且可以轻松专门化到特定的元素类型以提高类型安全性并减少转型和装箱。通用类型通过允许我们参数化元素类型来实现这一点。Stack<T> 同时具有 ObjectStack 和 IntStack 的优点。像 ObjectStack 一样,Stack<T> 一次编写即可普遍适用于所有类型。像 IntStack 一样,Stack<T> 专门化于特定类型——其美妙之处在于这种类型是 T,我们可以动态替换。
注意
ObjectStack 在功能上等同于 Stack<object>。
通用方法
通用方法在方法的签名内声明类型参数。
使用通用方法,可以以通用方式实现许多基本算法。以下是一个通用方法示例,用于交换任意类型 T 的两个变量的内容:
static void Swap<T> (ref T a, ref T b)
{
T temp = a;
a = b;
b = temp;
}
调用 Swap<T> 如下所示:
int x = 5;
int y = 10;
Swap (ref x, ref y);
通常情况下,不需要为通用方法提供类型参数,因为编译器可以隐式推断类型。如果存在歧义,可以如下调用通用方法带有类型参数:
Swap<int> (ref x, ref y);
在通用类型中,除非它引入类型参数(使用尖括号语法),否则方法不被归类为通用方法。我们的通用堆栈中的 Pop 方法仅使用类型的现有类型参数 T,不被归类为通用方法。
方法和类型是唯一可以引入类型参数的结构。属性、索引器、事件、字段、构造函数、运算符等无法声明类型参数,尽管它们可以参与其封闭类型已声明的任何类型参数。例如,在我们的通用堆栈示例中,我们可以编写一个索引器来返回通用项:
public T this [int index] => data [index];
类似地,构造函数可以参与现有的类型参数,但不能引入它们:
public Stack<T>() { } // Illegal
声明类型参数
类型参数可以在类、结构、接口、委托(详见第四章)和方法的声明中引入。其他构造,如属性,不能引入类型参数,但可以使用类型参数。例如,属性 Value 使用 T:
public struct Nullable<T>
{
public T Value { get; }
}
通用类型或方法可以具有多个参数:
class Dictionary<TKey, TValue> {...}
实例化:
Dictionary<int,string> myDict = new Dictionary<int,string>();
或者:
var myDict = new Dictionary<int,string>();
通用类型名称和方法名称可以重载,只要类型参数的数量不同。例如,以下三个类型名称不会冲突:
class A {}
class A<T> {}
class A<T1,T2> {}
注意
按照惯例,具有单一类型参数的通用类型和方法通常将其参数命名为 T,只要参数的意图清晰即可。在使用多个类型参数时,每个参数都以 T 为前缀,但具有更具描述性的名称。
typeof 和未绑定的通用类型
开放泛型类型在运行时不存在:它们作为编译的一部分关闭。然而,未绑定的泛型类型可以在运行时存在,纯粹作为Type对象。在 C# 中指定未绑定的泛型类型的唯一方法是通过typeof运算符:
class A<T> {}
class A<T1,T2> {}
...
Type a1 = typeof (A<>); // *Unbound* type (notice no type arguments).
Type a2 = typeof (A<,>); // Use commas to indicate multiple type args.
开放泛型类型与反射 API(第十八章)结合使用。
您还可以使用typeof运算符指定一个封闭类型:
Type a3 = typeof (A<int,int>);
或者,您可以指定一个在运行时关闭的开放类型:
class B<T> { void X() { Type t = typeof (T); } }
默认通用值
您可以使用default关键字获取泛型类型参数的默认值。引用类型的默认值为null,值类型的默认值是对值类型字段进行按位零处理的结果:
static void Zap<T> (T[] array)
{
for (int i = 0; i < array.Length; i++)
array[i] = default(T);
}
从 C# 7.1 开始,可以省略类型参数,这是编译器能够推断的情况。我们可以用以下代码替换最后一行:
array[i] = default;
泛型约束
默认情况下,可以用任何类型替换类型参数。约束可以应用于类型参数,以要求更具体的类型参数。这些是可能的约束:
where *T* : *base-class* // Base-class constraint
where *T* : *interface* // Interface constraint
where *T* : class // Reference-type constraint
where *T* : class? // (See "Nullable Reference Types" in Chapter 4)
where *T* : struct // Value-type constraint (excludes Nullable types)
where *T* : unmanaged // Unmanaged constraint
where *T* : new() // Parameterless constructor constraint
where *U* : *T* // Naked type constraint
where *T* : notnull // Non-nullable value type, or (from C# 8)
// a non-nullable reference type
在以下示例中,GenericClass<T,U>要求T派生自(或等同于)SomeClass并实现Interface1,并要求U提供无参数构造函数:
class SomeClass {}
interface Interface1 {}
class GenericClass<T,U> where T : SomeClass, Interface1
where U : new()
{...}
您可以在定义类型参数的地方应用约束,无论是在方法还是类型定义中。
注意
约束是限制;然而,类型参数约束的主要目的是启用其他情况下会被禁止的功能。
例如,约束T:Foo允许您将T的实例视为Foo,约束T:new()允许您构造T的新实例。
基类约束指定类型参数必须是某个类的子类(或匹配);接口约束指定类型参数必须实现该接口。这些约束允许将类型参数的实例隐式转换为该类或接口。例如,假设我们想要编写一个通用的Max方法,它返回两个值中的最大值。我们可以利用在System命名空间中定义的通用接口:
public interface IComparable<T> // Simplified version of interface
{
int CompareTo (T other);
}
CompareTo如果this大于other,则返回一个正数。通过此接口作为约束,我们可以编写Max方法如下(为避免分散注意力,省略了空值检查):
static T Max <T> (T a, T b) where T : IComparable<T>
{
return a.CompareTo (b) > 0 ? a : b;
}
Max方法可以接受实现IComparable<T>的任何类型的参数(包括大多数内置类型如int和string):
int z = Max (5, 10); // 10
string last = Max ("ant", "zoo"); // zoo
注意
从 C# 11 开始,接口约束还允许你在该接口上调用静态虚拟/抽象成员(参见“静态虚拟/抽象接口成员”)。例如,如果接口IFoo定义了一个名为Bar的静态抽象方法,则T:IFoo约束使得调用T.Bar()合法。我们在“静态多态性”中再次提到这个话题。
类约束 和 结构约束 指定T必须是引用类型或(非空)值类型。结构约束的一个很好的例子是System.Nullable<T>结构(我们在“可空值类型”中深入讨论这个类):
struct Nullable<T> where T : struct {...}
未管理约束(在 C# 7.3 中引入)是结构约束的一个更强版本:T必须是简单值类型或递归地没有任何引用类型的结构。
无参构造函数约束 要求T具有公共的无参构造函数。如果定义了这个约束,你可以在T上调用new():
static void Initialize<T> (T[] array) where T : new()
{
for (int i = 0; i < array.Length; i++)
array[i] = new T();
}
裸类型约束 要求一个类型参数从另一个类型参数派生(或匹配)。在这个例子中,方法FilteredStack返回另一个Stack,其中只包含类型参数U是类型参数T的子集的元素:
class Stack<T>
{
Stack<U> FilteredStack<U>() where U : T {...}
}
泛型类型的子类化
泛型类可以像非泛型类一样被子类化。子类可以保留基类的类型参数不变,如下面的例子:
class Stack<T> {...}
class SpecialStack<T> : Stack<T> {...}
或者,子类可以用具体类型关闭泛型类型参数:
class IntStack : Stack<int> {...}
子类型也可以引入新鲜的类型参数:
class List<T> {...}
class KeyedList<T,TKey> : List<T> {...}
注意
从技术上讲,子类型上的所有类型参数都是新鲜的:你可以说子类型关闭然后重新打开基类型参数。这意味着子类可以为重新打开的类型参数提供新的(可能更有意义的)名称:
class List<T> {...}
class KeyedList<TElement,TKey> : List<TElement> {...}
自引用泛型声明
一个类型可以在关闭类型参数时将自己命名为具体类型:
public interface IEquatable<T> { bool Equals (T obj); }
public class Balloon : IEquatable<Balloon>
{
public string Color { get; set; }
public int CC { get; set; }
public bool Equals (Balloon b)
{
if (b == null) return false;
return b.Color == Color && b.CC == CC;
}
}
以下也是合法的:
class Foo<T> where T : IComparable<T> { ... }
class Bar<T> where T : Bar<T> { ... }
静态数据
静态数据对于每个关闭类型是唯一的:
Console.WriteLine (++Bob<int>.Count); // 1
Console.WriteLine (++Bob<int>.Count); // 2
Console.WriteLine (++Bob<string>.Count); // 1
Console.WriteLine (++Bob<object>.Count); // 1
class Bob<T> { public static int Count; }
类型参数和转换
C#的转型运算符可以执行多种类型的转换,包括以下内容:
-
数字转换
-
引用转换
-
装箱/拆箱转换
-
自定义转换(通过运算符重载;参见第四章)
决定将发生哪种类型的转换是在编译时进行的,根据操作数的已知类型。这在泛型类型参数方面创建了一个有趣的场景,因为编译时未知精确的操作数类型。如果这导致歧义,编译器会生成错误。
最常见的情况是当你想要执行引用转换时:
StringBuilder Foo<T> (T arg)
{
if (arg is StringBuilder)
return (StringBuilder) arg; // Will not compile
...
}
在不知道 T 的实际类型的情况下,编译器担心您可能打算进行一个 自定义转换。最简单的解决方案是改用 as 运算符,因为它是明确的,因为它不能执行自定义转换:
StringBuilder Foo<T> (T arg)
{
StringBuilder sb = arg as StringBuilder;
if (sb != null) return sb;
...
}
更一般的解决方案是首先转换为 object。这是因为到/从 object 的转换被假定不是自定义转换,而是引用或装箱/拆箱转换。在这种情况下,StringBuilder 是一个引用类型,所以必须是一个引用转换:
return (StringBuilder) (object) arg;
拆箱转换也可能引入歧义。以下内容可能是一个拆箱、数值或自定义转换:
int Foo<T> (T x) => (int) x; // Compile-time error
解决方案再次是先转换为 object,然后再转换为 int(这在这种情况下明确地表示一个拆箱转换):
int Foo<T> (T x) => (int) (object) x;
协变
假设 A 可转换为 B,则如果 X<A> 可转换为 X<B>,则 X 具有协变类型参数。
注意
在 C# 的协变(和逆变)概念中,“可转换”意味着可以通过 隐式引用转换 进行转换,例如 A 子类化 B,或者 A 实现 B。数值转换、装箱转换和自定义转换不包括在内。
例如,类型 IFoo<T> 如果以下内容合法,则具有协变的 T:
IFoo<string> s = ...;
IFoo<object> b = s;
接口允许协变类型参数(委托也允许;参见 第四章),但类不允许。数组也允许协变(如果 A 对 B 有隐式引用转换,则 A[] 可以转换为 B[]),并且在此处进行了比较讨论。
注意
协变和逆变(或简称“变异”)是高级概念。引入和增强 C# 中协变和逆变的动机是为了使泛型接口和泛型类型(特别是在 .NET 中定义的那些,比如 IEnumerable<T>)能更符合您的预期工作。您可以在不理解协变和逆变的详细信息的情况下从中受益。
协变不是自动的
为了确保静态类型安全,类型参数不会自动变异。考虑以下情况:
class Animal {}
class Bear : Animal {}
class Camel : Animal {}
public class Stack<T> // A simple Stack implementation
{
int position;
T[] data = new T[100];
public void Push (T obj) => data[position++] = obj;
public T Pop() => data[--position];
}
以下内容无法编译通过:
Stack<Bear> bears = new Stack<Bear>();
Stack<Animal> animals = bears; // Compile-time error
该限制阻止了以下代码可能导致运行时失败的可能性:
animals.Push (new Camel()); // Trying to add Camel to bears
然而,缺乏协变可能会阻碍可重用性。例如,假设我们想编写一个方法来 Wash 一堆动物:
public class ZooCleaner
{
public static void Wash (Stack<Animal> animals) {...}
}
使用一堆熊调用 Wash 将会生成一个编译时错误。一种解决方法是重新定义带有约束条件的 Wash 方法:
class ZooCleaner
{
public static void Wash<T> (Stack<T> animals) where T : Animal { ... }
}
现在我们可以按以下方式调用 Wash:
Stack<Bear> bears = new Stack<Bear>();
ZooCleaner.Wash (bears);
另一种解决方案是让 Stack<T> 实现一个具有协变类型参数的接口,您很快将会看到。
数组
基于历史原因,数组类型支持协变。这意味着如果 B 是 A 的子类(且两者都是引用类型),则 B[] 可以转换为 A[]:
Bear[] bears = new Bear[3];
Animal[] animals = bears; // OK
这种可重用性的缺点是元素赋值可能在运行时失败:
animals[0] = new Camel(); // Runtime error
声明一个协变类型参数
接口和委托上的类型参数可以通过标记它们为out来声明为协变。该修饰符确保与数组不同,协变类型参数是完全类型安全的。
我们可以通过我们的Stack<T>类来说明这一点,通过实现以下接口:
public interface IPoppable<out T> { T Pop(); }
对于T上的out修饰符表明,T仅在输出位置(例如,方法的返回类型)中使用。out修饰符标志着类型参数为协变,并允许我们这样做:
var bears = new Stack<Bear>();
bears.Push (new Bear());
// Bears implements IPoppable<Bear>. We can convert to IPoppable<Animal>:
IPoppable<Animal> animals = bears; // Legal
Animal a = animals.Pop();
编译器允许从bears到animals的转换,因为类型参数是协变的。这是类型安全的,因为编译器试图避免的情况——将Camel推入堆栈——不可能发生,因为没有方法将Camel输入到仅能在输出位置中出现T的接口中。
注意
接口中的协变(和逆变)通常是消费的概念:相比之下,您很少需要编写变体接口。
警告
有趣的是,由于 CLR 的限制,标记为out的方法参数不符合协变条件。
我们可以利用协变转型来解决前面描述的可重用性问题:
public class ZooCleaner
{
public static void Wash (IPoppable<Animal> animals) { ... }
}
注意
在第七章中描述的IEnumerator<T>和IEnumerable<T>接口具有协变的T。这允许您将IEnumerable<string>转换为IEnumerable<object>,例如。
如果您在输入位置使用协变类型参数(例如,方法的参数或可写属性),编译器将生成错误。
注意
协变(和逆变)仅适用于引用转换的元素——而非装箱转换(这既适用于类型参数的方差,也适用于数组的方差)。因此,如果您编写了一个接受类型为IPoppable<object>的参数的方法,则可以使用IPoppable<string>但不能使用IPoppable<int>来调用它。
逆变
我们之前看到,假设A允许隐式引用转换到B,如果类型X<A>允许将引用转换到X<B>,则类型X具有协变类型参数。逆变是指可以反向转换——从X<B>到X<A>。如果类型参数仅在输入位置中出现并带有in修饰符,则支持此操作。扩展我们之前的示例,假设Stack<T>类实现了以下接口:
public interface IPushable<in T> { void Push (T obj); }
现在我们可以合法地执行此操作:
IPushable<Animal> animals = new Stack<Animal>();
IPushable<Bear> bears = animals; // Legal
bears.Push (new Bear());
IPushable中没有任何成员输出T,因此我们无法通过该接口将animals转换为bears(例如,无法通过该接口进行Pop操作)。
注意
我们的Stack<T>类可以同时实现IPushable<T>和IPoppable<T>—尽管T在这两个接口中具有相反的变异注释!这是因为你必须通过接口来实现变异,而不是类;因此,在执行变体转换之前,你必须承诺使用IPoppable或IPushable的视角。然后,这个视角限制了你只能在适当的变异规则下执行合法的操作。
这也说明了为什么类不允许变异类型参数:具体的实现通常要求数据在两个方向上流动。
再举一个例子,考虑在System命名空间中定义的以下接口:
public interface IComparer<in T>
{
// Returns a value indicating the relative ordering of a and b
int Compare (T a, T b);
}
因为接口具有逆变T,我们可以使用IComparer<**object**>来比较两个字符串:
var objectComparer = Comparer<object>.Default;
// objectComparer implements IComparer<object>
IComparer<string> stringComparer = objectComparer;
int result = stringComparer.Compare ("Brett", "Jemaine");
与协变相反,如果您尝试在输出位置(例如作为返回值或可读属性中)使用逆变类型参数,编译器将报告错误。
C#泛型与 C++模板对比
C#泛型与 C++模板在应用上相似,但它们的工作方式大不相同。在两种情况下,都需要在生产者和消费者之间进行综合,其中生产者的占位类型由消费者填充。然而,对于 C#泛型,生产者类型(例如List<T>这样的开放类型)可以编译为库(例如mscorlib.dll)。这是因为生产者和消费者之间在运行时实际上并不进行生成闭合类型的综合。而对于 C++模板,这种综合是在编译时执行的。这意味着在 C++中,你不会将模板库部署为.dll文件——它们仅存在于源代码中。这也使得动态检查甚至创建参数化类型变得困难。
要深入探讨这种情况背后的原因,再次考虑 C#中的Max方法:
static T Max <T> (T a, T b) where T : IComparable<T>
=> a.CompareTo (b) > 0 ? a : b;
为什么我们不能这样实现呢?
static T Max <T> (T a, T b)
=> (a > b ? a : b); // Compile error
原因是Max需要编译一次,并且适用于T的所有可能值。编译无法成功,因为对于所有T的值来说,>都没有单一的含义——事实上,并不是每个T都有>运算符。相比之下,以下代码展示了用 C++模板编写的相同Max方法。这段代码将为每个T的值单独编译,承担特定T所具有的>运算符的任何语义,并在特定T不支持>运算符时无法编译通过:
template <class T> T Max (T a, T b)
{
return a > b ? a : b;
}
¹ 参考类型也可以是System.ValueType或System.Enum(第六章)。
第四章:高级 C#
在本章中,我们涵盖了在第二章和第三章探讨的概念基础上构建的高级 C#主题。您应该按顺序阅读前四个部分;其余部分可以任意顺序阅读。
委托
委托是一个知道如何调用方法的对象。
委托类型定义了委托实例可以调用的方法类型。具体而言,它定义了方法的返回类型和参数类型。以下定义了一个名为Transformer的委托类型:
delegate int Transformer (int x);
Transformer与任何具有int返回类型和单个int参数的方法兼容,例如:
int Square (int x) { return x * x; }
或者更简洁地说:
int Square (int x) => x * x;
将方法分配给委托变量会创建委托实例:
Transformer t = Square;
您可以像调用方法一样调用委托实例:
int answer = t(3); // answer is 9
这里是一个完整的例子:
Transformer t = Square; // Create delegate instance
int result = t(3); // Invoke delegate
Console.WriteLine (result); // 9
int Square (int x) => x * x;
delegate int Transformer (int x); // Delegate type declaration
委托实例实际上充当调用者的代表:调用者调用委托,然后委托调用目标方法。这种间接性解耦了调用者与目标方法之间的关系。
语句:
Transformer t = Square;
是的缩写为:
Transformer t = new Transformer (Square);
注意
从技术上讲,当我们不使用括号或参数引用Square时,我们正在指定一个方法组。如果方法被重载,C#将根据分配给它的委托的签名选择正确的重载。
表达式
t(3)
是的缩写
t.Invoke(3)
注意
委托类似于回调,这是一个泛指,涵盖了诸如 C 函数指针之类的构造。
使用委托编写插件方法
委托变量在运行时分配一个方法。这对于编写插件方法非常有用。在本例中,我们有一个名为Transform的实用方法,它将转换应用于整数数组中的每个元素。Transform方法有一个委托参数,您可以用来指定一个插件转换:
int[] values = { 1, 2, 3 };
Transform (values, Square); // Hook in the Square method
foreach (int i in values)
Console.Write (i + " "); // 1 4 9
void Transform (int[] values, Transformer t)
{
for (int i = 0; i < values.Length; i++)
values[i] = t (values[i]);
}
int Square (int x) => x * x;
int Cube (int x) => x * x * x;
delegate int Transformer (int x);
我们可以通过在代码的第二行中将Square更改为Cube来更改转换。
我们的Transform方法是一个高阶函数,因为它是一个接受函数作为参数的函数。(返回委托的方法也将是高阶函数。)
实例和静态方法目标
委托的目标方法可以是局部、静态或实例方法。以下示例说明了一个静态目标方法:
Transformer t = Test.Square;
Console.WriteLine (t(10)); // 100
class Test { public static int Square (int x) => x * x; }
delegate int Transformer (int x);
以下示例说明了一个实例目标方法:
Test test = new Test();
Transformer t = test.Square;
Console.WriteLine (t(10)); // 100
class Test { public int Square (int x) => x * x; }
delegate int Transformer (int x);
当将实例方法分配给委托对象时,后者不仅保留对方法的引用,还保留对方法所属实例的引用。System.Delegate类的Target属性表示这个实例(对于引用静态方法的委托将为空)。以下是一个例子:
MyReporter r = new MyReporter();
r.Prefix = "%Complete: ";
ProgressReporter p = r.ReportProgress;
p(99); // %Complete: 99
Console.WriteLine (p.Target == r); // True
Console.WriteLine (p.Method); // Void ReportProgress(Int32)
r.Prefix = "";
p(99); // 99
public delegate void ProgressReporter (int percentComplete);
class MyReporter
{
public string Prefix = "";
public void ReportProgress (int percentComplete)
=> Console.WriteLine (Prefix + percentComplete);
}
因为实例存储在委托的Target属性中,其生命周期延长至(至少与)委托的生命周期相同。
多播委托
所有委托实例都具有多播能力。这意味着委托实例可以引用不只一个单一目标方法,还可以引用一个目标方法列表。+ 和 += 操作符可以组合委托实例:
SomeDelegate d = SomeMethod1;
d += SomeMethod2;
最后一行与以下内容功能上是相同的:
d = d + SomeMethod2;
调用 d 现在将调用 SomeMethod1 和 SomeMethod2。委托按照添加的顺序被调用。
- 和 -= 操作符会从左侧委托操作数中移除右侧委托操作数:
d -= SomeMethod1;
调用 d 现在只会导致 SomeMethod2 被调用。
当一个委托变量的值为null时,对其调用+或+=操作是有效的,并且等同于将该变量赋值为一个新值:
SomeDelegate d = null;
d += SomeMethod1; // Equivalent (when d is null) to d = SomeMethod1;
类似地,对一个委托变量调用-=并匹配单一目标,等同于将该变量赋值为null。
注意
委托是不可变的,因此当您调用 += 或 -= 时,实际上是创建一个新的委托实例并将其赋给现有变量。
如果一个多播委托具有非void返回类型,调用者将接收到最后一个被调用方法的返回值。前面的方法仍然会被调用,但它们的返回值会被丢弃。在大多数使用多播委托的场景中,它们具有void返回类型,因此这种细微差别并不会出现。
注意
所有委托类型都隐式派生自System.MulticastDelegate,后者继承自System.Delegate。在 C#中,对委托进行的+、-、+=和-=操作会被编译为System.Delegate类的静态Combine和Remove方法。
多播委托示例
假设您编写了一个执行时间长的方法。该方法可以通过调用委托定期向其调用者报告进度。在这个例子中,HardWork 方法具有一个 ProgressReporter 委托参数,用于指示进度:
public delegate void ProgressReporter (int percentComplete);
public class Util
{
public static void HardWork (ProgressReporter p)
{
for (int i = 0; i < 10; i++)
{
p (i * 10); // Invoke delegate
System.Threading.Thread.Sleep (100); // Simulate hard work
}
}
}
为了监视进度,我们可以创建一个多播委托实例 p,使得进度由两个独立的方法监视:
ProgressReporter p = WriteProgressToConsole;
p += WriteProgressToFile;
Util.HardWork (p);
void WriteProgressToConsole (int percentComplete)
=> Console.WriteLine (percentComplete);
void WriteProgressToFile (int percentComplete)
=> System.IO.File.WriteAllText ("progress.txt",
percentComplete.ToString());
泛型委托类型
委托类型可以包含泛型类型参数:
public delegate T Transformer<T> (T arg);
基于这个定义,我们可以编写一个通用的 Transform 实用方法,适用于任何类型:
int[] values = { 1, 2, 3 };
Util.Transform (values, Square); // Hook in Square
foreach (int i in values)
Console.Write (i + " "); // 1 4 9
int Square (int x) => x * x;
public class Util
{
public static void Transform<T> (T[] values, Transformer<T> t)
{
for (int i = 0; i < values.Length; i++)
values[i] = t (values[i]);
}
}
Func 和 Action 委托
使用泛型委托,可以编写一小组委托类型,这些类型如此通用,可以处理任何返回类型和任意(合理数量的)参数。这些委托是在 System 命名空间中定义的 Func 和 Action 委托(in 和 out 注释指示变异,我们会在委托上下文中详细讨论):
delegate TResult Func <out TResult> ();
delegate TResult Func <in T, out TResult> (T arg);
delegate TResult Func <in T1, in T2, out TResult> (T1 arg1, T2 arg2);
*... and so on, up to T16*
delegate void Action ();
delegate void Action <in T> (T arg);
delegate void Action <in T1, in T2> (T1 arg1, T2 arg2);
*... and so on, up to T16*
这些委托非常通用。在前面的例子中,Transformer 委托可以被一个 Func 委托替换,它接受类型为 T 的单一参数,并返回相同类型的值:
public static void Transform<T> (T[] values, Func<T,T> transformer)
{
for (int i = 0; i < values.Length; i++)
values[i] = transformer (values[i]);
}
这些委托未覆盖的唯一实际场景是ref/out和指针参数。
注意
当 C#首次引入时,不存在Func和Action委托(因为没有泛型)。正因为这个历史原因,.NET 的许多部分使用自定义委托类型而不是Func和Action。
委托与接口的比较
用委托解决的问题也可以用接口解决。例如,我们可以使用名为ITransformer的接口而不是委托来重写我们的原始示例:
int[] values = { 1, 2, 3 };
Util.TransformAll (values, new Squarer());
foreach (int i in values)
Console.WriteLine (i);
public interface ITransformer
{
int Transform (int x);
}
public class Util
{
public static void TransformAll (int[] values, ITransformer t)
{
for (int i = 0; i < values.Length; i++)
values[i] = t.Transform (values[i]);
}
}
class Squarer : ITransformer
{
public int Transform (int x) => x * x;
}
如果以下一个或多个条件为真,则委托设计可能是一个更好的选择而不是接口设计:
-
接口仅定义一个方法。
-
需要多播功能。
-
订阅者需要多次实现接口。
在ITransformer示例中,我们不需要多播。然而,接口只定义了一个方法。此外,我们的订阅者可能需要多次实现ITransformer,以支持不同的转换,比如平方或立方。使用接口时,我们被迫为每种转换编写单独的类型,因为类只能实现ITransformer一次。这相当麻烦:
int[] values = { 1, 2, 3 };
Util.TransformAll (values, new Cuber());
foreach (int i in values)
Console.WriteLine (i);
class Squarer : ITransformer
{
public int Transform (int x) => x * x;
}
class Cuber : ITransformer
{
public int Transform (int x) => x * x * x;
}
委托的兼容性
类型的兼容性
即使委托类型的签名相同,它们之间也是不兼容的:
D1 d1 = Method1;
D2 d2 = d1; // Compile-time error
void Method1() { }
delegate void D1();
delegate void D2();
注意
然而,允许以下内容:
D2 d2 = new D2 (d1);
如果委托实例具有相同的方法目标,则视为相等。
D d1 = Method1;
D d2 = Method1;
Console.WriteLine (d1 == d2); // True
void Method1() { }
delegate void D();
如果多播委托引用了相同顺序的相同方法,则被视为相等。
参数的兼容性
当您调用一个方法时,可以提供比该方法参数更具体的参数。这是普通的多态行为。出于同样的原因,委托的参数类型可能比其方法目标更具体。这被称为逆变性。以下是一个例子:
StringAction sa = new StringAction (ActOnObject);
sa ("hello");
void ActOnObject (object o) => Console.WriteLine (o); // hello
delegate void StringAction (string s);
(与类型参数的变异性类似,委托只对引用转换进行变异。)
委托仅代表他人调用方法。在这种情况下,StringAction以string类型的参数调用。然后将参数传递给目标方法时,参数会隐式向上转型为object。
注意
标准事件模式旨在通过使用常见的EventArgs基类帮助您利用逆变性。例如,可以通过两个不同的委托调用单个方法,一个传递MouseEventArgs,另一个传递KeyEventArgs。
返回类型的兼容性
如果你调用一个方法,可能会得到一个比你要求的更具体的类型。这是普通的多态行为。出于同样的原因,委托的目标方法可能返回比委托描述的更具体的类型。这被称为协变:
ObjectRetriever o = new ObjectRetriever (RetrieveString);
object result = o();
Console.WriteLine (result); // hello
string RetrieveString() => "hello";
delegate object ObjectRetriever();
ObjectRetriever期望返回一个object,但是一个object的子类也可以:委托的返回类型是协变的。
泛型委托类型参数的变异性
在第三章中,我们看到了泛型接口支持协变和逆变的类型参数。对于委托也存在相同的能力。
如果您正在定义通用委托类型,以下是一个良好的实践:
-
将仅用于返回值的类型参数标记为协变(
out)。 -
将仅用于参数的类型参数标记为逆变(
in)。
这样做可以通过尊重类型之间的继承关系,使转换自然工作。
下面的委托(在System命名空间中定义)具有协变的TResult:
delegate TResult Func<out TResult>();
这允许:
Func<string> x = ...;
Func<object> y = x;
下面的委托(在System命名空间中定义)具有逆变的T:
delegate void Action<in T> (T arg);
这允许:
Action<object> x = ...;
Action<string> y = x;
事件
当使用委托时,通常会出现两种新角色:广播器和订阅者。
广播器是包含委托字段的类型。广播器通过调用委托来决定何时广播。
订阅者是方法的目标接收者。订阅者通过调用广播器委托的+=和-=来决定何时开始和停止监听。订阅者不知道或干预其他订阅者。
事件是一种语言特性,正式化了这一模式。event是一种仅公开委托子集的构造,适用于广播者/订阅者模型。事件的主要目的是防止订阅者相互干扰。
声明事件的最简单方法是在委托成员前面放置event关键字:
// Delegate definition
public delegate void PriceChangedHandler (decimal oldPrice,
decimal newPrice);
public class Broadcaster
{
// Event declaration
public event PriceChangedHandler PriceChanged;
}
Broadcaster类型内的代码可以完全访问PriceChanged并将其视为委托。在Broadcaster外部的代码只能对PriceChanged事件执行+=和-=操作。
考虑以下示例。Stock类每次Stock的Price更改时都会触发其PriceChanged事件:
public delegate void PriceChangedHandler (decimal oldPrice,
decimal newPrice);
public class Stock
{
string symbol;
decimal price;
public Stock (string symbol) => this.symbol = symbol;
public event PriceChangedHandler PriceChanged;
public decimal Price
{
get => price;
set
{
if (price == value) return; // Exit if nothing has changed
decimal oldPrice = price;
price = value;
if (PriceChanged != null) // If invocation list not
PriceChanged (oldPrice, price); // empty, fire event.
}
}
}
如果我们从示例中移除event关键字,使PriceChanged成为普通的委托字段,我们的示例将产生相同的结果。但是,Stock会不太健壮,因为订阅者可以通过以下方式相互干扰:
-
通过重新分配
PriceChanged(而不是使用+=运算符)替换其他订阅者。 -
清除所有订阅者(通过将
PriceChanged设置为null)。 -
通过调用委托向其他订阅者广播。
标准事件模式
在.NET 库中定义事件的几乎所有情况下,它们的定义遵循设计的标准模式,以提供库和用户代码之间的一致性。标准事件模式的核心是System.EventArgs,这是一个预定义的.NET 类,没有成员(除了静态的Empty字段)。EventArgs是传递事件信息的基类。在我们的Stock示例中,当PriceChanged事件被触发时,我们会子类化EventArgs以传递旧价格和新价格:
public class PriceChangedEventArgs : System.EventArgs
{
public readonly decimal LastPrice;
public readonly decimal NewPrice;
public PriceChangedEventArgs (decimal lastPrice, decimal newPrice)
{
LastPrice = lastPrice;
NewPrice = newPrice;
}
}
为了可重用性,EventArgs子类根据其包含的信息命名(而不是将用于其的事件)。通常将数据公开为属性或只读字段。
有了EventArgs子类之后,下一步是选择或定义事件的委托。有三条规则:
-
它必须具有
void返回类型。 -
它必须接受两个参数:第一个是
object类型,第二个是EventArgs的子类。第一个参数表示事件广播器,第二个参数包含要传递的额外信息。 -
其名称必须以
EventHandler结尾。
.NET 定义了一个名为System.EventHandler<>的通用委托来帮助处理此问题:
public delegate void EventHandler<TEventArgs> (object source, TEventArgs e)
注意
在语言中存在泛型之前(即 C# 2.0 之前),我们必须编写一个自定义委托来代替:
public delegate void PriceChangedHandler
(object sender, PriceChangedEventArgs e);
由于历史原因,.NET 库中的大多数事件使用此方式定义委托。
下一步是定义所选委托类型的事件。在这里,我们使用泛型的EventHandler委托:
public class Stock
{
...
public event EventHandler<PriceChangedEventArgs> PriceChanged;
}
最后,模式要求您编写一个保护虚拟方法来触发事件。名称必须与事件名称匹配,前缀为“On”,然后接受单个EventArgs参数:
public class Stock
{
...
public event EventHandler<PriceChangedEventArgs> PriceChanged;
protected virtual void OnPriceChanged (PriceChangedEventArgs e)
{
if (PriceChanged != null) PriceChanged (this, e);
}
}
注意
要在多线程场景下可靠地工作(第十四章),您需要在测试和调用之前将委托分配给一个临时变量:
var temp = PriceChanged;
if (temp != null) temp (this, e);
我们可以使用空值条件运算符来实现相同的功能,而不需要temp变量:
PriceChanged?.Invoke (this, e);
既线程安全又简洁,这是调用事件的最佳通用方法。
这提供了一个中心点,供子类调用或覆盖事件(假设类不是密封的)。
这是完整的示例:
using System;
Stock stock = new Stock ("THPW");
stock.Price = 27.10M;
// Register with the PriceChanged event
stock.PriceChanged += stock_PriceChanged;
stock.Price = 31.59M;
void stock_PriceChanged (object sender, PriceChangedEventArgs e)
{
if ((e.NewPrice - e.LastPrice) / e.LastPrice > 0.1M)
Console.WriteLine ("Alert, 10% stock price increase!");
}
public class PriceChangedEventArgs : EventArgs
{
public readonly decimal LastPrice;
public readonly decimal NewPrice;
public PriceChangedEventArgs (decimal lastPrice, decimal newPrice)
{
LastPrice = lastPrice; NewPrice = newPrice;
}
}
public class Stock
{
string symbol;
decimal price;
public Stock (string symbol) => this.symbol = symbol;
public event EventHandler<PriceChangedEventArgs> PriceChanged;
protected virtual void OnPriceChanged (PriceChangedEventArgs e)
{
PriceChanged?.Invoke (this, e);
}
public decimal Price
{
get => price;
set
{
if (price == value) return;
decimal oldPrice = price;
price = value;
OnPriceChanged (new PriceChangedEventArgs (oldPrice, price));
}
}
}
预定义的非泛型EventHandler委托可用于事件不携带额外信息的情况。在本例中,我们重新编写Stock,使得PriceChanged事件在价格变动后触发,并且除了发生事件之外,不需要任何有关事件的信息。我们还利用了EventArgs.Empty属性,以避免不必要地实例化EventArgs的实例:
public class Stock
{
string symbol;
decimal price;
public Stock (string symbol) { this.symbol = symbol; }
public event EventHandler PriceChanged;
protected virtual void OnPriceChanged (EventArgs e)
{
PriceChanged?.Invoke (this, e);
}
public decimal Price
{
get { return price; }
set
{
if (price == value) return;
price = value;
OnPriceChanged (EventArgs.Empty);
}
}
}
事件访问器
事件的访问器是其+=和-=函数的实现。默认情况下,编译器会隐式实现访问器。考虑以下事件声明:
public event EventHandler PriceChanged;
编译器将其转换为以下内容:
-
私有委托字段
-
一对公共事件访问器函数(
add_PriceChanged和remove_PriceChanged),其实现将+=和-=操作转发到私有委托字段
您可以通过定义显式事件访问器来接管此过程。以下是我们之前示例中PriceChanged事件的手动实现:
private EventHandler priceChanged; // Declare a private delegate
public event EventHandler PriceChanged
{
add { priceChanged += value; }
remove { priceChanged -= value; }
}
此示例在功能上与 C#的默认访问器实现相同(除了 C#还通过无锁比较和交换算法确保围绕更新委托的线程安全性;参见http://albahari.com/threading)。通过自定义事件访问器,我们指示 C#不生成默认字段和访问器逻辑。
使用显式事件访问器,可以对底层委托的存储和访问应用更复杂的策略。这是三种有用的情况:
-
当事件访问器仅仅是为另一个正在广播事件的类中继的情况。
-
当类公开许多事件时,其中大多数时间只存在很少的订阅者,例如 Windows 控件。在这种情况下,最好将订阅者的委托实例存储在字典中,因为字典的存储开销比数十个空委托字段引用要小得多。
-
当显式实现声明事件的接口时。
这里有一个例子来说明最后一点:
public interface IFoo { event EventHandler Ev; }
class Foo : IFoo
{
private EventHandler ev;
event EventHandler IFoo.Ev
{
add { ev += value; }
remove { ev -= value; }
}
}
注意
事件的add和remove部分被编译为add_*XXX*和remove_*XXX*方法。
事件修饰符
像方法一样,事件可以是虚拟的、重写的、抽象的或封闭的。事件也可以是静态的:
public class Foo
{
public static event EventHandler<EventArgs> StaticEvent;
public virtual event EventHandler<EventArgs> VirtualEvent;
}
Lambda 表达式
Lambda 表达式是在委托实例的位置编写的未命名方法。编译器立即将 Lambda 表达式转换为以下之一:
-
一个委托实例。
-
一个表达式树,类型为
Expression<TDelegate>,表示 Lambda 表达式内部代码的可遍历对象模型。这允许 Lambda 表达式在运行时后续解释(参见“构建查询表达式”)。
在下面的例子中,x => x * x是一个 Lambda 表达式:
Transformer sqr = x => x * x;
Console.WriteLine (sqr(3)); // 9
delegate int Transformer (int i);
注意
在内部,编译器通过编写一个私有方法,然后将表达式的代码移到该方法中来解析此类型的 Lambda 表达式。
Lambda 表达式具有以下形式:
(*parameters*) => *expression-or-statement-block*
为了方便起见,如果且仅当存在一个可推断类型的参数时,可以省略括号。
在我们的例子中,有一个名为x的单一参数,表达式为x * x:
x => x * x;
Lambda 表达式的每个参数对应于一个委托参数,表达式的类型(可能是void)对应于委托的返回类型。
在我们的例子中,x对应于参数i,表达式x * x对应于返回类型int,因此与Transformer委托兼容:
delegate int Transformer (int i);
Lambda 表达式的代码可以是一个语句块而不是一个表达式。我们可以将我们的例子重写如下:
x => { return x * x; };
Lambda 表达式最常与Func和Action委托一起使用,因此您通常会看到我们之前的表达式写成如下形式:
Func<int,int> sqr = x => x * x;
这里是一个接受两个参数的表达式的例子:
Func<string,string,int> totalLength = (s1, s2) => s1.Length + s2.Length;
int total = totalLength ("hello", "world"); // total is 10;
如果不需要使用参数,可以使用下划线丢弃它们(从 C# 9 开始):
Func<string,string,int> totalLength = (_,_) => ...
这是一个不带参数的表达式的示例:
Func<string> greeter = () => "Hello, world";
从 C# 10 开始,编译器允许使用可以通过Func和Action委托解析的 lambda 表达式进行隐式类型推断,因此我们可以将此语句缩短为:
var greeter = () => "Hello, world";
显式指定 Lambda 参数和返回类型
当编译器无法上下文推断 lambda 参数的类型时,通常可以推断出类型。在这种情况下,必须显式指定每个参数的类型。考虑以下两种方法:
void Foo<T> (T x) {}
void Bar<T> (Action<T> a) {}
以下代码将无法编译,因为编译器无法推断出x的类型:
Bar (x => Foo (x)); // What type is x?
我们可以通过显式指定x的类型来修复这个问题,如下所示:
Bar ((int x) => Foo (x));
这个特定示例足够简单,可以通过另外两种方式修复:
Bar<int> (x => Foo (x)); // Specify type parameter for Bar
Bar<int> (Foo); // As above, but with method group
下面的示例演示了从 C# 10 开始的显式参数类型的另一种用法:
var sqr = (int x) => x * x;
编译器推断sqr的类型为Func<int,int>。(不指定int时,隐式类型推断将失败:编译器知道sqr应该是Func<T,T>,但不知道T应该是什么。)
从 C# 10 开始,您还可以指定 lambda 返回类型:
var sqr = int (int x) => x;
指定返回类型可以提高复杂嵌套 lambda 的编译器性能。
默认 Lambda 参数(C# 12)
就像普通方法可以有可选参数一样:
void Print (string message = "") => Console.WriteLine (message);
因此,lambda 表达式也可以:
var print = (string message = "") => Console.WriteLine (message);
print ("Hello");
print ();
此功能在诸如 ASP.NET Minimal API 等库中非常有用。
捕获外部变量
lambda 表达式可以引用在定义 lambda 表达式的位置可访问的任何变量。这些称为外部变量,可以包括局部变量、参数和字段:
int factor = 2;
Func<int, int> multiplier = n => n * factor;
Console.WriteLine (multiplier (3)); // 6
lambda 表达式引用的外部变量称为捕获变量。捕获变量的 lambda 表达式称为闭包。
注意
变量也可以被匿名方法和局部方法所捕获。在这些情况下,捕获变量的规则是相同的。
当委托实际调用时,捕获的变量被评估,而不是在捕获变量时评估:
int factor = 2;
Func<int, int> multiplier = n => n * factor;
factor = 10;
Console.WriteLine (multiplier (3)); // 30
lambda 表达式本身可以更新捕获的变量:
int seed = 0;
Func<int> natural = () => seed++;
Console.WriteLine (natural()); // 0
Console.WriteLine (natural()); // 1
Console.WriteLine (seed); // 2
捕获变量的生命周期延长到委托的生命周期。在以下示例中,局部变量seed在Natural执行完成后通常会从作用域中消失。但因为seed已被捕获,其生命周期延长到捕获委托natural的生命周期:
static Func<int> Natural()
{
int seed = 0;
return () => seed++; // Returns a *closure*
}
static void Main()
{
Func<int> natural = Natural();
Console.WriteLine (natural()); // 0
Console.WriteLine (natural()); // 1
}
在 lambda 表达式内部实例化的局部变量每次委托实例调用时都是唯一的。如果我们将先前的示例重构为在 lambda 表达式内部实例化seed,我们将得到一个不同的(在本例中是不可取的)结果:
static Func<int> Natural()
{
return() => { int seed = 0; return seed++; };
}
static void Main()
{
Func<int> natural = Natural();
Console.WriteLine (natural()); // 0
Console.WriteLine (natural()); // 0
}
注意
捕获是通过将捕获的变量“提升”为私有类的字段来内部实现的。当调用方法时,该类被实例化并与委托实例绑定其生命周期。
静态 lambda
当捕获局部变量、参数、实例字段或this引用时,编译器可能需要创建和实例化一个私有类来存储对捕获数据的引用。这会带来一定的性能成本,因为需要分配(以及随后回收)内存。在性能至关重要的情况下,一种微优化策略是通过确保代码热点路径几乎不或完全不产生分配来减少垃圾收集器的负担。
从 C# 9 开始,可以通过应用static关键字来确保 lambda 表达式、局部函数或匿名方法不会捕获状态。这在微优化场景中可能很有用,以防止意外的内存分配。例如,可以如下所示将 static 修饰符应用于 lambda 表达式:
Func<int, int> multiplier = static n => n * 2;
如果稍后尝试修改 lambda 表达式以捕获局部变量,则编译器将生成错误:
int factor = 2;
Func<int, int> multiplier = static n => n * factor; // will not compile
注意
lambda 本身会评估为一个委托实例,这需要进行内存分配。然而,如果 lambda 不捕获变量,编译器将在应用程序的整个生命周期内重用单个缓存实例,因此实际上不会产生额外的成本。
此功能也可用于局部方法。在以下示例中,Multiply方法无法访问factor变量:
void Foo()
{
int factor = 123;
static int Multiply (int x) => x * 2; // Local static method
}
当然,Multiply方法仍然可以通过调用new来显式分配内存。这种方法保护我们免受偷偷摸摸分配的潜在风险。在此处应用static也可视为一种文档工具,指示减少耦合级别。
静态 lambda 仍然可以访问静态变量和常量(因为这些不需要闭包)。
注意
static关键字仅作为一种检查存在;它对编译器生成的 IL 没有影响。如果没有static关键字,编译器不会生成闭包,除非它需要(即使如此,它也有一些技巧来减轻成本)。
捕获迭代变量
当你捕获for循环的迭代变量时,C#会将该变量视为在循环外部声明的变量。这意味着每次迭代都会捕获同一个变量。以下程序输出的是333而不是012:
Action[] actions = new Action[3];
for (int i = 0; i < 3; i++)
actions [i] = () => Console.Write (i);
foreach (Action a in actions) a(); // 333
每个闭包(closure)捕获的是同一个变量,i。(这在考虑到i是一个变量,其值在循环迭代之间保持不变时确实是有意义的;如果需要的话,甚至可以在循环体内显式修改i。)其结果是,当稍后调用这些委托时,每个委托看到的是调用时i的值——这里是 3。我们可以通过展开for循环来更好地说明这一点:
Action[] actions = new Action[3];
int i = 0;
actions[0] = () => Console.Write (i);
i = 1;
actions[1] = () => Console.Write (i);
i = 2;
actions[2] = () => Console.Write (i);
i = 3;
foreach (Action a in actions) a(); // 333
如果我们想要输出012,解决方案是将迭代变量分配给循环内部作用域的局部变量:
Action[] actions = new Action[3];
for (int i = 0; i < 3; i++)
{
int loopScopedi = i;
actions [i] = () => Console.Write (loopScopedi);
}
foreach (Action a in actions) a(); // 012
因为loopScopedi在每次迭代时都是全新创建的,所以每个闭包捕获的是不同的变量。
注意
在 C# 5.0 之前,foreach 循环的工作方式相同。这引起了相当大的混淆:与 for 循环不同,foreach 循环中的迭代变量是不可变的,因此你期望它在循环体内部被视为局部变量。好消息是现在已经修复,你可以安全地捕获 foreach 循环的迭代变量而不会有任何意外。
Lambda 表达式与局部方法
局部方法的功能(参见 “局部方法”)与 lambda 表达式重叠。局部方法有以下三个优点:
-
它们可以是递归的(可以调用自身),而无需丑陋的 hack。
-
它们避免了指定委托类型的混乱。
-
它们的开销稍微少一些。
局部方法更高效,因为它们避免了委托的间接调用(这会消耗一些 CPU 循环和内存分配)。它们还可以访问包含方法的局部变量,而无需编译器将捕获的变量提升到隐藏类中。
但是,在许多情况下,你需要 一个委托 —— 最常见的情况是调用高阶函数,即具有委托类型参数的方法:
public void Foo (Func<int,bool> predicate) { ... }
(你可以在 第八章 中看到更多类似的内容)。在这种情况下,无论如何都需要委托,通常 lambda 表达式更简洁更清晰。
匿名方法
匿名方法是 C# 2.0 的一个特性,大部分被 C# 3.0 的 lambda 表达式所取代。匿名方法类似于 lambda 表达式,但它缺少以下功能:
-
隐式类型参数
-
表达式语法(匿名方法必须始终是一个语句块)
-
编译为表达式树的能力,通过分配给
Expression<T>
匿名方法使用 delegate 关键字,后跟(可选的)参数声明,然后是方法体。例如:
Transformer sqr = delegate (int x) {return x * x;};
Console.WriteLine (sqr(3)); // 9
delegate int Transformer (int i);
第一行在语义上等同于以下 lambda 表达式:
Transformer sqr = (int x) => {return x * x;};
或者简单地说:
Transformer sqr = x => x * x;
匿名方法与 lambda 表达式一样捕获外部变量,并且可以在前面加上 static 关键字使其行为类似静态 lambda。
注意
匿名方法的一个独特特性是你可以完全省略参数声明,即使委托期望有参数。这在声明带有默认空处理程序的事件时非常有用:
public event EventHandler Clicked = delegate { };
这样可以避免在触发事件前进行空检查。以下也是合法的:
// Notice that we omit the parameters:
Clicked += delegate { Console.WriteLine ("clicked"); };
try 语句和异常
try 语句指定一个受错误处理或清理代码控制的代码块。try 块 必须后跟一个或多个 catch 块 和/或一个 finally 块,或两者都有。当在 try 块中抛出错误时,catch 块执行。finally 块在执行离开 try 块(或如果存在的话,catch 块)后执行清理代码,无论是否抛出异常。
catch块可以访问包含有关错误信息的Exception对象。您使用catch块来补偿错误或重新抛出异常。如果您仅想记录问题或重新抛出新的更高级别的异常类型,则重新抛出异常。
finally块为您的程序增加了确定性:CLR 努力始终执行它。用于关闭网络连接等清理任务很有用。
try语句看起来像这样:
try
{
... // exception may get thrown within execution of this block
}
catch (ExceptionA ex)
{
... // handle exception of type ExceptionA
}
catch (ExceptionB ex)
{
... // handle exception of type ExceptionB
}
finally
{
... // cleanup code
}
考虑以下程序:
int y = Calc (0);
Console.WriteLine (y);
int Calc (int x) => 10 / x;
因为x为零,运行时引发了DivideByZeroException并且我们的程序终止。我们可以通过捕获异常来防止这种情况,如下所示:
try
{
int y = Calc (0);
Console.WriteLine (y);
}
catch (DivideByZeroException ex)
{
Console.WriteLine ("x cannot be zero");
}
Console.WriteLine ("program completed");
int Calc (int x) => 10 / x;
下面是输出:
x cannot be zero
program completed
注意
这是一个简单的示例,用于说明异常处理。在实际中,我们可以通过在调用Calc之前显式检查除数是否为零来更好地处理这种特定情况。
检查可预防的错误优于依赖try/catch块,因为异常处理相对较昂贵,需要数百个时钟周期或更多。
当在try语句中引发异常时,CLR 执行测试:
在 try 语句中是否有任何兼容的 catch 块?
-
如果是这样,执行将跳转到兼容的
catch块,然后是finally块(如果存在),然后正常继续执行。 -
如果没有,执行直接跳转到
finally块(如果存在),然后 CLR 在调用堆栈中查找其他try块;如果找到,它会重复测试。
如果调用堆栈中没有任何函数负责异常,则程序终止。
catch子句
catch子句指定要捕获的异常类型。这必须是System.Exception或System.Exception的子类。
捕获System.Exception可以捕获所有可能的错误。在以下情况下这非常有用:
-
无论具体的异常类型如何,您的程序都可能恢复。
-
您计划重新抛出异常(可能在记录日志后)。
-
错误处理程序是程序终止之前的最后一道防线。
通常情况下,您会捕获特定的异常类型,以避免处理处理程序未设计的情况(例如,OutOfMemoryException)。
您可以使用多个catch子句处理多个异常类型(再次强调,这个例子可以通过显式的参数检查而不是异常处理来编写):
class Test
{
static void Main (string[] args)
{
try
{
byte b = byte.Parse (args[0]);
Console.WriteLine (b);
}
catch (IndexOutOfRangeException)
{
Console.WriteLine ("Please provide at least one argument");
}
catch (FormatException)
{
Console.WriteLine ("That's not a number!");
}
catch (OverflowException)
{
Console.WriteLine ("You've given me more than a byte!");
}
}
}
对于给定的异常,只有一个catch子句会执行。如果您想包含一个捕获更一般异常(如System.Exception)的安全网,必须将更具体的处理程序放在前面。
如果不需要访问其属性,可以在catch子句中捕获异常而不指定变量:
catch (OverflowException) // no variable
{
...
}
此外,您可以省略变量和类型(这意味着将捕获所有异常):
catch { ... }
异常过滤器
您可以通过添加when子句在catch子句中指定异常过滤器:
catch (WebException ex) when (ex.Status == WebExceptionStatus.Timeout)
{
...
}
如果在此示例中抛出 WebException,则将评估 when 关键字后面的布尔表达式。如果结果为 false,则忽略相关的 catch 块并考虑后续的 catch 子句。使用异常过滤器可以捕获同一类型的异常再次抛出是有意义的:
catch (WebException ex) when (ex.Status == WebExceptionStatus.Timeout)
{ ... }
catch (WebException ex) when (ex.Status == WebExceptionStatus.SendFailure)
{ ... }
when 子句中的布尔表达式可以具有副作用,例如记录异常以进行诊断。
finally 块
finally 块始终执行——无论是否抛出异常以及 try 块是否完成。通常使用 finally 块进行清理代码。
finally 块在以下任一情况之后执行:
-
catch块完成(或抛出新异常)。 -
try块完成(或抛出没有相应catch块的异常)。 -
因为
jump语句(例如return或goto)而离开try块。
唯一能够打败 finally 块的是无限循环或进程突然终止。
finally 块有助于使程序具有确定性。在以下示例中,我们打开的文件 总是 被关闭,无论是否:
-
try块正常完成。 -
由于文件为空(
EndOfStream),执行提前返回。 -
在读取文件时抛出
IOException:
void ReadFile()
{
StreamReader reader = null; // In System.IO namespace
try
{
reader = File.OpenText ("file.txt");
if (reader.EndOfStream) return;
Console.WriteLine (reader.ReadToEnd());
}
finally
{
if (reader != null) reader.Dispose();
}
}
在这个例子中,我们通过在 StreamReader 上调用 Dispose 方法来关闭文件。在 finally 块中调用对象的 Dispose 方法是一种标准约定,并且在 C# 中通过 using 语句明确支持。
using 语句
许多类封装了非托管资源,例如文件句柄、图形句柄或数据库连接。这些类实现了 System.IDisposable 接口,该接口定义了一个名为 Dispose 的无参方法来清理这些资源。using 语句为在 finally 块中调用 Dispose 提供了一种优雅的语法。
因此
using (StreamReader reader = File.OpenText ("file.txt"))
{
...
}
等价于以下内容:
{
StreamReader reader = File.OpenText ("file.txt");
try
{
...
}
finally
{
if (reader != null)
((IDisposable)reader).Dispose();
}
}
using 声明
如果省略 using 语句(C# 8+)后面的大括号和语句块,则它变成了 using 声明。当执行流程跳出 封闭 语句块时,资源将被释放:
if (File.Exists ("file.txt"))
{
using var reader = File.OpenText ("file.txt");
Console.WriteLine (reader.ReadLine());
...
}
在这种情况下,当执行流程离开 if 语句块时,reader 将被释放。
抛出异常
异常可以由运行时或用户代码抛出。在此示例中,Display 抛出了 System.ArgumentNullException:
try { Display (null); }
catch (ArgumentNullException ex)
{
Console.WriteLine ("Caught the exception");
}
void Display (string name)
{
if (name == null)
throw new ArgumentNullException (nameof (name));
Console.WriteLine (name);
}
注意
因为对参数进行空值检查并抛出 ArgumentNullException 是如此常见的代码路径,所以实际上从 .NET 6 开始有了一种快捷方式:
void Display (string name)
{
ArgumentNullException.ThrowIfNull (name);
Console.WriteLine (name);
}
请注意,我们无需指定参数的名称。稍后我们将在 “CallerArgumentExpression” 中解释原因。
throw 表达式
throw 也可以出现作为表达式的形式在表达式体函数中:
public string Foo() => throw new NotImplementedException();
throw 表达式也可以出现在三元条件表达式中:
string ProperCase (string value) =>
value == null ? throw new ArgumentException ("value") :
value == "" ? "" :
char.ToUpper (value[0]) + value.Substring (1);
重新抛出异常
您可以按以下方式捕获并重新抛出异常:
try { ... }
catch (Exception ex)
{
// Log error
...
throw; // Rethrow same exception
}
注意
如果我们用throw ex替换throw,例子仍然可以工作,但是新传播的异常的StackTrace属性将不再反映原始错误。
以这种方式重新抛出可以让您记录错误而不是吞噬它。它还允许您退出处理异常,如果情况超出您的预期。另一个常见情况是重新抛出更具体的异常类型:
try
{
... // Parse a DateTime from XML element data
}
catch (FormatException ex)
{
throw new XmlException ("Invalid DateTime", ex);
}
注意,当我们构造XmlException时,我们将原始异常ex作为第二个参数传递。此参数填充新异常的InnerException属性,并有助于调试。几乎所有类型的异常都提供类似的构造函数。
重新抛出较少具体的异常是在跨越信任边界时可能会做的事情,以便不向潜在的黑客泄露技术信息。
System.Exception 的关键属性
System.Exception的最重要属性如下:
StackTrace
表示从异常发生源到catch块调用的所有方法的字符串。
Message
描述错误的字符串。
InnerException
导致外部异常的内部异常(如果有)。这本身可能有另一个InnerException。
注意
所有 C#中的异常都是运行时异常——没有 Java 中编译时检查的异常等价物。
常见的异常类型
下列异常类型广泛用于 CLR 和.NET 库中。您可以自行抛出这些异常或将它们用作派生自定义异常类型的基类:
System.ArgumentException
当使用伪造参数调用函数时抛出。这通常表示程序错误。
System.ArgumentNullException
ArgumentException的子类,在函数参数(意外地)为null时抛出。
System.ArgumentOutOfRangeException
当(通常是数值的)参数太大或太小时抛出的ArgumentException的子类。例如,当向仅接受正值的函数传递负数时抛出此异常。
System.InvalidOperationException
当对象的状态不适合方法成功执行时抛出,而不考虑任何特定的参数值。例如,尝试读取未打开的文件或从在迭代过程中修改了基础列表的枚举器中获取下一个元素时。
System.NotSupportedException
表示不支持特定功能。一个很好的例子是在返回true的集合上调用Add方法的情况。
System.NotImplementedException
抛出以指示函数尚未实现。
System.ObjectDisposedException
在调用函数所在的对象已释放时抛出。
另一种常见的异常类型是 NullReferenceException。当您尝试访问其值为 null 的对象的成员时(表示代码中存在 bug),CLR 会引发此异常。您可以如下直接抛出 NullReferenceException(供测试目的):
throw null;
TryXXX 方法模式
在编写方法时,当出现问题时,您可以选择返回某种失败代码或引发异常。通常情况下,当错误超出正常工作流程时,或者您预期直接调用者无法处理时,可以引发异常。不过,偶尔提供两种选择给消费者可能更好。一个例子是 int 类型,它定义了其 Parse 方法的两个版本:
public int Parse (string input);
public bool TryParse (string input, out int returnValue);
如果解析失败,Parse 抛出异常;TryParse 返回 false。
您可以通过将 *XXX* 方法调用 Try*XXX* 方法来实现此模式,如下所示:
public *return-type XXX* (*input-type* input)
{
*return-type* returnValue;
if (!Try*XXX* (input, out returnValue))
throw new *YYY*Exception (...)
return returnValue;
}
异常的替代方法
与 int.TryParse 一样,函数可以通过返回类型或参数将失败传递给调用函数。尽管这可以处理简单和可预测的失败,但在处理不寻常或不可预测的错误时会变得笨拙,会污染方法签名并创建不必要的复杂性和混乱。
它也不能泛化为不是方法的函数,例如运算符(例如,除法运算符)或属性。一个替代方法是将错误放置在一个公共位置,让调用堆栈中的所有函数都能看到它(例如,一个静态方法,它在每个线程中存储当前错误)。然而,这要求每个函数参与错误传播模式,这很麻烦,而且具有讽刺性的是,这本身也容易出错。
枚举和迭代器
枚举
一个 枚举器 是对 一系列值 的只读、单向游标。如果类型执行以下任一操作,则 C# 将其视为枚举器:
-
具有名为
MoveNext的公共无参数方法和名为Current的属性 -
实现
System.Collections.Generic.IEnumerator<T> -
实现
System.Collections.IEnumerator
foreach 语句遍历 可枚举对象。可枚举对象是序列的逻辑表示。它本身不是游标,而是生成其自身上的游标的对象。如果类型执行以下任一操作,则 C# 将其视为可枚举类型(按此顺序进行检查):
-
具有名为
GetEnumerator的公共无参数方法,返回一个枚举器 -
实现
System.Collections.Generic.IEnumerable<T> -
实现
System.Collections.IEnumerable -
(来自 C# 9)可以绑定到名为
GetEnumerator的 扩展方法,该方法返回一个枚举器(参见 “扩展方法”)
枚举模式如下所示:
class *Enumerator* // Typically implements IEnumerator or IEnumerator<T>
{
public *IteratorVariableType* Current { get {...} }
public bool MoveNext() {...}
}
class *Enumerable* // Typically implements IEnumerable or IEnumerable<T>
{
public *Enumerator* GetEnumerator() {...}
}
这里是使用 foreach 语句高级遍历单词“beer”中的字符的方法:
foreach (char c in "beer")
Console.WriteLine (c);
这是在不使用foreach语句的情况下迭代“beer”中字符的低级方式:
using (var enumerator = "beer".GetEnumerator())
while (enumerator.MoveNext())
{
var element = enumerator.Current;
Console.WriteLine (element);
}
如果枚举器实现了IDisposable,那么foreach语句也充当using语句,隐式地处理枚举器对象。
第七章详细解释了枚举接口。
集合初始化器和集合表达式
您可以通过集合初始化器一次性实例化和填充可枚举对象:
using System.Collections.Generic;
var list = new List<int> {1, 2, 3};
从 C# 12 开始,您可以使用集合表达式(请注意方括号)进一步缩短:
using System.Collections.Generic;
List<int> list = [1, 2, 3];
注意
集合表达式是目标类型化的,这意味着[1,2,3]的类型取决于分配给它的类型(在本例中为List<int>)。在下面的例子中,目标类型是int[]和Span<int>(我们在第二十三章中介绍):
int[] array = [1, 2, 3];
Span<int> span = [1, 2, 3];
目标类型化意味着您可以在编译器可以推断出类型的其他场景中省略类型,例如调用方法时:
Foo ([1, 2, 3]);
void Foo (List<int> numbers) { ... }
编译器将其翻译为以下内容:
using System.Collections.Generic;
List<int> list = new List<int>();
list.Add (1);
list.Add (2);
list.Add (3);
这要求可枚举对象实现System.Collections.IEnumerable接口,并且具有适用于调用的Add方法的适当数量的参数。(使用集合表达式时,编译器还支持其他模式,以允许创建只读集合。)
类似地,您可以按以下方式初始化字典(参见“字典”):
var dict = new Dictionary<int, string>()
{
{ 5, "five" },
{ 10, "ten" }
};
或者更简洁地说:
var dict = new Dictionary<int, string>()
{
[3] = "three",
[10] = "ten"
};
后者不仅对字典有效,而且对于任何具有索引器的类型也有效。
迭代器
而foreach语句是枚举器的消费者,迭代器是枚举器的生产者。在这个例子中,我们使用迭代器返回斐波那契数列的序列(其中每个数字是前两个数字的和):
using System;
using System.Collections.Generic;
foreach (int fib in Fibs(6))
Console.Write (fib + " ");
}
IEnumerable<int> Fibs (int fibCount)
{
for (int i = 0, prevFib = 1, curFib = 1; i < fibCount; i++)
{
yield return prevFib;
int newFib = prevFib+curFib;
prevFib = curFib;
curFib = newFib;
}
}
OUTPUT: 1 1 2 3 5 8
而return语句表达“这是您要求我从此方法返回的值”,yield return语句表达“这是您要求我从此枚举器中生成的下一个元素”。在每个yield语句上,控制返回给调用者,但调用者的状态保持不变,以便方法可以继续执行,只要调用者枚举下一个元素。此状态的生命周期绑定到枚举器,以便在调用者完成枚举时可以释放该状态。
注意
编译器将迭代方法转换为实现IEnumerable<T>和/或IEnumerator<T>的私有类。迭代器块内的逻辑被“反转”并拼接到编译器生成的枚举器类的MoveNext方法和Current属性中。这意味着当您调用迭代方法时,您所做的只是实例化编译器生成的类;您的代码实际上并不运行!只有当您开始枚举结果序列时,通常使用foreach语句时,您的代码才会运行。
迭代器可以是局部方法(见“局部方法”)。
迭代器语义
迭代器是一个包含一个或多个yield语句的方法、属性或索引器。迭代器必须返回以下四个接口之一(否则,编译器将生成错误):
// Enumerable interfaces
System.Collections.IEnumerable
System.Collections.Generic.IEnumerable<T>
// Enumerator interfaces
System.Collections.IEnumerator
System.Collections.Generic.IEnumerator<T>
一个迭代器根据其返回的是可枚举接口还是枚举器接口具有不同的语义。我们在第七章中描述了这一点。
允许多个 yield 语句:
foreach (string s in Foo())
Console.WriteLine(s); // Prints "One","Two","Three"
IEnumerable<string> Foo()
{
yield return "One";
yield return "Two";
yield return "Three";
}
yield break
在迭代器块中,不能使用返回语句;而是必须使用 yield break 语句指示迭代器块应提前退出,不返回更多元素。我们可以修改 Foo 如下所示来演示:
IEnumerable<string> Foo (bool breakEarly)
{
yield return "One";
yield return "Two";
if (breakEarly)
yield break;
yield return "Three";
}
迭代器和 try/catch/finally 块
yield return 语句不能出现在带有 catch 子句的 try 块中:
IEnumerable<string> Foo()
{
try { yield return "One"; } // Illegal
catch { ... }
}
yield return 也不能出现在 catch 或 finally 块中。这些限制是因为编译器必须将迭代器转换为具有 MoveNext、Current 和 Dispose 成员的普通类,并且转换异常处理块会导致过多的复杂性。
然而,你可以在仅有 finally 块的 try 块中使用 yield:
IEnumerable<string> Foo()
{
try { yield return "One"; } // OK
finally { ... }
}
当消费枚举器到达序列末尾或被处理时,finally块中的代码将执行。如果提前中断foreach语句,则会隐式处理枚举器,这是一种安全的枚举方式。在显式处理枚举器时,一个陷阱是在不处理它的情况下提前放弃枚举,绕过finally块。通过将显式使用枚举器包装在using语句中,可以避免这种风险:
string firstElement = null;
var sequence = Foo();
using (var enumerator = sequence.GetEnumerator())
if (enumerator.MoveNext())
firstElement = enumerator.Current;
组合序列
迭代器具有高度的可组合性。我们可以扩展我们的示例,这次只输出偶数 Fibonacci 数:
using System;
using System.Collections.Generic;
foreach (int fib in EvenNumbersOnly (Fibs(6)))
Console.WriteLine (fib);
IEnumerable<int> Fibs (int fibCount)
{
for (int i = 0, prevFib = 1, curFib = 1; i < fibCount; i++)
{
yield return prevFib;
int newFib = prevFib+curFib;
prevFib = curFib;
curFib = newFib;
}
}
IEnumerable<int> EvenNumbersOnly (IEnumerable<int> sequence)
{
foreach (int x in sequence)
if ((x % 2) == 0)
yield return x;
}
直到请求执行 MoveNext() 操作时,每个元素才会被计算。图 4-1 显示了随时间的数据请求和数据输出。

图 4-1. 组合序列
迭代器模式的组合性在 LINQ 中非常有用;我们在第八章中再次讨论这个主题。
可空值类型
引用类型可以用空引用表示不存在的值。然而,值类型通常不能表示空值:
string s = null; // OK, Reference Type
int i = null; // Compile Error, Value Type cannot be null
要在值类型中表示空值,必须使用称为可空类型的特殊构造:可空类型由值类型后跟?符号表示:
int? i = null; // OK, Nullable Type
Console.WriteLine (i == null); // True
Nullable 结构
T? 转换为 System.Nullable<T>,这是一个轻量级的不可变结构,只有两个字段,用于表示 Value 和 HasValue。System.Nullable<T> 的本质非常简单:
public struct Nullable<T> where T : struct
{
public T Value {get;}
public bool HasValue {get;}
public T GetValueOrDefault();
public T GetValueOrDefault (T defaultValue);
...
}
代码
int? i = null;
Console.WriteLine (i == null); // True
转换为以下内容:
Nullable<int> i = new Nullable<int>();
Console.WriteLine (! i.HasValue); // True
当HasValue为 false 时尝试检索Value会抛出InvalidOperationException。如果HasValue为 true,则GetValueOrDefault()返回Value;否则,返回new T()或指定的自定义默认值。
T?的默认值为null。
可空隐式和显式转换
从T到T?的转换是隐式的,而从T?到T的转换是显式的:
int? x = 5; // implicit
int y = (int)x; // explicit
显式转换直接等效于调用可空对象的Value属性。因此,如果HasValue为 false,则抛出InvalidOperationException。
装箱和拆箱可空值
当T?装箱时,堆上的装箱值包含T,而不是T?。这种优化是可能的,因为装箱值是可以表示 null 的引用类型。
C#还允许使用as操作符对可空值类型进行拆箱。如果转换失败,则结果为null:
object o = "string";
int? x = o as int?;
Console.WriteLine (x.HasValue); // False
操作符提升
Nullable<T>结构体未定义诸如<、>或==等操作符。尽管如此,以下代码编译并正确执行:
int? x = 5;
int? y = 10;
bool b = x < y; // true
这是因为编译器从基础值类型中借用或“提升”了小于操作符。语义上,它将前面的比较表达式转换为:
bool b = (x.HasValue && y.HasValue) ? (x.Value < y.Value) : false;
换句话说,如果x和y都有值,则通过int的小于操作符比较;否则,返回false。
操作符提升意味着你可以隐式地在T?上使用T的操作符。你可以为T?定义操作符以提供特定的空值行为,但在绝大多数情况下,最好依赖编译器自动为你应用系统化的可空逻辑。以下是一些示例:
int? x = 5;
int? y = null;
// Equality operator examples
Console.WriteLine (x == y); // False
Console.WriteLine (x == null); // False
Console.WriteLine (x == 5); // True
Console.WriteLine (y == null); // True
Console.WriteLine (y == 5); // False
Console.WriteLine (y != 5); // True
// Relational operator examples
Console.WriteLine (x < 6); // True
Console.WriteLine (y < 6); // False
Console.WriteLine (y > 6); // False
// All other operator examples
Console.WriteLine (x + 5); // 10
Console.WriteLine (x + y); // *null* (prints empty line)
编译器根据操作符的类别以不同的方式执行空值逻辑。以下各节解释了这些不同的规则。
等式操作符(==和!=)
提升的等式操作符处理 null 与引用类型一样。这意味着两个 null 值是相等的:
Console.WriteLine ( null == null); // True
Console.WriteLine ((bool?)null == (bool?)null); // True
更进一步:
-
如果恰好一个操作数为 null,则操作数不相等。
-
如果两个操作数均为非 null,则比较它们的
Value。
关系操作符(<、<=、>=、>)
关系操作符的工作原理是对比 null 操作数是无意义的。这意味着将 null 值与 null 或非 null 值进行比较会返回false:
bool b = x < y; // Translation:
bool b = (x.HasValue && y.HasValue)
? (x.Value < y.Value)
: false;
// b is false (assuming x is 5 and y is null)
所有其他操作符(+、-、*、/、%、&、|、^、<<、>>、+、++、--、!、~)
当任一操作数为 null 时,这些操作符返回 null。这种模式对 SQL 用户来说应该很熟悉:
int? c = x + y; // Translation:
int? c = (x.HasValue && y.HasValue)
? (int?) (x.Value + y.Value)
: null;
// c is null (assuming x is 5 and y is null)
例外情况是当我们讨论将&和|操作符应用于bool?时。
混合可空和非可空类型
您可以混合使用可空和非可空值类型(这是因为从T到T?有隐式转换):
int? a = null;
int b = 2;
int? c = a + b; // c is null - equivalent to a + (int?)b
带&和|操作符的bool?
当提供bool?类型的操作数时,&和|操作符将null视为未知值。因此,null | true为 true,因为:
-
如果未知值为 false,则结果为 true。
-
如果未知值为真,则结果将为真。
同样地,null & false 是假的。这种行为对 SQL 用户来说应该很熟悉。以下例子列举了其他的组合:
bool? n = null;
bool? f = false;
bool? t = true;
Console.WriteLine (n | n); // *(null)*
Console.WriteLine (n | f); // *(null)*
Console.WriteLine (n | t); // True
Console.WriteLine (n & n); // *(null)*
Console.WriteLine (n & f); // False
Console.WriteLine (n & t); // *(null)*
可空值类型和空运算符
可空值类型与 ?? 运算符(参见“空合并运算符”)特别配合得很好,正如本例所示:
int? x = null;
int y = x ?? 5; // y is 5
int? a = null, b = 1, c = 2;
Console.WriteLine (a ?? b ?? c); // 1 *(first non-null value)*
对可空值类型使用 ?? 等同于使用具有显式默认值的 GetValueOrDefault 方法,只是如果变量为空,则默认值表达式永远不会被评估。
可空值类型也与空条件运算符(参见“空条件运算符”)非常配合。在下面的例子中,长度评估为空:
System.Text.StringBuilder sb = null;
int? length = sb?.ToString().Length;
我们可以与空合并运算符结合,使其评估为零而不是空:
int length = sb?.ToString().Length ?? 0; // Evaluates to 0 if sb is null
可空值类型的场景
可空值类型最常见的场景之一是表示未知值。这在数据库编程中经常发生,其中一个类映射到具有可空列的表。如果这些列是字符串(例如客户表上的 EmailAddress 列),则没有问题,因为字符串是 CLR 中的引用类型,可以为空。然而,大多数其他 SQL 列类型映射到 CLR 结构类型,使得可空值类型在将 SQL 映射到 CLR 时非常有用:
// Maps to a Customer table in a database
public class Customer
{
...
public decimal? AccountBalance;
}
可空类型也可以用来表示所谓的环境属性的后备字段。如果环境属性为空,它将返回其父级的值:
public class Row
{
...
Grid parent;
Color? color;
public Color Color
{
get { return color ?? parent.Color; }
set { color = value == parent.Color ? (Color?)null : value; }
}
}
可空值类型的替代方案
在 C# 语言中引入可空值类型之前(即在 C# 2.0 之前),有许多处理它们的策略,出于历史原因,在 .NET 库中仍然存在这些策略的示例。其中一种策略是指定一个特定的非空值作为“空值”,例如在字符串和数组类中。当字符未找到时,String.IndexOf 返回魔术值 −1:
int i = "Pink".IndexOf ('b');
Console.WriteLine (i); // −1
不过,只有当索引为 0 时,Array.IndexOf 才返回 -1。更一般的规则是 IndexOf 返回数组的下界减一。在下一个例子中,当未找到元素时,IndexOf 返回 0:
// Create an array whose lower bound is 1 instead of 0:
Array a = Array.CreateInstance (typeof (string),
new int[] {2}, new int[] {1});
a.SetValue ("a", 1);
a.SetValue ("b", 2);
Console.WriteLine (Array.IndexOf (a, "c")); // 0
指定“魔术值”存在多种问题:
-
这意味着每种值类型都有不同的空表示。相反,可空值类型提供了一个对所有值类型都适用的共同模式。
-
可能没有合理的指定值。在前面的例子中,
−1并不总是可用。在代表未知账户余额的早期例子中也是如此。 -
忘记测试魔术值将导致不正确的值,可能直到后期执行时才会被注意到——当它执行意外的魔术把戏时。然而,忘记在空值上测试
HasValue会立即抛出InvalidOperationException。 -
值可以为 null 的能力不包含在类型中。类型传达程序的意图,允许编译器检查正确性,并启用编译器强制执行的一致规则集。
可空引用类型
而可空值类型将空性引入值类型,可空引用类型(C# 8+)则相反。启用后,它们引入了引用类型的非空性(某种程度),旨在帮助避免NullReferenceException。
可空引用类型引入了一种安全级别,完全由编译器强制执行,当检测到有可能引发NullReferenceException的代码时会发出警告。
要启用可空引用类型,您必须在您的 .csproj 项目文件中添加Nullable元素(如果要为整个项目启用):
<PropertyGroup>
<Nullable>enable</Nullable>
</PropertyGroup>
或/和在代码中使用以下指令,以在应该生效的地方生效:
#nullable enable // enables nullable reference types from this point on
#nullable disable // disables nullable reference types from this point on
#nullable restore // resets nullable reference types to project setting
启用后,编译器将非空性作为默认值:如果希望引用类型接受空值而不生成编译器警告,则必须应用?后缀以指示可空引用类型。在以下示例中,s1是非可空的,而s2是可空的:
#nullable enable // Enable nullable reference types
string s1 = null; // Generates a compiler warning!
string? s2 = null; // OK: s2 is *nullable reference type*
注意
因为可空引用类型是编译时构造,所以string和string?之间没有运行时差异。相比之下,可空值类型在类型系统中引入了具体的内容,即Nullable<T>结构体。
由于x未初始化,以下示例也会生成警告:
class Foo { string x; }
如果通过字段初始化程序或构造函数中的代码初始化x,则警告消失。
空值允许操作符
如果编译器认为可能会发生NullReferenceException,它还会在解引用可空引用类型时向您发出警告。在以下示例中,访问字符串的Length属性会生成警告:
void Foo (string? s) => Console.Write (s.Length);
您可以使用空值允许操作符(!)消除警告:
void Foo (string? s) => Console.Write (s!.Length);
在本示例中使用空值允许操作符是危险的,因为我们最终可能会抛出我们试图避免的NullReferenceException。我们可以按如下方式修复它:
void Foo (string? s)
{
if (s != null) Console.Write (s.Length);
}
现在请注意,我们不再需要空值允许操作符。这是因为编译器执行静态流分析,在至少简单情况下能够推断出解引用是安全的,不会发生NullReferenceException。
编译器检测和警告的能力并非万无一失,并且在覆盖范围方面也存在限制。例如,它无法知道数组的元素是否已被填充,因此以下示例不会生成警告:
var strings = new string[10];
Console.WriteLine (strings[0].Length);
分离注解和警告上下文
通过#nullable enable指令(或<Nullable>enable</Nullable>项目设置)启用可空引用类型有两个作用:
-
它启用可空注解上下文,告诉编译器将所有引用类型变量声明视为非可空,除非后缀带有
?符号。 -
它启用可空警告上下文,告诉编译器在遇到可能引发
NullReferenceException的代码时生成警告。
有时将这两个概念分开并仅启用注解上下文可能很有用,或者(不太有用地)仅启用警告上下文:
#nullable enable annotations // Enable the annotation context
// OR:
#nullable enable warnings // Enable the warning context
(同样的技巧适用于#nullable disable和#nullable restore。)
你也可以通过项目文件来完成:
<Nullable>annotations</Nullable>
<!-- OR -->
<Nullable>warnings</Nullable>
仅为特定类或程序集启用注解上下文可能是将可空引用类型引入传统代码库的良好首选步骤。通过正确注解公共成员,您确保您的类或程序集可以作为其他类或程序集的“良好公民”来充分受益于可空引用类型,而无需处理自己类或程序集中的警告。
将可空警告视为错误
在新项目中,从一开始完全启用可空上下文是有意义的。您可能还希望进一步将可空警告视为错误,以便在解决所有空警告之前,项目无法编译完成:
<PropertyGroup>
<Nullable>enable</Nullable>
<WarningsAsErrors>CS8600;CS8602;CS8603</WarningsAsErrors>
</PropertyGroup>
扩展方法
扩展方法允许扩展现有类型并添加新方法,而无需更改原始类型的定义。扩展方法是静态类的静态方法,其中第一个参数应用了this修饰符。第一个参数的类型将是被扩展的类型:
public static class StringHelper
{
public static bool IsCapitalized (this string s)
{
if (string.IsNullOrEmpty(s)) return false;
return char.IsUpper (s[0]);
}
}
可以像在字符串上调用实例方法一样调用IsCapitalized扩展方法,如下所示:
Console.WriteLine ("Perth".IsCapitalized());
编译后,扩展方法调用将转换为普通的静态方法调用:
Console.WriteLine (StringHelper.IsCapitalized ("Perth"));
翻译工作如下所示:
arg0.Method (arg1, arg2, ...); // Extension method call
StaticClass.Method (arg0, arg1, arg2, ...); // Static method call
接口也可以扩展:
public static T First<T> (this IEnumerable<T> sequence)
{
foreach (T element in sequence)
return element;
throw new InvalidOperationException ("No elements!");
}
...
Console.WriteLine ("Seattle".First()); // S
扩展方法链
扩展方法与实例方法一样,提供了一种整洁的方法来链式调用函数。考虑以下两个函数:
public static class StringHelper
{
public static string Pluralize (this string s) {...}
public static string Capitalize (this string s) {...}
}
x和y是等价的,都会评估为"Sausages",但x使用扩展方法,而y使用静态方法:
string x = "sausage".Pluralize().Capitalize();
string y = StringHelper.Capitalize (StringHelper.Pluralize ("sausage"));
歧义和解决方法
命名空间
除非其类在范围内,否则无法访问扩展方法。考虑以下示例中的扩展方法IsCapitalized:
using System;
namespace Utils
{
public static class StringHelper
{
public static bool IsCapitalized (this string s)
{
if (string.IsNullOrEmpty(s)) return false;
return char.IsUpper (s[0]);
}
}
}
要使用IsCapitalized,应用程序必须导入Utils,以避免编译时错误。
namespace MyApp
{
using Utils;
class Test
{
static void Main() => Console.WriteLine ("Perth".IsCapitalized());
}
}
扩展方法与实例方法
任何兼容的实例方法始终优先于扩展方法。在以下示例中,即使使用类型为int的参数x调用,Test的Foo方法也始终优先:
class Test
{
public void Foo (object x) { } // This method always wins
}
static class Extensions
{
public static void Foo (this Test t, int x) { }
}
在这种情况下调用扩展方法的唯一方法是通过正常的静态语法,换句话说,Extensions.Foo(...)。
扩展方法与扩展方法
如果两个扩展方法具有相同的签名,则必须将扩展方法作为普通静态方法调用以消除调用的歧义。然而,如果一个扩展方法具有更具体的参数,则更具体的方法优先。
为了说明,考虑以下两个类:
static class StringHelper
{
public static bool IsCapitalized (this string s) {...}
}
static class ObjectHelper
{
public static bool IsCapitalized (this object s) {...}
}
以下代码调用StringHelper的IsCapitalized方法:
bool test1 = "Perth".IsCapitalized();
类和结构被认为比接口更具体。
降级扩展方法
当 Microsoft 向.NET 运行时库添加一个扩展方法与某些现有第三方库中的扩展方法冲突时,可能会出现有趣的情况。作为第三方库的作者,您可能希望“撤回”您的扩展方法,但不删除它并且不破坏现有消费者的二进制兼容性。
幸运的是,通过简单地从扩展方法的定义中移除this关键字,这是很容易实现的。这将把您的扩展方法降级为普通的静态方法。这种解决方案的美妙之处在于,任何针对旧库编译的程序集都将继续工作(并像以前一样绑定到您的方法)。原因是编译时将扩展方法调用转换为静态方法调用。
仅当消费者重新编译时,他们的降级才会受到影响,此时对您以前的扩展方法的调用将绑定到 Microsoft 的版本(如果已导入命名空间)。如果消费者仍然希望调用您的方法,他们可以将其作为静态方法调用。
匿名类型
匿名类型是编译器即时创建的简单类,用于存储一组值。要创建匿名类型,请使用new关键字,后面跟一个对象初始化器,指定类型将包含的属性和值;例如:
var dude = new { Name = "Bob", Age = 23 };
编译器将其翻译为(大致)以下内容:
internal class AnonymousGeneratedTypeName
{
private string name; // Actual field name is irrelevant
private int age; // Actual field name is irrelevant
public AnonymousGeneratedTypeName (string name, int age)
{
this.name = name; this.age = age;
}
public string Name => name;
public int Age => age;
// The Equals and GetHashCode methods are overridden (see Chapter 6).
// The ToString method is also overridden.
}
...
var dude = new AnonymousGeneratedTypeName ("Bob", 23);
您必须使用var关键字引用匿名类型,因为它没有名称。
匿名类型的属性名称可以从一个自身是标识符的表达式中推断出(或以标识符结尾);因此
int Age = 23;
var dude = new { Name = "Bob", Age, Age.ToString().Length };
等效于以下内容:
var dude = new { Name = "Bob", Age = Age, Length = Age.ToString().Length };
两个在同一程序集中声明的匿名类型实例,如果它们的元素命名和类型相同,则具有相同的基础类型:
var a1 = new { X = 2, Y = 4 };
var a2 = new { X = 2, Y = 4 };
Console.WriteLine (a1.GetType() == a2.GetType()); // True
此外,Equals方法被重写以执行结构相等比较(数据的比较):
Console.WriteLine (a1.Equals (a2)); // True
而等号操作符(==)执行引用比较:
Console.WriteLine (a1 == a2); // False
您可以按以下方式创建匿名类型的数组:
var dudes = new[]
{
new { Name = "Bob", Age = 30 },
new { Name = "Tom", Age = 40 }
};
一个方法不能(有用地)返回一个匿名类型的对象,因为编写其返回类型为var的方法是非法的:
var Foo() => new { Name = "Bob", Age = 30 }; // Not legal!
(在接下来的几节中,我们将描述记录和元组,它们提供了从方法返回多个值的替代方法。)
匿名类型是不可变的,因此实例在创建后无法修改。但是,从 C# 10 开始,你可以使用with关键字创建具有变化的副本(非破坏性变异):
var a1 = new { A = 1, B = 2, C = 3, D = 4, E = 5 };
var a2 = a1 with { E = 10 };
Console.WriteLine (a2); // { A = 1, B = 2, C = 3, D = 4, E = 10 }
在编写 LINQ 查询时,匿名类型特别有用(参见 第八章)。
元组
像匿名类型一样,元组提供了一种简单的方式来存储一组值。元组在 C#中引入,主要目的是允许方法返回多个值,而无需使用out参数(这是匿名类型无法做到的)。然而,自那时以来,记录已被引入,提供了一种简洁的类型化方法,我们将在下一节中描述。
注意
元组几乎可以做到匿名类型的所有功能,并且具有作为值类型的潜力优势,但正如你很快将看到的,它们遭受来自命名元素的运行时类型擦除的影响。
创建元组字面量的最简单方法是在括号中列出所需的值。这样会创建一个带有未命名元素的元组,你可以通过Item1、Item2等引用它们:
var bob = ("Bob", 23); // Allow compiler to infer the element types
Console.WriteLine (bob.Item1); // Bob
Console.WriteLine (bob.Item2); // 23
元组是值类型,其元素是可变的(可读/可写):
var joe = bob; // joe is a *copy* of bob
joe.Item1 = "Joe"; // Change joe’s Item1 from Bob to Joe
Console.WriteLine (bob); // (Bob, 23)
Console.WriteLine (joe); // (Joe, 23)
与匿名类型不同,你可以明确地指定一个元组类型。只需在括号中列出每个元素的类型:
(string,int) bob = ("Bob", 23);
这意味着你可以有用地从方法返回一个元组:
(string,int) person = GetPerson(); // Could use 'var' instead if we want
Console.WriteLine (person.Item1); // Bob
Console.WriteLine (person.Item2); // 23
(string,int) GetPerson() => ("Bob", 23);
元组与泛型很好地配合,因此以下类型都是合法的:
Task<(string,int)>
Dictionary<(string,int),Uri>
IEnumerable<(int id, string name)> // See below for naming elements
命名元组元素
创建元组字面量时,你可以选择为元素指定有意义的名称:
var tuple = (name:"Bob", age:23);
Console.WriteLine (tuple.name); // Bob
Console.WriteLine (tuple.age); // 23
当指定元组类型时,你可以做同样的事情:
var person = GetPerson();
Console.WriteLine (person.name); // Bob
Console.WriteLine (person.age); // 23
(string name, int age) GetPerson() => ("Bob", 23);
注意
在 “记录” 中,我们将展示如何定义简单的类或结构体,使得定义正式返回类型变得轻而易举:
var person = GetPerson();
Console.WriteLine (person.Name); // Bob
Console.WriteLine (person.Age); // 23
Person GetPerson() => new ("Bob", 23);
record Person (string Name, int Age);
与元组不同,记录的属性(Name和Age)是强类型的,因此可以轻松重构。这种方法还减少了代码重复,并以几种方式鼓励良好的设计。首先,决定一个简单而不牵强的类型名称的过程有助于验证你的设计(无法做到这一点可能表明缺乏单一的一致性目的)。其次,你可能最终会为记录添加方法或其他代码(命名良好的类型往往吸引代码),并将代码移到数据中是良好面向对象设计的核心原则。
请注意,你仍然可以将元素视为无名称,并将它们称为Item1、Item2等(尽管 Visual Studio 会在 IntelliSense 中隐藏这些字段)。
元素名称会自动从属性或字段名称推断:
var now = DateTime.Now;
var tuple = (now.Day, now.Month, now.Year);
Console.WriteLine (tuple.Day); // OK
如果元组的元素类型匹配(顺序相符),它们之间是类型兼容的。它们的元素名称不需要相同:
(string name, int age, char sex) bob1 = ("Bob", 23, 'M');
(string age, int sex, char name) bob2 = bob1; // No error!
我们的特定示例导致了混乱的结果:
Console.WriteLine (bob2.name); // M
Console.WriteLine (bob2.age); // Bob
Console.WriteLine (bob2.sex); // 23
类型擦除
我们之前提到 C#编译器通过为每个元素构建具有命名属性的自定义类来处理匿名类型。使用元组时,C#的工作方式不同,并使用预先存在的通用结构体系列:
public struct ValueTuple<T1>
public struct ValueTuple<T1,T2>
public struct ValueTuple<T1,T2,T3>
...
每个ValueTuple<>结构体都有名为Item1、Item2等的字段。
因此,(string,int)是ValueTuple<string,int>的别名,这意味着命名元组元素在基础类型中没有对应的属性名称。相反,名称仅存在于源代码中和编译器的想象中。在运行时,名称大部分消失,因此,如果反编译引用命名元组元素的程序,您将只看到对Item1、Item2等的引用。此外,在将元组变量分配给object(或在 LINQPad 中Dump)并在调试器中检查时,元素名称不存在。而且,在大多数情况下,您不能使用反射(见第十八章)在运行时确定元组的元素名称。这意味着在诸如System.Net.Http.HttpClient等 API 中,元组不能替换匿名类型,例如以下情景:
// Create JSON payload:
var json = JsonContent.Create (new { id = 123, name = "Test" })
注意
我们说过,名称大部分会消失,因为有例外情况。对于返回命名元组类型的方法/属性,编译器通过将名为TupleElementNamesAttribute的自定义属性(参见“属性”)应用于成员的返回类型来发出元素名称。这允许在调用不同程序集中的方法时命名元素可以正常工作(编译器没有源代码)。
给元组取别名(C# 12)
从 C# 12 开始,您可以利用using指令为元组定义别名:
using Point = (int, int);
Point p = (3, 4);
此功能还适用于具有命名元素的元组:
using Point = (int X, int Y); // Legal (but not necessarily *good*!)
Point p = (3, 4);
同样,我们很快将看到如何使用记录提供完全类型化的解决方案,具有相同的简洁性:
Point p = new (3, 4);
record Point (int X, int Y);
ValueTuple.Create
您还可以通过ValueTuple类型(非泛型)上的工厂方法创建元组:
ValueTuple<string,int> bob1 = ValueTuple.Create ("Bob", 23);
(string,int) bob2 = ValueTuple.Create ("Bob", 23);
(string name, int age) bob3 = ValueTuple.Create ("Bob", 23);
解构元组
元组隐式支持解构模式(见“解构方法”),因此您可以轻松解构元组为单独的变量。考虑以下示例:
var bob = ("Bob", 23);
string name = bob.Item1;
int age = bob.Item2;
使用元组的解构方法,您可以简化代码如下:
var bob = ("Bob", 23);
(string name, int age) = bob; // Deconstruct the bob tuple into
// separate variables (name and age).
Console.WriteLine (name);
Console.WriteLine (age);
解构语法与声明具有命名元素的元组的语法令人困惑地相似。以下突出了两者的区别:
(string name, int age) = bob; // Deconstructing a tuple
(string name, int age) bob2 = bob; // Declaring a new tuple
这里是另一个例子,这次是在调用方法时,并使用类型推断(var):
var (name, age, sex) = GetBob();
Console.WriteLine (name); // Bob
Console.WriteLine (age); // 23
Console.WriteLine (sex); // M
string, int, char) GetBob() => ( "Bob", 23, 'M');
您还可以直接解构到字段和属性中,这为在构造函数中填充多个字段或属性提供了一个便捷的快捷方式:
class Point
{
public readonly int X, Y;
public Point (int x, int y) => (X, Y) = (x, y);
}
相等比较
与匿名类型类似,Equals方法执行结构相等比较。这意味着它比较底层的数据而不是引用:
var t1 = ("one", 1);
var t2 = ("one", 1);
Console.WriteLine (t1.Equals (t2)); // True
此外,ValueTuple<>重载了==和!=运算符:
Console.WriteLine (t1 == t2); // True (from C# 7.3)
元组还重写了GetHashCode方法,使得在字典中可以实际使用元组作为键。我们在“相等比较”中详细介绍了相等比较,并在第七章中介绍了字典。
ValueTuple<>类型还实现了IComparable(参见“顺序比较”),使得可以将元组用作排序键。
System.Tuple 类
您将在System命名空间中找到另一系列泛型类型,称为Tuple(而不是ValueTuple)。这些类型在 2010 年引入,并被定义为类(而ValueTuple类型是结构)。事后认为将元组定义为类是一个错误:在元组常用的情况下,结构具有轻微的性能优势(它们避免了不必要的内存分配),几乎没有缺点。因此,当 Microsoft 在 C# 7 中为元组添加语言支持时,它忽略了现有的Tuple类型,转而支持新的ValueTuple。您可能仍会在 C# 7 之前编写的代码中遇到Tuple类。它们没有特殊的语言支持,使用方式如下:
Tuple<string,int> t = Tuple.Create ("Bob", 23); // Factory method
Console.WriteLine (t.Item1); // Bob
Console.WriteLine (t.Item2); // 23
记录
记录是一种特殊的类或结构,设计用于与不可变(只读)数据良好配合。它最有用的特性是非破坏性变异;然而,记录还可用于创建仅组合或保存数据的类型。在简单情况下,它们消除了样板代码,同时尊重最适合不可变类型的相等语义。
记录纯粹是 C#的编译时构造。在运行时,CLR 只将它们视为类或结构(编译器添加了一堆额外的“合成”成员)。
背景
编写不可变类型(其字段在初始化后不能被修改)是简化软件并减少错误的流行策略。它也是函数式编程的核心方面,其中避免可变状态,并将函数视为数据。LINQ 受此原则启发。
为了“修改”一个不可变对象,你必须创建一个新对象,并复制数据,同时合并你的修改(这被称为非破坏性变异)。在性能方面,这并不像你想象的那么低效,因为浅复制总是足够了(深复制,其中还复制子对象和集合,在数据不可变时是不必要的)。但从编码工作量来看,实现非破坏性变异可能非常低效,特别是当存在许多属性时。记录通过语言支持的模式解决了这个问题。
第二个问题是程序员,特别是函数式程序员,有时仅仅使用不可变类型来组合数据(而不添加行为)。定义这样的类型比应该更多工作,需要一个构造函数将每个参数分配给每个公共属性(析构函数也可能有用)。有了记录,编译器可以为您完成这项工作。
最后,对象不可变的一个后果是其标识不能更改,这意味着对于这样的类型来说,实现结构相等性比引用相等性更有用。结构相等性意味着如果它们的数据相同,则两个实例相同(与元组一样)。记录默认为您提供结构相等性 —— 无论基础类型是类还是结构体 —— 而不需要任何样板代码。
定义记录
记录定义类似于类或结构体定义,可以包含相同类型的成员,包括字段、属性、方法等。记录可以实现接口,(基于类的)记录可以继承其他(基于类的)记录。
默认情况下,记录的基础类型是类:
record Point { } // Point is a class
从 C# 10 开始,记录的基础类型也可以是结构体:
record struct Point { } // Point is a struct
(record class也是合法的,并且与record具有相同的含义。)
一个简单的记录可能只包含一堆仅初始化的属性,以及可能的构造函数:
record Point
{
public Point (double x, double y) => (X, Y) = (x, y);
public double X { get; init; }
public double Y { get; init; }
}
注意
我们的构造函数使用了我们在前一节中描述的快捷方式。
(X, Y) = (x, y);
在这种情况下等效于以下内容:
{ this.X = x; this.Y = y; }
在编译时,C#将记录定义转换为类(或结构体),并执行以下附加步骤:
-
它编写一个受保护的拷贝构造函数(以及一个隐藏的克隆方法),以便进行非破坏性变异。
-
它重写/重载了与相等性相关的函数,以实现结构相等性。
-
它重写了
ToString()方法(扩展记录的公共属性,就像匿名类型一样)。
前面的记录声明会扩展为类似于这样的内容:
class Point
{
public Point (double x, double y) => (X, Y) = (x, y);
public double X { get; init; }
public double Y { get; init; }
protected Point (Point original) // “Copy constructor”
{
this.X = original.X; this.Y = original.Y
}
// This method has a strange compiler-generated name:
public virtual Point <Clone>$() => new Point (this); // Clone method
// Additional code to override Equals, ==, !=, GetHashCode, ToString()
// ...
}
注意
虽然您可以将可选参数放入构造函数中,但在公共库中,一个好的模式是将它们留在构造函数之外,并且仅公开它们作为仅初始化的属性:
new Foo (123, 234) { Optional2 = 345 };
record Foo
{
public Foo (int required1, int required2) { ... }
public int Required1 { get; init; }
public int Required2 { get; init; }
public int Optional1 { get; init; }
public int Optional2 { get; init; }
}
这种模式的优势在于,您可以安全地稍后添加仅初始化的属性,而不会破坏与已编译为旧版本程序集的消费者的二进制兼容性。
参数列表
记录定义可以通过参数列表进行简化:
record Point (double X, double Y)
{
// You can optionally define additional class members here...
}
参数可以包括in和params修饰符,但不能包括out或ref。如果指定了参数列表,则编译器执行以下额外步骤:
-
它为每个参数编写了一个仅初始化的属性。
-
它编写一个主构造函数来填充属性。
-
它编写了一个析构函数。
这意味着,如果我们简单地将我们的Point记录声明为:
record Point (double X, double Y);
编译器最终生成的(几乎)与我们在前面展开中列出的内容完全相同。一个小的差异是主构造函数中的参数名称最终会变为X和Y,而不是x和y:
public Point (double X, double Y) // “Primary constructor”
{
this.X = X; this.Y = Y;
}
注意
此外,由于是主构造函数,参数X和Y会自动在记录中的任何字段或属性初始化器中变为可用。我们稍后在“主构造函数”中讨论这一点的微妙之处。
另一个区别是,当您定义参数列表时,编译器还会生成一个解构器:
public void Deconstruct (out double X, out double Y) // Deconstructor
{
X = this.X; Y = this.Y;
}
可以使用以下语法对带参数列表的记录进行子类化:
record Point3D (double X, double Y, double Z) : Point (X, Y);
然后,编译器发出主构造函数如下所示:
class Point3D : Point
{
public double Z { get; init; }
public Point3D (double X, double Y, double Z) : base (X, Y)
=> this.Z = Z;
}
注意
当您需要一个简单地将一堆值(函数式编程中的产品类型)组合在一起的类时,参数列表提供了一个不错的快捷方式,并且在原型设计时也可能非常有用。后面我们将看到,当您需要向init访问器添加逻辑(例如参数验证)时,它们并不是那么有用。
非破坏性变异
编译器对所有记录执行的最重要步骤是编写复制构造函数(和一个隐藏的Clone方法)。这使得通过with关键字进行非破坏性变异成为可能:
Point p1 = new Point (3, 3);
Point p2 = p1 with { Y = 4 };
Console.WriteLine (p2); // Point { X = 3, Y = 4 }
record Point (double X, double Y);
在此示例中,p2是p1的副本,但其Y属性设置为 4。当有更多属性时,这种好处更加明显:
Test t1 = new Test (1, 2, 3, 4, 5, 6, 7, 8);
Test t2 = t1 with { A = 10, C = 30 };
Console.WriteLine (t2);
record Test (int A, int B, int C, int D, int E, int F, int G, int H);
下面是输出:
Test { A = 10, B = 2, C = 30, D = 4, E = 5, F = 6, G = 7, H = 8 }
非破坏性变异分为两个阶段进行:
-
首先,复制构造函数克隆记录。默认情况下,它会复制记录的每个基础字段,创建一个忠实的复制品,同时绕过(初始化访问器的)任何逻辑开销。所有字段都包括在内(公共和私有的,以及支持自动属性的隐藏字段)。
-
然后,更新成员初始化列表中的每个属性(这次使用
init访问器)。
编译器转换
Test t2 = t1 with { A = 10, C = 30 };
转换为以下内容的功能等效物:
Test t2 = new Test(t1); // Use copy constructor to clone t1 field by field
t2.A = 10; // Update property A
t2.C = 30; // Update property C
(如果您显式编写,相同的代码将无法编译,因为A和C是仅初始化的属性。此外,复制构造函数是受保护的;C#通过调用一个写入到记录中的公共隐藏方法来解决此问题,该方法名为<Clone>$。)
如果需要,您可以定义自己的复制构造函数。C#将使用您的定义而不是自己写一个:
protected Point (Point original)
{
this.X = original.X; this.Y = original.Y;
}
如果您的记录包含可变子对象或您希望克隆的集合,或者包含希望清除的计算字段,则编写自定义复制构造函数可能很有用。不幸的是,您只能替换默认实现,而不能增强它。
注意
当子类化另一个记录时,复制构造函数负责仅复制自己的字段。要复制基记录的字段,请委托给基类:
protected Point (Point original) : base (original)
{
...
}
属性验证
使用显式属性,您可以将验证逻辑写入init访问器。在以下示例中,我们确保X永远不会是NaN(非数字):
record Point
{
// Notice that we assign x to the X property (and not the _x field):
public Point (double x, double y) => (X, Y) = (x, y);
double _x;
public double X
{
get => _x;
init
{
if (double.IsNaN (value))
throw new ArgumentException ("X Cannot be NaN");
_x = value;
}
}
public double Y { **get; init;** }
}
**我们的设计确保验证发生在构造期间和对象在非破坏性变异时:
Point p1 = new Point (2, 3);
Point p2 = p1 with { X = double.NaN }; // throws an exception
回想一下,自动生成的复制构造函数会复制所有字段和自动属性。这意味着生成的复制构造函数现在看起来像这样:
protected Point (Point original)
{
_x = original._x; Y = original.Y;
}
请注意,复制_x字段绕过了X属性访问器。然而,这不会造成任何问题,因为它忠实地复制了一个已经通过X的init访问器安全填充的对象。
一个流行的函数式编程模式,在不可变类型中表现良好,是惰性求值,其中值在需要时才计算,并且缓存以便重用。例如,假设我们想在我们的Point记录中定义一个属性,返回到原点(0, 0)的距离:
record Point (double X, double Y)
{
public double DistanceFromOrigin => Math.Sqrt (X*X + Y*Y);
}
现在让我们尝试重构这段代码,以避免每次访问属性时重新计算DistanceFromOrigin的成本。我们将从移除属性列表开始,并将X、Y和DistanceFromOrigin定义为只读属性。然后我们可以在构造函数中计算后者:
record Point
{
public double X { get; }
public double Y { get; }
public double DistanceFromOrigin { get; }
public Point (double x, double y) =>
(X, Y, DistanceFromOrigin) = (x, y, Math.Sqrt (x*x + y*y));
}
这样可以工作,但不允许非破坏性变异(将X和Y更改为仅初始化属性将破坏代码,因为在init访问器执行后,DistanceFromOrigin将变为陈旧)。此外,计算总是执行的,无论是否读取了DistanceFromOrigin属性,这也是次优解。最佳解决方案是在字段中缓存其值,并延迟(首次使用时)填充它:
record Point
{
...
double? _distance;
public double DistanceFromOrigin
{
get
{
if (_distance == null)
_distance = Math.Sqrt (X*X + Y*Y);
return _distance.Value;
}
}
}
注意
在这段代码中,技术上来说,我们改变了 _distance。尽管如此,称Point为不可变类型仍然合理。纯粹为了填充延迟值而变异字段,并不会使不可变性的原则或好处失效,并且可以通过我们在第二十一章中描述的Lazy<T>类型来隐藏。
使用 C#的空值合并赋值运算符(??=),我们可以将整个属性声明减少为一行代码:
public double DistanceFromOrigin => _distance ??= Math.Sqrt (X*X + Y*Y);
(这句话的意思是,如果_distance非空,则返回它;否则,返回Math.Sqrt(X*X + Y*Y)并将其赋给_distance。)
要使其与仅初始化属性一起工作,我们需要进一步的一步,即在通过init访问器更新X或Y时清除缓存的_distance字段。以下是完整的代码:
record Point
{
public Point (double x, double y) => (X, Y) = (x, y);
double _x, _y;
public double X { get => _x; init { _x = value; _distance = null; } }
public double Y { get => _y; init { _y = value; _distance = null; } }
double? _distance;
public double DistanceFromOrigin => _distance ??= Math.Sqrt (X*X + Y*Y);
}
Point现在可以进行非破坏性变异:
Point p1 = new Point (2, 3);
Console.WriteLine (p1.DistanceFromOrigin); // 3.605551275463989
Point p2 = p1 with { Y = 4 };
Console.WriteLine (p2.DistanceFromOrigin); // 4.47213595499958
一个很好的额外好处是,自动生成的复制构造函数会复制缓存的_distance字段。这意味着,如果一个记录有其他不参与计算的属性,对这些属性进行非破坏性变异不会触发不必要的缓存值丢失。如果您不喜欢这个额外好处,另一种方法是在init访问器中清除缓存值,编写一个自定义复制构造函数,它会忽略缓存字段。这样更简洁,因为它适用于参数列表,而自定义复制构造函数可以利用析构函数:
record Point (double X, double Y)
{
double? _distance;
public double DistanceFromOrigin => _distance ??= Math.Sqrt (X*X + Y*Y);
protected Point (Point other) => (X, Y) = other;
}
请注意,无论采用哪种解决方案,添加延迟计算字段都会破坏默认的结构相等比较(因为这些字段可能已经填充,也可能没有填充),尽管我们很快就会看到修复它相对容易。
主构造函数
当您定义一个带有参数列表的记录时,编译器会自动生成属性声明,以及一个主构造函数(和解构函数)。正如我们所见,这在简单情况下运行良好,在更复杂的情况下,您可以省略参数列表并手动编写属性声明和构造函数。
C# 还提供了一个稍微有用的中间选项——如果你愿意处理主构造函数的奇特语义——那就是在自己编写一部分或全部属性声明的同时定义参数列表:
record Student (string ID, string LastName, string GivenName)
{
public string ID { get; } = ID;
}
在这种情况下,我们“接管”了ID属性的定义,将其定义为只读(而不是init-only),防止其参与非破坏性变异。如果您从不需要非破坏性地改变特定属性,将其设为只读可以让您在记录中存储计算数据,而无需编写刷新机制。
请注意,我们需要包含属性初始化器(用粗体标出):
public string ID { get; } = ID;
当您“接管”一个属性声明时,您需要负责初始化其值;主构造函数不再自动执行此操作。(这与在类或结构体上定义主构造函数时的行为完全一致。)同时注意,粗体中的ID指的是主构造函数参数,而不是ID属性。
注意
对于记录结构体,重新定义属性作为字段是合法的:
record struct Student (string ID)
{
public string ID = ID;
}
与类和结构体上主构造函数的语义保持一致(参见“主构造函数”),主构造函数参数(在这种情况下是ID、LastName和GivenName)神奇地对所有字段和属性初始化器可见。我们可以通过以下扩展示例来说明这一点:
record Student (string ID, string LastName, string FirstName)
{
public string ID { get; } = ID;
readonly int _enrollmentYear = int.Parse (ID.Substring (0, 4));
}
再次强调,粗体中的ID指的是主构造函数的参数,而不是属性。(不存在歧义的原因是从初始化器中访问属性是非法的。)
在这个例子中,我们根据ID的前四位数字计算了_enrollmentYear。虽然将其存储在只读字段中是安全的(因为ID属性是只读的,因此不能非破坏性地变异),但是在实际情况下,这段代码可能效果不佳。原因在于没有显式的构造函数,没有一个集中的地方来验证ID并在其无效时抛出一个有意义的异常(这是一个常见的需求)。
验证也是需要编写显式的init-only访问器的一个好理由(正如我们在“属性验证”中讨论的那样)。不幸的是,主构造函数在这种情况下表现不佳。为了说明这一点,考虑下面的记录,其中init访问器执行了一个空值验证检查:
record Person (string Name)
{
string _name = Name;
public string Name
{
get => _name;
init => _name = value ?? throw new ArgumentNullException ("Name");
}
}
因为Name不是自动属性,所以不能定义初始化器。我们能做的最好的就是将初始化器放在背后的字段上(用粗体标出)。不幸的是,这样做会绕过空值检查:
var p = new Person (null); // Succeeds! (bypasses the null check)
难点在于没有办法在不编写构造函数的情况下将主构造函数参数分配给属性。虽然有解决方法(例如将 init 验证逻辑分解为我们调用两次的单独静态方法),但最简单的解决方法是完全避免参数列表,并手动编写一个普通的构造函数(以及析构函数,如果需要):
record Person
{
public Person (string name) => Name = name; // Assign to *PROPERTY*
string _name;
public string Name { get => _name; init => ... }
}
记录和相等比较
正如结构体、匿名类型和元组一样,记录提供了开箱即用的结构相等性,这意味着如果它们的字段(和自动属性)相等,则两个记录是相等的:
var p1 = new Point (1, 2);
var p2 = new Point (1, 2);
Console.WriteLine (p1.Equals (p2)); // True
record Point (double X, double Y);
等式运算符 也适用于记录(就像适用于元组一样):
Console.WriteLine (p1 == p2); // True
记录的默认相等实现是无法避免的脆弱的。特别是,如果记录包含惰性值、临时值、数组或集合类型(需要特殊处理以进行相等比较),则会出现问题。幸运的是,如果需要使相等性工作,修复它相对来说比为类或结构体添加完整的相等行为要简单得多。
与类和结构体不同,你不需要(也不能)重写 object.Equals 方法;相反,你需要定义一个公共的 Equals 方法,具有以下签名:
record Point (double X, double Y)
{
double _someOtherField;
public virtual bool Equals (Point other) =>
other != null && X == other.X && Y == other.Y;
}
Equals 方法必须是 virtual(而不是 override),并且它必须是强类型的,以便接受实际的记录类型(在本例中为 Point,而不是 object)。一旦你正确定义了签名,编译器将自动补充你的方法。
在我们的示例中,我们改变了相等逻辑,只比较 X 和 Y(并忽略 _someOtherField)。
如果你要对另一个记录进行子类化,你可以调用 base.Equals 方法:
public virtual bool Equals (Point other) => base.Equals (other) && ...
与任何类型一样,如果你接管了相等比较,你也应该重写 GetHashCode()。记录的一个好处是你不需要重载 != 或 ==;也不需要实现 IEquatable<T>:这一切都为你完成了。我们在“相等比较”章节中详细讨论了相等比较的主题。** **# 模式
在第三章中,我们演示了如何使用 is 运算符来测试引用转换是否成功:
if (obj is string)
Console.WriteLine (((string)obj).Length);
或者更简洁地说:
if (obj is string s)
Console.WriteLine (s.Length);
这个快捷方式使用了一种称为类型模式的模式之一。is 运算符还支持其他在最近版本的 C# 中引入的模式,例如属性模式:
if (obj is string { Length:4 })
Console.WriteLine ("A string with 4 characters");
模式在以下上下文中得到支持:
-
在
is运算符之后(*variable* is *pattern*) -
在 switch 语句中
-
在 switch 表达式中
我们已经在“类型切换”和“is 运算符”中简要介绍了类型模式(以及元组模式)。在本节中,我们将介绍最近版本的 C# 中引入的更高级的模式。
一些更专业的模式主要用于 switch 语句/表达式中。这些模式减少了when子句的需要,并允许您在以前无法使用 switch 的情况下使用它们。
注意
本节中的模式在某些情况下略微或中度有用。请记住,您总是可以用简单的if语句或在某些情况下,三元条件运算符,替换高度模式化的 switch 表达式,通常不需要额外的代码。
常量模式
常量模式允许您直接匹配常量,并在使用object类型时非常有用:
void Foo (object obj)
{
if (obj is 3) ...
}
粗体表达式等同于以下内容:
obj is int && (int)obj == 3
(作为静态操作符,C#不允许您直接使用==将object与常量进行比较,因为编译器需要预先知道类型。)
单独使用时,此模式只在有合理替代方案时才有少量用处:
if (3.Equals (obj)) ...
正如我们将很快看到的,与模式组合器一起,常量模式变得更加有用。
关系模式
从 C# 9 开始,您可以在模式中使用<、>、<=和>=操作符:
if (x is > 100) Console.WriteLine ("x is greater than 100");
在switch语句中,这变得非常有用:
string GetWeightCategory (decimal bmi) => bmi switch
{
< 18.5m => "underweight",
< 25m => "normal",
< 30m => "overweight",
_ => "obese"
};
关系模式与模式组合器结合使用时更加实用。
注意
当变量具有编译时object类型时,关系模式也起作用,但您必须非常小心使用数值常量。在下面的示例中,最后一行输出 False,因为我们试图将一个十进制值与整数字面值进行匹配:
object obj = 2m; // obj is decimal
Console.WriteLine (obj is < 3m); // True
Console.WriteLine (obj is < 3); // False
模式组合器
从 C# 9 开始,您可以使用and、or和not关键字组合模式:
bool IsJanetOrJohn (string name) => name.ToUpper() is "JANET" or "JOHN";
bool IsVowel (char c) => c is 'a' or 'e' or 'i' or 'o' or 'u';
bool Between1And9 (int n) => n is >= 1 and <= 9;
bool IsLetter (char c) => c is >= 'a' and <= 'z'
or >= 'A' and <= 'Z';
与&&和||运算符一样,and比or具有更高的优先级。您可以使用括号覆盖此优先级。
一个很好的技巧是将not组合器与类型模式结合使用,以测试对象是否为(不是)某种类型:
if (obj is not string) ...
这比以下形式更好看:
if (!(obj is string)) ...
变量模式
var 模式是类型模式的一种变体,其中您用var关键字替换类型名称。转换总是成功的,因此它的目的仅仅是让您重用随后的变量:
bool IsJanetOrJohn (string name) =>
name.ToUpper() is var upper && (upper == "JANET" || upper == "JOHN");
这相当于:
bool IsJanetOrJohn (string name)
{
string upper = name.ToUpper();
return upper == "JANET" || upper == "JOHN";
}
在表达式主体方法中引入和重用中间变量(在此示例中为upper)在 lambda 表达式中非常方便——尤其是当问题方法具有bool返回类型时。不幸的是,它在该模式有一个合理的替代方案时才是有用的。
元组和位置模式
元组模式(C# 8 引入)匹配元组:
var p = (2, 3);
Console.WriteLine (p is (2, 3)); // True
您可以用此功能切换多个值:
int AverageCelsiusTemperature (Season season, bool daytime) =>
(season, daytime) switch
{
(Season.Spring, true) => 20,
(Season.Spring, false) => 16,
(Season.Summer, true) => 27,
(Season.Summer, false) => 22,
(Season.Fall, true) => 18,
(Season.Fall, false) => 12,
(Season.Winter, true) => 10,
(Season.Winter, false) => -2,
_ => throw new Exception ("Unexpected combination")
};
enum Season { Spring, Summer, Fall, Winter };
元组模式可以被视为位置模式(C# 8+)的特例,它匹配任何具有Deconstruct方法的类型(参见“Deconstructors”)。在下面的示例中,我们利用Point记录的编译生成解构器:
var p = new Point (2, 2);
Console.WriteLine (p is (2, 2)); // True
record Point (int X, int Y); // Has compiler-generated deconstructor
您可以在匹配的同时解构,使用以下语法:
Console.WriteLine (p is (var x, var y) && x == y); // True
下面是一个结合类型模式和位置模式的 switch 表达式:
string Print (object obj) => obj switch
{
Point (0, 0) => "Empty point",
Point (var x, var y) when x == y => "Diagonal"
...
};
属性模式
属性模式(C# 8+)匹配对象的一个或多个属性值。我们之前在is运算符的上下文中给出了一个简单的例子:
if (obj is string { Length:4 }) ...
然而,这并没有比以下内容节省多少:
if (obj is string s && s.Length == 4) ...
使用switch语句和表达式时,属性模式更加有用。考虑System.Uri类,它表示一个 URI。它的属性包括Scheme、Host、Port和IsLoopback。在编写防火墙时,我们可以决定是否允许或阻止 URI 的使用,通过使用使用属性模式的switch表达式:
bool ShouldAllow (Uri uri) => uri switch
{
{ Scheme: "http", Port: 80 } => true,
{ Scheme: "https", Port: 443 } => true,
{ Scheme: "ftp", Port: 21 } => true,
{ IsLoopback: true } => true,
_ => false
};
你可以嵌套属性,使以下子句合法:
{ Scheme: { Length: 4 }, Port: 80 } => true,
从 C# 10 开始,可以简化为:
{ Scheme.Length: 4, Port: 80 } => true,
你可以在属性模式中使用其他模式,包括关系模式:
{ Host: { Length: < 1000 }, Port: > 0 } => true,
更复杂的条件可以用when子句表示:
{ Scheme: "http" } when string.IsNullOrWhiteSpace (uri.Query) => true,
你也可以将属性模式与类型模式结合使用:
bool ShouldAllow (object uri) => uri switch
{
Uri { Scheme: "http", Port: 80 } => true,
Uri { Scheme: "https", Port: 443 } => true,
...
正如你可能期望的那样,你可以在子句的末尾引入一个变量,然后使用该变量:
Uri { Scheme: "http", Port: 80 } httpUri => httpUri.Host.Length < 1000,
你还可以在when子句中使用该变量:
Uri { Scheme: "http", Port: 80 } httpUri
when httpUri.Host.Length < 1000 => true,
在属性模式中有点奇怪的是,你也可以在属性级别引入变量:
{ Scheme: "http", Port: 80, Host: string host } => host.Length < 1000,
隐式类型允许使用,因此你可以用var替换string。这里是一个完整的示例:
bool ShouldAllow (Uri uri) => uri switch
{
{ Scheme: "http", Port: 80, Host: var host } => host.Length < 1000,
{ Scheme: "https", Port: 443 } => true,
{ Scheme: "ftp", Port: 21 } => true,
{ IsLoopback: true } => true,
_ => false
};
很难找到一个例子,可以节省多于几个字符。在我们的情况下,另一种选择实际上更短:
{ Scheme: "http", Port: 80 } => uri.Host.Length < 1000 => ...
或:
{ Scheme: "http", Port: 80, Host: { Length: < 1000 } } => ...
列表模式
列表模式(从 C# 11 开始)适用于任何可计数的集合类型(具有Count或Length属性)和可索引的类型(具有int或System.Index类型的索引器)。
列表模式匹配方括号中的一系列元素:
int[] numbers = { 0, 1, 2, 3, 4 };
Console.Write (numbers is [0, 1, 2, 3, 4]); // True
下划线匹配任何值的单个元素:
Console.Write (numbers is [0, 1, _, _, 4]); // True
var模式也适用于匹配单个元素:
Console.Write (numbers is [0, 1, var x, 3, 4] && x > 1); // True
两个冒号表示一个切片。切片匹配零个或多个元素:
Console.Write (numbers is [0, .., 4]); // True
对于支持索引和范围的数组和其他类型(请参阅“索引和范围”),你可以在var模式后跟随一个切片:
Console.Write (numbers is [0, .. var mid, 4] && mid.Contains (2)); // True
列表模式最多可以包含一个切片。
属性
你已经熟悉了将程序代码元素归属为带有修饰符(如virtual或ref)的概念。这些结构内置于语言中。属性是一种可扩展的机制,用于向代码元素(程序集、类型、成员、返回值、参数和泛型类型参数)添加自定义信息。这种可扩展性对于深度集成到类型系统中的服务非常有用,而不需要特殊的关键字或 C#语言中的构造。
属性类
属性由继承(直接或间接)自抽象类System.Attribute的类定义。要将属性附加到代码元素,请在代码元素之前的方括号中指定属性的类型名称。例如,以下代码将ObsoleteAttribute附加到Foo类:
[ObsoleteAttribute]
public class Foo {...}
这个特定的属性被编译器识别,如果引用了被标记为过时的类型或成员,将会引发编译器警告。按照惯例,所有属性类型的名称都以“Attribute”结尾。C#识别这一点,并允许你在附加属性时省略后缀:
[Obsolete]
public class Foo {...}
ObsoleteAttribute是一个在System命名空间中声明的类型(简化以减少篇幅):
public sealed class ObsoleteAttribute : Attribute {...}
.NET 库包含许多预定义的属性。我们在第十八章中描述如何编写自己的属性。
命名和位置属性参数
属性可以有参数。在以下示例中,我们将XmlTypeAttribute应用于一个类。这个属性指示 XML 序列化器(位于System.Xml.Serialization中)如何表示 XML 中的对象,并接受几个属性参数。以下属性将CustomerEntity类映射到名为Customer的 XML 元素,该元素属于http://oreilly.com命名空间:
[XmlType ("Customer", Namespace="http://oreilly.com")]
public class CustomerEntity { ... }
(我们在在线补充中介绍 XML 和 JSON 序列化,网址为http://www.albahari.com/nutshell。)
属性参数分为两类:位置参数和命名参数。在前面的示例中,第一个参数是位置参数,第二个是命名参数。位置参数对应于属性类型的公共构造函数的参数。命名参数对应于属性类型上的公共字段或公共属性。
在指定属性时,必须包括与属性构造函数对应的位置参数。命名参数是可选的。
在第十八章中,我们描述了有效的参数类型及其评估规则。
将属性应用于程序集和后备字段
隐式地,属性的目标是它紧接着的代码元素,通常是一个类型或类型成员。但是,你也可以将属性附加到一个程序集上。这需要你明确指定属性的目标。以下是如何使用AssemblyFileVersion属性为程序集附加版本号的方法:
[assembly: AssemblyFileVersion ("1.2.3.4")]
使用field:前缀,你可以将属性应用于自动属性的后备字段。在某些特殊情况下,例如应用(现已过时的)NonSerialized属性时,这是非常有用的:
[field:NonSerialized]
public int MyProperty { get; set; }
将属性应用于 lambda 表达式
从 C# 10 开始,你可以将属性应用于 lambda 表达式的方法、参数和返回值:
Action<int> a = [Description ("Method")]
[return: Description ("Return value")]
([Description ("Parameter")]int x) => Console.Write (x);
注意
当与依赖于你在编写的方法上放置属性的框架(如 ASP.NET)一起工作时,这非常有用。有了这个特性,你可以避免为简单操作创建命名方法。
这些属性应用于委托指向的编译器生成的方法。在第十八章中,我们将描述如何在代码中反射属性。现在,这是解析该间接引用所需的额外代码:
var methodAtt = a.GetMethodInfo().GetCustomAttributes();
var paramAtt = a.GetMethodInfo().GetParameters()[0].GetCustomAttributes();
var returnAtt = a.GetMethodInfo().ReturnParameter.GetCustomAttributes();
为了避免在 lambda 表达式的参数上应用属性时的语法歧义,总是需要括号。不允许在表达树 lambda 上使用属性。
指定多个属性
你可以为单个代码元素指定多个属性。你可以在同一对方括号内(用逗号分隔)列出每个属性,或者在不同的方括号对内列出(或者两者的组合)。以下三个示例在语义上是相同的:
[Serializable, Obsolete, CLSCompliant(false)]
public class Bar {...}
[Serializable] [Obsolete] [CLSCompliant(false)]
public class Bar {...}
[Serializable, Obsolete]
[CLSCompliant(false)]
public class Bar {...}
调用者信息属性
你可以使用三种调用者信息属性中的一种标记可选参数,指示编译器将来自调用者源代码的信息输入到参数的默认值中:
-
[CallerMemberName]应用调用者的成员名称。 -
[CallerFilePath]应用调用者源代码文件的路径。 -
[CallerLineNumber]应用调用者源代码文件中的行号。
下面程序中的Foo方法演示了所有三种属性:
using System;
using System.Runtime.CompilerServices;
class Program
{
static void Main() => Foo();
static void Foo (
[CallerMemberName] string memberName = null,
[CallerFilePath] string filePath = null,
[CallerLineNumber] int lineNumber = 0)
{
Console.WriteLine (memberName);
Console.WriteLine (filePath);
Console.WriteLine (lineNumber);
}
}
假设我们的程序位于c:\source\test\Program.cs,输出将会是:
Main
c:\source\test\Program.cs
6
与标准可选参数一样,替换是在调用点完成的。因此,我们的Main方法对应的语法糖如下:
static void Main() => Foo ("Main", @"c:\source\test\Program.cs", 6);
调用者信息属性对于日志记录非常有用,以及用于实现模式,例如在对象的任何属性更改时触发单个更改通知事件。实际上,在System.ComponentModel命名空间中有一个名为INotifyPropertyChanged的标准接口:
public interface INotifyPropertyChanged
{
event PropertyChangedEventHandler PropertyChanged;
}
public delegate void PropertyChangedEventHandler
(object sender, PropertyChangedEventArgs e);
public class PropertyChangedEventArgs : EventArgs
{
public PropertyChangedEventArgs (string propertyName);
public virtual string PropertyName { get; }
}
注意,PropertyChangedEventArgs需要更改的属性名称。然而,通过应用[CallerMemberName]属性,我们可以实现此接口并在不指定属性名称的情况下调用事件:
public class Foo : INotifyPropertyChanged
{
public event PropertyChangedEventHandler PropertyChanged = delegate { };
void RaisePropertyChanged ([CallerMemberName] string propertyName = null)
=> PropertyChanged (this, new PropertyChangedEventArgs (propertyName));
string customerName;
public string CustomerName
{
get => customerName;
set
{
if (value == customerName) return;
customerName = value;
RaisePropertyChanged();
// The compiler converts the above line to:
// RaisePropertyChanged ("CustomerName");
}
}
}
CallerArgumentExpression
从 C# 10 开始,你可以将[CallerArgumentExpression]属性应用于方法参数,以从调用点捕获参数表达式:
Print (Math.PI * 2);
void Print (double number,
[CallerArgumentExpression("number")] string expr = null)
=> Console.WriteLine (expr);
// Output: Math.PI * 2
编译器会直接将调用表达式的源代码输入,包括注释:
Print (Math.PI /*(π)*/ * 2);
// Output: Math.PI /*(π)*/ * 2
此功能的主要应用程序是在编写验证和断言库时。在以下示例中,抛出了一个异常,其消息包含文本“2 + 2 == 5”。这有助于调试:
Assert (2 + 2 == 5);
void Assert (bool condition,
[CallerArgumentExpression ("condition")] string message = null)
{
if (!condition) throw new Exception ("Assertion failed: " + message);
}
另一个例子是.NET 6中ArgumentNullException类上的静态ThrowIfNull方法。该方法定义如下:
public static void ThrowIfNull (object argument,
[CallerArgumentExpression("argument")] string paramName = null)
{
if (argument == null)
throw new ArgumentNullException (paramName);
}
它的使用方式如下:
void Print (string message)
{
ArgumentNullException.ThrowIfNull (message);
...
}
可以多次使用[CallerArgumentExpression],以捕获多个参数表达式。
动态绑定
动态绑定将绑定——即解析类型、成员和运算符的过程——从编译时延迟到运行时。当在编译时您知道某个函数、成员或操作存在,但编译器不知道时,动态绑定就很有用。这通常发生在与动态语言(如 IronPython)和 COM 的交互以及其他可能需要使用反射的情况下。
动态类型是使用上下文关键字dynamic声明的:
dynamic d = GetSomeObject();
d.Quack();
动态类型告诉编译器放松。我们期望d的运行时类型有一个名为Quack的方法。我们只是不能在静态上下文中证明它。因为d是动态的,编译器将绑定Quack到d推迟到运行时。要理解这意味着什么,需要区分静态绑定和动态绑定。
静态绑定与动态绑定
典型的绑定示例是在编译表达式时将名称映射到特定函数。要编译以下表达式,编译器需要找到名为Quack的方法的实现:
d.Quack();
假设d的静态类型是Duck:
Duck d = ...
d.Quack();
在最简单的情况下,编译器通过查找Duck上名为Quack的无参方法来进行绑定。如果找不到,则编译器会扩展搜索至接受可选参数的方法、Duck的基类方法,以及将Duck作为第一个参数的扩展方法。如果没有找到匹配项,将会出现编译错误。无论绑定了哪种方法,最终的结果是由编译器完成的,且绑定完全依赖于静态地知道操作数的类型(在本例中为d)。这称为静态绑定。
现在让我们将d的静态类型改为object:
object d = ...
d.Quack();
调用Quack会导致编译错误,因为虽然存储在d中的值可能包含名为Quack的方法,但编译器无法知道,因为它唯一知道的信息是变量的类型,而在这种情况下是object。但现在让我们将d的静态类型改为dynamic:
dynamic d = ...
d.Quack();
dynamic类型类似于object——它对类型的描述同样不具体。不同之处在于,它允许您以编译时未知的方式使用它。动态对象根据其运行时类型而不是编译时类型在运行时绑定。当编译器看到一个动态绑定的表达式(通常是包含任何dynamic类型值的表达式),它只是简单地打包表达式,以便稍后在运行时进行绑定。
在运行时,如果动态对象实现了IDynamicMetaObjectProvider接口,则会使用该接口执行绑定。如果没有,则绑定方式几乎与编译器了解动态对象的运行时类型时的方式相同。这两种替代方式称为自定义绑定和语言绑定。
自定义绑定
自定义绑定发生在动态对象实现IDynamicMetaObjectProvider(IDMOP)时。虽然你可以在你用 C#编写的类型上实现 IDMOP,这样做很有用,但更常见的情况是你从.NET 上的动态语言(如 IronPython 或 IronRuby)中获得了一个 IDMOP 对象。这些语言的对象会隐式实现 IDMOP,以便直接控制对它们执行的操作的含义。
我们会在第十九章中更详细地讨论自定义绑定器,但现在让我们编写一个简单的示例来演示这个功能:
using System;
using System.Dynamic;
dynamic d = new Duck();
d.Quack(); // Quack method was called
d.Waddle(); // Waddle method was called
public class Duck : DynamicObject
{
public override bool TryInvokeMember (
InvokeMemberBinder binder, object[] args, out object result)
{
Console.WriteLine (binder.Name + " method was called");
result = null;
return true;
}
}
Duck类实际上没有Quack方法。相反,它使用自定义绑定来拦截和解释所有方法调用。
语言绑定
语言绑定发生在动态对象不实现IDynamicMetaObjectProvider时。当你在处理.NET 类型系统中的不完善设计类型或固有限制时,语言绑定就非常有用(我们在第十九章中探讨更多场景)。在使用数值类型时,一个典型的问题是它们没有共同的接口。我们已经看到我们可以动态绑定方法;同样适用于操作符:
int x = 3, y = 4;
Console.WriteLine (Mean (x, y));
dynamic Mean (dynamic x, dynamic y) => (x + y) / 2;
利益显而易见——你不需要为每种数值类型重复编写代码。然而,你失去了静态类型安全性,可能面临运行时异常而不是编译时错误。
注意
动态绑定绕过了静态类型安全性,但没有绕过运行时类型安全性。与反射不同(第十八章),你不能通过动态绑定绕过成员的可访问性规则。
设计上,语言运行时绑定尽可能地与静态绑定行为相似,如果动态对象的运行时类型在编译时已知的话。在我们之前的例子中,如果我们将Mean硬编码为与int类型一起工作,程序的行为将是相同的。静态和动态绑定之间最显著的不同之处在于扩展方法,我们在“不可调用的函数”中讨论。
注意
动态绑定还会带来性能损耗。由于 DLR 的缓存机制,对同一个动态表达式的重复调用被优化——允许你在循环中高效地调用动态表达式。这种优化使得在当今硬件上对简单动态表达式的典型开销降低到不到 100 纳秒。
RuntimeBinderException
如果成员无法绑定,将抛出RuntimeBinderException。你可以把它看作是运行时的编译时错误:
dynamic d = 5;
d.Hello(); // throws RuntimeBinderException
抛出异常是因为int类型没有Hello方法。
动态的运行时表示
dynamic类型与object类型之间存在深层次的等价性。运行时会将以下表达式视为true:
typeof (dynamic) == typeof (object)
这个原则也适用于构造类型和数组类型:
typeof (List<dynamic>) == typeof (List<object>)
typeof (dynamic[]) == typeof (object[])
与对象引用类似,动态引用可以指向任何类型的对象(除了指针类型):
dynamic x = "hello";
Console.WriteLine (x.GetType().Name); // String
x = 123; // No error (despite same variable)
Console.WriteLine (x.GetType().Name); // Int32
结构上,对象引用和动态引用之间没有区别。动态引用只是允许在其指向的对象上执行动态操作。您可以从 object 转换为 dynamic,以便在 object 上执行任何您想要的动态操作:
object o = new System.Text.StringBuilder();
dynamic d = o;
d.Append ("hello");
Console.WriteLine (o); // hello
注意
反映公开的具有 dynamic 成员的类型会显示这些成员表示为带注释的 object。例如,
public class Test
{
public dynamic Foo;
}
等同于:
public class Test
{
[System.Runtime.CompilerServices.DynamicAttribute]
public object Foo;
}
这允许该类型的消费者知道 Foo 应该被视为动态,同时允许不支持动态绑定的语言退回到 object。
动态转换
dynamic 类型对所有其他类型具有隐式转换:
int i = 7;
dynamic d = i;
long j = d; // No cast required (implicit conversion)
为了转换成功,动态对象的运行时类型必须隐式可转换为目标静态类型。前面的例子之所以有效,是因为 int 隐式可转换为 long。
以下示例引发 RuntimeBinderException,因为 int 不是 short 的隐式转换类型:
int i = 7;
dynamic d = i;
short j = d; // throws RuntimeBinderException
var 与 dynamic 的比较
var 和 dynamic 类型在表面上看起来相似,但其差异深远:
-
var表示:“让 编译器 确定类型。” -
dynamic表示:“让 运行时 确定类型。”
为了说明:
dynamic x = "hello"; // Static type is dynamic, runtime type is string
var y = "hello"; // Static type is string, runtime type is string
int i = x; // Runtime error (cannot convert string to int)
int j = y; // Compile-time error (cannot convert string to int)
使用 var 声明的变量的静态类型可以是 dynamic:
dynamic x = "hello";
var y = x; // Static type of y is dynamic
int z = y; // Runtime error (cannot convert string to int)
动态表达式
字段、属性、方法、事件、构造函数、索引器、运算符和转换都可以动态调用。
尝试使用具有 void 返回类型的动态表达式的结果是被禁止的,就像使用静态类型表达式一样。不同之处在于错误发生在运行时:
dynamic list = new List<int>();
var result = list.Add (5); // RuntimeBinderException thrown
涉及动态操作数的表达式通常本身是动态的,因为缺少类型信息的影响是级联的:
dynamic x = 2;
var y = x * 3; // Static type of y is dynamic
有几个明显的例外情况。首先,将动态表达式转换为静态类型会产生静态表达式:
dynamic x = 2;
var y = (int)x; // Static type of y is int
其次,构造函数调用始终产生静态表达式,即使使用动态参数调用。在此示例中,x 的静态类型为 StringBuilder:
dynamic capacity = 10;
var x = new System.Text.StringBuilder (capacity);
此外,还有一些边缘情况,其中包含动态参数的表达式是静态的,包括向数组传递索引和委托创建表达式。
没有动态接收者的动态调用
dynamic 的典型用例涉及动态 接收者。这意味着动态对象是动态函数调用的接收者:
dynamic x = ...;
x.Foo(); // x is the receiver
但是,您也可以使用动态参数调用静态已知函数。此类调用会受动态重载解析的影响,并可以包括以下内容:
-
静态方法
-
实例构造函数
-
具有静态已知类型接收者的实例方法
在下面的示例中,根据动态参数的运行时类型,特定的 Foo 将被动态绑定:
class Program
{
static void Foo (int x) => Console.WriteLine ("int");
static void Foo (string x) => Console.WriteLine ("string");
static void Main()
{
dynamic x = 5;
dynamic y = "watermelon";
Foo (x); // int
Foo (y); // string
}
}
因为没有涉及动态接收器,编译器可以静态执行基本检查,以查看动态调用是否会成功。它会检查是否存在名称和参数数量正确的函数。如果没有找到候选项,您将会得到一个编译时错误:
class Program
{
static void Foo (int x) => Console.WriteLine ("int");
static void Foo (string x) => Console.WriteLine ("string");
static void Main()
{
dynamic x = 5;
Foo (x, x); // Compiler error - wrong number of parameters
Fook (x); // Compiler error - no such method name
}
}
动态表达式中的静态类型
很明显,动态类型在动态绑定中被使用。而静态类型也会在可能的情况下被使用,这一点并不那么明显。考虑以下情况:
class Program
{
static void Foo (object x, object y) { Console.WriteLine ("oo"); }
static void Foo (object x, string y) { Console.WriteLine ("os"); }
static void Foo (string x, object y) { Console.WriteLine ("so"); }
static void Foo (string x, string y) { Console.WriteLine ("ss"); }
static void Main()
{
object o = "hello";
dynamic d = "goodbye";
Foo (o, d); // os
}
}
调用Foo(o,d)是动态绑定的,因为它的参数d是dynamic。但是因为o是静态已知的,尽管它是动态发生的绑定,但会利用到它。在这种情况下,重载解析将因o的静态类型和d的运行时类型选择Foo的第二个实现。换句话说,编译器“尽可能静态”。
无法调用的函数
有些函数无法动态调用。您无法调用以下内容:
-
扩展方法(通过扩展方法语法)
-
如果需要将其转换为该接口才能调用接口的成员
-
子类隐藏的基类成员
了解这一点对理解动态绑定非常有用。
动态绑定需要两个信息:要调用的函数的名称和要调用函数的对象。然而,在这三种无法调用的情况中,涉及一个额外的类型,它只在编译时知道。截至本文撰写时,没有办法动态指定这些额外类型。
在调用扩展方法时,隐含了那个额外的类型。它是定义扩展方法的静态类。编译器会根据源代码中的using指令来搜索它。这使得扩展方法只在编译时存在,因为using指令在编译后就会消失(在绑定过程中将简单名称映射到命名空间限定名称后)。
当通过接口调用成员时,您需要通过隐式或显式转换指定额外的类型。有两种情况您可能需要这样做:当显式调用接口成员和当调用另一个程序集中类型内实现的接口成员时。我们可以用以下两种类型来说明前者:
interface IFoo { void Test(); }
class Foo : IFoo { void IFoo.Test() {} }
要调用Test方法,我们必须将其转换为IFoo接口。这在静态类型中很容易实现:
IFoo f = new Foo(); // Implicit cast to interface
f.Test();
现在考虑动态类型的情况:
IFoo f = new Foo();
dynamic d = f;
d.Test(); // Exception thrown
粗体显示的隐式转换告诉编译器将接下来对f的成员调用绑定到IFoo而不是Foo,换句话说,通过IFoo接口的视角来看待这个对象。然而,这个视角在运行时丢失了,因此 DLR 无法完成绑定。这种丢失如下所示:
Console.WriteLine (f.GetType().Name); // Foo
当调用隐藏的基类成员时也会出现类似情况:您必须通过转换或base关键字指定一个额外的类型,并且这个额外的类型在运行时丢失了。
注意
如果需要动态调用接口成员,一种解决方法是使用 Uncapsulator 开源库,该库可在 NuGet 和 GitHub 上获得。Uncapsulator 是作者为解决此问题而编写的,并利用 自定义绑定 比 dynamic 提供更好的动态性:
IFoo f = new Foo();
dynamic uf = f.Uncapsulate();
uf.Test();
Uncapsulator 还允许你通过名称将类型转换为基本类型和接口,动态调用静态成员,并访问类型的非公共成员。
操作符重载
可以通过重载操作符为自定义类型提供更自然的语法。操作符重载最适合用于实现代表相当原始数据类型的自定义结构体。例如,自定义数值类型非常适合进行操作符重载。
可以重载以下符号操作符:
+(一元) |
-(一元) |
! |
˜ |
++ |
|---|---|---|---|---|
-- |
+ |
- |
* |
/ |
% |
& |
| |
^ |
<< |
>> |
== |
!= |
> |
< |
>= |
<= |
以下运算符也可以进行重载:
-
使用
implicit和explicit关键字进行隐式和显式转换 -
true和false操作符(而非 字面值)
以下操作符间接地进行了重载:
-
复合赋值操作符(例如
+=、/=)由重载非复合操作符(例如+、/)隐式重载。 -
条件操作符
&&和||通过重载位操作符&和|隐式重载。
操作符函数
通过声明 操作符函数 来重载操作符。操作符函数具有以下规则:
-
函数的名称由
operator关键字指定,后跟操作符符号。 -
操作符函数必须标记为
static和public。 -
操作符函数的参数表示操作数。
-
操作符函数的返回类型表示表达式的结果。
-
操作数中至少有一个操作数必须是声明操作符函数的类型。
在下面的例子中,我们定义了一个名为 Note 的结构体来表示音符,然后重载了 + 操作符:
public struct Note
{
int value;
public Note (int semitonesFromA) { value = semitonesFromA; }
public static Note operator + (Note x, int semitones)
{
return new Note (x.value + semitones);
}
}
这种重载允许我们将一个 int 添加到一个 Note 中:
Note B = new Note (2);
Note CSharp = B + 2;
当你重载一个操作符时,相应的复合赋值操作符也会自动进行重载。例如,在我们的例子中,因为我们重载了+,所以我们也可以使用+=:
CSharp += 2;
就像方法和属性一样,C# 允许将操作符函数写得更简洁,采用表达式体语法来编写单一表达式的操作符函数:
public static Note operator + (Note x, int semitones)
=> new Note (x.value + semitones);
Checked 操作符
从 C# 11 开始,当你声明一个操作符函数时,还可以声明一个 checked 版本:
public static Note operator + (Note x, int semitones)
=> new Note (x.value + semitones);
public static Note operator checked + (Note x, int semitones)
=> checked (new Note (x.value + semitones));
在 checked 表达式或块中将调用 checked 版本:
Note B = new Note (2);
Note other = checked (B + int.MaxValue); // throws OverflowException
重载相等和比较操作符
在编写结构时,有时会重载相等和比较运算符,在编写类时也有这种情况。重载相等和比较运算符会带来特殊的规则和义务,我们在第六章中有详细解释。这些规则的摘要如下:
配对
C#编译器要求定义逻辑对的操作符。这些操作符是(== !=),(< >),以及(<= >=)。
Equals和GetHashCode
在大多数情况下,如果重载了(==)和(!=),必须重写object中定义的Equals和GetHashCode方法才能得到有意义的行为。如果不这样做,C#编译器会发出警告。(详情见“相等比较”。)
IComparable和IComparable<T>
如果重载了(< >)和(<= >=),应该实现IComparable和IComparable<T>。
自定义隐式和显式转换
隐式和显式转换是可以重载的操作符。这些转换通常被重载,以使得在强相关类型(如数值类型)之间进行转换变得简洁和自然。
要在弱相关类型之间进行转换,以下策略更适合:
-
编写一个构造函数,其参数是要从中转换的类型。
-
编写
To*XXX*和(静态的)From*XXX*方法来在类型之间进行转换。
如类型讨论中所述,隐式转换的基本理由是它们保证成功且在转换过程中不会丢失信息。相反,显式转换应该在运行时情况决定是否转换成功或者在转换过程中可能会丢失信息时才需要。
在这个例子中,我们定义了我们的音乐Note类型与一个表示该音符频率的 double 之间的转换:
...
// Convert to hertz
public static implicit operator double (Note x)
=> 440 * Math.Pow (2, (double) x.value / 12 );
// Convert from hertz (accurate to the nearest semitone)
public static explicit operator Note (double x)
=> new Note ((int) (0.5 + 12 * (Math.Log (x/440) / Math.Log(2) ) ));
...
Note n = (Note)554.37; // explicit conversion
double x = n; // implicit conversion
注意
按照我们自己的指导方针,这个示例可能最好用一个ToFrequency方法(和一个静态的FromFrequency方法)来实现,而不是用隐式和显式操作符。
警告
自定义转换在as和is运算符中被忽略:
Console.WriteLine (554.37 is Note); // False
Note n = 554.37 as Note; // *Error*
重载 true 和 false
在极少数情况下,如果类型在精神上是布尔型但没有转换为bool,则会重载true和false操作符。一个例子是实现了三态逻辑的类型:通过重载true和false,这样的类型可以与条件语句和操作符(即if、do、while、for、&&、||和?:)无缝配合工作。System.Data.SqlTypes.SqlBoolean结构提供了这种功能:
SqlBoolean a = SqlBoolean.Null;
if (a)
Console.WriteLine ("True");
else if (!a)
Console.WriteLine ("False");
else
Console.WriteLine ("Null");
OUTPUT:
Null
下面的代码重新实现了展示true和false操作符所需的SqlBoolean部分:
public struct SqlBoolean
{
public static bool operator true (SqlBoolean x)
=> x.m_value == True.m_value;
public static bool operator false (SqlBoolean x)
=> x.m_value == False.m_value;
public static SqlBoolean operator ! (SqlBoolean x)
{
if (x.m_value == Null.m_value) return Null;
if (x.m_value == False.m_value) return True;
return False;
}
public static readonly SqlBoolean Null = new SqlBoolean(0);
public static readonly SqlBoolean False = new SqlBoolean(1);
public static readonly SqlBoolean True = new SqlBoolean(2);
private SqlBoolean (byte value) { m_value = value; }
private byte m_value;
}
静态多态
在“调用静态虚/抽象接口成员”中,我们介绍了一个高级功能,即接口可以定义static virtual或static abstract成员,然后类和结构体通过静态成员来实现。稍后,在“泛型约束”中,我们展示了将接口约束应用于类型参数,使方法能够访问该接口的成员。在本节中,我们将演示如何通过这种方式实现静态多态性,从而支持诸如通用数学之类的功能。
为了说明这一点,考虑以下接口,它定义了创建某种类型T的随机实例的静态方法:
interface ICreateRandom<T>
{
static abstract T CreateRandom(); // Create a random instance of T
}
假设我们希望在以下记录中实现此接口:
record Point (int X, int Y);
借助System.Random类(其Next方法生成随机整数),我们可以如下实现静态CreateRandom方法:
record Point (int X, int Y) : ICreateRandom<Point>
{
static Random rnd = new();
public static Point CreateRandom() => new Point (rnd.Next(), rnd.Next());
}
要通过接口调用此方法,我们使用约束类型参数。以下方法使用这种方法创建测试数据的数组:
T[] CreateTestData<T> (int count) where T : ICreateRandom<T>
{
T[] result = new T[count];
for (int i = 0; i < count; i++)
result [i] = T.CreateRandom();
return result;
}
此行代码展示了它的使用:
Point[] testData = CreateTestData<Point>(50); // Create 50 random Points.
我们在CreateTestData中对静态CreateRandom方法的调用是多态的,因为它不仅适用于Point,还适用于任何实现ICreateRandom<T>的类型。这与实例多态不同,因为我们不需要一个ICreateRandom<T>的实例来调用CreateRandom;我们直接在类型本身上调用CreateRandom。
多态运算符
因为操作符本质上是静态函数(参见“运算符重载”),操作符也可以声明为静态虚接口成员:
interface IAddable<T> where T : IAddable<T>
{
abstract static T operator + (T left, T right);
}
注意
在此接口定义中,自引用类型约束对满足编译器对操作符重载的规则是必要的。回想一下,当定义一个操作符函数时,至少一个操作数必须是声明操作符函数的类型。在本例中,我们的操作数是类型T,而包含的类型是IAddable<T>,因此我们需要一个自引用类型约束,以允许将T视为IAddable<T>。
这是我们如何实现接口的方法:
record Point (int X, int Y) : IAddable<Point>
{
public static Point operator + (Point left, Point right) =>
new Point (left.X + right.X, left.Y + right.Y);
}
通过约束类型参数,我们可以编写一个调用我们的加法操作符的方法(为简洁起见,省略了边缘情况处理):
T Sum<T> (params T[] values) where T : IAddable<T>
{
T total = values[0];
for (int i = 1; i < values.Length; i++)
total += values[i];
return total;
}
我们通过+操作符(通过+=操作符)的调用是多态的,因为它绑定到IAddable<T>,而不是Point。因此,我们的Sum方法适用于所有实现IAddable<T>的类型。
当然,如果像.NET 7中那样在.NET运行时中定义IAddable<T>这样的接口,并且所有.NET数值类型都实现它,那么这样的接口将会非常有用。幸运的是,这确实是情况:System.Numerics命名空间包括(更复杂的版本)IAddable,以及许多其他算术接口,其中大多数都包含在INumber<TSelf>中。
通用数学
在 .NET 7 之前,执行算术运算的代码必须针对特定的数值类型进行硬编码:
int Sum (params int[] numbers) // Works only with int.
{ // Cannot use with double, decimal, etc.
int total = 0;
foreach (int n in numbers)
total += n;
return total;
}
.NET 7 引入了INumber<TSelf>接口,统一了不同数值类型的算术操作。这意味着现在可以编写前述方法的通用版本:
T Sum<T> (params T[] numbers) where T : INumber<T>
{
T total = T.Zero;
foreach (T n in numbers)
total += n; // Invokes addition operator for any numeric type
return total;
}
int intSum = Sum (3, 5, 7);
double doubleSum = Sum (3.2, 5.3, 7.1);
decimal decimalSum = Sum (3.2m, 5.3m, 7.1m);
INumber<TSelf> 在 .NET 中的所有实数和整数数值类型(以及 char)中都有实现,并且可以被视为一个大伞接口,包括每种算术操作(加法、减法、乘法、除法、取模运算、比较等)的更细粒度接口,以及用于解析和格式化的接口。以下是一个这样的接口示例:
public interface IAdditionOperators<TSelf, TOther, TResult>
where TSelf : IAdditionOperators<TSelf, TOther, TResult>?
{
static abstract TResult operator + (TSelf left, TOther right);
public static virtual TResult operator checked +
(TSelf left, TOther right) => left + right; // Call operator above
}
static abstract + 操作符使得 += 操作符能够在我们的 Sum 方法内工作。同时注意在 checked 操作符上使用 static virtual:这为不提供 checked 版本的加法操作符的实现者提供了默认的回退行为。
System.Numerics 命名空间还包含一些不属于 INumber 的接口,用于特定类型的数值操作(如浮点数)。例如,要计算均方根,可以将 IRootFunctions<T> 接口添加到约束列表中,以公开其静态的 RootN 方法给 T 使用:
T RMS<T> (params T[] values) where T : INumber<T>, IRootFunctions<T>
{
T total = T.Zero;
for (int i = 0; i < values.Length; i++)
total += values [i] * values [i];
// Use T.CreateChecked to convert values.Length (type int) to T.
T count = T.CreateChecked (values.Length);
return T.RootN (total / count, 2); // Calculate square root
}
不安全代码与指针
C# 支持在标记为unsafe的代码块内通过指针直接进行内存操作。指针类型对于与本地 API 互操作、访问托管堆外内存以及在性能关键点实施微优化非常有用。
包含不安全代码的项目必须在项目文件中指定 <AllowUnsafeBlocks>true</AllowUnsafeBlocks>。
指针基础知识
对于每种值类型或引用类型 V,都存在相应的指针类型 *V**。指针实例保存变量的地址。指针类型可以(不安全地)转换为任何其他指针类型。以下是主要的指针操作符:
| 操作符 | 意义 |
|---|---|
& |
取地址 操作符返回一个指向变量地址的指针。 |
* |
解引用 操作符返回指针地址处的变量。 |
-> |
成员指针 操作符是一个语法快捷方式,其中 x->y 等效于 (*x).y。 |
与 C 语言一致,将整数偏移量添加(或减去)到指针会生成另一个指针。从一个指针减去另一个指针会生成一个 64 位整数(无论是 64 位还是 32 位平台)。
不安全代码
通过使用 unsafe 关键字标记类型、类型成员或语句块,允许在该作用域内使用指针类型并执行 C 风格的内存操作。以下是使用指针快速处理位图的示例:
unsafe void BlueFilter (int[,] bitmap)
{
int length = bitmap.Length;
fixed (int* b = bitmap)
{
int* p = b;
for (int i = 0; i < length; i++)
*p++ &= 0xFF;
}
}
不安全代码可能比对应的安全实现运行更快。在这种情况下,该代码需要一个带有数组索引和边界检查的嵌套循环。不安全的 C# 方法也可以比调用外部的 C 函数更快,因为离开托管执行环境时没有相关的开销。
固定语句
fixed 语句用于固定托管对象,例如前面示例中的位图。在程序执行期间,堆上分配和释放许多对象。为了避免内存的不必要浪费或碎片化,垃圾回收器会移动对象。如果指向对象的地址在引用时可能发生更改,则指向对象是没有意义的,因此 fixed 语句告诉垃圾回收器“固定”对象并且不移动它。这可能会影响运行时的效率,因此应该只在需要的时候使用固定块,并且应该避免在固定块内分配堆。
在 fixed 语句中,你可以获取任何值类型、值类型数组或字符串的指针。对于数组和字符串,指针实际上将指向第一个元素,该元素是一个值类型。
在引用类型内声明的值类型需要进行固定,如下所示:
Test test = new Test();
unsafe
{
fixed (int* p = &test.X) // Pins test
{
*p = 9;
}
Console.WriteLine (test.X);
}
class Test { public int X; }
我们在 “将结构映射到非托管内存” 进一步描述了 fixed 语句。
成员指针操作符
除了 & 和 * 操作符外,C# 还提供了类似 C++ 的 -> 操作符,你可以在结构体上使用它:
Test test = new Test();
unsafe
{
Test* p = &test;
p->X = 9;
System.Console.WriteLine (test.X);
}
struct Test { public int X; }
stackalloc 关键字
你可以通过使用 stackalloc 关键字显式在堆栈上分配内存块。因为它在堆栈上分配,其生命周期仅限于方法的执行,就像任何其他局部变量一样(如果它没有被 lambda 表达式、迭代器块或异步函数捕获,则其生命期未延长)。该块可以使用 [] 操作符来索引内存:
int* a = stackalloc int [10];
for (int i = 0; i < 10; ++i)
Console.WriteLine (a[i]);
在 第二十三章 中,我们描述了如何使用 Span<T> 来管理堆栈分配的内存,而不使用 unsafe 关键字:
Span<int> a = stackalloc int [10];
for (int i = 0; i < 10; ++i)
Console.WriteLine (a[i]);
固定大小的缓冲区
fixed 关键字还有另一个用途,即在结构体内创建固定大小的缓冲区(在调用非托管函数时很有用;参见 第二十四章):
new UnsafeClass ("Christian Troy");
unsafe struct UnsafeUnicodeString
{
public short Length;
public fixed byte Buffer[30]; // Allocate block of 30 bytes
}
unsafe class UnsafeClass
{
UnsafeUnicodeString uus;
public UnsafeClass (string s)
{
uus.Length = (short)s.Length;
fixed (byte* p = uus.Buffer)
for (int i = 0; i < s.Length; i++)
p[i] = (byte) s[i];
}
}
固定大小的缓冲区不是数组:如果 Buffer 是数组,则它将由存储在(托管)堆上的对象的引用组成,而不是结构体本身的 30 个字节。
fixed 关键字在本例中还用于固定包含缓冲区的堆上对象(这将是 UnsafeClass 的实例)。因此,fixed 意味着两种不同的含义:大小 固定和 位置 固定。这两者经常一起使用,即固定大小的缓冲区必须固定在位置上才能使用。
void*
无类型指针(void*)不对底层数据类型做任何假设,对处理原始内存的函数非常有用。任何指针类型都可以隐式转换为void*。不能对void*进行解引用,并且不能对其进行算术运算。这里有一个例子:
short[] a = { 1, 1, 2, 3, 5, 8, 13, 21, 34, 55 };
unsafe
{
fixed (short* p = a)
{
//sizeof returns size of value-type in bytes
Zap (p, a.Length * sizeof (short));
}
}
foreach (short x in a)
Console.WriteLine (x); // Prints all zeros
unsafe void Zap (void* memory, int byteCount)
{
byte* b = (byte*)memory;
for (int i = 0; i < byteCount; i++)
*b++ = 0;
}
原生大小的整数
原生大小的整数类型nint和nuint(在 C# 9 中引入)的大小与运行时进程的地址空间匹配(实际上是 32 位或 64 位)。原生大小的整数的行为类似于标准整数,完全支持算术运算和溢出检查:
nint x = 123, y = 234;
checked
{
nint sum = x + y, product = x * y;
Console.WriteLine (product);
}
原生大小的整数可以被分配为 32 位整数常量(但不能是 64 位整数常量,因为这些可能在运行时溢出)。您可以使用显式转换来转换到或从其他整数类型。
您可以使用原生大小的整数来表示内存地址或偏移量,而无需使用指针。nuint也是表示内存块长度的自然类型。
在处理指针时,使用原生大小的整数可以提高效率,因为在 C#中两个指针相减的结果始终是一个 64 位整数(long),这在 32 位平台上效率低下。通过首先将指针转换为nint,减法的结果也将是nint(在 32 位平台上将为 32 位):
unsafe nint AddressDif (char* x, char* y) => (nint)x - (nint)y;
注意
在实现Buffer.MemoryCopy时,结合指针使用nint和nuint的一个现实世界用例是在 GitHub 上的.NET源代码中的Buffer.cs,或通过在 ILSpy 中反编译方法。在C# 12 in a Nutshell的 LINQPad 示例中也包含了一个简化版本。
运行时处理在目标为.NET 7+时
对于目标为.NET 7 或更高版本的项目,nint和nuint作为底层.NET 类型System.IntPtr和System.UIntPtr的同义词(就像int是System.Int32的同义词一样)。这是因为IntPtr和UIntPtr类型(自.NET Framework 1.0 以来就存在,但功能有限)在.NET 7 中得到增强,以使 C#编译器具有完整的算术功能和溢出检查。
注意
向IntPtr/UIntPtr添加了检查算术能力在技术上是一种破坏性变更。但是,其影响有限,因为依赖于IntPtr不遵守checked块的遗留代码仅在仅在.NET 7+下运行时不会中断;要中断,项目必须同时使用.NET 7+目标重新编译。这意味着库作者在发布专门针对.NET 7 或更高版本的新版本之前不必担心这种破坏性变更。
运行时处理在目标为.NET 6 或更低版本时
对于目标 .NET 6 或以下版本(或 .NET Standard)的项目,nint 和 nuint 仍然使用 IntPtr 和 UIntPtr 作为它们的底层运行时类型。但是,由于传统的 IntPtr 和 UIntPtr 类型缺乏大多数算术操作的支持,编译器填补了这些空白,使 nint/nuint 类型的行为与 .NET 7+ 中的行为相同(包括允许 checked 操作)。您可以将 nint/nuint 变量视为戴着特殊帽子的 IntPtr/UIntPtr。编译器识别此帽子以意味着“请将我视为现代的 IntPtr/UIntPtr”。如果稍后将其强制转换为 IntPtr/UIntPtr,则自然会失去这顶帽子:
nint x = 123;
Console.WriteLine (x * x); // OK: multiplication supported
IntPtr y = x;
Console.WriteLine (y * y); // Compiler error: operator * not supported
函数指针
函数指针(来自 C# 9)类似于委托,但不带委托实例的间接性;而是直接指向方法。函数指针只能指向静态方法,缺少多播功能,并且需要在 unsafe 上下文中(因为它绕过运行时类型安全性)。它的主要目的是简化和优化与不受管理的 API 的交互(请参阅“来自不受管理代码的回调”)。
函数指针类型声明如下(返回类型最后出现):
delegate*<int, char, string, void> // (void refers to the return type)
这与具有此签名的函数匹配:
void SomeFunction (int x, char y, string z)
& 运算符从方法组创建函数指针。以下是一个完整的示例:
unsafe
{
delegate*<string, int> functionPointer = &GetLength;
int length = functionPointer ("Hello, world");
static int GetLength (string s) => s.Length;
}
在这个例子中,functionPointer 不是您可以调用 Invoke 方法的 对象(或带有对 Target 对象的引用)。相反,它是一个变量,直接指向内存中目标方法的地址:
Console.WriteLine ((IntPtr)functionPointer);
与任何其他指针一样,它不受运行时类型检查的影响。以下代码将我们函数的返回值视为 decimal(因为比 int 更长,这意味着我们将一些随机内存合并到输出中):
var pointer2 = (delegate*<string, decimal>) (IntPtr) functionPointer;
Console.WriteLine (pointer2 ("Hello, unsafe world"));
[跳过局部初始化]
当 C# 编译方法时,它会发出一个标志,指示运行时初始化方法的局部变量为它们的默认值(通过清零内存)。从 C# 9 开始,您可以通过将[SkipLocalsInit] 属性应用于方法(位于 System.Runtime.CompilerServices 命名空间中)来要求编译器不要发出此标志:
[SkipLocalsInit]
void Foo() ...
你也可以将此属性应用于类型——相当于将其应用于类型的所有方法——甚至是整个模块(程序集的容器):
[module: System.Runtime.CompilerServices.SkipLocalsInit]
在正常的安全场景中,[跳过局部初始化] 对功能或性能几乎没有影响,因为 C# 的明确赋值策略要求在读取之前必须显式赋值局部变量。这意味着 JIT 优化器很可能会发出相同的机器码,无论是否应用了该属性。
然而,在不安全的上下文中,使用 [SkipLocalsInit] 可以有效地减少 CLR 初始化值类型本地变量的开销,在大量使用堆栈的方法(通过大量使用 stackalloc)中创建小的性能收益。当应用 [SkipLocalsInit] 时,以下示例打印未初始化的内存(而不是全部为零):
[SkipLocalsInit]
unsafe void Foo()
{
int local;
int* ptr = &local;
Console.WriteLine (*ptr);
int* a = stackalloc int [100];
for (int i = 0; i < 100; ++i) Console.WriteLine (a [i]);
}
有趣的是,在“安全”上下文中,你可以通过使用 Span<T> 来达到相同的结果:
[SkipLocalsInit]
void Foo()
{
Span<int> a = stackalloc int [100];
for (int i = 0; i < 100; ++i) Console.WriteLine (a [i]);
}
因此,使用 [SkipLocalsInit] 需要将项目编译设置为 <AllowUnsafeBlocks> 为 true——即使您的方法中没有一个标记为 unsafe。
预处理指令
预处理指令为编译器提供有关代码区域的附加信息。最常见的预处理指令是条件指令,它们提供了一种在编译中包含或排除代码区域的方式:
#define DEBUG
class MyClass
{
int x;
void Foo()
{
#if DEBUG
Console.WriteLine ("Testing: x = {0}", x);
#endif
}
...
}
在此类中,Foo 中的语句在依赖于 DEBUG 符号的存在时进行条件编译。如果移除 DEBUG 符号,则不会编译该语句。您可以在源文件内(如我们所做的)或在 .csproj 文件的项目级别定义预处理符号:
<PropertyGroup>
<DefineConstants>DEBUG;ANOTHERSYMBOL</DefineConstants>
</PropertyGroup>
使用 #if 和 #elif 指令,可以使用 ||, &&, 和 ! 运算符对多个符号进行 或、与 和 非 操作。以下指令指示编译器在定义 TESTMODE 符号且未定义 DEBUG 符号时包含随后的代码:
#if TESTMODE && !DEBUG
...
请记住,您并非在构建普通的 C# 表达式,而且您操作的符号与 变量 —— 无论是静态还是其他方式的 —— 完全没有关联。
#error 和 #warning 符号通过使编译器生成警告或错误来防止条件指令的意外误用,给定不良的编译符号集。表 4-1 列出了预处理指令。
表 4-1. 预处理指令
| 预处理指令 | 动作 |
|---|---|
#define *symbol* |
定义 *symbol* |
#undef *symbol* |
取消定义 *symbol* |
#if *symbol* [*operator symbol2*]... |
要测试的 *symbol* |
*operator*s 包括 ==, !=, &&, 和 ||,后跟 #else, #elif, 和 #endif |
|
#else |
执行 #endif 后的代码 |
#elif *symbol* [*operator symbol2*] |
结合 #else 分支和 #if 测试 |
#endif |
结束条件指令 |
#warning *text* |
编译器输出的警告 *text* |
#error *text* |
编译器输出的错误 *text* |
#error version |
报告编译器版本并退出 |
#pragma warning [disable | restore] |
禁用/恢复编译器警告 |
#line [ *number* ["*file*"] | hidden] |
*number* 指定源代码中的行(从 C# 10 开始也可以指定列);*file* 是计算机输出中显示的文件名;hidden 指示调试器跳过从此处到下一个 #line 指令的代码 |
#region *name* |
标记大纲的开始位置 |
#endregion |
结束大纲区域 |
#nullable *option* |
参见 “可空引用类型” |
条件特性
使用 Conditional 属性修饰的特性仅在给定预处理器符号存在时才会被编译:
// file1.cs
#define DEBUG
using System;
using System.Diagnostics;
[Conditional("DEBUG")]
public class TestAttribute : Attribute {}
// file2.cs
#define DEBUG
[Test]
class Foo
{
[Test]
string s;
}
编译器仅在 DEBUG 符号适用于 file2.cs 时才会包含 [Test] 特性。
Pragma Warning
编译器在发现代码中出现看似不经意的内容时会生成警告。与错误不同,警告通常不会阻止应用程序编译。
编译器警告在发现 bug 时非常有价值。然而,当你遇到假警告时,它们的实用性就会受到影响。在大型应用程序中,保持良好的信噪比是非常重要的,这样才能注意到“真正”的警告。
为此,编译器允许您通过使用 #pragma warning 指令有选择地抑制警告。在此示例中,我们指示编译器不要警告我们未使用的 Message 字段:
public class Foo
{
static void Main() { }
#pragma warning disable 414
static string Message = "Hello";
#pragma warning restore 414
}
在#pragma warning指令中省略数字会禁用或恢复所有警告代码。
如果你在应用此指令时很彻底,可以使用 /warnaserror 开关进行编译 —— 这会指示编译器将任何剩余的警告视为错误。
XML 文档
文档注释是嵌入的 XML 片段,用于记录类型或成员。文档注释紧跟在类型或成员声明之前,并以三个斜杠开头:
/// <summary>Cancels a running query.</summary>
public void Cancel() { ... }
可以这样编写多行注释:
/// <summary>
/// Cancels a running query
/// </summary>
public void Cancel() { ... }
或者像这样(注意开头的额外星号):
/**
<summary> Cancels a running query. </summary>
*/
public void Cancel() { ... }
如果你在 .csproj 文件中添加以下选项:
<PropertyGroup>
<DocumentationFile>*SomeFile.xml*</DocumentationFile>
</PropertyGroup>
编译器会提取和整理文档注释到指定的 XML 文件中。这有两个主要用途:
-
如果放置在与编译后程序集相同的文件夹中,像 Visual Studio 和 LINQPad 这样的工具会自动读取 XML 文件,并使用其中的信息为同名程序集的消费者提供 IntelliSense 成员列表。
-
第三方工具(如 Sandcastle 和 NDoc)可以将 XML 文件转换为 HTML 帮助文件。
标准 XML 文档标签
这些是 Visual Studio 和文档生成器识别的标准 XML 标签:
<summary>
<summary>*...*</summary>
指示 IntelliSense 应显示给类型或成员的工具提示;通常是单个短语或句子。
<remarks>
<remarks>*...*</remarks>
描述类型或成员的额外文本。文档生成器会将其获取并合并到类型或成员描述的主体中。
<param>
<param name="*name*">*...*</param>
解释方法的参数。
<returns>
<returns>*...*</returns>
解释方法的返回值。
<exception>
<exception [cref="*type*"]>*...*</exception>
列出方法可能引发的异常(cref 引用异常类型)。
<example>
<example>*...*</example>
表示示例(由文档生成器使用)。这通常包含描述文本和源代码(源代码通常在 <c> 或 <code> 标签内)。
<c>
<c>*...*</c>
指示内联代码片段。此标签通常在 <example> 块内使用。
<code>
<code>*...*</code>
表示一个多行代码示例。此标签通常在<example>块内使用。
<see>
<see cref="*member*">*...*</see>
插入对另一个类型或成员的内联交叉引用。HTML 文档生成器通常会将其转换为超链接。如果类型或成员名称无效,编译器会发出警告。要引用泛型类型,请使用花括号;例如,cref="Foo{T,U}"。
<seealso>
<seealso cref="*member*">*...*</seealso>
交叉引用另一个类型或成员。文档生成器通常会将其写入页面底部的单独的“参见”部分。
<paramref>
<paramref name="*name*"/>
从 <summary> 或 <remarks> 标签内引用参数。
<list>
<list type=[ bullet | number | table ]>
<listheader>
<term>*...*</term>
<description>*...*</description>
</listheader>
<item>
<term>*...*</term>
<description>*...*</description>
</item>
</list>
指示文档生成器生成项目符号、编号或表格样式列表。
<para>
<para>*...*</para>
指示文档生成器将内容格式化为单独的段落。
<include>
<include file='*filename*' path='*tagpath*[@name="*id*"]'>*...*</include>
合并包含文档的外部 XML 文件。路径属性表示对该文件中特定元素的 XPath 查询。
用户定义的标签
C# 编译器识别的预定义 XML 标签没有特别之处,您可以自由定义自己的标签。编译器唯一特别处理的是 <param> 标签(其中验证参数名称及方法的所有参数是否都已文档化)和 cref 属性(验证属性是否指向真实类型或成员,并将其扩展为完全限定的类型或成员 ID)。您也可以在自定义标签中使用 cref 属性;编译器会像在预定义的 <exception>、<permission>、<see> 和 <seealso> 标签中一样验证和扩展它。
类型或成员的交叉引用
类型名称和类型或成员的交叉引用被翻译为唯一定义类型或成员的 ID。这些名称由定义 ID 表示的前缀和类型或成员的签名组成。以下是成员前缀:
| XML 类型前缀 | 应用于... 的 ID 前缀 |
|---|---|
N |
命名空间 |
T |
类型(类、结构体、枚举、接口、委托) |
F |
字段 |
P |
属性(包括索引器) |
M |
方法(包括特殊方法) |
E |
事件 |
! |
错误 |
描述如何生成签名的规则已经有详细文档,尽管相当复杂。
这里是类型和生成的 ID 的示例:
// Namespaces do not have independent signatures
namespace NS
{
/// T:NS.MyClass
class MyClass
{
/// F:NS.MyClass.aField
string aField;
/// P:NS.MyClass.aProperty
short aProperty {get {...} set {...}}
/// T:NS.MyClass.NestedType
class NestedType {...};
/// M:NS.MyClass.X()
void X() {...}
/// M:NS.MyClass.Y(System.Int32,System.Double@,System.Decimal@)
void Y(int p1, ref double p2, out decimal p3) {...}
/// M:NS.MyClass.Z(System.Char[ ],System.Single[0:,0:])
void Z(char[ ] p1, float[,] p2) {...}
/// M:NS.MyClass.op_Addition(NS.MyClass,NS.MyClass)
public static MyClass operator+(MyClass c1, MyClass c2) {...}
/// M:NS.MyClass.op_Implicit(NS.MyClass)˜System.Int32
public static implicit operator int(MyClass c) {...}
/// M:NS.MyClass.#ctor
MyClass() {...}
/// M:NS.MyClass.Finalize
˜MyClass() {...}
/// M:NS.MyClass.#cctor
static MyClass() {...}
}
}
```**
# 第五章:.NET 概述
几乎所有 .NET 8 运行时的功能都通过大量的托管类型暴露出来。这些类型按层次结构命名空间进行组织,并打包到一组程序集中。
一些 .NET 类型由 CLR 直接使用,并且对托管托管环境至关重要。这些类型位于一个名为 *System.Private.CoreLib.dll*(在 .NET Framework 中为 *mscorlib.dll*)的程序集中,包括 C# 的内置类型以及基本的集合类、用于流处理、序列化、反射、线程处理和本地互操作的类型。
在此之上是补充类型,这些类型“丰富”了 CLR 级别的功能,提供 XML、JSON、网络和语言集成查询等功能。它们构成了基础类库(BCL)。在其上层是*应用程序层*,为开发特定类型的应用程序(如 Web 或丰富客户端)提供 API。
在本章中,我们提供以下内容:
+ 本书其余部分中会详细介绍 BCL 概述。
+ 应用程序层的高级摘要
# 运行时目标和 TFMs
在项目文件中,`<TargetFramework>` 元素确定项目构建的运行时目标(其*框架目标*或*运行时目标*),并由*目标框架标识符*(TFM)表示。有效值包括 `net8.0`、`net7.0`、`net6.0`、`net5.0`(用于 .NET 版本 8、7、6 和 5)、`netcoreapp3.1`(用于 .NET Core 3.1)、`net48`(用于 .NET Framework 4.8)和 `netstandard2.0`(我们将在接下来的部分中介绍)。例如,以下是如何针对 .NET 8 进行目标设置的:
```cs
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<PropertyGroup>
您可以通过指定 <TargetFrameworks> 元素(复数形式)来针对多个运行时。每个 TFM 由分号分隔:
<TargetFrameworks>net8.0;net48</TargetFrameworks>
当您进行多目标时,编译器会为每个目标生成单独的输出程序集。
运行时目标通过 TargetFramework 属性编码到输出程序集中。一个程序集可以在比其目标更高的(但不是更低的)运行时上运行。
.NET Standard
公共图书馆的丰富资源(位于 NuGet 上)如果只支持 .NET 8 将不会那么有价值。编写库时,通常需要支持多种平台和运行时版本。为了在不为每个运行时版本创建单独的构建(多目标)的情况下实现该目标,必须以最低公分母为目标。如果您的项目针对 .NET 6 (net6.0),则您的库将在 .NET 6、.NET 7 和 .NET 8 上运行。
如果您还希望支持 .NET Framework(或 Xamarin 等旧版运行时),情况将变得更加混乱。原因是每个运行时都具有 CLR 和 BCL,其功能有重叠—没有一个运行时是其他运行时的纯子集。
.NET Standard 通过定义跨多个运行时工作的人工子集来解决此问题。通过针对 .NET Standard,您可以轻松编写具有广泛覆盖范围的库。
注意
.NET Standard 不是运行时;它仅仅是描述一组运行时兼容性所需的最低基线功能(类型和成员)的规范。这个概念类似于 C# 接口:.NET Standard 就像一个接口,具体类型(运行时)可以实现它。
.NET Standard 2.0
最有用的版本是 .NET Standard 2.0。一个针对 .NET Standard 2.0 而不是特定运行时的库将在现代 .NET(.NET 8/7/6/5,以及 .NET Core 2 至 4.6.1+)和 .NET Framework(4.6.1+)上无需修改即可运行。它还支持传统的 UWP(从 10.0.16299+ 开始)和 Mono 5.4+(老版本 Xamarin 使用的 CLR/BCL)。
要将目标设置为 .NET Standard 2.0,请将以下内容添加到您的 .csproj 文件中:
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<PropertyGroup>
本书描述的大多数 API 都受 .NET Standard 2.0 的支持(那些不支持的大多数可以作为 NuGet 包提供)。
其他 .NET 标准
.NET Standard 2.1 是 .NET Standard 2.0 的超集,仅支持以下平台:
-
.NET Core 3+
-
Mono 6.4+
.NET Standard 2.1 不受任何 .NET Framework 版本支持,因此比 .NET Standard 2.0 要不实用得多。
还有旧版的 .NET Standard,如 1.1、1.2、1.3 和 1.6,其兼容性延伸到古老的运行时,如 .NET Core 1.0 或 .NET Framework 4.5。1.x 标准缺少成千上万的 API,在 2.0 中都有(包括本书中描述的大部分内容),因此已基本废弃。
.NET Framework 和 .NET 8 的兼容性
由于 .NET Framework 存在时间很长,遇到仅适用于 .NET Framework(没有 .NET Standard、.NET Core 或 .NET 8 等同等版本)的库并不罕见。为了帮助缓解这种情况,.NET 5+ 和 .NET Core 项目被允许引用 .NET Framework 程序集,但必须注意以下几点:
-
如果 .NET Framework 程序集调用了不受支持的 API,则会抛出异常。
-
复杂的依赖关系可能(并经常)无法解析。
在实践中,它最有可能在简单情况下起作用,例如包装非托管 DLL 的程序集。
引用程序集
当您目标 .NET Standard 时,您的项目隐式引用了一个名为 netstandard.dll 的程序集,其中包含您选择版本的 .NET Standard 中允许的所有类型和成员。这称为 引用程序集,因为它仅存在于编译器的利益中,不包含任何编译代码。在运行时,通过程序集重定向属性来识别“真实”的程序集(选择的程序集取决于最终运行的运行时和平台)。
有趣的是,当您的目标是 .NET 8 时,您的项目隐式引用了一组引用程序集,其类型镜像了所选 .NET 版本的运行时程序集。这有助于版本控制和跨平台兼容性,并允许您将目标设置为与您的机器上安装的 .NET 版本不同的 .NET 版本。
运行时和 C#语言版本
默认情况下,项目的运行时目标决定使用的 C#语言版本:
| 运行时目标 | C# 版本 |
|---|---|
| .NET 8 | C# 12 |
| .NET 7 | C# 11 |
| .NET 6 | C# 10 |
| .NET 5 | C# 9 |
| .NET Core 3.x & 2.x | C# 8 |
| .NET Framework | C# 7.3 |
| .NET Standard 2.0 | C# 7.3 |
这是因为后续版本的 C#包含依赖于后续运行时中引入的类型的功能。
您可以通过<LangVersion>元素在项目文件中覆盖语言版本。在使用较旧的运行时(如.NET Framework)和较新的语言版本(如 C# 12)时,依赖于较新.NET 类型的语言特性将不起作用(尽管在某些情况下,您可以自定义这些类型,或从 NuGet 包导入)。
CLR 和 BCL
索引器请注意:请跳过本节中的所有内容(除了一级标题)。本节的所有内容在书中的后续部分中有更详细的涵盖。
系统类型
最基本的类型直接位于System命名空间中。这些包括 C#内置类型;Exception基类;Enum、Array和Delegate基类;以及Nullable、Type、DateTime、TimeSpan和Guid。System命名空间还包括执行数学函数(Math)、生成随机数(Random)以及类型转换(Convert和BitConverter)的类型。
第六章还描述了这些类型,以及定义跨.NET 用于格式化(IFormattable)和顺序比较(IComparable)等任务的标准协议的接口。
System命名空间还定义了与垃圾回收器交互的IDisposable接口和GC类,我们在第十二章中进行了详细讨论。
文本处理
System.Text命名空间包含StringBuilder类(string的可编辑或可变版本)以及用于处理文本编码(如 UTF-8 的Encoding及其子类型)的类型。我们在第六章中进行了详细介绍。
System.Text.RegularExpressions命名空间包含执行高级基于模式的搜索和替换操作的类型;我们在第二十五章中描述了这些功能。
集合
.NET 提供了多种用于管理项集合的类。这些包括基于列表和字典的结构;它们与一组标准接口结合工作,统一它们的共同特征。所有集合类型均定义在以下命名空间中,我们在第七章中进行了涵盖:
System.Collections // Nongeneric collections
System.Collections.Generic // Generic collections
System.Collections.Frozen // High-performance read-only collections
System.Collections.Immutable // General-purpose read-only collections
System.Collections.Specialized // Strongly typed collections
System.Collections.ObjectModel // Bases for your own collections
System.Collections.Concurrent // Thread-safe collection (Chapter 22)
查询
语言集成查询(LINQ)允许您在本地和远程集合(例如 SQL Server 表)上执行类型安全的查询,详细介绍见第八章、9 章和 10 章。LINQ 的一个重要优势是它在各种领域提供了一致的查询 API。关键类型位于以下命名空间中:
System.Linq // LINQ to Objects and PLINQ
System.Linq.Expressions // For building expressions manually
System.Xml.Linq // LINQ to XML
XML 和 JSON
XML 和 JSON 在.NET 中得到了广泛支持。第十章专注于 LINQ to XML——一种轻量级的 XML 文档对象模型(DOM),可以通过 LINQ 构建和查询。第十一章涵盖了高性能的低级 XML 读取器/写入器类、XML 模式和样式表,以及处理 JSON 的类型:
System.Xml // XmlReader, XmlWriter
System.Xml.Linq // The LINQ to XML DOM
System.Xml.Schema // Support for XSD
System.Xml.Serialization // Declarative XML serialization for .NET types
System.Xml.XPath // XPath query language
System.Xml.Xsl // Stylesheet support
System.Text.Json // JSON reader/writer and DOM
System.Text.Json.Nodes // JsonNode API (DOM)
在在线补充材料中http://www.albahari.com/nutshell,我们讨论了 JSON 序列化器。
诊断
在第十三章中,我们涵盖了日志记录和断言,并描述了如何与其他进程交互,写入 Windows 事件日志以及处理性能监控。相关类型在System.Diagnostics中定义和使用。
并发与异步
许多现代应用程序需要同时处理多个事务。自 C# 5.0 以来,通过异步函数和任务等高级结构变得更加容易。第十四章详细解释了这些内容,从多线程基础开始讲起。与线程和异步操作相关的类型位于System.Threading和System.Threading.Tasks命名空间中。
流和输入/输出
.NET 提供了基于流的低级输入/输出(I/O)模型。流通常用于直接读写文件和网络连接,并且可以通过链式或包装流添加压缩或加密功能。第十五章描述了流架构以及特定支持文件和目录处理、压缩、管道和内存映射文件的 I/O。Stream和 I/O 类型在System.IO命名空间中定义。
网络
通过System.Net中的类型,您可以直接访问大多数标准网络协议,如 HTTP、TCP/IP 和 SMTP。在第十六章中,我们展示了如何使用每个协议进行通信,从简单的任务如从网页下载开始,到直接使用 TCP/IP 检索 POP3 电子邮件为止。以下是我们涵盖的命名空间:
System.Net
System.Net.Http // HttpClient
System.Net.Mail // For sending mail via SMTP
System.Net.Sockets // TCP, UDP, and IP
程序集、反射和属性
C#程序编译生成的程序集包含可执行指令(存储为 IL)和元数据,描述程序的类型、成员和属性。通过反射,您可以在运行时检查这些元数据,并动态调用方法。使用Reflection.Emit,您可以动态生成新的代码。
在第十七章中,我们描述了程序集的组成及如何动态加载和隔离它们。在第十八章中,我们涵盖了反射和属性,描述了如何检查元数据、动态调用函数、编写自定义属性、生成新类型以及解析原始 IL。用于反射和处理程序集的类型位于以下命名空间中:
System
System.Reflection
System.Reflection.Emit
动态规划
在第十九章中,我们研究了一些动态编程模式,并利用了动态语言运行时(DLR)。我们描述了如何实现Visitor模式、编写自定义动态对象以及与 IronPython 进行互操作。动态编程的类型位于System.Dynamic中。
密码学
.NET 为流行的哈希和加密协议提供了广泛的支持。在第二十章,我们涵盖了哈希、对称和公钥加密以及 Windows 数据保护 API。这些类型的定义如下:
System.Security
System.Security.Cryptography
高级线程
C#的异步函数显著简化了并发编程,因为它们减少了对低级技术的需求。然而,在某些情况下,仍然需要信号构造、线程本地存储、读者/写者锁等。第二十一章深入解释了这些内容。线程类型位于System.Threading命名空间中。
并行编程
在第二十二章,我们详细介绍了利用多核处理器的库和类型,包括任务并行性的 API、命令式数据并行性以及函数并行性(PLINQ)。
Span<T>和Memory<T>
为了帮助微调性能热点,CLR 提供了许多类型,帮助您以减少内存管理器负担的方式编程。其中两个关键类型是Span<T>和Memory<T>,我们在第二十三章中进行了介绍。
本地和 COM 互操作性
您可以与本地代码和组件对象模型(COM)代码进行互操作。本地互操作性允许您调用未管理的 DLL 中的函数,注册回调,映射数据结构,并与本地数据类型进行互操作。COM 互操作性允许您调用 COM 类型(在 Windows 机器上)并将.NET 类型暴露给 COM。支持这些功能的类型位于System.Runtime.InteropServices中,我们在第二十四章中进行了详细介绍。
正则表达式
在第二十五章,我们讨论了如何使用正则表达式在字符串中匹配字符模式。
序列化
.NET 提供了多个系统,用于将对象保存和恢复为二进制或文本表示。这些系统可用于通信以及将对象保存和恢复为文件。在在线补充资料http://www.albahari.com/nutshell,我们涵盖了所有四个序列化引擎:二进制序列化器,(新更新的)JSON 序列化器,XML 序列化器和数据合同序列化器。
Roslyn 编译器
C# 编译器本身是用 C# 编写的 —— 这个项目称为“Roslyn”,并且这些库可以作为 NuGet 包提供。利用这些库,您可以以多种方式使用编译器的功能,例如编写代码分析和重构工具。我们在在线补充资料中介绍了 Roslyn,网址为http://www.albahari.com/nutshell。
应用程序层
用户界面(UI)应用程序可以分为两类:轻客户端,相当于网站,和丰富客户端,用户必须在计算机或移动设备上下载和安装的程序。
要在 C# 中编写轻客户端应用程序,可以使用 ASP.NET Core,在 Windows、Linux 和 macOS 上运行。ASP.NET Core 还专为编写 Web API 而设计。
对于丰富客户端应用程序,有多种 API 可供选择:
-
Windows 桌面层包括流行的 WPF 和 Windows Forms API,在 Windows 7/8/10/11 桌面上运行。
-
WinUI 3(Windows App SDK)是 UWP 的继任者,仅在 Windows 10+ 桌面上运行。
-
UWP 允许您编写运行在 Windows 10+ 桌面以及 Xbox 或 HoloLens 等设备上的 Windows Store 应用程序。
-
MAUI(前身为 Xamarin)运行在 iOS 和 Android 移动设备上。MAUI 还允许跨平台桌面应用程序,目标是 macOS(通过 Catalyst)和 Windows(通过 Windows App SDK)。
还有第三方跨平台 UI 库,如 Avalonia。与 MAUI 不同,Avalonia 还可以在 Linux 上运行,并且不依赖于 Catalyst/WinUI 间接层用于桌面平台,简化了开发和调试。
ASP.NET Core
ASP.NET Core 是 ASP.NET 的轻量模块化后继者,适用于创建网站、基于 REST 的 Web API 和微服务。它还可以与两个流行的单页应用程序框架一起运行:React 和 Angular。
ASP.NET 支持流行的模型-视图-控制器(MVC)模式,以及一种名为 Blazor 的新技术,在其中客户端代码用 C# 编写而不是 JavaScript。
ASP.NET Core 在 Windows、Linux 和 macOS 上运行,并且可以自托管在自定义进程中。与其 .NET Framework 前身(ASP.NET)不同,ASP.NET Core 不依赖于 System.Web 和 Web Forms 的历史包袱。
与任何轻客户端架构一样,ASP.NET Core 相对于丰富客户端提供以下一般优势:
-
在客户端端没有零部署。
-
客户端可以在支持 Web 浏览器的任何平台上运行。
-
更新可以轻松部署。
Windows 桌面
Windows 桌面应用层提供了两种 UI API 选择,用于编写丰富客户端应用程序:WPF 和 Windows Forms。这两种 API 都可以在 Windows 桌面/服务器 7 到 11 上运行。
WPF
WPF 于 2006 年推出,并且自那时以来一直在增强。与其前身 Windows Forms 不同,WPF 明确使用 DirectX 渲染控件,带来以下好处:
-
它支持复杂的图形,如任意变换、3D 渲染、多媒体和真正的透明度。通过样式和模板支持皮肤定制。
-
其主要测量单位不是基于像素的,因此应用程序可以在任何 DPI 设置下正确显示。
-
它具有广泛和灵活的布局支持,这意味着可以本地化应用程序而无需担心元素重叠的问题。
-
其使用 DirectX 使得渲染快速,并能利用图形硬件加速。
-
它提供可靠的数据绑定。
-
可以在 XAML 文件中声明性地描述 UI,这些文件可以独立于“代码后台”文件进行维护,有助于将外观与功能分离。
由于其规模和复杂性,学习 WPF 需要一些时间。用于编写 WPF 应用程序的类型位于System.Windows命名空间及其所有子命名空间中,除了System.Windows.Forms。
Windows Forms
Windows Forms 是一个富客户端 API,随着 2000 年第一个.NET Framework 版本一同发布。与 WPF 相比,Windows Forms 是一种相对简单的技术,提供了编写典型 Windows 应用程序所需的大多数功能。它在维护遗留应用程序方面也具有重要意义。但与 WPF 相比,它有许多缺点,大部分源于其作为 GDI+和 Win32 控件库包装器的特性:
-
虽然 Windows Forms 提供了 DPI 感知机制,但仍然很容易编写在客户端 DPI 设置与开发者不同的应用程序,从而导致应用程序崩溃。
-
用于绘制非标准控件的 API 是 GDI+,虽然相对灵活,但在渲染大面积时速度较慢(并且没有双缓冲,可能会闪烁)。
-
控件缺乏真正的透明度。
-
大多数控件都是非组合的。例如,您不能将图像控件放在选项卡控件标题中。在 Windows Forms 中以一种对 WPF 来说微不足道的方式自定义列表视图、组合框和选项卡控件是耗时且痛苦的。
-
动态布局很难正确可靠地实现。
最后一点是支持 WPF 而不是 Windows Forms 的绝佳理由,即使你只编写需要 UI 而不是“用户体验”的业务应用程序。WPF 中的布局元素,如 Grid,使得组合标签和文本框变得简单,即使在语言更改后的本地化中,它们始终对齐,无需混乱的逻辑和任何闪烁。此外,你不需要迎合屏幕分辨率的最低公分母——WPF 布局元素从一开始就被设计为能够正确调整大小。
正面看,Windows Forms 相对简单易学且仍有大量第三方控件。
Windows Forms 类型位于 System.Windows.Forms(在 System.Windows.Forms.dll 中)和 System.Drawing(在 System.Drawing.dll 中)命名空间中。后者还包含用于绘制自定义控件的 GDI+ 类型。
UWP 和 WinUI 3
UWP 是为编写面向触摸优先的 UI 的富客户端 API,目标是 Windows 10+ 桌面和设备。词汇“Universal”指其能够在一系列 Windows 10 设备上运行,包括 Xbox、Surface Hub、HoloLens 和(当时)Windows Phone。
UWP API 使用 XAML 并且在某种程度上类似于 WPF。以下是其主要区别:
-
UWP 应用程序的主要分发模式是 Windows Store。
-
UWP 应用程序在沙盒中运行,以减少恶意软件的威胁,这意味着它们不能执行诸如读取或写入任意文件之类的任务,并且不能以管理员权限运行。
-
UWP 依赖于操作系统(Windows)中的 WinRT 类型,而不是托管运行时。这意味着在编写应用程序时,你必须指定一个 Windows 版本范围(例如 Windows 10 版本 17763 到 Windows 10 版本 18362)。这意味着你必须要么针对旧的 API,要么要求客户安装最新的 Windows 更新。
由于这些差异所带来的限制,UWP 从未成功地匹配 WPF 和 Windows Forms 的流行度。为了解决这个问题,Microsoft 将 UWP 转变为一种名为 Windows App SDK 的新技术(具有称为 WinUI 3 的 UI 层)。
Windows App SDK 将 WinRT API 从操作系统传输到运行时,从而暴露出完全托管的接口,并且不需要针对特定操作系统版本范围进行目标设定。它还执行以下操作:
-
与 Windows 桌面 API(Windows Forms 和 WPF)更好地集成
-
允许你编写在 Windows Store 沙箱之外运行的应用程序
-
运行在最新的 .NET 之上(而不是像 UWP 那样绑定于 .NET Core 2.2)
尽管有这些改进,WinUI 3 在经典 Windows 桌面 API 中并没有获得广泛的流行。截至撰写本文时,Windows App SDK 也不支持 Xbox 或 HoloLens,并且需要单独的最终用户下载。
MAUI
MAUI(前身为 Xamarin)允许您使用 C#开发针对 iOS 和 Android 的移动应用(以及通过 Catalyst 和 Windows App SDK 针对 macOS 和 Windows 的跨平台桌面应用)。
运行在 iOS 和 Android 上的 CLR/BCL 被称为 Mono(开源 Mono 运行时的一个派生)。历史上,Mono 与.NET 并不完全兼容,能在 Mono 和.NET 上运行的库通常会选择目标为.NET Standard。然而,从.NET 6 开始,Mono 的公共接口与.NET 合并,使得 Mono 实际上成为了.NET 的一个实现。
MAUI 包括统一的项目接口、热重载功能,以及对 Blazor 桌面和混合应用的支持。详细信息请参阅https://github.com/dotnet/maui。
第六章:.NET 基础
在编程时,大多数核心设施不是由 C# 语言提供,而是由 .NET BCL 中的类型提供。本章中,我们涵盖了一些帮助进行基本编程任务的类型,例如虚拟相等比较、顺序比较和类型转换。我们还涵盖了基本的 .NET 类型,如 String、DateTime 和 Enum。
本节中的类型位于 System 命名空间中,以下是一些例外:
-
StringBuilder在System.Text中定义,包括 文本编码 的类型。 -
CultureInfo及其关联类型在System.Globalization中定义。 -
XmlConvert在System.Xml中定义。
字符串和文本处理
Char
C# 的 char 表示单个 Unicode 字符,并别名为 System.Char 结构体。在 第二章 中,我们描述了如何表示 char 文字:
char c = 'A';
char newLine = '\n';
System.Char 定义了一系列静态方法,用于处理字符,如 ToUpper、ToLower 和 IsWhiteSpace。您可以通过 System.Char 类型或其 char 别名调用这些方法:
Console.WriteLine (System.Char.ToUpper ('c')); // C
Console.WriteLine (char.IsWhiteSpace ('\t')); // True
ToUpper 和 ToLower 遵循最终用户的语言环境,这可能导致一些微妙的 bug。在土耳其,以下表达式评估为 false:
char.ToUpper ('i') == 'I'
这是因为在土耳其,char.ToUpper('i') 是 'İ'(请注意上面的点!)。为了避免这个问题,System.Char(以及 System.String)还提供了以 “Invariant” 结尾的文化不变版本的 ToUpper 和 ToLower,这些始终应用英语文化规则:
Console.WriteLine (char.ToUpperInvariant ('i')); // I
这是一个快捷方式:
Console.WriteLine (char.ToUpper ('i', CultureInfo.InvariantCulture))
有关语言环境和文化的更多信息,请参阅 “格式化和解析”。
大多数 char 的其他静态方法与字符分类有关。 表 6-1 列出了这些方法。
表 6-1 静态方法用于对字符进行分类
| 静态方法 | 包含的字符 | 包含的 Unicode 类别 |
|---|
| IsLetter | A–Z、a–z 和其他字母 | UpperCaseLetter LowerCaseLetter
TitleCaseLetter
ModifierLetter
OtherLetter |
IsUpper |
大写字母 | UpperCaseLetter |
|---|---|---|
IsLower |
小写字母 | LowerCaseLetter |
IsDigit |
0–9 及其他字母的数字 | DecimalDigitNumber |
IsLetterOrDigit |
字母加上数字 | (IsLetter, IsDigit) |
| IsNumber | 所有数字加上 Unicode 分数和罗马数字符号 | DecimalDigitNumber LetterNumber
OtherNumber |
IsSeparator |
空格加上所有 Unicode 分隔符字符 | LineSeparator ParagraphSeparator |
|---|---|---|
IsWhiteSpace |
所有分隔符加上 \n、\r、\t、\f 和 \v |
LineSeparator ParagraphSeparator |
| IsPunctuation | 用于西方及其他字母表中的标点符号的符号 | DashPunctuation ConnectorPunctuation
InitialQuotePunctuation
| FinalQuotePunctuation |
| IsSymbol | 大多数其他可打印符号 | MathSymbol ModifierSymbol
OtherSymbol |
IsControl |
0x20 以下的不可打印“控制”字符,例如 \r、\n、\t、\0,以及 0x7F 和 0x9A 之间的字符 |
(无) |
|---|
为了更细粒度的分类,char 提供了一个名为 GetUnicodeCategory 的静态方法;它返回一个 UnicodeCategory 枚举,其成员显示在 表 6-1 的最右列中。
注意
通过显式从整数转换,可以生成超出分配的 Unicode 集的 char。要测试字符的有效性,请调用 char.GetUnicodeCategory:如果结果为 UnicodeCategory.OtherNotAssigned,则该字符无效。
一个 char 宽度为 16 位 —— 足以表示基本多文种平面中的任何 Unicode 字符。要超出这一范围,必须使用代理对:我们将在 “文本编码和 Unicode” 中描述执行此操作的方法。
字符串
C# string (== System.String) 是一个不可变(无法改变)的字符序列。在 第二章 中,我们描述了如何表示字符串字面量,进行相等性比较以及连接两个字符串。本节介绍了其余处理字符串的函数,这些函数通过 System.String 类的静态和实例成员公开。
构造字符串
构造字符串的最简单方式是分配一个字面量,正如我们在 第二章 中看到的:
string s1 = "Hello";
string s2 = "First Line\r\nSecond Line";
string s3 = @"\\server\fileshare\helloworld.cs";
要创建一个重复的字符序列,您可以使用 string 的构造函数:
Console.Write (new string ('*', 10)); // **********
您还可以从 char 数组构造字符串。ToCharArray 方法则执行相反操作:
char[] ca = "Hello".ToCharArray();
string s = new string (ca); // s = "Hello"
string 的构造函数还可以重载以接受各种(不安全的)指针类型,以便从诸如 char* 等类型创建字符串。
空和空字符串
空字符串的长度为零。要创建空字符串,可以使用字面量或静态 string.Empty 字段;要测试空字符串,可以执行相等性比较或测试其 Length 属性:
string empty = "";
Console.WriteLine (empty == ""); // True
Console.WriteLine (empty == string.Empty); // True
Console.WriteLine (empty.Length == 0); // True
由于字符串是引用类型,它们也可以为 null:
string nullString = null;
Console.WriteLine (nullString == null); // True
Console.WriteLine (nullString == ""); // False
Console.WriteLine (nullString.Length == 0); // NullReferenceException
静态 string.IsNullOrEmpty 方法是测试给定字符串是否为 null 或空的一个便捷方法。
访问字符串中的字符
字符串的索引器返回给定索引处的单个字符。与所有操作字符串的函数一样,这是从零开始的索引:
string str = "abcde";
char letter = str[1]; // letter == 'b'
string 还实现了 IEnumerable<char>,因此您可以对其字符进行 foreach 循环:
foreach (char c in "123") Console.Write (c + ","); // 1,2,3,
在字符串中搜索
在字符串中进行搜索的最简单方法是使用 StartsWith、EndsWith 和 Contains。这些方法都返回 true 或 false:
Console.WriteLine ("quick brown fox".EndsWith ("fox")); // True
Console.WriteLine ("quick brown fox".Contains ("brown")); // True
这些方法还重载了让您可以指定 StringComparison 枚举以控制大小写和区域性敏感性(参见“按序数与文化比较”)。默认情况下,使用适用于当前(本地化)文化的规则执行区分大小写的匹配。以下示例使用 不变的 区域性规则执行不区分大小写的搜索:
"abcdef".StartsWith ("aBc", StringComparison.InvariantCultureIgnoreCase)
IndexOf 返回给定字符或子字符串的第一个位置(如果未找到子字符串,则返回 -1):
Console.WriteLine ("abcde".IndexOf ("cd")); // 2
IndexOf 还重载了以接受 startPosition(开始搜索的索引)和 StringComparison 枚举:
Console.WriteLine ("abcde abcde".IndexOf ("CD", 6,
StringComparison.CurrentCultureIgnoreCase)); // 8
LastIndexOf 类似于 IndexOf,但它是从字符串的末尾向前搜索。
IndexOfAny 返回一组字符中任意一个匹配字符的第一个位置:
Console.Write ("ab,cd ef".IndexOfAny (new char[] {' ', ','} )); // 2
Console.Write ("pas5w0rd".IndexOfAny ("0123456789".ToCharArray() )); // 3
LastIndexOfAny 在相反的方向上执行相同的操作。
操纵字符串
因为 String 是不可变的,所有“操纵”字符串的方法都会返回一个新的字符串,而原始字符串保持不变(当重新分配字符串变量时也是如此)。
Substring 提取字符串的一部分:
string left3 = "12345".Substring (0, 3); // left3 = "123";
string mid3 = "12345".Substring (1, 3); // mid3 = "234";
如果省略长度,则获取字符串的剩余部分:
string end3 = "12345".Substring (2); // end3 = "345";
Insert 和 Remove 在指定位置插入或删除字符:
string s1 = "helloworld".Insert (5, ", "); // s1 = "hello, world"
string s2 = s1.Remove (5, 2); // s2 = "helloworld";
PadLeft 和 PadRight 使用指定的字符(如果未指定,则使用空格)将字符串填充到给定长度:
Console.WriteLine ("12345".PadLeft (9, '*')); // ****12345
Console.WriteLine ("12345".PadLeft (9)); // 12345
如果输入字符串比填充长度长,则返回原始字符串而不做任何更改。
TrimStart 和 TrimEnd 从字符串的开头或结尾移除指定的字符;Trim 同时执行这两种操作。默认情况下,这些函数移除空白字符(包括空格、制表符、换行符和这些字符的 Unicode 变体):
Console.WriteLine (" abc \t\r\n ".Trim().Length); // 3
Replace 替换特定字符或子字符串的所有(非重叠)出现:
Console.WriteLine ("to be done".Replace (" ", " | ") ); // to | be | done
Console.WriteLine ("to be done".Replace (" ", "") ); // tobedone
ToUpper 和 ToLower 返回输入字符串的大写和小写版本。默认情况下,它们遵循用户当前的语言设置;ToUpperInvariant 和 ToLowerInvariant 总是应用英语字母规则。
字符串的拆分和连接
Split 将字符串分割成多个部分:
string[] words = "The quick brown fox".Split();
foreach (string word in words)
Console.Write (word + "|"); // The|quick|brown|fox|
默认情况下,Split 使用空白字符作为分隔符;它还重载了以接受 params 字符数组或字符串分隔符数组。Split 还可以选择接受 StringSplitOptions 枚举,该枚举有一个选项来移除空条目:当单词之间使用多个分隔符分隔时,这很有用。
静态 Join 方法与 Split 相反。它需要一个分隔符和字符串数组:
string[] words = "The quick brown fox".Split();
string together = string.Join (" ", words); // The quick brown fox
静态 Concat 方法类似于 Join,但仅接受 params 字符串数组并且不应用分隔符。Concat 实际上等同于 + 运算符(事实上,编译器将 + 转换为 Concat):
string sentence = string.Concat ("The", " quick", " brown", " fox");
string sameSentence = "The" + " quick" + " brown" + " fox";
String.Format 和复合格式字符串
静态 Format 方法提供了一种方便的方式来构建嵌入变量的字符串。嵌入的变量(或值)可以是任何类型;Format 简单地对它们调用 ToString。
包含嵌入变量的主字符串称为组合格式字符串。调用String.Format时,您提供一个组合格式字符串,后跟每个嵌入变量:
string composite = "It's {0} degrees in {1} on this {2} morning";
string s = string.Format (composite, 35, "Perth", DateTime.Now.DayOfWeek);
// s == "It's 35 degrees in Perth on this Friday morning"
(这是摄氏度!)
我们可以使用插值字符串字面量以同样的效果(参见“String Type”)。只需在字符串前加上$符号,并将表达式放在大括号中:
string s = $"It's hot this {DateTime.Now.DayOfWeek} morning";
大括号中的每个数字称为格式项。数字对应于参数位置,可选地跟随以下内容:
-
逗号和最小宽度应用
-
冒号和格式字符串
最小宽度对于对齐列非常有用。如果值为负,则数据左对齐;否则,右对齐:
string composite = "Name={0,-20} Credit Limit={1,15:C}";
Console.WriteLine (string.Format (composite, "Mary", 500));
Console.WriteLine (string.Format (composite, "Elizabeth", 20000));
这是结果:
Name=Mary Credit Limit= $500.00
Name=Elizabeth Credit Limit= $20,000.00
这是不使用string.Format的等效形式:
string s = "Name=" + "Mary".PadRight (20) +
" Credit Limit=" + 500.ToString ("C").PadLeft (15);
通过"C"格式字符串,信用限额以货币形式进行格式化。我们在“Formatting and Parsing”中详细描述格式字符串。
比较字符串
在比较两个值时,.NET 区分相等比较和顺序比较的概念。相等比较测试两个实例是否语义上相同;顺序比较测试这两个(如果有的话)实例在按升序或降序排列时谁先出现。
注意
相等比较不是顺序比较的子集;这两个系统有不同的目的。例如,在相同的排序位置上允许有两个不相等的值。我们在“Equality Comparison”中恢复这个主题。
对于字符串的相等比较,您可以使用==运算符或string的Equals方法之一。后者更加灵活,因为它们允许您指定选项,如不区分大小写。
警告
另一个区别是,如果将变量强制转换为object类型,则==在字符串上不可靠。我们在“Equality Comparison”中解释了这一点。
对于字符串顺序比较,您可以使用CompareTo实例方法或静态的Compare和CompareOrdinal方法。这些方法根据第一个值是否在第二个值之后、之前或与其并列返回正数、负数或零。
在详细讨论每个之前,我们需要检查.NET 的底层字符串比较算法。
顺序与文化比较
字符串比较有两种基本算法:顺序和文化敏感。顺序比较将字符简单地解释为数字(根据它们的数值 Unicode 值);文化敏感比较则根据特定的字母表解释字符。有两种特殊的文化:基于从计算机控制面板获取的设置的“当前文化”,以及在每台计算机上相同的“不变文化”(与美国文化接近)。
对于相等比较,无论是序数还是特定文化的算法都很有用。然而,对于排序来说,特定文化的比较几乎总是更可取的:要按字母顺序排序字符串,你需要一个字母表。序数依赖于数字 Unicode 点值,这恰好按照英文字符的字母顺序排列,但即使如此,也不完全如你所期望的那样。例如,假设区分大小写,请考虑字符串"Atom"、"atom"和"Zamia"。不变文化将它们按以下顺序排列:
"atom", "Atom", "Zamia"
序数则将它们安排如下:
"Atom", "Zamia", "atom"
这是因为不变文化封装了一个包括大写字符邻近其小写对应物的字母表(aAbBcCdD...)。然而,序数算法将所有大写字符放在前面,然后所有小写字符(A...Z,a...z)。这本质上是对上世纪 60 年代发明的 ASCII 字符集的回溯。
字符串相等比较
尽管序数有其局限性,string的==运算符总是执行序数区分大小写比较。调用时没有参数的string.Equals的实例版本也是如此;这定义了string类型的“默认”相等比较行为。
注意
序数算法被选为string的==和Equals函数的算法,因为它既高效又确定性。字符串的相等比较被认为是基础性的,并且比顺序比较频繁得多。
“严格”的相等概念也与==运算符的一般使用一致。
以下方法允许特定文化感知或不区分大小写的比较:
public bool Equals (string value, StringComparison comparisonType);
public static bool Equals (string a, string b,
StringComparison comparisonType);
静态版本的优势在于,即使其中一个或两个字符串为null,它仍然可以工作。StringComparison被定义为以下的enum:
public enum StringComparison
{
CurrentCulture, // Case-sensitive
CurrentCultureIgnoreCase,
InvariantCulture, // Case-sensitive
InvariantCultureIgnoreCase,
Ordinal, // Case-sensitive
OrdinalIgnoreCase
}
例如:
Console.WriteLine (string.Equals ("foo", "FOO",
StringComparison.OrdinalIgnoreCase)); // True
Console.WriteLine ("ṻ" == "ǖ"); // False
Console.WriteLine (string.Equals ("ṻ", "ǖ",
StringComparison.CurrentCulture)); // *?*
(第三个示例的结果取决于计算机的当前语言设置。)
字符串顺序比较
String的CompareTo实例方法执行特定文化、区分大小写的顺序比较。与==运算符不同,CompareTo不使用序数比较:对于排序,特定文化的算法要更有用。这里是该方法的定义:
public int CompareTo (string strB);
注意
实例方法CompareTo实现了通用的IComparable接口,这是.NET 库中使用的标准比较协议。这意味着string的CompareTo定义了在排序集合等应用中字符串的默认排序行为。有关IComparable的更多信息,请参阅“顺序比较”。
对于其他类型的比较,可以调用静态的Compare和CompareOrdinal方法:
public static int Compare (string strA, string strB,
StringComparison comparisonType);
public static int Compare (string strA, string strB, bool ignoreCase,
CultureInfo culture);
public static int Compare (string strA, string strB, bool ignoreCase);
public static int CompareOrdinal (string strA, string strB);
最后两个方法只是调用前两个方法的简便方法。
所有的顺序比较方法返回一个正数、一个负数或零,具体取决于第一个值是在第二个值之后、之前还是相同:
Console.WriteLine ("Boston".CompareTo ("Austin")); // 1
Console.WriteLine ("Boston".CompareTo ("Boston")); // 0
Console.WriteLine ("Boston".CompareTo ("Chicago")); // -1
Console.WriteLine ("ṻ".CompareTo ("ǖ")); // 1
Console.WriteLine ("foo".CompareTo ("FOO")); // -1
以下使用当前文化进行不区分大小写比较:
Console.WriteLine (string.Compare ("foo", "FOO", true)); // 0
通过提供一个 CultureInfo 对象,你可以插入任何字母表:
// CultureInfo is defined in the System.Globalization namespace
CultureInfo german = CultureInfo.GetCultureInfo ("de-DE");
int i = string.Compare ("Müller", "Muller", false, german);
StringBuilder
StringBuilder 类(System.Text 命名空间)表示一个可变(可编辑)字符串。使用 StringBuilder,可以在不替换整个 StringBuilder 的情况下 Append、Insert、Remove 和 Replace 子字符串。
StringBuilder 的构造函数可选择接受一个初始字符串值以及其内部容量的起始大小(默认为 16 个字符)。如果超出此范围,StringBuilder 会自动调整其内部结构以容纳(稍微降低性能)最大容量(默认为 int.MaxValue)。
使用 StringBuilder 的一个流行用法是通过重复调用 Append 来构建长字符串。这种方法比重复连接普通字符串类型要高效得多:
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 50; i++) sb.Append(i).Append(",");
要获取最终结果,请调用 ToString():
Console.WriteLine (sb.ToString());
0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,
27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,
AppendLine 执行一个 Append,添加一个新行序列(在 Windows 下是 "\r\n")。AppendFormat 接受一个复合格式字符串,就像 String.Format 一样。
除了 Insert、Remove 和 Replace 方法(Replace 的工作方式类似于字符串的 Replace),StringBuilder 定义了一个 Length 属性和一个可写索引器来获取/设置单个字符。
要清除 StringBuilder 的内容,可以实例化一个新的或将其 Length 设置为零。
警告
将 StringBuilder 的 Length 设置为零不会减小其内部容量。因此,如果 StringBuilder 以前包含一百万个字符,将其长度设置为零后,它仍将占用大约两兆字节的内存。如果要释放内存,必须创建一个新的 StringBuilder,并允许旧的 StringBuilder 超出作用域(并被垃圾回收)。
文本编码和 Unicode
字符集 是字符分配,每个字符都有一个数字代码或代码点。常用的字符集有两种:Unicode 和 ASCII。Unicode 的地址空间约为一百万个字符,目前分配约十万个。Unicode 覆盖大多数世界语言以及一些历史语言和特殊符号。ASCII 集仅是 Unicode 集的前 128 个字符,覆盖大部分美式键盘上的内容。ASCII 比 Unicode 早 30 年,有时因其简单和高效而仍在使用:每个字符用一个字节表示。
.NET 类型系统设计用于与 Unicode 字符集配合工作。ASCII 隐式支持,因为它是 Unicode 的子集。
文本编码将字符从其数字代码点映射到二进制表示。在.NET 中,文本编码主要用于处理文本文件或流。将文本文件读入字符串时,文本编码器将文件数据从二进制转换为char和string类型期望的内部 Unicode 表示。文本编码可以限制可以表示的字符,并影响存储效率。
在.NET 中有两类文本编码:
-
将 Unicode 字符映射到另一个字符集的编码
-
使用标准 Unicode 编码方案的编码
第一类包含传统编码,如 IBM 的 EBCDIC 和扩展字符集的 8 位字符集,这些字符集在 Unicode 之前非常流行(由代码页标识)。ASCII 编码也属于此类:它编码前 128 个字符并丢弃其他所有内容。此类还包含非遗留GB18030,这是自 2000 年以来在中国编写或销售的应用程序的强制标准。
第二类是 UTF-8、UTF-16 和 UTF-32(以及已废弃的 UTF-7)。每种编码在空间效率上有所不同。对于大多数文本,UTF-8 是最空间高效的:它使用一到四个字节来表示每个字符。前 128 个字符仅需一个字节,使其与 ASCII 兼容。UTF-8 是最流行的文本文件和流编码(特别是在互联网上),并且是.NET 中流输入/输出(I/O)的默认编码(事实上,它是几乎所有隐式使用编码的默认值)。
UTF-16 使用一个或两个 16 位字来表示每个字符。这是.NET 内部用于表示字符和字符串的方式。一些程序还以 UTF-16 编写文件。
UTF-32 是最不空间高效的:它直接将每个代码点映射到 32 位,因此每个字符占用四个字节。由于这个原因,UTF-32 很少被使用。然而,它确实使随机访问非常容易,因为每个字符占用相同数量的字节。
获取一个 Encoding 对象
System.Text中的Encoding类是封装文本编码的常见基类型。有几个子类——它们的目的是封装具有类似特性的编码族。最常见的编码可以通过Encoding上的专用静态属性获取:
| 编码名称 | Encoding 上的静态属性 |
|---|---|
| UTF-8 | Encoding.UTF8 |
| UTF-16 | Encoding.Unicode(不是 UTF16) |
| UTF-32 | Encoding.UTF32 |
| ASCII | Encoding.ASCII |
您可以通过使用标准的互联网分配的数字字符集(IANA)名称调用Encoding.GetEncoding来获取其他编码:
// In .NET 5+ and .NET Core, you must first call RegisterProvider:
Encoding.RegisterProvider (CodePagesEncodingProvider.Instance);
Encoding chinese = Encoding.GetEncoding ("GB18030");
静态的GetEncodings方法返回所有支持的编码列表,以及它们的标准 IANA 名称:
foreach (EncodingInfo info in Encoding.GetEncodings())
Console.WriteLine (info.Name);
另一种获取编码的方法是直接实例化编码类。这样做允许您通过构造函数参数设置各种选项,包括:
-
是否在解码时遇到无效字节序列时抛出异常。默认值为 false。
-
是否以最重要的字节优先(大端序)或最不重要的字节优先(小端序)编码/解码 UTF-16/UTF-32。默认是小端序,在 Windows 操作系统上是标准的。
-
是否发出字节顺序标记(指示字节顺序的前缀)。
用于文件和流 I/O 的编码
Encoding对象的最常见应用是控制如何将文本读取和写入文件或流。例如,以下代码以 UTF-16 编码将“Testing…”写入名为data.txt的文件:
System.IO.File.WriteAllText ("data.txt", "Testing...", Encoding.Unicode);
如果省略最后一个参数,WriteAllText将应用普遍使用的 UTF-8 编码。
注意
UTF-8 是所有文件和流 I/O 的默认文本编码。
我们将在第十五章,“流适配器”中继续讨论这个主题。
编码为字节数组
您还可以使用Encoding对象在字节数组和字符串之间进行转换。GetBytes方法使用给定的编码将string转换为byte[];GetString方法将byte[]转换为string:
byte[] utf8Bytes = System.Text.Encoding.UTF8.GetBytes ("0123456789");
byte[] utf16Bytes = System.Text.Encoding.Unicode.GetBytes ("0123456789");
byte[] utf32Bytes = System.Text.Encoding.UTF32.GetBytes ("0123456789");
Console.WriteLine (utf8Bytes.Length); // 10
Console.WriteLine (utf16Bytes.Length); // 20
Console.WriteLine (utf32Bytes.Length); // 40
string original1 = System.Text.Encoding.UTF8.GetString (utf8Bytes);
string original2 = System.Text.Encoding.Unicode.GetString (utf16Bytes);
string original3 = System.Text.Encoding.UTF32.GetString (utf32Bytes);
Console.WriteLine (original1); // 0123456789
Console.WriteLine (original2); // 0123456789
Console.WriteLine (original3); // 0123456789
UTF-16 和代理对
记得.NET 将字符和字符串存储在 UTF-16 中。因为 UTF-16 每个字符需要一个或两个 16 位字,而char长度仅为 16 位,所以一些 Unicode 字符需要两个char来表示。这有几个后果:
-
字符串的
Length属性可能大于其实际字符数。 -
单个
char并不总是足以完全表示一个 Unicode 字符。
大多数应用程序忽略此问题,因为几乎所有常用字符都适合 Unicode 的一个部分,称为基本多语言平面(BMP),在 UTF-16 中只需要一个 16 位字。BMP 涵盖几十种世界语言,包括超过 30,000 个中文字符。不包括某些古代语言的字符,音乐符号,一些不常见的中文字符以及大多数表情符号。
如果需要支持双字字符,char中的以下静态方法将 32 位代码点转换为两个char的字符串,并反向转换回去:
string ConvertFromUtf32 (int utf32)
int ConvertToUtf32 (char highSurrogate, char lowSurrogate)
两字字符称为代理对。它们很容易识别,因为每个字都在 0xD800 到 0xDFFF 范围内。您可以使用char中的以下静态方法来辅助:
bool IsSurrogate (char c)
bool IsHighSurrogate (char c)
bool IsLowSurrogate (char c)
bool IsSurrogatePair (char highSurrogate, char lowSurrogate)
System.Globalization命名空间中的StringInfo类还提供了一系列处理双字字符的方法和属性。
BMP 之外的字符通常需要特殊字体,并且操作系统支持有限。
日期和时间
System命名空间中的以下不可变结构体负责表示日期和时间:
DateTime、DateTimeOffset、TimeSpan、DateOnly、TimeOnly
C#没有定义任何特殊的关键字来映射到这些类型。
时间间隔
TimeSpan表示一段时间间隔或一天中的时间。在后一种角色中,它只是“时钟”时间(不包括日期),它等于从午夜开始的时间,假设没有夏令时过渡。TimeSpan的分辨率为 100 纳秒,最大值约为 1000 万天,可以为正或负。
有三种构造TimeSpan的方法:
-
通过其中一个构造函数
-
通过调用其中一个静态的
From…方法 -
通过将一个
DateTime减去另一个
这里是构造函数:
public TimeSpan (int hours, int minutes, int seconds);
public TimeSpan (int days, int hours, int minutes, int seconds);
public TimeSpan (int days, int hours, int minutes, int seconds,
int milliseconds);
public TimeSpan (int days, int hours, int minutes, int seconds,
int milliseconds, int microseconds);
public TimeSpan (long ticks); // Each tick = 100ns
当您想要仅使用单个单位(如分钟、小时等)指定间隔时,静态的From…方法更方便:
public static TimeSpan FromDays (double value);
public static TimeSpan FromHours (double value);
public static TimeSpan FromMinutes (double value);
public static TimeSpan FromSeconds (double value);
public static TimeSpan FromMilliseconds (double value);
public static TimeSpan FromMicroseconds (double value);
例如:
Console.WriteLine (new TimeSpan (2, 30, 0)); // 02:30:00
Console.WriteLine (TimeSpan.FromHours (2.5)); // 02:30:00
Console.WriteLine (TimeSpan.FromHours (-2.5)); // -02:30:00
TimeSpan重载了<和>运算符以及+和-运算符。以下表达式计算为 2.5 小时的TimeSpan:
TimeSpan.FromHours(2) + TimeSpan.FromMinutes(30);
下一个表达式计算为比 10 天短一秒:
TimeSpan.FromDays(10) - TimeSpan.FromSeconds(1); // 9.23:59:59
使用这个表达式,我们可以说明整数属性Days、Hours、Minutes、Seconds和Milliseconds:
TimeSpan nearlyTenDays = TimeSpan.FromDays(10) - TimeSpan.FromSeconds(1);
Console.WriteLine (nearlyTenDays.Days); // 9
Console.WriteLine (nearlyTenDays.Hours); // 23
Console.WriteLine (nearlyTenDays.Minutes); // 59
Console.WriteLine (nearlyTenDays.Seconds); // 59
Console.WriteLine (nearlyTenDays.Milliseconds); // 0
相比之下,Total...属性返回类型为double的值,描述整个时间跨度:
Console.WriteLine (nearlyTenDays.TotalDays); // 9.99998842592593
Console.WriteLine (nearlyTenDays.TotalHours); // 239.999722222222
Console.WriteLine (nearlyTenDays.TotalMinutes); // 14399.9833333333
Console.WriteLine (nearlyTenDays.TotalSeconds); // 863999
Console.WriteLine (nearlyTenDays.TotalMilliseconds); // 863999000
静态的Parse方法执行与ToString相反的操作,将字符串转换为TimeSpan。TryParse也执行相同的操作,但如果转换失败,则返回false而不是抛出异常。XmlConvert类还提供了TimeSpan/字符串转换方法,遵循标准的 XML 格式化协议。
TimeSpan的默认值是TimeSpan.Zero。
TimeSpan也可以用来表示一天中的时间(自午夜以来经过的时间)。要获取当前时间,请调用DateTime.Now.TimeOfDay。
DateTime 和 DateTimeOffset
DateTime和DateTimeOffset是表示日期和(可选)时间的不可变结构体。它们的分辨率为 100 纳秒,范围涵盖 0001 年至 9999 年。
DateTimeOffset在功能上类似于DateTime。其特点是它还存储一个协调世界时(UTC)偏移量;这在跨不同时区比较值时可以得到更有意义的结果。
在选择 DateTime 和 DateTimeOffset 之间
DateTime和DateTimeOffset在处理时区时有所不同。DateTime包含一个三态标志,指示DateTime是否相对于以下内容:
-
当前计算机上的本地时间
-
UTC(格林尼治标准时间的现代等价物)
-
未指定的
DateTimeOffset更具体一些—它将偏移量从 UTC 存储为TimeSpan:
July 01 2019 03:00:00 -06:00
这会影响到相等比较,这是在选择DateTime和DateTimeOffset之间的主要因素。具体来说:
-
DateTime在比较中忽略了三态标志,并且认为如果它们具有相同的年、月、日、小时、分钟等,则两个值相等。 -
DateTimeOffset认为如果它们引用同一时间点,则两个值相等。
警告
夏令时使得这种区别变得重要,即使您的应用程序不需要处理多个地理时区。
因此,DateTime 认为以下两个值是不同的,而 DateTimeOffset 认为它们是相等的:
July 01 2019 09:00:00 +00:00 (GMT)
July 01 2019 03:00:00 -06:00 (local time, Central America)
在大多数情况下,DateTimeOffset 的相等逻辑更可取。例如,在计算两个国际事件中哪个更近时,DateTimeOffset 会隐式地给出正确答案。同样,计划进行分布式拒绝服务攻击的黑客也会选择 DateTimeOffset!要使用 DateTime 实现相同效果,则需要在应用程序中统一使用单一时区(通常是 UTC)。这有两个问题:
-
为了对最终用户友好,UTC
DateTime需要在格式化之前显式转换为本地时间。 -
很容易忘记并纳入本地
DateTime。
DateTime 更好,尤其是在运行时相对于本地计算机指定值时——例如,如果您希望在每个国际办事处都安排下周日凌晨 3 点进行存档(当活动最少时)。在这种情况下,DateTime 更合适,因为它会尊重每个地点的本地时间。
注意
在内部,DateTimeOffset 使用短整数以分钟为单位存储 UTC 偏移量。它不存储任何地区信息,因此没有任何信息可以表明例如 +08:00 的偏移量是指新加坡时间还是珀斯时间。
我们在“日期和时区”中深入探讨了时区和相等比较。
注意
SQL Server 2008 通过同名的新数据类型直接支持了 DateTimeOffset。
构建 DateTime
DateTime 定义了构造函数,接受整数参数作为年、月、日——可选的还有时、分、秒、毫秒(从 .NET 7 开始,还包括微秒):
public DateTime (int year, int month, int day);
public DateTime (int year, int month, int day,
int hour, int minute, int second, int millisecond);
如果只指定日期,时间会被默认设置为午夜(0:00)。
DateTime 构造函数还允许您指定 DateTimeKind——一个包含以下值的枚举:
Unspecified, Local, Utc
这对应于前一节描述的三态标志。Unspecified 是默认值,表示 DateTime 是时区无关的。Local 表示相对于当前计算机上的本地时区。本地 DateTime 不包含有关其所指的具体时区的信息,或者说,不像 DateTimeOffset 那样包含与 UTC 的数值偏移量。
DateTime 的 Kind 属性返回其 DateTimeKind。
DateTime 的构造函数还有重载形式,可以接受一个 Calendar 对象。这允许您使用 System.Globalization 中定义的任何 Calendar 子类来指定日期:
DateTime d = new DateTime (5767, 1, 1,
new System.Globalization.HebrewCalendar());
Console.WriteLine (d); // 12/12/2006 12:00:00 AM
(本示例中的日期格式取决于计算机的控制面板设置。)DateTime 总是使用默认的公历日历——这个示例,一次性的转换,在构造时进行。要使用另一个日历进行计算,必须使用子类 Calendar 自身的方法。
也可以使用 long 类型的单个 ticks 值构造一个 DateTime,其中 ticks 是从公元 0001 年 01 月 01 日午夜开始的 100 纳秒间隔数。
为了互操作性,DateTime 提供了静态的 FromFileTime 和 FromFileTimeUtc 方法,用于从 Windows 文件时间(指定为 long)和 FromOADate 方法转换从 OLE 自动化日期/时间(指定为 double)。
要从字符串构造 DateTime,调用静态的 Parse 或 ParseExact 方法。这两种方法都接受可选的标志和格式提供程序;ParseExact 还接受一个格式字符串。我们将在“格式化和解析”中更详细地讨论解析。
构造 DateTimeOffset
DateTimeOffset 有一组类似的构造函数。区别在于,还需指定一个 UTC 偏移量作为 TimeSpan:
public DateTimeOffset (int year, int month, int day,
int hour, int minute, int second,
TimeSpan offset);
public DateTimeOffset (int year, int month, int day,
int hour, int minute, int second, int millisecond,
TimeSpan offset);
TimeSpan 必须是整数分钟;否则,会引发异常。
DateTimeOffset 还有一些构造函数接受 Calendar 对象、long 类型的 ticks 值,以及静态的 Parse 和 ParseExact 方法接受一个字符串。
可以使用以下构造函数之一从现有的 DateTime 构造一个 DateTimeOffset:
public DateTimeOffset (DateTime dateTime);
public DateTimeOffset (DateTime dateTime, TimeSpan offset);
或者使用隐式转换:
DateTimeOffset dt = new DateTime (2000, 2, 3);
注意
从 DateTime 到 DateTimeOffset 的隐式转换非常方便,因为大多数 .NET BCL 支持 DateTime — 而不支持 DateTimeOffset。
如果不指定偏移量,它将从 DateTime 值中推断使用以下规则:
-
如果
DateTime的DateTimeKind是Utc,则偏移量为零。 -
如果
DateTime的DateTimeKind是Local或Unspecified(默认值),则偏移量取自当前的本地时区。
转换到另一个方向时,DateTimeOffset 提供三个属性返回 DateTime 类型的值:
-
UtcDateTime属性返回 UTC 时间中的DateTime。 -
LocalDateTime属性返回当前本地时区的DateTime(如果需要,进行转换)。 -
DateTime属性返回一个DateTime,在指定的任何区域,具有Kind为Unspecified(即,它返回 UTC 时间加上偏移量)。
当前的 DateTime/DateTimeOffset
DateTime 和 DateTimeOffset 都有一个静态的 Now 属性,返回当前日期和时间:
Console.WriteLine (DateTime.Now); // 11/11/2019 1:23:45 PM
Console.WriteLine (DateTimeOffset.Now); // 11/11/2019 1:23:45 PM -06:00
DateTime 还提供一个 Today 属性,只返回日期部分:
Console.WriteLine (DateTime.Today); // 11/11/2019 12:00:00 AM
静态的 UtcNow 属性返回当前的 UTC 时间和日期:
Console.WriteLine (DateTime.UtcNow); // 11/11/2019 7:23:45 AM
Console.WriteLine (DateTimeOffset.UtcNow); // 11/11/2019 7:23:45 AM +00:00
所有这些方法的精度取决于操作系统,通常在 10 到 20 毫秒的范围内。
处理日期和时间
DateTime 和 DateTimeOffset 提供一组类似的实例属性,返回不同的日期/时间元素:
DateTime dt = new DateTime (2000, 2, 3,
10, 20, 30);
Console.WriteLine (dt.Year); // 2000
Console.WriteLine (dt.Month); // 2
Console.WriteLine (dt.Day); // 3
Console.WriteLine (dt.DayOfWeek); // Thursday
Console.WriteLine (dt.DayOfYear); // 34
Console.WriteLine (dt.Hour); // 10
Console.WriteLine (dt.Minute); // 20
Console.WriteLine (dt.Second); // 30
Console.WriteLine (dt.Millisecond); // 0
Console.WriteLine (dt.Ticks); // 630851700300000000
Console.WriteLine (dt.TimeOfDay); // 10:20:30 (returns a TimeSpan)
DateTimeOffset 还有一个 Offset 属性,类型为 TimeSpan。
两种类型提供以下实例方法来执行计算(大多数接受 double 或 int 类型的参数):
AddYears AddMonths AddDays
AddHours AddMinutes AddSeconds AddMilliseconds AddTicks
所有这些都会返回一个新的 DateTime 或 DateTimeOffset,并考虑诸如闰年等因素。您可以传入负值来进行减法运算。
Add 方法将 TimeSpan 添加到 DateTime 或 DateTimeOffset 中。+ 运算符重载以执行相同的操作:
TimeSpan ts = TimeSpan.FromMinutes (90);
Console.WriteLine (dt.Add (ts));
Console.WriteLine (dt + ts); // same as above
您还可以从 DateTime/DateTimeOffset 中减去 TimeSpan,以及从一个 DateTime/DateTimeOffset 中减去另一个 DateTime/DateTimeOffset。后者会返回一个 TimeSpan:
DateTime thisYear = new DateTime (2015, 1, 1);
DateTime nextYear = thisYear.AddYears (1);
TimeSpan oneYear = nextYear - thisYear;
格式化和解析日期时间
在 DateTime 上调用 ToString 时,格式化结果将作为 短日期(所有数字)后跟 长时间(包括秒);例如:
11/11/2019 11:50:30 AM
操作系统的控制面板默认确定诸如日期、月份或年份的顺序,前导零的使用以及是否使用 12 小时或 24 小时制。
在 DateTimeOffset 上调用 ToString 时,除了返回偏移量之外,还会返回相同的内容:
11/11/2019 11:50:30 AM -06:00
ToShortDateString 和 ToLongDateString 方法仅返回日期部分。长日期格式也由控制面板确定;例如:“2015 年 11 月 11 日 星期三”。ToShortTimeString 和 ToLongTimeString 方法仅返回时间部分,例如 17:10:10(前者不包括秒)。
这四个刚才描述的方法实际上是四个不同的 格式字符串 的快捷方式。ToString 被重载以接受格式字符串和提供程序,允许您指定广泛的选项并控制如何应用区域设置。我们在 “格式化和解析” 中描述了这一点。
警告
如果区域设置设置与格式化时不同,则可能会误解 DateTime 和 DateTimeOffset。您可以通过使用忽略区域设置的格式字符串(如“o”)与 ToString 结合使用来避免此问题:
DateTime dt1 = DateTime.Now;
string cannotBeMisparsed = dt1.ToString ("o");
DateTime dt2 = DateTime.Parse (cannotBeMisparsed);
静态的 Parse/TryParse 和 ParseExact/TryParseExact 方法执行 ToString 的反操作,将字符串转换为 DateTime 或 DateTimeOffset。这些方法还被重载以接受格式提供程序。Try* 方法在转换失败时返回 false 而不是抛出 FormatException。
空的 DateTime 和 DateTimeOffset 值
因为 DateTime 和 DateTimeOffset 是结构体,它们本身不可为空。当你需要可空性时,有两种方法可以解决这个问题:
-
使用可空类型(即
DateTime?或DateTimeOffset?)。 -
使用静态字段
DateTime.MinValue或DateTimeOffset.MinValue(这些类型的 默认值)。
可空类型通常是最佳选择,因为编译器可以帮助防止错误。DateTime.MinValue 对于与早于 C# 2.0 的代码向后兼容(当时引入了可空值类型)非常有用。
警告
在 DateTime.MinValue 上调用 ToUniversalTime 或 ToLocalTime 可能导致它不再是 DateTime.MinValue(取决于你位于 GMT 的哪一侧)。如果你正好在 GMT 上(如英格兰,在夏令时之外),这个问题将不会出现,因为本地时间和 UTC 时间相同。这是你对英国冬季的补偿!
DateOnly 和 TimeOnly
DateOnly 和 TimeOnly 结构体(来自 .NET 6)存在的目的是当你仅仅想表示日期或时间时使用。
DateOnly 类似于 DateTime,但没有时间部分。DateOnly 也缺乏 DateTimeKind;实际上,它总是 Unspecified,没有 Local 或 Utc 的概念。DateOnly 的历史替代方案是使用零时间(午夜)的 DateTime。这种方法的困难之处在于,当非零时间出现在你的代码中时,相等性比较会失败。
TimeOnly 类似于 DateTime,但没有日期部分。TimeOnly 用于捕捉一天中的时间,适用于记录闹钟时间或营业时间等应用。
日期和时区
在本节中,我们更详细地探讨了时区如何影响 DateTime 和 DateTimeOffset。我们还研究了 TimeZoneInfo 类型,该类型提供有关时区偏移和夏令时信息的信息。
DateTime 和时区
DateTime 在处理时区时是简单的。在内部,它使用两个信息来存储 DateTime:
-
一个 62 位数字,表示自公元 1/1/0001 起的 ticks 数量
-
一个 2 位枚举,表示
DateTimeKind(Unspecified,Local或Utc)
当你比较两个 DateTime 实例时,只比较它们的 ticks 值;它们的 DateTimeKind 将被忽略:
DateTime dt1 = new DateTime (2000, 1, 1, 10, 20, 30, DateTimeKind.Local);
DateTime dt2 = new DateTime (2000, 1, 1, 10, 20, 30, DateTimeKind.Utc);
Console.WriteLine (dt1 == dt2); // True
DateTime local = DateTime.Now;
DateTime utc = local.ToUniversalTime();
Console.WriteLine (local == utc); // False
实例方法 ToUniversalTime/ToLocalTime 将转换为通用/本地时间。这些方法应用于计算机当前的时区设置,并返回一个带有 DateTimeKind 为 Utc 或 Local 的新 DateTime。如果在已经是 Utc 的 DateTime 上调用 ToUniversalTime,或者在已经是 Local 的 DateTime 上调用 ToLocalTime,则不会发生转换。但是,如果在 Unspecified 的 DateTime 上调用 ToUniversalTime 或 ToLocalTime,则会进行转换。
你可以使用静态方法 DateTime.SpecifyKind 构造一个在 Kind 方面与另一个不同的 DateTime:
DateTime d = new DateTime (2015, 12, 12); // Unspecified
DateTime utc = DateTime.SpecifyKind (d, DateTimeKind.Utc);
Console.WriteLine (utc); // 12/12/2015 12:00:00 AM
DateTimeOffset 和时区
在内部,DateTimeOffset 包含一个 DateTime 字段,其值始终为 UTC,并且一个 16 位整数字段表示 UTC 偏移量(以分钟计)。比较仅考虑(UTC)DateTime;Offset 主要用于格式化。
ToUniversalTime/ToLocalTime 方法返回一个 DateTimeOffset,表示同一时间点但带有 UTC 或本地偏移量。与 DateTime 不同的是,这些方法不会影响基础的日期/时间值,只影响偏移量:
DateTimeOffset local = DateTimeOffset.Now;
DateTimeOffset utc = local.ToUniversalTime();
Console.WriteLine (local.Offset); // -06:00:00 (in Central America)
Console.WriteLine (utc.Offset); // 00:00:00
Console.WriteLine (local == utc); // True
要在比较中包含 Offset,必须使用 EqualsExact 方法:
Console.WriteLine (local.EqualsExact (utc)); // False
TimeZoneInfo
TimeZoneInfo类提供有关时区名称、UTC 偏移和夏令时规则的信息。
时区
静态方法TimeZone.CurrentTimeZone返回一个TimeZone
TimeZone zone = TimeZone.CurrentTimeZone;
Console.WriteLine (zone.StandardName); // Pacific Standard Time
Console.WriteLine (zone.DaylightName); // Pacific Daylight Time
GetDaylightChanges方法返回给定年份的特定夏令时信息:
DaylightTime day = zone.GetDaylightChanges (2019);
Console.WriteLine (day.Start.ToString ("M")); // 10 March
Console.WriteLine (day.End.ToString ("M")); // 03 November
Console.WriteLine (day.Delta); // 01:00:00
TimeZoneInfo
静态方法TimeZoneInfo.Local根据当前本地设置返回一个TimeZoneInfo对象。以下演示了在加利福尼亚运行时的结果:
TimeZoneInfo zone = TimeZoneInfo.Local;
Console.WriteLine (zone.StandardName); // Pacific Standard Time
Console.WriteLine (zone.DaylightName); // Pacific Daylight Time
IsDaylightSavingTime和GetUtcOffset方法的工作方式如下:
DateTime dt1 = new DateTime (2019, 1, 1); // DateTimeOffset works, too
DateTime dt2 = new DateTime (2019, 6, 1);
Console.WriteLine (zone.IsDaylightSavingTime (dt1)); // True
Console.WriteLine (zone.IsDaylightSavingTime (dt2)); // False
Console.WriteLine (zone.GetUtcOffset (dt1)); // -08:00:00
Console.WriteLine (zone.GetUtcOffset (dt2)); // -07:00:00
您可以通过调用FindSystemTimeZoneById并传入时区 ID 来获取世界任何时区的TimeZoneInfo。我们将因很快会变得清楚的原因而切换到西澳大利亚:
TimeZoneInfo wa = TimeZoneInfo.FindSystemTimeZoneById
("W. Australia Standard Time");
Console.WriteLine (wa.Id); // W. Australia Standard Time
Console.WriteLine (wa.DisplayName); // (GMT+08:00) Perth
Console.WriteLine (wa.BaseUtcOffset); // 08:00:00
Console.WriteLine (wa.SupportsDaylightSavingTime); // True
Id属性对应于传递给FindSystemTimeZoneById的值。静态的GetSystemTimeZones方法返回所有世界时区;因此,您可以列出所有有效的时区 ID 字符串如下:
foreach (TimeZoneInfo z in TimeZoneInfo.GetSystemTimeZones())
Console.WriteLine (z.Id);
注意
您还可以通过调用TimeZoneInfo.CreateCustomTimeZone创建自定义时区。由于TimeZoneInfo是不可变的,您必须将所有相关数据作为方法参数传入。
您可以通过调用ToSerializedString将预定义或自定义时区序列化为(半)人类可读的字符串,并通过调用TimeZoneInfo.FromSerializedString进行反序列化。
静态方法ConvertTime将DateTime或DateTimeOffset从一个时区转换到另一个时区。您可以只包括目标TimeZoneInfo,或同时包括源和目标TimeZoneInfo对象。您还可以使用ConvertTimeFromUtc和ConvertTimeToUtc方法直接与 UTC 进行转换。
为了处理夏令时,TimeZoneInfo提供以下额外方法:
-
如果
DateTime处于时钟前进时被跳过的一小时(或增量)内,则IsInvalidTime返回true。 -
如果
DateTime或DateTimeOffset处于时钟倒退时重复的一小时(或增量)内,则IsAmbiguousTime返回true。 -
GetAmbiguousTimeOffsets返回一个TimeSpan数组,表示模糊的DateTime或DateTimeOffset的有效偏移选择。
您无法从TimeZoneInfo获取夏令时开始和结束的简单日期。而是必须调用GetAdjustmentRules,该方法返回适用于所有年份的所有夏令时规则的声明性摘要。每个规则都有一个DateStart和DateEnd,指示规则有效的日期范围:
foreach (TimeZoneInfo.AdjustmentRule rule in wa.GetAdjustmentRules())
Console.WriteLine ("Rule: applies from " + rule.DateStart +
" to " + rule.DateEnd);
西澳大利亚于 2006 年首次引入夏令时,在赛季中期(然后在 2009 年撤销)。这需要在第一年制定特别规则;因此,存在两个规则:
Rule: applies from 1/01/2006 12:00:00 AM to 31/12/2006 12:00:00 AM
Rule: applies from 1/01/2007 12:00:00 AM to 31/12/2009 12:00:00 AM
每个AdjustmentRule都有一个DaylightDelta属性,类型为TimeSpan(几乎每种情况下为一小时),以及名为DaylightTransitionStart和DaylightTransitionEnd的属性。后两者的类型为TimeZoneInfo.TransitionTime,具有以下属性:
public bool IsFixedDateRule { get; }
public DayOfWeek DayOfWeek { get; }
public int Week { get; }
public int Day { get; }
public int Month { get; }
public DateTime TimeOfDay { get; }
过渡时间有点复杂,因为它需要同时表示固定日期和浮动日期。浮动日期的一个示例是“三月的最后一个星期日”。以下是解释过渡时间的规则:
-
如果对于结束过渡,
IsFixedDateRule为true,Day为1,Month为1,TimeOfDay为DateTime.MinValue,则在该年中没有夏令时的结束(这仅可能发生在南半球,在向该地区引入夏令时时)。 -
否则,如果
IsFixedDateRule为true,则Month、Day和TimeOfDay属性确定调整规则的开始或结束。 -
否则,如果
IsFixedDateRule为false,则Month、DayOfWeek、Week和TimeOfDay属性确定调整规则的开始或结束。
在最后一种情况下,Week指的是月中的第几周,“5”表示最后一周。我们可以通过枚举我们wa时区的调整规则来证明这一点:
foreach (TimeZoneInfo.AdjustmentRule rule in wa.GetAdjustmentRules())
{
Console.WriteLine ("Rule: applies from " + rule.DateStart +
" to " + rule.DateEnd);
Console.WriteLine (" Delta: " + rule.DaylightDelta);
Console.WriteLine (" Start: " + FormatTransitionTime
(rule.DaylightTransitionStart, false));
Console.WriteLine (" End: " + FormatTransitionTime
(rule.DaylightTransitionEnd, true));
Console.WriteLine();
}
在FormatTransitionTime中,我们遵循刚才描述的规则:
static string FormatTransitionTime (TimeZoneInfo.TransitionTime tt,
bool endTime)
{
if (endTime && tt.IsFixedDateRule
&& tt.Day == 1 && tt.Month == 1
&& tt.TimeOfDay == DateTime.MinValue)
return "-";
string s;
if (tt.IsFixedDateRule)
s = tt.Day.ToString();
else
s = "The " +
"first second third fourth last".Split() [tt.Week - 1] +
" " + tt.DayOfWeek + " in";
return s + " " + DateTimeFormatInfo.CurrentInfo.MonthNames [tt.Month-1]
+ " at " + tt.TimeOfDay.TimeOfDay;
}
夏令时和 DateTime
如果使用DateTimeOffset或 UTC DateTime,则等式比较不受夏令时影响的影响。但是对于本地的DateTime,夏令时可能会带来问题。
我们可以总结规则如下:
-
夏令时影响本地时间,但不影响 UTC 时间。
-
当时钟回拨时,依赖时间向前移动的比较会中断,但仅当它们使用本地
DateTime时。 -
您始终可以在 UTC 和本地时间之间(在同一台计算机上)可靠地往返(即使时钟回拨)。
IsDaylightSavingTime告诉您给定的本地DateTime是否适用于夏令时。UTC 时间始终返回false:
Console.Write (DateTime.Now.IsDaylightSavingTime()); // True or False
Console.Write (DateTime.UtcNow.IsDaylightSavingTime()); // Always False
假设dto是一个DateTimeOffset,则以下表达式也会产生相同效果:
dto.LocalDateTime.IsDaylightSavingTime
夏令时的结束对使用本地时间的算法提出了特定的复杂性问题,因为当时钟回拨时,同一小时(或更精确地说,Delta)会重复。
注意
您可以通过首先在每个DateTime上调用ToUniversalTime来可靠地比较任何两个DateTime。如果它们中正好有一个的DateTimeKind为Unspecified,则此策略会失败。这种失败的可能性是支持DateTimeOffset的另一个原因。
格式化和解析
格式化意味着将转换为字符串;解析意味着从字符串转换为。在编程中,经常需要进行格式化或解析,在各种情况下。因此,.NET 提供了多种机制:
ToString和Parse
这些方法为许多类型提供默认功能。
格式提供程序
这些表现为额外的ToString(和Parse)方法,这些方法接受格式字符串和/或格式提供程序。格式提供程序非常灵活且与文化相关。.NET 包含用于数字类型和DateTime/DateTimeOffset的格式提供程序。
XmlConvert
这是一个静态类,具有遵守 XML 标准的格式化和解析方法。XmlConvert在需要文化独立性或希望预防误解析时也非常有用。XmlConvert支持数字类型、bool、DateTime、DateTimeOffset、TimeSpan和Guid。
类型转换器
这些目标设计者和 XAML 解析器。
在本节中,我们讨论了前两种机制,特别关注格式提供者。然后我们描述了XmlConvert、类型转换器和其他转换机制。
ToString 和 Parse
最简单的格式化机制是ToString方法。它对所有简单值类型(bool、DateTime、DateTimeOffset、TimeSpan、Guid以及所有数值类型)都提供了有意义的输出。对于逆操作,每种类型都定义了一个静态的Parse方法:
string s = true.ToString(); // s = "True"
bool b = bool.Parse (s); // b = true
如果解析失败,将抛出FormatException。许多类型还定义了TryParse方法,如果转换失败,则返回false而不是抛出异常:
bool failure = int.TryParse ("qwerty", out int i1);
bool success = int.TryParse ("123", out int i2);
如果您不关心输出,并且只想测试解析是否成功,您可以使用抛弃:
bool success = int.TryParse ("123", out int _);
如果预期可能出错,调用TryParse比在异常处理块中调用Parse更快且更优雅。
在DateTime(Offset)和数值类型上,Parse和TryParse方法遵循本地文化设置;您可以通过指定CultureInfo对象来更改这一点。通常指定不变文化是个好主意。例如,在德国将“1.234”解析为double会得到 1234:
Console.WriteLine (double.Parse ("1.234")); // 1234 (In Germany)
这是因为在德国,句点表示千位分隔符而不是小数点。指定不变文化可以修复这个问题:
double x = double.Parse ("1.234", CultureInfo.InvariantCulture);
调用ToString()时情况也是如此:
string x = 1.234.ToString (CultureInfo.InvariantCulture);
注意
从.NET 8 开始,.NET 数字和日期/时间类型(以及其他简单类型)允许直接使用 UTF-8 进行格式化和解析,通过新的TryFormat和Parse/TryParse方法,这些方法操作字节数组或Span<byte>(见第二十三章)。在高性能场景中,这比使用普通(UTF-16)字符串并执行单独的 UTF-8 编码/解码更有效率。
格式化提供者
有时,您需要更多控制格式化和解析的方式。例如,有多种方法可以格式化DateTime(Offset)。格式提供者允许在格式化和解析方面进行广泛控制,并且支持数字类型和日期/时间。格式提供者也被用户界面控件用于格式化和解析。
使用格式提供者的入口是IFormattable。所有数值类型和DateTime(Offset)都实现了这个接口:
public interface IFormattable
{
string ToString (string format, IFormatProvider formatProvider);
}
第一个参数是格式字符串;第二个是格式提供者。格式字符串提供指令;格式提供者确定如何翻译这些指令。例如:
NumberFormatInfo f = new NumberFormatInfo();
f.CurrencySymbol = "$$";
Console.WriteLine (3.ToString ("C", f)); // $$ 3.00
在这里,"C" 是指示货币格式的格式字符串,而 NumberFormatInfo 对象是一个格式提供程序,决定如何呈现货币和其他数字表示。这种机制支持全球化。
注意
所有数字和日期的格式字符串都列在“标准格式字符串和解析标志”中。
如果指定了 null 格式字符串或提供程序,将应用默认设置。默认格式提供程序是 CultureInfo.CurrentCulture,除非重新分配,它反映计算机的运行时控制面板设置。例如,在这台计算机上:
Console.WriteLine (10.3.ToString ("C", null)); // $10.30
为方便起见,大多数类型都重载了 ToString 方法,这样您可以省略 null 提供程序:
Console.WriteLine (10.3.ToString ("C")); // $10.30
Console.WriteLine (10.3.ToString ("F4")); // 10.3000 (Fix to 4 D.P.)
在 DateTime(Offset)或无参数的数值类型上调用 ToString 等同于使用默认格式提供程序和空格式字符串。
.NET 定义了三种格式提供程序(它们都实现了 IFormatProvider 接口):
NumberFormatInfo
DateTimeFormatInfo
CultureInfo
注意
所有 enum 类型也都可格式化,尽管没有特殊的 IFormatProvider 类。
格式提供程序和 CultureInfo
在格式提供程序的上下文中,CultureInfo 作为其他两个格式提供程序的间接机制,返回适用于文化区域设置的 NumberFormatInfo 或 DateTimeFormatInfo 对象。
在下一个示例中,我们请求特定的文化(英国英语):
CultureInfo uk = CultureInfo.GetCultureInfo ("en-GB");
Console.WriteLine (3.ToString ("C", uk)); // £3.00
这使用适用于 en-GB 文化的默认 NumberFormatInfo 对象执行。
下一个示例使用不变文化格式化一个 DateTime。不变文化始终保持不变,不管计算机的设置如何:
DateTime dt = new DateTime (2000, 1, 2);
CultureInfo iv = CultureInfo.InvariantCulture;
Console.WriteLine (dt.ToString (iv)); // 01/02/2000 00:00:00
Console.WriteLine (dt.ToString ("d", iv)); // 01/02/2000
注意
不变文化基于美国文化,具有以下区别:
-
货币符号是 ☼ 而不是 $。
-
日期和时间格式化为带有前导零的格式(虽然月份仍然是第一个)。
-
时间使用 24 小时制而不是 AM/PM 指示符。
使用 NumberFormatInfo 或 DateTimeFormatInfo
在下一个示例中,我们实例化一个 NumberFormatInfo 并将组分隔符从逗号更改为空格。然后我们使用它将一个数字格式化为三位小数:
NumberFormatInfo f = new NumberFormatInfo ();
f.NumberGroupSeparator = " ";
Console.WriteLine (12345.6789.ToString ("N3", f)); // 12 345.679
NumberFormatInfo 或 DateTimeFormatInfo 的初始设置基于不变文化。然而,有时选择不同的起始点更有用。要做到这一点,您可以克隆一个现有的格式提供程序:
NumberFormatInfo f = (NumberFormatInfo)
CultureInfo.CurrentCulture.NumberFormat.Clone();
克隆的格式提供程序始终是可写的,即使原始格式提供程序是只读的。
复合格式化
复合格式字符串允许您将变量替换与格式字符串结合使用。静态方法 string.Format 接受一个复合格式字符串(我们在“String.Format 和复合格式字符串”中演示了这一点):
string composite = "Credit={0:C}";
Console.WriteLine (string.Format (composite, 500)); // Credit=$500.00
Console 类本身重载了其 Write 和 WriteLine 方法,以接受复合格式字符串,允许我们稍微简化此示例:
Console.WriteLine ("Credit={0:C}", 500); // Credit=$500.00
您还可以将复合格式字符串附加到StringBuilder(通过AppendFormat),以及用于 I/O 的TextWriter(参见第十五章)。
string.Format接受一个可选的格式提供程序。这个的简单应用就是对任意对象调用ToString并传入一个格式提供程序:
string s = string.Format (CultureInfo.InvariantCulture, "{0}", someObject);
这相当于以下内容:
string s;
if (someObject is IFormattable)
s = ((IFormattable)someObject).ToString (null,
CultureInfo.InvariantCulture);
else if (someObject == null)
s = "";
else
s = someObject.ToString();
使用格式提供程序进行解析
没有用于遍历格式提供程序的标准接口。相反,每个参与的类型都会重载其静态Parse(和TryParse)方法以接受格式提供程序,以及可选的NumberStyles或DateTimeStyles枚举。
NumberStyles和DateTimeStyles控制解析的方式:它们允许您指定诸如输入字符串中是否可以出现括号或货币符号等内容。 (默认情况下,对这两个问题的答案都是否。)例如:
int error = int.Parse ("(2)"); // Exception thrown
int minusTwo = int.Parse ("(2)", NumberStyles.Integer |
NumberStyles.AllowParentheses); // OK
decimal fivePointTwo = decimal.Parse ("£5.20", NumberStyles.Currency,
CultureInfo.GetCultureInfo ("en-GB"));
下一节列出了所有NumberStyles和DateTimeStyles成员以及每种类型的默认解析规则。
IFormatProvider 和 ICustomFormatter
所有格式提供程序都实现了IFormatProvider:
public interface IFormatProvider { object GetFormat (Type formatType); }
此方法的目的是提供间接性——这使得CultureInfo可以推迟到适当的NumberFormatInfo或DateTimeFormatInfo对象来完成工作。
通过实现IFormatProvider和ICustomFormatter,您还可以编写自己的格式提供程序,与现有类型一起使用。ICustomFormatter定义了一个方法,如下所示:
string Format (string format, object arg, IFormatProvider formatProvider);
下面的自定义格式提供程序将数字写成单词:
public class WordyFormatProvider : IFormatProvider, ICustomFormatter
{
static readonly string[] _numberWords =
"zero one two three four five six seven eight nine minus point".Split();
IFormatProvider _parent; // Allows consumers to chain format providers
public WordyFormatProvider () : this (CultureInfo.CurrentCulture) { }
public WordyFormatProvider (IFormatProvider parent) => _parent = parent;
public object GetFormat (Type formatType)
{
if (formatType == typeof (ICustomFormatter)) return this;
return null;
}
public string Format (string format, object arg, IFormatProvider prov)
{
// If it's not our format string, defer to the parent provider:
if (arg == null || format != "W")
return string.Format (_parent, "{0:" + format + "}", arg);
StringBuilder result = new StringBuilder();
string digitList = string.Format (CultureInfo.InvariantCulture,
"{0}", arg);
foreach (char digit in digitList)
{
int i = "0123456789-.".IndexOf (digit,
StringComparison.InvariantCulture);
if (i == -1) continue;
if (result.Length > 0) result.Append (' ');
result.Append (_numberWords[i]);
}
return result.ToString();
}
}
注意,在Format方法中,我们使用了string.Format——并使用了InvariantCulture——来将输入的数字转换为字符串。只需调用arg.ToString()会更简单,但那样会使用CurrentCulture。稍后几行代码之后需要使用不变文化的原因很明显:
int i = "0123456789-.".IndexOf (digit, StringComparison.InvariantCulture);
这里至关重要的是,数字字符串仅包含字符0123456789-.,而不包含任何这些字符的国际化版本。
这是使用WordyFormatProvider的示例:
double n = -123.45;
IFormatProvider fp = new WordyFormatProvider();
Console.WriteLine (string.Format (fp, "{0:C} in words is {0:W}", n));
// -$123.45 in words is minus one two three point four five
您只能在复合格式字符串中使用自定义格式提供程序。
标准格式字符串和解析标志
标准格式字符串控制如何将数字类型或DateTime/DateTimeOffset转换为字符串。有两种类型的格式字符串:
标准格式字符串
使用这些,您可以提供一般性指导。标准格式字符串由单个字母组成,后面可以跟一个数字(其含义取决于字母)。例如"C"或"F2"。
自定义格式字符串
使用这些模板,您可以微观管理每个字符。例如"0:#.000E+00"。
自定义格式字符串与自定义格式提供程序无关。
数字格式字符串
表 6-2 列出了所有标准数字格式字符串。
表 6-2. 标准数字格式字符串
| 字母 | 含义 | 示例输入 | 结果 | 注释 |
|---|
| G 或 g | “一般” | 1.2345, "G" 0.00001, "G"
0.00001, "g"
1.2345, "G3"
12345, "G3" | 1.2345 1E-05
1e-05
1.23
1.23E04 | 切换为小数点表示法以处理小数或大数。G3限制总体精度为三位数(小数点前后之和)。 |
F |
固定小数点 | 2345.678, "F2" 2345.6, "F2" |
2345.68 2345.60 |
F2四舍五入至小数点后两位。 |
|---|---|---|---|---|
N |
数值,带有分组分隔符的固定小数点 | 2345.678, "N2" 2345.6, "N2" |
2,345.68 2,345.60 |
如上,带有分组(千分位)分隔符(详细信息来自格式提供者)。 |
D |
填充前导零 | 123, "D5" 123, "D1" |
00123 123 |
仅适用于整数类型。D5填充左边至五位数,不截断。 |
| E 或 e | 强制指数表示法 | 56789, "E" 56789, "e"
56789, "E2" | 5.678900E+004 5.678900e+004
5.68E+004 | 六位默认精度。 |
C |
货币 | 1.2, "C" 1.2, "C4" |
$1.20 $1.2000 |
不带数字的C使用格式提供者的默认小数位数。 |
|---|---|---|---|---|
P |
百分比 | .503, "P" .503, "P0" |
50.30% 50% |
使用符号和布局来自格式提供者。小数位数可以选择性地被覆盖。 |
| X 或 x | 十六进制 | 47, "X" 47, "x"
47, "X4" | 2F 2f
002F | 大写十六进制数字的X;小写十六进制数字的x。仅适用于整数。 |
R 或 G9/G17 |
循环转换 | 1f / 3f, "R" |
0.3333333**43** |
使用R表示BigInteger,G17表示double,或G9表示float。 |
|---|
不提供数字格式字符串(或空字符串)等效于使用"G"标准格式字符串,其后不跟数字。表现如下:
-
数字小于 10^(−4)或大于类型精度的数字在指数(科学)表示法中表达。
-
浮点数或双精度的两位小数位在极限处被四舍五入,以掩盖从其底层二进制形式到十进制的转换中固有的不准确性。
注
刚才描述的自动四舍五入通常是有益的并且不易察觉的。然而,如果需要循环转换数字——也就是说,将其转换为字符串然后再转回去(可能是重复多次),这可能会引发问题。因此,存在R、G17和G9格式字符串以避免这种隐式四舍五入。
表 6-3 列出了自定义数字格式字符串。
表 6-3. 自定义数字格式字符串
| 指示符 | 含义 | 示例输入 | 结果 | 注释 |
|---|---|---|---|---|
# |
数字占位符 | 12.345, ".##" 12.345, ".####" |
12.35 12.345 |
限制小数点后的数字位数。 |
| 0 | 零占位符 | 12.345, ".00" 12.345, ".0000"
99, "000.00" | 12.35 12.3450
099.00 | 如上,但还在小数点前后填充零。 |
. |
小数点 | 表示小数点。实际符号来自NumberFormatInfo。 |
||
|---|---|---|---|---|
, |
分组分隔符 | 1234, "#,###,###" 1234, "0,000,000" |
1,234 0,001,234 |
符号来自NumberFormatInfo。 |
,(如上所示) |
倍数器 | 1000000, "#," 1000000, "#,, |
1000 1 |
如果逗号位于小数点之前或之后,则作为乘法器—将结果除以 1,000、1,000,000 等。 |
% |
百分比表示 | 0.6, "00%" |
60% |
首先乘以 100,然后用从NumberFormatInfo获取的百分号替换。 |
| E0, e0, E+0, e+0 E-0, e-0 | 指数表示 | 1234, "0E0" 1234, "0E+0"
1234, "0.00E00"
1234, "0.00e00" | 1E3 1E+3
1.23E03
1.23e03 | |
\ |
字面字符引用 | 50, @"\#0" |
#50 |
与字符串上的@前缀一起使用或使用\\ |
|---|---|---|---|---|
'xx''xx' |
字面字符串引用 | 50, "0 '...'" |
50 ... |
|
; |
分节符 | 15, "#;(#);zero" |
15 |
(如果为正) |
-5, "#;(#);zero" |
(5) |
(如果为负) | ||
0, "#;(#);zero" |
zero |
(如果为零) | ||
| 任何其他字符 | 字面 | 35.2, "$0 . 00c" | $35 . 20c |
NumberStyles
每种数值类型都定义了一个接受NumberStyles参数的静态Parse方法。NumberStyles是一个标志枚举,允许您确定将字符串转换为数值类型时如何读取它。它具有以下可组合的成员:
AllowLeadingWhite AllowTrailingWhite
AllowLeadingSign AllowTrailingSign
AllowParentheses AllowDecimalPoint
AllowThousands AllowExponent
AllowCurrencySymbol AllowHexSpecifier
NumberStyles还定义了以下复合成员:
None Integer Float Number HexNumber Currency Any
除了None,所有复合值均包括AllowLeadingWhite和AllowTrailingWhite。图 6-1 显示其余的组成部分,最有用的三个部分被强调。

图 6-1. 复合 NumberStyles
当您调用Parse而不指定任何标志时,将应用图 6-2 中说明的默认值。

图 6-2. 数值类型的默认解析标志
如果您不希望显示在图 6-2 中显示的默认值,则必须显式指定NumberStyles:
int thousand = int.Parse ("3E8", NumberStyles.HexNumber);
int minusTwo = int.Parse ("(2)", NumberStyles.Integer |
NumberStyles.AllowParentheses);
double aMillion = double.Parse ("1,000,000", NumberStyles.Any);
decimal threeMillion = decimal.Parse ("3e6", NumberStyles.Any);
decimal fivePointTwo = decimal.Parse ("$5.20", NumberStyles.Currency);
因为我们没有指定格式提供程序,所以这个示例将与您的本地货币符号、分组分隔符、小数点等一起工作。下一个示例硬编码为与欧元符号和空白分组分隔符一起工作:
NumberFormatInfo ni = new NumberFormatInfo();
ni.CurrencySymbol = "€";
ni.CurrencyGroupSeparator = " ";
double million = double.Parse ("€1 000 000", NumberStyles.Currency, ni);
日期/时间格式字符串
DateTime/DateTimeOffset的格式字符串可以根据是否遵循文化和格式提供程序设置分为两组。表 6-4 列出了遵循的那些;表 6-5 列出了不遵循的那些。样本输出来自格式化以下DateTime(使用不变文化,在表 6-4 的情况下):
new DateTime (2000, 1, 2, 17, 18, 19);
表 6-4. 文化敏感的日期/时间格式字符串
| 格式字符串 | 含义 | 样本输出 |
|---|---|---|
d |
短日期 | 2000 年 1 月 2 日 |
D |
长日期 | 2000 年 1 月 2 日 星期日 |
t |
短时间 | 17:18 |
T |
长时间 | 17:18:19 |
f |
长日期 + 短时间 | 2000 年 1 月 2 日 星期日 17:18 |
F |
长日期 + 长时间 | 2000 年 1 月 2 日 星期日 17:18:19 |
g |
短日期 + 短时间 | 2000/01/02 17:18 |
G(默认) |
短日期 + 长时间 | 2000/01/02 17:18:19 |
m, M |
月和日 | 1 月 2 日 |
y, Y |
年和月 | 2000 年 1 月 |
表 6-5. 与文化无关的日期/时间格式字符串
| 格式字符串 | 含义 | 示例输出 | 备注 |
|---|---|---|---|
o |
回溯 | 2000-01-02T17:18:19.0000000 |
除非DateTimeKind是Unspecified,否则将附加时区信息 |
r, R |
RFC 1123 标准 | 2000 年 1 月 2 日 星期日 17:18:19 GMT |
您必须使用DateTime.ToUniversalTime显式转换为 UTC |
s |
可排序;ISO 8601 | 2000-01-02T17:18:19 |
与基于文本的排序兼容 |
u |
“通用”可排序 | 2000-01-02 17:18:19Z |
与上述类似;必须显式转换为 UTC |
U |
UTC | 2000 年 1 月 2 日 星期日 17:18:19 |
长日期 + 短时间,转换为 UTC |
格式字符串"r"、"R"和"u"会生成一个暗示 UTC 的后缀;然而,它们不会自动将本地时间转换为 UTC DateTime(因此您必须自行进行转换)。具有讽刺意味的是,"U"会自动转换为 UTC,但不会写入时区后缀!实际上,"o"是该组中唯一可以在不需要干预的情况下编写清晰的DateTime的格式说明符。
DateTimeFormatInfo也支持自定义格式字符串:这些类似于数字自定义格式字符串。列表非常广泛,并且可以在微软的文档中在线获取。以下是一个自定义格式字符串的示例:
yyyy-MM-dd HH:mm:ss
解析和误解析日期时间
将月份或日期放在前面的字符串是模棱两可的,很容易被误解析,特别是对全球客户而言。这在用户界面控件中不是问题,因为在解析时与格式化时使用相同的设置。但是在写入文件时,例如,日期/月份误解析可能会是一个真正的问题。有两个解决方案:
-
在格式化和解析时始终指定相同的显式文化(例如,不变文化)。
-
以独立于文化的方式格式化
DateTime和DateTimeOffset。
第二种方法更为健壮,特别是如果选择一个将四位数年份放在首位的格式,这样的字符串更难以被其他方误解析。此外,采用符合标准的年份优先格式(如"o")格式化的字符串可以与本地格式化的字符串正确解析,就像“全球供应者”一样。(使用"s"或"u"格式化的日期还具有可排序的进一步好处。)
举例说明,假设我们生成一个与文化无关的DateTime字符串s如下:
string s = DateTime.Now.ToString ("o");
注意
"o"格式字符串在输出中包含毫秒。以下自定义格式字符串与"o"给出相同结果,但不包含毫秒:
yyyy-MM-ddTHH:mm:ss K
我们可以以两种方式重新解析这个。ParseExact 要求严格遵守指定的格式字符串:
DateTime dt1 = DateTime.ParseExact (s, "o", null);
(您可以使用 XmlConvert 的 ToString 和 ToDateTime 方法实现类似的结果。)
然而,Parse 隐式接受 "o" 格式和 CurrentCulture 格式:
DateTime dt2 = DateTime.Parse (s);
这适用于 DateTime 和 DateTimeOffset。
注意
如果你知道正在解析的字符串的格式,通常最好使用 ParseExact。这意味着如果字符串格式不正确,将会抛出异常,这通常比冒险误解析日期要好。
DateTimeStyles
DateTimeStyles 是一个标志枚举,在调用 DateTime(Offset)的 Parse 时提供额外的指令。以下是它的成员:
None,
AllowLeadingWhite, AllowTrailingWhite, AllowInnerWhite,
AssumeLocal, AssumeUniversal, AdjustToUniversal,
NoCurrentDateDefault, RoundTripKind
还有一个复合成员,AllowWhiteSpaces:
AllowWhiteSpaces = AllowLeadingWhite | AllowTrailingWhite | AllowInnerWhite
默认是 None。这意味着通常禁止额外的空白(标准 DateTime 模式中的空白除外)。
如果字符串没有时区后缀(例如 Z 或 +9:00),则 AssumeLocal 和 AssumeUniversal 会应用。AdjustToUniversal 仍然遵循时区后缀,但是使用当前区域设置转换为 UTC。
如果解析的字符串只包含时间而没有日期,则默认应用今天的日期。然而,如果应用了 NoCurrentDateDefault 标志,则使用 0001 年 1 月 1 日。
枚举格式字符串
在“Enums”中,我们描述了枚举值的格式化和解析。表 6-6 列出了每个格式字符串及其应用于以下表达式的结果:
Console.WriteLine (System.ConsoleColor.Red.ToString (formatString));
表 6-6. 枚举格式字符串
| 格式字符串 | 含义 | 示例输出 | 注释 |
|---|---|---|---|
G 或 g |
“通用” | Red |
默认 |
F 或 f |
如同存在 Flags 属性一样处理 |
Red |
即使 enum 没有 Flags 属性,也适用于组合成员 |
D 或 d |
十进制值 | 12 |
检索基础整数值 |
X 或 x |
十六进制值 | 0000000C |
检索基础整数值 |
其他转换机制
在前两节中,我们讨论了格式提供程序——.NET 的主要格式化和解析机制。其他重要的转换机制散布在各种类型和命名空间中。一些转换成和从 string,而一些进行其他类型的转换。在本节中,我们讨论以下主题:
-
Convert类及其函数:-
实数到整数的转换,而不是截断
-
二进制、八进制和十六进制中的数字解析
-
动态转换
-
Base-64 翻译
-
-
XmlConvert及其在 XML 格式化和解析中的角色 -
类型转换器及其在设计师和 XAML 格式化和解析中的角色
-
BitConverter,用于二进制转换
Convert
.NET 将以下类型称为基本类型:
-
bool、char、string、System.DateTime和System.DateTimeOffset -
所有的 C# 数字类型
静态的Convert类定义了将每个基本类型转换为其他每个基本类型的方法。不幸的是,大多数这些方法是无用的:它们要么抛出异常,要么与隐式转换并列。然而,在这些混乱之中,也有一些有用的方法,列在以下各节中。
注意
所有基本类型(显式地)实现了IConvertible接口,该接口定义了转换为其他所有基本类型的方法。在大多数情况下,这些方法的实现只是调用了Convert中的一个方法。在罕见情况下,编写一个接受IConvertible类型参数的方法可能会很有用。
将实数四舍五入到整数的转换
在第二章中,我们看到了隐式和显式转换如何允许您在数值类型之间进行转换。总结如下:
-
隐式转换适用于非损失转换(例如,
int到double)。 -
对于损失转换(例如,
double到int),需要显式强制转换。
强制转换被优化以提高效率;因此,它们会截断无法容纳的数据。当从实数转换为整数时,这可能会成为问题,因为通常希望四舍五入而不是截断。Convert的数值转换方法正好解决了这个问题——它们总是四舍五入:
double d = 3.9;
int i = Convert.ToInt32 (d); // i == 4
Convert使用银行家舍入,将中间值舍入为偶数(避免正负偏差)。如果银行家舍入成为问题,首先在实数上调用Math.Round:它接受一个额外的参数,允许您控制中间值的舍入。
在二进制 2、8 和 16 的基础上解析数字
在To(*integral-type*)方法中隐藏着一些重载,用于在另一种基数中解析数字:
int thirty = Convert.ToInt32 ("1E", 16); // Parse in hexadecimal
uint five = Convert.ToUInt32 ("101", 2); // Parse in binary
第二个参数指定了基数。它可以是您喜欢的任何基数——只要是 2、8、10 或 16!
动态转换
偶尔需要从一种类型转换为另一种类型,但直到运行时才知道类型。对此,Convert类提供了一个ChangeType方法:
public static object ChangeType (object value, Type conversionType);
源类型和目标类型必须是“基本”类型之一。ChangeType还接受一个可选的IFormatProvider参数。以下是一个示例:
Type targetType = typeof (int);
object source = "42";
object result = Convert.ChangeType (source, targetType);
Console.WriteLine (result); // 42
Console.WriteLine (result.GetType()); // System.Int32
一个例子是在编写可以处理多种类型的反序列化器时可能会有用。它还可以将任何枚举类型转换为其整数类型(参见“枚举”)。
ChangeType的一个限制是您无法指定格式字符串或解析标志。
Base-64 转换
有时,您需要在文本文档(如 XML 文件或电子邮件消息)中包含二进制数据,例如位图。Base 64 是一种将二进制数据编码为可读字符的普遍方式,使用 ASCII 集的 64 个字符。
Convert的ToBase64String方法将字节数组转换为 Base 64;FromBase64String则执行相反操作。
XmlConvert
如果处理来自或去向 XML 文件的数据,XmlConvert(位于 System.Xml 命名空间中)提供了最适合的格式化和解析方法。XmlConvert 中的方法处理 XML 格式的细微差别,无需特殊的格式字符串。例如,XML 中的 true 是 "true" 而不是 "True"。.NET BCL 内部广泛使用 XmlConvert。XmlConvert 也非常适合通用、与文化无关的序列化。
XmlConvert 中的格式化方法都作为重载的 ToString 方法提供;解析方法称为 ToBoolean、ToDateTime 等:
string s = XmlConvert.ToString (true); // s = "true"
bool isTrue = XmlConvert.ToBoolean (s);
转换至和从 DateTime 的方法接受一个 XmlDateTimeSerializationMode 参数。这是一个枚举,包含以下值:
Unspecified, Local, Utc, RoundtripKind
Local 和 Utc 在格式化时引发转换(如果 DateTime 尚未处于该时区)。然后将时区附加到字符串后面:
2010-02-22T14:08:30.9375 // Unspecified
2010-02-22T14:07:30.9375+09:00 // Local
2010-02-22T05:08:30.9375Z // Utc
Unspecified 在格式化之前去除嵌入在 DateTime 中的任何时区信息(即 DateTimeKind)。RoundtripKind 尊重 DateTime 的 DateTimeKind ——因此,当重新解析时,结果的 DateTime 结构将与最初的完全一致。
类型转换器
类型转换器旨在设计时环境中格式化和解析。它们还解析 Extensible Application Markup Language (XAML) 文档中的值——在 Windows Presentation Foundation (WPF) 中使用。
在 .NET 中,有超过 100 种类型转换器——涵盖颜色、图像和 URI 等内容。相比之下,格式提供程序仅针对少数简单值类型实现。
类型转换器通常以多种方式解析字符串——无需提示。例如,在 Visual Studio 的 WPF 应用程序中,如果将控件的背景颜色设为属性窗口中的 "Beige",Color 类型转换器会识别出您指的是颜色名称,而不是 RGB 字符串或系统颜色。这种灵活性有时会使得类型转换器在设计师和 XAML 文档之外的环境中也很有用。
所有类型转换器都是 System.ComponentModel 中 TypeConverter 的子类。要获取 TypeConverter,请调用 TypeDescriptor.GetConverter。以下是获取 Color 类型(位于 System.Drawing 命名空间中)的 TypeConverter 的示例:
TypeConverter cc = TypeDescriptor.GetConverter (typeof (Color));
TypeConverter 定义了诸多方法,例如 ConvertToString 和 ConvertFromString。我们可以这样调用:
Color beige = (Color) cc.ConvertFromString ("Beige");
Color purple = (Color) cc.ConvertFromString ("#800080");
Color window = (Color) cc.ConvertFromString ("Window");
按照惯例,类型转换器的名称以 Converter 结尾,通常与其转换的类型位于同一命名空间。通过 TypeConverterAttribute,类型链接到其转换器,允许设计者自动选择转换器。
类型转换器还可以提供设计时服务,例如生成标准值列表,以便在设计器中填充下拉列表或帮助代码序列化。
BitConverter
大多数基本类型都可以通过调用 BitConverter.GetBytes 转换为字节数组:
foreach (byte b in BitConverter.GetBytes (3.5))
Console.Write (b + " "); // 0 0 0 0 0 0 12 64
BitConverter 还提供了方法,如 ToDouble,用于反向转换。
BitConverter 不支持 decimal 和 DateTime(Offset) 类型。但是,你可以通过调用 decimal.GetBits 将 decimal 转换为 int 数组。反向转换时,decimal 提供了接受 int 数组的构造函数。
对于 DateTime,你可以在实例上调用 ToBinary 方法 —— 这将返回一个 long(之后可以使用 BitConverter)。静态的 DateTime.FromBinary 方法执行反向操作。
全球化
将应用程序 国际化 的两个方面是 全球化 和 本地化。
全球化 关注三个任务(按重要性递减排序):
-
确保你的程序在运行在其他文化环境时不会 中断。
-
尊重本地文化的格式化规则,例如在显示日期时。
-
设计你的程序,使其能够从后续编写和部署的卫星程序集中获取特定文化的数据和字符串。
本地化 意味着通过为特定文化编写卫星程序集来完成最后一个任务。你可以在编写程序之后进行此操作(我们在 “资源和卫星程序集” 中详细介绍)。
.NET 默认应用文化特定规则帮助你完成第二项任务。我们已经看到,在 DateTime 或数字上调用 ToString 会尊重本地格式化规则。不幸的是,这使得在期望日期或数字按照假定的文化格式化时易于失败并导致程序中断。正如我们所见,解决方法要么是在格式化和解析时指定文化(如不变文化),要么使用像 XmlConvert 中那样的文化独立方法。
全球化检查表
我们已经在本章中涵盖了重要的要点。以下是所需工作的要点总结:
-
了解 Unicode 和文本编码(参见 “文本编码和 Unicode”)。
-
请注意,对于
char和string上的ToUpper和ToLower方法是具有文化敏感性的:除非你需要文化敏感性,否则请使用ToUpperInvariant/ToLowerInvariant。 -
偏好独立于文化的日期时间格式化和解析机制,如
ToString("o")和XmlConvert,对于DateTime和DateTimeOffset类型。 -
否则,在格式化/解析数字或日期/时间时请指定文化(除非你 希望 使用本地文化行为)。
测试
通过重新分配 Thread 的 CurrentCulture 属性(位于 System.Threading 中),你可以针对不同的文化进行测试。以下更改当前文化为土耳其:
Thread.CurrentThread.CurrentCulture = CultureInfo.GetCultureInfo ("tr-TR");
土耳其是一个特别好的测试案例,因为:
-
"i".ToUpper() != "I"和"I".ToLower() != "i"。 -
日期以天.月.年格式化(注意点号分隔符)。
-
十进制点指示符为逗号而非句点。
你还可以通过更改 Windows 控制面板中的数字和日期格式设置来进行实验:这些会反映在默认文化 (CultureInfo.CurrentCulture) 中。
CultureInfo.GetCultures() 返回所有可用文化的数组。
注意
Thread 和 CultureInfo 还支持 CurrentUICulture 属性。这更多关注本地化,我们在 第十七章 中会讨论。
处理数字
转换
我们在前几章节中已经讨论了数值转换;表 6-7 总结了所有选项。
表 6-7. 数值转换摘要
| 任务 | 函数 | 示例 |
|---|
| 解析十进制数字 | Parse TryParse | double d = double.Parse ("3.5"); int i;
bool ok = int.TryParse ("3", out i); |
| 解析二进制、八进制或十六进制 | Convert.ToIntegral |
int i = Convert.ToInt32 ("1E", 16); |
|---|---|---|
| 格式化为十六进制 | ToString ("X") |
string hex = 45.ToString ("X"); |
| 无损数值转换 | 隐式类型转换 | int i = 23; double d = i; |
| 截断数值转换 | 显式类型转换 | double d = 23.5; int i = (int) d; |
| 截断 数值转换(实数到整数) | Convert.ToIntegral |
double d = 23.5; int i = Convert.ToInt32 (d); |
Math
表 6-8 列出了静态 Math 类的关键成员。三角函数接受 double 类型的参数;其他方法如 Max 已重载以操作所有数值类型。Math 类还定义了数学常数 E(e)和 PI。
表 6-8. 静态 Math 类中的方法
| 分类 | 方法 |
|---|---|
| 四舍五入 | Round, Truncate, Floor, Ceiling |
| 最大值/最小值 | Max, Min |
| 绝对值和符号 | Abs, Sign |
| 平方根 | Sqrt |
| 求幂 | Pow, Exp |
| 对数 | Log, Log10 |
| 三角函数 | Sin, Cos, Tan, Sinh, Cosh, Tanh,
Asin, Acos, Atan |
Round 方法允许你指定小数点后的位数以及如何处理中点(远离零或者使用银行家舍入法)。Floor 和 Ceiling 四舍五入到最近的整数:Floor 总是向下舍入,Ceiling 总是向上舍入,即使是负数也是如此。
Max 和 Min 只接受两个参数。如果你有一个数组或者数列,可以使用 System.Linq.Enumerable 中的 Max 和 Min 扩展方法。
BigInteger
BigInteger 结构体是一种专门的数值类型。它位于 System.Numerics 命名空间中,允许你表示任意大的整数而不会丢失精度。
C# 并不原生支持 BigInteger,因此没有办法表示 BigInteger 字面量。不过,你可以从任何其他整数类型隐式转换为 BigInteger:
BigInteger twentyFive = 25; // implicit conversion from integer
要表示更大的数字,例如一万亿(10¹⁰⁰),可以使用BigInteger的静态方法之一,如Pow(乘方):
BigInteger googol = BigInteger.Pow (10, 100);
或者,你可以通过Parse解析一个字符串:
BigInteger googol = BigInteger.Parse ("1".PadRight (101, '0'));
对其调用ToString()会打印每个数字:
Console.WriteLine (googol.ToString()); // 10000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000
你可以通过使用显式转换运算符在BigInteger和标准数值类型之间进行潜在的损失转换:
double g2 = (double) googol; // Explicit cast
BigInteger g3 = (BigInteger) g2; // Explicit cast
Console.WriteLine (g3);
这样输出显示了精度的损失:
9999999999999999673361688041166912...
BigInteger重载了所有算术运算符,包括余数(%)以及比较和相等运算符。
你也可以从字节数组构造一个BigInteger。以下代码生成一个适用于密码学的 32 字节随机数,然后将其分配给BigInteger:
// This uses the System.Security.Cryptography namespace:
RandomNumberGenerator rand = RandomNumberGenerator.Create();
byte[] bytes = new byte [32];
rand.GetBytes (bytes);
var bigRandomNumber = new BigInteger (bytes); // Convert to BigInteger
将这样的数存储在BigInteger中的优点是获得值类型语义。调用ToByteArray将BigInteger转换回字节数组。
Half
Half结构体是一个 16 位浮点类型,并且在.NET 5 中引入。Half主要用于与图形处理器的互操作,大多数 CPU 不原生支持。
你可以通过显式转换在Half和float或double之间进行转换:
Half h = (Half) 123.456;
Console.WriteLine (h); // 123.44 (note loss of precision)
此类型未定义算术运算,因此必须转换为另一种类型,如float或double,才能执行计算。
Half的范围是从-65500 到 65500:
Console.WriteLine (Half.MinValue); // -65500
Console.WriteLine (Half.MaxValue); // 65500
注意在最大范围时的精度损失:
Console.WriteLine ((Half)65500); // 65500
Console.WriteLine ((Half)65490); // 65500
Console.WriteLine ((Half)65480); // 65470
复数
Complex结构体是另一种专门的数值类型,表示具有double类型的实部和虚部的复数。Complex位于(以及BigInteger)命名空间中。
要使用Complex,实例化结构体,指定实部和虚部值:
var c1 = new Complex (2, 3.5);
var c2 = new Complex (3, 0);
也有从标准数值类型的隐式转换。
Complex结构体公开了用于实部和虚部以及相位和幅度的属性:
Console.WriteLine (c1.Real); // 2
Console.WriteLine (c1.Imaginary); // 3.5
Console.WriteLine (c1.Phase); // 1.05165021254837
Console.WriteLine (c1.Magnitude); // 4.03112887414927
你还可以通过指定幅度和相位来构造Complex数:
Complex c3 = Complex.FromPolarCoordinates (1.3, 5);
标准算术运算符被重载以在Complex数上工作:
Console.WriteLine (c1 + c2); // (5, 3.5)
Console.WriteLine (c1 * c2); // (6, 10.5)
Complex结构体公开了静态方法,用于更高级的函数,包括以下内容:
-
三角函数(
Sin,Asin,Sinh,Tan等) -
对数和指数
-
Conjugate
Random
Random类生成伪随机序列的随机byte,integer或double。
要使用Random,首先实例化它,可选择提供种子来启动随机数序列。使用相同的种子保证了相同的数列(如果在相同的 CLR 版本下运行),有时在需要可再现性时很有用。
Random r1 = new Random (1);
Random r2 = new Random (1);
Console.WriteLine (r1.Next (100) + ", " + r1.Next (100)); // 24, 11
Console.WriteLine (r2.Next (100) + ", " + r2.Next (100)); // 24, 11
如果不想要可再现性,可以使用无种子构造Random;在这种情况下,它使用当前系统时间生成一个种子。
警告
因为系统时钟的精度有限,两个在短时间内(通常在 10 毫秒内)创建的 Random 实例将产生相同的值序列。一个常见的陷阱是每次需要随机数时实例化一个新的 Random 对象,而不是重用同一个对象。
一个好的模式是声明单个静态的 Random 实例。然而,在多线程场景中,这可能会引起麻烦,因为 Random 对象不是线程安全的。我们在“线程本地存储”中描述了一种解决方法。
调用 Next(*n*) 生成介于 0 和 *n*−1 之间的随机整数。NextDouble 生成介于 0 和 1 之间的随机 double。NextBytes 用随机值填充字节数组。
从 .NET 8 开始,Random 类包括一个 GetItems 方法,从集合中随机选择 n 个项目。以下代码从五个项目的集合中选择两个随机数:
int[] numbers = { 10, 20, 30, 40, 50 };
int[] randomTwo = new Random().GetItems (numbers, 2);
从 .NET 8 开始,还有一个 Shuffle 方法,用于在数组或 span 中随机化项目的顺序。
Random 不被认为对于密码学等高安全性应用程序足够随机。为此,.NET 在 System.Security.Cryptography 命名空间中提供了密码强度随机数生成器。以下是如何使用它:
var rand = System.Security.Cryptography.RandomNumberGenerator.Create();
byte[] bytes = new byte [32];
rand.GetBytes (bytes); // Fill the byte array with random numbers.
缺点在于它的灵活性较差:填充字节数组是获取随机数的唯一手段。要获得整数,必须使用 BitConverter:
byte[] bytes = new byte [4];
rand.GetBytes (bytes);
int i = BitConverter.ToInt32 (bytes, 0);
BitOperations
System.Numerics.BitOperations 类(来自 .NET 6)公开了以下方法,以帮助进行基数为 2 的操作:
IsPow2
如果一个数字是 2 的幂,则返回 true
LeadingZeroCount/TrailingZeroCount
返回前导零的数量,以 32 位或 64 位无符号整数的二进制格式
Log2
返回无符号整数的基数为 2 的对数
PopCount
返回无符号整数中设置为 1 的位数
RotateLeft/RotateRight
执行按位左/右旋转
RoundUpToPowerOf2
将无符号整数四舍五入到最接近的 2 的幂
枚举
在第三章,我们描述了 C# 的枚举类型,并展示了如何组合成员、测试相等性、使用逻辑运算符和执行转换。.NET 通过 System.Enum 类型扩展了对 C# 枚举的支持。这个类型有两个角色:
-
为所有
enum类型提供类型统一 -
定义静态实用方法
类型统一意味着你可以隐式地将任何枚举成员转换为 System.Enum 实例:
Display (Nut.Macadamia); // Nut.Macadamia
Display (Size.Large); // Size.Large
void Display (Enum value)
{
Console.WriteLine (value.GetType().Name + "." + value.ToString());
}
enum Nut { Walnut, Hazelnut, Macadamia }
enum Size { Small, Medium, Large }
System.Enum 上的静态实用方法主要与执行转换和获取成员列表相关。
枚举转换
有三种方法来表示枚举值:
-
作为
enum成员 -
作为其底层整数值
-
作为一个字符串
在本节中,我们描述了如何在它们之间进行转换。
枚举到整数转换
请记住,显式转换在 enum 成员与其整数值之间进行转换。如果在编译时知道 enum 类型,则显式转换是正确的方法:
[Flags]
public enum BorderSides { Left=1, Right=2, Top=4, Bottom=8 }
...
int i = (int) BorderSides.Top; // i == 4
BorderSides side = (BorderSides) i; // side == BorderSides.Top
您可以以相同的方式将System.Enum实例强制转换为其整数类型。诀窍是首先将其转换为object,然后再转换为整数类型:
static int GetIntegralValue (Enum anyEnum)
{
return (int) (object) anyEnum;
}
这依赖于您知道整数类型:如果传递的枚举的整数类型是long,刚刚编写的方法将崩溃。要编写一个适用于任何整数类型枚举的方法,您可以采取以下三种方法之一。第一种是调用Convert.ToDecimal:
static decimal GetAnyIntegralValue (Enum anyEnum)
{
return Convert.ToDecimal (anyEnum);
}
这是因为每种整数类型(包括ulong)都可以在不丢失信息的情况下转换为十进制。第二种方法是调用Enum.GetUnderlyingType来获取枚举的整数类型,然后调用Convert.ChangeType:
static object GetBoxedIntegralValue (Enum anyEnum)
{
Type integralType = Enum.GetUnderlyingType (anyEnum.GetType());
return Convert.ChangeType (anyEnum, integralType);
}
这保留了原始的整数类型,如下例所示:
object result = GetBoxedIntegralValue (BorderSides.Top);
Console.WriteLine (result); // 4
Console.WriteLine (result.GetType()); // System.Int32
注意
我们的GetBoxedIntegralType方法实际上不执行值转换;相反,它重新装箱相同值到另一种类型中。它将枚举类型中的整数值转换为整数类型的值。我们在“枚举如何工作”中进一步描述了这一点。
第三种方法是调用Format或ToString并指定"d"或"D"格式字符串。这将给出枚举的整数值作为字符串,适用于编写自定义序列化格式化程序时非常有用:
static string GetIntegralValueAsString (Enum anyEnum)
{
return anyEnum.ToString ("D"); // returns something like "4"
}
整数到枚举转换
Enum.ToObject将整数值转换为给定类型的enum实例:
object bs = Enum.ToObject (typeof (BorderSides), 3);
Console.WriteLine (bs); // Left, Right
这是以下内容的动态等价物:
BorderSides bs = (BorderSides) 3;
ToObject被重载以接受所有整数类型以及object。(后者适用于任何装箱整数类型。)
字符串转换
要将enum转换为字符串,您可以调用静态的Enum.Format方法或在实例上调用ToString方法。每种方法都接受一个格式字符串,可以是"G"以获取默认格式行为,"D"以将底层整数值作为字符串输出,"X"以十六进制输出相同值,或者"F"以格式化带有Flags属性的枚举的组合成员。我们在“标准格式字符串和解析标志”中列出了这些的示例。
Enum.Parse将字符串转换为enum。它接受enum类型和可以包含多个成员的字符串:
BorderSides leftRight = (BorderSides) Enum.Parse (typeof (BorderSides),
"Left, Right");
可选的第三个参数允许您执行不区分大小写的解析。如果未找到成员,则会抛出ArgumentException。
枚举枚举值
Enum.GetValues返回一个包含特定enum类型的所有成员的数组:
foreach (Enum value in Enum.GetValues (typeof (BorderSides)))
Console.WriteLine (value);
也包括LeftRight = Left | Right等复合成员。
Enum.GetNames执行相同的功能,但返回一个字符串数组。
注意
在内部,CLR 通过反射枚举类型中的字段来实现GetValues和GetNames。结果被缓存以提高效率。
枚举如何工作
enum的语义主要由编译器强制执行。在 CLR 中,当enum实例(未装箱时)与其基础整数值之间没有运行时差异。此外,CLR 中的enum定义仅仅是System.Enum的一个子类型,每个成员都有静态的整数类型字段。这使得enum的普通使用非常高效,其运行时成本与整数常量相匹配。
这种策略的缺点是enum可以提供静态但不是强大的类型安全性。我们在第三章中看到了一个例子:
[Flags] public enum BorderSides { Left=1, Right=2, Top=4, Bottom=8 }
...
BorderSides b = BorderSides.Left;
b += 1234; // No error!
当编译器无法执行验证(如本例中),运行时没有备用方法抛出异常。
我们所说的enum实例与其整数值之间在运行时没有差异,可能与以下内容相矛盾:
[Flags] public enum BorderSides { Left=1, Right=2, Top=4, Bottom=8 }
...
Console.WriteLine (BorderSides.Right.ToString()); // Right
Console.WriteLine (BorderSides.Right.GetType().Name); // BorderSides
考虑到运行时enum实例的特性,您可能期望它打印出2和Int32!其行为的原因在于一些更多的编译时技巧。在调用其虚拟方法(如ToString或GetType)之前,C#明确地装箱一个enum实例。当enum实例被装箱时,它获得了一个运行时包装,引用其enum类型。
Guid结构体
Guid结构体代表一个全局唯一标识符:一个 16 字节的值,在生成时几乎可以肯定地是世界上唯一的。Guid经常用于各种应用程序和数据库的键。有 2¹²⁸,或 3.4 × 10³⁸个唯一的Guids。
静态的Guid.NewGuid方法生成一个唯一的Guid:
Guid g = Guid.NewGuid ();
Console.WriteLine (g.ToString()); // 0d57629c-7d6e-4847-97cb-9e2fc25083fe
要实例化一个现有的值,可以使用其中一个构造函数。最常用的两个构造函数是:
public Guid (byte[] b); // Accepts a 16-byte array
public Guid (string g); // Accepts a formatted string
当作为字符串表示时,Guid以 32 位十六进制数字格式化,第 8、12、16 和 20 位数字后可以加入可选的连字符。整个字符串也可以选择用方括号或花括号括起来:
Guid g1 = new Guid ("{0d57629c-7d6e-4847-97cb-9e2fc25083fe}");
Guid g2 = new Guid ("0d57629c7d6e484797cb9e2fc25083fe");
Console.WriteLine (g1 == g2); // True
Guid作为一个结构体,遵循值类型语义;因此,等号运算符在上述示例中有效。
ToByteArray方法将Guid转换为字节数组。
静态的Guid.Empty属性返回一个空的Guid(全为零)。这经常用于代替null。
等值比较
目前为止,我们假设==和!=操作符是等值比较的全部内容。然而,等值的问题更加复杂和微妙,有时需要使用额外的方法和接口。本节探讨了等值的标准 C#和.NET 协议,特别关注两个问题:
-
什么情况下使用
==和!=来进行等值比较是合适的,而何时是不合适的,并且还有什么替代方法? -
如何和何时自定义类型的等值逻辑?
但在探索等值协议的细节和如何定制它们之前,我们首先必须看一下值类型与引用类型等值的初步概念。
值类型与引用类型的等值比较
有两种类型的等值:
值类型的等值
两个值在某种意义上是 等价 的。
引用相等性
两个引用引用完全相同的对象。
除非被覆盖:
-
值类型使用 值相等性。
-
引用类型使用 引用相等性。(匿名类型和记录类型会覆盖这一点。)
值类型实际上可以使用 只有 值相等性(除非装箱)。展示值相等性的一个简单示例是比较两个数字:
int x = 5, y = 5;
Console.WriteLine (x == y); // True *(by virtue of value equality)*
更复杂的演示是比较两个 DateTimeOffset 结构。以下输出 True,因为两个 DateTimeOffset 引用的是相同的时间点,因此被视为等价:
var dt1 = new DateTimeOffset (2010, 1, 1, 1, 1, 1, TimeSpan.FromHours(8));
var dt2 = new DateTimeOffset (2010, 1, 1, 2, 1, 1, TimeSpan.FromHours(9));
Console.WriteLine (dt1 == dt2); // True
注意
DateTimeOffset 是一个结构体,其相等性语义已经被调整。默认情况下,结构体展示一种特殊的值相等性,称为 结构相等性,如果它们的所有成员都相等,则被视为相等。(您可以通过创建一个结构体并调用其 Equals 方法来查看这一点;稍后会详细介绍。)
引用类型默认展示引用相等性。在下面的示例中,f1 和 f2 不相等,尽管它们的对象具有相同的内容:
class Foo { public int X; }
...
Foo f1 = new Foo { X = 5 };
Foo f2 = new Foo { X = 5 };
Console.WriteLine (f1 == f2); // False
相反,f3 和 f1 相等,因为它们引用相同的对象:
Foo f3 = f1;
Console.WriteLine (f1 == f3); // True
在本节的后面,我们将解释如何 定制 引用类型以展示值相等性。一个例子是 System 命名空间中的 Uri 类:
Uri uri1 = new Uri ("http://www.linqpad.net");
Uri uri2 = new Uri ("http://www.linqpad.net");
Console.WriteLine (uri1 == uri2); // True
string 类也展示类似的行为:
var s1 = "http://www.linqpad.net";
var s2 = "http://" + "www.linqpad.net";
Console.WriteLine (s1 == s2); // True
标准相等性协议
类型可以实现三种标准协议进行相等性比较:
-
==和!=运算符 -
object中的虚拟Equals方法 -
IEquatable<T>接口
此外,还有 可插入 的协议和 IStructuralEquatable 接口,我们在 第七章 中描述。
== 和 !=
我们已经在许多示例中看到标准的 == 和 != 运算符如何执行相等性/不相等性比较。 == 和 != 的微妙之处在于它们是 运算符;因此,它们是静态解析的(实际上,它们被实现为 static 函数)。因此,当您使用 == 或 != 时,C# 在编译时决定哪种类型执行比较,并且没有 virtual 行为参与。这通常是可取的。在下面的示例中,编译器将 == 硬编码为 int 类型,因为 x 和 y 都是 int:
int x = 5;
int y = 5;
Console.WriteLine (x == y); // True
但在下一个示例中,编译器将 == 操作符硬编码为 object 类型:
object x = 5;
object y = 5;
Console.WriteLine (x == y); // False
因为 object 是一个类(因此是引用类型),object 的 == 运算符使用 引用相等性 来比较 x 和 y。结果是 false,因为 x 和 y 各自引用堆上不同的装箱对象。
虚拟的 Object.Equals 方法
在上述示例中正确地使 x 和 y 相等,我们可以使用虚拟的 Equals 方法。 Equals 定义在 System.Object 中,因此对所有类型都可用:
object x = 5;
object y = 5;
Console.WriteLine (x.Equals (y)); // True
Equals 是在运行时解析的——根据对象的实际类型。在这种情况下,它调用 Int32 的 Equals 方法,这适用于值相等性的操作数,返回 true。对于引用类型,Equals 默认执行引用相等性比较;对于结构体,Equals 通过调用每个字段的 Equals 方法执行结构比较。
因此,Equals 适用于以类型不可知的方式比较两个对象。以下方法将等同于任何类型的两个对象:
public static bool AreEqual (object obj1, object obj2)
=> obj1.Equals (obj2);
然而,有一个情况是失败的。如果第一个参数是 null,将会得到 NullReferenceException。以下是修复方法:
public static bool AreEqual (object obj1, object obj2)
{
if (obj1 == null) return obj2 == null;
return obj1.Equals (obj2);
}
或者更简洁地说:
public static bool AreEqual (object obj1, object obj2)
=> obj1 == null ? obj2 == null : obj1.Equals (obj2);
静态的 object.Equals 方法
object 类提供了一个静态帮助方法,在前面例子中的 AreEqual 中执行的工作。它的名字是 Equals —— 就像虚拟方法一样——但没有冲突,因为它接受两个参数:
public static bool Equals (object objA, object objB)
这为在编译时类型未知时提供了一个空安全的等式比较算法:
object x = 3, y = 3;
Console.WriteLine (object.Equals (x, y)); // True
x = null;
Console.WriteLine (object.Equals (x, y)); // False
y = null;
Console.WriteLine (object.Equals (x, y)); // True
当编写泛型类型时,一个有用的应用场景是。如果将 object.Equals 替换为 == 或 != 运算符,则以下代码将无法编译:
class Test <T>
{
T _value;
public void SetValue (T newValue)
{
if (!object.Equals (newValue, _value))
{
_value = newValue;
OnValueChanged();
}
}
protected virtual void OnValueChanged() { ... }
}
在此禁止使用运算符,因为编译器无法绑定到未知类型的静态方法。
注意
更复杂的实现方法是使用 EqualityComparer<T> 类进行比较。这样做的好处是避免装箱:
if (!EqualityComparer<T>.Default.Equals (newValue, _value))
我们在第七章中更详细地讨论了 EqualityComparer<T>(请参见“插入等式和顺序”)。
静态的 object.ReferenceEquals 方法
有时,您需要强制执行引用相等性比较。静态的 object.ReferenceEquals 方法正是为此而设计的:
Widget w1 = new Widget();
Widget w2 = new Widget();
Console.WriteLine (object.ReferenceEquals (w1, w2)); // False
class Widget { ... }
您可能想要这样做,因为 Widget 可以重写虚拟 Equals 方法以使 w1.Equals(w2) 返回 true。此外,Widget 可以重载 == 运算符,使得 w1==w2 也会返回 true。在这种情况下,调用 object.ReferenceEquals 可以保证正常的引用相等性语义。
注意
另一种强制执行引用相等性比较的方法是将值转换为 object,然后应用 == 运算符。
IEquatable<T> 接口
调用 object.Equals 的后果是在值类型上强制执行装箱。在高度性能敏感的情景中,这是不理想的,因为与实际比较相比,装箱相对昂贵。C# 2.0 中引入了一个解决方案,即 IEquatable<T> 接口:
public interface IEquatable<T>
{
bool Equals (T other);
}
其思想是,当实现时,IEquatable<T> 的结果与调用 object 的虚拟 Equals 方法相同——但更快。大多数基本的 .NET 类型实现了 IEquatable<T>。您可以在泛型类型中使用 IEquatable<T> 作为约束条件:
class Test<T> where T : IEquatable<T>
{
public bool IsEqual (T a, T b)
{
return a.Equals (b); // No boxing with generic T
}
}
如果我们移除泛型约束,类仍然可以编译,但 a.Equals(b) 将绑定到较慢的 object.Equals(假设 T 是值类型时)。
当Equals和==不相等时
我们之前说过,有时让==和Equals应用不同的相等性定义是很有用的。例如:
double x = double.NaN;
Console.WriteLine (x == x); // False
Console.WriteLine (x.Equals (x)); // True
double类型的==运算符强制一个NaN永远不能等于其他任何东西,即使是另一个NaN。从数学角度来看,这是最自然的,并且反映了底层 CPU 的行为。然而,Equals方法却必须应用自反相等性;换句话说:
x.Equals(x)必须始终返回 true。
集合和字典依赖于Equals的这种行为;否则,它们无法找到之前存储的项。
当作者定制Equals以执行值相等性而保留==以执行(默认)引用相等性时,值类型之间应用不同的相等性定义实际上是相当罕见的。更常见的情况是在引用类型中;当作者定制Equals使其执行值相等性而保留==执行(默认)引用相等性时,就会发生这种情况。StringBuilder类正是如此:
var sb1 = new StringBuilder ("foo");
var sb2 = new StringBuilder ("foo");
Console.WriteLine (sb1 == sb2); // False *(referential equality)*
Console.WriteLine (sb1.Equals (sb2)); // True *(value equality)*
现在让我们看看如何定制相等性。
平等性和自定义类型
回顾默认的相等性比较行为:
-
值类型使用值相等性。
-
引用类型使用引用相等性,除非被覆盖(如匿名类型和记录类型)。
此外:
- 结构体的
Equals方法默认应用结构值相等性(即比较结构体中的每个字段)。
有时,当编写一个类型时,覆盖此行为是有意义的。有两种情况可以这样做:
-
改变相等性的含义
-
为了加快结构体的相等性比较
改变相等性的含义
当默认的==和Equals行为对你的类型来说不自然且不符合消费者期望时,改变相等性的含义是有意义的。一个例子是DateTimeOffset,一个包含两个私有字段的结构体:一个 UTC DateTime和一个数值整数偏移量。如果你正在编写这种类型,你可能希望确保相等性比较仅考虑 UTC DateTime字段而不考虑偏移字段。另一个例子是支持NaN值的数值类型,如float和double。如果你自己实现这样的类型,你会希望确保在相等性比较中支持NaN比较逻辑。
对于类来说,有时候将值相等性作为默认选项而不是引用相等性更加自然。这在持有简单数据的小类(如System.Uri或System.String)中经常发生。
对于记录类型,编译器会自动实现结构相等性(通过比较每个字段)。然而,有时这将包括你不想比较的字段,或者需要特殊比较逻辑的对象,比如集合。用记录类型覆盖相等性的过程略有不同,因为记录类型遵循一种特殊的模式,旨在与其继承规则完美配合。
加快结构体的相等性比较
对于结构的默认结构相等比较算法相对较慢。 通过重写Equals来接管此过程可以将性能提升五倍。 重载==运算符并实现IEquatable<T>允许未装箱的相等比较,这可以再次加快五倍速度。
注意
重写引用类型的相等语义对性能没有好处。 引用相等比较的默认算法已经非常快速,因为它只是比较两个 32 位或 64 位引用。
对于优化结构的哈希算法来说,还有另一个相当奇特的情况,这可以改善哈希表中结构的性能。 这是由于相等比较和哈希在某种程度上是相关联的。 我们稍后会详细讨论哈希。
如何重写相等语义
要重写类或结构的相等性,以下是步骤:
-
覆盖
GetHashCode()和Equals()。 -
(可选)重载
!=和==。 -
(可选)实现
IEquatable<T>。
与记录不同(也更简单),因为编译器已经按照自己的特殊模式重写了相等方法和运算符。 如果你想介入,你必须符合这个模式,这意味着写一个像这样的Equals方法:
record Test (int X, int Y)
{
public virtual bool Equals (Test t) => t != null && t.X == X && t.Y == Y;
}
注意Equals是virtual(而不是override),并且接受实际的记录类型(在这种情况下是Test,而不是object)。 编译器将识别您的方法具有“正确”的签名,并将其修补入其中。
你还必须重写GetHashCode(),就像你为类或结构体所做的一样。 你不需要(也不应该)重载!=和==,或者实现IEquatable<T>,因为这些已经为你完成。
重写GetHashCode
System.Object ——具有成员的小足迹 ——定义了一个专门且狭窄目的的方法,可能会显得有些奇怪。 GetHashCode是Object中的一个虚方法,符合这一描述;它主要有利于以下两种类型:
System.Collections.Hashtable
System.Collections.Generic.Dictionary<TKey,TValue>
这些是哈希表 ——每个元素都有用于存储和检索的键的集合。 哈希表应用非常具体的策略来基于它们的键高效地分配元素。 这要求每个键有一个Int32数,或者哈希码。 哈希码不必对每个键唯一,但为了好的哈希表性能,应尽可能多样化。 哈希表被认为非常重要,以至于GetHashCode在System.Object中被定义 ——这样每种类型都可以生成一个哈希码。
注意
我们在第七章中详细描述了哈希表。
引用类型和值类型都有GetHashCode的默认实现,这意味着你不需要重写这个方法 —— 除非你重写 Equals。(如果你重写GetHashCode,你几乎肯定也想重写Equals。)
对于重写object.GetHashCode的其他规则如下:
-
对于两个对象,如果
Equals返回true,则它们必须返回相同的值(因此,一起重写GetHashCode和Equals)。 -
它不能抛出异常。
-
如果在同一对象上重复调用,则必须返回相同的值(除非对象已更改)。
为了在哈希表中获得最大性能,应编写GetHashCode以尽量减少两个不同值返回相同哈希码的可能性。这引出了在struct上重写Equals和GetHashCode的第三个原因,即提供比默认更高效的哈希算法。结构体的默认实现由运行时自行决定,并且可以基于结构体中的每个字段。
相反,类的默认GetHashCode实现基于内部对象令牌,在 CLR 当前实现中对每个实例都是唯一的。
警告
如果对象在添加为字典键之后其哈希码更改,则该对象将不再可访问字典中。您可以通过将哈希码计算基于不可变字段来预防此问题。
我们提供了一个完整的示例,说明如何很快地重写GetHashCode。
重写 Equals
object.Equals的公理如下:
-
对象不能等于
null(除非它是可空类型)。 -
相等性是自反的(对象等于自身)。
-
相等性是交换的(如果
a.Equals(b),则b.Equals(a))。 -
相等性是传递的(如果
a.Equals(b)和b.Equals(c),则a.Equals(c))。 -
相等操作是可重复且可靠的(它们不会抛出异常)。
重载==和!=
除了重写Equals之外,您还可以选择重载相等和不相等运算符。这几乎总是在struct中完成,因为不这样做的后果是==和!=运算符在您的类型上根本不起作用。
对于类,有两种方法可以继续:
-
保留
==和!=不变——使其适用于引用相等。 -
重载
==和!=以与Equals保持一致。
第一种方法在自定义类型中最为常见,特别是可变类型。它确保您的类型遵循==和!=应表现为引用类型的引用相等的预期,这样可以避免使消费者困惑。我们之前看过一个例子:
var sb1 = new StringBuilder ("foo");
var sb2 = new StringBuilder ("foo");
Console.WriteLine (sb1 == sb2); // False *(referential equality)*
Console.WriteLine (sb1.Equals (sb2)); // True *(value equality)*
第二种方法在消费者永远不希望引用相等的类型(通常是不可变的,如string和System.Uri类)时是有意义的,并且有时是struct的良好候选者。
注意
虽然可以重载!=以使其意味着不同于!(==),但在实践中很少这样做。一个例子是在System.Data.SqlTypes命名空间中定义的类型,它们表示 SQL Server 中的本机列类型。这些遵循数据库的空比较逻辑,即如果任一操作数为空,则=和<>运算符(在 C#中为==和!=)都返回 null。
实现 IEquatable
为了完整起见,当重写Equals时,实现IEquatable<T>也是个好主意。其结果应始终与重写对象的Equals方法匹配。如果你将Equals方法的实现结构化如下面的示例,实现IEquatable<T>并不会增加编程成本。
一个示例:Area 结构体
想象一下,我们需要一个结构体来表示一个宽度和高度可互换的区域。换句话说,5 × 10 等于 10 × 5。(这样的类型在安排矩形形状的算法中很合适。)
下面是完整的代码:
public struct Area : IEquatable <Area>
{
public readonly int Measure1;
public readonly int Measure2;
public Area (int m1, int m2)
{
Measure1 = Math.Min (m1, m2);
Measure2 = Math.Max (m1, m2);
}
public override bool Equals (object other)
=> other is Area a && Equals (a); // Calls method below
public bool Equals (Area other) // Implements IEquatable<Area>
=> Measure1 == other.Measure1 && Measure2 == other.Measure2;
public override int GetHashCode()
=> HashCode.Combine (Measure1, Measure2);
// Note that we call the static Equals method in the object class: this
// does null checking before calling our own (instance) Equals method.
public static bool operator == (Area a1, Area a2) => Equals (a1, a2);
public static bool operator != (Area a1, Area a2) => !(a1 == a2);
}
注意
从 C# 10 开始,你可以通过记录类型来简化这个过程。通过将其声明为record struct,你可以删除构造函数后面的所有代码。
在实现GetHashCode时,我们使用了.NET 的HashCode.Combine函数来生成一个复合哈希码。(在该函数存在之前,一种流行的方法是将每个值乘以某个质数,然后将它们相加。)
下面是Area结构的演示:
Area a1 = new Area (5, 10);
Area a2 = new Area (10, 5);
Console.WriteLine (a1.Equals (a2)); // True
Console.WriteLine (a1 == a2); // True
可插拔的相等性比较器
如果你希望某个类型在特定场景下具有不同的相等语义,可以使用可插拔的IEqualityComparer。这在与标准集合类结合使用时特别有用,我们将在下一章中描述它,即在“插入相等性和排序”中。
排序比较
除了定义相等的标准协议外,C#和.NET 还定义了两个用于确定一个对象相对于另一个对象顺序的标准协议:
-
IComparable接口(IComparable和IComparable<T>) -
>和<运算符
通用排序算法使用IComparable接口。在下面的示例中,静态的Array.Sort方法之所以有效,是因为System.String实现了IComparable接口:
string[] colors = { "Green", "Red", "Blue" };
Array.Sort (colors);
foreach (string c in colors) Console.Write (c + " "); // Blue Green Red
< 和 > 运算符更加专门化,主要用于数值类型。由于它们是静态解析的,它们可以转换为高效的字节码,非常适合计算密集型算法。
.NET 还通过IComparer接口提供了可插拔的排序协议。我们在第七章的最后一节中描述了这些内容。
IComparable
IComparable接口定义如下:
public interface IComparable { int CompareTo (object other); }
public interface IComparable<in T> { int CompareTo (T other); }
这两个接口代表相同的功能。对于值类型,泛型类型安全的接口比非泛型接口更快。无论哪种情况,CompareTo方法的工作方式如下:
-
如果
a在b之后,a.CompareTo(b)返回一个正数。 -
如果
a等同于b,a.CompareTo(b)返回0。 -
如果
a在b之前,a.CompareTo(b)返回一个负数。
例如:
Console.WriteLine ("Beck".CompareTo ("Anne")); // 1
Console.WriteLine ("Beck".CompareTo ("Beck")); // 0
Console.WriteLine ("Beck".CompareTo ("Chris")); // -1
大多数基本类型都同时实现了IComparable接口。在编写自定义类型时,有时也会实现这些接口。我们很快会提供一个示例。
IComparable 与 Equals
考虑一个既重写了Equals又实现了IComparable接口的类型。你期望当Equals返回true时,CompareTo应该返回0。而且你是对的。但这里有个要注意的地方:
- 当
Equals返回false时,CompareTo可以返回它喜欢的结果(只要内部一致即可)!
换句话说,相等性可以比比较性“苛刻”,但反之则不然(违反这一点将导致排序算法出错)。因此,CompareTo可以说:“所有对象都是相等的”,而Equals则说:“但有些对象比其他对象更相等!”
System.String就是一个很好的例子。String的Equals方法和==运算符使用序数比较,即比较每个字符的 Unicode 点值。然而,它的CompareTo方法使用与文化相关的比较,有时会将多个字符放入同一排序位置。
在第七章中,我们讨论了可插拔的排序协议IComparer,它允许在排序或实例化排序集合时指定替代排序算法。自定义的IComparer可以进一步扩展CompareTo和Equals之间的差距——例如,不区分大小写的字符串比较器在比较"A"和"a"时会返回0。然而,反向规则仍然适用:CompareTo永远不能比Equals更苛刻。
注意
当实现自定义类型中的IComparable接口时,可以通过将CompareTo的第一行编写如下,避免违反此规则:
if (Equals (other)) return 0;
在那之后,它可以返回它喜欢的结果,只要一致即可!
< and >
有些类型定义了<和>运算符,例如:
bool after2010 = DateTime.Now > new DateTime (2010, 1, 1);
你可以期望实现了IComparable接口的类型,一旦实现了<和>运算符,它们在功能上与接口是一致的。这在.NET 中是标准做法。
实现了IComparable接口是标准做法,只要重载了<和>运算符,尽管反之并非如此。事实上,大多数实现了IComparable接口的.NET 类型不重载<和>。这与重载==以及覆盖Equals时的情况不同。
通常情况下,只有在
-
一个类型具有强烈的内在“大于”和“小于”的概念(相对于
IComparable更广泛的“在之前”和“在之后”的概念)。 -
只有一种或上下文执行比较的方式。
-
结果在所有文化中都是不变的。
System.String不能满足最后一点:字符串比较的结果可以根据语言而变化。因此,string不支持>和<运算符:
bool error = "Beck" > "Anne"; // Compile-time error
实现 IComparable 接口
在以下表示音符的结构体中,我们实现了IComparable接口,并重载了<和>运算符。为了完整性,我们还重写了Equals/GetHashCode并重载了==和!=:
public struct Note : IComparable<Note>, IEquatable<Note>, IComparable
{
int _semitonesFromA;
public int SemitonesFromA { get { return _semitonesFromA; } }
public Note (int semitonesFromA)
{
_semitonesFromA = semitonesFromA;
}
public int CompareTo (Note other) // Generic IComparable<T>
{
if (Equals (other)) return 0; // Fail-safe check
return _semitonesFromA.CompareTo (other._semitonesFromA);
}
int IComparable.CompareTo (object other) // Nongeneric IComparable
{
if (!(other is Note))
throw new InvalidOperationException ("CompareTo: Not a note");
return CompareTo ((Note) other);
}
public static bool operator < (Note n1, Note n2)
=> n1.CompareTo (n2) < 0;
public static bool operator > (Note n1, Note n2)
=> n1.CompareTo (n2) > 0;
public bool Equals (Note other) // for IEquatable<Note>
=> _semitonesFromA == other._semitonesFromA;
public override bool Equals (object other)
{
if (!(other is Note)) return false;
return Equals ((Note) other);
}
public override int GetHashCode() => _semitonesFromA.GetHashCode();
// Call the static Equals method to ensure nulls are properly handled:
public static bool operator == (Note n1, Note n2) => Equals (n1, n2);
public static bool operator != (Note n1, Note n2) => !(n1 == n2);
}
实用类
控制台
静态Console类用于处理控制台应用程序的标准输入/输出。在命令行(控制台)应用程序中,输入来自键盘,通过Read,ReadKey和ReadLine方法,输出则通过Write和WriteLine方法显示在文本窗口中。你可以使用WindowLeft,WindowTop,WindowHeight和WindowWidth属性控制窗口的位置和尺寸。还可以更改BackgroundColor和ForegroundColor属性,并使用CursorLeft,CursorTop和CursorSize属性操纵光标:
Console.WindowWidth = Console.LargestWindowWidth;
Console.ForegroundColor = ConsoleColor.Green;
Console.Write ("test... 50%");
Console.CursorLeft -= 3;
Console.Write ("90%"); // test... 90%
Write和WriteLine方法支持复合格式字符串重载(参见“字符串和文本处理”中的String.Format)。但是,这两种方法均不接受格式提供程序,因此你将使用CultureInfo.CurrentCulture。(当然,解决方法是显式调用string.Format。)
Console.Out属性返回一个TextWriter。将Console.Out传递给期望TextWriter的方法是一个将该方法用于诊断目的写入Console的有用方式。
你还可以通过SetIn和SetOut方法重定向Console的输入和输出流:
// First save existing output writer:
System.IO.TextWriter oldOut = Console.Out;
// Redirect the console's output to a file:
using (System.IO.TextWriter w = System.IO.File.CreateText
("e:\\output.txt"))
{
Console.SetOut (w);
Console.WriteLine ("Hello world");
}
// Restore standard console output
Console.SetOut (oldOut);
在第十五章中,我们描述了流和文本写入器的工作方式。
注意
在 Visual Studio 下运行 WPF 或 Windows Forms 应用程序时,Console的输出会自动重定向到 Visual Studio 的输出窗口(调试模式下)。这使得Console.Write在诊断目的上非常有用;尽管在大多数情况下,System.Diagnostics命名空间中的Debug和Trace类更为适合(参见第十三章)。
环境
静态System.Environment类提供了一系列有用的属性:
文件和文件夹
CurrentDirectory, SystemDirectory, CommandLine
计算机和操作系统
MachineName, ProcessorCount, OSVersion, NewLine
用户登录
UserName, UserInteractive, UserDomainName
诊断
TickCount, StackTrace, WorkingSet, Version
你可以通过调用GetFolderPath获取额外的文件夹;我们在“文件和目录操作”中描述了这一点,位于第十五章。
你可以使用以下三种方法访问操作系统环境变量(在命令提示符下键入“set”时看到的内容):GetEnvironmentVariable,GetEnvironmentVariables和SetEnvironmentVariable。
ExitCode属性允许你设置返回代码,用于从命令或批处理文件调用程序时;FailFast方法立即终止程序,无需执行清理操作。
可供 Windows Store 应用程序使用的Environment类只提供了有限数量的成员(ProcessorCount,NewLine和FailFast)。
进程
System.Diagnostics 中的 Process 类允许您启动新进程。(在 第十三章 中,我们描述了如何使用它与计算机上运行的其他进程进行交互)。
警告
出于安全原因,Process 类不适用于 Windows Store 应用程序,您不能启动任意进程。相反,您必须使用 Windows.System.Launcher 类来“启动”您可以访问的 URI 或文件;例如:
Launcher.LaunchUriAsync (new Uri ("http://albahari.com"));
var file = await KnownFolders.DocumentsLibrary
.GetFileAsync ("foo.txt");
Launcher.LaunchFileAsync (file);
这将使用与 URI 方案或文件扩展关联的任何程序打开 URI 或文件。要使此功能正常工作,您的程序必须处于前台。
静态的 Process.Start 方法有多个重载;最简单的接受一个简单的文件名及可选参数:
Process.Start ("notepad.exe");
Process.Start ("notepad.exe", "e:\\file.txt");
最灵活的重载接受一个 ProcessStartInfo 实例。通过这个实例,您可以捕获和重定向启动进程的输入、输出和错误输出(如果将 UseShellExecute 保持为 false)。以下是调用 ipconfig 并捕获输出的示例:
ProcessStartInfo psi = new ProcessStartInfo
{
FileName = "cmd.exe",
Arguments = "/c ipconfig /all",
RedirectStandardOutput = true,
UseShellExecute = false
};
Process p = Process.Start (psi);
string result = p.StandardOutput.ReadToEnd();
Console.WriteLine (result);
如果不重定向输出,Process.Start 会并行执行程序。如果希望等待新进程完成,可以在 Process 对象上调用 WaitForExit,并可选择设置超时时间。
重定向输出和错误流
使用 UseShellExecute 为 false(在 .NET 中的默认值),您可以捕获标准输入、输出和错误流,然后通过 StandardInput、StandardOutput 和 StandardError 属性写入/读取这些流。
当需要重定向标准输出和标准错误流时会遇到困难,因为通常无法预知应按哪个顺序从每个流中读取数据(因为不知道数据将如何交错)。解决方案是同时从两个流中读取数据,您可以通过异步读取从(至少)其中一个流实现。以下是如何实现这一点的方式:
-
处理
OutputDataReceived和/或ErrorDataReceived事件。这些事件在接收到输出/错误数据时触发。 -
调用
BeginOutputReadLine和/或BeginErrorReadLine。这样可以启用上述事件。
以下方法在运行可执行文件的同时捕获输出和错误流:
(string output, string errors) Run (string exePath, string args = "")
{
using var p = Process.Start (new ProcessStartInfo (exePath, args)
{
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
});
var errors = new StringBuilder ();
// Read from the error stream asynchronously...
p.ErrorDataReceived += (sender, errorArgs) =>
{
if (errorArgs.Data != null) errors.AppendLine (errorArgs.Data);
};
p.BeginErrorReadLine ();
// ...while we read from the output stream synchronously:
string output = p.StandardOutput.ReadToEnd();
p.WaitForExit();
return (output, errors.ToString());
}
UseShellExecute
警告
在 .NET 5+(以及 .NET Core)中,默认值为 UseShellExecute 为 false,而在 .NET Framework 中,默认值为 true。因为这是一个破坏性变更,当从 .NET Framework 迁移代码时,值得检查对 Process.Start 的所有调用。
UseShellExecute 标志会改变 CLR 如何启动进程。如果 UseShellExecute 为 true,您可以执行以下操作:
-
指定文件或文档的路径,而不是可执行文件(导致操作系统使用其关联的应用程序打开文件或文档)
-
指定一个 URL(导致操作系统在默认浏览器中导航到该 URL)
-
(仅限 Windows)指定一个动词(例如“runas”,以使用管理员权限运行进程)
缺点在于无法重定向输入或输出流。如果需要在启动文件或文档时这样做,一种解决方法是将 UseShellExecute 设置为 false 并使用 “/c” 开关调用命令行进程(cmd.exe),就像我们在调用 ipconfig 时所做的那样。
在 Windows 下,UseShellExecute 指示 CLR 使用 Windows 的 ShellExecute 函数而不是 CreateProcess 函数。在 Linux 下,UseShellExecute 指示 CLR 调用 xdg-open、gnome-open 或 kfmclient。
AppContext
静态的 System.AppContext 类公开了两个有用的属性:
-
BaseDirectory返回应用程序启动的文件夹。此文件夹对于解析程序集(查找和加载依赖项)和定位配置文件(如 appsettings.json)至关重要。 -
TargetFrameworkName告诉您应用程序针对的 .NET 运行时的名称和版本(如其 .runtimeconfig.json 文件中指定)。这可能比实际使用的运行时旧。
此外,AppContext 类管理一个全局的字符串键字典,存储布尔值,旨在为库编写者提供一个标准机制,以允许消费者开启或关闭新功能。这种无类型的方法对于希望保持未公开文档的实验性功能是合理的。
某个库的消费者要求您按以下方式启用一个功能:
AppContext.SetSwitch ("MyLibrary.SomeBreakingChange", true);
然后库内的代码可以按以下方式检查该开关:
bool isDefined, switchValue;
isDefined = AppContext.TryGetSwitch ("MyLibrary.SomeBreakingChange",
out switchValue);
如果开关未定义,TryGetSwitch 将返回 false;这使您能够区分未定义开关和其值设置为 false 的开关,如果有必要的话。
注意
具有讽刺意味的是,TryGetSwitch 的设计展示了如何不编写 API。out 参数是不必要的,方法应该返回一个可空的 bool,其值为 true、false 或对未定义的值为 null。这将使以下用法成为可能:
bool switchValue = AppContext.GetSwitch ("...") ?? false;
第七章:集合
.NET 提供了一套标准类型,用于存储和管理对象集合。这些包括可调整大小的列表,链表,排序和未排序的字典,以及数组。其中,只有数组是 C# 语言的一部分;其余的集合只是像其他类一样实例化的类。
我们可以将.NET BCL 中用于集合的类型分为以下几类:
-
定义标准集合协议的接口
-
可直接使用的集合类(列表,字典等)
-
用于编写特定于应用程序的集合的基类
本章涵盖了这些类别的每一个,另外还有一个关于用于确定元素相等性和顺序的类型的部分。
集合命名空间如下:
| 命名空间 | 包含 |
|---|---|
System.Collections |
非泛型集合类和接口 |
System.Collections.Specialized |
强类型的非泛型集合类 |
System.Collections.Generic |
泛型集合类和接口 |
System.Collections.ObjectModel |
自定义集合的代理和基类 |
System.Collections.Concurrent |
线程安全的集合(参见第二十三章) |
枚举
在计算中,有许多不同类型的集合,从简单的数据结构(如数组或链表)到更复杂的数据结构(如红黑树和哈希表)。尽管这些数据结构的内部实现和外部特征各不相同,但遍历集合内容的能力几乎是普遍需求。.NET BCL 通过一对接口(IEnumerable,IEnumerator及其泛型对应项)支持这种需求,这些接口允许不同的数据结构公开一个通用的遍历 API。这些接口是图 7-1 中所示的一组更大的集合接口的一部分。

图 7-1. 集合接口
IEnumerable 和 IEnumerator
IEnumerator 接口定义了集合中元素以仅向前方式遍历或枚举的基本低级协议。其声明如下:
public interface IEnumerator
{
bool MoveNext();
object Current { get; }
void Reset();
}
MoveNext 将当前元素或“光标”移动到下一个位置,如果集合中没有更多元素,则返回false。Current 返回当前位置的元素(通常从object转换为更具体的类型)。在检索第一个元素之前必须调用MoveNext —— 这是为了允许空集合。如果实现了Reset 方法,则可以将其移回起始位置,从而允许重新枚举集合。Reset 主要用于组件对象模型(COM)互操作性;直接调用它通常是避免的,因为它不是普遍支持的(而且通常只需实例化一个新的枚举器就可以了)。
集合通常不会 实现 枚举器;相反,它们通过接口 IEnumerable 提供 枚举器:
public interface IEnumerable
{
IEnumerator GetEnumerator();
}
通过定义一个返回枚举器的单个方法,IEnumerable 提供了灵活性,使得迭代逻辑可以委托给另一个类。此外,这意味着多个消费者可以同时枚举集合而互不干扰。你可以将 IEnumerable 看作是“IEnumeratorProvider”,它是集合类实现的最基本接口。
以下示例说明了 IEnumerable 和 IEnumerator 的低级使用:
string s = "Hello";
// Because string implements IEnumerable, we can call GetEnumerator():
IEnumerator rator = s.GetEnumerator();
while (rator.MoveNext())
{
char c = (char) rator.Current;
Console.Write (c + ".");
}
// Output: H.e.l.l.o.
不过,以这种方式直接调用枚举器的方法是很少见的,因为 C# 提供了一种语法快捷方式:foreach 语句。以下是使用 foreach 重新编写的相同示例:
string s = "Hello"; // The String class implements IEnumerable
foreach (char c in s)
Console.Write (c + ".");
IEnumerable<T> 和 IEnumerator<T>
IEnumerator 和 IEnumerable 几乎总是与它们的扩展泛型版本一起实现:
public interface IEnumerator<T> : IEnumerator, IDisposable
{
T Current { get; }
}
public interface IEnumerable<T> : IEnumerable
{
IEnumerator<T> GetEnumerator();
}
通过定义 Current 和 GetEnumerator 的类型化版本,这些接口增强了静态类型安全性,避免了对值类型元素的装箱开销,并且对消费者更加方便。数组会自动实现 IEnumerable<T>(其中 T 是数组的成员类型)。
由于改进了静态类型安全性,使用字符数组调用以下方法将生成编译时错误:
void Test (IEnumerable<int> numbers) { ... }
集合类的标准做法是公开 IEnumerable<T>,同时通过显式接口实现“隐藏”非泛型的 IEnumerable。这样,如果直接调用 GetEnumerator(),你会得到类型安全的泛型 IEnumerator<T>。不过,出于向后兼容性的考虑(在 C# 2.0 之前没有泛型),有时会违反这个规则。一个很好的例子是数组 —— 它们必须返回非泛型(委婉的说法是“经典”)的 IEnumerator,以避免破坏早期的代码。要获得泛型的 IEnumerator<T>,你必须进行类型转换来公开显式接口:
int[] data = { 1, 2, 3 };
var rator = ((IEnumerable <int>)data).GetEnumerator();
幸运的是,由于 foreach 语句的存在,你很少需要编写这种类型的代码。
IEnumerable<T> 和 IDisposable
IEnumerator<T> 继承自 IDisposable。这使得枚举器可以持有诸如数据库连接之类的资源引用,并确保这些资源在枚举完成(或中途放弃)时被释放。foreach 语句能够识别这一细节,并将以下内容进行转换:
foreach (var element in somethingEnumerable) { ... }
转换成以下的逻辑等效:
using (var rator = somethingEnumerable.GetEnumerator())
while (rator.MoveNext())
{
var element = rator.Current;
...
}
using 块确保释放资源 —— 关于 IDisposable 的更多信息详见第十二章。
实现枚举接口
你可能希望为以下一个或多个原因之一实现 IEnumerable 或 IEnumerable<T>:
-
为了支持
foreach语句 -
为了与期望标准集合的任何东西进行互操作
-
以满足更复杂的集合接口的要求
-
为了支持集合初始化器
要实现IEnumerable/IEnumerable<T>,你必须提供一个枚举器。你可以通过以下三种方式之一实现:
-
如果类“包装”另一个集合,则通过返回包装集合的枚举器
-
通过使用
yield return创建迭代器 -
通过实例化自己的
IEnumerator/IEnumerator<T>实现
注意
你也可以从现有集合派生子类:Collection<T>专门设计用于此目的(参见“可自定义集合和代理”)。另一种方法是使用 LINQ 查询运算符,我们将在第八章中介绍。
返回另一个集合的枚举器只是在内部集合上调用GetEnumerator的问题。然而,这只在内部集合的项目恰好符合所需项目的最简单场景中有效。更灵活的方法是使用 C#的yield return语句编写一个迭代器。迭代器是 C#语言的一种特性,它有助于编写集合,就像foreach语句有助于消费集合一样。迭代器自动处理IEnumerable和IEnumerator(或它们的泛型版本)的实现。以下是一个简单的示例:
public class MyCollection : IEnumerable
{
int[] data = { 1, 2, 3 };
public IEnumerator GetEnumerator()
{
foreach (int i in data)
yield return i;
}
}
注意这种“黑魔法”:GetEnumerator似乎根本不返回枚举器!在解析yield return语句时,编译器在幕后编写一个隐藏的嵌套枚举器类,然后重构GetEnumerator以实例化并返回该类。迭代器功能强大且简单(在 LINQ-to-Object 标准查询操作的实现中广泛使用)。
按照这种方法,我们也可以实现泛型接口IEnumerable<T>:
public class MyGenCollection : IEnumerable<int>
{
int[] data = { 1, 2, 3 };
public IEnumerator<int> GetEnumerator()
{
foreach (int i in data)
yield return i;
}
// Explicit implementation keeps it hidden:
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}
因为IEnumerable<T>继承自IEnumerable,所以我们必须同时实现泛型和非泛型版本的GetEnumerator。根据标准实践,我们已经显式实现了非泛型版本,它只需调用泛型的GetEnumerator,因为IEnumerator<T>继承自IEnumerator。
我们刚刚编写的类将适合作为编写更复杂集合的基础。但是,如果我们只需要简单的IEnumerable<T>实现,yield return语句允许更简单的变体。而不是编写一个类,你可以将迭代逻辑移入一个返回泛型IEnumerable<T>的方法中,让编译器来处理其余部分。以下是一个示例:
public static IEnumerable <int> GetSomeIntegers()
{
yield return 1;
yield return 2;
yield return 3;
}
这是我们方法的使用:
foreach (int i in Test.GetSomeIntegers())
Console.WriteLine (i);
写GetEnumerator的最终方法是编写一个直接实现IEnumerator接口的类。这恰恰是编译器在幕后解析迭代器时所做的。(幸运的是,你自己很少需要走到这一步。)以下示例定义了一个硬编码为整数 1、2 和 3 的集合:
public class MyIntList : IEnumerable
{
int[] data = { 1, 2, 3 };
public IEnumerator GetEnumerator() => new Enumerator (this);
class Enumerator : IEnumerator // Define an inner class
{ // for the enumerator.
MyIntList collection;
int currentIndex = -1;
public Enumerator (MyIntList items) => this.collection = items;
public object Current
{
get
{
if (currentIndex == -1)
throw new InvalidOperationException ("Enumeration not started!");
if (currentIndex == collection.data.Length)
throw new InvalidOperationException ("Past end of list!");
return collection.data [currentIndex];
}
}
public bool MoveNext()
{
if (currentIndex >= collection.data.Length - 1) return false;
return ++currentIndex < collection.data.Length;
}
public void Reset() => currentIndex = -1;
}
}
注意
实现Reset是可选的 —— 你可以选择抛出NotSupportedException异常。
注意,第一次调用 MoveNext 应该移动到列表中的第一个(而不是第二个)项目。
要与迭代器的功能相匹配,我们还必须实现 IEnumerator<T>。以下是省略边界检查的示例:
class MyIntList : IEnumerable<int>
{
int[] data = { 1, 2, 3 };
// The generic enumerator is compatible with both IEnumerable and
// IEnumerable<T>. We implement the nongeneric GetEnumerator method
// explicitly to avoid a naming conflict.
public IEnumerator<int> GetEnumerator() => new Enumerator(this);
IEnumerator IEnumerable.GetEnumerator() => new Enumerator(this);
class Enumerator : IEnumerator<int>
{
int currentIndex = -1;
MyIntList collection;
public Enumerator (MyIntList items) => collection = items;
public int Current => collection.data [currentIndex];
object IEnumerator.Current => Current;
public bool MoveNext() => ++currentIndex < collection.data.Length;
public void Reset() => currentIndex = -1;
// Given we don't need a Dispose method, it's good practice to
// implement it explicitly, so it's hidden from the public interface.
void IDisposable.Dispose() {}
}
}
泛型示例更快,因为 IEnumerator<int>.Current 不需要从 int 转换为 object,因此避免了装箱的开销。
ICollection 和 IList 接口
虽然枚举接口提供了一种在集合上进行单向迭代的协议,但它们并没有提供确定集合大小、按索引访问成员或修改集合的机制。为此,.NET 定义了 ICollection、IList 和 IDictionary 接口。每种接口都有泛型和非泛型版本;然而,非泛型版本主要是为了向后兼容。
图 7-1 显示了这些接口的继承层次结构。总结它们的最简单方法如下:
IEnumerable<T>(以及 IEnumerable)
提供最小的功能性(仅枚举)
ICollection<T>(以及 ICollection)
提供中等功能性(例如,Count 属性)
IList<T>/*IDictionary<K,V> 及其非泛型版本
提供最大的功能性(包括按索引/键“随机”访问)
注意
几乎不需要实现这些接口。几乎在所有需要编写集合类的情况下,您都可以选择子类化 Collection<T>(参见“可自定义的集合和代理”)。LINQ 提供了另一种覆盖多种情况的选项。
泛型和非泛型版本在某些方面有所不同,尤其是在 ICollection 的情况下。这主要是因为历史原因:泛型是后来引入的,因此泛型接口在开发时可以借鉴先前的经验,从而做出了不同(更好)的成员选择。因此,ICollection<T> 不扩展 ICollection,IList<T> 不扩展 IList,IDictionary<TKey, TValue> 也不扩展 IDictionary。当然,如果有益的话,集合类本身可以自由地实现接口的两个版本(通常情况下确实如此)。
注意
另一个更微妙的原因是 IList<T> 没有扩展 IList 的原因是,将 IList<T> 强制转换为 IList 将会返回一个同时具有 Add(T) 和 Add(object) 成员的接口。这实际上会破坏静态类型安全,因为您可以使用任何类型的对象调用 Add。
本节涵盖了 ICollection<T> 和 IList<T> 及其非泛型版本;“字典” 则涵盖了字典接口。
注意
在 .NET 库中,并没有一致的方法来应用“集合”和“列表”这两个词。例如,因为 IList<T> 是 ICollection<T> 的更功能化版本,您可能会期望 List<T> 类相应地比 Collection<T> 类更具功能性。但实际情况并非如此。最好将术语“集合”和“列表”视为广义上的同义词,除非涉及特定类型。
ICollection<T> 和 ICollection
ICollection<T> 是可计数对象集合的标准接口。它提供了确定集合大小(Count)、确定集合中是否存在项(Contains)、将集合复制到数组中(ToArray)以及确定集合是否为只读(IsReadOnly)的能力。对于可写集合,还可以添加、移除和清除集合中的项。由于它扩展了 IEnumerable<T>,因此也可以通过 foreach 语句进行遍历:
public interface ICollection<T> : IEnumerable<T>, IEnumerable
{
int Count { get; }
bool Contains (T item);
void CopyTo (T[] array, int arrayIndex);
bool IsReadOnly { get; }
void Add(T item);
bool Remove (T item);
void Clear();
}
非泛型的 ICollection 类似地提供了可计数集合,但不提供更改列表或检查元素成员的功能:
public interface ICollection : IEnumerable
{
int Count { get; }
bool IsSynchronized { get; }
object SyncRoot { get; }
void CopyTo (Array array, int index);
}
非泛型接口还定义了用于同步的属性(第十四章)——这些在泛型版本中被舍弃,因为集合的线程安全性不再被视为固有的。
这两个接口都相对简单易实现。如果实现只读的 ICollection<T>,则 Add、Remove 和 Clear 方法应该抛出 NotSupportedException。
这些接口通常与 IList 或 IDictionary 接口一起实现。
IList<T> 和 IList
IList<T> 是通过位置索引访问的标准集合接口。除了从 ICollection<T> 和 IEnumerable<T> 继承的功能外,它还提供了通过位置(通过索引器)读取或写入元素以及插入/删除元素的能力:
public interface IList<T> : ICollection<T>, IEnumerable<T>, IEnumerable
{
T this [int index] { get; set; }
int IndexOf (T item);
void Insert (int index, T item);
void RemoveAt (int index);
}
IndexOf 方法在列表上执行线性搜索,如果未找到指定项,则返回 -1。
IList 的非泛型版本因为从 ICollection 继承较少的原因而具有更多的成员:
public interface IList : ICollection, IEnumerable
{
object this [int index] { get; set }
bool IsFixedSize { get; }
bool IsReadOnly { get; }
int Add (object value);
void Clear();
bool Contains (object value);
int IndexOf (object value);
void Insert (int index, object value);
void Remove (object value);
void RemoveAt (int index);
}
在非泛型的 IList 接口上,Add 方法返回一个整数,这是新添加项的索引。相比之下,ICollection<T> 上的 Add 方法返回类型为 void。
通用的 List<T> 类是 IList<T> 和 IList 的典型实现。C# 数组也同时实现了泛型和非泛型的 IList(尽管添加或移除元素的方法通过显式接口实现被隐藏,并且如果调用会抛出 NotSupportedException)。
警告
如果尝试通过 IList 的索引器访问多维数组,则会抛出 ArgumentException。编写如下方法时需要注意这一点:
public object FirstOrNull (IList list)
{
if (list == null || list.Count == 0) return null;
return list[0];
}
这看起来可能十分完美,但如果使用多维数组调用,将会抛出异常。你可以在运行时使用以下表达式测试是否为多维数组(更多信息请参见 第十九章):
list.GetType().IsArray && list.GetType().GetArrayRank()>1
IReadOnlyCollection<T> 和 IReadOnlyList<T>
.NET 还定义了仅公开只读操作所需成员的集合和列表接口:
public interface IReadOnlyCollection<out T> : IEnumerable<T>, IEnumerable
{
int Count { get; }
}
public interface IReadOnlyList<out T> : IReadOnlyCollection<T>,
IEnumerable<T>, IEnumerable
{
T this[int index] { get; }
}
因为这些接口的类型参数仅在输出位置使用,所以被标记为协变。这使得一个猫的列表,例如,可以被视为动物的只读列表。相反,T 在 ICollection<T> 和 IList<T> 中没有标记为协变,因为 T 在输入和输出位置都有使用。
注意
这些接口表示集合或列表的只读视图;底层实现可能仍然可写。大多数可写(可变)集合都同时实现只读和读写接口。
除了让你协变地处理集合外,只读接口还允许类公开对私有可写集合的只读视图。我们在 “ReadOnlyCollection
IReadOnlyList<T> 映射到 Windows Runtime 类型 IVectorView<T>。
数组类
Array 类是所有单维和多维数组的隐式基类之一,它是实现标准集合接口的最基础类型之一。Array 类提供类型统一化,因此所有数组都可以使用一组通用方法,无论其声明或底层元素类型如何。
由于数组非常基础,C# 提供了声明和初始化它们的显式语法,我们在第二章和第三章中有所描述。当使用 C# 的语法声明数组时,CLR 隐式地为 Array 类创建子类型,合成适合数组维度和元素类型的伪类型。这个伪类型实现了类型化的通用集合接口,比如 IList<string>。
在构造时,CLR 也会特殊处理数组类型,为其分配连续的内存空间。这使得数组的索引操作非常高效,但同时阻止它们在稍后进行调整大小。
Array 实现了集合接口,包括它们的泛型和非泛型形式,直至 IList<T>。但 IList<T> 本身是显式实现的,以保持 Array 的公共接口干净,不包含诸如 Add 或 Remove 的方法,这些方法在固定长度集合(如数组)上抛出异常。Array 类确实提供了一个静态的 Resize 方法,但这通过创建一个新数组然后复制每个元素来实现。除了效率低下外,在程序的其他位置引用数组仍将指向原始版本。对于可调整大小的集合,更好的解决方案是使用 List<T> 类(在下一节中描述)。
数组可以包含值类型或引用类型元素。值类型元素在数组中原地存储,因此三个长整数的数组(每个 8 字节)将占用 24 字节的连续内存。但引用类型元素仅占用一个引用的空间(在 32 位环境中为 4 字节,在 64 位环境中为 8 字节)。图 7-2 在内存中展示了以下程序的效果:
StringBuilder[] builders = new StringBuilder [5];
builders [0] = new StringBuilder ("builder1");
builders [1] = new StringBuilder ("builder2");
builders [2] = new StringBuilder ("builder3");
long[] numbers = new long [3];
numbers [0] = 12345;
numbers [1] = 54321;

图 7-2. 数组在内存中的表现
因为 Array 是一个类,所以数组始终是(它们自己)引用类型——无论数组的元素类型如何。这意味着语句 arrayB = arrayA 导致两个变量引用同一个数组。同样,除非使用结构相等比较器比较数组的每个元素,否则两个不同的数组总是无法通过相等性测试:
object[] a1 = { "string", 123, true };
object[] a2 = { "string", 123, true };
Console.WriteLine (a1 == a2); // False
Console.WriteLine (a1.Equals (a2)); // False
IStructuralEquatable se1 = a1;
Console.WriteLine (se1.Equals (a2,
StructuralComparisons.StructuralEqualityComparer)); // True
数组可以通过调用 Clone 方法进行复制:arrayB = arrayA.Clone()。然而,这将导致浅克隆,意味着只有数组本身表示的内存被复制。如果数组包含值类型对象,则值本身被复制;如果数组包含引用类型对象,则只复制引用(导致两个数组的成员引用相同的对象)。图 7-3 展示了向我们的示例添加以下代码的效果:
StringBuilder[] builders2 = builders;
StringBuilder[] shallowClone = (StringBuilder[]) builders.Clone();

图 7-3. 浅克隆一个数组
要创建深复制——即复制引用类型子对象——您必须循环遍历数组并手动克隆每个元素。其他 .NET 集合类型也适用相同规则。
虽然 Array 主要设计用于 32 位索引器,但它也对 64 位索引器有限支持(允许数组理论上寻址至多 2⁶⁴个元素),通过几种方法接受 Int32 和 Int64 参数。但实际上这些重载方法是无用的,因为 CLR 不允许任何对象——包括数组——超过两吉字节的大小(无论在 32 位还是 64 位环境中运行)。
警告
Array类上许多您期望是实例方法的方法实际上是静态方法。这是一个奇怪的设计决定,这意味着在寻找Array上的方法时,您应该检查静态方法和实例方法两者。
构造和索引
通过 C#语言结构创建和索引数组的最简单方法是:
int[] myArray = { 1, 2, 3 };
int first = myArray [0];
int last = myArray [myArray.Length - 1];
或者,您可以通过调用Array.CreateInstance动态实例化一个数组。这允许您在运行时指定元素类型和秩(维数),并通过指定较低边界来允许非零基础的数组。非零基础的数组与.NET 公共语言规范(CLS)不兼容,并且不应在可能被 F#或 Visual Basic 编写的程序消费的库中作为公共成员暴露出去。
GetValue和SetValue方法允许您访问动态创建数组中的元素(它们也适用于普通数组):
// Create a string array 2 elements in length:
Array a = Array.CreateInstance (typeof(string), 2);
a.SetValue ("hi", 0); // → a[0] = "hi";
a.SetValue ("there", 1); // → a[1] = "there";
string s = (string) a.GetValue (0); // → s = a[0];
// We can also cast to a C# array as follows:
string[] cSharpArray = (string[]) a;
string s2 = cSharpArray [0];
动态创建的零索引数组可以转换为匹配或兼容类型的 C#数组(通过标准数组协变规则兼容)。例如,如果Apple是Fruit的子类,则Apple[]可以转换为Fruit[]。这引出了为什么没有使用object[]作为统一的数组类型而是使用Array类的问题。答案是,object[]与多维和值类型数组(以及非零基础数组)都不兼容。int[]数组无法转换为object[]。因此,我们需要Array类进行完整的类型统一。
GetValue和SetValue也适用于编译器创建的数组,在编写可以处理任何类型和秩的数组的方法时非常有用。对于多维数组,它们接受一个索引器的数组:
public object GetValue (params int[] indices)
public void SetValue (object value, params int[] indices)
以下方法打印任何数组的第一个元素,无论其秩如何:
void WriteFirstValue (Array a)
{
Console.Write (a.Rank + "-dimensional; ");
// The indexers array will automatically initialize to all zeros, so
// passing it into GetValue or SetValue will get/set the zero-based
// (i.e., first) element in the array.
int[] indexers = new int[a.Rank];
Console.WriteLine ("First value is " + a.GetValue (indexers));
}
void Demo()
{
int[] oneD = { 1, 2, 3 };
int[,] twoD = { {5,6}, {8,9} };
WriteFirstValue (oneD); // 1-dimensional; first value is 1
WriteFirstValue (twoD); // 2-dimensional; first value is 5
}
注意
对于处理未知类型但已知秩的数组,泛型提供了更简单和更高效的解决方案:
void WriteFirstValue<T> (T[] array)
{
Console.WriteLine (array[0]);
}
如果元素与数组不兼容的类型,则SetValue会抛出异常。
当实例化数组时,无论是通过语言语法还是Array.CreateInstance,其元素都会自动初始化为它们的默认值。对于具有引用类型元素的数组,这意味着写入 null;对于具有值类型元素的数组,这意味着“零化”成员的位。Array类还通过Clear方法按需提供此功能:
public static void Clear (Array array, int index, int length);
这种方法不会影响数组的大小。这与通常使用的Clear(例如在ICollection<T>.Clear中)形成对比,后者会将集合减少到零个元素。
枚举
使用foreach语句可以轻松枚举数组:
int[] myArray = { 1, 2, 3};
foreach (int val in myArray)
Console.WriteLine (val);
您还可以使用静态的Array.ForEach方法进行枚举,其定义如下:
public static void ForEach<T> (T[] array, Action<T> action);
这使用了一个带有如下签名的Action委托:
public delegate void Action<T> (T obj);
这是使用Array.ForEach重写的第一个示例:
Array.ForEach (new[] { 1, 2, 3 }, Console.WriteLine);
我们可以通过C# 12中的集合表达式进一步简化这一过程:
Array.ForEach ([ 1, 2, 3 ], Console.WriteLine);
长度和秩
Array提供了以下用于查询长度和秩的方法和属性:
public int GetLength (int dimension);
public long GetLongLength (int dimension);
public int Length { get; }
public long LongLength { get; }
public int GetLowerBound (int dimension);
public int GetUpperBound (int dimension);
public int Rank { get; } // Returns number of dimensions in array
GetLength和GetLongLength返回给定维度的长度(对于单维数组为0),Length和LongLength返回数组中的总元素数,包括所有维度。
GetLowerBound和GetUpperBound对于非零索引的数组很有用。GetUpperBound返回与给定维度的GetLowerBound加上GetLength相同的结果。
搜索
Array类提供了一系列方法,用于在一维数组中查找元素:
BinarySearch方法
用于在排序的数组中快速搜索特定项
IndexOf/LastIndex方法
用于在未排序的数组中搜索特定项
Find/FindLast/FindIndex/FindLastIndex/FindAll/Exists/TrueForAll
用于在未排序的数组中搜索满足给定Predicate<T>条件的项
如果指定的值未找到,数组搜索方法都不会抛出异常。相反,如果未找到项,则返回整数的方法返回−1(假设是从零开始索引的数组),返回泛型类型的方法返回类型的默认值(例如,对于int返回0,对于string返回null)。
二分搜索方法速度快,但仅适用于排序的数组,并要求元素按顺序而不仅仅是相等性比较。为此,二分搜索方法可以接受IComparer或IComparer<T>对象来处理排序决策(见“插入相等性和顺序”)。这必须与最初用于排序数组的任何比较器一致。如果未提供比较器,则将基于其实现的IComparable / IComparable<T>应用类型的默认排序算法。
IndexOf和LastIndexOf方法在数组上执行简单的枚举,返回与给定值匹配的第一个(或最后一个)元素的位置。
基于谓词的搜索方法允许方法委托或 lambda 表达式来判断给定元素是否“匹配”。谓词只是接受对象并返回true或false的委托:
public delegate bool Predicate<T> (T object);
在以下示例中,我们搜索一个字符串数组,以找到包含字母“a”的名称:
string[] names = { "Rodney", "Jack", "Jill" };
string match = Array.Find (names, ContainsA);
Console.WriteLine (match); // Jack
ContainsA (string name) { return name.Contains ("a"); }
这是使用 lambda 表达式缩短的相同代码:
string[] names = { "Rodney", "Jack", "Jill" };
string match = Array.Find (names, n => n.Contains ("a")); // Jack
FindAll返回满足谓词条件的所有项的数组。事实上,它等同于System.Linq命名空间中的Enumerable.Where,但FindAll返回匹配项的数组,而不是相同的IEnumerable<T>。
Exists在任何数组成员满足给定谓词时返回true,等同于System.Linq.Enumerable中的Any。
TrueForAll在所有项满足谓词条件时返回true,等同于System.Linq.Enumerable中的All。
排序
Array具有以下内置排序方法:
// For sorting a single array:
public static void Sort<T> (T[] array);
public static void Sort (Array array);
// For sorting a pair of arrays:
public static void Sort<TKey,TValue> (TKey[] keys, TValue[] items);
public static void Sort (Array keys, Array items);
这些方法还可以接受以下内容的重载:
int index // Starting index at which to begin sorting
int length // Number of elements to sort
IComparer<T> comparer // Object making ordering decisions
Comparison<T> comparison // Delegate making ordering decisions
以下演示了 Sort 的最简单用法:
int[] numbers = { 3, 2, 1 };
Array.Sort (numbers); // Array is now { 1, 2, 3 }
接受一对数组的方法通过将每个数组的项配对重新排列来工作,并基于第一个数组进行排序决策。在下一个示例中,数字及其对应的单词都按数字顺序排序:
int[] numbers = { 3, 2, 1 };
string[] words = { "three", "two", "one" };
Array.Sort (numbers, words);
// numbers array is now { 1, 2, 3 }
// words array is now { "one", "two", "three" }
Array.Sort 要求数组中的元素实现 IComparable(参见 “排序比较”)。这意味着大多数内置的 C# 类型(例如前面示例中的整数)可以被排序。如果元素不是本质上可比较的,或者你想要覆盖默认的排序顺序,你必须提供一个自定义的 comparison 提供者给 Sort,用于报告两个元素的相对位置。有多种方法可以实现这一点:
-
通过实现
IComparer/IComparer<T>的辅助对象(见 “插入相等性和顺序”) -
通过
Comparison委托:
public delegate int Comparison<T> (T x, T y);
Comparison 委托遵循与 IComparer<T>.CompareTo 相同的语义:如果 x 在 y 之前,则返回负整数;如果 x 在 y 之后,则返回正整数;如果 x 和 y 的排序位置相同,则返回 0。
在下面的示例中,我们对整数数组进行排序,使奇数首先出现:
int[] numbers = { 1, 2, 3, 4, 5 };
Array.Sort (numbers, (x, y) => x % 2 == y % 2 ? 0 : x % 2 == 1 ? -1 : 1);
// numbers array is now { 1, 3, 5, 2, 4 }
注意
作为调用 Sort 的替代方案,你可以使用 LINQ 的 OrderBy 和 ThenBy 操作符。与 Array.Sort 不同,LINQ 操作符不会改变原始数组,而是在一个新的 IEnumerable<T> 序列中输出排序后的结果。
颠倒元素
以下 Array 方法颠倒数组中所有或部分元素的顺序:
public static void Reverse (Array array);
public static void Reverse (Array array, int index, int length);
复制
Array 提供了四种方法来执行浅复制:Clone、CopyTo、Copy 和 ConstrainedCopy。前两者是实例方法,后两者是静态方法。
Clone 方法返回一个全新的(浅复制的)数组。CopyTo 和 Copy 方法复制数组的连续子集。复制多维矩形数组需要将多维索引映射到线性索引。例如,3 × 3 数组中的中间方块(position[1,1])用索引 4 表示,计算方法为:1 * 3 + 1。源范围和目标范围可以重叠而不会造成问题。
ConstrainedCopy 执行一个 原子 操作:如果无法成功复制所有请求的元素(例如由于类型错误),则操作将回滚。
Array 还提供了一个 AsReadOnly 方法,返回一个包装器,防止元素被重新赋值。
转换和调整大小
Array.ConvertAll 创建并返回一个新的 TOutput 元素类型的数组,调用提供的 Converter 委托来复制元素。Converter 的定义如下:
public delegate TOutput Converter<TInput,TOutput> (TInput input)
以下示例将一个浮点数数组转换为整数数组:
float[] reals = { 1.3f, 1.5f, 1.8f };
int[] wholes = Array.ConvertAll (reals, r => Convert.ToInt32 (r));
// wholes array is { 1, 2, 2 }
Resize方法通过创建一个新数组并复制元素来工作,通过引用参数返回新数组。然而,其他对象中对原始数组的引用将保持不变。
注意
System.Linq命名空间提供了一系列适用于数组转换的扩展方法。这些方法返回一个IEnumerable<T>,你可以通过Enumerable的ToArray方法将其转换回数组。
列表、队列、堆栈和集合
.NET 提供了一组基本的具体集合类,实现了本章描述的接口。本节集中讨论类似于列表的集合(而不是类似于字典的集合,我们将在“字典”中讨论)。与我们之前讨论的接口一样,你通常可以选择每种类型的泛型或非泛型版本。在灵活性和性能方面,泛型类胜出,使它们的非泛型对应物除了向后兼容性之外都显得多余。这与集合接口的情况不同,对于集合接口,非泛型版本偶尔还是有用的。
在本节描述的类中,泛型List类是最常用的。
List和 ArrayList
泛型List和非泛型ArrayList类提供了一个动态大小的对象数组,是集合类中最常用的之一。ArrayList实现了IList接口,而List<T>则实现了IList和IList<T>(以及只读版本IReadOnlyList<T>)。与数组不同的是,所有接口都是公开实现的,诸如Add和Remove等方法都被暴露出来,并且按预期工作。
内部,List<T>和ArrayList通过维护一个对象的内部数组来工作,在达到容量时会被替换为一个更大的数组。附加元素是高效的(因为通常在末尾有空闲槽位),但插入元素可能很慢(因为必须将插入点之后的所有元素移动以创建空闲槽位),删除元素也可能很慢(特别是在接近开始位置时)。与数组一样,如果在已排序的列表上使用BinarySearch方法,搜索是高效的,但在其他情况下效率较低,因为必须逐个检查每个项目。
注意
如果 T 是值类型,List<T>比ArrayList快几倍,因为List<T>避免了元素装箱和拆箱的开销。
List<T>和ArrayList提供了接受现有元素集合的构造函数:这些函数将现有集合中的每个元素复制到新的List<T>或ArrayList中:
public class List<T> : IList<T>, IReadOnlyList<T>
{
public List ();
public List (IEnumerable<T> collection);
public List (int capacity);
// Add+Insert
public void Add (T item);
public void AddRange (IEnumerable<T> collection);
public void Insert (int index, T item);
public void InsertRange (int index, IEnumerable<T> collection);
// Remove
public bool Remove (T item);
public void RemoveAt (int index);
public void RemoveRange (int index, int count);
public int RemoveAll (Predicate<T> match);
// Indexing
public T this [int index] { get; set; }
public List<T> GetRange (int index, int count);
public Enumerator<T> GetEnumerator();
// Exporting, copying and converting:
public T[] ToArray();
public void CopyTo (T[] array);
public void CopyTo (T[] array, int arrayIndex);
public void CopyTo (int index, T[] array, int arrayIndex, int count);
public ReadOnlyCollection<T> AsReadOnly();
public List<TOutput> ConvertAll<TOutput> (Converter <T,TOutput>
converter);
// Other:
public void Reverse(); // Reverses order of elements in list.
public int Capacity { get;set; } // Forces expansion of internal array.
public void TrimExcess(); // Trims internal array back to size.
public void Clear(); // Removes all elements, so Count=0.
}
public delegate TOutput Converter <TInput, TOutput> (TInput input);
除了这些成员外,List<T>还提供了所有Array的搜索和排序方法的实例版本。
下面的代码演示了List的属性和方法(有关搜索和排序的示例,请参见“数组类”):
var words = new List<string>(); // New string-typed list
words.Add ("melon");
words.Add ("avocado");
words.AddRange (["banana", "plum"]);
words.Insert (0, "lemon"); // Insert at start
words.InsertRange (0, ["peach", "nashi"]); // Insert at start
words.Remove ("melon");
words.RemoveAt (3); // Remove the 4th element
words.RemoveRange (0, 2); // Remove first 2 elements
// Remove all strings starting in 'n':
words.RemoveAll (s => s.StartsWith ("n"));
Console.WriteLine (words [0]); // first word
Console.WriteLine (words [words.Count - 1]); // last word
foreach (string s in words) Console.WriteLine (s); // all words
List<string> subset = words.GetRange (1, 2); // 2nd->3rd words
string[] wordsArray = words.ToArray(); // Creates a new typed array
// Copy first two elements to the end of an existing array:
string[] existing = new string [1000];
words.CopyTo (0, existing, 998, 2);
List<string> upperCaseWords = words.ConvertAll (s => s.ToUpper());
List<int> lengths = words.ConvertAll (s => s.Length);
非泛型ArrayList类需要笨拙的类型转换,正如下面的示例所示:
ArrayList al = new ArrayList();
al.Add ("hello");
string first = (string) al [0];
string[] strArr = (string[]) al.ToArray (typeof (string));
此类转换无法通过编译器验证;以下代码编译成功,但在运行时失败:
int first = (int) al [0]; // Runtime exception
注意
ArrayList在功能上类似于List<object>。当您需要一个包含没有共同基类型(除object外)的混合类型元素列表时,这两者都非常有用。在这种情况下选择ArrayList的一个可能优势是,如果需要使用反射处理列表(见第十九章),则使用非泛型的ArrayList比List<object>更容易。反射在非泛型ArrayList上比List<object>更容易。
如果导入System.Linq命名空间,可以通过调用Cast然后ToList将ArrayList转换为泛型List:
ArrayList al = new ArrayList();
al.AddRange (new[] { 1, 5, 9 } );
List<int> list = al.Cast<int>().ToList();
Cast和ToList是System.Linq.Enumerable类中的扩展方法。
LinkedList
LinkedList<T>是泛型双向链表(见图 7-4)。双向链表是一系列节点,每个节点分别引用其前后节点和实际元素。其主要优势在于可以高效地在列表中的任何位置插入元素,只需创建一个新节点并更新少数引用即可。但是,首先找到要插入节点的位置可能会很慢,因为没有内在机制直接索引到链表;每个节点必须遍历,且无法进行二分查找。

图 7-4. LinkedList
LinkedList<T>实现了IEnumerable<T>和ICollection<T>(以及它们的非泛型版本),但不支持IList<T>,因为不支持按索引访问。列表节点是通过以下类实现的:
public sealed class LinkedListNode<T>
{
public LinkedList<T> List { get; }
public LinkedListNode<T> Next { get; }
public LinkedListNode<T> Previous { get; }
public T Value { get; set; }
}
在添加节点时,可以指定其相对于另一个节点的位置或位于列表的起始/末尾。LinkedList<T>提供了以下方法:
public void AddFirst(LinkedListNode<T> node);
public LinkedListNode<T> AddFirst (T value);
public void AddLast (LinkedListNode<T> node);
public LinkedListNode<T> AddLast (T value);
public void AddAfter (LinkedListNode<T> node, LinkedListNode<T> newNode);
public LinkedListNode<T> AddAfter (LinkedListNode<T> node, T value);
public void AddBefore (LinkedListNode<T> node, LinkedListNode<T> newNode);
public LinkedListNode<T> AddBefore (LinkedListNode<T> node, T value);
还提供了类似的方法来删除元素:
public void Clear();
public void RemoveFirst();
public void RemoveLast();
public bool Remove (T value);
public void Remove (LinkedListNode<T> node);
LinkedList<T>具有内部字段来跟踪列表中的元素数量以及列表的头部和尾部。这些在以下公共属性中公开:
public int Count { get; } // Fast
public LinkedListNode<T> First { get; } // Fast
public LinkedListNode<T> Last { get; } // Fast
LinkedList<T>也支持以下搜索方法(每种方法都要求列表在内部枚举):
public bool Contains (T value);
public LinkedListNode<T> Find (T value);
public LinkedListNode<T> FindLast (T value);
最后,LinkedList<T>支持将元素复制到数组以进行索引处理,并获取枚举器以支持foreach语句:
public void CopyTo (T[] array, int index);
public Enumerator<T> GetEnumerator();
下面演示了LinkedList<string>的使用:
var tune = new LinkedList<string>();
tune.AddFirst ("do"); // do
tune.AddLast ("so"); // do - so
tune.AddAfter (tune.First, "re"); // do - re- so
tune.AddAfter (tune.First.Next, "mi"); // do - re - mi- so
tune.AddBefore (tune.Last, "fa"); // do - re - mi - fa- so
tune.RemoveFirst(); // re - mi - fa - so
tune.RemoveLast(); // re - mi - fa
LinkedListNode<string> miNode = tune.Find ("mi");
tune.Remove (miNode); // re - fa
tune.AddFirst (miNode); // mi- re - fa
foreach (string s in tune) Console.WriteLine (s);
Queue和 Queue
Queue<T>和Queue是先进先出(FIFO)的数据结构,提供了Enqueue(将项目添加到队列的尾部)和Dequeue(检索并移除队列头部的项目)方法。还提供了一个Peek方法,在不移除元素的情况下返回队列头部的元素,并提供了一个Count属性(在出队之前检查元素是否存在时非常有用)。
尽管队列是可枚举的,但它们不实现IList<T>/IList,因为无法直接通过索引访问成员。但是,提供了一个ToArray方法,可以将元素复制到数组中,从而可以随机访问它们:
public class Queue<T> : IEnumerable<T>, ICollection, IEnumerable
{
public Queue();
public Queue (IEnumerable<T> collection); // Copies existing elements
public Queue (int capacity); // To lessen auto-resizing
public void Clear();
public bool Contains (T item);
public void CopyTo (T[] array, int arrayIndex);
public int Count { get; }
public T Dequeue();
public void Enqueue (T item);
public Enumerator<T> GetEnumerator(); // To support foreach
public T Peek();
public T[] ToArray();
public void TrimExcess();
}
下面是使用Queue<int>的示例:
var q = new Queue<int>();
q.Enqueue (10);
q.Enqueue (20);
int[] data = q.ToArray(); // Exports to an array
Console.WriteLine (q.Count); // "2"
Console.WriteLine (q.Peek()); // "10"
Console.WriteLine (q.Dequeue()); // "10"
Console.WriteLine (q.Dequeue()); // "20"
Console.WriteLine (q.Dequeue()); // throws an exception (queue empty)
队列使用内部调整大小的数组实现,与泛型List类似。队列维护直接指向头部和尾部元素的索引;因此,入队和出队操作非常快(除非需要内部调整大小)。
Stack<T> 和 Stack
Stack<T> 和 Stack 是后进先出(LIFO)的数据结构,提供Push(将项目添加到堆栈顶部)和Pop(检索并移除堆栈顶部的元素)方法。还提供了非破坏性的Peek方法,以及Count属性和ToArray方法用于导出数据进行随机访问:
public class Stack<T> : IEnumerable<T>, ICollection, IEnumerable
{
public Stack();
public Stack (IEnumerable<T> collection); // Copies existing elements
public Stack (int capacity); // Lessens auto-resizing
public void Clear();
public bool Contains (T item);
public void CopyTo (T[] array, int arrayIndex);
public int Count { get; }
public Enumerator<T> GetEnumerator(); // To support foreach
public T Peek();
public T Pop();
public void Push (T item);
public T[] ToArray();
public void TrimExcess();
}
下面的示例演示了Stack<int>:
var s = new Stack<int>();
s.Push (1); // Stack = 1
s.Push (2); // Stack = 1,2
s.Push (3); // Stack = 1,2,3
Console.WriteLine (s.Count); // Prints 3
Console.WriteLine (s.Peek()); // Prints 3, Stack = 1,2,3
Console.WriteLine (s.Pop()); // Prints 3, Stack = 1,2
Console.WriteLine (s.Pop()); // Prints 2, Stack = 1
Console.WriteLine (s.Pop()); // Prints 1, Stack = <empty>
Console.WriteLine (s.Pop()); // throws exception
栈使用内部调整大小的数组实现,与Queue<T>和List<T>类似。
BitArray
BitArray是一种动态大小的压缩布尔值集合。它比简单的bool数组和泛型List<bool>更节省内存,因为每个值仅使用一位,而bool类型通常为每个值占用一个字节。
BitArray的索引器可以读取和写入单个位:
var bits = new BitArray(2);
bits[1] = true;
有四个位操作方法(And、Or、Xor和Not)。除了最后一个外,所有方法都接受另一个BitArray:
bits.Xor (bits); // Bitwise exclusive-OR bits with itself
Console.WriteLine (bits[1]); // False
HashSet 和 SortedSet
HashSet<T> 和 SortedSet<T> 具有以下显著特点:
-
它们的
Contains方法使用基于哈希的快速查找。 -
它们不存储重复的元素,并且会默默地忽略添加重复元素的请求。
-
你不能通过位置访问元素。
SortedSet<T>保持元素有序,而HashSet<T>则不是。
HashSet<T> 和 SortedSet<T> 类型的共同点由接口ISet<T>捕获。从.NET 5 开始,这些类还实现了一个名为IReadOnlySet<T>的接口,这个接口也被不可变集合类型实现(参见“不可变集合”)。
HashSet<T>使用哈希表实现,仅存储键;SortedSet<T>使用红黑树实现。
这两个集合都实现了ICollection<T>接口,并提供了如Contains、Add和Remove等你预期的方法。此外,还提供了基于谓词的移除方法RemoveWhere。
下面的代码从现有集合构造了一个HashSet<char>,测试了成员资格,然后枚举了集合(注意没有重复项):
var letters = new HashSet<char> ("the quick brown fox");
Console.WriteLine (letters.Contains ('t')); // true
Console.WriteLine (letters.Contains ('j')); // false
foreach (char c in letters) Console.Write (c); // the quickbrownfx
(我们可以将string传递给HashSet<char>的构造函数的原因是因为string实现了IEnumerable<char>接口。)
真正有趣的方法是集合操作。以下集合操作是破坏性的,因为它们修改了集合:
public void UnionWith (IEnumerable<T> other); // Adds
public void IntersectWith (IEnumerable<T> other); // Removes
public void ExceptWith (IEnumerable<T> other); // Removes
public void SymmetricExceptWith (IEnumerable<T> other); // Removes
而以下方法仅查询集合,因此是非破坏性的:
public bool IsSubsetOf (IEnumerable<T> other);
public bool IsProperSubsetOf (IEnumerable<T> other);
public bool IsSupersetOf (IEnumerable<T> other);
public bool IsProperSupersetOf (IEnumerable<T> other);
public bool Overlaps (IEnumerable<T> other);
public bool SetEquals (IEnumerable<T> other);
UnionWith 将第二个集合中的所有元素添加到原始集合(不包括重复项)。 IntersectWith 删除不在两个集合中的元素。我们可以如下提取字符集中的所有元音字母:
var letters = new HashSet<char> ("the quick brown fox");
letters.IntersectWith ("aeiou");
foreach (char c in letters) Console.Write (c); // euio
ExceptWith 从源集合中移除指定的元素。在这里,我们从集合中剔除所有元音字母:
var letters = new HashSet<char> ("the quick brown fox");
letters.ExceptWith ("aeiou");
foreach (char c in letters) Console.Write (c); // th qckbrwnfx
SymmetricExceptWith 会删除除了两个集合中唯一的元素之外的所有元素:
var letters = new HashSet<char> ("the quick brown fox");
letters.SymmetricExceptWith ("the lazy brown fox");
foreach (char c in letters) Console.Write (c); // quicklazy
请注意,因为 HashSet<T> 和 SortedSet<T> 实现了 IEnumerable<T>,所以您可以将另一种类型的集合(或集合)用作任何集合操作方法的参数。
SortedSet<T> 提供 HashSet<T> 的所有成员,以及以下内容:
public virtual SortedSet<T> GetViewBetween (T lowerValue, T upperValue)
public IEnumerable<T> Reverse()
public T Min { get; }
public T Max { get; }
SortedSet<T> 在其构造函数中还接受一个可选的 IComparer<T>(而不是相等比较器)。
下面是一个将相同字母加载到 SortedSet<char> 的示例:
var letters = new SortedSet<char> ("the quick brown fox");
foreach (char c in letters) Console.Write (c); // bcefhiknoqrtuwx
继续之前,我们可以如下获取集合中 f 和 i 之间的字母:
foreach (char c in letters.GetViewBetween ('f', 'i'))
Console.Write (c); // fhi
字典
字典是一种每个元素都是键/值对的集合。字典最常用于查找和排序列表。
.NET 定义了字典的标准协议,通过接口 IDictionary 和 IDictionary <TKey, TValue>,以及一组通用的字典类。这些类在以下方面各不相同:
-
是否按排序顺序存储项
-
是否可以通过位置(索引)和键访问项
-
是否为泛型或非泛型
-
是否从大字典中通过键快速或慢速检索项
表 7-1 总结了每个字典类及其在这些方面的不同之处。性能时间以毫秒为单位,并基于在 1.5 GHz PC 上对整数键和值的字典执行 50,000 次操作。(相同底层集合结构使用泛型和非泛型对应体现出来的性能差异,是由于装箱而产生的。)
表 7-1. 字典类
| 类型 | 内部结构 | 是否按索引检索? | 内存开销(平均每项字节) | 随机插入速度 | 顺序插入速度 | 按键检索速度 |
|---|---|---|---|---|---|---|
| 未排序 | ||||||
Dictionary <K,V> |
哈希表 | 否 | 22 | 30 | 30 | 20 |
Hashtable |
哈希表 | 否 | 38 | 50 | 50 | 30 |
ListDictionary |
链表 | 否 | 36 | 50,000 | 50,000 | 50,000 |
OrderedDictionary |
哈希表 + 数组 | 是 | 59 | 70 | 70 | 40 |
| 已排序 | ||||||
SortedDictionary <K,V> |
红黑树 | 否 | 20 | 130 | 100 | 120 |
SortedList <K,V> |
2xArray | 是 | 2 | 3,300 | 30 | 40 |
SortedList |
2xArray | 是 | 27 | 4,500 | 100 | 180 |
用大 O 表示法,按键检索时间如下:
-
Hashtable、Dictionary和OrderedDictionary的大 O 为 O(1) -
对于
SortedDictionary和SortedList,为 O(log n)。 -
对于
ListDictionary(以及List<T>等非字典类型),为 O(n)
n代表集合中的元素数量。
IDictionary<TKey,TValue>
IDictionary<TKey,TValue>定义了所有基于键/值的集合的标准协议。它通过添加方法和属性来扩展ICollection<T>,以根据任意类型的键访问元素。
public interface IDictionary <TKey, TValue> :
ICollection <KeyValuePair <TKey, TValue>>, IEnumerable
{
bool ContainsKey (TKey key);
bool TryGetValue (TKey key, out TValue value);
void Add (TKey key, TValue value);
bool Remove (TKey key);
TValue this [TKey key] { get; set; } // Main indexer - by key
ICollection <TKey> Keys { get; } // Returns just keys
ICollection <TValue> Values { get; } // Returns just values
}
注意
还有一个名为IReadOnlyDictionary<TKey,TValue>的接口,定义了字典成员的只读子集。
要向字典中添加项目,可以调用Add或使用索引器的设置访问器——后者将项目添加到字典中(如果键尚不存在)或更新项目(如果已存在)。所有字典实现都禁止重复键,因此如果使用相同键两次调用Add会抛出异常。
要从字典中检索项目,请使用索引器或TryGetValue方法。如果键不存在,索引器会抛出异常,而TryGetValue会返回false。你可以通过调用ContainsKey显式测试成员资格;然而,如果你随后检索项目,则会产生两次查找的成本。
直接枚举IDictionary<TKey,TValue>会返回一个KeyValuePair结构的序列:
public struct KeyValuePair <TKey, TValue>
{
public TKey Key { get; }
public TValue Value { get; }
}
可以通过字典的Keys/Values属性仅枚举键或值。
我们在下一节中演示如何使用这个接口与泛型Dictionary类。
IDictionary
非泛型的IDictionary接口在原则上与IDictionary<TKey,TValue>相同,除了两个重要的功能差异。了解这些差异很重要,因为IDictionary在遗留代码中(包括.NET BCL 本身的某些位置)中出现:
-
通过索引器检索不存在的键会返回 null(而不是抛出异常)。
-
Contains用于测试成员资格,而不是ContainsKey。
枚举非泛型IDictionary会返回一个DictionaryEntry结构的序列:
public struct DictionaryEntry
{
public object Key { get; set; }
public object Value { get; set; }
}
Dictionary<TKey,TValue>和 Hashtable
泛型Dictionary类是最常用的集合之一(与List<T>集合一样)。它使用哈希表数据结构存储键和值,速度快且高效。
注意
非泛型的Dictionary<TKey,TValue>版本称为Hashtable;没有称为Dictionary的非泛型类。当我们简称Dictionary时,指的是泛型Dictionary<TKey,TValue>类。
Dictionary实现了泛型和非泛型的IDictionary接口,泛型的IDictionary是公开的。实际上,Dictionary是泛型IDictionary的“教科书”实现。
使用方法如下:
var d = new Dictionary<string, int>();
d.Add("One", 1);
d["Two"] = 2; // adds to dictionary because "two" isn't already present
d["Two"] = 22; // updates dictionary because "two" is now present
d["Three"] = 3;
Console.WriteLine (d["Two"]); // Prints "22"
Console.WriteLine (d.ContainsKey ("One")); // true (fast operation)
Console.WriteLine (d.ContainsValue (3)); // true (slow operation)
int val = 0;
if (!d.TryGetValue ("onE", out val))
Console.WriteLine ("No val"); // "No val" (case sensitive)
// Three different ways to enumerate the dictionary:
foreach (KeyValuePair<string, int> kv in d) // One; 1
Console.WriteLine (kv.Key + "; " + kv.Value); // Two; 22
// Three; 3
foreach (string s in d.Keys) Console.Write (s); // OneTwoThree
Console.WriteLine();
foreach (int i in d.Values) Console.Write (i); // 1223
其底层的哈希表通过将每个元素的键转换为整数哈希码(一种伪唯一的值),然后应用算法将哈希码转换为哈希键。这个哈希键在内部用于确定条目属于哪个“桶”。如果桶中包含多个值,则在桶上执行线性搜索。一个好的哈希函数不会力求返回严格唯一的哈希码(通常是不可能的),它力求返回均匀分布在 32 位整数空间中的哈希码。这避免了最终出现少数非常大(且低效)的桶的情况。
字典可以使用任何类型的键工作,只要能够确定键之间的相等性并获取哈希码。默认情况下,通过键的object.Equals方法确定相等性,并通过键的GetHashCode方法获取伪唯一的哈希码。可以通过覆盖这些方法或在构建字典时提供IEqualityComparer对象来更改此行为。在使用字符串键时,常见的应用是指定一个不区分大小写的相等性比较器:
var d = new Dictionary<string, int> (StringComparer.OrdinalIgnoreCase);
我们在“插入相等性和顺序”中进一步讨论这一点。
与许多其他类型的集合一样,可以通过在构造函数中指定集合的预期大小来略微提高字典的性能,从而避免或减少内部调整大小操作的需求。
非泛型版本命名为Hashtable,在功能上类似,除了因其公开了先前讨论过的非泛型IDictionary接口而产生的差异。
Dictionary和Hashtable的缺点是项目不会排序。此外,不会保留项目添加的原始顺序。与所有字典一样,不允许重复键。
注意
当泛型集合在 2005 年引入时,CLR 团队选择根据它们的表示方式(Dictionary、List)而不是它们的内部实现方式(Hashtable、ArrayList)来命名它们。尽管这样做是好的,因为它给了他们后来更改实现的自由,但这也意味着性能约定(通常是选择一种集合类型的最重要标准)不再在名称中体现。
OrderedDictionary
一个OrderedDictionary是一个非泛型字典,它以元素添加的顺序维护元素。使用OrderedDictionary,你可以通过索引和键访问元素。
注意
一个OrderedDictionary不是排序字典。
一个OrderedDictionary是Hashtable和ArrayList的组合。这意味着它具有Hashtable的所有功能,还具有诸如RemoveAt和整数索引器等功能。它还公开了Keys和Values属性,以原始顺序返回元素。
这个类在 .NET 2.0 中引入;然而,奇怪的是,没有泛型版本。
ListDictionary 和 HybridDictionary
ListDictionary 使用单向链表存储底层数据。它不提供排序,尽管它保留了项目的原始输入顺序。对于大型列表,ListDictionary 速度非常慢。它唯一真正的“成就”是在非常小的列表(少于 10 项)中的效率。
HybridDictionary 是 ListDictionary,在达到一定大小时会自动转换为 Hashtable,以解决 ListDictionary 在性能上的问题。其思想是在字典较小时获得较低的内存占用,并在字典较大时获得良好的性能。然而,考虑到从一个转换到另一个的开销——而且 Dictionary 在任何情况下都不会过于笨重或缓慢——因此,一开始使用 Dictionary 不会带来不合理的困扰。
这两个类仅以非泛型形式存在。
排序字典
.NET BCL 提供了两个字典类,其内部结构使其内容始终按键排序:
-
SortedDictionary<TKey,TValue> -
SortedList<TKey,TValue>¹
(在本节中,我们将 <TKey,TValue> 缩写为 <,>。)
SortedDictionary<,> 使用红黑树:一种设计成在任何插入或检索场景中表现一致良好的数据结构。
SortedList<,> 在内部实现中使用有序数组对,通过二分查找提供快速检索,但插入性能较差(因为需要移动现有值以腾出空间来放置新条目)。
SortedDictionary<,> 在随机顺序插入元素时比 SortedList<,> 快得多(特别是对于大型列表)。然而,SortedList<,> 有一个额外的能力:可以通过索引以及键访问项目。通过排序列表,您可以直接访问排序序列中的第 n 个元素(通过 Keys/Values 属性的索引器)。要在 SortedDictionary<,> 中做同样的事情,您必须手动枚举 n 个项目。(或者,您可以编写一个将排序字典与列表类结合的类。)
这三个集合都不允许重复键(就像所有字典一样)。
下面的示例使用反射将 System.Object 中定义的所有方法加载到按名称排序的排序列表中,然后枚举它们的键和值:
// MethodInfo is in the System.Reflection namespace
var sorted = new SortedList <string, MethodInfo>();
foreach (MethodInfo m in typeof (object).GetMethods())
sorted [m.Name] = m;
foreach (string name in sorted.Keys)
Console.WriteLine (name);
foreach (MethodInfo m in sorted.Values)
Console.WriteLine (m.Name + " returns a " + m.ReturnType);
这里是第一次枚举的结果:
Equals
GetHashCode
GetType
ReferenceEquals
ToString
这里是第二次枚举的结果:
Equals returns a System.Boolean
GetHashCode returns a System.Int32
GetType returns a System.Type
ReferenceEquals returns a System.Boolean
ToString returns a System.String
注意,我们通过其索引器填充了字典。如果我们使用 Add 方法,它将抛出异常,因为我们反射的 object 类重载了 Equals 方法,并且不能将相同的键两次添加到字典中。通过使用索引器,后续条目将覆盖较早的条目,从而防止此错误。
注意
可以通过将每个值元素设置为列表来存储相同键的多个成员:
SortedList <string, List<MethodInfo>>
扩展我们的示例,以下代码检索其键为 "GetHashCode" 的 MethodInfo,就像普通字典一样:
Console.WriteLine (sorted ["GetHashCode"]); // Int32 GetHashCode()
到目前为止,我们所做的一切也适用于 SortedDictionary<,>。然而,以下两行代码,用于检索最后一个键和值,仅适用于排序列表:
Console.WriteLine (sorted.Keys [sorted.Count - 1]); // ToString
Console.WriteLine (sorted.Values[sorted.Count - 1].IsVirtual); // True
可定制的集合和代理
在前面部分讨论的集合类非常方便,因为可以直接实例化它们,但是它们不允许控制将项添加到集合或从集合中移除时发生的情况。在应用程序中的强类型集合中,有时需要这种控制;例如:
-
当添加或删除项时触发事件
-
因为添加或删除项而更新属性
-
检测“非法”添加/删除操作并抛出异常(例如,如果操作违反业务规则)
.NET BCL 提供了专门用于此目的的集合类,位于 System.Collections.ObjectModel 命名空间中。这些实质上是通过将方法转发到底层集合来实现 IList<T> 或 IDictionary<,> 的代理或包装器。每个 Add、Remove 或 Clear 操作都通过一个虚拟方法路由,该方法在重写时充当“网关”。
可定制的集合类通常用于公开的集合,例如,在 System.Windows.Form 类上公开的控件集合。
Collection 和 CollectionBase
Collection<T> 类是 List<T> 的可定制包装器。
除了实现 IList<T> 和 IList 外,它还定义了四个额外的虚拟方法和一个受保护的属性,如下所示:
public class Collection<T> :
IList<T>, ICollection<T>, IEnumerable<T>, IList, ICollection, IEnumerable
{
// ...
protected virtual void ClearItems();
protected virtual void InsertItem (int index, T item);
protected virtual void RemoveItem (int index);
protected virtual void SetItem (int index, T item);
protected IList<T> Items { get; }
}
虚拟方法提供了一种网关,通过它可以“挂接”以更改或增强列表的正常行为。受保护的 Items 属性允许实现者直接访问“内部列表”——这用于在不触发虚拟方法的情况下进行内部更改。
不需要重写虚拟方法;它们可以保持不变,直到需要更改列表的默认行为。以下示例演示了 Collection<T> 的典型“骨架”用法:
Zoo zoo = new Zoo();
zoo.Animals.Add (new Animal ("Kangaroo", 10));
zoo.Animals.Add (new Animal ("Mr Sea Lion", 20));
foreach (Animal a in zoo.Animals) Console.WriteLine (a.Name);
public class Animal
{
public string Name;
public int Popularity;
public Animal (string name, int popularity)
{
Name = name; Popularity = popularity;
}
}
public class AnimalCollection : Collection <Animal>
{
// AnimalCollection is already a fully functioning list of animals.
// No extra code is required.
}
public class Zoo // The class that will expose AnimalCollection.
{ // This would typically have additional members.
public readonly AnimalCollection Animals = new AnimalCollection();
}
AnimalCollection 现在不再仅仅是一个简单的 List<Animal>;它的作用是为将来的扩展提供一个基础。为了说明这一点,现在让我们为 Animal 添加一个 Zoo 属性,以便它可以引用它所居住的 Zoo,并且重写 Collection<Animal> 中的每个虚拟方法以自动维护该属性:
public class Animal
{
public string Name;
public int Popularity;
public Zoo Zoo { get; internal set; }
public Animal(string name, int popularity)
{
Name = name; Popularity = popularity;
}
}
public class AnimalCollection : Collection <Animal>
{
Zoo zoo;
public AnimalCollection (Zoo zoo) { this.zoo = zoo; }
protected override void InsertItem (int index, Animal item)
{
base.InsertItem (index, item);
item.Zoo = zoo;
}
protected override void SetItem (int index, Animal item)
{
base.SetItem (index, item);
item.Zoo = zoo;
}
protected override void RemoveItem (int index)
{
this [index].Zoo = null;
base.RemoveItem (index);
}
protected override void ClearItems()
{
foreach (Animal a in this) a.Zoo = null;
base.ClearItems();
}
}
public class Zoo
{
public readonly AnimalCollection Animals;
public Zoo() { Animals = new AnimalCollection (this); }
}
Collection<T> 还具有接受现有 IList<T> 的构造函数。与其他集合类不同,所提供的列表是代理而非复制,这意味着后续更改将反映在包装的 Collection<T> 中(尽管不会触发 Collection<T> 的虚拟方法)。反之,通过 Collection<T> 进行的更改将改变底层列表。
CollectionBase
CollectionBase是Collection<T>的非泛型版本。它提供了大部分与Collection<T>相同的功能,但使用起来更加笨拙。与模板方法InsertItem、RemoveItem、SetItem和ClearItem不同,CollectionBase具有“挂钩”方法,这使得所需的方法数量翻倍:OnInsert、OnInsertComplete、OnSet、OnSetComplete、OnRemove、OnRemoveComplete、OnClear和OnClearComplete。由于CollectionBase是非泛型的,当子类化它时,您还必须实现类型化的方法——至少是一个类型化的索引器和Add方法。
KeyedCollection<TKey,TItem>和 DictionaryBase
KeyedCollection<TKey,TItem>是Collection<TItem>的子类。它既增加了功能,即通过键访问项目(类似于字典),又减少了能力,即无法代理自己的内部列表。
键控集合在某种程度上类似于OrderedDictionary,因为它将线性列表与哈希表结合在一起。但与OrderedDictionary不同的是,它不实现IDictionary,也不支持键/值对的概念。相反,键是通过抽象的GetKeyForItem方法从项目本身获取的。这意味着枚举键控集合与枚举普通列表完全相同。
KeyedCollection<TKey,TItem>可以理解为Collection<TItem>加上按键快速查找的功能。
因为它是Collection<>的子类,所以键控集合继承了Collection<>的所有功能,除了在构造时指定现有列表的能力。它定义的附加成员如下:
public abstract class KeyedCollection <TKey, TItem> : Collection <TItem>
// ...
protected abstract TKey GetKeyForItem(TItem item);
protected void ChangeItemKey(TItem item, TKey newKey);
// Fast lookup by key - this is in addition to lookup by index.
public TItem this[TKey key] { get; }
protected IDictionary<TKey, TItem> Dictionary { get; }
}
GetKeyForItem是实现者从基础对象获取项键的方法。如果项的键属性发生更改,则必须调用ChangeItemKey方法以更新内部字典。Dictionary属性返回用于实现查找的内部字典,该字典在添加第一项时创建。可以通过在构造函数中指定创建阈值来更改此行为,延迟内部字典的创建直到达到阈值(在此期间,如果按键请求项,则执行线性搜索)。不指定创建阈值的一个很好的理由是,拥有有效的字典对于通过Dictionary的Keys属性获取键的ICollection<>是有用的。然后可以将此集合传递给公共属性。
KeyedCollection<,>最常见的用途是提供一组可通过索引和名称访问的项目。为了演示这一点,让我们重新访问动物园,这次将AnimalCollection实现为KeyedCollection<string,Animal>:
public class Animal
{
string name;
public string Name
{
get { return name; }
set {
if (Zoo != null) Zoo.Animals.NotifyNameChange (this, value);
name = value;
}
}
public int Popularity;
public Zoo Zoo { get; internal set; }
public Animal (string name, int popularity)
{
Name = name; Popularity = popularity;
}
}
public class AnimalCollection : KeyedCollection <string, Animal>
{
Zoo zoo;
public AnimalCollection (Zoo zoo) { this.zoo = zoo; }
internal void NotifyNameChange (Animal a, string newName) =>
this.ChangeItemKey (a, newName);
protected override string GetKeyForItem (Animal item) => item.Name;
// The following methods would be implemented as in the previous example
protected override void InsertItem (int index, Animal item)...
protected override void SetItem (int index, Animal item)...
protected override void RemoveItem (int index)...
protected override void ClearItems()...
}
public class Zoo
{
public readonly AnimalCollection Animals;
public Zoo() { Animals = new AnimalCollection (this); }
}
下面的代码演示了它的使用:
Zoo zoo = new Zoo();
zoo.Animals.Add (new Animal ("Kangaroo", 10));
zoo.Animals.Add (new Animal ("Mr Sea Lion", 20));
Console.WriteLine (zoo.Animals [0].Popularity); // 10
Console.WriteLine (zoo.Animals ["Mr Sea Lion"].Popularity); // 20
zoo.Animals ["Kangaroo"].Name = "Mr Roo";
Console.WriteLine (zoo.Animals ["Mr Roo"].Popularity); // 10
DictionaryBase
KeyedCollection的非泛型版本称为DictionaryBase。这个传统类采用非常不同的方法,它实现了IDictionary并使用笨拙的挂钩方法,比如CollectionBase:OnInsert、OnInsertComplete、OnSet、OnSetComplete、OnRemove、OnRemoveComplete、OnClear和OnClearComplete(另外还有OnGet)。实现IDictionary而不是采用KeyedCollection方法的主要优势在于,您无需对其进行子类化即可获取键。但由于DictionaryBase的主要目的是被子类化,所以这并不是优势。KeyedCollection中改进的模型几乎肯定是由于它是在几年后编写的,并且具有事后诸葛亮的好处。DictionaryBase最好被认为是用于向后兼容性。
ReadOnlyCollection
ReadOnlyCollection<T>是一个提供集合只读视图的包装器或代理。这在允许类公开对其内部仍可更新的集合进行只读访问时非常有用。
只读集合在其构造函数中接受输入集合,并保持对输入集合的永久引用。它不会静态复制输入集合,因此对输入集合的后续更改将通过只读包装器可见。
举例来说,假设你的班级想要向名为Names的字符串列表提供只读的公共访问权限。我们可以这样做:
public class Test
{
List<string> names = new List<string>();
public IReadOnlyList<string> Names => names;
}
虽然Names返回一个只读接口,消费者仍然可以在运行时向List<string>或IList<string>进行降级,然后在列表上调用Add、Remove或Clear。ReadOnlyCollection<T>提供了更健壮的解决方案:
public class Test
{
List<string> names = new List<string>();
public ReadOnlyCollection<string> Names { get; private set; }
public Test() => Names = new ReadOnlyCollection<string> (names);
public void AddInternally() => names.Add ("test");
}
现在,只有Test类中的成员才能更改名称列表:
Test t = new Test();
Console.WriteLine (t.Names.Count); // 0
t.AddInternally();
Console.WriteLine (t.Names.Count); // 1
t.Names.Add ("test"); // Compiler error
((IList<string>) t.Names).Add ("test"); // NotSupportedException
不可变集合
我们刚刚描述了ReadOnlyCollection<T>如何创建集合的只读视图。限制对集合或任何其他对象的写入(突变)能力简化了软件并减少了错误。
不可变集合扩展了这一原则,通过提供在初始化后无法修改的集合来实现。如果需要向不可变集合添加项,必须实例化一个新集合,保持旧集合不变。
不可变性是函数式编程的标志,并具有以下好处:
-
它消除了与更改状态相关的一大类错误。
-
它极大地简化了并行和多线程,通过避免我们在第十四章、22 章和 23 章中描述的大多数线程安全问题。
-
它使得代码更容易理解。
不可变性的缺点是当你需要进行更改时,你必须创建一个全新的对象。尽管有减少性能损耗的策略,我们在本节中讨论了一些缓解策略,包括能够重用原始结构的部分。
不可变集合是 .NET 的一部分(在 .NET Framework 中,它们通过 System.Collections.Immutable NuGet 包提供)。所有集合都定义在 System.Collections.Immutable 命名空间中:
| 类型 | 内部结构 |
|---|---|
ImmutableArray<T> |
数组 |
ImmutableList<T> |
AVL 树 |
ImmutableDictionary<K,V> |
AVL 树 |
ImmutableHashSet<T> |
AVL 树 |
ImmutableSortedDictionary<K,V> |
AVL 树 |
ImmutableSortedSet<T> |
AVL 树 |
ImmutableStack<T> |
链表 |
ImmutableQueue<T> |
链表 |
ImmutableArray<T> 和 ImmutableList<T> 类型都是 List<T> 的不可变版本。它们的功能相同,但性能特性不同,我们在 “不可变集合和性能” 中讨论。
不可变集合公开了与其可变对应物类似的公共接口。主要区别在于看似会修改集合(如 Add 或 Remove)的方法并不会修改原始集合;相反,它们会返回一个包含请求添加或移除项的新集合。这被称为 非破坏性变异。
注意
不可变集合防止添加和移除项;它们不阻止 项本身 被突变。要充分利用不可变性的优势,需要确保只有不可变的项最终进入不可变集合。
创建不可变集合
每种不可变集合类型都提供一个 Create<T>() 方法,该方法接受可选的初始值并返回一个初始化的不可变集合:
ImmutableArray<int> array = ImmutableArray.Create<int> (1, 2, 3);
每个集合还提供一个 CreateRange<T> 方法,其功能与 Create<T> 相同;区别在于它的参数类型是 IEnumerable<T> 而不是 params T[]。
你还可以使用适当的扩展方法(ToImmutableArray、ToImmutableList、ToImmutableDictionary 等)从现有的 IEnumerable<T> 创建不可变集合:
var list = new[] { 1, 2, 3 }.ToImmutableList();
操作不可变集合
Add 方法返回一个包含现有元素和新元素的新集合:
var oldList = ImmutableList.Create<int> (1, 2, 3);
ImmutableList<int> newList = oldList.Add (4);
Console.WriteLine (oldList.Count); // 3 (unaltered)
Console.WriteLine (newList.Count); // 4
Remove 方法的操作方式相同,返回一个移除了指定项的新集合。
反复添加或移除元素这种方式效率低下,因为每次添加或移除操作都会创建一个新的不可变集合。更好的解决方案是调用 AddRange(或 RemoveRange),它接受一个 IEnumerable<T> 的项,在一次操作中添加或移除所有项:
var anotherList = oldList.AddRange ([4, 5, 6]);
不可变列表和数组还定义了Insert和InsertRange方法,用于在特定索引处插入元素,RemoveAt方法用于按索引移除元素,以及RemoveAll方法,根据谓词条件移除元素。
构建器
对于更复杂的初始化需求,每个不可变集合类都定义了一个构建器对应物。构建器是与可变集合功能上等效的类,具有类似的性能特征。在数据初始化后,对构建器调用.ToImmutable()将返回一个不可变集合。
ImmutableArray<int>.Builder builder = ImmutableArray.CreateBuilder<int>();
builder.Add (1);
builder.Add (2);
builder.Add (3);
builder.RemoveAt (0);
ImmutableArray<int> myImmutable = builder.ToImmutable();
您也可以使用构建器批量对现有的不可变集合进行多次更新:
var builder2 = myImmutable.ToBuilder();
builder2.Add (4); // Efficient
builder2.Remove (2); // Efficient
... // More changes to builder...
// Return a new immutable collection with all the changes applied:
ImmutableArray<int> myImmutable2 = builder2.ToImmutable();
不可变集合与性能
大多数不可变集合在内部使用AVL 树,这允许添加/移除操作重用原始内部结构的部分,而不必从头开始重新创建整个结构。这减少了添加/移除操作的开销,从可能是巨大的(对于大集合)降低到只是适度大,但这也使得读取操作变慢。最终结果是,大多数不可变集合在读取和写入方面都比其可变对应物慢。
受影响最严重的是ImmutableList<T>,在读取和添加操作方面,其速度比List<T>慢 10 到 200 倍(取决于列表的大小)。这就是为什么存在ImmutableArray<T>:通过在内部使用数组,它避免了读取操作的开销(在这方面,它的性能与普通可变数组相当)。但其反面是,在添加操作方面比(甚至)ImmutableList<T>慢得多,因为原始结构的任何部分都无法重用。
因此,当您希望不受阻碍的读取性能,并且不希望频繁调用Add或Remove(而不使用构建器)时,ImmutableArray<T>是理想的选择。
| 类型 | 读取性能 | 添加性能 |
|---|---|---|
ImmutableList<T> |
慢 | 慢 |
ImmutableArray<T> |
非常快 | 非常慢 |
注意
调用ImmutableArray上的Remove比在List<T>上调用Remove更昂贵——即使在移除第一个元素的最坏情况下——因为分配新集合会给垃圾收集器增加额外负载。
尽管整体上不可变集合可能会产生显著的性能成本,但保持总体量的透视是很重要的。在典型的笔记本电脑上,对具有百万个元素的ImmutableList执行Add操作仍然可能发生在小于一微秒的时间内,读取操作则在 100 纳秒以下。而且,如果需要在循环中执行写操作,可以使用构建器来避免累积的成本。
以下因素也有助于减轻成本:
-
不可变性允许轻松进行并发和并行处理(第二十三章),因此您可以利用所有可用的核心。使用可变状态进行并行处理很容易导致错误,并且需要使用锁或并发集合,这两者都会影响性能。
-
使用不可变性,您无需“防御性地复制”集合或数据结构以防止意外更改。这是在编写 Visual Studio 的最新部分时倾向于使用不可变集合的一个因素。
-
在大多数典型程序中,很少有足够多的项使得这些差异有影响。
除了 Visual Studio 外,性能良好的 Microsoft Roslyn 工具链也使用不可变集合构建,显示了其益处大于成本的情况。
冻结集合
从.NET 8 开始,System.Collections.Frozen命名空间包含以下两个只读集合类:
FrozenDictionary<TKey,TValue>
FrozenSet<T>
这些类似于ImmutableDictionary<K,V>和ImmutableHashSet<T>,但缺少非破坏性变异(例如Add或Remove)的方法,从而实现了高度优化的读取性能。要创建冻结集合,您可以从另一个集合或序列开始,然后调用ToFrozenDictionary或ToFrozenSet扩展方法:
int[] numbers = { 10, 20, 30 };
FrozenSet<int> frozen = numbers.ToFrozenSet();
Console.WriteLine (frozen.Contains (10)); // True
冻结集合非常适合在程序开始时初始化并在应用程序的整个生命周期中使用的查找操作:
class Disassembler
{
public readonly static IReadOnlyDictionary<string,string> OpCodeLookup =
new Dictionary<string, string>()
{
{ "ADC", "Add with Carry" },
{ "ADD", "Add" },
{ "AND", "Logical AND" },
{ "ANDN", "Logical AND NOT" },
...
}
.ToFrozenDictionary();
...
}
冻结集合实现了标准的字典/集合接口,包括它们的只读版本。在本例中,我们将我们的FrozenDictionary<string,string>作为IReadOnlyDictionary<string,string>类型的字段公开。
插入相等性和顺序
在部分章节“相等比较”和“顺序比较”中,我们描述了标准的.NET 协议,使得类型可以在字典或排序列表中“开箱即用”地正确运行。更具体地说:
-
一个类型的
Equals和GetHashCode返回有意义的结果,则可以将其用作Dictionary或Hashtable中的键。 -
实现
IComparable/IComparable<T>的类型可以用作排序字典或列表中的键。
类型的默认相等或比较实现通常反映了对该类型最“自然”的内容。然而,有时默认行为并非您想要的。您可能需要一个字典,其中string类型的键不考虑大小写。或者您可能希望按照每个客户的邮政编码对客户进行排序的排序列表。因此,.NET 还定义了一组匹配的“插件”协议。插件协议实现了两个目标:
-
它们允许您切换到替代的相等或比较行为。
-
它们允许您使用键类型不本质上可相等或可比较的字典或排序集合。
插件协议包括以下接口:
IEqualityComparer 和 IEqualityComparer<T>
-
执行插件 相等比较和哈希
-
被
Hashtable和Dictionary所识别
IComparer 和 IComparer<T>
-
执行插件 顺序比较
-
被排序字典和集合识别;同时也被
Array.Sort所识别
每个接口都有通用和非通用形式。IEqualityComparer 接口也在名为 EqualityComparer 的类中有默认实现。
此外,还有名为 IStructuralEquatable 和 IStructuralComparable 的接口,允许在类和数组上进行结构比较选项。
IEqualityComparer 和 EqualityComparer
一个相等比较器在非默认的相等和哈希行为上切换,主要用于 Dictionary 和 Hashtable 类。
回想一下基于哈希表的字典的要求。对于任何给定的键,它需要回答两个问题:
-
它是否与另一个相同?
-
它的整数哈希码是多少?
一个相等比较器通过实现 IEqualityComparer 接口来回答这些问题:
public interface IEqualityComparer<T>
{
bool Equals (T x, T y);
int GetHashCode (T obj);
}
public interface IEqualityComparer // Nongeneric version
{
bool Equals (object x, object y);
int GetHashCode (object obj);
}
要编写自定义比较器,您可以实现其中一个或两个接口(同时实现两个可以获得最大的互操作性)。由于这有些繁琐,一个替代方法是继承抽象类 EqualityComparer,定义如下:
public abstract class EqualityComparer<T> : IEqualityComparer,
IEqualityComparer<T>
{
public abstract bool Equals (T x, T y);
public abstract int GetHashCode (T obj);
bool IEqualityComparer.Equals (object x, object y);
int IEqualityComparer.GetHashCode (object obj);
public static EqualityComparer<T> Default { get; }
}
EqualityComparer 实现了这两个接口;您的任务只是覆盖这两个抽象方法。
Equals 和 GetHashCode 的语义遵循 object.Equals 和 object.GetHashCode 的相同规则,详见第六章。在以下示例中,我们定义一个 Customer 类,包含两个字段,然后编写一个相等比较器,匹配第一个和最后一个名字:
public class Customer
{
public string LastName;
public string FirstName;
public Customer (string last, string first)
{
LastName = last;
FirstName = first;
}
}
public class LastFirstEqComparer : EqualityComparer <Customer>
{
public override bool Equals (Customer x, Customer y)
=> x.LastName == y.LastName && x.FirstName == y.FirstName;
public override int GetHashCode (Customer obj)
=> (obj.LastName + ";" + obj.FirstName).GetHashCode();
}
为了说明这是如何工作的,让我们创建两个客户:
Customer c1 = new Customer ("Bloggs", "Joe");
Customer c2 = new Customer ("Bloggs", "Joe");
因为我们没有重写 object.Equals,所以正常的引用类型相等语义适用:
Console.WriteLine (c1 == c2); // False
Console.WriteLine (c1.Equals (c2)); // False
当在 Dictionary 中使用这些客户时,没有指定相等比较器时也适用相同的默认相等语义:
var d = new Dictionary<Customer, string>();
d [c1] = "Joe";
Console.WriteLine (d.ContainsKey (c2)); // False
现在,使用自定义的相等比较器:
var eqComparer = new LastFirstEqComparer();
var d = new Dictionary<Customer, string> (eqComparer);
d [c1] = "Joe";
Console.WriteLine (d.ContainsKey (c2)); // True
在此示例中,我们必须小心,不要在字典中使用客户的 FirstName 或 LastName 发生变化;否则,它的哈希码将会改变,字典将会出现问题。
EqualityComparer.Default
调用 EqualityComparer<T>.Default 返回一个通用的相等比较器,您可以将其用作替代静态的 object.Equals 方法。优势在于它首先检查 T 是否实现了 IEquatable<T>,如果是,则调用该实现,避免装箱开销。这在泛型方法中特别有用:
static bool Foo<T> (T x, T y)
{
bool same = EqualityComparer<T>.Default.Equals (x, y);
...
ReferenceEqualityComparer.Instance(.NET 5+)
从 .NET 5 开始,ReferenceEqualityComparer.Instance 返回一个总是应用引用相等的相等比较器。对于值类型,其 Equals 方法总是返回 false。
IComparer 和 Comparer
比较器用于为排序的字典和集合切换自定义排序逻辑。
请注意,对于像Dictionary和Hashtable这样的未排序字典,比较器是无用的——这些需要一个IEqualityComparer来获取哈希码。同样,对于排序字典和集合,相等比较器也是无用的。
这里是IComparer接口的定义:
public interface IComparer
{
int Compare(object x, object y);
}
public interface IComparer <in T>
{
int Compare(T x, T y);
}
与相等比较器一样,有一个抽象类,您可以对其进行子类型化,而不是实现接口:
public abstract class Comparer<T> : IComparer, IComparer<T>
{
public static Comparer<T> Default { get; }
public abstract int Compare (T x, T y); // Implemented *by* you
int IComparer.Compare (object x, object y); // Implemented *for* you
}
以下示例展示了描述愿望的类以及按优先级排序愿望的比较器:
class Wish
{
public string Name;
public int Priority;
public Wish (string name, int priority)
{
Name = name;
Priority = priority;
}
}
class PriorityComparer : Comparer<Wish>
{
public override int Compare (Wish x, Wish y)
{
if (object.Equals (x, y)) return 0; // Optimization
if (x == null) return -1;
if (y == null) return 1;
return x.Priority.CompareTo (y.Priority);
}
}
object.Equals检查确保我们永远不会违反Equals方法。在这种情况下调用静态的object.Equals方法比调用x.Equals更好,因为如果x为空,它仍然有效!
这里展示了我们的PriorityComparer如何用于对List进行排序:
var wishList = new List<Wish>();
wishList.Add (new Wish ("Peace", 2));
wishList.Add (new Wish ("Wealth", 3));
wishList.Add (new Wish ("Love", 2));
wishList.Add (new Wish ("3 more wishes", 1));
wishList.Sort (new PriorityComparer());
foreach (Wish w in wishList) Console.Write (w.Name + " | ");
// OUTPUT: 3 more wishes | Love | Peace | Wealth |
在下一个示例中,SurnameComparer允许您按适合电话簿列表的顺序对姓氏字符串进行排序:
class SurnameComparer : Comparer <string>
{
string Normalize (string s)
{
s = s.Trim().ToUpper();
if (s.StartsWith ("MC")) s = "MAC" + s.Substring (2);
return s;
}
public override int Compare (string x, string y)
=> Normalize (x).CompareTo (Normalize (y));
}
这里展示了SurnameComparer在排序字典中的使用:
var dic = new SortedDictionary<string,string> (new SurnameComparer());
dic.Add ("MacPhail", "second!");
dic.Add ("MacWilliam", "third!");
dic.Add ("McDonald", "first!");
foreach (string s in dic.Values)
Console.Write (s + " "); // first! second! third!
StringComparer
StringComparer是一个预定义的插件类,用于等同和比较字符串,允许您指定语言和大小写敏感性。StringComparer实现了IEqualityComparer和IComparer(以及它们的泛型版本),因此您可以将其与任何类型的字典或排序集合一起使用。
因为StringComparer是抽象的,您可以通过其静态属性获取实例。StringComparer.Ordinal反映了字符串相等比较的默认行为,StringComparer.CurrentCulture用于顺序比较。以下是它的所有静态成员:
public static StringComparer CurrentCulture { get; }
public static StringComparer CurrentCultureIgnoreCase { get; }
public static StringComparer InvariantCulture { get; }
public static StringComparer InvariantCultureIgnoreCase { get; }
public static StringComparer Ordinal { get; }
public static StringComparer OrdinalIgnoreCase { get; }
public static StringComparer Create (CultureInfo culture,
bool ignoreCase);
在下面的示例中,创建了一个按序大小写不敏感的字典,使得dict["Joe"]和dict["JOE"]意思相同:
var dict = new Dictionary<string, int> (StringComparer.OrdinalIgnoreCase);
在下一个示例中,一个名字数组按澳大利亚英语排序:
string[] names = { "Tom", "HARRY", "sheila" };
CultureInfo ci = new CultureInfo ("en-AU");
Array.Sort<string> (names, StringComparer.Create (ci, false));
最后一个示例是我们在上一节中编写的适用于电话簿列表的SurnameComparer的文化感知版本:
class SurnameComparer : Comparer<string>
{
StringComparer strCmp;
public SurnameComparer (CultureInfo ci)
{
// Create a case-sensitive, culture-sensitive string comparer
strCmp = StringComparer.Create (ci, false);
}
string Normalize (string s)
{
s = s.Trim();
if (s.ToUpper().StartsWith ("MC")) s = "MAC" + s.Substring (2);
return s;
}
public override int Compare (string x, string y)
{
// Directly call Compare on our culture-aware StringComparer
return strCmp.Compare (Normalize (x), Normalize (y));
}
}
IStructuralEquatable 和 IStructuralComparable
正如我们在第六章中讨论的那样,结构体默认实现结构比较:如果它们的所有字段都相等,则两个结构体相等。然而,有时候,结构相等性和顺序比较也可以作为插件选项应用于其他类型,比如数组。以下接口有助于实现这一点:
public interface IStructuralEquatable
{
bool Equals (object other, IEqualityComparer comparer);
int GetHashCode (IEqualityComparer comparer);
}
public interface IStructuralComparable
{
int CompareTo (object other, IComparer comparer);
}
你传入的IEqualityComparer/IComparer会应用于复合对象中的每个单独元素。我们可以通过使用数组来演示这一点。在下面的示例中,我们首先使用默认的Equals方法,然后使用IStructuralEquatable的版本来比较两个数组是否相等:
int[] a1 = { 1, 2, 3 };
int[] a2 = { 1, 2, 3 };
IStructuralEquatable se1 = a1;
Console.Write (a1.Equals (a2)); // False
Console.Write (se1.Equals (a2, EqualityComparer<int>.Default)); // True
这里是另一个示例:
string[] a1 = "the quick brown fox".Split();
string[] a2 = "THE QUICK BROWN FOX".Split();
IStructuralEquatable se1 = a1;
bool isTrue = se1.Equals (a2, StringComparer.InvariantCultureIgnoreCase);
¹ 还有一个功能上相同的非泛型版本称为SortedList。
第八章:LINQ 查询
LINQ,即语言集成查询,是一组用于在本地对象集合和远程数据源上编写结构化类型安全查询的语言和运行时特性。
LINQ 允许您查询任何实现 IEnumerable<T> 的集合,无论是数组、列表还是 XML 文档对象模型(DOM),以及远程数据源,如 SQL Server 数据库中的表。LINQ 提供了编译时类型检查和动态查询组合的优点。
本章描述了 LINQ 的架构和编写查询的基础知识。所有核心类型都定义在 System.Linq 和 System.Linq.Expressions 命名空间中。
注意
本章及其后两章的示例已预加载到名为 LINQPad 的交互式查询工具中。您可以从 http://www.linqpad.net 下载 LINQPad。
入门指南
LINQ 中的基本数据单元是序列和元素。序列是任何实现 IEnumerable<T> 的对象,元素是序列中的每个项目。在以下示例中,names 是一个序列,"Tom"、"Dick" 和 "Harry" 是元素:
string[] names = { "Tom", "Dick", "Harry" };
我们将其称为本地序列,因为它代表内存中的本地对象集合。
查询操作符 是一种转换序列的方法。典型的查询操作符接受一个输入序列并发出一个转换后的输出序列。在 System.Linq 的 Enumerable 类中,大约有 40 个查询操作符,全部实现为静态扩展方法。这些被称为标准查询操作符。
注意
操作本地序列的查询称为本地查询或LINQ-to-objects 查询。
LINQ 还支持可以从远程数据源(如 SQL Server 数据库)动态提供的序列。这些序列另外实现了 IQueryable<T> 接口,并通过 Queryable 类中一组匹配的标准查询操作符进行支持。我们在 “解释查询” 中进一步讨论此内容。
查询是一个表达式,当枚举时,使用查询操作符转换序列。最简单的查询包括一个输入序列和一个操作符。例如,我们可以在一个简单的数组上应用 Where 操作符,以提取长度至少为四个字符的字符串,如下所示:
string[] names = { "Tom", "Dick", "Harry" };
IEnumerable<string> filteredNames = System.Linq.Enumerable.Where
(names, n => n.Length >= 4);
foreach (string n in filteredNames)
Console.WriteLine (n);
*Dick*
*Harry*
由于标准查询操作符是作为扩展方法实现的,我们可以直接在 names 上调用 Where,就像它是一个实例方法一样:
IEnumerable<string> filteredNames = names.Where (n => n.Length >= 4);
要使其编译通过,必须导入 System.Linq 命名空间。以下是一个完整的示例:
using System;
using System.Collections.Generic;
using System.Linq;
string[] names = { "Tom", "Dick", "Harry" };
IEnumerable<string> filteredNames = names.Where (n => n.Length >= 4);
foreach (string name in filteredNames) Console.WriteLine (name);
*Dick*
*Harry*
注意
我们还可以通过隐式类型定义 filteredNames 进一步缩短我们的代码:
var filteredNames = names.Where (n => n.Length >= 4);
然而,在没有工具提示来帮助的 IDE 外部,这可能会降低可读性。因此,我们在本章中不像您自己的项目中那样频繁使用隐式类型定义。
大多数查询操作符接受 lambda 表达式作为参数。lambda 表达式有助于指导和塑造查询。在我们的示例中,lambda 表达式如下:
n => n.Length >= 4
输入参数对应于输入元素。在本例中,输入参数n代表数组中的每个名称,类型为string。Where操作符要求 lambda 表达式返回一个bool值,如果为true,则表示该元素应包含在输出序列中。以下是其签名:
public static IEnumerable<TSource> Where<TSource>
(this IEnumerable<TSource> source, Func<TSource,bool> predicate)
以下查询提取所有包含字母“a”的名称:
IEnumerable<string> filteredNames = names.Where (n => n.Contains ("a"));
foreach (string name in filteredNames)
Console.WriteLine (name); // Harry
到目前为止,我们已经使用扩展方法和 lambda 表达式构建了查询。很快您将看到,这种策略是高度可组合的,因为它允许链式查询操作符。在本书中,我们将其称为流畅语法。¹ C#还提供了另一种编写查询的语法,称为查询表达式语法。以下是我们之前的查询以查询表达式编写的方式:
IEnumerable<string> filteredNames = from n in names
where n.Contains ("a")
select n;
流畅语法和查询语法是互补的。在接下来的两个部分中,我们将更详细地探讨每种语法。
流畅的语法
流畅语法是最灵活和基础的。在本节中,我们将描述如何链式查询操作符以形成更复杂的查询,并展示为什么扩展方法对此过程至关重要。我们还将描述如何为查询操作符制定 lambda 表达式,并介绍几个新的查询操作符。
链式查询操作符
在前面的部分中,我们展示了两个简单的查询,每个查询操作符都包含一个查询操作符。要构建更复杂的查询,您需要将附加的查询操作符附加到表达式中,创建一个链。为了说明,以下查询提取所有包含字母“a”的字符串,按长度排序,然后将结果转换为大写:
using System;
using System.Collections.Generic;
using System.Linq;
string[] names = { "Tom", "Dick", "Harry", "Mary", "Jay" };
IEnumerable<string> query = names
.Where (n => n.Contains ("a"))
.OrderBy (n => n.Length)
.Select (n => n.ToUpper());
foreach (string name in query) Console.WriteLine (name);
*JAY*
*MARY*
*HARRY*
注意
在我们的示例中,变量n是私有范围的,每个 lambda 表达式都有自己的作用域。我们之所以可以重用标识符n,原因与我们可以在以下方法中重用标识符c的原因相同:
void Test()
{
foreach (char c in "string1") Console.Write (c);
foreach (char c in "string2") Console.Write (c);
foreach (char c in "string3") Console.Write (c);
}
Where、OrderBy和Select是标准查询操作符,将解析为Enumerable类中的扩展方法(如果导入System.Linq命名空间)。
我们已经介绍了Where操作符,它会发出输入序列的过滤版本。OrderBy操作符会发出其输入序列的排序版本;Select方法会发出一个序列,其中每个输入元素都会通过给定的 lambda 表达式(在本例中为n.ToUpper())进行转换或投影。数据从操作符链的左侧流向右侧,因此数据首先被过滤,然后排序,最后投影。
注意
查询操作符永远不会改变输入序列;相反,它会返回一个新序列。这与 LINQ 所灵感来源的函数式编程范式一致。
这些扩展方法的签名如下(OrderBy签名稍微简化):
public static IEnumerable<TSource> Where<TSource>
(this IEnumerable<TSource> source, Func<TSource,bool> predicate)
public static IEnumerable<TSource> OrderBy<TSource,TKey>
(this IEnumerable<TSource> source, Func<TSource,TKey> keySelector)
public static IEnumerable<TResult> Select<TSource,TResult>
(this IEnumerable<TSource> source, Func<TSource,TResult> selector)
当像这个例子中链式地使用查询操作符时,一个操作符的输出序列就是下一个操作符的输入序列。完整的查询类似于传送带的生产线,如 图 8-1 所示。

图 8-1. 链式查询操作符
我们可以逐步构建相同的查询,如下所示:
// You must import the System.Linq namespace for this to compile:
IEnumerable<string> filtered = names .Where (n => n.Contains ("a"));
IEnumerable<string> sorted = filtered.OrderBy (n => n.Length);
IEnumerable<string> finalQuery = sorted .Select (n => n.ToUpper());
finalQuery 在构造之前的 query 中是组合上相同的。此外,每个中间步骤还包含了一个可以执行的有效查询:
foreach (string name in filtered)
Console.Write (name + "|"); // Harry|Mary|Jay|
Console.WriteLine();
foreach (string name in sorted)
Console.Write (name + "|"); // Jay|Mary|Harry|
Console.WriteLine();
foreach (string name in finalQuery)
Console.Write (name + "|"); // JAY|MARY|HARRY|
为什么扩展方法很重要
您可以使用传统的静态方法语法调用查询操作符,而不是使用扩展方法语法:
IEnumerable<string> filtered = Enumerable.Where (names,
n => n.Contains ("a"));
IEnumerable<string> sorted = Enumerable.OrderBy (filtered, n => n.Length);
IEnumerable<string> finalQuery = Enumerable.Select (sorted,
n => n.ToUpper());
实际上,这正是编译器如何转换扩展方法调用的方式。然而,如果您想要像我们之前那样在单个语句中编写查询,则放弃扩展方法会有代价。让我们重新审视单语句查询——首先是扩展方法语法:
IEnumerable<string> query = names.Where (n => n.Contains ("a"))
.OrderBy (n => n.Length)
.Select (n => n.ToUpper());
其自然的线性形状反映了数据从左到右的流动,并且还将 lambda 表达式与其查询操作符保持在一起(中缀 表示)。如果没有扩展方法,查询将失去其流畅性:
IEnumerable<string> query =
Enumerable.Select (
Enumerable.OrderBy (
Enumerable.Where (
names, n => n.Contains ("a")
), n => n.Length
), n => n.ToUpper()
);
组合 Lambda 表达式
在之前的示例中,我们向 Where 操作符提供了以下 lambda 表达式:
n => n.Contains ("a") // Input type = string, return type = bool.
注意
一个接受值并返回 bool 的 lambda 表达式称为 谓词。
Lambda 表达式的目的取决于特定的查询操作符。对于 Where 操作符,它表示是否应该将一个元素包含在输出序列中。对于 OrderBy 操作符,lambda 表达式将输入序列的每个元素映射到其排序键。对于 Select 操作符,lambda 表达式确定在馈送到输出序列之前如何转换输入序列中的每个元素。
注意
查询操作符中的 lambda 表达式总是在输入序列的单个元素上工作,而不是整个序列。
查询操作符根据需要评估您的 lambda 表达式,通常是输入序列中的每个元素一次。Lambda 表达式允许您将自己的逻辑输入到查询操作符中。这使得查询操作符既多才多艺又简单易用。这里是 Enumerable.Where 的完整实现,除了异常处理:
public static IEnumerable<TSource> Where<TSource>
(this IEnumerable<TSource> source, Func<TSource,bool> predicate)
{
foreach (TSource element in source)
if (predicate (element))
yield return element;
}
Lambda 表达式和 Func 签名
标准查询操作符利用泛型 Func 委托。Func 是 System 命名空间中的一组通用目的泛型委托,其定义如下:
在
Func中的类型参数按照 lambda 表达式中它们的顺序出现。
因此,Func<TSource,bool> 与 TSource=>bool lambda 表达式相匹配:接受 TSource 参数并返回 bool 值的表达式。
类似地,Func<TSource,TResult> 与 TSource=>TResult lambda 表达式相匹配。
Func 委托列在 “Lambda 表达式” 中。
Lambda 表达式和元素类型
标准查询操作符使用以下类型参数名称:
| 通用类型字母 | 含义 |
|---|---|
TSource |
输入序列的元素类型 |
TResult |
输出序列的元素类型(如果不同于TSource) |
TKey |
用于排序、分组或连接中的键的元素类型 |
TSource由输入序列确定。TResult和TKey通常从您的 lambda 表达式中推断。
例如,考虑Select查询操作符的签名:
public static IEnumerable<TResult> Select<TSource,TResult>
(this IEnumerable<TSource> source, Func<TSource,TResult> selector)
Func<TSource,TResult>匹配TSource=>TResult的 lambda 表达式:将输入元素映射到输出元素的表达式。TSource和TResult可以是不同的类型,因此 lambda 表达式可以更改每个元素的类型。此外,lambda 表达式确定输出序列类型。以下查询使用Select将字符串类型元素转换为整数类型元素:
string[] names = { "Tom", "Dick", "Harry", "Mary", "Jay" };
IEnumerable<int> query = names.Select (n => n.Length);
foreach (int length in query)
Console.Write (length + "|"); // 3|4|5|4|3|
编译器可以推断出TResult的类型,从 lambda 表达式的返回值中推断出来。在本例中,n.Length返回一个int值,因此TResult被推断为int。
Where查询操作符更简单,对输出不需要类型推断,因为输入和输出元素是相同类型的。这是有道理的,因为该操作符仅仅是过滤元素,并不会转换它们:
public static IEnumerable<TSource> Where<TSource>
(this IEnumerable<TSource> source, Func<TSource,bool> predicate)
最后,考虑OrderBy操作符的签名:
// Slightly simplified:
public static IEnumerable<TSource> OrderBy<TSource,TKey>
(this IEnumerable<TSource> source, Func<TSource,TKey> keySelector)
Func<TSource,TKey>将输入元素映射到排序键。TKey从您的 lambda 表达式中推断出,并且与输入和输出元素类型分开。例如,我们可以选择按长度(int键)或按字母顺序(string键)对名称列表进行排序:
string[] names = { "Tom", "Dick", "Harry", "Mary", "Jay" };
IEnumerable<string> sortedByLength, sortedAlphabetically;
sortedByLength = names.OrderBy (n => n.Length); // int key
sortedAlphabetically = names.OrderBy (n => n); // string key
注意
您可以使用传统的委托调用Enumerable中的查询操作符,这些委托引用方法而不是 lambda 表达式。这种方法在简化某些本地查询(特别是 LINQ 到 XML)时非常有效,并在第十章中进行了演示。然而,对于基于IQueryable<T>的序列(例如查询数据库时),这种方法不适用,因为Queryable中的操作符需要 lambda 表达式以生成表达式树。我们稍后在“解释查询”中讨论这一点。
自然排序
LINQ 中输入序列中的原始元素顺序很重要。某些查询操作符依赖于此顺序,如Take、Skip和Reverse。
Take操作符输出前x个元素,并丢弃其余元素:
int[] numbers = { 10, 9, 8, 7, 6 };
IEnumerable<int> firstThree = numbers.Take (3); // { 10, 9, 8 }
Skip操作符忽略前x个元素,并输出剩余元素:
IEnumerable<int> lastTwo = numbers.Skip (3); // { 7, 6 }
Reverse确实如其名所示:
IEnumerable<int> reversed = numbers.Reverse(); // { 6, 7, 8, 9, 10 }
在本地查询(LINQ-to-objects)中,诸如Where和Select等操作符保留输入序列的原始顺序(与特定更改顺序的操作符除外)。
其他操作符
并非所有查询操作符都返回序列。元素 操作符从输入序列中提取一个元素;例如 First、Last 和 ElementAt:
int[] numbers = { 10, 9, 8, 7, 6 };
int firstNumber = numbers.First(); // 10
int lastNumber = numbers.Last(); // 6
int secondNumber = numbers.ElementAt(1); // 9
int secondLowest = numbers.OrderBy(n=>n).Skip(1).First(); // 7
由于这些操作符返回单个元素,除非该元素本身是一个集合,否则通常不会在其结果上再调用其他查询操作符。
聚合 操作符返回一个标量值,通常是数值类型:
int count = numbers.Count(); // 5;
int min = numbers.Min(); // 6;
量词 返回一个 bool 值:
bool hasTheNumberNine = numbers.Contains (9); // true
bool hasMoreThanZeroElements = numbers.Any(); // true
bool hasAnOddElement = numbers.Any (n => n % 2 != 0); // true
一些查询操作符接受两个输入序列。例如,Concat 将一个序列追加到另一个序列,而 Union 则执行相同的操作,但会移除重复项:
int[] seq1 = { 1, 2, 3 };
int[] seq2 = { 3, 4, 5 };
IEnumerable<int> concat = seq1.Concat (seq2); // { 1, 2, 3, 3, 4, 5 }
IEnumerable<int> union = seq1.Union (seq2); // { 1, 2, 3, 4, 5 }
连接操作符也属于这一类。第九章 详细介绍了所有查询操作符。
查询表达式
C# 提供了一种用于编写 LINQ 查询的语法快捷方式,称为 查询表达式。与流行观念相反,查询表达式并不是将 SQL 嵌入到 C# 中的一种方法。事实上,查询表达式的设计主要受到来自函数式编程语言(如 LISP 和 Haskell)的 列表推导 的启发,尽管 SQL 对其有些影响。
注意
在本书中,我们将查询表达式语法简称为 查询语法。
在前一节中,我们编写了一个流畅语法查询,以提取包含字母“a”的字符串,并按长度排序并转换为大写。下面是在查询语法中实现相同功能的示例:
using System;
using System.Collections.Generic;
using System.Linq;
string[] names = { "Tom", "Dick", "Harry", "Mary", "Jay" };
IEnumerable<string> query =
from n in names
where n.Contains ("a") // Filter elements
orderby n.Length // Sort elements
select n.ToUpper(); // Translate each element (project)
foreach (string name in query) Console.WriteLine (name);
*JAY*
*MARY*
*HARRY*
查询表达式始终以 from 子句开头,并以 select 或 group 子句结尾。from 子句声明一个 范围变量(在本例中为 n),您可以将其视为遍历输入序列——类似于 foreach。图 8-2 以铁路图的形式展示了完整的语法结构。
注意
要阅读此图表,请从左侧开始,然后沿着轨道进行。例如,在必须的 from 子句之后,您可以选择包括 orderby、where、let 或 join 子句。之后,您可以继续使用 select 或 group 子句,或者返回并包括另一个 from、orderby、where、let 或 join 子句。

图 8-2. 查询语法
编译器通过将查询表达式转换为流畅语法来处理查询表达式。它以一种相当机械的方式进行此操作——就像将 foreach 语句转换为对 GetEnumerator 和 MoveNext 的调用一样。这意味着您可以在查询语法中编写的任何内容也可以在流畅语法中编写。编译器(最初)将我们的示例查询转换为以下内容:
IEnumerable<string> query = names.Where (n => n.Contains ("a"))
.OrderBy (n => n.Length)
.Select (n => n.ToUpper());
Where、OrderBy 和 Select 操作符遵循与流畅语法写的查询相同的规则进行解析。在这种情况下,它们绑定到 Enumerable 类中的扩展方法,因为已导入 System.Linq 命名空间并且 names 实现了 IEnumerable<string> 接口。然而,编译器在转换查询表达式时并不明确偏好于 Enumerable 类。你可以将编译器视为机械地将 Where、OrderBy 和 Select 这些词注入语句中,然后编译它,就好像你自己输入了这些方法名一样。这样做提供了解析方式的灵活性。例如,我们在后面章节中编写的数据库查询中的操作符,会绑定到 Queryable 中的扩展方法上。
注意
如果我们从程序中移除 using System.Linq 指令,查询将无法编译,因为 Where、OrderBy 和 Select 方法无处可绑定。除非导入 System.Linq 或具有这些查询方法实现的其他命名空间,否则查询表达式无法编译。
范围变量
紧随 from 关键字语法的标识符称为范围变量。范围变量指的是要执行操作的序列中的当前元素。
在我们的示例中,范围变量 n 出现在查询的每个子句中。然而,该变量实际上在每个子句中枚举了一个不同的序列:
from n in names // n is our range variable
where n.Contains ("a") // n = directly from the array
orderby n.Length // n = subsequent to being filtered
select n.ToUpper() // n = subsequent to being sorted
当我们检查编译器对流畅语法的机械翻译时,这一点变得清晰起来:
names.Where (n => n.Contains ("a")) // Locally scoped n
.OrderBy (n => n.Length) // Locally scoped n
.Select (n => n.ToUpper()) // Locally scoped n
正如你所看到的,每个 n 的实例都被私有地限定在自己的 lambda 表达式中。
查询表达式还可以通过以下子句引入新的范围变量:
-
let -
into -
一个额外的
from子句 -
join
我们将在本章稍后部分以及第九章中的 “Composition Strategies”、Chapter 9 的 “Projecting” 和 “Joining” 中介绍这些内容。
查询语法与 SQL 语法
查询表达式在表面上看起来像 SQL,但两者非常不同。LINQ 查询归结为 C# 表达式,因此遵循标准的 C# 规则。例如,在 LINQ 中,你不能在声明变量之前使用它。而在 SQL 中,你可以在 FROM 子句中定义之前在 SELECT 子句中引用表别名。
在 LINQ 中,子查询只是另一个 C# 表达式,因此不需要特殊语法。而 SQL 中的子查询则受到特殊规则的约束。
使用 LINQ,数据在查询中从左到右逻辑流动。而 SQL 中,数据流的顺序对于数据流动的结构性程度较差。
LINQ 查询由一系列操作符组成的传送带或管道组成,它们接受并发出可能有序的序列元素。而 SQL 查询由主要处理无序集合的网络组成。
查询语法与流畅语法
查询语法和流畅语法各有优势。
查询语法对涉及以下任何内容的查询更简单:
-
let子句用于在范围变量旁引入新变量 -
SelectMany、Join或GroupJoin,后跟外部范围变量引用
(我们在 “组合策略” 中描述了 let 子句;我们在 第九章 中描述了 SelectMany、Join 和 GroupJoin。)
中间地带是涉及简单使用 Where、OrderBy 和 Select 的查询。任何语法都很有效;选择主要取决于个人喜好。
对于只包含单个运算符的查询,流畅语法更简短、更简洁。
最后,有许多运算符在查询语法中没有关键字。这些要求您至少部分使用流畅语法。这意味着除以下内容之外的任何运算符:
Where, Select, SelectMany
OrderBy, ThenBy, OrderByDescending, ThenByDescending
GroupBy, Join, GroupJoin
混合语法查询
如果查询运算符不支持查询语法,则可以混合查询语法和流畅语法。唯一的限制是每个查询语法组件必须完整(即必须以 from 子句开始,以 select 或 group 子句结束)。
假设这个数组声明
string[] names = { "Tom", "Dick", "Harry", "Mary", "Jay" };
以下示例计算包含字母“a”的名称数:
int matches = (from n in names where n.Contains ("a") select n).Count();
// 3
下一个查询按字母顺序获取第一个名字:
string first = (from n in names orderby n select n).First(); // Dick
在更复杂的查询中,混合语法方法有时更为有益。然而,在这些简单的示例中,我们可以始终坚持使用流畅的语法而不会受到任何处罚:
int matches = names.Where (n => n.Contains ("a")).Count(); // 3
string first = names.OrderBy (n => n).First(); // Dick
注意
有时,混合语法查询在功能和简洁性方面提供了最大的优势。重要的是不偏向任何一种查询语法;否则,在最佳选项时将无法编写混合语法查询。
在适用的情况下,本章的其余部分展示了流畅语法和查询语法中的关键概念。
延迟执行
大多数查询运算符的重要特性是它们在构造时不执行,而是在 枚举 时执行(换句话说,在其枚举器上调用 MoveNext 时)。考虑以下查询:
var numbers = new List<int> { 1 };
IEnumerable<int> query = numbers.Select (n => n * 10); // Build query
numbers.Add (2); // Sneak in an extra element
foreach (int n in query)
Console.Write (n + "|"); // 10|20|
我们在构造查询之后悄悄插入了额外的数字,它包含在结果中,因为直到 foreach 语句运行时才进行任何过滤或排序。这称为延迟执行或惰性执行,与委托的工作方式相同:
Action a = () => Console.WriteLine ("Foo");
// We’ve not written anything to the Console yet. Now let’s run it:
a(); // Deferred execution!
所有标准查询运算符都提供延迟执行,以下是例外:
-
返回单个元素或标量值的运算符,比如
First或Count -
以下 转换运算符:
ToArray, ToList, ToDictionary, ToLookup, ToHashSet
这些运算符会立即执行查询,因为它们的结果类型没有提供延迟执行的机制。例如,Count 方法返回一个简单的整数,不会被枚举。以下查询立即执行:
int matches = numbers.Where (n => n <= 2).Count(); // 1
延迟执行很重要,因为它将查询的构造与查询的执行分离。这允许您分步构建查询,并且也使得数据库查询成为可能。
注意
子查询提供另一层间接性。子查询中的所有内容都会被延迟执行,包括聚合和转换方法。我们在“子查询”中描述了这一点。
重新评估
延迟执行还有另一个后果:当您重新枚举时,延迟执行查询会重新评估:
var numbers = new List<int>() { 1, 2 };
IEnumerable<int> query = numbers.Select (n => n * 10);
foreach (int n in query) Console.Write (n + "|"); // 10|20|
numbers.Clear();
foreach (int n in query) Console.Write (n + "|"); // <nothing>
有几个原因说明为何重新评估有时会带来不利影响:
-
有时,您希望在特定时间“冻结”或缓存结果。
-
有些查询计算量大(或依赖于远程数据库查询),因此不希望不必要地重复它们。
您可以通过调用诸如ToArray或ToList之类的转换操作符来避免重新评估。ToArray将查询的输出复制到一个数组;ToList则复制到一个通用的List<T>:
var numbers = new List<int>() { 1, 2 };
List<int> timesTen = numbers
.Select (n => n * 10)
.ToList(); // Executes immediately into a List<int>
numbers.Clear();
Console.WriteLine (timesTen.Count); // Still 2
捕获的变量
如果您的查询的 lambda 表达式捕获了外部变量,则查询将在运行查询时遵循这些变量的值:
int[] numbers = { 1, 2 };
int factor = 10;
IEnumerable<int> query = numbers.Select (n => n * factor);
factor = 20;
foreach (int n in query) Console.Write (n + "|"); // 20|40|
在for循环中构建查询时,这可能是一个陷阱。例如,假设我们想从字符串中删除所有元音字母。以下代码虽然效率低下,但会得到正确的结果:
IEnumerable<char> query = "Not what you might expect";
query = query.Where (c => c != 'a');
query = query.Where (c => c != 'e');
query = query.Where (c => c != 'i');
query = query.Where (c => c != 'o');
query = query.Where (c => c != 'u');
foreach (char c in query) Console.Write (c); // Nt wht y mght xpct
现在看看当我们使用for循环重构时会发生什么:
IEnumerable<char> query = "Not what you might expect";
string vowels = "aeiou";
for (int i = 0; i < vowels.Length; i++)
query = query.Where (c => c != vowels[i]);
foreach (char c in query) Console.Write (c);
当枚举查询时,会抛出IndexOutOfRangeException,因为正如我们在第四章中看到的(参见“捕获外部变量”),编译器将for循环中的迭代变量作用域视为在循环外部声明的变量。因此,每个闭包都捕获了同一个变量(i),其值在实际枚举查询时为 5。为解决此问题,必须将循环变量赋给内部声明的另一个变量:
for (int i = 0; i < vowels.Length; i++)
{
char vowel = vowels[i];
query = query.Where (c => c != vowel);
}
这会在每次循环迭代时强制捕获一个新的局部变量。
注意
另一种解决方法是用foreach循环替换for循环:
foreach (char vowel in vowels)
query = query.Where (c => c != vowel);
延迟执行的工作原理
查询操作符通过返回装饰器序列提供延迟执行。
与传统的集合类(如数组或链表)不同,装饰器序列(一般情况下)没有自己的后备结构来存储元素。相反,它包装您在运行时提供的另一个序列,并且保持永久依赖于它。每当您从装饰器请求数据时,它反过来必须从包装的输入序列请求数据。
注意
查询操作符的转换构成“装饰”。如果输出序列没有执行任何转换,那么它将是一个代理而不是装饰器。
调用Where仅构造了装饰器包装序列,该序列保存了对输入序列、lambda 表达式和任何其他提供的参数的引用。仅当枚举装饰器时,才会枚举输入序列。
图 8-3 展示了以下查询的组合:
IEnumerable<int> lessThanTen = new int[] { 5, 12, 3 }.Where (n => n < 10);

图 8-3. 装饰器序列
当你枚举lessThanTen时,实际上是通过Where装饰器查询数组。
一个好消息——如果你想编写自己的查询操作符,使用 C#迭代器实现装饰器序列很容易。下面是如何编写自己的Select方法:
public static IEnumerable<TResult> MySelect<TSource,TResult>
(this IEnumerable<TSource> source, Func<TSource,TResult> selector)
{
foreach (TSource element in source)
yield return selector (element);
}
这个方法是一个迭代器,通过yield return语句实现。功能上,它是以下操作的快捷方式:
public static IEnumerable<TResult> MySelect<TSource,TResult>
(this IEnumerable<TSource> source, Func<TSource,TResult> selector)
{
return new *SelectSequence* (source, selector);
}
其中*SelectSequence*是一个(由编译器编写的)类,其枚举器封装了迭代器方法中的逻辑。
因此,当你调用诸如Select或Where的操作符时,实际上只是实例化了一个可枚举类,该类装饰了输入序列。
链式装饰器
链式查询操作创建了一系列装饰器。考虑下面的查询:
IEnumerable<int> query = new int[] { 5, 12, 3 }.Where (n => n < 10)
.OrderBy (n => n)
.Select (n => n * 10);
每个查询操作符实例化一个新的装饰器,包装前一个序列(有点像俄罗斯套娃)。图 8-4 展示了此查询的对象模型。注意,该对象模型在任何枚举之前完全构建。

图 8-4. 分层装饰器序列
当你枚举query时,你在查询原始数组,经过一层或多层装饰器的转换。
注意
在此查询末尾添加ToList会导致前面的操作立即执行,将整个对象模型折叠为单个列表。
图 8-5 展示了以统一建模语言(UML)语法表示的相同对象组合。Select的装饰器引用OrderBy的装饰器,后者引用Where的装饰器,后者引用数组。延迟执行的特性是,如果逐步组合查询,就会构建相同的对象模型:
IEnumerable<int>
source = new int[] { 5, 12, 3 },
filtered = source .Where (n => n < 10),
sorted = filtered .OrderBy (n => n),
query = sorted .Select (n => n * 10);

图 8-5. UML 装饰器组合
查询的执行方式
以下是对上述查询进行枚举的结果:
foreach (int n in query) Console.WriteLine (n);
*30*
*50*
在幕后,foreach调用Select的装饰器(最后或最外层的操作符)的GetEnumerator,从而启动一切。结果是一系列枚举器,结构上镜像了装饰器序列的链条。图 8-6 展示了随着枚举进行,执行流程的示意图。

图 8-6. 本地查询的执行
在本章的第一节中,我们将查询描绘为一个传送带的生产线。延伸这个类比,我们可以说 LINQ 查询是一个惰性的生产线,其中传送带只在需要时滚动元素。构建查询就像构建了一个生产线——所有的东西都准备好了,但什么都不滚动。然后,当消费者请求一个元素(枚举查询时),最右边的传送带开始运转;这会触发其他传送带随着输入序列元素的需求而逐步滚动。LINQ 遵循的是需求驱动的拉模型,而不是供给驱动的推模型。这一点很重要——后面你会看到——因为这样可以使 LINQ 能够扩展到查询 SQL 数据库。
子查询
子查询是包含在另一个查询的 Lambda 表达式中的查询。以下示例使用子查询按姓氏对音乐家进行排序:
string[] musos =
{ "David Gilmour", "Roger Waters", "Rick Wright", "Nick Mason" };
IEnumerable<string> query = musos.OrderBy (m => m.Split().Last());
m.Split 将每个字符串转换为单词集合,然后我们调用 Last 查询运算符。 m.Split().Last 就是子查询;query 引用了外部查询。
允许子查询,因为可以将任何有效的 C# 表达式放在 Lambda 的右侧。子查询只是另一个 C# 表达式。这意味着子查询的规则是 Lambda 表达式的规则(以及一般情况下查询运算符的行为)的结果。
注意
在一般意义上,术语子查询具有更广泛的含义。在描述 LINQ 的目的上,我们仅将此术语用于从另一个查询的 Lambda 表达式中引用的查询。在查询表达式中,子查询等同于从任何 from 子句以外的任何子句中引用的查询。
子查询的作用域限定在封闭表达式内部,并且可以引用外部 Lambda 表达式中的参数(或查询表达式中的范围变量)。
m.Split().Last 是一个非常简单的子查询。下一个查询检索数组中所有字符串,其长度与最短字符串相匹配。
string[] names = { "Tom", "Dick", "Harry", "Mary", "Jay" };
IEnumerable<string> outerQuery = names
.Where (n => n.Length == names.OrderBy (n2 => n2.Length)
.Select (n2 => n2.Length).First());
*// Tom, Jay*
下面是一个等效的查询表达式形式:
IEnumerable<string> outerQuery =
from n in names
where n.Length ==
(from n2 in names orderby n2.Length select n2.Length).First()
select n;
因为外部范围变量 (n) 在子查询中是作用域内的,所以我们不能将 n 作为子查询的范围变量重用。
子查询在封闭 Lambda 表达式被评估时执行。这意味着子查询按需执行,在外部查询的决定下。可以说执行是从外向内进行的。本地查询严格遵循这个模型;解释性查询(例如数据库查询)概念上遵循这个模型。
子查询根据需要执行,以供给外部查询。正如图 8-7 和 8-8 所示,在我们的示例中的子查询(图 8-7 中的顶部传送带)在每次外部循环迭代时执行一次。

图 8-7. 子查询构成
我们可以更简洁地表达我们之前的子查询如下:
IEnumerable<string> query =
from n in names
where n.Length == names.OrderBy (n2 => n2.Length).First().Length
select n;
使用Min聚合函数,我们可以进一步简化查询:
IEnumerable<string> query =
from n in names
where n.Length == names.Min (n2 => n2.Length)
select n;
在“解释查询”中,我们描述了如何查询远程来源,例如 SQL 表。我们的例子是一个理想的数据库查询,因为它将作为一个单元处理,只需一次往返到数据库服务器。但是,对于本地集合来说,这个查询是低效的,因为子查询在每次外部循环迭代时都会重新计算。我们可以通过单独运行子查询来避免这种低效:
int shortest = names.Min (n => n.Length);
IEnumerable<string> query = from n in names
where n.Length == shortest
select n;

图 8-8. UML 子查询组合
注意
在查询本地集合时,通常希望以这种方式分离子查询。一个例外是当子查询是相关的,意味着它引用外部的范围变量。我们在“投影”中探讨相关子查询。
子查询和延迟执行
子查询中的元素或聚合运算符(如First或Count)不会强制外部查询立即执行——延迟执行仍然适用于外部查询。这是因为子查询是通过委托(在本地查询的情况下)或表达式树间接调用的。
当你在Select表达式中包含一个子查询时,会出现一个有趣的情况。在本地查询的情况下,你实际上是在投影一系列查询,每个查询本身都受延迟执行的影响。这种效果通常是透明的,并且有助于进一步提高效率。我们将在第九章详细讨论Select子查询。
组合策略
在本节中,我们描述了构建更复杂查询的三种策略:
-
逐步查询构建
-
使用
into关键字 -
封装查询
所有的链接策略和生成的运行时查询都是相同的。
逐步查询构建
在本章开始时,我们演示了如何逐步构建一个流畅的查询:
var filtered = names .Where (n => n.Contains ("a"));
var sorted = filtered .OrderBy (n => n);
var query = sorted .Select (n => n.ToUpper());
因为每个参与的查询操作符都返回一个装饰器序列,所以结果查询与单一表达式查询得到的链式或层叠装饰器是相同的。然而,逐步构建查询有几个潜在的好处:
-
它可以使查询更容易编写。
-
你可以有条件地添加查询操作符。例如,
if (includeFilter) query = query.Where (...)比
query = query.Where (n => !includeFilter || *<expression>*)因为它避免了在
includeFilter为 false 时添加额外的查询操作符。
在查询理解中,逐步方法通常很有用。想象一下,我们想从一个名字列表中删除所有元音字母,然后按字母顺序呈现那些长度仍超过两个字符的名字。在流畅的语法中,我们可以将这个查询写成一个单一的表达式—通过在过滤之前投影:
IEnumerable<string> query = names
.Select (n => n.Replace ("a", "").Replace ("e", "").Replace ("i", "")
.Replace ("o", "").Replace ("u", ""))
.Where (n => n.Length > 2)
.OrderBy (n => n);
*// Dck*
*// Hrry*
*// Mry*
注意
而不是调用string的Replace方法五次,我们可以用正则表达式更有效地从字符串中移除元音:
n => Regex.Replace (n, "[aeiou]", "")
虽然string的Replace方法有一个优点,也就是在数据库查询中同样适用。
直接将其转换为查询表达式是麻烦的,因为select子句必须在where和orderby子句之后。如果我们重新排列查询以便最后投影,结果将会有所不同:
IEnumerable<string> query =
from n in names
where n.Length > 2
orderby n
select n.Replace ("a", "").Replace ("e", "").Replace ("i", "")
.Replace ("o", "").Replace ("u", "");
*// Dck*
*// Hrry*
*// Jy*
*// Mry*
*// Tm*
幸运的是,有许多方法可以在查询语法中获得原始结果。第一种方法是逐步查询:
IEnumerable<string> query =
from n in names
select n.Replace ("a", "").Replace ("e", "").Replace ("i", "")
.Replace ("o", "").Replace ("u", "");
query = from n in query where n.Length > 2 orderby n select n;
*// Dck*
*// Hrry*
*// Mry*
into关键字
注意
into关键字在查询表达式中有两种非常不同的解释方式,取决于上下文。我们现在描述的意义是用于信号查询继续(另一种是用于信号GroupJoin)。
into关键字允许您在投影后“继续”查询,并且是逐步查询的快捷方式。使用into,我们可以将前述查询重写如下:
IEnumerable<string> query =
from n in names
select n.Replace ("a", "").Replace ("e", "").Replace ("i", "")
.Replace ("o", "").Replace ("u", "")
into noVowel
where noVowel.Length > 2 orderby noVowel select noVowel;
您唯一可以在select或group子句之后使用into的地方。into“重新启动”一个查询,允许您引入新的where、orderby和select子句。
注意
虽然从查询表达式的角度来看,最容易将into视为重新启动查询的一部分,但在转换为最终的流畅形式时,它是同一个查询。因此,使用into不会导致性能损失。也不会因为使用它而丢失任何分数!
在流畅语法中,into的等效物只是一长串操作符。
作用域规则
所有范围变量在into关键字之后都已超出作用域。以下代码将无法编译通过:
var query =
from n1 in names
select n1.ToUpper()
into n2 // Only n2 is visible from here on.
where n1.Contains ("x") // Illegal: n1 is not in scope.
select n2;
要了解原因,请考虑它如何映射到流畅语法:
var query = names
.Select (n1 => n1.ToUpper())
.Where (n2 => n1.Contains ("x")); // Error: n1 no longer in scope
原始名称(n1)在Where过滤器运行时就丢失了。Where的输入序列只包含大写名称,因此无法基于n1进行过滤。
包装查询
通过将一个查询包装在另一个查询周围,逐步构建的查询可以被公式化为单个语句。一般来说,
var tempQuery = *tempQueryExpr*
var finalQuery = from *...* in tempQuery *...*
可以改写为:
var finalQuery = from *...* in (*tempQueryExpr*)
包装在语义上与逐步构建查询或使用into关键字(不使用中间变量)相同。所有情况下的最终结果都是一条线性的查询操作链。例如,考虑以下查询:
IEnumerable<string> query =
from n in names
select n.Replace ("a", "").Replace ("e", "").Replace ("i", "")
.Replace ("o", "").Replace ("u", "");
query = from n in query where n.Length > 2 orderby n select n;
在包装形式中重新表达,它是以下内容:
IEnumerable<string> query =
from n1 in
(
from n2 in names
select n2.Replace ("a", "").Replace ("e", "").Replace ("i", "")
.Replace ("o", "").Replace ("u", "")
)
where n1.Length > 2 orderby n1 select n1;
当转换为流畅语法时,结果与前面示例中的所有操作符链相同:
IEnumerable<string> query = names
.Select (n => n.Replace ("a", "").Replace ("e", "").Replace ("i", "")
.Replace ("o", "").Replace ("u", ""))
.Where (n => n.Length > 2)
.OrderBy (n => n);
(编译器不会生成最终的.Select(n => n),因为这是多余的。)
包装查询可能会令人困惑,因为它们类似于我们之前编写的子查询。两者都有内部查询和外部查询的概念。然而,当转换为流畅语法时,您会发现包装只是一种依次链接操作符的策略。最终结果与嵌入一个内部查询的lambda 表达式不像子查询,其外观毫不相似。
回到之前的类比:在包装时,“内部”查询相当于前面的传送带。相比之下,子查询则位于传送带上方,并且通过传送带的 lambda 工作者按需激活(如 图 8-7 所示)。
投影策略
对象初始化程序
到目前为止,我们所有的 select 子句都投影为标量元素类型。使用 C# 对象初始化程序,您可以投影为更复杂的类型。例如,假设在查询的第一步中,我们想要从一个名字列表中去除元音字母,同时保留原始版本以供后续查询使用。我们可以编写以下类来帮助:
class TempProjectionItem
{
public string Original; // Original name
public string Vowelless; // Vowel-stripped name
}
然后我们可以使用对象初始化程序对其进行投影:
string[] names = { "Tom", "Dick", "Harry", "Mary", "Jay" };
IEnumerable<TempProjectionItem> temp =
from n in names
select new TempProjectionItem
{
Original = n,
Vowelless = n.Replace ("a", "").Replace ("e", "").Replace ("i", "")
.Replace ("o", "").Replace ("u", "")
};
结果的类型是IEnumerable<TempProjectionItem>,我们随后可以进行查询:
IEnumerable<string> query = from item in temp
where item.Vowelless.Length > 2
select item.Original;
*// Dick*
*// Harry*
*// Mary*
匿名类型
匿名类型允许您在不编写特殊类的情况下结构化中间结果。我们可以使用匿名类型在先前示例中消除 TempProjectionItem 类:
var intermediate = from n in names
select new
{
Original = n,
Vowelless = n.Replace ("a", "").Replace ("e", "").Replace ("i", "")
.Replace ("o", "").Replace ("u", "")
};
IEnumerable<string> query = from item in intermediate
where item.Vowelless.Length > 2
select item.Original;
这与前面的示例给出了相同的结果,但无需编写一次性类。编译器会代劳,生成一个临时类,其字段与我们投影的结构匹配。然而,这意味着 intermediate 查询具有以下类型:
IEnumerable <*random-compiler-generated-name*>
唯一可以声明此类型变量的方法是使用 var 关键字。在这种情况下,var 不仅仅是减少杂乱的装置;它是必需的。
我们可以使用 into 关键字更简洁地编写整个查询:
var query = from n in names
select new
{
Original = n,
Vowelless = n.Replace ("a", "").Replace ("e", "").Replace ("i", "")
.Replace ("o", "").Replace ("u", "")
}
into temp
where temp.Vowelless.Length > 2
select temp.Original;
查询表达式提供了一种快捷方式来编写这种类型的查询:使用 let 关键字。
let 关键字
let 关键字在范围变量旁边引入一个新变量。
使用 let,我们可以编写一个查询,提取长度超过两个字符(不包括元音字母)的字符串,如下所示:
string[] names = { "Tom", "Dick", "Harry", "Mary", "Jay" };
IEnumerable<string> query =
from n in names
let vowelless = n.Replace ("a", "").Replace ("e", "").Replace ("i", "")
.Replace ("o", "").Replace ("u", "")
where vowelless.Length > 2
orderby vowelless
select n; // Thanks to let, n is still in scope.
编译器通过将投影到一个临时的匿名类型中来解析 let 子句,该类型包含范围变量和新表达式变量。换句话说,编译器将此查询转换为前面的示例。
let 实现了两件事情:
-
它将新元素与现有元素并列投影。
-
它允许在查询中多次使用表达式而无需重写。
在这个例子中,let 方法特别有优势,因为它允许 select 子句投影原始名称 (n) 或其去除元音版本 (vowelless)。
在 where 语句之前或之后可以有任意数量的 let 语句(参见 图 8-2)。 let 语句可以引用在先前 let 语句中引入的变量(受 into 子句强加的边界限制)。 let 重新投影 所有现有变量,这是透明的。
let 表达式不需要评估为标量类型:有时候将其评估为子序列也很有用,例如。
解释查询
LINQ 提供了两种并行架构:本地 查询用于本地对象集合,解释 查询用于远程数据源。到目前为止,我们已经研究了本地查询的架构,它操作实现了 IEnumerable<T> 的集合。本地查询解析为 Enumerable 类(默认情况下)中的查询操作符,这些操作符进一步解析为装饰器序列的链。它们接受的委托——无论是在查询语法、流畅语法还是传统委托中表达——都完全是 Intermediate Language (IL) 代码中的本地内容,就像任何其他 C# 方法一样。
相比之下,解释查询是 描述性的。它们操作实现了 IQueryable<T> 的序列,并解析为 Queryable 类中的查询操作符,这些操作符生成在运行时解释的 表达式树。例如,这些表达式树可以转换为 SQL 查询,允许您使用 LINQ 查询数据库。
注意
Enumerable 中的查询操作符实际上可以与 IQueryable<T> 序列一起工作。困难在于所产生的查询始终在客户端上本地执行。这就是为什么 Queryable 类中提供了第二组查询操作符的原因。
要编写解释查询,您需要从一个公开类型为 IQueryable<T> 的序列的 API 开始。例如,Microsoft 的 Entity Framework Core (EF Core) 允许您查询各种数据库,包括 SQL Server、Oracle、MySQL、PostgreSQL 和 SQLite。
还可以通过调用 AsQueryable 方法在普通可枚举集合周围生成 IQueryable<T> 包装器。我们在“构建查询表达式”中描述了 AsQueryable。
注意
IQueryable<T> 是具有用于构造表达式树的附加方法的 IEnumerable<T> 扩展。大多数情况下,您可以忽略这些方法的详细信息;它们由运行时间接调用。“构建查询表达式” 更详细地介绍了 IQueryable<T>。
为了说明,让我们在 SQL Server 中创建一个简单的客户表,并使用以下 SQL 脚本填充它:
create table Customer
(
ID int not null primary key,
Name varchar(30)
)
insert Customer values (1, 'Tom')
insert Customer values (2, 'Dick')
insert Customer values (3, 'Harry')
insert Customer values (4, 'Mary')
insert Customer values (5, 'Jay')
有了这张表,我们可以在 C# 中编写一个使用 EF Core 获取名称中包含字母“a”的客户的解释型 LINQ 查询,如下所示:
using System;
using System.Linq;
using Microsoft.EntityFrameworkCore;
using var dbContext = new NutshellContext();
IQueryable<string> query = from c in dbContext.Customers
where c.Name.Contains ("a")
orderby c.Name.Length
select c.Name.ToUpper();
foreach (string name in query) Console.WriteLine (name);
public class Customer
{
public int ID { get; set; }
public string Name { get; set; }
}
// We’ll explain the following class in more detail in the next section.
public class NutshellContext : DbContext
{
public virtual DbSet<Customer> Customers { get; set; }
protected override void OnConfiguring (DbContextOptionsBuilder builder)
=> builder.UseSqlServer ("...connection string...");
protected override void OnModelCreating (ModelBuilder modelBuilder)
=> modelBuilder.Entity<Customer>().ToTable ("Customer")
.HasKey (c => c.ID);
}
EF Core 将此查询转换为以下 SQL:
SELECT UPPER([c].[Name])
FROM [Customers] AS [c]
WHERE CHARINDEX(N'a', [c].[Name]) > 0
ORDER BY CAST(LEN([c].[Name]) AS int)
这是最终结果:
*// JAY*
*// MARY*
*// HARRY*
解释查询的工作原理
让我们看看前述查询是如何处理的。
首先,编译器将查询语法转换为流畅语法。这与本地查询完全相同:
IQueryable<string> query = dbContext.customers
.Where (n => n.Name.Contains ("a"))
.OrderBy (n => n.Name.Length)
.Select (n => n.Name.ToUpper());
接下来,编译器解析查询操作符方法。这是本地和解释查询不同之处——解释查询解析为 Queryable 类中的查询操作符,而不是 Enumerable 类中的操作符。
要理解原因,我们需要查看 dbContext.Customers 变量,即整个查询构建的源。dbContext.Customers 的类型是 DbSet<T>,实现了 IQueryable<T>(IEnumerable<T> 的子类型)。这意味着编译器在解析 Where 时可以选择调用 Enumerable 中的扩展方法或以下 Queryable 中的扩展方法:
public static IQueryable<TSource> Where<TSource> (this
IQueryable<TSource> source, Expression <Func<TSource,bool>> predicate)
编译器选择 Queryable.Where 是因为它的签名是更具体的匹配。
Queryable.Where 接受一个包装在 Expression<TDelegate> 类型中的谓词。这告诉编译器将提供的 lambda 表达式——也就是 n=>n.Name.Contains("a")——转换为表达式树,而不是编译后的委托。表达式树是基于 System.Linq.Expressions 中的类型的对象模型,可以在运行时进行检查(以便 EF Core 以后可以将其转换为 SQL 语句)。
因为 Queryable.Where 还返回 IQueryable<T>,所以相同的过程也适用于 OrderBy 和 Select 操作符。图 8-9 展示了最终结果。在阴影框中,有一个表达式树描述了整个查询,可以在运行时遍历。

图 8-9. 解释查询组合
执行
解释查询遵循延迟执行模型——就像本地查询一样。这意味着直到你开始枚举查询,SQL 语句才会被生成。此外,两次枚举相同的查询会导致数据库被查询两次。
在底层,解释查询在执行方式上与本地查询不同。当你枚举解释查询时,最外层的序列会运行一个程序,遍历整个表达式树,将其作为一个单元处理。在我们的示例中,EF Core 将表达式树转换为 SQL 语句,然后执行,产生一个序列作为结果。
注意
要使 EF Core 正常工作,它需要理解数据库的模式。它通过利用约定、代码属性和流畅的配置 API 来实现。我们稍后会详细探讨这一点。
我们之前说过,LINQ 查询就像一个生产线。然而,当你枚举一个 IQueryable 传送带时,它不会启动整条生产线,就像本地查询那样。相反,只有 IQueryable 传送带启动,带有一个特殊的枚举器,调用一个生产经理。经理检查整条生产线——它不是编译代码,而是假人(方法调用表达式),它们的前额上粘有指令(表达式树)。然后经理遍历所有表达式,在本例中将它们抄写到一张纸上(一个 SQL 语句),然后执行它,将结果反馈给消费者。只有一个传送带在运转;其余的生产线是一组空壳,存在只是为了描述需要做什么。
这具有一些实际意义。例如,对于本地查询,您可以编写自己的查询方法(使用迭代器相当容易),然后用它们来补充预定义的集合。对于远程查询,这很困难甚至不可取。如果您编写了一个接受 IQueryable<T> 的 MyWhere 扩展方法,那就像是在生产线上放入自己的假人。生产经理不会知道如何处理您的假人。即使在这个阶段进行干预,您的解决方案也将被硬编码为特定的提供程序,如 EF Core,并且不会与其他 IQueryable 实现一起工作。在 Queryable 中拥有一套标准方法的好处之一是它们为查询任何远程集合定义了一个标准词汇表。一旦您试图扩展词汇表,您就不再具备互操作性。
另一个模型的结果是,即使您坚持使用标准方法,IQueryable 提供程序也可能无法处理某些查询。EF Core 受数据库服务器能力的限制;一些 LINQ 查询无法转换为 SQL。如果您熟悉 SQL,您可能会对这些情况有直觉,尽管有时您需要实验才能看到什么会导致运行时错误;令人惊讶的是,有时候会工作!
结合解释和本地查询
查询可以包含解释和本地操作符。典型的模式是在外部使用本地操作符,在内部使用解释组件;换句话说,解释查询供给本地查询。当查询数据库时,这种模式效果良好。
例如,假设我们编写了一个自定义扩展方法来配对集合中的字符串:
public static IEnumerable<string> Pair (this IEnumerable<string> source)
{
string firstHalf = null;
foreach (string element in source)
if (firstHalf == null)
firstHalf = element;
else
{
yield return firstHalf + ", " + element;
firstHalf = null;
}
}
我们可以在混合了 EF Core 和本地操作符的查询中使用这个扩展方法:
using var dbContext = new NutshellContext ();
IEnumerable<string> q = dbContext.Customers
.Select (c => c.Name.ToUpper())
.OrderBy (n => n)
.Pair() // Local from this point on.
.Select ((n, i) => "Pair " + i.ToString() + " = " + n);
foreach (string element in q) Console.WriteLine (element);
*// Pair 0 = DICK, HARRY*
*// Pair 1 = JAY, MARY*
因为 dbContext.Customers 是实现 IQueryable<T> 的类型,所以 Select 操作符解析为 Queryable.Select。这将返回一个同样类型为 IQueryable<T> 的输出序列,因此 OrderBy 操作符同样解析为 Queryable.OrderBy。但接下来的查询操作符 Pair 没有接受 IQueryable<T> 的重载版本——只有不那么具体的 IEnumerable<T>。因此,它解析为我们的本地 Pair 方法——将解释查询包装在本地查询中。Pair 也返回 IEnumerable,因此接下来的 Select 解析为另一个本地操作符。
在 EF Core 方面,生成的 SQL 语句相当于这样:
SELECT UPPER([c].[Name]) FROM [Customers] AS [c] ORDER BY UPPER([c].[Name])
剩余的工作在本地完成。实际上,我们得到了一个本地查询(在外部),其源是一个解释查询(在内部)。
AsEnumerable
Enumerable.AsEnumerable 是所有查询操作符中最简单的。这是它的完整定义:
public static IEnumerable<TSource> AsEnumerable<TSource>
(this IEnumerable<TSource> source)
{
return source;
}
它的目的是将 IQueryable<T> 序列强制转换为 IEnumerable<T>,从而使后续查询操作符绑定到 Enumerable 操作符而不是 Queryable 操作符。这导致查询的其余部分在本地执行。
举例说明,假设我们在 SQL Server 中有一个MedicalArticles表,并且希望使用 EF Core 检索所有流感文章,其摘要包含少于 100 个单词。对于后一谓词,我们需要一个正则表达式:
Regex wordCounter = new Regex (@"\b(\w|[-'])+\b");
using var dbContext = new NutshellContext ();
var query = dbContext.MedicalArticles
.Where (article => article.Topic == "influenza" &&
wordCounter.Matches (article.Abstract).Count < 100);
问题在于 SQL Server 不支持正则表达式,因此 EF Core 将抛出异常,表示无法将查询转换为 SQL。我们可以通过两步查询来解决这个问题:首先通过 EF Core 查询检索所有流感文章,然后在本地过滤少于 100 个单词的摘要:
Regex wordCounter = new Regex (@"\b(\w|[-'])+\b");
using var dbContext = new NutshellContext ();
IEnumerable<MedicalArticle> efQuery = dbContext.MedicalArticles
.Where (article => article.Topic == "influenza");
IEnumerable<MedicalArticle> localQuery = efQuery
.Where (article => wordCounter.Matches (article.Abstract).Count < 100);
因为efQuery的类型是IEnumerable<MedicalArticle>,第二个查询绑定到本地查询运算符,强制该部分过滤在客户端运行。
使用AsEnumerable,我们可以在单个查询中完成相同的操作:
Regex wordCounter = new Regex (@"\b(\w|[-'])+\b");
using var dbContext = new NutshellContext ();
var query = dbContext.MedicalArticles
.Where (article => article.Topic == "influenza")
.AsEnumerable()
.Where (article => wordCounter.Matches (article.Abstract).Count < 100);
替代调用AsEnumerable的方法是调用ToArray或ToList。AsEnumerable的优点在于它不强制立即执行查询,也不创建任何存储结构。
注意
将查询处理从数据库服务器移动到客户端可能会影响性能,特别是如果意味着检索更多行。解决我们示例的更高效(虽然更复杂)的方法是使用 SQL CLR 集成来在数据库上公开实现正则表达式的函数。
我们在第十章中进一步演示了组合解释和本地查询。
EF Core
在本章和第九章中,我们使用 EF Core 来演示解释查询。现在让我们检查一下这项技术的关键特性。
EF Core 实体类
EF Core 允许您使用任何类来表示数据,只要它包含您想要查询的每个列的公共属性即可。
例如,我们可以定义以下实体类来查询和更新数据库中的Customers表:
public class Customer
{
public int ID { get; set; }
public string Name { get; set; }
}
DbContext
定义实体类之后,下一步是继承DbContext。该类的实例表示您与数据库会话的工作。通常,您的DbContext子类将包含每个模型实体的一个DbSet<T>属性:
public class NutshellContext : DbContext
{
public DbSet<Customer> Customers { get; set; }
... properties for other tables ...
}
DbContext对象执行三个操作:
-
它作为生成您可以查询的
DbSet<>对象的工厂。 -
它跟踪您对实体所做的任何更改,以便您可以将其写回(参见“更改跟踪”)。
-
它提供了虚拟方法,您可以重写以配置连接和模型。
配置连接
通过重写OnConfiguring方法,您可以指定数据库提供程序和连接字符串:
public class NutshellContext : DbContext
{
...
protected override void OnConfiguring (DbContextOptionsBuilder
optionsBuilder) =>
optionsBuilder.UseSqlServer
(@"Server=(local);Database=Nutshell;Trusted_Connection=True");
}
在本示例中,连接字符串被指定为字符串文字。生产应用程序通常会从诸如appsettings.json的配置文件中检索它。
UseSqlServer 是在Microsoft.EntityFramework.SqlServer NuGet 包的一个程序集中定义的扩展方法。其他数据库提供程序,如 Oracle、MySQL、PostgreSQL 和 SQLite,也提供了相应的包。
注意
如果您使用 ASP.NET,您可以允许其依赖注入框架预配置optionsBuilder;在大多数情况下,这样可以避免完全重写OnConfiguring。为此,请在DbContext上定义如下构造函数:
public NutshellContext (DbContextOptions<NutshellContext>
options)
: base(options) { }
如果您选择重写OnConfiguring(也许是为了在其他场景中使用您的DbContext提供配置),您可以像下面这样检查选项是否已经配置:
protected override void OnConfiguring (
DbContextOptionsBuilder optionsBuilder)
{
if (!optionsBuilder.IsConfigured)
{
...
}
}
在OnConfiguring方法中,您可以启用其他选项,包括延迟加载(参见“延迟加载”)。
配置模型
默认情况下,EF Core 是基于约定的,这意味着它从您的类和属性名称推断数据库模式。
您可以通过重写OnModelCreating并在ModelBuilder参数上调用扩展方法,覆盖默认设置。例如,我们可以显式指定我们的Customer实体的数据库表名如下:
protected override void OnModelCreating (ModelBuilder modelBuilder) =>
modelBuilder.Entity<Customer>()
.ToTable ("Customer"); // Table is called 'Customer'
如果没有此代码,EF Core 将会将此实体映射到名为Customers的表,而不是“Customer”,因为我们在DbContext中有一个名为Customers的DbSet<Customer>属性:
public DbSet<Customer> Customers { get; set; }
注意
以下代码将所有实体映射到与实体类名(通常是单数形式)匹配的表名,而不是与DbSet<T>的属性名(通常是复数形式)匹配:
protected override void OnModelCreating (ModelBuilder
modelBuilder)
{
foreach (IMutableEntityType entityType in
modelBuilder.Model.GetEntityTypes())
{
modelBuilder.Entity (entityType.Name)
.ToTable (entityType.ClrType.Name);
}
}
Fluent API 提供了一种扩展语法来配置列。在下面的示例中,我们使用了两种流行的方法:
-
HasColumnName,将属性映射到不同命名的列 -
IsRequired,表示列不可为 null
protected override void OnModelCreating (ModelBuilder modelBuilder) =>
modelBuilder.Entity<Customer> (entity =>
{
entity.ToTable ("Customer");
entity.Property (e => e.Name)
.HasColumnName ("Full Name") // Column name is 'Full Name'
.IsRequired(); // Column is not nullable
});
表 8-1 列出了 Fluent API 中一些最重要的方法。
注意
除了使用 Fluent API 外,您还可以通过将特殊属性应用于实体类和属性(“数据注释”)来配置模型。这种方法在配置必须在编译时固定的情况下较为灵活,并且在某些只能通过 Fluent API 配置的选项上较为弱。
表 8-1. Fluent API 模型配置方法
| 方法 | 目的 | 示例 |
|---|---|---|
ToTable |
指定给定实体的数据库表名 |
builder
.Entity<Customer>()
.ToTable("Customer");
|
HasColumnName |
指定给定属性的列名 |
|---|
builder.Entity<Customer>()
.Property(c => c.Name)
.HasColumnName("Full Name");
|
HasKey |
指定一个键(通常与约定不同) |
|---|
builder.Entity<Customer>()
.HasKey(c => c.CustomerNr);
|
IsRequired |
指定属性需要有值(不可为 null) |
|---|
builder.Entity<Customer>()
.Property(c => c.Name)
.IsRequired();
|
HasMaxLength |
指定可变长度类型(通常是字符串)的最大长度 |
|---|
builder.Entity<Customer>()
.Property(c => c.Name)
.HasMaxLength(60);
|
HasColumnType |
指定列的数据库数据类型 |
|---|
builder.Entity<Purchase>()
.Property(p => p.Description)
.HasColumnType("varchar(80)");
|
Ignore |
忽略一个类型 |
|---|
builder.Ignore<Products>();
|
Ignore |
忽略类型的属性 |
|---|
builder.Entity<Customer>()
.Ignore(c => c.ChatName);
|
HasIndex |
指定一个(或一组)属性在数据库中作为索引使用 |
|---|
// Compound index:
builder.Entity<Purchase>()
.HasIndex(p =>
new { p.Date, p.Price });
// Unique index on one property
builder
.Entity<MedicalArticle>()
.HasIndex(a => a.Topic)
.IsUnique();
|
HasOne |
参见 “导航属性” |
|---|
builder.Entity<Purchase>()
.HasOne(p => p.Customer)
.WithMany(c => c.Purchases);
|
HasMany |
参见 “导航属性” |
|---|
builder.Entity<Customer>()
.HasMany(c => c.Purchases)
.WithOne(p => p.Customer);
|
创建数据库
EF Core 支持代码优先方法,这意味着您可以首先定义实体类,然后要求 EF Core 创建数据库。完成后,可以在 DbContext 实例上调用以下方法:
dbContext.Database.EnsureCreated();
然而,更好的方法是使用 EF Core 的迁移功能,不仅创建数据库,还配置它,以便在将来您的实体类更改时,EF Core 可以自动更新模式。您可以在 Visual Studio 的 Package Manager Console 中启用迁移,并使用以下命令创建数据库:
Install-Package Microsoft.EntityFrameworkCore.Tools
Add-Migration InitialCreate
Update-Database
第一条命令安装工具,以便从 Visual Studio 中管理 EF Core。第二条命令生成一个特殊的 C# 类,称为代码迁移,其中包含创建数据库的指令。最后一条命令针对项目应用配置文件中指定的数据库连接字符串运行这些指令。
使用 DbContext
定义了实体类并对 DbContext 进行了子类化后,可以实例化您的 DbContext 并查询数据库,如下所示:
using var dbContext = new NutshellContext();
Console.WriteLine (dbContext.Customers.Count());
// Executes "SELECT COUNT(*) FROM [Customer] AS [c]"
您还可以使用 DbContext 实例将数据写入数据库。以下代码向 Customer 表插入一行:
using var dbContext = new NutshellContext();
Customer cust = new Customer()
{
Name = "Sara Wells"
};
dbContext.Customers.Add (cust);
dbContext.SaveChanges(); // Writes changes back to database
以下查询数据库中刚刚插入的客户:
using var dbContext = new NutshellContext();
Customer cust = dbContext.Customers
.Single (c => c.Name == "Sara Wells")
下面更新了该客户的姓名,并将更改写入数据库:
cust.Name = "Dr. Sara Wells";
dbContext.SaveChanges();
注意
Single 操作符非常适合通过主键检索行。与 First 不同的是,如果返回超过一个元素,它会抛出异常。
目标跟踪
DbContext 实例跟踪其实例化的所有实体,因此每当您请求表中的相同行时,它可以将相同的实体返回给您。换句话说,在其生命周期中,上下文永远不会发出指向表中同一行(其中一行由主键标识)的两个单独实体。这种能力称为对象跟踪。
举例来说,假设姓名按字母顺序排列的第一个客户也具有最低的 ID。在以下示例中,a 和 b 将引用同一个对象:
using var dbContext = new NutshellContext ();
Customer a = dbContext.Customers.OrderBy (c => c.Name).First();
Customer b = dbContext.Customers.OrderBy (c => c.ID).First();
考虑 EF Core 遇到第二个查询时会发生什么情况。它首先查询数据库,并获取一行数据。然后读取这一行的主键,并在上下文的实体缓存中进行查找。如果找到匹配项,则返回现有对象,而不更新任何值。因此,如果另一个用户刚刚更新了数据库中该客户的Name,新值将被忽略。这对于避免意外副作用(Customer对象可能在其他地方使用中)至关重要,也对并发管理至关重要。如果您已更改了Customer对象上的属性但尚未调用SaveChanges,您不希望自动覆盖您的属性。
注意
您可以通过将AsNoTracking扩展方法链接到您的查询或通过在上下文上设置ChangeTracker.QueryTrackingBehavior为QueryTrackingBehavior.NoTracking来禁用对象跟踪。无跟踪查询在数据只读时非常有用,因为它提高了性能并减少了内存使用。
要从数据库获取最新信息,必须要么实例化一个新的上下文,要么调用Reload方法,如下所示:
dbContext.Entry (myCustomer).Reload();
最佳实践是每个工作单元使用一个新的DbContext实例,以便几乎不需要手动重新加载实体。
更改跟踪
当您通过DbContext加载的实体更改属性值时,EF Core 会识别更改,并在调用SaveChanges时相应地更新数据库。为此,它会创建一个加载通过您的DbContext子类的实体状态的快照,并在调用SaveChanges时(或在手动查询更改跟踪时,稍后将看到)将当前状态与原始状态进行比较。您可以按如下方式枚举DbContext中跟踪的更改:
foreach (var e in dbContext.ChangeTracker.Entries())
{
Console.WriteLine ($"{e.Entity.GetType().FullName} is {e.State}");
foreach (var m in e.Members)
Console.WriteLine (
$" {m.Metadata.Name}: '{m.CurrentValue}' modified: {m.IsModified}");
}
当您调用SaveChanges时,EF Core 使用ChangeTracker中的信息构造 SQL 语句,以使数据库与您的对象中的更改保持一致,发出插入语句以添加新行,更新语句以修改数据,并发出删除语句以删除在您的DbContext子类的对象图中已删除的行。任何TransactionScope都将受到尊重;如果没有,它将所有语句包装在一个新的事务中。
您可以通过在实体中实现INotifyPropertyChanged(以及可选的INotifyPropertyChanging)来优化更改跟踪。前者使得 EF Core 可以避免比较修改后的原始实体的开销;后者允许 EF Core 完全避免存储原始值。在实现这些接口后,在配置模型时调用ModelBuilder上的HasChangeTrackingStrategy方法,以激活优化的更改跟踪。
导航属性
导航属性允许您执行以下操作:
-
查询相关表,而无需手动连接
-
在不显式更新外键的情况下插入、删除和更新相关行
例如,假设一个客户可以拥有多个购买记录。我们可以用以下实体表示客户和购买之间的一对多关系:
public class Customer
{
public int ID { get; set; }
public string Name { get; set; }
// Child navigation property, which must be of type ICollection<T>:
public virtual List<Purchase> Purchases {get;set;} = new List<Purchase>();
}
public class Purchase
{
public int ID { get; set; }
public DateTime Date { get; set; }
public string Description { get; set; }
public decimal Price { get; set; }
public int CustomerID? { get; set; } // Foreign key field
public Customer Customer { get; set; } // Parent navigation property
}
EF Core 能够从这些实体推断出CustomerID是指向客户表的外键,因为名称CustomerID遵循流行的命名约定。如果我们要求 EF Core 根据这些实体创建数据库,它将在Purchase.CustomerID和Customer.ID之间创建外键约束。
注意
如果 EF Core 无法推断出关系,则可以在OnModelCreating方法中显式配置如下:
modelBuilder.Entity<Purchase>()
.HasOne (e => e.Customer)
.WithMany (e => e.Purchases)
.HasForeignKey (e => e.CustomerID);
设置了这些导航属性后,我们可以编写如下查询:
var customersWithPurchases = Customers.Where (c => c.Purchases.Any());
我们在第九章中详细讨论如何编写此类查询。
添加和删除导航集合中的实体
当您将新实体添加到集合导航属性时,EF Core 在调用SaveChanges时会自动填充外键:
Customer cust = dbContext.Customers.Single (c => c.ID == 1);
Purchase p1 = new Purchase { Description="Bike", Price=500 };
Purchase p2 = new Purchase { Description="Tools", Price=100 };
cust.Purchases.Add (p1);
cust.Purchases.Add (p2);
dbContext.SaveChanges();
在此示例中,EF Core 自动将CustomerID列写入每个新购买记录,并将每个购买记录的数据库生成的 ID 写入Purchase.ID。
当您从集合导航属性中删除实体并调用SaveChanges时,EF Core 将根据配置或推断的关系清除外键字段或从数据库中删除相应的行。在这种情况下,我们已将Purchase.CustomerID定义为可为空的整数(这样我们可以表示没有客户或现金交易的购买记录),因此从客户那里删除购买记录将清除其外键字段而不是从数据库中删除它。
加载导航属性
当 EF Core 填充一个实体时,默认情况下不会填充它的导航属性:
using var dbContext = new NutshellContext();
var cust = dbContext.Customers.First();
Console.WriteLine (cust.Purchases.Count); // Always 0
一个解决方案是使用Include扩展方法,该方法指示 EF Core 急切地加载导航属性:
var cust = dbContext.Customers
.Include (c => c.Purchases)
.Where (c => c.ID == 2).First();
另一个解决方案是使用投影。当您只需要处理实体的部分属性时,这种技术尤其有用,因为它减少了数据传输:
var custInfo = dbContext.Customers
.Where (c => c.ID == 2)
.Select (c => new
{
Name = c.Name,
Purchases = c.Purchases.Select (p => new { p.Description, p.Price })
})
.First();
这两种技术都告诉 EF Core 您需要哪些数据,以便它可以在单个数据库查询中获取它们。还可以手动指示 EF Core 按需填充导航属性:
dbContext.Entry (cust).Collection (b => b.Purchases).Load();
// cust.Purchases is now populated.
这称为显式加载。与前面的方法不同,这会在数据库中产生额外的一次往返。
懒加载
另一种加载导航属性的方法称为懒加载。启用后,EF Core 通过为每个实体类生成代理类,在访问未加载的导航属性时进行拦截,按需填充导航属性。为使此方法有效,每个导航属性必须是虚拟的,并且定义它的类必须是可继承的(非密封的)。此外,当延迟加载发生时,上下文不能已被释放,以便可以执行额外的数据库请求。
你可以在你的DbContext子类的OnConfiguring方法中启用延迟加载,如下所示:
protected override void OnConfiguring (DbContextOptionsBuilder
optionsBuilder)
{
optionsBuilder
.UseLazyLoadingProxies()
...
}
(你还需要添加对Microsoft.EntityFrameworkCore.ProxiesNuGet 包的引用。)
延迟加载的成本在于,每次访问未加载的导航属性时,EF Core 必须向数据库发出额外的请求。如果进行了许多这样的请求,性能可能会因过多的往返而受到影响。
注意
启用延迟加载后,你的类的运行时类型是从实体类派生的代理类。例如:
using var dbContext = new NutshellContext();
var cust = dbContext.Customers.First();
Console.WriteLine (cust.GetType());
// Castle.Proxies.CustomerProxy
延迟执行
EF Core 查询与本地查询一样,都支持延迟执行。这使得你可以逐步构建查询。然而,EF Core 在一个方面具有特殊的延迟执行语义,即当子查询出现在Select表达式中时。
对于本地查询,你会得到双重延迟执行,因为从功能角度来看,你正在选择一系列查询。因此,如果枚举外部结果序列但从未枚举内部序列,则子查询将永远不会执行。
使用 EF Core,子查询在与主外查询同时执行。这可以防止过多的往返。
例如,以下查询在到达第一个foreach语句时执行单次往返:
using var dbContext = new NutshellContext ();
var query = from c in dbContext.Customers
select
from p in c.Purchases
select new { c.Name, p.Price };
foreach (var customerPurchaseResults in query)
foreach (var namePrice in customerPurchaseResults)
Console.WriteLine ($"{ namePrice.Name} spent { namePrice.Price}");
你显式投影的任何导航属性都将在单次往返中完全填充:
var query = from c in dbContext.Customers
select new { c.Name, c.Purchases };
foreach (var row in query)
foreach (Purchase p in row.Purchases) // No extra round-tripping
Console.WriteLine (row.Name + " spent " + p.Price);
但是,如果我们枚举一个导航属性而没有首先进行预加载或投影,则会应用延迟执行规则。在以下示例中,EF Core 在每次循环迭代中执行另一个Purchases查询(假设启用了延迟加载):
foreach (Customer c in dbContext.Customers.ToArray())
foreach (Purchase p in c.Purchases) // Another SQL round-trip
Console.WriteLine (c.Name + " spent " + p.Price);
当你想要有选择地执行内部循环时,这种模型是有利的,这是基于仅在客户端上可以执行的测试:
foreach (Customer c in dbContext.Customers.ToArray())
if (*myWebService.HasBadCreditHistory (c.ID)*)
foreach (Purchase p in c.Purchases) // Another SQL round trip
Console.WriteLine (c.Name + " spent " + p.Price);
注意
请注意在前两个查询中使用ToArray。默认情况下,SQL Server 在处理当前查询结果时无法启动新的查询。调用ToArray将使客户端材料化,以便可以发出额外的查询来检索每个客户的购买记录。可以通过在数据库连接字符串末尾添加;MultipleActiveResultSets=True来配置 SQL Server 允许多个活动结果集(MARS)。使用 MARS 时要谨慎,因为它可能掩盖需要通过预加载和/或投影改进的冗余数据库设计。
(在第九章中,我们将更详细地探讨Select子查询,在“投影”中。)
构建查询表达式
到目前为止,在本章中,当我们需要动态组合查询时,我们通过有条件地链接查询操作符来实现。尽管这在许多场景中已经足够,但有时你需要在更精细的级别上工作,并动态组合馈送操作符的 lambda 表达式。
在本节中,我们假设以下Product类:
public class Product
{
public int ID { get; set; }
public string Description { get; set; }
public bool Discontinued { get; set; }
public DateTime LastSale { get; set; }
}
委托与表达式树
请注意:
-
使用
Enumerable操作符的本地查询采用委托。 -
使用
Queryable操作符的解释查询采用表达式树。
通过比较 Enumerable 和 Queryable 中 Where 操作符的签名,我们可以看到:
public static IEnumerable<TSource> Where<TSource> (this
IEnumerable<TSource> source, Func<TSource,bool> predicate)
public static IQueryable<TSource> Where<TSource> (this
IQueryable<TSource> source, Expression<Func<TSource,bool>> predicate)
在查询中嵌入的 lambda 表达式无论绑定到 Enumerable 的操作符还是 Queryable 的操作符,看起来都是相同的:
IEnumerable<Product> q1 = localProducts.Where (p => !p.Discontinued);
IQueryable<Product> q2 = sqlProducts.Where (p => !p.Discontinued);
然而,当您将 lambda 表达式分配给中间变量时,必须明确指定是解析为委托(即 Func<>)还是表达式树(即 Expression<Func<>>)。在下面的示例中,predicate1 和 predicate2 不能互换使用:
Func <Product, bool> predicate1 = p => !p.Discontinued;
IEnumerable<Product> q1 = localProducts.Where (predicate1);
Expression <Func <Product, bool>> predicate2 = p => !p.Discontinued;
IQueryable<Product> q2 = sqlProducts.Where (predicate2);
编译表达式树
通过调用 Compile 方法,可以将表达式树转换为委托。在编写返回可重用表达式的方法时,这非常有价值。为了说明,让我们向 Product 类添加一个静态方法,如果产品没有停产且在过去 30 天内有销售,则返回评估为 true 的谓词:
public class Product
{
public static Expression<Func<Product, bool>> IsSelling()
{
return p => !p.Discontinued && p.LastSale > DateTime.Now.AddDays (-30);
}
}
刚才编写的方法可以在解释和本地查询中使用,如下所示:
void Test()
{
var dbContext = new NutshellContext();
Product[] localProducts = dbContext.Products.ToArray();
IQueryable<Product> sqlQuery =
dbContext.Products.Where (Product.IsSelling());
IEnumerable<Product> localQuery =
localProducts.Where (Product.IsSelling().Compile());
}
注意
.NET 没有提供将委托转换为表达式树的 API。这使得表达式树更加灵活多变。
AsQueryable
AsQueryable 操作符允许您编写可以运行在本地或远程序列上的整个查询:
IQueryable<Product> FilterSortProducts (IQueryable<Product> input)
{
return from p in input
where ...
orderby ...
select p;
}
void Test()
{
var dbContext = new NutshellContext();
Product[] localProducts = dbContext.Products.ToArray();
var sqlQuery = FilterSortProducts (dbContext.Products);
var localQuery = FilterSortProducts (localProducts.AsQueryable());
...
}
AsQueryable 将 IQueryable<T> 的外衣包裹在本地序列周围,以便后续的查询操作符解析为表达式树。当您稍后枚举结果时,表达式树会隐式编译(以微小的性能成本),并且本地序列会像通常一样枚举。
表达树
我们之前说过,从 lambda 表达式到 Expression<TDelegate> 的隐式转换会导致 C# 编译器生成构建表达式树的代码。通过一些编程工作,您可以在运行时手动执行相同的操作,换句话说,从头开始动态构建表达式树。结果可以转换为 Expression<TDelegate> 并且可以在 EF Core 查询中使用,或者通过调用 Compile 编译为普通委托。
表达式 DOM
表达式树是一个迷你的代码 DOM。树中的每个节点由 System.Linq.Expressions 命名空间中的一种类型表示。图 8-10 说明了这些类型。

图 8-10. 表达式类型
所有节点的基类是非泛型的 Expression 类。泛型的 Expression<TDelegate> 类实际上表示“类型化 lambda 表达式”,如果不考虑笨拙的情况,本应该命名为 LambdaExpression<TDelegate>:
LambdaExpression<Func<Customer,bool>> f = ...
Expression<T> 的基类型是非泛型的 LambdaExpression 类。LambdaExpression 提供了对 lambda 表达式树的类型统一:任何类型化的 Expression<T> 都可以转换为 LambdaExpression。
区别于普通Expression的是,LambdaExpression具有参数。
要创建表达式树,不要直接实例化节点类型;而是调用Expression类提供的静态方法,比如Add、And、Call、Constant、LessThan等等。
图 8-11 展示了以下赋值创建的表达式树:
Expression<Func<string, bool>> f = s => s.Length < 5;

图 8-11. 表达式树
我们可以按如下方式演示:
Console.WriteLine (f.Body.NodeType); // LessThan
Console.WriteLine (((BinaryExpression) f.Body).Right); // 5
现在让我们从头开始构建这个表达式。原则是从树的底部开始,逐步向上构建。在我们的树中,最底部的是一个ParameterExpression,名为“s”,类型为string:
ParameterExpression p = Expression.Parameter (typeof (string), "s");
下一步是构建MemberExpression和ConstantExpression。在前者的情况下,我们需要访问参数“s”的Length 属性:
MemberExpression stringLength = Expression.Property (p, "Length");
ConstantExpression five = Expression.Constant (5);
接下来是LessThan比较:
BinaryExpression comparison = Expression.LessThan (stringLength, five);
最后一步是构建 Lambda 表达式,它将一个Body表达式链接到一个参数集合:
Expression<Func<string, bool>> lambda
= Expression.Lambda<Func<string, bool>> (comparison, p);
编译 Lambda 的一个方便方式是将其编译为委托:
Func<string, bool> runnable = lambda.Compile();
Console.WriteLine (runnable ("kangaroo")); // False
Console.WriteLine (runnable ("dog")); // True
注意
确定使用哪种表达式类型的最简单方法是在 Visual Studio 调试器中检查现有的 Lambda 表达式。
我们在这里继续讨论,网址为 http://www.albahari.com/expressions。
¹ 这个术语基于 Eric Evans 和 Martin Fowler 关于流畅接口的工作。
第九章:LINQ 操作符
本章描述了 LINQ 查询操作符的每一个。除了作为参考之外,两个部分 “投影” 和 “连接” 还涵盖了许多概念领域:
-
投影对象层次结构
-
使用
Select、SelectMany、Join和GroupJoin进行连接 -
具有多个范围变量的查询表达式
本章中的所有示例假设一个 names 数组定义如下:
string[] names = { "Tom", "Dick", "Harry", "Mary", "Jay" };
查询数据库的示例假设一个名为 dbContext 的变量已被实例化为
var dbContext = new NutshellContext();
其中 NutshellContext 定义如下:
public class NutshellContext : DbContext
{
public DbSet<Customer> Customers { get; set; }
public DbSet<Purchase> Purchases { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Customer>(entity =>
{
entity.ToTable("Customer");
entity.Property(e => e.Name).IsRequired(); // Column is not nullable
});
modelBuilder.Entity<Purchase>(entity =>
{
entity.ToTable("Purchase");
entity.Property(e => e.Date).IsRequired();
entity.Property(e => e.Description).IsRequired();
});
}
}
public class Customer
{
public int ID { get; set; }
public string Name { get; set; }
public virtual List<Purchase> Purchases { get; set; }
= new List<Purchase>();
}
public class Purchase
{
public int ID { get; set; }
public int? CustomerID { get; set; }
public DateTime Date { get; set; }
public string Description { get; set; }
public decimal Price { get; set; }
public virtual Customer Customer { get; set; }
}
注意
本章的所有示例都预装在 LINQPad 中,包括一个具有匹配架构的示例数据库。您可以从 http://www.linqpad.net 下载 LINQPad。
下面是相应的 SQL Server 表定义:
CREATE TABLE Customer (
ID int NOT NULL IDENTITY PRIMARY KEY,
Name nvarchar(30) NOT NULL
)
CREATE TABLE Purchase (
ID int NOT NULL IDENTITY PRIMARY KEY,
CustomerID int NOT NULL REFERENCES Customer(ID),
Date datetime NOT NULL,
Description nvarchar(30) NOT NULL,
Price decimal NOT NULL
)
概述
在本节中,我们提供标准查询操作符的概述。它们分为三类:
-
输入序列,输出序列(序列 → 序列)
-
输入序列,输出单个元素或标量值
-
无输入,输出序列(生成 方法)
我们首先介绍每个类别和它们包含的查询操作符,然后详细讨论每个单独的查询操作符。
序列 → 序列
大多数查询操作符属于此类别,接受一个或多个序列作为输入,并输出单个输出序列。图 9-1 显示了重构序列形状的操作符。

图 9-1. 形状变换操作符
过滤
IEnumerable<TSource> → IEnumerable<TSource>
返回原始元素的子集。
Where, Take, TakeLast, TakeWhile, Skip, SkipLast, SkipWhile,
Distinct, DistinctBy
投影
IEnumerable<TSource> → IEnumerable<TResult>
用 lambda 函数转换每个元素。SelectMany 可以展平嵌套序列;Select 和 SelectMany 可以在 EF Core 中执行内连接、左外连接、交叉连接和非等连接。
Select, SelectMany
连接
IEnumerable<TOuter>、IEnumerable<TInner> → IEnumerable<TResult>
将一个序列的元素与另一个序列的元素合并。 Join 和 GroupJoin 操作符设计用于本地查询效率,并支持内连接和左外连接。 Zip 操作符按步骤枚举两个序列,并对每个元素对应用一个函数。与命名类型参数 TOuter 和 TInner 不同,Zip 操作符将它们命名为 TFirst 和 TSecond:
IEnumerable<TFirst>、IEnumerable<TSecond> → IEnumerable<TResult>
Join, GroupJoin, Zip
排序
IEnumerable<TSource> → IOrderedEnumerable<TSource>
返回序列的重新排序。
OrderBy, OrderByDescending, ThenBy, ThenByDescending, Reverse
分组
IEnumerable<TSource> → IEnumerable<IGrouping<TKey,TElement>>
IEnumerable<TSource> → IEnumerable<TElement[]>
将序列分组成子序列。
GroupBy, Chunk
集合运算符
IEnumerable<TSource>、IEnumerable<TSource> → IEnumerable<TSource>
以两个相同类型的序列为参数,返回它们的共同性、总和或差异。
Concat, Union, UnionBy, Intersect, IntersectBy, Except, ExceptBy
转换方法:导入
IEnumerable → IEnumerable<TResult>
OfType, Cast
转换方法:导出
IEnumerable<TSource>→数组、列表、字典、查找或序列
ToArray, ToList, ToDictionary, ToLookup, AsEnumerable, AsQueryable
Sequence→Element or Value
以下查询运算符接受一个输入序列并输出单个元素或值。
元素运算符
IEnumerable<TSource>→TSource
从序列中选择单个元素。
First, FirstOrDefault, Last, LastOrDefault, Single, SingleOrDefault,
ElementAt, ElementAtOrDefault, MinBy, MaxBy, DefaultIfEmpty
聚合方法
IEnumerable<TSource>→*scalar*
执行跨序列的计算,返回一个标量值(通常是一个数字)。
Aggregate, Average, Count, LongCount, Sum, Max, Min
量词
IEnumerable<TSource>→*bool*
返回true或false的聚合。
All, Any, Contains, SequenceEqual
Void→Sequence
第三个和最后一个类别中有一些查询运算符,可以从头开始生成输出序列。
生成方法
void→IEnumerable<TResult>
生成一个简单的序列。
Empty, Range, Repeat
过滤
IEnumerable<TSource>→IEnumerable<TSource>
| 方法 | 描述 | SQL 等效项 |
|---|---|---|
Where |
返回满足给定条件的元素子集 | WHERE |
Take |
返回前count个元素并丢弃其余部分 |
WHERE ROW_NUMBER()... 或 TOP *n* 子查询 |
Skip |
忽略前count个元素并返回其余部分 |
WHERE ROW_NUMBER()... 或 NOT IN (SELECT TOP *n*...) |
TakeLast |
仅保留最后count个元素 |
抛出异常 |
SkipLast |
忽略最后count个元素 |
抛出异常 |
TakeWhile |
输出输入序列中满足断言的元素直到为假 | 抛出异常 |
SkipWhile |
忽略输入序列中的元素直到断言为假,然后输出剩余元素 | 抛出异常 |
Distinct, DistinctBy |
返回一个排除重复项的序列 | SELECT DISTINCT... |
注意
本章参考表中的“SQL 等效项”列未必对应于如 EF Core 等 IQueryable 实现将生成的内容。相反,它指示了如果你自己编写 SQL 查询,你通常会使用的方法。在没有简单转换的情况下,该列为空白。在根本没有翻译的情况下,该列读作“抛出异常”。
当显示Enumerable实现代码时,不包括对空参数和索引断言的检查。
使用每个过滤方法,你最终得到的元素数目要么与开始时相同,要么更少。当它们被输出时,它们也是相同的;它们不会以任何方式进行转换。
Where
| 参数 | 类型 |
|---|---|
| 源序列 | IEnumerable<TSource> |
| 断言 | TSource => bool or (TSource,int) => bool^(a) |
| ^(a) 不适用于 LINQ to SQL 和 Entity Framework |
查询语法
where *bool-expression*
Enumerable.Where 实现
除了空检查外,Enumerable.Where的内部实现在功能上等同于以下内容:
public static IEnumerable<TSource> Where<TSource>
(this IEnumerable<TSource> source, Func <TSource, bool> predicate)
{
foreach (TSource element in source)
if (predicate (element))
yield return element;
}
概述
Where返回满足给定条件的输入序列的元素。
例如:
string[] names = { "Tom", "Dick", "Harry", "Mary", "Jay" };
IEnumerable<string> query = names.Where (name => name.EndsWith ("y"));
*// Harry*
*// Mary*
*// Jay*
在查询语法中:
IEnumerable<string> query = from n in names
where n.EndsWith ("y")
select n;
查询中可以多次出现 where 子句,并与 let、orderby 和 join 子句交错:
from n in names
where n.Length > 3
let u = n.ToUpper()
where u.EndsWith ("Y")
select u;
*// HARRY*
*// MARY*
标准的 C# 作用域规则适用于此类查询。换句话说,在使用范围变量或 let 子句声明变量之前,无法引用该变量。
索引过滤
Where 的谓词可以选择接受第二个参数,类型为 int。这个参数会接收每个元素在输入序列中的位置,允许谓词在其过滤决策中使用这些信息。例如,以下代码跳过每个第二个元素:
IEnumerable<string> query = names.Where ((n, i) => i % 2 == 0);
*// Tom*
*// Harry*
*// Jay*
在 EF Core 中,如果使用索引过滤,会引发异常。
在 EF Core 中的 SQL LIKE 比较
string 上的以下方法翻译为 SQL 的 LIKE 操作符:
Contains, StartsWith, EndsWith
例如,c.Name.Contains("abc") 翻译为 customer.Name LIKE '%abc%'(或更准确地说,它的参数化版本)。Contains 只允许针对本地评估的表达式进行比较;要针对另一列进行比较,必须使用 EF.Functions.Like 方法:
... where EF.Functions.Like (c.Description, "%" + c.Name + "%")
EF.Functions.Like 还允许您执行更复杂的比较(例如 LIKE 'abc%def%')。
在 EF Core 中进行 < 和 > 的字符串比较
您可以使用 string 的 CompareTo 方法对字符串执行 顺序 比较;这相当于 SQL 的 < 和 > 操作符:
dbContext.Purchases.Where (p => p.Description.CompareTo ("C") < 0)
在 EF Core 中使用 WHERE x IN (…, …, …)
使用 EF Core,您可以在过滤谓词中将 Contains 操作符应用于本地集合。例如:
string[] chosenOnes = { "Tom", "Jay" };
from c in dbContext.Customers
where chosenOnes.Contains (c.Name)
...
这相当于 SQL 的 IN 操作符。换句话说:
WHERE customer.Name IN ("Tom", "Jay")
如果本地集合是实体数组或非标量类型,则 EF Core 可能会代替发出 EXISTS 子句。
Take、TakeLast、Skip、SkipLast
| 参数 | 类型 |
|---|---|
| 源序列 | IEnumerable<TSource> |
| 要获取或跳过的元素数量 | int |
Take 返回前 n 个元素并丢弃其余部分;Skip 丢弃前 n 个元素并返回其余部分。当实现允许用户浏览大量匹配记录的网页时,这两种方法一起非常有用。例如,假设用户在书籍数据库中搜索术语“mercury”,共有 100 个匹配项。以下代码返回前 20 个:
IQueryable<Book> query = dbContext.Books
.Where (b => b.Title.Contains ("mercury"))
.OrderBy (b => b.Title)
.Take (20);
下一个查询返回书籍 21 到 40:
IQueryable<Book> query = dbContext.Books
.Where (b => b.Title.Contains ("mercury"))
.OrderBy (b => b.Title)
.Skip (20).Take (20);
EF Core 将 Take 和 Skip 转换为 SQL Server 2005 中的 ROW_NUMBER 函数,或者在较早版本的 SQL Server 中转换为 TOP n 子查询。
TakeLast 和 SkipLast 方法接受或跳过最后 n 个元素。
从 .NET 6 开始,Take 方法重载以接受 Range 变量。此重载可以替代所有四种方法的功能;例如,Take(5..) 等同于 Skip(5),而 Take(..⁵) 等同于 SkipLast(5)。
TakeWhile 和 SkipWhile
| 参数 | 类型 |
|---|---|
| 源序列 | IEnumerable<TSource> |
| 谓词 | TSource => bool 或 (TSource,int) => bool |
TakeWhile 枚举输入序列,返回每个项目,直到给定的谓词为假。然后忽略剩余元素:
int[] numbers = { 3, 5, 2, 234, 4, 1 };
var takeWhileSmall = numbers.TakeWhile (n => n < 100); // { 3, 5, 2 }
SkipWhile枚举输入序列,忽略每个项目,直到给定的谓词为 false。然后,它发出剩余的元素:
int[] numbers = { 3, 5, 2, 234, 4, 1 };
var skipWhileSmall = numbers.SkipWhile (n => n < 100); // { 234, 4, 1 }
TakeWhile和SkipWhile在 SQL 中没有对应的翻译,并且如果在 EF Core 查询中使用会抛出异常。
不同和 DistinctBy
Distinct返回输入序列,去除重复项。您可以选择传入自定义的相等比较器。以下返回字符串中不同的字母:
char[] distinctLetters = "HelloWorld".Distinct().ToArray();
string s = new string (distinctLetters); // HeloWrd
我们可以直接在字符串上调用 LINQ 方法,因为string实现了IEnumerable<char>。
.NET 6引入了DistinctBy方法,允许您指定在执行相等比较之前要应用的键选择器。以下表达式的结果是{1,2,3}:
new[] { 1.0, 1.1, 2.0, 2.1, 3.0, 3.1 }.DistinctBy (n => Math.Round (n, 0))
投影
IEnumerable<TSource>→ IEnumerable<TResult>
| 方法 | 描述 | SQL 等效项 |
|---|---|---|
Select |
使用给定的 lambda 表达式转换每个输入元素 | SELECT |
| SelectMany | 转换每个输入元素,然后展平和连接生成的子序列 | INNER JOIN,LEFT OUTER JOIN,
CROSS JOIN |
注意
在查询数据库时,Select和SelectMany是最通用的连接构造;对于本地查询,Join和GroupJoin是最高效的连接构造。
Select
| 参数 | 类型 |
|---|---|
| 源序列 | IEnumerable<TSource> |
| 结果选择器 | TSource => TResult 或 (TSource,int) => TResult^(a) |
| ^(a) 在 EF Core 中禁止使用 |
查询语法
select *projection-expression*
可枚举实现
public static IEnumerable<TResult> Select<TSource,TResult>
(this IEnumerable<TSource> source, Func<TSource,TResult> selector)
{
foreach (TSource element in source)
yield return selector (element);
}
概述
使用Select,您始终获得与开始时相同数量的元素。但是,每个元素可以由 lambda 函数以任何方式转换。
以下选择安装在计算机上的所有字体的名称(来自System.Drawing):
IEnumerable<string> query = from f in FontFamily.Families
select f.Name;
foreach (string name in query) Console.WriteLine (name);
在此示例中,select子句将FontFamily对象转换为其名称。以下是 lambda 等效项:
IEnumerable<string> query = FontFamily.Families.Select (f => f.Name);
Select语句通常用于投影到匿名类型:
var query =
from f in FontFamily.Families
select new { f.Name, LineSpacing = f.GetLineSpacing (FontStyle.Bold) };
不带任何转换的投影有时与查询语法一起使用,以满足查询以select或group子句结束的要求。以下选择支持删除线的字体:
IEnumerable<FontFamily> query =
from f in FontFamily.Families
where f.IsStyleAvailable (FontStyle.Strikeout)
select f;
foreach (FontFamily ff in query) Console.WriteLine (ff.Name);
在这种情况下,编译器在转换为流畅语法时会省略投影。
索引投影
selector表达式可以选择性地接受一个整数参数,该参数充当索引器,为输入序列中每个输入的位置提供表达式。这仅适用于本地查询:
string[] names = { "Tom", "Dick", "Harry", "Mary", "Jay" };
IEnumerable<string> query = names
.Select ((s,i) => i + "=" + s); // { "0=Tom", "1=Dick", ... }
选择子查询和对象层次结构
您可以在select子句中嵌套子查询以构建对象层次结构。以下示例返回描述Path.GetTempPath()下每个目录及其子文件集合的集合:
string tempPath = Path.GetTempPath();
DirectoryInfo[] dirs = new DirectoryInfo (tempPath).GetDirectories();
var query =
from d in dirs
where (d.Attributes & FileAttributes.System) == 0
select new
{
DirectoryName = d.FullName,
Created = d.CreationTime,
Files = from f in d.GetFiles()
where (f.Attributes & FileAttributes.Hidden) == 0
select new { FileName = f.Name, f.Length, }
};
foreach (var dirFiles in query)
{
Console.WriteLine ("Directory: " + dirFiles.DirectoryName);
foreach (var file in dirFiles.Files)
Console.WriteLine (" " + file.FileName + " Len: " + file.Length);
}
此查询的内部部分可以称为相关子查询。如果子查询引用外部查询中的对象,则该子查询是相关的——在本例中,它引用了正在枚举的目录d。
注意
在Select中的子查询允许您将一个对象层次结构映射到另一个对象层次结构,或者将关系对象模型映射到分层对象模型。
对于本地查询,Select中的子查询会导致双重延迟执行。在我们的示例中,直到内部的 foreach 语句枚举时,文件才被过滤或投影。
EF Core 中的子查询和连接
在 EF Core 中,子查询投影在功能上效果很好,您可以使用它们来执行类似 SQL 风格的连接。以下是如何检索每个客户的名称以及他们的高价值购买:
var query =
from c in dbContext.Customers
select new {
c.Name,
Purchases = (from p in dbContext.Purchases
where p.CustomerID == c.ID && p.Price > 1000
select new { p.Description, p.Price })
.ToList()
};
foreach (var namePurchases in query)
{
Console.WriteLine ("Customer: " + namePurchases.Name);
foreach (var purchaseDetail in namePurchases.Purchases)
Console.WriteLine (" - $$$: " + purchaseDetail.Price);
}
注意
注意在子查询中使用 ToList。当子查询引用 DbContext 时,EF Core 3 无法从子查询结果创建可查询对象。此问题正在由 EF Core 团队跟踪,可能会在未来的版本中解决。
注意
这种查询方式非常适合解释查询。外部查询和子查询作为一个单元进行处理,避免了不必要的往返。然而,对于本地查询来说,效率较低,因为必须枚举每个外部和内部元素的组合才能获取少数匹配组合。本地查询的更好选择是在以下章节中描述的Join或GroupJoin。
这个查询将来自两个不同集合的对象匹配起来,可以被视为一个“连接”。与传统的数据库连接(或子查询)的不同之处在于,我们没有将输出展平成单个二维结果集。我们将关系数据映射到分层数据,而不是扁平数据。
下面是通过在Customer实体上使用Purchases集合导航属性简化的相同查询:
from c in dbContext.Customers
select new
{
c.Name,
Purchases = from p in c.Purchases // Purchases is List<Purchase>
where p.Price > 1000
select new { p.Description, p.Price }
};
(在 EF Core 3 中,在导航属性上执行子查询时不需要 ToList。)
这两个查询与 SQL 中的左外连接类似,因为我们在外部枚举中获取所有客户,无论他们是否有任何购买。要模拟内连接——排除没有高价值购买的客户——我们需要在购买集合上添加一个过滤条件:
from c in dbContext.Customers
where c.Purchases.Any (p => p.Price > 1000)
select new {
c.Name,
Purchases = from p in c.Purchases
where p.Price > 1000
select new { p.Description, p.Price }
};
然而,这样稍显凌乱,因为我们两次写了相同的谓词(Price > 1000)。我们可以通过 let 子句避免这种重复:
from c in dbContext.Customers
let highValueP = from p in c.Purchases
where p.Price > 1000
select new { p.Description, p.Price }
where highValueP.Any()
select new { c.Name, Purchases = highValueP };
这种查询方式非常灵活。例如,通过将 Any 修改为 Count,我们可以修改查询以仅检索至少有两个高价值购买的客户:
...
where highValueP.Count() >= 2
select new { c.Name, Purchases = highValueP };
投影到具体类型
到目前为止的例子中,我们在输出中实例化了匿名类型。在某些情况下,实例化(普通)命名类也很有用,您可以使用对象初始化程序填充它们。这种类可以包含自定义逻辑,并且可以在方法和程序集之间传递,而无需使用类型信息。
一个典型的例子是自定义业务实体。自定义业务实体只是您编写的带有一些属性的类,旨在隐藏较低级别(与数据库相关的)细节。例如,您可能会从业务实体类中排除外键字段。假设我们编写了名为 CustomerEntity 和 PurchaseEntity 的自定义实体类,这是我们如何将其投射到它们中的方法:
IQueryable<CustomerEntity> query =
from c in dbContext.Customers
select new CustomerEntity
{
Name = c.Name,
Purchases =
(from p in c.Purchases
where p.Price > 1000
select new PurchaseEntity {
Description = p.Description,
Value = p.Price
}
).ToList()
};
// Force query execution, converting output to a more convenient List:
List<CustomerEntity> result = query.ToList();
注意
在程序或不同系统之间传输数据时,经常使用自定义业务实体类称为数据传输对象(DTO)。DTO 不包含任何业务逻辑。
注意到到目前为止,我们还没有使用 Join 或 SelectMany 语句。这是因为我们保持了数据的层次形状,正如在 Figure 9-2 中所示。使用 LINQ,您通常可以避免传统的 SQL 方法,将表格展平为二维结果集。

图 9-2. 投射对象层次结构
SelectMany
| 参数 | 类型 |
|---|---|
| 源序列 | IEnumerable<TSource> |
| 结果选择器 | TSource => IEnumerable<TResult> 或 (TSource,int) => IEnumerable<TResult>^(a) |
| ^(a) EF Core 不允许使用 |
查询语法
from *identifier1* in *enumerable-expression1*
from *identifier2* in *enumerable-expression2*
...
可枚举实现
public static IEnumerable<TResult> SelectMany<TSource,TResult>
(IEnumerable<TSource> source,
Func <TSource,IEnumerable<TResult>> selector)
{
foreach (TSource element in source)
foreach (TResult subElement in selector (element))
yield return subElement;
}
概览
SelectMany 将子序列连接成一个单一的扁平输出序列。
请记住,对于每个输入元素,Select 产生恰好一个输出元素。相比之下,SelectMany 产生0..n个输出元素。0..n元素来自于 lambda 表达式必须发出的子序列或子序列。
您可以使用 SelectMany 扩展子序列,展平嵌套集合,并将两个集合连接到一个扁平的输出序列中。使用传送带类比,SelectMany 将新鲜材料引入到传送带上。对于 SelectMany,每个输入元素都是引入新鲜材料的触发器。新鲜材料由 selector lambda 表达式发出,必须是一个序列。换句话说,lambda 表达式必须为每个输入元素发出一个子序列。最终结果是每个输入元素发出的子序列的串联。
从一个简单的例子开始,假设我们有以下名称数组,
string[] fullNames = { "Anne Williams", "John Fred Smith", "Sue Green" };
我们希望将其转换为单一的扁平单词集合——换句话说:
"Anne", "Williams", "John", "Fred", "Smith", "Sue", Green"
这项任务最适合使用 SelectMany,因为我们将每个输入元素映射到一个变量数量的输出元素。我们所需做的就是提出一个 selector 表达式,将每个输入元素转换为子序列。string.Split 很好地完成了这项工作:它接受一个字符串并将其拆分为单词,将结果作为数组发出:
string testInputElement = "Anne Williams";
string[] childSequence = testInputElement.Split();
// childSequence is { "Anne", "Williams" };
因此,这是我们的 SelectMany 查询和结果:
IEnumerable<string> query = fullNames.SelectMany (name => name.Split());
foreach (string name in query)
Console.Write (name + "|"); // Anne|Williams|John|Fred|Smith|Sue|Green|
注意
如果您将SelectMany替换为Select,您将以分层形式获得相同的结果。以下代码生成了一个字符串数组的序列,需要嵌套的foreach语句来枚举:
IEnumerable<string[]> query =
fullNames.Select (name => name.Split());
foreach (string[] stringArray in query)
foreach (string name in stringArray)
Console.Write (name + "|");
SelectMany的好处在于它产生一个单一的扁平结果序列。
SelectMany在查询语法中受支持,并且通过在查询中添加一个额外的from子句来调用它。from关键字在查询语法中有两个含义。在查询的开头,它引入原始的范围变量和输入序列。在查询的任何其他地方,它转换为SelectMany。以下是我们在查询语法中的查询:
IEnumerable<string> query =
from fullName in fullNames
from name in fullName.Split() // Translates to SelectMany
select name;
请注意,额外的生成器引入了一个新的范围变量——在这种情况下是name。然而,旧的范围变量仍然在作用域内,因此我们随后可以访问两者。
多个范围变量
在前面的示例中,name和fullName在查询结束或达到into子句之前仍然在作用域内。这些变量的扩展作用域是查询语法相对于流畅语法的杀手级场景。
为了说明这一点,我们可以采用前面的查询,并在最终投影中包括fullName:
IEnumerable<string> query =
from fullName in fullNames
from name in fullName.Split()
select name + " came from " + fullName;
*Anne came from Anne Williams*
*Williams came from Anne Williams*
*John came from John Fred Smith*
...
在幕后,编译器必须做一些技巧,让您访问这两个变量。欣赏这一点的好方法是尝试使用流畅语法编写相同的查询。这很棘手!如果在投影之前插入where或orderby子句,它将变得更加困难:
from fullName in fullNames
from name in fullName.Split()
orderby fullName, name
select name + " came from " + fullName;
问题在于SelectMany生成了一个平坦的子元素序列——在我们的情况下,是单词的平坦集合。从中来的原始“外部”元素(fullName)丢失了。解决方案是使用一个临时的匿名类型将外部元素“携带”到每个子元素中:
from fullName in fullNames
from x in fullName.Split().Select (name => new { name, fullName } )
orderby x.fullName, x.name
select x.name + " came from " + x.fullName;
这里唯一的变化是我们将每个子元素(name)包装在一个匿名类型中,该类型还包含其fullName。这类似于let子句的解析方式。这是转换为流畅语法的最终形式:
IEnumerable<string> query = fullNames
.SelectMany (fName => fName.Split()
.Select (name => new { name, fName } ))
.OrderBy (x => x.fName)
.ThenBy (x => x.name)
.Select (x => x.name + " came from " + x.fName);
在查询语法中思考
正如我们刚刚演示的,如果您需要多个范围变量,使用查询语法是有充分理由的。在这种情况下,不仅使用查询语法有帮助,而且直接用它的术语思考也是有益的。
写入额外生成器时有两种基本模式。第一种是展开和扁平化子序列。为此,您在额外的生成器中调用现有范围变量的属性或方法。我们在前面的示例中已经这样做了:
from fullName in fullNames
from name in fullName.Split()
在这里,我们从枚举全名扩展到枚举单词。类似的 EF Core 查询是当您扩展集合导航属性时。以下查询列出了所有客户及其购买记录:
IEnumerable<string> query = from c in dbContext.Customers
from p in c.Purchases
select c.Name + " bought a " + p.Description;
*Tom bought a Bike*
*Tom bought a Holiday*
*Dick bought a Phone*
*Harry bought a Car*
*...*
在这里,我们将每个客户扩展到购买的子序列中。
第二种模式执行的是笛卡尔积或交叉连接,其中一个序列的每个元素与另一个序列的每个元素匹配。为此,引入一个生成器,其selector表达式返回与范围变量无关的序列:
int[] numbers = { 1, 2, 3 }; string[] letters = { "a", "b" };
IEnumerable<string> query = from n in numbers
from l in letters
select n.ToString() + l;
// RESULT: { "1a", "1b", "2a", "2b", "3a", "3b" }
这种查询风格是 SelectMany 类型的 连接 的基础。
使用 SelectMany 进行连接
使用 SelectMany 可以通过过滤交叉产品的结果简单地连接两个序列。例如,假设我们想要为游戏匹配玩家,我们可以这样开始:
string[] players = { "Tom", "Jay", "Mary" };
IEnumerable<string> query = from name1 in players
from name2 in players
select name1 + " vs " + name2;
//RESULT: { "Tom vs Tom", "Tom vs Jay", "Tom vs Mary",
// "Jay vs Tom", "Jay vs Jay", "Jay vs Mary",
// "Mary vs Tom", "Mary vs "Jay", "Mary vs Mary" }
查询读取为“对于每个玩家,迭代每个玩家,选择玩家 1 对玩家 2”。虽然我们得到了我们要求的内容(一个交叉连接),但在添加过滤器之前结果并不有用:
IEnumerable<string> query = from name1 in players
from name2 in players
where name1.CompareTo (name2) < 0
orderby name1, name2
select name1 + " vs " + name2;
//RESULT: { "Jay vs Mary", "Jay vs Tom", "Mary vs Tom" }
过滤谓词构成了连接条件。我们的查询可以称为非等值连接,因为连接条件不使用等号运算符。
EF Core 中的 SelectMany
在 EF Core 中,SelectMany 可以执行交叉连接、非等值连接、内连接和左外连接。你可以像使用 Select 一样使用 SelectMany,既可以使用预定义的关联,也可以使用临时关系——不同之处在于 SelectMany 返回平面而不是分层的结果集。
EF Core 的交叉连接与前一节中一样书写。以下查询将每个顾客与每个购买匹配(交叉连接):
var query = from c in dbContext.Customers
from p in dbContext.Purchases
select c.Name + " might have bought a " + p.Description;
但更典型的情况是,你可能只想匹配顾客与其自己的购买。通过添加具有连接谓词的 where 子句来实现这一点。这会导致标准的 SQL 风格的等值连接:
var query = from c in dbContext.Customers
from p in dbContext.Purchases
where c.ID == p.CustomerID
select c.Name + " bought a " + p.Description;
注意
这在 SQL 中表现良好。在下一节中,我们将看到它如何扩展以支持外连接。使用 LINQ 的 Join 操作符重新制定这样的查询实际上会使其不易扩展——在这方面,LINQ 与 SQL 是相反的。
如果在实体中有集合导航属性,你可以通过展开子集合而不是过滤交叉产品来表达相同的查询:
from c in dbContext.Customers
from p in c.Purchases
select new { c.Name, p.Description };
优势在于我们消除了连接谓词。我们从过滤交叉产品转向扩展和展开。
你可以为这样的查询添加 where 子句进行额外的过滤。例如,如果我们只想要以“T”开头的客户,我们可以如下过滤:
from c in dbContext.Customers
where c.Name.StartsWith ("T")
from p in c.Purchases
select new { c.Name, p.Description };
如果 where 子句移到下一行,此 EF Core 查询同样有效,因为两种情况下生成相同的 SQL。然而,如果是本地查询,将 where 子句移到下方可能会降低效率。在本地查询中,你应该在连接之前进行过滤。
你可以通过额外的 from 子句将新表引入混合中。例如,如果每个购买有购买项目子行,你可以生成以下形式的平面结果集,其中包括顾客及其购买及其购买详细行:
from c in dbContext.Customers
from p in c.Purchases
from pi in p.PurchaseItems
select new { c.Name, p.Description, pi.Detail };
每个 from 子句引入一个新的 子 表。要包含通过导航属性从 父 表检索数据,你不需要添加 from 子句——只需导航到属性即可。例如,如果每个顾客都有一个销售员,你想查询其姓名,只需这样做:
from c in dbContext.Customers
select new { Name = c.Name, SalesPerson = c.SalesPerson.Name };
在这种情况下不使用 SelectMany,因为没有子集合需要展开。父导航属性返回单个项。
使用 SelectMany 进行外连接
我们之前看到,Select子查询产生的结果类似于左外连接:
from c in dbContext.Customers
select new {
c.Name,
Purchases = from p in c.Purchases
where p.Price > 1000
select new { p.Description, p.Price }
};
在此示例中,每个外部元素(客户)都包括在内,无论客户是否有任何购买。但假设我们将此查询重写为SelectMany,以便我们可以获取一个单一的平坦集合而不是层次化结果集:
from c in dbContext.Customers
from p in c.Purchases
where p.Price > 1000
select new { c.Name, p.Description, p.Price };
在展开查询的过程中,我们已经切换到内连接:现在只包括那些存在一个或多个高价值购买的客户。为了获得带有平坦结果集的左外连接,我们必须在内部序列上应用DefaultIfEmpty查询运算符。该方法在其输入序列没有元素时返回带有单个空元素的序列。以下是这样一个查询,价格谓词除外:
from c in dbContext.Customers
from p in c.Purchases.DefaultIfEmpty()
select new { c.Name, p.Description, Price = (decimal?) p.Price };
这在 EF Core 中完美运行,返回所有客户,即使他们没有购买。但是如果我们将其作为本地查询运行,当p为空时,p.Description和p.Price会抛出NullReferenceException。我们可以使我们的查询在任何情况下都更加健壮,如下所示:
from c in dbContext.Customers
from p in c.Purchases.DefaultIfEmpty()
select new {
c.Name,
Descript = p == null ? null : p.Description,
Price = p == null ? (decimal?) null : p.Price
};
现在让我们重新引入价格过滤器。我们不能像以前那样使用where子句,因为它会在DefaultIfEmpty之后执行:
from c in dbContext.Customers
from p in c.Purchases.DefaultIfEmpty()
where p.Price > 1000...
正确的解决方案是在DefaultIfEmpty之前用一个子查询剪切Where子句:
from c in dbContext.Customers
from p in c.Purchases.Where (p => p.Price > 1000).DefaultIfEmpty()
select new {
c.Name,
Descript = p == null ? null : p.Description,
Price = p == null ? (decimal?) null : p.Price
};
EF Core 将其转换为左外连接。这是编写此类查询的有效模式。
注意
如果你习惯于在 SQL 中编写外连接,你可能会倾向于忽视这种风格查询的更简单选项,而选择在这种风格查询中采用笨拙但熟悉的 SQL 中心的平坦方法。Select子查询产生的层次化结果集通常更适合于外连接风格的查询,因为没有额外的空值需要处理。
连接
| 方法 | 描述 | SQL 等效语句 |
|---|---|---|
Join |
应用查找策略以匹配两个集合的元素,生成平坦结果集 | INNER JOIN |
GroupJoin |
类似于Join,但生成一个层次化的结果集 |
INNER JOIN,LEFT OUTER JOIN |
Zip |
枚举两个序列以步骤方式(如拉链),对每对元素应用函数 | 抛出异常 |
连接与 GroupJoin
IEnumerable<TOuter>, IEnumerable<TInner>→IEnumerable<TResult>
连接参数
| 参数 | 类型 |
|---|---|
| 外部序列 | IEnumerable<TOuter> |
| 内部序列 | IEnumerable<TInner> |
| 外部键选择器 | TOuter => TKey |
| 内部键选择器 | TInner => TKey |
| 结果选择器 | (TOuter,TInner) => TResult |
GroupJoin 参数
| 参数 | 类型 |
|---|---|
| 外部序列 | IEnumerable<TOuter> |
| 内部序列 | IEnumerable<TInner> |
| 外部键选择器 | TOuter => TKey |
| 内部键选择器 | TInner => TKey |
| 结果选择器 | (TOuter,**IEnumerable<TInner>**) => TResult |
查询语法
from *outer-var* in *outer-enumerable*
join *inner-var* in *inner-enumerable* on *outer-key-expr* equals *inner-key-expr*
[ into *identifier* ]
概述
Join 和 GroupJoin 将两个输入序列合并为一个输出序列。Join 生成平坦的输出;GroupJoin 生成分层次的输出。
Join 和 GroupJoin 提供了一种替代策略来使用 Select 和 SelectMany。Join 和 GroupJoin 的优势在于它们在本地内存中的集合上执行效率高,因为它们首先将内部序列加载到键控查找中,避免了需要重复枚举每个内部元素的问题。缺点是它们只提供了等效的内部和左外连接;交叉连接和非等连接仍需使用 Select/SelectMany。在 EF Core 查询中,Join 和 GroupJoin 与 Select 和 SelectMany 相比并没有实质性的优势。
表 9-1 总结了每种连接策略之间的差异。
表 9-1. 连接策略
| 策略 | 结果形状 | 本地查询效率 | 内部连接 | 左外连接 | 交叉连接 | 非等连接 |
|---|---|---|---|---|---|---|
Select + SelectMany |
平坦 | 差 | 是 | 是 | 是 | 是 |
Select + Select |
嵌套 | 差 | 是 | 是 | 是 | 是 |
Join |
平坦 | 良好 | 是 | — | — | — |
GroupJoin |
嵌套 | 良好 | 是 | 是 | — | — |
GroupJoin + SelectMany |
平坦 | 良好 | 是 | 是 | — | — |
Join
Join 运算符执行内部连接,生成一个平坦的输出序列。
以下查询列出了所有客户以及他们的购买内容,而不使用导航属性:
IQueryable<string> query =
from c in dbContext.Customers
join p in dbContext.Purchases on c.ID equals p.CustomerID
select c.Name + " bought a " + p.Description;
结果与我们从 SelectMany 风格的查询中获得的结果相匹配:
*Tom bought a Bike*
*Tom bought a Holiday*
*Dick bought a Phone*
*Harry bought a Car*
要看到 Join 胜过 SelectMany 的好处,我们必须将其转换为本地查询。我们可以首先将所有的客户和购买内容复制到数组中,然后查询这些数组:
Customer[] customers = dbContext.Customers.ToArray();
Purchase[] purchases = dbContext.Purchases.ToArray();
var slowQuery = from c in customers
from p in purchases where c.ID == p.CustomerID
select c.Name + " bought a " + p.Description;
var fastQuery = from c in customers
join p in purchases on c.ID equals p.CustomerID
select c.Name + " bought a " + p.Description;
尽管两个查询产生相同的结果,但 Join 查询速度显著更快,因为它在 Enumerable 中的实现预加载了内部集合(purchases)到键控查找中。
Join 的查询语法通常可以用一般化的术语来描述,如下所示:
join *inner-var* in *inner-sequence* on *outer-key-expr* equals *inner-key-expr*
LINQ 中的连接操作符区分 外部序列 和 内部序列。语法上:
-
外部序列 是输入序列(在本例中是
customers)。 -
内部序列 是你引入的新集合(在本例中是
purchases)。
Join 执行内连接,意味着没有购买内容的客户将被排除在输出之外。在内连接中,您可以交换查询中的内部和外部序列,并且仍然可以得到相同的结果:
from p in purchases // p is now outer
join c in customers on p.CustomerID equals c.ID // c is now inner
...
您可以向相同的查询添加进一步的 join 子句。例如,如果每个购买具有一个或多个购买项目,您可以加入购买项目,如下所示:
from c in customers
join p in purchases on c.ID equals p.CustomerID // first join
join pi in purchaseItems on p.ID equals pi.PurchaseID // second join
...
在第一个连接中,purchases 充当 内部 序列,在第二个连接中充当 外部 序列。你可以使用嵌套的 foreach 语句以不高效的方式获得相同的结果,如下所示:
foreach (Customer c in customers)
foreach (Purchase p in purchases)
if (c.ID == p.CustomerID)
foreach (PurchaseItem pi in purchaseItems)
if (p.ID == pi.PurchaseID)
Console.WriteLine (c.Name + "," + p.Price + "," + pi.Detail);
在查询语法中,来自早期连接的变量保持在作用域内——就像在SelectMany样式的查询中一样。您还允许在join子句之间插入where和let子句。
在多个键上进行连接
您可以使用匿名类型在多个键上进行连接,如下所示:
from x in sequenceX
join y in sequenceY on new { K1 = x.Prop1, K2 = x.Prop2 }
equals new { K1 = y.Prop3, K2 = y.Prop4 }
...
要使其工作,这两个匿名类型必须结构相同。然后编译器使用相同的内部类型实现每个类型,使连接键兼容。
在流畅的语法中进行连接
以下的查询语法连接
from c in customers
join p in purchases on c.ID equals p.CustomerID
select new { c.Name, p.Description, p.Price };
在流畅的语法中如下所示:
customers.Join ( // outer collection
purchases, // inner collection
c => c.ID, // outer key selector
p => p.CustomerID, // inner key selector
(c, p) => new
{ c.Name, p.Description, p.Price } // result selector
);
最终的选择器表达式在输出序列中创建每个元素。如果您在投影之前有额外的子句,比如在这个例子中的orderby:
from c in customers
join p in purchases on c.ID equals p.CustomerID
orderby p.Price
select c.Name + " bought a " + p.Description;
在流畅的语法中,你必须在结果选择器中制造一个临时的匿名类型。这样可以在连接之后保持c和p的作用域:
customers.Join ( // outer collection
purchases, // inner collection
c => c.ID, // outer key selector
p => p.CustomerID, // inner key selector
(c, p) => new { c, p } ) // result selector
.OrderBy (x => x.p.Price)
.Select (x => x.c.Name + " bought a " + x.p.Description);
在连接时通常更喜欢使用查询语法;它不那么琐碎。
GroupJoin
GroupJoin做的工作与Join相同,但不是产生平坦的结果,而是产生按每个外部元素分组的分层结果。它还允许左外连接。GroupJoin目前不支持在 EF Core 中使用。
GroupJoin的查询语法与Join相同,但后面跟着into关键字。
这是一个使用本地查询的最基本的示例:
Customer[] customers = dbContext.Customers.ToArray();
Purchase[] purchases = dbContext.Purchases.ToArray();
IEnumerable<IEnumerable<Purchase>> query =
from c in customers
join p in purchases on c.ID equals p.CustomerID
into custPurchases
select custPurchases; // custPurchases is a sequence
注意
into子句仅在直接出现在join子句之后时才会转换为GroupJoin。在select或group子句之后,它表示查询继续。into关键字的两种用法非常不同,尽管它们有一个共同点:它们都引入了一个新的范围变量。
结果是一个序列的序列,我们可以按以下方式枚举:
foreach (IEnumerable<Purchase> purchaseSequence in query)
foreach (Purchase p in purchaseSequence)
Console.WriteLine (p.Description);
然而,这并不是很有用,因为purchaseSequence没有对客户的引用。更常见的是,你会这样做:
from c in customers
join p in purchases on c.ID equals p.CustomerID
into custPurchases
select new { CustName = c.Name, custPurchases };
这与以下(低效的)Select子查询给出相同的结果:
from c in customers
select new
{
CustName = c.Name,
custPurchases = purchases.Where (p => c.ID == p.CustomerID)
};
默认情况下,GroupJoin执行左外连接的等效操作。要获取内连接——即排除没有购买的客户——您需要在custPurchases上进行过滤:
from c in customers join p in purchases on c.ID equals p.CustomerID
into custPurchases
where custPurchases.Any()
select ...
在组连接into之后的子句操作内部子元素的子序列,而不是单个子元素。这意味着要过滤单独的购买,你需要在连接之前调用Where:
from c in customers
join p in purchases.Where (p2 => p2.Price > 1000)
on c.ID equals p.CustomerID
into custPurchases ...
你可以像使用Join一样使用GroupJoin构建 lambda 查询。
平面外连接
如果你既想要外连接又想要一个平面结果集,你会遇到一个困境。GroupJoin给你外连接;Join给你平面结果集。解决方案是首先调用GroupJoin,然后对每个子序列调用DefaultIfEmpty,最后在结果上调用SelectMany:
from c in customers
join p in purchases on c.ID equals p.CustomerID into custPurchases
from cp in custPurchases.DefaultIfEmpty()
select new
{
CustName = c.Name,
Price = cp == null ? (decimal?) null : cp.Price
};
DefaultIfEmpty如果购买的子序列为空,则发出一个包含单个空值的序列。第二个from子句转换为SelectMany。在这个角色中,它展开和扁平化所有的购买子序列,将它们连接成一个购买元素的单一序列。
使用查找进行连接
Enumerable 中的Join和GroupJoin方法分两步工作。首先,它们将内部序列加载到一个查找表中。其次,它们查询外部序列与查找表的组合。
Lookup 是一系列可以直接通过键访问的分组。另一种思考方式是它像是一个序列的字典,一个可以在每个键下接受多个元素的字典(有时称为多字典)。Lookup 是只读的,并由以下接口定义:
public interface ILookup<TKey,TElement> :
IEnumerable<IGrouping<TKey,TElement>>, IEnumerable
{
int Count { get; }
bool Contains (TKey key);
IEnumerable<TElement> this [TKey key] { get; }
}
注意
连接运算符——如其他发射序列的运算符——遵循延迟或惰性执行语义。这意味着直到您开始枚举输出序列(然后整个查找表才会构建)时,查找表才会构建。
处理本地集合时,手动创建和查询查找表是使用连接运算符的替代策略之一。这样做有几个好处:
-
您可以在多个查询中重用相同的查找表,以及在普通命令式代码中。
-
查询查找表是理解
Join和GroupJoin工作的一个绝佳方法。
ToLookup 扩展方法创建一个查找表。以下加载所有购买记录到一个由它们的 CustomerID 键控的查找表中:
ILookup<int?,Purchase> purchLookup =
purchases.ToLookup (p => p.CustomerID, p => p);
第一个参数选择键;第二个参数选择要加载到查找表中的对象值。
读取查找表有点像读取字典,但是索引器返回的是匹配项的序列而不是单个匹配项。以下列出了顾客 ID 为 1 的所有购买记录:
foreach (Purchase p in purchLookup [1])
Console.WriteLine (p.Description);
有了查找表,您可以编写SelectMany/Select查询,其执行效率与Join/GroupJoin查询一样高。Join等同于在查找表上使用SelectMany:
from c in customers
from p in purchLookup [c.ID]
select new { c.Name, p.Description, p.Price };
Tom Bike 500
Tom Holiday 2000
Dick Bike 600
Dick Phone 300
...
添加对DefaultIfEmpty的调用可将其转换为外连接:
from c in customers
from p in purchLookup [c.ID].DefaultIfEmpty()
select new {
c.Name,
Descript = p == null ? null : p.Description,
Price = p == null ? (decimal?) null : p.Price
};
GroupJoin 相当于在投影中读取查找表:
from c in customers
select new {
CustName = c.Name,
CustPurchases = purchLookup [c.ID]
};
Enumerable 实现
这是Enumerable.Join的最简单有效实现,忽略了空检查:
public static IEnumerable <TResult> Join
<TOuter,TInner,TKey,TResult> (
this IEnumerable <TOuter> outer,
IEnumerable <TInner> inner,
Func <TOuter,TKey> outerKeySelector,
Func <TInner,TKey> innerKeySelector,
Func <TOuter,TInner,TResult> resultSelector)
{
ILookup <TKey, TInner> lookup = inner.ToLookup (innerKeySelector);
return
from outerItem in outer
from innerItem in lookup [outerKeySelector (outerItem)]
select resultSelector (outerItem, innerItem);
}
GroupJoin 的实现类似于 Join 但更简单:
public static IEnumerable <TResult> GroupJoin
<TOuter,TInner,TKey,TResult> (
this IEnumerable <TOuter> outer,
IEnumerable <TInner> inner,
Func <TOuter,TKey> outerKeySelector,
Func <TInner,TKey> innerKeySelector,
Func <TOuter,IEnumerable<TInner>,TResult> resultSelector)
{
ILookup <TKey, TInner> lookup = inner.ToLookup (innerKeySelector);
return
from outerItem in outer
select resultSelector
(outerItem, lookup [outerKeySelector (outerItem)]);
}
Zip 操作符
IEnumerable<TFirst>, IEnumerable<TSecond>→IEnumerable<TResult>
Zip 操作符按步骤(如拉链一样)枚举两个序列,并返回基于每对元素应用函数的序列。例如,以下操作:
int[] numbers = { 3, 5, 7 };
string[] words = { "three", "five", "seven", "ignored" };
IEnumerable<string> zip = numbers.Zip (words, (n, w) => n + "=" + w);
生成具有以下元素的序列:
*3=three*
*5=five*
*7=seven*
任一输入序列中的额外元素将被忽略。EF Core 不支持Zip。
排序
IEnumerable<TSource>→IOrderedEnumerable<TSource>
| 方法 | 描述 | SQL 等效项 |
|---|---|---|
OrderBy, ThenBy |
按升序排序序列 | ORDER BY ... |
OrderByDescending, ThenByDescending |
按降序排序序列 | ORDER BY ... DESC |
Reverse |
返回按照逆序排序的序列 | 抛出异常 |
排序运算符按不同顺序返回相同的元素。
OrderBy、OrderByDescending、ThenBy、ThenByDescending
OrderBy 和 OrderByDescending 参数
| 参数 | 类型 |
|---|---|
| 输入序列 | IEnumerable<TSource> |
| 键选择器 | TSource => TKey |
返回类型 = IOrderedEnumerable<TSource>
ThenBy 和 ThenByDescending 参数
| 参数 | 类型 |
|---|---|
| 输入序列 | IOrderedEnumerable<TSource> |
| 键选择器 | TSource => TKey |
查询语法
orderby *expression1* [*descending*] [, *expression2* [descending] *...* ]
概述
OrderBy返回输入序列的排序版本,使用keySelector表达式进行比较。以下查询以字母顺序输出名称序列:
IEnumerable<string> query = names.OrderBy (s => s);
以下代码按长度排序名称:
IEnumerable<string> query = names.OrderBy (s => s.Length);
// Result: { "Jay", "Tom", "Mary", "Dick", "Harry" };
具有相同排序键的元素的相对顺序(在本例中为 Jay/Tom 和 Mary/Dick)是不确定的——除非您附加ThenBy运算符:
IEnumerable<string> query = names.OrderBy (s => s.Length).ThenBy (s => s);
// Result: { "Jay", "Tom", "Dick", "Mary", "Harry" };
ThenBy只重新排序具有相同排序键的先前排序中的元素。您可以链接任意数量的ThenBy运算符。以下代码首先按长度排序,然后按第二个字符排序,最后按第一个字符排序:
names.OrderBy (s => s.Length).ThenBy (s => s[1]).ThenBy (s => s[0]);
下面是查询语法中的等效语句:
from s in names
orderby s.Length, s[1], s[0]
select s;
警告
下面的变体是不正确的——它实际上将首先按s[1]排序,然后按s.Length排序(或在数据库查询的情况下,仅按s[1]排序并丢弃前一个排序):
from s in names
orderby s.Length
orderby s[1]
...
LINQ 还提供了OrderByDescending和ThenByDescending运算符,它们执行相同的操作,但以相反的顺序输出结果。以下是 EF Core 查询的示例,按价格降序检索购买项目,并按价格相同的字母顺序列出:
dbContext.Purchases.OrderByDescending (p => p.Price)
.ThenBy (p => p.Description);
查询语法中:
from p in dbContext.Purchases
orderby p.Price descending, p.Description
select p;
比较器和排序规则
在本地查询中,键选择器对象本身通过其默认的IComparable实现来确定排序算法(参见第七章)。您可以通过传入一个IComparer对象来覆盖排序算法。以下是执行不区分大小写排序的示例:
names.OrderBy (n => n, StringComparer.CurrentCultureIgnoreCase);
在查询语法或 EF Core 中,不支持传入比较器的方式。在查询数据库时,比较算法由参与列的排序决定。如果排序是区分大小写的,则可以通过在键选择器中调用ToUpper来请求不区分大小写的排序:
from p in dbContext.Purchases
orderby p.Description.ToUpper()
select p;
IOrderedEnumerable 和 IOrderedQueryable
排序运算符返回IEnumerable<T>的特殊子类型。Enumerable中的返回IOrderedEnumerable<TSource>;Queryable中的返回IOrderedQueryable<TSource>。这些子类型允许后续的ThenBy运算符来细化而不是替换现有的排序。
这些子类型定义的附加成员不公开显示,因此它们看起来像普通序列。它们是不同类型的事实在逐步构建查询时发挥作用:
IOrderedEnumerable<string> query1 = names.OrderBy (s => s.Length);
IOrderedEnumerable<string> query2 = query1.ThenBy (s => s);
如果我们将query1声明为IEnumerable<string>类型,第二行将无法编译——ThenBy需要IOrderedEnumerable<string>类型的输入。您可以通过隐式类型化范围变量来避免担心此问题:
var query1 = names.OrderBy (s => s.Length);
var query2 = query1.ThenBy (s => s);
隐式类型化可能会带来自身的问题。以下内容将无法编译:
var query = names.OrderBy (s => s.Length);
query = query.Where (n => n.Length > 3); // Compile-time error
编译器根据 OrderBy 的输出序列类型推断 query 的类型为 IOrderedEnumerable<string>。然而,下一行上的 Where 返回一个普通的 IEnumerable<string>,它不能被赋回给 query。您可以通过显式类型或在 OrderBy 后调用 AsEnumerable() 来解决这个问题:
var query = names.OrderBy (s => s.Length).AsEnumerable();
query = query.Where (n => n.Length > 3); // OK
解释查询中的等效操作是调用 AsQueryable。
分组
| 方法 | 描述 | SQL 等效项 |
|---|---|---|
GroupBy |
将序列分组为子序列 | GROUP BY |
Chunk |
将序列分组为固定大小的数组 |
GroupBy
IEnumerable<TSource>→IEnumerable<IGrouping<TKey,TElement>>
| 参数 | 类型 |
|---|---|
| 输入序列 | IEnumerable<TSource> |
| 键选择器 | TSource => TKey |
| 元素选择器(可选) | TSource => TElement |
| 比较器(可选) | IEqualityComparer<TKey> |
查询语法
group *element-expression* by *key-expression*
概述
GroupBy 将一个扁平的输入序列组织成 组 的序列。例如,以下内容通过扩展名组织 Path.GetTempPath() 中的所有文件:
string[] files = Directory.GetFiles (Path.GetTempPath());
IEnumerable<IGrouping<string,string>> query =
files.GroupBy (file => Path.GetExtension (file));
或者,使用隐式类型转换:
var query = files.GroupBy (file => Path.GetExtension (file));
这是如何枚举结果的:
foreach (IGrouping<string,string> grouping in query)
{
Console.WriteLine ("Extension: " + grouping.Key);
foreach (string filename in grouping)
Console.WriteLine (" - " + filename);
}
*Extension: .pdf*
*-- chapter03.pdf*
*-- chapter04.pdf*
*Extension: .doc*
*-- todo.doc*
*-- menu.doc*
*-- Copy of menu.doc*
*...*
Enumerable.GroupBy 通过将输入元素读取到临时字典的列表中来工作,使得所有具有相同键的元素最终位于同一个子列表中。然后,它发出一个 分组 的序列。分组是一个带有 Key 属性的序列:
public interface IGrouping <TKey,TElement> : IEnumerable<TElement>,
IEnumerable
{
TKey Key { get; } // Key applies to the subsequence as a whole
}
默认情况下,每个分组中的元素未经转换,除非指定了 elementSelector 参数。以下内容将每个输入元素投影到大写:
files.GroupBy (file => Path.GetExtension (file), file => file.ToUpper());
elementSelector 独立于 keySelector。在我们的情况下,这意味着每个分组上的 Key 仍保持其原始大小写:
Extension: .pdf
-- CHAPTER03.PDF
-- CHAPTER04.PDF
Extension: .doc
-- TODO.DOC
请注意,子集合不会按键的字母顺序发出。 GroupBy 只是 分组,而不是 排序。实际上,它保留了原始顺序。要进行排序,必须添加 OrderBy 运算符:
files.GroupBy (file => Path.GetExtension (file), file => file.ToUpper())
.OrderBy (grouping => grouping.Key);
GroupBy 在查询语法中有一个简单明了的翻译:
group *element-expr* by *key-expr*
下面是我们的查询语法示例:
from file in files
group file.ToUpper() by Path.GetExtension (file);
与 select 一样,group "结束" 了查询 —— 除非您添加查询继续子句:
from file in files
group file.ToUpper() by Path.GetExtension (file) into grouping
orderby grouping.Key
select grouping;
查询继续子句在 group by 查询中经常很有用。下一个查询过滤掉少于五个文件的组:
from file in files
group file.ToUpper() by Path.GetExtension (file) into grouping
where grouping.Count() >= 5
select grouping;
注意
group by 后的 where 相当于 SQL 中的 HAVING。它适用于每个子序列或分组作为整体而不是个别元素。
有时,您只对分组上的聚合结果感兴趣,因此可以放弃子序列:
string[] votes = { "Dogs", "Cats", "Cats", "Dogs", "Dogs" };
IEnumerable<string> query = from vote in votes
group vote by vote into g
orderby g.Count() descending
select g.Key;
string winner = query.First(); // Dogs
EF Core 中的 GroupBy
当在数据库查询中使用分组时,分组的工作方式相同。如果设置了导航属性,您会发现,与标准 SQL 相比,需要分组的情况较少。例如,要选择至少有两次购买的客户,您不需要 group;以下查询可以很好地完成工作:
from c in dbContext.Customers
where c.Purchases.Count >= 2
select c.Name + " has made " + c.Purchases.Count + " purchases";
一个使用分组的示例是按年列出总销售额:
from p in dbContext.Purchases
group p.Price by p.Date.Year into salesByYear
select new {
Year = salesByYear.Key,
TotalValue = salesByYear.Sum()
};
LINQ 的分组比 SQL 的 GROUP BY 更强大,因为你可以获取所有细节行而不进行任何聚合:
from p in dbContext.Purchases
group p by p.Date.Year
Date.Year
然而,这在 EF Core 中不起作用。一个简单的解决方法是在分组之前调用 .AsEnumerable(),以便在客户端上进行分组。只要在分组之前执行任何过滤操作,从服务器获取你需要的数据就不会影响效率。
另一个不同于传统 SQL 的地方在于没有义务投射到用于分组或排序的变量或表达式中。
按多个键分组
你可以通过匿名类型来进行复合键分组:
from n in names
group n by new { FirstLetter = n[0], Length = n.Length };
自定义相等比较器
在本地查询中,你可以将自定义的相等比较器传递给 GroupBy,以更改键比较的算法。尽管如此,很少需要这样做,因为通常只需更改键选择器表达式就足够了。例如,以下代码创建一个不区分大小写的分组:
group n by n*.ToUpper()*
块
IEnumerable<TSource>→IEnumerable<TElement[]>
| 参数 | 类型 |
|---|---|
| 输入序列 | IEnumerable<TSource> |
size |
int |
在 .NET 6 中引入的 Chunk 方法将序列分组为指定大小的块(如果元素不足则可能更少):
foreach (int[] chunk in new[] { 1, 2, 3, 4, 5, 6, 7, 8 }.Chunk (3))
Console.WriteLine (string.Join (", ", chunk));
输出:
1, 2, 3
4, 5, 6
7, 8
集合运算符
IEnumerable<TSource>, IEnumerable<TSource>→IEnumerable<TSource>
| 方法 | 描述 | SQL 等效项 |
|---|---|---|
Concat |
返回两个序列中所有元素的串联 | UNION ALL |
Union, UnionBy |
返回两个序列中各元素的串联,排除重复项 | UNION |
Intersect, IntersectBy |
返回两个序列中都存在的元素 | WHERE ... IN (...) |
Except, ExceptBy |
返回第一个序列中存在但第二个序列中不存在的元素 | EXCEPT 或 WHERE ... NOT IN (...) |
Concat, Union, UnionBy
Concat 返回第一个序列的所有元素,然后是第二个序列的所有元素。Union 也是如此,但会移除任何重复项:
int[] seq1 = { 1, 2, 3 }, seq2 = { 3, 4, 5 };
IEnumerable<int>
concat = seq1.Concat (seq2), // { 1, 2, 3, 3, 4, 5 }
union = seq1.Union (seq2); // { 1, 2, 3, 4, 5 }
明确指定类型参数在序列类型不同但元素具有共同基类型时非常有用。例如,在反射 API (第十八章) 中,方法和属性分别由 MethodInfo 和 PropertyInfo 类表示,它们有一个称为 MemberInfo 的共同基类。我们可以在调用 Concat 时显式地说明这个基类来连接方法和属性:
MethodInfo[] methods = typeof (string).GetMethods();
PropertyInfo[] props = typeof (string).GetProperties();
IEnumerable<MemberInfo> both = methods.Concat<MemberInfo> (props);
在下一个示例中,我们在连接之前过滤了方法:
var methods = typeof (string).GetMethods().Where (m => !m.IsSpecialName);
var props = typeof (string).GetProperties();
var both = methods.Concat<MemberInfo> (props);
本示例依赖于接口类型参数的协变性:methods 是 IEnumerable<MethodInfo> 类型,需要进行协变转换为 IEnumerable<MemberInfo>。这是一个很好的示例,说明了协变如何使事情更像你期望的那样工作。
.NET 6 引入的 UnionBy 方法接受一个 keySelector,用于确定元素是否重复。在以下示例中,我们执行不区分大小写的联合操作:
string[] seq1 = { "A", "b", "C" };
string[] seq2 = { "a", "B", "c" };
var union = seq1.UnionBy (seq2, x => x.ToUpperInvariant());
// union is { "A", "b", "C" }
在这种情况下,如果我们提供相等比较器,可以使用 Union 来实现相同的效果:
var union = seq1.Union (seq2, StringComparer.InvariantCultureIgnoreCase);
Intersect、Intersect By、Except 和 ExceptBy
Intersect 返回两个序列中共同的元素。Except 返回第一个输入序列中不在第二个序列中的元素:
int[] seq1 = { 1, 2, 3 }, seq2 = { 3, 4, 5 };
IEnumerable<int>
commonality = seq1.Intersect (seq2), // { 3 }
difference1 = seq1.Except (seq2), // { 1, 2 }
difference2 = seq2.Except (seq1); // { 4, 5 }
Enumerable.Except 的内部工作方式是将第一个集合中的所有元素加载到字典中,然后从字典中删除第二个序列中存在的所有元素。在 SQL 中的等效操作是 NOT EXISTS 或 NOT IN 子查询:
SELECT number FROM numbers1Table
WHERE number NOT IN (SELECT number FROM numbers2Table)
.NET 6 中的 IntersectBy 和 ExceptBy 方法允许您指定键选择器,在执行相等比较之前应用(请参见前面部分关于 UnionBy 的讨论)。
转换方法
LINQ 主要处理序列;换句话说,类型为 IEnumerable<T> 的集合。转换方法用于与其他类型的集合进行转换:
| 方法 | 描述 |
|---|---|
OfType |
将 IEnumerable 转换为 IEnumerable<T>,丢弃错误类型的元素 |
Cast |
将 IEnumerable 转换为 IEnumerable<T>,如果有错误类型的元素则抛出异常 |
ToArray |
将 IEnumerable<T> 转换为 T[] |
ToList |
将 IEnumerable<T> 转换为 List<T> |
ToDictionary |
将 IEnumerable<T> 转换为 Dictionary<TKey, TValue> |
ToLookup |
将 IEnumerable<T> 转换为 ILookup<TKey, TElement> |
AsEnumerable |
向上转型为 IEnumerable<T> |
AsQueryable |
转换或转换为 IQueryable<T> |
OfType 和 Cast
OfType 和 Cast 接受一个非泛型的 IEnumerable 集合,并生成一个泛型的 IEnumerable<T> 序列,您可以随后进行查询:
ArrayList classicList = new ArrayList(); // in System.Collections
classicList.AddRange ( new int[] { 3, 4, 5 } );
IEnumerable<int> sequence1 = classicList.Cast<int>();
当遇到不兼容类型的输入元素时,Cast 和 OfType 的行为有所不同。Cast 抛出异常;OfType 忽略不兼容的元素。继续前面的示例:
DateTime offender = DateTime.Now;
classicList.Add (offender);
IEnumerable<int>
sequence2 = classicList.OfType<int>(), // OK - ignores offending DateTime
sequence3 = classicList.Cast<int>(); // Throws exception
元素兼容性的规则完全遵循 C# 的 is 操作符的规则,因此仅考虑引用转换和拆箱转换。通过检查 OfType 的内部实现,我们可以看到这一点:
public static IEnumerable<TSource> OfType <TSource> (IEnumerable source)
{
foreach (object element in source)
if (element is TSource)
yield return (TSource)element;
}
Cast 有一个相同的实现,只是它省略了类型兼容性测试:
public static IEnumerable<TSource> Cast <TSource> (IEnumerable source)
{
foreach (object element in source)
yield return (TSource)element;
}
这些实现的结果是,您不能使用 Cast 进行数值或自定义转换(对于这些情况,您必须执行 Select 操作)。换句话说,Cast 不像 C# 的转型操作符那样灵活:
int i = 3;
long l = i; // Implicit *numeric conversion* int->long
int i2 = (int) l; // Explicit *numeric conversion* long->int
我们可以通过尝试使用 OfType 或 Cast 将 int 序列转换为 long 序列来演示这一点:
int[] integers = { 1, 2, 3 };
IEnumerable<long> test1 = integers.OfType<long>();
IEnumerable<long> test2 = integers.Cast<long>();
在枚举时,test1 发出零个元素,而 test2 抛出异常。检查 OfType 的实现后,这一点就变得非常清楚。在替换 TSource 后,我们得到以下表达式:
(element is long)
对于 int 元素,由于缺少继承关系,返回 false。
注意
test2 在枚举时抛出异常的原因更加微妙。请注意在 Cast 的实现中,element 的类型是 object。当 TSource 是值类型时,CLR 假定这是一种 拆箱转换,并合成一种方法来重现本节 “装箱和拆箱” 中描述的情景:
int value = 123;
object element = value;
long result = (long) element; // exception
因为 element 变量声明为 object 类型,所以执行了 object 到 long 的转换(拆箱),而不是 int 到 long 的数值转换。拆箱操作需要精确的类型匹配,因此在给定 int 时,object 到 long 的拆箱操作将失败。
正如我们之前建议的,解决方案是使用普通的 Select:
IEnumerable<long> castLong = integers.Select (s => (long) s);
OfType 和 Cast 在将泛型输入序列中的元素向下转换方面也很有用。例如,如果您有一个类型为 IEnumerable<Fruit> 的输入序列,OfType<Apple> 将仅返回苹果。这在 LINQ to XML 中特别有用(请参阅 第十章)。
Cast 支持查询语法:只需在范围变量前加上类型:
from TreeNode node in myTreeView.Nodes
...
ToArray, ToList, ToDictionary, ToHashSet, ToLookup
ToArray, ToList, 和 ToHashSet 将结果转换为数组,List<T> 或 HashSet<T>。执行时,这些操作符强制立即枚举输入序列。有关示例,请参阅 “延迟执行”。
ToDictionary 和 ToLookup 接受以下参数:
| 参数 | 类型 |
|---|---|
| 输入序列 | IEnumerable<TSource> |
| 键选择器 | TSource => TKey |
| 元素选择器(可选) | TSource => TElement |
| 比较器(可选) | IEqualityComparer<TKey> |
ToDictionary 也强制立即执行序列,并将结果写入通用的 Dictionary。您提供的 keySelector 表达式必须对输入序列中的每个元素评估为唯一值;否则,将抛出异常。相比之下,ToLookup 允许多个具有相同键的元素。我们在 “使用查找进行连接” 中描述了查找。
AsEnumerable 和 AsQueryable
AsEnumerable 将序列向上转换为 IEnumerable<T>,强制编译器将后续查询操作绑定到 Enumerable 中的方法,而不是 Queryable。有例子,请参见 “组合解释和本地查询”。
AsQueryable 如果实现了接口 IQueryable<T>,则将序列向下转换为 IQueryable<T>。否则,它会在本地查询上实例化一个 IQueryable<T> 包装器。
元素操作符
IEnumerable<TSource>→ TSource
| 方法 | 描述 | SQL 等效项 |
|---|---|---|
First, FirstOrDefault |
返回序列中的第一个元素,可选择满足谓词 | SELECT TOP 1 ... ORDER BY ... |
Last, LastOrDefault |
返回序列中的最后一个元素,可选择满足谓词 | SELECT TOP 1 ... ORDER BY ... DESC |
Single, SingleOrDefault |
等同于 First/FirstOrDefault,但如果有多个匹配则抛出异常 |
|
ElementAt, ElementAtOrDefault |
返回指定位置的元素 | 抛出异常 |
MinBy, MaxBy |
返回具有最小或最大值的元素 | 抛出异常 |
DefaultIfEmpty |
如果序列为空,则返回一个包含单个元素的序列,其值为 default(TSource) |
OUTER JOIN |
以 “OrDefault” 结尾的方法在输入序列为空或没有元素与提供的谓词匹配时返回 default(TSource),而不是抛出异常。
对于引用类型元素,default(TSource) 是 null,对于 bool 类型是 false,对于数值类型是零。
First、Last 和 Single
| 参数 | 类型 |
|---|---|
| 源序列 | IEnumerable<TSource> |
| 谓词(可选) | TSource => bool |
以下示例演示了 First 和 Last:
int[] numbers = { 1, 2, 3, 4, 5 };
int first = numbers.First(); // 1
int last = numbers.Last(); // 5
int firstEven = numbers.First (n => n % 2 == 0); // 2
int lastEven = numbers.Last (n => n % 2 == 0); // 4
下面演示了 First 与 FirstOrDefault 的区别:
int firstBigError = numbers.First (n => n > 10); // Exception
int firstBigNumber = numbers.FirstOrDefault (n => n > 10); // 0
为了避免异常,Single 需要恰好一个匹配元素;SingleOrDefault 需要一个 或零 个匹配元素:
int onlyDivBy3 = numbers.Single (n => n % 3 == 0); // 3
int divBy2Err = numbers.Single (n => n % 2 == 0); // Error: 2 & 4 match
int singleError = numbers.Single (n => n > 10); // Error
int noMatches = numbers.SingleOrDefault (n => n > 10); // 0
int divBy2Error = numbers.SingleOrDefault (n => n % 2 == 0); // Error
Single 在这些元素操作符中是“最挑剔”的。FirstOrDefault 和 LastOrDefault 则最为宽容。
在 EF Core 中,Single 经常用于通过主键从表中检索行:
Customer cust = dataContext.Customers.Single (c => c.ID == 3);
ElementAt
| 参数 | 类型 |
|---|---|
| 源序列 | IEnumerable<TSource> |
| 要返回的元素的索引 | int |
ElementAt 从序列中选取第 n 个元素:
int[] numbers = { 1, 2, 3, 4, 5 };
int third = numbers.ElementAt (2); // 3
int tenthError = numbers.ElementAt (9); // Exception
int tenth = numbers.ElementAtOrDefault (9); // 0
Enumerable.ElementAt 的实现如此,如果输入序列恰好实现了 IList<T>,则调用 IList<T> 的索引器。否则,它枚举 n 次然后返回下一个元素。在 EF Core 中不支持 ElementAt。
MinBy 和 MaxBy
MinBy 和 MaxBy(在 .NET 6 中引入)返回具有最小或最大值的元素,由 keySelector 决定:
string[] names = { "Tom", "Dick", "Harry", "Mary", "Jay" };
Console.WriteLine (names.MaxBy (n => n.Length)); // Harry
相比之下,Min 和 Max(我们将在下一节中介绍)返回的是最小或最大的值本身:
Console.WriteLine (names.Max (n => n.Length)); // 5
如果两个或更多元素具有最小/最大值,则 MinBy/MaxBy 返回第一个:
Console.WriteLine (names.MinBy (n => n.Length)); // Tom
如果输入序列为空,则如果元素类型可空,MinBy 和 MaxBy 返回 null(或者如果元素类型不可空则抛出异常)。
DefaultIfEmpty
DefaultIfEmpty 返回一个包含单个元素的序列,其值为 default(TSource),如果输入序列没有元素;否则,返回不变的输入序列。在编写平面外连接时使用它:参见 “使用 SelectMany 进行外连接” 和 “平面外连接”。
聚合方法
IEnumerable<TSource>→*scalar*
| 方法 | 描述 | 对应的 SQL |
|---|---|---|
Count, LongCount |
返回输入序列中元素的数目,可选地满足谓词条件 | COUNT (...) |
Min, Max |
返回序列中的最小或最大元素 | MIN (...), MAX (...) |
Sum, Average |
计算序列中元素的数字总和或平均值 | SUM (...), AVG (...) |
Aggregate |
执行自定义聚合 | 异常抛出 |
计数和 LongCount
| 参数 | 类型 |
|---|---|
| 源序列 | IEnumerable<TSource> |
| 谓词(可选) | TSource => bool |
Count 简单地枚举序列,返回项目数:
int fullCount = new int[] { 5, 6, 7 }.Count(); // 3
Enumerable.Count 的内部实现测试输入序列是否实现了 ICollection<T>。如果是,它只需调用 ICollection<T>.Count;否则,它会枚举每个项,递增计数器。
可以选择性地提供谓词:
int digitCount = "pa55w0rd".Count (c => char.IsDigit (c)); // 3
LongCount 执行与 Count 相同的工作,但返回一个 64 位整数,允许超过 20 亿元素的序列。
最小值和最大值
| 参数 | 类型 |
|---|---|
| 源序列 | IEnumerable<TSource> |
| 结果选择器(可选) | TSource => TResult |
Min 和 Max 返回序列中的最小或最大元素:
int[] numbers = { 28, 32, 14 };
int smallest = numbers.Min(); // 14;
int largest = numbers.Max(); // 32;
如果包含 selector 表达式,每个元素首先被投影:
int smallest = numbers.Max (n => n % 10); // 8;
如果项目本身不是本质上可比较的(即它们没有实现 IComparable<T>),则 selector 表达式是强制性的:
Purchase runtimeError = dbContext.Purchases.Min (); // Error
decimal? lowestPrice = dbContext.Purchases.Min (p => p.Price); // OK
selector 表达式不仅决定了如何比较元素,还决定了最终结果。在上面的例子中,最终结果是一个十进制值,而不是购买对象。要获取最便宜的购买,需要使用子查询:
Purchase cheapest = dbContext.Purchases
.Where (p => p.Price == dbContext.Purchases.Min (p2 => p2.Price))
.FirstOrDefault();
在这种情况下,您还可以通过使用 OrderBy 后跟 FirstOrDefault 而不使用聚合来构建查询。
总和与平均值
| 参数 | 类型 |
|---|---|
| 源序列 | IEnumerable<TSource> |
| 结果选择器(可选) | TSource => TResult |
Sum 和 Average 是聚合运算符,类似于 Min 和 Max 的使用方式:
decimal[] numbers = { 3, 4, 8 };
decimal sumTotal = numbers.Sum(); // 15
decimal average = numbers.Average(); // 5 (mean value)
以下返回 names 数组中每个字符串的总长度:
int combinedLength = names.Sum (s => s.Length); // 19
Sum 和 Average 在其类型上比较严格。它们的定义硬编码到每种数值类型(int、long、float、double、decimal 及其可空版本)。相比之下,Min 和 Max 可以直接操作任何实现了 IComparable<T> 的东西,比如 string。
此外,Average 总是根据以下表格返回 decimal、float 或 double:
| 选择器类型 | 结果类型 |
|---|---|
decimal |
decimal |
float |
float |
int, long, double |
double |
这意味着以下内容无法编译(“无法将 double 转换为 int”):
int avg = new int[] { 3, 4 }.Average();
但以下内容将会编译:
double avg = new int[] { 3, 4 }.Average(); // 3.5
Average 隐式地将输入值升级,以防止精度丢失。在这个例子中,我们对整数取平均值,得到了 3.5,而无需将输入元素强制转换为浮点数:
double avg = numbers.Average (n => (double) n);
在查询数据库时,Sum 和 Average 转换为标准的 SQL 聚合。以下查询返回平均购买超过 500 美元的客户:
from c in dbContext.Customers
where c.Purchases.Average (p => p.Price) > 500
select c.Name;
聚合
Aggregate 允许您指定用于实现不寻常聚合的自定义累积算法。Aggregate 在 EF Core 中不受支持,并且在其使用案例中有些特殊化。以下演示了如何使用 Aggregate 完成 Sum 的工作:
int[] numbers = { 1, 2, 3 };
int sum = numbers.Aggregate (0, (total, n) => total + n); // 6
Aggregate 的第一个参数是种子,从中开始累积。第二个参数是一个表达式,用于在给定新元素时更新累积值。您还可以选择提供第三个参数,以从累积值中投影出最终结果值。
注意
大多数 Aggregate 设计的问题都可以通过 foreach 循环轻松解决,并且具有更熟悉的语法。使用 Aggregate 的优势在于,对于大型或复杂的聚合操作,您可以通过 PLINQ 自动并行化操作(见第二十二章)。
未种子化的聚合
调用 Aggregate 时可以省略种子值,在这种情况下,第一个元素成为隐式种子,并且聚合从第二个元素开始。这里是前面的例子,未种子化:
int[] numbers = { 1, 2, 3 };
int sum = numbers.Aggregate ((total, n) => total + n); // 6
这给出了与之前相同的结果,但实际上我们正在进行不同的计算。之前,我们计算的是 0 + 1 + 2 + 3;现在我们计算的是 1 + 2 + 3。我们可以通过乘法而不是加法更好地说明这种差异:
int[] numbers = { 1, 2, 3 };
int x = numbers.Aggregate (0, (prod, n) => prod * n); // 0*1*2*3 = 0
int y = numbers.Aggregate ( (prod, n) => prod * n); // 1*2*3 = 6
正如您将在第二十二章中看到的那样,未种子化的聚合具有可以并行化的优势,而不需要使用特殊重载。但是,未种子化的聚合存在一些陷阱。
未种子化的聚合陷阱
未种子化的聚合方法适用于使用可交换和可结合的委托。如果用于其他用途,则结果要么是不直观的(对于普通查询),要么是非确定性的(在您使用 PLINQ 并行化查询的情况下)。例如,考虑以下函数:
(total, n) => total + n * n
这既不是可交换的也不是可结合的。(例如,1 + 2 * 2 != 2 + 1 * 1。)让我们看看当我们用它来计算 2、3 和 4 的平方数之和时会发生什么:
int[] numbers = { 2, 3, 4 };
int sum = numbers.Aggregate ((total, n) => total + n * n); // 27
而不是计算
2*2 + 3*3 + 4*4 // 29
它计算:
2 + 3*3 + 4*4 // 27
我们可以通过多种方式解决这个问题。首先,我们可以将 0 包含为第一个元素:
int[] numbers = { 0, 2, 3, 4 };
这不仅不优雅,而且如果并行化的话仍会给出不正确的结果——因为 PLINQ 使用函数的假定结合性通过选择多个元素作为种子。为了说明,如果我们将我们的聚合函数表示如下:
f(total, n) => total + n * n
LINQ 对象将计算此结果:
f(f(f(0, 2),3),4)
而 PLINQ 可能会执行:
f(f(0,2),f(3,4))
得到以下结果:
First partition: a = 0 + 2*2 (= 4)
Second partition: b = 3 + 4*4 (= 19)
Final result: a + b*b (= 365)
OR EVEN: b + a*a (= 35)
有两个好的解决方案。第一个是将其转换为以 0 作为种子的种子聚合。唯一的复杂性在于,使用 PLINQ 时,我们需要使用一个特殊的重载,以使查询不按顺序执行(见 “优化 PLINQ”)。
第二个解决方案是重新构造查询,使聚合函数成为可交换和可结合的:
int sum = numbers.Select (n => n * n).Aggregate ((total, n) => total + n);
注意
当然,在这种简单场景下,您可以(并且应该)使用Sum操作符,而不是Aggregate:
int sum = numbers.Sum (n => n * n);
您实际上可以通过Sum和Average做得相当远。例如,您可以使用Average计算均方根:
Math.Sqrt (numbers.Average (n => n * n))
您甚至可以计算标准差:
double mean = numbers.Average();
double sdev = Math.Sqrt (numbers.Average (n =>
{
double dif = n - mean;
return dif * dif;
}));
两者都是安全的、高效的,并且完全可并行化。在第二十二章,我们给出了一个无法简化为Sum或Average的自定义聚合的实际示例。
量词
IEnumerable<TSource>→*bool*
| 方法 | 描述 | SQL 等效项 |
|---|---|---|
Contains |
如果输入序列包含给定元素,则返回true |
WHERE ... IN (...) |
Any |
如果任何元素满足给定谓词,则返回true |
WHERE ... IN (...) |
All |
如果所有元素都满足给定谓词,则返回true |
WHERE (...) |
SequenceEqual |
如果第二个序列与输入序列具有相同的元素,则返回true |
包含和 Any
Contains方法接受一个类型为TSource的参数;Any接受一个可选的谓词。
Contains 如果给定元素存在,则返回true:
bool hasAThree = new int[] { 2, 3, 4 }.Contains (3); // true;
Any 如果至少有一个元素满足给定表达式,则返回true。我们可以用Any重写前面的查询如下:
bool hasAThree = new int[] { 2, 3, 4 }.Any (n => n == 3); // true;
Any 可以执行Contains的所有操作,甚至更多:
bool hasABigNumber = new int[] { 2, 3, 4 }.Any (n => n > 10); // false;
调用不带谓词的Any,如果序列有一个或多个元素,则返回true。以下是重写前面查询的另一种方式:
bool hasABigNumber = new int[] { 2, 3, 4 }.Where (n => n > 10).Any();
Any 在子查询中特别有用,并且在查询数据库时经常使用;例如:
from c in dbContext.Customers
where c.Purchases.Any (p => p.Price > 1000)
select c
All 和 SequenceEqual
All 如果所有元素都满足谓词,则返回true。以下查询返回购买金额少于$100 的客户:
dbContext.Customers.Where (c => c.Purchases.All (p => p.Price < 100));
SequenceEqual 比较两个序列。要返回true,每个序列必须具有相同的元素,且顺序相同。您可以选择性地提供相等比较器;默认为EqualityComparer<T>.Default。
生成方法
void→IEnumerable<TResult>
| 方法 | 描述 |
|---|---|
Empty |
创建一个空序列 |
Repeat |
创建一个重复元素的序列 |
Range |
创建一个整数序列 |
Empty、Repeat和Range是静态(非扩展)方法,用于制造简单的本地序列。
Empty
Empty 制造一个空序列,并且只需一个类型参数:
foreach (string s in Enumerable.Empty<string>())
Console.Write (s); // <nothing>
与??操作符结合使用,Empty执行DefaultIfEmpty的反向操作。例如,假设我们有一个整数的嵌套数组,并且我们想将所有整数放入单个平面列表中。如果任何内部数组为 null,则以下SelectMany查询将失败:
int[][] numbers =
{
new int[] { 1, 2, 3 },
new int[] { 4, 5, 6 },
null // this null makes the query below fail.
};
IEnumerable<int> flat = numbers.SelectMany (innerArray => innerArray);
Empty 与??结合修复问题:
IEnumerable<int> flat = numbers
.SelectMany (innerArray => innerArray ?? Enumerable.Empty <int>());
foreach (int i in flat)
Console.Write (i + " "); // 1 2 3 4 5 6
范围和重复
Range 接受起始索引和计数(都是整数):
foreach (int i in Enumerable.Range (5, 3))
Console.Write (i + " "); // 5 6 7
Repeat 接受一个要重复的元素和重复的次数:
foreach (bool x in Enumerable.Repeat (true, 3))
Console.Write (x + " "); // True True True
第十章:LINQ to XML
.NET 提供了多个用于处理 XML 数据的 API。用于通用 XML 文档处理的主要选择是 LINQ to XML。LINQ to XML 包括一个轻量级、与 LINQ 兼容的 XML 文档对象模型(DOM),以及一组补充查询运算符。
在本章中,我们完全专注于 LINQ to XML。在 第十一章 中,我们涵盖了仅向前 XML 读取器/写入器,而在 在线补充 中,我们涵盖了用于处理模式和样式表的类型。.NET 还包括基于传统 XmlDocument 的 DOM,但我们不予讨论。
注意
LINQ to XML DOM 设计非常精良且性能非常高。即使没有 LINQ,LINQ to XML DOM 作为低级 XmlReader 和 XmlWriter 类的轻量级外观也是非常有价值的。
所有 LINQ to XML 类型都定义在 System.Xml.Linq 命名空间中。
架构概述
本节从对 DOM 概念的非常简要介绍开始,然后解释了 LINQ to XML 的 DOM 背后的基本原理。
什么是 DOM?
考虑以下 XML 文件:
<?xml version="1.0" encoding="utf-8"?>
<customer id="123" status="archived">
<firstname>Joe</firstname>
<lastname>Bloggs</lastname>
</customer>
与所有 XML 文件一样,我们从一个 声明 开始,然后是一个名为 customer 的根 元素。customer 元素具有两个 属性,每个属性都有一个名称(id 和 status)和一个值("123" 和 "archived")。在 customer 内部,有两个子元素,firstname 和 lastname,每个都有简单的文本内容("Joe" 和 "Bloggs")。
这些构造——声明、元素、属性、值和文本内容——都可以用类表示。如果这些类具有用于存储子内容的集合属性,我们可以组装一个对象树来完整描述文档。这称为 文档对象模型,或 DOM。
LINQ to XML DOM
LINQ to XML 包括两个主要内容:
-
XML DOM,我们称之为 X-DOM
-
大约有 10 个补充查询运算符的集合
正如您可能期望的那样,X-DOM 包括诸如 XDocument、XElement 和 XAttribute 之类的类型。有趣的是,X-DOM 类型并不依赖于 LINQ——您可以加载、实例化、更新和保存 X-DOM,而无需编写 LINQ 查询。
相反,您可以使用 LINQ 查询旧的符合 W3C 标准的 DOM。然而,这将是令人沮丧和受限制的。X-DOM 的显著特点是它对 LINQ 友好,这意味着:
-
它具有生成有用的
IEnumerable序列的方法,您可以对其进行查询。 -
它的构造函数设计得使您能够通过 LINQ 投影构建 X-DOM 树。
X-DOM 概述
图 10-1 显示了核心 X-DOM 类型。其中最常用的是 XElement 类型。XObject 是继承层次结构的根;XElement 和 XDocument 是容器层次结构的根。

图 10-1. 核心 X-DOM 类型
图 10-2 显示了从以下代码创建的 X-DOM 树:
string xml = @"<customer id='123' status='archived'>
<firstname>Joe</firstname>
<lastname>Bloggs<!--nice name--></lastname>
</customer>";
XElement customer = XElement.Parse (xml);

图 10-2. 一个简单的 X-DOM 树
XObject 是所有 XML 内容的抽象基类。它定义了与容器树中的 Parent 元素的链接以及一个可选的 XDocument。
XNode 是大多数 XML 内容的基类,不包括属性。XNode 的显著特点是它可以位于混合类型 XNode 的有序集合中。例如,考虑以下 XML:
<data>
Hello world
<subelement1/>
<!--comment-->
<subelement2/>
</data>
在父元素 <data> 中,首先是一个 XText 节点(Hello world),然后是一个 XElement 节点,接着是一个 XComment 节点,然后是第二个 XElement 节点。相比之下,XAttribute 仅容忍其他 XAttribute 作为对等体。
虽然 XNode 可以访问其父 XElement,但它没有 子 节点的概念:这是其子类 XContainer 的工作。XContainer 定义了处理子节点的成员,并且是 XElement 和 XDocument 的抽象基类。
XElement 引入了用于管理属性的成员——以及 Name 和 Value。在元素只有一个 XText 子节点的情况下(这是相当常见的情况),XElement 上的 Value 属性封装了此子节点的内容,用于获取和设置操作,减少了不必要的导航。由于 Value,您大多数情况下可以避免直接使用 XText 节点。
XDocument 表示 XML 树的根。更确切地说,它 包装 了根 XElement,添加了 XDeclaration、处理指令和其他根级别的“fluff”。与 W3C DOM 不同的是,它的使用是可选的:您可以加载、操作和保存 X-DOM,而无需创建 XDocument!不依赖 XDocument 也意味着您可以高效且轻松地将节点子树移动到另一个 X-DOM 层次结构中。
加载和解析
XElement 和 XDocument 都提供静态的 Load 和 Parse 方法,从现有源构建 X-DOM 树:
-
Load从文件、URI、Stream、TextReader或XmlReader构建 X-DOM。 -
Parse从字符串构建 X-DOM。
例如:
XDocument fromWeb = XDocument.Load ("http://albahari.com/sample.xml");
XElement fromFile = XElement.Load (@"e:\media\somefile.xml");
XElement config = XElement.Parse (
@"<configuration>
<client enabled='true'>
<timeout>30</timeout>
</client>
</configuration>");
在后面的章节中,我们将描述如何遍历和更新 X-DOM。作为快速预览,这是如何操作我们刚刚填充的 config 元素的方法:
foreach (XElement child in config.Elements())
Console.WriteLine (child.Name); // client
XElement client = config.Element ("client");
bool enabled = (bool) client.Attribute ("enabled"); // Read attribute
Console.WriteLine (enabled); // True
client.Attribute ("enabled").SetValue (!enabled); // Update attribute
int timeout = (int) client.Element ("timeout"); // Read element
Console.WriteLine (timeout); // 30
client.Element ("timeout").SetValue (timeout * 2); // Update element
client.Add (new XElement ("retries", 3)); // Add new element
Console.WriteLine (config); // Implicitly call config.ToString()
这是最后一个 Console.WriteLine 的结果:
<configuration>
<client enabled="false">
<timeout>60</timeout>
<retries>3</retries>
</client>
</configuration>
注意
XNode 还提供了一个静态的 ReadFrom 方法,从 XmlReader 实例化和填充任何类型的节点。与 Load 不同,它在读取一个(完整的)节点后停止,因此您可以继续手动从 XmlReader 中读取。
您还可以反向使用 XmlReader 或 XmlWriter 通过其 CreateReader 和 CreateWriter 方法读取或写入 XNode。
我们描述了 XML 读取器和写入器以及如何在 第十一章 中与 X-DOM 一起使用它们。
保存和序列化
在任何节点上调用ToString将其内容转换为 XML 字符串——格式化为我们刚刚看到的带有换行和缩进的形式。(在调用ToString时,可以通过指定SaveOptions.DisableFormatting来禁用换行和缩进。)
XElement和XDocument还提供了一个Save方法,用于将 X-DOM 写入文件、Stream、TextWriter或XmlWriter。如果指定了一个文件,将自动写入 XML 声明。XNode类中还定义了一个WriteTo方法,只接受一个XmlWriter。
我们将在“文档和声明”中更详细地描述保存时处理 XML 声明的方法。
实例化 X-DOM
不要使用Load或Parse方法,可以通过手动实例化对象并通过XContainer的Add方法将它们添加到父对象来构建 X-DOM 树。
要构造XElement和XAttribute,只需提供一个名称和值:
XElement lastName = new XElement ("lastname", "Bloggs");
lastName.Add (new XComment ("nice name"));
XElement customer = new XElement ("customer");
customer.Add (new XAttribute ("id", 123));
customer.Add (new XElement ("firstname", "Joe"));
customer.Add (lastName);
Console.WriteLine (customer.ToString());
这是结果:
<customer id="123">
<firstname>Joe</firstname>
<lastname>Bloggs<!--nice name--></lastname>
</customer>
在构造XElement时,值是可选的——您可以只提供元素名称,稍后再添加内容。请注意,当我们提供值时,一个简单的字符串就足够了——我们不需要显式创建和添加XText子节点。X-DOM 会自动完成这项工作,因此您可以简单地处理“值”。
函数式构造
在我们之前的示例中,很难从代码中获取 XML 结构。X-DOM 支持另一种实例化模式,称为函数式构造(来自函数式编程)。使用函数式构造,您可以在单个表达式中构建整个树:
XElement customer =
new XElement ("customer", new XAttribute ("id", 123),
new XElement ("firstname", "joe"),
new XElement ("lastname", "bloggs",
new XComment ("nice name")
)
);
这有两个好处。首先,代码类似于 XML 的结构。其次,它可以并入 LINQ 查询的select子句中。例如,以下查询将从 EF Core 实体类投影到 X-DOM 中:
XElement query =
new XElement ("customers",
from c in dbContext.Customers.AsEnumerable()
select
new XElement ("customer", new XAttribute ("id", c.ID),
new XElement ("firstname", c.FirstName),
new XElement ("lastname", c.LastName,
new XComment ("nice name")
)
)
);
我们将在本章后面更深入地讨论这一点,见“投影到 X-DOM 中”。
指定内容
函数式构造是可能的,因为XElement(和XDocument)的构造函数被重载以接受一个params对象数组:
public XElement (XName name, params object[] content)
对于XContainer中的Add方法也是如此:
public void Add (params object[] content)
因此,在构建或附加 X-DOM 时,您可以指定任意数量的任何类型的子对象。这是因为任何东西都被视为合法内容。要了解具体情况,我们需要检查每个内容对象在内部是如何处理的。以下是XContainer做出的决定:
-
如果对象为
null,则会被忽略。 -
如果对象基于
XNode或XStreamingElement,它将按原样添加到Nodes集合中。 -
如果对象是
XAttribute,它将被添加到Attributes集合中。 -
如果对象是一个
string,它将被包装在一个XText节点中并添加到Nodes中。¹ -
如果对象实现了
IEnumerable,则会被枚举,并且相同的规则将应用于每个元素。 -
否则,对象将被转换为字符串,包装在
XText节点中,然后添加到Nodes中。²
一切最终都会进入两个桶之一:Nodes或Attributes。此外,任何对象都是有效内容,因为它始终可以最终调用ToString并将其视为XText节点。
注
在对任意类型调用ToString之前,XContainer首先测试它是否是以下类型之一:
float, double, decimal, bool,
DateTime, DateTimeOffset, TimeSpan
如果是这样,它将在XmlConvert助手类上调用适当类型化的ToString方法,而不是在对象本身上调用ToString。这确保数据是可往返的,并符合标准 XML 格式化规则。
自动深度克隆
当通过功能性构建或Add方法向元素添加节点或属性时,该节点或属性的Parent属性将设置为该元素。节点只能有一个父元素:如果将已有父节点的节点添加到第二个父节点中,则节点将自动进行深度克隆。在以下示例中,每个客户都有一个单独的address副本:
var address = new XElement ("address",
new XElement ("street", "Lawley St"),
new XElement ("town", "North Beach")
);
var customer1 = new XElement ("customer1", address);
var customer2 = new XElement ("customer2", address);
customer1.Element ("address").Element ("street").Value = "Another St";
Console.WriteLine (
customer2.Element ("address").Element ("street").Value); // Lawley St
此自动复制保持 X-DOM 对象实例化不受副作用的影响——这是函数式编程的又一标志。
导航和查询
正如您所预期的那样,XNode和XContainer类定义了遍历 X-DOM 树的方法和属性。但与传统的 DOM 不同,这些函数不会返回实现IList<T>的集合。相反,它们返回单个值或实现IEnumerable<T>的序列——您随后期望使用 LINQ 查询(或使用foreach枚举)。这不仅允许进行高级查询,还可以执行简单的导航任务——使用熟悉的 LINQ 查询语法。
注
X-DOM 中的元素和属性名称区分大小写,就像 XML 中一样。
子节点导航
| 返回类型 | 成员 | 作用于 |
|---|---|---|
XNode |
FirstNode { get; } |
XContainer |
LastNode { get; } |
XContainer |
|
IEnumerable<XNode> |
Nodes() |
XContainer* |
DescendantNodes() |
XContainer* |
|
DescendantNodesAndSelf() |
XElement* |
|
XElement |
Element (XName) |
XContainer |
IEnumerable<XElement> |
Elements() |
XContainer* |
Elements (XName) |
XContainer* |
|
Descendants() |
XContainer* |
|
Descendants (XName) |
XContainer* |
|
DescendantsAndSelf() |
XElement* |
|
DescendantsAndSelf (XName) |
XElement* |
|
bool |
HasElements { get; } |
XElement |
注
此表及其他表中第三列标有星号的功能也适用于同一类型的序列。例如,您可以在XContainer或XContainer对象序列上调用Nodes。这是因为System.Xml.Linq中定义的扩展方法——我们在概述中讨论的补充查询运算符使这成为可能。
FirstNode、LastNode 和 Nodes
FirstNode和LastNode为您提供对第一个或最后一个子节点的直接访问;Nodes将所有子节点作为序列返回。这三个函数仅考虑直接子代:
var bench = new XElement ("bench",
new XElement ("toolbox",
new XElement ("handtool", "Hammer"),
new XElement ("handtool", "Rasp")
),
new XElement ("toolbox",
new XElement ("handtool", "Saw"),
new XElement ("powertool", "Nailgun")
),
new XComment ("Be careful with the nailgun")
);
foreach (XNode node in bench.Nodes())
Console.WriteLine (node.ToString (SaveOptions.DisableFormatting) + ".");
这是输出:
<toolbox><handtool>Hammer</handtool><handtool>Rasp</handtool></toolbox>.
<toolbox><handtool>Saw</handtool><powertool>Nailgun</powertool></toolbox>.
<!--Be careful with the nailgun-->.
检索元素
Elements 方法仅返回类型为 XElement 的子节点:
foreach (XElement e in bench.Elements())
Console.WriteLine (e.Name + "=" + e.Value); // toolbox=HammerRasp
// toolbox=SawNailgun
以下 LINQ 查询找到带有钉枪的工具箱:
IEnumerable<string> query =
from toolbox in bench.Elements()
where toolbox.Elements().Any (tool => tool.Value == "Nailgun")
select toolbox.Value;
RESULT: { "SawNailgun" }
下一个示例使用 SelectMany 查询检索所有工具箱中的手工工具:
IEnumerable<string> query =
from toolbox in bench.Elements()
from tool in toolbox.Elements()
where tool.Name == "handtool"
select tool.Value;
RESULT: { "Hammer", "Rasp", "Saw" }
注意
Elements 本身相当于对 Nodes 的 LINQ 查询。我们之前的查询可以如下开始:
from toolbox in bench.Nodes().OfType<XElement>()
where ...
Elements 也可以返回给定名称的元素:
int x = bench.Elements ("toolbox").Count(); // 2
这相当于以下内容:
int x = bench.Elements().Where (e => e.Name == "toolbox").Count(); // 2
Elements 也被定义为接受 IEnumerable<XContainer> 或更精确地说是该类型参数的扩展方法:
IEnumerable<T> where T : XContainer
这使其也能处理元素序列。使用此方法,我们可以重写在所有工具箱中查找手工工具的查询如下:
from tool in bench.Elements ("toolbox").Elements ("handtool")
select tool.Value;
第一次调用 Elements 绑定到 XContainer 的实例方法;第二次调用 Elements 绑定到扩展方法。
检索单个元素
方法 Element(单数)返回给定名称的第一个匹配元素。 Element 用于简单的导航,如下所示:
XElement settings = XElement.Load ("databaseSettings.xml");
string cx = settings.Element ("database").Element ("connectString").Value;
Element 等同于调用 Elements() 然后应用 LINQ 的 FirstOrDefault 查询运算符进行名称匹配的谓词。如果请求的元素不存在,Element 返回 null。
注意
如果元素 xyz 不存在,Element("xyz").Value 将抛出 NullReferenceException。如果你宁愿得到 null 而不是异常,可以使用空值条件操作符——Element("xyz")?.Value——或者将 XElement 强制转换为 string 而不是查询其 Value 属性。换句话说:
string xyz = (string) settings.Element ("xyz");
这样可以正常工作,因为 XElement 为此目的定义了显式的 string 转换!
检索后代
XContainer 还提供了 Descendants 和 DescendantNodes 方法,返回子元素或节点及其所有子级(整个树)。 Descendants 可接受一个可选的元素名称。回到我们的早期示例,我们可以使用 Descendants 查找所有手工工具:
Console.WriteLine (bench.Descendants ("handtool").Count()); // 3
父节点和叶节点均包括在内,如下例所示:
foreach (XNode node in bench.DescendantNodes())
Console.WriteLine (node.ToString (SaveOptions.DisableFormatting));
这是输出:
<toolbox><handtool>Hammer</handtool><handtool>Rasp</handtool></toolbox>
<handtool>Hammer</handtool>
Hammer
<handtool>Rasp</handtool>
Rasp
<toolbox><handtool>Saw</handtool><powertool>Nailgun</powertool></toolbox>
<handtool>Saw</handtool>
Saw
<powertool>Nailgun</powertool>
Nailgun
<!--Be careful with the nailgun-->
下一个查询从 X-DOM 中的任何位置提取包含单词“careful”的所有评论:
IEnumerable<string> query =
from c in bench.DescendantNodes().OfType<XComment>()
where c.Value.Contains ("careful")
orderby c.Value
select c.Value;
父导航
所有 XNode 都有一个 Parent 属性和 Ancestor*XXX* 方法用于父导航。父始终是一个 XElement:
| 返回类型 | 成员 | 适用于 |
|---|---|---|
XElement |
Parent { get; } |
XNode |
Enumerable<XElement> |
Ancestors() |
XNode |
Ancestors (XName) |
XNode |
|
AncestorsAndSelf() |
XElement |
|
AncestorsAndSelf (XName) |
XElement |
如果 x 是 XElement,以下内容始终打印 true:
foreach (XNode child in x.Nodes())
Console.WriteLine (child.Parent == x);
然而,如果 x 是 XDocument,情况则不同。XDocument 很特别:它可以有子节点但永远不可能是任何人的父节点!要访问 XDocument,你可以使用 Document 属性;这适用于 X-DOM 树中的任何对象。
Ancestors 返回一个序列,其第一个元素是 Parent,下一个元素是 Parent.Parent,依此类推,直到根元素。
注意
您可以使用 LINQ 查询 AncestorsAndSelf().Last() 导航到根元素。
另一种实现相同功能的方法是调用 Document.Root,尽管这仅在存在 XDocument 时有效。
对等节点导航
| 返回类型 | 成员 | 定义在 |
|---|---|---|
bool |
IsBefore (XNode node) |
XNode |
IsAfter (XNode node) |
XNode |
|
XNode |
PreviousNode { get; } |
XNode |
NextNode { get; } |
XNode |
|
IEnumerable<XNode> |
NodesBeforeSelf() |
XNode |
NodesAfterSelf() |
XNode |
|
IEnumerable<XElement> |
ElementsBeforeSelf() |
XNode |
ElementsBeforeSelf (XName name) |
XNode |
|
ElementsAfterSelf() |
XNode |
|
ElementsAfterSelf (XName name) |
XNode |
使用 PreviousNode 和 NextNode(以及 FirstNode/LastNode),您可以遍历具有链表感觉的节点。这并非巧合:内部节点存储在链表中。
注意
XNode 内部使用单向链表,因此 PreviousNode 不具备性能。
属性导航
| 返回类型 | 成员 | 定义在 |
|---|---|---|
bool |
HasAttributes { get; } |
XElement |
XAttribute |
Attribute (XName name) |
XElement |
FirstAttribute { get; } |
XElement |
|
LastAttribute { get; } |
XElement |
|
IEnumerable<XAttribute> |
Attributes() |
XElement |
Attributes (XName name) |
XElement |
此外,XAttribute 定义了 PreviousAttribute 和 NextAttribute 属性以及 Parent。
接受名称的 Attributes 方法返回一个序列,其中包含零个或一个元素;XML 元素不能具有重复的属性名称。
更新 X-DOM
您可以通过以下方式更新元素和属性:
-
调用
SetValue或重新分配Value属性。 -
调用
SetElementValue或SetAttributeValue。 -
调用其中一个
Remove*XXX*方法。 -
调用
Add*XXX*或Replace*XXX*方法之一,指定新内容。
您还可以在 XElement 对象上重新分配 Name 属性。
简单值更新
| 成员 | 适用于 |
|---|---|
SetValue (object value) |
XElement, XAttribute |
Value { get; set } |
XElement, XAttribute |
SetValue 方法用简单值替换元素或属性的内容。设置 Value 属性也是如此,但仅接受字符串数据。我们稍后在“处理值”中详细描述这两个函数。调用 SetValue(或重新分配 Value)的一个效果是替换所有子节点:
XElement settings = new XElement ("settings",
new XElement ("timeout", 30)
);
settings.SetValue ("blah");
Console.WriteLine (settings.ToString()); // <settings>blah</settings>
更新子节点和属性
| 类别 | 成员 | 适用于 |
|---|---|---|
| 添加 | Add (params object[] content) |
XContainer |
AddFirst (params object[] content) |
XContainer |
|
| 移除 | RemoveNodes() |
XContainer |
RemoveAttributes() |
XElement |
|
RemoveAll() |
XElement |
|
| 更新 | ReplaceNodes (params object[] content) |
XContainer |
ReplaceAttributes (params object[] content) |
XElement |
|
ReplaceAll (params object[] content |
XElement |
|
SetElementValue (XName name, object value) |
XElement |
|
SetAttributeValue (XName name, object value) |
XElement |
此组中最方便的方法是最后两个:SetElementValue 和 SetAttributeValue。它们作为快捷方式,用于实例化 XElement 或 XAttribute,然后将其添加到父级,并替换同名的现有元素或属性:
XElement settings = new XElement ("settings");
settings.SetElementValue ("timeout", 30); // Adds child node
settings.SetElementValue ("timeout", 60); // Update it to 60
Add 将子节点附加到元素或文档。AddFirst 与其类似,但插入到集合的开头而不是结尾。
您可以使用 RemoveNodes 或 RemoveAttributes 一次性删除所有子节点或属性。RemoveAll 等同于同时调用这两种方法。
Replace*XXX* 方法等效于 Remove 然后 Add。它们对输入进行了快照,因此 e.ReplaceNodes(e.Nodes()) 能够正常工作。
通过父级更新
| 成员 | 适用对象 |
|---|---|
AddBeforeSelf (params object[] content) |
XNode |
AddAfterSelf (params object[] content) |
XNode |
Remove() |
XNode, XAttribute |
ReplaceWith (params object[] content) |
XNode |
方法 AddBeforeSelf、AddAfterSelf、Remove 和 ReplaceWith 不操作节点的子节点。而是操作节点所在的集合。这要求节点必须有一个父元素,否则会抛出异常。AddBeforeSelf 和 AddAfterSelf 适用于将节点插入到任意位置:
XElement items = new XElement ("items",
new XElement ("one"),
new XElement ("three")
);
items.FirstNode.AddAfterSelf (new XElement ("two"));
这是结果:
<items><one /><two /><three /></items>
在长序列中的任意位置插入元素是高效的,因为节点在内部以链表形式存储。
Remove 方法从其父节点中移除当前节点。ReplaceWith 也是如此,并在相同位置插入其他内容:
XElement items = XElement.Parse ("<items><one/><two/><three/></items>");
items.FirstNode.ReplaceWith (new XComment ("One was here"));
这是结果:
<items><!--one was here--><two /><three /></items>
删除一系列节点或属性
多亏了 System.Xml.Linq 中的扩展方法,您还可以对节点或属性的序列调用 Remove。考虑这个 X-DOM:
XElement contacts = XElement.Parse (
@"<contacts>
<customer name='Mary'/>
<customer name='Chris' archived='true'/>
<supplier name='Susan'>
<phone archived='true'>012345678<!--confidential--></phone>
</supplier>
</contacts>");
下面的示例删除了所有客户:
contacts.Elements ("customer").Remove();
下面的示例删除了所有存档的联系人(因此 Chris 消失了):
contacts.Elements().Where (e => (bool?) e.Attribute ("archived") == true)
.Remove();
如果我们将 Elements() 替换为 Descendants(),整个 DOM 中的所有存档元素都将消失,得到如下结果:
<contacts>
<customer name="Mary" />
<supplier name="Susan" />
</contacts>
下一个示例删除了任何位置包含树中任何地方注释“confidential”的所有联系人:
contacts.Elements().Where (e => e.DescendantNodes()
.OfType<XComment>()
.Any (c => c.Value == "confidential")
).Remove();
这是结果:
<contacts>
<customer name="Mary" />
<customer name="Chris" archived="true" />
</contacts>
与下面更简单的查询相比,它从树中删除所有注释节点:
contacts.DescendantNodes().OfType<XComment>().Remove();
注意
内部实现中,Remove 方法首先将所有匹配的元素读入临时列表,然后枚举临时列表以执行删除操作。这样可以避免同时删除和查询可能导致的错误。
使用值
XElement 和 XAttribute 都有 Value 属性,类型为 string。如果一个元素有一个单独的 XText 子节点,XElement 的 Value 属性作为方便的快捷方式,用于访问该节点的内容。对于 XAttribute,Value 属性就是属性的值。
尽管存储方式不同,X-DOM 为处理元素和属性值提供了一致的操作集。
设置数值
有两种方法可以赋值:调用 SetValue 或分配 Value 属性。SetValue 更灵活,因为它不仅接受字符串,还接受其他简单的数据类型:
var e = new XElement ("date", DateTime.Now);
e.SetValue (DateTime.Now.AddDays(1));
Console.Write (e.Value); // 2019-10-02T16:39:10.734375+09:00
相反,我们本可以直接设置元素的 Value 属性,但这意味着必须手动将 DateTime 转换为字符串。这比调用 ToString 更复杂 —— 它需要使用 XmlConvert 来获得符合 XML 标准的结果。
当您将 值 传递给 XElement 或 XAttribute 的构造函数时,对于非字符串类型也会发生相同的自动转换。这确保了 DateTime 被正确格式化;true 以小写形式写入,double.NegativeInfinity 写为 “-INF”。
获取数值
要反向操作并将 Value 解析回基本类型,只需将 XElement 或 XAttribute 强制转换为所需类型。听起来好像不应该起作用,但它确实可以!例如:
XElement e = new XElement ("now", DateTime.Now);
DateTime dt = (DateTime) e;
XAttribute a = new XAttribute ("resolution", 1.234);
double res = (double) a;
元素或属性本身并不原生存储 DateTime 或数字 —— 它们始终以文本形式存储,然后根据需要进行解析。它也不会 “记住” 原始类型,因此必须正确地进行强制转换,以避免运行时错误。为了使您的代码健壮,可以将强制转换放入 try/catch 块中,捕获 FormatException。
XElement 和 XAttribute 上的显式转换可以解析为以下类型:
-
所有标准数值类型
-
string、bool、DateTime、DateTimeOffset、TimeSpan和Guid -
上述值类型的
Nullable<>版本
在与 Element 和 Attribute 方法结合使用时,将类型强制转换为可空类型很有用,因为即使请求的名称不存在,强制转换仍然有效。例如,如果 x 没有 timeout 元素,第一行将生成运行时错误,而第二行不会:
int timeout = (int) x.Element ("timeout"); // Error
int? timeout = (int?) x.Element ("timeout"); // OK; timeout is null.
您可以通过 ?? 运算符在最终结果中消除可空类型。如果 resolution 属性不存在,则以下计算结果为 1.0:
double resolution = (double?) x.Attribute ("resolution") ?? 1.0;
不过,将类型强制转换为可空类型并不能解决问题,如果元素或属性 存在 并且具有空(或格式不正确)的值。对此,必须捕获 FormatException。
您还可以在 LINQ 查询中使用转换。以下返回“John”:
var data = XElement.Parse (
@"<data>
<customer id='1' name='Mary' credit='100' />
<customer id='2' name='John' credit='150' />
<customer id='3' name='Anne' />
</data>");
IEnumerable<string> query = from cust in data.Elements()
where (int?) cust.Attribute ("credit") > 100
select cust.Attribute ("name").Value;
将可空 int 强制转换可以防止 NullReferenceException 的发生,例如,如果 Anne 没有 credit 属性。另一种解决方法是在 where 子句中添加谓词:
where cust.Attributes ("credit").Any() && (int) cust.Attribute...
查询元素值时也适用相同原则。
值和混合内容节点
鉴于 Value 的值,您可能想知道何时需要直接处理 XText 节点。答案是当您有混合内容时,例如:
<summary>An XAttribute is <bold>not</bold> an XNode</summary>
一个简单的 Value 属性无法捕捉 summary 的内容。summary 元素包含三个子元素:一个 XText 节点,后跟一个 XElement,再跟一个 XText 节点。以下是构造它的方法:
XElement summary = new XElement ("summary",
new XText ("An XAttribute is "),
new XElement ("bold", "not"),
new XText (" an XNode")
);
有趣的是,我们仍然可以查询 summary 的 Value —— 而不会抛出异常。相反,我们得到的是每个子元素值的连接:
An XAttribute is not an XNode
重新分配 summary 的 Value 也是合法的,代价是用一个新的单个 XText 节点替换所有先前的子元素。
自动 XText 连接
当向 XElement 添加简单内容时,X-DOM 会追加到现有的 XText 子元素,而不是创建新的。在以下示例中,e1 和 e2 最终只有一个子 XText 元素,其值为 HelloWorld:
var e1 = new XElement ("test", "Hello"); e1.Add ("World");
var e2 = new XElement ("test", "Hello", "World");
然而,如果您明确创建 XText 节点,则最终会有多个子元素:
var e = new XElement ("test", new XText ("Hello"), new XText ("World"));
Console.WriteLine (e.Value); // HelloWorld
Console.WriteLine (e.Nodes().Count()); // 2
XElement 不会连接这两个 XText 节点,因此节点的对象标识被保留。
文档和声明
XDocument
正如前面所说,XDocument 包装了一个根 XElement,允许您添加 XDeclaration、处理指令、文档类型和根级注释。XDocument 是可选的,可以被忽略或省略:与 W3C DOM 不同,它不起粘合剂作用以使所有内容保持在一起。
XDocument 提供与 XElement 相同的功能构造函数。因为它基于 XContainer,所以还支持 Add*XXX*、Remove*XXX* 和 Replace*XXX* 方法。然而,与 XElement 不同的是,XDocument 只能接受有限的内容:
-
一个单独的
XElement对象(“根”) -
一个单独的
XDeclaration对象 -
一个单独的
XDocumentType对象(用于引用文档类型定义 [DTD]) -
任意数量的
XProcessingInstruction对象 -
任意数量的
XComment对象
注意
其中,仅根 XElement 是强制性的,以确保具有有效的 XDocument。XDeclaration 是可选的——如果省略,则在序列化期间应用默认设置。
最简单的有效 XDocument 只有一个根元素:
var doc = new XDocument (
new XElement ("test", "data")
);
注意,我们没有包含 XDeclaration 对象。然而,通过调用 doc.Save 生成的文件仍将包含一个 XML 声明,因为默认情况下会生成一个。
下一个示例生成了一个简单但正确的 XHTML 文件,展示了 XDocument 可以接受的所有结构:
var styleInstruction = new XProcessingInstruction (
"xml-stylesheet", "href='styles.css' type='text/css'");
var docType = new XDocumentType ("html",
"-//W3C//DTD XHTML 1.0 Strict//EN",
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd", null);
XNamespace ns = "http://www.w3.org/1999/xhtml";
var root =
new XElement (ns + "html",
new XElement (ns + "head",
new XElement (ns + "title", "An XHTML page")),
new XElement (ns + "body",
new XElement (ns + "p", "This is the content"))
);
var doc =
new XDocument (
new XDeclaration ("1.0", "utf-8", "no"),
new XComment ("Reference a stylesheet"),
styleInstruction,
docType,
root);
doc.Save ("test.html");
结果文件 test.html 的内容如下:
<?xml version="1.0" encoding="utf-8" standalone="no"?>
<!--Reference a stylesheet-->
<?xml-stylesheet href='styles.css' type='text/css'?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN"
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html >
<head>
<title>An XHTML page</title>
</head>
<body>
<p>This is the content</p>
</body>
</html>
XDocument 具有 Root 属性,用作访问文档的单个 XElement 的快捷方式。反向链接由 XObject 的 Document 属性提供,适用于树中的所有对象:
Console.WriteLine (doc.Root.Name.LocalName); // html
XElement bodyNode = doc.Root.Element (ns + "body");
Console.WriteLine (bodyNode.Document == doc); // True
请记住,文档的子元素没有 Parent:
Console.WriteLine (doc.Root.Parent == null); // True
foreach (XNode node in doc.Nodes())
Console.Write (node.Parent == null); // TrueTrueTrueTrue
注意
XDeclaration 不是 XNode,也不会出现在文档的 Nodes 集合中——不像注释、处理指令和根元素。相反,它被分配到一个名为 Declaration 的专用属性。这就是为什么最后一个例子中的“True”重复四次而不是五次。
XML 声明
标准的 XML 文件以如下声明开头:
<?xml version="1.0" encoding="utf-8" standalone="yes"?>
XML 声明确保文件将被解析器正确解析和理解。XElement 和 XDocument 遵循这些规则来发出 XML 声明:
-
使用文件名调用
Save总是会写入声明。 -
使用
XmlWriter调用Save写入声明,除非显式告知XmlWriter否则不要这样做。 -
ToString方法从不输出 XML 声明。
注意
当构造 XmlWriter 时,可以通过设置 XmlWriterSettings 对象的 OmitXmlDeclaration 和 ConformanceLevel 属性来指示 XmlWriter 不生成声明。我们在第十一章中描述了这一点。
不存在 XDeclaration 对象的存在或不存在不会影响是否写入 XML 声明。XDeclaration 的目的是 提示 XML 序列化 的两种方式:
-
使用的文本编码
-
XML 声明的
encoding和standalone属性应该放什么(是否应该写入声明)
XDeclaration 的构造函数接受三个参数,分别对应于属性 version、encoding 和 standalone。在下面的例子中,test.xml 使用 UTF-16 编码:
var doc = new XDocument (
new XDeclaration ("1.0", "utf-16", "yes"),
new XElement ("test", "data")
);
doc.Save ("test.xml");
注意
无论您为 XML 版本指定什么,XML 写入器都会忽略它:它总是写入 "1.0"。
编码必须使用像 XML 声明中出现的 IETF 代码 "utf-16"。
将声明写入字符串
假设我们想将 XDocument 序列化为 string,包括 XML 声明。因为 ToString 不会写入声明,我们需要使用 XmlWriter:
var doc = new XDocument (
new XDeclaration ("1.0", "utf-8", "yes"),
new XElement ("test", "data")
);
var output = new StringBuilder();
var settings = new XmlWriterSettings { Indent = true };
using (XmlWriter xw = XmlWriter.Create (output, settings))
doc.Save (xw);
Console.WriteLine (output.ToString());
这是结果:
<?xml version="1.0" encoding="utf-16" standalone="yes"?>
<test>data</test>
注意,尽管我们在 XDeclaration 中显式请求了 UTF-8,但输出中却有 UTF-16!这看起来可能像一个错误,但事实上,XmlWriter 的处理非常智能。因为我们写入的是一个 string 而不是文件或流,所以除了 UTF-16(字符串内部存储的格式)外,不可能应用任何其他编码。因此,XmlWriter 写入 "utf-16" 以免造成误导。
这也解释了为什么 ToString 方法不会输出 XML 声明。想象一下,如果不调用 Save 而是像以下这样将 XDocument 写入文件:
File.WriteAllText ("data.xml", doc.ToString());
如其现状,data.xml 将缺少 XML 声明,使其不完整但仍可解析(可以推断文本编码)。但如果 ToString() 输出了 XML 声明,data.xml 实际上将包含一个错误的声明(encoding="utf-16"),这可能导致完全无法读取,因为 WriteAllText 使用 UTF-8 进行编码。
名称和命名空间
就像.NET 类型可以有命名空间一样,XML 元素和属性也可以有命名空间。
XML 命名空间实现了两个功能。首先,类似于 C#中的命名空间,它们有助于防止命名冲突。当你将一个 XML 文件的数据合并到另一个 XML 文件中时,这可能会成为一个问题。其次,命名空间为名称分配了绝对的含义。例如,名称“nil”可以表示任何东西。然而,在http://www.w3.org/2001/xmlschema-instance命名空间内,“nil”的含义相当于 C#中的null,并且具有特定的应用规则。
由于 XML 命名空间是一个重要的混淆源,我们首先总结一般命名空间,然后再讨论它们在 LINQ to XML 中的使用。
XML 中的命名空间
假设我们想要在命名空间OReilly.Nutshell.CSharp中定义一个customer元素。有两种方法可以进行。第一种是使用xmlns属性:
<customer />
**xmlns是一个特殊的保留属性。当以这种方式使用时,它执行两个功能:
-
它为相关元素指定了命名空间。
-
它为所有后代元素指定了默认命名空间。
这意味着在以下示例中,address和postcode隐含地位于OReilly.Nutshell.CSharp命名空间中:
<customer >
<address>
<postcode>02138</postcode>
</address>
</customer>
如果我们希望address和postcode没有命名空间,我们需要这样做:
<customer >
<address xmlns="">
<postcode>02138</postcode> <!-- postcode now inherits empty ns -->
</address>
</customer>
前缀
指定命名空间的另一种方法是使用前缀。前缀是你为命名空间分配的别名,以节省输入。使用前缀有两个步骤—定义前缀和使用前缀。你可以同时进行:
<nut:customer xmlns:nut="OReilly.Nutshell.CSharp"/>
此处发生了两件不同的事情。右侧定义了一个名为nut的前缀,并使其在该元素及其所有后代中可用。左侧的nut:customer将新分配的前缀分配给了customer元素。
带有前缀的元素不会为后代元素定义默认命名空间。在以下 XML 中,firstname具有空命名空间:
<nut:customer >
<firstname>Joe</firstname>
</customer>
要给firstname分配OReilly.Nutshell.CSharp前缀,你必须这样做:
<nut:customer >
<nut:firstname>Joe</firstname>
</customer>
你还可以为你的后代方便地定义一个或多个前缀,而不将它们分配给父元素本身。以下定义了两个前缀,i和z,同时将customer元素本身保留为空命名空间:
<customer
>
...
</customer>
如果这是根节点,则整个文档都可以使用i和z前缀。当元素需要从多个命名空间中获取信息时,前缀非常方便。
注意,此示例中的两个命名空间都是 URI。使用 URI(你拥有的)是标准做法:它确保命名空间的唯一性。因此,在实际情况中,我们的customer元素更可能是这样的:
<customer />
或者:
<nut:customer />
属性
你也可以为属性分配命名空间。主要区别在于,属性始终需要一个前缀。例如:
<customer nut:id="123" />
另一个不同之处在于,未限定的属性始终具有空命名空间:它从不从父元素继承默认命名空间。
属性通常不需要命名空间,因为它们的含义通常局限于元素本身。一个例外是像 W3C 定义的 nil 属性这样的通用或元数据属性:
<customer >
<firstname>Joe</firstname>
<lastname xsi:nil="true"/>
</customer>
这明确表示 lastname 是 nil(在 C# 中是 null),而不是空字符串。因为我们使用了标准命名空间,通用的解析工具可以确切地了解我们的意图。** **## 在 X-DOM 中指定命名空间
到目前为止,在本章中,我们仅使用简单的字符串作为 XElement 和 XAttribute 的名称。一个简单的字符串对应于一个具有空命名空间的 XML 名称,类似于在全局命名空间中定义的 .NET 类型。
有几种指定 XML 命名空间的方法。第一种是在本地名称之前用大括号括起来:
var e = new XElement ("{http://domain.com/xmlspace}customer", "Bloggs");
Console.WriteLine (e.ToString());
这将产生如下的 XML:
<customer >Bloggs</customer>
第二种(更高效)方法是使用 XNamespace 和 XName 类型。以下是它们的定义:
public sealed class XNamespace
{
public string NamespaceName { get; }
}
public sealed class XName // A local name with optional namespace
{
public string LocalName { get; }
public XNamespace Namespace { get; } // Optional
}
两种类型都定义了从 string 的隐式转换,因此以下操作是合法的:
XNamespace ns = "http://domain.com/xmlspace";
XName localName = "customer";
XName fullName = "{http://domain.com/xmlspace}customer";
XNamespace 还重载了 + 运算符,允许你将命名空间和名称组合成一个 XName 而无需使用大括号:
XNamespace ns = "http://domain.com/xmlspace";
XName fullName = ns + "customer";
Console.WriteLine (fullName); // {http://domain.com/xmlspace}customer
X-DOM 中所有接受元素或属性名称的构造函数和方法实际上接受的是一个 XName 对象而不是一个 string。你之所以可以像我们到目前为止的所有示例那样替换一个字符串,是因为存在隐式转换。
无论是对元素还是属性,指定命名空间的方式都是相同的:
XNamespace ns = "http://domain.com/xmlspace";
var data = new XElement (ns + "data",
new XAttribute (ns + "id", 123)
);
X-DOM 和默认命名空间
X-DOM 在构造子 XElement 时忽略了默认命名空间的概念;如果需要,你必须显式为其指定命名空间;它不会从父元素继承:
XNamespace ns = "http://domain.com/xmlspace";
var data = new XElement (ns + "data",
new XElement (ns + "customer", "Bloggs"),
new XElement (ns + "purchase", "Bicycle")
);
直到实际输出 XML 时,X-DOM 才会应用默认命名空间:
Console.WriteLine (data.ToString());
OUTPUT:
<data >
<customer>Bloggs</customer>
<purchase>Bicycle</purchase>
</data>
Console.WriteLine (data.Element (ns + "customer").ToString());
OUTPUT:
<customer >Bloggs</customer>
如果在构造 XElement 子元素时没有指定命名空间,换句话说
XNamespace ns = "http://domain.com/xmlspace";
var data = new XElement (ns + "data",
new XElement ("customer", "Bloggs"),
new XElement ("purchase", "Bicycle")
);
Console.WriteLine (data.ToString());
你会得到以下结果:
<data >
<customer xmlns="">Bloggs</customer>
<purchase xmlns="">Bicycle</purchase>
</data>
另一个陷阱是在导航 X-DOM 时未包括命名空间:
XNamespace ns = "http://domain.com/xmlspace";
var data = new XElement (ns + "data",
new XElement (ns + "customer", "Bloggs"),
new XElement (ns + "purchase", "Bicycle")
);
XElement x = data.Element (ns + "customer"); // ok
XElement y = data.Element ("customer"); // null
如果在构建 X-DOM 树时未指定命名空间,你随后可以将每个元素分配到一个单一的命名空间,如下所示:
foreach (XElement e in data.DescendantsAndSelf())
if (e.Name.Namespace == "")
e.Name = ns + e.Name.LocalName;
前缀
X-DOM 对待前缀和命名空间的方式与其对待序列化功能相同。这意味着你可以选择完全忽略前缀的问题,并且顺利进行!唯一可能希望做出不同选择的原因是在将 XML 输出到文件时提高效率。例如,考虑下面这个:
XNamespace ns1 = "http://domain.com/space1";
XNamespace ns2 = "http://domain.com/space2";
var mix = new XElement (ns1 + "data",
new XElement (ns2 + "element", "value"),
new XElement (ns2 + "element", "value"),
new XElement (ns2 + "element", "value")
);
默认情况下,XElement 将按如下方式序列化:
<data >
<element >value</element>
<element >value</element>
<element >value</element>
</data>
正如你所看到的,存在一些不必要的重复。解决方案不是改变构造 X-DOM 的方式,而是在写入 XML 之前提示序列化器。通过添加定义你想要应用的前缀的属性来实现。这通常在根元素上完成:
mix.SetAttributeValue (XNamespace.Xmlns + "ns1", ns1);
mix.SetAttributeValue (XNamespace.Xmlns + "ns2", ns2);
这将前缀ns1分配给我们的XNamespace变量ns1,并将ns2分配给ns2。当序列化时,X-DOM 自动获取这些属性,并用它们来压缩生成的 XML。现在,在mix上调用ToString的结果如下:
<ns1:data
>
<ns2:element>value</ns2:element>
<ns2:element>value</ns2:element>
<ns2:element>value</ns2:element>
</ns1:data>
前缀不会改变您构造、查询或更新 X-DOM 的方式——对于这些活动,您忽略前缀的存在并继续使用完整名称。前缀仅在转换为和从 XML 文件或流中时才会起作用。
前缀在序列化属性时也会受到尊重。在以下示例中,我们记录了客户的出生日期和信用为"nil",并使用了 W3C 标准属性。突出显示的行确保前缀在序列化时不会重复命名空间:
XNamespace xsi = "http://www.w3.org/2001/XMLSchema-instance";
var nil = new XAttribute (xsi + "nil", true);
var cust = new XElement ("customers",
new XAttribute (XNamespace.Xmlns + "xsi", xsi),
new XElement ("customer",
new XElement ("lastname", "Bloggs"),
new XElement ("dob", nil),
new XElement ("credit", nil)
)
);
这是其 XML:
<customers >
<customer>
<lastname>Bloggs</lastname>
<dob xsi:nil="true" />
<credit xsi:nil="true" />
</customer>
</customers>
为了简洁起见,我们预先声明了 nil XAttribute,以便在构建 DOM 时可以使用它两次。您允许引用相同的属性两次,因为根据需要会自动复制它。** **# 注释
您可以使用注释将自定义数据附加到任何XObject上。注释专为您自己的私人使用而设计,并且在 X-DOM 中被视为黑匣子。如果您曾经在 Windows Forms 或 Windows Presentation Foundation (WPF) 控件的 Tag 属性上使用过,您可能已经熟悉这个概念——不同之处在于您有多个注释,并且您的注释可以是私有作用域。您可以创建其他类型甚至看不到——更不用说覆盖的注释。
XObject 上述方法用于添加和移除注释:
public void AddAnnotation (object annotation)
public void RemoveAnnotations<T>() where T : class
以下方法检索注释:
public T Annotation<T>() where T : class
public IEnumerable<T> Annotations<T>() where T : class
每个注释都以其类型作为键,必须是引用类型。以下是添加然后检索string注释的操作:
XElement e = new XElement ("test");
e.AddAnnotation ("Hello");
Console.WriteLine (e.Annotation<string>()); // Hello
您可以添加多个相同类型的注释,然后使用Annotations方法检索匹配序列。
诸如string之类的公共类型并不是一个很好的键,因为其他类型中的代码可能会干扰您的注释。更好的方法是使用内部或(嵌套的)私有类:
class X
{
class CustomData { internal string Message; } // Private nested type
static void Test()
{
XElement e = new XElement ("test");
e.AddAnnotation (new CustomData { Message = "Hello" } );
Console.Write (e.Annotations<CustomData>().First().Message); // Hello
}
}
要删除注释,您还必须访问键的类型:
e.RemoveAnnotations<CustomData>();
投射到 X-DOM 中
到目前为止,我们已经展示了如何使用 LINQ 从 X-DOM 中获取数据。您还可以使用 LINQ 查询来投射到 X-DOM。源可以是 LINQ 可查询的任何内容,例如以下内容:
-
EF Core 实体类
-
本地集合
-
另一个 X-DOM
无论来源如何,使用 LINQ 发出 X-DOM 的策略是相同的:首先编写一个功能构造表达式,以生成所需的 X-DOM 结构,然后围绕表达式构建 LINQ 查询。
例如,假设我们想要从数据库中检索以下 XML 的客户信息:
<customers>
<customer id="1">
<name>Sue</name>
<buys>3</buys>
</customer>
...
</customers>
我们从编写 X-DOM 的功能构造表达式开始,使用简单字面量:
var customers =
new XElement ("customers",
new XElement ("customer", new XAttribute ("id", 1),
new XElement ("name", "Sue"),
new XElement ("buys", 3)
)
);
然后我们将其转换为投射,并围绕其构建 LINQ 查询:
var customers =
new XElement ("customers",
// We must call AsEnumerable() due to a bug in EF Core.
from c in dbContext.Customers.AsEnumerable()
select
new XElement ("customer", new XAttribute ("id", c.ID),
new XElement ("name", c.Name),
new XElement ("buys", c.Purchases.Count)
)
);
注意
由于 EF Core 中的一个错误(计划在后续版本中修复),需要调用 AsEnumerable 方法。修复后,删除对 AsEnumerable 的调用将通过防止每次调用 c.Purchases.Count 都进行往返而提高效率。
这是结果:
<customers>
<customer id="1">
<name>Tom</name>
<buys>3</buys>
</customer>
<customer id="2">
<name>Harry</name>
<buys>2</buys>
</customer>
...
</customers>
通过两步构造相同的查询,我们可以更清晰地看到其工作原理。首先:
IEnumerable<XElement> sqlQuery =
from c in dbContext.Customers.AsEnumerable()
select
new XElement ("customer", new XAttribute ("id", c.ID),
new XElement ("name", c.Name),
new XElement ("buys", c.Purchases.Count)
);
这部分是一个正常的 LINQ 查询,将投影到 XElement 中。这是第二步:
var customers = new XElement ("customers", sqlQuery);
这构造了根 XElement。唯一不同寻常的是内容 sqlQuery 不是单个 XElement,而是实现了 IEnumerable<XElement> 的 IQueryable<XElement>。请记住,在处理 XML 内容时,集合会自动被枚举。因此,每个 XElement 都会作为子节点添加。
消除空元素
假设在前面的示例中,我们还想包含客户最近一次高价值购买的详细信息。我们可以这样做:
var customers =
new XElement ("customers",
// The AsEnumerable call can be removed when the EF Core bug is fixed.
from c in dbContext.Customers.AsEnumerable()
let lastBigBuy = (from p in c.Purchases
where p.Price > 1000
orderby p.Date descending
select p).FirstOrDefault()
select
new XElement ("customer", new XAttribute ("id", c.ID),
new XElement ("name", c.Name),
new XElement ("buys", c.Purchases.Count),
new XElement ("lastBigBuy",
new XElement ("description", lastBigBuy?.Description),
new XElement ("price", lastBigBuy?.Price ?? 0m)
)
)
);
这会发出空元素,但对于没有高价值购买的客户来说,不会发出 null。如果这是一个本地查询而不是数据库查询,它会抛出 NullReferenceException。在这种情况下,最好完全省略 lastBigBuy 节点。我们可以通过将 lastBigBuy 元素的构造函数包装在条件运算符中来实现:
select
new XElement ("customer", new XAttribute ("id", c.ID),
new XElement ("name", c.Name),
new XElement ("buys", c.Purchases.Count),
lastBigBuy == null ? null :
new XElement ("lastBigBuy",
new XElement ("description", lastBigBuy.Description),
new XElement ("price", lastBigBuy.Price)
对于没有 lastBigBuy 的客户,会发出 null 而不是空的 XElement。这正是我们想要的,因为 null 内容会被简单地忽略。
流式投影
如果你只是为了 Save(或对其调用 ToString)而将 X-DOM 投影,则可以通过 XStreamingElement 提高内存效率。XStreamingElement 是 XElement 的简化版本,将 延迟加载 语义应用于其子内容。要使用它,只需将外部的 XElement 替换为 XStreamingElement:
var customers =
new XStreamingElement ("customers",
from c in dbContext.Customers
select
new XStreamingElement ("customer", new XAttribute ("id", c.ID),
new XElement ("name", c.Name),
new XElement ("buys", c.Purchases.Count)
)
);
customers.Save ("data.xml");
XStreamingElement 构造函数中传递的查询,在调用元素的 Save、ToString 或 WriteTo 方法之前不会被枚举;这可以防止一次性将整个 X-DOM 加载到内存中。反之,重新 Save 时,查询会重新评估。此外,你无法遍历 XStreamingElement 的子内容—它不会公开诸如 Elements 或 Attributes 的方法。
XStreamingElement 不基于 XObject—或任何其他类—因为它具有非常有限的成员集。除了 Save、ToString 和 WriteTo 外,它唯一拥有的成员是以下几个:
-
Add方法,接受与构造函数类似的内容 -
Name属性
XStreamingElement 不允许以流式方式 读取 内容—为此,必须将 XmlReader 与 X-DOM 结合使用。我们在 “使用 XmlReader/XmlWriter 的模式” 中描述了如何做到这一点。
¹ X-DOM 实际上通过将简单文本内容存储在字符串中,在内部优化了这一步骤。直到在XContainer上调用Nodes( )时,XTEXT节点才会真正创建。
² 请参阅脚注 1。
第十一章:其他 XML 和 JSON 技术
在第十章中,我们介绍了 LINQ-to-XML API 和 XML 的一般概念。在本章中,我们将探索低级别的 XmlReader/XmlWriter 类以及处理 JavaScript 对象表示法(JSON)的相关类型,后者已成为 XML 的流行替代方案。
在在线补充中,我们描述了处理 XML 模式和样式表的工具。
XmlReader
XmlReader 是一种高性能的类,以逐个向前的方式读取 XML 流。
考虑以下 XML 文件,customer.xml:
<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<customer id="123" status="archived">
<firstname>Jim</firstname>
<lastname>Bo</lastname>
</customer>
要实例化一个 XmlReader,您可以调用静态方法 XmlReader.Create,并传入一个 Stream、TextReader 或 URI 字符串:
using XmlReader reader = XmlReader.Create ("customer.xml");
...
注意
因为 XmlReader 允许从潜在缓慢的来源(Stream 和 URI)读取,它提供了其大多数方法的异步版本,使您能够轻松编写非阻塞代码。我们在第十四章中详细介绍了异步处理。
要构造一个从字符串读取的 XmlReader:
using XmlReader reader = XmlReader.Create (
new System.IO.StringReader (myString));
您还可以传入一个 XmlReaderSettings 对象来控制解析和验证选项。XmlReaderSettings 上的以下三个属性特别适用于跳过多余的内容:
bool IgnoreComments // Skip over comment nodes?
bool IgnoreProcessingInstructions // Skip over processing instructions?
bool IgnoreWhitespace // Skip over whitespace?
在下面的示例中,我们指示阅读器不要输出空白节点,因为在典型场景中它们会造成干扰:
XmlReaderSettings settings = new XmlReaderSettings();
settings.IgnoreWhitespace = true;
using XmlReader reader = XmlReader.Create ("customer.xml", settings);
...
XmlReaderSettings 的另一个有用属性是 ConformanceLevel。其默认值 Document 指示阅读器假定一个带有单个根节点的有效 XML 文档。如果要读取仅包含多个节点的 XML 内部部分,则会遇到问题:
<firstname>Jim</firstname>
<lastname>Bo</lastname>
要在不抛出异常的情况下读取此内容,必须将 ConformanceLevel 设置为 Fragment。
XmlReaderSettings 还有一个名为 CloseInput 的属性,指示在关闭阅读器时是否关闭底层流(XmlWriterSettings 上也有类似的 CloseOutput 属性)。CloseInput 和 CloseOutput 的默认值均为 false。
读取节点
XML 流的单位是 XML 节点。阅读器以文本方式(深度优先)遍历流。阅读器的 Depth 属性返回光标当前的深度。
从 XmlReader 中读取 XML 的最基本方法是调用 Read。它前进到 XML 流中的下一个节点,类似于 IEnumerator 中的 MoveNext。首次调用 Read 会将光标定位在第一个节点上。当 Read 返回 false 时,意味着光标已经超过了最后一个节点,在这种情况下应关闭并丢弃 XmlReader。
XmlReader 上的两个 string 属性提供对节点内容的访问:Name 和 Value。根据节点类型,Name 或 Value(或两者)将被填充。
在此示例中,我们逐个读取 XML 流中的每个节点,并输出每个节点的类型:
XmlReaderSettings settings = new XmlReaderSettings();
settings.IgnoreWhitespace = true;
using XmlReader reader = XmlReader.Create ("customer.xml", settings);
while (reader.Read())
{
Console.Write (new string (' ', reader.Depth * 2)); // Write indentation
Console.Write (reader.NodeType.ToString());
if (reader.NodeType == XmlNodeType.Element ||
reader.NodeType == XmlNodeType.EndElement)
{
Console.Write (" Name=" + reader.Name);
}
else if (reader.NodeType == XmlNodeType.Text)
{
Console.Write (" Value=" + reader.Value);
}
Console.WriteLine ();
}
输出如下:
XmlDeclaration
Element Name=customer
Element Name=firstname
Text Value=Jim
EndElement Name=firstname
Element Name=lastname
Text Value=Bo
EndElement Name=lastname
EndElement Name=customer
注意
属性不包括在基于 Read 的遍历中(参见“读取属性”)。
NodeType 的类型是 XmlNodeType,它是一个枚举,包含以下成员:
| None XmlDeclaration
Element
EndElement
Text
Attribute | Comment Entity
EndEntity
EntityReference
ProcessingInstruction
CDATA | Document DocumentType
DocumentFragment
Notation
Whitespace
SignificantWhitespace |
读取元素
通常,您已经知道正在读取的 XML 文档的结构。为了帮助处理,XmlReader 提供了一系列方法,这些方法在 假定 特定结构的同时读取内容。这样做不仅简化了您的代码,还同时进行了一些验证。
注意
如果验证失败,XmlReader 会抛出 XmlException。XmlException 具有 LineNumber 和 LinePosition 属性,指示错误发生的位置——如果 XML 文件很大,记录此信息至关重要!
ReadStartElement 验证当前的 NodeType 是 Element,然后调用 Read。如果指定了名称,则验证其是否与当前元素的名称匹配。
ReadEndElement 验证当前的 NodeType 是 EndElement,然后调用 Read。
例如,我们可以读取
<firstname>Jim</firstname>
如下所示:
reader.ReadStartElement ("firstname");
Console.WriteLine (reader.Value);
reader.Read();
reader.ReadEndElement();
ReadElementContentAsString 方法一次完成所有操作。它读取起始元素、文本节点和结束元素,将内容作为字符串返回:
string firstName = reader.ReadElementContentAsString ("firstname", "");
第二个参数是指命名空间,在本例中为空白。此方法还有类型化版本,如 ReadElementContentAsInt,用于解析结果。返回到我们的原始 XML 文档:
<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<customer id="123" status="archived">
<firstname>Jim</firstname>
<lastname>Bo</lastname>
<creditlimit>500.00</creditlimit> <!-- OK, we sneaked this in! -->
</customer>
我们可以按以下方式读取它:
XmlReaderSettings settings = new XmlReaderSettings();
settings.IgnoreWhitespace = true;
using XmlReader r = XmlReader.Create ("customer.xml", settings);
r.MoveToContent(); // Skip over the XML declaration
r.ReadStartElement ("customer");
string firstName = r.ReadElementContentAsString ("firstname", "");
string lastName = r.ReadElementContentAsString ("lastname", "");
decimal creditLimit = r.ReadElementContentAsDecimal ("creditlimit", "");
r.MoveToContent(); // Skip over that pesky comment
r.ReadEndElement(); // Read the closing customer tag
注意
MoveToContent 方法非常有用。它跳过所有的冗余内容:XML 声明、空白、注释和处理指令。您还可以通过 XmlReaderSettings 上的属性自动指示阅读器执行大部分此操作。
可选元素
在前面的示例中,假设 <lastname> 是可选的。解决方法很简单:
r.ReadStartElement ("customer");
string firstName = r. ReadElementContentAsString ("firstname", "");
string lastName = r.Name == "lastname"
? r.ReadElementContentAsString() : null;
decimal creditLimit = r.ReadElementContentAsDecimal ("creditlimit", "");
随机元素顺序
本节的示例依赖于 XML 文件中元素按顺序出现的顺序。如果需要处理元素以任意顺序出现的情况,最简单的解决方案是将 XML 的该部分读入 X-DOM。我们稍后在“使用 XmlReader/XmlWriter 的模式”中描述如何做到这一点。
空元素
XmlReader 处理空元素的方式可能会导致严重问题。考虑以下元素:
<customerList></customerList>
在 XML 中,这相当于以下内容:
<customerList/>
然而,XmlReader 对这两种情况的处理方式不同。在第一种情况下,以下代码按预期工作:
reader.ReadStartElement ("customerList");
reader.ReadEndElement();
在第二种情况下,由于在 XmlReader 看来不存在单独的“结束元素”,因此 ReadEndElement 抛出异常。解决方法是检查空元素:
bool isEmpty = reader.IsEmptyElement;
reader.ReadStartElement ("customerList");
if (!isEmpty) reader.ReadEndElement();
实际上,这只有在所讨论的元素可能包含子元素(如客户列表)时才是一个麻烦。对于包含简单文本的元素(如firstname),你可以通过调用诸如ReadElementContentAsString的方法避免整个问题。ReadElement*XXX*方法可以正确处理这两种类型的空元素。
Other ReadXXX methods
Table 11-1 总结了XmlReader中所有Read*XXX*方法。其中大多数设计用于处理元素。粗体显示的示例 XML 片段是所述方法读取的部分。
表 11-1。读取方法
| Members | Works on NodeType | Sample XML fragment | Input parameters | Data returned |
|---|---|---|---|---|
ReadContentAs*XXX* |
Text |
<a>**x**</a> |
x |
|
ReadElementContentAs*XXX* |
Element |
**<a>x</a>** |
x |
|
ReadInnerXml |
Element |
**<a>x</a>** |
x |
|
ReadOuterXml |
Element |
**<a>x</a>** |
<a>x</a> |
|
ReadStartElement |
Element |
**<a>**x</a> |
||
ReadEndElement |
Element |
<a>x**</a>** |
||
ReadSubtree |
Element |
**<a>x</a>** |
<a>x</a> |
|
ReadToDescendant |
Element |
**<a>x**<b></b></a> |
"b" |
|
ReadToFollowing |
Element |
**<a>x**<b></b></a> |
"b" |
|
ReadToNextSibling |
Element |
**<a>x</a**><b></b> |
"b" |
|
ReadAttributeValue |
Attribute |
参见“读取属性” |
ReadContentAs*XXX*方法将文本节点解析为类型*XXX*。在内部,XmlConvert类执行字符串到类型的转换。文本节点可以位于元素或属性内。
ReadElementContentAs*XXX*方法是相应的ReadContentAs*XXX*方法的包装。它们适用于元素节点,而不是包含在元素中的文本节点。
ReadInnerXml通常适用于元素,它会读取并返回元素及其所有后代。当应用于属性时,它会返回属性的值。ReadOuterXml与之类似,但它包括光标位置处的元素,而不是排除它。
ReadSubtree返回一个代理阅读器,提供对当前元素(及其后代)的视图。必须在可以安全再次读取原始阅读器之前关闭代理阅读器。关闭代理阅读器时,原始阅读器的光标位置移动到子树的末尾。
ReadToDescendant将光标移动到具有指定名称/命名空间的第一个后代节点的开头。ReadToFollowing将光标移动到具有指定名称/命名空间的第一个节点的开头,无论深度如何。ReadToNextSibling将光标移动到具有指定名称/命名空间的第一个同级节点的开头。
还有两个传统方法:ReadString 和 ReadElementString 的行为类似于 ReadContentAsString 和 ReadElementContentAsString,但如果元素包含多个 单一 文本节点,则会抛出异常。应避免使用这些方法,因为如果元素包含注释,它们会抛出异常。
读取属性
XmlReader 提供了一个索引器,让您可以直接(随机)访问元素的属性——按名称或位置。使用索引器等同于调用 GetAttribute。
给定 XML 片段
<customer id="123" status="archived"/>
我们可以读取其属性,如下所示:
Console.WriteLine (reader ["id"]); // 123
Console.WriteLine (reader ["status"]); // archived
Console.WriteLine (reader ["bogus"] == null); // True
警告
XmlReader 必须定位在 起始元素 上才能读取属性。在调用 ReadStartElement 后,属性将永远消失!
尽管属性顺序在语义上无关紧要,但可以通过其序数位置访问属性。我们可以将前述示例重写如下:
Console.WriteLine (reader [0]); // 123
Console.WriteLine (reader [1]); // archived
索引器还允许您指定属性的命名空间(如果有)。
AttributeCount 返回当前节点的属性数量。
属性节点
要显式遍历属性节点,必须从仅调用 Read 的正常路径进行特殊分流。这样做的一个好理由是,如果你想将属性值解析为其他类型,可以通过 ReadContentAs*XXX* 方法。
操作必须从 起始元素 开始。为了简化工作,在属性遍历期间放宽了单向规则:通过调用 MoveToAttribute,你可以跳转到任何属性(向前或向后)。
注意
MoveToElement 从属性节点转到 start 元素。
回到我们之前的例子:
<customer id="123" status="archived"/>
我们可以这样做:
reader.MoveToAttribute ("status");
string status = reader.ReadContentAsString();
reader.MoveToAttribute ("id");
int id = reader.ReadContentAsInt();
如果指定的属性不存在,MoveToAttribute 返回 false。
您还可以通过调用 MoveToFirstAttribute 然后调用 MoveToNextAttribute 方法按顺序遍历每个属性:
if (reader.MoveToFirstAttribute())
do { Console.WriteLine (reader.Name + "=" + reader.Value); }
while (reader.MoveToNextAttribute());
// OUTPUT:
id=123
status=archived
命名空间和前缀
XmlReader 提供了两个并行系统来引用元素和属性名称:
-
Name -
NamespaceURI和LocalName
每当读取一个元素的 Name 属性或调用接受单个 name 参数的方法时,你正在使用第一个系统。如果没有命名空间或前缀,这种方式非常有效;否则,它会以一种粗糙和字面的方式工作。命名空间被忽略,前缀被包含在其原样写入的位置;例如:
| 示例片段 | 名称 |
|---|---|
<**customer** ...> |
customer |
<**customer** xmlns='blah' ...> |
customer |
<**x:customer** ...> |
x:customer |
下面的代码适用于前两种情况:
reader.ReadStartElement ("customer");
处理第三种情况需要以下操作:
reader.ReadStartElement ("x:customer");
第二个系统通过两个 命名空间感知 属性工作:NamespaceURI 和 LocalName。这些属性考虑了由父元素定义的前缀和默认命名空间。前缀会自动扩展。这意味着 NamespaceURI 总是反映当前元素的语义上正确的命名空间,而 LocalName 总是不带前缀的。
当您将两个名称参数传递给诸如 ReadStartElement 的方法时,您正在使用相同的系统。例如,考虑以下 XML:
<customer >
<address>
<other:city>
...
我们可以按以下方式读取它:
reader.ReadStartElement ("customer", "DefaultNamespace");
reader.ReadStartElement ("address", "DefaultNamespace");
reader.ReadStartElement ("city", "OtherNamespace");
抽象化掉前缀通常正是您想要的。如果需要,您可以通过调用 LookupNamespace 查看使用的前缀,并将其转换为命名空间。
XmlWriter
XmlWriter 是 XML 流的单向写入器。XmlWriter 的设计与 XmlReader 对称。
与 XmlTextReader 一样,您通过调用 Create(带有可选的 settings 对象)来构造 XmlWriter。在下面的示例中,我们启用缩进以使输出更易读,并写入一个简单的 XML 文件:
XmlWriterSettings settings = new XmlWriterSettings();
settings.Indent = true;
using XmlWriter writer = XmlWriter.Create ("foo.xml", settings);
writer.WriteStartElement ("customer");
writer.WriteElementString ("firstname", "Jim");
writer.WriteElementString ("lastname", "Bo");
writer.WriteEndElement();
这将产生以下文档(与我们在 XmlReader 的第一个示例中读取的文件相同):
<?xml version="1.0" encoding="utf-8"?>
<customer>
<firstname>Jim</firstname>
<lastname>Bo</lastname>
</customer>
XmlWriter 自动在顶部写入声明,除非在 XmlWriterSettings 中设置 OmitXmlDeclaration 为 true 或 ConformanceLevel 设置为 Fragment 以外。后者还允许写入多个根节点——否则会引发异常。
WriteValue 方法写入单个文本节点。它接受字符串和非字符串类型,如 bool 和 DateTime,在内部调用 XmlConvert 执行符合 XML 标准的字符串转换:
writer.WriteStartElement ("birthdate");
writer.WriteValue (DateTime.Now);
writer.WriteEndElement();
相反,如果我们调用
WriteElementString ("birthdate", DateTime.Now.ToString());
结果会既不符合 XML 标准,也容易受到错误解析的影响。
WriteString 等同于使用字符串调用 WriteValue。XmlWriter 自动转义否则在属性或元素中非法的字符,如 &、< > 和扩展的 Unicode 字符。
写入属性
您可以在写入 start 元素后立即写入属性:
writer.WriteStartElement ("customer");
writer.WriteAttributeString ("id", "1");
writer.WriteAttributeString ("status", "archived");
要写入非字符串值,请调用 WriteStartAttribute、WriteValue,然后 WriteEndAttribute。
写入其他节点类型
XmlWriter 还定义了用于写入其他类型节点的以下方法:
WriteBase64 // for binary data
WriteBinHex // for binary data
WriteCData
WriteComment
WriteDocType
WriteEntityRef
WriteProcessingInstruction
WriteRaw
WriteWhitespace
WriteRaw 直接将字符串注入输出流。还有一个接受 XmlReader 的 WriteNode 方法,从给定的 XmlReader 中回显所有内容。
命名空间和前缀
Write* 方法的重载允许您将元素或属性与命名空间关联起来。让我们重写前面示例中 XML 文件的内容。这次,我们将所有元素与 http://oreilly.com 命名空间关联起来,在 customer 元素处声明前缀 o:
writer.WriteStartElement ("o", "customer", "http://oreilly.com");
writer.WriteElementString ("o", "firstname", "http://oreilly.com", "Jim");
writer.WriteElementString ("o", "lastname", "http://oreilly.com", "Bo");
writer.WriteEndElement();
输出现在如下所示:
<?xml version="1.0" encoding="utf-8"?>
<o:customer xmlns:o='http://oreilly.com'>
<o:firstname>Jim</o:firstname>
<o:lastname>Bo</o:lastname>
</o:customer>
注意,为了简洁起见,当父元素已经声明了子元素的命名空间时,XmlWriter会省略子元素的命名空间声明。
使用 XmlReader/XmlWriter 的模式
处理层次数据
考虑以下类:
public class Contacts
{
public IList<Customer> Customers = new List<Customer>();
public IList<Supplier> Suppliers = new List<Supplier>();
}
public class Customer { public string FirstName, LastName; }
public class Supplier { public string Name; }
假设您希望使用XmlReader和XmlWriter将Contacts对象序列化为 XML,如下所示:
<?xml version="1.0" encoding="utf-8"?>
<contacts>
<customer id="1">
<firstname>Jay</firstname>
<lastname>Dee</lastname>
</customer>
<customer> <!-- we'll assume id is optional -->
<firstname>Kay</firstname>
<lastname>Gee</lastname>
</customer>
<supplier>
<name>X Technologies Ltd</name>
</supplier>
</contacts>
最好的方法不是编写一个大方法,而是在Customer和Supplier类型本身中封装 XML 功能,通过编写这些类型的ReadXml和WriteXml方法来实现。这种模式很简单:
-
ReadXml和WriteXml在退出时将读者/写者保持在相同的深度。 -
ReadXml读取外部元素,而WriteXml仅写入其内部内容。
下面是如何编写Customer类型的方法:
public class Customer
{
public const string XmlName = "customer";
public int? ID;
public string FirstName, LastName;
public Customer () { }
public Customer (XmlReader r) { ReadXml (r); }
public void ReadXml (XmlReader r)
{
if (r.MoveToAttribute ("id")) ID = r.ReadContentAsInt();
r.ReadStartElement();
FirstName = r.ReadElementContentAsString ("firstname", "");
LastName = r.ReadElementContentAsString ("lastname", "");
r.ReadEndElement();
}
public void WriteXml (XmlWriter w)
{
if (ID.HasValue) w.WriteAttributeString ("id", "", ID.ToString());
w.WriteElementString ("firstname", FirstName);
w.WriteElementString ("lastname", LastName);
}
}
注意,ReadXml读取外部的起始和结束元素节点。如果它的调用者执行这个工作,Customer无法读取自己的属性。不在WriteXml中对称处理的原因有两个:
-
调用者可能需要选择外部元素的命名方式。
-
调用者可能需要写入额外的 XML 属性,例如元素的subtype(然后可以用于在读回元素时决定实例化哪个类)。
遵循这种模式的另一个好处是,它使您的实现与IXmlSerializable兼容(我们在在线补充材料的“序列化”中介绍了这一点,网址为http://www.albahari.com/nutshell)。
Supplier类类似于Customer:
public class Supplier
{
public const string XmlName = "supplier";
public string Name;
public Supplier () { }
public Supplier (XmlReader r) { ReadXml (r); }
public void ReadXml (XmlReader r)
{
r.ReadStartElement();
Name = r.ReadElementContentAsString ("name", "");
r.ReadEndElement();
}
public void WriteXml (XmlWriter w) =>
w.WriteElementString ("name", Name);
}
对于Contacts类,我们必须在ReadXml中枚举customers元素,检查每个子元素是客户还是供应商。我们还需要编写代码来处理空元素的陷阱:
public void ReadXml (XmlReader r)
{
bool isEmpty = r.IsEmptyElement; // This ensures we don't get
r.ReadStartElement(); // snookered by an empty
if (isEmpty) return; // <contacts/> element!
while (r.NodeType == XmlNodeType.Element)
{
if (r.Name == Customer.XmlName) Customers.Add (new Customer (r));
else if (r.Name == Supplier.XmlName) Suppliers.Add (new Supplier (r));
else
throw new XmlException ("Unexpected node: " + r.Name);
}
r.ReadEndElement();
}
public void WriteXml (XmlWriter w)
{
foreach (Customer c in Customers)
{
w.WriteStartElement (Customer.XmlName);
c.WriteXml (w);
w.WriteEndElement();
}
foreach (Supplier s in Suppliers)
{
w.WriteStartElement (Supplier.XmlName);
s.WriteXml (w);
w.WriteEndElement();
}
}
下面是如何将填充了客户和供应商的Contacts对象序列化为 XML 文件:
var settings = new XmlWriterSettings();
settings.Indent = true; // To make visual inspection easier
using XmlWriter writer = XmlWriter.Create ("contacts.xml", settings);
var cts = new Contacts()
// Add Customers and Suppliers...
writer.WriteStartElement ("contacts");
cts.WriteXml (writer);
writer.WriteEndElement();
以下是如何从同一文件反序列化的方法:
var settings = new XmlReaderSettings();
settings.IgnoreWhitespace = true;
settings.IgnoreComments = true;
settings.IgnoreProcessingInstructions = true;
using XmlReader reader = XmlReader.Create("contacts.xml", settings);
reader.MoveToContent();
var cts = new Contacts();
cts.ReadXml(reader);
将 XmlReader/XmlWriter 与 X-DOM 混合使用
在 XML 树的任何点,当XmlReader或XmlWriter变得过于笨重时,您可以在 X-DOM 中飞行。使用 X-DOM 处理内部元素是将 X-DOM 的易用性与XmlReader和XmlWriter的低内存占用结合起来的绝佳方式。
使用 XmlReader 与 XElement
要将当前元素读入 X-DOM,您可以调用XNode.ReadFrom,将XmlReader传递给它。与XElement.Load不同,此方法不是“贪婪”的,它只读取当前子树的末尾。
例如,假设我们有一个结构化如下的 XML 日志文件:
<log>
<logentry id="1">
<date>...</date>
<source>...</source>
...
</logentry>
...
</log>
如果有一百万个logentry元素,将整个内容读入 X-DOM 将浪费内存。更好的解决方案是使用XmlReader遍历每个logentry,然后使用XElement逐个处理元素:
XmlReaderSettings settings = new XmlReaderSettings();
settings.IgnoreWhitespace = true;
using XmlReader r = XmlReader.Create ("logfile.xml", settings);
r.ReadStartElement ("log");
while (r.Name == "logentry")
{
XElement logEntry = (XElement) XNode.ReadFrom (r);
int id = (int) logEntry.Attribute ("id");
DateTime date = (DateTime) logEntry.Element ("date");
string source = (string) logEntry.Element ("source");
...
}
r.ReadEndElement();
如果您遵循前一节描述的模式,您可以将XElement插入到自定义类型的ReadXml或WriteXml方法中,而调用方不会知道您曾经欺骗过!例如,我们可以重新编写Customer的ReadXml方法,如下所示:
public void ReadXml (XmlReader r)
{
XElement x = (XElement) XNode.ReadFrom (r);
ID = (int) x.Attribute ("id");
FirstName = (string) x.Element ("firstname");
LastName = (string) x.Element ("lastname");
}
XElement与XmlReader合作,确保命名空间保持完整,并且前缀正确扩展——即使在外部级别定义。因此,如果我们的 XML 文件如下所示:
<log >
<logentry id="1">
...
我们在logentry级别构造的XElement将正确继承外部命名空间。
使用XElement和XmlWriter
您可以仅使用XElement将内部元素写入XmlWriter以及如何使用XElement将一百万logentry元素写入 XML 文件——而无需将整个元素存储在内存中:
using XmlWriter w = XmlWriter.Create ("logfile.xml");
w.WriteStartElement ("log");
for (int i = 0; i < 1000000; i++)
{
XElement e = new XElement ("logentry",
new XAttribute ("id", i),
new XElement ("date", DateTime.Today.AddDays (-1)),
new XElement ("source", "test"));
e.WriteTo (w);
}
w.WriteEndElement ();
使用XElement会产生最小的执行开销。如果我们在整个示例中使用XmlWriter进行修改,执行时间没有明显差异。
使用 JSON 工作
JSON 已成为 XML 的流行替代方案。虽然缺乏 XML 的高级功能(如命名空间、前缀和模式),但它简单、清晰,并且其格式类似于将 JavaScript 对象转换为字符串的格式。
历史上,.NET 没有内置对 JSON 的支持,您必须依赖第三方库,主要是 Json.NET。尽管现在情况不再如此,但 Json.NET 库因多种原因仍然很受欢迎:
-
它已经存在自 2011 年以来。
-
同一 API 也可在较旧的.NET 平台上运行。
-
在过去至少被认为更为功能性(在这一部分中)比微软的 JSON API。
微软的 JSON API 具有从头设计为简单和极其高效的优势。此外,从.NET 6 开始,它们的功能已经与 Json.NET 非常接近。
在这一部分中,我们涵盖了以下内容:
-
前向只读读取器和写入器(
Utf8JsonReader和Utf8JsonWriter) -
JsonDocument只读 DOM 读取器 -
JsonNode读写 DOM 读取器/写入器
在http://www.albahari.com/nutshell的在线补充部分中的“序列化”中,我们介绍了JsonSerializer,它可以自动将 JSON 序列化和反序列化为类。
Utf8JsonReader
System.Text.Json.Utf8JsonReader是针对 UTF-8 编码的 JSON 文本的优化前向只读器。在概念上,它类似于本章前面介绍的XmlReader,并且使用方式大致相同。
考虑以下名为people.json的 JSON 文件:
{
"FirstName":"Sara",
"LastName":"Wells",
"Age":35,
"Friends":["Dylan","Ian"]
}
大括号表示JSON 对象(其中包含"FirstName"和"LastName"等属性),而方括号表示JSON 数组(其中包含重复元素)。在本例中,重复元素是字符串,但它们可以是对象(或其他数组)。
下面的代码通过枚举其 JSON 令牌 解析文件。令牌可以是对象的开始或结束,数组的开始或结束,属性的名称,或者数组或属性的值(字符串、数字、true、false 或 null):
byte[] data = File.ReadAllBytes ("people.json");
Utf8JsonReader reader = new Utf8JsonReader (data);
while (reader.Read())
{
switch (reader.TokenType)
{
case JsonTokenType.StartObject:
Console.WriteLine ($"Start of object");
break;
case JsonTokenType.EndObject:
Console.WriteLine ($"End of object");
break;
case JsonTokenType.StartArray:
Console.WriteLine();
Console.WriteLine ($"Start of array");
break;
case JsonTokenType.EndArray:
Console.WriteLine ($"End of array");
break;
case JsonTokenType.PropertyName:
Console.Write ($"Property: {reader.GetString()}");
break;
case JsonTokenType.String:
Console.WriteLine ($" Value: {reader.GetString()}");
break;
case JsonTokenType.Number:
Console.WriteLine ($" Value: {reader.GetInt32()}");
break;
default:
Console.WriteLine ($"No support for {reader.TokenType}");
break;
}
}
这是输出:
Start of object
Property: FirstName Value: Sara
Property: LastName Value: Wells
Property: Age Value: 35
Property: Friends
Start of array
Value: Dylan
Value: Ian
End of array
End of object
因为 Utf8JsonReader 直接使用 UTF-8,所以它在遍历令牌时无需先将输入转换为 UTF-16(.NET 字符串的格式)。只有在调用 GetString() 等方法时才会进行 UTF-16 转换。
有趣的是,Utf8JsonReader 的构造函数不接受字节数组,而是接受 ReadOnlySpan<byte>(因此,Utf8JsonReader 被定义为 ref struct)。你可以传入一个字节数组,因为从 T[] 到 ReadOnlySpan<T> 有隐式转换。在 第二十三章 中,我们描述了 span 的工作原理以及如何通过减少内存分配来提高性能。
JsonReaderOptions
默认情况下,Utf8JsonReader 要求 JSON 严格符合 JSON RFC 8259 标准。您可以通过向 Utf8JsonReader 构造函数传递 JsonReaderOptions 实例来指示读取器更加宽容。选项允许以下操作:
C 风格的注释
默认情况下,JSON 中的注释会导致 JsonException 异常被抛出。将 CommentHandling 属性设置为 JsonCommentHandling.Skip 可以忽略注释,而 JsonCommentHandling.Allow 则使读取器识别它们,并在遇到时发出 JsonTokenType.Comment 令牌。注释不能出现在其他令牌中间。
尾随逗号
根据标准,对象的最后一个属性和数组的最后一个元素不能有尾随逗号。将 AllowTrailingCommas 属性设置为 e 可以放宽此限制。
控制最大嵌套深度
默认情况下,对象和数组可以嵌套到 64 层。将 MaxDepth 设置为其他数字会覆盖此设置。
Utf8JsonWriter
System.Text.Json.Utf8JsonWriter 是一个顺序写入的 JSON 写入器。它支持以下类型:
-
String和DateTime(格式化为 JSON 字符串) -
数值类型
Int32、UInt32、Int64、UInt64、Single、Double和Decimal(这些类型被格式化为 JSON 数字) -
bool(格式化为 JSON 的 true/false 字面值) -
JSON null
-
数组
您可以按照 JSON 标准将这些数据类型组织成对象。它还允许您写入注释,尽管注释不是 JSON 标准的一部分,但实际上 JSON 解析器通常支持。
以下代码演示了其用法:
var options = new JsonWriterOptions { Indented = true };
using (var stream = File.Create ("MyFile.json"))
using (var writer = new Utf8JsonWriter (stream, options))
{
writer.WriteStartObject();
// Property name and value specified in one call
writer.WriteString ("FirstName", "Dylan");
writer.WriteString ("LastName", "Lockwood");
// Property name and value specified in separate calls
writer.WritePropertyName ("Age");
writer.WriteNumberValue (46);
writer.WriteCommentValue ("This is a (non-standard) comment");
writer.WriteEndObject();
}
这会生成以下输出文件:
{
"FirstName": "Dylan",
"LastName": "Lockwood",
"Age": 46
/*This is a (non-standard) comment*/
}
从 .NET 6 开始,Utf8JsonWriter 具有 WriteRawValue 方法,用于直接将字符串或字节数组写入 JSON 流。这在特殊情况下非常有用,例如,如果希望始终包含小数点(1.0 而不是 1)。
在这个例子中,我们将 JsonWriterOptions 上的 Indented 属性设置为 true 以提高可读性。如果没有这样做,输出将如下所示:
{"FirstName":"Dylan","LastName":"Lockwood","Age":46...}
JsonWriterOptions 还具有 Encoder 属性以控制字符串的转义,以及 SkipValidation 属性以允许跳过结构验证检查(从而允许发出无效的输出 JSON)。
JsonDocument
System.Text.Json.JsonDocument 将 JSON 数据解析为只读 DOM,由按需生成的 JsonElement 实例组成。与 Utf8JsonReader 不同,JsonDocument 允许您随机访问元素。
JsonDocument 是两种基于 DOM 的用于处理 JSON 的 API 之一,另一种是 JsonNode(我们将在下一节中介绍)。JsonNode 在 .NET 6 中引入,主要是为了满足对可写 DOM 的需求。然而,在只读场景中它也很适用,并且提供了一种更为流畅的接口,支持传统的使用类表示 JSON 值、数组和对象的 DOM。相比之下,JsonDocument 极其轻量,仅包含一个值得注意的类 (JsonDocument) 和两个轻量级结构体 (JsonElement 和 JsonProperty),它们按需解析底层数据。差异可见于 Figure 11-1。
注意
在大多数实际场景中,JsonDocument 相对于 JsonNode 的性能优势微乎其微,因此如果您更喜欢学习单一的 API,可以直接跳转到 JsonNode。

图 11-1. JSON DOM APIs
警告
JsonDocument 还通过使用池化内存来最小化垃圾回收以提高其效率。这意味着您必须在使用后释放 JsonDocument;否则,其内存将不会返回到池中。因此,当一个类在字段中存储 JsonDocument 时,还必须实现 IDisposable 接口。如果这样做很繁琐,请考虑改用 JsonNode。
静态的 Parse 方法从流、字符串或内存缓冲区实例化 JsonDocument:
using JsonDocument document = JsonDocument.Parse (jsonString);
...
在调用 Parse 时,您可以选择提供 JsonDocumentOptions 对象以控制处理尾随逗号、注释和最大嵌套深度(有关这些选项的工作方式,请参见 “JsonReaderOptions”)。
然后,您可以通过 RootElement 属性访问 DOM:
using JsonDocument document = JsonDocument.Parse ("123");
JsonElement root = document.RootElement;
Console.WriteLine (root.ValueKind); // Number
JsonElement 可以表示 JSON 值(字符串、数字、true/false、null)、数组或对象;ValueKind 属性指示其类型。
注意
在接下来的几节中描述的方法中,如果元素不是预期的类型,则会抛出异常。如果不确定 JSON 文件的结构,可以通过先检查 ValueKind(或使用 TryGet* 方法)来避免此类异常。
JsonElement 还提供了适用于任何类型元素的两种方法:GetRawText() 返回内部的 JSON 数据,WriteTo 将该元素写入 Utf8JsonWriter。
读取简单值
如果元素表示 JSON 值,则可以通过调用 GetString、GetInt32、GetBoolean 等方法获取其值。
using JsonDocument document = JsonDocument.Parse ("123");
int number = document.RootElement.GetInt32();
JsonElement 还提供了将 JSON 字符串解析为其他常用 CLR 类型(如 DateTime 和甚至 base-64 二进制)的方法。还有 TryGet* 版本,如果解析失败,则不会抛出异常。
读取 JSON 数组
如果 JsonElement 表示一个数组,则可以调用以下方法:
EnumerateArray()
枚举 JSON 数组的所有子项(作为 JsonElement)。
GetArrayLength()
返回数组中的元素数量。
您还可以使用索引器返回特定位置的元素:
using JsonDocument document = JsonDocument.Parse (@"[1, 2, 3, 4, 5]");
int length = document.RootElement.GetArrayLength(); // 5
int value = document.RootElement[3].GetInt32(); // 4
读取 JSON 对象
如果元素表示 JSON 对象,则可以调用以下方法:
EnumerateObject()
枚举对象的所有属性名和值。
GetProperty (string propertyName)
通过名称获取属性(返回另一个 JsonElement)。如果名称不存在,则抛出异常。
TryGetProperty (string propertyName, out JsonElement value)
如果存在对象的属性,则返回该属性。
例如:
using JsonDocument document = JsonDocument.Parse (@"{ ""Age"": 32}");
JsonElement root = document.RootElement;
int age = root.GetProperty ("Age").GetInt32();
以下是我们如何“发现”Age属性的方式:
JsonProperty ageProp = root.EnumerateObject().First();
string name = ageProp.Name; // Age
JsonElement value = ageProp.Value;
Console.WriteLine (value.ValueKind); // Number
Console.WriteLine (value.GetInt32()); // 32
JsonDocument 和 LINQ
JsonDocument 非常适合于 LINQ。给定以下 JSON 文件:
[
{
"FirstName":"Sara",
"LastName":"Wells",
"Age":35,
"Friends":["Ian"]
},
{
"FirstName":"Ian",
"LastName":"Weems",
"Age":42,
"Friends":["Joe","Eric","Li"]
},
{
"FirstName":"Dylan",
"LastName":"Lockwood",
"Age":46,
"Friends":["Sara","Ian"]
}
]
我们可以使用 JsonDocument 和 LINQ 进行查询,如下所示:
using var stream = File.OpenRead (jsonPath);
using JsonDocument document = JsonDocument.Parse (json);
var query =
from person in document.RootElement.EnumerateArray()
select new
{
FirstName = person.GetProperty ("FirstName").GetString(),
Age = person.GetProperty ("Age").GetInt32(),
Friends =
from friend in person.GetProperty ("Friends").EnumerateArray()
select friend.GetString()
};
因为 LINQ 查询是惰性评估的,所以在文档超出范围和由 using 语句隐式处理的 JsonDocument 被释放之前,枚举查询是非常重要的。
使用 JSON writer 进行更新
虽然 JsonDocument 是只读的,但可以使用 WriteTo 方法将 JsonElement 的内容发送到 Utf8JsonWriter 中。这提供了一种机制,用于生成一个修改后的 JSON 版本。以下是如何从前面的示例中获取 JSON 并将其写入一个新的 JSON 文件,该文件只包含具有两个或更多朋友的人:
using var json = File.OpenRead (jsonPath);
using JsonDocument document = JsonDocument.Parse (json);
var options = new JsonWriterOptions { Indented = true };
using (var outputStream = File.Create ("NewFile.json"))
using (var writer = new Utf8JsonWriter (outputStream, options))
{
writer.WriteStartArray();
foreach (var person in document.RootElement.EnumerateArray())
{
int friendCount = person.GetProperty ("Friends").GetArrayLength();
if (friendCount >= 2)
person.WriteTo (writer);
}
}
然而,如果您需要更新 DOM 的能力,JsonNode 是一个更好的解决方案。
JsonNode
JsonNode(位于 System.Text.Json.Nodes 中)在 .NET 6 中引入,主要是为了满足可写 DOM 的需求。然而,在只读场景中它也很合适,并且提供了一个较为流畅的接口,支持传统的基于类的 DOM,用于表示 JSON 值、数组和对象(参见图 11-1)。作为类,它们会产生垃圾回收的开销,但在大多数实际场景中这可能是可以忽略不计的。JsonNode 仍然高度优化,并且在重复读取相同节点时实际上可能比 JsonDocument 更快(因为 JsonNode 虽然是惰性的,但缓存了解析结果)。
静态的 Parse 方法从流、字符串、内存缓冲区或 Utf8JsonReader 创建一个 JsonNode:
JsonNode node = JsonNode.Parse (jsonString);
在调用 Parse 方法时,您可以选择提供一个 JsonDocumentOptions 对象来控制尾随逗号、注释和最大嵌套深度的处理(关于这些选项的工作原理,请参见“JsonReaderOptions”)。与 JsonDocument 不同,JsonNode 不需要释放。
注意
在 JsonNode 上调用 ToString() 方法将返回一个人类可读的(缩进的)JSON 字符串。还有一个 ToJsonString() 方法,返回一个紧凑的 JSON 字符串。
从 .NET 8 开始,JsonNode 包含一个静态的 DeepEquals 方法,因此您可以在不先将其展开为 JSON 字符串的情况下比较两个 JsonNode 对象。从 .NET 8 还有一个 DeepClone 方法。
Parse 方法返回 JsonNode 的子类型,可能是 JsonValue、JsonObject 或 JsonArray。为了避免类型转换的混乱,JsonNode 提供了名为 AsValue()、AsObject() 和 AsArray() 的辅助方法:
var node = JsonNode.Parse ("123"); // Parses to a JsonValue
int number = node.AsValue().GetValue<int>();
// Shortcut for ((JsonValue)node).GetValue<int>();
然而,通常情况下您不需要调用这些方法,因为 JsonNode 类本身公开了最常用的成员:
var node = JsonNode.Parse ("123");
int number = node.GetValue<int>();
// Shortcut for node.AsValue().GetValue<int>();
读取简单值
我们刚刚看到,您可以通过使用类型参数调用 GetValue 方法来提取或解析简单值。为了使这更加简单,JsonNode 重载了 C# 的显式转换运算符,从而实现了以下快捷方式:
var node = JsonNode.Parse ("123");
int number = (int) node;
此功能适用于标准数值类型:char、bool、DateTime、DateTimeOffset 和 Guid(及其可空版本),以及 string。
如果不确定解析是否成功,需要使用以下代码:
if (node.AsValue().TryGetValue<int> (out var number))
Console.WriteLine (number);
从 .NET 8 开始,调用 node.GetValueKind() 将告诉您节点是字符串、数字、数组、对象还是 true/false。
注意
从 JSON 文本解析出来的节点在内部由 JsonElement 支持(它是 JsonDocument 只读 JSON API 的一部分)。您可以按以下方式提取底层的 JsonElement:
JsonElement je = node.GetValue<JsonElement>();
然而,当节点是显式实例化时(例如在更新 DOM 时),这种方法不起作用。这些节点不是由 JsonElement 支持,而是由实际解析的值支持(请参见“使用 JsonNode 进行更新”)。
读取 JSON 数组
表示 JSON 数组的 JsonNode 将是 JsonArray 类型。
JsonArray 实现了 IList<JsonNode> 接口,因此可以枚举它并像数组或列表一样访问元素:
var node = JsonNode.Parse (@"[1, 2, 3, 4, 5]");
Console.WriteLine (node.AsArray().Count); // 5
foreach (JsonNode child in node.AsArray())
{ ... }
作为快捷方式,您可以直接从 JsonNode 类中访问索引器:
Console.WriteLine ((int)node[0]); // 1
从 .NET 8 开始,还可以调用 GetValues<T> 方法将数据作为 IEnumerable<T> 返回:
int[] values = node.AsArray().GetValues<int>().ToArray();
读取 JSON 对象
表示 JSON 对象的 JsonNode 将是 JsonObject 类型。
JsonObject 实现了 IDictionary<string, JsonNode> 接口,因此可以通过索引器访问成员,并枚举字典的键/值对。
与 JsonArray 类似,您也可以直接从 JsonNode 类中访问索引器:
var node = JsonNode.Parse (@"{ ""Name"":""Alice"", ""Age"": 32}");
string name = (string) node ["Name"]; // Alice
int age = (int) node ["Age"]; // 32
以下是我们如何“发现”Name 和 Age 属性的方式:
// Enumerate over the dictionary’s key/value pairs:
foreach (KeyValuePair<string,JsonNode> keyValuePair in node.AsObject())
{
string propertyName = keyValuePair.Key; // "Name" (then "Age")
JsonNode value = keyValuePair.Value;
}
如果你不确定某个属性是否已经定义,以下模式也适用:
if (node.AsObject().TryGetPropertyValue ("Name", out JsonNode nameNode))
{ ... }
流畅遍历和 LINQ
你可以仅通过索引器深入到层次结构中。例如,给定以下 JSON 文件:
[
{
"FirstName":"Sara",
"LastName":"Wells",
"Age":35,
"Friends":["Ian"]
},
{
"FirstName":"Ian",
"LastName":"Weems",
"Age":42,
"Friends":["Joe","Eric","Li"]
},
{
"FirstName":"Dylan",
"LastName":"Lockwood",
"Age":46,
"Friends":["Sara","Ian"]
}
]
我们可以按以下方式提取第二个人的第三个朋友:
string li = (string) node[1]["Friends"][2];
通过 LINQ,对这样的文件进行查询也很容易:
JsonNode node = JsonNode.Parse (File.ReadAllText (jsonPath));
var query =
from person in node.AsArray()
select new
{
FirstName = (string) person ["FirstName"],
Age = (int) person ["Age"],
Friends =
from friend in person ["Friends"].AsArray()
select (string) friend
};
不像JsonDocument,JsonNode是不可释放的,所以我们不必担心在惰性枚举期间可能的释放问题。
使用 JsonNode 进行更新
JsonObject和JsonArray是可变的,因此你可以更新它们的内容。
用索引器来替换或添加JsonObject的属性是最简单的方法。在下面的示例中,我们将 Color 属性的值从“Red”改为“White”,并添加了一个名为“Valid”的新属性:
var node = JsonNode.Parse ("{ \"Color\": \"Red\" }");
node ["Color"] = "White";
node ["Valid"] = true;
Console.WriteLine (node.ToJsonString()); // {"Color":"White","Valid":true}
上述示例的第二行是以下代码的简写形式:
node ["Color"] = JsonValue.Create ("White");
与其为属性分配一个简单的值,你可以将其分配为JsonArray或JsonObject。(我们将在下一节中展示如何构建JsonArray和JsonObject实例。)
要删除一个属性,首先要转换为JsonObject(或调用AsObject),然后调用Remove方法:
node.AsObject().Remove ("Valid");
(JsonObject还公开了一个Add方法,如果属性已存在则会抛出异常。)
JsonArray也允许你使用索引器来替换项:
var node = JsonNode.Parse ("[1, 2, 3]");
node[0] = 10;
调用AsArray公开了Add/Insert/Remove/RemoveAt方法。在下面的示例中,我们移除数组中的第一个元素并在末尾添加一个元素:
var arrayNode = JsonNode.Parse ("[1, 2, 3]");
arrayNode.AsArray().RemoveAt(0);
arrayNode.AsArray().Add (4);
Console.WriteLine (arrayNode.ToJsonString()); // [2,3,4]
从.NET 8 开始,你还可以通过调用ReplaceWith来更新JsonNode:
var node = JsonNode.Parse ("{ \"Color\": \"Red\" }");
var color = node["Color"];
color.ReplaceWith ("Blue");
以编程方式构建 JsonNode DOM
JsonArray和JsonObject具有支持对象初始化语法的构造函数,这允许你在一个表达式中构建整个JsonNode DOM:
var node = new JsonArray
{
new JsonObject {
["Name"] = "Tracy",
["Age"] = 30,
["Friends"] = new JsonArray ("Lisa", "Joe")
},
new JsonObject {
["Name"] = "Jordyn",
["Age"] = 25,
["Friends"] = new JsonArray ("Tracy", "Li")
}
};
这将计算为以下 JSON:
[
{
"Name": "Tracy",
"Age": 30,
"Friends": ["Lisa", "Joe"]
},
{
"Name": "Jordyn",
"Age": 25,
"Friends": ["Tracy","Li"]
}
]
第十二章:处理与垃圾回收
一些对象需要显式的撤销代码来释放资源,例如打开的文件、锁、操作系统句柄和非托管对象。在.NET 术语中,这称为 处理,通过 IDisposable 接口支持。未使用对象占用的托管内存也必须在某个时候被回收;这个功能称为 垃圾回收,由 CLR 执行。
处理与垃圾回收的区别在于处理通常是显式启动的;垃圾回收则完全自动化。换句话说,程序员负责释放文件句柄、锁定和操作系统资源,而 CLR 负责释放内存。
本章讨论了处理和垃圾回收,还描述了 C#终结器及其提供处理备用的模式。最后,我们讨论了垃圾收集器的复杂性和其他内存管理选项。
IDisposable、Dispose 和 Close
.NET 为需要撤销方法的类型定义了一个特殊接口:
public interface IDisposable
{
void Dispose();
}
C#的 using 语句为实现 IDisposable 的对象调用 Dispose 提供了一种语法快捷方式,使用 try/finally 块:
using (FileStream fs = new FileStream ("myFile.txt", FileMode.Open))
{
// ... Write to the file ...
}
编译器将其转换为以下内容:
FileStream fs = new FileStream ("myFile.txt", FileMode.Open);
try
{
// ... Write to the file ...
}
finally
{
if (fs != null) ((IDisposable)fs).Dispose();
}
finally 块确保在抛出异常或提前退出代码块时仍调用 Dispose 方法。
同样,以下语法确保在 fs 超出范围时即时处理:
using FileStream fs = new FileStream ("myFile.txt", FileMode.Open);
// ... Write to the file ...
在简单场景中,编写自己的可处理类型只是实现 IDisposable 和编写 Dispose 方法的问题:
sealed class Demo : IDisposable
{
public void Dispose()
{
// Perform cleanup / tear-down.
...
}
}
注意
这种模式在简单情况下效果很好,并且适用于密封类。在“从终结器中调用 Dispose”中,我们描述了一种更复杂的模式,可以为忘记调用 Dispose 的消费者提供备用。对于未密封的类型,有理由从一开始就遵循后一种模式 —— 否则,如果子类型希望添加这样的功能,情况会变得非常混乱。
标准处理语义
.NET 遵循一套事实上的处理逻辑规则。这些规则与.NET 或 C#语言没有任何硬连接;它们的目的是为消费者定义一致的协议。以下是它们:
-
一旦对象已处理,就无法挽救。它无法重新激活,并且调用其方法或属性(除了
Dispose)会抛出ObjectDisposedException。 -
反复调用对象的
Dispose方法不会导致错误。 -
如果一次性对象 x “拥有”一次性对象 y,则 x 的
Dispose方法会自动调用 y 的Dispose方法 —— 除非另有指示。
在编写自己的类型时,这些规则也很有帮助,尽管它们不是强制性的。除了可能会因此而受到同事的反对外,没有什么可以阻止您编写“取消处理”方法!
根据第三条规则,容器对象会自动处理其子对象的释放。一个很好的例子是 Windows Forms 的容器控件,比如Form或Panel。容器可以承载许多子控件,但你不需要显式地释放每一个;关闭或释放父控件或窗体会照顾好所有的子控件。另一个例子是当你用DeflateStream包装一个FileStream时。释放DeflateStream也会释放FileStream——除非你在构造函数中另有指示。
关闭和停止
一些类型额外定义了一个叫做Close的方法,除了Dispose。.NET BCL 在Close方法的语义上并不完全一致,尽管在几乎所有情况下,它要么是以下两者之一:
-
功能上与
Dispose相同 -
Dispose的一个功能 子集
后者的一个例子是IDbConnection:一个Closed的连接可以重新打开;一个Dispose的连接不能。另一个例子是使用ShowDialog激活的 Windows Form:Close隐藏它;Dispose释放其资源。
一些类定义了一个Stop方法(例如Timer或HttpListener)。Stop方法可能释放非托管资源,像Dispose一样,但与Dispose不同的是,它允许重新启动。
何时进行释放
在几乎所有情况下,一个安全的规则是“有疑问就释放”。封装了非托管资源句柄的对象几乎总是需要释放才能释放该句柄。例如文件或网络流、网络套接字、Windows Forms 控件、GDI+的笔、画刷和位图。相反,如果一个类型是可释放的,它通常(但并不总是)会直接或间接地引用一个非托管句柄。这是因为非托管句柄为对象可以在未正确释放时在外部“世界”(如 OS 资源、网络连接和数据库锁)造成麻烦提供了入口。
然而,有三种情况不释放:
-
当你不“拥有”该对象时——例如通过静态字段或属性获取共享对象时
-
当对象的
Dispose方法执行了你不想要的操作时 -
当对象的
Dispose方法在设计上是不必要的时,并且释放该对象会给你的程序增加复杂性时
第一类别很少见。主要情况出现在System.Drawing命名空间中:通过静态字段或属性获取的 GDI+对象(例如Brushes.Blue)绝不能被释放,因为同一个实例在应用程序的整个生命周期内都在使用。然而通过构造函数获取的实例(例如new SolidBrush)应该被释放,像通过静态方法获取的实例(例如Font.FromHdc)也应该被释放。
第二类别更为常见。在System.IO和System.Data命名空间中有一些很好的例子:
| 类型 | 处置函数 | 何时不释放 |
|---|---|---|
MemoryStream |
防止进一步的 I/O | 当你以后需要读写流时 |
StreamReader, StreamWriter |
刷新读取器/写入器并关闭底层流 | 当你想保持底层流打开时(然后在完成后必须调用 Flush 在 StreamWriter 上) |
IDbConnection |
释放数据库连接并清除连接字符串 | 如果需要重新Open它,应该调用 Close 而不是 Dispose |
DbContext(EF Core) |
防止进一步使用 | 当可能有延迟评估查询连接到该上下文时 |
MemoryStream 的 Dispose 方法只禁用对象本身;它不执行任何关键的清理,因为 MemoryStream 不持有未托管的句柄或其他类似资源。
第三类包括诸如 StringReader 和 StringWriter 的类。这些类型是在其基类的压力下而不是通过真正需要执行基本清理时才能释放的。如果您恰好在一个方法中实例化和使用这样的对象,将其包装在 using 块中几乎没有什么不便。但是如果对象的寿命较长,跟踪其何时不再使用以便及时处置会增加不必要的复杂性。在这种情况下,可以简单地忽略对象的处置。
注意
忽略处理有时会导致性能成本(见“从终结器调用 Dispose”)。
清除处置中的字段
通常情况下,您不需要在对象的 Dispose 方法中清除对象的字段。然而,从对象在其生命周期内内部订阅的事件中取消订阅是一种良好的实践(例如,请参阅“托管内存泄漏”)。取消订阅这些事件可以防止接收到不需要的事件通知,并防止在垃圾回收器(GC)眼中无意中保持对象活动。
注意
Dispose 方法本身不会释放(托管)内存 —— 这只能通过垃圾回收(GC)来实现。
还值得一提的是,设置一个字段来指示对象已处置是很有必要的,这样如果消费者后来试图调用对象的成员,就可以抛出 ObjectDisposedException。一个好的模式是使用一个公共可读的自动属性来实现这一点:
public bool IsDisposed { get; private set; }
尽管在技术上不是必需的,但在 Dispose 方法中清除对象自身的事件处理程序(将它们设置为 null)也是一个好习惯。这样做可以消除在或之后事件触发的可能性。
偶尔,一个对象可能包含高价值的秘密,比如加密密钥。在这些情况下,在处理期间清除这些字段数据是有意义的(以避免当内存稍后释放到操作系统时,其他进程在机器上可能发现这些数据)。System.Security.Cryptography 中的 SymmetricAlgorithm 类正是通过在保存加密密钥的字节数组上调用 Array.Clear 来做到这一点。
匿名处理
有时,实现IDisposable是有用的,而不必编写一个类。例如,假设您希望在一个类上公开暂停和恢复事件处理的方法:
class Foo
{
int _suspendCount;
public void SuspendEvents() => _suspendCount++;
public void ResumeEvents() => _suspendCount--;
void FireSomeEvent()
{
if (_suspendCount == 0)
... fire some event ...
}
...
}
这样的 API 使用起来很笨拙。消费者必须记住调用ResumeEvents。并且为了健壮性,他们必须在finally块中执行此操作(以防抛出异常):
var foo = new Foo();
foo.SuspendEvents();
try
{
... do stuff ... // Because an exception could be thrown here
}
finally
{
foo.ResumeEvents(); // ...we must call this in a finally block
}
更好的模式是放弃ResumeEvents,而是让SuspendEvents返回一个IDisposable。消费者可以这样做:
using (foo.SuspendEvents())
{
... do stuff ...
}
这个问题是,这会将工作推给需要实现SuspendEvents方法的人。即使努力减少空白字符,我们最终还是会有额外的混乱:
public IDisposable SuspendEvents()
{
_suspendCount++;
return new SuspendToken (this);
}
class SuspendToken : IDisposable
{
Foo _foo;
public SuspendToken (Foo foo) => _foo = foo;
public void Dispose()
{
if (_foo != null) _foo._suspendCount--;
_foo = null; // Prevent against consumer disposing twice
}
}
匿名释放模式解决了这个问题。使用以下可重用类:
public class Disposable : IDisposable
{
public static Disposable Create (Action onDispose)
=> new Disposable (onDispose);
Action _onDispose;
Disposable (Action onDispose) => _onDispose = onDispose;
public void Dispose()
{
_onDispose?.Invoke(); // Execute disposal action if non-null.
_onDispose = null; // Ensure it can’t execute a second time.
}
}
我们可以将我们的SuspendEvents方法简化为以下内容:
public IDisposable SuspendEvents()
{
_suspendCount++;
return Disposable.Create (() => _suspendCount--);
}
自动垃圾收集
无论一个对象是否需要一个Dispose方法来进行自定义的拆卸逻辑,其在堆上占用的内存在某个时刻都必须被释放。CLR 完全自动地通过自动 GC 处理这一方面。您不需要自己释放托管内存。例如,考虑以下方法:
public void Test()
{
byte[] myArray = new byte[1000];
...
}
当Test执行时,会在内存堆上分配一个用于容纳 1000 字节的数组。该数组由存储在本地变量堆栈上的变量myArray引用。当方法退出时,此局部变量myArray超出作用域,意味着没有任何东西引用内存堆上的数组。然后,孤立的数组变得符合垃圾收集的条件。
注意
在调试模式下,关闭优化时,局部变量引用的对象的生命周期会延长到代码块的末尾,以便于调试。否则,在对象不再使用时,它会尽早成为可收集的对象。
对象孤立后,并不会立即进行垃圾收集。就像街上的垃圾收集一样,它是周期性进行的,尽管(不像街上的垃圾收集)没有固定的时间表。CLR 基于多种因素来决定何时进行收集,例如可用内存、内存分配量以及上次收集后的时间(GC 自动调整以优化应用程序特定的内存访问模式)。这意味着对象孤立和从内存中释放之间存在不确定的延迟。这种延迟可以从纳秒到几天不等。
注意
GC 并不会在每次收集时收集所有垃圾。相反,内存管理器将对象分为代,并且 GC 更频繁地收集新代(最近分配的对象),而不是老代(长期存在的对象)。我们将在“GC 工作原理”中详细讨论这个问题。
根
根是使对象保持活动的东西。如果一个对象不被直接或间接地根引用,它将符合垃圾收集的条件。
根是以下之一:
-
在执行方法中的局部变量或参数(或者在其调用堆栈中的任何方法)
-
静态变量
-
放在存储准备终结对象的队列上的对象(请参阅下一节)
已删除对象不可能执行代码,因此如果有任何实例方法可能执行,它的对象必须以某种方式通过引用。
请注意,循环引用的一组对象如果没有根引用(参见图 12-1),被视为已死亡。换句话说,无法通过从根对象跟随箭头(引用)访问的对象是不可达的,因此可能会被收集。

图 12-1. Roots
Finalizer
在释放对象之前,如果对象有 finalizer,则会运行它。Finalizer 的声明类似于构造函数,但前面加上 ˜ 符号:
class Test
{
˜Test()
{
// Finalizer logic...
}
}
(尽管在声明上与构造函数相似,finalizer 不能声明为公共或静态,不能有参数,并且不能调用基类。)
Finalizer 可能存在是因为垃圾回收工作在不同阶段。首先,GC 会识别出可以删除的未使用对象。那些没有 finalizer 的对象会立即删除。那些有待(未运行)finalizer 的对象会被保持活跃(暂时),并放入特殊队列中。
在那一刻,垃圾收集完成,你的程序继续执行。然后finalizer 线程开始并行运行,从特殊队列中拿出对象并运行它们的 finalization 方法。在每个对象的 finalizer 运行之前,它仍然是非常活跃的——那个队列充当了一个根对象。当它被出队并执行了 finalizer 后,该对象变成了孤儿,并将在下次收集(针对该对象的代)中被删除。
Finalizer 可能很有用,但有一些注意事项:
-
Finalizer 会减慢内存的分配和回收(GC 需要跟踪哪些 finalizer 已经运行)。
-
Finalizer 会延长对象及其引用对象的生命周期(它们都必须等待下一次垃圾收集以进行实际删除)。
-
不可能预测一组对象的 finalizer 将以何种顺序被调用。
-
对于对象的 finalizer 何时被调用,你的控制能力有限。
-
如果 finalizer 中的代码阻塞,其他对象将无法被终结。
-
如果应用程序无法干净卸载,可以完全避开 finalizer。
总之,finalizer 类似于律师——虽然有些情况确实需要它们,但通常情况下你不希望使用它们,除非绝对必要。如果你确实要使用它们,你需要百分之百地理解它们为你做了什么。
这里有一些实现 finalizer 的指导原则:
-
确保你的 finalizer 快速执行。
-
不要在你的 finalizer 中阻塞(参见“阻塞”)。
-
不要引用其他可终结的对象。
-
不要抛出异常。
注意
即使在构造函数期间抛出异常,CLR 也可以调用对象的终结器。因此,在编写终结器时,不要假设字段已经正确初始化。
从终结器中调用 Dispose
一个常见的模式是在终结器中调用 Dispose。当清理不紧急并且通过调用 Dispose 加快清理过程更多是一种优化而不是必要时,这是有道理的。
注意
请记住,使用此模式将内存释放与资源释放耦合在一起 —— 这两者可能有潜在的分歧(除非资源本身是内存)。同时也增加了终结线程的负担。
这种模式也作为一种备份用例存在,用于消费者简单地忘记调用 Dispose 的情况。但在这种情况下,最好记录失败,以便修复该错误。
实现这一点的标准模式如下所示:
class Test : IDisposable
{
public void Dispose() // NOT virtual
{
Dispose (true);
GC.SuppressFinalize (this); // Prevent finalizer from running.
}
protected virtual void Dispose (bool disposing)
{
if (disposing)
{
// Call Dispose() on other objects owned by this instance.
// You can reference other finalizable objects here.
// ...
}
// Release unmanaged resources owned by (just) this object.
// ...
}
~Test() => Dispose (false);
}
Dispose 方法被重载以接受 bool disposing 标志。无参数版本未声明为 virtual,并且简单地调用带有 true 参数的增强版本。
增强版本包含实际的处理逻辑,并且是 protected 和 virtual 的;这为子类添加其自己的处理逻辑提供了一个安全点。disposing 标志意味着它从 Dispose 方法中被“适当地”调用,而不是从终结器的“最后手段模式”中调用。其思想是,当以 disposing 设置为 false 调用时,该方法通常不应引用其他具有终结器的对象(因为这些对象可能已被终结,因此处于不可预测的状态)。这排除了很多情况!以下是 Dispose 方法在最后手段模式下仍然可以执行的几个任务:
-
释放所有 直接引用 的操作系统资源(可能通过调用 Win32 API 的 P/Invoke 调用获取)
-
删除在构造过程中创建的临时文件
为了使其更加健壮,任何可能抛出异常的代码都应包装在 try/catch 块中,并最好记录异常。任何日志记录应尽可能简单和健壮。
注意,在无参数的 Dispose 方法中调用了 GC.SuppressFinalize —— 这可以阻止垃圾回收稍后运行终结器。从技术上讲,这是不必要的,因为 Dispose 方法必须能够容忍重复调用。然而,这样做可以提高性能,因为它允许对象(及其引用的对象)在单个周期内被垃圾回收。
复活
假设一个终结器修改一个存活对象,使其引用回即将死亡的对象。当下次垃圾回收发生时(针对对象的代),CLR 将不再将先前的即将死亡对象视为孤立的 —— 因此它将逃避垃圾回收。这是一个高级场景,称为 复活。
举例说明,假设我们想编写一个管理临时文件的类。当该类的实例被垃圾收集时,我们希望终结器删除临时文件。听起来很容易:
public class TempFileRef
{
public readonly string FilePath;
public TempFileRef (string filePath) { FilePath = filePath; }
~TempFileRef() { File.Delete (FilePath); }
}
不幸的是,这里有一个 bug:File.Delete可能会抛出异常(例如,由于缺少权限,文件正在使用或已经被删除)。这样的异常会导致整个应用程序崩溃(并阻止其他终结器运行)。我们可以简单地通过空的 catch 块“吞噬”异常,但这样我们就不会知道出了什么问题。调用某些复杂的错误报告 API 也不可取,因为它会负担终结器线程,从而阻碍其他对象的垃圾收集。我们希望将最终化操作限制为简单、可靠和快速的操作。
更好的选择是将失败记录到静态集合中,如下所示:
public class TempFileRef
{
static internal readonly ConcurrentQueue<TempFileRef> FailedDeletions
= new ConcurrentQueue<TempFileRef>();
public readonly string FilePath;
public Exception DeletionError { get; private set; }
public TempFileRef (string filePath) { FilePath = filePath; }
~TempFileRef()
{
try { File.Delete (FilePath); }
catch (Exception ex)
{
DeletionError = ex;
FailedDeletions.Enqueue (this); // Resurrection
}
}
}
将对象加入静态FailedDeletions集合可以为对象再次提供一个引用,确保对象直到最终出列之前一直存活。
注意
ConcurrentQueue<T>是Queue<T>的线程安全版本,并且定义在System.Collections.Concurrent中(参见第二十二章)。使用线程安全集合有几个原因。首先,CLR 保留在多个线程并行执行终结器的权利。这意味着当访问共享状态(如静态集合)时,我们必须考虑同时终结两个对象的可能性。其次,我们迟早要从FailedDeletions中出列项目,以便我们可以采取措施处理它们。这也必须以线程安全的方式进行,因为它可能在终结器同时将另一个对象入队时发生。
GC.ReRegisterForFinalize
复活对象的终结器将不会第二次运行,除非您调用GC.ReRegisterForFinalize。
在下面的示例中,我们尝试在终结器中删除临时文件(与最后一个示例中一样)。但是,如果删除失败,我们会重新注册对象,以便在下次垃圾收集时再次尝试:
public class TempFileRef
{
public readonly string FilePath;
int _deleteAttempt;
public TempFileRef (string filePath) { FilePath = filePath; }
~TempFileRef()
{
try { File.Delete (FilePath); }
catch
{
if (_deleteAttempt++ < 3) GC.ReRegisterForFinalize (this);
}
}
}
第三次失败尝试后,我们的终结器将悄然放弃删除文件的尝试。我们可以通过将其与前面的示例结合起来来增强这一点,换句话说,在第三次失败后将其添加到FailedDeletions队列中。
警告
要小心在终结器方法中仅调用ReRegisterForFinalize一次。如果调用两次,对象将重新注册两次,并且将必须进行两次终结!
GC 的工作原理
标准 CLR 使用一种分代标记-压缩的 GC,为存储在托管堆上的对象执行自动内存管理。GC 被认为是一种跟踪 GC,因为它不会干扰对对象的每次访问,而是间歇性地唤醒并跟踪存储在托管堆上的对象图,以确定哪些对象可以被视为垃圾,因此可以被收集。
GC 在执行内存分配(通过 new 关键字)时启动垃圾收集,可能在分配了一定内存阈值后或其他时间以减少应用程序的内存占用。还可以通过调用 System.GC.Collect 方法手动启动此过程。在垃圾收集期间,所有线程都可以被冻结(更多信息见下一节)。
GC 从其根对象引用开始,并遍历对象图,标记所有接触到的对象为可达对象。当此过程完成时,所有未标记的对象被视为未使用的对象,并且可以进行垃圾收集。
没有 finalizer 的未使用对象会立即丢弃;带有 finalizer 的未使用对象会在 GC 完成后被加入到最终器线程的处理队列中。然后,这些对象在下一个 GC 中成为其代的可收集对象(除非被复活)。
剩余的“存活”对象然后被移动到堆的开始处(压缩),为更多对象释放空间。这种压缩有两个目的:防止内存碎片化,并允许 GC 在分配新对象时采用非常简单的策略,即始终在堆的末尾分配内存。这避免了维护空闲内存段列表可能耗时的任务。
如果在垃圾收集后没有足够的空间来分配新对象的内存,并且操作系统无法再分配更多内存,则会抛出 OutOfMemoryException 异常。
注意
您可以通过调用 GC.GetGCMemoryInfo() 获取有关托管堆当前状态的信息。从 .NET 5 开始,此方法已增强以返回与性能相关的数据。
优化技术
GC 结合了各种优化技术来减少垃圾收集时间。
分代收集
最重要的优化是 GC 是分代的。这利用了这样一个事实,即虽然许多对象被快速分配和丢弃,但某些对象具有长寿命,因此不需要在每次收集期间进行跟踪。
基本上,GC 将托管堆分为三代。刚刚分配的对象位于 Gen0,经过一次收集周期后存活的对象位于 Gen1;所有其他对象位于 Gen2。Gen0 和 Gen1 被称为短暂(短寿)代。
CLR 将 Gen0 部分保持相对较小(典型大小为几百 KB 到几 MB)。当 Gen0 部分填满时,GC 会发起 Gen0 集合—这种情况相对频繁发生。GC 对 Gen1 也应用类似的内存阈值(作为 Gen2 的缓冲区),因此 Gen1 集合也相对快速和频繁。然而,包括 Gen2 的完整集合需要更长时间,因此不经常发生。图 12-2 显示了完整集合的效果。

图 12-2. 堆代
给出一些非常粗略的估算,Gen0 集合可能不到 1 毫秒,这在典型应用程序中不足以被注意到。然而,对于具有大对象图的程序,完整集合可能需要长达 100 毫秒。这些数字取决于许多因素,因此可能会有很大变化—尤其是对于大小不受限制(与 Gen0 和 Gen1 不同)的 Gen2 而言。
要点是,短寿命对象在 GC 的使用效率方面非常高。在以下方法中创建的StringBuilder几乎可以肯定会在快速的 Gen0 集合中被回收:
string Foo()
{
var sb1 = new StringBuilder ("test");
sb1.Append ("...");
var sb2 = new StringBuilder ("test");
sb2.Append (sb1.ToString());
return sb2.ToString();
}
大对象堆
GC 使用称为大对象堆(LOH)的单独堆来存储大于某个阈值(目前为 85,000 字节)的对象。这可以避免大对象的压缩成本,并防止过多的 Gen0 集合—没有 LOH 的话,分配一系列 16 MB 对象可能会在每次分配后触发 Gen0 集合。
默认情况下,LOH 不会经过压缩,因为在垃圾收集期间移动大内存块将是极其昂贵的。这有两个后果:
-
分配可能会更慢,因为 GC 不能总是简单地在堆的末尾分配对象—它还必须在中间寻找间隙,这需要维护一个空闲内存块的链表。¹
-
LOH 会受到碎片化的影响。这意味着释放对象可能会在 LOH 中留下一个难以填补的空洞。例如,由 86,000 字节对象留下的空洞只能通过 85,000 字节到 86,000 字节之间的对象填补(除非与另一个空洞相邻)。
如果您预计会出现碎片问题,可以指示 GC 在下次收集时压缩 LOH,方法如下:
GCSettings.LargeObjectHeapCompactionMode =
GCLargeObjectHeapCompactionMode.CompactOnce;
如果您的程序频繁分配大数组,另一个解决方法是使用.NET 的数组池 API(参见“数组池”)。
LOH 也是非代数的:所有对象都被视为 Gen2。
工作站与服务器集合
.NET 提供了两种垃圾收集模式:工作站和服务器。工作站是默认值;您可以通过在应用程序的.csproj文件中添加以下内容来切换到服务器:
<PropertyGroup>
<ServerGarbageCollection>true</ServerGarbageCollection>
</PropertyGroup>
在构建项目时,这些设置会被写入应用程序的.runtimeconfig.json文件中,CLR 会从中读取:
"runtimeOptions": {
"configProperties": {
"System.GC.Server": true
...
启用服务器收集后,CLR 为每个核分配单独的堆和 GC。这加速了收集过程,但消耗了额外的内存和 CPU 资源(因为每个核需要自己的线程)。如果机器上运行了许多其他启用了服务器收集的进程,这可能导致 CPU 过度订阅,特别是在工作站上,这会使整个操作系统感觉不响应。
服务器收集仅在多核系统上可用:在单核设备(或单核虚拟机)上,该设置将被忽略。
后台收集
在工作站模式和服务器模式中,CLR 默认启用后台收集。您可以通过将以下内容添加到应用程序的.csproj文件中来禁用它:
<PropertyGroup>
<ConcurrentGarbageCollection>false</ConcurrentGarbageCollection>
</PropertyGroup>
在构建时,此设置被写入应用程序的.runtimeconfig.json文件:
"runtimeOptions": {
"configProperties": {
"System.GC.Concurrent": false,
...
GC 必须在收集期间冻结(阻塞)执行线程。后台收集减少了这些延迟期间的时间,使您的应用程序更具响应性。但这是以稍微增加 CPU 和内存消耗为代价的。因此,通过禁用后台收集,您可以实现以下效果:
-
稍微减少 CPU 和内存使用
-
增加垃圾收集发生时的暂停(或延迟)
背景收集通过允许应用程序代码与 Gen2 收集并行运行来工作。(Gen0 和 Gen1 收集被认为足够快速,不会从这种并行性中受益。)
背景收集是以前称为并发收集的改进版本:它消除了并发收集的限制,即如果 Gen0 区段在 Gen2 收集运行时填满,那么并发收集将停止并发。这使得持续分配内存的应用程序能够更具响应性。
GC 通知
如果禁用后台收集,您可以要求 GC 在进行全(阻塞)收集之前通知您。这适用于服务器农场配置:其想法是您在收集发生之前将请求重定向到另一台服务器。然后立即启动收集并等待其完成,然后再将请求重新路由回该服务器。
要启动通知,请调用GC.RegisterForFullGCNotification。然后,启动另一个线程(参见第十四章),首先调用GC.WaitForFullGCApproach。当此方法返回指示接近收集的GCNotificationStatus时,您可以将请求重新路由到其他服务器并强制手动收集(参见下一节)。然后调用GC.WaitForFullGCComplete:当此方法返回时,收集完成,您可以再次接受请求。然后重复整个周期。
强制垃圾收集
您可以随时通过调用GC.Collect手动强制进行垃圾收集。调用GC.Collect而不带参数会启动完整收集。如果传递一个整数值,只会收集到该值的代数,因此GC.Collect(0)仅执行快速的 Gen0 收集。
通常情况下,通过允许 GC 自行决定何时收集来获得最佳性能:强制收集可能会通过不必要地将 Gen0 对象提升为 Gen1(以及 Gen1 对象提升为 Gen2)来损害性能。它还可能会扰乱 GC 的自我调整能力,即 GC 在应用程序执行时动态调整每代的阈值以最大化性能。
然而,也存在例外情况。干预最常见的情况是应用程序暂时休眠:一个很好的例子是执行每日活动的 Windows 服务(例如检查更新)。这样的应用程序可能会使用System.Timers.Timer每 24 小时启动一次活动。完成活动后,接下来的 24 小时内不会执行进一步的代码,这意味着在此期间不会进行内存分配,因此垃圾回收器无法激活。服务在执行其活动时消耗的内存将继续在接下来的 24 小时内保持不变——即使对象图为空!解决方法是在每日活动完成后立即调用GC.Collect。
为确保收集那些由于终结器延迟而延迟收集的对象,请额外调用WaitForPendingFinalizers并重新收集:
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
通常情况下,这是通过循环完成的:运行终结器的操作可能会释放更多本身具有终结器的对象。
调用GC.Collect的另一个情况是测试具有终结器的类时。
在运行时调整垃圾回收
静态GCSettings.LatencyMode属性确定 GC 如何在延迟和整体效率之间进行平衡。将其从默认值Interactive更改为LowLatency或SustainedLowLatency指示 CLR 偏向更快(但更频繁)的收集。如果您的应用程序需要对实时事件非常快速响应,则这是有用的。将模式更改为Batch以牺牲响应速度最大化吞吐量,这对于批处理处理非常有用。
如果在.runtimeconfig.json文件中禁用后台收集,则不支持SustainedLowLatency。
您还可以通过调用GC.TryStartNoGCRegion暂时暂停垃圾收集器,并通过GC.EndNoGCRegion恢复。
内存压力
运行时根据多种因素(包括机器上的总内存负载)决定何时启动集合。如果您的程序分配了非托管内存(第二十四章),运行时将对其内存使用有一个不切实际的乐观看法,因为 CLR 仅了解托管内存。您可以通过调用GC.AddMemoryPressure指示 CLR假设分配了指定数量的非托管内存来缓解这一问题。在释放非托管内存时,调用GC.RemoveMemoryPressure以撤消此操作。
数组池
如果您的应用程序频繁实例化数组,您可以通过数组池避免大部分垃圾收集开销。数组池在.NET Core 3 中引入,通过“租用”一个数组,然后将其返回到池中以供重复使用。
要分配一个数组,请在System.Buffers命名空间中的ArrayPool类上调用Rent方法,指定您想要的数组大小:
int[] pooledArray = ArrayPool<int>.Shared.Rent (100); // 100 bytes
这将从全局共享数组池中分配至少 100 字节的数组。池管理器可能会提供一个比您请求的更大的数组(通常以 2 的幂分配)。
当您完成数组的使用后,请调用Return:这将释放数组到池中,允许再次租用同一数组:
ArrayPool<int>.Shared.Return (pooledArray);
您可以选择传递一个布尔值,指示池管理器在将数组返回给池之前清除数组。
警告
数组池的一个限制是,在将数组返回后,没有任何机制阻止您继续(非法)使用数组,因此需要小心编码以避免此情况。请记住,您不仅有可能破坏自己的代码,还有可能破坏使用数组池的其他 API,例如 ASP.NET Core。
与使用共享数组池不同,您可以创建一个自定义池并从中租用。这样可以避免破坏其他 API 的风险,但会增加总体内存使用量(因为减少了重用的机会):
var myPool = ArrayPool<int>.Create();
int[] array = myPool.Rent (100);
...
托管内存泄漏
在像 C++这样的非托管语言中,当对象不再需要时,必须记住手动释放内存;否则将导致内存泄漏。在托管世界中,由于 CLR 的自动垃圾回收系统,这种错误是不可能发生的。
尽管如此,大型和复杂的.NET 应用程序可能会展示出同样综合症的较轻形式,结果也相同:应用程序在其生命周期内消耗越来越多的内存,直到最终必须重新启动。好消息是,托管内存泄漏通常更容易诊断和预防。
未管理的内存泄漏是由于未使用的对象通过未使用或遗忘的引用仍然存活造成的。常见的候选对象是事件处理程序——除非目标是静态方法,否则这些处理程序将持有对目标对象的引用。例如,考虑以下类:
class Host
{
public event EventHandler Click;
}
class Client
{
Host _host;
public Client (Host host)
{
_host = host;
_host.Click += HostClicked;
}
void HostClicked (object sender, EventArgs e) { ... }
}
下面的测试类包含一个实例化 1,000 个客户端的方法:
class Test
{
static Host _host = new Host();
public static void CreateClients()
{
Client[] clients = Enumerable.Range (0, 1000)
.Select (i => new Client (_host))
.ToArray();
// Do something with clients ...
}
}
你可能期望在 CreateClients 执行完毕后,这 1000 个 Client 对象将变得可以收集。不幸的是,每个客户端还有另一个引用者: _host 对象,其 Click 事件现在引用每个 Client 实例。如果 Click 事件不触发,或者 HostClicked 方法没有做任何吸引注意力的事情,这可能不会被注意到。
解决方法之一是让 Client 实现 IDisposable 并在 Dispose 方法中取消事件处理程序的挂钩:
public void Dispose() { _host.Click -= HostClicked; }
当 Client 的消费者使用完实例后进行处理:
Array.ForEach (clients, c => c.Dispose());
注意
在 “弱引用” 中,我们描述了另一种解决这个问题的方法,这在不倾向于使用一次性对象的环境中可能很有用(例如 Windows Presentation Foundation [WPF])。事实上,WPF 提供了一个名为 WeakEventManager 的类,使用一种利用弱引用的模式。
定时器
遗忘的定时器也会导致内存泄漏(我们在 第二十一章 中讨论定时器)。这取决于定时器的类型,有两种不同的场景。首先看看 System.Timers 命名空间中的定时器。在以下示例中,当实例化 Foo 类时,它每秒调用一次 tmr_Elapsed 方法:
using System.Timers;
class Foo
{
Timer _timer;
Foo()
{
_timer = new System.Timers.Timer { Interval = 1000 };
_timer.Elapsed += tmr_Elapsed;
_timer.Start();
}
void tmr_Elapsed (object sender, ElapsedEventArgs e) { ... }
}
不幸的是,Foo 的实例永远无法进行垃圾回收!问题在于运行时本身保持对活动定时器的引用,以便触发它们的 Elapsed 事件;因此:
-
运行时将保持
_timer的活动状态。 -
通过
tmr_Elapsed事件处理程序,_timer将保持Foo实例的活动状态。
当你意识到 Timer 实现了 IDisposable 时,解决方案就显而易见了。释放定时器将停止它,并确保运行时不再引用该对象:
class Foo : IDisposable
{
...
public void Dispose() { _timer.Dispose(); }
}
注意
一个良好的准则是,如果类中的任何字段分配了实现 IDisposable 的对象,则自己实现 IDisposable。
关于刚才讨论的内容,WPF 和 Windows Forms 的定时器行为方式相同。
然而,位于 System.Threading 命名空间中的定时器是特殊的。.NET 不会保持对活动线程定时器的引用;相反,它直接引用回调委托。这意味着如果你忘记释放线程定时器,将会触发一个终结器,自动停止和释放定时器:
static void Main()
{
var tmr = new System.Threading.Timer (TimerTick, null, 1000, 1000);
GC.Collect();
System.Threading.Thread.Sleep (10000); // Wait 10 seconds
}
static void TimerTick (object notUsed) { Console.WriteLine ("tick"); }
如果此示例在“发布”模式下编译(禁用调试并启用优化),则定时器将在其有机会触发一次之前被收集和完成!同样,我们可以在使用完定时器后通过释放来修复这个问题:
using (var tmr = new System.Threading.Timer (TimerTick, null, 1000, 1000))
{
GC.Collect();
System.Threading.Thread.Sleep (10000); // Wait 10 seconds
}
在 using 块结束时隐式调用 tmr.Dispose 确保 tmr 变量在块结束之前被“使用”,因此 GC 不会将其视为死对象。讽刺的是,这个 Dispose 调用实际上使对象的生命周期更长!
诊断内存泄漏
避免托管内存泄漏的最简单方法是在编写应用程序时主动监视内存消耗。您可以通过以下方式获取程序对象的当前内存消耗(true 参数告诉 GC 首先执行一次收集):
long memoryUsed = GC.GetTotalMemory (true);
如果您正在实践测试驱动开发,一个可能的方法是使用单元测试来断言内存如预期地被回收。如果这样的断言失败,您只需要检查最近所做的更改。
如果您已经有一个存在托管内存泄漏的大型应用程序,windbg.exe工具可以帮助找到它。还有一些更友好的图形工具,如 Microsoft 的 CLR Profiler、SciTech 的 Memory Profiler 和 Red Gate 的 ANTS Memory Profiler。
CLR 还公开了许多事件计数器来帮助进行资源监视。
弱引用
有时,持有一个对 GC“不可见”的对象的引用是有用的,从而使对象保持活动状态。这称为弱引用,由System.WeakReference类实现。
要使用WeakReference,请使用目标对象构造它:
var sb = new StringBuilder ("this is a test");
var weak = new WeakReference (sb);
Console.WriteLine (weak.Target); // This is a test
如果目标仅由一个或多个弱引用引用,则 GC 将考虑目标对象可用于收集。当目标对象被收集时,WeakReference的Target属性将为 null:
var weak = GetWeakRef();
GC.Collect();
Console.WriteLine (weak.Target); // (nothing)
WeakReference GetWeakRef () =>
new WeakReference (new StringBuilder ("weak"));
为了防止目标在测试其是否为 null 和使用它之间被收集,请将目标分配给局部变量:
var sb = (StringBuilder) weak.Target;
if (sb != null) { /* Do something with sb */ }
当将目标分配给局部变量时,它有一个强根,因此在使用该变量时不能收集它。
以下类使用弱引用来跟踪所有已实例化的Widget对象,而不会阻止这些对象被收集:
class Widget
{
static List<WeakReference> _allWidgets = new List<WeakReference>();
public readonly string Name;
public Widget (string name)
{
Name = name;
_allWidgets.Add (new WeakReference (this));
}
public static void ListAllWidgets()
{
foreach (WeakReference weak in _allWidgets)
{
Widget w = (Widget)weak.Target;
if (w != null) Console.WriteLine (w.Name);
}
}
}
在这样的系统中唯一的注意事项是静态列表会随着时间的推移而增长,积累具有空目标的弱引用。因此,您需要实施一些清理策略。
弱引用和缓存
WeakReference的一个用途是缓存大型对象图。这允许将内存密集型数据短暂缓存,而不会导致过多的内存消耗:
_weakCache = new WeakReference (...); // _weakCache is a field
...
var cache = _weakCache.Target;
if (cache == null) { /* Re-create cache & assign it to _weakCache */ }
在实践中,这种策略可能只能起到轻微的效果,因为您无法控制垃圾收集器何时触发以及选择收集哪一代。特别是,如果您的缓存仍然在 Gen0 中,它可以在微秒内被收集(请记住,垃圾收集器不仅在内存不足时收集——在正常内存条件下它定期进行收集)。因此,至少,您应该采用两级缓存的方法,即首先使用强引用,随着时间的推移将其转换为弱引用。
弱引用和事件
我们之前看到事件如何导致托管内存泄漏。最简单的解决方案是要么避免在这种情况下订阅,要么实现一个Dispose方法来取消订阅。弱引用提供了另一种解决方案。
想象一个仅持有其目标弱引用的委托。这样的委托不会保持其目标的生存——除非这些目标有独立的裁判。当然,这并不能防止一个激活的委托击中一个未引用的目标——在目标符合回收条件并且 GC 赶上之前的时间内。为了使这样的解决方案有效,您的代码必须在这种情况下表现稳健。假设情况如此,您可以按以下方式实现弱委托类:
public class WeakDelegate<TDelegate> where TDelegate : Delegate
{
class MethodTarget
{
public readonly WeakReference Reference;
public readonly MethodInfo Method;
public MethodTarget (Delegate d)
{
// d.Target will be null for static method targets:
if (d.Target != null) Reference = new WeakReference (d.Target);
Method = d.Method;
}
}
List<MethodTarget> _targets = new List<MethodTarget>();
public void Combine (TDelegate target)
{
if (target == null) return;
foreach (Delegate d in (target as Delegate).GetInvocationList())
_targets.Add (new MethodTarget (d));
}
public void Remove (TDelegate target)
{
if (target == null) return;
foreach (Delegate d in (target as Delegate).GetInvocationList())
{
MethodTarget mt = _targets.Find (w =>
Equals (d.Target, w.Reference?.Target) &&
Equals (d.Method.MethodHandle, w.Method.MethodHandle));
if (mt != null) _targets.Remove (mt);
}
}
public TDelegate Target
{
get
{
Delegate combinedTarget = null;
foreach (MethodTarget mt in _targets.ToArray())
{
WeakReference wr = mt.Reference;
// Static target || alive instance target
if (wr == null || wr.Target != null)
{
var newDelegate = Delegate.CreateDelegate (
typeof(TDelegate), wr?.Target, mt.Method);
combinedTarget = Delegate.Combine (combinedTarget, newDelegate);
}
else
_targets.Remove (mt);
}
return combinedTarget as TDelegate;
}
set
{
_targets.Clear();
Combine (value);
}
}
}
在Combine和Remove方法中,我们通过as运算符而不是更常见的强制类型转换,执行从target到Delegate的引用转换。这是因为 C#不允许在这种类型的参数上使用强制类型转换操作符——由于自定义转换和引用转换之间可能存在的歧义。
然后我们调用GetInvocationList,因为这些方法可能会使用多播委托——即具有多个方法接收者的委托。
在Target属性中,我们构建了一个组合所有由活动目标的弱引用引用的委托的多播委托,从列表中移除剩余的(死亡的)引用,以防止_targets列表无限增长。(我们可以通过在Combine方法中执行相同操作来改进我们的类;另一个改进是为了线程安全添加锁 参见[“锁和线程安全”]。)我们还允许没有任何弱引用的委托;这些代表其目标为静态方法的委托。
下面说明了如何在实现事件时消费此委托:
public class Foo
{
WeakDelegate<EventHandler> _click = new WeakDelegate<EventHandler>();
public event EventHandler Click
{
add { _click.Combine (value); } remove { _click.Remove (value); }
}
protected virtual void OnClick (EventArgs e)
=> _click.Target?.Invoke (this, e);
}
¹ 在分代堆中由于固定(参见“fixed 语句”]),同样的情况有时也会发生。
第十三章:诊断
当出现问题时,重要的是有信息可用以帮助诊断问题。集成开发环境(IDE)或调试器可以极大地帮助此过程,但通常仅在开发期间可用。应用程序发布后,必须收集和记录诊断信息。为了满足此要求,.NET 提供了一组设施来记录诊断信息,监视应用程序行为,检测运行时错误,并在可能时与调试工具集成。
一些诊断工具和 API 是特定于 Windows 的,因为它们依赖于 Windows 操作系统的功能。为了防止平台特定的 API 混乱.NET BCL,微软已经将它们封装在单独的 NuGet 包中,您可以选择性地引用它们。有超过一打的特定于 Windows 的包,您可以使用 Microsoft.Windows.Compatibility “主” 包一次引用它们。
本章中的类型主要在 System.Diagnostics 命名空间中定义。
条件编译
您可以使用预处理指令在 C# 中有条件地编译任何代码段。预处理指令是以 # 符号开头的特殊指令(与其他 C# 结构不同,必须单独出现在一行上)。逻辑上,在主编译之前执行它们(尽管在实践中,编译器在词法分析阶段处理它们)。用于条件编译的预处理指令包括 #if、#else、#endif 和 #elif。
#if 指令指示编译器在指定的符号已被定义时忽略代码段。您可以通过使用 #define 指令在源代码中定义符号(在这种情况下,符号仅适用于该文件),或者在 .csproj 文件中使用 <DefineConstants> 元素(在这种情况下,符号适用于整个程序集):
#define TESTMODE // #define directives must be at top of file
// Symbol names are uppercase by convention.
using System;
class Program
{
static void Main()
{
#if TESTMODE
Console.WriteLine ("in test mode!"); // OUTPUT: in test mode!
#endif
}
}
如果我们删除第一行,则程序将编译,Console.WriteLine 语句将完全从可执行文件中删除,就像被注释掉一样。
#else 语句类似于 C# 的 else 语句,而 #elif 相当于 #else 后跟 #if。||、&& 和 ! 运算符执行或、与 和 非 操作:
#if TESTMODE && !PLAYMODE // if TESTMODE and not PLAYMODE
...
但请记住,您并不是在构建普通的 C# 表达式,您操作的符号与变量(静态或其他)没有任何关联。
可以通过编辑 .csproj 文件(或在 Visual Studio 中,在项目属性窗口的“生成”选项卡中)定义适用于程序集中每个文件的符号。以下定义了两个常量,TESTMODE 和 PLAYMODE:
<PropertyGroup>
<DefineConstants>TESTMODE;PLAYMODE</DefineConstants>
</PropertyGroup>
如果您在程序集级别定义了一个符号,然后希望在特定文件中“取消定义”它,可以使用 #undef 指令。
条件编译与静态变量标志对比
您可以使用简单的静态字段来实现前面的示例:
static internal bool TestMode = true;
static void Main()
{
if (TestMode) Console.WriteLine ("in test mode!");
}
这具有允许运行时配置的优点。那么,为什么选择条件编译?原因在于条件编译可以带你到变量标志无法到达的地方,比如以下情况:
-
条件包含属性
-
更改变量的声明类型
-
在
using指令中切换不同的命名空间或类型别名;例如:using TestType = #if V2 MyCompany.Widgets.GadgetV2; #else MyCompany.Widgets.Gadget; #endif
甚至可以在条件编译指令下执行重大重构,因此您可以即时在旧版和新版之间切换,并编写可以针对多个运行时版本进行编译的库,在可用时利用最新功能。
条件编译的另一个优点是调试代码可以引用部署中未包含的程序集中的类型。
条件属性
Conditional属性指示编译器忽略对特定类或方法的所有调用,如果指定的符号未被定义。
要看到这对您有多有用,请假设您编写了一个用于记录状态信息的方法,如下所示:
static void LogStatus (string msg)
{
string logFilePath = ...
System.IO.File.AppendAllText (logFilePath, msg + "\r\n");
}
现在想象一下,您只希望在定义了LOGGINGMODE符号时执行此操作。第一种解决方案是将对LogStatus的所有调用都包装在#if指令中:
#if LOGGINGMODE
LogStatus ("Message Headers: " + GetMsgHeaders());
#endif
这样可以得到理想的结果,但是这很繁琐。第二种解决方案是将#if指令放在LogStatus方法内部。然而,如果以以下方式调用LogStatus,这将会有问题:
LogStatus ("Message Headers: " + GetComplexMessageHeaders());
GetComplexMessageHeaders将始终被调用,这可能会带来性能损失。
我们可以通过将Conditional属性(定义在System.Diagnostics中)附加到LogStatus方法来将第一种解决方案的功能与第二种解决方案的便利性结合起来:
[Conditional ("LOGGINGMODE")]
static void LogStatus (string msg)
{
...
}
这会指示编译器将对LogStatus的调用视为包含在#if LOGGINGMODE指令中。如果未定义该符号,则在编译中将完全消除对LogStatus的任何调用,包括它们的参数评估表达式。(因此,任何具有副作用的表达式将被跳过。)即使LogStatus和调用方位于不同的程序集中,这也适用。
注意
[Conditional]的另一个好处是条件检查是在调用方编译时进行的,而不是在被调用方法编译时进行的。这很有益,因为它允许您编写包含诸如LogStatus之类方法的库,并且只构建该库的一个版本。
Conditional属性在运行时被忽略——它纯粹是对编译器的指示。
条件属性的替代方案
如果需要在运行时动态启用或禁用功能,则Conditional属性无效:相反,必须使用基于变量的方法。这留下了在调用条件日志方法时如何优雅地避免参数评估的问题。函数方法可以解决这个问题:
using System;
using System.Linq;
class Program
{
public static bool EnableLogging;
static void LogStatus (Func<string> message)
{
string logFilePath = ...
if (EnableLogging)
System.IO.File.AppendAllText (logFilePath, message() + "\r\n");
}
}
使用 lambda 表达式可以在不增加语法复杂性的情况下调用此方法:
LogStatus ( () => "Message Headers: " + GetComplexMessageHeaders() );
如果EnableLogging为false,则GetComplexMessageHeaders永远不会被评估。
调试和跟踪类
Debug和Trace是提供基本日志记录和断言能力的静态类。这两个类非常相似;主要区别在于它们的预期用途。Debug类用于调试版本;Trace类用于调试和发布版本。为此:
All methods of the Debug class are defined with [Conditional("DEBUG")].
All methods of the Trace class are defined with [Conditional("TRACE")].
这意味着,除非定义了DEBUG或TRACE符号,否则编译器将消除对Debug或Trace的所有调用(Visual Studio 在项目属性的构建选项卡中提供了定义这些符号的复选框,并默认使用新项目启用 TRACE 符号)。
Debug和Trace类都提供Write、WriteLine和WriteIf方法。默认情况下,这些方法将消息发送到调试器的输出窗口:
Debug.Write ("Data");
Debug.WriteLine (23 * 34);
int x = 5, y = 3;
Debug.WriteIf (x > y, "x is greater than y");
Trace类还提供TraceInformation、TraceWarning和TraceError方法。这些方法与Write方法的行为差异取决于活动的TraceListener(我们在TraceListener中介绍了这一点)。
失败和断言
Debug和Trace类都提供Fail和Assert方法。Fail将消息发送给Debug或Trace类的Listeners集合中的每个TraceListener(请参见下一节),默认情况下将消息写入调试输出:
Debug.Fail ("File data.txt does not exist!");
Assert如果bool参数为false,则简单调用Fail,这被称为进行断言,如果违反则表示代码中存在 bug。指定失败消息是可选的:
Debug.Assert (File.Exists ("data.txt"), "File data.txt does not exist!");
var result = ...
Debug.Assert (result != null);
除了消息外,Write、Fail和Assert方法还可以重载接受一个string类别,这在处理输出时非常有用。
另一种断言的替代方法是,如果相反条件为真,则抛出异常。这在验证方法参数时是一种常见做法:
public void ShowMessage (string message)
{
if (message == null) throw new ArgumentNullException ("message");
...
}
此类“断言”被无条件编译,而且在控制失败断言的结果上不够灵活,无法通过TraceListener来控制。严格来说,它们并不是断言。断言是指当前方法代码中的 bug,如果违反则表明存在问题。基于参数验证抛出异常表明调用者的代码中存在 bug。
TraceListener
Trace类有一个静态的Listeners属性,返回一个TraceListener实例的集合。这些负责处理Write、Fail和Trace方法发出的内容。
默认情况下,每个的Listeners集合都包含一个监听器(DefaultTraceListener)。默认监听器有两个关键特性:
-
当连接到诸如 Visual Studio 之类的调试器时,消息将被写入调试输出窗口;否则,将忽略消息内容。
-
当调用
Fail方法(或断言失败)时,应用程序将终止。
您可以通过(可选地)移除默认侦听器并添加一个或多个自定义侦听器来更改此行为。您可以从头编写跟踪侦听器(通过子类化TraceListener)或使用预定义类型之一:
-
TextWriterTraceListener写入到Stream或TextWriter或附加到文件。 -
EventLogTraceListener写入到 Windows 事件日志(仅限 Windows)。 -
EventProviderTraceListener写入到 Windows 事件跟踪(ETW)子系统(跨平台支持)。
TextWriterTraceListener进一步被子类化为ConsoleTraceListener、DelimitedListTraceListener、XmlWriterTraceListener和EventSchemaTraceListener。
以下示例清除Trace的默认侦听器,然后添加三个侦听器——一个附加到文件,一个写入控制台,一个写入 Windows 事件日志:
// Clear the default listener:
Trace.Listeners.Clear();
// Add a writer that appends to the trace.txt file:
Trace.Listeners.Add (new TextWriterTraceListener ("trace.txt"));
// Obtain the Console's output stream, then add that as a listener:
System.IO.TextWriter tw = Console.Out;
Trace.Listeners.Add (new TextWriterTraceListener (tw));
// Set up a Windows Event log source and then create/add listener.
// CreateEventSource requires administrative elevation, so this would
// typically be done in application setup.
if (!EventLog.SourceExists ("DemoApp"))
EventLog.CreateEventSource ("DemoApp", "Application");
Trace.Listeners.Add (new EventLogTraceListener ("DemoApp"));
对于 Windows 事件日志,使用Write、Fail或Assert方法写入的消息始终在 Windows 事件查看器中显示为“信息”消息。但是,通过TraceWarning和TraceError方法写入的消息将显示为警告或错误。
TraceListener还具有TraceFilter类型的Filter,您可以设置该过滤器以控制是否将消息写入该侦听器。为此,您可以实例化预定义的子类(如EventTypeFilter或SourceFilter),或者子类化TraceFilter并重写ShouldTrace方法。例如,您可以使用这个功能按类别进行过滤。
TraceListener还定义了用于控制缩进的IndentLevel和IndentSize属性,以及用于写入额外数据的TraceOutputOptions属性:
TextWriterTraceListener tl = new TextWriterTraceListener (Console.Out);
tl.TraceOutputOptions = TraceOptions.DateTime | TraceOptions.Callstack;
使用Trace方法时将应用TraceOutputOptions:
Trace.TraceWarning ("Orange alert");
*DiagTest.vshost.exe Warning: 0 : Orange alert*
*DateTime=2007-03-08T05:57:13.6250000Z*
*Callstack= at System.Environment.GetStackTrace(Exception e, Boolean*
*needFileInfo)*
*at System.Environment.get_StackTrace() at ...*
刷新和关闭侦听器
一些侦听器(如TextWriterTraceListener)最终会写入到可能受缓存影响的流中。这有两个影响:
-
消息可能不会立即显示在输出流或文件中。
-
您必须在应用程序结束之前关闭——或至少刷新——侦听器;否则,您将丢失缓存中的内容(默认情况下最多 4 KB,如果写入文件)。
Trace和Debug类提供静态的Close和Flush方法,这些方法会调用所有侦听器的Close或Flush(进而调用底层的编写器和流的Close或Flush)。Close隐含调用Flush,关闭文件句柄,并防止进一步写入数据。
通常建议在应用程序结束之前调用Close,并在希望确保当前消息数据已写入时调用Flush。如果使用基于流或文件的侦听器,则适用此规则。
Trace和Debug还提供AutoFlush属性,如果设置为true,则在每条消息后强制执行Flush。
注意
如果使用任何基于文件或流的侦听器,则将AutoFlush设置为true是一个好策略,否则,如果发生未处理的异常或关键错误,则可能会丢失最后的 4 KB 诊断信息。
调试器集成
有时,如果可用,应用程序与调试器进行交互是有用的。在开发过程中,调试器通常是你的 IDE(如 Visual Studio);在部署中,调试器更可能是较低级别的调试工具之一,如 WinDbg、Cordbg 或 MDbg。
附加和中断
System.Diagnostics中的静态Debugger类提供了与调试器交互的基本功能,即Break、Launch、Log和IsAttached。
调试器必须先附加到一个应用程序才能进行调试。如果你从 IDE 内部启动应用程序,这将自动发生,除非你另有要求(选择“不带调试启动”)。但有时,在 IDE 内部以调试模式启动应用程序可能不方便或不可能。例如,Windows 服务应用程序或(具有讽刺意味的是)Visual Studio 设计器。一个解决方案是正常启动应用程序,然后在 IDE 中选择“调试进程”。但这样做不能让你在程序执行早期设置断点。
解决方法是在应用程序内部调用Debugger.Break。这个方法会启动调试器,附加到它,并在那一点暂停执行(Launch做同样的事情,但不会暂停执行)。附加后,你可以使用Log方法直接向调试器的输出窗口记录消息。你可以通过检查IsAttached属性验证是否已连接到调试器。
调试器属性
DebuggerStepThrough和DebuggerHidden属性为调试器提供了关于如何处理特定方法、构造函数或类的单步执行建议。
DebuggerStepThrough 请求调试器在没有任何用户交互的情况下步入函数。这个属性在自动生成的方法和代理方法中特别有用,后者将实际工作转发到其他位置的方法。在后一种情况下,如果在“真实”方法内设置了断点,调试器仍会显示代理方法在调用堆栈中,除非你还添加了DebuggerHidden属性。你可以在代理上结合这两个属性,帮助用户专注于调试应用逻辑而不是底层管理:
[DebuggerStepThrough, DebuggerHidden]
void DoWorkProxy()
{
// setup...
DoWork();
// teardown...
}
void DoWork() {...} // Real method...
进程和进程线程
我们在第六章的最后一节中描述了如何使用Process.Start启动新进程。Process类还允许你查询和与运行在同一台或另一台计算机上的其他进程进行交互。Process类是.NET Standard 2.0 的一部分,尽管其功能对于 UWP 平台有所限制。
检查运行中的进程
Process.GetProcess*XXX* 方法通过名称或进程 ID 检索特定进程,或检索当前计算机或指定计算机上运行的所有进程。这包括托管和非托管进程。每个 Process 实例具有丰富的属性,映射统计信息,如名称、ID、优先级、内存和处理器利用率、窗口句柄等。以下示例枚举了当前计算机上所有正在运行的进程:
foreach (Process p in Process.GetProcesses())
using (p)
{
Console.WriteLine (p.ProcessName);
Console.WriteLine (" PID: " + p.Id);
Console.WriteLine (" Memory: " + p.WorkingSet64);
Console.WriteLine (" Threads: " + p.Threads.Count);
}
Process.GetCurrentProcess返回当前进程。
您可以通过调用其 Kill 方法终止进程。
检查进程中的线程
您也可以使用Process.Threads属性枚举其他进程的线程。但是,您获取的对象不是System.Threading.Thread对象;它们是ProcessThread对象,用于管理而不是同步任务。ProcessThread对象提供有关底层线程的诊断信息,并允许您控制一些方面,如其优先级和处理器亲和性:
public void EnumerateThreads (Process p)
{
foreach (ProcessThread pt in p.Threads)
{
Console.WriteLine (pt.Id);
Console.WriteLine (" State: " + pt.ThreadState);
Console.WriteLine (" Priority: " + pt.PriorityLevel);
Console.WriteLine (" Started: " + pt.StartTime);
Console.WriteLine (" CPU time: " + pt.TotalProcessorTime);
}
}
StackTrace 和 StackFrame
StackTrace 和 StackFrame 类提供了执行调用堆栈的只读视图。您可以为当前线程或 Exception 对象获取堆栈跟踪信息。这些信息主要用于诊断目的,尽管您也可以在编程(黑客)中使用它。StackTrace 表示完整的调用堆栈;StackFrame 表示该堆栈内的单个方法调用。
注意
如果你只需知道调用方法的名称和行号,调用者信息属性可以提供更简单和更快的替代方法。我们在“调用者信息属性”中讨论了这个话题。
如果您使用不带参数或带有bool参数的StackTrace对象进行实例化,您将获得当前线程调用堆栈的快照。如果bool参数为true,则StackTrace将读取程序集的 .pdb(项目调试)文件(如果存在),从而使您能够访问文件名、行号和列偏移数据。在使用 /debug 开关进行编译时生成项目调试文件。(Visual Studio 默认使用此开关进行编译,除非您通过 高级构建设置 请求否定。)
获得StackTrace后,可以通过调用GetFrame来检查特定的帧,或者通过使用GetFrames获取整个堆栈:
static void Main() { A (); }
static void A() { B (); }
static void B() { C (); }
static void C()
{
StackTrace s = new StackTrace (true);
Console.WriteLine ("Total frames: " + s.FrameCount);
Console.WriteLine ("Current method: " + s.GetFrame(0).GetMethod().Name);
Console.WriteLine ("Calling method: " + s.GetFrame(1).GetMethod().Name);
Console.WriteLine ("Entry method: " + s.GetFrame
(s.FrameCount-1).GetMethod().Name);
Console.WriteLine ("Call Stack:");
foreach (StackFrame f in s.GetFrames())
Console.WriteLine (
" File: " + f.GetFileName() +
" Line: " + f.GetFileLineNumber() +
" Col: " + f.GetFileColumnNumber() +
" Offset: " + f.GetILOffset() +
" Method: " + f.GetMethod().Name);
}
下面是输出:
Total frames: 4
Current method: C
Calling method: B
Entry method: Main
Call stack:
File: C:\Test\Program.cs Line: 15 Col: 4 Offset: 7 Method: C
File: C:\Test\Program.cs Line: 12 Col: 22 Offset: 6 Method: B
File: C:\Test\Program.cs Line: 11 Col: 22 Offset: 6 Method: A
File: C:\Test\Program.cs Line: 10 Col: 25 Offset: 6 Method: Main
注意
中间语言(IL)偏移量指示将执行下一个指令的偏移量 —— 而不是当前正在执行的指令。奇怪的是,如果存在 .pdb 文件,行和列号通常指示实际执行点。
这是因为 CLR 尽其所能推断出从 IL 偏移计算行和列的实际执行点。编译器以使这种可能成为可能的方式发出 IL —— 包括在 IL 流中插入 nop(无操作)指令。
使用启用优化编译时,将禁用插入nop指令,因此堆栈跟踪可能显示下一条要执行的语句的行号和列号。获得有用的堆栈跟踪进一步受到优化可以引入的其他技巧的阻碍,包括折叠整个方法。
获取整个StackTrace的基本信息的快捷方式是对其调用ToString。以下是结果的样子:
at DebugTest.Program.C() in C:\Test\Program.cs:line 16
at DebugTest.Program.B() in C:\Test\Program.cs:line 12
at DebugTest.Program.A() in C:\Test\Program.cs:line 11
at DebugTest.Program.Main() in C:\Test\Program.cs:line 10
您还可以通过将Exception对象传递给StackTrace的构造函数来获取异常对象(显示导致抛出异常的内容)的堆栈跟踪。
注意
Exception已经有一个StackTrace属性;然而,该属性返回一个简单的字符串,而不是StackTrace对象。在没有.pdb文件可用的部署后发生异常时,IL 偏移量比行号和列号更有用。使用 IL 偏移量和ildasm,您可以准确定位发生错误的方法内部位置。
Windows 事件日志
Win32 平台提供了一个集中的日志记录机制,以 Windows 事件日志的形式存在。
我们之前使用的Debug和Trace类如果注册了EventLogTraceListener,则写入 Windows 事件日志。然而,使用EventLog类,您可以直接将数据写入 Windows 事件日志,而无需使用Trace或Debug。您还可以使用此类来读取和监视事件数据。
注意
在 Windows 服务应用程序中写入 Windows 事件日志是有意义的,因为如果发生问题,您无法弹出用户界面,将用户引导到某个特殊文件中,其中包含已写入的诊断信息。此外,由于服务通常写入 Windows 事件日志是一种常见做法,如果您的服务停止运行,管理员可能会首先查看此处。
有三个标准的 Windows 事件日志,它们分别由以下名称标识:
-
应用程序
-
系统
-
安全
应用程序日志通常是大多数应用程序写入的地方。
写入事件日志
要写入 Windows 事件日志:
-
选择三个事件日志之一(通常是应用程序)。
-
决定一个源名称,如果需要则创建(创建需要管理员权限)。
-
使用日志名称、源名称和消息数据调用
EventLog.WriteEntry。
源名称是您的应用程序的易于识别的名称。您必须在使用之前注册源名称——CreateEventSource方法执行此功能。然后可以调用WriteEntry:
const string SourceName = "MyCompany.WidgetServer";
// CreateEventSource requires administrative permissions, so this would
// typically be done in application setup.
if (!EventLog.SourceExists (SourceName))
EventLog.CreateEventSource (SourceName, "Application");
EventLog.WriteEntry (SourceName,
"Service started; using configuration file=...",
EventLogEntryType.Information);
EventLogEntryType可以是Information、Warning、Error、SuccessAudit或FailureAudit。每种类型在 Windows 事件查看器中显示不同的图标。您还可以选择性地指定类别和事件 ID(每个都是您自己选择的数字),并提供可选的二进制数据。
CreateEventSource还允许您指定计算机名称:这是为了将日志写入另一台计算机的事件日志(如果您具有足够的权限)。
读取事件日志
要读取事件日志,请使用要访问的日志名称和(可选)日志所在计算机的名称实例化EventLog类。然后可以通过Entries集合属性读取每个日志条目:
EventLog log = new EventLog ("Application");
Console.WriteLine ("Total entries: " + log.Entries.Count);
EventLogEntry last = log.Entries [log.Entries.Count - 1];
Console.WriteLine ("Index: " + last.Index);
Console.WriteLine ("Source: " + last.Source);
Console.WriteLine ("Type: " + last.EntryType);
Console.WriteLine ("Time: " + last.TimeWritten);
Console.WriteLine ("Message: " + last.Message);
您可以通过静态方法EventLog.GetEventLogs(这需要管理员权限以获取完全访问权限)枚举当前(或另一个)计算机的所有日志:
foreach (EventLog log in EventLog.GetEventLogs())
Console.WriteLine (log.LogDisplayName);
这通常至少打印应用程序、安全性和系统。
监视事件日志
你可以通过EntryWritten事件在 Windows 事件日志中写入条目时得到提醒。这适用于本地计算机上的事件日志,并且不管哪个应用程序记录了事件,都会触发此事件。
要启用日志监视:
-
实例化一个
EventLog并将其EnableRaisingEvents属性设置为true。 -
处理
EntryWritten事件。
例如:
using (var log = new EventLog ("Application"))
{
log.EnableRaisingEvents = true;
log.EntryWritten += DisplayEntry;
Console.ReadLine();
}
void DisplayEntry (object sender, EntryWrittenEventArgs e)
{
EventLogEntry entry = e.Entry;
Console.WriteLine (entry.Message);
}
性能计数器
注意
性能计数器是仅适用于 Windows 的功能,需要 NuGet 包System.Diagnostics.PerformanceCounter。如果您的目标是 Linux 或 macOS,请参阅“跨平台诊断工具”以获取替代方案。
我们迄今讨论的日志记录机制对于捕获未来分析的信息很有用。但是,要深入了解应用程序(或整个系统)的当前状态,需要一种更实时的方法。Win32 解决这种需求的方案是性能监控基础设施,它由系统和应用程序公开的一组性能计数器以及用于实时监控这些计数器的 Microsoft Management Console(MMC)插件组成。
性能计数器分为类别,如“系统”、“处理器”、“.NET CLR 内存”等。这些类别有时也被图形用户界面(GUI)工具称为“性能对象”。每个类别分组了一组相关的性能计数器,用于监视系统或应用程序的一个方面。在“.NET CLR 内存”类别中,性能计数器的示例包括“%GC 时间”、“所有堆中的字节数”和“每秒分配的字节数”。
每个类别可以选择性地具有一个或多个可以独立监视的实例。例如,在“处理器”类别的“%处理器时间”性能计数器中,这在监视 CPU 利用率时非常有用。在多处理器计算机上,此计数器支持每个 CPU 的实例,允许您独立监视每个 CPU 的利用率。
以下各节说明了如何执行常见的任务,例如确定公开的计数器、监视计数器以及创建自己的计数器以公开应用程序状态信息。
注意
读取性能计数器或类别可能需要在本地或目标计算机上具有管理员权限,这取决于所访问的内容。
枚举可用计数器
以下示例枚举计算机上所有可用的性能计数器。对于具有实例的计数器,它枚举每个实例的计数器:
PerformanceCounterCategory[] cats =
PerformanceCounterCategory.GetCategories();
foreach (PerformanceCounterCategory cat in cats)
{
Console.WriteLine ("Category: " + cat.CategoryName);
string[] instances = cat.GetInstanceNames();
if (instances.Length == 0)
{
foreach (PerformanceCounter ctr in cat.GetCounters())
Console.WriteLine (" Counter: " + ctr.CounterName);
}
else // Dump counters with instances
{
foreach (string instance in instances)
{
Console.WriteLine (" Instance: " + instance);
if (cat.InstanceExists (instance))
foreach (PerformanceCounter ctr in cat.GetCounters (instance))
Console.WriteLine (" Counter: " + ctr.CounterName);
}
}
}
注意
结果超过 10,000 行长!执行起来也需要一些时间,因为 PerformanceCounterCategory.InstanceExists 的实现效率低下。在实际系统中,您希望仅在需要时检索更详细的信息。
下一个示例使用 LINQ 仅检索 .NET 性能计数器,并将结果写入 XML 文件:
var x =
new XElement ("counters",
from PerformanceCounterCategory cat in
PerformanceCounterCategory.GetCategories()
where cat.CategoryName.StartsWith (".NET")
let instances = cat.GetInstanceNames()
select new XElement ("category",
new XAttribute ("name", cat.CategoryName),
instances.Length == 0
?
from c in cat.GetCounters()
select new XElement ("counter",
new XAttribute ("name", c.CounterName))
:
from i in instances
select new XElement ("instance", new XAttribute ("name", i),
!cat.InstanceExists (i)
?
null
:
from c in cat.GetCounters (i)
select new XElement ("counter",
new XAttribute ("name", c.CounterName))
)
)
);
x.Save ("counters.xml");
读取性能计数器数据
要检索性能计数器的值,请实例化 PerformanceCounter 对象,然后调用 NextValue 或 NextSample 方法。NextValue 返回一个简单的 float 值;NextSample 返回一个 CounterSample 对象,该对象公开了一组更高级的属性,如 CounterFrequency、TimeStamp、BaseValue 和 RawValue。
PerformanceCounter 的构造函数接受类别名称、计数器名称和可选的实例。因此,要显示所有 CPU 的当前处理器利用率,您可以执行以下操作:
using PerformanceCounter pc = new PerformanceCounter ("Processor",
"% Processor Time",
"_Total");
Console.WriteLine (pc.NextValue());
或者显示当前进程的“真实”(即私有)内存消耗:
string procName = Process.GetCurrentProcess().ProcessName;
using PerformanceCounter pc = new PerformanceCounter ("Process",
"Private Bytes",
procName);
Console.WriteLine (pc.NextValue());
PerformanceCounter 没有公开 ValueChanged 事件,因此如果要监视更改,必须进行轮询。在下一个示例中,我们每 200 毫秒轮询一次,直到通过 EventWaitHandle 发出退出信号:
// need to import System.Threading as well as System.Diagnostics
static void Monitor (string category, string counter, string instance,
EventWaitHandle stopper)
{
if (!PerformanceCounterCategory.Exists (category))
throw new InvalidOperationException ("Category does not exist");
if (!PerformanceCounterCategory.CounterExists (counter, category))
throw new InvalidOperationException ("Counter does not exist");
if (instance == null) instance = ""; // "" == no instance (not null!)
if (instance != "" &&
!PerformanceCounterCategory.InstanceExists (instance, category))
throw new InvalidOperationException ("Instance does not exist");
float lastValue = 0f;
using (PerformanceCounter pc = new PerformanceCounter (category,
counter, instance))
while (!stopper.WaitOne (200, false))
{
float value = pc.NextValue();
if (value != lastValue) // Only write out the value
{ // if it has changed.
Console.WriteLine (value);
lastValue = value;
}
}
}
使用这种方法同时监视处理器和硬盘活动:
EventWaitHandle stopper = new ManualResetEvent (false);
new Thread (() =>
Monitor ("Processor", "% Processor Time", "_Total", stopper)
).Start();
new Thread (() =>
Monitor ("LogicalDisk", "% Idle Time", "C:", stopper)
).Start();
Console.WriteLine ("Monitoring - press any key to quit");
Console.ReadKey();
stopper.Set();
创建计数器和写入性能数据
在写入性能计数器数据之前,需要创建性能类别和计数器。您必须在一步中创建性能类别以及属于它的所有计数器,如下所示:
string category = "Nutshell Monitoring";
// We'll create two counters in this category:
string eatenPerMin = "Macadamias eaten so far";
string tooHard = "Macadamias deemed too hard";
if (!PerformanceCounterCategory.Exists (category))
{
CounterCreationDataCollection cd = new CounterCreationDataCollection();
cd.Add (new CounterCreationData (eatenPerMin,
"Number of macadamias consumed, including shelling time",
PerformanceCounterType.NumberOfItems32));
cd.Add (new CounterCreationData (tooHard,
"Number of macadamias that will not crack, despite much effort",
PerformanceCounterType.NumberOfItems32));
PerformanceCounterCategory.Create (category, "Test Category",
PerformanceCounterCategoryType.SingleInstance, cd);
}
当您选择添加计数器时,新的计数器将显示在 Windows 性能监视工具中。如果稍后要在同一类别中定义更多计数器,则必须首先调用 PerformanceCounterCategory.Delete 删除旧类别。
注意
创建和删除性能计数器需要管理员权限。因此,通常作为应用程序设置的一部分来完成。
创建计数器后,可以通过实例化 PerformanceCounter,将 ReadOnly 设置为 false,并设置 RawValue 来更新其值。您还可以使用 Increment 和 IncrementBy 方法来更新现有值:
string category = "Nutshell Monitoring";
string eatenPerMin = "Macadamias eaten so far";
using (PerformanceCounter pc = new PerformanceCounter (category,
eatenPerMin, ""))
{
pc.ReadOnly = false;
pc.RawValue = 1000;
pc.Increment();
pc.IncrementBy (10);
Console.WriteLine (pc.NextValue()); // 1011
}
Stopwatch 类
Stopwatch 类提供了一个方便的机制来测量执行时间。Stopwatch 使用操作系统和硬件提供的最高分辨率机制,通常小于一微秒。(相比之下,DateTime.Now 和 Environment.TickCount 的分辨率约为 15 毫秒。)
要使用 Stopwatch,调用 StartNew —— 这将实例化一个 Stopwatch 并启动它开始计时。(或者,您可以手动实例化然后调用 Start。)Elapsed 属性以 TimeSpan 形式返回经过的时间间隔:
Stopwatch s = Stopwatch.StartNew();
System.IO.File.WriteAllText ("test.txt", new string ('*', 30000000));
Console.WriteLine (s.Elapsed); // 00:00:01.4322661
Stopwatch 还公开了ElapsedTicks属性,它以long返回经过的“ticks”数。要从 ticks 转换为秒,请除以 StopWatch.Frequency。还有一个ElapsedMilliseconds属性,通常是最方便的。
调用Stop会冻结Elapsed和ElapsedTicks。不会有由“运行中”的Stopwatch引起的后台活动,因此调用Stop是可选的。
跨平台诊断工具
在本节中,我们简要介绍了.NET 可用的跨平台诊断工具:
dotnet-counters
提供运行应用程序状态的概述
dotnet-trace
获取更详细的性能和事件监视信息
dotnet-dump
要在需求或崩溃后获取内存转储
这些工具不需要管理员权限,并且适用于开发和生产环境。
dotnet-counters
dotnet-counters工具监视.NET 进程的内存和 CPU 使用情况,并将数据写入控制台(或文件)。
要安装工具,请从命令提示符或带有dotnet路径的终端运行以下命令:
dotnet tool install --global dotnet-counters
您可以按以下方式开始监视进程:
dotnet-counters monitor System.Runtime --process-id *<<ProcessID>>*
System.Runtime 表示我们要监视System.Runtime类别下的所有计数器。您可以指定类别或计数器名称(dotnet-counters list命令列出所有可用的类别和计数器)。
输出将持续刷新并类似于以下内容:
Press p to pause, r to resume, q to quit.
Status: Running
[System.Runtime]
# of Assemblies Loaded 63
% Time in GC (since last GC) 0
Allocation Rate (Bytes / sec) 244,864
CPU Usage (%) 6
Exceptions / sec 0
GC Heap Size (MB) 8
Gen 0 GC / sec 0
Gen 0 Size (B) 265,176
Gen 1 GC / sec 0
Gen 1 Size (B) 451,552
Gen 2 GC / sec 0
Gen 2 Size (B) 24
LOH Size (B) 3,200,296
Monitor Lock Contention Count / sec 0
Number of Active Timers 0
ThreadPool Completed Work Items / sec 15
ThreadPool Queue Length 0
ThreadPool Threads Count 9
Working Set (MB) 52
以下是所有可用的命令:
| 命令 | 目的 |
|---|---|
list |
显示计数器名称及其描述的列表 |
ps |
显示符合监视条件的 dotnet 进程列表 |
monitor |
显示所选计数器的值(定期刷新) |
collect |
将计数器信息保存到文件 |
支持以下参数:
| 选项/参数 | 目的 |
|---|---|
--version |
显示dotnet-counters的版本。 |
-h, --help |
显示程序的帮助信息。 |
-p, --process-id |
要监视的 dotnet 进程的 ID。适用于monitor和collect命令。 |
--refresh-interval |
设置所需的刷新间隔(以秒为单位)。适用于monitor和collect命令。 |
-o, --output |
设置输出文件名。适用于collect命令。 |
--format |
设置输出格式。有效的格式为csv或json。适用于collect命令。 |
dotnet-trace
跟踪是程序中事件的时间戳记录,例如调用方法或查询数据库。跟踪还可以包括性能指标和自定义事件,并可以包含局部上下文,例如局部变量的值。传统上,.NET Framework 和诸如 ASP.NET 之类的框架使用 ETW。在.NET 5 中,应用程序跟踪在 Windows 上运行时写入 ETW,在 Linux 上运行时写入 LTTng。
要安装工具,请执行以下命令:
dotnet tool install --global dotnet-trace
要开始记录程序的事件,请运行以下命令:
dotnet-trace collect --process-id *<<ProcessId>>*
这会使用默认配置文件运行dotnet-trace,该配置文件收集 CPU 和 .NET 运行时事件,并写入名为trace.nettrace的文件。您可以使用--profile开关指定其他配置文件:gc-verbose 跟踪垃圾回收和抽样对象分配,gc-collect 以低开销跟踪垃圾回收。-o开关允许您指定不同的输出文件名。
默认输出为.netperf文件,可以直接在 Windows 机器上使用 PerfView 工具分析。或者,您可以指示dotnet-trace创建与 Speedscope 兼容的文件,Speedscope 是一个免费的在线分析服务,位于https://speedscope.app。要创建 Speedscope(.speedscope.json)文件,请使用选项--format speedscope。
注:
您可以从https://github.com/microsoft/perfview下载 PerfView 的最新版本。随 Windows 10 发货的版本可能不支持.netperf文件。
支持以下命令:
| Commands | Purpose |
|---|---|
collect |
开始将计数器信息记录到文件中。 |
ps |
显示可供监视的 dotnet 进程列表。 |
list-profiles |
列出具有每个提供程序和过滤器描述的预构建跟踪配置文件。 |
convert <file> |
将nettrace(.netperf)格式转换为另一种格式。当前唯一的目标选项是speedscope。 |
自定义跟踪事件
您的应用程序可以通过定义自定义EventSource来发出自定义事件:
[EventSource (Name = "MyTestSource")]
public sealed class MyEventSource : EventSource
{
public static MyEventSource Instance = new MyEventSource ();
MyEventSource() : base (EventSourceSettings.EtwSelfDescribingEventFormat)
{
}
public void Log (string message, int someNumber)
{
WriteEvent (1, message, someNumber);
}
}
WriteEvent 方法被重载以接受各种简单类型的组合(主要是字符串和整数)。然后,您可以按以下方式调用它:
MyEventSource.Instance.Log ("Something", 123);
调用dotnet-trace时,必须指定要记录的任何自定义事件源的名称:
dotnet-trace collect --process-id *<<ProcessId>>* --providers MyTestSource
dotnet-dump
Dump,有时称为核心转储,是进程虚拟内存状态的快照。您可以按需转储正在运行的进程,或者配置操作系统在应用程序崩溃时生成转储。
在 Ubuntu Linux 上,以下命令在应用程序崩溃时启用核心转储(不同 Linux 版本间的必要步骤可能有所不同):
ulimit -c unlimited
在 Windows 上,使用regedit.exe在本地计算机 hive 中创建或编辑以下键:
SOFTWARE\Microsoft\Windows\Windows Error Reporting\LocalDumps
在此之下,添加与您的可执行文件相同的键(例如foo.exe),并在该键下添加以下键:
-
DumpFolder(REG_EXPAND_SZ),其值指示要写入转储文件的路径 -
DumpType(REG_DWORD),其值为 2,以请求完整转储 -
(可选)
DumpCount(REG_DWORD),指示在删除最老的转储文件之前的最大转储文件数
要安装该工具,请运行以下命令:
dotnet tool install --global dotnet-dump
安装完后,您可以按需进行转储(而无需结束进程),如下所示:
dotnet-dump collect --process-id *<<YourProcessId>>*
以下命令启动用于分析转储文件的交互式 shell:
dotnet-dump analyze *<<dumpfile>>*
如果异常导致应用程序崩溃,你可以使用printexceptions命令(简写为pe)来显示异常的详细信息。dotnet-dump shell 支持多个额外的命令,你可以使用help命令列出这些命令。
第十四章:并发性和异步性
大多数应用程序需要同时处理多个事物(并发)。在本章中,我们首先介绍必要的先决条件,即线程和任务的基础知识,然后详细描述了异步性原理和 C#的异步函数。
在第二十一章中,我们会更详细地讨论多线程,并在第二十二章中介绍相关的并行编程主题。
介绍
以下是最常见的并发场景:
编写响应式用户界面
在 Windows Presentation Foundation(WPF)、移动和 Windows Forms 应用程序中,必须与运行用户界面的代码并行运行耗时任务,以保持响应性。
允许请求同时处理
在服务器上,客户端请求可以同时到达,因此必须并行处理以保持可伸缩性。如果使用 ASP.NET Core 或 Web API,则运行时会自动处理。但是,您仍需注意共享状态(例如,使用静态变量进行缓存的影响)。
并行编程
如果工作负载在多核/多处理器计算机上分配,执行计算密集型计算的代码会更快(第二十二章专门讨论这一点)。
推测执行
在多核机器上,有时可以通过预测可能需要执行的任务并提前执行来提高性能。LINQPad 使用这种技术加速新查询的创建。另一种变体是并行运行多种不同算法来解决相同任务。首先完成的算法“获胜”——在无法预先知道哪种算法执行速度最快时,这种方法非常有效。
程序能够同时执行代码的一般机制称为多线程。多线程由 CLR 和操作系统支持,是并发中的基本概念。因此,理解线程的基础知识,特别是线程对共享状态的影响,是至关重要的。
线程
线程是可以独立进行的执行路径。
每个线程在操作系统进程内运行,提供一个隔离的环境来执行程序。在单线程程序中,只有一个线程在进程的隔离环境中运行,因此该线程具有独占访问权限。在多线程程序中,多个线程在同一个进程中运行,共享相同的执行环境(尤其是内存)。这部分原因解释了为什么多线程很有用:例如,一个线程可以在后台获取数据,而另一个线程在数据到达时显示数据。这些数据被称为共享状态。
创建线程
一个客户端程序(控制台,WPF,UWP 或 Windows 窗体)在操作系统(“主”线程)自动创建的单个线程中启动。在这里,它作为单线程应用程序存在,除非你通过创建更多线程(直接或间接)进行其他操作。¹
你可以通过实例化一个Thread对象并调用其Start方法来创建和启动一个新线程。Thread的最简构造函数接受一个ThreadStart委托:一个无参数方法,指示执行应该从哪里开始。以下是一个示例:
// NB: All samples in this chapter assume the following namespace imports:
using System;
using System.Threading;
Thread t = new Thread (WriteY); // Kick off a new thread
t.Start(); // running WriteY()
// Simultaneously, do something on the main thread.
for (int i = 0; i < 1000; i++) Console.Write ("x");
void WriteY()
{
for (int i = 0; i < 1000; i++) Console.Write ("y");
}
// Typical Output:
xxxxxxxxxxxxxxxxyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxyyyyyyyyyyyyy
yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyxxxxxxxxxxxxxxxxxxxxxx
xxxxxxxxxxxxxxxxxxxxxxyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy
yyyyyyyyyyyyyxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
...
主线程在一个新线程t上创建并运行一个重复打印字符y的方法。同时,主线程重复打印字符x,如图 14-1 所示。在单核计算机上,操作系统必须分配时间“片段”给每个线程(在 Windows 中通常为 20 毫秒)以模拟并发,导致x和y的重复块。在多核或多处理器机器上,两个线程可以真正并行执行(受计算机上其他活动进程的竞争影响),尽管在本例中由于Console处理并发请求的机制的细微差别,你仍然会得到x和y的重复块。

图 14-1. 开始一个新线程
注意
线程在其执行与另一个线程上的代码执行交织的点被称为抢占。这个术语经常用来解释为什么事情出了问题!
一旦启动,线程的IsAlive属性返回true,直到线程结束的时候。线程结束是指传递给Thread构造函数的委托执行完毕。一旦线程结束,线程就无法重新启动。
每个线程都有一个Name属性,你可以为了调试的利益进行设置。这在 Visual Studio 中特别有用,因为线程的名称显示在“线程”窗口和“调试位置”工具栏中。你只能设置一次线程的名称;尝试稍后更改将引发异常。
静态的Thread.CurrentThread属性提供当前正在执行的线程:
Console.WriteLine (Thread.CurrentThread.Name);
加入和休眠
你可以通过调用其Join方法等待另一个线程结束:
Thread t = new Thread (Go);
t.Start();
t.Join();
Console.WriteLine ("Thread t has ended!");
void Go() { for (int i = 0; i < 1000; i++) Console.Write ("y"); }
这会打印“y” 1,000 次,然后紧接着打印“线程 t 已结束!”。在调用Join时,你可以包含超时,可以是毫秒或TimeSpan。然后,如果线程结束,则返回true;如果超时,则返回false。
Thread.Sleep暂停当前线程一段指定的时间:
Thread.Sleep (TimeSpan.FromHours (1)); // Sleep for 1 hour
Thread.Sleep (500); // Sleep for 500 milliseconds
Thread.Sleep(0)立即放弃线程的当前时间片,自愿将 CPU 交给其他线程。Thread.Yield()也做同样的事情,不同的是它只放弃给同一处理器上运行的线程。
注意
Sleep(0)或Yield在生产代码中偶尔对高级性能调整很有用。它也是帮助发现线程安全问题的优秀诊断工具:如果在代码中的任何地方插入Thread.Yield()导致程序中断,几乎可以肯定存在 bug。
在等待Sleep或Join时,线程被阻塞。
阻塞
当线程的执行因某些原因暂停时,线程被认为是阻塞的,例如通过Sleep或通过Join等待另一个线程结束。阻塞的线程立即放弃其处理器时间片,并且从那时起,直到其阻塞条件满足之前,它不再消耗处理器时间。您可以通过其ThreadState属性测试线程是否阻塞:
bool blocked = (someThread.ThreadState & ThreadState.WaitSleepJoin) != 0;
注意
ThreadState是一个标志枚举,以位操作方式组合三个“层次”的数据。然而,大多数值是冗余的、未使用的或已弃用的。以下扩展方法将ThreadState剥离为四个有用的值之一:Unstarted、Running、WaitSleepJoin和Stopped:
public static ThreadState Simplify (this ThreadState ts)
{
return ts & (ThreadState.Unstarted |
ThreadState.WaitSleepJoin |
ThreadState.Stopped);
}
ThreadState属性对诊断目的很有用,但不适合用于同步,因为线程的状态可能在测试ThreadState和处理该信息之间发生变化。
当线程阻塞或解除阻塞时,操作系统执行上下文切换。这会产生一小部分开销,通常为一到两微秒。
I/O 绑定与计算绑定
大部分时间等待某事发生的操作称为I/O 绑定——例如下载网页或调用Console.ReadLine。(I/O 绑定操作通常涉及输入或输出,但这不是硬性要求:Thread.Sleep也被认为是 I/O 绑定。)相比之下,大部分时间执行 CPU 密集型工作的操作称为计算绑定。
阻塞与自旋
I/O 绑定操作有两种工作方式:要么在当前线程上同步等待直到操作完成(如Console.ReadLine、Thread.Sleep或Thread.Join),要么异步操作,在未来操作完成时触发回调(稍后详细介绍)。
等待同步操作的 I/O 绑定操作大部分时间都在阻塞线程。它们也可以定期在循环中“自旋”:
while (DateTime.Now < nextStartTime)
Thread.Sleep (100);
撇开有更好方法的事实(例如定时器或信号构造),另一种选择是线程可以连续自旋:
while (DateTime.Now < nextStartTime);
一般来说,这对处理器时间非常浪费:就 CLR 和操作系统而言,线程正在执行重要的计算,因此分配了相应的资源。事实上,我们已经把本应是 I/O 绑定操作变成了计算绑定操作。
注意
关于自旋和阻塞有一些微妙之处。首先,当您期望条件很快满足(可能在几微秒内)时,非常短暂的自旋可能是有效的,因为它避免了上下文切换的开销和延迟。.NET 提供了特殊的方法和类来辅助此过程——参见在线补充材料“SpinLock and SpinWait”。
其次,阻塞并非零成本。这是因为每个线程在其存活期间大约会占用 1MB 的内存,并且会导致 CLR 和操作系统的持续管理开销。因此,在需要处理数百或数千个并发操作的 I/O 密集型程序的情况下,阻塞可能会带来麻烦。相反,这些程序需要使用基于回调的方法,完全释放线程而不进行阻塞。这在我们稍后讨论的异步模式中部分体现出来。
本地状态与共享状态
CLR 为每个线程分配自己的内存堆栈,以便保持本地变量的分离。在下一个示例中,我们定义一个带有本地变量的方法,然后同时在主线程和新创建的线程上调用该方法:
new Thread (Go).Start(); // Call Go() on a new thread
Go(); // Call Go() on the main thread
void Go()
{
// Declare and use a local variable - 'cycles'
for (int cycles = 0; cycles < 5; cycles++) Console.Write ('?');
}
每个线程的内存堆栈上都创建了cycles变量的单独副本,因此输出是可以预测的,会输出 10 个问号。
线程在具有对同一对象或变量的共同引用时共享数据:
bool _done = false;
new Thread (Go).Start();
Go();
void Go()
{
if (!_done) { _done = true; Console.WriteLine ("Done"); }
}
两个线程共享_done变量,因此“Done”只会打印一次而不是两次。
由 lambda 表达式捕获的本地变量也可以被共享:
bool done = false;
ThreadStart action = () =>
{
if (!done) { done = true; Console.WriteLine ("Done"); }
};
new Thread (action).Start();
action();
更常见的情况是,字段用于在线程之间共享数据。在下面的示例中,两个线程都在同一个ThreadTest实例上调用Go(),因此它们共享相同的_done字段:
var tt = new ThreadTest();
new Thread (tt.Go).Start();
tt.Go();
class ThreadTest
{
bool _done;
public void Go()
{
if (!_done) { _done = true; Console.WriteLine ("Done"); }
}
}
静态字段提供了另一种在线程之间共享数据的方式:
class ThreadTest
{
static bool _done; // Static fields are shared between all threads
// in the same process.
static void Main()
{
new Thread (Go).Start();
Go();
}
static void Go()
{
if (!_done) { _done = true; Console.WriteLine ("Done"); }
}
}
所有四个示例都展示了另一个关键概念:即线程安全(或者说,缺乏线程安全!)。输出实际上是不确定的:“Done”可能会被打印两次(尽管可能性很小)。然而,如果我们交换Go方法中语句的顺序,那么“Done”被打印两次的几率会显著增加:
static void Go()
{
if (!_done) { Console.WriteLine ("Done"); _done = true; }
}
问题在于一个线程可以在另一个线程执行WriteLine语句之前恰好评估 if 语句,而它还没有机会将done设置为true。
注意
我们的示例说明了共享可写状态可能引入多线程环境下的间歇性错误的多种方式之一。接下来,我们将看看如何通过锁定来修复我们的程序;然而,在可能的情况下,最好完全避免共享状态。我们稍后会看到,异步编程模式如何帮助解决这个问题。
锁定和线程安全
注意
锁定和线程安全是一个广泛的话题。有关完整讨论,请参阅“独占锁定”和“锁定和线程安全”。
我们可以通过在读写共享字段时获取独占锁来修复前面的示例。C# 提供了 lock 语句来实现这一目的:
class ThreadSafe
{
static bool _done;
static readonly object _locker = new object();
static void Main()
{
new Thread (Go).Start();
Go();
}
static void Go()
{
lock (_locker)
{
if (!_done) { Console.WriteLine ("Done"); _done = true; }
}
}
}
当两个线程同时争夺一个锁(可以是任何引用类型对象;在本例中是 _locker),一个线程会等待,或者说阻塞,直到锁变为可用。在这种情况下,它确保只有一个线程可以同时进入其代码块,因此,“Done” 只会被打印一次。在这种多线程上下文中受保护的代码称为线程安全。
警告
即使是自增变量的操作也不是线程安全的:表达式 x++ 在底层处理器上执行为独立的读-增量-写操作。因此,如果两个线程在没有锁的情况下同时执行 x++,变量可能只会增加一次,而不是两次(或者更糟,x 可能会撕裂,在某些条件下会得到旧内容和新内容的混合)。
锁定并非线程安全的万能药——很容易忘记在访问字段时加锁,而且加锁本身可能会带来问题(如死锁)。
在 ASP.NET 应用程序中访问频繁访问的数据库对象的共享内存缓存时,锁定是一个很好的例子。这种应用程序简单易用,并且没有死锁的机会。我们在“应用程序服务器中的线程安全性”中给出了一个例子。
向线程传递数据
有时,您可能希望向线程的启动方法传递参数。这样做的最简单方法是使用一个 Lambda 表达式来调用带有所需参数的方法:
Thread t = new Thread ( () => Print ("Hello from t!") );
t.Start();
void Print (string message) => Console.WriteLine (message);
使用这种方法,你可以向方法传递任意数量的参数。甚至可以将整个实现包装在一个多语句 Lambda 表达式中:
new Thread (() =>
{
Console.WriteLine ("I'm running on another thread!");
Console.WriteLine ("This is so easy!");
}).Start();
另一种(更不灵活)的技术是将参数传递给 Thread 的 Start 方法:
Thread t = new Thread (Print);
t.Start ("Hello from t!");
void Print (object messageObj)
{
string message = (string) messageObj; // We need to cast here
Console.WriteLine (message);
}
这是因为 Thread 的构造函数被重载为接受两个委托之一:
public delegate void ThreadStart();
public delegate void ParameterizedThreadStart (object obj);
Lambda 表达式和捕获的变量
正如我们所见,Lambda 表达式是向线程传递数据最方便和强大的方法。但是,在启动线程后,您必须小心不要意外修改捕获的变量。例如,请考虑以下情况:
for (int i = 0; i < 10; i++)
new Thread (() => Console.Write (i)).Start();
输出是不确定的!这里是一个典型的结果:
0223557799
问题在于 i 变量在循环的整个生命周期中引用同一内存位置。因此,每个线程在运行时调用 Console.Write 的变量值可能会发生变化!解决方法是使用临时变量如下所示:
for (int i = 0; i < 10; i++)
{
int temp = i;
new Thread (() => Console.Write (temp)).Start();
}
然后,数字 0 到 9 的每个数字会被写入一次。(顺序仍然是未定义的,因为线程可以在不确定的时间启动。)
注意
这类似于我们在“捕获变量”中描述的问题。这个问题不仅仅涉及到 C# 中关于在 for 循环中捕获变量的规则,还涉及到多线程。
变量 temp 现在是每个循环迭代的本地变量。因此,每个线程捕获不同的内存位置,没有问题。我们可以用以下示例更简单地说明早期代码中的问题:
string text = "t1";
Thread t1 = new Thread ( () => Console.WriteLine (text) );
text = "t2";
Thread t2 = new Thread ( () => Console.WriteLine (text) );
t1.Start(); t2.Start();
因为两个 lambda 表达式都捕获相同的文本变量,t2 被打印两次。
异常处理
当创建线程时,现有的任何 try/catch/finally 块对线程在开始执行时不起作用。考虑以下程序:
try
{
new Thread (Go).Start();
}
catch (Exception ex)
{
// We'll never get here!
Console.WriteLine ("Exception!");
}
void Go() { throw null; } // Throws a NullReferenceException
在这个示例中,try/catch语句是无效的,并且新创建的线程将被未处理的NullReferenceException所拖累。考虑到每个线程都有独立的执行路径,这种行为是合理的。
解决方法是将异常处理程序移到 Go 方法中:
new Thread (Go).Start();
void Go()
{
try
{
...
throw null; // The NullReferenceException will get caught below
...
}
catch (Exception ex)
{
// Typically log the exception and/or signal another thread
// that we've come unstuck
...
}
}
在生产应用程序的所有线程入口方法上都需要异常处理程序——就像你在主线程(通常在执行堆栈的更高级别)上做的那样。未处理的异常会导致整个应用程序关闭,并出现一个丑陋的对话框!
注意
在编写此类异常处理块时,你很少会忽略错误:通常会记录异常的详细信息。对于客户端应用程序,你可能会显示一个对话框,允许用户自动将这些详细信息提交到你的 Web 服务器。然后,你可能会选择重新启动应用程序,因为意外的异常可能会使程序处于无效状态。
集中式异常处理
在 WPF、UWP 和 Windows Forms 应用程序中,你可以订阅“全局”异常处理事件,分别是 Application.DispatcherUnhandledException 和 Application.ThreadException。这些事件在程序的任何部分通过消息循环调用时(这相当于在 Application 激活时运行的所有代码)的未处理异常后触发。这对于日志记录和报告错误非常有用(尽管它不会对你创建的工作线程上的未处理异常触发)。处理这些事件可以防止程序关闭,尽管你可以选择重新启动应用程序,以避免从(或导致)未处理异常后可能发生的状态损坏。
前台线程与后台线程
默认情况下,显式创建的线程是前台线程。前台线程会在任何一个线程运行时保持应用程序处于活动状态,而后台线程则不会。当所有前台线程都完成后,应用程序结束,而仍在运行的任何后台线程将会突然终止。
注意
线程的前台/后台状态与其优先级(执行时间分配)无关。
你可以使用线程的IsBackground属性来查询或更改线程的后台状态:
static void Main (string[] args)
{
Thread worker = new Thread ( () => Console.ReadLine() );
if (args.Length > 0) worker.IsBackground = true;
worker.Start();
}
如果调用此程序时没有参数,则工作线程假设前台状态,并将等待在ReadLine语句上,等待用户按 Enter 键。与此同时,主线程退出,但应用程序仍在运行,因为前台线程仍然活动。另一方面,如果将参数传递给Main(),则工作线程被分配为后台状态,当主线程结束时,程序几乎立即退出(终止ReadLine)。
当以这种方式终止进程时,后台线程执行堆栈中的任何finally块都会被绕过。如果你的程序使用finally(或using)块执行清理工作,如删除临时文件,你可以通过显式等待这些后台线程在应用程序退出时结束,或者使用信号传递构造(参见“信号传递”)来避免这种情况。在任一情况下,你都应该指定一个超时时间,以便在线程拒绝结束时可以放弃一个“叛徒”线程;否则,你的应用程序将无法在用户未经任务管理器帮助的情况下关闭(或在 Unix 上使用kill命令)。
前台线程不需要这种处理,但是你必须小心,避免可能导致线程不结束的错误。导致应用程序未能正确退出的常见原因是存在活动的前台线程。
线程优先级
线程的Priority属性决定了它在操作系统中相对于其他活动线程分配的执行时间,采用以下刻度:
enum ThreadPriority { Lowest, BelowNormal, Normal, AboveNormal, Highest }
当多个线程同时活动时,这变得很重要。提高线程优先级时要小心,因为它可能会使其他线程饿死。如果你希望一个线程的优先级高于其他进程中的线程,还必须使用System.Diagnostics中的Process类提升进程优先级:
using Process p = Process.GetCurrentProcess();
p.PriorityClass = ProcessPriorityClass.High;
这在需要低延迟(即快速响应能力)进行最小工作的非 UI 进程中表现良好。对于计算密集型应用程序(特别是具有用户界面的应用程序),提高进程优先级可能会使其他进程饿死,从而减慢整个计算机的速度。
信号传递
有时候,你需要一个线程等待,直到接收到其他线程的通知。这被称为信号传递。最简单的信号传递构造是ManualResetEvent。在ManualResetEvent上调用WaitOne会阻塞当前线程,直到另一个线程通过调用Set来“打开”信号。在以下示例中,我们启动一个等待ManualResetEvent的线程。它将在主线程信号它之前保持阻塞两秒钟:
var signal = new ManualResetEvent (false);
new Thread (() =>
{
Console.WriteLine ("Waiting for signal...");
signal.WaitOne();
signal.Dispose();
Console.WriteLine ("Got signal!");
}).Start();
Thread.Sleep(2000);
signal.Set(); // “Open” the signal
调用Set后,信号保持打开状态;可以通过调用Reset再次关闭它。
ManualResetEvent是 CLR 提供的几种信号传递构造之一;我们在第二十一章中详细介绍它们。
丰富客户端应用程序中的线程管理
在 WPF、UWP 和 Windows Forms 应用程序中,在主线程上执行长时间运行的操作会使应用程序响应变慢,因为主线程还处理渲染、处理键盘和鼠标事件的消息循环。
一种常见的方法是为耗时操作启动“工作”线程。工作线程上的代码运行一个耗时操作,然后在完成时更新 UI。然而,所有丰富客户端应用程序都有一个线程模型,即 UI 元素和控件只能从创建它们的线程(通常是主 UI 线程)访问。违反此规则会导致不可预测的行为或引发异常。
因此,当您希望从工作线程更新 UI 时,必须将请求转发到 UI 线程(技术术语是“marshal”)。这样做的低级方法如下(稍后我们将讨论建立在此基础上的其他解决方案):
-
在 WPF 中,调用元素的
Dispatcher对象的BeginInvoke或Invoke方法。 -
在 UWP 应用中,调用
Dispatcher对象的RunAsync或Invoke方法。 -
在 Windows Forms 中,调用控件的
BeginInvoke或Invoke方法。
所有这些方法都接受一个委托,该委托引用您要运行的方法。BeginInvoke/RunAsync 通过将委托排入 UI 线程的 消息队列(处理键盘、鼠标和定时器事件的相同队列)来工作。Invoke 做同样的事情,但会阻塞,直到消息被 UI 线程读取和处理。因此,Invoke 允许您从方法中获取返回值。如果不需要返回值,建议使用 BeginInvoke/RunAsync,因为它们不会阻塞调用者,也不会引入死锁的可能性(参见 “死锁”)。
注意
您可以想象当调用 Application.Run 时,以下伪代码会执行:
while (!*thisApplication.Ended*)
{
*wait for something to appear in message queue*
*Got something: what kind of message is it?*
*Keyboard/mouse message -> fire an event handler*
*User* BeginInvoke *message -> execute delegate*
*User **Invoke** message -> execute delegate & post result*
}
正是这种循环方式使工作线程能够将委托调度到 UI 线程执行。
例如,假设我们有一个包含名为 txtMessage 的文本框的 WPF 窗口,我们希望在执行耗时任务(通过调用 Thread.Sleep 模拟)后由工作线程更新其内容。下面是我们如何做到的:
partial class MyWindow : Window
{
public MyWindow()
{
InitializeComponent();
new Thread (Work).Start();
}
void Work()
{
Thread.Sleep (5000); // Simulate time-consuming task
UpdateMessage ("The answer");
}
void UpdateMessage (string message)
{
Action action = () => txtMessage.Text = message;
Dispatcher.BeginInvoke (action);
}
}
运行此代码会立即显示一个响应迅速的窗口。五秒钟后,它会更新文本框的内容。对于 Windows Forms,代码类似,只是我们调用(窗体的)BeginInvoke 方法:
void UpdateMessage (string message)
{
Action action = () => txtMessage.Text = message;
this.BeginInvoke (action);
}
同步上下文
在 System.ComponentModel 命名空间中,有一个名为 SynchronizationContext 的类,它实现了线程调度的通用化。
移动和桌面端的丰富客户端 API(UWP、WPF 和 Windows Forms)各自定义并实例化 SynchronizationContext 的子类,您可以通过静态属性 SynchronizationContext.Current(在 UI 线程上运行时)获取它。捕获此属性后,您可以稍后从工作线程“发布”到 UI 控件:
partial class MyWindow : Window
{
SynchronizationContext _uiSyncContext;
public MyWindow()
{
InitializeComponent();
// Capture the synchronization context for the current UI thread:
_uiSyncContext = SynchronizationContext.Current;
new Thread (Work).Start();
}
void Work()
{
Thread.Sleep (5000); // Simulate time-consuming task
UpdateMessage ("The answer");
}
void UpdateMessage (string message)
{
// Marshal the delegate to the UI thread:
_uiSyncContext.Post (_ => txtMessage.Text = message, null);
}
}
这对所有富客户端用户界面 API 都非常有用。
调用Post等同于在Dispatcher或Control上调用BeginInvoke;还有一个等同于Invoke的Send方法。
线程池
每当启动线程时,都会花费几百微秒来组织诸如新的局部变量堆栈之类的内容。线程池通过具有预创建的可重用线程池减少此开销。线程池对于高效的并行编程和细粒度并发至关重要;它允许短操作运行而无需被线程启动的开销所淹没。
在使用池化线程时需要注意几点:
-
无法设置池化线程的
Name,这会使调试变得更加困难(虽然在 Visual Studio 的线程窗口中调试时可以附加描述)。 -
池化线程始终是后台线程。
-
阻塞池化线程可能会降低性能(参见“线程池中的卫生”)。
可以自由更改池化线程的优先级——释放回池中时将恢复为正常状态。
您可以通过属性Thread.CurrentThread.IsThreadPoolThread确定当前是否在池化线程上执行。
进入线程池
显式在池化线程上运行某些内容的最简单方法是使用Task.Run(我们将在后续章节中详细介绍):
// Task is in System.Threading.Tasks
Task.Run (() => Console.WriteLine ("Hello from the thread pool"));
因为在 .NET Framework 4.0 之前不存在任务,常见的替代方法是调用ThreadPool.QueueUserWorkItem:
ThreadPool.QueueUserWorkItem (notUsed => Console.WriteLine ("Hello"));
注意
以下情况隐含使用线程池:
-
ASP.NET Core 和 Web API 应用服务器
-
System.Timers.Timer和System.Threading.Timer -
我们在第二十二章中描述的并行编程构造
-
(传统的)
BackgroundWorker类
线程池中的卫生
线程池还有另一个功能,即确保临时的计算密集型工作过多时不会导致 CPU超订阅。超订阅是指活动线程比 CPU 核心更多的情况,操作系统必须对线程进行时间切片。超订阅会降低性能,因为时间切片需要昂贵的上下文切换,并且可能使 CPU 缓存无效化,而这对于提供现代处理器性能至关重要。
CLR 通过排队任务和限制其启动来防止线程池超订阅。它首先运行与硬件核心数相同数量的并发任务,然后通过爬坡算法调整并发级别,在特定方向上不断调整工作负载。如果吞吐量提高,则继续沿着同一方向进行(否则将反转)。这确保它始终跟踪最佳性能曲线——即使面对计算机上的竞争进程活动。
如果满足以下两个条件,CLR 的策略效果最佳:
-
工作项通常是短时间运行的(< 250 ms,理想情况下是 < 100 ms),这样 CLR 就有充足的机会来测量和调整。
-
大部分时间都处于阻塞状态的作业不会主导线程池。
阻塞是麻烦的,因为它会让 CLR 误以为它正在加载 CPU。CLR 足够智能,可以检测并补偿(通过向池中注入更多线程),尽管这可能使池子容易受到后续过度订阅的影响。它还会引入延迟,因为 CLR 会限制注入新线程的速率,尤其是在应用程序生命周期的早期(特别是在客户操作系统上,因为它偏向低资源消耗)。
在想要充分利用 CPU 时,线程池中保持良好的卫生特别重要(例如,通过 第二十二章 中的并行编程 API)。
任务
线程是创建并发的低级工具,因此它有一些限制,特别是以下内容:
-
虽然很容易将数据传递给启动的线程,但没有简单的方法可以从你
Join的线程中获取“返回值”。你需要设置某种共享字段。如果操作抛出异常,捕获和传播该异常同样是痛苦的。 -
当线程完成任务后,你不能告诉它开始其他操作;相反,你必须使用
Join方法(在此过程中会阻塞你自己的线程)。
这些限制不利于细粒度并发;换句话说,它们使得通过组合较小的操作来构建更大的并发操作变得困难(这对于我们在后续章节中讨论的异步编程至关重要)。这反过来又导致更多对手动同步(锁定、信号等)的依赖,以及相关问题。
直接使用线程也会影响性能,我们在 “线程池” 中讨论了这些影响。如果你需要运行数百或数千个并发的 I/O 绑定操作,基于线程的方法纯粹消耗数百或数千兆字节的内存作为线程开销。
Task 类帮助解决所有这些问题。与线程相比,Task 是更高级的抽象——它表示可能或可能不由线程支持的并发操作。任务是 可组合的(你可以通过 continuations 将它们链在一起)。它们可以使用 线程池 来减少启动延迟,并且通过 TaskCompletionSource,它们可以在等待 I/O 绑定操作时完全避免线程的回调方法。
Task 类型是在 Framework 4.0 中作为并行编程库的一部分引入的。然而,通过使用 awaiters,它们已经被增强以在更一般的并发场景中同样表现良好,并且是 C# 异步函数的后备类型。
注意
在本节中,我们忽略了专门用于并行编程的任务特性;相反,我们在第二十二章中详细介绍它们。
启动任务
启动由线程支持的Task最简单的方法是使用静态方法Task.Run(Task类位于System.Threading.Tasks命名空间中)。只需传递一个Action委托:
Task.Run (() => Console.WriteLine ("Foo"));
注意
任务默认使用池化线程,这些线程是后台线程。这意味着当主线程结束时,您创建的任何任务也会结束。因此,要从控制台应用程序运行这些示例,您必须在启动任务后阻塞主线程(例如通过Wait任务或调用Console.ReadLine):
Task.Run (() => Console.WriteLine ("Foo"));
Console.ReadLine();
在书籍的 LINQPad 伴侣示例中,由于 LINQPad 进程保持后台线程活动,省略了Console.ReadLine。
通过这种方式调用Task.Run类似于以下方式启动线程(但请注意我们随后讨论的线程池化影响):
new Thread (() => Console.WriteLine ("Foo")).Start();
Task.Run返回一个Task对象,我们可以用它来监视其进度,类似于Thread对象。(但请注意,我们在调用Task.Run后没有调用Start,因为此方法创建“热”任务;您可以使用Task的构造函数创建“冷”任务,尽管这在实践中很少这样做。)
您可以通过其Status属性跟踪任务的执行状态。
Wait
在任务上调用Wait会阻塞,直到它完成,相当于在线程上调用Join:
Task task = Task.Run (() =>
{
Thread.Sleep (2000);
Console.WriteLine ("Foo");
});
Console.WriteLine (task.IsCompleted); // False
task.Wait(); // Blocks until task is complete
Wait允许您可选地指定超时和取消令牌以提前结束等待(参见“取消”)。
长时间运行的任务
默认情况下,CLR 在池化线程上运行任务,这对于运行时间短且计算密集的工作非常理想。对于运行时间较长且阻塞操作(例如我们之前的示例),您可以禁止使用池化线程:
Task task = Task.Factory.StartNew (() => ...,
TaskCreationOptions.LongRunning);
注意
在池化线程上运行一个长时间运行的任务不会引起问题;当您同时运行多个长时间运行且可能阻塞的任务时,性能可能会受到影响。在这种情况下,通常有比TaskCreationOptions.LongRunning更好的解决方案:
-
如果任务是 I/O 绑定的,
TaskCompletionSource和异步函数让您可以通过回调(继续)而不是线程来实现并发。 -
如果任务是计算密集型的,生产者/消费者队列可以让您限制这些任务的并发性,避免其他线程和进程的饥饿(参见“编写生产者/消费者队列”)。
返回值
Task有一个泛型子类称为Task<TResult>,允许任务发出返回值。您可以通过使用Func<TResult>委托(或兼容的 lambda 表达式)而不是Action调用Task.Run来获取Task<TResult>:
Task<int> task = Task.Run (() => { Console.WriteLine ("Foo"); return 3; });
// ...
你可以通过查询Result属性稍后获取结果。如果任务还没有完成,访问这个属性会阻塞当前线程,直到任务完成:
int result = task.Result; // Blocks if not already finished
Console.WriteLine (result); // 3
在以下示例中,我们创建一个使用 LINQ 来计算前三百万(+2)个整数中的质数数量的任务:
Task<int> primeNumberTask = Task.Run (() =>
Enumerable.Range (2, 3000000).Count (n =>
Enumerable.Range (2, (int)Math.Sqrt(n)-1).All (i => n % i > 0)));
Console.WriteLine ("Task running...");
Console.WriteLine ("The answer is " + primeNumberTask.Result);
这会输出“任务正在运行...”,然后几秒钟后输出答案 216816。
注意
Task<TResult>可以被看作是一个“future”,因为它封装了稍后会变得可用的Result。
异常
与线程不同,任务可以方便地传播异常。因此,如果你的任务中的代码抛出了一个未处理的异常(换句话说,如果你的任务失败了),那么该异常会自动重新抛出给调用Wait()或访问Task<TResult>的Result属性的代码:
// Start a Task that throws a NullReferenceException:
Task task = Task.Run (() => { throw null; });
try
{
task.Wait();
}
catch (AggregateException aex)
{
if (aex.InnerException is NullReferenceException)
Console.WriteLine ("Null!");
else
throw;
}
(CLR 会在与并行编程场景兼容的情况下用AggregateException包装异常;我们在第二十二章中讨论这个问题。)
你可以通过Task的IsFaulted和IsCanceled属性测试任务是否失败而不重新抛出异常。如果两个属性都返回 false,表示没有错误;如果IsCanceled为 true,表示该任务因OperationCanceledException而取消(参见“Cancellation”);如果IsFaulted为 true,表示抛出了其他类型的异常,而Exception属性将指示错误信息。
异常和自主任务
对于自主的“设置和忘记”任务(即那些不通过Wait()或Result进行会合的任务或进行相同操作的后续任务),明确地异常处理任务代码是个好习惯,以避免静默失败,就像处理线程一样。
注意
当异常仅仅表示无法获得你不再感兴趣的结果时,忽略异常是可以接受的。例如,如果用户取消了请求下载网页,那么如果发现网页不存在,我们也不会在意。
当异常指示程序中的错误时,忽略异常是有问题的,原因有两个:
-
这个 bug 可能会使你的程序处于无效状态。
-
更多异常可能会因为 bug 而稍后发生,而不记录初始错误会使诊断变得困难。
你可以通过静态事件TaskScheduler.UnobservedTaskException全局订阅未观察到的异常;处理这个事件并记录错误是明智的做法。
有几个有趣的细微差别关于什么算作未观察到的异常:
-
如果在超时后发生故障,等待超时的任务会生成一个未观察到的异常。
-
在任务
faulted后检查任务的Exception属性会使异常被“观察到”。
后续任务
继续操作告诉任务:“完成后,请继续执行其他操作。”通常,继续操作由一个回调实现,该回调在操作完成后执行一次。有两种方法可以将继续操作附加到任务上。第一种特别重要,因为它被 C#的异步函数使用,很快您将看到。我们可以通过我们之前在“返回值”中编写的素数计数任务来演示它:
Task<int> primeNumberTask = Task.Run (() =>
Enumerable.Range (2, 3000000).Count (n =>
Enumerable.Range (2, (int)Math.Sqrt(n)-1).All (i => n % i > 0)));
var awaiter = primeNumberTask.GetAwaiter();
awaiter.OnCompleted (() =>
{
int result = awaiter.GetResult();
Console.WriteLine (result); // Writes result
});
调用任务上的GetAwaiter方法会返回一个awaiter对象,其OnCompleted方法告诉先行任务(primeNumberTask),当其完成(或出错)时执行委托。可以将继续操作附加到已完成的任务上,此时继续操作将立即被安排执行。
注意
awaiter是指任何公开我们刚刚看到的两个方法(OnCompleted和GetResult)及名为IsCompleted的布尔属性的对象。没有界面或基类可以统一所有这些成员(尽管OnCompleted是接口INotifyCompletion的一部分)。我们在“C#中的异步函数”中解释了此模式的重要性。
如果先行任务发生故障,则在继续操作代码调用awaiter.GetResult()时会重新抛出异常。而不是调用GetResult,我们可以简单地访问先行任务的Result属性。调用GetResult的好处在于,如果先行任务发生故障,则异常会直接抛出,而不会包装在AggregateException中,从而使catch块更简单和更清晰。
对于非泛型任务,GetResult()返回一个void值。它的有用功能就是重新抛出异常。
如果存在同步上下文,OnCompleted会自动捕获它,并将继续操作发布到该上下文中。这在富客户端应用程序中非常有用,因为它会将继续操作反弹回 UI 线程。然而,在编写库时,通常不希望这样做,因为相对昂贵的 UI 线程反弹应该仅在离开库时发生一次,而不是在方法调用之间。因此,可以通过使用ConfigureAwait方法来禁用它:
var awaiter = primeNumberTask.ConfigureAwait (false).GetAwaiter();
如果不存在同步上下文,或者使用了ConfigureAwait(false),则(通常情况下)继续操作将在一个池化线程上执行。
另一种附加继续操作的方法是调用任务的ContinueWith方法:
primeNumberTask.ContinueWith (antecedent =>
{
int result = antecedent.Result;
Console.WriteLine (result); // Writes 123
});
ContinueWith 本身返回一个 Task,如果你想附加更多后续操作,这非常有用。然而,如果任务失败,你必须直接处理 AggregateException,并在 UI 应用程序中编写额外的代码来调度后续操作(参见 “任务调度器”)。在非 UI 上下文中,如果希望后续操作在同一线程上执行,必须指定 TaskContinuationOptions.ExecuteSynchronously;否则它将跳到线程池。ContinueWith 在并行编程场景中特别有用;我们在 第二十二章 中详细介绍它。
TaskCompletionSource
我们已经看到 Task.Run 如何创建一个在池化(或非池化)线程上运行委托的任务。另一种创建任务的方式是使用 TaskCompletionSource。
TaskCompletionSource 允许你将任何在未来完成的操作转换为一个任务。它通过提供一个“从属”任务让你手动驱动——指示操作何时完成或失败。这对于 I/O 密集型工作非常理想:你获得了任务的所有好处(能够传播返回值、异常和后续操作),而不需要在操作期间阻塞线程。
要使用 TaskCompletionSource,只需实例化该类。它公开了一个 Task 属性,返回一个任务,你可以等待并附加后续操作——就像任何其他任务一样。然而,任务完全由 TaskCompletionSource 对象控制,通过以下方法:
public class TaskCompletionSource<TResult>
{
public void SetResult (TResult result);
public void SetException (Exception exception);
public void SetCanceled();
public bool TrySetResult (TResult result);
public bool TrySetException (Exception exception);
public bool TrySetCanceled();
public bool TrySetCanceled (CancellationToken cancellationToken);
...
}
调用这些方法之一会触发任务,将其置于完成、失败或取消状态(我们在 “取消” 部分中涵盖了后者)。你应该仅调用其中一个方法一次:如果再次调用,SetResult、SetException 或 SetCanceled 将抛出异常,而 Try* 方法将返回 false。
下面的示例在等待五秒钟后打印出 42:
var tcs = new TaskCompletionSource<int>();
new Thread (() => { Thread.Sleep (5000); tcs.SetResult (42); })
{ IsBackground = true }
.Start();
Task<int> task = tcs.Task; // Our "slave" task.
Console.WriteLine (task.Result); // 42
使用 TaskCompletionSource,我们可以编写自己的 Run 方法:
Task<TResult> Run<TResult> (Func<TResult> function)
{
var tcs = new TaskCompletionSource<TResult>();
new Thread (() =>
{
try { tcs.SetResult (function()); }
catch (Exception ex) { tcs.SetException (ex); }
}).Start();
return tcs.Task;
}
...
Task<int> task = Run (() => { Thread.Sleep (5000); return 42; });
调用此方法等效于使用 Task.Factory.StartNew 并传递 TaskCreationOptions.LongRunning 选项来请求非池化线程。
TaskCompletionSource 的真正威力在于创建不会阻塞线程的任务。例如,考虑一个等待五秒钟然后返回数字 42 的任务。我们可以使用 Timer 类来实现,它借助 CLR(和随之的操作系统)在 x 毫秒后触发事件(我们在 第二十一章 中重新审视定时器):
Task<int> GetAnswerToLife()
{
var tcs = new TaskCompletionSource<int>();
// Create a timer that fires once in 5000 ms:
var timer = new System.Timers.Timer (5000) { AutoReset = false };
timer.Elapsed += delegate { timer.Dispose(); tcs.SetResult (42); };
timer.Start();
return tcs.Task;
}
因此,我们的方法返回一个任务,五秒钟后完成,结果为 42。通过将后续操作附加到任务上,我们可以输出其结果而不阻塞任何线程:
var awaiter = GetAnswerToLife().GetAwaiter();
awaiter.OnCompleted (() => Console.WriteLine (awaiter.GetResult()));
我们可以通过参数化延迟时间并去除返回值来使这更有用,并将其转化为通用的Delay方法。这意味着将其返回一个Task而不是Task<int>。然而,TaskCompletionSource没有非泛型版本,这意味着我们不能直接创建非泛型Task。解决方法很简单:因为Task<TResult>派生自Task,我们创建一个TaskCompletionSource<*anything*>,然后将它给你的Task<*anything*>隐式转换为Task,就像这样:
var tcs = new TaskCompletionSource<object>();
Task task = tcs.Task;
现在我们可以编写我们的通用Delay方法:
Task Delay (int milliseconds)
{
var tcs = new TaskCompletionSource<object>();
var timer = new System.Timers.Timer (milliseconds) { AutoReset = false };
timer.Elapsed += delegate { timer.Dispose(); tcs.SetResult (null); };
timer.Start();
return tcs.Task;
}
注意
.NET 5 引入了一个非泛型的TaskCompletionSource,所以如果你的目标是.NET 5 或更高版本,你可以用TaskCompletionSource<object>替代TaskCompletionSource。
以下是如何在五秒钟后写入“42”的方法:
Delay (5000).GetAwaiter().OnCompleted (() => Console.WriteLine (42));
我们在没有线程的情况下使用TaskCompletionSource,这意味着只有当续体启动时,即五秒后才会涉及线程。我们可以通过同时启动 10,000 个这样的操作来演示这一点,而不会出错或消耗过多资源。
for (int i = 0; i < 10000; i++)
Delay (5000).GetAwaiter().OnCompleted (() => Console.WriteLine (42));
注意
定时器在池化线程上触发它们的回调,因此五秒后,线程池将收到 10,000 个请求,要求在TaskCompletionSource上调用SetResult(null)。如果请求到达的速度超过它们可以处理的速度,线程池将通过以最佳的并行性水平排队和处理它们来响应。这在线程绑定的作业运行时间短的情况下是理想的,本例中属实:线程绑定的作业仅仅是调用SetResult再加上将继续对象发布到同步上下文(在 UI 应用程序中)或者是继续对象本身(Console.WriteLine(42))的动作。
Task.Delay
我们刚刚编写的Delay方法非常有用,它作为Task类的静态方法提供:
Task.Delay (5000).GetAwaiter().OnCompleted (() => Console.WriteLine (42));
或:
Task.Delay (5000).ContinueWith (ant => Console.WriteLine (42));
Task.Delay是Thread.Sleep的异步等价物。
异步原则
在演示TaskCompletionSource时,我们最终编写了异步方法。在本节中,我们确切定义了异步操作,并解释了这如何导致异步编程。
同步与异步操作
同步操作在返回给调用者之前完成其工作。
异步操作可以在返回给调用者之后完成其(大部分或全部)工作。
您编写和调用的大多数方法都是同步的。例如List<T>.Add、Console.WriteLine或Thread.Sleep。异步方法较少见并引发并发,因为工作并行进行。异步方法通常会快速(或立即)返回给调用者;因此,它们也称为非阻塞方法。
到目前为止,我们看到的大多数异步方法可以描述为通用方法:
-
Thread.Start -
Task.Run -
将续体附加到任务的方法
此外,我们在 “同步上下文” 中讨论的一些方法(Dispatcher.BeginInvoke、Control.BeginInvoke 和 SynchronizationContext.Post)是异步的,我们在 “TaskCompletionSource” 中编写的方法也是如此,包括 Delay。
什么是异步编程?
异步编程的原则是您以异步方式编写长时间运行(或潜在长时间运行)的函数。这与传统的同步编写长时间运行函数的方法形成对比,然后从新线程或任务中调用这些函数以引入所需的并发性。
与异步方法的区别在于,并发是从长时间运行的函数内部 启动 而不是从函数 外部 启动。这有两个好处:
-
可以实现不捆绑线程的 I/O 绑定并发性(正如我们在 “TaskCompletionSource” 中演示的),从而改善可伸缩性和效率。
-
富客户端应用程序最终在工作线程上的代码量减少,简化了线程安全性。
这反过来导致异步编程有两个明显的用途。第一个是编写(通常是服务器端)应用程序,可以有效地处理大量并发的 I/O。这里的挑战不是线程 安全性(因为通常共享状态很少),而是线程 效率;特别是不要为每个网络请求消耗一个线程。因此,在这种情况下,只有 I/O 绑定的操作才能从异步中受益。
第二个用途是简化富客户端应用程序中的线程安全性。这在程序规模增大时特别重要,因为为了处理复杂性,我们通常将较大的方法重构为较小的方法,导致相互调用的方法链(调用图)。
使用传统的 同步 调用图,如果图中的任何操作耗时较长,我们必须在工作线程上运行整个调用图以保持响应的用户界面。因此,我们最终会得到一个跨越多个方法的单个并发操作(粗粒度并发),这需要考虑图中每个方法的线程安全性。
使用 异步 调用图,我们不需要在实际需要之前启动线程,通常在图的较低部分(或者在 I/O 绑定操作的情况下根本不需要)。所有其他方法都可以完全在 UI 线程上运行,线程安全性大大简化。这导致了 细粒度并发 ——一系列小的并发操作,其中执行在 UI 线程之间反弹。
注意
要从中受益,I/O 和计算绑定的操作都需要以异步方式编写;一个很好的经验法则是包括任何可能超过 50 毫秒的操作。
(另一方面,过度 细粒度的异步可能会损害性能,因为异步操作会产生开销——参见 “优化”。)
在本章中,我们主要关注更复杂的富客户端场景。在 第十六章 中,我们给出了两个示例,说明了 I/O 密集型场景(参见 “使用 TCP 进行并发” 和 “编写 HTTP 服务器”)。
注意
UWP 框架鼓励异步编程,以至于某些长时间运行的方法的同步版本要么不公开,要么会抛出异常。因此,您必须调用返回任务的异步方法(或可以通过 AsTask 扩展方法转换为任务的对象)。
异步编程和连续性
任务非常适合异步编程,因为它们支持连续性,这对于异步性是至关重要的(考虑我们在 TaskCompletionSource 中编写的 Delay 方法)。在编写 Delay 方法时,我们使用了 TaskCompletionSource,这是实现“底层”I/O 密集型异步方法的一种标准方式。
对于计算密集型方法,我们使用 Task.Run 来启动线程绑定的并发。通过将任务返回给调用方,我们简单地创建了一个异步方法。异步编程的区别在于,我们的目标是在调用图中较低的位置执行此操作,以便在富客户端应用程序中,高级方法可以保持在 UI 线程上,并访问控件和共享状态而无需担心线程安全问题。例如,考虑以下计算和计数素数的方法,利用所有可用的核心(我们在 第二十二章 中讨论了 ParallelEnumerable):
int GetPrimesCount (int start, int count)
{
return
ParallelEnumerable.Range (start, count).Count (n =>
Enumerable.Range (2, (int)Math.Sqrt(n)-1).All (i => n % i > 0));
}
这如何运行的细节并不重要;重要的是可能需要一段时间来运行。我们可以通过编写另一个方法来演示这一点:
void DisplayPrimeCounts()
{
for (int i = 0; i < 10; i++)
Console.WriteLine (GetPrimesCount (i*1000000 + 2, 1000000) +
" primes between " + (i*1000000) + " and " + ((i+1)*1000000-1));
Console.WriteLine ("Done!");
}
这里是输出:
78498 primes between 0 and 999999
70435 primes between 1000000 and 1999999
67883 primes between 2000000 and 2999999
66330 primes between 3000000 and 3999999
65367 primes between 4000000 and 4999999
64336 primes between 5000000 and 5999999
63799 primes between 6000000 and 6999999
63129 primes between 7000000 and 7999999
62712 primes between 8000000 and 8999999
62090 primes between 9000000 and 9999999
现在我们有一个 调用图,其中 DisplayPrimeCounts 调用 GetPrimesCount。为简单起见,后者使用 Console.WriteLine,尽管实际上在富客户端应用程序中更可能是更新 UI 控件,正如我们后面演示的那样。我们可以为此调用图启动粗粒度并发,如下所示:
Task.Run (() => DisplayPrimeCounts());
使用精细粒度的异步方法,我们首先编写 GetPrimesCount 的异步版本:
Task<int> GetPrimesCountAsync (int start, int count)
{
return Task.Run (() =>
ParallelEnumerable.Range (start, count).Count (n =>
Enumerable.Range (2, (int) Math.Sqrt(n)-1).All (i => n % i > 0)));
}
为何语言支持如此重要
现在我们必须修改 DisplayPrimeCounts,使其调用 GetPrimesCount**Async**。这就是 C# 的 await 和 async 关键字发挥作用的地方,因为否则这样做比听起来更加棘手。如果我们简单地修改循环如下:
for (int i = 0; i < 10; i++)
{
var awaiter = GetPrimesCountAsync (i*1000000 + 2, 1000000).GetAwaiter();
awaiter.OnCompleted (() =>
Console.WriteLine (awaiter.GetResult() + " primes between... "));
}
Console.WriteLine ("Done");
循环将快速通过 10 次迭代(方法为非阻塞),并且所有 10 个操作将并行执行(随后为过早的 “完成”)。
注意
在这种情况下并行执行这些任务是不可取的,因为它们的内部实现已经并行化;这只会使我们等待更长时间才能看到第一个结果(并且会混乱顺序)。
但有一个更常见的原因需要串行化任务的执行,那就是任务 B 依赖于任务 A 的结果。例如,在获取网页时,DNS 查找必须在 HTTP 请求之前进行。
要使它们按顺序运行,必须从继续本身触发下一个循环迭代。这意味着消除for循环,并在继续中采用递归调用:
void DisplayPrimeCounts()
{
DisplayPrimeCountsFrom (0);
}
void DisplayPrimeCountsFrom (int i)
{
var awaiter = GetPrimesCountAsync (i*1000000 + 2, 1000000).GetAwaiter();
awaiter.OnCompleted (() =>
{
Console.WriteLine (awaiter.GetResult() + " primes between...");
if (++i < 10) DisplayPrimeCountsFrom (i);
else Console.WriteLine ("Done");
});
}
如果我们想使DisplayPrimesCount 本身 异步运行,并返回一个任务以在完成时发出信号,情况会变得更糟。要实现这一点,需要创建一个TaskCompletionSource:
Task DisplayPrimeCountsAsync()
{
var machine = new PrimesStateMachine();
machine.DisplayPrimeCountsFrom (0);
return machine.Task;
}
class PrimesStateMachine
{
TaskCompletionSource<object> _tcs = new TaskCompletionSource<object>();
public Task Task { get { return _tcs.Task; } }
public void DisplayPrimeCountsFrom (int i)
{
var awaiter = GetPrimesCountAsync (i*1000000+2, 1000000).GetAwaiter();
awaiter.OnCompleted (() =>
{
Console.WriteLine (awaiter.GetResult());
if (++i < 10) DisplayPrimeCountsFrom (i);
else { Console.WriteLine ("Done"); _tcs.SetResult (null); }
});
}
}
幸运的是,C#的异步函数已经为我们完成了所有这些工作。使用async和await关键字,我们只需编写如下代码:
async Task DisplayPrimeCountsAsync()
{
for (int i = 0; i < 10; i++)
Console.WriteLine (await GetPrimesCountAsync (i*1000000 + 2, 1000000) +
" primes between " + (i*1000000) + " and " + ((i+1)*1000000-1));
Console.WriteLine ("Done!");
}
因此,async和await对于实现异步性而又不至于过于复杂至关重要。现在让我们看看这些关键字是如何工作的。
注意
另一种看待这个问题的方式是,命令式循环结构(例如for,foreach等)与继续(continuations)不太兼容,因为它们依赖于方法的当前本地状态(“这个循环还要运行多少次?”)。
虽然async和await关键字提供了一种解决方案,但有时可以通过将命令式循环结构替换为函数式等效物(即 LINQ 查询)的另一种方式来解决问题。这是响应式扩展(Rx)的基础,当您希望在结果上执行查询操作或组合多个序列时,可以是一个不错的选择。为了避免阻塞,Rx 通过基于推送的序列运行,这在概念上可能会有些棘手。
C#中的异步函数
async和await关键字使您能够编写具有与同步代码相同结构和简单性的异步代码,同时消除了异步编程的“管道工程”。
等待
await关键字简化了附加继续的过程。从基本场景开始,编译器扩展为:
var *result* = await *expression*;
*statement(s)*;
转换为类似以下功能的东西:
var awaiter = *expression*.GetAwaiter();
awaiter.OnCompleted (() =>
{
var *result* = awaiter.GetResult();
*statement(s)*;
});
注意
编译器还会生成代码,以在同步完成时快速终止继续(参见“优化”),并处理我们在后面章节中掌握的各种微妙之处。
为了演示,让我们重新审视之前编写的异步方法,计算并计数质数:
Task<int> GetPrimesCountAsync (int start, int count)
{
return Task.Run (() =>
ParallelEnumerable.Range (start, count).Count (n =>
Enumerable.Range (2, (int)Math.Sqrt(n)-1).All (i => n % i > 0)));
}
使用await关键字,我们可以如下调用它:
int result = await GetPrimesCountAsync (2, 1000000);
Console.WriteLine (result);
要编译,我们需要在包含的方法上添加async修饰符:
async void DisplayPrimesCount()
{
int result = await GetPrimesCountAsync (2, 1000000);
Console.WriteLine (result);
}
async修饰符指示编译器将await视为关键字,而不是标识符,这可以确保在该方法内部可能出现await作为标识符的代码仍然可以编译而不会出错。async修饰符只能应用于返回void或(稍后将看到的)Task或Task<TResult>的方法(和 lambda 表达式)。
注意
async修饰符类似于unsafe修饰符,因为它对方法的签名或公共元数据没有影响;它只影响方法内部的操作。因此,在接口中使用async是没有意义的。然而,例如,在重写非async虚拟方法时引入async是合法的,只要保持签名相同。
带有async修饰符的方法被称为异步函数,因为它们本身通常是异步的。要了解原因,让我们看看执行如何通过异步函数进行。
在遇到await表达式时,执行(通常)会返回给调用者,就像在迭代器中使用yield return一样。但在返回之前,运行时会将一个续约(continuation)附加到等待的任务上,确保任务完成时,执行会跳回方法中,并继续之前的执行点。如果任务失败,它的异常会被重新抛出,否则其返回值会被赋给await表达式。我们可以通过查看我们刚刚检查的异步方法的逻辑扩展来总结我们刚刚说的一切:
void DisplayPrimesCount()
{
var awaiter = GetPrimesCountAsync (2, 1000000).GetAwaiter();
awaiter.OnCompleted (() =>
{
int result = awaiter.GetResult();
Console.WriteLine (result);
});
}
你await的表达式通常是一个任务(task);然而,任何具有返回一个awaiter(实现了INotifyCompletion.OnCompleted,具有适当类型的GetResult方法和bool IsCompleted属性)的GetAwaiter方法的对象都将满足编译器的要求。
注意,我们的await表达式评估为int类型;这是因为我们等待的表达式是一个Task<int>(其GetAwaiter().GetResult()方法返回int)。
等待一个非泛型任务是合法的,并生成一个void表达式:
await Task.Delay (5000);
Console.WriteLine ("Five seconds passed!");
捕获本地状态
await表达式的真正威力在于它们几乎可以出现在代码的任何地方。具体来说,在异步函数中,await表达式可以出现在任何表达式的位置,除了在lock语句或unsafe上下文内部。
在以下示例中,我们在循环中使用await:
async void DisplayPrimeCounts()
{
for (int i = 0; i < 10; i++)
Console.WriteLine (await GetPrimesCountAsync (i*1000000+2, 1000000));
}
在首次执行GetPrimesCountAsync时,由于await表达式的存在,执行返回给调用者。当方法完成(或失败)时,执行会在之前暂停的地方恢复,本地变量和循环计数器的值保持不变。
如果没有await关键字,最简单的等价可能是我们在“为什么语言支持很重要”中编写的示例。然而,编译器更倾向于将这样的方法重构为状态机(类似于处理迭代器)。
编译器依赖于继续(通过等待器模式)来在 await 表达式后恢复执行。这意味着,如果在富客户端应用程序的 UI 线程上运行,同步上下文确保执行恢复在同一线程上。否则,执行将恢复在任务完成时的任何线程上。线程的更改不会影响执行顺序,并且在不依赖线程亲和性的情况下影响不大,可能通过线程本地存储(参见“线程本地存储”)进行依赖。这就像在城市中游览并招手拦出租车从一个地方到另一个地方。有同步上下文时,您总会得到相同的出租车;没有同步上下文时,您通常会每次都得到不同的出租车。不过,不管哪种情况,旅程都是一样的。
在 UI 中等待
我们可以通过编写一个简单的 UI,在调用计算绑定方法时保持响应性,更实际地演示异步函数。让我们从同步解决方案开始:
class TestUI : Window
{
Button _button = new Button { Content = "Go" };
TextBlock _results = new TextBlock();
public TestUI()
{
var panel = new StackPanel();
panel.Children.Add (_button);
panel.Children.Add (_results);
Content = panel;
_button.Click += (sender, args) => Go();
}
void Go()
{
for (int i = 1; i < 5; i++)
_results.Text += GetPrimesCount (i * 1000000, 1000000) +
" primes between " + (i*1000000) + " and " + ((i+1)*1000000-1) +
Environment.NewLine;
}
int GetPrimesCount (int start, int count)
{
return ParallelEnumerable.Range (start, count).Count (n =>
Enumerable.Range (2, (int) Math.Sqrt(n)-1).All (i => n % i > 0));
}
}
按下“Go”按钮后,应用程序在执行计算绑定代码时变得无响应。在将此异步化的两个步骤中,第一步是切换到我们在之前示例中使用的 GetPrimesCount 的异步版本:
Task<int> GetPrimesCountAsync (int start, int count)
{
return Task.Run (() =>
ParallelEnumerable.Range (start, count).Count (n =>
Enumerable.Range (2, (int) Math.Sqrt(n)-1).All (i => n % i > 0)));
}
第二步是修改 Go 方法调用 GetPrimesCountAsync:
async void Go()
{
_button.IsEnabled = false;
for (int i = 1; i < 5; i++)
_results.Text += await GetPrimesCountAsync (i * 1000000, 1000000) +
" primes between " + (i*1000000) + " and " + ((i+1)*1000000-1) +
Environment.NewLine;
_button.IsEnabled = true;
}
这说明了使用异步函数编程的简单性:您编写的方式与同步编程相同,但调用异步函数而不是阻塞函数,并且使用 await 等待它们。只有 GetPrimesCountAsync 中的代码在工作线程上运行;而 Go 中的代码则“租赁”了 UI 线程的时间。我们可以说 Go 在消息循环中 伪并发 执行(即其执行与 UI 线程处理的其他事件交织在一起)。在这种伪并发中,唯一可能发生抢占的时刻是在 await 期间。这简化了线程安全性:在我们的情况下,这可能导致的唯一问题是 重入(在运行时再次点击按钮,我们通过禁用按钮来防止这种情况)。真正的并发发生在调用 Task.Run 的调用堆栈较低处,以确保此模型的受益,真正的并发代码禁止访问共享状态或 UI 控件。
举例来说,假设我们不是计算素数,而是要下载几个网页并计算它们的长度。.NET 提供了许多返回任务的异步方法之一是 System.Net 中的 WebClient 类。DownloadDataTaskAsync 方法异步下载 URI 到字节数组,返回一个 Task<byte[]>,因此通过等待它,我们得到一个 byte[]。现在让我们重新编写我们的 Go 方法:
async void Go()
{
_button.IsEnabled = false;
string[] urls = "www.albahari.com www.oreilly.com www.linqpad.net".Split();
int totalLength = 0;
try
{
foreach (string url in urls)
{
var uri = new Uri ("http://" + url);
byte[] data = await new WebClient().DownloadDataTaskAsync (uri);
_results.Text += "Length of " + url + " is " + data.Length +
Environment.NewLine;
totalLength += data.Length;
}
_results.Text += "Total length: " + totalLength;
}
catch (WebException ex)
{
_results.Text += "Error: " + ex.Message;
}
finally { _button.IsEnabled = true; }
}
再次,这反映了我们同步编写它的方式,包括使用catch和finally块。尽管执行在第一个await后返回到调用者,但finally块直到方法逻辑上完成(通过所有代码执行或早期的return或未处理的异常)才执行。
考虑到底层正在发生的事情可能会有所帮助。首先,我们需要重新访问在 UI 线程上运行消息循环的伪代码:
*Set synchronization context for this thread to WPF sync context*
while (!*thisApplication.Ended*)
{
*wait for something to appear in message queue*
*Got something: what kind of message is it?*
*Keyboard/mouse message -> fire an event handler*
*User **BeginInvoke/Invoke** message -> execute delegate*
}
我们附加到 UI 元素的事件处理程序通过此消息循环执行。当我们的Go方法运行时,执行将继续到await表达式,然后返回到消息循环(使 UI 能够响应进一步的事件)。然而,await的编译器扩展确保在返回之前设置一个继续,以便在任务完成时执行恢复执行到离开的地方。并且因为我们在 UI 线程上等待,所以继续通过同步上下文发布,通过消息循环执行它,使我们整个Go方法在 UI 线程上伪并发执行。真正的(I/O 绑定)并发发生在DownloadDataTaskAsync的实现中。
与粗粒度并发比较
在 C# 5 之前,异步编程很困难,不仅因为没有语言支持,而且因为 .NET Framework 通过笨拙的模式(称为 EAP 和 APM,参见“过时模式”)暴露了异步功能,而不是返回任务的方法。
流行的解决方法是粗粒度并发(事实上,甚至还有一种称为BackgroundWorker的类型来帮助处理)。回到我们最初的同步示例GetPrimesCount,我们可以通过修改按钮的事件处理程序来演示粗粒度异步,如下所示:
...
_button.Click += (sender, args) =>
{
_button.IsEnabled = false;
Task.Run (() => Go());
};
(我们选择使用Task.Run而不是BackgroundWorker,因为后者对我们特定的示例没有简化作用。)无论哪种情况,最终结果是我们整个同步调用图(Go加上GetPrimesCount)都在工作线程上运行。并且因为Go更新 UI 元素,我们现在必须在代码中散布Dispatcher.BeginInvoke:
void Go()
{
for (int i = 1; i < 5; i++)
{
int result = GetPrimesCount (i * 1000000, 1000000);
Dispatcher.BeginInvoke (new Action (() =>
_results.Text += result + " primes between " + (i*1000000) +
" and " + ((i+1)*1000000-1) + Environment.NewLine));
}
Dispatcher.BeginInvoke (new Action (() => _button.IsEnabled = true));
}
与异步版本不同,循环本身在工作线程上运行。这看起来可能是无害的,然而,即使在这种简单情况下,我们的多线程使用也引入了竞争条件。(你能发现吗?如果不能,请尝试运行程序:几乎肯定会变得显而易见。)
实现取消和进度报告会增加线程安全错误的可能性,方法中的任何额外代码也会如此。例如,假设循环的上限不是硬编码的,而是来自方法调用:
for (int i = 1; i < GetUpperBound(); i++)
现在假设GetUpperBound()从延迟加载的配置文件中读取值,在第一次调用时从磁盘加载。所有这些代码现在都在您的工作线程上运行,这段代码很可能不是线程安全的。这就是在调用图的高处启动工作线程的危险。
编写异步函数
对于任何异步函数,您可以将void返回类型替换为Task,使方法本身有用异步(并且可以await)。不需要进一步的更改:
async Task PrintAnswerToLife() // We can return Task instead of void
{
await Task.Delay (5000);
int answer = 21 * 2;
Console.WriteLine (answer);
}
请注意,在方法体中我们并未显式返回任务。编译器会制造任务,并在方法完成时(或未处理的异常时)发出信号。这使得创建异步调用链变得容易:
async Task Go()
{
await PrintAnswerToLife();
Console.WriteLine ("Done");
}
因为我们已将Go声明为Task返回类型,所以Go本身是可等待的。
编译器会将返回任务的异步函数展开成使用TaskCompletionSource创建任务的代码,然后信号或故障。
除了细微差别,我们可以将PrintAnswerToLife扩展为以下功能等效形式:
Task PrintAnswerToLife()
{
var tcs = new TaskCompletionSource<object>();
var awaiter = Task.Delay (5000).GetAwaiter();
awaiter.OnCompleted (() =>
{
try
{
awaiter.GetResult(); // Re-throw any exceptions
int answer = 21 * 2;
Console.WriteLine (answer);
tcs.SetResult (null);
}
catch (Exception ex) { tcs.SetException (ex); }
});
return tcs.Task;
}
因此,每当返回任务的异步方法完成时,执行都会跳回到任何等待它的地方(通过延续)。
注:
在富客户端场景中,执行在此处回到 UI 线程(如果尚未在 UI 线程上)。否则,它会继续在连续体返回的任何线程上执行。这意味着在向上冒泡异步调用图时,除了第一次“弹跳”(如果是 UI 线程启动),没有延迟成本。
返回Task<TResult>
如果方法体返回TResult,则可以返回Task<TResult>:
async Task<int> GetAnswerToLife()
{
await Task.Delay (5000);
int answer = 21 * 2;
return answer; // Method has return type Task<int> we return int
}
在内部,这会导致TaskCompletionSource用值而不是null被信号化。我们可以通过从Go调用它的方式演示GetAnswerToLife(而Go本身则从PrintAnswerToLife调用):
async Task Go()
{
await PrintAnswerToLife();
Console.WriteLine ("Done");
}
async Task PrintAnswerToLife()
{
int answer = await GetAnswerToLife();
Console.WriteLine (answer);
}
async Task<int> GetAnswerToLife()
{
await Task.Delay (5000);
int answer = 21 * 2;
return answer;
}
实际上,我们将原始的PrintAnswerToLife重构为两种方法——与编程同步一样容易。与同步编程的相似性是有意的;这是我们调用图的同步等效,调用Go()在阻塞五秒后会得到相同的结果:
void Go()
{
PrintAnswerToLife();
Console.WriteLine ("Done");
}
void PrintAnswerToLife()
{
int answer = GetAnswerToLife();
Console.WriteLine (answer);
}
int GetAnswerToLife()
{
Thread.Sleep (5000);
int answer = 21 * 2;
return answer;
}
注:
这也说明了如何设计带有异步函数的基本原理:
-
将您的方法同步编写。
-
用异步方法调用替换同步方法调用,并对其进行
await。 -
除了“顶级”方法(通常是 UI 控件的事件处理程序),将您的异步方法的返回类型升级为
Task或Task<TResult>以使它们可以被等待。
编译器为异步函数制造任务的能力意味着,在大多数情况下,您只需在启动 I/O 绑定并发的底层方法(相对罕见的情况)中显式实例化TaskCompletionSource。(对于启动计算绑定并发的方法,您可以使用Task.Run创建任务。)
异步调用图执行
要确切了解这是如何执行的,重新排列我们的代码会有所帮助:
async Task Go()
{
var task = PrintAnswerToLife();
await task; Console.WriteLine ("Done");
}
async Task PrintAnswerToLife()
{
var task = GetAnswerToLife();
int answer = await task; Console.WriteLine (answer);
}
async Task<int> GetAnswerToLife()
{
var task = Task.Delay (5000);
await task; int answer = 21 * 2; return answer;
}
Go调用PrintAnswerToLife,它调用GetAnswerToLife,后者调用Delay然后等待。await导致执行返回到PrintAnswerToLife,它本身在等待,返回到Go,它也在等待并返回到调用方。所有这些都是在调用Go的线程上同步发生的;这是执行的简短同步阶段。
五秒钟后,Delay上的延续触发,执行返回到池化线程上的GetAnswerToLife。(如果我们在 UI 线程上启动,执行现在会回到该线程。)然后GetAnswerToLife中的剩余语句运行,之后该方法的Task<int>完成并以 42 的结果执行PrintAnswerToLife中的延续,执行该方法中的其余语句。此过程持续,直到Go的任务标记为完成。
执行流程与我们之前展示的同步调用图匹配,因为我们遵循的模式是,在调用每个异步方法后立即await它。这创建了一个顺序流程,在调用图内部没有并行或重叠执行。每个await表达式在执行中创建了一个“间隙”,在此之后程序恢复到离开的位置。
并行性
调用异步方法而不等待它允许后续代码并行执行。您可能已经注意到在先前的示例中,我们有一个按钮,其事件处理程序调用了Go,如下所示:
_button.Click += (sender, args) => Go();
尽管Go是一个异步方法,但我们并没有等待它,这确实有助于维护响应式 UI 所需的并发性。
我们可以使用相同的原理来并行运行两个异步操作:
var task1 = PrintAnswerToLife();
var task2 = PrintAnswerToLife();
await task1; await task2;
(在之后等待这两个操作后,我们“结束”了此时的并行性。稍后,我们将描述WhenAll任务组合器如何处理这种模式。)
以这种方式创建的并发无论操作是否在 UI 线程上启动都会发生,尽管它们的发生方式有所不同。在这两种情况下,我们都会在启动它的底层操作(如Task.Delay或委托给Task.Run的代码)中得到相同的“真正”并发。如果调用堆栈中的方法在没有同步上下文的情况下启动操作,那么这些方法将仅在await语句处于伪并发状态下(并简化线程安全);这使我们能够在GetAnswerToLife中定义一个共享字段_x并增加它,而无需锁定:
async Task<int> GetAnswerToLife()
{
_x++;
await Task.Delay (5000);
return 21 * 2;
}
(但我们无法假设在await之前和之后_x具有相同的值。)
异步 Lambda 表达式
就像普通的命名方法可以是异步的一样:
async Task NamedMethod()
{
await Task.Delay (1000);
Console.WriteLine ("Foo");
}
所以,如果前面加上async关键字,无名方法(Lambda 表达式和匿名方法)也可以:
Func<Task> unnamed = async () =>
{
await Task.Delay (1000);
Console.WriteLine ("Foo");
};
我们可以以相同的方式调用并等待这些内容:
await NamedMethod();
await unnamed();
当附加事件处理程序时,我们可以使用异步 lambda 表达式:
myButton.Click += async (sender, args) =>
{
await Task.Delay (1000);
myButton.Content = "Done";
};
这比下面具有相同效果的更为简洁:
myButton.Click += ButtonHandler;
...
async void ButtonHandler (object sender, EventArgs args)
{
await Task.Delay (1000);
myButton.Content = "Done";
};
异步 lambda 表达式也可以返回 Task<TResult>:
Func<Task<int>> unnamed = async () =>
{
await Task.Delay (1000);
return 123;
};
int answer = await unnamed();
异步流
使用 yield return,您可以编写迭代器;使用 await,您可以编写异步函数。异步流(来自 C# 8)结合了这些概念,让您编写一个同时等待和异步产生元素的迭代器。此支持建立在以下一对接口之上,它们是我们在 “枚举和迭代器” 中描述的枚举接口的异步对应版本:
public interface IAsyncEnumerable<out T>
{
IAsyncEnumerator<T> GetAsyncEnumerator (...);
}
public interface IAsyncEnumerator<out T>: IAsyncDisposable
{
T Current { get; }
ValueTask<bool> MoveNextAsync();
}
ValueTask<T> 是一个包装了 Task<T> 并在任务完成时行为类似于 Task<T> 的结构体(在枚举序列时经常发生)。参见 ValueTask<T> 讨论其区别。IAsyncDisposable 是 IDisposable 的异步版本;它提供了执行清理操作的机会,如果您选择手动实现这些接口:
public interface IAsyncDisposable
{
ValueTask DisposeAsync();
}
注意
从序列中获取每个元素的操作 (MoveNextAsync) 是一个异步操作,因此当元素逐个到达时,异步流非常适合(例如处理来自视频流的数据)。相比之下,以下类型在整体上延迟时更适合,但元素到达时会全部到达:
Task<IEnumerable<T>>
要生成异步流,您需要编写结合了迭代器和异步方法原理的方法。换句话说,您的方法应包括 yield return 和 await,并且应返回 IAsyncEnumerable<T>:
async IAsyncEnumerable<int> RangeAsync (
int start, int count, int delay)
{
for (int i = start; i < start + count; i++)
{
await Task.Delay (delay);
yield return i;
}
}
要消耗异步流,使用 await foreach 语句:
await foreach (var number in RangeAsync (0, 10, 500))
Console.WriteLine (number);
请注意,数据稳定地每 500 毫秒到达一次(或在现实中,随着数据的可用性)。与使用 Task<IEnumerable<T>> 的类似结构相比,后者直到最后一个数据可用时才返回数据:
static async Task<IEnumerable<int>> RangeTaskAsync (int start, int count,
int delay)
{
List<int> data = new List<int>();
for (int i = start; i < start + count; i++)
{
await Task.Delay (delay);
data.Add (i);
}
return data;
}
这是如何使用 foreach 语句消耗它的方法:
foreach (var data in await RangeTaskAsync(0, 10, 500))
Console.WriteLine (data);
查询 IAsyncEnumerable
System.Linq.Async NuGet 包定义了在 IAsyncEnumerable<T> 上操作的 LINQ 查询操作符,允许您像使用 IEnumerable<T> 一样编写查询。
例如,我们可以编写一个 LINQ 查询,针对前面章节中定义的 RangeAsync 方法,如下所示:
IAsyncEnumerable<int> query =
from i in RangeAsync (0, 10, 500)
where i % 2 == 0 // Even numbers only.
select i * 10; // Multiply by 10.
await foreach (var number in query)
Console.WriteLine (number);
这将输出 0、20、40 等。
注意
如果您熟悉 Rx,您还可以通过调用 ToObservable 扩展方法来获益,该方法转换 IAsyncEnumerable<T> 为 IObservable<T>,从而使用其更强大的查询操作符。还有一个 ToAsyncEnumerable 扩展方法,可以反向转换。
在 ASP.Net Core 中的 IAsyncEnumerable
ASP.Net Core 控制器动作现在可以返回 IAsyncEnumerable<T>。这样的方法必须标记为 async。例如:
[HttpGet]
public async IAsyncEnumerable<string> Get()
{
using var dbContext = new BookContext();
await foreach (var title in dbContext.Books
.Select(b => b.Title)
.AsAsyncEnumerable())
yield return title;
}
WinRT 中的异步方法
如果您正在开发 UWP 应用程序,则需要使用操作系统中定义的 WinRT 类型。WinRT 中 Task 的等效物是 IAsyncAction,Task<TResult> 的等效物是 IAsyncOperation<TResult>。而对于报告进度的操作,等效物是 IAsyncActionWithProgress<TProgress> 和 IAsyncOperationWithProgress<TResult, TProgress>。它们都定义在 Windows.Foundation 命名空间中。
您可以通过 AsTask 扩展方法从任一转换为 Task 或 Task<TResult>:
Task<StorageFile> fileTask = KnownFolders.DocumentsLibrary.CreateFileAsync
("test.txt").AsTask();
或者您可以直接等待它们:
StorageFile file = await KnownFolders.DocumentsLibrary.CreateFileAsync
("test.txt");
注意
由于 COM 类型系统的限制,IAsyncActionWithProgress<TProgress> 和 IAsyncOperationWithProgress<TResult, TProgress> 并不基于预期的 IAsyncAction。相反,两者都继承自称为 IAsyncInfo 的共同基类型。
AsTask 方法也重载为接受取消标记(参见 “Cancellation”)。当链接到 WithProgress 变体时,它还可以接受 IProgress<T> 对象(参见 “Progress Reporting”)。
异步和同步上下文
我们已经看到同步上下文的存在在提交延续方面是重要的。还有一些更微妙的方式,它们涉及空返回异步函数时的同步上下文。这些并不是 C# 编译器扩展的直接结果,而是编译器在扩展异步函数时使用的 System.CompilerServices 命名空间中的 Async*MethodBuilder 类型的功能。
异常发布
在富客户端应用程序中,依赖于中心异常处理事件(在 WPF 中为 Application.DispatcherUnhandledException)来处理 UI 线程上抛出的未处理异常是常见做法。在 ASP.NET Core 应用程序中,Startup.cs 的 ConfigureServices 方法中的自定义 ExceptionFilterAttribute 也完成类似的工作。在内部,它们通过在它们自己的 try/catch 块中调用 UI 事件(或在 ASP.NET Core 中,页面处理方法的流水线)来工作。
顶级异步函数使这变得复杂。考虑以下按钮点击事件处理程序:
async void ButtonClick (object sender, RoutedEventArgs args)
{
await Task.Delay(1000);
throw new Exception ("Will this be ignored?");
}
当点击按钮并运行事件处理程序时,在 await 语句后执行将正常返回到消息循环,一秒后抛出的异常将无法被消息循环中的 catch 块捕获。
为了缓解这个问题,AsyncVoidMethodBuilder 在空返回异步函数中捕获未处理的异常,并在存在同步上下文时将它们发布到同步上下文中,以确保全局异常处理事件仍然触发。
注意
编译器仅对返回void的异步函数应用此逻辑。因此,如果我们将ButtonClick改为返回Task而不是void,未处理的异常将导致结果Task的故障,而这个任务将无处可去(导致未观察到的异常)。
一个有趣的细微差别是,无论在await之前还是之后抛出异常都没有区别。因此,在以下示例中,异常将被发布到同步上下文(如果存在),而不是调用者:
async void Foo() { throw null; await Task.Delay(1000); }
(如果没有同步上下文存在,则异常将在线程池上传播,从而终止应用程序。)
异常未直接抛回给调用者的原因是为了确保可预测性和一致性。在以下示例中,InvalidOperationException总是会导致结果Task的故障——不论*someCondition*如何:
async Task Foo()
{
if (*someCondition*) await Task.Delay (100);
throw new InvalidOperationException();
}
迭代器的工作方式类似:
IEnumerable<int> Foo() { throw null; yield return 123; }
在此示例中,异常不会直接抛回给调用者:直到序列被枚举时,异常才会被抛出。
OperationStarted 和 OperationCompleted
如果存在同步上下文,返回void的异步函数还会在进入函数时调用其OperationStarted方法,并在函数完成时调用其OperationCompleted方法。
覆盖这些方法对于为单元测试编写自定义同步上下文非常有用。这在Microsoft 的并行编程博客中有所讨论。
优化
同步完成
异步函数可以在等待之前返回。考虑以下方法,该方法缓存下载网页:
static Dictionary<string,string> _cache = new Dictionary<string,string>();
async Task<string> GetWebPageAsync (string uri)
{
string html;
if (_cache.TryGetValue (uri, out html)) return html;
return _cache [uri] =
await new WebClient().DownloadStringTaskAsync (uri);
}
如果缓存中已存在 URI,执行将立即返回给调用者,而不会发生等待,并且方法会返回一个已标记的任务。这被称为同步完成。
当您等待一个同步完成的任务时,执行不会返回给调用者并通过继续反弹;相反,它会立即继续到下一个语句。编译器通过检查等待器上的IsCompleted属性来实现此优化;换句话说,无论何时您等待
Console.WriteLine (await GetWebPageAsync ("http://oreilly.com"));
编译器会发出代码以在同步完成时短路继续:
var awaiter = GetWebPageAsync().GetAwaiter();
if (awaiter.IsCompleted)
Console.WriteLine (awaiter.GetResult());
else
awaiter.OnCompleted (() => Console.WriteLine (awaiter.GetResult());
注意
等待一个返回同步完成的异步函数仍会产生(非常)小的开销——在 2019 年的 PC 上可能是 20 纳秒。
相比之下,切换到线程池会引入上下文切换的成本——可能是一到两微秒——而切换到 UI 消息循环,至少是其 10 倍(如果 UI 线程忙碌则更长)。
甚至可以编写永远不会await的异步方法,尽管编译器会生成警告:
async Task<string> Foo() { return "abc"; }
当重写虚拟/抽象方法时,如果您的实现恰好不需要异步性,这些方法可以非常有用。(例如MemoryStream的ReadAsync/WriteAsync方法;见第十五章。)另一种实现相同结果的方法是使用Task.FromResult,它返回一个已经信号化的任务:
Task<string> Foo() { return Task.FromResult ("abc"); }
如果从 UI 线程调用,我们的GetWebPageAsync方法在隐式上是线程安全的,您可以连续多次调用它(从而启动多个并发下载),而无需锁定以保护缓存。然而,如果这些调用系列是对同一 URI 的,则最终会启动多个冗余下载,所有这些下载最终都会更新同一个缓存条目(最后一个赢得胜利)。虽然不是错误的,但如果后续对同一 URI 的调用能够(异步地)等待正在进行的请求结果,则效率会更高。
有一种简单的方法可以实现这一点——无需使用锁定或信号化结构。我们不创建字符串缓存,而是创建“未来”(Task<string>)的缓存:
static Dictionary<string,Task<string>> _cache =
new Dictionary<string,Task<string>>();
Task<string> GetWebPageAsync (string uri)
{
if (_cache.TryGetValue (uri, out var downloadTask)) return downloadTask;
return _cache [uri] = new WebClient().DownloadStringTaskAsync (uri);
}
(请注意,我们不将方法标记为async,因为我们直接返回从调用WebClient方法获得的任务。)
如果我们重复使用相同的 URI 多次调用GetWebPageAsync,现在我们确保得到相同的Task<string>对象。(这还有额外的好处,可以最小化垃圾收集的负载。)而且如果任务已完成,等待它是廉价的,这要归功于我们刚讨论过的编译器优化。
我们可以进一步扩展我们的示例,使其在不需要同步上下文保护的情况下成为线程安全,只需在整个方法体周围进行锁定即可:
lock (_cache)
if (_cache.TryGetValue (uri, out var downloadTask))
return downloadTask;
else
return _cache [uri] = new WebClient().DownloadStringTaskAsync (uri);
}
这是因为我们不会在下载页面的整个持续时间内进行锁定(这会影响并发性);我们只会在检查缓存、必要时启动新任务并用该任务更新缓存的短暂持续时间内进行锁定。
ValueTask<T>
注意
ValueTask<T>旨在进行微优化场景,您可能从未需要编写返回此类型的方法。然而,仍需注意我们在下一节中概述的预防措施,因为某些 .NET 方法返回ValueTask<T>,而IAsyncEnumerable<T>也使用它。
我们刚刚描述了编译器如何优化对同步完成任务的await表达式——通过短路延续并立即继续到下一条语句。如果同步完成是由于缓存,我们看到缓存任务本身可以提供一种优雅且高效的解决方案。
然而,在所有同步完成场景中缓存任务并不实际。有时需要实例化一个新任务,这会造成(微小的)潜在效率问题。这是因为 Task 和 Task<T> 是引用类型,因此实例化需要基于堆的内存分配和随后的回收。一种极端的优化形式是编写无分配的代码;换句话说,不实例化任何引用类型,不增加垃圾收集的负担。为支持此模式,引入了 ValueTask 和 ValueTask<T> 结构体,编译器允许在 Task 和 Task<T> 的位置使用它们:
async ValueTask<int> Foo() { ... }
如果操作同步完成,则等待 ValueTask<T> 是无分配的。
int answer = await Foo(); // (Potentially) allocation-free
如果操作未同步完成,ValueTask<T> 在幕后创建一个普通的 Task<T>(它会将 await 转发给它),不会获得任何优势。
您可以通过调用 AsTask 方法将 ValueTask<T> 转换为普通的 Task<T>。
还有一个非泛型版本——ValueTask——与 Task 类似。
使用 ValueTask 时的预防措施
ValueTask<T> 相对不寻常,它纯粹因为性能原因被定义为结构体。这意味着它负载了不适当的值类型语义,可能会导致意外的行为。为避免不正确的行为,必须避免以下情况:
-
多次等待相同的
ValueTask<T> -
在操作未完成时调用
.GetAwaiter().GetResult()
如果需要执行这些操作,请调用 .AsTask(),并且改为操作返回的 Task。
注意
避免这些陷阱的最简单方法是直接等待方法调用,例如:
await Foo(); // Safe
当将(值)任务分配给变量时,会打开错误行为的大门:
ValueTask<int> valueTask = Foo(); // Caution!
// Our use of valueTask can now lead to errors.
可通过立即转换为普通任务来缓解:
Task<int> task = Foo().AsTask(); // Safe
// task is safe to work with.
避免过多的跳转
对于在循环中多次调用的方法,您可以通过调用 ConfigureAwait 来避免重复跳转到 UI 消息循环的成本。这会强制任务不将后续任务跳转到同步上下文,将开销削减到接近上下文切换的成本(或者如果您等待的方法同步完成,则远低于此成本):
async void A() { ... await B(); ... }
async Task B()
{
for (int i = 0; i < 1000; i++)
await C().ConfigureAwait (false);
}
async Task C() { ... }
这意味着对于 B 和 C 方法,我们取消了 UI 应用程序中简单的线程安全模型,其中代码在 UI 线程上运行,并且只能在 await 语句期间被抢占。然而,A 方法不受影响,如果它在 UI 线程上启动,则将保持在该线程上。
当编写库时,这种优化尤为重要:您不需要简化的线程安全性好处,因为您的代码通常不与调用方共享状态,也不访问 UI 控件。(在我们的示例中,如果它知道操作可能是短暂运行的,则使方法 C 同步完成也是有意义的。)
异步模式
取消
通常很重要的是,在启动后能够取消并发操作,也许是响应用户请求的一部分。实现这一点的一个简单方法是使用取消标志,我们可以通过编写如下类来封装它:
class CancellationToken
{
public bool IsCancellationRequested { get; private set; }
public void Cancel() { IsCancellationRequested = true; }
public void ThrowIfCancellationRequested()
{
if (IsCancellationRequested)
throw new OperationCanceledException();
}
}
然后我们可以编写一个可取消的异步方法如下:
async Task Foo (CancellationToken cancellationToken)
{
for (int i = 0; i < 10; i++)
{
Console.WriteLine (i);
await Task.Delay (1000);
cancellationToken.ThrowIfCancellationRequested();
}
}
当调用者想要取消时,它调用传递给Foo的取消令牌上的Cancel方法。这将将IsCancellationRequested设置为 true,导致Foo在短时间内出现OperationCanceledException(这是System命名空间中为此目的设计的预定义异常)。
除了线程安全性(我们应该在读取/写入IsCancellationRequested周围进行锁定)之外,这种模式非常有效,CLR 提供了一个名为CancellationToken的类型,与我们刚刚展示的非常相似。但是,它缺少一个Cancel方法;这个方法实际上是在另一个名为CancellationTokenSource的类型上公开的。这种分离提供了一些安全性:只有访问CancellationToken对象的方法可以检查但不能发起取消。
要获取取消令牌,我们首先实例化一个CancellationTokenSource:
var cancelSource = new CancellationTokenSource();
这暴露了一个Token属性,它返回一个CancellationToken。因此,我们可以像下面这样调用我们的Foo方法:
var cancelSource = new CancellationTokenSource();
Task foo = Foo (cancelSource.Token);
...
... *(sometime later)*
cancelSource.Cancel();
CLR 中的大多数异步方法都支持取消令牌,包括Delay。如果我们修改Foo,使其将其令牌传递给Delay方法,任务将在请求后立即结束(而不是最多一秒后)。
async Task Foo (CancellationToken cancellationToken)
{
for (int i = 0; i < 10; i++)
{
Console.WriteLine (i);
await Task.Delay (1000, cancellationToken);
}
}
请注意,我们不再需要调用ThrowIfCancellationRequested,因为Task.Delay已经为我们做了这件事。取消令牌在调用堆栈中很好地传播(就像取消请求通过引发异常向上调用堆栈级联一样)。
注意
UWP 依赖于 WinRT 类型,其异步方法遵循一种较低级的取消协议,不是接受CancellationToken,而是通过IAsyncInfo类型公开Cancel方法。AsTask扩展方法重载以接受取消令牌,以此来弥合差距。
同步方法也可以支持取消(例如Task的Wait方法)。在这种情况下,取消指令将需要异步地传递(例如来自另一个任务)。例如:
var cancelSource = new CancellationTokenSource();
Task.Delay (5000).ContinueWith (ant => cancelSource.Cancel());
...
实际上,您可以在构造CancellationTokenSource时指定一个时间间隔,以在一段时间后启动取消(就像我们演示的那样)。这对于实现超时非常有用,无论是同步还是异步的情况:
var cancelSource = new CancellationTokenSource (5000);
try { await Foo (cancelSource.Token); }
catch (OperationCanceledException ex) { Console.WriteLine ("Cancelled"); }
CancellationToken结构提供了一个Register方法,允许您注册一个回调委托,在取消时将触发该委托;它返回一个可以被处置以取消注册的对象。
编译器异步函数生成的任务,在未处理的OperationCanceledException时自动进入“已取消”状态(IsCanceled返回 true,IsFaulted返回 false)。对于使用Task.Run创建的任务,如果将(同一)CancellationToken传递给构造函数,情况也是如此。在异步场景中,故障任务和取消任务的区别并不重要,因为在等待时两者都会抛出OperationCanceledException;但在高级并行编程场景中(特别是条件连续性),这一点很重要。我们在“取消任务”中继续讨论这个话题。
进度报告
有时,您希望异步操作在运行时报告进度。一个简单的解决方案是将一个Action委托传递给异步方法,该方法在进度更改时触发:
Task Foo (Action<int> onProgressPercentChanged)
{
return Task.Run (() =>
{
for (int i = 0; i < 1000; i++)
{
if (i % 10 == 0) onProgressPercentChanged (i / 10);
// Do something compute-bound...
}
});
}
下面是我们如何调用它的方式:
Action<int> progress = i => Console.WriteLine (i + " %");
await Foo (progress);
虽然这在控制台应用程序中效果很好,在富客户端场景中并不理想,因为它从工作线程报告进度,可能会导致消费者的潜在线程安全问题。(事实上,我们已经允许并发的副作用“泄漏”到外部世界,这在从 UI 线程调用时是不幸的。)
IProgress<T>和Progress<T>
CLR 提供了一对类型来解决这个问题:一个名为IProgress<T>的接口和一个实现此接口的类Progress<T>。它们的目的实际上是“包装”一个委托,以便 UI 应用程序可以通过同步上下文安全地报告进度。
接口只定义了一个方法:
public interface IProgress<in T>
{
void Report (T value);
}
使用IProgress<T>很容易;我们的方法几乎不会改变:
Task Foo (IProgress<int> onProgressPercentChanged)
{
return Task.Run (() =>
{
for (int i = 0; i < 1000; i++)
{
if (i % 10 == 0) onProgressPercentChanged.Report (i / 10);
// Do something compute-bound...
}
});
}
Progress<T>类有一个构造函数,接受一个类型为Action<T>的委托,它进行包装:
var progress = new Progress<int> (i => Console.WriteLine (i + " %"));
await Foo (progress);
(Progress<T>还有一个ProgressChanged事件,您可以订阅它,而不是[或者另外]将动作委托传递给构造函数。)在实例化Progress<int>时,如果存在同步上下文,该类会捕获它。然后,当Foo调用Report时,委托通过该上下文被调用。
异步方法可以通过用自定义类型替换int来实现更复杂的进度报告,该类型公开一系列属性。
注意
如果您熟悉 Rx,您会注意到IProgress<T>与异步函数返回的任务一起提供了类似于IObserver<T>的功能集。不同之处在于,任务可以除了(并且与IProgress<T>发出的值不同类型)提供一个“最终”返回值。
IProgress<T>发出的值通常是“一次性”值(例如,完成百分比或到目前为止下载的字节数),而IObserver<T>的OnNext推送的值通常包括结果本身,这也是调用它的主要原因。
WinRT 中的异步方法还提供进度报告,尽管通过 COM 的(相对)原始类型系统使协议变得复杂。异步 WinRT 方法,而不是接受IProgress<T>对象的方法,会返回以下接口之一,以替代IAsyncAction和IAsyncOperation<TResult>:
IAsyncActionWithProgress<TProgress>
IAsyncOperationWithProgress<TResult, TProgress>
有趣的是,两者都基于IAsyncInfo(而不是IAsyncAction和IAsyncOperation<TResult>)。
好消息是AsTask扩展方法也被重载以接受IProgress<T>,用于前述接口,因此作为.NET 消费者,您可以忽略 COM 接口并执行此操作:
var progress = new Progress<int> (i => Console.WriteLine (i + " %"));
CancellationToken cancelToken = ...
var task = someWinRTobject.FooAsync().AsTask (cancelToken, progress);
任务异步模式
.NET 提供了数百个返回任务的异步方法,您可以进行await(主要与 I/O 相关)。这些方法大多(至少部分)遵循一种称为任务异步模式(TAP)的模式,这是我们到目前为止描述的内容的合理形式化。TAP 方法执行以下操作:
-
返回“热”(正在运行的)
Task或Task<TResult> -
具有“Async”后缀(除了特殊情况如任务组合器)
-
如果支持取消和/或进度报告,重载以接受取消标记和/或
IProgress<T> -
对调用者快速返回(只有一个小的同步阶段)
-
如果是 I/O 绑定,不会占用线程
正如我们所见,使用 C#的异步函数编写 TAP 方法非常简单。
任务组合器
异步函数有一个一致的协议的一个好处是(其中它们一致地返回任务),可以使用和编写任务组合器 —— 函数有用地组合任务,而不考虑这些特定任务所做的事情。
CLR 包括两个任务组合器:Task.WhenAny和Task.WhenAll。在描述它们时,我们假设以下方法已定义:
async Task<int> Delay1() { await Task.Delay (1000); return 1; }
async Task<int> Delay2() { await Task.Delay (2000); return 2; }
async Task<int> Delay3() { await Task.Delay (3000); return 3; }
WhenAny
Task.WhenAny 返回一个任务,当一组任务中的任何一个完成时,它也完成。以下示例在一秒钟内完成:
Task<int> winningTask = await Task.WhenAny (Delay1(), Delay2(), Delay3());
Console.WriteLine ("Done");
Console.WriteLine (winningTask.Result); // 1
因为Task.WhenAny本身返回一个任务,我们等待它,它返回首先完成的任务。我们的示例完全非阻塞,包括最后一行访问Result属性时(因为winningTask已经完成)。尽管如此,最好还是等待winningTask:
Console.WriteLine (await winningTask); // 1
因为任何异常都会被重新抛出,而不会用AggregateException包装。实际上,我们可以一次性执行两个await:
int answer = await await Task.WhenAny (Delay1(), Delay2(), Delay3());
如果一个未获胜的任务随后发生故障,除非随后等待该任务(或查询其Exception属性),否则异常将未被观察到。
WhenAny 对于对不支持超时或取消的操作应用超时或取消非常有用:
Task<string> task = SomeAsyncFunc();
Task winner = await (Task.WhenAny (task, Task.Delay(5000)));
if (winner != task) throw new TimeoutException();
string result = await task; // Unwrap result/re-throw
请注意,因为在这种情况下我们使用不同类型的任务调用WhenAny,因此赢家报告为一个普通的Task(而不是Task<string>)。
WhenAll
Task.WhenAll返回一个任务,当你传递给它的所有任务都完成时完成。以下代码在三秒后完成(并演示了分支/合并模式):
await Task.WhenAll (Delay1(), Delay2(), Delay3());
我们可以通过依次等待task1、task2和task3来获得类似的结果,而不是使用WhenAll:
Task task1 = Delay1(), task2 = Delay2(), task3 = Delay3();
await task1; await task2; await task3;
与其说这比等待一个更有效率(因为需要三个等待而不是一个),不如说如果task1出现故障,我们将永远无法等待task2/task3,它们的任何异常将未被观察。
相比之下,Task.WhenAll直到所有任务完成才完成,即使出现故障也是如此。如果出现多个故障,它们的异常将合并到任务的AggregateException中(这时AggregateException实际上变得有用——如果你对所有异常感兴趣的话)。然而,等待组合任务时,只会抛出第一个异常,因此要查看所有异常,你需要这样做:
Task task1 = Task.Run (() => { throw null; } );
Task task2 = Task.Run (() => { throw null; } );
Task all = Task.WhenAll (task1, task2);
try { await all; }
catch
{
Console.WriteLine (all.Exception.InnerExceptions.Count); // 2
}
使用Task<TResult>类型的任务调用WhenAll会返回一个Task<TResult[]>,在等待时给出所有任务的组合结果。这在等待时会简化为一个TResult[]:
Task<int> task1 = Task.Run (() => 1);
Task<int> task2 = Task.Run (() => 2);
int[] results = await Task.WhenAll (task1, task2); // { 1, 2 }
举个实际的例子,以下代码并行下载 URI 并计算它们的总长度:
async Task<int> GetTotalSize (string[] uris)
{
IEnumerable<Task<byte[]>> downloadTasks = uris.Select (uri =>
new WebClient().DownloadDataTaskAsync (uri));
byte[][] contents = await Task.WhenAll (downloadTasks);
return contents.Sum (c => c.Length);
}
这里有一点点效率问题,即我们不必要地保留下载的字节数组,直到每个任务完成。如果在下载后立即将字节数组折叠为它们的长度,将会更有效率。这就是异步 lambda 的用武之地,因为我们需要将await表达式传递给 LINQ 的Select查询运算符:
async Task<int> GetTotalSize (string[] uris)
{
IEnumerable<Task<int>> downloadTasks = uris.Select (async uri =>
(await new WebClient().DownloadDataTaskAsync (uri)).Length);
int[] contentLengths = await Task.WhenAll (downloadTasks);
return contentLengths.Sum();
}
自定义组合器
编写自己的任务组合器非常有用。最简单的“组合器”接受一个单一的任务,例如以下示例,它允许你在超时时等待任何任务:
async static Task<TResult> WithTimeout<TResult> (this Task<TResult> task,
TimeSpan timeout)
{
Task winner = await Task.WhenAny (task, Task.Delay (timeout))
.ConfigureAwait (false);
if (winner != task) throw new TimeoutException();
return await task.ConfigureAwait (false); // Unwrap result/re-throw
}
因为这是一个非常“库方法”,不涉及外部共享状态,所以在等待时我们使用ConfigureAwait(false)来避免潜在地跳转到 UI 同步上下文。当任务按时完成时,我们可以通过取消Task.Delay来进一步提高效率(避免定时器挂在那里产生的小开销):
async static Task<TResult> WithTimeout<TResult> (this Task<TResult> task,
TimeSpan timeout)
{
var cancelSource = new CancellationTokenSource();
var delay = Task.Delay (timeout, cancelSource.Token);
Task winner = await Task.WhenAny (task, delay).ConfigureAwait (false);
if (winner == task)
cancelSource.Cancel();
else
throw new TimeoutException();
return await task.ConfigureAwait (false); // Unwrap result/re-throw
}
以下代码允许你通过CancellationToken“放弃”一个任务:
static Task<TResult> WithCancellation<TResult> (this Task<TResult> task,
CancellationToken cancelToken)
{
var tcs = new TaskCompletionSource<TResult>();
var reg = cancelToken.Register (() => tcs.TrySetCanceled ());
task.ContinueWith (ant =>
{
reg.Dispose();
if (ant.IsCanceled)
tcs.TrySetCanceled();
else if (ant.IsFaulted)
tcs.TrySetException (ant.Exception.InnerExceptions);
else
tcs.TrySetResult (ant.Result);
});
return tcs.Task;
}
任务组合器可能很难编写,有时需要使用信号构造,我们在第二十一章中介绍。这实际上是件好事,因为它将与并发相关的复杂性从业务逻辑中分离出来,放入可重用的方法中,可以单独进行测试。
下一个组合器类似于WhenAll,但是如果任何任务出现故障,结果任务将立即失败:
async Task<TResult[]> WhenAllOrError<TResult>
(params Task<TResult>[] tasks)
{
var killJoy = new TaskCompletionSource<TResult[]>();
foreach (var task in tasks)
task.ContinueWith (ant =>
{
if (ant.IsCanceled)
killJoy.TrySetCanceled();
else if (ant.IsFaulted)
killJoy.TrySetException (ant.Exception.InnerExceptions);
});
return await await Task.WhenAny (killJoy.Task, Task.WhenAll (tasks))
.ConfigureAwait (false);
}
我们首先创建一个TaskCompletionSource,它的唯一工作是在任务故障时结束。因此,我们从不调用它的SetResult方法,只调用它的TrySetCanceled和TrySetException方法。在这种情况下,ContinueWith比GetAwaiter().OnCompleted更方便,因为我们不访问任务的结果,也不希望在此时跳转到 UI 线程。
异步锁定
在“异步信号量和锁”中,我们描述了如何使用SemaphoreSlim来异步锁定或限制并发。
废弃的模式
.NET 使用其他模式来处理异步,这些模式在任务和异步函数出现之前。现在,随着基于任务的异步成为主导模式,这些模式几乎不再需要。
异步编程模型
最古老的模式称为异步编程模型(APM),它使用一对以“Begin”和“End”开头的方法,并且一个名为IAsyncResult的接口。为了说明,让我们看一下System.IO中的Stream类及其Read方法。首先是同步版本:
public int Read (byte[] buffer, int offset, int size);
您可能已经可以预测基于任务的异步版本是什么样的:
public Task<int> ReadAsync (byte[] buffer, int offset, int size);
现在让我们来看一下 APM 版本:
public IAsyncResult BeginRead (byte[] buffer, int offset, int size,
AsyncCallback callback, object state);
public int EndRead (IAsyncResult asyncResult);
调用Begin*方法启动操作,返回一个IAsyncResult对象,它充当异步操作的标记。当操作完成(或故障)时,将触发AsyncCallback委托:
public delegate void AsyncCallback (IAsyncResult ar);
谁处理这个委托,然后调用End*方法,该方法提供操作的返回值,并在操作故障时重新抛出异常。
APM 不仅使用起来很笨拙,而且在正确实现时也令人意外地困难。处理 APM 方法的最简单方法是调用Task.Factory.FromAsync适配器方法,将 APM 方法对转换为一个Task。在内部,它使用TaskCompletionSource来提供一个在 APM 操作完成或故障时被信号的任务。
FromAsync方法需要以下参数:
-
指定
Begin*XXX*方法的委托 -
指定
End*XXX*方法的委托 -
附加参数将传递给这些方法
FromAsync被重载以接受与.NET 中几乎所有异步方法签名匹配的委托类型和参数。例如,假设stream是Stream,buffer是byte[],我们可以这样做:
Task<int> readChunk = Task<int>.Factory.FromAsync (
stream.BeginRead, stream.EndRead, buffer, 0, 1000, null);
基于事件的异步模式
基于事件的异步模式(EAP)于 2005 年引入,旨在为 APM 提供一个更简单的替代方案,特别是在 UI 场景中。然而,它仅在少数类型中实现,最显著的是System.Net中的WebClient。EAP 仅仅是一个模式;没有提供任何类型来帮助。基本上,该模式是这样的:一个类提供一组成员,这些成员在内部管理并发性,类似于以下内容:
// These members are from the WebClient class:
public byte[] DownloadData (Uri address); // Synchronous version
public void DownloadDataAsync (Uri address);
public void DownloadDataAsync (Uri address, object userToken);
public event DownloadDataCompletedEventHandler DownloadDataCompleted;
public void CancelAsync (object userState); // Cancels an operation
public bool IsBusy { get; } // Indicates if still running
*Async 方法异步启动操作。当操作完成时,会自动触发 ***Completed 事件(如果存在捕获的同步上下文则会自动发布)。该事件返回一个包含以下内容的事件参数对象:
-
一个指示操作是否被取消的标志(由消费者调用
CancelAsync设置) -
一个表示抛出的异常的
Error对象(如果有的话) -
在调用
Async方法时提供的userToken对象
EAP 类型还可以公开进度报告事件,每当进度发生变化时触发(也通过同步上下文发布):
public event DownloadProgressChangedEventHandler DownloadProgressChanged;
实现 EAP 需要大量的样板代码,使得该模式的可组合性较差。
BackgroundWorker
System.ComponentModel 中的 BackgroundWorker 是 EAP 的通用实现。它允许富客户端应用程序启动一个工作线程,并报告完成和基于百分比的进度,无需显式捕获同步上下文。以下是一个例子:
var worker = new BackgroundWorker { WorkerSupportsCancellation = true };
worker.DoWork += (sender, args) =>
{ // This runs on a worker thread
if (args.Cancel) return;
Thread.Sleep(1000);
args.Result = 123;
};
worker.RunWorkerCompleted += (sender, args) =>
{ // Runs on UI thread
// We can safely update UI controls here...
if (args.Cancelled)
Console.WriteLine ("Cancelled");
else if (args.Error != null)
Console.WriteLine ("Error: " + args.Error.Message);
else
Console.WriteLine ("Result is: " + args.Result);
};
worker.RunWorkerAsync(); // Captures sync context and starts operation
RunWorkerAsync 启动操作,会在一个池化的工作线程上触发 DoWork 事件。它还会捕获同步上下文,当操作完成(或出错)时,会通过该同步上下文调用 RunWorkerCompleted 事件(类似于一个延续)。
BackgroundWorker 创建粗粒度并发,即 DoWork 事件完全在工作线程上运行。如果需要在该事件处理程序中更新 UI 控件(而不仅仅是发布百分比完成消息),必须使用 Dispatcher.BeginInvoke 或类似方法。
我们在 http://albahari.com/threading 更详细地描述了 BackgroundWorker。
¹ CLR 在幕后为垃圾回收和终结创建其他线程。
第十五章:流和 I/O
本章描述了 .NET 中用于输入和输出的基本类型,重点介绍以下主题:
-
.NET 流架构及其如何为跨各种 I/O 类型的读写提供一致的编程接口
-
用于操作磁盘上文件和目录的类
-
专门用于压缩、命名管道和内存映射文件的流
本章主要集中在 System.IO 命名空间中的类型,这是低级 I/O 功能的主要组成部分。
流架构
.NET 流架构围绕三个概念展开:后备存储(backing stores)、装饰器(decorators)和适配器,如图 15-1 所示。
后备存储 是使输入和输出变得有用的终点,例如文件或网络连接。确切地说,它是以下两者之一或两者兼有:
-
一个可按顺序读取字节的源
-
一个可按顺序写入字节的目的地

图 15-1. 流架构
但是,后备存储如果不向程序员暴露就没有用处。Stream 是这一目的的标准 .NET 类;它公开了一组标准的读取、写入和定位方法。与数组不同,数组中的所有后备数据一次性存在于内存中,而流串行处理数据——无论是逐字节处理还是以可管理大小的块处理。因此,流可以使用少量固定的内存,无论其后备存储的大小如何。
流可分为两类:
后备存储流
这些方法被硬编码到特定类型的后备存储中,比如 FileStream 或 NetworkStream。
装饰器流
这些流依赖于另一个流,以某种方式转换数据,例如 DeflateStream 或 CryptoStream。
装饰器流具有以下架构优势:
-
它们使得后备存储流无需自己实现诸如压缩和加密等功能。
-
装饰器不会改变接口。
-
你可以在运行时连接装饰器。
-
您可以将装饰器链接在一起(例如,压缩器后跟加密器)。
后备存储和装饰器流都专门处理字节。虽然这很灵活和高效,但应用程序通常在文本或 XML 等更高层次上工作。适配器 通过将流包装在具有特定格式的类中来弥合这一差距。例如,文本读取器公开了 ReadLine 方法;XML 编写器公开了 WriteAttributes 方法。
注意
适配器包裹着一个流,就像装饰器一样。不过,与装饰器不同的是,适配器本身并不是一个流;它通常会完全隐藏字节导向方法。
总结一下,后备存储流提供原始数据;装饰器流提供透明的二进制转换,比如加密;适配器提供处理更高级类型(如字符串和 XML)的类型化方法。
图 15-1 展示了它们的关联。要组成链,只需将一个对象传递给另一个对象的构造函数。
使用流
抽象的Stream类是所有流的基类。它定义了三个基本操作的方法和属性:读取、写入和定位,以及用于关闭、刷新和配置超时等管理任务(见表 15-1)。
表 15-1. 流类成员
| 类别 | 成员 |
|---|---|
| 读取 | public abstract bool CanRead { get; } |
public abstract int Read (byte[] buffer, int offset, int count) |
|
public virtual int ReadByte(); |
|
| 写入 | public abstract bool CanWrite { get; } |
public abstract void Write (byte[] buffer, int offset, int count); |
|
public virtual void WriteByte (byte value); |
|
| 定位 | public abstract bool CanSeek { get; } |
public abstract long Position { get; set; } |
|
public abstract void SetLength (long value); |
|
public abstract long Length { get; } |
|
public abstract long Seek (long offset, SeekOrigin origin); |
|
| 关闭/刷新 | public virtual void Close(); |
public void Dispose(); |
|
public abstract void Flush(); |
|
| 超时 | public virtual bool CanTimeout { get; } |
public virtual int ReadTimeout { get; set; } |
|
public virtual int WriteTimeout { get; set; } |
|
| 其他 | public static readonly Stream Null; // "Null" stream |
public static Stream Synchronized (Stream stream); |
同样,还有异步版本的Read和Write方法,两者都返回Task,并可选择接受取消令牌,还有使用Span<T>和Memory<T>类型的重载版本,我们将在第二十三章中描述。
在下面的示例中,我们使用文件流进行读取、写入和定位:
using System;
using System.IO;
// Create a file called test.txt in the current directory:
using (Stream s = new FileStream ("test.txt", FileMode.Create))
{
Console.WriteLine (s.CanRead); // True
Console.WriteLine (s.CanWrite); // True
Console.WriteLine (s.CanSeek); // True
s.WriteByte (101);
s.WriteByte (102);
byte[] block = { 1, 2, 3, 4, 5 };
s.Write (block, 0, block.Length); // Write block of 5 bytes
Console.WriteLine (s.Length); // 7
Console.WriteLine (s.Position); // 7
s.Position = 0; // Move back to the start
Console.WriteLine (s.ReadByte()); // 101
Console.WriteLine (s.ReadByte()); // 102
// Read from the stream back into the block array:
Console.WriteLine (s.Read (block, 0, block.Length)); // 5
// Assuming the last Read returned 5, we'll be at
// the end of the file, so Read will now return 0:
Console.WriteLine (s.Read (block, 0, block.Length)); // 0
}
异步读取或写入只需调用ReadAsync/WriteAsync而不是Read/Write,并等待表达式(我们还必须在调用方法中添加async关键字,正如我们在第十四章中描述的那样):
async static void AsyncDemo()
{
using (Stream s = new FileStream ("test.txt", FileMode.Create))
{
byte[] block = { 1, 2, 3, 4, 5 };
await s.WriteAsync (block, 0, block.Length); // Write asychronously
s.Position = 0; // Move back to the start
// Read from the stream back into the block array:
Console.WriteLine (await s.ReadAsync (block, 0, block.Length)); // 5
}
}
异步方法使得编写响应迅速且可伸缩的应用程序变得简单,特别是处理潜在缓慢流(尤其是网络流)时,而无需挂起线程。
注意
为简洁起见,我们将在本章大部分示例中继续使用同步方法;但是,我们建议在涉及网络 I/O 的大多数场景中使用优先的异步Read/Write操作。
读取和写入
流可以支持读取、写入或两者兼具。如果CanWrite返回false,则流为只读;如果CanRead返回false,则流为只写。
Read 方法从流中接收一个数据块并存入数组中。它返回接收到的字节数,这个数字始终小于或等于 count 参数。如果小于 count,则表示已经到达了流的末尾或者流以较小的块向您提供数据(这在网络流中经常发生)。无论哪种情况,数组中剩余的字节将保持未写入状态,它们的先前值将被保留。
警告
使用 Read 方法时,只有当该方法返回 0 时,您才能确定已经到达了流的末尾。因此,如果有一个 1,000 字节的流,下面的代码可能无法将其全部读入内存:
// Assuming s is a stream:
byte[] data = new byte [1000];
s.Read (data, 0, data.Length);
Read 方法可以读取 1 到 1,000 字节的数据,留下流中剩余未读取的部分。
以下是通过 Read 方法正确读取 1,000 字节流的方法:
byte[] data = new byte [1000];
// bytesRead will always end up at 1000, unless the stream is
// itself smaller in length:
int bytesRead = 0;
int chunkSize = 1;
while (bytesRead < data.Length && chunkSize > 0)
bytesRead +=
chunkSize = s.Read (data, bytesRead, data.Length - bytesRead);
为了简化操作,在 .NET 7 中,Stream 类包含了名为 ReadExactly 和 ReadAtLeast 的帮助方法(以及每个方法的异步版本)。以下代码从流中精确读取 1,000 字节(如果流在此之前结束,则抛出异常):
byte[] data = new byte [1000];
s.ReadExactly (data); // Reads exactly 1000 bytes
最后一行相当于:
s.ReadExactly (data, offset:0, count:1000);
注意
BinaryReader 类型提供了另一种解决方案:
byte[] data = new BinaryReader (s).ReadBytes (1000);
如果流的长度小于 1,000 字节,则返回的字节数组反映实际的流大小。如果流是可定位的,则可以通过用 (int)s.Length 替换 1000 来读取其全部内容。
我们将在 “Stream Adapters” 中进一步描述 BinaryReader 类型。
ReadByte 方法更简单:它只读取一个字节,并返回 -1 表示流的结尾。ReadByte 实际上返回的是 int 而不是 byte,因为后者不能返回 -1。
Write 和 WriteByte 方法将数据发送到流中。如果它们无法发送指定的字节,则会抛出异常。
警告
在 Read 和 Write 方法中,offset 参数表示从 buffer 数组开始读取或写入的索引位置,而不是流中的位置。
寻址
如果 CanSeek 返回 true,则流是可寻址的。对于可寻址的流(例如文件流),可以查询或修改其 Length(通过调用 SetLength),并且随时可以更改正在读取或写入的 Position。Position 属性是相对于流的开头的位置;然而,Seek 方法允许您相对于当前位置或流的末尾移动。
注意
在 FileStream 上更改 Position 通常需要几微秒。如果在循环中进行了数百万次这样的操作,MemoryMappedFile 类可能比 FileStream 更合适(参见 “Memory-Mapped Files”)。
对于非可寻址流(例如加密流),唯一确定其长度的方法是完全读取它。此外,如果需要重新读取先前的部分,则必须关闭流,并使用新的流重新开始。
关闭和刷新
流在使用后必须被释放,以释放底层资源如文件和套接字句柄。通过在using块中实例化流可以简单地保证这一点。一般来说,流遵循标准的释放语义:
-
Dispose和Close在功能上是相同的。 -
重复释放或关闭流不会引起错误。
关闭装饰器流会同时关闭装饰器和其后备存储流。使用装饰器链时,关闭最外层的装饰器(链的头部)会关闭整个链。
一些流内部会缓冲数据到和从后备存储器,以减少往返并提高性能(文件流是一个很好的例子)。这意味着你写入流的数据可能不会立即到达后备存储器;它可以延迟,直到缓冲区填满。Flush方法强制将任何内部缓冲的数据立即写入。当流关闭时,Flush会被自动调用,因此你永远不需要执行以下操作:
s.Flush(); s.Close();
超时
如果CanTimeout返回true,则流支持读取和写入超时。网络流支持超时;文件和内存流不支持。对于支持超时的流,ReadTimeout和WriteTimeout属性确定所需的超时时间(以毫秒为单位),其中0表示没有超时。通过抛出异常,Read和Write方法指示发生了超时。
异步的ReadAsync/WriteAsync方法不支持超时;相反,你可以将取消令牌传递给这些方法。
线程安全性
作为一条规则,流是不线程安全的,这意味着两个线程不能同时读取或写入同一流,否则可能会出错。Stream类通过静态方法Synchronized提供了一个简单的解决方法。这个方法接受任何类型的流并返回一个线程安全的包装器。这个包装器通过在每次读取、写入或寻址周围获取独占锁来工作,确保只有一个线程可以执行这样的操作。实际上,这允许多个线程同时向同一流追加数据 — 其他类型的活动(如并发读取)需要额外的锁定来确保每个线程访问流的所需部分。我们在第二十一章中全面讨论了线程安全性。
注意
从 .NET 6 开始,你可以使用RandomAccess类进行高性能的线程安全文件 I/O 操作。RandomAccess还允许你传递多个缓冲区以提高性能。
后备存储流
图 15-2 展示了.NET 提供的关键后备存储流。通过Stream的静态Null字段也可以使用“null 流”。在编写单元测试时,空流可能很有用。

图 15-2. 后备存储流
在接下来的章节中,我们描述了FileStream和MemoryStream;在本章的最后一节中,我们描述了IsolatedStorageStream。在第十六章,我们涵盖了NetworkStream。
FileStream
在本节的前面部分,我们演示了使用FileStream读取和写入数据字节的基本用法。现在让我们来检查这个类的特殊功能。
注意
如果你仍在使用通用 Windows 平台[UWP],你也可以使用Windows.Storage中的类型进行文件 I/O。我们在在线补充说明中描述了这一点http://www.albahari.com/nutshell。
构造一个 FileStream
实例化一个FileStream的最简单方法是使用File类上的以下静态门面方法之一:
FileStream fs1 = File.OpenRead ("readme.bin"); // Read-only
FileStream fs2 = File.OpenWrite ("writeme.tmp"); // Write-only
FileStream fs3 = File.Create ("readwrite.tmp"); // Read/write
如果文件已经存在,OpenWrite和Create在行为上有所不同。Create会截断任何现有内容;OpenWrite保留现有内容,并将流定位在零处。如果你写入的字节数少于文件中先前的字节数,OpenWrite会留下新旧内容的混合。
你还可以直接实例化一个FileStream。其构造函数提供了访问每个特性的途径,允许你指定文件名或低级文件句柄、文件创建和访问模式,以及分享、缓冲和安全选项。以下示例打开一个现有文件,进行读写访问而不覆盖它(using关键字确保在fs退出作用域时进行释放):
using var fs = new FileStream ("readwrite.tmp", FileMode.Open);
我们稍后详细看看FileMode。
指定文件名
文件名可以是绝对的(例如,c:\temp\test.txt 或在 Unix 中的 /tmp/test.txt)或相对于当前目录的(例如,test.txt 或 temp\test.txt)。你可以通过静态的Environment.CurrentDirectory属性访问或更改当前目录。
警告
当程序启动时,当前目录可能与程序可执行文件的目录一致,也可能不一致。因此,你不应该依赖当前目录来定位随可执行文件打包的额外运行时文件。
AppDomain.CurrentDomain.BaseDirectory返回应用程序基目录,通常是包含程序可执行文件的文件夹。为了相对于此目录指定文件名,你可以调用Path.Combine:
string baseFolder = AppDomain.CurrentDomain.BaseDirectory;
string logoPath = Path.Combine (baseFolder, "logo.jpg");
Console.WriteLine (File.Exists (logoPath));
你可以通过 Universal Naming Convention (UNC) 路径跨 Windows 网络读取和写入,比如 \JoesPC\PicShare\pic.jpg 或 \10.1.1.2\PicShare\pic.jpg。(要从 macOS 或 Unix 访问 Windows 文件共享,请按照特定于你的操作系统的说明将其挂载到你的文件系统,然后使用 C# 中的普通路径打开它)。
指定文件模式
所有接受文件名的FileStream构造函数还需要一个FileMode枚举参数。图 15-3 展示了如何选择FileMode,并且选择会产生类似于在File类上调用静态方法的结果。

图 15-3. 选择 FileMode
警告
使用 File.Create 和 FileMode.Create 操作隐藏文件会抛出异常。要覆盖隐藏文件,必须删除并重新创建它:
File.Delete ("hidden.txt");
using var file = File.Create ("hidden.txt");
...
仅提供文件名和 FileMode 构造 FileStream 将给你一个可读写的流(只有一个例外)。如果还提供 FileAccess 参数,可以请求降级权限:
[Flags]
public enum FileAccess { Read = 1, Write = 2, ReadWrite = 3 }
下列语句返回一个只读流,等效于调用 File.OpenRead:
using var fs = new FileStream ("x.bin", FileMode.Open, FileAccess.Read);
...
FileMode.Append 是一个特例:使用此模式会得到一个只写流。要以读写支持进行追加,必须改用 FileMode.Open 或 FileMode.OpenOrCreate,然后定位到流的末尾:
using var fs = new FileStream ("myFile.bin", FileMode.Open);
fs.Seek (0, SeekOrigin.End);
...
高级 FileStream 功能
在构造 FileStream 时,还可以包括以下可选参数:
-
一个
FileShare枚举,描述其他进程在你完成之前对文件的访问权限(None、Read[默认]、ReadWrite或Write)。 -
内部缓冲区的大小(默认为 4 KB)以字节为单位。
-
一个指示是否将异步 I/O 延迟至操作系统处理的标志。
-
FileOptions标志枚举用于请求操作系统加密(Encrypted)、在关闭时自动删除临时文件(DeleteOnClose)以及优化提示(RandomAccess和SequentialScan)。还有一个WriteThrough标志,请求操作系统禁用写后缓存;这适用于事务文件或日志。底层操作系统不支持的标志会被静默忽略。
使用 FileShare.ReadWrite 打开文件允许其他进程或用户同时读取和写入同一文件。为避免混乱,你可以约定在读取或写入前锁定文件的指定部分,使用以下方法:
// Defined on the FileStream class:
public virtual void Lock (long position, long length);
public virtual void Unlock (long position, long length);
如果请求的文件部分或全部已被锁定,Lock 将抛出异常。
MemoryStream
MemoryStream 使用数组作为后备存储。这在某种程度上违背了拥有流的初衷,因为整个后备存储必须一次性驻留在内存中。当你需要对一个不可寻址的流进行随机访问时,MemoryStream 仍然非常有用。如果你知道源流的大小是可管理的,可以按照以下方式将其复制到 MemoryStream 中:
var ms = new MemoryStream();
sourceStream.CopyTo (ms);
你可以通过调用 ToArray 将 MemoryStream 转换为字节数组。GetBuffer 方法通过返回对底层存储数组的直接引用更高效地执行相同的任务;不过,这个数组通常比实际流的长度长。
注意
关闭和刷新 MemoryStream 是可选的。如果关闭了 MemoryStream,则无法再对其进行读取或写入,但仍然可以调用 ToArray 获取底层数据。在内存流上,Flush 完全不起作用。
你可以在 “压缩流” 和 “概述” 中找到更多的 MemoryStream 示例。
PipeStream
PipeStream提供了一种通过操作系统的管道协议使一个进程能够与另一个进程通信的简单方法。有两种类型的管道:
匿名管道(更快)
允许在同一台计算机上的父子进程之间进行单向通信
命名管道(更灵活)
允许在同一台计算机上或跨网络上的不同计算机之间进行双向通信。
管道在单台计算机上进行进程间通信(IPC)非常有用:它不依赖于网络传输,这意味着没有网络协议开销,并且不会受到防火墙的影响。
注意
管道基于流,因此一个进程等待接收一系列字节,而另一个进程则发送这些字节。另一种方法是通过块共享内存来进行进程间通信;我们将在"内存映射文件"中描述如何做到这一点。
PipeStream是一个抽象类,有四种具体的子类型。两种用于匿名管道,另外两种用于命名管道:
匿名管道
AnonymousPipeServerStream和AnonymousPipeClientStream
命名管道
NamedPipeServerStream和NamedPipeClientStream
Named pipes 更容易使用,因此我们先描述它们。
命名管道
使用命名管道时,各方通过同名管道进行通信。协议定义了两个不同的角色:客户端和服务器。通信如下进行:
-
服务器实例化一个
NamedPipeServerStream,然后调用WaitForConnection。 -
客户端实例化一个
NamedPipeClientStream,然后调用Connect(可以选择设置超时时间)。
然后,双方读取和写入流以进行通信。
下面的示例演示了一个发送单个字节(100)的服务器,然后等待接收单个字节:
using var s = new NamedPipeServerStream ("pipedream");
s.WaitForConnection();
s.WriteByte (100); // Send the value 100.
Console.WriteLine (s.ReadByte());
这是对应的客户端代码:
using var s = new NamedPipeClientStream ("pipedream");
s.Connect();
Console.WriteLine (s.ReadByte());
s.WriteByte (200); // Send the value 200 back.
命名管道流默认是双向的,因此任何一方都可以读取或写入它们的流。这意味着客户端和服务器必须就某种协议达成一致,以协调它们的操作,以免双方同时发送或接收。
还需要就每次传输的长度达成一致。在我们的示例中,这很简单,因为我们只在每个方向上传输了一个字节。为了处理比一个字节长的消息,管道提供了消息传输模式(仅限于 Windows)。如果启用了此模式,调用Read的一方可以通过检查IsMessageComplete属性来知道消息何时完成。为了演示,我们首先编写一个辅助方法,从启用了消息的PipeStream(即读取直到IsMessageComplete为 true)中读取整个消息:
static byte[] ReadMessage (PipeStream s)
{
MemoryStream ms = new MemoryStream();
byte[] buffer = new byte [0x1000]; // Read in 4 KB blocks
do { ms.Write (buffer, 0, s.Read (buffer, 0, buffer.Length)); }
while (!s.IsMessageComplete);
return ms.ToArray();
}
(为了实现异步操作,将"s.Read"替换为"await s.ReadAsync"。)
警告
你不能仅仅通过等待Read返回 0 来确定PipeStream是否已经完成读取消息。这是因为,与大多数其他流类型不同,管道流和网络流没有明确定义的结束。相反,在消息传输之间它们暂时“干涸”。
现在我们可以激活消息传输模式。在服务器端,通过在构建流时指定PipeTransmissionMode.Message来完成:
using var s = new NamedPipeServerStream ("pipedream", PipeDirection.InOut,
1, PipeTransmissionMode.Message);
s.WaitForConnection();
byte[] msg = Encoding.UTF8.GetBytes ("Hello");
s.Write (msg, 0, msg.Length);
Console.WriteLine (Encoding.UTF8.GetString (ReadMessage (s)));
在客户端,我们在调用Connect后设置ReadMode以激活消息传输模式:
using var s = new NamedPipeClientStream ("pipedream");
s.Connect();
s.ReadMode = PipeTransmissionMode.Message;
Console.WriteLine (Encoding.UTF8.GetString (ReadMessage (s)));
byte[] msg = Encoding.UTF8.GetBytes ("Hello right back!");
s.Write (msg, 0, msg.Length);
注意
只有在 Windows 上支持消息模式。其他平台会抛出PlatformNotSupportedException。
匿名管道
匿名管道提供了父进程和子进程之间的单向通信流。与使用系统范围内名称不同,匿名管道通过私有句柄进行调节。
与命名管道一样,匿名管道也有明确的客户端和服务器角色。然而,通信系统稍有不同,具体操作如下:
-
服务器实例化了一个
AnonymousPipeServerStream,指定了PipeDirection为In或Out。 -
服务器调用
GetClientHandleAsString获取管道的标识符,然后将其传递给客户端(通常作为启动子进程时的参数)。 -
子进程实例化了一个
AnonymousPipeClientStream,指定了相反的PipeDirection。 -
服务器通过调用
DisposeLocalCopyOfClientHandle释放在步骤 2 中生成的本地句柄。 -
父进程和子进程通过读写流进行通信。
因为匿名管道是单向的,服务器必须创建两个管道进行双向通信。以下控制台程序创建了两个管道(输入和输出),然后启动了一个子进程。它然后向子进程发送一个字节,并接收一个字节作为回复:
class Program
{
static void Main (string[] args)
{
if (args.Length == 0)
// No arguments signals server mode
AnonymousPipeServer();
else
// We pass in the pipe handle IDs as arguments to signal client mode
AnonymousPipeClient (args [0], args [1]);
}
static void AnonymousPipeClient (string rxID, string txID)
{
using var rx = new AnonymousPipeClientStream (PipeDirection.In, rxID);
using var tx = new AnonymousPipeClientStream (PipeDirection.Out, txID);
Console.WriteLine ("Client received: " + rx.ReadByte ());
tx.WriteByte (200);
}
static void AnonymousPipeServer ()
{
using var tx = new AnonymousPipeServerStream (
PipeDirection.Out, HandleInheritability.Inheritable);
using var rx = new AnonymousPipeServerStream (
PipeDirection.In, HandleInheritability.Inheritable);
string txID = tx.GetClientHandleAsString ();
string rxID = rx.GetClientHandleAsString ();
// Create and start up a child process.
// We'll use the same Console executable, but pass in arguments:
string thisAssembly = Assembly.GetEntryAssembly().Location;
string thisExe = Path.ChangeExtension (thisAssembly, ".exe");
var args = $"{txID} {rxID}";
var startInfo = new ProcessStartInfo (thisExe, args);
startInfo.UseShellExecute = false; // Required for child process
Process p = Process.Start (startInfo);
tx.DisposeLocalCopyOfClientHandle (); // Release unmanaged
rx.DisposeLocalCopyOfClientHandle (); // handle resources.
tx.WriteByte (100); // Send a byte to the child process
Console.WriteLine ("Server received: " + rx.ReadByte ());
p.WaitForExit ();
}
}
与命名管道一样,客户端和服务器必须协调它们的发送和接收,并同意每个传输的长度。不幸的是,匿名管道不支持消息模式,因此您必须为消息长度协议实施自己的协议。其中一个解决方案是在每次传输的前四个字节中发送一个定义消息长度的整数值。BitConverter类提供了在整数值和四个字节数组之间进行转换的方法。
BufferedStream
BufferedStream 装饰或包装另一个具有缓冲功能的流,它是.NET 中多种装饰器流类型之一,这些类型都在图 15-4 中有所说明。

图 15-4. 装饰器流
缓冲可以通过减少到后备存储的往返次数来提高性能。这是我们如何将FileStream包装在一个 20 KB 的BufferedStream中的方法:
// Write 100K to a file:
File.WriteAllBytes ("myFile.bin", new byte [100000]);
using FileStream fs = File.OpenRead ("myFile.bin");
using BufferedStream bs = new BufferedStream (fs, 20000); //20K buffer
bs.ReadByte();
Console.WriteLine (fs.Position); // 20000
在此示例中,由于预读缓冲区的存在,仅读取一个字节后底层流会提前 20,000 个字节。在 FileStream 再次受到影响之前,我们可以再调用 ReadByte 另外 19,999 次。
将 BufferedStream 与 FileStream 耦合,如本例所示,价值有限,因为 FileStream 已经内置了缓冲区。唯一可能的用途可能是在已构建的 FileStream 上扩展缓冲区。
自动关闭 BufferedStream 会关闭底层的后备存储流。
流适配器
Stream 仅处理字节;要读取或写入诸如字符串、整数或 XML 元素之类的数据类型,必须插入适配器。以下是 .NET 提供的内容:
文本适配器(用于字符串和字符数据)
TextReader, TextWriter
StreamReader, StreamWriter
StringReader, StringWriter
二进制适配器(用于诸如 int、bool、string 和 float 等基本类型)
BinaryReader, BinaryWriter
XML 适配器(在 第 11 章 中讨论)
XmlReader, XmlWriter
图 15-5 展示了这些类型之间的关系。

图 15-5. 读者和写者
文本适配器
TextReader 和 TextWriter 是专门处理字符和字符串的适配器的抽象基类。在 .NET 中,每个类都有两个通用的实现:
StreamReader/StreamWriter
使用 Stream 作为其原始数据存储,将流的字节转换为字符或字符串
StringReader/StringWriter
使用内存中的字符串实现 TextReader/TextWriter
表 15-2 根据类别列出了 TextReader 的成员。Peek 返回流中的下一个字符而不移动位置。Peek 和 Read 的无参数版本在流结束时返回 −1;否则,它们返回一个可以直接转换为 char 的整数。接受 char[] 缓冲区的 Read 重载在功能上与 ReadBlock 方法相同。ReadLine 读取直到 CR(字符 13)、LF(字符 10)或 CR+LF 连续序列。然后返回一个字符串,丢弃 CR/LF 字符。
表 15-2. TextReader 成员
| 类别 | 成员 |
|---|---|
| 读取一个字符 | public virtual int Peek(); // 将结果转换为 char |
public virtual int Read(); // 将结果转换为 char |
|
| 读取多个字符 | public virtual int Read (char[] buffer, int index, int count); |
public virtual int ReadBlock (char[] buffer, int index, int count); |
|
public virtual string ReadLine(); |
|
public virtual string ReadToEnd(); |
|
| 关闭 | public virtual void Close(); |
public void Dispose(); // 与 Close 方法相同 |
|
| 其他 | public static readonly TextReader Null; |
public static TextReader Synchronized (TextReader reader); |
注意
Environment.NewLine 返回当前操作系统的换行序列。
在 Windows 上,这是 "\r\n"(想一想“回车”),它松散地模拟了机械打字机:CR(字符 13)后跟 LF(字符 10)。反转顺序,你将得到两个新行或一个新行都没有!
在 Unix 和 macOS 上,它只是 "\n"。
TextWriter 还有类似的写入方法,如 表 15-3 所示。Write 和 WriteLine 方法还重载以接受每种原始类型,以及 object 类型。这些方法简单地调用传入对象的 ToString 方法(通过在调用方法或构造 TextWriter 时指定的 IFormatProvider)。
表 15-3. TextWriter 成员
| 类别 | 成员 |
|---|---|
| 写入一个字符 | public virtual void Write (char value); |
| 写入多个字符 | public virtual void Write (string value); |
public virtual void Write (char[] buffer, int index, int count); |
|
public virtual void Write (string format, params object[] arg); |
|
public virtual void WriteLine (string value); |
|
| 关闭和刷新 | public virtual void Close(); |
public void Dispose(); // 与 Close 相同 |
|
public virtual void Flush(); |
|
| 格式化和编码 | public virtual IFormatProvider FormatProvider { get; } |
public virtual string NewLine { get; set; } |
|
public abstract Encoding Encoding { get; } |
|
| 其他 | public static readonly TextWriter Null; |
public static TextWriter Synchronized (TextWriter writer); |
WriteLine 简单地使用 Environment.NewLine 附加给定的文本。您可以通过 NewLine 属性更改此行为(这对与 Unix 文件格式的互操作性很有用)。
注意
与 Stream 类似,TextReader 和 TextWriter 提供了其读写方法的基于任务的异步版本。
StreamReader 和 StreamWriter
在以下示例中,StreamWriter 将两行文本写入文件,然后 StreamReader 读取文件内容:
using (FileStream fs = File.Create ("test.txt"))
using (TextWriter writer = new StreamWriter (fs))
{
writer.WriteLine ("Line1");
writer.WriteLine ("Line2");
}
using (FileStream fs = File.OpenRead ("test.txt"))
using (TextReader reader = new StreamReader (fs))
{
Console.WriteLine (reader.ReadLine()); // Line1
Console.WriteLine (reader.ReadLine()); // Line2
}
由于文本适配器经常与文件耦合,File 类提供了 CreateText、AppendText 和 OpenText 的静态方法来简化流程:
using (TextWriter writer = File.CreateText ("test.txt"))
{
writer.WriteLine ("Line1");
writer.WriteLine ("Line2");
}
using (TextWriter writer = File.AppendText ("test.txt"))
writer.WriteLine ("Line3");
using (TextReader reader = File.OpenText ("test.txt"))
while (reader.Peek() > -1)
Console.WriteLine (reader.ReadLine()); // Line1
// Line2
// Line3
这还说明了如何测试文件的结束(即 reader.Peek())。另一种选项是读取直到 reader.ReadLine 返回 null。
您还可以读取和写入其他类型,例如整数,但因为 TextWriter 在您的类型上调用 ToString,所以在读取时必须解析字符串:
using (TextWriter w = File.CreateText ("data.txt"))
{
w.WriteLine (123); // Writes "123"
w.WriteLine (true); // Writes the word "true"
}
using (TextReader r = File.OpenText ("data.txt"))
{
int myInt = int.Parse (r.ReadLine()); // myInt == 123
bool yes = bool.Parse (r.ReadLine()); // yes == true
}
字符编码
TextReader 和 TextWriter 本身只是没有与流或后备存储相关联的抽象类。然而,StreamReader 和 StreamWriter 类型与基础字节流连接,因此它们必须在字符和字节之间进行转换。它们通过 System.Text 命名空间中的 Encoding 类来执行此操作,您在构造 StreamReader 或 StreamWriter 时可以选择它。如果不选择,则使用默认的 UTF-8 编码。
警告
如果显式指定编码,StreamWriter 将默认在流的开头写入一个前缀以标识编码。通常这是不希望的,您可以通过以下方式构造编码以防止此情况:
var encoding = new UTF8Encoding (
encoderShouldEmitUTF8Identifier:false,
throwOnInvalidBytes:true);
第二个参数告诉 StreamWriter(或 StreamReader)在遇到不具有有效字符串翻译的字节时抛出异常,这与其默认行为匹配,如果未指定编码。
最简单的编码之一是 ASCII,因为每个字符由一个字节表示。ASCII 编码将 Unicode 集的前 127 个字符映射到其单字节中,涵盖了美式键盘上看到的内容。大多数其他字符,包括专用符号和非英文字符,无法表示,并转换为□字符。默认的 UTF-8 编码可以映射所有已分配的 Unicode 字符,但它更复杂。前 127 个字符编码为一个字节,以兼容 ASCII;其余字符编码为变长的字节(通常为两个或三个)。请考虑以下内容:
using (TextWriter w = File.CreateText ("but.txt")) // Use default UTF-8
w.WriteLine ("but-"); // encoding.
using (Stream s = File.OpenRead ("but.txt"))
for (int b; (b = s.ReadByte()) > -1;)
Console.WriteLine (b);
单词“but”后面不是标准的连字符,而是更长的破折号(—)字符,U+2014。这是不会让您与编辑员起冲突的字符!让我们来检查输出:
98 // b
117 // u
116 // t
226 // em dash byte 1 Note that the byte values
128 // em dash byte 2 are >= 128 for each part
148 // em dash byte 3 of the multibyte sequence.
13 // <CR>
10 // <LF>
因为破折号位于 Unicode 集的前 127 个字符之外,因此在 UTF-8 中需要多于一个字节来编码(在本例中为三个)。UTF-8 在西方字母表中效率很高,因为大多数流行的字符仅消耗一个字节。它也可以简单地通过忽略所有大于 127 的字节降级为 ASCII。其缺点是在流中进行定位很麻烦,因为字符的位置不对应于流中的字节位置。一个替代方案是 UTF-16(在 Encoding 类中标记为“Unicode”)。以下是我们如何使用 UTF-16 编写相同字符串:
using (Stream s = File.Create ("but.txt"))
using (TextWriter w = new StreamWriter (s, Encoding.Unicode))
w.WriteLine ("but-");
foreach (byte b in File.ReadAllBytes ("but.txt"))
Console.WriteLine (b);
这里是输出:
255 // Byte-order mark 1
254 // Byte-order mark 2
98 // 'b' byte 1
0 // 'b' byte 2
117 // 'u' byte 1
0 // 'u' byte 2
116 // 't' byte 1
0 // 't' byte 2
20 // '--' byte 1
32 // '--' byte 2
13 // <CR> byte 1
0 // <CR> byte 2
10 // <LF> byte 1
0 // <LF> byte 2
从技术上讲,UTF-16 每个字符使用两个或四个字节(已分配或保留接近一百万个 Unicode 字符,因此两个字节不总是足够)。然而,由于 C# 的 char 类型本身仅有 16 位宽度,UTF-16 编码将始终每个 .NET char 使用精确两个字节。这使得在流中跳转到特定字符索引变得容易。
UTF-16 使用两字节前缀来标识字节对是按“小尾端”还是“大尾端”顺序编写的(最不重要的字节在前或最重要的字节在前)。默认的小尾端顺序是 Windows 系统的标准。
StringReader 和 StringWriter
StringReader和StringWriter适配器根本不包装流;相反,它们使用字符串或StringBuilder作为底层数据源。这意味着不需要字节转换 — 实际上,这些类所做的就是你可以轻松通过字符串或StringBuilder加上索引变量实现的事情。然而,它们的优势在于与StreamReader/StreamWriter共享一个基类。例如,假设我们有一个包含 XML 的字符串,并希望使用XmlReader解析它。XmlReader.Create方法接受以下之一:
-
一个
URI -
一个
Stream -
一个
TextReader
那么,如何解析我们的 XML 字符串?因为StringReader是TextReader的子类,所以我们很幸运。我们可以实例化并传入StringReader如下:
XmlReader r = XmlReader.Create (new StringReader (myString));
二进制适配器
BinaryReader和BinaryWriter读取和写入本机数据类型:bool、byte、char、decimal、float、double、short、int、long、sbyte、ushort、uint和ulong,以及原始数据类型的数组和字符串。
与StreamReader和StreamWriter不同,二进制适配器在内存中高效地存储原始数据类型。因此,一个int使用四个字节;一个double使用八个字节。字符串通过文本编码写入(与StreamReader和StreamWriter一样),但在前面加了长度前缀,以便能够读回一系列字符串而无需特殊分隔符。
假设我们有一个简单的类型,定义如下:
public class Person
{
public string Name;
public int Age;
public double Height;
}
我们可以向Person添加以下方法,使用二进制适配器将其数据保存到流中/从流中加载:
public void SaveData (Stream s)
{
var w = new BinaryWriter (s);
w.Write (Name);
w.Write (Age);
w.Write (Height);
w.Flush(); // Ensure the BinaryWriter buffer is cleared.
// We won't dispose/close it, so more data
} // can be written to the stream.
public void LoadData (Stream s)
{
var r = new BinaryReader (s);
Name = r.ReadString();
Age = r.ReadInt32();
Height = r.ReadDouble();
}
BinaryReader还可以读取到字节数组。以下代码读取可定位流的全部内容:
byte[] data = new BinaryReader (s).ReadBytes ((int) s.Length);
这比直接从流中读取更方便,因为它不需要循环以确保已读取所有数据。
关闭和释放流适配器
在拆除流适配器时有四种选择:
-
仅关闭适配器
-
关闭适配器,然后关闭流
-
(对于写入器)刷新适配器,然后关闭流
-
(对于读取器)仅关闭流
注意
Close和Dispose与适配器同义,正如它们与流一样。
选项 1 和 2 在语义上是相同的,因为关闭适配器会自动关闭底层流。每当嵌套using语句时,你隐式地选择选项 2:
using (FileStream fs = File.Create ("test.txt"))
using (TextWriter writer = new StreamWriter (fs))
writer.WriteLine ("Line");
因为巢式结构是从内到外释放的,所以先关闭适配器,然后再关闭流。此外,如果在适配器的构造函数中抛出异常,流仍会关闭。嵌套的using语句很难出错!
警告
在关闭或刷新其写入器之前,永远不要关闭流 — 这将切断适配器中缓冲的任何数据。
选项 3 和 4 有效,因为适配器属于可选的一类不寻常的可丢弃对象。例如,当你完成了适配器的使用但希望保留底层流以备后续使用时,可能选择不丢弃适配器:
using (FileStream fs = new FileStream ("test.txt", FileMode.Create))
{
StreamWriter writer = new StreamWriter (fs);
writer.WriteLine ("Hello");
writer.Flush();
fs.Position = 0;
Console.WriteLine (fs.ReadByte());
}
在这里,我们向文件中写入数据,重新定位流,然后在关闭流之前读取第一个字节。如果我们处理了 StreamWriter,它也会关闭底层的 FileStream,导致后续读取失败。需要注意的是,我们调用 Flush 来确保 StreamWriter 的缓冲区写入到底层流中。
注意
流适配器—具有可选的处理语义—不实现终结器调用 Dispose 的扩展处理模式。这使得一个被丢弃的适配器在垃圾收集器追赶时能够逃避自动处理。
StreamReader/StreamWriter 还有一个构造函数,指示在处理后保持流开放。因此,我们可以将前面的示例重写如下:
using (var fs = new FileStream ("test.txt", FileMode.Create))
{
using (var writer = new StreamWriter (fs, new UTF8Encoding (false, true),
0x400, true))
writer.WriteLine ("Hello");
fs.Position = 0;
Console.WriteLine (fs.ReadByte());
Console.WriteLine (fs.Length);
}
压缩流
在 System.IO.Compression 命名空间中提供了两个通用的压缩流: DeflateStream 和 GZipStream。两者使用类似 ZIP 格式的流行压缩算法。它们的区别在于 GZipStream 在开头和结尾写入了一个额外的协议,包括用于检测错误的 CRC。GZipStream 还符合其他软件认可的标准。
.NET 还包括实现Brotli压缩算法的 BrotliStream。 BrotliStream 比 DeflateStream 和 GZipStream 慢超过 10 倍,但达到更好的压缩比。 (性能损失仅适用于压缩,解压性能非常好。)
所有三个流都允许读取和写入,但有以下注意事项:
-
压缩时总是向流中写入。
-
解压时总是从流中读取。
DeflateStream、GZipStream 和 BrotliStream 是装饰器;它们从你在构造中提供的另一个流中压缩或解压数据。在下面的示例中,我们使用 FileStream 作为支持存储,压缩和解压一系列字节:
using (Stream s = File.Create ("compressed.bin"))
using (Stream ds = new DeflateStream (s, CompressionMode.Compress))
for (byte i = 0; i < 100; i++)
ds.WriteByte (i);
using (Stream s = File.OpenRead ("compressed.bin"))
using (Stream ds = new DeflateStream (s, CompressionMode.Decompress))
for (byte i = 0; i < 100; i++)
Console.WriteLine (ds.ReadByte()); // Writes 0 to 99
使用 DeflateStream 压缩后的文件为 102 字节:略大于原始文件(使用 BrotliStream 压缩后为 73 字节)。压缩在处理“密集”的非重复二进制数据时表现不佳(尤其是加密数据,由于设计缺乏规律性,表现最差)。对于大多数文本文件效果很好;在下一个示例中,我们压缩和解压一个由从一小段句子中随机选择的 1,000 个单词组成的文本流,使用 Brotli 算法。这也展示了链式使用支持存储流、装饰器流、适配器(正如本章开头在图 15-1 中所示)和异步方法的用法:
string[] words = "The quick brown fox jumps over the lazy dog".Split();
Random rand = new Random (0); // Give it a seed for consistency
using (Stream s = File.Create ("compressed.bin"))
using (Stream ds = new BrotliStream (s, CompressionMode.Compress))
using (TextWriter w = new StreamWriter (ds))
for (int i = 0; i < 1000; i++)
await w.WriteAsync (words [rand.Next (words.Length)] + " ");
Console.WriteLine (new FileInfo ("compressed.bin").Length); // 808
using (Stream s = File.OpenRead ("compressed.bin"))
using (Stream ds = new BrotliStream (s, CompressionMode.Decompress))
using (TextReader r = new StreamReader (ds))
Console.Write (await r.ReadToEndAsync()); // Output below:
lazy lazy the fox the quick The brown fox jumps over fox over fox The
brown brown brown over brown quick fox brown dog dog lazy fox dog brown
over fox jumps lazy lazy quick The jumps fox jumps The over jumps dog...
在这种情况下,BrotliStream 高效压缩到 808 字节 —— 每个单词少于一个字节。(作为对比,DeflateStream 将相同数据压缩到 885 字节。)
在内存中进行压缩
有时,您需要完全在内存中压缩。以下是如何使用 MemoryStream 进行此操作:
byte[] data = new byte[1000]; // We can expect a good compression
// ratio from an empty array!
var ms = new MemoryStream();
using (Stream ds = new DeflateStream (ms, CompressionMode.Compress))
ds.Write (data, 0, data.Length);
byte[] compressed = ms.ToArray();
Console.WriteLine (compressed.Length); // 11
// Decompress back to the data array:
ms = new MemoryStream (compressed);
using (Stream ds = new DeflateStream (ms, CompressionMode.Decompress))
for (int i = 0; i < 1000; i += ds.Read (data, i, 1000 - i));
在 DeflateStream 周围的 using 语句中以教科书般的方式关闭它,刷新任何未写入的缓冲区。这也关闭了它包装的 MemoryStream —— 这意味着我们必须随后调用 ToArray 来提取其数据。
这里有一个替代方案,避免关闭 MemoryStream 并使用异步读写方法:
byte[] data = new byte[1000];
MemoryStream ms = new MemoryStream();
using (Stream ds = new DeflateStream (ms, CompressionMode.Compress, true))
await ds.WriteAsync (data, 0, data.Length);
Console.WriteLine (ms.Length); // 113
ms.Position = 0;
using (Stream ds = new DeflateStream (ms, CompressionMode.Decompress))
for (int i = 0; i < 1000; i += await ds.ReadAsync (data, i, 1000 - i));
发送到 DeflateStream 构造函数的附加标志指示它不遵循通常的协议,即在处理时带上底层流。换句话说,MemoryStream 保持打开状态,允许我们将其位置重新定位到零并重新读取它。
Unix gzip 文件压缩
GZipStream 的压缩算法在 Unix 系统中作为文件压缩格式非常流行。每个源文件都压缩成一个带有 .gz 扩展名的单独目标文件。
下面的方法执行 Unix 命令行 gzip 和 gunzip 实用程序的工作:
async Task GZip (string sourcefile, bool deleteSource = true)
{
var gzip = $"{sourcefile}.gz";
if (File.Exists (gzip))
throw new Exception ("Gzip file already exists");
// Compress
using (FileStream inStream = File.Open (sourcefile, FileMode.Open))
using (FileStream outStream = new FileStream (gzip, FileMode.CreateNew))
using (GZipStream gzipStream =
new GZipStream (outStream, CompressionMode.Compress))
await inStream.CopyToAsync (gzipStream);
if (deleteSource) File.Delete(sourcefile);
}
async Task GUnzip (string gzipfile, bool deleteGzip = true)
{
if (Path.GetExtension (gzipfile) != ".gz")
throw new Exception ("Not a gzip file");
var uncompressedFile = gzipfile.Substring (0, gzipfile.Length - 3);
if (File.Exists (uncompressedFile))
throw new Exception ("Destination file already exists");
// Uncompress
using (FileStream uncompressToStream =
File.Open (uncompressedFile, FileMode.Create))
using (FileStream zipfileStream = File.Open (gzipfile, FileMode.Open))
using (var unzipStream =
new GZipStream (zipfileStream, CompressionMode.Decompress))
await unzipStream.CopyToAsync (uncompressToStream);
if (deleteGzip) File.Delete (gzipfile);
}
下面的内容压缩一个文件:
await GZip ("/tmp/myfile.txt"); // Creates /tmp/myfile.txt.gz
然后进行解压:
await GUnzip ("/tmp/myfile.txt.gz") // Creates /tmp/myfile.txt
使用 ZIP 文件
System.IO.Compression 中的 ZipArchive 和 ZipFile 类支持 ZIP 压缩格式。ZIP 格式相对于 DeflateStream 和 GZipStream 的优势在于它还充当多个文件的容器,并且与通过 Windows Explorer 创建的 ZIP 文件兼容。
ZipArchive 与流一起工作,而 ZipFile 处理更常见的文件工作场景。(ZipFile 是 ZipArchive 的静态辅助类。)
ZipFile 的 CreateFromDirectory 方法将指定目录中的所有文件添加到 ZIP 文件中:
ZipFile.CreateFromDirectory (@"d:\MyFolder", @"d:\archive.zip");
ExtractToDirectory 反之将 ZIP 文件提取到目录中:
ZipFile.ExtractToDirectory (@"d:\archive.zip", @"d:\MyFolder");
(从 .NET 8 开始,您还可以指定 Stream 而不是 zip 文件路径。)
在压缩时,您可以指定是否优化文件大小或速度,以及是否在存档中包含源目录的名称。在我们的示例中启用后者选项将在存档中创建一个名为 MyFolder 的子目录,其中压缩的文件将放置在其中。
ZipFile 具有用于读取/写入单个条目的 Open 方法。它返回一个 ZipArchive 对象(您也可以通过实例化 ZipArchive 对象来获取 Stream 对象)。调用 Open 时,必须指定文件名,并指示是否要 Read、Create 或 Update 存档。然后,您可以通过 Entries 属性枚举现有条目,或通过调用 GetEntry 找到特定文件:
using (ZipArchive zip = ZipFile.Open (@"d:\zz.zip", ZipArchiveMode.Read))
foreach (ZipArchiveEntry entry in zip.Entries)
Console.WriteLine (entry.FullName + " " + entry.Length);
ZipArchiveEntry 还具有 Delete 方法,一个 ExtractToFile 方法(实际上是 ZipFileExtensions 类中的扩展方法),以及一个返回可读/可写 Stream 的 Open 方法。您可以通过在 ZipArchive 上调用 CreateEntry(或 CreateEntryFromFile 扩展方法)来创建新条目。以下创建了存档 d:\zz.zip,其中添加了 foo.dll,在存档中称为 bin\X86 的目录结构下:
byte[] data = File.ReadAllBytes (@"d:\foo.dll");
using (ZipArchive zip = ZipFile.Open (@"d:\zz.zip", ZipArchiveMode.Update))
zip.CreateEntry (@"bin\X64\foo.dll").Open().Write (data, 0, data.Length);
您还可以通过使用 MemoryStream 构造 ZipArchive 完全在内存中执行相同的操作。
使用 Tar 文件
System.Formats.Tar 命名空间中的类型(从 .NET 7 开始)支持 .tar 存档格式,这在 Unix 系统上用于打包多个文件。要创建 .tar 文件(tarball),请调用 TarFile.CreateFromDirectory:
TarFile.CreateFromDirectory ("/tmp/testfolder", "/tmp/test.tar", false);
(第三个参数指示是否在存档条目中包含基本目录名称。)
要提取 tarball,请调用 TarFile.ExtractToDirectory:
TarFile.ExtractToDirectory ("/tmp/test.tar", "/tmp/testfolder", true);
(第三个参数指示是否覆盖现有文件。)
这两种方法都允许您指定一个 Stream 而不是 .tar 文件路径。在以下示例中,我们将 tarball 写入内存流,然后使用 GZipStream 将该流压缩为 .tar.gz 文件:
var ms = new MemoryStream();
TarFile.CreateFromDirectory ("/tmp/testfolder", ms, false);
ms.Position = 0; // So that we can re-use the stream for reading.
using (var fs = File.Create ("/tmp/test.tar.gz"))
using (var gz = new GZipStream (fs, CompressionMode.Compress))
ms.CopyTo (gz);
(将 .tar 压缩为 .tar.gz 很有用,因为 .tar 格式本身不包含压缩,不像 .zip 格式。)我们可以按以下方式提取 .tar.gz 文件:
using (var fs = File.OpenRead ("/tmp/test.tar.gz"))
using (var gz = new GZipStream (fs, CompressionMode.Decompress))
TarFile.ExtractToDirectory (gz, "/tmp/testfolder", true);
您还可以使用 TarReader 和 TarWriter 类以更细粒度的级别访问 API。以下示例说明了如何使用 TarReader:
using (FileStream archiveStream = File.OpenRead ("/tmp/test.tar "))
using (TarReader reader = new (archiveStream))
while (true)
{
TarEntry entry = reader.GetNextEntry();
if (entry == null) break; // No more entries
Console.WriteLine (
$"Entry {entry.Name} is {entry.DataStream.Length} bytes long");
entry.ExtractToFile (
Path.Combine ("/tmp/testfolder", entry.Name), true);
}
文件和目录操作
System.IO 命名空间提供了一组类型,用于执行“实用”文件和目录操作,如复制和移动、创建目录以及设置文件属性和权限。对于大多数功能,您可以选择两个类中的任何一个,一个提供静态方法,另一个提供实例方法:
静态类
文件 和 目录
实例方法类(使用文件或目录名构造)
FileInfo 和 DirectoryInfo
此外,还有一个名为 Path 的静态类。它不对文件或目录执行操作,而是为文件名和目录路径提供字符串操作方法。Path 还可辅助处理临时文件。
文件类
File 是一个静态类,其所有方法都接受文件名。文件名可以是相对于当前目录的,也可以是完全限定的带有目录的。以下是它的方法(全部为 public 和 static):
bool Exists (string path); // Returns true if the file is present
void Delete (string path);
void Copy (string sourceFileName, string destFileName);
void Move (string sourceFileName, string destFileName);
void Replace (string sourceFileName, string destinationFileName,
string destinationBackupFileName);
FileAttributes GetAttributes (string path);
void SetAttributes (string path, FileAttributes fileAttributes);
void Decrypt (string path);
void Encrypt (string path);
DateTime GetCreationTime (string path); // UTC versions are
DateTime GetLastAccessTime (string path); // also provided.
DateTime GetLastWriteTime (string path);
void SetCreationTime (string path, DateTime creationTime);
void SetLastAccessTime (string path, DateTime lastAccessTime);
void SetLastWriteTime (string path, DateTime lastWriteTime);
FileSecurity GetAccessControl (string path);
FileSecurity GetAccessControl (string path,
AccessControlSections includeSections);
void SetAccessControl (string path, FileSecurity fileSecurity);
如果目标文件已存在,则 Move 抛出异常;Replace 则不会。这两种方法都允许文件重命名以及移动到另一个目录。
如果文件标记为只读,Delete将抛出UnauthorizedAccessException;你可以通过调用GetAttributes提前检查。如果操作系统不允许你的进程删除该文件,它也会抛出该异常。以下是GetAttributes返回的FileAttribute枚举的所有成员:
Archive, Compressed, Device, Directory, Encrypted,
Hidden, IntegritySystem, Normal, NoScrubData, NotContentIndexed,
Offline, ReadOnly, ReparsePoint, SparseFile, System, Temporary
该枚举中的成员可以组合使用。以下是如何在不影响其他属性的情况下切换单个文件属性的方法:
string filePath = "test.txt";
FileAttributes fa = File.GetAttributes (filePath);
if ((fa & FileAttributes.ReadOnly) != 0)
{
// Use the exclusive-or operator (^) to toggle the ReadOnly flag
fa ^= FileAttributes.ReadOnly;
File.SetAttributes (filePath, fa);
}
// Now we can delete the file, for instance:
File.Delete (filePath);
注意
FileInfo提供了一个更简单的方法来更改文件的只读标志:
new FileInfo ("test.txt").IsReadOnly = false;
压缩和加密属性
注意
此功能仅适用于 Windows,并且需要 NuGet 包System.Management。
Compressed和Encrypted文件属性对应于 Windows 资源管理器中文件或目录属性对话框中的压缩和加密复选框。这种类型的压缩和加密是透明的,因为操作系统在幕后完成所有工作,允许你读写明文数据。
你不能使用SetAttributes来更改文件的Compressed或Encrypted属性——如果你尝试,它会静默失败!在后一种情况下的解决方法很简单:你可以调用File类中的Encrypt()和Decrypt()方法。对于压缩,情况更为复杂;一个解决方案是使用System.Management中的 Windows 管理基础结构(WMI)API。以下方法压缩一个目录,成功时返回0(或者返回 WMI 错误代码):
static uint CompressFolder (string folder, bool recursive)
{
string path = "Win32_Directory.Name='" + folder + "'";
using (ManagementObject dir = new ManagementObject (path))
using (ManagementBaseObject p = dir.GetMethodParameters ("CompressEx"))
{
p ["Recursive"] = recursive;
using (ManagementBaseObject result = dir.InvokeMethod ("CompressEx",
p, null))
return (uint) result.Properties ["ReturnValue"].Value;
}
}
要解压,请将CompressEx替换为UncompressEx。
透明加密依赖于从已登录用户密码生成的密钥。该系统能够处理由认证用户执行的密码更改,但如果通过管理员重置密码,则加密文件中的数据将无法恢复。
注意
透明加密和压缩需要特殊的文件系统支持。NTFS(在硬盘上最常见)支持这些功能;CDFS(在 CD-ROM 上)和 FAT(在可移动媒体卡上)不支持。
你可以通过 Win32 互操作确定一个卷是否支持压缩和加密:
using System;
using System.IO;
using System.Text;
using System.ComponentModel;
using System.Runtime.InteropServices;
class SupportsCompressionEncryption
{
const int SupportsCompression = 0x10;
const int SupportsEncryption = 0x20000;
[DllImport ("Kernel32.dll", SetLastError = true)]
extern static bool GetVolumeInformation (string vol, StringBuilder name,
int nameSize, out uint serialNum, out uint maxNameLen, out uint flags,
StringBuilder fileSysName, int fileSysNameSize);
static void Main()
{
uint serialNum, maxNameLen, flags;
bool ok = GetVolumeInformation (@"C:\", null, 0, out serialNum,
out maxNameLen, out flags, null, 0);
if (!ok)
throw new Win32Exception();
bool canCompress = (flags & SupportsCompression) != 0;
bool canEncrypt = (flags & SupportsEncryption) != 0;
}
}
Windows 文件安全
注意
此功能仅适用于 Windows,并且需要 NuGet 包System.IO.FileSystem.AccessControl。
FileSecurity类允许你查询和更改分配给用户和角色的操作系统权限(命名空间System.Security.AccessControl)。
在这个例子中,我们列出了文件的现有权限,然后将写权限分配给“Users”组:
using System;
using System.IO;
using System.Security.AccessControl;
using System.Security.Principal;
void ShowSecurity (FileSecurity sec)
{
AuthorizationRuleCollection rules = sec.GetAccessRules (true, true,
typeof (NTAccount));
foreach (FileSystemAccessRule r in rules.Cast<FileSystemAccessRule>()
.OrderBy (rule => rule.IdentityReference.Value))
{
// e.g., MyDomain/Joe
Console.WriteLine ($" {r.IdentityReference.Value}");
// Allow or Deny: e.g., FullControl
Console.WriteLine ($" {r.FileSystemRights}: {r.AccessControlType}");
}
}
var file = "sectest.txt";
File.WriteAllText (file, "File security test.");
var sid = new SecurityIdentifier (WellKnownSidType.BuiltinUsersSid, null);
string usersAccount = sid.Translate (typeof (NTAccount)).ToString();
Console.WriteLine ($"User: {usersAccount}");
FileSecurity sec = new FileSecurity (file,
AccessControlSections.Owner |
AccessControlSections.Group |
AccessControlSections.Access);
Console.WriteLine ("AFTER CREATE:");
ShowSecurity(sec); // BUILTIN\Users doesn't have Write permission
sec.ModifyAccessRule (AccessControlModification.Add,
new FileSystemAccessRule (usersAccount, FileSystemRights.Write,
AccessControlType.Allow),
out bool modified);
Console.WriteLine ("AFTER MODIFY:");
ShowSecurity (sec); // BUILTIN\Users has Write permission
我们稍后在“特殊文件夹”中给出另一个示例。
Unix 文件安全
从.NET 7 开始,File类包含了在 Unix 系统上获取和设置文件权限的方法GetUnixFileMode和SetUnixFileMode。现在Directory.CreateDirectory方法也可以接受 Unix 文件模式,并且在创建文件时可以指定文件模式,如下所示:
var fs = new FileStream ("test.txt",
new FileStreamOptions
{
Mode = FileMode.Create,
UnixCreateMode = UnixFileMode.UserRead | UnixFileMode.UserWrite
});
目录类
静态 Directory 类提供了一组类似于 File 类的方法,用于检查目录是否存在 (Exists)、移动目录 (Move)、删除目录 (Delete)、获取/设置创建或最后访问时间以及获取/设置安全权限。此外,Directory 还公开了以下静态方法:
string GetCurrentDirectory ();
void SetCurrentDirectory (string path);
DirectoryInfo CreateDirectory (string path);
DirectoryInfo GetParent (string path);
string GetDirectoryRoot (string path);
string[] GetLogicalDrives(); // Gets mount points on Unix
// The following methods all return full paths:
string[] GetFiles (string path);
string[] GetDirectories (string path);
string[] GetFileSystemEntries (string path);
IEnumerable<string> EnumerateFiles (string path);
IEnumerable<string> EnumerateDirectories (string path);
IEnumerable<string> EnumerateFileSystemEntries (string path);
注意
最后三个方法比 Get* 变体可能更有效,因为它们是惰性评估的——在枚举序列时从文件系统获取数据。它们特别适合于 LINQ 查询。
Enumerate* 和 Get* 方法还重载为接受 searchPattern(字符串)和 searchOption(枚举)参数。如果指定 SearchOption.SearchAllSubDirectories,则执行递归子目录搜索。*FileSystemEntries 方法将 *Files 的结果与 *Directories 结合起来。
如果目录不存在,则可以按以下方式创建它:
if (!Directory.Exists (@"d:\test"))
Directory.CreateDirectory (@"d:\test");
FileInfo 和 DirectoryInfo
File 和 Directory 上的静态方法对于执行单个文件或目录操作很方便。如果需要连续调用一系列方法,FileInfo 和 DirectoryInfo 类提供了一个对象模型,使任务更容易完成。
FileInfo 在实例形式下提供了大多数 File 的静态方法,还有一些额外的属性,如 Extension、Length、IsReadOnly 和 Directory,用于返回 DirectoryInfo 对象。例如:
static string TestDirectory =>
RuntimeInformation.IsOSPlatform (OSPlatform.Windows)
? @"C:\Temp"
: "/tmp";
Directory.CreateDirectory (TestDirectory);
FileInfo fi = new FileInfo (Path.Combine (TestDirectory, "FileInfo.txt"));
Console.WriteLine (fi.Exists); // false
using (TextWriter w = fi.CreateText())
w.Write ("Some text");
Console.WriteLine (fi.Exists); // false (still)
fi.Refresh();
Console.WriteLine (fi.Exists); // true
Console.WriteLine (fi.Name); // FileInfo.txt
Console.WriteLine (fi.FullName); // c:\temp\FileInfo.txt (Windows)
// /tmp/FileInfo.txt (Unix)
Console.WriteLine (fi.DirectoryName); // c:\temp (Windows)
// /tmp (Unix)
Console.WriteLine (fi.Directory.Name); // temp
Console.WriteLine (fi.Extension); // .txt
Console.WriteLine (fi.Length); // 9
fi.Encrypt();
fi.Attributes ^= FileAttributes.Hidden; // (Toggle hidden flag)
fi.IsReadOnly = true;
Console.WriteLine (fi.Attributes); // ReadOnly,Archive,Hidden,Encrypted
Console.WriteLine (fi.CreationTime); // 3/09/2019 1:24:05 PM
fi.MoveTo (Path.Combine (TestDirectory, "FileInfoX.txt"));
DirectoryInfo di = fi.Directory;
Console.WriteLine (di.Name); // temp or tmp
Console.WriteLine (di.FullName); // c:\temp or /tmp
Console.WriteLine (di.Parent.FullName); // c:\ or /
di.CreateSubdirectory ("SubFolder");
这是如何使用 DirectoryInfo 枚举文件和子目录的方法:
DirectoryInfo di = new DirectoryInfo (@"e:\photos");
foreach (FileInfo fi in di.GetFiles ("*.jpg"))
Console.WriteLine (fi.Name);
foreach (DirectoryInfo subDir in di.GetDirectories())
Console.WriteLine (subDir.FullName);
Path
Path 类的静态 Path 方法定义了用于处理路径和文件名的方法和字段。
假设有这个设置代码:
string dir = @"c:\mydir"; // or /mydir
string file = "myfile.txt";
string path = @"c:\mydir\myfile.txt"; // or /mydir/myfile.txt
Directory.SetCurrentDirectory (@"k:\demo"); // or /demo
我们可以用以下表达式演示 Path 的方法和字段:
| 表达式 | 结果(Windows,然后 Unix) |
|---|---|
Directory.GetCurrentDirectory() |
k:\demo\ 或 /demo |
Path.IsPathRooted (file) |
False |
Path.IsPathRooted (path) |
True |
Path.GetPathRoot (path) |
c:\ 或 / |
Path.GetDirectoryName (path) |
c:\mydir 或 /mydir |
Path.GetFileName (path) |
myfile.txt |
Path.GetFullPath (file) |
k:\demo\myfile.txt 或 /demo/myfile.txt |
Path.Combine (dir, file) |
c:\mydir\myfile.txt 或 /mydir/myfile.txt |
| 文件扩展名: | |
Path.HasExtension (file) |
True |
Path.GetExtension (file) |
.txt |
Path.GetFileNameWithoutExtension (file) |
myfile |
Path.ChangeExtension (file, ".log") |
myfile.log |
| 分隔符和字符: | |
Path.DirectorySeparatorChar |
\ 或 / |
Path.AltDirectorySeparatorChar |
/ |
Path.PathSeparator |
; 或 : |
Path.VolumeSeparatorChar |
: 或 / |
Path.GetInvalidPathChars() |
字符 0 到 31 和 "<>|eor 0 |
Path.GetInvalidFileNameChars() |
字符 0 到 31 和 "<>|:*?\/ 或 0 和 / |
| 临时文件: | |
Path.GetTempPath() |
|
Path.GetRandomFileName() |
*d2dwuzjf.dnp* |
Path.GetTempFileName() |
\tmp14B.tmp 或 */tmp/*tmpubSUYO.tmp` |
Combine 特别有用:它允许您将目录和文件名或两个目录组合在一起,而无需首先检查是否存在尾部路径分隔符,并且它会自动使用正确的操作系统路径分隔符。它提供了多达四个目录和/或文件名的重载。
GetFullPath 将相对于当前目录的路径转换为绝对路径。它接受诸如 ....\file.txt 这样的值。
GetRandomFileName 返回一个真正唯一的 8.3 字符文件名,实际上并不创建任何文件。 GetTempFileName 使用自增计数器生成临时文件名,每 65,000 个文件循环一次。然后在本地临时目录创建以此名称命名的零字节文件。
警告
当您完成使用由 GetTempFileName 生成的文件时,您必须删除它;否则,在您第 65,000 次调用 GetTempFileName 后,它最终会抛出异常。如果这是一个问题,您可以使用 Combine GetTempPath 与 GetRandomFileName。只需小心,不要填满用户的硬盘驱动器!
特殊文件夹
Path 和 Directory 中缺少的一件事是定位文件夹(如 My Documents、Program Files、Application Data 等)的方法。这由 System.Environment 类中的 GetFolderPath 方法提供:
string myDocPath = Environment.GetFolderPath
(Environment.SpecialFolder.MyDocuments);
Environment.SpecialFolder 是一个枚举,其值涵盖了 Windows 中的所有特殊目录,例如 AdminTools、ApplicationData、Fonts、History、SendTo、StartMenu 等等。这里涵盖了所有内容,除了 .NET 运行时目录,您可以通过以下方式获取它:
System.Runtime.InteropServices.RuntimeEnvironment.GetRuntimeDirectory()
注意
大多数特殊文件夹在 Unix 系统上没有分配路径。在 Ubuntu Linux 18.04 桌面版上有路径的特殊文件夹包括:ApplicationData、CommonApplicationData、Desktop、DesktopDirectory、LocalApplicationData、MyDocuments、MyMusic、MyPictures、MyVideos、Templates 和 UserProfile。
在 Windows 系统上特别有价值的是 ApplicationData,您可以在这里存储随用户跨网络移动的设置(如果在网络域上启用了漫游配置文件);LocalApplicationData 用于非漫游数据(特定于登录用户);CommonApplicationData 被计算机上的每个用户共享。将应用程序数据写入这些文件夹被认为优于使用 Windows 注册表。在这些文件夹中存储数据的标准协议是创建一个以您的应用程序名称命名的子目录:
string localAppDataPath = Path.Combine (
Environment.GetFolderPath (Environment.SpecialFolder.ApplicationData),
"MyCoolApplication");
if (!Directory.Exists (localAppDataPath))
Directory.CreateDirectory (localAppDataPath);
使用CommonApplicationData时存在一个可怕的陷阱:如果用户以管理员权限启动您的程序,然后您的程序在CommonApplicationData中创建文件夹和文件,那么在以受限 Windows 登录方式运行时,该用户可能缺少替换这些文件的权限。(在受限权限帐户之间切换时也存在类似问题。)您可以通过在设置过程中创建所需的文件夹(并为每个人分配权限)来解决此问题。
另一个写入配置和日志文件的地方是应用程序的基目录,您可以通过AppDomain.CurrentDomain.BaseDirectory获取它。然而,这并不推荐,因为操作系统可能会在初始安装后拒绝您的应用程序对此文件夹的写入权限(没有管理员权限)。
查询卷信息
您可以使用DriveInfo类查询计算机上的驱动器:
DriveInfo c = new DriveInfo ("C"); // Query the C: drive.
// On Unix: /
long totalSize = c.TotalSize; // Size in bytes.
long freeBytes = c.TotalFreeSpace; // Ignores disk quotas.
long freeToMe = c.AvailableFreeSpace; // Takes quotas into account.
foreach (DriveInfo d in DriveInfo.GetDrives()) // All defined drives.
// On Unix: mount points
{
Console.WriteLine (d.Name); // C:\
Console.WriteLine (d.DriveType); // Fixed
Console.WriteLine (d.RootDirectory); // C:\
if (d.IsReady) // If the drive is not ready, the following two
// properties will throw exceptions:
{
Console.WriteLine (d.VolumeLabel); // The Sea Drive
Console.WriteLine (d.DriveFormat); // NTFS
}
}
静态的GetDrives方法返回所有映射驱动器,包括 CD-ROM、媒体卡和网络连接。DriveType是一个枚举,具有以下值:
Unknown, NoRootDirectory, Removable, Fixed, Network, CDRom, Ram
捕捉文件系统事件
FileSystemWatcher类允许您监视目录(以及可选的子目录)的活动。FileSystemWatcher具有在创建、修改、重命名和删除文件或子目录以及更改它们的属性时触发的事件。这些事件不论执行更改的用户或进程如何都会触发。以下是一个示例:
Watch (GetTestDirectory(), "*.txt", true);
void Watch (string path, string filter, bool includeSubDirs)
{
using (var watcher = new FileSystemWatcher (path, filter))
{
watcher.Created += FileCreatedChangedDeleted;
watcher.Changed += FileCreatedChangedDeleted;
watcher.Deleted += FileCreatedChangedDeleted;
watcher.Renamed += FileRenamed;
watcher.Error += FileError;
watcher.IncludeSubdirectories = includeSubDirs;
watcher.EnableRaisingEvents = true;
Console.WriteLine ("Listening for events - press <enter> to end");
Console.ReadLine();
}
// Disposing the FileSystemWatcher stops further events from firing.
}
void FileCreatedChangedDeleted (object o, FileSystemEventArgs e)
=> Console.WriteLine ("File {0} has been {1}", e.FullPath, e.ChangeType);
void FileRenamed (object o, RenamedEventArgs e)
=> Console.WriteLine ("Renamed: {0}->{1}", e.OldFullPath, e.FullPath);
void FileError (object o, ErrorEventArgs e)
=> Console.WriteLine ("Error: " + e.GetException().Message);
string GetTestDirectory() =>
RuntimeInformation.IsOSPlatform (OSPlatform.Windows)
? @"C:\Temp"
: "/tmp";
警告
因为FileSystemWatcher在单独的线程上引发事件,所以必须对事件处理代码进行异常处理,以防止错误导致应用程序崩溃。更多信息,请参阅“异常处理”。
Error事件不会通知您文件系统错误;相反,它指示FileSystemWatcher的事件缓冲区溢出,因为它被Changed、Created、Deleted或Renamed事件压倒。您可以通过InternalBufferSize属性更改缓冲区大小。
IncludeSubdirectories递归应用。因此,如果您在*C:*上创建一个FileSystemWatcher并设置IncludeSubdirectories true,则其事件将在硬盘驱动器的任何位置发生文件或目录更改时触发。
警告
在使用FileSystemWatcher时的一个陷阱是,在文件完全填充或更新之前打开和读取新创建或更新的文件。如果您正在与其他软件一起工作,该软件正在创建文件,您可能需要考虑一些策略来减轻这个问题,例如在未监视的扩展名下创建文件,然后在完全写入后重命名它们。
操作系统安全性
所有应用程序都受操作系统限制,根据用户的登录权限。这些限制会影响文件 I/O 以及其他功能,如访问 Windows 注册表。
在 Windows 和 Unix 中,有两种类型的账户:
-
在访问本地计算机时不施加任何限制的管理/超级用户账户
-
限制权限账户会限制管理员功能和其他用户数据的可见性
在 Windows 上,一个名为用户账户控制(UAC)的功能意味着管理员在登录时会获得两个令牌或“帽子”:一个管理员帽子和一个普通用户帽子。默认情况下,程序以普通用户帽子运行,具有受限权限,除非程序请求管理员提升。用户必须在呈现的对话框中批准请求。
在 Unix 上,用户通常使用受限账户登录。管理员也是如此,以减少意外损坏系统的可能性。当用户需要运行需要提升权限的命令时,他们在命令前加上sudo(简称“超级用户执行”)。
默认情况下,您的应用程序将以受限用户权限运行。这意味着您必须选择以下一种方式:
-
编写您的应用程序,使其可以在无需管理员权限的情况下运行。
-
在应用程序清单中要求管理员提升(仅限 Windows),或检测缺少的所需权限并提示用户以管理员/超级用户身份重新启动应用程序。
第一种选择对用户来说更安全和更方便。在大多数情况下,设计您的程序以无需管理员权限运行是很容易的。
您可以通过以下方式确定是否正在以管理员帐户运行:
[DllImport("libc")]
public static extern uint getuid();
static bool IsRunningAsAdmin()
{
if (RuntimeInformation.IsOSPlatform (OSPlatform.Windows))
{
using var identity = WindowsIdentity.GetCurrent();
var principal = new WindowsPrincipal (identity);
return principal.IsInRole (WindowsBuiltInRole.Administrator);
}
return getuid() == 0;
}
在 Windows 启用 UAC 后,只有当前进程具有管理员提升时,此返回值才为true。在 Linux 上,仅在当前进程作为超级用户运行时(例如,sudo myapp),此返回值才为true。
在标准用户账户中运行
这里是标准用户账户中不能执行的关键操作:
-
写入以下目录:
-
操作系统文件夹(通常为\Windows或/bin,/sbin,...)及其子目录
-
程序文件夹(\Program Files或/usr/bin,/opt)及其子目录
-
操作系统驱动器的根目录(例如C:*或/*)
-
-
写入到注册表的 HKEY_LOCAL_MACHINE 分支(Windows)
-
读取性能监视(WMI)数据(Windows)
此外,作为普通 Windows 用户(甚至作为管理员),您可能会被拒绝访问属于其他用户的文件或资源。Windows 使用访问控制列表(ACL)系统来保护这些资源,您可以通过System.Security.AccessControl中的类型查询和断言自己在 ACL 中的权限。ACL 也可应用于跨进程等待句柄,详见第二十一章。
如果由于操作系统安全性而拒绝访问任何内容,CLR 会检测到失败并抛出UnauthorizedAccessException(而不是静默失败)。
在大多数情况下,您可以按以下方式处理标准用户的限制:
-
将文件写入推荐的位置。
-
避免使用注册表存储可以存储在文件中的信息(除了 HKEY_CURRENT_USER hive,在 Windows 上只有读写访问权限)。
-
在安装过程中注册 ActiveX 或 COM 组件(仅限 Windows)。
用户文档的推荐位置是SpecialFolder.MyDocuments:
string docsFolder = Environment.GetFolderPath
(Environment.SpecialFolder.MyDocuments);
string path = Path.Combine (docsFolder, "test.txt");
用户可能需要在应用程序之外修改的配置文件的推荐位置是SpecialFolder.ApplicationData(仅当前用户)或SpecialFolder.CommonApplicationData(所有用户)。通常在这些文件夹中根据组织和产品名称创建子目录。
管理员提升和虚拟化
使用应用程序清单,您可以请求 Windows 在运行程序时提示用户进行管理员提升(Linux 会忽略此请求):
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" >
<trustInfo >
<security>
<requestedPrivileges>
<requestedExecutionLevel level="requireAdministrator" />
</requestedPrivileges>
</security>
</trustInfo>
</assembly>
(我们在第十七章中更详细地描述了应用程序清单。)
如果您将requireAdministrator替换为asInvoker,它会告诉 Windows 不需要管理员提升。其效果几乎与根本没有应用程序清单一样——除了禁用虚拟化。虚拟化是 Windows Vista 引入的临时措施,旨在帮助旧应用程序在没有管理员权限的情况下正确运行。没有包含requestedExecutionLevel元素的应用程序清单将激活此向后兼容功能。
当应用程序写入Program Files或Windows目录,或者注册表的 HKEY_LOCAL_MACHINE 区域时,虚拟化就会发挥作用。不会抛出异常,而是将更改重定向到硬盘上的另一个位置,这样就不会影响原始数据。这可以防止应用程序干扰操作系统或其他良好行为的应用程序。
内存映射文件
内存映射文件提供了两个关键功能:
-
对文件数据的高效随机访问
-
在同一台计算机上不同进程之间共享内存的能力
内存映射文件的类型位于System.IO.MemoryMappedFiles命名空间。在内部,它们通过包装操作系统的内存映射文件 API 来工作。
内存映射文件和随机文件 I/O
虽然普通的FileStream允许随机文件 I/O(通过设置流的Position属性),但它优化了顺序 I/O。粗略的经验法则是:
-
对于顺序 I/O,
FileStream比内存映射文件大约快 10 倍。 -
内存映射文件对于随机 I/O 比
FileStream大约快 10 倍。
更改FileStream的Position可能会耗费几微秒时间,如果在循环中执行,这些时间将会累积。FileStream也不适合多线程访问——因为在读取或写入时其位置会发生变化。
创建内存映射文件:
-
获得
FileStream与通常一样。 -
实例化
MemoryMappedFile,并传入文件流。 -
在内存映射文件对象上调用
CreateViewAccessor。
最后一步为您提供了一个MemoryMappedViewAccessor对象,该对象提供了用于随机读写简单类型、结构和数组的方法(更多信息请参见“使用视图访问器”)。
以下代码创建了一个百万字节的文件,然后使用内存映射文件 API 在位置 500,000 读取并写入一个字节:
File.WriteAllBytes ("long.bin", new byte [1000000]);
using MemoryMappedFile mmf = MemoryMappedFile.CreateFromFile ("long.bin");
using MemoryMappedViewAccessor accessor = mmf.CreateViewAccessor();
accessor.Write (500000, (byte) 77);
Console.WriteLine (accessor.ReadByte (500000)); // 77
当调用CreateFromFile时,您还可以指定映射名称和容量。指定非空映射名称允许与其他进程共享内存块(请参阅以下部分);指定容量会自动将文件扩展到该值。以下创建了一个 1,000 字节的文件:
File.WriteAllBytes ("short.bin", new byte [1]);
using (var mmf = MemoryMappedFile.CreateFromFile
("short.bin", FileMode.Create, null, 1000))
...
内存映射文件和共享内存(Windows)
在 Windows 下,您还可以使用内存映射文件作为在同一台计算机上多个进程之间共享内存的一种手段。一个进程通过调用MemoryMappedFile.CreateNew创建一个共享内存块,然后其他进程通过调用具有相同名称的MemoryMappedFile.OpenExisting来订阅同一内存块。尽管它仍然被称为内存映射的“文件”,但它完全驻留在内存中,没有磁盘存在。
以下代码创建了一个 500 字节的共享内存映射文件,并在位置 0 写入整数 12345:
using (MemoryMappedFile mmFile = MemoryMappedFile.CreateNew ("Demo", 500))
using (MemoryMappedViewAccessor accessor = mmFile.CreateViewAccessor())
{
accessor.Write (0, 12345);
Console.ReadLine(); // Keep shared memory alive until user hits Enter.
}
以下代码打开了那个内存映射文件并读取了那个整数:
// This can run in a separate executable:
using (MemoryMappedFile mmFile = MemoryMappedFile.OpenExisting ("Demo"))
using (MemoryMappedViewAccessor accessor = mmFile.CreateViewAccessor())
Console.WriteLine (accessor.ReadInt32 (0)); // 12345
跨平台进程间共享内存
Windows 和 Unix 都允许多个进程将同一文件映射到内存中。您必须小心确保适当的文件共享设置:
static void Writer()
{
var file = Path.Combine (TestDirectory, "interprocess.bin");
File.WriteAllBytes (file, new byte [100]);
using FileStream fs =
new FileStream (file, FileMode.Open, FileAccess.ReadWrite,
FileShare.ReadWrite);
using MemoryMappedFile mmf = MemoryMappedFile
.CreateFromFile (fs, null, fs.Length, MemoryMappedFileAccess.ReadWrite,
HandleInheritability.None, true);
using MemoryMappedViewAccessor accessor = mmf.CreateViewAccessor();
accessor.Write (0, 12345);
Console.ReadLine(); // Keep shared memory alive until user hits Enter.
File.Delete (file);
}
static void Reader()
{
// This can run in a separate executable:
var file = Path.Combine (TestDirectory, "interprocess.bin");
using FileStream fs =
new FileStream (file, FileMode.Open, FileAccess.ReadWrite,
FileShare.ReadWrite);
using MemoryMappedFile mmf = MemoryMappedFile
.CreateFromFile (fs, null, fs.Length, MemoryMappedFileAccess.ReadWrite,
HandleInheritability.None, true);
using MemoryMappedViewAccessor accessor = mmf.CreateViewAccessor();
Console.WriteLine (accessor.ReadInt32 (0)); // 12345
}
static string TestDirectory =>
RuntimeInformation.IsOSPlatform (OSPlatform.Windows)
? @"C:\Test"
: "/tmp";
使用视图访问器
在MemoryMappedFile上调用CreateViewAccessor会给您一个视图访问器,让您可以在随机位置读/写值。
Read*/Write*方法接受数值类型、bool和char,以及包含值类型元素或字段的数组和结构体。禁止使用引用类型及包含引用类型的数组或结构体,因为它们无法映射到非托管内存中。因此,如果要写入字符串,必须将其编码为字节数组:
byte[] data = Encoding.UTF8.GetBytes ("This is a test");
accessor.Write (0, data.Length);
accessor.WriteArray (4, data, 0, data.Length);
请注意,我们先写入了长度。这意味着我们知道稍后要读回多少字节:
byte[] data = new byte [accessor.ReadInt32 (0)];
accessor.ReadArray (4, data, 0, data.Length);
Console.WriteLine (Encoding.UTF8.GetString (data)); // This is a test
这里有一个读取/写入结构体的例子:
struct Data { public int X, Y; }
...
var data = new Data { X = 123, Y = 456 };
accessor.Write (0, ref data);
accessor.Read (0, out data);
Console.WriteLine (data.X + " " + data.Y); // 123 456
Read和Write方法的速度令人惊讶地慢。您可以通过直接访问底层非托管内存的指针来获得更好的性能。继续上一个例子:
unsafe
{
byte* pointer = null;
try
{
accessor.SafeMemoryMappedViewHandle.AcquirePointer (ref pointer);
int* intPointer = (int*) pointer;
Console.WriteLine (*intPointer); // 123
}
finally
{
if (pointer != null)
accessor.SafeMemoryMappedViewHandle.ReleasePointer();
}
}
您的项目必须配置为允许不安全代码。您可以通过编辑.csproj文件来实现这一点:
<PropertyGroup>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
指针的性能优势在处理大型结构时尤为明显,因为它们让您直接处理原始数据,而不是使用Read/Write在托管和非托管内存之间复制数据。我们在第二十四章进一步探讨这一点。
第十六章:网络
.NET 提供了一系列类用于通过标准网络协议(如 HTTP 和 TCP/IP)进行通信,这些类位于 System.Net.* 命名空间中。以下是关键组件的摘要:
-
HttpClient用于消费 HTTP Web API 和 RESTful 服务 -
HttpListener用于编写 HTTP 服务器 -
SmtpClient用于通过 SMTP 构建和发送邮件消息 -
Dns用于在域名和地址之间进行转换 -
TcpClient、UdpClient、TcpListener和Socket类用于直接访问传输和网络层
本章中的 .NET 类型位于 System.Net.* 和 System.IO 命名空间中。
注意
.NET 还为 FTP 提供了客户端支持,但仅限于自 .NET 6 起已标记为过时的类。如果需要使用 FTP,您最好选择 NuGet 库,例如 FluentFTP。
网络架构
图 16-1 展示了 .NET 网络类型及其所属的通信层。大多数类型位于传输层或应用层。传输层定义了发送和接收字节的基本协议(TCP 和 UDP);应用层定义了针对特定应用程序设计的高级协议,例如检索网页(HTTP)、发送邮件(SMTP)和在域名和 IP 地址之间转换(DNS)。

图 16-1. 网络架构
通常最方便的是在应用层编程;但是,直接在传输层工作可能有几个理由。其中一个是如果您需要 .NET 中没有提供的应用层协议,比如用于检索邮件的 POP3。另一个是如果您想为特殊应用程序(如对等客户端)发明自定义协议。
应用层协议中,HTTP 在其通用通信适用性上非常特殊。其基本操作模式——“给我这个 URL 的网页”——很好地适应于“使用这些参数调用此端点后得到的结果”。(除了“get”动词外,还有“put”、“post”和“delete”,允许基于 REST 的服务。)
HTTP 还具有丰富的功能集,对于多层业务应用程序和面向服务的架构非常有用,如身份验证和加密协议、消息分块、可扩展的头部和 Cookie、以及许多服务器应用程序共享单一端口和 IP 地址的能力。因此,HTTP 在 .NET 中得到了很好的支持——既通过本章描述的直接支持,也通过 Web API 和 ASP.NET Core 等技术在更高层次上支持。
如前所述,网络是一个充斥着缩略词的领域。我们在表 16-1 中列出了最常见的缩略词。
表 16-1. 网络缩略词
| 缩略词 | 扩展 | 备注 |
|---|---|---|
| DNS | 域名服务 | 在域名(例如 ebay.com)和 IP 地址(例如 199.54.213.2)之间进行转换 |
| FTP | 文件传输协议 | 用于发送和接收文件的互联网协议 |
| HTTP | 超文本传输协议 | 检索网页和运行网络服务 |
| IIS | 互联网信息服务 | 微软的网络服务器软件 |
| IP | 互联网协议 | 位于 TCP 和 UDP 之下的网络层协议 |
| LAN | 局域网 | 大多数 LAN 使用基于互联网的协议,如 TCP/IP |
| POP | 邮局协议 | 检索互联网邮件 |
| REST | 表述性状态转移 | 使用响应中的可机器跟随链接的流行 Web 服务架构,可以在基本的 HTTP 上运行 |
| SMTP | 简单邮件传输协议 | 发送互联网邮件 |
| TCP | 传输和控制协议 | 大多数高层服务建立在其上的传输层互联网协议 |
| UDP | 通用数据报协议 | 用于低开销服务(如 VoIP)的传输层互联网协议 |
| UNC | 通用命名约定 | \computer\sharename\filename |
| URI | 统一资源标识符 | 普遍的资源命名系统(例如,http://www.amazon.com 或 mailto:joe@bloggs.org) |
| URL | 统一资源定位符 | 技术含义(逐渐不再使用):URI 的子集;普通含义:URI 的同义词 |
地址和端口
为了使通信正常工作,计算机或设备需要一个地址。互联网使用两个寻址系统:
IPv4
目前主导的寻址系统;IPv4 地址为 32 位。以字符串格式化时,IPv4 地址写成四个点分隔的十进制数(例如,101.102.103.104)。一个地址可以是全球唯一的,或者在特定子网内是唯一的(例如在企业网络上)。
IPv6
较新的 128 位寻址系统。地址以十六进制格式化,并用冒号分隔(例如,[3EA0:FFFF:198A:E4A3:4FF2:54fA:41BC:8D31])。在.NET 中,需要在地址周围加上方括号。
System.Net命名空间中的IPAddress类表示任一协议中的地址。它有一个接受字节数组的构造函数,并且一个静态的Parse方法接受正确格式的字符串:
IPAddress a1 = new IPAddress (new byte[] { 101, 102, 103, 104 });
IPAddress a2 = IPAddress.Parse ("101.102.103.104");
Console.WriteLine (a1.Equals (a2)); // True
Console.WriteLine (a1.AddressFamily); // InterNetwork
IPAddress a3 = IPAddress.Parse
("[3EA0:FFFF:198A:E4A3:4FF2:54fA:41BC:8D31]");
Console.WriteLine (a3.AddressFamily); // InterNetworkV6
TCP 和 UDP 协议将每个 IP 地址分解为 65535 个端口,允许单个地址上的计算机运行多个应用程序,每个应用程序位于自己的端口上。许多应用程序具有标准的默认端口分配;例如,HTTP 使用端口 80;SMTP 使用端口 25。
注意
TCP 和 UDP 端口从 49152 到 65535 是官方未分配的,因此它们非常适合测试和小规模部署。
在.NET 中,IP 地址和端口组合由IPEndPoint类表示:
IPAddress a = IPAddress.Parse ("101.102.103.104");
IPEndPoint ep = new IPEndPoint (a, 222); // Port 222
Console.WriteLine (ep.ToString()); // 101.102.103.104:222
注意
防火墙阻止端口。在许多企业环境中,只有少数端口是开放的,通常是端口 80(用于非加密的 HTTP)和端口 443(用于安全 HTTP)。
URI
URI 是一个特殊格式的字符串,描述了互联网或 LAN 上的资源,例如网页、文件或电子邮件地址。示例包括http://www.ietf.org、ftp://myisp/doc.txt和mailto:joe@bloggs.com。确切的格式由Internet Engineering Task Force(IETF)定义。
URI 可以分解为一系列元素—通常是scheme、authority和path。System命名空间中的Uri类正是执行此分割,为每个元素公开一个属性,如在图 16-2 中所示。

图 16-2. URI 属性
注意
当您需要验证 URI 字符串的格式或将 URI 拆分为其组成部分时,Uri类非常有用。否则,您可以简单地将 URI 视为字符串—大多数网络方法都重载以接受Uri对象或字符串。
您可以通过将以下任何字符串传递到其构造函数来构造Uri对象:
-
URI 字符串,例如http://www.ebay.com或file://janespc/sharedpics/dolphin.jpg
-
硬盘上文件的绝对路径,例如c:\myfiles\data.xlsx或在 Unix 上,/tmp/myfiles/data.xlsx
-
LAN 上文件的 UNC 路径,例如\janespc\sharedpics\dolphin.jpg
文件和 UNC 路径会自动转换为 URI:添加了“file:”协议,并将反斜杠转换为正斜杠。Uri构造函数还在创建Uri之前对您的字符串执行一些基本清理,包括将方案和主机名转换为小写,并删除默认和空白端口号。如果提供没有方案的 URI 字符串,例如www.test.com,则会引发UriFormatException。
Uri有一个IsLoopback属性,指示Uri是否引用本地主机(IP 地址 127.0.0.1),以及一个IsFile属性,指示Uri是否引用本地或 UNC(IsUnc)路径(IsUnc对于在 Linux 文件系统中挂载的Samba共享返回false)。如果IsFile返回true,则LocalPath属性返回一个针对本地操作系统友好的AbsolutePath版本(适当地使用斜杠或反斜杠),您可以在其上调用File.Open。
Uri的实例具有只读属性。要修改现有的Uri,请实例化一个UriBuilder对象—它具有可写属性,并可以通过其Uri属性转换回来。
Uri还提供了比较和减去路径的方法:
Uri info = new Uri ("http://www.domain.com:80/info/");
Uri page = new Uri ("http://www.domain.com/info/page.html");
Console.WriteLine (info.Host); // www.domain.com
Console.WriteLine (info.Port); // 80
Console.WriteLine (page.Port); // 80 (Uri knows the default HTTP port)
Console.WriteLine (info.IsBaseOf (page)); // True
Uri relative = info.MakeRelativeUri (page);
Console.WriteLine (relative.IsAbsoluteUri); // False
Console.WriteLine (relative.ToString()); // page.html
相对Uri,例如本示例中的page.html,如果调用除IsAbsoluteUri和ToString()之外的任何属性或方法,将抛出异常。您可以直接实例化相对Uri,如下所示:
Uri u = new Uri ("page.html", UriKind.Relative);
警告
URI 中的尾部斜杠对于服务器如何处理请求(如果存在路径组件)具有重要意义。
在传统的 Web 服务器中,例如给定 URI http://www.albahari.com/nutshell/,您可以期望 HTTP Web 服务器查找站点 Web 文件夹中的nutshell子目录,并返回默认文档(通常是index.html)。
如果没有尾部斜杠,Web 服务器将会查找站点根目录中名为nutshell(没有扩展名)的文件——这通常不是您想要的。如果不存在这样的文件,大多数 Web 服务器会假定用户输入错误,并返回 301 永久重定向错误,建议客户端使用带有尾部斜杠的 URI 重试。默认情况下,.NET HTTP 客户端将以与 Web 浏览器相同的方式透明地响应 301,重新尝试建议的 URI。这意味着如果您在应包含尾部斜杠的地方省略了它,您的请求仍将起作用,但会增加不必要的额外往返。
Uri类还提供了静态帮助方法,例如EscapeUriString(),它将字符串转换为有效的 URL,将所有 ASCII 值大于 127 的字符转换为十六进制表示。CheckHostName()和CheckSchemeName()方法接受一个字符串,并检查其在给定属性上是否语法有效(尽管它们不试图确定主机或 URI 是否存在)。
HttpClient
HttpClient类提供了现代 API,用于 HTTP 客户端操作,替代了旧的WebClient和WebRequest/WebResponse类型(这些类型已被标记为过时)。
HttpClient是为应对基于 HTTP 的 Web API 和 REST 服务的增长而编写的,并且在处理比简单获取 Web 页面更复杂的协议时提供了良好的体验。特别是:
-
单个
HttpClient实例可以处理并发请求,并与自定义头部、cookie 和认证方案等功能良好配合。 -
HttpClient允许您编写和插入自定义消息处理程序。这使得在单元测试中进行模拟成为可能,并创建自定义管道(用于日志记录、压缩、加密等)。 -
HttpClient具有丰富且可扩展的类型系统,用于处理头部和内容。
注意
HttpClient不支持进度报告。要解决此问题,请参阅http://www.albahari.com/nutshell/code.aspx上的“使用 Progress.linq 的 HttpClient”或通过 LINQPad 的交互式样本库。
使用HttpClient的最简单方法是实例化它,然后调用其Get*方法之一,传入一个 URI:
string html = await new HttpClient().GetStringAsync ("http://linqpad.net");
(还有GetByteArrayAsync和GetStreamAsync。)HttpClient中所有的 I/O 绑定方法都是异步的。
与其WebRequest/WebResponse前身不同,为了获得最佳性能,您必须重用同一个HttpClient实例(否则诸如 DNS 解析可能会被不必要地重复,并且套接字会比必要时更长时间保持打开状态)。HttpClient允许并发操作,因此以下操作是合法的,并同时下载两个网页:
var client = new HttpClient();
var task1 = client.GetStringAsync ("http://www.linqpad.net");
var task2 = client.GetStringAsync ("http://www.albahari.com");
Console.WriteLine (await task1);
Console.WriteLine (await task2);
HttpClient 有一个 Timeout 属性和一个 BaseAddress 属性,该属性会将 URI 前缀添加到每个请求中。HttpClient 在某种程度上是一个薄壳:您可能希望在此处找到的大多数其他属性都在另一个名为 HttpClientHandler 的类中定义。要访问此类,您首先实例化它,然后将实例传递给 HttpClient 的构造函数:
var handler = new HttpClientHandler { UseProxy = false };
var client = new HttpClient (handler);
...
在此示例中,我们告诉处理程序禁用代理支持,有时可以通过避免自动代理检测的成本来提高性能。还有控制 cookie、自动重定向、身份验证等属性(我们将在下面的部分描述这些属性)。
GetAsync 和 响应消息
GetStringAsync、GetByteArrayAsync 和 GetStreamAsync 方法是调用更通用的 GetAsync 方法的便捷快捷方式,后者返回一个 响应消息:
var client = new HttpClient();
// The GetAsync method also accepts a CancellationToken.
HttpResponseMessage response = await client.GetAsync ("http://...");
response.EnsureSuccessStatusCode();
string html = await response.Content.ReadAsStringAsync();
HttpResponseMessage 公开了用于访问标头(请参见 “标头”)和 HTTP StatusCode 的属性。不成功的状态代码(如 404(未找到))不会引发异常,除非您显式调用 EnsureSuccessStatusCode。然而,通信或 DNS 错误确实会引发异常。
HttpContent 有一个 CopyToAsync 方法,用于将内容写入另一个流中,这在将输出写入文件时非常有用:
using (var fileStream = File.Create ("linqpad.html"))
await response.Content.CopyToAsync (fileStream);
GetAsync 是对应 HTTP 的四个动词之一(其余分别是 PostAsync、PutAsync 和 DeleteAsync)。我们稍后在 “上传表单数据” 中演示 PostAsync。
SendAsync 和 请求消息
GetAsync、PostAsync、PutAsync 和 DeleteAsync 都是调用 SendAsync 的快捷方式,这是单个低级方法,所有其他方法都依赖于此。要使用此方法,首先构造一个 HttpRequestMessage:
var client = new HttpClient();
var request = new HttpRequestMessage (HttpMethod.Get, "http://...");
HttpResponseMessage response = await client.SendAsync (request);
response.EnsureSuccessStatusCode();
...
实例化 HttpRequestMessage 对象意味着可以自定义请求的属性,如标头(请参见 “标头”)和内容本身,允许您上传数据。
上传数据和HttpContent
在实例化 HttpRequestMessage 对象之后,可以通过分配其 Content 属性来上传内容。该属性的类型是名为 HttpContent 的抽象类。.NET 包括以下用于不同内容类型的具体子类(您也可以编写自己的):
-
ByteArrayContent -
StringContent -
FormUrlEncodedContent(参见 “上传表单数据”) -
StreamContent
例如:
var client = new HttpClient (new HttpClientHandler { UseProxy = false });
var request = new HttpRequestMessage (
HttpMethod.Post, "http://www.albahari.com/EchoPost.aspx");
request.Content = new StringContent ("This is a test");
HttpResponseMessage response = await client.SendAsync (request);
response.EnsureSuccessStatusCode();
Console.WriteLine (await response.Content.ReadAsStringAsync());
HttpMessageHandler
我们先前说过,大多数用于自定义请求的属性实际上不是在 HttpClient 中定义的,而是在 HttpClientHandler 中定义的。后者实际上是抽象类 HttpMessageHandler 的子类,定义如下:
public abstract class HttpMessageHandler : IDisposable
{
protected internal abstract Task<HttpResponseMessage> SendAsync
(HttpRequestMessage request, CancellationToken cancellationToken);
public void Dispose();
protected virtual void Dispose (bool disposing);
}
SendAsync 方法是从 HttpClient 的 SendAsync 方法中调用的。
HttpMessageHandler 足够简单,易于子类化,并提供了对 HttpClient 的可扩展性入口。
单元测试和模拟
我们可以通过子类化HttpMessageHandler创建一个模拟处理程序来辅助单元测试:
class MockHandler : HttpMessageHandler
{
Func <HttpRequestMessage, HttpResponseMessage> _responseGenerator;
public MockHandler
(Func <HttpRequestMessage, HttpResponseMessage> responseGenerator)
{
_responseGenerator = responseGenerator;
}
protected override Task <HttpResponseMessage> SendAsync
(HttpRequestMessage request, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var response = _responseGenerator (request);
response.RequestMessage = request;
return Task.FromResult (response);
}
}
其构造函数接受一个函数,告诉模拟器如何从请求生成响应。这是最灵活的方法,因为同一个处理程序可以测试多个请求。
通过Task.FromResult,SendAsync是同步的。我们本可以通过使我们的响应生成器返回Task<HttpResponseMessage>来保持异步,但考虑到我们可以预期模拟函数的执行时间很短,这是毫无意义的。以下是如何使用我们的模拟处理器:
var mocker = new MockHandler (request =>
new HttpResponseMessage (HttpStatusCode.OK)
{
Content = new StringContent ("You asked for " + request.RequestUri)
});
var client = new HttpClient (mocker);
var response = await client.GetAsync ("http://www.linqpad.net");
string result = await response.Content.ReadAsStringAsync();
Assert.AreEqual ("You asked for *http://www.linqpad.net/*", result);
(Assert.AreEqual是你会在诸如 NUnit 之类的单元测试框架中找到的方法。)
使用 DelegatingHandler 链接处理程序
您可以通过子类化DelegatingHandler创建调用另一个处理程序的消息处理程序(从而形成处理程序链)。您可以使用此方法来实现自定义认证、压缩和加密协议。以下是一个简单日志处理程序的示例:
class LoggingHandler : DelegatingHandler
{
public LoggingHandler (HttpMessageHandler nextHandler)
{
InnerHandler = nextHandler;
}
protected async override Task <HttpResponseMessage> SendAsync
(HttpRequestMessage request, CancellationToken cancellationToken)
{
Console.WriteLine ("Requesting: " + request.RequestUri);
var response = await base.SendAsync (request, cancellationToken);
Console.WriteLine ("Got response: " + response.StatusCode);
return response;
}
}
请注意,我们在覆盖SendAsync时保持了异步性。在覆盖返回任务的方法时引入async修饰符是完全合法的,而且在这种情况下是可取的。
比直接写入Console更好的解决方案是让构造函数接受某种日志对象。更好的做法是接受一对Action<T>委托,告诉它如何记录请求和响应对象。
代理
代理服务器是一个中介,通过它可以路由 HTTP 请求。有时组织会设置代理服务器作为员工访问互联网的唯一途径,主要是因为它简化了安全性。代理有自己的地址,并且可以要求身份验证,以便只有局域网上的选定用户可以访问互联网。
要使用HttpClient与代理,首先创建一个HttpClientHandler并分配其Proxy属性,然后将其传递给HttpClient的构造函数:
WebProxy p = new WebProxy ("192.178.10.49", 808);
p.Credentials = new NetworkCredential ("*username*", "*password*", "*domain*");
var handler = new HttpClientHandler { Proxy = p };
var client = new HttpClient (handler);
...
HttpClientHandler还有一个UseProxy属性,您可以将其赋值为 false,而不是将Proxy属性置空以避免自动检测。
当构建NetworkCredential时,如果提供了一个域名,将使用基于 Windows 的认证协议。要使用当前认证的 Windows 用户,请将静态值CredentialCache.DefaultNetworkCredentials分配给代理的Credentials属性。
作为重复设置Proxy的替代方案,您可以如下设置全局默认值:
HttpClient.DefaultWebProxy = myWebProxy;
认证
您可以通过以下方式向HttpClient提供用户名和密码:
string username = "myuser";
string password = "mypassword";
var handler = new HttpClientHandler();
handler.Credentials = new NetworkCredential (username, password);
var client = new HttpClient (handler);
...
这适用于基于对话框的认证协议,如基本和摘要,并且可以通过AuthenticationManager类进行扩展。它还支持 Windows NTLM 和 Kerberos(如果在构建NetworkCredential对象时包括域名)。如果要使用当前认证的 Windows 用户,可以将Credentials属性留空,而是将UseDefaultCredentials设置为true。
当您提供凭据时,HttpClient会自动协商兼容的协议。在某些情况下,可能会有选择:例如,如果您检查来自 Microsoft Exchange 服务器 Web 邮件页面的初始响应,则可能包含以下标头:
HTTP/1.1 401 Unauthorized
Content-Length: 83
Content-Type: text/html
Server: Microsoft-IIS/6.0
WWW-Authenticate: Negotiate
WWW-Authenticate: NTLM
WWW-Authenticate: Basic realm="exchange.somedomain.com"
X-Powered-By: ASP.NET
Date: Sat, 05 Aug 2006 12:37:23 GMT
401 代码表示需要授权;“WWW-Authenticate”标头指示理解的身份验证协议。然而,如果您配置了HttpClientHandler正确的用户名和密码,此消息将对您隐藏,因为运行时会自动选择兼容的身份验证协议,然后重新提交带有额外标头的原始请求。以下是一个示例:
Authorization: Negotiate TlRMTVNTUAAABAAAt5II2gjACDArAAACAwACACgAAAAQ
ATmKAAAAD0lVDRdPUksHUq9VUA==
此机制提供透明性,但每个请求都会生成一个额外的往返。您可以通过将HttpClientHandler的PreAuthenticate属性设置为true,来避免对同一 URI 的后续请求中产生额外的往返。
CredentialCache
您可以使用CredentialCache对象强制指定特定的身份验证协议。凭据缓存包含一个或多个NetworkCredential对象,每个对象都与特定的协议和 URI 前缀相关联。例如,当登录到 Exchange Server 时,您可能希望避免基本协议,因为它会以明文传输密码:
CredentialCache cache = new CredentialCache();
Uri prefix = new Uri ("http://exchange.somedomain.com");
cache.Add (prefix, "Digest", new NetworkCredential ("joe", "passwd"));
cache.Add (prefix, "Negotiate", new NetworkCredential ("joe", "passwd"));
var handler = new HttpClientHandler();
handler.Credentials = cache;
...
身份验证协议被指定为一个字符串。有效的值包括:
Basic, Digest, NTLM, Kerberos, Negotiate
在这种特定情况下,它将选择Negotiate,因为服务器未指示其在身份验证标头中支持Digest。Negotiate是一种 Windows 协议,目前归结为 Kerberos 或 NTLM,取决于服务器的能力,但确保在部署未来安全标准时应用程序具备前向兼容性。
静态的CredentialCache.DefaultNetworkCredentials属性允许您将当前经过身份验证的 Windows 用户添加到凭据缓存,而无需指定密码:
cache.Add (prefix, "Negotiate", CredentialCache.DefaultNetworkCredentials);
通过标头进行身份验证
另一种身份验证方式是直接设置身份验证标头:
var client = new HttpClient();
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue ("Basic",
Convert.ToBase64String (Encoding.UTF8.GetBytes ("username:password")));
...
这种策略也适用于 OAuth 等自定义身份验证系统。
标头
HttpClient允许您向请求添加自定义的 HTTP 标头,并枚举响应中的标头。标头简单地是包含元数据的键/值对,例如消息内容类型或服务器软件。HttpClient暴露了具有标准 HTTP 标头属性的强类型集合。DefaultRequestHeaders属性适用于每个请求的标头:
var client = new HttpClient (handler);
client.DefaultRequestHeaders.UserAgent.Add (
new ProductInfoHeaderValue ("VisualStudio", "2022"));
client.DefaultRequestHeaders.Add ("CustomHeader", "VisualStudio/2022");
但是,HttpRequestMessage类上的Headers属性是特定于请求的标头。
查询字符串
查询字符串只是附加到 URI 的字符串,带有问号,用于将简单数据发送到服务器。您可以使用以下语法在查询字符串中指定多个键/值对:
?key1=value1&key2=value2&key3=value3...
这是一个带有查询字符串的 URI:
string requestURI = "http://www.google.com/search?q=HttpClient&hl=fr";
如果您的查询可能包含符号或空格,请使用Uri的EscapeDataString方法创建合法的 URI:
string search = Uri.EscapeDataString ("(HttpClient or HttpRequestMessage)");
string language = Uri.EscapeDataString ("fr");
string requestURI = "http://www.google.com/search?q=" + search +
"&hl=" + language;
此生成的 URI 为:
http://www.google.com/search?q=(HttpClient%20OR%20HttpRequestMessage)&hl=fr
(EscapeDataString类似于EscapeUriString,但还会对&和=等字符进行编码,否则会破坏查询字符串。)
上传表单数据
要上传 HTML 表单数据,请创建并填充FormUrlEncodedContent对象。然后,可以将其传递给PostAsync方法或分配给请求的Content属性:
string uri = "http://www.albahari.com/EchoPost.aspx";
var client = new HttpClient();
var dict = new Dictionary<string,string>
{
{ "Name", "Joe Albahari" },
{ "Company", "O'Reilly" }
};
var values = new FormUrlEncodedContent (dict);
var response = await client.PostAsync (uri, values);
response.EnsureSuccessStatusCode();
Console.WriteLine (await response.Content.ReadAsStringAsync());
Cookie
Cookie 是 HTTP 服务器在响应标头中发送给客户端的名称/值字符串对。Web 浏览器客户端通常会记住 cookie,并在每个后续请求(到同一地址)中向服务器重放它们,直到它们过期。Cookie 允许服务器知道它是否在与一分钟前或昨天的同一客户端交谈,而无需在 URI 中使用混乱的查询字符串。
默认情况下,HttpClient会忽略从服务器收到的任何 cookie。要接受 cookie,请创建一个CookieContainer对象,并将其分配给HttpClientHandler:
var cc = new CookieContainer();
var handler = new HttpClientHandler();
handler.CookieContainer = cc;
var client = new HttpClient (handler);
...
要在将来的请求中重放接收到的 cookie,只需再次使用相同的CookieContainer对象。或者,您可以从头开始使用新的CookieContainer,然后手动添加 cookie,如下所示:
Cookie c = new Cookie ("PREF",
"ID=6b10df1da493a9c4:TM=1179...",
"/",
".google.com");
freshCookieContainer.Add (c);
第三和第四个参数表示发起者的路径和域。客户端上的CookieContainer可以存储来自许多不同位置的 cookie;HttpClient仅发送路径和域与服务器匹配的 cookie。
编写 HTTP 服务器
注意
如果您需要编写一个 HTTP 服务器,从.NET 6 开始,使用 ASP.NET 最小 API 是一种替代的更高级方法。以下是入门所需的全部步骤:
var app = WebApplication.CreateBuilder().Build();
app.MapGet ("/", () => "Hello, world!");
app.Run();
您可以使用HttpListener类编写自己的.NET HTTP 服务器。以下是一个简单的服务器示例,监听端口 51111,等待单个客户端请求,然后返回一行回复:
using var server = new SimpleHttpServer();
// Make a client request:
Console.WriteLine (await new HttpClient().GetStringAsync
("http://localhost:51111/MyApp/Request.txt"));
class SimpleHttpServer : IDisposable
{
readonly HttpListener listener = new HttpListener();
public SimpleHttpServer() => ListenAsync();
async void ListenAsync()
{
listener.Prefixes.Add ("http://localhost:51111/MyApp/"); // Listen on
listener.Start(); // port 51111
// Await a client request:
HttpListenerContext context = await listener.GetContextAsync();
// Respond to the request:
string msg = "You asked for: " + context.Request.RawUrl;
context.Response.ContentLength64 = Encoding.UTF8.GetByteCount (msg);
context.Response.StatusCode = (int)HttpStatusCode.OK;
using (Stream s = context.Response.OutputStream)
using (StreamWriter writer = new StreamWriter (s))
await writer.WriteAsync (msg);
}
public void Dispose() => listener.Close();
}
OUTPUT: You asked for: /MyApp/Request.txt
在 Windows 上,HttpListener不会内部使用.NET Socket对象;它会调用 Windows HTTP 服务器 API。这允许计算机上的许多应用程序监听相同的 IP 地址和端口,只要每个应用程序注册不同的地址前缀即可。在我们的示例中,我们注册了前缀http://localhost/myapp,因此另一个应用程序可以在另一个前缀http://localhost/anotherapp上自由监听相同的 IP 和端口。这很重要,因为在企业防火墙上打开新端口可能会很复杂。
当您调用GetContext时,HttpListener会等待下一个客户端请求,并返回一个具有Request和Response属性的对象。每个属性从服务器的角度看类似于客户端请求或响应。例如,您可以读取和写入请求和响应对象的头和 cookie,就像在客户端端一样。
你可以根据预期的客户端受众选择如何完全支持 HTTP 协议的功能。至少,你应该在每个请求上设置内容长度和状态码。
这是一个非常简单的异步编写的网页服务器:
using System;
using System.IO;
using System.Net;
using System.Text;
using System.Threading.Tasks;
class WebServer
{
HttpListener _listener;
string _baseFolder; // Your web page folder.
public WebServer (string uriPrefix, string baseFolder)
{
_listener = new HttpListener();
_listener.Prefixes.Add (uriPrefix);
_baseFolder = baseFolder;
}
public async void Start()
{
_listener.Start();
while (true)
try
{
var context = await _listener.GetContextAsync();
Task.Run (() => ProcessRequestAsync (context));
}
catch (HttpListenerException) { break; } // Listener stopped.
catch (InvalidOperationException) { break; } // Listener stopped.
}
public void Stop() => _listener.Stop();
async void ProcessRequestAsync (HttpListenerContext context)
{
try
{
string filename = Path.GetFileName (context.Request.RawUrl);
string path = Path.Combine (_baseFolder, filename);
byte[] msg;
if (!File.Exists (path))
{
Console.WriteLine ("Resource not found: " + path);
context.Response.StatusCode = (int) HttpStatusCode.NotFound;
msg = Encoding.UTF8.GetBytes ("Sorry, that page does not exist");
}
else
{
context.Response.StatusCode = (int) HttpStatusCode.OK;
msg = File.ReadAllBytes (path);
}
context.Response.ContentLength64 = msg.Length;
using (Stream s = context.Response.OutputStream)
await s.WriteAsync (msg, 0, msg.Length);
}
catch (Exception ex) { Console.WriteLine ("Request error: " + ex); }
}
}
下面的代码启动了一切:
// Listen on port 51111, serving files in d:\webroot:
var server = new WebServer ("http://localhost:51111/", @"d:\webroot");
try
{
server.Start();
Console.WriteLine ("Server running... press Enter to stop");
Console.ReadLine();
}
finally { server.Stop(); }
您可以使用任何 Web 浏览器在客户端端测试此功能;在这种情况下,URI 将是http://localhost:51111/加上网页的名称。
警告
如果其他软件竞争使用相同的端口,HttpListener将无法启动(除非该软件也使用 Windows HTTP 服务器 API)。可能监听默认端口 80 的应用程序包括 Web 服务器或对等程序(如 Skype)。
我们使用异步函数使得该服务器具有可伸缩性和效率。然而,从用户界面(UI)线程启动会阻碍可伸缩性,因为每个请求在每个await后会回到 UI 线程。考虑到我们没有共享状态,产生这样的开销特别是没有意义的,在 UI 场景下我们应该离开 UI 线程,像这样:
Task.Run (Start);
或在调用GetContextAsync之后调用ConfigureAwait(false)。
注意,我们使用Task.Run调用ProcessRequestAsync,即使该方法已经是异步的。这允许调用者立即处理另一个请求,而不必首先等待方法的同步阶段(直到第一个await)。
使用 DNS
静态的Dns类封装了 DNS,它在原始 IP 地址(例如 66.135.192.87)与人类友好的域名(例如ebay.com)之间进行转换。
GetHostAddresses方法从域名转换为 IP 地址(或多个地址):
foreach (IPAddress a in Dns.GetHostAddresses ("albahari.com"))
Console.WriteLine (a.ToString()); // 205.210.42.167
GetHostEntry方法反向操作,从地址转换为域名:
IPHostEntry entry = Dns.GetHostEntry ("205.210.42.167");
Console.WriteLine (entry.HostName); // albahari.com
GetHostEntry还接受IPAddress对象,因此可以指定 IP 地址为字节数组:
IPAddress address = new IPAddress (new byte[] { 205, 210, 42, 167 });
IPHostEntry entry = Dns.GetHostEntry (address);
Console.WriteLine (entry.HostName); // albahari.com
当您使用类似WebRequest或TcpClient的类时,域名会自动解析为 IP 地址。但是,如果您计划在应用程序的生命周期内对同一地址进行多次网络请求,有时通过首先使用Dns显式将域名转换为 IP 地址,然后直接与该 IP 地址通信,可以提高性能。这避免了重复的往返来解析相同的域名,并且在处理传输层(通过TcpClient,UdpClient或Socket)时可能会有所好处。
DNS 类还提供了可等待的基于任务的异步方法:
foreach (IPAddress a in await Dns.GetHostAddressesAsync ("albahari.com"))
Console.WriteLine (a.ToString());
使用 SmtpClient 发送邮件
System.Net.Mail命名空间中的SmtpClient类允许您通过普遍存在的简单邮件传输协议(SMTP)发送邮件消息。要发送简单文本消息,请实例化SmtpClient,将其Host属性设置为您的 SMTP 服务器地址,然后调用Send:
SmtpClient client = new SmtpClient();
client.Host = "mail.myserver.com";
client.Send ("from@adomain.com", "to@adomain.com", "subject", "body");
构造MailMessage对象还提供了进一步的选项,包括添加附件的能力:
SmtpClient client = new SmtpClient();
client.Host = "mail.myisp.net";
MailMessage mm = new MailMessage();
mm.Sender = new MailAddress ("kay@domain.com", "Kay");
mm.From = new MailAddress ("kay@domain.com", "Kay");
mm.To.Add (new MailAddress ("bob@domain.com", "Bob"));
mm.CC.Add (new MailAddress ("dan@domain.com", "Dan"));
mm.Subject = "Hello!";
mm.Body = "Hi there. Here's the photo!";
mm.IsBodyHtml = false;
mm.Priority = MailPriority.High;
Attachment a = new Attachment ("photo.jpg",
System.Net.Mime.MediaTypeNames.Image.Jpeg);
mm.Attachments.Add (a);
client.Send (mm);
为了阻止垃圾邮件发送者,大多数互联网上的 SMTP 服务器只接受来自经过身份验证的连接,并要求通过 SSL 进行通信:
var client = new SmtpClient ("smtp.myisp.com", 587)
{
Credentials = new NetworkCredential ("me@myisp.com", "MySecurePass"),
EnableSsl = true
};
client.Send ("me@myisp.com", "someone@somewhere.com", "Subject", "Body");
Console.WriteLine ("Sent");
通过更改DeliveryMethod属性,您可以指示SmtpClient改用 IIS 发送邮件消息,或者仅将每条消息写入指定目录中的.eml文件。这在开发过程中可能会很有用:
SmtpClient client = new SmtpClient();
client.DeliveryMethod = SmtpDeliveryMethod.SpecifiedPickupDirectory;
client.PickupDirectoryLocation = @"c:\mail";
使用 TCP
TCP 和 UDP 构成了大多数互联网和局域网服务构建在其上的传输层协议。HTTP(版本 2 及以下)、FTP 和 SMTP 使用 TCP;DNS 和 HTTP 版本 3 使用 UDP。TCP 是面向连接的,并包含可靠性机制;UDP 是无连接的,具有较低的开销,并支持广播。BitTorrent使用 UDP,VoIP(语音 IP)也是如此。
传输层提供了比较高层更大的灵活性和潜在的性能提升,但要求您自己处理诸如认证和加密等任务。
在.NET 中使用 TCP 时,您可以选择更易于使用的TcpClient和TcpListener外观类,或者功能丰富的Socket类。(事实上,您可以混合使用,因为TcpClient通过Client属性公开了底层的Socket对象。)Socket类公开了更多的配置选项,并允许直接访问网络层(IP)和非互联网协议,如 Novell 的 SPX/IPX。
与其他协议一样,TCP 区分客户端和服务器:客户端发起请求,而服务器等待请求。这是同步 TCP 客户端请求的基本结构:
using (TcpClient client = new TcpClient())
{
client.Connect ("address", port);
using (NetworkStream n = client.GetStream())
{
// Read and write to the network stream...
}
}
TcpClient的Connect方法会阻塞,直到建立连接(ConnectAsync是异步等价方法)。然后,NetworkStream提供了一种方式进行双向通信,用于从服务器传输和接收字节数据。
简单的 TCP 服务器看起来像这样:
TcpListener listener = new TcpListener (*<ip address>*, port);
listener.Start();
while (*keepProcessingRequests*)
using (TcpClient c = listener.AcceptTcpClient())
using (NetworkStream n = c.GetStream())
{
// Read and write to the network stream...
}
listener.Stop();
TcpListener需要本地 IP 地址来监听(例如,一台计算机有两张网络卡,可能有两个地址)。您可以使用IPAddress.Any指示它监听所有(或唯一的)本地 IP 地址。AcceptTcpClient会阻塞,直到收到客户端的请求(也有一个异步版本),此时我们调用GetStream,就像在客户端一样。
在传输层工作时,您需要决定谁何时说话以及持续多长时间,有点像使用对讲机。如果双方同时说话或同时听取,通信就会中断!
让我们发明一个协议,在这个协议中客户端首先说“Hello”,然后服务器回复“Hello right back!” 这是代码:
using System;
using System.IO;
using System.Net;
using System.Net.Sockets;
using System.Threading;
new Thread (Server).Start(); // Run server method concurrently.
Thread.Sleep (500); // Give server time to start.
Client();
void Client()
{
using (TcpClient client = new TcpClient ("localhost", 51111))
using (NetworkStream n = client.GetStream())
{
BinaryWriter w = new BinaryWriter (n);
w.Write ("Hello");
w.Flush();
Console.WriteLine (new BinaryReader (n).ReadString());
}
}
void Server() // Handles a single client request, then exits.
{
TcpListener listener = new TcpListener (IPAddress.Any, 51111);
listener.Start();
using (TcpClient c = listener.AcceptTcpClient())
using (NetworkStream n = c.GetStream())
{
string msg = new BinaryReader (n).ReadString();
BinaryWriter w = new BinaryWriter (n);
w.Write (msg + " right back!");
w.Flush(); // Must call Flush because we're not
} // disposing the writer.
listener.Stop();
}
// OUTPUT: Hello right back!
在此示例中,我们使用 localhost 回环来在同一台机器上运行客户端和服务器。我们任意选择了一个未分配范围内的端口(大于 49152),并使用 BinaryWriter 和 BinaryReader 来编码文本消息。为了保持底层的 NetworkStream 在对话完成之前保持打开状态,我们避免关闭或释放读写器。
BinaryReader 和 BinaryWriter 可能看起来是读写字符串的奇怪选择。然而,它们比 StreamReader 和 StreamWriter 有一个重要优势:它们在字符串前面加上一个指示长度的整数,因此 BinaryReader 总是确切地知道要读取多少字节。如果调用 StreamReader.ReadToEnd,可能会无限期地阻塞,因为 NetworkStream 没有结束!只要连接保持打开状态,网络流就无法确定客户端是否会发送更多数据。
注意
StreamReader 实际上与 NetworkStream 完全不兼容,即使你只计划调用 ReadLine。这是因为 StreamReader 有一个预读缓冲区,可能会读取比当前可用的字节更多的字节,导致阻塞(或直到套接字超时)。其他流如 FileStream 与 StreamReader 不会有这种不兼容性,因为它们有一个明确的结束—当 Read 到达结束时,立即返回值 0。
TCP 并发处理
TcpClient 和 TcpListener 提供了基于任务的异步方法,用于可扩展的并发处理。使用它们只需将阻塞方法调用替换为它们的 *Async 版本,并等待返回的任务。
在以下示例中,我们编写了一个异步 TCP 服务器,接受长度为 5,000 字节的请求,颠倒字节顺序,然后将其发送回客户端:
async void RunServerAsync ()
{
var listener = new TcpListener (IPAddress.Any, 51111);
listener.Start ();
try
{
while (true)
Accept (await listener.AcceptTcpClientAsync ());
}
finally { listener.Stop(); }
}
async Task Accept (TcpClient client)
{
await Task.Yield ();
try
{
using (client)
using (NetworkStream n = client.GetStream ())
{
byte[] data = new byte [5000];
int bytesRead = 0; int chunkSize = 1;
while (bytesRead < data.Length && chunkSize > 0)
bytesRead += chunkSize =
await n.ReadAsync (data, bytesRead, data.Length - bytesRead);
Array.Reverse (data); // Reverse the byte sequence
await n.WriteAsync (data, 0, data.Length);
}
}
catch (Exception ex) { Console.WriteLine (ex.Message); }
}
这样的程序具有可伸缩性,因为它不会在请求的整个持续时间内阻塞线程。因此,如果有 1,000 个客户端同时通过慢速网络连接连接(例如,每个请求从开始到结束需要几秒钟),这个程序不会在此期间需要 1,000 个线程(不像同步解决方案)。相反,它只会在 await 表达式之前和之后短暂租用线程的时间。
使用 TCP 接收 POP3 邮件
.NET 在应用层上不提供 POP3 的支持,因此你需要在 TCP 层编写代码来接收来自 POP3 服务器的邮件。幸运的是,这是一个简单的协议;POP3 会话的过程如下:
| 客户端 | 邮件服务器 | 备注 |
|---|---|---|
| 客户端连接中... | +OK 你好。 |
欢迎消息 |
USER joe |
+OK 需要密码。 |
|
PASS 密码 |
+OK 登录成功。 |
| LIST | +OK 1 1876
2 5412
3 845
. | 列出服务器上每个消息的 ID 和文件大小 |
| RETR 1 | +OK 1876 字节 消息 #1 的内容...
. | 检索指定 ID 的消息 |
DELE 1 |
+OK 已删除。 |
从服务器上删除一封消息 |
|---|---|---|
QUIT |
+OK 再见。 |
每个命令和响应都以新行(CR + LF)终止,除了多行的 LIST 和 RETR 命令,它们以单独一行上的一个点终止。因为我们不能在 NetworkStream 中使用 StreamReader,所以我们可以先编写一个帮助方法以非缓冲的方式读取一行文本:
string ReadLine (Stream s)
{
List<byte> lineBuffer = new List<byte>();
while (true)
{
int b = s.ReadByte();
if (b == 10 || b < 0) break;
if (b != 13) lineBuffer.Add ((byte)b);
}
return Encoding.UTF8.GetString (lineBuffer.ToArray());
}
我们还需要一个帮助方法来发送命令。因为我们总是期望收到以 +OK 开头的响应,所以我们可以同时读取和验证响应:
void SendCommand (Stream stream, string line)
{
byte[] data = Encoding.UTF8.GetBytes (line + "\r\n");
stream.Write (data, 0, data.Length);
string response = ReadLine (stream);
if (!response.StartsWith ("+OK"))
throw new Exception ("POP Error: " + response);
}
使用这些编写的方法,检索邮件的工作变得简单。我们在端口 110 上(默认的 POP3 端口)建立一个 TCP 连接,然后开始与服务器通信。在这个例子中,我们将每封邮件写入一个随机命名的带有 .eml 扩展名的文件中,然后从服务器上删除该消息:
using (TcpClient client = new TcpClient ("*mail.isp.com*", 110))
using (NetworkStream n = client.GetStream())
{
ReadLine (n); // Read the welcome message.
SendCommand (n, "USER username");
SendCommand (n, "PASS password");
SendCommand (n, "LIST"); // Retrieve message IDs
List<int> messageIDs = new List<int>();
while (true)
{
string line = ReadLine (n); // e.g., "1 1876"
if (line == ".") break;
messageIDs.Add (int.Parse (line.Split (' ')[0] )); // Message ID
}
foreach (int id in messageIDs) // Retrieve each message.
{
SendCommand (n, "RETR " + id);
string randomFile = Guid.NewGuid().ToString() + ".eml";
using (StreamWriter writer = File.CreateText (randomFile))
while (true)
{
string line = ReadLine (n); // Read next line of message.
if (line == ".") break; // Single dot = end of message.
if (line == "..") line = "."; // "Escape out" double dot.
writer.WriteLine (line); // Write to output file.
}
SendCommand (n, "DELE " + id); // Delete message off server.
}
SendCommand (n, "QUIT");
}
注意
您可以在 NuGet 上找到开源的 POP3 库,提供支持协议方面的功能,如身份验证 TLS/SSL 连接,MIME 解析等。
第十七章:程序集
程序集是.NET 中的部署基本单元,也是所有类型的容器。程序集包含具有其中间语言(IL)代码的编译类型,运行时资源,并帮助版本控制和引用其他程序集的信息。程序集还定义了类型解析的边界。在.NET 中,一个程序集由一个扩展名为.dll的单个文件组成。
注意
当您在.NET 中构建可执行应用程序时,最终会得到两个文件:一个程序集(.dll)和一个适合目标平台的可执行启动器(.exe)。
这与.NET Framework 中的情况不同,后者生成一个可移植可执行文件(PE)程序集。PE 具有.exe扩展名,既可以作为程序集,又可以作为应用程序启动器。PE 可以同时针对 32 位和 64 位 Windows 版本。
本章大部分类型来自以下命名空间:
System.Reflection
System.Resources
System.Globalization
一个程序集的内容
一个程序集包含四种内容:
程序集清单
提供 CLR 的信息,例如程序集的名称、版本和其他它引用的程序集
应用程序清单
提供操作系统的信息,例如程序集应如何部署以及是否需要管理员权限
编译类型
程序集中定义的类型的编译 IL 代码和元数据
资源
程序集中嵌入的其他数据,例如图像和可本地化文本
这些中,只有程序集清单是强制的,尽管一个程序集几乎总是包含编译的类型(除非它是一个资源程序集。参见“资源和卫星程序集”)。
程序集清单
程序集清单有两个目的:
-
它描述了程序集给托管的主机环境。
-
它充当模块、类型和程序集中的资源的目录。
因此,程序集是自描述的。使用者可以发现程序集的所有数据、类型和函数,而无需额外的文件。
注意
程序集清单并不是你明确添加到程序集中的东西——它是编译过程中自动嵌入到程序集中的一部分。
下面是存储在清单中的功能重要数据的摘要:
-
程序集的简单名称
-
版本号(
AssemblyVersion) -
程序集的公钥和签名哈希(如果是强命名的)
-
引用程序集的列表,包括它们的版本和公钥
-
列出程序集中定义的类型
-
它所针对的文化,如果是卫星程序集(
AssemblyCulture)
清单还可以存储以下信息数据:
-
完整的标题和描述(
AssemblyTitle和AssemblyDescription) -
公司和版权信息(
AssemblyCompany和AssemblyCopyright) -
显示版本(
AssemblyInformationalVersion) -
用于自定义数据的其他属性
其中一些数据源于向编译器提供的参数,例如引用的程序集列表或用于签名程序集的公钥。其余部分来自程序集属性,括在括号中。
注意
您可以使用 .NET 工具 ildasm.exe 查看程序集清单的内容。在 第十八章 中,我们描述了如何使用反射以编程方式执行相同操作。
指定程序集属性
常用程序集属性可以在项目的属性页上指定,位于“包”选项卡中的 Visual Studio 中。该选项卡上的设置将添加到项目文件(.csproj)中。
要指定不受“包”选项卡支持的属性,或者不使用 .csproj 文件,可以在源代码中指定程序集属性(通常在名为 AssemblyInfo.cs 的文件中完成)。
专用属性文件仅包含 using 语句和程序集属性声明。例如,要向单元测试项目公开内部作用域类型,可以执行以下操作:
using System.Runtime.CompilerServices;
[assembly:InternalsVisibleTo("MyUnitTestProject")]
应用程序清单(Windows)
应用程序清单是一个 XML 文件,用于向操作系统传达有关程序集的信息。在构建过程中,应用程序清单作为 Win32 资源嵌入到启动可执行文件中。如果存在清单,则在 CLR 加载程序集之前会读取和处理清单,可能影响 Windows 启动应用程序的方式。
.NET 应用程序清单在 XML 命名空间 urn:schemas-microsoft-com:asm.v1 中具有名为 assembly 的根元素:
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" >
<!-- contents of manifest -->
</assembly>
以下清单指示操作系统请求管理员权限:
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" >
<trustInfo >
<security>
<requestedPrivileges>
<requestedExecutionLevel level="requireAdministrator" />
</requestedPrivileges>
</security>
</trustInfo>
</assembly>
(UWP 应用程序具有更为复杂的清单,在 Package.appxmanifest 文件中进行描述。这包括程序的功能声明,决定了操作系统授予的权限。编辑此文件的最简单方式是使用 Visual Studio,双击清单文件时会显示对话框。)
部署应用程序清单
在 Visual Studio 中,通过在解决方案资源管理器中右键单击项目,选择“添加”,然后选择“新建项目”,再选择“应用程序清单文件”,即可向 .NET 项目添加应用程序清单。构建项目后,清单将嵌入到输出的程序集中。
注意
.NET 工具 ildasm.exe 无法感知嵌入的应用程序清单的存在。但是,使用 Visual Studio 时,如果在解决方案资源管理器中双击程序集,可以看到是否存在嵌入的应用程序清单。
模块
程序集的内容实际上打包在一个中间容器中,称为 模块。模块对应于包含程序集内容的文件。引入此额外的容器层的原因是允许一个程序集跨多个文件。这是 .NET Framework 中存在但在 .NET 5+ 和 .NET Core 中缺失的功能。图 17-1 描述了这种关系。

图 17-1. 单文件组件
尽管 .NET 不支持多文件组件,但有时您需要注意模块带来的额外层次结构。主要场景是反射(见 “反射组件” 和 “生成组件和类型”)。
Assembly 类
System.Reflection 中的 Assembly 类是在运行时访问组件元数据的入口。有多种方法可以获取组件对象:最简单的方法是通过 Type 的 Assembly 属性:
Assembly a = typeof (Program).Assembly;
您还可以通过调用 Assembly 的静态方法之一来获取 Assembly 对象:
GetExecutingAssembly
返回定义当前执行函数的类型的组件
GetCallingAssembly
执行与 GetExecutingAssembly 相同的操作,但适用于调用当前执行函数的函数
GetEntryAssembly
返回定义应用程序原始入口方法的组件
获取 Assembly 对象后,您可以使用其属性和方法查询组件的元数据,并反映其类型。表 17-1 总结了这些功能。
表 17-1. 组件成员
| 函数 | 目的 | 参见章节... |
|---|---|---|
FullName, GetName |
返回完全限定名称或 AssemblyName 对象 |
“组件名称” |
CodeBase, Location |
组件文件的位置 | “加载、解析和隔离组件” |
Load, LoadFrom, LoadFile |
手动将组件加载到内存中 | “加载、解析和隔离组件” |
GetSatelliteAssembly |
定位给定文化的卫星组件 | “资源和卫星组件” |
GetType, GetTypes |
返回组件中定义的类型或所有类型 | “反射和激活类型” |
EntryPoint |
返回应用程序的入口方法,作为 MethodInfo |
“反射和调用成员” |
GetModule, GetModules, ManifestModule |
返回组件的所有模块或主模块 | “反射组件” |
GetCustomAttribute, GetCustomAttributes |
返回组件的属性 | “使用属性” |
强命名和组件签名
注意
在 .NET Framework 中,强命名组件之所以重要有两个原因:
-
它允许组件加载到“全局组件缓存”中。
-
它允许其他强命名组件引用该组件。
在 .NET 5+ 和 .NET Core 中,强命名不再那么重要,因为这些运行时没有全局组件缓存,也不会施加第二个限制。
强命名的程序集具有唯一的标识。它通过向清单添加两个元数据来工作:
-
属于程序集作者的唯一编号
-
程序集的签名哈希,证明唯一编号持有者生成了该程序集
这需要一个公共/私有密钥对。公共密钥提供唯一的标识号码,私有密钥用于签名。
注意
强名称签名与Authenticode签名不同。我们稍后在本章讨论 Authenticode。
公共密钥在保证程序集引用的唯一性方面非常有价值:强命名的程序集将公钥并入其标识中。
在.NET Framework 中,私钥保护您的程序集免受篡改,没有私钥,无法发布修改版而不破坏签名的程序集。实际上,在将程序集加载到.NET Framework 的全局程序集缓存时,这非常有用。在.NET 5+和.NET Core 中,签名几乎没有用,因为从未检查过。
向之前命名“弱”的程序集添加强名称会改变其标识。因此,如果您认为程序集将来可能需要强名称,最好从一开始就为其添加强名称。
如何为程序集提供强名称
要为程序集提供强名称,首先使用sn.exe实用程序生成公共/私有密钥对:
sn.exe -k MyKeyPair.snk
注意
Visual Studio 安装了一个名为Developer Command Prompt for VS的快捷方式,它启动的命令提示符包含开发工具,如sn.exe。
这会生成一个新的密钥对,并将其存储到名为MyKeyPair.snk的文件中。如果随后丢失此文件,您将永久失去使用相同标识重新编译程序集的能力。
您可以通过更新项目文件来使用此文件对程序集进行签名。从 Visual Studio,转到项目属性窗口,然后在Signing选项卡上,选中“Sign the assembly”复选框,并选择您的.snk文件。
相同的密钥对可以签署多个程序集 - 如果它们的简单名称不同,则它们仍将具有不同的标识。
程序集名称
程序集的“标识”由其清单中的四个元数据组成:
-
其简单名称
-
其版本号(如果不存在则为“0.0.0.0”)
-
其文化(如果不是卫星则为“neutral”)
-
其公钥令牌(如果未强命名则为“null”)
简单名称并非由任何属性命名,而是来自最初编译的文件名称(减去任何扩展名)。因此,System.Xml.dll 程序集的简单名称是“System.Xml”。重命名文件不会改变程序集的简单名称。
版本号来自AssemblyVersion属性。它是一个分为四部分的字符串,如下所示:
*major*.*minor*.*build*.*revision*
您可以如下指定版本号:
[assembly: AssemblyVersion ("2.5.6.7")]
文化来自AssemblyCulture属性,并适用于稍后在“资源和卫星程序集”部分描述的卫星程序集。
公钥标记来自于编译时提供的强名称,正如我们在前面的部分讨论的那样。
完全限定名称
完全限定的程序集名称是一个字符串,其中包括所有四个标识组件,格式如下:
*simple-name*, Version=*version*, Culture=*culture*, PublicKeyToken=*public-key*
例如,System.Private.CoreLib.dll的完全限定名称是System.Private.CoreLib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=7cec85d7bea7798e。
如果程序集没有AssemblyVersion属性,则版本显示为0.0.0.0。如果它未签名,则其公钥标记显示为null。
Assembly对象的FullName属性返回其完全限定名称。编译器在记录程序集引用到清单中时总是使用完全限定名称。
注意
完全限定的程序集名称不包括用于在磁盘上定位它的目录路径。定位位于另一个目录中的程序集是一个完全不同的问题,我们将在“加载、解析和隔离程序集”中讨论。
AssemblyName类
AssemblyName是一个类,每个完全限定程序集名称的组件都有一个类型化的属性。AssemblyName有两个目的:
-
它解析或构建一个完全限定的程序集名称。
-
它存储一些额外的数据以帮助解析(查找)程序集。
你可以通过以下任一方式获得一个AssemblyName对象:
-
实例化一个
AssemblyName,提供完全限定名。 -
在现有的
Assembly上调用GetName。 -
调用
AssemblyName.GetAssemblyName,提供磁盘上程序集文件的路径。
你也可以实例化一个没有任何参数的AssemblyName对象,然后设置每个属性以构建一个完全限定名。以这种方式构造的AssemblyName是可变的。
下面是它的基本属性和方法:
string FullName { get; } // Fully qualified name
string Name { get; set; } // Simple name
Version Version { get; set; } // Assembly version
CultureInfo CultureInfo { get; set; } // For satellite assemblies
string CodeBase { get; set; } // Location
byte[] GetPublicKey(); // 160 bytes
void SetPublicKey (byte[] key);
byte[] GetPublicKeyToken(); // 8-byte version
void SetPublicKeyToken (byte[] publicKeyToken);
Version本身是一个强类型表示,具有Major、Minor、Build和Revision数字的属性。GetPublicKey返回完整的加密公钥;GetPublicKeyToken返回用于建立身份的最后八个字节。
要使用AssemblyName获取程序集的简单名称:
Console.WriteLine (typeof (string).Assembly.GetName().Name);
// System.Private.CoreLib
要获取程序集版本:
string v = myAssembly.GetName().Version.ToString();
程序集信息和文件版本
另外两个与版本相关的程序集属性可用。与AssemblyVersion不同,以下两个属性不会影响程序集的标识,因此对编译时或运行时发生的情况没有影响:
AssemblyInformationalVersion
作为最终用户可见的版本。这在 Windows 文件属性对话框中显示为产品版本。可以在此处使用任何字符串,例如“5.1 Beta 2”。通常,应用程序中的所有程序集将被分配相同的信息版本号。
AssemblyFileVersion
这是用来指代该程序集的生成编号。在 Windows 文件属性对话框中称为文件版本。与AssemblyVersion一样,它必须包含一个由点分隔的最多四个数字组成的字符串。
Authenticode 签名
Authenticode是一个代码签名系统,其目的是证明发布者的身份。Authenticode 和强名称签名是独立的:您可以使用任一或两种系统对程序集进行签名。
尽管强名称签名可以证明程序集 A、B 和 C 来自同一方(假设私钥未泄漏),但它不能告诉您那个方是谁。要知道方是 Joe Albahari 或 Microsoft Corporation,您需要 Authenticode。
从互联网下载程序时,Authenticode 非常有用,因为它确保程序来自证书颁发机构指定的发布者,并且在传输过程中未经修改。它还可以防止第一次运行下载应用程序时的“未知发布者”警告。提交应用程序到 Windows 商店也需要 Authenticode 签名。
Authenticode 不仅适用于.NET 程序集,还适用于非托管可执行文件和二进制文件,如.msi部署文件。当然,Authenticode 并不能保证程序没有恶意软件——尽管它确实降低了这种可能性。一个人或实体愿意在可执行文件或库文件背后署名(由护照或公司文件支持)。
注意
CLR 不将 Authenticode 签名视为程序集身份的一部分。然而,它可以按需读取和验证 Authenticode 签名,您将很快看到。
使用 Authenticode 需要与证书颁发机构(CA)联系,提供个人身份或公司身份的证明(公司章程等)。CA 审核您的文件后,将颁发一个通常有效期为一到五年的 X.509 代码签名证书。这使您可以使用signtool实用程序签名程序集。您也可以使用makecert实用程序自行创建证书;但是,它只能在显式安装了该证书的计算机上识别。
(非自签名)证书可以在任何计算机上工作的事实依赖于公钥基础设施。基本上,您的证书由另一个属于 CA 的证书签名。CA 是受信任的,因为所有 CA 都加载到操作系统中。(要查看它们,请转到 Windows 控制面板,然后在搜索框中键入**certificate**。在“管理计算机证书”中,打开“受信任的根证书颁发机构”节点,然后单击证书。这将启动证书管理器。)如果泄露了发布者的证书,CA 可以吊销其证书,因此验证 Authenticode 签名需要定期向 CA 请求最新的证书吊销列表。
因为 Authenticode 使用加密签名,如果有人随后篡改文件,Authenticode 签名将无效。我们将在第二十章讨论加密、哈希和签名。
如何使用 Authenticode 签名
获取和安装证书
第一步是从 CA 获取代码签名证书(请参阅接下来的侧边栏)。然后,您可以将证书作为受密码保护的文件处理,或者将证书加载到计算机的证书存储中。后者的好处是,您可以在不需要指定密码的情况下进行签名。这是有利的,因为它可以防止密码出现在自动构建脚本或批处理文件中。
要将证书加载到计算机的证书存储中,请如前所述打开证书管理器。打开个人文件夹,右键单击其证书文件夹,然后选择所有任务/导入。导入向导将指导您完成整个过程。导入完成后,单击证书上的“查看”按钮,转到“详细信息”选项卡,并复制证书的指纹。这是 SHA-256 哈希值,您随后需要用它来识别签名时使用的证书。
注意
如果您还希望对程序集进行强名称签名,必须在进行 Authenticode 签名之前完成。这是因为 CLR 了解 Authenticode 签名,但反之不然。因此,如果在 Authenticode 签名后再对程序集进行强名称签名,后者将视 CLR 的强名称添加为未经授权的修改,并认为程序集已被篡改。
使用 signtool.exe 进行签名
您可以使用随 Visual Studio 提供的signtool工具对程序进行 Authenticode 签名(在Program Files下的Microsoft SDKs\ClickOnce\SignTool文件夹中查找)。以下是使用计算机的My Store中名为“Joseph Albahari”的证书,并使用安全的 SHA256 哈希算法对名为LINQPad.exe的文件进行签名的示例:
signtool sign /n "Joseph Albahari" /fd sha256 LINQPad.exe
您还可以使用/d和/du指定描述和产品 URL:
... /d LINQPad /du *http://www.linqpad.net*
大多数情况下,您还需要指定一个时间戳服务器。
时间戳
在您的证书过期后,您将无法再签名程序。但是,如果在其过期之前使用/tr开关指定了时间戳服务器来签名程序,那么您之前签名的程序仍将有效。CA 将为此提供一个 URI:以下是为 Comodo(或 K Software)提供的:
... /tr *http://timestamp.comodoca.com/authenticode* /td SHA256
验证程序是否已签名
在 Windows 资源管理器中查看文件的 Authenticode 签名最简单的方法是查看文件属性(查看数字签名选项卡)。signtool工具也提供了此选项。
资源和卫星程序集
应用程序通常不仅包含可执行代码,还包括文本、图像或 XML 文件等内容。这些内容可以通过资源在程序集中表示。资源有两个重叠的用途场景:
-
包含不能放入源代码的数据,例如图片
-
在多语言应用程序中存储可能需要翻译的数据
程序集资源最终是一个带有名称的字节流。你可以将程序集看作是包含以字符串为键的字节数组字典。如果你反汇编包含名为 banner.jpg 和名为 data.xml 的资源的程序集,可以在 ildasm 中看到这一点:
.mresource public banner.jpg
{
// Offset: 0x00000F58 Length: 0x000004F6
}
.mresource public data.xml
{
// Offset: 0x00001458 Length: 0x0000027E
}
在这种情况下,banner.jpg 和 data.xml 直接包含在程序集中,每个作为其自身的嵌入资源。这是最简单的工作方式。
.NET 还允许你通过中间的 .resources 容器添加内容。这些设计用于保存可能需要翻译成不同语言的内容。本地化的 .resources 可以打包为个别的卫星程序集,根据用户操作系统语言在运行时自动选择。
图 17-2 说明了一个包含两个直接嵌入资源以及一个名为 welcome.resources 的 .resources 容器的程序集,我们已经为它创建了两个本地化卫星。

图 17-2. 资源
直接嵌入资源
注意
在 Windows Store 应用中不支持将资源嵌入到程序集中。相反,将任何额外的文件添加到你的部署包中,并通过从应用的 StorageFolder (Package.Current.InstalledLocation) 读取它们来访问。
直接使用 Visual Studio 嵌入资源:
-
将文件添加到你的项目中。
-
将其构建操作设置为嵌入资源。
Visual Studio 总是使用项目的默认命名空间前缀资源名称,加上包含文件的任何子文件夹的名称。所以,如果你的项目默认命名空间是 Westwind.Reports,并且你的文件在名为 pictures 的文件夹中叫做 banner.jpg,资源名称将是 Westwind.Reports.pictures.banner.jpg。
注意
资源名称区分大小写。这使得在包含资源的 Visual Studio 项目子文件夹名称实际上是大小写敏感的。
要检索资源,可以在包含资源的程序集上调用 GetManifestResourceStream。这会返回一个流,你可以像处理其他流一样读取它:
Assembly a = Assembly.GetEntryAssembly();
using (Stream s = a.GetManifestResourceStream ("TestProject.data.xml"))
using (XmlReader r = XmlReader.Create (s))
...
System.Drawing.Image image;
using (Stream s = a.GetManifestResourceStream ("TestProject.banner.jpg"))
image = System.Drawing.Image.FromStream (s);
返回的流是可寻址的,所以你也可以这样做:
byte[] data;
using (Stream s = a.GetManifestResourceStream ("TestProject.banner.jpg"))
data = new BinaryReader (s).ReadBytes ((int) s.Length);
如果你使用 Visual Studio 嵌入资源,必须记得包含基于命名空间的前缀。为了帮助避免错误,你可以在单独的参数中指定前缀,使用类型。类型的命名空间被用作前缀:
using (Stream s = a.GetManifestResourceStream (typeof (X), "data.xml"))
X 可以是带有你资源所需命名空间的任何类型(通常是同一项目文件夹中的类型)。
注意
在 Windows Presentation Foundation(WPF)应用程序中,将项目项的构建操作设置为资源与设置其构建操作设置为嵌入资源不同。前者实际上将项目添加到名为 Application 类访问,使用 URI 作为键。
为了增加混淆,WPF 进一步重载了术语“资源”。静态资源 和 动态资源 都与程序集资源无关!
GetManifestResourceNames 返回程序集中所有资源的名称。
.resources 文件
.resources 文件是包含潜在本地化内容的容器。 .resources 文件最终作为嵌入式资源嵌入到程序集中,就像任何其他类型的文件一样。区别在于,您必须执行以下操作:
-
将内容打包到 .resources 文件中开始
-
通过
ResourceManager或 pack URI 访问其内容,而不是通过GetManifestResourceStream获取。
.resources 文件以二进制结构化,因此不适合人工编辑;因此,您必须依赖 .NET 和 Visual Studio 提供的工具来处理它们。对于字符串或简单数据类型的标准方法是使用 .resx 格式,该格式可以通过 Visual Studio 或 resgen 工具转换为 .resources 文件。 .resx 格式也适用于用于 Windows Forms 或 ASP.NET 应用程序的图像。
在 WPF 应用程序中,无论是否需要本地化,都必须使用 Visual Studio 的“资源”构建操作来处理需要通过 URI 引用的图像或类似内容。
我们在以下各节中描述如何执行每个操作。
.resx 文件
.resx 文件是用于生成 .resources 文件的设计时格式。 .resx 文件使用 XML,并且结构化为以下名称/值对:
<root>
<data name="Greeting">
<value>hello</value>
</data>
<data name="DefaultFontSize" type="System.Int32, mscorlib">
<value>10</value>
</data>
</root>
要在 Visual Studio 中创建 .resx 文件,请添加类型为资源文件的项目项。其余工作将自动完成:
-
创建了正确的标头。
-
提供了一个设计器,用于添加字符串、图像、文件和其他类型的数据。
-
.resx 文件在编译时自动转换为 .resources 格式并嵌入到程序集中。
-
编写一个类来帮助您稍后访问数据。
注意
资源设计器将图像添加为类型化的 Image 对象(System.Drawing.dll),而不是字节数组,因此不适用于 WPF 应用程序。
读取 .resources 文件
注意
如果在 Visual Studio 中创建 .resx 文件,则将自动生成同名类,其中包含检索其各个项的属性。
ResourceManager 类读取嵌入在程序集中的 .resources 文件:
ResourceManager r = new ResourceManager ("welcome",
Assembly.GetExecutingAssembly());
(如果资源是在 Visual Studio 中编译的,则第一个参数必须带命名空间前缀。)
然后,通过调用 GetString 或 GetObject(进行强制转换)访问其中的内容。
string greeting = r.GetString ("Greeting");
int fontSize = (int) r.GetObject ("DefaultFontSize");
Image image = (Image) r.GetObject ("flag.png");
要枚举 .resources 文件的内容:
ResourceManager r = new ResourceManager (...);
ResourceSet set = r.GetResourceSet (CultureInfo.CurrentUICulture,
true, true);
foreach (System.Collections.DictionaryEntry entry in set)
Console.WriteLine (entry.Key);
在 Visual Studio 中创建 pack URI 资源
在 WPF 应用程序中,XAML 文件需要能够通过 URI 访问资源。例如:
<Button>
<Image Height="50" Source="flag.png"/>
</Button>
或者,如果资源在另一个程序集中:
<Button>
<Image Height="50" Source="UtilsAssembly;Component/flag.png"/>
</Button>
(Component 是一个字面关键字。)
要创建能以这种方式加载的资源,你不能使用.resx文件。相反,你必须将文件添加到项目中,并将它们的构建操作设置为 Resource(而不是 Embedded Resource)。然后,Visual Studio 会将它们编译成一个名为
要以编程方式加载 URI 键入的资源,请调用Application.GetResourceStream:
Uri u = new Uri ("flag.png", UriKind.Relative);
using (Stream s = Application.GetResourceStream (u).Stream)
注意我们使用了一个相对 URI。你也可以使用完全相同格式的绝对 URI(三个逗号不是打字错误):
Uri u = new Uri ("pack://application:,,,/flag.png");
如果你更愿意指定一个Assembly对象,你可以使用ResourceManager来检索内容:
Assembly a = Assembly.GetExecutingAssembly();
ResourceManager r = new ResourceManager (a.GetName().Name + ".g", a);
using (Stream s = r.GetStream ("flag.png"))
...
ResourceManager还允许你枚举给定程序集内.g.resources容器的内容。
卫星程序集
嵌入在.resources中的数据是可本地化的。
当你的应用程序在用于显示不同语言的 Windows 版本上运行时,资源本地化就显得尤为重要。为了保持一致性,你的应用程序也应该使用相同的语言。
典型的设置如下:
-
主程序集包含默认或回退语言的.resources。
-
分开的卫星程序集包含翻译成不同语言的本地化.resources。
当你的应用程序运行时,.NET 会检查当前操作系统的语言(来自CultureInfo.CurrentUICulture)。每当你使用ResourceManager请求资源时,运行时会查找本地化的卫星程序集。如果有可用的卫星程序集,并且它包含你请求的资源键,则会用它替代主程序集的版本。
这意味着你可以通过添加新的卫星程序集来简单增强语言支持,而无需更改主程序集。
注意
一个卫星程序集不能包含可执行代码,只能包含资源。
卫星程序集部署在程序集文件夹的子目录中,如下所示:
programBaseFolder\MyProgram.exe
\MyLibrary.exe
\*XX*\MyProgram.resources.dll
\*XX*\MyLibrary.resources.dll
*XX*指的是两个字母的语言代码(例如德语为“de”)或语言和区域代码(例如英语在英国的代码为“en-GB”)。这种命名系统允许 CLR 自动找到并加载正确的卫星程序集。
构建卫星程序集
回顾我们之前的.resx示例,其中包括以下内容:
<root>
...
<data name="Greeting"
<value>hello</value>
</data>
</root>
然后我们在运行时检索问候语如下:
ResourceManager r = new ResourceManager ("welcome",
Assembly.GetExecutingAssembly());
Console.Write (r.GetString ("Greeting"));
假设我们希望在德语版 Windows 上运行时写入“hallo”而不是“hello”。第一步是添加另一个名为welcome.de.resx的.resx文件,将hello替换为hallo:
<root>
<data name="Greeting">
<value>hallo<value>
</data>
</root>
在 Visual Studio 中,这是你需要做的一切——重新构建时,一个名为MyApp.resources.dll的卫星程序集会自动创建在名为de的子目录中。
测试卫星程序集
要模拟在具有不同语言的操作系统上运行,你必须使用Thread类来更改CurrentUICulture:
System.Threading.Thread.CurrentThread.CurrentUICulture
= new System.Globalization.CultureInfo ("de");
CultureInfo.CurrentUICulture是相同属性的只读版本。
注意
一个有用的测试策略是将ℓѻ¢αℓïʐɘ转换为仍然可以读作英语但不使用标准罗马 Unicode 字符的单词。
Visual Studio 设计器支持
Visual Studio 中的设计器为本地化组件和视觉元素提供了扩展支持。WPF 设计器有自己的本地化工作流程;其他基于组件的设计器使用设计时属性,使组件或 Windows Forms 控件看起来具有Language属性。要定制为另一种语言,只需更改Language属性,然后开始修改组件。所有被标记为Localizable的控件属性都将保存到该语言的.resx文件中。您可以随时通过更改Language属性来在不同语言之间切换。
文化和子文化
文化被分为文化和子文化。文化代表特定的语言;子文化代表该语言的区域变体。.NET 运行时遵循RFC1766标准,使用两字母代码表示文化和子文化。这里是英语和德语文化的代码:
En
de
这里是澳大利亚英语和奥地利德语子文化的代码:
en-AU
de-AT
在.NET 中,文化使用System.Globalization.CultureInfo类表示。您可以检查应用程序的当前文化,如下所示:
Console.WriteLine (System.Threading.Thread.CurrentThread.CurrentCulture);
Console.WriteLine (System.Threading.Thread.CurrentThread.CurrentUICulture);
在配置为澳大利亚本地化的计算机上运行这个示例,可以展示两者之间的差异:
en-AU
en-US
CurrentCulture反映了 Windows 控制面板的区域设置,而CurrentUICulture反映了操作系统的语言。
区域设置包括时区、货币和日期格式等内容。CurrentCulture决定了诸如DateTime.Parse等函数的默认行为。区域设置可以定制到不再符合任何特定文化的程度。
CurrentUICulture决定计算机与用户通信的语言。澳大利亚在这方面不需要单独的英语版本,因此只使用美国英语。如果我在奥地利工作了几个月,我会去控制面板将我的CurrentCulture更改为奥地利德语。但是,考虑到我不会说德语,我的CurrentUICulture仍然是美国英语。
ResourceManager默认使用当前线程的CurrentUICulture属性来确定加载正确卫星程序集。在加载资源时,ResourceManager使用回退机制。如果定义了子文化程序集,则使用该程序集;否则,回退到通用文化。如果通用文化不存在,则回退到主程序集中的默认文化。
加载、解析和隔离程序集
从已知位置加载程序集是一个相对简单的过程。我们称之为程序集加载。
然而,更常见的情况是,您(或 CLR)需要加载一个程序集,只知道其完整(或简单)名称。这称为程序集解析。程序集解析与加载不同之处在于必须首先定位程序集。
程序集解析在两种情况下触发:
-
由 CLR 在需要解析依赖项时
-
明确地说,当您调用诸如
Assembly.Load(AssemblyName)的方法时
为了说明第一个场景,考虑一个由主程序集和一组静态引用的库程序集(依赖项)组成的应用程序,如本例所示:
AdventureGame.dll // Main assembly
Terrain.dll // Referenced assembly
UIEngine.dll // Referenced assembly
“静态引用”是指AdventureGame.dll编译时引用了Terrain.dll和UIEngine.dll。编译器本身不需要执行程序集解析,因为它被告知(明确或通过 MSBuild)在哪里找到Terrain.dll和UIEngine.dll。在编译过程中,它将 Terrain 和 UIEngine 程序集的完整名称写入AdventureGame.dll的元数据中,但不包含有关如何找到它们的信息。因此,在运行时,必须解析Terrain 和 UIEngine 程序集。
程序集加载和解析由程序集加载上下文(ALC)处理;具体来说,是System.Runtime.Loader中AssemblyLoadContext类的一个实例。因为AdventureGame.dll是应用程序的主程序集,CLR 使用默认 ALC(AssemblyLoadContext.Default)来解析其依赖项。默认 ALC 首先通过查找和检查名为AdventureGame.deps.json的文件(描述了依赖项的位置),或者如果不存在,则在应用程序基础文件夹中查找,那里将找到Terrain.dll和UIEngine.dll。(默认 ALC 还解析.NET 运行时程序集。)
作为开发人员,您可以在程序执行过程中动态加载额外的程序集。例如,您可能希望将可选功能打包在仅在购买这些功能时部署的程序集中。在这种情况下,您可以通过调用Assembly.Load(AssemblyName)来加载额外的程序集(如果存在)。
一个更复杂的例子是实现一个插件系统,用户可以提供第三方程序集,您的应用程序在运行时检测并加载以扩展应用程序的功能。复杂性在于每个插件程序集可能有自己的依赖项,这些依赖项也必须被解析。
通过对AssemblyLoadContext进行子类化并重写其程序集解析方法(Load),您可以控制插件如何找到其依赖项。例如,您可能决定每个插件都应该位于自己的文件夹中,而其依赖项也应该位于该文件夹中。
ALC 还有另一个目的:通过为每个(插件+依赖项)实例化单独的AssemblyLoadContext,您可以保持每个 ALC 的隔离性,确保它们的依赖项并行加载且不会相互干扰(也不会干扰宿主应用程序)。例如,每个 ALC 可以拥有自己的 JSON.NET 版本。因此,除了加载和解析之外,ALC 还提供了一种隔离的机制。在某些条件下,ALC 甚至可以卸载,释放其内存。
在本节中,我们详细阐述了这些原则,并描述了以下内容:
-
ALC 如何处理加载和解析
-
默认 ALC 的角色
-
Assembly.Load和上下文 ALC -
如何使用
AssemblyDependencyResolver -
如何加载和解析非托管库
-
卸载 ALC
-
旧版程序集加载方法
然后,我们将理论付诸实践,并展示如何使用 ALC 隔离编写插件系统。
注意
AssemblyLoadContext类是.NET 5+和.NET Core 中的新功能。在.NET Framework 中,ALC 存在但受限且隐藏:与其间接地通过Assembly类的LoadFile(string)、LoadFrom(string)和Load(byte[])静态方法交互是唯一的方式。与 ALC API 相比,这些方法不够灵活,并且在处理依赖项时可能会出现意外情况。因此,在.NET 5+和.NET Core 中最好明确使用AssemblyLoadContextAPI。
程序集加载上下文
正如我们刚刚讨论的那样,AssemblyLoadContext类负责加载和解析程序集,并提供隔离的机制。
每个.NET Assembly对象都属于一个唯一的AssemblyLoadContext。您可以按如下方式获取一个程序集的 ALC:
Assembly assem = Assembly.GetExecutingAssembly();
AssemblyLoadContext context = AssemblyLoadContext.GetLoadContext (assem);
Console.WriteLine (context.Name);
反过来,您可以将 ALC 视为“包含”或“拥有”程序集的容器,您可以通过其Assemblies属性获取它们。继续前面的例子:
foreach (Assembly a in context.Assemblies)
Console.WriteLine (a.FullName);
AssemblyLoadContext类还有一个静态的All属性,用于枚举所有的 ALC。
您可以通过实例化AssemblyLoadContext并提供一个名称(在调试时这个名称很有帮助)来创建一个新的 ALC,尽管更常见的做法是首先子类化AssemblyLoadContext,以便您可以实现解析依赖项的逻辑;换句话说,根据其名称加载一个程序集。
加载程序集
AssemblyLoadContext提供了以下方法来显式地将程序集加载到其上下文中:
public Assembly LoadFromAssemblyPath (string assemblyPath);
public Assembly LoadFromStream (Stream assembly, Stream assemblySymbols);
第一种方法从文件路径加载程序集,而第二种方法从Stream加载它(可以直接来自内存)。第二个参数是可选的,对应于项目调试(.pdb)文件的内容,这允许堆栈跟踪在代码执行时包含源代码信息(在异常报告中非常有用)。
使用这两种方法都不会进行解析。
以下代码将程序集c:\temp\foo.dll加载到其自己的 ALC 中:
var alc = new AssemblyLoadContext ("Test");
Assembly assem = alc.LoadFromAssemblyPath (@"c:\temp\foo.dll");
如果程序集有效,则加载将始终成功,但受到一个重要规则的限制:程序集的 简单名称 必须在其 ALC 中唯一。这意味着不能将同名程序集的多个版本加载到单个 ALC 中;要实现此目的,必须创建额外的 ALC。
var alc2 = new AssemblyLoadContext ("Test 2");
Assembly assem2 = alc2.LoadFromAssemblyPath (@"c:\temp\foo.dll");
请注意,即使程序集在其他方面相同,但源自不同 Assembly 对象的类型也是不兼容的。在我们的示例中,assem 中的类型与 assem2 中的类型是不兼容的。
在加载程序集后,除非卸载其 ALC(请参阅 “卸载 ALCs”),否则无法卸载程序集。CLR 在加载期间保持文件的锁定状态。
注意
您可以通过通过字节数组加载程序集来避免锁定文件:
bytes[] bytes = File.ReadAllBytes (@"c:\temp\foo.dll");
var ms = new MemoryStream (bytes);
var assem = alc.LoadFromStream (ms);
这有两个缺点:
-
程序集的
Location属性最终将为空白。有时,知道程序集加载自何处(某些 API 依赖于此填充)是很有用的。 -
私有内存消耗必须立即增加以适应程序集的完整大小。如果您从文件名加载,则 CLR 使用内存映射文件,这使得延迟加载和进程共享成为可能。此外,如果内存不足,操作系统可以释放其内存并根据需要重新加载,而无需写入分页文件。
LoadFromAssemblyName
AssemblyLoadContext 还提供了以下方法,通过 名称 加载程序集:
public Assembly LoadFromAssemblyName (AssemblyName assemblyName);
与刚讨论的两种方法不同,您不需要传递任何信息来指示程序集的位置;相反,您正在指示 ALC 解析 程序集。
解析程序集
前面的方法触发了 程序集解析。CLR 在加载依赖项时也会触发程序集解析。例如,假设程序集 A 静态引用程序集 B。为了解析引用 B,CLR 会在加载程序集 A 的 ALC 程序集 上触发程序集解析。
注意
CLR 通过触发程序集解析来解析依赖关系——触发程序集的是默认的还是自定义的 ALC。不同之处在于,默认 ALC 的解析规则是硬编码的,而自定义 ALC 则需要您自己编写规则。
然后发生了以下情况:
-
CLR 首先检查在该 ALC 中是否已经进行了相同的解析(使用匹配的完整程序集名称);如果是,则返回它之前返回的
Assembly。 -
否则,CLR 调用 ALC 的(虚拟受保护的)
Load方法,执行定位和加载程序集的工作。默认 ALC 的Load方法适用我们在 “默认 ALC” 中描述的规则。使用自定义 ALC,您完全可以决定如何定位程序集。例如,您可以在某个文件夹中查找,然后在找到程序集时调用LoadFromAssemblyPath。从同一或另一个 ALC 返回已加载的程序集也是完全合法的(我们在 “编写插件系统” 中演示了这一点)。 -
如果第二步返回 null,CLR 将在默认的 ALC 上调用
Load方法(这作为解析.NET 运行时和常见应用程序程序集的有用“回退”)。 -
如果第三步返回 null,CLR 将在两个 ALC 上依次触发
Resolving事件:首先是默认的 ALC,然后是原始的 ALC。 -
(与.NET Framework 兼容性):如果程序集仍未解析,
AppDomain.CurrentDomain.AssemblyResolve事件将触发。注意
完成此过程后,CLR 进行“健全性检查”,以确保加载的程序集名称与请求的兼容。简单名称必须匹配;如果指定了,则公钥令牌必须匹配。版本不需要匹配 - 可以比请求的版本高或低。
由此可见,在自定义 ALC 中实现程序集解析有两种方法:
-
重写 ALC 的
Load方法。这使得您的 ALC 在发生的事情上“第一说”,通常是可取的(当需要隔离时是必要的)。 -
处理 ALC 的
Resolving事件。这仅在默认 ALC 未能解析程序集后才触发。
注意
如果将多个事件处理程序附加到 Resolving 事件,第一个返回非 null 值的事件处理程序胜出。
举例说明,假设我们要加载一个主应用程序在编译时不知道的程序集 foo.dll,位于 c:\temp(与我们的应用程序文件夹不同)。我们还假设 foo.dll 私有依赖于 bar.dll。我们希望确保当加载 c:\temp\foo.dll 并执行其代码时,c:\temp\bar.dll 能够正确解析。我们还希望确保 foo 及其私有依赖项 bar 不会影响主应用程序。
让我们从编写自定义 ALC 重写 Load 开始:
using System.IO;
using System.Runtime.Loader;
class FolderBasedALC : AssemblyLoadContext
{
readonly string _folder;
public FolderBasedALC (string folder) => _folder = folder;
protected override Assembly Load (AssemblyName assemblyName)
{
// Attempt to find the assembly:
string targetPath = Path.Combine (_folder, assemblyName.Name + ".dll");
if (File.Exists (targetPath))
return LoadFromAssemblyPath (targetPath); // Load the assembly
return null; // We can’t find it: it could be a .NET runtime assembly
}
}
注意在 Load 方法中,如果程序集文件不存在,则返回 null 是重要的检查。这是因为 foo.dll 也依赖于.NET BCL 程序集;因此,当像 System.Runtime 这样的程序集调用 Load 方法时,我们返回 null,允许 CLR 回退到默认的 ALC,从而正确解析这些程序集。
注意
请注意,我们没有尝试将 .NET 运行时 BCL 程序集加载到我们自己的 ALC 中。这些系统程序集并不适用于在默认 ALC 之外运行,尝试加载它们到您自己的 ALC 可能导致不正确的行为、性能下降和意外的类型不兼容。
下面是如何使用我们的自定义 ALC 加载 c:\temp 中的 foo.dll 程序集:
var alc = new FolderBasedALC (@"c:\temp");
Assembly foo = alc.LoadFromAssemblyPath (@"c:\temp\foo.dll");
...
当我们随后开始调用 foo 程序集中的代码时,CLR 最终会需要解析对 bar.dll 的依赖关系。这是自定义 ALC 的 Load 方法将会触发并成功在 c:\temp 中定位 bar.dll 程序集的时机。
在这种情况下,我们的 Load 方法也能够解析 foo.dll,所以我们可以简化我们的代码为:
var alc = new FolderBasedALC (@"c:\temp");
Assembly foo = alc.LoadFromAssemblyName (new AssemblyName ("foo"));
...
现在,让我们考虑一种替代方案:不是子类化 AssemblyLoadContext 并重写 Load,而是实例化一个普通的 AssemblyLoadContext 并处理其 Resolving 事件:
var alc = new AssemblyLoadContext ("test");
alc.Resolving += (loadContext, assemblyName) =>
{
string targetPath = Path.Combine (@"c:\temp", assemblyName.Name + ".dll");
return alc.LoadFromAssemblyPath (targetPath); // Load the assembly
};
Assembly foo = alc.LoadFromAssemblyName (new AssemblyName ("foo"));
现在请注意,我们不需要检查程序集是否存在。因为 Resolving 事件是在默认 ALC 尝试解析程序集失败后触发的,所以我们的处理程序不会对 .NET BCL 程序集触发。这使得这种解决方案更简单,尽管存在一个缺点。请记住,在我们的场景中,主应用程序在编译时不知道 foo.dll 或 bar.dll。这意味着主应用程序可能依赖于编译时存在的 foo.dll 或 bar.dll。如果这种情况发生,Resolving 事件将不会触发,而是加载应用程序的 foo 和 bar 程序集。换句话说,我们将无法实现隔离。
注意
我们的 FolderBasedALC 类很好地说明了程序集解析的概念,但在实际应用中用处较小,因为它无法处理特定于平台和(对于库项目)开发时的 NuGet 依赖项。在“AssemblyDependencyResolver”中,我们描述了解决此问题的方法,在“编写插件系统”中,我们给出了详细的示例。
默认 ALC
当应用程序启动时,CLR 会为静态的 AssemblyLoadContext.Default 属性分配一个特殊的 ALC。默认的 ALC 是启动程序集加载的地方,以及其静态引用的依赖项和 .NET 运行时 BCL 程序集。
默认的 ALC 首先在默认探测路径中查找以自动解析程序集(见“默认探测”);这通常等同于应用程序的 .deps.json 和 .runtimeconfig.json 文件中指示的位置。
如果 ALC 在其默认探测路径中找不到一个程序集,将会触发其Resolving事件。处理此事件可以让您从其他位置加载程序集,这意味着您可以将应用程序的依赖项部署到其他位置,如子文件夹、共享文件夹,甚至作为主机程序集内的二进制资源:
AssemblyLoadContext.Default.Resolving += (loadContext, assemblyName) =>
{
// Try to locate assemblyName, returning an Assembly object or null.
// Typically you’d call LoadFromAssemblyPath after finding the file.
// ...
};
在默认 ALC 中,当自定义 ALC 无法解析(换句话说,当其Load方法返回null时)且默认 ALC 无法解析该程序集时,Resolving事件也会触发。
您还可以从外部加载程序集到默认 ALC 中的Resolving事件之外。但在继续之前,您应该首先确定是否可以通过使用单独的 ALC 或使用我们在以下部分描述的方法(使用executing和contextual ALC)来更好地解决问题。因为将代码硬编码到默认 ALC 会使其变得脆弱,因为它不能完全被隔离(例如通过单元测试框架或 LINQPad)。
如果您仍然希望继续,最好调用一个解析方法(如LoadFromAssemblyName),而不是一个加载方法(例如LoadFromAssemblyPath)——特别是如果您的程序集是静态引用的情况下。这是因为可能已经加载了该程序集,此时LoadFromAssemblyName将返回已加载的程序集,而LoadFromAssemblyPath将抛出异常。
(使用LoadFromAssemblyPath时,您也可能面临从与 ALC 默认解析机制不一致的位置加载程序集的风险。)
如果程序集位于 ALC 不会自动找到的位置,您仍然可以遵循此过程并另外处理 ALC 的Resolving事件。
请注意,在调用LoadFromAssemblyName时,您不需要提供完整的名称;简单名称就足够了(即使程序集是强命名的也是有效的):
AssemblyLoadContext.Default.LoadFromAssemblyName ("System.Xml");
但是,如果在名称中包含公钥令牌,则必须与加载的内容匹配。
默认探测
默认探测路径通常包括以下内容:
-
路径在AppName.deps.json中指定(其中AppName是您的应用程序主程序集的名称)。如果此文件不存在,则使用应用程序基础文件夹。
-
包含.NET 运行时系统程序集的文件夹(如果您的应用程序是依赖于 Framework 的)。
MSBuild 会自动生成一个名为AppName.deps.json的文件,其中描述了如何找到所有依赖项。这些包括跨平台的程序集,放置在应用程序基础文件夹中,以及特定于平台的程序集,放置在runtimes*子目录下的一个子文件夹中,例如win或unix*。
在生成的.deps.json文件中指定的路径是相对于应用程序基础文件夹的,或者是您在AppName.runtimeconfig.json和/或AppName.runtimeconfig.dev.json配置文件的additionalProbingPaths部分中指定的任何附加文件夹(后者仅适用于开发环境)。
“当前”ALC
在前面的部分中,我们警告不要显式加载程序集到默认 ALC 中。您通常希望的是加载/解析到“当前”ALC 中。
在大多数情况下,“当前”的 ALC 是包含当前执行程序集的 ALC:
var executingAssem = Assembly.GetExecutingAssembly();
var alc = AssemblyLoadContext.GetLoadContext (executingAssem);
Assembly assem = alc.LoadFromAssemblyName (...); // to resolve by name
// OR: = alc.LoadFromAssemblyPath (...); // to load by path
这是一种更灵活和明确的获取 ALC 的方式:
var myAssem = typeof (SomeTypeInMyAssembly).Assembly;
var alc = AssemblyLoadContext.GetLoadContext (myAssem);
...
有时,不可能推断“当前”的 ALC。例如,假设您负责编写.NET 二进制序列化器(我们在http://www.albahari.com/nutshell的在线补充中描述了序列化)。这样的序列化器写入它序列化的类型的完整名称(包括它们的程序集名称),在反序列化期间必须进行解析。问题是,您应该使用哪个 ALC?依赖执行程序集的问题是,它将返回包含反序列化器的程序集,而不是调用反序列化器的程序集。
最佳解决方案不是猜测,而是询问:
public object Deserialize (Stream stream, AssemblyLoadContext alc)
{
...
}
明确指定可最大化灵活性并最小化出错几率。调用者现在可以决定什么应该算作“当前”的 ALC:
var assem = typeof (SomeTypeThatIWillBeDeserializing).Assembly;
var alc = AssemblyLoadContext.GetLoadContext (assem);
var object = Deserialize (someStream, alc);
Assembly.Load 和上下文 ALCs
为了帮助加载程序集到当前执行的 ALC 的常见情况;即:
var executingAssem = Assembly.GetExecutingAssembly();
var alc = AssemblyLoadContext.GetLoadContext (executingAssem);
Assembly assem = alc.LoadFromAssemblyName (...);
Microsoft 已在Assembly类中定义了以下方法:
public static Assembly Load (string assemblyString);
以及一个功能上相同的接受AssemblyName对象版本:
public static Assembly Load (AssemblyName assemblyRef);
(不要将这些方法与完全不同方式行为的旧版Load(byte[])方法混淆,请参阅“旧版加载方法”。)
与LoadFromAssemblyName一样,您可以选择指定程序集的简单、部分或完整名称:
Assembly a = Assembly.Load ("System.Private.Xml");
这将System.Private.Xml程序集加载到执行代码所在的任何 ALC 中。
在这种情况下,我们指定了一个简单名称。以下字符串也是有效的,并且在.NET 中结果相同:
"System.Private.Xml, PublicKeyToken=cc7b13ffcd2ddd51"
"System.Private.Xml, Version=4.0.1.0"
"System.Private.Xml, Version=4.0.1.0, PublicKeyToken=cc7b13ffcd2ddd51"
如果选择指定公钥令牌,它必须与加载的内容匹配。
注意
Microsoft 开发人员网络(MSDN)警告不要根据部分名称加载程序集,建议您指定确切的版本和公钥令牌。他们的理由基于.NET Framework 相关因素,例如全局程序集缓存和代码访问安全性的影响。在.NET 5+和.NET Core 中,这些因素不存在,因此通常可以从简单或部分名称加载。
这两种方法都严格用于解析,因此不能指定文件路径。(如果在AssemblyName对象的CodeBase属性中填充内容,将会被忽略。)
警告
不要陷入使用Assembly.Load加载静态引用程序集的陷阱。在这种情况下,您只需引用程序集中的某个类型并从中获取该程序集:
Assembly a = typeof (System.Xml.Formatting).Assembly;
或者,您甚至可以这样做:
Assembly a = System.Xml.Formatting.Indented.GetType().Assembly;
这样可以避免硬编码程序集名称(您将来可能会更改),同时在执行代码的ALC 上触发程序集解析(就像使用Assembly.Load一样)。
如果您要自己编写Assembly.Load方法,它(几乎)看起来会像这样:
[MethodImpl(MethodImplOptions.NoInlining)]
Assembly Load (string name)
{
Assembly callingAssembly = Assembly.GetCallingAssembly();
var callingAlc = AssemblyLoadContext.GetLoadContext (callingAssembly);
return callingAlc.LoadFromAssemblyName (new AssemblyName (name));
}
EnterContextualReflection
当通过中介(如反序列化器或单元测试运行器)调用Assembly.Load时,Assembly.Load使用调用方的加载上下文的策略失败。如果中介定义在不同的程序集中,则使用中介的加载上下文而不是调用方的加载上下文。
注意
我们在早些时候描述了这种情况,当我们讨论如何编写反序列化器时。在这种情况下,理想的解决方案是强制调用者指定一个 ALC,而不是通过Assembly.Load(string)推断它。
但由于.NET 5+和.NET Core 是从.NET Framework 进化而来——在那里,隔离是通过应用程序域而不是 ALC 完成的——理想的解决方案并不普遍,有时在无法可靠推断 ALC 的情况下,会不适当地使用Assembly.Load(string)。一个例子是.NET 二进制序列化器。
为了在这种场景下仍然允许Assembly.Load工作,Microsoft 添加了一个方法到AssemblyLoadContext,名为EnterContextualReflection。这会将一个 ALC 分配给AssemblyLoadContext.CurrentContextualReflectionContext。虽然这是一个静态属性,但它的值存储在一个AsyncLocal变量中,因此可以在不同线程上持有不同的值(但在整个异步操作期间仍然保持)。
如果此属性非空,Assembly.Load会自动使用它,而不是调用方的 ALC:
Method1();
var myALC = new AssemblyLoadContext ("test");
using (myALC.EnterContextualReflection())
{
Console.WriteLine (
AssemblyLoadContext.CurrentContextualReflectionContext.Name); // test
Method2();
}
// Once disposed, EnterContextualReflection() no longer has an effect.
Method3();
void Method1() => Assembly.Load ("..."); // Will use calling ALC
void Method2() => Assembly.Load ("..."); // Will use myALC
void Method3() => Assembly.Load ("..."); // Will use calling ALC
我们之前演示了如何编写一个功能类似于Assembly.Load的方法。这里是一个更准确的版本,考虑到了上下文反射上下文:
[MethodImpl(MethodImplOptions.NoInlining)]
Assembly Load (string name)
{
var alc = AssemblyLoadContext.CurrentContextualReflectionContext
?? AssemblyLoadContext.GetLoadContext (Assembly.GetCallingAssembly());
return alc.LoadFromAssemblyName (new AssemblyName (name));
}
即使上下文反射上下文对于允许旧代码运行可能有用,但更健壮的解决方案(正如我们之前所描述的)是修改调用Assembly.Load的代码,使其改为在由调用者传入的 ALC 上调用LoadFromAssemblyName。
注意
.NET Framework 没有EnterContextualReflection的等价物——也不需要它——尽管有相同的Assembly.Load方法。这是因为在.NET Framework 中,隔离主要通过应用程序域而不是 ALC 完成。应用程序域提供了更强的隔离模型,每个应用程序域都有自己的默认加载上下文,因此即使只使用默认加载上下文,隔离仍然可以工作。
加载和解析非托管库
ALC 也可以加载和解析本机库。当调用标有[DllImport]属性的外部方法时,会触发本机解析:
[DllImport ("SomeNativeLibrary.dll")]
static extern int SomeNativeMethod (string text);
因为我们在[DllImport]属性中没有指定完整路径,调用SomeNativeMethod会触发在包含SomeNativeMethod定义的程序集的任何 ALC 中的解析。
ALC 中的虚拟解析方法称为LoadUnmanagedDll,加载方法称为LoadUnmanagedDllFromPath:
protected override IntPtr LoadUnmanagedDll (string unmanagedDllName)
{
// Locate the full path of unmanagedDllName...
string fullPath = ...
return LoadUnmanagedDllFromPath (fullPath); // Load the DLL
}
如果无法找到文件,可以返回IntPtr.Zero。CLR 将会触发 ALC 的ResolvingUnmanagedDll事件。
有趣的是,LoadUnmanagedDllFromPath方法是受保护的,因此您通常无法从ResolvingUnmanagedDll事件处理程序中调用它。但是,您可以通过调用静态的NativeLibrary.Load来达到相同的效果:
someALC.ResolvingUnmanagedDll += (requestingAssembly, unmanagedDllName) =>
{
return NativeLibrary.Load ("(full path to unmanaged DLL)");
};
尽管通常由 ALC 解析和加载本机库,但本机库并不属于任何 ALC。加载后,本机库独立存在,并负责解析可能具有的任何传递依赖项。此外,本机库是进程全局的,因此如果具有相同文件名的两个不同版本的本机库,是不可能加载的。
AssemblyDependencyResolver
在“默认探测”中,我们说默认的 ALC 会读取.deps.json和.runtimeconfig.json文件(如果存在),以确定在哪里查找解析平台特定和开发时 NuGet 依赖项。
如果您想将一个具有平台特定或 NuGet 依赖项的程序集加载到自定义 ALC 中,您需要以某种方式复制此逻辑。您可以通过解析配置文件并仔细遵循平台特定的标识规则来完成此操作,但这不仅很困难,而且如果 .NET 的后续版本中更改了规则,您编写的代码将会出错。
AssemblyDependencyResolver类解决了这个问题。要使用它,您需要实例化它并提供您想要探测其依赖项的程序集的路径:
var resolver = new AssemblyDependencyResolver (@"c:\temp\foo.dll");
然后,要找到依赖项的路径,您需要调用ResolveAssemblyToPath方法:
string path = resolver.ResolveAssemblyToPath (new AssemblyName ("bar"));
如果没有.deps.json文件(或者.deps.json文件中没有与bar.dll相关的内容),则将评估为c:\temp\bar.dll。
您可以通过调用ResolveUnmanagedDllToPath类似地解析非托管依赖项。
说明更复杂场景的一个很好的方法是创建一个名为ClientApp的新控制台项目,然后为其添加一个Microsoft.Data.SqlClient的 NuGet 引用。添加以下类:
using Microsoft.Data.SqlClient;
namespace ClientApp
{
public class Program
{
public static SqlConnection GetConnection() => new SqlConnection();
static void Main() => GetConnection(); // Test that it resolves
}
}
现在构建应用程序并查看输出文件夹:您会看到一个名为Microsoft.Data.SqlClient.dll的文件。但是,运行时此文件不会加载,并且尝试显式加载它会引发异常。实际加载的程序集位于runtimes\win(或runtimes/unix)子文件夹中;默认的 ALC 知道加载它,因为它解析了ClientApp.deps.json文件。
如果您试图从另一个应用程序加载ClientApp.dll程序集,您需要编写一个可以解析其依赖项Microsoft.Data.SqlClient.dll的 ALC。在此过程中,仅仅查找ClientApp.dll所在的文件夹是不够的(就像我们在“解析程序集”中所做的那样)。相反,您需要使用AssemblyDependencyResolver来确定正在使用的平台中该文件的位置:
string path = @"C:\source\ClientApp\bin\Debug\netcoreapp3.0\ClientApp.dll";
var resolver = new AssemblyDependencyResolver (path);
var sqlClient = new AssemblyName ("Microsoft.Data.SqlClient");
Console.WriteLine (resolver.ResolveAssemblyToPath (sqlClient));
在 Windows 机器上,这将输出以下内容:
C:\source\ClientApp\bin\Debug\netcoreapp3.0\runtimes\win\lib\netcoreapp2.1
\Microsoft.Data.SqlClient.dll
我们在“编写插件系统”中提供了一个完整的示例。
卸载 ALC
在简单的情况下,可以卸载非默认的AssemblyLoadContext,释放内存并释放它加载的程序集的文件锁。为了使其工作,ALC 必须使用isCollectible参数true进行实例化:
var alc = new AssemblyLoadContext ("test", isCollectible:true);
然后可以调用 ALC 上的Unload方法来启动卸载过程。
卸载模型是合作性的,而不是抢占式的。如果任何 ALC 的程序集中的任何方法正在执行,则卸载将被推迟,直到这些方法完成。
实际的卸载发生在垃圾回收期间;如果来自 ALC 外部的任何东西(包括对象、类型和程序集)对 ALC 内部的任何东西具有任何(非弱)引用,则不会发生卸载。在.NET BCL 中,包括静态字段或字典中缓存对象以及订阅事件是很常见的 API,这使得很容易创建引用,阻止卸载,尤其是如果 ALC 中的代码以非平凡的方式使用其 ALC 外的 API。确定卸载失败的原因很困难,需要使用诸如 WinDbg 之类的工具。
旧加载方法
如果您仍在使用.NET Framework(或编写目标为.NET Standard 并希望支持.NET Framework 的库),则无法使用AssemblyLoadContext类。取而代之的是通过以下方法实现加载:
public static Assembly LoadFrom (string assemblyFile);
public static Assembly LoadFile (string path);
public static Assembly Load (byte[] rawAssembly);
LoadFile和Load(byte[])提供隔离性,而LoadFrom则不提供。
通过处理应用程序域的AssemblyResolve事件来实现解析,其工作方式类似于默认 ALC 的Resolving事件。
Assembly.Load(string)方法也可用于触发解析,并以类似的方式工作。
LoadFrom
LoadFrom从给定路径加载程序集到默认 ALC 中。这有点像调用AssemblyLoadContext.Default.LoadFromAssemblyPath,除了以下几点:
-
如果在默认 ALC 中已经存在具有相同简单名称的程序集,则
LoadFrom返回该程序集,而不是抛出异常。 -
如果在默认的 ALC 中尚未存在具有相同简单名称的程序集,并且发生了加载,则该程序集将被赋予特殊的“LoadFrom”状态。该状态影响默认 ALC 的解析逻辑,即如果该程序集在相同的文件夹中有任何依赖项,则这些依赖项将自动解析。
注意
.NET Framework 有全局程序集缓存(GAC)。如果程序集存在于 GAC 中,则 CLR 将始终从那里加载。这适用于所有三种加载方法。
LoadFrom自动解析传递性相同文件夹依赖项的能力可能很方便,直到它加载不应该加载的程序集。由于这种情况很难调试,最好使用Load(string)或LoadFile,并通过处理应用程序域的AssemblyResolve事件来解析传递性依赖项。这样可以让您决定如何解析每个程序集,并允许通过在事件处理程序内创建断点进行调试。
LoadFile 和 Load(byte[])
LoadFile和Load(byte[])从给定的文件路径或字节数组加载程序集到新的 ALC 中。与LoadFrom不同,这些方法提供了隔离并允许您加载同一程序集的多个版本。但是,有两个注意事项:
-
使用相同路径再次调用
LoadFile将返回先前加载的程序集。 -
在 .NET Framework 中,这两种方法首先检查 GAC,并在程序集存在时从那里加载。
使用LoadFile和Load(byte[]),您会得到一个单独的 ALC 每个程序集(除了注意事项)。这种隔离使得管理变得更加复杂。
要解析依赖项,您需要处理AppDomain的Resolving事件,该事件在所有 ALC 上触发:
AppDomain.CurrentDomain.AssemblyResolve += (sender, args) =>
{
string fullAssemblyName = args.Name;
// return an Assembly object or null
...
};
args 变量还包括一个名为 RequestingAssembly 的属性,该属性告诉您触发解析的程序集是哪个。
找到组件后,可以调用Assembly.LoadFile来加载它。
注意
您可以使用AppDomain.CurrentDomain.GetAssemblies()枚举已加载到当前应用程序域中的所有程序集。在 .NET 5+ 中也适用,其等效于以下内容:
AssemblyLoadContext.All.SelectMany (a => a.Assemblies)
编写插件系统
为了完全演示本节中涵盖的概念,让我们编写一个插件系统,该系统使用无法卸载的 ALC 来隔离每个插件。
我们的演示系统最初将包括三个 .NET 项目:
Plugin.Common(库)
定义插件将实现的接口
Capitalizer(库)
一个将文本转换为大写的插件
Plugin.Host(控制台应用程序)
定位并调用插件
假设项目位于以下目录中:
c:\source\PluginDemo\Plugin.Common
c:\source\PluginDemo\Capitalizer
c:\source\PluginDemo\Plugin.Host
所有项目将引用 Plugin.Common 库,而没有其他项目间的引用。
注意
如果 Plugin.Host 引用了 Capitalizer,我们将不会编写一个插件系统;中心思想是插件是由 Plugin.Host 和 Plugin.Common 发布后的第三方编写的。
如果您使用 Visual Studio,将这三个项目放入单个解决方案中可能会很方便,以便进行此演示。如果这样做,请右键单击 Plugin.Host 项目,选择“生成依赖项” > “项目依赖项”,然后选中 Capitalizer 项目。这样在运行 Plugin.Host 项目时,会强制 Capitalizer 在不添加引用的情况下进行构建。
Plugin.Common
让我们从 Plugin.Common 开始。我们的插件将执行一个非常简单的任务,即转换一个字符串。以下是我们如何定义接口:
namespace Plugin.Common
{
public interface ITextPlugin
{
string TransformText (string input);
}
}
插件.Common 就是这样。
大写(插件)
我们的大写插件将引用 Plugin.Common 并包含一个单独的类。目前,我们将保持逻辑简单,以便插件没有额外的依赖关系:
public class CapitalizerPlugin : Plugin.Common.ITextPlugin
{
public string TransformText (string input) => input.ToUpper();
}
如果您构建了两个项目并查看大写插件的输出文件夹,您将看到以下两个程序集:
Capitalizer.dll // Our plug-in assembly
Plugin.Common.dll // Referenced assembly
Plugin.Host
Plugin.Host 是一个包含两个类的控制台应用程序。第一个类是加载插件的自定义 ALC:
class PluginLoadContext : AssemblyLoadContext
{
AssemblyDependencyResolver _resolver;
public PluginLoadContext (string pluginPath, bool collectible)
// Give it a friendly name to help with debugging:
: base (name: Path.GetFileName (pluginPath), collectible)
{
// Create a resolver to help us find dependencies.
_resolver = new AssemblyDependencyResolver (pluginPath);
}
protected override Assembly Load (AssemblyName assemblyName)
{
// See below
if (assemblyName.Name == typeof (ITextPlugin).Assembly.GetName().Name)
return null;
string target = _resolver.ResolveAssemblyToPath (assemblyName);
if (target != null)
return LoadFromAssemblyPath (target);
// Could be a BCL assembly. Allow the default context to resolve.
return null;
}
protected override IntPtr LoadUnmanagedDll (string unmanagedDllName)
{
string path = _resolver.ResolveUnmanagedDllToPath (unmanagedDllName);
return path == null
? IntPtr.Zero
: LoadUnmanagedDllFromPath (path);
}
}
在构造函数中,我们传入主插件程序集的路径以及一个标志,指示我们是否希望 ALC 可以被收集(以便可以卸载它)。
Load方法是我们处理依赖项解析的地方。所有插件都必须引用 Plugin.Common,以便它们可以实现ITextPlugin。这意味着Load方法将在某个时候触发以解析 Plugin.Common。我们需要小心,因为插件的输出文件夹很可能不仅包含Capitalizer.dll,还包含自己的Plugin.Common.dll副本。如果我们加载这个Plugin.Common.dll副本到PluginLoadContext中,我们将得到两个程序集副本:一个在主机的默认上下文中,一个在插件的PluginLoadContext中。这些程序集将不兼容,主机将抱怨插件未实现ITextPlugin!
要解决这个问题,我们明确地检查这个条件:
if (assemblyName.Name == typeof (ITextPlugin).Assembly.GetName().Name)
return null;
返回 null 允许主机的默认 ALC 来解析程序集。
注意
而不是返回 null,我们可以返回typeof(ITextPlugin).Assembly,它也将正确工作。我们如何确保ITextPlugin将在主机的 ALC 上解析,而不是在我们的PluginLoadContext上?请记住,我们的PluginLoadContext类定义在Plugin.Host程序集中。因此,您从这个类静态引用的任何类型都将触发在其加载的 ALC 上解析程序集的组件,即Plugin.Host。
检查通用程序集后,我们使用AssemblyDependencyResolver来定位插件可能具有的任何私有依赖项。(现在不会有。)
注意,我们还重写了LoadUnamangedDll方法。这确保了如果插件有任何非托管依赖项,它们也会正确加载。
Plugin.Host 的第二个要编写的类是程序的主要程序本身。为简单起见,让我们将路径硬编码到我们的 Capitalizer 插件(在现实生活中,您可能通过查找已知位置的 DLL 或从配置文件中读取来发现插件的路径):
class Program
{
const bool UseCollectibleContexts = true;
static void Main()
{
const string capitalizer = @"C:\source\PluginDemo\"
+ @"Capitalizer\bin\Debug\netcoreapp3.0\Capitalizer.dll";
Console.WriteLine (TransformText ("big apple", capitalizer));
}
static string TransformText (string text, string pluginPath)
{
var alc = new PluginLoadContext (pluginPath, UseCollectibleContexts);
try
{
Assembly assem = alc.LoadFromAssemblyPath (pluginPath);
// Locate the type in the assembly that implements ITextPlugin:
Type pluginType = assem.ExportedTypes.Single (t =>
typeof (ITextPlugin).IsAssignableFrom (t));
// Instantiate the ITextPlugin implementation:
var plugin = (ITextPlugin)Activator.CreateInstance (pluginType);
// Call the TransformText method
return plugin.TransformText (text);
}
finally
{
if (UseCollectibleContexts) alc.Unload(); // unload the ALC
}
}
}
让我们来看看TransformText方法。我们首先为我们的插件实例化一个新的 ALC,然后要求它加载主插件程序集。接下来,我们使用反射来定位实现ITextPlugin接口的类型(我们在第十八章中详细讨论这个)。然后,我们实例化插件,调用TransformText方法,然后卸载 ALC。
注意
如果你需要重复调用TransformText方法,更好的方法是缓存 ALC 而不是在每次调用后卸载它。
这里是输出:
BIG APPLE
添加依赖项
我们的代码完全能够解析和隔离依赖项。举例来说,让我们首先添加一个 NuGet 引用到Humanizer.Core,版本为 2.6.2。你可以通过 Visual Studio 的 UI 或者将以下元素添加到Capitalizer.csproj文件来实现这一点:
<ItemGroup>
<PackageReference Include="Humanizer.Core" Version="2.6.2" />
</ItemGroup>
现在,修改CapitalizerPlugin如下:
using Humanizer;
namespace Capitalizer
{
public class CapitalizerPlugin : Plugin.Common.ITextPlugin
{
public string TransformText (string input) => input.Pascalize();
}
}
如果重新运行程序,输出现在将是这样的:
BigApple
接下来,我们创建另一个名为 Pluralizer 的插件。创建一个新的.NET 库项目,并添加一个 NuGet 引用到Humanizer.Core,版本为 2.7.9:
<ItemGroup>
<PackageReference Include="Humanizer.Core" Version="2.7.9" />
</ItemGroup>
现在,添加一个名为PluralizerPlugin的类。这将类似于CapitalizerPlugIn,但我们调用的是Pluralize方法:
using Humanizer;
namespace Pluralizer
{
public class PluralizerPlugin : Plugin.Common.ITextPlugin
{
public string TransformText (string input) => input.Pluralize();
}
}
最后,我们需要在 Plugin.Host 的Main方法中添加代码来加载和运行 Pluralizer 插件:
static void Main()
{
const string capitalizer = @"C:\source\PluginDemo\"
+ @"Capitalizer\bin\Debug\netcoreapp3.0\Capitalizer.dll";
Console.WriteLine (TransformText ("big apple", capitalizer));
const string pluralizer = @"C:\source\PluginDemo\"
+ @"Pluralizer\bin\Debug\netcoreapp3.0\Pluralizer.dll";
Console.WriteLine (TransformText ("big apple", pluralizer));
}
输出现在将是这样的:
BigApple
big apples
要完全了解发生了什么,请将UseCollectibleContexts常量更改为 false,并将以下代码添加到Main方法以枚举 ALC 及其程序集:
foreach (var context in AssemblyLoadContext.All)
{
Console.WriteLine ($"Context: {context.GetType().Name} {context.Name}");
foreach (var assembly in context.Assemblies)
Console.WriteLine ($" Assembly: {assembly.FullName}");
}
在输出中,你可以看到两个不同版本的 Humanizer,每个加载到它自己的 ALC 中:
Context: PluginLoadContext Capitalizer.dll
Assembly: Capitalizer, Version=1.0.0.0, Culture=neutral, PublicKeyToken=...
Assembly: Humanizer, Version=2.6.0.0, Culture=neutral, PublicKeyToken=...
Context: PluginLoadContext Pluralizer.dll
Assembly: Pluralizer, Version=1.0.0.0, Culture=neutral, PublicKeyToken=...
Assembly: Humanizer, Version=2.7.0.0, Culture=neutral, PublicKeyToken=...
Context: DefaultAssemblyLoadContext Default
Assembly: System.Private.CoreLib, Version=4.0.0.0, Culture=neutral,...
Assembly: Host, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null
...
注意
即使两个插件都使用相同版本的 Humanizer,分离的程序集仍然有益,因为每个程序集都有自己的静态变量。
第十八章:反射和元数据
正如我们在 第十七章 中看到的,C# 程序编译为包含元数据、编译代码和资源的程序集。在运行时检查元数据和编译代码称为 反射。
程序集中编译的代码包含几乎所有原始源代码的内容。会丢失一些信息,如局部变量名称、注释和预处理指令。但是,反射可以访问几乎所有其他内容,甚至可以编写反编译器。
.NET 中提供的许多服务(如动态绑定、序列化和数据绑定)依赖于元数据的存在。您的程序也可以利用此元数据,甚至使用自定义属性添加新信息。System.Reflection 命名空间包含反射 API。在运行时,还可以通过 System.Reflection.Emit 命名空间中的类动态创建新的元数据和中间语言(IL)可执行指令。
本章的示例假定您导入了 System、System.Reflection 和 System.Reflection.Emit 命名空间。
注意
在本章中,当我们使用术语“动态”时,指的是使用反射执行某些仅在运行时强制执行类型安全性的任务。这与通过 C# 的 dynamic 关键字进行的 动态绑定 在原理上类似,尽管机制和功能不同。
动态绑定更容易使用,并使用动态语言运行时(DLR)实现动态语言的互操作性。反射在使用上相对笨拙,但在与 CLR 的交互方面更加灵活。例如,反射允许您获取类型和成员列表,通过字符串实例化对象的名称,并动态构建程序集。
反射和激活类型
在本节中,我们将探讨如何获取 Type,检查其元数据,并使用它动态实例化对象。
获取类型
System.Type 的一个实例表示类型的元数据。因为 Type 被广泛使用,所以它位于 System 命名空间而不是 System.Reflection 命名空间中。
您可以通过在任何对象上调用 GetType 或使用 C# 的 typeof 运算符来获取 System.Type 的实例:
Type t1 = DateTime.Now.GetType(); // Type obtained at runtime
Type t2 = typeof (DateTime); // Type obtained at compile time
可以使用 typeof 获取数组类型和泛型类型,如下所示:
Type t3 = typeof (DateTime[]); // 1-d Array type
Type t4 = typeof (DateTime[,]); // 2-d Array type
Type t5 = typeof (Dictionary<int,int>); // Closed generic type
Type t6 = typeof (Dictionary<,>); // Unbound generic type
您还可以通过名称检索 Type。如果有其 Assembly 的引用,请调用 Assembly.GetType(我们在部分 “反映程序集” 中进一步描述):
Type t = Assembly.GetExecutingAssembly().GetType ("Demos.TestProgram");
如果没有 Assembly 对象,可以通过其 程序集限定名称(类型的完整名称后跟程序集的完全或部分限定名称)获取类型。程序集会隐式加载,就像调用了 Assembly.Load(string) 一样:
Type t = Type.GetType ("System.Int32, System.Private.CoreLib");
获得 System.Type 对象后,可以使用其属性访问类型的名称、程序集、基类型、可见性等:
Type stringType = typeof (string);
string name = stringType.Name; // String
Type baseType = stringType.BaseType; // typeof(Object)
Assembly assem = stringType.Assembly; // System.Private.CoreLib
bool isPublic = stringType.IsPublic; // true
System.Type 实例是类型的整个元数据的窗口 —— 以及它所定义的程序集。
注意
System.Type 是抽象的,因此 typeof 操作符实际上必须给你一个 Type 的子类。CLR 使用的子类是 .NET 内部的,称为 RuntimeType。
TypeInfo
如果要定位到 .NET Core 1.x(或较旧的 Windows Store 配置文件),你会发现大多数 Type 的成员都丢失了。这些丢失的成员在称为 TypeInfo 的类上公开,通过调用 GetTypeInfo 可获取。因此,为了使我们之前的示例运行,你将执行以下操作:
Type stringType = typeof(string);
string name = stringType.Name;
Type baseType = stringType.GetTypeInfo().BaseType;
Assembly assem = stringType.GetTypeInfo().Assembly;
bool isPublic = stringType.GetTypeInfo().IsPublic;
TypeInfo 也存在于 .NET Core 2 和 3 以及 .NET 5+(以及 .NET Framework 4.5+ 和所有 .NET Standard 版本),因此前面的代码几乎通用。TypeInfo 还包括用于反射成员的附加属性和方法。
获取数组类型
正如我们刚才看到的,typeof 和 GetType 可与数组类型一起使用。你还可以通过在 元素 类型上调用 MakeArrayType 来获取数组类型:
Type simpleArrayType = typeof (int).MakeArrayType();
Console.WriteLine (simpleArrayType == typeof (int[])); // True
通过在 MakeArrayType 上传递整数参数,可以创建多维数组:
Type cubeType = typeof (int).MakeArrayType (3); // cube shaped
Console.WriteLine (cubeType == typeof (int[,,])); // True
GetElementType 执行反向操作:检索数组类型的元素类型:
Type e = typeof (int[]).GetElementType(); // e == typeof (int)
GetArrayRank 返回矩形数组的维数:
int rank = typeof (int[,,]).GetArrayRank(); // 3
获取嵌套类型
要检索嵌套类型,请在包含类型上调用 GetNestedTypes:
foreach (Type t in typeof (System.Environment).GetNestedTypes())
Console.WriteLine (t.FullName);
OUTPUT: System.Environment+SpecialFolder
或:
foreach (TypeInfo t in typeof (System.Environment).GetTypeInfo()
.DeclaredNestedTypes)
Debug.WriteLine (t.FullName);
嵌套类型的唯一警告是 CLR 将嵌套类型视为具有特殊的“嵌套”可访问级别:
Type t = typeof (System.Environment.SpecialFolder);
Console.WriteLine (t.IsPublic); // False
Console.WriteLine (t.IsNestedPublic); // True
类型名称
类型具有 Namespace、Name 和 FullName 属性。在大多数情况下,FullName 是前两者的组合:
Type t = typeof (System.Text.StringBuilder);
Console.WriteLine (t.Namespace); // System.Text
Console.WriteLine (t.Name); // StringBuilder
Console.WriteLine (t.FullName); // System.Text.StringBuilder
有两个例外情况:嵌套类型和封闭泛型类型。
注意
Type 还有一个名为 AssemblyQualifiedName 的属性,返回 FullName,后跟逗号,然后是其程序集的完整名称。这与您可以传递给 Type.GetType 的字符串相同,并在默认加载上下文中唯一标识类型。
嵌套类型名称
对于嵌套类型,包含的类型仅出现在 FullName 中:
Type t = typeof (System.Environment.SpecialFolder);
Console.WriteLine (t.Namespace); // System
Console.WriteLine (t.Name); // SpecialFolder
Console.WriteLine (t.FullName); // System.Environment+SpecialFolder
+ 符号区分包含类型和嵌套命名空间。
泛型类型名称
泛型类型名称以 ' 符号结尾,后跟类型参数的数量。如果泛型类型未绑定,此规则适用于 Name 和 FullName 两者:
Type t = typeof (Dictionary<,>); // Unbound
Console.WriteLine (t.Name); // Dictionary'2
Console.WriteLine (t.FullName); // System.Collections.Generic.Dictionary'2
然而,如果泛型类型已关闭,FullName(仅)将获取一个显著的额外附加部分。列举每个类型参数的完整 程序集限定名称:
Console.WriteLine (typeof (Dictionary<int,string>).FullName);
// OUTPUT:
System.Collections.Generic.Dictionary`2[[System.Int32,
System.Private.CoreLib, Version=4.0.0.0, Culture=neutral,
PublicKeyToken=7cec85d7bea7798e],[System.String, System.Private.CoreLib,
Version=4.0.0.0, Culture=neutral, PublicKeyToken=7cec85d7bea7798e]]
这确保 AssemblyQualifiedName(类型的全名和程序集名称的组合)包含足够的信息,以完全标识泛型类型及其类型参数。
数组和指针类型名称
数组的表示与你在 typeof 表达式中使用的后缀相同:
Console.WriteLine (typeof ( int[] ).Name); // Int32[]
Console.WriteLine (typeof ( int[,] ).Name); // Int32[,]
Console.WriteLine (typeof ( int[,] ).FullName); // System.Int32[,]
指针类型相似:
Console.WriteLine (typeof (byte*).Name); // Byte*
ref 和 out 参数类型名称
描述 ref 或 out 参数的 Type 具有 & 后缀:
public void RefMethod (ref int p)
{
Type t = MethodInfo.GetCurrentMethod().GetParameters()[0].ParameterType;
Console.WriteLine (t.Name); // Int32&
}
更多内容稍后在 “反射和调用成员” 一节中讨论。
基类型和接口
Type 公开了 BaseType 属性:
Type base1 = typeof (System.String).BaseType;
Type base2 = typeof (System.IO.FileStream).BaseType;
Console.WriteLine (base1.Name); // Object
Console.WriteLine (base2.Name); // Stream
GetInterfaces 方法返回一个类型实现的接口列表:
foreach (Type iType in typeof (Guid).GetInterfaces())
Console.WriteLine (iType.Name);
*IFormattable*
*IComparable*
*IComparable'1*
*IEquatable'1*
(GetInterfaceMap 方法返回一个结构,显示接口的每个成员在类或结构中如何实现——我们在 “调用静态虚拟/抽象接口成员” 中展示了此高级功能的用法。)
反射提供了三种 C# 静态 is 运算符的动态等效方式:
IsInstanceOfType
接受类型和实例
IsAssignableFrom 和(从 .NET 5 开始)IsAssignableTo
接受两种类型
这是第一个例子:
object obj = Guid.NewGuid();
Type target = typeof (IFormattable);
bool isTrue = obj is IFormattable; // Static C# operator
bool alsoTrue = target.IsInstanceOfType (obj); // Dynamic equivalent
IsAssignableFrom 更为灵活:
Type target = typeof (IComparable), source = typeof (string);
Console.WriteLine (target.IsAssignableFrom (source)); // True
IsSubclassOf 方法与 IsAssignableFrom 方法的工作原理相同,但不包括接口。
实例化类型
有两种方式可以根据其类型动态实例化对象:
-
调用静态
Activator.CreateInstance方法 -
在
Type上调用GetConstructor返回的ConstructorInfo对象上调用Invoke(高级场景)
Activator.CreateInstance 接受一个 Type 和可选参数,将其传递给构造函数:
int i = (int) Activator.CreateInstance (typeof (int));
DateTime dt = (DateTime) Activator.CreateInstance (typeof (DateTime),
2000, 1, 1);
CreateInstance 允许你指定许多其他选项,例如从中加载类型的程序集以及是否绑定到非公共构造函数。如果运行时找不到合适的构造函数,则会抛出 MissingMethodException。
当你的参数值无法区分重载构造函数时,需要在 ConstructorInfo 上调用 Invoke。例如,假设类 X 有两个构造函数:一个接受 string 类型的参数,另一个接受 StringBuilder 类型的参数。如果你将 null 参数传递给 Activator.CreateInstance,目标将变得模糊。这时就需要使用 ConstructorInfo:
// Fetch the constructor that accepts a single parameter of type string:
ConstructorInfo ci = typeof (X).GetConstructor (new[] { typeof (string) });
// Construct the object using that overload, passing in null:
object foo = ci.Invoke (new object[] { null });
或者,如果你的目标是 .NET Core 1 或较旧的 Windows Store 配置文件:
ConstructorInfo ci = typeof (X).GetTypeInfo().DeclaredConstructors
.FirstOrDefault (c =>
c.GetParameters().Length == 1 &&
c.GetParameters()[0].ParameterType == typeof (string));
要获取非公共构造函数,需要指定 BindingFlags ——参见后面一节中的 “访问非公共成员” 和 “反射和调用成员”。
警告
动态实例化会在构造对象的时间上增加几微秒。相对来说,这在一般情况下并不多,因为 CLR 在实例化对象方面通常非常快速(在一个小类上进行简单的 new 操作大约需要几十纳秒的时间)。
要根据元素类型动态实例化数组,首先调用 MakeArrayType。你也可以实例化泛型类型:我们在下一节中描述这一点。
要动态实例化委托,请调用 Delegate.CreateDelegate。以下示例演示了如何实例化实例委托和静态委托:
class Program
{
delegate int IntFunc (int x);
static int Square (int x) => x * x; // Static method
int Cube (int x) => x * x * x; // Instance method
static void Main()
{
Delegate staticD = Delegate.CreateDelegate
(typeof (IntFunc), typeof (Program), "Square");
Delegate instanceD = Delegate.CreateDelegate
(typeof (IntFunc), new Program(), "Cube");
Console.WriteLine (staticD.DynamicInvoke (3)); // 9
Console.WriteLine (instanceD.DynamicInvoke (3)); // 27
}
}
您可以调用由 DynamicInvoke 返回的 Delegate 对象,就像我们在本例中所做的那样,或者通过将其转换为具有类型的委托来调用:
IntFunc f = (IntFunc) staticD;
Console.WriteLine (f(3)); // 9 *(but much faster!)*
您可以将 MethodInfo 传递给 CreateDelegate 而不是方法名称。我们稍后在 “反射和调用成员” 中描述 MethodInfo,以及将动态创建的委托类型转换回静态委托类型的原因。
泛型类型
一个 Type 可以表示封闭或未绑定的泛型类型。与编译时一样,封闭泛型类型可以实例化,而未绑定类型则不能:
Type closed = typeof (List<int>);
List<int> list = (List<int>) Activator.CreateInstance (closed); // OK
Type unbound = typeof (List<>);
object anError = Activator.CreateInstance (unbound); // Runtime error
MakeGenericType 方法将一个未绑定的泛型类型转换为封闭泛型类型。只需传入所需的类型参数:
Type unbound = typeof (List<>);
Type closed = unbound.MakeGenericType (typeof (int));
GetGenericTypeDefinition 方法执行相反的操作:
Type unbound2 = closed.GetGenericTypeDefinition(); // unbound == unbound2
如果一个 Type 是泛型,IsGenericType 属性返回 true;如果泛型类型是未绑定的,IsGenericTypeDefinition 属性返回 true。以下代码测试一个类型是否是可空值类型:
Type nullable = typeof (bool?);
Console.WriteLine (
nullable.IsGenericType &&
nullable.GetGenericTypeDefinition() == typeof (Nullable<>)); // True
GetGenericArguments 返回封闭泛型类型的类型参数:
Console.WriteLine (closed.GetGenericArguments()[0]); // System.Int32
Console.WriteLine (nullable.GetGenericArguments()[0]); // System.Boolean
对于未绑定的泛型类型,GetGenericArguments 返回代表在泛型类型定义中指定的占位符类型的伪类型:
Console.WriteLine (unbound.GetGenericArguments()[0]); // T
注意
在运行时,所有泛型类型都是未绑定或封闭的。在表达式 typeof(Foo<>) 这种(相对不常见的)情况下,它们是未绑定的;否则,它们是封闭的。在以下类中的方法总是打印 False:
class Foo<T>
{
public void Test()
=> Console.Write (GetType().IsGenericTypeDefinition);
}
反射和调用成员
GetMembers 方法返回类型的成员。考虑以下类:
class Walnut
{
private bool cracked;
public void Crack() { cracked = true; }
}
我们可以反射其公共成员,如下所示:
MemberInfo[] members = typeof (Walnut).GetMembers();
foreach (MemberInfo m in members)
Console.WriteLine (m);
这是结果:
Void Crack()
System.Type GetType()
System.String ToString()
Boolean Equals(System.Object)
Int32 GetHashCode()
Void .ctor()
当不带参数调用时,GetMembers 返回类型的所有公共成员(及其基类型)。GetMember 通过名称检索特定成员,虽然它仍然返回一个数组,因为成员可以被重载:
MemberInfo[] m = typeof (Walnut).GetMember ("Crack");
Console.WriteLine (m[0]); // Void Crack()
MemberInfo 还有一个名为 MemberType 的 MemberTypes 类型的属性。这是一个带有以下值的标志枚举:
All Custom Field NestedType TypeInfo
Constructor Event Method Property
调用 GetMembers 时,可以传入一个 MemberTypes 实例来限制返回的成员类型。或者,您可以通过调用 GetMethods、GetFields、GetProperties、GetEvents、GetConstructors 或 GetNestedTypes 来限制结果集。每个方法还有相应的单数版本,以精确找到特定成员。
注意
在检索类型成员时尽可能具体是值得的,这样如果以后添加了其他成员,您的代码也不会出错。如果通过名称检索方法,请指定所有参数类型,以确保如果稍后重载了方法,您的代码仍将正常工作(我们稍后在 “方法参数” 中提供示例)。
一个 MemberInfo 对象有一个 Name 属性和两个 Type 属性:
DeclaringType
返回定义成员的 Type
ReflectedType
返回调用 GetMembers 的 Type
当在基类型中定义的成员上调用时,这两者之间存在差异:DeclaringType 返回基类型,而 ReflectedType 返回子类型。以下示例突出了这一点:
// MethodInfo is a subclass of MemberInfo; see Figure 18-1.
MethodInfo test = typeof (Program).GetMethod ("ToString");
MethodInfo obj = typeof (object) .GetMethod ("ToString");
Console.WriteLine (test.DeclaringType); // System.Object
Console.WriteLine (obj.DeclaringType); // System.Object
Console.WriteLine (test.ReflectedType); // Program
Console.WriteLine (obj.ReflectedType); // System.Object
Console.WriteLine (test == obj); // False
因为它们具有不同的 ReflectedType,所以 test 和 obj 对象不相等。然而,它们的差异纯粹是反射 API 的产物;我们的 Program 类型在底层类型系统中没有明确的 ToString 方法。我们可以通过两种方式验证这两个 MethodInfo 对象是否引用同一个方法:
Console.WriteLine (test.MethodHandle == obj.MethodHandle); // True
Console.WriteLine (test.MetadataToken == obj.MetadataToken // True
&& test.Module == obj.Module);
MethodHandle 是进程中每个(真正不同的)方法独有的;MetadataToken 在程序集模块中的所有类型和成员中是唯一的。
MemberInfo 还定义了返回自定义属性的方法(参见“在运行时检索属性”)。
注意
你可以通过调用 MethodBase.GetCurrentMethod 获得当前执行方法的 MethodBase。
成员类型
MemberInfo 本身的成员较少,因为它是显示在 Figure 18-1 中的类型的抽象基类。

图 18-1. 成员类型
你可以根据其 MemberType 属性将 MemberInfo 强制转换为其子类型。如果通过 GetMethod、GetField、GetProperty、GetEvent、GetConstructor 或它们的复数版本获得成员,则不需要转换。Table 18-1 总结了每种 C# 结构的使用方法。
Table 18-1. 检索成员元数据
| C# 结构 | 使用方法 | 使用名称 | 结果 |
|---|---|---|---|
| 方法 | GetMethod |
(方法名) | MethodInfo |
| 属性 | GetProperty |
(属性名) | PropertyInfo |
| 索引器 | GetDefaultMembers |
MemberInfo[](如果在 C# 中编译,则包含 PropertyInfo 对象) |
|
| 字段 | GetField |
(字段名) | FieldInfo |
| 枚举成员 | GetField |
(成员名) | FieldInfo |
| 事件 | GetEvent |
(事件名) | EventInfo |
| 构造函数 | GetConstructor |
ConstructorInfo |
|
| 终结器 | GetMethod |
"Finalize" |
MethodInfo |
| 操作符 | GetMethod |
"op_" + 操作符名称 |
MethodInfo |
| 嵌套类型 | GetNestedType |
(类型名) | Type |
每个 MemberInfo 子类都有丰富的属性和方法,公开了成员元数据的所有方面。这包括可见性、修饰符、泛型类型参数、参数、返回类型和自定义属性等内容。
下面是使用 GetMethod 的示例:
MethodInfo m = typeof (Walnut).GetMethod ("Crack");
Console.WriteLine (m); // Void Crack()
Console.WriteLine (m.ReturnType); // System.Void
所有 *Info 实例在第一次使用时都会被反射 API 缓存:
MethodInfo method = typeof (Walnut).GetMethod ("Crack");
MemberInfo member = typeof (Walnut).GetMember ("Crack") [0];
Console.Write (method == member); // True
除了保留对象标识,缓存还提高了本来是相当慢的 API 的性能。
C# 成员与 CLR 成员的对比
上述表格说明了一些 C# 的功能构造与 CLR 构造之间并非一一对应。这是有道理的,因为 CLR 和反射 API 是设计用来支持所有 .NET 语言的——你甚至可以从 Visual Basic 使用反射。
一些 C# 构造——尤其是索引器、枚举、运算符和终结器——在 CLR 看来是人为的。具体来说:
-
C# 的索引器转换为接受一个或多个参数的属性,并标记为类型的
[DefaultMember]。 -
C# 中的枚举转换为
System.Enum的子类型,每个成员都有一个静态字段。 -
C# 运算符转换为一个以
"op_"开头的特殊命名的静态方法,例如"op_Addition"。 -
C# 的终结器转换为一个覆盖
Finalize的方法。
另一个复杂之处在于属性和事件实际上包含两个部分:
-
描述属性或事件的元数据(由
PropertyInfo或EventInfo封装) -
一个或两个后备方法
在 C# 程序中,后备方法封装在属性或事件定义中。但在编译到 IL 时,这些后备方法呈现为可以像任何其他方法一样调用的普通方法。这意味着 GetMethods 会返回属性和事件的后备方法以及普通方法:
class Test { public int X { get { return 0; } set {} } }
void Demo()
{
foreach (MethodInfo mi in typeof (Test).GetMethods())
Console.Write (mi.Name + " ");
}
// OUTPUT:
get_X set_X GetType ToString Equals GetHashCode
通过 MethodInfo 的 IsSpecialName 属性可以识别这些方法。对于属性、索引器、事件访问器以及运算符,IsSpecialName 返回 true。对于传统的 C# 方法(以及定义了终结器的 Finalize 方法),它返回 false。
这些是 C# 生成的后备方法:
| C# 构造 | 成员类型 | IL 中的方法 |
|---|---|---|
| 属性 | Property |
get_*XXX* 和 set_*XXX* |
| 索引器 | Property |
get_Item 和 set_Item |
| 事件 | Event |
add_*XXX* 和 remove_*XXX* |
每个后备方法都有其自己关联的 MethodInfo 对象。你可以按以下方式访问它们:
PropertyInfo pi = typeof (Console).GetProperty ("Title");
MethodInfo getter = pi.GetGetMethod(); // get_Title
MethodInfo setter = pi.GetSetMethod(); // set_Title
MethodInfo[] both = pi.GetAccessors(); // Length==2
GetAddMethod 和 GetRemoveMethod 为 EventInfo 执行类似的工作。
要从 MethodInfo 到其关联的 PropertyInfo 或 EventInfo 实现反向操作,你需要执行一个查询。LINQ 是这项工作的理想选择:
PropertyInfo p = mi.DeclaringType.GetProperties()
.First (x => x.GetAccessors (true).Contains (mi));
只读初始化属性
C# 9 中引入的只读属性可以通过对象初始化器设置,但编译器会将其后续视为只读。从 CLR 的角度看,init 访问器就像是普通的 set 访问器,但 set 方法的返回类型上有一个特殊的标志(这对编译器意味着一些东西)。
有趣的是,这个标志并没有编码为一个约定属性。相反,它使用了一个相对隐蔽的机制称为 modreq,这确保了早期版本的 C# 编译器(不识别新的 modreq)会忽略访问器,而不是将属性视为可写。
只读访问器的 modreq 称为 IsExternalInit,你可以按如下方式查询它:
bool IsInitOnly (PropertyInfo pi) => pi
.GetSetMethod().ReturnParameter.GetRequiredCustomModifiers()
.Any (t => t.Name == "IsExternalInit");
NullabilityInfoContext
从.NET 6 开始,你可以使用NullabilityInfoContext类获取有关字段、属性、事件或参数的 nullability 注释的信息:
void PrintPropertyNullability (PropertyInfo pi)
{
var info = new NullabilityInfoContext().Create (pi);
Console.WriteLine (pi.Name + " read " + info.ReadState);
Console.WriteLine (pi.Name + " write " + info.WriteState);
// Use info.Element to get nullability info for array elements
}
泛型类型成员
你可以获取未绑定和闭合泛型类型的成员元数据:
PropertyInfo unbound = typeof (IEnumerator<>) .GetProperty ("Current");
PropertyInfo closed = typeof (IEnumerator<int>).GetProperty ("Current");
Console.WriteLine (unbound); // T Current
Console.WriteLine (closed); // Int32 Current
Console.WriteLine (unbound.PropertyType.IsGenericParameter); // True
Console.WriteLine (closed.PropertyType.IsGenericParameter); // False
从未绑定和闭合泛型类型返回的MemberInfo对象始终是不同的,即使对于不包含泛型类型参数签名的成员也是如此:
PropertyInfo unbound = typeof (List<>) .GetProperty ("Count");
PropertyInfo closed = typeof (List<int>).GetProperty ("Count");
Console.WriteLine (unbound); // Int32 Count
Console.WriteLine (closed); // Int32 Count
Console.WriteLine (unbound == closed); // False
Console.WriteLine (unbound.DeclaringType.IsGenericTypeDefinition); // True
Console.WriteLine (closed.DeclaringType.IsGenericTypeDefinition); // False
未绑定泛型类型的成员无法动态调用。
动态调用成员
注意
可以通过Uncapsulator 开源库(在 NuGet 和 GitHub 上可用)更轻松地实现动态调用成员。Uncapsulator 是作者编写的,提供了一个流畅的 API,通过反射使用自定义动态绑定器调用公共和非公共成员。
当你拥有MethodInfo、PropertyInfo或FieldInfo对象后,你可以动态调用它或获取/设置其值。这被称为后期绑定,因为你在运行时选择要调用的成员,而不是在编译时。
为了说明,以下使用普通的静态绑定:
string s = "Hello";
int length = s.Length;
下面是使用后期绑定动态执行的相同操作:
object s = "Hello";
PropertyInfo prop = s.GetType().GetProperty ("Length");
int length = (int) prop.GetValue (s, null); // 5
GetValue和SetValue获取和设置PropertyInfo或FieldInfo的值。第一个参数是实例,对于静态成员可以是null。访问索引器就像访问名为“Item”的属性一样,只是在调用GetValue或SetValue时,你需要将索引值作为第二个参数提供。
要动态调用方法,请在MethodInfo上调用Invoke,并提供一个参数数组传递给该方法。如果你传递的参数类型有误,运行时会抛出异常。使用动态调用,你失去了编译时类型安全性,但仍具有运行时类型安全性(就像使用dynamic关键字一样)。
方法参数
假设我们想要动态调用string的Substring方法。静态情况下,我们可以这样做:
Console.WriteLine ("stamp".Substring(2)); // "amp"
下面是使用反射和后期绑定的动态等效代码:
Type type = typeof (string);
Type[] parameterTypes = { typeof (int) };
MethodInfo method = type.GetMethod ("Substring", parameterTypes);
object[] arguments = { 2 };
object returnValue = method.Invoke ("stamp", arguments);
Console.WriteLine (returnValue); // "amp"
因为Substring方法是重载的,所以我们必须在GetMethod中传递参数类型的数组,以指示我们想要哪个版本。如果没有传递参数类型,GetMethod会抛出AmbiguousMatchException。
在MethodBase(MethodInfo和ConstructorInfo的基类)上定义的GetParameters方法返回参数元数据。我们可以继续我们之前的例子,如下所示:
ParameterInfo[] paramList = method.GetParameters();
foreach (ParameterInfo x in paramList)
{
Console.WriteLine (x.Name); // startIndex
Console.WriteLine (x.ParameterType); // System.Int32
}
处理ref和out参数
要传递ref或out参数,请在获取方法之前在类型上调用MakeByRefType。例如,你可以动态执行这段代码:
int x;
bool successfulParse = int.TryParse ("23", out x);
如下:
object[] args = { "23", 0 };
Type[] argTypes = { typeof (string), typeof (int).MakeByRefType() };
MethodInfo tryParse = typeof (int).GetMethod ("TryParse", argTypes);
bool successfulParse = (bool) tryParse.Invoke (null, args);
Console.WriteLine (successfulParse + " " + args[1]); // True 23
此方法对ref和out参数类型都适用。
检索和调用泛型方法
在调用GetMethod时显式指定参数类型可能在消除重载方法歧义时至关重要。但是,无法指定泛型参数类型。例如,请考虑System.Linq.Enumerable类,它重载了Where方法,如下所示:
public static IEnumerable<TSource> Where<TSource>
(this IEnumerable<TSource> source, Func<TSource, bool> predicate);
public static IEnumerable<TSource> Where<TSource>
(this IEnumerable<TSource> source, Func<TSource, int, bool> predicate);
要检索特定的重载,我们必须检索所有方法,然后手动找到所需的重载。以下查询检索Where的前一个重载:
from m in typeof (Enumerable).GetMethods()
where m.Name == "Where" && m.IsGenericMethod
let parameters = m.GetParameters()
where parameters.Length == 2
let genArg = m.GetGenericArguments().First()
let enumerableOfT = typeof (IEnumerable<>).MakeGenericType (genArg)
let funcOfTBool = typeof (Func<,>).MakeGenericType (genArg, typeof (bool))
where parameters[0].ParameterType == enumerableOfT
&& parameters[1].ParameterType == funcOfTBool
select m
在此查询上调用.Single()会给出正确的MethodInfo对象,带有未绑定的类型参数。下一步是通过调用MakeGenericMethod关闭类型参数:
var closedMethod = unboundMethod.MakeGenericMethod (typeof (int));
在这种情况下,我们用int关闭了TSource,允许我们使用类型为IEnumerable<int>的源和类型为Func<int,bool>的谓词调用Enumerable.Where:
int[] source = { 3, 4, 5, 6, 7, 8 };
Func<int, bool> predicate = n => n % 2 == 1; // Odd numbers only
现在我们可以调用闭合泛型方法了:
var query = (IEnumerable<int>) closedMethod.Invoke
(null, new object[] { source, predicate });
foreach (int element in query) Console.Write (element + "|"); // 3|5|7|
注意
如果您使用System.Linq.Expressions API 动态构建表达式(第八章),您无需费力指定泛型方法。Expression.Call方法被重载,允许您指定要调用的方法的闭合类型参数:
int[] source = { 3, 4, 5, 6, 7, 8 };
Func<int, bool> predicate = n => n % 2 == 1;
var sourceExpr = Expression.Constant (source);
var predicateExpr = Expression.Constant (predicate);
var callExpression = Expression.Call (
typeof (Enumerable), "Where",
new[] { typeof (int) }, // Closed generic arg type.
sourceExpr, predicateExpr);
使用委托提升性能
动态调用通常效率较低,开销通常在几微秒左右。如果你在循环中重复调用方法,可以通过调用一个动态实例化的委托来将每次调用的开销降低到纳秒级别。在下面的例子中,我们动态调用string的Trim方法一百万次,没有显著的开销:
MethodInfo trimMethod = typeof (string).GetMethod ("Trim", new Type[0]);
var trim = (StringToString) Delegate.CreateDelegate
(typeof (StringToString), trimMethod);
for (int i = 0; i < 1000000; i++)
trim ("test");
delegate string StringToString (string s);
这样做更快,因为昂贵的后期绑定(如粗体所示)只发生一次。
访问非公共成员
所有用于探测元数据的类型的方法(例如GetProperty,GetField等)都有接受BindingFlags枚举的重载。此枚举用作元数据过滤器,并允许您更改默认选择标准。最常见的用法是检索非公共成员(仅适用于桌面应用程序)。
例如,考虑以下类:
class Walnut
{
private bool cracked;
public void Crack() { cracked = true; }
public override string ToString() { return cracked.ToString(); }
}
我们可以打开核桃,如下所示:
Type t = typeof (Walnut);
Walnut w = new Walnut();
w.Crack();
FieldInfo f = t.GetField ("cracked", BindingFlags.NonPublic |
BindingFlags.Instance);
f.SetValue (w, false);
Console.WriteLine (w); // False
使用反射访问非公共成员非常强大,但也很危险,因为它可以绕过封装,创建对类型内部实现的难以管理的依赖。
BindingFlags 枚举
BindingFlags旨在进行按位组合。为了获得任何匹配项,您需要从以下四种组合中选择一种起始组合:
BindingFlags.Public | BindingFlags.Instance
BindingFlags.Public | BindingFlags.Static
BindingFlags.NonPublic | BindingFlags.Instance
BindingFlags.NonPublic | BindingFlags.Static
NonPublic包括internal,protected,protected internal和private。
以下示例检索类型为object的所有公共静态成员:
BindingFlags publicStatic = BindingFlags.Public | BindingFlags.Static;
MemberInfo[] members = typeof (object).GetMembers (publicStatic);
以下示例检索类型为object的所有非公共成员,包括静态和实例:
BindingFlags nonPublicBinding =
BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Instance;
MemberInfo[] members = typeof (object).GetMembers (nonPublicBinding);
DeclaredOnly标志排除了从基类型继承的函数,除非它们被重写。
注意
DeclaredOnly标志有些令人困惑,因为它限制结果集(而所有其他绑定标志都扩展结果集)。
泛型方法
你不能直接调用泛型方法;以下代码会抛出异常:
class Program
{
public static T Echo<T> (T x) { return x; }
static void Main()
{
MethodInfo echo = typeof (Program).GetMethod ("Echo");
Console.WriteLine (echo.IsGenericMethodDefinition); // True
echo.Invoke (null, new object[] { 123 } ); // *Exception*
}
}
需要额外的一步,即在MethodInfo上调用MakeGenericMethod,指定具体的泛型类型参数。这将返回另一个MethodInfo,然后可以像以下这样调用它:
MethodInfo echo = typeof (Program).GetMethod ("Echo");
MethodInfo intEcho = echo.MakeGenericMethod (typeof (int));
Console.WriteLine (intEcho.IsGenericMethodDefinition); // False
Console.WriteLine (intEcho.Invoke (null, new object[] { 3 } )); // 3
匿名调用泛型接口的成员
当你需要调用泛型接口的成员但直到运行时才知道类型参数时,反射是很有用的。理论上,如果类型设计完美,很少需要这样做;当然,类型并不总是设计得完美。
比如,假设我们想要编写一个更强大的ToString版本,可以扩展 LINQ 查询的结果。我们可以这样开始:
public static string ToStringEx <T> (IEnumerable<T> sequence)
{
...
}
这已经相当有限了。如果sequence包含我们还想要枚举的嵌套集合怎么办?我们需要重载方法来应对:
public static string ToStringEx <T> (IEnumerable<IEnumerable<T>> sequence)
然后如果sequence包含分组或嵌套序列的投影怎么办?方法重载的静态解决方案变得不切实际,我们需要一种能够处理任意对象图的方法,例如下面的方法:
public static string ToStringEx (object value)
{
if (value == null) return "<null>";
StringBuilder sb = new StringBuilder();
if (value is List<>) // Error
sb.Append ("List of " + ((List<>) value).Count + " items"); // Error
if (value is IGrouping<,>) // Error
sb.Append ("Group with key=" + ((IGrouping<,>) value).Key); // Error
// Enumerate collection elements if this is a collection,
// recursively calling ToStringEx()
// ...
return sb.ToString();
}
不幸的是,这段代码无法编译:你不能调用未绑定泛型类型如List<>或IGrouping<>的成员。对于List<>,我们可以通过使用非泛型的IList接口来解决这个问题:
if (value is IList)
sb.AppendLine ("A list with " + ((IList) value).Count + " items");
注意
我们能够这样做是因为List<>的设计者有远见,实现了经典的IList(以及泛型的IList)。在编写自己的泛型类型时,考虑到消费者可以依赖的非泛型接口或基类可能非常有价值。
对于IGrouping<,>来说解决方案就没有那么简单。这是接口的定义方式:
public interface IGrouping <TKey,TElement> : IEnumerable <TElement>,
IEnumerable
{
TKey Key { get; }
}
没有非泛型类型可以用来访问Key属性,因此我们必须在这里使用反射。解决方案不是调用未绑定泛型类型的成员(这是不可能的),而是调用封闭泛型类型的成员,在运行时确定其类型参数。
注意
在接下来的章节中,我们将使用 C#的dynamic关键字更简单地解决这个问题。动态绑定的一个很好的指示是当你需要进行类型操作时,就像我们现在正在做的一样。
第一步是确定value是否实现了IGrouping<,>,如果是,则获取其封闭的泛型接口。我们可以通过执行 LINQ 查询来最简单地完成这个任务。然后,我们检索并调用Key属性:
public static string ToStringEx (object value)
{
if (value == null) return "<null>";
if (value.GetType().IsPrimitive) return value.ToString();
StringBuilder sb = new StringBuilder();
if (value is IList)
sb.Append ("List of " + ((IList)value).Count + " items: ");
Type closedIGrouping = value.GetType().GetInterfaces()
.Where (t => t.IsGenericType &&
t.GetGenericTypeDefinition() == typeof (IGrouping<,>))
.FirstOrDefault();
if (closedIGrouping != null) // Call the Key property on IGrouping<,>
{
PropertyInfo pi = closedIGrouping.GetProperty ("Key");
object key = pi.GetValue (value, null);
sb.Append ("Group with key=" + key + ": ");
}
if (value is IEnumerable)
foreach (object element in ((IEnumerable)value))
sb.Append (ToStringEx (element) + " ");
if (sb.Length == 0) sb.Append (value.ToString());
return "\r\n" + sb.ToString();
}
这种方法非常强大:无论IGrouping<,>是隐式实现还是显式实现,都能正常工作。以下演示了这种方法:
Console.WriteLine (ToStringEx (new List<int> { 5, 6, 7 } ));
Console.WriteLine (ToStringEx ("xyyzzz".GroupBy (c => c) ));
*List of 3 items: 5 6 7*
*Group with key=x: x*
*Group with key=y: y y*
*Group with key=z: z z z*
调用静态虚/抽象接口成员
从.NET 7 和 C# 11 开始,接口可以定义静态虚拟和抽象成员(参见“静态虚拟/抽象接口成员”)。一个例子是.NET 中的IParsable<TSelf>接口:
public interface IParsable<TSelf> where TSelf : IParsable<TSelf>
{
static abstract TSelf Parse (string s, IFormatProvider provider);
...
}
通过受约束的类型参数,可以多态地调用静态抽象接口成员:
T ParseAny<T> (string s) where T : IParsable<T> => T.Parse (s, null);
要通过反射调用静态抽象接口成员,你必须从实现该接口的具体类型中获取MethodInfo,而不是从接口本身获取。显而易见的解决方案是通过签名检索具体成员:
MethodInfo GetParseMethod (Type concreteType) =>
concreteType.GetMethod ("Parse",
new[] { typeof (string), typeof (IFormatProvider) });
然而,如果成员是显式实现的,则会失败。为了以通用方式解决这个问题,我们将首先编写一个函数,用于在实现指定接口方法的具体类型上检索MethodInfo:
MethodInfo GetImplementedInterfaceMethod (Type concreteType,
Type interfaceType, string methodName, Type[] paramTypes)
{
var map = concreteType.GetInterfaceMap (interfaceType);
return map.InterfaceMethods
.Zip (map.TargetMethods)
.Single (m => m.First.Name == methodName &&
m.First.GetParameters().Select (p => p.ParameterType)
.SequenceEqual (paramTypes))
.Second;
}
使其工作的关键是调用GetInterfaceMap。这个方法返回以下结构体:
public struct InterfaceMapping
{
public MethodInfo[] InterfaceMethods; // These arrays each
public MethodInfo[] TargetMethods; // have the same length.
...
}
这个结构告诉我们,实现的接口成员(InterfaceMethods)如何映射到具体类型的成员(TargetMethods)。
注意
GetInterfaceMap也适用于普通(实例)方法;在处理静态抽象接口成员时特别有用。
我们接着使用 LINQ 的Zip方法来对齐这两个数组中的元素,从而可以轻松地获取与所需签名的接口方法对应的目标方法。
现在我们可以利用这一点来编写基于反射的ParseAny方法:
object ParseAny (Type type, string value)
{
MethodInfo parseMethod = GetImplementedInterfaceMethod (type,
type.GetInterface ("IParsable`1"),
"Parse",
new[] { typeof (string), typeof (IFormatProvider) });
return parseMethod.Invoke (null, new[] { value, null });
}
Console.WriteLine (ParseAny (typeof (float), ".2")); // 0.2
在调用GetImplementedInterfaceMethod时,我们需要提供(封闭的)接口类型,我们通过在具体类型上调用GetInterface("IParsable`1")来获取它。鉴于在编译时我们知道所需的接口,我们可以改用以下表达式:
typeof (IParsable<>).MakeGenericType (type)
反射程序集
你可以通过在Assembly对象上调用GetType或GetTypes来动态反射一个程序集。以下从当前程序集中检索名为Demos命名空间下的TestProgram类型:
Type t = Assembly.GetExecutingAssembly().GetType ("Demos.TestProgram");
你也可以从现有类型获取程序集:
typeof (Foo).Assembly.GetType ("Demos.TestProgram");
下一个示例列出了位于e:\demo下的mylib.dll程序集中的所有类型:
Assembly a = Assembly.LoadFile (@"e:\demo\mylib.dll");
foreach (Type t in a.GetTypes())
Console.WriteLine (t);
或者:
Assembly a = typeof (Foo).GetTypeInfo().Assembly;
foreach (Type t in a.ExportedTypes)
Console.WriteLine (t);
GetTypes和ExportedTypes仅返回顶层类型,而不是嵌套类型。
模块
在多模块程序集上调用GetTypes会返回所有模块中的所有类型。因此,你可以忽略模块的存在,并将程序集视为类型的容器。不过,在处理元数据标记时,有一个情况是模块是相关的。
元数据标记是一个整数,唯一地引用模块范围内的类型、成员、字符串或资源。IL 使用元数据标记,因此如果你在解析 IL,你需要能够解析它们。执行此操作的方法在Module类型中定义,称为ResolveType、ResolveMember、ResolveString和ResolveSignature。我们将在本章的最后一节,关于编写反汇编器时重新讨论这一点。
通过调用 GetModules,你可以获取程序集中所有模块的列表。你也可以通过其 ManifestModule 属性直接访问程序集的主模块。
使用属性工作
CLR 允许通过属性将附加的元数据附加到类型、成员和程序集上。这是某些重要 CLR 功能(如程序集标识或为本机互操作而编组类型)的指导机制,使属性成为应用程序不可分割的一部分。
属性的一个关键特性是,你可以编写自己的属性,然后像使用任何其他属性一样“装饰”代码元素,以提供额外信息。这些额外信息编译到底层程序集中,并且可以通过反射在运行时检索,用于构建声明性工作的服务,如自动化单元测试。
属性基础知识
有三种类型的属性:
-
位映射属性
-
自定义属性
-
伪自定义属性
其中,只有 自定义属性 是可扩展的。
注意
术语“属性”本身可以指任何三种属性之一,尽管在 C# 中,它通常指自定义属性或伪自定义属性。
位映射属性(我们的术语)映射到类型元数据中的专用位。大多数 C# 的修饰符关键字,如 public、abstract 和 sealed,编译为位映射属性。这些属性非常高效,因为它们在元数据中消耗的空间很小(通常只有一个位),CLR 可以通过很少或没有间接寻址来定位它们。反射 API 通过 Type(和其他 MemberInfo 子类)的专用属性(如 IsPublic、IsAbstract 和 IsSealed)公开它们。Attributes 属性以标志枚举的形式一次性描述它们中的大多数:
static void Main()
{
TypeAttributes ta = typeof (Console).Attributes;
MethodAttributes ma = MethodInfo.GetCurrentMethod().Attributes;
Console.WriteLine (ta + "\r\n" + ma);
}
这里是结果:
AutoLayout, AnsiClass, Class, Public, Abstract, Sealed, BeforeFieldInit
PrivateScope, Private, Static, HideBySig
相比之下,自定义属性 编译成附加在类型主要元数据表上的一个 blob。所有自定义属性都由 System.Attribute 的子类表示,并且与位映射属性不同,它们是可扩展的。元数据中的 blob 标识属性类,并存储了应用属性时指定的任何位置参数或命名参数的值。你自己定义的自定义属性在架构上与 .NET 库中定义的属性完全相同。
第四章 描述了如何在 C# 中将自定义属性附加到类型或成员上。在这里,我们将预定义的 Obsolete 属性附加到 Foo 类上:
[Obsolete] public class Foo {...}
这指示编译器将 ObsoleteAttribute 的一个实例合并到 Foo 的元数据中,然后可以通过在 Type 或 MemberInfo 对象上调用 GetCustomAttributes 在运行时反映它。
伪自定义属性 在外观和感觉上与标准自定义属性完全相同。它们由 System.Attribute 的子类表示,并且以标准方式附加:
[System.Runtime.InteropServices.StructLayout(LayoutKind.Sequential)]
class SystemTime { ... }
区别在于编译器或 CLR 通过将伪自定义属性转换为位图属性来进行内部优化。示例包括StructLayout、In和Out(第二十四章)。反射通过专用属性(如IsLayoutSequential)公开伪自定义属性,并且在调用GetCustomAttributes时,在许多情况下它们也作为System.Attribute对象返回。这意味着您几乎可以忽略伪自定义属性和非伪自定义属性之间的区别(一个值得注意的例外是在使用Reflection.Emit在运行时动态生成类型时;请参阅“发出程序集和类型”)。
AttributeUsage 属性
AttributeUsage是应用于属性类的属性。它指示编译器如何使用目标属性:
public sealed class AttributeUsageAttribute : Attribute
{
public AttributeUsageAttribute (AttributeTargets validOn);
public bool AllowMultiple { get; set; }
public bool Inherited { get; set; }
public AttributeTargets ValidOn { get; }
}
AllowMultiple控制正在定义的属性是否可以多次应用于相同的目标;Inherited控制应用于基类的属性是否也应用于派生类(或在方法的情况下,应用于虚方法的属性是否也应用于覆盖方法)。ValidOn确定属性可以附加到的目标集(类、接口、属性、方法、参数等)。它接受AttributeTargets枚举的任何值组合,该枚举具有以下成员:
All |
Delegate |
GenericParameter |
Parameter |
|---|---|---|---|
Assembly |
Enum |
Interface |
Property |
Class |
Event |
Method |
ReturnValue |
Constructor |
Field |
Module |
Struct |
为了说明,这里是.NET 的作者如何将AttributeUsage应用于Serializable属性:
[AttributeUsage (AttributeTargets.Delegate |
AttributeTargets.Enum |
AttributeTargets.Struct |
AttributeTargets.Class, Inherited = false)
]
public sealed class SerializableAttribute : Attribute { }
实际上,这几乎是Serializable属性的完整定义。编写一个没有属性或特殊构造函数的属性类就是这么简单。
定义您自己的属性
下面是如何编写自己的属性:
-
从
System.Attribute或System.Attribute的后代类派生一个类。按照惯例,类名应该以单词“Attribute”结尾,尽管这不是必需的。 -
应用在前一节中描述的
AttributeUsage属性。如果属性在其构造函数中不需要任何属性或参数,则工作完成。
-
编写一个或多个公共构造函数。构造函数的参数定义了属性的位置参数,并在使用属性时将变为必需。
-
为您希望支持的每个命名参数声明一个公共字段或属性。在使用属性时,命名参数是可选的。
注意
属性属性和构造函数参数必须是以下类型之一:
-
一个封闭的原始类型:换句话说,
bool、byte、char、double、float、int、long、short或string -
Type类型 -
一个枚举类型
-
这些中的任何一个一维数组
当应用属性时,编译器还必须能够静态评估每个属性或构造函数参数。
以下类定义了一个属性,用于辅助自动化单元测试系统。它指示应测试的方法、测试重复次数以及在失败时的消息:
[AttributeUsage (AttributeTargets.Method)]
public sealed class TestAttribute : Attribute
{
public int Repetitions;
public string FailureMessage;
public TestAttribute () : this (1) { }
public TestAttribute (int repetitions) { Repetitions = repetitions; }
}
这里是一个带有各种方式装饰Test属性的Foo类的方法:
class Foo
{
[Test]
public void Method1() { ... }
[Test(20)]
public void Method2() { ... }
[Test(20, FailureMessage="Debugging Time!")]
public void Method3() { ... }
}
在运行时检索属性
有两种标准方法在运行时检索属性:
-
在任何
Type或MemberInfo对象上调用GetCustomAttributes -
调用
Attribute.GetCustomAttribute或Attribute.GetCustomAttributes
后两种方法重载以接受与有效属性目标对应的任何反射对象(Type、Assembly、Module、MemberInfo或ParameterInfo)。
注意
您还可以在类型或成员上调用GetCustomAttributes**Data**()来获取属性信息。与GetCustomAttributes()的区别在于前者让您了解属性如何实例化:它报告了使用的构造函数重载以及每个构造函数参数和命名参数的值。这在您希望发出代码或 IL 以重建属性到相同状态时非常有用(参见“发出类型成员”)。
下面是如何枚举先前的Foo类中具有TestAttribute的每个方法:
foreach (MethodInfo mi in typeof (Foo).GetMethods())
{
TestAttribute att = (TestAttribute) Attribute.GetCustomAttribute
(mi, typeof (TestAttribute));
if (att != null)
Console.WriteLine ("Method {0} will be tested; reps={1}; msg={2}",
mi.Name, att.Repetitions, att.FailureMessage);
}
或:
foreach (MethodInfo mi in typeof (Foo).GetTypeInfo().DeclaredMethods)
...
这是输出:
Method Method1 will be tested; reps=1; msg=
Method Method2 will be tested; reps=20; msg=
Method Method3 will be tested; reps=20; msg=Debugging Time!
为了完成说明,展示如何使用此方法编写单元测试系统,以下是扩展示例,实际调用装饰有Test属性的方法:
foreach (MethodInfo mi in typeof (Foo).GetMethods())
{
TestAttribute att = (TestAttribute) Attribute.GetCustomAttribute
(mi, typeof (TestAttribute));
if (att != null)
for (int i = 0; i < att.Repetitions; i++)
try
{
mi.Invoke (new Foo(), null); // Call method with no arguments
}
catch (Exception ex) // Wrap exception in att.FailureMessage
{
throw new Exception ("Error: " + att.FailureMessage, ex);
}
}
返回到属性反射,这里有一个示例列出了特定类型上存在的属性:
object[] atts = Attribute.GetCustomAttributes (typeof (Test));
foreach (object att in atts) Console.WriteLine (att);
[Serializable, Obsolete]
class Test
{
}
这里是输出:
System.ObsoleteAttribute
System.SerializableAttribute
动态代码生成
System.Reflection.Emit命名空间包含用于在运行时创建元数据和 IL 的类。动态生成代码对于某些类型的编程任务很有用。例如,正则表达式 API 会发出针对特定正则表达式进行调优的高性能类型。另一个例子是 Entity Framework Core,它使用Reflection.Emit生成代理类以实现延迟加载。
使用DynamicMethod生成 IL
DynamicMethod类是System.Reflection.Emit命名空间中用于动态生成方法的轻量级工具。与TypeBuilder不同,它不需要您首先设置动态程序集、模块和类型来包含方法。这使它适用于简单的任务,同时也是Reflection.Emit的很好入门。
注意
当不再引用DynamicMethod及其关联的 IL 时,它们会被垃圾回收。这意味着您可以重复生成动态方法,而不会填满内存。(要在动态程序集中执行相同操作,创建程序集时必须应用AssemblyBuilderAccess.RunAndCollect标志。)
下面是使用 DynamicMethod 创建一个向控制台输出 Hello world 的方法的简单示例:
public class Test
{
static void Main()
{
var dynMeth = new DynamicMethod ("Foo", null, null, typeof (Test));
ILGenerator gen = dynMeth.GetILGenerator();
gen.EmitWriteLine ("Hello world");
gen.Emit (OpCodes.Ret);
dynMeth.Invoke (null, null); // Hello world
}
}
OpCodes 拥有每个 IL 操作码的静态只读字段。大多数功能通过各种操作码公开,尽管 ILGenerator 还有专门用于生成标签和本地变量以及异常处理的方法。方法总是以 OpCodes.Ret 结束,这意味着“返回”,或者某种分支/抛出指令。ILGenerator 上的 EmitWriteLine 方法是 Emit 低级操作码的一种快捷方式。如果我们用下面的代码替换对 EmitWriteLine 的调用,将获得相同的结果:
MethodInfo writeLineStr = typeof (Console).GetMethod ("WriteLine",
new Type[] { typeof (string) });
gen.Emit (OpCodes.Ldstr, "Hello world"); // Load a string
gen.Emit (OpCodes.Call, writeLineStr); // Call a method
注意我们将 typeof(Test) 传递给 DynamicMethod 构造函数。这使得动态方法可以访问该类型的非公共方法,从而使我们可以执行如下操作:
public class Test
{
static void Main()
{
var dynMeth = new DynamicMethod ("Foo", null, null, typeof (Test));
ILGenerator gen = dynMeth.GetILGenerator();
MethodInfo privateMethod = typeof(Test).GetMethod ("HelloWorld",
BindingFlags.Static | BindingFlags.NonPublic);
gen.Emit (OpCodes.Call, privateMethod); // Call HelloWorld
gen.Emit (OpCodes.Ret);
dynMeth.Invoke (null, null); // Hello world
}
static void HelloWorld() // private method, yet we can call it
{
Console.WriteLine ("Hello world");
}
}
理解 IL 需要大量时间投资。与其理解所有操作码,不如先编译一个 C# 程序,然后检查、复制和调整 IL 更为简单。LINQPad 可以显示您键入的任何方法或代码片段的 IL,而诸如 ILSpy 的程序集查看工具对于检查现有程序集非常有用。
评估堆栈
IL 的核心概念是评估堆栈。要调用带有参数的方法,首先将参数推送(“加载”)到评估堆栈上,然后调用方法。方法然后从评估堆栈弹出其需要的参数。我们先前在调用 Console.WriteLine 中演示了这一点。以下是一个类似的例子,调用一个整数:
var dynMeth = new DynamicMethod ("Foo", null, null, typeof(void));
ILGenerator gen = dynMeth.GetILGenerator();
MethodInfo writeLineInt = typeof (Console).GetMethod ("WriteLine",
new Type[] { typeof (int) });
// The Ldc* op-codes load numeric literals of various types and sizes.
gen.Emit (OpCodes.Ldc_I4, 123); // Push a 4-byte integer onto stack
gen.Emit (OpCodes.Call, writeLineInt);
gen.Emit (OpCodes.Ret);
dynMeth.Invoke (null, null); // 123
要将两个数字相加,首先将每个数字加载到评估堆栈上,然后调用 Add。Add 操作码从评估堆栈弹出两个值并将结果推送回去。以下代码将 2 和 2 相加,然后使用之前获取的 writeLine 方法写入结果:
gen.Emit (OpCodes.Ldc_I4, 2); // Push a 4-byte integer, value=2
gen.Emit (OpCodes.Ldc_I4, 2); // Push a 4-byte integer, value=2
gen.Emit (OpCodes.Add); // Add the result together
gen.Emit (OpCodes.Call, writeLineInt);
要计算 10 / 2 + 1,可以选择下面任意一种方法:
gen.Emit (OpCodes.Ldc_I4, 10);
gen.Emit (OpCodes.Ldc_I4, 2);
gen.Emit (OpCodes.Div);
gen.Emit (OpCodes.Ldc_I4, 1);
gen.Emit (OpCodes.Add);
gen.Emit (OpCodes.Call, writeLineInt);
或者这样:
gen.Emit (OpCodes.Ldc_I4, 1);
gen.Emit (OpCodes.Ldc_I4, 10);
gen.Emit (OpCodes.Ldc_I4, 2);
gen.Emit (OpCodes.Div);
gen.Emit (OpCodes.Add);
gen.Emit (OpCodes.Call, writeLineInt);
向动态方法传递参数
Ldarg 和 Ldarg_*XXX* 操作码将传递给方法的参数加载到堆栈上。为了返回一个值,在完成时确保堆栈上恰好有一个值。为了使其工作,调用 DynamicMethod 构造函数时必须指定返回类型和参数类型。以下创建了一个动态方法,返回两个整数的和:
DynamicMethod dynMeth = new DynamicMethod ("Foo",
typeof (int), // Return type = int
new[] { typeof (int), typeof (int) }, // Parameter types = int, int
typeof (void));
ILGenerator gen = dynMeth.GetILGenerator();
gen.Emit (OpCodes.Ldarg_0); // Push first arg onto eval stack
gen.Emit (OpCodes.Ldarg_1); // Push second arg onto eval stack
gen.Emit (OpCodes.Add); // Add them together (result on stack)
gen.Emit (OpCodes.Ret); // Return with stack having 1 value
int result = (int) dynMeth.Invoke (null, new object[] { 3, 4 } ); // 7
警告
当退出时,评估堆栈必须恰好有 0 或 1 个项(取决于方法是否返回值)。如果违反此规则,CLR 将拒绝执行您的方法。通过发出 OpCodes.Pop 可以从堆栈中移除一个不处理的项。
与其调用 Invoke,使用动态方法作为类型化委托可能更方便。CreateDelegate 方法正是为此而设计。在我们的例子中,我们需要一个有两个整数参数和一个整数返回类型的委托。我们可以使用 Func<int, int, int> 委托来实现这一目的。因此,前面例子的最后一行变成了如下内容:
var func = (Func<int,int,int>) dynMeth.CreateDelegate
(typeof (Func<int,int,int>));
int result = func (3, 4); // 7
注意
代理还消除了动态方法调用的开销,每次调用可节省几微秒。
我们演示了如何在 “发出类型成员” 中通过引用传递。
生成本地变量
您可以通过在 ILGenerator 上调用 DeclareLocal 来声明一个局部变量。这会返回一个 LocalBuilder 对象,您可以将其与诸如 Ldloc(加载局部变量)或 Stloc(存储局部变量)的操作码结合使用。Ldloc 将堆栈推入评估堆栈;Stloc 弹出评估堆栈。例如,请考虑以下 C# 代码:
int x = 6;
int y = 7;
x *= y;
Console.WriteLine (x);
以下动态生成了前面的代码:
var dynMeth = new DynamicMethod ("Test", null, null, typeof (void));
ILGenerator gen = dynMeth.GetILGenerator();
LocalBuilder localX = gen.DeclareLocal (typeof (int)); // Declare x
LocalBuilder localY = gen.DeclareLocal (typeof (int)); // Declare y
gen.Emit (OpCodes.Ldc_I4, 6); // Push literal 6 onto eval stack
gen.Emit (OpCodes.Stloc, localX); // Store in localX
gen.Emit (OpCodes.Ldc_I4, 7); // Push literal 7 onto eval stack
gen.Emit (OpCodes.Stloc, localY); // Store in localY
gen.Emit (OpCodes.Ldloc, localX); // Push localX onto eval stack
gen.Emit (OpCodes.Ldloc, localY); // Push localY onto eval stack
gen.Emit (OpCodes.Mul); // Multiply values together
gen.Emit (OpCodes.Stloc, localX); // Store the result to localX
gen.EmitWriteLine (localX); // Write the value of localX
gen.Emit (OpCodes.Ret);
dynMeth.Invoke (null, null); // 42
分支
在 IL 中,没有 while、do 和 for 循环;所有操作都使用标签和等效的 goto 和条件 goto 语句完成。这些是分支操作码,如 Br(无条件跳转)、Brtrue(如果评估堆栈上的值为 true 跳转)、Blt(如果第一个值小于第二个值跳转)。
要设置分支目标,首先调用 DefineLabel(这会返回一个 Label 对象),然后在希望锚定标签的位置调用 MarkLabel。例如,请考虑以下 C# 代码:
int x = 5;
while (x <= 10) Console.WriteLine (x++);
我们可以这样发出它:
ILGenerator gen = *...*
Label startLoop = gen.DefineLabel(); // Declare labels
Label endLoop = gen.DefineLabel();
LocalBuilder x = gen.DeclareLocal (typeof (int)); // int x
gen.Emit (OpCodes.Ldc_I4, 5); //
gen.Emit (OpCodes.Stloc, x); // x = 5
gen.MarkLabel (startLoop);
gen.Emit (OpCodes.Ldc_I4, 10); // Load 10 onto eval stack
gen.Emit (OpCodes.Ldloc, x); // Load x onto eval stack
gen.Emit (OpCodes.Blt, endLoop); // if (x > 10) goto endLoop
gen.EmitWriteLine (x); // Console.WriteLine (x)
gen.Emit (OpCodes.Ldloc, x); // Load x onto eval stack
gen.Emit (OpCodes.Ldc_I4, 1); // Load 1 onto the stack
gen.Emit (OpCodes.Add); // Add them together
gen.Emit (OpCodes.Stloc, x); // Save result back to x
gen.Emit (OpCodes.Br, startLoop); // return to start of loop
gen.MarkLabel (endLoop);
gen.Emit (OpCodes.Ret);
实例化对象并调用实例方法
new 的 IL 等效指令是 Newobj 操作码。这需要一个构造函数,并将构造的对象加载到评估堆栈上。例如,以下代码构造了一个 StringBuilder:
var dynMeth = new DynamicMethod ("Test", null, null, typeof (void));
ILGenerator gen = dynMeth.GetILGenerator();
ConstructorInfo ci = typeof (StringBuilder).GetConstructor (new Type[0]);
gen.Emit (OpCodes.Newobj, ci);
在将对象加载到评估堆栈后,可以使用 Call 或 Callvirt 操作码调用对象的实例方法。延续这个例子,我们将通过调用属性的获取器查询 StringBuilder 的 MaxCapacity 属性,然后输出结果:
gen.Emit (OpCodes.Callvirt, typeof (StringBuilder)
.GetProperty ("MaxCapacity").GetGetMethod());
gen.Emit (OpCodes.Call, typeof (Console).GetMethod ("WriteLine",
new[] { typeof (int) } ));
gen.Emit (OpCodes.Ret);
dynMeth.Invoke (null, null); // 2147483647
模仿 C# 调用语义:
-
使用
Call调用静态方法和值类型实例方法。 -
使用
Callvirt调用引用类型实例方法(无论它们是否声明为虚拟的)。
在我们的例子中,我们在 StringBuilder 实例上使用了 Callvirt,即使 MaxProperty 不是虚拟的。这不会引发错误:它只是执行一个非虚拟调用。始终使用 Callvirt 调用引用类型实例方法可以避免出现相反的情况:使用 Call 调用虚拟方法。(风险是真实存在的。目标方法的作者可能稍后 更改 其声明。)Callvirt 还有一个好处,即检查接收者是否非空。
警告
使用 Call 调用虚方法会绕过虚拟调用语义并直接调用该方法。这很少是理想的,并且实际上违反了类型安全性。
在下面的示例中,我们构造了一个 StringBuilder,传入两个参数,向 StringBuilder 添加 ", world!",然后调用它的 ToString:
// We will call: new StringBuilder ("Hello", 1000)
ConstructorInfo ci = typeof (StringBuilder).GetConstructor (
new[] { typeof (string), typeof (int) } );
gen.Emit (OpCodes.Ldstr, "Hello"); // Load a string onto the eval stack
gen.Emit (OpCodes.Ldc_I4, 1000); // Load an int onto the eval stack
gen.Emit (OpCodes.Newobj, ci); // Construct the StringBuilder
Type[] strT = { typeof (string) };
gen.Emit (OpCodes.Ldstr, ", world!");
gen.Emit (OpCodes.Call, typeof (StringBuilder).GetMethod ("Append", strT));
gen.Emit (OpCodes.Callvirt, typeof (object).GetMethod ("ToString"));
gen.Emit (OpCodes.Call, typeof (Console).GetMethod ("WriteLine", strT));
gen.Emit (OpCodes.Ret);
dynMeth.Invoke (null, null); // Hello, world!
为了好玩,我们在 typeof(object) 上调用了 GetMethod,然后使用 Callvirt 执行了对 ToString 的虚方法调用。我们也可以直接在 StringBuilder 类型上调用 ToString 来获得相同的结果:
gen.Emit (OpCodes.Callvirt, typeof (StringBuilder).GetMethod ("ToString",
new Type[0] ));
(在调用GetMethod时需要空的类型数组,因为StringBuilder重载了另一个签名的ToString。)
注意
如果我们非虚拟地调用object的ToString方法:
gen.Emit (OpCodes.Call,
typeof (object).GetMethod ("ToString"));
结果将会是System.Text.StringBuilder。换句话说,我们将会绕过StringBuilder的ToString重写,并直接调用object的版本。
异常处理
ILGenerator为异常处理提供了专门的方法。因此,这段 C#代码的翻译:
try { throw new NotSupportedException(); }
catch (NotSupportedException ex) { Console.WriteLine (ex.Message); }
finally { Console.WriteLine ("Finally"); }
是这样的:
MethodInfo getMessageProp = typeof (NotSupportedException)
.GetProperty ("Message").GetGetMethod();
MethodInfo writeLineString = typeof (Console).GetMethod ("WriteLine",
new[] { typeof (object) } );
gen.BeginExceptionBlock();
ConstructorInfo ci = typeof (NotSupportedException).GetConstructor (
new Type[0] );
gen.Emit (OpCodes.Newobj, ci);
gen.Emit (OpCodes.Throw);
gen.BeginCatchBlock (typeof (NotSupportedException));
gen.Emit (OpCodes.Callvirt, getMessageProp);
gen.Emit (OpCodes.Call, writeLineString);
gen.BeginFinallyBlock();
gen.EmitWriteLine ("Finally");
gen.EndExceptionBlock();
就像在 C#中一样,你可以包含多个catch块。要重新抛出相同的异常,请发出Rethrow操作码。
警告
ILGenerator提供了一个名为ThrowException的辅助方法。然而,这个方法有一个 bug,阻止它与DynamicMethod一起使用。它仅适用于MethodBuilder(见下一节)。
发射程序集和类型
虽然DynamicMethod很方便,但它只能生成方法。如果你需要发射任何其他结构或者完整的类型,你需要使用完整的“重量级”API。这意味着动态构建一个程序集和模块。程序集不需要在磁盘上存在(实际上不能,因为.NET 5+和.NET Core 不允许将生成的程序集保存到磁盘)。
假设我们想要动态构建一个类型。因为类型必须存在于程序集的模块中,所以我们必须在创建类型之前先创建程序集和模块。这是AssemblyBuilder和ModuleBuilder类型的任务:
AssemblyName aname = new AssemblyName ("MyDynamicAssembly");
AssemblyBuilder assemBuilder =
AssemblyBuilder.DefineDynamicAssembly (aname, AssemblyBuilderAccess.Run);
ModuleBuilder modBuilder = assemBuilder.DefineDynamicModule ("DynModule");
注意
你不能向现有程序集中添加类型,因为一旦创建程序集,它就是不可变的。
动态程序集不会被垃圾回收,并且会一直留在内存中,直到进程结束,除非在定义程序集时指定了AssemblyBuilderAccess.RunAndCollect。对可收集程序集有各种限制(参见http://albahari.com/dynamiccollect)。
在我们有类型所在的模块之后,我们可以使用TypeBuilder来创建类型。以下定义了一个名为Widget的类:
TypeBuilder tb = modBuilder.DefineType ("Widget", TypeAttributes.Public);
TypeAttributes标志枚举支持 CLR 类型修饰符,你可以在使用ildasm反汇编类型时看到这些修饰符。除了成员可见性标志外,还包括类型修饰符如Abstract和Sealed,以及定义.NET 接口的Interface。它还包括Serializable,相当于在 C#中应用[Serializable]属性,以及Explicit,相当于应用[StructLayout(LayoutKind.Explicit)]。我们将在本章后面的“附加属性”中描述如何应用其他类型的属性,位于“Attaching Attributes”。
注意
DefineType方法还接受一个可选的基类型:
-
要定义一个结构体,请指定基类型为
System.ValueType。 -
要定义委托,请指定基类型为
System.MulticastDelegate。 -
要实现一个接口,使用接受接口类型数组的构造函数。
-
要定义一个接口,请指定
TypeAttributes.Interface | TypeAttributes.Abstract。
定义委托类型需要进行多个额外的步骤。在他的博客中,Joel Pobar 在标题为“通过 Reflection.Emit 创建委托类型”的文章中展示了如何实现。
我们现在可以在类型中创建成员:
MethodBuilder methBuilder = tb.DefineMethod ("SayHello",
MethodAttributes.Public,
null, null);
ILGenerator gen = methBuilder.GetILGenerator();
gen.EmitWriteLine ("Hello world");
gen.Emit (OpCodes.Ret);
我们现在准备创建该类型,从而最终定义它:
Type t = tb.CreateType();
创建类型后,我们可以使用普通的反射来检查和执行后期绑定:
object o = Activator.CreateInstance (t);
t.GetMethod ("SayHello").Invoke (o, null); // Hello world
Reflection.Emit 对象模型
图 18-2 展示了System.Reflection.Emit中的关键类型。每种类型描述了一个 CLR 构造,并基于System.Reflection命名空间中的对应项。这使得你可以在构建类型时使用发射的构造替代常规构造。例如,我们之前调用了Console.WriteLine,如下所示:
MethodInfo writeLine = typeof(Console).GetMethod ("WriteLine",
new Type[] { typeof (string) });
gen.Emit (OpCodes.Call, writeLine);
我们可以通过使用MethodBuilder而不是MethodInfo,通过调用gen.Emit来调用动态生成的方法,这是至关重要的——否则,你无法编写一个调用同一类型中另一个动态方法的动态方法。

图 18-2. System.Reflection.Emit
请记住,在完成填充TypeBuilder后,必须调用TypeBuilder上的CreateType。调用CreateType封闭了TypeBuilder及其所有成员——因此,不能再添加或更改任何内容——并返回一个真实的Type,你可以实例化它。
在调用CreateType之前,TypeBuilder及其成员处于“未创建”状态。对未创建的构造有显著的限制。特别地,你不能调用返回MemberInfo对象的任何成员,例如GetMembers、GetMethod或GetProperty——这些都会抛出异常。如果要引用未创建类型的成员,必须使用原始的发射:
TypeBuilder tb = ...
MethodBuilder method1 = tb.DefineMethod ("Method1", ...);
MethodBuilder method2 = tb.DefineMethod ("Method2", ...);
ILGenerator gen1 = method1.GetILGenerator();
// Suppose we want method1 to call method2:
gen1.Emit (OpCodes.Call, method2); // Right
gen1.Emit (OpCodes.Call, tb.GetMethod ("Method2")); // Wrong
在调用CreateType后,你不仅可以反射并激活返回的Type,还可以反射原始的TypeBuilder对象。事实上,TypeBuilder会变形为真实Type的代理。你将看到为什么此功能在“尴尬的发射目标”中很重要。
发射类型成员
本节中的所有示例假设已经实例化了TypeBuilder,如下所示:
AssemblyName aname = new AssemblyName ("MyEmissions");
AssemblyBuilder assemBuilder = AssemblyBuilder.DefineDynamicAssembly (
aname, AssemblyBuilderAccess.Run);
ModuleBuilder modBuilder = assemBuilder.DefineDynamicModule ("MainModule");
TypeBuilder tb = modBuilder.DefineType ("Widget", TypeAttributes.Public);
发射方法
调用DefineMethod时,可以像实例化DynamicMethod时一样指定返回类型和参数类型。例如,以下方法:
public static double SquareRoot (double value) => Math.Sqrt (value);
可以这样生成:
MethodBuilder mb = tb.DefineMethod ("SquareRoot",
MethodAttributes.Static | MethodAttributes.Public,
CallingConventions.Standard,
typeof (double), // Return type
new[] { typeof (double) } ); // Parameter types
mb.DefineParameter (1, ParameterAttributes.None, "value"); // Assign name
ILGenerator gen = mb.GetILGenerator();
gen.Emit (OpCodes.Ldarg_0); // Load 1st arg
gen.Emit (OpCodes.Call, typeof(Math).GetMethod ("Sqrt"));
gen.Emit (OpCodes.Ret);
Type realType = tb.CreateType();
double x = (double) tb.GetMethod ("SquareRoot").Invoke (null,
new object[] { 10.0 });
Console.WriteLine (x); // 3.16227766016838
调用DefineParameter是可选的,通常用于为参数分配名称。数字 1 表示第一个参数(0 表示返回值)。如果调用DefineParameter,则参数将隐式命名为__p1、__p2等。分配名称是有意义的,如果你将程序集写入磁盘,则使你的方法对消费者更友好。
注意
DefineParameter返回一个ParameterBuilder对象,你可以在其上调用SetCustomAttribute来附加属性(参见“附加属性”)。
要发出引用传递的参数,例如以下的 C#方法:
public static void SquareRoot (ref double value)
=> value = Math.Sqrt (value);
在参数类型上调用MakeByRefType:
MethodBuilder mb = tb.DefineMethod ("SquareRoot",
MethodAttributes.Static | MethodAttributes.Public,
CallingConventions.Standard,
null,
new Type[] { typeof (double).MakeByRefType() } );
mb.DefineParameter (1, ParameterAttributes.None, "value");
ILGenerator gen = mb.GetILGenerator();
gen.Emit (OpCodes.Ldarg_0);
gen.Emit (OpCodes.Ldarg_0);
gen.Emit (OpCodes.Ldind_R8);
gen.Emit (OpCodes.Call, typeof (Math).GetMethod ("Sqrt"));
gen.Emit (OpCodes.Stind_R8);
gen.Emit (OpCodes.Ret);
Type realType = tb.CreateType();
object[] args = { 10.0 };
tb.GetMethod ("SquareRoot").Invoke (null, args);
Console.WriteLine (args[0]); // 3.16227766016838
这里的操作码是从反汇编的 C#方法中复制的。注意访问通过引用传递的参数的语义差异:Ldind和Stind分别表示“间接加载”和“间接存储”。R8 后缀表示一个八字节的浮点数。
发出out参数的过程相同,只是你调用DefineParameter,如下所示:
mb.DefineParameter (1, ParameterAttributes.Out, "value");
生成实例方法
要生成一个实例方法,请在调用DefineMethod时指定MethodAttributes.Instance:
MethodBuilder mb = tb.DefineMethod ("SquareRoot",
MethodAttributes.Instance | MethodAttributes.Public
...
对于实例方法,参数零隐含为this;其余参数从 1 开始。因此,Ldarg_0将this加载到评估堆栈上;Ldarg_1加载第一个真实的方法参数。
覆盖方法
在基类中覆盖虚方法很简单:只需定义一个具有相同名称、签名和返回类型的方法,在调用DefineMethod时指定MethodAttributes.Virtual。在实现接口方法时也是如此。
TypeBuilder还暴露了一个名为DefineMethodOverride的方法,用于覆盖具有不同名称的方法。这仅在显式接口实现时有意义;在其他情况下,请使用DefineMethod。
HideBySig
如果你正在子类化另一个类型,在定义方法时几乎总是值得指定MethodAttributes.HideBySig。HideBySig确保应用 C#风格的方法隐藏语义,即只有在子类型定义具有相同签名的方法时,基方法才会被隐藏。没有HideBySig,方法隐藏仅考虑名称,因此子类型中的Foo(string)将隐藏基类型中的Foo(),这通常是不希望的。
发出字段和属性
要创建字段,你需要在TypeBuilder上调用DefineField,指定所需的字段名称、类型和可见性。以下创建了一个名为“length”的私有整数字段:
FieldBuilder field = tb.DefineField ("length", typeof (int),
FieldAttributes.Private);
创建属性或索引器需要几个步骤。首先,在TypeBuilder上调用DefineProperty,提供属性的名称和类型:
PropertyBuilder prop = tb.DefineProperty (
"Text", // Name of property
PropertyAttributes.None,
typeof (string), // Property type
new Type[0] // Indexer types
);
(如果你正在编写索引器,最后一个参数是索引器类型的数组。)请注意,我们尚未指定属性的可见性:这在访问器方法上是单独完成的。
接下来要做的是编写get和set方法。按照惯例,它们的名称以“get_”或“set_”为前缀。然后,通过在PropertyBuilder上调用SetGetMethod和SetSetMethod将它们附加到属性。
要给出完整的示例,让我们接受以下字段和属性声明
string _text;
public string Text
{
get => _text;
internal set => _text = value;
}
并动态生成它:
FieldBuilder field = tb.DefineField ("_text", typeof (string),
FieldAttributes.Private);
PropertyBuilder prop = tb.DefineProperty (
"Text", // Name of property
PropertyAttributes.None,
typeof (string), // Property type
new Type[0]); // Indexer types
MethodBuilder getter = tb.DefineMethod (
"get_Text", // Method name
MethodAttributes.Public | MethodAttributes.SpecialName,
typeof (string), // Return type
new Type[0]); // Parameter types
ILGenerator getGen = getter.GetILGenerator();
getGen.Emit (OpCodes.Ldarg_0); // Load "this" onto eval stack
getGen.Emit (OpCodes.Ldfld, field); // Load field value onto eval stack
getGen.Emit (OpCodes.Ret); // Return
MethodBuilder setter = tb.DefineMethod (
"set_Text",
MethodAttributes.Assembly | MethodAttributes.SpecialName,
null, // Return type
new Type[] { typeof (string) } ); // Parameter types
ILGenerator setGen = setter.GetILGenerator();
setGen.Emit (OpCodes.Ldarg_0); // Load "this" onto eval stack
setGen.Emit (OpCodes.Ldarg_1); // Load 2nd arg, i.e., value
setGen.Emit (OpCodes.Stfld, field); // Store value into field
setGen.Emit (OpCodes.Ret); // return
prop.SetGetMethod (getter); // Link the get method and property
prop.SetSetMethod (setter); // Link the set method and property
我们可以按如下方式测试属性:
Type t = tb.CreateType();
object o = Activator.CreateInstance (t);
t.GetProperty ("Text").SetValue (o, "Good emissions!", new object[0]);
string text = (string) t.GetProperty ("Text").GetValue (o, null);
Console.WriteLine (text); // Good emissions!
注意,在定义访问器MethodAttributes时,我们包括了SpecialName。这指示编译器在静态引用程序集时禁止直接绑定到这些方法。它还确保反射工具和 Visual Studio 的 IntelliSense 适当处理访问器。
注意
您可以通过在TypeBuilder上调用DefineEvent类似的方式发出事件。然后,通过调用SetAddOnMethod和SetRemoveOnMethod将显式事件访问器方法附加到EventBuilder。
发射构造函数
您可以通过在类型生成器上调用DefineConstructor定义自己的构造函数。如果不这样做,将自动提供默认的无参数构造函数。默认构造函数在子类型化时调用基类构造函数,就像在 C#中一样。定义一个或多个构造函数会替代此默认构造函数。
如果需要初始化字段,则构造函数是一个好的选择。实际上,这是唯一的选择:C#的字段初始化器没有特殊的 CLR 支持——它们只是在构造函数中为字段赋值的一种语法快捷方式。
因此,要复制这一点:
class Widget
{
int _capacity = 4000;
}
您将会定义一个构造函数,如下所示:
FieldBuilder field = tb.DefineField ("_capacity", typeof (int),
FieldAttributes.Private);
ConstructorBuilder c = tb.DefineConstructor (
MethodAttributes.Public,
CallingConventions.Standard,
new Type[0]); // Constructor parameters
ILGenerator gen = c.GetILGenerator();
gen.Emit (OpCodes.Ldarg_0); // Load "this" onto eval stack
gen.Emit (OpCodes.Ldc_I4, 4000); // Load 4000 onto eval stack
gen.Emit (OpCodes.Stfld, field); // Store it to our field
gen.Emit (OpCodes.Ret);
调用基础构造函数
如果子类化另一个类型,则刚刚编写的构造函数将规避基类构造函数。这与 C#不同,后者始终调用基类构造函数,无论是直接还是间接调用。例如,给定以下代码:
class A { public A() { Console.Write ("A"); } }
class B : A { public B() {} }
编译器实际上将第二行转换为以下内容:
class B : A { public B() : base() {} }
这在生成 IL 时并非如此:如果希望其执行(几乎总是如此),必须显式调用基础构造函数。假设基类称为A,以下是如何做到这一点的示例:
gen.Emit (OpCodes.Ldarg_0);
ConstructorInfo baseConstr = typeof (A).GetConstructor (new Type[0]);
gen.Emit (OpCodes.Call, baseConstr);
使用参数调用构造函数与调用方法完全相同。
附加属性
您可以通过使用CustomAttributeBuilder调用SetCustomAttribute为动态构造附加自定义属性。例如,假设我们想将以下属性声明附加到字段或属性:
[XmlElement ("FirstName", Namespace="http://test/", Order=3)]
这依赖于接受单个字符串的XmlElementAttribute构造函数。要使用CustomAttributeBuilder,我们必须检索此构造函数以及我们要设置的另外两个属性(Namespace和Order):
Type attType = typeof (XmlElementAttribute);
ConstructorInfo attConstructor = attType.GetConstructor (
new Type[] { typeof (string) } );
var att = new CustomAttributeBuilder (
attConstructor, // Constructor
new object[] { "FirstName" }, // Constructor arguments
new PropertyInfo[]
{
attType.GetProperty ("Namespace"), // Properties
attType.GetProperty ("Order")
},
new object[] { "http://test/", 3 } // Property values
);
myFieldBuilder.SetCustomAttribute (att);
// or propBuilder.SetCustomAttribute (att);
// or typeBuilder.SetCustomAttribute (att); etc
发射泛型方法和类型
本节中的所有示例都假定modBuilder已经实例化如下:
AssemblyName aname = new AssemblyName ("MyEmissions");
AssemblyBuilder assemBuilder = AssemblyBuilder.DefineDynamicAssembly (
aname, AssemblyBuilderAccess.Run);
ModuleBuilder modBuilder = assemBuilder.DefineDynamicModule ("MainModule");
定义泛型方法
要发射泛型方法:
-
在
MethodBuilder上调用DefineGenericParameters以获取GenericTypeParameterBuilder对象数组。 -
在
MethodBuilder上使用这些泛型类型参数调用SetSignature。 -
可选择地,可以按您通常的方式命名参数。
例如,以下泛型方法:
public static T Echo<T> (T value)
{
return value;
}
可以这样发射:
TypeBuilder tb = modBuilder.DefineType ("Widget", TypeAttributes.Public);
MethodBuilder mb = tb.DefineMethod ("Echo", MethodAttributes.Public |
MethodAttributes.Static);
GenericTypeParameterBuilder[] genericParams
= mb.DefineGenericParameters ("T");
mb.SetSignature (genericParams[0], // Return type
null, null,
genericParams, // Parameter types
null, null);
mb.DefineParameter (1, ParameterAttributes.None, "value"); // Optional
ILGenerator gen = mb.GetILGenerator();
gen.Emit (OpCodes.Ldarg_0);
gen.Emit (OpCodes.Ret);
DefineGenericParameters 方法接受任意数量的字符串参数 —— 这些对应于所需的通用类型名称。在本例中,我们只需要一个名为 T 的通用类型。GenericTypeParameterBuilder 基于 System.Type,因此在发出操作码时,可以将其用于 TypeBuilder 的位置。
GenericTypeParameterBuilder 还允许您指定基类型约束:
genericParams[0].SetBaseTypeConstraint (typeof (Foo));
和接口约束:
genericParams[0].SetInterfaceConstraints (typeof (IComparable));
要复制这个:
public static T Echo<T> (T value) where T : IComparable<T>
你会这样写:
genericParams[0].SetInterfaceConstraints (
typeof (IComparable<>).MakeGenericType (genericParams[0]) );
对于其他类型的约束,请调用 SetGenericParameterAttributes。它接受 GenericParameterAttributes 枚举的一个成员,其中包括以下值:
DefaultConstructorConstraint
NotNullableValueTypeConstraint
ReferenceTypeConstraint
Covariant
Contravariant
最后两个等同于将 out 和 in 修饰符应用于类型参数。
定义泛型类型
您可以以类似的方式定义通用类型。不同之处在于,您在 TypeBuilder 上调用 DefineGenericParameters 而不是 MethodBuilder。因此,要复制这个:
public class Widget<T>
{
public T Value;
}
您将执行以下操作:
TypeBuilder tb = modBuilder.DefineType ("Widget", TypeAttributes.Public);
GenericTypeParameterBuilder[] genericParams
= tb.DefineGenericParameters ("T");
tb.DefineField ("Value", genericParams[0], FieldAttributes.Public);
可以像处理方法一样添加泛型约束。
尴尬的发射目标
本节中的所有示例都假设 modBuilder 已像前几节中那样被实例化。
未创建的关闭泛型
假设您希望发出使用关闭的泛型类型的方法:
public class Widget
{
public static void Test() { var list = new List<int>(); }
}
这个过程非常直接:
TypeBuilder tb = modBuilder.DefineType ("Widget", TypeAttributes.Public);
MethodBuilder mb = tb.DefineMethod ("Test", MethodAttributes.Public |
MethodAttributes.Static);
ILGenerator gen = mb.GetILGenerator();
Type variableType = typeof (List<int>);
ConstructorInfo ci = variableType.GetConstructor (new Type[0]);
LocalBuilder listVar = gen.DeclareLocal (variableType);
gen.Emit (OpCodes.Newobj, ci);
gen.Emit (OpCodes.Stloc, listVar);
gen.Emit (OpCodes.Ret);
现在假设我们不是要一个整数列表,而是要一个小部件列表:
public class Widget
{
public static void Test() { var list = new List<Widget>(); }
}
理论上,这是一个简单的修改;我们所做的只是替换这一行:
Type variableType = typeof (List<int>);
使用这个:
Type variableType = typeof (List<>).MakeGenericType (tb);
不幸的是,这会导致在我们调用 GetConstructor 时抛出 NotSupportedException。问题在于,您无法在使用未创建类型生成器关闭的通用类型上调用 GetConstructor。对于 GetField 和 GetMethod 也是如此。
解决方案不直观。TypeBuilder 提供了三个静态方法:
public static ConstructorInfo GetConstructor (Type, ConstructorInfo);
public static FieldInfo GetField (Type, FieldInfo);
public static MethodInfo GetMethod (Type, MethodInfo);
尽管看起来并非如此,但这些方法专门用于获取使用未创建类型生成器关闭的通用类型的成员!第一个参数是关闭的通用类型;第二个参数是您希望在 未绑定 通用类型上获取的成员。以下是我们示例的修正版本:
MethodBuilder mb = tb.DefineMethod ("Test", MethodAttributes.Public |
MethodAttributes.Static);
ILGenerator gen = mb.GetILGenerator();
Type variableType = typeof (List<>).MakeGenericType (tb);
ConstructorInfo unbound = typeof (List<>).GetConstructor (new Type[0]);
ConstructorInfo ci = TypeBuilder.GetConstructor (variableType, unbound);
LocalBuilder listVar = gen.DeclareLocal (variableType);
gen.Emit (OpCodes.Newobj, ci);
gen.Emit (OpCodes.Stloc, listVar);
gen.Emit (OpCodes.Ret);
循环依赖
假设您希望构建彼此引用的两种类型,例如以下这些:
class A { public B Bee; }
class B { public A Aye; }
您可以按以下方式动态生成它:
var publicAtt = FieldAttributes.Public;
TypeBuilder aBuilder = modBuilder.DefineType ("A");
TypeBuilder bBuilder = modBuilder.DefineType ("B");
FieldBuilder bee = aBuilder.DefineField ("Bee", bBuilder, publicAtt);
FieldBuilder aye = bBuilder.DefineField ("Aye", aBuilder, publicAtt);
Type realA = aBuilder.CreateType();
Type realB = bBuilder.CreateType();
注意我们在填充两个对象之前没有在 aBuilder 或 bBuilder 上调用 CreateType。原则是:首先连接所有内容,然后在每个类型生成器上调用 CreateType。
有趣的是,realA 类型在在 bBuilder 上调用 CreateType 之前是有效但 不起作用 的。(如果您在此之前开始使用 aBuilder,则在尝试访问 Bee 字段时会抛出异常。)
你可能会想知道 bBuilder 如何知道在创建 realB 后修复 realA。答案是它不知道:realA 可以在下次使用时自行修复。这是可能的,因为在调用 CreateType 后,TypeBuilder 会变成真实运行时类型的代理。因此,realA 可以轻松获取它需要的用于升级的元数据,包括对 bBuilder 的引用。
当类型生成器要求未构造类型的简单信息时,比如类型、成员和对象引用等信息,此系统可行。在创建 realA 时,类型生成器不需要知道 realB 最终在内存中将占用多少字节,例如。这正如事实一样,因为 realB 尚未被创建!但现在想象一下,realB 是一个结构体。在创建 realA 时,realB 的最终大小现在是关键信息。
如果关系是非循环的;例如:
struct A { public B Bee; }
struct B { }
你可以通过首先创建结构体 B,然后再创建结构体 A 来解决这个问题。但请考虑这一点:
struct A { public B Bee; }
struct B { public A Aye; }
我们不会尝试生成这个,因为两个结构体互相包含是毫无意义的(如果你尝试的话,C#会生成编译时错误)。但以下变体既合法又有用:
public struct S<T> { ... } // S can be empty and this demo will work.
class A { S<B> Bee; }
class B { S<A> Aye; }
在创建 A 时,现在 TypeBuilder 需要知道 B 的内存占用,反之亦然。为了说明问题,让我们假设结构体 S 是静态定义的。以下是生成类 A 和 B 的代码:
var pub = FieldAttributes.Public;
TypeBuilder aBuilder = modBuilder.DefineType ("A");
TypeBuilder bBuilder = modBuilder.DefineType ("B");
aBuilder.DefineField ("Bee", typeof(S<>).MakeGenericType (bBuilder), pub);
bBuilder.DefineField ("Aye", typeof(S<>).MakeGenericType (aBuilder), pub);
Type realA = aBuilder.CreateType(); // Error: cannot load type B
Type realB = bBuilder.CreateType();
现在,无论你如何操作,CreateType 都会抛出 TypeLoadException:
-
首先调用
aBuilder.CreateType,然后它会报“无法加载类型B”。 -
首先调用
bBuilder.CreateType,然后它会报“无法加载类型A”!
要解决这个问题,你必须允许类型生成器在创建 realA 过程中部分创建 realB。你可以通过在调用 CreateType 前处理 AppDomain 类上的 TypeResolve 事件来实现这一点。所以,在我们的例子中,我们用以下内容替换最后两行:
TypeBuilder[] uncreatedTypes = { aBuilder, bBuilder };
ResolveEventHandler handler = delegate (object o, ResolveEventArgs args)
{
var type = uncreatedTypes.FirstOrDefault (t => t.FullName == args.Name);
return type == null ? null : type.CreateType().Assembly;
};
AppDomain.CurrentDomain.TypeResolve += handler;
Type realA = aBuilder.CreateType();
Type realB = bBuilder.CreateType();
AppDomain.CurrentDomain.TypeResolve -= handler;
TypeResolve 事件在调用 aBuilder.CreateType 时触发,在这一点上,它需要你调用 bBuilder 上的 CreateType。
注意
在定义嵌套类型时,像这个例子中处理 TypeResolve 事件同样是必要的,当嵌套和父类型相互引用时。
解析 IL
你可以通过在 MethodBase 对象上调用 GetMethodBody 来获取关于现有方法内容的信息。这将返回一个 MethodBody 对象,该对象具有用于检查方法的局部变量、异常处理子句和堆栈大小的属性,以及原始 IL。有点类似于 Reflection.Emit 的反向过程!
检查方法的原始 IL 可以帮助优化代码性能。一个简单的用法是确定当一个程序集更新时哪些方法发生了变化。
为了演示 IL 解析,我们将编写一个应用程序,以 ildasm 风格反汇编 IL。这可以作为代码分析工具或更高级别语言反汇编器的起点。
注意
记住,在反射 API 中,所有 C# 的功能结构都由 MethodBase 的子类型表示,或者(在属性、事件和索引器的情况下)附有 MethodBase 对象。
编写反汇编器
这是我们的反汇编器将产生的输出示例:
IL_00EB: ldfld Disassembler._pos
IL_00F0: ldloc.2
IL_00F1: add
IL_00F2: ldelema System.Byte
IL_00F7: ldstr "Hello world"
IL_00FC: call System.Byte.ToString
IL_0101: ldstr " "
IL_0106: call System.String.Concat
要获取此输出,我们必须解析组成 IL 的二进制标记。第一步是在 MethodBody 上调用 GetILAsByteArray 方法,以获取 IL 作为字节数组。为了使其余的工作更容易,我们将其写入一个类中,如下所示:
public class Disassembler
{
public static string Disassemble (MethodBase method)
=> new Disassembler (method).Dis();
StringBuilder _output; // The result to which we'll keep appending
Module _module; // This will come in handy later
byte[] _il; // The raw byte code
int _pos; // The position we're up to in the byte code
Disassembler (MethodBase method)
{
_module = method.DeclaringType.Module;
_il = method.GetMethodBody().GetILAsByteArray();
}
string Dis()
{
_output = new StringBuilder();
while (_pos < _il.Length) DisassembleNextInstruction();
return _output.ToString();
}
}
静态 Disassemble 方法将是此类的唯一公共成员。所有其他成员将对反汇编过程保持私有。Dis 方法包含我们处理每个指令的“主”循环。
有了这个框架,剩下的就是编写 DisassembleNextInstruction。但在这样做之前,将所有操作码加载到静态字典中将有所帮助,这样我们可以通过它们的 8 位或 16 位值访问它们。实现这一点的最简单方法是使用反射来检索 OpCodes 类中类型为 OpCode 的所有静态字段:
static Dictionary<short,OpCode> _opcodes = new Dictionary<short,OpCode>();
static Disassembler()
{
Dictionary<short, OpCode> opcodes = new Dictionary<short, OpCode>();
foreach (FieldInfo fi in typeof (OpCodes).GetFields
(BindingFlags.Public | BindingFlags.Static))
if (typeof (OpCode).IsAssignableFrom (fi.FieldType))
{
OpCode code = (OpCode) fi.GetValue (null); // Get field's value
if (code.OpCodeType != OpCodeType.Nternal)
_opcodes.Add (code.Value, code);
}
}
我们将其写在静态构造函数中,以便它只执行一次。
现在我们可以编写 DisassembleNextInstruction。每个 IL 指令由一个或两个字节的操作码组成,后跟零、一个、两个、四个或八个字节的操作数。(一个例外是内联开关操作码,其后跟有可变数量的操作数。)因此,我们读取操作码,然后读取操作数,并输出结果:
void DisassembleNextInstruction()
{
int opStart = _pos;
OpCode code = ReadOpCode();
string operand = ReadOperand (code);
_output.AppendFormat ("IL_{0:X4}: {1,-12} {2}",
opStart, code.Name, operand);
_output.AppendLine();
}
要读取操作码,我们前进一个字节,然后查看是否有有效的指令。如果没有,我们前进另一个字节,然后寻找一个两字节的指令:
OpCode ReadOpCode()
{
byte byteCode = _il [_pos++];
if (_opcodes.ContainsKey (byteCode)) return _opcodes [byteCode];
if (_pos == _il.Length) throw new Exception ("Unexpected end of IL");
short shortCode = (short) (byteCode * 256 + _il [_pos++]);
if (!_opcodes.ContainsKey (shortCode))
throw new Exception ("Cannot find opcode " + shortCode);
return _opcodes [shortCode];
}
要读取操作数,我们首先必须确定其长度。我们可以根据操作数类型来做这个操作。因为大多数操作数长度为四个字节,所以我们可以在条件子句中相对容易地过滤出例外情况。
下一步是调用 FormatOperand,它尝试格式化操作数:
string ReadOperand (OpCode c)
{
int operandLength =
c.OperandType == OperandType.InlineNone
? 0 :
c.OperandType == OperandType.ShortInlineBrTarget ||
c.OperandType == OperandType.ShortInlineI ||
c.OperandType == OperandType.ShortInlineVar
? 1 :
c.OperandType == OperandType.InlineVar
? 2 :
c.OperandType == OperandType.InlineI8 ||
c.OperandType == OperandType.InlineR
? 8 :
c.OperandType == OperandType.InlineSwitch
? 4 * (BitConverter.ToInt32 (_il, _pos) + 1) :
4; // All others are 4 bytes
if (_pos + operandLength > _il.Length)
throw new Exception ("Unexpected end of IL");
string result = FormatOperand (c, operandLength);
if (result == null)
{ // Write out operand bytes in hex
result = "";
for (int i = 0; i < operandLength; i++)
result += _il [_pos + i].ToString ("X2") + " ";
}
_pos += operandLength;
return result;
}
如果调用 FormatOperand 的结果为 null,这意味着操作数不需要特殊格式化,因此我们简单地以十六进制写出它。我们可以通过编写一个总是返回 null 的 FormatOperand 方法来测试反汇编器在此点的工作情况。输出将如下所示:
IL_00A8: ldfld 98 00 00 04
IL_00AD: ldloc.2
IL_00AE: add
IL_00AF: ldelema 64 00 00 01
IL_00B4: ldstr 26 04 00 70
IL_00B9: call B6 00 00 0A
IL_00BE: ldstr 11 01 00 70
IL_00C3: call 91 00 00 0A
...
虽然操作码是正确的,但操作数没有多大用处。我们希望使用成员名称和字符串,而不是十六进制数。当编写时,FormatOperand 方法将解决此问题,识别从这种格式化中受益的特殊情况。这包括大多数四字节操作数和短分支指令:
string FormatOperand (OpCode c, int operandLength)
{
if (operandLength == 0) return "";
if (operandLength == 4)
return Get4ByteOperand (c);
else if (c.OperandType == OperandType.ShortInlineBrTarget)
return GetShortRelativeTarget();
else if (c.OperandType == OperandType.InlineSwitch)
return GetSwitchTarget (operandLength);
else
return null;
}
我们处理的三种四字节操作数类型是特殊的。第一种是成员或类型的引用——我们通过调用定义模块的ResolveMember方法来提取成员或类型名称。第二种情况是字符串——这些存储在程序集模块的元数据中,并且可以通过调用ResolveString来检索。最后一种情况是分支目标,其中操作数引用 IL 中的字节偏移量。我们通过计算当前指令后的绝对地址(+四字节)来格式化这些:
string Get4ByteOperand (OpCode c)
{
int intOp = BitConverter.ToInt32 (_il, _pos);
switch (c.OperandType)
{
case OperandType.InlineTok:
case OperandType.InlineMethod:
case OperandType.InlineField:
case OperandType.InlineType:
MemberInfo mi;
try { mi = _module.ResolveMember (intOp); }
catch { return null; }
if (mi == null) return null;
if (mi.ReflectedType != null)
return mi.ReflectedType.FullName + "." + mi.Name;
else if (mi is Type)
return ((Type)mi).FullName;
else
return mi.Name;
case OperandType.InlineString:
string s = _module.ResolveString (intOp);
if (s != null) s = "'" + s + "'";
return s;
case OperandType.InlineBrTarget:
return "IL_" + (_pos + intOp + 4).ToString ("X4");
default:
return null;
}
}
注意
我们调用ResolveMember的点是一个很好的窗口,用于报告方法依赖关系的代码分析工具。
对于任何其他四字节操作码,我们返回null(这会导致ReadOperand将操作数格式化为十六进制数字)。
需要特别关注的最终操作数类型包括短分支目标和内联开关。短分支目标描述目标偏移量为单个有符号字节,就像在当前指令的末尾(即,+一个字节)。开关目标后跟随变量数量的四字节分支目标:
string GetShortRelativeTarget()
{
int absoluteTarget = _pos + (sbyte) _il [_pos] + 1;
return "IL_" + absoluteTarget.ToString ("X4");
}
string GetSwitchTarget (int operandLength)
{
int targetCount = BitConverter.ToInt32 (_il, _pos);
string [] targets = new string [targetCount];
for (int i = 0; i < targetCount; i++)
{
int ilTarget = BitConverter.ToInt32 (_il, _pos + (i + 1) * 4);
targets [i] = "IL_" + (_pos + ilTarget + operandLength).ToString ("X4");
}
return "(" + string.Join (", ", targets) + ")";
}
这样完成了反汇编器。我们可以通过反汇编其自身的方法来测试它:
MethodInfo mi = typeof (Disassembler).GetMethod (
"ReadOperand", BindingFlags.Instance | BindingFlags.NonPublic);
Console.WriteLine (Disassembler.Disassemble (mi));
第十九章:动态编程
第四章 解释了 C# 语言中动态绑定的工作原理。在本章中,我们简要介绍了动态语言运行时(DLR),然后探讨以下动态编程模式:
-
动态成员重载解析
-
自定义绑定(实现动态对象)
-
动态语言互操作性
注意
在 第二十四章 中,我们描述了 dynamic 如何改进 COM 互操作性。
本章中的类型位于 System.Dynamic 命名空间中,除了 CallSite<>,它位于 System.Runtime.CompilerServices 中。
动态语言运行时
C# 依赖于 DLR(动态语言运行时)来执行动态绑定。
尽管名字与 CLR 的动态版本相反,DLR 实际上是位于 CLR 之上的一个库——就像任何其他库,比如 System.Xml.dll。它的主要作用是提供运行时服务来统一静态和动态类型语言的动态编程。因此,诸如 C#、Visual Basic、IronPython 和 IronRuby 等语言都使用相同的调用函数动态协议。这使它们可以共享库并调用其他语言编写的代码。
DLR 还使得在 .NET 中编写新的动态语言相对容易。动态语言作者不需要发出中间语言(IL),而是在表达树的级别上工作(这些表达树与我们在 第八章 中讨论的 System.Linq.Expressions 中的表达树相同)。
DLR 进一步确保所有消费者都能从调用站点缓存中获益,这是一种优化方法,DLR 可以防止重复进行在动态绑定过程中可能昂贵的成员解析决策。
动态成员重载解析
使用动态类型参数调用静态已知方法会将成员重载解析从编译时推迟到运行时。这在简化某些编程任务(例如简化访问者设计模式)中非常有用。它还有助于解决 C# 静态类型强加的限制。
简化访问者模式
本质上,访问者模式允许您在类层次结构中“添加”方法,而无需修改现有类。虽然有用,但与大多数其他设计模式相比,其静态版本显得微妙和不直观。它还要求被访问的类通过暴露 Accept 方法来使其“访问者友好”,如果这些类不在您的控制之下,则可能无法实现。
借助动态绑定,您可以更轻松地实现相同的目标,而无需修改现有的类。为了说明这一点,请考虑以下类层次结构:
class Person
{
public string FirstName { get; set; }
public string LastName { get; set; }
// The Friends collection may contain Customers & Employees:
public readonly IList<Person> Friends = new Collection<Person> ();
}
class Customer : Person { public decimal CreditLimit { get; set; } }
class Employee : Person { public decimal Salary { get; set; } }
假设我们想编写一个方法,程序化地将一个Person的详细信息导出到 XML XElement中。最明显的解决方案是在Person类中编写一个虚方法ToXElement(),返回一个填充了Person属性的XElement。然后我们在Customer和Employee类中重写它,使得XElement还填充了CreditLimit和Salary。然而,这种模式可能存在问题,原因有两个:
-
你可能没有
Person、Customer和Employee类的所有权,因此无法向它们添加方法。(扩展方法也无法提供多态行为。) -
Person、Customer和Employee类可能已经相当庞大。一个常见的反模式是“上帝对象”,其中一个类如Person吸引了大量功能,使得维护变得一团糟。一个良好的解药是避免向Person添加不需要访问Person私有状态的函数。ToXElement方法可能是一个很好的选择。
使用动态成员重载解析,我们可以在一个单独的类中编写ToXElement功能,而不需要基于类型的丑陋开关:
class ToXElementPersonVisitor
{
public XElement DynamicVisit (Person p) => Visit ((dynamic)p);
XElement Visit (Person p)
{
return new XElement ("Person",
new XAttribute ("Type", p.GetType().Name),
new XElement ("FirstName", p.FirstName),
new XElement ("LastName", p.LastName),
p.Friends.Select (f => DynamicVisit (f))
);
}
XElement Visit (Customer c) // Specialized logic for customers
{
XElement xe = Visit ((Person)c); // Call "base" method
xe.Add (new XElement ("CreditLimit", c.CreditLimit));
return xe;
}
XElement Visit (Employee e) // Specialized logic for employees
{
XElement xe = Visit ((Person)e); // Call "base" method
xe.Add (new XElement ("Salary", e.Salary));
return xe;
}
}
DynamicVisit方法执行动态分派——根据运行时确定的最具体版本调用Visit。注意粗体行中的内容,在其中我们对Friends集合中的每个人调用DynamicVisit方法。这确保如果朋友是Customer或Employee,则调用正确的重载。
我们可以演示这个类,如下所示:
var cust = new Customer
{
FirstName = "Joe", LastName = "Bloggs", CreditLimit = 123
};
cust.Friends.Add (
new Employee { FirstName = "Sue", LastName = "Brown", Salary = 50000 }
);
Console.WriteLine (new ToXElementPersonVisitor().DynamicVisit (cust));
结果如下:
<Person Type="Customer">
<FirstName>Joe</FirstName>
<LastName>Bloggs</LastName>
<Person Type="Employee">
<FirstName>Sue</FirstName>
<LastName>Brown</LastName>
<Salary>50000</Salary>
</Person>
<CreditLimit>123</CreditLimit>
</Person>
变化
如果计划多个访问者类,一个有用的变化是为访问者定义一个抽象基类:
abstract class PersonVisitor<T>
{
public T DynamicVisit (Person p) { return Visit ((dynamic)p); }
protected abstract T Visit (Person p);
protected virtual T Visit (Customer c) { return Visit ((Person) c); }
protected virtual T Visit (Employee e) { return Visit ((Person) e); }
}
然后子类不需要定义自己的DynamicVisit方法:它们只需重写Visit的版本,以便专门化它们想要的行为。这样做的优点是集中包含Person层次结构的方法,并允许实现者更自然地调用基本方法:
class ToXElementPersonVisitor : PersonVisitor<XElement>
{
protected override XElement Visit (Person p)
{
return new XElement ("Person",
new XAttribute ("Type", p.GetType().Name),
new XElement ("FirstName", p.FirstName),
new XElement ("LastName", p.LastName),
p.Friends.Select (f => DynamicVisit (f))
);
}
protected override XElement Visit (Customer c)
{
XElement xe = base.Visit (c);
xe.Add (new XElement ("CreditLimit", c.CreditLimit));
return xe;
}
protected override XElement Visit (Employee e)
{
XElement xe = base.Visit (e);
xe.Add (new XElement ("Salary", e.Salary));
return xe;
}
}
随后你甚至可以直接继承ToXElementPersonVisitor本身。
匿名调用泛型类型的成员
C#静态类型的严格性是一把双刃剑。一方面,它在编译时强制执行一定程度的正确性。另一方面,它偶尔会使某些类型的代码难以表达,这时你必须依赖反射。在这些情况下,动态绑定是反射的一个更清晰和更快速的替代方案。
当你需要处理类型为G<T>的对象时,其中T是未知的时候,我们可以通过定义以下类来说明这一点:
public class Foo<T> { public T Value; }
假设我们接着按以下方式编写一个方法:
static void Write (object obj)
{
if (obj is Foo<>) // Illegal
Console.WriteLine ((Foo<>) obj).Value); // Illegal
}
这个方法不会编译通过:你不能调用未绑定泛型类型的成员。
动态绑定提供了两种方法来解决这个问题。第一种是动态访问Value成员,如下所示:
static void Write (dynamic obj)
{
try { Console.WriteLine (obj.Value); }
catch (Microsoft.CSharp.RuntimeBinder.RuntimeBinderException) {...}
}
这样做的(潜在)优势是能够与定义 Value 字段或属性的任何对象一起工作。然而,存在一些问题。首先,在这种方式中捕获异常有些凌乱和效率低下(并且没有办法事先询问 DLR,“这个操作会成功吗?”)。其次,如果 Foo 是一个接口(比如 IFoo<T>)并且满足以下任一条件,则会失败:
-
Value已经显式实现 -
实现了
IFoo<T>的类型是不可访问的(稍后详述)
更好的解决方案是编写一个名为 GetFooValue 的重载辅助方法,并使用 动态成员重载解析 调用它:
static void Write (dynamic obj)
{
object result = GetFooValue (obj);
if (result != null) Console.WriteLine (result);
}
static T GetFooValue<T> (Foo<T> foo) => foo.Value;
static object GetFooValue (object foo) => null;
注意,我们重载了 GetFooValue 来接受一个 object 参数,它作为任何类型的后备。在运行时,C# 的动态绑定器将在调用 GetFooValue 时选择最佳重载。如果涉及的对象不基于 Foo<T>,它将选择对象参数的重载,而不是抛出异常。
注意
另一种选择是仅编写第一个 GetFooValue 重载,然后捕获 RuntimeBinderException。优点是可以区分 foo.Value 为 null 的情况。缺点是会产生抛出和捕获异常的性能开销。
在 第十八章 中,我们使用反射解决了使用接口的相同问题,需要更多的工作(参见 “匿名调用泛型接口的成员”)。我们使用的示例是设计一个更强大的 ToString() 版本,能够理解诸如 IEnumerable 和 IGrouping<,> 等对象。以下是使用动态绑定更优雅地解决相同示例:
static string GetGroupKey<TKey,TElement> (IGrouping<TKey,TElement> group)
=> "Group with key=" + group.Key + ": ";
static string GetGroupKey (object source) => null;
public static string ToStringEx (object value)
{
if (value == null) return "<null>";
if (value is string s) return s;
if (value.GetType().IsPrimitive) return value.ToString();
StringBuilder sb = new StringBuilder();
string groupKey = GetGroupKey ((dynamic)value); // Dynamic dispatch
if (groupKey != null) sb.Append (groupKey);
if (value is IEnumerable)
foreach (object element in ((IEnumerable)value))
sb.Append (ToStringEx (element) + " ");
if (sb.Length == 0) sb.Append (value.ToString());
return "\r\n" + sb.ToString();
}
下面是它的作用:
Console.WriteLine (ToStringEx ("xyyzzz".GroupBy (c => c) ));
*Group with key=x: x*
*Group with key=y: y y*
*Group with key=z: z z z*
请注意,我们使用动态 成员重载解析 来解决这个问题。如果我们反而做了以下操作:
dynamic d = value;
try { groupKey = d.Value); }
catch (Microsoft.CSharp.RuntimeBinder.RuntimeBinderException) {...}
它会因为 LINQ 的 GroupBy 操作符返回实现 IGrouping<,> 的类型,而该类型本身是内部的,因此不可访问:
internal class Grouping : IGrouping<TKey,TElement>, ...
{
public TKey Key;
...
}
即使 Key 属性被声明为 public,它所在的类将其限制在 internal,因此只能通过 IGrouping<,> 接口访问。并且正如在 第四章 中所解释的,当动态调用 Value 成员时,没有办法指示 DLR 绑定到该接口。
实现动态对象
一个对象可以通过实现 IDynamicMetaObjectProvider 提供其绑定语义,或者更简单地通过子类化 DynamicObject,后者提供了此接口的默认实现。这在 第四章 中通过以下示例简要演示:
dynamic d = new Duck();
d.Quack(); // Quack method was called
d.Waddle(); // Waddle method was called
public class Duck : DynamicObject
{
public override bool TryInvokeMember (
InvokeMemberBinder binder, object[] args, out object result)
{
Console.WriteLine (binder.Name + " method was called");
result = null;
return true;
}
}
DynamicObject
在前面的示例中,我们重写了TryInvokeMember,允许使用者在动态对象上调用方法——例如Quack或Waddle。DynamicObject公开了其他虚拟方法,使消费者能够使用其他编程构造。以下是在 C#中具有表示的对应构造:
| Method | 编程构造 |
|---|---|
TryInvokeMember |
方法 |
TryGetMember, TrySetMember |
属性或字段 |
TryGetIndex, TrySetIndex |
索引器 |
TryUnaryOperation |
例如 ! 的一元操作符 |
TryBinaryOperation |
例如 == 的二元操作符 |
TryConvert |
转换(类型转换)为另一种类型 |
TryInvoke |
在对象本身上的调用——例如d("foo") |
如果这些方法成功返回true,则它们应返回true。如果它们返回false,DLR 将回退到语言绑定程序,查找DynamicObject(子类)本身的匹配成员。如果失败,将抛出RuntimeBinderException。
我们可以通过一个类来说明TryGetMember和TrySetMember,该类允许我们动态访问XElement中的属性(System.Xml.Linq):
static class XExtensions
{
public static dynamic DynamicAttributes (this XElement e)
=> new XWrapper (e);
class XWrapper : DynamicObject
{
XElement _element;
public XWrapper (XElement e) { _element = e; }
public override bool TryGetMember (GetMemberBinder binder,
out object result)
{
result = _element.Attribute (binder.Name).Value;
return true;
}
public override bool TrySetMember (SetMemberBinder binder,
object value)
{
_element.SetAttributeValue (binder.Name, value);
return true;
}
}
}
这里是如何使用它的:
XElement x = XElement.Parse (@"<Label Text=""Hello"" Id=""5""/>");
dynamic da = x.DynamicAttributes();
Console.WriteLine (da.Id); // 5
da.Text = "Foo";
Console.WriteLine (x.ToString()); // <Label Text="Foo" Id="5" />
以下对System.Data.IDataRecord做了类似的事情,使得使用数据读取器更加简单:
public class DynamicReader : DynamicObject
{
readonly IDataRecord _dataRecord;
public DynamicReader (IDataRecord dr) { _dataRecord = dr; }
public override bool TryGetMember (GetMemberBinder binder,
out object result)
{
result = _dataRecord [binder.Name];
return true;
}
}
...
using (IDataReader reader = *someDbCommand*.ExecuteReader())
{
dynamic dr = new DynamicReader (reader);
while (reader.Read())
{
int id = dr.ID;
string firstName = dr.FirstName;
DateTime dob = dr.DateOfBirth;
...
}
}
以下展示了TryBinaryOperation和TryInvoke的示例:
dynamic d = new Duck();
Console.WriteLine (d + d); // foo
Console.WriteLine (d (78, 'x')); // 123
public class Duck : DynamicObject
{
public override bool TryBinaryOperation (BinaryOperationBinder binder,
object arg, out object result)
{
Console.WriteLine (binder.Operation); // Add
result = "foo";
return true;
}
public override bool TryInvoke (InvokeBinder binder,
object[] args, out object result)
{
Console.WriteLine (args[0]); // 78
result = 123;
return true;
}
}
DynamicObject还公开了一些为动态语言提供的虚拟方法。特别是,重写GetDynamicMemberNames允许您返回您的动态对象提供的所有成员名称列表。
注意
另一个实现GetDynamicMemberNames的原因是 Visual Studio 的调试器利用此方法显示动态对象的视图。
ExpandoObject
DynamicObject的另一个简单应用是编写一个动态类,该类通过字符串键存储和检索对象。然而,通过ExpandoObject类已提供了这种功能:
dynamic x = new ExpandoObject();
x.FavoriteColor = ConsoleColor.Green;
x.FavoriteNumber = 7;
Console.WriteLine (x.FavoriteColor); // Green
Console.WriteLine (x.FavoriteNumber); // 7
ExpandoObject实现了IDictionary<string,object>,因此我们可以继续我们的示例并执行以下操作:
var dict = (IDictionary<string,object>) x;
Console.WriteLine (dict ["FavoriteColor"]); // Green
Console.WriteLine (dict ["FavoriteNumber"]); // 7
Console.WriteLine (dict.Count); // 2
与动态语言的互操作性
尽管 C#通过dynamic关键字支持动态绑定,但它并不允许您在运行时执行描述在字符串中的表达式这么远:
string expr = "2 * 3";
// We can’t "execute" expr
这是因为将字符串转换为表达式树的代码需要词法和语义分析器。这些功能内置于 C#编译器中,不作为运行时服务提供。在运行时,C#仅提供一个binder,它指示 DLR 如何解释已构建的表达式树。
真正的动态语言,如 IronPython 和 IronRuby,确实允许您执行任意字符串,这在诸如脚本编写、编写动态配置系统和实现动态规则引擎等任务中非常有用。因此,尽管您可以在 C#中编写大部分应用程序,但在这些任务中调用动态语言可能很有用。此外,您可能希望使用用动态语言编写的 API,在.NET 库中没有等效功能的情况下使用。
注意
Roslyn 脚本化 NuGet 包Microsoft.CodeAnalysis.CSharp.Scripting提供了一个 API,让您可以执行 C#字符串,尽管它是通过将您的代码编译成程序来实现的。编译开销使其比 Python 互操作更慢,除非您打算重复执行相同的表达式。
在以下示例中,我们使用 IronPython 来评估在 C#中动态创建的表达式。您可以使用以下脚本编写计算器。
注意
要运行此代码,请将 NuGet 包DynamicLanguageRuntime(不要与System.Dynamic.Runtime包混淆)和IronPython添加到您的应用程序中。
using System;
using IronPython.Hosting;
using Microsoft.Scripting;
using Microsoft.Scripting.Hosting;
int result = (int) Calculate ("2 * 3");
Console.WriteLine (result); // 6
object Calculate (string expression)
{
ScriptEngine engine = Python.CreateEngine();
return engine.Execute (expression);
}
因为我们将一个字符串传递给 Python,所以表达式将按照 Python 的规则进行评估,而不是 C#的规则。这也意味着我们可以使用 Python 的语言特性,比如列表:
var list = (IEnumerable) Calculate ("[1, 2, 3] + [4, 5]");
foreach (int n in list) Console.Write (n); // 12345
在 C#和脚本之间传递状态
要将变量从 C#传递到 Python,需要进行几个额外的步骤。以下示例说明了这些步骤,并可以作为规则引擎的基础:
// The following string could come from a file or database:
string auditRule = "taxPaidLastYear / taxPaidThisYear > 2";
ScriptEngine engine = Python.CreateEngine ();
ScriptScope scope = engine.CreateScope ();
scope.SetVariable ("taxPaidLastYear", 20000m);
scope.SetVariable ("taxPaidThisYear", 8000m);
ScriptSource source = engine.CreateScriptSourceFromString (
auditRule, SourceCodeKind.Expression);
bool auditRequired = (bool) source.Execute (scope);
Console.WriteLine (auditRequired); // True
通过调用GetVariable也可以获取变量:
string code = "result = input * 3";
ScriptEngine engine = Python.CreateEngine();
ScriptScope scope = engine.CreateScope();
scope.SetVariable ("input", 2);
ScriptSource source = engine.CreateScriptSourceFromString (code,
SourceCodeKind.SingleStatement);
source.Execute (scope);
Console.WriteLine (scope.GetVariable ("result")); // 6
请注意,在第二个示例中,我们指定了SourceCodeKind.SingleStatement(而不是Expression),以通知引擎我们要执行一个语句。
类型在.NET 和 Python 世界之间自动驱动。您甚至可以从脚本侧访问.NET 对象的成员:
string code = @"sb.Append (""World"")";
ScriptEngine engine = Python.CreateEngine ();
ScriptScope scope = engine.CreateScope ();
var sb = new StringBuilder ("Hello");
scope.SetVariable ("sb", sb);
ScriptSource source = engine.CreateScriptSourceFromString (
code, SourceCodeKind.SingleStatement);
source.Execute (scope);
Console.WriteLine (sb.ToString()); // HelloWorld
第二十章:密码学
在本章中,我们讨论了.NET 中的主要密码学 API:
-
Windows 数据保护 API(DPAPI)
-
哈希
-
对称加密
-
公钥加密和签名
本章涵盖的类型在以下命名空间中定义:
System.Security;
System.Security.Cryptography;
概述
表 20-1 总结了.NET 中的密码学选项。在接下来的几节中,我们将详细探讨每一种选项。
表 20-1. .NET 中的加密和哈希选项
| 选项 | 管理的密钥 | 速度 | 强度 | 注释 |
|---|---|---|---|---|
File.Encrypt |
0 | 快速 | 依赖用户的密码 | 通过文件系统支持透明地保护文件。从当前登录用户的凭据隐式派生密钥。仅限 Windows 操作系统。 |
| Windows 数据保护 | 0 | 快速 | 依赖用户的密码 | 使用隐式派生密钥加密和解密字节数组。 |
| 哈希 | 0 | 快速 | 高 | 单向(不可逆)转换。用于存储密码、比较文件和检查数据完整性。 |
| 对称加密 | 1 | 快速 | 高 | 用于通用加密/解密。同一密钥加密和解密。可用于安全传输消息。 |
| 公钥加密 | 2 | 慢 | 高 | 加密和解密使用不同的密钥。用于在消息传输中交换对称密钥和数字签名文件。 |
.NET 还提供了更专门的支持,用于创建和验证基于 XML 的签名,位于System.Security.Cryptography.Xml命名空间,以及用于处理数字证书的类型,位于System.Security.Cryptography.X509Certificates命名空间。
Windows 数据保护
注意
Windows 数据保护仅在 Windows 操作系统上可用,在其他操作系统上会抛出PlatformNotSupportedException异常。
在“文件和目录操作”部分中,我们描述了如何使用File.Encrypt请求操作系统透明地加密文件:
File.WriteAllText ("myfile.txt", "");
File.Encrypt ("myfile.txt");
File.AppendAllText ("myfile.txt", "sensitive data");
在这种情况下的加密使用从当前登录用户密码派生的密钥。您可以使用同样的隐式派生密钥,通过 Windows 数据保护 API(DPAPI)加密字节数组。DPAPI 通过ProtectedData类公开——这是一个具有两个静态方法的简单类型:
public static byte[] Protect
(byte[] userData, byte[] optionalEntropy, DataProtectionScope scope);
public static byte[] Unprotect
(byte[] encryptedData, byte[] optionalEntropy, DataProtectionScope scope);
无论您在optionalEntropy中包含什么,都会添加到密钥中,从而增加其安全性。DataProtectionScope枚举参数允许两个选项:CurrentUser或LocalMachine。使用CurrentUser,密钥从当前登录用户的凭据派生;使用LocalMachine,则使用全局的机器密钥,适用于所有用户。这意味着使用CurrentUser范围加密的数据不能被其他用户解密。LocalMachine密钥提供较少的保护,但适用于需要在多个账户下操作的 Windows 服务或程序。
这里是一个简单的加密和解密演示:
byte[] original = {1, 2, 3, 4, 5};
DataProtectionScope scope = DataProtectionScope.CurrentUser;
byte[] encrypted = ProtectedData.Protect (original, null, scope);
byte[] decrypted = ProtectedData.Unprotect (encrypted, null, scope);
// decrypted is now {1, 2, 3, 4, 5}
Windows 数据保护根据用户密码的强度提供了对拥有完全访问权限的攻击者的中等安全性保护。在LocalMachine范围内,它仅对那些受限的物理和电子访问者有效。
哈希计算
哈希算法将一个潜在的大量字节压缩为一个固定长度的哈希码。哈希算法设计得如此,源数据的任何单比特更改都会导致显著不同的哈希码。这使得它适用于比较文件或检测文件或数据流的意外(或恶意)损坏。
哈希还充当单向加密,因为很难或不可能将哈希码转换回原始数据。这使其非常适合将密码存储在数据库中,因为如果您的数据库被攻击者入侵,您不希望攻击者能够访问明文密码。要进行身份验证,只需对用户输入的内容进行哈希并将其与数据库中存储的哈希进行比较。
要进行哈希计算,可以在HashAlgorithm的子类(如SHA1或SHA256)上调用ComputeHash:
byte[] hash;
using (Stream fs = File.OpenRead ("checkme.doc"))
hash = SHA1.Create().ComputeHash (fs); // SHA1 hash is 20 bytes long
ComputeHash方法还接受字节数组作为参数,这对于哈希密码非常方便(我们在“哈希密码”中描述了更安全的技术):
byte[] data = System.Text.Encoding.UTF8.GetBytes ("stRhong%pword");
byte[] hash = SHA256.Create().ComputeHash (data);
注意
在Encoding对象上调用GetBytes方法将字符串转换为字节数组;调用GetString方法将其转换回来。然而,Encoding对象不能将加密或哈希的字节数组转换为字符串,因为乱序的数据通常违反文本编码规则。取而代之的是使用Convert.ToBase64String和Convert.FromBase64String:它们可以在任何字节数组和合法(且适于 XML 或 JSON)的字符串之间转换。
.NET 中的哈希算法
SHA1和SHA256是.NET 提供的HashAlgorithm子类型之一。以下是主要算法,按安全性从低到高排序:
| 类别 | 算法 | 哈希长度(字节) | 强度 |
|---|---|---|---|
MD5 |
MD5 | 16 | 非常差 |
SHA1 |
SHA-1 | 20 | 差 |
SHA256 |
SHA-2 | 32 | 良好 |
SHA384 |
SHA-2 | 48 | 良好 |
SHA512 |
SHA-2 | 64 | 良好 |
所有五种算法在当前实现中执行速度大致相似,除了 SHA256,它比其他算法快 2-3 倍(这可能会随硬件和操作系统的不同而有所变化)。以一个大概的数字为例,您可以期望在 2024 年的桌面或服务器上,所有算法至少达到每秒 500 MB。较长的哈希值降低了碰撞(两个不同的文件产生相同的哈希值)的可能性。
警告
使用至少SHA256来哈希密码或其他安全敏感数据。MD5和SHA1被认为在此目的上不安全,仅适用于防止意外损坏,而非故意篡改。
注意
.NET 8 及更高版本还通过 SHA3_256、SHA3_384 和 SHA3_512 类支持最新的 SHA-3 哈希算法。SHA-3 算法被认为比前面列出的算法更安全(但更慢),但需要 Windows Build 25324+ 或具有 OpenSSL 1.1.1+ 的 Linux。可以通过这些类的静态 IsSupported 属性来测试操作系统是否支持。
密码哈希
如果你要求强密码策略以减轻字典攻击(即攻击者通过对字典中的每个单词进行哈希来构建密码查找表的策略),那么更长的 SHA 算法适合用作密码哈希的基础。
在哈希密码时的一个标准技术是将“盐”——一系列长字节——合并到每个密码之前,然后再进行哈希。这种做法通过两种方式使黑客感到沮丧:
-
他们还必须知道盐字节。
-
他们无法使用彩虹表(公开可用的预计算密码及其哈希码数据库),尽管使用足够的计算能力可能仍然可以进行字典攻击。
要进一步增强安全性,可以通过“拉伸”密码哈希来实现——重复重新哈希以获得更加计算密集的字节序列。如果重新哈希 100 次,一个原本需要一个月才能完成的字典攻击将需要八年时间。KeyDerivation、Rfc2898DeriveBytes 和 PasswordDeriveBytes 类可以执行这种类型的拉伸,并且允许方便的盐化。其中,KeyDerivation.Pbkdf2 提供了最佳的哈希算法:
byte[] encrypted = KeyDerivation.Pbkdf2 (
password: "stRhong%pword",
salt: Encoding.UTF8.GetBytes ("j78Y#p)/saREN!y3@"),
prf: KeyDerivationPrf.HMACSHA512,
iterationCount: 100,
numBytesRequested: 64);
注意
KeyDerivation.Pbkdf2 需要 NuGet 包 Microsoft.AspNetCore.Cryptography.KeyDerivation。尽管它位于 ASP.NET Core 命名空间中,但任何 .NET 应用程序都可以使用它。
对称加密
对称加密使用相同的密钥进行加密和解密。.NET BCL 提供了四种对称算法,其中 Rijndael(发音为“Rhine Dahl”或“Rain Doll”)是最优秀的;其他算法主要用于与旧应用程序的兼容性。Rijndael 既快速又安全,并且有两种实现方式:
-
Rijndael类 -
Aes类
这两者几乎是相同的,唯一的区别在于 Aes 不允许通过更改块大小来削弱密码。CLR 安全团队推荐使用 Aes。
Rijndael 和 Aes 允许使用长度为 16、24 或 32 字节的对称密钥:所有这些密钥长度目前都被认为是安全的。以下是如何使用 16 字节密钥将一系列字节加密并写入文件的方法:
byte[] key = {145,12,32,245,98,132,98,214,6,77,131,44,221,3,9,50};
byte[] iv = {15,122,132,5,93,198,44,31,9,39,241,49,250,188,80,7};
byte[] data = { 1, 2, 3, 4, 5 }; // This is what we're encrypting.
using (SymmetricAlgorithm algorithm = Aes.Create())
using (ICryptoTransform encryptor = algorithm.CreateEncryptor (key, iv))
using (Stream f = File.Create ("encrypted.bin"))
using (Stream c = new CryptoStream (f, encryptor, CryptoStreamMode.Write))
c.Write (data, 0, data.Length);
以下代码解密文件:
byte[] key = {145,12,32,245,98,132,98,214,6,77,131,44,221,3,9,50};
byte[] iv = {15,122,132,5,93,198,44,31,9,39,241,49,250,188,80,7};
byte[] decrypted = new byte[5];
using (SymmetricAlgorithm algorithm = Aes.Create())
using (ICryptoTransform decryptor = algorithm.CreateDecryptor (key, iv))
using (Stream f = File.OpenRead ("encrypted.bin"))
using (Stream c = new CryptoStream (f, decryptor, CryptoStreamMode.Read))
for (int b; (b = c.ReadByte()) > -1;)
Console.Write (b + " "); // 1 2 3 4 5
在这个例子中,我们使用了 16 个随机选择的字节来生成密钥。如果在解密时使用了错误的密钥,CryptoStream 将抛出 CryptographicException。捕获此异常是测试密钥是否正确的唯一方法。
除了密钥,我们还制定了一个 IV,或初始化向量。这个 16 字节的序列是密码的一部分——类似于密钥——但不被视为机密。如果您要传输加密的消息,可以在明文中发送 IV(可能在消息头中),然后每条消息都更改它。这将使每条加密消息与任何先前的消息在外观上都不相似,即使它们的未加密版本相似或相同。
注意
如果您不需要——或不想要——IV 的保护,可以通过同时使用相同的 16 字节值作为密钥和 IV 来避免它。然而,使用相同 IV 发送多条消息会削弱密码,并且甚至可能使其被破解。
密码工作分布在不同的类中。Aes 是数学家;它应用密码算法,以及它的 encryptor 和 decryptor 转换。CryptoStream 是管道工;它负责流的管道。您可以用不同的对称算法替换 Aes,但仍然可以使用 CryptoStream。
CryptoStream 是双向的,这意味着您可以根据选择的 CryptoStreamMode.Read 或 CryptoStreamMode.Write 从流中读取或写入数据。加密器和解密器都具备读取和写入的能力,产生四种组合——这个选择可能会让您盯着空白屏幕发呆一段时间!将读取视为“拉取”,写入视为“推送”可能会有所帮助。如果有疑问,可以从加密时选择 Write,解密时选择 Read 开始;这通常是最自然的选择。
要生成随机的密钥或 IV,请使用 System.Cryptography 中的 RandomNumberGenerator。它产生的数字是真正不可预测的,或者密码学强度(System.Random 类无法提供相同的保证)。以下是一个例子:
byte[] key = new byte [16];
byte[] iv = new byte [16];
RandomNumberGenerator rand = RandomNumberGenerator.Create();
rand.GetBytes (key);
rand.GetBytes (iv);
或者,从 .NET 6 开始:
byte[] key = RandomNumberGenerator.GetBytes (16);
byte[] iv = RandomNumberGenerator.GetBytes (16);
如果您没有指定密钥和 IV,则会自动生成密码学上强大的随机值。您可以通过 Aes 对象的 Key 和 IV 属性查询这些值。
在内存中加密
从 .NET 6 开始,您可以利用 EncryptCbc 和 DecryptCbc 方法来简化字节数组的加密和解密过程:
public static byte[] Encrypt (byte[] data, byte[] key, byte[] iv)
{
using Aes algorithm = Aes.Create();
algorithm.Key = key;
return algorithm.EncryptCbc (data, iv);
}
public static byte[] Decrypt (byte[] data, byte[] key, byte[] iv)
{
using Aes algorithm = Aes.Create();
algorithm.Key = key;
return algorithm.DecryptCbc (data, iv);
}
这是在所有 .NET 版本中都有效的等效方法:
public static byte[] Encrypt (byte[] data, byte[] key, byte[] iv)
{
using (Aes algorithm = Aes.Create())
using (ICryptoTransform encryptor = algorithm.CreateEncryptor (key, iv))
return Crypt (data, encryptor);
}
public static byte[] Decrypt (byte[] data, byte[] key, byte[] iv)
{
using (Aes algorithm = Aes.Create())
using (ICryptoTransform decryptor = algorithm.CreateDecryptor (key, iv))
return Crypt (data, decryptor);
}
static byte[] Crypt (byte[] data, ICryptoTransform cryptor)
{
MemoryStream m = new MemoryStream();
using (Stream c = new CryptoStream (m, cryptor, CryptoStreamMode.Write))
c.Write (data, 0, data.Length);
return m.ToArray();
}
在这里,CryptoStreamMode.Write 在加密和解密时都能很好地工作,因为在这两种情况下,我们都在“推送”到一个新的内存流中。
这里有接受和返回字符串的重载:
public static string Encrypt (string data, byte[] key, byte[] iv)
{
return Convert.ToBase64String (
Encrypt (Encoding.UTF8.GetBytes (data), key, iv));
}
public static string Decrypt (string data, byte[] key, byte[] iv)
{
return Encoding.UTF8.GetString (
Decrypt (Convert.FromBase64String (data), key, iv));
}
以下演示了它们的使用:
byte[] key = new byte[16];
byte[] iv = new byte[16];
var cryptoRng = RandomNumberGenerator.Create();
cryptoRng.GetBytes (key);
cryptoRng.GetBytes (iv);
string encrypted = Encrypt ("Yeah!", key, iv);
Console.WriteLine (encrypted); // R1/5gYvcxyR2vzPjnT7yaQ==
string decrypted = Decrypt (encrypted, key, iv);
Console.WriteLine (decrypted); // Yeah!
链式加密流
CryptoStream 是一个装饰器,意味着您可以将它与其他流链接起来。在下面的示例中,我们将压缩加密文本写入文件,然后再读取回来:
byte[] key = new byte [16];
byte[] iv = new byte [16];
var cryptoRng = RandomNumberGenerator.Create();
cryptoRng.GetBytes (key);
cryptoRng.GetBytes (iv);
using (Aes algorithm = Aes.Create())
{
using (ICryptoTransform encryptor = algorithm.CreateEncryptor(key, iv))
using (Stream f = File.Create ("serious.bin"))
using (Stream c = new CryptoStream (f, encryptor, CryptoStreamMode.Write))
using (Stream d = new DeflateStream (c, CompressionMode.Compress))
using (StreamWriter w = new StreamWriter (d))
await w.WriteLineAsync ("Small and secure!");
using (ICryptoTransform decryptor = algorithm.CreateDecryptor(key, iv))
using (Stream f = File.OpenRead ("serious.bin"))
using (Stream c = new CryptoStream (f, decryptor, CryptoStreamMode.Read))
using (Stream d = new DeflateStream (c, CompressionMode.Decompress))
using (StreamReader r = new StreamReader (d))
Console.WriteLine (await r.ReadLineAsync()); // Small and secure!
}
(最后一步,通过调用 WriteLineAsync 和 ReadLineAsync 使我们的程序异步化,并等待结果。)
在这个例子中,所有单字母变量都是链的一部分。数学家——algorithm、encryptor 和 decryptor——在密码流的工作中起到了辅助作用,正如 图 20-1 所示。
以这种方式链接流无论最终流的大小如何,都需要很少的内存。

图 20-1. 加密和压缩流的链接
处置加密对象
处置CryptoStream确保其内部数据缓存刷新到底层流中。内部缓存对于加密算法是必需的,因为它们以块而不是逐字节方式处理数据。
CryptoStream的不寻常之处在于其Flush方法什么也不做。要刷新流(而不是释放它),必须调用FlushFinalBlock。与Flush相反,只能调用FlushFinalBlock一次,然后不能再写入更多数据。
我们还处理了数学家——Aes算法和ICryptoTransform对象(encryptor和decryptor)。当 Rijndael 变换被处理时,它们会从内存中擦除对称密钥和相关数据,防止其他在计算机上运行的软件(我们说的是恶意软件)后续发现。您不能依赖垃圾收集器来执行此任务,因为它仅仅将内存部分标记为可用;它不会在每个字节上写零。
在using语句外释放Aes对象最简单的方法是调用Clear方法。它的Dispose方法通过显式实现隐藏(用于表示其不寻常的处理语义,即清除内存而不是释放非托管资源)。
注意
您可以通过以下方法进一步减少应用程序通过释放的内存泄露秘密的风险:
-
避免使用字符串存储安全信息(由于不可变性,一旦创建,字符串的值就无法清除)
-
一旦不再需要,立即覆盖缓冲区(例如,在字节数组上调用
Array.Clear)
密钥管理
密钥管理是安全性的关键因素:如果您的密钥暴露了,那么您的数据也暴露了。您需要考虑谁应该访问密钥,以及如何在硬件故障时进行备份,同时以防止未经授权的访问方式存储它们。
不建议硬编码加密密钥,因为存在流行的工具可以轻松反编译程序集,无需专业知识。更好的选择(在 Windows 上)是为每个安装制造一个随机密钥,并安全地存储在 Windows 数据保护中。
对于部署到云中的应用程序,Microsoft Azure 和 Amazon Web Services(AWS)提供了具有额外功能的密钥管理系统,这些功能在企业环境中可能非常有用,例如审计跟踪。如果您正在加密消息流,公钥加密仍然提供了最佳选择。
公钥加密和签名
公钥加密是非对称的,意味着加密和解密使用不同的密钥。
与对称加密不同,对称加密可以使用任意长度的任意字节序列作为密钥,非对称加密需要专门制作的密钥对。密钥对包含一对公钥和私钥组件,它们如下配合工作:
-
公钥加密消息。
-
私钥解密消息。
负责“制作”密钥对的一方会保持私钥保密,同时自由分发公钥。这种加密方式的特殊特性在于无法从公钥计算出私钥。因此,如果私钥丢失,则无法恢复加密数据;相反,如果私钥泄漏,则加密系统变得无用。
公钥握手允许两台计算机在公共网络上进行安全通信,无需先前联系和现有共享秘密。为了看到其工作原理,假设计算机Origin想要将机密消息发送给计算机Target:
-
Target生成一个公钥/私钥对,然后将其公钥发送给Origin。
-
Origin使用Target的公钥加密机密消息,然后将其发送给Target。
-
Target使用其私钥解密机密消息。
窃听者将看到以下内容:
-
Target的公钥
-
使用Target的公钥加密的秘密消息
但是没有Target的私钥,无法解密消息。
注意
这不能防止中间人攻击:换句话说,Origin无法知道Target是否某些恶意方。要对收件人进行身份验证,发起者需要已知收件人的公钥或能够通过数字站点证书验证其密钥。
因为公钥加密速度较慢且消息大小有限,从Origin发送到Target的秘密消息通常包含用于随后对称加密的新密钥。这允许在会话的其余部分放弃公钥加密,转而采用能够处理更大消息的对称算法。如果每个会话生成一个新的公钥/私钥对,则此协议尤其安全,因为然后无需在任何计算机上存储密钥。
注意
公钥加密算法依赖于消息大小小于密钥的情况。这使它们适合仅加密少量数据,例如随后对称加密的密钥。如果尝试加密远大于密钥大小一半的消息,则提供程序将引发异常。
RSA 类
.NET 提供多种非对称算法,其中 RSA 最为流行。以下是如何使用 RSA 加密和解密的方法:
byte[] data = { 1, 2, 3, 4, 5 }; // This is what we're encrypting.
using (var rsa = new RSACryptoServiceProvider())
{
byte[] encrypted = rsa.Encrypt (data, true);
byte[] decrypted = rsa.Decrypt (encrypted, true);
}
因为我们没有指定公钥或私钥,加密提供程序会自动使用默认长度为 1,024 位生成密钥对;您可以通过构造函数请求更长的密钥,以 8 字节的增量。对于安全关键的应用程序,建议请求 2,048 位:
var rsa = new RSACryptoServiceProvider (2048);
生成密钥对的计算密集型操作可能需要约 10 毫秒。因此,RSA 实现推迟到实际需要密钥时才执行,例如在调用Encrypt时。这使您有机会加载现有的密钥或密钥对(如果存在)。
方法ImportCspBlob和ExportCspBlob以字节数组格式加载和保存密钥。FromXmlString和ToXmlString以字符串格式执行相同的工作,字符串包含 XML 片段。布尔标志允许您指示在保存时是否包括私钥。以下是如何生成密钥对并将其保存到磁盘上的示例:
using (var rsa = new RSACryptoServiceProvider())
{
File.WriteAllText ("PublicKeyOnly.xml", rsa.ToXmlString (false));
File.WriteAllText ("PublicPrivate.xml", rsa.ToXmlString (true));
}
因为我们没有提供现有密钥,ToXmlString强制生成了一个新的密钥对(在第一次调用时)。在下一个示例中,我们将读取这些密钥并使用它们来加密和解密消息。
byte[] data = Encoding.UTF8.GetBytes ("Message to encrypt");
string publicKeyOnly = File.ReadAllText ("PublicKeyOnly.xml");
string publicPrivate = File.ReadAllText ("PublicPrivate.xml");
byte[] encrypted, decrypted;
using (var rsaPublicOnly = new RSACryptoServiceProvider())
{
rsaPublicOnly.FromXmlString (publicKeyOnly);
encrypted = rsaPublicOnly.Encrypt (data, true);
// The next line would throw an exception because you need the private
// key in order to decrypt:
// decrypted = rsaPublicOnly.Decrypt (encrypted, true);
}
using (var rsaPublicPrivate = new RSACryptoServiceProvider())
{
// With the private key we can successfully decrypt:
rsaPublicPrivate.FromXmlString (publicPrivate);
decrypted = rsaPublicPrivate.Decrypt (encrypted, true);
}
数字签名
您还可以使用公钥算法来对消息或文档进行数字签名。签名类似于哈希,但其生成需要私钥,因此无法伪造。公钥用于验证签名。以下是一个示例:
byte[] data = Encoding.UTF8.GetBytes ("Message to sign");
byte[] publicKey;
byte[] signature;
object hasher = SHA1.Create(); // Our chosen hashing algorithm.
// Generate a new key pair, then sign the data with it:
using (var publicPrivate = new RSACryptoServiceProvider())
{
signature = publicPrivate.SignData (data, hasher);
publicKey = publicPrivate.ExportCspBlob (false); // get public key
}
// Create a fresh RSA using just the public key, then test the signature.
using (var publicOnly = new RSACryptoServiceProvider())
{
publicOnly.ImportCspBlob (publicKey);
Console.Write (publicOnly.VerifyData (data, hasher, signature)); // True
// Let's now tamper with the data and recheck the signature:
data[0] = 0;
Console.Write (publicOnly.VerifyData (data, hasher, signature)); // False
// The following throws an exception as we're lacking a private key:
signature = publicOnly.SignData (data, hasher);
}
签名的工作方式是首先对数据进行哈希处理,然后应用非对称算法到结果哈希上。由于哈希值是固定大小的小块,因此可以相对快速地对大型文档进行签名(与哈希相比,公钥加密需要更多的 CPU 资源)。如果愿意,您可以自行进行哈希计算,然后调用SignHash而不是SignData:
using (var rsa = new RSACryptoServiceProvider())
{
byte[] hash = SHA1.Create().ComputeHash (data);
signature = rsa.SignHash (hash, CryptoConfig.MapNameToOID ("SHA1"));
...
}
SignHash仍然需要知道您使用的哈希算法;CryptoConfig.MapNameToOID从友好名称(如“SHA1”)提供此信息,以正确的格式提供。
RSACryptoServiceProvider生成的签名大小与密钥大小相匹配。目前,没有主流算法生成比 128 字节更小的安全签名(适用于产品激活码等)。
注意
为了签名有效,接收者必须知道并信任发送者的公钥。这可以通过先前的通信、预配置或网站证书来实现。网站证书是发起者公钥和名称的电子记录,由独立可信的机构签名。命名空间System.Security.Cryptography.X509Certificates定义了用于处理证书的类型。
第二十一章:高级线程
我们从基础线程(作为任务和异步的前导)开始第十四章。具体来说,我们展示了如何启动和配置线程,并涵盖了线程池、阻塞、自旋和同步上下文等关键概念。我们还介绍了锁定和线程安全,并演示了最简单的信号构件ManualResetEvent。
本章继续第十四章关于线程的话题。在前三节中,我们更详细地阐述了同步、锁定和线程安全。然后,我们涵盖了:
-
非独占锁定(
Semaphore和读写锁) -
所有信号构件(
AutoResetEvent、ManualResetEvent、CountdownEvent和Barrier) -
惰性初始化(
Lazy<T>和LazyInitializer) -
线程本地存储(
ThreadStaticAttribute、ThreadLocal<T>和GetData/SetData) -
定时器
线程是如此广泛的话题,我们在网上提供了额外的材料以完整展示整个情景。请访问http://albahari.com/threading进行更深入的讨论,包括以下更深奥的主题:
-
专用信号场景中使用
Monitor.Wait和Monitor.Pulse -
用于微优化的非阻塞同步技术(
Interlocked、内存屏障、volatile) -
高并发场景下的
SpinLock和SpinWait
同步概述
同步是协调并发操作以实现可预测结果的行为。在多个线程访问同一数据时,同步尤为重要;在这个领域中轻易犯错。
可能是最简单且最有用的同步工具,毫无疑问是第十四章中描述的延续和任务组合器。通过将并发程序制定为异步操作,并用延续和组合器串联起来,您可以减少对锁定和信号的需求。然而,在某些时候,低级构件仍然会发挥作用。
同步构件可分为三类:
独占锁定
独占锁定允许仅有一个线程执行某些活动或一段代码。它们的主要目的是让线程在不互相干扰的情况下访问共享写入状态。独占锁定构件包括lock、Mutex和SpinLock。
非独占锁定
非独占锁定允许限制并发性。非独占锁定构件包括Semaphore(Slim)和ReaderWriterLock(Slim)。
信号
这些允许线程在接收来自其他线程的一个或多个通知之前阻塞。信号构件包括ManualResetEvent(Slim)、AutoResetEvent、CountdownEvent和Barrier。前三者被称为事件等待句柄。
也可以(而且很棘手地)通过使用非阻塞同步构造在共享状态上执行某些并发操作。这些构造包括Thread.MemoryBarrier、Thread.VolatileRead、Thread.VolatileWrite、volatile关键字和Interlocked类。我们在在线文档中介绍了这个主题,同时还介绍了Monitor的Wait/Pulse方法,您可以使用它们来编写自定义的信号逻辑。
独占锁定
有三种独占锁定构造:lock语句、Mutex和SpinLock。lock语句是最方便和广泛使用的,而其他两种则针对特定的场景:
-
Mutex允许您跨多个进程进行跨计算机范围的锁定。 -
SpinLock实现了一种微优化,可以减少高并发场景中的上下文切换(参见http://albahari.com/threading)。
lock语句
为了说明锁定的必要性,考虑以下类:
class ThreadUnsafe
{
static int _val1 = 1, _val2 = 1;
static void Go()
{
if (_val2 != 0) Console.WriteLine (_val1 / _val2);
_val2 = 0;
}
}
这个类不是线程安全的:如果两个线程同时调用Go方法,可能会因为一个线程在另一个线程在执行if语句和Console.WriteLine之间将_val2设置为零时而导致除零错误。这里是lock如何解决这个问题的方式:
class ThreadSafe
{
static readonly object _locker = new object();
static int _val1 = 1, _val2 = 1;
static void Go()
{
lock (_locker)
{
if (_val2 != 0) Console.WriteLine (_val1 / _val2);
_val2 = 0;
}
}
}
只有一个线程可以一次锁定同步对象(在本例中为_locker),任何竞争线程都会被阻塞,直到锁被释放。如果有多个线程竞争锁,它们会排队在“准备队列”上,并按先到先服务的顺序获得锁。[¹] 独占锁有时被认为是强制串行化访问被锁定的内容,因为一个线程的访问不能与另一个线程的访问重叠。在这种情况下,我们保护Go方法内部的逻辑以及字段_val1和_val2。
Monitor.Enter和Monitor.Exit
C#的lock语句实际上是调用Monitor.Enter和Monitor.Exit方法,并带有try/finally块的语法快捷方式。这里是前面示例中Go方法中实际发生的(简化版):
Monitor.Enter (_locker);
try
{
if (_val2 != 0) Console.WriteLine (_val1 / _val2);
_val2 = 0;
}
finally { Monitor.Exit (_locker); }
在同一对象上调用Monitor.Exit之前未调用Monitor.Enter会引发异常。
lockTaken的重载版本
我们刚刚演示的代码存在一个微妙的漏洞。考虑在调用Monitor.Enter和try块之间抛出异常的情况(可能是由于OutOfMemoryException或在.NET Framework 中,线程被中止)。在这种情况下,锁可能会被获取,也可能不会被获取。如果锁被获取,它将不会被释放,因为我们永远不会进入try/finally块。这将导致锁泄漏。为了避免这种危险,Monitor.Enter定义了以下重载:
public static void Enter (object obj, ref bool lockTaken);
lockTaken如果(且仅当)Enter方法抛出异常且未获取锁时,其值为 false。
这是更健壮的使用模式(这正是 C# 如何转换 lock 语句的方式)。
bool lockTaken = false;
try
{
Monitor.Enter (_locker, ref lockTaken);
// Do your stuff...
}
finally { if (lockTaken) Monitor.Exit (_locker); }
TryEnter
Monitor 还提供了一个 TryEnter 方法,允许指定超时时间,可以是毫秒或 TimeSpan。该方法如果获取到锁则返回 true,如果因超时而未获取到锁则返回 false。TryEnter 还可以不带参数调用,如果无法立即获取锁则立即超时。与 Enter 方法一样,TryEnter 被重载以接受 lockTaken 参数。
选择同步对象
可以使用任何每个参与线程都可见的对象作为同步对象,但有一个硬性规定:它必须是引用类型。同步对象通常是私有的(因为这有助于封装锁定逻辑),通常是实例或静态字段。同步对象可以兼作其保护的对象,就像下面的示例中的 _list 字段一样。
class ThreadSafe
{
List <string> _list = new List <string>();
void Test()
{
lock (_list)
{
_list.Add ("Item 1");
...
专门用于锁定目的的字段(例如前面示例中的 _locker)允许对锁的范围和粒度进行精确控制。您还可以使用包含对象 (this) 作为同步对象:
lock (this) { ... }
或者甚至它的类型:
lock (typeof (Widget)) { ... } // For protecting access to statics
以这种方式锁定的缺点是,您没有封装锁定逻辑,因此更难以防止死锁和过度阻塞。
您还可以锁定 lambda 表达式或匿名方法捕获的局部变量。
注意
锁定并不以任何方式限制对同步对象本身的访问。换句话说,x.ToString() 不会因为另一个线程调用了 lock(x) 而被阻塞;要发生阻塞,两个线程必须同时调用 lock(x)。
何时进行锁定
作为基本规则,您需要在访问任何可写共享字段时进行锁定。即使在最简单的情况下——对单个字段的赋值操作——您也必须考虑同步。在下面的类中,Increment 和 Assign 方法都不是线程安全的:
class ThreadUnsafe
{
static int _x;
static void Increment() { _x++; }
static void Assign() { _x = 123; }
}
这里是 Increment 和 Assign 的线程安全版本:
static readonly object _locker = new object();
static int _x;
static void Increment() { lock (_locker) _x++; }
static void Assign() { lock (_locker) _x = 123; }
如果没有锁定,可能会出现两个问题:
-
操作,如递增变量(或者在某些条件下甚至读取/写入变量),不是原子的。
-
编译器、CLR 和处理器有权重新排序指令和缓存 CPU 寄存器中的变量以提高性能——只要这些优化不会改变单线程程序(或使用锁的多线程程序)的行为。
锁定可以缓解第二个问题,因为它在锁定之前和之后创建了一个内存屏障。内存屏障是一个“栅栏”,通过它,重新排序和缓存的效果无法穿透。
注意
这不仅适用于锁定,还适用于所有同步构造。因此,如果您使用信号构造确保只有一个线程在任一时间读取/写入变量,您无需锁定。因此,以下代码在围绕x加锁的情况下是线程安全的:
var signal = new ManualResetEvent (false);
int x = 0;
new Thread (() => { x++; signal.Set(); }).Start();
signal.WaitOne();
Console.WriteLine (x); // 1 *(always)*
在“非阻塞同步”一章中,我们解释了这种需求的起因以及内存屏障和Interlocked类在这些情况下提供的锁定替代方案。
锁定与原子性
如果一组变量总是在相同的锁定内读取和写入,您可以说这些变量被读取和写入原子化。假设字段 x 和 y 总是在对象 locker 上的 lock 内读取和赋值:
lock (locker) { if (x != 0) y /= x; }
我们可以说 x 和 y 由于代码块无法在另一个线程的操作中被分割或抢占,因此它们是原子访问的,这将改变 x 或 y 并使其失效的结果。只要在相同的独占锁内始终访问 x 和 y,您永远不会遇到除以零的错误。
注意
如果在lock块内抛出异常(无论是否涉及多线程),锁提供的原子性将被破坏。例如,请考虑以下情况:
decimal _savingsBalance, _checkBalance;
void Transfer (decimal amount)
{
lock (_locker)
{
_savingsBalance += amount;
_checkBalance -= amount + GetBankFee();
}
}
如果GetBankFee()抛出异常,银行将会损失资金。在这种情况下,我们可以通过早期调用GetBankFee来避免问题。在更复杂的情况下,可以在catch或finally块中实现“回滚”逻辑。
指令的原子性是一个不同但类似的概念:如果指令在底层处理器上以不可分割的方式执行,则该指令是原子的。
嵌套锁定
线程可以以嵌套(可重入)方式重复锁定同一对象:
lock (locker)
lock (locker)
lock (locker)
{
// Do something...
}
或者:
Monitor.Enter (locker); Monitor.Enter (locker); Monitor.Enter (locker);
// Do something...
Monitor.Exit (locker); Monitor.Exit (locker); Monitor.Exit (locker);
在这些场景中,对象只有在最外层的lock语句退出时——或者执行了匹配数量的Monitor.Exit语句后——才解锁。
当一个方法在锁定内调用另一个方法时,嵌套锁定非常有用:
object locker = new object();
lock (locker)
{
AnotherMethod();
// We still have the lock - because locks are reentrant.
}
void AnotherMethod()
{
lock (locker) { Console.WriteLine ("Another method"); }
}
一个线程只能在第一个(最外层)锁上阻塞。
死锁
当两个线程分别等待另一个持有的资源时,就会发生死锁,因此两者都无法继续进行。最简单的方法是使用两个锁来说明这一点:
object locker1 = new object();
object locker2 = new object();
new Thread (() => {
lock (locker1)
{
Thread.Sleep (1000);
lock (locker2); // Deadlock
}
}).Start();
lock (locker2)
{
Thread.Sleep (1000);
lock (locker1); // Deadlock
}
您可以使用三个或更多线程创建更复杂的死锁链。
注意
在标准托管环境中,CLR 不像 SQL Server 那样自动检测和解决死锁问题,它会无限期地阻塞参与的线程,除非您已指定锁定超时。(然而,在 SQL CLR 集成主机中,死锁会自动检测,并在其中一个线程上抛出一个[可捕获的]异常。)
在多线程中,死锁是最困难的问题之一——特别是当存在许多相互关联的对象时。从根本上说,难题在于您无法确定调用者已经获取了哪些锁。
因此,你可能会在类 x 内锁定私有字段 a,而不知道你的调用者(或调用者的调用者)已经在类 y 中锁定了字段 b。与此同时,另一个线程在做相反的事情——造成死锁。具有讽刺意味的是,这个问题被(好的)面向对象设计模式加剧了,因为这些模式创建的调用链直到运行时才确定。
流行的建议“按一致的顺序锁定对象以防止死锁”,虽然在我们的初始示例中很有帮助,但很难应用到刚才描述的场景中。一个更好的策略是小心在调用可能会引用回你自己对象的对象方法时进行锁定。此外,考虑一下是否真的需要在调用其他类方法时进行锁定(通常是需要的——正如你将在“锁定和线程安全”中看到的——但有时也有其他选择)。更多依赖于高级别同步选项,如任务延续/组合器、数据并行和不可变类型(本章后面会介绍)可以减少对锁定的需求。
注意
这里是另一种理解问题的方式:当你在持有锁的同时调用其他代码时,该锁的封装会微妙地泄漏。这不是 CLR 的错误;这是锁定在一般情况下的一个根本限制。锁定问题正在被各种研究项目解决,包括软件事务内存。
另一个死锁场景是在拥有锁时调用 Dispatcher.Invoke(在 WPF 应用程序中)或 Control.Invoke(在 Windows Forms 应用程序中)。如果用户界面碰巧正在运行另一个等待同一锁的方法,死锁将会发生。你通常可以通过简单地调用 BeginInvoke 而不是 Invoke(或者依赖于存在同步上下文时自动执行此操作的异步函数)来修复这个问题。或者,在调用 Invoke 之前释放你的锁,尽管如果你的调用者获取了锁,则这种方法不起作用。
性能
锁定速度很快:在 2020 年代的计算机上,如果锁没有争用,你可以期望在不到 20 纳秒内获取和释放一个锁。如果有争用,随之而来的上下文切换将使开销接近微秒区域,尽管在线程实际重新调度之前可能会更长。
互斥体
Mutex 就像是 C# 中的 lock,但可以跨多个进程工作。换句话说,Mutex 可以是全局的,也可以是应用程序级的。获取和释放一个没有争用的 Mutex 大约需要半微秒,比 lock 慢 20 倍。
使用 Mutex 类,你调用 WaitOne 方法来锁定,ReleaseMutex 来解锁。与 lock 语句一样,只能从获取它的同一线程中释放 Mutex。
注意
如果忘记调用ReleaseMutex,而只是调用Close或Dispose,其他等待该互斥体的人会抛出AbandonedMutexException异常。
跨进程使用的Mutex的常见用途是确保程序一次只能运行一个实例。以下是实现方式:
// Naming a Mutex makes it available computer-wide. Use a name that's
// unique to your company and application (e.g., include your URL).
using var mutex = new Mutex (true, @"Global\oreilly.com OneAtATimeDemo");
// Wait a few seconds if contended, in case another instance
// of the program is still in the process of shutting down.
if (!mutex.WaitOne (TimeSpan.FromSeconds (3), false))
{
Console.WriteLine ("Another instance of the app is running. Bye!");
return;
}
try { RunProgram(); }
finally { mutex.ReleaseMutex (); }
void RunProgram()
{
Console.WriteLine ("Running. Press Enter to exit");
Console.ReadLine();
}
注意
如果在终端服务或单独的 Unix 控制台下运行,计算机范围的Mutex通常只对同一会话中的应用程序可见。要使其对所有终端服务器会话可见,请在名称前加上*Global*,如示例中所示。
加锁与线程安全
如果程序或方法能够在任何多线程场景下正确工作,则它是线程安全的。通过加锁和减少线程交互的可能性来实现线程安全。
一般用途的类型完全不会是线程安全的,原因如下:
-
在完全实现线程安全时的开发负担可能很大,特别是如果一个类型有很多字段(每个字段都是在任意多线程环境中进行交互的潜在因素)。
-
线程安全可能会带来性能开销(部分是由于类型是否真正被多线程使用而产生的)。
-
线程安全的类型并不一定能使使用它的程序线程安全,而通常后者所涉及的工作会使前者变得多余。
因此,线程安全通常只在需要处理特定多线程场景时实现。
然而,在多线程环境中运行大型和复杂类的几种“作弊”方法。其中一种方法是通过包装大段代码(甚至整个对象的访问)在单个独占锁中执行,强制高级别的序列化访问。事实上,如果你想在多线程环境中使用线程不安全的第三方代码(或大多数.NET 类型),这种策略是必不可少的。关键是简单地使用相同的独占锁来保护对线程不安全对象的所有属性、方法和字段的访问。如果对象的方法都执行得很快,这种解决方案效果很好(否则将会有很多阻塞发生)。
注意
除了基本类型之外,很少有.NET 类型在实例化时对于任何更多于并发只读访问是线程安全的。开发者需要通过独占锁来实现线程安全(我们在第二十二章中介绍的System.Collections.Concurrent集合是一个例外)。
另一种欺骗的方法是通过最小化线程交互来最小化共享数据。这是一种极好的方法,隐式地在“无状态”中间层应用程序和网页服务器中使用。因为多个客户端请求可能同时到达,所以它们调用的服务器方法必须是线程安全的。无状态设计(由于可伸缩性原因而受欢迎)本质上限制了交互的可能性,因为类在请求之间不保存数据。然后,线程交互仅限于您可能选择创建的静态字段,用于在内存中缓存常用数据并提供诸如身份验证和审计等基础设施服务。
另一种解决方案(在富客户端应用程序中)是在 UI 线程上运行访问共享状态的代码。正如我们在第十四章中看到的那样,异步函数使这变得容易。
线程安全和.NET 类型
您可以使用锁定将线程不安全的代码转换为线程安全的代码。一个很好的应用是.NET:几乎所有非原始类型在实例化时都不是线程安全的(超过只读访问),但是如果所有对任何给定对象的访问都通过锁保护,则可以在多线程代码中使用它们。以下是一个例子,在该例子中,两个线程同时向相同的List集合添加项目,然后枚举该列表:
class ThreadSafe
{
static List <string> _list = new List <string>();
static void Main()
{
new Thread (AddItem).Start();
new Thread (AddItem).Start();
}
static void AddItem()
{
lock (_list) _list.Add ("Item " + _list.Count);
string[] items;
lock (_list) items = _list.ToArray();
foreach (string s in items) Console.WriteLine (s);
}
}
在这种情况下,我们正在对_list对象本身进行锁定。如果我们有两个相互关联的列表,我们将需要选择一个公共对象进行锁定(我们可以提名其中一个列表,或者更好的是使用独立字段)。
枚举.NET 集合在某种意义上也是线程不安全的,如果在枚举过程中修改列表,则会抛出异常。在枚举期间而不是持续锁定,例如,在此示例中,我们首先将项目复制到数组中。如果我们在枚举期间执行的操作可能耗时较长,则避免过多保持锁定。 (另一个解决方案是使用读写锁;请参见“读写锁”。)
在围绕线程安全对象进行锁定时
有时,您还需要在访问线程安全对象时进行锁定。为了说明这一点,想象一下.NET 的List类确实是线程安全的,并且我们想要向列表添加一个项目:
if (!_list.Contains (newItem)) _list.Add (newItem);
无论列表是否线程安全,这个语句肯定不是!整个if语句都需要包装在锁中,以防止在测试容器和添加新项目之间被抢占。然后,同一把锁将需要在修改列表的任何地方使用。例如,以下语句也需要包装在相同的锁中,以确保它不会抢占前一个语句:
_list.Clear();
换句话说,我们需要像我们的线程不安全集合类一样进行锁定(使List类的假设线程安全性变得多余)。
注意
在访问集合时进行锁定可能会在高度并发的环境中导致过多的阻塞。为此,.NET 提供了线程安全的队列、栈和字典,我们在第二十二章中讨论过。
静态成员
在自定义锁周围包装对象的访问仅在所有并发线程都知道并使用该锁时才有效。如果对象的范围广泛,这可能不是情况。最糟糕的情况是公共类型中的静态成员。例如,想象一下如果DateTime 结构上的静态属性 DateTime.Now 不是线程安全的,并且两个并发调用可能导致混乱的输出或异常。唯一修复此问题的外部锁可能是在调用 DateTime.Now 之前锁定类型本身 — lock(typeof(DateTime))。只有当所有程序员同意执行此操作时才会起作用(这是不太可能的)。此外,在锁定类型本身时会创建自身的问题。
由于这个原因,DateTime 结构上的静态成员被精心设计为线程安全。这是.NET 中的一个常见模式:静态成员是线程安全的;实例成员则不是。 遵循这个模式在为公共使用编写类型时也是有意义的,以免造成不可能的线程安全困境。换句话说,通过使静态方法线程安全,你在编程时不会排除那些类型的使用者的线程安全性。
注意
静态方法中的线程安全性是你必须显式编码的内容:它不会因为方法是静态而自动发生!
只读线程安全性
使类型对于并发只读访问(可能的情况下)是有利的,因为这意味着消费者可以避免过多的锁定。许多.NET 类型遵循这一原则:例如,集合对于并发读取是线程安全的。
遵循这个原则本身很简单:如果你将一个类型标记为对于并发只读访问是线程安全的,请不要在消费者期望是只读的方法内写入字段(或在其周围进行锁定)。例如,在集合中实现 ToArray() 方法时,你可能会首先压缩集合的内部结构。然而,这会使得消费者期望该方法是只读的时候线程不安全。
只读线程安全性是枚举器与“可枚举对象”分离的原因之一:两个线程可以同时枚举一个集合,因为每个线程都获得一个单独的枚举器对象。
注意
在没有文档的情况下,假设一个方法的性质是只读的是值得谨慎的。一个很好的例子是 Random 类:当你调用 Random.Next() 时,其内部实现需要更新私有种子值。因此,你必须要么在使用 Random 类时进行锁定,要么为每个线程保持单独的实例。
应用服务器中的线程安全性
应用服务器需要是多线程的以处理同时的客户端请求。ASP.NET Core 和 Web API 应用程序隐式地是多线程的。这意味着在服务器端编写代码时,如果可能有线程之间的交互,则必须考虑线程安全性。幸运的是,这种可能性很少;典型的服务器类要么是无状态的(没有字段),要么具有为每个客户端或每个请求创建单独对象实例的激活模型。交互通常仅通过静态字段产生,有时用于缓存数据库内存部分以提高性能。
例如,假设您有一个RetrieveUser方法用于查询数据库:
// User is a custom class with fields for user data
internal User RetrieveUser (int id) { ... }
如果此方法经常被调用,可以通过将结果缓存到静态的Dictionary中来提高性能。以下是一个概念上简单的解决方案,考虑了线程安全性:
static class UserCache
{
static Dictionary <int, User> _users = new Dictionary <int, User>();
internal static User GetUser (int id)
{
User u = null;
lock (_users)
if (_users.TryGetValue (id, out u))
return u;
u = RetrieveUser (id); // Method to retrieve from database;
lock (_users) _users [id] = u;
return u;
}
}
至少我们必须在读取和更新字典时进行锁定,以确保线程安全性。在这个例子中,我们在简单性和锁定性能之间选择了一个实际的折衷方案。我们的设计会造成小小的低效性:如果两个线程同时使用相同的先前未检索到的id调用此方法,RetrieveUser方法将被调用两次,字典将被不必要地更新。在整个方法内部进行一次锁定将防止这种情况发生,但会造成更糟糕的低效性:整个缓存在调用RetrieveUser期间将被锁定,期间其他线程将被阻塞在检索任何用户。
对于一个理想的解决方案,我们需要使用我们在“同步完成”中描述的策略。我们不是缓存User,而是缓存Task<User>,然后调用者等待它:
static class UserCache
{
static Dictionary <int, Task<User>> _userTasks =
new Dictionary <int, Task<User>>();
internal static Task<User> GetUserAsync (int id)
{
lock (_userTasks)
if (_userTasks.TryGetValue (id, out var userTask))
return userTask;
else
return _userTasks [id] = Task.Run (() => RetrieveUser (id));
}
}
请注意,我们现在有一个覆盖整个方法逻辑的单个锁。我们可以做到这一点而不影响并发性,因为在锁内部我们只是访问字典,并且(可能)启动一个异步操作(通过调用Task.Run)。如果两个线程同时使用相同的 ID 调用此方法,它们将都等待相同的任务,这正是我们想要的结果。
不可变对象
不可变对象是一种状态不可更改的对象—无论是外部还是内部。不可变对象中的字段通常声明为只读,并在构造期间完全初始化。
不可变性是函数式编程的标志,它不是变异对象,而是创建具有不同属性的新对象。LINQ 遵循这一范式。在多线程环境中,不可变性也是有价值的,因为它避免了共享可写状态的问题,通过消除(或最小化)可写状态。
一种模式是使用不可变对象封装一组相关字段,以最小化锁定持续时间。举个非常简单的例子,假设我们有两个字段,如下所示:
int _percentComplete;
string _statusMessage;
现在假设我们希望以原子方式读取和写入它们。与其在这些字段周围加锁不如定义以下不可变类:
class ProgressStatus // Represents progress of some activity
{
public readonly int PercentComplete;
public readonly string StatusMessage;
// This class might have many more fields...
public ProgressStatus (int percentComplete, string statusMessage)
{
PercentComplete = percentComplete;
StatusMessage = statusMessage;
}
}
然后我们可以定义一个该类型的单一字段,以及一个锁对象:
readonly object _statusLocker = new object();
ProgressStatus _status;
现在,我们可以在不超过单个赋值的情况下读取和写入该类型的值了:
var status = new ProgressStatus (50, "Working on it");
// Imagine we were assigning many more fields...
// ...
lock (_statusLocker) _status = status; // Very brief lock
要读取对象,我们首先获取对象引用的副本(在锁内)。然后,我们可以读取其值而不需要保持锁定:
ProgressStatus status;
lock (_statusLocker) status = _status; // Again, a brief lock
int pc = status.PercentComplete;
string msg = status.StatusMessage;
...
非排他锁定
非排他锁定构造用于限制并发。在本节中,我们介绍信号量和读/写锁,并演示了SemaphoreSlim类如何通过异步操作限制并发。
信号量
一个信号量就像一个有限容量的夜总会,由门卫强制执行。当夜总会满员时,不能再进入更多人,外面就会排起队伍。
信号量的计数对应于夜总会中的空位数。释放信号量增加计数;这通常发生在有人离开夜总会(对应于释放资源)或者初始化信号量时(设置其起始容量)。您也可以随时调用Release来增加容量。
在信号量上等待会减少计数,通常发生在获取资源之前。在信号量当前计数大于0时,调用Wait会立即完成。
信号量可以选择性地有一个最大计数,作为硬限制。将计数增加到超过此限制会引发异常。在构造信号量时,您指定初始计数(起始容量),以及可选的最大限制。
初始计数为一的信号量类似于Mutex或lock,但信号量没有“所有者”——它是线程不可知的。任何线程都可以在信号量上调用Release,而在Mutex和lock中,只有获得锁的线程才能释放它。
注意
这个类有两个功能上类似的版本:Semaphore和SemaphoreSlim。后者已经经过优化,以满足并行编程的低延迟需求。它在传统的多线程编程中也很有用,因为它允许在等待时指定取消令牌(见“取消”),并且暴露了一个WaitAsync方法用于异步编程。但是,您不能用它来进行进程间信号传递。
Semaphore调用WaitOne和Release大约需要一微秒;SemaphoreSlim则大约需要十分之一微秒。
信号量在限制并发方面非常有用——防止太多线程同时执行特定的代码片段。在以下示例中,五个线程试图进入一个一次只允许三个线程的夜总会:
class TheClub // No door lists!
{
static SemaphoreSlim _sem = new SemaphoreSlim (3); // Capacity of 3
static void Main()
{
for (int i = 1; i <= 5; i++) new Thread (Enter).Start (i);
}
static void Enter (object id)
{
Console.WriteLine (id + " wants to enter");
_sem.Wait();
Console.WriteLine (id + " is in!"); // Only three threads
Thread.Sleep (1000 * (int) id); // can be here at
Console.WriteLine (id + " is leaving"); // a time.
_sem.Release();
}
}
1 wants to enter
1 is in!
2 wants to enter
2 is in!
3 wants to enter
3 is in!
4 wants to enter
5 wants to enter
1 is leaving
4 is in!
2 is leaving
5 is in!
也可以合法地用初始计数(容量)为 0 来实例化一个信号量,然后调用Release来增加其计数。以下两个信号量是等价的:
var semaphore1 = new SemaphoreSlim (3);
var semaphore2 = new SemaphoreSlim (0); semaphore2.Release (3);
一个Semaphore,如果命名,可以像Mutex一样跨进程工作(命名的Semaphore仅在 Windows 上可用,而命名的Mutex也适用于 Unix 平台)。
异步信号量和锁
跨越await语句进行锁定是非法的:
lock (_locker)
{
await Task.Delay (1000); // Compilation error
...
}
这样做是没有意义的,因为锁由一个线程持有,在从等待返回时通常会更改。锁定还会阻塞,并且阻塞可能很长时间,这正是您在异步函数中尝试不实现的。
然而,有时仍然希望使异步操作按顺序执行——或者限制并行性,以便不超过n个操作同时执行。例如,考虑一个 Web 浏览器:它需要并行执行异步下载,但可能希望强加限制,最多同时进行 10 个下载。我们可以通过使用SemaphoreSlim来实现这一点:
SemaphoreSlim _semaphore = new SemaphoreSlim (10);
async Task<byte[]> DownloadWithSemaphoreAsync (string uri)
{
await _semaphore.WaitAsync();
try { return await new WebClient().DownloadDataTaskAsync (uri); }
finally { _semaphore.Release(); }
}
将信号量的initialCount减少到1会将最大并行性减少为 1,将其转换为异步锁。
编写一个 EnterAsync 扩展方法
以下扩展方法通过使用我们在“匿名处理”中编写的Disposable类简化了SemaphoreSlim的异步使用:
public static async Task<IDisposable> EnterAsync (this SemaphoreSlim ss)
{
await ss.WaitAsync().ConfigureAwait (false);
return Disposable.Create (() => ss.Release());
}
有了这种方法,我们可以将我们的DownloadWithSemaphoreAsync方法重写如下:
async Task<byte[]> DownloadWithSemaphoreAsync (string uri)
{
using (await _semaphore.EnterAsync())
return await new WebClient().DownloadDataTaskAsync (uri);
}
Parallel.ForEachAsync
从.NET 6 开始,另一种限制异步并发性的方法是使用Parallel.ForEachAsync方法。假设uris是您希望下载的 URI 数组,以下是如何并行下载它们,同时将并发性限制为最多 10 个并行下载:
await Parallel.ForEachAsync (uris,
new ParallelOptions { MaxDegreeOfParallelism = 10 },
async (uri, cancelToken) =>
{
var download = await new HttpClient().GetByteArrayAsync (uri);
Console.WriteLine ($"Downloaded {download.Length} bytes");
});
Parallel 类中的其他方法旨在(计算密集型)并行编程场景中使用,我们在第二十二章中描述了这些场景。
读者/写者锁
很多时候,一个类型的实例对于并发读取操作是线程安全的,但对于并发更新(或并发读取和更新)是不安全的。这在像文件这样的资源上也可能是真实的。虽然使用简单的独占锁保护此类类型的实例通常可以达到预期的效果,但如果有许多读取者和偶尔的更新者,则可能会不合理地限制并发性。一个例子是在业务应用服务器中,常用数据被缓存以便在静态字段中快速检索。ReaderWriterLockSlim类专为在这种场景中提供最大可用性的锁定而设计。
注意
ReaderWriterLockSlim是旧的“fat”ReaderWriterLock类的替代品。后者在功能上类似,但速度慢几倍,并且在处理锁升级的机制上存在设计缺陷。
与普通的lock(Monitor.Enter/Exit)相比,ReaderWriterLockSlim仍然慢两倍。权衡是更少的竞争(当有大量读取和最小的写入时)。
对于这两个类,有两种基本类型的锁——读锁和写锁:
-
写锁是互斥的。
-
读锁与其他读锁兼容。
因此,持有写锁的线程会阻止所有试图获取读锁或写锁的其他线程(反之亦然)。但是,如果没有线程持有写锁,则任意数量的线程可以同时获取读锁。
ReaderWriterLockSlim定义了以下用于获取和释放读/写锁的方法:
public void EnterReadLock();
public void ExitReadLock();
public void EnterWriteLock();
public void ExitWriteLock();
此外,所有Enter*XXX*方法都有相应的“Try”版本,接受超时参数,类似于Monitor.TryEnter(如果资源争用严重,很容易出现超时)。 ReaderWriterLock提供类似的方法,名为Acquire*XXX*和Release*XXX*。如果超时发生,这些方法会抛出ApplicationException,而不是返回false。
以下程序演示了ReaderWriterLockSlim。三个线程不断枚举列表,而另外两个线程每 100 毫秒向列表追加一个随机数。读锁保护列表读取器,写锁保护列表写入器:
class SlimDemo
{
static ReaderWriterLockSlim _rw = new ReaderWriterLockSlim();
static List<int> _items = new List<int>();
static Random _rand = new Random();
static void Main()
{
new Thread (Read).Start();
new Thread (Read).Start();
new Thread (Read).Start();
new Thread (Write).Start ("A");
new Thread (Write).Start ("B");
}
static void Read()
{
while (true)
{
_rw.EnterReadLock();
foreach (int i in _items) Thread.Sleep (10);
_rw.ExitReadLock();
}
}
static void Write (object threadID)
{
while (true)
{
int newNumber = GetRandNum (100);
_rw.EnterWriteLock();
_items.Add (newNumber);
_rw.ExitWriteLock();
Console.WriteLine ("Thread " + threadID + " added " + newNumber);
Thread.Sleep (100);
}
}
static int GetRandNum (int max) { lock (_rand) return _rand.Next(max); }
}
注意
在生产代码中,通常会添加try/finally块,以确保在抛出异常时释放锁。
这是结果:
Thread B added 61
Thread A added 83
Thread B added 55
Thread A added 33
...
ReaderWriterLockSlim允许比简单锁更多的并发Read活动。我们可以通过在Write方法的while循环开始处插入以下行来说明这一点:
Console.WriteLine (_rw.CurrentReadCount + " concurrent readers");
这几乎总是打印“3 个并发读取器”(Read方法大部分时间在其foreach循环内)。除了CurrentReadCount,ReaderWriterLockSlim还提供了以下用于监视锁的属性:
public bool IsReadLockHeld { get; }
public bool IsUpgradeableReadLockHeld { get; }
public bool IsWriteLockHeld { get; }
public int WaitingReadCount { get; }
public int WaitingUpgradeCount { get; }
public int WaitingWriteCount { get; }
public int RecursiveReadCount { get; }
public int RecursiveUpgradeCount { get; }
public int RecursiveWriteCount { get; }
升级锁
有时,将读锁替换为写锁在单个原子操作中是有用的。例如,假设您希望仅在列表中不存在该项时才将项添加到列表中。理想情况下,您希望尽量减少持有(独占)写锁的时间,因此可以按以下步骤进行:
-
获取读锁。
-
测试列表中是否已经存在该项;如果是,则释放锁并
return。 -
释放读锁。
-
获取写锁。
-
添加该项。
问题在于,在步骤 3 和步骤 4 之间,另一个线程可能会悄悄地修改列表(例如,添加相同的项)。 ReaderWriterLockSlim通过第三种称为升级锁的锁来解决这个问题。升级锁类似于读锁,但稍后可以以原子操作升级为写锁。以下是如何使用它的方法:
-
调用
EnterUpgradeableReadLock。 -
执行基于读的活动(例如,测试列表中是否已经存在该项)。
-
调用
EnterWriteLock(这将升级锁为写锁)。 -
执行基于写的活动(例如,将项添加到列表中)。
-
调用
ExitWriteLock(这将写锁转换回升级锁)。 -
执行任何其他基于读取的活动。
-
调用
ExitUpgradeableReadLock。
从调用者的角度来看,这更像是嵌套或递归锁定。 但在功能上,在第 3 步中,ReaderWriterLockSlim释放您的读锁并原子性地获取一个新的写锁。
读锁和可升级锁之间还有一个重要的区别。 虽然可升级锁可以与任意数量的读锁共存,但每次只能获取一个可升级锁。 这通过串行化竞争转换来防止转换死锁,就像 SQL Server 中的更新锁一样:
| SQL Server | ReaderWriterLockSlim |
|---|---|
| 共享锁 | 读锁 |
| 独占锁 | 写锁 |
| 更新锁 | 可升级锁 |
我们可以通过更改前面示例中的Write方法来演示可升级锁,只有在列表中不存在时才添加一个数字:
while (true)
{
int newNumber = GetRandNum (100);
_rw.EnterUpgradeableReadLock();
if (!_items.Contains (newNumber))
{
_rw.EnterWriteLock();
_items.Add (newNumber);
_rw.ExitWriteLock();
Console.WriteLine ("Thread " + threadID + " added " + newNumber);
}
_rw.ExitUpgradeableReadLock();
Thread.Sleep (100);
}
注意
ReaderWriterLock也可以进行锁定转换,但不可靠,因为它不支持可升级锁的概念。 这就是为什么ReaderWriterLockSlim的设计者们不得不重新开始用一个新的类的原因。
锁定递归
通常,使用ReaderWriterLockSlim禁止嵌套或递归锁定。 因此,以下操作会引发异常:
var rw = new ReaderWriterLockSlim();
rw.EnterReadLock();
rw.EnterReadLock();
rw.ExitReadLock();
rw.ExitReadLock();
如果您按以下方式构造ReaderWriterLockSlim,它将无错误运行:
var rw = new ReaderWriterLockSlim (LockRecursionPolicy.SupportsRecursion);
这确保只有在计划中才能发生递归锁定。 递归锁定可能会产生不必要的复杂性,因为可能会获取多种类型的锁:
rw.EnterWriteLock();
rw.EnterReadLock();
Console.WriteLine (rw.IsReadLockHeld); // True
Console.WriteLine (rw.IsWriteLockHeld); // True
rw.ExitReadLock();
rw.ExitWriteLock();
基本规则是,一旦你获得了一个锁,后续的递归锁可以按以下规模减少,但不能增加:
读锁→可升级锁→写锁
但是,请求将可升级锁升级为写锁总是合法的。
使用事件等待句柄进行信号传递
最简单的信号构造被称为事件等待句柄(与 C#事件无关)。 事件等待句柄有三种类型:AutoResetEvent、ManualResetEvent(Slim)和CountdownEvent。 前两者基于常见的EventWaitHandle类,从中继承所有功能。
AutoResetEvent
AutoResetEvent类似于票证旋转门:插入一张票证允许一人通过。 类名中的“auto”指的是开放旋转门后自动关闭或“复位”的事实。 线程通过调用WaitOne(在这个“one”旋转门等待直到它打开)等待或阻塞在旋转门处,并通过调用Set方法插入票证。 如果多个线程调用WaitOne,则在旋转门后面会形成一个队列²。 票证可以来自任何线程;换句话说,任何(未阻塞)有权访问AutoResetEvent对象的线程都可以在其上调用Set以释放一个被阻塞的线程。
您可以通过两种方式创建AutoResetEvent。 第一种是通过其构造函数:
var auto = new AutoResetEvent (false);
(在构造函数中传入true相当于立即调用Set。)创建AutoResetEvent的第二种方式如下:
var auto = new EventWaitHandle (false, EventResetMode.AutoReset);
在以下示例中,启动了一个线程,其工作是等待另一个线程的信号(参见图 21-1):
class BasicWaitHandle
{
static EventWaitHandle _waitHandle = new AutoResetEvent (false);
static void Main()
{
new Thread (Waiter).Start();
Thread.Sleep (1000); // Pause for a second...
_waitHandle.Set(); // Wake up the Waiter.
}
static void Waiter()
{
Console.WriteLine ("Waiting...");
_waitHandle.WaitOne(); // Wait for notification
Console.WriteLine ("Notified");
}
}
// Output:
Waiting... *(pause)* Notified.

图 21-1. 使用EventWaitHandle进行信号
如果在没有线程等待时调用Set,则句柄保持打开状态,直到某个线程调用WaitOne。这种行为有助于防止一个线程走向旋转门,另一个线程插入票(“哎呀,票插得太早了,现在你将无限期地等待!”)。然而,在一个无人等待的旋转门上重复调用Set并不会在整个队伍到达时允许他们全部通过:只有下一个人可以通过,并且多余的票“被浪费”。
调用Reset关闭一个AutoResetEvent的旋转门(如果它是打开的),而不会等待或阻塞。
WaitOne接受一个可选的超时参数,如果由于超时而结束等待,则返回false而不是获取信号。
注
使用超时值0调用WaitOne可以测试等待句柄是否“打开”,而不会阻塞调用者。不过,请记住,如果已经打开,这样做会重置AutoResetEvent。
双向信号
假设我们希望主线程连续三次向工作线程发出信号。如果主线程快速连续调用等待句柄上的Set,则第二次或第三次信号可能会丢失,因为工作线程可能需要时间处理每个信号。
解决方案是主线程在向其发出信号之前等待工作线程就绪。我们可以通过使用另一个AutoResetEvent来实现:
class TwoWaySignaling
{
static EventWaitHandle _ready = new AutoResetEvent (false);
static EventWaitHandle _go = new AutoResetEvent (false);
static readonly object _locker = new object();
static string _message;
static void Main()
{
new Thread (Work).Start();
_ready.WaitOne(); // First wait until worker is ready
lock (_locker) _message = "ooo";
_go.Set(); // Tell worker to go
_ready.WaitOne();
lock (_locker) _message = "ahhh"; // Give the worker another message
_go.Set();
_ready.WaitOne();
lock (_locker) _message = null; // Signal the worker to exit
_go.Set();
}
static void Work()
{
while (true)
{
_ready.Set(); // Indicate that we're ready
_go.WaitOne(); // Wait to be kicked off...
lock (_locker)
{
if (_message == null) return; // Gracefully exit
Console.WriteLine (_message);
}
}
}
}
// Output:
ooo
ahhh
图 21-2 展示了这个过程。

图 21-2. 双向信号
在这里,我们使用空消息来指示工作线程应该结束。对于运行无限期的线程,拥有退出策略是很重要的!
ManualResetEvent
正如我们在第十四章中描述的那样,ManualResetEvent的功能类似于一个简单的门闩。调用Set打开门闩,允许调用WaitOne的任意数量线程通过。调用Reset关闭门闩。调用WaitOne在关闭状态下的门闩会阻塞;下次打开门闩时,它们将一次性释放。除了这些差异,ManualResetEvent的功能与AutoResetEvent类似。
与AutoResetEvent一样,可以通过两种方式构造ManualResetEvent:
var manual1 = new ManualResetEvent (false);
var manual2 = new EventWaitHandle (false, EventResetMode.ManualReset);
注
还有另一个称为ManualResetEventSlim的版本的ManualResetEvent。后者针对短等待时间进行了优化,具有可以选择自旋一定次数的能力。它还具有更有效的托管实现,并允许通过CancellationToken取消Wait。ManualResetEventSlim不继承自WaitHandle;但是,当调用时它公开一个WaitHandle属性,返回基于WaitHandle的对象(具有传统等待句柄的性能特征)。
ManualResetEvent在允许一个线程解除阻塞多个其他线程方面非常有用。相反的情况由CountdownEvent处理。
CountdownEvent
CountdownEvent允许您等待多个线程。该类具有高效的完全托管实现。要使用该类,请用您要等待的线程数或“计数”来实例化它:
var countdown = new CountdownEvent (3); // Initialize with "count" of 3.
调用Signal减少“计数”;调用Wait阻塞直到计数减少为零:
new Thread (SaySomething).Start ("I am thread 1");
new Thread (SaySomething).Start ("I am thread 2");
new Thread (SaySomething).Start ("I am thread 3");
countdown.Wait(); // Blocks until Signal has been called 3 times
Console.WriteLine ("All threads have finished speaking!");
void SaySomething (object thing)
{
Thread.Sleep (1000);
Console.WriteLine (thing);
countdown.Signal();
}
注意
有时您可以通过使用我们在第二十二章中描述的结构化并行构造(如 PLINQ 和Parallel类)更轻松地解决适合CountdownEvent的问题。
您可以通过调用AddCount重新增加CountdownEvent的计数。但是,如果它已经达到零,这将抛出异常:无法通过调用AddCount“取消”CountdownEvent。为防止抛出异常的可能性,您可以调用TryAddCount,如果倒计时为零,则返回false。
要取消标记一个倒计时事件,请调用Reset:这既取消了构造并将其计数重置为原始值。
像ManualResetEventSlim一样,CountdownEvent公开了一个WaitHandle属性,用于某些其他类或方法期望基于WaitHandle的对象的场景。
创建跨进程的 EventWaitHandle
EventWaitHandle的构造函数允许创建一个“命名”的EventWaitHandle,能够跨多个进程进行操作。名称只是一个字符串,可以是任何不会意外与他人冲突的值!如果计算机上已经使用了该名称,则会得到对同一基础EventWaitHandle的引用;否则,操作系统会创建一个新的。以下是一个示例:
EventWaitHandle wh = new EventWaitHandle (false, EventResetMode.AutoReset,
@"Global\MyCompany.MyApp.SomeName");
如果两个应用程序都运行此代码,则它们将能够互相发信号:等待句柄将跨所有线程在两个进程中运行。
命名事件等待句柄仅在 Windows 上可用。
等待句柄和继续
而不是等待等待句柄(并阻塞您的线程),您可以通过调用ThreadPool.RegisterWaitForSingleObject为其附加一个“继续”。此方法接受一个委托,在等待句柄被标记时执行:
var starter = new ManualResetEvent (false);
RegisteredWaitHandle reg = ThreadPool.RegisterWaitForSingleObject
(starter, Go, "Some Data", -1, true);
Thread.Sleep (5000);
Console.WriteLine ("Signaling worker...");
starter.Set();
Console.ReadLine();
reg.Unregister (starter); // Clean up when we’re done.
void Go (object data, bool timedOut)
{
Console.WriteLine ("Started - " + data);
// Perform task...
}
// Output:
(5 second delay)
Signaling worker...
Started - Some Data
当等待句柄被标记(或超时时间到达)时,委托在一个池线程上运行。然后,您应该调用Unregister来释放到回调的非托管句柄。
除了等待句柄和委托之外,RegisterWaitForSingleObject还接受一个“黑匣子”对象,它将其传递给你的委托方法(类似于ParameterizedThreadStart),以及一个毫秒级超时(-1表示无超时)和一个布尔标志,指示请求是一次性而非定期的。
注意
你只能可靠地对每个等待句柄调用一次RegisterWaitForSingleObject。在同一个等待句柄上再次调用此方法会导致间歇性失败,即使未信号化的等待句柄会像已信号化一样触发回调。
此限制使(非精简)等待句柄非常不适合异步编程。
WaitAll、WaitAny和SignalAndWait
除了Set、WaitOne和Reset方法之外,WaitHandle类上还有静态方法来解决更复杂的同步问题。WaitAny、WaitAll和SignalAndWait方法在多个句柄上执行信号和等待操作。等待句柄可以是不同类型的(包括Mutex和Semaphore,因为它们也从抽象的WaitHandle类派生)。通过它们的WaitHandle属性,ManualResetEventSlim和CountdownEvent也可以参与这些方法。
注意
WaitAll和SignalAndWait与传统的 COM 架构有一个奇怪的联系:这些方法要求调用者位于多线程公寓中,这种模型最不适合互操作性。例如,WPF 或 Windows Forms 应用程序的主线程无法在此模式下与剪贴板交互。我们很快将讨论替代方法。
WaitHandle.WaitAny在等待句柄数组中的任何一个句柄;WaitHandle.WaitAll原子地等待所有给定的句柄。这意味着如果你等待两个AutoResetEvent:
-
WaitAny永远不会“锁住”两个事件。 -
WaitAll永远不会“锁住”仅一个事件。
SignalAndWait在一个WaitHandle上调用Set,然后在另一个WaitHandle上调用WaitOne。在信号第一个句柄后,它将立即排队等待第二个句柄;这有助于它成功(尽管操作并非真正原子)。你可以将这种方法看作是在一对EventWaitHandle上“交换”一个信号以设置两个线程在同一时间点“会合”或“汇合”。任一AutoResetEvent或ManualResetEvent都能完成任务。第一个线程执行以下操作:
WaitHandle.SignalAndWait (wh1, wh2);
第二个线程执行相反操作:
WaitHandle.SignalAndWait (wh2, wh1);
替代方案替代WaitAll和SignalAndWait
WaitAll 和 SignalAndWait 不会在单线程公寓中运行。幸运的是,有替代方案。对于 SignalAndWait,很少需要其队列跳跃语义:例如,在我们的汇合示例中,如果仅仅是用等待句柄来实现,只需在第一个等待句柄上调用 Set,然后在其他等待句柄上调用 WaitOne 就足够了。在下一节中,我们将探讨另一种实现线程汇合的选项。
对于 WaitAny 和 WaitAll,如果不需要原子性,可以使用我们在前一节中编写的代码将等待句柄转换为任务,然后使用 Task.WhenAny 和 Task.WhenAll(第十四章)。
如果需要原子性,可以采用最低级别的信号处理方法,并使用 Monitor 的 Wait 和 Pulse 方法自行编写逻辑。我们在http://albahari.com/threading中详细描述了 Wait 和 Pulse。
Barrier 类
Barrier 类实现了线程执行屏障,允许多个线程在某个时间点汇合(与 Thread.MemoryBarrier 不要混淆)。该类非常快速高效,基于 Wait、Pulse 和自旋锁实现。
使用此类:
-
实例化它时,指定应参与汇合的线程数(您可以随后调用
AddParticipants/RemoveParticipants更改此值)。 -
每个线程在希望汇合时都要调用
SignalAndWait。
使用值为 3 实例化 Barrier 会导致 SignalAndWait 阻塞,直到该方法被调用三次。然后它重新开始:再次调用 SignalAndWait 将阻塞,直到再次调用三次。这使得每个线程与其他线程“同步”。
在以下示例中,三个线程分别写入数字 0 到 4,并与其他线程保持同步:
var barrier = new Barrier (3);
new Thread (Speak).Start();
new Thread (Speak).Start();
new Thread (Speak).Start();
void Speak()
{
for (int i = 0; i < 5; i++)
{
Console.Write (i + " ");
barrier.SignalAndWait();
}
}
OUTPUT: 0 0 0 1 1 1 2 2 2 3 3 3 4 4 4
Barrier 的一个非常有用的特性是在构造时还可以指定后阶段操作。这是一个在调用 SignalAndWait n 次后、但线程解除阻塞之前运行的委托(如 图 21-3 中显示的阴影区域)。例如,如果我们按以下方式实例化我们的屏障:
static Barrier _barrier = new Barrier (3, barrier => Console.WriteLine());
输出如下:
0 0 0
1 1 1
2 2 2
3 3 3
4 4 4

图 21-3. 屏障
后阶段操作对于合并来自每个工作线程的数据非常有用。它不需要担心抢占,因为所有工作线程在其执行期间都会被阻塞。
惰性初始化
线程中常见的问题是如何以线程安全的方式惰性初始化共享字段。当需要构造成本高昂的类型字段时就会出现这种需求:
class Foo
{
public readonly Expensive Expensive = new Expensive();
...
}
class Expensive { /* Suppose this is expensive to construct */ }
此代码的问题在于实例化 Foo 会导致实例化 Expensive 的性能成本,无论是否实际访问了 Expensive 字段。显而易见的答案是按需构造实例:
class Foo
{
Expensive _expensive;
public Expensive Expensive // *Lazily* instantiate Expensive
{
get
{
if (_expensive == null) _expensive = new Expensive();
return _expensive;
}
}
...
}
那么问题来了,这样做是否线程安全?除了我们在没有内存屏障的情况下在锁之外访问_expensive之外,考虑一下如果两个线程同时访问这个属性会发生什么。它们都可能满足if语句的谓词,每个线程最终都会得到一个不同的Expensive实例。因为这可能导致微妙的错误,我们通常会说,总的来说,这段代码是不线程安全的。
解决问题的方法是在检查和初始化对象周围加锁:
Expensive _expensive;
readonly object _expenseLock = new object();
public Expensive Expensive
{
get
{
lock (_expenseLock)
{
if (_expensive == null) _expensive = new Expensive();
return _expensive;
}
}
}
Lazy
Lazy<T>类可用于帮助进行延迟初始化。如果使用true参数实例化它,它实现了刚刚描述的线程安全初始化模式。
注意
Lazy<T>实际上实现了这种模式的微优化版本,称为双重检查锁定。双重检查锁定执行额外的 volatile 读取,以避免在对象已初始化时获取锁的成本。
要使用Lazy<T>,用告诉它如何初始化新值的值工厂委托实例化该类,并使用参数true。然后,通过Value属性访问其值:
Lazy<Expensive> _expensive = new Lazy<Expensive>
(() => new Expensive(), true);
public Expensive Expensive { get { return _expensive.Value; } }
如果将false传递给Lazy<T>的构造函数,它将实现我们在本节开头描述的线程不安全的延迟初始化模式——当您想在单线程上下文中使用Lazy<T>时,这是合理的。
LazyInitializer
LazyInitializer是一个静态类,其工作方式与Lazy<T>完全相同,除了:
-
它的功能通过直接在自己类型的字段上操作的静态方法公开。这样可以避免一级间接,提高在需要极端优化的情况下的性能。
-
它提供了另一种初始化模式,其中多个线程可以竞速初始化。
要使用LazyInitializer,在访问字段之前调用EnsureInitialized,将字段的引用和工厂委托传递给它:
Expensive _expensive;
public Expensive Expensive
{
get // Implement double-checked locking
{
LazyInitializer.EnsureInitialized (ref _expensive,
() => new Expensive());
return _expensive;
}
}
您还可以传入另一个参数,请求竞争的线程竞速初始化。这听起来与我们原来的线程不安全的示例类似,不同之处在于第一个完成的线程总是获胜,因此最终只会得到一个实例。这种技术的优势在于,它甚至比双重检查锁定更快(在多核上),因为它可以完全不使用锁定,而是使用我们在“非阻塞同步”和“延迟初始化”中描述的先进技术。这是一种极端(而且很少需要的)优化,代价是:
-
当有更多的线程竞速初始化时,速度会变慢,超过了你的核心数。
-
这可能会浪费 CPU 资源执行冗余的初始化。
-
初始化逻辑必须是线程安全的(在这种情况下,如果
Expensive的构造函数写入静态字段,它将是线程不安全的)。 -
如果初始化器实例化一个需要处理的对象,没有额外逻辑的话,"浪费"的对象不会被处理。
线程本地存储
本章的大部分内容集中在同步构造和多线程同时访问相同数据带来的问题上。然而,有时您希望保持数据隔离,确保每个线程有自己的副本。局部变量正好实现了这一点,但它们仅适用于瞬态数据。
解决方案是线程本地存储。您可能难以想象一种要求:希望将数据隔离到线程的数据通常是短暂性的。它的主要应用是用于存储“带外”数据——支持执行路径基础设施的数据,如消息、事务和安全令牌。在方法参数中传递这样的数据可能会很笨拙,也可能会疏远除了您自己的方法之外的所有人;在普通的静态字段中存储此类信息意味着在所有线程之间共享它。
线程本地存储在优化并行代码方面也非常有用。它允许每个线程独占地访问其自己的版本的线程不安全对象,而无需锁定并且无需在方法调用之间重建该对象。
有四种实现线程本地存储的方法。我们将在以下小节中看一下它们。
[ThreadStatic]
最简单的线程本地存储方法是使用ThreadStatic属性标记静态字段:
[ThreadStatic] static int _x;
每个线程都会看到_x的一个独立副本。
不幸的是,[ThreadStatic]对实例字段无效(它仅仅不起作用);它也无法很好地与字段初始化器配合使用——它们只在执行静态构造函数时在当前运行的线程上执行一次。如果需要操作实例字段或者以非默认值开始,ThreadLocal<T>提供了更好的选择。
ThreadLocal<T>
ThreadLocal<T>为静态和实例字段提供了线程本地存储,并允许您指定默认值。
下面是如何为每个线程创建一个默认值为3的ThreadLocal<int>:
static ThreadLocal<int> _x = new ThreadLocal<int> (() => 3);
然后,您可以使用_x的Value属性获取或设置其线程本地值。使用ThreadLocal的一个额外好处是值的延迟评估:工厂函数在第一次调用时进行评估(对于每个线程)。
ThreadLocal<T>和实例字段
ThreadLocal<T>在处理实例字段和捕获的局部变量时也非常有用。例如,考虑在多线程环境中生成随机数的问题。Random类不是线程安全的,因此我们必须在使用Random时要么加锁(限制并发性),要么为每个线程生成一个独立的Random对象。ThreadLocal<T>使后者变得简单:
var localRandom = new ThreadLocal<Random>(() => new Random());
Console.WriteLine (localRandom.Value.Next());
我们用于创建Random对象的工厂函数有点简单,因为Random的无参构造函数依赖于系统时钟以获取随机数种子。这可能导致在相隔不到10 ms内创建的两个Random对象使用相同的种子。以下是解决此问题的一种方法:
var localRandom = new ThreadLocal<Random>
( () => new Random (Guid.NewGuid().GetHashCode()) );
我们在第二十二章中使用这个(请参阅“PLINQ”中的并行拼写检查示例)。
获取数据和设置数据
第三种方法是使用Thread类中的两个方法:GetData和SetData。它们将数据存储在特定于线程的“槽位”中。Thread.GetData从线程的隔离数据存储中读取;Thread.SetData向其写入。这两个方法都需要一个LocalDataStoreSlot对象来标识槽位。您可以在所有线程中使用相同的槽位,并且它们仍然会得到不同的值。以下是一个示例:
class Test
{
// The same LocalDataStoreSlot object can be used across all threads.
LocalDataStoreSlot _secSlot = Thread.GetNamedDataSlot ("securityLevel");
// This property has a separate value on each thread.
int SecurityLevel
{
get
{
object data = Thread.GetData (_secSlot);
return data == null ? 0 : (int) data; // null == uninitialized
}
set { Thread.SetData (_secSlot, value); }
}
...
在这个实例中,我们调用了Thread.GetNamedDataSlot,它创建了一个命名的槽位——这允许在整个应用程序中共享该槽位。或者,您可以通过调用Thread.AllocateDataSlot来自行控制槽位的作用域:
class Test
{
LocalDataStoreSlot _secSlot = Thread.AllocateDataSlot();
...
Thread.FreeNamedDataSlot将释放跨所有线程的命名数据槽,但仅当该LocalDataStoreSlot的所有引用都已经超出范围并且已被垃圾收集时。这确保线程在需要时保持对适当LocalDataStoreSlot对象的引用,以免槽位被废弃。
异步本地
到目前为止,我们讨论的线程本地存储方法与异步函数不兼容,因为在await之后,执行可能会在不同的线程上恢复。AsyncLocal<T>类通过在await期间保留其值来解决这个问题:
static AsyncLocal<string> _asyncLocalTest = new AsyncLocal<string>();
async void Main()
{
_asyncLocalTest.Value = "test";
await Task.Delay (1000);
// The following works even if we come back on another thread:
Console.WriteLine (_asyncLocalTest.Value); // test
}
AsyncLocal<T>仍然能够区分在不同线程上启动的操作,无论是由Thread.Start还是Task.Run发起的。以下写入“one one”和“two two”:
static AsyncLocal<string> _asyncLocalTest = new AsyncLocal<string>();
void Main()
{
// Call Test twice on two concurrent threads:
new Thread (() => Test ("one")).Start();
new Thread (() => Test ("two")).Start();
}
async void Test (string value)
{
_asyncLocalTest.Value = value;
await Task.Delay (1000);
Console.WriteLine (value + " " + _asyncLocalTest.Value);
}
AsyncLocal<T>有一个有趣且独特的细微差别:如果在线程启动时AsyncLocal<T>对象已经有一个值,新线程将“继承”该值:
static AsyncLocal<string> _asyncLocalTest = new AsyncLocal<string>();
void Main()
{
_asyncLocalTest.Value = "test";
new Thread (AnotherMethod).Start();
}
void AnotherMethod() => Console.WriteLine (_asyncLocalTest.Value); // test
然而,新线程得到的是值的副本,因此它所做的任何更改都不会影响原始值:
static AsyncLocal<string> _asyncLocalTest = new AsyncLocal<string>();
void Main()
{
_asyncLocalTest.Value = "test";
var t = new Thread (AnotherMethod);
t.Start(); t.Join();
Console.WriteLine (_asyncLocalTest.Value); // test (not ha-ha!)
}
void AnotherMethod() => _asyncLocalTest.Value = "ha-ha!";
请记住,新线程得到的是值的浅拷贝。因此,如果您将Async<string>替换为Async<StringBuilder>或Async<List<string>>,新线程可能会清除StringBuilder或向List<string>添加/删除项目,并且这将影响原始值。
定时器
如果您需要定期在一定间隔内重复执行某个方法,最简单的方法是使用定时器。定时器在内存和资源的使用上非常方便和高效——相比于以下技术:
new Thread (delegate() {
while (*enabled*)
{
*DoSomeAction*();
Thread.Sleep (TimeSpan.FromHours (24));
}
}).Start();
这不仅会永久地占用一个线程资源,而且在没有额外编码的情况下,DoSomeAction将每天稍后的某个时间发生。定时器解决了这些问题。
.NET 提供了五种定时器。其中两种是通用多线程定时器:
-
System.Threading.Timer -
System.Timers.Timer
另外两个是特定用途的单线程定时器:
-
System.Windows.Forms.Timer(Windows Forms 定时器) -
System.Windows.Threading.DispatcherTimer(WPF 定时器)
多线程定时器更强大、精确和灵活;单线程定时器更安全,更适合运行更新 Windows Forms 控件或 WPF 元素的简单任务。
最后,从 .NET 6 开始,有 PeriodicTimer,我们将首先介绍它。
PeriodicTimer
PeriodicTimer 实际上并不是一个定时器;它是一个类,用于简化异步循环。考虑到 async 和 await 的出现,传统定时器通常不再必要。相反,以下模式效果良好:
StartPeriodicOperation();
async void StartPeriodicOperation()
{
while (true)
{
await Task.Delay (1000);
Console.WriteLine ("Tick"); // Do some action
}
}
注意
如果从 UI 线程调用 StartPeriodicOperation,它将表现为单线程定时器,因为 await 将始终返回相同的同步上下文。
只需在 await 后添加 .ConfigureAwait(false),即可使其表现为多线程定时器。
PeriodicTimer 是一个用于简化此模式的类:
var timer = new PeriodicTimer (TimeSpan.FromSeconds (1));
StartPeriodicOperation();
// Optionally dispose timer when you want to stop looping.
async void StartPeriodicOperation()
{
while (await timer.WaitForNextTickAsync())
Console.WriteLine ("Tick"); // Do some action
}
PeriodicTimer 还允许您通过释放定时器实例来停止定时器。这将导致 WaitForNextTickAsync 返回 false,从而结束循环。
多线程定时器
System.Threading.Timer 是最简单的多线程定时器:它只有一个构造函数和两个方法(对极简主义者和书籍作者来说是一种乐事!)。在以下示例中,定时器调用 Tick 方法,在五秒钟后写入“tick...”,然后每秒执行一次,直到用户按下 Enter:
using System;
using System.Threading;
// First interval = 5000ms; subsequent intervals = 1000ms
Timer tmr = new Timer (Tick, "tick...", 5000, 1000);
Console.ReadLine();
tmr.Dispose(); // This both stops the timer and cleans up.
void Tick (object data)
{
// This runs on a pooled thread
Console.WriteLine (data); // Writes "tick..."
}
注意
参见“定时器”,讨论如何处理释放多线程定时器的问题。
通过调用其 Change 方法,您可以稍后更改定时器的间隔。如果要使定时器仅触发一次,请在构造函数的最后一个参数中指定 Timeout.Infinite。
.NET 在 System.Timers 命名空间中提供了另一个同名定时器类。它简单地包装了 System.Threading.Timer,在使用相同的底层引擎的同时提供了额外的便利性。以下是其新增功能的摘要:
-
实现
IComponent接口,允许在 Visual Studio 的设计器组件托盘中设置 -
Interval属性而非Change方法 -
使用
Elapsed事件 而非回调委托 -
Enabled属性用于启动和停止定时器(默认值为false) -
Start和Stop方法,以防你对Enabled感到困惑 -
一个
AutoReset标志用于指示重复事件(默认值为true) -
SynchronizingObject属性,具有Invoke和BeginInvoke方法,用于安全地调用 WPF 元素和 Windows Forms 控件的方法
以下是一个示例:
using System;
using System.Timers; // Timers namespace rather than Threading
var tmr = new Timer(); // Doesn't require any args
tmr.Interval = 500;
tmr.Elapsed += tmr_Elapsed; // Uses an event instead of a delegate
tmr.Start(); // Start the timer
Console.ReadLine();
tmr.Stop(); // Stop the timer
Console.ReadLine();
tmr.Start(); // Restart the timer
Console.ReadLine();
tmr.Dispose(); // Permanently stop the timer
void tmr_Elapsed (object sender, EventArgs e)
=> Console.WriteLine ("Tick");
多线程定时器使用线程池来服务多个定时器。这意味着每次调用回调方法或 Elapsed 事件时,它可能在不同的线程上触发。此外,Elapsed 事件始终(大致)按时触发,不受前一个 Elapsed 事件是否执行完成的影响。因此,回调或事件处理程序必须是线程安全的。
多线程定时器的精度取决于操作系统,通常在 10 到 20 毫秒范围内。如果需要更高的精度,可以使用本地 Interop 并调用 Windows 多媒体定时器。这种定时器的精度可达到一毫秒,定义在winmm.dll中。首先调用timeBeginPeriod通知操作系统您需要高时序精度,然后调用timeSetEvent启动多媒体定时器。完成后,调用timeKillEvent停止定时器,并调用timeEndPeriod通知操作系统您不再需要高时序精度。第二十四章展示了如何使用 P/Invoke 调用外部方法。您可以通过搜索关键字dllimport winmm.dll timesetevent在互联网上找到使用多媒体定时器的完整示例。
单线程定时器
.NET 提供了专为消除 WPF 和 Windows Forms 应用程序的线程安全问题而设计的定时器:
-
System.Windows.Threading.DispatcherTimer(WPF) -
System.Windows.Forms.Timer(Windows Forms)
注
单线程定时器不设计用于其各自的环境之外。例如,如果在 Windows 服务应用程序中使用 Windows Forms 定时器,则Timer事件不会触发!
两者在公开的成员(如Interval、Start和Stop,以及等效于Elapsed的Tick)方面类似于System.Timers.Timer,并且使用方式类似。然而,它们在内部工作方式上有所不同。它们不是在池化线程上触发计时器事件,而是将事件发布到 WPF 或 Windows Forms 消息循环中。这意味着Tick事件始终在最初创建计时器的同一线程上触发——在正常应用程序中,这是用于管理所有用户界面元素和控件的同一线程。这带来了许多好处:
-
您可以忘记线程安全性。
-
新的
Tick将不会触发,直到前一个Tick处理完毕。 -
可以直接从
Tick事件处理代码更新用户界面元素和控件,无需调用Control.BeginInvoke或Dispatcher.BeginInvoke。
因此,使用这些定时器的程序实际上并非多线程:您最终会得到与第十四章中描述的伪并发相同的伪并发,其中异步函数在 UI 线程上执行。一个线程为所有定时器和处理 UI 事件服务,这意味着Tick事件处理程序必须快速执行,否则 UI 会变得无响应。
这使得 WPF 和 Windows Forms 定时器适用于小型任务,通常用于更新 UI 的某些方面(例如时钟或倒计时显示)。
在精度方面,单线程定时器与多线程定时器类似(数十毫秒),尽管它们通常不如后者准确,因为它们可能会因其他 UI 请求(或其他计时器事件)而延迟。
¹ Windows 和 CLR 行为的微妙之处意味着队列的公平性有时会受到侵犯。
² 与锁一样,由于操作系统的微妙之处,队列的公平性有时也会被侵犯。
第二十二章:并行编程
在本章中,我们涵盖了旨在利用多核处理器的多线程 API 和构造体:
-
并行 LINQ 或PLINQ
-
Parallel类 -
任务并行构造体
-
并发集合
这些构造体(松散地)被称为 Parallel Framework(PFX)。Parallel类与任务并行构造体一起被称为任务并行库(TPL)。
在阅读本章之前,特别是锁定、线程安全和Task类,你需要熟悉第十四章中的基础知识。
注意
.NET 提供了许多专门的 API 来帮助并行和异步编程:
-
System.Threading.Channels.Channel是一个高性能的异步生产者/消费者队列,引入于.NET Core 3。 -
Microsoft Dataflow(在
System.Threading.Tasks.Dataflow命名空间中)是一个复杂的 API,用于创建并行执行操作或数据转换的缓冲块网络,类似于 actor/agent 编程。 -
响应式扩展实现了 LINQ 在
IObservable(与IAsyncEnumerable的替代抽象)上,并擅长于组合异步流。响应式扩展在System.Reactive NuGet 包中发布。
为什么使用 PFX?
在过去的 15 年中,CPU 制造商已经从单核转向多核处理器。这对程序员来说是个问题,因为单线程代码不会因为额外的核心而自动运行得更快。
对于大多数服务器应用程序来说,利用多核是很容易的,每个线程可以独立处理一个单独的客户端请求,但是在桌面上更难,因为通常需要将计算密集型代码拿出来并执行以下操作:
-
将其分割成小块。
-
通过多线程并行执行这些块。
-
整理结果,以线程安全和高性能的方式。
尽管你可以使用经典的多线程构造体完成所有这些工作,但这样做很笨重——特别是分割和整理的步骤。另一个问题是,在许多线程同时处理相同数据时,常规的线程安全锁定策略会导致很多争用。
PFX 库专门设计用于帮助这些场景。
注意
利用多核或多处理器进行编程被称为并行编程。这是多线程概念的一个子集。
PFX 概念
有两种策略用于在线程之间分区工作:数据并行和任务并行。
当需要在许多数据值上执行一组任务时,我们可以通过让每个线程在值的子集上执行(相同的)任务集来并行化。这被称为数据并行性,因为我们在线程之间分区数据。相比之下,任务并行性是分区任务;换句话说,我们让每个线程执行不同的任务。
通常情况下,数据并行性更简单,并且更适应高度并行硬件,因为它减少或消除了共享数据(从而减少争用和线程安全问题)。此外,数据并行性利用了数据值通常比离散任务多的事实,增加了并行化的潜力。
数据并行性也有助于结构化并行性,这意味着并行工作单元在程序中的开始和结束在同一地点。相反,任务并行性往往是非结构化的,意味着并行工作单元可能在程序中分散的位置开始和结束。结构化并行性更简单,更少出错,并允许您将分区和线程协调(甚至结果整合)的困难工作交给库处理。
PFX 组件
PFX 由两层功能组成,如图 22-1 所示。较高层包括两个结构化数据并行性 API:PLINQ 和 Parallel 类。较低层包含任务并行性类以及一组额外的构造用于帮助并行编程活动。

图 22-1. PFX 组件
PLINQ 提供了最丰富的功能:它自动化了并行化的所有步骤,包括将工作分区为任务、在线程上执行这些任务以及将结果整合为单一输出序列。它被称为声明性,因为您只需声明要并行化的工作(将其结构化为 LINQ 查询),然后让运行时处理实现细节。相比之下,其他方法是命令式的,您需要显式编写代码来分区或整合。如下面的概述所示,在Parallel 类的情况下,您必须自行整合结果;而在任务并行性构造中,您还必须自行分区工作:
| 分区工作 | 整合结果 | |
|---|---|---|
| PLINQ | 是 | 是 |
Parallel 类 |
是 | 否 |
| PFX 的任务并行性 | 否 | 否 |
并发集合和自旋原语帮助您处理低级并行编程活动。这些很重要,因为 PFX 不仅设计用于当前的硬件,还可以适用于未来更多核心的处理器。如果你想搬运一堆劈好的木头,有 32 名工人来做这份工作,最大的挑战是在不让工人互相干扰的情况下搬动木头。同样的情况也出现在将算法分配给 32 个核心上:如果使用普通的锁来保护共享资源,由此引起的阻塞可能意味着只有少部分核心实际上忙碌。并发集合专门针对高度并发访问进行了调整,重点是尽量减少或消除阻塞。PLINQ 和Parallel类本身依赖于并发集合和自旋原语,以有效地管理工作。
使用 PFX 的时机
PFX 的主要用途是并行编程:利用多核处理器加速计算密集型代码。
并行编程中的挑战之一是阿姆达尔定律,该定律指出并行化带来的最大性能提升受限于必须顺序执行的代码部分。例如,如果算法执行时间的三分之二只能并行化,即使有无限数量的核心,性能提升也不能超过三倍。
因此,在继续之前,值得验证的是瓶颈是否在可以并行化的代码上。还值得考虑的是,你的代码是否需要计算密集型—优化通常是最简单和最有效的方法。然而,有一个权衡,某些优化技术可能会使代码更难以并行化。
最容易获得的收益来自于所谓的极易并行问题—当工作可以轻松地分成有效执行的任务时(结构化并行非常适合这类问题)。例如,许多图像处理任务、光线追踪以及数学或密码学中的穷举方法都属于此类问题。非极易并行问题的一个例子是实现快速排序算法的优化版本—这需要一些思考,并且可能需要非结构化的并行。
PLINQ
PLINQ 会自动并行化本地 LINQ 查询。PLINQ 的优势在于使用简单,因为它将工作分割和结果收集的负担都交给了.NET。
要使用 PLINQ,只需在输入序列上调用AsParallel(),然后像往常一样继续 LINQ 查询。以下查询计算了在 3 到 100,000 之间的素数,充分利用了目标机器上的所有核心:
// Calculate prime numbers using a simple (unoptimized) algorithm.
IEnumerable<int> numbers = Enumerable.Range (3, 100000-3);
var parallelQuery =
from n in numbers.AsParallel()
where Enumerable.Range (2, (int) Math.Sqrt (n)).All (i => n % i > 0)
select n;
int[] primes = parallelQuery.ToArray();
AsParallel 是 System.Linq.ParallelEnumerable 中的扩展方法。它将输入包装在基于 ParallelQuery<TSource> 的序列中,这会导致随后调用的 LINQ 查询操作符绑定到在 ParallelEnumerable 中定义的另一组扩展方法。这些方法提供了每个标准查询操作符的并行实现。基本上,它们通过将输入序列分区为在不同线程上执行的块,并将结果汇总回一个用于消费的单个输出序列中,如 图 22-2 所示。

图 22-2. PLINQ 执行模型
调用 AsSequential() 会取消 ParallelQuery 序列的包装,这样随后的查询操作符就会绑定到标准查询操作符并按顺序执行。在调用具有副作用或不是线程安全的方法之前,这是必要的。
对于接受两个输入序列的查询操作符(Join、GroupJoin、Concat、Union、Intersect、Except 和 Zip),你必须对两个输入序列都应用 AsParallel()(否则会抛出异常)。然而,你无需在查询进度中继续应用 AsParallel,因为 PLINQ 的查询操作符会输出另一个 ParallelQuery 序列。事实上,再次调用 AsParallel 会导致效率低下,因为它会强制合并和重新分区查询:
mySequence.AsParallel() // Wraps sequence in ParallelQuery<int>
.Where (n => n > 100) // Outputs another ParallelQuery<int>
.AsParallel() // Unnecessary - and inefficient!
.Select (n => n * n)
并非所有查询操作符都能有效地并行化。对于那些不能并行化的操作符(参见 “PLINQ 限制”),PLINQ 将顺序实现该操作符。如果 PLINQ 怀疑并行化的开销实际上会减慢特定查询的速度,它也可能会顺序操作。
PLINQ 仅适用于本地集合:例如,它不能与 Entity Framework 一起使用,因为在这些情况下,LINQ 会转换为 SQL,然后在数据库服务器上执行。但是,你可以使用 PLINQ 对从数据库查询中获取的结果集执行额外的本地查询。
警告
如果 PLINQ 查询引发异常,则会作为 AggregateException 重新抛出,其 InnerExceptions 属性包含实际的异常(或异常)。有关详细信息,请参阅 “使用 AggregateException”。
并行执行的策略
像普通的 LINQ 查询一样,PLINQ 查询也是惰性评估的。这意味着只有在开始消耗结果时才会触发执行,通常通过 foreach 循环(尽管也可以通过像 ToArray 这样的转换操作符或返回单个元素或值的操作符来触发)。
但是,在枚举结果时,执行方式与普通的顺序查询有些不同。顺序查询完全由消费者以“拉取”方式驱动:输入序列的每个元素都会在消费者需要时精确获取。并行查询通常使用独立线程轻微提前获取输入序列中的元素(类似于新闻主播的电视提词器)。然后通过查询链并行处理元素,并将结果保存在小缓冲区中,以便按需提供给消费者。如果消费者暂停或提前退出枚举,查询处理器也会暂停或停止,以避免浪费 CPU 时间或内存。
注意
你可以在 AsParallel 后调用 WithMergeOptions 调整 PLINQ 的缓冲行为。AutoBuffered 的默认值通常提供最佳的整体结果。NotBuffered 禁用缓冲区,如果你希望尽快看到结果,则很有用;FullyBuffered 在向消费者呈现整个结果集之前缓存整个结果(OrderBy 和 Reverse 操作符自然以此方式工作,聚合和转换操作符也是如此)。
PLINQ 和排序
并行化查询操作符的一个副作用是,在整理结果时,它们的顺序不一定与提交时的顺序相同(参见 图 22-2)。换句话说,LINQ 对序列的正常顺序保留保证不再适用。
如果需要保留顺序,可以在 AsParallel() 后调用 AsOrdered() 强制执行:
myCollection.AsParallel().AsOrdered()...
调用 AsOrdered 会导致大量元素时的性能损失,因为 PLINQ 必须跟踪每个元素的原始位置。
你可以通过调用 AsUnordered 来取消 AsOrdered 在查询中的影响:这引入了一个“随机洗牌点”,允许查询从那一点开始更高效地执行。因此,如果你只想保留前两个查询操作的输入顺序,可以这样做:
inputSequence.AsParallel().AsOrdered()
*.QueryOperator1*()
* .QueryOperator2*()
.AsUnordered() // From here on, ordering doesn’t matter
.*QueryOperator3*()
...
AsOrdered 不是默认选项,因为对于大多数查询来说,原始输入的顺序并不重要。换句话说,如果 AsOrdered 是默认选项,你需要对大多数并行查询应用 AsUnordered 以获得最佳性能,这会增加负担。
PLINQ 的限制
在什么情况下 PLINQ 可以并行化存在实际限制。以下查询操作符默认情况下阻止并行化,除非源元素处于它们的原始索引位置:
Select、SelectMany和ElementAt的索引版本
大多数查询操作符会更改元素的索引位置(包括删除元素的操作,如 Where)。这意味着如果要使用前面的操作符,它们通常需要位于查询的开始位置。
下列查询运算符是可以并行化的,但使用昂贵的分区策略,有时比顺序处理更慢:
Join、GroupBy、GroupJoin、Distinct、Union、Intersect和Except
Aggregate运算符的标准化版本的种子重载不可并行化 —— PLINQ 提供了特殊的重载来处理这个问题(见“优化 PLINQ”)。
所有其他运算符都是可以并行化的,尽管使用这些运算符并不保证您的查询会并行化。如果 PLINQ 认为并行化的开销会减慢特定查询的速度,它可能会顺序运行您的查询。您可以在AsParallel()后调用以下方法来覆盖此行为并强制并行处理:
.WithExecutionMode (ParallelExecutionMode.ForceParallelism)
示例:并行拼写检查器
假设我们想要编写一个拼写检查器,通过利用所有可用核心,快速处理非常大的文档。通过将我们的算法制定为 LINQ 查询,我们可以非常容易地并行化它。
第一步是将英文单词的字典下载到一个HashSet中,以便进行高效的查找:
if (!File.Exists ("WordLookup.txt") // Contains about 150,000 words
File.WriteAllText ("WordLookup.txt",
await new HttpClient().GetStringAsync (
"http://www.albahari.com/ispell/allwords.txt"));
var wordLookup = new HashSet<string> (
File.ReadAllLines ("WordLookup.txt"),
StringComparer.InvariantCultureIgnoreCase);
然后,我们使用我们的单词查找来创建一个测试“文档”,包含一个百万个随机单词的数组。在我们构建数组之后,让我们引入一些拼写错误:
var random = new Random();
string[] wordList = wordLookup.ToArray();
string[] wordsToTest = Enumerable.Range (0, 1000000)
.Select (i => wordList [random.Next (0, wordList.Length)])
.ToArray();
wordsToTest [12345] = "woozsh"; // Introduce a couple
wordsToTest [23456] = "wubsie"; // of spelling mistakes.
现在,我们可以通过将wordsToTest与wordLookup进行测试来执行并行拼写检查。PLINQ 使这一过程非常简单:
var query = wordsToTest
.AsParallel()
.Select ((word, index) => (word, index))
.Where (iword => !wordLookup.Contains (iword.word))
.OrderBy (iword => iword.index);
foreach (var mistake in query)
Console.WriteLine (mistake.word + " - index = " + mistake.index);
// OUTPUT:
// woozsh - index = 12345
// wubsie - index = 23456
谓词中的wordLookup.Contains方法为查询提供了一些“实质”,使其值得并行化。
注意
注意,我们的查询使用元组(word, index)而不是匿名类型。因为元组是作为值类型而不是引用类型实现的,这通过减少堆分配和随后的垃圾回收,改善了峰值内存消耗和性能。(基准测试显示,在实践中,由于内存管理器的效率和这些分配不超过 Generation 0,这些收益是适度的。)
使用ThreadLocal<T>
让我们通过并行化随机测试词列表的创建来扩展我们的示例。我们将其构造为 LINQ 查询,因此应该很容易。以下是顺序版本:
string[] wordsToTest = Enumerable.Range (0, 1000000)
.Select (i => wordList [random.Next (0, wordList.Length)])
.ToArray();
不幸的是,对random.Next的调用不是线程安全的,因此将AsParallel()插入查询中并不简单。一个潜在的解决方案是编写一个在random.Next周围加锁的函数;然而,这会限制并发性。更好的选择是使用ThreadLocal<Random>(参见“线程本地存储”),为每个线程创建一个单独的Random对象。然后我们可以按如下方式并行化查询:
var localRandom = new ThreadLocal<Random>
( () => new Random (Guid.NewGuid().GetHashCode()) );
string[] wordsToTest = Enumerable.Range (0, 1000000).AsParallel()
.Select (i => wordList [localRandom.Value.Next (0, wordList.Length)])
.ToArray();
在我们用于实例化Random对象的工厂函数中,我们传递一个Guid的哈希码,以确保如果在短时间内创建了两个Random对象,它们将产生不同的随机数序列。
函数纯度
因为 PLINQ 在并行线程上运行查询,所以必须小心不要执行线程不安全的操作。特别是写入变量是副作用,因此是线程不安全的:
// The following query multiplies each element by its position.
// Given an input of Enumerable.Range(0,999), it should output squares.
int i = 0;
var query = from n in Enumerable.Range(0,999).AsParallel() select n * i++;
我们可以通过使用锁使增加i线程安全,但问题仍然存在,即i不一定对应于输入元素的位置。并且添加AsOrdered到查询中也不能解决后一个问题,因为AsOrdered仅确保元素按照它们被顺序处理的顺序输出——它实际上不会顺序处理它们。
正确的解决方案是重写我们的查询,使用索引版本的Select:
var query = Enumerable.Range(0,999).AsParallel().Select ((n, i) => n * i);
为了获得最佳性能,从查询操作符调用的任何方法都应该通过不写入字段或属性(非副作用,或功能纯粹)来保持线程安全。如果它们通过锁定而不是副作用来保持线程安全,查询的并行潜力将受到争用效果的限制。
设置并行度
默认情况下,PLINQ 选择适合正在使用的处理器的最佳并行度。您可以通过在AsParallel后调用WithDegreeOfParallelism来覆盖它:
...AsParallel().WithDegreeOfParallelism(4)...
一个例子是在 I/O 绑定的工作中可能会增加并行性(例如同时下载多个网页)。然而,任务组合器和异步函数提供了一个同样简单且更高效的解决方案(参见“任务组合器”)。与Task不同,PLINQ 无法执行 I/O 绑定工作而不阻塞线程(并且池化线程会使情况变得更糟)。
更改并行度
在 PLINQ 查询中,只能调用一次WithDegreeOfParallelism。如果需要再次调用它,必须通过在查询中再次调用AsParallel()来强制合并和重新分区查询:
"The Quick Brown Fox"
.AsParallel().WithDegreeOfParallelism (2)
.Where (c => !char.IsWhiteSpace (c))
.AsParallel().WithDegreeOfParallelism (3) // Forces Merge + Partition
.Select (c => char.ToUpper (c))
取消
取消在 foreach 循环中消耗其结果的 PLINQ 查询非常简单:只需中断 foreach,查询将自动取消,因为枚举器会被隐式处理。
对于以转换、元素或聚合操作结束的查询,可以通过取消令牌(参见“取消”)从另一个线程取消它。要插入一个令牌,在调用AsParallel后调用WithCancellation,传递CancellationTokenSource对象的Token属性。然后,另一个线程可以调用令牌源的Cancel(或者我们可以自己延迟调用)。然后,在查询的消费者上抛出OperationCanceledException:
IEnumerable<int> tenMillion = Enumerable.Range (3, 10_000_000);
var cancelSource = new CancellationTokenSource();
cancelSource.CancelAfter (100); // Cancel query after 100 milliseconds
var primeNumberQuery =
from n in tenMillion.AsParallel().WithCancellation (cancelSource.Token)
where Enumerable.Range (2, (int) Math.Sqrt (n)).All (i => n % i > 0)
select n;
try
{
// Start query running:
int[] primes = primeNumberQuery.ToArray();
// We'll never get here because the other thread will cancel us.
}
catch (OperationCanceledException)
{
Console.WriteLine ("Query canceled");
}
在取消时,PLINQ 等待每个工作线程完成其当前元素后结束查询。这意味着查询调用的任何外部方法都将完全运行。
优化 PLINQ
输出端优化
PLINQ 的一个优点是方便地将并行工作的结果整理到单一输出序列中。但有时,您需要做的只是对每个元素运行某个函数一次:
foreach (int n in parallelQuery)
DoSomething (n);
如果是这种情况——并且您不关心元素处理顺序——您可以通过 PLINQ 的ForAll方法提高效率。
ForAll方法在ParallelQuery的每个输出元素上运行委托。它直接连接到 PLINQ 的内部,跳过整理和枚举结果的步骤。这里是一个简单的示例:
"abcdef".AsParallel().Select (c => char.ToUpper(c)).ForAll (Console.Write);
图 22-3 展示了该过程。

图 22-3. PLINQ ForAll
注意
整理和枚举结果并非代价高昂,因此ForAll优化在输入元素快速执行较多的情况下获得最大收益。
输入端优化
PLINQ 有三种分区策略用于将输入元素分配给线程:
| 策略 | 元素分配 | 相对性能 |
|---|---|---|
| 分块分区 | 动态 | 平均 |
| 范围分区 | 静态 | 差至优 |
| 哈希分区 | 静态 | 一般 |
对于需要比较元素的查询操作符(GroupBy、Join、GroupJoin、Intersect、Except、Union和Distinct),您没有选择:PLINQ 始终使用哈希分区。哈希分区相对低效,因为它必须预先计算每个元素的哈希码(以便具有相同哈希码的元素可以在同一线程上处理)。如果发现这太慢,则唯一选择是调用AsSequential禁用并行化。
对于所有其他查询操作符,您可以选择使用范围或分块分区。默认情况下:
-
如果输入序列是可索引的(如果是数组或实现了
IList<T>),PLINQ 会选择范围分区。 -
否则,PLINQ 会选择分块分区。
简言之,对于每个元素处理时间相似且序列较长的情况下,范围分区更快。否则,通常分块分区更快。
要强制执行范围分区:
-
如果查询以
Enumerable.Range开始,请用ParallelEnumerable.Range替换该方法。 -
否则,只需在输入序列上调用
ToList或ToArray(显然,这本身会带来性能成本,需要考虑)。
警告
ParallelEnumerable.Range不仅仅是调用Enumerable.Range(…)AsParallel()的捷径。它通过激活范围分区来改变查询的性能。
要强制进行分块分区,请将输入序列封装在调用Partitioner.Create(在System.Collection.Concurrent中)中,如下所示:
int[] numbers = { 3, 4, 5, 6, 7, 8, 9 };
var parallelQuery =
Partitioner.Create (numbers, true).AsParallel()
.Where (...)
第二个参数Partitioner.Create指示您希望负载均衡查询,这也意味着您希望分块分区。
块划分通过让每个工作线程定期从输入序列中抓取小的“块”元素来处理工作(见 图 22-4)。PLINQ 从分配非常小的块开始(每次一个或两个元素)。随着查询的进行,它会增加块的大小:这确保了小序列能够有效并行化处理,而大序列则不会导致过多的往返。如果一个工作线程处理起来“容易”的元素(即处理速度快),它最终会获取更多的块。这个系统保持了每个线程同样忙碌(和核心“平衡”);唯一的缺点是从共享输入序列中获取元素需要同步(通常是独占锁定),这可能会导致一些开销和竞争。
范围划分绕过了正常的输入端枚举,并预先分配了相等数量的元素给每个工作线程,避免了在输入序列上的竞争。但是,如果一些线程碰巧获得简单的元素并且早早地完成,它们会空闲,而剩余的线程则继续工作。我们之前的素数计算器在使用范围划分时可能性能不佳。范围划分能很好地处理以下情况,即计算前 1000 万个整数的平方根的和:
ParallelEnumerable.Range (1, 10000000).Sum (i => Math.Sqrt (i))

图 22-4. 块划分与范围划分
ParallelEnumerable.Range 返回一个 ParallelQuery<T>,因此无需随后调用 AsParallel。
注意
范围划分并不一定在连续块中分配元素范围,它可能选择“条带化”策略。例如,如果有两个工作线程,一个线程可能处理奇数编号的元素,而另一个线程则处理偶数编号的元素。TakeWhile 操作符几乎肯定会触发条带化策略,以避免不必要地处理序列后面的元素。
优化自定义聚合
PLINQ 在不需要额外干预的情况下有效地并行化 Sum、Average、Min 和 Max 操作符。然而,Aggregate 操作符对于 PLINQ 提出了特殊挑战。正如 第九章 中描述的那样,Aggregate 执行自定义聚合。例如,以下代码对数字序列求和,模仿 Sum 操作符的功能:
int[] numbers = { 1, 2, 3 };
int sum = numbers.Aggregate (0, (total, n) => total + n); // 6
此外,我们还在 第九章 中看到,对于未种子聚合,提供的委托必须是可结合和可交换的。如果违反了此规则,PLINQ 将给出不正确的结果,因为它会从输入序列中提取多个种子以同时聚合多个分区的序列。
显式种子聚合看起来像是使用 PLINQ 的安全选择,但不幸的是,这些通常由于依赖单一种子而顺序执行。为了缓解这个问题,PLINQ 提供了另一个Aggregate的重载,允许您指定多个种子或者种子工厂函数。对于每个线程,它执行此函数以生成一个单独的种子,这成为一个线程本地的累加器,用于局部聚合元素。
您还必须提供一个函数来指示如何结合本地和主累加器。最后,这个Aggregate重载(有些过分地)期望一个委托来执行结果的任何最终转换(您也可以在之后对结果运行某些函数来轻松实现这一点)。因此,这里是四个委托,按照它们被传递的顺序:
seedFactory
返回一个新的本地累加器
updateAccumulatorFunc
将一个元素聚合到本地累加器中
combineAccumulatorFunc
将本地累加器与主累加器结合起来
resultSelector
对最终结果应用任何最终转换
注意
在简单的情况下,您可以指定一个种子值而不是种子工厂。当种子是您想要改变的引用类型时,这种策略会失败,因为同一个实例会被每个线程共享。
举个非常简单的例子,下面的代码对numbers数组中的值进行求和:
numbers.AsParallel().Aggregate (
() => 0, // seedFactory
(localTotal, n) => localTotal + n, // updateAccumulatorFunc
(mainTot, localTot) => mainTot + localTot, // combineAccumulatorFunc
finalResult => finalResult) // resultSelector
这个例子有些刻意,因为我们可以使用更简单的方法得到相同的答案(比如未种子化的聚合,或者更好的是Sum运算符)。为了给出一个更现实的例子,假设我们想计算给定字符串中每个英文字母的频率。一个简单的顺序解决方案可能如下所示:
string text = "Let’s suppose this is a really long string";
var letterFrequencies = new int[26];
foreach (char c in text)
{
int index = char.ToUpper (c) - 'A';
if (index >= 0 && index < 26) letterFrequencies [index]++;
};
注意
一个输入文本可能非常长的示例是在基因序列中。此时,“字母表”将由字母a、c、g和t组成。
要并行化这个过程,我们可以将foreach语句替换为调用Parallel.ForEach(我们在下一节中介绍),但这将使我们需要处理共享数组的并发问题。而且,在访问该数组时进行锁定几乎会杀死并行化的潜力。
Aggregate提供了一个整洁的解决方案。在这种情况下,累加器就像我们之前例子中的letterFrequencies数组一样。这里是使用Aggregate的顺序版本:
int[] result =
text.Aggregate (
new int[26], // Create the "accumulator"
(letterFrequencies, c) => // Aggregate a letter into the accumulator
{
int index = char.ToUpper (c) - 'A';
if (index >= 0 && index < 26) letterFrequencies [index]++;
return letterFrequencies;
});
现在是并行版本,使用 PLINQ 的特殊重载:
int[] result =
text.AsParallel().Aggregate (
() => new int[26], // Create a new local accumulator
(localFrequencies, c) => // Aggregate into the local accumulator
{
int index = char.ToUpper (c) - 'A';
if (index >= 0 && index < 26) localFrequencies [index]++;
return localFrequencies;
},
// Aggregate local->main accumulator
(mainFreq, localFreq) =>
mainFreq.Zip (localFreq, (f1, f2) => f1 + f2).ToArray(),
finalResult => finalResult // Perform any final transformation
); // on the end result.
注意本地累积函数改变了localFrequencies数组。执行这种优化的能力非常重要——因为localFrequencies是每个线程本地的,所以是合法的。
并行类
PFX 通过Parallel类中的三个静态方法提供了一种基本的结构化并行处理:
Parallel.Invoke
并行执行委托数组
Parallel.For
执行 C# for 循环的并行等效操作
Parallel.ForEach
执行与 C# foreach 循环的并行等价操作
所有三种方法都会阻塞,直到所有工作完成。与 PLINQ 类似,在未处理的异常后,剩余的工作者将在它们当前的迭代后停止,并将异常(或异常)抛回给调用者——封装在 AggregateException 中(参见 “处理 AggregateException”)。
Parallel.Invoke
Parallel.Invoke 在并行执行一个 Action 委托数组后等待它们完成。该方法的最简单版本定义如下:
public static void Invoke (params Action[] actions);
就像对待 PLINQ 一样,Parallel.* 方法被优化用于计算密集型而不是 I/O 密集型工作。然而,同时下载两个网页提供了演示 Parallel.Invoke 的简单方法:
Parallel.Invoke (
() => new WebClient().DownloadFile ("http://www.linqpad.net", "lp.html"),
() => new WebClient().DownloadFile ("http://microsoft.com", "ms.html"));
表面上看,这似乎是创建和等待两个绑定线程的 Task 对象的便捷捷径。但是有一个重要的区别:如果你传入一个包含一百万委托的数组,Parallel.Invoke 仍然能够高效工作。这是因为它将大量元素分区成几批,并分配给少数几个基础 Task,而不是为每个委托创建一个单独的 Task。
与所有 Parallel 方法一样,在收集结果方面你是独立的。这意味着你需要考虑线程安全性。例如,以下代码是线程不安全的:
var data = new List<string>();
Parallel.Invoke (
() => data.Add (new WebClient().DownloadString ("http://www.foo.com")),
() => data.Add (new WebClient().DownloadString ("http://www.far.com")));
在向列表添加锁定的情况下,可以解决这个问题,尽管锁定会在有大量快速执行委托的情况下创建瓶颈。更好的解决方案是使用线程安全的集合,我们将在后面的章节中介绍——在这种情况下,ConcurrentBag 是理想的选择。
Parallel.Invoke 还重载为接受 ParallelOptions 对象:
public static void Invoke (ParallelOptions options,
params Action[] actions);
使用 ParallelOptions,你可以插入一个取消标记,限制最大并发数,并指定自定义任务调度程序。当你执行(大致)多个任务比你有的内核时,取消标记是相关的:在取消时,任何未启动的委托将被放弃。然而,任何已经执行的委托将继续完成。参见 “取消” 以查看如何使用取消标记的示例。
Parallel.For 和 Parallel.ForEach
Parallel.For 和 Parallel.ForEach 执行与 C# 的 for 和 foreach 循环等价的操作,但每次迭代都是并行执行而不是顺序执行。以下是它们的(最简单的)签名:
public static ParallelLoopResult For (
int fromInclusive, int toExclusive, Action<int> body)
public static ParallelLoopResult ForEach<TSource> (
IEnumerable<TSource> source, Action<TSource> body)
这个顺序 for 循环:
for (int i = 0; i < 100; i++)
Foo (i);
被并行化为这样:
Parallel.For (0, 100, i => Foo (i));
或者更简单地说:
Parallel.For (0, 100, Foo);
这个顺序 foreach:
foreach (char c in "Hello, world")
Foo (c);
被并行化为这样:
Parallel.ForEach ("Hello, world", Foo);
举个实际的例子,如果我们导入 System.Security.Cryptography 命名空间,我们可以并行生成六对公钥/私钥字符串,如下所示:
var keyPairs = new string[6];
Parallel.For (0, keyPairs.Length,
i => keyPairs[i] = RSA.Create().ToXmlString (true));
与 Parallel.Invoke 一样,我们可以将大量的工作项传递给 Parallel.For 和 Parallel.ForEach,它们将被有效地分区到少数几个任务中。
注意
后一种查询也可以使用 PLINQ 完成:
string[] keyPairs =
ParallelEnumerable.Range (0, 6)
.Select (i => RSA.Create().ToXmlString (true))
.ToArray();
外部与内部循环
Parallel.For和Parallel.ForEach通常在外部循环上效果最佳,而不是内部循环。这是因为前者提供了更大的工作块来并行化,从而稀释了管理开销。通常情况下,并行化内外部循环都是不必要的。
在下面的示例中,我们通常需要超过 100 个核心来从内部并行化中获益:
Parallel.For (0, 100, i =>
{
Parallel.For (0, 50, j => Foo (i, j)); // Sequential would be better
}); // for the inner loop.
索引化的 Parallel.ForEach
有时,了解循环迭代索引是很有用的。使用顺序的foreach很容易实现:
int i = 0;
foreach (char c in "Hello, world")
Console.WriteLine (c.ToString() + i++);
然而,在并行环境中,递增共享变量是不安全的。您必须使用以下版本的ForEach:
public static ParallelLoopResult ForEach<TSource> (
IEnumerable<TSource> source, Action<TSource,ParallelLoopState,long> body)
我们将忽略ParallelLoopState(我们将在下一节中讨论)。目前,我们对类型为long的Action的第三个类型参数感兴趣,它指示循环索引:
Parallel.ForEach ("Hello, world", (c, state, i) =>
{
Console.WriteLine (c.ToString() + i);
});
为了把这一点放到实际背景中,让我们重新审视使用 PLINQ 编写的拼写检查器。以下代码加载了一个词典,以及一个用于测试的一百万个单词的数组:
if (!File.Exists ("WordLookup.txt")) // Contains about 150,000 words
new WebClient().DownloadFile (
"http://www.albahari.com/ispell/allwords.txt", "WordLookup.txt");
var wordLookup = new HashSet<string> (
File.ReadAllLines ("WordLookup.txt"),
StringComparer.InvariantCultureIgnoreCase);
var random = new Random();
string[] wordList = wordLookup.ToArray();
string[] wordsToTest = Enumerable.Range (0, 1000000)
.Select (i => wordList [random.Next (0, wordList.Length)])
.ToArray();
wordsToTest [12345] = "woozsh"; // Introduce a couple
wordsToTest [23456] = "wubsie"; // of spelling mistakes.
我们可以使用索引版本的Parallel.ForEach对wordsToTest数组执行拼写检查,如下所示:
var misspellings = new ConcurrentBag<Tuple<int,string>>();
Parallel.ForEach (wordsToTest, (word, state, i) =>
{
if (!wordLookup.Contains (word))
misspellings.Add (Tuple.Create ((int) i, word));
});
注意,我们必须将结果整理到一个线程安全的集合中:与使用 PLINQ 相比,这样做的劣势在于成本较高。与 PLINQ 相比的优势在于,我们避免了应用索引化的Select查询运算符的成本,后者效率低于索引化的ForEach。
ParallelLoopState:提前退出循环
因为并行For或ForEach中的循环体是一个委托,所以您不能使用break语句提前退出循环。相反,您必须在ParallelLoopState对象上调用Break或Stop:
public class ParallelLoopState
{
public void Break();
public void Stop();
public bool IsExceptional { get; }
public bool IsStopped { get; }
public long? LowestBreakIteration { get; }
public bool ShouldExitCurrentIteration { get; }
}
获取ParallelLoopState很容易:所有版本的For和ForEach都重载了接受类型为Action<TSource, ParallelLoopState>的循环体。因此,要并行化这个:
foreach (char c in "Hello, world")
if (c == ',')
break;
else
Console.Write (c);
这样做:
Parallel.ForEach ("Hello, world", (c, loopState) =>
{
if (c == ',')
loopState.Break();
else
Console.Write (c);
});
// OUTPUT: Hlloe
您可以从输出中看到,循环体可以以随机顺序完成。除了这个区别外,调用Break至少产生与顺序执行循环相同的元素:此示例将始终以某种顺序输出字母H、e、l、l和o。相反,调用Stop而不是Break会立即使所有线程在当前迭代后完成。在我们的示例中,如果另一个线程落后,调用Stop可以给我们字母H、e、l、l和o的一个子集。在找到所需内容时或发生错误并且您不希望查看结果时,调用Stop非常有用。
注意
Parallel.For和Parallel.ForEach方法返回一个ParallelLoopResult对象,该对象公开了名为IsCompleted和LowestBreakIteration的属性。这些属性告诉您循环是否已完成;如果没有完成,则指示循环在哪个周期中断。
如果LowestBreakIteration返回 null,则表示在循环中调用了Stop(而不是Break)。
如果你的循环主体很长,你可能希望其他线程在方法体中途因为早期的 Break 或 Stop 而中断。你可以在代码的各个地方轮询 ShouldExitCurrentIteration 属性来做到这一点;该属性在 Stop 后立即变为 true,或者在 Break 后不久也会如此。
注意
ShouldExitCurrentIteration 在取消请求后或循环中抛出异常后也会变为 true。
IsExceptional 通知您其他线程是否发生异常。任何未处理的异常都会导致每个线程当前迭代后停止循环:为了避免这种情况,您必须在代码中显式处理异常。
使用本地值进行优化
Parallel.For 和 Parallel.ForEach 各自提供了一组重载,其中包含一个称为 TLocal 的泛型类型参数。这些重载旨在帮助您优化迭代密集型循环中数据的汇总。最简单的是这样:
public static ParallelLoopResult For <TLocal> (
int fromInclusive,
int toExclusive,
Func <TLocal> localInit,
Func <int, ParallelLoopState, TLocal, TLocal> body,
Action <TLocal> localFinally);
实际上很少需要这些方法,因为它们的目标场景大多已被 PLINQ 覆盖(这是幸运的,因为这些重载有点令人生畏!)。
问题本质上是这样的:假设我们想要对 1 到 1000 万的数字进行平方根求和。计算 1000 万个平方根很容易并行化,但计算它们的总和很麻烦,因为我们必须在更新总值时进行锁定:
object locker = new object();
double total = 0;
Parallel.For (1, 10000000,
i => { lock (locker) total += Math.Sqrt (i); });
并行化的收益超过了获取 1000 万个锁的成本和相应的阻塞。
但现实是,我们实际上并不 需要 1000 万个锁。想象一个团队的志愿者清理大量垃圾。如果所有工作人员共用一个垃圾桶,旅行和争用将使过程极其低效。显而易见的解决方案是每个工作人员都有一个私人或“本地”垃圾桶,偶尔倒入主垃圾桶。
For 和 ForEach 的 TLocal 版本确实是这样工作的。志愿者是内部工作线程,而 本地值 表示本地垃圾桶。为了让 Parallel 执行这项工作,您必须提供两个额外的委托,指示以下情况:
-
如何初始化一个新的本地值
-
如何将本地聚合与主值合并
另外,代替主体委托返回 void,它应该返回本地值的新聚合。以下是我们的示例重构:
object locker = new object();
double grandTotal = 0;
Parallel.For (1, 10000000,
() => 0.0, // Initialize the local value.
(i, state, localTotal) => // Body delegate. Notice that it
localTotal + Math.Sqrt (i), // returns the new local total.
localTotal => // Add the local value
{ lock (locker) grandTotal += localTotal; } // to the master value.
);
我们仍然必须锁定,但只是围绕将本地值聚合到总值的操作。这显著提高了流程的效率。
注意
如前所述,PLINQ 在这些场景中通常很合适。我们的示例可以像这样并行化使用 PLINQ:
ParallelEnumerable.Range (1, 10000000)
.Sum (i => Math.Sqrt (i))
(请注意,我们使用 ParallelEnumerable 来强制 范围分区:这在这种情况下提高了性能,因为所有数字的处理时间相同。)
在更复杂的场景中,您可能会使用 LINQ 的 Aggregate 操作符而不是 Sum。如果您提供了一个本地种子工厂,情况将有些类似于在 Parallel.For 中提供本地值函数。
任务并行性
任务并行性 是使用 PFX 进行并行化的最低级别方法。在这个级别工作的类定义在 System.Threading.Tasks 命名空间中,包括以下内容:
| 类 | 目的 |
|---|---|
Task |
用于管理一个工作单元 |
Task<TResult> |
用于管理带有返回值的工作单元 |
TaskFactory |
用于创建任务 |
TaskFactory<TResult> |
用于创建具有相同返回类型的任务和延续 |
TaskScheduler |
用于管理任务的调度 |
TaskCompletionSource |
用于手动控制任务的工作流程 |
我们在 第十四章 中介绍了任务的基础知识;在本节中,我们将介绍旨在并行编程的任务的高级特性:
-
调整任务的调度
-
当从另一个任务启动一个任务时,建立父/子关系
-
高级使用的延续
-
TaskFactory
警告
任务并行库允许您以最小的开销创建数百(甚至数千)个任务。但是,如果您想要创建数百万个任务,您需要将这些任务分成更大的工作单元以保持效率。Parallel 类和 PLINQ 自动执行此操作。
注意
Visual Studio 提供了一个用于监视任务的窗口(Debug®Window®Parallel Tasks)。这相当于线程窗口,但用于任务。并行堆栈窗口还有一个专门用于任务的特殊模式。
创建和启动任务
如在 第十四章 中描述的,Task.Run 创建并启动一个 Task 或 Task<TResult>。该方法实际上是调用 Task.Factory.StartNew 的一种快捷方式,通过额外的重载提供了更大的灵活性。
指定状态对象
Task.Factory.StartNew 允许您指定传递给目标的 状态 对象。然后,目标方法的签名必须包括一个单一的对象类型参数:
var task = Task.Factory.StartNew (Greet, "Hello");
task.Wait(); // Wait for task to complete.
void Greet (object state) { Console.Write (state); } // Hello
这样可以避免执行调用 Greet 的 lambda 表达式所需的闭包成本。这是一种微小的优化,在实践中很少需要,所以我们可以更好地利用 状态 对象,为任务指定一个有意义的名称。然后,我们可以使用 AsyncState 属性来查询它的名称:
var task = Task.Factory.StartNew (state => Greet ("Hello"), "Greeting");
Console.WriteLine (task.AsyncState); // Greeting
task.Wait();
void Greet (string message) { Console.Write (message); }
注意
Visual Studio 在并行任务窗口中显示每个任务的 AsyncState,因此在此处使用一个有意义的名称可以极大地简化调试。
TaskCreationOptions
您可以通过在调用 StartNew(或实例化 Task)时指定 TaskCreationOptions 枚举来调整任务的执行。TaskCreationOptions 是一个标志枚举,具有以下(可组合)值:
LongRunning, PreferFairness, AttachedToParent
LongRunning建议调度程序为任务分配一个线程,正如我们在第十四章中描述的那样,这对于 I/O 绑定任务和可能迫使短期运行任务等待不合理的时间以进行调度的长期运行任务非常有利。
PreferFairness指示调度程序尝试确保按照启动顺序调度任务。通常情况下可能会有所不同,因为它在内部使用本地工作窃取队列优化任务的调度——这种优化允许创建子任务而不会产生单一工作队列所产生的争用开销。通过指定AttachedToParent来创建子任务。
子任务
当一个任务启动另一个任务时,您可以选择建立父子关系:
Task parent = Task.Factory.StartNew (() =>
{
Console.WriteLine ("I am a parent");
Task.Factory.StartNew (() => // Detached task
{
Console.WriteLine ("I am detached");
});
Task.Factory.StartNew (() => // Child task
{
Console.WriteLine ("I am a child");
}, TaskCreationOptions.AttachedToParent);
});
子任务在等待父任务完成时是特殊的,它也会等待任何子任务完成。在这一点上,任何子异常都会冒泡上来:
TaskCreationOptions atp = TaskCreationOptions.AttachedToParent;
var parent = Task.Factory.StartNew (() =>
{
Task.Factory.StartNew (() => // Child
{
Task.Factory.StartNew (() => { throw null; }, atp); // Grandchild
}, atp);
});
// The following call throws a NullReferenceException (wrapped
// in nested AggregateExceptions):
parent.Wait();
当一个子任务是一个继续任务时,这尤其有用,您很快就会看到。
等待多个任务
我们在第十四章中看到,您可以通过调用其Wait方法或访问其Result属性(如果它是Task<TResult>)等待单个任务。您还可以同时等待多个任务——通过静态方法Task.WaitAll(等待所有指定任务完成)和Task.WaitAny(仅等待一个任务完成)。
WaitAll类似于依次等待每个任务完成,但效率更高,因为它最多需要一个上下文切换。此外,如果一个或多个任务抛出未处理的异常,WaitAll仍然会等待每个任务完成。然后重新抛出一个累积了每个出错任务异常的AggregateException(这正是AggregateException真正有用的地方)。它相当于执行以下操作:
// Assume t1, t2 and t3 are tasks:
var exceptions = new List<Exception>();
try { t1.Wait(); } catch (AggregateException ex) { exceptions.Add (ex); }
try { t2.Wait(); } catch (AggregateException ex) { exceptions.Add (ex); }
try { t3.Wait(); } catch (AggregateException ex) { exceptions.Add (ex); }
if (exceptions.Count > 0) throw new AggregateException (exceptions);
调用WaitAny相当于等待每个任务完成时由ManualResetEventSlim发出的信号。
除了超时外,您还可以在Wait方法中传递取消令牌:这使您可以取消等待——而不是任务本身。
取消任务
您可以选择在启动任务时传递取消令牌。然后,如果通过该令牌发生取消,任务本身进入“已取消”状态:
var cts = new CancellationTokenSource();
CancellationToken token = cts.Token;
cts.CancelAfter (500);
Task task = Task.Factory.StartNew (() =>
{
Thread.Sleep (1000);
token.ThrowIfCancellationRequested(); // Check for cancellation request
}, token);
try { task.Wait(); }
catch (AggregateException ex)
{
Console.WriteLine (ex.InnerException is TaskCanceledException); // True
Console.WriteLine (task.IsCanceled); // True
Console.WriteLine (task.Status); // Canceled
}
TaskCanceledException是OperationCanceledException的子类。如果您想明确抛出OperationCanceledException(而不是调用token.ThrowIfCancellationRequested),您必须将取消令牌传递给OperationCanceledException的构造函数。如果未能这样做,任务将不会以TaskStatus.Canceled状态结束,也不会触发OnlyOnCanceled继续执行。
如果任务在启动之前被取消,它将不会被调度——而是立即在任务上抛出OperationCanceledException。
由于其他 API 识别取消令牌,您可以将它们传递到其他结构中,并且取消将无缝传播:
var cancelSource = new CancellationTokenSource();
CancellationToken token = cancelSource.Token;
Task task = Task.Factory.StartNew (() =>
{
// Pass our cancellation token into a PLINQ query:
var query = *someSequence*.AsParallel().WithCancellation (token)...
... *enumerate query* ...
});
在此示例中调用 cancelSource 上的 Cancel 将取消 PLINQ 查询,这将在任务体上引发 OperationCanceledException,随后取消任务。
注意
您可以将取消令牌传递到诸如 Wait 和 CancelAndWait 的方法中,以取消等待操作而不是任务本身。
续体
ContinueWith 方法在任务结束后立即执行委托:
Task task1 = Task.Factory.StartNew (() => Console.Write ("antecedent.."));
Task task2 = task1.ContinueWith (ant => Console.Write ("..continuation"));
一旦 task1(前驱)完成、失败或被取消,task2(续体)就开始执行。(如果 task1 在第二行代码运行之前完成,task2 将立即被调度执行。)传递给续体 lambda 表达式的 ant 参数是对前驱任务的引用。ContinueWith 本身返回一个任务,使得可以轻松添加进一步的续体。
默认情况下,前驱和续体任务可能在不同的线程上执行。通过在调用 ContinueWith 时指定 TaskContinuationOptions.ExecuteSynchronously 可以强制它们在同一线程上执行:这可以通过减少间接性来提高非常精细的续体的性能。
续体和 Task
就像普通任务一样,续体可以是 Task<TResult> 类型并返回数据。在以下示例中,我们使用一系列链式任务计算 Math.Sqrt(8*2),然后输出结果:
Task.Factory.StartNew<int> (() => 8)
.ContinueWith (ant => ant.Result * 2)
.ContinueWith (ant => Math.Sqrt (ant.Result))
.ContinueWith (ant => Console.WriteLine (ant.Result)); // 4
我们的示例有点刻意简化;在现实生活中,这些 lambda 表达式会调用计算密集型函数。
续体和异常
续体可以通过查询前驱任务的 Exception 属性来知道前驱是否故障,或者简单地调用 Result / Wait 并捕获生成的 AggregateException。如果前驱故障而续体两者都不做,异常被认为是未观察到的,当任务稍后被垃圾回收时,静态 TaskScheduler.UnobservedTaskException 事件将触发。
安全的模式是重新抛出前驱异常。只要等待续体,异常将被传播并重新抛出给等待者:
Task continuation = Task.Factory.StartNew (() => { throw null; })
.ContinueWith (ant =>
{
ant.Wait();
// Continue processing...
});
continuation.Wait(); // Exception is now thrown back to caller.
处理异常的另一种方式是为异常和非异常结果指定不同的续体。可以通过 TaskContinuationOptions 来实现:
Task task1 = Task.Factory.StartNew (() => { throw null; });
Task error = task1.ContinueWith (ant => Console.Write (ant.Exception),
TaskContinuationOptions.OnlyOnFaulted);
Task ok = task1.ContinueWith (ant => Console.Write ("Success!"),
TaskContinuationOptions.NotOnFaulted);
此模式在与子任务结合时尤为有用,您很快就会看到。
以下扩展方法“吞噬”了任务的未处理异常:
public static void IgnoreExceptions (this Task task)
{
task.ContinueWith (t => { var ignore = t.Exception; },
TaskContinuationOptions.OnlyOnFaulted);
}
(可以通过添加代码来记录异常以改进此功能。)以下是如何使用它的示例:
Task.Factory.StartNew (() => { throw null; }).IgnoreExceptions();
续体和子任务
续体的一个强大特性是它们只有在所有子任务完成后才启动(参见图 22-5)。在那时,由子任务抛出的任何异常都将传递给续体。
在以下示例中,我们启动三个子任务,每个任务都会抛出 NullReferenceException。然后,我们通过父任务上的后续操作一次性捕获所有这些异常:
TaskCreationOptions atp = TaskCreationOptions.AttachedToParent;
Task.Factory.StartNew (() =>
{
Task.Factory.StartNew (() => { throw null; }, atp);
Task.Factory.StartNew (() => { throw null; }, atp);
Task.Factory.StartNew (() => { throw null; }, atp);
})
.ContinueWith (p => Console.WriteLine (p.Exception),
TaskContinuationOptions.OnlyOnFaulted);

图 22-5. 后续操作
条件性后续操作
默认情况下,后续操作被无条件地调度,无论先决条件是否完成、抛出异常或取消。您可以通过 TaskContinuationOptions 枚举中包含的一组(可组合)标志来更改此行为。以下是控制条件后续操作的三个核心标志:
NotOnRanToCompletion = 0x10000,
NotOnFaulted = 0x20000,
NotOnCanceled = 0x40000,
这些标志是减法的,即您应用得越多,后续操作执行的可能性就越小。为了方便起见,还有以下预组合值:
OnlyOnRanToCompletion = NotOnFaulted | NotOnCanceled,
OnlyOnFaulted = NotOnRanToCompletion | NotOnCanceled,
OnlyOnCanceled = NotOnRanToCompletion | NotOnFaulted
(将所有 Not* 标志 [NotOnRanToCompletion, NotOnFaulted, NotOnCanceled] 组合在一起是毫无意义的,因为这将导致后续操作始终被取消。)
“RanToCompletion” 表示先决条件成功完成,没有取消或未处理的异常。
“Faulted” 表示先决条件上抛出了未处理的异常。
“已取消”有两种含义:
-
先决条件是通过其取消令牌取消的。换句话说,当启动先决条件时,如果抛出
OperationCanceledException,则其CancellationToken属性与传递给它的先决条件匹配。 -
先决条件由于未满足条件性后续操作断言而被隐式取消。
重要的是要理解,当一个后续操作由于这些标志而不执行时,并不意味着后续操作被遗忘或放弃,而是被取消了。这意味着后续操作本身上的任何后续操作都将运行,除非您使用 NotOnCanceled 进行断言。例如,考虑以下情况:
Task t1 = Task.Factory.StartNew (...);
Task fault = t1.ContinueWith (ant => Console.WriteLine ("fault"),
TaskContinuationOptions.OnlyOnFaulted);
Task t3 = fault.ContinueWith (ant => Console.WriteLine ("t3"));
目前情况下,t3 将始终被安排执行——即使 t1 不抛出异常(参见 图 22-6)。这是因为如果 t1 成功,fault 任务将被取消,并且对 t3 没有施加任何后续操作限制,t3 将会无条件执行。
如果我们希望 t3 只在实际运行 fault 时才执行,我们必须做如下处理:
Task t3 = fault.ContinueWith (ant => Console.WriteLine ("t3"),
TaskContinuationOptions.NotOnCanceled);
(或者,我们可以指定 OnlyOnRanToCompletion;区别在于,如果在 fault 内抛出异常,则 t3 将不会执行。)

图 22-6. 条件性后续操作
多个先决条件的后续操作
您可以使用 TaskFactory 类中的 ContinueWhenAll 和 ContinueWhenAny 方法,根据多个先决条件的完成来调度后续操作。然而,随着讨论的任务组合器 WhenAll 和 WhenAny 的引入,这些方法已经变得多余了。特别是,考虑以下任务:
var task1 = Task.Run (() => Console.Write ("X"));
var task2 = Task.Run (() => Console.Write ("Y"));
我们可以安排一个后续操作,在两者都完成时执行如下:
var continuation = Task.Factory.ContinueWhenAll (
new[] { task1, task2 }, tasks => Console.WriteLine ("Done"));
这里是使用 WhenAll 任务组合器得到相同结果的情况:
var continuation = Task.WhenAll (task1, task2)
.ContinueWith (ant => Console.WriteLine ("Done"));
单个前序任务的多个延续
在同一任务上多次调用ContinueWith会创建单个前序任务的多个延续。当前序任务完成时,所有延续将同时开始(除非您指定TaskContinuationOptions.ExecuteSynchronously,在这种情况下,延续将按顺序执行)。
以下等待一秒钟,然后写入XY或YX:
var t = Task.Factory.StartNew (() => Thread.Sleep (1000));
t.ContinueWith (ant => Console.Write ("X"));
t.ContinueWith (ant => Console.Write ("Y"));
任务调度器
任务调度器分配任务给线程,并由抽象的TaskScheduler类表示。.NET 提供了两个具体的实现:与 CLR 线程池协同工作的默认调度器和同步上下文调度器。后者主要设计用于帮助您处理 WPF 和 Windows Forms 的线程模型,这要求用户界面元素和控件只能从创建它们的线程访问(见“富客户端应用程序中的线程”)。通过捕获它,我们可以指示任务或延续在此上下文中执行:
// Suppose we are on a UI thread in a Windows Forms / WPF application:
_uiScheduler = TaskScheduler.FromCurrentSynchronizationContext();
假设Foo是一个计算密集型方法,返回一个字符串,而lblResult是一个 WPF 或 Windows Forms 标签,我们可以在操作完成后安全地更新标签,如下所示:
Task.Run (() => Foo())
.ContinueWith (ant => lblResult.Content = ant.Result, _uiScheduler);
当然,C#的异步函数更常用于这种类型的操作。
也可以编写自己的任务调度器(通过子类化TaskScheduler),尽管这只在非常专业的场景下才这样做。对于自定义调度,通常会使用TaskCompletionSource。
TaskFactory
当调用Task.Factory时,您实际上在Task上调用一个静态属性,该属性返回一个默认的TaskFactory对象。任务工厂的目的是创建任务;具体来说,是三种类型的任务:
-
“普通”任务(通过
StartNew) -
多个前序任务的延续(通过
ContinueWhenAll和ContinueWhenAny) -
包装了遵循已废弃的 APM(通过
FromAsync;见“过时的模式”)的方法的任务
另一种创建任务的方式是实例化Task并调用Start。然而,这只允许您创建“普通”的任务,而不是延续。
创建自己的任务工厂
TaskFactory不是一个抽象工厂:你实际上可以实例化这个类,在你想要使用相同(非标准)值为TaskCreationOptions、TaskContinuationOptions或TaskScheduler重复创建任务时非常有用。例如,如果我们想要重复创建长时间运行的父级任务,我们可以创建一个自定义工厂,如下所示:
var factory = new TaskFactory (
TaskCreationOptions.LongRunning | TaskCreationOptions.AttachedToParent,
TaskContinuationOptions.None);
创建任务只是简单地在工厂上调用StartNew的问题:
Task task1 = factory.StartNew (Method1);
Task task2 = factory.StartNew (Method2);
...
在调用ContinueWhenAll和ContinueWhenAny时应用自定义的延续选项。
使用 AggregateException
正如我们所见,PLINQ、Parallel 类和 Task 会自动将异常传递给消费者。要理解这一点为何至关重要,请考虑以下 LINQ 查询,它在第一次迭代中抛出 DivideByZeroException:
try
{
var query = from i in Enumerable.Range (0, 1000000)
select 100 / i;
...
}
catch (DivideByZeroException)
{
...
}
如果我们让 PLINQ 并行化这个查询并且它忽略了异常处理,DivideByZeroException 可能会在单独的线程上被抛出,绕过我们的 catch 块并导致应用程序崩溃。
因此,异常会自动捕获并重新抛给调用方。但遗憾的是,要捕获 DivideByZeroException 并不像看起来那么简单。因为这些库利用了多线程,实际上可能会同时抛出两个或更多异常。为确保报告所有异常,因此将异常包装在一个 AggregateException 容器中,其暴露了一个 InnerExceptions 属性,其中包含每个捕获的异常:
try
{
var query = from i in ParallelEnumerable.Range (0, 1000000)
select 100 / i;
// Enumerate query
...
}
catch (AggregateException aex)
{
foreach (Exception ex in aex.InnerExceptions)
Console.WriteLine (ex.Message);
}
注意
PLINQ 和 Parallel 类在遇到第一个异常时结束查询或循环执行 —— 即不再处理任何后续元素或循环体。然而,在当前周期完成之前可能会抛出更多异常。在 AggregateException 中的第一个异常可通过 InnerException 属性看到。
Flatten 和 Handle
AggregateException 类提供了几种简化异常处理的方法:Flatten 和 Handle。
Flatten
AggregateException 往往会包含其他 AggregateException。一个例子是如果子任务抛出异常。你可以通过调用 Flatten 方法消除任意层级的嵌套以简化处理。该方法返回一个新的 AggregateException,其中包含一个简单的内部异常列表:
catch (AggregateException aex)
{
foreach (Exception ex in aex.Flatten().InnerExceptions)
myLogWriter.LogException (ex);
}
Handle
有时候,捕获特定类型的异常并重新抛出其他类型的异常是很有用的。AggregateException 上的 Handle 方法为此提供了一种快捷方式。它接受一个异常谓词,该谓词运行在每个内部异常上:
public void Handle (Func<Exception, bool> predicate)
如果谓词返回 true,则认为该异常“已处理”。委托运行完所有异常后,将执行以下操作:
-
如果所有异常都被“处理”(委托返回
true),则不会重新抛出异常。 -
如果委托返回
false(“未处理”)的任何异常,则会构建一个新的AggregateException包含这些异常并重新抛出。
例如,以下代码最终会重新抛出包含单个 NullReferenceException 的另一个 AggregateException:
var parent = Task.Factory.StartNew (() =>
{
// We’ll throw 3 exceptions at once using 3 child tasks:
int[] numbers = { 0 };
var childFactory = new TaskFactory
(TaskCreationOptions.AttachedToParent, TaskContinuationOptions.None);
childFactory.StartNew (() => 5 / numbers[0]); // Division by zero
childFactory.StartNew (() => numbers [1]); // Index out of range
childFactory.StartNew (() => { throw null; }); // Null reference
});
try { parent.Wait(); }
catch (AggregateException aex)
{
aex.Flatten().Handle (ex => // Note that we still need to call Flatten
{
if (ex is DivideByZeroException)
{
Console.WriteLine ("Divide by zero");
return true; // This exception is "handled"
}
if (ex is IndexOutOfRangeException)
{
Console.WriteLine ("Index out of range");
return true; // This exception is "handled"
}
return false; // All other exceptions will get rethrown
});
}
并发集合
.NET 在 System.Collections.Concurrent 命名空间中提供了线程安全的集合:
| 并发集合 | 非并发等效集合 |
|---|---|
ConcurrentStack<T> |
Stack<T> |
ConcurrentQueue<T> |
Queue<T> |
ConcurrentBag<T> |
(无) |
ConcurrentDictionary<TKey,TValue> |
Dictionary<TKey,TValue> |
并发集合针对高并发场景进行了优化;然而,在需要线程安全集合(作为普通集合的替代品进行锁定)的任何情况下,它们也很有用。然而,还有一些注意事项:
-
在所有但高并发场景下,传统集合的性能优于并发集合。
-
线程安全的集合并不保证使用它的代码是线程安全的(参见“锁定和线程安全”)。
-
如果在另一个线程修改并发集合时枚举它,不会抛出异常,而是会得到旧内容和新内容的混合。
-
没有
List<T>的并发版本。 -
并发的栈、队列和背包类在内部使用链表实现。这使它们在内存效率上不如非并发的
Stack和Queue类,但在并发访问时更好,因为链表有利于无锁或低锁实现。这是因为将节点插入链表只需要更新几个引用,而将元素插入类似List<T>的结构可能需要移动数千个现有元素。
换句话说,这些集合不仅仅是使用带锁的普通集合的捷径。举例来说,如果我们在单个线程上执行以下代码:
var d = new ConcurrentDictionary<int,int>();
for (int i = 0; i < 1000000; i++) d[i] = 123;
它的运行速度比这个慢三倍:
var d = new Dictionary<int,int>();
for (int i = 0; i < 1000000; i++) lock (d) d[i] = 123;
(然而,从 ConcurrentDictionary 读取 是快速的,因为读取是无锁的。)
并发集合还不同于传统集合,它们公开了特殊的方法来执行原子的测试和操作,比如 TryPop。大多数这些方法通过 IProducerConsumerCollection<T> 接口统一。
IProducerConsumerCollection
生产者/消费者集合的两个主要用例是:
-
添加一个元素(“生产”)
-
检索元素同时将其移除(“消费”)
经典的例子是栈和队列。生产者/消费者集合在并行编程中很重要,因为它们有利于高效的无锁实现。
IProducerConsumerCollection<T> 接口代表一个线程安全的生产者/消费者集合。以下类实现了这个接口:
ConcurrentStack<T>
ConcurrentQueue<T>
ConcurrentBag<T>
IProducerConsumerCollection<T> 扩展了 ICollection,增加了以下方法:
void CopyTo (T[] array, int index);
T[] ToArray();
bool TryAdd (T item);
bool TryTake (out T item);
TryAdd 和 TryTake 方法测试是否可以执行添加/移除操作;如果可以,则执行添加/移除。测试和执行是原子性的,消除了像传统集合那样需要锁定的必要性:
int result;
lock (myStack) if (myStack.Count > 0) result = myStack.Pop();
TryTake 如果集合为空则返回 false。TryAdd 在提供的三个实现中总是成功并返回 true。然而,如果你编写了自己的并发集合以禁止重复,那么如果元素已经存在,TryAdd 将返回 false(例如,如果你编写了一个并发的集合)。
TryTake移除的特定元素由子类定义:
-
使用栈时,
TryTake会删除最近添加的元素。 -
使用队列时,
TryTake会移除最近添加的元素。 -
使用袋子时,
TryTake会以最高效的方式删除任何它可以删除的元素。
三个具体类大多会显式实现TryTake和TryAdd方法,并通过更具体命名的公共方法(如TryDequeue和TryPop)暴露相同的功能。
ConcurrentBag
ConcurrentBag<T>存储一个无序的对象集合(允许重复)。在调用Take或TryTake时,ConcurrentBag<T>适用于您不在乎获取哪个元素的情况。
ConcurrentBag<T>相较于并发队列或栈的好处在于,当多个线程同时调用袋子的Add方法时,几乎没有竞争。相比之下,同时在队列或栈上并行调用Add会产生一些竞争(尽管比在非并发集合周围加锁要少得多)。在并发袋子上调用Take也非常高效,只要每个线程不取出比它Add的元素还多。
在并发袋子内部,每个线程都有自己的私有链表。元素被添加到调用Add的线程的私有列表中,从而消除了竞争。当你枚举袋子时,枚举器遍历每个线程的私有列表,依次生成每个元素。
当你调用Take时,袋子首先查看当前线程的私有列表。如果至少有一个元素,¹它可以轻松地完成任务,而且没有竞争。但是如果列表为空,则必须从另一个线程的私有列表“偷取”一个元素,并且可能会发生竞争。
因此,准确地说,调用Take会给你在该线程上最近添加的元素;如果该线程上没有元素,它会随机选择另一个线程上最近添加的元素。
当你的集合上的并行操作大部分由Add元素组成时,或者Add和Take在一个线程上是平衡的时候,并发袋子是理想的选择。我们之前看到了前一种情况的例子,即使用Parallel.ForEach来实现并行拼写检查器:
var misspellings = new ConcurrentBag<Tuple<int,string>>();
Parallel.ForEach (wordsToTest, (word, state, i) =>
{
if (!wordLookup.Contains (word))
misspellings.Add (Tuple.Create ((int) i, word));
});
对于生产者/消费者队列来说,并发袋子不是一个好选择,因为元素是由不同的线程添加和移除的。
BlockingCollection
如果在我们之前讨论过的生产者/消费者集合中的任何一个上调用TryTake,如ConcurrentStack<T>、ConcurrentQueue<T>和ConcurrentBag<T>,而且集合为空,则该方法返回false。在这种情况下,有时等待直到有元素可用会更有用。
PFX 的设计者们没有通过对TryTake方法进行过多的重载(这样做会导致在允许取消令牌和超时后成员数量爆炸),而是将这个功能封装到一个名为BlockingCollection<T>的包装类中。阻塞集合包装任何实现IProducerConsumerCollection<T>的集合,并允许你从包装集合中Take一个元素——如果没有可用元素则阻塞。
阻塞集合还允许你限制集合的总大小,如果超出该大小则阻塞生产者。以这种方式限制的集合称为有界阻塞集合。
要使用BlockingCollection<T>:
-
实例化该类,可以选择包装
IProducerConsumerCollection<T>和集合的最大大小(界限)。 -
调用
Add或TryAdd以向底层集合添加元素。 -
调用
Take或TryTake以从底层集合中移除(消费)元素。
如果在不传递集合的情况下调用构造函数,该类将自动实例化一个ConcurrentQueue<T>。生产和消费方法允许你指定取消令牌和超时。Add和TryAdd可能会在集合大小有限时阻塞;Take和TryTake在集合为空时阻塞。
另一种消费元素的方法是调用GetConsumingEnumerable。它返回一个(可能是)无限序列,随着元素变得可用而产生。你可以通过调用CompleteAdding强制结束序列:这个方法也阻止进一步的元素入列。
BlockingCollection还提供了名为AddToAny和TakeFromAny的静态方法,让你在指定多个阻塞集合时添加或取出一个元素。动作将由能够服务请求的第一个集合执行。
编写生产者/消费者队列
生产者/消费者队列在并行编程和一般并发场景中非常有用。它的工作原理如下:
-
设置队列以描述工作项——或对其进行处理的数据。
-
当任务需要执行时,它被加入队列,调用者继续处理其他事务。
-
一个或多个工作线程在后台运行,从队列中取出并执行排队的项。
生产者/消费者队列让你精确控制同时执行的工作线程数量,这不仅有助于限制 CPU 消耗,还包括其他资源。例如,如果任务执行密集的磁盘 I/O 操作,你可以限制并发以避免使操作系统和其他应用程序饥饿。你还可以在队列生命周期中动态添加和删除工作线程。CLR 的线程池本身就是一种生产者/消费者队列,专门优化于短期运行的计算密集型任务。
一个生产者/消费者队列通常保存数据项,对这些数据项执行(相同的)任务。例如,数据项可以是文件名,任务可能是加密这些文件。然而,通过将数据项设为委托,你可以编写一个更通用的生产者/消费者队列,其中每个数据项都可以执行任何操作。
在http://albahari.com/threading,我们展示如何使用AutoResetEvent(以及后来使用Monitor的Wait和Pulse)从头开始编写一个生产者/消费者队列。然而,从头开始编写一个生产者/消费者是不必要的,因为大部分功能已经被BlockingCollection<T>提供。以下是如何使用它:
public class PCQueue : IDisposable
{
BlockingCollection<Action> _taskQ = new BlockingCollection<Action>();
public PCQueue (int workerCount)
{
// Create and start a separate Task for each consumer:
for (int i = 0; i < workerCount; i++)
Task.Factory.StartNew (Consume);
}
public void Enqueue (Action action) { _taskQ.Add (action); }
void Consume()
{
// This sequence that we’re enumerating will *block* when no elements
// are available and will *end* when CompleteAdding is called.
foreach (Action action in _taskQ.GetConsumingEnumerable())
action(); // Perform task.
}
public void Dispose() { _taskQ.CompleteAdding(); }
}
因为我们没有向BlockingCollection的构造函数传递任何内容,它自动实例化了一个并发队列。如果我们传入了一个ConcurrentStack,我们将得到一个生产者/消费者栈。
使用任务
我们刚刚编写的生产者/消费者是不灵活的,因为我们无法在将工作项入队后跟踪它们。如果我们能做到以下几点就好了:
-
知道何时一个工作项已经完成(并
await它) -
取消一个工作项
-
优雅地处理工作项抛出的任何异常
一个理想的解决方案是让Enqueue方法返回一个对象,给我们刚才描述的功能。好消息是已经存在一个类来做到这一点——Task类,我们可以通过TaskCompletionSource生成或直接实例化(创建一个未启动或冷任务):
public class PCQueue : IDisposable
{
BlockingCollection<Task> _taskQ = new BlockingCollection<Task>();
public PCQueue (int workerCount)
{
// Create and start a separate Task for each consumer:
for (int i = 0; i < workerCount; i++)
Task.Factory.StartNew (Consume);
}
public Task Enqueue (Action action, CancellationToken cancelToken
= default (CancellationToken))
{
var task = new Task (action, cancelToken);
_taskQ.Add (task);
return task;
}
public Task<TResult> Enqueue<TResult> (Func<TResult> func,
CancellationToken cancelToken = default (CancellationToken))
{
var task = new Task<TResult> (func, cancelToken);
_taskQ.Add (task);
return task;
}
void Consume()
{
foreach (var task in _taskQ.GetConsumingEnumerable())
try
{
if (!task.IsCanceled) task.RunSynchronously();
}
catch (InvalidOperationException) { } // Race condition
}
public void Dispose() { _taskQ.CompleteAdding(); }
}
在Enqueue中,我们入队并返回给调用者一个我们创建但不启动的任务。
在Consume中,我们在消费者线程上同步运行任务。我们捕获InvalidOperationException以处理任务在检查是否取消和运行之间被取消的不太可能事件。
以下是如何使用这个类:
var pcQ = new PCQueue (2); // Maximum concurrency of 2
string result = await pcQ.Enqueue (() => "That was easy!");
...
因此,我们拥有任务的所有好处——异常传播、返回值和取消——同时完全控制调度。
¹ 由于一个实现细节,实际上至少需要两个元素才能完全避免争用。
第二十三章:Span 和 Memory
结构Span<T>和Memory<T>作为数组、字符串或任何连续的托管或非托管内存块的低级外观。它们的主要目的是帮助进行某些类型的微优化,特别是编写低分配代码,以最小化托管内存分配(从而减少垃圾收集器的负载),而无需为不同类型的输入复制代码。它们还支持切片——在不创建副本的情况下处理数组、字符串或内存块的一部分。
Span<T>和Memory<T>在性能热点中特别有用,例如 ASP.NET Core 处理流水线或为对象数据库提供服务的 JSON 解析器。
注意
如果在 API 中遇到这些类型而又不需要或关心它们的潜在性能优势,可以如下简单处理它们:
-
当调用期望
Span<T>、ReadOnlySpan<T>、Memory<T>或ReadOnlyMemory<T>的方法时,请传递数组;即T[]。(这得益于隐式转换运算符。) -
要从 span/memory 转换为数组,请调用
ToArray方法。如果T是char,则ToString将 span/memory 转换为字符串。
从 C# 12 开始,您还可以使用集合初始化器来创建 span。
具体来说,Span<T>有两个功能:
-
它为托管数组、字符串和基于指针的内存提供了一个通用的类似数组的接口。这使您可以自由地使用栈分配和非托管内存来避免垃圾收集,而无需复制代码或操纵指针。
-
它允许“切片”:在不制作副本的情况下公开可重复使用的 span 的子部分。
注意
Span<T>仅由两个字段组成,即指针和长度。因此,它只能表示连续的内存块。(如果需要处理非连续内存,可以使用ReadOnlySequence<T>类作为链表。)
因为Span<T>可以包装栈分配的内存,所以在存储或传递实例时有一些限制(部分由于Span<T>是ref struct)。Memory<T>充当了没有这些限制的 span,但它不能包装栈分配的内存。Memory<T>仍然提供切片的好处。
每个结构都有一个只读对应物(ReadOnlySpan<T>和ReadOnlyMemory<T>)。除了防止意外更改外,只读对应物通过允许编译器和运行时额外的优化自由进一步提高性能。
.NET 本身(以及 ASP.NET Core)使用这些类型来提高 I/O、网络、字符串处理和 JSON 解析的效率。
注意
Span<T>和Memory<T>的数组切片功能使旧的ArraySegment<T>类变得多余。为了帮助进行任何过渡,从ArraySegment<T>到所有 span/memory 结构的隐式转换运算符,以及从Memory<T>和ReadOnlyMemory<T>到ArraySegment<T>的转换运算符都是可用的。
Span 和切片
不同于数组,Span 可以轻松地 切片 来表示同一底层数据的不同子部分,如 图 23-1 所示。
举个实际的例子,假设您正在编写一个方法来对整数数组进行求和。一个微优化的实现将避免使用 LINQ,而是使用 foreach 循环:
int Sum (int[] numbers)
{
int total = 0;
foreach (int i in numbers) total += i;
return total;
}

图 23-1. 切片
现在想象一下,您只想对数组的 一部分 求和。您有两个选项:
-
首先复制您想要求和的数组部分到另一个数组中
-
向方法添加额外的参数(
offset和count)
第一种选择效率低;第二种选择增加了混乱和复杂性(特别是当方法需要接受多个数组时更糟糕)。
Span 很好地解决了这个问题。您所需做的就是将参数类型从 int[] 更改为 ReadOnlySpan<int>(其他都保持不变):
int Sum (ReadOnlySpan<int> numbers)
{
int total = 0;
foreach (int i in numbers) total += i;
return total;
}
注意
我们使用 ReadOnlySpan<T> 而不是 Span<T>,因为我们不需要修改数组。从 Span<T> 到 ReadOnlySpan<T> 有一个隐式转换,因此您可以将 Span<T> 传递给期望 ReadOnlySpan<T> 的方法。
我们可以如下测试这个方法:
var numbers = new int [1000];
for (int i = 0; i < numbers.Length; i++) numbers [i] = i;
int total = Sum (numbers);
我们可以因为从 T[] 到 Span<T> 和 ReadOnlySpan<T> 有隐式转换,所以可以用数组调用 Sum 方法。另一个选项是使用 AsSpan 扩展方法:
var span = numbers.AsSpan();
ReadOnlySpan<T> 的索引器使用了 C# 的 ref readonly 功能直接访问底层数据:这使得我们的方法执行效果几乎与最初使用数组的示例一样好。但是我们所获得的是现在可以“切片”数组,并且仅对部分元素求和,如下所示:
// Sum the middle 500 elements (starting from position 250):
int total = Sum (numbers.AsSpan (250, 500));
如果您已经有一个 Span<T> 或 ReadOnlySpan<T>,可以通过调用 Slice 方法来切片它:
Span<int> span = numbers;
int total = Sum (span.Slice (250, 500));
您也可以使用 C# 的 索引 和 范围(自 C# 8 起):
Span<int> span = numbers;
Console.WriteLine (span [¹]); // Last element
Console.WriteLine (Sum (span [..10])); // First 10 elements
Console.WriteLine (Sum (span [100..])); // 100th element to end
Console.WriteLine (Sum (span [⁵..])); // Last 5 elements
尽管 Span<T> 不实现 IEnumerable<T>(由于它是一个 ref 结构,不能实现接口),但它确实实现了允许 C# 的 foreach 语句工作的模式(参见 “枚举”)。
CopyTo 和 TryCopyTo
CopyTo 方法将元素从一个 span(或 Memory<T>)复制到另一个 span 中。在下面的例子中,我们将 span x 中的所有元素复制到 span y 中:
Span<int> x = [1, 2, 3, 4]; // Collection expression
Span<int> y = new int[4];
x.CopyTo (y);
注意
请注意,我们使用集合表达式初始化了 x。集合表达式(自 C# 12 起)不仅是一个有用的快捷方式,而且在 span 的情况下,它允许编译器自由选择底层类型。当元素数量较小时,编译器可以在栈上分配内存(而不是创建数组),以避免堆分配的开销。
切片使得这个方法更加有用。在下一个示例中,我们将 span x 的前半部分复制到 span y 的后半部分:
Span<int> x = [1, 2, 3, 4 ];
Span<int> y = [10, 20, 30, 40];
x[..2].CopyTo (y[2..]); // y is now [10, 20, 1, 2]
如果目标中没有足够的空间来完成复制,CopyTo 抛出异常,而 TryCopyTo 返回 false(不复制任何元素)。
跨度结构体还公开了Clear和Fill跨度的方法,以及一个用于在跨度中搜索元素的IndexOf方法。
在跨度中搜索
MemoryExtensions类定义了许多扩展方法,用于在跨度内搜索值,如Contains、IndexOf、LastIndexOf和BinarySearch(以及那些改变跨度的方法,如Fill、Replace和Reverse)。
从.NET 8 开始,还有一些搜索多个值的方法,例如ContainsAny、ContainsAnyExcept、IndexOfAny和IndexOfAnyExcept。通过这些方法,你可以指定要搜索的值,可以作为跨度或SearchValues<T>实例(位于System.Buffers中),通过调用SearchValues.Create来实例化:
ReadOnlySpan<char> span = "The quick brown fox jumps over the lazy dog.";
var vowels = SearchValues.Create ("aeiou");
Console.WriteLine (span.IndexOfAny (vowels)); // 2
SearchValues<T>在多个搜索中重复使用实例时提高性能。
注意
当处理数组或字符串时,也可以简单地调用数组或字符串的AsSpan()方法。
处理文本
跨度被设计成与字符串很好地配合使用,字符串被视为ReadOnlySpan<char>。以下方法计算空白字符的数量:
int CountWhitespace (ReadOnlySpan<char> s)
{
int count = 0;
foreach (char c in s)
if (char.IsWhiteSpace (c))
count++;
return count;
}
你可以使用字符串调用此类方法(感谢隐式转换运算符):
int x = CountWhitespace ("Word1 Word2"); // OK
或者使用子字符串:
int y = CountWhitespace (someString.AsSpan (20, 10));
ToString()方法将ReadOnlySpan<char>转换回字符串。
扩展方法确保字符串类上的一些常用方法也适用于ReadOnlySpan<char>:
var span = "This ".AsSpan(); // ReadOnlySpan<char>
Console.WriteLine (span.StartsWith ("This")); // True
Console.WriteLine (span.Trim().Length); // 4
(请注意,诸如StartsWith的方法使用ordinal比较,而string类上的相应方法默认使用区域敏感比较。)
诸如ToUpper和ToLower之类的方法可用,但必须传入具有正确长度的目标跨度(这使您可以决定如何在何处分配内存)。
一些string的方法不可用,例如Split(将字符串拆分为单词数组)。事实上,直接等效于string的Split方法是不可能的,因为你无法创建跨度的数组。
注意
这是因为跨度被定义为ref structs,这些结构体只能存在于堆栈上。
(通过“只能存在于堆栈上”,我们指的是结构体本身只能存在于堆栈上。跨度包裹的内容可以存在于堆上,也正如本例所示。)
System.Buffers.Text命名空间包含其他类型,帮助您处理基于跨度的文本,包括以下内容:
-
Utf8Formatter.TryFormat相当于在内置和简单类型(如decimal、DateTime等)上调用ToString,但是将结果写入跨度而不是字符串。 -
Utf8Parser.TryParse则相反,从跨度中解析数据为简单类型。 -
Base64类型提供了用于读取/写入 base-64 数据的方法。
注意
从 .NET 8 开始,.NET 数字和日期/时间类型(以及其他简单类型)允许直接使用 UTF-8 格式化和解析,通过新的 TryFormat 和 Parse/TryParse 方法,这些方法操作 Span<byte>。新方法定义在 IUtf8SpanFormattable 和 IUtf8SpanParsable<TSelf> 接口中(后者利用了 C# 12 能够定义静态抽象接口成员的能力)。
基本的 CLR 方法,比如 int.Parse 已经被重载以接受 ReadOnlySpan<char>。
Memory
Span<T> 和 ReadOnlySpan<T> 被定义为 ref 结构,以最大化它们的优化潜力,并允许它们安全地与栈分配内存一起工作(正如你将在下一节看到的那样)。然而,它也会带来一些限制。除了不友好的数组外,你不能将它们用作类中的字段(这会将它们放在堆上)。这反过来阻止它们出现在 lambda 表达式中——以及作为异步方法、迭代器和异步流的参数:
async void Foo (Span<int> notAllowed) // Compile-time error!
(记住编译器通过编写私有 状态机 处理异步方法和迭代器,这意味着任何参数和局部变量最终都成为字段。同样适用于关闭变量的 lambda 表达式:这些变量也最终成为 闭包 中的字段。)
Memory<T> 和 ReadOnlyMemory<T> 结构绕过了这一点,它们充当不能包装栈分配内存的 span,允许在字段、lambda 表达式、异步方法等中使用。
你可以通过隐式转换或 AsMemory() 扩展方法从数组获取 Memory<T> 或 ReadOnlyMemory<T>:
Memory<int> mem1 = new int[] { 1, 2, 3 };
var mem2 = new int[] { 1, 2, 3 }.AsMemory();
你可以通过其 Span 属性将 Memory<T> 或 ReadOnlyMemory<T> 轻松地“转换”为 Span<T> 或 ReadOnlySpan<T>,以便像处理 span 一样与之交互。这种转换非常高效,不会执行任何复制:
async void Foo (Memory<int> memory)
{
Span<int> span = memory.Span;
...
}
(你也可以通过其 Slice 方法或 C# 范围直接切片 Memory<T> 或 ReadOnlyMemory<T>,并通过其 Length 属性访问其长度。)
注意
另一种获取 Memory<T> 的方法是从 池 中租借,使用 System.Buffers.MemoryPool<T> 类。这与数组池化类似(参见 “数组池化”),并提供了另一种减少垃圾收集器负载的策略。
在前一节中我们提到,你不能为 span 直接编写与 string.Split 等效的代码,因为你无法创建 span 数组。这种限制不适用于 ReadOnlyMemory<char>:
// Split a string into words:
IEnumerable<ReadOnlyMemory<char>> Split (ReadOnlyMemory<char> input)
{
int wordStart = 0;
for (int i = 0; i <= input.Length; i++)
if (i == input.Length || char.IsWhiteSpace (input.Span [i]))
{
yield return input [wordStart..i]; // Slice with C# range operator
wordStart = i + 1;
}
}
这比字符串的 Split 方法更高效:它不会为每个单词创建新的字符串,而是返回原始字符串的 切片:
foreach (var slice in Split ("The quick brown fox jumps over the lazy dog"))
{
// slice is a ReadOnlyMemory<char>
}
注意
当你有选择时,最好编写接受 Span<T> 而不是 Memory<T> 的方法,因为你可以轻松地将 Memory<T> 转换为 Span<T>(通过 Span 属性),但反之则不行。
出于同样的原因,最好编写接受 ReadOnlySpan<T> 而不是 Span<T> 的方法。
仅向前枚举器
在前面的部分中,我们使用 ReadOnlyMemory<char> 作为实现类似字符串 Split 方法的解决方案。但是通过放弃 ReadOnlySpan<char>,我们失去了支持由非托管内存支持的切片 span 的能力。让我们重新审视 ReadOnlySpan<char>,看看是否能找到另一个解决方案。
一个可能的选择是编写我们的 Split 方法,使其返回 ranges:
Range[] Split (ReadOnlySpan<char> input)
{
int pos = 0;
var list = new List<Range>();
for (int i = 0; i <= input.Length; i++)
if (i == input.Length || char.IsWhiteSpace (input [i]))
{
list.Add (new Range (pos, i));
pos = i + 1;
}
return list.ToArray();
}
调用者可以使用这些范围来切片原始 span:
ReadOnlySpan<char> source = "The quick brown fox";
foreach (Range range in Split (source))
{
ReadOnlySpan<char> wordSpan = source [range];
...
}
这是一种改进,但仍然不完美。首先使用 span 的原因之一是避免内存分配。但请注意,我们的 Split 方法创建了一个 List<Range>,向其中添加了项目,然后将列表转换为数组。这至少需要两次内存分配以及一次内存复制操作。
解决此问题的方法是放弃列表和数组,转而使用仅向前的枚举器。枚举器使用起来更笨重,但可以通过使用结构体来实现零分配:
// We must define this as a ref struct, because _input is a ref struct.
public readonly ref struct CharSpanSplitter
{
readonly ReadOnlySpan<char> _input;
public CharSpanSplitter (ReadOnlySpan<char> input) => _input = input;
public Enumerator GetEnumerator() => new Enumerator (_input);
public ref struct Enumerator // Forward-only enumerator
{
readonly ReadOnlySpan<char> _input;
int _wordPos;
public ReadOnlySpan<char> Current { get; private set; }
public Rator (ReadOnlySpan<char> input)
{
_input = input;
_wordPos = 0;
Current = default;
}
public bool MoveNext()
{
for (int i = _wordPos; i <= _input.Length; i++)
if (i == _input.Length || char.IsWhiteSpace (_input [i]))
{
Current = _input [_wordPos..i];
_wordPos = i + 1;
return true;
}
return false;
}
}
}
public static class CharSpanExtensions
{
public static CharSpanSplitter Split (this ReadOnlySpan<char> input)
=> new CharSpanSplitter (input);
public static CharSpanSplitter Split (this Span<char> input)
=> new CharSpanSplitter (input);
}
这是如何调用它的:
var span = "the quick brown fox".AsSpan();
foreach (var word in span.Split())
{
// word is a ReadOnlySpan<char>
}
通过定义一个 Current 属性和一个 MoveNext 方法,我们的枚举器可以与 C# 的 foreach 语句一起使用(参见“枚举”)。我们不必实现 IEnumerable<T>/IEnumerator<T> 接口(实际上我们不能;ref 结构体不能实现接口)。我们牺牲了抽象性以进行微优化。
使用栈分配和非托管内存
另一种有效的微优化技术是通过最小化基于堆的分配来减轻垃圾收集器的负担。这意味着更多地使用基于栈的内存,甚至是非托管内存。
不幸的是,这通常需要您重写代码以使用指针。在我们之前用来对数组元素求和的例子中,我们需要编写另一个版本,如下所示:
unsafe int Sum (int* numbers, int length)
{
int total = 0;
for (int i = 0; i < length; i++) total += numbers [i];
return total;
}
这样我们就可以做到这一点:
int* numbers = stackalloc int [1000]; // Allocate array on the stack
int total = Sum (numbers, 1000);
Spans 解决了这个问题:您可以直接从指针构造 Span<T> 或 ReadOnlySpan<T>:
int* numbers = stackalloc int [1000];
var span = new Span<int> (numbers, 1000);
或者一步到位:
Span<int> numbers = stackalloc int [1000];
(请注意,这不需要使用 unsafe)。回想一下我们之前编写的 Sum 方法:
int Sum (ReadOnlySpan<int> numbers)
{
int total = 0;
int len = numbers.Length;
for (int i = 0; i < len; i++) total += numbers [i];
return total;
}
这种方法对于栈分配的 span 同样有效。我们在三个方面取得了进展:
-
同样的方法适用于数组和栈分配的内存
-
我们可以使用栈分配的内存,最小化使用指针
-
可以对 span 进行切片
注意
编译器足够聪明,可以防止您编写一个在堆栈上分配内存并通过 Span<T> 或 ReadOnlySpan<T> 返回给调用者的方法。
(然而,在其他情况下,您可以合法地返回 Span<T> 或 ReadOnlySpan<T>。)
您还可以使用 span 来包装从非托管堆中分配的内存。在以下示例中,我们使用 Marshal.AllocHGlobal 函数分配非托管内存,将其包装在 Span<char> 中,然后将字符串复制到非托管内存中。最后,我们使用前面部分中编写的 CharSpanSplitter 结构来将非托管字符串拆分成单词:
var source = "The quick brown fox".AsSpan();
var ptr = Marshal.AllocHGlobal (source.Length * sizeof (char));
try
{
var unmanaged = new Span<char> ((char*)ptr, source.Length);
source.CopyTo (unmanaged);
foreach (var word in unmanaged.Split())
Console.WriteLine (word.ToString());
}
finally { Marshal.FreeHGlobal (ptr); }
一个不错的奖励是,Span<T>的索引器执行边界检查,防止缓冲区溢出。如果正确实例化Span<T>,则会应用此保护:在我们的示例中,如果错误获取了该跨度,则会失去此保护。
var span = new Span<char> ((char*)ptr, source.Length * 2);
相当于悬空指针的保护也不存在,因此在使用Marshal.FreeHGlobal释放其非托管内存后,必须小心不要访问该跨度。
第二十四章:本地和 COM 互操作性
本章描述如何与本地(非托管)动态链接库(DLL)和组件对象模型(COM)组件集成。除非另有说明,本章提到的类型存在于System或System.Runtime.InteropServices命名空间中。
调用本地 DLL
P/Invoke,简称平台调用服务,允许您访问非托管 DLL 中的函数、结构和回调(Unix 上的共享库)。
例如,考虑在 Windows DLL user32.dll 中定义的MessageBox函数如下:
int MessageBox (HWND hWnd, LPCTSTR lpText, LPCTSTR lpCaption, UINT uType);
您可以通过声明同名的静态方法、应用extern关键字并添加DllImport属性来直接调用此函数:
using System;
using System.Runtime.InteropServices;
MessageBox (IntPtr.Zero,
"Please do not press this again.", "Attention", 0);
[DllImport("user32.dll")]
static extern int MessageBox (IntPtr hWnd, string text, string caption,
int type);
System.Windows和System.Windows.Forms命名空间中的MessageBox类本身调用类似的非托管方法。
这是 Ubuntu Linux 的一个DllImport示例:
Console.WriteLine ($"User ID: {getuid()}");
[DllImport("libc")]
static extern uint getuid();
CLR 包含一个编组器,知道如何在.NET 类型和非托管类型之间转换参数和返回值。在 Windows 示例中,int参数直接转换为函数期望的四字节整数,字符串参数转换为以 UTF-16 编码的空终止 Unicode 字符数组。IntPtr是一个设计用来封装非托管句柄的结构体;在 32 位平台上宽度为 32 位,在 64 位平台上宽度为 64 位。在 Unix 上也会进行类似的转换。(从 C# 9 开始,您还可以使用nint类型,它映射到IntPtr。)
类型和参数编组
编组常见类型
在非托管端,可以有多种方式来表示给定的数据类型。例如,字符串可以包含单字节的 ANSI 字符或 UTF-16 Unicode 字符,并且可以是长度前缀、空终止或固定长度。使用MarshalAs属性,您可以指定 CLR 编组器正在使用的变体,以便它提供正确的转换。以下是一个示例:
[DllImport("...")]
static extern int Foo ( [MarshalAs (UnmanagedType.LPStr)] string s );
UnmanagedType枚举包括编组器了解的所有 Win32 和 COM 类型。在本例中,编组器被告知转换为LPStr,这是一个以 null 结尾的单字节 ANSI 字符串。
在.NET 端,您也可以选择使用哪种数据类型。例如,非托管句柄可以映射到IntPtr、int、uint、long或ulong。
注意
大多数非托管句柄封装了一个地址或指针,因此必须映射到IntPtr以兼容 32 位和 64 位操作系统。典型示例是 HWND。
在 Win32 和 POSIX 函数中,经常会遇到接受一组常量的整数参数,这些常量在 C++头文件(如WinUser.h)中定义。您可以选择将其定义为简单的 C#常量,也可以将其定义为枚举。使用枚举可以使代码更整洁,同时增加静态类型安全性。我们在“共享内存”中提供了一个示例。
注意
在安装 Microsoft Visual Studio 时,请确保安装 C++头文件——即使在 C++类别中未选择其他任何选项。这是定义所有本机 Win32 常量的地方。然后,可以通过在 Visual Studio 程序目录中搜索**.h*来定位所有头文件。
在 Unix 上,POSIX 标准定义了常量的名称,但符合 POSIX 的 Unix 系统的各个实现可能为这些常量分配不同的数值。你必须使用所选操作系统的正确数值。类似地,POSIX 定义了在互操作调用中使用的结构体的标准。结构体中字段的顺序不由标准固定,Unix 实现可能会添加额外的字段。定义函数和类型的 C++头文件通常安装在/usr/include或/usr/local/include中。
从非托管代码接收字符串返回到.NET 需要进行一些内存管理。如果你使用StringBuilder而不是string声明外部方法,封送程序会自动执行这项工作,如下所示:
StringBuilder s = new StringBuilder (256);
GetWindowsDirectory (s, 256);
Console.WriteLine (s);
[DllImport("kernel32.dll")]
static extern int GetWindowsDirectory (StringBuilder sb, int maxChars);
在 Unix 上,工作方式类似。以下调用getcwd以返回当前目录:
var sb = new StringBuilder (256);
Console.WriteLine (getcwd (sb, sb.Capacity));
[DllImport("libc")]
static extern string getcwd (StringBuilder buf, int size);
虽然StringBuilder使用方便,但 CLR 在执行时需要进行额外的内存分配和复制,效率略低。在性能热点处,可以通过使用char[]来避免这种开销:
[DllImport ("kernel32.dll", CharSet = CharSet.Unicode)]
static extern int GetWindowsDirectory (char[] buffer, int maxChars);
注意,你必须在DllImport属性中指定CharSet。调用函数后还必须将输出字符串修剪到指定长度。你可以通过使用数组池(参见“数组池”)来实现这一点,同时最小化内存分配,如下所示:
string GetWindowsDirectory()
{
var array = ArrayPool<char>.Shared.Rent (256);
try
{
int length = GetWindowsDirectory (array, 256);
return new string (array, 0, length).ToString();
}
finally { ArrayPool<char>.Shared.Return (array); }
}
(当然,这个例子有些刻意,因为你可以通过内置的Environment.GetFolderPath方法获取 Windows 目录。)
注意
如果你不确定如何调用特定的 Win32 或 Unix 方法,通常可以在互联网上搜索方法名称和DllImport来找到示例。对于 Windows,网站http://www.pinvoke.net是一个旨在记录所有 Win32 签名的维基。
管理类和结构体
有时,你需要将结构体传递给非托管方法。例如,Win32 API 中的GetSystemTime定义如下:
void GetSystemTime (LPSYSTEMTIME lpSystemTime);
LPSYSTEMTIME符合此 C 结构:
typedef struct _SYSTEMTIME {
WORD wYear;
WORD wMonth;
WORD wDayOfWeek;
WORD wDay;
WORD wHour;
WORD wMinute;
WORD wSecond;
WORD wMilliseconds;
} SYSTEMTIME, *PSYSTEMTIME;
要调用GetSystemTime,我们必须定义一个与此 C 结构体匹配的.NET 类或结构体:
using System;
using System.Runtime.InteropServices;
[StructLayout(LayoutKind.Sequential)]
class SystemTime
{
public ushort Year;
public ushort Month;
public ushort DayOfWeek;
public ushort Day;
public ushort Hour;
public ushort Minute;
public ushort Second;
public ushort Milliseconds;
}
StructLayout属性指示封送程序如何将每个字段映射到其非托管对应项。LayoutKind.Sequential表示我们希望字段按pack-size边界依次对齐(你很快就会明白这是什么意思),就像它们在 C 结构体中一样。这里字段名不重要;字段顺序才是重要的。
现在我们可以调用GetSystemTime:
SystemTime t = new SystemTime();
GetSystemTime (t);
Console.WriteLine (t.Year);
[DllImport("kernel32.dll")]
static extern void GetSystemTime (SystemTime t);
同样,在 Unix 上:
Console.WriteLine (GetSystemTime());
static DateTime GetSystemTime()
{
DateTime startOfUnixTime =
new DateTime(1970, 1, 1, 0, 0, 0, 0, System.DateTimeKind.Utc);
Timespec tp = new Timespec();
int success = clock_gettime (0, ref tp);
if (success != 0) throw new Exception ("Error checking the time.");
return startOfUnixTime.AddSeconds (tp.tv_sec).ToLocalTime();
}
[DllImport("libc")]
static extern int clock_gettime (int clk_id, ref Timespec tp);
[StructLayout(LayoutKind.Sequential)]
struct Timespec
{
public long tv_sec; /* seconds */
public long tv_nsec; /* nanoseconds */
}
在 C 和 C# 中,对象的字段位于该对象地址加上 n 字节的位置。不同之处在于,在 C# 程序中,CLR 通过查找字段标记来找到偏移量;而 C 中的字段名直接编译为偏移量。例如,在 C 中,wDay 只是一个标记,用于表示 SystemTime 实例地址加上 24 字节处的内容。
为了访问速度,每个字段都被放置在其大小的倍数的偏移量处。然而,该乘数受到 x 字节的限制,其中 x 是包大小。在当前实现中,默认的包大小是 8 字节,因此由一个 sbyte 和一个(8 字节)long 组成的结构体占据 16 字节,并且 sbyte 后面的 7 字节被浪费了。您可以通过 StructLayout 属性的 Pack 属性指定一个包大小来减少或消除这种浪费:这使得字段对齐到指定包大小的倍数的偏移量上。因此,使用包大小为 1,刚刚描述的结构体将仅占用 9 字节。可以指定包大小为 1、2、4、8 或 16 字节。
StructLayout 属性还允许您指定显式字段偏移量(参见“模拟 C 联合”)。
进出传递
在前面的示例中,我们将 SystemTime 实现为一个类。我们也可以选择使用结构体——前提是 GetSystemTime 声明为具有 ref 或 out 参数:
[DllImport("kernel32.dll")]
static extern void GetSystemTime (out SystemTime t);
在大多数情况下,C# 的参数传递语义与外部方法相同。按值传递的参数被复制进去,C# 的 ref 参数是传入/传出的,C# 的 out 参数则是传出的。然而,对于具有特殊转换的类型,存在一些例外情况。例如,数组类和 StringBuilder 类在从函数中输出时需要复制,因此它们是传入/传出的。偶尔会有覆盖此行为的情况,可以使用 In 和 Out 属性。例如,如果数组应该是只读的,in 修饰符表示仅在进入函数时复制数组,而不是在离开函数时复制:
static extern void Foo ( [In] int[] array);
调用约定
非托管方法通过堆栈和(可选地)CPU 寄存器接收参数和返回值。由于有多种实现方式,因此出现了多种不同的协议。这些协议称为调用约定。
当前 CLR 支持三种调用约定:StdCall、Cdecl 和 ThisCall。
默认情况下,CLR 使用平台默认调用约定(该平台的标准约定)。在 Windows 上是 StdCall,在 Linux x86 上是 Cdecl。
如果非托管方法不遵循此默认设置,可以明确声明其调用约定如下:
[DllImport ("MyLib.dll", CallingConvention=CallingConvention.Cdecl)]
static extern void SomeFunc (...)
有些颇具误导性的命名为 CallingConvention.WinApi 实际上指的是平台默认。
从非托管代码的回调
C# 还允许外部函数通过回调调用 C# 代码。有两种方法可以实现回调:
-
通过函数指针
-
通过委托
为了说明问题,我们将调用以下位于 User32.dll 中的 Windows 函数,该函数枚举所有顶级窗口句柄:
BOOL EnumWindows (WNDENUMPROC lpEnumFunc, LPARAM lParam);
WNDENUMPROC 是一个回调函数,按顺序触发每个窗口的句柄(或直到回调返回 false)。以下是其定义:
BOOL CALLBACK EnumWindowsProc (HWND hwnd, LPARAM lParam);
使用函数指针的回调
从 C# 9 开始,当您的回调是静态方法时,最简单且性能最佳的选项是使用 函数指针。对于 WNDENUMPROC 回调,我们可以使用以下函数指针:
delegate*<IntPtr, IntPtr, bool>
这表示一个接受两个 IntPtr 参数并返回 bool 的函数。然后,您可以使用 & 运算符将静态方法传递给它:
using System;
using System.Runtime.InteropServices;
unsafe
{
EnumWindows (&PrintWindow, IntPtr.Zero);
[DllImport ("user32.dll")]
static extern int EnumWindows (
delegate*<IntPtr, IntPtr, bool> hWnd, IntPtr lParam);
static bool PrintWindow (IntPtr hWnd, IntPtr lParam)
{
Console.WriteLine (hWnd.ToInt64());
return true;
}
}
对于函数指针,回调必须是静态方法(或者是本示例中的静态局部函数)。
UnmanagedCallersOnly
您可以通过在函数指针声明中应用 unmanaged 关键字,以及在回调方法上应用 [UnmanagedCallersOnly] 属性来提高性能:
using System;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
unsafe
{
EnumWindows (&PrintWindow, IntPtr.Zero);
[DllImport ("user32.dll")]
static extern int EnumWindows (
delegate* unmanaged <IntPtr, IntPtr, byte> hWnd, IntPtr lParam);
[UnmanagedCallersOnly]
static byte PrintWindow (IntPtr hWnd, IntPtr lParam)
{
Console.WriteLine (hWnd.ToInt64());
return 1;
}
}
此属性标记 PrintWindow 方法,以便它只能从未管理的代码中调用,从而允许运行时采取捷径。请注意,我们还将方法的返回类型从 bool 更改为 byte:这是因为您应用 [UnmanagedCallersOnly] 的方法只能在签名中使用 可平铺 的值类型。可平铺类型是那些在托管和未托管世界中都以相同方式表示的类型,因此不需要任何特殊的编组逻辑。这些包括原始整数类型、float、double,以及仅包含可平铺类型的结构体。如果位于具有指定 CharSet.Unicode 的 StructLayout 属性的结构体中,则 char 类型也是可平铺的:
[StructLayout (LayoutKind.Sequential, CharSet=CharSet.Unicode)]
非默认的调用约定
默认情况下,编译器假定未管理的回调遵循平台默认的调用约定。如果情况不是这样,您可以通过 [UnmanagedCallersOnly] 属性的 CallConvs 参数显式指定其调用约定:
[UnmanagedCallersOnly (CallConvs = new[] { typeof (CallConvStdcall) })]
static byte PrintWindow (IntPtr hWnd, IntPtr lParam) ...
您还必须通过在 unmanaged 关键字后插入特殊修饰符来更新函数指针类型:
delegate* unmanaged[Stdcall] <IntPtr, IntPtr, byte> hWnd, IntPtr lParam);
注意
编译器允许您在方括号内放置任何标识符(例如 XYZ),只要有一个名为 CallConv**XYZ** 的.NET 类型(这是由运行时理解的,并且与您在应用 [UnmanagedCallersOnly] 属性时指定的匹配)。这样做可以使微软在未来更容易添加新的调用约定。
在这种情况下,我们指定了 StdCall,这是 Windows 平台的默认值(Cdecl 是 Linux x86 的默认值)。以下是当前支持的所有选项:
| 名称 | 未管理的修饰符 | 支持的类型 |
|---|---|---|
| Stdcall | unmanaged[Stdcall] |
CallConvStdcall |
| Cdecl | unmanaged[Cdecl] |
CallConvCdecl |
| ThisCall | unmanaged[Thiscall] |
CallConvThiscall |
使用委托的回调
未管理的回调也可以通过委托来完成。这种方法适用于所有版本的 C#,并允许引用实例方法的回调。
要继续进行,首先声明一个与回调匹配的委托类型。然后,您可以将委托实例传递给外部方法:
class CallbackFun
{
delegate bool EnumWindowsCallback (IntPtr hWnd, IntPtr lParam);
[DllImport("user32.dll")]
static extern int EnumWindows (EnumWindowsCallback hWnd, IntPtr lParam);
static bool PrintWindow (IntPtr hWnd, IntPtr lParam)
{
Console.WriteLine (hWnd.ToInt64());
return true;
}
static readonly EnumWindowsCallback printWindowFunc = PrintWindow;
static void Main() => EnumWindows (printWindowFunc, IntPtr.Zero);
}
对于未管理的回调,使用委托具有讽刺意味,因为很容易陷入陷阱,允许在委托实例超出范围后发生回调(在这种情况下,委托变得符合垃圾收集)。这可能导致最糟糕的运行时异常之一——没有有用的堆栈跟踪。对于静态方法回调,您可以通过将委托实例分配给只读静态字段(就像我们在这个例子中所做的那样)来避免这种情况。对于实例方法回调,这种模式将无法帮助,因此您必须小心编码,以确保在任何潜在的回调期间至少保持对委托实例的一个引用。即使如此,如果未管理的一侧存在错误——它在您告诉它不要后继续调用回调——您可能仍然需要处理无法跟踪的异常。一个解决方法是为每个未管理的函数定义一个唯一的委托类型:这有助于诊断,因为异常中报告了委托类型。
您可以通过将 [UnmanagedFunctionPointer] 属性应用于委托来更改回调的调用约定,从而使其不同于平台默认值:
[UnmanagedFunctionPointer (CallingConvention.Cdecl)]
delegate void MyCallback (int foo, short bar);
模拟 C 联合
struct 中的每个字段都有足够的空间来存储其数据。考虑一个包含一个 int 和一个 char 的 struct。int 可能从偏移量 0 开始,并且保证至少有四个字节。因此,char 将至少从偏移量 4 开始。如果由于某种原因 char 从偏移量 2 开始,如果给 char 赋值,则会更改 int 的值。听起来像混乱,不是吗?奇怪的是,C 语言支持一个称为 union 的结构体变体,正是做到了这一点。您可以通过使用 LayoutKind.Explicit 和 FieldOffset 属性在 C# 中模拟这一点。
想象一个这种方法会有用的情况可能会有些挑战。但是,假设您想在外部合成器上播放一个音符。Windows 多媒体 API 通过 MIDI 协议提供了一个可以做到这一点的函数:
[DllImport ("winmm.dll")]
public static extern uint midiOutShortMsg (IntPtr handle, uint message);
第二个参数 message 描述要播放的音符。问题在于构造这个 32 位无符号整数:它在内部被分成字节,表示 MIDI 通道、音符以及打击时的速度。一个解决方案是通过位移和掩码操作符 <<、>>、& 和 | 将这些字节转换为和从 32 位“打包”消息。尽管更简单的方法是定义一个具有显式布局的结构体:
[StructLayout (LayoutKind.Explicit)]
public struct NoteMessage
{
[FieldOffset(0)] public uint PackedMsg; // 4 bytes long
[FieldOffset(0)] public byte Channel; // FieldOffset also at 0
[FieldOffset(1)] public byte Note;
[FieldOffset(2)] public byte Velocity;
}
Channel、Note和Velocity字段故意与 32 位打包消息重叠。这允许您使用任一字段进行读取和写入。无需计算即可保持其他字段同步:
NoteMessage n = new NoteMessage();
Console.WriteLine (n.PackedMsg); // 0
n.Channel = 10;
n.Note = 100;
n.Velocity = 50;
Console.WriteLine (n.PackedMsg); // 3302410
n.PackedMsg = 3328010;
Console.WriteLine (n.Note); // 200
共享内存
内存映射文件,或共享内存,是 Windows 中的一项功能,允许同一台计算机上的多个进程共享数据。共享内存非常快速,并且与管道不同,提供对共享数据的随机访问。我们在第十五章中看到,您可以使用MemoryMappedFile类访问内存映射文件;绕过这一点,直接调用 Win32 方法是演示 P/Invoke 的好方法。
Win32 的CreateFileMapping函数分配共享内存。您告诉它需要多少字节以及用于标识共享的名称。然后,另一个应用程序可以通过使用相同名称调用OpenFileMapping来订阅这个内存。这两种方法都返回一个句柄,您可以通过调用MapViewOfFile将其转换为指针。
这是一个封装对共享内存访问的类:
using System;
using System.Runtime.InteropServices;
using System.ComponentModel;
public sealed class SharedMem : IDisposable
{
// Here we're using enums because they're safer than constants
enum FileProtection : uint // constants from winnt.h
{
ReadOnly = 2,
ReadWrite = 4
}
enum FileRights : uint // constants from WinBASE.h
{
Read = 4,
Write = 2,
ReadWrite = Read + Write
}
static readonly IntPtr NoFileHandle = new IntPtr (-1);
[DllImport ("kernel32.dll", SetLastError = true)]
static extern IntPtr CreateFileMapping (IntPtr hFile,
int lpAttributes,
FileProtection flProtect,
uint dwMaximumSizeHigh,
uint dwMaximumSizeLow,
string lpName);
[DllImport ("kernel32.dll", SetLastError=true)]
static extern IntPtr OpenFileMapping (FileRights dwDesiredAccess,
bool bInheritHandle,
string lpName);
[DllImport ("kernel32.dll", SetLastError = true)]
static extern IntPtr MapViewOfFile (IntPtr hFileMappingObject,
FileRights dwDesiredAccess,
uint dwFileOffsetHigh,
uint dwFileOffsetLow,
uint dwNumberOfBytesToMap);
[DllImport ("Kernel32.dll", SetLastError = true)]
static extern bool UnmapViewOfFile (IntPtr map);
[DllImport ("kernel32.dll", SetLastError = true)]
static extern int CloseHandle (IntPtr hObject);
IntPtr fileHandle, fileMap;
public IntPtr Root => fileMap;
public SharedMem (string name, bool existing, uint sizeInBytes)
{
if (existing)
fileHandle = OpenFileMapping (FileRights.ReadWrite, false, name);
else
fileHandle = CreateFileMapping (NoFileHandle, 0,
FileProtection.ReadWrite,
0, sizeInBytes, name);
if (fileHandle == IntPtr.Zero)
throw new Win32Exception();
// Obtain a read/write map for the entire file
fileMap = MapViewOfFile (fileHandle, FileRights.ReadWrite, 0, 0, 0);
if (fileMap == IntPtr.Zero)
throw new Win32Exception();
}
public void Dispose()
{
if (fileMap != IntPtr.Zero) UnmapViewOfFile (fileMap);
if (fileHandle != IntPtr.Zero) CloseHandle (fileHandle);
fileMap = fileHandle = IntPtr.Zero;
}
}
在本示例中,我们在使用SetLastError协议发出错误代码的DllImport方法上设置了SetLastError=true。这确保当抛出异常时,Win32Exception中填充了错误的详细信息。(它还允许您通过调用Marshal.GetLastWin32Error显式查询错误。)
要演示这个类,我们需要运行两个应用程序。第一个应用程序创建共享内存如下:
using (SharedMem sm = new SharedMem ("MyShare", false, 1000))
{
IntPtr root = sm.Root;
// I have shared memory!
Console.ReadLine(); // Here's where we start a second app...
}
第二个应用程序订阅共享内存,通过构造同名的SharedMem对象,使用existing参数设置为true:
using (SharedMem sm = new SharedMem ("MyShare", true, 1000))
{
IntPtr root = sm.Root;
// I have the same shared memory!
// ...
}
结果是,每个程序都有一个IntPtr——指向同一非托管内存的指针。现在,这两个应用程序需要以某种方式通过这个共享指针读取和写入内存。一种方法是编写一个类来封装所有共享数据,然后使用UnmanagedMemoryStream将数据(反)序列化到非托管内存中。然而,如果数据量很大,这种方法效率低下。想象一下,如果共享内存类有一兆字节的数据,而只需更新一个整数。更好的方法是将共享数据结构定义为结构体,然后直接映射到共享内存中。我们将在接下来的部分讨论这个问题。
将结构体映射到非托管内存
您可以直接将带有Sequential或Explicit的StructLayout映射到非托管内存中。考虑以下结构体:
[StructLayout (LayoutKind.Sequential)]
unsafe struct MySharedData
{
public int Value;
public char Letter;
public fixed float Numbers [50];
}
fixed指令允许我们在内联中定义固定长度的值类型数组,并将我们带入unsafe领域。这个结构体中为 50 个浮点数分配了内联空间。与标准的 C#数组不同,Numbers不是一个指向数组的引用——它就是数组。如果我们运行以下代码:
static unsafe void Main() => Console.WriteLine (sizeof (MySharedData));
结果是 208:50 个四字节浮点数,加上Value整数的四个字节,加上Letter字符的两个字节。总计 206,由于floats在四字节边界上对齐(四个字节是float的大小),所以四舍五入为 208。
我们可以在unsafe上下文中展示MySharedData,最简单的方法是使用栈分配的内存:
MySharedData d;
MySharedData* data = &d; // Get the address of d
data->Value = 123;
data->Letter = 'X';
data->Numbers[10] = 1.45f;
or:
// Allocate the array on the stack:
MySharedData* data = stackalloc MySharedData[1];
data->Value = 123;
data->Letter = 'X';
data->Numbers[10] = 1.45f;
当然,我们并没有展示任何在受控上下文中无法实现的事情。然而,假设我们想要将MySharedData的一个实例存储在不受 CLR 垃圾收集器管理的非托管堆上。这时指针变得非常有用:
MySharedData* data = (MySharedData*)
Marshal.AllocHGlobal (sizeof (MySharedData)).ToPointer();
data->Value = 123;
data->Letter = 'X';
data->Numbers[10] = 1.45f;
Marshal.AllocHGlobal在非托管堆上分配内存。以下是如何稍后释放相同内存的方法:
Marshal.FreeHGlobal (new IntPtr (data));
(忘记释放内存的结果是一个老式的内存泄漏。)
注意
从.NET 6 开始,您可以使用NativeMemory类来分配和释放非托管内存。NativeMemory使用比AllocHGlobal更新(更好)的底层 API,并且还包括执行对齐分配的方法。
顾名思义,在这里我们使用MySharedData与我们在前一节中编写的SharedMem类结合使用。以下程序分配了一块共享内存块,然后将MySharedData结构映射到该内存中:
static unsafe void Main()
{
using (SharedMem sm = new SharedMem ("MyShare", false,
(uint) sizeof (MySharedData)))
{
void* root = sm.Root.ToPointer();
MySharedData* data = (MySharedData*) root;
data->Value = 123;
data->Letter = 'X';
data->Numbers[10] = 1.45f;
Console.WriteLine ("Written to shared memory");
Console.ReadLine();
Console.WriteLine ("Value is " + data->Value);
Console.WriteLine ("Letter is " + data->Letter);
Console.WriteLine ("11th Number is " + data->Numbers[10]);
Console.ReadLine();
}
}
注意
您可以使用内置的MemoryMappedFile类来替代SharedMem,如下所示:
using (MemoryMappedFile mmFile =
MemoryMappedFile.CreateNew ("MyShare", 1000))
using (MemoryMappedViewAccessor accessor =
mmFile.CreateViewAccessor())
{
byte* pointer = null;
accessor.SafeMemoryMappedViewHandle.AcquirePointer
(ref pointer);
void* root = pointer;
...
}
这是第二个程序,它附加到相同的共享内存中,读取由第一个程序写入的值(必须在第一个程序在等待ReadLine语句时运行,因为共享内存对象在离开其using语句时被释放):
static unsafe void Main()
{
using (SharedMem sm = new SharedMem ("MyShare", true,
(uint) sizeof (MySharedData)))
{
void* root = sm.Root.ToPointer();
MySharedData* data = (MySharedData*) root;
Console.WriteLine ("Value is " + data->Value);
Console.WriteLine ("Letter is " + data->Letter);
Console.WriteLine ("11th Number is " + data->Numbers[10]);
// Our turn to update values in shared memory!
data->Value++;
data->Letter = '!';
data->Numbers[10] = 987.5f;
Console.WriteLine ("Updated shared memory");
Console.ReadLine();
}
}
这些程序的输出如下:
// First program:
Written to shared memory
Value is 124
Letter is !
11th Number is 987.5
// Second program:
Value is 123
Letter is X
11th Number is 1.45
Updated shared memory
不要被指针吓到:C++程序员在整个应用程序中都在使用它们,并且大多数时候都能使其正常工作!这种用法相对简单。
正如发生的那样,我们的示例因为另一个原因而不安全——字面上来说。我们没有考虑到两个程序同时访问同一内存时出现的线程安全(或更准确地说,进程安全)问题。要在生产应用程序中使用这一点,我们需要在MySharedData结构的Value和Letter字段中添加volatile关键字,以防止这些字段被即时(JIT)编译器(或硬件 CPU 寄存器)缓存。此外,随着我们与字段的交互超出了琐碎的范围,我们很可能需要通过跨进程的Mutex来保护它们的访问,就像我们在多线程程序中使用lock语句来保护对字段的访问一样。我们在第二十一章中详细讨论了线程安全性。
fixed and fixed
直接将结构体映射到内存的一个限制是结构体只能包含未管理的类型。例如,如果你需要共享字符串数据,必须使用固定长度的字符数组。这意味着需要手动转换到和从string类型。以下是如何做到的:
[StructLayout (LayoutKind.Sequential)]
unsafe struct MySharedData
{
...
// Allocate space for 200 chars (i.e., 400 bytes).
const int MessageSize = 200;
fixed char message [MessageSize];
// One would most likely put this code into a helper class:
public string Message
{
get { fixed (char* cp = message) return new string (cp); }
set
{
fixed (char* cp = message)
{
int i = 0;
for (; i < value.Length && i < MessageSize - 1; i++)
cp [i] = value [i];
// Add the null terminator
cp [i] = '\0';
}
}
}
}
注意
没有指向固定数组的引用;相反,你得到一个指针。当你索引固定数组时,实际上在进行指针算术运算!
使用fixed关键字首次使用时,我们在结构体中为 200 个字符分配了空间。然而,同一关键字在后续属性定义中有不同的含义。它指示 CLR 固定 一个对象,以便如果它决定在fixed块内部执行垃圾收集,它将不会移动内存堆上的底层结构(因为其内容通过直接内存指针进行迭代)。看看我们的程序,你可能会想知道MySharedData如何在内存中移动,因为它位于不受管理的世界中,垃圾收集器在那里无权利。然而,编译器并不知道这一点,并且担心我们可能在托管上下文中使用MySharedData,因此坚持我们添加fixed关键字,以使我们的unsafe代码在托管上下文中安全。编译器确实有一点道理——只需要将MySharedData放到堆上:
object obj = new MySharedData();
这导致MySharedData在堆上装箱,并且在垃圾收集期间可以传输。
本例说明了如何在映射到未管理内存的结构体中表示字符串。对于更复杂的类型,你也可以使用现有的序列化代码。唯一的注意是序列化数据的长度绝不要超过结构体分配的空间;否则,结果将是与后续字段意外联合。
COM 互操作性
.NET 运行时为 COM 提供了特殊支持,使得可以从.NET 使用 COM 对象,反之亦然。COM 仅在 Windows 上可用。
COM 的目的
COM 是 Component Object Model 的缩写,是一种与库进行接口交互的二进制标准,由微软在 1993 年发布。发明 COM 的动机是使组件能够以语言无关和版本宽容的方式相互通信。在 COM 出现之前,Windows 的方法是发布声明使用 C 编程语言的结构和函数的 DLL。这种方法不仅特定于语言,而且很脆弱。在这样的库中,类型的规范与其实现是不可分割的:即使更新具有新字段的结构也意味着破坏其规范。
COM 的优点在于通过称为COM 接口的结构从其底层实现中分离出类型的规范。COM 还允许在有状态对象上调用方法,而不仅仅限于简单的过程调用。
注意
在某种程度上,.NET 编程模型是 COM 编程原则的进化:.NET 平台也促进跨语言开发,并允许二进制组件在不破坏依赖于它们的应用程序的情况下演变。
COM 类型系统的基础知识
COM 类型系统围绕接口展开。COM 接口与 .NET 接口类似,但更常见,因为 COM 类型仅通过接口公开其功能。在 .NET 世界中,例如,我们可以简单地声明一个类型,如下所示:
public class Foo
{
public string Test() => "Hello, world";
}
类型的消费者可以直接使用 Foo。如果以后我们更改了 Test() 的实现,调用方不需要重新编译。在这方面,.NET 将接口与实现分离——而无需接口。我们甚至可以添加一个重载而不会破坏调用者:
public string Test (string s) => $"Hello, world {s}";
在 COM 世界中,Foo 通过接口公开其功能以实现同样的解耦。因此,在 Foo 的类型库中,可能存在这样的接口:
public interface IFoo { string Test(); }
(我们通过展示一个 C# 接口(而不是 COM 接口)来说明这一点。然而,原理是相同的——尽管具体实现方式不同。)
调用方随后将与 IFoo 交互,而不是 Foo。
当涉及添加 Test 的重载版本时,使用 COM 比使用 .NET 更复杂。首先,我们将避免修改 IFoo 接口,因为这将破坏与前一个版本的二进制兼容性(COM 的原则之一是一旦发布,接口就是不可变的)。其次,COM 不允许方法重载。解决方案是让 Foo 实现第二个接口:
public interface IFoo2 { string Test (string s); }
(同样,我们将其转译为 .NET 接口以便熟悉。)
支持多个接口对于使 COM 库具有版本化能力至关重要。
IUnknown 和 IDispatch
所有的 COM 接口都使用全局唯一标识符(GUID)来标识。
COM 中的根接口是 IUnknown——所有 COM 对象必须实现它。该接口有三个方法:
-
AddRef -
Release -
QueryInterface
AddRef 和 Release 用于生命周期管理,因为 COM 使用引用计数而不是自动垃圾回收(COM 设计用于与不可管理代码一起工作,其中自动垃圾回收不可行)。QueryInterface 方法返回一个支持该接口的对象引用,如果可以的话。
为了实现动态编程(例如脚本和自动化),COM 对象还可以实现 IDispatch。这使得动态语言可以以后期绑定的方式调用 COM 对象——有点像 C# 中的 dynamic(尽管仅限于简单调用)。
从 C# 调用 COM 组件
CLR 对 COM 的内置支持意味着你不直接使用 IUnknown 和 IDispatch。相反,你使用 CLR 对象,并且运行时通过 Runtime-Callable Wrappers (RCWs) 将你的调用封送到 COM 世界。运行时还通过调用 AddRef 和 Release(在 .NET 对象被终结时)来处理生命周期管理,并且处理两个世界之间的原始类型转换。类型转换确保每一方以熟悉的形式看到整数和字符串类型等。
此外,需要一种以静态类型方式访问 RCW 的方法。这是COM 互操作类型的任务。 COM 互操作类型是自动生成的代理类型,每个 COM 成员都暴露一个 .NET 成员。类型库导入工具(tlbimp.exe)基于你选择的 COM 库从命令行生成 COM 互操作类型,并将它们编译成COM 互操作程序集。
注意
如果一个 COM 组件实现多个接口,则 tlbimp.exe 工具会生成一个包含所有接口成员并集的单一类型。
你可以在 Visual Studio 中通过转到“添加引用”对话框框,并从 COM 选项卡中选择一个库来创建 COM 互操作程序集。例如,如果安装了 Microsoft Excel,则添加对 Microsoft Excel 对象库的引用允许你与 Excel 的 COM 类互操作。以下是创建和显示工作簿,然后在该工作簿中填充单元格的 C# 代码:
using System;
using Excel = Microsoft.Office.Interop.Excel;
var excel = new Excel.Application();
excel.Visible = true;
excel.WindowState = Excel.XlWindowState.xlMaximized;
Excel.Workbook workBook = excel.Workbooks.Add();
((Excel.Range)excel.Cells[1, 1]).Font.FontStyle = "Bold";
((Excel.Range)excel.Cells[1, 1]).Value2 = "Hello World";
workBook.SaveAs (@"d:\temp.xlsx");
注意
当前需要在应用程序中嵌入互操作类型(否则,运行时无法在运行时找到它们)。可以在 Visual Studio 的解决方案资源管理器中单击 COM 引用,然后在属性窗口中将 Embed Interop Types 属性设置为 true,或者打开 .csproj 文件并添加以下行(加粗):
<ItemGroup>
<COMReference Include="Microsoft.Office.Excel.dll">
...
<EmbedInteropTypes>true</EmbedInteropTypes>
</COMReference>
</ItemGroup>
Excel.Application 类是一个 COM 互操作类型,其运行时类型是一个 RCW。当我们访问 Workbooks 和 Cells 属性时,会得到更多的互操作类型。
可选参数和命名参数
因为 COM API 不支持函数重载,所以经常有函数具有许多参数,其中许多是可选的。例如,这是如何调用 Excel 工作簿的 Save 方法的方式:
var missing = System.Reflection.Missing.Value;
workBook.SaveAs (@"d:\temp.xlsx", missing, missing, missing, missing,
missing, Excel.XlSaveAsAccessMode.xlNoChange, missing, missing,
missing, missing, missing);
好消息是,C# 对可选参数的支持是 COM 感知的,所以我们可以这样做:
workBook.SaveAs (@"d:\temp.xlsx");
(正如我们在 第三章 中所述,可选参数由编译器“展开”为完整的冗长形式。)
命名参数允许你指定额外的参数,而不管它们的位置如何:
workBook.SaveAs (@"d:\test.xlsx", Password:"foo");
隐式 ref 参数
一些 COM API(特别是 Microsoft Word)公开的函数将每一个参数声明为按引用传递,无论函数是否修改参数值。这是因为认为不复制参数值会带来性能提升(实际性能提升微乎其微)。
从历史上看,从 C# 调用这样的方法一直很笨拙,因为您必须对每个参数指定 ref 关键字,这会阻止使用可选参数。例如,要打开 Word 文档,我们过去必须这样做:
object filename = "foo.doc";
object notUsed1 = Missing.Value;
object notUsed2 = Missing.Value;
object notUsed3 = Missing.Value;
...
Open (ref filename, ref notUsed1, ref notUsed2, ref notUsed3, ...);
由于隐式引用参数,您可以省略 COM 函数调用中的 ref 修饰符,从而允许使用可选参数:
word.Open ("foo.doc");
缺点是,如果调用的 COM 方法实际上会改变参数值,您既不会得到编译时错误,也不会得到运行时错误。
索引器
省略 ref 修饰符的能力还有另一个好处:它使带有 ref 参数的 COM 索引器可通过普通的 C# 索引器语法访问。否则,这将被禁止,因为 ref/out 参数在 C# 索引器中不受支持。
您还可以调用接受参数的 COM 属性。在以下示例中,Foo 是一个接受整数参数的属性:
myComObject.Foo [123] = "Hello";
在 C# 中自己编写这样的属性仍然是被禁止的:类型只能在自身上(“默认”索引器)公开索引器。因此,如果您想在 C# 中编写使前述语句合法的代码,Foo 需要返回另一种公开了(默认)索引器的类型。
动态绑定
动态绑定在调用 COM 组件时有两种帮助方式。
第一种方式是允许访问不使用 COM 互操作类型的 COM 组件。为此,请调用 Type.GetTypeFromProgID 以获取 COM 实例的 COM 组件名称,然后使用动态绑定从此调用成员。当然,这没有 IntelliSense,并且无法进行编译时检查:
Type excelAppType = Type.GetTypeFromProgID ("Excel.Application", true);
dynamic excel = Activator.CreateInstance (excelAppType);
excel.Visible = true;
dynamic wb = excel.Workbooks.Add();
excel.Cells [1, 1].Value2 = "foo";
(可以用反射而不是动态绑定实现相同的功能,但更加笨拙。)
注意
此主题的一个变种是调用仅支持 IDispatch 的 COM 组件。但是,这样的组件非常罕见。
动态绑定在处理 COM variant 类型时也可能有用(程度较低)。由于设计不良而非必要原因,COM API 函数经常会用到这种类型,它在 .NET 中大致相当于 object。如果在项目中启用了“嵌入互操作类型”(稍后详述),运行时会将 variant 映射为 dynamic,而不是映射为 object,从而避免了需要进行强制类型转换。例如,您可以合法地执行以下操作:
excel.Cells [1, 1].Font.FontStyle = "Bold";
而不是:
var range = (Excel.Range) excel.Cells [1, 1];
range.Font.FontStyle = "Bold";
以这种方式工作的缺点是,您会失去自动完成功能,因此您必须知道名为 Font 的属性存在。因此,通常更容易动态地将结果分配给其已知的互操作类型:
Excel.Range range = excel.Cells [1, 1];
range.Font.FontStyle = "Bold";
正如您所见,与老式方法相比,这仅节省了五个字符!
将 variant 映射为 dynamic 是默认设置,并且是启用引用时的一个功能。
嵌入互操作类型
我们之前说过,C# 通常通过调用 tlbimp.exe 工具(直接或通过 Visual Studio)生成的互操作类型来调用 COM 组件。
历史上,你唯一的选择是像对待任何其他程序集一样引用互操作程序集。这可能会麻烦,因为互操作程序集可以因复杂的 COM 组件而变得相当庞大。例如,微软 Word 的一个小插件需要一个比其自身大几个数量级的互操作程序集。
而不是引用互操作程序集,你可以选择嵌入你使用的部分。编译器会分析程序集,精确确定应用程序所需的类型和成员,并直接在应用程序中嵌入这些类型和成员的定义。这样既避免了臃肿,又避免了需要额外传送文件。
要启用此功能,可以在 Visual Studio 的解决方案资源管理器中选择 COM 引用,然后在属性窗口中将“嵌入互操作类型”设置为 true,或者像我们之前描述的那样编辑.csproj文件(参见“从 C#调用 COM 组件”)。
类型等价性
CLR 支持链接互操作类型的类型等价性。这意味着如果两个程序集分别链接到一个互操作类型,那么即使这些链接到的互操作程序集是独立生成的,这些类型也会被视为等效,只要它们包装了相同的 COM 类型。
注意
类型等价性依赖于System.Runtime.InteropServices命名空间中的TypeIdentifierAttribute特性。当链接到互操作程序集时,编译器会自动应用此特性。如果 COM 类型具有相同的 GUID,则这些类型被认为是等效的。
将 C#对象公开给 COM
也可以在 C#中编写可以在 COM 世界中消耗的类。CLR 通过称为COM-Callable Wrapper(CCW)的代理实现了这一点。CCW 在两个世界之间进行类型的封送(与 RCW 类似),并根据 COM 协议实现了IUnknown(和可选的IDispatch)。CCW 通过引用计数从 COM 侧进行生命周期控制(而不是通过 CLR 的垃圾收集器)。
你可以将任何公共类公开给 COM(作为“进程内”服务器)。要实现此功能,首先创建一个接口,分配一个唯一的 GUID(在 Visual Studio 中,你可以使用工具 > 创建 GUID),声明其对 COM 可见,然后设置接口类型:
namespace MyCom
{
[ComVisible(true)]
[Guid ("226E5561-C68E-4B2B-BD28-25103ABCA3B1")] // Change this GUID
[InterfaceType (ComInterfaceType.InterfaceIsIUnknown)]
public interface IServer
{
int Fibonacci();
}
}
接下来,提供一个接口的实现,并为该实现分配一个唯一的 GUID:
namespace MyCom
{
[ComVisible(true)]
[Guid ("09E01FCD-9970-4DB3-B537-0EC555967DD9")] // Change this GUID
public class Server
{
public ulong Fibonacci (ulong whichTerm)
{
if (whichTerm < 1) throw new ArgumentException ("...");
ulong a = 0;
ulong b = 1;
for (ulong i = 0; i < whichTerm; i++)
{
ulong tmp = a;
a = b;
b = tmp + b;
}
return a;
}
}
}
编辑你的.csproj文件,添加以下行:
<PropertyGroup>
<EnableComHosting>true</EnableComHosting>
</PropertyGroup>
现在,当你构建你的项目时,会生成一个额外的文件,MyCom.comhost.dll,可以注册为 COM 互操作。(请记住,该文件始终是 32 位或 64 位,取决于你的项目配置:在这种情况下不存在“任何 CPU”选项。)从提升的命令提示符中,切换到保存 DLL 的目录,并运行regsvr32 MyCom.comhost.dll。
然后,您可以从大多数支持 COM 的语言中消费您的 COM 组件。例如,您可以在文本编辑器中创建此 Visual Basic 脚本,并通过在 Windows 资源管理器中双击该文件或从命令提示符中启动它来运行它,就像运行程序一样:
REM Save file as ComClient.vbs
Dim obj
Set obj = CreateObject("MyCom.Server")
result = obj.Fibonacci(12)
Wscript.Echo result
注意,.NET Framework 不能加载到与 .NET 5+ 或 .NET Core 相同的进程中。因此,.NET 5+ COM 服务器无法加载到 .NET Framework COM 客户端进程中,反之亦然。
启用无注册表 COM
传统上,COM 将类型信息添加到注册表中。无注册表 COM 使用一个清单文件而不是注册表来控制对象的激活。要启用此功能,请将以下行(加粗)添加到您的 .csproj 文件中:
<PropertyGroup>
<TargetFramework>netcoreapp3.0</TargetFramework>
<EnableComHosting>true</EnableComHosting>
<EnableRegFreeCom>true</EnableRegFreeCom>
</PropertyGroup>
然后,您的构建将生成 MyCom.X.manifest。
注意
在 .NET 5+ 中不支持生成 COM 类型库 (*.tlb)。您可以手动编写一个 IDL(接口定义语言)文件或者 C++ 头文件来定义接口中的本地声明。
第二十五章:正则表达式
正则表达式语言识别字符模式。支持正则表达式的.NET 类型基于 Perl 5 正则表达式,支持搜索和搜索/替换功能。
正则表达式用于诸如以下任务:
-
验证文本输入,如密码和电话号码。
-
将文本数据解析为更结构化的形式(例如,NuGet 版本字符串)
-
替换文档中的文本模式(例如,仅整个单词)
本章分为概念部分和参考部分,分别介绍.NET 中正则表达式的基础知识和正则表达式语言。
所有正则表达式类型都定义在System.Text.RegularExpressions中。
注意
本章中的示例都预装在 LINQPad 中,该工具还包括一个交互式的 RegEx 工具(按 Ctrl+Shift+F1)。在线工具可访问http://regexstorm.net/tester。
正则表达式基础知识
最常见的正则表达式运算符之一是量词。?是一个量词,匹配前面的项目 0 或 1 次。换句话说,?表示可选。项目可以是单个字符或方括号中复杂的字符结构。例如,正则表达式"colou?r"匹配color和colour,但不匹配colouur:
Console.WriteLine (Regex.Match ("color", @"colou?r").Success); // True
Console.WriteLine (Regex.Match ("colour", @"colou?r").Success); // True
Console.WriteLine (Regex.Match ("colouur", @"colou?r").Success); // False
Regex.Match在较大字符串内搜索。返回的对象具有匹配的Index和Length属性以及实际匹配的Value:
Match m = Regex.Match ("any colour you like", @"colou?r");
Console.WriteLine (m.Success); // True
Console.WriteLine (m.Index); // 4
Console.WriteLine (m.Length); // 6
Console.WriteLine (m.Value); // colour
Console.WriteLine (m.ToString()); // colour
您可以将Regex.Match视为string的IndexOf方法的更强大版本。不同之处在于它搜索模式而不是字面字符串。
IsMatch方法是调用Match并测试Success属性的快捷方式。
默认情况下,正则表达式引擎从左到右工作,因此仅返回最左边的匹配项。您可以使用NextMatch方法返回更多匹配项:
Match m1 = Regex.Match ("One color? There are two colours in my head!",
@"colou?rs?");
Match m2 = m1.NextMatch();
Console.WriteLine (m1); // color
Console.WriteLine (m2); // colours
Matches方法返回数组中的所有匹配项。我们可以按照前面的示例重写如下:
foreach (Match m in Regex.Matches
("One color? There are two colours in my head!", @"colou?rs?"))
Console.WriteLine (m);
另一个常见的正则表达式运算符是交替符,用竖线|表示。交替符表示备选项。以下匹配“Jen”,“Jenny”和“Jennifer”:
Console.WriteLine (Regex.IsMatch ("Jenny", "Jen(ny|nifer)?")); // True
交替符周围的括号将备选项与表达式的其余部分分开。
注意
当匹配正则表达式时,你可以指定超时时间。如果匹配操作超过指定的TimeSpan,将抛出RegexMatchTimeoutException异常。如果你的程序处理用户提供的正则表达式,这非常有用,因为它可以防止格式错误的正则表达式无限循环。
编译正则表达式
在前面的一些示例中,我们反复调用了静态的Regex方法,使用相同的模式。在这些情况下的另一种方法是使用模式和RegexOptions.Compiled实例化一个Regex对象,然后调用实例方法:
Regex r = new Regex (@"sausages?", RegexOptions.Compiled);
Console.WriteLine (r.Match ("sausage")); // sausage
Console.WriteLine (r.Match ("sausages")); // sausages
RegexOptions.Compiled 指示 RegEx 实例使用轻量级代码生成(Reflection.Emit 中的 DynamicMethod)动态构建和编译特定于该正则表达式的代码。这样做可以实现更快的匹配速度,但需要付出初始编译成本。
您还可以实例化 Regex 对象而不使用 RegexOptions.Compiled。Regex 实例是不可变的。
注意
正则表达式引擎非常快速。即使不进行编译,简单匹配通常少于一微秒。
RegexOptions
RegexOptions 标志枚举允许您调整匹配行为。RegexOptions 的常见用法是执行不区分大小写的搜索:
Console.WriteLine (Regex.Match ("a", "A", RegexOptions.IgnoreCase)); // a
这适用于当前文化规则的大小写等价性。CultureInvariant 标志允许您请求不变的文化:
Console.WriteLine (Regex.Match ("a", "A", RegexOptions.IgnoreCase
| RegexOptions.CultureInvariant));
您可以在正则表达式中通过单字母代码激活大多数 RegexOptions 标志,如下所示:
Console.WriteLine (Regex.Match ("a", @"(?i)A")); // a
您可以在表达式中随时打开和关闭选项:
Console.WriteLine (Regex.Match ("AAAa", @"(?i)a(?-i)a")); // Aa
另一个有用的选项是 IgnorePatternWhitespace 或 (?x)。这允许您插入空格以使正则表达式更易读,而不会将空格视为字面字符。
NonBacktracking 选项(从 .NET 7 开始)指示正则表达式引擎使用前向匹配算法。这通常导致性能较慢,并禁用某些高级特性,如前瞻或后顾。但是,它还可以防止形式不正确或恶意构造的表达式花费近乎无限的时间,从而减轻处理用户提供的正则表达式时可能发生的拒绝服务攻击(ReDOS 攻击)。在这种情况下,指定超时也很有用。
表格 25-1 列出所有 RegExOptions 值及其单字母代码。
表格 25-1. 正则表达式选项
| 枚举值 | 正则表达式代码 | 描述 |
|---|---|---|
None |
||
IgnoreCase |
i |
忽略大小写(默认情况下,正则表达式区分大小写) |
Multiline |
m |
更改 ^ 和 ` |
| --- | --- | --- |
None |
||
IgnoreCase |
i |
忽略大小写(默认情况下,正则表达式区分大小写) |
| ,使其匹配行的开头/结尾而不是字符串的开头/结尾 | ||
ExplicitCapture |
n |
仅捕获显式命名或显式编号的组(见 “Groups”) |
Compiled |
强制编译为 IL(见 “Compiled Regular Expressions”) | |
Singleline |
s |
使 . 匹配每个字符(而不是匹配除 \n 外的每个字符) |
IgnorePatternWhitespace |
x |
从模式中消除未转义的空格 |
RightToLeft |
r |
从右向左搜索;不能在中间指定 |
ECMAScript |
强制执行 ECMAScript 兼容性(默认情况下,实现不符合 ECMAScript) | |
CultureInvariant |
关闭字符串比较的特定于文化的行为 | |
NonBacktracking |
禁用回溯以确保可预测(尽管较慢)的性能 |
字符转义
正则表达式具有以下元字符,它们具有特殊的而不是字面意义:
\ * + ? | { [ () ^ $ . #
要字面使用元字符,必须使用反斜杠进行前缀,即转义字符。在下面的例子中,我们转义 ? 字符来匹配字符串 "what?":
Console.WriteLine (Regex.Match ("what?", @"what\?")); // what? (correct)
Console.WriteLine (Regex.Match ("what?", @"what?")); // what (incorrect)
注意
如果字符位于集合(方括号)内,则此规则不适用,并且元字符被逐字解释。我们在下一节讨论集合。
Regex 的 Escape 和 Unescape 方法通过将包含正则表达式元字符的字符串替换为转义等效项(反之亦然)来转换字符串:
Console.WriteLine (Regex.Escape (@"?")); // \?
Console.WriteLine (Regex.Unescape (@"\?")); // ?>
本章中所有的正则表达式字符串都用 C# 的 @ 文字表达。这是为了绕过 C# 的转义机制,该机制也使用反斜杠。没有 @,字面上的反斜杠将需要四个反斜杠:
Console.WriteLine (Regex.Match ("\\", "\\\\")); // \
除非包括 (?x) 选项,否则空格在正则表达式中被视为字面量:
Console.Write (Regex.IsMatch ("hello world", @"hello world")); // True
字符集
字符集充当特定字符集的通配符。
| 表达式 | 含义 | 反义(“非”) |
|---|---|---|
[abcdef] |
匹配列表中的单个字符。 | [^abcdef] |
[a-f] |
匹配范围内的单个字符。 | [^a-f] |
\d |
匹配 Unicode 数字 类别中的任何内容。在 ECMAScript 模式下,[0-9]。 |
\D |
\w |
匹配一个单词字符(默认情况下,根据 CultureInfo.CurrentCulture 变化;例如,在英语中,与 [a-zA-Z_0-9] 相同)。 |
\W |
\s |
匹配空白字符;即,char.IsWhiteSpace 返回 true 的任何内容(包括 Unicode 空格)。在 ECMAScript 模式下,[\n\r\t\f\v ]。 |
\S |
\p{category} |
匹配指定类别中的字符。 | \P |
. |
(默认模式)匹配除 \n 之外的任何字符。 |
\n |
. |
(SingleLine 模式)匹配任何字符。 |
\n |
要匹配一组字符中的一个,请将字符集放在方括号内:
Console.Write (Regex.Matches ("That is that.", "[Tt]hat").Count); // 2
要匹配除了集合中字符以外的任何字符,请在方括号中使用 ^ 符号放置集合:
Console.Write (Regex.Match ("quiz qwerty", "q[^aeiou]").Index); // 5
你可以使用连字符指定一系列字符。以下正则表达式匹配象棋走法:
Console.Write (Regex.Match ("b1-c4", @"[a-h]\d-[a-h]\d").Success); // True
\d 表示数字字符,因此 \d 将匹配任何数字。 \D 匹配任何非数字字符。
\w 表示一个单词字符,包括字母、数字和下划线。\W 匹配任何非单词字符。对于非英语字母,如西里尔字母,这些也能正常工作。
. 匹配除了 \n 之外的任何字符(但允许 \r)。
\p 匹配指定类别中的字符,例如 {Lu} 表示大写字母或 {P} 表示标点符号(我们稍后在参考部分列出类别):
Console.Write (Regex.IsMatch ("Yes, please", @"\p{P}")); // True
当我们将它们与量词结合使用时,我们将发现更多关于 \d、\w 和 . 的用法。
量词
量词匹配指定次数的项。
| 量词 | 含义 |
|---|---|
* |
零个或多个匹配 |
+ |
一次或多次匹配 |
? |
零或一个匹配 |
{*n*} |
正好 *n* 次匹配 |
{*n*,} |
至少 *n* 次匹配 |
{*n*,*m*} |
匹配 *n* 到 *m* 次 |
* 量词匹配前面的字符或组零次或更多次。下面的示例匹配 cv.docx,以及同一文件的任何编号版本(例如 cv2.docx,cv15.docx):
Console.Write (Regex.Match ("cv15.docx", @"cv\d*\.docx").Success); // True
注意,我们必须使用反斜杠转义文件扩展名中的句点。
下面允许在 cv 和 .docx 之间的任何内容,并且等效于 dir cv*.docx:
Console.Write (Regex.Match ("cvjoint.docx", @"cv.*\.docx").Success); // True
+ 量词匹配前面的字符或组一次或更多次。例如:
Console.Write (Regex.Matches ("slow! yeah slooow!", "slo+w").Count); // 2
{} 量词匹配指定数量(或范围)的重复。以下匹配一个血压读数:
Regex bp = new Regex (@"\d{2,3}/\d{2,3}");
Console.WriteLine (bp.Match ("It used to be 160/110")); // 160/110
Console.WriteLine (bp.Match ("Now it's only 115/75")); // 115/75
贪婪量词与懒惰量词的比较
默认情况下,量词是 贪婪 的,与 懒惰 相对。贪婪量词重复尽可能多次,然后再继续。懒惰量词重复尽可能少次,然后再继续。通过在其后缀加上 ? 符号,你可以使任何量词变成懒惰的。为了说明差异,请考虑以下 HTML 片段:
string html = "<i>By default</i> quantifiers are <i>greedy</i> creatures";
假设我们要提取两个斜体短语。如果我们执行以下操作:
foreach (Match m in Regex.Matches (html, @"<i>.*</i>"))
Console.WriteLine (m);
结果不是两个匹配,而是单个匹配:
<i>By default</i> quantifiers are <i>greedy</i>
问题在于我们的 * 量词贪婪地重复尽可能多次,直到匹配 </i>。因此,它会跳过第一个 </i>,仅在最后一个 </i>(表达式其余部分仍可匹配的最后点)处停止。
如果我们使量词懒惰,* 将在表达式其余部分仍可匹配的第一个点处退出:
foreach (Match m in Regex.Matches (html, @"<i>.*?</i>"))
Console.WriteLine (m);
这是结果:
<i>By default</i>
<i>greedy</i>
零宽断言
正则表达式语言允许您在匹配之前或之后对应该发生的条件进行设置,通过回顾、预查、锚点和词边界。这些称为零宽断言,因为它们不增加匹配本身的宽度(或长度)。
预查和回顾
(?=*expr*) 构造检查紧随的文本是否与 *expr* 匹配,但不包括 expr 在结果中。这被称为 正向预查。在下面的例子中,我们寻找一个数字后面跟着单词“miles”:
Console.WriteLine (Regex.Match ("say 25 miles more", @"\d+\s(?=miles)"));
*OUTPUT: 25*
注意,单词“miles”未在结果中返回,尽管它是必须满足的匹配。
在成功的 预查 之后,匹配继续进行,就好像预览从未发生过一样。因此,如果我们像这样追加 .* 到我们的表达式中:
Console.WriteLine (Regex.Match ("say 25 miles more", @"\d+\s(?=miles).*"));
结果是 25 miles more。
预查可以在强密码规则强制执行中非常有用。假设密码必须至少包含六个字符并至少包含一个数字。通过预查,我们可以实现如下:
string password = "...";
bool ok = Regex.IsMatch (password, @"(?=.*\d).{6,}");
首先执行预查以确保数字在字符串的某处出现。如果满足条件,则返回预览开始之前的位置,并匹配六个或更多字符。(在“正则表达式手册”中,我们包括一个更实质的密码验证示例。)
相反的是负向预查结构(?!*expr*)。这要求匹配不后跟*expr*。以下表达式匹配“good”—除非后续字符串中出现“however”或“but”:
string regex = "(?i)good(?!.*(however|but))";
Console.WriteLine (Regex.IsMatch ("Good work! But...", regex)); // False
Console.WriteLine (Regex.IsMatch ("Good work! Thanks!", regex)); // True
(?<=*expr*)结构表示正向回顾,要求匹配之前由指定表达式。相反的结构(?<!*expr*)表示负向回顾,要求匹配不之前有指定的表达式。例如,以下匹配“good”—除非前面字符串中出现“however”:
string regex = "(?i)(?<!however.*)good";
Console.WriteLine (Regex.IsMatch ("However good, we...", regex)); // False
Console.WriteLine (Regex.IsMatch ("Very good, thanks!", regex)); // True
我们可以通过添加单词边界断言来改进这些示例,稍后我们将介绍这一点。
锚点
锚点^和$匹配特定的位置。默认情况下:
^
匹配字符串的开始
$
匹配字符串的结尾
注意
^有两个上下文相关的含义:锚点和字符类否定器。
$有两个上下文相关的含义:锚点和替换组指示符。
例如:
Console.WriteLine (Regex.Match ("Not now", "^[Nn]o")); // No
Console.WriteLine (Regex.Match ("f = 0.2F", "[Ff]$")); // F
当您指定RegexOptions.Multiline或在表达式中包含(?m)时:
-
^匹配字符串或行的开始(直接在\n之后)。 -
$匹配字符串或行的结尾(直接在\n之前)。
在多行模式下使用$有一个注意事项:Windows 中的换行符几乎总是用\r\n表示,而不仅仅是\n。这意味着对于 Windows 文件,要使$有用,通常必须同时匹配\r,使用正向预查:
(?=\r?$)
正向预查确保\r不成为结果的一部分。以下匹配以".txt"结尾的行:
string fileNames = "a.txt" + "\r\n" + "b.docx" + "\r\n" + "c.txt";
string r = @".+\.txt(?=\r?$)";
foreach (Match m in Regex.Matches (fileNames, r, RegexOptions.Multiline))
Console.Write (m + " ");
*OUTPUT: a.txt c.txt*
匹配字符串s中所有空行:
MatchCollection emptyLines = Regex.Matches (s, "^(?=\r?$)",
RegexOptions.Multiline);
以下匹配所有空行或仅包含空白的行:
MatchCollection blankLines = Regex.Matches (s, "^[ \t]*(?=\r?$)",
RegexOptions.Multiline);
注意
因为锚点匹配的是位置而不是字符,所以在其自身上指定锚点会匹配一个空字符串:
Console.WriteLine (Regex.Match ("x", "$").Length); // 0
单词边界
单词边界断言\b匹配单词字符(\w)与以下之一相邻:
-
非单词字符(
\W) -
字符串的开始/结束(
^和$)
\b通常用于匹配整个单词:
foreach (Match m in Regex.Matches ("Wedding in Sarajevo", @"\b\w+\b"))
Console.WriteLine (m);
*Wedding*
*in*
*Sarajevo*
以下语句突出显示单词边界的效果:
int one = Regex.Matches ("Wedding in Sarajevo", @"\bin\b").Count; // 1
int two = Regex.Matches ("Wedding in Sarajevo", @"in").Count; // 2
下一个查询使用正向预查来返回后面跟有“(sic)”的单词:
string text = "Don't loose (sic) your cool";
Console.Write (Regex.Match (text, @"\b\w+\b\s(?=\(sic\))")); // loose
组
有时将正则表达式分解为一系列子表达式或组是有用的。例如,考虑以下表示美国电话号码(如 206-465-1918)的正则表达式:
\d{3}-\d{3}-\d{4}
假设我们想将其分成两组:区号和本地号码。我们可以通过使用括号来捕获每个组来实现此目的:
(\d{3})-(\d{3}-\d{4})
程序化地检索组:
Match m = Regex.Match ("206-465-1918", @"(\d{3})-(\d{3}-\d{4})");
Console.WriteLine (m.Groups[1]); // 206
Console.WriteLine (m.Groups[2]); // 465-1918
第零组表示整个匹配。换句话说,它的值与匹配的Value相同:
Console.WriteLine (m.Groups[0]); // 206-465-1918
Console.WriteLine (m); // 206-465-1918
组是正则表达式语言的一部分。这意味着您可以在正则表达式内部引用一个组。\n语法允许您在表达式内通过组号n索引组。例如,表达式(\w)ee\1匹配deed和peep。在以下示例中,我们找到字符串中所有以相同字母开头和结尾的单词:
foreach (Match m in Regex.Matches ("pop pope peep", @"\b(\w)\w+\1\b"))
Console.Write (m + " "); // pop peep
\w周围的括号指示正则表达式引擎将子匹配存储在一个组中(在本例中是单个字母),以便稍后使用。我们稍后使用\1引用该组,表示表达式中的第一个组。
命名组
在长或复杂的表达式中,通过名称而不是索引来处理组可能更容易。以下是先前示例的重写,使用了我们命名为'letter'的组:
string regEx =
@"\b" + // word boundary
@"(?'letter'\w)" + // match first letter, and name it 'letter'
@"\w+" + // match middle letters
@"\k'letter'" + // match last letter, denoted by 'letter'
@"\b"; // word boundary
foreach (Match m in Regex.Matches ("bob pope peep", regEx))
Console.Write (m + " "); // bob peep
这是如何命名捕获组的:
(?'*group-name*'group-expr) *or* (?<*group-name*>group-expr)
下面是如何引用一个组:
\k'*group-name*' *or* \k<*group-name*>
以下示例通过查找具有匹配名称的起始和结束节点来匹配简单(非嵌套)XML/HTML 元素:
string regFind =
@"<(?'tag'\w+?).*>" + // lazy-match first tag, and name it 'tag'
@"(?'text'.*?)" + // lazy-match text content, name it 'text'
@"</\k'tag'>"; // match last tag, denoted by 'tag'
Match m = Regex.Match ("<h1>hello</h1>", regFind);
Console.WriteLine (m.Groups ["tag"]); // h1
Console.WriteLine (m.Groups ["text"]); // hello
允许 XML 结构的所有可能变化,如嵌套元素,这更加复杂。.NET 正则表达式引擎具有称为“匹配平衡构造”的复杂扩展,可用于嵌套标签 - 有关此信息,请查看互联网和 Jeffrey E. F. Friedl 的Mastering Regular Expressions(O’Reilly)。
替换和拆分文本
RegEx.Replace方法的工作方式类似于string.Replace,但它使用正则表达式。
以下代码将“cat”替换为“dog”。与string.Replace不同,"catapult"不会变成"dogapult",因为我们匹配了单词边界:
string find = @"\bcat\b";
string replace = "dog";
Console.WriteLine (Regex.Replace ("catapult the cat", find, replace));
*OUTPUT: catapult the dog*
替换字符串可以使用$0替换构造引用原始匹配项。以下示例在字符串中的数字周围加上尖括号:
string text = "10 plus 20 makes 30";
Console.WriteLine (Regex.Replace (text, @"\d+", @"<$0>"));
*OUTPUT: <10> plus <20> makes <30>*
您可以使用$1,$2,$3等访问任何捕获的组,或${*name*}用于命名组。为了说明这可以有多有用,考虑前一节中匹配简单 XML 元素的正则表达式。通过重新排列这些组,我们可以形成一个替换表达式,将元素的内容移到 XML 属性中:
string regFind =
@"<(?'tag'\w+?).*>" + // lazy-match first tag, and name it 'tag'
@"(?'text'.*?)" + // lazy-match text content, name it 'text'
@"</\k'tag'>"; // match last tag, denoted by 'tag'
string regReplace =
@"<${tag}" + // <tag
@"value=""" + // value="
@"${text}" + // text
@"""/>"; // "/>
Console.Write (Regex.Replace ("<msg>hello</msg>", regFind, regReplace));
这是结果:
<msg value="hello"/>
MatchEvaluator 委托
Replace有一个重载,接受一个MatchEvaluator委托,每次匹配时调用。当正则表达式语言表达能力不足时,这允许您将替换字符串的内容委托给 C#代码:
Console.WriteLine (Regex.Replace ("5 is less than 10", @"\d+",
m => (int.Parse (m.Value) * 10).ToString()) );
*OUTPUT: 50 is less than 100*
在“Cookbook Regular Expressions”中,我们展示如何使用MatchEvaluator适当地转义 Unicode 字符以用于 HTML。
拆分文本
静态 Regex.Split 方法是 string.Split 方法的更强大版本,其中正则表达式表示分隔符模式。在此示例中,我们将字符串分割,其中任何数字都作为分隔符:
foreach (string s in Regex.Split ("a5b7c", @"\d"))
Console.Write (s + " "); // a b c
此结果不包括分隔符本身。但是,可以通过使用 正向预查 将表达式包裹起来来包含分隔符。以下内容将驼峰格式的字符串拆分为单独的单词:
foreach (string s in Regex.Split ("oneTwoThree", @"(?=[A-Z])"))
Console.Write (s + " "); // one Two Three
食谱正则表达式
配方
匹配美国社会安全号码/电话号码
string ssNum = @"\d{3}-\d{2}-\d{4}";
Console.WriteLine (Regex.IsMatch ("123-45-6789", ssNum)); // True
string phone = @"(?x)
( \d{3}[-\s] | \(\d{3}\)\s? )
\d{3}[-\s]?
\d{4}";
Console.WriteLine (Regex.IsMatch ("123-456-7890", phone)); // True
Console.WriteLine (Regex.IsMatch ("(123) 456-7890", phone)); // True
提取“名称 = 值”对(每行一个)
注意,此处使用了 多行 指令 (?m):
string r = @"(?m)^\s*(?'name'\w+)\s*=\s*(?'value'.*)\s*(?=\r?$)";
string text =
@"id = 3
secure = true
timeout = 30";
foreach (Match m in Regex.Matches (text, r))
Console.WriteLine (m.Groups["name"] + " is " + m.Groups["value"]);
*id is 3 secure is true timeout is 30*
强密码验证
下面的内容检查密码是否至少包含六个字符,并且是否包含数字、符号或标点符号:
string r = @"(?x)^(?=.* ( \d | \p{P} | \p{S} )).{6,}";
Console.WriteLine (Regex.IsMatch ("abc12", r)); // False
Console.WriteLine (Regex.IsMatch ("abcdef", r)); // False
Console.WriteLine (Regex.IsMatch ("ab88yz", r)); // True
至少包含 80 个字符的行
string r = @"(?m)^.{80,}(?=\r?$)";
string fifty = new string ('x', 50);
string eighty = new string ('x', 80);
string text = eighty + "\r\n" + fifty + "\r\n" + eighty;
Console.WriteLine (Regex.Matches (text, r).Count); // 2
解析日期/时间(N/N/N H:M:S AM/PM)
此表达式处理多种数字日期格式,并且无论年份先出现还是后出现都能正常工作。(?x) 指令通过允许空白符提高了可读性;(?i) 关闭了大小写敏感性(用于可选的 AM/PM 指示符)。然后,可以通过 Groups 集合访问每个匹配的组件:
string r = @"(?x)(?i)
(\d{1,4}) [./-]
**(\d{1,2}) [./-]**
**(\d{1,4}) [\sT]**
(\d+):(\d+):(\d+) \s? (A\.?M\.?|P\.?M\.?)?";
string text = "01/02/2008 5:20:50 PM";
foreach (Group g in Regex.Match (text, r).Groups)
Console.WriteLine (g.Value + " ");
*01/02/2008 5:20:50 PM 01 02 2008 5 20 50 PM*
(当然,这并不验证日期/时间是否正确。)
匹配罗马数字
string r =
@"(?i)\bm*" +
@"(d?c{0,3}|c[dm])" +
@"(l?x{0,3}|x[lc])" +
@"(v?i{0,3}|i[vx])" +
@"\b";
Console.WriteLine (Regex.IsMatch ("MCMLXXXIV", r)); // True
删除重复的单词
在此,我们捕获了一个名为 dupe 的命名组:
string r = @"(?'dupe'\w+)\W\k'dupe'";
string text = "In the the beginning...";
Console.WriteLine (Regex.Replace (text, r, "${dupe}"));
*In the beginning*
单词计数
string r = @"\b(\w|[-'])+\b";
string text = "It's all mumbo-jumbo to me";
Console.WriteLine (Regex.Matches (text, r).Count); // 5
匹配 GUID
string r =
@"(?i)\b" +
@"[0-9a-fA-F]{8}\-" +
@"[0-9a-fA-F]{4}\-" +
@"[0-9a-fA-F]{4}\-" +
@"[0-9a-fA-F]{4}\-" +
@"[0-9a-fA-F]{12}" +
@"\b";
string text = "Its key is {3F2504E0-4F89-11D3-9A0C-0305E82C3301}.";
Console.WriteLine (Regex.Match (text, r).Index); // 12
解析 XML/HTML 标签
正则表达式对解析 HTML 片段非常有用——特别是当文档可能不完整时:
string r =
@"<(?'tag'\w+?).*>" + // lazy-match first tag, and name it 'tag'
@"(?'text'.*?)" + // lazy-match text content, name it 'textd'
@"</\k'tag'>"; // match last tag, denoted by 'tag'
string text = "<h1>hello</h1>";
Match m = Regex.Match (text, r);
Console.WriteLine (m.Groups ["tag"]); // h1
Console.WriteLine (m.Groups ["text"]); // hello
拆分驼峰命名的单词
这需要使用 正向预查 来包括大写分隔符:
string r = @"(?=[A-Z])";
foreach (string s in Regex.Split ("oneTwoThree", r))
Console.Write (s + " "); // one Two Three
获得合法文件名
string input = "My \"good\" <recipes>.txt";
char[] invalidChars = System.IO.Path.GetInvalidFileNameChars();
string invalidString = Regex.Escape (new string (invalidChars));
string valid = Regex.Replace (input, "[" + invalidString + "]", "");
Console.WriteLine (valid);
*My good recipes.txt*
用于 HTML 的转义 Unicode 字符
string htmlFragment = "© 2007";
string result = Regex.Replace (htmlFragment, @"[\u0080-\uFFFF]",
m => @"&#" + ((int)m.Value[0]).ToString() + ";");
Console.WriteLine (result); // © 2007
在 HTTP 查询字符串中取消转义字符
string sample = "C%23 rocks";
string result = Regex.Replace (
sample,
@"%[0-9a-f][0-9a-f]",
m => ((char) Convert.ToByte (m.Value.Substring (1), 16)).ToString(),
RegexOptions.IgnoreCase
);
Console.WriteLine (result); // C# rocks
从 web 统计日志中解析 Google 搜索术语
你应该与前面的示例结合使用来在查询字符串中取消转义字符:
string sample =
"http://google.com/search?hl=en&q=greedy+quantifiers+regex&btnG=Search";
Match m = Regex.Match (sample, @"(?<=google\..+search\?.*q=).+?(?=(&|$))");
string[] keywords = m.Value.Split (
new[] { '+' }, StringSplitOptions.RemoveEmptyEntries);
foreach (string keyword in keywords)
Console.Write (keyword + " "); // greedy quantifiers regex
正则表达式语言参考
表格 25-2 至 25-12 总结了在 .NET 实现中支持的正则表达式语法和语法。
表 25-2. 字符转义
| 转义代码序列 | 含义 | 十六进制等效 |
|---|---|---|
\a |
响铃符 | \u0007 |
\b |
退格符 | \u0008 |
\t |
制表符 | \u0009 |
\r |
回车符 | \u000A |
\v |
垂直制表符 | \u000B |
\f |
换页符 | \u000C |
\n |
换行符 | \u000D |
\e |
Escape | \u001B |
\*nnn* |
ASCII 字符 nnn(如 \n052) |
|
\x*nn* |
ASCII 字符 nn(如 \x3F) |
|
\c*l* |
ASCII 控制字符 l(例如,\cG 表示 Ctrl-G) |
|
\u*nnnn* |
Unicode 字符 nnnn(如 \u07DE) |
|
\*symbol* |
一个非转义的符号 |
特殊情况:在正则表达式中,\b 表示单词边界,但在 [ ] 集合中,\b 表示退格字符。
表 25-3. 字符集
| 表达式 | 含义 | 反义(“非”) |
|---|---|---|
[abcdef] |
匹配列表中的单个字符 | [^abcdef] |
[a-f] |
匹配范围内的单个字符 | [^a-f] |
\d |
匹配一个十进制数字,等同于 [0-9] |
\D |
\w |
匹配一个单词字符(默认情况下,根据 CultureInfo.CurrentCulture 变化;例如,在英语中,与 [a-zA-Z_0-9] 相同) |
\W |
\s |
匹配空白字符,等同于 [\n\r\t\f\v ] |
\S |
\p{*category*} |
匹配指定category中的字符(参见表 25-4) | \P |
. |
(默认模式)匹配除 \n 外的任意字符 |
\n |
. |
(单行模式)匹配任意字符,不包括 \n |
\n |
表 25-4. 字符类别
| 量词 | 意义 |
|---|---|
\p{L} |
字母 |
\p{Lu} |
大写字母 |
\p{Ll} |
小写字母 |
\p{N} |
数字 |
\p{P} |
标点符号 |
\p{M} |
重音符号 |
\p{S} |
符号 |
\p{Z} |
分隔符 |
\p{C} |
控制字符 |
表 25-5. 量词
| 量词 | 意义 |
|---|---|
* |
零次或多次匹配 |
+ |
一次或多次匹配 |
? |
零或一次匹配 |
{*n*} |
恰好 *n* 次匹配 |
{*n*,} |
至少 *n* 次匹配 |
{*n,m*} |
匹配 *n* 到 *m* 次 |
可以将 ? 后缀应用于任何量词,使其变为懒惰而不是贪婪。
表 25-6. 替换
| 表达式 | 意义 |
|---|---|
$0 |
替换匹配的文本 |
$*group-number* |
在匹配的文本中替换索引为 *group-number* 的文本 |
${*group-name*} |
在匹配的文本中替换文本 *group-name* |
替换只在替换模式中指定。
表 25-7. 零宽断言
| 表达式 | 意义 |
|---|---|
^ |
字符串开头(或在多行模式下是行首) |
| ` | 表达式 |
| --- | --- |
^ |
字符串开头(或在多行模式下是行首) |
| 字符串结尾(或在多行模式下是行尾) | |
\A |
字符串开头(忽略多行模式) |
\z |
字符串结尾(忽略多行模式) |
\Z |
行尾或字符串结尾 |
\G |
开始搜索的位置 |
\b |
在单词边界上 |
\B |
不在单词边界上 |
(?=*expr*) |
只有右边的表达式 *expr* 匹配时继续匹配(正向先行断言) |
(?!*expr*) |
只有右边的表达式 *expr* 不匹配时继续匹配(负向先行断言) |
(?<=*expr*) |
只有左边的表达式 *expr* 匹配时继续匹配(正向后行断言) |
(?<!*expr*) |
只有左边的表达式 *expr* 不匹配时继续匹配(负向后行断言) |
(?>*expr*) |
子表达式 *expr* 仅匹配一次,不回溯 |
表 25-8. 分组结构
| 语法 | 意义 |
|---|---|
(*expr*) |
将匹配的表达式 *expr* 捕获到索引组中 |
(?*number*) |
将匹配的子字符串捕获到指定的组 *number* |
(?'*name*') |
将匹配的子字符串捕获到组 *name* 中 |
(?'*name1-name2*') |
取消定义*name2*并将区间和当前组存储到*name1*;如果*name2*未定义,则匹配回溯 |
(?:*expr*) |
非捕获组 |
表 25-9. 后向引用
| 参数语法 | 含义 |
|---|---|
\*index* |
根据*index*引用先前捕获的组 |
\k<*name*> |
根据*name*引用先前捕获的组 |
表 25-10. 选择
| 表达式语法 | 含义 |
|---|---|
| |
逻辑或 |
(?(*expr*)*yes*|*no*) |
如果表达式匹配,则匹配*yes*;否则,匹配*no*(*no*为可选项) |
(?(*name*)*yes*|*no*) |
如果命名组有匹配,则匹配*yes*;否则,匹配*no*(*no*为可选项) |
表 25-11. 杂项构造
| 表达式语法 | 含义 |
|---|---|
(?#*comment*) |
行内注释 |
#*comment* |
行尾注释(仅在IgnorePatternWhitespace模式下有效) |
表 25-12. 正则表达式选项
| 选项 | 含义 |
|---|---|
(?i) |
大小写不敏感匹配(忽略大小写) |
(?m) |
多行模式;改变^和` |
| --- | --- |
(?i) |
大小写不敏感匹配(忽略大小写) |
| 以匹配任何行的开头和结尾 | |
(?n) |
仅捕获显式命名或编号的组 |
(?c) |
编译为中间语言 |
(?s) |
单行模式;改变“.”的含义以匹配每个字符 |
(?x) |
从模式中消除未转义的空白 |
(?r) |
从右到左搜索;不能在中间指定 |


浙公网安备 33010602011771号