C#12 技术手册(一)
原文:
zh.annas-archive.org/md5/e2c84fd09097e50aedbc4e5989f32a85译者:飞龙
前言
C# 12 代表了微软旗舰编程语言的第九个重大更新,将 C#定位为一种具有非同寻常灵活性和广度的语言。一方面,它提供了高级抽象,如查询表达式和异步继续,而另一方面,它通过自定义值类型和可选指针等构造允许低级效率。
这种增长的代价是需要学习的内容比以往任何时候都多。尽管诸如微软的 IntelliSense 和在线参考等工具在帮助您处理工作中的问题方面非常出色,但它们假设您具备一定的概念知识图谱。本书正是以简洁统一的风格提供了这样一张知识地图——没有冗长的介绍和混乱的内容。
与过去七个版本一样,《C# 12 简明手册》围绕概念和用例组织,使其既适合顺序阅读,又适合随机浏览。它还深入到重要的深度,同时仅假设基本的背景知识,因此对中级和高级读者都很容易理解。
本书涵盖了 C#、公共语言运行时(CLR)以及.NET 8 基础类库(BCL)。我们选择这个重点,以便为难度较大和高级主题留出空间,而不影响深度或可读性。最近添加到 C#的功能已经标记,这样您也可以将本书作为 C# 11 和 C# 10 的参考。
目标读者
本书面向中高级读者。不需要事先了解 C#,但需要一些通用的编程经验。对于初学者,本书是编程教程风格介绍的补充,而非替代。
本书是任何专注于应用技术(如 ASP.NET Core 或 Windows Presentation Foundation(WPF))的大量图书的理想伴侣。《C# 12 简明手册》涵盖了这些书籍所忽略的语言和.NET 的领域,反之亦然。
如果您寻找一本涵盖每一个.NET 技术的书籍,那么这本书不适合您。如果您想要了解特定于移动设备开发的 API,则本书也不适合。
本书的组织结构
第二章到第四章完全集中于 C#,从语法、类型和变量的基础开始,到诸如不安全代码和预处理器指令等高级主题。如果您是这门语言的新手,应该按顺序阅读这些章节。
剩余章节专注于 .NET 8 的基础类库,涵盖语言集成查询(LINQ)、XML、集合、并发、I/O 和网络、内存管理、反射、动态编程、属性、加密和本地互操作性等主题。您可以随机阅读大多数章节,除了第 5 和第六章,这两章为后续主题奠定基础。最好按顺序阅读关于 LINQ 的三章,有些章节假设您具备一些并发知识,我们在 第十四章 中进行讨论。
使用本书所需条件
本书示例需要 .NET 8. 您还会发现 Microsoft 的 .NET 文档对查找单个类型和成员(可在线访问)非常有用。
虽然可以在简单的文本编辑器中编写源代码,并从命令行构建程序,但使用 代码临时记事本 可以更快速地测试代码片段,再加上集成开发环境(IDE)可以更高效地生成可执行文件和库。
对于 Windows 代码临时记事本,请从 www.linqpad.net 下载 LINQPad 8(免费)。LINQPad 完全支持 C# 12,并由作者维护。
对于 Windows IDE,请下载 Visual Studio 2022:任何版本都适用于本书教授的内容。对于跨平台 IDE,请下载 Visual Studio Code。
注意事项
所有章节的代码清单均作为交互式(可编辑)LINQPad 示例提供。您可以一键下载所有示例:在左下角点击 LINQPad 的“Samples”选项卡,点击“Download more samples”,然后选择“C# 12 in a Nutshell”。
本书使用的约定
本书使用基本的 UML 符号来说明类型之间的关系,如 图 P-1 所示。倾斜的矩形表示抽象类;圆圈表示接口。带有空心三角形的线表示继承,三角形指向基类型。带有箭头的线表示单向关联;没有箭头的线表示双向关联。
图 P-1. 示例图
本书使用以下排版约定:
斜体
表示新术语、URI、文件名和目录
等宽字体
表示 C# 代码、关键字和标识符以及程序输出
**等宽字体加粗**
显示代码的突出部分
*等宽字体斜体*
显示应由用户提供值替换的文本
使用代码示例
补充材料(代码示例、练习等)可在 http://www.albahari.com/nutshell 下载。
本书旨在帮助您完成工作。通常情况下,您可以在您的程序和文档中使用本书中的代码,无需联系我们获得许可。但如果您要复制本书的大部分代码,则需要许可。例如,编写一个使用本书中几个代码块的程序不需要许可。销售或分发 O’Reilly 图书的示例需要许可。引用本书并引用示例代码来回答问题不需要许可(尽管我们感谢署名)。将本书的大量示例代码整合到产品文档中需要许可。
我们欣赏,但通常不要求署名。署名通常包括标题、作者、出版商和 ISBN。例如:“C# 12 in a Nutshell by Joseph Albahari (O’Reilly)。Copyright 2024 Joseph Albahari, 978-1-098-14744-0.”
如果您认为您使用的代码示例超出了公平使用或这里给出的许可,请随时通过 permissions@oreilly.com 与我们联系。
如何联系我们
请将关于本书的评论和问题发送给出版商:
-
O’Reilly Media, Inc.
-
1005 Gravenstein Highway North
-
加利福尼亚州塞巴斯托波尔 95472
-
800-889-8969(美国或加拿大境内)
-
707-829-7019(国际或本地)
-
707-829-0104(传真)
我们为本书设有网页,列出勘误、示例及其他信息。您可以访问https://oreil.ly/c-sharp-nutshell-12查看此页面。
代码清单和其他资源请参阅:
获取有关我们的图书和课程的新闻和信息,请访问https://oreilly.com。
在 LinkedIn 上找到我们:https://linkedin.com/company/oreilly-media
在 Twitter 上关注我们:https://twitter.com/oreillymedia
在 YouTube 上观看我们:https://youtube.com/oreillymedia
致谢
Joseph Albahari
自 2007 年首次问世以来,本书依赖于一些出色的技术审阅者的意见。对于他们在最近版本中的贡献,我特别感谢 Stephen Toub、Paulo Morgado、Fred Silberberg、Vitek Karas、Aaron Robinson、Jan Vorlicek、Sam Gentile、Rod Stephens、Jared Parsons、Matthew Groves、Dixin Yan、Lee Coward、Bonnie DeWitt、Wonseok Chae、Lori Lalonde 和 James Montemagno。
我特别感谢埃里克·利珀特(Eric Lippert)、乔恩·斯基特(Jon Skeet)、史蒂芬·托布(Stephen Toub)、尼古拉斯·帕尔迪诺(Nicholas Paldino)、克里斯·伯罗斯(Chris Burrows)、肖恩·法卡斯(Shawn Farkas)、布莱恩·格伦克迈耶(Brian Grunkemeyer)、莫妮·斯蒂芬斯(Maoni Stephens)、大卫·德温特(David DeWinter)、迈克·巴内特(Mike Barnett)、梅丽塔·安德森(Melitta Andersen)、米奇·韦特(Mitch Wheat)、布莱恩·皮克(Brian Peek)、克日什托夫·瓦利纳(Krzysztof Cwalina)、马特·沃伦(Matt Warren)、乔尔·波巴尔(Joel Pobar)、格林·格里菲斯(Glyn Griffiths)、伊昂·瓦西里安(Ion Vasilian)、布拉德·艾布拉姆斯(Brad Abrams)和亚当·内森(Adam Nathan)的早期贡献。
我感谢微软的许多技术审阅者都是杰出的个人,特别感谢你们花时间将本书提升到下一个质量水平。
我要感谢本·阿尔巴哈里(Ben Albahari)和埃里克·约翰森(Eric Johannsen),他们对之前版本有所贡献,以及 O'Reilly 团队,特别是我高效负责的编辑科尔宾·科林斯(Corbin Collins)。最后,我深深感谢我的美妙妻子李·阿尔巴哈里(Li Albahari),在整个项目期间她的存在使我保持愉快。
第一章:介绍 C#和.NET
C#是一种通用、类型安全的面向对象编程语言。语言的目标是提高程序员的生产力。为此,C#平衡了简单性、表达能力和性能。自从第一个版本以来,语言的首席架构师是 Anders Hejlsberg(Turbo Pascal 的创造者和 Delphi 的架构师)。C#语言是平台中立的,并与一系列特定于平台的运行时配合工作。
面向对象
C# 是面向对象范式的丰富实现,包括封装、继承和多态。封装意味着在对象周围创建一个边界,以分隔其外部(公共)行为和内部(私有)实现细节。以下是从面向对象的角度看 C#的独特特性:
统一类型系统
C# 中的基本构建块是一个称为类型的封装单元,其中所有类型最终都共享一个共同的基类型。这意味着所有类型,无论是代表业务对象还是诸如数字之类的基本类型,都共享相同的基本功能。例如,任何类型的实例都可以通过调用其ToString方法转换为字符串。
类和接口
在传统的面向对象范式中,类型的唯一种类是类。在 C#中,还有几种其他类型,其中一种是接口。接口类似于一个不能持有数据的类。这意味着它只能定义行为(而不是状态),这允许多重继承以及规范与实现的分离。
属性、方法和事件
在纯粹的面向对象范式中,所有函数都是方法。在 C#中,方法只是函数成员的一种,其中还包括属性和事件(还有其他类型)。属性是封装对象状态的一部分的函数成员,例如按钮的颜色或标签的文本。事件是简化对象状态更改处理的函数成员。
虽然 C# 主要是面向对象的语言,但它也借鉴了函数式编程范式,具体来说:
函数可以被视为值
使用委托,C#允许将函数作为值传递给其他函数,并从其他函数返回。
C# 支持纯度模式
函数式编程的核心是避免使用值会变化的变量,而是采用声明式模式。C#具有关键功能来帮助这些模式,包括能够即时编写“捕获”变量的未命名函数(lambda 表达式),以及通过查询表达式执行列表或响应式编程。C#还提供了记录,使得编写不可变(只读)类型变得更加容易。
类型安全
C#主要是一种类型安全的语言,意味着类型的实例只能通过它们定义的协议进行交互,从而确保每种类型的内部一致性。例如,C#会阻止你像操作整数类型一样操作字符串类型。
具体来说,C#支持静态类型,这意味着语言在编译时强制实施类型安全性。这是对运行时类型安全性的补充。
静态类型化甚至在程序运行之前就消除了大量错误。它将负担从运行时单元测试转移到编译器,以验证程序中所有类型的正确匹配。这使得大型程序更易管理,更可预测,更健壮。此外,静态类型化允许工具如 Visual Studio 中的智能感知帮助编程,因为它知道给定变量的类型,从而知道可以在该变量上调用哪些方法。此类工具还可以识别程序中使用变量、类型或方法的所有地方,从而支持可靠的重构。
注意
C#还允许你的代码的部分通过dynamic关键字进行动态类型化。然而,C#仍然是一种主要静态类型的语言。
C# 也被称为强类型语言,因为其类型规则严格执行(无论是静态还是运行时)。例如,你不能用浮点数直接调用设计为接受整数的函数,除非你首先显式将浮点数转换为整数。这有助于防止错误。
内存管理
C#依赖运行时执行自动内存管理。公共语言运行时具有作为程序一部分执行的垃圾收集器,回收不再引用的对象的内存。这使得程序员无需显式地为对象释放内存,消除了在 C++等语言中遇到的指针错误问题。
C#并未消除指针:它仅使它们对大多数编程任务不必要。对于性能关键的热点和互操作性,可以在标记为unsafe的块中使用指针和显式内存分配。
平台支持
C#具有支持以下平台的运行时:
-
Windows 7+桌面(用于富客户端、Web、服务器和命令行应用程序)
-
macOS(用于 Web 和命令行应用程序,以及通过 Mac Catalyst 的富客户端应用程序)
-
Linux(用于 Web 和命令行应用程序)
-
Android 和 iOS(用于移动应用程序)
-
Windows 10 设备(Xbox、Surface Hub 和 HoloLens)通过 UWP
还有一种称为Blazor的技术,可以将 C#编译为在浏览器中运行的 WebAssembly。
CLR、BCL 和运行时
C# 程序的运行时支持包括 公共语言运行时 和 基础类库。运行时还可以包括一个更高级的 应用层,其中包含用于开发富客户端、移动或 Web 应用程序的库(见 图 1-1)。存在不同的运行时以支持不同类型的应用程序和不同的平台。
图 1-1. 运行时架构
公共语言运行时
公共语言运行时(CLR)提供了自动内存管理和异常处理等重要的运行时服务。(“公共”一词指的是同一个运行时可以被其他托管编程语言共享,如 F#、Visual Basic 和 Managed C++。)
C# 被称为 托管语言,因为它将源代码编译为托管代码,这些代码以 中间语言(IL)表示。CLR 将 IL 转换为机器的本机代码,如 X64 或 X86,通常在执行前进行。这称为即时(JIT)编译。还可以提供预编译来改善大型程序集或资源受限设备的启动时间(以及在开发移动应用程序时满足 iOS 应用商店规则)。
托管代码的容器称为 程序集。一个程序集不仅包含 IL,还包含类型信息(元数据)。有了元数据,程序集可以引用其他程序集中的类型,而无需额外的文件。
注意
使用 Microsoft 的 ildasm 工具可以检查和分解汇编内容。而使用 ILSpy 或 JetBrain 的 dotPeek 等工具,可以进一步反编译 IL 到 C#。因为 IL 比本机机器码更高级,所以反编译器可以相当好地重建原始的 C#。
程序可以查询其自身的元数据(反射),甚至在运行时生成新的 IL(反射.emit)。
基础类库
CLR 总是随附一组称为 基础类库(BCL)的程序集。BCL 为程序员提供核心功能,例如集合、输入/输出、文本处理、XML/JSON 处理、网络、加密、互操作、并发和并行编程。
BCL 还实现了 C# 语言本身需要的类型(例如枚举、查询、异步等功能),并允许您显式访问 CLR 的功能,如反射和内存管理。
运行时
运行时(也称为框架)是一个可部署的单元,您可以下载并安装。运行时包括一个 CLR(及其 BCL),以及一个特定于您正在编写的应用程序类型的可选的 应用层 —— Web、移动、富客户端等。(如果您正在编写命令行控制台应用程序或非 UI 库,则不需要应用层。)
在编写应用程序时,您针对特定运行时,这意味着您的应用程序使用并依赖运行时提供的功能。您的运行时选择还决定了应用程序将支持哪些平台。
下表列出了主要的运行时选项:
| 应用层 | CLR/BCL | 程序类型 | 运行于... | |
|---|---|---|---|---|
| ASP.NET | .NET 8 | Web | Windows、Linux、macOS | |
| Windows Desktop | .NET 8 | Windows | Windows 10+ | |
| WinUI 3 | .NET 8 | Windows | Windows 10+ | |
| MAUI | .NET 8 | 移动、桌面 | iOS、Android、macOS、Windows 10+ | |
| .NET Framework | .NET Framework | Web, Windows | Windows 7+ |
图 1-2 在图形上显示了这些信息,同时也作为本书内容的指南。
图 1-2. C# 运行时
.NET 8
.NET 8 是微软的旗舰开源运行时。您可以编写运行在 Windows、Linux 和 macOS 上的 Web 和控制台应用程序;运行在 Windows 10+ 和 macOS 上的富客户端应用程序;以及运行在 iOS 和 Android 上的移动应用程序。本书重点介绍 .NET 8 的 CLR 和 BCL。
与 .NET Framework 不同,.NET 8 未预装在 Windows 机器上。如果尝试在没有正确运行时的情况下运行 .NET 8 应用程序,将出现消息引导您访问网页下载运行时。您可以通过创建自包含部署来避免这种情况,该部署包括应用程序所需的运行时部分。
注
.NET 的更新历史如下:.NET Core 1.x → .NET Core 2.x → .NET Core 3.x → .NET 5 → .NET 6 → .NET 7 → .NET 8. 在 .NET Core 3 之后,Microsoft 删除了名称中的“Core”,并跳过了版本 4,以避免与* .NET Framework* 4.x 混淆,后者是所有前述运行时的先行版本但仍得到支持并广泛使用。
这意味着在 .NET Core 1.x → .NET 7 下编译的程序集在大多数情况下可以在 .NET 8 下运行而无需修改。相比之下,在任何版本的 .NET Framework 下编译的程序集通常与 .NET 8 不兼容。
Windows 桌面和 WinUI 3
为了编写在 Windows 10 及更高版本上运行的富客户端应用程序,您可以选择经典的 Windows 桌面 API(Windows Forms 和 WPF)和 WinUI 3. Windows 桌面 API 是 .NET 桌面运行时的一部分,而 WinUI 3 则属于Windows 应用程序 SDK(需要单独下载)。
经典的 Windows 桌面 API 自 2006 年以来存在,并且享有出色的第三方库支持,以及在诸如 StackOverflow 等网站上提供大量问题解答。WinUI 3 在 2022 年发布,旨在编写现代沉浸式应用程序,具备最新的 Windows 10+ 控件。它是Universal Windows Platform(UWP)的后继者。
MAUI
MAUI(多平台应用程序 UI)主要设计用于创建 iOS 和 Android 的移动应用程序,尽管也可用于通过 Mac Catalyst 和 WinUI 3 在 macOS 和 Windows 上运行的桌面应用程序。MAUI 是 Xamarin 的演变,允许单个项目目标多个平台。
注意
对于跨平台桌面应用程序,第三方库 Avalonia 提供了一个 MAUI 的替代方案。Avalonia 还可以在 Linux 上运行,结构比 MAUI 更简单(因为它在没有 Catalyst/WinUI 间接层的情况下运行)。Avalonia 的 API 类似于 WPF,并且还提供一个名为 XPF 的商业附加组件,几乎完全兼容 WPF。
.NET Framework
.NET Framework 是微软最初仅限于 Windows 的运行时,用于编写运行于 Windows 桌面/服务器上的 Web 和丰富客户端应用程序。尽管没有计划推出主要新版本,但由于现有应用程序的丰富性,微软将继续支持和维护当前的 4.8 版本。
在.NET Framework 中,CLR/BCL 与应用程序层集成。在.NET 8 下重新编译.NET Framework 编写的应用程序通常需要一些修改。.NET Framework 的一些功能在.NET 8 中不存在(反之亦然)。
.NET Framework 与 Windows 预装,并通过 Windows 更新自动打补丁。当你的目标是.NET Framework 4.8 时,你可以使用 C# 7.3 及更早版本的功能。(你可以通过在项目文件中指定更高语言版本来覆盖此设置,这将解锁所有最新语言功能,除了需要新运行时支持的那些功能。)
注意
单词“.NET”长期以来被用作涵盖包括“.NET”在内的任何技术的总称(.NET Framework、.NET Core、.NET Standard 等)。
这意味着微软将.NET Core 重命名为.NET,造成了不幸的歧义。在本书中,当出现歧义时,我们将把新的.NET 称为*.NET 5+*。为了指代.NET Core 及其后继版本,我们将使用短语“.NET Core 和.NET 5+”。
为增加混淆,.NET (5+) 是一个框架,但与*.NET Framework截然不同。因此,在可能的情况下,我们将更倾向于使用术语运行时而非框架*。
专用运行时
还有以下专用运行时:
-
Unity 是一个游戏开发平台,允许用 C#编写游戏逻辑。
-
Universal Windows Platform(UWP)旨在编写在 Windows 10+桌面和设备上运行的触摸优先应用程序,包括 Xbox、Surface Hub 和 HoloLens。UWP 应用程序是沙盒化的,并通过 Windows Store 发布。UWP 使用.NET Core 2.2 CLR/BCL 的一个版本,不太可能更新这种依赖关系;相反,微软建议用户切换到其现代替代品 WinUI 3。但由于 WinUI 3 仅支持 Windows 桌面,UWP 仍然在定位 Xbox、Surface Hub 和 HoloLens 时具有专用应用程序的市场。
-
.NET Micro Framework 用于在资源极为有限的嵌入式设备上运行 .NET 代码(不到一兆字节)。
在 SQL Server 中还可以运行托管代码。通过 SQL Server CLR 集成,你可以用 C# 编写自定义函数、存储过程和聚合函数,然后从 SQL 中调用它们。这与 .NET Framework 和一个特殊的“托管”CLR 结合使用,强制实施沙箱以保护 SQL Server 进程的完整性。
C# 简史
下面是每个 C# 版本中新功能的逆时代顺序,以便读者了解老版本语言的好处。
C# 12 中的新功能
C# 12 随 Visual Studio 2022 发布,并且当你的目标是 .NET 8 时使用。
集合表达式
而不是像下面这样初始化数组:
char[] vowels = {'a','e','i','o','u'};
现在可以使用方括号(一个 集合表达式):
char[] vowels = ['a','e','i','o','u'];
集合表达式有两个主要优点。首先,相同的语法也适用于其他集合类型,例如列表和集合(甚至低级别的 span 类型):
List<char> list = ['a','e','i','o','u'];
HashSet<char> set = ['a','e','i','o','u'];
ReadOnlySpan<char> span = ['a','e','i','o','u'];
第二,它们是 目标类型推断,这意味着在编译器可以推断出类型的其他情况下,可以省略类型,例如在调用方法时:
Foo (['a','e','i','o','u']);
void Foo (char[] letters) { ... }
更多详情请参见 “集合初始化器和集合表达式”。
类和结构体中的主要构造函数
从 C# 12 开始,你可以直接在类(或结构体)声明后包含一个参数列表:
class Person (string firstName, string lastName)
{
public void Print() => Console.WriteLine (firstName + " " + lastName);
}
这指示编译器自动构建一个 主要构造函数,允许以下操作:
Person p = new Person ("Alice", "Jones");
p.Print(); // Alice Jones
这个特性自 C# 9 开始存在于记录(records)中——在那里它们的行为稍有不同。对于记录,编译器(默认情况下)为每个主要构造函数参数生成一个公共的只读属性。对于类和结构体来说并非如此;要达到相同的结果,必须显式定义这些属性:
class Person (string firstName, string lastName)
{
public string FirstName { get; set; } = firstName;
public string LastName { get; set; } = lastName;
}
主要构造函数在简单场景中运行良好。我们在 “主要构造函数(C# 12)” 中描述了它们的细微差别和限制。
默认的 Lambda 参数
就像普通方法可以定义带有默认值的参数一样:
void Print (string message = "") => Console.WriteLine (message);
因此,lambda 表达式也可以:
var print = (string message = "") => Console.WriteLine (message);
print ("Hello");
print ();
这个特性在诸如 ASP.NET Minimal API 等库中非常有用。
给任何类型取别名
C# 一直允许你通过 using 指令给一个简单或通用类型取别名:
using ListOfInt = System.Collections.Generic.List<int>;
var list = new ListOfInt();
从 C# 12 开始,这种方法也适用于其他类型,例如数组和元组:
using NumberList = double[];
using Point = (int X, int Y);
NumberList numbers = { 2.5, 3.5 };
Point p = (3, 4);
其他新特性
C# 12 还支持 内联数组,通过 [System.Runtime.CompilerServices.InlineArray] 属性。这允许在结构体中创建固定大小的数组而无需在不安全的上下文中进行,并且主要用于运行时 API 中。
C# 11 中的新功能
C# 11 随 Visual Studio 2022 发布,并且当你的目标是 .NET 7 时默认使用。
原始字符串字面量
用三个或更多引号字符包裹字符串创建原始字符串字面量,它几乎可以包含任何字符序列,无需转义或重复:
string raw = """<file path="c:\temp\test.txt"></file>""";
原始字符串字面量可以是多行的,并且可以通过$前缀进行插值:
string multiLineRaw = $"""
Line 1
Line 2
The date and time is {DateTime.Now}
""";
在原始字符串字面量前使用两个(或更多)$字符可以改变插值序列,从单个大括号变为两个(或更多)大括号,允许你在字符串本身中包含大括号:
Console.WriteLine ($$"""{ "TimeStamp": "{{DateTime.Now}}" }""");
// Output: *{ "TimeStamp": "01/01/2024 12:13:25 PM" }*
我们在“原始字符串字面量(C# 11)”和“字符串插值”章节中涵盖了此功能的细微差别。
UTF-8 字符串
使用u8后缀,你可以创建使用 UTF-8 编码而不是 UTF-16 编码的字符串字面量。此功能适用于高级场景,例如在性能热点中低级处理 JSON 文本:
ReadOnlySpan<byte> utf8 = "ab→cd"u8; // Arrow symbol consumes 3 bytes
Console.WriteLine (utf8.Length); // 7
底层类型是ReadOnlySpan<byte>(第二十三章),您可以通过调用其ToArray()方法将其转换为字节数组。
列表模式
列表模式匹配方括号中的一系列元素,并与任何可计数的集合类型一起使用(具有Count或Length属性以及int类型或System.Index类型的索引器):
int[] numbers = { 0, 1, 2, 3, 4 };
Console.WriteLine (numbers is [0, 1, 2, 3, 4]); // True
下划线匹配任意值的单个元素,而两个点匹配零个或多个元素(切片):
Console.WriteLine (numbers is [_, 1, .., 4]); // True
切片后可以跟随var模式—详见“列表模式”。
必需成员
将required修饰符应用于字段或属性会强制类或结构的使用者在构造时通过对象初始化器填充该成员:
Asset a1 = new Asset { Name = "House" }; // OK
Asset a2 = new Asset(); // Error: will not compile!
class Asset { public required string Name; }
使用此功能,您可以避免编写具有长参数列表的构造函数,从而简化子类化。如果您希望编写构造函数,可以通过在其上应用[SetsRequiredMembers]属性来绕过该构造函数的必需成员限制—详见“必需成员(C# 11)”。
静态虚拟/抽象接口成员
从 C# 11 开始,接口可以将成员声明为static virtual或static abstract:
public interface IParsable<TSelf>
{
static abstract TSelf Parse (string s);
}
这些成员在类或结构中以静态函数实现,并可以通过约束类型参数进行多态调用。
T ParseAny<T> (string s) where T : IParsable<T> => T.Parse (s);
运算符函数也可以声明为static virtual或static abstract。
更多细节,请参见“静态虚拟/抽象接口成员”和“静态多态性”。我们还描述了如何通过反射调用静态抽象成员的方法,详见“调用静态虚拟/抽象接口成员”。
泛型数学
System.Numerics.INumber<TSelf> 接口(在 .NET 7 中新增)统一了所有数值类型的算术操作,允许编写如下泛型方法:
T Sum<T> (T[] numbers) where T : INumber<T>
{
T total = T.Zero;
foreach (T n in numbers)
total += n; // Invokes addition operator for any numeric type
return total;
}
int intSum = Sum (3, 5, 7);
double doubleSum = Sum (3.2, 5.3, 7.1);
decimal decimalSum = Sum (3.2m, 5.3m, 7.1m);
INumber<TSelf> 被所有实数和整数数字类型(以及char)实现,并包含多个接口,包括以下静态抽象操作符定义:
static abstract TResult operator + (TSelf left, TOther right);
我们在“多态运算符”和“通用数学”中进行了讨论。
其他新特性
具有file访问修饰符的类型只能从同一文件中访问,并且旨在在源生成器内使用:
file class Foo { ... }
C# 11 还引入了检查运算符(参见“检查运算符”),用于定义在checked块内调用的运算符函数(这是实现通用数学的完整实现所需的)。C# 11 还放宽了结构体构造函数中必须填充每个字段的要求(参见“结构构造语义”)。
此外,C# 11 在面向 .NET 7 或更高版本时增强了在运行时(32 或 64 位)匹配进程地址空间的nint和nuint本机大小整数类型,这些类型在 C# 9 中引入时与其底层运行时类型(IntPtr 和 UIntPtr)之间的编译时区别已经消失。详见“本机大小整数”以获取详细讨论。
C# 10 中的新特性
C# 10 随 Visual Studio 2022 发布,并在目标 .NET 6 时使用。
文件范围命名空间
在常见情况下,文件中的所有类型都定义在单个命名空间中,C# 10 中的文件范围命名空间声明可以减少混乱并消除不必要的缩进级别:
namespace MyNamespace; // Applies to everything that follows in the file.
class Class1 {} // inside MyNamespace
class Class2 {} // inside MyNamespace
全局 using 指令
当您在using指令前加上global关键字时,它会将该指令应用于项目中的所有文件:
global using System;
global using System.Collection.Generic;
这使您可以避免在每个文件中重复相同的指令。global using 指令与 using static 兼容。
此外,.NET 6 项目现在支持隐式全局 using 指令:如果项目文件中的ImplicitUsings元素设置为 true,则会自动导入最常用的命名空间(基于 SDK 项目类型)。详见“全局 using 指令”获取更多细节。
对于匿名类型的非破坏性突变
C# 9 引入了with关键字,用于对记录执行非破坏性突变。在 C# 10 中,with关键字也适用于匿名类型:
var a1 = new { A = 1, B = 2, C = 3, D = 4, E = 5 };
var a2 = a1 with { E = 10 };
Console.WriteLine (a2); // { A = 1, B = 2, C = 3, D = 4, E = 10 }
新的解构语法
C# 7 引入了元组(或任何具有Deconstruct方法的类型)的解构语法。C# 10 深化了此语法,允许在同一解构中混合赋值和声明:
var point = (3, 4);
double x = 0;
(x, double y) = point;
结构中的字段初始化器和无参数构造函数
从 C# 10 开始,您可以在结构体中包含字段初始化程序和无参数构造函数(参见“结构体”)。这些仅在显式调用构造函数时执行,因此可以轻松地通过default关键字绕过。此功能主要为结构记录的利益而引入。
记录结构体
记录最早在 C# 9 中引入,作为增强编译类。在 C# 10 中,记录还可以是结构体:
record struct Point (int X, int Y);
否则规则相似:记录结构体与类结构体具有几乎相同的特性(参见“记录”)。唯一的例外是记录结构体上的编译器生成的属性是可写的,除非您在记录声明前加上readonly关键字。
Lambda 表达式增强
对 lambda 表达式的语法进行了多方面增强。首先,允许隐式类型化(var):
var greeter = () => "Hello, world";
lambda 表达式的隐式类型为Action或Func委托,在这种情况下,greeter的类型为Func<string>。必须明确声明任何参数类型:
var square = (int x) => x * x;
其次,lambda 表达式可以指定返回类型:
var sqr = int (int x) => x;
这主要是为了改善复杂嵌套 lambda 的编译器性能。
第三,您可以将 lambda 表达式传递给object、Delegate或Expression类型的方法参数:
M1 (() => "test"); // Implicitly typed to Func<string>
M2 (() => "test"); // Implicitly typed to Func<string>
M3 (() => "test"); // Implicitly typed to Expression<Func<string>>
void M1 (object x) {}
void M2 (Delegate x) {}
void M3 (Expression x) {}
最后,您可以将属性应用于 lambda 表达式的编译生成目标方法(以及其参数和返回值):
Action a = [Description("test")] () => { };
参见“将属性应用于 Lambda 表达式”以获取更多详细信息。
嵌套属性模式
在 C# 10 中,可以使用以下简化的语法进行嵌套属性模式匹配(参见“属性模式”):
var obj = new Uri ("https://www.linqpad.net");
if (obj is Uri { Scheme.Length: 5 }) ...
这相当于:
if (obj is Uri { Scheme: { Length: 5 }}) ...
CallerArgumentExpression
将[CallerArgumentExpression]属性应用于方法参数,可以从调用站点捕获参数表达式:
Print (Math.PI * 2);
void Print (double number,
[CallerArgumentExpression("number")] string expr = null)
=> Console.WriteLine (expr);
// Output: Math.PI * 2
此功能主要用于验证和断言库(参见“CallerArgumentExpression”)。
其他新功能
C# 10 中的#line指令已增强,允许指定列和范围。
C# 10 中的插值字符串可以是常量,只要插入的值是常量即可。
记录可以在 C# 10 中封闭ToString()方法。
C#的明确赋值分析已得到改进,使得以下表达式等均可工作:
if (foo?.TryParse ("123", out var number) ?? false)
Console.WriteLine (number);
(在 C# 10 之前,编译器会生成错误:“使用未分配的局部变量‘number’。”)
C# 9.0 中的新功能
C# 9.0 与Visual Studio 2019一同发布,并在您的目标为.NET 5 时使用。
顶级语句
通过顶级语句(参见“顶级语句”),您可以编写一个程序,而无需Main方法和Program类的包袱:
using System;
Console.WriteLine ("Hello, world");
顶层语句可以包含方法(作为本地方法)。您还可以通过“magic” args 变量访问命令行参数,并将值返回给调用者。顶层语句后面可以跟随类型和命名空间声明。
仅初始化的设置器
在属性声明中,仅初始化的设置器(见“仅初始化的设置器”)使用 init 关键字而不是 set 关键字:
class Foo { public int ID { get; init; } }
这表现得像一个只读属性,但也可以通过对象初始化程序进行设置:
var foo = new Foo { ID = 123 };
这使得可以创建可通过对象初始化程序而不是构造函数填充的不可变(只读)类型,并有助于避免接受大量可选参数的构造函数的反模式。仅初始化的设置器还允许在记录中使用时进行非破坏性变异。
记录
记录(见“记录”)是一种特殊类型的类,旨在与不可变数据很好地配合。其最特别的功能是通过新关键字(with)支持非破坏性变异:
Point p1 = new Point (2, 3);
Point p2 = p1 with { Y = 4 }; // p2 is a copy of p1, but with Y set to 4
Console.WriteLine (p2); // Point { X = 2, Y = 4 }
record Point
{
public Point (double x, double y) => (X, Y) = (x, y);
public double X { get; init; }
public double Y { get; init; }
}
在简单情况下,记录还可以消除定义属性和编写构造函数和析构函数的样板代码。我们可以用以下方式替换我们的 Point 记录定义,而不会丧失功能:
record Point (double X, double Y);
像元组一样,默认情况下,记录展示结构相等性。记录可以子类化其他记录,并且可以包含类可以包含的相同结构。编译器在运行时将记录实现为类。
模式匹配改进
关系模式(见“模式”)允许在模式中出现 <, >, <=, 和 >= 操作符:
string GetWeightCategory (decimal bmi) => bmi switch {
< 18.5m => "underweight",
< 25m => "normal",
< 30m => "overweight",
_ => "obese" };
使用模式组合器,您可以通过三个新关键字(and, or, 和 not)组合模式:
bool IsVowel (char c) => c is 'a' or 'e' or 'i' or 'o' or 'u';
bool IsLetter (char c) => c is >= 'a' and <= 'z'
or >= 'A' and <= 'Z';
与 && 和 || 操作符一样,and 的优先级高于 or。您可以用括号覆盖这一点。
not 组合器可以与类型模式一起使用,以测试对象是否为(非)类型:
if (obj is not string) ...
靶向类型的新表达式
在构造对象时,C# 9 允许您在编译器可以明确推断类型名称时省略类型名称:
System.Text.StringBuilder sb1 = new();
System.Text.StringBuilder sb2 = new ("Test");
当变量声明和初始化位于代码的不同部分时,这尤其有用:
class Foo
{
System.Text.StringBuilder sb;
public Foo (string initialValue) => sb = new (initialValue);
}
并且在以下场景中:
MyMethod (new ("test"));
void MyMethod (System.Text.StringBuilder sb) { ... }
更多信息,请参阅“靶向类型的新表达式”。
互操作性改进
C# 9 引入函数指针(见“函数指针”和“使用函数指针进行回调”)。它们的主要目的是允许非托管代码调用 C#中的静态方法,而无需委托实例的开销,并且可以绕过 P/Invoke 层,当参数和返回类型是可直接传送(在每一侧都表示相同)时。
C# 9 还引入了nint和nuint本机大小的整数类型(参见“本机大小的整数”),在运行时映射到System.IntPtr和System.UIntPtr。在编译时,它们表现得像支持算术运算的数值类型。
其他新特性
此外,C# 9 现在还允许你:
-
重写方法或只读属性,使其返回更派生的类型(参见“协变返回类型”)。
-
对本地函数应用属性(参见“属性”)。
-
对 lambda 表达式或本地函数应用
static关键字,以确保不会意外捕获本地或实例变量(参见“静态 lambda”)。 -
通过编写
GetEnumerator扩展方法,使任何类型与foreach语句一起工作。 -
定义模块初始化器方法,该方法在装配体首次加载时执行,通过在(静态无参数)方法上应用
[ModuleInitializer]属性。 -
将“丢弃”(下划线符号)作为 lambda 表达式参数。
-
编写扩展部分方法,这些方法是强制实现的,可用于场景,如 Roslyn 的新源生成器(参见“扩展部分方法”)。
-
将属性应用于方法、类型或模块,以防止本地变量在运行时被初始化(参见“[SkipLocalsInit]”)。
C# 8.0 的新功能
C# 8.0 首次与Visual Studio 2019一起发布,并在今天仍在使用,当你的目标是.NET Core 3 或.NET Standard 2.1 时。
索引和范围
索引和范围简化了与数组的元素或部分(或底层类型Span<T>和ReadOnlySpan<T>)的工作。
索引允许你通过使用^运算符相对于数组的末尾引用元素。¹指的是最后一个元素,²指的是倒数第二个元素,依此类推:
char[] vowels = new char[] {'a','e','i','o','u'};
char lastElement = vowels [¹]; // 'u'
char secondToLast = vowels [²]; // 'o'
范围允许你使用..运算符“切片”数组:
char[] firstTwo = vowels [..2]; // 'a', 'e'
char[] lastThree = vowels [2..]; // 'i', 'o', 'u'
char[] middleOne = vowels [2..3] // 'i'
char[] lastTwo = vowels [²..]; // 'o', 'u'
C#通过Index和Range类型实现索引和范围:
Index last = ¹;
Range firstTwoRange = 0..2;
char[] firstTwo = vowels [firstTwoRange]; // 'a', 'e'
你可以通过定义带有Index或Range参数类型的索引器来支持你自己的类:
class Sentence
{
string[] words = "The quick brown fox".Split();
public string this [Index index] => words [index];
public string[] this [Range range] => words [range];
}
欲了解更多信息,请参见“索引和范围”。
空值合并赋值
??=运算符仅在变量为 null 时才分配变量。而不是
if (s == null) s = "Hello, world";
现在你可以这样写:
s ??= "Hello, world";
使用声明
如果省略using语句后的括号和语句块,则成为using 声明。当执行超出封闭语句块时,资源将被释放:
if (File.Exists ("file.txt"))
{
using var reader = File.OpenText ("file.txt");
Console.WriteLine (reader.ReadLine());
...
}
在这种情况下,当执行超出if语句块时,reader将被释放。
只读成员
C# 8 允许你对结构体的函数应用readonly修饰符,确保如果函数试图修改任何字段,则会生成编译时错误:
struct Point
{
public int X, Y;
public readonly void ResetX() => X = 0; // Error!
}
如果 readonly 函数调用非 readonly 函数,则编译器会生成警告(并防御性地复制结构体以避免可能的突变)。
静态局部方法
将 static 修饰符添加到局部方法可以防止其访问封闭方法的局部变量和参数。这有助于减少耦合,并使局部方法能够随意声明变量,而无需担心与包含方法中的变量冲突。
默认接口成员
在 C# 8 中,您可以为接口成员添加默认实现,从而使得实现变为可选:
interface ILogger
{
void Log (string text) => Console.WriteLine (text);
}
这意味着您可以向接口添加成员而不会破坏现有实现。必须显式通过接口调用默认实现:
((ILogger)new Logger()).Log ("message");
接口还可以定义静态成员(包括字段),可以从默认实现内部的代码中访问:
interface ILogger
{
void Log (string text) => Console.WriteLine (Prefix + text);
static string Prefix = "";
}
或者从接口外部,除非在静态接口成员上通过可访问性修饰符(如 private、protected 或 internal)进行限制:
ILogger.Prefix = "File log: ";
实例字段是被禁止的。更多细节,请参见 “默认接口成员”。
Switch 表达式
自 C# 8 开始,您可以在 表达式 上下文中使用 switch:
string cardName = cardNumber switch // assuming cardNumber is an int
{
13 => "King",
12 => "Queen",
11 => "Jack",
_ => "Pip card" // equivalent to 'default'
};
更多示例,请参见 “Switch 表达式”。
元组、位置和属性模式
C# 8 支持三种新模式,主要用于增强 switch 语句/表达式的功能(请参见 “模式”)。元组模式 允许您在多个值上进行 switch:
int cardNumber = 12; string suite = "spades";
string cardName = (cardNumber, suite) switch
{
(13, "spades") => "King of spades",
(13, "clubs") => "King of clubs",
...
};
位置模式 允许为公开解构器的对象使用类似的语法,属性模式 允许您匹配对象的属性。您可以在 switch 和 is 运算符中同时使用所有模式。以下示例使用 属性模式 来测试 obj 是否为具有长度为 4 的字符串:
if (obj is string { Length:4 }) ...
可空引用类型
而 可空值类型 将 nullability 带给值类型,可空引用类型 则相反,并为引用类型带来(某种程度的)非空值性,旨在帮助避免 NullReferenceException。可空引用类型通过编译器纯粹形式的警告或错误引入了一定的安全级别,用于检测代码是否有可能生成 NullReferenceException。
可空引用类型可以在项目级别启用(通过 .csproj 项目文件中的 Nullable 元素)或在代码中启用(通过 #nullable 指令)。启用后,编译器将非空值性设置为默认值:如果要使引用类型接受 null 值,必须应用 ? 后缀以指示 可空引用类型:
#nullable enable // Enable nullable reference types from this point on
string s1 = null; // Generates a compiler warning! (s1 is non-nullable)
string? s2 = null; // OK: s2 is *nullable reference type*
如果类型未标记为可为空,未初始化的字段会生成警告,还有可能发生 NullReferenceException 的可空引用类型解引用也会如此:
void Foo (string? s) => Console.Write (s.Length); // Warning (.Length)
要消除警告,您可以使用 null-forgiving operator (!):
void Foo (string? s) => Console.Write (s!.Length);
有关全面讨论,请参见“可为空引用类型”。
异步流
之前的 C# 8 中,你可以使用yield return来编写迭代器,或者使用await来编写异步函数。但不能既编写迭代器又编写等待的异步函数。C# 8 通过引入异步流来解决这个问题:
async IAsyncEnumerable<int> RangeAsync (
int start, int count, int delay)
{
for (int i = start; i < start + count; i++)
{
await Task.Delay (delay);
yield return i;
}
}
await foreach语句消耗一个异步流:
await foreach (var number in RangeAsync (0, 10, 100))
Console.WriteLine (number);
更多信息,请参见“异步流”。
C# 7.x 新功能概述
C# 7.x 首次发布时可以在 Visual Studio 2017 中使用。C# 7.3 仍然被 Visual Studio 2019 使用,当您针对.NET Core 2、.NET Framework 4.6 到 4.8 或.NET Standard 2.0 进行定位时。
C# 7.3
C# 7.3 对现有功能进行了微小的改进,例如使得可以在元组上使用等值运算符,改进的重载解析以及能够将属性应用于自动属性的支持:
[field:NonSerialized]
public int MyProperty { get; set; }
C# 7.3 还基于 C# 7.2 的高级低分配编程特性,具备重新分配引用局部变量的能力,不需要在索引fixed字段时固定内存,以及使用stackalloc支持字段初始化:
int* pointer = stackalloc int[] {1, 2, 3};
Span<int> arr = stackalloc [] {1, 2, 3};
注意,栈分配的内存可以直接分配给一个Span<T>。我们在第二十三章中描述了 Span 及其用途。
C# 7.2
C# 7.2 增加了一个新的private protected修饰符(internal和protected的交集),以及在调用方法时跟随命名参数使用位置参数的能力,以及readonly结构。readonly结构强制所有字段为readonly,以帮助声明意图,并允许编译器更大的优化自由度:
readonly struct Point
{
public readonly int X, Y; // X and Y must be readonly
}
C# 7.2 还增加了专门的功能,以帮助进行微优化和低分配编程:请参见“in 修饰符”、“引用局部变量”、“引用返回”和“引用结构”。
C# 7.1
从 C# 7.1 开始,当使用default关键字时,如果类型可以被推断,你可以省略类型:
decimal number = default; // number is decimal
C# 7.1 还放宽了 switch 语句的规则(使得你可以在泛型类型参数上模式匹配),允许程序的Main方法是异步的,并允许推断元组元素的名称:
var now = DateTime.Now;
var tuple = (now.Hour, now.Minute, now.Second);
数字文字改进
C# 7 中的数字文字可以包括下划线,以提高可读性。这些被称为数字分隔符,在编译器中会被忽略:
int million = 1_000_000;
二进制文字可以使用0b前缀指定:
var b = 0b1010_1011_1100_1101_1110_1111;
出参变量和丢弃
C# 7 使得调用包含out参数的方法变得更简单。首先,你现在可以在不声明out 变量的情况下快速创建out 变量(请参见“out 变量和丢弃”):
bool successful = int.TryParse ("123", out int result);
Console.WriteLine (result);
当调用具有多个out参数的方法时,你可以用下划线字符丢弃不感兴趣的参数:
SomeBigMethod (out _, out _, out _, out int x, out _, out _, out _);
Console.WriteLine (x);
类型模式和模式变量
您还可以使用is运算符即时引入变量。这些称为模式变量(参见“引入模式变量”):
void Foo (object x)
{
if (x is string s)
Console.WriteLine (s.Length);
}
switch语句还支持类型模式,因此你可以根据类型以及常量进行切换(参见“类型切换”)。你可以使用when子句指定条件,并在null值上进行切换:
switch (x)
{
case int i:
Console.WriteLine ("It's an int!");
break;
case string s:
Console.WriteLine (s.Length); // We can use the s variable
break;
case bool b when b == true: // Matches only when b is true
Console.WriteLine ("True");
break;
case null:
Console.WriteLine ("Nothing");
break;
}
局部方法
局部方法是在另一个函数内声明的方法(参见“局部方法”):
void WriteCubes()
{
Console.WriteLine (Cube (3));
Console.WriteLine (Cube (4));
Console.WriteLine (Cube (5));
int Cube (int value) => value * value * value;
}
局部方法仅对包含函数可见,并且可以像 lambda 表达式一样捕获局部变量。
更多的表达式主体成员
C# 6 引入了方法、只读属性、运算符和索引器的表达式主体“胖箭头”语法。C# 7 将其扩展到构造函数、读写属性和终结器:
public class Person
{
string name;
public Person (string name) => Name = name;
public string Name
{
get => name;
set => name = value ?? "";
}
~Person () => Console.WriteLine ("finalize");
}
解构函数
C# 7 引入了解构模式(参见“解构函数”)。构造函数通常接受一组值(作为参数)并将它们分配给字段,而解构则相反,将字段分配回一组变量。我们可以为前面示例中的Person类编写如下的解构函数(除了异常处理):
public void Deconstruct (out string firstName, out string lastName)
{
int spacePos = name.IndexOf (' ');
firstName = name.Substring (0, spacePos);
lastName = name.Substring (spacePos + 1);
}
解构函数使用以下特殊语法调用:
var joe = new Person ("Joe Bloggs");
var (first, last) = joe; // Deconstruction
Console.WriteLine (first); // Joe
Console.WriteLine (last); // Bloggs
元组
可能是 C# 7 最显著的改进是对显式tuple支持(参见“元组”)。元组提供了一种简单的方式来存储一组相关的值:
var bob = ("Bob", 23);
Console.WriteLine (bob.Item1); // Bob
Console.WriteLine (bob.Item2); // 23
C#的新元组是使用System.ValueTuple<…>泛型结构的语法糖。但由于编译器的魔法,元组元素可以被命名:
var tuple = (name:"Bob", age:23);
Console.WriteLine (tuple.name); // Bob
Console.WriteLine (tuple.age); // 23
使用元组,函数可以返回多个值,而无需使用out参数或额外的类型包装:
static (int row, int column) GetFilePosition() => (3, 10);
static void Main()
{
var pos = GetFilePosition();
Console.WriteLine (pos.row); // 3
Console.WriteLine (pos.column); // 10
}
元组隐式支持解构模式,因此你可以轻松地将它们解构为单独的变量:
static void Main()
{
(int row, int column) = GetFilePosition(); // Creates 2 local variables
Console.WriteLine (row); // 3
Console.WriteLine (column); // 10
}
throw 表达式
在 C# 7 之前,throw始终是一个语句。现在它也可以出现在表达式主体的函数中作为一个表达式:
public string Foo() => throw new NotImplementedException();
throw表达式也可以出现在三元条件表达式中:
string Capitalize (string value) =>
value == null ? throw new ArgumentException ("value") :
value == "" ? "" :
char.ToUpper (value[0]) + value.Substring (1);
C# 6.0 新特性
C# 6.0 随Visual Studio 2015一同发布,具有新一代完全用 C#编写的编译器。被称为项目“Roslyn”的新编译器通过库公开了整个编译流水线,允许您对任意源代码执行代码分析。编译器本身是开源的,源代码位于https://github.com/dotnet/roslyn。
此外,C# 6.0 还引入了几个次要但重要的增强功能,主要旨在减少代码混乱。
空值条件 (“Elvis”) 运算符 (见 “空值运算符”) 避免在调用方法或访问类型成员之前显式检查 null。在下面的例子中,result 评估为 null 而不是抛出 NullReferenceException:
System.Text.StringBuilder sb = null;
string result = sb?.ToString(); // result is null
表达式体函数 (见 “方法”) 允许将由单个表达式组成的方法、属性、运算符和索引器以更简洁的方式编写,类似于 lambda 表达式的风格:
public int TimesTwo (int x) => x * 2;
public string SomeProperty => "Property value";
属性初始化器 (第三章) 允许您为自动属性分配初始值:
public DateTime TimeCreated { get; set; } = DateTime.Now;
初始化属性也可以是只读的:
public DateTime TimeCreated { get; } = DateTime.Now;
只读属性也可以在构造函数中设置,这样更容易创建不可变(只读)类型。
索引初始化器 (第四章) 允许对任何公开索引器的类型进行单步初始化:
var dict = new Dictionary<int,string>()
{
[3] = "three",
[10] = "ten"
};
字符串插值 (见 “字符串类型”) 提供了 string.Format 的简洁替代方法:
string s = $"It is {DateTime.Now.DayOfWeek} today";
异常过滤器 (见 “try 语句和异常”) 允许您对 catch 块应用条件:
string html;
try
{
html = await new HttpClient().GetStringAsync ("http://asef");
}
catch (WebException ex) when (ex.Status == WebExceptionStatus.Timeout)
{
...
}
using static (见 “命名空间”) 指令允许您导入类型的所有静态成员,以便可以不加限定地使用这些成员:
using static System.Console;
...
WriteLine ("Hello, world"); // WriteLine instead of Console.WriteLine
nameof (第三章) 运算符返回变量、类型或其他符号的名称作为字符串。这样可以避免在 Visual Studio 中重命名符号时破坏代码:
int capacity = 123;
string x = nameof (capacity); // x is "capacity"
string y = nameof (Uri.Host); // y is "Host"
最后,您现在可以在 catch 和 finally 块内部使用 await。
C# 5.0 新特性
C# 5.0 的主要新功能是通过两个新关键字 async 和 await 支持 异步函数。异步函数使 异步继续 更容易编写响应式和线程安全的富客户端应用程序。它们还使编写高并发和高效的 I/O 绑定应用程序变得轻松,而不会占用每个操作的线程资源。我们在 第十四章 中详细介绍了异步函数。
C# 4.0 新特性
C# 4.0 引入了四个重大改进:
动态绑定 (第 4 和 19 章节) 将 绑定 —— 解析类型和成员的过程 —— 从编译时延迟到运行时,并且在需要复杂反射代码的场景下非常有用。动态绑定在与动态语言和 COM 组件互操作时也非常有用。
可选参数 (第二章) 允许函数指定默认参数值,以便调用者可以省略参数,并且 命名参数 允许函数调用者通过名称而非位置标识参数。
在 C# 4.0 中放宽了类型变异规则(第 3 和 4 章),使得泛型接口和泛型委托中的类型参数可以标记为协变或逆变,从而允许更自然的类型转换。
COM 互操作性(第二十四章)在 C# 4.0 中通过三种方式进行了增强。首先,参数可以在不使用 ref 关键字的情况下按引用传递(特别在与可选参数结合时非常有用)。其次,包含 COM 互操作类型的程序集可以进行链接而不是引用。链接的互操作类型支持类型等价性,避免了对主互操作程序集的需求,从而消除了版本控制和部署问题的头痛。第三,从链接的互操作类型返回 COM 变体类型的函数被映射为 dynamic 而不是 object,消除了类型转换的需求。
C# 3.0 中的新特性
添加到 C# 3.0 的特性主要集中在语言集成查询(LINQ)功能上。LINQ 允许直接在 C# 程序中编写查询,并在静态上下文中检查其正确性,可以查询本地集合(如列表或 XML 文档)或远程数据源(如数据库)。支持 LINQ 的 C# 3.0 特性包括隐式类型本地变量、匿名类型、对象初始化程序、Lambda 表达式、扩展方法、查询表达式和表达式树。
隐式类型本地变量(var 关键字,第二章)允许您在声明语句中省略变量类型,让编译器推断类型。这不仅减少了冗余,还允许匿名类型(第四章),这是在最终 LINQ 查询输出中常用的简单类。您还可以隐式类型化数组(第二章)。
对象初始化程序(第三章)通过允许您在构造函数调用后内联设置属性来简化对象构建。对象初始化程序适用于命名类型和匿名类型。
Lambda 表达式(第四章)是编译器即时创建的迷你函数;它们在“流畅”的 LINQ 查询中尤其有用(第八章)。
扩展方法(第四章)通过向现有类型添加新方法(而不更改类型的定义)来扩展类型,使静态方法感觉像实例方法。LINQ 的查询操作符就是以扩展方法实现的。
查询表达式(第八章)提供了一种更高级的语法,用于编写 LINQ 查询,当处理多个序列或范围变量时可以大幅简化操作。
表达式树(第八章)是描述分配给特殊类型Expression<TDelegate>的 lambda 表达式的迷你代码文档对象模型(DOM)。表达式树使得 LINQ 查询可以在远程执行(例如在数据库服务器上),因为它们可以在运行时进行内省和翻译(例如转换成 SQL 语句)。
C# 3.0 还添加了自动属性和部分方法。
自动属性(第三章)减少了编写仅get/set私有后备字段的属性所需的工作量,编译器会自动完成这些工作。部分方法(第三章)允许自动生成的部分类为手动编写提供可定制的钩子,如果未使用则会自动“消失”。
C# 2.0 的新特性
C# 2 中的重要新功能包括泛型(第三章)、可空值类型(第四章)、迭代器(第四章)和匿名方法(lambda 表达式的前身)。这些功能为 C# 3 中 LINQ 的引入铺平了道路。
C# 2 还增加了对部分类、静态类以及一系列较小和杂项功能的支持,如命名空间别名限定符、友元程序集和固定大小缓冲区。
泛型的引入要求一个新的 CLR(CLR 2.0),因为泛型在运行时保持完整的类型保真度。
第二章:C#语言基础
在本章中,我们介绍了 C#语言的基础知识。
注意
本书中的几乎所有代码清单都可以在 LINQPad 中作为交互式示例使用。通过与书籍一起使用这些示例,可以加速学习,因为您可以编辑示例并立即查看结果,而不需要在 Visual Studio 中设置项目和解决方案。
要下载示例,请在 LINQPad 中点击“示例”选项卡,然后点击“下载更多示例”。LINQPad 是免费的—请访问 *www.linqpad.net*。
第一个 C#程序
以下是一个程序,它将 12 乘以 30 并将结果 360 打印到屏幕上。双斜杠表示行的余下部分是一个 注释:
int x = 12 * 30; // Statement 1
System.Console.WriteLine (x); // Statement 2
我们的程序由两个 语句 组成。C# 中的语句按顺序执行,并以分号结束。第一个语句计算 表达式 12 * 30 并将结果存储在一个名为 x 的 变量 中,其类型为 32 位整数(int)。第二个语句调用名为 WriteLine 的 方法,在名为 Console 的 类 上调用,该类在名为 System 的 命名空间 中定义。这将把变量 x 打印到屏幕上的文本窗口中。
一个方法执行一个功能;一个类将功能成员和数据成员组合起来形成一个面向对象的构建块。Console类组合了处理命令行输入/输出(I/O)功能的成员,比如WriteLine方法。类是一种 类型,我们在 “类型基础” 中进行讨论。
在最外层级别,类型被组织成 命名空间。许多常用的类型—包括 Console 类—位于 System 命名空间中。.NET 库被组织成嵌套的命名空间。例如,System.Text 命名空间包含用于处理文本的类型,而 System.IO 包含用于输入/输出的类型。
在每次使用时用 System 命名空间限定 Console 类会增加混乱。using 指令允许您通过 导入 命名空间来避免此混乱:
using System; // Import the System namespace
int x = 12 * 30;
Console.WriteLine (x); // No need to specify System.
代码重用的一种基本形式是编写调用低级函数的高级函数。我们可以使用可重用的 方法 FeetToInches 重构我们的程序,该方法将整数乘以 12,如下所示:
using System;
Console.WriteLine (FeetToInches (30)); // 360
Console.WriteLine (FeetToInches (100)); // 1200
int FeetToInches (int feet)
{
int inches = feet * 12;
return inches;
}
我们的方法包含一系列语句,这些语句被一对大括号包围。这被称为 语句块。
方法可以通过指定 参数 从调用者那里接收 输入 数据,并通过指定 返回类型 将 输出 数据返回给调用者。我们的 FeetToInches 方法有一个用于输入英尺的参数,以及一个用于输出英寸的返回类型:
int FeetToInches (int feet)
...
字面量 30 和 100 是传递给FeetToInches方法的 参数。
如果一个方法不接收输入,使用空括号。如果它不返回任何内容,使用void关键字:
using System;
SayHello();
void SayHello()
{
Console.WriteLine ("Hello, world");
}
方法是 C# 中几种函数的一种。我们在示例程序中使用的另一种函数是执行乘法的 * 运算符。还有 构造函数、属性、事件、索引器 和 终结器。
编译
C# 编译器将源代码(具有 .cs 扩展名的一组文件)编译成一个 程序集。程序集是 .NET 中的打包和部署单元。程序集可以是 应用程序 或 库。普通的控制台或 Windows 应用程序有一个 入口点,而库则没有。库的目的是被应用程序或其他库调用(引用)。.NET 本身是一组库(以及运行时环境)。
前面部分的每个程序直接以一系列语句(称为 顶级语句)开始。顶级语句的存在隐含地创建了一个控制台或 Windows 应用程序的入口点。(没有顶级语句时,Main 方法 表示应用程序的入口点,请参阅 “自定义类型”。)
注意
不同于 .NET Framework,.NET 8 程序集从不使用 .exe 扩展名。在构建 .NET 8 应用程序后看到的 .exe 是一个特定于平台的本机加载器,负责启动您的应用程序的 .dll 程序集。
.NET 8 还允许您创建自包含部署,其中包括加载器、您的程序集以及所需的 .NET 运行时部分,全部打包在单个 .exe 文件中。.NET 8 还支持提前(AOT)编译,可使可执行文件包含预编译的本机代码,从而加快启动速度并减少内存消耗。
dotnet 工具(在 Windows 上为 dotnet.exe)可帮助您从命令行管理 .NET 源代码和二进制文件。您可以使用它来构建和运行程序,作为使用集成开发环境(如 Visual Studio 或 Visual Studio Code)的替代方案。
您可以通过安装 .NET 8 SDK 或安装 Visual Studio 来获取 dotnet 工具。它在 Windows 上的默认位置为 %ProgramFiles%\dotnet,在 Ubuntu Linux 上为 /usr/bin/dotnet。
要编译一个应用程序,dotnet 工具需要一个 项目文件 以及一个或多个 C# 文件。以下命令创建一个新的控制台项目(创建其基本结构):
dotnet new Console -n MyFirstProgram
这将创建一个名为 MyFirstProgram 的子文件夹,其中包含一个名为 MyFirstProgram.csproj 的项目文件和一个名为 Program.cs 的 C# 文件,该文件打印出“Hello world”。
要从 MyFirstProgram 文件夹构建和运行程序,请运行以下命令:
dotnet run MyFirstProgram
或者,如果您只想构建而不运行:
dotnet build MyFirstProgram.csproj
输出的程序集将被写入到 bin\debug 子目录下。
我们在 第十七章 中详细解释了程序集。
语法
C# 语法受到 C 和 C++ 语法的启发。在本节中,我们使用以下程序描述了 C# 的语法元素:
using System;
int x = 12 * 30;
Console.WriteLine (x);
标识符和关键字
标识符是程序员为类、方法、变量等选择的名称。以下是我们示例程序中的标识符,按照它们出现的顺序列出:
System x Console WriteLine
标识符必须是一个完整的词,基本上由以字母或下划线开头的 Unicode 字符组成。C#标识符区分大小写。按照惯例,参数、局部变量和私有字段应该使用驼峰命名法(例如,myVariable),而其他所有标识符应该使用帕斯卡命名法(例如,MyMethod)。
关键字是编译器特殊意义的名称。在我们的示例程序中有两个关键字:using和int。
大多数关键字是保留字,这意味着你不能将它们用作标识符。以下是 C#保留关键字的完整列表:
| abstract as
base
bool
break
byte
case
catch
char
checked
class
const
continue
decimal
default
delegate | do double
else
enum
event
explicit
extern
false
finally
fixed
float
for
foreach
goto
if
implicit | in int
interface
internal
is
lock
long
namespace
new
null
object
operator
out
override
params
private | protected public
readonly
record
ref
return
sbyte
sealed
short
sizeof
stackalloc
static
string
struct
switch
this | throw true
try
typeof
uint
ulong
unchecked
unsafe
ushort
using
virtual
void
volatile
while |
如果你确实想要使用与保留关键字冲突的标识符,可以通过使用@前缀来完成。例如:
int using = 123; // Illegal
int @using = 123; // Legal
@符号本身不属于标识符的一部分。因此,@myVariable与myVariable是相同的。
上下文关键字
一些关键字是上下文相关的,这意味着你也可以将它们作为标识符使用,无需使用@符号:
| add alias
and
ascending
async
await
by
descending | dynamic equals
file
from
get
global
group
init | into join
let
managed
nameof
nint
not
notnull | nuint on
or
orderby
partial
remove
required
select | set unmanaged
value
var
with
when
where
yield |
在使用上下文关键字的上下文中,不会产生歧义。
字面量、标点符号和运算符
字面量是程序中词法嵌入的原始数据片段。在我们的示例程序中使用的字面量包括12和30。
标点符号有助于标明程序的结构。例如,分号用于结束语句。语句可以跨多行:
Console.WriteLine
(1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + 9 + 10);
运算符可以转换和组合表达式。C#中大多数运算符用符号表示,比如乘法运算符*。我们在示例程序中使用的运算符如下:
= * . ()
句点表示某物的成员(或数字文字中的小数点)。在声明或调用方法时使用括号;当方法不接受参数时使用空括号。(括号还有其他目的,稍后在本章中你会看到。)等号执行赋值。(双等号 == 执行相等比较,稍后会看到。)
注释
C# 提供两种不同风格的源代码文档:单行注释和多行注释。单行注释以双斜杠开头,并持续到行尾;例如:
int x = 3; // Comment about assigning 3 to x
多行注释以 /* 开始,以 */ 结束;例如:
int x = 3; /* This is a comment that
spans two lines */
注释可以嵌入 XML 文档标签,我们在 “XML 文档” 中详细解释。
类型基础
类型定义了一个值的蓝图。在这个例子中,我们使用了两个类型为 int、值为 12 和 30 的字面量。我们还声明了一个名为 x 的类型为 int 的变量:
int x = 12 * 30;
Console.WriteLine (x);
注意
因为本书中大部分代码清单需要 System 命名空间中的类型,所以我们将从现在开始省略“using System”,除非我们在说明与命名空间相关的概念。
变量表示随时间可以包含不同值的存储位置。相比之下,常量始终表示相同的值(稍后详细说明):
const int y = 360;
在 C# 中,所有的值都是类型的实例。一个值的含义以及变量可能具有的可能值集由其类型确定。
预定义类型示例
预定义类型是编译器特别支持的类型。int 类型是表示适合于 32 位内存的整数集的预定义类型,范围从 −2³¹ 到 2³¹−1,并且是此范围内数字文字的默认类型。您可以对 int 类型的实例执行算术操作,如下所示:
int x = 12 * 30;
另一个预定义的 C# 类型是 string。string 类型表示字符序列,例如 “.NET” 或 http://oreilly.com。您可以通过调用它们上面的函数来处理字符串,如下所示:
string message = "Hello world";
string upperMessage = message.ToUpper();
Console.WriteLine (upperMessage); // HELLO WORLD
int x = 2022;
message = message + x.ToString();
Console.WriteLine (message); // Hello world2022
在这个例子中,我们调用 x.ToString() 来获取整数 x 的字符串表示。你几乎可以在任何类型的变量上调用 ToString()。
预定义的 bool 类型只有两个可能的值:true 和 false。bool 类型通常与 if 语句一起用于有条件地分支执行流程:
bool simpleVar = false;
if (simpleVar)
Console.WriteLine ("This will not print");
int x = 5000;
bool lessThanAMile = x < 5280;
if (lessThanAMile)
Console.WriteLine ("This will print");
注意
在 C# 中,预定义类型(也称为内置类型)由 C# 关键字识别。在 .NET 中,System 命名空间包含许多重要的类型,这些类型不是由 C# 预定义的(例如 DateTime)。
自定义类型
就像我们可以编写自己的方法一样,我们也可以编写自己的类型。在下一个示例中,我们定义了一个名为 UnitConverter 的自定义类型——一个作为单位转换蓝图的类:
UnitConverter feetToInchesConverter = new UnitConverter (12);
UnitConverter milesToFeetConverter = new UnitConverter (5280);
Console.WriteLine (feetToInchesConverter.Convert(30)); // 360
Console.WriteLine (feetToInchesConverter.Convert(100)); // 1200
Console.WriteLine (feetToInchesConverter.Convert(
milesToFeetConverter.Convert(1))); // 63360
public class UnitConverter
{
int ratio; // Field
public UnitConverter (int unitRatio) // Constructor
{
ratio = unitRatio;
}
public int Convert (int unit) // Method
{
return unit * ratio;
}
}
注意
在这个例子中,我们的类定义出现在与顶层语句相同的文件中。这是合法的——只要顶层语句出现在前面——在编写小型测试程序时也是可以接受的。对于较大的程序,标准做法是将类定义放在单独的文件中,如UnitConverter.cs。
类的成员
类包含数据成员和函数成员。UnitConverter的数据成员是称为比率的字段。UnitConverter的函数成员包括Convert方法和UnitConverter的构造函数。
预定义类型和自定义类型的对称性
C#的一个美妙之处在于预定义类型和自定义类型几乎没有差别。预定义的int类型用作整数的蓝图。它保存数据——32 位——并提供使用该数据的函数成员,如ToString。同样,我们的自定义UnitConverter类型作为单位转换的蓝图。它保存数据——比率——并提供使用该数据的函数成员。
构造函数和实例化
数据是通过实例化类型来创建的。预定义类型可以通过字面量(如12或"Hello world")简单实例化。new运算符创建自定义类型的实例。我们使用以下语句创建和声明了UnitConverter类型的一个实例:
UnitConverter feetToInchesConverter = new UnitConverter (12);
new运算符实例化对象后,会调用对象的构造函数进行初始化。构造函数定义类似于方法,但方法名称和返回类型缩减为封闭类型的名称:
public UnitConverter (int unitRatio) { ratio = unitRatio; }
实例成员与静态成员的区别
实例类型的数据成员和函数成员称为实例成员。UnitConverter的Convert方法和int的ToString方法就是实例成员的例子。默认情况下,成员都是实例成员。
不操作类型实例的数据成员和函数成员可以标记为static。要从其类型外部引用静态成员,需指定其类型名称而不是实例。例如,Console类的WriteLine方法。因为这是静态的,我们调用Console.WriteLine()而不是new Console().WriteLine()。
(Console类实际上声明为静态类,这意味着所有它的成员都是静态的,你永远无法创建Console的实例。)
在以下代码中,Name实例字段属于特定Panda的实例,而Population则属于所有Panda实例的集合。我们创建了两个Panda的实例,打印它们的名称,然后打印总人口数量:
Panda p1 = new Panda ("Pan Dee");
Panda p2 = new Panda ("Pan Dah");
Console.WriteLine (p1.Name); // Pan Dee
Console.WriteLine (p2.Name); // Pan Dah
Console.WriteLine (Panda.Population); // 2
public class Panda
{
public string Name; // Instance field
public static int Population; // Static field
public Panda (string n) // Constructor
{
Name = n; // Assign the instance field
Population = Population + 1; // Increment the static Population field
}
}
尝试评估p1.Population或Panda.Name将生成编译时错误。
公共关键字
public关键字将成员暴露给其他类。在本例中,如果Panda中的Name字段未标记为public,则将是私有的,无法从类外部访问。将成员标记为public是类型通信的方式:“这是我希望其他类型看到的东西——其他都是我自己的私有实现细节。”从面向对象的角度来看,我们说公共成员封装了类的私有成员。
定义命名空间
特别是在较大的程序中,将类型组织到命名空间中是有意义的。以下是如何在名为Animals的命名空间内定义Panda类:
using System;
using Animals;
Panda p = new Panda ("Pan Dee");
Console.WriteLine (p.Name);
namespace Animals
{
public class Panda
{
...
}
}
在这个例子中,我们还导入了Animals命名空间,以便我们的顶级语句可以无需限定地访问其类型。如果没有这个导入,我们需要这样做:
Animals.Panda p = new Animals.Panda ("Pan Dee");
我们在本章末尾详细介绍命名空间(请参阅“命名空间”)。
定义主方法
到目前为止,我们所有的示例都使用了顶级语句(这是 C# 9 引入的一个功能)。
没有顶级语句,简单的控制台或 Windows 应用程序如下所示:
using System;
class Program
{
static void Main() // Program entry point
{
int x = 12 * 30;
Console.WriteLine (x);
}
}
在没有顶级语句的情况下,C#会寻找名为Main的静态方法,这成为入口点。Main方法可以定义在任何类内部(只能存在一个Main方法)。
Main方法可以选择性地返回整数(而不是void),以向执行环境返回一个值(其中非零值通常表示错误)。Main方法还可以选择性地接受字符串数组作为参数(该参数将填充为传递给可执行文件的任何参数)。例如:
static int Main (string[] args) {...}
注意
数组(如string[])表示特定类型的固定数量的元素。数组通过在元素类型后面放置方括号来指定。我们在“数组”中描述它们。
(Main方法还可以声明为async并返回Task或Task<int>,以支持异步编程,我们在第十四章中介绍。)
类型和转换
C#可以在兼容类型的实例之间进行转换。转换总是从现有值创建新值。转换可以是隐式或显式:隐式转换会自动发生,显式转换需要强制转换。在以下示例中,我们隐式将int转换为具有两倍int位容量的long类型,并显式将int转换为具有int位容量一半的short类型:
int x = 12345; // int is a 32-bit integer
long y = x; // Implicit conversion to 64-bit integer
short z = (short)x; // Explicit conversion to 16-bit integer
当满足以下两个条件时,允许隐式转换:
-
编译器可以保证它们将始终成功。
-
转换过程中不会丢失信息。¹
相反,当满足以下条件之一时,需要显式转换:
-
编译器无法保证它们将始终成功。
-
转换过程中可能会丢失信息。
(如果编译器可以确定转换将始终失败,则两种类型的转换都被禁止。涉及泛型的转换在某些条件下也可能失败 —— 见“类型参数和转换”。)
注意
我们刚刚看到的数值转换是语言内置的。C#还支持引用转换和装箱转换(参见第 3 章),以及自定义转换(参见“运算符重载”)。编译器不会对自定义转换强制执行上述规则,因此设计不良的类型可能会有不同的行为。
值类型与引用类型对比
所有 C#类型都属于以下类别:
-
值类型
-
引用类型
-
泛型类型参数
-
指针类型
注意
在本节中,我们描述了值类型和引用类型。我们在“泛型”中介绍了泛型类型参数,以及在“不安全代码和指针”中介绍了指针类型。
值类型 包括大多数内置类型(具体来说是所有数值类型、char类型和bool类型),以及自定义的struct和enum类型。
引用类型 包括所有类、数组、委托和接口类型。(这包括预定义的string类型。)
值类型和引用类型的根本区别在于它们在内存中的处理方式。
值类型
值类型变量或常量的内容只是一个值。例如,内置的值类型int的内容是 32 位的数据。
您可以使用struct关键字定义自定义值类型(见图 2-1):
public struct Point { public int X; public int Y; }
或更简洁地说:
public struct Point { public int X, Y; }
图 2-1. 内存中的值类型实例
值类型实例的赋值总是会复制实例;例如:
Point p1 = new Point();
p1.X = 7;
Point p2 = p1; // Assignment causes copy
Console.WriteLine (p1.X); // 7
Console.WriteLine (p2.X); // 7
p1.X = 9; // Change p1.X
Console.WriteLine (p1.X); // 9
Console.WriteLine (p2.X); // 7
图 2-2 显示了p1和p2有独立的存储。
图 2-2. 分配复制值类型实例
引用类型
引用类型比值类型更复杂,包含两个部分:对象和指向该对象的引用。引用类型变量或常量的内容是指向包含值的对象的引用。这里是我们之前示例中的Point类型,它被重写为一个类而不是struct(如图 2-3 所示):
public class Point { public int X, Y; }
图 2-3. 内存中的引用类型实例
分配引用类型变量会复制引用而非对象实例。这允许多个变量引用同一个对象,这在值类型中通常是不可能的。如果我们重复之前的例子,但现在Point是一个类,对p1的操作会影响到p2:
Point p1 = new Point();
p1.X = 7;
Point p2 = p1; // Copies p1 reference
Console.WriteLine (p1.X); // 7
Console.WriteLine (p2.X); // 7
p1.X = 9; // Change p1.X
Console.WriteLine (p1.X); // 9
Console.WriteLine (p2.X); // 9
图 2-4 显示 p1 和 p2 是指向同一对象的两个引用。
图 2-4. 分配复制一个引用
空值
可以将引用分配为字面量 null,表示引用指向没有对象:
Point p = null;
Console.WriteLine (p == null); // True
// The following line generates a runtime error
// (a NullReferenceException is thrown):
Console.WriteLine (p.X);
class Point {...}
注意
在 “可空引用类型” 中,我们描述了 C#的一个特性,有助于减少意外的 NullReferenceException 错误。
相比之下,值类型通常不能有空值:
Point p = null; // Compile-time error
int x = null; // Compile-time error
struct Point {...}
注意
C# 还有一种称为可空值类型的构造,用于表示值类型的空值。有关更多信息,请参见 “可空值类型”。
存储开销
值类型实例占用精确存储其字段所需的内存。在这个例子中,Point 占用 8 字节的内存:
struct Point
{
int x; // 4 bytes
int y; // 4 bytes
}
注意
从技术上讲,CLR 将类型内的字段定位到地址,该地址是字段大小的倍数(最多为 8 字节)。因此,以下实际上会消耗 16 字节的内存(第一个字段后面的 7 字节“浪费”):
struct A { byte b; long l; }
您可以通过应用 StructLayout 属性来覆盖此行为(参见 “将结构映射到非托管内存”)。
引用类型需要单独分配内存来存储引用和对象。对象消耗的字节数与其字段一样多,再加上额外的管理开销。精确的开销是.NET 运行时实现的私有信息,但至少为 8 字节,用于存储对象类型的键以及临时信息,例如其在多线程中的锁定状态和指示其是否已由垃圾收集器固定的标志。每个对象的引用需要额外的 4 或 8 字节,具体取决于.NET 运行时是否运行在 32 位或 64 位平台上。
预定义类型分类
C# 中的预定义类型如下:
值类型
-
数值
-
符号整数 (
sbyte,short,int,long) -
无符号整数 (
byte,ushort,uint,ulong) -
实数 (
float,double,decimal)
-
-
逻辑 (
bool) -
字符 (
char)
引用类型
-
字符串 (
string) -
对象 (
object)
C# 中的预定义类型别名 .NET 类型位于 System 命名空间中。这两个语句之间只有语法上的差异:
int i = 5;
System.Int32 i = 5;
CLR 中,预定义的值类型不包括 decimal,它们被称为 CLR 中的基元类型。之所以称为基元类型,是因为它们直接通过编译后的指令支持,在底层处理器上通常会直接翻译为支持;例如:
// Underlying hexadecimal representation
int i = 7; // 0x7
bool b = true; // 0x1
char c = 'A'; // 0x41
float f = 0.5f; // uses IEEE floating-point encoding
System.IntPtr 和 System.UIntPtr 类型也是基元类型(参见 第二十四章)。
数值类型
C# 中有如下所示的预定义数值类型表 表 2-1。
表 2-1. C#中的预定义数值类型
| C# 类型 | 系统类型 | 后缀 | 大小 | 范围 |
|---|---|---|---|---|
| 整数—有符号 | ||||
sbyte | SByte | 8 位 | –2⁷ 到 2⁷–1 | |
short | Int16 | 16 位 | –2¹⁵ 到 2¹⁵–1 | |
int | Int32 | 32 位 | –2³¹ 到 2³¹–1 | |
long | Int64 | L | 64 位 | –2⁶³ 到 2⁶³–1 |
nint | IntPtr | 32/64 位 | ||
| 整数—无符号 | ||||
byte | Byte | 8 位 | 0 到 2⁸–1 | |
ushort | UInt16 | 16 位 | 0 到 2¹⁶–1 | |
uint | UInt32 | U | 32 位 | 0 到 2³²–1 |
ulong | UInt64 | UL | 64 位 | 0 到 2⁶⁴–1 |
nuint | UIntPtr | 32/64 位 | ||
| 实数 | ||||
float | Single | F | 32 位 | ± (~10^(–45) 到 10³⁸) |
double | Double | D | 64 位 | ± (~10^(–324) 到 10³⁰⁸) |
decimal | Decimal | M | 128 位 | ± (~10^(–28) 到 10²⁸) |
在整数类型中,int 和 long 是头等公民,并且被 C# 和运行时青睐。其他整数类型通常用于互操作性或空间效率至关重要时。nint 和 nuint 本地大小的整数类型在处理指针时非常有用,因此我们将在后面的章节中描述它们(参见 “本地大小整数”)。
在实数类型中,float 和 double 被称为 浮点类型²,通常用于科学和图形计算。decimal 类型通常用于财务计算,其中需要基于十进制的精确算术和高精度。
注意
.NET 还用几种特殊的数值类型补充了此列表,包括用于有符号和无符号 128 位整数的 Int128 和 UInt128,用于任意大整数的 BigInteger,以及用于 16 位浮点数的 Half。Half 主要用于与图形处理器的互操作,并且在大多数 CPU 中没有本地支持,因此在一般用途中,float 和 double 是更好的选择。
数字文字
整数类型文字 可以使用十进制或十六进制表示法;十六进制用 0x 前缀表示。例如:
int x = 127;
long y = 0x7F;
你可以在数字文字中的任何位置插入下划线,以提高其可读性:
int million = 1_000_000;
您可以使用 0b 前缀指定二进制数字:
var b = 0b1010_1011_1100_1101_1110_1111;
实数文字 可以使用十进制和/或指数表示法:
double d = 1.5;
double million = 1E06;
数字文字类型推断
默认情况下,编译器会 推断 数字文字为 double 或整数类型之一:
-
如果文字包含小数点或指数符号 (
E),则为double。 -
否则,文字的类型是此列表中可以容纳文字值的第一个类型:
int、uint、long和ulong。
例如:
Console.WriteLine ( 1.0.GetType()); // Double *(double)*
Console.WriteLine ( 1E06.GetType()); // Double *(double)*
Console.WriteLine ( 1.GetType()); // Int32 *(int)*
Console.WriteLine ( 0xF0000000.GetType()); // UInt32 *(uint)*
Console.WriteLine (0x100000000.GetType()); // Int64 *(long)*
数字后缀
数字后缀 明确定义了文字的类型。后缀可以是小写或大写,如下所示:
| 类别 | C# 类型 | 示例 |
|---|---|---|
F | float | float f = 1.0F; |
D | double | double d = 1D; |
M | decimal | decimal d = 1.0M; |
U | uint | uint i = 1U; |
L | long | long i = 1L; |
UL | ulong | ulong i = 1UL; |
后缀 U 和 L 很少需要,因为 uint,long 和 ulong 类型几乎总是可以从 int 推断 或隐式转换而来:
long i = 5; // Implicit lossless conversion from int literal to long
D 后缀在技术上是多余的,因为所有带小数点的字面量都被推断为 double。而且你总是可以向数字字面量添加一个小数点:
double x = 4.0;
F 和 M 后缀是最有用的,当指定 float 或 decimal 字面量时应始终添加。如果没有 F 后缀,下面的行将无法编译,因为 4.5 将被推断为 double 类型,而 double 类型没有到 float 类型的隐式转换:
float f = 4.5F;
十进制字面量也适用同样的原则:
decimal d = -1.23M; // Will not compile without the M suffix.
我们将在下一节详细描述数值转换的语义。
数字转换
整数类型之间的转换
当目标类型能够表示源类型的每一个可能值时,整数类型转换是隐式的。否则,需要进行显式转换;例如:
int x = 12345; // int is a 32-bit integer
long y = x; // Implicit conversion to 64-bit integral type
short z = (short)x; // Explicit conversion to 16-bit integral type
浮点类型之间的转换
给定 double 可以表示 float 的每一个可能值,float 可以隐式转换为 double。反向转换必须是显式的。
浮点类型和整数类型之间的转换
所有整数类型可以隐式转换为所有浮点类型:
int i = 1;
float f = i;
反向转换必须是显式的:
int i2 = (int)f;
注意
当你从浮点数转换为整数类型时,任何小数部分都会被截断;不进行四舍五入。静态类System.Convert提供了在各种数值类型之间进行转换时进行四舍五入的方法(见第六章)。
将大整数类型隐式转换为浮点类型会保留幅度,但有时可能会丢失精度。这是因为浮点类型始终具有比整数类型更大的幅度,但可能具有较少的精度。通过使用一个较大的数字来重新编写我们的示例来演示这一点:
int i1 = 100000001;
float f = i1; // Magnitude preserved, precision lost
int i2 = (int)f; // 100000000
十进制转换
所有整数类型可以隐式转换为十进制类型,前提是十进制可以表示所有可能的 C# 整数类型值。对于十进制类型的所有其他数值转换必须是显式的,因为它们可能导致值超出范围或精度丢失的可能性。
算术运算符
算术运算符(+,-,*,/,%)适用于所有数值类型,但不适用于 8 位和 16 位整数类型:
+ Addition
- Subtraction
* Multiplication
/ Division
% Remainder after division
自增和自减运算符
自增和自减运算符(++,--,分别)通过 1 增加和减少数值类型。运算符可以跟随或在变量之前,具体取决于您希望其值在增加/减少之前还是之后;例如:
int x = 0, y = 0;
Console.WriteLine (x++); // Outputs 0; x is now 1
Console.WriteLine (++y); // Outputs 1; y is now 1
整数类型的专用操作
整数类型包括int、uint、long、ulong、short、ushort、byte和sbyte。
除法
对整数类型的除法操作总是消除余数(向零舍入)。除以值为零的变量会生成运行时错误(DivideByZeroException):
int a = 2 / 3; // 0
int b = 0;
int c = 5 / b; // throws DivideByZeroException
除以文字或常量 0 会生成编译时错误。
溢出
在运行时,整数类型的算术操作可能会溢出。默认情况下,这种情况发生时是静默的——不会抛出异常,并且结果表现为“环绕”行为,就像在更大的整数类型上进行计算并丢弃额外的有效位一样。例如,将最小可能的int值递减结果为最大可能的int值:
int a = int.MinValue;
a--;
Console.WriteLine (a == int.MaxValue); // True
溢出检查运算符
checked运算符指示运行时在整数类型表达式或语句超出算术限制时生成OverflowException,而不是静默溢出。checked运算符影响具有++、−−、+、−(二元和一元)、*、/和整数类型之间的显式转换运算符的表达式。溢出检查会带来小的性能成本。
注意
checked运算符对double和float类型无效(它们溢出到特殊的“无穷”值,稍后您将看到),并且对decimal类型无效(它总是检查的)。
您可以在表达式或语句块周围使用checked:
int a = 1000000;
int b = 1000000;
int c = checked (a * b); // Checks just the expression.
checked // Checks all expressions
{ // in statement block.
...
c = a * b;
...
}
您可以通过选择项目级别的“checked”选项(在 Visual Studio 中,转到高级构建设置)使算术溢出检查成为程序中所有表达式的默认值。然后,如果需要仅为特定表达式或语句禁用溢出检查,可以使用unchecked运算符。例如,以下代码不会抛出异常——即使选择了项目的“checked”选项:
int x = int.MaxValue;
int y = unchecked (x + 1);
unchecked { int z = x + 1; }
常量表达式的溢出检查
无论“checked”项目设置如何,在编译时评估的表达式总是进行溢出检查——除非您应用unchecked运算符:
int x = int.MaxValue + 1; // Compile-time error
int y = unchecked (int.MaxValue + 1); // No errors
位操作符
C#支持以下位操作符:
| 操作符 | 含义 | 示例表达式 | 结果 |
|---|---|---|---|
~ | 补码 | ~0xfU | 0xfffffff0U |
& | 与 | 0xf0 & 0x33 | 0x30 |
| | 或 | 0xf0 | 0x33 | 0xf3 |
^ | 异或 | 0xff00 ^ 0x0ff0 | 0xf0f0 |
<< | 左移 | 0x20 << 2 | 0x80 |
>> | 右移 | 0x20 >> 1 | 0x10 |
>>> | 无符号右移 | int.MinValue >>> 1 | 0x40000000 |
右移操作符>>在对有符号整数进行操作时复制高阶位,而无符号右移操作符(>>>)则不会。
注意
附加的位操作通过名为BitOperations的类在System.Numerics命名空间中公开(参见“位操作”)。
8 位和 16 位整数类型
8 位和 16 位整数类型是 byte、sbyte、short 和 ushort。这些类型缺少自己的算术运算符,因此 C# 根据需要隐式将它们转换为较大的类型。当尝试将结果分配回小整数类型时,这可能会导致编译时错误:
short x = 1, y = 1;
short z = x + y; // Compile-time error
在这种情况下,x 和 y 被隐式转换为 int 以执行加法。这意味着结果也是一个 int,不能隐式地转回 short(因为可能导致数据丢失)。为了使其编译通过,必须添加显式转换:
short z = (short) (x + y); // OK
特殊浮点和双精度值
不像整数类型,浮点类型具有某些操作对待特殊的值。这些特殊值包括 NaN(不是一个数字)、+∞、−∞ 和 −0. 类 float 和 double 有 NaN、+∞ 和 −∞ 的常量,以及其他值(MaxValue、MinValue 和 Epsilon);例如:
Console.WriteLine (double.NegativeInfinity); // -Infinity
表示 double 和 float 特殊值的常量如下:
| 特殊值 | Double 常量 | Float 常量 |
|---|---|---|
| NaN | double.NaN | float.NaN |
| +∞ | double.PositiveInfinity | float.PositiveInfinity |
| −∞ | double.NegativeInfinity | float.NegativeInfinity |
| −0 | −0.0 | −0.0f |
将非零数除以零会得到无限值:
Console.WriteLine ( 1.0 / 0.0); // Infinity
Console.WriteLine (−1.0 / 0.0); // -Infinity
Console.WriteLine ( 1.0 / −0.0); // -Infinity
Console.WriteLine (−1.0 / −0.0); // Infinity
将零除以零或从无穷大中减去无穷大会得到 NaN:
Console.WriteLine ( 0.0 / 0.0); // NaN
Console.WriteLine ((1.0 / 0.0) − (1.0 / 0.0)); // NaN
当使用 == 时,NaN 值永远不等于另一个值,即使是另一个 NaN 值:
Console.WriteLine (0.0 / 0.0 == double.NaN); // False
要测试一个值是否为 NaN,必须使用 float.IsNaN 或 double.IsNaN 方法:
Console.WriteLine (double.IsNaN (0.0 / 0.0)); // True
当使用 object.Equals 时,两个 NaN 值是相等的:
Console.WriteLine (object.Equals (0.0 / 0.0, double.NaN)); // True
注:
NaNs 有时用于表示特殊值。在 Windows Presentation Foundation (WPF) 中,double.NaN 表示值为“自动”的测量。表示这种值的另一种方式是使用可空类型(第四章);另一种方式是使用包装数值类型并添加附加字段的自定义结构体(第三章)。
float 和 double 遵循 IEEE 754 格式类型的规范,几乎所有处理器都原生支持。您可以在 http://www.ieee.org 上找到有关这些类型行为的详细信息。
double 与 decimal
double 对于科学计算(如计算空间坐标)很有用。decimal 对于金融计算和制造而非实际测量结果的值很有用。以下是两者差异的摘要。
| 类别 | double | decimal |
|---|---|---|
| 内部表示 | Base 2 | Base 10 |
| 十进制精度 | 15–16 有效数字 | 28–29 有效数字 |
| 范围 | ±(~10^(−324) 到 ~10³⁰⁸) | ±(~10^(−28) 到 ~10²⁸) |
| 特殊值 | +0、−0、+∞、−∞ 和 NaN | 无 |
| 速度 | 本地处理器原生 | 非本地处理器(大约比 double 慢 10 倍) |
实数舍入误差
float和double在内部以 2 进制表示数字。因此,只有能够用 2 进制表示的数字才能被精确表示。实际上,这意味着大多数带有小数部分的字面量(以 10 进制表示)将不能被精确表示;例如:
float x = 0.1f; // Not quite 0.1
Console.WriteLine (x + x + x + x + x + x + x + x + x + x); // 1.0000001
这就是为什么float和double在财务计算中表现不佳。相比之下,decimal以 10 进制工作,因此可以精确表示以 10 进制表示的数字(以及它的因子,即 2 进制和 5 进制)。由于实数字面量是以 10 进制表示的,decimal可以精确表示诸如 0.1 这样的数字。然而,无论是double还是decimal都无法精确表示其 10 进制表示为循环的分数:
decimal m = 1M / 6M; // 0.1666666666666666666666666667M
double d = 1.0 / 6.0; // 0.16666666666666666
这导致了累积的舍入误差:
decimal notQuiteWholeM = m+m+m+m+m+m; // 1.0000000000000000000000000002M
double notQuiteWholeD = d+d+d+d+d+d; // 0.99999999999999989
打破等式和比较运算的操作:
Console.WriteLine (notQuiteWholeM == 1M); // False
Console.WriteLine (notQuiteWholeD < 1.0); // True
布尔类型和运算符
C#的bool类型(别名System.Boolean类型)是一个逻辑值,可以赋值为字面量true或false。
尽管布尔值只需要存储一个比特,但运行时会使用一个字节的内存,因为这是运行时和处理器能有效处理的最小块。为了避免在数组情况下的空间效率低下,.NET 在System.Collections命名空间中提供了一个BitArray类,设计用于每个布尔值只使用一个比特。
布尔类型转换
不能从bool类型进行数值类型或反之的强制转换。
等式和比较运算符
==和!=测试任何类型的相等和不等,但始终返回一个bool值。³ 值类型通常具有非常简单的相等概念:
int x = 1;
int y = 2;
int z = 1;
Console.WriteLine (x == y); // False
Console.WriteLine (x == z); // True
对于引用类型,默认情况下,等式是基于引用而不是底层对象的值(在第六章中详细介绍):
Dude d1 = new Dude ("John");
Dude d2 = new Dude ("John");
Console.WriteLine (d1 == d2); // False
Dude d3 = d1;
Console.WriteLine (d1 == d3); // True
public class Dude
{
public string Name;
public Dude (string n) { Name = n; }
}
等式和比较运算符==、!=、<、>、>=和<=适用于所有数值类型,但在使用实数时应谨慎(如我们在“实数舍入误差”中看到的)。比较运算符也适用于enum类型成员,通过比较它们的底层整数类型值进行比较。我们在“枚举”中描述了这一点。
我们在“运算符重载”,“等式比较”和“顺序比较”中更详细地解释了等式和比较运算符。
条件运算符
&&和||运算符测试与和或条件。它们经常与!运算符一起使用,表示非。在以下示例中,如果天气雨天或晴天(用来遮挡雨水或阳光),UseUmbrella方法将返回true,只要不是多风的情况(风中使用伞是无效的):
static bool UseUmbrella (bool rainy, bool sunny, bool windy)
{
return !windy && (rainy || sunny);
}
&&和||运算符在可能时短路评估。在上述示例中,如果有风,表达式(rainy || sunny)甚至不会被评估。短路在允许如下表达式运行而不抛出NullReferenceException时是至关重要的:
if (sb != null && sb.Length > 0) ...
&和|运算符还测试and和or条件:
return !windy & (rainy | sunny);
不同之处在于它们不进行短路。因此,它们很少用于替代条件运算符。
注意
与 C 和 C++不同,当应用于bool表达式时,&和|运算符执行(非短路)布尔比较。当应用于数字时,&和|运算符只执行位操作。
条件运算符(三元运算符)
条件运算符(更常称为三元运算符,因为它是唯一接受三个操作数的运算符)的形式为q ? a : b;因此,如果条件q为真,则评估a;否则评估b:
static int Max (int a, int b)
{
return (a > b) ? a : b;
}
条件运算符在语言集成查询(LINQ)表达式中特别有用(第八章)。
字符串和字符
C#的char类型(别名System.Char类型)表示一个 Unicode 字符,占据 2 个字节(UTF-16)。char字面量在单引号内指定:
char c = 'A'; // Simple character
转义序列表示不能以字面或直接方式表示或解释的字符。转义序列是一个反斜杠后跟具有特殊含义的字符;例如:
char newLine = '\n';
char backSlash = '\\';
表 2-2 显示了转义序列字符。
表 2-2. 转义序列字符
| Char | 含义 | 值 |
|---|---|---|
\' | 单引号 | 0x0027 |
\" | 双引号 | 0x0022 |
\\ | 反斜杠 | 0x005C |
\0 | 空字符 | 0x0000 |
\a | 警报 | 0x0007 |
\b | 退格 | 0x0008 |
\f | 换页符 | 0x000C |
\n | 换行符 | 0x000A |
\r | 回车符 | 0x000D |
\t | 水平制表符 | 0x0009 |
\v | 垂直制表符 | 0x000B |
\u(或\x)转义序列允许您通过其四位十六进制代码指定任何 Unicode 字符:
char copyrightSymbol = '\u00A9';
char omegaSymbol = '\u03A9';
char newLine = '\u000A';
字符转换
从char到数值类型的隐式转换适用于可以容纳无符号short的数值类型。对于其他数值类型,需要显式转换。
字符串类型
C#的字符串类型(别名System.String类型,在第六章深入讨论)表示一种不可变(不可修改)的 Unicode 字符序列。字符串字面量在双引号内指定:
string a = "Heat";
注意
string是引用类型而不是值类型。然而,其相等运算符遵循值类型语义:
string a = "test";
string b = "test";
Console.Write (a == b); // True
对于char字面量有效的转义序列也适用于字符串内部:
string a = "Here's a tab:\t";
代价是每当需要字面反斜杠时,必须写两次:
string a1 = "\\\\server\\fileshare\\helloworld.cs";
为避免此问题,C#允许verbatim字符串直接文字。verbatim 字符串直接文字以@为前缀,不支持转义序列。以下 verbatim 字符串与前述字符串相同:
string a2 = @"\\server\fileshare\helloworld.cs";
verbatim 字符串直接文字也可以跨多行:
string escaped = "First Line\r\nSecond Line";
string verbatim = @"First Line
Second Line";
// True if your text editor uses CR-LF line separators:
Console.WriteLine (escaped == verbatim);
你可以通过将其写两次来在直接文字文本中包含双引号字符:
string xml = @"<customer id=""123""></customer>";
原始字符串直接文字(C# 11)
用三个或更多引号字符(""")包裹字符串会创建一个原始字符串直接文字。原始字符串直接文字可以包含几乎任何字符序列,无需转义或重复:
string raw = """<file path="c:\temp\test.txt"></file>""";
原始字符串直接文字使得表示 JSON、XML 和 HTML 直接文字、正则表达式和源代码变得容易。如果需要在字符串本身中包含三个(或更多)引号字符,可以通过将字符串包装在四个(或更多)引号字符中来实现:
string raw = """"The """ sequence denotes raw string literals."""";
多行原始字符串直接文字受特殊规则约束。我们可以将字符串"Line 1\r\nLine 2"表示如下:
string multiLineRaw = """
Line 1
Line 2
""";
注意,开头和结尾的引号必须在不同的行上。另外:
-
忽略opening
"""(在同一行上)之后的空白。 -
在同一行上,closing
"""之前的空白被视为common indentation并从字符串的每一行中移除。这使您可以包含用于源代码可读性的缩进,而不将该缩进作为字符串的一部分。
下面是另一个示例,以说明多行原始字符串直接文字的规则:
if (true)
Console.WriteLine ("""
{
"Name" : "Joe"
}
""");
输出如下:
{
"Name" : "Joe"
}
如果多行原始字符串直接文字中的每一行未以关闭引号指定的公共缩进为前缀,则编译器将生成错误。
原始字符串直接文字可以被插值,受“字符串插值”描述的特殊规则约束。
字符串连接
+运算符连接两个字符串:
string s = "a" + "b";
运算符的一个操作数可能是非字符串值,在这种情况下,将对该值调用ToString:
string s = "a" + 5; // a5
反复使用+运算符来构建字符串是低效的:更好的解决方案是使用System.Text.StringBuilder类型(在第六章中描述)。
字符串插值
以$字符为前缀的字符串称为插值字符串。插值字符串可以包含用大括号括起来的表达式:
int x = 4;
Console.Write ($"A square has {x} sides"); // Prints: A square has 4 sides
任何类型的有效 C#表达式都可以出现在大括号内,并且 C#将通过调用其ToString方法或等效方法将表达式转换为字符串。您可以通过追加表达式和冒号以及格式字符串来更改格式(格式字符串在“String.Format 和组合格式字符串”中描述):
string s = $"255 in hex is {byte.MaxValue:X2}"; // X2 = 2-digit hexadecimal
// Evaluates to "255 in hex is FF"
如果需要为其他目的使用冒号(例如三元条件运算符,我们稍后会讨论),必须将整个表达式包装在括号中:
bool b = true;
Console.WriteLine ($"The answer in binary is {(b ? 1 : 0)}");
从 C# 10 开始,插值字符串可以是常量,只要插值的值是常量:
const string greeting = "Hello";
const string message = $"{greeting}, world";
从 C# 11 开始,允许插值字符串跨多行(无论是标准还是文本):
string s = $"this interpolation spans {1 +
1} lines";
原始字符串字面量(从 C# 11 开始)也可以插值:
string s = $"""The date and time is {DateTime.Now}""";
要在插值字符串中包含大括号字面量:
-
使用标准和文本字符串字面量时,重复所需的大括号字符。
-
使用原始字符串字面量时,通过重复
$前缀改变插值序列。
在原始字符串字面量前使用两个(或更多)$字符会改变插值序列,从一个大括号变为两个(或更多)大括号:
Console.WriteLine ($$"""{ "TimeStamp": "{{DateTime.Now}}" }""");
// Output: { "TimeStamp": "01/01/2024 12:13:25 PM" }
这保留了将文本复制粘贴到原始字符串字面量中而无需修改字符串的能力。
字符串比较
要使用==运算符(或string的Equals方法之一)执行相等比较,必须使用字符串的CompareTo方法进行顺序比较;不支持<和>运算符。我们在“比较字符串”中详细描述了相等性和顺序比较。
UTF-8 字符串
从 C# 11 开始,可以使用u8后缀创建以 UTF-8 编码而不是 UTF-16 编码的字符串字面量。此功能适用于高级场景,例如在性能热点处低级处理 JSON 文本:
ReadOnlySpan<byte> utf8 = "ab→cd"u8; // Arrow symbol consumes 3 bytes
Console.WriteLine (utf8.Length); // 7
底层类型是ReadOnlySpan<byte>,我们在第二十三章中介绍了它。您可以调用ToArray()方法将其转换为数组。
数组
数组表示特定类型的固定数量变量(称为元素)。数组中的元素总是存储在连续的内存块中,提供高效的访问。
数组在元素类型后用方括号表示:
char[] vowels = new char[5]; // Declare an array of 5 characters
方括号还用于索引数组,通过位置访问特定元素。
vowels[0] = 'a';
vowels[1] = 'e';
vowels[2] = 'i';
vowels[3] = 'o';
vowels[4] = 'u';
Console.WriteLine (vowels[1]); // e
这会打印“e”,因为数组索引从 0 开始。您可以使用for循环语句迭代数组中的每个元素。在此示例中,for循环从整数i循环到4:
for (int i = 0; i < vowels.Length; i++)
Console.Write (vowels[i]); // aeiou
数组的Length属性返回数组中的元素数量。创建数组后,无法更改其长度。System.Collection命名空间及其子命名空间提供了更高级的数据结构,例如动态大小的数组和字典。
数组初始化表达式允许您在单个步骤中声明和填充数组:
char[] vowels = new char[] {'a','e','i','o','u'};
或简单地说:
char[] vowels = {'a','e','i','o','u'};
注意
从 C# 12 开始,可以使用方括号代替花括号:
char[] vowels = ['a','e','i','o','u'];
这被称为集合表达式,其优点在于在调用方法时也可以使用:
Foo (['a','e','i','o','u']);
void Foo (char[] letters) { ... }
集合表达式还适用于其他集合类型,如列表和集合——参见“集合初始化器和集合表达式”。
所有数组都继承自System.Array类,为所有数组提供共享服务。这些成员包括无论数组类型如何都能获取和设置元素的方法。我们在“数组类”中描述了它们。
默认元素初始化
创建数组时,总是使用默认值预初始化元素。类型的默认值是内存的比特位清零结果。例如,考虑创建整数数组。因为int是值类型,这将在内存中分配 1,000 个整数,连续分配的内存块中每个元素的默认值为 0:
int[] a = new int[1000];
Console.Write (a[123]); // 0
值类型与引用类型
数组元素类型是值类型还是引用类型对性能有重要影响。当元素类型是值类型时,每个元素值作为数组的一部分分配,如下所示:
Point[] a = new Point[1000];
int x = a[500].X; // 0
public struct Point { public int X, Y; }
如果Point是一个类,创建数组只会分配 1,000 个空引用:
Point[] a = new Point[1000];
int x = a[500].X; // Runtime error, NullReferenceException
public class Point { public int X, Y; }
要避免此错误,我们必须在实例化数组后显式实例化 1,000 个Point:
Point[] a = new Point[1000];
for (int i = 0; i < a.Length; i++) // Iterate i from 0 to 999
a[i] = new Point(); // Set array element i with new point
一个数组 本身 总是一个引用类型对象,无论元素类型如何。例如,以下操作是合法的:
int[] a = null;
索引和范围
索引和范围(在 C# 8 中引入)简化了处理数组元素或部分的工作。
注意
索引和范围也适用于 CLR 类型Span<T>和ReadOnlySpan<T>(参见第二十三章)。
您还可以通过定义Index或Range类型的索引器,使自定义类型与索引和范围一起工作(参见“索引器”)。
索引
索引允许您使用^运算符相对于数组的末尾引用元素。¹引用最后一个元素,²引用倒数第二个元素,依此类推:
char[] vowels = new char[] {'a','e','i','o','u'};
char lastElement = vowels [¹]; // 'u'
char secondToLast = vowels [²]; // 'o'
(⁰等于数组的长度,因此vowels[⁰]将生成错误。)
C# 使用Index类型实现索引,因此您也可以执行以下操作:
Index first = 0;
Index last = ¹;
char firstElement = vowels [first]; // 'a'
char lastElement = vowels [last]; // 'u'
范围
范围允许您通过使用..运算符来“切片”数组:
char[] firstTwo = vowels [..2]; // 'a', 'e'
char[] lastThree = vowels [2..]; // 'i', 'o', 'u'
char[] middleOne = vowels [2..3]; // 'i'
范围中的第二个数字是排除的,因此..2返回vowels[2]之前的元素。
您还可以在范围中使用^符号。以下返回最后两个字符:
char[] lastTwo = vowels [²..]; // 'o', 'u'
C# 使用Range类型实现范围,因此您也可以执行以下操作:
Range firstTwoRange = 0..2;
char[] firstTwo = vowels [firstTwoRange]; // 'a', 'e'
多维数组
多维数组有两种类型:矩形和嵌套。矩形数组表示一个n维内存块,而嵌套数组是数组的数组。
矩形数组
使用逗号分隔每个维度声明矩形数组。以下声明了维度为 3 乘 3 的矩形二维数组:
int[,] matrix = new int[3,3];
数组的GetLength方法返回给定维度(从 0 开始)的长度:
for (int i = 0; i < matrix.GetLength(0); i++)
for (int j = 0; j < matrix.GetLength(1); j++)
matrix[i,j] = i * 3 + j;
您可以使用显式值初始化矩形数组。以下代码创建与前面示例相同的数组:
int[,] matrix = new int[,]
{
{0,1,2},
{3,4,5},
{6,7,8}
};
嵌套数组
声明嵌套数组时,使用连续的方括号来表示每个维度。以下是声明外部维度为 3 的嵌套二维数组的示例:
int[][] matrix = new int[3][];
注意
有趣的是,这是new int[3][]而不是new int[][3]。Eric Lippert 在这篇优秀的文章中详细解释了为什么会这样。
声明中未指定内部维度,因为与矩形数组不同,每个内部数组可以是任意长度。每个内部数组隐式初始化为 null,而不是空数组。您必须手动创建每个内部数组:
for (int i = 0; i < matrix.Length; i++)
{
matrix[i] = new int[3]; // Create inner array
for (int j = 0; j < matrix[i].Length; j++)
matrix[i][j] = i * 3 + j;
}
您可以使用显式值初始化锯齿数组。以下代码创建了一个与上一个示例相同的数组,并在末尾添加了一个额外的元素:
int[][] matrix = new int[][]
{
new int[] {0,1,2},
new int[] {3,4,5},
new int[] {6,7,8,9}
};
简化的数组初始化表达式
缩短数组初始化表达式有两种方法。第一种是省略new运算符和类型限定符:
char[] vowels = {'a','e','i','o','u'};
int[,] rectangularMatrix =
{
{0,1,2},
{3,4,5},
{6,7,8}
};
int[][] jaggedMatrix =
{
new int[] {0,1,2},
new int[] {3,4,5},
new int[] {6,7,8,9}
};
(从 C# 12 开始,您可以在单维数组中使用方括号而不是大括号。)
第二种方法是使用var关键字,它指示编译器隐式地为局部变量赋予类型。以下是简单的示例:
var i = 3; // i is implicitly of type int
var s = "sausage"; // s is implicitly of type string
同样的原则也适用于数组,只是可以进一步进行。通过在new关键字后省略类型限定符,编译器推断出数组类型:
var vowels = new[] {'a','e','i','o','u'}; // Compiler infers char[]
下面是如何将其应用于多维数组的方法:
var rectMatrix = new[,] // rectMatrix is implicitly of type int[,]
{
{0,1,2},
{3,4,5},
{6,7,8}
};
var jaggedMat = new int[][] // jaggedMat is implicitly of type int[][]
{
new[] {0,1,2},
new[] {3,4,5},
new[] {6,7,8,9}
};
为了使其工作,所有元素都必须隐式转换为单一类型(至少一个元素必须是该类型,并且必须有一个最佳类型),如下例所示:
var x = new[] {1,10000000000}; // all convertible to long
边界检查
运行时对所有数组索引进行边界检查。如果使用无效索引,则会抛出IndexOutOfRangeException:
int[] arr = new int[3];
arr[3] = 1; // IndexOutOfRangeException thrown
数组边界检查对类型安全性和简化调试是必要的。
注意
通常,边界检查带来的性能损失很小,即时(JIT)编译器可以执行优化,例如在进入循环之前预先确定所有索引是否安全,从而避免每次迭代都进行检查。此外,C#提供了可以显式绕过边界检查的“不安全”代码(参见“不安全代码和指针”)。
变量和参数
变量表示具有可修改值的存储位置。变量可以是局部变量、参数(值、ref、out或in)、字段(实例或静态)或数组元素。
栈和堆
栈和堆是变量驻留的地方。它们具有非常不同的生存周期语义。
栈
栈是用于存储局部变量和参数的内存块。栈在进入和退出方法或函数时逻辑增长和收缩。考虑以下方法(为了避免分散注意力,忽略了输入参数检查):
static int Factorial (int x)
{
if (x == 0) return 1;
return x * Factorial (x-1);
}
此方法是递归的,意味着它调用自身。每次进入方法时,在堆栈上分配一个新的int,每次退出方法时,int都会被释放。
堆
堆是 对象(即引用类型实例)所驻留的内存。每当创建新对象时,它都会被分配到堆上,并返回对该对象的引用。程序执行期间,堆会随着新对象的创建而填充。运行时有一个垃圾收集器定期从堆中释放对象,以确保程序不会耗尽内存。一个对象在不再被任何“活动”的引用引用时,就有资格被回收。
在下面的示例中,我们首先创建一个由变量 ref1 引用的 StringBuilder 对象,然后输出其内容。由于后续没有任何使用它的操作,这个 StringBuilder 对象随即成为垃圾回收的对象。
接着,我们创建另一个由变量 ref2 引用的 StringBuilder,并将该引用复制给 ref3。尽管此后未再使用 ref2,但 ref3 保持对同一 StringBuilder 对象的引用,确保在我们完成对 ref3 的使用之前,它不会成为回收对象:
using System;
using System.Text;
StringBuilder ref1 = new StringBuilder ("object1");
Console.WriteLine (ref1);
// The StringBuilder referenced by ref1 is now eligible for GC.
StringBuilder ref2 = new StringBuilder ("object2");
StringBuilder ref3 = ref2;
// The StringBuilder referenced by ref2 is NOT yet eligible for GC.
Console.WriteLine (ref3); // object2
值类型实例(以及对象引用)存在于变量声明的位置。如果实例被声明为类类型的字段或数组元素,则该实例存在于堆上。
注意
在 C# 中,你无法像在 C++ 中那样显式地删除对象。一个未被引用的对象最终会被垃圾收集器收集。
堆还存储静态字段。与分配在堆上的对象不同(可以进行垃圾回收),这些字段一直存在,直到进程结束。
明确赋值
C# 强制执行明确赋值策略。在实践中,这意味着在 unsafe 或互操作上下文之外,你不能意外地访问未初始化的内存。明确赋值有三个影响:
-
局部变量在使用之前必须被赋予一个值。
-
在调用方法时,必须提供函数参数(除非标记为可选;参见 “可选参数”)。
-
其他所有变量(如字段和数组元素)都会由运行时自动初始化。
例如,以下代码会导致编译时错误:
int x;
Console.WriteLine (x); // Compile-time error
字段和数组元素会自动使用其类型的默认值进行初始化。以下代码输出 0,因为数组元素会隐式地赋值为它们的默认值:
int[] ints = new int[2];
Console.WriteLine (ints[0]); // 0
以下代码输出 0,因为字段会隐式地被赋予一个默认值(无论是实例字段还是静态字段):
Console.WriteLine (Test.X); // 0
class Test { public static int X; } // field
默认值
所有类型实例都有一个默认值。预定义类型的默认值是对内存的比特位清零的结果:
| 类型 | 默认值 |
|---|---|
| 引用类型(以及可空值类型) | null |
| 数值和枚举类型 | 0 |
char 类型 | '\0' |
bool 类型 | false |
你可以通过 default 关键字获取任何类型的默认值:
Console.WriteLine (default (decimal)); // 0
当类型可以被推断时,可以选择省略类型声明:
decimal d = default;
自定义值类型(即 struct)中的默认值与定义的每个字段的默认值相同。
参数
一个方法可以有一系列参数。参数定义了必须为该方法提供的参数集。在以下示例中,方法 Foo 有一个名为 p 的参数,类型为 int:
Foo (8); // 8 is an argument
static void Foo (int p) {...} // p is a parameter
您可以使用 ref、in 和 out 修饰符来控制参数的传递方式:
| 参数修饰符 | 传递方式 | 变量必须有明确的赋值 |
|---|---|---|
| (无) | 值 | 传递进去 |
ref | 引用 | 传递进去 |
in | 引用(只读) | 传递进去 |
out | 引用 | 传递出去 |
按值传递参数
默认情况下,C# 中的参数是按值传递的,这是最常见的情况。这意味着传递到方法时会创建值的副本:
int x = 8;
Foo (x); // Make a copy of x
Console.WriteLine (x); // x will still be 8
static void Foo (int p)
{
p = p + 1; // Increment p by 1
Console.WriteLine (p); // Write p to screen
}
将 p 赋予一个新值不会改变 x 的内容,因为 p 和 x 存在于不同的内存位置。
按引用类型参数传递的参数按值复制引用但不复制对象。在以下示例中,Foo 看到我们实例化的同一个 StringBuilder 对象(sb),但是具有独立的引用。换句话说,sb 和 fooSB 是引用同一个 StringBuilder 对象的不同变量:
StringBuilder sb = new StringBuilder();
Foo (sb);
Console.WriteLine (sb.ToString()); // test
static void Foo (StringBuilder fooSB)
{
fooSB.Append ("test");
fooSB = null;
}
因为 fooSB 是引用的副本,将其设置为 null 不会使 sb 变为 null。(但是,如果 fooSB 声明并使用了 ref 修饰符,sb 会 变为 null。)
ref 修饰符
要按引用传递,C# 提供了 ref 参数修饰符。在以下示例中,p 和 x 指向相同的内存位置:
int x = 8;
Foo (ref x); // Ask Foo to deal directly with x
Console.WriteLine (x); // x is now 9
static void Foo (ref int p)
{
p = p + 1; // Increment p by 1
Console.WriteLine (p); // Write p to screen
}
现在将 p 赋予一个新值会改变 x 的内容。请注意,在编写和调用方法时都需要 ref 修饰符。⁴ 这使得发生的事情非常清楚。
ref 修饰符在实现交换方法(在“泛型”中,我们展示了如何编写适用于任何类型的交换方法)中至关重要:
string x = "Penn";
string y = "Teller";
Swap (ref x, ref y);
Console.WriteLine (x); // Teller
Console.WriteLine (y); // Penn
static void Swap (ref string a, ref string b)
{
string temp = a;
a = b;
b = temp;
}
注意
参数可以按引用或按值传递,无论参数类型是引用类型还是值类型。
out 修饰符
out 参数与 ref 参数类似,除了以下情况:
-
进入函数之前不需要对其进行赋值。
-
在离开函数之前必须为其分配一个值。
out 修饰符最常用于从方法中获取多个返回值;例如:
string a, b;
Split ("Stevie Ray Vaughn", out a, out b);
Console.WriteLine (a); // Stevie Ray
Console.WriteLine (b); // Vaughn
void Split (string name, out string firstNames, out string lastName)
{
int i = name.LastIndexOf (' ');
firstNames = name.Substring (0, i);
lastName = name.Substring (i + 1);
}
像 ref 参数一样,out 参数也是按引用传递的。
Out 变量和丢弃
在调用具有 out 参数的方法时,您可以在调用时临时声明变量。我们可以用以下方式替换我们前面示例的前两行:
Split ("Stevie Ray Vaughan", out string a, out string b);
在调用具有多个 out 参数的方法时,有时您对其中一些参数的值不感兴趣。在这种情况下,您可以使用下划线“丢弃”您不感兴趣的参数:
Split ("Stevie Ray Vaughan", out string a, out _); // Discard 2nd param
Console.WriteLine (a);
在这种情况下,编译器将下划线视为一个特殊符号,称为丢弃。你可以在单个调用中包含多个丢弃。假设SomeBigMethod已经定义了七个**out**参数,我们可以忽略除了第四个之外的所有参数,如下所示:
SomeBigMethod (out _, out _, out _, out int x, out _, out _, out _);
为了向后兼容,如果实际下划线变量在作用域中,则不会生效:
string _;
Split ("Stevie Ray Vaughan", out string a, out _);
Console.WriteLine (_); // Vaughan
传递引用的影响
当您通过引用传递参数时,您将现有变量的存储位置别名化,而不是创建一个新的存储位置。在以下示例中,变量x和y表示同一个实例:
class Test
{
static int x;
static void Main() { Foo (out x); }
static void Foo (out int y)
{
Console.WriteLine (x); // x is 0
y = 1; // Mutate y
Console.WriteLine (x); // x is 1
}
}
in 修饰符
in参数类似于ref参数,但是方法不能修改参数的值(这样做会生成编译时错误)。当将大的值类型传递给方法时,这个修饰符非常有用,因为它允许编译器在传递参数之前避免复制参数的开销,同时仍然保护原始值不被修改。
仅仅基于in的存在进行重载是允许的:
void Foo ( SomeBigStruct a) { ... }
void Foo (in SomeBigStruct a) { ... }
要调用第二个重载,调用者必须使用in修饰符:
SomeBigStruct x = ...;
Foo (x); // Calls the first overload
Foo (in x); // Calls the second overload
当没有歧义时
void Bar (in SomeBigStruct a) { ... }
对于调用者来说,使用in修饰符是可选的:
Bar (x); // OK (calls the 'in' overload)
Bar (in x); // OK (calls the 'in' overload)
要使这个例子有意义,SomeBigStruct应该被定义为一个结构体(参见“结构体”)。
params 修饰符
如果params修饰符应用于方法的最后一个参数,则该方法可以接受特定类型的任意数量的参数。参数类型必须声明为(单维)数组,如下例所示:
int total = Sum (1, 2, 3, 4);
Console.WriteLine (total); // 10
// The call to Sum above is equivalent to:
int total2 = Sum (new int[] { 1, 2, 3, 4 });
int Sum (params int[] ints)
{
int sum = 0;
for (int i = 0; i < ints.Length; i++)
sum += ints [i]; // Increase sum by ints[i]
return sum;
}
如果在params位置没有参数,则创建一个长度为零的数组。
您还可以将params参数作为普通数组提供。我们示例中的第一行在语义上等同于这个:
int total = Sum (new int[] { 1, 2, 3, 4 } );
可选参数
方法、构造函数和索引器(见第三章)可以声明可选参数。如果参数在声明中指定了默认值,则该参数是可选的:
void Foo (int x = 23) { Console.WriteLine (x); }
调用方法时可以省略可选参数:
Foo(); // 23
默认参数的23实际上传递给了可选参数x——编译器将值23嵌入编译代码中的调用端。前面对Foo的调用在语义上等同于:
Foo (23);
因为编译器简单地在使用时替换可选参数的默认值。
警告
添加一个可选参数到一个从另一个程序集调用的公共方法需要重新编译两个程序集——就像这个参数是必须的一样。
可选参数的默认值必须由常量表达式、值类型的无参数构造函数或default表达式指定。可选参数不能标记为ref或out。
强制参数必须在方法声明和方法调用中之前的可选参数(例外是 params 参数,它们始终位于最后)。在以下示例中,显式值 1 被传递给 x,默认值 0 被传递给 y:
Foo (1); // 1, 0
void Foo (int x = 0, int y = 0) { Console.WriteLine (x + ", " + y); }
你可以通过将可选参数与命名参数结合使用来做相反的操作(向 x 传递默认值,向 y 传递显式值)。
命名参数
而不是按位置标识参数,你可以按名称标识参数:
Foo (x:1, y:2); // 1, 2
void Foo (int x, int y) { Console.WriteLine (x + ", " + y); }
命名参数可以按任何顺序出现。以下对 Foo 的调用在语义上是相同的:
Foo (x:1, y:2);
Foo (y:2, x:1);
注意
一个微妙的差异是参数表达式在调用现场按出现顺序进行评估。通常,这只在互相关联的具有副作用的表达式(例如下面写出 0, 1)中有所不同。
int a = 0;
Foo (y: ++a, x: --a); // ++a is evaluated first
当然,在实践中几乎肯定会避免编写这样的代码!
你可以混合使用命名和位置参数:
Foo (1, y:2);
但是有一个限制:位置参数必须在命名参数之前,除非它们在正确的位置使用。因此,你可以像这样调用 Foo:
Foo (x:1, 2); // OK. Arguments in the declared positions
但不是这样:
Foo (y:2, 1); // Compile-time error. y isn't in the first position
命名参数在与可选参数结合使用时特别有用。例如,考虑以下方法:
void Bar (int a = 0, int b = 0, int c = 0, int d = 0) { ... }
你可以只为 d 提供一个值进行调用,如下所示:
Bar (d:3);
这在调用 COM API 时特别有用,我们在第二十四章中详细讨论。
Ref Locals
C# 的一个相对生僻的特性是,你可以定义一个局部变量,引用数组中的元素或对象中的字段(从 C# 7 开始):
int[] numbers = { 0, 1, 2, 3, 4 };
ref int numRef = ref numbers [2];
在此示例中,numRef 是对 numbers[2] 的引用。当我们修改 numRef 时,我们修改了数组元素:
numRef *= 10;
Console.WriteLine (numRef); // 20
Console.WriteLine (numbers [2]); // 20
ref local 的目标必须是数组元素、字段或局部变量;不能是属性(见第三章)。Ref locals 用于专门的微优化场景,通常与ref returns一起使用。
Ref 返回
注意
我们在第二十三章中描述的 Span<T> 和 ReadOnlySpan<T> 类型使用 ref 返回来实现高效的索引器。除此类场景外,ref 返回并不常用,你可以将其视为微优化特性。
你可以从方法中返回一个ref local。这称为ref return:
class Program
{
static string x = "Old Value";
static ref string GetX() => ref x; // This method returns a ref
static void Main()
{
ref string xRef = ref GetX(); // Assign result to a ref local
xRef = "New Value";
Console.WriteLine (x); // New Value
}
}
如果在调用端省略了 ref 修饰符,则会回归到返回普通值:
string localX = GetX(); // Legal: localX is an ordinary non-ref variable.
当定义属性或索引器时,也可以使用 ref 返回:
static ref string Prop => ref x;
尽管没有 set 访问器,这样的属性在隐式上是可写的:
Prop = "New Value";
你可以通过使用 ref readonly 来防止这种修改:
static ref readonly string Prop => ref x;
ref readonly修饰符防止修改,同时仍然允许通过引用返回以获得性能提升。在这种情况下,性能提升非常小,因为x是string类型(引用类型):无论字符串有多长,你希望避免的唯一低效性只是单个 32 位或 64 位引用的复制。
尝试在ref 返回属性或索引器上定义显式的set访问器是非法的。
var—隐式类型局部变量
经常情况下,你会在一步内声明并初始化一个变量。如果编译器能够从初始化表达式推断出类型,你可以使用关键字var替代类型声明;例如:
var x = "hello";
var y = new System.Text.StringBuilder();
var z = (float)Math.PI;
这等效于以下内容:
string x = "hello";
System.Text.StringBuilder y = new System.Text.StringBuilder();
float z = (float)Math.PI;
因为这种直接等价性,隐式类型变量是静态类型的。例如,以下代码会生成编译时错误:
var x = 5;
x = "hello"; // Compile-time error; x is of type int
注意
当你无法仅通过查看变量声明来推断类型时,var可能会降低代码的可读性。例如:
Random r = new Random();
var x = r.Next();
x的类型是什么?
在“匿名类型”中,我们将描述一种必须使用var的场景。
目标类型化的新表达式
另一种减少词汇重复的方式是使用目标类型化的new 表达式(从 C# 9 开始):
System.Text.StringBuilder sb1 = new();
System.Text.StringBuilder sb2 = new ("Test");
这等效于:
System.Text.StringBuilder sb1 = new System.Text.StringBuilder();
System.Text.StringBuilder sb2 = new System.Text.StringBuilder ("Test");
原则是,如果编译器能够明确推断,可以在不指定类型名称的情况下调用new。目标类型化的new表达式特别适用于变量声明和初始化位于代码不同部分的情况。一个常见的例子是在构造函数中初始化字段时:
class Foo
{
System.Text.StringBuilder sb;
public Foo (string initialValue)
{
sb = new (initialValue);
}
}
目标类型化的new表达式在以下场景中也非常有用:
MyMethod (new ("test"));
void MyMethod (System.Text.StringBuilder sb) { ... }
表达式和操作符
一个表达式本质上表示一个值。最简单的表达式类型是常量和变量。表达式可以通过操作符进行转换和组合。操作符接受一个或多个输入操作数以生成一个新的表达式。
以下是一个常量表达式的示例:
12
我们可以使用*操作符结合两个操作数(字面表达式12和30),如下所示:
12 * 30
我们可以构建复杂的表达式,因为操作数本身可以是一个表达式,例如以下示例中的操作数(12 * 30):
1 + (12 * 30)
C#中的操作符可以被分类为一元、二元或三元,取决于它们操作的操作数数量(一个、两个或三个)。二元操作符总是使用中缀表示法,其中操作符被放置在两个操作数之间。
主表达式
主表达式包括由语言基本结构内在操作符组成的表达式。以下是一个示例:
Math.Log (1)
该表达式由两个主表达式组成。第一个表达式执行成员查找(使用.运算符),第二个表达式执行方法调用(使用()运算符)。
无值表达式
无值表达式是指没有值的表达式,比如这个:
Console.WriteLine (1)
因为它没有值,您不能将无值表达式用作操作数来构建更复杂的表达式:
1 + Console.WriteLine (1) // Compile-time error
赋值表达式
赋值表达式使用=运算符将另一个表达式的结果分配给变量;例如:
x = x * 5
赋值表达式不是无值表达式——它具有被分配的值,因此可以并入另一个表达式。在以下示例中,该表达式将2赋给x,将10赋给y:
y = 5 * (x = 2)
您可以使用这种表达式样式来初始化多个值:
a = b = c = d = 0
复合赋值运算符是将赋值与另一个运算符结合的语法快捷方式:
x *= 2 // equivalent to x = x * 2
x <<= 1 // equivalent to x = x << 1
(对这一规则的一个微妙例外是关于事件的描述,在第四章中:这里的+=和-=运算符被特殊对待,并映射到事件的add和remove访问器。)
运算符优先级和结合性
当一个表达式包含多个运算符时,优先级和结合性决定它们评估的顺序。具有较高优先级的运算符在低优先级运算符之前执行。如果运算符具有相同的优先级,则运算符的结合性决定评估的顺序。
优先级
以下表达式
1 + 2 * 3
因为*的优先级高于+,所以它被解释如下:
1 + (2 * 3)
左结合运算符
除了赋值、lambda 和空合并运算符之外,二元运算符(除了赋值、lambda 和空合并运算符)都是左结合的;换句话说,它们从左到右进行评估。例如,以下表达式
8 / 4 / 2
被解释如下:
( 8 / 4 ) / 2 // 1
您可以插入括号来改变实际的评估顺序:
8 / ( 4 / 2 ) // 4
右结合运算符
赋值运算符以及 lambda、空合并和条件运算符是右结合的;换句话说,它们从右到左进行评估。
右结合性允许多次分配,例如以下的编译:
x = y = 3;
首先将3赋给y,然后将该表达式的结果(3)赋给x。
运算符表
表格 2-3 按优先级顺序列出了 C#的运算符。同一类别中的运算符具有相同的优先级。
我们在“运算符重载”中解释了可用户重载的运算符。
表 2-3. C#运算符(按优先级顺序的类别)
| 类别 | 运算符符号 | 运算符名称 | 示例 | 可用户重载 | |
|---|---|---|---|---|---|
| 主要 | . | 成员访问 | x.y | 否 | |
?. 和 ?[] | 空条件 | x?.y 或 x?[0] | 否 | ||
!(后缀) | 空值前缀 | x!.y 或 x![0] | 否 | ||
->(不安全) | 指向结构的指针 | x->y | 否 | ||
() | 函数调用 | x() | 否 | ||
[] | 数组/索引 | a[x] | 通过索引器 | ||
++ | 后增 | x++ | 是 | ||
−− | 后减 | x−− | 是 | ||
new | 创建实例 | new Foo() | 否 | ||
stackalloc | 栈分配 | stackalloc(10) | 否 | ||
typeof | 根据标识符获取类型 | typeof(int) | 否 | ||
nameof | 获取标识符的名称 | nameof(x) | 否 | ||
checked | 整数溢出检查 | checked(x) | 否 | ||
unchecked | 整数溢出检查关闭 | unchecked(x) | 否 | ||
default | 默认值 | default(char) | 否 | ||
| 一元 | await | 等待 | await myTask | 否 | |
sizeof | 获取结构体大小 | sizeof(int) | 否 | ||
+ | 正值 | +x | 是 | ||
− | 负值 | −x | 是 | ||
! | 非 | !x | 是 | ||
~ | 按位补码 | ~x | 是 | ||
++ | 前增 | ++x | 是 | ||
−− | 前减 | −−x | 是 | ||
() | 强制转换 | (int)x | 否 | ||
^ | 从末尾索引 | array[¹] | 否 | ||
*(不安全) | 地址的值 | *x | 否 | ||
&(不安全) | 值的地址 | &x | 否 | ||
| 范围 | .. ..^ | 索引范围 | x..y x..^y | 否 |
| Switch 和 with | switch | Switch 表达式 | num switch { 1 => true,
_ => false
} | 否 |
with | With 表达式 | rec with { X = 123 } | 否 | ||
|---|---|---|---|---|---|
| 乘法 | * | 乘法 | x * y | 是 | |
/ | 除 | x / y | 是 | ||
% | 余数 | x % y | 是 | ||
| 加法 | + | 加 | x + y | 是 | |
− | 减 | x − y | 是 | ||
| 移位 | << | 左移 | x << 1 | 是 | |
>> | 右移 | x >> 1 | 是 | ||
>>> | 无符号右移 | x >>> 1 | 是 | ||
| 关系 | < | 小于 | x < y | 是 | |
> | 大于 | x > y | 是 | ||
<= | 小于或等于 | x <= y | 是 | ||
>= | 大于或等于 | x >= y | 是 | ||
is | 类型是或是子类 | x is y | 否 | ||
as | 类型转换 | x as y | 否 | ||
| 相等性 | == | 等于 | x == y | 是 | |
!= | 不等于 | x != y | 是 | ||
| 位与 | & | 与 | x & y | 是 | |
| 位异或 | ^ | 异或 | x ^ y | 是 | |
| 位或 | | | 或 | x | y | 是 | |
| 条件与 | && | 条件与 | x && y | 通过 & | |
| 条件或 | || | 条件或 | x || y | 通过 | | |
| 空值合并 | ?? | 空值合并 | x ?? y | 否 | |
| 条件 | ?: | 条件运算符 | isTrue ? thenThis : elseThis | 否 | |
| 赋值和 Lambda | = | 赋值 | x = y | 否 | |
*= | 自乘 | x *= 2 | 通过 * | ||
/= | 自除 | x /= 2 | 通过 / | ||
%= | 余数并赋值 | x %= 2 | |||
+= | 自增 | x += 2 | 通过 + | ||
−= | 自减赋值 | x −= 2 | 通过 − | ||
<<= | 左移赋值 | x <<= 2 | 通过 << | ||
>>= | 右移赋值 | x >>= 2 | 通过 >> | ||
>>>= | 无符号右移赋值 | x >>>= 2 | 通过 >>> | ||
&= | 按位与赋值 | x &= 2 | 通过 & | ||
^= | 按位异或赋值 | x ^= 2 | 通过 ^ | ||
|= | 按位或赋值 | x |= 2 | 通过 | | ||
??= | 空值合并赋值 | x ??= 0 | 否 | ||
=> | Lambda | x => x + 1 | 否 |
空操作符
C# 提供了三个操作符来更轻松地处理 null:空值合并运算符、空值合并赋值运算符和空值条件运算符。
空值合并运算符
?? 运算符是空值合并运算符。它表示,“如果左边的操作数非 null,则给我;否则,给我另一个值。”例如:
string s1 = null;
string s2 = s1 ?? "nothing"; // s2 evaluates to "nothing"
如果左侧表达式非 null,则不会评估右侧表达式。空值合并运算符也适用于可空值类型(参见“可空值类型”)。
空值合并赋值运算符
??= 运算符(在 C# 8 中引入)是空值合并赋值运算符。它表示,“如果左边的操作数为 null,则将右边的操作数赋给左操作数。”考虑以下情况:
myVariable ??= someDefault;
这等同于:
if (myVariable == null) myVariable = someDefault;
??= 运算符在实现延迟计算属性时特别有用。我们稍后将在“计算字段和延迟评估”中介绍这个主题。
空值条件运算符
?. 运算符是空值条件或“Elvis”运算符(以 Elvis 表情命名)。它允许您调用方法和访问成员,就像标准点运算符一样,除非左边的操作数为 null,否则表达式将计算为 null,而不是抛出 NullReferenceException:
System.Text.StringBuilder sb = null;
string s = sb?.ToString(); // No error; s instead evaluates to null
最后一行等同于以下内容:
string s = (sb == null ? null : sb.ToString());
空值条件表达式也适用于索引器:
string[] words = null;
string word = words?[1]; // word is null
遇到 null 时,Elvis 运算符将短路表达式的其余部分。在以下示例中,s 计算为 null,即使在 ToString() 和 ToUpper() 之间使用标准点运算符:
System.Text.StringBuilder sb = null;
string s = sb?.ToString().ToUpper(); // s evaluates to null without error
仅在左侧的操作数可能为 null 时才需要重复使用 Elvis。以下表达式对 x 为 null 和 x.y 为 null 都是健壮的:
x?.y?.z
它等同于以下内容(除了只计算 x.y 一次):
x == null ? null
: (x.y == null ? null : x.y.z)
最终表达式必须能够接受 null。以下是非法的:
System.Text.StringBuilder sb = null;
int length = sb?.ToString().Length; // Illegal : int cannot be null
我们可以通过使用可空值类型来解决此问题(参见“可空值类型”)。如果您已经熟悉可空值类型,这里是一个预览:
int? length = sb?.ToString().Length; // OK: int? can be null
您还可以使用空值条件运算符调用空方法:
someObject?.SomeVoidMethod();
如果 someObject 是 null,这将成为“无操作”,而不是抛出 NullReferenceException。
你可以使用空值条件运算符与我们在第三章中描述的常用类型成员,包括方法、字段、属性和索引器。它还可以很好地与空值合并运算符结合使用:
System.Text.StringBuilder sb = null;
string s = sb?.ToString() ?? "nothing"; // s evaluates to "nothing"
语句
函数由按照它们出现的文本顺序依次执行的语句组成。语句块是出现在大括号({})之间的一系列语句。
声明语句
变量声明引入一个新变量,并可选择用表达式进行初始化。你可以在逗号分隔的列表中声明多个相同类型的变量:
string someWord = "rosebud";
int someNumber = 42;
bool rich = true, famous = false;
常量声明类似于变量声明,但在声明后不能更改,并且必须在声明时进行初始化(参见“常量”):
const double c = 2.99792458E08;
c += 10; // Compile-time Error
局部变量
局部变量或局部常量的作用域在整个当前块中延伸。你不能在当前块或任何嵌套块中声明另一个同名的局部变量:
int x;
{
int y;
int x; // Error - x already defined
}
{
int y; // OK - y not in scope
}
Console.Write (y); // Error - y is out of scope
注意
变量的作用域在其代码块中向两个方向延伸。这意味着,如果我们将x的初始声明移动到方法底部,我们会得到相同的错误。这与 C++不同,并且有些特别,因为在声明之前引用变量或常量是不合法的。
表达式语句
表达式语句是有效的表达式,同时也是有效的语句。表达式语句必须改变状态或调用可能改变状态的内容。改变状态实质上意味着改变一个变量。以下是可能的表达式语句:
-
赋值表达式(包括增量和减量表达式)
-
方法调用表达式(无论是 void 还是非 void)
-
对象实例化表达式
这里有一些例子:
// Declare variables with declaration statements:
string s;
int x, y;
System.Text.StringBuilder sb;
// Expression statements
x = 1 + 2; // Assignment expression
x++; // Increment expression
y = Math.Max (x, 5); // Assignment expression
Console.WriteLine (y); // Method call expression
sb = new StringBuilder(); // Assignment expression
new StringBuilder(); // Object instantiation expression
当你调用一个构造函数或返回值的方法时,你不一定要使用这个结果。然而,除非构造函数或方法改变状态,否则这个语句完全没有用处:
new StringBuilder(); // Legal, but useless
new string ('c', 3); // Legal, but useless
x.Equals (y); // Legal, but useless
选择语句
C#有以下机制来有条件地控制程序执行流程:
-
选择语句(
if,switch) -
条件运算符(
?:) -
循环语句(
while,do-while,for,foreach)
本节涵盖了最简单的两个结构:if语句和switch语句。
if 语句
如果一个bool表达式为真,则if语句执行一个语句:
if (5 < 2 * 3)
Console.WriteLine ("true"); // true
语句可以是一个代码块:
if (5 < 2 * 3)
{
Console.WriteLine ("true");
Console.WriteLine ("Let’s move on!");
}
else 子句
if 语句可以选择包含一个else子句:
if (2 + 2 == 5)
Console.WriteLine ("Does not compute");
else
Console.WriteLine ("False"); // False
在else子句中,你可以嵌套另一个if语句:
if (2 + 2 == 5)
Console.WriteLine ("Does not compute");
else
if (2 + 2 == 4)
Console.WriteLine ("Computes"); // Computes
用括号改变执行流程
else子句始终应用于语句块中的上一个if语句:
if (true)
if (false)
Console.WriteLine();
else
Console.WriteLine ("executes");
这在语义上与以下内容相同:
if (true)
{
if (false)
Console.WriteLine();
else
Console.WriteLine ("executes");
}
我们可以通过移动括号来改变执行流程:
if (true)
{
if (false)
Console.WriteLine();
}
else
Console.WriteLine ("does not execute");
使用大括号,您明确说明了您的意图。这可以改善嵌套if语句的可读性,即使编译器不要求。一个显著的例外是以下模式:
void TellMeWhatICanDo (int age)
{
if (age >= 35)
Console.WriteLine ("You can be president!");
else if (age >= 21)
Console.WriteLine ("You can drink!");
else if (age >= 18)
Console.WriteLine ("You can vote!");
else
Console.WriteLine ("You can wait!");
}
在这里,我们已经安排了if和else语句,以模仿其他语言的“elseif”构造(以及 C#的#elif预处理器指令)。Visual Studio 的自动格式化识别此模式并保留缩进。但从语义上讲,每个跟在else语句后的if语句在功能上都是嵌套在else子句中。
switch 语句
switch语句允许您根据变量可能具有的一组可能值来分支程序执行。switch语句可能会比多个if语句生成更清晰的代码,因为switch语句只需要评估一次表达式:
void ShowCard (int cardNumber)
{
switch (cardNumber)
{
case 13:
Console.WriteLine ("King");
break;
case 12:
Console.WriteLine ("Queen");
break;
case 11:
Console.WriteLine ("Jack");
break;
case -1: // Joker is -1
goto case 12; // In this game joker counts as queen
default: // Executes for any other cardNumber
Console.WriteLine (cardNumber);
break;
}
}
此示例演示了最常见的情况,即切换到常量。当您指定常量时,您受限于内置数值类型和bool,char,string和enum类型。
在每个case子句的末尾,必须明确指定下一步执行的位置,使用某种跳转语句(除非您的代码以无限循环结束)。以下是选项:
-
break(跳转到switch语句的结尾) -
goto case *x*(跳转到另一个case子句) -
goto default(跳转到default子句) -
任何其他跳转语句——即
return,throw,continue或goto *label*
当多个值应执行相同的代码时,您可以按顺序列出通用case:
switch (cardNumber)
{
case 13:
case 12:
case 11:
Console.WriteLine ("Face card");
break;
default:
Console.WriteLine ("Plain card");
break;
}
switch语句的这个特性在生成比多个if-else语句更干净的代码方面至关重要。
切换类型
注意
切换类型是切换到模式的特殊情况。最近版本的 C#中引入了许多其他模式,请参阅“模式”进行全面讨论。
你还可以从 C# 7 中类型(来自 C# 7)切换:
TellMeTheType (12);
TellMeTheType ("hello");
TellMeTheType (true);
void TellMeTheType (object x) // object allows any type.
{
switch (x)
{
case int i:
Console.WriteLine ("It's an int!");
Console.WriteLine ($"The square of {i} is {i * i}");
break;
case string s:
Console.WriteLine ("It's a string");
Console.WriteLine ($"The length of {s} is {s.Length}");
break;
case DateTime:
Console.WriteLine ("It's a DateTime");
break;
default:
Console.WriteLine ("I don't know what x is");
break;
}
}
(object类型允许任何类型的变量;我们在“继承”和“object 类型”中对此进行了全面讨论。)
每个case子句指定要匹配的类型,以及如果匹配成功则要分配的变量(“模式”变量)。与常量不同,您可以使用任何类型,没有限制。
您可以使用when关键字对case进行断言:
switch (x)
{
case bool b when b == true: // Fires only when b is true
Console.WriteLine ("True!");
break;
case bool b:
Console.WriteLine ("False!");
break;
}
当切换到类型时,case 子句的顺序可能很重要(与切换到常量不同)。如果我们反转两个 case,此示例将产生不同的结果(事实上,它甚至无法编译,因为编译器将确定第二个 case 是不可达的)。这个规则的一个例外是default子句,它始终在最后执行,无论其出现在何处。
您可以堆叠多个 case 子句。下面代码中的Console.WriteLine将对任何大于 1,000 的浮点类型执行:
switch (x)
{
case float f when f > 1000:
case double d when d > 1000:
case decimal m when m > 1000:
Console.WriteLine ("We can refer to x here but not f or d or m");
break;
}
在本例中,编译器允许我们仅在 when 子句中使用模式变量 f、d 和 m。当调用 Console.WriteLine 时,未知哪一个变量将被赋值,因此编译器将它们全部超出范围。
你可以在同一个开关语句中混合使用常量和模式。你也可以针对 null 值进行开关:
case null:
Console.WriteLine ("Nothing here");
break;
开关表达式
自 C# 8 开始,你可以在 表达式 上下文中使用 switch。假设 cardNumber 是 int 类型,以下示例演示了其用法:
string cardName = cardNumber switch
{
13 => "King",
12 => "Queen",
11 => "Jack",
_ => "Pip card" // equivalent to 'default'
};
注意,switch 关键字出现在变量名之后,并且 case 子句是表达式(以逗号终止),而不是语句。开关表达式比其开关语句对应物更紧凑,并且可以在 LINQ 查询中使用(参见第八章)。
如果你省略了默认表达式(_)并且开关未匹配成功,将抛出异常。
你还可以针对多个值进行开关(元组 模式):
int cardNumber = 12;
string suite = "spades";
string cardName = (cardNumber, suite) switch
{
(13, "spades") => "King of spades",
(13, "clubs") => "King of clubs",
...
};
通过使用 模式 可以实现更多选项(详见“模式”)。
迭代语句
C# 允许一系列语句通过 while、do-while、for 和 foreach 语句重复执行。
while 和 do-while 循环
while 循环在 bool 表达式为 true 时重复执行代码体。在执行循环体之前测试表达式。例如,以下代码将输出 012:
int i = 0;
while (i < 3)
{
Console.Write (i);
i++;
}
do-while 循环在功能上与 while 循环只有一个不同点,即它在执行语句块之后测试表达式(确保语句块至少执行一次)。以下是使用 do-while 循环重写的前面示例:
int i = 0;
do
{
Console.WriteLine (i);
i++;
}
while (i < 3);
for 循环
for 循环与 while 循环类似,具有用于 初始化 和 迭代 循环变量的特殊子句。for 循环包含如下三个子句:
for (*initialization-clause*; *condition-clause*; *iteration-clause*)
*statement-or-statement-block*
每个子句的作用如下:
初始化子句
在循环开始之前执行;用于初始化一个或多个 迭代 变量
条件子句
一个 bool 表达式,在为 true 时执行循环体
迭代子句
在每次迭代语句块之后执行;通常用于更新迭代变量
例如,以下打印出数字 0 到 2:
for (int i = 0; i < 3; i++)
Console.WriteLine (i);
以下打印出前 10 个斐波那契数(其中每个数是前两个数的和):
for (int i = 0, prevFib = 1, curFib = 1; i < 10; i++)
{
Console.WriteLine (prevFib);
int newFib = prevFib + curFib;
prevFib = curFib; curFib = newFib;
}
for 语句的三个部分都可以省略。你可以实现类似以下的无限循环(尽管可以使用 while(true) 替代):
for (;;)
Console.WriteLine ("interrupt me");
foreach 循环
foreach 语句在可枚举对象中迭代每个元素。大多数表示元素集合或列表的 .NET 类型都是可枚举的。例如,数组和字符串都是可枚举的。以下是枚举字符串中字符的示例,从第一个字符到最后一个字符:
foreach (char c in "beer") // c is the *iteration variable*
Console.WriteLine (c);
这里是输出:
b
e
e
r
我们在 “枚举和迭代器” 中定义可枚举对象。
跳转语句
C# 跳转语句包括 break、continue、goto、return 和 throw。
注意
跳转语句遵守 try 语句的可靠性规则(参见 “try 语句和异常”)。这意味着:
-
从
try块跳出总是在达到跳转目标之前执行try的finally块。 -
不能从
finally块的内部跳到外部(除非通过throw)。
break 语句
break 语句结束循环体或 switch 语句的执行:
int x = 0;
while (true)
{
if (x++ > 5)
break; // break from the loop
}
// execution continues here after break
...
继续语句
continue 语句放弃循环中的剩余语句,并提前开始下一次迭代。以下循环跳过偶数:
for (int i = 0; i < 10; i++)
{
if ((i % 2) == 0) // If i is even,
continue; // continue with next iteration
Console.Write (i + " ");
}
OUTPUT: 1 3 5 7 9
goto 语句
goto 语句将执行转移到语句块内的另一个标签。其形式如下:
goto *statement-label*;
或者,当在 switch 语句中使用时:
goto case *case-constant*; // (Only works with constants, not patterns)
标签是代码块中语句之前的占位符,用冒号后缀表示。以下迭代 1 到 5 的数字,模拟 for 循环:
int i = 1;
startLoop:
if (i <= 5)
{
Console.Write (i + " ");
i++;
goto startLoop;
}
OUTPUT: 1 2 3 4 5
goto case *case-constant* 将执行转移到 switch 块中的另一个 case(参见 “switch 语句”)。
返回语句
return 语句退出方法,如果方法是非 void 类型,则必须返回方法返回类型的表达式:
decimal AsPercentage (decimal d)
{
decimal p = d * 100m;
return p; // Return to the calling method with value
}
return 语句可以出现在方法的任何位置(除了 finally 块),并且可以多次使用。
抛出语句
throw 语句抛出异常,指示发生错误(参见 “try 语句和异常”):
if (w == null)
throw new ArgumentNullException (...);
杂项语句
using 语句提供了一种优雅的语法,用于在对象实现 IDisposable 时调用 Dispose,在 finally 块中(参见 “try 语句和异常” 和 “IDisposable、Dispose 和 Close”)。
注意
C# 重载 using 关键字,以在不同的上下文中具有独立的含义。具体来说,using 指令 与 using 语句 不同。
lock 语句是调用 Monitor 类的 Enter 和 Exit 方法的快捷方式(参见第 14 和 23 章节)。
命名空间
命名空间是类型名称的域。类型通常组织到分层命名空间中,使其更易于查找并避免冲突。例如,处理公钥加密的 RSA 类型定义在以下命名空间中:
System.Security.Cryptography
命名空间是类型名称的一个组成部分。以下代码调用 RSA 的 Create 方法:
System.Security.Cryptography.RSA rsa =
System.Security.Cryptography.RSA.Create();
注意
命名空间与程序集独立,程序集是作为部署单元的 .dll 文件(详见第十七章)。
命名空间对成员可见性没有影响——public、internal、private 等。
namespace 关键字为该块内部的类型定义了一个命名空间;例如:
namespace Outer.Middle.Inner
{
class Class1 {}
class Class2 {}
}
命名空间中的点表示嵌套命名空间的层次结构。接下来的代码在语义上与前面的示例相同:
namespace Outer
{
namespace Middle
{
namespace Inner
{
class Class1 {}
class Class2 {}
}
}
}
可以使用完全限定名称引用类型,该名称包括从最外层到最内层的所有命名空间。例如,我们可以在前面的示例中将 Class1 称为 Outer.Middle.Inner.Class1。
没有定义在任何命名空间中的类型称为全局命名空间。全局命名空间还包括顶层命名空间,例如我们示例中的 Outer。
文件范围命名空间
通常情况下,你会希望文件中的所有类型都定义在同一个命名空间中:
namespace MyNamespace
{
class Class1 {}
class Class2 {}
}
从 C# 10 开始,你可以通过文件范围命名空间来实现这一点:
namespace MyNamespace; // Applies to everything that follows in the file.
class Class1 {} // inside MyNamespace
class Class2 {} // inside MyNamespace
文件范围的命名空间减少了混乱,并消除了不必要的缩进级别。
using 指令
using 指令导入一个命名空间,允许你引用类型而无需完全限定其名称。以下导入了前面示例的 Outer.Middle.Inner 命名空间:
using Outer.Middle.Inner;
Class1 c; // Don’t need fully qualified name
注意
定义相同类型名称在不同命名空间中是合法的(通常也是可取的)。但是,你通常只会在不太可能同时导入两个命名空间的情况下这样做。一个很好的例子是 TextBox 类,它在 System.Windows.Controls(WPF)和 System.Windows.Forms(Windows Forms)中都有定义。
using 指令可以嵌套在命名空间本身内部,以限制指令的范围。
全局 using 指令
从 C# 10 开始,如果使用 global 关键字前缀 using 指令,则该指令将应用于项目或编译单元中的所有文件:
global using System;
global using System.Collection.Generic;
这使得你可以集中常见导入并避免在每个文件中重复相同的指令。
global using 指令必须位于非全局指令之前,并且不能出现在命名空间声明内部。全局指令可以与 using static 一起使用。
隐式全局导入
从 .NET 6 开始,项目文件允许隐式 global using 指令。如果项目文件中的 ImplicitUsings 元素设置为 true(新项目的默认设置),则会自动导入以下命名空间:
System
System.Collections.Generic
System.IO
System.Linq
System.Net.Http
System.Threading
System.Threading.Tasks
根据项目 SDK(Web、Windows Forms、WPF 等),还会导入其他命名空间。
using static
using static 指令导入一个类型而不是命名空间。然后可以无需限定符使用导入类型的所有静态成员。在下面的示例中,我们调用 Console 类的静态 WriteLine 方法,无需引用类型:
using static System.Console;
WriteLine ("Hello");
using static指令导入类型的所有可访问静态成员,包括字段、属性和嵌套类型(第三章)。也可以将此指令应用于枚举类型(第三章),在这种情况下将导入其成员。因此,如果我们导入以下枚举类型:
using static System.Windows.Visibility;
我们可以指定Hidden而不是Visibility.Hidden:
var textBox = new TextBox { Visibility = Hidden }; // XAML-style
如果多个静态导入之间存在歧义,C#编译器无法从上下文中推断出正确的类型,并将生成错误。
命名空间内的规则
名称作用域
在内部命名空间中声明的名称可以在其中的内部命名空间中不带限定地使用。在此示例中,Inner内部不需要在Class1中进行限定:
namespace Outer
{
class Class1 {}
namespace Inner
{
class Class2 : Class1 {}
}
}
如果要引用命名空间层次结构中不同分支中的类型,可以使用部分限定名称。在以下示例中,我们将SalesReport基于Common.ReportBase:
namespace MyTradingCompany
{
namespace Common
{
class ReportBase {}
}
namespace ManagementReporting
{
class SalesReport : Common.ReportBase {}
}
}
名称隐藏
如果相同的类型名称同时出现在内部和外部命名空间中,则内部名称优先。要引用外部命名空间中的类型,必须限定其名称:
namespace Outer
{
class Foo { }
namespace Inner
{
class Foo { }
class Test
{
Foo f1; // = Outer.Inner.Foo
Outer.Foo f2; // = Outer.Foo
}
}
}
注意
所有类型名称在编译时转换为完全限定名称。中间语言(IL)代码不包含未限定或部分限定的名称。
重复的命名空间
您可以重复命名空间声明,只要命名空间中的类型名称不冲突:
namespace Outer.Middle.Inner
{
class Class1 {}
}
namespace Outer.Middle.Inner
{
class Class2 {}
}
我们甚至可以将示例分解为两个源文件,以便将每个类编译到不同的程序集中。
源文件 1:
namespace Outer.Middle.Inner
{
class Class1 {}
}
源文件 2:
namespace Outer.Middle.Inner
{
class Class2 {}
}
嵌套的 using 指令
在命名空间中可以嵌套using指令。这样可以在命名空间声明内部作用域限定using指令。在下面的示例中,Class1在一个作用域内可见,但在另一个作用域内不可见:
namespace N1
{
class Class1 {}
}
namespace N2
{
using N1;
class Class2 : Class1 {}
}
namespace N2
{
class Class3 : Class1 {} // Compile-time error
}
别名类型和命名空间
导入命名空间可能导致类型名称冲突。与其导入整个命名空间,你可以只导入需要的特定类型,并为每个类型指定别名:
using PropertyInfo2 = System.Reflection.PropertyInfo;
class Program { PropertyInfo2 p; }
可以对整个命名空间进行别名,如下所示:
using R = System.Reflection;
class Program { R.PropertyInfo p; }
别名任何类型(C# 12)
从 C# 12 开始,using指令可以为任何类型(例如数组)设置别名:
using NumberList = double[];
NumberList numbers = { 2.5, 3.5 };
您还可以为元组设置别名-我们将在“别名元组(C# 12)”中讨论此问题。
高级命名空间功能
Extern
外部别名允许您的程序引用具有相同完全限定名称的两种类型(即命名空间和类型名称相同)。这是一个不寻常的情况,只有当这两种类型来自不同的程序集时才会发生。考虑以下示例。
库 1,编译到Widgets1.dll:
namespace Widgets
{
public class Widget {}
}
库 2,编译到Widgets2.dll:
namespace Widgets
{
public class Widget {}
}
应用程序,引用Widgets1.dll和Widgets2.dll:
using Widgets;
Widget w = new Widget();
应用程序无法编译,因为Widget存在歧义。外部别名可以解决这种歧义。第一步是修改应用程序的*.csproj*文件,为每个引用分配唯一别名:
<ItemGroup>
<Reference Include="Widgets1">
<Aliases>W1</Aliases>
</Reference>
<Reference Include="Widgets2">
<Aliases>W2</Aliases>
</Reference>
</ItemGroup>
第二步是使用 extern alias 指令:
extern alias W1;
extern alias W2;
W1.Widgets.Widget w1 = new W1.Widgets.Widget();
W2.Widgets.Widget w2 = new W2.Widgets.Widget();
命名空间别名限定符
正如我们之前提到的,内部命名空间中的名称会隐藏外部命名空间中的名称。然而,有时即使使用完全限定的类型名称也无法解决冲突。请考虑以下示例:
namespace N
{
class A
{
static void Main() => new A.B(); // Instantiate class B
public class B {} // Nested type
}
}
namespace A
{
class B {}
}
Main 方法可以实例化嵌套类 B,或者命名空间 A 中的类 B。编译器总是优先考虑当前命名空间中的标识符(在本例中是嵌套类 B)。
要解决此类冲突,可以对命名空间名称进行限定,相对于以下之一:
-
全局命名空间——所有命名空间的根(用上下文关键字
global标识) -
外部别名集合
:: 符号执行命名空间别名限定。在此示例中,我们使用全局命名空间进行限定(这在自动生成的代码中最常见,用于避免名称冲突):
namespace N
{
class A
{
static void Main()
{
System.Console.WriteLine (new A.B());
System.Console.WriteLine (new global::A.B());
}
public class B {}
}
}
namespace A
{
class B {}
}
这里有一个使用别名进行限定的示例(改编自 “Extern” 中的例子):
extern alias W1;
extern alias W2;
W1::Widgets.Widget w1 = new W1::Widgets.Widget();
W2::Widgets.Widget w2 = new W2::Widgets.Widget();
¹ 一个小的注意事项是,非常大的 long 值在转换为 double 时会失去一些精度。
² 从技术上讲,decimal 也是一种浮点类型,尽管在 C# 语言规范中没有这样称呼。
³ 可以重载这些运算符(参见第四章),使其返回非bool类型,但实际上这几乎从不会这样做。
⁴ 这个规则的一个例外是调用组件对象模型 (COM) 方法时。我们在第二十五章中讨论过这个问题。