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

98 阅读46分钟

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 存储库上进行更新。

我们还有来自我们丰富书籍和视频目录的其他代码包,网址为 github.com/PacktPublis…

实战代码

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

下载彩色图像

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

使用的约定

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

文本中的代码:表示文本中的代码词,数据库表名,文件夹名,文件名,文件扩展名,路径名,虚拟 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 中所有异常的基类是什么?