C#10 编程指南(一)
原文:
zh.annas-archive.org/md5/f6bf98ae10aa686be15d58fe9358e0e2译者:飞龙
前言
C#现在已经存在大约二十年。它在强大性和规模上稳步增长,但微软始终保持了其基本特性的完整性。每一个新的能力都设计成与其余部分清晰集成,增强语言而不将其变成杂乱无章的特性集合。
虽然 C#在其核心上仍然是一种相当简单的语言,但现在关于它的内容远远超过了它的首次出现。由于需要涵盖如此广泛的内容,本书期望读者具备一定的技术能力。
本书的受众
我写这本书是为了有经验的开发者——我多年来一直在编程,并且我决定把这本书设计成我如果在其他语言上有这种经验,并且今天正在学习 C#时想要阅读的书籍。尽管早期版本解释了一些基本概念,如类、多态性和集合,但我假设读者已经知道这些是什么。早期章节仍然描述 C#如何呈现这些常见概念,但重点是特定于 C#的细节,而不是广泛的概念。
本书使用的约定
本书使用以下排版约定:
斜体
指示新术语,URL,电子邮件地址,文件名和文件扩展名。
Constant width
用于程序清单,以及在段落中引用程序元素,例如变量或函数名称,数据库,数据类型,环境变量,语句和关键字。
Constant width bold
显示用户应直接输入的命令或其他文本。在示例中,突出显示特别感兴趣的代码。
Constant width italic
显示应替换为用户提供的值或由上下文确定的值的文本。
提示
这个元素表示一个提示或建议。
注意
这个元素表示一般提示。
警告
这个元素表示一个警告或注意事项。
使用代码示例
可下载的补充材料(代码示例、练习等)位于https://oreil.ly/prog-cs-10-repo。
如果您有技术问题或使用代码示例遇到问题,请发送电子邮件至bookquestions@oreilly.com。
本书旨在帮助您完成工作。通常情况下,如果本书提供了示例代码,您可以在您的程序和文档中使用它。除非您复制了大量代码,否则无需联系我们请求许可。例如,编写一个使用本书中几个代码片段的程序不需要许可。销售或分发 O'Reilly 书籍中的示例代码需要许可。引用本书并引用示例代码回答问题不需要许可。将本书中大量示例代码整合到您产品的文档中需要许可。
我们感谢您的支持,但通常不要求署名。一般的署名包括标题、作者、出版商和 ISBN。例如:“Programming C# 10 by Ian Griffiths (O’Reilly). Copyright 2022 by Ian Griffiths, 978-1-098-11781-8.”
如果您认为您对代码示例的使用超出了合理使用范围或上述许可,请随时通过permissions@oreilly.com与我们联系。
O’Reilly 在线学习
注意
超过 40 年来,O’Reilly为公司提供技术和商业培训、知识和见解,帮助它们取得成功。
我们独特的专家和创新者网络通过书籍、文章、会议和我们的在线学习平台分享他们的知识和专长。O’Reilly 的在线学习平台为您提供按需访问的实时培训课程、深度学习路径、交互式编码环境,以及来自 O’Reilly 和其他 200 多家出版商的大量文本和视频。更多信息,请访问http://oreilly.com。
如何联系我们
关于本书的评论和问题,请联系出版商:
-
O’Reilly Media, Inc.
-
1005 Gravenstein Highway North
-
CA 95472,Sebastopol
-
800-998-9938 (美国或加拿大)
-
707-829-0515 (国际或本地)
-
707-829-0104 (传真)
我们为这本书建立了一个网页,列出勘误、示例和任何额外信息。您可以访问https://oreil.ly/prgrmg-c-10获取更多信息。
通过bookquestions@oreilly.com向我们发送评论或技术问题的电子邮件。
获取关于我们的书籍和课程的新闻和信息,请访问https://oreilly.com。
在 LinkedIn 上找到我们:https://linkedin.com/company/oreilly-media。
关注我们的 Twitter 账号:https://twitter.com/oreillymedia。
在 YouTube 上关注我们:https://youtube.com/oreillymedia。
致谢
衷心感谢本书的官方技术审稿人:Stephen Toub、Howard van Rooijen 和 Glyn Griffiths。我还要特别感谢那些审阅单独章节或以其他方式提供帮助或信息以改进本书的人:Brian Rasmussen、Eric Lippert、Andrew Kennedy、Daniel Sinclair、Brian Randell、Mike Woodring、Mike Taulty、Bart De Smet、Matthew Adams、Jess Panni、Jonathan George、Mike Larah、Carmel Eve、Ed Freeman、Elisenda Gascon、Jessica Hill、Liam Mooney、Nehemiah Campbell 和 Shahryar Saljoughi。特别感谢 endjin,不仅允许我抽出时间写这本书,还为创造这样一个优秀的工作环境而致谢。
感谢 O’Reilly 公司的所有人员,他们的工作使这本书得以问世。特别感谢 Corbin Collins 在推动这本书问世方面的支持,以及 Amanda Quinn 在启动这个项目方面的支持。还要感谢 Elizabeth Faerm、Cassandra Furtado、Ron Bilodeau、Nick Adams、Kate Dullea、Karen Montgomery 和 Kristen Brown,在完成这项工作中的帮助。进一步感谢 Sue Klefstad 和 WordCo Indexing Services, Inc. 对索引的工作。也要感谢 Kim Cofer 进行了彻底而周到的编辑工作,以及 Kim Sandoval 的勤奋校对工作。最后,感谢 John Osborn,在我写第一本书时成为 O’Reilly 的作者。
第一章:介绍 C#
C# 编程语言(发音为 “see sharp”)用于许多类型的应用程序,包括网站、基于云的系统、物联网设备、机器学习、桌面应用程序、嵌入式控制器、移动应用程序、游戏和命令行实用程序。C# 和相关的运行时、库和工具被称为 .NET,已经在 Windows 开发者中心舞台上超过 20 年。如今,.NET 是跨平台和开源的,使得用 C# 编写的应用程序和服务可以在包括 Android、iOS、macOS 和 Linux 在内的操作系统上运行,以及 Windows。
C# 10.0 的发布及其对应的运行时 .NET 6.0 标志着一个重要的里程碑:C# 成为完全跨平台、开源语言的旅程现已完成。尽管在 C# 的大部分历史中都存在开源实现,但在 2016 年,微软发布了 .NET Core 1.0,这是第一个由微软全面支持在 Linux 和 macOS 以及 Windows 上运行 C# 的平台。最初 .NET Core 的库和工具支持并不完善,因此微软继续发布其较旧的运行时版本,即仅限 Windows 的封闭源 .NET Framework,但六年后,这个旧运行时版本实际上已经退出,¹ 现在跨平台版本全面超越了它。.NET 5.0 删除了其名称中的 “Core”,表明它现在是主要版本,但是在 .NET 6.0 中,跨平台版本才真正到来,因为这个版本享有完整的 长期支持 (LTS) 状态。首次,这个与平台无关的 C# 和 .NET 版本已经取代了旧的 .NET Framework。
C# 和 .NET 是开源项目,尽管最初并非如此。在 C# 的早期历史中,微软严格保护其所有源代码,但在 2014 年创建了 .NET Foundation 来促进 .NET 世界中开源项目的发展。现在,微软许多重要的 C# 和 .NET 项目都在该基金会的管理下(除了许多非微软项目)。这包括 微软的 C# 编译器 和 .NET 运行时与库。如今,几乎围绕 C# 的所有内容都是在公开开发的,欢迎外部人员贡献代码。新的语言特性提案在 GitHub 上进行管理,从最早的阶段就能够进行社区参与。
为什么选择 C#?
尽管可以使用多种方式使用 C#,其他语言始终是一个选择。为什么你会选择 C#而不是其他语言?这取决于您需要做什么,以及您在编程语言中喜欢和不喜欢的方面。我发现 C#提供了相当大的力量、灵活性和性能,并且以足够高的抽象级别工作,以至于我不会在程序试图解决的问题的细节上花费大量精力。
C#的强大之处在于它支持的多种编程技术。例如,它提供面向对象的特性、泛型和函数式编程。它支持动态和静态类型。由于语言集成查询(LINQ),它提供了强大的列表和集合操作功能。它还具有异步编程的内在支持。此外,支持 C#的各种开发环境都提供了广泛的增强生产力的功能。
C#提供了在开发便捷性与性能之间取得平衡的选项。运行时一直提供垃圾回收器(GC),使开发人员不必过多地处理程序不再使用的内存回收工作。GC 在现代编程语言中是一项常见功能,虽然对大多数程序有益,但在某些特定场景下其性能影响可能成问题,因此 C#支持更显式的内存管理方式,使您可以在不丧失类型安全性的前提下,在开发便捷性与运行时性能之间进行权衡。这使得 C#适用于多年来一直是较不安全语言(如 C 和 C++)所独有的某些对性能要求极高的应用场景。
编程语言并非孤立存在,具备广泛特性的高质量库至关重要。一些优雅而学术美观的语言在处理平凡任务时仍显光辉,例如与数据库交互或确定用户设置存储位置。无论语言提供了多么强大的编程习语,它还需要提供对底层平台服务的完整便捷访问。在这方面,C#表现非常强大,归功于其运行时、内置类库以及广泛的第三方库支持。
.NET 包括 C#程序使用的运行时和主要类库。运行时部分称为公共语言运行时(通常缩写为 CLR),因为它不仅支持 C#,还支持任何.NET 语言。例如,Microsoft 还提供 Visual Basic、F#以及 C++的.NET 扩展。CLR 具有公共类型系统(CTS),它使来自多种语言的代码可以自由互操作,这意味着.NET 库通常可以从任何.NET 语言中使用——F#可以使用用 C#编写的库,C#可以使用 Visual Basic 库,等等。
在 .NET 中内置了一套庞大的类库集合。多年来,这些类库曾用过几个名称,包括基础类库 (BCL)、框架类库和框架库,但是微软现在似乎已经将 运行时类库 定为 .NET 这一部分的名称。这些类库为许多底层操作系统 (OS) 功能提供了包装器,同时它们本身也提供了大量的功能,例如集合类和 JSON 处理等。
.NET 运行时类库并非全部内容——许多其他系统也提供了它们自己的 .NET 类库。例如,有些类库使得 C# 程序可以使用流行的云服务。正如你所预期的那样,Microsoft 提供了全面的 .NET 类库,用于与其 Azure 云平台上的服务进行交互。同样,亚马逊提供了一个功能完备的开发工具包,供使用 C# 和其他 .NET 语言访问 Amazon Web Services (AWS)。并且,类库并不一定要与特定服务相关联。有一个庞大的 .NET 类库生态系统,其中既有商业产品,也有免费产品,包括数学工具、解析类库以及用户界面 (UI) 组件等等。即使你不幸需要使用一个没有任何 .NET 类库包装器的操作系统特性,C# 也提供了各种机制,用于与其他类型的 API 进行交互,例如在 Win32、macOS 和 Linux 上可用的 C 风格 API,或者在 Windows 上基于组件对象模型 (COM) 的 API。
除了类库之外,还有许多应用框架。.NET 内置了用于创建 Web 应用程序和 Web API、桌面应用程序以及移动应用程序的框架。还有针对各种分布式系统开发风格的开源框架,例如高容量事件处理的 Reaqtor 或者全球分布式高可用系统的 Orleans。
最后,随着 .NET 已经存在了二十多年,许多组织已经大量投资于基于这一平台构建的技术。因此,C# 往往是获得这些投资回报的自然选择。
总之,使用 C# 我们得到了一组内置的强大抽象,一个强大的运行时,以及轻松访问大量的类库和平台功能。
托管代码和 CLR
C# 是第一种旨在成为 CLR 世界中本地语言的语言。这赋予了 C# 独特的感觉。这也意味着,如果你想理解 C#,你需要了解 CLR 及其运行代码的方式。
多年来,编译器处理源代码并生成可由计算机 CPU 直接执行的输出形式,一直是最常见的工作方式。编译器会生成机器码 ——符合计算机 CPU 所需的二进制格式的一系列指令。许多编译器仍然采用这种方式工作,但 C# 编译器不是这样。它使用一种称为托管代码的模型。
在托管代码中,编译器不会生成 CPU 执行的机器码。相反,编译器生成一种称为中间语言(IL)的二进制代码形式。可执行二进制通常在运行时生成,虽然不总是如此。使用 IL 使得在传统模型下难以或者甚至不可能提供的功能成为可能。
可能托管模型最明显的好处是,编译器的输出不与单一的 CPU 架构绑定。例如,大多数现代计算机使用的 CPU 支持 32 位和 64 位指令集(分别因历史原因而称为x86和x64)。在旧模型下将源代码编译成机器语言时,您需要选择要支持的指令集之一,并且在需要目标多个指令集时,需要构建多个版本的组件。但是在 .NET 中,您可以构建一个单一的组件,无需修改即可在 32 位或 64 位进程中运行。同一组件甚至可以在完全不同的架构上运行,例如 ARM(一种广泛用于手机、较新的 Mac 和树莓派等小型设备的处理器架构)。如果使用直接编译为机器码的语言,则需要为每种架构构建不同的二进制文件,或者在某些情况下,可能会构建一个包含多个代码副本的单一文件,每个副本针对每种支持的架构。在 .NET 中,您可以编译一个只包含一个代码版本的单一组件,它可以在任何架构上运行。即使在编译代码时未支持的平台未来提供了合适的运行时,这些组件也可以本地运行,而不依赖于通常用于使旧代码在新处理器上工作的Rosetta翻译技术。更一般地说,CLR 代码生成的任何改进——无论是对新 CPU 架构的支持还是对现有架构的性能改进——都会立即使所有 .NET 语言受益。例如,早期版本的 CLR 没有利用现代 x86 和 x64 处理器上可用的向量处理扩展,但当前版本通常在生成循环代码时会利用这些扩展。所有运行在当前 .NET 版本上的代码都从中受益,包括在此增强功能添加之前构建的组件。
CLR 生成可执行机器代码的确切时机可能会有所不同。通常情况下,它使用一种称为即时(JIT)编译的方法,即每个单独函数的机器代码在第一次运行时生成。但它不一定非得这样工作。运行时实现之一称为 Mono,能够直接解释 IL,而不必将其转换为可运行的机器语言,这在诸如 iOS 这样的平台上非常有用,因为法律约束可能阻止 JIT 编译。.NET 软件开发工具包(SDK)还提供了一个名为crossgen的工具,它使你能够在 IL 旁边构建预编译代码。这种提前编译(AoT)可以提高应用程序的启动时间。还有一个完全独立的运行时称为.NET Native,它仅支持预编译,并且被用于为通用 Windows 平台(UWP)构建的 Windows Store 应用程序。(请注意,微软已宣布 Windows 专用的.NET Native 运行时可能会被其跨平台后继者 NativeAOT 所取代,逐步淘汰。)
注
即使使用 crossgen 预编译代码,仍然可能在运行时生成可执行代码。CLR 的分层编译功能可以选择动态重新编译方法,以优化其在运行时的使用方式,无论您使用 JIT 还是 AoT,它都可以做到这一点。²
托管代码具有普遍存在的类型信息。.NET 运行时需要这些信息存在,因为它启用了某些运行时特性。例如,.NET 提供各种自动序列化服务,可以将对象转换为其状态的二进制或文本表示,并且稍后可以将这些表示再转换回对象,甚至可能在不同的计算机上。这种服务依赖于对象结构的完整和准确描述,在托管代码中是有保证的。类型信息还可以用于其他方面。例如,单元测试框架可以使用它来检查测试项目中的代码,并发现你编写的所有单元测试。这依赖于 CLR 的反射服务,这是第十三章的主题。
尽管 C#与运行时的紧密连接是其主要的定义特征之一,但这并不是唯一的特征。C#的设计背后有一定的哲学支持。
C#更偏向于泛化而不是特化
C#更倾向于通用语言特性而不是专用特性。C#现在已经是其第 10 个主要版本,并且每次发布时,语言的设计者都在设计新功能时考虑了特定的场景。然而,他们始终努力确保每个添加的元素在超出这些主要场景时也是有用的。
例如,几年前,C# 语言设计师决定向 C# 添加功能,使数据库访问与语言紧密集成。由此产生的技术,即语言集成查询(LINQ,详见 第十章),确实支持了这一目标,但他们在不向语言直接添加数据访问支持的情况下实现了这一点。设计团队引入了一系列看似差异很大的能力,包括更好地支持函数式编程习惯用法,能够在不使用继承的情况下向现有类型添加新方法,支持匿名类型,能够获取表示表达式结构的对象模型,并引入了查询语法。其中最后一个与数据访问有明显的关联,但其他的与当前任务的关联则较为困难。尽管如此,这些能力可以集体使用,显著简化某些数据访问任务。这些功能在其自身权威上都很有用,因此除了支持数据访问外,它们还能够支持更广泛的场景。例如,这些添加使得处理列表、集合和其他对象组变得更加容易,因为新功能适用于来自任何来源的事物集合,而不仅仅是数据库。
这种通用性哲学的一个例证是为 C# 原型化但最终设计师们选择不继续推进的语言功能。该功能将允许您直接在源代码中编写 XML,在运行时嵌入表达式以计算特定内容的值。该原型将其编译为在运行时生成完成的 XML 的代码。微软研究部门公开展示了这一功能,但这一特性最终没有进入 C#,尽管它后来在另一种 .NET 语言 Visual Basic 中推出,并为从 XML 文档中提取信息提供了一些专门的查询功能。嵌入式 XML 表达式是一个相对狭窄的功能,只在创建 XML 文档时有用。至于查询 XML 文档,C# 通过其通用的 LINQ 功能支持此功能,而无需任何特定于 XML 的语言功能。自从提出这个语言概念以来,XML 的星光已经逐渐黯淡,在许多情况下已被 JSON 取代(毫无疑问,这些年后将被其他东西所取代)。如果嵌入式 XML 最终进入了 C#,那么现在它可能会感觉像一个略显过时的奇特现象。
在后续版本的 C# 中添加的新功能继续沿着同样的思路发展。例如,跨过去几个版本添加的解构和模式匹配功能旨在以微妙但有用的方式简化生活,并且不限于任何特定的应用领域。
C# 标准与实现
在我们可以开始编写实际代码之前,我们需要知道我们正在目标化哪个 C# 实现和运行时。Ecma 标准化机构编写了定义 C# 语言和运行时行为的规范(分别是 ECMA-334 和 ECMA-335)。这使得多个 C# 实现和运行时得以出现。目前,广泛使用的有四种:Mono、.NET Native、.NET(之前称为 .NET Core)和 .NET Framework。有些令人困惑的是,微软背后支持了所有这些项目,尽管最初并非如此。
许多 .NET 实现
Mono 项目于 2001 年启动,并非起源于微软。(这就是为什么它的名字中没有 .NET,它可以使用 C# 这个名称,因为标准称该语言为 C#,但在 .NET 基金会成立前,.NET 品牌专门由微软使用。)Mono 最初的目标是在 Linux 上支持使用 C# 进行桌面应用程序开发,但后来它增加了对 iOS 和 Android 的支持。这一重要举措帮助 Mono 找到了自己的市场定位,因为它现在主要用于开发跨平台移动设备应用程序的 C#。现在,Mono 还支持目标 WebAssembly(也称为 WASM),并包括一个可以在任何符合标准的 Web 浏览器中运行的 CLR 实现,使得 C# 代码能够在 Web 应用程序的客户端上运行。这通常与一个名为 Blazor 的 .NET 应用程序框架一起使用,Blazor 允许您构建基于 HTML 的用户界面,同时使用 C# 实现行为。Blazor 与 WASM 的组合还使得 C# 成为与 Electron 等使用 Web 客户端技术创建跨平台桌面应用程序的平台合作的一种可行语言。(Blazor 不需要 WASM,它也可以使用正常编译的 C# 代码在 .NET 运行时上运行;这是 .NET 的多平台应用程序用户界面(MAUI)的基础,它使得编写可以在 Android、iOS、macOS 和 Windows 上运行的单一应用程序成为可能。)
Mono 从一开始就是开源的,并且在其存在的整个过程中得到了多家公司的支持。2016 年,微软收购了拥有 Mono 管理权的公司:Xamarin。目前,微软将 Xamarin 作为一个独立的品牌保留下来,并将其定位为编写可在移动设备上运行的跨平台 C#应用程序的方式。Mono 的核心技术已经并入了微软的.NET 运行时代码库。这是多年融合的终点,其中 Mono 逐渐与.NET 共享越来越多的共同点。最初,Mono 提供了自己的一套实现:C#编译器、库和 CLR。但是当微软发布了其自己的开源编译器时,Mono 工具就转移到了那里。Mono 曾经有自己完整的.NET 运行时库实现,但自从微软首次发布开源.NET Core 以来,Mono 越来越依赖于它。如今,Mono 实际上是主要.NET 运行时库中两个 CLR 实现之一,支持移动和 WebAssembly 运行时环境。
其他三种实现是什么情况呢?它们似乎都被称为.NET?其中之一是.NET Native,用于 UWP 应用程序,正如前文所述,这是.NET 的一种专门版本,仅支持 AoT 编译。然而,.NET Native 计划被 NativeAOT 取代,后者将有效地成为.NET 的一个特性,而不是完全独立的实现,因此在实际应用中,我们现在只有两个当前的、非注定失败的版本:.NET Framework(仅限 Windows,闭源)和.NET(跨平台,开源;以前称为.NET Core)。然而,正如前面提到的,微软不打算向仅限 Windows 的.NET Framework 添加任何新功能,因此这使得.NET 6.0 实际上是唯一的当前版本。
.NET 6 的一个主要目标是回归到一个主要的当前版本,这使得它成为一个特别重要的版本。然而,了解其他版本也是有用的,因为你可能会遇到继续在这些版本上运行的实时系统。.NET Framework 继续流行的一个原因是它可以做一些.NET 6.0 无法做到的事情。.NET Framework 仅在 Windows 上运行,而.NET 6.0 支持 Windows、macOS 和 Linux,尽管这使得.NET Framework 的可用性较小,但它可以支持一些 Windows 特定的功能。例如,.NET Framework 类库中有一个部分专门用于与 COM+组件服务一起工作,这是一个用于托管与 Microsoft 事务服务器集成的组件的 Windows 特性。这在新的跨平台.NET 版本上是不可能的,因为代码可能在 Linux 上运行,那里的等效功能要么不存在,要么与通过相同的.NET API 呈现的方式有太大不同。
在过去几个版本中,仅限于.NET Framework 的特性数量已经大幅减少,因为微软一直致力于使即使是仅限 Windows 的应用程序也能使用最新版本的.NET 6.0。例如,System.Speech .NET 库过去仅在.NET Framework 上可用,因为它提供对 Windows 特定的语音识别和合成功能的访问,但现在有了.NET 6.0 版本的这个库。该库仅在 Windows 上工作,但其可用性意味着依赖它的应用程序开发人员现在可以自由地从.NET Framework 转移到.NET。未能迁移的剩余.NET Framework 特性是那些使用不足以证明工程投入的特性。COM+支持不仅仅是一个库——它对 CLR 执行代码的方式有影响,因此在现代.NET 中支持它会带来难以接受的成本,这现在已经是一个很少使用的功能了。
跨平台的.NET 是过去几年中大部分.NET 新开发发生的地方。.NET Framework 仍然得到支持,但已经落后了一段时间。例如,微软的 Web 应用程序框架 ASP.NET Core 在 2019 年就停止了对.NET Framework 的支持。因此,.NET Framework 的退役和.NET 6.0 作为唯一真正的.NET 的到来,是一个已经进行了几年的过程的不可避免的结论。
发布周期和长期支持
微软目前每年发布一个新版本的.NET,通常在 11 月或 12 月左右发布,但并非所有版本都是平等的。备用版本会得到长期支持(LTS),这意味着微软承诺至少支持该版本三年。在此期间,工具、库和运行时将定期更新以提供安全补丁。.NET 6.0 在 2021 年 11 月发布,是一个 LTS 版本。之前的 LTS 版本是.NET Core 3.1,于 2019 年 12 月发布,因此支持将持续到 2022 年 12 月;再早之前的 LTS 版本是.NET Core 2.1,在 2021 年 8 月停止支持。
那么非 LTS 版本呢?这些版本在发布时得到支持,但在下一个 LTS 版本发布六个月后就会停止支持。例如,.NET 5.0 在 2020 年 12 月发布时得到了支持,但在.NET 6.0 发布后的 2022 年 5 月支持就结束了。当然,微软可以选择延长支持,但为了规划目的,假设非 LTS 版本在大约 18 个月内基本上就无法使用了是明智的。
生态系统通常需要几个月的时间才能跟上新版本的发布。实际上,在发布当天可能还不能使用新版本的 .NET,因为你的云平台提供商可能还不支持,或者可能存在你需要使用的库的不兼容性。这显著缩短了非 LTS 版本的有效使用寿命,并可能导致在下一个 LTS 版本出现时,升级的时间窗口非常狭窄而令人不安。如果工具、平台和依赖的库需要几个月才能与新版本对齐,那么在它退出支持之前,你将有很少的时间可以升级。在极端情况下,这个升级的机会甚至可能不存在:.NET Core 2.2 在 Azure Functions 完全支持 .NET Core 3.0 或 3.1 之前已经到了支持结束的生命周期,因此那些在 Azure Functions 上使用非 LTS .NET Core 2.2 的开发者发现自己处于一个最新支持版本实际上倒退的情况:他们不得不选择要么回退到 .NET Core 2.1,要么在生产中使用不支持的运行时几个月。因此,一些开发者把非 LTS 版本看作预览版本:你可以试验性地针对新功能,预期它们会在 LTS 版本中使用。
使用 .NET Standard 针对多个 .NET 版本的目标
长期以来,每个运行时版本的多样性,每个都有其自己不同的运行时库版本,对于希望将其 C# 代码提供给其他开发者的人来说一直是一个挑战。尽管我们最终看到的 .NET 6.0 的收敛可以减少这种问题,但想要继续支持运行在旧 .NET Framework 上的系统将是常见的。这意味着,为了可预见的未来,生产目标多个 .NET 运行时的组件将是有用的。有一个.NET 组件的包存储库,微软发布所有不属于 .NET 本身的 .NET 库的地方,也是大多数 .NET 开发者发布他们想要分享的库的地方。但是,你应该为哪个版本构建呢?这是一个二维的问题:有运行时实现(.NET、.NET Framework)和版本(例如,.NET Core 3.1 或 .NET 6.0;.NET Framework 4.7.2 或 4.8)。许多通过 NuGet 分发的热门开源软件包的作者支持多个新旧版本。
组件作者过去常常通过构建多个库的变体来支持多个运行时。当通过 NuGet 分发 .NET 库时,你可以在包中嵌入多组二进制文件,每组针对不同的 .NET 变体。然而,其中一个主要问题是,随着多年来出现了新形式的 .NET,现有库可能无法在所有新的运行时上运行。为 .NET Framework 4.0 编写的组件将适用于所有后续版本的 .NET Framework,但不适用于比如说 .NET 6.0。即使组件的源代码与较新的运行时完全兼容,你也需要编译一个针对该平台的单独版本。如果你使用的库的作者没有为 .NET 提供明确的支持,这将阻止你使用它。这对每个人都是不利的。多年来出现了各种版本的 .NET(比如 Silverlight 和几个 Windows Phone 变体),这意味着组件作者发现自己不得不不断推出其组件的新变体,并且因为这依赖于那些作者是否有这样做的意愿和时间,组件的消费者可能会发现并非所有他们想要使用的组件都在他们选择的平台上可用。
为了避免这种情况,微软推出了 .NET Standard,它定义了 .NET 运行时库 API 表面的常见子集。如果一个 NuGet 包的目标是,比如说,.NET Standard 1.0,这就保证它能在 .NET Framework 版本 4.5 或更高版本、.NET Core 1.0 或更高版本、.NET 5.0 及更高版本,或者 Mono 4.6 及更高版本上运行。至关重要的是,如果出现了另一个 .NET 的变种,只要它也支持 .NET Standard 1.0,现有的组件就能够在不需修改的情况下运行,即使在编写这些组件时,那个新平台还不存在。
今天,.NET Standard 2.0 很可能是希望支持广泛平台的组件作者的最佳选择,因为所有最近发布的 .NET 版本都支持它,并且它提供了非常广泛的功能集。然而,微软今天支持的 .NET 变体数量远低于 .NET Standard 首次推出时的水平,因此 .NET Standard 的重要性可能不如过去。如今,将代码目标设置为 .NET Standard 的主要好处是你的代码将在 .NET Framework 以及 .NET Core 和 .NET 上运行。如果你不需要支持 .NET Framework,将代码目标设置为 .NET Core 3.1 或 .NET 6.0 可能更合理。第十二章 详细描述了围绕 .NET Standard 的一些考虑。
微软不仅提供语言和各种运行时及其相关的类库,还提供可以帮助你编写、测试、调试和维护代码的开发环境。
Visual Studio、Visual Studio Code 和 JetBrains Rider
微软提供了三种桌面开发环境:Visual Studio Code、Visual Studio 和 Visual Studio for Mac。这三款产品都提供了基本功能,如文本编辑器、构建工具和调试器,但是 Visual Studio 为开发 C#应用程序提供了最全面的支持,无论这些应用程序是在 Windows 还是其他平台上运行。Visual Studio 已经存在很长时间——从 C#诞生之时起,因此它来自于开源之前的时代,并继续作为闭源产品存在。各种可用的版本从免费到价格高昂都有。微软并不是唯一的选择:开发者生产力公司 JetBrains 销售一款名为 Rider 的完整的.NET IDE,它能在 Windows、Linux 和 macOS 上运行。
Visual Studio 是一种集成开发环境(IDE),因此采用“一切包含”的方式。除了功能齐全的文本编辑器外,它还提供了用于 UI 可视化编辑的工具。它与 Git 等源代码控制系统以及提供源代码库、问题跟踪和其他应用生命周期管理(ALM)功能的在线系统(例如 GitHub 和 Microsoft 的 Azure DevOps 系统)深度集成。Visual Studio 提供内置的性能监控和诊断工具。它具有多种特性,用于处理开发和部署到 Microsoft 的 Azure 云平台的应用程序。它是这三个 Microsoft 环境中拥有最广泛重构功能集的产品之一。请注意,Visual Studio 仅在 Windows 上运行。
2017 年,微软发布了适用于 Mac 的 Visual Studio。这不是 Windows 版本的简单移植。它起源于一个名为 Xamarin 的平台,这是一个专门用于在 Mac 上构建运行在 Mono 运行时上的 C#移动应用程序的开发环境。Xamarin 最初是一项独立技术,但在微软收购了开发它的公司后,微软将 Windows 版 Visual Studio 的各种功能整合到了这个产品中,并将其纳入 Visual Studio 品牌。
JetBrains Rider IDE 是一款能在三个操作系统上运行的单一产品。它比 Visual Studio 更专注,因为它专门设计用于支持.NET 应用程序开发(Visual Studio 也支持 C++)。它采用了类似的“一切包含”方式,并提供了特别强大的重构工具。
Visual Studio Code(通常缩写为 VS Code)于 2015 年首次发布。它是开源且跨平台的,支持 Linux 以及 Windows 和 Mac 操作系统。它基于 Electron 平台,并主要使用 TypeScript 编写。(这意味着与 Visual Studio 不同,VS Code 在所有操作系统上确实是同一个程序。)VS Code 比 Visual Studio 更加轻量级:基本安装仅支持文本编辑。然而,当您打开文件时,它会发现可下载的扩展程序,如果选择安装,可以为 C#、F#、TypeScript、PowerShell、Python 和许多其他语言添加支持。(扩展机制是开放的,因此任何愿意的人都可以发布扩展。)因此,尽管在初始形式上它更像是一个简单的文本编辑器而不是一个集成开发环境(IDE),其可扩展性模型使其非常强大。广泛的扩展程序范围使得 VS Code 在微软语言以外的世界中非常流行,进而促进了扩展程序范围更大的增长的良性循环。
Visual Studio 和 JetBrains Rider 提供了最简单的路径来开始使用 C# - 您无需安装任何扩展程序或修改任何配置即可启动并运行。但是,由于 Visual Studio Code 面向更广泛的受众,因此我将在接下来的快速介绍中使用它来进行 C#的工作。尽管如此,所有环境都适用于相同的基本概念,因此如果您将使用 Visual Studio 或 Rider,则我在这里描述的大部分内容仍然适用。
提示
您可以免费下载Visual Studio Code。您还需要安装.NET SDK。
如果您使用 Windows 并希望使用 Visual Studio,您可以下载免费版本的 Visual Studio,称为Visual Studio Community。在安装期间,只要选择至少一个.NET 工作负载,它将为您安装.NET SDK。
任何非平凡的 C#项目都将具有多个源代码文件,并且这些文件将属于一个项目。每个项目构建一个单一的输出,或称为目标。构建目标可能会很简单,比如一个单文件 - 例如,一个 C#项目可以生成可执行文件或库 - 但某些项目会生成更复杂的输出。例如,某些项目类型会构建网站。网站通常包含多个文件,但总体而言,这些文件代表一个单一的实体:一个网站。每个项目的输出将作为一个单元部署,即使它由多个文件组成。
注意
在 Windows 上,可执行文件通常具有*.exe文件扩展名,而库使用.dll*(历史上简称为动态链接库)。然而,使用.NET,所有的代码都放在*.dll文件中。SDK 还可以生成引导执行文件(在 Windows 上具有.exe扩展名),但这只是启动运行时,然后加载包含主要编译输出的.dll文件。(如果你的目标是.NET Framework,则稍有不同:它会将应用程序直接编译为自我引导的.exe*,而不是分开的*.dll*。)无论如何,应用程序的主要编译输出和库的唯一区别在于前者指定了应用程序的入口点。这两种文件类型都可以导出供其他组件消费的功能。这些都是程序集的例子,是第十二章的主题。
C#项目文件使用*.csproj扩展名,如果你使用文本编辑器查看这些文件,你会发现它们包含 XML。.csproj*文件描述了项目的内容并配置了项目的构建方式。这些文件可以被 Visual Studio 和 VS Code 的.NET 扩展识别。它们也可以被各种命令行构建工具识别,例如.NET SDK 安装的dotnet命令行工具,以及微软的旧版 MSBuild 工具。(MSBuild 支持多种语言和目标,不仅仅是.NET。实际上,当你使用.NET SDK 的dotnet build命令构建 C#项目时,它实际上是 MSBuild 的一个包装。)
通常情况下,你会希望处理一组项目。例如,为你的代码编写测试是一个良好的实践,但大多数测试代码不需要作为应用程序的一部分部署,因此你通常会将自动化测试放入单独的项目中。你可能也想因其他原因拆分代码。也许你正在构建的系统有一个桌面应用程序和一个网站,而你希望在这两个应用程序中使用相同的通用代码。在这种情况下,你需要一个项目来构建包含通用代码的库,另一个生成桌面应用程序可执行文件,另一个构建网站,以及另外三个项目分别包含每个主项目的测试。
理解.NET 的构建工具和 IDE 帮助你通过所谓的解决方案来处理多个相关项目。解决方案是一个带有*.sln*扩展名的文件,定义了一组项目。尽管解决方案中的项目通常是相关的,但它们不一定非要相关。
如果你正在使用 Visual Studio,请注意,即使只有一个项目,它也要求项目属于一个解决方案。Visual Studio Code 可以愉快地打开单个项目,但其.NET 扩展也可以识别解决方案。
一个项目可以属于多个解决方案。在一个大型代码库中,通常会有多个带有不同项目组合的*.sln*文件。你通常会有一个包含每个项目的主解决方案,但并非所有开发人员都希望一直处理所有代码。在我们的假设示例中,处理桌面应用程序的人还会想要共享库,但可能对加载 Web 项目不感兴趣。
我将展示如何创建一个新项目,在 Visual Studio Code 中打开它并运行它。然后我将逐步介绍一个新的 C#项目的各种特性,作为语言介绍的一部分。我还会展示如何添加一个单元测试项目,以及如何创建一个包含两者的解决方案。
简单程序的解剖
一旦你安装了.NET 6.0 SDK,可以直接安装或者通过安装一个 IDE 来创建一个新的.NET 程序。首先在计算机上创建一个名为HelloWorld的新目录来保存代码。打开命令提示符,并确保当前目录设置为该目录,然后运行以下命令:
dotnet new console
这通过创建两个文件创建了一个新的 C#控制台应用程序。它创建一个基于父目录命名的项目文件:在这种情况下是HelloWorld.csproj。还会有一个包含代码的Program.cs文件。如果你在文本编辑器中打开这个文件,你会看到它非常简单,正如示例 1-1 所示。
示例 1-1. 我们的第一个程序
// See https://aka.ms/new-console-template for more information
Console.WriteLine("Hello, World!");
你可以使用以下命令编译并运行此程序:
dotnet run
正如你可能已经猜到的那样,这将显示文本Hello, World!作为输出。
如果你已经有一些 C#经验,并且正在阅读本书以了解 C# 10.0 中的新内容,这个例子可能会让你感到惊讶。在语言的早期版本中,所有编程书籍必须以经典的“Hello, World!”示例开头,而它要大得多。这看起来如此不同,以至于.NET SDK 的作者们认为有必要提供一个解释——这个例子的一半以上只是一个带有链接到网页的注释,解释其余代码的位置。这里的第二行就是你所需的全部内容。
这展示了 C# 10.0 引入的变化之一:它旨在通过减少样板代码的数量使应用程序直奔主题。样板代码是指需要存在以满足某些规则或约定的代码,但在任何项目中看起来多少都是一样的。例如,C#要求代码在方法内定义,而方法必须始终在类型内定义。你可以在示例 1-1 中看到这些规则的证据。为了产生输出,它依赖于.NET 运行时显示文本的能力,这体现在一个名为WriteLine的方法中。但我们不只是说WriteLine,因为 C#方法总是属于类型,这就是为什么代码将其标记为Console.WriteLine的原因。
当然,我们编写的任何 C# 代码都受到规则的约束,因此我们调用Console.WriteLine方法的代码本身必须存在于一个类型内的方法中。在大多数 C# 代码中,这是显式的:在大多数情况下,您将看到类似示例 1-2 的代码。
示例 1-2. 可见样板的“Hello, World!”
using System;
internal class Program
{
private static void Main(string[] args)
{
Console.WriteLine("Hello, World!");
}
}
在这里仍然只有一行定义应用程序行为的代码,与示例 1-1 中相同。第一个示例的明显优势在于它让我们集中精力在程序实际做什么上,尽管缺点是很多东西都会变得看不见。在示例 1-2 中采用显式风格,没有任何隐藏。在示例 1-1 中,编译器仍然会将代码放在一个名为Program的类型内定义的方法中;只是从代码中看不出来而已。在示例 1-2 中,方法和类型都是清晰可见的。
实际上,大多数 C# 代码看起来更像示例 1-2 而不是示例 1-1,因为 C# 10.0 的大部分样板减少措施只是为了程序入口点。当您编写希望在程序启动时执行的代码时,您不需要定义一个包含类或方法。但是一个程序只有一个入口点,对于其他所有内容,您仍然需要详细说明。
由于实际项目涉及多个文件,通常还涉及多个项目,让我们进入一个稍微现实的例子。我将创建一个计算一些数字平均值(确切地说是算术平均值)的程序。我还将创建第二个项目来自动测试我们的第一个项目。由于我有两个项目,这次我将需要一个解决方案。我将创建一个名为Averages的新目录。如果您在跟着做,无论放在哪里都没有关系,尽管最好不要将其放在第一个项目的目录内。我将在该目录中打开命令提示符并运行以下命令:
dotnet new sln
这将创建一个名为Averages.sln的新解决方案文件。(默认情况下,dotnet new通常根据其包含目录的名称命名新项目和解决方案,尽管您可以指定其他名称。)现在我将使用以下两个命令添加我需要的两个项目:
dotnet new console -o Averages
dotnet new mstest -o Averages.Tests
这里的-o选项(缩写为output)表示我希望每个新项目都在新的子目录中创建——当您有多个项目时,每个项目都需要其自己的目录。
现在我需要将它们添加到解决方案中:
dotnet sln add ./Averages/Averages.csproj
dotnet sln add ./Averages.Tests/Averages.Tests.csproj
我将使用第二个项目来定义一些测试,检查第一个项目中的代码(这就是为什么我指定了mstest项目类型——这个项目将使用微软的单元测试框架)。为了使其工作,第二个项目将需要访问第一个项目中的代码。为了实现这一点,我运行以下命令:
dotnet add ./Averages.Tests/Averages.Tests.csproj reference
./Averages/Averages.csproj
(我把它分成两行以便适应,但需要作为单个命令运行。) 最后,为了编辑项目,我可以使用以下命令在当前目录中启动 VS Code:
code .
如果你在跟着做,并且这是你第一次运行 VS Code,它会要求你做一些决策,比如选择一个配色方案。你可能会忽略它的问题,但此时它提供的其中一个选项是安装语言支持的扩展。人们使用 VS Code 来处理各种语言,安装程序不会假设你将使用哪种语言,所以你必须安装一个扩展来获取 C# 支持。但是如果你按照 VS Code 的指示浏览语言扩展,它会提供微软的 C# 扩展。如果 VS Code 没有提供这样做,请不要惊慌。也许你已经安装了它,所以它不再询问这些入门问题,或者自从我写这篇文章以来,Code 的首次运行行为发生了变化。你仍然可以非常容易地找到这个扩展。点击左侧栏上的 Extensions 图标,它将显示一组它认为可能相关的扩展。如果你在一个包含 .csproj 文件的目录中打开了 VS Code,这将包括 C# 扩展。如果其他方法都失败了,你可以搜索你需要的扩展。图 1-1 显示了 VS Code 的扩展面板——你可以通过点击左侧栏上的图标进入这个面板。这里底部显示的是四个方块的那一个。
图 1-1. Visual Studio Code 的 C# 扩展
正如你所见,我在顶部的搜索框中输入了C#,这里的第一个结果是微软的 C#扩展。还有几个其他结果也显示出来。如果你在跟着做,请确保选择正确的结果。如果你点击搜索结果,它将显示更详细的信息,其中应该显示其全名为“C# for Visual Studio Code (powered by OmniSharp)”,并且显示“Microsoft”作为发布者。点击安装按钮来安装这个扩展。
安装 C# 扩展可能需要几分钟时间,但一旦完成,窗口左下角的状态栏应该类似于 图 1-2,显示解决方案文件的名称和一个火焰图标,表示 OmniSharp 已准备好,这是在 VS Code 中提供 C# 支持的系统。可能会在窗口顶部出现一个项目选择器——C# 扩展已扫描解决方案目录并找到两个 C# 项目及其所在的解决方案。通常它会直接打开解决方案文件,但根据你的系统配置,它可能会询问你想使用哪个。我将在解决方案的两个项目中进行工作,所以我将选择 Averages.sln 条目。
Figure 1-2. Visual Studio Code 状态栏
现在 C# 扩展将检查解决方案中所有项目的所有源代码。显然,这些项目中目前没有太多内容,但随着我输入代码,它将继续分析,帮助我识别问题并提出建议。在此过程中,它会注意到尚未为项目配置构建和调试设置。如 Figure 1-3 所示,它会在窗口右下角显示一个对话框,提供添加这些设置的选项。建议点击“是”按钮,并在询问你要启动哪个项目时选择主程序 Averages.csproj,以便 VS Code 在运行或调试代码时知道要使用哪一个。
Figure 1-3. C# 扩展提供添加构建和调试资产的选项
我可以通过切换到资源管理器视图来查看代码,方法是点击左侧工具栏顶部的按钮。正如 Figure 1-4 所示,它显示目录和文件。我已展开 Averages.Test 目录并选择了其 UnitTest1.cs 文件。
Figure 1-4. Visual Studio Code 的资源管理器
小贴士
如果你在资源管理器面板中单击文件,VS Code 将在预览标签中显示它,这意味着它不会长时间保持打开状态:一旦你单击其他文件,它就会被替换。这样设计是为了避免打开数百个标签页,但如果你需要频繁在两个文件之间切换,这可能有些烦人。你可以通过双击文件来避免这种情况,这样会打开一个非预览标签,直到你有意关闭它为止。另外,如果你已经在预览标签中打开了一个文件,你可以双击标签将其转换为普通标签。VS Code 在预览标签中以斜体显示文件名,当你双击时,它将变为非斜体。
你可能会想知道为什么我展开了 Averages.Tests 目录。这个测试项目的目的是确保主项目的功能正常。我偏好在编写代码之前编写测试的开发风格,因此我会从测试项目开始。
编写单元测试
当我之前运行命令创建这个项目时,我指定了一个mstest项目类型。这个项目模板为我提供了一个测试类来启动我的工作,在一个名为UnitTest1.cs的文件中。我想选择一个更具信息性的名称。有多种关于如何组织单元测试的思路。一些开发人员主张为每个要测试的类编写一个测试类,但我喜欢的风格是为您想要测试特定类的每个场景编写一个类,并为该场景中您的代码应该正确的每个事物编写一个方法。此程序只有一种行为:计算其输入的算术平均值。因此,我将UnitTest1.cs源文件重命名为WhenCalculatingAverages.cs。(您可以通过右键单击 VS Code 的 Explorer 面板中的文件,并选择 Rename 条目来重命名文件。)此测试应验证我们对几个代表性输入得到了预期结果。示例 1-3 展示了一个完成此任务的完整源文件;这里有两个测试,用粗体显示。
示例 1-3. 我们第一个程序的单元测试类
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace Averages.Tests;
[TestClass]
public class WhenCalculatingAverages
{
`[TestMethod]`
`public` `void` `SingleInputShouldProduceSameValueAsResult``(``)`
`{`
`string``[``]` `inputs` `=` `{` `"1"` `}``;`
`double` `result` `=` `AverageCalculator``.``ArithmeticMean``(``inputs``)``;`
`Assert``.``AreEqual``(``1.0``,` `result``,` `1E-14``)``;`
`}`
`[TestMethod]`
`public` `void` `MultipleInputsShouldProduceAverageAsResult``(``)`
`{`
`string``[``]` `inputs` `=` `{` `"1"``,` `"2"``,` `"3"` `}``;`
`double` `result` `=` `AverageCalculator``.``ArithmeticMean``(``inputs``)``;`
`Assert``.``AreEqual``(``2.0``,` `result``,` `1E-14``)``;`
`}`
}
一旦展示了程序本身,我将解释该文件中的每个特性。目前,这个示例中最有趣的部分是两个方法。首先是SingleInputShouldProduceSameValueAsResult方法,它检查我们的程序是否正确处理只有一个输入的情况。此方法内的第一行描述了输入——一个数字。(有点令人惊讶的是,这个测试将数字表示为字符串。这是因为我们的输入最终将作为命令行参数,所以我们的测试需要反映这一点。)第二行执行了待测试的代码(实际上我还没有写)。第三行说明计算出的平均值应该等于唯一的输入。如果不是,则此测试将报告失败。第二个方法MultipleInputsShouldProduceAverageAsResult检查了稍微复杂一些的情况,其中有三个输入,但基本形状与第一个相同。
注意
这里我们使用了 C#的double类型,即双精度浮点数,以便能够处理不是整数的结果。在下一章中,我将更详细地描述 C#的内置数据类型,但要注意,与大多数编程语言一样,C#中的浮点运算精度有限。我在这里使用的Assert.AreEqual方法考虑到了这一点,并允许我指定最大的误差容限。每种情况下的最后一个参数1E-14表示数字 1 除以 10 的 14 次方,因此这些测试表明结果需要正确到小数点后 14 位。
让我们关注这些测试中的一个特定行:运行我想测试的代码的那一行。示例 1-4 显示了从 示例 1-3 中相关的行。这是在 C# 中调用返回结果的方法。这行代码首先声明一个变量来保存结果(double 表示数据类型,result 是变量的名称)。所有的 C# 方法都需要在一个类型内定义,就像我们之前在 Console.WriteLine 示例中看到的一样,在这里也是相同的形式:类型名称,然后是一个句点,然后是方法名称。然后在括号内是方法的输入。
示例 1-4. 调用一个方法
double result = AverageCalculator.ArithmeticMean(inputs);
如果你正在阅读时同时输入代码,首先:做得好。但是第二,如果你查看这行代码出现的两个地方(每个测试方法中一次),你可能会注意到 VS Code 在 AverageCalculator 下面画了一条波浪线。将鼠标悬停在这种波浪线上会显示一个错误消息,就像 图 1-5 所示的那样。
图 1-5. 一个未识别的类型
这告诉我们一些我们已经知道的事情:我还没有编写这个测试的代码。让我们解决这个问题。我需要添加一个新文件,在 VS Code 的资源管理器视图中,通过点击 Averages 目录,然后在选择了它之后,点击资源管理器顶部附近的最左侧按钮。当你将鼠标悬停在此按钮上时,会显示一个工具提示确认其用途。点击后,我可以输入 AverageCalculator.cs 作为新文件的名称。
图 1-6. 添加一个新文件
VS Code 将创建一个新的空文件。我将添加尽可能少的代码来修复 图 1-5 中报告的错误。示例 1-5 将满足 C# 编译器。它还不完整——它还没有执行必要的计算,但我们会解决这个问题。
示例 1-5. 一个简单的类
namespace Averages;
public static class AverageCalculator
{
public static double ArithmeticMean(string[] args)
{
return 1.0;
}
}
由于现在代码可以编译,我可以用以下命令运行测试:
dotnet test
这将产生以下输出:
Failed MultipleInputsShouldProduceAverageAsResult [291 ms]
Error Message:
Assert.AreEqual failed. Expected a difference no greater than <1E-14>
between expected value <2> and actual value <1>.
Stack Trace:
at Averages.Tests.WhenCalculatingAverages.
MultipleInputsShouldProduceAverageAsResult() in
C:\book\Averages\Averages.Tests\WhenCalculatingAverages.cs:line 21
Failed! - Failed: 1, Passed: 1, Skipped: 0, Total: 2,
Duration: 364 ms - Averages.Tests.dll (net6.0)
正如预期的那样,由于我还没有编写一个合适的实现,我们会得到失败的结果。但首先,我想逐个解释 示例 1-5 的每个元素,因为它对 C# 语法和结构的一些重要元素提供了一个有用的介绍。这个文件的第一件事就是一个 命名空间声明。
命名空间
命名空间为本应混乱不堪的事物带来了秩序和结构。.NET 运行时库包含大量类型,还有许多第三方库中的类型,更不用说你自己编写的类了。在处理这么多命名实体时会出现两个问题。首先,保证唯一性变得困难。其次,在未经组织的数万个事物中找到你需要的 API 可能会变得具有挑战性;除非你知道或能猜出正确的名称,否则很难找到所需的内容。命名空间解决了这两个问题。
大多数.NET 类型都定义在一个命名空间中。关于命名空间有一些约定,你会经常看到。例如,.NET 运行时库中的类型在以System开头的命名空间中。此外,微软提供了许多有用的库,虽然它们不是.NET 核心的一部分,但通常以Microsoft开头;或者,如果仅用于某些特定技术,它们可能以此命名。例如,有一些用于使用微软 Azure 云平台的库,它们在以Azure开头的命名空间中定义类型。来自其他供应商的库通常以公司名称或产品名称开头,而开源库通常使用其项目名称。你不必把自己的类型放入命名空间中,但建议这样做。C#并不把System作为特殊的命名空间,所以没有什么能阻止你将其用于自己的类型,但除非你正在编写将作为拉取请求提交给.NET 运行时源代码库的.NET 运行时库贡献,否则这是一个坏主意,因为它会导致其他开发者混淆。你应该为自己的代码选择更具有特色的名称,比如你的公司或项目名称。正如你可以从示例 1-5 的第一行看到的那样,我选择在名为Averages的命名空间中定义我们的AverageCalculator类,与我们的项目名称相匹配。
在 示例 1-5 中展示的命名空间声明风格是 C# 10.0 的新特性。如今,你可能会遇到的大多数代码都采用稍显冗长的旧式风格,如 示例 1-6 所示。两者的区别在于命名空间声明后跟着大括号 ({}),其作用仅限于大括号内的内容。这使得单个文件可以包含多个命名空间声明。但实际上,绝大多数 C# 文件只包含一个命名空间声明。在旧语法中,这意味着每个文件的绝大部分内容必须位于一对大括号内,缩进一个制表符。而 示例 1-5 中展示的新风格适用于文件中声明的所有类型,无需显式包裹。这是 C# 10.0 旨在减少源文件中无效冗余的一部分。
示例 1-6. C# 10.0 之前的命名空间声明
namespace Averages
{
public static class AverageCalculator
{
...as before...
}
}
命名空间通常提示类型的用途。例如,所有与文件处理相关的运行库类型都可以在 System.IO 命名空间中找到,而与网络相关的则位于 System.Net 下。命名空间可以形成层次结构。因此,框架的 System 命名空间包含类型以及其他命名空间,如 System.Net,而这些通常还包含更多的命名空间,如 System.Net.Sockets 和 System.Net.Mail。这些示例显示,命名空间充当一种描述,有助于你浏览库。例如,如果你在寻找正则表达式处理功能,你可能会浏览可用的命名空间,并注意到 System.Text 命名空间。在那里查找,你会找到一个 System.Text.RegularExpressions 命名空间,这时你会相当有信心你找对了地方。
命名空间还提供了确保唯一性的一种方式。类型定义所在的命名空间是其完整名称的一部分。这使得库可以为事物使用短小的简单名称。例如,正则表达式 API 包含一个 Capture 类,用于表示正则表达式捕获的结果。如果你正在开发处理图像的软件,术语 capture 通常用于表示获取某些图像数据,你可能认为在你自己的代码中 Capture 是描述最为准确的类名。如果你的图像获取代码根本不使用正则表达式,意味着你根本没有打算使用现有的 Capture 类型,那么因为最佳名称已经被使用而不得不选择其他名称会很令人恼火。
但事实上,这样也没问题。这两种类型都可以称为Capture,它们仍然会有不同的名称。正则表达式Capture类的完整名称实际上是System.Text.RegularExpressions.Capture,同样地,您类的完整名称将包括其所在的命名空间(例如,SpiffingSoftworks.Imaging.Capture)。
如果确实希望,您可以每次使用类型时都写出完全限定的名称,但大多数开发人员不想做这样单调乏味的事情,这就是我们在示例 1-2 和 1-3 开头看到的using指令的用处。在每个源文件的顶部看到一列指令是很常见的,它声明了该文件意图使用的类型的命名空间。在此示例中,dotnet命令行工具在创建测试项目时添加了using Microsoft.VisualStudio.TestTools.UnitTesting;。您会在不同的上下文中看到不同的集合。例如,如果添加一个代表 UI 元素的类,Visual Studio 会在列表中包含各种与 UI 相关的命名空间。
针对 C# 10.0 或更高版本的项目通常比您在为旧版本编写的项目中看到的using指令要少,这是因为有了一个新的语言特性:全局 using 指令。如果我们在指令前加上global关键字,如示例 1-7 所示,该指令适用于项目中的所有文件。然后,.NET SDK 进一步采取了措施,在您的项目中生成了一个隐藏文件,并使用一组这些global using指令来确保常用的命名空间,例如System和System.Collections.Generic可用。(隐式全局导入的确切命名空间集合因项目类型而异——例如,Web 项目会额外获取几个。如果您想知道为什么单元测试项目不会像示例 1-7 那样自动进行全局 using 指令,原因是.NET SDK 没有针对测试项目的特定项目类型——它认为它们只是一种类库。)
示例 1-7. 全局using指令
global using Microsoft.VisualStudio.TestTools.UnitTesting;
使用这样的using声明(可以是每个文件或全局),您可以仅使用类的简短、未限定的名称。使得示例 1-1 中的代码行能够发挥作用的代码行使用了System.Console类,但由于 SDK 为System命名空间添加了一个隐式的global using指令,因此它可以简称为Console。
注意
之前,我使用dotnet CLI 从我们的Averages.Tests项目向我们的Averages项目添加了一个引用。你可能会认为引用是多余的 — 编译器不能从命名空间中推断出我们正在使用的外部库吗?如果命名空间与库或包直接对应,那么它可能可以,但实际上并非如此。有时候会有表面上的关联 — 流行的Newtonsoft.Json NuGet 包含一个Newtonsoft.Json.dll文件,其中包含Newtonsoft.Json命名空间的类,例如。但通常情况下并没有这样的对应关系 — .NET 运行时库包括一个System.Private.CoreLib.dll文件,但却没有System.Private.CoreLib命名空间。因此,有必要告诉编译器你的项目依赖哪些库,以及使用了哪些命名空间。我们将在第十二章中更详细地讨论库文件的性质和结构。
即使使用了命名空间,仍然存在潜在的歧义。单个源文件可能使用两个命名空间,这两个命名空间恰好都定义了同名的类。如果要使用这个类,就需要显式地引用它的完整名称。如果在文件中经常需要使用这些类,你仍然可以节省些打字:只需使用完整名称一次,因为你可以定义一个别名。示例 1-8 使用别名来解决我遇到过几次的冲突:.NET 的桌面 UI 框架,Windows Presentation Foundation(WPF),定义了一个用于处理贝塞尔曲线、多边形和其他形状的Path类,但也有一个用于处理文件系统路径的Path类,你可能想要同时使用这两种类型来生成文件内容的图形表示。如果不加任何using指令直接使用这两个命名空间,简单名称Path会存在歧义。但正如示例 1-8 所示,你可以为每个类定义不同的别名。
示例 1-8. 使用别名消除歧义
using System.IO;
using System.Windows.Shapes;
`using` `IoPath` `=` `System``.``IO``.``Path``;`
`using` `WpfPath` `=` `System``.``Windows``.``Shapes``.``Path``;`
有了这些别名,你可以使用IoPath作为文件相关的Path类的同义词,而使用WpfPath作为图形化类的同义词。
顺便说一句,你可以在自己的命名空间中引用类型而无需限定符,也不需要using指令。这就是为什么示例 1-3 中的测试代码没有using Averages;指令的原因。不过,也许你会想知道这是如何工作的,因为测试代码声明了一个不同的命名空间Averages.Tests。要理解这一点,我们需要看看命名空间的嵌套。
嵌套命名空间
正如你已经看到的,.NET 运行时库会对其命名空间进行嵌套,有时候相当深入,你也经常会想要做同样的事情。你可以通过两种方式来实现这一点。你可以像示例 1-9 所示那样嵌套命名空间声明。
示例 1-9. 嵌套命名空间声明
namespace MyApp
{
namespace Storage
{
...
}
}
或者,您可以在单个声明中指定完整的命名空间,正如 示例 1-10 所示。这是更常用的风格。这种单一声明样式适用于新的 C# 10.0 样式声明或使用大括号的旧样式。
示例 1-10. 单个声明的嵌套命名空间
namespace MyApp.Storage;
任何您在嵌套命名空间中编写的代码都可以使用不仅来自该命名空间的类型,还可以使用其包含命名空间的类型而无需限定符。示例 1-9 或 1-10 中的代码不需要显式限定或 using 指令来使用 MyApp.Storage 命名空间或 MyApp 命名空间中的类型。这就是为什么在 示例 1-3 中,我不需要添加 using Averages; 指令来访问 Averages 命名空间中的 AverageCalculator:测试被声明在 Averages.Tests 命名空间中,因此自动具有对该外部命名空间的访问权限。
当您定义嵌套命名空间时,惯例是创建匹配的目录层次结构。一些工具期望如此。虽然 VS Code 目前在此方面没有特别的期望,但 Visual Studio 遵循此惯例。如果您的项目叫做 MyApp,那么当您向项目中添加新类时,它们将放在 MyApp 命名空间中。但如果您在项目中创建一个名为 Storage 的新目录,Visual Studio 将把您创建的任何新类放入 MyApp.Storage 命名空间中。再次强调,您并不需要保持这一点 —— Visual Studio 在创建文件时只是添加一个命名空间声明,您可以自由更改它。编译器不需要命名空间与目录层次结构匹配。但由于许多工具(包括 Visual Studio)支持这种约定,如果您遵循这种约定,生活会更轻松。
类
在命名空间声明之后,我们的 AverageCalculator.cs 文件定义了一个 class。 示例 1-11 展示了文件的这一部分。它以 public 关键字开始,这使得该类可以被其他组件访问。接下来是 static 关键字,表明此类不应该被实例化 —— 它仅提供类级别的操作而没有每个实例的特性。然后是 class 关键字,后跟名称,当然,该类型的完整名称实际上是 Averages.AverageCalculator,因为有了命名空间声明。正如您所见,C# 使用大括号({})来界定各种内容 —— 我们已经在旧的(但仍广泛使用的)命名空间声明语法中看到了这一点,这里您可以看到类似的情况,还有它包含的方法。
示例 1-11. 带有方法的类
public static class AverageCalculator
{
public static double ArithmeticMean(string[] args)
{
return 1.0;
}
}
类是 C#中定义结合状态和行为实体的机制,这是一种常见的面向对象习语。但这个类只包含一个方法。C#不支持全局方法——所有代码都必须作为某种类型的成员编写。因此,这个特定的类并不是很有趣——它的唯一作用是作为执行实际工作的方法的容器。在第三章中,我们将看到一些更有趣的类的用法。
与类一样,我将方法标记为public,以便从其他组件访问。我还声明了这是一个静态方法,这意味着不需要创建包含类型(在本例中为AverageCalculator)的实例即可调用该方法。后面跟随的double关键字表示此方法返回的数据类型是双精度浮点数。
方法声明后跟着方法体,例如本例中包含返回占位值的代码,因此剩下的工作就是修改方法体边界括号内的代码。示例 1-12 展示了计算平均值的代码,而不仅仅返回 1.0。
示例 1-12. 计算平均值
return args.Select(numText => double.Parse(numText)).Average();
这依赖于处理集合的库函数,这些函数是作为 LINQ 功能集的一部分,即语言集成查询的一部分。这是第十章的主题。但是,简单描述一下这里发生的情况,Select方法允许我们对集合中的每个项应用操作,在这种情况下,我应用的操作是double.Parse方法,它是一个.NET 运行时库函数,用于将包含数字的文本字符串转换为本机双精度浮点类型。然后我们通过Average方法将这些转换后的结果推送,该方法为我们执行计算。
设置好这些之后,如果再次运行dotnet test,它将报告所有测试都已通过。因此,显然代码是有效的。但是,如果我试图通过运行程序来非正式验证它,我会遇到一个问题,我可以用这个命令来执行:
./Averages/bin/Debug/net6.0/Averages 1 2 3 4 5
这只是将Hello, World!输出到屏幕上。我已经编写并测试了执行所需计算的代码,但尚未将其连接到程序的入口点。程序启动时运行的代码位于Program.cs中,尽管该文件名并不特殊。程序入口点可以位于任何文件中。在较早版本的 C#中,您通过定义一个名为Main的static方法来表示入口点,就像示例 1-2 所示。但从 C# 10.0 开始,您可以添加一个包含可执行语句的文件,而无需显式将它们放在类型的方法中,C#编译器将其视为入口点。(您只允许在项目中有一个以这种方式编写的文件,因为程序只能有一个入口点。)如果我用示例 1-13 中显示的代码替换Program.cs的整个内容,它将产生预期的效果。
示例 1-13. 带参数的程序入口点
using Averages;
Console.WriteLine(AverageCalculator.ArithmeticMean(args));
请注意,当您使用 C# 10.0 的新简化程序入口点语法时,该文件中的代码默认不属于任何命名空间,因此我需要声明我想要使用在Averages命名空间中定义的类。之后,这段代码调用我之前编写的方法,并将args作为参数传递,然后调用Console.WriteLine来显示结果。当您使用这种程序入口点样式时,args是一个特殊名称——它实际上是一个隐式定义的本地变量,提供对命令行参数的访问。这将是一个字符串数组,每个参数对应一个条目。如果您希望再次使用相同的参数运行程序,请先运行dotnet build命令重新构建它。
提示
一些 C 家族语言将程序本身的文件名作为第一个参数包含在内,因为它是用户在命令提示符下键入的一部分。C#不遵循这种约定。如果程序在没有参数的情况下启动,数组的长度将为 0。您可能已经注意到,该代码在这种情况下处理得不好。请随意添加一个定义相关行为的新测试场景,并修改程序以匹配。
单元测试
现在程序已经运行正常,我想回到测试,因为它们展示了一些主程序中没有的 C#特性。如果你回顾一下示例 1-3,它从一个相当普通的方式开始:我们有一个using指令,然后是一个命名空间声明,这次是为Averages.Tests,与测试项目名称匹配。但这个类看起来有些不同。示例 1-14 展示了示例 1-3 中相关的部分。
Example 1-14. 具有属性的测试类
[TestClass]
public class WhenCalculatingAverages
{
在类声明之前的文本是 [TestClass]。这是一个 属性。属性是你可以应用于类、方法和代码的其他特性的注解。它们中的大多数本身什么都不做——编译器只会在编译输出中记录属性的存在。属性只有在某些情况下才有用,因此它们倾向于被框架使用。在这种情况下,我使用的是微软的单元测试框架,它会寻找带有 TestClass 属性的类。它会忽略没有此注解的类。属性通常特定于特定的框架,你也可以定义自己的属性,正如我们将在 第十四章 中看到的。
类中的两个方法也标有属性。示例 1-15 展示了来自 示例 1-3 的相关摘录。测试运行器将执行任何标记有 [TestMethod] 属性的方法。
示例 1-15. 标记方法
[TestMethod]
public void SingleInputShouldProduceSameValueAsResult()
...
[TestMethod]
public void MultipleInputsShouldProduceAverageAsResult()
...
我们已经检查了程序的每个元素以及验证其正常工作的测试项目。
摘要
现在你已经看到了 C# 程序的基本结构。我创建了一个包含两个项目的解决方案,一个用于测试,一个用于程序本身。这是一个简单的例子,因此每个项目只有一个或两个感兴趣的源文件。必要时,这些文件以 using 指令开头,指示文件使用的类型。程序的入口点使用了 C# 10.0 的新精简样式,但另外两个项目使用了更传统的结构,包含一个声明命名空间的命名空间声明,以及包含一个或多个方法或其他成员(如字段)的类。
我们将在 第三章 中更详细地讨论类型及其成员,但首先,第二章 将处理位于方法内部的代码,其中我们表达了我们的程序想要做什么。
¹ 旧的 .NET Framework 将继续得到支持很多年,但微软已经表示它将不会获得任何新功能。
² .NET Native 和 NativeAOT 并不这样做:它们专为避免任何运行时 JIT 而设计,因此它们不提供分层编译。
³ 如果你想知道这些版本号和日期如何与年度交替发布相符,当前的时间表是从 .NET Core 3.1 开始介绍的,没有 .NET Core 4。当 .NET Core 被重新命名为纯粹的 .NET 时,它从 3.1 跳到了 5.0,以强调这一点是从 .NET Framework 转移,其最新版本为 4.8。
⁴ 或者 .NET Core。这里的名称变更可能会引起混淆。支持 .NET Core 3.1 的组件将在 .NET 5.0 和 .NET 6.0 上运行,因为它们是同一运行时的更新版本;在 .NET 5.0 发布时,它只是去掉了 Core 这个词,并跳过了一个版本号。
第二章:C# 基本编码
所有编程语言都必须提供一定的功能。我们必须能够表达代码应执行的计算和操作。程序需要能够根据输入做出决策。有时我们需要重复执行任务。这些基本功能是编程的基础,本章将展示这些功能在 C# 中的工作原理。
根据你的背景,本章的部分内容可能非常熟悉。C# 被称为“C 家族”语言的一员。C 是一种极具影响力的编程语言,许多语言借鉴了其语法。有直接的后继者,如 C++ 和 Objective-C。还有更远的关联语言,包括 Java、JavaScript 和 C# 本身,它们没有与 C 的兼容性,但仍然复制了其语法的许多方面。如果你熟悉这些语言中的任何一种,你将会认识到我们即将探讨的许多语言特性。
我们在第一章中看到了程序的基本要素。在本章中,我们将仅关注方法内的代码。正如你所见,C# 需要一定的结构:代码由位于方法内的语句组成,该方法属于一个类型,通常位于一个命名空间内,所有这些都在一个项目的文件中,通常包含在一个解决方案中。(在程序入口点的特殊情况下,由于 C# 10.0 的简化特性,包含的方法和类型可能会隐藏起来,但在大多数文件中它们是可见的。)为了清晰起见,本章的大多数示例将单独显示感兴趣的代码,例如示例 2-1。
示例 2-1. 代码及其余无余地
Console.WriteLine("Hello, World!");
虽然 C# 10.0 接受更短的示例作为程序的全部内容,但任何大于单个文件的程序(即几乎所有有用的程序)都需要明确包含其他元素。因此,除非我另有说明,这种摘录是为了在合适结构化的文件内显示上下文中的代码。例如,像示例 2-1 这样的示例相当于更像示例 2-2。
示例 2-2. 整段代码
using System;
internal class MyType
{
private static void SomeMethod()
{
Console.WriteLine("Hello, World!");
}
}
虽然我会在本节介绍语言的基本要素,但这本书是给那些已经熟悉至少一种编程语言的人看的,所以我会相对简短地介绍语言的最常见特性,并会更详细地讲解那些特别适用于 C# 的方面。
局部变量
不可避免的“Hello, World!”示例缺少一个重要元素:它实际上并未处理信息。有用的程序通常会获取、处理和生成数据,因此定义和标识数据的能力是语言中最重要的功能之一。与大多数语言一样,C# 允许您定义本地变量,这些是方法内的命名元素,每个都包含一部分信息。
注意
在 C# 规范中,术语变量可以指本地变量,也可以指对象中的字段和数组元素。本节完全涉及本地变量,但是继续阅读本地前缀会有点累。因此,在本节中,变量指的是本地变量。
C# 是一种静态类型语言,这意味着代码中任何代表或产生信息的元素(如变量或表达式)在编译时都有确定的数据类型。这与动态类型语言(如 JavaScript)不同,后者在运行时确定类型。¹
看到 C# 的静态类型在简单变量声明中的实际运行方式最简单的方法是,例如在示例 2-3 中的简单变量声明。每个变量声明以数据类型开头,前两个变量是string类型,接着两个是int类型。这些类型分别表示文本字符串和 32 位有符号整数。
示例 2-3. 变量声明
string part1 = "the ultimate question";
string part2 = "of something";
int theAnswer = 42;
int andAnotherThing;
数据类型紧跟变量名之后。变量名必须以字母或下划线开头,后面可以跟任意字母、十进制数字和下划线的组合。(至少在 ASCII 码情况下是这样。C# 支持 Unicode,因此如果您以 UTF-8 或 UTF-16 格式保存文件,标识符中第一个字符后面的字符可以是 Unicode 规范“标识符和模式语法”附录中描述的任何字符。这包括各种重音符号、变音符号和许多标点符号,但只有 Unicode 标识为用于单词内部的字符可以用于分隔单词的字符不能用。)这些规则同样适用于 C# 中任何用户定义实体的合法标识符,如类或方法。
示例 2-3 显示了几种变量声明的形式。前三个变量包括一个初始化器,提供变量的初始值,但是如最后一个变量所示,这是可选的。这是因为您可以在任何时候将新值赋给变量。示例 2-4 继续自示例 2-3,展示了不管变量是否有初始值,都可以向变量赋新值。
示例 2-4. 为先前声明的变量赋值
part2 = " of life, the universe, and everything";
andAnotherThing = 123;
因为变量具有静态类型,所以编译器将拒绝尝试分配错误类型的数据。因此,如果我们从示例 2-3 继续使用示例 2-5 中的代码,编译器将会抱怨。它知道名为theAnswer的变量具有int类型,这是一个数值类型,因此如果我们尝试将文本字符串分配给它,它将报告一个错误。
示例 2-5。一个错误:错误的类型
theAnswer = "The compiler will reject this";
在 JavaScript 等动态语言中,您允许这样做,因为在这些语言中,变量没有自己的类型 - 所有的一切都取决于它包含的值的类型,并且随着代码运行,它可以改变。在 C#中,可以通过声明具有类型dynamic或object的变量来执行类似的操作(稍后在“动态”和“对象”中描述)。但是,在 C#中最常见的做法是使变量具有更具体的类型。
注意
静态类型并不能始终提供完整的图片,多亏了继承。我会在第六章中讨论这个问题,但现在知道一些类型可以通过继承来扩展就足够了,如果一个变量使用了这样的类型,那么它可能引用从变量的静态类型派生的类型的某些对象。接口,在第三章中描述,提供了一种类似的灵活性。但是,静态类型总是决定您可以对变量执行哪些操作。如果您想使用一些特定派生类型的附加成员,您将无法通过基础类型的变量来执行。
您不必明确声明变量类型。您可以使用关键字var代替数据类型,让编译器为您完成。示例 2-6 显示了来自示例 2-3 的前三个变量声明,但使用var代替显式数据类型。
示例 2-6。使用var关键字的隐式变量类型
var part1 = "the ultimate question";
var part2 = "of something";
var theAnswer = 40 + 2;
这段代码经常会误导那些了解一些 JavaScript 的人,因为 JavaScript 中也有一个var关键字,可以以类似的方式使用。但是var在 C#中的工作方式与 JavaScript 不同:这些变量仍然都是静态类型的。改变的只是我们没有说类型是什么 - 我们让编译器为我们推断。它查看初始化程序,并可以看到前两个变量是字符串,而第三个是整数。(这就是为什么我从示例 2-3 中省略了第四个变量andAnotherThing。它没有初始化程序,所以编译器无法推断其类型。如果尝试在没有初始化程序的情况下使用var关键字,会收到编译器错误。)
你可以证明使用 var 声明的变量是静态类型的,通过尝试将不同类型的东西分配给它们。我们可以重复在 示例 2-5 中尝试的相同事情,但这次使用 var 样式的变量。示例 2-7 这样做,它会产生完全相同的编译器错误,因为这是相同的错误——我们试图将文本字符串分配给不兼容类型的变量。这里的变量 theAnswer 在这里的类型是 int,尽管我们没有明确说明。
示例 2-7. 错误:错误的类型(再次)
var theAnswer = 42;
theAnswer = "The compiler will reject this";
对于何时以及如何使用 var 关键字,意见分歧很大,后面的边栏 “To var, or Not to var?” 描述了这一点。
声明的最后一个值得知道的是,你可以在一行中声明并选择性地初始化多个变量。如果你需要多个相同类型的变量,这可能会减少代码的混乱。在 示例 2-8 中,它声明了三个相同类型的变量,并初始化了其中的两个。
示例 2-8. 单次声明中的多个变量
double a, b = 2.5, c = -3;
无论你如何声明它,变量都保存特定类型的某些信息,并且编译器会阻止我们将不兼容类型的数据放入该变量中。变量之所以有用,仅仅是因为我们稍后可以在代码中引用它们。示例 2-9 从我们之前看到的变量声明开始,然后继续使用这些变量的值来初始化更多变量,并显示结果。
示例 2-9. 使用变量
string part1 = "the ultimate question";
string part2 = "of something";
int theAnswer = 42;
part2 = "of life, the universe, and everything";
string questionText = "What is the answer to " + part1 + ", " + part2 + "?";
string answerText = "The answer to " + part1 + ", " +
part2 + ", is: " + theAnswer;
Console.WriteLine(questionText);
Console.WriteLine(answerText);
顺便说一句,这段代码依赖于 C# 对 + 运算符的几种含义的定义,当它与字符串一起使用时。首先,当你将两个字符串“相加”在一起时,它们会连接起来。其次,当你将不是字符串的东西添加到字符串的末尾(正如 answerText 的初始化器所做的那样——它添加了一个数字 theAnswer),C# 会生成将值转换为字符串然后附加它的代码。因此,示例 2-9 会产生如下输出:
What is the answer to the ultimate question, of life, the universe, and everythi
ng?
The answer to the ultimate question, of life, the universe, and everything, is:
42
注意
在本书中,超过 80 个字符的文本会被换行以适应页面。如果您尝试这些示例,如果您的控制台窗口配置为不同的宽度,则它们将看起来不同。
当你使用一个变量时,它的值就是你最后分配给它的值。如果你尝试在分配值之前使用一个变量,正如 示例 2-10 所做的那样,C# 编译器将报告一个错误。
示例 2-10. 错误:使用未赋值的变量
int willNotWork;
Console.WriteLine(willNotWork);
编译后,第二行会产生以下错误:
error CS0165: Use of unassigned local variable 'willNotWork'
编译器使用一种稍微悲观的系统(称为明确的赋值规则)来确定变量是否已经有值。在每种可能的情况下都无法创建能够确定这些事情的算法。² 由于编译器必须谨慎处理,有些情况下变量在执行相关代码时已经有值,但编译器仍会抱怨。解决方案是编写一个初始化器,以便变量始终包含某些内容,例如对于数值使用0,对于布尔变量使用false。在第三章中,我将介绍引用类型,顾名思义,这种类型的变量可以保存对类型实例的引用。如果需要在有东西可以引用之前初始化这样的变量,可以使用关键字null,表示一个指向无内容的引用。或者,您可以使用关键字default来初始化任何类型的变量,它表示零、false或null的值。
明确的赋值规则决定了编译器认为变量包含有效值的代码部分,并因此允许您从中读取。写入变量的操作不受限制,但正如您可能预料的那样,任何给定变量只能从代码的某些部分访问。让我们看看控制这些规则的细则。
范围
变量的作用域是您可以通过其名称引用该变量的代码范围。变量并非唯一具有作用域的事物。方法、属性、类型,实际上,所有具有名称的东西都有作用域。这些需要扩展作用域的定义:它是您可以在代码中通过名称引用实体而无需额外限定的部分。当我写Console.WriteLine时,我是通过其名称(WriteLine)引用方法,但我需要使用类名(Console)加以限定,因为该方法不在作用域内。但对于局部变量,作用域是绝对的:要么可以无需限定就可以访问,要么根本无法访问。
广义上讲,变量的作用域从其声明开始,到其所在的块结束。(某些语句,如循环,通过将变量声明放在其所在作用域之前来使此过程复杂化。)块是由一对大括号({})界定的代码区域。方法体就是一个块,因此在一个方法中定义的变量在另一个方法中是不可见的,因为它超出了作用域。如果尝试编译示例 2-11,将会收到一个错误,指出当前上下文中不存在名称'thisWillNotWork'。
Example 2-11. 错误:超出范围
static void SomeMethod()
{
int thisWillNotWork = 42;
}
static void AnUncompilableMethod()
{
Console.WriteLine(thisWillNotWork);
}
方法通常包含嵌套块,特别是当你使用本章稍后将介绍的循环和流控制结构时。在嵌套块开始的地方,外部块中的所有作用域继续在该嵌套块内部有效。示例 2-12 声明了一个名为 someValue 的变量,然后在 if 语句的一部分中引入了一个嵌套块。此块内的代码能够访问在包含块中声明的该变量。
示例 2-12. 在块外声明变量,在块内使用
int someValue = GetValue();
if (someValue > 100)
{
Console.WriteLine(someValue);
}
逆否命题并不成立。如果你在嵌套块中声明一个变量,其作用域不会延伸到该块外部。因此,示例 2-13 编译将失败,因为 willNotWork 变量只在嵌套块内部有效。由于试图在该块外部使用该变量,最后一行代码将产生编译器错误。
示例 2-13. 错误:尝试使用不在作用域内的变量
int someValue = GetValue();
if (someValue > 100)
{
int willNotWork = someValue - 100;
}
Console.WriteLine(willNotWork);
这可能看起来相当简单,但当涉及到潜在的命名冲突时情况会变得更加复杂。在这里,C#有时会让人感到意外。
变量名称的歧义
考虑 示例 2-14 中的代码。这里声明了一个名为 anotherValue 的变量在一个嵌套块内。正如你所知,该变量仅在该嵌套块的末尾处于作用域内。在该块结束后,我们尝试声明另一个同名变量。
示例 2-14. 错误:令人惊讶的名称冲突
int someValue = GetValue();
if (someValue > 100)
{
int anotherValue = someValue - 100; // Compiler error
Console.WriteLine(anotherValue);
}
int anotherValue = 123;
这导致了在第一行声明 anotherValue 时的编译器错误:
error CS0136: A local or parameter named 'anotherValue' cannot be declared in
this scope because that name is used in an enclosing local scope to define a
local or parameter
这似乎有些奇怪。在最后一行,所谓的冲突早期声明已经不在作用域内,因为我们已经超出了它所声明的嵌套块。此外,第二个声明在该嵌套块内也不在作用域内,因为声明发生在块之后。尽管作用域不重叠,但尽管如此,我们仍然在处理 C#避免名称冲突规则时遇到问题。要了解为什么此示例失败,首先需要看一个不那么令人意外的示例。
C# 试图通过不允许一个名称可能指代多个东西的代码来避免歧义。示例 2-15 展示了它旨在避免的问题类型。这里我们有一个名为 errorCount 的变量,并且代码在进展过程中开始修改它,³ 但在中途,它在一个嵌套块中引入了一个新的同名变量,也叫 errorCount。可以想象一种允许这种情况的语言——你可以有一个规则,即当多个同名项处于作用域内时,只选择最后声明的那个。
示例 2-15. 错误:隐藏一个变量
int errorCount = 0;
if (problem1)
{
errorCount += 1;
if (problem2)
{
errorCount += 1;
}
// Imagine that in a real program there was a big
// chunk of code here before the following lines.
int errorCount = GetErrors(); // Compiler error
if (problem3)
{
errorCount += 1;
}
}
C#选择不允许这种情况,因为这样的代码很容易误解。这是一个人为缩短的方法,因为它是书中的一个假设示例,所以很容易看到重复的名称,但如果代码再长一点,很容易忽略嵌套变量声明。那么,在方法结束时,我们可能意识不到errorCount的含义与之前不同。C#简单地禁止这种情况以避免误解。
但是为什么示例 2-14 会失败呢?这两个变量的作用域并不重叠。嗯,事实证明,禁止示例 2-15 的规则并不基于作用域。它基于一个微妙不同的概念,叫做声明空间。声明空间是代码中的一个区域,在这个区域中,一个名称不能指代两个不同的实体。每个方法为变量引入一个声明空间。嵌套块也会引入声明空间,而在嵌套声明空间中声明与其父声明空间中同名变量是非法的。这就是我们在这里违反的规则——示例 2-15 中最外层的声明空间包含一个名为errorCount的变量,而嵌套块的声明空间试图引入另一个同名变量。
如果这一切看起来有点枯燥或武断,了解为什么有一个完全独立的名称冲突规则集合可能会有所帮助,而不是基于作用域。声明空间规则的意图大部分情况下不应受到声明放置位置的影响。如果你将一个块中的所有变量声明移动到该块的开头——某些组织有规范要求这种布局——这些规则的理念就是这不应该改变代码的含义。如果示例 2-15 是合法的,这将是不可能的。这也解释了为什么示例 2-14 是非法的。虽然作用域不重叠,但如果你将所有变量声明移动到包含块的顶部,它们将会重叠。
局部变量实例
变量是源代码的特征,因此每个特定变量都有一个独特的身份:它在源代码中只声明一次,并且在一个明确定义的地方超出作用域。但这并不意味着它对应于内存中的单个存储位置。通过递归、多线程或异步执行,可能会同时存在单个方法的多个调用。
每次方法运行时,它都会获得一组独特的存储位置来保存局部变量的值。这使得多个线程可以同时执行同一个方法而不会出现问题,因为每个线程都有自己的局部变量集合。同样,在递归代码中,每个嵌套调用都会获得自己的局部变量集合,不会干扰任何调用它的方法。对于同一方法的多个并发调用也是如此。严格来说,每个特定作用域的执行都有自己的变量集合。当您使用匿名函数时,这一区别很重要,详见第九章中的描述。作为优化,C# 在可能的情况下会重用存储位置,因此只有在真正需要时才会为每个作用域的执行分配新内存。(例如,除非您将其置于必须这样做的情况中,否则不会为循环体内声明的变量每次迭代分配新内存。)但其效果就像每次都分配了新空间一样。
请注意,C# 编译器对于变量存放位置并没有特别的保证(除了一些特殊情况,我们将在第十八章中看到)。它们可能存在于堆栈上,但有时并非如此。当我们在后面的章节中看匿名函数时,您将看到有时变量需要超出声明它们的方法的生存期,因为它们在嵌套方法中仍处于作用域中,这些嵌套方法将在包含方法返回后作为回调运行。
顺便说一句,在我们继续之前,请注意,变量不是唯一具有作用域的事物,还有适用于声明空间的规则的其他语言特性,我们稍后会看到,包括类、方法和属性等。
语句和表达式
变量为我们提供了一个存放代码处理信息的地方,但要对这些变量进行任何操作,我们需要编写一些代码。这意味着编写语句和表达式。
语句
当我们编写一个 C# 方法时,我们实际上是在编写一系列语句。非正式地说,方法中的语句描述了我们希望方法执行的操作。示例 2-16 中的每一行都是一个语句。也许有些诱人的想法认为语句是一个指令(例如初始化变量或调用方法)。或者您可能采用更词法化的观点,认为任何以分号结尾的东西都是语句。(顺便说一句,这里重要的是分号,而不是换行。我本可以将其写成一行长代码,它的意义完全相同。)然而,这两种描述都过于简单化,尽管它们在这个特定示例中恰好是正确的。
示例 2-16. 一些语句
int a = 19;
int b = 23;
int c;
c = a + b;
Console.WriteLine(c);
C#识别许多不同类型的语句。 示例 2-16 的前三行是声明语句,用于声明并可选地初始化变量。第四和第五行是表达式语句。但是有些语句比这个例子中的更有结构。
当你编写一个循环时,那是一个迭代语句。当你在本章后面描述的使用if或switch机制来选择各种可能操作时,那些是选择语句。实际上,C#规范区分了 13 种语句类别。大多数都可以广泛地归类为描述代码接下来应该做什么,或者对于循环或条件语句等功能,描述如何决定接下来该做什么。第二类语句通常包含一个或多个嵌入语句,描述在循环中执行的操作,或者在if语句的条件满足时执行的操作。
有一种特殊情况。块是一种语句。这使得诸如循环之类的语句比通常更有用,因为循环只是迭代一个嵌入的语句。那个语句可以是一个块,而由于块本身是一系列语句(用大括号分隔),这使得循环可以包含多于一个语句。
这解释了为什么前面提到的两种简单观点——“语句是行动”和“语句是以分号结尾的东西”——都是错误的。比较示例 2-16 和 2-17。两者做的事情是一样的,因为我们想要执行的各种操作保持完全一样,并且两者都包含五个分号。然而,示例 2-17 包含了一个额外的语句。前两个语句是相同的,但后面跟着一个第三个语句,一个块,其中包含了来自 示例 2-16 的最后三个语句。这个额外的语句,即块,既不以分号结尾,也不执行任何操作。在这个特定的例子中,它是无意义的,但有时候引入这样一个嵌套块可以避免名称歧义错误。因此,语句可以是结构性的,而不是在运行时引起任何事情发生。
示例 2-17. 一个块
int a = 19;
int b = 23;
{
int c;
c = a + b;
Console.WriteLine(c);
}
虽然你的代码将包含多种类型的语句,但最终至少会包含一些表达式语句。表达式语句是由合适的表达式后跟一个分号组成的语句。什么是合适的表达式?实际上什么是表达式?在回到组成语句的有效表达式之前,我最好先回答第二个问题。
表达式
微软对 C# expression的官方定义相当枯燥:“一系列操作符和操作数。”尽管如此,语言规范往往是这样的,但除了这种形式化的散文外,C#规范还包含一些非常可读的非正式解释更正式表达的想法。 (例如,在说明语句作为表达程序操作的手段之前,它描述了表达式的含义,然后用不太接近但技术上更精确的语言来确定。)本段开头的引语来自表达式的正式定义,所以我们可能希望在引言中的非正式解释将更有帮助。没那么幸运:它说表达式“是从操作数和操作符构造的。”这当然不如其他定义那么精确,但理解起来也不容易。问题在于,有几种类型的表达式,它们执行不同的工作,所以没有单一的、通用的、非正式的描述。
描述一个表达式为产生值的代码是很诱人的。对于所有表达式来说并非如此,但你将写的大多数表达式都符合这个描述,所以我现在将重点放在这一点上,并稍后提到例外情况。
最简单的表达式是literals,我们只需写出我们想要的值,比如"Hello, World!"或42。你也可以使用变量的名称作为一个表达式。表达式可以涉及运算符,描述进行的计算或其他计算。运算符有固定数量的输入,称为operands。有些运算符只需要一个操作数。例如,你可以通过在数字前面加一个减号来对数字取反。有些运算符需要两个操作数:+运算符允许你形成一个表达式,将两侧操作数的结果相加。
注意
一些符号在不同的上下文中有不同的作用。减号不仅仅用于取反。如果它出现在两个表达式之间,则充当双操作数减法运算符。
通常,操作数也是表达式。所以,当我们写2 + 2时,这是一个包含两个更多表达式的表达式——+符号两侧的一对"2" literals。这意味着我们可以通过在表达式内部嵌套表达式来编写任意复杂的表达式。示例 2-18 利用这一点来评估二次方程的解(解二次方程的标准方法)。
示例 2-18。表达式内的表达式
double a = 1, b = 2.5, c = -3;
`double` `x` `=` `(``-``b` `+` `Math``.``Sqrt``(``b` `*` `b` `-` `4` `*` `a` `*` `c``)``)` `/` `(``2` `*` `a``)``;`
Console.WriteLine(x);
看看第二行的声明语句。其初始化表达式的整体结构是一个除法操作。但是该除法运算符的两个操作数也是表达式。其左操作数是一个括号表达式,告诉编译器我希望整个表达式 (-b + Math.Sqrt(b * b - 4 * a * c)) 成为除法的第一个操作数。这个子表达式包含一个加法,其左操作数是一个否定表达式,其单个操作数是变量 b。加法的右操作数则对另一个更复杂的表达式进行平方根运算。而除法的右操作数是另一个括号表达式,其中包含一个乘法。图 2-1 展示了表达式的完整结构。
图 2-1. 表达式的结构
最后一个示例的一个重要细节是,方法调用是一种表达式。在示例 2-18 中使用的 Math.Sqrt 方法是一个 .NET 运行时库函数,用于计算其输入的平方根并返回结果。也许更令人惊讶的是,像 Console.WriteLine 这样不返回值的方法调用,从技术上讲也是表达式。还有一些其他不产生值但仍被视为表达式的结构,包括对类型的引用(例如 Console.WriteLine 中的 Console)或对命名空间的引用。这些构造利用了一套通用规则(例如作用域、如何解析名称引用等),因此被视为表达式。然而,所有不生成值的表达式只能在特定情况下使用(例如,不能将一个表达式用作另一个表达式的操作数)。因此,虽然从技术上讲定义表达式为生成值的代码片段并不完全正确,但我们在描述代码要执行的计算时确实使用这些表达式。
现在我们可以回到一个问题,即在表达式语句中可以放什么?粗略来说,表达式必须执行某些操作;它不能只计算一个值。因此,虽然 2 + 2 是一个有效的表达式,但如果您试图在其末尾添加分号将其转换为表达式语句,您将会得到一个错误。这个表达式计算了某些东西,但没有对结果做任何事情。更准确地说,您可以将以下类型的表达式用作语句:方法调用、赋值、增量、减量以及新对象的创建。我们将在本章后面讨论增量和减量,后续章节还会讨论对象,因此留下了调用和赋值两种情况。
因此,方法调用允许作为表达式语句。这可能涉及其他类型的嵌套表达式,但整个表达式必须是一个方法调用。示例 2-19 展示了一些有效的例子。请注意,C# 编译器并不检查方法调用是否真正产生了任何持久效果——Math.Sqrt 函数是一个纯函数,它仅仅根据其输入返回一个值。因此调用它然后不对结果做任何操作实际上什么都没做——这不比表达式2 + 2更有作用。但就 C# 编译器而言,任何方法调用都允许作为表达式语句。
示例 2-19. 方法调用表达式作为语句
Console.WriteLine("Hello, World!");
Console.WriteLine(12 + 30);
Console.ReadKey();
Math.Sqrt(4);
注意
如果在 VS Code 中运行此示例,则ReadKey的调用可能会失败,因为调试器默认会重定向输入和输出。文档说明了在调试需要读取控制台输入的程序时如何避免此问题。
C# 禁止我们将加法表达式用作语句,而允许Math.Sqrt,看起来是不一致的。这两者都执行计算并产生结果,因此在这种方式下使用它们是毫无意义的。如果 C# 只允许调用不返回任何内容的方法用作表达式语句,那么这会更一致吗?这将排除示例 2-19 的最后一行,因为这段代码并不执行任何有用的操作。这也与2 + 2不能形成表达式语句的事实一致。不幸的是,有时您希望忽略返回值。示例 2-19 调用Console.ReadKey(),它等待按键并返回一个值,指示按下了哪个键。如果我的程序行为依赖于用户按下的特定键,我需要检查方法的返回值,但如果我只是想等待任何键,忽略返回值就可以了。如果 C# 不允许具有返回值的方法用作表达式语句,那么我将无法这样做。编译器无法区分哪些方法会导致毫无意义的语句,因为它们没有副作用(比如Math.Sqrt),哪些可能是好的候选(比如Console.ReadKey),因此它允许任何方法。
要使表达式成为有效的表达式语句,仅仅包含方法调用是不够的。示例 2-20 展示了一些调用方法并将其用作加法表达式一部分的表达式。虽然这些是有效的表达式,但它们不是有效的表达式语句,因此会导致编译器错误。关键在于最外层的表达式。在这两行中,最外层都是加法表达式,这就是为什么这些是不允许的原因。
Example 2-20. 错误:一些不作为语句工作的表达式
Console.ReadKey().KeyChar + "!";
Math.Sqrt(4) + 1;
之前我说过我们可以将作为语句使用的一种表达式是赋值。赋值作为表达式并不明显,但确实如此,并且它们会产生一个值:赋值表达式的结果是分配给变量的值。这意味着可以在示例 2-21 中编写这样的代码是合法的。这里的第二行使用赋值表达式作为方法调用的参数,展示了该表达式的值。前两个WriteLine调用都显示123。
Example 2-21. 赋值是表达式
int number;
`Console``.``WriteLine``(``number` `=` `123``)``;`
Console.WriteLine(number);
int x, y;
`x` `=` `y` `=` `0``;`
Console.WriteLine(x);
Console.WriteLine(y);
本例的第二部分通过利用赋值为两个变量同时分配一个值,说明了赋值作为表达式的事实——它将y = 0表达式的值(评估为0)分配给了x。
这表明评估表达式不仅仅是产生一个值。一些表达式具有副作用。我们刚刚看到赋值是一个表达式,当然它有改变变量内容的效果。方法调用也是表达式,尽管可以编写仅从其输入计算结果的纯函数,比如Math.Sqrt,但许多方法会有一些持久的效果,例如向屏幕写入数据,更新数据库或触发建筑物的拆除。这意味着我们可能关心表达式的操作数评估顺序。
表达式的结构对操作符完成工作的顺序施加了一些约束。例如,我可以使用括号强制执行顺序。表达式10 + (8 / 2)的值为 14,而表达式(10 + 8) / 2的值为 9,尽管它们都有完全相同的文字操作数和算术运算符。这里的括号决定了除法是在减法之前还是之后执行。⁴
然而,虽然表达式的结构对操作数的评估顺序施加了一些约束,但仍然有一些余地:虽然加法的两个操作数在执行加法之前必须先评估,但加法运算符不关心我们先评估哪个操作数。但如果操作数是具有副作用的表达式,顺序可能很重要。对于这些简单的表达式,这并不重要,因为我使用了文字,所以我们无法真正知道它们何时被评估。但是,如果操作数调用了某些方法的表达式呢?示例 2-22 包含这种类型的代码。
Example 2-22. 操作数的评估顺序
static int X(string label, int i)
{
Console.Write(label);
return i;
}
Console.WriteLine(X("a", 1) + X("b", 1) + X("c", 1) + X("d", 1));
这定义了一个方法,X,它接受两个参数。它显示第一个参数,并返回第二个参数。然后,我在表达式中多次使用了这个方法,这样我们就可以确切地看到调用 X 的操作数何时被评估。一些语言选择不定义此顺序,使得这样的程序的行为变得不可预测,但是在 C# 中这里是有规定的。规则是在任何表达式内部,操作数按照它们在源代码中出现的顺序进行评估。因此,当 示例 2-22 中的 Console.WriteLine 运行时,它会多次调用 X,每次调用 X 都会调用 Console.Write,因此我们会看到这样的输出:abcd4。
然而,这忽略了一个重要的微妙之处:当嵌套发生时,我们在说表达式的顺序时到底是什么意思?Console.WriteLine 的整个参数是一个大的加法表达式,其中第一个操作数是 X("a", 1),第二个操作数是另一个加法表达式,它又以 X("b", 1) 作为第一个操作数,并且有一个第二操作数,它又是另一个加法表达式,其操作数分别是 X("c", 1) 和 X("d", 1)。考虑这些加法表达式中的第一个,它构成了传递给 Console.WriteLine 的整个参数,现在问这个加法表达式的第一个操作数究竟是在其第一个操作数之前还是之后,这是否有意义?在词法上,最外层的加法表达式从其第一个操作数开始的确切点开始,并在其第二操作数结束的点结束(这也恰好是最终的 X("d", 1) 结束的地方)。在这种特定情况下,真正重要的是评估顺序的唯一可观察效果是调用 X 方法时产生的输出。没有一个调用 X 的表达式是嵌套在另一个表达式中的,因此我们可以有意义地说这些表达式的顺序,并且我们看到的输出与该顺序匹配。然而,在某些情况下,如 示例 2-23,嵌套表达式的重叠可能会产生可见的影响。
示例 2-23. 带有嵌套表达式的操作数评估顺序
Console.WriteLine(
X("a", 1) +
X("b", (X("c", 1) + X("d", 1) + X("e", 1))) +
X("f", 1));
这里,Console.WriteLine 的参数添加了三次调用 X 的结果;然而,这三次 X 的调用中,第二次调用(第一个参数为"b")的第二个参数是一个表达式,该表达式又添加了三次调用 X 的结果(参数分别为"c"、"d" 和 "e")。通过最后一次调用 X(传递"f"),在该语句中我们总共有六个调用 X 的表达式。C# 按照表达式出现的顺序来评估表达式的规则始终适用,但由于存在重叠,结果一开始会令人惊讶。尽管字母按照字母表顺序出现在源代码中,输出却是"acdebf5"。如果你想知道这是如何与按顺序评估表达式保持一致,请考虑代码从表达式开始评估的顺序,以及在表达式完成评估时的顺序,这两者是不同的排序方式。特别是,使用"b"调用 X 的表达式开始其评估比使用"c"、"d" 和 "e" 调用 X 的表达式开始评估更早,但在它们之后完成其评估。我们在输出中看到的正是这种后续排序。如果你在本例中找到每个与调用 X 相对应的闭合括号,你会发现调用顺序与显示的内容完全一致。
注释与空白
大多数编程语言允许源文件包含编译器忽略的文本,C#也不例外。与大多数 C 家族语言一样,它支持两种用于此目的的注释风格。有单行注释,如示例 2-24 中所示,其中写入两个/字符,从而使得从这里到行尾的所有内容都将被编译器忽略。
示例 2-24. 单行注释
Console.WriteLine("Say"); // This text will be ignored, but the code on
Console.WriteLine("Anything"); // the left is still compiled as usual.
C# 也支持定界注释。你可以使用/*开始这种类型的注释,编译器将忽略直到遇到第一个*/字符序列的所有内容。如果你不希望注释一直持续到行尾,这将会很有用,正如示例 2-25 的第一行所示。本例还展示了定界注释可以跨越多行。
示例 2-25. 定界注释
Console.WriteLine(/* Has side effects */ GetLog());
/* Some developers like to use delimited comments for big blocks of text,
* where they need to explain something particularly complex or odd in the
* code. The column of asterisks on the left is for decoration - asterisks
* are necessary only at the start and end of the comment.
*/
使用定界注释可能会遇到一个小问题;即使注释在单行内,也可能会发生,但更常见的是在多行注释中出现。示例 2-26 展示了从第一行中间开始到第四行末尾的注释问题。
示例 2-26. 多行注释
Console.WriteLine("This will run"); /* This comment includes not just the
Console.WriteLine("This won't"); * text on the right but also the text
Console.WriteLine("Nor will this"); /* on the left except the first and last
Console.WriteLine("Nor this"); * lines. */
Console.WriteLine("This will also run");
注意,在本示例中/*字符序列出现了两次。当此序列出现在注释中间时,它什么特别操作也不会执行——注释不会嵌套。尽管我们看到了两个/*序列,但第一个*/就足以结束注释。这有时令人沮丧,但对于 C 家族语言来说,这是常态。
有时临时禁用一段代码并且轻松恢复是非常有用的。将代码转换为注释是一个常见的方法,虽然一个分隔的注释看起来似乎是一个显而易见的选择,但如果你注释掉的区域恰好包含另一个分隔的注释,那么它会变得很笨拙。由于没有支持嵌套,你需要在内部注释的闭合*/后添加一个/*来确保你注释掉了整个范围。因此,通常使用单行注释来实现这一目的。(你还可以使用下一节中描述的#if指令。)
注意
Visual Studio 和 VS Code 都可以帮助你注释掉代码区域。如果你选择了几行文本并按下 Ctrl-K,然后立即按下 Ctrl-C,它会在选择的每一行开头添加 //。而取消注释则是通过 Ctrl-K,Ctrl-U 来实现。(在安装 Visual Studio 时,如果你选择了除了 C# 以外的首选语言,这些操作可能绑定了不同的键序列,但它们也可以在“编辑”→“高级”菜单中找到,并且在默认情况下会显示在文本编辑器工具栏中,这是 Visual Studio 显示的标准工具栏之一。)
谈到忽略的文本,C# 在大多数情况下忽略额外的空白。并非所有的空白都是无关紧要的,因为你至少需要一些空间来分隔完全由字母数字符号组成的标记。例如,你不能将staticvoid作为方法声明的开头—你需要至少一个空格(或制表符、换行符或其他类似的空格字符)来分隔static和void。但是对于非字母数字符号,空格是可选的,并且在大多数情况下,单个空格等同于任意数量的空白和换行符。这意味着 示例 2-27 中的三个语句都是等效的。
示例 2-27. 无关紧要的空白
Console.WriteLine("Testing");
Console . WriteLine( "Testing");
Console.
WriteLine ("Testing" )
;
有几种情况下,C# 对空白更为敏感。在字符串文字内部,空格是有意义的,因为你写入的空格将出现在字符串值中。此外,虽然 C# 大多数情况下不关心你是否将每个元素放在自己的一行中,或者将所有代码放在一个大行中,或者(似乎更可能的是)介于两者之间,但有一个例外:预处理指令必须单独出现在它们自己的行上。
预处理指令
如果你熟悉 C 语言或其直接后代,你可能会想知道 C# 是否有预处理器。它没有单独的预处理阶段,也不提供宏。但是,它确实有少数与 C 预处理器提供的指令类似的指令,尽管选择非常有限。即使 C# 没有像 C 那样的完整预处理阶段,这些仍然被称为预处理指令。
编译符号
C# 提供了一个#define指令,允许你定义一个编译符号。这些符号通常与#if指令一起使用,根据不同情况编译代码。例如,你可能希望某些代码仅在调试版本中存在,或者可能需要在不同平台上使用不同的代码以达到特定效果。通常情况下,你不会直接使用#define指令,而是通过编译器的构建设置定义编译符号。你可以打开*.csproj*文件,在任何<PropertyGroup>的<DefineConstants>元素中定义你想要的值。另外,Visual Studio 也可以帮助你完成这些操作:右键单击解决方案资源管理器中的项目节点,选择属性,在打开的属性页中转到“生成”部分。该界面允许你为每个构建配置配置不同的符号值(通过向包含这些设置的<PropertyGroup>添加像Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'"这样的属性)。
注意
.NET SDK 默认定义了某些符号。它支持两种配置,Debug 和 Release。在 Debug 配置中定义了一个DEBUG编译符号,而 Release 则会定义RELEASE。它在两种配置中都定义了一个名为TRACE的符号。某些项目类型会获得额外的符号。一个面向.NET Standard 的库将定义NETSTANDARD,以及一个特定版本的符号,比如NETSTANDARD2_0。目标为.NET 6.0 的项目会得到一个NET6_0符号。
编译符号通常与#if、#else、#elif和#endif指令一起使用(#elif是else if的简写)。示例 2-28 使用了其中一些指令,以确保只在调试版本中编译某些代码行。(你也可以写#if false来完全阻止某些代码段的编译。这通常只是临时措施,是一种避免注释嵌套问题的替代方法。)
示例 2-28. 条件编译
#if DEBUG
Console.WriteLine("Starting work");
#endif
DoWork();
#if DEBUG
Console.WriteLine("Finished work");
#endif
C# 提供了一种更微妙的机制来支持这种情况,称为条件方法。编译器识别运行时库定义的一个称为ConditionalAttribute的特性,为其提供特殊的编译时行为。你可以使用这个特性注解任何方法。示例 2-29 使用它来指示只有在定义了DEBUG编译符号时才应使用注解方法。
示例 2-29. 条件方法
[System.Diagnostics.Conditional("DEBUG")]
static void ShowDebugInfo(object o)
{
Console.WriteLine(o);
}
如果您编写调用以这种方式注释的方法的代码,C# 编译器将在不定义相关符号的构建中省略该调用。因此,如果您编写调用 ShowDebugInfo 方法的代码,编译器将在非调试构建中剥离所有这些调用。这意味着您可以获得与 示例 2-28 相同的效果,但不会用指令使代码混乱。
运行时库的 System.Diagnostics 命名空间中的 Debug 和 Trace 类使用了这个特性。Debug 类提供了各种方法来生成诊断输出,这些方法在 DEBUG 编译符号条件下才会生效,而 Trace 类的方法则在 TRACE 条件下才会生效。如果保留新的 C# 项目的默认设置,通过 Trace 类产生的任何诊断输出将在调试和发布构建中都可用,但调用 Debug 类上的方法的任何代码将不会编译到发布构建中。
警告
Debug 类的 Assert 方法在 DEBUG 条件下才会生效,这有时会让开发人员感到困惑。Assert 允许您指定必须在运行时为真的条件,如果条件为假,则会抛出异常。C# 初学者经常错误地将两件事放入 Debug.Assert 中:实际上应该在所有构建中发生的检查,以及代码其余部分依赖的具有副作用的表达式。这会导致错误,因为编译器会在非调试构建中剥离此代码。
#error 和 #warning
C# 允许您使用 #error 和 #warning 指令生成编译器错误或警告。这些通常用于条件区域内,就像 示例 2-30 所示的那样,尽管无条件的 #warning 可能会作为提醒自己尚未编写某些特别重要的代码的一种方式。
示例 2-30. 生成编译器错误
#if NETSTANDARD
#error .NET Standard is not a supported target for this source file
#endif
#line
#line 指令在生成的代码中很有用。当编译器产生错误或警告时,它会说明问题发生的位置,提供文件名、行号和该行内的偏移量。但是,如果所讨论的代码是使用其他文件自动生成的,并且如果该其他文件包含问题的根本原因,那么将错误报告在输入文件中可能更有用,而不是在生成的文件中。#line 指令可以指示 C# 编译器表现得好像错误发生在指定的行号,并且可选地,好像错误发生在完全不同的文件中。示例 2-31 展示了如何使用它。指令后的错误将被报告好像来自名为 Foo.cs 的文件的第 123 行。您可以通过编写 #line default 来告诉编译器恢复报告警告和错误而不进行伪造。
示例 2-31. #line 指令和故意错误
#line 123 "Foo.cs"
intt x;
此指令还影响调试。当编译器生成调试信息时,它会考虑#line指令。这意味着在调试器中逐步执行代码时,你将看到#line引用的位置。
文件名部分是可选的,这使你可以伪造行号。相反,此编译指示还接受更复杂的形式,在其中可以提供列和范围信息,用于生成的代码与输入之间没有直接的行对行关系的情况。ASP.NET Core Web 框架使用此功能:它包括一个名为 Razor 的功能,允许将 C# 表达式与 HTML 混合。Razor 通过生成 C# 文件工作,但它使用#line指令,以便调试器显示开发人员在 Razor 文件中编写的原始代码,而不是生成的代码。
这个指令还有另外一个用法。不需要行号(和可选的文件名),你可以写#line hidden。这只影响调试器的行为:在单步调试时,Visual Studio 将直接运行所有这种指令之后的代码,直到遇到非hidden #line指令(通常是#line default)为止。
#pragma
#pragma指令提供了两个功能:它可用于禁用选定的编译器警告,也可用于覆盖编译器放入包含调试信息的*.pdb*文件中的校验和值。这两者主要设计用于代码生成场景,尽管偶尔在普通代码中禁用警告可能也有用。示例 2-32 展示了如何使用#pragma防止编译器在你声明了但未使用的变量时发出的警告。
示例 2-32. 禁用编译器警告
#pragma warning disable CS0168
int a;
通常应避免禁用警告。此功能在生成的代码中很有用,因为代码生成通常会创建未始终使用的项,而编译器指令可能是获得干净编译的唯一途径。但当你手动编写代码时,通常应该能够避免首先出现正常的编译器警告。
话虽如此,如果您选择了额外的诊断,禁用特定警告可能会很有用。NuGet 上的一些组件提供代码分析器,这些组件连接到 C# 编译器 API 并有机会检查代码并生成自己的诊断消息。(这发生在构建时,在 Visual Studio 中编辑时也会提供实时诊断,即您键入时。如果安装了 OmniSharp C# 扩展并启用了 omnisharp.enableRoslynAnalyzers 设置,它们也会在 Visual Studio Code 中实时工作。).NET SDK 还包括内置的分析器,可以检查代码的各个方面,如遵守命名约定或常见安全错误的存在。您可以使用 AnalysisMode 设置在项目级别配置这些内容,但与编译器警告一样,可能希望在特定情况下禁用分析器警告。您可以使用 #pragma warning 指令来控制来自代码分析器的警告,而不仅仅是来自 C# 编译器的警告。分析器通常在其警告编号前加上一些字母以便您区分它们——例如,编译器警告全部以 CS 开头,而来自.NET SDK 分析器的警告以 CA 开头。
C# 的未来版本可能基于 #pragma 添加其他功能。当编译器遇到它不理解的 #pragma 时,它会生成一个警告而不是错误,因为未识别的 #pragma 可能对未来的编译器版本或其他供应商的编译器有效。
#nullable
#nullable 指令允许对可为空注解上下文和可为空警告上下文进行精细控制。这是可为空引用功能的一部分。第三章 更详细地描述了 #nullable 指令。
#region 和 #endregion
最后,我们有两个什么也不做的预处理指令。如果您写 #region 指令,编译器唯一做的就是确保它们有相应的 #endregion 指令。不匹配会导致编译器错误,但编译器会忽略正确配对的 #region 和 #endregion 指令。区域可以是嵌套的。
这些指令完全是为了那些选择识别它们的文本编辑器而存在。Visual Studio、VS Code 和 Rider 使用它们来提供将代码段折叠到屏幕上单行的能力。C# 编辑器自动允许某些特性扩展和折叠,例如类定义、方法和代码块(一种称为大纲的功能)。如果你使用这两个指令定义区域,它也将允许这些区域进行扩展和折叠。这允许在编辑器自动提供的细粒度(例如单个块内)和粗粒度(例如多个相关方法)的大纲之间进行大纲化。
如果你在 Visual Studio 中将鼠标悬停在折叠区域上,它会显示一个工具提示,显示该区域的内容。你可以在#region标记后面放置文本。当 IDE 显示一个折叠区域时,它将此文本显示在剩下的单行上。虽然可以省略此文本,但通常最好包含一些描述性文本,以便人们可以大致了解他们如果展开将会看到什么。
一些人喜欢将类的整个内容放入不同的区域,因为通过折叠所有区域,你可以一目了然地看到文件的结构。由于区域被缩减为单行,甚至整个文件可能会一次性显示在屏幕上。另一方面,一些人讨厌折叠区域,因为它们在查看代码时会造成阻碍,并且还会鼓励人们将过多的源代码放入一个文件中。
基础数据类型
.NET 在其运行库中定义了数千种类型,你可以编写自己的类型,因此 C#可以处理无限数量的数据类型。然而,一些类型从编译器中获得特殊处理。你之前在示例 2-9 中看到过,如果你有一个字符串,并尝试将数字添加到它,编译后的代码将把该数字转换为字符串并附加到第一个字符串上。事实上,行为比那更一般——它不仅限于数字。编译后的代码通过调用String.Concat方法工作,如果向其传递任何非字符串参数,它将在执行附加操作之前调用它们的ToString方法。所有类型都提供ToString方法,因此这意味着你可以将任何类型的值附加到字符串上。
这很方便,但它之所以有效,是因为 C#编译器了解字符串并为其提供特殊服务。(C#规范的一部分定义了+运算符的唯一字符串处理方式。)C#不仅为字符串提供各种特殊服务,还为某些数值数据类型、布尔值、一系列称为元组的类型以及两种特定类型——dynamic和object提供特殊服务。这些大多数不仅对 C#特有,而且对运行时也是特有的——几乎所有数值类型在中间语言(IL)中都得到直接支持,而bool、string和object类型也被运行时本质上理解。
数值类型
C#支持整数和浮点数算术运算。有符号和无符号整数类型,它们有各种不同的大小,如表 2-1 所示。最常用的整数类型是int,因为它足够大,可以表示广泛的值范围,而且在支持.NET 的所有 CPU 上工作效率也很高。(较大的数据类型可能不会被 CPU 原生支持,并且在多线程代码中可能具有不良特性:32 位类型的读取和写入是原子的⁵,但对于较大的类型可能不是。)
表 2-1. 整数类型
| C# 类型 | CLR 名称 | 有符号 | 位大小 | 包含范围 |
|---|---|---|---|---|
byte | System.Byte | 否 | 8 | 0 到 255 |
sbyte | System.SByte | 是 | 8 | −128 到 127 |
ushort | System.UInt16 | 否 | 16 | 0 到 65,535 |
short | System.Int16 | 是 | 16 | −32,768 到 32,767 |
uint | System.UInt32 | 否 | 32 | 0 到 4,294,967,295 |
int | System.Int32 | 是 | 32 | −2,147,483,648 到 2,147,483,647 |
ulong | System.UInt64 | 否 | 64 | 0 到 18,446,744,073,709,551,615 |
long | System.Int64 | 是 | 64 | −9,223,372,036,854,775,808 到 9,223,372,036,854,775,807 |
nint | System.IntPtr | 是 | 取决于 | 取决于 |
nuint | System.UIntPtr | 否 | 取决于 | 取决于 |
表 2-1 中的第二列显示了 CLR 中类型的名称。不同的语言有不同的命名约定,C# 使用其 C 家族根源的名称用于数值类型,但这些名称不符合 .NET 对其数据类型的命名约定。对于运行时来说,第二列中的名称是真正的名称——有各种 API 可以在运行时报告类型信息,它们报告这些 CLR 名称,而不是 C# 的名称。除了最后两项外,在 C# 源代码中,名称在语义上是同义词,因此您可以自由地使用运行时名称,但 C# 的名称在风格上更合适——C 家族语言的关键字均为小写。由于编译器处理这些类型的方式与其他类型不同,因此让它们显眼可能是个好主意。
nint 和 nuint 类型在这里是特例。这些是本地大小整数类型(因此有 n 前缀),用于需要直接处理内存中数据地址的低级代码。这就是它们没有固定大小的原因——在 32 位进程中它们是 32 位宽,在 64 位进程中是 64 位宽。与表 2-1 中的所有其他类型不同,根据使用 C# 名称或 CLR 名称的方式,可用的特性也不同:C# 当前不允许在使用 System.IntPtr 或 System.UIntPtr 时进行算术运算,但它支持 nint 和 nuint,并且还添加了来自其他整数类型的各种隐式转换。这些是非常专业的类型,通常仅在为非 .NET 库编写包装器时使用,并且我仅出于完整性将它们包含在这个表格中。
警告
并非所有的.NET 语言都支持无符号数,因此.NET 运行库倾向于避免使用它们。支持多种语言的运行时(如 CLR)面临着在提供足够丰富的类型系统以涵盖大多数语言需求之间的权衡,同时又不会强加过于复杂的类型系统于简单的语言上。为解决这个问题,.NET 的类型系统 CTS 相对而言是相当全面的,但语言并不必须支持其全部。.NET 还定义了公共语言规范(CLS),它确定了所有语言应支持的相对较小的 CTS 子集。有符号整数在 CLS 中,但无符号整数不在其中。这解释了一些看起来令人惊讶的类型选择,比如数组的Length属性是int(而不是uint),尽管它永远不会返回负值。
C# 也支持浮点数。有两种类型:float 和 double,分别是 32 位和 64 位的数字,符合标准的IEEE 754 格式,如表 2-2 中的 CLR 名称所示,这些通常被称为单精度和双精度数。浮点数值的工作方式与整数不同,因此这张表格与整数类型表格有所不同。浮点数存储值和指数(在概念上类似于科学计数法,但是使用二进制而不是十进制)。精度列显示了值部分有多少位可用,然后范围被表示为可以表示的最小非零值和最大值(这些可以是正数或负数)。
表 2-2. 浮点数类型
| C# 类型 | CLR 名称 | 位大小 | 精度 | 范围(数量级) |
|---|---|---|---|---|
float | System.Single | 32 | 23 位(约 7 个十进制数字) | 1.5 × 10^(−45) 到 3.4 × 10³⁸ |
double | System.Double | 64 | 52 位(约 15 个十进制数字) | 5.0 × 10^(−324) 到 1.7 × 10³⁰⁸ |
C#识别了第三种非整数数值表示,称为decimal(或 CLR 中的System.Decimal)。这是一个 128 位的值,因此它可以提供比其他格式更高的精度,但它并不仅仅是double的扩展版本。它设计用于需要可预测处理小数部分的计算,这是float和double都无法提供的。如果你编写了这样的代码:将类型为float的变量初始化为 0,然后连续九次加上 0.1,你可能期望得到一个值为 0.9,但实际上你会得到大约是 0.9000001。这是因为 IEEE 754 标准将数字存储为二进制,无法表示所有的十进制小数。它可以处理一些情况,比如十进制的 0.5 在二进制中表示为 0.1。但是十进制的 0.1 在二进制中会变成一个循环数(具体来说,是 0.0 后跟一个循环序列 0011)。这意味着float和double只能表示十进制值 0.1 的近似值,更广义地说,只有少数小数可以被完全准确地表示。这并不总是立即显而易见,因为当浮点数转换为文本时,它们会被舍入为一个可以掩盖差异的十进制近似值。但在多次计算中,这种不准确性往往会累积,最终产生看似令人惊讶的结果。
对于某些类型的计算,这并不重要;例如在模拟或信号处理中,预期会有一些噪声和误差。但是会计师和金融监管者往往不太宽容——这种小的差异可能会让人觉得钱似乎神奇地消失或出现了。我们需要涉及金钱的计算绝对精确,这使得二进制浮点数对于这样的工作来说是一个糟糕的选择。这就是为什么 C#提供decimal类型,它提供了一个明确定义的十进制精度水平。
注意
大多数整数类型可以由 CPU 本地处理。(在 64 位进程中运行时,它们全部可以处理。)同样,许多 CPU 可以直接处理float和double的表示。然而,没有一个 CPU 有内置的支持decimal,这意味着即使是简单的操作,如加法,也需要多个 CPU 指令。这意味着使用decimal进行算术运算比迄今为止展示的其他数值类型要慢得多。
decimal将数字存储为符号位(正或负)和一对整数。有一个 96 位整数,而decimal的值是这第一个整数(如果符号位表示如此,则取负数)除以 10 的第二整数次方,这是 0 到 28 之间的一个数(并不是所有的 29 位数,但有一些是)。 因此,第二个整数——表示第一个整数除以的 10 的幂——有效地决定了小数点的位置。这种格式使得能够精确表示任何具有 28 个或更少数字的十进制数。
当您编写字面数值时,可以选择类型,也可以让编译器为您选择合适的类型。如果您写一个普通整数,比如 123,其类型将为 int、uint、long 或 ulong — 编译器将从这些范围包含该值的第一个类型中进行选择(所以 123 将是 int,3000000000 将是 uint,5000000000 将是 long 等)。如果您写一个带有小数点的数字,例如 1.23,其类型是 double。
如果您处理大数,很容易搞错零的数量。这通常是不好的,可能会非常昂贵或危险,具体取决于您的应用领域。C#通过允许在数字文字中的任何位置添加下划线来提供一些缓解,可以根据您的需求将数字分割开来。这类似于大多数讲英语的国家中常见的用逗号将零分组成三组的常见做法。例如,大多数以英语为母语的人不会写 5000000000,而会写成 5,000,000,000,这样一来,您能够立即看到这是 50 亿而不是 500 亿或 500 百万。 (很多以英语为母语的人不知道的是,世界上有几个国家使用句号,而不是逗号。这些国家会把 5,000,000,000 写成 5.000.000.000,而把逗号放在大多数以英语为母语的人会把小数点放的位置。要理解一个像€100.000 这样的值,您需要知道正在使用哪个国家的惯例,以免犯灾难性的金融计算错误。不过,我岔开了话题。) 在 C#中,我们可以通过将数字文字写成 5_000_000_000 来做类似的事情。
通过添加后缀,您可以告诉编译器您需要的特定类型。因此,123U 是 uint,123L 是 long,而 123UL 则是 ulong。后缀字母不区分大小写和顺序,所以您可以写成 123UL,也可以写成 123Lu、123uL 或任何其他排列组合。对于 double、float 和 decimal,分别使用后缀 D、F 和 M。
这些最后三种类型都支持大数字的十进制指数字面量格式,其中您先放置一个小数点,然后是字母E,后跟一个整数。该值是第一个数字乘以 10 的第二个数字次方。例如,字面值1.5E-20是值 1.5 乘以 10^(−20)的结果。(这恰好是double类型,因为这是具有小数点的数字的默认类型,无论其是否处于指数格式中。您可以写1.5E-20F和1.5E-20M来表示等效值的float和decimal常量。
在十六进制中写入整数字面量通常很有用,因为数字在运行时使用的二进制表示中更好地映射到数字。当数字的不同位范围表示不同事物时,这一点尤为重要。例如,您可能需要处理来自 Windows 系统调用的数值错误代码——这些错误偶尔会出现在异常中。在某些情况下,这些代码使用最高位来指示成功或失败,接下来的几位表示错误的起源,剩余的位用于标识具体的错误。例如,COM 错误代码 E_ACCESSDENIED 的值为−2,147,024,891. 在十进制中很难看到结构,但在十六进制中更容易:80070005. 数字 8 表示这是一个错误,接下来的 007 表示这原本是一个普通的 Win32 错误,已经转换为 COM 错误。剩余的位表示 Win32 错误代码为 5(ERROR_ACCESS_DENIED)。在这种情况下,C#允许您以十六进制编写整数字面量,以便更清晰地阅读。只需在数字前加上0x;因此,在这种情况下,您会写成0x80070005。
您还可以使用0b前缀编写二进制字面量。在十六进制和二进制中可以像在十进制中一样使用数字分隔符,虽然在四位一组地分组二进制数字比十六进制更常见,如此:0b_0010_1010。显然,这比十六进制使数字中的任何二进制结构更加明显,但 32 位二进制字面量的长度令人不便,这就是为什么我们经常使用十六进制的原因。
数字转换
每种内置的数字类型在内存中存储数字时使用不同的表示。从一种形式转换为另一种形式需要一些工作,即使数字 1 在查看其二进制表示作为float、int和decimal时看起来差异很大。然而,C#能够生成代码来在各种格式之间进行转换,并且通常会自动执行这些转换。示例 2-33 展示了一些会发生这种情况的案例。
示例 2-33. 隐式转换
int i = 42;
double di = i;
Console.WriteLine(i / 5);
Console.WriteLine(di / 5);
Console.WriteLine(i / 5.0);
第二行将一个int变量的值赋给一个double变量。C#编译器会生成必要的代码,将整数值转换为其等效的浮点值。更微妙的是,最后两行将执行类似的转换,正如我们从代码的输出中可以看到的那样:
8
8.4
8.4
这表明第一次除法产生了一个整数结果——将整数变量i除以整数文字 5 导致编译器生成执行整数除法的代码,因此结果为 8。但另外两个除法产生了浮点结果。在第二种情况下,我们将double变量di除以整数文字 5。在执行除法之前,C#将该 5 转换为浮点数。 (作为优化,在这种特定情况下,编译器恰好在编译时执行了该转换,因此它为该表达式生成了与我们写了di / 5.0相同的代码。)而在最后一行中,我们将整数变量除以浮点文字。这次是变量的值在执行除法之前从整数转换为浮点值。(由于i是一个变量,而不是常量,因此编译器会生成在运行时执行该转换的代码。)
一般情况下,当您执行包含不同数值类型混合的算术运算时,C#会选择具有最大范围的类型,并在执行计算之前将具有较窄范围的类型的值提升为该较大类型。 (算术运算符通常要求所有操作数具有相同的类型,因此如果您提供具有不同类型的操作数,则某种类型必须在任何特定的运算符中“获胜”。)例如,double可以表示int可以表示的任何值,以及许多int无法表示的值,因此double是更具表现力的类型。⁷
在 C#中,当转换是升级(即目标类型比源类型范围更广)时,C#会隐式执行数值转换,因为不存在转换失败的可能性。然而,在另一个方向上,它不会隐式转换。示例 2-34 的第二和第三行将无法编译通过,因为它们试图将double类型的表达式分配给int,这是一种缩小转换,意味着源类型可能包含超出目标范围的值。
示例 2-34. 错误:隐式转换不可用
int i = 42;
int willFail = 42.0;
int willAlsoFail = i / 1.0;
在这个方向上是可以转换的,只是不能隐式转换。你可以使用强制转换,在括号中指定要转换为的类型的名称。示例 2-35 展示了示例 2-34 的修改版本,我们明确表示我们要转换为int,并且要么不介意这个转换可能不正确,要么有理由相信,在这种特定情况下,值将在范围内。请注意,在最后一行中,我在强制转换后的表达式周围加上了括号。这使得强制转换应用于整个表达式;否则,C#的优先规则意味着它只适用于i变量,而由于那已经是一个int,它将没有效果。
示例 2-35. 使用强制转换进行显式转换
int i = 42;
int i2 = (int) 42.0;
int i3 = (int) (i / 1.0);
因此,缩小转换需要显式转换,而不能丢失信息的转换会隐式发生。然而,对于某些类型的组合,两者都不严格比另一个更具表现力。如果尝试将int加到uint,或者将int加到float会发生什么?这些类型都是 32 位大小,因此它们都不可能提供超过 2³²个不同的值,但它们具有不同的范围,这意味着每种类型都有它可以表示的值,其他类型无法表示。例如,你可以在uint中表示值 3000000001,但对于int来说太大了,只能在float中近似表示。随着浮点数变得更大,可以表示的值之间的距离变得更远——float可以表示 3000000000 和 3000001024,但中间没有任何值。因此,对于值 3000000001,uint似乎比float更好。但是-1 呢?那是一个负数,所以uint无法处理。然后有一些非常大的数字,float可以表示,但对于int和uint来说超出范围。每种类型都有其优势和劣势,说其中一种通常比其他类型更好是没有意义的。
令人惊讶的是,即使在这些潜在的有损情况下,C#也允许一些隐式转换。规则只考虑范围,而不考虑精度:如果目标类型的范围完全包含源类型的范围,则允许隐式转换。因此,你可以从int或uint转换为float,因为虽然float无法精确表示某些值,但至少没有int或uint值它无法至少近似表示。但是,不允许在另一个方向进行隐式转换,因为有些float值太大了——与float不同,整数类型无法为更大的数字提供近似值。
当你强制将一个数值转换为int类型时,可能会想知道在超出范围的情况下会发生什么,就像示例 2-35 中所做的那样。答案取决于你要转换的类型。从一个整数类型到另一个整数类型的转换与从浮点数到整数的转换有所不同。事实上,C#规范并未定义如何将过大的浮点数转换为整数类型——结果可能是任何值。但是当在不同大小的整数类型之间进行转换时,结果是明确定义的。如果两种类型的大小不同,二进制数据将会被截断或填充零(或者如果源类型是有符号的且值为负,则填充为一),以使其成为目标类型的正确大小,然后这些位将被视为目标类型的位。这有时很有用,但更可能会产生令人惊讶的结果,因此你可以通过将其设置为checked转换来选择任何超出范围的转换的替代行为。
checked上下文
C#定义了checked关键字,你可以将其放在块语句或表达式前面,使其成为checked上下文。这意味着某些算术操作,包括转换,会在运行时检查是否发生了范围溢出。如果在checked上下文中将一个值转换为整数类型,且该值过高或过低以至于无法容纳,将会导致错误——代码将抛出System.OverflowException。
除了检查转换外,checked上下文还将检测普通算术中的范围溢出。加法、减法和其他操作可能使一个值超出其数据类型的范围。对于整数来说,当未经检查时,这会导致数值“溢出”,因此将最大值加 1 会产生最小值,反之亦然。有时这种环绕操作可能很有用。例如,如果你想确定代码中两个时间点之间经过了多少时间,一种方法是使用Environment.TickCount属性。⁸(这比使用当前日期和时间更可靠,因为后者可能会因时钟调整或时区切换而改变。Tick count 会以稳定的速率不断增加。尽管如此,在实际代码中,你可能会使用运行时库的Stopwatch类。)示例 2-36 展示了一种实现这一点的方法。
示例 2-36. 利用未检查的整数溢出
int start = Environment.TickCount;
DoSomeWork();
int end = Environment.TickCount;
int totalTicks = end - start;
Console.WriteLine(totalTicks);
Environment.TickCount的棘手之处在于它偶尔会“环绕”。它计算自系统上次启动以来的毫秒数,由于其类型为int,最终会超出范围。25 天的时间跨度是 21.6 亿毫秒,这个数字对于int来说太大了。(可以通过使用TickCount64属性来避免这种情况,它可以支持近 3 亿年的时间。但在.NET Framework 或任何当前的.NET 标准中都不可用。)假设时刻数为 2,147,483,637,比int的最大值少 10。你希望它在 100 毫秒后是多少?它不能比之前的值高 100(2,147,483,727),因为那对于int来说太大了。我们期望它在 10 毫秒后达到最大可能值,因此在 11 毫秒后,它将会回到最小值;因此,在 100 毫秒后,我们预期时刻数将比最小值高 89(即−2,147,483,559)。
警告
实际上,时刻数并不一定精确到最近的毫秒。在跳跃前,它通常会静止几毫秒的时间,然后以 10 毫秒、15 毫秒或更多的增量向前跳跃。然而,这个值仍然会溢出——你可能无法观察到它的每一个可能的时刻值在溢出时的情况。
有趣的是,示例 2-36 完美地处理了这个问题。如果 start 中的时刻数在计数环绕之前获得,而 end 中的时刻数在之后获得,end 将包含一个比 start 低得多的值,这似乎有些反常,它们之间的差异将会很大——大于一个 int 的范围。然而,当我们从 start 中减去 end 时,溢出会以与时刻数溢出完全匹配的方式发生,这意味着我们最终会得到正确的结果。例如,如果 start 包含从溢出前 10 毫秒获得的时刻数,而 end 是从之后 90 毫秒获得的,减去相关的时刻数(即减去−2,147,483,558 从 2,147,483,627),看起来应该产生 4,294,967,185 的结果。但由于减法溢出的方式,我们实际上得到了一个结果为 100,这对应于 100 毫秒的经过时间。
但在大多数情况下,这种整数溢出是不可取的。这意味着在处理大数时,可能会得到完全不正确的结果。通常情况下,这不是一个大问题,因为你将处理的是相当小的数字,但如果你的计算可能会遇到溢出的可能性,你可能希望使用 checked 上下文。在表达式中使用 checked 运算符可以请求这一点,就像示例 2-37 所示的那样。括号内的所有内容将在 checked 上下文中进行评估,所以如果将 a 和 b 相加时发生溢出,你将看到 OverflowException。这里 checked 关键字并不适用于整个语句,因此如果由于添加 c 而导致溢出,则不会引发异常。
示例 2-37. Checked 表达式
int result = checked(a + b) + c;
你也可以通过 checked 语句来为整个代码块开启检查,这是一个以 checked 关键字开头的块,如示例 2-38 所示。checked 语句总是涉及一个块 —— 你不能只在示例 2-37 中的 int 关键字前面添加 checked 关键字就将其转换为 checked 语句。你还需要将代码放在大括号中。
示例 2-38. Checked 语句
checked
{
int r1 = a + b;
int r2 = r1 - (int) c;
}
警告
checked 块只影响块内的代码行。如果代码调用任何方法,这些方法不会受到 checked 关键字的影响——CPU 中没有某种 checked 位在 checked 块内启用当前线程。(换句话说,此关键字的范围是词法作用域,而非动态作用域。)
C# 还有一个 unchecked 关键字。你可以在 checked 块内使用它来指示特定表达式或嵌套块不应处于 checked 上下文中。如果你希望除了一个特定表达式外的所有内容都要检查,而不是将所有内容标记为 checked,你可以将所有代码放入 checked 块中,然后排除希望允许溢出而无错误的部分。
您可以配置 C#编译器,使其默认将所有内容放入检查上下文中,以便仅显式unchecked表达式和语句才能在溢出时静默失败。在 Visual Studio 中,您可以通过打开项目属性,转到“生成”选项卡,然后单击“高级”按钮来配置此设置。或者您可以编辑*.csproj*文件,在<PropertyGroup>中添加<CheckForOverflowUnderflow>true</CheckForOverflowUnderflow>。请注意,这样做会有显著的成本——检查可能会使单个整数操作变慢数倍。整体上对应用程序的影响将较小,因为程序不会花费全部时间执行算术运算,但成本可能仍然不容忽视。当然,与任何性能问题一样,您应该测量实际影响。您可能会发现,性能成本是为了保证发现意外溢出而支付的可接受代价。
BigInteger
有一个最后一个值得注意的数字类型:BigInteger。它是运行时库的一部分,并且不会受到 C#编译器的特殊认可,因此严格来说不属于本书的这一部分。然而,它定义了算术运算符和转换,这意味着你可以像使用内置数据类型一样使用它。它将编译为稍微不那么紧凑的代码格式——.NET 程序的编译格式可以原生地表示整数和浮点值,但是BigInteger必须依赖于普通类库类型使用的更通用的机制。从理论上讲,它可能会慢得多,尽管在大量代码中,你对小整数进行基本算术运算的速度并不是限制因素,所以你可能不会注意到这一点。至于编程模型,它在你的代码中看起来和感觉像是正常的数字类型。
顾名思义,BigInteger代表一个整数。它的独特卖点是它将根据需要增长以容纳值。因此,与内置数值类型不同,它在范围上没有理论限制。示例 2-39 使用它来计算斐波那契数列中的值,并显示每 10 万个值。这很快产生了远远超出其他整数类型范围的数字。我展示了此示例的完整源代码,包括using指令,以说明此类型定义在System.Numerics命名空间中。
示例 2-39. 使用BigInteger
using System.Numerics;
BigInteger i1 = 1;
BigInteger i2 = 1;
Console.WriteLine(i1);
int count = 0;
while (true)
{
// The % operator returns the remainder of dividing its 1st operand by its
// 2nd, so this displays the number only when count is divisible by 100000.
if (count++ % 100000 == 0)
{
Console.WriteLine(i2);
}
BigInteger next = i1 + i2;
i1 = i2;
i2 = next;
}
尽管BigInteger没有固定的限制,但存在实际限制。例如,您可能会生成一个超出可用内存范围的数字。或者更有可能的是,数字可能会增长到足以使即使是基本算术所需的 CPU 时间变得不可接受的程度。但在耗尽内存或耐心之前,BigInteger将会增长以容纳任意大的数字。
布尔值
C#定义了一个叫做 bool 的类型,或者在运行时称之为 System.Boolean。它只提供了两个值: true 和 false。而某些 C 语言家族允许数字类型代表布尔值,例如约定使用 0 表示假和其他任何值表示真,C#不会接受数字。它要求用 bool 表示真或假,并且任何数字类型都不能转换为 bool。例如,在 if 语句中,你不能写 if (someNumber) 来仅在 someNumber 非零时运行某些代码。如果你想要这样做,你需要明确地写成 if (someNumber != 0)。
字符串和字符
string 类型(与 CLR System.String 类型同义)代表文本。字符串是一系列 char 类型的值(或 CLR 称之为 System.Char),每个 char 是一个表示单个 UTF-16 代码单元 的 16 位值。
一个常见的错误是认为每个 char 表示一个字符。(类型的名称要为此负责的一部分。)这通常是正确的,但并非总是如此。需要记住两个因素:首先,我们可能认为是单个字符的东西可以由多个 Unicode 代码点 组成。(代码点是 Unicode 的核心概念,至少在英语中,每个字符都由一个单独的代码点表示,但某些语言更复杂。)示例 2-40 使用 Unicode 的 0301“组合重音符号”在字母上添加重音以形成文本 cafés。
示例 2-40. 字符与 char
char[] chars = { 'c', 'a', 'f', 'e', (char) 0x301, 's' };
string text = new string(chars);
因此,这个字符串是六个 char 值的序列,但它代表的文本看起来只包含五个字符。还有其他方法可以实现这一点——我可以使用代码点 00E9“拉丁小写带重音的 e”来表示该重音字符作为单个代码点。但任何一种方法都是有效的,并且在某些场景中,创建所需确切字符的唯一方法是使用这种组合字符机制。这意味着对字符串中的 char 值执行某些操作可能会产生令人惊讶的结果——如果你颠倒值的顺序,结果字符串看起来不会像文本的颠倒版本——重音符号现在会应用于 s,导致 śefac!(如果我使用 00E9 而不是组合 e 和 0301,颠倒字符将产生不那么令人惊讶的 séfac。)
尽管 Unicode 的组合标记,还有第二个因素需要考虑。Unicode 标准定义的代码点数量多于可以用单个 16 位值表示的数量。(我们在 2001 年超过了这一点,当时 Unicode 3.1 定义了 94,205 个代码点。)UTF-16 将任何值大于 65,535 的代码点表示为一对 UTF-16 代码单元,称为代理对。Unicode 标准定义了将代码点映射到代理对的规则,以便生成的代码单元的值在 0xD800 到 0xDFFF 的范围内,这是一个保留范围,永远不会定义任何代码点。(例如,代码点 10C48,“古代突厥文字母 ORKHON BASH”,看起来像 ,将变成 0xD803,后跟 0xDC48。)
总之,用户视为单个字符的项可能用多个 Unicode 代码点表示,而某些单个代码点可能表示为多个代码单元。因此,操作构成字符串的单个 char 值是一项您应该谨慎对待的工作。通常情况下,简单的方法已经足够——例如,如果您想要搜索一个字符串以查找某些适合单个代码单元(如 /)的特定字符,一个简单的基于 char 的搜索就足够了。但是,如果您有一个更复杂的场景需要正确检测所有多代码单元序列,运行时库在这里提供了一些帮助。
string 类型提供了一个 EnumerateRunes 方法,有效地将代理对组合成它们所表示的代码点的值。它将字符串呈现为 Rune 类型值的序列,如果一个字符串包含刚刚描述的 0xD803, 0xDC48 序列,这对 char 值将被呈现为一个值为 0x10C48 的单个 Rune。Rune 类型仍然在单个代码点的级别上操作,因此它不能帮助您处理组合字符,但如果您需要进一步,运行时库在 System.Globalization 命名空间中定义了一个 StringInfo 类。它将字符串解释为“文本元素”的序列,在像 cafés 这样的情况下,它将 é 报告为一个单一的文本元素,即使它是使用组合字符机制形成的两个代码点。
字符串的不可变性
.NET 字符串是不可变的。有许多操作听起来似乎会修改字符串,比如连接操作,或者 ToUpper 和 ToLower 方法,但每个操作都会生成一个新的字符串,原始字符串保持不变。这意味着,如果你将字符串作为参数传递,即使是给你没有编写的代码,你也可以确保它不能改变你的字符串。
不可变性的缺点在于字符串处理可能效率低下。如果需要对字符串进行一系列修改的工作,比如逐字符构建字符串,你将会分配大量内存,因为每次修改都会生成一个新的字符串。这会给.NET 的垃圾收集器增加很多额外工作,导致程序使用比必要更多的 CPU 时间。在这些情况下,你可以使用一种叫做StringBuilder的类型。(与string不同,这种类型在 C#编译器中并未特别认可。)这在概念上类似于string——它是一系列char值,并提供各种有用的字符串操作方法——但是它是可修改的。或者,在极其性能敏感的场景中,你可能会使用第十八章中展示的技术。
字符串操作方法
string类型具有许多实例方法来处理字符串。我已经提到了ToUpper和ToLower,但还有用于在字符串中查找文本的方法,包括IndexOf和LastIndexOf。StartsWith和EndsWith返回一个bool值,指示字符串是否以特定字符或字符串开头或结尾。Split接受一个或多个分隔符字符(例如逗号或空格),并返回一个数组,其中包含分隔符之间的每个子字符串。例如,"One,two,three".Split(',')返回一个包含三个字符串"One"、"two"和"three"的数组。Substring接受一个起始位置和可选长度,并返回一个新字符串,其中包含从起始位置开始到字符串末尾或指定长度的所有字符;Remove则相反:它通过删除Substring将返回的原始字符串的一部分形成一个新字符串。Insert通过在另一个字符串的中间插入一个字符串来形成一个新字符串。Replace返回一个通过将特定字符或字符串的所有实例替换为另一个字符或字符串而形成的新字符串。Trim可用于删除不需要的前导和尾随字符,如空格。
格式化字符串中的数据
C# 提供了一种语法,使得可以轻松生成包含固定文本和运行时确定信息的字符串。(这种特性的官方名称是字符串插值。)例如,如果你有名为name和age的局部变量,你可以在字符串中使用它们,就像示例 2-41 所示。
示例 2-41. 字符串中的表达式
string message = $"{name} is {age} years old";
当你在字符串字面量前面加上$符号时,C#编译器会查找由大括号分隔的嵌入表达式,并生成将表达式的文本表示插入到字符串中的代码。 (因此,如果name和age分别是Ian和48,则字符串的值将是"Ian is 48 years old"。)嵌入表达式可以比变量名更复杂,正如示例 2-42 所示。
示例 2-42. 字符串中的更复杂表达式
double width = 3, height = 4;
string info = $"Hypotenuse: {Math.Sqrt(width * width + height * height)}";
如果你想使用字符串插值,但又希望生成的字符串包含开放或关闭的大括号,则将它们加倍。当插值字符串包含{{或}}时,编译器不会将它们解释为嵌入表达式的分隔符,而只会在输出中生成单个{或}。例如,$"Brace: {{, braces: {{}}, width: {width}, braced width: {{{width}}}"评估为Brace: {, braces: {}, width: 3, braced width: {3}(假设width为3)。
运行时库提供了另一种将值插入字符串的机制。string类的Format方法接受一个带有形如{0}和{1}的编号占位符的字符串,后跟一系列提供这些占位符值的参数。示例 2-43 使用这种方法实现了与示例 2-41 和 2-42 相同的效果。
示例 2-43. 使用string.Format
string message = string.Format("{0} is {1} years old", name, age);
string info = string.Format(
"Hypotenuse: {0}",
Math.Sqrt(width * width + height * height));
这种编号占位符机制较旧,自 C# 1.0 起就存在,而字符串插值是在 C# 6.0 中引入的,因此你会在许多地方看到它的身影。例如,Console.WriteLine支持它。它确实比字符串插值有一个优点:如果你想将大量表达式组合成一个字符串,或者如果你要使用的任何表达式很大,则插值字符串语法可能变得笨拙;像示例 2-43 那样将一个长的成分表达式放在自己的一行上有时可以提高可读性。但是,字符串插值要少出错得多——string.Format使用基于位置的占位符,很容易将表达式放在错误的位置。对于阅读代码的任何人来说,尝试弄清编号占位符与后续参数的关系尤其是在表达式数量增加时是很乏味的。插值字符串通常更容易阅读。
插值字符串有时可以提供性能优势。string.Format 总是在运行时组装字符串,但是使用字符串插值时,编译器可能能够执行编译时优化。例如,如果插值字符串中的表达式是一个const字符串(第三章描述了const关键字),编译器将在编译时将其值插入到字符串中。此外,C# 10.0 允许库表明它们希望参与插值过程,从而可以避免在不使用该字符串的情况下创建字符串。何时可能编写一个不会使用的插值字符串?请参考 示例 2-44。
示例 2-44. 潜在未使用的插值字符串
Debug.Assert(everythingIsOk, $"Everything is *not* OK: {myApplicationModel}");
这里使用了 Debug.Assert,这是一个诊断方法,您可以将其添加到代码中,以检测应用程序是否进入了某些意外状态。Debug.Assert 检查其第一个参数,如果为false,它将停止程序,并显示作为第二个参数传递的消息。但是,如果参数为true,它将在不使用第二个参数的情况下继续执行。在本例中,如果在插值字符串中调用 MyApplicationModel 的 ToString() 方法很昂贵,那么即使在一切正常的情况下也会很不好——我们的程序可能正在做大量工作来创建一个最终会被丢弃的字符串。但是,.NET 6.0 添加了 Debug.Assert 的新重载,利用了 C# 10.0 中的新字符串插值特性,以一种避免在不使用时创建该字符串的方式。此机制也可以被日志记录框架使用,其中代码通常可以生成大量字符串以提供发生情况的详细描述,但在未启用详细日志记录的典型情况下将不会使用这些字符串。
对于某些数据类型,它们的文本表示方式有所选择余地。例如,对于浮点数,您可能希望限制小数位数,或者强制使用指数表示法。(例如,1e6代替1000000。)在.NET 中,我们通过格式说明符来控制这一点,它是一个描述如何将某些数据转换为字符串的字符串。某些数据类型只有一个合理的字符串表示形式,因此它们不支持此功能,但对于具有多个字符串形式的类型,您可以将格式说明符作为参数传递给ToString方法。例如,System.Math.PI.ToString("f4") 将PI常量(类型为double)格式化为四位小数("3.1416")。数字有九种内置格式,如果没有一种适合您的要求,还有一种用于定义自定义格式的小语言。此外,不同类型使用不同的格式字符串——例如,日期与数字的工作方式大不相同——因此,这里列出的可用格式的范围太大了。Microsoft 提供了详尽的文档说明。
在使用string.Format时,您可以在占位符中包含格式说明符;例如,{0:f3} 表示第一个表达式应格式化为小数点后三位数。您也可以以类似的方式在字符串插值中包含格式说明符。示例 2-45 展示了带有小数点后一位数的年龄。
示例 2-45. 格式说明符
string message = $"{name} is {age:f1} years old";
这里有一个细微的问题:对于许多数据类型,转换为字符串的过程是与文化相关的。例如,如前所述,在美国和英国,小数通常用句点分隔整数部分和小数部分,您可能使用逗号来分组数字以提高可读性,但一些欧洲国家则颠倒此习惯:他们使用句点分组数字,而逗号表示小数部分的开始。因此,在一个国家中写成 1,000.2,在另一个国家可能写成 1.000,2。
就源代码中的数字文字而言,这是一个无关紧要的问题:C#使用下划线进行数字分组,并始终使用句点作为小数点。但是在运行时处理数字时怎么办?默认情况下,您将获得由当前线程文化确定的约定,并且除非您已更改,否则它将使用计算机的区域设置。有时这很有用——它可以意味着数字、日期等按照程序运行的任何区域设置正确格式化。但这可能会有问题:如果您的代码依赖于字符串以特定方式格式化(例如,序列化将通过网络传输的数据),则可能需要应用特定的约定集。因此,您可以向string.Format方法传递格式提供程序,这是一个控制格式约定的对象。同样,依赖于区域设置的数据类型接受其ToString方法中的可选格式提供程序参数。但是,在使用字符串插值时如何控制这一点呢?没有地方可以放置格式提供程序。您可以通过string类型的Create方法解决此问题,如示例 2-46 所示。
Example 2-46. 使用不变文化的格式规范
decimal v = 1234567.654m;
string i = string.Create(CultureInfo.InvariantCulture, $"Quantity {v:N}");
string f = string.Create(new CultureInfo("fr"), $"Quantity {v:N}");
string frc = string.Create(new CultureInfo("fr-FR"), $"Quantity {v:C}");
string cac = string.Create(new CultureInfo("fr-CA"), $"Quantity {v:C}");
这里将不同的格式提供程序传递给string.Create方法,但每次使用相同的插值字符串。请注意,它在前两行变量名后面加上:N。这要求普通数字格式,包括数字分隔符。第一次调用使用不变文化,这保证了无论代码在何种区域设置中运行,格式始终一致,导致i得到值"Quantity 1,234,567.654"。第三行使用构造参数为"fr"的CultureInfo对象。这告诉它我们希望以法语文化为代表的方式格式化字符串,所以变量f得到值"Quantity 1.234.567,654"。最后两行使用:C,表示我们希望以货币形式显示值。我分别传递了代表法国和加拿大法语区域的文化,结果分别为欧元和美元符号。
这可能看起来很奇怪:通常,方法参数在传递到方法之前会先进行评估,因此您可能希望插值字符串在调用string.Create之前变成普通字符串,这意味着应用指定的格式提供程序已经太晚了。但正如我之前所说,方法可以表明它们希望参与字符串插值过程。string.Create方法正是这样做的,使其能够控制该过程,这就是它能够应用格式提供程序的方式。
原始字符串字面量
C# 支持一种更方便的表示字符串值的方式:你可以在字符串字面量前加上 @ 符号,如 @"Hello"。这种形式的字符串被称为逐字字符串字面量。它们有两个优点:一是能提高包含反斜杠的字符串的可读性,二是能够编写多行字符串字面量。
在普通字符串字面量中,编译器将反斜杠视为转义字符,使得可以包含各种特殊值。例如,在字面量 "Hello\tWorld!" 中,\t 表示单个制表符(代码点 9)。这是在 C 系列语言中表达控制字符的常见方式。你还可以使用反斜杠在字符串中包含双引号——反斜杠可以阻止编译器将字符解释为字符串结束。尽管如此,这使得在字符串中包含反斜杠有些麻烦:你必须写两个反斜杠。由于 Windows 在路径中使用反斜杠,这可能会变得很丑陋:"C:\\Windows\\System32\\"。在这种情况下,逐字字符串字面量非常有用,因为它会逐字处理反斜杠,使你可以仅写 @"C:\Windows\System32"。 (你仍然可以在逐字字面量中包含双引号:只需连续写两个双引号。例如,@"Hello ""World""" 会产生字符串值 Hello "World"。)
提示
你可以在插值字符串前面使用 @ 符号。这样做既结合了逐字字面量的好处——直接使用反斜杠和换行符,又支持嵌入表达式。
逐字字符串字面量还允许值跨多行。在普通字符串字面量中,如果结束的双引号不在同一行上,编译器将报错。但在逐字字符串字面量中,字符串可以跨越源代码的任意行数。
结果字符串将使用源代码使用的换行符约定。假如你还没遇到这种情况,那么计算机历史上的一个不幸意外是不同系统使用不同的字符序列来表示换行符。互联网协议中主导的系统使用一对控制码表示每行结尾:无论是 Unicode 还是 ASCII,我们使用代码点 13 和 10,分别表示回车和换行,通常简称为 CR LF。这是计算机屏幕出现之前的过时遗物,开始新行意味着将电传打印机的打印头移回起始位置(回车),然后将纸向上移动一行(换行)。时代背景下,HTTP 规范和多种流行的电子邮件标准如 SMTP、POP3 和 IMAP 要求使用这种表示法。这也是 Windows 的标准约定。不幸的是,Unix 操作系统及其大多数衍生产品如 macOS 和 Linux 的约定不同——这些系统的约定是仅使用单个换行字符。C#编译器接受任意一种约定,并且即使单个源文件混合使用了这两种约定,它也不会抱怨。这给多行字符串字面量引入了潜在问题,特别是如果你正在使用一个为你转换换行符的源代码控制系统。例如,Git是一个非常流行的源代码控制系统,由于它的起源(由 Linux 的创始人 Linus Torvalds 创建),它的仓库中广泛使用 Unix 风格的换行符约定。然而,在 Windows 上可以配置它将文件的工作副本转换为 CR LF 表示法,在提交更改时再将其转换回 LF。这意味着,文件看起来会因为在 Windows 系统或 Unix 系统上查看它们而使用不同的换行符约定。(甚至从一个 Windows 系统到另一个 Windows 系统也可能不同,因为默认的换行符处理是可配置的。个别用户可以配置机器范围内的默认设置,也可以为他们本地克隆的任何仓库设置配置,如果该仓库未指定该设置。)这反过来意味着,在 Windows 系统上编译包含多行字面量字符串的文件可能会产生与在 Unix 系统上看到的完全相同文件产生微妙不同的行为,如果启用了自动换行符转换(在大多数 Windows 安装的 Git 上默认是这样)。这可能没问题——在 Windows 上运行时通常需要 CR LF,在 Unix 上运行时需要 LF——但如果将代码部署到与构建代码的操作系统不同的机器上可能会有意外情况发生。因此,在你的仓库中提供一个*.gitattributes文件是非常重要的,以便指定所需的行为,而不是依赖于可变的本地设置。如果需要在字符串字面量中依赖特定的换行符,最好在.gitattributes*中禁用换行符转换。
元组
元组让你将多个值组合成一个值。元组这个名称(与许多提供类似功能的编程语言共享)意味着它是诸如double、triple、quadruple等单词的泛化版本,但即使在我们不需要泛化的情况下,我们通常也称它们为元组。例如,即使我们在讨论一个包含两个项目的元组时,我们仍然称其为元组,而不是双。示例 2-47 创建一个包含两个int值的元组,然后显示它们。
示例 2-47. 创建和使用元组
(int X, int Y) point = (10, 5);
Console.WriteLine($"X: {point.X}, Y: {point.Y}");
那第一行是一个带有初始化器的变量声明。值得详细解释一下,因为元组的语法使得声明看起来比我们到目前为止见到的稍微复杂一些。记住,这种形式语句的一般模式如下:
*type identifier* = *initial-value*;
这意味着在示例 2-47 中,类型为(int X, int Y)。因此,我们说我们的变量point是一个包含两个int类型值的元组,我们希望将它们称为X和Y。这里的初始化器是(10, 5)。因此,当我们运行这个示例时,它产生以下输出:
X: 10, Y: 5
如果你喜欢使用var,你会高兴地知道,你可以使用在示例 2-48 中展示的语法在初始化器中指定名称,从而使用var而不是显式类型。这相当于示例 2-47。
示例 2-48. 在初始化器中命名元组成员
var point = (X: 10, Y: 5);
Console.WriteLine($"X: {point.X}, Y: {point.Y}");
如果你从现有变量初始化一个元组并且没有指定名称,编译器会假定你想使用那些变量的名称,正如示例 2-49 所示。
示例 2-49. 从变量推断元组成员名称
int x = 10, y = 5;
var point = (x, y);
Console.WriteLine($"X: {point.x}, Y: {point.y}");
这引出了一个风格上的问题:元组成员的名称应该以小写还是大写字母开头?这些成员在性质上类似于属性,我们将在第三章中讨论,按照传统,这些属性通常以大写字母开头。因此,许多人认为元组成员的名称也应该是大写的。对于一个经验丰富的 .NET 开发者来说,示例 2-49 中的point.x看起来很奇怪。然而,另一个 .NET 的惯例是局部变量通常以小写字母开头命名。如果你遵循这两个惯例,元组名称推断看起来并不是很有用。许多开发者选择接受在纯粹用于局部变量的元组中使用小写元组成员名称,因为这样做可以使用方便的名称推断功能,仅在暴露给方法外部的元组中使用这种大小写风格。
可能这并不重要,因为元组成员名称实际上只存在于观察者的眼中。首先,它们是可选的。正如示例 2-50 所示,省略它们是完全合法的。这些名称默认为Item1、Item2等。
示例 2-50. 默认元组成员名称
(int, int) point = (10, 5);
Console.WriteLine($"X: {point.Item1}, Y: {point.Item2}");
其次,名称仅用于使用元组的代码的便利性,并不对运行时可见。您可能已经注意到,在示例 2-47 中,我使用了与示例 2-50 中相同的初始化表达式(10, 5),因为它没有指定名称,所以表达式的类型是(int, int),这与在示例 2-47 中的(int X, int Y)匹配。这是因为名称本质上是无关紧要的—在底层它们都是相同的东西。(正如我们将在第四章 中看到的那样,在运行时,它们都表示为类型为ValueTuple<int, int>的实例。)C#编译器会跟踪我们选择使用的名称,但是对于 CLR 来说,所有这些元组都只有称为Item1和Item2的成员。由此产生的结果是,我们可以将任何元组分配给具有相同形状的任何变量,正如示例 2-51 所示。
示例 2-51. 元组的结构等价性
(int X, int Y) point = (46, 3);
(int Width, int Height) dimensions = point;
(int Age, int NumberOfChildren) person = point;
这种灵活性是一把双刃剑。在示例 2-51 中的赋值看起来相当草率。将代表位置的值赋予代表大小的值可能是可以接受的某些情况。但是将同样的值赋予看似表示某人年龄和子女数量的值似乎是错误的。尽管如此,编译器不会阻止我们,因为它认为所有包含一对int值的元组都具有相同的类型。(这与将一个名为age的int变量赋值给一个名为height的int变量的情况没有什么不同。它们都是int类型。)
如果要强制进行语义区分,最好根据第三章 中描述的方式定义自定义类型。元组的真正设计目的是在不真正需要的情况下方便地将几个值打包在一起。
C#确实要求元组具有适当的形状。您不能将(int, int)赋值给(int, string),也不能赋值给(int, int, int)。然而,在“数字转换”中的所有隐式转换都是有效的,因此您可以将具有(int, int)形状的任何内容赋给(int, double)或(double, long)。因此,元组实际上就像是在另一个变量中整齐地包含了一些变量。
元组支持比较,因此您可以在本章后面描述的==和!=关系运算符中使用它们。为了被视为相等,两个元组必须具有相同的形状,并且第一个元组中的每个值必须等于其在第二个元组中的对应值。
元组解构
有时候你会想要将一个元组分解为其组成部分。最直接的方法是按顺序访问每个项的名称(或者作为Item1、Item2等,如果你没有指定名称),但是 C#提供了另一种机制,称为解构。示例 2-52 声明并初始化了两个元组,然后展示了两种不同的解构方式。
示例 2-52. 构造后解构元组
(int X, int Y) point1 = (40, 6);
(int X, int Y) point2 = (12, 34);
`(``int` `x``,` `int` `y``)` `=` `point1``;`
Console.WriteLine($"1: {x}, {y}");
`(``x``,` `y``)` `=` `point2``;`
Console.WriteLine($"2: {x}, {y}");
在定义了point1和point2之后,这将point1解构为两个变量,x和y。这种特定形式的解构还声明了元组被解构到的变量。在我们解构point2时展示了另一种形式 —— 在这里,我们将其解构为两个已经存在的变量,因此不需要声明它们。
直到你习惯了这种语法,第一个解构示例可能会令人困惑地类似于前几行,在那里我们声明并初始化了新的元组。在那些最初的几行中,(int X, int Y)文本表示一个具有两个名为X和Y的int值的元组类型,但在解构行中当我们写(int x, int y)时,我们实际上声明了两个类型为int的变量。唯一显著的区别是,在构造新元组的行中,在=符号之前有一个变量名。(此外,在那里我们使用大写名称,但这只是一种约定。完全合法的是写(int x, int y) point3 = point1;。那将声明一个名为point3的新元组,其中包含两个名为x和y的int值,初始化为与point1中相同的值。同样,我们可以写(int X, int Y) = point1;。那将把point1解构为两个名为X和Y的局部变量。)
从 C# 10.0 开始,你可以混合使用两种解构形式。在此之前,元组的任何单一解构都必须为目标的每个部分声明一个新变量,或者每个目标都必须是一个已存在的变量。但是正如示例 2-53 所示,现在单个解构可以包含目标类型的混合。
示例 2-53. 在元组解构中混合声明和现有变量
int u;
(u, int v) = point1;
如果你不需要元组的每个元素,你可以使用下划线,正如示例 2-54 所示。这被称为废弃。
示例 2-54. 使用废弃的元组解构
(_, int h) = point1;
下划线字符可以出现在目标的任何位置,并告诉编译器我们不需要将元组的该部分提取到变量中。
动态类型
C#定义了一种名为dynamic的类型。这个类型并不直接对应 CLR 中的任何类型 —— 当我们在 C#中使用dynamic时,编译器将其呈现给运行时作为object,这在下一节中有描述。然而从 C#代码的角度来看,dynamic是一种独特的类型,它启用了一些特殊的行为。
使用 dynamic 时,编译器在编译时不会尝试检查代码执行的操作是否可能成功。换句话说,它有效地禁用了我们通常在 C# 中得到的静态类型行为。你可以自由地在 dynamic 变量上尝试几乎任何操作 —— 你可以使用算术运算符,可以尝试调用其方法,可以尝试将其分配到其他类型的变量中,并且可以尝试获取或设置其属性。当你这样做时,编译器生成的代码试图在运行时理解你要求它做的事情。
如果你从一种这种行为是常态的语言(如 JavaScript)转到 C#,你可能会倾向于因为它符合你习惯的工作方式而将 dynamic 用于一切。然而,你应该意识到它存在一些问题。首先,它是为特定场景设计的:与某些早期 .NET Windows 组件的互操作性。Windows 中的组件对象模型(COM)是自动化 Microsoft Office 套件及许多其他应用程序的基础,而 Office 内置的脚本语言是动态的。由此带来的一个结果是,从 C# 中使用许多 Office 的自动化 API 曾经是一项艰苦的工作。将 dynamic 添加到语言中的一个主要动机之一是希望改进这一点。
和所有 C# 特性一样,它的设计考虑了更广泛的适用性,而不仅仅是作为 Office 互操作功能。但由于这是该功能最重要的应用场景,你可能会发现它支持你从动态语言熟悉的习惯用法的能力令人失望。还需要注意的第二个问题是,它并不是语言中正在进行大量新工作的领域。在引入它时,微软竭尽全力确保所有动态行为尽可能与编译器在编译时知道你将使用的类型时所见到的行为一致。
这意味着支持 dynamic 的基础架构(称为动态语言运行时或 DLR)必须复制 C# 行为的重要部分。然而,自从 dynamic 在 2010 年的 C# 4.0 中添加以来,DLR 并没有得到太多更新,尽管语言自那时以来引入了许多新功能。当然,dynamic 仍然可用,但其功能反映了大约十年前的语言外观。
尽管 dynamic 一开始出现时就存在一些限制。C# 的某些方面依赖于静态类型信息的可用性,这意味着 dynamic 一直在处理委托以及 LINQ 方面存在问题。因此,从一开始,与按照预期使用 C# 作为静态类型语言相比,它确实处于某种劣势。
Object
C# 编译器最后一个特别认可的数据类型是object(或 CLR 称之为System.Object)。这是几乎所有 C# 类型的基类。类型为object的变量能够引用任何派生自object的类型的值。这包括所有数值类型、bool和string类型,以及你可以使用下一章将介绍的关键字定义的任何自定义类型,例如class、record和struct。同时也包括运行时库定义的所有类型,除了某些只能存储在堆栈上并在第十八章中描述的特定类型。
因此,object是终极通用容器。你可以用object变量引用几乎任何东西。我们在第六章中讨论继承时将回到这一点。
运算符
你之前看到表达式是运算符和操作数的序列。我展示了一些可用作操作数的类型,现在是时候看看 C# 提供了哪些运算符了。表 2-3 展示了支持常见算术操作的运算符。
表 2-3. 基本算术运算符
| 名称 | 示例 |
|---|---|
| 一元加号(不起作用) | +x |
| 取反(一元负号) | -x |
| 后增量 | x++ |
| 后减量 | x-- |
| 前增量 | ++x |
| 前减量 | --x |
| 加法 | x + y |
| 减法 | x - y |
| 乘法 | x * y |
| 除法 | x / y |
| 取余 | x % y |
如果你有其他 C 家族语言的经验,所有这些都应该很熟悉。如果不熟悉,可能最奇特的是增量和减量运算符。它们有副作用:对应用于的变量加一或减一(这意味着它们只能应用于变量)。对于后增量和后减量,尽管变量被修改,但包含的表达式最终获取原始值。因此,如果x是一个包含值为 5 的变量,则x++的值也是 5,尽管在计算x++表达式后,x变量将具有值 6。前缀形式返回修改后的值,因此如果x最初是 5,++x生成值 6,这也是在评估表达式后x的值。
尽管表 2-3 中的运算符用于算术运算,某些非数值类型也可以使用。如前所述,当处理字符串时,+符号表示连接,如在第九章中所示,加法和减法运算符也用于组合和删除委托。
C# 还提供了一些运算符,在构成值的位上执行某些二进制操作,如表 2-4 所示。这些运算符不适用于浮点类型。
表 2-4. 二进制整数运算符
| 名称 | 示例 |
|---|---|
| 按位取反 | ~x |
| 按位 AND | x & y |
| 按位 OR | x | y |
| 按位异或 | x ^ y |
| 左移 | x << y |
| 右移 | x >> y |
按位取反运算符反转整数中的所有位数 —— 任何值为 1 的二进制数字变为 0,反之亦然。移位运算符将所有二进制位数左移或右移,移动的位数由第二个操作数指定。左移将底部数字设为 0。对于无符号整数,右移将填充顶部数字为 0,而对于有符号整数的右移则保留顶部数字不变(即负数保持负数,因为它们保持其顶部位设置,而正数将其顶部位设为 0,因此保持正数)。
按位 AND、OR 和 XOR(异或)运算符在整数上执行每个位的布尔逻辑运算。当操作数为 bool 类型时,这三个运算符也可用。(实际上,这些运算符将 bool 视为一位二进制数。)还有一些额外的 bool 值运算符,详见 表 2-5。! 运算符对 bool 执行与 ~ 运算符对整数位的操作相同。
表 2-5. bool 类型运算符
| 名称 | 示例 |
|---|---|
| 逻辑取反(也称为 NOT) | !x |
| 条件 AND | x && y |
| 条件 OR | x || y |
如果您没有使用其他 C 家族语言,AND 和 OR 运算符的条件版本可能对您来说是新的。它们仅在必要时评估其第二个操作数。例如,在评估 (a && b) 时,如果表达式 a 是 false,编译器生成的代码甚至不会尝试评估 b,因为无论 b 的值如何,结果都将是 false。相反,如果第一个操作数是 true,条件 OR 运算符则不会费心评估其第二个操作数,因为无论第二个操作数的值如何,结果都将是 true。如果第二个操作数的表达式包含具有副作用(例如方法调用)或可能产生错误的元素,则这一点非常重要。例如,您经常会看到类似 示例 2-55 的代码。
例 2-55. 条件 AND 运算符
if (s != null && s.Length > 10)
...
此代码检查变量 s 是否包含特殊值 null,即它当前不引用任何值。此处使用 && 运算符很重要,因为如果 s 是 null,评估表达式 s.Length 将导致运行时错误。如果我们使用了 & 运算符,编译器将生成代码,总是评估两个操作数,这意味着如果 s 是 null,运行时将会看到 NullReferenceException。通过使用条件 AND 运算符,我们避免了这种情况,因为第二个操作数 s.Length > 10 仅在 s 不是 null 时才会被评估。
注意
尽管像示例 2-55 中所示的代码曾经很常见,但由于引入了 C# 6.0 中的一个特性——null-conditional operators,它已逐渐变得越来越少见。如果你写 s?.Length 而不是仅仅 s.Length,编译器会生成代码来首先检查 s 是否为 null,从而避免 NullReferenceException。这意味着检查可以简化为 if (s?.Length > 10)。此外,C# 的可选可空引用类型(一个相对较新的特性,在第 3 章中讨论)可以帮助减少对 null 测试的需求。
示例 2-55 通过使用 > 运算符测试一个属性是否大于 10。这是几种关系运算符之一,允许我们比较值。它们都接受两个操作数,并产生一个 bool 结果。表格 2-6 显示了这些运算符,支持所有数值类型。某些运算符也适用于其他一些类型。例如,你可以使用 == 和 != 运算符比较字符串值。(对于 string,其他关系运算符没有内置的排序含义,因为不同国家对字符串排序顺序有不同的理解。如果你想进行有序的字符串比较,.NET 提供了 StringComparer 类,允许你选择排序规则。)
表格 2-6. 关系运算符
| 名称 | 示例 |
|---|---|
| 小于 | x < y |
| 大于 | x > y |
| 小于或等于 | x <= y |
| 大于或等于 | x >= y |
| 等于 | x == y |
| 不等于 | x != y |
与 C 语言家族语言一样,等号运算符是一对等号。这是因为单个等号表示其他东西:它是一个赋值操作,而赋值也是表达式。这可能导致一个不幸的问题:在某些 C 语言家族语言中,当你打算写 if (x == y) 时却误写成 if (x = y)。幸运的是,在 C# 中这通常会产生编译器错误,因为 C# 有一个专门的类型来表示布尔值。在允许数字代表布尔值的语言中,即使 x 和 y 是数字,这两段代码都是合法的。(第一段意味着将 y 的值赋给 x,然后如果该值非零,则执行 if 语句的主体。这与第二段代码非常不同,它并不改变任何东西的值,并且仅在 x 和 y 相等时执行 if 语句的主体。)但在 C# 中,第一个示例仅在 x 和 y 都是 bool 类型时才有意义。¹⁰
C 家族常见的另一个特性是条件运算符。(有时也称为三元运算符,因为它是语言中唯一接受三个操作数的运算符。)它在两个表达式之间进行选择。更确切地说,它评估其第一个操作数,该操作数必须是布尔表达式,然后根据第一个操作数的值是true还是false返回第二个或第三个操作数的值,分别是。(这只是举例说明。在实践中,您通常会使用.NET 的Math.Max方法,其效果相同但更易读。Math.Max还有一个好处,即如果使用具有副作用的表达式,它将仅评估每个表达式一次,这是您无法使用示例 2-56 中显示的方法做到的,因为我们最终会写两次每个表达式。)
示例 2-56。条件运算符
int max = (x > y) ? x : y;
这说明了为什么 C 及其后继者以简洁的语法而闻名。如果您熟悉此类家族的任何语言,示例 2-56 将很容易阅读,但如果您不熟悉,则其含义可能不会立即清晰。这将评估?符号之前的表达式,在本例中是(x > y),并且它要求是产生bool值的表达式。(括号是可选的。我添加它们是为了使代码更易于阅读。)如果这是true,则使用?和:符号之间的表达式(在本例中是x);否则,使用:符号之后的表达式(在这里是y)。
条件运算符类似于条件 AND 和 OR 运算符,因为它只评估必须的操作数。它总是评估其第一个操作数,但永远不会同时评估第二个和第三个操作数。这意味着您可以通过编写类似于示例 2-57 的代码来处理null值。这不会因为s为null而导致NullReferenceException的风险,因为它只有在s不为null时才会评估第三个操作数。
示例 2-57。利用条件评估
int characterCount = s == null ? 0 : s.Length;
但在某些情况下,处理null值的方法更简单。假设您有一个string变量,如果它为null,则希望使用空字符串代替。您可以写(s == null ? "" : s)。但您也可以直接使用空值合并运算符,因为它专门设计用于此任务。这个运算符显示在示例 2-58 中(它是??符号),它评估其第一个操作数,如果第一个操作数非空,则结果是该表达式的结果。如果第一个操作数为null,则评估其第二个操作数并使用它代替。
示例 2-58。空值合并运算符
string neverNull = s ?? "";
我们可以将空值条件运算符与空值合并运算符结合起来,以提供比示例 2-57 更简洁的替代方案,如示例 2-59 所示。
示例 2-59. 空值条件和空值合并运算符
int characterCount = s?.Length ?? 0;
条件、空值条件和空值合并运算符提供的主要好处之一是,它们通常允许您在需要编写大量代码的情况下仅编写单个表达式。如果您将该表达式作为方法的参数使用,这将特别有用,例如在示例 2-60 中。
示例 2-60. 条件表达式作为方法参数
FadeVolume(gateOpen ? MaxVolume : 0.0, FadeDuration, FadeCurve.Linear);
与如果条件运算符不存在时需要编写的代码进行比较。您将需要一个 if 语句。(我会在下一节讨论 if 语句,但由于本书不是给初学者的,我假设您对大致概念很熟悉。)您还需要引入一个本地变量,如示例 2-61 所示,或者在 if/else 的两个分支中复制方法调用,并只更改第一个参数。因此,尽管条件和空值合并运算符很简洁,但它们可以从您的代码中移除大量混乱。
示例 2-61. 没有条件运算符的生活
double targetVolume;
if (gateOpen)
{
targetVolume = MaxVolume;
}
else
{
targetVolume = 0.0;
}
FadeVolume(targetVolume, FadeDuration, FadeCurve.Linear);
还有最后一组要看的运算符:复合赋值 运算符。这些结合赋值和其他某些操作,并适用于 +, -, *, /, %, <<, >>, &, ^, |, 和 ?? 运算符。它们使您无需编写像示例 2-62 中显示的代码。
示例 2-62. 赋值和加法
x = x + 1;
我们可以将此赋值语句更简洁地写成 示例 2-63 中的代码。所有复合赋值运算符都采用这种形式——您只需在原始运算符的末尾加上 =。
示例 2-63. 复合赋值(加法)
x += 1;
这是一种独特的语法,非常清楚地表明我们正在以某种特定方式修改变量的值。因此,尽管这两个片段执行相同的工作,许多开发人员发现第二种习惯上更可取。
这并不是运算符的全面列表。还有一些更专业的运算符,我会在我们看过为其定义的语言区域后再介绍。 (有些与类和其他类型相关,有些与继承相关,有些与集合相关,有些与委托相关。接下来的章节将讲解所有这些内容。)顺便说一句,虽然我一直在描述哪些运算符适用于哪些类型,但也可以编写自定义类型,为大多数这些运算符定义自己的含义。这就是 .NET 的 BigInteger 类型如何支持与内置数值类型相同的算术运算的方式。我将展示如何在 Chapter 3 中实现这一点。
流程控制
到目前为止,我们所检查的大部分代码按照编写顺序执行语句,并在到达末尾时停止。如果这是代码执行流动的唯一可能方式,那么 C# 将没有多大用处。因此,正如你所期望的那样,它有多种结构来编写循环并根据输入来决定执行哪些代码。
使用 if 语句进行布尔决策
if 语句根据 bool 表达式的值决定是否运行特定语句。例如,在 Example 2-64 中的 if 语句将仅在 age 变量的值小于 18 时执行显示消息的块语句。
Example 2-64. 简单的 if 语句
if (age < 18)
{
Console.WriteLine("You are too young to buy alcohol in a bar in the UK.");
}
使用 if 语句时不一定需要使用块语句。你可以使用任何类型的语句作为主体。只有当你希望 if 语句控制多个语句的执行时才需要块。然而,一些编程风格指南建议在所有情况下都使用块。这部分是为了保持一致性,同时也是因为在以后修改代码时避免可能的错误:如果你的 if 语句的主体是非块语句,然后你在后面添加另一个语句,打算让它成为相同主体的一部分,很容易忘记在这两个语句周围添加块,导致像 Example 2-65 中那样的代码。缩进表明开发人员希望最后一个语句是 if 语句主体的一部分,但 C# 忽略缩进,因此最后一个语句将始终运行。如果你习惯于始终使用块,你就不会犯这种错误。
Example 2-65. 可能不是预期的结果
if (authenticationCodesCorrect)
SendTransferConfirmation();
TransferFunds();
if 语句还可以选择包含一个 else 部分,后面跟着另一个语句,仅在 if 语句的表达式求值为 false 时运行。所以 Example 2-66 将根据 optimistic 变量是 true 还是 false 写入第一条或第二条消息。
Example 2-66. if 和 else
if (optimistic)
{
Console.WriteLine("Glass half full");
}
else
{
Console.WriteLine("Glass half empty");
}
else关键字后可以跟随任何语句,通常这是一个代码块。但是,有一种情况大多数开发者不会为else部分使用代码块,那就是它们使用另一个if语句时。示例 2-67 展示了这一点——它的第一个if语句有一个else部分,该部分的主体是另一个if语句。
示例 2-67. 选择多个可能性
if (temperatureInCelsius < 52)
{
Console.WriteLine("Too cold");
}
else if (temperatureInCelsius > 58)
{
Console.WriteLine("Too hot");
}
else
{
Console.WriteLine("Just right");
}
尽管代码看起来仍然像是为第一个else使用了代码块,但该代码块实际上是第二个if语句的主体。第二个if语句才是else的主体。如果我们要严格遵循给每个if和else主体分配自己的代码块的规则,我们会将示例 2-67 重写为示例 2-68。这似乎过于繁琐,因为我们试图通过使用代码块来避免的主要风险在示例 2-67 中并不真正适用。
示例 2-68. 过度使用代码块
if (temperatureInCelsius < 52)
{
Console.WriteLine("Too cold");
}
else
{
if (temperatureInCelsius > 58)
{
Console.WriteLine("Too hot");
}
else
{
Console.WriteLine("Just right");
}
}
尽管我们可以像示例 2-67 中展示的那样将if语句链接在一起,但 C#提供了一种更专门的语句,有时可能更易于阅读。
用switch语句进行多选
switch语句定义了多个语句组,并根据输入表达式的值运行其中一个组或者什么都不做。正如示例 2-69 所示,你将表达式放在switch关键字后的括号内,然后是由大括号界定的区域,其中包含一系列case部分,为表达式的每个预期值定义行为。
示例 2-69. 使用字符串的switch语句
switch (workStatus)
{
case "ManagerInRoom":
WorkDiligently();
break;
case "HaveNonUrgentDeadline":
case "HaveImminentDeadline":
CheckTwitter();
CheckEmail();
CheckTwitter();
ContemplateGettingOnWithSomeWork();
CheckTwitter();
CheckTwitter();
break;
case "DeadlineOvershot":
WorkFuriously();
break;
default:
CheckTwitter();
CheckEmail();
break;
}
如你所见,单个部分可以服务于多个可能性——你可以在部分的开头放置多个不同的case标签,如果任何一个情况适用,该部分中的语句将会运行。你也可以编写一个default部分,如果没有case匹配表达式的值,则运行该部分。switch语句不必是全面的,因此如果没有与表达式值匹配的case,也没有default部分,则switch语句不执行任何操作。
不像if语句需要将主体包裹在一个代码块中,case后面可以跟随多个语句而无需包裹它们。示例 2-69 中的各个部分由break语句界定,这会导致执行跳到switch语句的结尾。这并非结束部分的唯一方式——严格来说,C#编译器规定每个case语句列表的结束点不能可达,因此任何导致执行离开switch语句的方式都是可接受的。你可以使用return语句,或者抛出异常,甚至可以使用goto语句。
一些 C 家族语言(例如 C)允许穿透,意味着如果执行允许达到case部分语句的末尾,它将继续执行下一个。示例 2-70 展示了这种风格,但在 C#中不允许,因为该规则要求case语句列表的末尾不可到达。
示例 2-70. C 风格的穿透,在 C#中是非法的。
switch (x)
{
case "One":
Console.WriteLine("One");
case "Two": // This line will not compile
Console.WriteLine("One or two");
break;
}
C#禁止这种做法,因为绝大多数case部分不会穿透,而在允许它的语言中,当开发者忘记写break语句(或者其他中断switch的语句)时,通常会导致错误。意外的穿透可能会产生不希望的行为,因此 C#要求不仅仅是省略了break:如果你想要穿透,必须明确请求。正如示例 2-71 所示,我们使用不被喜爱的goto关键字来表达我们确实希望一个case穿透到下一个case。
示例 2-71. C#中的穿透。
switch (x)
{
case "One":
Console.WriteLine("One");
`goto` `case` `"Two"``;`
case "Two":
Console.WriteLine("One or two");
break;
}
这在技术上不是goto语句。这是一个goto case语句,只能在switch块内部使用。C#还支持更一般的goto语句——您可以在代码中添加标签,并在方法内部跳转。但是goto被严重反对,因此goto case语句提供的穿透形式似乎是这个关键字唯一被认为是现代社会可接受的用法。
所有这些示例都使用了字符串。你还可以在整数类型、char 类型以及任何枚举(在下一章节中讨论的一种类型)上使用switch。但是case标签不一定要是常量:你还可以使用模式,这将在本章后面讨论。
循环:while和do
C#支持通常的 C 家族循环机制。示例 2-72 展示了一个while循环。它采用一个bool表达式。评估该表达式,如果结果为true,它将执行后续的语句。到目前为止,这与if语句完全相同,但不同之处在于一旦嵌套语句完成,它将再次评估表达式,如果再次为true,它将再次执行嵌套语句。它将继续执行,直到表达式评估为false。与if语句一样,循环体不需要是一个块,但通常会是。
示例 2-72. 一个while循环。
while (!reader.EndOfStream)
{
Console.WriteLine(reader.ReadLine());
}
循环体可能会使用break语句提前结束循环。while表达式是true或false并不重要——执行break语句总是会终止循环。
C# 还提供了continue语句。像break语句一样,它终止当前迭代,但与break不同的是,它然后重新评估while表达式,因此迭代可以继续。continue和break都直接跳转到循环的结尾,但你可以认为continue直接跳转到循环结束}之前的点,而break则跳转到之后的点。顺便说一下,continue和break也适用于我即将展示的所有其他循环样式。
因为while语句在每次迭代之前评估其表达式,所以while循环有可能根本不运行其主体。有时,您可能希望编写一个至少运行一次的循环,仅在第一次迭代后评估bool表达式。这就是do循环的目的,如示例 2-73 所示。
示例 2-73. 一个do循环
char k;
do
{
Console.WriteLine("Press x to exit");
k = Console.ReadKey().KeyChar;
}
while (k != 'x');
注意,示例 2-73 以分号结尾,表示语句结束。与包含while关键字的示例 2-72 中的行进行比较,后者尽管看起来非常相似,但没有分号。这看起来可能不一致,但这不是打字错误。在示例 2-72 中带有while关键字的行末尾放置分号是合法的,但这会改变其含义——它将指示我们希望while循环的主体是一个空语句。随后的代码块将被视为一个全新的语句,在循环完成后执行。代码将陷入无限循环,除非读者已经在流的末尾。(顺便说一句,编译器会发出“可能是误写的空语句”警告。)
C 风格的for循环
C# 继承自 C 的另一种循环方式是for循环。这类似于while,但它为循环的bool表达式添加了两个特性:提供了一个声明和/或初始化一个或多个变量的位置,这些变量在循环运行期间保持在作用域内,并且提供了一个在每次循环时执行某些操作的位置(除了形成循环体的语句)。因此,for循环的结构如下:
for (*`initializer`*; *`condition`*; *`iterator`*) *`body`*
这种循环的一个非常常见的应用是对数组中的所有元素执行某些操作。示例 2-74 展示了一个for循环,它将数组中的每个元素乘以 2。条件部分的工作方式与while循环完全相同——它确定嵌入语句形成的循环体是否运行,并且在每次迭代之前对其进行评估。再次强调,循环体不一定严格要求是一个块,但通常是。
示例 2-74. 使用for循环修改数组元素
for (int i = 0; i < myArray.Length; i++)
{
myArray[i] *= 2;
}
此示例中的初始化器声明了一个名为i的变量,并将其初始化为 0。此初始化仅发生一次——如果它每次循环都重置变量为 0,这将毫无用处,因为循环永远不会结束。该变量的生命周期实际上是在循环开始之前开始,并在循环结束时结束。初始化器不需要是变量声明——您可以使用任何表达式语句。
示例 2-74 中的迭代器仅将循环计数器加 1。它在每次循环迭代结束时运行,在主体运行之后和条件重新评估之前运行。(因此,如果条件最初为假,则不仅主体不运行,迭代器也永远不会被评估。)C#不对迭代器表达式的结果执行任何操作——它仅用于其副作用。因此,无论您是写i++、++i、i += 1,甚至i = i + 1,都没有关系。
for循环不允许您做任何通过编写while循环并在循环之前放置初始化代码以及在循环体末尾放置迭代器来实现的事情。¹¹ 但是,可能存在可读性的好处。for语句将定义如何循环的代码放在一个地方,与定义每次循环时做什么的代码分开,这可能有助于阅读代码的人理解它的功能。他们不必扫描长循环到底部以找到迭代器语句(尽管长循环体跨越代码页通常被认为是不良实践,因此最后一个好处有些可疑)。
如示例 2-75 所示,初始化器和迭代器都可以包含列表,尽管在这种特定情况下并不是非常有用——因为所有迭代器每次循环都会运行,i和j将始终具有相同的值。
示例 2-75. 多个初始化器和迭代器
for (int i = 0, j = 0; i < myArray.Length; i++, j++)
...
您不能编写一个单独的for循环来执行多维迭代。如果需要,您可以像示例 2-76 中所示一样将一个循环嵌套在另一个循环中。
示例 2-76. 嵌套for循环
for (int j = 0; j < height; ++j)
{
for (int i = 0; i < width; ++i)
{
...
}
}
尽管示例 2-74 展示了一个足够常见的遍历数组的习惯用法,您通常会使用不同、更专业的构造。
使用 foreach 循环进行集合迭代
C#提供了一种不在 C 语系语言中通用的循环风格。foreach循环专门用于迭代集合。foreach循环符合以下模式:
foreach (*`item``-``type` `iteration``-``variable`* in *`collection`*) *`body`*
集合 是一个表达式,其类型必须与编译器识别的特定模式匹配。运行时库的IEnumerable<T>接口,我们将在第五章中看到,符合这种模式,尽管编译器实际上并不需要实现该接口——它只需要集合有一个类似该接口定义的GetEnumerator方法。示例 2-77 使用foreach显示数组中的所有字符串(所有数组都提供foreach所需的方法)。
示例 2-77. 使用foreach遍历集合
string[] messages = GetMessagesFromSomewhere();
foreach (string message in messages)
{
Console.WriteLine(message);
}
此循环将为数组中的每个项目运行一次主体。迭代变量(在本例中为message)每次循环都不同,并且将引用当前迭代的项目。
从某种角度来看,这比示例 2-74 中显示的基于for的循环更不灵活:foreach循环无法修改其迭代的集合。这是因为并非所有集合都支持修改。IEnumerable<T>对其集合要求非常少——它不要求可修改性、随机访问,甚至不要求在前面知道集合提供的项目数量。事实上,IEnumerable<T>能够支持永不结束的集合。例如,可以完全合法地编写一个实现,它将返回随机数,只要你愿意继续获取值即可。
但foreach比for提供了两个优势。一个优势是主观的,因此存在争议:它更可读。但显著的是,它也更通用。如果您正在编写对集合执行操作的方法,那么如果使用foreach而不是for,这些方法将更广泛适用,因为您将能够接受一个IEnumerable<T>。示例 2-78 可以处理包含字符串的任何集合,而不仅仅限于数组。
示例 2-78. 通用集合迭代
public static void ShowMessages(IEnumerable<string> messages)
{
foreach (string message in messages)
{
Console.WriteLine(message);
}
}
此代码可以处理不支持随机访问的集合类型,例如第五章中描述的LinkedList<T>类。它还可以处理决定按需生成项目的惰性集合,包括迭代器函数生成的集合,同样显示在第五章中,以及某些 LINQ 查询生成的集合,如第十章中描述的那样。
模式
C#中还有一个最后一个重要的机制要看一看:模式。模式描述了一个值可以根据其进行测试的一个或多个条件。你已经在某些简单模式中看到了它们的作用:switch中的每个case指定了一个模式。但正如我们将要看到的,有许多种类的模式,它们不仅仅适用于switch语句。
之前的 switch 示例,例如 示例 2-69,都使用了最简单的模式类型之一:它们都是常量模式。对于这些模式,只需指定一个常量值,如果表达式具有该值,则匹配此模式。示例 2-79 展示了更有趣的模式类型:它使用了声明模式。如果表达式具有指定类型,则匹配声明模式。正如你在 “Object” 中看到的那样,某些变量能够保存各种不同类型的值。类型为 object 的变量是这种情况的极端案例,因为它们几乎可以保存任何类型的值。语言特性如接口(在 第三章 中讨论)、泛型(在 第四章 中)和继承(在 第六章 中)可能导致变量的静态类型提供了比任意类型 object 更多的信息,但仍为运行时的各种可能类型留下了余地。在这些情况下,声明模式可以非常有用。
示例 2-79. 声明模式
switch (o)
{
case string s:
Console.WriteLine($"A piece of string is {s.Length} long");
break;
case int i:
Console.WriteLine($"That's numberwang! {i}");
break;
}
声明模式有一个有趣的特性,常量模式没有:除了所有模式共有的布尔匹配/不匹配之外,声明模式会产生额外的输出。示例 2-79 中的每个 case 引入一个变量,然后该 case 的代码继续使用该变量。这个输出只是输入,但是复制到具有指定静态类型的变量中。因此,如果 o 最终是 string,那么第一个 case 将匹配,并且我们可以通过 s 变量访问它(这就是为什么 s.Length 表达式编译正确;如果 o 的类型是 object,o.Length 就不会编译通过)。
有时,实际上并不需要声明模式的输出结果——知道输入是否匹配模式就足够了。处理这些情况的一种方法是使用丢弃:如果在通常用于输出变量名称的位置放置一个下划线 (_),那告诉 C# 编译器你只关心值是否匹配类型。C# 9.0 引入了一个更简洁的替代方案:类型模式。类型模式看起来和工作方式类似于声明模式,但没有变量 —— 如 示例 2-80 所示,模式仅由类型名称组成。
示例 2-80. 类型模式
switch (o)
{
case string:
Console.WriteLine("This is a piece of string");
break;
case int:
Console.WriteLine("That's numberwang!");
break;
}
有些模式需要更多工作来产生它们的输出。例如,示例 2-81 展示了一个位置模式,它匹配任何包含一对 int 值的元组,并将这些值提取到两个变量 x 和 y 中。
示例 2-81. 位置模式
case (int x, int y):
Console.WriteLine($"I know where it's at: {x}, {y}");
break;
位置模式是递归模式的一个示例:它们是包含其他模式的模式。在这种情况下,这个位置模式包含声明模式作为它的每一个子模式。但正如示例 2-82 展示的那样,我们可以在每个位置使用常量值来匹配具有特定值的元组。
示例 2-82. 带有常量值的位置模式
switch (p)
{
case (0, 0):
Console.WriteLine("How original");
break;
case (0, 1):
case (1, 0):
Console.WriteLine("What an absolute unit");
break;
case (1, 1):
Console.WriteLine("Be there and be square");
break;
}
我们可以混合使用,因为位置模式可以在每个位置包含不同的模式类型。示例 2-83 展示了一个在第一个位置使用常量模式,在第二个位置使用声明模式的位置模式。
示例 2-83. 带有常量和声明模式的位置模式
case (0, int y):
Console.WriteLine($"This is on the X axis, at height {y}");
break;
如果你是var的粉丝,你可能会想知道是否可以像示例 2-84 那样编写。这是可行的,这里的x和y变量的静态类型取决于模式输入表达式的类型。如果编译器可以确定表达式如何解构(例如,如果switch语句输入的静态类型是(int, int)元组),那么它将使用这些信息来确定输出变量的静态类型。在未知情况下,但仍然可以想象此模式可能匹配的情况下(例如,如果输入是object),那么这里的x和y也将具有类型object。
示例 2-84. 带有var的位置模式
case (var x, var y):
Console.WriteLine($"I know where it's at: {x}, {y}");
break;
注意
编译器将拒绝那些它能够确定匹配不可能发生的模式。例如,如果它知道输入类型是 (string, int, bool) 元组,它不可能匹配只有两个子模式的位置模式,所以 C# 不会允许你尝试。
示例 2-84 展示了一个不寻常的情况,即在某些情况下,使用 var 而不是显式类型可能会引入显著的行为变化。这些 var 模式 与 示例 2-81 中的 声明模式 在一个重要方面有所不同:var 模式 总是匹配其输入,而 声明模式 则检查其输入的类型以确定在运行时是否匹配。实际上,这种检查可能会被优化掉——有些情况下,声明模式将始终匹配,因为其输入类型在编译时已知。但在代码中表达的唯一方法,以确保在位置模式中的子模式不执行运行时检查,是使用 var。因此,尽管一个包含声明模式的位置模式与 示例 2-52 中显示的解构语法非常相似,其行为却大不相同。示例 2-81 实际上执行了三个运行时测试:值是否为 2 元组,第一个值是否为 int,第二个值是否为 int。(因此,它适用于静态类型为 (object, object) 的元组,只要每个值在运行时为 int 即可。)这其实不应该让人感到意外:模式的目的是在运行时测试值是否具有某些特征。然而,在某些递归模式中,您可能希望表达运行时匹配的混合(例如,这个东西是 string 吗?)与静态类型的解构(例如,如果这是 string,我想提取其 Length 属性,我相信它的类型是 int,如果这种信念被证明错误,我希望编译器报错)。模式并不设计用于这样做,所以最好不要试图以这种方式使用它们。
如果我们不需要使用元组中的所有项怎么办?您已经知道一种处理方法。由于我们可以在每个位置使用任何模式,我们可以使用一个声明模式,在第二个位置丢弃其结果:(int x, int _)。或者我们可以使用类型模式:(int x, int)。然而,示例 2-85 显示了一个更简短的替代方案:与其使用类型模式,我们可以只使用一个孤立的下划线。这是一个 丢弃模式。您可以在需要模式但希望指示该特定位置可以使用任何内容且您不需要知道其内容的任何位置使用它。
示例 2-85. 带有丢弃模式的位置模式
case (int x, _):
Console.WriteLine($"At X: {x}. As for Y, who knows?");
break;
这与丢弃声明模式或类型模式的语义略有不同:这些模式将在运行时检查要丢弃的值是否具有指定的类型,并且仅当此检查成功时,模式才会匹配。但是丢弃模式总是匹配的,因此它将匹配 (10, 20),(10, "Foo") 和 (10, (20, 30)),例如。
位置模式不是唯一的递归模式:您还可以编写属性模式。我们将在下一章详细讨论属性,但现在只需知道它们是一种类型的成员,提供某种信息,例如string类型的Length属性,返回一个int,告诉您字符串包含多少个代码单元。示例 2-86 显示了检查此Length属性的属性模式。
示例 2-86. 属性模式
case string { Length: 0 }:
Console.WriteLine("How long is a piece of string? Not very!");
break;
此属性模式以类型名称开头,因此它有效地包含了类型模式的行为,除了其基于属性的测试之外。(在类型模式的输入类型已足够具体以识别属性的情况下,可以省略此内容。例如,在这种情况下,如果输入已经是类型为string的静态内容,则可以省略此内容。)然后,跟随一个花括号中的部分,列出模式想要检查的每个属性及其应用的模式。(这些子模式是使其成为另一个递归模式的内容。)因此,此示例首先检查输入是否为string。如果是,然后将常量模式应用于字符串的Length,因此仅当输入为具有长度为 0 的string时,此模式匹配。
属性模式可以选择指定输出。示例 2-86 没有这样做。示例 2-87 显示了语法,尽管在这种特定情况下,这并不是非常有用,因为此模式将确保s仅指向空字符串。
示例 2-87. 具有输出的属性模式
case string { Length: 0 } s:
Console.WriteLine($"How long is a piece of string? This long: {s.Length}");
break;
由于属性模式中的每个属性都包含一个嵌套模式,因此这些模式也可以产生输出,如示例 2-88 所示。
示例 2-88. 具有输出的嵌套模式的属性模式
case string { Length: int length }:
Console.WriteLine($"How long is a piece of string? This long: {length}");
break;
您可以在属性模式内嵌套属性模式。示例 2-89 使用这种方式来检查由Environment.OSVersion报告的操作系统版本,测试其主要版本是否等于 10。
示例 2-89. 具有嵌套属性模式的属性模式
switch (Environment.OSVersion)
{
case { Version: { Major: 10 } }:
Console.WriteLine("Windows 10, 11, or later");
break;
}
C# 10.0 添加了一种更简洁的语法来表达相同的内容。您可以用示例 2-90 替换示例 2-89 中的case。它具有完全相同的效果,但是表达意图更为紧凑,且可以认为更易读。
示例 2-90. 扩展属性模式
case { Version.Major: 10 }:
Console.WriteLine("Windows 10, 11, or later");
break;
组合和否定模式
C# 提供了三种用于模式匹配的逻辑操作:and(与)、or(或)和 not(非)。其中最简单的是 not,它可以反转模式的含义。示例 2-91 使用 not 来确保仅在变量非空时运行特定代码。这将 not 应用于一个常量模式:这里的 null 被解释为一个常量模式。如果我们仅写 null,那么当值为 null 时该模式匹配,但使用 not null 时,该模式在值非 null 时匹配。
示例 2-91. 使用模式非空检测非空性
case not null:
Console.WriteLine($"User's middle name is: {middleName}");
break;
我们可以使用 and 和 or 来组合两个模式。(这些被官方称为 合取 和 析取 模式;显然 C# 的语言设计者们是形式逻辑的粉丝。)如果我们使用 and 来组合两个模式,结果是一个仅在两个组成模式都匹配时才匹配的模式。例如,如果你想编写代码反对我的中间名,你可以使用 示例 2-92 中展示的方法。这也展示了你可以混合使用这些逻辑操作:它同时使用了 and 和 not。
示例 2-92. 使用模式合取 (and) 和非 (not)
case not null and not "David":
Console.WriteLine($"User's middle name is: {middleName}");
break;
我们可以类似地使用 or,其效果是当其组成模式中任何一个匹配时,该模式匹配其输入。通过重复使用 and 和/或 or 可以构建更大的组合。
关系模式
当模式的类型支持比较时,模式可以使用 <、<=、>= 和 > 操作符。示例 2-93 展示了一个包含两个 关系模式 的 switch 语句,这些基于这些操作符的模式称为关系模式。
示例 2-93. 关系模式
switch (value)
{
case > 0: Console.WriteLine("Positive"); break;
case < 0: Console.WriteLine("Negative"); break;
default: Console.WriteLine("Neither strictly positive nor negative"); break;
};
你可以在任何其他模式可以使用的位置使用关系模式。因此它们可以出现在位置模式内(例如,如果你想匹配 Y 轴上方 X 轴上的点,你可以写 (0, > 0))。示例 2-94 使用两个关系模式作为合取的组成部分,以表达一个值在特定范围内的要求。
示例 2-94. 在合取中使用关系模式
case >= 168 and <= 189:
Console.WriteLine("Is within inner 90 percentiles");
break;
关系模式仅支持与常量的比较。你不能用变量替换前面示例中的数字。
更具体的 when 使用
有时,内置的模式类型不能提供您所需的精度级别。例如,使用位置模式,我们已经看到如何编写匹配任何值对、任何数字对或第一个数字具有特定值的数字对的模式。但是,如果您想匹配第一个数字大于第二个数字的数字对怎么办?这不是一个大的概念性跳跃,但是没有内置支持--关系模式无法做到这一点,因为它们只能与常量进行比较。当然,我们可以用if语句检测条件,但是要重构我们的代码从switch到一系列if和else语句似乎有点可惜,仅仅为了迈出这一小步。幸运的是,我们不必这样做。
case标签中的任何模式都可以通过添加when子句来限定。它允许包含一个布尔表达式。如果值与模式的主体部分匹配,则将评估此表达式,并且仅当when子句为真时,该值才会作为整体模式匹配。示例 2-95 展示了一个带有when子句的位置模式,该模式匹配第一个数字大于第二个数字的对。
示例 2-95. 带有when子句的模式
case (int w, int h) when w > h:
Console.WriteLine("Landscape");
break;
表达式中的模式
所有到目前为止我展示的模式都出现在switch语句的case标签中。这不是使用模式的唯一方式。它们也可以出现在表达式中。要了解这如何有用,请先看看示例 2-96 中的switch语句。这里的意图是返回一个由输入确定的单个值,但有点笨拙:我不得不写四个单独的return语句来表达这一点。
示例 2-96. 模式,但不在表达式中
switch (shape)
{
case (int w, int h) when w < h: return "Portrait";
case (int w, int h) when w > h: return "Landscape";
case (int _, int _): return "Square";
default: return "Unknown";
}
示例 2-97 展示了执行相同任务的代码,但重写为使用switch 表达式。与switch语句一样,switch表达式包含一系列模式。区别在于,switch语句中的标签后面跟着一系列语句,而在switch表达式中,每个模式后面跟着一个单一表达式。switch表达式的值是与第一个匹配模式相关联的表达式的结果。
示例 2-97. 一个switch表达式
return shape switch
{
(int w, int h) when w < h => "Portrait",
(int w, int h) when w > h => "Landscape",
(int _, int _) => "Square",
_ => "Unknown"
};
switch 表达式看起来与 switch 语句有很大不同,因为它们不使用 case 关键字。相反,它们直接使用模式,然后在模式和相应表达式之间使用 =>。这样做有几个原因。首先,它使 switch 表达式更加紧凑。通常在其他东西内部使用表达式——在这种情况下,switch 表达式是 return 语句的值,但您也可以在方法参数或任何允许表达式的地方使用它们——因此我们通常希望它们简洁明了。其次,在这里使用 case 可能会导致混淆,因为对于 switch 语句和 switch 表达式,跟随每个 case 的规则是不同的:在 switch 语句中,每个 case 标签后面跟随一个或多个语句,但在 switch 表达式中,每个模式后必须跟随一个单一的表达式。最后,尽管 switch 表达式是在 C# 的 8.0 版本中添加的,但这种构造形式在其他语言中已经存在多年了。C# 的版本更接近于其他语言中的等价物,而不是使用 case 关键字时可能会有所不同。
注意,在 示例 2-97 中,最后的模式是一个丢弃模式。它会匹配任何内容,并且用于确保模式是穷尽的,即覆盖了所有可能的情况。它与 switch 语句中的 default 部分有类似的效果。不同于 switch 语句,其中无匹配是可以接受的,switch 表达式必须产生一个结果,因此如果您的模式不处理输入类型的所有可能情况,编译器会发出警告。如果我们移除了最后一个情况(假设 shape 的类型是 object),在这种情况下编译器会抱怨。反之,如果 shape 的类型是 (int, int),那么我们必须移除最后一个情况,因为前三个情况实际上覆盖了该类型的所有可能值,编译器会提示最后的模式永远不会应用。如果忽略此警告,然后在运行时评估一个 switch 表达式,传入一个无法匹配的值,它将抛出 SwitchExpressionException。异常在 第 8 章 中有描述。
还有一种方法可以在表达式中使用模式,那就是使用 is 关键字。它将任何模式转换为布尔表达式。示例 2-98 展示了一个简单的例子,用于确定一个值是否是包含两个整数的元组。
示例 2-98. 一个 is 表达式
bool isPoint = value is (int, int);
这也提供了一种在继续之前确保值非空的方法。示例 2-99 结合了否定和常量模式测试 null。
示例 2-99. 使用 is 进行非空性测试
if (s is not null)
{
Console.WriteLine(s.Length);
}
你可能会想为什么我们不直接写s != null。在大多数情况下,这样做是有效的,但它存在一个潜在问题:类型可以自定义比较运算符(如!=)的行为。示例 2-99 中的方法的优势在于,即使类型已经自定义了!=和==的行为,它也会始终执行与null的简单比较。(肯定形式is null也具有相同的优势。)
与switch语句或表达式中的模式一样,is表达式中的模式可以从其源中提取值。就像示例 2-98 中的模式一样,示例 2-100 中的模式测试一个值是否是包含两个整数的元组,但继续使用元组中的两个值。
示例 2-100。使用is表达式中的模式的值
if (value is (int x, int y))
{
Console.WriteLine($"X: {x}, Y: {y}");
}
通过is表达式以这种方式引入的新变量在其包含语句之后仍然在作用域内。因此,在这两个示例中,x和y将一直在作用域内,直到包含块的末尾。由于示例 2-100 中的模式在if语句的条件表达式中,这意味着这些变量在主体块之后仍然在作用域内。然而,如果您尝试在主体之外使用它们,您会发现编译器的明确赋值规则会告诉您它们未初始化。它允许示例 2-100,因为它知道if语句的主体只有在模式匹配时才会运行,因此在这种情况下,x和y将已经初始化并且可以安全使用。
is表达式中的模式不能包含when子句。这是多余的:结果是一个布尔表达式,因此您可以使用正常的布尔运算符添加任何所需的限定条件,就像示例 2-101 所示。
示例 2-101。is表达式中的模式不需要when
if (value is (int w, int h) && w < h)
{
Console.WriteLine($"(Portrait) Width: {w}, Height: {h}");
}
摘要
在本章中,我展示了 C#代码的基本要素——变量、语句、表达式、基本数据类型、运算符、流控制和模式。现在是时候看看程序的更广泛结构了。C#程序中的所有代码必须属于一个类型,而类型将是下一章的主题。
¹ C#确实提供了动态类型作为一个选项,使用dynamic关键字,但它采取了一个略微不同寻常的步骤,将其纳入静态类型的观点中:动态变量的静态类型为dynamic。
² 有关计算的详细信息,请参阅艾伦·图灵的开创性工作。查尔斯·佩兹尔德的《图灵注释》(约翰·威利和儿子)是相关论文的优秀指南。
³ 如果您对 C 系列语言不熟悉,+=运算符可能会让您感到陌生。它是一个复合赋值运算符,在本章后面进行了描述。我在这里使用它来将errorCount增加一。
⁴ 在没有括号的情况下,C# 有 优先级 规则来确定操作符的求值顺序。有关完整且不是很有趣的细节,请参阅文档。在这个例子中,因为除法比加法具有更高的优先级,所以在没有括号的情况下,表达式将求值为 14。
⁵ 严格来说,这仅对正确对齐的 32 位类型保证。然而,C# 默认正确对齐它们,只有当你的代码需要调用非托管代码时,你才会遇到数据错位的情况。
⁶ 因此,十进制数并没有使用其全部的 128 位。如果使其更小会导致对齐困难,而将额外的位用于提高精度则会对性能产生显著影响,因为长度是 32 位的整数对大多数 CPU 来说更容易处理。
⁷ 升级实际上并不是 C# 的一个特性。有一个更通用的机制:转换操作符。C# 为内置数据类型定义了内置的隐式转换操作符。这里讨论的升级是由编译器根据其通常的转换规则而发生的。
⁸ 属性 是类型的成员,代表可以读取或修改或两者都可以的值。第三章 详细描述了属性。
⁹ 存在一些特殊的例外情况,比如指针类型。
¹⁰ 语言专家会注意到,在存在自定义的隐式转换到 bool 的情况下,它在某些情况下也会有意义。我们将在 第三章 中讨论自定义转换。
¹¹ continue 语句使事情变得复杂,因为它提供了一种在不完全执行循环体的情况下进入下一次迭代的方式。即使如此,在使用 continue 语句时也可以复制迭代器的效果,只是需要更多的工作。