C-9-和--NET5-高级教程-一-

65 阅读1小时+

C#9 和 .NET5 高级教程(一)

原文:Pro C# 9 with .NET 5

协议:CC BY-NC-SA 4.0

一、C# 和 .NET(Core)5 简介

微软的。NET 平台和 C# 编程语言大约在 2002 年正式引入,并迅速成为现代软件开发的中流砥柱。的。NET 平台使得大量的编程语言(包括 C#、VB.NET 和 F#)能够相互交互。用 C# 写的程序可以被用 VB.NET 写的另一个程序引用。本章稍后将详细介绍这种互操作性。

2016 年,微软正式推出。NET 核心。比如。网,。NET Core 允许语言之间的互操作(尽管支持的语言数量有限)。更重要的是,这个新框架不再局限于在 Windows 操作系统上运行,还可以在 iOS 和 Linux 上运行(并被开发)。这种平台独立性向更多的开发人员开放了 C#。而之前的版本支持跨平台使用 C#。NET 核心,这是通过各种其他框架,如 Mono 项目。

Note

你可能对章节标题中的括号感到疑惑。随着的发布。NET 5,名称中的“核心”部分被去掉,以表明这个版本是所有. NET 的统一。网芯和。为了清楚起见,请使用. NET Framework。

2020 年 11 月 10 日,微软推出 C# 9 和。净 5。像 C# 8 一样,C# 9 被绑定到框架的一个特定版本,只能在。NET 5.0 及以上。与框架版本绑定的语言版本让 C# 团队可以自由地将由于框架限制而无法添加到语言中的特性引入到 C# 中。

正如在书的介绍中提到的,这篇文章的目标是双重的。首要任务是为您提供对 C# 语法和语义的深入而详细的研究。第二个(同样重要的)事项是说明许多。NET 核心 API。其中包括使用 ADO.NET 和实体框架(EF)核心的数据库访问,使用 WPF(WPF)的用户界面,以及使用 ASP.NET 核心的 RESTful 服务和 web 应用。俗话说,千里之行,始于足下;就这样,我欢迎你来到第一章。

这第一章为本书的其余部分奠定了概念基础。在这里,您会发现一些高层次的讨论。NET 相关的主题,如程序集、公共中间语言(CIL)和实时(JIT)编译。除了预览 C# 编程语言的一些关键字之外,您还将逐渐理解。NET 核心框架。这包括。NET 运行库,它结合了。NET 核心公共语言运行库(CoreCLR)和。NET 核心库(CoreFX)合并成一个代码库;通用类型系统(CTS);通用语言规范(CLS);还有。NET 标准。

本章还概述了提供的功能。NET 核心基类库,有时缩写为 bcl。在这里,您将对。NET 核心平台。正如您所料,这些主题将在本文的剩余部分进行更详细的探讨。

Note

本章(以及整本书)强调的许多特征也适用于原著。NET 框架。在本书中,我总是使用这些术语。NET 核心框架和。NET 核心运行时的一般术语。NET 来明确说明这些功能在。NET 核心。

探索的一些主要优势.NETCore 平台

那个。NET 核心框架是一个软件平台,用于在 Windows、iOS 和 Linux 操作系统上构建 web 应用和服务系统,以及在 Windows 操作系统上构建 WinForms 和 WPF 应用。为了做好准备,以下是提供的一些核心特性的简要介绍.NETCore:

  • 与现有代码的互操作性:这(当然)是一件好事。现存的。NET Framework 软件可以与较新的。NET 核心软件,反之亦然,通过。净标准。

  • 支持多种编程语言:。NET 核心应用可以使用 C#、F# 和 VB.NET 编程语言创建(C# 和 F# 是 ASP.NET 核心的主要语言)。

  • 由所有人共享的公共运行时引擎。NET 核心语言:这个引擎的一个方面是一组定义良好的类型。网芯语言懂。

  • 语言整合:。NET Core 支持代码的跨语言继承、跨语言异常处理和跨语言调试。例如,您可以在 C# 中定义一个基类,并在 Visual Basic 中扩展此类型。

  • 一个 全面的基础类库:这个库提供了数千种预定义的类型,让你可以构建代码库、简单的终端应用、图形化桌面应用、企业级网站。

  • 一个 简化部署模型:。NET Core 库没有注册到系统注册表中。此外。NET 核心平台允许多个版本的框架和应用在一台机器上和谐共存。

  • 广泛的命令行支持:即。NET Core 命令行界面(CLI)是一个用于开发和打包的跨平台工具链。NET 核心应用。除了随附的标准工具之外,还可以(全局或本地)安装其他工具。NET Core SDK。

在接下来的章节中,你将会看到这些主题中的每一个(以及更多的主题)。但首先,我需要解释新的支持生命周期。NET 核心。

了解.NETCore 支持生命周期

。NET 核心版本的发布比。NET 框架。对于所有这些可用的版本,可能很难跟上,尤其是在企业开发环境中。为了更好地定义版本的支持生命周期,微软采用了长期支持模型的变体,现代开源框架通常使用的 1

长期支持(LTS)版本是将获得长期支持的主要版本。在他们的整个生命周期中,他们只会收到关键的和/或非破坏性的修复。在停产之前,LTS 版本将更改为维护名称。LTS 发布。NET Core 将在以下时间段内获得支持,以时间较长者为准:

  • 首次发布三年后

  • 后续 LTS 版本发布后的一年维护支持

Microsoft 已经决定将短期支持版本命名为当前版本,它是主要 LTS 版本之间的间隔版本。在随后的当前版本或 LTS 版本之后,它们将获得三个月的支持。

如前所述。NET 5 于 2020 年 11 月 10 日发布。它是作为当前版本发布的,而不是 LTS 版本。这意味着。NET 5 将在下一个版本发布三个月后停止支持。。2019 年 12 月发布的 NET Core 3.1 是 LTS 版本,完全支持到 2022 年 12 月 3 日。

Note

的下一个计划发布。NET 是。网 6,定于 2021 年 11 月。大约可以支持 15 个月。净 5。然而,如果微软决定发布一个补丁(例如,5.1),那么三个月的时间将随着该版本开始计时。我建议您在选择开发生产应用的版本时,考虑一下这个支持策略。澄清一下,我不是说你应该用而不是。净 5。我强烈建议您在选择时了解支持政策。生产应用开发的. NET(核心)版本。

检查每个新版本的支持策略非常重要。发布的 NET Core。只是有一个较高的数字并不一定意味着它会得到长期支持。完整的政策位于此处:

https://dotnet.microsoft.com/platform/support-policy/dotnet-core

预览的构造块。NET 核心平台(。NET 运行时、CTS 和 CLS)

现在您已经了解了。NET Core,让我们预览一下使这一切成为可能的关键(和相关的)主题:核心运行时(以前是 CoreCLR 和 CoreFX)、CTS 和 CLS。从程序员的角度来看。NET Core 可以理解为一个运行时环境,一个综合的基础类库。运行时层包含一组特定于平台(Windows、iOS、Linux)和架构(x86、x64、ARM)的最小实现,以及。NET 核心。

的另一个构造块。NET 核心平台是通用类型系统,或 CTS 。CTS 规范完整地描述了运行库支持的所有可能的数据类型和所有编程构造,指定了这些实体如何相互交互,并详细说明了它们如何在。NET Core 元数据格式(本章后面有更多关于元数据的信息;详见第十七章。

明白一个给定的。NET 核心语言可能不支持 CTS 定义的所有功能。公共语言规范,或 CLS ,是一个相关的规范,它定义了所有公共类型和编程结构的子集。NET 核心编程语言可以达成一致。因此如果你建造。NET 核心类型只公开 CLS 兼容的功能,您可以放心,所有。NET 核心语言可以消费它们。相反,如果您使用 CLS 范围之外的数据类型或编程构造,则不能保证每个。NET 核心编程语言可以与您的。NET 核心代码库。令人欣慰的是,正如你将在本章后面看到的,告诉你的 C# 编译器检查你所有的代码是否符合 CLS 是很简单的。

基类库的作用

的。NET Core platform 还提供了一组所有人都可以使用的基本类库(bcl)。NET 核心编程语言。这个基本类库不仅封装了各种原语,如线程、文件输入/输出(I/O)、图形渲染系统以及与各种外部硬件设备的交互,而且还为大多数现实应用所需的许多服务提供了支持。

基本类库定义了可用于构建任何类型的软件应用的类型,以及用于该应用的组件彼此交互的类型。

的作用.NET 标准

中基类库的数量。NET 框架远远超过了那些在。NET 核心,即使发布了。NET 5.0。这是可以理解的。NET Framework 领先了 14 年。NET 核心。这种差异在尝试使用。NET 框架代码。NET 核心代码。的解决方案(和要求)。NET 框架/。NET 核心互操作是。净标准。

。NET 标准是一种规范,它定义了。NET APIs 和基类库,它们在每个实现中都必须可用。该标准支持以下场景:

  • 为所有定义一组统一的 BCL APIs。NET 实现来实现,独立于工作负载

  • 使开发人员能够生成可跨平台使用的可移植库。NET 实现,使用同一套 API

  • 减少甚至消除共享源代码的条件编译。NET APIs,仅适用于 OS APIs

位于微软文档中的图表( https://docs.microsoft.com/en-us/dotnet/standard/net-standard )显示了各种之间的兼容性。NET 框架和。NET 核心。这对于以前版本的 C# 非常有用。然而,C# 9 只能在。NET 5.0(或以上)或。NET 标准 2.1,以及。NET Standard 2.1 不可用于。NET 框架。

C# 带来了什么

C# 是一种编程语言,其核心语法看起来与 Java 的语法非常相似。但是,把 C# 称为 Java 克隆是不准确的。实际上,C# 和 Java 都是 C 编程语言家族的成员(例如,C、Objective-C、C++ ),因此共享相似的语法。

事实是,C# 的许多语法结构都是模仿 Visual Basic (VB)和 C++的各个方面的。例如,像 VB 一样,C# 支持类属性(相对于传统的 getter 和 setter 方法)和可选参数的概念。像 C++一样,C# 允许重载操作符,以及创建结构、枚举和回调函数(通过委托)。

此外,当您阅读本文时,您将很快发现 C# 支持许多功能,如 lambda 表达式和匿名类型,这些功能通常在各种函数式语言(如 LISP 或 Haskell)中都能找到。此外,随着语言集成查询 (LINQ)的出现,C# 支持许多结构,这使得它在编程领域非常独特。然而,大部分 C# 确实受到了基于 C 语言的影响。

因为 C# 是多种语言的混合体,所以它的语法和 Java 一样简洁(如果不是比 Java 更简洁的话),和 VB 一样简单,并且提供了和 C++一样的功能和灵活性。以下是在所有版本的语言中都能找到的 C# 核心特性的部分列表:

  • 不需要指针!C# 程序通常不需要直接的指针操作(尽管如果绝对必要的话,你可以自由地下降到那个级别,如第十一章所示)。

  • 通过垃圾收集实现自动内存管理。鉴于此,C# 不支持delete关键字。

  • 类、接口、结构、枚举和委托的正式语法构造。

  • 类似 C++的为自定义类型重载运算符的能力,没有复杂性。

  • 支持基于属性的编程。这种类型的开发允许您注释类型及其成员,以进一步限定它们的行为。例如,如果您用[Obsolete]属性标记一个方法,程序员将会看到您定制的警告消息,如果他们试图使用被修饰的成员的话。

C# 9 已经是一种强大的语言,结合。NET Core 支持构建各种应用类型。

以前版本中的主要功能

随着的发布。NET 2.0(大约在 2005 年),C# 编程语言被更新以支持许多新的功能,最值得注意的是以下功能:

  • 生成泛型类型和泛型成员的能力。使用泛型,您能够构建高效且类型安全的代码,这些代码定义了在您与泛型项目交互时指定的大量占位符

  • 支持匿名方法,这允许您在任何需要委托类型的地方提供内联函数。

  • 使用partial关键字跨多个代码文件定义单一类型(或者,如果需要,作为内存中的表示)的能力。

。NET 3.5(大约在 2008 年发布)为 C# 编程语言增加了更多的功能,包括以下特性:

  • 支持用于与各种形式的数据交互的强类型查询(如 LINQ)。你将在第十三章第一次遇到 LINQ。

  • 支持匿名类型,允许你在代码中动态地对一个类型的结构建模(而不是它的行为)。

  • 使用扩展方法扩展现有类型的功能(无需子类化)的能力。

  • 包含 lambda 运算符(=>),这进一步简化了。NET 委托类型。

  • 一种新的对象初始化语法,允许您在创建对象时设置属性值。

。NET 4.0(2010 年发布)再次更新了 C# 的一些特性,如下所示:

  • 支持可选的方法参数,以及命名的方法参数。

  • 支持通过关键字dynamic在运行时动态查找成员。正如你将在第十九章中看到的,这提供了一个统一的方法来动态调用成员,不管成员实现了哪个框架。

  • 使用泛型类型要直观得多,因为您可以通过协方差和逆变轻松地将泛型数据映射到一般的System.Object集合或从一般的System.Object集合中映射出来。

随着的发布。NET 4.5,C# 收到了一对新的关键字(asyncawait),大大简化了多线程和异步编程。如果您使用过以前版本的 C#,您可能还记得通过辅助线程调用方法需要大量的神秘代码和各种。NET 命名空间。假定 C# 现在支持为您处理这种复杂性的语言关键字,异步调用方法的过程几乎与以同步方式调用方法一样简单。第十五章将详细讨论这些话题。

C# 6 是随。NET 4.6,并引入了许多有助于简化代码库的小特性。下面是 C# 6 中一些新特性的简要介绍:

  • 自动属性的内联初始化以及对只读自动属性的支持

  • 使用 C# lambda 运算符的单行方法实现

  • 支持静态导入,以提供对名称空间内静态成员的直接访问

  • 空条件运算符,有助于检查方法实现中的空参数

  • 称为字符串插值的新字符串格式化语法

  • 使用新的when关键字过滤异常的能力

  • 使用catchfinally块中的await

  • nameOf表达式返回一个表示符号的字符串

  • 索引初始值设定项

  • 改进了霸王分辨率

C# 7,与一起发布。NET 4.7 在 2017 年 3 月推出了简化您的代码库的附加功能,它添加了一些更重要的功能(如元组和ref局部变量和返回),开发人员很长一段时间以来一直要求在语言规范中包含这些功能。下面是 C# 7 中新特性的简要概述:

  • out变量声明为内联参数

  • 本地功能

  • 附加表达式主体成员

  • 通用异步返回类型

  • 改进数字常量可读性的新标记

  • 包含多个字段的轻量级未命名类型(称为元组)

  • 除了值检查(模式匹配)之外,还使用类型匹配更新逻辑流

  • 返回对一个值的引用,而不仅仅是值本身(ref locals and returns)

  • 轻量级一次性变量的引入(称为丢弃)

  • 抛出表达式,允许抛出在更多的地方执行,比如条件表达式、lambdas 等等

C# 7 有两个小版本,增加了以下特性:

  • 拥有一个程序的主方法的能力是async

  • 一个新的文字,default,允许任何类型的初始化。

  • 修正了模式匹配的一个问题,该问题阻止了在新的模式匹配特性中使用泛型。

  • 像匿名方法一样,元组名称可以从创建它们的投影中推断出来。

  • 编写安全、高效代码的技术,语法改进的组合,支持使用引用语义处理值类型。

  • 命名参数后面可以跟位置参数。

  • 数字文字现在可以在任何打印的数字前有前导下划线。

  • private protected访问修饰符允许访问同一程序集中的派生类。

  • 条件表达式(?:)的结果现在可以作为引用。

这也是我在部分标题中添加“(新的 7.x)”和“(更新的 7.x)”的版本,以便更容易找到语言与前一版本的变化。“x”表示 C# 7 的次要版本,如 7.1。

C# 8,与一起发布。NET Core 3.0 于 2019 年 9 月 23 日推出了用于简化您的代码库的附加功能,它添加了一些更重要的功能(如元组和ref局部变量和返回),这些功能是开发人员很长一段时间以来一直要求包含在语言规范中的。

C# 8 有两个小版本,增加了以下特性:

  • 结构的只读成员

  • 默认接口成员

  • 模式匹配增强

  • 使用声明

  • 静态局部函数

  • 一次性参考支柱

  • 可为空的引用类型

  • 异步流

  • 指数和范围

  • 零合并赋值

  • 非托管构造类型

  • stackalloc在嵌套表达式中

  • 插值逐字字符串的增强

C# 8 中的新特性在它们的章节标题中用“(新 8)”表示,更新的特性用“(更新 8)”表示

C# 9 中的新特性

C# 8,2020 年 11 月 10 日发布,带。NET 5,增加了以下功能:

  • 记录

  • 仅初始化设置器

  • 顶级语句

  • 模式匹配增强

  • 互操作的性能改进

  • “适合和完成”功能

  • 支持代码生成器

C# 9 中的新特性在它们的章节标题中被标记为“(新 9)”,更新的特性被标记为“(更新 9)”

托管代码与非托管代码

值得注意的是,C# 语言只能用于构建托管在。NET 核心运行时(您永远不能使用 C# 来构建本机 COM 服务器或非托管 C/C++风格的应用)。正式来说,这个术语用于描述针对。NET 核心运行时是托管代码。包含托管代码的二进制单元被称为程序集(稍后会有更多关于程序集的细节)。相反,不能由。NET 核心运行时被称为非托管代码

如前所述。NET 核心平台可以运行在多种操作系统上。因此,很有可能在 Windows 机器上构建 C# 应用,并使用。NET 核心运行时。同样,您可以使用 Visual Studio 代码在 Linux 上构建 C# 应用,并在 Windows 上运行该程序。使用 Visual Studio for Mac,您还可以构建。NET 核心应用运行在 Windows、macOS 或 Linux 上。

仍然可以从 C# 程序访问非托管代码,但是它会将您锁定在特定的开发和部署目标上。

使用附加。支持. NET 核心的编程语言

要明白 C# 并不是唯一可以用来构建的语言。NET 核心应用。。NET 核心应用一般可以用 C#、Visual Basic、F# 这三种微软直接支持的语言来构建。

概述.NET 程序集

不管哪个。NET 核心语言,尽管。NET 核心二进制文件与非托管 Windows 二进制文件(*.dll)采用相同的文件扩展名,它们绝对没有内部相似性。具体来说,。NET Core 二进制文件不包含特定于平台的指令,而是包含与平台无关的中间语言 ( IL )和类型元数据。

Note

IL 也称为微软中间语言(MSIL)或通用中间语言(CIL)。因此,当您阅读。NET/。NET 核心文献,理解 IL,MSIL 和 CIL 都在描述本质上相同的概念。在本书中,我将使用缩写 CIL 来指代这个低级指令集。

当使用. NET 核心编译器创建了一个*.dll时,二进制 blob 被称为一个程序集。你将会检查大量的细节。第十六章中的网络核心组件。但是,为了便于当前的讨论,您需要理解这种新文件格式的四个基本属性。

第一,不像。可以是*.dll*.exe的. NET Framework 程序集。NET 核心项目总是被编译成扩展名为 ?? 的文件,即使这个项目是可执行的。可执行。用命令dotnet <assembly name>.dll执行 NET Core 汇编。新进。NET Core 3.0(及更高版本)中,dotnet.exe命令被复制到构建目录中,并重命名为<assembly name>.exe。运行这个命令会自动调用dotnet <assembly name>.dll文件,执行等同于dotnet <assembly name>.dll的功能。带有您项目名称的*.exe实际上不是您项目的代码;这是运行应用的便捷方式。

新进。NET 5,您的应用可以简化为一个直接执行的文件。尽管这个文件看起来和行为都像一个 C++风格的本地可执行文件,但是这个文件便于打包。它包含运行应用所需的所有文件,甚至可能包含。NET 5 运行时本身!但是要知道,您的代码仍然在托管容器中运行,就好像它是作为多个文件发布的一样。

其次,汇编包含 CIL 代码,这在概念上类似于 Java 字节码,因为除非绝对必要,否则它不会被编译成特定于平台的指令。通常,“绝对必要”是指 CIL 指令块(如方法实现)被。NET 核心运行时。

第三,程序集还包含元数据,它生动详细地描述了二进制文件中每个“类型”的特征。例如,如果你有一个名为SportsCar的类,类型元数据描述了细节,比如SportsCar的基类,指定了哪些接口是由SportsCar实现的(如果有的话),并且给出了SportsCar类型支持的每个成员的完整描述。。NET Core 元数据始终存在于程序集中,并由语言编译器自动生成。

最后,除了 CIL 和类型元数据之外,程序集本身也使用元数据来描述,这被正式称为清单。清单包含有关程序集的当前版本的信息、区域性信息(用于本地化字符串和图像资源)以及正确执行所需的所有外部引用程序集的列表。在接下来的几章中,您将研究各种可用于检查程序集的类型、元数据和清单信息的工具。

公共中间语言的作用

让我们更详细地研究一下 CIL 代码、类型元数据和程序集清单。CIL 是一种位于任何特定平台指令集之上的语言。例如,下面的 C# 代码模拟了一个简单的计算器。现在不要关心确切的语法,但是请注意Calc类中Add()方法的格式。

//Calc.cs
using System;

namespace CalculatorExamples
{
  //This class contains the app's entry point.
  class Program
  {
    static void Main(string[] args)
    {
      Calc c = new Calc();
      int ans = c.Add(10, 84);
      Console.WriteLine("10 + 84 is {0}.", ans);
      //Wait for user to press the Enter key
      Console.ReadLine();
    }
  }
  // The C# calculator.
  class Calc
  {
    public int Add(int addend1, int addend2)
    {
      return addend1 + addend2;
    }
  }
}

编译这段代码会生成一个文件*.dll集合,其中包含清单、CIL 指令和描述CalcProgram类各个方面的元数据。

Note

第二章探讨了如何使用图形化集成开发环境(ide)来编译你的代码文件,比如 Visual Studio 社区。

例如,如果您要使用ildasm.exe从这个程序集输出 IL(本章稍后会详细介绍),您会发现Add()方法是使用 CIL 表示的,如下所示:

.method public hidebysig instance int32
        Add(int32 addend1,
            int32 addend2) cil managed
{
  // Code size       9 (0x9)
  .maxstack  2
  .locals init (int32 V_0)
  IL_0000:  nop
  IL_0001:  ldarg.1
  IL_0002:  ldarg.2
  IL_0003:  add
  IL_0004:  stloc.0
  IL_0005:  br.s       IL_0007
  IL_0007:  ldloc.0
  IL_0008:  ret
} // end of method Calc::Add

如果你不能理解这个方法产生的 CIL,不要担心,因为第十九章将描述 CIL 编程语言的基础。需要注意的是,C# 编译器发出的是 CIL,而不是特定于平台的指令。

现在,回想一下这是真的。NET 核心编译器。举例来说,假设您使用 Visual Basic 而不是 C# 创建了相同的应用。

' Calc.vb
Namespace CalculatorExample
  Module Program
    ' This class contains the app's entry point.
    Sub Main(args As String())
      Dim c As New Calc
      Dim ans As Integer = c.Add(10, 84)
      Console.WriteLine("10 + 84 is {0}", ans)
      'Wait for user to press the Enter key before shutting down
      Console.ReadLine()
    End Sub
  End Module
  ' The VB.NET calculator.
  Class Calc
    Public Function Add(ByVal addend1 As Integer, ByVal addend2 As Integer) As Integer
      Return addend1 + addend2
    End Function
  End Class
End Namespace

如果您检查Add()方法的 CIL,您会发现类似的指令(被 Visual Basic 编译器稍微调整了一下)。

  .method public instance int32  Add(int32 addend1,
                                     int32 addend2) cil managed
  {
    // Code size       9 (0x9)
    .maxstack  2
    .locals init (int32 V_0)
    IL_0000:  nop
    IL_0001:  ldarg.1
    IL_0002:  ldarg.2
    IL_0003:  add.ovf
    IL_0004:  stloc.0
    IL_0005:  br.s       IL_0007

    IL_0007:  ldloc.0
    IL_0008:  ret
  } // end of method Calc::Add

作为最后一个例子,用 F# 开发的同样简单的 Calc 程序(另一个。NET 核心语言)如下所示:

// Learn more about F# at http://fsharp.org

// Calc.fs
open System

module Calc =
    let add addend1 addend2 =
        addend1 + addend2

[<EntryPoint>]
let main argv =
    let ans = Calc.add 10 84
    printfn "10 + 84 is %d" ans
    Console.ReadLine()
    0

如果您检查Add()方法的 CIL,您会再次发现类似的指令(被 F# 编译器稍微调整了一下)。

.method public static int32  Add(int32 addend1,
                                   int32 addend2) cil managed
{
  .custom instance void [FSharp.Core]Microsoft.FSharp.Core.CompilationArgumentCountsAttribute::.ctor(int32[]) = ( 01 00 02 00 00 00 01 00 00 00 01 00 00 00 00 00 )
  // Code size       4 (0x4)
  .maxstack  8
  IL_0000:  ldarg.0
  IL_0001:  ldarg.1
  IL_0002:  add
  IL_0003:  ret
} // end of method Calc::'add'

CIL 的好处

此时,您可能想知道将源代码编译成 CIL,而不是直接编译成特定的指令集,到底能得到什么。一个好处是语言整合。正如您已经看到的,每个。NET 核心编译器产生几乎相同的 CIL 指令。因此,所有语言都能够在一个定义明确的二进制领域内进行交互。

此外,鉴于 CIL 是平台不可知的。NET Core Framework 本身是与平台无关的,提供了 Java 开发人员已经习惯的相同优势(例如,在众多操作系统上运行的单一代码库)。事实上,C# 语言有一个国际标准。之前。NET 核心,有许多实现。NET 用于非 Windows 平台,比如 Mono。这些仍然存在,尽管随着的跨平台能力,对它们的需求大大减少了。NET 核心。

将 CIL 编译成特定于平台的指令

因为程序集包含 CIL 指令而不是特定于平台的指令,所以 CIL 代码必须在使用前被动态编译。将 CIL 代码编译成有意义的 CPU 指令的实体是 JIT 编译器,它有时被冠以友好的名字 jitter 。那个。NET Core 运行时环境为每个面向运行时的 CPU 利用了 JIT 编译器,每个都针对底层平台进行了优化。

例如,如果您正在构建一个要部署到手持设备(比如 iOS 或 Android 手机)上的. NET 核心应用,那么相应的抖动可以在低内存环境中运行。另一方面,如果您将程序集部署到后端公司服务器(在那里内存很少成为问题),抖动将被优化以在高内存环境中运行。通过这种方式,开发人员可以编写一个单独的代码体,该代码体可以在具有不同架构的机器上高效地进行 JIT 编译和执行。

此外,当给定的抖动将 CIL 指令编译成相应的机器代码时,它会以适合目标操作系统的方式将结果缓存在存储器中。这样,如果调用名为PrintDocument()的方法,CIL 指令在第一次调用时被编译成特定于平台的指令,并保留在内存中以备后用。因此,下次调用PrintDocument()时,不需要重新编译 CIL。

将 CIL 预编译为特定于平台的指令

中有一个实用程序。NET Core 叫做crossgen.exe,可以用来预 JIT 你的代码。好在,在。NET Core 3.0 中,框架内置了生成“现成”程序集的能力。本书后面会有更多相关内容。

的作用.NETCore 类型元数据

除了 CIL 指令之外,. NET 核心汇编件还包含完整、完整且准确的元数据,该元数据描述了二进制文件中定义的每种类型(例如,类、结构、枚举)以及每种类型的成员(例如,属性、方法、事件)。令人欣慰的是,发出最新和最好的类型元数据总是编译器(而不是程序员)的工作。因为。NET 核心元数据非常细致,程序集完全是自描述的实体。

说明…的格式。NET Core 类型元数据,我们来看看已经为您之前检查过的 C# Calc类的Add()方法生成的元数据(为 Visual Basic 版本的Add()方法生成的元数据类似,所以我们将只检查 C# 版本)。

TypeDef #2 (02000003)
-------------------------------------------------------
  TypDefName: CalculatorExamples.Calc  (02000003)
  Flags     : [NotPublic] [AutoLayout] [Class] [AnsiClass] [BeforeFieldInit]  (00100000)
  Extends   : 0100000C [TypeRef] System.Object
  Method #1 (06000003)
  -------------------------------------------------------
    MethodName: Add (06000003)
    Flags     : [Public] [HideBySig] [ReuseSlot]  (00000086)
    RVA       : 0x00002090
    ImplFlags : [IL] [Managed]  (00000000)
    CallCnvntn: [DEFAULT]
    hasThis
    ReturnType: I4
    2 Arguments
      Argument #1:  I4
      Argument #2:  I4
    2 Parameters
      (1) ParamToken : (08000002) Name : addend1 flags: [none] (00000000)
      (2) ParamToken : (08000003) Name : addend2 flags: [none] (00000000)

元数据用于的许多方面。NET 核心运行时环境,以及各种开发工具。例如,Visual Studio 等工具提供的智能感知功能是通过在设计时读取程序集的元数据来实现的。各种对象浏览实用程序、调试工具和 C# 编译器本身也使用元数据。可以肯定的是,元数据是众多。NET 核心技术,包括反射、延迟绑定和对象序列化。第十七章将正式确定。NET 核心元数据。

程序集清单的角色

最后但同样重要的是,记住. NET 核心程序集还包含描述程序集本身的元数据(技术上称为清单)。在其他详细信息中,清单记录了当前程序集正常工作所需的所有外部程序集、程序集的版本号、版权信息等等。与类型元数据一样,生成程序集清单始终是编译器的工作。以下是编译本章前面显示的Calc.cs代码文件时生成的清单的一些相关细节(为简洁起见,省略了一些行):

.assembly extern /*23000001*/ System.Runtime
{
  .publickeytoken = (B0 3F 5F 7F 11 D5 0A 3A ) // .?_....:
  .ver 5:0:0:0
}
.assembly extern /*23000002*/ System.Console
{
  .publickeytoken = (B0 3F 5F 7F 11 D5 0A 3A ) // .?_....:
  .ver 5:0:0:0
}
.assembly /*20000001*/ Calc.Cs
{
  .hash algorithm 0x00008004
  .ver 1:0:0:0
}
.module Calc.Cs.dll
.imagebase 0x00400000
.file alignment 0x00000200
.stackreserve 0x00100000
.subsystem 0x0003       // WINDOWS_CUI
.corflags 0x00000001    //  ILONLY

简而言之,清单记录了Calc.dll(通过.assembly extern指令)所需的外部程序集集合,以及程序集本身的各种特征(例如,版本号、模块名)。第十六章将会更详细的讨论清单数据的用处。

了解通用类型系统

给定的程序集可以包含任意数量的不同类型。在的世界里。NET Core, type 只是一个通用术语,用来指代集合{类、接口、结构、枚举、委托}中的成员。当您使用. NET 核心语言构建解决方案时,您很可能会与这些类型中的许多类型进行交互。例如,您的程序集可能定义一个实现一些接口的类。也许其中一个接口方法将枚举类型作为输入参数,并向调用者返回一个结构。

回想一下,CTS 是一个正式的规范,它记录了为了由。NET 运行时。通常,对 CTS 的内部工作方式非常关心的人只有那些针对。NET 核心平台。然而,这对所有人都很重要。NET 程序员学习如何使用 CTS 以他们选择的语言定义的五种类型。以下是简要概述。

CTS 类别类型

每一个。NET 核心语言至少支持类类型的概念,这是面向对象编程(OOP)的基石。一个类可以由任意数量的成员(如构造函数、属性、方法和事件)和数据点(字段)组成。在 C# 中,类是使用class关键字声明的,就像这样:

// A C# class type with 1 method.
class Calc
{
  public int Add(int addend1, int addend2)
  {
    return addend1 + addend2;
  }
}

第五章将开始你用 C# 构建类类型的正式考试;然而,表 1-1 记录了许多与类类型相关的特征。

表 1-1。

CTS 类别特征

|

阶级特征

|

生命的意义

| | --- | --- | | 班级被封了吗? | 密封类不能作为其他类的基类。 | | 这个类实现任何接口了吗? | 接口是抽象成员的集合,它提供了对象和对象用户之间的契约。CTS 允许一个类实现任意数量的接口。 | | 这个类是抽象的还是具体的? | 抽象类不能被直接实例化,而是用来定义派生类型的公共行为。具体的类可以直接实例化。 | | 这个班的知名度如何? | 每个类都必须配置一个可见性关键字,如publicinternal。基本上,这控制了该类是可以由外部程序集使用,还是只能从定义程序集内部使用。 |

CTS 接口类型

接口只不过是抽象成员定义和/或(C# 8 中新增的)默认实现的命名集合,它们由给定的类或结构实现(在默认实现的情况下是可选的)。在 C# 中,接口类型是使用interface关键字定义的。按照惯例,都是。NET 接口以大写字母 I 开头,如下例所示:

// A C# interface type is usually
// declared as public, to allow types in other
// assemblies to implement their behavior.
public interface IDraw
{
  void Draw();
}

接口本身用处不大。但是,当一个类或结构以其独特的方式实现一个给定的接口时,您能够以多态的方式使用接口引用请求访问所提供的功能。基于接口的编程将在第章和第章中全面探讨。

CTS 结构类型

结构的概念也在 CTS 下形式化。如果你有 C 背景,你应该很高兴知道这些用户定义类型(udt)在。NET Core(虽然它们在引擎盖下的表现有点不同)。简单地说,一个结构可以被认为是一个轻量级的类类型,具有基于值的语义。关于结构细节的更多信息,请参见第四章。通常,结构最适合于对几何和数学数据进行建模,并使用关键字struct在 C# 中创建,如下所示:

// A C# structure type.
struct Point
{
  // Structures can contain fields.
  public int xPos, yPos;

  // Structures can contain parameterized constructors.
  public Point(int x, int y)
  { xPos = x; yPos = y;}

  // Structures may define methods.
  public void PrintPosition()
  {
    Console.WriteLine("({0}, {1})", xPos, yPos);
  }
}

CTS 枚举类型

枚举是一种方便的编程构造,允许您对名称-值对进行分组。例如,假设您正在创建一个视频游戏应用,它允许玩家从三个角色类别(巫师、战士或小偷)中进行选择。您可以使用enum关键字构建一个强类型枚举,而不是跟踪简单的数值来表示每种可能性。

// A C# enumeration type.
enum CharacterTypeEnum
{
  Wizard = 100,
  Fighter = 200,
  Thief = 300
}

默认情况下,用于保存每个项目的存储是一个 32 位整数;然而,如果需要的话,可以改变这个存储槽(例如,当为诸如移动设备的低存储设备编程时)。此外,CTS 要求枚举类型从一个公共基类System.Enum派生。正如你将在第四章中看到的,这个基类定义了许多有趣的成员,允许你以编程的方式提取、操作和转换底层的名称-值对。

CTS 委托类型

代表们是。等效于类型安全的 C 风格函数指针。关键的区别在于. NET 核心委托是一个从System.MulticastDelegate派生的,而不是一个简单的指向原始内存地址的指针。在 C# 中,委托是使用delegate关键字声明的。

// This C# delegate type can "point to" any method
// returning an int and taking two ints as input.
delegate int BinaryOp(int x, int y);

当您希望为一个对象向另一个对象转发调用提供一种方法,并为。NET 核心事件架构。正如您将在第 12 和 14 章中看到的,委托对多播(即,将一个请求转发给多个接收者)和异步方法调用(即,在辅助线程上调用方法)有内在的支持。

CTS 类型成员

现在您已经预览了 CTS 形式化的每一种类型,意识到大多数类型接受任意数量的成员。从形式上讲,类型成员受集合{构造函数,终结器,静态构造函数,嵌套类型,运算符,方法,属性,索引器,字段,只读字段,常量,事件}约束。

CTS 定义了可能与给定成员相关联的各种装饰。例如,每个成员具有给定的可见性特征(例如,公共的、私有的、受保护的)。有些成员可以声明为抽象的(在派生类型上强制执行多态行为)和虚拟的(定义固定的但可重写的实现)。此外,大多数成员可以配置为静态(在类级别绑定)或实例(在对象级别绑定)。类型成员的创建将在接下来的几章中讨论。

Note

如第十章所述,C# 语言也支持泛型类型和泛型成员的创建。

内在 CTS 数据类型

CTS 目前需要注意的最后一个方面是,它建立了一组定义明确的基本数据类型。尽管给定语言通常有一个唯一的关键字用于声明基本数据类型,但所有。NET 语言关键字最终解析为名为mscorlib.dll的程序集中定义的相同 CTS 类型。考虑表 1-2 ,它记录了关键 CTS 数据类型如何在 VB.NET 和 C# 中表示。

表 1-2。

固有 CTS 数据类型

|

CTS 数据类型

|

VB 关键字

|

C# 关键字

| | --- | --- | --- | | System.Byte | Byte | byte | | System.SByte | SByte | sbyte | | System.Int16 | Short | short | | System.Int32 | Integer | int | | System.Int64 | Long | long | | System.UInt16 | UShort | ushort | | System.UInt32 | UInteger | uint | | System.UInt64 | ULong | ulong | | System.Single | Single | float | | System.Double | Double | double | | System.Object | Object | object | | System.Char | Char | char | | System.String | String | string | | System.Decimal | Decimal | decimal | | System.Boolean | Boolean | bool |

假设托管语言的唯一关键字只是在System名称空间中对真实类型的速记符号,您就不必再担心数值数据的上溢/下溢情况,或者字符串和布尔值在不同语言中是如何内部表示的。请考虑以下代码片段,这些代码片段使用语言关键字和正式的 CTS 数据类型在 C# 和 Visual Basic 中定义了 32 位数值变量:

// Define some "ints" in C#.
int i = 0;
System.Int32 j = 0;

' Define some  "ints" in VB.
Dim i As Integer = 0
Dim j As System.Int32 = 0

理解公共语言规范

如您所知,不同的语言用独特的、特定于语言的术语来表达相同的编程结构。例如,在 C# 中,您使用加号运算符(+)来表示字符串连接,而在 VB 中,您通常使用&符号(&)。即使两种不同的语言表达了相同的编程习惯用法(例如,一个没有返回值的函数),语法表面上看起来也很有可能完全不同。

// C# method returning nothing.
public void MyMethod()
{
  // Some interesting code...
}

' VB method returning nothing.
Public Sub MyMethod()
  ' Some interesting code...
End Sub

正如您已经看到的,这些微小的语法变化在。NET 核心运行时,假设各自的编译器(csc.exevbc.exe,在这种情况下)发出一组类似的 CIL 指令。然而,各种语言的总体功能水平也可能有所不同。例如,. NET 核心语言可能有也可能没有表示无符号数据的关键字,可能支持也可能不支持指针类型。考虑到这些可能的变化,最好有一个基线。NET 核心语言应该是一致的。

CLS 是一组规则,生动详细地描述了给定的最小和完整的特征集。NET Core 编译器必须支持才能生成可由。NET 运行库,同时所有面向。NET 核心平台。在许多方面,CLS 可以被视为 CTS 定义的全部功能的子集。

CLS 最终是一组规则,如果编译器构建者希望他们的产品在。净核心宇宙。每个规则都被赋予一个简单的名称(例如,CLS 规则 6 ),并描述该规则如何影响构建编译器的人以及(以某种方式)与编译器交互的人。CLS 的精英就是规则 1。

  • 规则 1 : CLS 规则只适用于那些暴露在定义程序集之外的类型部分。

根据这条规则,您可以(正确地)推断 CLS 的其余规则不适用于用于构建. NET 核心类型内部工作的逻辑。类型必须符合 CLS 的唯一方面是成员定义本身(即命名约定、参数和返回类型)。成员的实现逻辑可以使用任意数量的非 CLS 技术,因为外界不会知道其中的区别。

举例来说,下面的 C# Add()方法不符合 CLS,因为参数和返回值使用了无符号数据(这不是 CLS 的要求):

class Calc
{
  // Exposed unsigned data is not CLS compliant!
  public ulong Add(ulong addend1, ulong addend2)
  {
    return addend1 + addend2;
  }
}

但是,请考虑以下在方法内部使用无符号数据的代码:

class Calc
{
  public int Add(int addend1, int addend2)
  {
    // As this ulong variable is only used internally,
    // we are still CLS compliant.
    ulong temp = 0;
    ...
    return addend1 + addend2;
  }
}

该类仍然符合 CLS 的规则,可以放心,所有。NET 核心语言能够调用Add()方法。

当然,除了规则 1,CLS 还规定了许多其他规则。例如,CLS 描述了给定语言必须如何表示文本字符串,枚举应该如何在内部表示(用于存储的基类型),如何定义静态成员,等等。幸运的是,要成为一名专家,你不需要记住这些规则。NET 开发者。同样,总的来说,对 CTS 和 CLS 规范的深入理解通常只对工具/编译器开发者有意义。

确保 CLS 合规

正如你将在本书中看到的,C# 确实定义了许多不符合 CLS 标准的编程结构。然而,好消息是,您可以指示 C# 编译器使用单个。NET 属性。

// Tell the C# compiler to check for CLS compliance.
[assembly: CLSCompliant(true)]

第十七章深入基于属性编程的细节。在此之前,只需理解[CLSCompliant]属性将指示 C# 编译器根据 CLS 的规则检查每一行代码。如果发现任何 CLS 违规,您会收到编译器警告和违规代码的描述。

了解。NET 核心运行时

除了 CTS 和 CLS 规范之外,需要解决的最后一个难题是。NET 核心运行时,或简称为。NET 运行时。从编程的角度来说,术语 runtime 可以理解为执行给定的编译代码单元所需的服务集合。例如,当 Java 开发人员将软件部署到一台新计算机上时,他们需要确保该计算机上已经安装了 Java 虚拟机(JVM ),以便运行他们的软件。

那个。NET 核心平台提供了另一个运行时系统。之间的主要区别。NET 核心运行时和我刚才提到的各种其他运行时。NET Core runtime 提供了一个单一的、定义良好的运行时层,由所有的语言和平台共享。NET 核心。

区分程序集、命名空间和类型

我们每个人都明白代码库的重要性。框架库的目的是为开发人员提供一组定义良好的现有代码,以便在他们的应用中加以利用。然而,C# 语言并没有特定于语言的代码库。相反,C# 开发人员利用了语言中立性。NET 核心库。为了保持基类库中的所有类型组织有序。NET Core 平台广泛使用了名称空间的概念。

命名空间是一组语义相关的类型,包含在一个程序集中,或者可能分布在多个相关的程序集中。例如,System.IO名称空间包含与文件 I/O 相关的类型,System.Data名称空间定义基本的数据库类型,等等。需要指出的是,单个程序集可以包含任意数量的命名空间,每个命名空间可以包含任意数量的类型。

这种方法和特定于语言的库之间的主要区别在于,任何面向。NET 核心运行时使用相同的名称空间和相同的类型。例如,以下两个程序都说明了无处不在的 Hello World 应用,它们是用 C# 和 VB 编写的:

// Hello World in C#.
using System;

public class MyApp
{
  static void Main()
  {
    Console.WriteLine("Hi from C#");
  }
}

' Hello World in VB.
Imports System
Public Module MyApp
  Sub Main()
    Console.WriteLine("Hi from VB")
  End Sub
End Module

注意,每种语言都使用在System名称空间中定义的Console类。除了一些明显的语法差异之外,这些应用在物理上和逻辑上都非常相似。

很明显,一旦你对你的。NET 核心编程语言,作为. NET 核心开发人员,您的下一个目标是了解(众多)中定义的丰富类型。NET 核心命名空间。最基本的名称空间最初被命名为System。这个命名空间提供了一个核心类型体,作为. NET 核心开发人员,您需要反复利用它。事实上,如果不引用System名称空间,就无法构建任何功能性的 C# 应用,因为核心数据类型(例如System.Int32System.String)就是在这里定义的。表 1-3 提供了部分(但肯定不是全部)的概要。NET 核心命名空间按相关功能分组。

表 1-3。

的样本。NET 命名空间

|

。NET 命名空间

|

生命的意义

| | --- | --- | | System | 在System中,您可以找到许多有用的类型来处理内部数据、数学计算、随机数生成、环境变量和垃圾收集,以及许多常用的异常和属性。 | | System.Collections System.Collections.Generic | 这些名称空间定义了许多库存容器类型,以及允许您构建定制集合的基本类型和接口。 | | System.Data System.Data.Common System.Data.SqlClient | 这些名称空间用于通过 ADO.NET 与关系数据库进行交互。 | | System.IO System.IO.Compression System.IO.Ports | 这些名称空间定义了许多用于文件 I/O、数据压缩和端口操作的类型。 | | System.Reflection System.Reflection.Emit | 这些命名空间定义了支持运行时类型发现以及动态创建类型的类型。 | | System.Runtime.InteropServices | 这个命名空间提供了允许。NET 类型与非托管代码(例如,基于 C 的 dll 和 COM 服务器)进行交互,反之亦然。 | | System.Drawing System.Windows.Forms | 这些命名空间定义了用于使用。NET 的原创 UI 工具包(Windows Forms)。 | | System.Windows System.Windows.Controls System.Windows.Shapes | System.Windows命名空间是 Windows Presentation Foundation 应用中使用的几个命名空间的根。 | | System.Windows.FormsSystem.Drawing | System.Windows.Forms命名空间是 Windows 窗体应用中使用的几个命名空间的根。 | | System.Linq System.Linq.Expressions | 这些命名空间定义了针对 LINQ API 编程时使用的类型。 | | System.AspNetCore | 这是允许您构建 ASP.NET 核心 web 应用和 RESTful 服务的众多名称空间之一。 | | System.Threading System.Threading.Tasks | 这些命名空间定义了许多类型来构建多线程应用,这些应用可以在多个 CPU 之间分配工作负载。 | | System.Security | 安全性是。净宇宙。在以安全为中心的名称空间中,您会发现许多处理权限、加密等的类型。 | | System.Xml | 以 XML 为中心的名称空间包含许多用于与 XML 数据交互的类型。 |

以编程方式访问命名空间

值得重申的是,命名空间只不过是我们人类逻辑理解和组织相关类型的一种方便方式。再次考虑一下System名称空间。从您的角度来看,您可以假设System.Console表示一个名为Console的类,该类包含在名为System的名称空间中。然而,在的眼里。NET 核心运行时,情况并非如此。运行时引擎只看到一个名为System.Console的类。

在 C# 中,using关键字简化了引用特定命名空间中定义的类型的过程。这是它的工作原理。回到本章前面的 Calc 示例程序,在文件的顶部有一个 using 语句。

using System;

该语句是启用这行代码的快捷方式:

Console.WriteLine("10 + 84 is {0}.", ans);

如果没有using语句,代码需要写成这样:

System.Console.WriteLine("10 + 84 is {0}.", ans);

虽然使用完全限定名定义类型提供了更好的可读性,但我想你会同意 C# using关键字减少了击键次数。在本文中,我们将避免使用完全限定名(除非有明确的歧义需要解决),而选择 C# using关键字的简化方法。

但是,请始终记住,using关键字只是一种用于指定类型的完全限定名的简写符号,这两种方法都会产生相同的基础 CIL(假设 CIL 代码总是使用完全限定名),并且对性能或程序集的大小没有影响。

引用外部程序集

以前版本的。NET Framework 使用了一个通用的框架库安装位置,称为全局程序集缓存 (GAC)。不是只有一个安装位置。NET Core 不使用 GAC。相反,每个版本(包括次要版本)都安装在计算机上自己的位置(按版本)。当使用 Windows 时,每个版本的运行时和 SDK 都被安装到c:\Program Files\dotnet中。

将组件添加到 most 中。NET 核心项目是通过添加 NuGet 包来完成的(在本文后面会涉及到)。然而,。以 Windows 为目标(或在 Windows 上开发)的. NET 核心应用仍然可以访问 COM 库。这也将在本文的后面部分讨论。

为了使一个程序集能够访问您正在生成的(或某人为您生成的)另一个程序集,您需要添加一个从您的程序集到另一个程序集的引用,并且能够物理访问该程序集。这取决于您用来构建您的。NET 核心应用中,您将有各种方法来通知编译器您希望在编译周期中包含哪些程序集。

使用 ildasm.exe 浏览程序集

如果您开始对掌握。NET 核心平台,请记住,名称空间的独特之处在于它包含以某种方式语义相关的类型。因此,如果除了简单的控制台应用之外,您不需要其他用户界面,那么您可以完全忘记桌面和 web 名称空间(以及其他名称空间)。如果您正在构建一个绘画应用,那么数据库名称空间很可能不太重要。随着时间的推移,您将了解与您的编程需求最相关的名称空间。

中间语言反汇编器实用程序(ildasm.exe)允许您创建一个表示. NET 核心程序集的文本文档,并研究其内容,包括相关的清单、CIL 代码和类型元数据。该工具允许您深入研究他们的 C# 代码如何映射到 CIL,并最终帮助您理解。NET 核心平台。而你绝对不需要才能用ildasm.exe成为高手。NET 核心程序员,我强烈建议您不时地使用这个工具,以便更好地理解您的 C# 代码如何映射到运行时概念。

Note

ildasm.exe程序不再随。NET 5 运行时。有两种方法可以将此工具放入您的工作空间。第一种是从。NET 5 运行时源码位于 https://github.com/dotnet/runtime 。第二种,也是更容易的方法,是从 www.nuget.org 中下拉想要的版本。NuGet 上的 ILDasm 在 https://www.nuget.org/packages/Microsoft.NETCore.ILDAsm/ 。确保选择正确的版本(对于本书,您需要 5.0.0 或更高版本)。使用下面的命令将 ILDasm 添加到您的项目中:dotnet add package Microsoft.NETCore.ILDAsm --version 5.0.0

这实际上并没有将ILDasm.exe加载到您的项目中,而是将它放在您的包文件夹中(在 Windows 上):%userprofile%\.nuget\packages\microsoft.netcore.ildasm\5.0.0\runtimes\native\

我还将本书 GitHub repo 中的ILDasm.exe5 . 0 . 0 版本包含在章节 1 文件夹中(以及使用ILDasm.exe的每个章节)。

ildasm.exe加载到您的机器上之后,您可以从命令行不带任何参数地运行程序来查看帮助注释。至少,您必须指定程序集来提取 CIL。

命令行示例如下:

ildasm /all /METADATA /out=csharp.il calc.cs.dll

这将创建一个名为csharp.il的文件,将所有可用数据导出到该文件中。

摘要

这一章的重点是为本书的其余部分奠定必要的概念框架。我首先研究了在之前的技术中发现的一些限制和复杂性。NET 核心,并概述了如何。NET Core 和 C# 试图简化当前的事态。

。NET Core 基本上归结为一个运行时执行引擎(??)和基本类库。运行库能够承载任何。遵守托管代码规则的. NET 核心二进制文件(也称为程序集)。正如您所看到的,程序集包含 CIL 指令(除了类型元数据和程序集清单之外),这些指令使用实时编译器编译成特定于平台的指令。此外,您还探索了公共语言规范和公共类型系统的作用。

在下一章中,你将浏览一下在构建 C# 编程项目时可以使用的通用集成开发环境。您会很高兴地知道,在本书中,您将使用完全免费(且功能丰富)的 ide,因此您可以开始探索。净核心宇宙没有钱下来。

Footnotes 1

https://en.wikipedia.org/wiki/Long-term_support

 

二、构建 C# 应用

作为一名 C# 程序员,你可以从众多的工具中进行选择。NET 核心应用。您选择的工具将主要基于三个因素:任何相关成本、您用于开发软件的操作系统以及您的目标计算平台。本章的目的是提供安装所需的信息。NET 5 SDK 和运行时,并介绍了微软的旗舰 ide,Visual Studio 代码和 Visual Studio 的初步看法。

本章的第一部分将介绍如何使用设置您的计算机。NET 5 SDK 和运行时。下一节将研究用 Visual Studio 代码和 Visual Studio Community Edition 构建您的第一个 C# 应用。

Note

本章和后续章节中的截图来自 Windows 上的 Visual Studio 代码 v 1.51.1 或 Visual Studio 2019 社区版 v16.8.1。如果你想在不同的操作系统或 IDE 上构建你的应用,本章将为你指明正确的方向;但是,您的 IDE 的外观可能与本文中的各种截图不同。

正在安装。NET 5

开始用 C# 9 和。NET 5(在 Windows、macOS 或 Linux 上)。需要安装. NET 5 SDK(它还会安装。NET 5 运行时)。的所有安装。NET 和。网芯位于方便的 www.dot.net 。在主页上,单击下载,然后单击全部。点击“全部”后。你会看到两个 LTS 版本的。NET 核心(2.1 和 3.1)和一个链接。NET 5.0。点击”。NET 5.0(推荐)。”进入该页面后,选择正确的。适用于您的操作系统的 NET 5 SDK。对于这本书,您需要安装 SDK。NET Core 版本 5.0.100 或更高版本,该版本还会安装。NET、ASP.NET 和。NET 桌面(在 Windows 上)运行时。

Note

自发布以来,下载页面发生了变化。净 5。现在有三列带有标题。网,“”。网芯,“和”。NET 框架。点击“全部。NET Core 下载”。NET 或。NET Core header 带你到同一个页面。安装 Visual Studio 2019 也会安装。NET Core SDK 和运行时。

了解。NET 5 版本编号方案

在撰写本文时。NET 5 SDK 的版本是 5.0.100。前两个数字(5.0)表示您可以瞄准的运行时的最高版本。在这种情况下,这是 5.0。这意味着 SDK 也支持开发较低版本的运行时,如。网芯 3.1。下一个数字(1)是季度特征带。由于我们目前处于自发布以来的第一季度,所以它是 1。最后两个数字(00)表示修补程序版本。如果你在脑海中给版本加一个分隔符,把当前版本想成 5.0.1.00,这就稍微清楚一点了。

确认。NET 5 安装

要确认 SDK 和运行时的安装,请打开一个命令窗口并使用。NET 5 命令行界面(CLI),dotnet.exe。CLI 提供了 SDK 选项和命令。这些命令包括创建、构建、运行和发布项目和解决方案,您将在本文后面看到这些命令的示例。在本节中,我们将检查 SDK 选项,共有四个,如表 2-1 所示。

表 2-1。

。NET 5 CLI SDK 选项

|

[计]选项

|

生命的意义

| | --- | --- | | --version | 显示。正在使用. NET SDK 版本 | | --info | 展示.NET 信息 | | --list-runtimes | 显示已安装的运行时 | | --list-sdks | 显示已安装的 SDK | | --version | 显示。正在使用. NET SDK 版本 |

--version选项显示安装在您机器上的 SDK 的最高版本,或者位于您当前目录或以上的global.json中指定的版本。检查的当前版本。NET 5 SDK,请输入以下内容:

dotnet --version

对于这本书,结果需要是 5.0.100(或更高)。

展示所有的。NET Core 运行时,请输入以下内容:

dotnet --list-runtimes

有三种不同的运行时间:

  • Microsoft.AspNetCore.App(用于构建 ASP.NET 核心应用)

  • Microsoft.NETCore.App(的基础运行时.NETCore)

  • Microsoft.WindowsDesktop.App(用于构建 WinForms 和 WPF 应用)

如果您运行的是 Windows 操作系统,则每个版本都必须是 5.0.0(或更高版本)。如果你不在 Windows 上,你只需要前两个,Microsoft.NETCore.AppMicrosoft.AspNetCore.App,并且显示版本 5.0.0(或更高版本)。

最后,要显示所有安装的 SDK,请输入以下内容:

dotnet --list-sdks

同样,版本必须是 5.0.100(或更高)。

使用早期版本的。NET(核心)SDK

如果需要将项目固定到早期版本的。NET Core SDK,你可以用一个global.json文件来完成。要创建该文件,可以使用以下命令:

dotnet new globaljson –sdk-version 3.1.404

这将创建一个类似如下的global.json文件:

{
  "sdk": {
    "version": "3.1.404"
  }
}

该文件“固定”了。将当前目录及其下的所有目录的. NET Core SDK 版本升级到 3.1.404。在这个目录下运行dotnet.exe --version会返回 3.1.404。

建筑。NET 核心应用与 Visual Studio

如果您有使用以前版本的 Microsoft 技术构建应用的经验,您可能对 Visual Studio 很熟悉。在产品的整个生命周期中,版本名称和功能集一直在变化,但自发布以来已经稳定下来。NET 核心。Visual Studio 有以下版本(适用于 Window 和 Mac):

  • Visual Studio 2019 社区(免费)

  • Visual Studio 2019 专业版(付费)

  • Visual Studio 2019 企业版(付费)

社区版和专业版本质上是一样的。最显著的区别在于许可模式。社区被许可用于开源、学术和小型企业。Professional 和 Enterprise 是许可用于任何开发(包括企业开发)的商业产品。正如所料,与专业版相比,企业版有许多附加功能。

Note

具体许可详情,请前往 www.visualstudio.com 。微软产品的许可可能会很复杂,本书不涉及细节。出于写作(和阅读)本书的目的,使用社区是合法的。

所有 Visual Studio 版本都附带了复杂的代码编辑器、集成的调试器、桌面和 web 应用的 GUI 设计器等等。由于它们都有一套共同的核心特性,好消息是它们之间的转换很容易,而且对它们的基本操作也很熟悉。

安装 Visual Studio 2019 (Windows)

在使用 Visual Studio 2019 开发、执行和调试 C# 应用之前,您需要安装它。2017 版本的安装体验发生了巨大变化,值得更详细地讨论。

Note

可以从 www.visualstudio.com/downloads 下载 Visual Studio 2019 社区。确保您下载并安装的版本至少是 16.8.1 或更高版本。

Visual Studio 2019 安装流程现在被分解为应用类型的工作负载。这允许您只安装您计划构建的应用类型所需的组件。例如,如果您要构建 web 应用,您应该安装“ASP。NET 和 web 开发”工作量。

另一个(极其)重大的变化是,Visual Studio 2019 支持真正的并行安装。注意,我指的不仅仅是以前版本的 Visual Studio,而是 Visual Studio 2019 本身!例如,在我的主要工作计算机上,我为我的专业工作安装了 Visual Studio 2019 Enterprise,并为我的书籍、课程和会议讲座安装了 Visual Studio 2019 社区。如果你有雇主提供的 Professional 或 Enterprise,你仍然可以安装 Community edition 来处理开源项目(或本书中的代码)。

当您启动 Visual Studio 2019 Community 的安装程序时,您会看到如图 2-1 所示的屏幕。该屏幕显示了所有可用的工作负载、选择单个组件的选项,以及显示所选内容的右侧摘要。

img/340876_10_En_2_Fig1_HTML.jpg

图 2-1。

新的 Visual Studio 安装程序

对于本书,您需要安装以下工作负载:

  • 。NET 桌面开发

  • ASP.NET 和网络开发

  • 数据存储和处理

  • 。NET Core 跨平台开发

在“单个组件”选项卡上,还选择类设计器、Git for Windows 和“GitHub extension for Visual Studio”(都在“代码工具”下)。一旦您选择了它们,点击安装。这将为你提供完成本书中的例子所需的一切。

试用 Visual Studio 2019

Visual Studio 2019 是软件开发的一站式商店。NET 平台和 C#。让我们通过构建一个简单的。NET 5 控制台应用。

使用“新建项目”对话框和 C# 代码编辑器

当你启动 Visual Studio 时,你会看到更新后的启动对话框,如图 2-2 所示。对话框的左侧有最近使用的解决方案,右侧有用于启动 Visual Studio 的选项,包括从存储库中启动代码、打开现有项目/解决方案、打开本地文件夹或创建新项目。还有一个选项是在没有任何代码的情况下继续,这只是启动 Visual Studio IDE。

img/340876_10_En_2_Fig2_HTML.jpg

图 2-2。

新的 Visual Studio 启动对话框

选择“创建一个n ew 项目”选项,会出现“创建新项目”对话框提示。如图 2-3 所示,最近使用的模板(如果有)在左边,所有可用的模板在右边,包括一组过滤器和一个搜索框。

img/340876_10_En_2_Fig3_HTML.jpg

图 2-3。

“创建新项目”对话框

首先,创建一个新的控制台应用(。NET Core) C# 项目,确保选择 C# 版本,而不是 Visual Basic 版本。

下一个屏幕是“配置你的新项目”对话框,如图 2-4 所示。输入 SimpleCSharpConsoleApp 作为项目名称,并为项目选择一个位置。该向导还将创建一个 Visual Studio 解决方案,默认情况下以项目名称命名。

img/340876_10_En_2_Fig4_HTML.jpg

图 2-4。

“配置您的新项目”对话框

Note

创建解决方案和项目也可以使用。NET Core CLI。这将在 Visual Studio 代码中介绍。

一旦创建了项目,您将会看到初始的 C# 代码文件(名为Program.cs)已经在代码编辑器中打开。用下面的代码替换Main()方法中的单行代码。您会注意到,在您键入时,智能感知(代码完成帮助)将在您应用点运算符时生效。

static void Main(string[] args)
{
  // Set up Console UI (CUI)
  Console.Title = "My Rocking App";
  Console.ForegroundColor = ConsoleColor.Yellow;
  Console.BackgroundColor = ConsoleColor.Blue;
  Console.WriteLine("*************************************");
  Console.WriteLine("***** Welcome to My Rocking App *****");
  Console.WriteLine("*************************************");
  Console.BackgroundColor = ConsoleColor.Black;

  // Wait for Enter key to be pressed.
  Console.ReadLine();
}

这里,您使用的是在System名称空间中定义的Console类。因为System名称空间已经通过using语句自动包含在文件的顶部,所以不需要在类名前限定名称空间(例如System.Console.WriteLine())。这个程序没有做任何太有趣的事情;但是,请注意对Console.ReadLine()的最后调用。这只是为了确保用户必须按键才能终止应用。对于 Visual Studio 2019,这是不必要的,因为 VS 调试器将暂停程序并阻止它退出。如果你要导航到编译版本并运行它,当调试程序时,程序几乎会立即消失!

Note

如果你想改变 VS 调试体验,自动结束程序,选择工具➤选项➤调试➤调试停止时自动关闭控制台。

改变目标。NET 核心框架

默认。NET 核心版本。NET 核心控制台应用和类库是最新的 LTS 版本。网芯 3.1。要用。NET 5 或者只是检查。NET (Core ),在解决方案资源管理器中双击该项目。这将在编辑器中打开项目文件(这是 Visual Studio 2019 和的新功能。网芯)。您也可以通过在解决方案资源管理器中右击项目名称并选择“编辑项目文件”来编辑项目文件您将看到以下内容:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>netcoreapp3.1</TargetFramework>
  </PropertyGroup>
</Project>

换成不同的。NET 核心版到。NET 5,只需将 TargetFramework 值更改为 net5.0,如下所示:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net5.0</TargetFramework>
  </PropertyGroup>
</Project>

您还可以通过在解决方案资源管理器中右键单击项目名称并选择 Properties,打开 Application 选项卡,并更新目标框架值来更改目标框架,如图 2-5 所示。

img/340876_10_En_2_Fig5_HTML.jpg

图 2-5。

更改应用的目标框架

使用 C# 9 特性

在的早期版本中。NET 中,一个项目所支持的 C# 版本是可以改变的。和。NET Core 3.0+,使用的 C# 版本捆绑成框架版本。若要确认这一点,请在解决方案资源管理器中右击项目名称,然后选择“属性”。在“属性”对话框中,单击左栏中的“构建”,然后单击右下角的“高级”。这将弹出如图 2-6 所示的对话框。

img/340876_10_En_2_Fig6_HTML.jpg

图 2-6。

高级构建设置

为了。NET 5.0 项目,语言版本锁定为 C# 9。表 2-2 列出了目标框架(。网芯,。NET 标准,以及。NET Framework)和使用的默认 C# 版本。

表 2-2。

C# 8 版本和目标框架

|

目标框架

|

版本

|

C# 语言版本默认值

| | --- | --- | --- | | 。网 | 5.x | C# 9.0 | | 。净核心 | 3.x | C# 8.0 | | 。净核心 | 2.x | C# 7.3 | | 。净标准 | Two point one | C# 8.0 | | 。净标准 | Two | C# 7.3 | | .NET 标准 | 1.x | C# 7.3 | | 。NET 框架 | 全部 | C# 7.3 |

运行和调试项目

要运行程序并查看输出,请按 Ctrl+F5 键盘命令(也可以从“调试➤”的“不调试启动”菜单选项中访问)。一旦你这样做了,你会看到一个 Windows 控制台窗口弹出在屏幕上与你的自定义(和丰富多彩的)信息。请注意,当您使用 Ctrl+F5“运行”您的程序时,您会绕过集成调试器。

Note

。NET 核心应用也可以使用 CLI 编译和执行。要运行您的项目,请在与项目文件相同的目录中输入dotnet run(在本例中为SimpleCSharpApp.csproj)。dotnet run命令也会自动构建项目。

如果您需要调试您的代码(这在构建更大的程序时肯定很重要),您的第一步是在您想要检查的代码语句处设置断点。虽然这个例子代码不多,但是通过点击代码编辑器最左边的灰色条来设置断点(注意断点是用红点图标标记的;参见图 2-7 。

img/340876_10_En_2_Fig7_HTML.jpg

图 2-7。

设置断点

如果您现在按 F5 键(或者使用“调试➤”“开始调试”菜单选项,或者单击工具栏中旁边带有“开始”的绿色箭头),您的程序将在每个断点处暂停。如您所料,您可以使用 IDE 的各种工具栏按钮和菜单选项与调试器进行交互。一旦评估完所有断点,应用将最终在Main()完成后终止。

Note

微软 ide 有复杂的调试器,你将在接下来的章节中学习各种技术。现在,请注意,当您处于调试会话中时,大量有用的选项会出现在调试菜单下。请花点时间亲自验证这一点。

使用解决方案浏览器

如果您看一下 IDE 的右侧,您会看到一个解决方案资源管理器窗口,它向您展示了一些重要的东西。首先,请注意 IDE 已经创建了一个包含单个项目的解决方案。这一开始可能会令人困惑,因为它们被赋予了相同的名称(SimpleCSharpConsoleApp)。这里的想法是一个“解决方案”可以包含多个一起工作的项目。例如,您的解决方案可能包括三个类库、一个 WPF 应用和一个 ASP.NET 核心 web 服务。本书的前几章总是有一个单独的项目;然而,当您构建一些更复杂的示例时,您将看到如何向您的初始解决方案空间添加新项目。

Note

请注意,当您在“解决方案资源管理器”窗口中选择最顶层的解决方案时,IDE 的菜单系统将向您显示一组与选择项目时不同的选项。如果您发现自己想知道某个菜单项消失到哪里了,请仔细检查您没有意外选择错误的节点。

使用可视化类设计器

Visual Studio 还使您能够以可视化的方式设计类和其他类型(如接口或委托)。类设计器实用工具允许您查看和修改项目中类型(类、接口、结构、枚举和委托)的关系。使用此工具,您可以直观地向类型添加(或从中移除)成员,并将您的修改反映在相应的 C# 文件中。此外,当您修改给定的 C# 文件时,更改会反映在类图中。

若要访问可视化类型设计器工具,第一步是插入新的类图文件。为此,激活项目➤添加新项目菜单选项,并定位类图类型(图 2-8 )。

img/340876_10_En_2_Fig8_HTML.jpg

图 2-8。

将类图文件插入到当前项目中

最初,设计器将是空的;但是,您可以将文件从解决方案资源管理器窗口拖放到图面上。例如,一旦您将Program.cs拖到设计器上,您会发现Program类的可视化表示。如果你点击给定类型的箭头图标,你可以显示或隐藏该类型的成员(参见图 2-9 )。

img/340876_10_En_2_Fig9_HTML.jpg

图 2-9。

类图查看器

Note

使用类设计器工具栏,可以微调设计器图面的显示选项。

类设计器实用工具与 Visual Studio 的其他两个方面协同工作:“类详细信息”窗口(使用“查看➤其他窗口”菜单激活)和“类设计器工具箱”(使用“查看➤工具箱”菜单项激活)。“类详细信息”窗口不仅显示图中当前所选项的详细信息,还允许您动态修改现有成员和插入新成员(参见图 2-10 )。

img/340876_10_En_2_Fig10_HTML.jpg

图 2-10。

“类详细信息”窗口

类设计器工具箱也可以使用视图菜单激活,它允许您可视地将新类型插入到项目中(并创建这些类型之间的关系)(参见图 2-11 )。(请注意,要查看该工具箱,必须有一个类图作为活动窗口。)这样做时,IDE 会在后台自动创建新的 C# 类型定义。

img/340876_10_En_2_Fig11_HTML.jpg

图 2-11。

类设计器工具箱

例如,将一个新类从类设计器工具箱拖到您的类设计器上。在出现的对话框中,将这个类命名为Car。这将导致创建一个名为Car.cs的新 C# 文件,它会自动添加到您的项目中。现在,使用类细节窗口,添加一个名为PetName的公共string字段(见图 2-12 )。

img/340876_10_En_2_Fig12_HTML.jpg

图 2-12。

使用“类详细信息”窗口添加字段

如果您现在查看Car类的 C# 定义,您会看到它已经被相应地更新了(去掉了这里显示的附加代码注释):

public class Car
{
   // Public data is typically a bad idea; however,
   // it keeps this example simple.
   public string PetName;
}

现在,再次激活设计器文件,并将另一个新类拖到设计器上,并将其命名为SportsCar。单击类设计器工具箱中的继承图标,然后单击SportsCar图标的顶部。接下来,在Car类图标上点击鼠标。如果您正确地执行了这些步骤,那么您已经从Car中派生出了SportsCar类(参见图 2-13 )。

img/340876_10_En_2_Fig13_HTML.jpg

图 2-13。

可视化地从现有类派生

Note

继承的概念将在第六章中详细讨论。

为了完成这个例子,用名为GetPetName()的公共方法更新生成的SportsCar类,如下所示:

public class SportsCar : Car
{
   public string GetPetName()
   {
     PetName = "Fred";
     return PetName;
   }
}

如您所料,设计器显示了添加到SportsCar类的方法。

这就结束了您对 Visual Studio 的初步了解。在本文中,您将看到更多使用 Visual Studio 构建 C# 9 和。NET 5 应用。

建筑。带有 Visual Studio 代码的. NET 核心应用

微软的另一个流行的 IDE 是 Visual Studio Code (VSC)。Visual Studio 代码对于微软家族来说是一个相对较新的版本;是免费的、开源的、跨平台的;并在国内外的开发人员中获得了广泛的采用。网核生态系统。Visual Studio 代码的重点是(顾名思义)应用的代码。它没有 Visual Studio 中包含的许多内置功能。但是,还可以通过扩展将其他功能添加到 Visual Studio 代码中。这允许您拥有一个为您的工作流定制的快速 IDE。本书中的许多示例都是用 Visual Studio 代码构建和测试的。您可以从这里下载:

https://code.visualstudio.com/download

安装 VSC 后,您将需要添加 C# 扩展,如下所示:

https://marketplace.visualstudio.com/items?itemName=ms-dotnettools.csharp

Note

Visual Studio 代码用于开发基于多种语言的许多不同类型的应用。有 Angular,View,PHP,Java,还有很多很多更多的扩展。

试用 Visual Studio 代码

让我们快速浏览一下 Visual Studio 代码。NET 5 控制台应用。

创建解决方案和项目

当您启动 Visual Studio 代码时,您会看到一个空白板。创建解决方案和项目必须通过。NET 5 命令行界面,也称为 CLI。首先,通过选择“文件”“➤”“打开文件夹”,打开包含 Visual Studio 代码的文件夹,然后在资源管理器窗口中导航到您希望解决方案和项目所在的位置。接下来,通过选择终端➤新终端或按 Ctl+Shift+`,打开一个终端窗口。

在终端窗口中,输入以下命令创建一个空的。NET 5 解决方案文件:

dotnet new sln -n SimpleCSharpConsoleApp -o .\VisualStudioCode

这将在名为 VisualStudioCode 的子目录中创建一个名为(-n ) SimpleCSharpConsoleApp 的新解决方案文件。将 Visual Studio 代码用于单个项目应用时,不需要创建解决方案文件。Visual Studio 以解决方案为中心;Visual Studio 代码是以代码为中心的。我们在这里创建了一个解决方案文件来复制 Visual Studio 示例中的过程。

Note

这些示例使用 Windows 目录分隔符。根据您的操作系统调整分离器。

接下来,创建一个新的 C# 9/。NET 5 ( -f net5.0)同名子目录(-o)中名为(-n ) SimpleCSharpConsoleApp 的控制台应用(注意该命令必须全部在一行中):

dotnet new console -lang c# -n SimpleCSharpConsoleApp -o .\VisualStudioCode\SimpleCSharpConsoleApp -f net5.0

Note

因为目标框架是使用-f选项指定的,所以不需要像使用 Visual Studio 那样更新项目文件。

最后,使用以下命令将新创建的项目添加到解决方案中:

dotnet sln .\VisualStudioCode\SimpleCSharpConsoleApp.sln add .\VisualStudioCode\SimpleCSharpConsoleApp

Note

这只是 CLI 功能的一小部分。要发现 CLI 可以做的一切,请输入dotnet -h

探索 Visual Studio 代码工作区

正如您在图 2-14 中看到的,Visual Studio 代码工作区专注于代码,但也提供了许多附加功能来帮助您提高工作效率。浏览器(1)是一个集成的文件浏览器,在图中被选中。源代码控件(2)与 Git 集成。调试图标(3)启动适当的调试器(假设安装了正确的扩展)。下一个是扩展管理器(4)。单击调试图标将显示推荐的扩展以及所有可用扩展的列表。扩展管理器是上下文敏感的,它会根据打开的目录和子目录中的代码类型提出建议。

img/340876_10_En_2_Fig14_HTML.jpg

图 2-14。

Visual Studio 代码工作区

代码编辑器(5)具有完整的颜色编码和智能感知支持,这两者都依赖于扩展。代码映射(6)显示整个代码文件的映射,调试控制台(7)接收调试会话的输出并接受用户的输入(类似于 Visual Studio 中的即时窗口)。

还原包,构建和运行程序

那个。NET 5 CLI 拥有还原包、构建解决方案、构建项目和运行应用所需的所有功能。要恢复您的解决方案和项目所需的所有 NuGet 包,请在终端窗口(或 VSC 以外的命令窗口)中输入以下命令,确保从与解决方案文件相同的目录中运行该命令:

dotnet restore

要构建您的解决方案中的所有项目,请在终端/命令窗口中执行以下命令(同样,确保命令在与解决方案文件相同的目录中执行):

dotnet build

Note

当在包含解决方案文件的目录中执行dotnet restoredotnet build时,解决方案中的所有项目都会被执行。也可以通过运行 C# 项目文件目录中的命令来运行单个项目中的命令(*.csproj)。

若要在不调试的情况下运行项目,请执行以下命令。与项目文件(SimpleCSharpConsoleApp.csproj)在同一目录下的. NET CLI 命令:

dotnet run

调试您的项目

要调试程序,按 F5 键盘命令或点击调试图标(图 2-14 中的 2)。假设您已经加载了 VSC 的 C# 扩展,程序将在调试模式下运行。断点的管理与使用 Visual Studio 时相同,尽管它们在编辑器中没有那么明显(图 2-15 )。

img/340876_10_En_2_Fig15_HTML.jpg

图 2-15。

Visual Studio 代码中的断点

要更改要集成的终端并允许输入到您的程序中,首先打开launch.json文件(位于.vscode目录中)。将控制台入口从internalConsole更改为integratedTerminal,如下图所示:

{
   // Use IntelliSense to find out which attributes exist for C# debugging
   // Use hover for the description of the existing attributes
   // For further information visit https://github.com/OmniSharp/omnisharp-vscode/blob/master/debugger-launchjson.md
   "version": "0.2.0",
   "configurations": [
        {
            "name": ".NET Core Launch (console)",
            "type": "coreclr",
            "request": "launch",
            "preLaunchTask": "build",
            // If you have changed target frameworks, make sure to update the program path.
            "program": "${workspaceFolder}/SimpleCSharpConsoleApp/bin/Debug/net5.0/SimpleCSharpConsoleApp.Cs.dll",
            "args": [],
            "cwd": "${workspaceFolder}/SimpleCSharpConsoleApp",
            // For more information about the 'console' field, see https://aka.ms/VSCode-CS-LaunchJson-Console
            "console": "integratedTerminal",
            "stopAtEntry": false
        },
        {
            "name": ".NET Core Attach",
            "type": "coreclr",
            "request": "attach",
            "processId": "${command:pickProcess}"
        }
    ]
}

寻找。NET 核心和 C# 文档

C# 和。NET 核心文档非常好,可读性很强,并且充满了有用的信息。鉴于大量预定义的。NET 类型(数以千计),您必须愿意卷起袖子深入研究所提供的文档。您可以在此处查看所有 Microsoft 文档:

https://docs.microsoft.com/en-us/dotnet/csharp/

在本书的前半部分,您将会用到最多的地方是 C# 文档和。NET 核心文档,可在以下位置找到:

https://docs.microsoft.com/en-us/dotnet/csharp/
https://docs.microsoft.com/en-us/dotnet/core/

摘要

本章的目的是为您提供使用。NET 5 SDK 和运行时,并提供 Visual Studio 2019 社区版和 Visual Studio 代码之旅。如果你只对构建跨平台感兴趣。NET 核心应用,您有许多选择。Visual Studio(仅限 Windows)、Visual Studio for the Mac(仅限 Mac)和 Visual Studio Code(跨平台)都是由微软提供的。构建 WPF 或 WinForms 应用仍然需要 Windows 计算机上的 Visual Studio。

三、核心 C# 编程结构:第一部分

本章介绍了一些在您探索 C# 编程语言时必须熟悉的小范围独立主题,从而开始了您对 C # 编程语言的正式研究。NET 核心框架。首要任务是理解如何构建程序的应用对象,并检查可执行程序入口点的组成:方法Main()以及 C# 9.0 的一个新特性,顶级语句。接下来,您将研究基本的 C# 数据类型(以及它们在System名称空间中的等价类型),包括对System.StringSystem.Text.StringBuilder类的研究。

在你了解了基本的细节之后。NET 核心数据类型,然后您将研究许多数据类型转换技术,包括收缩操作、扩大操作以及关键字checkedunchecked的使用。

本章还将考察 C# var关键字的作用,它允许你隐式地定义一个局部变量。正如您将在本书后面看到的,当使用 LINQ 技术集时,隐式类型非常有用,如果不是偶尔强制的话。您将通过快速检查 C# 关键字和操作符来结束本章,这些关键字和操作符允许您使用各种循环和决策结构来控制应用的流程。

分解一个简单的 C# 程序

C# 要求所有的程序逻辑都包含在一个类型定义中(回想一下第一章中的类型是一个通用术语,指集合{类、接口、结构、枚举、委托}的成员)。与许多其他语言不同,在 C# 中不可能创建全局函数或全局数据点。相反,所有数据成员和所有方法都必须包含在类型定义中。首先,创建一个名为Chapter3_AllProject.sln的新的空解决方案,其中包含一个名为 SimpleCSharpApp 的 C# 控制台应用。

从 Visual Studio 中,选择“创建新项目”屏幕上的空白解决方案模板。当解决方案打开时,在解决方案资源管理器中右击该解决方案,然后选择“添加➤新项目”。从模板中选择“C# 控制台应用”,命名为 SimpleCSharpApp ,点击创建。记得把目标框架更新到 net5.0

从命令行执行以下操作:

dotnet new sln -n Chapter3_AllProjects

dotnet new console -lang c# -n SimpleCSharpApp -o .\SimpleCSharpApp -f net5.0
dotnet sln .\Chapter3_AllProjects.sln add .\SimpleCSharpApp

您可能同意初始Program.cs文件中的代码相当平淡无奇。

using System;

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

鉴于此,用下面的代码语句更新您的Program类的Main()方法:

class Program
{
  static void Main(string[] args)
  {
    // Display a simple message to the user.
    Console.WriteLine("***** My First C# App *****");
    Console.WriteLine("Hello World!");
    Console.WriteLine();

    // Wait for Enter key to be pressed before shutting down.
    Console.ReadLine();
  }
}

Note

C# 是一种区分大小写的编程语言。因此,不同,读线读线不同。请注意,所有 C# 关键字都是小写的(例如,publiclockclassdynamic),而名称空间、类型和成员名称(按照惯例)以首字母大写开始,任何嵌入单词的首字母都是大写的(例如,Console.WriteLineSystem.Windows.MessageBoxSystem.Data.SqlClient)。作为一个经验法则,每当你收到一个关于“未定义符号”的编译器错误时,一定要首先检查你的拼写和大小写!

前面的代码包含一个支持名为Main()的单一方法的类类型的定义。默认情况下,Visual Studio 命名定义Main() Program的类;但是,如果您愿意,您可以自由更改。在 C# 9.0 之前,每个可执行的 C# 应用(控制台程序、Windows 桌面程序或 Windows 服务)都必须包含一个定义Main()方法的类,该方法用于表示应用的入口点。

正式来说,定义Main()方法的类被称为应用对象。一个可执行的应用可能有多个应用对象(这在执行单元测试时很有用),但是编译器必须知道哪个Main()方法应该被用作入口点。这可以通过项目文件中的元素或位于 Visual Studio 项目属性窗口的应用选项卡上的启动对象下拉列表框来完成。

请注意,Main()的签名带有static关键字,这将在第五章中详细讨论。目前,只需理解静态成员的作用域是类级别(而不是对象级别),因此无需首先创建新的类实例就可以调用静态成员。

除了关键字static之外,这个Main()方法还有一个参数,这个参数恰好是一个字符串数组(string[] args)。尽管您目前并不想处理这个数组,但是这个参数可能包含任意数量的传入命令行参数(稍后您将看到如何访问它们)。最后,这个Main()方法已经设置了一个void返回值,这意味着在退出方法范围之前,不需要使用return关键字显式定义返回值。

Program类的逻辑在Main()内。这里,您使用了在System名称空间中定义的Console类。它的一组成员中有一个静态的WriteLine(),正如您可能想到的,它向标准输出发送一个文本字符串和回车。您还调用了Console.ReadLine()来确保 Visual Studio IDE 启动的命令提示符保持可见。跑步的时候。NET Core 控制台应用,默认情况下,控制台窗口仍然可见。可以通过启用“工具”“➤”“选项”“➤调试”下的“调试停止时自动关闭控制台”设置来更改此行为。控制台。如果通过双击产品*.exe文件从 Windows 资源管理器执行程序,ReadLine 方法可以保持窗口打开。你很快会学到更多关于System.Console类的知识。

使用 Main()方法的变体(更新 7.1)

默认情况下,Visual Studio 将生成一个Main()方法,该方法有一个void返回值和一个string类型的数组作为单个输入参数。然而,这并不是Main()的唯一可能形式。允许使用以下任何签名构造应用的入口点(假设它包含在 C# 类或结构定义中):

// int return type, array of strings as the parameter.
static int Main(string[] args)
{
  // Must return a value before exiting!
  return 0;
}

// No return type, no parameters.
static void Main()
{
}

// int return type, no parameters.
static int Main()
{
  // Must return a value before exiting!
  return 0;
}

随着 C# 7.1 的发布,Main()方法现在可以异步了。异步编程包含在第十五章中,但是现在意识到还有四个额外的签名。

static Task Main()
static Task<int> Main()
static Task Main(string[])
static Task<int> Main(string[])

Note

Main()方法也可以被定义为 public 而不是 private。请注意,如果不提供特定的访问修饰符,则假定为 private。Visual Studio 自动将程序的Main()方法定义为隐式私有。第五章详细介绍了访问修饰符。

显然,你对如何构造Main()的选择将基于三个问题。首先,当Main()已经完成并且你的程序终止时,你想给系统返回值吗?如果是这样,你需要返回一个int数据类型,而不是void。第二,您需要处理任何用户提供的命令行参数吗?如果是,它们将被存储在string s 的数组中。最后,你需要从Main()方法中调用异步代码吗?让我们更详细地检查前两个选项,将异步选项留到第十五章。

使用顶级语句(新 9.0)

虽然在 C# 9.0 之前,所有的 C# 都是。NET 核心应用必须有一个Main()方法,C# 9.0 引入了顶级语句,消除了围绕 C# 应用入口点的许多仪式。类(Program)和Main()方法都可以被移除。要查看这一点,请更新Program.cs类以匹配以下内容:

using System;

// Display a simple message to the user.
Console.WriteLine("***** My First C# App *****");
Console.WriteLine("Hello World!");
Console.WriteLine();

// Wait for Enter key to be pressed before shutting down.
Console.ReadLine();

你会看到,当你运行程序时,你会得到同样的结果!使用顶级语句有一些规则:

  • 应用中只有一个文件可以使用顶级语句。

  • 使用顶级语句时,程序不能有声明的入口点。

  • 顶级语句不能包含在命名空间中。

  • 顶级语句仍然访问一个string参数数组。

  • 顶级语句通过使用 return 返回应用代码(见下一节)。

  • Program类中声明的函数成为顶级语句的局部函数。(本地功能包含在第四章中。)

  • 附加类型可以在所有顶级语句之后声明。在顶级语句结束之前声明的任何类型都将导致编译错误。

在幕后,编译器填充空白。检查为更新代码生成的 IL,您将看到下面的TypeDef是应用的入口点:

// TypeDef #1 (02000002)
// -------------------------------------------------------
//     TypDefName: <Program>$  (02000002)
//     Flags     : [NotPublic] [AutoLayout] [Class] [Abstract] [Sealed] [AnsiClass] [BeforeFieldInit]  (00100180)
//     Extends   : 0100000D [TypeRef] System.Object
//     Method #1 (06000001) [ENTRYPOINT]
//     -------------------------------------------------------
//             MethodName: <Main>$ (06000001)

将它与第一章中的入口点TypeDef进行比较:

// TypeDef #1 (02000002)
// -------------------------------------------------------
//     TypDefName: CalculatorExamples.Program  (02000002)
//     Flags     : [NotPublic] [AutoLayout] [Class] [AnsiClass] [BeforeFieldInit]  (00100000)
//     Extends   : 0100000C [TypeRef] System.Object
//     Method #1 (06000001) [ENTRYPOINT]
//     -------------------------------------------------------
//             MethodName: Main (06000001)

注意第一章的例子,TypDefName值显示为名称空间(CalculatorExamples)加上类名(Program),MethodName值为Main。在使用顶级语句的更新示例中,编译器为TypDefName填充了<Program>$的值,为方法名填充了<Main>$的值。

指定应用错误代码(更新 9.0)

虽然绝大多数的Main()方法(或顶级语句)将返回void作为返回值,但是返回int(或Task<int>)的能力使 C# 与其他基于 C 的语言保持一致。按照惯例,返回值0表示程序已经成功终止,而另一个值(比如-1)表示一个错误条件(注意,值0是自动返回的,即使您构造了一个原型化的Main()方法来返回void)。

当使用顶级语句时(因此没有Main()方法),如果执行代码返回一个整数,那就是返回代码。如果没有显式返回任何东西,它仍然返回 0,就像显式使用一个Main()方法一样。

在 Windows 操作系统上,应用的返回值存储在名为%ERRORLEVEL%的系统环境变量中。如果你要创建一个以编程方式启动另一个可执行文件的应用(这个主题在第十九章中讨论),你可以使用已启动进程的ExitCode属性获得%ERRORLEVEL%的值。

假设应用的返回值是在应用终止时传递给系统的,那么应用显然不可能在运行时获得并显示其最终的错误代码。但是,为了说明如何在程序终止时查看该错误级别,首先更新顶级语句,如下所示:

// Note we are explicitly returning an int, rather than void.
// Display a message and wait for Enter key to be pressed.
Console.WriteLine("***** My First C# App *****");
Console.WriteLine("Hello World!");
Console.WriteLine();
Console.ReadLine();

// Return an arbitrary error code.
return -1;

如果程序仍然使用一个Main()方法作为入口点,改变方法签名以返回int而不是void,如下所示:

static int Main()
{
…
}

现在让我们在批处理文件的帮助下捕获程序的返回值。使用 Windows 资源管理器,导航到包含您的项目文件的文件夹(例如,C:\SimpleCSharpApp)并将一个新的文本文件(名为SimpleCSharpApp.cmd)添加到该文件夹。将文件夹的内容更新为以下内容(如果您以前没有创作过*.cmd文件,请不要关心这些细节):

@echo off
rem A batch file for SimpleCSharpApp.exe
rem which captures the app's return value.

dotnet run
@if "%ERRORLEVEL%" == "0" goto success

:fail
  echo This application has failed!
  echo return value = %ERRORLEVEL%
  goto end
:success
  echo This application has succeeded!
  echo return value = %ERRORLEVEL%
  goto end
:end
echo All Done.

此时,打开命令提示符(或使用 VSC 终端)并导航到包含新的*.cmd文件的文件夹。通过键入文件名并按回车键来执行文件。假设您的Main()方法正在返回-1,您应该会发现如下所示的输出。如果Main()方法返回了0,您将会看到消息“该应用已经成功!”打印到控制台。

***** My First C# App *****

Hello World!

This application has failed!
return value = -1
All Done.

前面的*.cmd文件的 PowerShell 等价物如下:

dotnet run
if ($LastExitCode -eq 0) {
    Write-Host "This application has succeeded!"
} else
{
    Write-Host "This application has failed!"
}
Write-Host "All Done."

要运行这个脚本,在 VSC 终端中键入PowerShell,然后通过键入以下命令执行脚本:

.\SimpleCSharpApp.ps1

您将在终端窗口中看到以下内容:

***** My First C# App *****

Hello World!

This application has failed!
All Done.

绝大多数(如果不是全部的话)C# 应用将使用void作为来自Main()的返回值,正如您所记得的,它隐式返回错误代码 0。为此,本文中使用的Main()方法(超出当前示例)将返回void

处理命令行参数(已更新 9.0)

现在您已经更好地理解了Main()方法或顶级语句的返回值,让我们检查一下string数据的传入数组。假设您现在想要更新您的应用来处理任何可能的命令行参数。一种方法是使用 C# for循环。(请注意,C# 的迭代结构将在本章末尾详细讨论。)

// Display a message and wait for Enter key to be pressed.
Console.WriteLine("***** My First C# App *****");
Console.WriteLine("Hello World!");
Console.WriteLine();
// Process any incoming args.
for (int i = 0; i < args.Length; i++)
{
  Console.WriteLine("Arg: {0}", args[i]);
}
Console.ReadLine();
// Return an arbitrary error code.
return 0;

Note

这个例子使用了顶级语句,它没有使用Main()方法。更新Main()方法以接受args参数的内容将很快介绍。

再次使用顶级语句检查程序的生成 IL,注意,<Main>$方法接受一个名为argsstring数组,如下所示(缩写为 space):

.class private abstract auto ansi sealed beforefieldinit '<Program>$'
       extends [System.Runtime]System.Object
{
  .custom instance void [System.Runtime]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor()=
    ( 01 00 00 00 )
  .method private hidebysig static
          void  '<Main>$'(string[] args) cil managed
  {
    .entrypoint
…
  } // end of method '<Program>$'::'<Main>$'
} // end of class '<Program>$'

如果程序仍然使用Main()方法作为入口点,确保方法签名接受名为argsstring数组,如下所示:

static int Main(string[] args)
{
…
}

在这里,您使用System.ArrayLength属性来检查string的数组是否包含一些条目。正如你将在第四章中看到的,所有 C# 数组实际上都是System.Array类的别名,因此,共享一组公共成员。当您循环数组中的每一项时,它的值会打印到控制台窗口。在命令行提供参数同样简单,如下所示:

C:\SimpleCSharpApp>dotnet run /arg1 -arg2

***** My First C# App *****
Hello World!
Arg: /arg1
Arg: -arg2

作为标准for循环的替代方法,您可以使用 C# foreach关键字迭代一个传入的string数组。下面是一些示例用法(但同样,你将在本章后面看到循环结构的细节):

// Notice you have no need to check the size of the array when using "foreach".
// Process any incoming args using foreach.
foreach(string arg in args)
{
  Console.WriteLine("Arg: {0}", arg);
}
Console.ReadLine();
return 0;

最后,您还可以使用System.Environment类型的静态GetCommandLineArgs()方法来访问命令行参数。该方法的返回值是一个由string组成的数组。第一个条目包含应用本身的名称,而数组中的其余元素包含各个命令行参数。

.
// Get arguments using System.Environment.
string[] theArgs = Environment.GetCommandLineArgs();
foreach(string arg in theArgs)
{
  Console.WriteLine("Arg: {0}", arg);
}
Console.ReadLine();
return 0;

Note

GetCommandLineArgs方法不通过Main()方法接收应用的参数,也不依赖于string[] args参数。

当然,由您来决定您的程序将响应哪些命令行参数(如果有的话)以及它们必须如何被格式化(比如用一个-/前缀)。在这里,我只是传递了一系列直接打印到命令提示符下的选项。然而,假设您正在创建一个新的视频游戏,并编写您的应用来处理一个名为-godmode的选项。如果用户用这个标志启动你的应用,你知道他实际上是一个骗子,你可以采取适当的行动。

用 Visual Studio 指定命令行参数

在现实世界中,最终用户在启动程序时可以选择提供命令行参数。但是,在开发周期中,出于测试目的,您可能希望指定可能的命令行标志。若要使用 Visual Studio 执行此操作,请在解决方案资源管理器中右击项目名称,选择“属性”,然后导航到左侧的“调试”选项卡。在那里,使用“应用参数”文本框指定值(见图 3-1 )并保存您的更改。

img/340876_10_En_3_Fig1_HTML.jpg

图 3-1。

在 Visual Studio 中设置应用参数

在您建立了这样的命令行参数之后,当在 Visual Studio IDE 中调试或运行您的应用时,它们将自动传递给Main()方法。

一个有趣的旁白:该系统的一些额外成员。环境类

除了GetCommandLineArgs()之外,Environment类还公开了许多非常有用的方法。具体来说,这个类允许您获取有关当前承载您的。NET 5 应用使用各种静态成员。为了说明System.Environment的用处,更新您的代码来调用名为ShowEnvironmentDetails()的本地方法。

// Local method within the Top-level statements.
ShowEnvironmentDetails();

Console.ReadLine();
return -1;
}

在顶级语句之后实现这个方法来调用Environment类型的各种成员:

static void ShowEnvironmentDetails()
{
  // Print out the drives on this machine,
  // and other interesting details.
  foreach (string drive in Environment.GetLogicalDrives())
  {
    Console.WriteLine("Drive: {0}", drive);
  }
  Console.WriteLine("OS: {0}", Environment.OSVersion);
  Console.WriteLine("Number of processors: {0}",
    Environment.ProcessorCount);
  Console.WriteLine(".NET Core Version: {0}",
    Environment.Version);
}

以下输出显示了调用此方法的可能测试运行:

***** My First C# App *****

Hello World!

Drive: C:\
OS: Microsoft Windows NT 10.0.19042.0
Number of processors: 16
.NET Core Version: 5.0.0

Environment类型定义的成员不同于上一个示例中显示的成员。表 3-1 记录了一些感兴趣的附加属性;但是,请务必查看在线文档以了解完整的详细信息。

表 3-1

选择系统属性。环境

|

财产

|

生命的意义

| | --- | --- | | ExitCode | 获取或设置应用的退出代码 | | Is64BitOperatingSystem | 返回一个bool来表示主机是否运行 64 位操作系统 | | MachineName | 获取当前计算机的名称 | | NewLine | 获取当前环境的换行符 | | SystemDirectory | 返回系统目录的完整路径 | | UserName | 返回启动该应用的用户名 | | Version | 返回一个代表.NETCore 平台 |

使用系统。控制台类

在本书前几章中创建的几乎所有示例应用都大量使用了System.Console类。虽然控制台用户界面(CUI)确实不如图形用户界面(GUI)或 web 应用那样吸引人,但是将早期的示例限制在控制台程序将使您能够专注于 C# 的语法和。NET 5 平台,而不是处理构建桌面 GUI 或网站的复杂性。

顾名思义,Console类封装了基于控制台的应用的输入、输出和错误流操作。表 3-2 列出了一些(但肯定不是全部)感兴趣的成员。正如您所看到的,Console类确实提供了一些成员,可以为简单的命令行应用增添趣味,比如改变背景和前景色以及发出哔哔声(各种频率!).

表 3-2。

选择系统成员。安慰

|

成员

|

生命的意义

| | --- | --- | | Beep() | 此方法强制控制台发出指定频率和持续时间的嘟嘟声。 | | BackgroundColor | 这些属性设置当前输出的背景/前景色。 | | ForegroundColor | 它们可以被赋予ConsoleColor枚举的任何成员。 | | BufferHeightBufferWidth | 这些属性控制控制台缓冲区的高度/宽度。 | | Title | 此属性获取或设置当前控制台的标题。 | | WindowHeightWindowWidthWindowTopWindowLeft | 这些属性控制控制台相对于已建立缓冲区的尺寸。 | | Clear() | 此方法清除已建立的缓冲区和控制台显示区域。 |

使用控制台类执行基本输入和输出(I/O)

除了表 3-2 中的成员之外,Console类型还定义了一组捕获输入和输出的方法,所有这些方法都是静态的,因此通过在方法名前面加上类名(Console)来调用。正如您所看到的,WriteLine()将一个文本字符串(包括回车)抽取到输出流中。Write()方法将文本抽取到输出流中,不需要回车。ReadLine()允许您从输入流接收信息,直到按下回车键,而Read()用于从输入流中捕获单个字符。

为了说明使用Console类的简单 I/O,创建一个名为 BasicConsoleIO 的新控制台应用项目,并使用以下 CLI 命令将其添加到您的解决方案中:

dotnet new console -lang c# -n BasicConsoleIO -o .\BasicConsoleIO -f net5.0
dotnet sln .\Chapter3_AllProjects.sln add .\BasicConsoleIO

用以下代码替换Program.cs代码:

using System;
Console.WriteLine("***** Basic Console I/O *****");
GetUserData();
Console.ReadLine();
static void GetUserData()
{
}

Note

Visual Studio 和 Visual Studio 代码都支持许多“代码片段”,这些代码片段在激活后将插入代码。在本文的前几章中,cw代码片段非常有用,因为它会自动扩展到Console.WriteLine()!为了测试你自己,在你的code中输入cw,然后按 Tab 键。注意:在 Visual Studio 代码中,你按一次 Tab 键;在 Visual Studio 中,必须按两次 Tab 键。

在顶级语句之后实现此方法,逻辑提示用户输入一些信息,并将每一项回显到标准输出流。例如,您可以要求用户输入姓名和年龄(为简单起见,将其视为文本值,而不是预期的数值),如下所示:

static void GetUserData()
{
  // Get name and age.
  Console.Write("Please enter your name: ");
  string userName = Console.ReadLine();
  Console.Write("Please enter your age: ");
  string userAge = Console.ReadLine();

  // Change echo color, just for fun.
  ConsoleColor prevColor = Console.ForegroundColor;
  Console.ForegroundColor = ConsoleColor.Yellow;

  // Echo to the console.
  Console.WriteLine("Hello {0}! You are {1} years old.",
  userName, userAge);

  // Restore previous color.
  Console.ForegroundColor = prevColor;
}

毫不奇怪,当您运行这个应用时,输入数据被打印到控制台(使用自定义颜色启动!).

格式化控制台输出

在这前几章中,您可能已经注意到在各种字符串文字中出现了大量的标记,如{0}{1}。那个。NET 5 平台支持的字符串格式有点类似于 c 语言的printf()语句。简而言之,当您定义一个包含数据段的字符串文字时,其值直到运行时才知道,您可以使用这个花括号语法在字符串文字中指定一个占位符。在运行时,传入Console.WriteLine()的值会替换每个占位符。

WriteLine()的第一个参数代表一个字符串文字,它包含由{0}{1}{2}等指定的可选占位符。请注意,花括号占位符的第一个序号总是以0开头。WriteLine()的其余参数只是插入到各自占位符中的值。

Note

如果唯一编号的花括号占位符比填充参数多,将在运行时收到格式异常。但是,如果填充参数多于占位符,未使用的填充参数将被忽略。

允许给定的占位符在给定的字符串中重复出现。例如,如果你是披头士的粉丝,想要构建字符串"9, Number 9, Number 9",你可以这样写:

// John says...
Console.WriteLine("{0}, Number {0}, Number {0}", 9);

另外,要知道可以将每个占位符放在字符串中的任何位置,并且不需要按照递增的顺序。例如,考虑下面的代码片段:

// Prints: 20, 10, 30
Console.WriteLine("{1}, {0}, {2}", 10, 20, 30);

字符串也可以使用字符串内插法格式化,这将在本章后面介绍。

格式化数字数据

如果需要对数字数据进行更精细的格式化,每个占位符可以选择包含各种格式字符。表 3-3 显示了最常见的格式化选项。

表 3-3。

.NETCore 数字格式字符

|

字符串格式字符

|

生命的意义

| | --- | --- | | Cc | 用于格式化货币。默认情况下,旗帜会将当地文化符号作为前缀(美元符号[$]代表美国英语)。 | | Dd | 用于格式化十进制数。该标志还可以指定用于填充该值的最小位数。 | | Ee | 用于指数记数法。大小写控制指数常量是大写(E)还是小写(e)。 | | Ff | 用于定点格式化。该标志还可以指定用于填充该值的最小位数。 | | Gg | 代表将军。此字符可用于将数字格式化为固定格式或指数格式。 | | Nn | 用于基本的数字格式(带逗号)。 | | Xx | 用于十六进制格式。如果您使用大写的X,您的十六进制格式也将包含大写字符。 |

这些格式字符使用冒号标记作为给定占位符值的后缀(例如,{0:C}{1:d}{2:X})。举例来说,更新Main()方法来调用名为FormatNumericalData()的新助手函数。在您的Program类中实现这个方法,以多种方式格式化一个固定的数值。

// Now make use of some format tags.
static void FormatNumericalData()
{
  Console.WriteLine("The value 99999 in various formats:");
  Console.WriteLine("c format: {0:c}", 99999);
  Console.WriteLine("d9 format: {0:d9}", 99999);
  Console.WriteLine("f3 format: {0:f3}", 99999);
  Console.WriteLine("n format: {0:n}", 99999);

  // Notice that upper- or lowercasing for hex
  // determines if letters are upper- or lowercase.
  Console.WriteLine("E format: {0:E}", 99999);
  Console.WriteLine("e format: {0:e}", 99999);
  Console.WriteLine("X format: {0:X}", 99999);
  Console.WriteLine("x format: {0:x}", 99999);
}

下面的输出显示了调用FormatNumericalData()方法的结果:

The value 99999 in various formats:

c format: $99,999.00
d9 format: 000099999
f3 format: 99999.000
n format: 99,999.00
E format: 9.999900E+004
e format: 9.999900e+004
X format: 1869F
x format: 1869f

在整篇文章中,您会看到其他需要的格式示例;但是,如果您有兴趣进一步研究字符串格式,请在。NET 核心文档。

格式化控制台应用之外的数字数据

最后要注意的是,字符串格式字符的使用不仅限于控制台程序。当调用静态string.Format()方法时,可以使用相同的格式化语法。当您需要在运行时编写文本数据以用于任何类型的应用(例如,桌面 GUI 应用、ASP.NET web 应用等)时,这很有帮助。).

string.Format()方法返回一个新的string对象,该对象根据提供的标志进行格式化。以下代码将字符串格式化为十六进制:

  // Using string.Format() to format a string literal.
  string userMessage = string.Format("100000 in hex is {0:x}", 100000);

使用系统数据类型和相应的 C# 关键字

与任何编程语言一样,C# 为基本数据类型定义了关键字,这些关键字用于表示局部变量、类数据成员变量、方法返回值和参数。然而,与其他编程语言不同,这些关键字不仅仅是简单的编译器可识别的标记。相反,C# 数据类型关键字实际上是在System名称空间中成熟类型的简写符号。表 3-4 列出了每个系统数据类型、其范围、相应的 C# 关键字以及该类型是否符合通用语言规范(CLS)。所有的系统类型都在 system 命名空间中,为了便于阅读,没有在图表中显示。

表 3-4。

C# 的内在数据类型

|

C# 速记

|

CLS 顺从吗?

|

系统类型

|

范围

|

生命的意义

| | --- | --- | --- | --- | --- | | bool | 是 | Boolean | 对还是错 | 代表真理或谬误 | | sbyte | 不 | SByte | –128 至 127 | 有符号的 8 位数字 | | byte | 是 | Byte | 0 到 255 | 无符号 8 位数 | | short | 是 | Int16 | –32768 至 32767 | 有符号的 16 位数字 | | ushort | 不 | UInt16 | 0 到 65,535 | 无符号 16 位数 | | int | 是 | Int32 | -2147483648 至 2147483647 | 有符号的 32 位数字 | | uint | 不 | UInt32 | 0 到 4,294,967,295 | 无符号 32 位数字 | | long | 是 | Int64 | -9 223 372 036 854 775 808 至 9 223 372 036 854 775 807 | 带符号的 64 位数字 | | ulong | 不 | UInt64 | 0 到 18446744073709551615 | 无符号 64 位数字 | | char | 是 | Char | U+0000 至 U+ffff | 单个 16 位 Unicode 字符 | | float | 是 | Single | –3.4 1038至+3.4 10 38 | 32 位浮点数 | | double | 是 | Double | 5.0 10–324至 1.7 10 308 | 64 位浮点数 | | decimal | 是 | Decimal | (–7.9 x 1028至 7.9 x 10 28 )/(10 0 至 28 | 128 位有符号数 | | string | 是 | String | 受系统内存限制 | 表示一组 Unicode 字符 | | object | 是 | Object | 可以在对象变量中存储任何数据类型 | 中所有类型的基类。净宇宙 |

Note

回想一下第一章中提到的 CLS 合规。NET 核心代码可以被任何其他人使用。NET 核心编程语言。如果您从您的程序中公开不符合 CLS 标准的数据,其他。NET 核心语言可能无法利用它。

理解变量声明和初始化

当声明局部变量(例如,成员范围内的变量)时,可以通过指定数据类型,后跟变量名来实现。首先,创建一个名为 BasicDataTypes 的新控制台应用项目,并使用以下命令将其添加到解决方案中:

dotnet new console -lang c# -n BasicDataTypes -o .\BasicDataTypes -f net5.0
dotnet sln .\Chapter3_AllProjects.sln add .\BasicDataTypes

将代码更新为以下内容:

using System;
using System.Numerics;

Console.WriteLine("***** Fun with Basic Data Types *****\n");

现在,添加以下静态局部函数,并从顶级语句中调用它:

static void LocalVarDeclarations()
{
  Console.WriteLine("=> Data Declarations:");
  // Local variables are declared as so:
  // dataType varName;
  int myInt;
  string myString;
  Console.WriteLine();
}

请注意,在赋值初始值之前使用局部变量是一个编译器错误。考虑到这一点,在声明时给本地数据点分配一个初始值是一个好的做法。您可以在一行中完成,也可以将声明和赋值分成两个代码语句来完成。

static void LocalVarDeclarations()
{
  Console.WriteLine("=> Data Declarations:");
  // Local variables are declared and initialized as follows:
  // dataType varName = initialValue;
  int myInt = 0;

  // You can also declare and assign on two lines.
  string myString;
  myString = "This is my character data";

  Console.WriteLine();
}

也允许在一行代码中声明同一基础类型的多个变量,如以下三个bool变量:

static void LocalVarDeclarations()
{
  Console.WriteLine("=> Data Declarations:");
  int myInt = 0;
  string myString;
  myString = "This is my character data";

  // Declare 3 bools on a single line.
  bool b1 = true, b2 = false, b3 = b1;
  Console.WriteLine();
}

由于 C# bool关键字只是System.Boolean结构的简写符号,所以也可以使用全名来分配任何数据类型(当然,对于任何 C# 数据类型关键字也是如此)。下面是LocalVarDeclarations()的最终实现,它说明了声明一个局部变量的各种方法:

static void LocalVarDeclarations()
{
  Console.WriteLine("=> Data Declarations:");
  // Local variables are declared and initialized as follows:
  // dataType varName = initialValue;
  int myInt = 0;

  string myString;
  myString = "This is my character data";

  // Declare 3 bools on a single line.
  bool b1 = true, b2 = false, b3 = b1;

  // Use System.Boolean data type to declare a bool.
  System.Boolean b4 = false;

  Console.WriteLine("Your data: {0}, {1}, {2}, {3}, {4}, {5}",
      myInt, myString, b1, b2, b3, b4);
  Console.WriteLine();
}

默认文字(新 7.1)

default文字为变量分配其数据类型的默认值。这适用于标准数据类型以及定制类(第五章和泛型类型(第十章)。创建一个名为DefaultDeclarations()的新方法,并添加以下代码:

static void DefaultDeclarations()
{
  Console.WriteLine("=> Default Declarations:");
  int myInt = default;
}

使用内部数据类型和新运算符(更新 9.0)

所有的内在数据类型都支持所谓的默认构造函数 ??(见第五章)。此功能允许您使用new关键字创建一个变量,该关键字会自动将变量设置为其默认值:

  • bool变量被设置为false

  • 数值数据被设置为0(或者在浮点数据类型的情况下设置为0.0)。

  • char变量被设置为单个空字符。

  • BigInteger变量被设置为0

  • DateTime变量被设置为1/1/0001 12:00:00 AM

  • 对象引用(包括string s)被设置为null

Note

前面列表中提到的BigInteger数据类型将在稍后解释。

尽管在创建基本数据类型变量时使用new关键字更麻烦,但下面是语法上格式良好的 C# 代码:

static void NewingDataTypes()
{
  Console.WriteLine("=> Using new to create variables:");
  bool b = new bool();              // Set to false.
  int i = new int();                // Set to 0.
  double d = new double();          // Set to 0.
  DateTime dt = new DateTime();     // Set to 1/1/0001 12:00:00 AM
  Console.WriteLine("{0}, {1}, {2}, {3}", b, i, d, dt);
  Console.WriteLine();
}

C# 9.0 增加了创建变量实例的快捷方式。这个快捷方式只是使用没有数据类型的关键字new()。这里显示的是NewingDataTypes的更新版本:

static void NewingDataTypesWith9()
{
  Console.WriteLine("=> Using new to create variables:");
  bool b = new();              // Set to false.
  int i = new();                // Set to 0.
  double d = new();          // Set to 0.
  DateTime dt = new();     // Set to 1/1/0001 12:00:00 AM
  Console.WriteLine("{0}, {1}, {2}, {3}", b, i, d, dt);
  Console.WriteLine();
}

了解数据类型类层次结构

有趣的是,即使是原始人。NET Core 数据类型被安排在一个类层次结构中。如果你是继承领域的新手,你会在第六章中发现全部细节。在此之前,只需理解位于类层次结构顶部的类型提供了一些授予派生类型的默认行为。这些核心系统类型之间的关系如图 3-2 所示。

img/340876_10_En_3_Fig2_HTML.jpg

图 3-2。

系统类型的类层次结构

注意,每个类型最终都是从System.Object派生出来的,它定义了一组方法(例如,ToString()Equals()GetHashCode()),这些方法对。NET 核心基础类库(这些方法在第六章中有详细介绍)。

还要注意,许多数字数据类型都是从名为System.ValueType的类中派生出来的。ValueType的后代被自动分配到堆栈上,因此具有可预测的生命周期,并且非常高效。另一方面,继承链中没有System.ValueType的类型(比如System.TypeSystem.StringSystem.ArraySystem.ExceptionSystem.Delegate)不会被分配到堆栈中,而是被分配到垃圾收集堆中。(你可以在第四章找到更多关于这种区别的信息。)

不要太纠结于System.ObjectSystem.ValueType的细节,只要理解因为 C# 关键字(比如int)只是对应系统类型(在本例中为System.Int32)的简写符号,下面是完全合法的语法,假设System.Int32(c#int)最终从System.Object派生而来,因此可以调用它的任何公共成员,如这个额外的助手函数所示:

static void ObjectFunctionality()
{
  Console.WriteLine("=> System.Object Functionality:");

  // A C# int is really a shorthand for System.Int32,
  // which inherits the following members from System.Object.
  Console.WriteLine("12.GetHashCode() = {0}", 12.GetHashCode());
  Console.WriteLine("12.Equals(23) = {0}", 12.Equals(23));
  Console.WriteLine("12.ToString() = {0}", 12.ToString());
  Console.WriteLine("12.GetType() = {0}", 12.GetType());
  Console.WriteLine();
}

如果您要从Main()中调用这个方法,您会发现如下所示的输出:

=> System.Object Functionality:

12.GetHashCode() = 12
12.Equals(23) = False
12.ToString() = 12
12.GetType() = System.Int32

了解数字数据类型的成员

要继续试验固有的 C# 数据类型,请理解。NET Core 支持MaxValueMinValue属性,这些属性提供关于给定类型可以存储的范围的信息。除了MinValue / MaxValue属性之外,一个给定的数值系统类型可以定义更多有用的成员。例如,System.Double类型允许您获得ε和无穷大的值(这可能会引起那些数学爱好者的兴趣)。举例来说,考虑下面的助手函数:

static void DataTypeFunctionality()
{
  Console.WriteLine("=> Data type Functionality:");

  Console.WriteLine("Max of int: {0}", int.MaxValue);
  Console.WriteLine("Min of int: {0}", int.MinValue);
  Console.WriteLine("Max of double: {0}", double.MaxValue);
  Console.WriteLine("Min of double: {0}", double.MinValue);
  Console.WriteLine("double.Epsilon: {0}", double.Epsilon);
  Console.WriteLine("double.PositiveInfinity: {0}",
    double.PositiveInfinity);
  Console.WriteLine("double.NegativeInfinity: {0}",
    double.NegativeInfinity);
  Console.WriteLine();
}

当您定义一个文字整数(比如500)时,运行时会将数据类型默认为int。同样,文字浮点数据(如55.333)将默认为double。要将底层数据类型设置为long,请使用后缀lL ( 4L)。要声明一个float变量,对原始数值(5.3F)使用后缀fF,对浮点数使用后缀mM声明一个小数(300.5M)。这在隐式声明变量时变得更加重要,这将在本章后面讨论。

了解系统成员。布尔代数学体系的

接下来,考虑System.Boolean数据类型。C# bool可以接受的唯一有效赋值是来自集合{ true || false }。鉴于这一点,应该清楚的是System.Boolean不支持MinValue / MaxValue属性集,而是支持TrueString / FalseString(分别产生字符串"True""False")。这里有一个例子:

Console.WriteLine("bool.FalseString: {0}", bool.FalseString);
Console.WriteLine("bool.TrueString: {0}", bool.TrueString);

了解系统成员。茶

C# 文本数据由关键字stringchar表示,它们是System.StringSystem.Char的简单简写符号,两者都是 Unicode。您可能已经知道,string代表一组连续的字符(例如"Hello",而char可以代表string中的一个槽(例如'H')。

除了保存单点字符数据的能力之外,System.Char类型还为您提供了大量的功能。使用System.Char的静态方法,您能够确定一个给定的字符是数字、字母、标点符号还是其他什么。考虑以下方法:

static void CharFunctionality()
{
  Console.WriteLine("=> char type Functionality:");
  char myChar = 'a';
  Console.WriteLine("char.IsDigit('a'): {0}", char.IsDigit(myChar));
  Console.WriteLine("char.IsLetter('a'): {0}", char.IsLetter(myChar));
  Console.WriteLine("char.IsWhiteSpace('Hello There', 5): {0}",
    char.IsWhiteSpace("Hello There", 5));
  Console.WriteLine("char.IsWhiteSpace('Hello There', 6): {0}",
    char.IsWhiteSpace("Hello There", 6));
  Console.WriteLine("char.IsPunctuation('?'): {0}",
    char.IsPunctuation('?'));
  Console.WriteLine();
}

如前面的方法所示,System.Char的许多成员有两个调用约定:一个单独的字符或一个带有数字索引的字符串,该数字索引指定了要测试的字符的位置。

解析字符串数据中的值

那个。NET Core 数据类型提供了在给定文本等价(例如,解析)的情况下生成其基础类型的变量的能力。当您想要将一些用户输入数据(例如从基于 GUI 的下拉列表框中选择的数据)转换成数值时,这种技术非常有用。考虑名为ParseFromStrings()的方法中的以下解析逻辑:

static void ParseFromStrings()
{
  Console.WriteLine("=> Data type parsing:");
  bool b = bool.Parse("True");
  Console.WriteLine("Value of b: {0}", b);
  double d = double.Parse("99.884");
  Console.WriteLine("Value of d: {0}", d);
  int i = int.Parse("8");
  Console.WriteLine("Value of i: {0}", i);
  char c = Char.Parse("w");
  Console.WriteLine("Value of c: {0}", c);
  Console.WriteLine();
}

使用 TryParse 解析字符串数据中的值

上述代码的一个问题是,如果字符串不能完全转换为正确的数据类型,将会引发异常。例如,以下内容将在运行时失败:

bool b = bool.Parse("Hello");

一种解决方案是将每个对Parse()的调用包装在一个try-catch块中(异常处理在第七章中有详细介绍),这可能会增加很多代码,或者使用一个TryParse()语句。TryParse()语句接受一个out参数(第四章详细介绍了out修饰符),如果解析成功,则返回一个bool。创建一个名为ParseFromStringWithTryParse()的新方法,并添加以下代码:

static void ParseFromStringsWithTryParse()
{
  Console.WriteLine("=> Data type parsing with TryParse:");
  if (bool.TryParse("True", out bool b))
  {
    Console.WriteLine("Value of b: {0}", b);
  }
  else
  {
    Console.WriteLine("Default value of b: {0}", b);
  }
  string value = "Hello";
  if (double.TryParse(value, out double d))
  {
    Console.WriteLine("Value of d: {0}", d);
  }
  else
  {
    Console.WriteLine("Failed to convert the input ({0}) to a double and the variable was assigned the default {1}", value,d);
  }
  Console.WriteLine();
}

如果你是编程新手,不知道if / else语句是如何工作的,本章后面会详细介绍。从前面的例子中需要注意的重要一点是,如果一个字符串可以被转换成所请求的数据类型,TryParse()方法返回true,并将解析后的值赋给传递给该方法的变量。如果值不能被解析,变量被赋予默认值,并且TryParse()方法返回false

使用系统。日期时间和系统。时间间隔

名称空间定义了一些没有 C# 关键字的有用的数据类型,比如结构 ?? 和 ??。(关于System.Void的调查,如图 3-2 ,我就留给感兴趣的读者吧。)

DateTime类型包含表示特定日期(月、日、年)和时间值的数据,这两种数据都可以使用提供的成员以多种方式进行格式化。TimeSpan结构允许您使用各种成员轻松定义和转换时间单位。

static void UseDatesAndTimes()
{
  Console.WriteLine("=> Dates and Times:");

  // This constructor takes (year, month, day).
  DateTime dt = new DateTime(2015, 10, 17);

  // What day of the month is this?
  Console.WriteLine("The day of {0} is {1}", dt.Date, dt.DayOfWeek);

  // Month is now December.
  dt = dt.AddMonths(2);
  Console.WriteLine("Daylight savings: {0}", dt.IsDaylightSavingTime());

  // This constructor takes (hours, minutes, seconds).
  TimeSpan ts = new TimeSpan(4, 30, 0);
  Console.WriteLine(ts);

  // Subtract 15 minutes from the current TimeSpan and
  // print the result.
  Console.WriteLine(ts.Subtract(new TimeSpan(0, 15, 0)));
}

与系统一起工作。数字命名空间

System.Numerics名称空间定义了一个名为BigInteger的结构。顾名思义,BigInteger数据类型可以在需要表示巨大的数值时使用,这些数值不受固定上限或下限的约束。

Note

System.Numerics名称空间定义了第二个名为Complex的结构,它允许您对复杂的数字数据进行数学建模(例如,虚数、实数、双曲正切)。请参考。NET 核心文档。

而你们中的许多人。NET 核心应用可能永远不需要使用BigInteger结构,如果您发现需要定义大量数值,您的第一步是将下面的using指令添加到文件中:

// BigInteger lives here!
using System.Numerics;

此时,您可以使用new操作符创建一个BigInteger变量。在构造函数中,可以指定一个数值,包括浮点数据。然而,C# 隐式地将非浮点数类型化为int,将浮点数类型化为double。那么,如何将BigInteger设置为一个巨大的值,同时又不会溢出用于原始数值的默认数据类型呢?

最简单的方法是将大量数值建立为文本文字,可以通过静态的Parse()方法将其转换为BigInteger变量。如果需要的话,你也可以将一个字节数组直接传递给BigInteger类的构造函数。

Note

在你给一个BigInteger变量赋值后,你不能改变它,因为数据是不可变的。然而,BigInteger类定义了许多成员,这些成员将根据您的数据修改返回新的BigInteger对象(例如下面代码示例中使用的静态Multiply()方法)。

在任何情况下,在你定义了一个BigInteger变量之后,你会发现这个类定义了类似的成员作为其他内在的 C# 数据类型(例如floatint)。此外,BigInteger类定义了几个静态成员,允许您将基本的数学表达式(如加法和乘法)应用于BigInteger变量。下面是一个使用BigInteger类的例子:

static void UseBigInteger()
{
  Console.WriteLine("=> Use BigInteger:");
  BigInteger biggy =
    BigInteger.Parse("9999999999999999999999999999999999999999999999");
  Console.WriteLine("Value of biggy is {0}", biggy);
  Console.WriteLine("Is biggy an even value?: {0}", biggy.IsEven);
  Console.WriteLine("Is biggy a power of two?: {0}", biggy.IsPowerOfTwo);
  BigInteger reallyBig = BigInteger.Multiply(biggy,
    BigInteger.Parse("8888888888888888888888888888888888888888888"));
  Console.WriteLine("Value of reallyBig is {0}", reallyBig);
}

同样重要的是要注意到,BigInteger数据类型响应 C# 固有的数学运算符,如+-*。因此,您可以编写以下代码,而不是调用BigInteger.Multiply()将两个巨大的数字相乘:

BigInteger reallyBig2 = biggy * reallyBig;

至此,我希望您理解表示基本数据类型的 C# 关键字在。NET 核心基类库,每个都公开一个固定的功能。虽然我没有详细介绍这些数据类型的每个成员,但是您可以根据自己的需要深入研究这些细节。请务必查阅。NET 核心文档,了解有关各种。NET 数据类型—您可能会对内置功能的数量感到惊讶。

使用数字分隔符(新 7.0)

有时,当给一个数值变量分配一个大的数字时,数字的数量会超过肉眼所能看到的数量。C# 7.0 引入了下划线(_)作为数字分隔符(用于integerlongdecimaldouble数据或十六进制类型)。C# 7.2 允许十六进制值(以及接下来介绍的新的二进制文字,在开始声明后以下划线开头)。以下是使用新数字分隔符的示例:

static void DigitSeparators()
{
  Console.WriteLine("=> Use Digit Separators:");
  Console.Write("Integer:");
  Console.WriteLine(123_456);
  Console.Write("Long:");
  Console.WriteLine(123_456_789L);
  Console.Write("Float:");
  Console.WriteLine(123_456.1234F);
  Console.Write("Double:");
  Console.WriteLine(123_456.12);
  Console.Write("Decimal:");
  Console.WriteLine(123_456.12M);
  //Updated in 7.2, Hex can begin with _
  Console.Write("Hex:");
  Console.WriteLine(0x_00_00_FF);
}

使用二进制文字(新的 7.0/7.2)

C# 7.0 为二进制值引入了新的文字,例如,用于创建位掩码。新的数字分隔符适用于二进制文字,C# 7.2 允许二进制和十六进制数字以下划线开头。现在,二进制数可以像你想的那样书写。这里有一个例子:

0b_0001_0000

下面是一个方法,显示了如何使用带有数字分隔符的新文字:

 static void BinaryLiterals()
{
  //Updated in 7.2, Binary can begin with _
  Console.WriteLine("=> Use Binary Literals:");
  Console.WriteLine("Sixteen: {0}",0b_0001_0000);
  Console.WriteLine("Thirty Two: {0}",0b_0010_0000);
  Console.WriteLine("Sixty Four: {0}",0b_0100_0000);
}

使用字符串数据

System.String提供了许多您期望从这样一个实用程序类中得到的方法,包括返回字符数据长度、在当前字符串中查找子字符串以及在大写/小写之间进行转换的方法。表 3-5 列出了一些(但绝不是全部)有趣的成员。

表 3-5。

选择系统成员。线

|

字符串成员

|

生命的意义

| | --- | --- | | Length | 该属性返回当前字符串的长度。 | | Compare() | 这个静态方法比较两个字符串。 | | Contains() | 此方法确定字符串是否包含特定的子字符串。 | | Equals() | 此方法测试两个 string 对象是否包含相同的字符数据。 | | Format() | 这个静态方法使用其他原语(例如,数字数据、其他字符串)和本章前面讨论过的{0}符号来格式化字符串。 | | Insert() | 此方法在给定的字符串中插入一个字符串。 | | PadLeft() \ PadRight() | 这些方法用于用一些字符填充字符串。 | | Remove() \ Replace() | 这些方法用于接收经过修改(字符被删除或替换)的字符串副本。 | | Split() | 该方法返回一个包含该实例中子字符串的String数组,这些子字符串由指定的char数组或string数组的元素分隔。 | | Trim() | 此方法从当前字符串的开头和结尾移除一组指定字符的所有匹配项。 | | ToUpper() \ ToLower() | 这些方法分别以大写或小写格式创建当前字符串的副本。 |

执行基本的字符串操作

System.String的成员一起工作正如你所料。只需声明一个string变量,并通过点运算符使用提供的功能。请注意,System.String的一些成员是静态成员,因此在类(而不是对象)级别被调用。

假设您已经创建了一个名为 FunWithStrings 的新控制台应用项目,并将其添加到您的解决方案中。清除现有代码并添加以下内容:

using System;
using System.Text;
BasicStringFunctionality();

static void BasicStringFunctionality()
{
  Console.WriteLine("=> Basic String functionality:");
  string firstName = "Freddy";
  Console.WriteLine("Value of firstName: {0}", firstName);
  Console.WriteLine("firstName has {0} characters.", firstName.Length);
  Console.WriteLine("firstName in uppercase: {0}", firstName.ToUpper());
  Console.WriteLine("firstName in lowercase: {0}", firstName.ToLower());
  Console.WriteLine("firstName contains the letter y?: {0}",
    firstName.Contains("y"));
  Console.WriteLine("New first name: {0}", firstName.Replace("dy", ""));
  Console.WriteLine();
}

这里没有太多要说的,因为这个方法只是在一个本地string变量上调用各种成员,比如ToUpper()Contains(),以产生各种格式和转换。以下是初始输出:

***** Fun with Strings *****

=> Basic String functionality:
Value of firstName: Freddy
firstName has 6 characters.
firstName in uppercase: FREDDY
firstName in lowercase: freddy
firstName contains the letter y?: True
firstName after replace: Fred

虽然这个输出看起来不太令人惊讶,但是通过调用Replace()方法看到的输出有点误导。实际上,firstName变量一点都没变;相反,你会收到一个修改后的新的string。过一会儿,您将重新审视字符串不可变的本质。

执行字符串串联

String变量可以通过 C# +(以及+=)操作符连接起来构建更大的string。如你所知,这种技术被正式命名为字符串连接。考虑以下新的助手函数:

static void StringConcatenation()
{
  Console.WriteLine("=> String concatenation:");
  string s1 = "Programming the ";
  string s2 = "PsychoDrill (PTP)";
  string s3 = s1 + s2;
  Console.WriteLine(s3);
  Console.WriteLine();
}

您可能有兴趣知道 C# +符号是由编译器处理的,以发出对静态String.Concat()方法的调用。考虑到这一点,可以通过直接调用String.Concat()来执行字符串连接,如下面这个方法的修改版本所示(尽管这样做并没有带来任何好处——事实上,您已经招致了额外的击键!):

static void StringConcatenation()
{
  Console.WriteLine("=> String concatenation:");
  string s1 = "Programming the ";
  string s2 = "PsychoDrill (PTP)";
  string s3 = String.Concat(s1, s2);
  Console.WriteLine(s3);
  Console.WriteLine();
}

使用转义字符

与其他基于 C 的语言一样,C# 字符串文字可能包含各种转义字符,这些字符限定了字符数据应该如何输出到输出流。每个转义字符都以反斜杠开头,后跟一个特定的标记。如果你对这些转义字符背后的含义有点生疏,表 3-6 列出了更常见的选项。

表 3-6。

字符串转义字符

|

性格;角色;字母

|

生命的意义

| | --- | --- | | \' | 在字符串中插入单引号。 | | \" | 在字符串中插入双引号。 | | \\ | 在字符串中插入反斜杠。这在定义文件或网络路径时非常有用。 | | \a | 触发系统警报(嘟嘟声)。对于控制台程序,这可以是给用户的音频提示。 | | \n | 插入新行(在 Windows 平台上)。 | | \r | 插入一个回车。 | | \t | 在字符串中插入水平制表符。 |

例如,要打印每个单词之间包含一个制表符的字符串,可以使用\t转义字符。或者假设您想要创建一个包含引号的字符串文字,另一个定义目录路径,最后一个在打印字符数据后插入三个空行的字符串文字。要做到这一点而不出现编译器错误,您需要使用\"\\\n转义字符。此外,为了骚扰你周围 10 英尺范围内的任何人,请注意,我在每个字符串文字中嵌入了一个警报(触发一个嘟嘟声)。请考虑以下几点:

static void EscapeChars()
{
  Console.WriteLine("=> Escape characters:\a");
  string strWithTabs = "Model\tColor\tSpeed\tPet Name\a ";
  Console.WriteLine(strWithTabs);

  Console.WriteLine("Everyone loves \"Hello World\"\a ");
  Console.WriteLine("C:\\MyApp\\bin\\Debug\a ");

  // Adds a total of 4 blank lines (then beep again!).
  Console.WriteLine("All finished.\n\n\n\a ");
  Console.WriteLine();
}

执行字符串插值

本章中说明的花括号语法({0}{1}等)。)已经存在于。NET 平台从 1.0 版本开始。从 C# 6 开始,C# 程序员可以使用另一种语法来构建包含变量占位符的字符串文字。形式上,这被称为字符串插值。虽然该操作的输出与传统的字符串格式语法相同,但这种新方法允许您直接嵌入变量本身,而不是将它们作为逗号分隔的列表附加上去。

考虑您的Program类(StringInterpolation())的以下附加方法,它使用每种方法构建一个string变量:

static void StringInterpolation()
{
    Console.WriteLine("=> String interpolation:\a");

    // Some local variables we will plug into our larger string
    int age = 4;
    string name = "Soren";

    // Using curly-bracket syntax.
    string greeting = string.Format("Hello {0} you are {1} years old.", name, age);
    Console.WriteLine(greeting);

    // Using string interpolation
    string greeting2 = $"Hello {name} you are {age} years old.";
    Console.WriteLine(greeting2);
}

greeting2变量中,注意您正在构建的字符串是如何以美元符号($)前缀开始的。接下来,注意花括号仍然用于标记变量占位符;但是,您可以将变量直接放入作用域中,而不是使用数字标记。假定的优点是,这种新的格式化语法更容易以线性(从左到右)方式阅读,因为您不需要“跳到末尾”来查看运行时要插入的值列表。

这种新语法还有一个有趣的方面:字符串插值中使用的花括号是一个有效的作用域。因此,您可以对变量使用点操作来更改它们的状态。考虑对每个汇编的string变量进行更新。

string greeting = string.Format("Hello {0} you are {1} years old.", name.ToUpper(), age);
string greeting2 = $"Hello {name.ToUpper()} you are {age} years old.";

在这里,我通过调用ToUpper()将名称大写。请注意,在字符串插值方法中,您在调用该方法时不需要而不是添加分号终止符。鉴于此,您不能将花括号作用域用作包含多行可执行代码的完全成熟的方法作用域。相反,您可以使用点运算符调用对象上的单个成员,并定义一个简单的通用表达式,如{age += 1}

同样值得注意的是,在这个新语法中,您仍然可以在字符串中使用转义字符。因此,如果您想插入一个制表符,您可以将一个\t标记作为前缀,如下所示:

string greeting = string.Format("\tHello {0} you are {1} years old.", name.ToUpper(), age);
string greeting2 = $"\tHello {name.ToUpper()} you are {age} years old.";

定义逐字字符串(更新 8.0)

当您在一个字符串前面加上@符号时,您就创建了一个被称为的逐字字符串。使用逐字字符串,您可以禁用对文字转义字符的处理,并按原样打印出一个string。这在使用代表目录和网络路径的string时非常有用。因此,您可以简单地编写以下代码,而不是使用\\转义字符:

// The following string is printed verbatim,
// thus all escape characters are displayed.
Console.WriteLine(@"C:\MyApp\bin\Debug");

另请注意,逐字字符串可以用于保留多行字符串的空白。

// Whitespace is preserved with verbatim strings.
string myLongString = @"This is a very
     very
          very
               long string";
Console.WriteLine(myLongString);

使用逐字字符串,您还可以通过将"标记加倍来直接将双引号插入到文字字符串中。

Console.WriteLine(@"Cerebus said ""Darrr! Pret-ty sun-sets""");

通过指定插值运算符($)和逐字运算符(@),逐字字符串也可以是插值字符串。

string interp = "interpolation";
string myLongString2 = $@"This is a very
   very
         long string with {interp}";

这是 C# 8 中的新特性,顺序无关紧要。使用$@@$都可以。

使用字符串和等式

正如将在第四章中全面解释的那样,引用类型是在垃圾收集托管堆上分配的对象。默认情况下,当您对引用类型执行相等测试时(通过 C# ==!=操作符),如果引用指向内存中的同一个对象,您将返回true。然而,即使string数据类型确实是一个引用类型,相等操作符已经被重新定义来比较string对象的,而不是它们引用的内存中的对象。

static void StringEquality()
{
  Console.WriteLine("=> String equality:");
  string s1 = "Hello!";
  string s2 = "Yo!";
  Console.WriteLine("s1 = {0}", s1);
  Console.WriteLine("s2 = {0}", s2);
  Console.WriteLine();

  // Test these strings for equality.
  Console.WriteLine("s1 == s2: {0}", s1 == s2);
  Console.WriteLine("s1 == Hello!: {0}", s1 == "Hello!");
  Console.WriteLine("s1 == HELLO!: {0}", s1 == "HELLO!");
  Console.WriteLine("s1 == hello!: {0}", s1 == "hello!");
  Console.WriteLine("s1.Equals(s2): {0}", s1.Equals(s2));
  Console.WriteLine("Yo!.Equals(s2): {0}", "Yo!".Equals(s2));
  Console.WriteLine();
}

默认情况下,C# 相等运算符对string对象执行区分大小写、不区分区域性、逐个字符的相等测试。因此,"Hello!"不等于"HELLO!",这也与"hello!"不同。此外,记住stringSystem.String之间的联系,注意您可以使用StringEquals()方法以及内置的等式操作符来测试等式。最后,假设每个字符串文字(比如"Yo!")都是一个有效的System.String实例,那么您就能够从固定的字符序列中访问以字符串为中心的功能。

修改字符串比较行为

如上所述,字符串相等运算符(Compare()Equals()==)以及IndexOf()函数在默认情况下是区分大小写和不区分文化的。如果您的程序不关心大小写,这可能会导致问题。克服这个问题的一个方法是将所有内容转换为大写或小写,然后进行比较,如下所示:

if (firstString.ToUpper() == secondString.ToUpper())
{
  //Do something
}

这将复制所有小写字母的每个字符串。在大多数情况下,这可能不是问题,但如果字符串非常大,可能会影响性能。就算不是性能问题,每次写都有点痛苦。如果你忘记打电话给ToUpper()怎么办?这可能会导致程序中出现难以发现的错误。

一个更好的实践是使用前面列出的方法的重载,这些重载接受一个StringComparison枚举的值来精确地控制比较是如何完成的。表 3-7 描述了StringComparison值。

表 3-7。

StringComparison 枚举的值

|

C# 等式/关系运算符

|

生命的意义

| | --- | --- | | CurrentCulture | 使用区分区域性的排序规则和当前区域性比较字符串 | | CurrentCultureIgnoreCase | 使用区分区域性的排序规则和当前区域性比较字符串,并忽略被比较字符串的大小写 | | InvariantCulture | 使用区分区域性的排序规则和固定区域性比较字符串 | | InvariantCultureIgnoreCase | 使用区分区域性的排序规则和固定区域性比较字符串,并忽略被比较字符串的大小写 | | Ordinal | 使用序数(二进制)排序规则比较字符串 | | OrdinalIgnoreCare | 使用序数(二进制)排序规则比较字符串,并忽略被比较字符串的大小写 |

要查看使用StringComparison选项的效果,创建一个名为StringEqualitySpecifyingCompareRules()的新方法,并添加以下代码:

static void StringEqualitySpecifyingCompareRules()
{
  Console.WriteLine("=> String equality (Case Insensitive:");
  string s1 = "Hello!";
  string s2 = "HELLO!";
  Console.WriteLine("s1 = {0}", s1);
  Console.WriteLine("s2 = {0}", s2);
  Console.WriteLine();

  // Check the results of changing the default compare rules.
  Console.WriteLine("Default rules: s1={0},s2={1}s1.Equals(s2): {2}", s1, s2, s1.Equals(s2));
  Console.WriteLine("Ignore case: s1.Equals(s2, StringComparison.OrdinalIgnoreCase): {0}",
    s1.Equals(s2, StringComparison.OrdinalIgnoreCase));
  Console.WriteLine("Ignore case, Invariant Culture: s1.Equals(s2, StringComparison.InvariantCultureIgnoreCase): {0}",
    s1.Equals(s2, StringComparison.InvariantCultureIgnoreCase));
  Console.WriteLine();
  Console.WriteLine("Default rules: s1={0},s2={1} s1.IndexOf(\"E\"): {2}", s1, s2, s1.IndexOf("E"));
  Console.WriteLine("Ignore case: s1.IndexOf(\"E\", StringComparison.OrdinalIgnoreCase): {0}", s1.IndexOf("E",
    StringComparison.OrdinalIgnoreCase));
  Console.WriteLine("Ignore case, Invariant Culture: s1.IndexOf(\"E\", StringComparison.InvariantCultureIgnoreCase): {0}",
    s1.IndexOf("E", StringComparison.InvariantCultureIgnoreCase));
  Console.WriteLine();
}

虽然这里的例子很简单,并且在大多数文化中使用相同的字母,但是如果您的应用需要考虑不同的文化集,那么使用StringComparison选项是必须的。

字符串是不可变的

System.String的一个有趣的方面是,在你给一个string对象赋了初始值之后,角色数据就不能被改变。乍一看,这似乎是一个彻头彻尾的谎言,因为您总是将字符串重新分配给新值,并且因为System.String类型定义了许多方法,这些方法似乎以某种方式修改字符数据(例如大写和小写)。然而,如果你更仔细地观察幕后发生的事情,你会注意到string类型的方法实际上是以修改后的格式返回给你一个新的string对象。

static void StringsAreImmutable()
{
    Console.WriteLine("=> Immutable Strings:\a");
  // Set initial string value.
  string s1 = "This is my string.";
  Console.WriteLine("s1 = {0}", s1);

  // Uppercase s1?
  string upperString = s1.ToUpper();
  Console.WriteLine("upperString = {0}", upperString);

  // Nope! s1 is in the same format!
  Console.WriteLine("s1 = {0}", s1);
}

如果您检查下面的相关输出,您可以验证原始的string对象(s1)在调用ToUpper()时没有大写。相反,你会得到一个经过修改的string副本

s1 = This is my string.

upperString = THIS IS MY STRING.
s1 = This is my string.

当您使用 C# 赋值操作符时,不变性法则同样适用。举例来说,实现下面的StringsAreImmutable2()方法:

static void StringsAreImmutable2()
{
    Console.WriteLine("=> Immutable Strings 2:\a");
  string s2 = "My other string";
  s2 = "New string value";
}

现在,编译你的应用并运行ildasm.exe(参见第一章)。下面的输出显示了如果您要为StringsAreImmutable2()方法生成 CIL 代码,您会发现什么:

.method private hidebysig static void  StringsAreImmutable2() cil managed

{
  // Code size       21 (0x15)
  .maxstack  1
  .locals init (string V_0)
  IL_0000:  nop
  IL_0001:  ldstr      "My other string"
  IL_0006:  stloc.0
  IL_0007:  ldstr      "New string value" /* 70000B3B */
  IL_000c:  stloc.0
  IL_000d:  ldloc.0
  IL_0013:  nop
  IL_0014:  ret
} // end of method Program::StringsAreImmutable2

尽管您还没有检查 CIL 的底层细节,但是请注意对ldstr (load string)操作码的大量调用。简单地说,CIL 的ldstr操作码在托管堆上加载一个新的string对象。包含值"My other string"的前一个string对象最终将被垃圾回收。

那么,你从这种洞察力中能收集到什么呢?简而言之,string类可能效率低下,如果误用会导致代码膨胀,尤其是在执行字符串连接或处理大量文本数据时。如果您需要表示基本的字符数据,比如美国社会保险号、名字或姓氏,或者应用中使用的简单文本,那么string类是最佳选择。

然而,如果您正在构建一个大量使用频繁变化的文本数据的应用(比如一个字处理程序),那么使用string对象来表示字处理数据将是一个坏主意,因为您将最有可能(并且经常是间接地)最终制作不必要的字符串数据副本。那么,程序员要做什么呢?很高兴你问了。

使用系统。Text.StringBuilder 类型

鉴于string类型在鲁莽使用时可能效率低下,因此。NET 核心基类库提供了System.Text命名空间。在这个(相对较小的)名称空间中有一个名为StringBuilder的类。例如,像System.String类一样,StringBuilder定义了允许你替换或格式化段的方法。当您希望在 C# 代码文件中使用此类型时,第一步是确保将以下命名空间导入到代码文件中(对于新的 Visual Studio 项目,这应该已经是这样的情况):

// StringBuilder lives here!
using System.Text;

StringBuilder的独特之处在于,当您调用这种类型的成员时,您是在直接修改对象的内部字符数据(使其更有效),而不是以修改后的格式获得数据的副本。当您创建一个StringBuilder的实例时,您可以通过许多构造函数中的一个来提供对象的初始启动值。如果你是构造函数的新手,只需理解构造函数允许你在应用new关键字时创建一个具有初始状态的对象。考虑StringBuilder的以下用法:

static void FunWithStringBuilder()
{
  Console.WriteLine("=> Using the StringBuilder:");
  StringBuilder sb = new StringBuilder("**** Fantastic Games ****");
  sb.Append("\n");
  sb.AppendLine("Half Life");
  sb.AppendLine("Morrowind");
  sb.AppendLine("Deus Ex" + "2");
  sb.AppendLine("System Shock");
  Console.WriteLine(sb.ToString());
  sb.Replace("2", " Invisible War");
  Console.WriteLine(sb.ToString());
  Console.WriteLine("sb has {0} chars.", sb.Length);
  Console.WriteLine();
}

这里,我构造了一个设置为初始值"**** Fantastic Games ****"StringBuilder。正如你所看到的,我附加到内部缓冲区,并能够随意替换或删除字符。默认情况下,StringBuilder最初只能容纳 16 个字符或更少的字符串(但是如果需要会自动扩展);但是,这个默认的起始值可以通过一个额外的构造函数参数来更改。

// Make a StringBuilder with an initial size of 256.
StringBuilder sb = new StringBuilder("**** Fantastic Games ****", 256);

如果您添加的字符超过了指定的限制,StringBuilder对象会将其数据复制到一个新的实例中,并按照指定的限制增加缓冲区。

缩小和扩大数据类型转换

既然您已经理解了如何使用内部 C# 数据类型,那么让我们来研究一下相关的主题数据类型转换。假设您有一个名为 TypeConversions 的新控制台应用项目,并将其添加到您的解决方案中。更新代码以匹配以下内容:

using System;

Console.WriteLine("***** Fun with type conversions *****");

// Add two shorts and print the result.
short numb1 = 9, numb2 = 10;
Console.WriteLine("{0} + {1} = {2}",
  numb1, numb2, Add(numb1, numb2));
Console.ReadLine();

static int Add(int x, int y)
{
  return x + y;
}

注意,Add()方法期望被发送两个int参数。然而,调用代码实际上发送了两个short变量。虽然这看起来像是数据类型完全不匹配,但程序编译和执行时没有错误,返回预期的结果 19。

编译器认为这段代码在语法上是正确的,因为它不可能丢失数据。鉴于 a 的最大值short (32,767)正好在 a 的最大范围int (2,147,483,647)内,编译器隐式地将每个short加宽为int。正式来说,加宽是用来定义不会导致数据丢失的隐式向上转换的术语。

Note

中查找“类型转换表”。NET Core 文档,如果您想了解每种 C# 数据类型允许的扩大(和缩小,下面讨论)转换。

虽然在前面的例子中,这种隐式扩展对您有利,但在其他时候,这种“特性”可能是编译时错误的来源。例如,假设您已经设置了numb1numb2的值,当它们加在一起时,溢出了short的最大值。另外,假设您将Add()方法的返回值存储在一个新的本地short变量中,而不是直接将结果打印到控制台。

static void Main(string[] args)
{
  Console.WriteLine("***** Fun with type conversions *****");

  // Compiler error below!
  short numb1 = 30000, numb2 = 30000;
  short answer = Add(numb1, numb2);

  Console.WriteLine("{0} + {1} = {2}",
    numb1, numb2, answer);
  Console.ReadLine();
}

在这种情况下,编译器会报告以下错误:

Cannot implicitly convert type 'int' to 'short'. An explicit conversion exists (are you missing a cast?)

问题是,尽管Add()方法能够返回值为 60,000 的int(这在System.Int32的范围内),但是该值不能存储在short中,因为它溢出了该数据类型的界限。从形式上讲,CoreCLR 无法应用缩小操作。正如您所猜测的,缩小与扩大在逻辑上是相反的,因为较大的值存储在较小的数据类型变量中。

需要指出的是,所有收缩转换都会导致编译器错误,即使您可以推断收缩转换确实应该成功。例如,以下代码也会导致编译器错误:

// Another compiler error!
static void NarrowingAttempt()
{
  byte myByte = 0;
  int myInt = 200;
  myByte = myInt;

  Console.WriteLine("Value of myByte: {0}", myByte);
}

这里,int变量(myInt)中包含的值安全地在一个byte的范围内;因此,您可能希望收缩操作不会导致运行时错误。然而,考虑到 C# 是一种考虑到类型安全的语言,您确实会收到一个编译器错误。

当您想要通知编译器您愿意处理由于缩小操作而可能丢失的数据时,您必须使用 C# 强制转换运算符()应用显式强制转换。考虑下面对Program类型的更新:

class Program
{
  static void Main(string[] args)
  {
    Console.WriteLine("***** Fun with type conversions *****");
    short numb1 = 30000, numb2 = 30000;

    // Explicitly cast the int into a short (and allow loss of data).
    short answer = (short)Add(numb1, numb2);

    Console.WriteLine("{0} + {1} = {2}",
      numb1, numb2, answer);
    NarrowingAttempt();
    Console.ReadLine();
}

  static int Add(int x, int y)
{
    return x + y;
}

  static void NarrowingAttempt()
{
    byte myByte = 0;
    int myInt = 200;

    // Explicitly cast the int into a byte (no loss of data).
    myByte = (byte)myInt;
    Console.WriteLine("Value of myByte: {0}", myByte);
  }
}

此时,代码编译完毕;但是,相加的结果是完全不正确的。

***** Fun with type conversions *****

30000 + 30000 = -5536
Value of myByte: 200

正如您刚刚看到的,显式强制转换允许您强制编译器应用收缩转换,即使这样做可能会导致数据丢失。在使用NarrowingAttempt()方法的情况下,这不是一个问题,因为值 200 可以恰好在byte的范围内。但是在Main()内的两个short相加的情况下,最终结果是完全不能接受的(30000+30000 =–5536?).

如果您正在构建一个数据丢失总是不可接受的应用,C# 提供了checkedunchecked关键字来确保数据丢失不会不被发现。

使用选中的关键字

让我们从学习关键字checked的作用开始。假设您在Program中有一个新方法,它试图添加两个byte,每个都被赋予一个低于最大值(255)的安全值。如果您要将这些类型的值相加(将返回的int转换为byte,您会认为结果将是每个成员的精确总和。

static void ProcessBytes()
{
  byte b1 = 100;
  byte b2 = 250;
  byte sum = (byte)Add(b1, b2);

  // sum should hold the value 350\. However, we find the value 94!
  Console.WriteLine("sum = {0}", sum);
}

如果您要查看这个应用的输出,您可能会惊讶地发现sum包含值 94(而不是预期的 350)。原因很简单。假定System.Byte只能保存 0 到 255 之间的值(包括 0 和 255,总共 256 个槽),sum现在包含溢出值(350–256 = 94)。默认情况下,如果您不采取纠正措施,上溢/下溢情况会正确发生。

要处理应用中的上溢或下溢情况,有两种选择。您的第一选择是利用您的智慧和编程技能来手动处理所有上溢/下溢情况。当然,这种技术的问题是一个简单的事实,即你是人,即使你尽了最大的努力也可能导致你没有注意到的错误。

谢天谢地,C# 提供了checked关键字。当您在checked关键字的范围内包装一个语句(或一个语句块)时,C# 编译器会发出额外的 CIL 指令来测试两个数字数据类型的加、乘、减或除时可能导致的溢出情况。

如果发生溢出,您将收到一个运行时异常:System.OverflowException。第七章将研究结构化异常处理的所有细节以及trycatch关键字的使用。在这一点上不要太纠结于细节,观察下面的更新:

static void ProcessBytes()
{
  byte b1 = 100;
  byte b2 = 250;

  // This time, tell the compiler to add CIL code
  // to throw an exception if overflow/underflow
  // takes place.
  try
  {
    byte sum = checked((byte)Add(b1, b2));
    Console.WriteLine("sum = {0}", sum);
  }
  catch (OverflowException ex)
  {
    Console.WriteLine(ex.Message);
  }
}

注意,Add()的返回值已经被包装在checked关键字的范围内。因为总和大于一个byte,这触发了一个运行时异常。注意通过Message属性打印出来的错误消息。

Arithmetic operation resulted in an overflow.

如果希望对代码语句块强制进行溢出检查,可以通过定义如下的“检查范围”来实现:

try
{
  checked
  {
    byte sum = (byte)Add(b1, b2);
    Console.WriteLine("sum = {0}", sum);
  }
}
catch (OverflowException ex)
{
  Console.WriteLine(ex.Message);
}

在这两种情况下,将自动评估有问题的代码是否存在可能的溢出条件,如果遇到这种情况,将触发溢出异常。

设置项目范围的溢出检查

如果您正在创建一个永远不允许静默溢出发生的应用,您可能会发现自己处于一个恼人的位置,即在 checked 关键字的范围内包装许多行代码。作为替代,C# 编译器支持/checked标志。当它被启用时,你所有的算法都将被计算溢出,而不需要使用 C# checked关键字。如果已经发现溢出,您仍然会收到一个运行时异常。要为整个项目设置此项,请在项目文件中输入以下内容:

<PropertyGroup>
    <CheckForOverflowUnderflow>true</CheckForOverflowUnderflow>
</PropertyGroup>

设置项目范围的溢出检查(Visual Studio)

若要使用 Visual Studio 启用此标志,请打开项目的属性页。选择所有配置,然后单击“构建”选项卡上的“高级”按钮。从出现的对话框中,选择“检查算术溢出/下溢”复选框(见图 3-3 )。在创建调试版本时,启用此设置会很有帮助。在所有溢出异常都被挤出代码库之后,您可以为后续构建禁用/checked标志(这可以提高应用的运行时性能)。

img/340876_10_En_3_Fig3_HTML.jpg

图 3-3。

启用项目范围的溢出/下溢数据检查

Note

如果不选择所有配置,则该设置将仅应用于当前选定的配置(例如,调试、发布)

使用未检查的关键字

现在,假设您已经启用了这个项目范围的设置,如果您有一个数据丢失可接受的代码块,您该怎么办?考虑到/checked标志将评估所有的算术逻辑,C# 提供了unchecked关键字来根据具体情况禁止抛出溢出异常。该关键字的用法与checked关键字的用法相同,因为您可以指定一条语句或一组语句。

// Assuming /checked is enabled,
// this block will not trigger
// a runtime exception.
unchecked
{
  byte sum = (byte)(b1 + b2);
  Console.WriteLine("sum = {0} ", sum);
}

所以,总结一下 C# checkedunchecked关键字,记住。NET 核心运行时忽略算术上溢/下溢。当你想有选择地处理离散语句时,使用checked关键字。如果您想在整个应用中捕获溢出错误,启用/checked标志。最后,如果您有一个溢出是可接受的代码块(因此不应该触发运行时异常),可以使用unchecked关键字。

理解隐式类型的局部变量

直到本章的这一点,当你已经定义了局部变量,你已经明确地指定了每个变量的底层数据类型。

static void DeclareExplicitVars()
{
  // Explicitly typed local variables
  // are declared as follows:
  // dataType variableName = initialValue;
  int myInt = 0;
  bool myBool = true;
  string myString = "Time, marches on...";
}

虽然许多人认为显式指定每个变量的数据类型通常是一种好的做法,但 C# 语言确实提供了使用var关键字隐式键入局部变量的。var关键字可以用来代替指定特定的数据类型(例如intboolstring)。当您这样做时,编译器将根据用于初始化本地数据点的初始值自动推断基础数据类型。

为了说明隐式类型的作用,创建一个名为 ImplicitlyTypedLocalVars 的新控制台应用项目,并将其添加到您的解决方案中。将Program.cs中的代码更新如下:

using System;
using System.Linq;

Console.WriteLine("***** Fun with Implicit Typing *****");

添加以下函数来演示隐式声明:

static void DeclareImplicitVars()
{
  // Implicitly typed local variables
  // are declared as follows:
  // var variableName = initialValue;
  var myInt = 0;
  var myBool = true;
  var myString = "Time, marches on...";
}

Note

严格来说,var不是 C# 关键字。允许在没有编译时错误的情况下声明名为var的变量、参数和字段。然而,当var标记被用作数据类型时,编译器会根据上下文将其视为关键字。

在这种情况下,给定初始赋值,编译器能够推断出myInt实际上是一个System.Int32myBool是一个System.BooleanmyString确实是类型System.String。您可以通过反射打印类型名来验证这一点。正如你将在第十七章中看到的更多细节,反射是在运行时确定一个类型的组成的行为。例如,使用反射,可以确定隐式类型化局部变量的数据类型。使用以下代码语句更新您的方法:

static void DeclareImplicitVars()
{
  // Implicitly typed local variables.
  var myInt = 0;
  var myBool = true;
  var myString = "Time, marches on...";

  // Print out the underlying type.
  Console.WriteLine("myInt is a: {0}", myInt.GetType().Name);
  Console.WriteLine("myBool is a: {0}", myBool.GetType().Name);
  Console.WriteLine("myString is a: {0}", myString.GetType().Name);
}

Note

请注意,您可以对任何类型使用这种隐式类型,包括数组、泛型类型(参见第十章)和您自己的自定义类型。在本书的过程中,你会看到其他隐式类型的例子。

如果您从顶层语句中调用DeclareImplicitVars()方法,您会发现如下所示的输出:

***** Fun with Implicit Typing *****

myInt is a: Int32
myBool is a: Boolean
myString is a: String

隐式声明数字

如前所述,整数默认为整数,浮点数默认为双精度。创建一个名为DeclareImplicitNumerics的新方法,并添加以下代码来演示 numerics 的隐式声明:

static void DeclareImplicitNumerics()
{
  // Implicitly typed numeric variables.
  var myUInt = 0u;
  var myInt = 0;
  var myLong = 0L;
  var myDouble = 0.5;
  var myFloat = 0.5F;
  var myDecimal = 0.5M;

  // Print out the underlying type.
  Console.WriteLine("myUInt is a: {0}", myUInt.GetType().Name);
  Console.WriteLine("myInt is a: {0}", myInt.GetType().Name);
  Console.WriteLine("myLong is a: {0}", myLong.GetType().Name);
  Console.WriteLine("myDouble is a: {0}", myDouble.GetType().Name);
  Console.WriteLine("myFloat is a: {0}", myFloat.GetType().Name);
  Console.WriteLine("myDecimal is a: {0}", myDecimal.GetType().Name);
}

了解隐式类型变量的限制

关于var关键字的使用有各种限制。首先,隐式类型将应用于方法或属性范围内的局部变量。使用var关键字定义自定义类型的返回值、参数或字段数据是非法的。例如,下面的类定义将导致各种编译时错误:

class ThisWillNeverCompile
{
  // Error! var cannot be used as field data!
  private var myInt = 10;

  // Error! var cannot be used as a return value
  // or parameter type!
  public var MyMethod(var x, var y){}
}

同样,用关键字var声明的局部变量必须在声明的确切时间被赋予一个初始值,而不能被赋予初始值null。这最后一个限制应该是有意义的,因为编译器不能仅仅根据null来推断变量将指向内存中的哪种类型。

// Error! Must assign a value!
var myData;

// Error! Must assign value at exact time of declaration!
var myInt;
myInt = 0;

// Error! Can't assign null as initial value!
var myObj = null;

然而,允许在初始赋值后将推断的局部变量赋值给null(假设它是引用类型)。

// OK, if SportsCar is a reference type!
var myCar = new SportsCar();
myCar = null;

此外,允许将隐式类型的局部变量的值赋给其他变量的值,无论是否是隐式类型的。

// Also OK!
var myInt = 0;
var anotherInt = myInt;

string myString = "Wake up!";
var myData = myString;

此外,如果方法返回类型与var定义的数据点是相同的底层类型,那么允许向调用者返回隐式类型的局部变量。

static int GetAnInt()
{
  var retVal = 9;
  return retVal;
}

隐式类型数据是强类型数据

请注意,局部变量的隐式类型化会导致强类型数据。因此,var关键字的使用是而不是与脚本语言(如 JavaScript 或 Perl)或 COM Variant数据类型使用的相同技术,其中变量可以在程序的生存期内保存不同类型的值(通常称为动态类型化)。

Note

C# 允许使用名为-surprise,surprise-dynamic的关键字进行动态输入。你会在第十八章学到这方面的知识。

相反,类型推断保留了 C# 语言的强类型特征,并且只影响编译时的变量声明。之后,数据点被视为是用该类型声明的;将不同类型的值赋给该变量将导致编译时错误。

static void ImplicitTypingIsStrongTyping()
{
  // The compiler knows "s" is a System.String.
  var s = "This variable can only hold string data!";
  s = "This is fine...";

  // Can invoke any member of the underlying type.
  string upper = s.ToUpper();

  // Error! Can't assign numerical data to a string!
  s = 44;
}

理解隐式类型化局部变量的有用性

既然您已经看到了用于声明隐式类型化局部变量的语法,我相信您一定想知道什么时候使用这种结构。首先,仅仅为了声明局部变量而使用var并没有带来什么好处。这样做可能会让阅读您代码的其他人感到困惑,因为快速确定底层数据类型变得更加困难,因此理解变量的整体功能也更加困难。所以,如果你知道你需要一个int,那就声明一个int

然而,正如你将在第十三章开始看到的,LINQ 技术集利用了查询表达式,它可以基于查询本身的格式产生动态创建的结果集。在这些情况下,隐式类型非常有用,因为您不需要显式定义查询可能返回的类型,而这在某些情况下实际上是不可能做到的。不要纠结于下面的 LINQ 示例代码,看看您是否能弄清楚subset的底层数据类型:

static void LinqQueryOverInts()
{
  int[] numbers = { 10, 20, 30, 40, 1, 2, 3, 8 };

  // LINQ query!
  var subset = from i in numbers where i < 10 select i;

  Console.Write("Values in subset: ");
  foreach (var i in subset)
  {
    Console.Write("{0} ", i);
  }
  Console.WriteLine();

  // Hmm...what type is subset?
  Console.WriteLine("subset is a: {0}", subset.GetType().Name);
  Console.WriteLine("subset is defined in: {0}", subset.GetType().Namespace);
}

您可能会假设subset数据类型是一个整数数组。看起来是这样,但事实上,它是一种低级的 LINQ 数据类型,除非你已经做了很长时间的 LINQ,或者你在ildasm.exe中打开编译后的图像,否则你永远不会知道它。好消息是,当您使用 LINQ 时,您很少(如果曾经)关心查询返回值的底层类型;您只需将值赋给隐式类型的局部变量。

事实上,可以说唯一一次使用var关键字是在定义从 LINQ 查询返回的数据时。记住,如果你知道你需要一个int,就声明一个int!过度使用隐式类型(通过var关键字)被大多数开发人员认为是产品代码中糟糕的风格。

使用 C# 迭代构造

所有编程语言都提供了重复代码块的方法,直到满足终止条件。不管你过去使用过哪种语言,我想 C# 迭代语句应该不会引起太多的关注,也不需要太多的解释。C# 提供了以下四种迭代构造:

  • for循环

  • foreach/in循环

  • while循环

  • do / while循环

让我们使用一个名为 IterationsAndDecisions 的新控制台应用项目,依次快速检查每个循环构造。

Note

我将保持本章的这一节简明扼要,因为我假设你有使用类似关键字的经验(ifforswitch等)。)用你现在的编程语言。如果您需要更多信息,请在 C# 文档中查找主题“迭代语句(C# 参考)”、“跳转语句(C# 参考)”和“选择语句(C# 参考)”。

使用 for 循环

当您需要迭代固定次数的代码块时,for语句提供了很大的灵活性。本质上,您可以指定一段代码重复多少次,以及终止条件。无需赘述这一点,下面是一个语法示例:

// A basic for loop.
static void ForLoopExample()
{
  // Note! "i" is only visible within the scope of the for loop.
  for(int i = 0; i < 4; i++)
  {
    Console.WriteLine("Number is: {0} ", i);
  }
  // "i" is not visible here.
}

在构建 C# for语句时,您所有的 C、C++和 Java 技巧仍然有效。您可以创建复杂的终止条件,构建无限循环,反向循环(通过--操作符),并使用gotocontinuebreak跳转关键字。

使用 foreach 循环

C# foreach关键字允许你遍历容器中的所有条目,而不需要测试上限。然而,与for循环不同的是,foreach循环只会以线性(n+1)的方式遍历容器(因此,你不能向后遍历容器,跳过每三个元素,等等)。

然而,当您只是需要一个一个地浏览集合时,foreach循环是完美的选择。这里有两个使用foreach的例子——一个遍历字符串数组,另一个遍历整数数组。注意,in关键字之前的数据类型代表容器中的数据类型。

// Iterate array items using foreach.
static void ForEachLoopExample()
{
  string[] carTypes = {"Ford", "BMW", "Yugo", "Honda" };
  foreach (string c in carTypes)
  {
    Console.WriteLine(c);
  }

  int[] myInts = { 10, 20, 30, 40 };
  foreach (int i in myInts)
  {
    Console.WriteLine(i);
  }
}

关键字in之后的项可以是一个简单的数组(见这里),或者更具体地说,可以是实现IEnumerable接口的任何类。正如你将在第十章中看到的。NET 核心基类库附带了许多集合,这些集合包含通用抽象数据类型(ADT)的实现。这些项目中的任何一个(比如通用的List<T>)都可以在foreach循环中使用。

在 foreach 构造中使用隐式类型

也可以在一个foreach循环结构中使用隐式类型。如你所料,编译器会正确地推断出正确的“类型”回想一下本章前面展示的 LINQ 示例方法。假设您不知道subset变量的确切底层数据类型,那么您可以使用隐式类型对结果集进行迭代。确保将下面的using语句添加到文件的顶部:

using System.Linq;
static void LinqQueryOverInts()
{
  int[] numbers = { 10, 20, 30, 40, 1, 2, 3, 8 };

  // LINQ query!
  var subset = from i in numbers where i < 10 select i;
  Console.Write("Values in subset: ");

  foreach (var i in subset)
  {
    Console.Write("{0} ", i);
  }
}

使用 while 和 do/while 循环结构

如果您想执行一个语句块,直到达到某个终止条件,那么while循环结构非常有用。在一个while循环的范围内,您需要确保这个终止事件确实被建立;否则,你会陷入死循环。在下面的例子中,消息"In while loop"将持续打印,直到用户在命令提示符下输入yes终止循环:

static void WhileLoopExample()
{
  string userIsDone = "";

  // Test on a lower-class copy of the string.
  while(userIsDone.ToLower() != "yes")
  {
    Console.WriteLine("In while loop");
    Console.Write("Are you done? [yes] [no]: ");
    userIsDone = Console.ReadLine();
  }
}

while循环密切相关的是do / while语句。像一个简单的while循环一样,do / while在你需要执行某个动作不确定的次数时使用。不同的是do / while循环保证至少执行一次相应的代码块。相反,如果终止条件从一开始就是假的,那么简单的while循环可能永远不会执行。

static void DoWhileLoopExample()
{
  string userIsDone = "";

  do
  {
    Console.WriteLine("In do/while loop");
    Console.Write("Are you done? [yes] [no]: ");
    userIsDone = Console.ReadLine();
  }while(userIsDone.ToLower() != "yes"); // Note the semicolon!
}

关于范围的快速讨论

像所有基于 C 的语言(C#,Java 等。),使用花括号创建一个范围。到目前为止,您已经在许多示例中看到了这一点,包括名称空间、类和方法。迭代和决策构造也在一个范围内操作,如下例所示:

for(int i = 0; i < 4; i++)
{
  Console.WriteLine("Number is: {0} ", i);
}

对于这些结构(在前一节和下一节中),不使用花括号是允许的。换句话说,下面的代码与前面的例子完全相同:

for(int i = 0; i < 4; i++)
  Console.WriteLine("Number is: {0} ", i);

虽然这是允许的,但通常不是一个好主意。问题不在于一行语句,而在于从一行到多行的语句。如果没有大括号,在迭代/决策结构中扩展代码时可能会出错。例如,下面两个例子是 而不是 相同:

for(int i = 0; i < 4; i++)
{
  Console.WriteLine("Number is: {0} ", i);
  Console.WriteLine("Number plus 1 is: {0} ", i+1)
}
for(int i = 0; i < 4; i++)
  Console.WriteLine("Number is: {0} ", i);
  Console.WriteLine("Number plus 1 is: {0} ", i+1)

如果你幸运的话(就像这个例子),额外的一行代码会产生一个编译错误,因为变量 i 只在for循环的范围内定义。如果您运气不好,您正在执行的代码不会被标记为编译器错误,而是一个逻辑错误,更难发现和调试。

使用决策构造和关系/等式运算符

既然可以迭代语句块,下一个相关的概念就是如何控制程序执行的流程。C# 定义了两个简单的构造来根据各种意外情况改变程序的流程:

  • if / else语句

  • switch声明

Note

C# 7 用一种叫做模式匹配的技术扩展了is表达式和switch语句。为了完整起见,这里显示了这些扩展如何影响if / elseswitch语句的基础知识。阅读完第六章后,这些扩展会更有意义,这一章涵盖了基类/派生类规则、类型转换和标准的is操作符。

使用 if/else 语句

首先是if / else语句。与 C 和 C++不同,C# 中的if / else语句只对布尔表达式进行操作,而不是像–10这样的特殊值。

使用等式和关系运算符

C# if / else语句通常涉及使用表 3-8 中所示的 C# 运算符来获得一个文字布尔值。

表 3-8。

C# 关系和等式运算符

|

C# 等式/关系运算符

|

用法示例

|

生命的意义

| | --- | --- | --- | | == | if(age == 30) | 仅当每个表达式都相同时,才返回true | | != | if("Foo" != myStr) | 仅当每个表达式不同时才返回true | | < | if(bonus < 2000) | 如果表达式 A ( bonus)小于表达式 B ( 2000),则返回true | | > | if(bonus > 2000) | 如果表达式 A ( bonus)大于表达式 B ( 2000),则返回true | | <= | if(bonus <= 2000) | 如果表达式 A ( bonus)小于或等于表达式 B ( 2000),则返回true | | >= | if(bonus >= 2000) | 如果表达式 A ( bonus)大于或等于表达式 B ( 2000),则返回true |

同样,C 和 C++程序员需要知道,测试不等于零的条件的老把戏在 C# 中不起作用。假设您想要查看您正在使用的string是否长于零个字符。你可能会想这样写:

static void IfElseExample()
{
  // This is illegal, given that Length returns an int, not a bool.
  string stringData = "My textual data";
  if(stringData.Length)
  {
    Console.WriteLine("string is greater than 0 characters");
  }
  else
  {
    Console.WriteLine("string is not greater than 0 characters");
  }
  Console.WriteLine();
}

如果您想要使用String.Length属性来确定真或假,您需要修改您的条件表达式来解析为布尔值。

// Legal, as this resolves to either true or false.
If (stringData.Length > 0)
{
  Console.WriteLine("string is greater than 0 characters");
}

使用带有模式匹配的 if/else(新 7.0)

C# 7.0 新增,模式匹配if / else语句中是允许的。模式匹配允许代码检查对象的某些特征和属性,并根据这些属性和特征的存在与否做出决定。如果您是面向对象编程的新手,请不要担心;前面的句子将在后面的章节中详细解释。只需知道(目前)你可以使用is关键字检查一个对象的类型,如果模式匹配,将该对象赋给一个变量,然后使用该变量。

IfElsePatternMatching方法检查两个对象变量,确定它们是 string 还是 int,然后将结果打印到控制台:

static void IfElsePatternMatching()
{
  Console.WriteLine("===If Else Pattern Matching ===/n");
  object testItem1 = 123;
  object testItem2 = "Hello";
  if (testItem1 is string myStringValue1)
  {
    Console.WriteLine($"{myStringValue1} is a string");
  }
  if (testItem1 is int myValue1)
  {
    Console.WriteLine($"{myValue1} is an int");
  }
  if (testItem2 is string myStringValue2)
  {
    Console.WriteLine($"{myStringValue2} is a string");
  }
  if (testItem2 is int myValue2)
  {
    Console.WriteLine($"{myValue2} is an int");
  }
  Console.WriteLine();
}

改进模式匹配(新 9.0)

C# 9.0 引入了大量对模式匹配的改进,如表 3-9 所示。

表 3-9。

模式匹配改进

|

模式

|

生命的意义

| | --- | --- | | Type patterns | 检查变量是否是一种类型 | | Parenthesized patterns | 强制或强调模式组合的优先级 | | Conjuctive (and) patterns | 要求两种模式匹配 | | Disjunctive (or) patterns | 要求两种模式匹配 | | Negated (not) patterns | 要求模式不匹配 | | Relational patterns | 要求输入小于、小于或等于、大于或大于或等于 |

更新后的IfElsePatternMatchingUpdatedInCSharp9()展示了这些新模式的作用:

static void IfElsePatternMatchingUpdatedInCSharp9()
{
    Console.WriteLine("================ C# 9 If Else Pattern Matching Improvements ===============/n");
    object testItem1 = 123;
    Type t = typeof(string);
    char c = 'f';

    //Type patterns
    if (t is Type)
    {
        Console.WriteLine($"{t} is a Type");
    }

    //Relational, Conjuctive, and Disjunctive patterns
    if (c is >= 'a' and <= 'z' or >= 'A' and <= 'Z')
    {
        Console.WriteLine($"{c} is a character");
    };

    //Parenthesized patterns
    if (c is (>= 'a' and <= 'z') or (>= 'A' and <= 'Z') or '.' or ',')
    {
        Console.WriteLine($"{c} is a character or separator");
    };

    //Negative patterns
    if (testItem1 is not string)
    {
        Console.WriteLine($"{testItem1} is not a string");
    }
    if (testItem1 is not null)
    {
        Console.WriteLine($"{testItem1} is not null");
    }
    Console.WriteLine();
}

使用条件运算符(更新了 7.2、9.0)

条件运算符(?:),也称为三元条件运算符,是书写简单if / else语句的一种速记方法。语法是这样的:

condition ? first_expression : second_expression;

这个条件就是条件测试(if / else语句的if部分)。如果测试通过,则执行问号(?)后面的代码。如果测试结果不为真,则执行冒号后的代码(if / else语句的else部分)。前面的代码示例可以使用条件运算符编写,如下所示:

static void ExecuteIfElseUsingConditionalOperator()
{
  string stringData = "My textual data";
  Console.WriteLine(stringData.Length > 0
    ? "string is greater than 0 characters"
    : "string is not greater than 0 characters");
  Console.WriteLine();
}

条件运算符有一些限制。首先,first_expressionsecond_expression两种类型都必须有从一个到另一个的隐式转换,或者,C# 9.0 中的新特性,每种类型都必须有到目标类型的隐式转换。其次,条件运算符只能在赋值语句中使用。以下代码将导致编译器错误“只有赋值、调用、递增、递减和新对象表达式可以用作语句”:

  stringData.Length > 0
    ? Console.WriteLine("string is greater than 0 characters")
    : Console.WriteLine("string is not greater than 0 characters");

C# 7.2 中新增的条件运算符可用于返回对条件结果的引用。以下面的例子为例,它使用了两种形式的条件运算符 by ref:

static void ConditionalRefExample()
{
  var smallArray = new int[] { 1, 2, 3, 4, 5 };
  var largeArray = new int[] { 10, 20, 30, 40, 50 };

  int index = 7;
  ref int refValue = ref ((index < 5)
    ? ref smallArray[index]
    : ref largeArray[index - 5]);
  refValue = 0;

  index = 2;
  ((index < 5)
    ? ref smallArray[index]
    : ref largeArray[index - 5]) = 100;

  Console.WriteLine(string.Join(" ", smallArray));
  Console.WriteLine(string.Join(" ", largeArray));
}

如果你不熟悉关键字ref,在这一点上不要太担心,因为它将在下一章中深入讨论。总而言之,第一个例子返回一个引用到用条件检查的数组位置,并将变量refValue赋给这个引用。从概念上来说,可以把引用看作是指向数组中位置的指针,而不是数组位置的实际值。这允许通过改变分配给变量的值来直接改变该位置的数组值。将refValue变量的值设置为零的结果会将第二个数组的值更改为 10,20, 0 ,40,50。第二个示例将第一个数组的第二个值更新为 100,得到 1,2, 100 ,4,5。

使用逻辑运算符

一个if语句也可以由复杂的表达式组成,并且可以包含else语句来执行更复杂的测试。语法与 C(和 C++)和 Java 相同。为了构建复杂的表达式,C# 提供了一组预期的逻辑运算符,如表 3-10 所示。

表 3-10。

C# 逻辑运算符

|

操作员

|

例子

|

生命的意义

| | --- | --- | --- | | && | if(age == 30 && name == "Fred") | 和运算符。如果所有表达式都为真,则返回true。 | | &#124;&#124; | if(age == 30 &#124;&#124; name == "Fred") | 或操作员。如果至少有一个表达式为真,则返回true。 | | ! | if(!myBool) | 不是操作员。如果为假,则返回true,如果为真,则返回false。 |

Note

必要时,&&||操作器都“短路”。这意味着在一个复杂表达式被确定为假之后,剩余的子表达式将不会被检查。如果你需要测试所有的表达式,你可以使用相关的&|操作符。

使用 switch 语句

C# 提供的另一个简单的选择结构是switch语句。与其他基于 C 的语言一样,switch语句允许您基于一组预定义的选项来处理程序流。例如,下面的逻辑基于两个可能的选择之一打印一个特定的字符串消息(?? 案例处理一个无效的选择):

// Switch on a numerical value.
static void SwitchExample()
{
  Console.WriteLine("1 [C#], 2 [VB]");
  Console.Write("Please pick your language preference: ");

  string langChoice = Console.ReadLine();
  int n = int.Parse(langChoice);

  switch (n)
  {
    case 1:
      Console.WriteLine("Good choice, C# is a fine language.");
      break;
    case 2:
      Console.WriteLine("VB: OOP, multithreading, and more!");
      break;
    default:
      Console.WriteLine("Well...good luck with that!");
      break;
  }
}

Note

C# 要求每个包含可执行语句的 case(包括default)都有一个终止returnbreakgoto,以避免陷入下一个语句。

C# switch语句的一个很好的特性是,除了数值数据之外,您还可以计算string数据。事实上,所有版本的 C# 都可以评估charstringboolintlongenum数据类型。正如您将在下一节看到的,C# 7 增加了额外的功能。下面是更新后的switch语句,它计算一个字符串变量:

static void SwitchOnStringExample()
{
  Console.WriteLine("C# or VB");
  Console.Write("Please pick your language preference: ");

  string langChoice = Console.ReadLine();
  switch (langChoice.ToUpper())
  {
    case "C#":
      Console.WriteLine("Good choice, C# is a fine language.");
      break;
    case "VB":
      Console.WriteLine("VB: OOP, multithreading and more!");
      break;
    default:
      Console.WriteLine("Well...good luck with that!");
      break;
  }
}

也可以打开枚举数据类型。正如你将在第四章中看到的,C# enum关键字允许你定义一组定制的名称-值对。为了激起您的兴趣,考虑下面的最后一个助手函数,它在System.DayOfWeek enum上执行一个switch测试。您会注意到一些我还没有检查的语法,但是重点是切换到enum本身的问题;缺失的部分将在后面的章节中补上。

static void SwitchOnEnumExample()
{
  Console.Write("Enter your favorite day of the week: ");
  DayOfWeek favDay;
  try
  {
    favDay = (DayOfWeek) Enum.Parse(typeof(DayOfWeek), Console.ReadLine());
  }
  catch (Exception)
  {
    Console.WriteLine("Bad input!");
    return;
  }
  switch (favDay)
  {
    case DayOfWeek.Sunday:
      Console.WriteLine("Football!!");
      break;
    case DayOfWeek.Monday:
      Console.WriteLine("Another day, another dollar");
      break;
    case DayOfWeek.Tuesday:
      Console.WriteLine("At least it is not Monday");
      break;
    case DayOfWeek.Wednesday:
      Console.WriteLine("A fine day.");
      break;
    case DayOfWeek.Thursday:
      Console.WriteLine("Almost Friday...");
      break;
    case DayOfWeek.Friday:
      Console.WriteLine("Yes, Friday rules!");
      break;
    case DayOfWeek.Saturday:
      Console.WriteLine("Great day indeed.");
      break;
  }
  Console.WriteLine();
}

不允许从一个case语句跳到另一个case语句,但是如果多个case语句产生相同的结果呢?幸运的是,它们可以组合在一起,如下面的代码片段所示:

case DayOfWeek.Saturday:
case DayOfWeek.Sunday:
  Console.WriteLine("It’s the weekend!");
  break;

如果在case语句之间包含任何代码,编译器将抛出一个错误。只要是连续的语句,如前所示,case语句可以组合在一起,共享共同的代码。

除了前面代码示例中显示的returnbreak语句,switch语句还支持使用goto来退出case条件并执行另一个case语句。虽然这是受支持的,但它被普遍认为是一种反模式,并不常用。下面是一个在switch块中使用goto语句的例子:

static void SwitchWithGoto()
{
  var foo = 5;
  switch (foo)
  {
    case 1:
      //do something
      goto case 2;
    case 2:
      //do something else
      break;
    case 3:
      //yet another action
      goto default;
    default:
      //default action
      break;
  }
}

执行 switch 语句模式匹配(新 7.0,更新 9.0)

在 C# 7 之前,switch语句中的匹配表达式仅限于将变量与常量值进行比较,有时也称为常量模式。在 C# 7 中,switch语句也可以使用类型模式,其中case语句可以评估被检查变量的类型,并且case表达式不再局限于常量值。每个case语句必须以returnbreak结束的规则仍然适用;但是,使用类型模式不支持goto语句。

Note

如果您是面向对象编程的新手,这一节可能会有点混乱。当你在类和基类的上下文中重新审视 C# 7 的新模式匹配特性时,这些都会在第六章 ?? 中出现。现在,只要明白有一种强有力的新方法来编写switch语句。

添加另一个名为ExecutePatternMatchingSwitch()的方法,并添加以下代码:

static void ExecutePatternMatchingSwitch()
{
  Console.WriteLine("1 [Integer (5)], 2 [String (\"Hi\")], 3 [Decimal (2.5)]");
  Console.Write("Please choose an option: ");
  string userChoice = Console.ReadLine();
  object choice;
  //This is a standard constant pattern switch statement to set up the example
  switch (userChoice)
  {
    case "1":
      choice = 5;
      break;
    case "2":
      choice = "Hi";
      break;
    case "3":
      choice = 2.5;
      break;
    default:
      choice = 5;
      break;
  }
  //This is new the pattern matching switch statement
  switch (choice)
  {
    case int i:
      Console.WriteLine("Your choice is an integer.");
      break;
    case string s:
      Console.WriteLine("Your choice is a string.");
      break;
    case decimal d:
      Console.WriteLine("Your choice is a decimal.");
      break;
    default:
      Console.WriteLine("Your choice is something else");
      break;
  }
  Console.WriteLine();
}

第一个switch语句使用了标准的常量模式,它只是用来设置这个(琐碎的)例子。在第二个switch语句中,变量被类型化为object,并且根据用户的输入,可以被解析为intstringdecimal数据类型。基于变量的类型,匹配不同的 case 语句。除了检查数据类型之外,在每个case语句中都分配了一个变量(除了default的情况)。将代码更新为以下内容,以使用变量中的值:

//This is new the pattern matching switch statement
switch (choice)
{
  case int i:
    Console.WriteLine("Your choice is an integer {0}.",i);
    break;
  case string s:
    Console.WriteLine("Your choice is a string. {0}", s);
    break;
  case decimal d:
    Console.WriteLine("Your choice is a decimal. {0}", d);
    break;
  default:
    Console.WriteLine("Your choice is something else");
    break;
}

除了评估匹配表达式的类型之外,when子句可以添加到case语句中,以评估变量的条件。在此示例中,除了检查类型之外,还会检查转换类型的值是否匹配:

static void ExecutePatternMatchingSwitchWithWhen()
{
  Console.WriteLine("1 [C#], 2 [VB]");
  Console.Write("Please pick your language preference: ");

  object langChoice = Console.ReadLine();
  var choice = int.TryParse(langChoice.ToString(), out int c) ? c : langChoice;

  switch (choice)
  {
    case int i when i == 2:
    case string s when s.Equals("VB", StringComparison.OrdinalIgnoreCase):
      Console.WriteLine("VB: OOP, multithreading, and more!");
      break;
    case int i when i == 1:
    case string s when s.Equals("C#", StringComparison.OrdinalIgnoreCase):
      Console.WriteLine("Good choice, C# is a fine language.");
      break;
    default:
      Console.WriteLine("Well...good luck with that!");
      break;
  }
  Console.WriteLine();
}

这给switch语句增加了一个新的维度,因为case语句的顺序现在很重要。对于固定模式,每个case语句都必须是唯一的。有了类型模式,就不再是这种情况了。例如,下面的代码将匹配第一个 case 语句中的每个整数,并且永远不会执行第二个或第三个(实际上,下面的代码将无法编译):

switch (choice)
{
  case int i:
    //do something
    break;
  case int i when i == 0:
    //do something
    break;
  case int i when i == -1:
    // do something
    break;
}

在 C# 7 的最初版本中,当使用泛型类型时,模式匹配有一个小问题。C# 7.1 已经解决了这个问题。通用类型将在第十章中介绍。

Note

之前演示的 C# 9.0 中的所有模式匹配改进也可用于 switch 语句中。

使用开关表达式(新 8.0)

C# 8 中的新特性是switch表达式,允许在简洁的语句中给变量赋值。考虑这个方法的 C# 7 版本,它接受一种颜色并返回颜色名称的十六进制值:

static string FromRainbowClassic(string colorBand)
{
  switch (colorBand)
  {
    case "Red":
      return "#FF0000";
    case "Orange":
      return "#FF7F00";
    case "Yellow":
      return "#FFFF00";
    case "Green":
      return "#00FF00";
    case "Blue":
      return "#0000FF";
    case "Indigo":
      return "#4B0082";
    case "Violet":
      return "#9400D3";
    default:
      return "#FFFFFF";
  };
}

有了 C# 8 中的新开关表达式,以前的方法可以写成如下形式,这要简洁得多:

static string FromRainbow(string colorBand)
{
  return colorBand switch
  {
    "Red" => "#FF0000",
    "Orange" => "#FF7F00",
    "Yellow" => "#FFFF00",
    "Green" => "#00FF00",
    "Blue" => "#0000FF",
    "Indigo" => "#4B0082",
    "Violet" => "#9400D3",
    _ => "#FFFFFF",
  };
}

在这个例子中,有很多东西需要解开,从 lambda ( =>)语句到 discard ( _)。这些都将在后面的章节中讨论,这个例子将会更详细。

在结束 switch 表达式的主题之前,还有一个例子,它涉及到元组。元组在第四章中有详细介绍,所以现在把元组想象成一个简单的结构,它保存多个值并用括号定义,就像这个保存一个string和一个int的元组:

(string, int)

在下面的示例中,传递到RockPaperScissors方法中的两个值被转换为一个元组,然后 switch 表达式在单个表达式中计算这两个值。这种模式允许在一个switch语句中比较多个值。

//Switch expression with Tuples
static string RockPaperScissors(string first, string second)
{
  return (first, second) switch
  {
    ("rock", "paper") => "Paper wins.",
    ("rock", "scissors") => "Rock wins.",
    ("paper", "rock") => "Paper wins.",
    ("paper", "scissors") => "Scissors wins.",
    ("scissors", "rock") => "Rock wins.",
    ("scissors", "paper") => "Scissors wins.",
    (_, _) => "Tie.",
  };
}

要调用这个方法,将下面几行代码添加到Main()方法中:

Console.WriteLine(RockPaperScissors("paper","rock"));
Console.WriteLine(RockPaperScissors("scissors","rock"));

当引入元组时,将在第四章中再次讨论这个例子。

摘要

本章的目标是向你展示 C# 编程语言的许多核心方面。您研究了您可能有兴趣构建的任何应用中的常见结构。在研究了 application 对象的角色之后,您了解到每个 C# 可执行程序都必须有一个定义Main()方法的类型,要么显式定义,要么通过使用顶级语句来定义。这个方法作为程序的入口点。

接下来,您深入研究了 C# 内置数据类型的细节,并开始理解每个数据类型关键字(例如,int)实际上是在System名称空间中成熟类型的简写符号(在本例中是System.Int32)。鉴于此,每种 C# 数据类型都有许多内置成员。同样,您还了解了扩大缩小的作用,以及checkedunchecked关键字的作用。

本章最后介绍了使用var关键字的隐式类型的作用。如前所述,隐式类型最有用的地方是在使用 LINQ 编程模型时。最后,您快速检查了 C# 支持的各种迭代和决策结构。

现在你已经理解了一些基本的细节,下一章(第章和第章)将会完成你对核心语言特性的研究。之后,你将为从第五章开始研究 C# 的面向对象特性做好充分准备。