C--编程学习手册-全-

C# 编程学习手册(全)

原文:zh.annas-archive.org/md5/43CC9F8096F66361F01960142D9E6C0F

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

C#是一种通用的、多范式的编程语言,结合了面向对象、命令式、泛型、函数式、声明式和动态编程。在发布后不久,C#就成为开发人员编写各种类型应用程序的首选之一。虽然它不是唯一针对 CLI 的语言(其他语言包括 VB.NET 和 F#),但它是编写桌面、Web、云和移动平台的.NET 应用程序的首选语言。

多年来,这种语言逐渐而稳定地发展。尽管最初它是一种面向对象的编程语言,但新版本已经将这种语言开放到了新的范式,比如泛型、函数式和动态编程。新的语言特性和更简洁的语法也定期添加进来。随着它作为.NET 编译器平台(也称为 Roslyn)的开源项目发布,这是一组用于 C#和 VB.NET 的编译器和代码分析 API,这种语言已经进入了一个新的开放时代,社区深度参与了语言的发展。

当前版本的语言被称为 C# 8。这是在 2019 年 9 月发布的,适用于.NET Core 3.0,并需要 Visual Studio 2019 16.3 或更新版本。C# 8 也可以与.NET Framework 一起使用,尽管并非所有功能都可用。这是因为它们需要运行时更改,这是微软不愿意做的事情,因为他们打算不再投资于.NET Framework(除了长期支持),并将.NET Core 转变为用于定位所有平台和类型应用程序的唯一框架。这个框架将简单地称为.NET。

这本书旨在帮助你从零开始学习这种语言,并最终掌握它的多范式编程方面。我们从非常基础的东西开始:数据类型、语句和其他构件。然后我们继续讲解面向对象的概念,比如类、接口、继承和多态。我们涵盖了泛型、函数式编程和 LINQ,反射和动态编程,以及更高级的主题,比如资源管理、模式匹配、并发和异步编程、错误处理和序列化。在书的最后,我们特别关注了 C# 8 中引入的新特性。最后,但同样重要的是,我们讨论了单元测试以及如何为你的 C#代码编写单元测试。在每一章的结尾,我们都会提供一系列问题,帮助你评估你在该章节中学到了什么。

这本书包含了许多代码片段,旨在帮助你轻松理解和学习所有的语言特性。所有这些代码都可以在附带的源代码中找到。你需要使用 Visual Studio 或 Visual Studio Code 来尝试它们。或者,你可以使用在线编译器,这种情况下的首选是sharplab.io/

这本书适合谁?

如果你是一个热情的程序员,想要学习 C#,那么这本书适合你。如果你想开始学习编程,并且想要用 C#和.NET 来做到这一点,你也会发现这本书很有价值。然而,我们假设你对编程概念有一些基本的了解,比如什么是编译器,什么是类和方法等等。另一方面,如果你是一名经验丰富的 C#程序员,但想要了解 C# 8 的最新特性,或者如何使用.NET Core 并从.NET Framework 迁移,这本书对你也很有用。

这本书涵盖了什么

第一章, 从 C#的基本构件开始,介绍了这种语言的历史,以及它与公共语言基础设施和.NET Framework 的关系,同时介绍了今天使用的.NET 框架系列。最后,你将学习有关程序集的知识,如何在 Visual Studio 中创建项目,以及如何用 C#编写一个 Hello World 程序。

第二章《数据类型和运算符》带您了解语言的基本元素,包括内置数据类型、变量和常量、引用和值类型、可空类型和数组类型,以及类型转换和内置运算符。

第三章《控制语句和异常》深入探讨了如何编写选择语句和循环,并简要介绍了处理异常。

第四章《理解各种用户定义类型》提供了关于类、字段、属性、方法、构造函数、如何向方法传递参数、访问修饰符以及与类相关的其他方面的信息。在接近结尾时,您将了解结构以及它们与类的比较,以及枚举。

第五章《C#中的面向对象编程》延续了前一章所建立的基础,教会您面向对象编程的核心支柱,以及如何使用 C#语言特性来实现它们,如接口、虚拟成员、方法重载等。

第六章《泛型》涵盖了 C#中泛型编程的所有方面,并教会您如何编写泛型类型和方法,以及如何为类型参数使用约束。

第七章《集合》介绍了通常在编写 C#程序时使用的.NET 基类库中的通用集合。该章节以概述在多线程场景中使用的并发集合结束。

第八章《高级主题》包含各种更高级的特性,如委托和事件、元组、扩展方法、模式匹配和正则表达式。

第九章《资源管理》解释了垃圾收集器的工作原理以及您应该如何确定性地处理资源。此外,在本章中,您将学习如何使用平台调用服务进行系统或一般的本机 API 调用,以及如何编写不安全的代码。

第十章《Lambda、LINQ 和函数式编程》概述了函数式编程概念以及与 C#中的 lambda 表达式相关的细节。您将学习如何使用语言集成查询(或 LINQ)统一查询各种数据源。在章节的结尾,我们涵盖了几个典型的函数式编程概念:部分函数应用、柯里化、闭包、幺半群和单子,以及它们在 C#中的工作原理。

第十一章《反射和动态编程》教会您反射服务是什么,以及如何使用它们编写可扩展的应用程序,如何动态加载程序集并执行代码,如何使用属性,以及如何使用动态语言运行时和动态类型与动态语言进行交互。

第十二章《多线程和异步编程》深入研究了线程、任务和同步机制,并揭示了用于在 C#中编写异步程序的 async-await 模式的细节。

第十三章《文件、流和序列化》解释了如何处理路径、文件和目录,以及如何使用流从各种存储选项(如文件和内存)读取和写入数据。在本章的第二部分,您将了解使用 XML 和 JSON 进行数据序列化。

第十四章,错误处理,是在第三章引入的关于异常处理的概念的基础上构建的,控制语句和异常,并教会你异常的内部工作原理以及异常处理与错误处理的区别。您将学习有关调试和监视的宝贵信息,以及处理异常的最佳实践。

第十五章,C# 8 的新特性,详细介绍了 C# 8 中引入的所有新语言特性,包括可空引用类型,异步流,范围和索引,模式匹配以及接口成员的默认实现。

第十六章,C#在.NET Core 3 中的应用,教会您如何使用.NET CLI 构建.NET Core 应用程序,如何针对 Linux 进行开发,.NET Standard 是什么以及它如何帮助应用程序设计,如何使用 NuGet 包,以及如何将.NET Framework 应用程序迁移到.NET Core。

第十七章,单元测试,涵盖了单元测试,微软用于测试 C#代码的工具,如何使用 Visual Studio 创建单元测试项目,以及如何编写单元测试和数据驱动的单元测试。

为了充分利用本书

这是一本涵盖 C#的书,从其基本构件到其最高级功能。本书适用于想要学习 C#的人。因此,我们不希望您具有任何关于该语言的先前知识。但是,我们希望您对编程概念有一些基本了解,比如编译器是什么,编译时和运行时的区别,堆栈和堆的区别等。

本书中的所有代码示例都是使用 C# 8 和现代编程风格(如使用表达式主体成员,插值字符串,本地函数等)编写的。所有这些示例与本书一起提供,项目针对.NET Core 3.1。

以下表格列出了运行这些示例所需的软件和平台要求:

要运行源代码,您需要 Visual Studio 2019 16.3 或更新版本的任何版本,或者 Visual Studio Code。大多数示例也可以使用在线编译器进行测试。如果您更喜欢这个选项,我们建议您使用sharplab.io/

如果您使用的是本书的数字版本,我们建议您自己输入代码或通过 GitHub 存储库访问代码(链接在下一节中提供)。这样做将帮助您避免与复制/粘贴代码相关的任何潜在错误。

下载示例代码文件

您可以从www.packt.com的帐户中下载本书的示例代码文件。如果您在其他地方购买了本书,可以访问www.packtpub.com/support注册并直接将文件发送到您的邮箱。

您可以按照以下步骤下载代码文件:

  1. www.packt.com上登录或注册。

  2. 选择支持选项卡。

  3. 单击代码下载

  4. 搜索框中输入书名,然后按照屏幕上的说明操作。

下载文件后,请确保使用最新版本的解压缩软件解压缩文件夹:

  • Windows 上的 WinRAR/7-Zip

  • Mac 上的 Zipeg/iZip/UnRarX

  • Linux 上的 7-Zip/PeaZip

该书的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Learn-C-Sharp-Programming。如果代码有更新,将在现有的 GitHub 存储库上进行更新。

我们还有来自我们丰富书籍和视频目录的其他代码包,网址为 https://github.com/PacktPublishing/。去看看吧!

实战代码

本书的代码演示视频可以在bit.ly/2VaAls9上观看。

下载彩色图像

我们还提供了一个 PDF 文件,其中包含本书中使用的屏幕截图/图表的彩色图像。您可以在这里下载:https://static.packt-cdn.com/downloads/9781789805864_ColorImages.pdf。

使用的约定

本书中使用了许多文本约定。

文本中的代码:表示文本中的代码词,数据库表名,文件夹名,文件名,文件扩展名,路径名,虚拟 URL,用户输入和 Twitter 用户名。这是一个例子:“在这个例子中,我们创建了一个Employee类,其中包含三个字段,用于表示员工的 ID,名和姓。”

代码块设置如下:

class Employee
{
    public int    EmployeeId;
    public string FirstName;
    public string LastName;
}

当我们希望引起您对代码块的特定部分的注意时,相关的行或项目将以粗体显示:

public struct Vector
{
    public float x;
    public float y;
    private readonly float SquaredRo => (x * x) + (y * y);
    public readonly float GetLengthRo() => MathF.Sqrt(SquaredRo);
    public float GetLength() => MathF.Sqrt(SquaredRo);
}

任何命令行输入或输出都将按以下方式编写:

cd HelloSolution
dotnet new console -o Hello
dotnet sln add Hello

粗体:表示一个新术语,一个重要单词,或者您在屏幕上看到的单词。例如,菜单或对话框中的单词会以这种方式出现在文本中。这是一个例子:“创建新项目时,选择Console App (.NET Core)。”

提示或重要说明

看起来像这样。

第一章:从 C#的基本构建块开始

C#是最广泛使用的通用编程语言之一。它是一种多范式语言,结合了面向对象、命令式、声明式、函数式、泛型和动态编程。C#是为公共语言基础设施CLI)平台设计的编程语言之一,这是由微软开发并由国际标准化组织ISO)和欧洲计算机制造商协会ECMA)标准化的开放规范,描述了可在不同计算机平台上使用的可执行代码和运行时环境,而无需为特定架构重新编写。

多年来,C#随着版本的发布而不断演进,引入了强大的功能。最近的版本(在撰写本文时)是 C# 8,它引入了几个功能,使开发人员能够更加高效。这些功能包括可空引用类型、范围和索引、异步流、接口成员的默认实现、递归模式、开关表达式等。您将在第十五章中详细了解这些功能,C# 8 的新功能

在本章中,我们将向您介绍语言、.NET Framework 以及围绕它们的基本概念。我们将本章的内容结构化如下:

  • 了解 C#的历史

  • 理解 CLI

  • 了解.NET 框架家族

  • .NET 中的程序集

  • 理解 C#程序的基本结构

在本章结束时,您将学会如何在 C#中编写一个Hello World!程序。

C#的历史

C#的开发始于 1990 年代末的微软团队,由 Anders Hejlsberg 领导。最初它被称为 Cool,但当.NET 项目在 2002 年夏天首次公开宣布时,语言被重新命名为 C#。使用井号后缀的用意是表示该语言是 C的一个增量,C与 Java、Delphi 和 Smalltalk 一起,为 CLI 和 C#语言设计提供了灵感。

C#的第一个版本称为1.0,于 2002 年与.NET Framework 1.0 和 Visual Studio .NET 2002 捆绑发布。从那时起,随着新版本的.NET Framework 和 Visual Studio 的发布,语言的主要和次要增量版本也相继发布。以下表格列出了所有版本以及每个版本的一些关键功能:

在撰写本文时,最新版本的语言是 8.0,它将与.NET Core 3.0 一起发布。虽然大多数功能也适用于针对.NET Framework 的项目,但其中一些功能不适用,因为它们需要对运行时进行更改,而微软将不再这样做,因为.NET Framework 正在被淘汰,取而代之的是.NET Core。

现在您已经了解了 C#语言随时间的演变概况,让我们开始看一下语言所针对的平台。

理解 CLI

CLI 是一项规范,描述了运行时环境如何在不同计算机平台上使用,而无需为特定架构重新编写。它由微软开发,并由 ECMA 和 ISO 标准化。以下图示显示了 CLI 的高级功能:

图 1.1 - CLI 的高级功能图示

图 1.1 - CLI 的高级功能图示

CLI 使得用各种编程语言(符合 CLS 的)编写的程序可以在任何操作系统上以及单个运行时上执行。CLI 指定了一个通用语言,称为公共语言规范(CLS),任何语言必须支持的一组通用数据类型,称为公共类型系统,以及其他一些内容,例如异常处理和状态管理方式。CLI 指定的各个方面在以下各节中有更详细的描述。

信息框

由于本章的范围有限,深入研究规范是不可能的。如果您想了解更多关于 CLI 的信息,可以访问 ISO 网站www.iso.org/standard/58046.html

CLI 有几种实现,其中最重要的是.NET Framework、.NET Core 和 Mono/Xamarin。

公共类型系统(CTS)

CTS 是 CLI 的一个组成部分,描述了类型定义和值的表示以及内存的用途,旨在促进数据在编程语言之间的共享。以下是 CTS 的一些特点和功能:

  • 它实现了跨平台集成、类型安全和高性能代码执行。

  • 它提供了一个支持许多编程语言完整实现的面向对象模型。

  • 它为语言提供规则,以确保不同编程语言中编写的对象和数据类型可以相互交互。

  • 它定义了类型可见性和对成员的访问规则。

  • 它定义了类型继承、虚拟方法和对象生命周期的规则。

CTS 支持两类类型:

  • 值类型:这些类型直接包含其数据,并具有复制语义,这意味着当此类型的对象被复制时,其数据也被复制。

  • 引用类型:这些类型包含对数据存储的内存地址的引用。当引用类型的对象被复制时,复制的是引用而不是它指向的数据。

尽管这是一个实现细节,值类型通常存储在堆栈上,引用类型存储在堆上。值类型和引用类型之间的转换是可能的,称为装箱,而反之则称为拆箱。这些概念将在下一章中进一步详细解释。

公共语言规范(CLS)

CLS 包括一组规则,任何针对 CLI 的语言都需要遵守这些规则,以便与其他符合 CLS 的语言进行互操作。CLS 规则属于 CTS 的更广泛规则,因此可以说 CLS 是 CTS 的子集。除非 CLS 规则更严格,否则所有 CTS 规则都适用于 CLS。使代码的类型安全性难以验证的语言构造被排除在 CLS 之外,以便所有与 CLS 一起工作的语言都可以生成可验证的代码。

CTS 与 CLS 之间的关系以及针对 CLI 的编程语言在以下图表中概念上显示:

图 1.2 - 显示 CTS 和 CLS 之间的概念关系以及针对 CLI 的编程语言

图 1.2 - 显示 CTS 和 CLS 之间的概念关系以及针对 CLI 的编程语言

仅使用 CLS 规则构建的组件称为CLS 兼容。这样的组件的一个例子是需要在.NET 上支持的所有语言中工作的框架库。

公共中间语言(CIL)

CIL 是一个平台中立的中间语言(以前称为Microsoft 中间语言MSIL),代表了 CLI 定义的中间语言二进制指令集。它是一种基于堆栈的面向对象的汇编语言,代表了以字节码格式的代码。

一旦应用程序的源代码被编译,编译器将其转换为 CIL 字节码并生成 CLI 程序集。当执行 CLI 程序集时,字节码通过即时编译器传递,生成本机代码,然后由计算机的处理器执行。CIL 的 CPU 和平台无关性使得代码可以在支持 CLI 的任何环境上执行。

为了帮助我们理解 CIL,让我们看一个例子。以下列表显示了一个非常简单的 C#程序,它向控制台打印Hello, World!消息:

using System;
namespace chapter_01
{
   class Program
   {
      static void Main(string[] args)
      {
         Console.WriteLine("Hello World!");
      }
   }
}

可以使用各种实用工具查看编译器生成的程序集的内容,例如.NET Framework 附带的ildasm.exe或 ILSpy,后者是一个开源的.NET 程序集浏览器和反编译器(可在www.ilspy.net/上找到)。ildasm.exe文件显示了程序及其组件(如类和成员)的可视化表示:

图 1.3 - ildasm 工具显示程序集内容的屏幕截图

图 1.3 - ildasm 工具显示程序集内容的屏幕截图

如果双击它,还可以看到清单的内容(包括程序集元数据)以及每个方法的 CIL 代码。以下屏幕截图显示了Main方法的反汇编代码:

图 1.4 - ildasm 工具显示 Main 方法的 IL 代码的屏幕截图

图 1.4 - ildasm 工具显示 Main 方法的 IL 代码的屏幕截图

CIL 代码的可读性转储也是可用的。这从清单开始,然后继续类成员的声明。以下是前面程序的 CIL 代码的部分列表:

// Metadata version: v4.0.30319
.assembly extern System.Runtime
{
  .publickeytoken = (B0 3F 5F 7F 11 D5 0A 3A )                         // .?_....:
  .ver 4:2:1:0
}
.assembly extern System.Console
{
  .publickeytoken = (B0 3F 5F 7F 11 D5 0A 3A )                         // .?_....:
  .ver 4:1:1:0
}
.assembly chapter_01
{
}
.module chapter_01.dll
// MVID: {1CFF5587-0C75-4C14-9BE5-1605F27AE750}
.imagebase 0x00400000
.file alignment 0x00000200
.stackreserve 0x00100000
.subsystem 0x0003       // WINDOWS_CUI
.corflags 0x00000001    //  ILONLY
// Image base: 0x00F30000
// =============== CLASS MEMBERS DECLARATION ===================
.class private auto ansi beforefieldinit chapter_01.Program
       extends [System.Runtime]System.Object
{
  .method private hidebysig static void  Main(string[] args) cil managed
  {
    .entrypoint
    // Code size       13 (0xd)
    .maxstack  8
    IL_0000:  nop
    IL_0001:  ldstr      "Hello World!"
    IL_0006:  call       void [System.Console]System.Console::WriteLine(string)
    IL_000b:  nop
    IL_000c:  ret
  } // end of method Program::Main
  .method public hidebysig specialname rtspecialname 
          instance void  .ctor() cil managed
  {
    // Code size       8 (0x8)
    .maxstack  8
    IL_0000:  ldarg.0
    IL_0001:  call       instance void [System.Runtime]System.Object::.ctor()
    IL_0006:  nop
    IL_0007:  ret
  } // end of method Program::.ctor
} // end of class chapter_01.Program

这里对代码的解释超出了本章的范围,但你可能一眼就能识别出其中的部分,比如类、方法以及每个方法中执行的指令。

虚拟执行系统(VES)

VES 是 CLI 的一部分,代表提供执行托管代码的环境的运行时系统。它具有几个内置服务,以支持代码的执行和异常处理等功能。

公共语言运行时是.NET Framework 对虚拟执行系统的实现。CLI 的其他实现提供了它们自己的 VES 实现。

.NET 框架家族

.NET 是由微软开发的通用开发平台,用于编写各种类型的桌面、云和移动应用程序。.NET Framework 是 CLI 的第一个实现,但随着时间的推移,已经创建了一系列其他框架,如.NET Micro Framework、.NET Native 和 Silverlight。虽然.NET Framework 适用于 Windows,但其他当前的实现,如.NET Core 和 Mono/Xamarin,是跨平台的,可以在其他操作系统上运行,如 Linux、macOS、iOS 或 Android。

以下屏幕截图显示了当前顶级.NET 框架的主要特征。.NET Framework 用于开发 Windows 的.NET 应用程序,并随操作系统分发。.NET Core 是跨平台和开源的,针对现代应用程序需求和开发人员工作流程进行了优化,并随应用程序分发。Xamarin 使用基于 Mono 的运行时,也是跨平台和开源的。它用于开发 iOS、macOS、Android 和 Windows 的移动应用程序,并随应用程序分发:

图 1.5 - 具有最重要的.NET 框架主要特征的图表

图 1.5 - 具有最重要的.NET 框架主要特征的图表

所有这些实现都基于一个共同的基础设施,包括语言、编译器和运行时组件,并支持各种应用模型,其中一些显示在以下截图中:

图 1.6 - .NET 框架基础设施和它们支持的应用模型的高级图表

图 1.6 - .NET 框架基础设施和它们支持的应用模型的高级图表

在这里,您可以看到每个框架都位于共同基础设施的顶部,并提供一组基本库以及不同的应用模型。

.NET 框架

.NET 框架是 CLI 的第一个实现。它是 Windows 服务器和客户端开发人员的主要开发平台。它包含一个支持许多类型应用程序的大型类库。该框架作为操作系统的一部分分发,因此新版本通过Windows Update进行更新,尽管也提供独立的安装程序。最初,.NET 框架是由微软开发的专有软件。近年来,.NET 框架的部分内容已经开源。

以下表格显示了.NET 框架的历史,以及每个版本中可用的主要功能:

在未来,微软打算将所有.NET 框架统一为一个。在撰写本书时,计划将其命名为.NET 5。

.NET 框架包括公共语言运行时CLR),它是框架的执行引擎,提供诸如内存管理、类型安全、垃圾回收、异常处理、线程管理等服务。它还包括 CLI 基础标准库的实现。以下是标准库的组件列表(尽管不是全部):

  • 基础类库BCL):它提供用于表示 CLI 内置类型、简单文件访问、自定义属性、字符串处理、格式化、集合、流等的类型。

  • 运行时基础设施库:它提供从流中动态加载类型以及其他允许编译器针对 CLI 的服务。

  • 反射库:它提供使得在运行时检查类型结构、实例化对象和调用方法成为可能的服务。

  • 网络库:它提供网络服务。

  • 扩展数值库:它提供对浮点和扩展精度数据类型的支持。

  • 并行库:它提供简单形式的并行性。

除了这些库,还有System.*Microsoft.*命名空间。

在.NET 平台上开发 C#的一个关键方面是内存管理。一般来说,开发人员不必担心对象的生命周期和内存的释放。内存管理由 CLR 通过垃圾回收器GC)自动完成。GC 处理堆上对象的分配和在堆对象不再使用时的内存释放。

垃圾回收是一个非确定性的过程,因为它是根据需要进行的,而不是在某些确定的时刻进行的。有关垃圾回收工作方式的详细描述,请参阅第九章资源管理

.NET Core

.NET Core 是 CLI 的新实现,它是跨平台的、开源的和模块化的。它旨在开发各种应用程序,如运行在 Windows、Linux 和 macOS 上的 Web 应用程序、微服务、库或控制台应用程序。.NET Core 框架使用 NuGet 打包;因此,它要么直接编译到应用程序中,要么放入应用程序内的文件夹中。因此,.NET Core 应用程序直接分发框架组件,尽管从 2.0 版本开始,也提供了一个用于集中部署的缓存系统,称为运行时包存储

.NET Core 的 VES 实现称为CoreCLR。同样,CLI 基础标准库的实现称为CoreFX

ASP.NET Core 是.NET Core 的一部分,但也可以在.NET Framework CLR 上运行。但是,当目标是.NET Core 时,ASP.NET Core 应用程序才是跨平台的。

随着 2019 年 9 月发布的 3.0 版本,开发人员可以使用.NET Core 创建 Web 应用程序、微服务、桌面应用程序、机器学习和人工智能应用程序、物联网应用程序、库和控制台应用程序。

您将在第十六章中了解更多关于.NET Core 的信息,使用.NET Core 3 进行 C#编程

Xamarin

Xamarin 是基于Mono的 CLI 实现,它是一个跨平台的开源.NET 框架。一般来说,Mono API 遵循了.NET Framework 的进展,而不是.NET Core。该框架旨在编写可以在 iOS、Android、macOS 和 Windows 设备上运行的移动应用程序。

使用 Xamarin 开发的应用程序是本机的,提供了与使用 Objective-C 或 Swift 开发的 iOS 和 Java 或 Kotlin 开发的 Android 应用程序类似的性能。Xamarin 还提供了直接调用 Objective-C、Java、C 和 C++库的功能。

Xamarin 应用程序是用 C#编写的,并使用.NET 基类库。它们可以共享大部分代码,只需要少量特定于平台的代码。

有关 Xamarin 的详细信息超出了本书的范围。如果您想了解更多关于这个实现的信息,您应该使用其他资源。

.NET 中的程序集

程序集是部署、版本控制和安全性的基本单位。程序集有两种形式,要么是.exe,要么是.dll。程序集是类型、资源和元信息的集合,形成一个逻辑功能单元。只有在需要时,程序集才会加载到内存中。对于.NET Framework 应用程序,程序集可以位于应用程序私有文件夹中,也可以共享在全局程序集缓存中,只要它们是强命名的。对于.NET Core 应用程序,后一种解决方案不可用。

每个程序集都包含一个包含以下信息的清单:

  • 程序集的身份(如名称和版本)

  • 文件表描述了组成程序集的文件,例如其他程序集或资源(如图像)

  • 包含应用程序所需的外部依赖项的程序集引用列表

一个程序集的身份由几个部分组成:

  • 文件的名称,其中名称应符合 Windows 可移植可执行文件格式

  • 一个major.minor.build.revision,例如 1.12.3.0

  • 文化,除了卫星程序集(这些是区域感知的程序集)外,应该是与区域无关的

  • 公钥标记,它是用于签署程序集的私钥的 64 位哈希;签名程序集具有旨在提供唯一名称的强名称

您将在第十一章中了解更多关于程序集的信息,反射和动态编程

全局程序集缓存(GAC)

如前一节所述,.NET Framework 程序集可以存储在本地,即应用程序文件夹中,也可以存储在GAC中。这是一个机器范围的代码缓存,可以在应用程序之间共享程序集。自.NET Framework 4 发布以来,GAC 的默认位置是%windir%\Microsoft.NET\assembly;然而,以前的位置是%windir%\assembly。GAC 还可以存储同一程序集的多个版本,而在私有文件夹中实际上是不可能的,因为您不能在同一文件夹中存储多个同名文件。

要将程序集部署到 GAC,您可以使用名为gacutil.exe的 Windows SDK 实用工具或能够与 GAC 一起工作的安装程序。但是,程序集必须具有强名称才能部署到 GAC。一个sn.exe)。

注意

有关如何对程序集进行签名的更多详细信息,请参阅以下文档,其中描述了如何使用强名称对程序集进行签名:docs.microsoft.com/en-us/dotnet/framework/app-domains/how-to-sign-an-assembly-with-a-strong-name

将程序集添加到 GAC 时,将对程序集中包含的所有文件执行完整性检查。这样做是为了确保程序集没有被篡改。加密签名确保对程序集中任何文件的更改都会使签名无效,只有拥有私钥访问权限的人才能重新对程序集进行签名。

运行时包存储

GAC 不用于.NET Core 程序集。这些程序集可以在任何平台上运行,而不仅仅是 Windows。在.NET Core 2.0 之前,部署的唯一选项是应用程序文件夹。然而,自 2.0 版本以来,可以将应用程序打包并部署到目标环境中已知的一组包中。这样可以实现更快的部署和更低的磁盘空间要求。通常,此存储库在 macOS 和 Linux 上可用于/usr/local/share/dotnet/store,在 Windows 上可用于C:/Program Files/dotnet/store

运行时包存储中可用的包列在目标清单文件中,该文件在发布应用程序时使用。此文件的格式与项目文件格式(.csproj)兼容。

详细介绍定位过程超出了本章的范围,但您可以通过访问以下链接了解有关运行时包存储的更多信息:docs.microsoft.com/en-us/dotnet/core/deploying/runtime-store

了解 C#程序的基本结构

到目前为止,我们已经了解了 C#和.NET 运行时的基础知识。在本节中,我们将编写一个简单的 C#程序,以便简要介绍一些简单程序的关键要素。

在编写程序之前,您必须创建一个项目。为此,您应该使用 Visual Studio 2019;或者,您可以在本书的大部分内容中使用任何其他版本。本书附带的源代码是在 Visual Studio 2019 中使用.NET Core 项目编写的。创建新项目时,选择chapter_01

图 1.7 - 在创建时选择控制台应用程序(.NET Core)模板在 Visual Studio 中创建一个新项目

图 1.7 - 在 Visual Studio 中创建新项目时选择控制台应用程序(.NET Core)模板

将自动为您创建具有以下内容的项目:

图 1.8 - Visual Studio 的屏幕截图和所选模板生成的代码

图 1.8 - Visual Studio 的屏幕截图和所选模板生成的代码

这段代码代表了一个 C#程序必须包含的最小内容:一个包含一个名为Main的方法的单个文件。您可以编译和运行该项目,控制台将显示消息Hello World!。然而,为了更好地理解它,让我们看一下实际的 C#程序。

程序的第一行(using System;)声明了我们想在这个程序中使用的命名空间。命名空间包含类型,这里使用的是基类库的核心命名空间。

在下一行,我们定义了自己的命名空间,名为chapter_01,其中包含我们的代码。命名空间是用namespace关键字引入的。在这个命名空间中,我们定义了一个名为Program的类。类是用class关键字引入的。此外,这个类包含一个名为Main的方法,它有一个名为args的字符串数组参数。命名空间、类型(无论是类、结构、接口还是枚举)和方法中的代码总是用大括号{}提供。这个方法是程序的入口点,这意味着程序的执行从这里开始。一个 C#程序必须有且只有一个Main方法。

Main方法包含一行代码。它使用System.Console.WriteLine静态方法将文本打印到控制台。静态方法是属于类型而不是类型的实例的方法,这意味着您不通过对象调用它。Main方法本身是一个静态方法,而且是一个特殊的方法。每个 C#程序必须有一个名为Main的静态方法,它被认为是程序的入口点,在程序执行开始时首先被调用。

在接下来的章节中,我们将学习命名空间、类型、方法和 C#的其他关键特性。

总结

在本章中,我们简要介绍了 C#的历史。然后,我们探讨了 CLI 背后的基本概念及其组成部分,如 CTS、CLS、CIL 和 VES。接着,我们了解了.NET 框架家族,并简要讨论了.NET Framework、.NET Core 和 Xamarin。我们还谈到了程序集、GAC(针对.NET Framework)和运行时包存储(针对.NET Core)。最后,我们编写了我们的第一个 C#程序,并了解了它的结构。

这些框架和运行时的概述将帮助您了解编写和执行 C#程序的背景,并在我们讨论更高级功能(如反射、程序集加载)或研究.NET Core 框架时提供良好的背景知识。

在下一章中,我们将探讨 C#中的基本数据类型和运算符,并学习如何使用它们。

测试你所学到的知识

  1. C#是何时首次发布的,目前的语言版本是多少?

  2. 什么是公共语言基础设施?它的主要组成部分是什么?

  3. 什么是公共中间语言,它与即时编译器有什么关系?

  4. 您可以使用什么工具来反汇编和探索编译器生成的程序集?

  5. 什么是公共语言运行时?

  6. 什么是基类库?

  7. 目前主要的.NET 框架是什么?哪一个将不再开发?

  8. 什么是程序集?程序集的标识包括什么?

  9. 什么是全局程序集缓存?运行时包存储又是什么?

  10. 一个 C#程序必须包含什么最少才能执行?

第二章:数据类型和运算符

在上一章中,我们了解了.NET Framework 并理解了 C#程序的基本结构。在本章中,我们将学习 C#中的数据类型和对象。除了控制语句,我们将在下一章中探讨,这些是每个程序的构建块。我们将讨论内置数据类型,解释值类型和引用类型之间的区别,并学习如何在类型之间进行转换。随着我们的学习,我们还将讨论语言定义的运算符。

本章将涵盖以下主题:

  • 基本内置数据类型

  • 变量和常量

  • 引用类型和值类型

  • 可空类型

  • 数组

  • 类型转换

  • 运算符

通过本章结束时,您将能够使用上述语言特性编写一个简单的 C#程序。

基本数据类型

在这一部分,我们将探讨基本数据类型。System命名空间。然而,它们都有C#别名。这些别名是 C#语言中的关键字,这意味着它们只能在它们指定的上下文中使用,而不能在其他地方使用,比如变量、类或方法名。C#名称和.NET 名称以及每种类型的简短描述列在以下表中(按 C#名称字母顺序列出):

此表中列出的类型称为简单类型原始类型。除了这些,还有两种内置类型:

让我们在接下来的章节中详细探讨所有原始类型。

整数类型

C#支持表示各种整数范围的八种整数类型。它们的位数和范围如下表所示:

如前表所示,C#定义了有符号和无符号整数类型。有符号和无符号整数之间的主要区别在于高阶位的读取方式。对于有符号整数,高阶位被视为符号标志。如果符号标志为 0,则数字为正数,但如果符号标志为 1,则数字为负数。

所有整数类型的默认值都是 0。所有这些类型都定义了两个常量,称为MinValueMaxValue,它们提供了类型的最小值和最大值。

整数字面值,即直接出现在代码中的数字(如 0、-42 等),可以指定为十进制、十六进制或二进制字面值。十进制字面值不需要任何后缀。十六进制字面值以0x0X为前缀,二进制字面值以0b0B为前缀。下划线(_)可以用作所有数字字面值的数字分隔符。此类字面值的示例如下片段所示:

int dec = 32;
int hex = 0x2A;
int bin = 0b_0010_1010;

编译器推断没有后缀的整数值为int。要表示长整数,使用lL表示有符号 64 位整数,使用ulUL表示无符号 64 位整数。

浮点类型

浮点类型用于表示具有小数部分的数字。C#定义了两种浮点类型,如下表所示:

float类型表示 32 位单精度浮点数,而double表示 64 位双精度浮点数。这些类型是**IEEE 浮点算术标准(IEEE 754)的实现,这是电气和电子工程师学会(IEEE)**在 1985 年制定的浮点算术标准。

浮点类型的默认值是 0。这些类型还定义了两个常量,称为 MinValueMaxValue,提供类型的最小值和最大值。然而,这些类型还提供了表示非数字(System.Double.NaN)和无穷大(System.Double.NegativeInfinitySystem.Double.PositiveInfinity)的常量。下面的代码列表显示了用浮点值初始化的几个变量:

var a = 42.99;
float b = 19.50f;
System.Double c = -1.23;

默认情况下,非整数数字如 42.99 被视为双精度。如果要将其指定为浮点类型,则需要在值后加上 fF 字符,如 42.99f42.99F。另外,也可以使用 dD 后缀来明确指定双精度字面量,如 42.99d42.99D

浮点类型将小数部分存储为二的倒数。因此,它们只能表示精确值,如 1010.2510.5 等。其他数字,如 1.2319.99,无法精确表示,只是一个近似值。即使 double 有 15 位小数精度,而 float 只有 7 位,但在执行重复计算时,精度损失开始积累。

这使得 doublefloat 在某些类型的应用中难以使用,甚至不合适,比如金融应用,其中精度很重要。为此,提供了 decimal 类型。

十进制类型

decimal 类型最多可以表示 28 位小数。decimal 类型的详细信息如下表所示:

十进制类型的默认值是 0。还有定义了类型的最小值和最大值的 MinValueMaxValue 常量。十进制字面量可以使用 mM 后缀来指定,如下面的片段所示:

decimal a = 42.99m;
var b = 12.45m;
System.Decimal c = 100.75M;

需要注意的是,decimal 类型可以最小化舍入误差,但并不能消除对舍入的需求。例如,1m / 3 * 3 的操作结果不是 1,而是 0.9999999999999999999999999999。另一方面,Math.Round(1m / 3 * 3) 得到的值是 1。

decimal 类型适用于需要精度的应用程序。浮点数和双精度是更快的类型(因为它们使用二进制数学,计算速度更快),而 decimal 类型较慢(顾名思义,它使用十进制数学,计算速度较慢)。decimal 类型可能比 double 类型慢一个数量级。金融应用程序是 decimal 类型的典型用例,其中小的不准确性可能在重复计算中积累成重要的值。在这种应用中,速度不重要,但精度很重要。

字符类型

字符类型用于表示 16 位 Unicode 字符。Unicode 定义了一个字符集,旨在表示世界上大多数语言的字符。字符用单引号括起来表示('')。例如,'A''B''c''\u0058'

字符值可以是字面量、十六进制转义序列(形式为 '\xdddd'),或者具有形式 '\udddd' 的 Unicode 表示(其中 dddd 是一个十六进制值)。下面的列表显示了几个示例:

char a = 'A';
char b = '\x0065';
char c = '\u15FE';

char 类型的默认值是十进制 0,或其等价值 '\0''\x0000''\u0000'

布尔类型

C# 使用 bool 关键字来表示布尔类型。它可以有两个值,truefalse,如下表所示:

布尔类型的默认值是 false。与其他语言(如 C++)不同,整数值或任何其他值不会隐式转换为 bool 类型。布尔变量可以被赋予布尔字面量(truefalse)或求值为 bool 的表达式。

字符串类型

字符串是字符数组。在 C#中,表示字符串的类型称为string,它是.NETSystem.String的别名。您可以互换使用这两种类型。在内部,字符串包含一个只读的char对象集合。这使得字符串是不可变的,这意味着您不能更改字符串,但需要每次修改现有字符串的内容时创建一个新的字符串。字符串不是以 null 结尾(与其他语言如 C++不同),可以包含任意数量的空字符('\0')。字符串长度将包含char对象的总数。

字符串可以以各种方式声明和初始化,如下所示:

string s1;                       // unitialized
string s2 = null;                // initialized with null
string s3 = String.Empty;        // empty string
string s4 = "hello world";       // initialized with text
var s5 = "hello world";
System.String s6 = "hello world";
char[] letters = { 'h', 'e', 'l', 'l', 'o'};
string s7 = new string(letters); // from an array of chars

重要的是要注意,唯一需要使用new运算符创建字符串对象的情况是当您从字符数组初始化它时。

如前所述,字符串是不可变的。虽然您可以访问字符串的字符,但您可以读取它们,但不能更改它们:

char c = s4[0];  // OK
s4[0] = 'H';     // error

以下是似乎修改字符串的方法:

  • Remove(): 这会删除字符串的一部分。

  • ToUpper()/ToLower(): 这将所有字符转换为大写或小写。

这些方法都不会修改现有字符串,而是返回一个新的字符串。

在下面的示例中,s6是之前定义的字符串,s8将包含hellos9将包含HELLO WORLD,而s6将继续包含hello world

var s8 = s6.Remove(5);       // hello
var s9 = s6.ToUpper();       // HELLO WORLD

您可以使用ToString()方法将任何内置类型,如整数或浮点数,转换为字符串。这实际上是System.Object类型的虚拟方法,即任何.NET 类型的基类。通过重写此方法,任何类型都可以提供一种将对象序列化为字符串的方法:

int i = 42;
double d = 19.99;
var s1 = i.ToString();
var s2 = d.ToString();

字符串可以以几种方式组成:

  • 可以使用连接运算符+来完成。

  • 使用Format()方法:此方法的第一个参数是格式,在其中每个参数都用花括号中指定的索引位置表示,例如{0}{1}{2}等。指定超出参数数量的索引会导致运行时异常。

  • 使用字符串插值,这实际上是使用String.Format()方法的一种语法快捷方式:字符串必须以$为前缀,并且参数直接在花括号中指定。

这里显示了所有这些方法的示例:

int i = 42;
string s1 = "This is item " + i.ToString();
string s2 = string.Format("This is item {0}", i);
string s3 = $"This is item {i}";

一些字符具有特殊含义,并以反斜杠(\)为前缀。这些称为转义序列。以下表列出了所有这些转义序列:

在某些情况下,需要使用转义序列,例如当指定 Windows 文件路径或需要生成多行文本时。以下代码显示了使用转义序列的几个示例:

var s1 = "c:\\Program Files (x86)\\Windows Kits\\";
var s2 = "That was called a \"demo\"";
var s3 = "This text\nspawns multiple lines.";

但是,您可以通过使用逐字字符串来避免使用转义序列。这些字符串以@符号为前缀。当编译器遇到这样的字符串时,它不会解释转义序列。如果要在使用逐字字符串时在字符串中使用引号,必须将其加倍。以下示例显示了使用逐字字符串重写的前面的示例:

var s1 = @"c:\Program Files (x86)\Windows Kits\";
var s2 = @"That was called a ""demo""";
var s3 = @"This text
spawns multiple lines.";

在 C# 8 之前,如果要在逐字字符串中使用字符串插值,必须首先为字符串插值指定$符号,然后为逐字字符串指定@。在 C# 8 中,您可以以任何顺序指定这两个符号。

对象类型

object类型是 C#中所有其他类型的基本类型,即使您没有明确指定,我们将在接下来的章节中看到。C#中的object关键字是.NETSystem.Object类型的别名。您可以互换使用这两个。

object类型以几种虚拟方法的形式为所有其他类提供一些基本功能,任何派生类都可以覆盖这些方法,如果有必要的话。这些方法列在下表中:

除此之外,object类包含几个其他方法。一个重要的方法是GetType()方法,它不是虚拟的,并返回一个System.Type对象,其中包含有关当前实例类型的信息。

另一个重要的事情要注意的是Equals()方法的工作方式,因为它对于引用类型和值类型的行为是不同的。我们还没有涵盖这些概念,但稍后在本章中会详细介绍。暂时要记住的是,对于引用类型,这个方法执行引用相等性;这意味着它检查两个变量是否指向堆上的同一个对象。对于值类型,它执行值相等性;这意味着两个变量是相同类型,并且两个对象的公共和私有字段是相等的。

object类型是一个引用类型。object类型的变量的默认值是null。然而,object类型的变量可以被赋予任何类型的任何值。当你将值类型的值赋给object时,这个操作被称为拆箱。这将在本章的后面部分详细介绍。

你将在本书中更多地了解object类型及其方法。

变量

变量被定义为一个命名的内存位置,可以赋予一个值。有几种类型的变量,包括以下几种:

  • 局部变量:这些是在方法内部定义的变量,它们的作用域局限于该方法。

  • 方法参数:这些是在函数调用期间传递给方法的参数。

  • 类字段:这些是在类范围内定义的变量,可以被所有类方法访问,并取决于字段对其他类的可访问性。

  • 数组元素:这些是指向数组中元素的变量。

在本节中,我们将提到局部变量,这些变量是在函数体中声明的。这些变量使用以下语法声明:

datatype variable_name;

在这个语句中,datatype是变量的数据类型,variable_name是变量的名称。以下是几个例子:

bool f;
char ch = 'x';
int a, b = 20, c = 42;
a = -1;
f = true;

在这个例子中,f是一个未初始化的bool变量。未初始化的变量不能在任何表达式中使用。这样做将导致编译器错误。所有变量在使用之前必须初始化。变量可以在声明时初始化,比如前面例子中的chbc,也可以在以后的任何时间初始化,比如af

相同类型的多个变量可以在单个语句中声明和初始化,用逗号分隔。在前面的代码片段中,int变量abc就是一个例子。

命名约定

有几条规则必须遵循以命名变量:

  • 变量名只能由字母、数字和下划线字符(_)组成。

  • 在命名变量时,不能使用除下划线(_)之外的任何特殊字符。因此,@sample#tag、*name%*等都是非法的变量名。

  • 变量名必须以字母或下划线字符(_)开头。变量的名称不能以数字开头。因此,2small作为变量名将会引发编译时错误。

  • 变量名区分大小写。因此,personPERSON被视为两个不同的变量。

  • 变量名不能是 C#的任何保留关键字。因此,truefalsedoublefloatvar等都是非法的变量名。然而,使用@前缀使编译器将它们视为标识符而不是关键字。因此,像@true@return@var这样的变量名是允许的。这些被称为逐字标识符

  • 除了在命名变量时必须遵循的语言规则外,你还应该确保所选择的名称具有描述性且易于理解。你应该始终优先选择这种名称,而不是难以理解的缩写名称。有各种编码规范和命名约定,你应该遵循其中的一种。这有助于保持一致性,并使代码更易于阅读、理解和维护。

在命名约定方面,编写 C#代码时应该遵循以下规则:

  • 对于类、结构、枚举、委托、构造函数、方法、属性和常量,使用帕斯卡命名法。在帕斯卡命名法中,名称中的每个单词都首字母大写;例如ConnectionStringUserGroupXmlReader

  • 对于字段、局部变量和方法参数,使用驼峰命名法。在驼峰命名法中,名称的第一个单词不大写,但其他单词都大写;例如userIdxmlDocumentuiControl

  • 除了用于私有字段前缀的情况外,不要在标识符中使用下划线,例如_firstName_lastName

  • 优先选择描述性名称而不是缩写。例如,优先选择labelText而不是lbltxtemployeeId而不是eid

你可以通过查阅其他资源了解更多关于 C#编码规范和命名约定的信息。

隐式类型的变量

正如我们在之前的例子中看到的,当我们声明变量时,需要指定变量的类型。然而,C#还提供了另一种声明变量的方式,允许编译器根据初始化时赋予的值推断变量的类型。这些被称为隐式类型的变量

我们可以使用var关键字创建一个隐式类型的变量。这种变量必须在声明时进行初始化,因为编译器会根据初始化的值推断变量的类型。以下是一个例子:

var a = 10;

由于a变量被初始化为整数字面量,编译器将a视为int类型的变量。

在使用var声明变量时,你必须牢记以下事项:

  • 隐式类型的变量必须在声明时初始化一个值,否则编译器无法推断变量类型,会导致编译时错误。

  • 你不能将其初始化为 null。

  • 变量类型一旦声明并初始化后就不能更改。

信息框

var关键字不是一种数据类型,而是实际类型的占位符。在声明变量时使用var是有用的,当类型名称很长并且你想避免输入大量内容时(例如Dictionary<string, KeyValuePair<int, string>>),或者你只关心值而不关心实际类型时。

现在你已经学会了如何声明变量,让我们来看一个关键概念:变量的作用域。

理解变量的作用域和生命周期

在 C#中,作用域被定义为在开放大括号和对应的闭合大括号之间的代码块。作用域定义了变量的可见性和生命周期。变量只能在其定义的作用域内访问。在特定作用域中定义的变量对该作用域外的代码不可见。

让我们通过一个例子来理解这一点:

class Program
{
    static void Main(string[] args)
    {
        for (int i = 1; i < 10; i++)
        {
            Console.WriteLine(i);
        }
        i = 20; // i is out of scope
    }
}

在这个例子中,i变量是在for循环内部定义的,因此一旦控制流退出循环,它就超出了作用域,无法在for循环外部访问。你将在下一章学习更多关于for循环的知识。

我们也可以有嵌套作用域。这意味着在一个作用域中定义的变量可以在包含在该作用域内的另一个作用域中访问。然而,外部作用域的变量对内部作用域可见,但内部作用域的变量在外部作用域中不可访问。C#编译器不允许在一个作用域内创建两个同名的变量。

让我们扩展前面例子中的代码来理解这一点:

class Program
{
    static void Main(string[] args)
    {
        int a = 5;
        for (int i = 1; i < 10; i++)
        {
            char a = 'w';                 // compiler error
            if (i % 2 == 0)
            {
                Console.WriteLine(i + a); // a is within the 
                                          // scope of Main
            }
        }
        i = 20;                           // i is out of scope
    }
}

在这里,整数变量afor循环之外定义,但在Main的作用域内。因此,它可以在for循环内部访问,因为它在此作用域内。然而,在for循环内部定义的i变量无法在Main的作用域内访问。

如果我们尝试在作用域内声明另一个同名变量,将会得到一个编译时错误。因此,我们不能在for循环内部声明字符变量a,因为我们已经有一个同名的整数变量。

理解常量

有一些情况下,我们不希望在初始化后改变变量的值。例如数学常数(π,欧拉数等),物理常数(阿伏伽德罗常数,玻尔兹曼常数等),或任何应用程序特定的常数(最大允许的登录次数,失败操作的最大重试次数,状态码等)。C#为我们提供了常量变量来实现这一目的。一旦定义,常量变量的值在其作用域内不能被改变。如果尝试在初始化后改变常量变量的值,编译器将抛出错误。

要使变量成为常量,我们需要在前面加上const关键字。常量变量必须在声明时初始化。下面是一个初始化为42的整数常量的例子:

const int a = 42;

重要的是要注意,只有内置类型可以用来声明常量。用户定义的类型不能用于此目的。

引用类型和值类型

C#中的数据类型分为值类型和引用类型。这两者之间有一些重要的区别,比如复制语义。我们将在接下来的章节中详细讨论这些区别。

值类型

值类型的变量直接包含值。当从另一个值类型变量赋值时,存储的值会被复制。我们之前看到的原始数据类型都是值类型。所有使用struct关键字声明的用户定义类型都是值类型。尽管所有类型都是隐式从object派生的,值类型不支持显式继承,这是第四章中讨论的一个主题,理解各种用户定义类型

让我们在这里看一个例子:

int a = 20;
DateTime dt = new DateTime(2019, 12, 25);

值类型通常存储在内存中的堆栈上,尽管这是一个实现细节,而不是值类型的特征。如果将值类型的值赋给另一个变量,那么该值将被复制到新变量中,改变一个变量不会影响另一个变量:

int a = 20;
int b = a;  // b is 20
a = 42;     // a is 42, b is 20

在前面的例子中,a的值被初始化为20,然后赋给变量b。此时,两个变量都包含相同的值。然而,在将值42赋给a变量后,b的值保持不变。这在下面的图表中概念上显示出来:

图 2.1 - 在执行前面代码时堆栈中的变化的概念表示

图 2.1 - 在执行前面代码时堆栈中的变化的概念表示

在这里,你可以看到,最初在堆栈上分配了一个对应于整数a的存储位置,并且其值为 20。然后,分配了第二个存储位置,并将第一个的值复制到了第二个存储位置。然后,我们改变了a变量的值,因此第一个存储位置中的值也改变了。第二个存储位置保持不变。

引用类型

引用类型的变量不直接包含值,而是包含对存储实际值的内存位置的引用。内置数据类型objectstring都是引用类型。数组、接口、委托和任何定义为类的用户定义类型也被称为引用类型。以下示例显示了几个不同引用类型的变量:

int[]  a = new int[10];
string s = "sample";
object o = new List<int>();

引用类型存储在堆上。引用类型的变量可以被分配null值,表示变量不存储对对象实例的引用。当尝试使用分配了null值的变量时,结果是运行时异常。当引用类型的变量被分配一个值时,复制的是对象的实际内存位置的引用,而不是对象本身的值。

在下面的例子中,a1是一个包含两个整数的数组。数组的引用被复制到变量a2中。当数组的内容发生变化时,通过a1a2都可以看到这些变化,因为这两个变量都指向同一个数组:

int[] a1 = new int[] { 42, 43 };
int[] a2 = a1;   // a2 is { 42, 43 }
a1[0] = 0;       // a1 is { 0, 43 }, a2 is { 0, 43 }

这个例子在下面的图中以概念方式解释:

图 2.2 - 在上述片段执行期间堆栈和堆的概念表示

图 2.2 - 在上述片段执行期间堆栈和堆的概念表示

您可以在此图中看到,a1a2是堆上分配的相同整数数组的堆栈上的变量。当通过a1变量更改数组的第一个元素时,这些更改会自动显示在a2变量上,因为a1a2指向同一个对象。

尽管string类型是引用类型,但它似乎表现不同。看下面的例子:

string s1 = "help";
string s2 = s1;     // s2 is "help"
s1 = "demo";        // s1 is "demo", s2 is "help"

在这里,s1"help"字面量初始化,然后将实际数组堆对象的引用复制到变量s2中。此时,它们都指向"help"字符串。然而,稍后s1被分配一个新的字符串"demo"。此时,s2将继续指向"help"字符串。原因是字符串是不可变的。这意味着当您修改一个字符串对象时,将创建一个新的字符串,并且变量将接收对新字符串对象的引用。任何其他引用旧字符串的变量将继续这样做。

装箱和拆箱

我们在本章前面简要提到了装箱和拆箱,当我们谈到object类型时。装箱是将值类型存储在object中的过程,而拆箱是将object的值转换为值类型的相反操作。让我们通过一个例子来理解这一点:

int a = 42;
object o = a;   // boxing
o = 43;
int b = (int)o; // unboxing
Console.WriteLine(x);  // 42
Console.WriteLine(y);  // 43

在上述代码中,a是一个初始化为值42的整数类型的变量。作为值类型,整数值42存储在堆栈上。另一方面,o是一个object类型的变量。这是一个引用类型。这意味着它只包含对存储实际对象的堆内存位置的引用。因此,当a分配给o时,发生了称为装箱的过程。

在堆栈上分配一个对象,将a的值(即42)复制到该对象中,然后将对该对象的引用分配给变量o。当我们稍后将值43分配给o时,只有装箱对象发生变化,而a没有发生变化。最后,我们将由o引用的对象的值复制到一个名为b的新变量中。这将具有值43,并且作为int也存储在堆栈上。

这里描述的过程在下面的图中以图形方式显示:

图 2.3 - 显示先前描述的装箱和拆箱过程的堆栈的概念表示

图 2.3 - 显示先前描述的装箱和拆箱过程的堆栈的概念表示

现在您了解了值类型和引用类型之间的区别,让我们来看看可空类型的主题。

可空类型

引用类型的默认值是null,表示变量未分配给任何对象的实例。值类型没有这样的选项。但是,有些情况下,对于值类型来说,没有值也是有效的值。为了表示这样的情况,可以使用可空类型。

System.Nullable<T>是一个泛型值类型,可以表示基础T类型的值,该类型只能是值类型,还可以表示额外的null值。以下示例展示了一些示例:

Nullable<int> a;
Nullable<int> b = null;
Nullable<int> c = 42;

您可以使用简写语法T?来代替Nullable<T>;这两者是可以互换的。以下示例是前面示例的替代方案:

int? a;
int? b = null;
int? c = 42;

您可以使用HasValue属性来检查可空类型对象是否有值,使用Value来访问基础值:

if (c.HasValue)
    Console.WriteLine(c.Value);

以下是一些可空类型的特征列表:

  • 您可以像为基础类型赋值一样为可空类型对象赋值。

  • 您可以使用GetValueOrDefault()方法来获取已分配的值或基础类型的默认值(如果没有分配值)。

  • 装箱是在基础类型上执行的。如果可空类型对象没有分配任何值,装箱的结果是一个null对象。

  • 您可以使用空值合并运算符??来访问可空类型对象的值(例如,int d = c ?? -1;)。

在 C# 8 中,引入了可空引用类型和非可空引用类型。这是一个您必须在项目属性中选择的功能。它允许您确保只有声明为可空的引用类型对象,使用T?语法可以被赋予null值。尝试在非可空引用类型上这样做将导致编译器警告(不是错误,因为这可能会影响大量现有代码的部分):

string? s1 = null; // OK, nullable type
string s2 = null;  // error, non-nullable type

您将在第十五章“C# 8 的新特性”中了解更多关于可空引用类型的内容。

数组

数组是一种数据结构,可以容纳相同数据类型的多个值(包括零个或一个)。它是一系列同类元素的固定大小序列,存储在连续的内存位置中。C#中的数组是从零开始索引的,意味着数组的第一个元素的位置是零,最后一个元素的位置是元素总数减一。

数组类型是引用类型,因此数组是在堆上分配的。数值数组的元素的默认值是零,引用类型数组的默认值是null。数组的元素类型可以是任何类型,包括另一个数组类型。

C#中的数组可以是一维的、多维的或交错的。让我们详细探讨这些。

一维数组

可以使用语法datatype[] variable_name来定义一维数组。数组可以在声明时初始化。如果数组变量没有初始化,它的值为null。您可以在初始化时指定数组的元素数量,也可以跳过这一步,让编译器从初始化表达式中推断出来。以下示例展示了声明和初始化数组的各种方式:

int[] arr1;
int[] arr2 = null;
int[] arr3 = new int[6];
int[] arr4 = new int[] { 1, 1, 2, 3, 5, 8 };
int[] arr5 = new int[6] { 1, 1, 2, 3, 5, 8 };
int[] arr6 = { 1, 1, 2, 3, 5, 8 };

在这个例子中,arr1arr2的值为nullarr3是一个包含六个整数元素的数组,因为没有提供初始化,所以所有元素都被设置为0arr4arr5arr6是包含相同值的六个整数的数组。

初始化后,数组的大小不能改变。如果需要改变,必须创建一个新的数组对象,或者使用可变大小的容器,比如List<T>,我们将在第七章“集合”中讨论。

您可以使用索引器或枚举器访问数组的元素。以下代码片段是等价的:

for(int i = 0; i < arr6.Length; ++i)
 Console.WriteLine(arr6[i]);
foreach(int element in arr6)
 Console.WriteLine(element);

尽管这两个循环的效果是相同的,但有一个细微的区别——使用枚举器不允许修改数组的元素。使用索引运算符按索引访问元素确实提供了对元素的写访问权限。使用枚举器是可能的,因为数组类型隐式地从基本类型System.Array派生,该类型实现了IEnumerableIEnumerable<T>

这在以下示例中显示:

for (int i = 0; i < arr6.Length; ++i)
   arr6[i] *= 2;  // OK
foreach (int element in arr6)
   element *= 2;  // error

在第一个循环中,我们通过它们的索引访问数组的元素并且可以修改它们。然而,在第二个循环中,使用了一个迭代器,这提供了对元素的只读访问。试图修改它们会产生编译时错误。

多维数组

多维数组是具有多个维度的数组。它也被称为矩形数组。这可以是一个二维数组(矩阵)或一个三维数组(立方体),最大维数为32

可以使用以下语法定义二维数组:datatype[,] variable_name;。多维数组的声明和初始化方式与单维数组类似。您可以指定每个维度的秩(即元素的数量),也可以让编译器从初始化表达式中推断出来。以下代码片段显示了声明和初始化二维数组的不同方式:

int[,] arr1;
arr1 = new int[2, 3] { { 1, 2, 3 }, { 4, 5, 6 } };
int[,] arr2 = null;
int[,] arr3 = new int[2,3];
int[,] arr4 = new int[,] { { 1, 2, 3 }, { 4, 5, 6 } };
int[,] arr5 = new int[2,3] { { 1, 2, 3 }, { 4, 5, 6 } };
int[,] arr6 = { { 1, 2, 3 }, { 4, 5, 6 } };

在这个例子中,arr1最初是null,然后被赋予一个包含两行三列的数组的引用。同样,arr2也是null。另一方面,arr3arr4arr5arr6都是包含两行三列的数组;arr3的所有元素都设置为零,而其他元素则使用指定的值进行初始化。这个例子中的数组具有以下形式:

1 2 3
4 5 6

您可以使用GetLength()GetLongLength()方法检索每个维度的元素数量(第一个返回 32 位整数,第二个返回 64 位整数)。以下示例将arr6数组的内容打印到控制台:

for (int i = 0; i < arr6.GetLength(0); ++i)
{
   for (int j = 0; j < arr6.GetLength(1); ++j)
   {
      Console.Write($"{arr6[i, j]} ");
   }
   Console.WriteLine();
}

超过两个维度的数组以类似的方式创建和处理。以下示例显示了如何声明和初始化一个4 x 3 x 2元素的三维数组:

int[,,] arr7 = new int[4, 3, 2]
{
    { { 11, 12}, { 13, 14}, {15, 16 } },
    { { 21, 22}, { 23, 24}, {25, 26 } },
    { { 31, 32}, { 33, 34}, {35, 36 } },
    { { 41, 42}, { 43, 44}, {45, 46 } }
};

另一种多维数组的形式是所谓的不规则数组。我们将在下面学习这个。

不规则数组

不规则数组是数组的数组。这些包含其他数组,不规则数组中的每个数组的大小可以不同。例如,我们可以使用语法datatype [][] variable_name;声明一个二维不规则数组。以下代码片段显示了声明和初始化不规则数组的各种示例:

int[][] arr1;
int[][] arr2 = null;
int[][] arr3 = new int[2][];
arr3[0] = new int[3];
arr3[1] = new int[] { 1, 1, 2, 3, 5, 8 };
int[][] arr4 = new int[][]
{
   new int[] { 1, 2, 3 },
   new int[] { 1, 1, 2, 3, 5, 8 }
};
int[][] arr5 =
{
   new int[] { 1, 2, 3 },
   new int[] { 1, 1, 2, 3, 5, 8 }
};
int[][,] arr6 = new int[][,]
{
    new int[,] { { 1, 2}, { 3, 4 } },
    new int[,] { {11, 12, 13}, { 14, 15, 16} }
};

在这个例子中,arr1arr2都被设置为null。另一方面,arr3是一个包含两个数组的数组。它的第一个元素被设置为一个包含三个初始化为零的元素的数组;它的第二个元素被设置为一个包含从提供的值初始化的六个元素的数组。

arr4arr5数组是等价的,但arr5使用了数组初始化的简写语法。arr6混合了不规则数组和多维数组。它是一个包含两个数组的数组,第一个数组是一个2x2的二维数组,第二个数组是一个2x3元素的二维数组。

可以使用arr[i][j]语法访问不规则数组的元素(此示例适用于二维数组)。以下代码片段显示了如何打印先前显示的arr5数组的内容:

for(int i = 0; i < arr5.Length; ++i)
{
   for(int j = 0; j < arr5[i].Length; ++j)
   {
      Console.Write($"{arr5[i][j]} ");
   }
   Console.WriteLine();
}

现在我们已经看过了在 C#中可以使用的数组类型,让我们转移到另一个重要的主题,即各种数据类型之间的转换。

类型转换

有时我们需要将一种数据类型转换为另一种数据类型,这就是类型转换的作用。类型转换可以分为几类:

  • 隐式类型转换

  • 显式类型转换

  • 用户定义的转换

  • 使用辅助类进行转换

让我们详细探讨这些内容。

隐式类型转换

对于内置的数字类型,当我们将一个变量的值赋给另一个数据类型时,如果两种类型兼容且目标类型的范围大于源类型的范围,则会发生隐式类型转换。例如,intfloat是兼容的类型。因此,我们可以将整数变量赋给float类型的变量。同样,double类型足够大,可以容纳任何其他数字类型的值,包括longfloat,如下例所示:

int i = 10;
float f = i;
long l = 7195467872;
double d = l;

以下表格显示了 C#中数字类型之间的隐式类型转换:

隐式数字转换有几点需要注意:

  • 您可以将任何整数类型转换为任何浮点类型。

  • charbytesbyte类型之间没有隐式转换。

  • doubledecimal之间没有隐式转换;这包括从decimaldoublefloat的隐式转换。

对于引用类型,类和其直接或间接基类或接口之间始终可以进行隐式转换。以下是一个从stringobject的隐式转换的示例:

string s = "example";
object o = s;

object类型(它是System.Object的别名)是所有.NET 类型的基类,包括string(它是System.String的别名)。因此,存在从stringobject的隐式转换。

显式类型转换

当两种类型之间无法进行隐式转换,因为存在丢失信息的风险(例如将 32 位整数的值赋给 16 位整数时),就需要进行显式类型转换。显式类型转换也称为强制转换。要执行强制转换,我们需要在源变量前面的括号中指定目标数据类型。

例如,doubleint不兼容的类型。因此,我们需要在它们之间进行显式类型转换。在下面的例子中,我们使用显式类型转换将double值(d)赋给整数。但是,在进行此转换时,double变量的小数部分将被截断。因此,i的值将为12

double d = 12.34;
int i = (int)d;

以下表格显示了 C#中数字类型之间的预定义显式转换列表:

有几点需要注意关于显式数字转换:

  • 显式转换可能导致精度丢失或抛出异常,例如OverflowException

  • 当从一个整数类型转换为另一个整数类型时,结果取决于所谓的checked 上下文,可能会导致成功转换,可能会丢弃额外的最高有效字节,也可能会导致溢出异常。

  • 当将浮点类型转换为整数类型时,值将向零舍入到最接近的整数值。但是,该操作也可能导致溢出异常。

C#语句可以在checkedunchecked上下文中执行,可以使用checkunchecked关键字或编译器选项-checked来控制。当没有指定这些选项时,对于非常量表达式,上下文被视为未经检查。对于可以在编译时计算的常量表达式,默认上下文始终为 checked。在 checked 上下文中,对于整数类型的算术操作和转换启用了溢出检查。在 unchecked 上下文中,这些检查被抑制。当启用溢出检查并发生溢出时,运行时会抛出System.OverflowException异常。

对于引用类型,在想要从基类或接口转换为派生类时,需要进行显式转换。以下示例显示了从objectstring值的转换:

string s = "example";
object o = s;          // implicit conversion
string r = (string)o;  // explicit conversion

stringobject的转换是隐式进行的。然而,相反的情况需要在(string)o形式中进行显式转换,如前面的代码片段所示。

用户定义的类型转换

用户定义的转换可以定义从一种类型到另一种类型的隐式转换或显式转换,或者两者都定义。定义这些转换的类型必须是类型或目标类型之一。为此,您必须使用operator关键字,后面跟隐式或显式。以下示例显示了一个名为fancyint的类型,它定义了从intint的隐式和显式转换:

public readonly struct fancyint
{
    private readonly int value;
    public fancyint(int value)
    {
        this.value = value;
    }
    public static implicit operator int(fancyint v) => v.value;
    public static explicit operator fancyint(int v) => new fancyint(v);
    public override string ToString() => $"{value}";
}

您可以如下使用这种类型:

fancyint a = new fancyint(42);
int i = a;                 // implicit conversion
fancyint b = (fancyint)i;  // explicit conversion

在这个例子中,afancyint类型的对象。a的值可以隐式转换为int,因为定义了隐式转换运算符。然而,从intfancyint的转换被定义为显式,因此需要进行转换,如(fancyint)i

使用辅助类进行转换

使用辅助类或方法进行转换对于在不兼容类型之间进行转换非常有用,比如在字符串和整数之间或System.DateTime对象之间。框架提供了各种辅助类,如System.BitConverter类,System.Convert类以及内置数值类型的Parse()TryParse()方法。但是,您可以提供自己的类和方法来在任何类型之间进行转换。

以下清单显示了使用辅助类进行转换的几个示例:

DateTime dt1 = DateTime.Parse("2019.08.31");
DateTime.TryParse("2019.08.31", out DateTime dt2);
int i1 = int.Parse("42");          // successful, i1 = 42
int i2 = int.Parse("42.15");       // error, throws exception
int.TryParse("42.15", out int i3); // error, returns false, 
                                   // i3 = 0

重要的是要注意Parse()TryParse()之间的关键区别。前者尝试执行解析,如果成功,则返回解析后的值;但如果失败,则会抛出异常。后者不会抛出异常,而是返回bool,指示成功或失败,并将第二个out参数设置为解析成功时的值,或者在失败时设置为默认值。

运算符

C#为内置类型提供了广泛的运算符集。运算符在以下类别中广泛分类:算术、关系、逻辑、位、赋值和其他运算符。一些运算符可以被重载为用户定义的类型。这个主题将在第五章C#面向对象编程中进一步讨论。

在评估表达式时,运算符优先级和结合性确定了操作的执行顺序。您可以通过使用括号来改变这个顺序,就像您在数学表达式中所做的那样。

以下表格列出了具有最高优先级的运算符在顶部,最低优先级在底部的顺序。在同一行上列出的运算符具有相同的优先级:

对于具有相同优先级的运算符,结合性决定了首先计算哪个。有两种类型的结合性:

  • 左结合性:这确定了运算符从左到右进行计算。除了赋值运算符和空值合并运算符之外,所有二元运算符都是左结合的。

  • 右结合性:这确定了运算符从右到左进行计算。赋值运算符、空值合并运算符和条件运算符都是右结合的。

在接下来的几节中,我们将更详细地研究每个运算符类别。

算术运算符

算术运算符对数字类型执行算术运算,并且可以是一元或二元运算符。一元运算符有一个操作数,而二元运算符有两个操作数。在 C#中定义了以下一组算术运算符:

+-*将按照加法,减法和乘法的数学规则工作。但是,/操作符的行为有点不同。当应用于整数时,它将截断除法的余数。例如,20/3 将返回 6。要获得余数,我们需要使用模运算符。例如,20%3 将返回 2。

在这些中,递增和递减操作符需要特别注意。这些操作符有两种形式:

  • 后缀形式

  • 前缀形式

递增操作符将增加其操作数的值1,而递减操作符将减少其操作数的值1。在以下示例中,a变量最初为10,但应用递增操作符后,其值将为11

int a = 10;
a++;

前缀和后缀变体在以下方面不同:

  • 前缀操作符首先执行操作,然后返回值。

  • 后缀运算符首先保留值,然后递增它,然后返回原始值。

让我们通过以下代码片段来理解这一点。在以下示例中,a10。当a++赋值给b时,b取值10a递增为11

int a = 10;
int b = a++;

然而,如果我们将++a赋值给b,那么a将递增为11,并且该值将被赋给b,因此ab都将具有值11

int a = 10;
int b = ++a;

我们将要学习的下一个操作符类别是关系操作符。

关系操作符

关系操作符,也称为比较操作符,对其操作数执行比较。C#定义了以下一组关系操作符:

关系操作符的结果是bool值。这些操作符支持所有内置的数值和浮点类型。但是,枚举类型也支持这些操作符。对于相同枚举类型的操作数,将比较基础整数类型的相应值。枚举将在稍后讨论第四章理解各种用户定义类型中。

下一个代码清单显示了几个关系操作符的使用:

int a = 42;
int b = 10;
bool v1 = a != b;
bool v2 = 0 <= a && a <= 100;
if(a == 42) { /* ... */ }

<><=>=操作符可以为用户定义的类型进行重载。但是,如果类型重载了<>,它必须同时重载两者。同样,如果类型重载了<=>=,它必须同时重载两者。

逻辑操作符

逻辑操作符对bool操作数执行逻辑操作。C#中定义了以下一组逻辑操作符:

以下示例显示了这些操作数的使用:

bool a = true, b = false;
bool c = a && b;
bool d = a || !b;

在这个例子中,由于atruebfalsec将为falsed将为true

按位和移位操作符

按位操作符将直接在其操作数的位上工作。按位操作符只能与整数操作数一起使用。以下表格列出了所有按位和移位操作符:

在以下示例中,a10,在二进制中为1010b5,在二进制中为0101。按位 AND 的结果是0000,因此c将具有值0,按位 OR 的结果是1111,因此d将具有值15

int a = 10;    // 1010
int b = 5;     // 0101
int c = a & b; // 0000
int d = a | b; // 1111

左移运算符将左操作数向左移动右操作数定义的位数。类似地,右移运算符将左操作数向右移动右操作数定义的位数。左移运算符丢弃超出结果类型范围的高阶位,并将低阶位设置为零。右移运算符丢弃低阶位,并将高阶位设置如下:

  • 如果被移位的值是intlong,则执行算术移位。这意味着符号位在高阶空位上向右传播。因此,对于正数,高阶位设置为零(因为符号位为0),对于负数,高阶位设置为 1(因为符号位为1)。

  • 如果被移位的值是uintulong,则执行逻辑移位。在这种情况下,高阶位始终设置为0

移位操作仅对intuintlongulong定义。如果左操作数是另一种整数类型,则在应用操作之前将其转换为int。移位操作的结果将始终包含至少 32 位。

以下清单显示了移位操作的示例:

// left-shifting
int x = 0b_0000_0110;
x = x << 4;  // 0b_0110_0000
uint y = 0b_1111_0000_0000_0000_1111_1110_1100_1000;
y = y << 2;  // 0b_1100_0000_0000_0011_1111_1011_0010_0000;
// right-shifting
int x = 0b_0000_0000;
x = x >> 4;  // 0b_0110_0000
uint y = 0b_1111_0000_0000_0000_1111_1110_1100_1000;
y = y >> 2;  // 0b_0011_1100_0000_0000_0011_1111_1011_0010;

在这个例子中,我们使用二进制字面量初始化了xy变量,以便更容易理解移位的工作原理。移位后变量的值也以二进制形式显示在注释中。

赋值运算符

赋值运算符根据其右操作数的值将一个值分配给其左操作数。C#中提供了以下赋值运算符:

在这个表中,我们有简单的赋值运算符(=),它将右操作数的值分配给左操作数,然后我们有复合赋值运算符,它首先执行一个操作(算术、移位或位运算),然后将操作的结果分配给左操作数。因此,诸如a = a + 2a += 2的操作是等价的。

其他运算符

除了迄今为止讨论的运算符外,C#中还有其他对内置类型和用户定义类型都适用的有用运算符。这些包括条件运算符、空值条件运算符、空值合并运算符和空值合并赋值运算符。我们将在接下来的页面中介绍这些运算符。

三元条件运算符

?:通常简称为条件运算符。它允许您根据布尔条件的评估结果返回两个可用选项中的一个值。

三元运算符的语法如下:

condition ? consequent : alternative;

如果布尔条件评估为true,则将评估consequent表达式并返回其结果。否则,将评估alternative表达式并返回其结果。三元条件运算符也可以被视为if-else语句的简写。

在下面的例子中,名为max()的函数返回两个整数中的最大值。条件运算符用于检查a是否大于或等于b,在这种情况下返回a的值;否则,结果是b的值:

static int max(int a, int b)
{
   return a >= b ? a : b;
}

还有另一种形式的这个运算符叫做条件 ref 表达式(自 C# 7.2 起可用),它允许返回对两个表达式中的一个结果的引用。在这种情况下,语法如下:

condition ? ref consequent : ref alternative;

结果引用可以分配给ref本地变量或ref只读本地变量,并将其用作引用返回值或作为 ref 方法参数。条件ref表达式要求consequentalternative的类型相同。

在下面的例子中,条件ref表达式用于根据用户输入在两个选择项之间进行选择。如果输入的是偶数,则v变量将保存对a的引用;否则,它将保存对b的引用。增加v的值,然后将ab打印到控制台:

int a = 42;
int b = 21;
int.TryParse(Console.ReadLine(), out int alt);
ref int v = ref (alt % 2 == 0 ? ref a : ref b);
v++;
Console.WriteLine($"a={a}, b={b}");

虽然条件运算符检查条件是否为真,但空值条件运算符检查操作数是否为 null。我们将在下一节中介绍这个运算符。

空值条件运算符

?.(也称为?[]用于数组的元素访问)。这些运算符仅在其操作数不为null时才应用操作。否则,应用运算符的结果也为null

下面的示例展示了如何使用空合并运算符来调用名为run()的方法,通过一个可能为null的类foo的实例,通过?.的操作数是null,那么其评估结果也是null

class foo
{
    public int run() { return 42; }
}
foo f = null;
int? i = f?.run()

空合并运算符可以链接在一起。但是,如果链中的一个运算符求值为null,则链的其余部分将被短路,不进行求值。

在下面的示例中,bar类具有foo类型的属性。创建了一个bar对象数组,并尝试从数组中的第一个bar元素的f属性的run()方法的执行中检索值:

class bar
{
    public foo f { get; set; }
}
bar[] bars = new bar[] { null };
int? i = bars[0]?.f?.run();

如果我们将空合并运算符与空合并赋值运算符结合起来,并在空合并运算符返回null时提供默认值,就可以避免使用可空类型。下面是一个示例:

int i = bars[0]?.f?.run() ?? -1;

空合并运算符在下一节中讨论。

空合并和空合并赋值运算符

??如果左操作数不为null,则返回左操作数;否则,将对右操作数进行求值并返回其结果。左操作数不能是非可空值类型。只有在左操作数为null时才会对右操作数进行求值。

??=是 C# 8 中新增的一个新操作符。如果左操作数求值为null,则将其右操作数的值赋给左操作数。如果左操作数不为null,则不会对右操作数进行求值。

????=都是右关联的。这意味着表达式a ?? b ?? c将被解释为a ?? (b ?? c)。同样,表达式a ??= b ??= c将被解释为a ??= (b ??= c)

看一下下面的代码片段:

int? n1 = null;
int n2 = n1 ?? 2;  // n2 is set to 2
n1 = 5;
int n3 = n1 ?? 2;  // n3 is set to 5

我们定义了一个可空变量n1,并将其初始化为null。由于n1null,因此n2的值将被设置为2。在给n1赋予非空值后,我们将在n1和整数2上应用条件运算符。在这种情况下,由于n1不为null,因此n3的值将与n1的值相同。

空合并运算符可以在表达式中多次使用。在下面的示例中,GetDisplayName()函数返回name的值(如果不为null),否则返回email的值(如果不为null);如果email也为null,则返回"unknown"

string GetDisplayName(string name, string email)
{
    return name ?? email ?? "unknown";
}

空合并运算符也可以用于参数检查。如果期望参数为非空,但实际上为null,则可以从右操作数抛出异常。下面是一个示例:

class foo
{
   readonly string text;
   public foo(string value)
   {
      text = value ?? throw new
        ArgumentNullException(nameof(value));
   }
}

空合并赋值运算符在替换检查变量是否为null的代码时非常有用,可以用更简洁的形式来实现。基本上,??=运算符是以下代码的语法糖:

if(a is null)
   a = b;

可以用a ??= b来替换。

总结

在本章中,我们学习了 C#中的内置数据类型,包括数值类型、浮点类型、布尔和字符类型、字符串和对象。此外,我们还涵盖了可空类型和数组类型。我们学习了变量和常量,并查看了值类型和引用类型之间的区别。除此之外,我们还涵盖了类型转换和强制转换的概念。在本章的最后,我们学习了 C#中可用的各种类型的运算符。

在下一章中,我们将探讨 C#中的控制语句和异常。

测试你所学到的知识

  1. C#中的整数内置类型有哪些?

  2. 浮点类型和decimal类型之间有什么区别?

  3. 如何连接字符串?

  4. 转义序列是什么,它们与逐字字符串有什么关系?

  5. 隐式类型变量是什么?这些变量可以用null初始化吗?

  6. 什么是值类型?什么是引用类型?它们之间的主要区别是什么?

  7. 什么是装箱和拆箱?

  8. 什么是可空类型,如何声明可空整数变量?

  9. 有多少种类型的数组存在,它们之间有什么区别?

  10. 有哪些可用的类型转换,如何提供用户定义的类型转换?

第三章:控制语句和异常

在上一章中,我们讨论了 C#中的数据类型和运算符。在本章中,我们将探讨 C#中的控制语句。控制语句允许我们在代码中实现条件执行路径。我们还将学习如何实现异常处理,这将帮助我们处理在执行应用程序时可能发生的错误。

在这一章中,我们将涵盖以下概念:

  • 控制语句

  • 异常处理

在本章结束时,我们将看到如何实际实现这些语句和子句。让我们使用示例详细讨论每个主题。

理解控制语句

控制语句允许我们控制程序的执行流程。它们还允许我们根据特定条件执行特定的代码块。C#定义了三种控制语句的类别,如下所述:

  • ifswitch

  • for, while, do-while, 和 foreach

  • break, continue, goto, return, 和 yield

我们将在接下来的章节中详细探讨这些语句。

选择语句

选择语句允许我们根据条件是否为真来改变执行流程。C#为我们提供了两种类型的选择语句:ifswitch

if 语句

以下代码片段显示了if语句的语法:

if (condition1)
    statement1;
else if(condition2)
    statement2;
else
    statement3;

如果condition1评估为true,那么将执行statement1。否则,如果condition2评估为true,那么将执行statement2。否则,将执行statement3

else-ifelse子句是可选的,可以省略其中任何一个,或者两者都可以省略。另一方面,您可以有尽可能多的else-if子句。

在这个例子中,我们只有一个语句要执行ifelse子句。如果我们需要执行一系列语句,我们需要添加大括号({})使其成为一个代码块。对于单个语句来说,这是可选的,尽管这通常是使代码更清晰或更不容易出错的好方法。在这种情况下,语法将如下改变:

if (condition)
{
  statement 1;
  statement 2;
}
else
{
  statement 3;
  statement 4;
}

如果condition评估为true,那么statement1statement2都将被执行。否则,将执行statement3statement4。让我们尝试通过以下代码片段来理解if-else语句。

class Program
{
    static void Main(string[] args)
    {
        Console.WriteLine("Enter a positive integer");
        var line = Console.ReadLine(); 
        int.TryParse(line, out int number);
        if (number % 2 == 0)
        {
            Console.WriteLine("Even number");
        }
        else
        {
            Console.WriteLine("Odd number");
        }
    }
}

前面的程序检查正整数是偶数还是奇数。我们从控制台读取一个整数作为输入。由于控制台上输入的值被视为字符串,我们需要将其转换为整数。然后,我们将通过应用模运算符(%)找到除以2的余数。如果余数是0,那么数字是偶数,如果不是,那么数字是奇数

if语句可以嵌套。我们可以在另一个if语句或else语句中放置一个if语句。以下语法显示了嵌套if语句的示例:

if (condition1)
{
  if(condition2)
      statement 1;
  if(condition3)
      statement 2;
  else
      statement 3;
}
else
{
  if(condition4)
      statement 4;
  else
      statement 5;
}

在这个例子中,如果condition1评估为true,那么控制将进入if块并根据嵌套if语句的评估执行语句。如果condition1false,那么将执行else子句内的嵌套if语句。

在嵌套的if语句中,每个else子句都属于最后一个没有相应else语句的if语句。为了避免混淆和错误,建议在嵌套if语句时使用大括号正确配对ifelse子句。例如,以下示例:

if(condition1)
    if(condition2)
        statement1;
    else
        statement2;

前面的例子与以下内容不同:

if(condition1)
{
    if(condition2)
        statement1;
}
else
{
    statement2;
}

在第一个例子中,else子句属于第二个内部if子句。另一方面,在第二个例子中,else子句属于第一个外部if子句。

switch 语句

switch语句为我们提供了一种执行多个可用替代方案的方法。它将表达式的值与可用值列表进行匹配。如果找到匹配项,则执行与该值相关联的代码。

switch语句是级联if-else-if语句的替代方案。如果匹配的数量很少,可能更喜欢使用if语句。但是,如果匹配条件的数量较大,则更喜欢使用switch语句而不是if语句,因为它更易读和易维护。

switch语句的语法如下:

switch (expression)
{
  case value1:
    statement 1;
    break;
  case value2:
    statement 2;
    statement 3;
    break;
  default:
    statement 4;
    break;
}

switch语句包含一个或多个部分,每个部分都有一个或多个case标签。每个case标签可以有一个或多个语句。每个case标签指定一个将与switch表达式匹配的值。如果找到匹配项,控制将转移到匹配的case标签。

case标签中的语句将执行,直到遇到break语句。如果找不到匹配项,控制将转到default情况。在执行特定case标签后,控制将退出 switch。default情况是可选的。如果没有default情况,并且没有找到任何 case 标签的匹配项,控制将跳出switch语句。

请注意,我们在 case 标签内没有使用大括号({})default情况可以出现在列表的任何位置。在评估所有case标签之后,它始终最后进行评估。

您可以在同一个 switch 部分中放置多个 case 标签;在这种情况下,任何一个 case 标签的匹配都将触发 switch 部分的执行。在switch语句中,只能执行一个 switch 部分。不可能从一个部分跳转到另一个部分。每个switch语句必须跟随一个breakgotoreturn语句。

以下示例显示了一个带有多个 switch 部分的switch语句,其中一些带有多个case标签。default情况放在最后,通常会这样做。每个部分都用break语句退出:

Console.WriteLine("Enter number (1-10)");
var line = Console.ReadLine();
int.TryParse(line, out int number);
switch(number)
{
   case 1:
      Console.WriteLine("Smallest number");
      break;
   case 2: case 3: case 5: case 7:
      Console.WriteLine("Prime number");
      break;
   case 4: case 6: case 8:
      Console.WriteLine("Even number");
      break;
   case 9:
      Console.WriteLine("Odd number");
      break;
   default:
      Console.WriteLine("Not in the range");
      break;
}

switch语句支持各种形式的模式匹配。但这是一个更高级的主题,将在第八章中详细介绍,高级主题,以及第十五章中介绍,C# 8 的新特性

迭代语句

迭代语句允许我们在循环中执行一组代码,只要满足某个条件。C#为我们提供了四种不同类型的循环:

  • for

  • while

  • do-while

  • foreach

让我们详细探讨一下。

for循环

for循环允许我们执行代码块,只要布尔表达式评估为true。以下代码段显示了for循环的一般语法:

for(initializer; condition; iterator)
{
    statement1;
    statement2;
}

initializer部分由一个或多个初始化语句组成,用于初始化控制循环的计数器。这将在第一次进入循环之前执行一次。如果initializer部分中有多个语句,它们必须用逗号分隔。但是,initializer部分是可选的,可以留空

循环控制计数器也称为循环控制变量。此变量局限于循环范围内,不能在for循环范围外访问。

condition是一个布尔表达式,将确定循环是否执行。它将在每次循环迭代时进行评估。如果评估为true,则执行循环。一旦布尔条件评估为false,循环将终止,并且程序控制将跳出循环。此语句是可选的,可以留空。

iterator是一个表达式,用于在循环的每次迭代后更改(增加/减少)循环控制变量。它可以有多个用逗号分隔的语句。这个语句也是可选的,可以留空。实际上,这三个语句(initializerconditioniterator)都可以被省略,这样我们就有了一个无限循环,就像下面的片段一样:

for(;;)
{
    /* infinite loop, unless a break, goto, return, or throw
    executes */
}

for循环是一个入口控制循环,这意味着在进入循环之前将评估布尔条件。如果条件在第一次迭代中评估为false,那么循环内部的代码块将根本不会被执行。

让我们通过以下代码片段来理解for循环:

for (int i = 0; i <= 10; i++)
{
    if (i % 2 == 0)
    {
        Console.WriteLine($"{i} is an even number");
    }
    else
    {
        Console.WriteLine($"{i} is an odd number");
    }
}

在这里,我们运行一个for循环来检查010之间的哪些整数是偶数或奇数。当您执行此代码时,您将看到以下输出屏幕:

图 3.1 - 控制台截图显示前面片段的输出

图 3.1 - 控制台截图显示前面片段的输出

我们还可以在另一个for循环中放置一个for循环。在这种情况下,内部循环将完全执行每次外部循环的迭代。看看以下代码片段。在这里,j变量的所有值(即12)将被打印到i变量的每个值(即1234):

for (int i = 1; i < 5; i++)
{
   for (int j = 1; j < 3; j++)
   {
      Console.WriteLine($"i = {i},j = {j}");
   }
}

在执行时,您可以看到程序的以下输出:

图 3.2 - 前面片段执行的控制台输出

图 3.2 - 控制台截图显示前面片段的输出

嵌套for循环的典型示例是多维数组遍历。在下面的示例中,我们有一个整数数组,有三行两列,在声明时进行了初始化。嵌套for循环用于将其元素的值打印到控制台:

var arr = new int[3, 2] { { 1, 2, }, { 3, 4 }, { 5, 6 } };
for (int r = 0; r <= arr.GetUpperBound(0); r++)
{
    for (int c = 0; c <= arr.GetUpperBound(1); c++)
    {
        Console.Write($"{arr[r, c]} ");
    }
    Console.WriteLine();
}

请注意,我们使用GetUpperBound()方法来检索指定维度的最后一个元素的索引,以避免为数组大小硬编码数值。

您可以在条件仍为true时退出循环迭代,使用breakgotoreturnthrow语句。您可以使用continue语句跳过当前迭代的循环块的执行。对于其他循环(whiledoforeach)也是如此。jump语句将在本章后面详细探讨。

while循环

while循环是一个入口控制循环。只要指定的布尔表达式评估为true,它就会执行一系列语句的块。while循环的语法如下:

while (condition)
{
    statement1;
    statement2;
}

在这里,condition是一个布尔表达式,它控制着循环。当condition评估为true时,循环内部的代码块将被执行。当condition变为false时,程序控制将跳出循环。因为condition首先被评估,如果condition最初为falsewhile循环可能根本不会执行。

while循环与for循环非常相似。实际上,您可以将任何while循环重写为for循环,反之亦然。您可以在以下代码片段中看到如何使用while循环重新编写for循环的语法:

initializer;
while(condition)
{
    statement1;
    statement2;
    iterator;
}

在以下代码片段中,我们已经使用while循环重新编写了上一节中打印偶数和奇数到控制台的示例:

int i = 0;
while (i <= 10)
{
    if (i % 2 == 0)
    {
        Console.WriteLine($"{i} is an even number");
    }
    else
    {
        Console.WriteLine($"{i} is an odd number");
    }
    i++;
}

程序的执行结果没有改变。实际上,还有另一种方法可以实现相同的结果,那就是使用do语句。

do-while循环

do-while循环是一个退出控制循环。这意味着布尔条件将在循环结束时被检查。这确保了do-while循环至少会被执行一次,即使条件在第一次迭代中求值为false。这是whiledo-while循环之间的关键区别;前者可能根本不执行,但后者至少会执行一次。

do-while循环的语法如下:

do
{
    statement1;
    statement2;
} while (condition);

在下面的代码片段中,我们使用do-while循环打印出010之间的所有数字,并指定哪些是奇数,哪些是偶数。这段代码将产生与while循环示例中所示的相同输出:

int i = 0;
do
{
    if (i % 2 == 0)
    {
        Console.WriteLine($"{i} is an even number");
    }
    else
    {
        Console.WriteLine($"{i} is an odd number");
    }
    i++;
}
while (i <= 10);

到目前为止,我们学习的循环允许我们重复执行一个或多个语句,比如根据索引迭代集合的元素。另一种循环语句,比如foreach,简化了在我们只关心元素而不关心索引的所有情况下的迭代。让我们接下来看一下foreach

foreach 循环

foreach循环允许我们迭代实现了System.Collections.IEnumerableSystem.Collections.Generic.IEnumerable<T>接口的集合的项。集合在第七章 Collections中有详细讨论。

foreach循环的语法如下:

foreach(datatype iterator in collection)
{
  statement1;
  statement2;
}

这里,datatype表示 C#中的一个有效类型,它必须与集合的数据类型相同,或者存在隐式转换的类型。你也可以使用var代替实际的类型名,这样编译器将从集合元素的类型推断出iterator变量的类型。

iterator变量是一个循环迭代变量。在foreach循环中,循环迭代变量是只读的。这意味着我们不能在循环体内改变它的值。在循环的每次迭代中,迭代器被赋予集合中的一个值。当集合的所有元素都被迭代完时,循环退出。退出循环也可以通过breakgotoreturnthrow语句来实现。

让我们通过以下代码片段来看一下foreach循环:

string[] languages = { "Java", "C#", "Python", "C++", "JavaScript" };
foreach (string lang in languages)
{
    Console.WriteLine(lang);
}

在这个例子中,我们定义了一个包含编程语言列表的字符串数组。我们使用foreach循环来迭代它,并在控制台上打印数组的每个元素。这段代码的输出如下截图所示:

图 3.3 - 使用 foreach 语句将字符串数组的内容打印到控制台的输出

图 3.3 - 使用 foreach 语句将字符串数组的内容打印到控制台的输出

前面的foreach语句在语义上等同于以下内容:

var enumerator = languages.GetEnumerator();
while(enumerator.MoveNext())
{
    Console.WriteLine(enumerator.Current);
}

集合类型可能并不一定实现IEnumerableIEnumerable<T>接口,但它必须有一个名为GetEnumerator()的公共方法,不带参数并返回一个类、结构或接口,具有包含名为Current的公共属性和一个返回bool的公共无参数方法MoveNext()

如果枚举器类型的Current属性返回一个引用返回值(这是在 C# 7.3 中实现的),那么你可以用refref only修饰符声明迭代变量。这个片段中展示了一个例子:

Span<int> arr = stackalloc int[]{ 1, 1, 2, 3, 5, 8 };
foreach(ref int n in arr)
{
    n *= 2;
}
foreach(ref readonly var n in arr)
{
    Console.WriteLine(n);
}

在这里,arr变量是System.Span<int>。其GetEnumerator()方法的返回类型Span<T>.Enumerator满足前面提到的条件。第一个foreach循环遍历数组的元素(stackalloc数组在堆栈上分配并在函数调用返回时被释放),并将每个元素的初始值加倍。第二个foreach循环再次以只读方式遍历元素。在只读循环中尝试更改迭代变量的值将导致编译器错误。

跳转语句

跳转语句允许我们立即将控制从应用程序中的一个点转移到另一个点。C#为我们提供了五种不同的跳转语句:

  • break

  • continue

  • goto

  • return

  • yield

我们将在接下来的章节中详细探讨它们。

break语句

我们已经看到如何使用break来退出switch case。我们还可以使用break语句终止循环的执行。一旦程序控制在循环中遇到break语句,循环立即终止,控制流出循环。

看一下以下代码片段:

for (int i = 0; i <= 10; i++)
{
    Console.WriteLine(i);
    if (i == 5)
        break;
}

在这里,我们从010进行迭代,并将当前值写入控制台。如果循环控制变量的值变为5,循环将中断,不会再将任何元素打印到控制台。尽管循环预计会运行 10 次,但break语句使其立即终止,因为迭代器的值变为5。执行后,您可以看到以下输出:

图 3.4 – 控制台截图显示前面片段的输出

图 3.4 – 控制台截图显示前面片段的输出

break语句不是唯一可以控制循环执行的语句。另一个是continue,我们将在下一节中看到。

continue语句

continue语句将控制传递到封闭循环的下一次迭代,无论是forwhiledo还是foreach。它用于终止当前迭代中循环体的执行并跳到下一个迭代。continue语句不确定循环语句的返回,而只是中止当前迭代的执行并将控制移动到循环条件的评估。

看一下以下代码片段:

for (int i = 0; i <= 10; i++)
{
    if (i % 2 == 0)
        continue;
    Console.WriteLine(i);
}

在这个例子中,我们从010进行迭代;如果值是偶数,则跳过当前迭代循环,继续下一个。这段代码将只打印出010之间的奇数。输出如下:

图 3.5 – 打印到控制台的前一个片段的输出,小于 10 的奇数

图 3.5 – 打印到控制台的前一个片段的输出,小于 10 的奇数

breakcontinue语句控制循环的执行。下一个语句用于结束函数的执行。

返回语句

return语句终止当前执行流并将控制返回到调用方法。可选地,我们还可以向调用方法返回一个值。如果方法有定义返回类型,我们需要返回一个值。否则,当返回类型为 void 时,我们可以返回而不指定任何值。

以下示例显示了一个可能的实现,该函数返回第 n 个斐波那契数:

static int Fibonacci(int n)
{
    if (n > 1)
        return Fibonacci(n - 1) + Fibonacci(n - 2);
    else
        return n;
}

return语句触发当前函数执行的停止,并将控制返回到调用函数。

跳转语句

goto语句是一个无条件跳转语句。当程序控制遇到goto语句时,它将跳转到指定的位置。goto的目标使用标签指定,标签是一个标识符后跟一个冒号(:)。我们也可以使用goto来退出循环。在这种情况下,它的行为类似于break语句。

考虑以下代码片段:

for (int i = 0; i <= 10; i++)
{
    Console.WriteLine(i);
    if (i == 5)
    {
        goto printmessage;
    }
}
printmessage:
    Console.WriteLine("The goto statement is executed");

在这个例子中,我们从010进行迭代。如果迭代器的值变为5,我们将使用goto语句跳出循环。这段代码的输出如下所示:

图 3.6 - 前述代码片段的控制台输出

图 3.6 - 前述代码片段的控制台输出

通常应避免使用goto语句作为良好的编程实践,因为它可能导致代码结构混乱且难以维护。

yield 语句

yield是一个上下文关键字(即,在代码中提供特定含义而不是保留字的单词)。它表示在出现在returnbreak语句之前的方法、运算符或get访问器中,它是一个迭代器。从迭代器方法返回的序列可以使用foreach语句进行消耗。yield语句使得可以在生成时返回值并在可用时进行消耗,这在异步环境中特别有用。

为了更好地理解yield的用法,让我们考虑以下例子。我们有一个函数,让我们称之为GetNumbers(),它返回从1100的所有数字的集合。可能的实现如下所示:

IEnumerable<int> GetNumbers()
{
    var list = new List<int>();
    for (int i = 1; i <= 100; ++i)
    {
        list.Add(i);
    }
    return list;
}

这种实现的问题在于我们无法在所有数字都生成之前消耗这些数字。一方面,在实际例子中,这可能是耗时的,我们可能希望在生成数字时消耗这些数字。另一方面,我们可能只对其中一些数字感兴趣,而不是所有数字。

使用这种实现方式,我们必须先生成所有需要的数字,然后再使用我们需要的数字。在下面的例子中,我们只将前五个数字打印到控制台上:

var numbers = GetNumbers().Take(5);
Console.WriteLine(string.Join(",", numbers));

yield return语句会在项目可用时立即返回该项目。这是创建迭代器的一种简写,这样会使代码变得更加费力。

GetNumbers()的实现将改为以下内容:

IEnumerable<int> GetNumbers()
{
   for (int i = 1; i <= 100; ++i)
   {
      yield return i;
   }
}

我们会在项目可用时返回每个数字,并且只有在我们通过枚举器进行迭代时才这样做,比如使用foreach语句。前面的例子,将前五个数字打印到控制台上的例子,保持不变。但是,执行方式不同,因为for循环只会执行五次迭代。

为了更好地理解这一点,让我们稍微改变一下例子,以便在生成和消耗每个项目之前分别在控制台上显示一条消息:

IEnumerable<int> GetNumbers()
{
    for (int i = 1; i <= 100; ++i)
    {
        Thread.Sleep(1000);
        Console.WriteLine($"Produced: {i}");
        yield return i;
    }
}
foreach(var i in GetNumbers().Take(5))
{
    Console.WriteLine($"Consumed: {i}");
}

调用Thread.Sleep()用于模拟产生下一个数字时的一秒延迟。这段代码的执行结果如下图所示:

图 3.7 - 前述代码执行的结果

图 3.7 - 前述代码的执行结果

现在我们已经看到了如何从代码的正常执行中返回,让我们快速看一下在代码执行过程中发生意外错误时如何处理异常情况。

异常处理

有些情况下我们的代码会产生错误。错误可能是由于代码中的逻辑问题引起的,比如试图除以零或访问数组中超出数组边界的元素。例如,试图访问一个大小为三的数组中的第四个元素。错误也可能是由外部因素引起的,比如试图读取磁盘上不存在的文件。

C#为我们提供了一个内置的异常处理机制,以处理代码级别的这些类型的错误。异常处理的语法如下:

try
{
    Statement1;
    Statement2;
} 
catch (type)
{
    // code for error handling
}
finally
{
    // code to always run at the end
}

try块可以包含一个或多个语句。catch块包含错误处理代码。finally块包含在try部分之后将执行的代码。这无论执行是否恢复正常,或者控制是否因breakcontinuegotoreturn语句而离开try块。

如果发生异常并且存在catch块,则finally块也一定会执行。如果异常未被处理,finally块的执行取决于异常展开操作是如何触发的,这取决于运行机器的设置。finally块是可选的。

在执行时,程序控制将执行try块内的代码。如果try块中没有发生错误,执行将继续正常进行,并且控制转移到finally块(如果存在)。当try块内发生异常时,程序控制将转移到catch块(如果存在)。在执行catch块后,程序控制将转移到finally块(如果存在)。

同一个try块可能存在多个catch子句。它们列出的顺序很重要,因为它们按照给定的顺序进行评估。这意味着更具体的异常应该在更一般的异常之前捕获。可以指定一个没有异常类型的catch子句,以捕获所有异常。但是,这被认为是一个不好的做法,因为您应该只捕获您知道如何处理和恢复的异常。

当发生异常时,catch块处理当前执行的方法。如果不存在catch块,则会查找调用当前方法的方法,依此类推。如果找不到匹配的catch块,则会显示未处理的异常消息,并且程序的执行将被中止。

让我们尝试通过以下代码片段来理解异常处理:

class Program
{
    static void Main(string[] args)
    {
        try
        {
            int a = 10;
            int b = a / 0;
        }
        catch (Exception ex)
        {
            Console.WriteLine(ex);
        }
    }
}

在这里,我们试图模拟除零错误。当在try块内发生错误时,它将创建Exception类的一个实例并抛出异常。在catch块中,我们指定了Exception类型的参数。异常提供了错误消息,还提供了关于错误发生位置(文件名和路径)以及调用堆栈的信息。

如果我们只想要与异常相关联的消息,我们可以使用Exception类的Message属性。此代码片段的输出如下:

图 3.8 - 控制台显示除零异常的消息

图 3.8 - 控制台显示除零异常的消息

异常是使用throw语句抛出的。您必须指定System.Exception类的一个实例或从它派生的类。类将在第四章理解各种用户定义类型中讨论,继承在第五章C#面向对象编程中讨论,但目前请记住,有许多异常类型,它们都基于System.Exceptionthrow语句可以在catch块中使用,而不带任何参数来重新抛出异常,保留调用堆栈。当您想在发生异常时执行某些操作(如记录),但也希望将异常传递到另一个地方进行完全处理时,这是有用的。

在下面的例子中,一个名为FunctionThatThrows()的函数做一些事情,但在检查其输入参数之前。如果object参数为null,它会抛出ArgumentNullException类型的异常。然而,如果参数不为 null,但类型不是string,它会抛出ArgumentException类型的异常。这是ArgumentNullException的基类。在调用该方法时,我们捕获多个异常类型:

  • ArgumentNullException

  • ArgumentException

  • Exception

顺序很重要,因为它从最派生的类开始,以所有异常的基类结束。finally块用于在执行结束时显示消息:

void FunctionThatThrows(object o)
{
    if (o is null)
        throw new ArgumentNullException(nameof(o));
    if (!(o is string))
        throw new ArgumentException("A string is expected");
    // do something
}
try
{
    Console.WriteLine("executing");
    FunctionThatThrows(42);
}
catch (ArgumentNullException e)
{
    Console.WriteLine($"Null argument: {e.Message}");
}
catch (ArgumentException e)
{
    Console.WriteLine($"Wrong argument: {e.Message}");
}
catch(Exception e)
{
    Console.WriteLine($"Error: {e.Message}");
}
finally
{
    Console.WriteLine("done");
}

该程序的执行输出如下:

图 3.9 - 从前面片段执行的控制台输出

图 3.9 - 从前面片段执行的控制台输出

异常处理的主题将在第十四章中进行更详细的讨论,错误处理。如果你想在这一点上了解更多关于异常的知识,你可以继续阅读这一章,然后再继续下一章。

总结

在本章中,我们探讨了 C#中的控制语句。我们通过示例学习了不同类型的循环和跳转语句的工作原理。我们还简要介绍了如何抛出和捕获异常。

在下一章中,我们将看看用户定义的类型,并探索类中的字段、属性、方法、索引器和构造函数。

测试你学到的东西

  1. C#语言中有哪些选择语句可用?

  2. switch语句的默认情况可以出现在哪里,何时进行评估?

  3. forforeach语句有什么区别?

  4. whiledo-while语句有什么区别?

  5. 你可以使用哪些语句来从函数返回?

  6. 你可以在哪里使用break语句,它是如何工作的?

  7. yield语句是做什么的,它在哪些场景中使用?

  8. 如何捕获函数调用中的所有异常?

  9. finally块是做什么的?

  10. .NET 中所有异常的基类是什么?

第四章:*第四章:*理解各种用户定义类型

在上一章中,我们学习了 C#中的控制语句和异常。在本章中,我们将探讨 C#中的用户定义类型。我们将学习如何使用类、结构和枚举来创建自定义用户类型。我们将探讨类中的字段、属性、方法、索引器和构造函数。我们将研究 C#中的访问修饰符,并学习如何使用它们来定义类型和成员的可见性。我们还将学习 C#中的两个重要关键字thisstatic,并了解方法的refinout参数修饰符。

我们将详细探讨以下主题:

  • 类和对象

  • 结构

  • 枚举

  • 命名空间

对这些概念的良好了解对于理解我们将在下一章中涵盖的面向对象编程OOP)概念是必要的。

类和对象

在我们继续之前,重要的是你理解这两个关键概念。类是指定对象形式的模板或蓝图。它包含操作该数据的数据和代码。对象是类的一个实例。类是使用class关键字和一个类的类型是引用类型来定义的。引用类型的变量的默认值是null。您可以将其分配为类型实例的引用。实例 - 也就是对象 - 是使用new运算符创建的。

信息框

术语对象在不同的技术文档中经常可以互换使用。它们并不相同,这样使用是不正确的。类是指定对象的内存布局并定义与该内存操作的功能的蓝图。对象是根据蓝图创建和操作的实际实体。

看一下以下代码片段,以了解如何定义类。在这个例子中,我们创建了一个Employee类,其中包含三个字段,用于表示员工的 ID、名字和姓氏:

class Employee
{
    public int    EmployeeId;
    public string FirstName;
    public string LastName;
}

我们将使用new关键字来创建类的实例。new运算符在运行时为对象分配内存并返回对其的引用。然后,将该引用存储在指定对象名称的变量中。对象存储在堆上,对象的引用存储在与命名变量对应的堆栈存储位置上。

要创建Employee类的对象,我们将使用以下语句:

Employee obj = new Employee();

使用对象访问类的成员(字段、属性、方法),我们使用点(.)运算符。因此,要为对象的字段赋值(obj),我们将使用以下语句:

obj.EmployeeId = 1;
obj.FirstName = "John";
obj.LastName = "Doe"

以下图表概念上显示了这里发生的情况:

图 4.1 - 先前雇员对象的概念内存布局

图 4.1 - 先前雇员对象的概念内存布局

Employee类型的obj变量被分配在堆栈上。但是,堆栈不包含实际的Employee对象,而只包含对它的引用。对象分配在堆上,并且对象的地址存储在堆栈上,因此通过obj变量我们可以访问位于堆上的对象。

类的两个不同实例是两个不同的对象。对象的引用可以分配给多个变量。在这种情况下,通过一个变量对对象的修改将通过另一个变量可见。这在以下示例中显示:

Employee obj1 = new Employee();
obj1.EmployeeId = 1;
Employee obj2 = obj1; 
obj2.FirstName = "John";    // obj1.FirstName == "John"
obj2.LastName = "Doe";      // obj1.LastName == "Doe"

在这里,我们创建了Employee类的第一个实例,并且只为EmployeeId赋了一个值。然后,我们创建了第二个实例,并为名字和姓氏赋值,跳过了标识符。这是两个不同的对象,驻留在内存中的不同位置。

员工的属性存储在类的成员字段中。接下来将讨论这些。

字段

这些是直接在类内声明的变量,因此是类的成员。字段用于存储对象的状态,这是必须在类方法执行期间存活并且应该从多个方法中访问的数据。不在单个方法范围之外使用的变量应该被定义为局部变量而不是类字段。

在前面的部分中,EmployeeIdFirstNameLastName是提到的字段。这些被称为实例字段,因为它们属于类的实例,这意味着每个对象都有自己的这些字段的实例。另一方面,静态字段属于类,并且被所有类的实例共享。静态成员将在本章的后面部分讨论。

这些字段已被声明为public,这意味着任何人都可以访问它们。然而,这是一个不好的做法。字段通常应该声明为private(只能被类成员访问)或者protected(也可以被派生类访问)。这确保了更好的封装,这将在下一章进一步讨论。字段可以通过方法、属性和索引器进行读取和写入。我们将在下面的部分讨论这些。

const修饰符声明的字段称为常量。只有内置类型可以是常量。常量始终使用字面值初始化,并且是在编译时已知的值,不能在运行时更改:

class Employee
{
    public const int StartId = 100;
}

常量字段在中间语言代码中被其字面值替换,这意味着不能通过引用传递常量字段。但这还有另一个更微妙的含义:如果常量值在类型定义的程序集之外的程序集中被引用,并且常量的字面值在将来的版本中被更改,那么引用常量的程序集将继续具有旧版本,直到它们被重新编译。

例如,如果在 A 程序集中定义了一个整数常量并且初始值为 42,然后在 B 程序集中引用了它,那么值 42 将被存储在 B 程序集中。将常量的值更改为其他值(比如 100)将不会反映在 B 程序集中,它将继续存储旧值,直到使用新版本的 A 程序集重新编译。

字段也可以用readonly修饰符声明。这些字段只能在构造函数中初始化,它们的值以后不能被改变。它们可以被看作是运行时常量

在下面的例子中,EmployeeId字段是一个在构造函数中初始化的readonly字段。类的实例只能改变姓和名字段:

class Employee
{
   public readonly int EmployeeId;
   public string       FirstName;
   public string       LastName;
   public Employee(int id)
   {
      EmployeeId = id;
   }
}
Employee obj = new Employee(1);
obj.FirstName = "John";
obj.LastName = "Doe";

现在我们已经看到如何使用字段,让我们学习一下方法。

方法

方法是在调用方法时执行的一个或多个语句的系列。实例方法需要对象才能被调用。静态方法属于类,不使用对象调用。

方法有一个所谓的签名,由几个部分组成:

  • 默认为private

  • virtualabstractsealedstatic:这些都是可选的,将在后面的部分讨论。

  • 如果方法不返回任何值,则为void

  • 名字:这必须是一个有效的标识符。

  • refinout修饰符。

在下面的例子中,我们将在Employee类中添加一个方法:

class Employee
{
    public int    EmployeeId;
    public string FirstName;
    public string LastName;
    public string GetEmployeeName()
    {
        return $"{FirstName} {LastName}";
    }
}

在这里,我们添加了一个名为GetEmployeeName()的方法。访问修饰符是public,这允许从代码的任何部分调用这个方法。返回类型是string,因为该方法通过连接FirstNameLastName字段并用空格分隔返回员工的名字。

简单地评估表达式并可能返回评估结果的方法可以使用另一种语法member => expression;形式编写,并且支持所有类成员,不仅仅是方法,还包括字段、属性、索引器、构造函数和终结器。表达式评估的结果值类型必须与方法的返回类型匹配。

以下代码显示了使用表达式体定义的GetEmployeeName()方法的实现:

public string GetEmployeeName() => $"{FirstName} {LastName}";

重载方法是具有相同名称但不同签名的多个方法。这样的方法是可以存在的。在方法重载的上下文中,这些方法的返回类型不是签名的一部分。这意味着你不能有两个具有相同参数列表但不同返回值的方法。

在以下示例中,GetEmployeeName(bool)是前一个GetEmployeeName()方法的重载方法:

public string GetEmployeeName(bool lastNameFirst) => lastNameFirst ? $"{LastName} {FirstName}" : 
                $"{FirstName} {LastName}";

这个方法与前一个方法同名,但参数列表不同。它接受一个布尔值,指示是否应该先放姓氏,否则返回名字和姓氏,就像前一个方法一样。

构造函数

构造函数是在类中定义的特殊方法,当我们实例化一个类的对象时会调用它。构造函数用于在对象创建时初始化类的成员。构造函数不能有返回类型,并且与类同名。可以存在具有不同参数的多个构造函数。

没有任何参数的构造函数称为默认构造函数。编译器为所有类提供了这样的构造函数。默认构造函数在编译时创建并将成员变量初始化为它们的默认值。对于数值数据类型,默认值为 0,对于bool类型为false,对于引用类型为null。如果我们定义自己的构造函数,编译器将不再提供默认构造函数。

构造函数可以有访问修饰符。构造函数的默认访问修饰符是private。然而,这个修饰符使得在类外部无法实例化类本身。在大多数情况下,构造函数的访问修饰符被定义为public,因为构造函数通常是从类的外部调用的。

私有构造函数在某些情况下很有用。一个例子是在实现单例模式时。

让我们尝试通过以下示例来理解到目前为止涵盖的所有概念:

class Employee
{
    public int EmployeeId;
    public string FirstName;
    public string LastName;
    public Employee(int employeeId, 
                    string firstName, string lastName)
    {
        EmployeeId = employeeId;
        FirstName = firstName;
        LastName = lastName;
    }
    public string GetEmployeeName() => 
           $"{FirstName} {LastName}";   
}

我们扩展了Employee类并在其中包含了一个构造函数。这个构造函数将接受三个参数来初始化所有三个字段的值:EmployeeIdFirstNameLastName。在创建类的实例时,必须为类的构造函数指定适当的参数:

Employee obj = new Employee(1, "John", "Doe");
Console.WriteLine("Employee ID is: {0}", obj.EmployeeID);
Console.WriteLine("The full name of employee is: {0}",
                   obj.GetEmployeeName());

执行后,该程序将给出以下截图中显示的输出:

图 4.2 - 显示前面片段输出的控制台截图

图 4.2 - 显示前面片段输出的控制台截图

对象可以使用所谓的对象初始化器以声明方式进行初始化。您调用一个构造函数,并且除了为构造函数提供必要的参数之外,还为可访问成员(如字段、属性或索引器)提供一个初始化语句列表,放在花括号内。

考虑Employee类没有用户定义的构造函数,由编译器提供的默认(无参数)构造函数,我们可以编写以下代码来初始化类的实例:

Employee obj = new Employee()
{
    EmployeeId = 1,
    FirstName = "John",
    LastName = "Doe"
};

到目前为止,在本章中,我们已经使用字段来存储对象的状态。C#语言提供了一种替代字段的方法:属性,这是下一节的主题。

属性

属性是字段和访问该字段的方法的组合。它们看起来像字段,但实际上是称为访问器的方法。属性使得以简单的方式读取或写入类状态成为可能,并隐藏实现细节,包括验证代码。

属性定义的两个访问器称为get(用于从属性返回值)和set(用于分配新值)。在set访问器的上下文中,value关键字定义正在访问的值(即从用户代码分配的值)。

在下面的例子中,本章前面显示的Employee类被重写,以便员工 ID、名和姓是私有字段,通过属性对类客户端可用:

class Employee
{
   private int employeeId;
   private string firstName;
   private string lastName;
   public int EmployeeId
   {
      get { return employeeId; }
      set { employeeId = value; }
   }
   public string FirstName
   {
      get { return firstName; }
      set { firstName = value; }
   }
   public string LastName
   {
      get { return lastName; }
      set { lastName = value; }
   }
}

实际上,使用属性的getset访问器是透明的。您不会显式调用它们,而是像字段一样使用属性。以下示例显示了如何访问Employee类的三个属性进行读写:

Employee obj = new Employee();
obj.EmployeeId = 1;
obj.FirstName = "John";
obj.LastName = "Doe";
Console.WriteLine($"{obj.EmployeeId} - {obj.LastName}, {obj.FirstName}");

在前面的代码中显示的属性的实现是直接的——它只返回或设置私有字段的值。但是,访问器就像任何其他方法一样,因此您可以编写任何代码,例如参数验证,如下例所示:

public int EmployeeId
{
    get { return employeeId; }
    set {
        if (value < 0)
            throw new ArgumentException(
              "ID must be greater than zero.");
        employeeId = value;
    }
}

另一方面,属性不需要引用相应的字段。属性可以返回不从一个字段中读取的值,或者可以从评估不同字段计算出的值。以下示例显示了一个Name属性,它连接了FirstNameLastName属性的值:

public string Name
{
    get { return $"{FirstName} {LastName}"; }
}

请注意,在这个属性的情况下,缺少set访问器。getset访问器都是可选的。但是,至少必须实现一个。另一方面,只写属性没有太多价值,您可能希望将这些功能实现为常规方法。此外,getset访问器可以具有不同的访问修饰符。

以这种方式实现属性是很麻烦的,因为您需要明确定义除了属性之外其他地方不使用的私有字段。此外,每个属性都必须明确实现getset访问器,基本上是一遍又一遍地重复相同的代码。可以使用自动实现的属性以更短的语法实现相同的结果。这些属性是编译器将提供私有字段和getset访问器的实现,就像我们之前做的那样。

Employee类使用自动实现的属性进行了重写,如下所示。这非常类似于我们第一次实现时使用公共字段的情况:

class Employee
{
    public int EmployeeId { get; set; }
    public string FirstName { get; set; }
    public string LastName { get; set; }
}

如果您不想设置这些属性的值,可以只声明get访问器为public。在这种情况下,set访问器可能是private,并且您将通过构造函数提供值。这里显示了一个示例:

class Employee
{
    public int EmployeeId { get; private set; }
    public string FirstName { get; private set; }
    public string LastName { get; private set; }
    public Employee(int id, string firstName, string lastName)
    {
        EmployeeId = id;
        FirstName = firstName;
        LastName = lastName;
    }
}

可以使用表达式主体定义来实现属性。前面显示的Name属性可以实现如下:

public string Name => $"{FirstName} {LastName}";

这是一个只有get访问器的只读属性。但是,您可以显式实现getset访问器作为表达式主体成员。这在以下清单中显示:

class Employee
{
    private int employeeId;
    public int EmployeeId
    {
        get => employeeId;
        set => employeeId = value > 0 ? value : 
                 throw new ArgumentException(
                      "ID must be greater than zero.");
    }
}

自动实现的属性也可以使用以下示例中显示的语法进行初始化:

class Employee
{
   public int EmployeeId { get; set; } = 1;
   public string FirstName { get; set; }
   public string LastName { get; set; }
}

EmployeeId属性的值被初始化为1。除非另行明确设置,Employee类的所有实例都将EmployeeId设置为1

如果使用表达式主体定义实现只读属性,则不需要指定get访问器。在这种情况下,语法如下:

class Employee
{
    public int EmployeeId => 1;
    public string FirstName { get; set; }
    public string LastName { get; set; }
}

然而,这看起来与以下内容非常相似:

class Employee
{
    public int EmployeeId = 1;
    public string FirstName { get; set; }
    public string LastName { get; set; }
}

这些语法之间存在很大的区别:

  • 在前面的例子中,使用=>时,EmployeeId是一个具有表达式体定义只读公共属性

  • 在后面的例子中,使用=时,EmployeeId是一个公共字段,具有初始化程序

有一种特殊形式的属性可以接受参数,并允许使用[]运算符访问类实例。这些被称为索引器,将在下一节中讨论。

索引器

索引器允许像数组一样对对象进行索引。索引器定义了getset访问器,类似于属性。索引器没有显式名称。它是通过使用this关键字创建的。索引器有一个或多个参数,可以是任何类型。与属性一样,getset访问器通常很简单,由一个返回或设置值的单个语句组成。

在下面的例子中,ProjectRoles类包含项目 ID 和员工在每个项目中担任的角色的映射。这个映射是私有的,但可以通过索引器访问:

class ProjectRoles
{
    readonly Dictionary<int, string> roles = 
        new Dictionary<int, string>();
    public string this[int projectId]
    {
        get
        {
            if (!roles.TryGetValue(projectId, out string role))
                throw new Exception("Project ID not found!");
            return role;
        }
        set
        {
            roles[projectId] = value;
        }
    }
}

索引器使用public string this[int projectId]语法进行定义,其中包含以下内容:

  • 访问修饰符

  • 索引器的类型,即string

  • this关键字和方括号[]中的参数列表

getset访问器的实现方式与常规属性相同。ProjectRoles类可以在Employee类中如下使用:

class Employee
{
    public int          EmployeeId { get; set; }
    public string       FirstName { get; set; }
    public string       LastName { get; set; }
    public ProjectRoles Roles { get; private set; }
    public Employee() => Roles = new ProjectRoles();
}

我们可以使用Roles[i]语法访问员工角色,就好像Roles是一个数组一样。在这个例子中,参数不是数组中的索引,而是一个项目标识符,实际上是项目和角色字典的键。参数可以是任何类型,不仅仅是数值类型:

Employee obj = new Employee()
{
    EmployeeId = 1,
    FirstName = "John",
    LastName = "Doe"
};
obj.Roles[1] = "Dev";
obj.Roles[3] = "SA";

for(int i = 1; i <= 3; ++i)
{
    try
    {
        Console.WriteLine($"Project {i}: role is {obj.Roles[i]}");
    }
    catch(Exception ex)
    {
        Console.WriteLine(ex.Message);
    }
}

执行此示例代码的输出显示在以下截图中:

图 4.3 - 执行前面代码片段的控制台输出

图 4.3 - 执行前面代码片段的控制台输出

索引器,包括只读索引器,可以使用表达式体定义。但是,没有自动实现的索引器;它们必须显式实现。

如前所述,索引器是使用this关键字定义的。但是,这个关键字在索引器范围之外还有其他意义。这个主题将在下一节中讨论。

this 关键字

this关键字用于表示类的当前实例。当调用方法时,使用this将调用对象的引用传递给它。这不是显式地完成的,而是由编译器在后台完成的。

this关键字有两个重要的目的:

  • 当参数或局部变量具有相同名称时,限定类成员

  • 将对当前实例的引用作为参数传递给另一个方法

让我们看看Employee类的以下实现:

class Employee
{
    public int EmployeeID;
    public string FirstName;
    public string LastName;
    public Employee(int EmployeeID, 
                    string FirstName, string LastName)
    {
       this.EmployeeID = EmployeeID;
       this.FirstName = FirstName;
       this.LastName = LastName;
    }
}

在这个例子中,构造函数的参数与类的字段具有相同的名称。C#允许我们对参数和实例变量使用相同的名称。由于参数名称是方法局部的,局部名称优先于实例变量。为了缓解这种情况,我们使用this关键字来引用当前方法调用的实例变量。

到目前为止,我们已经看到this关键字用于引用类的当前实例和声明索引。但是,它还用于另一个目的,即声明扩展方法。这将在第八章 高级主题中讨论。现在,让我们看看另一个重要的关键字:static

静态关键字

static关键字可用于声明类或类成员。这与我们目前所见的不同,因为您不创建静态类的实例,也不需要对象来访问静态成员。我们将在以下小节中详细探讨这些内容。

静态成员

字段、属性、方法和构造函数可以声明为static。索引器和终结器不能声明为static。静态成员不属于对象(如实例成员的情况),而是属于类型本身。因此,您不能通过对象访问静态成员,而是通过类型名称。

在下面的示例中,我们有一个Employee类的实现,其中有一个名为id的静态字段和一个名为Create()的静态方法。静态字段存储下一个员工 ID 的值,静态方法用于创建类的新实例,因为构造函数是private,因此只能从类内部调用:

class Employee
{
    private static int id = 1;
    public int EmployeeId { get; private set; }
    public string FirstName { get; private set; }
    public string LastName { get; private set; }
    private Employee(int id, string firstName, string lastName)
    {
        EmployeeId = id;
        FirstName = firstName;
        LastName = lastName;
    }
    public static Employee Create(string firstName, 
                                  string lastName)
    {
        return new Employee(id++, firstName, lastName);
    }
}

我们可以按以下方式调用Create()方法来实例化这个类的新对象:

Employee obj1 = Employee.Create("John", "Doe");
Employee obj2 = Employee.Create("Jane", "Doe");
Console.WriteLine($"{obj1.EmployeeId} {obj1.FirstName}");
Console.WriteLine($"{obj2.EmployeeId} {obj2.FirstName}");

像这样创建的第一个对象将EmployeeID设置为1,第二个对象将EmployeeID设置为2,依此类推。请注意,我们使用了Employee.Create()语法来调用静态方法。

静态类

static类也使用static关键字声明。static类不能被实例化。由于我们无法创建static类的实例,因此我们使用类名本身来访问类成员。静态类的所有成员本身必须是静态的。静态类基本上与非静态类相同,具有private构造函数和所有成员声明为static

静态类通常用于定义仅在其参数(如果有)上操作并且不依赖于类字段的方法。这通常是实用类的情况。

下面的示例显示了一个名为MassConverters的静态类,其中包含用于在千克和磅之间转换的静态方法:

static class MassConverters
{
    public static double PoundToKg(double pounds)
    {
        return pounds * 0.45359237;
    }
    public static double KgToPound(double kgs)
    {
        return kgs * 2.20462262185;
    }
}
var lbs = MassConverters.KgToPound(42.5);
var kgs = MassConverters.PoundToKg(180);

因为静态类不能被实例化,所以this关键字在这样的类的上下文中没有意义。尝试使用它将导致编译器错误。

静态构造函数

一个类可以有静态构造函数,无论类本身是否是静态的。静态构造函数没有参数或访问修饰符,用户无法调用它。CLR 在以下情况下自动调用静态构造函数:

  • 在静态类中,当第一次访问类的第一个静态成员时

  • 在非静态类中,当类首次实例化时

静态构造函数对于初始化静态字段非常有用。例如,static readonly字段只能在声明期间或在静态构造函数中初始化。当值来自配置文件时,用于将条目写入日志文件,或者用于编写非托管代码的包装器时,静态构造函数可以调用LoadLibrary()API,这是非常有用的。

在下面的示例中,修改了Employee类的先前实现,提供了一个静态构造函数来初始化静态id字段的值。这个构造函数从应用程序文件中读取下一个员工的 ID,如果找不到文件,则将其初始化为1。每次创建类的新实例时,下一个员工 ID 的值都将写入此文件:

class Employee
{
    private static int id;
    public int EmployeeId { get; private set; }
    public string FirstName { get; private set; }
    public string LastName { get; private set; }
    static Employee()
    {
        string text = "1";
        try
        {
            text = File.ReadAllText("app.data");
        }
        catch { }
        int.TryParse(text, out id);
    }
    private Employee(int id, string firstName, string lastName)
    {
        EmployeeId = id;
        FirstName = firstName;
        LastName = lastName;
    }
    public static Employee Create(string firstName, 
                                  string lastName)
    {
        var employee = new Employee(id++, firstName, lastName);
        File.WriteAllText("app.data", id.ToString());
        return employee;
    }
}

如果您多次运行以下代码,第一次两个员工的 ID 将是12,然后是34,依此类推:

Employee obj1 = Employee.Create("John", "Doe");
Employee obj2 = Employee.Create("Jane", "Doe");
Console.WriteLine($"{obj1.EmployeeId} {obj1.FirstName}");
Console.WriteLine($"{obj2.EmployeeId} {obj2.FirstName}");

到目前为止,我们已经看到了如何创建方法和构造函数。在下一节中,我们将学习有关将参数传递给它们的不同方法。

引用、输入和输出参数

当我们将参数传递给方法时,它是按值传递的。这意味着会创建一个副本。如果类型是值类型,则参数的值将被复制到方法参数中。如果类型是引用类型,则引用将被复制到方法参数中。当您更改参数值时,它会更改本地副本。这意味着值类型的参数更改不会传播到调用者。至于引用类型的参数,您可以更改堆上的引用对象,但不能更改引用本身。使用refinout关键字可以改变这种行为。

ref 关键字

ref关键字允许我们创建按引用调用机制,而不是按值调用机制。在声明和调用方法时指定了ref关键字。使用ref关键字改变参数,使其成为参数的别名,必须是一个变量。这意味着您不能将属性或索引器(实际上是一个方法)作为ref参数的参数传递。ref参数必须在方法调用之前初始化。

让我们看一下以下代码示例:

class Program
{
    static void Swap(ref int a, ref int b)
    {
        int temp = a;
        a = b;
        b = temp;
    }
    static void Main(string[] args)
    {
        int num1 = 10;
        int num2 = 20;
        Console.WriteLine($"Before swapping: num1={num1}, num2={num2}");
        Swap(ref num1, ref num2);
        Console.WriteLine($"After swapping:  num1={num1}, num2={num2}");
    }
}

在此程序中,我们定义了一个Swap方法来交换两个整数值。我们使用ref关键字来声明方法参数。我们将此方法定义为static,以便我们可以在没有对象引用的情况下调用它。在Main方法中,我们初始化了两个整数变量。

在调用Swap方法时,我们还使用了ref关键字和参数名称。这些ref参数作为引用传递,并且num1num2变量的实际值将被交换。这种更改会反映在Main方法中的变量中。该程序的输出如下截图所示:

图 4.4 - 控制台显示交换前后 num1 和 num2 的值

图 4.4 - 控制台显示交换前后 num1 和 num2 的值

ref关键字可用于指定引用返回值。在这种情况下,它必须出现在以下位置:

  • 在方法签名中,在返回类型之前。

  • 在返回语句中,在return关键字和返回的值之间。这样的值称为ref 返回值

  • 在将返回的引用分配给本地变量的声明中,在变量的类型之前。这样的变量称为ref 本地变量

  • 在调用带有ref返回的方法之前。

在以下示例中,Project类具有Employee类型的成员字段。在构造函数中设置了对Employee实例的引用。GetOwner()方法返回对成员字段的引用:

class Project
{
    Employee owner;
    public string Name { get; private set; }
    public Project(string name, Employee owner)
    {
        Name = name;
        this.owner = owner;
    }
    public ref Employee GetOwner()
    {
        return ref owner;
    }
    public override string ToString() => 
      $"{Name} (Owner={owner.FirstName} {owner.LastName})";
}

可以按以下方式用于检索和更改项目的所有者。在以下代码中,请注意在本地变量声明和调用GetOwner()方法中使用ref关键字:

Employee e1 = new Employee(1, "John", "Doe");
Project proj = new Project("Demo", e1);
Console.WriteLine(proj);
ref Employee owner = ref proj.GetOwner();
owner = new Employee(2, "Jane", "Doe");
Console.WriteLine(proj);

该程序的输出如下截图所示:

图 4.5 - 上一段代码的输出截图

图 4.5 - 上一段代码的输出截图

在使用ref返回值时,必须注意以下事项:

  • 不可能返回对局部变量的引用。

  • 不可能返回对this的引用。

  • 可以返回对类字段的引用,也可以返回对没有set访问器的属性的引用。

  • 可以返回对ref/in/out参数的引用。

  • 通过引用返回会破坏封装,因为调用者可以完全访问对象的状态或部分状态。

让我们现在来看一下in关键字。

in 关键字

in关键字与ref关键字非常相似。它导致参数通过引用传递。然而,关键区别在于in参数不能被调用方法修改。in参数基本上是一个readonly ref参数。如果被调用的方法尝试修改值,编译器将发出错误。作为in参数传递的变量在传递给调用方法的参数之前必须初始化。

以下示例显示了一个接受两个in参数的方法。任何试图更改它们的值都会导致编译器错误:

static void DontTouch(in int value, in string text)
{
    value = 42;   // error
    ++value;      // error
    text = null;  // error
}
int i = 0;
string s = "hello";
DontTouch(i, s);

在传递参数给方法时,指定in关键字是可选的。在上面的例子中,这是被省略的。

in说明符主要用于在热路径上传递值类型对象的引用,即重复调用的函数。当将值类型对象传递给函数时,在堆栈上会创建一个值的副本。通常,这不会引起任何性能问题,但是当这种情况一再发生时,性能问题就会出现。通过使用in说明符,可以传递对象的只读引用,从而避免复制。

in说明符的另一个好处是清晰地传达参数不应该被方法修改的设计意图。

out 关键字

out关键字类似于ref关键字。不同之处在于,作为out参数传递的变量在调用方法之前不必初始化,但是在返回之前,接受out参数的方法必须为其分配一个值。在方法定义和方法调用之前,out关键字必须都存在。

返回输出值在方法需要返回多个值或者需要返回一个值但也需要返回执行是否成功的信息时非常有用。一个例子是int.TryParse(),它返回一个布尔值,指示解析是否成功,并将实际解析的值作为out参数返回。

为了看看它是如何工作的,让我们看下面的例子:

static void Square(int input, out int output)
{
    output = input * input;
}

我们已经定义了一个static方法来返回一个整数的平方。Square方法将接受两个参数。int参数将是一个整数值,并且它将通过out参数输出返回输入数字的平方。可以如下使用:

int num = 10;
int SquareNum;
Square(num, out SquareNum);

执行后,此程序的输出将是100

作为out参数使用的变量可以在方法调用中内联声明。这会产生更简单、更紧凑的代码。内联变量的作用域是调用方法的作用域。

上述代码可以简化如下:

int num = 10;
Square(num, out int SquareNum);

在使用这些参数说明符时有一些限制,将在下一节中进行解释。

了解它们的限制

在使用refinout参数时,必须注意几个限制。这些关键字不能与以下内容一起使用:

  • 使用async修饰符定义的异步方法。

  • 迭代器方法,其中包括yield returnyield break

另一方面,在重载解析的上下文中,refinout关键字不被视为方法签名的一部分。这意味着你不能有两个相同方法的重载:一个接受ref参数,另一个接受相同参数作为out参数。但是,如果一个方法有一个值参数,另一个方法有一个refinout参数,那么可以有重载的方法:

class foo
{
  public void DoSomething(ref int i);
  public void DoSomething(out int i); // error: cannot overload
}
class bar
{
  public void DoSomethingElse(int i);
  public void DoSomethingElse(ref int i);  // OK
}

到目前为止,我们在本书中看到的所有方法都有固定数量的参数。然而,语言允许我们定义可以接受可变数量参数的方法。下面将讨论这个主题。

具有可变数量参数的方法

到目前为止,我们只看到了接受零个或固定数量参数的方法。然而,也可以定义接受相同类型任意数量参数的方法。为此,必须有一个由params关键字引导的单维数组参数。这个参数不一定要是方法的唯一参数,但在它之后不允许有更多的参数。

在以下示例中,我们有两个方法—Any()All()—它们接受可变数量的布尔值,并返回一个布尔值,指示它们中是否有任何一个为true,以及分别是否它们全部为true

static bool Any(params bool [] values)
{
    foreach (bool v in values)
        if (v) return true;
    return false;
}
static bool All(params bool[] values)
{
    if (values.Length == 0) return false;
    foreach (bool v in values)
        if (!v) return false;
    return true;
}

这两种方法都可以用零个、一个或任意数量的参数来调用,如下所示:

var a = Any(42 > 15, "text".Length == 3);  // a=true
var b = All(true, false, true);            // b=false
var c = All();                             // c=false

方法调用时提供参数的方式是灵活的。接下来我们将看看现有的可能性。

命名和可选参数

到目前为止,我们所见过的所有例子中,方法调用的参数都是按照方法签名中参数声明的顺序提供的。这些被称为位置参数,因为它们是基于给定位置进行评估的。此外,所有参数都是必需的,这意味着除非为参数列表中的每个参数提供了参数,否则不能发生调用。

然而,C#支持另外两种类型的参数:可选参数命名参数。这些经常一起使用,使我们能够为可选参数列表中的参数提供部分参数。这些可以用于方法、索引器、构造函数和委托。

可选参数

在声明方法、构造函数、索引器或委托时,我们可以为参数指定默认值。当存在这样的参数时,在成员调用时提供参数是可选的。如果没有提供参数,编译器将使用默认值。参数的默认值必须是以下之一:

  • 常量表达式

  • new T()形式的表达式,其中T是值类型

  • default(T)形式的表达式,其中T也是值类型

方法可以有必需和可选参数。如果存在可选参数,则它们必须跟在所有非可选参数后面。非可选参数不能跟在可选参数后面。

让我们考虑Point结构的以下实现:

struct Point
{
    public int X { get; }
    public int Y { get; }
    public Point(int x = 0, int y = 0)
    {
        X = x;
        Y = y;
    }
}

构造函数接受两个参数,它们都具有默认值0。这意味着它们都是可选的。我们可以以以下任何形式调用构造函数:

Point p1 = new Point();     // x = 0, y = 0
Point p2 = new Point(1);    // x = 1, y = 0
Point p3 = new Point(1, 2); // x = 1, y = 2

在第一个例子中,没有提供Point构造函数的参数,因此编译器将使用0作为xy的值。在第二个例子中,提供了一个参数,它将用于绑定到第一个构造函数参数。因此,x将是1y将是0。在第三个和最后一个例子中,提供了两个参数,它们按照这个顺序绑定到xy。因此,x是 1,y2

命名参数

命名参数使我们能够通过它们的名称而不是在参数列表中的位置来调用方法。参数可以以任何顺序指定,并且与默认参数结合使用,我们可以为方法调用指定部分参数。通过指定参数名称后跟冒号(:)和值来提供命名参数。

让我们考虑以下例子:

Point p1 = new Point(x: 1, y: 2); // x = 1, y = 2
Point p2 = new Point(1, y: 2);    // x = 1, y = 2
Point p3 = new Point(x: 1, 2);    // x = 1, y = 2
Point p4 = new Point(y: 2);       // x = 0, y = 2
Point p5 = new Point(x: 1);       // x = 1, y = 0

前三个构造函数调用是等效的;p1p2p3代表同一个点。构造函数的调用使用了一个或多个命名参数,但效果是相同的。另一方面,构造p4时,只指定了y的值。因此,x将是0y将是2。最后,通过仅指定x的命名参数来创建p5。因此,x将是1y将是0

访问修饰符

访问修饰符用于定义 C#中类型或成员的可见性。它指定程序集中的其他代码部分或其他程序集可以访问类型或类型成员的内容。C#定义了六种类型的访问修饰符,如下所示:

  • public:公共字段可以被同一程序集中的任何代码部分或另一个程序集中的代码访问。

  • protected:受保护类型或成员只能在当前类和派生类中访问。

  • internal:内部类型或成员只能在当前程序集中访问。

  • protected internal:这是protectedinternal访问级别的组合。受保护的内部类型或成员可以在当前程序集中或派生类中访问。

  • private:私有类型或成员只能在类或结构内部访问。这是 C#中定义的最不可访问级别。

  • private protected:这是privateprotected访问级别的组合。私有受保护类型或类型成员可以被同一类中的代码或派生类中的代码访问,但只能在同一程序集中。

尝试访问超出其访问级别的类型或类型成员将导致编译时错误。

适用于类型和类型成员的可访问性有不同种类的规则:

  • publicinternal(默认)。另一方面,派生类不能比其基类型具有更大的可访问性。这意味着如果有一个internalB,则不能从中派生一个publicD

  • publicinternalprivate。这些规则适用于嵌套的结构和类。类和结构成员的默认访问级别是private。私有的嵌套类型只能从包含类型中访问。成员的可访问性不能大于包含它的类型。

此外,字段、属性或事件的类型必须至少与字段本身一样可访问。类似地,方法、索引器或委托的返回类型以及其参数的类型不能比成员本身更不可访问。

  • publicstatic。终结器不能有访问修饰符。直接在命名空间中定义的接口可以是publicinternal(默认)。访问修饰符不能应用于任何接口成员,它们隐式为public。类似地,枚举成员隐式为public,并且不能有访问修饰符。委托类似于类和结构-当直接在命名空间中定义时,默认访问级别为internal,在另一个类型中嵌套时为private

以下代码显示了类型和类型成员的访问修饰符的各种用法:

public interface IEngine
{
   double Power { get; }
   void Start();
}
public class DieselEngine : IEngine
{
   private double _power;
   public double Power { get { return _power; } }
   public void Start() { }
}

在本章中,我们学习了如何定义自定义类。到目前为止的所有示例中,整个类都是在一个地方定义的。然而,可以将一个类分割成几个不同的定义,可以在同一个文件或不同的文件中进行,这是我们将在下一节中讨论的内容。

部分类

部分类允许我们将类分成多个类定义,当一个类变得非常庞大或者我们想要逻辑上将一个类分成多个部分时,这是非常有用的。这使得诸如 WPF 之类的技术能够更好地工作,因为用户代码和 IDE 设计者编写的代码被分隔到不同的源文件中。

每个部分可以使用partial关键字在不同的源文件中定义。此关键字必须立即出现在class关键字之前。这些部分必须在编译时可用。在编译过程中,这些部分被合并成一个单一类型。

partial关键字不仅可以应用于类,还可以应用于结构、接口和方法。所有这些都适用相同的规则。

这里展示了partial类的一个示例:

partial class Employee
{
    partial void Promote();
}
partial class Employee
{
    public int EmployeeId { get; set; }
}
partial class Employee
{
    public string FirstName { get; set; }
    public string LastName { get; set; }
    partial void Promote()
    {
        Console.WriteLine("Employee promoted!");
    }
}

在这里,我们将类定义分成了两个partial类。两个partial类都包含一些属性。我们可以实例化partial类并像普通类一样使用它的属性。参考以下代码片段:

Employee obj = new Employee()
{
    EmployeeId = 1,
    FirstName = "John",
    LastName = "Doe"
};
obj.Promote();

以下列表包含了部分类型的属性,以及它们的规则:

  • 所有部分必须具有相同的可访问性。

  • 不同的部分可以指定不同的基接口。最终类型将实现所有列出的接口。

  • 如果多个部分指定了一个基类,那么它必须是相同的基类,因为 C#不支持多重继承。基类只能在一个部分上指定。其他部分是可选的。

  • 所有部分的属性在编译时合并在一起。最终类型将具有所有部分声明中使用的属性。

  • 嵌套类也可以是 partial 的。

方法也可以是 partial 的。这使我们能够在partial类或结构的一个部分中提供签名,而在另一个部分中提供实现。这在 IDE 中很有用,可以提供开发人员可能或可能不实现的方法挂钩。如果一个 partial 方法没有实现,它将在编译时从类定义中移除。partial 方法不能有访问修饰符,它们是隐式私有的。此外,partial 方法不能返回值;partial 方法的返回类型必须是void

结构

到目前为止,本章的内容都集中在类上。作为类定义的类型是引用类型。然而,在.NET 和 C#中,还有另一类类型:值类型。值类型具有值语义,这意味着在赋值时复制的是对象的值,而不是对象的引用。

值类型使用struct关键字来定义,而不是class。在大多数方面,结构与类是相同的,本章介绍的特性也适用于结构。然而,它们也有一些关键的区别:

  • 结构不支持继承。虽然一个结构可以实现任意数量的接口,但它不能从另一个结构派生。因此,结构成员不能有protected访问修饰符。此外,结构的方法或属性不能是abstractvirtual

  • 结构不能声明默认(无参数)构造函数。

  • 结构可以在不使用new运算符的情况下实例化。

  • 在结构声明中,除非声明为conststatic,否则字段不能被初始化。

让我们考虑以下示例,在这个示例中,我们定义了一个名为Point的结构,它有两个整数字段:

struct Point
{
    public int x;
    public int y;
}

我们可以使用new运算符来实例化它,这将调用默认构造函数,将所有成员字段初始化为它们的默认值,或者直接在没有new运算符的情况下实例化它。在这种情况下,成员字段将保持未初始化状态。这可能对性能有用,但在所有字段正确初始化之前,这样的对象不能被使用:

Point p = new Point()
{
    x = 2,
    y = 3
};

前面的代码使用new运算符来创建类型的实例。另一方面,在下面的例子中,对象是在没有new运算符的情况下创建的:

Point p;
p.x = 2;
p.y = 3;

虽然结构和类有许多共同之处,但它们在一些关键方面也有所不同。重要的是要理解何时应该使用类,何时应该使用结构。在以下情况下应该使用结构:

  • 当它表示单个值(例如一个点,一个 GUID 等)

  • 当它很小(通常不超过 16 个字节)

  • 当它是不可变的时候

  • 当它是短暂的时候

  • 当它在装箱和拆箱操作中不经常使用(这会影响性能)

在所有其他情况下,类型应该被定义为类。

值类型的变量不能被赋予null值。然而,对于值类型来说,当没有值是有效的值时,可以使用可空值类型(使用简写语法表示为T?)。可空类型在第二章 数据类型和运算符中已经讨论过。

以下是一个将可空的Point变量赋值为null的示例:

Point? p = null;

文献中经常提到值类型的实例存储在堆栈上。这种说法只是部分正确的。堆栈是一个实现细节;它不是值类型的特征之一。值类型的局部变量或临时变量确实存储在堆栈上(除非它们没有封闭在 lambda 或匿名方法的外部变量中),并且不是迭代器块的一部分。

否则,它们通常存储在堆上。然而,这完全是一个实现和编译器的细节,事实上,值类型可以存储在许多地方:在堆栈中,在 CPU 寄存器中,在 FPU 帧上,在垃圾收集器管理的堆上,在 AppDomain 的加载器堆中,或者在线程本地存储中(如果变量具有ThreadStorage属性)。

当将值类型对象(存储位置直接包含值)赋给引用类型对象(存储位置包含实际值的引用)时,会发生装箱的过程。反之,这个过程称为拆箱。我们在本书中之前已经讨论过这两个过程,在第二章 数据类型和运算符中。

看一下下面的例子:

struct Point
{
   public int X { get; }
   public int Y { get; }
   public Point(int x = 0, int y = 0)
   {
      X = x;
      Y = y;
   }
}
Point p1 = new Point(2, 3);
Point p2 = new Point(0, 3);
if (p1.Equals(p2)) { /* do something */ }

在这里,我们有两个Point值类型的变量,我们想要检查它们是否相等。为此,我们调用了在System.Object基类中定义的Equals()方法。当我们这样做时,会发生装箱,因为Equals的参数是一个对象,即一个引用类型。如果装箱频繁进行,可能会成为性能问题。有两种方法可以避免对值类型进行装箱。

第一种解决方案是实现包含单个Equals(T)方法的IEquatable<T>接口。这个方法允许值类型和引用类型都实现一种确定两个对象是否相等的方式。这个接口被泛型集合用于在各种方法中测试相等性。因此,出于性能原因,所有可能存储在泛型集合中的类型都应该实现这个接口。

实现了实现IEquatable<T>Point结构如下:

struct Point : IEquatable<Point>
{
    public int X { get; }
    public int Y { get; }
    public Point(int x = 0, int y = 0)
    {
        X = x;
        Y = y;
    }
    public bool Equals(Point other)
    {
        return X == other.X && Y == other.Y;
    }
    public override bool Equals(object obj)
    {
        if (obj is Point other)
        {
            return this.Equals(other);
        }
        return false;
    }
    public override int GetHashCode()
    {
        return X.GetHashCode() * 17 + Y.GetHashCode();
    }
}

在这个例子中,你应该注意到IEquatable的泛型类型参数是类型本身,即Point。这是一种称为奇异递归模板模式的技术。该类实现了Equals(Point),检查类型的属性。然而,它还重写了System.Object虚拟方法,Equals()GetHashCode(),确保这两个实现是一致的。

在实现IEquatable<T>接口时,应牢记以下几点:

  • Equals(T)Equals(object)必须返回一致的结果。

  • 如果值是可比较的,那么它也应该实现IComparable<T>

  • 如果类型实现了IComparable<T>,那么它也应该实现IEquatable<T>

第二种解决方案是重载==!=运算符。可以这样做:

struct Point
{
    public int X { get; }
    public int Y { get; }
    public Point(int x = 0, int y = 0)
    {
        X = x;
        Y = y;
    }
    public override bool Equals(object obj)
    {
        if (obj is Point other)
        {
            return this.Equals(other);
        }
        return false;
    }
    public override int GetHashCode()
    {
        return X.GetHashCode() * 17 + Y.GetHashCode();
    }
    public static bool operator !=(Point p1, Point p2)
    {
        return p1.X != p2.X || p1.Y != p2.Y;
    }
    public static bool operator ==(Point p1, Point p2)
    {
        return p1.X == p2.X && p1.Y == p2.Y;
    }
}

在这种情况下,我们将不再使用Equals()来比较值,而是使用两个运算符==!=

Point p1 = new Point(2, 3);
Point p2 = new Point(0, 3);
if (p1 == p2) { /* do something */ }

然而,如果你想要能够双向检查相等性,也可以同时实现IEquatable<T>和重载比较运算符。我们将在第五章 C#面向对象编程中更详细地讨论运算符重载。

枚举

枚举是一组命名的整数常量。我们使用enum关键字声明枚举。枚举是值类型。当我们想要为某个特定目的使用有限数量的整数值时,枚举非常有用。定义和使用枚举有几个优点:

  • 我们使用命名常量而不是文字值。这样做使代码更易读和更易于维护。

  • 当使用诸如 Visual Studio 之类的 IDE 时,可以看到可以分配给变量的可能值列表。

  • 它强制使用数字常量进行类型安全。

下面的例子显示了一个名为Priority的枚举,有四个可能的值:

enum Priority
{
    Low,
    Normal,
    Important,
    Urgent
}

枚举的每个元素代表一个整数值。默认情况下,第一个标识符被分配为零(0)。每个后续标识符的值将递增一。还可以为每个元素指定显式值。以下规则适用:

  • 这些值必须在基础类型的范围内。

  • 这些值不必是连续的或有序的。

  • 可以定义具有相同数值的多个标识符。

如定义的枚举,语义上等同于以下情况,其中值是显式指定的:

enum Priority
{
    Low = 0,
    Normal = 1,
    Important = 2,
    Urgent = 3
}

如前所述,枚举的每个元素都可以具有任何数值。下面的例子显示了Priority枚举的定义。其中一些元素具有显式值,其他元素是基于它们计算的:

enum Priority
{
    Low = 10,
    Normal,
    Important = 20,
    Urgent
}

在这个实现中,Low是 10,Normal是 11,Important是 20,Urgent是 21。

枚举的默认基础类型是int,但可以指定任何整数类型作为基础类型。char类型不能作为枚举的基础类型。在下面的例子中,bytePriority的基础类型:

enum Priority : byte
{
    Low = 10,
    Normal,
    Important = 20,
    Urgent
}

要使用枚举的元素,需要指定枚举名称,后跟一个点(.)和元素名称,例如Priority.Normal

Priority p = Priority.Normal;
Console.WriteLine(Priority.Urgent);

可以将基础类型的任何值分配给枚举变量,即使不存在具有相应数值的元素。这只能通过强制转换来实现。但是,文字0可以隐式转换为任何枚举类型,无需强制转换:

Priority p1 = (Priority)42;   // p1 is 42
Priority p2 = 0;              // p2 is 0
Priority p3 = (int)10;        // p3 is Low

另一方面,枚举和整数类型之间没有隐式转换。要获得枚举标识符的整数值,必须使用显式转换,如下所示:

int i = (int)Priority.Normal;

因为枚举的所有元素的引用在编译时都被替换为它们的文字值,所以改变枚举元素的值会影响引用的程序集。当枚举类型在其他程序集中使用时,数值将存储在这些程序集中。除非重新编译,否则对枚举的更改不会反映在依赖的程序集中。

如果需要从字符串解析枚举值,可以使用通用的Enum.TryParse()方法,如下例所示:

Enum.TryParse("Normal", out Priority p); // p is Normal

然而,如果要从字符串中解析并忽略大小写,则需要使用相同方法的非泛型重载,如下所示:

Enum.TryParse(typeof(Priority), "normal", true, out object o);
Priority p = (Priority)o;   // p is Normal

在这个例子中,字符串"normal"被解析,忽略大小写以识别Priority枚举的可能值。输出参数中返回的值是Priority.Normal

命名空间

在本书中我们已经多次提到了命名空间,但没有解释它们到底是什么。命名空间用于将代码组织成逻辑单元。命名空间定义了一个包含类型的声明空间。这个声明空间有一个名称,是类型名称的一部分。例如,.NET 类型StringSystem命名空间中声明。类型的完整名称是System.String。这被称为类型的完全限定名称。通常,我们只使用类型的未限定名称(在这种情况下是String),因为我们使用using指令从特定命名空间将声明引入当前范围。

命名空间主要用于两个目的:

  • 为了帮助组织代码。通常,属于一起的类型在同一个命名空间中声明。

  • 为了避免类型可能的名称冲突。程序可能依赖于不同的库,很可能在这些库中存在同名的类型。通过使用具有高度唯一性的命名空间,可以大大减少名称冲突的机会。

  • 命名空间是用namespace关键字引入的。它们是隐式公共的,当声明它们时不能使用访问修饰符。命名空间可以包含任意数量的类型(类、结构、枚举或委托)。

以下示例显示了如何定义一个名为chapter_04的命名空间:

namespace chapter_04
{
   class foo { }
}

命名空间可以嵌套;一个命名空间可以包含其他命名空间。下面的代码片段中展示了一个例子,其中chapter_04命名空间包含一个名为demo的嵌套命名空间:

namespace chapter_04
{
   namespace demo
   {
      class foo { }
   }
}

在这个例子中,foo类型的完全限定名称是chapter_04.demo.foo

为了简洁起见,嵌套命名空间可以用简写语法声明:只需要一个命名空间声明,而不是多个。命名空间的名称是所有命名空间名称的连接,用点分隔。前面的声明等同于以下内容:

namespace chapter_04.demo
{
   class foo { }
}

要使用foo类型的实例,您必须使用它的完全限定名称,如下所示:

namespace chapter_04
{
    class Program
    {
        static void Main(string[] args)
        {
            var f = new chapter_04.demo.foo();
        }
    }
}

为了避免这种情况,您可以使用using指令,指定命名空间名称如下:

using chapter_04.demo;
namespace chapter_04
{
    class Program
    {
        static void Main(string[] args)
        {
            var f = new foo();
        }
    }
}

using指令只能存在于命名空间级别(而不是局部于方法或类型)。通常,您将它们放在源文件的开头,在这种情况下,它的类型在整个源代码中都可用。或者,您可以将它们指定在特定的命名空间中,在这种情况下,它的类型只对该命名空间可用。

命名空间被称为是开放式的。这意味着您可以有多个具有相同名称的命名空间声明,无论是在同一个源文件中还是在不同的源文件中。在这种情况下,所有这些声明都代表同一个命名空间,并且贡献到同一个声明空间。下面的代码片段演示了这种情况的一个例子:

namespace chapter_04.demo
{
   class foo { }
}
namespace chapter_04.demo
{
   class bar { }
}

有一个隐式命名空间,它是所有命名空间的根(包含所有未在命名空间中声明的命名空间和类型)。这个命名空间叫做global。如果您需要包含它以指定完全限定名称,那么您必须用::而不是点来分隔它,就像global::System.String一样。这在命名空间名称冲突的情况下可能是必要的。这里有一个例子:

namespace chapter_04.System
{
    class Console { }

    class Program
    {
        static void Main(string[] args)
        {
            global::System.Console.WriteLine("Hello, world!");
        }
    }
}

在这个例子中,如果没有global::别名,用户定义的chapter_04.System.Console类型将在Main()函数中使用,而不是预期的System.Console类型。

总结

在本章中,我们已经学习了 C#中的用户定义类型。我们学习了类和结构,这些帮助我们在 C#中创建自定义用户类型。我们还学习了如何在类中创建和使用字段、属性、方法、索引器和构造函数,以及我们学习了thisstatic关键字。

我们探讨了访问修饰符的概念,并了解了如何为类型和成员定义不同级别的访问。我们还学习了refinout参数修饰符,以及具有可变数量参数的方法。最后但同样重要的是,我们学习了命名空间以及如何使用它们来组织代码并避免名称冲突。

在下一章中,我们将学习面向对象编程OOP)的概念。我们将探讨 OOP 的构建模块——封装、继承、多态和抽象。我们还将学习抽象类和接口。

测试你所学到的知识

  1. 什么是类,什么是对象?

  2. 类和结构之间有什么区别?

  3. 什么是只读字段?

  4. 什么是表达式主体定义?

  5. 默认构造函数是什么,静态构造函数又是什么?

  6. 什么是自动实现属性?

  7. 索引器是什么,它们如何定义?

  8. 静态类是什么,它可以包含什么?

  9. 参数修饰符是什么,它们有什么不同?

  10. 什么是枚举,它们在什么时候有用?

第五章:C#中的面向对象编程

在上一章中,我们介绍了用户定义类型,并学习了类、结构和枚举。在本章中,我们将学习面向对象编程(简称OOP)。对 OOP 概念的深入理解对使用 C#编写更好的程序至关重要。面向对象编程可以减少代码复杂性,增加代码可重用性,并使软件易于维护和扩展。

我们将详细介绍以下概念:

  • 理解面向对象编程

  • 抽象

  • 封装

  • 继承

  • 多态

  • SOLID 原则

通过本章的学习,您将学会如何使用面向对象编程创建类和方法。让我们从面向对象编程的概述开始。

理解面向对象编程

面向对象编程是一种允许我们围绕对象编写程序的范式。正如前一章中讨论的,对象包含数据和用于操作该数据的方法。每个对象都有自己的一组数据和方法。如果一个对象想要访问另一个对象的数据,它必须通过该对象中定义的方法进行访问。一个对象可以使用继承的概念继承另一个对象的属性。因此,我们可以说面向对象编程是围绕数据和允许对数据进行的操作组织起来的。

C#是一种通用多范式编程语言。面向对象编程只是其中的一种范式。其他支持的范式,如泛型和函数式编程,将在后续章节中讨论。

在讨论面向对象编程时,了解类和对象之间的区别是很重要的。如前所述,在上一章中,类是定义数据及其在内存中的表示以及操作这些数据的功能的蓝图。另一方面,对象是根据蓝图构建和运行的类的实例。它在内存中有一个物理表示,而类只存在于源代码中。

在进行面向对象编程时,您首先要确定需要操作的实体,它们之间的关系以及它们如何相互作用。这个过程称为数据建模。其结果是一组概括了确定实体的类。这些实体可以是从物理实体(人、物体、机器等)到抽象实体(订单、待办事项列表、连接字符串等)的各种形式。

抽象、封装、多态和继承是面向对象编程的核心原则。我们将在本章中详细探讨它们。

抽象

抽象是通过去除非必要的特征来以简单的方式描述实体和过程的过程。一个物理或抽象实体可能有许多特征,但是对于某些应用程序或领域的目的,并不是所有特征都是重要的。通过将实体抽象为简单模型(对应应用程序领域有意义的模型),我们可以构建更简单、更高效的程序。

让我们以员工为例。员工是一个人。一个人有姓名、生日、身体特征(如身高、体重、头发颜色、眼睛颜色)、亲戚和朋友、喜欢的爱好(如食物、书籍、电影、运动)、地址、财产(如房屋或公寓、汽车或自行车)、一个或多个电话号码和电子邮件地址,以及我们可以列出的许多其他事物。

根据我们构建的应用程序的类型,其中一些特征是相关的,而另一些则不是。例如,如果我们构建一个工资系统,我们对员工的姓名、生日、地址、电话和电子邮件感兴趣,以及入职日期、部门、职位、工资等。如果我们构建一个社交媒体应用程序,我们对用户的姓名、生日、地址、亲戚、朋友、兴趣、活动等感兴趣。

有时需要不同级别的抽象 - 一些更一般的,另一些更具体的。例如,如果我们构建一个可以绘制形状的图形系统,我们可能需要用少量功能模拟一个通用形状,比如能够自己绘制或变换(平移和旋转)的能力。然后我们可以有二维形状和三维形状,每个都有更具体的属性和功能,基于这些形状的特征。

我们可以将线条、椭圆和多边形构建为二维形状。一条线有诸如起点和终点之类的属性,但一个椭圆有两个焦点,以及一个面积和周长。三维对象,如立方体,可以投下阴影。虽然我们仍在抽象概念,但我们已经从更一般的抽象转向更具体的抽象。当这些抽象是基于彼此的时,实现它们的典型方式是通过继承。

封装

封装被定义为将数据和操作数据的代码绑定在一个单元中。数据被私下绑定在一个类中,外部无法直接访问。所有需要读取或修改对象数据的对象都应该通过类提供的公共方法来进行。这种特性被称为数据隐藏,通过定义有限数量的对象数据入口点,使代码更不容易出错。

让我们来看看这里的Employee类:

public class Employee
{
    private string name;
    private double salary;
    public string Name
    {
        get { return name; }
        set { name = value; }
    }
    public double Salary
    {
        get { return salary; }
    }
    public Employee(string name, double salary)
    {
        this.name = name;
        this.salary = salary;
    }
    public void GiveRaise(double percent)
    {
        salary *= (1.0 + percent / 100.0);
    }
}

这里模拟了员工有两个属性:namesalary。这些被实现为private类字段,这使它们只能从Employee类内部访问。这两个值在构造函数中设置。名字通过名为Name的属性暴露出来以供读取和写入。然而,salary变量只暴露出来供读取,使用名为Salary的只读属性。要更改工资,我们必须调用GiveRaise()方法。当然,这只是一种可能的实现。我们可以使用自动实现的属性而不是字段,或者可能使用不同的其他方法来修改工资。这个类可以如下使用:

Employee employee = new Employee("John Doe", 2500);
Console.WriteLine($"{employee.Name} earns {employee.Salary}");
employee.GiveRaise(5.5);
Console.WriteLine($"{employee.Name} earns {employee.Salary}");

我们已经创建了Employee类的对象,并使用构造函数将值设置为私有字段。Employee类不允许直接访问其字段。要读取它们的值,我们使用公共属性的get访问器。要更改工资,我们使用GiveRaise()方法。该程序的输出如下所示:

图 5.1 - 从前面片段的执行中得到的控制台输出

图 5.1 - 从前面片段的执行中得到的控制台输出

封装允许我们将类内部的数据隐藏起来,这就是为什么它也被称为数据隐藏。

封装很重要,因为它通过为不同组件定义最小的公共接口来减少它们之间的依赖关系。它还增加了代码的可重用性和安全性,并使代码更容易进行单元测试。

继承

继承是一种机制,通过它一个类可以继承另一个类的属性和功能。如果我们有一组多个类共享的常见功能和数据,我们可以将它们放在一个称为类的类中。其他类可以继承父类的这些功能和数据,同时扩展或修改它们并添加额外的功能和属性。从另一个类继承的类称为派生类。因此,继承有助于代码重用

在 C#中,继承仅支持引用类型。只有定义为类的类型才能从其他类型派生。定义为结构的类型是值类型,不能从其他类型派生。但是,在 C#中,所有类型都是值类型或引用类型,并且间接从System.Object类型派生。这种关系是隐式的,不需要开发人员做任何特殊处理。

C#支持三种类型的继承:

  • 单一继承:当一个类从一个父类继承时。子类不应该作为任何其他类的父类。参考下图,类 B 从类 A 继承:

图 5.2 - 类图显示类 B 从类 A 继承

图 5.2 - 类图显示类 B 从类 A 继承

  • 多级继承:这实际上是对前一种情况的扩展,因为子类又是另一个类的父类。在下图中,类 B 既是类 A 的子类,也是类 C 的父类:

图 5.3 - 类图显示类 A 作为类 B 的基类,而类 B 又是类 C 的基类

图 5.3 - 类图显示类 A 作为类 B 的基类,而类 B 又是类 C 的基类

  • 分层继承:一个类作为多个类的父类。参考下图。这里,类 B 和类 C 都继承自同一个父类 A:

图 5.4 - 类图显示类 B 和类 C 从基类 A 继承

图 5.4 - 类图显示类 B 和类 C 从基类 A 继承

与其他编程语言(如 C++)不同,C#不支持多重继承。这意味着一个类不能从多个类派生。

要理解继承,让我们考虑以下示例:我们正在构建一个必须表示地形、障碍、人物、机械等对象的游戏。这些是具有不同属性的各种类型的对象。例如,人和机器可以移动和战斗,障碍可以被摧毁,地形可以是可穿越的或不可穿越的,等等。然而,所有这些游戏对象都有一些共同的属性:它们都在游戏中有一个位置,并且它们都可以在表面上绘制(可以是屏幕、内存等)。我们可以表示一个提供这些功能的基类如下:

class GameUnit
{
    public Position Position { get; protected set; }
    public GameUnit(Position position)
    {
        Position = position;
    }
    public void Draw(Surface surface)
    {
        surface.DrawAt(GetImage(), Position);
    }
    protected virtual char GetImage() { return ' '; }
}

GameUnit是一个具有名为Position的属性的类;访问器get是公共的,但访问器set是受保护的,这意味着它只能从这个类或它的派生类中访问。Draw()公共方法在当前单位位置在表面上绘制单位。GetImage()是一个虚方法,返回单位的表示(在我们的例子中是一个单一字符)。在基类中,这只返回一个空格,但在派生类中,这将被实现为返回一个实际字符。

这里看到的PositionSurface类的实现如下:

struct Position
{
    public int X { get; private set; }
    public int Y { get; private set; }
    public Position(int x = 0, int y = 0)
    {
        X = x;
        Y = y;
    }
}
class Surface
{
    private int left;
    private int top;
    public void BeginDraw()
    {
        Console.Clear();
        left = Console.CursorLeft;
        top = Console.CursorTop;
    }
    public void DrawAt(char c, Position position)
    {
        try
        {
            Console.SetCursorPosition(left + position.X, 
                                      top + position.Y);
            Console.Write(c);
        }
        catch (ArgumentOutOfRangeException e)
        {
            Console.Clear();
            Console.WriteLine(e.Message);
        }
    }
}

现在,我们将从基类派生出几个其他类。为了简单起见,我们将暂时专注于地形对象:

class Terrain : GameUnit
{
    public Terrain(Position position) : base(position) { }
}
class Water : Terrain
{
    public Water(Position position) : base(position) { }
    protected override char GetImage() { return '░'; }
}
class Hill : Terrain
{
    public Hill(Position position) : base(position) { }
    protected override char GetImage() { return '≡'; }
}

我们在这里定义了一个从GameUnit派生的Terrain类,它本身是所有类型地形的基类。在这个类中我们没有太多东西,但在一个真实的应用程序中,会有各种功能。WaterHill是从Terrain派生的两个类,它们覆盖了GetImage()类,返回一个不同的字符来表示地形。我们可以使用这些来构建一个游戏:

var objects = new List<v1.GameUnit>()
{
    new v1.Water(new Position(3, 2)),
    new v1.Water(new Position(4, 2)),
    new v1.Water(new Position(5, 2)),
    new v1.Hill(new Position(3, 1)),
    new v1.Hill(new Position(5, 3)),
};
var surface = new v1.Surface();
surface.BeginDraw();
foreach (var unit in objects)
    unit.Draw(surface);

该程序的输出如下截图所示:

图 5.5 - 前一个程序执行的控制台输出

图 5.5 - 前一个程序执行的控制台输出

虚成员

在前面的例子中,我们已经看到了一个虚方法。这是一个在基类中有实现但可以在派生类中被重写的方法,这对于改变实现细节很有帮助。方法默认情况下是非虚的。基类中的虚方法使用virtual关键字声明。派生类中虚方法的重写实现使用override关键字定义,而不是virtual关键字。虚方法和重写方法的方法签名必须匹配。

方法不是唯一可以是虚的类成员。virtual关键字可以应用于属性、索引器和事件。但是virtual修饰符不能与staticabstractprivateoverride修饰符一起使用。

在派生类中重写的虚成员可以在派生类的派生类中进一步重写。这种虚继承链会无限继续,除非使用sealed关键字明确停止,如后续章节所述。

之前显示的类可以修改为在以下代码中使用虚属性Image,而不是虚方法GetImage()。在这种情况下,它们将如下所示:

class GameUnit
{
    public Position Position { get; protected set; }
    public GameUnit(Position position)
    {
        Position = position;
    }
    public void Draw(Surface surface)
    {
        surface.DrawAt(Image, Position);
    }
    protected virtual char Image => ' ';
}
class Terrain : GameUnit
{
    public Terrain(Position position) : base(position) { }
}
class Water : Terrain
{
    public Water(Position position) : base(position) { }
    protected override char Image => '░';
}
class Hill : Terrain
{
    public Hill(Position position) : base(position) { }
    protected override char Image => '≡';
}

有时候你希望一个方法在派生类中被重写,但在基类中不提供实现。这样的虚方法称为抽象,将在下一节中讨论。

抽象类和成员

到目前为止我们看到的例子有一个不便之处,因为GameUnitTerrain类只是一些在游戏中没有实际表示的基类,我们仍然可以实例化它们。这是不幸的,因为我们只希望能够创建WaterHill的对象。此外,GetImage()虚方法或Image虚属性必须在基类中有一个实现,这并没有太多意义。实际上,我们只希望在表示物理对象的类中有一个实现。这可以通过使用抽象类和成员来实现。

使用abstract关键字声明抽象类。抽象类不能被实例化,这意味着我们不能创建抽象类的对象。如果我们尝试创建抽象类的实例,将导致编译时错误。抽象类应该是其他类的基类,这些类将实现类定义的抽象。

抽象类必须包含至少一个抽象成员,可以是方法、属性、索引器或事件。抽象成员也使用abstract关键字声明。从抽象类派生的非抽象类必须实现所有继承的抽象成员和属性访问器。

我们可以使用抽象类和成员重写游戏单位示例。如下所示:

abstract class GameUnit
{
    public Position Position { get; protected set; }
    public GameUnit(Position position)
    {
        Position = position;
    }
    public void Draw(Surface surface)
    {
        surface.DrawAt(Image, Position);
    }
    protected abstract char Image { get; }
}
abstract class Terrain : GameUnit
{
    public Terrain(Position position) : base(position) { }
}
class Water : Terrain
{
    public Water(Position position) : base(position) { }
    protected override char Image => '░';
}
class Hill : Terrain
{
    public Hill(Position position) : base(position) { }
    protected override char Image => '≡';
}

在这个例子中,GameUnit类被声明为abstract。它有一个抽象属性Image,不再有实现。Terrain是从GameUnit派生的,但因为它没有重写抽象属性,它本身是一个抽象类,必须使用abstract修饰符声明。WaterHill类都重写了Image属性,并使用override关键字进行了重写。

抽象类的一些特点如下:

  • 抽象类可以有抽象和非抽象成员。

  • 如果一个类包含抽象成员,则该类必须标记为abstract

  • 抽象成员不能是私有的。

  • 抽象成员不能有实现。

  • 抽象类必须为其实现的所有接口的所有成员提供实现(如果有的话)。

同样,抽象方法或属性具有以下特点:

  • 抽象方法隐式地是虚方法。

  • 声明为抽象的成员不能是staticvirtual

  • 派生类中的实现必须在成员的声明中指定override关键字。

到目前为止,我们已经看到了如何派生和重写类和成员。然而,可以阻止这种情况发生。我们将在下一节中学习如何做到这一点。

封闭类和成员

如果我们想要限制一个类不被另一个类继承,那么我们将该类声明为sealed。如果我们尝试继承一个被封闭的类,将导致编译时错误。我们使用sealed关键字来创建一个封闭的类。参考以下示例:

sealed class Water : Terrain
{
   public Water(Position position) : base(position) { }
   protected override char Image => '░';
}
class Lake : Water  // ERROR: cannot derived from sealed type
{
   public Lake(Position position) : base(position) { }
}

这里的Water类被声明为sealed。尝试将其用作另一个类的基类将导致编译时错误。

不仅可以将类声明为sealed,还可以将重写的成员声明为sealed。类可以通过在override前面使用sealed关键字来阻止成员的虚继承。在进一步派生类中再次尝试重写它将导致编译器错误。

在以下示例中,Water类没有被封闭,但其Image属性被封闭。尝试在Lake派生类中重写它将产生编译器错误:

class Water : Terrain
{
    public Water(Position position) : base(position) { }
    protected sealed override char Image => '░';
}

class Lake : Water
{
    public Lake(Position position) : base(position) { }
    protected sealed override char Image => '░';  // ERROR
}

现在我们已经看到了如何使用封闭类和成员,让我们看看如何隐藏基类成员。

隐藏基类成员

在某些情况下,您可能希望在派生类中使用new关键字在成员的返回类型前面隐藏基类的现有成员,而不是虚调用(即在类层次结构中调用虚方法)。可以通过在派生类中成员的返回类型前面使用new关键字来实现这一点,如以下示例所示:

class Base
{
    public int Get() { return 42; }
}
class Derived : Base
{
    public new int Get() { return 10; }
}

以这种方式定义的新成员在通过对派生类型的引用调用时将被调用。然而,如果通过对基类型的引用调用成员,则将调用隐藏的基成员,如下所示:

Derived d = new Derived();
Console.WriteLine(d.Get()); // prints 10
Base b = d;
Console.WriteLine(b.Get()); // prints 42

与虚方法不同,虚方法是根据用于调用它们的对象的运行时类型在运行时调用的,隐藏方法是根据用于调用它们的对象的编译时类型在编译时解析的。

隐藏成员的一个可能用途在以下示例中显示,我们有一个需要支持克隆方法的类层次结构。然而,每个类应该返回自己的新副本,而不是基类的引用:

class Pet
{
     public string Name { get; private set; }
     public Pet(string name)
     { Name = name; }
     public Pet Clone() { return new Pet(Name); }
}
class Dog : Pet
{
     public string Color { get; private set; }
     public Dog(string name, string color):base(name)
     { Color = color; }
     public new Dog Clone() { return new Dog(Name, Color); }
}

有了这些定义,我们可以编写以下代码:

Pet pet = new Pet("Lola");
Dog dog = new Dog("Rex", "black");
Pet cpet = pet.Clone();
Dog ddog = dog.Clone();

请注意,这仅在我们从该类的对象调用Clone()方法时才起作用,而不是通过对基类的引用。因为调用在编译时解析,如果你有一个对Pet的引用,即使对象的运行时类型是Dog,也只会克隆Pet。这在以下示例中得到了说明:

Pet another = new Dog("Dark", "white");
Dog copy = another.Clone(); // ERROR this method returns a Pet

一般来说,成员隐藏被认为是代码异味(即设计和代码库中存在更深层次问题的指示)并且应该避免。通过成员隐藏实现的目标通常可以通过更好的方式实现。例如,这里显示的克隆示例可以通过使用创建型设计模式,通常是原型模式,但可能还有其他模式,如工厂方法来实现。

到目前为止,在本章中,我们已经看到了如何创建类和类的层次结构。面向对象编程中的另一个重要概念是接口,这是我们接下来要讨论的主题。

接口

接口包含一组必须由实现接口的任何类或结构实现的成员。接口定义了一个由实现接口的所有类型支持的合同。这也意味着使用接口的客户端不需要了解任何关于实际实现细节的信息,这有助于松耦合,有助于维护和可测试性。

因为语言不支持多重类继承或结构的继承,接口提供了一种模拟它们的方法。无论是引用类型还是值类型,类型都可以实现任意数量的接口。

通常,接口只包含成员的声明,而不包含实现。从 C# 8 开始,接口可以包含默认方法;这是一个将在第十五章中详细介绍的主题,C# 8 的新特性。在 C#中,接口使用interface关键字声明。

以下列表包含在使用接口时需要考虑的重要要点:

  • 接口只能包含方法、属性、索引器和事件。它们不能包含字段。

  • 如果一个类型实现了一个接口,那么它必须为接口的所有成员提供实现。接口的方法签名和返回类型不能被实现接口的类型改变。

  • 当一个接口定义属性或索引器时,实现可以为它们提供额外的访问器。例如,如果接口中的属性只有get访问器,实现也可以提供set访问器。

  • 接口不能有构造函数或运算符。

  • 接口不能有静态成员。

  • 接口成员隐式定义为public。如果尝试在接口的成员上使用访问修饰符,将导致编译时错误。

  • 一个接口可以被多种类型实现。一个类型可以实现多个接口。

  • 如果一个类从另一个类继承并同时实现一个接口,那么基类名称必须在接口名称之前,用逗号分隔。

  • 通常,接口名称以字母I开头,比如IEnumerableIList<T>等等。

为了理解接口的工作原理,我们将考虑游戏单位的示例。

在以前的实现中,我们有一个名为Surface的类,负责绘制游戏对象。我们的实现是打印到控制台,但这可以是任何东西——游戏窗口、内存、位图等等。为了能够轻松地在这些之间进行切换,并且不将GameUnit类与特定的表面实现绑定,我们可以定义一个接口,指定任何实现必须提供的功能。然后游戏单位将使用这个接口进行渲染。这样的接口可以定义如下:

interface ISurface
{
    void BeginDraw();
    void EndDraw();
    void DrawAt(char c, Position position);
}

它包含三个成员函数,都是隐式的public。然后Surface类将实现这个接口:

class Surface : ISurface
{
    private int left;
    private int top;
    public void BeginDraw()
    {
        Console.Clear();
        left = Console.CursorLeft;
        top = Console.CursorTop;
    }
    public void EndDraw()
    {
        Console.WriteLine();
    }
    public void DrawAt(char c, Position position)
    {
        try
        {
            Console.SetCursorPosition(left + position.X, 
                                      top + position.Y);
            Console.Write(c);
        }
        catch (ArgumentOutOfRangeException e)
        {
            Console.Clear();
            Console.WriteLine(e.Message);
        }
    }
}

该类必须实现接口的所有成员。但是也可以跳过。在这种情况下,类必须是抽象的,并且必须声明抽象成员以匹配它没有实现的接口成员。

在前面的例子中,Surface类实现了ISurface接口的所有三个方法。这些方法被明确声明为public。使用其他访问修饰符会导致编译错误,因为接口中的成员在类中是隐式公共的,类不能降低它们的可见性。GameUnit类将发生变化,使得Draw()方法将有一个ISurface参数:

abstract class GameUnit
{
    public Position Position { get; protected set; }
    public GameUnit(Position position)
    {
        Position = position;
    }
    public void Draw(ISurface surface)
    {
        surface?.DrawAt(Image, Position);
    }
    protected abstract char Image { get; }
}

让我们进一步扩展这个例子,考虑另一个名为IMoveable的接口,它定义了一个MoveTo()方法,将游戏对象移动到另一个位置:

interface IMoveable
{
    void MoveTo(Position pos);
}

这个接口将被所有可以移动的游戏对象实现,比如人、机器等等。一个名为ActionUnit的类作为所有这些对象的基类,并实现了IMoveable

abstract class ActionUnit : GameUnit, IMoveable
{
    public ActionUnit(Position position) : base(position) { }
    public void MoveTo(Position pos)
    {
        Position = pos;
    }
}

ActionUnit也是从GameUnit派生的,因此基类出现在接口列表之前。然而,由于这个类只作为其他类的基类,它不实现Image属性,因此必须是抽象的。下面的代码显示了一个从ActionUnit派生的Meeple类:

class Meeple : ActionUnit
{
    public Meeple(Position position) : base(position) { }
    protected override char Image => 'M';
}

我们可以使用Meeple类的实例来扩展我们在之前示例中构建的游戏:

var objects = new List<GameUnit>()
{
    new Water(new Position(3, 2)),
    new Water(new Position(4, 2)),
    new Water(new Position(5, 2)),
    new Hill(new Position(3, 1)),
    new Hill(new Position(5, 3)),
    new Meeple(new Position(0, 0)),
    new Meeple(new Position(4, 3)),
};
ISurface surface = new Surface();
surface.BeginDraw();
foreach (var unit in objects)
   unit.Draw(surface);
surface.EndDraw();

该程序的输出如下:

图 5.6 - 修改后的游戏执行的控制台输出

图 5.6 - 修改后的游戏执行的控制台输出

现在我们已经了解了继承,是时候看看面向对象编程的最后一个支柱,即多态性了。

多态性

面向对象编程的最后一个核心支柱是多态性。多态性是一个希腊词,代表着多种形式。这是使用一个实体的多种形式的能力。有两种类型的多态性:

  • 编译时多态性:当我们有相同名称但参数数量或类型不同的方法时,这被称为方法重载。

  • 运行时多态性:这有两个不同的方面:

一方面,派生类的对象可以无缝地用作数组或其他类型的集合、方法参数和其他位置中的基类对象。

另一方面,类可以定义虚拟方法,可以在派生类中重写。在运行时,公共语言运行时CLR)将调用与对象的运行时类型相对应的虚拟成员的实现。当派生类的对象被用于替代基类的对象时,对象的声明类型和运行时类型不同时。

多态性促进了代码重用,这可以使代码更容易阅读、测试和维护。它还促进了关注点的分离,这是面向对象编程中的一个重要原则。另一个好处是它有助于隐藏实现细节,因为它允许通过一个公共接口与不同的类进行交互。

在前面的章节中,我们已经看到了这两个方面的例子。我们已经看到了如何声明虚拟成员以及如何重写它们,以及如何使用sealed关键字停止虚拟继承。我们还看到了派生类的对象在基类数组中的使用的例子。这里再次是这样一个例子:

var objects = new List<GameUnit>()
{
    new Water(new Position(3, 2)),
    new Hill(new Position(3, 1)),
    new Meeple(new Position(0, 0)),
};

编译时多态性由方法和运算符重载表示。我们将在接下来的章节中探讨这些。

方法重载

方法重载允许我们在同一个类中声明两个或多个具有相同名称但不同参数的方法。这可以是不同数量的参数或不同类型的参数。返回类型不考虑重载解析。如果两个方法只在返回类型上有所不同,那么编译器将发出错误。此外,refinout参数修饰符不参与重载解析。这意味着两个方法不能仅在参数修饰符上有所不同,比如一个方法有一个ref参数,另一个方法有相同的参数指定为inout修饰符。另一方面,一个没有修饰符的参数的方法可以被一个具有相同参数指定为refinout的方法重载。

让我们看下面的例子来理解方法重载。考虑之前显示的IMoveable接口,我们可以修改它,使其包含两个名为MoveTo()的方法,参数不同:

interface IMoveable
{
    void MoveTo(Position pos);
    void MoveTo(int x, int y);
}
abstract class ActionUnit : GameUnit, IMoveable
{
    public ActionUnit(Position position) : base(position) { }
    public void MoveTo(Position pos)
    {
        Position = pos;
    }
    public void MoveTo(int x, int y)
    {
        Position = new Position(x, y);
    }
}

ActionUnit类提供了这两种重载的实现。当调用重载的方法时,编译器会根据提供的参数的类型和数量找到最佳匹配,并调用适当的重载。示例如下:

Meeple m = new Meeple(new Position(3, 4));
m.MoveTo(new Position(1, 1));
m.MoveTo(2, 5);

识别方法调用的最佳匹配的过程称为重载解析。有许多规则定义了如何找到最佳匹配,列出它们都超出了本书的范围。简单来说,重载解析的执行如下:

  1. 创建一个具有指定名称的成员集合。

  2. 消除从调用范围不可访问的所有成员。

  3. 消除所有不适用的成员。适用的成员是指每个参数都有一个参数,并且参数可以隐式转换为参数的类型。

  4. 如果一个成员有一个带有可变数量参数的形式,那么评估它们并消除不适用的形式。

  5. 对于剩下的集合,应用找到最佳匹配的规则。更具体的参数比不太具体的更好。这意味着,例如,更具体的派生类比基类更好。此外,非泛型参数比泛型参数更具体。

与方法重载类似,但语法和语义略有不同的是运算符重载,我们将在下面看到。

运算符重载

运算符重载允许我们针对特定类型提供用户定义的功能。当一个或两个操作数是该类型时,类型可以为可重载的运算符提供自定义实现。

在实现运算符重载时需要考虑的一些重要点如下:

  • operator关键字用于声明运算符。这样的方法必须是publicstatic

  • 赋值运算符不能被重载。

  • 重载运算符方法的参数不应该使用refinout修饰符。

  • 我们不能通过运算符重载改变运算符的优先级。

  • 我们不能改变运算符所需的操作数数量。但是,重载的运算符可以忽略一个操作数。

C#语言有一元、二元和三元运算符。然而,只有前两类运算符可以被重载。让我们从学习如何重载二元运算符开始。

重载二元运算符

二元运算符的至少一个参数必须是TT?类型,其中T是定义运算符的类型。

让我们考虑一下我们想要重载运算符的类型:

struct Complex
{
    public double Real      { get; private set; }
    public double Imaginary { get; private set; }
    public Complex(double real = 0, double imaginary = 0)
    {
        Real = real;
        Imaginary = imaginary;
    }
}

这是一个非常简单的复数实现,只有实部和虚部两个属性。我们希望能够进行加法和减法等算术运算,如下所示:

var c1 = new Complex(2, 3);
var c2 = new Complex(4, 5);
var c3 = c1 + c2;
var c4 = c1 - c2;

要这样做,我们必须按照以下方式重载+-二元运算符(前面显示的Complex结构的部分为简单起见而省略):

public struct Complex
{
    // [...] omitted members
    public static Complex operator +(Complex number1, 
                                     Complex number2)
    {
        return new Complex()
        {
            Real = number1.Real + number2.Real,
            Imaginary = number2.Imaginary + number2.Imaginary
        };
    }
    public static Complex operator -(Complex number1, 
                                     Complex number2)
    {
        return new Complex()
        {
            Real = number1.Real - number2.Real,
            Imaginary = number2.Imaginary - number2.Imaginary
        };
    }
}

我们可能还想要能够进行对象比较。在这种情况下,我们需要重载==!=<><=>=运算符或它们的组合:

if (c3 == c2) { /* do something */}
if (c1 != c4) { /* do something else */}

在下面的清单中,您可以看到Complex类型的==!=运算符的实现:

struct Complex
{
    // [...] omitted members
    public static bool operator ==(Complex number1, 
                                   Complex number2)
    {
        return number1.Real.Equals(number2.Real) &&
               number2.Imaginary.Equals(number2.Imaginary);
    }
    public static bool operator !=(Complex number1, 
      Complex number2)
    {
        return !number1.Real.Equals(number2.Real) ||
               !number2.Imaginary.Equals(number2.Imaginary);
    }
    public override bool Equals(object obj)
    {
        return Real.Equals(((Complex)obj).Real) &&
               Imaginary.Equals(((Complex)obj).Imaginary);
    }
    public override int GetHashCode()
    {
        return Real.GetHashCode() * 17 + 
          Imaginary.GetHashCode();
    }
}

在重载比较运算符时,你必须按照成对实现它们,如前所述:

  • 如果你重载==!=,你必须同时重载它们。

  • 如果你重载<>,你必须同时重载它们。

  • 如果你重载=<>=,你必须同时重载它们。

此外,当你重载==!=时,你还需要重写System.Object的虚拟方法,Equals()GetHashCode()

重载一元运算符

一元运算符的单个参数必须是TT?,其中T是定义运算符的类型。

我们将再次使用Complex类型和增量和减量运算符进行举例。可以实现如下:

struct Complex
{
    // [...] omitted members
    public static Complex operator ++(Complex number)
    {
        return new Complex(number.Real + 1, number.Imaginary);
    }
    public static Complex operator --(Complex number)
    {
        return new Complex(number.Real - 1, number.Imaginary);
    }
}

在这个实现中,增量(++)运算符和减量(--)运算符只改变复数的实部,并返回一个新的复数。然后我们可以编写以下代码来展示这些运算符如何被使用:

var c = new Complex(5, 7);
Console.WriteLine(c);  // 5i + 7
c++;
Console.WriteLine(c);  // 6i + 7
++c;
Console.WriteLine(c);  // 7i + 7

需要注意的是,当调用增量或减量运算符时,操作的对象被赋予一个新值。对于引用类型来说,这意味着被赋予一个新对象的引用。因此,增量和减量运算符不应该修改原始对象并返回对其的引用。通过将Complex类型实现为一个类来理解原因:

public class Complex
{
    // [...] omitted members
    public static Complex operator ++(Complex number)
    {
        // WRONG implementation
        number.Real++;
        return number;
    }
}

这个实现是错误的,因为它会影响对修改后对象的所有引用。考虑以下例子:

var c1 = new Complex(5, 7);
var c2 = c1;
Console.WriteLine(c1);  // 5i + 7
Console.WriteLine(c2);  // 5i + 7
c1++;
Console.WriteLine(c1);  // 6i + 7
Console.WriteLine(c2);  // 6i + 7

最初,c1c2是相等的。然后我们增加了c1的值,由于Complex类中++运算符的实现,c1c2将具有相同的值。正确的实现如下:

class Complex
{
    // [...] omitted members 
    public static Complex operator ++(Complex number)
    {
        return new Complex(number.Real + 1, number.Imaginary);
    }
}

虽然这对值类型不是问题,但你应该养成从一元运算符返回一个新对象的习惯。

SOLID 原则

我们在本章讨论的原则——抽象、封装、继承和多态——是面向对象编程的支柱。然而,这些并不是开发人员在进行面向对象编程时所采用的唯一原则。还有许多其他原则,但在这一点上值得一提的是由缩写SOLID所知的五个原则。这些最初是由 Robert C. Martin 在 2000 年在一篇名为设计原则和设计模式的论文中首次提出的。后来,Michael Feathers 创造了 SOLID 这个术语:

  • S代表单一职责原则,它规定一个模块或类应该只有一个职责,其中职责被定义为变化的原因。当一个类提供的功能可能在不同时间和出于不同原因而发生变化时,这意味着这些功能不应该放在一起,应该分开成不同的类。

  • O代表开闭原则,它规定一个模块、类或函数应该对扩展开放,但对修改关闭。也就是说,当功能需要改变时,这些改变不应该影响现有的实现。继承是实现这一点的典型方式,因为派生类可以添加更多功能或专门化现有功能。扩展方法是 C#中的另一种可用技术。

  • L代表里氏替换原则,它规定如果 S 是 T 的子类型,那么 T 的对象可以被 S 的对象替换而不会破坏程序的功能。这个原则是以首次提出它的 Barbara Liskov 的名字命名的。为了理解这个原则,让我们考虑一个处理形状的系统。我们可能有一个椭圆类,其中有方法来改变它的两个焦点。当实现一个圆时,我们可能会倾向于专门化椭圆类,因为在数学上,圆是具有两个相等焦点的特殊椭圆。在这种情况下,圆必须在这两个方法中将两个焦点设置为相同的值。这是客户端不期望的,因此椭圆不能替换圆。为了避免违反这个原则,我们必须实现圆而不是从椭圆派生。为了确保遵循这个原则,你应该为所有方法定义前置条件和后置条件。前置条件在方法执行之前必须为真,后置条件在方法执行后必须为真。当专门化一个方法时,你只能用更弱的前置条件和更强的后置条件替换它的前置条件和后置条件。

  • I代表接口隔离原则,它规定更小、更具体的接口比更大、更一般的接口更可取。原因是客户端可能只需要实现它需要的功能,而不需要其他的。通过分离职责,这个原则促进了组合和解耦。

  • D代表依赖反转原则,是列表中的最后一个。该原则规定软件实体应依赖于抽象而不是实现。高级模块不应依赖低级模块;相反,它们都应该依赖于抽象。此外,抽象不应依赖具体实现,而是相反。对实现的依赖引入了紧耦合,使得难以替换组件。然而,对高级抽象的依赖解耦了模块,并促进了灵活性和可重用性。

这五个原则使我们能够编写更简单、更易理解的代码,这也使得它更容易维护。同时,它们使代码更具可重用性,也更容易测试。

摘要

在本章中,我们学习了面向对象编程的核心概念:抽象、封装、继承和多态。我们了解了使它们成为可能的语言功能,比如继承、虚成员、抽象类型和成员、密封类型和成员、接口,以及方法和运算符重载。在本章末尾,我们简要讨论了其他被称为 SOLID 的面向对象原则。

在下一章中,我们将学习 C#中的另一种编程范式——泛型编程。

测试你学到的东西

  1. 什么是面向对象编程,其核心原则是什么?

  2. 封装有哪些好处?

  3. 什么是继承,C#支持哪些类型的继承?

  4. 什么是虚方法?重写方法呢?

  5. 如何防止派生类中的虚成员被重写?

  6. 什么是抽象类,它们有哪些特点?

  7. 什么是接口,它可以包含哪些成员?

  8. 存在哪些多态类型?

  9. 什么是重载方法?如何重载运算符?

  10. 什么是 SOLID 原则?

进一步阅读

第六章:泛型

在上一章中,我们学习了 C#中的面向对象编程。在本章中,我们将探讨泛型的概念。泛型允许我们以一种类型安全的环境中使用不同的数据类型创建类、结构、接口、方法和委托。泛型是作为 C# 2.0 版本的一部分添加的。它促进了代码的可重用性和可扩展性,是 C#最强大的特性之一。

在本章中,我们将学习以下概念:

  • 泛型类和泛型继承

  • 泛型接口和变体泛型接口

  • 泛型结构

  • 泛型方法

  • 类型约束

通过本章结束时,您将具备编写泛型类型、方法和变体泛型接口以及使用类型约束所需的技能。

理解泛型

简而言之,泛型是用其他类型参数化的类型。正如我们之前提到的,我们可以创建一个类、结构、接口、方法或委托,它们接受一个或多个数据类型作为参数。这些参数被称为类型参数,充当编译时传递的实际数据类型的占位符

例如,我们可以创建一个模拟列表的类,它是相同类型元素的可变长度序列。我们可以创建一个泛型类,它具有指定其元素实际类型的类型参数。然后,当我们实例化类时,我们将在编译时指定实际类型。

使用泛型的优点包括以下内容:

  • 泛型提供了可重用性:我们可以创建代码的单个版本,并将其用于不同的数据类型。

  • 泛型提倡类型安全:在使用泛型时,我们不需要执行显式类型转换。类型转换由编译器处理。

  • object类型转换为引用类型是耗时的。因此,通过避免这些操作,它们有助于提高执行时间。

泛型类型和方法可以受限,以便只有满足要求的类型可以用作类型参数。关于实际类型的信息用于实例化可以在运行时使用反射获得的泛型类型。

泛型最常见的用途是创建集合或包装类。集合将是下一章的主题。

泛型类型

引用类型和值类型都可以是泛型的。我们已经在本书的早期看到了泛型类型的例子,比如Nullable<T>List<T>

在本节中,我们将学习如何创建泛型类、结构和接口。

泛型类

创建泛型类与创建非泛型类没有区别。唯一不同的是类型参数列表及其在类中作为实际类型的占位符的使用。让我们看一个泛型类的例子:

public class GenericDemo<T>
{
    public T Value { get; private set; }
    public GenericDemo(T value)
    {
        Value = value;
    }
    public override string ToString() => $"{typeof(T)} : {Value}";
}

在这里,我们定义了一个泛型类GenericDemo,它接受一个类型参数T。我们定义了一个名为ValueT类型属性,并在类构造函数中对其进行了初始化。构造函数接受T类型的参数。重写的方法ToString()将返回一个包含属性类型和值的字符串。

要实例化这个泛型类的对象,我们将按以下步骤进行:

var obj1 = new GenericDemo<int>(10);
var obj2 = new GenericDemo<string>("Hello World");

在这个例子中,我们在创建泛型类GenericDemo<T>的对象时为类型参数指定了数据类型。obj1obj2都是相同泛型类型的实例,但它们的类型参数不同:一个是int,另一个是string。因此,它们彼此不兼容。这意味着如果我们尝试将一个对象分配给另一个对象,将导致编译时错误。

我们可以使用反射来获取关于这些对象类型和它们的通用类型参数的信息(我们将在第十一章“反射和动态编程”中进行讨论),如下面的示例所示:

var t1 = obj1.GetType();
Console.WriteLine(t1.Name);
Console.WriteLine(t1.GetGenericArguments()
                    .FirstOrDefault().Name);
var t2 = obj2.GetType();
Console.WriteLine(t2.Name);
Console.WriteLine(t2.GetGenericArguments()
                    .FirstOrDefault().Name);
Console.WriteLine(obj1);
Console.WriteLine(obj2);

执行后,我们将看到以下输出:

图 6.1 - 显示类型反射内容的控制台截图

图 6.1 - 显示类型反射内容的控制台截图

我们可以为泛型类型声明多个类型参数。在这种情况下,我们需要将所有类型参数指定为角括号内的逗号分隔值。以下是一个示例:

class Pair<T, U>
{
    public T Item1 { get; private set; }
    public U Item2 { get; private set; }
    public Pair(T item1, U item2)
    {
        Item1 = item1;
        Item2 = item2;
    }
}
var p1 = new Pair<int, int>(1, 2);
var p2 = new Pair<int, double>(1, 42.99);
var p3 = new Pair<string, bool>("true", true);

在这里,Pair<T, U>是一个需要两个类型参数的类。我们使用不同类型的组合来实例化对象p1p2p3

这个类实际上与.NET 类KeyValueType<TKey,TValue>非常相似,它来自System.Collections.Generic命名空间。实际上,框架提供了许多泛型类。您应该在可能的情况下使用现有类型,而不是定义自己的类型。

泛型类的继承

泛型类可以作为基类派生类。当从泛型类派生时,子类必须指定基类所需的类型参数。这些类型参数可以是实际类型,也可以是派生类的类型参数,即泛型类。

让我们通过这里展示的示例来理解泛型类的继承是如何工作的:

public abstract class Shape<T>
{
    public abstract T Area { get; }
}

我们定义了一个泛型抽象类Shape,其中包含一个表示形状面积的单个抽象属性Area。该属性的类型也是T。考虑这里的类定义:

public class Square : Shape<int>
{
    public int Length { get; set; }
    public Square(int length)
    {
        Length = length;
    }
    public override int Area => Length * Length;
}

在这里,我们定义了一个名为Square的类,它继承自泛型抽象类Shape。我们使用int类型作为类型参数。我们为Square类定义了一个名为Length的属性,并在构造函数中对其进行了初始化。我们重写了Area属性以计算正方形的面积。现在,考虑下面的另一个类定义:

public class Circle : Shape<double>
{
    public double Radius { get; set; }
    public Circle(double radius)
    {
        Radius = radius;
    }
    public override double Area => Math.PI * Radius * Radius;
}

Circle类也继承自泛型抽象类Shape<T>。父类Shape的类型参数现在指定为double。定义了Radius属性来存储圆的半径。我们再次重写了Area属性以计算圆的面积。我们可以如下使用这些派生类:

Square objSquare = new Square(10);
Console.WriteLine($"The area of square is {objSquare.Area}");
Circle objCircle = new Circle(7.5);
Console.WriteLine($"The area of circle is {objCircle.Area}");

我们创建SquareCircle的实例,并将每个形状的面积打印到控制台上。执行后,我们将看到以下输出:

图 6.2 - 正方形和圆的面积显示在控制台上

图 6.2 - 正方形和圆的面积显示在控制台上

重要的是要注意,尽管SquareCircle都是从Shape<T>派生出来的,但这些类型不能被多态地对待。一个是Shape<int>,另一个是Shape<double>。因此,SquareCircle的实例不能放在同质容器中。唯一可能的解决方案是使用object类型来保存对这些实例的引用,然后执行类型转换。

在这个例子中,Shape<T>是一个泛型类型。Shape<int>是从Shape<T>构造出来的类型,通过用int替换类型参数T。这样的类型被称为构造类型。这也是一个封闭构造类型,因为所有类型参数都已被替换。非泛型类型都是封闭类型。泛型类型是开放类型。构造泛型类型可以是开放的或封闭的。开放构造类型是具有未被替换的类型参数的类型。封闭构造类型是任何不是开放的类型。

创建通用类型时另一个重要的事情是,一些运算符,比如算术运算符,不能与类型参数的对象一起使用。让我们看下面的代码来举例说明这种情况:

public class Square<T> : Shape<T>
{
    public T Length { get; set; }
    public Square(T length)
    {
        Length = length;
    }
    /* ERROR: Operator '*' cannot be applied to operands 
    of type 'T' and 'T' */
    public override T Area => Length * Length;
}

Square类型现在是一个通用类型。类型参数T用于基类的类型参数以及Length属性。然而,在计算面积时,使用*运算符会产生编译错误。这是因为编译器不知道T将使用什么具体类型,以及它们是否已重载*运算符。为了确保在任何情况下都不会发生无效实例化,编译器会生成错误。

可以确保只有符合预定义约束的类型在编译时用于实例化通用类型或调用通用方法。这些被称为类型约束,将在本章的类型参数约束部分中讨论。

现在我们已经看到如何创建和使用通用类,让我们看看如何使用通用接口。

通用接口

在前面的例子中,通用类Shape<T>除了一个抽象属性之外什么也没有。这不是一个好的类候选,它应该是一个接口。通用接口与非通用接口的区别与通用类与非通用类的区别相同。以下是一个通用接口的例子:

public interface IShape<T>
{
    public T Area { get; }
}

类型参数的指定方式与类或结构相同。这个接口可以这样实现:

public class Square : IShape<int>
{
    public int Length { get; set; }
    public Square(int length)
    {
        Length = length;
    }
    public int Area => Length * Length;
}
public class Circle : IShape<double>
{
    public double Radius { get; set; }
    public Circle(double radius)
    {
        Radius = radius;
    }
    public double Area => Math.PI * Radius * Radius;
}

SquareCircle类的实现与前一节中所见的略有不同。

具体类,比如这里的SquareCircle,可以实现封闭构造的接口,比如IShape<int>IShape<double>。如果类参数列表提供了接口所需的所有类型参数,通用类也可以实现通用或封闭构造的接口。另一方面,通用接口可以继承非通用接口;然而,通用类必须是逆变的。

通用接口的变异将在下一节中讨论。

变异通用接口

可以将通用接口中的类型参数声明为协变逆变

  • 协变类型参数用out关键字声明,允许接口方法具有比指定类型参数更多派生的返回类型。

  • 逆变类型参数用in关键字声明,允许接口方法具有比指定类型参数更少派生的参数。

具有协变或逆变类型参数的通用接口称为变异通用接口。变异只支持引用类型。

为了理解协变是如何工作的,让我们看看System.IEnumerable<T>通用接口。这是一个变异接口,因为它的类型参数声明为协变。接口定义如下:

public interface IEnumerable
{
    IEnumerator GetEnumerator();
}
public interface IEnumerable<out T> : IEnumerable
{
    IEnumerator<T> GetEnumerator();
}

实现IEnumerable<T>(和其他接口)的类是List<T>。因为T是协变的,我们可以编写以下代码:

IEnumerable<string> names = 
   new List<string> { "Marius", "Ankit", "Raffaele" };
IEnumerable<object> objects = names;

在这个例子中,namesIEnumerable<string>objectsIEnumerable<object>。前者不派生自后者,但string派生自object,并且因为T是协变的,我们可以将names赋值给objects。然而,这只有在使用变异接口时才可能。

实现变异接口的类本身不是变异的,而是不变的。这意味着下面的例子,我们用List<T>替换IEnumerable<T>,将产生编译错误,因为List<string>不能赋值给List<object>

IEnumerable<string> names = 
   new List<string> { "Marius", "Ankit", "Raffaele" };
List<object> objects = names; // error

如前所述,值类型不支持变异。IEnumerable<int>不能赋值给IEnumerable<object>

IEnumerable<int> numbers = new List<int> { 1, 1, 2, 3, 5, 8 };
IEnumerable<object> objects = numbers; // error

总之,接口中的协变类型参数必须:

  • 必须以out关键字为前缀

  • 只能用作方法的返回类型,而不能用作方法参数的类型

  • 不能用作接口方法的泛型约束

逆变是处理传递给接口方法的参数的另一种变体形式。为了理解它是如何工作的,让我们考虑一个情况,我们想要比较各种形状的大小,定义如下:

public interface IShape
{
    public double Area { get; }
}
public class Square : IShape
{
    public double Length { get; set; }
    public Square(int length)
    {
        Length = length;
    }
    public double Area => Length * Length;
}
public class Circle : IShape
{
    public double Radius { get; set; }
    public Circle(double radius)
    {
        Radius = radius;
    }
    public double Area => Math.PI * Radius * Radius;
}

这些与之前使用的类型略有不同,因为IShape不再是泛型,以保持示例简单。我们想要的是能够比较形状。为此,提供了一系列类,如下所示:

public class ShapeComparer : IComparer<IShape>
{
    public int Compare(IShape x, IShape y)
    {
        if (x is null) return y is null ? 0 : -1;
        if (y is null) return 1;
        return x.Area.CompareTo(y.Area);
    }
}
public class SquareComparer : IComparer<Square>
{
    public int Compare(Square x, Square y)
    {
        if (x is null) return y is null ? 0 : -1;
        if (y is null) return 1;
        return x.Length.CompareTo(y.Length);
    }
}
public class CircleComparer : IComparer<Circle>
{
    public int Compare(Circle x, Circle y)
    {
        if (x is null) return y is null ? 0 : -1;
        if (y is null) return 1;
        return x.Radius.CompareTo(y.Radius);
    }
}

在这里,ShapeComparer通过它们的面积比较IShape对象,SquareComparer通过它们的长度比较正方形,CircleComparer通过它们的半径比较圆。所有这些类都实现了System.Collections.Generic命名空间中的IComparer<T>接口。该接口定义如下:

public interface IComparer<in T>
{
    int Compare(T x, T y);
}

这个接口有一个名为Compare()的方法,它接受两个T类型的对象并返回以下之一:

  • 如果第一个小于第二个,则为负数

  • 如果它们相等,则为 0

  • 如果第一个大于第二个,则为正数

然而,其定义的关键是使用类型参数的in关键字,使其逆变。因此,可以在期望SquareCircle的地方传递IShape引用。这意味着我们可以安全地传递IComparer<IShape>到需要IComparer<Square>的地方。让我们看一个具体的例子。

以下类包含一个检查Square对象是否比另一个大的方法。IsBigger()方法还接受一个实现IComparer<Square>的对象的引用:

public class SquareComparison
{
    public static bool IsBigger(Square a, Square b,
                                IComparer<Square> comparer)
    {
        return comparer.Compare(a, b) >= 0;
    }
}

我们可以调用这个方法传递SquareComparerShapeComparer,结果将是相同的:

Square sqr1 = new Square(4);
Square sqr2 = new Square(5);
SquareComparison.IsBigger(sqr1, sqr2, new SquareComparer());
SquareComparison.IsBigger(sqr1, sqr2, new ShapeComparer());

如果IComparer<T>接口是不变的,传递ShapeComparer将导致编译错误。如果我们尝试传递CircleComparer,也会发出编译错误,因为Circle不是Square的派生类,它实际上是继承层次结构中的同级。

总之,接口中的逆变类型参数:

  • 必须以in关键字为前缀

  • 只能用于方法参数,而不能作为返回类型

  • 可以用作接口方法的泛型约束

可以定义一个既是协变又是逆变的接口,如下所示:

interface IMultiVariant<out T, in U>
{
    T Make();
    void Take(U arg);
}

在前面的片段中显示的IMultiVariant<T, U>接口对T是协变的,对U是逆变的。

泛型结构

与泛型类类似,我们也可以创建泛型结构。泛型结构的语法与泛型类相同。在前面的示例中使用的CircleSquare类型很小,可以定义为结构而不是类:

public struct Square : IShape<int>
{
    public int Length { get; set; }
    public Square(int length)
    {
        Length = length;
    }
    public int Area => Length * Length;
}
public struct Circle : IShape<double>
{
    public double Radius { get; set; }
    public Circle(double radius)
    {
        Radius = radius;
    }
    public double Area => Math.PI * Radius * Radius;
}

所有适用于泛型类的规则也适用于泛型结构。因为值类型不支持继承,结构不能从其他泛型类型派生,但可以实现任意数量的泛型或非泛型接口。

泛型方法

C#允许我们创建接受一个或多个泛型类型参数的泛型方法。我们可以在泛型类内部创建泛型方法,也可以在非泛型类内部创建泛型方法。静态方法和非静态方法都可以是泛型的。类型推断的规则对所有类型都是相同的。类型参数必须在方法名之后、参数列表之前的尖括号内声明,就像我们对类型所做的那样。

让我们通过以下示例来了解如何使用泛型方法:

class CompareObjects
{
    public bool Compare<T>(T input1, T input2)
    {
        return input1.Equals(input2);
    }
}

非泛型类CompareObjects包含一个泛型方法Compare,用于比较两个对象。该方法接受两个参数——input1input2。我们使用System.Object基类的Equals()方法来比较输入参数。该方法将根据输入是否相等返回一个布尔值。考虑下面的代码:

CompareObjects comps = new CompareObjects();
Console.WriteLine(comp.Compare<int>(10, 10));
Console.WriteLine(comp.Compare<double>(10.5, 10.8));
Console.WriteLine(comp.Compare<string>("a", "a"));
Console.WriteLine(comp.Compare<string>("a", "b"));

我们正在创建CompareObjects类的对象,并为各种数据类型调用Compare()方法。在这个例子中,类型参数是显式指定的。然而,编译器能够从参数中推断出来,因此可以省略,如下所示:

CompareObjects comp = new CompareObjects();
Console.WriteLine(comp.Compare(10, 10));
Console.WriteLine(comp.Compare(10.5, 10.8));
Console.WriteLine(comp.Compare("a", "a"));
Console.WriteLine(comp.Compare("a", "b"));

如果泛型方法具有与定义它的类、结构或接口的类型参数相同的类型参数,编译器会发出警告,因为方法类型参数隐藏了外部类型的类型参数,如下面的代码所示:

class ConflictingGenerics<T>
{
    public void DoSomething<T>(T arg) // warning
    { 
    }
}

泛型方法和泛型类型都支持类型参数约束来对类型施加限制。这个主题将在本章的下一节中讨论。

类型参数约束

泛型类型或方法中的类型参数可以被任何有效类型替换。然而,在某些情况下,我们希望限制可以用作类型参数的类型。例如,我们之前看到的泛型Shape<T>类或IShape<T>接口。

类型参数T被用于Area属性的类型。我们期望它要么是整数类型,要么是浮点类型。但是没有限制,有人可以使用boolstring或任何其他类型。当然,根据类型参数的使用方式,这可能导致各种编译错误。然而,能够限制用于实例化泛型类型或调用泛型方法的类型是有用的。

为此,我们可以对类型参数应用约束。约束用于告诉编译器类型参数必须具有什么样的能力。如果我们不指定约束,那么类型参数可以被任何类型替换。应用约束将限制可以用作类型参数的类型。

约束使用关键字where来指定。C#定义了以下八种泛型约束类型:

约束应该在类型参数之后指定。我们可以通过逗号分隔它们来使用多个约束。对于使用这些约束有一些规则:

  • struct约束意味着new()约束,因此所有值类型必须有一个公共的无参数构造函数。这两个约束,structnew(),不能一起使用。

  • unmanaged约束意味着struct约束;因此,这两个不能一起使用。它也不能与new()约束一起使用。

  • 在使用多个约束时,new()约束必须在约束列表中最后提及。

  • notnull约束从 C# 8 开始可用,必须在可空上下文中使用,否则编译器会生成警告。当约束被违反时,编译器不会生成错误,而是生成警告。

  • 从 C# 7.3 开始,System.EnumSystem.DelegateSystem.MulticastDelegate可以用作基类约束。

没有约束的类型参数称为无界。无界类型参数有几条规则:

  • 你不能使用!===运算符来处理这些类型,因为不可能知道具体类型是否重载了它们。

  • 它们可以与null进行比较。对于值类型,这种比较总是返回false

  • 它们可以转换为和从System.Object

  • 它们可以转换为任何接口类型。

为了理解约束的工作原理,让我们从以下泛型结构的示例开始:

struct Point<T>
{
    public T X { get; }
    public T Y { get; }
    public Point(T x, T y)
    {
        X = x;
        Y = y;
    }
}

Point<T>是表示二维空间中的点的结构。这个类是泛型的,因为我们可能希望使用整数值作为点坐标或实数值(浮点值)。但是,我们可以使用任何类型来实例化该类,例如boolstringCircle,如下例所示:

Point<int> p1 = new Point<int>(3, 4);
Point<double> p2 = new Point<double>(3.12, 4.55);
Point<bool> p3 = new Point<bool>(true, false);
Point<string> p4 = new Point<string>("alpha", "beta");

为了将Point<T>的实例化限制为数字类型(即整数和浮点类型),我们可以为类型参数T编写约束,如下所示:

struct Point<T>
    where T : struct, 
              IComparable, IComparable<T>,
              IConvertible,
              IEquatable<T>,
              IFormattable
{
    public T X { get; }
    public T Y { get; }
    public Point(T x, T y)
    {
        X = x;
        Y = y;
    }
}

我们使用了两种类型的约束:struct约束和接口约束,并且它们用逗号分隔列出。不幸的是,没有约束可以将类型定义为数字,但这些约束是表示数字类型的最佳组合,因为所有数字类型都是值类型,并且它们都实现了这里列出的五个接口。bool类型实现了前四个,但没有实现IFormattable。因此,使用boolstring实例化Point<T>现在将产生编译错误。

类型或方法可以有多个类型参数,每个类型参数都可以有自己的约束。我们可以在下面的示例中看到这一点:

class RestrictedDictionary<TKey, TValue> : Dictionary<TKey, List<TValue>>
    where TKey : System.Enum
    where TValue : class, new()
{
    public T Make<T>(TKey key) where T : TValue, new()
    {
        var value = new T();
        if (!TryGetValue(key, out List<TValue> list))
            Add(key, new List<TValue>() { value });
        else
            list.Add(value);
        return value;
    }
}

RestrictedDictionary<TKey, TValue>类是一个特殊的字典,它只允许枚举类型作为键类型。为此,它使用了基类约束System.Enum。值的类型必须是具有公共默认构造函数的引用类型。为此,它使用了classnew()约束。这个类有一个名为Make<T>()的公共泛型方法。

类型参数T必须是TValue或从TValue派生的类型,并且还必须具有公共默认构造函数。此方法创建类型T的新实例,将其添加到与指定键关联的字典中的列表中,并返回对新创建对象的引用。

让我们也考虑以下形状类的层次结构。请注意,为简单起见,这些被保持在最低限度:

enum ShapeType { Sharp, Rounded };
class Shape { }
class Ellipsis  : Shape { }
class Circle    : Shape { }
class Rectangle : Shape { }
class Square    : Shape { }

我们可以像这样使用RestrictedDictionary类:

var dictionary = new RestrictedDictionary<ShapeType, Shape>();
var c = dictionary.Make<Circle>(ShapeType.Rounded);
var e = dictionary.Make<Ellipsis>(ShapeType.Rounded);
var r = dictionary.Make<Rectangle>(ShapeType.Sharp);
var s = dictionary.Make<Square>(ShapeType.Sharp);

在这个例子中,我们将几种形状(圆形、椭圆形、矩形和正方形)添加到受限制的字典中。键类型是ShapeType,值类型是ShapeMake()方法接受ShapeType类型的参数,并返回对形状对象的引用。每种类型都必须派生自Shape并具有公共默认构造函数。否则,代码将产生错误。

总结

在本章中,我们学习了 C#中的泛型。泛型允许我们在 C#中创建参数化类型。泛型增强了代码的可重用性并确保类型安全。我们探讨了如何创建泛型类和泛型结构。我们还在泛型类中实现了继承。

我们学习了如何在泛型类型或方法的类型参数上实现约束。约束允许我们限制可以用作类型参数的数据类型。我们还学习了如何创建泛型方法和泛型接口。

您可以主要用于创建集合和包装的泛型。在下一章中,我们将探讨.NET 中最重要的集合。

测试你所学到的

  1. 泛型是什么,它们提供了什么好处?

  2. 什么是类型参数?

  3. 如何定义泛型类?泛型方法呢?

  4. 一个类可以从泛型类型派生吗?结构呢?

  5. 什么是构造类型?

  6. 泛型接口的协变类型参数是什么?

  7. 泛型接口的逆变类型参数是什么?

  8. 什么是类型参数约束,以及如何指定它们?

  9. new()类型参数约束是做什么的?

  10. C# 8 中引入了什么类型参数约束,它是做什么的?

第七章:集合

在上一章中,我们学习了 C#中的泛型编程。泛型的最重要的应用之一就是创建泛型集合。集合是一组对象。我们学习了如何在第二章数据类型和运算符中使用数组。然而,数组是固定大小的序列,在大多数情况下,我们需要处理可变大小的序列。

.NET 框架提供了代表各种类型集合的泛型类,如列表、队列、集合、映射等。使用这些类,我们可以轻松地对对象集合执行插入、更新、删除、排序和搜索等操作。

在本章中,您将学习以下泛型集合:

  • List<T>集合

  • Stack<T>集合

  • Queue<T>集合

  • LinkedList<T>集合

  • Dictionary<TKey, TValue>集合

  • HashSet<T>集合

在本章结束时,您将对.NET 中最重要的集合有很好的理解,它们模拟了什么数据结构,它们之间的区别是什么,以及何时应该使用它们。

之前提到的所有集合都不是线程安全的。这意味着它们不能在多线程场景中使用,当一个线程可能在读取时,另一个线程可能在写入相同的集合,而不使用外部同步机制。然而,.NET 还提供了几个线程安全的集合,它们位于System.Collections.Concurrent命名空间中,使用高效的锁定或无锁同步机制,在许多情况下,提供比使用外部锁更好的性能。在本章中,我们还将介绍这些集合,并了解何时适合使用它们。

让我们通过查看System.Collections.Generic命名空间来概述泛型集合库,这是所有泛型集合的所在地。

介绍 System.Collections.Generic 命名空间

我们将在本章介绍的泛型集合类是System.Collections.Generic命名空间的一部分。该命名空间包含定义泛型集合和操作的接口和类。所有泛型集合都实现了一系列泛型接口,这些接口也在该命名空间中定义。这些接口可以大致分为两类:

  • 可变的,支持更改集合内容的操作,如添加新元素或删除现有元素。

  • 只读集合,不提供更改集合内容的方法。

表示可变集合的接口如下:

  • IEnumerable<T>:这是所有其他接口的基本接口,并公开一个支持遍历T类型集合元素的枚举器。

  • ICollection<T>:这定义了操作泛型集合的方法——Add()Clear()Contains()CopyTo()Remove()——以及Count等属性。这些成员应该是不言自明的。

  • IList<T>:表示可以通过索引访问其元素的泛型集合。它定义了三种方法:IndexOf(),用于检索元素的索引,Insert(),用于在指定索引处插入元素,RemoveAt(),用于移除指定索引处的元素,此外,它还提供了一个用于直接访问元素的索引器。

  • ISet<T>:这是抽象集合集合的基本接口。它定义了诸如Add()ExceptWith()IntersetWith()UnionWith()IsSubsetOf()IsSupersetOf()等方法。

  • IDictionary<TKey, TValue>:这是抽象出键值对集合的基本接口。它定义了Add()ContainsKey()Remove()TryGetValue()方法,以及一个索引器和KeysValues属性,分别返回键和值的集合。

这些接口之间的关系如下图所示:

图 7.1 - System.Collections.Generic 命名空间中通用集合接口的层次结构。

图 7.1 - System.Collections.Generic 命名空间中通用集合接口的层次结构。

代表只读集合的接口如下:

  • IReadOnlyCollection<T>:这代表了一个只读的元素的通用集合。它只定义了一个成员:Count属性。

  • IReadOnlyList<T>:这代表了一个只读的可以通过索引访问的元素的通用集合。它只定义了一个成员:一个只读的索引器。

  • IReadOnlyDictionary<TKey, TValue>:这代表了一个只读的键值对的通用集合。这个接口定义了ContainsKey()TryGetValue()方法,以及KeysValues属性和一个只读的索引器。

再次,这些接口的关系如下图所示:

图 7.2 - 只读通用集合接口的层次结构。

图 7.2 - 只读通用集合接口的层次结构。

每个通用集合都实现了几个这些接口。例如,List<T>实现了IList<T>ICollection<T>IEnumerable<T>IReadOnlyCollection<T>IReadOnlyList<T>。下图显示了我们将在本章学习的通用集合所实现的所有接口:

图 7.3 - 一个类图显示了最重要的通用集合和它们实现的接口。

图 7.3 - 一个类图显示了最重要的通用集合和它们实现的接口。

这些图表中显示的继承层次实际上是实际继承层次的简化。所有的通用集合都有一个非通用的等价物。例如,IEnumerable<T>IEnumerable的通用等价物,ICollection<T>ICollection的通用等价物,IList<T>Ilist的通用等价物,依此类推。这些是由ArrayListQueueStackDictionaryBaseHashtable等遗留集合实现的遗留接口,所有这些都在System.Collections命名空间中可用。这些非通用的遗留集合没有强类型。出于几个原因,使用通用集合是首选的:`

  • 它们提供了类型安全的好处。不需要从基本集合派生并实现特定类型的成员。

  • 对于值类型,它们具有更好的性能,因为没有元素的装箱和拆箱,这是非通用集合中必要的过程。

  • 一些通用集合提供了非通用集合中不可用的功能,比如接受委托用于搜索或对每个元素执行操作的方法。

当你需要将集合作为参数传递给函数或从函数返回集合时,应该避免使用具体的实现,而是使用接口。当你只想遍历元素时,IEnumerable<T>是合适的,但如果你需要多次这样做,你可以使用IReadOnlyCollection<T>。只读集合应该在两种情况下被优先选择:

  • 当一个方法不修改作为参数传递的集合时

  • 当你返回一个集合,如果集合已经在内存中,调用者不应该修改它

最终,最合适的接口因情况而异。

在接下来的几节中,我们将介绍最常用的类型安全的泛型集合。非泛型集合在遗留代码之外几乎没有什么意义。

List 集合

List<T> 泛型类表示可以通过索引访问其元素的集合。List<T> 与数组非常相似,只是集合的大小不是固定的,而是可变的,可以随着元素的添加或删除而增长或减少。事实上,List<T> 的实现使用数组来存储元素。当元素的数量超过数组的大小时,将分配一个新的更大的数组,并将先前数组的内容复制到新数组中。这意味着 List<T> 在连续的内存位置中存储元素。但是,对于值类型,这些位置包含值,但对于引用类型,它们包含对实际对象的引用。可以将对同一对象的多个引用添加到列表中。

List<T> 类实现了一系列泛型和非泛型接口,如下面的类声明所示:

public class List<T> : ICollection<T>, ICollection
                       IEnumerable<T>, IEnumerable, 
                       IList<T>, IList,
                       IReadOnlyCollection<T>, IReadOnlyList<T> {}

列表可以通过几种方式创建:

  • 使用默认构造函数,这会导致一个具有默认容量的空列表。

  • 通过指定特定的容量但没有初始元素,这会再次使列表为空。

  • 从一系列元素中。

在以下示例中,numbers 是一个空的整数列表,words 是一个空的字符串列表:

var numbers = new List<int>();
var words = new List<string>();

另一方面,以下示例初始化了一些元素的列表。第一个列表将包含六个整数,第二个列表将包含两个字符串:

var numbers = new List<int> { 1, 2, 3, 5, 7, 11 };
var words = new List<string> { "one", "two" };

这个类支持你从这样的集合中期望的所有典型操作——添加、删除和搜索元素。有几种方法可以向列表中添加元素:

  • Add() 将元素添加到列表的末尾。

  • AddRange() 将一系列元素(以 IEnumerable<T> 的形式)添加到列表的末尾。

  • Insert() 在指定位置插入一个元素。位置必须是有效的索引,在列表的范围内;否则,将抛出 ArgumentOutOfRangeException 异常。

  • InsertRange() 在指定的索引处插入一系列元素(以 IEnumerable<T> 的形式),该索引必须在列表的范围内。

如果内部数组的容量超过了,所有这些操作可能需要重新分配存储元素的内部数组。如果不需要分配空间,Add() 是一个 O(1) 操作,当需要分配空间时,为 O(n)

如果不需要分配空间,AddRange() 的时间复杂度为 O(n),如果需要分配空间,则为 O(n+k)Insert() 操作始终为 O(n)InsertRange() 如果不需要分配空间,则为 O(n),如果需要分配空间,则为 O(n+k)。在这个表示法中,n 是列表中的元素数量,k 是要添加的元素数量。我们可以在以下示例中看到这些操作的示例:

var numbers = new List<int> {1, 2, 3}; // 1 2 3
numbers.Add(5);                        // 1 2 3 5
numbers.AddRange(new int[] { 7, 11 }); // 1 2 3 5 7 11
numbers.Insert(5, 1);                  // 1 2 3 5 7 1 11
numbers.Insert(5, 1);                  // 1 2 3 5 7 1 1 11
numbers.InsertRange(                   // 1 13 17 19 2 3 5..
    1, new int[] {13, 17, 19});        // ..7 1 1 11

使用不同的方法也可以以几种方式删除元素:

  • Remove() 从列表中删除指定的元素。

  • RemoveAt() 删除指定索引处的元素,该索引必须在列表的范围内。

  • RemoveRange() 删除指定数量的元素,从给定的索引开始。

  • RemoveAll() 删除列表中满足提供的谓词要求的所有元素。

  • Clear() 删除列表中的所有元素。

所有这些操作都在 O(n) 中执行,其中 n 是列表中的元素数量。RemoveAt() 是一个例外,其中 nCount - index。原因是在删除一个元素后,必须在内部数组中移动元素。使用这些函数的示例在以下代码片段中显示:

numbers.Remove(1);              // 13 17 19  2  3  5  7  1  
                                // 1 11
numbers.RemoveRange(2, 3);      // 13 17  5  7  1  1 11
numbers.RemoveAll(e => e < 10); // 13 17 11
numbers.RemoveAt(1);            // 13 11
numbers.Clear();                // empty

可以通过指定谓词来搜索列表中的元素。

信息框

谓词 是返回布尔值的委托。它们通常用于过滤元素,例如在搜索集合时。

有几种可以用于搜索元素的方法:

  • Find() 返回与谓词匹配的第一个元素,如果找不到则返回T的默认值。

  • FindLast() 返回与谓词匹配的最后一个元素,如果找不到则返回T的默认值。

  • FindAll() 返回与谓词匹配的所有元素的List<T>,如果找不到则返回一个空列表。

所有这些方法都在*O(n)*中执行,如下面的代码片段所示:

var numbers = new List<int> { 1, 2, 3, 5, 7, 11 };
var a = numbers.Find(e => e < 10);      // 1
var b = numbers.FindLast(e => e < 10);  // 7
var c = numbers.FindAll(e => e < 10);   // 1 2 3 5 7

还可以搜索元素的从零开始的索引。有几种方法允许我们这样做:

  • IndexOf() 返回与提供的参数相等的第一个元素的索引。

  • LastIndexOf() 返回搜索元素的最后一个索引。

  • FindIndex() 返回满足提供的谓词的第一个元素的索引。

  • FindLastIndex() 返回满足提供的谓词的最后一个元素的索引。

  • BinarySearch() 使用二进制搜索返回满足提供的元素或比较器的第一个元素的索引。此函数假定列表已经排序;否则,结果是不正确的。

BinarySearch() 在*O(log n)中执行,而其他所有操作都在O(n)*中执行。这是因为它们使用线性搜索。如果找不到满足搜索条件的元素,它们都返回-1。示例如下所示:

var numbers = new List<int> { 1, 1, 2, 3, 5, 8, 11 };
var a = numbers.FindIndex(e => e < 10);     // 0
var b = numbers.FindLastIndex(e => e < 10); // 5
var c = numbers.IndexOf(5);                 // 4
var d = numbers.LastIndexOf(1);             // 1
var e = numbers.BinarySearch(8);            // 5

有一些方法允许我们修改列表的内容,例如对元素进行排序或反转:

  • Sort() 根据默认或指定的条件对列表进行排序。有几个重载允许我们指定比较委托或IComparer<T>对象,甚至是要排序的列表的子范围。在大多数情况下,此操作在O(n log n)中执行,但在最坏的情况下为O(n2)

  • Reverse() 反转列表中的元素。有一个重载允许您指定要恢复的子范围。此操作在*O(n)*中执行。

以下是使用这些函数的示例:

var numbers = new List<int> { 1, 5, 3, 11, 8, 1, 2 };
numbers.Sort();     // 1 1 2 3 5 8 11
numbers.Reverse();  // 11 8 5 3 2 1 1

List<T>类中有更多的方法,不仅限于此处显示的方法。但是,浏览所有这些方法超出了本书的范围。您应该在线查阅该类的官方文档,以获取该类所有成员的完整参考。

Stack<T>集合

栈是一种线性数据结构,允许我们按特定顺序插入和删除项目。新项目添加到栈顶。如果要从栈中移除项目,只能移除顶部项目。由于只允许从一端插入和删除,因此最后插入的项目将是首先删除的项目。因此,栈被称为**后进先出(LIFO)**集合。

以下图表描述了一个栈,其中push表示向栈中添加项目,pop表示从栈中删除项目:

图 7.4 - 栈的概念表示。

图 7.4 - 栈的概念表示。

.NET 提供了用于处理栈的通用Stack<T>类。该类包含几个构造函数,允许我们创建空栈或使用元素集合初始化栈。看一下以下代码片段,我们正在创建一个包含三个初始元素和一个空整数栈的字符串栈:

var arr = new string[] { "Ankit", "Marius", "Raffaele" };
Stack<string> names = new Stack<string>(arr);
Stack<int> numbers = new Stack<int>();

栈支持的主要操作如下:

  • Push(): 在栈顶插入一个项目。如果不需要重新分配,则这是一个O(1)操作,否则为O(n)

  • Pop(): 从栈顶移除并返回项目。这是一个*O(1)*操作。

  • Peek(): 返回栈顶的项目,而不移除它。这是一个*O(1)*操作。

  • Clear(): 从栈中移除所有元素。这是一个*O(n)*操作。

让我们通过以下示例来理解它们是如何工作的,在左侧,您可以看到每个操作后栈的内容:

var numbers = new Stack<int>(new int[]{ 1, 2, 3 });// 3 2 1
numbers.Push(5);                                   // 5 3 2 1
numbers.Push(7);                                   // 7 5 3 2 1
numbers.Pop();                                     // 5 3 2 1
var n = numbers.Peek();                            // 5 3 2 1
numbers.Push(11);                                 // 11 5 3 2 1
numbers.Clear();                                  // empty

Pop()Peek()方法如果栈为空会抛出InvalidOperationException异常。在.NET Core 中,自 2.0 版本以来,有两种替代的非抛出方法可用——TryPop()TryPeek()。这些方法返回一个布尔值,指示是否找到了顶部元素,如果找到了,它将作为out参数返回。

队列集合

队列是一种线性数据结构,其中插入和删除元素是从两个不同的端口执行的。新项目从队列的后端添加,现有项目的删除从前端进行。因此,要首先插入的项目将是要首先删除的项目。因此,队列被称为先进先出(FIFO)集合。下图描述了一个队列,其中Enqueue表示向队列添加项目,Dequeue表示从队列中删除项目:

图 7.5 – 队列的概念表示。

图 7.5 – 队列的概念表示。

在.NET 中,实现通用队列的类是Queue<T>。类似于Stack<T>,有重载的构造函数,允许我们创建一个空队列或一个从IEnumerable<T>集合中的元素初始化的队列。看一下下面的代码片段,我们正在创建一个包含三个初始元素的字符串队列和一个空的整数队列:

var arr = new string[] { "Ankit", "Marius", "Raffaele" };
Queue<string> names = new Queue<string>(arr);
Queue<int> numbers = new Queue<int>();

队列支持的主要操作如下:

  • Enqueue(): 在队列的末尾插入一个项目。这是一个*O(1)操作,除非需要重新分配内部数组,否则它将成为一个O(n)*操作。

  • Dequeue(): 从队列的前端移除并返回一个项目。这是一个*O(1)*操作。

  • Peek(): 从队列的前端返回一个项目,但不移除它。这是一个*O(1)*操作。

  • Clear(): 从队列中移除所有元素。这是一个*O(n)*操作。

要了解这些方法如何工作,让我们看下面的例子:

var numbers = new Queue<int>(new int[] { 1, 2, 3 });// 1 2 3
numbers.Enqueue(5);                                 // 1 2 3 5
numbers.Enqueue(7);                                // 1 2 3 5 7
numbers.Dequeue();                                 // 2 3 5 7
var n = numbers.Peek();                            // 2 3 5 7
numbers.Enqueue(11);                              // 2 3 5 7 11
numbers.Clear();                                 // empty

Dequeue()Peek()方法如果队列为空会抛出InvalidOperationException异常。在.NET Core 中,自 2.0 版本以来,有两种替代的非抛出方法可用——TryDequeue()TryPeek()。这些方法返回一个布尔值,指示是否找到了顶部元素,如果找到了,它将作为一个 out 参数返回。

从这些示例中可以看出,Stack<T>Queue<T>有非常相似的实现,尽管语义不同。它们的公共成员几乎相同,不同之处在于栈操作称为Push()Pop(),队列操作称为Enqueue()Dequeue()

LinkedList集合

链表是一种线性数据结构,由一组节点组成,每个节点包含数据以及一个或多个节点的地址。这里有四种类型的链表,如下所述:

  • 单链表:包含存储值和对节点序列中下一个节点的引用的节点。最后一个节点的下一个节点的引用将指向 null。

  • 双向链表:在这里,每个节点包含两个链接——第一个链接指向前一个节点,下一个链接指向序列中的下一个节点。第一个节点的上一个节点的引用和最后一个节点的下一个节点的引用将指向 null。

  • 循环单链表:最后一个节点的下一个节点的引用将指向第一个节点,从而形成一个循环链。

  • 双向循环链表:在这种类型的链表中,最后一个节点的下一个节点的引用将指向第一个节点,第一个节点的上一个节点的引用将指向最后一个节点。

双向链表的概念表示如下:

图 7.6 – 双向链表的概念表示。

图 7.6 – 双向链表的概念表示。

在这里,每个节点包含一个值和两个指针。Next 指针包含对序列中下一个节点的引用,并允许在链表的正向方向上进行简单导航。Prev 指针包含对序列中前一个节点的引用,并允许我们在链表中向后移动。

.NET 提供了 LinkedList<T> 类,表示双向链表。该类包含 LinkedListNode<T> 类型的项。插入和删除操作在 O(1) 中执行,搜索在 O(n) 中执行。节点可以从同一链表对象或另一个链表中移除和重新插入。列表维护内部计数,因此使用 Count 属性检索列表的大小也是 O(1) 操作。链表不支持循环、分割、链接或其他可能使列表处于不一致状态的操作。

LinkedListNode<T> 类具有以下四个属性:

  • List:此属性将返回对 LinkedList<T> 对象的引用,该对象属于 LinkedListNode<T>

  • Next:表示对 LinkedList<T> 对象中下一个节点的引用,如果当前节点是最后一个节点,则为 null

  • Previous:表示对 LinkedList<T> 对象中前一个节点的引用,如果当前节点是第一个节点,则为 null

  • Value:此属性的类型为 T,表示节点中包含的值。

对于值类型,LinkedListNode<T> 包含实际值,而对于引用类型,它包含对对象的引用。

该类具有重载的构造函数,使我们能够创建一个空的链表或一个以 IEnumerable<T> 形式的元素序列进行初始化的链表。看一下以下示例,看一些示例:

var arr = new string[] { "Ankit", "Marius", "Raffaele" };
var words = new LinkedList<string>(arr);
var numbers = new LinkedList<int>();

使用以下方法可以以多种方式向链表添加新元素:

  • AddFirst() 在列表开头添加一个新节点或值。

  • AddLast() 在列表末尾添加一个新节点或值。

  • AddAfter() 在指定节点之后的列表中添加一个新节点或值。

  • AddBefore() 在指定节点之前的列表中添加一个新节点或值。

我们可以在以下示例中看到为这些方法添加新值的重载的示例:

var numbers = new LinkedList<int>();
var n2 = numbers.AddFirst(2);      // 2
var n1 = numbers.AddFirst(1);      // 1 2
var n7 = numbers.AddLast(7);       // 1 2 7
var n11 = numbers.AddLast(11);     // 1 2 7 11
var n3 = numbers.AddAfter(n2, 3);  // 1 2 3 7 11
var n5 = numbers.AddBefore(n7, 5); // 1 2 3 5 7 11

可以使用以下方法之一在链表中搜索元素:

  • Contains():这检查指定的值是否在列表中,并返回一个布尔值以指示成功或失败。

  • Find():查找并返回包含指定值的第一个节点。

  • FindLast():查找并返回包含指定值的最后一个节点。

以下是使用这些函数的示例:

var fn1 = numbers.Find(5);
var fn2 = numbers.FindLast(5);
Console.WriteLine(fn1 == fn2);           // True
Console.WriteLine(numbers.Contains(3));  // True
Console.WriteLine(numbers.Contains(13)); // False

使用以下方法可以以多种方式从列表中移除元素:

  • RemoveFirst() 从列表中移除第一个节点。

  • RemoveLast() 移除列表中的最后一个节点。

  • Remove() 从列表中移除指定的节点或指定值的第一个出现。

  • Clear() 从列表中移除所有元素。

您可以在以下列表中看到所有这些方法的工作方式:

numbers.RemoveFirst(); // 2 3 5 7 11
numbers.RemoveLast();  // 2 3 5 7
numbers.Remove(3);     // 2 5 7
numbers.Remove(n5);    // 2 7
numbers.Clear();       // empty

链表类还具有几个属性,包括 Count,它返回列表中的元素数量,First,它返回第一个节点,以及 Last,它返回最后一个节点。如果列表为空,则 Count0FirstLast 都设置为 null

Dictionary<TKey, TValue> 集合

字典是一组键值对,允许根据键进行快速查找。添加、搜索和删除项目都是非常快速的操作,并且在 O(1) 中执行。唯一的例外是在必须增加容量时添加新值,此时它变为 O(n)

在.NET 中,泛型Dictionary<TKey,TValue>类实现了一个字典。TKey表示键的类型,TValue表示值的类型。字典的元素是KeyValuePair<TKey,TValue>对象。

Dictionary<TKey, TValue>有几个重载的构造函数,允许我们创建一个空字典或一个填充了一些初始值的字典。该类的默认构造函数将创建一个空字典。看一下以下代码片段:

var languages = new Dictionary<int, string>(); 

在这里,我们正在创建一个名为languages的空字典,它具有int类型的键和string类型的值。我们还可以在声明时初始化字典。考虑以下代码片段:

var languages = new Dictionary<int, string>()
{
    {1, "C#"}, 
    {2, "Java"}, 
    {3, "Python"}, 
    {4, "C++"}
};

在这里,我们正在创建一个字典,该字典初始化了四个具有键1234的值。这在语义上等同于以下初始化:

var languages = new Dictionary<int, string>()
{
    [1] = "C#",
    [2] = "Java",
    [3] = "Python",
    [4] = "C++"
};

字典必须包含唯一的键;但是,值可以是重复的。同样,键不能是null,但是值(如果是引用类型)可以是null。要添加、删除或搜索字典值,我们可以使用以下方法:

  • Add():这向字典中添加具有指定键的新值。如果键为null或键已存在于字典中,则会抛出异常。

  • Remove():这删除具有指定键的值。

  • Clear():这从字典中删除所有值。

  • ContainsKey():这检查字典是否包含指定的键,并返回一个布尔值以指示。

  • ContainsValue():这检查字典是否包含指定的值,并返回一个布尔值以指示。该方法执行线性搜索;因此,它是一个*O(n)*操作。

  • TryGetValue():这检查字典是否包含指定的键,如果是,则将关联的值作为out参数返回。如果成功获取了值,则该方法返回true,否则返回false。如果键不存在,则输出参数设置为TValue类型的默认值(即数值类型为0,布尔类型为false,引用类型为null)。

在.NET Core 2.0 及更高版本中,还有一个名为TryAdd()的额外方法,它尝试向字典中添加新值。该方法仅在键尚未存在时成功。它返回一个布尔值以指示成功或失败。

该类还包含一组属性,其中最重要的是以下属性:

  • Count:这返回字典中键值对的数量。

  • Keys:这返回一个集合(类型为Dictionary<TKey,TValue>.KeyCollection)包含字典中的所有键。此集合中键的顺序未指定。

  • Values:这返回一个集合(类型为Dictionary<TKey,TValue>.ValueCollection)包含字典中的所有值。此集合中值的顺序未指定,但保证与Keys集合中的关联键的顺序相同。

  • Item[]:这是一个索引器,用于获取或设置与指定键关联的值。索引器可用于向字典中添加值。如果键不存在,则会添加新的键值对。如果键已存在,则值将被覆盖。

看一下以下示例,我们在创建一个字典,然后以几种方式添加键值对:

var languages = new Dictionary<int, string>()
{
    {1, "C#"},
    {2, "Java"},
    {3, "Python"},
    {4, "C++"}
};
languages.Add(5, "JavaScript");
languages.TryAdd(5, "JavaScript");
languages[6] = "F#";
languages[5] = "TypeScript";

最初,字典包含了对[1, C#] [2, Java] [3, Python] [4, C++]的配对,然后我们两次添加了[5, JavaScript]。但是,因为第二次使用了TryAdd(),操作将在不抛出任何异常的情况下发生。然后我们使用索引器添加了另一对[6, F#],并且还更改了现有键(即 5)的值,即从 JavaScript 更改为 TypeScript。

我们可以使用前面提到的方法搜索字典:

Console.WriteLine($"Has 5: {languages.ContainsKey(5)}");
Console.WriteLine($"Has C#: {languages.ContainsValue("C#")}");
if (languages.TryGetValue(1, out string lang))
    Console.WriteLine(lang);
else
    Console.WriteLine("Not found!");

我们还可以通过枚举器遍历字典的元素,在这种情况下,键值对被检索为KeyValuePair<TKey, TValue>对象:

foreach(var kvp in languages)
{
    Console.WriteLine($"[{kvp.Key}] = {kvp.Value}");
}

要删除元素,我们可以使用Remove()Clear(),后者用于从字典中删除所有键值对:

languages.Remove(5);
languages.Clear();

另一个基于哈希的集合,只维护键或唯一值的集合,是HashSet<T>。我们将在下一节中看到它。

HashSet集合

集合是一个只包含不同项的集合,可以是任何顺序。.NET 提供了HashSet<T>类来处理集合。该类包含处理集合元素的方法,还包含建模数学集合操作如并集交集的方法。

与所有其他集合一样,HashSet<T>包含多个重载的构造函数,允许我们创建空集或填充有初始值的集合。要声明一个空集,我们使用默认构造函数(即没有参数的构造函数):

HashSet<int> numbers = new HashSet<int>();

但我们也可以使用一些值初始化集合,如下例所示:

HashSet<int> numbers = new HashSet<int>()
{
    1, 1, 2, 3, 5, 8, 11
};

要使用集合,我们可以使用以下方法:

  • Add() 如果元素尚未存在,则将新元素添加到集合中。该函数返回一个布尔值以指示成功或失败。

  • Remove() 从集合中移除指定的元素。

  • RemoveWhere() 从集合中删除与提供的谓词匹配的所有元素。

  • Clear() 从集合中移除所有元素。

  • Contains() 检查指定的元素是否存在于集合中。

我们可以在以下示例中看到这些方法的运行情况:

HashSet<int> numbers = new HashSet<int>() { 11, 3, 8 };
numbers.Add(1);                       // 11 3 8 1
numbers.Add(1);                       // 11 3 8 1
numbers.Add(2);                       // 11 3 8 1 2
numbers.Add(5);                       // 11 3 8 1 2 5
Console.WriteLine(numbers.Contains(1));
Console.WriteLine(numbers.Contains(7));
numbers.Remove(1);                    // 11 3 8 2 5
numbers.RemoveWhere(n => n % 2 == 0); // 11 3 5
numbers.Clear();                      // empty

如前所述,HashSet<T>类提供了以下数学集合操作的方法:

  • UnionWith(): 这执行两个集合的并集。当前集合对象通过添加来自提供的集合中不在集合中的所有元素来进行修改。

  • IntersectWith(): 这执行两个集合的交集。当前集合对象被修改,以便它仅包含在提供的集合中也存在的元素。

  • ExceptWith(): 这执行集合减法。当前集合对象通过移除在提供的集合中也存在的所有元素来进行修改。

  • SymmetricExceptWith(): 这执行集合对称差。当前集合对象被修改为仅包含存在于集合或提供的集合中的元素,但不包含两者都存在的元素。

使用这些方法的示例在以下清单中显示:

HashSet<int> a = new HashSet<int>() { 1, 2, 5, 6, 9};
HashSet<int> b = new HashSet<int>() { 1, 2, 3, 4};
var s1 = new HashSet<int>(a);
s1.IntersectWith(b);               // 1 2
var s2 = new HashSet<int>(a);
s2.UnionWith(b);                   // 1 2 5 6 9 3 4
var s3 = new HashSet<int>(a);
s3.ExceptWith(b);                  // 5 6 9
var s4 = new HashSet<int>(a);
s4.SymmetricExceptWith(b);         // 4 3 5 6 9

除了这些数学集合操作,该类还提供了用于确定集合相等性、重叠或一个集合是否是另一个集合的子集或超集的方法。其中一些方法列在这里:

  • Overlaps() 确定当前集合和提供的集合是否包含任何共同元素。如果至少存在一个共同元素,则该方法返回true,否则返回false

  • IsSubsetOf() 确定当前集合是否是另一个集合的子集,这意味着它的所有元素也存在于另一个集合中。空集是任何集合的子集。

  • IsSupersetOf() 确定当前集合是否是另一个集合的超集,这意味着当前集合包含另一个集合的所有元素。

使用这些方法的示例在以下片段中显示:

HashSet<int> a = new HashSet<int>() { 1, 2, 5, 6, 9 };
HashSet<int> b = new HashSet<int>() { 1, 2, 3, 4 };
HashSet<int> c = new HashSet<int>() { 2, 5 };
Console.WriteLine(a.Overlaps(b));     // True
Console.WriteLine(a.IsSupersetOf(c)); // True
Console.WriteLine(c.IsSubsetOf(a));   // True

HashSet<T>类包含其他方法和属性。您应该查看在线文档以获取该类成员的完整参考。

选择正确的集合类型

到目前为止,我们已经看过最常用的泛型集合类型,尽管基类库提供了更多。在单独查看每个集合后出现的关键问题是何时应该使用这些集合。在本节中,我们将提供一些选择正确集合的指南。让我们来看一下:

  • List<T> 是在需要连续存储元素并直接访问它们时的默认集合,而且没有其他特定约束时可以使用。列表的元素可以通过它们的索引直接访问。在末尾添加和删除元素非常高效,但在开头或中间这样做是昂贵的,因为它涉及移动至少一些元素。

  • Stack<T> 是在需要按 LIFO 方式检索后通常丢弃元素的顺序列表时的典型选择。元素从栈顶添加和移除,这两个操作都需要恒定时间。

  • Queue<T> 是在需要按 FIFO 方式检索后也通常丢弃元素的顺序列表时的一个不错的选择。元素在末尾添加并从队列顶部移除。这两个操作都非常快。

  • LinkedList<T> 在需要快速添加和删除列表中的许多元素时非常有用。然而,这是以牺牲通过索引随机访问列表元素的能力为代价。链表不会连续存储其元素,您必须从一端遍历列表以找到一个元素。

  • Dictionary<TKey, TValue> 应该在需要存储与键关联的值时使用。插入、删除和查找都非常快 - 无论字典的大小如何,都需要恒定时间。实现使用哈希表,这意味着键被哈希,因此键的类型必须实现 GetHashCode()Equals()。或者,您需要在构建字典对象时提供 IEqualityComparer 实现。字典的元素是无序存储的,这会阻止您以特定顺序遍历字典中的值。

  • HashSet<T> 是在需要唯一值列表时可以使用的集合。插入、删除和查找非常高效。元素无序但连续存储。哈希集合在逻辑上类似于字典,其中值也是键,尽管它是一个非关联容器。因此,其元素的类型必须实现 GetHashCode()Equals(),或者在构建哈希集合时必须提供 IEqualityComparer 实现。

以下表格总结了前面列表中的信息:

如果性能对您的应用程序至关重要,那么无论您基于指南和最佳实践做出何种选择,都很重要的是要进行测量,以查看所选的集合类型是否符合您的要求。此外,请记住,基类库中有比本章讨论的更多的集合。在某些特定场景中,SortedList<TKey, TValue>SortedDictionary<TKey, TValue>SortedSet<T> 也可能很有价值。

使用线程安全集合

到目前为止我们看到的泛型集合都不是线程安全的。这意味着在多线程场景中使用它们时,您需要使用外部锁来保护对这些集合的访问,这在许多情况下可能会降低性能。.NET 提供了几种线程安全的集合,它们使用高效的锁定和无锁同步机制来实现线程安全。这些集合提供在 System.Collections.Concurrent 命名空间中,并应在多个线程同时访问集合的场景中使用。然而,实际的好处可能比使用外部锁保护的标准集合要小或大。本节稍后将讨论这个问题。

信息框

多线程和异步编程的主题将在第十二章中进行讨论,多线程和异步编程,您将学习有关线程和任务、同步机制、等待/异步模型等内容。

尽管System.Collections.Concurrent命名空间中的集合是线程安全的,但不能保证通过扩展方法或显式接口实现对其元素的访问也是线程安全的,可能需要调用者进行额外的显式同步。

线程安全的通用集合是可用的,并将在以下小节中进行讨论。

IProducerConsumerCollection

这不是一个实际的集合,而是一个定义了操作线程安全集合的方法的接口。它提供了两个名为TryAdd()TryTake()的方法,可以以线程安全的方式向集合添加和移除元素,并且还支持使用CancellationToken对象进行取消。

此外,它还有一个ToArray()方法,它将元素从基础集合复制到一个新数组,并且有CopyTo()的重载,它将集合的元素复制到从指定索引开始的数组。所有实现都必须确保此接口的所有方法都是线程安全的。ConcurrentBag<T>ConcurrentStack<T>ConcurrentQueue<T>BlockingCollection<T>都实现了这个接口。如果标准实现不满足您的需求,您也可以提供自己的实现。

BlockingCollection

这是一个实现了IProducerConsumerCollection<T>接口定义的生产者-消费者模式的类。它实际上是IProducerConsumerCollection<T>接口的简单包装器,并没有内部基础存储;相反,必须提供一个(实现了IProducerConsumerCollection<T>接口的集合)。如果没有提供实现,它将默认使用ConcurrentQueue<T>类。

BlockingCollection<T>类支持限制阻塞。限制意味着您可以设置集合的容量。这意味着当集合达到最大容量时,任何生产者(向集合添加元素的线程)将被阻塞,直到消费者(从集合中移除元素的线程)移除一个元素。

另一方面,任何想要在集合为空时移除元素块的消费者,直到生产者向集合添加元素。添加和移除可以使用Add()Take(),也可以使用TryAdd()TryTake()版本,与前者不同,它们支持取消操作。还有一个CompleteAdding()方法,它将集合标记为完成,这种情况下进一步添加将不再可能,并且在集合为空时尝试移除元素将不再被阻塞。

让我们看一个例子来理解这是如何工作的。在以下示例代码中,我们有一个任务正在向BlockingCollection<int>中生产元素,还有两个任务正在从中消费。集合创建如下:

using var bc = new BlockingCollection<int>();

这使用了类的默认构造函数,它将使用ConcurrentQueue<int>类作为集合的基础存储来实例化它。生产者任务使用阻塞集合添加数字,在这种特殊情况下是斐波那契序列的前 12 个元素。请注意,最后,我们调用CompleteAdding()来标记集合为完成。进一步尝试添加将失败:

using var producer = Task.Run(() => {
   int a = 1, b = 1;
   bc.Add(a);
   bc.Add(b);
   for(int i = 0; i < 10; ++i)
   {
      int c = a + b;
      bc.Add(c);
      a = b;
      b = c;
   }
   bc.CompleteAdding();
});

第一个消费者是一个任务,它通过集合无限迭代,每次取一个元素。如果集合为空,调用Take()会阻塞调用线程。但是,如果集合为空并且已标记为完成,该操作将抛出InvalidOperationException

using var consumer1 = Task.Run(() => { 
   try
   {
      while (true)
         Console.WriteLine($"[1] {bc.Take()}");
   }
   catch (InvalidOperationException)
   {
      Console.WriteLine("[1] collection completed");
   }
   Console.WriteLine("[1] work done");
});

第二个消费者是一个执行非常相似工作的任务。但是,它使用foreach语句而不是使用无限循环。这是因为BlockingCollection<T>有一个名为GetConsumingEnumerable()的方法,它检索IEnumerable<T>,使得可以使用foreach循环或Parallel.ForEach从集合中移除项目。

与无限循环不同,枚举器提供项目,直到集合被标记为已完成。如果集合为空但未标记为已完成,则该操作将阻塞,直到有一个项目可用。在调用GetConsumingEnumerable()时,检索操作也可以通过使用CancellationToken对象进行取消:

using var consumer2 = Task.Run(() => {
   foreach(var n in bc.GetConsumingEnumerable())
      Console.WriteLine($"[2] {n}");
   Console.WriteLine("[2] work done");
});

有了这三个任务,我们应该等待它们全部完成:

await Task.WhenAll(producer, consumer1, consumer2); 

执行此示例的可能输出如下:

图 7.7 - 前面片段执行的可能输出。

图 7.7 - 前面片段执行的可能输出。

请注意,输出将因不同运行而异(这意味着处理元素的顺序将不同且来自同一任务)。

ConcurrentQueue

这是一个队列(即 FIFO 集合)的线程安全实现。它提供了三种方法:Enqueue(),将元素添加到集合的末尾,TryPeek(),尝试返回队列开头的元素而不移除它,TryDequeue(),尝试移除并返回集合开头的元素。它还为IProducerConsumerCollection<T>接口提供了显式实现。

ConcurrentStack

这个类实现了一个线程安全的堆栈(即 LIFO 集合)。它提供了四种方法:Push(),在堆栈顶部添加一个元素,TryPeek(),尝试返回顶部的元素而不移除它,TryPop(),尝试移除并返回顶部的元素,TryPopRange(),尝试移除并返回堆栈顶部的多个对象。此外,它还为IProducerConsumerCollection<T>接口提供了显式实现。

ConcurrentBag

这个类表示一个线程安全的无序对象集合。当您想要存储对象(包括重复项)且它们的顺序不重要时,这可能很有用。该实现针对同一线程既是生产者又是消费者的情况进行了优化。添加使用Add()完成,移除使用TryPeek()TryTake()完成。您还可以通过调用Clear()来移除包中的所有元素。与并发堆栈和队列实现一样,该类还为IProducerConsumerCollection<T>接口提供了显式实现。

ConcurrentDictionary<TKey, TValue>

这代表了一个线程安全的键值对集合。它提供了诸如TryAdd()(尝试添加新的键值对)、TryUpdate()(尝试更新现有项)、AddOrUpdate()(添加新项或更新现有项)和GetOrAdd()(检索现有项或添加新项(如果找不到键))等方法。

这些操作是原子的,并且是线程安全的,但其重载除外,它们采用委托。这些在锁之外执行,因此它们的代码不是操作的原子性的一部分。此外,TryGetValue()尝试获取指定键的值,TryRemove()尝试移除并返回与指定键关联的值。

选择正确的并发集合类型

现在我们已经了解了并发集合是什么,重要的问题是何时应该使用它们,特别是与非线程安全集合相关。一般来说,您可以按以下方式使用它们:

  • BlockingCollection<T>用于需要边界和阻塞场景。

  • 当处理时间至少为 500 时,应优先选择ConcurrentQueue<T>而不是带有外部锁的Queue<T>ConcurrentQueue<T>在一个线程进行入队操作,另一个线程进行出队操作时表现最佳。

  • 如果同一个线程可以添加或移除元素,则应优先选择ConcurrentStack<T>而不是带有外部锁的Stack<T>,在这种情况下,无论处理时间长短都更快。然而,如果一个线程添加,另一个线程移除元素,则ConcurrentStack<T>和带有外部锁的Stack<T>的性能相对相同。但是当线程数量增加时,Stack<T>实际上可能表现更好。

  • 在所有同时进行多线程添加和更新的场景中,ConcurrentDictionary<TKey, TValue>的性能优于Dictionary<TKey, TValue>,尽管如果更新频繁但读取很少,则好处非常小。如果读取和更新都频繁,那么ConcurrentDictionary<TKey, TValue>会显著更快。Dictionary<TKey, TValue>只适用于所有线程只进行读取而不进行更新的场景。

  • ConcurrentBag<T>适用于同一个线程既添加又消耗元素的场景。然而,在只添加或只移除的场景中,它比所有其他并发集合都慢。

请记住,前面的列表只代表指南和一般行为,可能并不适用于所有情况。一般来说,当你处理并发和并行时,你需要考虑你的场景的特定方面。无论你使用什么算法和数据结构,你都必须对它们的执行进行分析,看它们的表现如何,无论是与顺序实现还是其他并发替代方案相比。

总结

在本章中,我们了解了.NET 中的通用集合,它们模拟的数据结构以及它们实现的接口。我们看了System.Collections.Generic命名空间中最重要的集合,List<T>Stack<T>Queue<T>LinkedList<T>Dictionary<TKey, TValue>HashSet<T>,并学习了如何使用它们以及执行添加、移除或搜索元素等操作。在本章的最后部分,我们还看了System.Collection.Concurrent命名空间和它提供的线程安全集合。然后,我们了解了每个集合的特点以及它们适合使用的典型场景。

在下一章中,我们将探讨一些高级主题,如委托和事件、元组、正则表达式、模式匹配和扩展方法。

测试你所学到的知识

  1. 通用集合位于哪个命名空间下?

  2. 所有定义通用集合功能的其他接口的基本接口是什么?

  3. 使用通用集合而不是非通用集合的好处是什么?

  4. List<T>是什么,如何向其中添加或移除元素?

  5. Stack<T>是什么,如何向其中添加或移除元素?

  6. Queue<T>是什么?它的Dequeue()Peek()方法有什么区别?

  7. LinkedList<T>是什么?你可以使用哪些方法向集合中添加元素?

  8. Dictionary<K, V>是什么,它的元素是什么类型?

  9. HashSet<T>是什么,它与Dictionary<K, V>有什么不同?

  10. BlockingCollection<T>是什么?它适用于哪些并发场景?

第八章:高级主题

在前几章中,我们学习了语言语法、数据类型、类和结构的使用、泛型、集合等主题,这些知识使你能够编写至少简单的 C#程序。然而,语言还有更多内容,本章中我们将探讨更高级的概念。这将包括委托,它对于我们后面在本书中涵盖的函数式和异步编程至关重要,以及各种形式的模式匹配,包括用于文本的正则表达式。

我们将讨论的主题如下:

  • 委托和事件

  • 匿名类型

  • 元组

  • 模式匹配

  • 正则表达式

  • 扩展方法

完成本章后,你将了解如何使用委托来响应应用程序中发生的事件,如何使用元组处理多个值而不引入新类型,如何在代码中执行模式匹配,以及如何使用正则表达式搜索和替换文本。最后但同样重要的是,你将学会如何使用扩展方法在不修改其实际源代码的情况下扩展类型。

让我们通过学习委托和事件来开始本章。

委托和事件

回调是一个函数(或更一般地说,任何可执行代码),它作为参数传递给另一个函数,以便立即调用(同步回调)或在以后的某个时间调用(异步回调)。操作系统(如 Windows)广泛使用回调来允许应用程序响应鼠标事件或按键事件等事件。回调的另一个典型例子是通用算法,它使用回调来处理来自集合的元素,例如比较它们以对其进行排序或筛选。

在诸如 C 和 C++之类的语言中,回调只是一个函数指针(即函数的地址)。然而,在.NET 中,回调是强类型对象,它不仅保存了一个或多个方法的引用,还保存了关于它们的参数和返回类型的信息。在.NET 和 C#中,回调由委托表示。

委托

delegate关键字。声明看起来像一个函数签名,但编译器实际上引入了一个类,该类可以保存与委托签名匹配的方法的引用。委托可以保存对静态实例方法的引用。

为了更好地理解委托的定义和使用方式,我们将考虑以下例子。

我们有一个表示引擎的类。引擎可以做不同的事情,但我们将专注于启动和停止这个引擎。当这些事件发生时,我们希望让使用引擎的客户端知道这一点,并给他们机会做一些事情。简单起见,客户端只会将事件记录到控制台。在这个简单的模型中,引擎可以处于这两种状态中的任何一种:StatusChange

public enum Status { Started, Stopped }
public delegate void StatusChange(Status status);

StatusChange不是一个函数,而是一个类型。我们将用它来声明引擎中保存回调方法引用的变量。表示引擎的类如下:

public class Engine
{
    private StatusChange statusChangeHandler;
    public void RegisterStatusChangeHandler(StatusChange handler)
    {
        statusChangeHandler = handler;
    }
    public void Start()
    {
        // start the engine
        if (statusChangeHandler != null)
            statusChangeHandler(Status.Started);
    }
    public void Stop()
    {
        // stop the engine
        if (statusChangeHandler != null)
            statusChangeHandler(Status.Stopped);
    }
}

这里有几件事情需要注意:

  • 首先,RegisterStatusChangeHandler() 方法接受委托类型(StatusChange)的参数,并将其分配给statusChangeHandler成员字段。

  • 其次,Start()Stop()方法实际上并没有做太多事情(仅为简单起见),但你可以想象它们正在启动和停止引擎。然而,在此之后,它们调用回调函数,就像普通函数一样,传递所有必要的参数。

  • 在这个例子中,委托不返回任何值,但委托可以返回任何东西。然而,在调用回调方法之前,会执行空引用检查。如果委托没有被分配到一个方法的引用,调用委托会导致NullReferenceException

客户端代码创建了Engine类的一个实例,注册了状态更改的处理程序,然后启动和停止它。代码如下:

class Program
{
    static void Main(string[] args)
    {
        Engine engine = new Engine();
        engine.RegisterStatusChangeHandler
          (OnEngineStatusChanged); 
        engine.Start();
        engine.Stop();
    }
    private static void OnEngineStatusChanged(Status status)
    {
        Console.WriteLine($"Engine is now {status}");
    }
}

静态方法OnEngineStatusChanged()用作引擎启动和停止事件的回调。其签名与委托的类型匹配。执行此程序将产生以下输出:

Engine is now Started
Engine is now Stopped

.NET 委托的一个重要方面是它们支持多播。这意味着您实际上可以设置对要调用的任意多个方法的引用;然后委托将按照它们被添加的顺序调用它们。多播委托由System.MulticastDelegate类表示。该类在内部具有称为调用列表的委托链表。此列表可以有任意数量的元素。当调用多播委托时,调用列表中的所有委托按照它们在列表中出现的顺序(即它们被添加的顺序)被调用。此操作是同步的,如果在调用列表的执行过程中出现任何错误,将抛出异常。

另一方面,当您不再希望调用某个方法时,可以从委托中移除对该方法的引用。这两个方面将在以下示例中得到说明,其中我们改变了Engine类以允许多个回调不仅被注册,而且还可以被注销:

public class Engine
{
    private StatusChange statusChangeHandler;
    public void RegisterStatusChangeHandler(StatusChange handler)
    {
        statusChangeHandler += handler;
    }
    public void UnregisterStatusChangeHandler(StatusChange handler)
    {
        statusChangeHandler -= handler;
    }
    public void Start()
    {
        statusChangeHandler?.Invoke(Status.Started);
    }
    public void Stop()
    {
        statusChangeHandler?.Invoke(Status.Stopped);
    }
}

再次,这里有两件事需要注意:

  • 首先,RegisterStatusChangeHandler()方法不再简单地将其参数分配给statusChangeHandler字段,而是实际上使用+=运算符向委托内部持有的列表添加一个新引用。因此,UnregisterStatusChangeHandler()方法使用-=运算符从委托中移除一个引用。+=-=运算符已被委托类型重载。

  • 其次,Start()Stop()中的代码略有改变。使用空值条件运算符(?.)仅在对象不为null时调用Invoke()方法。

另一方面,主程序中的更改如下:

class Program
{
    static void Main(string[] args)
    {
        Engine engine = new Engine();
        engine.RegisterStatusChangeHandler
          (OnEngineStatusChanged); 
        engine.RegisterStatusChangeHandler
          (OnEngineStatusChanged2); 
        engine.Start();
        engine.Stop();
        engine.UnregisterStatusChangeHandler
          (OnEngineStatusChanged2);
        engine.Start();
    }
    private static void OnEngineStatusChanged(Status status)
    {
        Console.WriteLine($"Engine is now {status}");
    }
    private static void OnEngineStatusChanged2(Status status)
    {
        File.AppendAllText(@"c:\temp\engine.log",
                           $"Engine is now {status}\n");
    }
}

这次,我们注册了两个回调:

  • 一个在控制台上记录事件。

  • 一个记录到文件的回调。

我们启动和停止引擎,然后注销记录到磁盘文件的回调函数。最后,我们再次启动引擎。因此,控制台上的输出将如下所示:

Engine is now Started
Engine is now Stopped
Engine is now Started

然而,只有前两行也出现在磁盘文件上,因为在重新启动引擎之前已经移除了第二个回调函数。

在这个第二个示例中,我们使用Invoke()方法调用委托引用的方法。Invoke()方法是从哪里来的呢?在幕后,当您声明委托类型时,编译器会生成一个从System.MulticastDelegate派生的密封类,该类又从System.Delegate派生。这些都是您不允许显式派生的系统类型。但是,它们提供了我们迄今为止看到的所有功能,例如能够向委托的调用列表中添加和移除方法的能力。

编译器创建的类包含三种方法——Invoke()(用于以同步方式调用回调函数)、BeginInvoke()EndInvoke()(用于以异步方式调用回调函数)。有关异步委托的示例,请参考其他参考资料。您实际上可以通过在反汇编器(如ildasm.exeILSpy)中打开程序集来检查编译器生成的代码。

事件

到目前为止,我们编写的代码有点太显式了。我们不得不创建方法来注册和取消注册对回调方法的引用。这是因为在类中,持有这些引用的委托是私有的。我们可以将其设为公共的,但这样会破坏封装性,并有风险允许客户端错误地覆盖委托的调用列表。为了帮助处理这些方面,.NET 和 C#提供了事件,它们只是我们之前为注册和取消注册回调编写的显式代码的语法糖。事件是用event关键字引入的。

引擎的最后一个实现将更改为以下内容:

public class Engine
{
    public event StatusChange StatusChanged;
    public void Start()
    {
        StatusChanged?.Invoke(Status.Started);
    }
    public void Stop()
    {
        StatusChanged?.Invoke(Status.Stopped);
    }
}

请注意,我们不再有用于注册和取消注册回调的方法,只有一个名为StatusChanged的事件对象。这些是在客户端代码中在事件对象上完成的,使用+=(添加对方法的引用)和-=(删除对方法的引用)操作符。我们可以在以下代码中看到客户端代码。

在这个例子中,我们创建了一个Engine对象,并为StatusChanged事件注册了回调函数——一个是对OnEngineStatusChanged()方法的引用(将事件记录到文件中),另一个是一个 lambda 表达式(将事件记录到控制台):

class Program
{
    static void Main(string[] args)
    {
        Engine engine = new Engine();
        engine.StatusChanged += OnEngineStatusChanged;
        engine.StatusChanged += 
            status => Console.WriteLine(
                        $"Engine is now {status}");
        engine.Start();
        engine.Stop();
        engine.StatusChanged -= OnEngineStatusChanged;
        engine.Start();
    }
    private static void OnEngineStatusChanged(Status status)
    {
        File.AppendAllText(@"c:\temp\engine.log",
                           $"Engine is now {status}\n");
    }
}

启动和停止引擎后,我们取消对OnEngineStatusChanged()的引用,然后重新启动引擎。执行此程序的结果与先前的程序相同。

到目前为止,所有的例子中,委托类型都有一个参数,即引擎的状态。然而,事件模式的正确实现(在整个.NET Framework 中都使用)是有两个参数:

  • 第一个参数是System.Object,它保存了生成事件的对象的引用。由调用的客户端决定是否使用此引用。

  • 第二个参数是从System.EventArgs派生的类型,其中包含与事件相关的所有信息。

为了符合这种模式,我们的Engine的实现将更改为以下内容:

public class EngineEventArgs : EventArgs
{
    public Status Status { get; private set; }
    public EngineEventArgs(Status s)
    {
        Status = s;
    }
}
public delegate void StatusChange(
         object sender, EngineEventArgs args);
public class Engine
{
    public event StatusChange StatusChanged;
    public void Start()
    {
        StatusChanged?.Invoke(this, 
           new EngineEventArgs(Status. Started));
    }
    public void Stop()
    {
        StatusChanged?.Invoke(this, 
           new EngineEventArgs(Status.Stopped));
    }
}

我们将留给读者练习对主程序进行必要的更改,以使用Engine类的新实现。

有关委托和事件的关键要点如下:

  • 委托允许将方法作为参数传递,以便稍后调用,可以同步或异步调用。

  • 委托支持多播,即调用多个回调方法。

  • 静态方法、实例方法、匿名方法和 lambda 表达式都可以作为委托的回调使用。

  • 委托可以是泛型的。

  • 事件是一种语法糖,有助于注册和移除回调。

本章讨论的下一个主题是匿名类型。

匿名类型

有时需要构造临时对象来保存一些值,通常是某个较大对象的子集。为了避免仅为此目的创建特定类型,语言提供了所谓的匿名类型。这些是一种使用后即忘记的类型,通常与语言集成查询LINQ)一起在查询表达式中使用。这个主题将在第十章中讨论,Lambda、LINQ 和函数式编程

这些类型被称为匿名,因为在源代码中没有指定名称。名称由编译器分配。它们只包含只读属性;不允许任何其他成员类型。只读属性的类型不能显式指定,而是由编译器推断。

使用new关键字引入匿名类型,后面跟着一系列属性(对象初始化器)的尖括号。以下代码片段显示了一个例子:

var o = new { Name = "M270 Turbo", Capacity = 1600, 
Power = 75.0 };
Console.WriteLine($"{o.Name} {o.Capacity / 1000.0}l 
{o.Power}kW");

在这里,我们定义了一个具有三个属性NameCapacityPower的匿名类型。这些属性的类型由编译器从它们的初始化值中推断出来。在这种情况下,它们分别是NamestringCapacityintPowerdouble

当从表达式初始化属性时,必须指定属性的名称。但是,如果它是从另一个对象的字段或属性初始化的,名称是可选的。在这种情况下,编译器使用与用于初始化它的成员相同的名称。举个例子,让我们考虑以下类型:

class Engine
{
    public string Name { get; }
    public int Capacity { get; }
    public double Power { get; }

    public Engine(string name, int capacity, double power)
    {
        Name = name;
        Capacity = capacity;
        Power = power;
    }
}

有了这个,我们可以写如下:

var e = new Engine("M270 Turbo", 1600, 75.0);
var o = new { e.Name, e.Power };
Console.WriteLine($"{o.Name} {o.Power}kW");

我们已经创建了Engine类的一个实例。从这个实例中,我们创建了另一个匿名类型的对象,它有两个属性,编译器称之为NamePower,因为它们是从Engine类的NamePower属性初始化的。

匿名类型具有以下属性:

  • 它们被实现为密封类,因此是引用类型。CLI 不会区分匿名类型和其他引用类型。

  • 它们直接派生自System.Object,只能转换为System.Object

  • 它们只能包含只读属性。不允许其他成员。

  • 它们不能用作字段、属性、事件、方法的返回类型或方法、构造函数或索引器的参数类型。

  • 您可以为匿名类型的只读属性指定名称。这在从表达式初始化时是强制性的,但在从字段或属性初始化时是可选的。在这种情况下,编译器使用成员的名称作为属性的名称。

  • 用于初始化属性的表达式不能为 null、匿名函数或指针类型。

  • 匿名类型的作用域是定义它的方法。

  • 当声明匿名类型的变量时,必须使用var作为类型名称的占位符。

元组提供了一种类似的临时类型概念,但具有不同的语义,这是下一节的主题。

元组

outref参数,或者当您想要将多个值作为单个对象传递给方法时。

这个方面代表了匿名类型和元组之间的关键区别。前者用于在单个方法的范围内使用,不能作为参数传递或从方法返回。后者则是为了这个确切的目的而设计的。

在 C#中,有两种类型的元组:

  • System.Tuple

  • System.ValueTuple结构

在下一小节中,我们将看看这两种类型。

元组类

引用元组是在.NET Framework 4.0 中引入的。泛型类System.Tuple可以容纳最多八个不同类型的值。如果需要超过八个值的元组,您将不得不创建嵌套元组。元组可以通过以下两种方式实例化:

  • 通过使用Tuple<T>构造函数

  • 通过使用辅助方法Tuple.Create()

以下两行是等价的:

var engine = new Tuple<string, int, double>("M270 Turbo", 1600, 75);
var engine = Tuple.Create("M270 Turbo", 1600, 75);

这里的第二行更好,因为它更简单,你不必指定每个值的类型。这是因为编译器从参数中推断出类型。

元组的元素可以通过名为Item1Item2Item3Item4Item5Item6Item7Rest的属性访问。在下面的示例中,我们使用Item1Item2Item3属性将引擎名称、容量和功率打印到控制台上:

Console.WriteLine(
    $"{engine.Item1} {engine.Item2/1000.0}l {engine.Item3}kW");

当需要超过八个元素时,可以使用嵌套元组。在这种情况下,将嵌套元组放在最后一个元素是有意义的。以下示例创建了一个具有 10 个值的元组,其中最后三个值(表示不同功率的发动机功率,单位为千瓦)被分组在第二个嵌套元组中:

var engine = Tuple.Create(
    "M270 DE16 LA R", 1595, 83, 73.7, 180, "gasoline", 2015, 
    Tuple.Create(75, 90, 115));
Console.WriteLine($"{engine.Item1} powers: {engine.Rest.Item1}");

请注意这里我们使用的是Rest.Item1而不是简单的Rest。该程序的输出如下:

M270 DE16 LA R powers: (75, 90, 115)

这是因为变量 engine 的推断类型是 Tuple<string, int, int, double, int, string, int, Tuple<Tuple<int, int, int>>>。因此,Rest 表示一个包含单个值的元组,该值也是包含三个 int 值的元组。要访问嵌套元组的元素,您必须使用,对于这种情况,Rest.Item1.Item1Rest.Item1.Item2Rest.Item1.Item3

要创建类型为 Tuple<string, int, int, double, int, string, int, Tuple<int, int, int>> 的元组,必须使用构造函数的显式语法:

var engine = new Tuple<string, int, int, double, int, string, int, Tuple<int, int, int>>
    ("M270 DE16 LA R", 1595, 83, 73.7, 180, "gasoline", 2015,
    new Tuple<int, int, int>(75, 90, 115));
Console.WriteLine($"{engine.Item1} powers: {engine.Rest}");

System.Tuple 是一个引用类型,因此此类型的对象分配在堆上。如果在程序执行过程中发生许多小对象的分配,可能会影响性能。

这增加了我们之前看到的限制——元素数量和未命名属性。为了克服这些问题,C# 7.0、.NET Framework 4.7 和 .NET Standard 2.0 引入了值类型元组,我们将在下一节中探讨。

值元组

这些由 System.ValueTuple 结构表示。如果您的项目不针对 .NET Framework 4.7 或更高版本,或 .NET Standard 2.0 或更高版本,您仍然可以通过将其安装为 NuGet 包来使用 ValueTuple

在几个 7.x 版本的语言中添加了各种值元组功能。这里描述的功能与 C# 8 对齐。

除了值语义之外,值元组在几个重要方面与引用元组不同:

  • 它们可以容纳任意数量的元素序列,但至少需要两个。

  • 它们可能具有编译时命名字段。

  • 它们具有更简单但更丰富的语法,用于创建、赋值、解构和比较值。

使用括号语法和指定的值来创建值元组。以下三个声明是等价的:

ValueTuple<string, int, double> engine = ("M270 Turbo", 1600, 75.0);
(string, int, double) engine = ("M270 Turbo", 1600, 75.0);
var engine = ("M270 Turbo", 1600, 75.0);

在所有这些情况下,变量 engine 的类型是 ValueTuple<string, int, double>,元组被称为未命名。在这种情况下,它的值可以在公共字段中访问——Item1Item2Item3,这些是编译器隐式分配的名称:

Console.WriteLine(
    $"{engine.Item1} {engine.Item2/1000.0}l {engine.Item3}kW");

但是,在创建值元组时,您可以选择为值指定名称,从而为字段创建同义词,如 Item1Item2 等。这种值元组称为命名元组。您可以在以下代码片段中看到一个命名元组的示例:

var engine = (Name: "M270 Turbo", Capacity: 1600, Power: 75.0);
Console.WriteLine(
    $"{engine.name} {engine.capacity / 1000.0}l {engine.power}kW");

这些同义词仅在编译时可用,因为 IDE 利用 Roslyn API 从源代码中为您提供它们,但在编译器中间语言代码中,它们不可用,只有未命名字段——Item1Item2 等。

字段的名称可以出现在赋值的任一侧;此外,它们可以同时出现在两侧,在这种情况下,左侧名称优先右侧名称被忽略。以下两个声明将产生一个与前面代码中看到的命名值元组相同的命名值元组:

(string Name, int Capacity, double Power) engine = 
    ("M270 Turbo", 1600, 75.0);
(string Name, int Capacity, double Power) engine = 
    (name: "M270 Turbo", cap: 1600, pow: 75.0);

字段的名称也可以从用于初始化值元组的变量中推断出(如 C# 7.1)。在以下示例中,值元组将具有名为 namecapacity(小写)和 Item3 的字段,因为最后一个值是一个没有明确指定名称的文字:

var name = "M270 Turbo";
var capacity = 1600;
var engine = (name, capacity, 75);
Console.WriteLine(
    $"{engine.name} {engine.capacity / 1000.0}l {engine.Item3}kW");

从方法返回值元组非常简单。在以下示例中,GetEngine() 函数返回一个未命名的值类型:

(string, int, double) GetEngine()
{
    return ("M270 Turbo", 1600, 75.0);
}

但是,您可以选择返回一个命名值类型,在这种情况下,您需要指定字段的名称,如下所示:

(string Name, int Capacity, double Power) GetEngine2()
{
    return ("M270 Turbo", 1600, 75.0);
}

从 C# 7.3 开始,可以使用==!=运算符测试值元组的相等性不相等性。这些运算符通过按顺序比较左侧的每个元素与右侧的每个元素来工作。当第一对不相等时,比较停止。但是,这仅在元组的形状相同时发生,即字段的数量和它们的类型。名称不参与相等性或不相等性的测试。下一个示例比较了两个值元组:

var e1 = ("M270 Turbo", 1600, 75.0);
var e2 = (Name: "M270 Turbo", Capacity: 1600, Power: 75.0);
Console.WriteLine(e1 == e2);

元组相等如果一个元组是可空元组,则执行提升转换,以及对两个元组的每个成员进行隐式转换。后者包括提升转换、扩展转换或其他隐式转换。例如,以下元组是相等的:

(int, long) t1 = (1, 2);
(long, int) t2 = (1, 2);
Console.WriteLine(t1 == t2);

可以解构元组的值。可以通过显式指定变量的类型或使用var来实现。以下声明都是等效的。在以下和最后一个示例中,var的使用与显式类型名称相结合:

(string name, int capacity, double power) = GetEngine();
(var name, var capacity, var power) = GetEngine();
var (name, capacity, power) = GetEngine();
(var name, var capacity, double power) = GetEngine();

如果有您不感兴趣的值,可以使用_占位符来忽略它们,如下所示:

(var name, _, _) = GetEngine();

可以对任何.NET 类型进行解构,只要提供了一个名为Deconstruct的方法,该方法具有您想要检索的每个值的out参数。

在下面的示例中,Engine类有三个属性:NameCapacityPowerDeconstruct()公共方法使用三个输出参数匹配这些属性。这使得可以使用元组语法对此类型的对象进行解构。以下清单显示了提供元组解构的Engine类的实现:

class Engine
{
    public string Name { get; }
    public int Capacity { get; }
    public double Power { get; }
    public Engine(string name, int capacity, double power)
    {
        Name = name;
        Capacity = capacity;
        Power = power;
    }
    public void Deconstruct(out string name, out int capacity, 
                            out double power)
    {
        name = Name;
        capacity = Capacity;
        power = Power;
    }
}
var engine = new Engine("M270 Turbo", 1600, 75.0);
var (Name, Capacity, Power) = engine;

Deconstruct方法可以作为扩展方法提供,使您能够为您没有编写的类型提供解构语义,前提是您只需要解构通过类型的公共接口可访问的值。这里展示了一个示例:

class Engine
{
    public string Name { get; }
    public int Capacity { get; }
    public double Power { get; }
    public Engine(string name, int capacity, double power)
    {
        Name = name;
        Capacity = capacity;
        Power = power;
    } 
}
static class EngineExtension
{
    public static void Deconstruct(this Engine engine, 
                                   out string name, 
                                   out int capacity, 
                                   out double power)
    {
        name = engine.Name;
        capacity = engine.Capacity;
        power = engine.Power;
    }
}

如果您有一个类的层次结构,并且提供了Deconstruct()方法,则必须确保不会引入歧义,例如在不同重载具有相同数量的参数的情况下。应该注意,解构运算符不参与测试相等性。因此,以下示例将生成编译器错误:

var engine = new Engine("M270 Turbo", 1600, 75.0);
Console.WriteLine(engine == ("M270 Turbo", 1600, 75.0));

总结一下,C# 7 中对值元组的支持使得在关键场景中更容易使用元组,比如保存临时值或来自数据库的记录。这可以在不引入新类型或返回多个值的情况下完成,而不使用outref参数。通过值语义的性能优势以及基于名称的元素访问的改进,以及其他关键特性,命名值是本节开始时看到的引用类型元组的重要改进。

模式匹配

ifswitch语句中,我们检查对象是否具有某个值,然后继续从中提取信息。然而,这是一种基本形式的模式匹配。

在 C# 7 中,对isswitch语句添加了新的功能,以实现模式匹配功能,从而更好地分离数据和代码,并导致更简洁和可读的代码。C# 8 中的新功能扩展了模式匹配功能。您将在第十五章中了解这些内容,C# 8 的新功能

is 表达式

在运行时,is运算符检查对象是否与给定类型兼容(一般形式为expr is type)。然而,在 C# 7 中,这被扩展为包括几种形式的模式匹配:

  • expr is type varname形式,检查表达式是否可以转换为指定类型,如果可以,则将其转换为指定类型的变量。

  • expr is constant形式,检查表达式是否评估为指定的常量。特定常量是null,其模式为expr is null

  • expr is var varname形式,总是成功并将值绑定到一个新的局部变量。与类型模式的一个关键区别是null总是匹配,并且新变量被赋值为null

为了理解这些工作原理,我们将使用几个代表车辆的类:

class Airplane
{
    public void Fly() { }
}
class Bike
{
    public void Ride() { }
}
class Car
{
    public bool HasAutoDrive { get; }
    public void Drive() { }
    public void AutoDrive() { }
}

这些车辆类不是类层次结构的一部分,但它们有设置车辆运动的公共方法,根据其类型。例如,飞机飞行,自行车骑行,汽车驾驶。下一个代码清单显示了使用几种形式的模式匹配的函数:

void SetInMotion(object vehicle)
{
    if (vehicle is null)
        throw new ArgumentNullException(
            message: "Vehicle must not be null",
            paramName: nameof(vehicle));
    else if (vehicle is Airplane a)
        a.Fly();
    else if (vehicle is Bike b)
        b.Ride();
    else if (vehicle is Car c)
    {
        if (c.HasAutoDrive) c.AutoDrive();
        else c.Drive();
    }
    else
        throw new ArgumentException(
           message: "Unexpected vehicle type", 
           paramName: nameof(vehicle)); 
}

该函数根据其特定的方式使车辆运动起来。像if(vehicle is Airplane a)这样的语句测试变量 vehicle 是否可以转换为Airplane类型,如果是,则将其分配给Airplane类型的新变量(在本例中为a)。这适用于值类型和引用类型。

这里看到的变量abc只在ifelse语句的局部范围内。然而,只有在匹配成功时,这些变量才在范围内并被赋值。这可以防止您在模式匹配表达式未匹配时访问结果。

除了类型模式,这里还使用了常量模式。if (vehicle is null)语句是一个测试,用于查看引用是否实际设置为对象的实例;如果没有,就会抛出异常。然而,如前所述,常量模式匹配可以与任何常量一起使用——文字值、用 const 修饰符声明的变量,或者枚举值。常量表达式的评估方式如下:

  • 如果expr和常量都是整数类型,它基本上评估expr == constant表达式。

  • 否则,它调用静态方法Object.Equals(expr, constant)

以下函数显示了更多的常量模式匹配示例。IsTrue()函数将提供的参数转换为布尔值。布尔值(true),整数值(1),字符串("1")和字符串("true")都转换为true;包括null在内的其他所有内容都转换为false

bool IsTrue(object value)
{
    if (value is null) return false;
    else if (value is 1) return true;
    else if (value is true) return true;
    else if (value is "true") return true;
    else if (value is "1") return true;
    return false;
}
Console.WriteLine(IsTrue(null));   // False
Console.WriteLine(IsTrue(0));      // False
Console.WriteLine(IsTrue(1));      // True
Console.WriteLine(IsTrue(true));   // True
Console.WriteLine(IsTrue("true")); // True
Console.WriteLine(IsTrue("1"));    // True
Console.WriteLine(IsTrue("demo")); // False

switch 表达式

您需要检查的模式越多,编写这些if-else语句就越繁琐。自然地,您会想用switch替换它们。相同类型的模式匹配也支持switch语句,具有类似的语法。

直到 C# 7.0,switch语句支持整数类型和字符串的常量模式匹配。自 C# 7.0 以来,前面看到的类型模式也支持在switch语句中。

在前一节中显示的SetInMotion()函数可以修改为使用switch语句:

void SetInMotion(object vehicle)
{
    switch (vehicle)
    {
        case Airplane a:
            a.Fly();
            break;
        case Bike b:
            b.Ride();
            break;
        case Car c:
            if (c.HasAutoDrive) c.AutoDrive();
            else c.Drive();
            break;
        case null:
            throw new ArgumentNullException(
                message: "Vehicle must not be null",
                paramName: nameof(vehicle));
        default:
            throw new ArgumentException(
               message: "Unexpected vehicle type", 
               paramName: nameof(vehicle));
    }
}

使用常量模式匹配的switch语句只能有一个与switch表达式的值匹配的情况标签。此外,switch部分不能穿过下一个部分,而必须以breakreturngoto结束。然而,它们可以以任何顺序排列,而不会影响程序语义和执行的行为。

使用类型模式匹配,规则会发生变化。switch部分可以穿过下一个,goto不再支持作为跳转机制。情况标签表达式按照它们在文本中出现的顺序进行评估,只有在没有任何情况标签与模式匹配时才执行默认情况。默认情况可以出现在switch的任何位置,但始终在最后执行。

如果默认情况缺失,并且没有任何现有的情况标签与模式匹配,执行将在switch语句之后继续,而不会执行任何情况标签中的代码。

switch表达式的类型模式匹配还支持when子句。以下示例展示了SetInMotion()方法的另一个版本,它使用了两个 case 标签来匹配Car类型,但其中一个带有条件——即Car对象的HasAutoDrive属性设置为true

void SetInMotion(object vehicle)
{
    switch (vehicle)
    {
        case Airplane a:
            a.Fly();
            break;
        case Bike b:
            b.Ride();
            break;
        case Car c when c.HasAutoDrive:
            c.AutoDrive();
            break;
        case Car c:
            c.Drive();
            break;
        case null:
            throw new ArgumentNullException(
                message: "Vehicle must not be null",
                paramName: nameof(vehicle));
        default:
            throw new ArgumentException(
              message: "Unexpected vehicle type", 
              paramName: nameof(vehicle)); 
    }
}

需要注意的是,匹配类型模式保证了非空值,因此不需要进一步测试null。对于在语言中匹配null有特殊规则。null值不匹配类型模式,无论变量的类型如何。可以在具有类型模式匹配的 switch 表达式中添加一个用于特别处理null值的模式匹配的 case 标签。在前面的实现中就有这样的例子。

一种特殊的类型模式匹配形式是使用var。规则与is表达式相似——类型是从 switch 表达式的静态类型中推断出来的,而null值总是匹配的。因此,在使用var模式时,您必须添加显式的null检查,因为值实际上可能是nullvar声明可能与默认情况匹配相同的条件;在这种情况下,即使存在默认情况,它也永远不会执行。

让我们看一下以下函数,它执行作为字符串参数接收的命令:

void ExecuteCommand(string command)
{
    switch(command)
    {
        case "add":  /* add */    break;
        case "del":  /* delete */ break;
        case "exit": /* exit */   break;
        case var o when (o?.Trim().Length ?? 0) == 0:
            /* do nothing */
            break;
        default:
            /* invalid command */
            break;
    }
}

这个函数尝试匹配adddelexit命令,并适当地执行它们。但是,如果参数是null、空或只包含空格,它将不执行任何操作。但这与不支持或无法识别的实际命令是不同的情况。var模式匹配有助于以简单而优雅的方式区分这两种情况。

以下是本主题的关键要点:

  • C# 7.0 中添加的模式匹配功能是对已有简单模式匹配能力的增量更新。

  • 新支持的模式包括常量模式、类型模式和var模式。

  • 模式匹配与is表达式和switch语句中的 case 块一起工作。

  • switch表达式模式匹配支持where子句。

  • var模式始终匹配任何值,包括null,因此需要进行null测试。

C# 8.0 还为 switch 表达式模式匹配引入了更多功能:属性模式、元组模式和位置模式。您可以在第十五章中了解这些内容,C# 8 的新功能

正则表达式

另一种模式匹配形式是正则表达式。System.Text.RegularExpressions命名空间。在接下来的页面中,我们将看看如何使用这个类来匹配输入文本,找到其中的部分,或替换文本的部分。

正则表达式由常量(代表字符串集合)和操作符号(代表对这些集合进行操作的操作符)组成。构建正则表达式的实际语言比本章节的范围所能描述的更加复杂。如果您对正则表达式不熟悉,我们建议您使用其他资源来学习。您也可以使用在线工具(例如 https://regex101.com/或 https://regexr.com/)构建和测试您的正则表达式。

概述

.NET 中的正则表达式是基于 Perl 5 正则表达式构建的。因此,大多数 Perl 5 正则表达式与.NET 正则表达式兼容。另一方面,该框架支持另一种表达式风格,称为ECMAScript,这基本上是 JavaScript 的另一个名称(ECMAScript实际上是脚本语言的 ECMA 标准,JavaScript 是其最著名的实现)。但是,在使用正则表达式时,您必须明确指定此风格。自.NET 2.0 以来,.NET 正则表达式的实现保持不变,在.NET Core 中也是如此。

以下是此实现支持的一些功能:

  • 不区分大小写匹配

  • 从右到左搜索(用于具有从右到左书写系统的语言,如阿拉伯语、希伯来语或波斯语)

  • 多行或单行搜索模式,改变一些符号的含义,如ˆ$.(点)

  • 将正则表达式编译为程序集,并在使用模式搜索大量字符串时提高性能的可能性

  • 无限宽度的后行断言使我们能够向后移动到任意长度,并在字符串中检查后行断言内的文本是否可以在那里匹配

  • 字符类减法允许您从另一个字符类中指定一个字符类来减去

  • 平衡组允许您确保子表达式与另一个子表达式匹配的类型数量相等

其中一些功能是通过作为Regex类构造函数参数提供的标志来启用的。RegexOptions枚举提供以下标志,可以组合使用:

在我们转到下一节来看如何在 C#中实际使用正则表达式之前,还有两件重要的事情要提到:

  • 首先,正则表达式具有一组特殊字符。其中之一是\(反斜杠)。与另一个文字字符结合使用时,这将创建一个具有特殊含义的新标记。例如,\d匹配 0 到 9 之间的任何单个数字。由于反斜杠在 C#中也是一个特殊字符,用于引入字符转义序列,因此在字符串中编写正则表达式时,您需要使用双反斜杠,例如"(\\d+)"。但是,您可以使用逐字字符串来避免这种情况,并保持正则表达式的自然形式。前面的示例可以写成@"(\d+)"

  • 另一个重要的事情是Regex类隐式假定要匹配的字符串采用 UTF-8 编码。这意味着\w\d\s标记匹配任何 UTF-8 代码点,该代码点是任何语言中的有效字符、数字或空白字符。例如,如果您使用\d+来匹配任意数量的数字,您可能会惊讶地发现它不仅匹配 0-9,还匹配以下字符:

如果要将匹配限制为\d的英文数字,\w的英文数字和字母以及下划线,以及\s的标准空白字符,则需要使用RegexOptions.ECMAScript选项。

现在让我们看看如何定义正则表达式并使用它们来确定某些文本是否与表达式匹配。

匹配输入文本

正则表达式提供的最简单功能是检查输入字符串是否具有所需的格式。这对于执行验证非常有用,例如检查字符串是否是有效的电子邮件地址、IP 地址、日期等。

为了理解这是如何工作的,我们将验证输入文本是否是有效的 ISO 8061 日期。为简单起见,我们只考虑YYYY-MM-DD的形式,但是作为练习,您可以扩展此以支持其他格式。我们将用于此的正则表达式是(\d{4})-(1[0-2]|0[1-9]|[0-9]{1})-(3[01]|[12][0-9]|0[1-9]|[1-9]{1})

分解成部分,子表达式如下:

以下两个例子是等价的。Regex类对于IsMatch()有静态和非静态的重载,你可以使用任何一个得到相同的结果。其他方法也是如此,我们将在接下来的章节中看到,比如Match()Matches()Replace()Split()

var pattern = @"(\d{4})-(1[0-2]|0[1-9]|[1-9]{1})-(3[01]|[12][0-9]|0[1-9]|[1-9]{1})";
var success = Regex.IsMatch("2019-12-25", pattern);
// or
var regex = new Regex(pattern);
var success = regex.IsMatch("2019-12-25");

如果你只需要匹配一个模式一次或几次,那么你可以使用静态方法,因为它们更简单。然而,如果你需要匹配数万次或更多次相同的模式,使用类的实例并调用非静态成员可能更快。对于大多数常见的用法,情况并非如此。在下面的例子中,我们将只使用静态方法。

IsMatch()方法有一些重载,使我们能够为正则表达式指定选项和超时时间间隔。当正则表达式过于复杂,或者输入文本过长,解析所需的时间超过了期望的时间时,这是很有用的。看下面的例子:

var success = Regex.IsMatch("2019-12-25",
                            pattern,
                            RegexOptions.ECMAScript,
                            TimeSpan.FromMilliseconds(1));

在这里,我们启用了正则表达式的 ECMAScript 兼容行为,并设置了一毫秒的超时值。

现在我们已经看到了如何匹配文本,让我们学习如何搜索子字符串和模式的多次出现。

查找子字符串

到目前为止的例子中,我们只检查了输入文本是否符合特定的模式。但也可以获取有关结果的信息。例如,每个标题组中匹配的文本、整个匹配值、输入文本中的位置等。为了做到这一点,必须使用另一组重载。

Match()方法检查输入字符串中与正则表达式匹配的子字符串,并返回第一个匹配项。Matches()方法也进行相同的搜索,但返回所有匹配项。前者的返回类型是System.Text.RegularExpressions.Match(表示单个匹配项),后者的返回类型是System.Text.RegularExpressions.MatchCollection(表示匹配项的集合)。考虑下面的例子:

var pattern =
    @"(\d{4})-(1[0-2]|0[1-9]|[1-9]{1})-(3[01]|[12][0-9]|0[1-9]|[1-9]{1})";
var match = Regex.Match("2019-12-25", pattern);
Console.WriteLine(match.Value);
Console.WriteLine(
    $"{match.Groups[1]}.{match.Groups[2]}.{match.Groups[3]}");

控制台打印的第一个值是2019-12-25,因为这是整个匹配的值。第二个值是由每个捕获组的单独值组成的,但是用点(.)作为分隔符。因此,输出文本是2019.12.25

捕获组可能有名称;形式为(?<name>...)。在下面的例子中,我们称正则表达式的三个捕获组为yearmonthday

var pattern =
    @"(?<year>\d{4})-(?<month>1[0-2]|0[1-9]|[1-9]{1})-(?<day>3[01]|[12][0-9]|0[1-9]|[1-9]{1})";
var match = Regex.Match("2019-12-25", pattern);
Console.WriteLine(
    $"{match.Groups["year"]}-{match.Groups["month"]}-{match.Groups["day"]}");

如果输入文本有多个与模式匹配的子字符串,我们可以使用Matches()函数获取所有这些子字符串。在下面的例子中,日期每行提供一个,但最后两个日期不合法(2019-13-212019-1-32);因此,这些在结果中找不到。为了解析字符串,我们使用了多行选项,这样^$就分别指向每行的开头和结尾,而不是整个字符串,如下面的例子所示:

var text = "2019-05-01\n2019-5-9\n2019-12-25\n2019-13-21\n2019-1-32";
var pattern =
    @"^(\d{4})-(1[0-2]|0[1-9]|[1-9]{1})-(3[01]|[12][0-9]|0[1-9]|[1-9]{1})$";
var matches = Regex.Matches(
  text, pattern, RegexOptions. Multiline); 
foreach(Match match in matches)
{
    Console.WriteLine(
      $"[{match.Index}..{match.Length}]={match. Value}");
}

程序的输出如下:

[0..10]=2019-05-01
[11..8]=2019-5-9
[20..10]=2019-12-25

有时,我们不仅想要找到输入文本的子字符串;我们还想用其他东西替换它们。这个主题在下一节中讨论。

替换文本的部分

正则表达式也可以用来用另一个字符串替换匹配正则表达式的字符串的部分。Replace()方法有一组重载,你可以指定一个字符串或一个所谓的Match参数,并返回一个字符串。在下面的例子中,我们将使用这个方法将日期的格式从YYYY-MM-DD改为MM/DD/YYYY

var text = "2019-12-25";
var pattern = @"(\d{4})-(1[0-2]|0[1-9]|[1-9]{1})-(3[01]|[12]
    [0-9]|0[1-9]|[1-9]{1})";
var result = Regex.Replace(
    text, pattern,
    m => $"{m.Groups[2]}/{m.Groups[3]}/{m.Groups[1]}");

作为进一步的练习,你可以编写一个程序,将形式为 2019-12-25 的输入日期转换为 Dec 25, 2019 的形式。

作为本节的总结,正则表达式提供了丰富的模式匹配功能。.NET 提供了代表具有丰富功能的正则表达式引擎的 Regex 类。在本节中,我们已经看到了如何基于模式匹配、搜索和替换文本。这些是您将在各种应用程序中遇到的常见操作。您可以选择这些方法的静态和实例重载,并使用各种选项自定义它们的工作方式。

扩展方法

有时候,向类型添加功能而不改变实现、创建派生类型或重新编译代码是很有用的。我们可以通过在辅助类中创建方法来实现这一点。假设我们想要一个函数来颠倒字符串的内容,因为 System.String 没有这样的函数。这样的函数可以实现如下:

static class StringExtensions
{
    public static string Reverse(string s)
    {
        var charArray = s.ToCharArray();
        Array.Reverse(charArray);
        return new string(charArray);
    }
}

可以按以下方式调用:

var text = "demo";
var rev = StringExtensions.Reverse(text);

C#语言允许我们以一种使我们能够调用它就像它是 System.String 的实际成员的方式来定义这个函数。这样的函数被称为 Reverse() 方法,使其成为扩展方法。新的实现如下所示:

static class StringExtensions
{
    public static string Reverse(this string s)
    {
        var charArray = s.ToCharArray();
        Array.Reverse(charArray);
        return new string(charArray);
    }
}

请注意,实现的唯一变化是在函数参数前面加上了 this 关键字。通过这些变化,函数可以被调用,就好像它是字符串类的一部分:

var text = "demo";
var rev = text.Reverse();

扩展方法的定义和行为适用以下规则:

  • 它们可以扩展类、结构和枚举。

  • 它们必须声明为静态、非嵌套、非泛型类的静态方法。

  • 它们的第一个参数是它们要添加功能的类型。该参数前面带有 this 关键字。

  • 它们只能调用它们扩展的类型的公共成员。

  • 只有当它们声明的命名空间通过 using 指令引入到当前范围时,扩展方法才可用。

  • 如果一个扩展方法(在当前范围内可用)与类的实例方法具有相同的签名,编译器将始终优先选择实例成员,扩展方法将永远不会被调用。

以下示例显示了一个名为 AllMessages() 的扩展方法,它扩展了 System.Exception 类型的功能。这代表了一个异常,有一个消息,但也可能包含内部异常。这个扩展方法返回一个由所有嵌套异常的所有消息连接而成的字符串。布尔参数指示是否应该从主异常到最内部异常连接消息,还是以相反的顺序:

static class ExceptionExtensions
{
    public static string AllMessages(this Exception exception, 
                                     bool reverse = false)
    {
        var messages = new List<string>();
        var ex = exception;
        while(ex != null)
        {
            messages.Add(ex.Message);
            ex = ex.InnerException;
        }
        if (reverse) messages.Reverse();
        return string.Join(Environment.NewLine, messages);
    }
}

然后可以按以下方式调用扩展方法:

var exception = 
    new InvalidOperationException(
        "An invalid operation occurred",
        new NotSupportedException(
            "The operation is not supported",
            new InvalidCastException(
                "Cannot apply cast!")));
Console.WriteLine(exception.AllMessages());
Console.WriteLine(exception.AllMessages(true));

来自.NET 的最常见的扩展方法是扩展 IEnumerableIEnumerable<T> 类型的 LINQ 标准运算符。我们将在第十章 Lambdas, LINQ, and Functional Programming中探讨 LINQ。如果您实现扩展方法来扩展无法更改的类型,您必须牢记将来对类型的更改可能会破坏扩展方法。

总结

在本章中,我们讨论了一系列高级语言特性。我们从实现强类型回调的委托和事件开始。我们继续讨论了匿名类型和元组,这些是轻量级类型,可以保存任何值,并帮助我们避免定义新的显式类型。然后我们看了模式匹配,这是检查值是否具有特定形状以及提取有关它的信息的过程。我们继续讨论了正则表达式,这是具有明确定义的语法的模式,可以与文本匹配。最后,我们学习了扩展方法,它使我们能够向类型添加功能,而不改变它们的实现,比如当我们不拥有源代码时。

在下一章中,我们将讨论垃圾回收和资源管理。

测试你学到的知识

  1. 什么是回调函数,它们与委托有什么关系?

  2. 你如何定义委托?事件又是什么?

  3. 有多少种类型的元组?它们之间的主要区别是什么?

  4. 什么是命名元组,如何创建它们?

  5. 什么是模式匹配,它可以与哪些语句一起使用?

  6. 模式匹配空值的规则是什么?

  7. 哪个类实现了正则表达式,它默认使用什么编码?

  8. 这个类的Match()Matches()方法有什么区别?

  9. 什么是扩展方法,它们为什么有用?

  10. 你如何定义一个扩展方法?

第九章:资源管理

在之前的章节中,我们讨论并使用了值类型和引用类型,并且也看到了它们的不同之处。我们也简要讨论了运行时如何管理分配的内存。

在本章中,我们将更详细地讨论这个主题,并查看管理内存和资源的语言特性和最佳实践。

本章将讨论以下主题:

  • 垃圾回收

  • 终结器

  • IDisposable 接口

  • using语句

  • 平台调用

  • 不安全的代码

在本章结束时,您将学会如何实现可处理的类型以及在不再需要时如何处理对象。您还将学会如何调用本机 API 并编写不安全的代码。

垃圾回收

公共语言运行时CLR)负责管理对象的生命周期,并在不再使用时释放内存,以便在进程内分配新对象。它通过一个名为垃圾收集器GC)的组件来实现这一点,该组件以高效的方式在托管堆上分配对象,并通过回收不再使用的对象来清除内存。垃圾收集器使得开发应用程序更容易,因为您不必担心手动释放内存。这就是使为.NET 编写的应用程序被称为托管的原因。

在我们讨论所有这些是如何发生之前,你需要理解之间的区别,以及类型对象引用之间的区别。

类型(无论是在 C#中使用class还是struct关键字引入的)是构造对象的蓝图。它在源代码中使用语言特性描述。对象是类型的实例化,并存在于内存中。引用是一种句柄(基本上是一个存储位置),指向一个对象。

现在,让我们讨论内存。栈是编译器分配的一个相对较小的内存段,用于跟踪运行应用程序所需的内存。栈具有**后进先出(LIFO)**语义,并且随着程序执行调用函数或从函数返回而增长和缩小。另一方面,堆是程序可能在运行时分配内存的一个大内存段,在.NET 中由 CLR 管理。

值类型的对象可以存储在多个位置。它们通常存储在栈上,但也可以存储在 CPU 寄存器上。作为引用类型的值类型存储在堆上作为封闭对象的一部分。引用类型的对象总是存储在堆上,但对象的引用存储在栈或 CPU 寄存器上。

为了更好地理解这一点,让我们考虑下面的短程序,其中Point2D是一个值类型,Engine是一个引用类型:

class Program
{
    static void Main(string[] args)
    {
        var i = 42;
        var pt = new Point2D(1, 2); // value type
        var engine = new Engine();  // reference type
    }
}

在概念上(因为这是一个非常简单的表示),栈和堆将包含以下值:

图 9.1 - 在上述程序执行期间栈和堆内容的概念表示

图 9.1 - 在上述程序执行期间栈和堆内容的概念表示

栈由编译器管理,本章的其余部分我们将讨论堆以及运行时如何管理它。.NET 运行时将对象分为两组:

  • 大型:这些对象是大于 85 KB 的对象;多维对象也包括在此类别中。

  • 小型:这些对象是所有其他对象。

堆由称为的几个内存段组成。内存有三代 - 012

  • 第 0 代包含,通常是短寿命的对象,比如局部变量或在函数调用的生命周期内实例化的对象。

  • 第一代包含小对象,它们在对第 0 代内存进行垃圾收集后幸存下来。

  • 第 2 代包含长寿命的小对象,它们在对第 1 代内存进行垃圾收集后幸存下来,以及大对象(总是分配在这个段上)。

当运行时需要在托管堆上分配对象而内存不足时,它会触发垃圾收集。垃圾收集有三个阶段:

  • 首先,垃圾收集器构建了所有活动对象的图形,以便弄清楚什么仍在使用,什么可以被删除。

  • 第二,更新将被压缩的对象的引用。

  • 第三,死对象被移除,幸存对象被压缩。通常,包含大对象的大对象堆不会被压缩,因为移动大块数据会产生性能成本。

当垃圾收集开始时,所有托管线程都被暂停,除了启动收集的线程。当垃圾收集结束时,线程会恢复。垃圾收集的第一阶段从所谓的应用根开始,这些是包含对堆上对象引用的存储位置。应用根包括对全局对象、静态对象、字段、局部对象、作为函数参数传递的对象、等待终结的对象以及包含对堆上对象引用的 CPU 寄存器的引用。

CLR 构建了可达堆对象的图形;所有不可达的对象将被删除。如果所有第 0 代对象都已经被评估,但释放的内存还不够,垃圾收集将继续评估第 1 代。如果之后需要更多内存,垃圾收集将继续评估第 2 代。

幸存下来的第 0 代垃圾收集的对象被分配到第 1 代,幸存下来的第 1 代对象被分配到第 2 代。然而,幸存下来的第 2 代垃圾收集的对象仍然留在第 2 代。如果垃圾收集过程结束后,在大对象堆上没有足够的内存(总是属于第 2 代)来分配所请求的内存,CLR 会抛出OutOfMemoryException类型的异常。这并不一定意味着没有更多的内存,而是这个段上未压缩的内存不包含足够大的块来存放新对象。

基类库包含一个名为System.GC的类,它使我们能够与垃圾收集器交互。然而,除了在本章后面将看到的IDisposable 接口部分中实现的可释放模式之外,这很少发生。这个类有几个成员:

以下程序使用System.GC类来显示Engine对象的当前代数,以及调用时托管堆的估计大小:

class Program
{
    static void Main(string[] args)
    {
        var engine = new Engine("M270 Turbo", 1600, 75.0);
        Console.WriteLine(
          $"Generation of engine: 
        {GC.GetGeneration(engine)}"); 
        Console.WriteLine(
          $"Estimated heap size: {GC.
        GetTotalMemory(false)}"); 
    }
}

程序的输出如下:

图 9.2 – 一个控制台截图显示了前面程序的输出

图 9.2 – 一个控制台截图显示了前面程序的输出

我们将在下一节学习终结器。

终结器

垃圾收集器提供了托管资源的自动释放。然而,有些情况下你必须处理非托管资源,比如原始文件句柄、窗口或其他通过平台调用服务P/Invoke)调用检索的操作系统资源,以及一些高级场景中的 COM 对象引用。这些资源在对象被垃圾收集器销毁之前必须显式释放,否则会发生资源泄漏。

每个对象都有一个特殊的方法,称为System.Object类有一个虚拟的受保护成员叫做Finalize(),带有一个空的实现。下面的代码展示了这一点:

class Object
{
    protected virtual void Finalize() {}
}

尽管这是一个虚方法,但你实际上不能直接重写它。相反,C#语言提供了一个与 C++中析构函数相同的语法来创建一个终结器并重写System.Object方法。然而,这只对引用类型实现是可能的;值类型不能有终结器,因为它们不会被垃圾收集。以下代码展示了这一点:

class ResourceWrapper
{
    // constructor
    ResourceWrapper() 
    {
        // construct the object
    }
    // finalizer
    ~ResourceWrapper()
    {
        // release unmanaged resources
    }
}

你不能显式重写Finalize()方法的原因是,C#编译器会添加额外的代码来确保在终结时实际上调用基类的实现(这意味着在继承链中的所有实例上都调用Finalize()方法)。因此,编译器用以下代码替换了之前显示的终结器:

class ResourceWrapper
{
    protected override void Finalize()
    {
        try
        {
            // release unmanaged resources
        }
        finally
        {
            base.Finalize();
        }
    }
}

尽管一个类可能有多个构造函数,但它只能有一个终结器。因此,终结器不能被重载或具有修饰符和参数;它们也不能被继承。终结器不会被直接调用,而是由垃圾收集器调用。

垃圾收集器调用终结器的方式如下。当创建一个具有终结器的对象时,垃圾收集器将其引用添加到一个名为终结队列的内部结构中。在收集对象时,垃圾收集器调用终结队列中所有对象的终结器,除非它们已经通过调用GC.SupressFinalize()免除了终结。这也是在应用程序域被卸载时进行的操作,但仅适用于.NET Framework;对于.NET Core 来说,情况并非如此。终结器的调用仍然是不确定的。调用的确切时刻以及调用发生的线程都是未定义的。此外,即使两个对象的终结器相互引用,也不能保证以任何特定顺序发生。

信息框

由于终结器会导致性能损失,请确保不要创建空的终结器。只有在对象必须处理未托管资源时才实现终结器。

以下代码中显示的HandleWrapper类是一个本机句柄的包装器。实际的实现可能更复杂;这只是为教学目的而显示的。原始句柄可能是在本机代码中创建并传递给托管应用程序。这个类拥有句柄的所有权,因此在对象不再需要时需要释放它。这是通过使用P/Invoke调用CloseHandle()系统 API 来完成的。该类定义了一个终结器来实现这一点。让我们看一下以下代码:

public class HandleWrapper
{
    [DllImport("kernel32.dll", SetLastError=true)]
    static extern bool CloseHandle(IntPtr hHandle);
    public IntPtr Handle { get; private set; }

    public HandleWrapper(IntPtr ptr)
    {
        Handle = ptr;
    }

    ~HandleWrapper()
    {
        if(Handle != default)
            CloseHandle(Handle);
    } 
}

很少有情况下你实际上需要创建一个终结器。对于前面提到的情景,有系统包装器可用于处理未托管资源。你应该使用以下安全句柄之一:

  • SafeFileHandle:文件句柄的包装器

  • SafeMemoryMappedFileHandle,内存映射文件句柄的包装器

  • SafeMemoryMappedViewHandle,一个对未托管内存块的指针的包装器

  • SafeNCryptKeyHandleSafeNCryptProviderHandleSafeNCryptSecretHandle,加密句柄的包装器

  • SafePipeHandle,管道句柄的包装器

  • SafeRegistryHandle,对注册表键句柄的包装器

  • SafeWaitHandle,等待句柄的包装器

如前所述,终结器仍然是不确定的。为了确保资源的确定性释放,无论是托管的还是未托管的,一个类型应该提供一个Close()方法或实现IDisposable接口。在这种情况下,终结器只能用于在未调用Dispose()方法时释放未托管资源。

我们将在下一节学习IDisposable接口。

IDisposable接口

资源的确定性处理可以通过实现System.IDisposable接口来完成。这个接口有一个叫做Dispose()的方法,当一个对象不再被使用并且它的资源可以被处理时,用户可以显式调用这个方法。然而,你只应该在以下情况下实现这个接口:

  • 这个类拥有非托管资源

  • 这个类拥有托管资源,它们本身是可处理的

这个接口应该如何实现取决于这个类是否拥有非托管资源。当你既有托管资源又有非托管资源时,通常的模式如下:

public class MyResource : IDisposable
{
    private bool disposed = false;
    protected virtual void Dispose(bool disposing)
    {
        if (!disposed)
        {
            if (disposing)
            {
                // dispose managed objects
            }
            // free unmanaged resources
            // set large fields to null.
            disposed = true;
        }
    }
    ~MyResource()
    {
        Dispose(false);
    }
    public void Dispose()
    {
        Dispose(true);
        GC.SuppressFinalize(this);
    }
}

IDisposable接口的Dispose()方法中,我们调用一个受保护的虚拟方法,方法名相同(尽管可以有任何名字),并且有一个参数指定对象正在被销毁。为了确保资源的处理只发生一次,使用了一个布尔字段(这里叫做disposed)。重载的Dispose()方法的布尔参数指示这个方法是由用户以确定性方式调用的,还是由垃圾收集器在对象终结时以非确定性方式调用的。

在前一种情况下,托管和非托管资源都应该被处理,并且对象的终结应该被抑制,通过调用GC.SupressFinalize()。在后一种情况下,只有非托管资源必须被处理,因为处理不是由用户调用的,而是由垃圾收集器调用的。这个函数是虚拟的和受保护的原因是,派生类应该能够重写它,但不应该能够直接从类外部调用它。

让我们看看如何为不同的情况实现这个。首先,我们将考虑这样一个情况,即类只有可处理的托管资源。在下面的例子中,Engine类实现了IDisposable。它具体做什么,管理什么资源,以及如何处理它们并不重要。然而,Car类拥有对Engine对象的拥有引用,这个引用应该在Car对象被销毁时立即销毁。此外,这应该以确定性的方式进行,当Car不再需要时。在这种情况下,Car类必须按照以下方式实现IDisposable接口:

public class Engine : IDisposable {}
public class Car : IDisposable
{
    private Engine engine;
    public Car(Engine e)
    {
        engine = e;
    }
    #region IDisposable Support
    private bool disposed = false;
    protected virtual void Dispose(bool disposing)
    {
        if (!disposed)
        {
            if (disposing)
            {
                engine?.Dispose();
            }
            disposed = true;
        }
    }
    public void Dispose()
    {
        Dispose(true);
    }
    #endregion
}

由于这个类没有终结器,重载的Dispose()方法在这里用处不大,代码可以进一步简化。然而,派生类可以重写它并处理更多的资源。

在前一节中,我们实现了一个叫做HandleWrapper的类,它有一个终结器来关闭它拥有的系统句柄。在下面的清单中,你可以看到这个类的修改版本,它实现了IDisposable接口:

public class HandleWrapper : IDisposable
{
    [DllImport("kernel32.dll", SetLastError = true)]
    static extern bool CloseHandle(IntPtr hHandle);
    public IntPtr Handle { get; private set; }
    public HandleWrapper(IntPtr ptr)
    {
        Handle = ptr;
    }
    private bool disposed = false; // To detect redundant calls
    protected virtual void Dispose(bool disposing)
    {
        if (!disposed)
        {
            if (disposing)
            {
                // nothing to dispose
            }
            if (Handle != default)
                CloseHandle(Handle);
            disposed = true;
        }
    }
    ~HandleWrapper()
    {
        Dispose(false);
    }
    public void Dispose()
    {
        Dispose(true);
        GC.SuppressFinalize(this);
    }
}

这个类既有一个Dispose()方法(可以被用户调用),又有一个终结器(在用户没有调用Dispose()方法的情况下,由垃圾收集器调用)。在这个例子中没有托管资源需要释放,所以重载的Dispose()方法的布尔参数基本上是没有用的。

语言为我们提供了一种自动处理实现IDisposable接口的对象的方式,当它们不再需要时。我们将在下一节中了解这个。

using 语句

在我们介绍using语句之前,让我们看看如何以正确的方式进行显式资源管理。这将帮助你更好地理解using语句的需要和工作原理。

我们在前一节中看到的Car类可以这样使用:

Car car = null;
try
{
    car = new Car(new Engine());
    // use the car here
}
finally
{
    car?.Dispose();
}

应该使用try-catch-finally块(尽管这里没有明确显示catch)来确保在不再需要对象时正确处理对象。然而,C#语言提供了一个方便的语法来确保使用using语句正确处理对象的释放。它的形式如下:

using (ResourceType resource = expression) statement

编译器将其转换为以下代码:

{
    ResourceType resource = expression;
    try {
        statement;
    }
    finally {
        resource.Dispose();
    }
}

using语句引入了一个变量的作用域,并确保在退出作用域之前正确处理对象。实际的处理取决于资源是值类型、可空值类型、引用类型还是动态类型。之前对resource.Dispose()的调用实际上是以下之一:

// value types
((IDisposable)resource).Dispose();
// nullable value types or reference types
if (resource != null) 
    ((IDisposable)resource).Dispose();
// dynamic
if (((IDisposable)resource) != null) 
    ((IDisposable)resource).Dispose();

对于汽车示例,我们可以如下使用它:

using (Car car = new Car(new Engine()))
{
    // use the car here
}

多个对象可以实例化到同一个using语句中,如下例所示:

using (Car car1 = new Car(new Engine()),
           car2 = new Car(new Engine()))
{
    // use car1 and car2 here
}

另一方面,多个using语句可以链接在一起,如下所示,这等效于前面的代码:

using (var car1 = new Car(new Engine()))
using (var car2 = new Car(new Engine()))
{
    // use car1 and car2 here
}

在 C# 8 中,using语句可以写成如下形式:

using Car car = new Car(new Engine());
// use the car here

有关更多信息,请参阅第十五章C# 8 的新功能

平台调用

在本章的早些时候,我们实现了一个句柄包装类,该类使用 Windows API 函数CloseHandle()在对象被处理时删除系统句柄。C#程序可以调用 Windows API,也可以调用从本机动态链接库(DLL)导出的任何函数,都是通过平台调用服务,也称为平台调用P/Invoke

P/Invoke 定位并调用导出的函数,并在托管和非托管边界之间进行参数传递。为了能够使用 P/Invoke 调用函数,您必须知道函数的名称和签名,以及它所在的 DLL 的名称。然后,您必须创建非托管函数的托管定义。为了理解这是如何工作的,我们将看一个user32.dll中可用的MessageBox()函数的示例。函数签名如下:

int MessageBox(HWND hWnd, LPCTSTR lpText,
               LPCTSTR lpCaption, UINT uType);

我们可以为函数创建以下托管定义:

static class WindowsAPI
{
    [DllImport("user32.dll")]
    public static extern int MessageBox(IntPtr hWnd, 
                                        string lpText, 
                                        string lpCaption, 
                                        uint uType);
}

这里有几件事情需要注意:

  • 托管定义的签名必须与本机定义匹配,使用等效的托管类型作为参数。

  • 函数必须定义为staticextern

  • 函数必须用DllImportAttribute修饰。此属性为运行时调用本机函数定义了必要的信息。

DllImportAttribute至少需要指定从中导出本机函数的 DLL 的名称。您可以省略 DLL 中入口点的名称,此时将使用托管函数的名称来标识它。但是,您也可以使用属性的EntryPoint显式指定它。您可以指定的其他属性如下:

  • BestFitMapping:一个布尔标志,指示是否启用最佳匹配映射。在从 Unicode 到 ANSI 字符的转换时使用。最佳匹配映射使得互操作编组器在不存在精确匹配时使用最接近的字符(例如,版权字符被替换为c)。

  • CallingConvention:入口点的调用约定。默认值为Winapi,默认为StdCall

  • CharSet:指定字符串参数的编组行为。它还用于指定要调用的入口点名称。例如,对于消息框示例,Windows 实际上有两个函数—MessageBoxA()MessageBoxW()CharSet参数的值使运行时能够在其中选择一个;更准确地说,以CharSet.Ansi结尾的名称用于CharSet.Ansi(这是 C#的默认值),以CharSet.Unicode结尾的名称用于CharSet.Unicode

  • EntryPoint:入口点名称或序数。

  • ExactSpelling:指示CharSet字段是否确定 CLR 搜索非托管 DLL 以查找除已指定的之外的入口点名称。

  • PreserveSig:一个布尔标志,指示HRESULTretval值是直接翻译(如果为true)还是自动转换为异常(如果为false)。默认值为true`。

  • SetLastError:如果为true,则表示被调用者在返回之前调用SetLastError()。在这种情况下,CLR 调用GetLastError()并缓存该值,以防止被其他 Windows API 调用覆盖和丢失。要检索该值,可以调用Marshal.GetLastWin32Error()

  • ThrowOnUnmappableChar:指示(当为true时)编组器在将 Unicode 字符转换为 ANSI '?'时是否应抛出错误。默认值为false

以下表格显示了 Windows API 和 C 风格函数中的数据类型,以及它们对应的 C#或.NET Framework 类型:

重要提示

[1] 在string参数上使用CharSet.Ansi修饰或使用[MarshalAs(UnmanagedType.LPStr)]属性。

[2] 在string参数上使用CharSet.Unicode修饰或使用[MarshalAs(UnmanagedType.LPWStr)]属性。

为了能够正确调用我们之前定义的MessageBox()函数,我们还应该为可能的参数和返回值定义常量。下面是一个片段:

static class WindowsAPI
{
    public static class MessageButtons
    {
        public const int MB_OK = 0;
        public const int MB_OKCANCEL = 1;
        public const int MB_YESNOCANCEL = 3;
        public const int MB_YESNO = 4; 
    }

    public static class MessageIcons
    {
        public const int MB_ICONERROR = 0x10;
        public const int MB_ICONQUESTION = 0x20;
        public const int MB_ICONWARNING = 0x30;
        public const int MB_ICONINFORMATION = 0x40;
    }

    public static class MessageResult
    {
        public const int IDOK = 1;
        public const int IDYES = 6;
        public const int IDNO = 7;
    }
}

设置好这一切后,我们可以调用MessageBox()函数,如下所示:

class Program
{
    static void Main(string[] args)
    {
        var result = WindowsAPI.MessageBox(
            IntPtr.Zero, 
            "Is this book helpful?",
            "Question",
            WindowsAPI.MessageButtons.MB_YESNO | 
            WindowsAPI.MessageIcons.MB_ICONQUESTION);

        if(result == WindowsAPI.MessageResult.IDYES)
        {
            // time to learn more
        }
    }
}

许多 Windows API 需要使用缓冲区来返回数据。例如,advapi32.dll中的GetUserName()函数返回与当前执行线程关联的用户的名称。函数签名如下:

BOOL GetUserName(LPSTR lpBuffer, LPDWORD pcbBuffer);

第一个参数是一个指向字符数组的指针,用于接收用户的名称,而第二个参数是一个指向无符号整数的指针,用于指定缓冲区的大小。缓冲区需要足够大以接收用户名。否则,函数将返回false,在pcbBuffer参数中设置所需的大小,并将最后的错误设置为ERROR_INSUFFICIENT_BUFFER

虽然您可以分配一个足够大的缓冲区来容纳结果(一些函数对返回值的大小施加限制),但您并不总是能确定。因此,通常,您会调用这样的函数两次:

  • 首先,使用一个空缓冲区来获取实际所需的缓冲区大小

  • 然后,分配必要的内存后,再次调用,使用足够大的缓冲区来接收结果

为了看到这是如何工作的,我们将 P/InvokeGetUserName()函数,其托管定义如下:

[DllImport("advapi32.dll", SetLastError = true,
           CharSet = CharSet.Unicode)]
public static extern bool GetUserName(StringBuilder lpBuffer,
                                      ref uint nSize);

请注意,我们在缓冲区参数中使用StringBuilder。虽然这可以增长到任何容量,但我们需要知道要指定的大小。而不是指定一个随机的大尺寸,我们调用函数两次,如下所示:

uint size = 0;
var result = WindowsAPI.GetUserName(null, ref size);
if(!result &&
   Marshal.GetLastWin32Error() ==
       WindowsAPI.ErrorCodes.ERROR_INSUFFICIENT_BUFFER)
{
    Console.WriteLine($"Requires buffer size: {size}");
    StringBuilder buffer = new StringBuilder((int)size);
    result = WindowsAPI.GetUserName(buffer, ref size);
    if(result)
    {
        Console.WriteLine($"User name: {buffer.ToString()}");
    }
}

在这个例子中,StringBuffer对象是用初始容量创建的,尽管这并不是真正必要的。您不必指定其容量;它将增长到所需的容量并接收正确的结果。

让我们总结一下平台调用服务,使用以下几点:

  • 允许调用从本地 DLL 导出的函数。

  • 您必须为函数创建一个托管定义,具有相同的签名和本机类型的等效托管类型。

  • 在定义托管函数时,您必须至少指定函数入口点和导出 DLL 的名称。

使用 P/Invoke 时存在一些缺点,因此您应该牢记以下几点:

  • 如果您使用 P/Invoke 调用 Windows API 中的函数,则您的应用程序将仅在 Windows 上运行。如果您不打算使其跨平台,这不是问题。否则,您必须完全避免这种情况。

  • 如果您需要调用 C库中的函数,您必须在导入声明中指定装饰名称,这可能会很麻烦。如果您还要编写 C库,可以导出具有extern "C"链接的函数,以防止链接器对名称进行装饰。

  • 在托管类型和非托管类型之间进行编组会有一些轻微的开销。

  • 有时这可能不太直观;例如,指针和句柄使用什么类型。

在本章的最后一节中,我们将讨论不安全的代码和指针类型,这是 C#中的第三类类型。

不安全的代码

当我们讨论.NET Framework 和 C#语言支持的类型时,我们指的是值类型(结构)和引用类型(类)。然而,还有一种类型得到了支持,那就是指针类型。如果你不熟悉 C 或 C++编程语言,特别是指针,那么你应该知道指针就像引用——它们是包含对象地址的存储位置。引用基本上是由 CLR 管理的安全指针

要使用指针类型,你必须建立所谓的不安全上下文。在 CLR 术语中,这被称为不可验证的代码,因为 CLR 无法验证其安全性。不安全的代码不一定是危险的,但你完全有责任确保你不会引入指针错误或安全风险。

事实上,在 C#中,有很少的情况下你实际上需要在不安全的上下文中使用指针。有两种常见的情况可能会出现这种情况:

  • 调用从本机 DLL 或 COM 服务器导出的需要指针类型作为参数的函数。然而,在大多数情况下,你仍然可以使用System.IntPtrSystem.Runtime.InteropServices.Marshal类型的成员来使用安全代码。

  • 优化特定算法,性能至关重要。

你可以使用unsafe关键字定义不安全的上下文。这可以应用于以下情况:

  • 类型(类、结构、接口、委托),在这种情况下,整个类型的文本上下文被视为不安全:
unsafe struct Node
{
    public int value;
    public Node* left;
    public Node* right;
}
  • 方法、字段、属性、事件、索引器、运算符、实例和静态构造函数以及析构函数,在这种情况下,成员的整个文本上下文被视为不安全:
struct Node
{
    public int Value;
    public unsafe Node* Left;
    public unsafe Node* Right;
}
unsafe void Increment(int* value)
{
    *value += 1;
}
  • 一个语句(块),在这种情况下,整个块的文本上下文被视为不安全:
static void Main(string[] args)
{
    int value = 42;
    unsafe
    {
        int* p = &value;
        *p += 1;
    }
    Console.WriteLine(value); // prints 43
 }

然而,为了能够编译使用不安全上下文的代码,你必须显式地使用/unsafe编译器开关。在 Visual Studio 中,你可以在项目属性 | 构建下的常规部分中勾选允许不安全代码选项,如下截图所示:

图 9.3 – Visual Studio 的项目属性页面,允许启用不安全代码选项

图 9.3 – Visual Studio 的项目属性页面,允许启用不安全代码选项

不安全的代码只能从另一个不安全的上下文中执行。例如,如果你有一个声明为unsafe的方法,你只能从不安全的上下文中调用它。这在下面的例子中得到了展示,其中不安全的Increment()方法(之前介绍过)从一个unsafe上下文中被调用。在安全的上下文中尝试这样做会导致编译错误:

static void Main(string[] args)
{
    int value = 42;
    Increment(&value);     // error
    unsafe
    {
        Increment(&value); // OK
    }
 }

如果你熟悉 C 或 C++,你会知道指针符号(*)可以放在类型旁边、变量旁边或者中间。在 C/C++中,以下都是等价的:

int* a;
int * a;
int *a;
int* a, *b; // define two variables of type pointer to int

然而,在 C#中,你总是在类型旁边放上*,就像下面的例子一样:

int* a, b; // define two variables of type pointer to int

变量可以是两种类型——固定的可移动的。可移动的变量驻留在由垃圾收集器控制的存储位置中,因此可以移动或收集。固定的变量驻留在不受垃圾收集器操作影响的存储位置中。

在不安全的代码中,你可以使用&运算符无限制地获取固定变量的地址。然而,你只能使用固定语句来处理可移动变量。固定语句是用fixed关键字引入的,在许多方面类似于using语句。

以下是使用固定语句的一个例子:

class Color
{
    public byte Alpha;
    public byte Red;
    public byte Green;
    public byte Blue;
    public Color(byte a, byte r, byte g, byte b)
    {
        Alpha = a;
        Red = r;
        Green = g;
        Blue = b;
    }
}
static void SetTransparency(Color color, double value)
{
    unsafe
    {
        fixed (byte* alpha = &color.Alpha)
        {
            *alpha = (byte)(value * 255);
        }
    }
}

SetTransparency()函数使用指向Alpha字段的指针来更改Color对象的 alpha 值。尽管这是值类型的byte类型,但它位于托管堆上,因为它是引用类型的一部分。垃圾回收器可能会在访问Alpha字段之前移动或收集Color对象。因此,检索其地址的唯一可能方法是使用fixed语句。这基本上固定了托管对象,以便垃圾回收器不会移动或收集它。

除了usafefixed,还有两个关键字可以在不安全的上下文中使用:

  • stackalloc用于声明在调用堆栈上分配内存的变量(类似于 C 中的_alloca()):
static unsafe void AllocArrayExample(int size)
{
    int* arr = stackalloc int[size];
    for (int i = 1; i <= size; ++i)
    arr[i] = i;
}
  • sizeof用于获取值类型的字节大小。对于原始类型和枚举类型,sizeof运算符实际上也可以在安全的上下文中调用:
static void SizeOfExample()
{
    unsafe
    {
        Console.WriteLine(
          $"Pointer size: {sizeof(int*)}");
    }
}

让我们通过查看以下关键点来总结不安全代码:

  • 它只能在不安全的上下文中执行,使用unsafe关键字在使用/unsafe开关编译时引入。

  • 类型、成员和代码块可以是不安全的上下文。

  • 它引入了安全性和稳定性风险,你需要对此负责。

  • 只有极少数情况下需要使用它。

总结

本章重点介绍了运行时(通过垃圾回收器)如何管理对象和资源的生命周期。我们学习了垃圾回收器的工作原理,以及如何编写终结器来处理本机资源。我们已经看到了如何正确实现IDisposable接口和using语句的模式,以确定性地释放对象。我们还研究了平台调用服务,它使我们能够从托管代码中进行本机调用,以及编写不安全的代码——这是 CLR 无法验证安全性的代码。

在本书的下一章中,我们将研究不同的编程范式,函数式编程,并了解它在 C#中的关键概念以及它们能够让我们做什么。

测试你学到的东西

  1. 栈和堆是什么?每个上面分配了什么?

  2. 堆的内存段是什么,每个上面分配了什么?

  3. 垃圾回收是如何工作的?

  4. 终结器是什么?处理和终结之间有什么区别?

  5. GC.SupressFinalize()方法是做什么的?

  6. IDisposable是什么,何时应该使用它?

  7. using语句是什么?

  8. 你如何在 C#中从本机 DLL 调用函数?

  9. 不安全代码是什么,它通常在哪些场景中使用?

  10. 你可以声明哪些程序元素为不安全?

进一步阅读

第十章:Lambda、LINQ 和函数式编程

尽管 C#在其核心是一种面向对象的编程语言,但它实际上是一种多范式语言。到目前为止,在本书中,我们已经讨论了命令式编程、面向对象编程和泛型编程。然而,C#也支持函数式编程特性。在第七章集合第八章高级主题中,我们已经使用了其中一些,比如 lambda 和语言集成查询(LINQ)

在本章中,我们将从功能编程的角度详细讨论这些内容。学习函数式编程技术将帮助您以声明性的方式编写代码,通常比等效的命令式代码更简单、更容易理解。

本章将涵盖以下主题:

  • 函数式编程

  • 函数作为一等公民

  • Lambda 表达式

  • LINQ

  • 更多函数式编程概念

通过本章的学习,您将能够详细了解 lambda 表达式,并能够与 LINQ 一起查询各种来源的数据。此外,您将熟悉函数式编程的概念和技术,如高阶函数、闭包、单子和幺半群。

让我们从功能编程及其核心原则的概述开始这一章。

函数式编程

C#是一种通用的多范式编程语言。然而,到目前为止,在本书中,我们只涵盖了命令式编程范式,它使用语句来改变程序状态,并且专注于描述程序的操作方式。在命令式编程中,函数可能具有副作用,因此在执行时改变程序状态。或者,函数的执行可能取决于程序状态。

相反的范式是函数式编程,它关注描述程序做什么而不是如何做。函数式编程将计算视为函数的评估;它使用不可变数据并避免改变状态。函数式编程是一种声明性的编程范式,其中使用表达式而不是语句。函数不再具有副作用,而是幂等的。这意味着使用相同参数调用函数每次都会产生相同的结果。

函数式编程提供了几个优势,包括以下内容:

  • 由于函数不改变状态,只依赖于它们接收的参数,代码更容易理解和维护。

  • 由于数据是不可变的,函数没有副作用,因此更容易测试代码。

  • 由于数据是不可变的,函数没有副作用,实现并发更简单高效,这避免了数据竞争。

Rectangle(这也可以是一个类)代表一个矩形:

struct Rectangle
{
    public int Left;
    public int Right;
    public int Top;
    public int Bottom;
    public int Width { get { return Right - Left; } }
    public int Height { get { return Bottom - Top; } }
    public Rectangle(int l, int t, int r, int b)
    {
        Left = l;
        Top = t;
        Right = r;
        Bottom = b;
    }
}

我们可以实例化这种类型并改变它的属性。例如,如果我们想要将矩形的宽度增加 10 个单位,每个方向都相等,我们可以这样做:

var r = new Rectangle(10, 10, 30, 20);
r.Left -= 5;
r.Right += 5;
r.Top -= 5;
r.Bottom += 5;

我们还可以编写一个我们可以调用的函数。这可以是一个成员函数,如下所示:

public void Inflate(int l, int t, int r, int b)
{
    Left -= l;
    Right += r;
    Top -= t;
    Bottom += b;
}
// invoked as
r.Inflate(5, 0, 5, 0);

这也可以是一个非成员函数,如下面的代码所示。两者之间的区别只是设计上的问题。如果我们无法修改源代码,将其编写为扩展方法是唯一的选择:

static void Inflate(ref Rectangle rect, 
                    int l, int t, int r, int b)
{
    rect.Left -= l;
    rect.Right += r;
    rect.Top -= t;
    rect.Bottom += b;
}
// invoked as
Inflate(ref r, 5, 0, 5, 0);

Rectangle数据类型是可变的,因为它的状态可以改变。Inflate()方法具有副作用,因为它改变了矩形的状态。在函数式编程中,Rectangle应该是不可变的。可能的实现如下所示:

struct Rectangle
{
    public readonly int Left;
    public readonly int Right;
    public readonly int Top;
    public readonly int Bottom;
    public int Width { get { return Right - Left; } }
    public int Height { get { return Bottom - Top; } }
    public Rectangle(int l, int t, int r, int b)
    {
        Left = l;
        Top = t;
        Right = r;
        Bottom = b;
    }
}

Inflate()方法的纯函数版本不会产生副作用。它的行为仅取决于参数,结果将是相同的,无论调用多少次具有相同参数。这样的实现示例如下:

static Rectangle Inflate(Rectangle rect, 
                         int l, int t, int r, int b)
{
    return new Rectangle(rect.Left - l, rect.Top - t,
                         rect.Right + r, rect.Bottom + b);
}

现在可以像下面的例子一样使用它们:

var r = new Rectangle(10, 10, 30, 20);
r = Inflate(r, 5, 0, 5, 0);

函数式编程源自λ演算(由阿隆佐·邱奇开发),它是一个基于函数抽象和应用的计算表达的框架或数学系统,使用变量绑定和替换。一些编程语言,比如 Haskell,是纯函数式的。其他的,比如 C#,支持多种范式,不是纯函数式的。

前面的例子展示了一个变量r,它被初始化为一个值,然后被改变。在纯函数式编程中,这是不可能的。一旦初始化,变量就不能改变值;而是必须分配一个新的变量。这使得表达式可以被它们的值替换,这是引用透明性的一个特性。

C#使我们能够使用函数式编程的概念和习语来编写代码。所有这些的核心都是 lambda 表达式,我们将很快深入研究。在那之前,我们需要探索另一个函数式编程的支柱,那就是将函数视为一等公民

函数作为一等公民

第八章《高级主题》中,我们学习了关于委托和事件。委托看起来像一个函数,但它是一种保存与委托定义匹配的函数引用的类型。委托实例可以作为函数参数传递。让我们看一个例子,其中有一个委托接受两个int参数并返回一个int值:

public delegate int Combine(int a, int b);

然后我们有不同的函数,比如Add(),它可以将两个整数相加并返回和,Sub(),它可以将两个整数相减并返回差,或者Mul(),它可以将两个整数相乘并返回积。它们的签名与委托匹配,因此Combine委托的实例可以保存对所有这些函数的引用。这些函数如下所示:

class Math
{
    public static int Add(int a, int b) { return a + b; }
    public static int Sub(int a, int b) { return a - b; }
    public static int Mul(int a, int b) { return a * b; }
}

我们可以编写一个通用函数,可以将其中一个函数应用于两个参数。这样的函数可能如下所示:

int Apply(int a, int b, Combine f)
{
    return f(a, b);
}

调用它很简单——我们传递参数和我们想要调用的实际函数的引用:

var s = Apply(2, 3, Math.Add);
var d = Apply(2, 3, Math.Sub);
var p = Apply(2, 3, Math.Mul);

为了方便,.NET 定义了一组名为Func的通用委托,以避免一直定义自己的委托。这些定义在System命名空间中,如下所示:

public delegate TResult Func<out TResult>();
public delegate TResult Func<in T,out TResult>(T arg);
public delegate TResult Func<in T1,in T2,out TResult>(T1 arg1, T2 arg2);
...
public delegate TResult Func<in T1,in T2,in T3,in T4,in T5,in T6,in T7,in T8,in T9,in T10,in T11,in T12,in T13,in T14,in T15,in T16,out TResult>(T1 arg1, T2 arg2, T3 arg3, T4 arg4, T5 arg5, T6 arg6, T7 arg7, T8 arg8, T9 arg9, T10 arg10, T11 arg11, T12 arg12, T13 arg13, T14 arg14, T15 arg15, T16 arg16);

这是一组有 17 个重载的函数,可以接受 0、1 或多达 16 个参数(可能是不同类型的),并返回一个值。使用这些系统委托,我们可以将Apply函数重写如下:

T Apply<T>(T a, T b, Func<T, T, T> f)
{
    return f(a, b);
}

这个版本的函数是通用的,因此它可以用其他类型的参数来调用,而不仅仅是整数。在前面的例子中调用函数的方式并没有改变。

这些委托返回一个值,因此不能用于没有返回值的函数。在System命名空间中有一组类似的重载,称为Action,定义如下:

public delegate void Action();
public delegate void Action<in T>(T obj);
public delegate void Action<in T1,in T2>(T1 arg1, T2 arg2);
...
public delegate void Action<in T1,in T2,in T3,in T4,in T5,in T6,in T7,in T8,in T9,in T10,in T11,in T12,in T13,in T14,in T15,in T16>(T1 arg1, T2 arg2, T3 arg3, T4 arg4, T5 arg5, T6 arg6, T7 arg7, T8 arg8, T9 arg9, T10 arg10, T11 arg11, T12 arg12, T13 arg13, T14 arg14, T15 arg15, T16 arg16);

这些委托与我们之前看到的Func委托认非常相似。唯一的区别是它们不返回值。仍然有 17 个重载,可以接受 0、1 或多达 16 个输入参数。

在下面的例子中,Apply函数被重载,以便它还接受Action<string>类型的参数,这是一个具有string类型的单个参数并且不返回任何值的函数。在应用函数之后,但在返回结果之前,将调用此操作,并传递描述实际操作的字符串:

T Apply<T>(T a, T b, Func<T, T, T> f, Action<string> log)
{
    var r = f(a, b);
    log?.Invoke($"{f.Method.Name}({a},{b}) = {r}");
    return r;
}

我们可以通过将Console.WriteLine作为最后一个参数传递来调用这个新的重载,这样操作就会被记录到控制台上:

var s = Apply(2, 3, Math.Add, Console.WriteLine);
var p = Apply(2, 3, Math.Mul, Console.WriteLine);

Apply函数被称为高阶函数。高阶函数是一个接受一个或多个函数作为参数、返回一个函数或两者都有的函数。其他所有的函数都被称为一阶函数

有许多高阶函数可能会在没有意识到的情况下使用。例如,List<T>.Sort (Comparison<T> comparison)就是这样一个函数。LINQ 中的大多数查询谓词(我们将在本章的LINQ部分中探讨)都是高阶函数。

高阶函数的一个例子是返回另一个函数的函数,如下面的代码片段所示。ApplyReverse()接受一个函数作为参数,并返回另一个函数,该函数以两个参数调用参数函数,但顺序相反:

Func<T, T, T> ApplyReverse<T>(Func<T, T, T> f)
{
    return delegate(T a, T b) { return f(b, a); };
}

这个函数被调用如下:

var s = ApplyReverse<int>(Math.Add)(2, 3);
var d = ApplyReverse<int>(Math.Sub)(2, 3);

到目前为止,我们所看到的是在 C#中将函数作为参数传递,从函数中返回函数,将函数分配给变量,将它们存储在数据结构中,或者定义匿名函数(即没有名称的函数)的可能性。还可以嵌套函数并测试函数的引用是否相等。一个能做到这些的编程语言被称为将函数视为一等公民,并且它的函数是一等公民。因此,C#就是这样一种语言。

回到之前的例子,调用Apply()方法的另一种更简单的方法如下:

var s = Apply(2, 3, (a, b) => a + b);
var d = Apply(2, 3, (a, b) => a - b);
var p = Apply(2, 3, (a, b) => a * b);

在这里,Math类的方法已被替换为诸如(a, b) => a + b这样的 lambda 表达式。我们甚至可以将Apply()函数定义为 lambda 表达式并相应地调用它:

Func<int, int, Func<int, int, int>, int> apply = 
   (a, b, f) => f(a, b);
var s = apply(2, 3, (a, b) => a + b);
var d = apply(2, 3, (a, b) => a - b);
var p = apply(2, 3, (a, b) => a * b);

我们将在下一节深入研究 lambda 表达式。

Lambda 表达式

Lambda 表达式是一种方便的写匿名函数的方式。它们是一段代码,可以是一个表达式或一个或多个语句,表现得像一个函数,并且可以被分配给一个委托。因此,lambda 表达式可以作为参数传递给函数或从函数中返回。它们是编写 LINQ 查询、将函数传递给高阶函数(包括应该由Task.Run()异步执行的代码)以及创建表达式树的一种方便方式。

表达式树是一种以树状数据结构表示代码的方式,其中节点是表达式(如方法调用或二进制操作)。这些表达式树可以被编译和执行,从而使可执行代码能够进行动态更改。表达式树用于实现各种数据源的 LINQ 提供程序以及 DLR 中的.NET Framework 和动态语言之间的互操作性。

让我们从一个简单的例子开始,我们有一个整数列表,我们想要从中删除所有的奇数。可以写成如下形式(注意IsOdd()函数可以是类方法,也可以是本地函数):

bool IsOdd(int n) { return n % 2 == 1; }
var list = new List<int>() { 1, 2, 3, 4, 5, 6, 7, 8, 9 };
list.RemoveAll(IsOdd);

这段代码实际上可以用匿名方法来简化,允许我们将代码传递给委托,而无需定义单独的IsOdd()函数:

var list = new List<int>() { 1, 2, 3, 4, 5, 6, 7, 8, 9 };
list.RemoveAll(delegate (int n) { return n % 2 == 1; });

Lambda 表达式允许我们使用更简单的语法进一步简化代码,编译器将其转换为类似于前面代码的内容:

var list = new List<int>() { 1, 2, 3, 4, 5, 6, 7, 8, 9 };
list.RemoveAll(n => n % 2 == 1);

我们在这里看到的 lambda 表达式(n => n % 2 == 1)有两部分,由=>分隔,这是lambda 声明运算符

  • 表达式的左部是参数列表(如果有多个参数,则用逗号分隔并括在括号中)。

  • 表达式的右部要么是表达式,要么是语句。如果右部是表达式(就像前面的例子中),lambda 被称为表达式 lambda。如果右部是一个语句,lambda 被称为语句 lambda

语句总是用大括号{}括起来。任何表达式 lambda 实际上都可以写成一个语句 lambda。表达式 lambda 是语句 lambda 的简化版本。前面的例子使用表达式 lambda 可以写成以下形式的语句 lambda:

var list = new List<int>() { 1, 2, 3, 4, 5, 6, 7, 8, 9 };
list.RemoveAll(n => { return n % 2 == 1; });

有几个 lambda 表达式的例子:

lambda 没有自己的类型。相反,它的类型要么是分配给它的委托的类型,要么是当 lambda 用于构建表达式树时的System.Expression类型。不返回值的 lambda 对应于System.Action委托(并且可以分配给一个)。返回值的 lambda 对应于System.Func委托。

当你写一个 lambda 表达式时,你不需要写参数的类型,因为这些类型是由编译器推断的。类型推断的规则如下:

  • lambda 必须具有与其分配的委托相同数量的参数。

  • lambda 的每个参数必须隐式转换为它所分配的委托的对应参数。

  • 如果 lambda 有返回值,它的类型必须隐式转换为它所分配的委托的返回类型。

Lambda 表达式可以是异步的。这样的 lambda 前面要加上async关键字,并且必须包含至少一个await表达式。下面的例子展示了一个 Windows Forms 表单上按钮的Click事件的异步处理程序:

public partial class MyForm : Form
{
    public MyForm()
    {
        InitializeComponent();

        myButton.Click += async (sender, e) =>
        {
            await ExampleMethodAsync();
        };
    }
    private async Task ExampleMethodAsync()
    {
        // a time-consuming action
        await Task.Delay(1000);
    }
}

在这个例子中,MyForm是一个表单类,在它的构造函数中,我们注册了一个Click事件的处理程序。这是使用 lambda 表达式完成的,但 lambda 是异步的(它调用一个异步函数),因此需要在前面加上async

lambda 可以使用在方法或包含 lambda 表达式的类型范围内的变量。当变量在 lambda 中使用时,它被捕获,以便即使超出范围也可以使用。这些变量在 lambda 中使用之前必须被明确赋值。在下面的例子中,lambda 表达式捕获了两个变量——value函数参数和Data类成员:

class Foo
{
    public int Data { get; private set; }
    public Foo(int value)
    {
        Data = value;
    }
    public void Scramble(int value, int iterations)
    {
        Func<int, int> apply = (i) => Data ^ i + value;
        for(int i = 0; i < iterations; ++i)
           Data = apply(i);
    }
}

以下是 lambda 表达式中变量作用域的规则:

  • lambda 表达式中引入的变量在 lambda 之外是不可见的(例如,在封闭方法中)。

  • lambda 不能捕获封闭方法的inrefout参数。

  • 被 lambda 表达式捕获的变量不会被垃圾回收,即使它们本来会超出范围,直到 lambda 分配的委托被垃圾回收。

  • lambda 表达式的返回语句仅指代 lambda 所代表的匿名方法,并不会导致封闭方法返回。

lambda 表达式最常见的用例是编写 LINQ 查询表达式。我们将在下一节中看到这一点。

LINQ

LINQ 是一组技术,使开发人员能够以一致的方式查询多种数据源。通常,您会使用不同的语言和技术来查询不同类型的数据,比如关系数据库使用 SQL,XML 使用 XPath。SQL 查询是以字符串形式编写的,这使得它们无法在编译时进行验证,并增加了运行时错误的可能性。

LINQ 定义了一组操作符和用于查询数据的内置语言语法。LINQ 查询是强类型的,因此在编译时进行验证。LINQ 还提供了一个框架,用于构建自己的 LINQ 提供程序,这些提供程序是将查询转换为特定于特定数据源的 API 的组件。该框架提供了对查询对象(.NET 中的任何集合)、关系数据库和 XML 的内置支持。第三方已经为许多数据源编写了 LINQ 提供程序,比如 Web 服务。

LINQ 使开发人员能够专注于要做什么,而不太关心如何做。为了更好地理解这是如何工作的,让我们看一个例子,我们有一个整数数组,我们想找到所有奇数的和。通常,您会写类似以下的内容:

int[] arr = { 1, 1, 3, 5, 8, 13, 21, 34};
int sum = 0;
for(int i = 0; i < arr.Length; ++i)
{
    if (arr[i] % 2 == 1)
    sum += arr[i];
}

使用 LINQ,可以将所有这些冗长的代码简化为以下一行:

int sum = arr.Where(x => x % 2 == 1).Sum();

在这里,我们使用了 LINQ 标准查询操作符,它们是作用于序列的扩展方法,提供了包括过滤、投影、聚合、排序等在内的查询功能。然而,许多这些查询操作符在 LINQ 查询语法中都有直接的支持,这是一种非常类似于 SQL 的查询语言。使用查询语言,解决问题的方案可以写成如下形式:

int sum = (from x in arr
           where x % 2 == 1
           select x).Sum();

正如你在这个例子中所看到的,不是每个查询操作符都有查询语法中的等价物。Sum()和所有其他聚合操作符都没有等价物。在接下来的章节中,我们将更详细地研究这两种 LINQ 的用法。

标准查询操作符

LINQ 标准查询操作符是一组作用于实现IEnumerable<T>IQueryable<T>的序列的扩展方法。前者导出一个允许对序列进行迭代的枚举器。后者是一个特定于 LINQ 的接口,它继承自IEnumerable<T>并为我们提供了对特定数据源进行查询的功能。标准查询操作符被定义为作用于EnumerableQueryable类的扩展方法,具体取决于它们操作的序列的类型。作为扩展方法,它们可以使用静态方法语法或实例方法语法进行调用。

大多数查询操作符可能返回多个值。这些方法返回IEnumerable<T>IQueryable<T>,这使得它们可以链接在一起。它们返回的可枚举对象上的实际查询在迭代时被推迟到数据源上。另一方面,返回单个值的标准查询操作符(如Sum()Count())不推迟执行并立即执行。

以下表格包含了所有 LINQ 标准查询操作符的名称:

标准查询操作符的数量很大。讨论它们中的每一个超出了本书的范围。你应该阅读官方文档或其他资源,以熟悉它们所有。

为了更加熟悉 LINQ,我们将看几个例子。在第一个例子中,我们想要计算句子中的单词数量。我们将句子以句号(.)、逗号(,)和空格作为分隔符。我们将字符串分割成部分,然后过滤掉所有非空的部分并计数它们。使用 LINQ,这就像下面这样简单:

var text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.";
var count = text.Split(new char[] { ' ', ',', '.' })
                .Where(w => !string.IsNullOrEmpty(w))
                .Count();

然而,如果我们想要根据它们的长度对所有单词进行分组并将它们打印到控制台上,问题就变得有点复杂了。我们需要以单词长度为键创建分组,以单词本身为元素,过滤掉长度为零的分组,并根据单词长度按升序排序剩下的部分:

var groups = text.Split(new char[] { ' ', ',', '.' })
                 .GroupBy(w => w.Length, w => w.ToLower())
                 .Select(g => new { Length =g.Key, Words = g })
                 .Where(g => g.Length > 0)
                 .OrderBy(g => g.Length);
foreach (var group in groups)
{
    Console.WriteLine($"Length={group.Length}");
    foreach (var word in group.Words)
    {
        Console.WriteLine($" {word}");
    }
}

前一个查询在调用Count()时执行,而这个查询的执行被推迟到我们实际迭代它时。

到目前为止,我们看到的例子并不是太复杂。然而,使用 LINQ,你可以构建更复杂的查询。为了说明这一点,让我们考虑一个处理客户订单的系统。该系统使用CustomerArticleOrderLineOrder等实体,这里以非常简化的形式显示:

class Customer
{
    public long Id { get; set; }
    public string FirstName { get; set; }
    public string LastName { get; set; }
    public string Email { get; set; }
}
class Article
{
    public long Id { get; set; }
    public string EAN13 { get; set; }
    public string Name { get; set; }
    public double Price { get; set; }
}
class OrderLine
{
    public long Id { get; set; }
    public long OrderId { get; set; }
    public long ArticleId { get; set; }
    public double Quantity { get; set; }
    public double Discount { get; set; }
}
class Order
{
    public long Id { get; set; }
    public DateTime Date { get; set; }
    public long CustomerId { get; set; }
    public double Discount { get; set; }
}

让我们也考虑一下我们有这些类型的序列,如下所示(为简单起见,每种类型只显示了一些记录,但你可以在本书附带的源代码中找到完整的例子):

var articles = new List<Article>()
{
     new Article(){ Id = 1, EAN13 = "5901234123457", 
                    Name = "paper", Price = 100.0},
     new Article(){ Id = 2, EAN13 = "5901234123466", 
                    Name = "pen", Price = 200.0},
     /* more */
};
var customers = new List<Customer>()
{
     new Customer() { Id = 101, FirstName = "John", 
               LastName = "Doe", Email = "john.doe@email.com"},
     new Customer() { Id = 102, FirstName = "Jane", 
               LastName = "Doe", Email = "jane.doe@email.com"},
     /* more */
};
var orders = new List<Order>()
{
     new Order() { Id = 1001, Date = new DateTime(2020, 3, 12),
                   CustomerId = customers[0].Id },
     new Order() { Id = 1002, Date = new DateTime(2020, 4, 23),
                   CustomerId = customers[1].Id },
     /* more */
};
var orderlines = new List<OrderLine>()
{
    new OrderLine(){ Id = 1, OrderId=orders[0].Id, 
                     ArticleId = articles[0].Id, Quantity=2},
    new OrderLine(){ Id = 2, OrderId=orders[0].Id, 
                     ArticleId = articles[1].Id, Quantity=1},
    /* more */
};

我们想要找到答案的问题是,*一个特定客户自从某一天以来购买的所有文章的名称是什么?*使用命令式方法编写这个问题可能会很麻烦,但是使用 LINQ,可以表达如下:

var query = 
    orders.Join(orderlines,
                o => o.Id,
                ol => ol.OrderId,
                (o, ol) => new { Order = o, Line = ol })
          .Join(customers,
                o => o.Order.CustomerId,
                c => c.Id,
                (o, c) => new { o.Order, o.Line, Customer = c})
          .Join(articles,
                o => o.Line.ArticleId,
                a => a.Id,
                (o, a) => new { o.Order, o.Line, 
                               o.Customer, Article = a})
        .Where(o => o.Order.Date >= new DateTime(2020, 4, 1) &&
                    o.Customer.FirstName == "John")
          .OrderBy(o => o.Article.Name) 
          .Select(o => o.Article.Name);

在这个例子中,我们将订单与订单行和客户进行了连接,并将订单行与文章进行了连接,并且只保留了 2020 年 4 月 1 日后由名为 John 的客户下的订单。然后,我们按文章名称的字典顺序对它们进行了排序,并只选择了文章名称进行投影。

有几个Join()操作,语法可能看起来更难理解。让我们使用以下例子来解释一下:

orders.Join(orderlines,
            o => o.Id,
            ol => ol.OrderId,
            (o, ol) => new { Order = o, Line = ol })

在这里,orders被称为外部序列orderlines被称为内部序列Join()的第二个参数,即o => o.Id,被称为外部序列的键选择器。我们用它来选择订单。Join()的第三个参数,即ol => ol.OrderId,被称为内部序列的键选择器。我们用它来选择订单行。

基本上,这两个 lambda 表达式帮助匹配具有OrderId等于订单 ID 的订单行。最后一个参数(o, ol) => new { Order = o, Line = ol }是连接操作的投影。我们正在创建一个具有名为OrderLine的两个属性的新对象。

一些标准查询操作更容易使用,而其他一些可能更复杂,可能需要一些练习才能理解得很好。然而,对于其中许多操作,存在一个更简单的替代方案——LINQ 查询语法,我们将在下一节中探讨。

查询语法

LINQ 查询语法基本上是标准查询操作的语法糖(即,设计成更容易编写和理解的简化语法)。编译器将使用查询语法编写的查询转换为使用标准查询操作的查询。查询语法比标准查询操作更简单、更易读,但它们在语义上是等价的。然而,正如前面提到的,不是所有的标准查询操作在查询语法中都有等价物。

为了看到标准查询操作的方法语法和查询语法的比较,让我们使用查询语法重写上一节中的例子。

首先,让我们看一下在一段文本中计算单词数的问题。使用查询语法,查询变成了以下形式。请注意,Count()在查询语法中没有等价物:

var count = (from w in text.Split(new char[] { ' ', ',', '.' })
             where !string.IsNullOrEmpty(w)
             select w).Count();

另一方面,第二个问题可以完全使用查询语法来编写,如下所示:

var groups = from w in text.Split(new char[] { ' ', ',', '.' })
             group w.ToLower() by w.Length into g
             where g.Key > 0
             orderby g.Key
             select new { Length = g.Key, Words = g };
foreach (var group in groups)
{
    Console.Write($"Length={group.Length}: ");
    Console.WriteLine(string.Join(',', group.Words));
}

打印文本有点不同。单词以逗号分隔的形式显示在一行上。为了组成逗号分隔的单词文本,我们使用了string.Join()静态方法,它接受一个分隔符和一系列值,并将它们连接成一个字符串。这个程序的输出如下:

Length=2: do,ut,et
Length=3: sit,sed
Length=4: amet,elit
Length=5: lorem,ipsum,dolor,magna
Length=6: tempor,labore,dolore,aliqua
Length=7: eiusmod
Length=10: adipiscing,incididunt
Length=11: consectetur

我们将重写的最后一个问题是与客户订单相关的例子。这个查询可以非常简洁地表达,如下面的代码所示。这段代码类似于 SQL,join操作的写法确实更简单,更易读,更易理解:

var query = from o in orders
            join ol in orderlines on o.Id equals ol.OrderId
            join c in customers on o.CustomerId equals c.Id
            join a in articles on ol.ArticleId equals a.Id
            where o.Date >= new DateTime(2019, 4, 1) &&
                  c.FirstName == "John"
            orderby a.Name
            select a.Name;

从这些例子中可以看出,LINQ 帮助以比传统的命令式编程更简单的方式构建查询。不同性质的数据源可以以类似 SQL 的语言一致地进行查询。查询是强类型的,并且在编译时进行验证,这有助于解决许多潜在的错误。

现在,让我们来看一些更多的函数式编程概念:部分函数应用、柯里化、闭包、幺半群和单子。

更多的函数式编程概念

在本章的开头,我们看了一般的函数式编程概念,主要是高阶函数和不可变性。在本节中,我们将探讨几个更多的函数式编程概念和技术——部分函数应用、柯里化、闭包、幺半群和单子。

部分函数应用

部分函数应用是将具有N 个参数一个参数的函数进行处理,并在将参数固定为函数的一个参数后返回具有N-1 个参数的另一个函数的过程。当然,也可能会使用多个参数进行调用,比如M,在这种情况下返回的函数将具有N-M个参数。

要理解这是如何工作的,让我们从一个具有多个参数并返回一个字符串(包含参数值)的函数开始:

string AsString(int a, double b, string c)
{
    return $"a={a}, b={b}, c={c}";
}

如果我们将这个函数作为AsString(42, 43.5, "44")调用,结果将是字符串"a=42, b=43.5, c=44"。然而,如果我们有一个函数(让我们称之为Apply())可以将一个参数绑定到这个函数的第一个参数,那么我们可以用相同的结果来调用它:

var f1 = Apply<int, double, string, string>(AsString, 42);
var result = f1(43.5, "44");

实现这样一个Apply()函数的方法如下:

Func<T2, T3, TResult>
Apply<T1, T2, T3, TResult>(Func<T1, T2, T3, TResult> f, T1 arg)
{
    return (b, c) => f(arg, b, c);
}

这个高阶函数接受另一个函数和一个值作为参数,并返回另一个参数少一个的高阶函数。这个函数解析为使用f参数函数和arg参数值以及其他参数。

也可能继续将函数减少到另一个参数少一个的函数,直到我们有一个没有参数的函数,如下所示:

var f1 = Apply<int, double, string, string>(AsString, 42);
var f2 = Apply(f1, 43.5);
var f3 = Apply(f2, "44");
string result = f3();

然而,要实现这一点,我们需要Apply()函数的额外重载,以及相应数量的参数。对于这里显示的情况,我们需要以下内容(实际上,如果你有超过三个参数的函数,你需要更多的重载来考虑所有可能的参数数量):

Func<T2, TResult> Apply<T1, T2, TResult>(Func<T1, T2, TResult> f, T1 arg)
{
    return b => f(arg, b);
}
Func<TResult> Apply<T1, TResult>(Func<T1, TResult> f, T1 arg)
{
    return () => f(arg);
}

在这个例子中,重要的是要注意,只有当所有参数都提供时,才会实际调用AsString()函数;也就是说,当我们调用f3()时。

你可能想知道部分函数应用何时有用。典型情况是当你多次(或多次)调用一个函数,而一些参数是相同的。在这种情况下,有几种替代方案,包括以下几种:

  • 在定义函数时为函数参数提供默认值。然而,由于不同的原因,这可能是不可能的。也许默认值只在某些情况下有意义,或者你实际上并不拥有这段代码,所以无法提供默认值。

  • 在多次调用函数的类中,可以编写一个带有较少参数的helper函数,以使用正确的默认值调用函数。

部分函数应用可能是(在许多情况下)更简单的解决方案。

柯里化

柯里化是将具有N个参数的函数分解为接受一个参数的N个函数的过程。这种技术得名于数学家和逻辑学家 Haskell Curry,函数式编程语言Haskell也是以他的名字命名的。

柯里化使得能够在只能使用一个参数的情况下使用具有多个参数的函数。数学中的分析技术就是一个例子,它只能应用于具有单个参数的函数。

考虑到上一节中的AsString()函数,对这个函数进行柯里化将会做如下操作:

  • 返回一个函数f1

  • 当使用参数a调用时,它将返回一个函数f2

  • 当使用参数b调用时,它将返回一个函数f3

  • 当使用参数c调用时,它将调用AsString(a, b, c)

将这些放入代码中,看起来如下:

var f1 = Curry<int, double, string, string>(AsString);
var f2 = f1(42);
var f3 = f2(43.5);
string result = f3("44");

在这里看到的通用Curry()函数类似于上一节中的Apply()函数。但是,它返回的不是具有N-1个参数的函数,而是具有一个参数的函数:

Func<T1, Func<T2, Func<T3, TResult>>> 
Curry<T1, T2, T3, TResult>(Func<T1, T2, T3, TResult> f)
{
    return a => b => c => f(a, b, c);
}

这个函数可以用于柯里化具有三个参数的函数。如果你需要对具有其他参数数量的函数进行柯里化,那么你需要适当的重载(就像在Apply()的情况下一样)。

您应该注意,您不一定需要将AsString()函数分解为三个不同的函数,就像之前的f1f2f3一样。您可以跳过中间函数,并通过适当调用函数来实现相同的结果,如下面的代码所示:

var f = Curry<int, double, string, string>(AsString);
string result = f(42)(43.5)("44");

函数编程中的另一个重要概念是闭包。我们将在下一节学习有关闭包的知识。

闭包

闭包被定义为在具有头等函数的语言中实现词法范围名称绑定的技术。词法或静态作用域是将变量的作用域设置为定义它的块,因此只能在该作用域内通过其名称引用它。

信息框

C#中的作用域称为静态词法,可以在编译时查看。相反的是动态作用域,它只在运行时解析,但在 C#中不支持这种作用域。

正如我们在本章前面看到的,C#是一种具有头等函数的语言,因为您可以将函数分配给变量,传递它们并调用它们。然而,这种对闭包的定义可能更难理解,因此我们将使用一个示例逐步解释它。

让我们考虑以下示例:

class Program
{
    static Func<int, int> Increment()
    {
        int step = 1;
        return x => x + step;
    }
    static void Main(string[] args)
    {
        var inc = Increment();
        Console.WriteLine(inc(42));
    }
}

在这里,我们有一个名为Increment()的函数,它返回另一个函数,该函数使用一个值递增其参数。然而,该值既不作为参数传递给 lambda,也不在 lambda 中定义为局部变量。相反,它是从外部范围捕获的。因此,在 lambda 的范围内,step 变量被称为step变量;如果在那里找不到它,它会查找封闭范围,这种情况下是Increment()函数。如果那里也找不到它,它将进一步查找类范围,依此类推。

接下来发生的是,我们将从Increment()函数返回的值(另一个函数)分配给inc变量,然后使用值42调用它。结果是将值43打印到控制台。

问题是,这是如何工作的? step变量实际上是一个局部函数变量,应该在调用Increment()后立即超出范围。然而,在调用从Increment()返回的函数时,它的值是已知的。这是因为 lambda 表达式x => x + step被认为是闭合在自由变量step上,从而定义了一个闭包。lambda 表达式和step一起传递(作为闭包的一部分),以便变量通常会超出范围,但在调用闭包时仍然存在。

闭包经常被使用,而我们甚至没有意识到。考虑以下示例,我们有一个引擎列表,我们想要搜索具有最小功率和容量的引擎。您通常会使用 lambda 表达式编写如下内容:

var list = new List<Engine>();
var minp = 75.0;
var minc = 1600;
var engine = list.Find(e => e.Power >= minp && 
                       e.Capacity >= minc);

但这实际上创建了一个闭包,因为 lambda 闭合了minpminc自由变量。如果语言不支持闭包,编写相同功能的代码将会很麻烦。你基本上需要编写一个捕获这些变量值的类,并且有一个方法,该方法接受一个Engine对象并将其属性与这些值进行比较。在这种情况下,代码可能如下所示:

sealed class EngineFinder
{
    public EngineFinder(double minPower, int minCapacity)
    {
        this.minPower = minPower;
        this.minCapacity = minCapacity;
    }
    public double minPower;
    public int minCapacity;
    public bool IsMatch(Engine engine)
    {
        return engine.Power >= minPower && 
            engine.Capacity >= minCapacity;
    }
}
var engine = list.Find(new EngineFinder(minp, minc).IsMatch);

这与编译器在遇到闭包时所做的事情非常相似,但这是你不必担心的细节。

您还应该注意,lambda 中捕获的自由变量的值可以改变。我们通过以下示例来说明这一点,其中GetNextId()函数定义了一个闭包,该闭包在每次调用时递增捕获的自由变量id的值:

Func<int> GetNextId()
{
    int id = 1;
    return () => id++;
}
var nextId = GetNextId();
Console.WriteLine(nextId()); // prints 1
Console.WriteLine(nextId()); // prints 2
Console.WriteLine(nextId()); // prints 3

我们将在下一节学习有关单子的知识。

单子

单子是一种具有单一可结合二元操作和单位元的代数结构。任何具有这两个元素的 C#类型都是单子。单子对于定义概念和重用代码非常有用。它们帮助我们从简单的组件构建复杂的行为,而无需在我们的代码中引入新的概念。让我们看看如何在 C#中创建和使用单子。

我们可以在 C#中定义一个通用接口来表示单子,如下所示:

interface IMonoid<T>
{
    T Combine(T a, T b);
    T Identity { get; }
}

单子确保结合性和左右单位性,以便对于任何值abc,我们有以下内容:

  • Combine((Combine(a, b), c) == Combine(a, Combine(b, c))

  • Combine(Identify, a) == a

  • Combine(a, Identity) == a

连接字符串或列表是一个可结合的二元操作的例子。提供该函数的类型,以及一个单位元(在这些情况下是一个空字符串或一个空列表),就是一个单子。因此,我们实际上可以在 C#中实现这些功能,如下所示:

struct ConcatList<T> : IMonoid<List<T>>
{
    public List<T> Identity => new List<T> { };
    public List<T> Combine(List<T> a, List<T> b)
    {
        var l = new List<T>(a);
        l.AddRange(b);
        return l;
    }
}
struct ConcatString : IMonoid<string>
{
    public string Identity => string.Empty;
    public string Combine(string a, string b)
    {
        return a + b;
    }
}

ConcatListConcatString都是单子的例子。后者可以如下使用:

var m = new ConcatString();
var text = m.Combine("Learning", m.Combine(" ", "C# 8"));
Console.WriteLine(text);

这将在控制台上打印Learning C# 8。然而,这段代码有点繁琐。我们可以通过创建一个带有静态方法Concat()的辅助类来简化它,该方法接受一个单子和一系列元素,并使用单子的二元操作和其初始值的单位元将它们组合在一起:

static class Monoid
{
    public static T Concat<MT, T>(IEnumerable<T> seq)
        where MT : struct, IMonoid<T>
    {
       var result = default(MT).Identity;
       foreach (var e in seq)
           result = default(MT).Combine(result, e);
       return result;
    }
}

有了这个辅助类,我们可以编写以下简化的代码:

var text = Monoid.Concat<ConcatString, string>(
              new[] { "Learning", " ", "C# 8"});
Console.WriteLine(text);
var list = Monoid.Concat<ConcatList<int>, List<int>>(
    new[] { new List<int>{ 1,2,3},
    new List<int> { 4, 5 },
    new List<int> { } });
Console.WriteLine(string.Join(",", list));

在这个例子的第一部分中,我们将一系列字符串连接成一个单一的字符串并打印到控制台。在第二部分中,我们将一系列整数的列表连接成一个单一的整数列表,然后也打印到控制台。

在接下来的部分,我们将看看单子。

单子

这通常是一个更难解释,也许更难理解的概念,尽管已经有很多文献写过它。在这本书中,我们将尝试用简单的术语来解释它,但我们建议您阅读其他资源。

简而言之,单子是一个封装了一些功能的容器,它包裹在它的值之上。我们经常在 C#中使用单子而没有意识到。Nullable<T>是一个定义了特殊功能的单子,即可空性,这意味着一个值可能存在,也可能不存在。带有awaitTask<T>是一个定义了特殊功能的单子,即异步性,这意味着一个值可以在实际计算之前被使用。带有 LINQ 查询SelectMany()操作符的IEnumerable<T>也是一个单子。

单子有两个操作:

  • 一个将值v转换为包装它的容器(v -> C(v))的函数。在函数式编程中,这个函数被称为return

  • 一个将两个容器扁平化为一个单一容器的函数(C(C(v)) -> C(v))。在函数式编程中,这被称为bind

让我们看下面的例子:

var numbers = new int[][]{ new[]{ 1, 2, 3},
                           new[]{ 4, 5 },
                           new[]{ 6, 7} };
IEnumerable<int> odds = numbers.SelectMany(
                           n => n.Where(x => x % 2 == 1));

在这里,numbers是一个整数数组的数组。SelectMany()用于选择奇数的子序列。然而,这将结果扁平化为IEnumerable<int>而不是IEnumerable<IEnumerable<int>>。正如我们之前提到的,带有SelectMany()IEnumerable<T>是一个单子。

但是你如何在 C#中实现一个单子呢?最简单的形式如下:

class Monad<T>
{
    public Monad(T value) => Value = value;
    public T Value { get; }
    public Monad<U> Bind<U>(Func<T, Monad<U>> f) => f(Value);
}

实际上被称为x => x,你将得到初始单子:

var m = new Monad<int>(42);
var mm = new Monad<Monad<int>>(m);
var r = mm.Bind(x => x); // r equals m

这个单子如何使用的另一个例子在下面的代码中展示:

var m = new Monad<int>(21);
var r = m.Bind(x => new Monad<int>(x * 2))
         .Bind(x => new Monad<string>($"x={x}"));
Console.WriteLine(r.Value); // prints x=42

在这个例子中,m是一个包装整数值21的单子。我们使用一个返回新单子的函数进行绑定,该单子的值是初始值的两倍。我们可以再次使用一个将整数转换为字符串的函数对这个单子进行绑定。

从这个例子中,你可以看到这些绑定操作可以链接在一起。这就是流畅接口提供的功能——通过链接方法来编写类似书面散文的代码。这可以通过以下示例进一步说明——假设一个企业有客户,客户下订单,订单可以包含一个或多个商品,你需要找出一个特定企业所有客户购买的所有不同商品。

为简单起见,让我们考虑以下类:

class Business
{
    public IEnumerable<Customer> GetCustomers() { 
      return /* … */; }
}
class Customer
{
    public IEnumerable<Order> GetOrders() { return /* … */; }
}
class Order
{
    public IEnumerable<Article> GetArticles() { return /* … */; }
}
class Article { }

在典型的命令式风格中,你可以按照以下方式实现解决方案:

IEnumerable<Article> GetArticlesSoldBy(Business business)
{
    var articles = new HashSet<Article>();
    foreach (var customer in business.GetCustomers())
    {
        foreach (var order in customer.GetOrders())
        {
            foreach (var article in order.GetArticles())
            {
                articles.Add(article);
            }
        }
    }
    return articles;
}

然而,通过使用 LINQ 和IEnumerable<T>和“SelectMany()”单子,这可以更简化。函数式编程风格的实现可能如下所示:

IEnumerable<Article> GetArticlesSoldBy(Business business)
{
    return business.GetCustomers()
                   .SelectMany(c => c.GetOrders())
                   .SelectMany(o => o.GetArticles())
                   .Distinct()
                   .ToList();
}

这使用了流畅接口模式,结果是更简洁的代码,也更容易理解。

总结

这一章是对 C#命令式编程特性的一次离开,因为我们探讨了内置到语言中的函数式编程概念和技术。我们研究了高阶函数、lambda 表达式、部分函数应用、柯里化、闭包、幺半群和单子。我们还介绍了 LINQ 及其两种风格:方法语法和查询语法。这些大多数主题都比本书的建议范围复杂和更高级。因此,我们建议您使用其他资源来掌握它们。

在下一章中,我们将研究.NET 中可用的反射服务以及 C#的动态编程能力。

测试你学到了什么

  1. 函数式编程的主要特征是什么?它提供了什么优势?

  2. 什么是高阶函数?

  3. 是什么让函数在 C#语言中成为一等公民?

  4. 什么是 lambda 表达式?写 lambda 表达式的语法是什么?

  5. lambda 表达式中变量作用域适用的规则是什么?

  6. 什么是 LINQ?标准查询操作符是什么?查询语法是什么?

  7. “Select()”和“SelectMany()”之间有什么区别?

  8. 什么是部分函数应用,它与柯里化有什么不同?

  9. 什么是幺半群?

  10. 什么是单子?

第十一章:反射和动态编程

在上一章中,我们讨论了函数式编程、lambda 表达式以及它们所支持的功能,比如语言集成查询(LINQ)。本章侧重于反射服务和动态编程。您将学习什么是反射,以及如何在运行时获取有关类型的信息,以及代码和资源如何存储在程序集中,以及如何在运行时动态加载它们,无论是用于反射还是代码执行。

这对于构建支持插件或附加组件形式的扩展的应用程序至关重要。我们将看到属性是什么,以及它们在反射中扮演的角色。本章中我们将讨论的另一个重要主题是动态编程和动态语言运行时,它使动态语言能够在**公共语言运行时(CLR)**上运行,并为静态类型语言添加动态特性。

本章我们将讨论以下主题:

  • 理解反射

  • 动态加载程序集

  • 理解后期绑定

  • 使用dynamic类型

  • 属性

在本章结束时,您将对反射、属性及其在反射中的使用,以及程序集加载和代码执行有很好的理解。另一方面,您还将学习关于dynamic类型,并能够与动态语言进行交互。

理解反射

.NET 中的部署单元是程序集。程序集是一个文件(可以是可执行文件或动态链接库),其中包含ildasm.exeilspy.exe(一个开源项目);或其他允许您查看程序集内容的工具。以下是ildasm.exe的屏幕截图,显示了本书源代码中提供的chapter_11_01.dll程序集:

图 11.1 - chapter 11 程序集的反汇编源代码。

图 11.1 - chapter_11_01 程序集的反汇编源代码

反射是在运行时发现类型并对其进行更改的过程。这意味着我们可以在运行时检索有关类型、其成员和属性的信息。这带来了一些重要的好处:

  • 在运行时动态加载程序集(后期绑定)、检查类型和执行代码的能力使得构建可扩展应用程序变得容易。应用程序可以通过接口和基类定义功能,然后在单独的模块(插件或附加组件)中实现或扩展这些功能,并根据各种条件在运行时加载和执行它们。

  • 属性,我们稍后将在本章中看到,使得以声明方式提供有关类型、方法、属性和其他内容的元信息成为可能。通过能够在运行时读取这些属性,系统可以改变它们的行为。例如,工具可以警告某个方法的使用方式与预期不同(比如过时方法的情况),或以特定方式执行它们。测试框架(我们将在最后一章中看到一些)广泛使用了这种功能。

  • 它提供了执行私有或其他访问级别的类型和成员的能力,否则这些类型和成员将无法访问。这对于测试框架来说非常方便。

  • 它允许在运行时修改现有类型或创建全新类型,并使用它们执行代码。

反射也有一些缺点:

  • 它会产生一个可能降低性能的开销。在运行时加载、发现和执行代码会更慢,可能会阻止优化。

  • 它暴露了类型的内部,因为它允许对所有类型和成员进行内省,而不考虑它们的访问级别。

.NET 反射服务允许您使用System.Reflection命名空间中的 API 发现与前面提到的工具中看到的相同的信息。这个过程的关键是名为System.Type的类型,其中包含公开所有类型元数据的成员。这是通过System.Reflection命名空间中的其他类型的帮助完成的,其中一些列在以下表中:

System.Type类的一些最重要的成员列在以下表中:

有几种方法可以在运行时检索System.Type的实例以访问类型元数据;以下是其中的一些:

  • 使用System.Object类型的GetType()方法。由于这是所有值类型和引用类型的基类,您可以使用任何类型的实例调用:
var engine = new Engine();
var type = engine.GetType();
  • 使用System.TypeGetType()静态方法。有许多重载,允许您指定名称和各种参数:
var type = Type.GetType("Engine");
  • 使用 C#的typeof运算符:
var type = typeof(Engine);

让我们看看如何通过查看一个实际的例子来使用反射。我们将考虑以下Engine类型,它具有几个属性、一个构造函数和一对改变引擎状态(启动或停止)的方法:

public enum EngineStatus { Stopped, Started }
public class Engine
{
    public string Name { get; }
    public int Capacity { get; }
    public double Power { get; }
    public EngineStatus Status { get; private set; }
    public Engine(string name, int capacity, double power)
    {
        Name = name;
        Capacity = capacity;
        Power = power;
        Status = EngineStatus.Stopped;
    }
    public void Start()
    {
        Status = EngineStatus.Started;
    }
    public void Stop()
    {
        Status = EngineStatus.Stopped;
    }
}

我们将构建一个小程序,它将在运行时读取有关Engine类型的元数据,并将以下内容打印到控制台:

  • 类型的名称

  • 所有属性的名称以及它们的类型的名称

  • 所有声明的方法的名称(不包括继承的方法)

  • 它们的返回类型的名称

  • 每个参数的名称和类型

以下是用于在运行时读取和打印有关Engine类型的元数据的程序:

static void Main(string[] args)
{
    var type = typeof(Engine);
    Console.WriteLine(type.Name);
    var properties = type.GetProperties();
    foreach(var p in properties)
    {
        Console.WriteLine($"{p.Name} ({p.PropertyType.Name})");
    }
    var methods = type.GetMethods(BindingFlags.Public |
                                  BindingFlags.Instance |
                                  BindingFlags.DeclaredOnly);
    foreach(var m in methods)
    {
        var parameters = string.Join(
            ',',
            m.GetParameters()
             .Select(p => $"{p.ParameterType.Name} {p.Name}"));
        Console.WriteLine(
          $"{m.ReturnType.Name} {m.Name} ({parameters})");
   }
}

在这个例子中,我们使用typeof运算符检索System.Type类型的实例,以发现Engine类型的元数据。为了检索属性,我们使用了没有参数的GetProperties()重载,它返回当前类型的所有公共属性。然而,对于方法,我们使用了GetMethod()方法的重载,它以一个由一个或多个BindingFlags值组成的位掩码作为参数。

BindingFlags类型是一个枚举,其中的标志控制绑定和在反射期间执行类型和方法搜索的方式。在我们的例子中,我们使用PublicInstanceDeclareOnly来指定仅在此类型中声明的公共非静态方法,并排除继承的方法。这个程序的输出如下:

Engine
Name (String)
Capacity (Int32)
Power (Double)
Status (EngineStatus)
String get_Name ()
Int32 get_Capacity ()
Double get_Power ()
EngineStatus get_Status ()
Void Start ()
Void Stop ()

Engine类型位于执行反射代码的程序集中。但是,您也可以反射来自其他程序集的类型,无论它们是从执行程序集引用还是在运行时加载的,这是我们将在下一节中看到的。

动态加载程序集

反射服务允许您在运行时加载程序集。这是使用System.Reflection.Assembly类型完成的,它提供了各种加载程序集的方法。

程序集可以是公共(也称为共享)或私有。共享程序集旨在供多个应用程序使用,并且通常位于**全局程序集缓存(GAC)**下,这是程序集的系统存储库。私有程序集旨在供单个应用程序使用,并存储在应用程序目录或其子目录中。共享程序集必须具有强名称并强制执行版本约束;对于私有程序集,这些要求是不必要的。

程序集可以在三个上下文中之一加载,也可以不加载:

  • 加载上下文,其中包含从 GAC、应用程序目录(应用程序域的ApplicationBase)或其私有程序集的子目录(应用程序域的PrivateBinPath)加载的程序集

  • 加载上下文,其中包含从除了程序集加载程序探测的路径加载的程序集

  • 仅反射上下文,其中包含仅用于反射目的加载的程序集,不能用于执行代码

  • 无上下文,在某些特殊情况下使用,例如从字节数组加载的程序集

用于加载程序集的最重要的方法列在下表中:

我们将看几个动态加载程序集的例子。

在第一个例子中,我们使用Assembly.Load()从应用程序目录加载名为EngineLib的程序集:

var assembly = Assembly.Load("EngineLib");

在这里,我们只指定了程序集的名称,但我们也可以指定显示名称,该名称不仅由名称组成,还包括版本、文化和用于签名程序集的公钥标记。对于没有强名称的程序集,这是null。在下面的行中,我们使用显示名称,与先前使用的行等效:

var assembly = Assembly.Load(@"EngineLib, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null");

可以使用AssemblyName类以类型安全的方式创建显示名称。该类具有各种属性和方法,允许您构建显示名称。可以按照以下方式完成:

var assemblyName = new AssemblyName()
{
    Name = "EngineLib",
    Version = new Version(1,0,0,0),
    CultureInfo = null,
};
var assembly = Assembly.Load(assemblyName);

公共(或共享)程序集必须具有强名称。这有助于唯一标识程序集,从而避免可能的冲突。签名是使用公共-私钥完成的;私钥用于签名,公钥与程序集一起分发并用于验证签名。

可以使用与 Visual Studio 一起分发的sn.exe工具生成这样的加密对;此工具也可用于验证签名。对于强名称程序集,必须指定PublicKeyToken,否则加载将失败。以下示例显示了如何从 GAC 加载WindowsBase.dll

var assembly = Assembly.Load(@"WindowsBase, Version=4.0.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35");

使用程序集名称加载程序集的替代方法是使用其实际路径。但是,在这种情况下,您必须使用LoadFrom()的一个重载之一。这对于必须加载既不在 GAC 中也不在应用程序文件夹下的程序集的情况非常有用。一个例子可以是一个可扩展的系统,可以加载可能安装在某个自定义目录中的插件:

var assembly = Assembly.LoadFrom(@"c:\learningc#8\chapter_11_02\bin\Debug\netcoreapp2.1\EngineLib.dll");

Assembly类具有提供有关程序集本身信息的成员,以及提供有关其包含的类型信息的成员。以下是一些最重要的成员:

在下面的例子中,使用先前显示的方法之一加载程序集后,我们列出程序集名称和程序集清单中的文件,以及引用程序集的名称。之后,我们搜索EngineLib.Engine类型并打印其所有属性的名称和类型:

if (assembly != null)
{
    Console.WriteLine(
$@"Name: {assembly.GetName().FullName}
Files: {string.Join(',', 
                    assembly.GetFiles().Select(
                        s=>Path.GetFileName(s.Name)))}
Refs:  {string.Join(',', 
                    assembly.GetReferencedAssemblies().Select(
                        n=>n.Name))}");
    var type = assembly.GetType("EngineLib.Engine");
    if (type != null)
    {
        var properties = type.GetProperties();
        foreach (var p in properties)
        {
            Console.WriteLine(
              $"{p.Name} ({p.PropertyType.Name})");
        }
    }
}

除了查询有关程序集及其内容的信息之外,还可以在运行时从中执行代码。这是我们将在下一节中讨论的内容。

理解后期绑定

在编译时引用程序集时,编译器可以完全访问该程序集中可用的类型。这称为早期绑定。但是,如果程序集仅在运行时加载,编译器将无法访问该程序集的内容。这称为后期绑定,是构建可扩展应用程序的关键。使用后期绑定,您不仅可以加载和查询程序集,还可以执行代码。我们将在下面的例子中看到。

假设先前显示的Engine类在名为EngineLib的程序集中可用。可以使用Assembly.Load()Assembly.LoadFrom()加载该程序集。加载后,我们可以使用Assembly.GetType()Type的类方法获取有关Engine类型的信息。但是,使用Assembly.CreateInstance(),我们可以实例化该类的对象:

var assembly = Assembly.LoadFrom("EngineLib.dll");
if (assembly != null)
{
    var type = assembly.GetType("EngineLib.Engine");
    object engine = assembly.CreateInstance(
        "EngineLib.Engine",
        true,
        BindingFlags.CreateInstance,
        null,
        new object[] { "M270 Turbo", 1600, 75.0 },
        null,
        null);
    var pi = type.GetProperty("Status");
    if (pi != null)
        Console.WriteLine(pi.GetValue(engine));
    var mi = type.GetMethod("Start");
    if (mi != null)
        mi.Invoke(engine, null);
    if (pi != null)
        Console.WriteLine(pi.GetValue(engine));
}

Assembly.CreateInstance()方法有许多参数,但其中三个最重要:

  • 第一个参数string typeName,表示程序集的名称。

  • 第三个参数,BindingFlags bindingAttr,表示绑定标志。

  • 第五个参数,object[] args,表示用于调用构造函数的参数数组;对于默认构造函数,这个对象可以是null

在创建类型的实例之后,我们可以使用PropertyInfoMethodInfo等的实例来调用其成员。例如,在前面的示例中,我们首先检索了名为Status的属性的PropertyInfo实例,然后通过调用GetValue()并传递引擎对象来获取属性的值。

同样地,我们使用GetMethod()来检索一个名为Start()的方法的MethodInfo实例,然后通过调用Invoke()来调用它。这个方法接受一个对象的引用和一个表示参数的对象数组;由于Start()方法没有参数,在这里使用了null

Assembly.CreateInstance()方法有很多参数,使用起来可能很麻烦。作为替代,System.Activator类提供了在运行时创建类型实例的更简单的方法。它有一个重载的CreateInstance()方法。实际上,Assembly.CreateInstance()在内部实际上就是使用了它。在最简单的形式中,它只需要Type和一个表示构造函数参数的对象数组,并实例化该类型的对象。示例如下:

object engine = Activator.CreateInstance(
    type,
    new object[] { "M270 Turbo", 1600, 75.0 });

Activator.CreateInstance()不仅更简单易用,而且在某些情况下可以提供一些好处。例如,它可以在其他应用程序域或另一台服务器上使用远程调用来创建对象。另一方面,Assembly.CreateIntance()如果尚未加载程序集,则不会尝试加载程序集,而System.Activator会将程序集加载到当前应用程序域中。

使用晚期绑定和以前展示的方式调用代码并不一定实用。在实践中,当构建一个可扩展的系统时,您可能会有一个或多个包含接口和公共类型的程序集,这些插件(或插件,取决于您希望如何称呼它们)依赖于这些基本程序集。您将对这些基本程序集进行早期绑定,然后使用插件进行晚期绑定。

为了更好地理解这一点,我们将通过以下示例进行演示。EngineLibBase是一个定义了名为IEngineEngineStatus枚举的接口的程序集:

namespace EngineLibBase
{
    public enum EngineStatus { Stopped, Started }
    public interface IEngine
    {
        EngineStatus Status { get; }
        void Start();
        void Stop();
    }
}

这个程序集直接被EngineLib程序集引用,它提供了实现IEngine接口的Engine类。示例如下:

using EngineLibBase;
namespace EngineLib
{ 
    public class Engine : IEngine
    {
        public string Name { get; }
        public int Capacity { get; }
        public double Power { get; }
        public EngineStatus Status { get; private set; }
        public Engine(string name, int capacity, double power)
        {
            Name = name;
            Capacity = capacity;
            Power = power;
            Status = EngineStatus.Stopped;
        }
        public void Start()
        {
            Status = EngineStatus.Started;
        }
        public void Stop()
        {
            Status = EngineStatus.Stopped;
        }
    }
}

在我们的应用程序中,我们再次引用了EngineLibBase程序集,以便我们可以使用IEngine接口。在运行时加载EngineLib程序集后,我们实例化了Engine类的对象,并将其转换为IEngine接口,这样即使在编译时实际实例未知的情况下,也可以访问接口的成员。代码如下所示:

var assembly = Assembly.LoadFrom("EngineLib.dll");
if (assembly != null)
{
    var type = assembly.GetType("EngineLib.Engine");
    var engine = (IEngine)Activator.CreateInstance(
        type,
        new object[] { "M270 Turbo", 1600, 75.0 });
    Console.WriteLine(engine.Status);
    engine.Start();
    Console.WriteLine(engine.Status);
}

正如我们将在本章后面看到的那样,这并不是使用晚期绑定和在运行时动态执行代码的唯一方法。另一种可能性是使用 DLR 和dynamic类型。我们将在下一节中看到这一点。

使用动态类型

在本书中,我们已经谈到了CLR。.NET Framework,然而,还包含了另一个组件,称为动态语言运行时(DLR)。这是另一个运行时环境,它在 CLR 之上添加了一组服务,使动态语言能够在 CLR 上运行,并为静态类型语言添加动态特性。C#和 Visual Basic 是静态类型语言。相比之下,诸如 JavaScript、Python、Ruby、PHP、Smalltalk、Lua 等语言是动态语言。这些语言的关键特征是它们在运行时识别对象的类型,而不是在编译时像静态类型语言那样。

DLR 为 C#(和 Visual Basic)提供了动态特性,使它们能够以简单的方式与动态语言进行互操作。如前所述,DLR 为 CLR 添加了一组服务。这些服务如下:

  • 表达式树用于表示语言语义。这些是与 LINQ 一起使用的相同表达式树,但扩展到包括控制流、赋值和其他内容。

  • 调用站点缓存是一个缓存有关操作和对象(如对象的类型)的信息的服务,这样当再次执行相同的操作时,它可以被快速分派。

  • IDynamicMetaObjectProviderDynamicMetaObjectDynamicObjectExpandoObject

DLR 为 C# 4 引入的dynamic类型提供了基础设施。这是一个静态类型,这意味着在编译时为该类型的变量分配了dynamic类型。但是,它们绕过了静态类型检查。这意味着对象的实际类型只在运行时知道,编译器无法知道并且无法强制执行对该类型对象执行的任何检查。您实际上可以调用任何带有任何参数的方法,编译器不会检查和抱怨;但是,如果操作无效,运行时将抛出异常。

以下代码显示了dynamic类型的几个变量的示例。请注意,s是一个字符串,lList<int>。调用l.Add()是有效的操作,因为List<T>包含这样的方法。但是,调用s.Add()是无效的,因为string类型没有这样的方法。因此,对于此调用,在运行时会抛出RuntimeBinderException类型的异常:

dynamic i = 42;
dynamic s = "42";
dynamic d = 42.0;
dynamic l = new List<int> { 42 };
l.Add(43); // OK
try
{
   s.Add(44); /* RuntimeBinderException:
            'string' does not contain a definition for 'Add' */
}
catch (Exception ex)
{
    Console.WriteLine(ex.Message);
}

dynamic类型使得在编译时不知道对象类型的情况下轻松消耗对象变得容易。考虑前一段中的第一个例子,在那里我们使用反射加载了一个程序集,实例化了一个Engine类型的对象并调用了它的方法和属性。可以用dynamic类型以更简单的方式重写该示例,如下所示:

var assembly = Assembly.LoadFrom("EngineLib.dll");
if (assembly != null)
{
    var type = assembly.GetType("EngineLib.Engine");
    dynamic engine = Activator.CreateInstance(
        type,
        new object[] { "M270 Turbo", 1600, 75.0 });
    Console.WriteLine(engine.Status);
    engine.Start();
    Console.WriteLine(engine.Status);
}

dynamic类型的对象在许多情况下的行为就像它具有object类型一样(除了没有编译时检查)。但是,对象值的实际来源是无关紧要的。它可以是.NET 对象、COM 对象、HTML DOM 对象、通过反射创建的对象,例如前面的示例等。

动态操作的结果类型也是dynamic,除了从dynamic到另一种类型的转换和包括dynamic类型参数的构造函数调用。从静态类型到dynamic的隐式转换以及相反的转换都会执行。代码块中显示了这一点:

dynamic d = "42";
string s = d;

对于静态类型,编译器执行重载解析以找出对函数调用的最佳匹配。因为在编译时没有关于dynamic类型的信息,所以对于至少有一个参数是dynamic类型的方法,同样的操作在运行时执行。

dynamic类型通常用于简化在互操作程序集不可用时消耗 COM 对象。以下是一个创建带有一些虚拟数据的 Excel 文档的示例:

dynamic excel = Activator.CreateInstance(
    Type.GetTypeFromProgID("Excel.Application.16")); 
if (excel != null)
{
    excel.Visible = true;

    dynamic workBook = excel.Workbooks.Add();
    dynamic workSheet = excel.ActiveWorkbook.ActiveSheet;
    workSheet.Cells[1, 1] = "ID";
    workSheet.Cells[1, 2] = "Name";
    workSheet.Cells[2, 1] = "1";
    workSheet.Cells[2, 2] = "One";
    workSheet.Cells[3, 1] = "2";
    workSheet.Cells[3, 2] = "Two";
    workBook.SaveAs("d:\\demo.xls", 
        Excel.XlFileFormat.xlWorkbookNormal, 
        AccessMode : Excel.XlSaveAsAccessMode.xlExclusive);
    workBook.Close(true);
    excel.Quit();
}

这段代码的作用如下:

  • 它检索由程序标识符Excel.Application.16标识的 COM 对象的System.Type,并创建其实例。

  • 它将 Excel 应用程序的Visible属性设置为true,这样您就可以看到窗口。

  • 它创建一个工作簿并向其活动工作表添加一些数据。

  • 它将文档保存在名为demo.xls的文件中。

  • 它关闭工作簿并退出 Excel 应用程序。

在本章的最后一节中,我们将看看如何在反射服务中使用属性。

属性

属性提供有关程序集、类型和成员的元信息。编译器、CLR 或使用反射服务读取它们的工具会消耗这些元信息。属性实际上是从System.Attribute抽象类派生的类型。.NET 框架提供了大量的属性,但用户也可以定义自己的属性。

属性在方括号中指定,例如[SerializableAttribute]。属性的命名约定是类型名称总是以Attribute一词结尾。C#语言提供了一种语法快捷方式,允许在不带后缀Attribute的情况下指定属性的名称,例如[Serializable]。但是,只有在类型名称根据此约定正确后缀时才可能。

我们将在下一节首先介绍一些广泛使用的系统属性。

系统属性

.NET Framework 在不同的程序集和命名空间中提供了数百个属性。枚举它们不仅几乎不可能,而且也没有多大意义。然而,以下表格列出了一些经常使用的属性;其中一些我们在本书中已经见过:

另一方面,通常需要或有用的是创建自己的属性类。在下一节中,我们将看看用户定义的属性。

用户定义的属性

您可以创建自己的属性来标记程序元素。您需要从System.Attribute派生,并遵循将类型后缀命名为Attribute的命名约定。以下是一个名为Description的属性,其中包含一个名为Text的属性:

class DescriptionAttribute : Attribute
{
    public string Text { get; private set; }
    public DescriptionAttribute(string description)
    {
        Text = description;
    }
}

此属性可用于装饰任何程序元素。在下面的示例中,我们可以看到这个属性用在了一个类、属性和方法参数上:

[Description("Main component of the car")]
class Engine
{
    public string Name { get; }
    [Description("cm³")]
    public int Capacity { get; }
    [Description("kW")]
    public double Power { get; }
    public Engine([Description("The name")] string name, 
                  [Description("The capacity")] int capacity, 
                  [Description("The power")] double power)
    {
        Name = name;
        Capacity = capacity;
        Power = power;
    }
}

属性可以有位置命名参数:

  • 位置参数由公共实例构造函数的参数定义。每个这样的构造函数的参数定义了一组命名参数。

  • 另一方面,每个非静态公共字段和可读写属性定义了一个命名参数。

以下示例显示了早期介绍的Description属性,修改后可以使用一个名为Required的公共属性:

class DescriptionAttribute : Attribute
{
    public string Text { get; private set; }
    public bool Required { get; set; }
    public DescriptionAttribute(string description)
    {
        Text = description;
    }
}

此属性可以在程序元素上的属性声明中用作命名参数。如下例所示:

[Description("Main component of the car", Required = true)]
class Engine
{
}

让我们在下一节中学习如何使用属性。

如何使用属性?

程序元素可以标记多个属性。有两种等效的方法可以实现这一点:

  • 第一种方法(因为它最具描述性和清晰,所以被广泛使用)是在一对方括号内分别声明每个属性。以下示例显示了如何完成此操作:
[Serializable]
[Description("Main component of the car")]
[ComVisible(false)]
class Engine
{
}
  • 另一种方法是在同一对方括号内声明多个属性,用逗号分隔。以下代码等同于之前的代码:
[Serializable, 
 Description("Main component of the car"), 
 ComVisible(false)]
class Engine
{
}

让我们在下一节中看看如何指定属性的目标。

属性目标

默认情况下,属性应用于它前面的任何程序元素。但是,可以指定目标,比如类型、方法等。这是通过使用另一个名为AttributeUsage的属性标记属性类型来完成的。除了指定目标外,此属性还允许指定新定义的属性是否可以多次应用以及是否可以继承。

以下修改后的DescriptionAttribute版本指示它只能用于类、结构、方法、属性和字段。此外,它指定了该属性被派生类继承,并且可以在同一元素上多次使用:

[AttributeUsage(AttributeTargets.Class|
                AttributeTargets.Struct|
                AttributeTargets.Method|
                AttributeTargets.Property|
                AttributeTargets.Field,
                AllowMultiple = true,
                Inherited = true)]
class DescriptionAttribute : Attribute
{
    public string Text { get; private set; }
    public bool Required { get; set; }
    public DescriptionAttribute(string description)
    {
        Text = description;
    }
}

由于这些变化,这个属性不能再用于方法参数,就像之前的例子中所示的那样。那将导致编译器错误。

到目前为止,我们使用的属性针对程序元素,如类型和方法。但是也可以使用程序集级属性。我们将在下一节中看到这些。

程序集属性

有一些属性可以针对程序集并指定有关程序集的信息。这些信息可以是程序集的标识(即名称、版本和文化)、清单信息、强名称或其他信息。这些属性使用语法[assembly: attribute]指定。这些属性通常可以在为每个.NET Framework 项目生成的AssemblyInfo.cs文件中找到。以下是这些属性的一个示例:

[assembly: AssemblyTitle("project_name")]
[assembly: AssemblyDescription("")]
[assembly: AssemblyConfiguration("")]
[assembly: AssemblyCompany("")]
[assembly: AssemblyProduct("project_name")]
[assembly: AssemblyCopyright("Copyright © 2019")]
[assembly: AssemblyTrademark("")]
[assembly: AssemblyCulture("")]
[assembly: AssemblyVersion("1.0.0.0")]
[assembly: AssemblyFileVersion("1.0.0.0")]

属性用于反射服务。既然我们已经看到了如何创建和使用属性,让我们看看如何在反射中使用它们。

反射中的属性

属性本身没有太大的价值,直到有人反映它们并根据属性的含义和值执行特定的操作。System.Type类型以及System.Reflection命名空间中的其他类型都有一个名为GetCustomAttributes()的重载方法,用于检索特定程序元素标记的属性。其中一个重载采用属性的类型,因此它只返回该类型的实例;另一个不是,返回所有属性。

以下示例从Engine类型中首先检索所有Description属性的实例,然后从类型的所有属性中检索并在控制台中显示描述文本:

var e = new Engine("M270 Turbo", 1600, 75.0);
var type = e.GetType();
var attributes = type.GetCustomAttributes(typeof(DescriptionAttribute), 
                                          true);
if (attributes != null)
{
    foreach (DescriptionAttribute attr in attributes)
    {
        Console.WriteLine(attr.Text);
    }
}
var properties = type.GetProperties();
foreach (var property in properties)
{
    var pattributes = 
      property.GetCustomAttributes(
         typeof(DescriptionAttribute), false);
    if (attributes != null)
    {
        foreach (DescriptionAttribute attr in pattributes)
        {
            Console.WriteLine(
              $"{property.Name} [{attr.Text}]");
        }
    }
}

该程序的输出如下:

Main component of the car
Capacity [cm3]
Power [kW]

摘要

在本章中,我们看了反射服务,如何在运行时加载程序集,并查询关于类型的元信息。我们还学习了如何使用系统反射和 DLR 以及动态类型来动态执行代码。DLR 为 C#提供了动态特性,并以简单的方式实现了与动态语言的互操作性。本章最后涵盖的主题是属性。我们学习了常见的系统属性是什么,以及如何创建自己的类型以及如何在反射中使用它们。

在下一章中,我们将专注于并发和并行性。

测试你学到的东西

  1. .NET 中的部署单位是什么,它包含什么?

  2. 什么是反射?它提供了什么好处?

  3. .NET 类型暴露了关于类型的元数据?你如何创建这种类型的实例?

  4. 公共程序集和私有程序集之间有什么区别?

  5. 在.NET Framework 中,程序集可以在什么上下文中被加载?

  6. 什么是早期绑定?晚期绑定呢?后者提供了什么好处?

  7. 什么是动态语言运行时?

  8. 动态类型是什么,它通常在哪些场景中使用?

  9. 属性是什么,你如何在代码中指定它们?

  10. 你如何创建用户定义的属性?

第十二章:多线程和异步编程

自第一台个人电脑以来,我们已经受益于 CPU 功率的持续增加-这一现象严重影响了开发人员对工具、语言和应用程序设计的选择,而在历史上并没有花费太多精力来编写利用多线程的程序。

在硬件方面,摩尔定律的预测是处理器中晶体管的密度应该每 2 年翻一番,从而提供更多的计算能力,这个预测在一些十年内有效,但我们已经可以观察到它放缓了。即使 CPU 制造商大约 20 年前开始生产多核 CPU,执行代码的能力主要由操作系统(OSes)用于使执行多个进程更加流畅。

这并不意味着代码无法利用并发的力量,而只是只有少量的应用程序完全拥抱了多线程范式。这主要是因为我们编写的所有代码都是从操作系统基础设施提供的单个线程顺序执行,除非我们明确请求创建其他线程并编排它们的执行。

这种趋势主要是因为许多编程语言没有提供构造来自动生成多线程代码。这是因为很难提供适合任何用例并有效利用现代 CPU 提供的并发处理能力的语义。

另一方面,有时我们并不真正需要并发执行应用程序代码,但我们无法继续执行,因为需要等待一些未完成的 I/O 操作。同时,阻塞代码执行也是不可接受的,因此需要采用不同的策略。这类问题领域被归类为异步编程,需要稍微不同的工具。

在本章中,我们将学习多线程和异步编程的基础知识,并具体了解以下内容:

  • 什么是线程?

  • 在.NET 中创建线程

  • 理解同步原语

  • 任务范式

在本章结束时,您将熟悉多线程技术,使用原语来同步代码执行、任务、继续和取消标记。您还将了解潜在的危险操作以及在多个线程之间共享资源时避免问题的基本模式。

我们现在将开始熟悉操作多线程和异步编程所需的基本概念。

什么是线程?

每个操作系统都提供抽象来允许多个程序共享相同的硬件资源,如 CPU、内存和输入输出设备。进程是这些抽象之一,提供了一个保留的虚拟地址空间,其运行代码无法逃离。这种基本的沙盒避免了进程代码干扰其他进程,为平衡生态系统奠定了基础。进程与代码执行无关,主要与内存有关。

负责代码执行的抽象是线程。每个进程至少有一个线程,但任何进程代码都可以请求创建更多的线程,它们都将共享相同的虚拟地址空间,由所属进程限定。在单个进程中运行多个线程大致相当于一组木工朋友共同完成同一个项目-他们需要协调,关注彼此的进展,并注意不要阻塞彼此的活动。

所有现代操作系统都提供抢占式多任务处理策略,而不是合作式多任务处理。这意味着操作系统的一个特殊组件安排每个线程可以运行的时间,而无需从正在运行的代码中获得任何合作。

提示

早期版本的 Windows,如 Windows 3.x 和 Windows 9x,使用协作式多任务处理,这意味着任何应用程序都可以通过简单的无限循环挂起整个操作系统。这主要是因为 CPU 功率和能力的限制。所有后来的操作系统,如从最初的NT 3.1 高级服务器开始的 Windows 版本和所有类 Unix 的操作系统,一直都使用抢占式多任务处理,使操作系统更加健壮,并提供更好的用户体验。

您可以使用任务管理器、Process Explorer 或 Process Hacker 工具查看每个运行进程中使用的线程数。您会立即注意到,许多应用程序,包括所有.NET 应用程序,都使用不止一个线程。这些信息并不能告诉我们太多关于应用程序的设计,因为现代运行时(如.NET CLR)使用后台线程进行内部处理,例如垃圾回收器终结队列等。

提示

要查看运行进程使用的线程数,请打开任务管理器Ctrl + Shift + Esc),单击详细信息选项卡,并添加线程列。可以通过右键单击其中一个网格标题,选择选择列菜单项,最后勾选线程选项来添加列。

以下屏幕截图显示了一个 C控制台应用程序,用户的代码使用一个线程,而其他三个线程是由 C运行时创建的:

图 12.1 - 任务管理器显示具有四个线程的 NativeConsole.exe 进程

图 12.1 - 任务管理器显示具有四个线程的 NativeConsole.exe 进程

包含处理线程的基元的命名空间是System.Threading,但在本章后面,我们还将介绍System.Threading.Tasks命名空间。

当.NET 应用程序启动时,.NET 运行时会准备我们的进程,分配内存并创建一些线程,包括将从Main入口点开始执行我们的代码的线程。

以下控制台应用程序访问当前线程并在屏幕上打印当前线程的Id

static void Main(string[] args)
{
    Console.WriteLine($"Current Thread Id: {Thread.CurrentThread.ManagedThreadId}");
    Console.ReadKey();
}

ManagedThreadId属性在诊断多线程代码时很重要,因为它将某些代码的执行与特定线程相关联。

Id只能在运行的进程中使用,并且与操作系统线程标识符不同。如果您需要访问本机标识符,您需要使用互操作性,如下面的仅限 Windows 的代码片段所示:

[DllImport("Kernel32.dll")]
private static extern int GetCurrentThreadId();
static void Main(string[] args)
{
    Console.WriteLine($"Current Thread Id: {Thread.CurrentThread.ManagedThreadId}");
    Console.WriteLine($"Current Native Thread Id: {GetCurrentThreadId()}");
    Console.ReadKey();
}

本机Id是您可以在Process ExplorerProcess Hacker工具中看到的Id,这是与其他本机 API 进行交互所需的Id。在下面的屏幕截图中,您可以看到左侧控制台中打印的结果,右侧是 Process Hacker 线程窗口:

图 12.2 - 控制台应用程序与 Process Hacker 并排显示相同的本机线程 Id

图 12.2 - 控制台应用程序与 Process Hacker 并排显示相同的本机线程 Id

线程也可以由操作系统、.NET 运行时或某个库创建,而无需我们的代码明确请求。例如,以下类展示了FileSystemWatcher类的使用情况,并为每个文件系统操作打印了ManagedThreadId属性:Run方法打印与主线程关联的 ID,而Wacher_DeletedWatcher_Created方法是由操作系统或基础架构创建的线程执行的:

public class FileWatcher
{
    private FileSystemWatcher _watcher;
    public void Run()
    {
        var path = Path.GetFullPath(".");
        Console.WriteLine($"Observing changes in path: {path}");
        _watcher = new FileSystemWatcher(path, "*.txt");
        _watcher.Created += Watcher_Created;
        _watcher.Deleted += Watcher_Deleted;
        Console.WriteLine($"TID: {Thread.CurrentThread.ManagedThreadId}");
        _watcher.EnableRaisingEvents = true;
    }
    private void Watcher_Deleted(object sender, FileSystemEventArgs e)
    {
        Console.WriteLine($"Deleted occurred in TID: {Thread.CurrentThread.ManagedThreadId}");
    }
    private void Watcher_Created(object sender, FileSystemEventArgs e)
    {
        Console.WriteLine($"Created occurred in TID: {Thread.CurrentThread.ManagedThreadId}");
    }
} 

您可以通过创建控制台应用程序并将以下代码添加到Main方法来尝试此代码:

var fw = new FileWatcher();
fw.Run();
Console.ReadKey();

现在,如果您开始在控制台文件夹中创建和删除一些.txt文件,您将看到类似于这样的东西:

Observing changes in path: C:\projects\Watch\bin\Debug\netcoreapp3.1
TID: 1
Created occurred in TID: 5
Created occurred in TID: 7
Deleted occurred in TID: 5
Deleted occurred in TID: 5

您看到的TID号码可能会在每次重新运行应用程序时发生变化:它们既不可预测,也不按相同顺序使用。

我们现在将看到如何创建一个新线程,同时执行一些代码,并检查线程的主要特征。

在.NET 中创建线程

创建原始线程在大多数情况下只有在有长时间运行的操作且仅依赖于 CPU 时才有意义。例如,假设我们想计算质数,而不真正关心可能的优化:

public class Primes : IEnumerable<long>
{
	public Primes(long Max = long.MaxValue)
	{
		this.Max = Max;
	}
	public long Max { get; private set; }
	IEnumerator IEnumerable.GetEnumerator() => ((IEnumerable<long>)this).GetEnumerator();
	public IEnumerator<long> GetEnumerator()
	{
		yield return 1;
		bool bFlag;
		long start = 2;
		while (start < Max)
		{
			bFlag = false;
			var number = start;
			for (int i = 2; i < number; i++)
			{
				if (number % i == 0)
				{
					bFlag = true;
					break;
				}
			}
			if (!bFlag)
			{
				yield return number;
			}
			start++;
		}
	}
}

Primes类实现了IEnumerable<long>,这样我们可以轻松枚举质数,Max参数用于限制结果序列,否则将受long.MaxValue的限制。

调用上述代码非常容易,但是由于计算可能需要很长时间,它会完全阻塞执行线程:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text;
using System.Threading;
// namespace and class declaration omitted for clarity
Console.WriteLine("Start primes");
foreach (var n in new Primes(1000000))   {  /* ...  */ }
Console.WriteLine("End primes"); // the wait is too long!

这里发生的情况是主线程正在忙于计算质数。由于抢占式多任务处理,这个线程将被操作系统调度程序中断,以便让其他进程的线程有机会运行它们的代码。然而,由于我们的应用程序没有其他线程执行应用程序代码,我们只能等待。

在任何桌面应用程序中,无论是控制台还是 GUI,用户体验都会很糟糕,因为鼠标和键盘的任何交互都会被阻塞。更糟糕的是,GUI 甚至无法重新绘制屏幕内容,因为唯一的线程被质数计算占用了。

第一步是将阻塞代码移到一个单独的方法中,这样我们就可以在一个新的独立线程中执行它:

private void Worker(object param)
{
    PrintThreadInfo(Thread.CurrentThread);
    foreach (var n in new Primes(1000000))
    {
        Thread.Sleep(100);
    }
    Console.WriteLine("Computation ended!");
}

Thread.Sleep方法仅用于观察 CPU 使用情况。然后,Sleep告诉操作系统暂停当前线程的执行一段时间,以毫秒为单位。通常,不建议在生产代码中调用Sleep,因为它会阻止线程被重用。在本章后面,我们将发现更好的方法来在我们的代码中插入延迟。

Worker方法没有什么特别之处,它可能会选择性地获取一个对象参数,该参数可用于初始化局部变量。我们不直接调用它,而是要求基础设施在新线程的上下文中调用它:

Console.WriteLine("Start primes");
PrintThreadInfo(Thread.CurrentThread);
var t1 = new Thread(Worker);
//t1.IsBackground = true; // try with/without this line
t1.Start();
Console.WriteLine("Primes calculation is happening in background");

从上述代码中可以看出,创建了Thread对象,但线程尚未启动。我们必须显式调用Start方法才能启动它。这很重要,因为Thread类还有其他重要的属性,只能在线程启动之前设置。

最后,使用PrintThreadInfo方法打印主线程的详细信息。请注意,有些属性并不总是可用。因此,我们必须在打印PriorityIsBackground之前检查线程是否正在运行。由于ThreadState枚举具有Flags属性,而Running状态为零,官方文档(https://docs.microsoft.com/en-us/dotnet/api/system.threading.threadstate?view=netframework-4.8#remarks)提醒我们要检查StoppedUnstarted位是否未设置:

private void PrintThreadInfo(Thread t)
{
    var sb = new StringBuilder();
    var state = t.ThreadState;
    sb.Append($"Id:{t.ManagedThreadId} Name:{t.Name} State:{state} ");
    if ((state & (ThreadState.Stopped | ThreadState.Unstarted)) == 0)
    {
        sb.Append($"Priority:{t.Priority} IsBackground:{t.IsBackground}");
    }
    Console.WriteLine(sb.ToString());
}

执行上述代码的结果如下:

Start primes
Id:1 Name: State:Running Priority:Normal IsBackground:False
Primes calculation is happening in background
Id:5 Name: State:Running Priority:Normal IsBackground:False

即使这是一个微不足道的例子,我们还是必须观察一些事情:

  • 首先,我们无法保证关于Primes calculation …Id:5 …行的输出顺序。它们可能以相反的顺序出现。为了获得确定性行为,您需要应用我们将在理解同步原语部分讨论的同步技术。

  • 另一个重要的考虑是CPU 使用率。如果你打开任务管理器,在性能选项卡下,你可以设置查看每个逻辑 CPU 的单独图表。在下面的截图中,你可以看到一个四核 CPU,有八个逻辑核心(多亏了英特尔超线程技术!)。你可能还想显示内核时间(以较深的颜色显示),因为内核模式只执行操作系统和驱动程序的代码,而用户模式(以较浅的颜色显示)只执行我们编写的代码。这种区别将使你立即看到哪个应用程序代码正在执行:

图 12.3 - 任务管理器显示所有逻辑处理器

图 12.3 - 任务管理器显示所有逻辑处理器

如果我们现在执行我们的代码而没有Sleep调用,我们会发现其中一个 CPU 将显示更高的 CPU 使用率,因为一个线程一直在消耗操作系统分配的全部执行时间。这个单个线程会影响总共(100%)CPU 时间的100% / 8 个 CPU = 12.5%。事实上,在计算过程中,任务管理器详细信息选项卡将显示你的进程大约消耗了 CPU 的 12%:

图 12.4 - 任务管理器显示分布在所有可用逻辑 CPU 上的执行时间

图 12.4 - 任务管理器显示分布在所有可用逻辑 CPU 上的执行时间

线程计算在多个逻辑 CPU 上分布。每当操作系统中断线程,安排另一个进程的其他工作,然后回到我们的线程时,线程可能在任何其他逻辑 CPU 上执行。

只是作为一个实验,你可以通过在Worker方法的开头添加以下代码来强制执行在特定的逻辑 CPU 上进行:

var threads = System.Diagnostics.Process.GetCurrentProcess().Threads;
var processThread = threads
    .OfType<System.Diagnostics.ProcessThread>()
    .Where(pt => pt.Id == GetCurrentThreadId())
    .Single();
processThread.ProcessorAffinity = (IntPtr)2; // CPU 2

这段代码需要在类内部进行以下声明:

[DllImport("Kernel32.dll")]
private static extern int GetCurrentThreadId();

这些新的代码行检索了我们进程的所有ProcessThread对象的列表,然后过滤出与正在执行的本机 ID 匹配的ProcessThread对象。

设置ProcessorAffinity后,新的执行将完全加载逻辑 CPU 2,如下面的截图所示(CPU 2的浅蓝色部分完全填满了矩形):

图 12.5 - 任务管理器显示 CPU 2 完全加载了示例代码的执行

图 12.5 - 任务管理器显示 CPU 2 完全加载了示例代码的执行

在启动线程之前,我们有可能通过设置一个或多个这些属性来塑造线程的特性:

  • Priority属性是由操作系统调度程序使用的,用于决定线程可以运行的时间段。给予它高优先级将减少线程挂起的时间。

  • Name属性在调试时很有用,因为你可以在 Visual Studio 线程窗口中看到它。

  • 我们简要讨论了ThreadState属性,它可以有许多不同的值。其中之一——WaitSleepJoin——代表一个正在Wait方法中或正在睡眠的线程。

  • CurrentCultureCurrentUICulture属性由某些依赖于区域的 API 读取。例如,当你将数字或日期转换为字符串(使用ToString方法)或使用相反的转换的Parse静态方法时,当前的区域设置将被使用。

  • IsBackground属性指定线程是否应该在仍然活动时阻止进程终止。当为 true 时,进程将不会等待线程完成工作。在我们的示例中,如果你将其设置为 true,那么你可以通过按任意键来结束进程。

你可能已经注意到Thread类有Abort方法。它不应该被使用,因为它可能会破坏内存状态或阻止托管资源的正确处理。

终止线程的正确方法是从最初启动的方法中正常退出。在我们的情况下,这是Worker方法。你只需要一个简单的return语句。

我们已经看到了如何手动创建线程,但还有一种更方便的方法可以在单独的线程中运行一些代码——ThreadPool类。

使用 ThreadPool 类

我们花了一些时间研究线程的特性,这确实非常有用,因为线程是基本的代码执行构建块。手动创建线程是正确的,只要它执行与 CPU 相关且运行时间长的代码。无论如何,由于线程的成本取决于操作系统,因此最好创建适量的线程并重用它们。它们的数量非常依赖于可用的逻辑 CPU 和其他因素,这就是为什么最好使用ThreadPool抽象的原因。

静态的ThreadPool类提供了一个线程池,可以用来运行一些并发计算。一旦代码终止,线程就会回到池中,可以在不需要销毁和重新创建的情况下,为将来的操作提供可用性。

提示

请注意不要修改从ThreadPool中选择的线程的任何属性。例如,如果修改了ProcessorAffinity,即使线程被重用于不同的目的,此设置仍将有效。如果需要修改线程的属性,手动创建仍然是最佳选择。

使用ThreadPool类运行我们的Worker非常简单:

Console.WriteLine("Start primes");
PrintThreadInfo(Thread.CurrentThread);
ThreadPool.QueueUserWorkItem(Worker);
Console.WriteLine("Primes calculation is happening in background");

请注意,Thread类构造函数和QueueUserWorkItem接受的委托参数是不同的,但接受对象参数的委托对两者都兼容。

我们已经看到了如何启动并行计算,但我们仍然无法编排它们的执行。如果算法应在不同的线程上运行,我们需要知道它的终止以及如何访问结果。

提示

ThreadPool被许多流行的库使用,包括随.NET 运行时一起提供的基类库。每当需要访问需要 I/O 操作的资源,而这些操作可能需要一段时间才能成功或失败时,大多数情况下会使用ThreadPool。这些资源包括数据库、文件系统对象或可以通过网络访问的任何资源。

每当需要并发访问资源时,无论是通过 I/O 操作检索的资源还是内存中的对象实例,都可能需要同步其访问。在下一节中,我们将看到如何同步线程执行。

理解同步原语

每当编写单线程代码时,任何方法执行都是顺序进行的,开发人员无需采取特殊操作。另一方面,当一些代码在单独的线程上执行时,需要同步以确保避免两种危险的并发条件——竞争死锁。这些问题的类别在设计时必须小心避免,因为它们的检测很困难,而且可能偶尔发生。

竞争条件是指两个或多个线程访问未受保护的共享资源,或者线程的执行根据时间和底层进程架构的不同而表现不同的情况。

死锁条件发生在两个或多个线程之间存在循环依赖以访问资源的情况。

编写可能从多个线程执行的代码时,一般建议如下:

  • 尽量避免共享资源。它们的访问必须通过锁进行同步,这会影响执行性能。

  • 栈是你的朋友。每当调用一个方法时,局部栈是私有的,确保局部变量不会与其他调用者和线程共享。

  • 每当您需要在多个线程之间共享资源时,请使用文档验证它是否是线程安全的。每当它不是线程安全的时候,锁必须保护资源或代码序列。

  • 即使共享资源是线程安全的,您也必须考虑是否需要原子地执行一些语句,以保证它们的可靠性。

线程库有许多可用于保护资源的原语,但我们将更多地关注那些更有可能在异步上下文中使用的原语,这是本章将涵盖的最重要的主题。

有两组同步原语:

  • 由操作系统在内核模式中实现的原语

  • 由.NET 类库提供的用户模式中的同步原语

这种区别非常重要,因为每当您通过系统调用转换到内核模式时,操作系统都必须保存本地调用和堆栈,这将在操作的性能上产生影响。内核模式原语的优势在于能够为它们命名并使它们跨进程共享,提供强大的机器级同步机制。

以下示例显示了来自ThreadPool的两个线程打印PingPong。每个线程通过等待匹配的ManualResetEventSlim来与另一个线程同步:

public void PingPong()
{
    bool quit = false;
    var ping = new ManualResetEventSlim(false);
    var pong = new ManualResetEventSlim(false);
    ThreadPool.QueueUserWorkItem(_ =>
    {
        Console.WriteLine($"Ping thread: {Thread.CurrentThread.ManagedThreadId}");
        while (!quit)
        {
            pong.Wait();
            pong.Reset();
            Console.WriteLine("Ping");
            Thread.Sleep(1000);
            ping.Set();
        }
    });
    ThreadPool.QueueUserWorkItem(_ =>
    {
        Console.WriteLine($"Pong thread: {Thread.CurrentThread.ManagedThreadId}");
        while (!quit)
        {
            ping.Wait();
            ping.Reset();
            Console.WriteLine("Pong");
            Thread.Sleep(1000);
            pong.Set();
        }
    });
    pong.Set();
    Console.ReadKey();
    quit = true;
}

创建了两个事件之后,两个线程被运行并打印它们正在运行的线程的 ID。在这些线程内部,每次执行都会在Wait方法中暂停,这样可以避免线程消耗任何 CPU 资源。在清单的末尾,pong.Set方法启动游戏并解除第一个线程的阻塞。由于事件是手动的,它们必须被重置为未发信号状态以供下一次使用。此时,会打印一条消息,延迟模拟一些艰苦的工作,最后,另一个事件被发信号,这将导致第二个线程解除阻塞。

或者,我们可以使用ManualResetEvent内核事件,其使用方法非常相似。例如,它具有WaitOne方法,而不是Wait。但是,如果我们在高性能同步算法中使用这些事件,将会有很大的差异。以下表格显示了使用流行的 Benchmark.NET 微基准库测量的两种同步原语的比较。这两个测试只是调用Set(),然后调用Reset()方法:

|          Method |        Mean |     Error |    StdDev |
|---------------- |------------:|----------:|----------:|
| KernelModeEvent | 1,892.11 ns | 24.463 ns | 22.883 ns |
|   UserModeEvent |    25.67 ns |  0.320 ns |  0.283 ns |

这两者之间存在大约两个数量级的差异,这绝对不可忽视。

除了能够使用内核事件来同步在不同进程中运行的代码之外,它们还可以与强大的WaitHandle.WaitAnyWaitAll方法结合使用,如下例所示:

public void WaitMultiple()
{
    var one = new ManualResetEvent(false);
    var two = new ManualResetEvent(false);
    ThreadPool.QueueUserWorkItem(_ =>
    {
        Thread.Sleep(3000);
        one.Set();
    });
    ThreadPool.QueueUserWorkItem(_ =>
    {
        Thread.Sleep(2000);
        two.Set();
    });
    int signaled = WaitHandle.WaitAny(
        new WaitHandle[] { one, two }, 500);
    switch(signaled)
    {
        case 0:
            Console.WriteLine("One was set");
            break;
        case 1:
            Console.WriteLine("Two was set");
            break;
        case WaitHandle.WaitTimeout:
            Console.WriteLine("Time expired");
            break;
    }
}

您可以通过以毫秒为单位表示的三个超时时间来查看不同的结果。主要思想是尽快退出等待,只要任何事件或超时到期,以先到者为准。

提示

Windows 操作系统的内核对象可以在等待原语中全部使用。例如,如果您想等待多个进程退出,您可以使用前面代码块中显示的WaitHandle原语与进程句柄一起使用。

我们只是刚刚触及了表面,但官方文档中有许多示例展示了各种同步对象的使用。相反,我们将继续专注于对本书更为相关的内容,例如从多个线程访问共享资源。

在以下示例中,我们有一个名为_shared的共享变量,一个用于同时启动所有线程的ManualResetEvent对象,以及一个简单的对象。Shared属性利用Thread.Sleep,在 setter 上引起了显式的线程上下文切换。当操作系统调度程序在系统中将控制权预先交给另一个线程时,这种切换通常会发生。这不是一个技巧;它只是增加了 getter 和 setter 不会被每个线程连续执行的概率:

int _shared;
int Shared
{
    get => _shared;
    set { Thread.Sleep(1); _shared = value; }
}
ManualResetEvent evt = new ManualResetEvent(false);
object sync = new object();

以下方法将共享变量初始化为0并创建 10 个线程,所有线程都执行相同的 lambda 中的代码:

public void SharedResource()
{
    Shared = 0;
    var loop = 100;
    var threads = new List<Thread>();
    for (int i = 0; i < loop; i++)
    {
        var t = new Thread(() =>
        {
            evt.WaitOne();
            //lock (sync)
            {
                Shared++;
            }
        });
        t.Start();
        threads.Add(t);
    }
    evt.Set(); // make all threads start together
    foreach (var t in threads)
        t.Join();   // wait for the thread to finish
    Console.WriteLine($"actual:{Shared}, expected:{loop}");
}

所有线程立即启动并阻塞在WaitOne事件中,该事件由Set方法解除阻塞。这为许多线程以相同的时间执行 lambda 中的代码提供了更多机会。最后,我们调用Join等待每个线程的执行结束并打印结果。

这段代码的同步问题存在于线程将读取一个值,将数字增加到 CPU 寄存器中,并将结果写回变量。由于许多线程将读取相同的值,写回变量的值是旧的,其真实的当前值丢失了。

通过取消注释锁定语句,我们指示编译器用关键部分包围大括号中的语句,这是最快的用户模式同步对象。这将导致对该代码的访问进行序列化,对性能产生非常显著的影响,这是必要且不可避免的。

我们在开始时创建的空对象实例不应更改;否则,不同的线程将等待不同的临界区。请注意,lock参数可以是任何引用类型。例如,如果您需要保护一个集合,可以直接锁定它,而无需外部对象的帮助。无论如何,在我们的示例中,Shared是一个值类型,必须借助一个单独的引用类型来保护它。

如果您用一个简单的字段替换Shared属性,问题发生的可能性将会降低。此外,编译器配置(调试与发布)将产生很大的差异,因为内联和其他优化使得在访问字段或简单属性时更有可能发生线程上下文切换。物理硬件配置和 CPU 架构是可能会极大影响这些测试结果的其他变量。

提示

单元测试不适合确保不存在竞争条件或死锁等问题。此外,请注意,虚拟机是最不适合测试并发代码的环境,因为调度程序比在物理硬件上运行的操作系统更可预测。

我们已经看到了如何确保一系列语句被原子地执行,没有干扰。但如果只是为了确保底层_shared字段的原子增量,有一个更方便的工具——Interlocked类。

Interlocked是一个静态类,公开了一些有用的方法来确保某些操作的原子性。例如,我们可以使用以下代码而不是lock语句,这样做会更快,即使只限于Interlocked公开的操作。以下代码显示了如何原子地增加_shared变量:

Interlocked.Increment(ref _shared);

除其他事项外,我们可以用它来原子地写入变量并获取旧值(Exchange方法),或者读取大小大于可用本机寄存器的变量(Read方法)。

我们已经看到了为什么需要同步以及我们可以用来防止这些并发访问问题的主要工具。但现在,是时候引入一个抽象,这将使每个开发人员的生活更轻松——任务范式。

任务范式

并发主要是关于设计具有非常松散耦合的工作单元的算法,这通常是不可能的,或者会使复杂性超出任何可能的好处。

异步编程与操作系统和设备的异步性相关,无论是因为它们触发事件还是因为完成所请求的操作需要时间。每当用户移动鼠标、在键盘上输入键或从互联网检索一些数据时,操作系统都会在一个单独的线程中向我们的进程呈现数据,我们的代码必须准备好消费它。

最简单的例子之一是从磁盘加载文本文件并计算字符串长度,这可能与文件长度不同,这取决于编码:

public int ReadLength(string filename)
{
    string content = File.ReadAllText(filename);
    return content.Length;
}

一旦调用此方法,调用线程将被阻塞,直到操作系统和库完成读取。该操作可能非常快速,也可能非常缓慢,这取决于其大小和技术。文本文件可能位于网络附加存储(NAS)、本地磁盘、损坏的 USB 键或通过虚拟专用网络(VPN)访问的远程服务器上。

在桌面应用程序的上下文中,任何阻塞线程都会导致不愉快的用户体验,因为主线程已经负责重绘用户界面并响应来自输入设备的事件。

服务器应用程序也不例外,因为任何阻塞线程都是一种资源,无法有效地与其他请求一起使用,从而阻止应用程序扩展并为其他用户提供服务。

几十年来,解决这个问题的方法是通过手动创建一个单独的线程来执行长时间运行的代码,但是最近,.NET 运行时引入了任务范式,C#语言引入了asyncawait关键字。从那时起,整个.NET 库已经进行了修订,以拥抱这种范式,提供返回基于任务的操作的方法。

任务库,位于System.Threading.Tasks命名空间中,以及语言集成提供了一个抽象,大大简化了异步操作的管理。任务代表了执行明确定义的工作单元。无论您处理并发性还是异步事件,任务都定义了给定的工作及其生命周期,从创建到完成,其选项包括成功、失败或取消。

通过定义其他任务应该在给定操作之后立即执行来组合任务。这个链接的任务称为延续,并且通过任务调度程序从库中自动安排。

默认情况下,任务库提供了一个默认实现(TaskScheduler.Default静态属性),大多数开发人员永远不需要深入研究。默认实现使用ThreadPool来编排任务的执行,并使用工作窃取技术将任务队列重新分配到多个线程上,以提供负载平衡,并防止任务被阻塞太长时间。请注意,这个默认实现足够聪明,最终会决定直接在主线程上安排任务的执行,而不是从池中选择一个。勇敢的人可以尝试创建自定义调度程序来改变调度策略,但这并不是很多开发人员真正需要做的事情。

稍后,在同步上下文部分,我们将讨论同步上下文,它允许延续在调用线程中执行,并避免使用前一节中描述的同步原语的需要。

让我们从读取文本文件的异步版本开始研究任务:

Task<string> content = File.ReadAllTextAsync(filename);

这个方法的新版本立即完成,而不是返回文件的内容,而是返回表示正在进行操作的对象。

由于我们刚刚启动了尚未完成的操作,管理完成所需的步骤如下:

  1. 将异步操作后面的代码(获取字符串长度)重构为一个单独的方法。这个方法相当于旧式的回调,不能在异步操作完成之前调用。

  2. 监视正在进行的任务,并在完成或失败时提供通知。

  3. 完成后,检索结果并在主线程上同步执行(通过同步上下文),或者如果出现问题则抛出异常。如果我们不想搞乱潜在的竞争条件,这一步是至关重要的。

  4. 调用我们在第一个点重构出来的回调。

当然,我们不必手动管理所有这些机制。任务库的第一个有趣的优势是它支持继续,这允许开发人员指定任务成功完成后要执行的代码:

public Task<int> ReadLengthAsync(string filename)
{
    Task<int> lengthTask = File.ReadAllTextAsync(filename)
        .ContinueWith(t => t.Result.Length);
    return lengthTask;
}

这个新版本比创建线程和手动编写同步代码要好,即使它还可以进一步改进。ContinueWith方法包含了确定其他代码在文件成功读取后立即执行的代码。

t变量包含任务,该任务要么失败,要么成功完成。如果成功,t.Result包含从ReadAllTextAsync方法获取的字符串内容。

无论如何,我们仍然没有长度;我们只是表达了如何在将来检索ReadAllTextAsync的结果后检索长度。这就是为什么lengthTask变量是Task<int>,即整数的承诺。

我强烈建议尝试使用任务和继续,因为有时它们需要直接管理。

但 C#语言还引入了两个宝贵的关键字,进一步简化了我们需要编写的代码。await关键字用于指示操作的结果以及其后的所有内容都是一个继续的一部分。

由于await关键字,编译器重构并生成新的中间语言IL)代码,以提供适当的异步操作和继续的管理。最终的代码以异步方式加载文件内容并返回字符串长度如下:

public async Task<int> ReadLengthAsync(string filename)
{
    string content = await File.ReadAllTextAsync(filename);
    return content.Length;
}

编译器重构的代码部分不仅仅是一个继续。编译器生成一个来负责监视任务进度的状态机,并生成一个调用适当代码或抛出异常的方法,一旦任务状态发生变化。

提示

如果您想深入了解生成的代码的更多细节,可以使用ILSpy工具(https://github.com/icsharpcode/ILSpy/releases)并查看生成的 IL 代码。

显然,编译器可以摆脱承诺,让我们处理返回的内容,对吗?实际上不是 - 这段代码被重构了,我们编写的代码是表达我们的期望,而不是方法中通常和顺序发生的事情。

事实上,前面的代码看起来矛盾,因为content.Length整数只会在将来可用,但我们直接从返回类型为Task<int>的方法中返回它。

这就是async关键字发挥作用的地方:

  • async关键字是一个修饰符,每次我们想在方法内部使用await时都必须指定。

  • async关键字告诉我们,return语句指定了一个未来的对象或值。在我们的情况下,我们返回int,但async告诉我们它实际上是一个Task<int>

  • 如果一个async方法返回void,返回类型变成了非泛型的Task

我们现在有一个异步处理文件的方法,但我们不得不将签名从int改为Task<int>

当您在 lambda 中使用await关键字时,也需要使用async关键字。例如,让我们看一下以下代码:

Func<int, int, Task<int>> adder = 
    async (a, b) => await AddAsync(a, b);

在方法上使用async意味着所有调用者也必须采用任务范式,否则他们可能无法知道操作何时完成。

异步方法的同步实现

我们已经看到了任务范例如何影响方法签名,我们知道方法签名有多重要。当它出现在公共 API 或接口中时,它是一个合同,大多数情况下我们不能更改。从设计的角度来看,对于预期可能使用任务实现的方法的可能性,这可能非常有价值,但也有一些不需要异步性的情况。

对于这些情况,Task类公开了一个静态方法,允许我们直接构建一个带有或不带结果的已完成任务。在下面的示例中,异步方法同步返回一个已完成的任务:

public Task WriteEmptyJsonObjectAsync(string filename)
{
    File.WriteAllText(filename, "{}");
    return Task.CompletedTask;
}

CompletedTask属性仅为整个应用程序域创建一次;因此,它非常轻量级,不应引起性能方面的担忧。

如果需要返回一个值,我们可以使用静态的FromResult方法,它在每次调用时内部创建一个新的已完成Task

public Task<int> AddAsync(int a, int b)
{
    return Task.FromResult(a + b);
}

每次我们添加两个数字时创建一个对象绝对是性能问题,因为它直接影响垃圾收集器需要做的工作量。因此,最近,微软引入了ValueTask类。

偶尔的异步方法

ValueTask不可变结构是对同步结果或Task的便捷包装。这种进一步的抽象旨在简化那些需要方法具有异步签名,但其实现只是偶尔异步的情况。

我们在上一节中使用任务定义的AddAsync方法可以很容易地转换为使用ValueTask结构:

public ValueTask<int> AddAsync(int a, int b)
{
    return new ValueTask<int>(a + b);
}

对于微不足道的总和使用Task的开销是明显的;因此,每当在热路径(一些性能关键代码)中应该调用这样的方法时,肯定会引起性能问题。

无论如何,有些情况下,您可能需要将ValueTask转换为Task,以便从本章剩余部分讨论的所有实用工具中受益。转换可通过AsTask方法实现,该方法返回包装的任务(如果有),或者如果没有,则创建一个全新的Task

中断任务链 - 阻塞线程

给定一个任务,如果调用Wait方法或访问Result获取器属性,它们将阻塞线程执行,直到任务完成或取消。任务范例背后的理念是避免阻塞线程,以便它们可以被重用于其他目的。但是阻塞也可能引发非常严重的副作用。

由于异步编程的默认线程来源是ThreadPool(如果耗尽其线程),任何进一步的请求都将自动阻塞。这种现象被称为线程饥饿

一般建议是避免等待,而是使用await关键字或延续来完成一些工作。

手动创建任务

有时库不提供异步行为,但您不希望保持当前线程忙碌太长时间。在这种情况下,您可以使用Task.Run方法,该方法安排执行 lambda,这很可能会发生在一个单独的线程中。下面的示例展示了如何读取文件的长度,如果我们之前使用的异步ReadAllTextAsync方法不可用:

public Task<int> ReadLengthAsync(string filename)
{
    return Task.Run<int>(() =>
    {
        var content = File.ReadAllText(filename);
        return content.Length;
    });
}

您应该始终优先使用提供的异步版本,而不是使用Run方法,因为安排此任务的线程将一直阻塞,直到同步执行结束。

现在,我们将看看在任务内部有大量工作要做时,采取的最佳行动方案是什么。

长时间运行的任务

即使您不阻塞线程,当异步堆栈从不等待并成为长时间运行的作业时,仍然存在饥饿的风险,使线程保持忙碌。

这些情况可以用两种不同的策略来处理:

  • 第一种是手动“创建线程”,这是我们在本章开头已经讨论过的。当你需要更多控制或需要修改线程属性时,这是最好的策略。

  • 第二种可能性是通知任务调度程序任务将要运行很长时间。这样,调度程序将采取不同的策略,完全避免ThreadPool。以下代码显示了如何运行一个长时间运行的任务:

var t = new Task(() => Thread.Sleep(30000),
    TaskCreationOptions.LongRunning);
t.Start();

基本建议是尝试将长时间的工作拆分成可以轻松转换为任务的较小工作单元。

打破任务链 - 火而忘

我们已经看到,拥抱任务范式需要修改整个调用链。但有时这是不可能的,也不可取。例如,在桌面 WPF 应用程序的上下文中,您可能需要在按钮点击事件处理程序中写入文件:

void Button_Click(object sender, RoutedEventArgs e) { ... }

我们不能改变它的签名来返回一个Task;而且,出于两个原因,这也没有意义:

  • 调用库在任务之前设计过,它将无法管理任务的进度。

  • 这是设计为火而忘操作之一,意味着你并不真的在乎它们会花多长时间或者它们将计算出什么结果。

对于这些情况,你可以拥抱async/await关键字,同时根本不使用返回的Task

async void Button_Click(object sender, RoutedEventArgs e)
{
    await File.WriteAllTextAsync("log.txt", "something");
    // ... other code
}

但请记住,当你打破任务链时,你失去了知道操作是否会完成或失败的可能性。

信息框

每当你在你的代码中看到async void时,你应该想知道它是否可能是一个潜在的错误,或者只是你真的不想知道最终会发生什么。多年来,使用async void而不是async Task的习惯一直是异步代码中错误的主要来源。

同样,如果你只是调用一个异步方法而不等待它(或使用ContinueWith方法之一),你将失去对调用的控制,获得相同的火而忘行为,因为异步方法在启动异步操作后立即返回。此外,不等待异步操作之后的所有代码将同时执行,存在竞争条件或访问尚不可用的数据的风险:

void Button_Click(object sender, RoutedEventArgs e)
{
    File.WriteAllTextAsync("log.txt", "something");
}

我们已经看到了当一切顺利完成时管理异步操作是多么简单,但代码可能会抛出异常,我们需要适当地捕获它们。

任务和异常

当出现问题时,有两种异常可能发生。第一种是在调用任何异步方法之前发生的,而第二种与异步代码中发生的异常有关。

以下示例展示了这两种情况:

public Task<int> CrashBeforeAsync()
{
    throw new Exception("Boom");
}
public Task<int> CrashAfterAsync()
{
    return Task.FromResult(0)
        .ContinueWith<int>(t => throw new Exception("Boom"));
}

在第一种情况下,我们告诉调用者我们将返回一个Task<int>,但还没有开始任何异步操作。这种情况与同步方法中发生的情况完全相同,可以相应地捕获:

public Task<int> HandleCrashBeforeAsync()
{
    Task<int> resultTask;
    try
    {
        resultTask = CrashBeforeAsync();
    }
    catch (Exception) { throw; }
    return resultTask;
}

另一方面,如果异常发生在继续执行中,异常不会立即发生;它只会在任务被“消耗”时发生:

public async Task<int> HandleCrashAfterAsync()
{
    Task<int> resultTask = CrashAfterAsync();
    int result;
    try
    {
        result = await resultTask;
    }
    catch (Exception) { throw; }
    return result;
}

一旦resultTask完成为故障,异常已经发生,但是编译器生成的代码捕获了它并将其分配给Task.Exception属性。由于在Task内可能同时发生多个异常,生成的代码将所有捕获的异常封装在单个AggregateException中。AggregateException中的InnerExceptionInnerExceptions属性包含原始异常。

每当你想要处理异常并立即解决它们时,你可能希望使用继续而不是await关键字:

public Task<int> HandleCrashAfter2Async()
{
    Task<int> resultTask = CrashAfterAsync();
    try
    {
        return resultTask.ContinueWith<int>(t =>
        {
           if (t.IsCompletedSuccessfully) return t.Result;
           if(t.Exception.InnerException is OverflowException)
               return -1;
           throw t.Exception.InnerException;
        });                
    }
    catch (Exception) { throw; }
}

正如我们之前提到的,在faulted任务中的异常会在结果被消耗时立即抛出,我们之前在使用await的情况下提到过。然而,当访问t.Result属性时,这也可能发生。

提示

Task类公开了GetAwaiter方法,该方法返回表示异步操作的内部结构。你可以使用task.GetAwaiter().GetResult()来获取异步操作的结果,以及task.Result,但两者有一点不同。实际上,在发生异常时,前者返回原始异常,而后者返回包含原始异常的AggregateException

最后,值得一提的是,我们可以使用静态的Task.FromException<T>方法来重写CrashAfterAsync方法:

public Task<int> CrashAfterAsync() =>
    Task.FromException<int>(new Exception("Boom"));

与我们在FromResult<T>中看到的类似,创建了一个新的Task,但这次,它的状态被初始化为faulted,并包含所需的异常。

前面的例子相当抽象,但足够简洁,让你了解如何根据抛出异常的时间来正确处理异常。有许多常见的情况会发生这种情况。这种二元性的一个真实例子是,在准备 JSON 参数时发生序列化异常,或者在 HTTP rest 调用期间由于网络故障而发生异常。

除了转换为故障状态,任务也可以被取消,这要归功于任务范例提供的内置标准机制。

取消任务

与故障不同,取消是由调用者请求来中断一个或多个任务的执行。取消可以是强制性的,也可以是超时,当给定任务不应该花费超过一定时间时,这是非常有用的。

从调用者的角度来看,取消模式源自CancellationTokenSource类,它提供了三种不同的构造函数:

  • 当你愿意通过强制调用Cancel方法来取消任务时,使用默认构造函数。

  • 其他构造函数接受intTimeSpan,它们确定在触发取消之前的最长时间,除非任务在此之前完成。

在下面的例子中,我们将使用从定时CancellationTokenSource获得的CancellationToken来取消三个工作方法中的一个:

public async Task CancellingTask()
{
    CancellationTokenSource cts2 = new
        CancellationTokenSource(TimeSpan.FromSeconds(2));
    var tok2 = cts2.Token;
    try
    {
        await WorkForever1Async(tok2);
        //await WorkForever2Async(tok2);
        //await WorkForever3Async(tok2);
        Console.WriteLine("let's continue");
    }
    catch (TaskCanceledException err)
    {
        Console.WriteLine(err.Message);
    }
}

Token属性返回一个只读结构,可以被多个消费者使用,而不会影响垃圾收集器,甚至不会被复制,因为它是不可变的。

这里正在检查的第一个消费者接受CancellationToken,并将其正确传播给任何其他接受取消的方法。在我们的例子中,只有Task.Delay,这是一个非常方便的方法,用于指示基础设施在 5 秒后触发继续执行:

public async Task WorkForever1Async(
    CancellationToken ct = default(CancellationToken))
{
    while (true)
    {
        await Task.Delay(5000, ct);
    }
}

前面代码的执行结果是任务被取消,这通过从await关键字生成的代码转换为TaskCanceledException

A task was canceled.

另一种可能性是,当工作程序只执行同步代码并且仍然需要被取消时:

public Task WorkForever2Async(
    CancellationToken ct = default(CancellationToken))
{
    while (true)
    {
        Thread.Sleep(5000);
        if (ct.IsCancellationRequested)
            return Task.FromCanceled(ct);
    }
}

请注意使用Thread.Sleep而不是Delay方法,这是因为我们需要同步实现。

Thread.Sleep方法非常不同,因为它完全阻塞线程,并防止线程在其他任何地方被重用,而Task.Delay会生成一个请求,在指定的时间过去后立即调用以下代码作为继续执行。

更有趣的部分是测试IsCancellationRequested布尔属性,以允许协作取消任务。通过显式检查该属性来进行协作是必要的,因为在释放某些资源之前,你可能不需要中断执行,无论是在数据库上还是其他地方。

再次执行前面的方法的结果将如下:

A task was canceled.

第三种情况是当你不想抛出任何异常,而只是从执行中返回:

public async Task WorkForever3Async(
    CancellationToken ct = default(CancellationToken))
{
    while (true)
    {
        await Task.Delay(5000);
        if (ct.IsCancellationRequested) return;
    }
}

在这种情况下,我们小心地避免将CancellationToken传播到底层调用,因为使用await会触发异常。

这个最终的WorkForever3Async方法的执行不会引发任何异常,并让执行继续正常进行:

let's continue

这种实现的缺点是取消可能不会立即发生。Task.Delay将需要完成,而不管取消,这在最坏的情况下可能在 5 秒之前无法发生。

我们已经看到任务范式如何使运行异步操作变得极其容易,但我们如何同时运行多个异步请求呢?它们可能会并行运行,以避免无用的等待。

监视任务的进度

用户开始长时间运行操作后,提供反馈非常重要,以避免用户变得沮丧。当你控制正在发生的事情时,比如一些耗时的算法,这是可能的。然而,当长时间运行的操作依赖于对外部库的调用时,监视进度是不可能的。

任务库没有专门支持监视进度,但.NET 库提供了IProgress<T>,可以轻松实现这一目标。这个接口只提供一个成员——void Report(T value)——这给了实现细节完全的自由。在最简单的情况下,T将是一个表示进度的整数值,表示为百分比。

例如,加载操作可以实现如下:

public async Task Load(IProgress<int> progress = null)
{
    var steps = 30;
    for (int i = 0; i < steps; i++)
    {
        await Task.Delay(300);
        progress?.Report((i + 1) * 100 / steps);
    }
}

在我们的情况下,这个方法通过调用Task.Delay来模拟异步操作,必须预测与进度的 100%相关的总步数。在每一步之后,调用Report方法来通知我们当前的百分比,但要确保代码受到保护,以防进度为空,因为消费者可能对接收这样的反馈不感兴趣。

在消费者端,首先要做的是创建进度提供程序,这只是一个实现IProgress<int>的类:

public class ConsoleProgress : IProgress<int>
{
    void IProgress<int>.Report(int value) =>
        Console.Write($"{value}%  ");
}

最后,调用者只需将提供程序实例传递给Load方法:

await test.Load(new ConsoleProgress());

正如你所期望的那样,输出如下:

3%  6%  10%  13%  16%  20%  23%  26%  30%  33%  36%  40%  43%  46%  50%  53%  56%  60%  63%  66%  70%  73%  76%  80%  83%  86%  90%  93%  96%  100%

IProgress<T>的通用参数可能被用来暂停执行或触发更复杂的逻辑,比如暂停/恢复行为。

并行化任务

一个常见的编程任务是从互联网上检索一些资源。例如,通过 HTTP 下载资源的基本代码如下:

public async Task<byte[]> GetResourceAsync(string uri)
{
    using var client = new HttpClient();
    using var response = await client.GetAsync(uri);
    response.EnsureSuccessStatusCode();
    return await response.Content.ReadAsByteArrayAsync();
}

由于EnsureSuccessStatusCode,任何失败都会触发异常,将捕获的责任留给调用者。此外,我们甚至没有设置任何标头,但对我们的目的来说已经足够了。

我们已经知道如何调用这个异步方法来下载图像,但现在的挑战是选择正确的策略来下载许多图像:

  • 第一个问题是:我们如何并行下载多个图像? 如果我们需要下载 10 张图像,我们不想将下载每张图像所需的时间相加。无论如何,我们不会讨论如果我们需要下载数百万张图像时可以扩展多少。这超出了关于异步机制的讨论范围。

  • 第二个问题是:我们需要同时使用它们吗? 在这种情况下,我们可以使用Task.WhenAll辅助方法,它接受一个任务数组,并返回一个表示整体操作的单个任务。

对于这些示例,我们将使用名为Lorem PicSumpicsum.photos/)的在线免费服务。每次你向代码中看到的 URI 发出请求时,都会检索到一个新的不同大小为 200 x 200 的图像。当然,你可以使用你选择的任何 URI:

public async Task NeedAll()
{
    var uri = "https://picsum.photos/200";
    Task<byte[]>[] tasks = Enumerable.Range(0, 10)
        .Select(_ => GetResourceAsync(uri))
        .ToArray();
    Task allTask = Task.WhenAll(tasks);
    try
    {
        await allTask;
    }
    catch (Exception)
    {
        Console.WriteLine("One or more downloads failed");
    }
    foreach (var completedTask in tasks)
        Console.WriteLine(
            $"New image: {completedTask.Result.Length}");
}

使用Enumerable.Range是一种很好的方式,可以重复执行给定次数的操作。实际上,我们并不关心生成的数字;事实上,我们在Select方法中使用了discard (_)标记而不是变量。

Select lambda 只是启动下载操作,返回相应的任务,我们还没有等待。相反,我们要求WhenAll方法创建一个新的Task,一旦所有任务都成功完成,就会发出信号。如果任何任务失败,从await关键字生成的代码将导致抛出异常。

WhenAll方法获得的任务不能用于检索结果,但它保证我们可以访问所有任务的Result属性。因此,在等待allTask之后,我们迭代tasks数组,检索所有已下载图像的byte[]数组。以下是同时等待所有下载的输出:

New image: 6909
New image: 3846
New image: 8413
New image: 9000
New image: 7057
New image: 8565
New image: 6617
New image: 8720
New image: 4107
New image: 6763

在许多情况下,这是一个很好的策略,因为我们可能需要在继续之前获取所有资源。另一种选择是等待第一次下载,这样我们就可以开始处理它,但我们仍然希望同时下载它们以节省时间。

这种替代策略可以借助WaitAny方法来实现。在下面的示例中,开始下载没有什么不同。我们只是添加了一个Stopwatch类,以显示下载结束时花费的毫秒数:

public async Task NeedAny()
{
    var sw = new Stopwatch();
    sw.Start();
    var uri = "https://picsum.photos/200";
    Task<byte[]>[] tasks = Enumerable.Range(0, 10)
        .Select(_ => GetResourceAsync(uri))
        .ToArray();
    while (tasks.Length > 0)
    {
        await Task.WhenAny(tasks);
        var elapsed = sw.ElapsedMilliseconds;
        var completed = tasks.Where(t => t.IsCompleted).ToArray();
        foreach (var completedTask in completed)
            Console.WriteLine($"{elapsed} New image: {completedTask.Result.Length}");
        tasks = tasks.Where(t => !t.IsCompletedSuccessfully).ToArray();
    }
}

while循环用于处理所有未完成的任务。最初,tasks数组包含所有任务,但每次WhenAny完成时,至少一个任务已完成。已完成的任务立即在屏幕上打印出来,并显示自操作开始以来经过的毫秒数。其他任务被重新分配给tasks变量,这样我们就可以循环回去处理已完成的任务,直到最后一个任务。这种新方法的输出如下:

368 New image: 9915
368 New image: 6032
419 New image: 6486
452 New image: 9810
471 New image: 7030
514 New image: 10009
514 New image: 10660
593 New image: 6871
658 New image: 2738
12850 New image: 6072
The last image took a lot of time to download, probably because the online service throttles the requests. Using WhenAll, we would have to wait about 13 seconds before getting them all. Instead, we could start processing as soon as each image was available.

当然,你可以将这两种方法结合起来。例如,如果你想在不超过 100 毫秒的时间内尽可能多地获取已下载的图像,只需用以下一行替换WhenAny行:

await Task.WhenAll(Task.Delay(100), Task.WhenAny(tasks));

换句话说,我们要求等待任何任务(至少一个),但不超过 100 毫秒。while循环将重复操作,就像我们之前所做的那样,消耗所有剩余的任务:

345 New image: 8416
345 New image: 7315
345 New image: 8237
345 New image: 6391
345 New image: 5477
457 New image: 9592
457 New image: 3922
457 New image: 8870
563 New image: 3695

在测试这些代码片段时,一定要在循环中运行它们,因为第一次运行可能会受到即时编译器的严重影响。

我们已经看到Task类提供了一个非常强大的构建块来消耗异步操作,但这需要提供异步行为的库。在下一节中,我们将看到如何暴露手动任务并触发其完成。

使用TaskCompletionSource对象发出任务信号

回到本章开头什么是线程?部分的文件监视器示例中,你可能还记得FileSystemWatcher暴露了事件,而没有采用任务范例。你可能会想知道我们是否编写了某种适配器来利用任务库提供的所有好工具的能力,答案是

TaskCompletionSource对象提供了一个重要的构建块,我们可以用它来暴露异步行为。它在生产者端创建和使用,以信号操作的完成,无论是成功还是失败。它通过Task属性提供了任务对象,客户端必须使用它来等待通知。

以下类使用FileSystemWatcher来监视当前文件夹中的文件系统。Deleted事件停止通知并通知完成源文件成功删除。类似地,Error事件设置了最终将在await语句的消费方触发的异常:

public class DeletionNotifier : IDisposable
{
   private TaskCompletionSource<FileSystemEventArgs> _tcs;
   private FileSystemWatcher _watcher;
   public DeletionNotifier()
   {
      var path = Path.GetFullPath(".");
      Console.WriteLine($"Observing changes in path: {path}");
      _watcher = new FileSystemWatcher(path, "*.txt");
      _watcher.Deleted += (s, e) =>
      {
         _watcher.EnableRaisingEvents = false;
         _tcs.SetResult(e);
      };
      _watcher.Error += (s, e) =>
      {
         _watcher.EnableRaisingEvents = false;
         _tcs.SetException(e.GetException());
      };
  }
  public Task<FileSystemEventArgs> WhenDeleted()
  {
    _tcs = new TaskCompletionSource<FileSystemEventArgs>();
    _watcher.EnableRaisingEvents = true;
    return _tcs.Task;
  }
  public void Dispose() => _watcher.Dispose();
}

每当调用WhenDeleted方法时,都会创建一个新的完成源,启动文件监视器,并将负责通知的Task返回给客户端。

从消费者的角度来看,这个解决方案很棒,因为它消除了任何复杂性:

var dn = new DeletionNotifier();
var deleted = await dn.WhenDeleted();
Console.WriteLine($"Deleted: {deleted.Name}");

这种解决方案的缺点是一次只能检测到一个删除。

此外,由于Deleted事件中的代码关闭了通知,循环内调用WhenDeleted方法可能会导致删除事件丢失。

但我们可以解决这个问题!稍微复杂一点的解决方案是将事件缓冲在一个线程安全的队列中,并通过出队可用事件的方式改变WhenDeleted方法的策略,如果有的话。

以下是修改后的代码:

public class DeletionNotifier : IDisposable
{
  private TaskCompletionSource<FileSystemEventArgs> _tcs;
  private FileSystemWatcher _watcher;
  private ConcurrentQueue<FileSystemEventArgs> _queue;
  private Exception _error;
  public DeletionNotifier()
  {
    var path = Path.GetFullPath(".");
    Console.WriteLine($"Observing changes in path: {path}");
    _queue = new ConcurrentQueue<FileSystemEventArgs>();
    _watcher = new FileSystemWatcher(path, "*.txt");
    _watcher.Deleted += (s, e) =>
    {
      _queue.Enqueue(e);
      _tcs.TrySetResult(e);
    };
    _watcher.Error += (s, e) =>
    {
      _watcher.EnableRaisingEvents = false;
      _error = e.GetException();
      _tcs.TrySetException(_error);
    };
    _watcher.EnableRaisingEvents = true;
  }
  public Task<FileSystemEventArgs> WhenDeleted()
  {
    if (_queue.TryDequeue(out FileSystemEventArgs fsea))
      return Task.FromResult(fsea);
    if (_error != null)
      return Task.FromException<FileSystemEventArgs>(_error);
    _tcs = new TaskCompletionSource<FileSystemEventArgs>();
    return _tcs.Task;
  }
  public void Dispose() => _watcher.Dispose();
}

再一次,我们可以仅使用任务库工具来解决问题。根据用例,这种策略需要每次重新创建一个新的TaskCompletionSource<T>,并且由于它是一个引用类型,可能会影响性能,受到垃圾回收的影响。如果我们需要重用相同的通知对象,我们可以通过创建一个自定义通知对象来实现。

实际上,await关键字只需要一个实现了名为GetAwaiter的方法的对象,返回一个实现了INotifyCompletion接口的对象。这个对象又必须实现一个IsCompleted属性和模拟TaskCompletionSource行为的所有必需机制。

进一步阅读部分,你会发现一篇有趣的文章,名为await anything,来自微软官方博客,深入探讨了这个主题。

同步上下文

根据我们正在编写的应用程序,不是所有的线程都是平等的。桌面应用程序有一个主线程,只允许在屏幕上绘制和处理图形控件。GUI 库围绕着消息队列的概念工作,每个请求都被发布。主线程负责出队这些消息,并将它们分派到实现所需行为的用户定义处理程序中。

每当在 UI 线程之外的线程上发生某些事件时,必须进行编组操作,这将导致消息被发布到主线程管理的队列中。在 UI 线程中编组消息的两个常见示例是 Windows Forms 应用程序中的Control.Invoke和 Windows Presentation Foundation 中的Dispatcher.Invoke

信息框

WPF 的第一个预发布版本是多线程的。但是,代码复杂性要求用户处理多线程,并且用户代码中可能出现的 bug 提高了门槛。甚至许多 C++库,如 DirectX 和 OpenGL,大多数都是单线程的,以减少复杂性。

在服务器端,ASP.NET 应用程序也有主线程的上下文,但实际上不只有一个——事实上,每个用户的请求都有自己的主线程。

SynchronizationContext是一个抽象的基类,定义了一种在特殊线程上执行一些代码的标准方式。这并不是魔术;事实上,正在执行的代码是在一个 lambda 中定义的,并且被发布到一个队列中。在主线程上,基础设施提供的一些代码会出队 lambda,并在其上下文中执行它。

这种自动编组是基本的,因为在执行任何异步方法之后,比如从互联网下载图像,你希望避免调用Invoke方法,该方法需要将结果编组回主线程,这是为了使用返回的数据更新用户界面所必需的。

每当你等待某个异步操作时,生成的代码会负责捕获当前的SynchronizationContext,并确保继续在特定线程上执行。基本上,你不需要做任何事情,因为基础设施已经为你做了。

我们完成了吗?实际上并没有,因为有时情况并非如此。根据我们所说,以下示例中的三个 ID 应该都是相同的:

public async Task AsyncTest1()
{
    Console.WriteLine($"Id: {Thread.CurrentThread.ManagedThreadId}");
    await Task.Delay(100);
    Console.WriteLine($"Id: {Thread.CurrentThread.ManagedThreadId}");
    await Task.Delay(100);
    Console.WriteLine($"Id: {Thread.CurrentThread.ManagedThreadId}");
}

这不是因为它是一个默认情况下不设置任何同步上下文的控制台应用程序。这是因为在 Microsoft 的Console类的文档中有原因。您会在文档页面的末尾看到线程安全部分,其中指出此类型是线程安全的。换句话说,没有理由返回到原始线程。

如果您创建一个新的 Windows Forms 应用程序,并在按钮单击处理程序中调用该代码,您会发现 ID 始终相同,这要归功于SynchronizationContext

始终重要的是要了解异步代码在线程方面发生了什么,因为有时将结果返回到主线程不是理想的,因为返回有性能影响。例如,库开发人员在编写异步代码时必须非常小心,因为他们无法知道他们的代码是否会在有同步上下文或没有同步上下文的情况下执行。

一个明显的例子是库开发人员正在处理来自网络的数据块。每个块都是通过异步 HTTP 请求检索的,块的数量可能非常多,就像以下示例中一样:

public async Task AsyncLoop()
{
    Console.WriteLine($"Id: {Thread.CurrentThread.ManagedThreadId}");
    byte[] data;
    while((data = await GetNextAsync()).Length > 0)
    {
        Console.WriteLine($"Id: {Thread.CurrentThread.ManagedThreadId}");
        // process data
    }
}

除非处理代码将与 UI(或与主线程相关的任何内容)交互,禁用同步上下文绝对是性能的提升,并且非常容易实现:

public async Task AsyncLoop()
{
    Console.WriteLine($"Id: {Thread.CurrentThread.ManagedThreadId}");
    byte[] data;
    while((data = await GetNextAsync().ConfigureAwait(false)).Length > 0)
    {
        Console.WriteLine($"Id: {Thread.CurrentThread.ManagedThreadId}");
        // process data
    }
} 

通过将ConfigureAwait方法应用于异步方法,操作的结果将不会发布回主线程,并且生成的继续将在辅助线程上执行(无论异步操作是否计划在不同的线程上)。

这种修改后的行为有两个后果:

  • 将消息发布到主线程队列会产生性能影响。例如,库开发人员可能希望在进行一些内部工作时将ConfigureAwait设置为false以提高性能。

  • 每当您决定使用Wait方法或Result属性同步执行异步方法时,您可能会遇到死锁。这可能是因为同步上下文将执行返回到繁忙的主线程。虽然应该通过永远不使用WaitResult来避免这种情况,但另一种方法是通过将ConfigureAwait设置为false,使调用在辅助线程上完成执行。

请注意,如果您真的希望在辅助线程上继续执行,确保对所有后续调用应用ConfigureAwait。事实上,第一个异步调用在没有使用ConfigureAwait的情况下执行将导致执行返回到主线程。

由于ConfigureAwait后面的代码在辅助线程上执行,记得手动返回到主线程,以避免竞争条件。例如,要更新 UI,您必须调用相关的Windows FormsWPF Invoke方法。

任务范式是编程语言中的一场革命,如果没有新语言关键字和编译器生成的魔法,它是无法存在的。这一新特性在其他语言中也引起了很大的共鸣。例如,ECMAScript 2017 通过提供承诺和 async/await 关键字支持来采纳了这些概念。

在这一漫长的章节中,我们学到了异步编程的重要性,以及任务库如何使异步代码直观且易于编写,同时又不会让我们过多地去关注隐含的复杂性。除了获得对这些工具的一般理解之外,现在重要的是要进行实验并深入研究每个方面,以掌握这些技术。

总结

在本章中,我们讨论了任何开发人员都可以利用的最重要的工具,以利用多线程和异步编程技术。

构建块是基本的抽象,允许代码在不同的执行上下文中运行,而不管它们当前运行在哪个操作系统上。这些原语必须以智慧使用,但与本地语言和库相比,这并不以任何方式限制开发人员的可能性。

除此之外,当涉及到与那些本质是异步的事件交互时,任务范式提供了一种自然的方法。System.Threading.Tasks命名空间提供了与异步现象交互所需的所有抽象。

该库已经被广泛重组和扩展以支持任务范式。最重要的是,该语言提供了asyncawait关键字来分解复杂性,并使异步世界流畅地进行,就像是过程性代码一样。

在下一章中,我们将学习文件、文件流和序列化的概念。

测试你所学到的东西

  1. 如果你有一个非常消耗 CPU、持续时间很长的算法要运行,你会采用手动创建线程、使用任务库还是使用线程池中的哪种策略?

  2. 命名一个可以用来写文件并增加内存中整数值的高效同步技术。

  3. 你应该使用什么方法来暂停执行 100 毫秒,为什么?

  4. 你应该怎么做来等待多个异步操作产生的结果?

  5. 你如何创建一个等待 CLR 事件的任务?

  6. 当一个方法的签名中有Task但没有使用任何异步方法时,你应该返回什么?

  7. 你如何创建一个长时间运行的任务?

  8. 一个按钮点击处理程序正在异步访问互联网以加载一些数据。你应该使用Control.Invoke来更新屏幕上的结果吗?为什么?

  9. 在一个Task上评估使用ConfigureAwait方法的原因是什么?

  10. 在使用了ConfigureAwait(false)之后,你能直接更新 UI 吗?

进一步阅读

第十三章:文件、流和序列化

编程主要涉及处理可能来自各种来源的数据,例如本地内存、磁盘文件或通过网络从远程服务器获取的数据。大多数数据必须被持久化,以供长时间或无限期使用。它必须在不同应用程序重新启动之间可用,或在多个应用程序之间共享。无论存储是纯文本文件还是各种类型的数据库,无论它们是本地的、来自网络的还是云端的,无论物理位置是硬盘驱动器、固态驱动器还是 USB 存储设备,所有数据都保存在文件系统中。不同的平台具有不同类型的文件系统,但它们都使用相同的抽象:路径、文件和目录。

在本章中,我们将探讨.NET 为处理文件系统提供的功能。本章将涵盖的主要主题如下:

  • System.IO 命名空间概述

  • 处理路径

  • 处理文件和目录

  • 处理流

  • 序列化和反序列化 XML

  • 序列化和反序列化 JSON

通过本章的学习,您将学会如何创建、修改和删除文件和目录。您还将学会如何读取和写入不同类型的数据文件(包括二进制和文本)。最后,您将学会如何将对象序列化为 XML 和 JSON。

让我们从探索System.IO命名空间开始。

System.IO 命名空间概述

.NET 框架提供了类以及其他辅助类型,如枚举、接口和委托,帮助我们在基类库中使用System.IO命名空间。类型的完整列表相当长,但以下表格显示了其中最重要的类型,分成几个类别。

用于处理文件系统对象的最重要的类如下:

用于处理的最重要的类如下:

如前表所示,此列表中的具体类是成对出现的:一个读取器和一个写入器。通常,它们的使用方式如下:

  • BinaryReaderBinaryWriter用于显式地将原始数据类型序列化和反序列化到二进制文件中。

  • StreamReaderStreamWriter用于处理来自文本文件的具有不同编码的基于字符的数据。

  • StringReaderStringWriter具有与前一对类似的接口和目的,尽管它们在字符串和字符串缓冲区上工作,而不是流。

前表中类之间的关系如下简化的类图所示:

图 13.1 - 流类和先前提到的读取器和写入器类的类图

图 13.1 - 流类以及先前提到的读取器和写入器类的类图

从这个图表中,您可以看到只有FileStreamMemoryStream实际上是流类。BinaryReaderStreamReader是适配器,从流中读取数据,而BinaryWriterStreamWriter向流中写入数据。所有这些类都需要一个流来创建实例(流作为参数传递给构造函数)。另一方面,StringReaderStringWriter根本不使用流;相反,它们从字符串或字符串缓冲区中读取和写入。

文件系统对象或流的大多数操作在发生错误时会抛出异常。其中最重要的异常如下所列:

在本章的后续部分,我们将详细介绍其中一些类。现在,我们将从Path类开始。

处理路径

System.IO.Path是一个静态类,对表示文件系统对象(文件或目录)的路径执行操作。该类的方法都不验证字符串是否表示有效文件或目录的路径。但是,接受输入路径的成员会验证路径是否格式良好;否则,它们会抛出异常。该类可以处理不同平台的路径。路径的格式,如根元素的存在或路径分隔符,取决于平台,并由应用程序运行的平台确定。

路径可以是相对的绝对的。绝对路径是完全指定位置的路径。另一方面,相对路径是由当前位置确定的部分位置,可以通过调用Directory.GetCurrentDirector()方法检索。

Path类的所有成员都是静态的。最重要的成员列在下表中:

为了了解这是如何工作的,我们可以考虑以下示例,其中我们使用Path类的各种方法打印有关c:\Windows\System32\mmc.exe路径的信息:

var path = @"c:\Windows\System32\mmc.exe";
Console.WriteLine(Path.HasExtension(path));
Console.WriteLine(Path.IsPathFullyQualified(path));
Console.WriteLine(Path.IsPathRooted(path));
Console.WriteLine(Path.GetPathRoot(path));
Console.WriteLine(Path.GetDirectoryName(path));
Console.WriteLine(Path.GetFileName(path));
Console.WriteLine(Path.GetFileNameWithoutExtension(path));
Console.WriteLine(Path.GetExtension(path));
Console.WriteLine(Path.ChangeExtension(path, ".dll"));

该程序的输出如下屏幕截图所示:

图 13.2 - 执行前面示例的屏幕截图,打印有关路径的信息

](https://gitee.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/lrn-cs-prog/img/Figure_13.2_B12346.jpg)

图 13.2 - 执行前面示例的屏幕截图,打印有关路径的信息

Path类包含一个名为Combine()的方法,建议使用它来从两个或多个路径组合新路径。该方法有四个重载;这些重载接受两个、三个、四个路径或路径数组作为输入参数。为了理解这是如何工作的,我们将看一下以下示例,其中我们正在连接两个路径:

var path1 = Path.Combine(@"c:\temp", @"sub\data.txt");
Console.WriteLine(path1); // c:\temp\sub\data.txt 
var path2 = Path.Combine(@"c:\temp\sub", @"..\", "log.txt");
Console.WriteLine(path2); // c:\temp\sub\..\log.txt

在第一个例子中,连接的结果是c:\temp\sub\data.txt,这在tempsub之间正确地包括了路径分隔符,而这两个输入路径中都没有。在第二个例子中,连接三个路径的结果是c:\temp\sub\..\log.txt。请注意,路径被正确组合,但未解析为实际路径,即c:\temp\log.txt

除了前面列出的方法之外,Path类中还有几个其他静态方法,其中一些用于处理临时文件。这些在这里列出:

让我们看一个处理临时路径的例子:

var temp = Path.GetTempPath();
var name = Path.GetRandomFileName();
var path1 = Path.Combine(temp, name);
Console.WriteLine(path1);
var path2 = Path.GetTempFileName();
Console.WriteLine(path2);
File.Delete(path2);

如下屏幕截图所示,path1将包含一个路径,例如C:\Users\Marius\AppData\Local\Temp\w22fbbqw.y34,尽管文件名(包括扩展名)会随着每次执行而改变。此外,这个路径不会在磁盘上创建,不像第二个例子,其中C:\Users\Marius\AppData\Local\Temp\tmp8D5A.tmp路径实际上代表一个新创建的文件:

图 13.3 - 屏幕截图,演示了使用 GetRandomFileName()方法

](https://gitee.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/lrn-cs-prog/img/Figure_13.3_B12346.jpg)

图 13.3 - 屏幕截图,演示了使用 GetRandomFileName()和 GetTempFileName()方法

这两个临时路径之间有两个重要的区别——第一个使用了加密强大的方法来生成名称,而第二个使用了一个更简单的算法。另一方面,GetRandomFileName()返回一个带有随机扩展名的名称,而GetTempFileName()总是返回一个带有.TMP扩展名的文件名。

要验证路径是否存在并执行创建、移动、删除或打开目录或文件等操作,我们必须使用System.IO命名空间中的其他类。我们将在下一节中看到这些类。

处理文件和目录

System.IO命名空间包含两个用于处理目录的类(DirectoryDirectoryInfo),以及两个用于处理文件的类(FileFileInfo)。DirectoryFileDirectoryInfoFileInfo

后两者都是从FileSystemInfo基类派生的,该基类提供了对文件和目录进行操作的常用成员。其中最重要的成员是以下表中列出的属性:

DirectoryInfo类的最重要成员(不包括在前面的表中列出的从基类继承的成员)如下:

同样,FileInfo类的最重要成员(不包括从基类继承的成员)如下:

现在我们已经看过了用于处理文件系统对象及其最重要成员的类,让我们看一些使用它们的示例。

在第一个示例中,我们将使用DirectoryInfo的实例来打印有关目录(在本例中为C:\Program Files (x86)\Microsoft SDKs\Windows\)的信息,如名称、父级、根、创建时间和属性,以及所有子目录的名称:

var dir = new DirectoryInfo(@"C:\Program Files (x86)\Microsoft SDKs\Windows\");
Console.WriteLine($"Full name : {dir.FullName}");
Console.WriteLine($"Name      : {dir.Name}");
Console.WriteLine($"Parent    : {dir.Parent}");
Console.WriteLine($"Root      : {dir.Root}");
Console.WriteLine($"Created   : {dir.CreationTime}");
Console.WriteLine($"Attribute : {dir.Attributes}");
foreach(var subdir in dir.EnumerateDirectories())
{
    Console.WriteLine(subdir.Name);
}

执行此代码的输出如下(请注意,每台执行代码的机器都会有所不同):

图 13.4 - 屏幕截图显示先前示例的目录信息

图 13.4 - 屏幕截图显示先前示例的目录信息

DirectoryInfo还允许我们创建和删除目录,这是我们将在下一个示例中做的事情。首先,我们创建C:\Temp\Dir\Sub目录。其次,我们相对于先前的目录创建子目录层次结构sub1\sub2\sub3。最后,我们从C:\Temp\Dir\Sub\sub1\sub2目录中删除最内部的目录sub3

var dir = new DirectoryInfo(@"C:\Temp\Dir\Sub");
Console.WriteLine($"Exists: {dir.Exists}");
dir.Create();
var sub = dir.CreateSubdirectory(@"sub1\sub2\sub3");
Console.WriteLine(sub.FullName);
sub.Delete();

请注意,CreateSubdirectory()方法返回一个表示创建的最内部子目录的DirectoryInfo实例,在这种情况下是C:\Temp\Dir\Sub\sub1\sub2\sub3。因此,在此实例上调用Delete()时,只会删除sub3子目录。

我们可以使用Directory静态类及其CreateDirectory()Delete()方法来编写相同的功能,如下面的代码所示:

var path = @"C:\Temp\Dir\Sub";
Console.WriteLine($"Exists: {Directory.Exists(path)}");
Directory.CreateDirectory(path);
var sub = Path.Combine(path, @"sub1\sub2\sub3");
Directory.CreateDirectory(sub);
Directory.Delete(sub);
Directory.Delete(path, true);

第一次调用Delete()将删除C:\Temp\Dir\Sub\sub1\sub2\sub3子目录,但仅当它为空时。第二次调用将以递归方式删除C:\Temp\Dir\Sub子目录及其所有内容(文件和子目录)。

在下一个示例中,我们将列出从给定目录(在本例中为C:\Program Files (x86)\Microsoft SDKs\Windows\v10.0A\bin\NETFX 4.8 Tools\)开始以字母T开头的所有可执行文件。为此,我们将使用GetFiles()方法提供适当的过滤器。该方法返回一个FileInfo对象数组,我们使用该类的不同属性打印有关文件的信息:

var dir = new DirectoryInfo(@"C:\Program Files (x86)\Microsoft SDKs\Windows\v10.0A\bin\NETFX 4.8 Tools\");
foreach(var file in dir.GetFiles("t*.exe"))
{
    Console.WriteLine(
      $"{file.Name} [{file.Length}] 
    [{file.Attributes}]");}

执行此代码示例的输出可能如下所示:

图 13.5 - 屏幕截图显示从给定目录中以字母 T 开头的可执行文件列表

图 13.5 - 屏幕截图显示从给定目录中以字母 T 开头的可执行文件列表

为了打印有关文件的信息,我们使用了之前提到的FileInfo类。NameLengthAttributes只是该类提供的一些属性。其他包括扩展名和文件时间。下面的代码片段显示了使用它们的示例:

var file = new FileInfo(@"C:\Windows\explorer.exe");
Console.WriteLine($"Name: {file.Name}");
Console.WriteLine($"Extension: {file.Extension}");
Console.WriteLine($"Full name: {file.FullName}");
Console.WriteLine($"Length: {file.Length}");
Console.WriteLine($"Attributes: {file.Attributes}");
Console.WriteLine($"Creation: {file.CreationTime}");
Console.WriteLine($"Last access:{file.LastAccessTime}");
Console.WriteLine($"Last write: {file.LastWriteTime}");

尽管输出在每台机器上会有所不同,但应如下所示:

图 13.6 - 利用 FileInfo 类显示详细文件信息

图 13.6 - 使用 FileInfo 类显示的详细文件信息

我们可以利用到目前为止学到的知识来创建一个函数,将目录的内容递归地写入控制台,并在这样做的同时,随着在目录层次结构中的深入导航,也缩进文件和目录的名称。这样的函数可能如下所示:

void PrintContent(string path, string indent = null)
{
    try
    {
        foreach(var file in Directory.EnumerateFiles(path))
        {
            var fi = new FileInfo(file);
            Console.WriteLine($"{indent}{fi.Name}");
        }
       foreach(var dir in Directory.EnumerateDirectories(path))
        {
            var di = new DirectoryInfo(dir);
            Console.WriteLine($"{indent}[{di.Name}]");
            PrintContent(dir, indent + " ");
        }
    }
    catch(Exception ex)
    {
        Console.Error.WriteLine(ex.Message);
    }
}

当以项目目录的路径作为输入执行时,它会将以下输出打印到控制台(以下截图是完整输出的一部分):

图 13.7 - 打印指定目录内容的程序的部分输出

图 13.7 - 打印指定目录内容的程序的部分输出

您可能已经注意到,我们同时使用了GetFiles()EnumerateFile(),以及EnumerateDirectories()。这两组方法,以GetEnumerate为前缀的方法,在返回文件或目录的集合方面是相似的。

然而,它们在一个关键方面有所不同——Get方法返回一个对象数组,而Enumerate方法返回一个IEnumerable<T>,允许客户端在检索到所有文件系统对象之前开始迭代,并且只消耗他们想要的。因此,在许多情况下,这些方法可能是一个更好的选择。

到目前为止,大多数示例都集中在获取文件和目录信息上,尽管我们确实创建和删除了目录。我们可以使用FileFileInfo类来创建和删除文件。例如,我们可以使用File.Create()来创建一个新文件或打开并覆盖现有文件,如下例所示:

using (var file = new StreamWriter(
   File.Create(@"C:\Temp\Dir\demo.txt")))
{
    file.Write("This is a demo");
}

File.Create()返回一个FileStream,在这个例子中,然后用它来创建一个StreamWriter,允许我们向文件写入文本This is a demo。然后流被处理,文件句柄被正确关闭。

如果您只对写入文本或二进制数据感兴趣,可以使用File类的静态成员,如WriteAllText()WriteAllLines()WriteAllBytes()。这些方法有多个重载,允许您指定文本编码,例如。还有异步对应方法,WriteAllTextAsync()WriteAllLinesAsync()WriteAllBytesAsync()。所有这些方法都会覆盖文件的当前内容(如果文件已经存在)。如果您希望保留内容并追加到文件的末尾,那么可以使用AppendAllText()AppendAllLines()方法及其异步对应方法AppendAllTextAsync()AppendAllLinesAsync()

以下示例显示了如何使用这里提到的一些方法向现有文件写入和追加文本:

var path = @"C:\Temp\Dir\demo.txt";
File.WriteAllText(path, "This is a demo");
File.AppendAllText(path, "1st line");
File.AppendAllLines(path, new string[]{ 
   "2nd line", "3rd line"});

第一次调用WriteAllText()This is a demo写入文件,覆盖任何内容。第二次调用AppendAllText()1st line追加到文件中,而不添加任何新行。第三次调用AppendAllLines()将每个字符串写入文件,并在每个字符串后添加一个新行。因此,执行此代码后,文件的内容将如下所示:

This is a demo1st line2nd line
3rd line

与向文件写入内容类似,使用File类及其ReadAllText()ReadAllLines()ReadAllBytes()方法也可以进行读取。与写入方法一样,还有异步版本,ReadAllTextAsync()ReadAllLinesAsync()ReadAllBytesAsync()。下面的代码示例展示了如何使用其中一些方法:

var path = @"C:\Temp\Dir\demo.txt";
string text = File.ReadAllText(path);
string[] lines = File.ReadAllLines(path);

执行此代码后,text变量将包含从文件中读取的整个文本。另一方面,lines将是一个包含两个元素的数组,第一个是This is a demo1st line2nd line,第二个是3rd line

纯文本并不是我们通常会写入文件的唯一类型的数据,文件也不是数据的唯一存储系统。有时,我们可能对从管道、网络、本地内存或其他地方读取和写入感兴趣。为了处理所有这些,.NET 提供了,这是下一节的主题。

处理流

Stream,提供了对流进行读取和写入的支持。另一方面,流在概念上分为三类:

  • FileStreamMemoryStreamNetworkStream来实现后备存储。

  • BufferedStreamCryptoStreamDeflateStreamGZipStream

  • boolintdouble等)、文本、XML 数据等。.NET 提供的适配器包括BinaryReaderBinaryWriterStreamReaderStreamWriter,以及XmlReaderXmlWriter

以下图表概念上展示了流架构:

图 13.8 - 流架构的概念图

图 13.8 - 流架构的概念图

讨论前面图中显示的所有流类超出了本书的范围。然而,在本节中,我们将重点关注BinaryReader/BinaryWriterStreamReader/StreamWriter适配器,以及FileStreamMemoryStream后备存储流。

流类的概述

正如我之前提到的,所有流类的基类是System.IO.Stream类。这是一个提供从流中读取和写入的方法和属性的抽象类。其中许多是抽象的,并且在派生类中实现。以下是该类的最重要的方法:

列出的一些操作有异步伴侣,其后缀为Async(例如ReadAsync()WriteAsync())。读取和写入操作会使指示当前流位置的指针前进读取或写入的字节数。

Stream类还提供了几个有用的属性,列在下表中:

代表文件的后备存储流的类称为FileStream。这个类是从抽象的Stream类派生而来的,并实现了抽象成员。它支持同步和异步操作,并且不仅可以用于打开、读取、写入和关闭磁盘文件,还可以用于其他操作系统对象,比如管道和标准输入和输出。异步方法对于执行耗时操作而不阻塞主线程非常有用。

FileStream类支持对文件的随机访问。Seek()方法允许我们在流内移动当前指针的位置进行读取/写入。在改变位置时,必须指定一个字节偏移量和一个查找原点。字节偏移量是相对于查找原点的,查找原点可以是流的开头、当前位置或者末尾。

该类提供了许多构造函数来创建类的实例。您可以以各种组合提供文件句柄(作为IntPtrSafeFileHandle)、文件路径、文件模式(确定文件应该如何打开)、文件访问(确定文件应该如何访问 - 读取、写入或两者)、以及文件共享(确定其他文件流如何访问相同的文件)。在这里列出所有这些构造函数是不切实际的,但我们将在本章中看到几个示例。

表示内存备份存储的类称为MemoryStream,也是从Stream派生而来的。该类的大多数成员都是基类的抽象成员的实现。但是,该类具有几个构造函数,允许我们创建可调整大小的流(初始为空或具有指定容量)或从字节数组创建不可调整大小的流。从字节数组创建的内存流不能扩展或收缩,可以是可写的或只读的。

使用文件流

FileStream类允许我们从文件中读取和写入一系列字节。它可以操作原始数据,如byte[]Span<byte>Memory<byte>。我们可以使用File类的静态方法或FileInfo类的非静态方法来获取FileStream对象:

我们可以通过以下示例看到这是如何工作的,我们将四个字节写入到位于C:\Temp\data.raw的文件中,然后读取文件的整个内容并将其打印到控制台上:

var path = @"C:\Temp\data.raw";
var data = new byte[] { 0xBA, 0xAD, 0xF0, 0x0D};
using(FileStream wr = File.Create(path))
{
    wr.Write(data, 0, data.Length);
}
using(FileStream rd = File.OpenRead(path))
{
    var buffer = new byte[rd.Length];
    rd.Read(buffer, 0, buffer.Length);
    Console.WriteLine(
       string.Join(" ", buffer.Select(
                   e => $"{e:X02}")));
}

在第一部分中,我们使用File.Create()打开一个文件进行写入。如果文件不存在,则会创建文件。如果文件存在,则其内容将被覆盖。使用FileStream.Write()方法将字节数组的内容写入文件。当FileStream对象在using语句结束时被处理时,流将被刷新到文件,并关闭文件句柄。

在第二部分中,我们使用File.OpenRead()打开先前写入的文件,但这次是用于读取。我们分配了一个足够大的数组来接收文件的整个内容,并使用FileStream.Read()来读取其内容。这段代码的输出如下:

图 13.9 - 显示在控制台上创建的二进制文件的内容

图 13.9 - 显示在控制台上创建的二进制文件的内容

处理原始数据可能很麻烦。因此,.NET 提供了流适配器,允许我们处理更高级别的数据。第一对适配器是BinaryReaderBinaryWriter,它们提供了对二进制格式中的原始类型和字符串的读取和写入支持。以下是使用这两个适配器的示例:

var path = @"C:\Temp\data.bin";
using (var wr = new BinaryWriter(File.Create(path)))
{
    wr.Write(true);
    wr.Write('x');
    wr.Write(42);
    wr.Write(19.99);
    wr.Write(49.99M);
    wr.Write("text");
}
using(var rd = new BinaryReader(File.OpenRead(path)))
{
    Console.WriteLine(rd.ReadBoolean()); // True
    Console.WriteLine(rd.ReadChar());    // x
    Console.WriteLine(rd.ReadInt32());   // 42
    Console.WriteLine(rd.ReadDouble());  // 19.99
    Console.WriteLine(rd.ReadDecimal()); // 49.99
    Console.WriteLine(rd.ReadString());  // text
} 

我们首先使用File.Create()打开一个文件,返回FileStream。这个流被用作BinaryWriter流适配器的构造函数的参数。Write()方法对所有原始类型(charboolsbytebyteshortushortintuintlongulongfloatdoubledecimal)以及byte[]char[]string进行了重载。

其次,我们重新打开相同的文件,但这次是用于读取,使用File.OpenRead()。这个方法返回的FileStream对象被用作BinaryReader流适配器的构造函数的参数。该类有一组读取方法,每种原始类型都有一个,比如ReadBoolean()ReadChar()ReadInt16()ReadInt32()ReadDouble()ReadDecimal(),以及用于读取byte[]的方法 - ReadBytes()char[] - ReadChars(),和字符串 - ReadString()。你可以在前面的示例中看到其中一些方法的使用。

默认情况下,BinaryReaderBinaryWriter都使用UTF-8 编码处理字符串。但是,它们都有重载的构造函数,允许我们使用System.Text.Encoding类指定另一种编码。

尽管这两个适配器可以用于处理字符串,但由于缺乏对诸如行处理之类的功能的支持,因此使用它们来读写文本文件可能会很麻烦。为了处理文本文件,应该使用StreamReaderStreamWriter适配器。默认情况下,它们将文本处理为 UTF-8 编码,但它们的构造函数允许我们指定不同的编码。在以下示例中,我们将文本写入文件,然后将其读取并打印到控制台:

var path = @"C:\Temp\data.txt";
using(StreamWriter wr = File.CreateText(path))
{
    wr.WriteLine("1st line");
    wr.WriteLine("2nd line");
}
using(StreamReader rd = File.OpenText(path))
{
    while(!rd.EndOfStream)
        Console.WriteLine(rd.ReadLine());
}

File.CreateText()方法打开一个文件进行写入(创建或覆盖),并返回一个使用 UTF-8 编码的StreamWriter类的实例。WriteLine()方法将字符串写入文件,然后添加一个新行。WriteLine()有重载版本,还有重载的Write()方法,可以在不添加新行的情况下写入charchar[]string

在第二部分中,我们使用File.OpenText()方法打开先前写入的文本文件进行读取。这会返回一个读取 UTF-8 文本的StreamReader对象。ReadLine()方法用于在循环中逐行读取内容,直到流的末尾。EndOfStream属性用于检查当前流位置是否达到流的末尾。

我们可以使用File.Open()方法,而不是使用File.OpenText()方法,这允许我们指定打开模式、文件访问和共享。我们可以将之前显示的读取部分重写如下:

using(var rd = new StreamReader(
  File.Open(path, FileMode.Open,
         FileAccess.Read, 
         FileShare.Read)))
{
    while (!rd.EndOfStream)
        Console.WriteLine(rd.ReadLine());
}

有时,我们需要一个流来处理临时数据。使用文件可能很麻烦,也会给 I/O 操作增加不必要的开销。为此,内存流是最合适的。

使用内存流

内存流是本地内存的后备存储。这样的流在需要临时存储转换数据时非常有用。示例可以包括 XML 序列化或数据压缩和解压缩。我们将在接下来的代码中看到这两个操作。

下面的代码中显示的静态Serializer<T>类包含两个方法——Serialize()Deserialize()。前者接受一个T对象,使用XmlSerializer生成其 XML 表示,并将 XML 数据作为字符串返回。后者接受包含 XML 数据的字符串,并使用XmlSerializer读取它并从中创建一个新的T类型对象。以下是代码:

public static class Serializer<T>
{
    static readonly XmlSerializer _serializer =
       new XmlSerializer(typeof(T));
    static readonly Encoding _encoding = Encoding.UTF8;
    public static string Serialize(T value)
    {
        using (var ms = new MemoryStream())
        {
            _serializer.Serialize(ms, value);
            return _encoding.GetString(ms.ToArray());
        }
    }
    public static T Deserialize(string value)
    {
        using (var ms = new MemoryStream(
           _encoding.GetBytes(value)))
        {
            return (T)_serializer.Deserialize(ms);
        }
    }
}

Serialize()方法中创建的内存流是可调整大小的。它最初是空的,根据需要增长。然而,在Deserialize()方法中创建的内存流是不可调整大小的,因为它是从字节数组初始化的。这个流用于只读目的。

MemoryStream类实现了IDisposable接口,因为它继承自Stream,而Stream实现了IDisposable。然而,MemoryStream没有需要处理的资源,因此Dispose()方法什么也不做。显式调用对流没有影响。因此,不需要像前面的例子中那样将内存流变量包装在using语句中。

让我们考虑一个Employee类的以下实现:

public class Employee
{
    public int EmployeeId { get; set; }
    public string FirstName { get; set; }
    public string LastName { get; set; }
    public override string ToString() => 
        $"[{EmployeeId}] {LastName}, {FirstName}";
}

我们可以按照以下方式对这个类的实例进行序列化和反序列化:

var employee = new Employee
{
    EmployeeId = 42,
    FirstName = "John",
    LastName = "Doe"
};
var text = Serializer<Employee>.Serialize(employee);
var result = Serializer<Employee>.Deserialize(text);
Console.WriteLine(employee);
Console.WriteLine(text);
Console.WriteLine(result);

执行此代码的结果显示在以下屏幕截图中:

图 13.10 – 在控制台上显示的 XML 序列化的 Employee 对象

图 13.10 – 在控制台上显示的 XML 序列化的 Employee 对象

我们提到的另一个内存流很方便的例子是数据的压缩和解压缩System.IO.Compression命名空间中的GZipStream类是一个流装饰器,支持使用 GZip 数据格式规范对流进行压缩和解压缩。MemoryStream对象被用作GZipStream装饰器的后备存储。这里显示的静态Compression类提供了压缩和解压缩字节数组的两个方法:

public static class Compression
{
    public static byte[] Compress(byte[] data)
    {
        if (data == null) return null;
        if (data.Length == 0) return new byte[] { };
        using var ms = new MemoryStream();
        using var gzips =
           new GZipStream(ms,
        CompressionMode.Compress);
        gzips.Write(data, 0, data.Length);
        gzips.Close();
        return ms.ToArray();
    }
    public static byte[] Decompress(byte[] data)
    {
        if (data == null) return null;
        if (data.Length == 0) return new byte[] { };

        using var source = new MemoryStream(data);
        using var gzips =
           new GZipStream(source,
        CompressionMode.Decompress);
        using var target = new MemoryStream(data.Length * 2);
        gzips.CopyTo(target);
        return target.ToArray();
    }
}

我们可以使用这个辅助类将字符串压缩为字节数组,然后将其解压缩为字符串。以下代码显示了这样一个例子:

var text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.";
var data = Encoding.UTF8.GetBytes(text);
var compressed = Compression.Compress(data);
var decompressed = Compression.Decompress(compressed);
var result = Encoding.UTF8.GetString(decompressed);
Console.WriteLine($"Text size: {text.Length}");
Console.WriteLine($"Compressed: {compressed.Length}");
Console.WriteLine($"Decompressed: {decompressed.Length}");
Console.WriteLine(result);
if (text == result)
    Console.WriteLine("Decompression successful!");

执行此示例代码的输出显示在以下屏幕截图中:

图 13.11 – 一个屏幕截图,显示了压缩和解压文本的结果

图 13.11 – 一个屏幕截图,显示了压缩和解压文本的结果

在本节中,我们已经看到了如何简单地序列化和反序列化 XML。我们将在下一节详细介绍这个主题。

序列化和反序列化 XML

在前一节中,我们已经看到了如何使用System.Xml.Serialization命名空间中的XmlSerializer类对数据进行序列化和反序列化。这个类对于将对象序列化为 XML 和将 XML 反序列化为对象非常方便。尽管在前面的示例中,我们使用了内存流进行序列化,但它实际上可以与任何流一起使用;此外,它还可以与TextWriterXmlWriter适配器一起使用。

以下示例显示了一个修改后的Serializer<T>类,其中我们指定了要将 XML 文档写入或从中读取的文件的路径:

public static class Serializer<T>
{
    static readonly XmlSerializer _serializer = 
        new XmlSerializer(typeof(T));
    public static void Serialize(T value, string path)
    {
        using var ms = File.CreateText(path);
        _serializer.Serialize(ms, value);
    }
    public static T Deserialize(string path)
    {
        using var ms = File.OpenText(path);
        return (T)_serializer.Deserialize(ms);
    }
}

我们可以像下面这样使用这个新的实现:

var employee = new Employee
{
    EmployeeId = 42,
    FirstName = "John",
    LastName = "Doe"
};
var path = Path.Combine(Path.GetTempPath(), "employee1.xml");
Serializer<Employee>.Serialize(employee, path);
var result = Serializer<Employee>.Deserialize(path);

使用此代码进行 XML 序列化的结果是具有以下内容的文档:

<?xml version="1.0" encoding="utf-8"?>
<Employee xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
  <EmployeeId>42</EmployeeId>
  <FirstName>John</FirstName>
  <LastName>Doe</LastName>
</Employee>

XmlSerializer通过将类型的所有公共属性和字段序列化为 XML 来工作。它使用一些默认设置,例如类型变为节点,属性和字段变为元素。类型、属性或字段的名称成为节点或元素的名称,字段或属性的值成为其文本。它还添加了默认命名空间(您可以在前面的代码中看到)。但是,可以使用类型和成员上的属性来控制序列化的方式。下面的代码示例中显示了这样一个示例:

[XmlType("employee")]
public class Employee
{
    [XmlAttribute("id")]
    public int EmployeeId { get; set; }
    [XmlElement(ElementName = "firstName")]
    public string FirstName { get; set; }
    [XmlElement(ElementName = "lastName")]
    public string LastName { get; set; }
    public override string ToString() => 
        $"[{EmployeeId}] {LastName}, {FirstName}";
}

对这个Employee类实现的实例进行序列化将产生以下 XML 文档:

<?xml version="1.0" encoding="utf-8"?>
<employee xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" id="42">
  <firstName>John</firstName>
  <lastName>Doe</lastName>
</employee>

我们在这里使用了几个属性,XmlTypeXmlAttributeXmlElement,但列表很长。以下表列出了最重要的 XML 属性及其作用。这些属性位于System.Xml.Serialization命名空间中:

XmlSerializer类的工作方式是,在运行时,每次应用程序运行时,为临时序列化程序集中的每种类型生成序列化代码。在某些情况下,这可能是一个性能问题,可以通过预先生成这些程序集来避免。Sgen.exe可以用来生成这些程序集。如果包含序列化代码的程序集称为MyAssembly.dll,则生成的序列化程序集将被称为MyAssembly.XmlSerializer.dll。该工具作为 Windows SDK 的一部分部署。

您还可以使用xsd.exe从类生成 XML 模式(XSD 文档)或从现有 XML 模式生成类。该工具作为 Windows SDK 的一部分或与 Visual Studio 一起分发。

XmlSerializer可能存在的问题是,它将单个.NET 对象序列化为 XML 文档(当然,该对象可以是复杂的,并包含其他对象和对象数组)。如果您有两个要写入同一文档的单独对象,则无法正常工作。假设我们还有以下类,表示公司中的一个部门:

public class Department
{
    [XmlAttribute]
    public int Id { get; set; }

    public string Name { get; set; }
}

我们可能希望编写一个包含员工和部门的 XML 文档。使用XmlSerializer将无法正常工作。这在以下示例中显示:

public static class Serializer<T>
{
    static readonly XmlSerializer _serializer = 
        new XmlSerializer(typeof(T));
    public static void Serialize(T value, StreamWriter stream)
    {
        _serializer.Serialize(stream, value);
    }
    public static T Deserialize(StreamReader stream)
    {
        return (T)_serializer.Deserialize(stream);
    }
}

我们可以尝试使用以下代码将员工和部门序列化到同一个 XML 文档中:

var employee = new Employee
{
    EmployeeId = 42,
    FirstName = "John",
    LastName = "Doe"
};
var department = new Department
{
    Id = 102, 
    Name = "IT"
};
var path = Path.Combine(Path.GetTempPath(), "employee.xml");
using (var wr = File.CreateText(path))
{
    Serializer<Employee>.Serialize(employee, wr);
    wr.WriteLine();
    Serializer<Department>.Serialize(department, wr);
}

生成到磁盘文件的 XML 文档将具有以下代码中显示的内容。这不是有效的 XML,因为它具有多个文档声明,并且没有单个根元素:

<?xml version="1.0" encoding="utf-8"?>
<employee xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" id="42">
   <firstName>John</firstName>
   <lastName>Doe</lastName>
</employee>
<?xml version="1.0" encoding="utf-8"?>
<Department xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" Id="102">
   <Name>IT</Name>
</Department>

要使其工作,我们必须创建一个额外的类型,该类型将包含一个员工和一个部门,并且我们必须序列化此类型的实例。此额外对象将作为 XML 文档的根元素进行序列化。我们将通过以下示例进行演示(请注意,这里有一个额外的名为Version的属性):

public class Data
{
    [XmlAttribute]
    public int Version { get; set; }
    public Employee Employee { get; set; }
    public Department Department { get; set; }
}
var data = new Data()
{
    Version = 1,
    Employee = new Employee {
        EmployeeId = 42,
        FirstName = "John",
        LastName = "Doe"
    },
    Department = new Department {
        Id = 102,
        Name = "IT"
    }
};
var path = Path.Combine(Path.GetTempPath(), "employee.xml");
using (var wr = File.CreateText(path))
{
    Serializer<Data>.Serialize(data, wr);
}

这次,输出是一个格式良好的 XML 文档,列在以下代码中:

<?xml version="1.0" encoding="utf-8"?>
<Data xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" Version="1">
  <Employee id="42">
    <firstName>John</firstName>
    <lastName>Doe</lastName>
  </Employee>
  <Department Id="102">
    <Name>IT</Name>
  </Department>
</Data>

为了进一步控制读取和写入 XML,.NET 基类库包含两个名为XmlReaderXmlWriter的类,它们提供了一种快速、非缓存、仅向前的方式来从流或文件读取或生成 XML 数据。

XmlWriter类可用于将 XML 数据写入流、文件、文本读取器或字符串。它提供了以下功能:

  • 验证字符和 XML 名称

  • 验证 XML 文档是否格式良好

  • 支持 CLR 类型,这样您就不需要手动将所有内容转换为字符串

  • 用于在 XML 文档中写入二进制数据的 Base64 和 BaseHex 编码

XmlWriter类包含许多方法;其中一些方法列在下表中。尽管此列表仅包括同步方法,但它们都有异步伴侣,比如WriteElementStringAsync()对应于WriteElementString()

在使用XmlWriter时,可以指定各种设置,如编码、缩进、属性应该如何写入(在新行上还是同一行上)、省略 XML 声明等。这些设置由XmlWriterSettings类控制。

以下清单显示了使用XmlWriter创建包含员工和部门的 XML 文档的示例,作为名为Data的根元素的一部分。实际上,结果与前一个示例相同,只是没有创建命名空间:

var employee = new Employee
{
    EmployeeId = 42,
    FirstName = "John",
    LastName = "Doe"
};
var department = new Department
{
    Id = 102,
    Name = "IT"
};
var path = Path.Combine(Path.GetTempPath(), "employee.xml");
var settings = new XmlWriterSettings 
{ 
    Encoding = Encoding.UTF8, 
    Indent = true 
};
var namespaces = new XmlSerializerNamespaces();
namespaces.Add(string.Empty, string.Empty);
using (var wr = XmlWriter.Create(path, settings))
{
    wr.WriteStartDocument();
    wr.WriteStartElement("Data");
    wr.WriteStartAttribute("Version");
    wr.WriteValue(1);
    wr.WriteEndAttribute();
    var employeeSerializer = 
      new XmlSerializer(typeof(Employee));
    employeeSerializer.Serialize(wr, employee, namespaces);
    var depSerializer = new XmlSerializer(typeof(Department));
    depSerializer.Serialize(wr, department, namespaces);
    wr.WriteEndElement();
    wr.WriteEndDocument();
}

在这个例子中,我们使用了以下组件:

  • XmlWriterSettings的一个实例,用于将编码设置为 UTF-8 并启用输出的缩进。

  • XmlWriter.Create()用于创建XmlWriter类的实现的实例。

  • XmlWriter类的各种方法来写入 XML 数据。

  • XmlSerializerNamespaces的实例,用于控制生成的命名空间。在这个例子中,我们添加了一个空的方案和命名空间,这导致 XML 文档中没有命名空间。

  • XmlSerializer类的实例,用于简化EmployeeDepartment对象到 XML 文档的序列化。这是可能的,因为Serialize()方法可以将XmlWriter作为生成的 XML 文档的目的地。

XmlWriter的伴侣类是XmlReader。这个类允许我们在 XML 数据中移动并读取其内容,但是以一种只能向前的方式,这意味着您不能从给定点返回。XmlReader类是一个抽象类,就像XmlWriter一样,有具体的实现,比如XmlTextReaderXmlNodeReaderXmlValidatingReader

然而,对于大多数情况,您应该使用XmlReader。要创建它的实例,请使用静态的XmlReader.Create()方法。该类包含一长串的方法和属性,以下表格列出了其中的一些。就像在XmlWriter的情况下一样,XmlReader也有同步和异步方法。这里只列出了一些同步方法:

在创建XmlReader的实例时,您可以指定要启用的一组功能,例如应使用的模式、忽略注释或空格、类型分配的验证等。XmlReaderSettings类用于此目的。

在下面的示例中,我们使用XmlReader来读取先前写入的 XML 文档的内容,并在控制台上显示其内容的表示:

var rdsettings = new XmlReaderSettings()
{
    IgnoreComments = true,
    IgnoreWhitespace = true
};
using (var rd = XmlReader.Create(path, rdsettings))
{
    string indent = string.Empty;
    while(rd.Read())
    {
        switch(rd.NodeType)
        {
            case XmlNodeType.Element:
                Console.Write(
                  $"{indent}{{ {rd.Name} : ");
                indent = indent + " ";
                while (rd.MoveToNextAttribute())
                {
                    Console.WriteLine();
                    Console.WriteLine($"{indent}{{{rd.Name}:{rd.Value}}}");
                } 
                break;
            case XmlNodeType.Text:
                Console.Write(rd.Value);
                break;
            case XmlNodeType.EndElement:
                indent = indent.Remove(0, 2);
                Console.WriteLine($"{indent}}}");
                break;
            default:
                Console.WriteLine($"[{rd.Name} {rd.Value}]");
                break;
        }
    }
}

执行此代码的输出如下:

图 13.12 - 从磁盘读取的 XML 文档内容的屏幕截图并显示在控制台上

图 13.12 - 从磁盘读取的 XML 文档内容的屏幕截图并显示在控制台上

以下是此示例的几个关键点:

  • 我们创建了一个XmlReaderSettings的实例,告诉XmlReader忽略注释和空格。

  • 我们使用XmlReader.Create()创建了一个新的XmlReader实现的实例,用于从指定路径的文件中读取 XML 数据。

  • Read()方法用于循环读取 XML 文档的每个节点。

  • 我们使用属性,如NodeTypeNameValue来检查每个节点的类型,名称和值。

有关使用XmlReaderXmlWriter处理 XML 数据以及使用XmlSerializer进行序列化的许多细节。在这里讨论所有这些内容将花费太多时间。我们建议您使用其他资源,如官方文档,来了解更多关于这些类的信息。

现在我们已经看到了如何处理 XML 数据,让我们来看看 JSON。

序列化和反序列化 JSON

近年来,JavaScript 对象表示法(JSON)已成为数据序列化的事实标准,不仅用于 Web 和移动端,也用于桌面端。.NET 没有提供适当的库来序列化和反序列化 JSON;因此,开发人员转而使用第三方库。其中一个库是Json.NET(也称为Newtonsoft.Json,以其创建者 Newton-King 命名)。这已成为大多数.NET 开发人员的首选库,并且是 ASP.NET Core 的依赖项。然而,随着.NET Core 3.0 的发布,微软提供了自己的 JSON 序列化器,称为System.Text.Json,根据其可用的命名空间命名。在本章的最后部分,我们将看看这两个库,并了解它们的一些功能以及它们之间的比较。

使用 Json.NET

Json.NET 目前是最广泛使用的.NET 库,用于 JSON 序列化和反序列化。它是一个高性能、易于使用的开源库,可作为名为Newtonsoft.Json的 NuGet 包使用。事实上,这是迄今为止在 NuGet 上下载量最大的包。它提供的一些功能列在这里:

  • 大多数常见序列化和反序列化场景的简单 API,使用JsonConvert,它是JsonSerializer的包装器。

  • 使用JsonSerializer对序列化/反序列化过程进行更精细的控制。该类可以通过JsonTextWriterJsonTextReader直接向流中写入文本或从流中读取文本。

  • 使用JObjectJArrayJValue创建,修改,解析和查询 JSON 的可能性。

  • 在 XML 和 JSON 之间进行转换的可能性。

  • 使用 JSON Path 查询 JSON 的可能性,这是一种类似于 XPath 的查询语言。

  • 使用 JSON 模式验证 JSON。

  • 支持BsonReaderBsonWriter。这是一种类似于 JSON 的文档的二进制编码序列化。

在本节中,我们将使用以下Employee类的实现来探索几种常见的序列化和反序列化场景:

public enum EmployeeStatus { Active, Inactive }
public class Employee
{
    public int EmployeeId { get; set; }
    public string FirstName { get; set; }
    public string LastName { get; set; }
    public DateTime? HireDate { get; set; }
    public List<string> Telephones { get; set; }
    public bool IsOnLeave { get; set; }   

    [JsonConverter(typeof(StringEnumConverter))]
    public EmployeeStatus Status { get; set; }
    [JsonIgnore]
    public DateTime LastModified { get; set; }
    public override string ToString() => 
        $"[{EmployeeId}] {LastName}, {FirstName}";
}

尽管该库功能丰富,但在这里涵盖所有功能超出了本书的范围。我们建议阅读 Json.NET 的在线文档,网址为 https://www.newt](https://www.newtonsoft.com/json)onsoft.com/json。

获取包含Employee对象的 JSON 序列化的字符串非常简单,如下例所示:

var employee = new Employee
{
    EmployeeId = 42,
    FirstName = "John",
    LastName = "Doe"
};
var text = JsonConvert.SerializeObject(employee);

默认情况下,JsonConvert.SerializeObject()将生成缩小的 JSON,不包含缩进和空格。上述代码的结果是以下 JSON:

{"EmployeeId":42,"FirstName":"John","LastName":"Doe",
"HireDate":null,"Telephones":null,"IsOnLeave":false,
"Status":"Active"}

尽管这适用于在网络上传输数据,比如与 web 服务通信时,因为大小较小,它更难以被人类阅读。如果您希望 JSON 文档可读性强,应该使用缩进。这可以通过提供格式选项来指定,该选项可用于Formatting枚举。这里显示了一个示例:

var text = JsonConvert.SerializeObject(
    employee, Formatting.Indented);

这次,结果如下:

{
  "EmployeeId": 42,
  "FirstName": "John",
  "LastName": "Doe",
  "HireDate": null,
  "Telephones": null,
  "IsOnLeave": false,
  "Status": "Active"
}

缩进不是我们可以指定的唯一序列化选项。实际上,您可以使用JsonSerializerSettings类设置许多选项,该类可以作为SerializeObject()方法的参数提供。例如,我们可能希望跳过序列化引用的属性或字段,或者将设置为null的可空类型。例如,HireDateTelephones分别是DateTime?List<string>类型。可以按以下方式完成:

var text = JsonConvert.SerializeObject(
    employee,
    Formatting.Indented,
    new JsonSerializerSettings()
    {
        NullValueHandling = NullValueHandling.Ignore,
    });

在前面的示例中,我们使用的employee对象序列化的结果如下所示。您会注意到HireDateTelephones不再出现在生成的 JSON 中:

{
  "EmployeeId": 42,
  "FirstName": "John",
  "LastName": "Doe",
  "IsOnLeave": false,
  "Status": "Active"
}

可以为序列化指定的另一个选项控制默认值的处理方式。DefaultValueHandling是一个枚举,指定了默认值的成员应该如何被序列化或反序列化。通过指定Ignore,您可以使序列化器跳过输出中值与其类型的默认值相同的成员(对于数字类型为0,对于boolfalse,对于引用和可空类型为null)。实际上可以使用一个名为DefaultValueAttribute的属性来更改被忽略的默认值,该属性被指定在成员上。让我们考虑以下示例:

var text = JsonConvert.SerializeObject(
    employee,
    Formatting.Indented,
    new JsonSerializerSettings()
    {
        NullValueHandling = NullValueHandling.Ignore,
        DefaultValueHandling = DefaultValueHandling.Ignore
    });

这次,生成的 JSON 更加简单,如下所示。这是因为IsOnLeaveStatus属性分别设置为它们的默认值,即falseEmployeeStatus.Active

{
  "EmployeeId": 42,
  "FirstName": "John",
  "LastName": "Doe"
}

我们之前提到了一个叫做DefaultValueAttribute的属性。您可能已经注意到在Employee类的声明中使用了另外两个属性,JsonIgnoreAttributeJsonConverterAttribute。序列化可以通过属性进行控制,该库支持标准的.NET 序列化属性(如SerializableAttributeDataContractAttributeDataMemberAttributeNonSerializedAttributes)和内置的 Json.NET 属性。当两者同时存在时,内置的 Json.NET 属性优先于其他属性。内置的 Json.NET 属性如下表所示:

在这些属性中,我们使用了JsonIgnoreAttribute来指示Employee类的LastModified属性不应该被序列化,并使用了JsonConverterAttribute来指示Status属性应该使用StringEnumConverter类进行序列化。结果是该属性将被序列化为一个字符串(值为ActiveInactive),而不是一个数字(值为01)。

JsonConvert.SerializeObject()方法返回一个字符串。可以使用流(如文件或内存流)进行序列化和反序列化。但是,为此我们必须使用JsonSerializer类。该类具有重载的Serialize()Deserialize()方法,以及一系列属性,允许我们自定义序列化。以下示例显示了如何使用该类将迄今为止使用的员工对象序列化到磁盘上的文本文件中:

var path = Path.Combine(Path.GetTempPath() + "employee.json");
var serializer = new JsonSerializer()
{
    Formatting = Formatting.Indented,
    NullValueHandling = NullValueHandling.Ignore,
    DefaultValueHandling = DefaultValueHandling.Ignore
};
using (var sw = File.CreateText(path))
using (var jw = new JsonTextWriter(sw))
{
    serializer.Serialize(jw, employee);
}

我们指定了我们想要使用缩进并跳过null或具有类型默认值的成员。序列化的结果是一个文本文件,内容如下:

{
  "EmployeeId": 42,
  "FirstName": "John",
  "LastName": "Doe"
}

反序列化的相反过程也是直接的。使用JsonSerializer,我们可以从之前创建的文本文件中读取。为此,我们使用JsonTextReader,这是JsonTextWriter的伴侣类:

using (var sr = File.OpenText(path))
using (var jr = new JsonTextReader(sr))
{
    var result = serializer.Deserialize<Employee>(jr);
    Console.WriteLine(result);
}

从字符串反序列化也是可能且直接的,使用JsonConvert类。为此目的使用了重载的DeserializeObject()方法,如下所示:

var json = @"{
    ""EmployeeId"": 42,
    ""FirstName"": ""John"",
    ""LastName"": ""Doe""
}";
var result = JsonConvert.DeserializeObject<Employee>(json);

尽管被广泛使用,Json.NET 库也有一些缺点:

  • .NET 的string类型使用 UTF-16 编码,然而大多数网络协议,包括 HTTP,使用 UTF-8。Json.NET 在这两者之间进行转换,这会影响性能。

  • 作为第三方库,而不是基类库(或基础类库)的组件,您可能有依赖于不同版本的项目。ASP.NET Core 使用 Json.NET 作为依赖项,这有时会导致版本冲突。

  • 它没有利用新的.NET 类型,比如Span<T>,这些类型旨在增加某些情况下的性能,比如解析文本时。

为了克服这些问题,微软提供了自己的 JSON 序列化程序的实现,我们将在下一节中看到。

使用 System.Text.Json

这是.NET Core 随附的新 JSON 序列化程序。它取代了 ASP.NET Core 中的 Json.NET,现在提供了一个集成包。如果您的目标是.NET Framework 或.NET Standard,您仍然可以使用System.Text.Json,它作为一个 NuGet 包可用,也称为System.Text.Json

新的序列化程序的性能优于 Json.NET,主要有两个原因:它使用Span<T>和 UTF-8 本地化(因此避免了 UTF-8 和 UTF-16 之间的转码)。根据微软的说法,这个序列化程序在不同情况下可以提供 1.3 倍到 5 倍的加速。

然而,这些 API 受到了 Json.NET 的启发,对于简单的情况,如我们在本章的前一节中看到的情况,从 Json.NET 过渡是无缝的。以下示例显示了如何将Employee对象序列化为string

var employee = new Employee
{
    EmployeeId = 42,
    FirstName = "John",
    LastName = "Doe"
};
var text = JsonSerializer.Serialize(employee);

这看起来与 Json.NET 非常相似,它也生成了压缩的 JSON,您可以在以下代码中看到:

{"EmployeeId":42,"FirstName":"John","LastName":"Doe",
"HireDate":null,"Telephones":null,"IsOnLeave":false,
"Status":"Active"}

然而,可以通过提供各种选项来自定义序列化,例如缩进、处理空值、命名策略、尾随逗号、忽略只读属性等。这些选项由JsonSerializerOptions类提供。这里展示了一个缩进和跳过空值的示例:

var text = JsonSerializer.Serialize(
    employee,
    new JsonSerializerOptions()
    {
        WriteIndented = true,
        IgnoreNullValues = true 
    });

在这种情况下,输出如下:

{
  "EmployeeId": 42,
  "FirstName": "John",
  "LastName": "Doe",
  "IsOnLeave": false,
  "Status": "Active"
}

在这些示例中使用的Employee类的实现几乎与上一节中的实现相同。让我们看一下以下代码,试着找出区别:

public class Employee
{
    public int EmployeeId { get; set; }
    public string FirstName { get; set; }
    public string LastName { get; set; }
    public DateTime? HireDate { get; set; }
    public List<string> Telephones { get; set; }
    public bool IsOnLeave { get; set; }
    [JsonConverter(typeof(JsonStringEnumConverter))]
    public EmployeeStatus Status { get; set; }
    [JsonIgnore]
    public DateTime LastModified { get; set; }
    public override string ToString() => 
        $"[{EmployeeId}] {LastName}, {FirstName}";
}

我们再次使用了JsonIgnoreAttributeJsonConverterAttribute属性,指定LastModified属性应该被跳过,Status属性应该被序列化为字符串而不是数字。唯一的区别是我们在这里使用的转换器类型,称为JsonStringEnumConverter(而在 Json.NET 中称为StringEnumConverter)。然而,这些都不是System.Text.Json.Serialization命名空间。这些属性列在下表中:

从这个表中,我们可以看到System.Text.Json序列化程序不支持序列化和反序列化字段,这是 Json.NET 支持的功能。如果这是您需要的功能,您必须将字段更改为属性,为字段提供属性,或者使用支持字段的序列化程序。

如果您想对写入或读取的内容有更多控制,可以使用Utf8JsonWriterUtf8JsonReader类。这些类提供了高性能的 API,用于仅向前、无缓存的写入或只读读取 UTF-8 编码的 JSON 文本。在下面的示例中,我们将使用Utf8JsonWriter将 JSON 文档写入到磁盘上的文件中,其中包含一个员工:

var path = Path.Combine(Path.GetTempPath() + "employee.json");
var options = new JsonWriterOptions()
{
    Indented = true
};
using (var sw = File.CreateText(path))
using (var jw = new Utf8JsonWriter(sw.BaseStream, options))
{
    jw.WriteStartObject();
    jw.WriteNumber("EmployeeId", 42);
    jw.WriteString("FirstName", "John");
    jw.WriteString("LastName", "Doe");
    jw.WriteBoolean("IsOnLeave", false);
    jw.WriteString("Status", EmployeeStatus.Active.ToString());
    jw.WriteEndObject();
}

执行此代码的结果是一个文本文件,内容如下:

{
  "EmployeeId": 42,
  "FirstName": "John",
  "LastName": "Doe",
  "IsOnLeave": false,
  "Status": "Active"
}

要读取此处生成的 JSON 文档,我们可以使用Utf8JsonReader。但是,这个阅读器不适用于流,而是适用于原始数据的视图,以ReadOnlySpan<byte>ReadOnlySequence<byte>的形式。这个阅读器允许我们逐个令牌地读取数据并相应地处理它。下面的代码段中显示了一个示例:

byte[] data = Encoding.UTF8.GetBytes(text);
Utf8JsonReader reader = new Utf8JsonReader(data, true,
                                           default);
while (reader.Read())
{
    switch (reader.TokenType)
    {
        case JsonTokenType.PropertyName:
            Console.Write($@"""{reader.GetString()}"" : ");
            break;
        case JsonTokenType.String:
            Console.WriteLine($"{reader.GetString()},");
            break;
        case JsonTokenType.Number:
            Console.WriteLine($"{reader.GetInt32()},");
            break;
        case JsonTokenType.False:
        case JsonTokenType.True:
            Console.WriteLine($"{reader.GetBoolean()},");
            break;
    }
}

执行此代码的输出如下:

"EmployeeId" : 42,
"FirstName" : John,
"LastName" : Doe,
"IsOnLeave" : False,
"Status" : Active,

System.Text.Json序列化器比这里的示例所展示的要复杂。我们建议您阅读在线文档,以更好地熟悉其 API。

Json.NETSystem.Text.Json并不是.NET 中唯一的 JSON 序列化器,也不是性能最好的。如果 JSON 性能对您的应用程序很重要,您可能希望使用Utf8Json(可在github.com/neuecc/Utf8Json)或Jil(可在github.com/kevin-montrose/Jil)这两个序列化器,它们的性能优于本章中介绍的两个序列化器。

摘要

我们从System.IO命名空间的概述开始本章,并了解了它为处理文件系统提供的功能。然后我们学习了处理路径和文件系统对象。我们看到了如何创建、编辑、移动、删除或枚举文件和目录。

我们还学习了如何使用流从磁盘文件读取和写入数据。我们研究了不同类型的流,并学习了如何使用不同的流适配器向文件和内存流写入和读取数据。

在本章的最后部分,我们学习了数据序列化,学会了如何序列化和反序列化 XML 和 JSON。对于后者,我们探讨了 Json.NET 序列化器,这是最流行的.NET JSON 库,以及System.Text.Json,这是新的.NET JSON 库。

在下一章中,我们将讨论一个名为错误处理的不同主题。您将学习有关错误代码和异常以及处理错误的最佳实践。

测试你所学到的知识

  1. System.IO命名空间中用于处理文件系统对象的最重要的类是什么?

  2. 什么是连接路径的推荐方法?

  3. 如何获取当前用户临时文件夹的路径?

  4. FileFileInfo类之间有什么区别?DirectoryDirectoryInfo之间的区别呢?

  5. 您可以使用哪些方法来创建目录?枚举目录呢?

  6. .NET 中流的三个类别是什么?

  7. .NET 中流类的基类是什么,它提供了哪些功能?

  8. BinaryReaderBinaryWriter默认假定使用什么编码来处理字符串?如何更改这个设置?

  9. 如何将T类型的对象序列化为 XML?

  10. .NET Core 附带的 JSON 序列化器是什么,如何使用它来序列化T类型的对象?

第十四章:错误处理

从历史上看,管理运行时错误一直是一个难题,因为它们的性质复杂而不同,涵盖了从硬件故障到业务逻辑错误的各种情况。

其中一些错误,如除以零空指针解引用,是由 CPU 本身作为异常生成的,而其他一些是在软件级别生成的,并根据运行时和编程语言作为异常或错误代码传播。

.NET 平台已经设计了通过异常策略来管理错误条件,这具有极大的优势,可以大大简化处理代码。这意味着任何属性或方法都可能抛出异常,并通过异常对象传达错误条件。

抛出异常引发了一个重要问题——异常是库实现者和其使用者之间的契约的一部分,还是实现细节?

在本章中,我们将开始分析语言语法,以便从生产者或消费者的角度参与异常模型。然而,我们还需要超越语法,分析开发人员寻求调试原因和与错误抛出和错误处理相关的设计问题的影响。本章的以下三个部分将涵盖这些主题:

  • 错误

  • 异常

  • 调试和监控异常

在本章结束时,您将能够捕获现有库中的异常,了解方法是否应返回失败代码或抛出异常,并在有意义时创建自定义异常类型。

错误

在软件开发中,用于管理错误的两种策略是winerror.h文件,即使它们都是 Windows 操作系统的一部分。换句话说,错误代码不是标准的一部分,当调用穿越边界时(如不同的操作系统或运行时环境),它们需要被转换。

错误代码的另一个重要方面是它们是方法声明的一部分。例如,定义除法方法如下会感觉非常自然:

double Div(double a, double b) { ... }

但如果分母是0,我们应该向调用者传达无效参数错误。采用错误代码对方法签名有直接影响,在这种情况下将是以下内容:

int Div(double a, double b, out double result) { ... }

这个最后的签名(返回整数类型的错误代码)并不像任何库用户所期望的那样整洁。此外,调用代码有责任确定操作是否成功,这会引发多个问题。

第一个问题是代码检查错误代码的复杂性,就像这个例子:

var api = new SomeApi();
if (api.Begin() == 0)
{
    if (api.DoWork() == 0)
    {
        api.End();
    }
}

假设0是成功代码,每个块内部的代码都必须缩进,创建一个烦人且令人困惑的三角形,其大小与调用方法的数量一样大。即使通过逆转逻辑并检查失败条件,情况也不会改善,因为必须放置大量的if语句以避免讨厌的错误。

前面的代码还显示了一个常见情况,即api.End()方法返回一个看似无用的错误代码,因为它结束了调用序列,而实际上可能需要处理它。这个问题的根源在于错误代码将决定错误严重性的责任留给了调用者。异常模型的一个优点是它将这种权力交给了被调用的方法,它可以强制执行错误的严重性。这绝对更有意义,因为严重性很可能是特定于实现的。

前面的代码也隐藏了一个潜在的性能问题,这是由于现代 CPU 的特性,提供了一种称为分支预测的功能,这是 CPU 在预加载跳转后的指令时所做的一种猜测。根据许多因素,CPU 可能预加载一条路径,使其他路径运行得更慢,因为它们的代码没有被预取。

最后,就所有现代语言中设计的类型成员属性而言,它们与错误代码不匹配,因为没有语法允许调用者了解错误,因此使用异常是沟通问题的唯一方式。

出于所有这些原因,当.NET 运行时最初设计时,团队决定采用异常范例,将任何错误条件视为带外信息,而不是方法签名的一部分。

异常

异常是运行时提供的一种机制,可以使执行突然中断并跳转到处理错误的代码。由于处理程序可能由调用路径中的任何调用者声明,运行时负责恢复堆栈和任何其他未完成的finally块,我们将在本章的finally 块部分进行讨论。

调用代码可能希望处理异常,如果是这样,它可以决定恢复正常执行,或者只是让异常继续传递给其他处理程序(如果有的话)。每当应用程序没有提供处理代码时,运行时都会捕获错误条件,并做唯一合理的事情——终止应用程序。

这让我们回到了我们在介绍中提出的最初问题——异常是否是库实现者与其消费者之间的契约的一部分,还是一个实现细节?

由于实现者通过异常向其调用者传达异常情况,看起来异常是契约的一部分。至少这是其他语言实现者的结论,包括 Java 和 C++,它们都具有指定方法生成的可能异常列表的能力。无论如何,最近的 C++标准已经废弃并后来删除了声明中的异常规范,只留下了指定方法是否可能抛出异常的能力。

.NET 平台决定不将异常与方法签名绑定在一起,因为它被认为是一个实现细节。事实上,同一个接口或基类的多个实现可能使用不同的技术抛出不同的异常。例如,当你创建一个对象的代理时,你可能需要抛出不同类型的异常,除了代理对象中声明的异常。

由于异常不是签名的一部分,.NET 平台为所有可能的异常定义了一个名为System.Exception的基类。这种类型实际上是约束消费者(调用者)与生产者(被调用方法)之间的契约的一部分。

当然,.NET 运行时是捕获异常并负责执行匹配处理程序的主体。因此,异常只在.NET 上下文中有效,每次跨越边界时,都会有Win32ExceptionCOMException,它们都是从Exception派生而来。

显然,异常模型是管理错误的普遍良药,但仍有一个非常重要的方面需要考虑——性能方面

捕获异常、展开堆栈、调用相关的finally块以及执行其他必要的基础设施代码的整个过程需要时间。从这个角度来看,毫无疑问,错误代码的性能要好得多,但这是我们已经提到的所有优势的回报。

当我们谈论性能时,必须进行测量,这又取决于影响性能的代码是否经常运行。换句话说,如果异常使用是异常的,它不会影响整体性能。例如,System.IO.File.Exists方法返回一个布尔值,告诉我们文件是否存在于文件系统中。但是,这不会抛出异常,因为找不到文件不是一个异常情况,在重复调用时抛出异常可能会严重影响性能。

现在让我们通过检查处理异常所需的语句来动手编写代码。当您阅读以下各节时,您会注意到我们在第三章中简要介绍了一些这些概念,控制语句和异常,当我们谈论异常处理时。在本章中,我们将更深入地涵盖这些主题。

捕获异常

一般来说,最好在异常被抛出之前避免错误。例如,验证来自表示层的输入参数是您最好的机会。

在尝试打开和读取文件之前,您可能希望检查其是否存在:

if (!File.Exists(filename)) return null;
var content = File.ReadAllText(filename);

但是,这个检查并不能保护代码免受其他可能的错误,因为文件名可能包含在 Windows 和 Linux 操作系统中都被禁止的斜杠(/)。尝试对文件名进行消毒是没有意义的,因为在访问文件系统时可能会发生其他错误,比如错误的路径或损坏的媒体。

每当错误发生且无法轻易预防时,代码必须受到 C#语言提供的建议的保护:trycatch块语句。

以下片段演示了如何保护File.ReadAllText免受任何可能的错误:

try
{
    var content = File.ReadAllText(filename);
    return content.Length;
}
catch (Exception) { /* ... */ }
return 0;

try块包围了我们想要保护的代码。因此,File.ReadAllText抛出的任何异常都会导致执行立即停止(content.Length不会被执行),并跳转到匹配的 catch 处理程序。

catch块必须紧随try块之后,并指定只有在抛出的异常与圆括号内指定的类型匹配时才必须执行的代码。

前面的示例能够在catch块中捕获任何错误,因为Exception是所有异常的基类层次结构。但这未必是一件好事,因为您可能希望从特定异常中恢复,同时将其他失败的责任留给调用者。

信息框

大多数与文件名相关的问题可以通过添加File.Exists的检查来避免,但我们故意省略了它,以便在我们的示例中有更多可能的异常选择。

前面的片段可能会因为为文件名提供不同的值而失败。例如,如果filename为 null,则从File.ReadAllText方法抛出ArgumentNullException。如果相反,filename/,那么它会被解释为对根驱动器的访问,这需要管理员权限,因此异常将是System.UnauthorizedAccessException。当值为//时,会抛出System.IO.IOException,因为路径无效。

由于根据异常类型做出不同决策可能很有用,C#语法提供了指定多个catch块的能力,如下例所示:

try
{
    if (validateExistence && !File.Exists(filename)) return 0;
    var content = File.ReadAllText(filename);
    return content.Length;
}
catch (ArgumentNullException) { /* ... */ }
catch (IOException) { /* ... */ }
catch (UnauthorizedAccessException) { /* ... */ }
catch (Exception) { /* ... */ }

官方的.NET 类库文档包含了可以抛出异常的任何成员的异常部分。如果您使用 Visual Studio 并将鼠标悬停在 API 上,您将看到一个工具提示显示所有可能的异常的列表。以下截图显示了File.ReadAllText方法的工具提示:

图 14.1 - 显示 File.ReadAllText 方法的异常的工具提示

图 14.1 - 显示 File.ReadAllText 方法的异常的工具提示

现在让我们想象一下,filename指定了一个不存在的文件:在这段代码中会发生什么?根据工具提示异常列表,我们可以很容易地猜到会抛出FileNotFoundException异常。这个异常的类层次结构分别是IOExceptionSystemException,当然还有Exception

有两个 catch 块满足匹配——IOExceptionException——但第一个获胜,因为catch块的顺序非常重要。如果尝试颠倒这些块的顺序,将会得到编译错误,并在编辑器中得到反馈,因为这将导致无法访问的catch块。下面的例子显示了当指定catch(Exception)作为第一个时,Visual Studio 编辑器生成的红色波浪线:

图 14.2 - 当 catch(Exception)是第一个使用的异常时,编辑器会抱怨

图 14.2 - 当 catch(Exception)是第一个使用的异常时,编辑器会抱怨

编译器发出的错误是CS0160

error CS0160: A previous catch clause already catches all exceptions of this or of a super type ('Exception')

我们已经看到了如何在同一个方法中捕获异常。但异常模型的强大之处在于它能够沿着调用链向后查找最合适的处理程序。

在下面的例子中,我们有两个不同的方法,其中我们适当地处理了ArgumentNullException

public string ReadTextFile(string filename)
{
    try
    {
        var content = File.ReadAllText(filename);
        return content;
    }
    catch (ArgumentNullException) { /* ... */ }
    return null;
}
public void WriteTextFile(string filename, string content)
{
    try
    {
        File.WriteAllText(filename, content);
    }
    catch (ArgumentNullException) { /* ... */ }
}

即使这两个方法中已经声明了try..catch块,但无论何时发生IOException,这些处理程序都不会被调用。相反,运行时会开始在调用者链中寻找兼容的处理程序。这个完全由.NET 运行时管理的过程称为堆栈展开,它包括从堆栈中检索的第一个兼容处理程序的调用处跳转。

在下面的例子中,try..catch块拦截了可能由ReadAllTextWriteAllTextAPI 引发的IOException,这些 API 被ReadTextFileWriteTextFile方法使用:

public void CopyReversedTextFile(string source, string target)
{
    try
    {
        var content = ReadTextFile(source);
        content = content.Replace("\r\n", "\r");
        WriteTextFile(target, content);
    }
    catch (IOException) { /*...*/ }
}

无论调用堆栈有多深,try..catch块都将保护这段代码免受任何IOException的影响。

通过前面的所有例子,我们已经学会了如何区分异常类型,但catch块接收到该类型的对象,提供了关于异常性质的上下文信息。现在让我们来看看异常对象。

异常对象

除了异常类型之外,catch块语法还可以指定变量的名称,引用被捕获的异常。下面的例子展示了一个计算所有指定文件的内容字符串长度的方法:

int[] GetFileLengths(params string[] filenames)
{
    try
    {
        var sizes = new int[filenames.Length];
        int i = 0;
        foreach(var filename in filenames)
        {
            var content = File.ReadAllText(filename);
            sizes[i++] = content.Length;  // may differ from file size
        }
        return sizes;
    }
    catch (FileNotFoundException err)
    {
        Debug.WriteLine($"Cannot find {err.FileName}");
        return null;
    }
}

每当我们打开一个文件而没有先使用File.Exists来避免异常时,我们可能会收到FileNotFoundException。这个对象是IOException的一个特例,并暴露了一个Filename属性,提供了找不到的文件名。我甚至记不清有多少次我希望从有故障的应用程序中获得这样的反馈!

信息框

我们将在调试和监控部分更详细地了解基本异常成员,但您现在可以开始调查基类库中抛出的众多异常所暴露的属性。

下面的代码展示了另一个有趣的例子,同时捕获ArgumentException——当参数未通过使用它的方法的验证时发生的异常:

private void CopyFrom(string source)
{
    try
    {
        var target = CreateFilename(source);
        File.Copy(source, target);
    }
    catch (ArgumentException err)
    {
        Debug.WriteLine($"The parameter {err.ParamName} is invalid");
        return;
    }
}

catch块拦截了sourcetarget参数的故障。与source参数验证相关的任何错误都应该反弹到调用者,而target参数在本地计算。

我们如何只捕获我们感兴趣的异常?答案在于 C# 6 中引入的一种语言特性。

条件捕获

catch块可以选择性地指定一个when子句来限制处理程序的范围。

下面的示例与前一个示例非常相似,但将catch块限制为只钩住ArgumentException,其ParamName"destFileName",这是File.Copy方法的第二个参数的名称:

private void CopyFrom(string source)
{
    try
    {
        var target = CreateFilename(source);
        File.Copy(source, target);
    }
    catch (ArgumentException err) when (err.ParamName == "destFileName")
    {
        Debug.WriteLine($"The parameter {err.ParamName} is invalid");
        return;
    }
}

when子句接受任何有效的布尔表达式,不一定要使用catch块中指定的异常对象。

请注意,在此示例中,我们使用了"destFileName"字符串来指定File.Copy的第二个参数。如果您使用 Visual Studio,可以将光标放在所需的参数上,然后使用快捷键Ctrl + Shift + 空格键来查看参数名称,会显示以下建议窗口:

图 14.3 - 编辑器显示的建议窗口

图 14.3 - 编辑器显示的建议窗口

现在是时候转到生产者方面,看看我们如何抛出异常。

抛出异常

当我们使用已经提供所需参数验证的 API 时,您可以决定不验证参数,并最终抛出异常。在下面的示例中,我们打开一个日志文件,给定其名称由logName指定:

private string ReadLog(string logName)
{
    return File.ReadAllText(logName);
}

验证logName是否为 null 或空字符串的决定并没有提供任何价值,因为被调用的方法已经提供了考虑更多情况的验证,比如无效路径或不存在的文件。

logName参数可能表达不同的语义,指定日志的名称而不是要写入磁盘的文件名(如果有的话)。协调两种可能含义的解决方案是,如果尚未存在,则添加".log"扩展名:

private string ReadLog(string logName)
{
    var filename = "App-" + (logName.EndsWith(".log") ? logName : logName + ".log");
    return File.ReadAllText(filename);
}

这更有意义,但logName可能为null,导致在突出显示的代码上引发NullReferenceException异常,这将使故障排除变得更加困难。

为了解决这个问题,我们可以添加null参数验证:

private string ReadLog(string logName)
{
    if(logName == null) throw new ArgumentNullException(nameof(logName));
    var filename = "App-" + (logName.EndsWith(".log") ? logName : logName + ".log");
    return File.ReadAllText(filename);
}

throw语句接受任何继承自异常的对象,并立即中断方法的执行。运行时会钩住异常并将其分派到适当的处理程序,正如我们在前面的章节中已经调查过的那样。

提示

请注意使用nameof(logName)来指定有问题的参数的名称。我们在前一节中使用了这个参数来捕获File.Copy方法中的异常。确保永远不要将参数名称指定为文字。使用nameof()可以保证名称始终有效,并避免重构时出现问题。

throw语句非常简单,但请记住只在异常情况下使用它;否则,您可能会遇到性能问题。在下面的示例中,我们使用流行的Benchmark.NET微基准库比较了两个循环。LoopNop方法中的一个执行永远不会抛出异常的代码,而LoopEx中的另一个在每次迭代时都会抛出异常:

public int Loop { get; } = 1000;
[Benchmark]
public void LoopNop()
{
    for (var i = 0; i < Loop; i++)
    {
        try { Nop(i); }
        catch (Exception) { }
    }
}
[MethodImpl(MethodImplOptions.NoOptimization | MethodImplOptions.NoInlining)]
private void Nop(int i) { }

LoopNop方法只是循环执行Nop空方法 1,000 次。Nop方法被标记为NoInlining,以避免编译器优化删除调用。

第二种方法执行相同的循环 1,000 次,但调用Crash方法,该方法在每次迭代时都会抛出异常:

[Benchmark]
public void LoopEx()
{
    for (var i = 0; i < Loop; i++)
    {
        try { Crash(i); }
        catch (Exception) { }
    }
}
[MethodImpl(MethodImplOptions.NoOptimization | MethodImplOptions.NoInlining)]
private void Crash(int i) => 
    throw new InvalidOperationException();

Crash方法每次都创建一个新的异常对象,这是异常对象的实际用法。但即使每次重复使用相同的对象,异常模型的性能损失也是巨大的。

基准测试的结果是了解影响异常使用的数量级的想法,我们的示例中是四个数量级。

以下输出显示了基准测试的结果:

|        Method |          Mean |       Error | Allocated |
|-------------- |--------------:|------------:|----------:|
|       LoopNop |      2.284 us |   0.0444 us |         - |
|        LoopEx | 25,365.467 us | 486.2660 us |  320000 B |

这个基准测试只是证明了抛出异常必须只用于异常情况,并不应该对异常模型的有效性产生任何疑问。

我们已经看到了基类库中提供的一些异常类型。现在,我们将看一下最常见的异常以及何时使用它们。

常见的异常类型

基类库中提供的异常表达了最流行的故障类别的语义。在基类库中提供的所有异常中,值得一提的是开发人员最常使用的异常。在本章中,我们已经看到其他流行的异常,比如NullReferenceException,但它们通常只会由运行时抛出:

  • ArgumentNullException:通常在方法开头验证方法参数时使用。由于引用类型可能假定空值,因此用于通知调用者空值不是方法的可接受值。

  • ArgumentException:这是另一个在方法开头使用的异常。它的含义更广泛,当参数值无效时抛出。

  • InvalidOperationException:通常用于拒绝方法调用,每当对象的状态对于所请求的操作无效时。

  • FormatException:类库使用它来表示格式错误的字符串。它也可以用于解析文本以进行任何其他目的的用户代码。

  • IndexOutOfRangeException:每当参数指向容器的预期范围之外时使用,比如数组或集合。

  • NotImplementedException:用于通知调用者所调用的方法没有可用的实现。例如,当您要求 Visual Studio 在类主体内实现一个接口时,代码生成器会生成抛出此异常的属性和方法。

  • TypeLoadException:您可能很少需要抛出此异常。它通常发生在无法将类型加载到内存中时。每当在静态构造函数中发生异常时,通常会发生,并且除非您记得这个说明,否则可能很难诊断。

基类库的所有异常的详尽列表可以在Exception类文档中找到(docs.microsoft.com/en-us/dotnet/api/system.exception?view=netcore-3.1)。

在决定抛出异常时,非常重要的是使用完全表达错误语义的异常。每当在.NET 中找不到合适的类时,最好定义一个自定义异常类型。

创建自定义异常类型

定义异常类型就像编写一个简单的类一样简单;唯一的要求是继承自Exception等异常类型。

以下代码声明了一个用于表示应用程序数据层中的失败的自定义异常:

public class DataLayerException : Exception
{
    public DataLayerException(string queryKeyword = null)
        : base()
    {
        this.QueryKeyword = queryKeyword;
    }
    public DataLayerException(string message, string queryKeyword = null)
        : base(message)
    {
        this.QueryKeyword = queryKeyword;
    }
    public DataLayerException(string message, Exception innerException, string queryKeyword = null)
        : base(message, innerException)
    {
        this.QueryKeyword = queryKeyword;
    }
    public string QueryKeyword { get; private set; }
}

前面的自定义异常类定义了三个构造函数,因为它们旨在在开发人员构造它们时提供一致的体验:

  • 默认构造函数可能存在,每当您不需要使用额外参数构建异常时。在我们的情况下,默认情况下允许使用空的QueryKeyword构建异常对象。

  • 接受message参数的构造函数在表达可能简化诊断的任何人类信息时非常重要。消息应该只提供诊断信息,永远不应该显示给最终用户。

  • 接受内部异常的构造函数在提供有关导致当前错误情况的任何底层异常的额外信息方面非常有价值。

一旦定义了新的自定义异常,它就可以与throw语句一起使用。在下面的示例中,我们看到一些假设的代码向存储库发出查询并将底层错误条件转换为我们的自定义异常:

public IList<string> GetCustomerNames(string queryKeyword)
{
    var repository = new Repository();
    try
    {
        return repository.GetCustomerNames(queryKeyword);
    }
    catch (Exception err)
    {
        throw new DataLayerException($"Error on repository {repository.RepositoryName}", err, queryKeyword);
    }
}

被捕获的异常作为参数传递给构造函数,以保留错误的原始原因,同时抛出更好地表示错误性质的自定义异常。

catch块内部抛出异常揭示了关于错误语义的架构问题。在前面的例子中,我们无法恢复错误,但仍然希望捕获它,因为我们的应用程序的安装可能会导致被查询的存储库非常不同。例如,如果存储库是数据库,内部异常将与SQL Server相关,而如果是文件系统,它将是IOException

如果我们希望应用程序的更高级别能够适当地处理错误并有机会恢复错误,我们需要抽象底层错误并提供业务逻辑异常,例如我们定义的DataLayerException

信息框

.NET Framework 最初将ApplicationException定义为所有自定义异常的基类。由于没有强制执行,基类库本身从未广泛采用这种最佳实践。因此,当前的最佳实践是从Exception派生所有自定义异常,正如您可以在官方文档中阅读的那样:

docs.microsoft.com/en-us/dotnet/api/system.applicationexception?view=netcore-3.1

catch块内部抛出异常的能力并不局限于自定义异常。

重新抛出异常

我们刚刚看到了如何在catch块内部抛出一个新的异常,但是有一个重要的快捷方式可以重新抛出相同的异常。

catch块通常用于尝试恢复错误或仅仅记录它。在这两种情况下,我们可能希望让异常继续,就好像根本没有被捕获一样。C#语言为这种情况提供了throw语句的简单用法,如下例所示:

public string ReadAllText(string filename)
{
    try
    {
        return File.ReadAllText(filename);
    }
    catch (Exception err)
    {
        Log(err.ToString());
        throw;
    }
}

throw语句后面没有任何参数,但它相当于在catch块中指定相同的异常:

    catch (Exception err)
    {
        Log(err.ToString());
        throw err;
    }
}

除非err引用被更改以指向不同的对象,否则这两个语句是等价的,并且具有保留导致错误的原始堆栈的重大优势。无论如何,我们仍然能够向异常对象添加更多信息(HelpLink属性是一个典型的例子)。

如果我们抛出一个不同的异常对象,原始堆栈不是被抛出的异常的一部分,这就是为什么innerException存在的原因。

在某些情况下,您可能希望保存catch块捕获的异常,并稍后重新抛出它。通过简单地抛出捕获的异常,捕获的堆栈将会不同且不太有用。如果您需要保留最初捕获异常的堆栈,可以使用ExceptionDispatchInfo类,该类提供了两个简单的方法。Capture静态方法接受一个异常并返回一个包含Capture调用时刻的所有堆栈信息的ExceptionDispatchInfo实例。您可以保存这个对象,然后使用它的Throw方法抛出异常以及原始堆栈信息。这种模式在以下示例中显示:

public void Foo()
{
    ExceptionDispatchInfo exceptionDispatchInfo = null;
    try
    {
        ExecuteFunctionThatThrows();
    }
    catch(Exception ex)
    {                 
        exceptionDispatchInfo = ExceptionDispatchInfo.Capture(ex);
    }

    // do something you cannot do in the catch block

    // rethrow
    if (exceptionDispatchInfo != null)
        exceptionDispatchInfo.Throw();
}

在这里,我们调用一个抛出异常的方法,然后在catch子句中捕获它。我们使用静态的ExceptionDispatchInfo.Capture方法存储对这个异常的引用,这有助于保留调用堆栈。在方法的最后,我们使用ExceptionDispatchInfoThrow方法重新抛出异常。

最后的块

finally块是与异常管理相关的最后一个 C#语句。它非常重要,因为它允许表达在try块之后必须被调用的代码部分,无论是否发生异常。

在前面的章节中,我们已经看到了代码执行的行为,具体取决于是否发生异常。try块中的代码执行可能会被未决的异常中断,跳过该代码的部分。一旦发生错误,我们保证将执行匹配的catch块,使其有机会将问题写入日志,可能执行一些恢复逻辑。

finally块甚至可以在没有任何catch块的情况下指定,这意味着任何异常都将反弹到调用链,但是finally块中指定的代码将在try块之后的任何情况下执行。

以下示例显示了三个方法,它们的调用是嵌套的。第一个方法M1调用M2M2调用M3M3调用Crash,最终抛出异常,如下面的代码所示:

private void M1()
{
    try { M2(); }
    catch (Exception) { Debug.WriteLine("catch in M1"); }
    finally { Debug.WriteLine("finally in M1"); }
}
private void M2()
{
    try { M3(); }
    catch (Exception) { Debug.WriteLine("catch in M2"); }
    finally { Debug.WriteLine("finally in M2"); }
}
private void M3()
{
    try { Crash(); }
    finally { Debug.WriteLine("finally in M3"); }
}
private void Crash() => throw new Exception("Boom");

当我们调用M1并且调用链到达Crash时,在M3中没有catch块来处理异常,但是在离开方法之前它的finally块被调用。此时,运行时会反弹到M2的调用者,它捕获了异常,但也调用了它的finally代码。最后,由于异常已经被处理,M2自然地将控制返回给M1,并且它的finally代码也被执行,如下面的输出所示:

finally in M3
catch in M2
finally in M2
finally in M1

如果愿意,您可以通过向try块添加额外详细的日志记录来重复此实验,但这里的重点是finally块总是在离开方法之前始终执行

try..finally组合的另一个常见用途是确保资源已被正确释放,C#已经将这种模式作为关键字,即using语句。以下示例显示了两个等效的代码片段。C#编译器生成的 IL 代码基本相同,您可以使用ILSpy工具自行测试 IL 语言的反编译结果:

void FinallyBlock()
{
    Resource res = new Resource();
    try
    {
        Console.WriteLine();
    }
    finally
    {
        res?.Dispose();
    }
}
void UsingStatement()
{
    using(var res = new Resource())
    {
        Console.WriteLine();
    }
}

当然,using语句将其使用限制在实现IDisposable接口的对象上,但它生成相同的模式。这是我们在第九章中深入研究的一个主题,资源管理

现在我们已经从消费者和生产者的角度看到了异常的所有方面,我们将讨论与异常相关的问题的诊断调查。

调试和监视异常

调试异常与调试普通代码有些不同,因为自然流程被运行时中断和处理。除非在处理异常的代码上设置断点,否则有可能不理解问题从何处开始。当捕获异常并且没有重新抛出或者方法在catch块内没有重新抛出时,就会发生这种情况。

这可能看起来是异常模型的一个重要缺点,但.NET 运行时提供了克服这个问题的所有必要支持。事实上,运行时内置了对调试器的支持,为愿意拦截异常的调试器提供了有价值的钩子。

从调试器的角度来看,您有两种可能性,或者机会,来拦截任何被抛出的异常:

  • First-chance exceptions代表异常在非常早期阶段的状态,即它们被抛出并在跳转到其处理程序之前。拦截异常(在第一次出现时)的优势在于我们可以精确地确定是哪段代码导致了异常。相反,被拦截的异常可能是合法的并且被正确处理。换句话说,调试器将停止任何发生的异常,即使那些没有引起麻烦的异常。默认情况下,调试器在第一次出现异常时不会停止,但会在调试器输出窗口中打印跟踪。

  • 第二次机会未处理异常是致命的。这意味着.NET 运行时没有找到任何合适的处理程序来管理它们,并在强制关闭崩溃的应用程序之前调用调试器。当发生第二次机会异常时,调试器总是会停止,这总是代表一个错误条件。第二次机会异常会在输出窗口中打印,并在异常对话框中呈现为未处理的异常。

使用默认设置,Visual Studio 调试器将中断,显示在崩溃应用程序之前可能运行的最后一行代码。这段代码不一定是导致应用程序崩溃的原因;因此,您可能需要修改这些设置以更好地理解原因。

调试第二次机会异常

当抛出的异常在我们的源代码中可用时,调试器的默认设置足以理解问题的原因,如下例所示:

public void TestMethod1() => Crash1();
private void Crash1() => throw new Exception("This will make the app crash");

Visual Studio 调试器将在突出显示的代码处停止,显示臭名昭著的异常对话框:

图 14.4 - 显示异常类型、消息和获取更多信息的链接的对话框

图 14.4 - 显示异常类型、消息和获取更多信息的链接的对话框

输出窗口还提供了其他信息:

Exception thrown: 'System.Exception' in ExceptionDiagnostics.dll
An unhandled exception of type 'System.Exception' occurred in ExceptionDiagnostics.dll
This will make the app crash

Visual Studio 调试器不断改进诊断输出版本。在许多情况下,它能够打印出完全代表问题起源的消息。在以下示例代码中,异常是由null引用引起的:

public void TestMethod2() => Crash2(null);
private void Crash2(string str) => Console.WriteLine(str.Length);

对话框显示了一个str was null消息,告诉我们发生了什么:

图 14.5 - 在变量为 null 之前看到的异常对话框显示细节

图 14.5 - 在变量为 null 之前看到的异常对话框显示细节

同样,输出窗口显示了类似的消息:

**str** was null.

现在我们已经看到了调试器的默认行为,让我们考虑一个稍微复杂一点的场景。

调试首次机会异常

在本章中,我们强调了尝试从异常中恢复或重新抛出不同异常的价值,以便为调用代码提供更好的语义。在以下代码中,这增加了一些调试的困难:

public void TestMethod3()
{
    try
    {
        Crash1();
    }
    catch (Exception) { }
}

由于catch块没有重新抛出,异常被简单地吞噬,因此调试器根本不会中断。但这种情况可能会揭示问题的真正原因。我们如何要求调试器在这个异常处停止?

答案在 Visual Studio(或其他暴露相同功能的调试器)的异常窗口中。从调试 | 窗口 | 异常设置菜单,Visual Studio 将显示以下窗口:

图 14.6 - 异常设置窗口

图 14.6 - 异常设置窗口

.NET 运行时的相关异常是公共语言运行时异常项下的异常:

图 14.7 - 显示可选择异常的异常设置窗口的一部分

图 14.7 - 显示可选择异常的异常设置窗口的一部分

其中大多数异常都未选中,这意味着,正如我们已经说过的,调试器不会在首次机会异常处中断,除非选中该复选框。

例如,如果我们想在上一个示例的throw语句处中断,我们只需从列表中选择System.Exception

提示

请注意,此列表中的每个异常只包括确切的类型,而不包括派生类型的层次结构。换句话说,System.Exception不会挂钩整个层次结构。

通过浏览列表,您可能会注意到System.NullReferenceException和其他异常默认已被选中,因为这些异常通常被认为是应该通过在代码中验证参数来避免的错误。

由于异常列表非常长,Common Language Runtime Exceptions根项目是一个三状态切换器,可以选择所有项目、无项目或重置为默认设置。

AppDomain 异常事件

第一次和第二次机会异常也可以通过AppDomain对象提供的两个事件进行监视,但不能拦截。您可以通过在应用程序中使用以下代码订阅这些事件:

AppDomain.CurrentDomain.FirstChanceException += CurrentDomain_FirstChanceException;
AppDomain.CurrentDomain.UnhandledException += CurrentDomain_UnhandledException;
// ...
private static void CurrentDomain_FirstChanceException(object sender,     System.Runtime.ExceptionServices.FirstChanceExceptionEventArgs e)
{
    Console.WriteLine($"First-Chance. {e.Exception.Message}");
}
private static void CurrentDomain_UnhandledException(object sender, UnhandledExceptionEventArgs e)
{
    var ex = (Exception)e.ExceptionObject;
    Console.WriteLine($"Unhandled Exception. IsTerminating: {e.IsTerminating} - {ex.Message}");
}

大多数情况下,您不会希望监视第一次机会异常,因为它们可能不会对应用程序造成任何麻烦。无论如何,当您认为它们可能由于合理处理的异常而导致性能问题时,摆脱它们可能是有用的。

第二次机会(未处理)异常对于提供任何无法捕获或意外的异常的日志非常有用。此外,在桌面应用程序环境中,典型的用例是显示自定义崩溃对话框。

提示

请注意,.NET Core 始终只有一个应用程序域,而.NET Framework 可能有多个,这在Internet Information ServicesIIS)在重新启动主机进程时通常是真实的,特别是在 ASP.NET 应用程序中。

我们已经看到了在调试会话期间如何获取关于异常的详细信息以及记录它们的最佳选项。现在我们将看到异常对象中提供的调试信息的类型,这些信息可以在应用程序崩溃后使用。

记录异常

创建异常对象后,运行时会丰富其状态,以提供最详细的诊断信息,以便识别故障。无论您如何访问异常对象,无论是从catch块还是AppDomain事件,都有额外的信息可以访问。

我们已经讨论了InnerException属性,它递归地提供了对链中所有内部异常的访问。以下示例显示了如何迭代整个链:

private static void Dump(Exception err)
{
    var current = err;
    while (current != null)
    {
        Console.WriteLine(current.InnerException?.Message);
        current = current.InnerException;
    }
}

创建转储时访问内部异常并不是真正需要的,因为异常对象的ToString方法即使非常冗长也提供了整个链的转储。

ToString方法打印了运行时提供的StackTrace字符串属性,以捕获异常发生的整个方法链。

由于StackTrace是从运行时组装的字符串,异常对象还提供了TargetSite属性,它是MethodBase类型的反射对象,表示出错的方法。该对象公开了Name属性和方法名。

最后,GetBaseException方法返回最初生成故障的第一个异常,前提是任何重新抛出语句都保留了内部异常或未指定参数,正如我们已经在重新抛出异常部分讨论过的那样。如果您需要知道是否有异常被某个处理程序吞没,您将需要挂钩第一次机会异常事件。

还有更高级的调试技术,您可能希望使用“进一步阅读”部分提供的链接进行调查。它们包括创建转储文件,这是一个包含应用程序进程在崩溃时刻的内存的二进制文件。转储文件可以在以后使用调试工具进行调查。另一个强大且非常高级的工具是dotnet-dump analyze .NET Core 工具。

这些都是低级工具,通常用于所谓的dotnet-dump,它提供了.NET 特定的信息,除了本机调试器提供的标准元素。

例如,使用这些工具,您可以获取有关每个线程的当前堆栈状态、最近的异常数据、内存中每个对象是如何引用或被其他对象引用的、每个对象使用的内存以及与应用程序元数据和 .NET 运行时相关的其他信息。

摘要

在本章中,我们首先了解了为什么 .NET 采用了异常模型,而不是许多其他技术所使用的错误代码。

异常模型已经证明它非常强大,提供了一种高效而干净的方式来向调用链报告错误。它避免了用额外的参数和错误检查条件来污染代码,这可能在某些情况下导致效率损失。我们还通过基准测试验证了异常模型只能用于异常情况,否则可能严重影响应用程序的性能。

我们还详细看了 trycatchfinally 语句的语法,这些语句允许我们拦截和处理异常,并对任何未决资源进行确定性处理。

最后,我们还研究了诊断和日志记录选项,这些选项在提供所有必要信息以修复错误方面非常有用。

在下一章中,我们将学习 C# 8 的新功能,这些功能通过在性能和健壮性方面提供更多的表达能力和功能来增强语言。

测试你所学到的知识

  1. 哪个 block 语句可以用来包围可能引发异常的一些代码?

  2. 在任何 catch 块内的典型任务是什么?

  3. 在指定多个 catch 块时,应该遵守什么顺序,为什么?

  4. catch 语句中应该指定异常变量名吗?为什么?

  5. 你在 catch 块中捕获了一个异常。为什么你要重新抛出它?

  6. finally 块的作用是什么?

  7. 你可以指定一个没有 catch 块的 finally 块吗?

  8. 什么是第一次机会异常?

  9. 如何将 Visual Studio 调试器中的第一次机会异常中断?

  10. 何时需要挂钩 AppDomain 的 UnhandledException 事件?

进一步阅读

第十五章:C# 8 的新功能

C#是一种成熟的编程语言,但它仍在不断发展,以满足新兴软件架构带来的新需求。C# 7 的四个语言版本的主要重点是提供在使用值类型时令人印象深刻的性能工具。

随着最新版本的推出,C# 8 引入了许多重要的新功能,重点放在四个主要领域:使代码更紧凑、更易阅读,以及性能、健壮性和表现力。C# 8 的根本变化在于它是该语言的第一个版本,没有在.NET Framework 中获得官方支持,因为其中一些功能需要.NET Core 运行时的增强。

在本章中,我们将介绍以下新的语言特性:

  • 可空引用类型

  • 接口成员的默认实现

  • 范围和索引

  • 模式匹配

  • 使用声明

  • 异步 Dispose

  • 结构和 ref 结构中的可处置模式

  • 异步流

  • 只读结构成员

  • 空合并赋值

  • 静态局部函数

  • 更好的插值原始字符串

  • 在嵌套表达式中使用 stackalloc

  • 未管理的构造类型

在本章结束时,您将了解使用每个功能的用例,并能够逐步在您的列表中采用它们。一如既往,您越多地将这些功能付诸实践,您就越快掌握它们。

我们现在将开始介绍一种语言特性,它有着减少.NET 应用程序中主要崩溃原因之一NullReferenceException的伟大抱负。

可空引用类型

在上一章中,我们了解到 C#中的类型系统分为引用类型值类型。值类型分配在堆栈上,并且每次分配给新变量时都会进行内存复制。另一方面,引用类型分配在堆上,由垃圾收集器管理。每当我们分配一个新的引用类型时,我们都会收到一个引用,作为标识分配的内存的关键,从垃圾收集器那里。

引用本质上是一个指针,可以假定特殊的空值,这是指示值的缺失的最简单、因此最流行的方式。请记住,除了使用空值之外,另一个解决方案是采用特殊情况的架构模式,它的最简单形式是该对象的一个实例,其中包含一个布尔字段,指示对象是否有效,这就是Nullable<T>的工作原理。在许多其他情况下,开发人员实际上不需要使用空值,对其进行验证需要大量的代码,这将影响运行时性能。

空引用的问题在于编译器无法讨论潜在问题,因为它在语法上是正确的,但在运行时对其进行取消引用将导致NullReferenceException,这是.NET 世界中应用程序崩溃的首要原因。

让我们暂时考虑一个简单的类,它有两个构造函数,只有第二个初始化了_name字段:

public class SomeClass
{
    private string _name;
    public SomeClass() { }
    public SomeClass(string name) { _name = name; }
    public int NameLength
    {
        get { return _name.Length; }
    }
}

当使用第一个构造函数时,NameLength属性将导致NullReferenceException

在测试方面,这是突出显示以下两种情况的代码:

Assert.ThrowsException<NullReferenceException>(() => new SomeClass().NameLength);
Assert.IsTrue(new SomeClass("Raf").NameLength >= 0);

根本问题在于我们的代码行为取决于运行时的值,显然,编译器无法知道我们是否会在调用默认构造函数后初始化_name字段。

信息框

空引用是由 Tony Hoare 爵士在 1965 年发明的概念。然而,2009 年,他对自己的发明感到遗憾,称其为我的十亿美元的错误(https://en.wikipedia.org/wiki/Tony_Hoare)。由于无法轻松地从框架中删除空值,可空引用类型旨在使用代码分析方法解决这个问题。

这个概念在大多数编程语言中都很普遍,包括.NET 生态系统中的所有语言。这意味着任何试图从框架中移除空概念的努力都将是一个巨大的破坏性变化,可能会破坏当前的应用程序。编译器可以做什么来解决这个问题?答案是进行静态代码分析,这是一种在不运行代码的情况下了解源代码运行时行为的技术。

信息框

2011 年,微软开始着手进行一项名为Microsoft.CodeAnalysis的革命性项目,即公开为编译器通常完成的所有处理提供 API。

传统上,编译器是黑匣子,但 Roslyn 使得可以以编程方式解析源代码,获取语法和语义树,使用访问者检索或重写特定节点,并分析源代码的语义。

当开发人员在编辑器中看到黄色灯泡或代码下方的波浪线建议进行一些重构或指出潜在问题时,他们可能已经看到了静态代码分析在 Visual Studio 中的工作。通过编写自定义分析器,这些能力可以进一步扩展,分发为 Visual Studio 扩展或 NuGet 包。

由于静态代码分析无法知道引用在运行时所假定的值,它只检查所有可能的使用路径,并尝试判断其中是否可能会取消引用(使用点或方括号)。但是分析可以提出两种不同的策略,取决于是否希望引用假定空值:

  • 我们可能希望防止引用假定空值。在这种情况下,分析器将建议在声明或构造时进行初始化,并在任何后续赋值时进行初始化。

  • 我们可能需要引用假定空值。在这种情况下,分析器将验证是否有足够的空检查代码(if语句或类似的)来避免任何可能取消引用空值的路径。

在这两种策略之间的选择是开发人员的选择,开发人员需要提供额外的信息,以便编译器知道应该提供哪种反馈。

C# 8 可空引用类型功能支持两种策略的高级静态代码分析功能,这得益于能够注释引用以告知编译器有关预期引用使用的能力。为此,C#语法已经扩展,提供了装饰引用类型为可空的能力。根据这个新规则,在前面示例类中声明的字符串字段假定引用不为空,并且必须在构造时初始化:

private string _name;	// must be initialized at construction time

当开发人员希望给编译器一个提示,使_name引用可能为空时,他们必须用问号装饰来声明它:

private string? _name;

使用问号字符作为装饰符并不是新的;它在 C# 2 中引入,将Nullable<T>声明缩短为T?,并且包括将值类型包装到一个结构中,使用布尔字段来知道值类型是否设置为空。

引用类型的问号装饰在 C# 8 中是新的,其含义类似,但不涉及包装。相反,这种装饰只是一种通知代码分析有关引用预期使用的方式。

默认情况下,代码分析是关闭的,因为现有的应用程序总是假定任何引用都可能为空,并且在现有代码上默认启用它将导致大量的波浪线和编译器消息遍布整个代码。

注意

当引用用问号装饰,并且可空引用类型功能尚未启用时,Visual Studio 会用绿色波浪线标记问号,建议问号功能尚未生效,因为该功能尚未激活。

除了问号外,C#还添加了宽容运算符,表示为感叹号,用于通知代码分析在特定情况下宽容一个语句。使用宽容运算符是很少见的,因为这意味着分析未能识别开发人员自己知道引用不可能为空的情况。其使用的一个现实例子是当一些不安全/本地代码改变了由引用指向的内存值,而托管代码中没有任何证据。在其他非常极端的情况下,纯托管代码可能非常复杂,编译器无法识别它。我个人会选择简化代码而不是使用宽容运算符。

请记住,在声明引用时使用问号,在取消引用时使用感叹号。以下示例显示了一个语句,该语句将不会从静态代码分析中进行分析,并且不会提供任何反馈,因为开发人员在强烈承诺引用永远不会为空:

var len = _name!.Length;

值得重申,它应该只在极其罕见的情况下使用。

启用可空引用类型功能

有多种选项可以启用此功能;这样做的原因是能够逐步调整现有代码上的功能,而不会被阻塞或收到大量消息。每次启动新项目时,您可能希望完全启用此功能,以避免通过打开 Visual Studio 解决方案资源管理器、双击项目节点并编辑.csproj文件而受到过多的干扰。或者,您可以右键单击项目节点,然后从上下文菜单中选择编辑项目文件

通过添加可空的 XML 标记,该功能将在整个项目中启用,这是在启动新项目时的最佳选项:

<PropertyGroup>
  <TargetFramework>netcoreapp3.0</TargetFramework>
  <Nullable>enable</Nullable>
</PropertyGroup>

您可以在现有项目上执行相同的操作,但编译器提供的反馈可能过多,会分散开发人员的注意力。因此,C#编译器提供了四个新的编译指示,可以在选定的代码部分启用和禁用该功能。有趣的是,restore 编译指示可以恢复先前定义的设置,以允许编译指示的嵌套:

#nullable enable
public class SomeClass
{
    private string? _name;
    public SomeClass() { }
    public SomeClass(string name) { _name = name; }
    public int NameLength
    {
        // you should see a green squiggle below _name
        get { return _name.Length; }
    }
}
#nullable restore

该功能的可能设置范围使其能够实现其他细微差别,具体取决于您是否希望能够使用修饰符(问号和感叹号),和/或在可能导致NullReferenceException的代码上获得警告:

  • 同时启用警告和注释:这是通过仅启用该功能来完成的,正如我们之前提到的。根据此规则,可以使用问号对代码进行注释,以提示编译器有关引用的预期使用。代码编辑器将显示任何潜在问题,编译器将为这些问题生成警告:
Csproj: <Nullable>enable</Nullable>
Code: #nullable enable
  • Nullable功能可以在整个项目或选定的代码部分上使用:
Csproj: <Nullable>disable</Nullable>
Code: #nullable disable
  • 仅启用注释而不启用编译器警告:在现有项目中采用此功能时,可以非常有用地开始注释代码而不在 IDE 或编译器输出中收到任何警告。值得记住的是,许多公司强制执行门控检入,拒绝产生警告的任何代码。在这种情况下,可以在整个项目中启用注释,并逐渐逐个文件启用警告,以逐步迁移代码:
Csproj: <Nullable>annotations</Nullable>
Code: #nullable enable annotations
  • NullReferenceException
Csproj: <Nullable>warnings</Nullable>
Code: #nullable enable warnings
  • 在代码中恢复先前的设置(仅在代码文件中):在使用编译指示时,最好使用恢复编译指示标记给定区域的结束,而不是启用/禁用,以使嵌套区域正确运行:
#nullable restore annotations
#nullable restore warnings
  • 选择性地禁用设置(仅在代码文件中):最终设置是用于有选择地在代码的给定区域禁用注释或警告的设置。当您希望应用逆逻辑时,即为整个项目启用该功能并仅禁用代码的选定部分时,这将非常有用:
#nullable disable annotations
#nullable disable warnings

在现有项目中采用此功能时,这种细粒度控制可空引用类型功能的能力非常重要。除此之外,您可能会发现在整个项目范围内启用它更简单。

使用可空引用类型

一旦启用,代码分析将在代码编辑器中提供反馈,具体取决于引用是否已用问号装饰。开发人员可以选择不装饰变量,这意味着引用永远不应假定为空值。在这种情况下,声明看起来非常熟悉:

private string _name;

在这里,代码分析将标记负责不初始化字符串的构造函数代码,如果没有问号,则该字符串不能为 null。在这种情况下,补救措施很简单:您可以将_name变量初始化为一个空字符串,或者删除默认构造函数,强制所有调用者在创建对象时提供非空字符串。

另一种策略是将_name变量声明为可空的:

private string? _name;

当解除引用Length属性时,代码分析将显示绿色波浪线。在这种情况下,解决方案是显式检查_name是否为 null,并返回适当的值(或引发异常)。这是属性的可能实现:

public int NameLength2
{
    get
    {
        if (_name == null) return 0; else return _name.Length;
    }
}

以下是相同代码的另一种替代和更优雅的实现:

public int NameLength2 => _name?.Length ?? 0;

注释代码很简单,因为它类似于已经使用的可空类型的策略,但是对于数组,装饰略微复杂,因为游戏中存在两种可能的引用类型:数组本身和数组中保存的项目。

字符串数组可以声明如下:

private string[]?  _names; // array can be null
private string?[]  _names; // items in the array can be null
private string?[]? _names; // both the array and its items can
                           // be null
private string[]   _names; // neither of the two can be null

但请记住,我们使用的问号越多,我们需要做的空值检查就越多。让我们考虑这个简单的类:

public class OtherClass
{
    private string?[]? _names;
    public OtherClass() { }
    public int Count => _names?.Length ?? 0;
    public string GetItemLength(int index)
    {
        if (_names == null) return string.Empty;
        var name = _names[index];
        if (name == null) return string.Empty;
        return name;
    }
}

Count属性之所以短,仅因为我们使用了现代紧凑的语法,但它仍然包含了一个空值检查。GetItemLength返回数组中第 n 个项目的长度,由于数组和项目都可能为 null,因此需要两个不同的空值检查。

如果您只是考虑将GetItemLength方法的返回类型设置为string?,这种解决方案将使实现代码变得更短,但所有调用者都将被迫检查空值,需要进行更多的代码更改。

将现有代码迁移到可空引用类型

每个项目都有其自己的特点,但根据我的个人经验,我已经成功地确定了迁移现有项目到这一强大功能时的一些最佳实践。

第一个建议是从依赖树底部的项目开始启用此功能。在项目上下文中,您可能希望使用编译指示开始启用分析,从最常用的代码文件开始,例如助手、扩展方法等。

第二个建议是尝试避免使用问号:每次您用问号装饰引用时,代码分析都会要求您编写一些代码来证明空值解除引用不会发生,增加样板代码的数量,这可能会影响热路径的性能。

最后,当您使用这个功能编译一个库时,编译器会应用两个隐藏属性,以在元数据中留下关于代码中公开使用的引用的可空性的记录。每当编译引用您的库的一些代码时,编译器都会知道库方法是否接受可空引用,假设只有在属性明确宣传时才接受不可空引用参数。因此,在公共库中使用这个功能是最佳实践,这样其他人就可以从这些元数据中受益。

可空引用类型非常有用,可以减少运行时的NullReferenceException异常,这是应用程序崩溃的主要原因。

虽然这个功能是可选的,但使用编译指令逐渐应用所需的小改动以使代码具有空值保护是非常方便的。这是任何团队都应该将其技术债务添加到其中以提高代码质量的典型任务。除此之外,采用这一功能的库作者自动在其库中提供了可空性元数据,使整个引用链更加稳定。

接口成员的默认实现

我们已经学到,接口用于定义每个实现类型必须满足的合同。每个接口成员通过指定名称和其签名(输入和输出参数)来定义合同的一部分。然后,具体类型实现接口提供了定义成员的实现(或主体)。

通过接口成员的默认实现,C# 8 扩展了接口类型语法,包括以下功能:

  • 接口现在可以为方法属性索引器事件定义主体。

  • 接口可以声明静态成员,包括静态构造函数嵌套类型

  • 它们可以明确指定可见性修饰符,比如privateprotectedinternalpublic(后者仍然是默认值)。

  • 它们还可以指定其他修饰符,比如virtualabstractsealedexternpartial

这个新功能的语法很简单,就像给成员添加实现一样简单:

public interface ICalc
{
    int Add(int x, int y) => x + y;
    int Mul(int x, int y) => x * y;
}

乍一看,给接口成员添加实现似乎是矛盾的。事实上,前面的例子很好地演示了语法,但这绝对不是一个好的设计策略。您可能会想知道定义接口成员默认实现的一个好用例是什么。第一个原因是接口版本控制,传统上很难管理。

接口版本控制

例如,让我们从一个经典的接口IWelcome开始,声明两个简单的属性和一个Person类来实现它:

public interface IWelcome
{
    string FirstName { get; }
    string LastName { get; }
}
public class Person : IWelcome
{
    public Person(string firstName, string lastName)
    {
        this.FirstName = firstName;
        this.LastName = lastName;
    }
    public string FirstName { get; }
    public string LastName { get; }
}

现在可以添加一个具有默认实现的新方法:

public interface IWelcome
{
    string FirstName { get; }
    string LastName { get; }
    string Greet() => $"Welcome {FirstName}";
}

实现类不需要更新。它甚至可以驻留在不同的程序集中,而不会对接口的更改产生任何影响。

由于实现由接口提供,而类没有为Greet方法提供实现,因此仍然无法从Person引用中访问。换句话说,以下声明是不合法的:

var p = new Person("John", "Doe");
p.Greet(); // Wrong, Greet() is not available in Person

为了调用默认实现,我们需要一个IWelcome引用:

IWelcome p = new Person("John", "Doe");
Assert.AreEqual("Welcome John", p.Greet()); // valid code

这个功能对于一个历史悠久的接口的影响非常重要:例如,List<T>类公开了AddRange方法,而在IList<T>接口中不可用。在几乎 20 年的应用程序依赖于该接口之后,任何更改都将是一个巨大的破坏性变化。

接口上可能会发生什么变化?通过不鼓励使用ObsoleteAttribute来避免成员的使用,可以避免删除成员。也许几个版本之后,它将开始抛出NotImplementedException,而无需从接口中删除该成员。

改变成员总是一个不好的做法,因为接口是契约;通常,对于变更的需求可以通过使用不同名称和签名的新成员来建模。

因此,添加新成员是唯一的真正挑战,因为它会破坏二进制兼容性,并迫使每个接口实现者更改要求。例如,如果接口非常受欢迎,比如IList<T>,几乎不可能添加新成员,因为这将破坏所有人的代码。

传统上,接口版本问题是通过创建一个扩展先前接口的新接口来解决的,但这种解决方案并不实用,因为采用新接口需要实现者在对象继承声明中用新接口替换旧接口,并且当然要实现新成员。

C# 8 中的默认实现与普通类实现的行为不同,因为它为该层次结构定义了基线实现。假设您有一组接口层次结构和一个如下所示的类定义:

public interface IDog // defined in Assembly1
{
    string Name { get; }
    string Noise => "barks";
}
public interface ILabrador : IDog // defined in Assembly1
{
    int RetrieverAbility { get; }
}
public class Labrador : ILabrador // defined in Assembly2
{
    public Labrador(string name)
    {
        this.Name = name;
    }
    public string Name { get; }
    public int RetrieverAbility { get; set; }
}

在当前情况下,以下断言为真:

IDog archie = new Labrador("Archie");
Assert.AreEqual("barks", archie.Noise);

现在,修复ILabrador的默认实现并修改接口,如下所示:

public interface ILabrador : IDog
{
    int RetrieverAbility { get; }
    string IDog.Noise => "woofs"; // Version 2
}

值得注意的是,必须通过指定完整路径IDog.Noise来重新定义Noise方法。原因是因为.NET 允许接口进行多重继承;因此,在更复杂的继承结构中,可能会有多条路径导致Noise方法。

因此,语法要求指定完整路径以克服潜在的歧义。如果编译器发现无法通过指定完整路径解决的歧义,它将生成显式错误。

ILabrador的默认实现重新定义了IDogNoise的基线实现。这意味着,即使我们使用的是IDog引用,ILabrador中的更改也会影响结果,如下所示:

IDog archie = new Labrador("Archie");
Assert.AreEqual("woofs", archie.Noise); 

此外,您可能已经注意到在前面示例的注释中,接口和类位于两个不同的程序集中。如果包含ILabrador的第一个程序集重新编译并添加了新成员,而第二个程序集保持不变,您仍将看到Noise被更新为woofs。这意味着修补第一个程序集将使所有应用程序受益于更新,即使不重新编译整个代码。

接口重新抽象

从派生接口重新定义默认实现的能力对于理解重新抽象是至关重要的。原则是相同的,但派生接口可以决定擦除默认接口实现,将成员标记为抽象。

继续上面的例子,我们可以定义以下接口:

public interface IYellowLabrador : ILabrador
{
    abstract string IDog.Noise { get; }
}

但是,这次,新接口的实现者需要实现Noise方法:

public class YellowLabrador : IYellowLabrador
{
    public YellowLabrador(string name)
    {
        this.Name = name;
    }
    public string Name { get; }
    public int RetrieverAbility { get; set; }
    public string Noise { get; set; }
}

这种能力很有用,因为默认实现是为了提供最佳实现,可以被层次结构中所有类型通用使用。但是,这些类型中的某个分支可能与该实现不太匹配,您希望在接口级别上擦除它以避免任何不当行为。

接口作为特质

详细讨论特质组合的概念需要一个完整的章节,但值得注意的是,C# 8 刚刚打开了特质的大门,让语言的未来版本有机会填补空白,您可以在 C#语言公共存储库的设计说明中阅读到相关内容。

特质组合是其他语言(如 C++)中众所周知的概念。它涉及定义一组成员以确定一个众所周知的行为。目标是定义不同类型(特质),以便任何类都能通过继承特质来组合自己的行为能力。

在这个语言的发布之前,我们通常会创建静态帮助类来定义一组可重用的行为。在 C# 8 中,我们可以将这些成员定义在接口内,这样它们可以通过继承接口来重用。接口的选择非常方便,因为.NET 只支持接口上的多重继承,允许多个特质被继承到一个新的类中。

如果你要尝试使用特质,试着在不考虑经典接口用法的情况下对其进行建模;相反,看看它们固有的能力,能够打开到多重继承,从而组合一组方法。

特质通常在你要定义的每个类的行为的可用性非常依赖于每个类时非常有用。在设计方面,这将转化为一个非常长的接口列表,每个接口定义一个单一的行为,或者一个单一的接口,许多对象通过抛出NotImplementedException来实现其部分方法。

让我们试着看一个非常简单的例子,你想要向你的应用程序公开一个字母转换服务。有多种实现方法:使用 Windows 本机 API、一个 NuGet 库或一个云服务。我们可能会尝试定义一个单一的接口,其中包含支持从一个字母表到另一个字母表的所有可能排列的长列表方法,但这并不是很实用,因为这些库或服务只支持所有可能的转换的一部分。这将导致许多实现抛出NotImplementedException

另一种方法是为每种可能的转换定义一个接口,但实现这些接口的类需要将成员实现重定向到调用适当库的外部帮助类。

特质解决方案看起来更简单一些,因为它只是模拟我们可以做什么。例如,在这里,有两种可能的转换接口:

public interface ICyrillicToLatin
{
  public string Convert(string input)
  {
    return Transliteration.CyrillicToLatin(input, Language.Russian);
  }
}
public interface ILatinToCyrillic
{
  public string Convert(string input)
  {
    return Transliteration.LatinToCyrillic(input, Language.Russian);
  }
}

它们仍然是接口,但需要共同实现的类可以将接口添加到继承列表中,而无需其他任何操作:

class CompositeTransliterator : ICyrillicToLatin, ILatinToCyrillic
{
  // ...
}

最后,为了让消费者的生活更加便利,该类可以暴露一个使用模式匹配的开关表达式,以调用尝试转换成/从给定字母表的转换,并返回计算结果:

public string TransliterateCyrillic(string input)
{
    string result;
    return this switch
    {
        ICyrillicToLatin c when (result = c.Convert(input)) != input => result,
        ILatinToCyrillic l when (result = l.Convert(input)) != input => result,
        _ => throw new NotImplementedException("N/A"),
    };
}

这段代码尝试使用所有可用的服务对文本进行转换,如果其中一个服务由类实现,就尝试进行转换。一旦短语可以转换(即,转换结果与输入不同),就将其返回给调用者。

接口中的默认接口实现对所有实用主义者来说都是一个有价值的功能。Java 和 Swift 是已经支持这一功能的编程语言的例子。如果你是一个需要将你的代码移植到多种语言的库开发人员,它将使你的生活更加轻松,并避免重新设计代码的部分来克服它在语言先前版本中的缺失。

一如既往,建议明智地使用默认实现。如果用例已经很好地适应了以前的工具和模式,那么它就不会有用。

默认实现的一个有趣的边缘情况是,现在你可以用以下代码定义你的应用程序的入口点:

interface IProgram
{
    static void Main() => Console.WriteLine("Hello, world");
}

默认接口成员是一个具有争议性的功能,利用了.NET 接口支持多重继承的固有能力。实用主义者应该欣赏这个小革命所证明的实际用例,而其他人可以继续像以前一样使用接口。

现在我们可以继续下一个功能,这应该有助于避免在切片数组和列表时出现一些头痛和IndexOutOfRangeException异常。

范围和索引

C# 8 中引入的另一个方便的功能是用于标识序列中单个元素或范围的新语法。语言已经提供了使用方括号和数字索引在数组中获取或设置元素的能力,但通过添加两个运算符来标识从序列末尾获取项目和提取两个索引之间的范围,这个概念已经得到了扩展。

除了上述运算符之外,基类库现在还提供了两种新的系统类型,System.IndexSystem.Range,我们将立即看到它们的作用。让我们考虑一个包含六个国家名称的字符串数组:

var countries = new[] { "Italy", "Romania", "Switzerland", "Germany", "France", "England" };
var length = countries.Length;

我们已经知道如何使用数字索引器来获取对第一个项目的引用:

Assert.IsTrue(countries[0] == "Italy");

新的System.Index类型只是一个方便的包装器,可以直接用于数组上:

var italyIndex = new Index(0);
Assert.IsTrue(countries[0] == countries[italyIndex]);

有趣的部分是当我们需要从序列末尾开始处理项目时:

// first item from the end is length - 1
Assert.IsTrue(countries[length - 1] == "England");
var englandIndex = new Index(1, true);
Assert.IsTrue(countries[length - 1] == countries[englandIndex]);

新的^运算符为我们提供了一种简洁而有效的方法来获取最后一个项目:

Assert.IsTrue(countries[¹] == countries[englandIndex]);

重要的是要注意,从开始计数时零是第一个索引,但从末尾计数时,它指向总长度之外的一个项目。这意味着[⁰]表达式将始终抛出IndexOutOfRangeException

Assert.ThrowsException<IndexOutOfRangeException>(() => countries[⁰]);

在涉及范围时,新语法的价值更加明显,因为它是一种全新的概念,在语言或基类库中以前从未存在过。新的..运算符界定了两个用于标识范围的索引。运算符左侧和右侧的界定符也可以省略,无论何时都应该跳过边界处的项目。

以下示例展示了指定数组中所有项目的三种方式:

var countries = new[] { "Italy", "Romania", "Switzerland", "Germany", "France", "England" };
var expected = countries.ToArray();
var all1 = countries[..];
var all2 = countries[0..⁰];
var allRange = new Range(0, new Index(0, true));
var all3 = countries[allRange];
Assert.IsTrue(expected.SequenceEqual(all1));
Assert.IsTrue(expected.SequenceEqual(all2));
Assert.IsTrue(expected.SequenceEqual(all3));

expected变量只是获得了国家数组的克隆,方便的SequenceEqual Linq 扩展方法在两个序列中的项目相同且排序相同时返回 true。前面的示例并不是很有用,但突出了边界的语义:边界始终是包含的,而边界始终是排除的。

以下示例更加现实,并展示了指定范围的三种不同方式,只跳过序列的第一个项目:

var countries = new[] { "Italy", "Romania", "Switzerland", "Germany", "France", "England" };
var expected = new[] { "Romania", "Switzerland", "Germany", "France", "England" };
var skipFirst1 = countries[1..];
var skipFirst2 = countries[1..⁰];
var skipFirstRange = new Range(1, new Index(0, true));
var skipFirst3 = countries[skipFirstRange];
Assert.IsTrue(expected.SequenceEqual(skipFirst1));
Assert.IsTrue(expected.SequenceEqual(skipFirst2));
Assert.IsTrue(expected.SequenceEqual(skipFirst3));

同样,以下示例展示了如何跳过序列中的最后一个项目:

var countries = new[] { "Italy", "Romania", "Switzerland", "Germany", "France", "England" };
var expected = new[] { "Italy", "Romania", "Switzerland", "Germany", "France" };
var skipLast1 = countries[..¹];
var skipLast2 = countries[0..¹];
var skipLastRange = new Range(0, new Index(1, true));
var skipLast3 = countries[skipLastRange];
Assert.IsTrue(expected.SequenceEqual(skipLast1));
Assert.IsTrue(expected.SequenceEqual(skipLast2));
Assert.IsTrue(expected.SequenceEqual(skipLast3));

将所有内容放在一起很简单,以下示例展示了如何跳过序列的第一个和最后一个元素:

var countries = new[] { "Italy", "Romania", "Switzerland", "Germany", "France", "England" };
var expected = new[] { "Romania", "Switzerland", "Germany", "France" };
var skipFirstAndLast1 = countries[1..¹];
var skipFirstAndLastRange = new Range(1, new Index(1, true));
var skipFirstAndLast2 = countries[skipFirstAndLastRange];
Assert.IsTrue(expected.SequenceEqual(skipFirstAndLast1));
Assert.IsTrue(expected.SequenceEqual(skipFirstAndLast2));

指定起始和结束索引的范围语法可以从开始或末尾开始计数。在以下示例中,切片的数组将只返回第二个和第三个元素,都是从开始计数的:

var countries = new[] { "Italy", "Romania", "Switzerland", "Germany", "France", "England" };
var expected = new[] { "Romania", "Switzerland" };
var skipSecondAndThird1 = countries[1..3];
var skipSecondAndThirdRange = new Range(1, 3);
var skipSecondAndThird2 = countries[skipSecondAndThirdRange];
Assert.IsTrue(expected.SequenceEqual(skipSecondAndThird1));
Assert.IsTrue(expected.SequenceEqual(skipSecondAndThird2));

当从末尾计数时,同样有效,这是以下示例的目标:

var countries = new[] { "Italy", "Romania", "Switzerland", "Germany", "France", "England" };
var expected = new[] { "Germany", "France" };
var fromEnd1 = countries[³..¹];
var fromEndRange = new Range(new Index(3, true), new Index(1, true));
var fromEnd2 = countries[fromEndRange];
Assert.IsTrue(expected.SequenceEqual(fromEnd1));
Assert.IsTrue(expected.SequenceEqual(fromEnd2));

这种语法非常简单,但您可能已经注意到,我们只使用了数组,以及字符串,它们在 C#中被视为特殊。事实上,如果我们尝试在List<T>中使用相同的语法,它将无法工作,因为没有成员知道IndexRange是什么:

var countries = new MyList<string>(new[] { "Italy", "Romania", "Switzerland", "Germany", "France", "England" });
var expected = new[] { "Romania", "Switzerland", "Germany", "France" };
MyList<string> sliced = countries[1..¹];
Assert.IsTrue(expected.SequenceEqual(sliced));

现在的问题是,我们如何使以下测试通过?有三种不同的方法可以使其编译并工作。第一种方法很直接,就是提供一个接受System.Range作为参数的索引器:

public class MyList<T> : List<T>
{
  public MyList() { }
  public MyList(IEnumerable<T> items) : base(items) { }
  public MyList<T> this[Range range]
  {
    get
    {
      (var from, var count) = range.GetOffsetAndLength(this.Count);
      return new MyList<T>(this.GetRange(from, count));
    }
  }
}

List<T>基类提供了一个接受整数的索引器,而MyList<T>添加了一个接受Range类型的重载,它在 C# 8 中作为..语法的别名使用。在新的索引器中,我们使用Range.GetOffsetAndLength,这是一个非常方便的方法,它返回一个元组,其中包含切片的初始索引和长度。最后,List<T>.GetRange基本方法提供了用于创建新的MyList<T>集合的切片序列。

另一个使先前的测试通过的可能解决方案是利用特殊的Slice方法,C# 8 编译器会通过模式搜索。在我们之前编写的索引器不存在的情况下,如果编译器找到一个名为Slice的方法,该方法接受两个整数,它会将范围语法重新映射为对Slice方法的调用。因此,以下代码更整洁,更易于阅读:

public class MyList<T> : List<T>
{
    public MyList() { }
    public MyList(IEnumerable<T> items) : base(items) { }
    public MyList<T> Slice(int offset, int count)
    {
        return new MyList<T>(this.GetRange(offset, count));
    }
}

请注意,任何使用范围语法的调用,如countries[1..¹],都将调用Slice方法。

这个解决方案很好,但无法解决流行的List<T>类的问题,这个类几乎可以在代码的任何地方找到,特别是因为 Linq 扩展方法ToList()返回一个IList<T>。编写一个Slice扩展方法是行不通的,因为编译器会在实例方法中寻找Slice,而扩展方法是静态的。

解决方案是编写一个接受Range的扩展方法,如下例所示。这次,countries引用是任何继承ICollection<T>并支持使用countries.Slice(1..¹)的漂亮语法进行切片的集合:

public static class CollectionExtensions
{
    public static IEnumerable<T> Slice<T>(this ICollection<T> items, Range range)
    {
        (var offset, var count) = range.GetOffsetAndLength(items.Count);
        return items.Skip(offset).Take(count);
    }
}

在所有先前的例子中,我们都是使用它们的构造函数显式创建了IndexRange,但我建议花一些时间探索IndexRange类提供的便捷静态工厂,比如Range.All()Index.FromEnd()

范围和索引提供了强大且表达力强的运算符和类型,以简化序列中单个或多个项目的选择。其主要目的是使代码更易读,减少错误,而不影响性能。

关于范围的最重要建议是,范围的边界只在范围的左侧是包含的。

模式匹配

模式匹配是在 C# 7 中引入的,但语言规范的第 8 版通过平滑语法和更紧凑可读的方式扩大了其使用范围。本章将避免重复之前版本中已经看到的功能,只专注于新概念。

流行的switch语句在 C#中已经发展成为一个具有非常流畅语法的表达式。例如,假设您正在使用Console.ReadKey方法读取应用程序中的控制台键,以获取与RGB字符匹配的颜色:

public Color ToColor(ConsoleKey key) 
{
    return key switch
    {
        ConsoleKey.R => Color.Red,
        ConsoleKey.G => Color.Green,
        ConsoleKey.B => Color.Blue,
        _ => throw new ArgumentException($"Invalid {nameof(key)}"),
    };
}

或者,如果您更喜欢更紧凑的版本,我们可以这样写:

public Color ToColor(ConsoleKey key) => key switch
    {
        ConsoleKey.R => Color.Red,
        ConsoleKey.G => Color.Green,
        ConsoleKey.B => Color.Blue,
        _ => throw new ArgumentException($"Invalid {nameof(key)}"),
    };

switch表达式在语义上与 C# 7 模式匹配的先前创新没有改变;相反,它变得更简单,更紧凑,有一些重要的事情需要强调:

  • 作为表达式,switch语句必须返回一个值(在我们的示例中是Color枚举)。

  • 弃用字符(_)取代了经典switch语句中的default关键字。

  • 将键映射到颜色的子表达式按顺序进行评估,第一个匹配就会退出。

当使用switch表达式匹配类型时,事情可能变得更加有趣,如下例所示:

string GetString(object o) => o switch
   {
     string s   => $"string '{s}'",
     int i      => $"integer {i:d4}",
     double d   => $"double {d:n}",
     Derived d  => $"Derived: {d.Other}",
     Base b     => $"Base: {b.Name}",
     null       =>  "null",
     _          => $"Fallback: {o}",
   };

这个方法接受一个未知对象作为输入,并返回一个根据其运行时类型格式化不同的字符串,该类型必须与确切的类型匹配。例如,GetString((Int16)1)将不匹配,也不会返回字符串Fallback: 1。另一个失败的匹配是GetString(10.6m),因为字面量是十进制,返回的字符串将是Fallback: 10.6

在 C# 7 之前,对值类型或引用类型进行类型识别测试非常麻烦,因为它需要第二步,要么将值类型转换为所需类型,要么对引用类型进行空检查条件操作。多亏了 C# 7,我们学会了使用is模式匹配,当检查单个类型时非常完美。

使用新的 C# 8 语法,生成的代码更加简洁,更不容易出错,具有许多优点:

  • 在每种情况下都不必担心空引用,这对于方法成为即时编译器JIT)内联的更好候选者有积极的影响,从而提高性能。

  • 评估遵循顺序,这在测试类型层次结构时非常有用。在我们的例子中,评估Derived类在Base之前是至关重要的,否则switch表达式将始终匹配Base

  • 显式捕获null case中的空值避免了任何条件表达式。

switch表达式非常强大,但模式匹配的改进还没有结束。

递归模式匹配

模式匹配已经扩展,允许深入到对象属性和元组中。这一改进的基础语法包括在模式后的花括号中指定表达式的能力:

var weekDays = Enum.GetNames(typeof(DayOfWeek));
var expected = new[] { "Sunday", "Monday", "Friday", };
var six = weekDays
    .Where(w => w is string { Length: 6 })
    .ToArray();
Assert.IsTrue(six.SequenceEqual(expected));

花括号内的表达式只能指定属性,并且必须使用常量文字。这使我们能够匹配类型,并同时评估其属性,可能会在子表达式中重复。

当我们需要评估以图形结构化的对象时,真正的力量就会发挥作用,就像在Order类的两个Customer属性中一样。

public class Order
{
    public Guid Id { get; set; }
    public bool IsMadeOnWeb { get; set; }
    public Customer Customer { get; set; }
    public decimal Quantity { get; set; }
}
public class Customer
{
    public Guid Id { get; set; }
    public string Name { get; set; }
    public string Country { get; set; }
}

现在,假设我们正在开发一个电子商务应用程序,其中折扣取决于订单属性:

public decimal GetDiscount(Order order) => order switch
{
    var o when o.Quantity > 100 => 7.5m,
    { IsMadeOnWeb: true } => 5.0m,
    { Customer: { Country: "Italy" } } => 2.0m,
    _ => 0,
};

在这里,第一个子表达式重新分配了对o变量的引用,然后通过when子句评估了Quantity属性。如果满足o.Quantity > 100,则返回 7.5%的折扣。

在第二种情况下,当Order.IsMadeOnWeb为真时,返回 5%的折扣。第三种情况评估了通过导航Order.Customer.Country获得的属性,仅因为订单来自意大利,返回 2%的折扣。最后,丢弃字符表示回退到零折扣。

属性的语法很棒,但是当涉及元组时,情况会变得更加复杂,因为您可能希望匹配单个元组项,以及多个元组项,它们的位置也是至关重要的。

例如,考虑一个简单的Point结构,其中有两个整数属性XY

struct Point
{
    public Point(int x, int y)
    {
        X = x;
        Y = y;
    }
    public int X { get; set; }
    public int Y { get; set; }
}

我们如何编写一个方法,返回点是否位于水平轴或垂直轴上?如果XY为零,则满足条件;因此,一个可能的方法是这样做:

bool IsOnAxis(Point p) => (p.X, p.Y) switch
{
    (0, _) => true,
    (_, 0) => true,
    (_, _) => false,
};

传统上,我们会使用一个if和一个or运算符来编写这个方法,但是参数越多,代码就变得越难读。前面例子的一个有趣的地方是,我们动态构建了一个元组,并在switch表达式中对其进行了评估,通过它们的位置匹配参数,并丢弃(使用_字符)与评估无关的参数。

当编写Point结构中的特殊Deconstruct方法时,情况变得更加有趣,因为它简化了元组的创建:

public struct Point
{
    public Point(int x, int y)
    {
        X = x;
        Y = y;
    }
    public int X { get; set; }
    public int Y { get; set; }
    public void Deconstruct(out int x, out int y)
    {
        x = X;
        y = Y;
    }
}
public bool IsOnAnyAxis(Point p) => p switch
{
    (0, _) => true,
    (_, 0) => true,
    _ => false,
};

switch表达式中使用元组时,通过使用when子句评估其值,可以获得更多的功能。

在下面的例子中,我们使用when子句来识别对角线位置,除了轴。为此,我们定义了SpecialPosition枚举器,并使用switch表达式以及when子句来匹配对角线:

enum SpecialPosition
{
    None,
    Origin,
    XAxis,
    YAxis,
    MainDiagonal,
    AntiDiagonal,
}
SpecialPosition GetSpecialPosition(Point p) => p switch
{
    (0, 0) => SpecialPosition.Origin,
    (0, _) => SpecialPosition.YAxis,
    (_, 0) => SpecialPosition.XAxis,
    var (x, y) when x ==  y => SpecialPosition.MainDiagonal,
    var (x, y) when x == -y => SpecialPosition.AntiDiagonal,
    _ => SpecialPosition.None,
};

模式匹配在过去两个语言版本中获得了很大的力量,现在允许开发人员专注于代码的重要部分,而不会被以前语言规则所需的样板代码分散注意力。

switch表达式特别适用于那些结果可以从多个选择中得出的表达式,如果评估需要深入到对象图或评估元组。强大的丢弃字符允许部分评估,避免了通常复杂且容易出错的代码。

using 声明

using声明是一个非常方便的语法,相当于try/finally块,并确定性地调用Dispose方法。这个声明可以用于所有实现IDisposable接口的对象:

class DisposableClass : IDisposable
{
    public void Dispose() => Console.WriteLine("Dispose!");
}

我们已经知道,using声明在遇到其闭合大括号时会确定性地调用Dispose方法:

void SomeMethod()
{
    using (var x = new DisposableClass())
    {
        //...
    }	// Dispose is called
}

每当需要在同一作用域中使用多个可释放对象时,嵌套的using声明会导致令人讨厌的三角形代码对齐:

using (var x = new Disposable1())
{
    using (var y = new Disposable2())
    {
        using (var z = new Disposable3())
        {
            //...
        }
    }
}

如果Dispose方法在当前块(闭合大括号)的末尾被调用,无论该块是一个语句(如for/if/…)还是当前方法,这种烦恼最终可以被消除。

C# 8 中的新语法允许我们完全删除using声明中的大括号,将前面的示例转换为以下形式:

void SomeMethod()
{
    using (var x = new Disposable1());
    using (var y = new Disposable2());
    using (var z = new Disposable3());
    //...
} // Dispose methods are called

当前块的第一个闭合大括号将自动触发三个Dispose方法,按照声明的相反顺序。但关于Dispose还有更多内容要讨论;事实上,这种紧凑的语法也适用于async using声明,这将在下一节中介绍。

异步 Dispose

在.NET 中引入 Tasks 之后,大多数管理 I/O 操作的库逐渐转向异步行为。例如,System.Net.Websocket类成员采用基于任务的编程策略,提供更好的开发人员体验和更高效的行为。

每当开发人员需要编写一个 C#客户端来访问基于 WebSocket 协议的某些服务时,他们通常会编写一个包装类,公开专门的send方法,并实现释放模式以调用Websocket.CloseAsync方法。我们也知道任何异步方法都应该返回一个Task,但是 Dispose 方法在Task时代之前就已经定义为 void,因此不太适合Task链中。

Websocket 示例非常现实,因为我曾经遇到过这个确切的问题,即在 Dispose 内部阻塞当前线程等待 CloseAsync 完成会导致死锁。

从 C# 8 和.NET Core 3.0 开始,我们现在有两个重要的工具:

  • 在.NET Core 3 中定义的IAsyncDisposable接口,返回轻量级的ValueTask类型

  • 利用新的AsyncDisposable接口的await using构造

让我们看看如何在代码中使用它们:

public class AsyncDisposableClass : IAsyncDisposable
{
    public ValueTask DisposeAsync()
    {
        Console.WriteLine("Dispose called");
        return new ValueTask();
    }
}
private async Task SomeMethodAsync()
{
    await using (var x = new AsyncDisposableClass())
    {
        // ...
    }
}

值得记住的是,await using声明受益于简洁的单行语法,正如我们之前讨论的那样:

private async Task SomeMethodAsync()
{
    await using (var x = new AsyncDisposableClass());
}

如果您是一个公开可释放类型的库作者,您可以实现这两种接口中的任何一种,甚至同时实现IDisposableIAsyncDisposable接口。

结构体和 ref 结构中的可释放模式

随着时间的推移,C#引入了一些基于模式的构造来解决由于规则无法在每种情况下应用而导致的问题。例如,foreach语句不需要对象实现IEnumerable<>接口,而只需依赖于GetEnumerator方法的存在,同样,GetEnumerator返回的对象不需要实现IEnumerator,而只需公开所需的成员即可。

这一变化是由最近引入的ref 结构驱动的,它对减少垃圾收集器的压力很重要,因为它们保证只在堆栈上存在,但不允许实现接口。

基于模式的方法现在已经在特定条件下扩展到了DisposeDisposeAsync方法,我们现在将讨论这些条件。

从 C# 8 开始,开发人员可以定义DisposeDisposeAsync而无需实现IDisposableIAsyncDisposable。通过模式实现Dispose方法已经被限制ref struct类型,因为将其扩展到任何其他类型最终可能会导致已经定义了Dispose方法但未在继承列表中声明IDisposable的类型发生破坏性变化。

以下定义是DisposeDisposeAsync方法的有效实现:

ref struct MyRefStruct
{
    public void Dispose() => Debug.WriteLine("Dispose");
    public ValueTask DisposeAsync()
    {
        Debug.WriteLine("DisposeAsync");
        return default(ValueTask);
    }
}

Dispose方法可以像往常一样使用:

public void TestMethod1()
{
    using var s1 = new MyRefStruct();
}

但这种声明是不允许的,因为我们不能在异步方法中使用ref

public async Task TestMethod2()
{
    //await using var s2 = new MyRefStruct(); // Error!
}

解决方法是扩展await using声明,使用完整的try/finally

public Task TestMethod3()
{
    var s2 = new MyRefStruct();
    Task result;
    try { /*...*/ }
    finally
    {
        result = s2.DisposeAsync().AsTask();
    }
    return result;
}

这段代码肯定不好阅读,但我们应该考虑,在一个生命周期仅限于堆栈的类型中声明Dispose的异步版本可能不是一个好主意。

虽然通过模式实现的Dispose已经被预防性地限制为ref structs,但通过模式实现的DisposeAsync没有限制,因此在老式类中声明DisposeAsync并在await using语句中使用它是完全合法的。

异步流

异步流是任务故事的最后一块缺失的部分,这个故事始于几年前Task类、asyncawait首次引入。一个未解决的用例示例是在从互联网下载数据时处理数据块。基本点在于我们不想等待整个数据流,而是一次获取一个数据块,处理它,然后等待下一个。这样处理可以在其他数据仍在下载时进行,未使用的线程时间也可以用来为其他用户提供服务,增加应用程序的总可伸缩性。

在深入研究新的 C#特性之前,让我们快速回顾一下在同步世界中如何创建可枚举对象。以下示例展示了一个可在foreach语句中使用的可枚举序列;您可能会注意到,枚举类型是整数,而不是假设的从互联网下载的数据块组成的字节数组,但这并不重要。

最简单的实现利用了 C#迭代器,通过yield关键字实现:

static IEnumerable<int> SyncIterator()
{
    foreach (var item in Enumerable.Range(0, 10))
    {
        Thread.Sleep(500);
        yield return item;
    }
}

它的主要使用者当然是foreach语句:

foreach (var item in SyncIterator())
{
    // ...
}

在底层,编译器生成代码,暴露一个IEnumerable<T>,其职责是提供枚举器,一个由CurrentResetMoveNext成员组成的类来展开序列。这段代码的相关部分是MoveNext方法中的Thread.Sleep,模拟了一个缓慢的迭代。

以下代码是等效的,但手动实现了IEnumerableIEnumerator接口:

public class SyncSequence : IEnumerable<int>
{
    private int[] _data = Enumerable.Range(0, 10).ToArray();
    public IEnumerator<int> GetEnumerator() => new SyncSequenceEnumerator<int>(_data);
    IEnumerator IEnumerable.GetEnumerator() => new SyncSequenceEnumerator<int>(_data);
    private class SyncSequenceEnumerator<T> : IEnumerator<T>, IEnumerator, IDisposable
    {
        private T[] _sequence;
        private int _index;
        public SyncSequenceEnumerator(T[] sequence)
        {
            _sequence = sequence;
            _index = -1;
        }
        object IEnumerator.Current => _sequence[_index];
        public T Current => _sequence[_index];
        public void Dispose() { }
        public void Reset() => _index = -1;
        public bool MoveNext()
        {
            Thread.Sleep(500);
            _index++;
            if (_sequence.Length <= _index) return false;
            return true;
        }
    }
}

再一次,foreach语句可以轻松地消耗序列,共享由Thread.Sleep引起的阻塞线程的问题,而在现实生活中,这将是操作系统网络堆栈中正在进行的 I/O 操作:

foreach (var item in new SyncSequence())
{
    // ...
}

为了解决这个问题,C# 8 引入了非常方便的await foreach,用于迭代异步枚举,这又需要两个新接口:IAsyncEnumerable<T>IAsyncEnumerator<T>

新的异步流的最简单的生产者和消费者与以前的非常相似:

async IAsyncEnumerable<int> AsyncIterator()
{
    foreach (var item in Enumerable.Range(0, 10))
    {
        await Task.Delay(500);
        yield return item;
    }
}
await foreach (var item in AsyncIterator())
{
    // ...
}

如果我们需要手动实现这两个接口,那么与同步实现并没有太大不同,不出所料,我们需要实现MoveNext的异步版本MoveNextAsync

public class AsyncSequence : IAsyncEnumerable<int>
{
    private int[] _data = Enumerable.Range(0, 10).ToArray();
    public IAsyncEnumerator<int> GetAsyncEnumerator(CancellationToken cancellationToken = default)
    {
        return new MyAsyncEnumerator<int>(_data);
    }
    private class MyAsyncEnumerator<T> : IAsyncEnumerator<T>
    {
        private T[] _sequence;
        private int _index;
        public MyAsyncEnumerator(T[] sequence)
        {
            _sequence = sequence;
            _index = -1;
        }
        public T Current => _sequence[_index];
        public ValueTask DisposeAsync() => default(ValueTask);
        public async ValueTask<bool> MoveNextAsync()
        {
            await Task.Delay(500);
            _index++;
            if (_sequence.Length <= _index) return false;
            return true;
        }
    }
}

IEnumerator<T>IDisposable<T>派生一样,IAsyncEnumerator<T>接口从我们已经讨论过的IAsyncDisposable<T>派生。

MoveNextAsyncCurrentIAsyncEnumerator<T>接口需要的唯一其他成员,其方法返回轻量级ValueTask类型,这在DisposeAsync中已经见过。

注意

在撰写本文时,基类库中实现IAsyncEnumerable<T>的唯一类是System.Threading.Channel,因此为了充分利用异步流的功能,您应该采用外部库或自己实现这两个接口,这非常简单。

消费新的异步序列的代码在结构上是相同的:

await foreach (var item in new AsyncSequence())
{
    // ...
} 

为了完整起见,消费代码等同于以下内容:

var sequence = new AsyncSequence();
IAsyncEnumerator<int> enumerator = sequence.GetAsyncEnumerator();
try
{
    while (await enumerator.MoveNextAsync())
    {
        // some code using enumerator.Current
    }
}
finally { await enumerator.DisposeAsync(); }

静态的TaskAsyncEnumerableExtensions类包含一些扩展方法,允许配置IAsyncEnumerable对象,就像您从任何其他Task对象中期望的那样。

第一个扩展方法是ConfigureAwait,我们已经在第十二章中进行了讨论,多线程和异步编程。另一个是WithCancellation,它接受一个CancellationToken值,可以用于取消正在进行的任务。

异步流非常强大,因为它简化了开发人员的代码,同时使其更加强大。在生产者方面,实现所需的接口(IAsyncEnumerableIAsyncEnumerator)非常简单,而在消费者方面,由于新的async foreach,可以轻松地异步枚举序列。

一个缺点是当前的库生态系统与新接口不兼容。因此,社区已经编写了一组新的 Linq 风格的扩展方法,提供了与基类库中内置方法相同的外观和感觉

对于每种用例使用合适的工具也很重要。换句话说,并不是因为语言已经扩展了就需要将一切都转换成异步的。这只是每个开发人员在有意义时可以使用的重要工具。

只读结构成员

在 C# 7 引入readonly结构后,现在可以单独在其成员上指定readonly修饰符。这个特性是为了所有那些结构类型不能完全标记为只读的情况而添加的,但是当一个或多个成员可以保证不修改实例状态时。

我喜欢这个功能的主要原因是因为明确表达意图在维护和可用性方面是最佳实践。

从性能的角度来看也很重要,因为readonly结构为编译器提供了一种提示,可以应用更好的优化。修饰符可以应用于字段、属性和方法,以确保它不会改变结构实例,但不会对引用的对象提供任何保证。

处理属性时,修饰符可以应用于属性或者仅应用于其中一个访问器:

public readonly int Num0
{
    get => _i;
    set { } // not useful but valid
}
public readonly int Num1
{
    get => _i;
    //set => _i = value; // not valid
}
public int Num2
{
    readonly get => _i;
    set => _i = value; // ok
}
public int Num3
{
    get => ++_i;     // strongly discouraged but it works
    readonly set { } // does not make sense but it works
}

例如,让我们定义一个Vector 结构,公开两个返回向量长度的方法,其中只有一个标记为readonly

public struct Vector
{
    public float x;
    public float y;
    private readonly float SquaredRo => (x * x) + (y * y);
    public readonly float GetLengthRo() => MathF.Sqrt(SquaredRo);
    public float GetLength() => MathF.Sqrt(SquaredRo);
}

由于值类型(如Vector)在作为参数传递时会被复制,一个常见的解决方案是应用in修饰符(意味着readonly ref),就像以下示例中一样:

public static float SomeMethod(in Vector vector)
{
    // a local copy is done because GetLength is not readonly
    return vector.GetLength();
}

不幸的是,in修饰符不能保证引用地址的其他数据的不可变性。因此,一旦编译器看到调用GetLength方法,它就必须假设可能会对向量实例进行潜在更改,导致Vector的防御性隐藏本地副本,而不管它是通过引用传递的。

如果我们用只读的GetLengthRo方法替换对GetLength的调用,编译器会理解在修改Vector内容时没有风险,并且可以避免生成本地副本,从而提供更好的应用性能:

public static float ReadonlyBehavior(in Vector vector)
{
    // no local copy is done because GetLengthRo is readonly
    return vector.GetLengthRo();
}

值得一提的是,编译器足够聪明,可以提供一些自动优化。例如,自动生成的属性 getter 已经标记为只读,但请记住对所有其他不改变实例状态的成员应用readonly修饰符,为编译器提供重要提示,并获得尽可能好的优化。

注意

版本之后,编译器改进了其检测潜在副作用的能力,例如局部副本。您可以使用ildasmILSpy等反编译器自行验证生成的 IL 代码,但请注意,这些优化可能随时间而变化。

如果将方法标记为只读,即使它修改了实例的状态,编译器也会生成错误或警告,具体取决于情况:

  • 如果readonly方法尝试修改结构的任何字段,编译器将报告CS1604错误。

  • 每当代码访问不是只读属性 getter 时,编译器都会生成一个CS8656警告,以提醒生成所需的代码来创建结构的防御性隐藏本地副本,如消息描述中所述。

在 CS8656 警告消息中,编译器建议生成'this'的副本以避免改变当前实例:

"Call to a non readonly member '...' from a 'readonly' member results in an implicit copy of 'this'".

关于编译器识别不良情况的能力有一个重要的副作用。它无法检测任何试图修改对引用对象的更改,如下面的代码所示:

struct Undetected
{
    private IDictionary<string, object> _bag;
    public Undetected(IDictionary<string, object> bag)
    {
        _bag = bag;
    }
    public readonly string Description
    {
        get => (string)_bag["Description"];
        set => _bag["Description"] = value;
    }
}

虽然我们似乎在不对不修改值类型状态的结构成员应用readonly修饰符中看不到任何缺点,但一定要小心,因为这可能对热点路径的性能产生很大影响。

空值合并赋值

在 C# 8 中,空值合并运算符??已扩展以支持赋值。空值合并运算符的一个常见用法涉及方法开头的参数检查,就像以下示例中所示:

class Person
{
    public Person(string firstName, string lastName, int age)
    {
        this.FirstName = firstName ?? throw new ArgumentNullException(nameof(firstName));
        this.LastName = lastName ?? throw new ArgumentNullException(nameof(lastName));
        this.Age = age;
    }
    public string FirstName { get; set; }
    public string LastName { get; set; }
    public int Age { get; set; }
}

新的赋值允许我们在引用为 null 时重新分配引用,就像以下示例所示:

void Accumulate(ref List<string> list, params string[] words)
{
    list ??= new List<string>();
    list.AddRange(words);
}

参数列表最初可以为 null,在这种情况下,它将被重新分配给一个新实例,但在接下来的时间里,赋值将不再发生:

List<string> x = null;
Accumulate(ref x, "one", "two");
Accumulate(ref x, "three");
Assert.IsTrue(x.Count == 3);

空值合并赋值看起来并不是很重要,但它执行最右边表达式的能力是一个你不应该低估的重要价值。

静态本地函数

引入了本地函数,通过将某个代码片段的可见性限制为单个方法,使代码更易读:

void PrintName(Person person)
{
    var p = person ?? throw new ArgumentNullException(nameof(person));
    Console.WriteLine(Obfuscated());
    string Obfuscated()
    {
        if (p.Age < 18) return $"{p.FirstName[0]}. {p.LastName[0]}.";
        return $"{p.FirstName} {p.LastName}"; 
    }
}

在这个例子中,Obfuscated方法只能被PrintName使用,并且具有忽略任何参数检查的优势,因为p捕获的参数在使用它的上下文中不允许其值为 null。这可以在复杂的场景中提供性能优势,但它捕获局部变量(包括this)的能力可能会令人困惑。

在 C# 8 中,现在可以通过将本地函数标记为静态来避免任何捕获:

private void PrintName(Person person)
{
    var p = person ?? throw new ArgumentNullException(nameof(person));
    Console.WriteLine(Obfuscated(p));
    static string Obfuscated(Person p)
    {
        if (p.Age < 18) return $"{p.FirstName[0]}. {p.LastName[0]}.";
        return $"{p.FirstName} {p.LastName}";
    }
}

这个方法的新版本强化了它自我描述的能力,同时仍然具有忽略由于已知上下文而导致的任何参数检查的优势。值得注意的是,捕获通常在性能方面不是问题,但可能严重影响可读性,因为 C#默认允许自动捕获,与 C++ lambda 等其他语言形成对比。

更好的插值原始字符串

我们已经学会了字符串文字支持一些变体以避免转义字符:

string s1 = "c:\\temp";
string s2 = @"c:\temp";
Assert.AreEqual(s1, s2);

它们也可以用于改进格式,感谢插值:

var s3 = $"The path for {folder} is c:\\{folder}";

自从插值字符串被引入以来,我们一直能够混合两种格式化样式:

var s4 = $@"The path for {folder} is c:\{folder}";
Assert.AreEqual(s3, s4);

但在 C# 8 之前,不可能颠倒$@字符:

var s5 = @$"The path for {folder} is c:\{folder}";
Assert.AreEqual(s3, s5);

有了这个小改进,你就不必再担心前缀的顺序了。

在嵌套表达式中使用 stackalloc

在 C# 7 中,我们开始使用Span<T>ReadOnlySpan<T>Memory<T>,因为它们是保证在堆栈上分配的ref struct实例,因此不会影响垃圾收集器。由于Span,也可以避免声明直接分配给SpanReadOnlySpanstackalloc语句作为不安全的:

Span<int> nums = stackalloc int[10];

从 C# 8 开始,编译器将stackalloc的使用扩展到任何期望SpanReadOnlySpan的表达式。在下面的例子中,测试从input字符串中修剪了三个特殊字符,得到了expected变量中指定的字符串:

string input = " this string can be trimmed \r\n";
var expected = "this string can be trimmed";
ReadOnlySpan<char> trimmedSpan = input.AsSpan()
    .Trim(stackalloc[] { ' ', '\r', '\n' });
string result = trimmedSpan.ToString();
Assert.AreEqual(expected, result);

前面例子中执行的操作如下:

  • AsSpan扩展方法将字符串转换为ReadOnlySpan<char>

  • Trim扩展方法将ReadOnlySpan<char>的边界缩小到stackalloc数组指定的字符。这个Trim方法不需要任何分配。

  • 最后,调用ToString方法从ReadOnlySpan<char>创建一个新的字符串。

这段代码的优势在于,除了用于验证测试的新的int[]表达式和用于创建结果的ToString方法之外,不会执行其他堆分配。

未管理构造类型

在深入研究这个新的 C#特性之前,有必要通过分析语言规范中引用的“未管理”和“构造类型”的定义来理解这个主题:

  • 如果类型是泛型的,并且类型参数已经定义,则称为“构造”类型。例如,List<string>是一个构造类型,而List<T>则不是。

  • 当它可以在不安全的上下文中使用时,类型被称为“未管理”。这对许多内置的基本类型都是正确的。官方文档包括这些类型的列表:sbytebyteshortushortintuintlongulongcharfloatdoubledecimalboolenumspointersstruct

在 C# 8 之前无法声明的未管理构造类型的一个例子如下:

struct Header<T>
{
    T Word1;
    T Word2;
    T Word3;
}

允许泛型结构体为未管理的两个主要优势如下:

  • 它们可以使用stackalloc在堆栈上分配。

  • 我们可以使用这些类型与指针和不安全的代码一起与本机代码进行互操作。当处理本机块时,这是有用的,其字段可以是 32 位或 64 位:

Span<Header<int>> records1 = stackalloc Header<int>[10];
Span<Header<long>> records2 = stackalloc Header<long>[10];

有了这个特性,语言规范朝着简化本机互操作性的方向发展,而不会导致以往需要使用 C 或 C++语言的性能损失。

总结

毫无疑问,新的 C# 8 功能标志着代码健壮性和清晰度方面的重要里程碑。语言变得越来越复杂和难以阅读并不罕见,但 C#引入了诸如模式匹配和范围等功能,使任何开发人员都能用更简洁和明确的代码表达其意图。

尽管有争议,但默认接口成员将 Traits 范式引入了.NET 世界,并解决了接口版本化等多年来困扰开发人员的问题。

我们了解了一个关键特性,即内置可空引用静态代码分析,它允许我们逐步审查代码并大大减少由于取消引用空引用而导致的错误数量。

这并不是为了提高生产力而调整语言的终点,因为我们继续通过 C#7 性能之旅,引入了异步流、只读结构成员以及对stackalloc和未管理构造类型的更新,所有这些都使 C#成为本机语言中的一个引人注目的竞争者,同时仍然强制执行代码安全性。

其他较小的特性,如简洁的using声明、异步Dispose、可处置模式、静态局部函数、插值字符串的修复和空值合并赋值,都非常容易记住并提供实际优势。

新的语言特性不仅仅是开发人员瑞士军刀中的额外工具,而是改进代码库的重大机会。如果我们回到过去,想想 C# 2.0 引入的泛型类型,它们大大提高了生产力和性能。后来,语言引入了 LINQ 查询、lambda 表达式和扩展方法,从而带来了更多的表现力,并开启了之前更加困难的新设计策略。整个编程语言的历史,不仅仅是 C#,都以改进满足现代开发需求为特点。如今,应用程序开发明显倾向于通过采用持续集成/持续交付CI/CD)流水线来缩短开发周期,这带来了对代码质量和生产力的强烈要求。考虑到这个更广泛的视角,毫无疑问,跟上最新语言特性的步伐对于任何开发人员来说都是必须的。

在下一章中,我们将学习.NET Core 3 如何将语言形式转化为运行代码,无论是在 Windows 还是 Linux 上。我们将学习创建一个可以从任何.NET 运行时环境中使用的库;使用包,这是这个生态系统的真正丰富之处;最后,发布应用程序,将我们所有的工作转化为最终用户的巨大价值。

测试你所学到的知识

  1. 你如何最小化代码中的NullReferenceException异常数量?

  2. 使用什么语法来读取数组中的最后一个项目?

  3. 在使用switch表达式时,什么关键字等同于使用弃置字符(_)?

  4. 你如何等待一个异步调用来关闭Dispose方法中的文件?

  5. 在下面的语句中,当分配orders变量时,方法调用是否在每次执行时都被调用?

var orders ??= GetOrders();
  1. 定义一个序列为IAsyncEnumerable是必须的,才能用新的async foreach语句进行迭代吗?

进一步阅读

如果你想要跟踪 C#的发展,你可以在 GitHub 上查看关于语言下一个版本的提案和讨论:https://github.com/dotnet/csharplang。

第十六章:使用.NET Core 3 中的 C#

C#编程语言是我们用来将想法转化为可运行代码的媒介。在编译时,整套规则、语法、约束和语义都被转换为中间语言——一种用于指导公共语言运行时(CLR)的高级汇编语言,后者提供运行代码所需的必要服务。

为了执行一些代码,像 C、C++和 Rust 这样的本地语言需要一个轻量级的运行时库来与操作系统(OS)交互,并执行程序加载构造函数析构函数等抽象。另一方面,像 C#和 Java 这样的高级语言需要一个更复杂的运行时引擎来提供其他基本服务,如垃圾回收即时编译异常管理

当.NET Framework 首次创建时,CLR 被设计为仅在 Windows 上运行,但后来,许多其他运行时(实现相同的 ECMA 规范)出现,对市场起着重要作用。例如,Mono 运行时是第一个在 Linux 平台上运行的社区驱动项目,而微软的 Silverlight 项目在所有主要平台的浏览器中都取得了短暂的成功。其他运行时,如用于微控制器的.NET Micro Framework,用于针对嵌入式 Windows CE 操作系统的.NET Compact Framework,以及在 Windows Phone 和通用 Windows 平台上运行的更近期的运行时的例子,都展示了.NET 实现的多样性,这些实现能够运行我们今天仍在使用的相同一组指令。

这些运行时都是根据当时的历史背景所规定的一系列要求构建的,没有例外。在大约 20 年前诞生时,.NET Framework 旨在满足不断增长的基于 Windows 的个人电脑生态系统,其 CPU 功率、内存和存储空间随着时间的推移而增长。多年来,大多数这些运行时成功地转向了更受限制的硬件规格,仍然提供大致相同的功能集。例如,即使现代手机具有非常强大的微处理器,代码效率对于保护这些设备的电池寿命仍然至关重要,这是.NET Framework 最初设计时不相关的要求。

尽管这些运行时使用的.NET 规范仍然相同,但存在差异,使得每个开发人员在尝试设计能够在多个运行时上运行的应用程序时变得困难,特别是当要求它能够跨平台和/或跨设备运行时。

.NET Core 3 运行时诞生于解决这些问题,通过提供满足所有现代要求的新运行时。在本章中,我们将研究开发 C#应用程序时与运行时相关的因素:

  • 使用.NET 命令行界面(CLI)

  • 在 Linux 发行版上开发

  • .NET 标准是什么以及它如何帮助应用程序设计

  • 消费 NuGet 包

  • 迁移使用.NET Framework 设计的应用程序

  • 发布应用程序

到本章结束时,您将更熟悉允许您编译和发布应用程序的.NET Core 工具,以便您可以设计一个库,与在.NET Core 或其他运行时版本上运行的其他应用程序共享代码。此外,如果您已经有一个基于.NET Framework 的应用程序,您将学习迁移它以充分利用.NET Core 运行时的主要步骤。

使用.NET 命令行界面(CLI)

命令行界面CLI)是.NET 生态系统中的一个新但战略性的工具,它可以在所有平台上以相同的方式使用,实现现代的开发方法。乍一看,基于旧控制台的工具定义为“现代”可能看起来很奇怪,但在现代开发世界中,脚本化构建过程以支持持续集成持续交付/部署CI/CD)策略对于提供更快和更高质量的开发生命周期至关重要。

安装.NET Core SDK(参见dotnet.microsoft.com/)后,可以通过 Linux 终端或 Windows 命令提示符使用.NET CLI。在 Windows 上的一个很好的替代品是新的Windows 终端应用程序,可以通过 Windows 商店下载,并提供了传统命令提示符以及PowerShell终端的很好替代。

.NET CLI 具有丰富的命令列表,可以完成整个开发生命周期的一整套操作。通过将––help字符串添加为最后一个参数,可以获得每个命令的详细和上下文帮助。最相关的命令如下:

  • dotnet newnew命令基于预定义的模板创建一个新的应用程序项目或解决方案的文件夹,这些模板可以很容易地安装在默认模板之外。仅输入此命令将列出所有可用的模板。

  • dotnet restorerestore命令从 NuGet 服务器还原引用的库(在默认的nuget.org互联网软件包存储库之外,用户可以创建一个nuget.config文件来指定其他位置,如 GitHub,甚至是本地文件夹)。

  • dotnet runrun命令在一个步骤中构建,还原和运行项目。

  • dotnet testtest命令运行指定项目的测试。

  • dotnet publishpublish命令创建可部署的二进制文件,我们将在发布应用程序部分讨论。

除了这些命令之外,.NET CLI 还可以用于调用其他工具。其中一些是预安装的。例如,dotnet dev-certs是一个用于管理本地机器上的 HTTPS 证书的工具。提供的预安装工具的另一个例子是dotnet watch,它观察项目中对源文件所做的更改,并在发生任何更改时自动重新运行应用程序。

dotnet tool命令是扩展 CLI 功能的入口,因为它允许我们通过配置的 NuGet 服务器下载和安装附加工具。在撰写本文时,尚无法在nuget.org上过滤包含.NET 工具的软件包;因此,您最好的选择是阅读文章或其他用户的建议。

在创建新项目(使用 CLI)时,您可能希望首先决定运行时版本。dotnet ––info命令返回所有已安装的运行时和 SDK 的列表。默认情况下,CLI 使用最近安装的global.json。此文件中的设置将影响包含该文件的文件夹下的所有操作所使用的.NET CLI(也被 Visual Studio 使用):

C:\Projects>dotnet new globaljson
The template "global.json file" was created successfully.

现在,您可以使用您喜欢的编辑器编辑文件,并将 SDK 版本更改为先前列出的值之一:

{
    "sdk": {
        "version": "3.0.100"
    }
}

小心选择info参数。

这个过程对于将应用程序绑定到特定的 SDK 而不是自动继承最新安装的 SDK 是有用的。话虽如此,现在是时候创建一个新的空解决方案了,这是一个一个或多个项目的无代码容器。创建解决方案是可选的,但在需要创建多个交叉引用的项目时非常有用:

C:\Projects>dotnet new sln -o HelloSolution
The template "Solution File" was created successfully.

现在是在解决方案文件夹下创建一个新的控制台项目的时候了。由于文件夹中只有一个解决方案,因此可以在sln add命令中省略解决方案名称:

cd HelloSolution
dotnet new console -o Hello
dotnet sln add Hello

最后,我们可以构建和运行项目:

cd Hello
C:\Projects\HelloSolution\Hello>dotnet run
Hello World!

或者,我们可以使用watch命令在任何文件更改时重新运行项目:

C:\Projects\HelloSolution\Hello>dotnet watch run
watch : Started
Hello World!
watch : Exited
watch : Waiting for a file to change before restarting dotnet...
watch : Started
Hello Raf!
watch : Exited
watch : Waiting for a file to change before restarting dotnet...

当控制台上打印出第一个等待文件更改后重新启动 dotnet...消息时,我使用 Visual Studio Code 编辑器修改并保存了Program.cs文件。该文件的更改自动触发了构建过程,并且二进制文件像往常一样在bin文件夹中创建,其树结构已经从.NET Framework 中略有改变。

仍然有DebugRelease文件夹,其中包含一个名为框架的新子文件夹;在这种情况下,是netcoreapp3.0。新的项目系统支持多目标,并且可以根据项目文件中指定的框架、运行时和位数生成不同的二进制文件。该文件夹的内容如下:

  • Hello.dll。这是包含编译器生成的IL代码的程序集。

  • Hello.exe.exe文件是一个托管应用程序,用于引导您的应用程序。稍后,我们将讨论使用更多选项发布/部署应用程序。

  • Hello.pdb.pdb文件包含允许调试器将IL代码与源文件进行交叉引用的符号,以及符号(即变量、方法或类)名称与实际代码进行交叉引用。

  • Hello.deps.json:此文件以 JSON 格式包含完整的依赖树。它用于在编译期间检索所需的库,并且是发现不需要的依赖项或在混合不同版本的相同程序集时出现问题的非常有效的方法。

  • Hello.runtimeconfig.jsonHello.runtimeconfig.dev.json:这些文件由运行时使用,以了解应该使用哪个共享运行时来运行应用程序。.dev文件包含在环境指定应用程序应在开发环境中运行时使用的配置。

我们刚刚创建了一个非常基本的应用程序,但这些步骤就是创建一个由几个库组成并使用其他更复杂模板的复杂应用程序所需的全部步骤。有趣的是,可以在Linux 终端上执行相同的步骤以获得相同的结果。

在 Linux 发行版上开发

开发人员所感受到的需求革命并没有随着移动市场而停止,今天仍在持续进行。例如,跨多个操作系统运行的需求比以往任何时候都更为重要,因为云时代开始了。许多应用程序开始从本地部署转移到云架构,从虚拟机转移到容器,从面向服务的架构转移到微服务。这种转变如此之大,以至于即使微软的 CEO 也自豪地庆祝了 Azure 上 Linux 操作系统的普及,这清楚地表明了创建跨平台应用程序的重要性。

毫无疑问,.NET Core 在不同的操作系统、设备和 CPU 架构上运行的能力至关重要,但它带来了令人惊叹的抽象水平,最大程度地减少了开发人员的工作量,隐藏了大部分差异。例如,Linux 景观提供了多种发行版,但你不需要担心,因为抽象不会影响应用程序的性能。

IT 行业学到的教训是,当前推动云增长的技术并不是最终目的地,而只是一个过渡。在撰写本文时,一种名为Web Assembly System Interface (WASI)的技术正在标准化,作为一个强大的抽象,用于隔离小的代码单元,提供安全隔离,可以用于运行不仅是 Web 应用程序(已经通过WebAssembly在每个浏览器中可用),而且还可以运行云或经典的独立应用程序。

我们仍然不知道 WASI 是否会成功,但毫无疑问,现代运行时必须准备好迎接这一浪潮,这意味着要拥抱快速发展和变异的灵活性,一旦新的需求敲门。

准备开发环境

在创建 Linux 上的开发环境时,有多种选择。第一种是在物理机器上安装 Linux,这在整个开发生命周期中都具有性能优势。主要操作系统的选择非常主观,虽然 Windows 和 macOS 目前提供更好的桌面体验,但选择主要取决于您需要的应用程序生态系统。

另一个经过充分测试的方案是在虚拟机内进行开发。在这种情况下,您可以在 Mac 上使用Windows Hyper-VParallels Desktop。如果您没有选择的发行版,我强烈建议您开始安装 Ubuntu 桌面版。

在 Windows 上,您会发现使用名为Windows 子系统 Linux(WSL)的集成 Linux 支持非常有用,它可以作为 Windows 10 的附加组件进行安装。在撰写本文时,当前成熟的版本是WSL 1,它在 Windows 内核上运行 Linux 发行版。在这个解决方案中,Linux 系统调用会自动重新映射到 Windows 内核模式的实现。

在这种配置中安装的发行版是一个真正的 Linux 发行版,其中一些系统调用无法被翻译,而其他一些,如文件系统操作,由于它们的翻译不是微不足道的,因此速度较慢。使用WSL 1,大多数.NET Core 代码将无缝运行;因此,它是快速在 Windows 桌面和真正的 Linux 环境之间切换的好选择。

WSL 的未来已经在最新的 Windows 预览版中可用,并将很快完全发布。在这种配置中,完整的 Linux 内核安装在 Windows 上,并与 Windows 内核共存,消除了以前的任何限制,并提供接近本机速度。一旦它完全可用,我强烈推荐这个开发环境。

准备好 Linux 机器后,您有三个选择:

  • 安装.NET Core SDK,因为您希望从 Linux 内部管理开发人员生命周期。

  • 安装.NET Core 运行时,因为您只想在 Linux 上运行应用程序和/或其测试,以验证跨平台开发是否按预期工作。

  • 不要安装这两者中的任何一个,因为您希望将应用程序作为独立部署进行测试。我们将在发布应用程序部分稍后调查这个选项。

SDK 或运行时所需的先决条件和软件包不断变化;因此,最好参考官方下载页面dot.net。安装后,从终端运行dotnet ––info,将显示以下信息:

The runtime and sdk versions listed by this command may be different from the ones on Windows. You should consider the opportunity to create a global.json outside the sources repository in order to avoid mismatches when cloning a repository on different operating systems.

如果您决定使用虚拟机或 WSL,现在应该安装SSH 守护程序,以便您可以从主机机器与 Linux 通信。您应该参考特定于 Linux 发行版的说明,但通常来说,openssh软件包是最受欢迎的选择:

sudo apt-get install openssh-server
(eventually configure the configuration file /etc/ssh/sshd_config)
systemctl start ssh

现在,Linux 机器可以通过主机名(如果它已自动注册到您的 DNS)或 IP 地址进行联系。您可以通过输入以下内容获取这两个信息:

  • ip address

  • hostname

在 Windows 中有各种免费的ssh命令行工具:

ssh username@machinenameORipaddress

如果由于配置问题而无法工作,则典型的故障排除路径是恢复配置文件的默认权限:

Install-Module -Force OpenSSHUtils -Scope AllUsers
Repair-UserSshConfigPermission ~/.ssh/config
Get-ChildItem ~\.ssh\* -Include "id_rsa","id_dsa" -ErrorAction SilentlyContinue | % {
    Repair-UserKeyPermission -FilePath $_.FullName @psBoundParameters
}

当然,Linux 有许多可选工具,但在这里值得一提的是其中一些:

  • Net-tools:这是一个包含许多与网络相关的工具的软件包,用于诊断网络协议,如arphostnamenetstatroute。一些发行版已经包含它们;否则,您可以使用您喜欢的软件包管理器进行安装,例如 Ubuntu 上的apt-get

  • LLDB:这是一个 Linux 本地调试器。微软提供了 LLDB 的 SOS 扩展,其中包含与更受欢迎的 WinDbg 的 SOS 相同的一组命令。此扩展提供了许多.NET 特定的命令,用于诊断泄漏,遍历对象图,调查异常,并且它们也可以用于崩溃转储。

  • Build-essential:这是一个包含许多开发工具的软件包,包括 C/C++编译器和相关库,用于开发本地代码。如果您希望创建本地代码,并希望使用PInvoke从.NET 调用它们,这将非常有用。

  • 底层的ssh工具是Remote - SSHRemote - WSL。SSH 扩展允许我们通过 SSH 在远程 Linux 机器上开发,而 WSL 允许我们在本地 WSL 子系统上开发。

您可以按照最新的扩展说明来配置远程机器(详尽的文档可以在本章末尾的进一步阅读部分的安装链接中找到)。安装完成后,通过按下F1,您可以访问 Visual Studio Code 命令。然后,输入Remote-SSH,点击添加新的 SSH 主机,最后重复并选择连接到主机

图 16.1 - 通过 SSH 从 Visual Studio Code 连接到远程主机

图 16.1 - 通过 SSH 从 Visual Studio Code 连接到远程主机

这第一次连接将在 Linux 上远程安装所需的工具,以启用远程开发场景,其中所有编译和运行任务都是在远程完成,而不是在您输入代码的机器上完成。

即使您可以部署二进制文件并远程运行它们,但这种配置对于测试在 Linux 上运行时显示异常的代码非常有用。在 Visual Studio Code 中,您可以使用查看 | 终端菜单打开终端窗口。集成的终端窗口可用于创建解决方案和项目,并观察源代码以在以前相同的方式自动重新运行应用程序。

编写跨平台感知的代码

.NET Core 提供的抽象让您忘记了许多存在并在不同操作系统上工作方式不同的特殊性,但在开发代码时仍然有一些必须仔细考虑的事情。这些看似微不足道的细节大多应成为开发人员的最佳实践,以避免在不同系统上运行应用程序时出现问题。

文件系统大小写

最常见的错误是不考虑文件系统的大小写。在 Linux 上,文件和文件夹的名称是区分大小写的;因此,发现由于路径包含文件或文件夹名称的错误大小写而导致问题并不罕见。

主目录

在 Windows 和 Linux 中,用户配置文件的结构是不同的,而且更重要的是,在使用sudo(管理员)权限运行应用程序时,主目录与当前登录用户不同。

路径分隔符

我们都知道 Linux 和 Windows 使用正斜杠和反斜杠字符来分隔文件和文件夹。这就是为什么System.IO.Path类通过一些属性公开可用的分隔符。更好的是,根本不要使用分隔符。例如,要组成一个文件夹,应优先选择以下语句:

Path.Combine("..", "..", "..", "..", "Test",
    "bin", "Debug", "netcoreapp3.0", "test.exe");

最后,要将相对路径转换为完整路径,请使用Path.GetFullPath方法。

行尾分隔符

处理文本文件时,Windows 的行尾分隔符是\r\n0x0D0x0A),而在 Linux 上,我们只使用\r0x0D)。至于Path类,分隔符可以在运行时通过Environment.NewLine检索,但大多数情况下,您可以通过让System.IO.TextReader.ReadLineSystem.IO.TextWriter.WriteLine抽象来处理这个区别。

数字证书

虽然 Windows 有一个标准的数字证书中央存储库,但 Linux 没有,开发人员需要决定是依赖于证书文件还是特定于发行版的解决方案。当您需要存储证书时,包括私钥,必须加以保护,因为私钥是绝对不能泄露的秘密。提供适当的限制以保护这些证书是开发人员的责任。

特定于平台的 API

每个特定于平台的 API,例如NotImplementedException。在 Windows 上,注册表历来用于存储与应用程序相关的每个用户甚至全局设置。Linux 没有等价物;因此,在现代开发中,最好完全摆脱注册表。另一个流行的 API 是Windows 管理仪器(WMI),它仅在 Windows 上可用,在 Linux 上没有等价物。

安全

与 Windows 帐户相关的所有内容仅在 Windows 上可用。在 Linux 上修改文件系统安全标志的最简单方法是生成一个新进程,运行带有适当参数的标准chmod命令行工具。

环境变量

所有平台中非常强大且常见的共同点是环境变量的可用性。Windows 开发人员通常不经常使用它们,而它们在 Linux 上非常受欢迎。例如,ASP.NET Core 使用它们在开发、暂存和生产之间切换配置,但也可以用于检索标准变量,例如 Linux 上的HOME和 Windows 上的HOMEPATH,它们都代表当前用户配置文件的根文件夹。

您可能只在运行时发现的差距

有时您可能需要在运行时检测代码正在运行的操作系统或 CPU 架构。为此,System.Runtime.InteropServices.RuntimeInformation类提供了许多有趣的信息:

  • OSDescription 属性返回描述应用程序正在运行的操作系统的字符串。

  • OSArchitecture 属性返回带有 OS 架构的字符串。例如,X64值代表 Intel 64 位架构。

  • FrameworkDescription 属性返回描述当前框架的字符串,例如*.NET Core 3.0.1*。而短字符串3.0.1则可通过Environment.Version属性获得。

  • ProcessArchitecture 属性返回处理器架构。这种区别存在是因为 Windows 可以在其 64 位版本上创建 32 位进程。

  • GetRuntimeDirectory 方法返回指向应用程序使用的运行时的完整路径。

  • 最后,RuntimeInformation.IsOSPlatform 方法返回一个布尔值,可以用于执行特定于平台的代码:

if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
    Console.WriteLine("Linux!");
else if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
    Console.WriteLine("Windows!");
else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
    Console.WriteLine("MacOS!");
else if (RuntimeInformation.IsOSPlatform(OSPlatform.FreeBSD))
    Console.WriteLine("FreeBSD!");
else
    Console.WriteLine("Unknown :(");

您应该始终评估是否使用此技术来采用特定于平台的决策,或者创建一个包含每个平台的一个 DLL 的 NuGet 包。后一种解决方案更易于维护,但本书未对此进行讨论。

什么是.NET Standard,它如何帮助应用程序设计

虽然.NET Core 是在几乎所有地方运行代码的最佳选择,但也是事实,我们目前可能需要在不同的运行时上运行我们的代码,例如.NET Framework 用于现有的 Windows 应用程序,Xamarin 用于开发移动应用程序,以及 Blazor 用于在 WebAssembly 沙箱中运行代码或在其他较旧的运行时上运行。

在多个运行时之间共享编译库的第一次尝试是使用可移植类库,开发人员只能使用所有选定运行时中可用的 API。由于将可用 API 的数量限制为仅限于公共 API 太过限制,因此得到的交集是不切实际的。.NET Standard 倡议诞生于解决此问题,通过为许多知名 API 创建版本化的 API 定义集来解决此问题。为了符合.NET Standard,任何运行时都必须保证实现该完整的 API 集。将.NET Standard 视为一种包含所有包含的 API 的巨大接口。此外,每个新版本的.NET Standard 都会向以前的版本添加新的 API。

提示

即使 API 是.NET Standard 合同的一部分,它也可以通过抛出NotImplementedException在某些平台上实现。允许这种解决方案是为了简化将旧应用程序迁移到.NET Standard,并且在使用.NET Standard 库时必须考虑这一点。

.NET Standard 版本 1.0 定义了一个非常小的 API 集,以满足几乎所有过去的可用运行时,例如SilverlightWindows Phone 8。版本之后,定义的 API 数量变得更多,排除了旧的运行时,但也为开发人员提供了更多的 API。例如,版本 1.5 在 API 数量方面提供了一个很好的折衷,因为它支持非常流行的.NET Framework 4.6.2。在 GitHub 上的.NET Standard 存储库(github.com/dotnet/standard/tree/master/docs/versions),您可以找到版本和支持的 API 集的完整列表。

在撰写本文时,您应该只关心.NET Standard 版本作为库作者。如果您查看 NuGet 上非常流行的Newtonsoft.Json包,您会发现它符合.NET Standard 1.0。这是非常合理的,因为它允许该库被几乎整个.NET 生态系统使用。简单的规则是库开发人员应该支持最低可能的版本。

从应用程序开发人员的角度来看,问题是不同的,因为您可能希望使用尽可能高的数字,以便拥有最多的 API。如果您的目标是仅为.NET Framework 和.NET Core 开发应用程序(在迁移到新运行时时非常常见),您的选择将是版本 2.0,因为这是.NET Framework 支持的最后一个.NET Standard 合同版本。

在撰写本文时,最新版本的.NET Standard 是 2.1,其中包括诸如Span<T>之类的 API,以及许多新的方法重载,这些方法采用Span<T>而不是数组,从而提供更好的性能结果。

创建.NET Standard 库

创建.NET Standard 库非常简单。在 Visual Studio 中,有一个特定的模板,而从命令行中,以下命令将创建一个默认版本为 2.0 的.NET Standard 库。您可以通过在以下命令的末尾添加--help来列出其他选择,或者您可以保持netstandard2.0并创建库项目:

C:\Projects\HelloSolution>dotnet new classlib -o MyLibrary

创建后,可以使用此命令将库添加到以前的解决方案中:

dotnet sln add MyLibrary

最后,您可以使用另一个命令将MyLibrary引用添加到Hello项目中:

C:\Projects\HelloSolution>dotnet add Hello reference MyLibrary
Reference `..\MyLibrary\MyLibrary.csproj` added to the project.

生成的程序集是一个类库,可以从所有针对运行时并支持该.NET Standard 版本的项目中引用。

在.NET Standard 和.NET Core 库之间做出决定

每当您需要在多个运行时之间共享一些代码时,最好的选择是尽可能将其放入.NET Standard 库中。

我们已经说过,库的作者应该针对最低可能的版本号,但当然,如果你是唯一的库使用者,你可能决定采用.NET Standard 2.0 来共享代码,例如,在.NET Framework、.NET Core Mono 5.4 和 Unity 2018.1 之间。

每当你的库将被专门用于.NET Core 应用程序时,你可能希望创建一个.NET Core 类库,因为它不限制你在应用程序中可以使用的 API 集:

C:\Projects\HelloSolution>dotnet new classlib -f netcoreapp3.0 -o NetCoreLibrary
C:\Projects\HelloSolution>dotnet add Hello reference NetCoreLibrary

在前面的例子中,已经创建了一个新的.NET Core 类库(NetCoreLibrary)并将其添加到Hello项目的引用中。

使用 NuGet 包

包在现代应用程序开发中扮演着非常重要的角色,因为它们定义了一个独立的代码单元,可以用作构建更大应用程序的基石。

过去,这个定义也适用于由单个.dll文件组成的库,但现代开发通常需要更多的文件来构建一个适当独立的代码单元。最简单的例子是当一个包包含了库以及它的依赖项,但另一个更复杂的例子是编写一个需要对本地 API 进行平台调用的库。

RuntimeInformation类,但通常为了性能和维护的考虑,最好将代码分割成每个操作系统和 CPU 架构的一个库。打包平台相关库的优势在于它让.NET Core 构建工具在发布时将相关库复制到输出文件夹中。除了与本地代码的互操作性之外,还有其他情况,比如根据运行时(例如.NET Core、.NET Framework、Mono 等)提供不同的实现。

向项目添加包

有多种方法可以向项目添加包引用;这主要取决于你选择的 IDE。Visual Studio 通过打开解决方案资源管理器(这是显示解决方案和项目层次结构的窗口),展开项目树,右键单击依赖项节点,并选择管理 NuGet 包菜单项来提供完整的可视化支持。以下是一个典型的 NuGet 窗口,列出了可以从nuget.org添加到你的项目中的包:

图 16.2–NuGet 包管理器窗口

图 16.2–NuGet 包管理器窗口

NuGet 窗口允许你添加、删除或更新项目包的不同版本:

  • 在右侧,包源组合框显示了提供包的网站或本地文件夹的列表。点击附近的齿轮图标可以配置列表。

  • 在左侧,author:microsoft

  • 已安装选项卡只显示已安装在项目中的包。

  • 更新选项卡显示了已安装包的新版本,这些新版本来自所选源。

  • 一旦你在选项卡的右侧选择了一个包,你就可以选择所需的版本,然后它将根据你从哪个选项卡开始进行安装、卸载或更新。

当一个解决方案由多个项目组成时,保持版本包的一致性非常重要。因此,Visual Studio 提供了管理解决方案的 NuGet 包的功能,这是一个右键单击解决方案节点可用的菜单项。这个窗口类似,但有一个额外的选项卡叫做整合,显示了在多个项目中安装了不同版本的包。理想情况下,这个选项卡不应该显示任何包:

图 16.3–解决方案的 NuGet 包管理器,整合选项卡

图 16.3–解决方案的 NuGet 包管理器,整合选项卡

搜索包的另一种方法是直接到源头。在下面的截图中,你可以看到nuget.org网站,这是.NET 包的主要存储库:

图 16.4-在 NuGet 库网站上搜索

图 16.4-在 NuGet 库网站上搜索

这个网页显示了您选择的每个包的重要细节:

  • 右侧的源代码库链接在可用时跳转到源代码库。

  • 依赖项部分可以展开,显示它依赖的其他包。

  • GitHub 使用部分充当了包的声誉,显示了有多少开源项目依赖于它。一个包被社区使用的次数越多,它被支持和可靠的机会就越大。

在页面的上部,包部分显示了将包添加到项目的不同方法:

  • 包管理器显示您可以从 Visual Studio 中同名窗口执行的手动命令。

  • .NET CLI显示.NET CLI 命令。

  • .csproj直接。

  • Paket CLI是.NET CLI 的另一种 CLI 工具。

通过 CLI 添加包是很简单的,因为nuget.org已经为我们提供了要在控制台终端中输入的确切命令字符串。记得先进入项目文件夹,然后输入命令。例如,以下是从命令行添加对Newtonsoft.Json包的引用的命令:

dotnet add package Newtonsoft.Json --version 12.0.3

无论操作系统如何,如果您使用 Visual Studio Code,它都提供了一个方便的终端窗口,您可以在其中输入任何.NET CLI 命令。

另一个经常使用的添加包引用的方法是直接编辑.csproj文件。使用.NET Core,项目文件结构得到了大幅简化,摆脱了过去的所有标签,并且还提供了在 Visual Studio 中编辑和更新文件的能力,而无需关闭或卸载项目。

以下是一个.csproj文件的相关部分,您可以手动添加PackageReference标签:

<Project Sdk="Microsoft.NET.Sdk">
 …
  <ItemGroup>
     …
  </ItemGroup>
  <ItemGroup>
    <PackageReference Include="Newtonsoft.Json" Version="12.0.3" />
  </ItemGroup>
</Project>

正如您所看到的,ItemGroup元素可以多次重复,并且每个元素可能包含多个PackageReference标签。

从.NET Framework 迁移到.NET Core

我认为.NET Core 运行时最重要的新功能是它能够与任何其他.NET Core 版本并行部署,确保任何未来的发布都不会影响旧的运行时或库,因此也不会影响应用程序。阻止微软现代化和改进.NET Framework 性能的主要原因是.NET 运行时和基类库的共享性质。因此,对这些库的最小更改可能会导致已部署的数亿个安装出现不可接受的破坏性变化。

.NET Core 新的并行部署策略的明显后果是全局程序集缓存(GAC)的完全消失,它提供了一个中央存储库,可以将系统或用户库部署到其中。运行时现在完全与系统的其余部分隔离,这个决定使得能够将应用程序部署到所谓的自包含部署中,其中所有所需的代码,包括运行时和系统库,以及应用程序代码,都被复制到一个文件夹中。我们将在发布应用程序部分深入探讨部署选项。

在所有可用的运行时中,.NET Framework 一直是基准,在撰写本文时,它仍然是一个有效的生态系统,将在未来很长一段时间内得到微软的支持,因为它与 Windows 客户端和服务器操作系统一起重新分发。尽管如此,作为明智的开发人员,我们不能忽视.NET Core 3 的发布,微软发表了两个重要声明:

  • .NET Framework 4.8 将是这个运行时和库的最后一个版本

  • .NET 5 将是 2020 年底发布的.NET Core 的新简称

毫无疑问,.NET Core 3 标志着.NET 运行时历史上的一个转折点,因为它提供了以前由.NET Framework 支持的所有工作负载。从.NET Core 3 开始,您现在可以创建服务器和 Windows 桌面应用程序,利用机器学习的力量,或开发云应用程序。这也是对所有相关开发人员的强烈建议,他们被邀请使用.NET Core 创建全新的应用程序,因为它提供了最先进的运行时、库、编译器和工具技术。

分析您的架构

在开始任何迁移步骤之前,重要的是要验证技术、框架和第三方库是否在.NET Core 上可用。

旧的.NET Framework 基类库已完全移植,微软和其他第三方撰写的大多数最受欢迎的 NuGet 包也已移植,这使我们所有人都有很高的机会找到与.NET Core 兼容的更新版本。如果这些依赖项可用作.NET Standard 2.0 或更低版本(请记住,.NET Standard 2.1 不受.NET Framework 支持),那么它们就可以使用。但正如我们之前所见,NuGet 包可能包含针对不同运行时的多个库,因此验证库在供应商页面上的兼容性非常重要。

如果您的项目严重依赖于 Windows,因为它们需要 Windows API,您可能需要查看Windows 兼容性包 NuGet包,其中包含约 20,000 个 API。

信息框

即使一个库只兼容.NET Framework,在大多数情况下,由于shim 机制的存在,它也可以被.NET Core 引用。在这种情况下,Visual Studio 会在构建日志中显示一个黄色三角形,表示警告。潜在的不兼容性应该经过仔细测试,以验证应用程序的正确性。

尽管.NET Core 支持绝大多数过去的工作负载,但其中一些不可用,其他一些已经被重写,使得迁移过程有点困难,但同时也带来了其他优势。

迁移 ASP.NET Web Forms 应用程序

这项技术非常古老,被认为已经过时,因为今天的网络与过去的网络技术相比已经演变出非常不同的范式。迁移此代码的最佳途径是使用Blazor 模板,这使我们能够在浏览器中运行 C#代码,这要归功于WebAssembly支持,现在在任何现代浏览器中都可用。虽然这个解决方案并不是真正的移植,而是重写,但它允许我们在服务器和大部分客户端代码上都使用 C#。

Windows 通信基础(WCF)

在.NET Core 上,Windows 通信基础WCF)仅适用于客户端,这意味着只能消费 WCF 服务。如今,有更高性能和更简单的技术可用,例如gRPC(需要 HTTP2)和REST(Web API)。对于仍然需要创建基于 SOAP 的 Web 服务的人来说,一个名为CoreWCF的社区驱动的开源项目在 GitHub 上可用。在开始使用此库迁移旧代码之前,您应该验证项目中使用的所有 WCF 选项在 CoreWCF 上是否也可用。

在撰写本文时,无论是.NET Core 还是 CoreWCF 都不支持WS-*标准。

Windows 工作流基础

工作流基础并未移植,但另一个名为CoreWF的开源项目在 GitHub 上可用。正如我们先前提到的 WCF 一样,您应该首先验证项目中使用的功能的完全可用性。

Entity Framework

Entity Framework 6(EF6)也可以在.NET Core 上使用,你在迁移这个项目时不应该遇到任何问题,但值得一提的是,这项技术被微软认为是功能完备的,现在只开发Entity Framework Core(EF Core)。根据你的存储库访问结构,包括模型图和项目中使用的提供程序,你可能希望考虑将你的访问代码迁移到 EF Core。在这种情况下,要注意的是,在.NET Core 3 中,支持多对多关系,但需要在模型中描述中间实体类。EF Core 中的 API 与之前非常不同,但另一方面,它们提供了许多新的功能。.NET 5(这是.NET Core 的新名称)的路线图包括许多你可能想要考虑的新功能。

基于上述所有原因,你可能会发现首先使用 EF6 进行迁移,然后再迁移到 EF Core 会更容易。这个决定非常依赖于项目本身。

ASP.NET MVC

ASP.NET MVC 框架已经完全重写为 ASP.NET Core,但它仍然提供相同的关键功能。除非你深度定制和扩展基础设施,否则迁移肯定是直接的,但仍然需要对代码进行一些小的重写,因为命名空间和类型发生了变化。

代码访问安全 API

所有的**代码访问安全(CAS)**API 都已经从.NET Core 中移除,因为唯一可信的边界是由托管代码的进程本身提供的。如果你仍在使用 CAS,强烈建议摆脱它,无论你的.NET Core 迁移如何。

AppDomains 和远程 API

在.NET Core 中,每个进程始终只有一个 AppDomain。因此,你会发现大多数 AppDomain API 都已经消失并且不可用。如果你曾经使用 AppDomains 来隔离和卸载某些程序集,你应该看看AssemblyLoadContext,这是.NET Core 3 中的一个新 API,它可以以强大的方式解决这个问题,而不需要远程通信,因为远程通信也已经从.NET Core 中移除了。

准备迁移过程

从.NET Framework 迁移到.NET Core 的迁移过程中,一个常见的步骤是将.NET Framework 更新至至少 4.7.2 版本。

4.7.2 版本是一个特殊的版本,因为它是第一个完全支持.NET 标准二进制契约的版本,避免了需要填补空白的外部 NuGet 包的要求。这一步不应该引起任何问题,你可以继续使用这个最新版本的.NET Framework 部署当前的项目,而不必担心。根据解决方案的复杂性,你可能希望在仍然在.NET Framework 上运行生产代码的同时进行迁移,直到一切都经过充分测试。

在这一点上,分析应该集中在外部依赖上,比如来自第三方的 NuGet 包,这些包是你无法控制的。一旦你确定了更新的包,更新它们,这样你的.NET Framework 解决方案就可以在更新的版本上运行。即使你没有改变任何代码,你仍然有一个可部署的解决方案,它以与.NET Core 兼容的一些部分开始。

可移植性分析器工具

API Port 工具在 GitHub 上可用,网址是github.com/microsoft/dotnet-apiport,它为我们提供了创建一个详细报告的能力,列出了.NET 应用程序中使用的所有 API 以及它们在其他平台上是否可用。该工具既可以作为 Visual Studio 扩展,也可以通过 CLI 使用,这样你就可以根据需要自动化这个过程。该工具提供的最终报告是一个 Excel 电子表格,其中包含所有 API 的交叉引用,让你可以在迁移过程中进行规划,而不会在过程中遇到任何不良的意外。

迁移库

我们终于可以开始更新解决方案中的库项目了。重要的是要清楚地了解整个解决方案和包的依赖树。如果项目非常庞大,您可能希望利用外部工具的强大功能,比如流行的NDepend。在依赖树上,您应该识别出树底部没有其他外部包依赖的库,它们是最好的起点。

在大多数情况下,迁移没有依赖关系的库(或者库依赖于可以在两个框架上运行的包)是直接的。没有自动化支持,因此您应该创建一个**.NET Standard 2.0**项目。

提示

在撰写本文时,github.com/dotnet/try-convert/releases存储库包含了一个工具的预览,该工具能够将项目转换为.NET Core。正如try-convert这个名字所暗示的,它无法处理所有类型的项目,但仍然可以作为迁移的起点。

迁移到新的.csproj项目结构可以通过以下两种方式之一完成:

  • 创建新项目并将源文件移动到其中

  • 修改旧项目的.csproj文件

第一种策略更简单,但缺点是会改变项目名称,这也意味着要更改默认的命名空间和程序集名称。这些可以通过对.csproj文件进行以下更改来重命名:

<PropertyGroup>
    ...
  <AssemblyName>MyLibrary2</AssemblyName>
 <RootNamespace>MyLibrary2</RootNamespace>
</PropertyGroup>

请记住,创建新项目也意味着修复所有依赖项目的引用。

第二种策略包括替换.csproj文件的内容,这要求您在单独的项目上测试了这些更改之前。在迁移包引用时,请注意新的.NET Core 项目会忽略packages.config文件,并要求所有引用都在PackageReference标签中指定,就像在使用 NuGet 包部分中提到的那样。

查找缺失的 API

在迁移过程中,您可能会发现一些缺失的 API。对于这种特定情况,微软创建了apisof.net/网站,该网站对基类库和 NuGet 可用的 70 万多个 API 进行了分类。由于其搜索功能,您可以搜索任何类、方法、属性或事件,并发现其用法以及支持它的平台和版本。

迁移测试

一旦您迁移了较低级别的依赖库,最好创建测试项目,以便对任何迁移的代码在两个框架上进行测试。测试项目本身实际上不应该被迁移,因为您可能希望在两个框架上测试代码。因此,您可能希望在共享项目(在 Visual Studio 的以下屏幕中可用的模板)中共享测试代码,这是一个不会产生任何二进制文件的特殊项目:

图 16.5 - 添加新项目的 Visual Studio 对话框

图 16.5 - 添加新项目的 Visual Studio 对话框

所有引用共享项目的项目都继承了其源代码,就好像它们直接包含在其中一样。所有主要的测试框架(xUnit、NUnit 和 MSTest)都已经移植到.NET Core,但在支持的测试 API 方面可能会有一些差异;因此,任何使用测试 API 的基础设施代码都应该首先进行验证。

最后,如果测试代码使用 AppDomains 来卸载某些程序集,请记住要使用更强大的AssemblyLoadContext API 进行重写。现在应该继续迁移,迭代移植库和它们的测试,直到所有基础设施都已经迁移并在两个框架上运行。

迁移桌面项目

WPF 和 Windows Forms 工作负载可在.NET Core 3 上使用,它们的迁移应该是直接的。在撰写本文时,Windows Forms 设计器作为预览可用,但您仍然可以在之前提到的共享项目中共享设计器代码,以继续使用.NET Framework 设计器。

.NET Core 3.1 上,一些 Windows Forms 控件已被移除,但它们可以被具有相同功能的新控件替代:

另一个缺失的功能是ClickOnce,这是许多公司内广泛使用的部署系统。微软建议将部署包迁移到更新的MSIX技术。

迁移 ASP.NET 项目

迁移 ASP.NET MVC 项目是唯一需要更多手动工作和代码更改的工作负载,但也带来了许多明显的优势,因为新编写的 ASP.NET Core 框架在性能和简化方面,如MVCWebAPI世界的统一Controller层次结构。

提示

在开始之前,我强烈建议熟悉ASP.NET Core MVC框架,特别关注依赖注入、身份验证、授权、配置和日志记录,这些细节远远超出了本书的范围。

要迁移 ASP.NET Web 项目,最好始于新的 ASP.NET Core MVC 模板,而不是调整旧的.csproj,因为代码不会原样运行,总是需要一些更改。

与 ASP.NET 基础设施相关的任何代码都是您可能想要迁移的第一项。例如,Global.asax通常包含初始化代码,而HTTP 模块处理程序是旨在拦截请求和响应的基础代码。迁移此代码的一般规则如下:

  • 静态结构或全局助手应转换为**依赖注入(DI)**单例服务。

  • 任何旨在拦截、读取或修改 HTTP 请求和响应的代码都应成为中间件,并在Startup类中进行配置。

  • 识别Controller逻辑之外的任何代码,确定其生命周期,并通过Controller构造函数使其可用,考虑创建一个工厂,然后通过Controller提供工厂。

在旧的 MVC 框架中,大多数基础设施定制是为了向控制器提供外部服务。这不再需要,因为DI允许控制器随时需要任何服务。

第二个关键步骤是确定身份框架基础设施需求。新模板提供了许多增强功能,以及对法律GDPR 要求的基本支持。在大多数情况下,最好从新基础设施开始,并迁移数据库,而不仅仅是移植旧代码。在 NuGet 上,您会发现许多提供程序的支持,从 OAuth 通用提供程序到社交身份提供程序,OpenID 规范提供程序等等。还可以利用流行的开源项目Identity Server,这是.NET 基金会的一部分。

授权框架也发生了变化,并带来了两个重要的关键功能。第一个是基于声明的。与旧的基于角色的安全性相比,这带来了许多优势(它有一些限制)。 Claims也可以用作角色,每当您的检查只是布尔值时,但它们允许更复杂的逻辑结构化为 ASP.NET Core 中的Policies,这绝对值得采用。

一旦所有基础设施都已移植或转换,应用程序逻辑最终可以移至新的控制器。正如我们之前提到的,现在有一个单一的Controller基类,用于 MVC 和 Web API 控制器。通过路由机制匹配请求的控制器。在 ASP.NET Core 中,路由是通过Controller类中的属性进行配置的。

每个控制器可能公开一个或多个“操作”,可以使用定义它们所限制的 HTTP 动词的属性进行标记,例如HttpGetHttpPost。与 HTTPGET动词相关的操作不接受任何输入参数,而其他动词(如POSTPUT)可以受益于模型绑定功能,该功能会自动将请求传递的值映射到输入参数。您可以在官方文档docs.microsoft.com/en-us/aspnet/core/mvc/models/model-binding中找到有关模型绑定的更多信息。

HTTP 往返的响应当然取决于其 HTTP 动词。操作的典型返回类型如下:

  • 代表要返回给 HTTP 客户端的响应值的对象。它将根据客户端在接受标头中指定的类型进行基础设施序列化。

  • Task<T>,其中T是前述中指定的响应值。每当内容检索需要一些“慢速”访问时,例如访问文件系统或数据库时,应使用任务。

  • 实现IActionResult的对象,例如由ControllerBase类中同名方法创建的OkResultNotFoundResult,该类是任何控制器的基类。它们用于完全控制状态代码和响应标头。准备好使用的IActionResult类型的完整列表在Microsoft.AspNetCore.MVC命名空间中定义。其中一些对象具有构造函数,接受要返回的对象,例如OkObjectResult,它将对象作为内容返回,并将 HTTP 状态代码设置为 200。

  • 实现Task<IActionResult>的对象,这是前一种情况的异步版本。

  • 最后一种情况是返回void,这样基础设施将返回没有任何内容的默认响应。

一旦代码已经迁移,您必须考虑托管环境。ASP.NET Core 应用程序的 Web 服务器称为web.config文件,应该在新的appsettings.json配置文件中进行修订,或者直接在Program.cs文件中进行 Kestrel 配置的代码中进行修订。

请注意,仍然可以使用 IIS,但这只能用作反向代理,并且需要使用官方的 ASP.NET Core IIS 模块,该模块将所有 HTTP 流量转发到 Kestrel Web 服务器。

这个解决方案为 ASP.NET Core 带来了一个出色的、改进的、跨平台的解决方案,但如果您仍然希望在 IIS 上托管项目,通过在托管服务器上安装官方的ASP.NET Core IIS 模块,这是完全可能的。该模块将所有 HTTP 请求和响应转发到 Kestrel Web 服务器,因此 IIS 中的大多数设置都可以安全地忽略。

总结迁移步骤

规划迁移肯定并不总是容易的,但有一条明确的路径可以应用于任何一组项目。以下一些步骤可能更难或更容易,这取决于它们所实施的技术,而其他一些步骤非常直接,只需要提前练习,但从.NET Core 版本 3 开始,可用的 API 数量使得整个过程变得更加容易。迁移应用程序的大致步骤如下:

  1. 确保您正在使用.NET Core 中可用的技术。当它们不可用时,您可能需要考虑进行替换,但要仔细分析对应用程序架构的影响。

  2. 一旦决定开始迁移,首先将所有项目升级到最新的.NET Framework。

  3. 确保所有第三方依赖项都可用作.NET Standard,并将您当前的.NET Framework 项目迁移到使用它们。

  4. 使用可移植性分析器工具分析您的项目,或验证 API 的可用性 https://apisof.net/。

  5. 每次将单个.NET Framework 库项目迁移到.NET Standard 时,应用程序都有可能合并回主分支并部署到生产环境。

  6. 通过从没有依赖关系的项目开始导航依赖树,一直到引用已经迁移的项目的应用程序,来迁移项目。

乍一看,迁移可能看起来有点可怕,但一旦应用程序开始在.NET Core 上运行,您将会欣赏到许多优势。其中,部署提供了新的、令人兴奋的、强大的功能,我们将在下一节中讨论。

发布应用程序

使应用程序在开发者机器之外可用的最后一个关键步骤是发布。有两种部署方式:依赖框架和自包含。

**Framework-dependent deployment (FDD)**会创建一个包含在任何安装了相同操作系统和.NET 运行时的计算机上运行应用程序所需的所有必需二进制文件的文件夹。FDD 部署有几个优点:

  • 这降低了部署文件夹的大小。

  • 这使得安全更新易于由 IT 管理员安装,而无需重新部署它们。

  • 在 Docker 容器中部署时,您可以从预先构建的镜像开始,这些镜像已经包含您所需的.NET 运行时版本。

另一个发布选项是自包含部署(SCD),它会创建/复制运行应用程序所需的所有文件,包括运行时和所有基类库。SCD 的主要优势在于它消除了对托管目标的任何要求,使得您可以通过复制文件夹来运行应用程序。

提示

在 Linux 上,某些基本库可能需要在某些非常受限制的发行版上。在dot上,您可以找到关于这些要求的更新信息。

另一方面,自包含部署方案也有一些缺点:

  • 应用程序必须发布到特定的操作系统和 CPU 架构。

  • 每次.NET Core 运行时获得安全更新时,您都应立即响应安全公告。在这种情况下,在将更新应用到开发者机器后,您将不得不重新构建和部署应用程序。

  • 总部署大小要大得多。

从.NET Core 2.2 开始,FDD 会自动生成可执行文件,而不仅仅是主项目的.dll文件,而在过去,FDD 应用程序需要通过dotnet run命令运行。现在,它们被创建为可执行文件,也被称为Framework Dependent Executables (FDE),这是使用.NET Core 3 SDK发布应用程序时的默认设置。

作为 FDD 发布

如果您希望保持部署大小紧凑,只需确保目标机器上安装了您选择的.NET Core 运行时版本,并将应用程序发布为FDD。从命令行发布应用程序作为FDD很简单;首先,进入项目文件夹,然后输入以下命令:

C:\Projects\HelloSolution\Hello>dotnet publish -c Release

CLI 将构建和发布项目,并在屏幕上打印发布文件夹的路径:

  Hello -> C:\Projects\HelloSolution\Hello\bin\Release\netcoreapp3.0\publish\

可以通过在上一个命令中添加-o参数来更改目标文件夹:

C:\Projects\HelloSolution\Hello>dotnet publish -c Release -o myfolder

在这种情况下,输出文件夹将如下所示:

  Hello -> C:\Projects\HelloSolution\Hello\myfolder\

发布命令还可以指定所请求的运行时,接受Runtime Identifier (RID)docs.microsoft.com/en-us/dotnet/core/rid-catalog)。例如,使用以下命令在 64 位架构的 Linux 上发布应用程序:

dotnet publish -c Release -r linux-x64 --no-self-contained

除非您还指定了输出文件夹,否则这将反映指定的 RID:

  Hello -> C:\Projects\HelloSolution\Hello\bin\Release\netcoreapp3.0\linux-x64\publish\

需要--no-self-contained参数,因为默认情况下,如果指定了运行时标识符,应用程序将作为自包含发布。

作为 SCD 发布

使用 SCD 意味着摆脱任何已安装的运行时依赖关系。因此,当您决定以 SCD 方式发布时,还必须指定运行时标识符(目标操作系统和 CPU 架构),以便所有必需的运行时依赖项与应用程序一起发布。

作为 SCD 发布只需要添加--self-contained-r选项,后面跟着运行时标识符。较短的版本只需指定-r选项,因为默认情况下,这也会打开自包含选项。例如,为 Windows 的 64 位版本发布自包含应用程序的命令如下:

dotnet publish -c Release -r win-x64

在这种情况下,输出文件夹将如下所示,由命令行的输出消息指定:

  Hello -> C:\Projects\HelloSolution\Hello\bin\Release\netcoreapp3.0\win-x64\publish\

在发布时,是否依赖于运行时安装只是其中一个选项。现在,我们将研究其他有趣的可能性。

了解其他发布选项

从.NET Core 3 开始,可以在发布时指定许多有趣的选项。这些选项可以在命令行上指定,甚至可以在.csproj文件中强制执行,使其成为PropertyGroup标签内项目的默认选项。

单文件发布

将应用程序发布为单个文件是一个非常方便的功能,它为所有项目文件创建一个单个文件。拥有一个单独的可执行文件使得可以通过 USB 键或下载轻松移动应用程序。唯一无法嵌入可执行文件的文件是配置文件和 Web 静态文件(例如 HTML)。

以下是用于将应用程序发布为单个文件的命令行。单文件发布与 FDD 兼容;在这种情况下,您可以在命令行中附加--no-self-contained

dotnet publish -r win-x64 -o folder -p:PublishSingleFile=true

或者,您可以在.csproj文件中打开单文件发布选项:

<PublishSingleFile>true</PublishSingleFile>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>

您会立即注意到二进制文件的大小特别大,因为它包含所有的依赖代码,甚至是您不需要的程序集部分。如果我们可以摆脱所有未使用的方法、属性或类,那该多好啊?解决方案来自IL 修剪

IL 修剪

修剪是从部署二进制文件中删除所有未使用代码的能力。这个功能来自Mono IL 链接器代码库。此设置要求部署为自包含,这又要求指定运行时标识符。

在命令行上发布时,可以打开PublishTrimmed工厂:

dotnet publish -c Release -r win-x64 -p:PublishTrimmed=true

否则,可以在csproj文件中指定:

<PublishTrimmed>true</PublishTrimmed>

当大量使用反射时,修剪器失去了理解哪些库和成员是必需的能力。例如,如果动态组合成员名称,修剪器无法知道要保留还是丢弃的成员。在这种情况下,还有另外两个选项,TrimmerRootAssemblyTrimmerRootDescription,可以用来指定不应被修剪的代码。

提前编译(AOT)编译

AOT 编译允许我们通过在开发者机器上生成几乎所有本机 CPU 汇编代码来预编译应用程序。如果你从未听说过.NET Framework 中的ngen工具,它是用于在目标机器上生成本机汇编代码的,使应用程序的引导性能更快,因为不再需要即时JIT)编译器。AOT 编译器具有相同的目标,但使用不同的策略:实际上,编译是在开发者机器上完成的,因此生成的代码质量较低。这是因为编译器无法对将运行代码的 CPU 做出假设。

为了平衡较低质量的代码,.NET Core 3 默认启用了TieredCompilation。每当一个应用程序方法被调用超过 30 次时,它被视为“热点”,并安排在远程线程上重新从JIT 编译器进行重新编译,从而提供更好的性能。

在发布时,可以通过以下命令行启用AOT编译:

dotnet publish -c Release -r win-x64 -p:PublishReadyToRun=true

或者,您可以修改.csproj文件以使此设置持久化:

<PublishReadyToRun>true</PublishReadyToRun>

AOT 编译提供了更好的启动,但也需要指定运行时标识符,这意味着为特定的操作系统和 CPU 架构进行编译。这种设置消除了 IL 代码部署在多个平台上的优势。

快速 JIT

每当您担心需要预生成本机编译,但仍需要提供快速的应用程序引导时,您可以启用QuickJIT,这是一个更快的JIT编译器,缺点是生成的代码性能较差。再次,分层编译平衡了代码质量的缺点,并在其符合热路径条件时重新编译代码。

从命令行启用 Quick JIT 与其他选项没有区别:

dotnet publish -c Release -p:TieredCompilationQuickJit=true

csproj文件中启用 Quick JIT 也是类似的:

<TieredCompilationQuickJit>false</TieredCompilationQuickJit>

需要注意的是,AOT 编译器无法将对外部库的调用编译为目标机器上的本机代码,因为库可能会被新版本替换,从而使生成的代码失效。每当有些代码无法编译为本机代码时,它将在目标机器上使用JIT进行编译。因此,完全有意义同时启用AOTQuickJIT

提示

.NET Framework 的ngen编译器能够为程序集中的所有 IL 生成汇编代码,但一旦任何依赖的程序集被替换,所有本机代码都将失效,需要 JIT 重新编译所有代码。

无论您的应用程序需要自包含、单文件还是预编译,.NET Core 都提供了多种部署选项,使您的应用程序在各种情况下都能发光,现在您可以选择您喜欢的选项。

总结

在本章中,我们经历了构建使用.NET Core 运行时的新应用程序所需遵循的所有基本步骤,该运行时伴随着增加的 API 数量。我们首先看了一下新的强大的命令行,它提供了控制应用程序开发生命周期的所有命令。命令行的可扩展性消除了任何限制,允许任何人向生态系统中添加本地和全局工具。

我们还看到了当在 Linux 操作系统上开发时,命令行命令与在 Windows 上开发时完全相同,可以直接或通过 Windows 使用作为开发工具。事实上,Visual Studio Code 远程扩展允许您从 Windows 在 Linux 机器上开发和调试代码。

但我们也看到,.NET Core 3 并不是单向旅程,因为.NET 标准库使我们能够与所有最新的运行时共享代码,使代码重用变得更加容易。除此之外,NuGet 包的非常丰富的生态系统使得消费库变得简单直接。

采用新的运行时并不难:一些应用程序可以通过简单地转换项目文件来迁移,而其他应用程序则需要更多的编码,但最终的应用程序将受益于新的生态系统。

在最后一节中,我们研究了发布应用程序时的完整可能性,这是应用程序开发过程的顶点。在这一点上,您可以将想法和算法转化为运行中的应用程序,可能在最流行的操作系统上运行。

在下一章中,我们将讨论单元测试,这是非常重要的实践,可以保证代码质量并提供证据,证明未来的开发迭代不会引入破坏性变化或退化。

测试你所学到的东西

  1. 安装了五个不同的 SDK 后,如何告诉 CLI 在整个解决方案中使用特定版本?

  2. 如何将一些路径连接起来,以便它们在 Windows 和 Linux 上都能正确工作?

  3. 如何在基于.NET Framework、.NET Core 3 和 Xamarin 的三个不同应用程序之间共享一些代码?

  4. 为新的库项目添加与现有项目完全相同的引用的最快方法是什么?

  5. 在迁移复杂解决方案时,我们应该从哪里开始?

  6. 哪些部署选项可以保证更快的应用程序启动时间?

进一步阅读

Visual Studio Code 扩展可以在远程 Linux 或 WSL 会话上编译和调试项目,可以在以下链接找到:

描述了创建包含多个二进制文件的 NuGet 包的能力,每个二进制文件都针对不同的 CPU 架构或框架版本,可以在以下链接找到:docs.microsoft.com/en-us/nuget/create-packages/supporting-multiple-target-frameworks

第十七章:单元测试

在整本书中,您已经学会了使用 C#语言进行编程所需的一切——从语句到类,从泛型到函数式编程,从反射到并发等等。我们还涵盖了许多与.NET Framework 和.NET Core 相关的主题,包括集合、正则表达式、文件和流、资源管理以及语言集成查询LINQ)。

然而,编程的一个关键方面是确保代码的行为符合预期。没有经过适当测试的代码容易出现意外错误。有各种类型和级别的测试,但通常由开发人员在开发过程中执行的是单元测试。这是本书最后一章涵盖的主题。在本章中,您将学习什么是单元测试,以及用于编写 C#单元测试的内置工具。然后,我们将详细了解如何利用这些工具来对我们的 C#代码进行单元测试。

在本章中,我们将重点关注以下主题:

  • 什么是单元测试?

  • 微软的单元测试工具有哪些?

  • 创建 C#单元测试项目

  • 编写单元测试

  • 编写数据驱动的单元测试

让我们从单元测试的概述开始。

什么是单元测试?

单元测试是一种软件测试类型,其中测试单个代码单元以验证它们是否按设计工作。单元测试是软件测试的第一级,其他级别包括集成测试、系统测试和验收测试。讨论这些测试类型超出了本书的范围。单元测试通常由软件开发人员执行。

执行单元测试具有重要的好处:

  • 它有助于在开发周期的早期识别和修复错误,从而有助于节省时间和金钱。

  • 它有助于开发人员更好地理解代码,并允许他们快速更改代码库。

  • 它通过要求更模块化来帮助代码重用。

  • 它可以作为项目文档。

  • 它有助于加快开发速度,因为使用开发人员手动测试的各种方法来识别错误的工作量大于编写单元测试所花费的时间。

  • 它简化了调试,因为当测试失败时,只需要查看和调试最新的更改。

测试的单元可能不同。它可以是一个函数(通常是在命令式编程中)或一个(在面向对象编程中)。单元是单独和独立地进行测试的。这要求单元被设计为松散耦合,但也需要使用替代品,如存根、模拟和伪造。虽然这些概念的定义可能有所不同,但存根是作为其他函数的替代品,模拟它们的行为。示例可能包括用于从 Web 服务检索数据的函数的存根,或者用于稍后添加的功能的临时替代品。模拟是模拟其他对象行为的对象,通常是复杂的,不适合用于单元测试。术语伪造可能指的是存根模拟,用于指示一个不真实的实体。

除了使用替代品,单元测试通常需要使用测试工具。测试工具是一种自动化测试框架,通过支持测试的创建、执行测试和生成报告来实现测试的自动化。

代码库被单元测试覆盖的程度被称为代码覆盖率。代码覆盖率通过提供定量度量来指示代码库已经经过测试的程度。代码覆盖率帮助我们识别程序中未经充分测试的部分,并允许我们创建更多的测试来增加覆盖率。

微软的单元测试工具有哪些?

如果您正在使用 Visual Studio,有几个工具可以帮助您为您的 C#代码编写单元测试。这些工具包括以下内容:

  • Test Explorer:这是 IDE 的一个组件,允许您查看单元测试,运行它们并查看它们的结果。Test Explorer不仅适用于 MSTest(Microsoft 的测试单元框架)。它有一个可扩展的 API,允许为第三方框架开发适配器。一些提供Test Explorer适配器的框架包括NUnitxUnit

  • Microsoft 托管代码单元测试框架或 MSTest:这是与 Visual Studio 一起安装的,也可以作为 NuGet 包使用。还有一个类似功能的本地代码单元测试框架。

  • 代码覆盖工具:它们允许您确定单元测试覆盖的代码量。

  • Microsoft Fakes 隔离框架:这允许您为类和方法创建替代品。目前,这仅适用于.NET Framework 和 Visual Studio Enterprise。目前,不支持.NET 标准项目。

在撰写本书时,使用 Microsoft 测试框架进行.NET Framework 和.NET Core 的测试体验有些不同,因为.NET Core 测试项目没有单元测试模板。这意味着您需要手动创建测试类和方法,并使用适当的属性进行修饰,我们很快就会看到。

创建一个 C#单元测试项目

在本节中,我们将一起看一下如何在 Visual Studio 2019 中创建一个单元测试项目。当您打开文件|新建项目菜单时,您可以在各种测试项目之间进行选择:

图 17.1 - Visual Studio 2019 单元测试项目模板

图 17.1 - Visual Studio 2019 单元测试项目模板

如果您需要测试一个.NET Framework 项目,那么您选择Unit Test Project (.NET Framework)

一个项目会为您创建一个包含以下内容的单元测试文件:

using System;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace UnitTestDemo
{
    [TestClass]
    public class UnitTest1
    {
        [TestMethod]
        public void TestMethod1()
        {
        }
    }
}

在这里,UnitTest1是一个包含测试方法的类。这个类被标记为TestClassAttribute属性。另一个属性TestMethodAttribute被用来标记TestMethod1()方法。这些属性被测试框架用来识别包含测试的类和方法。然后它们会显示在Test Explorer中,您可以在那里运行或调试它们并查看它们的结果,就像您在下面的截图中看到的那样:

图 17.2 - Visual Studio 中的 Test Explorer 显示了从所选模板创建的空单元测试的执行结果。

图 17.2 - Visual Studio 中的 Test Explorer 显示了从所选模板创建的空单元测试的执行结果

您可以通过手动方式或使用 Visual Studio 中可用的测试模板来添加更多的单元测试类,就像下面的截图所示:

图 17.3 - Visual Studio 中的添加新项对话框,其中包含一些单元测试项目。

图 17.3 - Visual Studio 中的添加新项对话框,其中包含一些单元测试项目

如果您正在测试一个.NET Core 项目,那么在创建测试项目时,您应该选择名为**MSTest Test Project (.NET Core)**的模板(参考本节开头的截图)。结果是一个包含单个文件和之前显示的相同内容的项目。然而,使用向导添加更多的单元测试项目是不可能的,您必须手动创建一切。目前,MSTest 对.NET Core 没有可用的项目模板。

在本章的其余部分,我们将专注于测试.NET Core 项目。

编写单元测试

在本节中,我们将看一下如何为您的 C#代码编写单元测试。为此,我们将考虑一个矩形的以下实现:

public struct Rectangle
{
    public readonly int Left;
    public readonly int Top;
    public readonly int Right;
    public readonly int Bottom;
    public int Width => Right - Left;
    public int Height => Bottom - Top;
    public int Area => Width * Height;
    public Rectangle(int left, int top, int right, int bottom)
    {
        Left = left;
        Top = top;
        Right = right;
        Bottom = bottom;
    }
    public static Rectangle Empty => new Rectangle(0, 0, 0, 0); 
}

这个实现应该是直接的,不需要进一步的解释。这是一个简单的类,关于矩形并没有提供太多的功能。我们可以通过扩展方法提供更多功能。以下清单显示了增加和减少矩形大小的扩展,以及检查两个矩形是否相交,并确定它们相交的结果矩形:

public static class RectangleExtensions
{
    public static Rectangle Inflate(this Rectangle r, 
                                    int left, int top, 
                                    int right, int bottom) =>
        new Rectangle(r.Left + left, r.Top + top, 
                      r.Right + right, r.Bottom + bottom);
    public static Rectangle Deflate(this Rectangle r, 
                                    int left, int top, 
                                    int right, int bottom) =>
        new Rectangle(r.Left - left, r.Top - top, 
                      r.Right - right, r.Bottom - bottom);
    public static Rectangle Interset(
      this Rectangle a, Rectangle b)
    {
        int l = Math.Max(a.Left, b.Left);
        int r = Math.Min(a.Right, b.Right);
        int t = Math.Max(a.Top, b.Top);
        int bt = Math.Min(a.Bottom, b.Bottom);
        if (r >= l && bt >= t)
            return new Rectangle(l, t, r, bt);
        return Rectangle.Empty;
    }
    public static bool IntersectsWith(
       this Rectangle a, Rectangle b) =>
        ((b.Left < a.Right) && (a.Left < b.Right)) &&
        ((b.Top < a.Bottom) && (a.Top < b.Bottom));
}

我们将从测试Rectangle结构开始,为此,我们将不得不创建一个单元测试项目,如前一节所述。创建项目后,我们可以编辑生成的存根,使用以下代码:

[TestClass]
public class RectangleTests
{
    [TestMethod]
    public void TestEmpty()
    {
        var rectangle = Rectangle.Empty;
        Assert.AreEqual(0, rectangle.Left);
        Assert.AreEqual(0, rectangle.Top);
        Assert.AreEqual(0, rectangle.Right);
        Assert.AreEqual(0, rectangle.Bottom);
    }
    [TestMethod]
    public void TestConstructor()
    {
        var rectangle = new Rectangle(1, 2, 3, 4);
        Assert.AreEqual(1, rectangle.Left);
        Assert.AreEqual(2, rectangle.Top);
        Assert.AreEqual(3, rectangle.Right);
        Assert.AreEqual(4, rectangle.Bottom);
    }
    [TestMethod]
    public void TestProperties()
    {
      var rectangle = new Rectangle(1, 2, 3, 4);
      Assert.AreEqual(2, rectangle.Width, "With must be 2");
      Assert.AreEqual(2, rectangle.Height, "Height must be 2");
      Assert.AreEqual(4, rectangle.Area, "Area must be 4"); 
    }
    [TestMethod]
    public void TestPropertiesMore()
    {
        var rectangle = new Rectangle(1, 2, -3, -4);
        Assert.IsTrue(rectangle.Width < 0,
                      "Width should be negative");
        Assert.IsFalse(rectangle.Height > 0,
                       "Height should be negative");
    }
}

在此列表中,我们有一个名为RectangleTests的测试类,其中包含几个测试方法:

  • TestEmpty()

  • TestConstructor()

  • TestProperties()

  • TestPropertiesMore()

这些方法中的每一个都测试了Rectangle类的一部分。为此,我们使用了Microsoft.VisualStudio.TestTools.UnitTesting中的Assert类。该类包含一系列静态方法,帮助我们执行测试。当测试失败时,将引发异常,并且测试方法的执行将停止,并继续下一个测试方法。

在下一个截图中,我们可以看到执行我们之前编写的测试方法的结果。您可以看到所有测试都已成功执行:

图 17.4 - 测试资源管理器显示先前编写的测试方法成功执行。

图 17.4 - 测试资源管理器显示先前编写的测试方法成功执行

当测试失败时,它将显示为红色的圆点,您可以检查TestProperties()方法,看看以下不正确的测试:

Assert.AreEqual(6, rectangle.Area, "Area must be 6");

这将导致TestProperties()测试方法失败,如下一个截图所示:

图 17.5 - 测试资源管理器显示测试方法执行失败的 TestProperties()方法。

图 17.5 - 测试资源管理器显示 TestProperties()方法执行失败的测试方法

失败的原因在测试详细摘要窗格中有详细说明,如下一个截图所示。单击失败的测试时,将显示此窗格:

图 17.6 - 测试资源管理器的测试详细摘要窗格显示了有关失败测试的详细信息。

图 17.6 - 测试资源管理器的测试详细摘要窗格显示了有关失败测试的详细信息

从此窗格中的报告中,我们可以看到RectangleTests.cs第 30 行Assert.AreEqual()失败,因为期望的结果是6,但实际值是4。我们还得到了我们提供给Assert.AreEqual()方法的消息。前一个截图中的整个文本消息如下:

TestProperties
   Source: RectangleTests.cs line 30
   Duration: 29 ms
  Message: 
    Assert.AreEqual failed. Expected:<6>. Actual:<4>. Area must be 6
  Stack Trace: 
    RectangleTests.TestProperties() line 35

到目前为止编写的测试代码中,我们使用了几种断言方法——AreEqual()IsTrue()IsFalse()。然而,这些并不是唯一可用的断言方法;还有很多。以下表格显示了一些最常用的断言方法:

此表中列出的所有方法实际上都是重载方法。您可以通过在线文档获得完整的参考资料。

分析代码覆盖率

当我们创建Rectangle类时,还为其创建了几个扩展方法,因此我们应该编写更多的单元测试来覆盖这两个。我们可以将这些测试放入另一个测试类中。尽管附带本书的源代码包含更多的单元测试,但为简洁起见,我们在这里只列出了其中一些:

[TestClass]
public class RectangleExtensionsTests
{
    [TestMethod]
    public void TestInflate()
    {
        var rectangle1 = Rectangle.Empty.Inflate(1, 2, 3, 4);
        Assert.AreEqual(1, rectangle1.Left);
        Assert.AreEqual(2, rectangle1.Top);
        Assert.AreEqual(3, rectangle1.Right);
        Assert.AreEqual(4, rectangle1.Bottom);
    }
    [TestMethod]
    public void TestDeflate()
    {
        var rectangle1 = Rectangle.Empty.Deflate(1, 2, 3, 4);
        Assert.AreEqual(-1, rectangle1.Left);
        Assert.AreEqual(-2, rectangle1.Top);
        Assert.AreEqual(-3, rectangle1.Right);
        Assert.AreEqual(-4, rectangle1.Bottom);
    }
    [TestMethod]
    public void TestIntersectsWith()
    {
        var rectangle = new Rectangle(1, 2, 10, 12);
        var rectangle1 = new Rectangle(3, 4, 5, 6);
        var rectangle2 = new Rectangle(5, 10, 20, 13);
        var rectangle3 = new Rectangle(11, 13, 15, 16);
        Assert.IsTrue(rectangle.IntersectsWith(rectangle1));
        Assert.IsTrue(rectangle.IntersectsWith(rectangle2));
        Assert.IsFalse(rectangle.IntersectsWith(rectangle3));
    }
    [TestMethod]
    public void TestIntersect()
    {
        var rectangle = new Rectangle(1, 2, 10, 12);
        var rectangle1 = new Rectangle(3, 4, 5, 6);
        var rectangle3 = new Rectangle(11, 13, 15, 16);
        var intersection1 = rectangle.Intersect(rectangle1);
        var intersection3 = rectangle.Intersect(rectangle3);
        Assert.AreEqual(3, intersection1.Left);
        Assert.AreEqual(4, intersection1.Top);
        Assert.AreEqual(5, intersection1.Right);
        Assert.AreEqual(6, intersection1.Bottom);
        Assert.AreEqual(0, intersection3.Left);
        Assert.AreEqual(0, intersection3.Top);
        Assert.AreEqual(0, intersection3.Right);
        Assert.AreEqual(0, intersection3.Bottom);
    }
}

编译单元测试项目后,新的单元测试类和方法将出现在测试资源管理器中,因此您可以运行或调试它们。以下截图显示了所有测试方法的成功执行:

图 17.7 - 测试资源管理器窗口显示了所有单元测试的成功执行,包括为矩形扩展方法编写的单元测试。

图 17.7 - 测试资源管理器窗口显示了所有单元测试的成功执行,包括为矩形扩展方法编写的单元测试

我们还可以根据您编写的单元测试来获取代码覆盖率。您可以从测试资源管理器测试顶级菜单触发代码覆盖。根据我们目前所见的单元测试,我们得到以下覆盖范围:

图 17.8 - Visual Studio 中显示我们单元测试代码覆盖率的代码覆盖结果窗格。

图 17.8 - Visual Studio 中显示我们单元测试代码覆盖率的代码覆盖结果窗格

在这里,我们可以看到Rectangle类完全被单元测试覆盖。然而,包含扩展的静态类只覆盖了IntersectsWith(),有一个八分之一的代码块没有被我们编写的单元测试覆盖。我们可以使用这份报告来识别代码中未被测试覆盖的部分,以便您可以编写更多测试。

测试的解剖学

到目前为止,我们编写的测试中,我们已经看到了测试类和测试方法。然而,测试类可能具有在不同阶段执行的其他方法。下面的代码显示了一个完整的示例:

[TestClass]
public class YourUnitTests
{
   [AssemblyInitialize]
   public static void AssemblyInit(TestContext context) { }
   [AssemblyCleanup]
   public static void AssemblyCleanup() { }
   [ClassInitialize]
   public static void TestFixtureSetup(TestContext context) { }
   [ClassCleanup]
   public static void TestFixtureTearDown() { }
   [TestInitialize]
   public void Setup() { }
   [TestCleanup]
   public void TearDown() { }

   [TestMethod]
   public void TestMethod1() { }
   TestMethod]
   public void TestMethod2() { }
}

这些方法的名称是无关紧要的。这里重要的是用于标记它们的属性。这些属性由测试框架反映,并确定方法被调用的顺序。对于这个特定的例子,顺序如下:

AssemblyInit()          // once per assembly
  TestFixtureSetup()    // once per test class
    Setup()             // before each test of the class
      TestMethod1()
    TearDown()          // after each test of the class
    Setup()
      TestMethod2()
    TearDown()
  TestFixtureTearDown() // once per test class
AssemblyCleanup()       // once per assembly

用于标记这些方法的属性列在下表中:

当您想要对同一个函数进行多个不同数据集的测试时,您可以从数据源中检索它们。托管代码的单元测试框架使这成为可能,我们将在下一节中看到。

编写数据驱动的单元测试

如果您再看一下之前的测试,比如TestIntersectsWith()测试方法,您会发现我们尝试测试各种情况,比如一个矩形与其他几个矩形的交集,一些相交,一些不相交。这是一个简单的例子,在实践中,应该有更多的矩形需要测试,以覆盖所有可能的矩形交集情况。

一般来说,随着代码的发展,测试也会发展,您经常需要添加更多的测试数据集。与其像我们之前的例子中那样在测试方法中明确地编写数据,您可以从数据源中获取数据。然后,测试方法针对数据源中的每一行执行一次。托管代码的单元测试框架支持三种不同的场景。

属性数据

第一种选项是通过代码提供数据,但通过一个名为DataRowAttribute的属性。这个属性有一个构造函数,允许我们指定任意数量的参数。然后,这些参数按照相同的顺序转发到它所用于的测试方法的参数中。让我们看一个例子:

[DataTestMethod]
[DataRow(true, 3, 4, 5, 6)]
[DataRow(true, 5, 10, 20, 13)]
[DataRow(false, 11, 13, 15, 16)]
public void TestIntersectsWith_DataRows(
    bool result, 
    int left, int top, int right, int bottom)
{
    var rectangle = new Rectangle(1, 2, 10, 12);
    Assert.AreEqual(
        result,
        rectangle.IntersectsWith(
            new Rectangle(left, top, right, bottom)));
}

在这个例子中有几件事情需要注意。首先,用于指示这是一个数据驱动测试方法的属性是DataTestMethodAttribute。然而,为了向后兼容,也支持TestMethodAttribute,尽管不鼓励使用。第二件需要注意的事情是DataRowAttribute的使用。我们用它来提供几个矩形的数据,以及与测试方法中的参考矩形相交的预期结果。如前所述,该方法对数据源中的每一行执行一次,这种情况下,即DataRow属性的每次出现。

以下清单显示了执行测试方法的输出:

Test has multiple result outcomes
   4 Passed
Results
    1) TestIntersectsWith_DataRows
      Duration: 8 ms
    2) TestIntersectsWith_DataRows (True,3,4,5,6)
      Duration: < 1 ms
    3) TestIntersectsWith_DataRows (True,5,10,20,13)
      Duration: < 1 ms
    4) TestIntersectsWith_DataRows (False,11,13,15,16)
      Duration: < 1 ms

如果数据源中的一行使测试失败,则会报告这种情况,但是方法的执行将重复进行,直到数据源中的下一行。

动态数据

使用DataRow属性是一种改进,因为它使测试代码更简单,但并非最佳选择。稍微更好的选择是动态地从类的方法或属性中获取数据。这可以使用另一个名为DynamicDataAttribute的属性来实现。您必须指定数据源的名称和类型(方法或属性)。下面的代码示例:

public static IEnumerable<object[]> GetData()
{
    yield return new object[] { true, 3, 4, 5, 6 };
    yield return new object[] { true, 5, 10, 20, 13 };
    yield return new object[] { false, 11, 13, 15, 16 };
}
[DataTestMethod]
[DynamicData(nameof(GetData), DynamicDataSourceType.Method)]
public void TestIntersectsWith_DynamicData(
    bool result, 
    int left, int top, int right, int bottom)
{
    var rectangle = new Rectangle(1, 2, 10, 12);
    Assert.AreEqual(
        result,
        rectangle.IntersectsWith(
            new Rectangle(left, top, right, bottom)));
} 

在本例中,我们定义了一个名为GetData()的方法,该方法返回一个对象数组的可枚举序列。我们用矩形边界和与参考矩形的交集的结果填充这些数组。然后,在测试方法中,我们使用DynamicData属性,并向其提供提供数据的方法的名称和数据源类型(DynamicDataSourceType.Method)。实际的测试代码与前一个示例中的代码没有任何不同。

然而,这种替代方案也依赖于硬编码数据。最理想的解决方案是从外部数据源读取数据。

来自外部源的数据

测试数据可以从外部源获取,例如 SQL Server 数据库、CSV 文件、Excel 文档或 XML。为此,我们必须使用另一个名为DataSourceAttribute的属性。此属性有几个构造函数,允许您指定到源的连接字符串和其他必要的参数。

注意

在撰写本书时,此解决方案和此属性仅适用于.NET Framework,并且尚不支持.NET Core。

要编写一个从外部源获取数据的测试方法,您需要能够访问有关此数据源的信息。这可以通过TestContext对象来实现,该对象由框架作为参数传递给标有AssemblyInitializeClassInitialize属性的方法。获取对该对象的引用的一个更简单的解决方案是,在测试类中提供一个名为TestContext的公共属性,并将其类型设置为TestContext,如下面的代码所示。框架将自动使用对测试上下文对象的引用来设置它:

public TestContext TestContext { get; set; }

然后,我们可以使用上下文来访问数据源信息。在接下来的示例中,我们将重写测试方法,以从与测试应用程序位于同一文件夹中的名为TestData.csv的 CSV 文件中获取数据。该文件的内容如下:

expected,left,top,right,bottom
true,3,4,5,6
true,5,10,20,13
false,11,13,15,16

第一列是与参考矩形的交集的预期结果,每行中的其他值是矩形的边界。从此 CSV 文件中获取数据执行的测试方法如下所示:

[DataTestMethod]
[DataSource("Microsoft.VisualStudio.TestTools.DataSource.CSV",
          "TestData.csv",
          "TestData#csv",
          DataAccessMethod.Sequential)]
public void TestIntersectsWith_CsvData()
{
    var rectangle = new Rectangle(1, 2, 10, 12);
    bool result = Convert.ToBoolean(
      TestContext.DataRow["Expected"]);
    int left = Convert.ToInt32(TestContext.DataRow["left"]);
    int top = Convert.ToInt32(TestContext.DataRow["top"]);
    int right = Convert.ToInt32(TestContext.DataRow["right"]);
    int bottom = Convert.ToInt32(
        TestContext.DataRow["bottom"]);
    Assert.AreEqual(
        result,
        rectangle.IntersectsWith(
            new Rectangle(left, top, right, bottom)));
}

您可以看到,与以前的方法不同,此方法没有参数。数据可通过TestContext对象的DataRow属性获得,并且此方法对 CSV 文件中的每一行调用一次。

如果您不希望在源代码中指定数据源信息(例如连接字符串),则可以使用应用程序配置文件来提供。为此,您必须添加一个自定义部分,然后定义一个连接字符串(带有名称、字符串和提供程序名称)和数据源(带有名称、连接字符串名称、表名称和数据访问方法)。对于我们在前面示例中使用的 CSV 文件,App.config文件将如下所示:

<?xml version="1.0" encoding="utf-8" ?>
<configuration>
   <configSections>
      <section name="microsoft.visualstudio.testtools"
               type="Microsoft.VisualStudio.TestTools.UnitTesting.TestConfigurationSection, Microsoft.VisualStudio.TestPlatform.TestFramework.Extensions"/>
   </configSections>
   <connectionStrings>
         <add name="MyCSVConn"
              connectionString="TestData.csv"
              providerName="Microsoft.VisualStudio.TestTools.DataSource.CSV" />
      </connectionStrings>
   <microsoft.visualstudio.testtools>
      <dataSources>
         <add name="MyCSVDataSource"
              connectionString="MyCSVConn"
              dataTableName="TestData#csv"
              dataAccessMethod="Sequential"/>
      </dataSources>
   </microsoft.visualstudio.testtools>
</configuration>

有了这个定义,我们唯一需要对测试方法进行的更改就是更改DataSource属性,指定来自.config文件的数据源的名称(在我们的示例中为MyCSVDataSource)。如下面的代码所示。

[DataTestMethod]
[DataSource("MyCSVDataSource")]
public void TestIntersectsWith_CsvData()
{
    /* ... */
}

要获取有关如何为各种类型的数据源提供连接字符串的更多信息,您应该阅读在线文档。

摘要

这本书的最后一章专门讲述了单元测试,这对于编写高质量的代码至关重要。我们从基本介绍单元测试开始,了解了微软用于编写单元测试的工具,包括托管代码的单元测试框架。我们看到了如何使用这个框架创建单元测试项目,无论是针对.NET Framework 还是.NET Core。然后我们看了单元测试框架的最重要特性,并学习了如何编写单元测试。在最后一节中,我们了解了数据驱动测试,并学习了如何使用各种数据源编写测试。

随着这本书在这里结束,我们作为作者,要感谢你抽出时间来阅读。通过撰写这本书,我们试图为您提供成为 C#语言专家所必需的一切。我们希望这本书对您学习和掌握 C#语言是一个宝贵的资源。

检验你所学到的内容。

  1. 什么是单元测试,它的最重要的好处是什么?

  2. Visual Studio 提供了哪些工具来帮助编写单元测试?

  3. Visual Studio 的测试资源管理器提供了哪些功能?

  4. 如何指定单元测试项目中的类包含单元测试?

  5. 你可以使用哪些类和方法来执行断言?

  6. 如何检查单元测试的代码覆盖率?

  7. 如何编写测试夹具,使其每个测试类执行一次?每个方法的测试夹具又是怎样的?

  8. 什么是数据驱动的单元测试?

  9. DynamicDataAttribute是做什么的?DataSourceAttribute又是什么?

  10. 支持的测试数据外部来源有哪些?

第十八章:评估

章节 1

  1. C#语言的第一个版本 1.0 于 2002 年发布,与.NET Framework 1.0 和 Visual Studio .NET 2002 捆绑在一起。在撰写本书时,该语言的当前版本是 C# 8。

  2. CLI 是一种规范,描述了如何在不为特定架构重写的情况下,在不同的计算机平台上使用运行时环境。CLI 描述了四个主要组件:公共类型系统CTS)、公共语言规范CLS)、虚拟执行系统VES)以及程序结构和内容的元数据。

  3. CIL 是一个平台中立的中间语言,代表了 CLI 定义的中间语言二进制指令集。当您编译程序的源代码时,编译器将其转换为 CIL 字节码,并生成 CLI 程序集。当执行 CLI 程序集时,字节码通过即时编译器传递,以生成本机代码,然后由计算机处理器执行。

  4. 要查看程序集的内容,您必须使用反汇编器。反汇编器的示例包括与.NET Framework 一起分发的 ildasm.exe,或者 ILSpy,一个开源的.NET 程序集浏览器和反编译器。

  5. 公共语言运行时是.NET Framework 对 VES 的实现。CLR 提供诸如内存管理、类型安全、垃圾回收、异常处理、线程管理等服务。

  6. BCL 是标准库的一个组件,提供了用于表示 CLI 内置类型、简单文件访问、自定义属性、字符串处理、格式化、集合、流等类型。

  7. 当前的主要.NET 框架是.NET Framework、.NET Core 和 Xamarin。由于微软计划使.NET Core 成为用于构建桌面、服务器、Web、云和移动应用程序的唯一框架;.NET Framework 被放置在维护模式,并且只包括安全更新。

  8. 程序集是部署、版本控制和安全性的基本单位。它们有两种形式:可执行文件(.exe)和动态链接库(.dll)。程序集是类型、资源和元信息的集合,形成一个逻辑功能单元。程序集的标识由名称、版本、文化和公钥令牌组成。

  9. GAC 是一个机器范围的代码缓存,它可以在应用程序之间共享程序集。其默认位置是%windir%\Microsoft.NET\assembly。Runtime Package Store 是.NET Core 应用程序的等效物。它可以实现更快的部署和更低的磁盘空间要求。通常,该存储在 macOS 和 Linux 上可用于/usr/local/share/dotnet/store,在 Windows 上可用于C:/Program Files/dotnet/store

  10. 为了编译和执行,C#程序必须包含一个包含名为Main()的静态方法的类。

章节 2

  1. C#中的内置整数类型是bytesbyteushortshortuintintulonglong

  2. floatdouble类型使用 2 的倒数来表示数字的小数部分。因此,它们无法准确表示诸如 1.23 或 19.99 之类的数字,而只能近似表示它们。尽管double具有 15 位精度,而float只有 7 位;但在执行重复计算时,精度损失会累积。decimal类型使用实数的十进制表示,计算速度要慢得多,但提供更好的精度。decimal类型具有 28 位精度,适用于金融应用等类别的应用程序,这是关键。

  3. 字符串可以使用+运算符进行连接。除了连接,您还可以使用String.Format()静态方法或字符串插值来组成字符串,这是该方法的一种语法快捷方式。

  4. 一些字符在字符串中具有特殊含义。这些称为转义序列,并以反斜杠(\)为前缀。例如单引号(\')、双引号(\")、换行字符(\n)和反斜杠(\\)。逐字字符串是以@标记为前缀的字符串。对于逐字字符串,编译器不解释转义序列。这使得编写多行文本或文件路径变得更容易。

  5. 隐式类型变量使用var关键字声明,而不是实际类型,并且必须在声明时初始化。编译器从用于初始化它们的值或表达式中推断出实际类型。

  6. 值类型和引用类型是 C#和.NET 中的两种主要类型类别。值类型的变量直接存储值。引用类型的变量存储指向(地址)包含实际对象的内存位置的引用。值类型具有值语义(简单来说,当你复制一个对象时,它的值被复制),引用类型具有值语义(当你复制一个对象时,它的引用被复制)。通常,值类型存储在堆栈上,引用类型存储在堆上,但这是一个实现细节,而不是类型的特征。

  7. 装箱是将值类型存储在object中的过程,拆箱是将object的值转换为值类型的相反操作。

  8. 可空类型是System.Nullable<T>的实例,它是一个可以表示基础T类型的值的泛型值类型,该类型只能是值类型,以及额外的空值。可空整数变量可以声明为Nullable<int>int?

  9. C#中有三种类型的数组。第一种类型是一维数组,它是单维数组。例如int[6],它是一个包含 6 个整数的数组。第二种类型是多维数组,它是两个或更多维度的数组,最多 32 个。例如int[2,3],它是一个具有 2 行 3 列的整数数组。第三种类型是交错数组,它是数组的数组。交错数组是一个一维数组,其元素是其他数组,每个数组可以是另一个维度。

  10. 系统定义的类型转换有隐式转换(例如从intdouble),显式转换(例如从doubleint)。显式类型转换也称为强制转换,在两种类型之间进行转换时可能会丢失信息时是必要的。用户定义的转换可以通过为某种类型定义隐式或显式操作符或使用辅助类来实现。

第三章

  1. C#语言中的选择语句是ifswitch

  2. switch语句的default情况可以出现在列表的任何位置。在所有情况标签被评估之后,它总是最后被评估。

  3. for循环允许我们执行一段代码,只要布尔表达式评估为 true。foreach循环允许我们遍历实现IEnumerable接口的集合的元素。

  4. while循环是一个入口控制循环。这意味着只要指定的布尔表达式评估为 true,它就会执行一系列语句。在执行块之前检查表达式。do-while循环是一个出口控制循环。这意味着布尔表达式将在循环结束时被检查。这确保了do-while循环至少会执行一次,即使条件在第一次迭代中评估为 false。

  5. 要从函数返回,可以使用returnyieldthrow。前两个表示正常返回。throw语句表示由于执行流中的错误情况而返回。

  6. break语句可用于退出switch情况或终止循环的执行。它适用于所有循环:forwhiledo-whileforeach

  7. 它表示方法、运算符或get访问器是一个迭代器,它出现在returnbreak语句之前。从迭代器方法返回的序列可以使用foreach语句消耗。yield语句使得可以在生成时返回值并在可用时消耗它们,这在异步上下文中特别有用。

  8. 您可以通过catch(Exception)捕获函数调用的所有异常,这样您就可以访问有关异常的信息,或者使用简单的catch语句(不指定异常类型),这样您就无法获取有关异常的任何信息。

  9. finally块包含在try部分之后执行的代码。无论执行是否正常恢复或控制是否因breakcontinuegotoreturn语句而离开try块,都会发生这种情况。

  10. .NET 中所有异常类型的基类是System.Exception类。

第四章

  1. 类是指定对象形式的模板或蓝图。它包含操作该数据的数据和代码。对象是类的一个实例。类是用class关键字引入的,并定义了一个引用类型。结构是用struct关键字引入的,并定义了一个值类型。与类不同,结构不支持继承,不能有显式的默认构造函数,并且除非它们被声明为conststatic,否则字段不能在声明时初始化。

  2. 只读字段是使用readonly修饰符定义的字段。这样的字段只能在构造函数中初始化,其值以后不能被改变。

  3. 表达式体定义是一种替代语法,通常用于方法和属性,它们只是评估表达式并可能返回评估结果。它们的形式是member => expression。它们支持所有类成员,不仅仅是方法,还有字段、属性、索引器、构造函数和终结器。表达式评估的结果值的类型必须与方法的返回类型匹配。

  4. 默认构造函数是一个没有任何参数的类的构造函数。另一方面,静态构造函数是用static关键字定义的构造函数,没有参数或访问修饰符,并且不能被用户调用。当首次访问类的第一个静态成员时,CLR 会自动调用静态构造函数,或者在首次实例化类时,CLR 会自动调用静态构造函数。静态构造函数用于初始化静态字段。

  5. 自动实现属性是编译器将提供私有字段和getset访问器的属性。

  6. 索引器是一个类成员,允许对象像数组一样被索引。索引器定义了getset访问器,就像属性一样。索引器没有显式的名称。它是通过使用this关键字创建的。索引器有一个或多个可以是任何类型的参数。

  7. 静态类是用static关键字声明的类。它只能包含静态成员,不能被实例化。静态类成员是使用类名而不是通过对象访问的。静态类基本上与非静态类相同,具有私有构造函数,并且所有成员都声明为static

  8. 可用的参数修饰符是refoutinref修饰符修改参数,使其成为参数的别名,参数必须是一个变量。它允许我们创建按引用调用的机制,而不是隐式的按值调用。in修饰符类似,它导致参数按引用传递,但不允许函数修改它。它基本上与readonly ref相同。out关键字也定义了按引用调用的机制,但它要求函数在返回之前初始化参数。它保证在指定的函数调用期间变量被赋值。

  9. 具有可变数量参数的方法必须具有一个参数,该参数是由params关键字引导的一维数组。这不必是函数的唯一参数,但必须是最后一个参数。

  10. 枚举是一组命名的整数常量。您必须使用enum关键字声明枚举。枚举是值类型。当我们想要为特定目的使用有限数量的整数值时,枚举非常有用。

第五章

  1. 面向对象编程是一种范例,允许我们围绕对象编写程序。它的核心原则是抽象、封装、继承和多态。

  2. 封装允许我们将类内部的数据隐藏在外部世界之外。封装很重要,因为它通过为不同组件定义最小的公共接口来减少它们之间的依赖关系。它还增加了代码的可重用性和安全性,并使代码更容易进行单元测试。

  3. 继承是一种机制,通过它一个类可以继承另一个类的属性和功能。C#支持单继承,但仅适用于引用类型。

  4. 虚方法是在基类中具有实现但可以在派生类中被重写的方法,这有助于更改或扩展实现细节。基类中的实现使用virtual关键字定义。派生类中的实现称为重写方法,并使用override关键字定义。

  5. 您可以通过使用sealed关键字声明虚成员来防止派生类中的成员被重写。

  6. 抽象类不能被实例化,这意味着我们不能创建抽象类的对象。抽象类使用abstract关键字声明。它们可以有抽象成员和非抽象成员。抽象成员不能是私有的,也不能有实现。抽象类必须为它实现的所有接口的所有成员提供实现(如果有的话)。

  7. 接口定义了一个由所有实现接口的类型支持的契约。接口是使用interface关键字引入的类型,包含一组必须由实现接口的任何类或结构实现的成员。通常,接口只包含成员的声明,而不包含实现。从 C# 8 开始,接口可以包含默认方法。

  8. 有两种类型的多态性:编译时多态性,由方法重载表示,以及运行时多态性。运行时多态性有两个方面。一方面,派生类的对象可以无缝地用作基类的对象,放在数组或其他类型的集合、方法参数和其他位置。另一方面,类可以定义虚方法,可以在派生类中重写。在运行时,CLR 将调用与对象的运行时类型相对应的虚成员的实现。当派生类的对象在基类的对象位置上使用时,对象的声明类型和运行时类型不同。

  9. 重载方法是具有相同名称但具有不同类型或不同数量参数的方法。返回类型不考虑方法重载。运算符也可以重载。当一个或两个操作数是该类型时,类型可以为重载运算符提供自定义实现。使用operator关键字声明运算符。这样的方法必须是publicstatic

  10. SOLID 原则包括:单一责任原则(S)开闭原则(O)里氏替换原则(L)接口隔离原则(I)依赖注入原则(D)

第六章

  1. 通用是用其他类型参数化的类型。通用提供可重用性,促进类型安全,并且可以提供更好的性能(通过避免值类型的装箱和拆箱)。

  2. 用于为通用类型或方法参数化的类型称为类型参数。

  3. 通用类的定义方式与非通用类相同,只是在类名后的尖括号内(如<T>)指定一个或多个类型参数的列表。通用方法也是如此;类型参数在类名后指定。

  4. 类可以派生自通用类型。结构不支持显式继承,但可以实现任意数量的通用接口。

  5. 构造类型是从通用类型构造的类型,通过用实际类型替换类型参数。例如,对于Shape<T>通用类型,Shape<int>是一个构造类型。

  6. 协变类型参数是使用out关键字声明的类型参数。这样的类型参数允许接口方法具有比指定类型参数更派生的返回类型。

  7. 逆变类型参数是使用in关键字声明的类型参数。这样的类型参数允许接口方法具有比指定类型参数更不派生的参数。

  8. 类型参数约束是为类型参数指定的限制,通知编译器类型参数必须具有什么样的能力。应用约束会限制可以用于从通用类型构造类型的类型。

  9. new()类型约束指定类型必须提供公共默认构造函数。

  10. C# 8 中引入的类型参数约束是notnull。它只能在可空上下文中使用,否则编译器会生成警告。它指定类型参数必须是非空类型。它可以是非空引用类型(在 C#8 中)或非空值类型。

第七章

  1. 包含通用集合的 BCL 命名空间是System.Collections.Generic

  2. 定义用于通用集合功能的所有其他接口的基本接口是IEnumerable<T>

  3. 通用集合优于非通用集合,因为它们提供类型安全性的好处,对值类型有更好的性能(因为它们避免了装箱和拆箱),并且在某些情况下,它们提供非通用集合中不可用的功能。

  4. List<T>通用类表示可以通过它们的索引访问的元素集合。List<T>与数组非常相似,只是集合的大小不是固定的,而是可变的,可以随着元素的添加或删除而增长或减少。您可以使用Add()AddRange()Insert()InsertRange()添加元素。您可以使用Remove()RemoveAt()RemoveRange()RemoveAll()Clear()删除元素。

  5. Stack<T>通用类表示具有后进先出语义的集合。元素使用Push()方法添加到顶部,并使用Pop()方法从顶部移除。

  6. Queue<T>泛型类表示具有先进先出语义的集合。Dequeue()方法从队列的前端移除并返回项目。Peek()方法返回队列前端的项目,但不移除它。

  7. LinkedList<T>泛型类表示双向链表。它的元素是LinkedListNode<T>类型。要向链表添加元素,可以使用AddFirst()AddLast()AddAfter()AddBefore()方法。

  8. Dictionary<TKey, TValue>泛型类表示键值对的集合,允许基于键进行快速查找。这个字典类的元素是KeyValuePair<TKey, TValue>类型。

  9. HashSet<T>泛型类表示一组不同的项目,可以以任何顺序存储在一起。哈希集在逻辑上类似于字典,其中值也是键。但是,与Dictionary<TKey, TValue>不同,HashSet<T>是一个非关联容器。

  10. BlockingCollection<T>是一个实现了IProducerConsumerCollection<T>接口定义的生产者-消费者模式的类。它实际上是IProducerConsumerCollection<T>接口的一个简单包装器,没有内部基础存储,但必须提供一个(实现了IProducerConsumerCollection<T>接口的集合)。如果没有提供实现,它默认使用ConcurrentQueue<T>类。它适用于需要边界和阻塞的场景。

第八章

  1. 回调是作为参数传递给另一个函数以立即调用(同步回调)或在以后调用(异步回调)的函数的函数(或更一般地说,任何可执行代码)。委托是一种强类型的回调。

  2. 使用delegate关键字定义委托。声明看起来像函数签名,但实际上编译器引入了一个可以持有方法引用的类,其签名与委托的签名匹配。事件是使用event关键字声明的委托类型的变量。

  3. C#中有两种元组:引用元组,由System.Tuple类表示,和值元组,由System.ValueTuple结构表示。引用元组最多只能容纳八个元素,而值元组可以容纳任意数量的元素,但至少需要两个。值元组可以具有编译时命名字段,并且具有更简单但更丰富的语法来创建、赋值、解构和比较值。

  4. 命名元组是具有字段名称的值元组。这些名称是字段Item1Item2等的同义词,但仅在源代码级别可用。

  5. 模式匹配是检查值是否具有特定形状以及在匹配成功时从值中提取信息的过程。它可以与isswitch表达式一起使用。

  6. 空值不匹配类型模式,无论变量的类型如何。可以在具有类型模式匹配的switch表达式中添加一个用于匹配空值的switch case 标签,以专门处理空值。使用var模式时,空值始终匹配。因此,在使用var模式时,必须添加显式的空值检查,因为值可能为空。

  7. .NET 中用于处理正则表达式的类是System.Text.RegularExpressions命名空间中的Regex类。默认情况下,它使用 UTF-8 编码进行字符串匹配。

  8. Match()方法检查输入字符串中与正则表达式匹配的子字符串,并返回第一个匹配项。Matches()方法执行相同的搜索,但返回所有匹配项。

  9. 扩展方法是扩展类型功能而不改变其源代码的方法。它们很有用,因为它们允许扩展而不改变实现,创建派生类型或重新编译代码,一般来说。

  10. 扩展方法被定义为静态方法,属于静态、非嵌套、非泛型类,它们的第一个参数是它们扩展的类型,前面加上this关键字。

第九章

  1. 栈是编译器分配的相对较小的内存段,用于跟踪运行应用程序所需的内存。栈具有 LIFO 语义,并随着程序执行调用函数或从函数返回而增长和缩小。另一方面,堆是程序可能在运行时用来分配内存的大内存段,在.NET 中由 CLR 管理。通常,值类型的对象分配在栈上,引用类型的对象分配在堆上。

  2. 托管堆有三个内存段,称为代。它们被命名为代 0、1 和 2。代 0 包含小的、通常是短寿命的对象,比如局部变量或在函数调用的生命周期内实例化的对象。代 1 包含在代 0 的内存回收中幸存下来的小对象。代 2 包含在代 1 的内存回收中幸存下来的长寿命小对象和大对象(总是分配在这个段上)。

  3. 垃圾收集有三个阶段。首先,垃圾收集器构建所有活动对象的图形,以便找出仍在使用的对象和可能被删除的对象。其次,将要压缩的对象的引用被更新。第三,死对象被移除,幸存的对象被压缩。通常,包含大对象的大对象堆不会被压缩,因为移动大块数据会产生性能成本。

  4. 终结器是一个类的特殊方法(与类名相同,但前缀为~),应该处理类拥有所有权的非托管资源。当对象被回收时,垃圾收集器会调用这个方法。这个过程是非确定性的,这是终结和处理之间的关键区别。后者是一个确定性的过程,发生在显式调用Dispose()方法时(对于实现了IDisposable接口的类)。

  5. GC.SuppressFinalize()方法请求 CRL 不要调用指定对象的终结器。通常在实现IDisposable接口时调用这个方法,以便非托管资源不会被处理两次。

  6. IDisposable是一个接口,有一个名为Dispose()的方法,定义了对象的确定性处理的模式。

  7. using语句表示对实现IDisposable接口的类型的对象进行确定性处理的简写语法。using语句引入了在语句中定义的变量的作用域,并确保在退出作用域之前正确处理对象。实际的处理细节取决于资源是值类型、可空值类型、引用类型还是动态类型。

  8. 可以使用平台调用服务(Platform Invocation Services,或 P/Invoke)在 C#中调用来自本机 DLL 的函数。为此,必须定义一个与本机函数签名匹配的static extern方法(使用等效的托管类型作为其参数)。这个托管函数必须用DllImport属性修饰,该属性定义了运行时调用本机函数所需的信息。

  9. 不安全代码是 CLR 无法验证其安全性的代码。不安全代码使得可以使用指针类型并支持指针算术。不安全代码不一定是危险的,但您完全有责任确保不会引入指针错误或安全风险。使用不安全代码的典型场景包括调用从本机 DLL 或 COM 服务器导出的需要指针类型作为参数的函数,并优化一些性能关键的算法。

  10. 使用unsafe关键字定义不安全代码,可以应用于类型(类、结构、接口和委托)、类型成员(方法、字段、属性、事件、索引器、运算符、实例构造函数和静态构造函数)和语句块。

第十章

  1. 函数式编程的主要特征是不可变性(对象具有不变的状态)和无副作用的函数(函数不修改值或状态在它们的局部范围之外)。函数式编程的优点包括以下几点:首先,代码更容易理解和维护,因为函数不改变状态,只依赖于它们接收的参数。其次,由于同样的原因,代码更容易测试。第三,实现并发更简单和更有效,因为数据是不可变的,函数没有副作用,避免了数据竞争。

  2. 高阶函数是一个接受一个或多个函数作为参数、返回一个函数或两者兼有的函数。

  3. C#提供了将函数作为参数传递、从函数返回函数、将函数分配给变量、将函数存储在数据结构中、定义匿名函数、嵌套函数以及测试函数引用是否相等的能力。所有这些特性使 C#成为一种被称为将函数视为一等公民的语言。

  4. Lambda 表达式是一种方便的编写匿名函数的方式。这是一段代码,可以是一个表达式或一个或多个行为像函数一样的语句,并且可以被分配给一个委托。因此,lambda 表达式可以作为参数传递给函数或从函数返回。它们是编写 LINQ 查询、将函数传递给高阶函数(包括应该由Task.Run()异步执行的代码)以及创建表达式树的一种方便的方式。Lambda 表达式由 lambda 声明运算符=>分隔成两部分。左部是参数列表,右部是一个表达式或一个语句。Lambda 表达式的一个例子是n => n%2==1

  5. Lambda 表达式中变量作用域的规则如下:首先,lambda 表达式中引入的变量在 lambda 之外是不可见的。其次,lambda 不能捕获封闭方法中的inrefout参数。第三,lambda 捕获的变量在委托被垃圾回收之前不会被垃圾回收,即使它们本来应该超出作用域。第四,最后,lambda 表达式的返回语句仅与 lambda 所代表的匿名方法有关,并不会导致封闭方法返回。

  6. LINQ 是一组技术,使开发人员能够以一致的方式查询多种数据源。LINQ 标准查询操作符是一组在实现IEnumerable<T>IQueryable<T>的序列上操作的扩展方法。LINQ 查询语法基本上是标准查询操作符的语法糖。编译器将用查询语法编写的查询转换为使用标准查询操作符的查询。查询语法比标准查询操作符更简单、更易读,但它们在语义上是等价的。然而,并非所有的标准查询操作符在查询语法中都有等价物。

  7. Select()方法将序列的每个元素投影到一个新形式中。这需要一个选择器,即一个转换函数,为集合的每个元素产生一个新值。然而,当集合的元素本身是集合时,通常需要将它们展平为单个集合。这就是SelectMany()方法所做的事情。

  8. 部分函数应用是将具有N个参数和一个参数的函数进行处理,并在将参数固定为函数的一个参数后返回另一个具有N-1个参数的函数的过程。这种技术是柯里化的相反,柯里化是将具有N个参数的函数进行处理,并将其分解为接受一个参数的N个函数的过程。

  9. 幺半群是具有单一可结合二元运算和单位元素的代数结构。任何具有这两个元素的 C#类型都是幺半群。

  10. 单子是封装在值之上的一些功能的容器。单子有两个操作:第一个将一个值v转换为封装它的容器(v -> C(v))。在函数式编程中,这个函数被称为返回。第二个将两个容器展平为一个单一的容器(C(C(v)) -> C(v))。在函数式编程中,这被称为绑定。一个单子的例子是带有 LINQ 查询运算符SelectMany()IEnumerable<T>

第十一章

  1. 在.NET 中,部署的单位是程序集。程序集是一个文件(可执行文件或动态链接库),其中包含 MSIL 代码以及有关程序集内容的元数据,以及可选的资源。

  2. 反射是运行时类型发现和对其进行更改的过程。这意味着我们可以在运行时检索有关类型、其成员和属性的信息。反射使得可以轻松构建可扩展的应用程序;执行私有或具有其他访问级别的类型和成员,否则这些类型和成员将无法访问,这对于测试很有用;在运行时修改现有类型或创建全新类型并使用它们执行代码;以及通常在运行时更改系统行为,通常使用属性。

  3. 提供有关类型的元信息的类型是System.Type。可以使用GetType()方法、Type.GetType()静态方法或 C#的typeof运算符创建此类型的实例。

  4. 共享程序集旨在被多个应用程序使用,通常位于全局程序集缓存(GAC)下,这是程序集的系统存储库。私有程序集旨在被单个应用程序使用,并存储在应用程序目录或其子目录中。共享程序集必须具有强名称并强制版本约束;这些要求对于私有程序集并非必需。

  5. 在.NET 中,程序集可以在以下上下文中加载:加载上下文(包含从 GAC、应用程序目录或其子目录加载的程序集)、从其他路径加载的程序集的加载上下文、仅用于反射目的加载的反射上下文,或者根本没有上下文(例如从字节数组加载程序集时)。

  6. 早期绑定是在编译时创建程序集依赖关系(引用)的过程。这使得编译器可以完全访问程序集中可用的类型。晚期绑定是在运行时加载程序集的过程,在这种情况下,编译器无法访问程序集的内容。然而,这对于构建可扩展的应用程序非常重要。

  7. 动态语言运行时是.NET 平台的一个组件,它定义了一个运行时环境,该环境在 CLR 之上添加了一组服务,以便使动态语言能够在 CLR 上运行,并为静态类型的语言添加动态特性。

  8. dynamic类型是静态类型,意味着在编译时将变量分配给dynamic类型。但是,它们绕过了静态类型检查。这意味着对象的实际类型只在运行时才知道,编译器无法知道也无法强制执行对该类型对象执行的任何操作。您可以调用任何带有任何参数的方法,编译器不会检查也不会抱怨;但是,如果操作无效,运行时将抛出异常。dynamic类型通常用于在 Interop 程序集不可用时简化对 COM 对象的使用。

  9. 属性是从System.Attribute抽象类派生的类型,提供有关程序集、类型和成员的元信息。这些元信息由编译器、CLR 或使用反射服务读取它们的工具消耗。属性在方括号中指定,例如[SerializableAttribute]。属性的命名约定是类型名称总是以Attribute一词结尾。C#语言提供了一种语法快捷方式,允许在不带后缀Attribute的情况下指定属性的名称,例如[Serializable]

  10. 要创建用户定义的属性,必须从System.Attribute类型派生,并遵循将类型后缀命名为Attribute的命名约定。

第十二章

  1. 当需要执行一些长时间运行的、CPU 密集型的代码时,手动创建一个专用线程是首选。另一个选项是使用TaskCreationOptions.LongRunning创建一个任务,或者在大多数高级场景下,编写一个自定义任务调度程序。

  2. 最有效的同步技术是不使用内核对象而是用户模式对象的技术。为了原子地在文件和内存中写入某个值,关键部分是最合适的技术,并且通过 C#语言的lock关键字可用。

  3. Task.Delay API 是最合适的延迟,因为它在指定的毫秒数后调度继续执行的代码,同时让线程在此期间被重用。相反,操作系统的Sleep API 在.NET 中暴露为Thread.Sleep,它会暂停线程的执行一定的毫秒数,但会使线程无法被重用。

  4. Task 库提供了WaitHandle.WaitAnyWaitHandle.WaitAll方法,分别在任何所有操作完成时立即调用继续执行的代码。可以在返回的任务完成后立即访问任务结果。

  5. TaskCompletionSource是一个用于创建和控制Task的类。它可以用于将任何异步行为(如 CLR 事件)转换为基于任务的操作。客户端代码可以等待从TaskCompletionSource获得的任务,而不是订阅事件。

  6. Task库提供了预构建的Task.CompletedTask来返回一个空的Task,以及Task.FromResultTask.FromCanceledTask.FromException方法来创建返回结果、报告取消或抛出异常的任务。

  7. 通过在Task构造函数中指定TaskCreationOptions.LongRunning可以创建长时间运行的任务。

  8. 需要使用Control.Invoke(或 WPF 中的Dispatcher.Invoke)可以通过Control.InvokeRequired(或 WPF 中的Dispatcher.CheckAccess())进行验证,并取决于用于访问资源的库是否已经在主线程中调度了结果。如果库已经包含了任务,并且库作者没有调用Task.ConfigureAwait(false),那么可以直接使用结果,因为在await关键字之后执行的继续操作是由 UI 框架提供的同步上下文在主线程中调用的。

  9. ConfigureAwait方法可用于避免在进程中使用同步上下文时发生的无用调度操作。这通常由 UI 框架和 ASP.NET 应用程序创建。ConfigureAwait的主要用户是不需要访问只能从主线程使用的应用程序对象的库开发人员。

  10. 首先必须验证异步操作是否在主线程中完成(例如,通过在 Windows Forms 中使用Control.InvokeRequired或在 WPF 中使用Dispatcher.CheckAccess())。如果在不同的线程中完成,需要通过Control.InvokeDispatcher.Invoke访问 UI。

第十三章

  1. System.IO命名空间中与系统对象一起工作的最重要的类是Path用于路径,FileFileInfo用于文件,DirectoryDirectoryInfo用于目录。

  2. 连接路径的首选方法是使用Path.Combine()静态方法。

  3. 可以使用Path.GetTempPath()静态方法检索当前用户的临时文件夹的路径。

  4. FileFileInfo类提供类似的功能,但File是一个静态类,FileInfo是一个非静态类。同样,Directory是一个静态类,DirectoryInfo是一个非静态类,尽管它们的功能类似。

  5. 要创建目录,可以使用Create()CreateSubdirectory()方法。前者在其直接父目录存在时创建目录。后者创建一个子目录,以及必要时一直到根目录的所有其他子目录。要枚举目录,使用EnumerateDirectories()方法,它检索一个可枚举的目录集合,在整个集合返回之前可以枚举。有多个重载用于各种搜索选项。

  6. .NET 中流的三个类别是后备存储(表示字节序列的源或目的地的流)、装饰器(从另一个流中读取或写入数据,以某种方式转换它)、适配器(实际上不是流,而是帮助我们以比字节更高级别的方式处理数据源的包装器)。

  7. .NET 中流的基类是System.IO.Stream类。这是一个提供从流中读取和写入的方法和属性的抽象类。其中许多是抽象的,并在派生类中实现。

  8. 默认情况下,BinaryReaderBinaryWriter都使用 UTF-8 编码处理字符串。但是,它们都有重载的构造函数,允许使用System.Text.Encoding类指定另一个编码。

  9. System.Xml.Serialization命名空间中的XmlSerializer类可用于序列化和反序列化数据。XmlSerializer通过将类型的所有公共属性和字段序列化为 XML 来工作。它使用一些默认设置,例如类型变为节点,属性和字段变为元素。类型、属性或字段的名称成为节点或元素的名称,字段或属性的值成为其文本。

  10. .NET Core 附带的 JSON 序列化器称为System.Text.Json。对于.NET Framework 和.NET Standard 项目,它作为 NuGet 包提供,名称相同。您可以使用JsonSerializer.Serialize()静态方法来序列化数据,使用JsonSerializer.Deserialize<T>()静态方法来反序列化数据。您可以使用特定属性来控制序列化过程。另一方面,如果您想更多地控制写入或读取的内容,可以使用Utf8JsonWriterUtf8JsonReader类。

第十四章

  1. 可能会引发异常的代码必须放在try块中。

  2. catch块中,您可能主要想尝试恢复错误。恢复策略可能非常不同,可能从向用户报告友好的错误到使用不同参数重复操作。记录是catch块中执行的另一个典型操作。

  3. catch块中指定的异常类型捕获与相同类型或任何派生类型匹配的异常。因此,层次结构中较低的异常必须最后指定。在任何情况下,如果顺序不正确,C#编译器将生成错误。

  4. 通过在catch语句中指定变量名,您可以访问异常对象。它提供了诸如消息和其他信息的重要信息,在记录错误时非常宝贵。异常对象还可以在创建新的更具体的异常时用作内部异常参数。

  5. 在检查异常对象后,您可能会意识到无法对操作进行任何恢复。在这种情况下,更合适的是让异常继续传递给调用者。这可以通过使用无参数的throw语句来完成,或者通过在构造函数中传递异常对象来创建并抛出新异常。

  6. finally块用于声明一个无论try块中指定的代码是失败还是成功都必须执行的代码块。

  7. 当您不需要被通知try块内部代码的失败时,可以指定一个不带catchfinally块。finally代码将在任何情况下执行。

  8. 首次异常代表异常在非常早期阶段的情况,即它们被抛出并在跳转到其处理程序之前。调试器可能会在这些异常处停止,从而更准确地指示潜在的错误。

  9. Visual Studio 调试器允许我们选择我们想要在其中停止的首次异常。这可以通过异常设置窗口完成。

  10. 在应用程序即将崩溃之前触发UnhandledException事件。此事件可用于向用户提供更好的建议,记录错误,甚至自动重新启动应用程序。

第十五章

  1. 通过启用 C# 8 可空引用类型功能并在代码中装饰引用类型,您将大大减少代码中NullReferenceException异常的发生。

  2. 访问数组中的最后一项的新简洁语法是[¹],它利用了System.Index类型。

  3. 在 switch 表达式中,丢弃(_)字符等同于default,通常用于 switch 语句中。

  4. C# 8 引入了异步处理特性,以在处理资源时提供异步行为。这样,我们可以等待DisposeAsync方法的异步关闭操作,避免在Dispose中使用Task.Wait方法的危险。

  5. 空合并赋值??=用于在左侧(在我们的示例中为orders)不为 null 时避免执行赋值右侧(GetOrders()方法)的代码。

  6. 为了能够与async foreach一起迭代,一个序列必须表现出一种无法使用IEnumerableIEnumerator接口及其通用对应项来完成的异步行为。新的IAsyncEnumerable<T>IAsyncEnumerator<T>接口专门设计用于支持async foreach语句使用的异步行为。

第十六章

  1. global.json文件用于确定在给定目录树中将使用哪个 SDK。您可以使用dotnet new globaljson命令在解决方案根文件夹(或任何父文件夹)中创建此文件,并手动编辑它以匹配dotnet --info命令返回的版本之一。

  2. Path.Combine方法是在 Windows 和 Linux 上连接路径的最佳方法,两者使用不同的路径分隔符。这种方法也非常方便,可以避免在连接相对路径时出现错误,并且可以避免重复或省略分隔符。

  3. 符合.NET Standard 规范的库与支持它的任何框架都是二进制兼容的。当您需要在不同的框架之间共享代码时,请验证它们支持的最新版本的.NET Standard,并创建一个使用它的库。如果您需要使用的 API 不受所需版本的.NET Standard 支持,您可以改变策略,创建单独的库,并将它们打包在一个单独的 NuGet 包中。包清单将需要将每个程序集与库可以运行的特定框架、平台或架构相关联。

  4. 由于新的项目文件格式,现在可以从一个项目复制所需的PackageReference标签到另一个项目。当解决方案打开时,也可以在 Visual Studio 中执行此操作,并且一旦文件保存,NuGet 包将自动恢复。

  5. 在分析了架构影响之后,第一步是将当前解决方案升级到最新版本的.NET Framework,至少是 4.7.2 版本。

  6. 为了最小化启动时间,.NET Core 3 提供了两个新的发布选项。第一个是AOT编译,它立即生成程序集代码,大大减少了对JIT编译器的需求。第二个是启用Quick JIT编译器,它在运行时使用,比传统的JIT编译器更快,但生成的代码不太优化。

第十七章

  1. 单元测试是一种软件测试类型,其中测试单个代码单元,以验证它们是否按照设计要求工作。单元测试有助于在开发周期的早期识别和修复错误,因此有助于节省时间和金钱。它有助于开发人员更好地理解代码,并允许他们更容易地进行更改。它通过要求代码更模块化来帮助代码重用。它可以作为项目文档。它还有助于调试,因为当测试失败时,只需要检查和调试最新的更改。

  2. 用于单元测试的 Visual Studio 工具包括Test Explorer(您可以在其中查看、运行、调试和分析测试)、用于托管代码的 Microsoft 单元测试框架、代码覆盖工具(确定单元测试覆盖的代码量)和 Microsoft Fakes 隔离框架(允许您为类和方法创建替代品)。

  3. Visual Studio 中的Test Explorer允许您查看可用的单元测试,按不同级别(项目、类等)分组。您可以从Test Explorer运行和调试单元测试,并查看它们的执行结果。

  4. 要指定一个类包含单元测试,必须使用TestClass属性对其进行修饰。包含单元测试的方法必须使用TestMethod属性进行修饰。

  5. 用于执行断言的类称为Assert,并且位于Microsoft.VisualStudio.TestTools.UnitTesting命名空间中。它包含许多静态方法,例如AreEqual()AreNotEqual()IsTrue()IsFalse()AreSame()AreNotSame()IsNull()IsNotNull()

  6. 代码覆盖率可以根据测试资源管理器测试顶级菜单中的可用单元测试来确定。结果可在代码覆盖率结果窗格中查看。

  7. 您可以通过提供使用ClassInitializeClassCleanup属性修饰的方法来提供每个类执行一次的固定装置。前者在执行所有测试之前每个类执行一次,后者在执行所有测试之后执行一次。对于在每个单元测试之前和之后执行的固定装置,您必须提供使用TestInitializeTestCleanup属性修饰的方法。

  8. 数据驱动的单元测试意味着编写从外部源(如文件或数据库)获取测试数据的单元测试。然后,测试方法针对数据源中的每一行执行一次。

  9. DynamicData属性允许您指定单元测试类的方法或属性作为数据源。DataSource属性允许您指定外部数据源。

  10. Microsoft 单元测试框架支持的数据驱动测试的外部数据源包括 SQL 数据库、CSV 文件、Excel 文档和 XML 文档。

posted @ 2024-05-17 17:50  绝不原创的飞龙  阅读(10)  评论(0编辑  收藏  举报