《C#语法一篇通》,20万字,48小时阅读,持续完善中。。。

本文摘录了C#语法的主要内容,接近20万字。

所有鸡汤的味道都等于马尿!

如果你相信任何所谓的鸡汤文章,智商堪忧。

计算机语言没有"好不好"之说,骗子才会告诉你哪个语言好,学好任何一本基础语言(C,C++,C#,Java不含python),其他语言都是一天就可以搞定的。

学习任何东西,第一要知道哪些内容要先学,哪些内容后学。懂的,很容易就像俺一样的学霸。不懂的,就是学渣。

本文91.48%的内容不适合初学者。想学好编程,最好不要读这样的文章。成为一个真正的程序员,应该不看书,多写能解决问题程序,多写别人喜欢用的程序,要会搜索,会git。

本文属于闲了蛋疼抄来的。

01 C# 语言介绍

C# 语言是可以运行于 Windows/Linux与兼容系统,适用于 .NET 平台(免费的跨平台开源开发环境)的最流行与全面的语言。 C# 程序可以在许多不同的设备上运行,从物联网 (IoT) 设备到云以及介于两者之间的任何设备。 可为手机、台式机、笔记本电脑和服务器编写应用。

C# 是一种跨平台的通用语言,可以让开发人员在编写高性能代码时提高工作效率。 C# 是数百万开发人员中最受欢迎的 .NET 语言。 C# 在生态系统和所有 .NET 工作负载中具有广泛的支持。 基于面向对象的原则,它融合了其他范例中的许多功能,尤其是函数编程。 低级功能支持高效方案,无需编写不安全的代码。 大多数 .NET 运行时和库都是用 C# 编写的,C# 的进步通常会使所有 .NET 开发人员受益。

01.01 Hello world(看不看都行)

"Hello, World"程序历来都用于介绍编程语言。 下面展示了此程序的 C# 代码:

cs 复制代码
// This line prints "Hello, World" 
Console.WriteLine("Hello, World");

// 开头的行是单行注释。 C# 单行注释以 // 开头,持续到当前行的末尾。 C# 还支持多行注释。 多行注释以 /* 开头,以 */ 结尾。 System 命名空间中的 Console 类的 WriteLine 方法生成程序的输出。 此类由标准类库提供,默认情况下,每个 C# 程序中会自动引用这些库。

以上示例显示了"Hello,World"程序的一个窗体,其中使用了顶级语句。 早期版本的 C# 要求在方法中定义程序的入口点。 此格式仍然有效,你将在许多现有 C# 示例中看到它。 你也应该熟悉此窗体,如以下示例所示:

cs 复制代码
using System;

class Hello
{
    static void Main()
    {
        // This line prints "Hello, World" 
        Console.WriteLine("Hello, World");
    }
}

此版本显示了在程序中使用的构建基块。 "Hello, World"程序始于引用 System 命名空间的 using 指令。 命名空间提供了一种用于组织 C# 程序和库的分层方法。 命名空间包含类型和其他命名空间。例如,System 命名空间包含许多类型(如程序中引用的 Console 类)和其他许多命名空间(如 IOCollections)。 借助引用给定命名空间的 using 指令,可以非限定的方式使用作为相应命名空间成员的类型。 由于使用 using 指令,因此程序可以使用 Console.WriteLine 作为 System.Console.WriteLine 的简写。 在前面的示例中,该命名空间是隐式包含的。

"Hello, World"程序声明的 Hello 类只有一个成员,即 Main 方法。 Main 方法使用 static 修饰符进行声明。 实例方法可以使用关键字 this 引用特定的封闭对象实例,而静态方法则可以在不引用特定对象的情况下运行。 按照惯例,当没有顶级语句时,名为 Main 的静态方法将充当 C# 程序的入口点

这两个入口点窗体生成等效的代码。 使用顶级语句时,编译器会合成程序入口点的包含类和方法。

提示

本文中的示例帮助你初步了解 C# 代码。 某些示例可能会显示你不熟悉的 C# 元素。 当准备好学习 C# 时,请从我们的初学者教程开始,或通过每个部分中的链接学习深度知识。 如果你拥有 JavaJavaScriptTypeScriptPython 方面的经验,请阅读我们的提示,其中提供了快速学习 C# 所需的信息。

01.02 熟悉的 C# 功能(可以不读)

C# 对于初学者而言很容易上手,但同时也为经验丰富的专业应用程序开发人员提供了高级功能。 你很快就能提高工作效率。 你可以根据应用程序的需要学习更专业的技术。

C# 应用受益于 .NET 运行时的自动内存管理。 C# 应用还可以使用 .NET SDK 提供的丰富运行时库。 有些组件独立于平台,例如文件系统库、数据集合与数学库。 还有一些组件特定于单个工作负载,例如 ASP.NET Core Web 库或 .NET MAUI UI 库。 NuGet 的丰富开源生态系统增强了作为运行时一部分的库。 这些库提供更多可用的组件。

C# 属于 C 语言家族。 如果你使用过 C、C++、JavaScript 或 Java,那么也会熟悉 C# 语法。 与 C 语言家族中的所有语言一样,分号 (;) 定义语句的结束。 C# 标识符区分大小写。 C# 同样使用大括号({})、控制语句(例如 ifelseswitch)以及循环结构(例如 forwhile)。 C# 还具有适用于任何集合类型的 foreach 语句。

C# 是一种强类型语言。 声明的每个变量都有一个在编译时已知的类型。 编译器或编辑工具会告诉你是否错误地使用了该类型。 可以在运行程序之前修复这些错误。 以下基础数据类型内置于语言和运行时中:值类型(例如 intdoublechar)、引用类型(例如 string)、数组和其他集合。 编写程序时,你会创建自己的类型。 这些类型可以是值的 struct 类型,也可以是定义面向对象的行为的 class 类型。 可以将 record 修饰符添加到 structclass 类型,以便编译器合成用于执行相等性比较的代码。 还可以创建 interface 定义,用于定义实现该接口的类型必须提供的协定或一组成员。 还可以定义泛型类型和方法。 泛型使用类型参数为使用的实际类型提供占位符。

编写代码时,可以将函数(也称为方法)定义为 structclass 类型的成员。 这些方法定义类型的行为。 可以使用不同数量或类型的参数来重载方法。 方法可以选择性地返回一个值。 除了方法之外,C# 类型还可以带有属性,即由称作访问器的函数支持的数据元素。 C# 类型可以定义事件,从而允许类型向订阅者通知重要操作。 C# 支持面向对象的技术,例如 class 类型的继承和多形性。

C# 应用使用异常来报告和处理错误。 如果你使用过 C++ 或 Java,则也会熟悉这种做法。 当无法执行预期的操作时,代码会引发异常。 其他代码(无论位于调用堆栈上面的多少个级别)可以选择性地使用 try - catch 块进行恢复。

01.03 独特的 C# 功能(强烈建议掠过,你看不懂的)

你可能不太熟悉 C# 的某些元素。 语言集成查询 (LINQ) 提供一种基于模式的通用语法来查询或转换任何数据集合。 LINQ 统一了查询内存中集合、结构化数据(例如 XML 或 JSON)、数据库存储,甚至基于云的数据 API 的语法。 你只需学习一套语法即可搜索和操作数据,无论其存储在何处。 以下查询查找平均学分大于 3.5 的所有学生:

cs 复制代码
var honorRoll = from student in Students
                where student.GPA > 3.5
                select student;

上面的查询适用于 Students 表示的许多存储类型。 它可以是对象的集合、数据库表、云存储 Blob 或 XML 结构。 相同的查询语法适用于所有存储类型。

使用基于任务的异步编程模型,可以编写看起来像是同步运行的代码,即使它是异步运行的。 它利用 asyncawait 关键字来描述异步方法,以及表达式何时进行异步计算。 以下示例等待异步 Web 请求。 异步操作完成后,该方法返回响应的长度:

cs 复制代码
public static async Task<int> GetPageLengthAsync(string endpoint)
{
    var client = new HttpClient();
    var uri = new Uri(endpoint);
    byte[] content = await client.GetByteArrayAsync(uri);
    return content.Length;
}

C# 还支持使用 await foreach 语句来迭代由异步操作支持的集合,例如 GraphQL 分页 API。 以下示例以块的形式读取数据,并返回一个迭代器,该迭代器提供对每个可用元素的访问:

cs 复制代码
public static async IAsyncEnumerable<int> ReadSequence()
{
    int index = 0;
    while (index < 100)
    {
        int[] nextChunk = await GetNextChunk(index);
        if (nextChunk.Length == 0)
        {
            yield break;
        }
        foreach (var item in nextChunk)
        {
            yield return item;
        }
        index++;
    }
}

调用方可以使用 await foreach 语句迭代该集合:

cs 复制代码
await foreach (var number in ReadSequence())
{
    Console.WriteLine(number);
}

C# 提供模式匹配。 这些表达式使你能够检查数据并根据其特征做出决策。 模式匹配为基于数据的控制流提供了极好的语法。 以下代码演示如何使用模式匹配语法来表达布尔 and、or 和 xor 运算的方法:

cs 复制代码
public static bool Or(bool left, bool right) =>
    (left, right) switch
    {
        (true, true) => true,
        (true, false) => true,
        (false, true) => true,
        (false, false) => false,
    };

public static bool And(bool left, bool right) =>
    (left, right) switch
    {
        (true, true) => true,
        (true, false) => false,
        (false, true) => false,
        (false, false) => false,
    };
public static bool Xor(bool left, bool right) =>
    (left, right) switch
    {
        (true, true) => false,
        (true, false) => true,
        (false, true) => true,
        (false, false) => false,
    };

可以通过对任何值统一使用 _ 来简化模式匹配表达式。 以下示例演示如何简化 and 方法:

cs 复制代码
public static bool ReducedAnd(bool left, bool right) =>
    (left, right) switch
    {
        (true, true) => true,
        (_, _) => false,
    };

最后,作为 .NET 生态系统的一部分,你可以将 Visual StudioVisual Studio CodeC# DevKit 配合使用。 这些工具可以全方位地理解 C# 语言,包括你编写的代码。 它们还提供调试功能。

01.04 C# 标识符命名规则和约定

标识符是分配给类型(类、接口、结构、委托或枚举)、成员、变量或命名空间的名称。

01.04 命名规则(熟读一万遍,堆码如有神!)

有效标识符必须遵循以下规则。 C# 编译器针对不遵循以下规则的任何标识符生成错误:

  • 标识符必须以字母或下划线 (_) 开头。
  • 标识符可以包含 Unicode 字母字符、十进制数字字符、Unicode 连接字符、Unicode 组合字符或 Unicode 格式字符。 有关 Unicode 类别的详细信息,请参阅 Unicode 类别数据库

可以在标识符上使用 @ 前缀来声明与 C# 关键字匹配的标识符。 @ 不是标识符名称的一部分。 例如,@if 声明名为 if 的标识符。 这些逐字标识符主要用于与使用其他语言声明的标识符的互操作性。

有关有效标识符的完整定义,请参阅 C# 语言规范中的标识符一文

重要

C# 语言规范仅允许字母(Lu、Ll、Lt、Lm、Lo 或 Nl)、数字 (Nd)、连接 (Pc)、组合 (Mn 或 Mc) 和格式 (Cf) 类别。 除此之外的任何内容都会自动使用 _ 替换。 这可能会影响某些 Unicode 字符。

01.04.01 命名约定

除了规则之外,标识符名称的约定也在整个 .NET API 中使用。 这些约定为名称提供一致性,但编译器不会强制执行它们。 可以在项目中使用不同的约定。

按照约定,C# 程序对类型名称、命名空间和所有公共成员使用 PascalCase。 此外,dotnet/docs 团队使用从 .NET Runtime 团队的编码风格中吸收的以下约定:

  • 接口名称以大写字母 I 开头。

  • 属性类型以单词 Attribute 结尾。

  • 枚举类型对非标记使用单数名词,对标记使用复数名词。

  • 标识符不应包含两个连续的下划线 (_) 字符。 这些名称保留给编译器生成的标识符。

  • 对变量、方法和类使用有意义的描述性名称。

  • 清晰胜于简洁。。

  • 将 PascalCase 用于类名和方法名称。

  • 对方法参数和局部变量使用驼峰式大小写。

  • 将 PascalCase 用于常量名,包括字段和局部常量。

  • 专用实例字段以下划线 (_) 开头,其余文本为驼峰式大小写。

  • 静态字段以 s_ 开头。 此约定不是默认的 Visual Studio 行为,也不是框架设计准则的一部分,但在 editorconfig 中可配置

  • 避免在名称中使用缩写或首字母缩略词,但广为人知和广泛接受的缩写除外。

  • 使用遵循反向域名表示法的有意义的描述性命名空间。

  • 选择表示程序集主要用途的程序集名称。

  • 避免使用单字母名称,但简单循环计数器除外。 此外,描述 C# 构造的语法示例通常使用与 C# 语言规范中使用的约定相匹配的以下单字母名称。 语法示例是规则的例外。

    • S 用于结构,将 C 用于类。
    • M 用于方法。
    • v 用于变量,将 p 用于参数。
    • r 用于 ref 参数。

提示

可以使用代码样式命名规则强制实施涉及大写、前缀、后缀和单词分隔符的命名约定。

在下面的示例中,与标记为 public 的元素相关的指导也适用于使用 protectedprotected internal 元素的情况 - 所有这些元素旨在对外部调用方可见。

01.04.02 Pascal 大小写

在命名 classinterfacestructdelegate 类型时,使用 Pascal 大小写("PascalCasing")。

public class DataService
{
}

C#

public record PhysicalAddress(
    string Street,
    string City,
    string StateOrProvince,
    string ZipCode);

C#

public struct ValueCoordinate
{
}

C#

public delegate void DelegateType(string message);

命名 interface 时,使用 pascal 大小写并在名称前面加上前缀 I。 此前缀可以清楚地向使用者表明这是 interface

C#

public interface IWorkerQueue
{
}

在命名字段、属性和事件等类型的 public 成员时,使用 pascal 大小写。 此外,对所有方法和本地函数使用 pascal 大小写。

C#

public class ExampleEvents
{
    // A public field, these should be used sparingly
    public bool IsValid;

    // An init-only property
    public IWorkerQueue WorkerQueue { get; init; }

    // An event
    public event Action EventProcessing;

    // Method
    public void StartEventProcessing()
    {
        // Local function
        static int CountQueueItems() => WorkerQueue.Count;
        // ...
    }
}

编写位置记录时,对参数使用 pascal 大小写,因为它们是记录的公共属性。

C#

public record PhysicalAddress(
    string Street,
    string City,
    string StateOrProvince,
    string ZipCode);

有关位置记录的详细信息,请参阅属性定义的位置语法

01.04.03 驼峰式大小写

在命名 privateinternal 字段时,使用驼峰式大小写("camelCasing"),并对它们添加 _ 作为前缀。 命名局部变量(包括委托类型的实例)时,请使用驼峰式大小写。

C#

public class DataService
{
    private IWorkerQueue _workerQueue;
}

提示

在支持语句完成的 IDE 中编辑遵循这些命名约定的 C# 代码时,键入 _ 将显示所有对象范围的成员。

使用为 privateinternalstatic 字段时 请使用 s_ 前缀,对于线程静态,请使用 t_

C#

public class DataService
{
    private static IWorkerQueue s_workerQueue;

    [ThreadStatic]
    private static TimeSpan t_timeSpan;
}

编写方法参数时,请使用驼峰式大小写。

C#

public T SomeMethod<T>(int someNumber, bool isValid)
{
}

有关 C# 命名约定的详细信息,请参阅 .NET Runtime 团队的编码样式

01.04.04 类型参数命名指南

以下准则适用于泛型类型参数上的类型参数。 类型参数是泛型类型或泛型方法中参数的占位符。 可以在 C# 编程指南中详细了解 泛型类型参数

  • 请使用描述性名称命名泛型类型参数,除非单个字母名称完全具有自我说明性且描述性名称不会增加任何作用。

    ./snippets/coding-conventions复制

    public interface ISessionChannel<TSession> { /*...*/ }
    public delegate TOutput Converter<TInput, TOutput>(TInput from);
    public class List<T> { /*...*/ }
    
  • 对具有单个字母类型参数的类型,考虑 使用 T 作为类型参数名称。

    ./snippets/coding-conventions复制

    public int IComparer<T>() { return 0; }
    public delegate bool Predicate<T>(T item);
    public struct Nullable<T> where T : struct { /*...*/ }
    
  • 在类型参数描述性名称前添加前缀 "T"。

    ./snippets/coding-conventions复制

    public interface ISessionChannel<TSession>
    {
        TSession Session { get; }
    }
    
  • 请考虑在参数名称中指示出类型参数的约束。 例如,约束为 ISession 的参数可命名为 TSession

可以使用代码分析规则 CA1715 确保恰当地命名类型参数。

01.04.05 额外的命名约定

  • 在不包括 using 指令的示例中,使用命名空间限定。 如果你知道命名空间默认导入项目中,则不必完全限定来自该命名空间的名称。 如果对于单行来说过长,则可以在点 (.) 后中断限定名称,如下面的示例所示。

    C#

    var currentPerformanceCounterCategory = new System.Diagnostics.
        PerformanceCounterCategory();
    
  • 你不必更改使用 Visual Studio 设计器工具创建的对象的名称以使它们适合其他准则。

01.05 常见 C# 代码约定

编码约定对于在开发团队中维护代码可读性、一致性和协作至关重要。 遵循行业实践和既定准则的代码更易于理解、维护和扩展。 大多数项目通过代码约定强制要求样式一致。 dotnet/docsdotnet/samples 项目并不例外。 在本系列文章中,你将了解我们的编码约定和用于强制实施这些约定的工具。 你可以按原样采用我们的约定,或修改它们以满足团队的需求。

我们对约定的选择基于以下目标:

  1. 正确性:我们的示例将会复制并粘贴到你的应用程序中。 我们希望如此,因此我们需要代码具有复原能力且正确无误,即使在多次编辑之后也是如此。
  2. 教学:示例的目的是教授 .NET 和 C# 的全部内容。 因此,我们不会对任何语言功能或 API 施加限制。 相反,这些示例会告知某个功能在何时会是良好的选择。
  3. 一致性:读者期望我们的内容提供一致的体验。 所有示例应遵循相同的样式。
  4. 采用:我们积极更新示例以使用新的语言功能。 这种做法提高了对新功能的认识,并且提高了所有 C# 开发人员对这些功能的熟悉程度。

重要

Microsoft 会使用这些准则来开发示例和文档。 它们摘自 .NET 运行时、C# 编码样式C# 编译器 (roslyn) 准则。 我们选择这些准则是因为它们已经经过了多年开放源代码开发的测试。 他们帮助社区成员参与运行时和编译器项目。 它们是常见 C# 约定的示例,而不是权威列表(有关此内容,请参阅框架设计指南)。

教学采用 目标是文档编码约定不同于运行时和编译器约定的原因。 运行时和编译器对热路径具有严格的性能指标。 许多其他应用程序则并非如此。 我们的教学 目标要求我们不会禁止任何构造。 相反,示例显示了何时应使用构造。 与大多数生产应用程序相比,我们在更新示例方面更加积极。 我们的采用目标要求我们显示你目前应该编写的代码,即使去年编写的代码无需更改。

本文将对我们的准则进行说明。 这些准则已随时间推移发生变化,因此,你会发现并不遵循准则的示例。 我们欢迎推动这些示例合规的 PR,或促使我们关注应更新的示例的问题。 我们的准则是开放源代码的,因此我们欢迎 PR 和问题。 但如果你的提交将更改这些建议,请先提出一个问题以供讨论。 欢迎使用我们的准则,或根据你的需求对其进行调整。

01.05.01 工具和分析器(非初学者的菜!)

工具可帮助团队强制实施约定。 可以启用代码分析来强制实施你偏好的规则。 还可以创建 editorconfig,以便 Visual Studio 可自动强制实施样式准则。 作为起点,可以复制 dotnet/docs 存储库的文件以使用我们的样式。

借助这些工具,团队可以更轻松地采用首选的准则。 Visual Studio 将在范围中的所有 .editorconfig 文件中应用规则,以设置代码的格式。 可以使用多个配置来强制实施企业范围的约定、团队约定,甚至精细的项目约定。

启用的规则被违反时,代码分析会生成警告和诊断。 可以配置想要应用于项目的规则。 然后,每个 CI 生成会在违反任何规则时通知开发人员。

01.05.02 语言准则

以下部分介绍了 .NET 文档团队在准备代码示例和示例时遵循的做法。 一般情况下,请遵循以下做法:

  • 尽可能利用新式语言功能和 C# 版本。
  • 避免陈旧或过时的语言构造。
  • 仅捕获可以正确处理的异常;避免捕获泛型异常。
  • 使用特定的异常类型提供有意义的错误消息。
  • 使用 LINQ 查询和方法进行集合操作,以提高代码可读性。
  • 将异步编程与异步和等待用于 I/O 绑定操作。
  • 请谨慎处理死锁,并在适当时使用 Task.ConfigureAwait
  • 对数据类型而不是运行时类型使用语言关键字。 例如,使用 string 而不是 System.String,或使用 int 而不是 System.Int32
  • 使用 int 而不是无符号类型。 int 的使用在整个 C# 中很常见,并且当你使用 int 时,更易于与其他库交互。 特定于无符号数据类型的文档例外。。
  • 仅当读者可以从表达式推断类型时使用 var。 读者可在文档平台上查看我们的示例。 它们没有悬停或显示变量类型的工具提示。
  • 以简洁明晰的方式编写代码。
  • 避免过于复杂和费解的代码逻辑。

遵循更具体的准则。

01.05.03 字符串数据

  • 使用字符串内插来连接短字符串,如下面的代码所示。

    C#

    string displayName = $"{nameList[n].LastName}, {nameList[n].FirstName}";
    
  • 若要在循环中追加字符串,尤其是在使用大量文本时,请使用 System.Text.StringBuilder 对象。

    C#

    cs 复制代码
    var phrase = "lalalalalalalalalalalalalalalalalalalalalalalalalalalalalala";
    var manyPhrases = new StringBuilder();
    for (var i = 0; i < 10000; i++)
    {
        manyPhrases.Append(phrase);
    }
    //Console.WriteLine("tra" + manyPhrases);

01.05.04 数组

  • 当在声明行上初始化数组时,请使用简洁的语法。 在以下示例中,不能使用 var 替代 string[]

C#

string[] vowels1 = { "a", "e", "i", "o", "u" };
  • 如果使用显式实例化,则可以使用 var

C#

var vowels2 = new string[] { "a", "e", "i", "o", "u" };

01.05.05 委托(非初学者的菜!)

  • 使用 Func<> 和 Action<>,而不是定义委托类型。 在类中,定义委托方法。

C#

Action<string> actionExample1 = x => Console.WriteLine($"x is: {x}");

Action<string, string> actionExample2 = (x, y) =>
    Console.WriteLine($"x is: {x}, y is {y}");

Func<string, int> funcExample1 = x => Convert.ToInt32(x);

Func<int, int, int> funcExample2 = (x, y) => x + y;
  • 使用 Func<>Action<> 委托定义的签名来调用方法。

C#

actionExample1("string for x");

actionExample2("string for x", "string for y");

Console.WriteLine($"The value is {funcExample1("1")}");

Console.WriteLine($"The sum is {funcExample2(1, 2)}");
  • 如果创建委托类型的实例,请使用简洁的语法。 在类中,定义委托类型和具有匹配签名的方法。

    C#

    public delegate void Del(string message);
    
    public static void DelMethod(string str)
    {
        Console.WriteLine("DelMethod argument: {0}", str);
    }
    
  • 创建委托类型的实例,然后调用该实例。 以下声明显示了紧缩的语法。

    C#

    Del exampleDel2 = DelMethod;
    exampleDel2("Hey");
    
  • 以下声明使用了完整的语法。

    C#

    Del exampleDel1 = new Del(DelMethod);
    exampleDel1("Hey");
    

01.05.06 try-catchusing 语句正在异常处理中

  • 对大多数异常处理使用 try-catch 语句。

    C#

    static double ComputeDistance(double x1, double y1, double x2, double y2)
    {
        try
        {
            return Math.Sqrt((x1 - x2) * (x1 - x2) + (y1 - y2) * (y1 - y2));
        }
        catch (System.ArithmeticException ex)
        {
            Console.WriteLine($"Arithmetic overflow or underflow: {ex}");
            throw;
        }
    }
    
  • 通过使用 C# using 语句简化你的代码。 如果具有 try-finally 语句(该语句中 finally 块的唯一代码是对 Dispose 方法的调用),请使用 using 语句代替。

    在以下示例中,try-finally 语句仅在 finally 块中调用 Dispose

    C#

    Font bodyStyle = new Font("Arial", 10.0f);
    try
    {
        byte charset = bodyStyle.GdiCharSet;
    }
    finally
    {
        if (bodyStyle != null)
        {
            ((IDisposable)bodyStyle).Dispose();
        }
    }
    

    可以使用 using 语句执行相同的操作。

    C#

    using (Font arial = new Font("Arial", 10.0f))
    {
        byte charset2 = arial.GdiCharSet;
    }
    

    使用不需要大括号的新 using 语法

    C#

    using Font normalStyle = new Font("Arial", 10.0f);
    byte charset3 = normalStyle.GdiCharSet;
    

01.05.07 &&|| 运算符

  • 在执行比较时,使用 && 而不是 &,使用 || 而不是 |,如以下示例所示。

    C#

    cs 复制代码
    Console.Write("Enter a dividend: ");
    int dividend = Convert.ToInt32(Console.ReadLine());
    
    Console.Write("Enter a divisor: ");
    int divisor = Convert.ToInt32(Console.ReadLine());
    
    if ((divisor != 0) && (dividend / divisor) is var result)
    {
        Console.WriteLine("Quotient: {0}", result);
    }
    else
    {
        Console.WriteLine("Attempted division by 0 ends up here.");
    }

如果除数为 0,则 if 语句中的第二个子句将导致运行时错误。 但是,当第一个表达式为 false 时,&& 运算符将发生短路。 也就是说,它并不评估第二个表达式。 如果 divisor 为 0,则 & 运算符将同时计算这两个表达式,从而导致运行时错误。

01.05.08 new 运算符

  • 使用对象实例化的简洁形式之一,如以下声明中所示。

    C#

    var firstExample = new ExampleClass();
    

    C#

    ExampleClass instance2 = new();
    

    前面的声明等效于下面的声明。

    C#

    ExampleClass secondExample = new ExampleClass();
    
  • 使用对象初始值设定项简化对象创建,如以下示例中所示。

    C#

    var thirdExample = new ExampleClass { Name = "Desktop", ID = 37414,
        Location = "Redmond", Age = 2.3 };
    

    下面的示例设置了与前面的示例相同的属性,但未使用初始值设定项。

    C#

    var fourthExample = new ExampleClass();
    fourthExample.Name = "Desktop";
    fourthExample.ID = 37414;
    fourthExample.Location = "Redmond";
    fourthExample.Age = 2.3;
    

01.05.09 事件处理

  • 使用 lambda 表达式定义稍后无需移除的事件处理程序:

C#

public Form2()
{
    this.Click += (s, e) =>
        {
            MessageBox.Show(
                ((MouseEventArgs)e).Location.ToString());
        };
}

Lambda 表达式缩短了以下传统定义。

C#

public Form1()
{
    this.Click += new EventHandler(Form1_Click);
}

void Form1_Click(object? sender, EventArgs e)
{
    MessageBox.Show(((MouseEventArgs)e).Location.ToString());
}

01.05.10 静态成员

使用类名调用 static 成员:ClassName.StaticMember。 这种做法通过明确静态访问使代码更易于阅读。 请勿使用派生类的名称来限定基类中定义的静态成员。 编译该代码时,代码可读性具有误导性,如果向派生类添加具有相同名称的静态成员,代码可能会被破坏。

01.05.11 LINQ 查询

  • 对查询变量使用有意义的名称。 下面的示例为位于西雅图的客户使用 seattleCustomers

    C#

    var seattleCustomers = from customer in customers
                           where customer.City == "Seattle"
                           select customer.Name;
    
  • 使用别名确保匿名类型的属性名称都使用 Pascal 大小写格式正确大写。

    C#

    var localDistributors =
        from customer in customers
        join distributor in distributors on customer.City equals distributor.City
        select new { Customer = customer, Distributor = distributor };
    
  • 如果结果中的属性名称模棱两可,请对属性重命名。 例如,如果你的查询返回客户名称和分销商 ID,而不是在结果中将它们保留为 NameID,请对它们进行重命名以明确 Name 是客户的名称,ID 是分销商的 ID。

    C#

    var localDistributors2 =
        from customer in customers
        join distributor in distributors on customer.City equals distributor.City
        select new { CustomerName = customer.Name, DistributorID = distributor.ID };
    
  • 在查询变量和范围变量的声明中使用隐式类型化。 有关 LINQ 查询中隐式类型的本指导会替代适用于隐式类型本地变量的一般规则。 LINQ 查询通常使用创建匿名类型的投影。 其他查询表达式使用嵌套泛型类型创建结果。 隐式类型变量通常更具可读性。

    C#

    var seattleCustomers = from customer in customers
                           where customer.City == "Seattle"
                           select customer.Name;
    
  • 对齐 from 子句下的查询子句,如上面的示例所示。

  • 在其他查询子句前面使用 where 子句,确保后面的查询子句作用于经过缩减和筛选的一组数据。

    C#

    var seattleCustomers2 = from customer in customers
                            where customer.City == "Seattle"
                            orderby customer.Name
                            select customer;
    
  • 使用多行 from 子句代替 join 子句来访问内部集合。 例如,Student 对象的集合可能包含测验分数的集合。 当执行以下查询时,它返回高于 90 的分数,并返回得到该分数的学生的姓氏。

    C#

    var scoreQuery = from student in students
                     from score in student.Scores!
                     where score > 90
                     select new { Last = student.LastName, score };
    

01.05.12 隐式类型本地变量

  • 当变量的类型在赋值右侧比较明显时,对局部变量使用隐式类型

    C#

    var message = "This is clearly a string.";
    var currentTemperature = 27;
    
  • 当类型在赋值右侧不明显时,请勿使用 var。 请勿假设类型明显来自方法名称。 如果变量类型是 new 运算符、对文本值的显式强制转换或赋值,则将其视为明确的变量类型。

    C#

    int numberOfIterations = Convert.ToInt32(Console.ReadLine());
    int currentMaximum = ExampleClass.ResultSoFar();
    
  • 不要使用变量名称指定变量的类型。 它可能不正确。 请改用类型来指定类型,并使用变量名称来指示变量的语义信息。 以下示例应对类型使用 string,并使用类似 iterations 的内容指示从控制台读取的信息的含义。

    C#

    var inputInt = Console.ReadLine();
    Console.WriteLine(inputInt);
    
  • 避免使用 var 来代替 dynamic。 如果想要进行运行时类型推理,请使用 dynamic。 有关详细信息,请参阅使用类型 dynamic(C# 编程指南)

  • for 循环中对循环变量使用隐式类型。

    下面的示例在 for 语句中使用隐式类型化。

    C#

    var phrase = "lalalalalalalalalalalalalalalalalalalalalalalalalalalalalala";
    var manyPhrases = new StringBuilder();
    for (var i = 0; i < 10000; i++)
    {
        manyPhrases.Append(phrase);
    }
    //Console.WriteLine("tra" + manyPhrases);
    
  • 不要使用隐式类型化来确定 foreach 循环中循环变量的类型。 在大多数情况下,集合中的元素类型并不明显。 不应仅依靠集合的名称来推断其元素的类型。

    下面的示例在 foreach 语句中使用显式类型化。

    C#

    cs 复制代码
    foreach (char ch in laugh)
    {
        if (ch == 'h')
        {
            Console.Write("H");
        }
        else
        {
            Console.Write(ch);
        }
    }
    Console.WriteLine();
  • 对 LINQ 查询中的结果序列使用隐式类型。 关于 LINQ 的部分说明了许多 LINQ 查询会导致必须使用隐式类型的匿名类型。 其他查询则会产生嵌套泛型类型,其中 var 的可读性更高。

    备注

    注意不要意外更改可迭代集合的元素类型。 例如,在 foreach 语句中从 System.Linq.IQueryable 切换到 System.Collections.IEnumerable 很容易,这会更改查询的执行。

我们的一些示例解释了表达式的自然类型 。 这些示例必须使用 var,以便编译器选取自然类型。 即使这些示例不太明显,但示例必须使用 var。 文本应解释该行为。

01.05.13 将 using 指令放在命名空间声明之外

using 指令位于命名空间声明之外时,该导入的命名空间是其完全限定的名称。 完全限定的名称更加清晰。 如果 using 指令位于命名空间内部,则它可以是相对于该命名空间的,也可以是它的完全限定名称。

cs 复制代码
using Azure;

namespace CoolStuff.AwesomeFeature
{
    public class Awesome
    {
        public void Stuff()
        {
            WaitUntil wait = WaitUntil.Completed;
            // ...
        }
    }
}

假设存在对 WaitUntil 类的引用(直接或间接)。

现在,让我们稍作改动:

cs 复制代码
namespace CoolStuff.AwesomeFeature
{
    using Azure;

    public class Awesome
    {
        public void Stuff()
        {
            WaitUntil wait = WaitUntil.Completed;
            // ...
        }
    }
}

今天的编译成功了。 明天的也没问题。 但在下周的某个时候,前面(未改动)的代码失败,并出现两个错误:

- error CS0246: The type or namespace name 'WaitUntil' could not be found (are you missing a using directive or an assembly reference?)
- error CS0103: The name 'WaitUntil' does not exist in the current context

其中一个依赖项已在命名空间中引入了此类,然后以 .Azure 结尾:

cs 复制代码
namespace CoolStuff.Azure
{
    public class SecretsManagement
    {
        public string FetchFromKeyVault(string vaultId, string secretId) { return null; }
    }
}

放置在命名空间中的 using 指令与上下文相关,使名称解析复杂化。 在此示例中,它是它找到的第一个命名空间。

  • CoolStuff.AwesomeFeature.Azure
  • CoolStuff.Azure
  • Azure

添加匹配 CoolStuff.AzureCoolStuff.AwesomeFeature.Azure 的新命名空间将在全局 Azure 命名空间前匹配。 可以通过向 using 声明添加 global:: 修饰符来解决此问题。 但是,改为将 using 声明放在命名空间之外更容易。

cs 复制代码
namespace CoolStuff.AwesomeFeature
{
    using global::Azure;

    public class Awesome
    {
        public void Stuff()
        {
            WaitUntil wait = WaitUntil.Completed;
            // ...
        }
    }
}

01.06 编码样式指南

一般情况下,对代码示例使用以下格式:

  • 使用四个空格缩进。 不要使用选项卡。
  • 一致地对齐代码以提高可读性。
  • 将行限制为 65 个字符,以增强文档上的代码可读性,尤其是在移动屏幕上。
  • 将长语句分解为多行以提高清晰度。
  • 对大括号使用"Allman"样式:左和右大括号另起一行。 大括号与当前缩进级别对齐。
  • 如有必要,应在二进制运算符之前换行。

01.06.01 注释样式

  • 使用单行注释(//)以进行简要说明。

  • 避免使用多行注释(/* */)来进行较长的解释。

    代码示例中的注释未本地化。 这意味着不会翻译代码中嵌入的说明。 较长的解释性文本应放在配套文章中,以便对其进行本地化。

  • 若要描述方法、类、字段和所有公共成员,请使用 XML 注释

  • 将注释放在单独的行上,而非代码行的末尾。

  • 以大写字母开始注释文本。

  • 以句点结束注释文本。

  • 在注释分隔符 (//) 与注释文本之间插入一个空格,如下面的示例所示。

    C#复制

    // The following declaration creates a query. It does not run
    // the query.
    

01.06.02 布局约定

好的布局利用格式设置来强调代码的结构并使代码更便于阅读。 Microsoft 示例和样本符合以下约定:

  • 使用默认的代码编辑器设置(智能缩进、4 字符缩进、制表符保存为空格)。 有关详细信息,请参阅选项、文本编辑器、C#、格式设置

  • 每行只写一条语句。

  • 每行只写一个声明。

  • 如果连续行未自动缩进,请将它们缩进一个制表符位(四个空格)。

  • 在方法定义与属性定义之间添加至少一个空白行。

  • 使用括号突出表达式中的子句,如下面的代码所示。

    cs 复制代码
    if ((startX > endX) && (startX > previousX))
    {
        // Take appropriate action.
    }

例外情况出现在示例解释运算符或表达式优先级时。

02 C# 程序的通用结构

C# 程序由一个或多个文件组成。 每个文件均包含零个或多个命名空间。 一个命名空间包含类、结构、接口、枚举、委托等类型或其他命名空间。 以下示例是包含所有这些元素的 C# 程序主干。

cs 复制代码
// A skeleton of a C# program
using System;

// Your program starts here:
Console.WriteLine("Hello world!");

namespace YourNamespace
{
    class YourClass
    {
    }

    struct YourStruct
    {
    }

    interface IYourInterface
    {
    }

    delegate int YourDelegate();

    enum YourEnum
    {
    }

    namespace YourNestedNamespace
    {
        struct YourStruct
        {
        }
    }
}

前面的示例使用顶级语句作为程序的入口点。 C# 9 中添加了此功能。 在 C# 9 之前,入口点是名为 Main 的静态方法,如以下示例所示:

cs 复制代码
// A skeleton of a C# program
using System;
namespace YourNamespace
{
    class YourClass
    {
    }

    struct YourStruct
    {
    }

    interface IYourInterface
    {
    }

    delegate int YourDelegate();

    enum YourEnum
    {
    }

    namespace YourNestedNamespace
    {
        struct YourStruct
        {
        }
    }

    class Program
    {
        static void Main(string[] args)
        {
            //Your program starts here...
            Console.WriteLine("Hello world!");
        }
    }
}

02.01 Main() 和命令行参数(扫一眼即可!)

Main 方法是 C# 应用程序的入口点。 Main 方法是应用程序启动后调用的第一个方法。

C# 程序中只能有一个入口点。 如果多个类包含 Main 方法,必须使用 StartupObject 编译器选项来编译程序,以指定将哪个 Main 方法用作入口点。 有关详细信息,请参阅 StartupObject(C# 编译器选项)

cs 复制代码
class TestClass
{
    static void Main(string[] args)
    {
        // Display the number of command line arguments.
        Console.WriteLine(args.Length);
    }
}

还可以在一个文件中使用顶级语句作为应用程序的入口点。 与 Main 方法一样,顶级语句还可以返回值和访问命令行参数。 有关详细信息,请参阅顶级语句

cs 复制代码
using System.Text;

StringBuilder builder = new();
builder.AppendLine("The following arguments are passed:");

// Display the command line arguments using the args variable.
foreach (var arg in args)
{
    builder.AppendLine($"Argument={arg}");
}

Console.WriteLine(builder.ToString());

// Return a success code.
return 0;

02.02 Main概述(扫两眼即可!)

  • Main 方法是可执行程序的入口点,也是程序控制开始和结束的位置。
  • Main 必须在类或结构中进行声明。 封闭 class 可以是 static
  • Main 必须为 static
  • Main 可以具有任何访问修饰符file 除外)。
  • Main 的返回类型可以是 voidintTaskTask<int>
  • 当且仅当 Main 返回 TaskTask<int> 时,Main 的声明可包括 async 修饰符。 这明确排除了 async void Main 方法。
  • 使用或不使用包含命令行自变量的 string[] 参数声明 Main 方法都行。 使用 Visual Studio 创建 Windows 应用程序时,可以手动添加此形参,也可以使用 GetCommandLineArgs() 方法来获取命令行实参。 参数被读取为从零开始编制索引的命令行自变量。 与 C 和 C++ 不同,程序的名称不被视为 args 数组中的第一个命令行实参,但它是 GetCommandLineArgs() 方法中的第一个元素。

以下列表显示了最常见的 Main 声明:

cs 复制代码
static void Main() { }
static int Main() { }
static void Main(string[] args) { }
static int Main(string[] args) { }
static async Task Main() { }
static async Task<int> Main() { }
static async Task Main(string[] args) { }
static async Task<int> Main(string[] args) { }

前面的示例未指定访问修饰符,因此默认为隐式 private。 这是典型的,但可以指定任何显式访问修饰符。

提示

添加 asyncTaskTask<int> 返回类型可简化控制台应用程序需要启动时的程序代码,以及 Main 中的 await 异步操作。

02.03 Main() 返回值(没什么用!)

可以通过以下方式之一定义方法,以从 Main 方法返回 int

Main 声明 Main 方法代码
static int Main() 不使用 argsawait
static int Main(string[] args) 使用 args,不使用 await
static async Task<int> Main() 不使用 args,使用 await
static async Task<int> Main(string[] args) 使用 argsawait

如果不使用 Main 的返回值,则返回 voidTask 可使代码变得略微简单。

展开表

Main 声明 Main 方法代码
static void Main() 不使用 argsawait
static void Main(string[] args) 使用 args,不使用 await
static async Task Main() 不使用 args,使用 await
static async Task Main(string[] args) 使用 argsawait

但是,返回 intTask<int> 可使程序将状态信息传递给调用可执行文件的其他程序或脚本。

下面的示例演示了如何访问进程的退出代码。

此示例使用 .NET Core 命令行工具。 如果不熟悉 .NET Core 命令行工具,可通过本入门文章进行了解。

通过运行 dotnet new console 创建新的应用程序。 修改 Program.cs 中的 Main 方法,如下所示:

cs 复制代码
// Save this program as MainReturnValTest.cs.
class MainReturnValTest
{
    static int Main()
    {
        //...
        return 0;
    }
}

在 Windows 中执行程序时,从 Main 函数返回的任何值都存储在环境变量中。 可使用批处理文件中的 ERRORLEVEL 或 PowerShell 中的 $LastExitCode 来检索此环境变量。

可使用 dotnet CLI dotnet build 命令构建应用程序。

接下来,创建一个 PowerShell 脚本来运行应用程序并显示结果。 将以下代码粘贴到文本文件中,并在包含该项目的文件夹中将其另存为 test.ps1。 可通过在 PowerShell 提示符下键入 test.ps1 来运行 PowerShell 脚本。

因为代码返回零,所以批处理文件将报告成功。 但是,如果将 MainReturnValTest.cs 更改为返回非零值,然后重新编译程序,则 PowerShell 脚本的后续执行将报告为失败。

cs 复制代码
dotnet run
if ($LastExitCode -eq 0) {
    Write-Host "Execution succeeded"
} else
{
    Write-Host "Execution Failed"
}
Write-Host "Return value = " $LastExitCode

输出

Execution succeeded
Return value = 0

02.04 Async Main 返回值(不看!别看!)

声明 Mainasync 返回值时,编译器会生成样本代码,用于调用 Main 中的异步方法。 如果未指定 async 关键字,则需要自行编写该代码,如以下示例所示。 示例中的代码可确保程序一直运行,直到异步操作完成:

cs 复制代码
class AsyncMainReturnValTest
{
    public static int Main()
    {
        return AsyncConsoleWork().GetAwaiter().GetResult();
    }

    private static async Task<int> AsyncConsoleWork()
    {
        // Main body here
        return 0;
    }
}

该样本代码可替换为:

cs 复制代码
class Program
{
    static async Task<int> Main(string[] args)
    {
        return await AsyncConsoleWork();
    }

    private static async Task<int> AsyncConsoleWork()
    {
        // main body here 
        return 0;
    }
}

Main 声明为 async 的优点是,编译器始终生成正确的代码。

当应用程序入口点返回 TaskTask<int> 时,编译器生成一个新的入口点,该入口点调用应用程序代码中声明的入口点方法。 假设此入口点名为 $GeneratedMain,编译器将为这些入口点生成以下代码:

  • static Task Main() 导致编译器发出 private static void $GeneratedMain() => Main().GetAwaiter().GetResult(); 的等效项
  • static Task Main(string[]) 导致编译器发出 private static void $GeneratedMain(string[] args) => Main(args).GetAwaiter().GetResult(); 的等效项
  • static Task<int> Main() 导致编译器发出 private static int $GeneratedMain() => Main().GetAwaiter().GetResult(); 的等效项
  • static Task<int> Main(string[]) 导致编译器发出 private static int $GeneratedMain(string[] args) => Main(args).GetAwaiter().GetResult(); 的等效项

备注

如果示例在 Main 方法上使用 async 修饰符,则编译器将生成相同的代码。

02.05 命令行自变量(快速粗看,走驴看粪!)

可以通过以下方式之一定义方法来将自变量发送到 Main 方法:

Main 声明 Main 方法代码
static void Main(string[] args) 无返回值,不使用 await
static int Main(string[] args) 返回值,不使用 await
static async Task Main(string[] args) 无返回值,使用 await
static async Task<int> Main(string[] args) 返回值,使用 await

如果不使用参数,可以从方法声明中省略 args,使代码更为简单:

Main 声明 Main 方法代码
static void Main() 无返回值,不使用 await
static int Main() 返回值,不使用 await
static async Task Main() 无返回值,使用 await
static async Task<int> Main() 返回值,使用 await

备注

还可使用 Environment.CommandLineEnvironment.GetCommandLineArgs 从控制台或 Windows 窗体应用程序的任意位置访问命令行参数。 若要在 Windows 窗体应用程序的 Main 方法中启用命令行参数,必须手动修改 Main 的声明。 Windows 窗体设计器生成的代码创建没有输入参数的 Main

Main 方法的参数是一个表示命令行参数的 String 数组。 通常,通过测试 Length 属性来确定参数是否存在,例如:

cs 复制代码
if (args.Length == 0)
{
    System.Console.WriteLine("Please enter a numeric argument.");
    return 1;
}

提示

args 数组不能为 null。 因此,无需进行 null 检查即可放心地访问 Length 属性。

还可以使用 Convert 类或 Parse 方法将字符串参数转换为数字类型。 例如,以下语句使用 Parse 方法将 string 转换为 long 数字:

cs 复制代码
long num = Int64.Parse(args[0]);

也可以使用 C# 类型 long,其别名为 Int64

cs 复制代码
long num = long.Parse(args[0]);

还可以使用 Convert 类方法 ToInt64 来执行同样的操作:

cs 复制代码
long num = Convert.ToInt64(s);

有关详细信息,请参阅 ParseConvert

提示:本段,以下不看。

分析命令行参数可能比较复杂。 请考虑使用 System.CommandLine 库(目前为 beta 版)来简化该过程。

以下示例演示如何在控制台应用程序中使用命令行参数。 应用程序在运行时获取一个参数,将该参数转换为整数,并计算数字的阶乘。 如果未提供任何参数,则应用程序会发出一条消息,说明程序的正确用法。

若要在命令提示符下编译并运行该应用程序,请按照下列步骤操作:

  1. 将以下代码粘贴到任何文本编辑器,然后将该文件保存为名为"Factorial.cs"的文本文件。

    cs 复制代码
    public class Functions
    {
        public static long Factorial(int n)
        {
            // Test for invalid input.
            if ((n < 0) || (n > 20))
            {
                return -1;
            }
    
            // Calculate the factorial iteratively rather than recursively.
            long tempResult = 1;
            for (int i = 1; i <= n; i++)
            {
                tempResult *= i;
            }
            return tempResult;
        }
    }
    
    class MainClass
    {
        static int Main(string[] args)
        {
            // Test if input arguments were supplied.
            if (args.Length == 0)
            {
                Console.WriteLine("Please enter a numeric argument.");
                Console.WriteLine("Usage: Factorial <num>");
                return 1;
            }
    
            // Try to convert the input arguments to numbers. This will throw
            // an exception if the argument is not a number.
            // num = int.Parse(args[0]);
            int num;
            bool test = int.TryParse(args[0], out num);
            if (!test)
            {
                Console.WriteLine("Please enter a numeric argument.");
                Console.WriteLine("Usage: Factorial <num>");
                return 1;
            }
    
            // Calculate factorial.
            long result = Functions.Factorial(num);
    
            // Print result.
            if (result == -1)
                Console.WriteLine("Input must be >= 0 and <= 20.");
            else
                Console.WriteLine($"The Factorial of {num} is {result}.");
    
            return 0;
        }
    }
    // If 3 is entered on command line, the
    // output reads: The factorial of 3 is 6.
  2. 从"开始"屏幕或"开始"菜单中,打开 Visual Studio"开发人员命令提示"窗口,然后导航到包含你创建的文件的文件夹。

  3. 输入以下命令以编译应用程序。

    dotnet build

    如果应用程序不存在编译错误,则会创建一个名为"Factorial.exe"的可执行文件。

  4. 输入以下命令以计算 3 的阶乘:

    dotnet run -- 3

  5. 该命令将生成以下输出:The factorial of 3 is 6.

备注

在 Visual Studio 中运行应用程序时,可在"项目设计器"->"调试"页中指定命令行参数。

03 顶级语句 - 不使用 Main 方法的程序(本节下面的几乎是狗屁!)

无需在控制台应用程序项目中显式包含 Main 方法。 相反,可以使用顶级语句功能最大程度地减少必须编写的代码。

使用顶级语句可直接在文件的根目录中编写可执行代码,而无需在类或方法中包装代码。 这意味着无需使用 Program 类和 Main 方法即可创建程序。 在这种情况下,编译器将使用入口点方法为应用程序生成 Program 类。 生成方法的名称不是 Main,而是你的代码无法直接引用的实现详细信息。

下面是一个 Program.cs 文件看,它是 C# 10 中的一个完整 C# 程序:

cs 复制代码
Console.WriteLine("Hello World!");

借助顶级语句,可以为小实用程序(如 Azure Functions 和 GitHub Actions)编写简单的程序。 它们还使初次接触 C# 的程序员能够更轻松地开始学习和编写代码。

以下各节介绍了可对顶级语句执行和不能执行的操作的规则。

03.01 仅能有一个顶级文件

一个应用程序只能有一个入口点。 一个项目只能有一个包含顶级语句的文件。 在项目中的多个文件中放置顶级语句会导致以下编译器错误:

CS8802:只有一个编译单元可具有顶级语句。

一个项目可具有任意数量的其他源代码文件,这些文件不包含顶级语句。

03.02 没有其他入口点

可以显式编写 Main 方法,但它不能作为入口点。 编译器将发出以下警告:

CS7022:程序的入口点是全局代码;忽略"Main()"入口点。

在具有顶级语句的项目中,不能使用 -main 编译器选项来选择入口点,即使该项目具有一个或多个 Main 方法。

03.03 using 指令

如果包含 using 指令,这些指令必须先出现在文件中,如以下示例中所示:

cs 复制代码
using System.Text;

StringBuilder builder = new();
builder.AppendLine("The following arguments are passed:");

// Display the command line arguments using the args variable.
foreach (var arg in args)
{
    builder.AppendLine($"Argument={arg}");
}

Console.WriteLine(builder.ToString());

// Return a success code.
return 0;

03.04 全局命名空间

顶级语句隐式位于全局命名空间中。

03.05 命名空间和类型定义

具有顶级语句的文件还可以包含命名空间和类型定义,但它们必须位于顶级语句之后。 例如:

cs 复制代码
MyClass.TestMethod();
MyNamespace.MyClass.MyMethod();

public class MyClass
{
    public static void TestMethod()
    {
        Console.WriteLine("Hello World!");
    }
}

namespace MyNamespace
{
    class MyClass
    {
        public static void MyMethod()
        {
            Console.WriteLine("Hello World from MyNamespace.MyClass.MyMethod!");
        }
    }
}

03.06 args

顶级语句可以引用 args 变量来访问输入的任何命令行参数。 args 变量永远不会为 null,但如果未提供任何命令行参数,则其 Length 将为零。 例如:

cs 复制代码
if (args.Length > 0)
{
    foreach (var arg in args)
    {
        Console.WriteLine($"Argument={arg}");
    }
}
else
{
    Console.WriteLine("No arguments");
}

03.07 await

可以通过使用 await 来调用异步方法。 例如:

cs 复制代码
Console.Write("Hello ");
await Task.Delay(5000);
Console.WriteLine("World!");

03.08 进程的退出代码

若要在应用程序结束时返回 int 值,请像在 Main 方法中返回 int 那样使用 return 语句。 例如:

cs 复制代码
string? s = Console.ReadLine();

int returnValue = int.Parse(s ?? "-1");
return returnValue;

03.09 隐式入口点方法

编译器会生成一个方法,作为具有顶级语句的项目的程序入口点。 方法的签名取决于顶级语句是包含 await 关键字还是 return 语句。 下表显示了方法签名的外观,为了方便起见,在表中使用了方法名称 Main

顶级代码包含 隐式 Main 签名
awaitreturn static async Task<int> Main(string[] args)
await static async Task Main(string[] args)
return static int Main(string[] args)
awaitreturn static void Main(string[] args)

04 数据与类型(干货开始,读到似懂非懂即可)

C# 是一种强类型语言。 每个变量和常量都有一个类型,每个求值的表达式也是如此。 每个方法声明都为每个输入参数和返回值指定名称、类型和种类(值、引用或输出)。 .NET 类库定义了内置数值类型和表示各种构造的复杂类型。 其中包括文件系统、网络连接、对象的集合和数组以及日期。 典型的 C# 程序使用类库中的类型,以及对程序问题域的专属概念进行建模的用户定义类型。

类型中可存储的信息包括以下项:

  • 类型变量所需的存储空间。
  • 可以表示的最大值和最小值。
  • 包含的成员(方法、字段、事件等)。
  • 继承自的基类型。
  • 它实现的接口。
  • 允许执行的运算种类。

编译器使用类型信息来确保在代码中执行的所有操作都是类型安全的。 例如,如果声明 int 类型的变量,那么编译器允许在加法和减法运算中使用此变量。 如果尝试对 bool 类型的变量执行这些相同操作,则编译器将生成错误,如以下示例所示:

cs 复制代码
int a = 5;
int b = a + 2; //OK

bool test = true;

// Error. Operator '+' cannot be applied to operands of type 'int' and 'bool'.
int c = a + test;

备注

C 和 C++ 开发人员请注意,在 C# 中,bool 不能转换为 int

编译器将类型信息作为元数据嵌入可执行文件中。 公共语言运行时 (CLR) 在运行时使用元数据,以在分配和回收内存时进一步保证类型安全性。

04.01 类型概述

04.01.01 在变量声明中指定类型

当在程序中声明变量或常量时,必须指定其类型或使用 var 关键字让编译器推断类型。 以下示例显示了一些使用内置数值类型和复杂用户定义类型的变量声明:

cs 复制代码
// Declaration only:
float temperature;
string name;
MyClass myClass;

// Declaration with initializers (four examples):
char firstLetter = 'C';
var limit = 3;
int[] source = { 0, 1, 2, 3, 4, 5 };
var query = from item in source
            where item <= limit
            select item;

方法声明指定方法参数的类型和返回值。 以下签名显示了需要 int 作为输入参数并返回字符串的方法:

cs 复制代码
public string GetName(int ID)
{
    if (ID < names.Length)
        return names[ID];
    else
        return String.Empty;
}
private string[] names = { "Spencer", "Sally", "Doug" };

声明变量后,不能使用新类型重新声明该变量,并且不能分配与其声明的类型不兼容的值。 例如,不能声明 int 后再向它分配 true 的布尔值。 不过,可以将值转换成其他类型。例如,在将值分配给新变量或作为方法自变量传递时。 编译器会自动执行不会导致数据丢失的类型转换。 如果类型转换可能会导致数据丢失,必须在源代码中进行显式转换

有关详细信息,请参阅显式转换和类型转换

04.01.02 内置类型

C# 提供了一组标准的内置类型。 这些类型表示整数、浮点值、布尔表达式、文本字符、十进制值和其他数据类型。 还有内置的 stringobject 类型。 这些类型可供在任何 C# 程序中使用。 有关内置类型的完整列表,请参阅内置类型

04.01.03 自定义类型

可以使用 structclassinterfaceenumrecord 构造来创建自己的自定义类型。 .NET 类库本身是一组自定义类型,以供你在自己的应用程序中使用。 默认情况下,类库中最常用的类型在任何 C# 程序中均可用。 其他类型只有在显式添加对定义这些类型的程序集的项目引用时才可用。 编译器引用程序集之后,你可以声明在源代码的此程序集中声明的类型的变量(和常量)。 有关详细信息,请参阅 .NET 类库

04.01.04 通用类型系统

对于 .NET 中的类型系统,请务必了解以下两个基本要点:

  • 它支持继承原则。 类型可以派生自其他类型(称为基类型 )。 派生类型继承(有一些限制)基类型的方法、属性和其他成员。 基类型可以继而从某种其他类型派生,在这种情况下,派生类型继承其继承层次结构中的两种基类型的成员。 所有类型(包括 System.Int32 (C# keyword: int) 等内置数值类型)最终都派生自单个基类型,即 System.Object (C# keyword: object)。 这样的统一类型层次结构称为通用类型系统 (CTS)。 若要详细了解 C# 中的继承,请参阅继承
  • CTS 中的每种类型被定义为值类型或引用类型。 这些类型包括 .NET 类库中的所有自定义类型以及你自己的用户定义类型。 使用 struct 关键字定义的类型是值类型;所有内置数值类型都是 structs。 使用 classrecord 关键字定义的类型是引用类型。 引用类型和值类型遵循不同的编译时规则和运行时行为。

下图展示了 CTS 中值类型和引用类型之间的关系。

备注

你可能会发现,最常用的类型全都被整理到了 System 命名空间中。 不过,包含类型的命名空间与类型是值类型还是引用类型没有关系。

类和结构是 .NET 通用类型系统的两种基本构造。 C# 9 添加记录,记录是一种类。 每种本质上都是一种数据结构,其中封装了同属一个逻辑单元的一组数据和行为。 数据和行为是类、结构或记录的成员。 这些行为包括方法、属性和事件等,本文稍后将具体列举。

类、结构或记录声明类似于一张蓝图,用于在运行时创建实例或对象。 如果定义名为 Person 的类、结构或记录,则 Person 是类型的名称。 如果声明和初始化 Person 类型的变量 p,那么 p 就是所谓的 Person 对象或实例。 可以创建同一 Person 类型的多个实例,每个实例都可以有不同的属性和字段值。

类是引用类型。 创建类型的对象后,向其分配对象的变量仅保留对相应内存的引用。 将对象引用分配给新变量后,新变量会引用原始对象。 通过一个变量所做的更改将反映在另一个变量中,因为它们引用相同的数据。

结构是值类型。 创建结构时,向其分配结构的变量保留结构的实际数据。 将结构分配给新变量时,会复制结构。 因此,新变量和原始变量包含相同数据的副本(共两个)。 对一个副本所做的更改不会影响另一个副本。

记录类型可以是引用类型 (record class) 或值类型 (record struct)。

一般来说,类用于对更复杂的行为建模。 类通常存储计划在创建类对象后进行修改的数据。 结构最适用于小型数据结构。 结构通常存储不打算在创建结构后修改的数据。 记录类型是具有附加编译器合成成员的数据结构。 记录通常存储不打算在创建对象后修改的数据。

04.01.05 值类型

值类型派生自System.ValueType(派生自 System.Object)。 派生自 System.ValueType 的类型在 CLR 中具有特殊行为。 值类型变量直接包含其值。 结构的内存在声明变量的任何上下文中进行内联分配。 对于值类型变量,没有单独的堆分配或垃圾回收开销。 可以声明属于值类型的 record struct 类型,并包括记录的合成成员。

值类型分为两类:structenum

内置的数值类型是结构,它们具有可访问的字段和方法:

cs 复制代码
// constant field on type byte.
byte b = byte.MaxValue;

但可将这些类型视为简单的非聚合类型,为其声明并赋值:

cs 复制代码
byte num = 0xA;
int i = 5;
char c = 'Z';

值类型已密封。 不能从任何值类型(例如 System.Int32)派生类型。 不能将结构定义为从任何用户定义的类或结构继承,因为结构只能从 System.ValueType 继承。 但是,一个结构可以实现一个或多个接口。 可将结构类型强制转换为其实现的任何接口类型。 这将导致"装箱"操作,以将结构包装在托管堆上的引用类型对象内。 当你将值类型传递给使用 System.Object 或任何接口类型作为输入参数的方法时,就会发生装箱操作。 有关详细信息,请参阅装箱和取消装箱

使用 struct 关键字可以创建你自己的自定义值类型。 结构通常用作一小组相关变量的容器,如以下示例所示:

cs 复制代码
public struct Coords
{
    public int x, y;

    public Coords(int p1, int p2)
    {
        x = p1;
        y = p2;
    }
}

有关结构的详细信息,请参阅结构类型。 有关值类型的详细信息,请参阅值类型

另一种值类型是enum。 枚举定义的是一组已命名的整型常量。 例如,.NET 类库中的 System.IO.FileMode 枚举包含一组已命名的常量整数,用于指定打开文件应采用的方式。 下面的示例展示了具体定义:

cs 复制代码
public enum FileMode
{
    CreateNew = 1,
    Create = 2,
    Open = 3,
    OpenOrCreate = 4,
    Truncate = 5,
    Append = 6,
}

System.IO.FileMode.Create 常量的值为 2。 不过,名称对于阅读源代码的人来说更有意义,因此,最好使用枚举,而不是常量数字文本。 有关详细信息,请参阅 System.IO.FileMode

所有枚举从 System.Enum(继承自 System.ValueType)继承。 适用于结构的所有规则也适用于枚举。 有关枚举的详细信息,请参阅枚举类型

04.01.06 引用类型

定义为 classrecorddelegate、数组或 interface 的类型是 reference type

在声明变量 reference type 时,它将包含值 null,直到你将其分配给该类型的实例,或者使用 new 运算符创建一个。 下面的示例演示了如何创建和分配类:

cs 复制代码
MyClass myClass = new MyClass();
MyClass myClass2 = myClass;

无法使用 new 运算符直接实例化 interface。 而是创建并分配实现接口的类实例。 请考虑以下示例:

cs 复制代码
MyClass myClass = new MyClass();

// Declare and assign using an existing value.
IMyInterface myInterface = myClass;

// Or create and assign a value in a single statement.
IMyInterface myInterface2 = new MyClass();

创建对象时,会在托管堆上分配内存。 变量只保留对对象位置的引用。 对于托管堆上的类型,在分配内存和回收内存时都会产生开销。 "垃圾回收"是 CLR 的自动内存管理功能,用于执行回收。 但是,垃圾回收已是高度优化,并且在大多数情况下,不会产生性能问题。 有关垃圾回收的详细信息,请参阅自动内存管理

所有数组都是引用类型,即使元素是值类型,也不例外。 数组隐式派生自 System.Array 类。 可以使用 C# 提供的简化语法声明和使用数组,如以下示例所示:

cs 复制代码
// Declare and initialize an array of integers.
int[] nums = { 1, 2, 3, 4, 5 };

// Access an instance property of System.Array.
int len = nums.Length;

引用类型完全支持继承。 创建类时,可以从其他任何未定义为密封的接口或类继承。 其他类可以从你的类继承并替代虚拟方法。 若要详细了解如何创建你自己的类,请参阅类、结构和记录。 有关继承和虚方法的详细信息,请参阅继承

04.01.07 文本值的类型

在 C# 中,文本值从编译器接收类型。 可以通过在数字末尾追加一个字母来指定数字文本应采用的类型。 例如,若要指定应按 float 来处理值 4.56,则在该数字后追加一个"f"或"F",即 4.56f。 如果没有追加字母,那么编译器就会推断文本值的类型。 若要详细了解可以使用字母后缀指定哪些类型,请参阅整型数值类型浮点数值类型

由于文本已类型化,且所有类型最终都是从 System.Object 派生,因此可以编写和编译如下所示的代码:

string s = "The answer is " + 5.ToString();
// Outputs: "The answer is 5"
Console.WriteLine(s);

Type type = 12345.GetType();
// Outputs: "System.Int32"
Console.WriteLine(type);

04.01.08 泛型类型(放过它!)

可使用一个或多个类型参数声明的类型,用作实际类型(具体类型)的占位符 。 客户端代码在创建类型实例时提供具体类型。 这种类型称为泛型类型。 例如,.NET 类型 System.Collections.Generic.List<T> 具有一个类型参数,它按照惯例被命名为 T。 当创建类型的实例时,指定列表将包含的对象的类型,例如 string

cs 复制代码
List<string> stringList = new List<string>();
stringList.Add("String example");
// compile time error adding a type other than a string:
stringList.Add(4);

通过使用类型参数,可重新使用相同类以保存任意类型的元素,且无需将每个元素转换为对象。 泛型集合类称为强类型集合,因为编译器知道集合元素的具体类型,并能在编译时引发错误,例如当尝试向上面示例中的 stringList 对象添加整数时。 有关详细信息,请参阅泛型

04.01.09 隐式类型、匿名类型和可以为 null 的值类型

你可以使用 var 关键字隐式键入一个局部变量(但不是类成员)。 变量仍可在编译时获取类型,但类型是由编译器提供。 有关详细信息,请参阅隐式类型局部变量

不方便为不打算存储或传递外部方法边界的简单相关值集合创建命名类型。 因此,可以创建匿名类型 。 有关详细信息,请参阅匿名类型

普通值类型不能具有 null 值。 不过,可以在类型后面追加 ?,创建可为空的值类型。 例如,int? 是还可以包含值 nullint 类型。 可以为 null 的值类型是泛型结构类型 System.Nullable<T> 的实例。 在将数据传入和传出数据库(数值可能为 null)时,可为空的值类型特别有用。 有关详细信息,请参阅可以为 null 的值类型

04.01.10 编译时类型和运行时类型

变量可以具有不同的编译时和运行时类型。 编译时类型是源代码中变量的声明或推断类型。 运行时类型是该变量所引用的实例的类型。 这两种类型通常是相同的,如以下示例中所示:

cs 复制代码
string message = "This is a string of characters";

在其他情况下,编译时类型是不同的,如以下两个示例所示:

cs 复制代码
object anotherMessage = "This is another string of characters";
IEnumerable<char> someCharacters = "abcdefghijklmnopqrstuvwxyz";

在上述两个示例中,运行时类型为 string。 编译时类型在第一行中为 object,在第二行中为 IEnumerable<char>

如果变量的这两种类型不同,请务必了解编译时类型和运行时类型的应用情况。 编译时类型确定编译器执行的所有操作。 这些编译器操作包括方法调用解析、重载决策以及可用的隐式和显式强制转换。 运行时类型确定在运行时解析的所有操作。 这些运行时操作包括调度虚拟方法调用、计算 isswitch 表达式以及其他类型的测试 API。 为了更好地了解代码如何与类型进行交互,请识别哪个操作应用于哪种类型。

04.02 命名空间 namespace (看!)

04.02.01 基本概念

在 C# 编程中,命名空间在两个方面被大量使用。 首先,.NET 使用命名空间来组织它的许多类,如下所示:

cs 复制代码
System.Console.WriteLine("Hello World!");

System 是一个命名空间,Console 是该命名空间中的一个类。 可使用 using 关键字,这样就不必使用完整的名称,如下例所示:

cs 复制代码
using System;
cs 复制代码
Console.WriteLine("Hello World!");

有关详细信息,请参阅 using 指令

重要

适用于 .NET 6 的 C# 模板使用顶级语句。 如果你已升级到 .NET 6,则应用程序可能与本文中的代码不匹配。 有关详细信息,请参阅有关新 C# 模板生成顶级语句的文章

.NET 6 SDK 还为使用以下 SDK 的项目添加了一组隐式 global using 指令:

这些隐式 global using 指令包含项目类型最常见的命名空间。

有关详细信息,请参阅隐式 using 指令一文

其次,在较大的编程项目中,声明自己的命名空间可以帮助控制类和方法名称的范围。 使用 namespace 关键字可声明命名空间,如下例所示:

cs 复制代码
namespace SampleNamespace
{
    class SampleClass
    {
        public void SampleMethod()
        {
            System.Console.WriteLine(
                "SampleMethod inside SampleNamespace");
        }
    }
}

命名空间的名称必须是有效的 C# 标识符名称

从 C# 10 开始,可以为该文件中定义的所有类型声明一个命名空间,如以下示例所示:

cs 复制代码
namespace SampleNamespace;

class AnotherSampleClass
{
    public void AnotherSampleMethod()
    {
        System.Console.WriteLine(
            "SampleMethod inside SampleNamespace");
    }
}

这种新语法的优点是更简单,这节省了水平空间且不必使用大括号。 这使得你的代码易于阅读。

04.02.02 命名空间概述

命名空间具有以下属性:

  • 它们组织大型代码项目。
  • 通过使用 . 运算符分隔它们。
  • using 指令可免去为每个类指定命名空间的名称。
  • global 命名空间是"根"命名空间:global::System 始终引用 .NET System 命名空间。

04.03 类 class (细看!)

04.03.02 引用类型(编号没错!原文呵呵!)

定义为 class 的类型是引用类型 。 在运行时,如果声明引用类型的变量,此变量就会一直包含值 null,直到使用 new 运算符显式创建类实例,或直到为此变量分配已在其他位置创建的兼容类型,如下面的示例所示:

cs 复制代码
//Declaring an object of type MyClass.
MyClass mc = new MyClass();

//Declaring another object of the same type, assigning it the value of the first object.
MyClass mc2 = mc;

创建对象时,在该托管堆上为该特定对象分足够的内存,并且该变量仅保存对所述对象位置的引用。 对象使用的内存由 CLR 的自动内存管理功能(称为垃圾回收 )回收。 有关垃圾回收的详细信息,请参阅自动内存管理和垃圾回收

04.03.01 声明类

使用后跟唯一标识符的 class 关键字可以声明类,如下例所示:

cs 复制代码
//[access modifier] - [class] - [identifier]
public class Customer
{
   // Fields, properties, methods and events go here...
}

可选访问修饰符位于 class 关键字前面。 class 类型的默认访问权限为 internal。 此例中使用的是 public,因此任何人都可创建此类的实例。 类的名称遵循 class 关键字。 类名称必须是有效的 C# 标识符名称。 定义的其余部分是类的主体,其中定义了行为和数据。 类上的字段、属性、方法和事件统称为类成员

04.03.03 创建对象

虽然它们有时可以互换使用,但类和对象是不同的概念。 类定义对象类型,但不是对象本身。 对象是基于类的具体实体,有时称为类的实例。

可通过使用 new 关键字,后跟类的名称来创建对象,如下所示:

cs 复制代码
Customer object1 = new Customer();

创建类的实例后,会将一个该对象的引用传递回程序员。 在上一示例中,object1 是对基于 Customer 的对象的引用。 该引用指向新对象,但不包含对象数据本身。 事实上,可以创建对象引用,而完全无需创建对象本身:

cs 复制代码
Customer object2;

不建议创建不引用对象的对象引用,因为尝试通过这类引用访问对象会在运行时失败。 但是,但实际上引用可以引用某个对象,方法是创建新对象,或者将其分配给现有对象,例如:

C#复制

cs 复制代码
Customer object3 = new Customer();
Customer object4 = object3;

此代码创建指向同一对象的两个对象引用。 因此,通过 object3 对对象做出的任何更改都会在后续使用 object4 时反映出来。 由于基于类的对象是通过引用来实现其引用的,因此类被称为引用类型。

04.03.04 构造函数和初始化

前面的部分介绍了声明类类型并创建该类型的实例的语法。 创建类型的实例时,需要确保其字段和属性已初始化为有用的值。 可通过多种方式初始化值:

  • 接受默认值
  • 字段初始化表达式
  • 构造函数参数
  • 对象初始值设定项

每个 .NET 类型都有一个默认值。 通常,对于数字类型,该值为 0,对于所有引用类型,该值为 null。 如果默认值在应用中是合理的,则可以依赖于该默认值。

当 .NET 默认值不是正确的值时,可以使用字段初始化表达式设置初始值:

cs 复制代码
public class Container
{
    // Initialize capacity field to a default value of 10:
    private int _capacity = 10;
}

可以通过定义负责设置初始值的构造函数来要求调用方提供初始值:

cs 复制代码
public class Container
{
    private int _capacity;

    public Container(int capacity) => _capacity = capacity;
}

从 C# 12 开始,可以将主构造函数定义为类声明的一部分:

cs 复制代码
public class Container(int capacity)
{
    private int _capacity = capacity;
}

向类名添加参数可定义主构造函数。 这些参数在包含其成员的类正文中可用。 可以将其用于初始化字段或需要它们的任何其他位置。

还可以对某个属性使用 required 修饰符,并允许调用方使用对象初始值设定项来设置该属性的初始值:

cs 复制代码
public class Person
{
    public required string LastName { get; set; }
    public required string FirstName { get; set; }
}

添加 required 关键字要求调用方必须将这些属性设置为 new 表达式的一部分:

cs 复制代码
var p1 = new Person(); // Error! Required properties not set
var p2 = new Person() { FirstName = "Grace", LastName = "Hopper" };

04.03.05 类继承

类完全支持继承 ,这是面向对象的编程的基本特点。 创建类时,可以从其他任何未定义为 sealed 的类继承。 其他类可以从你的类继承并替代类虚拟方法。 此外,你可以实现一个或多个接口。

继承是通过使用派生 来完成的,这意味着类是通过使用其数据和行为所派生自的基类来声明的。 基类通过在派生的类名称后面追加冒号和基类名称来指定,如:

cs 复制代码
public class Manager : Employee
{
    // Employee fields, properties, methods and events are inherited
    // New Manager fields, properties, methods and events go here...
}

类声明包括基类时,它会继承基类除构造函数外的所有成员。 有关详细信息,请参阅继承

C# 中的类只能直接从基类继承。 但是,因为基类本身可能继承自其他类,因此类可能间接继承多个基类。 此外,类可以支持实现一个或多个接口。 有关详细信息,请参阅接口

类可以声明为 abstract。 抽象类包含抽象方法,抽象方法包含签名定义但不包含实现。 抽象类不能实例化。 只能通过可实现抽象方法的派生类来使用该类。 与此相反,密封类不允许其他类继承。 有关详细信息,请参阅抽象类、密封类和类成员

类定义可以在不同的源文件之间分割。 有关详细信息,请参阅分部类和方法

04.04 记录 Record

C# 中的记录是一个结构,它为使用数据模型提供特定的语法和行为。 record 修饰符指示编译器合成对主要角色存储数据的类型有用的成员。 这些成员包括支持值相等的 ToString() 和成员的重载。

04.04.01 何时使用记录

在下列情况下,请考虑使用记录而不是类或结构:

  • 你想要定义依赖值相等性的数据模型。
  • 你想要定义对象不可变的类型。

04.04.02 值相等性

对记录来说,值相等性是指如果记录类型的两个变量类型相匹配,且所有属性和字段值都相同,那么记录类型的两个变量是相等的。 对于其他引用类型(例如类),相等性默认指引用相等性,除非执行了值相等性。 也就是说,如果类的两个变量引用同一个对象,则这两个变量是相等的。 确定两个记录实例的相等性的方法和运算符使用值相等性。

并非所有数据模型都适合使用值相等性。 例如,Entity Framework Core 依赖引用相等性,来确保它对概念上是一个实体的实体类型只使用一个实例。 因此,记录类型不适合用作 Entity Framework Core 中的实体类型。

04.04.03 不可变性

不可变类型会阻止你在对象实例化后更改该对象的任何属性或字段值。 如果你需要一个类型是线程安全的,或者需要哈希代码在哈希表中能保持不变,那么不可变性很有用。 记录为创建和使用不可变类型提供了简洁的语法。

不可变性并不适用于所有数据方案。 例如,Entity Framework Core 不支持通过不可变实体类型进行更新。

04.04.04 记录与类和结构的区别

声明实例化类或结构时使用的语法与操作记录时的相同。 只是将 class 关键字替换为 record,或者使用 record struct 而不是 struct。 同样地,记录类支持相同的表示继承关系的语法。 记录与类的区别如下所示:

  • 可在主构造函数中使用位置参数来创建和实例化具有不可变属性的类型。
  • 在类中指示引用相等性或不相等的方法和运算符(例如 Object.Equals(Object)==)在记录中指示值相等性或不相等
  • 可使用 with 表达式对不可变对象创建在所选属性中具有新值的副本。
  • 记录的 ToString 方法会创建一个格式字符串,它显示对象的类型名称及其所有公共属性的名称和值。
  • 记录可从另一个记录继承。 但记录不可从类继承,类也不可从记录继承。

记录结构与结构的不同之处是,编译器合成了方法来确定相等性和 ToString。 编译器为位置记录结构合成 Deconstruct 方法。

编译器为 record class 中的每个主构造函数参数合成一个公共仅初始化属性。 在 record struct 中,编译器合成公共读写属性。 编译器不会在不包含 record 修饰符的 classstruct 类型中创建主构造函数参数的属性。

04.04.05 Record 示例

下面的示例定义了一个公共记录,它使用位置参数来声明和实例化记录。 然后,它会输出类型名称和属性值:

cs 复制代码
public record Person(string FirstName, string LastName);

public static class Program
{
    public static void Main()
    {
        Person person = new("Nancy", "Davolio");
        Console.WriteLine(person);
        // output: Person { FirstName = Nancy, LastName = Davolio }
    }

}

下面的示例演示了记录中的值相等性:

cs 复制代码
public record Person(string FirstName, string LastName, string[] PhoneNumbers);
public static class Program
{
    public static void Main()
    {
        var phoneNumbers = new string[2];
        Person person1 = new("Nancy", "Davolio", phoneNumbers);
        Person person2 = new("Nancy", "Davolio", phoneNumbers);
        Console.WriteLine(person1 == person2); // output: True

        person1.PhoneNumbers[0] = "555-1234";
        Console.WriteLine(person1 == person2); // output: True

        Console.WriteLine(ReferenceEquals(person1, person2)); // output: False
    }
}

下面的示例演示如何使用 with 表达式来复制不可变对象和更改其中的一个属性:

cs 复制代码
public record Person(string FirstName, string LastName)
{
    public required string[] PhoneNumbers { get; init; }
}

public class Program
{
    public static void Main()
    {
        Person person1 = new("Nancy", "Davolio") { PhoneNumbers = new string[1] };
        Console.WriteLine(person1);
        // output: Person { FirstName = Nancy, LastName = Davolio, PhoneNumbers = System.String[] }

        Person person2 = person1 with { FirstName = "John" };
        Console.WriteLine(person2);
        // output: Person { FirstName = John, LastName = Davolio, PhoneNumbers = System.String[] }
        Console.WriteLine(person1 == person2); // output: False

        person2 = person1 with { PhoneNumbers = new string[1] };
        Console.WriteLine(person2);
        // output: Person { FirstName = Nancy, LastName = Davolio, PhoneNumbers = System.String[] }
        Console.WriteLine(person1 == person2); // output: False

        person2 = person1 with { };
        Console.WriteLine(person1 == person2); // output: True
    }
}

有关详细信息,请查看记录(C# 参考)

04.05 接口 interface - 定义多种类型的行为

接口包含非抽象 classstruct 必须实现的一组相关功能的定义。 接口可以定义 static 方法,此类方法必须具有实现。 接口可为成员定义默认实现。 接口不能声明实例数据,如字段、自动实现的属性或类似属性的事件。

例如,使用接口可以在类中包括来自多个源的行为。 该功能在 C# 中十分重要,因为该语言不支持类的多重继承。 此外,如果要模拟结构的继承,也必须使用接口,因为它们无法实际从另一个结构或类继承。

04.05.01 接口

可使用 interface 关键字定义接口,如以下示例所示。

cs 复制代码
interface IEquatable<T>
{
    bool Equals(T obj);
}

接口名称必须是有效的 C# 标识符名称。 按照约定,接口名称以大写字母 I 开头。

实现 IEquatable<T> 接口的任何类或结构都必须包含与该接口指定的签名匹配的 Equals 方法的定义。 因此,可以依靠实现 IEquatable<T> 的类型 T 的类来包含 Equals 方法,类的实例可以通过该方法确定它是否等于相同类的另一个实例。

IEquatable<T> 的定义不为 Equals 提供实现。 类或结构可以实现多个接口,但是类只能从单个类继承。

有关抽象类的详细信息,请参阅抽象类、密封类及类成员

接口可以包含实例方法、属性、事件、索引器或这四种成员类型的任意组合。 接口可以包含静态构造函数、字段、常量或运算符。 从 C# 11 开始,非字段接口成员可以是 static abstract。 接口不能包含实例字段、实例构造函数或终结器。 接口成员默认是公共的,可以显式指定可访问性修饰符(如 publicprotectedinternalprivateprotected internalprivate protected)。 private 成员必须有默认实现。

若要实现接口成员,实现类的对应成员必须是公共、非静态,并且具有与接口成员相同的名称和签名。

备注

当接口声明静态成员时,实现该接口的类型也可能声明具有相同签名的静态成员。 它们是不同的,并且由声明成员的类型唯一标识。 在类型中声明的静态成员不会覆盖接口中声明的静态成员。

实现接口的类或结构必须为所有已声明的成员提供实现,而非接口提供的默认实现。 但是,如果基类实现接口,则从基类派生的任何类都会继承该实现。

下面的示例演示 IEquatable<T> 接口的实现。 实现类 Car 必须提供 Equals 方法的实现。

cs 复制代码
public class Car : IEquatable<Car>
{
    public string? Make { get; set; }
    public string? Model { get; set; }
    public string? Year { get; set; }

    // Implementation of IEquatable<T> interface
    public bool Equals(Car? car)
    {
        return (this.Make, this.Model, this.Year) ==
            (car?.Make, car?.Model, car?.Year);
    }
}

类的属性和索引器可以为接口中定义的属性或索引器定义额外的访问器。 例如,接口可能会声明包含 get 取值函数的属性。 实现此接口的类可以声明包含 getget 取值函数的同一属性。 但是,如果属性或索引器使用显式实现,则访问器必须匹配。 有关显式实现的详细信息,请参阅显式接口实现接口属性

接口可从一个或多个接口继承。 派生接口从其基接口继承成员。 实现派生接口的类必须实现派生接口中的所有成员,包括派生接口的基接口的所有成员。 该类可能会隐式转换为派生接口或任何其基接口。 类可能通过它继承的基类或通过其他接口继承的接口来多次包含某个接口。 但是,类只能提供接口的实现一次,并且仅当类将接口作为类定义的一部分 (class ClassName : InterfaceName) 进行声明时才能提供。 如果由于继承实现接口的基类而继承了接口,则基类会提供接口的成员的实现。 但是,派生类可以重新实现任何虚拟接口成员,而不是使用继承的实现。 当接口声明方法的默认实现时,实现该接口的任何类都会继承该实现(你需要将类实例强制转换为接口类型,才能访问接口成员上的默认实现)。

基类还可以使用虚拟成员实现接口成员。 在这种情况下,派生类可以通过重写虚拟成员来更改接口行为。 有关虚拟成员的详细信息,请参阅多态性

04.05.02 接口摘要

接口具有以下属性:

  • 在 8.0 以前的 C# 版本中,接口类似于只有抽象成员的抽象基类。 实现接口的类或结构必须实现其所有成员。
  • 从 C# 8.0 开始,接口可以定义其部分或全部成员的默认实现。 实现接口的类或结构不一定要实现具有默认实现的成员。 有关详细信息,请参阅默认接口方法
  • 接口无法直接进行实例化。 其成员由实现接口的任何类或结构来实现。
  • 一个类或结构可以实现多个接口。 一个类可以继承一个基类,还可实现一个或多个接口。

04.06 泛型类和方法

泛型向 .NET 引入了类型参数的概念。 泛型支持设计类和方法,你可在在代码中使用该类或方法时,再定义一个或多个类型参数的规范。 例如,通过使用泛型类型参数 T,可以编写其他客户端代码能够使用的单个类,而不会产生运行时转换或装箱操作的成本或风险,如下所示:

cs 复制代码
// Declare the generic class.
public class GenericList<T>
{
    public void Add(T input) { }
}
class TestGenericList
{
    private class ExampleClass { }
    static void Main()
    {
        // Declare a list of type int.
        GenericList<int> list1 = new GenericList<int>();
        list1.Add(1);

        // Declare a list of type string.
        GenericList<string> list2 = new GenericList<string>();
        list2.Add("");

        // Declare a list of type ExampleClass.
        GenericList<ExampleClass> list3 = new GenericList<ExampleClass>();
        list3.Add(new ExampleClass());
    }
}

泛型类和泛型方法兼具可重用性、类型安全性和效率,这是非泛型类和非泛型方法无法实现的。 在编译过程中将泛型类型参数替换为类型参数。 在前面的示例中,编译器会使用 int 替换 T。 泛型通常与集合以及作用于集合的方法一起使用。 System.Collections.Generic 命名空间包含几个基于泛型的集合类。 不建议使用非泛型集合(如 ArrayList),并且仅出于兼容性目的而维护非泛型集合。 有关详细信息,请参阅 .NET 中的泛型

04.06.01 关于泛型

你也可创建自定义泛型类型和泛型方法,以提供自己的通用解决方案,设计类型安全的高效模式。 以下代码示例演示了出于演示目的的简单泛型链接列表类。 (大多数情况下,应使用 .NET 提供的 List<T> 类,而不是自行创建类。)在通常使用具体类型来指示列表中所存储项的类型的情况下,可使用类型参数 T

  • AddHead 方法中作为方法参数的类型。
  • Node 嵌套类中作为 Data 属性的返回类型。
  • 在嵌套类中作为私有成员 data 的类型。

T 可用于 Node 嵌套类。 如果使用具体类型实例化 GenericList<T>(例如,作为 GenericList<int>),则出现的所有 T 都将替换为 int

cs 复制代码
// type parameter T in angle brackets
public class GenericList<T>
{
    // The nested class is also generic on T.
    private class Node
    {
        // T used in non-generic constructor.
        public Node(T t)
        {
            next = null;
            data = t;
        }

        private Node? next;
        public Node? Next
        {
            get { return next; }
            set { next = value; }
        }

        // T as private member data type.
        private T data;

        // T as return type of property.
        public T Data
        {
            get { return data; }
            set { data = value; }
        }
    }

    private Node? head;

    // constructor
    public GenericList()
    {
        head = null;
    }

    // T as method parameter type:
    public void AddHead(T t)
    {
        Node n = new Node(t);
        n.Next = head;
        head = n;
    }

    public IEnumerator<T> GetEnumerator()
    {
        Node? current = head;

        while (current != null)
        {
            yield return current.Data;
            current = current.Next;
        }
    }
}

以下代码示例演示了客户端代码如何使用泛型 GenericList<T> 类来创建整数列表。 如果更改类型参数,以下代码将创建字符串列表或任何其他自定义类型:

cs 复制代码
class TestGenericList
{
    static void Main()
    {
        // int is the type argument
        GenericList<int> list = new GenericList<int>();

        for (int x = 0; x < 10; x++)
        {
            list.AddHead(x);
        }

        foreach (int i in list)
        {
            System.Console.Write(i + " ");
        }
        System.Console.WriteLine("\nDone");
    }
}

备注

泛型类型不限于类。 前面的示例使用了 class 类型,但你可以定义泛型 interfacestruct 类型,包括 record 类型。

04.06.02 泛型概述

  • 使用泛型类型可以最大限度地重用代码、保护类型安全性以及提高性能。
  • 泛型最常见的用途是创建集合类。
  • .NET 类库在 System.Collections.Generic 命名空间中包含几个新的泛型集合类。 应尽可能使用泛型集合来代替某些类,如 System.Collections 命名空间中的 ArrayList
  • 可以创建自己的泛型接口、泛型类、泛型方法、泛型事件和泛型委托。
  • 可以对泛型类进行约束以访问特定数据类型的方法。
  • 可以使用反射在运行时获取有关泛型数据类型中使用的类型的信息。

04.07 匿名类型

匿名类型提供了一种方便的方法,可用来将一组只读属性封装到单个对象中,而无需首先显式定义一个类型。 类型名由编译器生成,并且不能在源代码级使用。 每个属性的类型由编译器推断。

可结合使用 new 运算符和对象初始值设定项创建匿名类型。 有关对象初始值设定项的详细信息,请参阅对象和集合初始值设定项

以下示例显示了用两个名为 AmountMessage 的属性进行初始化的匿名类型。

cs 复制代码
var v = new { Amount = 108, Message = "Hello" };

// Rest the mouse pointer over v.Amount and v.Message in the following
// statement to verify that their inferred types are int and string.
Console.WriteLine(v.Amount + v.Message);

匿名类型通常用在查询表达式的 select 子句中,以便返回源序列中每个对象的属性子集。 有关查询的详细信息,请参阅C# 中的 LINQ

匿名类型包含一个或多个公共只读属性。 包含其他种类的类成员(如方法或事件)为无效。 用来初始化属性的表达式不能为 null、匿名函数或指针类型。

最常见的方案是用其他类型的属性初始化匿名类型。 在下面的示例中,假定名为 Product 的类存在。 类 Product 包括 ColorPrice 属性,以及你不感兴趣的其他属性。 变量 Product``products 是 对象的集合。 匿名类型声明以 new 关键字开始。 声明初始化了一个只使用 Product 的两个属性的新类型。 使用匿名类型会导致在查询中返回的数据量变少。

如果你没有在匿名类型中指定成员名称,编译器会为匿名类型成员指定与用于初始化这些成员的属性相同的名称。 需要为使用表达式初始化的属性提供名称,如下面的示例所示。 在下面示例中,匿名类型的属性名称都为 Price``Color 和 。

cs 复制代码
var productQuery =
    from prod in products
    select new { prod.Color, prod.Price };

foreach (var v in productQuery)
{
    Console.WriteLine("Color={0}, Price={1}", v.Color, v.Price);
}

提示

可以使用 .NET 样式规则 IDE0037 强制执行是首选推断成员名称还是显式成员名称。

还可以按另一种类型(类、结构或另一个匿名类型)的对象定义字段。 它通过使用保存此对象的变量来完成,如以下示例中所示,其中两个匿名类型是使用已实例化的用户定义类型创建的。 在这两种情况下,匿名类型 shipmentshipmentWithBonus 中的 product 字段的类型均为 Product,其中包含每个字段的默认值。 bonus 字段将是编译器创建的匿名类型。

cs 复制代码
var product = new Product();
var bonus = new { note = "You won!" };
var shipment = new { address = "Nowhere St.", product };
var shipmentWithBonus = new { address = "Somewhere St.", product, bonus };

通常,当使用匿名类型来初始化变量时,可以通过使用 var 将变量作为隐式键入的本地变量来进行声明。 类型名称无法在变量声明中给出,因为只有编译器能访问匿名类型的基础名称。 有关 var 的详细信息,请参阅隐式类型本地变量

可通过将隐式键入的本地变量与隐式键入的数组相结合创建匿名键入的元素的数组,如下面的示例所示。

cs 复制代码
var anonArray = new[] { 
    new { name = "apple", diam = 4 }, 
    new { name = "grape", diam = 1 }
};

匿名类型是 class 类型,它们直接派生自 object,并且无法强制转换为除 object 外的任何类型。 虽然你的应用程序不能访问它,编译器还是提供了每一个匿名类型的名称。 从公共语言运行时的角度来看,匿名类型与任何其他引用类型没有什么不同。

如果程序集中的两个或多个匿名对象初始值指定了属性序列,这些属性采用相同顺序且具有相同的名称和类型,则编译器将对象视为相同类型的实例。 它们共享同一编译器生成的类型信息。

匿名类型支持采用 with 表达式形式的非破坏性修改。 这使你能够创建匿名类型的新实例,其中一个或多个属性具有新值:

cs 复制代码
var apple = new { Item = "apples", Price = 1.35 };
var onSale = apple with { Price = 0.79 };
Console.WriteLine(apple);
Console.WriteLine(onSale);

无法将字段、属性、时间或方法的返回类型声明为具有匿名类型。 同样,你不能将方法、属性、构造函数或索引器的形参声明为具有匿名类型。 要将匿名类型或包含匿名类型的集合作为参数传递给某一方法,可将参数作为类型 object 进行声明。 但是,对匿名类型使用 object 违背了强类型的目的。 如果必须存储查询结果或者必须将查询结果传递到方法边界外部,请考虑使用普通的命名结构或类而不是匿名类型。

由于匿名类型上的 EqualsGetHashCode 方法是根据方法属性的 EqualsGetHashCode 定义的,因此仅当同一匿名类型的两个实例的所有属性都相等时,这两个实例才相等。

备注

匿名类型的辅助功能级别internal,因此在不同程序集中定义的两种匿名类型并非同一类型。 因此,当在不同的程序集中进行定义时,匿名类型的实例不能彼此相等,即使其所有属性都相等。

匿名类型确实会重写 ToString 方法,将用大括号括起来的每个属性的名称和 ToString 输出连接起来。

cs 复制代码
var v = new { Title = "Hello", Age = 24 };

Console.WriteLine(v.ToString()); // "{ Title = Hello, Age = 24 }"

05 面向对象

05.01 C# 中的类、结构和记录概述

在 C# 中,某个类型(类、结构或记录)的定义的作用类似于蓝图,指定该类型可以进行哪些操作。 从本质上说,对象是按照此蓝图分配和配置的内存块。 本文概述了这些蓝图及其功能。 本系列的下一篇文章介绍对象。

05.01.02 封装

封装 有时称为面向对象的编程的第一支柱或原则。 类或结构可以指定自己的每个成员对外部代码的可访问性。 可以隐藏不得在类或程序集外部使用的方法和变量,以限制编码错误或恶意攻击发生的可能性。 有关详细信息,请参阅面向对象的编程教程。

05.01.03 成员

类型的成员包括所有方法、字段、常量、属性和事件。 C# 没有全局变量或方法,这一点其他某些语言不同。 即使是编程的入口点(Main 方法),也必须在类或结构中声明(使用顶级语句时,隐式声明)。

下面列出了所有可以在类、结构或记录中声明的各种成员。

  • 字段
  • 常量
  • 属性
  • 方法
  • 构造函数
  • 事件
  • 终结器
  • 索引器
  • 运算符
  • 嵌套类型

有关详细信息,请参见成员

05.01.04 可访问性

一些方法和属性可供类或结构外部的代码(称为"客户端代码")调用或访问。 另一些方法和属性只能在类或结构本身中使用。 请务必限制代码的可访问性,仅供预期的客户端代码进行访问。 需要使用以下访问修饰符指定类型及其成员对客户端代码的可访问性:

可访问性的默认值为 private

05.01.05 继承

类(而非结构)支持继承的概念。 派生自另一个类(称为基类)的类自动包含基类的所有公共、受保护和内部成员(其构造函数和终结器除外)。

可以将类声明为 abstract,即一个或多个方法没有实现代码。 尽管抽象类无法直接实例化,但可以作为提供缺少实现代码的其他类的基类。 类还可以声明为 sealed,以阻止其他类继承。

有关详细信息,请参阅继承多态性

05.01.06 界面

类、结构和记录可以实现多个接口。 从接口实现意味着类型实现接口中定义的所有方法。 有关详细信息,请参阅接口

05.01.07 泛型类型

类、结构和记录可以使用一个或多个类型参数进行定义。 客户端代码在创建类型实例时提供类型。 例如,System.Collections.Generic 命名空间中的 List<T> 类就是用一个类型参数定义的。 客户端代码创建 List<string>List<int> 的实例来指定列表将包含的类型。 有关详细信息,请参阅泛型

05.01.08 静态类型

类(而非结构或记录)可以声明为static。 静态类只能包含静态成员,不能使用 new 关键字进行实例化。 在程序加载时,类的一个副本会加载到内存中,而其成员则可通过类名进行访问。 类、结构和记录可以包含静态成员。 有关详细信息,请参阅静态类和静态类成员

05.01.09 嵌套类型

类、结构和记录可以嵌套在其他类、结构和记录中。 有关详细信息,请参阅嵌套类型

05.01.10 分部类型

可以在一个代码文件中定义类、结构或方法的一部分,并在其他代码文件中定义另一部分。 有关详细信息,请参阅分部类和方法

05.01.11 对象初始值设定项

可以通过将值分配给属性来实例化和初始化类或结构对象以及对象集合。 有关详细信息,请参阅如何使用对象初始值设定项初始化对象

05.01.12 匿名类型

在不方便或不需要创建命名类的情况下,可以使用匿名类型。 匿名类型由其命名数据成员定义。 有关详细信息,请参阅匿名类型

05.01.13 扩展方法

可以通过创建单独的类型来"扩展"类,而无需创建派生类。 该类型包含可以调用的方法,就像它们属于原始类型一样。 有关详细信息,请参阅扩展方法

05.01.14 隐式类型的局部变量

在类或结构方法中,可以使用隐式类型指示编译器在编译时确定变量类型。 有关详细信息,请参阅 var(C# 参考)

05.01.15 记录

C# 9 引入了 record 类型,可创建此引用类型而不创建类或结构。 记录是带有内置行为的类,用于将数据封装在不可变类型中。 C# 10 引入了 record struct 值类型。 记录(record classrecord struct)提供以下功能:

  • 用于创建具有不可变属性的引用类型的简明语法。
  • 值相等性。 两个记录类型的变量在它们的类型和两个记录中每个字段的值都相同时,它们是相等的。 类使用引用相等性,即:如果类类型的两个变量引用同一对象,则这两个变量是相等的。
  • 非破坏性变化的简明语法。 使用 with 表达式,可以创建作为现有实例副本的新记录实例,但更改了指定的属性值。
  • 显示的内置格式设置。 ToString 方法输出记录类型名称以及公共属性的名称和值。
  • 支持记录类中的继承层次结构。 记录类支持继承。 记录结构不支持继承。

有关详细信息,请参阅记录

05.02 对象 - 创建类型的实例

类或结构定义的作用类似于蓝图,指定该类型可以进行哪些操作。 从本质上说,对象是按照此蓝图分配和配置的内存块。 程序可以创建同一个类的多个对象。 对象也称为实例,可以存储在命名变量中,也可以存储在数组或集合中。 使用这些变量来调用对象方法及访问对象公共属性的代码称为客户端代码。 在 C# 等面向对象的语言中,典型的程序由动态交互的多个对象组成。

备注

静态类型的行为与此处介绍的不同。 有关详细信息,请参阅静态类和静态类成员

05.02.01 结构实例与类实例

由于类是引用类型,因此类对象的变量引用该对象在托管堆上的地址。 如果将同一类型的第二个变量分配给第一个变量,则两个变量都引用该地址的对象。 本文稍后部分将更详细地讨论这一点。

类的实例是使用 new 运算符创建的。 在下面的示例中,Person 为类型,person1person2 为该类型的实例(即对象)。

cs 复制代码
using System;

public class Person
{
    public string Name { get; set; }
    public int Age { get; set; }
    public Person(string name, int age)
    {
        Name = name;
        Age = age;
    }
    // Other properties, methods, events...
}

class Program
{
    static void Main()
    {
        Person person1 = new Person("Leopold", 6);
        Console.WriteLine("person1 Name = {0} Age = {1}", person1.Name, person1.Age);

        // Declare new person, assign person1 to it.
        Person person2 = person1;

        // Change the name of person2, and person1 also changes.
        person2.Name = "Molly";
        person2.Age = 16;

        Console.WriteLine("person2 Name = {0} Age = {1}", person2.Name, person2.Age);
        Console.WriteLine("person1 Name = {0} Age = {1}", person1.Name, person1.Age);
    }
}
/*
    Output:
    person1 Name = Leopold Age = 6
    person2 Name = Molly Age = 16
    person1 Name = Molly Age = 16
*/

由于结构是值类型,因此结构对象的变量具有整个对象的副本。 结构的实例也可使用 new 运算符来创建,但这不是必需的,如下面的示例所示:

cs 复制代码
using System;

namespace Example
{
    public struct Person
    {
        public string Name;
        public int Age;
        public Person(string name, int age)
        {
            Name = name;
            Age = age;
        }
    }

    public class Application
    {
        static void Main()
        {
            // Create  struct instance and initialize by using "new".
            // Memory is allocated on thread stack.
            Person p1 = new Person("Alex", 9);
            Console.WriteLine("p1 Name = {0} Age = {1}", p1.Name, p1.Age);

            // Create  new struct object. Note that  struct can be initialized
            // without using "new".
            Person p2 = p1;

            // Assign values to p2 members.
            p2.Name = "Spencer";
            p2.Age = 7;
            Console.WriteLine("p2 Name = {0} Age = {1}", p2.Name, p2.Age);

            // p1 values remain unchanged because p2 is  copy.
            Console.WriteLine("p1 Name = {0} Age = {1}", p1.Name, p1.Age);
        }
    }
    /*
        Output:
        p1 Name = Alex Age = 9
        p2 Name = Spencer Age = 7
        p1 Name = Alex Age = 9
    */
}

p1p2 的内存在线程堆栈上进行分配。 该内存随声明它的类型或方法一起回收。 这就是在赋值时复制结构的一个原因。 相比之下,当对类实例对象的所有引用都超出范围时,为该类实例分配的内存将由公共语言运行时自动回收(垃圾回收)。 无法像在 C++ 中那样明确地销毁类对象。 有关 .NET 中的垃圾回收的详细信息,请参阅垃圾回收

备注

公共语言运行时中高度优化了托管堆上内存的分配和释放。 在大多数情况下,在堆上分配类实例与在堆栈上分配结构实例在性能成本上没有显著的差别。

05.02.02 对象标识与值相等性

在比较两个对象是否相等时,首先必须明确是想知道两个变量是否表示内存中的同一对象,还是想知道这两个对象的一个或多个字段的值是否相等。 如果要对值进行比较,则必须考虑这两个对象是值类型(结构)的实例,还是引用类型(类、委托、数组)的实例。

  • 若要确定两个类实例是否引用内存中的同一位置(这意味着它们具有相同的标识),可使用静态 Object.Equals 方法。 (System.Object 是所有值类型和引用类型的隐式基类,其中包括用户定义的结构和类。)

  • 若要确定两个结构实例中的实例字段是否具有相同的值,可使用 ValueType.Equals 方法。 由于所有结构都隐式继承自 System.ValueType,因此可以直接在对象上调用该方法,如以下示例所示

    cs 复制代码
    // Person is defined in the previous example.
    
    //public struct Person
    //{
    //    public string Name;
    //    public int Age;
    //    public Person(string name, int age)
    //    {
    //        Name = name;
    //        Age = age;
    //    }
    //}
    
    Person p1 = new Person("Wallace", 75);
    Person p2 = new Person("", 42);
    p2.Name = "Wallace";
    p2.Age = 75;
    
    if (p2.Equals(p1))
        Console.WriteLine("p2 and p1 have the same values.");
    
    // Output: p2 and p1 have the same values.

    EqualsSystem.ValueType 实现在某些情况下使用装箱和反射。 若要了解如何提供特定于类型的高效相等性算法,请参阅如何为类型定义值相等性。 记录是使用值语义实现相等性的引用类型。

  • 若要确定两个类实例中字段的值是否相等,可以使用 Equals 方法或 == 运算符。 但是,只有类通过重写或重载提供关于那种类型对象的"相等"含义的自定义时,才能使用它们。 类也可能实现 IEquatable<T> 接口或 IEqualityComparer<T> 接口。 这两个接口都提供可用于测试值相等性的方法。 设计好替代 Equals 的类后,请务必遵循如何为类型定义值相等性Object.Equals(Object) 中介绍的准则。

05.03 继承 - 派生用于创建更具体的行为的类型

继承(以及封装和多态性)是面向对象的编程的三个主要特征之一。 通过继承,可以创建新类,以便重用、扩展和修改在其他类中定义的行为。 其成员被继承的类称为"基类",继承这些成员的类称为"派生类"。 派生类只能有一个直接基类。 但是,继承是可传递的。 如果 ClassC 派生自 ClassB,并且 ClassB 派生自 ClassA,则 ClassC 将继承在 ClassBClassA 中声明的成员。

备注

结构不支持继承,但它们可以实现接口。

从概念上讲,派生类是基类的专门化。 例如,如果有一个基类 Animal,则可以有一个名为 Mammal 的派生类,以及另一个名为 Reptile 的派生类。 MammalAnimalReptile 也是 Animal,但每个派生类表示基类的不同专门化。

接口声明可以为其成员定义默认实现。 这些实现通过派生接口和实现这些接口的类来继承。 有关默认接口方法的详细信息,请参阅关于接口的文章。

定义要从其他类派生的类时,派生类会隐式获得基类的所有成员(除了其构造函数和终结器)。 派生类可以重用基类中的代码,而无需重新实现。 可以在派生类中添加更多成员。 派生类扩展了基类的功能。

下图显示一个类 WorkItem,它表示某个业务流程中的工作项。 像所有类一样,它派生自 System.Object 且继承其所有方法。 WorkItem 会添加其自己的六个成员。 这些成员中包括一个构造函数,因为不会继承构造函数。 类 ChangeRequest 继承自 WorkItem,表示特定类型的工作项。 ChangeRequest 将另外两个成员添加到它从 WorkItemObject 继承的成员中。 它必须添加自己的构造函数,并且还添加了 originalItemID。 属性 originalItemID 使 ChangeRequest 实例可以与向其应用更改请求的原始 WorkItem 相关联。

下面的示例演示如何在 C# 中表示前面图中所示的类关系。 该示例还演示了 WorkItem 替代虚方法 Object.ToString 的方式,以及 ChangeRequest 类继承该方法的 WorkItem 的实现方式。 第一个块定义类:

cs 复制代码
// WorkItem implicitly inherits from the Object class.
public class WorkItem
{
    // Static field currentID stores the job ID of the last WorkItem that
    // has been created.
    private static int currentID;

    //Properties.
    protected int ID { get; set; }
    protected string Title { get; set; }
    protected string Description { get; set; }
    protected TimeSpan jobLength { get; set; }

    // Default constructor. If a derived class does not invoke a base-
    // class constructor explicitly, the default constructor is called
    // implicitly.
    public WorkItem()
    {
        ID = 0;
        Title = "Default title";
        Description = "Default description.";
        jobLength = new TimeSpan();
    }

    // Instance constructor that has three parameters.
    public WorkItem(string title, string desc, TimeSpan joblen)
    {
        this.ID = GetNextID();
        this.Title = title;
        this.Description = desc;
        this.jobLength = joblen;
    }

    // Static constructor to initialize the static member, currentID. This
    // constructor is called one time, automatically, before any instance
    // of WorkItem or ChangeRequest is created, or currentID is referenced.
    static WorkItem() => currentID = 0;

    // currentID is a static field. It is incremented each time a new
    // instance of WorkItem is created.
    protected int GetNextID() => ++currentID;

    // Method Update enables you to update the title and job length of an
    // existing WorkItem object.
    public void Update(string title, TimeSpan joblen)
    {
        this.Title = title;
        this.jobLength = joblen;
    }

    // Virtual method override of the ToString method that is inherited
    // from System.Object.
    public override string ToString() =>
        $"{this.ID} - {this.Title}";
}

// ChangeRequest derives from WorkItem and adds a property (originalItemID)
// and two constructors.
public class ChangeRequest : WorkItem
{
    protected int originalItemID { get; set; }

    // Constructors. Because neither constructor calls a base-class
    // constructor explicitly, the default constructor in the base class
    // is called implicitly. The base class must contain a default
    // constructor.

    // Default constructor for the derived class.
    public ChangeRequest() { }

    // Instance constructor that has four parameters.
    public ChangeRequest(string title, string desc, TimeSpan jobLen,
                         int originalID)
    {
        // The following properties and the GetNexID method are inherited
        // from WorkItem.
        this.ID = GetNextID();
        this.Title = title;
        this.Description = desc;
        this.jobLength = jobLen;

        // Property originalItemID is a member of ChangeRequest, but not
        // of WorkItem.
        this.originalItemID = originalID;
    }
}

下一个块显示如何使用基类和派生类:

cs 复制代码
// Create an instance of WorkItem by using the constructor in the
// base class that takes three arguments.
WorkItem item = new WorkItem("Fix Bugs",
                            "Fix all bugs in my code branch",
                            new TimeSpan(3, 4, 0, 0));

// Create an instance of ChangeRequest by using the constructor in
// the derived class that takes four arguments.
ChangeRequest change = new ChangeRequest("Change Base Class Design",
                                        "Add members to the class",
                                        new TimeSpan(4, 0, 0),
                                        1);

// Use the ToString method defined in WorkItem.
Console.WriteLine(item.ToString());

// Use the inherited Update method to change the title of the
// ChangeRequest object.
change.Update("Change the Design of the Base Class",
    new TimeSpan(4, 0, 0));

// ChangeRequest inherits WorkItem's override of ToString.
Console.WriteLine(change.ToString());
/* Output:
    1 - Fix Bugs
    2 - Change the Design of the Base Class
*/

05.03.01 抽象方法和虚方法

基类将方法声明为 virtual 时,派生类可以使用其自己的实现override该方法。 如果基类将成员声明为 abstract,则必须在直接继承自该类的任何非抽象类中重写该方法。 如果派生类本身是抽象的,则它会继承抽象成员而不会实现它们。 抽象和虚拟成员是多形性(面向对象的编程的第二个主要特征)的基础。 有关详细信息,请参阅多态性

05.03.02 抽象基类

如果要通过使用 new 运算符来防止直接实例化,则可以将类声明为抽象。 只有当一个新类派生自该类时,才能使用抽象类。 抽象类可以包含一个或多个本身声明为抽象的方法签名。 这些签名指定参数和返回值,但没有任何实现(方法体)。 抽象类不必包含抽象成员;但是,如果类包含抽象成员,则类本身必须声明为抽象。 本身不抽象的派生类必须为来自抽象基类的任何抽象方法提供实现。

05.03.03 接口

接口是定义一组成员的引用类型。 实现该接口的所有类和结构都必须实现这组成员。 接口可以为其中任何成员或全部成员定义默认实现。 类可以实现多个接口,即使它只能派生自单个直接基类。

接口用于为类定义特定功能,这些功能不一定具有"is a (是)"关系。 例如,System.IEquatable<T> 接口可由任何类或结构实现,以确定该类型的两个对象是否等效(但是由该类型定义等效性)。 IEquatable<T> 不表示基类和派生类之间存在的同一种"是"关系(例如,MammalAnimal)。 有关详细信息,请参阅接口

05.03.04 防止进一步派生

类可以通过将自己或成员声明为 sealed,来防止其他类继承自它或继承自其任何成员。

05.03.05 基类成员的派生类隐藏

派生类可以通过使用相同名称和签名声明成员来隐藏基类成员。 new 修饰符可以用于显式指示成员不应作为基类成员的重写。 使用 new 不是必需的,但如果未使用 new,则会产生编译器警告。 有关详细信息,请参阅使用 Override 和 New 关键字进行版本控制了解何时使用 Override 和 New 关键字

05.03 多形性

多态性常被视为自封装和继承之后,面向对象的编程的第三个支柱。 Polymorphism(多态性)是一个希腊词,指"多种形态",多态性具有两个截然不同的方面:

  • 在运行时,在方法参数和集合或数组等位置,派生类的对象可以作为基类的对象处理。 在出现此多形性时,该对象的声明类型不再与运行时类型相同。
  • 基类可以定义并实现方法,派生类可以重写这些方法,即派生类提供自己的定义和实现。 在运行时,客户端代码调用该方法,CLR 查找对象的运行时类型,并调用虚方法的重写方法。 你可以在源代码中调用基类的方法,执行该方法的派生类版本。

虚方法允许你以统一方式处理多组相关的对象。 例如,假定你有一个绘图应用程序,允许用户在绘图图面上创建各种形状。 你在编译时不知道用户将创建哪些特定类型的形状。 但应用程序必须跟踪创建的所有类型的形状,并且必须更新这些形状以响应用户鼠标操作。 你可以使用多态性通过两个基本步骤解决这一问题:

  1. 创建一个类层次结构,其中每个特定形状类均派生自一个公共基类。
  2. 使用虚方法通过对基类方法的单个调用来调用任何派生类上的相应方法。

首先,创建一个名为 Rectangle``Shape 的基类,并创建一些派生类,例如 Triangle``Circle、 和 。 为 Shape 类提供一个名为 Draw 的虚拟方法,并在每个派生类中重写该方法以绘制该类表示的特定形状。 创建 List<Shape> 对象,并向其添加 CircleTriangleRectangle

cs 复制代码
public class Shape
{
    // A few example members
    public int X { get; private set; }
    public int Y { get; private set; }
    public int Height { get; set; }
    public int Width { get; set; }

    // Virtual method
    public virtual void Draw()
    {
        Console.WriteLine("Performing base class drawing tasks");
    }
}

public class Circle : Shape
{
    public override void Draw()
    {
        // Code to draw a circle...
        Console.WriteLine("Drawing a circle");
        base.Draw();
    }
}
public class Rectangle : Shape
{
    public override void Draw()
    {
        // Code to draw a rectangle...
        Console.WriteLine("Drawing a rectangle");
        base.Draw();
    }
}
public class Triangle : Shape
{
    public override void Draw()
    {
        // Code to draw a triangle...
        Console.WriteLine("Drawing a triangle");
        base.Draw();
    }
}

若要更新绘图图面,请使用 foreach 循环对该列表进行循环访问,并对其中的每个 Shape 对象调用 Draw 方法。 虽然列表中的每个对象都具有声明类型 Shape,但调用的将是运行时类型(该方法在每个派生类中的重写版本)。

cs 复制代码
// Polymorphism at work #1: a Rectangle, Triangle and Circle
// can all be used wherever a Shape is expected. No cast is
// required because an implicit conversion exists from a derived
// class to its base class.
var shapes = new List<Shape>
{
    new Rectangle(),
    new Triangle(),
    new Circle()
};

// Polymorphism at work #2: the virtual method Draw is
// invoked on each of the derived classes, not the base class.
foreach (var shape in shapes)
{
    shape.Draw();
}
/* Output:
    Drawing a rectangle
    Performing base class drawing tasks
    Drawing a triangle
    Performing base class drawing tasks
    Drawing a circle
    Performing base class drawing tasks
*/

在 C# 中,每个类型都是多态的,因为包括用户定义类型在内的所有类型都继承自 Object

05.03.01 多形性概述

05.03.02 虚拟成员

当派生类从基类继承时,它包括基类的所有成员。 基类中声明的所有行为都是派生类的一部分。 这使派生类的对象能够被视为基类的对象。 访问修饰符(publicprotectedprivate 等)确定是否可以从派生类实现访问这些成员。 通过虚拟方法,设计器可以选择不同的派生类行为:

  • 派生类可以重写基类中的虚拟成员,并定义新行为。
  • 派生类可能会继承最接近的基类方法而不重写方法,同时保留现有的行为,但允许进一步派生的类重写方法。
  • 派生类可以定义隐藏基类实现的成员的新非虚实现。

仅当基类成员声明为 virtualabstract 时,派生类才能重写基类成员。 派生成员必须使用 override 关键字显式指示该方法将参与虚调用。 以下代码提供了一个示例:

cs 复制代码
public class BaseClass
{
    public virtual void DoWork() { }
    public virtual int WorkProperty
    {
        get { return 0; }
    }
}
public class DerivedClass : BaseClass
{
    public override void DoWork() { }
    public override int WorkProperty
    {
        get { return 0; }
    }
}

字段不能是虚拟的,只有方法、属性、事件和索引器才可以是虚拟的。 当派生类重写某个虚拟成员时,即使该派生类的实例被当作基类的实例访问,也会调用该成员。 以下代码提供了一个示例:

cs 复制代码
DerivedClass B = new DerivedClass();
B.DoWork();  // Calls the new method.

BaseClass A = B;
A.DoWork();  // Also calls the new method.

虚方法和属性允许派生类扩展基类,而无需使用方法的基类实现。 有关详细信息,请参阅使用 Override 和 New 关键字进行版本控制。 接口提供另一种方式来定义将实现留给派生类的方法或方法集。

05.03.03 使用新成员隐藏基类成员

如果希望派生类具有与基类中的成员同名的成员,则可以使用 new 关键字隐藏基类成员。 new 关键字放置在要替换的类成员的返回类型之前。 以下代码提供了一个示例:

cs 复制代码
public class BaseClass
{
    public void DoWork() { WorkField++; }
    public int WorkField;
    public int WorkProperty
    {
        get { return 0; }
    }
}

public class DerivedClass : BaseClass
{
    public new void DoWork() { WorkField++; }
    public new int WorkField;
    public new int WorkProperty
    {
        get { return 0; }
    }
}

通过将派生类的实例强制转换为基类的实例,可以从客户端代码访问隐藏的基类成员。 例如:

cs 复制代码
DerivedClass B = new DerivedClass();
B.DoWork();  // Calls the new method.

BaseClass A = (BaseClass)B;
A.DoWork();  // Calls the old method.

05.03.04 阻止派生类重写虚拟成员

无论在虚拟成员和最初声明虚拟成员的类之间已声明了多少个类,虚拟成员都是虚拟的。 如果类 A 声明了一个虚拟成员,类 BA 派生,类 C 从类 B 派生,则不管类 B 是否为虚拟成员声明了重写,类 C 都会继承该虚拟成员,并可以重写它。 以下代码提供了一个示例:

cs 复制代码
public class A
{
    public virtual void DoWork() { }
}
public class B : A
{
    public override void DoWork() { }
}

派生类可以通过将重写声明为 sealed 来停止虚拟继承。 停止继承需要在类成员声明中的 override 关键字前面放置 sealed 关键字。 以下代码提供了一个示例:

cs 复制代码
public class C : B
{
    public sealed override void DoWork() { }
}

在上一个示例中,方法 DoWork 对从 C 派生的任何类都不再是虚拟方法。 即使它们转换为类型 B 或类型 A,它对于 C 的实例仍然是虚拟的。 通过使用 new 关键字,密封的方法可以由派生类替换,如下面的示例所示:

cs 复制代码
public class D : C
{
    public new void DoWork() { }
}

在此情况下,如果在 D 中使用类型为 D 的变量调用 DoWork,被调用的将是新的 DoWork。 如果使用类型为 CBA 的变量访问 D 的实例,对 DoWork 的调用将遵循虚拟继承的规则,即把这些调用传送到类 CDoWork 实现。

05.03.05 从派生类访问基类虚拟成员

已替换或重写某个方法或属性的派生类仍然可以使用 base 关键字访问基类的该方法或属性。 以下代码提供了一个示例:

cs 复制代码
public class Base
{
    public virtual void DoWork() {/*...*/ }
}
public class Derived : Base
{
    public override void DoWork()
    {
        //Perform Derived's work here
        //...
        // Call DoWork on base class
        base.DoWork();
    }
}

有关详细信息,请参阅 base

备注

建议虚拟成员在它们自己的实现中使用 base 来调用该成员的基类实现。 允许基类行为发生使得派生类能够集中精力实现特定于派生类的行为。 未调用基类实现时,由派生类负责使它们的行为与基类的行为兼容。

06 异常和异常处理

C# 语言的异常处理功能有助于处理在程序运行期间发生的任何意外或异常情况。 异常处理功能使用 trycatchfinally 关键字来尝试执行可能失败的操作、在你确定合理的情况下处理故障,以及在事后清除资源。 公共语言运行时 (CLR)、.NET/第三方库或应用程序代码都可生成异常。 异常是使用 throw 关键字创建而成。

在许多情况下,异常并不是由代码直接调用的方法抛出,而是由调用堆栈中再往下的另一方法抛出。 如果发生这种异常,CLR 会展开堆栈,同时针对特定异常类型查找包含 catch 代码块的方法,并执行它找到的首个此类 catch 代码块。 如果在调用堆栈中找不到相应的 catch 代码块,将会终止进程并向用户显示消息。

在以下示例中,方法用于测试除数是否为零,并捕获相应的错误。 如果没有异常处理功能,此程序将终止,并显示 DivideByZeroException was unhandled 错误。

cs 复制代码
public class ExceptionTest
{
    static double SafeDivision(double x, double y)
    {
        if (y == 0)
            throw new DivideByZeroException();
        return x / y;
    }

    public static void Main()
    {
        // Input for test purposes. Change the values to see
        // exception handling behavior.
        double a = 98, b = 0;
        double result;

        try
        {
            result = SafeDivision(a, b);
            Console.WriteLine("{0} divided by {1} = {2}", a, b, result);
        }
        catch (DivideByZeroException)
        {
            Console.WriteLine("Attempted divide by zero.");
        }
    }
}

06.01 异常概述

异常具有以下属性:

  • 异常是最终全都派生自 System.Exception 的类型。
  • 在可能抛出异常的语句周围使用 try 代码块。
  • try 代码块中出现异常后,控制流会跳转到调用堆栈中任意位置上的首个相关异常处理程序。 在 C# 中,catch 关键字用于定义异常处理程序。
  • 如果给定的异常没有对应的异常处理程序,那么程序会停止执行,并显示错误消息。
  • 除非可以处理异常并让应用程序一直处于已知状态,否则不捕获异常。 如果捕获 System.Exception,使用 catch 代码块末尾的 throw 关键字重新抛出异常。
  • 如果 catch 代码块定义异常变量,可以用它来详细了解所发生的异常类型。
  • 使用 throw 关键字,程序可以显式生成异常。
  • 异常对象包含错误详细信息,如调用堆栈的状态和错误的文本说明。
  • 即使引发异常,finally 代码块中的代码仍会执行。 使用 finally 代码块可释放资源。例如,关闭在 try 代码块中打开的任何流或文件。
  • .NET 中的托管异常在 Win32 结构化异常处理机制的基础之上实现。 有关详细信息,请参阅结构化异常处理 (C/C++)速成教程:深入了解 Win32 结构化异常处理

06.02 使用异常

在 C# 中,程序中的运行时错误通过使用一种称为"异常"的机制在程序中传播。 异常由遇到错误的代码引发,由能够更正错误的代码捕捉。 异常可由 .NET 运行时或由程序中的代码引发。 一旦引发了一个异常,此异常会在调用堆栈中传播,直到找到针对它的 catch 语句。 未捕获的异常由系统提供的通用异常处理程序处理,该处理程序会显示一个对话框。

异常由从 Exception 派生的类表示。 此类标识异常的类型,并包含详细描述异常的属性。 引发异常涉及创建异常派生类的实例,配置异常的属性(可选),然后使用 throw 关键字引发该对象。 例如:

cs 复制代码
class CustomException : Exception
{
    public CustomException(string message)
    {
    }
}
private static void TestThrow()
{
    throw new CustomException("Custom exception in TestThrow()");
}

引发异常后,运行时将检查当前语句,以确定它是否在 try 块内。 如果在,则将检查与 try 块关联的所有 catch 块,以确定它们是否可以捕获该异常。 Catch 块通常会指定异常类型;如果该 catch 块的类型与异常或异常的基类的类型相同,则该 catch 块可处理该方法。 例如:

cs 复制代码
try
{
    TestThrow();
}
catch (CustomException ex)
{
    System.Console.WriteLine(ex.ToString());
}

如果引发异常的语句不在 try 块内或者包含该语句的 try 块没有匹配的 catch 块,则运行时将检查调用方法中是否有 try 语句和 catch 块。 运行时将继续调用堆栈,搜索兼容的 catch 块。 在找到并执行 catch 块之后,控制权将传递给 catch 块之后的下一个语句。

一个 try 语句可包含多个 catch 块。 将执行第一个能够处理该异常的 catch 语句;将忽略任何后续的 catch 语句,即使它们是兼容的也是如此。 按从最具有针对性(或派生程度最高)到最不具有针对性的顺序对 catch 块排列。 例如:

cs 复制代码
using System;
using System.IO;

namespace Exceptions
{
    public class CatchOrder
    {
        public static void Main()
        {
            try
            {
                using (var sw = new StreamWriter("./test.txt"))
                {
                    sw.WriteLine("Hello");
                }
            }
            // Put the more specific exceptions first.
            catch (DirectoryNotFoundException ex)
            {
                Console.WriteLine(ex);
            }
            catch (FileNotFoundException ex)
            {
                Console.WriteLine(ex);
            }
            // Put the least specific exception last.
            catch (IOException ex)
            {
                Console.WriteLine(ex);
            }
            Console.WriteLine("Done");
        }
    }
}

执行 catch 块之前,运行时会检查 finally 块。 Finally 块使程序员可以清除中止的 try 块可能遗留下的任何模糊状态,或者释放任何外部资源(例如图形句柄、数据库连接或文件流),而无需等待垃圾回收器在运行时完成这些对象。 例如:

cs 复制代码
static void TestFinally()
{
    FileStream? file = null;
    //Change the path to something that works on your machine.
    FileInfo fileInfo = new System.IO.FileInfo("./file.txt");

    try
    {
        file = fileInfo.OpenWrite();
        file.WriteByte(0xF);
    }
    finally
    {
        // Closing the file allows you to reopen it immediately - otherwise IOException is thrown.
        file?.Close();
    }

    try
    {
        file = fileInfo.OpenWrite();
        Console.WriteLine("OpenWrite() succeeded");
    }
    catch (IOException)
    {
        Console.WriteLine("OpenWrite() failed");
    }
}

如果 WriteByte() 引发了异常并且未调用 file.Close(),则第二个 try 块中尝试重新打开文件的代码将会失败,并且文件将保持锁定状态。 由于即使引发异常也会执行 finally 块,前一示例中的 finally 块可使文件正确关闭,从而有助于避免错误。

如果引发异常之后没有在调用堆栈上找到兼容的 catch 块,则会出现以下三种情况之一:

  • 如果异常存在于终结器内,将中止终结器,并调用基类终结器(如果有)。
  • 如果调用堆栈包含静态构造函数或静态字段初始值设定项,将引发 TypeInitializationException,同时将原始异常分配给新异常的 InnerException 属性。
  • 如果到达线程的开头,则终止线程。

06.03 异常处理

C# 程序员使用 try 块来对可能受异常影响的代码进行分区。 关联的 catch 块用于处理生成的任何异常。 finally 块包含无论 try 块中是否引发异常都会运行的代码,如发布 try 块中分配的资源。 try 块需要一个或多个关联的 catch 块或一个 finally 块,或两者皆之。

下面的示例演示 try-catch 语句、try-finally 语句和 try-catch-finally 语句。

cs 复制代码
try
{
    // Code to try goes here.
}
catch (SomeSpecificException ex)
{
    // Code to handle the exception goes here.
    // Only catch exceptions that you know how to handle.
    // Never catch base class System.Exception without
    // rethrowing it at the end of the catch block.
}

C#

cs 复制代码
try
{
    // Code to try goes here.
}
finally
{
    // Code to execute after the try block goes here.
}

C#

cs 复制代码
try
{
    // Code to try goes here.
}
catch (SomeSpecificException ex)
{
    // Code to handle the exception goes here.
}
finally
{
    // Code to execute after the try (and possibly catch) blocks
    // goes here.
}

一个不具有 catchfinally 块的 try 块会导致编译器错误。

06.03.01 catch 块

catch 块可以指定要捕获的异常的类型。 该类型规范称为异常筛选器。 异常类型应派生自 Exception。 一般情况下,不要将 Exception 指定为异常筛选器,除非了解如何处理可能在 try 块中引发的所有异常,或者已在 catch 块的末尾处包括了 throw 语句

可将具有不同异常类的多个 catch 块链接在一起。 代码中 catch 块的计算顺序为从上到下,但针对引发的每个异常,仅执行一个 catch 块。 将执行指定所引发的异常的确切类型或基类的第一个 catch 块。 如果没有 catch 块指定匹配的异常类,则将选择不具有类型的 catch 块(如果语句中存在)。 务必首先定位具有最具体的(即,最底层派生的)异常类的 catch 块。

当以下条件为 true 时,捕获异常:

  • 能够很好地理解可能会引发异常的原因,并且可以实现特定的恢复,例如捕获 FileNotFoundException 对象时提示用户输入新文件名。

  • 可以创建和引发一个新的、更具体的异常。

    cs 复制代码
    int GetInt(int[] array, int index)
    {
        try
        {
            return array[index];
        }
        catch (IndexOutOfRangeException e)
        {
            throw new ArgumentOutOfRangeException(
                "Parameter index is out of range.", e);
        }
    }
  • 想要先对异常进行部分处理,然后再将其传递以进行更多处理。 在下面的示例中,catch 块用于在重新引发异常之前将条目添加到错误日志。

    cs 复制代码
    try
    {
        // Try to access a resource.
    }
    catch (UnauthorizedAccessException e)
    {
        // Call a custom error logging procedure.
        LogError(e);
        // Re-throw the error.
        throw;
    }

还可以指定异常筛选器,以向 catch 子句添加布尔表达式。 异常筛选器表明仅当条件为 true 时,特定 catch 子句才匹配。 在以下示例中,两个 catch 子句均使用相同的异常类,但是会检查其他条件以创建不同的错误消息:

cs 复制代码
int GetInt(int[] array, int index)
{
    try
    {
        return array[index];
    }
    catch (IndexOutOfRangeException e) when (index < 0) 
    {
        throw new ArgumentOutOfRangeException(
            "Parameter index cannot be negative.", e);
    }
    catch (IndexOutOfRangeException e)
    {
        throw new ArgumentOutOfRangeException(
            "Parameter index cannot be greater than the array size.", e);
    }
}

始终返回 false 的异常筛选器可用于检查所有异常,但不可用于处理异常。 典型用途是记录异常:

cs 复制代码
public class ExceptionFilter
{
    public static void Main()
    {
        try
        {
            string? s = null;
            Console.WriteLine(s.Length);
        }
        catch (Exception e) when (LogException(e))
        {
        }
        Console.WriteLine("Exception must have been handled");
    }

    private static bool LogException(Exception e)
    {
        Console.WriteLine($"\tIn the log routine. Caught {e.GetType()}");
        Console.WriteLine($"\tMessage: {e.Message}");
        return false;
    }
}

LogException 方法始终返回 false,使用此异常筛选器的 catch 子句均不匹配。 catch 子句可以是通用的,使用 System.Exception后面的子句可以处理更具体的异常类。

06.03.02 finally 块

finally 块让你可以清理在 try 块中所执行的操作。 如果存在 finally 块,将在执行 try 块和任何匹配的 catch 块之后,最后执行它。 无论是否会引发异常或找到匹配异常类型的 catch 块,finally 块都将始终运行。

finally 块可用于发布资源(如文件流、数据库连接和图形句柄)而无需等待运行时中的垃圾回收器来完成对象。

在下面的示例中,finally 块用于关闭在 try 块中打开的文件。 请注意,在关闭文件之前,将检查文件句柄的状态。 如果 try 块不能打开文件,则文件句柄仍将具有值 nullfinally 块不会尝试将其关闭。 或者,如果在 try 块中成功打开文件,则 finally 块将关闭打开的文件。

FileStream? file = null;
FileInfo fileinfo = new System.IO.FileInfo("./file.txt");
try
{
    file = fileinfo.OpenWrite();
    file.WriteByte(0xF);
}
finally
{
    // Check for null because OpenWrite might have failed.
    file?.Close();
}

有关详细信息,请参阅 C# 语言规范中的异常try 语句。 该语言规范是 C# 语法和用法的权威资料。

06.04 创建和引发异常

异常用于指示在运行程序时发生了错误。 此时将创建一个描述错误的异常对象,然后使用 throw 语句或表达式引发。 然后,运行时搜索最兼容的异常处理程序。

当存在下列一种或多种情况时,程序员应引发异常:

  • 方法无法完成其定义的功能。 例如,如果一种方法的参数具有无效的值:

    static void CopyObject(SampleClass original)
    {
        _ = original ?? throw new ArgumentException("Parameter cannot be null", nameof(original));
    }
    
  • 根据对象的状态,对某个对象进行不适当的调用。 一个示例可能是尝试写入只读文件。 在对象状态不允许操作的情况下,引发 InvalidOperationException 的实例或基于此类的派生的对象。 以下代码是引发 InvalidOperationException 对象的方法示例:

    public class ProgramLog
    {
        FileStream logFile = null!;
        public void OpenLog(FileInfo fileName, FileMode mode) { }
    
        public void WriteLog()
        {
            if (!logFile.CanWrite)
            {
                throw new InvalidOperationException("Logfile cannot be read-only");
            }
            // Else write data to the log and return.
        }
    }
    
  • 方法的参数引发了异常。 在这种情况下,应捕获原始异常,并创建 ArgumentException 实例。 应将原始异常作为 InnerException 参数传递给 ArgumentException 的构造函数:

    static int GetValueFromArray(int[] array, int index)
    {
        try
        {
            return array[index];
        }
        catch (IndexOutOfRangeException e)
        {
            throw new ArgumentOutOfRangeException(
                "Parameter index is out of range.", e);
        }
    }
    

    备注

    前面的示例演示了如何使用 InnerException 属性。 这是有意简化的。 在实践中,应先检查索引是否在范围内,然后再使用它。 当参数成员引发在调用成员之前无法预料到的异常时,可以使用此方法来包装异常。

异常包含一个名为 StackTrace 的属性。 此字符串包含当前调用堆栈上的方法的名称,以及为每个方法引发异常的位置(文件名和行号)。 StackTrace 对象由公共语言运行时 (CLR) 从 throw 语句的位置点自动创建,因此必须从堆栈跟踪的开始点引发异常。

所有异常都包含一个名为 Message 的属性。 应设置此字符串来解释发生异常的原因。 不应将安全敏感的信息放在消息文本中。 除 Message 以外,ArgumentException 也包含一个名为 ParamName 的属性,应将该属性设置为导致引发异常的参数的名称。 在属性资源库中,ParamName 应设置为 value

公共的受保护方法在无法完成其预期功能时将引发异常。 引发的异常类是符合错误条件的最具体的可用异常。 这些异常应编写为类功能的一部分,并且原始类的派生类或更新应保留相同的行为以实现后向兼容性。

06.04.01 引发异常时应避免的情况

以下列表标识了引发异常时要避免的做法:

  • 不要使用异常在正常执行过程中更改程序的流。 使用异常来报告和处理错误条件。
  • 只能引发异常,而不能作为返回值或参数返回异常。
  • 请勿有意从自己的源代码中引发 System.ExceptionSystem.SystemExceptionSystem.NullReferenceExceptionSystem.IndexOutOfRangeException
  • 不要创建可在调试模式下引发,但不会在发布模式下引发的异常。 若要在开发阶段确定运行时错误,请改用调试断言。

06.04.02 任务返回方法中的异常

使用 async 修饰符声明的方法在出现异常时,有一些特殊的注意事项。 方法 async 中引发的异常会存储在返回的任务中,直到任务即将出现时才会出现。 有关存储的异常的详细信息,请参阅异步异常

建议在输入方法的异步部分之前验证参数并引发任何相应的异常,例如 ArgumentExceptionArgumentNullException。 也就是说,在开始工作之前,这些验证异常应同步出现。 以下代码片段演示了一个示例,其中,如果引发异常,ArgumentException 个异常将同步出现,而 InvalidOperationException 个将存储在返回的任务中。

C#复制

// Non-async, task-returning method.
// Within this method (but outside of the local function),
// any thrown exceptions emerge synchronously.
public static Task<Toast> ToastBreadAsync(int slices, int toastTime)
{
    if (slices is < 1 or > 4)
    {
        throw new ArgumentException(
            "You must specify between 1 and 4 slices of bread.",
            nameof(slices));
    }

    if (toastTime < 1)
    {
        throw new ArgumentException(
            "Toast time is too short.", nameof(toastTime));
    }

    return ToastBreadAsyncCore(slices, toastTime);

    // Local async function.
    // Within this function, any thrown exceptions are stored in the task.
    static async Task<Toast> ToastBreadAsyncCore(int slices, int time)
    {
        for (int slice = 0; slice < slices; slice++)
        {
            Console.WriteLine("Putting a slice of bread in the toaster");
        }
        // Start toasting.
        await Task.Delay(time);

        if (time > 2_000)
        {
            throw new InvalidOperationException("The toaster is on fire!");
        }

        Console.WriteLine("Toast is ready!");

        return new Toast();
    }
}

06.04.03 定义异常的类别

程序可以引发 System 命名空间中的预定义异常类(前面提到的情况除外),或通过从 Exception 派生来创建其自己的异常类。 派生类应该至少定义三个构造函数:一个无参数构造函数、一个用于设置消息属性,还有一个用于设置 MessageInnerException 属性。 例如:

C#复制

[Serializable]
public class InvalidDepartmentException : Exception
{
    public InvalidDepartmentException() : base() { }
    public InvalidDepartmentException(string message) : base(message) { }
    public InvalidDepartmentException(string message, Exception inner) : base(message, inner) { }
}

当新属性提供的数据有助于解决异常时,将新属性添加到异常类中。 如果将新属性添加到派生异常类中,则应替代 ToString() 以返回添加的信息。

06.04 编译器生成的异常

当基本操作失败时,.NET 运行时会自动引发一些异常。 这些异常及其错误条件在下表中列出。

例外 描述
ArithmeticException 算术运算期间出现的异常的基类,例如 DivideByZeroExceptionOverflowException
ArrayTypeMismatchException 由于元素的实际类型与数组的实际类型不兼容而导致数组无法存储给定元素时引发。
DivideByZeroException 尝试将整数值除以零时引发。
IndexOutOfRangeException 索引小于零或超出数组边界时,尝试对数组编制索引时引发。
InvalidCastException 从基类型显式转换为接口或派生类型在运行时失败时引发。
NullReferenceException 尝试引用值为 null 的对象时引发。
OutOfMemoryException 尝试使用运算符分配内存失败时引发。 此异常表示可用于公共语言运行时的内存已用尽。
OverflowException checked 上下文中的算术运算溢出时引发。
StackOverflowException 执行堆栈由于有过多挂起的方法调用而用尽时引发;通常表示非常深的递归或无限递归。
TypeInitializationException 静态构造函数引发异常并且没有兼容的 catch 子句来捕获异常时引发。

07 C#语法的骨架,语句概要(更详细的在后面)

07.01 语句概要

程序执行的操作采用语句表达。 常见操作包括声明变量、赋值、调用方法、循环访问集合,以及根据给定条件分支到一个或另一个代码块。 语句在程序中的执行顺序称为"控制流"或"执行流"。 根据程序对运行时所收到的输入的响应,在程序每次运行时控制流可能有所不同。

语句可以是以分号结尾的单行代码,也可以是语句块中的一系列单行语句。 语句块括在括号 {} 中,并且可以包含嵌套块。 以下代码演示了两个单行语句示例和一个多行语句块:

cs 复制代码
    public static void Main()
    {
        // Declaration statement.
        int counter;

        // Assignment statement.
        counter = 1;

        // Error! This is an expression, not an expression statement.
        // counter + 1;

        // Declaration statements with initializers are functionally
        // equivalent to  declaration statement followed by assignment statement:
        int[] radii = [15, 32, 108, 74, 9]; // Declare and initialize an array.
        const double pi = 3.14159; // Declare and initialize  constant.

        // foreach statement block that contains multiple statements.
        foreach (int radius in radii)
        {
            // Declaration statement with initializer.
            double circumference = pi * (2 * radius);

            // Expression statement (method invocation). A single-line
            // statement can span multiple text lines because line breaks
            // are treated as white space, which is ignored by the compiler.
            System.Console.WriteLine("Radius of circle #{0} is {1}. Circumference = {2:N2}",
                                    counter, radius, circumference);

            // Expression statement (postfix increment).
            counter++;
        } // End of foreach statement block
    } // End of Main method body.
} // End of SimpleStatements class.
/*
   Output:
    Radius of circle #1 = 15. Circumference = 94.25
    Radius of circle #2 = 32. Circumference = 201.06
    Radius of circle #3 = 108. Circumference = 678.58
    Radius of circle #4 = 74. Circumference = 464.96
    Radius of circle #5 = 9. Circumference = 56.55
*/

07.02 语句的类型

下表列出了 C# 中的各种语句类型及其关联的关键字,并提供指向包含详细信息的主题的链接:

类别 C# 关键字/说明
声明语句 声明语句引入新的变量或常量。 变量声明可以选择为变量赋值。 在常量声明中必须赋值。
表达式语句 用于计算值的表达式语句必须在变量中存储该值。
选择语句 选择语句用于根据一个或多个指定条件分支到不同的代码段。 有关详情,请参阅以下主题: * if * switch
迭代语句 迭代语句用于遍历集合(如数组),或重复执行同一组语句直到满足指定的条件。 有关详情,请参阅以下主题: * do * for * foreach * while
跳转语句 跳转语句将控制转移给另一代码段。 有关详情,请参阅以下主题: * break * continue * goto * return * yield
异常处理语句 异常处理语句用于从运行时发生的异常情况正常恢复。 有关详情,请参阅以下主题: * throw * try-catch * try-finally * try-catch-finally
checked 和 unchecked checkedunchecked 语句用于指定将结果存储在变量中、但该变量过小而不能容纳结果值时,是否允许整型数值运算导致溢出。
await 语句 如果用 async 修饰符标记方法,则可以使用该方法中的 await 运算符。 在控制到达异步方法的 await 表达式时,控制将返回到调用方,该方法中的进程将挂起,直到等待的任务完成为止。 任务完成后,可以在方法中恢复执行。 有关简单示例,请参阅方法的"异步方法"一节。 有关详细信息,请参阅 async 和 await 的异步编程
yield return 语句 迭代器对集合执行自定义迭代,如列表或数组。 迭代器使用 yield return 语句返回元素,每次返回一个。 到达 yield return 语句时,会记住当前在代码中的位置。 下次调用迭代器时,将从该位置重新开始执行。 有关更多信息,请参见 迭代器
fixed 语句 fixed 语句禁止垃圾回收器重定位可移动的变量。 有关详细信息,请参阅 fixed
lock 语句 lock 语句用于限制一次仅允许一个线程访问代码块。 有关详细信息,请参阅 lock
带标签的语句 可以为语句指定一个标签,然后使用 goto 关键字跳转到该带标签的语句。 (参见下一行中的示例。)
空语句 空语句只含一个分号。 不执行任何操作,可以在需要语句但不需要执行任何操作的地方使用。

07.02.01 声明语句

以下代码显示了具有和不具有初始赋值的变量声明的示例,以及具有必要初始化的常量声明。

// Variable declaration statements.
double area;
double radius = 2;

// Constant declaration statement.
const double pi = 3.14159;

07.02.02 表达式语句

以下代码显示了表达式语句的示例,包括赋值、使用赋值创建对象和方法调用。

C#

// Expression statement (assignment).
area = 3.14 * (radius * radius);

// Error. Not  statement because no assignment:
//circ * 2;

// Expression statement (method invocation).
System.Console.WriteLine();

// Expression statement (new object creation).
System.Collections.Generic.List<string> strings =
    new System.Collections.Generic.List<string>();

07.02.03 空语句

以下示例演示了空语句的两种用法:

C#

void ProcessMessages()
{
    while (ProcessMessage())
        ; // Statement needed here.
}

void F()
{
    //...
    if (done) goto exit;
//...
exit:
    ; // Statement needed here.
}

07.02.04 嵌入式语句

某些语句(如迭代语句)后面始终跟有一条嵌入式语句。 此嵌入式语句可以是单个语句,也可以是语句块中括在括号 {} 内的多个语句。 甚至可以在括号 {} 内包含单行嵌入式语句,如以下示例所示:

C#

// Recommended style. Embedded statement in  block.
foreach (string s in System.IO.Directory.GetDirectories(
                        System.Environment.CurrentDirectory))
{
    System.Console.WriteLine(s);
}

// Not recommended.
foreach (string s in System.IO.Directory.GetDirectories(
                        System.Environment.CurrentDirectory))
    System.Console.WriteLine(s);

未括在括号 {} 内的嵌入式语句不能作为声明语句或带标签的语句。 下面的示例对此进行了演示:

C#

if(pointB == true)
    //Error CS1023:
    int radius = 5;

将该嵌入式语句放在语句块中以修复错误:

C#

cs 复制代码
if (b == true)
{
    // OK:
    System.DateTime d = System.DateTime.Now;
    System.Console.WriteLine(d.ToLongDateString());
}

07.02.05 嵌套语句块

语句块可以嵌套,如以下代码所示:

C#

cs 复制代码
foreach (string s in System.IO.Directory.GetDirectories(
    System.Environment.CurrentDirectory))
{
    if (s.StartsWith("CSharp"))
    {
        if (s.EndsWith("TempFolder"))
        {
            return s;
        }
    }
}
return "Not found.";

07.02.06 无法访问的语句

如果编译器认为在任何情况下控制流都无法到达特定语句,将生成警告 CS0162,如下例所示:

C#

cs 复制代码
// An over-simplified example of unreachable code.
const int val = 5;
if (val < 4)
{
    System.Console.WriteLine("I'll never write anything."); //CS0162
}

07.03 相等性 == 比较(C# 编程指南)

有时需要比较两个值是否相等。 在某些情况下,测试的是"值相等性",也称为"等效性",这意味着两个变量包含的值相等。 在其他情况下,必须确定两个变量是否引用内存中的同一基础对象。 此类型的相等性称为"引用相等性"或"标识"。 本主题介绍这两种相等性,并提供指向其他主题的链接,供用户了解详细信息。

07.03.01 引用相等性

引用相等性指两个对象引用均引用同一基础对象。 这可以通过简单的赋值来实现,如下面的示例所示。

C#

cs 复制代码
using System;
class Test
{
    public int Num { get; set; }
    public string Str { get; set; }

    public static void Main()
    {
        Test a = new Test() { Num = 1, Str = "Hi" };
        Test b = new Test() { Num = 1, Str = "Hi" };

        bool areEqual = System.Object.ReferenceEquals(a, b);
        // False:
        System.Console.WriteLine("ReferenceEquals(a, b) = {0}", areEqual);

        // Assign b to a.
        b = a;

        // Repeat calls with different results.
        areEqual = System.Object.ReferenceEquals(a, b);
        // True:
        System.Console.WriteLine("ReferenceEquals(a, b) = {0}", areEqual);
    }
}

在此代码中,创建了两个对象,但在赋值语句后,这两个引用所引用的是同一对象。 因此,它们具有引用相等性。 使用 ReferenceEquals 方法确定两个引用是否引用同一对象。

引用相等性的概念仅适用于引用类型。 由于在将值类型的实例赋给变量时将产生值的副本,因此值类型对象无法具有引用相等性。 因此,永远不会有两个未装箱结构引用内存中的同一位置。 此外,如果使用 ReferenceEquals 比较两个值类型,结果将始终为 false,即使对象中包含的值都相同也是如此。 这是因为会将每个变量装箱到单独的对象实例中。 有关详细信息,请参阅如何测试引用相等性(标识)

07.03.02 值相等性

值相等性指两个对象包含相同的一个或多个值。 对于基元值类型(例如 intbool),针对值相等性的测试简单明了。 可以使用 == 运算符,如下面的示例所示。

C#

cs 复制代码
int a = GetOriginalValue();  
int b = GetCurrentValue();  
  
// Test for value equality.
if (b == a)
{  
    // The two integers are equal.  
}  

对于大多数其他类型,针对值相等性的测试较为复杂,因为它需要用户了解类型对值相等性的定义方式。 对于具有多个字段或属性的类和结构,值相等性的定义通常指所有字段或属性都具有相同的值。 例如,如果 pointA.X 等于 pointB.X,并且 pointA.Y 等于 pointB.Y,则可以将两个 Point 对象定义为相等。 对记录来说,值相等性是指如果记录类型的两个变量类型相匹配,且所有属性和字段值都一致,那么记录类型的两个变量是相等的。

但是,并不要求类型中的所有字段均相等。 只需子集相等即可。 比较不具所有权的类型时,应确保明确了解相等性对于该类型是如何定义的。 若要详细了解如何在自己的类和结构中定义值相等性,请参阅如何为类型定义值相等性

07.03.03 浮点值的值相等性

由于二进制计算机上的浮点算法不精确,因此浮点值(doublefloat)的相等比较会出现问题。 有关更多信息,请参阅 System.Double 主题中的备注部分。

07.03.04 如何为类或结构定义值相等性(C# 编程指南)

记录自动实现值相等性。 当你的类型为数据建模并应实现值相等性时,请考虑定义 record 而不是 class

定义类或结构时,需确定为类型创建值相等性(或等效性)的自定义定义是否有意义。 通常,预期将类型的对象添加到集合时,或者这些对象主要用于存储一组字段或属性时,需实现值相等性。 可以基于类型中所有字段和属性的比较结果来定义值相等性,也可以基于子集进行定义。

在任何一种情况下,类和结构中的实现均应遵循 5 个等效性保证条件(对于以下规则,假设 xyz 都不为 null):

  1. 自反属性:x.Equals(x) 将返回 true

  2. 对称属性:x.Equals(y) 返回与 y.Equals(x) 相同的值。

  3. 可传递属性:如果 (x.Equals(y) && y.Equals(z)) 返回 true,则 x.Equals(z) 将返回 true

  4. 只要未修改 x 和 y 引用的对象,x.Equals(y) 的连续调用就将返回相同的值。

  5. 任何非 null 值均不等于 null。 然而,当 x 为 null 时,x.Equals(y) 将引发异常。 这会违反规则 1 或 2,具体取决于 Equals 的参数。

定义的任何结构都已具有其从 Object.Equals(Object) 方法的 System.ValueType 替代中继承的值相等性的默认实现。 此实现使用反射来检查类型中的所有字段和属性。 尽管此实现可生成正确的结果,但与专门为类型编写的自定义实现相比,它的速度相对较慢。

类和结构的值相等性的实现详细信息有所不同。 但是,类和结构都需要相同的基础步骤来实现相等性:

  1. 替代虚拟 Object.Equals(Object) 方法。 大多数情况下,bool Equals( object obj ) 实现应只调入作为 System.IEquatable<T> 接口的实现的类型特定 Equals 方法。 (请参阅步骤 2。)

  2. 通过提供类型特定的 Equals 方法实现 System.IEquatable<T> 接口。 实际的等效性比较将在此接口中执行。 例如,可能决定通过仅比较类型中的一两个字段来定义相等性。 不会从 Equals 引发异常。 对于与继承相关的类:

    • 此方法应仅检查类中声明的字段。 它应调用 base.Equals 来检查基类中的字段。 (如果类型直接从 Object 中继承,则不会调用 base.Equals,因为 Object.Equals(Object)Object 实现会执行引用相等性检查。)

    • 仅当要比较的变量的运行时类型相同时,才应将两个变量视为相等。 此外,如果变量的运行时和编译时类型不同,请确保使用运行时类型的 Equals 方法的 IEquatable 实现。 确保始终正确比较运行时类型的一种策略是仅在 sealed 类中实现 IEquatable。 有关详细信息,请参阅本文后续部分的类示例

  3. 可选,但建议这样做:重载 ==!= 运算符。

  4. 替代 Object.GetHashCode,以便具有值相等性的两个对象生成相同的哈希代码。

  5. 可选:若要支持"大于"或"小于"定义,请为类型实现 IComparable<T> 接口,并同时重载 <> 运算符。

备注

可以使用记录来获取值相等性语义,而不需要任何不必要的样板代码。

07.03.05 类class == 示例

下面的示例演示如何在类(引用类型)中实现值相等性。

C#

cs 复制代码
namespace ValueEqualityClass;

class TwoDPoint : IEquatable<TwoDPoint>
{
    public int X { get; private set; }
    public int Y { get; private set; }

    public TwoDPoint(int x, int y)
    {
        if (x is (< 1 or > 2000) || y is (< 1 or > 2000))
        {
            throw new ArgumentException("Point must be in range 1 - 2000");
        }
        this.X = x;
        this.Y = y;
    }

    public override bool Equals(object obj) => this.Equals(obj as TwoDPoint);

    public bool Equals(TwoDPoint p)
    {
        if (p is null)
        {
            return false;
        }

        // Optimization for a common success case.
        if (Object.ReferenceEquals(this, p))
        {
            return true;
        }

        // If run-time types are not exactly the same, return false.
        if (this.GetType() != p.GetType())
        {
            return false;
        }

        // Return true if the fields match.
        // Note that the base class is not invoked because it is
        // System.Object, which defines Equals as reference equality.
        return (X == p.X) && (Y == p.Y);
    }

    public override int GetHashCode() => (X, Y).GetHashCode();

    public static bool operator ==(TwoDPoint lhs, TwoDPoint rhs)
    {
        if (lhs is null)
        {
            if (rhs is null)
            {
                return true;
            }

            // Only the left side is null.
            return false;
        }
        // Equals handles case of null on right side.
        return lhs.Equals(rhs);
    }

    public static bool operator !=(TwoDPoint lhs, TwoDPoint rhs) => !(lhs == rhs);
}

// For the sake of simplicity, assume a ThreeDPoint IS a TwoDPoint.
class ThreeDPoint : TwoDPoint, IEquatable<ThreeDPoint>
{
    public int Z { get; private set; }

    public ThreeDPoint(int x, int y, int z)
        : base(x, y)
    {
        if ((z < 1) || (z > 2000))
        {
            throw new ArgumentException("Point must be in range 1 - 2000");
        }
        this.Z = z;
    }

    public override bool Equals(object obj) => this.Equals(obj as ThreeDPoint);

    public bool Equals(ThreeDPoint p)
    {
        if (p is null)
        {
            return false;
        }

        // Optimization for a common success case.
        if (Object.ReferenceEquals(this, p))
        {
            return true;
        }

        // Check properties that this class declares.
        if (Z == p.Z)
        {
            // Let base class check its own fields
            // and do the run-time type comparison.
            return base.Equals((TwoDPoint)p);
        }
        else
        {
            return false;
        }
    }

    public override int GetHashCode() => (X, Y, Z).GetHashCode();

    public static bool operator ==(ThreeDPoint lhs, ThreeDPoint rhs)
    {
        if (lhs is null)
        {
            if (rhs is null)
            {
                // null == null = true.
                return true;
            }

            // Only the left side is null.
            return false;
        }
        // Equals handles the case of null on right side.
        return lhs.Equals(rhs);
    }

    public static bool operator !=(ThreeDPoint lhs, ThreeDPoint rhs) => !(lhs == rhs);
}

class Program
{
    static void Main(string[] args)
    {
        ThreeDPoint pointA = new ThreeDPoint(3, 4, 5);
        ThreeDPoint pointB = new ThreeDPoint(3, 4, 5);
        ThreeDPoint pointC = null;
        int i = 5;

        Console.WriteLine("pointA.Equals(pointB) = {0}", pointA.Equals(pointB));
        Console.WriteLine("pointA == pointB = {0}", pointA == pointB);
        Console.WriteLine("null comparison = {0}", pointA.Equals(pointC));
        Console.WriteLine("Compare to some other type = {0}", pointA.Equals(i));

        TwoDPoint pointD = null;
        TwoDPoint pointE = null;

        Console.WriteLine("Two null TwoDPoints are equal: {0}", pointD == pointE);

        pointE = new TwoDPoint(3, 4);
        Console.WriteLine("(pointE == pointA) = {0}", pointE == pointA);
        Console.WriteLine("(pointA == pointE) = {0}", pointA == pointE);
        Console.WriteLine("(pointA != pointE) = {0}", pointA != pointE);

        System.Collections.ArrayList list = new System.Collections.ArrayList();
        list.Add(new ThreeDPoint(3, 4, 5));
        Console.WriteLine("pointE.Equals(list[0]): {0}", pointE.Equals(list[0]));

        // Keep the console window open in debug mode.
        Console.WriteLine("Press any key to exit.");
        Console.ReadKey();
    }
}

/* Output:
    pointA.Equals(pointB) = True
    pointA == pointB = True
    null comparison = False
    Compare to some other type = False
    Two null TwoDPoints are equal: True
    (pointE == pointA) = False
    (pointA == pointE) = False
    (pointA != pointE) = True
    pointE.Equals(list[0]): False
*/

在类(引用类型)上,两种 Object.Equals(Object) 方法的默认实现均执行引用相等性比较,而不是值相等性检查。 实施者替代虚方法时,目的是为其指定值相等性语义。

即使类不重载 ==!= 运算符,也可将这些运算符与类一起使用。 但是,默认行为是执行引用相等性检查。 在类中,如果重载 Equals 方法,则应重载 ==!= 运算符,但这并不是必需的。

重要

前面的示例代码可能无法按照预期的方式处理每个继承方案。 考虑下列代码:

C#

TwoDPoint p1 = new ThreeDPoint(1, 2, 3);
TwoDPoint p2 = new ThreeDPoint(1, 2, 4);
Console.WriteLine(p1.Equals(p2)); // output: True

根据此代码报告,尽管 z 值有所不同,但 p1 等于 p2。 由于编译器会根据编译时类型选取 IEquatableTwoDPoint 实现,因而会忽略该差异。

record 类型的内置值相等性可以正确处理这类场景。 如果 TwoDPointThreeDPointrecord 类型,则 p1.Equals(p2) 的结果会是 False。 有关详细信息,请参阅 record 类型继承层次结果中的相等性

07.03.06 结构record == 示例

下面的示例演示如何在结构(值类型)中实现值相等性:

C#

cs 复制代码
namespace ValueEqualityStruct
{
    struct TwoDPoint : IEquatable<TwoDPoint>
    {
        public int X { get; private set; }
        public int Y { get; private set; }

        public TwoDPoint(int x, int y)
            : this()
        {
            if (x is (< 1 or > 2000) || y is (< 1 or > 2000))
            {
                throw new ArgumentException("Point must be in range 1 - 2000");
            }
            X = x;
            Y = y;
        }

        public override bool Equals(object? obj) => obj is TwoDPoint other && this.Equals(other);

        public bool Equals(TwoDPoint p) => X == p.X && Y == p.Y;

        public override int GetHashCode() => (X, Y).GetHashCode();

        public static bool operator ==(TwoDPoint lhs, TwoDPoint rhs) => lhs.Equals(rhs);

        public static bool operator !=(TwoDPoint lhs, TwoDPoint rhs) => !(lhs == rhs);
    }

    class Program
    {
        static void Main(string[] args)
        {
            TwoDPoint pointA = new TwoDPoint(3, 4);
            TwoDPoint pointB = new TwoDPoint(3, 4);
            int i = 5;

            // True:
            Console.WriteLine("pointA.Equals(pointB) = {0}", pointA.Equals(pointB));
            // True:
            Console.WriteLine("pointA == pointB = {0}", pointA == pointB);
            // True:
            Console.WriteLine("object.Equals(pointA, pointB) = {0}", object.Equals(pointA, pointB));
            // False:
            Console.WriteLine("pointA.Equals(null) = {0}", pointA.Equals(null));
            // False:
            Console.WriteLine("(pointA == null) = {0}", pointA == null);
            // True:
            Console.WriteLine("(pointA != null) = {0}", pointA != null);
            // False:
            Console.WriteLine("pointA.Equals(i) = {0}", pointA.Equals(i));
            // CS0019:
            // Console.WriteLine("pointA == i = {0}", pointA == i);

            // Compare unboxed to boxed.
            System.Collections.ArrayList list = new System.Collections.ArrayList();
            list.Add(new TwoDPoint(3, 4));
            // True:
            Console.WriteLine("pointA.Equals(list[0]): {0}", pointA.Equals(list[0]));

            // Compare nullable to nullable and to non-nullable.
            TwoDPoint? pointC = null;
            TwoDPoint? pointD = null;
            // False:
            Console.WriteLine("pointA == (pointC = null) = {0}", pointA == pointC);
            // True:
            Console.WriteLine("pointC == pointD = {0}", pointC == pointD);

            TwoDPoint temp = new TwoDPoint(3, 4);
            pointC = temp;
            // True:
            Console.WriteLine("pointA == (pointC = 3,4) = {0}", pointA == pointC);

            pointD = temp;
            // True:
            Console.WriteLine("pointD == (pointC = 3,4) = {0}", pointD == pointC);

            Console.WriteLine("Press any key to exit.");
            Console.ReadKey();
        }
    }

    /* Output:
        pointA.Equals(pointB) = True
        pointA == pointB = True
        Object.Equals(pointA, pointB) = True
        pointA.Equals(null) = False
        (pointA == null) = False
        (pointA != null) = True
        pointA.Equals(i) = False
        pointE.Equals(list[0]): True
        pointA == (pointC = null) = False
        pointC == pointD = True
        pointA == (pointC = 3,4) = True
        pointD == (pointC = 3,4) = True
    */
}

对于结构,Object.Equals(Object)System.ValueType 中的替代版本)的默认实现通过使用反射来比较类型中每个字段的值,从而执行值相等性检查。 实施者替代结构中的 Equals 虚方法时,目的是提供更高效的方法来执行值相等性检查,并选择根据结构字段或属性的某个子集来进行比较。

除非结构显式重载了 ==!= 运算符,否则这些运算符无法对结构进行运算。

07.03.07 如何测试引用相等性(标识)(C# 编程指南)

无需实现任何自定义逻辑,即可支持类型中的引用相等性比较。 此功能由静态 Object.ReferenceEquals 方法向所有类型提供。

以下示例演示如何确定两个变量是否具有引用相等性,即它们引用内存中的同一对象。

该示例还演示 Object.ReferenceEquals 为何始终为值类型返回 false,以及您为何不应使用 ReferenceEquals 来确定字符串相等性。

07.03.08 示例

C#

cs 复制代码
using System.Text;

namespace TestReferenceEquality
{
    struct TestStruct
    {
        public int Num { get; private set; }
        public string Name { get; private set; }

        public TestStruct(int i, string s) : this()
        {
            Num = i;
            Name = s;
        }
    }

    class TestClass
    {
        public int Num { get; set; }
        public string? Name { get; set; }
    }

    class Program
    {
        static void Main()
        {
            // Demonstrate reference equality with reference types.
            #region ReferenceTypes

            // Create two reference type instances that have identical values.
            TestClass tcA = new TestClass() { Num = 1, Name = "New TestClass" };
            TestClass tcB = new TestClass() { Num = 1, Name = "New TestClass" };

            Console.WriteLine("ReferenceEquals(tcA, tcB) = {0}",
                                Object.ReferenceEquals(tcA, tcB)); // false

            // After assignment, tcB and tcA refer to the same object.
            // They now have reference equality.
            tcB = tcA;
            Console.WriteLine("After assignment: ReferenceEquals(tcA, tcB) = {0}",
                                Object.ReferenceEquals(tcA, tcB)); // true

            // Changes made to tcA are reflected in tcB. Therefore, objects
            // that have reference equality also have value equality.
            tcA.Num = 42;
            tcA.Name = "TestClass 42";
            Console.WriteLine("tcB.Name = {0} tcB.Num: {1}", tcB.Name, tcB.Num);
            #endregion

            // Demonstrate that two value type instances never have reference equality.
            #region ValueTypes

            TestStruct tsC = new TestStruct( 1, "TestStruct 1");

            // Value types are copied on assignment. tsD and tsC have
            // the same values but are not the same object.
            TestStruct tsD = tsC;
            Console.WriteLine("After assignment: ReferenceEquals(tsC, tsD) = {0}",
                                Object.ReferenceEquals(tsC, tsD)); // false
            #endregion

            #region stringRefEquality
            // Constant strings within the same assembly are always interned by the runtime.
            // This means they are stored in the same location in memory. Therefore,
            // the two strings have reference equality although no assignment takes place.
            string strA = "Hello world!";
            string strB = "Hello world!";
            Console.WriteLine("ReferenceEquals(strA, strB) = {0}",
                             Object.ReferenceEquals(strA, strB)); // true

            // After a new string is assigned to strA, strA and strB
            // are no longer interned and no longer have reference equality.
            strA = "Goodbye world!";
            Console.WriteLine("strA = \"{0}\" strB = \"{1}\"", strA, strB);

            Console.WriteLine("After strA changes, ReferenceEquals(strA, strB) = {0}",
                            Object.ReferenceEquals(strA, strB)); // false

            // A string that is created at runtime cannot be interned.
            StringBuilder sb = new StringBuilder("Hello world!");
            string stringC = sb.ToString();
            // False:
            Console.WriteLine("ReferenceEquals(stringC, strB) = {0}",
                            Object.ReferenceEquals(stringC, strB));

            // The string class overloads the == operator to perform an equality comparison.
            Console.WriteLine("stringC == strB = {0}", stringC == strB); // true

            #endregion

            // Keep the console open in debug mode.
            Console.WriteLine("Press any key to exit.");
            Console.ReadKey();
        }
    }
}

/* Output:
    ReferenceEquals(tcA, tcB) = False
    After assignment: ReferenceEquals(tcA, tcB) = True
    tcB.Name = TestClass 42 tcB.Num: 42
    After assignment: ReferenceEquals(tsC, tsD) = False
    ReferenceEquals(strA, strB) = True
    strA = "Goodbye world!" strB = "Hello world!"
    After strA changes, ReferenceEquals(strA, strB) = False
    ReferenceEquals(stringC, strB) = False
    stringC == strB = True
*/

System.Object 通用基类中实现 Equals 也会执行引用相等性检查,但最好不要使用这种检查,因为如果恰好某个类替代了此方法,结果可能会出乎意料。 以上情况同样适用于 ==!= 运算符。 当它们作用于引用类型时,==!= 的默认行为是执行引用相等性检查。 但是,派生类可重载运算符,执行值相等性检查。 为了尽量降低错误的可能性,当需要确定两个对象是否具有引用相等性时,最好始终使用 ReferenceEquals

运行时始终暂存同一程序集内的常量字符串。 也就是说,仅维护每个唯一文本字符串的一个实例。 但是,运行时不能保证会暂存在运行时创建的字符串,也不保证会暂存不同程序集中两个相等的常量字符串。

07.04 C# 运算符和表达式

C# 提供了许多运算符。 其中许多都受到内置类型的支持,可用于对这些类型的值执行基本操作。 这些运算符包括以下组:

通常可以重载这些运算符,也就是说,可以为用户定义类型的操作数指定运算符行为。

最简单的 C# 表达式是文本(例如整数实数)和变量名称。 可以使用运算符将它们组合成复杂的表达式。 运算符优先级结合性决定了表达式中操作的执行顺序。 可以使用括号更改由运算符优先级和结合性决定的计算顺序。

在下面的代码中,表达式的示例位于赋值的右侧:

int a, b, c;
a = 7;
b = a;
c = b++;
b = a + b * c;
c = a >= 100 ? b : c / 10;
a = (int)Math.Sqrt(b * b + c * c);

string s = "String literal";
char l = s[s.Length - 1];

var numbers = new List<int>(new[] { 1, 2, 3 });
b = numbers.FindLast(n => n > 1);

通常情况下,表达式会生成结果,并可包含在其他表达式中。 void 方法调用是不生成结果的表达式的示例。 它只能用作语句,如下面的示例所示:

Console.WriteLine("Hello, world!");

下面是 C# 提供的一些其他类型的表达式:

  • 内插字符串表达式,提供创建格式化字符串的便利语法:

    C#复制运行

    var r = 2.3;
    var message = $"The area of a circle with radius {r} is {Math.PI * r * r:F3}.";
    Console.WriteLine(message);
    // Output:
    // The area of a circle with radius 2.3 is 16.619.
    
  • Lambda 表达式,可用于创建匿名函数:

    C#复制运行

    int[] numbers = { 2, 3, 4, 5 };
    var maximumSquare = numbers.Max(x => x * x);
    Console.WriteLine(maximumSquare);
    // Output:
    // 25
    
  • 查询表达式,可用于直接以 C# 使用查询功能:

    C#复制运行

    var scores = new[] { 90, 97, 78, 68, 85 };
    IEnumerable<int> highScoresQuery =
        from score in scores
        where score > 80
        orderby score descending
        select score;
    Console.WriteLine(string.Join(" ", highScoresQuery));
    // Output:
    // 97 90 85
    

可使用表达式主体定义为方法、构造函数、属性、索引器或终结器提供简洁的定义。

07.04.01 运算符优先级

在包含多个运算符的表达式中,先按优先级较高的运算符计算,再按优先级较低的运算符计算。 在下面的示例中,首先执行乘法,因为其优先级高于加法:

C#复制运行

var a = 2 + 2 * 2;
Console.WriteLine(a); //  output: 6

使用括号更改运算符优先级所施加的计算顺序:

C#复制运行

var a = (2 + 2) * 2;
Console.WriteLine(a); //  output: 8

下表按最高优先级到最低优先级的顺序列出 C# 运算符。 每行中运算符的优先级相同。

运算符 类别或名称
x.yf(x)a[i]x?.yx?[y]x++x--x!newtypeofcheckeduncheckeddefaultnameofdelegatesizeofstackallocx->y 主要
+x-x、x~x++x--x^x(T)xawait、&&x*xtrue 和 false 一元
x..y 范围
switchwith switchwith 表达式
x * yx / yx % y 乘法
x + yx -- y 加法
x << yx >> y Shift
x < yx > yx <= yx >= yisas 关系和类型测试
x == yx != y 相等
x & y 布尔逻辑 AND按位逻辑 AND
x ^ y 布尔逻辑 XOR按位逻辑 XOR
`x y`
x && y 条件"与"
[x || y](https://learn.microsoft.com/zh-cn/dotnet/csharp/language-reference/operators/boolean-logical-operators#conditional-logical-or-operator- "x
x ?? y Null 合并运算符
c ? t : f 条件运算符
x = yx += yx -= yx *= yx /= yx %= yx &= y、[x |= y](https://learn.microsoft.com/zh-cn/dotnet/csharp/language-reference/operators/boolean-logical-operators#compound-assignment "x = y")、x ^= yx <<= yx >>= yx ??= y=>

07.04.02 运算符结合性

当运算符的优先级相同,运算符的结合性决定了运算的执行顺序:

  • 左结合运算符按从左到右的顺序计算。 除赋值运算符null 合并运算符外,所有二元运算符都是左结合运算符。 例如,a + b - c 将计算为 (a + b) - c
  • 右结合运算符按从右到左的顺序计算。 赋值运算符、null 合并运算符、lambda 和条件运算符?:是右结合运算符。 例如,x = y = z 将计算为 x = (y = z)

使用括号更改运算符结合性所施加的计算顺序:

C#复制运行

int a = 13 / 5 / 2;
int b = 13 / (5 / 2);
Console.WriteLine($"a = {a}, b = {b}");  // output: a = 1, b = 6

07.04.03 操作数计算

与运算符的优先级和结合性无关,从左到右计算表达式中的操作数。 以下示例展示了运算符和操作数的计算顺序:

展开表

表达式 计算顺序
a + b a, b, +
a + b * c a, b, c, *, +
a / b + c * d a, b, /, c, d, *, +
a / (b + c) * d a, b, c, +, /, d, *

通常,会计算所有运算符操作数。 但是,某些运算符有条件地计算操作数。 也就是说,此类运算符的最左侧操作数的值定义了是否应计算其他操作数,或计算其他哪些操作数。 这些运算符有条件逻辑 AND (&&)OR (||) 运算符、null 合并运算符 ?? 和 ??=null 条件运算符 ?. 和 ?[] 以及条件运算符?:。 有关详细信息,请参阅每个运算符的说明。

07.05 表达式

07.05.01 选择语句 - ifif-elseswitch

ifif-elseswitch 语句根据表达式的值从多个可能的语句选择要执行的路径。 仅当提供的布尔表达式的计算结果为 true 时,ifif 语句才执行语句。 语句if-else允许你根据布尔表达式选择要遵循的两个代码路径中的哪一个。 switch 语句根据与表达式匹配的模式来选择要执行的语句列表。

07.05.01.01 if 语句

if 语句可采用以下两种形式中的任一种:

  • 包含 else 部分的 if 语句根据布尔表达式的值选择两个语句中的一个来执行,如以下示例所示:

    C#

    DisplayWeatherReport(15.0);  // Output: Cold.
    DisplayWeatherReport(24.0);  // Output: Perfect!
    
    void DisplayWeatherReport(double tempInCelsius)
    {
        if (tempInCelsius < 20.0)
        {
            Console.WriteLine("Cold.");
        }
        else
        {
            Console.WriteLine("Perfect!");
        }
    }
    
  • 不包含 else 部分的 if 语句仅在布尔表达式计算结果为 true 时执行其主体,如以下示例所示:

    C#

    DisplayMeasurement(45);  // Output: The measurement value is 45
    DisplayMeasurement(-3);  // Output: Warning: not acceptable value! The measurement value is -3
    
    void DisplayMeasurement(double value)
    {
        if (value < 0 || value > 100)
        {
            Console.Write("Warning: not acceptable value! ");
        }
    
        Console.WriteLine($"The measurement value is {value}");
    }
    

可嵌套 if 语句来检查多个条件,如以下示例所示:

C#

DisplayCharacter('f');  // Output: A lowercase letter: f
DisplayCharacter('R');  // Output: An uppercase letter: R
DisplayCharacter('8');  // Output: A digit: 8
DisplayCharacter(',');  // Output: Not alphanumeric character: ,

void DisplayCharacter(char ch)
{
    if (char.IsUpper(ch))
    {
        Console.WriteLine($"An uppercase letter: {ch}");
    }
    else if (char.IsLower(ch))
    {
        Console.WriteLine($"A lowercase letter: {ch}");
    }
    else if (char.IsDigit(ch))
    {
        Console.WriteLine($"A digit: {ch}");
    }
    else
    {
        Console.WriteLine($"Not alphanumeric character: {ch}");
    }
}

在表达式上下文中,可使用条件运算符 ?: 根据布尔表达式的值计算两个表达式中的一个。

07.05.01.02 switch 语句

switch 语句根据与匹配表达式匹配的模式来选择要执行的语句列表,如以下示例所示:

C#复制

DisplayMeasurement(-4);  // Output: Measured value is -4; too low.
DisplayMeasurement(5);  // Output: Measured value is 5.
DisplayMeasurement(30);  // Output: Measured value is 30; too high.
DisplayMeasurement(double.NaN);  // Output: Failed measurement.

void DisplayMeasurement(double measurement)
{
    switch (measurement)
    {
        case < 0.0:
            Console.WriteLine($"Measured value is {measurement}; too low.");
            break;

        case > 15.0:
            Console.WriteLine($"Measured value is {measurement}; too high.");
            break;

        case double.NaN:
            Console.WriteLine("Failed measurement.");
            break;

        default:
            Console.WriteLine($"Measured value is {measurement}.");
            break;
    }
}

在上述示例中,switch 语句使用以下模式:

  • 关系模式:用于将表达式结果与常量进行比较。
  • 常量模式:测试表达式结果是否等于常量。

重要

有关 switch 语句支持的模式的信息,请参阅模式

上述示例还展示了 default case。 default case 指定匹配表达式与其他任何 case 模式都不匹配时要执行的语句。 如果匹配表达式与任何 case 模式都不匹配,且没有 default case,控制就会贯穿 switch 语句。

switch 语句执行第一个 switch 部分中的语句列表,其 case 模式与匹配表达式匹配,并且它的 case guard(如果存在)求值为 trueswitch 语句按文本顺序从上到下对 case 模式求值。 编译器在 switch 语句包含无法访问的 case 时会生成错误。 这种 case 已由大写字母处理或其模式无法匹配。

备注

default case 可以在 switch 语句的任何位置出现。 无论其位置如何,仅当所有其他事例模式都不匹配或 goto default; 语句在其中一个 switch 节中执行时,default 才会计算事例。

可以为 switch 语句的一部分指定多个 case 模式,如以下示例所示:

C#复制

DisplayMeasurement(-4);  // Output: Measured value is -4; out of an acceptable range.
DisplayMeasurement(50);  // Output: Measured value is 50.
DisplayMeasurement(132);  // Output: Measured value is 132; out of an acceptable range.

void DisplayMeasurement(int measurement)
{
    switch (measurement)
    {
        case < 0:
        case > 100:
            Console.WriteLine($"Measured value is {measurement}; out of an acceptable range.");
            break;
        
        default:
            Console.WriteLine($"Measured value is {measurement}.");
            break;
    }
}

switch 语句中,控制不能从一个 switch 部分贯穿到下一个 switch 部分。 如本部分中的示例所示,通常使用每个 switch 部分末尾的 break 语句将控制从 switch 语句传递出去。 还可使用 returnthrow 语句将控制从 switch 语句传递出去。 若要模拟贯穿行为,将控制传递给其他 switch 部分,可使用 goto 语句

在表达式上下文中,可使用 switch 表达式,根据与表达式匹配的模式,对候选表达式列表中的单个表达式进行求值。

07.05.01.03 Case guard

case 模式可能表达功能不够,无法指定用于执行 switch 部分的条件。 在这种情况下,可以使用 case guard。 这是一个附加条件,必须与匹配模式同时满足。 case guard 必须是布尔表达式。 可以在模式后面的 when 关键字之后指定一个 case guard,如以下示例所示:

C#复制

DisplayMeasurements(3, 4);  // Output: First measurement is 3, second measurement is 4.
DisplayMeasurements(5, 5);  // Output: Both measurements are valid and equal to 5.

void DisplayMeasurements(int a, int b)
{
    switch ((a, b))
    {
        case (> 0, > 0) when a == b:
            Console.WriteLine($"Both measurements are valid and equal to {a}.");
            break;

        case (> 0, > 0):
            Console.WriteLine($"First measurement is {a}, second measurement is {b}.");
            break;

        default:
            Console.WriteLine("One or both measurements are not valid.");
            break;
    }
}

上述示例使用带有嵌套关系模式位置模式

07.05.02 迭代语句 - forforeachdowhile

此迭代语句重复执行语句或语句块。 for 语句:在指定的布尔表达式的计算结果为 true 时会执行其主体。 foreach 语句:枚举集合元素并对集合中的每个元素执行其主体。 do 语句:有条件地执行其主体一次或多次。 while 语句:有条件地执行其主体零次或多次。

在迭代语句体中的任何点,都可以使用 break 语句跳出循环。 可以使用 continue 语句进入循环中的下一个迭代。

07.05.02.01 for 语句

在指定的布尔表达式的计算结果为 true 时,for 语句会执行一条语句或一个语句块。 以下示例显示了 for 语句,该语句在整数计数器小于 3 时执行其主体:

C#复制运行

for (int i = 0; i < 3; i++)
{
    Console.Write(i);
}
// Output:
// 012

上述示例展示了 for 语句的元素:

  • "初始化表达式"部分仅在进入循环前执行一次。 通常,在该部分中声明并初始化局部循环变量。 不能从 for 语句外部访问声明的变量。

    上例中的"初始化表达式"部分声明并初始化整数计数器变量:

    C#复制

    int i = 0
    
  • "条件"部分确定是否应执行循环中的下一个迭代。 如果计算结果为 true 或不存在,则执行下一个迭代;否则退出循环。 "条件"部分必须为布尔表达式。

    上例中的"条件"条件部分检查计数器值是否小于 3:

    C#复制

    i < 3
    
  • "迭代器"部分定义循环主体的每次执行后将执行的操作。

    上例中的"迭代器"部分增加计数器:

    C#复制

    i++
    
  • 循环体,必须是一个语句或一个语句块。

"迭代器"部分可包含用逗号分隔的零个或多个以下语句表达式:

如果未在"初始化表达式"部分中声明循环变量,则还可以在"初始化表达式"部分中使用上述列表中的零个或多个表达式。 下面的示例显示了几种不太常见的"初始化表达式"和"迭代器"部分的使用情况:为"初始化表达式"部分中的外部变量赋值、同时在"初始化表达式"部分和"迭代器"部分中调用一种方法,以及更改"迭代器"部分中的两个变量的值:

C#复制运行

int i;
int j = 3;
for (i = 0, Console.WriteLine($"Start: i={i}, j={j}"); i < j; i++, j--, Console.WriteLine($"Step: i={i}, j={j}"))
{
    //...
}
// Output:
// Start: i=0, j=3
// Step: i=1, j=2
// Step: i=2, j=1

for 语句的所有部分都是可选的。 例如,以下代码定义无限 for 循环:

C#复制

for ( ; ; )
{
    //...
}
07.05.02​​​​​​​.02 foreach 语句

foreach 语句为类型实例中实现 System.Collections.IEnumerableSystem.Collections.Generic.IEnumerable<T> 接口的每个元素执行语句或语句块,如以下示例所示:

C#复制运行

List<int> fibNumbers = new() { 0, 1, 1, 2, 3, 5, 8, 13 };
foreach (int element in fibNumbers)
{
    Console.Write($"{element} ");
}
// Output:
// 0 1 1 2 3 5 8 13

foreach 语句并不限于这些类型。 可以将其与满足以下条件的任何类型的实例一起使用:

  • 类型具有公共无参数 GetEnumerator 方法。 GetEnumerator 方法可以是类型的扩展方法
  • GetEnumerator 方法的返回类型具有公共 Current 属性和公共无参数 MoveNext 方法(其返回类型为 bool)。

下面的示例使用 foreach 语句,其中包含 System.Span<T> 类型的实例,该实例不实现任何接口:

C#复制

Span<int> numbers = [3, 14, 15, 92, 6];
foreach (int number in numbers)
{
    Console.Write($"{number} ");
}
// Output:
// 3 14 15 92 6

如果枚举器的 Current 属性返回引用返回值ref T,其中 T 为集合元素类型),就可以使用 refref readonly 修饰符来声明迭代变量,如下面的示例所示:

C#复制

Span<int> storage = stackalloc int[10];
int num = 0;
foreach (ref int item in storage)
{
    item = num++;
}
foreach (ref readonly var item in storage)
{
    Console.Write($"{item} ");
}
// Output:
// 0 1 2 3 4 5 6 7 8 9

如果 foreach 语句的源集合为空,则 foreach 语句的正文不会被执行,而是被跳过。 如果 foreach 语句应用为 null,则会引发 NullReferenceException

07.05.02​​​​​​​.03 await foreach

可以使用 await foreach 语句来使用异步数据流,即实现 IAsyncEnumerable<T> 接口的集合类型。 异步检索下一个元素时,可能会挂起循环的每次迭代。 下面的示例演示如何使用 await foreach 语句:

C#复制

await foreach (var item in GenerateSequenceAsync())
{
    Console.WriteLine(item);
}

还可以将 await foreach 语句与满足以下条件的任何类型的实例一起使用:

  • 类型具有公共无参数 GetAsyncEnumerator 方法。 该方法可以是类型的扩展方法
  • GetAsyncEnumerator 方法的返回类型具有公共 Current 属性和公共无参数 MoveNextAsync 方法(其返回类型为 Task<bool>ValueTask<bool> 或任何其他可等待类型,其 awaiter 的 GetResult 方法返回 bool 值)。

默认情况下,在捕获的上下文中处理流元素。 如果要禁用上下文捕获,请使用 TaskAsyncEnumerableExtensions.ConfigureAwait 扩展方法。 有关同步上下文并捕获当前上下文的详细信息,请参阅使用基于任务的异步模式。 有关异步流的详细信息,请参阅异步流教程

07.05.02​​​​​​​.04 迭代变量的类型

可以使用 var 关键字让编译器推断 foreach 语句中迭代变量的类型,如以下代码所示:

C#复制

foreach (var item in collection) { }

备注

译器可以将 var 的类型推断为可为空的引用类型,具体取决于是否启用可为空的感知上下文以及初始化表达式的类型是否为引用类型。 有关详细信息,请参阅隐式类型本地变量

还可以显式指定迭代变量的类型,如以下代码所示:

C#复制

IEnumerable<T> collection = new T[5];
foreach (V item in collection) { }

在上述窗体中,集合元素的类型 T 必须可隐式或显式地转换为迭代变量的类型 V。 如果从 TV 的显式转换在运行时失败,foreach 语句将引发 InvalidCastException。 例如,如果 T 是非密封类类型,则 V 可以是任何接口类型,甚至可以是 T 未实现的接口类型。 在运行时,集合元素的类型可以是从 T 派生并且实际实现 V 的类型。 如果不是这样,则会引发 InvalidCastException

07.05.02​​​​​​​.05 do 语句

在指定的布尔表达式的计算结果为 true 时,do 语句会执行一条语句或一个语句块。 由于在每次执行循环之后都会计算此表达式,所以 do 循环会执行一次或多次。 do 循环不同于 while 循环(该循环执行零次或多次)。

下面的示例演示 do 语句的用法:

C#复制运行

int n = 0;
do
{
    Console.Write(n);
    n++;
} while (n < 5);
// Output:
// 01234
07.05.02​​​​​​​.06 while 语句

在指定的布尔表达式的计算结果为 true 时,while 语句会执行一条语句或一个语句块。 由于在每次执行循环之前都会计算此表达式,所以 while 循环会执行零次或多次。 while 循环不同于 do 循环(该循环执行 1 次或多次)。

下面的示例演示 while 语句的用法:

C#复制运行

int n = 0;
while (n < 5)
{
    Console.Write(n);
    n++;
}
// Output:
// 01234

07.05.03 跳转语句 - breakcontinuereturngoto

jump 语句无条件转移控制。 break 语句将终止最接近的封闭迭代语句switch 语句continue 语句启动最接近的封闭迭代语句的新迭代。 return 语句终止它所在的函数的执行,并将控制权返回给调用方。 goto 语句将控制权转交给带有标签的语句。

有关引发异常并无条件转移控制权的 throw 语句的信息,请参阅异常处理语句一文的throw 语句部分。

07.05.03.01 break 语句

break 语句:将终止最接近的封闭迭代语句(即 forforeachwhiledo 循环)或 switch 语句break 语句将控制权转交给已终止语句后面的语句(若有)。

C#复制运行

int[] numbers = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
foreach (int number in numbers)
{
    if (number == 3)
    {
        break;
    }

    Console.Write($"{number} ");
}
Console.WriteLine();
Console.WriteLine("End of the example.");
// Output:
// 0 1 2 
// End of the example.

在嵌套循环中,break 语句仅终止包含它的最内部循环,如以下示例所示:

C#复制运行

for (int outer = 0; outer < 5; outer++)
{
    for (int inner = 0; inner < 5; inner++)
    {
        if (inner > outer)
        {
            break;
        }

        Console.Write($"{inner} ");
    }
    Console.WriteLine();
}
// Output:
// 0
// 0 1
// 0 1 2
// 0 1 2 3
// 0 1 2 3 4

在循环内使用 switch 语句时,switch 节末尾的 break 语句仅从 switch 语句中转移控制权。 包含 switch 语句的循环不受影响,如以下示例所示:

C#复制

double[] measurements = [-4, 5, 30, double.NaN];
foreach (double measurement in measurements)
{
    switch (measurement)
    {
        case < 0.0:
            Console.WriteLine($"Measured value is {measurement}; too low.");
            break;

        case > 15.0:
            Console.WriteLine($"Measured value is {measurement}; too high.");
            break;

        case double.NaN:
            Console.WriteLine("Failed measurement.");
            break;

        default:
            Console.WriteLine($"Measured value is {measurement}.");
            break;
    }
}
// Output:
// Measured value is -4; too low.
// Measured value is 5.
// Measured value is 30; too high.
// Failed measurement.
07.05.03.02 continue 语句

continue 语句启动最接近的封闭迭代语句(即 forforeachwhiledo 循环)的新迭代,如以下示例所示:

C#复制运行

for (int i = 0; i < 5; i++)
{
    Console.Write($"Iteration {i}: ");
    
    if (i < 3)
    {
        Console.WriteLine("skip");
        continue;
    }
    
    Console.WriteLine("done");
}
// Output:
// Iteration 0: skip
// Iteration 1: skip
// Iteration 2: skip
// Iteration 3: done
// Iteration 4: done
07.05.03.03 return 语句

return 语句终止它所在的函数的执行,并将控制权和函数结果(若有)返回给调用方。

如果函数成员不计算值,则使用不带表达式的 return 语句,如以下示例所示:

C#复制运行

Console.WriteLine("First call:");
DisplayIfNecessary(6);

Console.WriteLine("Second call:");
DisplayIfNecessary(5);

void DisplayIfNecessary(int number)
{
    if (number % 2 == 0)
    {
        return;
    }

    Console.WriteLine(number);
}
// Output:
// First call:
// Second call:
// 5

如前面的示例所示,通常使用不带表达式的 return 语句提前终止函数成员。 如果函数成员不包含 return 语句,则在执行其最后一个语句后终止。

如果函数成员不计算值,则使用带表达式的 return 语句,如以下示例所示:

C#复制运行

double surfaceArea = CalculateCylinderSurfaceArea(1, 1);
Console.WriteLine($"{surfaceArea:F2}"); // output: 12.57

double CalculateCylinderSurfaceArea(double baseRadius, double height)
{
    double baseArea = Math.PI * baseRadius * baseRadius;
    double sideArea = 2 * Math.PI * baseRadius * height;
    return 2 * baseArea + sideArea;
}

如果 return 语句具有表达式,该表达式必须可隐式转换为函数成员的返回类型,除非它是异步的。 从 async 函数返回的表达式必须隐式转换为 Task<TResult>ValueTask<TResult> 类型参数,以函数的返回类型为准。 如果 async 函数的返回类型为 TaskValueTask,则使用不带表达式的 return 语句。

07.05.03.04 引用返回

默认情况下,return 语句返回表达式的值。 可以返回对变量的引用。 引用返回值(或 ref 返回值)是由方法按引用向调用方返回的值。 即是说,调用方可以修改方法所返回的值,此更改反映在所调用方法中的对象的状态中。 为此,请使用带 ref 关键字的 return 语句,如以下示例所示:

C#复制运行

int[] xs = new int [] {10, 20, 30, 40 };
ref int found = ref FindFirst(xs, s => s == 30);
found = 0;
Console.WriteLine(string.Join(" ", xs));  // output: 10 20 0 40

ref int FindFirst(int[] numbers, Func<int, bool> predicate)
{
    for (int i = 0; i < numbers.Length; i++)
    {
        if (predicate(numbers[i]))
        {
            return ref numbers[i];
        }
    }
    throw new InvalidOperationException("No element satisfies the given condition.");
}

借助引用返回值,方法可以将对变量的引用(而不是值)返回给调用方。 然后,调用方可以选择将返回的变量视为按值返回或按引用返回。 调用方可以新建称为引用本地的变量,其本身就是对返回值的引用。 引用返回值是指,方法返回对某变量的引用(或别名)。 相应变量的作用域必须包括方法。 相应变量的生存期必须超过方法的返回值。 调用方对方法的返回值进行的修改应用于方法返回的变量。

如果声明方法返回引用返回值,表明方法返回变量别名。 设计意图通常是让调用代码通过别名访问此变量(包括修改它)。 方法的引用返回值不得包含返回类型 void

为方便调用方修改对象的状态,引用返回值必须存储在被显式定义为 reference 变量的变量中。

ref 返回值是被调用方法范围中另一个变量的别名。 可以将引用返回值的所有使用都解释为,使用它取别名的变量:

  • 分配值时,就是将值分配到它取别名的变量。
  • 读取值时,就是读取它取别名的变量的值。
  • 如果以引用方式返回它,就是返回对相同变量所取的别名。
  • 如果以引用方式将它传递到另一个方法,就是传递对它取别名的变量的引用。
  • 如果返回引用本地别名,就是返回相同变量的新别名。

引用返回必须是调用方法的 ref-safe-context。 也就是说:

  • 返回值的生存期必须长于方法执行时间。 换言之,它不能是返回自身的方法中的本地变量。 它可以是实例或类的静态字段,也可是传递给方法的参数。 尝试返回局部变量将生成编译器错误 CS8168:"无法按引用返回局部 "obj",因为它不是 ref 局部变量"。
  • 返回值不得为文本 null。 使用引用返回值的方法可以返回值当前为 null(未实例化)或可为空的值类型的变量别名。
  • 返回值不得为常量、枚举成员、通过属性的按值返回值或 class/struct 方法。

此外,禁止对异步方法使用引用返回值。 异步方法可能会在执行尚未完成时就返回值,尽管返回值仍未知。

返回引用返回值的方法必须:

  • 在返回类型前面有 ref 关键字。
  • 方法主体中的每个 return 语句都在返回实例的名称前面有 ref 关键字。

下面的示例方法满足这些条件,且返回对名为 pPerson 对象的引用:

C#复制

public ref Person GetContactInformation(string fname, string lname)
{
    // ...method implementation...
    return ref p;
}

下面是一个更完整的 ref 返回示例,同时显示方法签名和方法主体。

C#复制

public static ref int Find(int[,] matrix, Func<int, bool> predicate)
{
    for (int i = 0; i < matrix.GetLength(0); i++)
        for (int j = 0; j < matrix.GetLength(1); j++)
            if (predicate(matrix[i, j]))
                return ref matrix[i, j];
    throw new InvalidOperationException("Not found");
}

所调用方法还可能会将返回值声明为 ref readonly 以按引用返回值,并坚持调用代码无法修改返回的值。 调用方法可以通过将返回值存储在局部 ref readonly reference 变量中来避免复制该值。

下列示例定义一个具有两个 String 字段(TitleAuthor)的 Book 类。 还定义包含 Book 对象的专用数组的 BookCollection 类。 通过调用 GetBookByTitle 方法,可按引用返回个别 book 对象。

C#复制

public class Book
{
    public string Author;
    public string Title;
}

public class BookCollection
{
    private Book[] books = { new Book { Title = "Call of the Wild, The", Author = "Jack London" },
                        new Book { Title = "Tale of Two Cities, A", Author = "Charles Dickens" }
                       };
    private Book nobook = null;

    public ref Book GetBookByTitle(string title)
    {
        for (int ctr = 0; ctr < books.Length; ctr++)
        {
            if (title == books[ctr].Title)
                return ref books[ctr];
        }
        return ref nobook;
    }

    public void ListBooks()
    {
        foreach (var book in books)
        {
            Console.WriteLine($"{book.Title}, by {book.Author}");
        }
        Console.WriteLine();
    }
}

调用方将 GetBookByTitle 方法所返回的值存储为 ref 局部变量时,调用方对返回值所做的更改将反映在 BookCollection 对象中,如下例所示。

C#复制

var bc = new BookCollection();
bc.ListBooks();

ref var book = ref bc.GetBookByTitle("Call of the Wild, The");
if (book != null)
    book = new Book { Title = "Republic, The", Author = "Plato" };
bc.ListBooks();
// The example displays the following output:
//       Call of the Wild, The, by Jack London
//       Tale of Two Cities, A, by Charles Dickens
//
//       Republic, The, by Plato
//       Tale of Two Cities, A, by Charles Dickens
07.05.03.05 goto 语句

goto 语句将控制权转交给带有标签的语句,如以下示例所示:

C#复制

var matrices = new Dictionary<string, int[][]>
{
    ["A"] =
    [
        [1, 2, 3, 4],
        [4, 3, 2, 1]
    ],
    ["B"] =
    [
        [5, 6, 7, 8],
        [8, 7, 6, 5]
    ],
};

CheckMatrices(matrices, 4);

void CheckMatrices(Dictionary<string, int[][]> matrixLookup, int target)
{
    foreach (var (key, matrix) in matrixLookup)
    {
        for (int row = 0; row < matrix.Length; row++)
        {
            for (int col = 0; col < matrix[row].Length; col++)
            {
                if (matrix[row][col] == target)
                {
                    goto Found;
                }
            }
        }
        Console.WriteLine($"Not found {target} in matrix {key}.");
        continue;

    Found:
        Console.WriteLine($"Found {target} in matrix {key}.");
    }
}
// Output:
// Found 4 in matrix A.
// Not found 4 in matrix B.

如前面的示例所示,可以使用 goto 语句退出嵌套循环。

提示

使用嵌套循环时,请考虑将单独的循环重构为单独的方法。 这可能会导致没有 goto 语句的更简单、更具可读性的代码。

还可使用 switch 语句中的 goto 语句将控制权移交到具有常量大小写标签的 switch 节,如以下示例所示:

C#复制运行

using System;

public enum CoffeeChoice
{
    Plain,
    WithMilk,
    WithIceCream,
}

public class GotoInSwitchExample
{
    public static void Main()
    {
        Console.WriteLine(CalculatePrice(CoffeeChoice.Plain));  // output: 10.0
        Console.WriteLine(CalculatePrice(CoffeeChoice.WithMilk));  // output: 15.0
        Console.WriteLine(CalculatePrice(CoffeeChoice.WithIceCream));  // output: 17.0
    }

    private static decimal CalculatePrice(CoffeeChoice choice)
    {
        decimal price = 0;
        switch (choice)
        {
            case CoffeeChoice.Plain:
                price += 10.0m;
                break;

            case CoffeeChoice.WithMilk:
                price += 5.0m;
                goto case CoffeeChoice.Plain;

            case CoffeeChoice.WithIceCream:
                price += 7.0m;
                goto case CoffeeChoice.Plain;
        }
        return price;
    }
}

switch 语句中,还可使用语句 goto default; 将控制权转交给带 default 标签的 switch 节。

如果当前函数成员中不存在具有给定名称的标签,或者 goto 语句不在标签范围内,则会出现编译时错误。 也就是说,你不能使用 goto 语句将控制权从当前函数成员转移到任何嵌套范围。

07.05.04 异常处理语句 - throwtry-catchtry-finallytry-catch-finally

使用 throwtry 语句来处理异常。 使用 throw 语句引发异常。 使用 try 语句捕获和处理在执行代码块期间可能发生的异常。

07.05.04.01 throw 语句

throw 语句引发异常:

C#复制

if (shapeAmount <= 0)
{
    throw new ArgumentOutOfRangeException(nameof(shapeAmount), "Amount of shapes must be positive.");
}

throw e; 语句中,表达式 e 的结果必须隐式转换为 System.Exception

可以使用内置异常类,例如 ArgumentOutOfRangeExceptionInvalidOperationException。 .NET 还提供了以下在某些情况下引发异常的帮助程序方法:ArgumentNullException.ThrowIfNullArgumentException.ThrowIfNullOrEmpty。 还可以定义自己的派生自 System.Exception 的异常类。 有关详细信息,请参阅创建和引发异常

catch 块内,可以使用 throw; 语句重新引发由 catch 块处理的异常:

C#复制

try
{
    ProcessShapes(shapeAmount);
}
catch (Exception e)
{
    LogError(e, "Shape processing failed.");
    throw;
}

备注

throw; 保留异常的原始堆栈跟踪,该跟踪存储在 Exception.StackTrace 属性中。 与此相反,throw e; 更新 eStackTrace 属性。

引发异常时,公共语言运行时 (CLR) 将查找可以处理此异常的 catch 块。 如果当前执行的方法不包含此类 catch 块,则 CLR 查看调用了当前方法的方法,并以此类推遍历调用堆栈。 如果未找到 catch 块,CLR 将终止正在执行的线程。 有关详细信息,请参阅 C# 语言规范如何处理异常部分。

07.05.04.02 throw 表达式

还可以将 throw 用作表达式。 这在很多情况下可能很方便,包括:

  • 条件运算符。 以下示例使用 throw 表达式在传递的数组 args 为空时引发 ArgumentException

    C#复制

    string first = args.Length >= 1 
        ? args[0]
        : throw new ArgumentException("Please supply at least one argument.");
    
  • null 合并运算符。 以下示例使用 throw 表达式在要分配给属性的字符串为 null 时引发 ArgumentNullException

    C#复制

    public string Name
    {
        get => name;
        set => name = value ??
            throw new ArgumentNullException(paramName: nameof(value), message: "Name cannot be null");
    }
    
  • expression-bodied lambda 或方法。 以下示例使用 throw 表达式引发 InvalidCastException,以指示不支持转换为 DateTime 值:

    C#复制

    DateTime ToDateTime(IFormatProvider provider) =>
             throw new InvalidCastException("Conversion to a DateTime is not supported.");
    

07.05.04.03 try 语句

可以通过以下任何形式使用 try 语句:try-catch - 处理在 try 块内执行代码期间可能发生的异常,try-finally - 指定在控件离开 try 块时执行的代码,以及 try-catch-finally - 作为上述两种形式的组合。

07.05.04.04 try-catch 语句

使用 try-catch 语句处理在执行代码块期间可能发生的异常。 将代码置于 try 块中可能发生异常的位置。 使用 catch 子句 指定要在相应的 catch 块中处理的异常的基类型:

C#复制

try
{
    var result = Process(-3, 4);
    Console.WriteLine($"Processing succeeded: {result}");
}
catch (ArgumentException e)
{
    Console.WriteLine($"Processing failed: {e.Message}");
}

可以提供多个 catch 子句:

C#复制

try
{
    var result = await ProcessAsync(-3, 4, cancellationToken);
    Console.WriteLine($"Processing succeeded: {result}");
}
catch (ArgumentException e)
{
    Console.WriteLine($"Processing failed: {e.Message}");
}
catch (OperationCanceledException)
{
    Console.WriteLine("Processing is cancelled.");
}

发生异常时,将从上到下按指定顺序检查 catch 子句。 对于任何引发的异常,最多只执行一个 catch 块。 如前面的示例所示,可以省略异常变量的声明,并在 catch 子句中仅指定异常类型。 没有任何指定异常类型的 catch 子句与任何异常匹配,如果存在,则必须是最后一个 catch 子句。

如果要重新引发捕获的异常,请使用 throw 语句,如以下示例所示:

C#复制

try
{
    var result = Process(-3, 4);
    Console.WriteLine($"Processing succeeded: {result}");
}
catch (Exception e)
{
    LogError(e, "Processing failed.");
    throw;
}

备注

throw; 保留异常的原始堆栈跟踪,该跟踪存储在 Exception.StackTrace 属性中。 与此相反,throw e; 更新 eStackTrace 属性。

07.05.04.05 when 异常筛选器

除了异常类型之外,还可以指定异常筛选器,该筛选器进一步检查异常并确定相应的 catch 块是否处理该异常。 异常筛选器是遵循 when 关键字的布尔表达式,如以下示例所示:

C#复制

try
{
    var result = Process(-3, 4);
    Console.WriteLine($"Processing succeeded: {result}");
}
catch (Exception e) when (e is ArgumentException || e is DivideByZeroException)
{
    Console.WriteLine($"Processing failed: {e.Message}");
}

前面的示例使用异常筛选器提供单个 catch 块来处理两个指定类型的异常。

可以为相同异常类型提供若干 catch 子句,如果它们通过异常筛选器区分。 其中一个子句可能没有异常筛选器。 如果存在此类子句,则它必须是指定该异常类型的最后一个子句。

如果 catch 子句具有异常筛选器,则可以指定与 catch 子句之后出现的异常类型相同或小于派生的异常类型。 例如,如果存在异常筛选器,则 catch (Exception e) 子句不需要是最后一个子句。

07.05.04.06 异步和迭代器方法中的异常

如果异步函数中发生异常,则等待函数的结果时,它会传播到函数的调用方,如以下示例所示:

C#复制

public static async Task Run()
{
    try
    {
        Task<int> processing = ProcessAsync(-1);
        Console.WriteLine("Launched processing.");

        int result = await processing;
        Console.WriteLine($"Result: {result}.");
    }
    catch (ArgumentException e)
    {
        Console.WriteLine($"Processing failed: {e.Message}");
    }
    // Output:
    // Launched processing.
    // Processing failed: Input must be non-negative. (Parameter 'input')
}

private static async Task<int> ProcessAsync(int input)
{
    if (input < 0)
    {
        throw new ArgumentOutOfRangeException(nameof(input), "Input must be non-negative.");
    }

    await Task.Delay(500);
    return input;
}

如果迭代器方法中发生异常,则仅当迭代器前进到下一个元素时,它才会传播到调用方。

07.05.04.07 try-finally 语句

try-finally 语句中,当控件离开 try 块时,将执行 finally 块。 控件可能会离开 try 块,因为

  • 正常执行,
  • 执行 jump 语句(即 returnbreakcontinuegoto),或
  • try 块中传播异常。

以下示例使用 finally 块在控件离开方法之前重置对象的状态:

C#复制

public async Task HandleRequest(int itemId, CancellationToken ct)
{
    Busy = true;

    try
    {
        await ProcessAsync(itemId, ct);
    }
    finally
    {
        Busy = false;
    }
}

还可以使用 finally 块来清理 try 块中使用的已分配资源。

备注

当资源类型实现 IDisposableIAsyncDisposable 接口时,请考虑 using 语句using 语句可确保在控件离开 using 语句时释放获取的资源。 编译器将 using 语句转换为 try-finally 语句。

finally 块的执行取决于操作系统是否选择触发异常解除操作。 未执行 finally 块的唯一情况涉及立即终止程序。 例如,由于 Environment.FailFast 调用或 OverflowExceptionInvalidProgramException 异常,可能会发生此类终止。 大多数操作系统在停止和卸载进程的过程中执行合理的资源清理。

07.05.04.08 try-catch-finally 语句

使用 try-catch-finally 语句来处理在执行 try 块期间可能发生的异常,并指定在控件离开 try 语句时必须执行的代码:

C#复制

public async Task ProcessRequest(int itemId, CancellationToken ct)
{
    Busy = true;

    try
    {
        await ProcessAsync(itemId, ct);
    }
    catch (Exception e) when (e is not OperationCanceledException)
    {
        LogError(e, $"Failed to process request for item ID {itemId}.");
        throw;
    }
    finally
    {
        Busy = false;
    }

}

catch 块处理异常时,finally 块在执行该 catch 块后执行(即使执行 catch 块期间发生另一个异常)。 有关 catchfinally 块的信息,请分别参阅 try-catch 语句try-finally 语句 部分。

07.05.06 checked 和 unchecked 语句

07.05.06.01 checked unchecked

checkedunchecked 语句指定整型类型算术运算和转换的溢出检查上下文。 当发生整数算术溢出时,溢出检查上下文将定义发生的情况。 在已检查的上下文中,引发 System.OverflowException;如果在常数表达式中发生溢出,则会发生编译时错误。 在未检查的上下文中,会通过丢弃任何不适应目标类型的高序位来将操作结果截断。 例如,在加法示例中,它将从最大值包装到最小值。 以下示例显示了已检查和未检查上下文中的相同操作:

C#复制运行

uint a = uint.MaxValue;

unchecked
{
    Console.WriteLine(a + 3);  // output: 2
}

try
{
    checked
    {
        Console.WriteLine(a + 3);
    }
}
catch (OverflowException e)
{
    Console.WriteLine(e.Message);  // output: Arithmetic operation resulted in an overflow.
}

备注

用户定义的运算符和溢出情况下的转换行为可能与上一段中描述的不同。 特别是,用户定义的 checked 运算符可能不会在已检查的上下文中引发异常。

有关详细信息,请参阅算术运算符一文的算术溢出和被零除以及用户定义的 checked 运算符部分。

若要为表达式指定溢出检查上下文,还可以使用 checkedunchecked 运算符,如以下示例所示:

C#复制运行

double a = double.MaxValue;

int b = unchecked((int)a);
Console.WriteLine(b);  // output: -2147483648

try
{
    b = checked((int)a);
}
catch (OverflowException e)
{
    Console.WriteLine(e.Message);  // output: Arithmetic operation resulted in an overflow.
}

checkedunchecked 语句和运算符仅影响以文本形式存在于语句块或运算符括号内的操作的溢出检查上下文,如以下示例所示:

C#复制运行

int Multiply(int a, int b) => a * b;

int factor = 2;

try
{
    checked
    {
        Console.WriteLine(Multiply(factor, int.MaxValue));  // output: -2
    }
}
catch (OverflowException e)
{
    Console.WriteLine(e.Message);
}

try
{
    checked
    {
        Console.WriteLine(Multiply(factor, factor * int.MaxValue));
    }
}
catch (OverflowException e)
{
    Console.WriteLine(e.Message);  // output: Arithmetic operation resulted in an overflow.
}

在前面的示例中,第一次调用 Multiply 本地函数表明,checked 语句不会影响 Multiply 函数中的溢出检查上下文,因为不会引发任何异常。 在第二次调用 Multiply 函数时,计算函数第二个参数的表达式将在已检查的上下文中计算,并导致异常,因为它以文本形式存在于 checked 语句的块内。

07.05.06.02 受溢出检查上下文影响的操作

溢出检查上下文会影响以下操作:

07.05.06.03 默认溢出检查上下文

如果未指定溢出检查上下文,则 CheckForOverflowUnderflow 编译器选项的值将定义非常数表达式的默认上下文。 默认情况下,该选项的值未设置,并且整型算术运算和转换在未检查的上下文中执行。

默认情况下,常数表达式在已检查的上下文中计算,如果发生溢出,则会发生编译时错误。 可以使用 unchecked 语句或运算符为常数表达式显式指定未检查的上下文。

07.05.07 fixed 语句 - 固定用于指针操作的变量

fixed 语句可防止垃圾回收器重新定位可移动变量,并声明指向该变量的指针。 固定变量的地址在语句的持续时间内不会更改。 只能在相应的 fixed 语句中使用声明的指针。 声明的指针是只读的,无法修改:

C#复制

unsafe
{
    byte[] bytes = [1, 2, 3];
    fixed (byte* pointerToFirst = bytes)
    {
        Console.WriteLine($"The address of the first array element: {(long)pointerToFirst:X}.");
        Console.WriteLine($"The value of the first array element: {*pointerToFirst}.");
    }
}
// Output is similar to:
// The address of the first array element: 2173F80B5C8.
// The value of the first array element: 1.

备注

只能在不安全的上下文中使用 fixed 语句。 必须使用 AllowUnsafeBlocks 编译器选项来编译包含不安全块的代码。

可以按如下所示初始化声明的指针:

  • 使用数组,如本文开头的示例所示。 初始化的指针包含第一个数组元素的地址。

  • 使用变量的地址。 使用 address-of & 运算符,如以下示例所示:

    C#复制

    unsafe
    {
        int[] numbers = [10, 20, 30];
        fixed (int* toFirst = &numbers[0], toLast = &numbers[^1])
        {
            Console.WriteLine(toLast - toFirst);  // output: 2
        }
    }
    

    对象字段是可以固定的可移动变量的另一个示例。

    当初始化的指针包含对象字段或数组元素的地址时,fixed 语句保证垃圾回收器在语句主体执行期间不会重新定位或释放包含对象实例。

  • 使用实现名为 GetPinnableReference 的方法的类型的实例。 该方法必须返回非托管类型ref 变量。 .NET 类型 System.Span<T>System.ReadOnlySpan<T> 使用此模式。 可以固定跨度实例,如以下示例所示:

    C#复制

    unsafe
    {
        int[] numbers = [10, 20, 30, 40, 50];
        Span<int> interior = numbers.AsSpan()[1..^1];
        fixed (int* p = interior)
        {
            for (int i = 0; i < interior.Length; i++)
            {
                Console.Write(p[i]);  
            }
            // output: 203040
        }
    }
    

    有关详细信息,请参阅 Span<T>.GetPinnableReference() API 参考。

  • 使用字符串,如以下示例所示:

    C#复制

    unsafe
    {
        var message = "Hello!";
        fixed (char* p = message)
        {
            Console.WriteLine(*p);  // output: H
        }
    }
    
  • 使用固定大小的缓冲区

可以在堆栈上分配内存,在这种情况下,内存不受垃圾回收的约束,因此不需要固定。 为此,请使用 stackalloc 表达式

还可以使用 fixed 关键字声明固定大小的缓冲区

07.05.08 lock 语句 - 确保对共享资源的独占访问权限

lock 语句获取给定对象的互斥 lock,执行语句块,然后释放 lock。 持有 lock 时,持有 lock 的线程可以再次获取并释放 lock。 阻止任何其他线程获取 lock 并等待释放 lock。 lock 语句可确保在任何时候最多只有一个线程执行其主体。

07.05.08.01 lock 语句

C#复制

lock (x)
{
    // Your code...
}

变量 xSystem.Threading.Lock 类型或引用类型的表达式。 当 x 在编译时已知属于类型 System.Threading.Lock 时,它完全等效于:

C#复制

using (x.EnterScope())
{
    // Your code...
}

Lock.EnterScope() 返回的对象是包括一个 Dispose() 方法的 ref struct。 生成的 using 语句可确保即使 lock 语句正文引发异常,也会释放范围。

否则,该 lock 语句完全等效于:

C#复制

object __lockObj = x;
bool __lockWasTaken = false;
try
{
    System.Threading.Monitor.Enter(__lockObj, ref __lockWasTaken);
    // Your code...
}
finally
{
    if (__lockWasTaken) System.Threading.Monitor.Exit(__lockObj);
}

由于该代码使用 try-finally 语句,因此即使在 lock 语句的正文中引发异常,也会释放 lock。

lock 语句的正文中不能使用 await 表达式

07.05.08.02 准则

从 .NET 9 和 C# 13 开始,锁定 System.Threading.Lock 类型的专用对象实例以获取最佳性能。 此外,如果已知的 Lock 对象被强制转换为另一种类型并锁定,编译器会发出警告。 如果使用旧版的 .NET 和 C#,请锁定不用于其他用途的专用对象实例。 避免对不同的共享资源使用相同的 lock 对象实例,因为这可能导致死锁或锁争用。 具体而言,请避免将以下实例用作 lock 对象:

  • this,因为调用方也可能锁定 this
  • Type 实例(可以通过 typeof 运算符或反射获取)。
  • 字符串实例,包括字符串字面量,(这些可能是暂存的)。

尽可能缩短持有锁的时间,以减少锁争用。

07.05.08.03 示例

以下示例定义了一个 Account 类,该类通过锁定专用的 balanceLock 实例来同步对其专用 balance 字段的访问。 使用同一实例进行锁定可确保两个不同的线程不能同时调用 DebitCredit 方法更新 balance 字段。 此示例使用 C# 13 和新 Lock 对象。 如果使用较旧版本的 C# 或较旧的 .NET 库,请锁定 object 实例。

C#复制

using System;
using System.Threading.Tasks;

public class Account
{
    // Use `object` in versions earlier than C# 13
    private readonly System.Threading.Lock _balanceLock = new();
    private decimal _balance;

    public Account(decimal initialBalance) => _balance = initialBalance;

    public decimal Debit(decimal amount)
    {
        if (amount < 0)
        {
            throw new ArgumentOutOfRangeException(nameof(amount), "The debit amount cannot be negative.");
        }

        decimal appliedAmount = 0;
        lock (_balanceLock)
        {
            if (_balance >= amount)
            {
                _balance -= amount;
                appliedAmount = amount;
            }
        }
        return appliedAmount;
    }

    public void Credit(decimal amount)
    {
        if (amount < 0)
        {
            throw new ArgumentOutOfRangeException(nameof(amount), "The credit amount cannot be negative.");
        }

        lock (_balanceLock)
        {
            _balance += amount;
        }
    }

    public decimal GetBalance()
    {
        lock (_balanceLock)
        {
            return _balance;
        }
    }
}

class AccountTest
{
    static async Task Main()
    {
        var account = new Account(1000);
        var tasks = new Task[100];
        for (int i = 0; i < tasks.Length; i++)
        {
            tasks[i] = Task.Run(() => Update(account));
        }
        await Task.WhenAll(tasks);
        Console.WriteLine($"Account's balance is {account.GetBalance()}");
        // Output:
        // Account's balance is 2000
    }

    static void Update(Account account)
    {
        decimal[] amounts = [0, 2, -3, 6, -2, -1, 8, -5, 11, -6];
        foreach (var amount in amounts)
        {
            if (amount >= 0)
            {
                account.Credit(amount);
            }
            else
            {
                account.Debit(Math.Abs(amount));
            }
        }
    }
}

07.05.09 yield 语句 - 提供下一个元素

迭代器中使用 yield 语句提供下一个值或表示迭代结束。 yield 语句有以下两种形式:

  • yield return:在迭代中提供下一个值,如以下示例所示:

    C#复制运行

    foreach (int i in ProduceEvenNumbers(9))
    {
        Console.Write(i);
        Console.Write(" ");
    }
    // Output: 0 2 4 6 8
    
    IEnumerable<int> ProduceEvenNumbers(int upto)
    {
        for (int i = 0; i <= upto; i += 2)
        {
            yield return i;
        }
    }
    
  • yield break:显式示迭代结束,如以下示例所示:

    C#复制运行

    Console.WriteLine(string.Join(" ", TakeWhilePositive(new int[] {2, 3, 4, 5, -1, 3, 4})));
    // Output: 2 3 4 5
    
    Console.WriteLine(string.Join(" ", TakeWhilePositive(new int[] {9, 8, 7})));
    // Output: 9 8 7
    
    IEnumerable<int> TakeWhilePositive(IEnumerable<int> numbers)
    {
        foreach (int n in numbers)
        {
            if (n > 0)
            {
                yield return n;
            }
            else
            {
                yield break;
            }
        }
    }
    

    当控件到达迭代器的末尾时,迭代也结束。

在前面的示例中,迭代器的返回类型为 IEnumerable<T>(在非泛型情况下,使用 IEnumerable 作为迭代器的返回类型)。 还可以使用 IAsyncEnumerable<T> 作为迭代器的返回类型。 这使得迭代器异步。 使用 await foreach 语句对迭代器的结果进行迭代,如以下示例所示:

C#复制

await foreach (int n in GenerateNumbersAsync(5))
{
    Console.Write(n);
    Console.Write(" ");
}
// Output: 0 2 4 6 8

async IAsyncEnumerable<int> GenerateNumbersAsync(int count)
{
    for (int i = 0; i < count; i++)
    {
        yield return await ProduceNumberAsync(i);
    }
}

async Task<int> ProduceNumberAsync(int seed)
{
    await Task.Delay(1000);
    return 2 * seed;
}

迭代器的返回类型可以是 IEnumerator<T>IEnumerator。 在以下方案中实现 GetEnumerator 方法时,请使用这些返回类型:

  • 设计实现 IEnumerable<T>IEnumerable 接口的类型。

  • 添加实例或扩展 GetEnumerator 方法来使用 foreach 语句对类型的实例启用迭代,如以下示例所示:

    C#复制

    public static void Example()
    {
        var point = new Point(1, 2, 3);
        foreach (int coordinate in point)
        {
            Console.Write(coordinate);
            Console.Write(" ");
        }
        // Output: 1 2 3
    }
    
    public readonly record struct Point(int X, int Y, int Z)
    {
        public IEnumerator<int> GetEnumerator()
        {
            yield return X;
            yield return Y;
            yield return Z;
        }
    }
    

不能在下列情况中使用 yield 语句:

  • 带有 inrefout 参数的方法
  • Lambda 表达式匿名方法
  • 不安全块。 在 C# 13 之前,yield 在具有 unsafe 块的任何方法中都无效。 从 C# 13 开始,可以在包含 unsafe 块的方法中使用 yield,但不能在 unsafe 块中使用。
  • yield returnyield break 不能在 trycatchfinally 块中使用。
07.05.09.01 迭代器的执行

迭代器的调用不会立即执行,如以下示例所示:

C#复制运行

var numbers = ProduceEvenNumbers(5);
Console.WriteLine("Caller: about to iterate.");
foreach (int i in numbers)
{
    Console.WriteLine($"Caller: {i}");
}

IEnumerable<int> ProduceEvenNumbers(int upto)
{
    Console.WriteLine("Iterator: start.");
    for (int i = 0; i <= upto; i += 2)
    {
        Console.WriteLine($"Iterator: about to yield {i}");
        yield return i;
        Console.WriteLine($"Iterator: yielded {i}");
    }
    Console.WriteLine("Iterator: end.");
}
// Output:
// Caller: about to iterate.
// Iterator: start.
// Iterator: about to yield 0
// Caller: 0
// Iterator: yielded 0
// Iterator: about to yield 2
// Caller: 2
// Iterator: yielded 2
// Iterator: about to yield 4
// Caller: 4
// Iterator: yielded 4
// Iterator: end.

如前面的示例所示,当开始对迭代器的结果进行迭代时,迭代器会一直执行,直到到达第一个 yield return 语句为止。 然后,迭代器的执行会暂停,调用方会获得第一个迭代值并处理该值。 在后续的每次迭代中,迭代器的执行都会在导致上一次挂起的 yield return 语句之后恢复,并继续执行,直到到达下一个 yield return 语句为止。 当控件到达迭代器或 yield break 语句的末尾时,迭代完成。

07.06 字符串和字符串字面量

字符串是值为文本的 String 类型对象。 文本在内部存储为 Char 对象的依序只读集合。 在 C# 字符串末尾没有 null 终止字符;因此,一个 C# 字符串可以包含任何数量的嵌入的 null 字符 ('\0')。 字符串的 Length 属性表示其包含的 Char 对象数量,而非 Unicode 字符数。 若要访问字符串中的各个 Unicode 码位,请使用 StringInfo 对象。

07.06.01 string 与System.String

在 C# 中,string 关键字是 String 的别名。 因此,Stringstring 是等效的(虽然建议使用提供的别名 string),因为即使不使用 using System;,它也能正常工作。 String 类提供了安全创建、操作和比较字符串的多种方法。 此外,C# 语言重载了部分运算符,以简化常见字符串操作。 有关关键字的详细信息,请参阅 string。 有关类型及其方法的详细信息,请参阅 String

07.06.02 声明和初始化字符串

可以使用各种方法声明和初始化字符串,如以下示例中所示:

C#复制

// Declare without initializing.
string message1;

// Initialize to null.
string message2 = null;

// Initialize as an empty string.
// Use the Empty constant instead of the literal "".
string message3 = System.String.Empty;

// Initialize with a regular string literal.
string oldPath = "c:\\Program Files\\Microsoft Visual Studio 8.0";

// Initialize with a verbatim string literal.
string newPath = @"c:\Program Files\Microsoft Visual Studio 9.0";

// Use System.String if you prefer.
System.String greeting = "Hello World!";

// In local variables (i.e. within a method body)
// you can use implicit typing.
var temp = "I'm still a strongly-typed System.String!";

// Use a const string to prevent 'message4' from
// being used to store another string value.
const string message4 = "You can't get rid of me!";

// Use the String constructor only when creating
// a string from a char*, char[], or sbyte*. See
// System.String documentation for details.
char[] letters = { 'A', 'B', 'C' };
string alphabet = new string(letters);

不要使用 new 运算符创建字符串对象,除非使用字符数组初始化字符串。

使用 Empty 常量值初始化字符串,以新建字符串长度为零的 String 对象。 长度为零的字符串文本表示法是""。 通过使用 Empty 值(而不是 null)初始化字符串,可以减少 NullReferenceException 发生的可能性。 尝试访问字符串前,先使用静态 IsNullOrEmpty(String) 方法验证字符串的值。

07.06.03 字符串的不可变性

字符串对象是"不可变的":它们在创建后无法更改。 看起来是在修改字符串的所有 String 方法和 C# 运算符实际上都是在新的字符串对象中返回结果。 在下面的示例中,当 s1s2 的内容被串联在一起以形成单个字符串时,两个原始字符串没有被修改。 += 运算符创建一个新的字符串,其中包含组合的内容。 这个新对象被分配给变量 s1,而分配给 s1 的原始对象被释放,以供垃圾回收,因为没有任何其他变量包含对它的引用。

C#复制

string s1 = "A string is more ";
string s2 = "than the sum of its chars.";

// Concatenate s1 and s2. This actually creates a new
// string object and stores it in s1, releasing the
// reference to the original object.
s1 += s2;

System.Console.WriteLine(s1);
// Output: A string is more than the sum of its chars.

由于字符串"modification"实际上是一个新创建的字符串,因此,必须在创建对字符串的引用时使用警告。 如果创建了字符串的引用,然后"修改"了原始字符串,则该引用将继续指向原始对象,而非指向修改字符串时所创建的新对象。 以下代码阐释了此行为:

C#复制

string str1 = "Hello ";
string str2 = str1;
str1 += "World";

System.Console.WriteLine(str2);
//Output: Hello

有关如何创建基于修改的新字符串的详细信息,例如原始字符串上的搜索和替换操作,请参阅如何修改字符串内容

07.06.04 带引号的字符串字面量

带引号的字符串字面量在同一行上以单个双引号字符 (") 开头和结尾。 带引号的字符串字面量最适合匹配单个行且不包含任何转义序列的字符串。 带引号的字符串字面量必须嵌入转义字符,如以下示例所示:

C#复制

string columns = "Column 1\tColumn 2\tColumn 3";
//Output: Column 1        Column 2        Column 3

string rows = "Row 1\r\nRow 2\r\nRow 3";
/* Output:
    Row 1
    Row 2
    Row 3
*/

string title = "\"The \u00C6olean Harp\", by Samuel Taylor Coleridge";
//Output: "The Æolean Harp", by Samuel Taylor Coleridge

07.06.05 逐字字符串文本

对于多行字符串、包含反斜杠字符或嵌入双引号的字符串,逐字字符串字面量更方便。 逐字字符串将新的行字符作为字符串文本的一部分保留。 使用双引号在逐字字符串内部嵌入引号。 下面的示例演示逐字字符串的一些常见用法:

C#复制

string filePath = @"C:\Users\scoleridge\Documents\";
//Output: C:\Users\scoleridge\Documents\

string text = @"My pensive SARA ! thy soft cheek reclined
    Thus on mine arm, most soothing sweet it is
    To sit beside our Cot,...";
/* Output:
My pensive SARA ! thy soft cheek reclined
    Thus on mine arm, most soothing sweet it is
    To sit beside our Cot,...
*/

string quote = @"Her name was ""Sara.""";
//Output: Her name was "Sara."

07.06.06 原始字符串文本

从 C# 11 开始,可以使用原始字符串字面量更轻松地创建多行字符串,或使用需要转义序列的任何字符。 原始字符串字面量无需使用转义序列。 你可以编写字符串,包括空格格式,以及你希望在输出中显示该字符串的方式。 原始字符串字面量:

  • 以至少三个双引号字符序列 (""") 开头和结尾。 可以使用三个以上的连续字符开始和结束序列,以支持包含三个(或更多)重复引号字符的字符串字面量。
  • 单行原始字符串字面量需要左引号和右引号字符位于同一行上。
  • 多行原始字符串字面量需要左引号和右引号字符位于各自的行上。
  • 在多行原始字符串字面量中,会删除右引号左侧的任何空格。

以下示例演示了这些规则:

C#复制

string singleLine = """Friends say "hello" as they pass by.""";
string multiLine = """
    "Hello World!" is typically the first program someone writes.
    """;
string embeddedXML = """
       <element attr = "content">
           <body style="normal">
               Here is the main text
           </body>
           <footer>
               Excerpts from "An amazing story"
           </footer>
       </element >
       """;
// The line "<element attr = "content">" starts in the first column.
// All whitespace left of that column is removed from the string.

string rawStringLiteralDelimiter = """"
    Raw string literals are delimited 
    by a string of at least three double quotes,
    like this: """
    """";

以下示例演示了基于这些规则报告的编译器错误:

C#复制

// CS8997: Unterminated raw string literal.
var multiLineStart = """This
    is the beginning of a string 
    """;

// CS9000: Raw string literal delimiter must be on its own line.
var multiLineEnd = """
    This is the beginning of a string """;

// CS8999: Line does not start with the same whitespace as the closing line
// of the raw string literal
var noOutdenting = """
    A line of text.
Trying to outdent the second line.
    """;

前两个示例无效,因为多行原始字符串字面量需要让左引号和右引号序列在其自己的行上。 第三个示例无效,因为文本已从右引号序列中缩进。

使用带引号的字符串字面量或逐字字符串字面量时,如果生成的文本包括需要转义序列的字符,应考虑原始字符串字面量。 原始字符串字面量将更易于你和其他人阅读,因为它更类似于输出文本。 例如,请考虑包含格式化 JSON 字符串的以下代码:

C#复制

string jsonString = """
{
  "Date": "2019-08-01T00:00:00-07:00",
  "TemperatureCelsius": 25,
  "Summary": "Hot",
  "DatesAvailable": [
    "2019-08-01T00:00:00-07:00",
    "2019-08-02T00:00:00-07:00"
  ],
  "TemperatureRanges": {
    "Cold": {
      "High": 20,
      "Low": -10
    },
    "Hot": {
      "High": 60,
      "Low": 20
    }
            },
  "SummaryWords": [
    "Cool",
    "Windy",
    "Humid"
  ]
}
""";

将该文本与 JSON 序列化示例中的等效文本(没有使用此新功能)进行比较。

07.06.07 字符串转义序列

展开表

转义序列 字符名称 Unicode 编码
\' 单引号 0x0027
\" 双引号 0x0022
\ 反斜杠 0x005C
\0 null 0x0000
\a 警报 0x0007
\b Backspace 0x0008
\f 换页 0x000C
\n 换行 0x000A
\r 回车 0x000D
\t 水平制表符 0x0009
\v 垂直制表符 0x000B
\u Unicode 转义序列 (UTF-16) \uHHHH(范围:0000 - FFFF;示例:\u00E7 ="ç")
\U Unicode 转义序列 (UTF-32) \U00HHHHHH(范围:000000 - 10FFFF;示例:\U0001F47D = "👽")
\x 除长度可变外,Unicode 转义序列与"\u"类似 \xH[H][H][H](范围:0 - FFFF;示例:\x00E7\x0E7\xE7 ="ç")

警告

使用 \x 转义序列且指定的位数小于 4 个十六进制数字时,如果紧跟在转义序列后面的字符是有效的十六进制数字(即 0-9、A-F 和 a-f),则这些字符将被解释为转义序列的一部分。 例如,\xA1 会生成"¡",即码位 U+00A1。 但是,如果下一个字符是"A"或"a",则转义序列将转而被解释为 \xA1A 并生成"ਚ"(即码位 U+0A1A)。 在此类情况下,如果指定全部 4 个十六进制数字(例如 \x00A1),则可能导致解释出错。

备注

在编译时,逐字字符串被转换为普通字符串,并具有所有相同的转义序列。 因此,如果在调试器监视窗口中查看逐字字符串,将看到由编译器添加的转义字符,而不是来自你的源代码的逐字字符串版本。 例如,原义字符串 @"C:\files.txt" 在监视窗口中显示为"C:\files.txt"。

07.06.08 格式字符串

格式字符串是在运行时以动态方式确定其内容的字符串。 格式字符串是通过将内插表达式或占位符嵌入字符串大括号内创建的。 大括号 ({...}) 中的所有内容都将解析为一个值,并在运行时以格式化字符串的形式输出。 有两种方法创建格式字符串:字符串内插和复合格式。

07.06.09 字符串内插

在 C# 6.0 及更高版本中提供,内插字符串$ 特殊字符标识,并在大括号中包含内插表达式。 如果不熟悉字符串内插,请参阅字符串内插 - C# 交互式教程快速概览。

使用字符串内插来改善代码的可读性和可维护性。 字符串内插可实现与 String.Format 方法相同的结果,但提高了易用性和内联清晰度。

C#复制

var jh = (firstName: "Jupiter", lastName: "Hammon", born: 1711, published: 1761);
Console.WriteLine($"{jh.firstName} {jh.lastName} was an African American poet born in {jh.born}.");
Console.WriteLine($"He was first published in {jh.published} at the age of {jh.published - jh.born}.");
Console.WriteLine($"He'd be over {Math.Round((2018d - jh.born) / 100d) * 100d} years old today.");

// Output:
// Jupiter Hammon was an African American poet born in 1711.
// He was first published in 1761 at the age of 50.
// He'd be over 300 years old today.

从 C# 10 开始,当用于占位符的所有表达式也是常量字符串时,可以使用字符串内插来初始化常量字符串。

从 C# 11 开始,可以将原始字符串字面量与字符串内插结合使用。 使用三个或更多个连续双引号开始和结束格式字符串。 如果输出字符串应包含 {} 字符,则可以使用额外的 $ 字符来指定开始和结束内插的 {} 字符数。 输出中包含任何更少的 {} 字符序列。 以下示例演示了如何使用该功能来显示点与原点的距离,以及如何将点置于大括号中:

C#复制

int X = 2;
int Y = 3;

var pointMessage = $$"""The point {{{X}}, {{Y}}} is {{Math.Sqrt(X * X + Y * Y)}} from the origin.""";

Console.WriteLine(pointMessage);
// Output:
// The point {2, 3} is 3.605551275463989 from the origin.

07.06.10 复合格式设置

String.Format 利用大括号中的占位符创建格式字符串。 此示例生成与上面使用的字符串内插方法类似的输出。

C#复制

var pw = (firstName: "Phillis", lastName: "Wheatley", born: 1753, published: 1773);
Console.WriteLine("{0} {1} was an African American poet born in {2}.", pw.firstName, pw.lastName, pw.born);
Console.WriteLine("She was first published in {0} at the age of {1}.", pw.published, pw.published - pw.born);
Console.WriteLine("She'd be over {0} years old today.", Math.Round((2018d - pw.born) / 100d) * 100d);

// Output:
// Phillis Wheatley was an African American poet born in 1753.
// She was first published in 1773 at the age of 20.
// She'd be over 300 years old today.

有关设置 .NET 类型格式的详细信息,请参阅 .NET 中的格式设置类型

07.06.11 子字符串

子字符串是包含在字符串中的任何字符序列。 使用 Substring 方法可以通过原始字符串的一部分新建字符串。 可以使用 IndexOf 方法搜索一次或多次出现的子字符串。 使用 Replace 方法可以将出现的所有指定子字符串替换为新字符串。 与 Substring 方法一样,Replace 实际返回的是新字符串,且不修改原始字符串。 有关详细信息,请参阅如何搜索字符串如何修改字符串内容

C#复制

string s3 = "Visual C# Express";
System.Console.WriteLine(s3.Substring(7, 2));
// Output: "C#"

System.Console.WriteLine(s3.Replace("C#", "Basic"));
// Output: "Visual Basic Express"

// Index values are zero-based
int index = s3.IndexOf("C");
// index = 7

07.06.12 访问单个字符

可以使用包含索引值的数组表示法来获取对单个字符的只读访问权限,如下面的示例中所示:

C#复制

string s5 = "Printing backwards";

for (int i = 0; i < s5.Length; i++)
{
    System.Console.Write(s5[s5.Length - i - 1]);
}
// Output: "sdrawkcab gnitnirP"

如果 String 方法不提供修改字符串中的各个字符所需的功能,可以使用 StringBuilder 对象"就地"修改各个字符,再新建字符串来使用 StringBuilder 方法存储结果。 在下面的示例中,假定必须以特定方式修改原始字符串,然后存储结果以供未来使用:

C#复制

string question = "hOW DOES mICROSOFT wORD DEAL WITH THE cAPS lOCK KEY?";
System.Text.StringBuilder sb = new System.Text.StringBuilder(question);

for (int j = 0; j < sb.Length; j++)
{
    if (System.Char.IsLower(sb[j]) == true)
        sb[j] = System.Char.ToUpper(sb[j]);
    else if (System.Char.IsUpper(sb[j]) == true)
        sb[j] = System.Char.ToLower(sb[j]);
}
// Store the new string.
string corrected = sb.ToString();
System.Console.WriteLine(corrected);
// Output: How does Microsoft Word deal with the Caps Lock key?

07.06.13 Null 字符串和空字符串

空字符串是包含零个字符的 System.String 对象实例。 空字符串常用在各种编程方案中,表示空文本字段。 可以对空字符串调用方法,因为它们是有效的 System.String 对象。 对空字符串进行了初始化,如下所示:

C#复制

string s = String.Empty;

相比较而言,null 字符串并不指 System.String 对象实例,只要尝试对 null 字符串调用方法,都会引发 NullReferenceException。 但是,可以在串联和与其他字符串的比较操作中使用 null 字符串。 以下示例说明了对 null 字符串的引用会引发和不会引发意外的某些情况:

C#复制

string str = "hello";
string nullStr = null;
string emptyStr = String.Empty;

string tempStr = str + nullStr;
// Output of the following line: hello
Console.WriteLine(tempStr);

bool b = (emptyStr == nullStr);
// Output of the following line: False
Console.WriteLine(b);

// The following line creates a new empty string.
string newStr = emptyStr + nullStr;

// Null strings and empty strings behave differently. The following
// two lines display 0.
Console.WriteLine(emptyStr.Length);
Console.WriteLine(newStr.Length);
// The following line raises a NullReferenceException.
//Console.WriteLine(nullStr.Length);

// The null character can be displayed and counted, like other chars.
string s1 = "\x0" + "abc";
string s2 = "abc" + "\x0";
// Output of the following line: * abc*
Console.WriteLine("*" + s1 + "*");
// Output of the following line: *abc *
Console.WriteLine("*" + s2 + "*");
// Output of the following line: 4
Console.WriteLine(s2.Length);

07.06.14 使用 StringBuilder 快速创建字符串

.NET 中的字符串操作进行了高度的优化,在大多数情况下不会显著影响性能。 但是,在某些情况下(例如,执行数百次或数千次的紧密循环),字符串操作可能影响性能。 StringBuilder 类创建字符串缓冲区,用于在程序执行多个字符串操控时提升性能。 使用 StringBuilder 字符串,还可以重新分配各个字符,而内置字符串数据类型则不支持这样做。 例如,此代码更改字符串的内容,而无需创建新的字符串:

C#复制

System.Text.StringBuilder sb = new System.Text.StringBuilder("Rat: the ideal pet");
sb[0] = 'C';
System.Console.WriteLine(sb.ToString());
//Outputs Cat: the ideal pet

在以下示例中,StringBuilder 对象用于通过一组数字类型创建字符串:

C#复制

var sb = new StringBuilder();

// Create a string composed of numbers 0 - 9
for (int i = 0; i < 10; i++)
{
    sb.Append(i.ToString());
}
Console.WriteLine(sb);  // displays 0123456789

// Copy one character of the string (not possible with a System.String)
sb[0] = sb[9];

Console.WriteLine(sb);  // displays 9123456789

07.06.15 字符串、扩展方法和 LINQ

由于 String 类型实现 IEnumerable<T>,因此可以对字符串使用 Enumerable 类中定义的扩展方法。 为了避免视觉干扰,这些方法已从 String 类型的 IntelliSense 中排除,但它们仍然可用。 还可以使用字符串上的 LINQ 查询表达式。 有关详细信息,请参阅 LINQ 和字符串

07.06.16 如何确定字符串是否表示数值

若要确定字符串是否是指定数值类型的有效表示形式,请使用由所有基元数值类型以及如 DateTimeIPAddress 等类型实现的静态 TryParse 方法。 以下示例演示如何确定"108"是否为有效的 int

C#复制

int i = 0;
string s = "108";  
bool result = int.TryParse(s, out i); //i now = 108  

如果该字符串包含非数字字符,或者数值对于指定的特定类型而言太大或太小,则 TryParse 将返回 false 并将 out 参数设置为零。 否则,它将返回 true 并将 out 参数设置为字符串的数值。

备注

字符串可能仅包含数字字符,但对于你使用的 TryParse 方法的类型仍然无效。 例如,"256"不是 byte 的有效值,但对 int 有效。 "98.6"不是 int 的有效值,但它是有效的 decimal

以下示例演示如何对 longbytedecimal 值的字符串表示形式使用 TryParse

C#复制

string numString = "1287543"; //"1287543.0" will return false for a long
long number1 = 0;
bool canConvert = long.TryParse(numString, out number1);
if (canConvert == true)
Console.WriteLine("number1 now = {0}", number1);
else
Console.WriteLine("numString is not a valid long");

byte number2 = 0;
numString = "255"; // A value of 256 will return false
canConvert = byte.TryParse(numString, out number2);
if (canConvert == true)
Console.WriteLine("number2 now = {0}", number2);
else
Console.WriteLine("numString is not a valid byte");

decimal number3 = 0;
numString = "27.3"; //"27" is also a valid decimal
canConvert = decimal.TryParse(numString, out number3);
if (canConvert == true)
Console.WriteLine("number3 now = {0}", number3);
else
Console.WriteLine("number3 is not a valid decimal");

08 类型转换专题

08.01 强制转换和类型转换

由于 C# 是在编译时静态类型化的,因此变量在声明后就无法再次声明,或无法分配另一种类型的值,除非该类型可以隐式转换为变量的类型。 例如,string 无法隐式转换为 int。 因此,在将 i 声明为 int 后,无法将字符串"Hello"分配给它,如以下代码所示:

C#

int i;

// error CS0029: can't implicitly convert type 'string' to 'int'
i = "Hello";

但有时可能需要将值复制到其他类型的变量或方法参数中。 例如,可能需要将一个整数变量传递给参数类型化为 double 的方法。 或者可能需要将类变量分配给接口类型的变量。 这些类型的操作称为类型转换。 在 C# 中,可以执行以下几种类型的转换:

  • 隐式转换:不需要特殊语法,因为转换始终会成功,并且不会丢失任何数据。 示例包括从较小整数类型到较大整数类型的转换以及从派生类到基类的转换。

  • 显式转换(强制转换) :必须使用强制转换表达式,才能执行显式转换。 在转换中可能丢失信息时或在出于其他原因转换可能不成功时,必须进行强制转换。 典型的示例包括从数值到精度较低或范围较小的类型的转换和从基类实例到派生类的转换。

  • 用户定义的转换 :用户定义的转换使用你可以定义的特殊方法,以支持在不具有基类和派生类关系的自定义类型之间实现显式和隐式转换。 有关详细信息,请参阅用户定义转换运算符

  • 使用帮助程序类进行转换 :若要在非兼容类型(如整数和 System.DateTime 对象,或十六进制字符串和字节数组)之间转换,可使用 System.BitConverter 类、System.Convert 类和内置数值类型的 Parse 方法(如 Int32.Parse)。 有关详细信息,请参见如何将字节数组转换为 int如何将字符串转换为数字如何在十六进制字符串与数值类型之间转换

08.01.01 隐式转换

对于内置数值类型,如果要存储的值无需截断或四舍五入即可适应变量,则可以进行隐式转换。 对于整型类型,这意味着源类型的范围是目标类型范围的正确子集。 例如,long 类型的变量(64 位整数)能够存储 int(32 位整数)可存储的任何值。 在下面的示例中,编译器先将右侧的 num 值隐式转换为 long 类型,再将它赋给 bigNum

C#

// Implicit conversion. A long can
// hold any value an int can hold, and more!
int num = 2147483647;
long bigNum = num;

有关所有隐式数值转换的完整列表,请参阅内置数值转换一文的隐式数值转换表部分。

对于引用类型,隐式转换始终存在于从一个类转换为该类的任何一个直接或间接的基类或接口的情况。 由于派生类始终包含基类的所有成员,因此不必使用任何特殊语法。

C#

Derived d = new Derived();

// Always OK.
Base b = d;

08.01.02 显式转换

但是,如果进行转换可能会导致信息丢失,则编译器会要求执行显式转换,显式转换也称为强制转换 。 强制转换是显式告知编译器以下信息的一种方式:你打算进行转换且你知道可能会发生数据丢失,或者你知道强制转换有可能在运行时失败。 若要执行强制转换,请在要转换的值或变量前面的括号中指定要强制转换到的类型。 下面的程序将 double 强制转换为 int。如不强制转换,程序将无法编译。

C#

class Test
{
    static void Main()
    {
        double x = 1234.7;
        int a;
        // Cast double to int.
        a = (int)x;
        System.Console.WriteLine(a);
    }
}
// Output: 1234

有关支持的显式数值转换的完整列表,请参阅内置数值转换一文的显式数值转换部分。

对于引用类型,如果需要从基类型转换为派生类型,则必须进行显式强制转换:

C#

// Create a new derived type.
Giraffe g = new Giraffe();

// Implicit conversion to base type is safe.
Animal a = g;

// Explicit conversion is required to cast back
// to derived type. Note: This will compile but will
// throw an exception at run time if the right-side
// object is not in fact a Giraffe.
Giraffe g2 = (Giraffe)a;

引用类型之间的强制转换操作不会更改基础对象的运行时类型;它只更改用作对该对象引用的值的类型。 有关详细信息,请参阅多态性

08.01.03 运行时的类型转换异常

在某些引用类型转换中,编译器无法确定强制转换是否会有效。 正确进行编译的强制转换操作有可能在运行时失败。 如下面的示例所示,类型转换在运行时失败将导致引发 InvalidCastException

C#

class Animal
{
    public void Eat() => System.Console.WriteLine("Eating.");

    public override string ToString() => "I am an animal.";
}

class Reptile : Animal { }
class Mammal : Animal { }

class UnSafeCast
{
    static void Main()
    {
        Test(new Mammal());

        // Keep the console window open in debug mode.
        System.Console.WriteLine("Press any key to exit.");
        System.Console.ReadKey();
    }

    static void Test(Animal a)
    {
        // System.InvalidCastException at run time
        // Unable to cast object of type 'Mammal' to type 'Reptile'
        Reptile r = (Reptile)a;
    }
}

Test 方法有一个 Animal 形式参数,因此,将实际参数 a 显式强制转换为 Reptile 会造成危险的假设。 更安全的做法是不要做出假设,而是检查类型。 C# 提供 is 运算符,使你可以在实际执行强制转换之前测试兼容性。 有关详细信息,请参阅如何使用模式匹配以及 as 和 is 运算符安全地进行强制转换

08.02 装箱和取消装箱

装箱是将值类型转换为 object 类型或由此值类型实现的任何接口类型的过程。 常见语言运行时 (CLR) 对值类型进行装箱时,会将值包装在 System.Object 实例中并将其存储在托管堆中。 取消装箱将从对象中提取值类型。 装箱是隐式的;取消装箱是显式的。 装箱和取消装箱的概念是类型系统 C# 统一视图的基础,其中任一类型的值都被视为一个对象。

下例将整型变量 i 进行了装箱并分配给对象 o

C#

int i = 123;
// The following line boxes i.
object o = i;

然后,可以将对象 o 取消装箱并分配给整型变量 i

C#

o = 123;
i = (int)o;  // unboxing

以下示例演示如何在 C# 中使用装箱。

C#

// String.Concat example.
// String.Concat has many versions. Rest the mouse pointer on
// Concat in the following statement to verify that the version
// that is used here takes three object arguments. Both 42 and
// true must be boxed.
Console.WriteLine(String.Concat("Answer", 42, true));

// List example.
// Create a list of objects to hold a heterogeneous collection
// of elements.
List<object> mixedList = new List<object>();

// Add a string element to the list.
mixedList.Add("First Group:");

// Add some integers to the list.
for (int j = 1; j < 5; j++)
{
    // Rest the mouse pointer over j to verify that you are adding
    // an int to a list of objects. Each element j is boxed when
    // you add j to mixedList.
    mixedList.Add(j);
}

// Add another string and more integers.
mixedList.Add("Second Group:");
for (int j = 5; j < 10; j++)
{
    mixedList.Add(j);
}

// Display the elements in the list. Declare the loop variable by
// using var, so that the compiler assigns its type.
foreach (var item in mixedList)
{
    // Rest the mouse pointer over item to verify that the elements
    // of mixedList are objects.
    Console.WriteLine(item);
}

// The following loop sums the squares of the first group of boxed
// integers in mixedList. The list elements are objects, and cannot
// be multiplied or added to the sum until they are unboxed. The
// unboxing must be done explicitly.
var sum = 0;
for (var j = 1; j < 5; j++)
{
    // The following statement causes a compiler error: Operator
    // '*' cannot be applied to operands of type 'object' and
    // 'object'.
    //sum += mixedList[j] * mixedList[j];

    // After the list elements are unboxed, the computation does
    // not cause a compiler error.
    sum += (int)mixedList[j] * (int)mixedList[j];
}

// The sum displayed is 30, the sum of 1 + 4 + 9 + 16.
Console.WriteLine("Sum: " + sum);

// Output:
// Answer42True
// First Group:
// 1
// 2
// 3
// 4
// Second Group:
// 5
// 6
// 7
// 8
// 9
// Sum: 30

08.02.01 性能

相对于简单的赋值而言,装箱和取消装箱过程需要进行大量的计算。 对值类型进行装箱时,必须分配并构造一个新对象。 取消装箱所需的强制转换也需要进行大量的计算,只是程度较轻。 有关更多信息,请参阅性能

08.02.02 装箱

装箱用于在垃圾回收堆中存储值类型。 装箱是值类型object 类型或到此值类型所实现的任何接口类型的隐式转换。 对值类型装箱会在堆中分配一个对象实例,并将该值复制到新的对象中。

请看以下值类型变量的声明:

C#

int i = 123;

以下语句对变量 i 隐式应用了装箱操作:

C#

// Boxing copies the value of i into object o.
object o = i;

此语句的结果是在堆栈上创建对象引用 o,而在堆上则引用 int 类型的值。 该值是赋给变量 i 的值类型值的一个副本。 以下装箱转换图说明了 io 这两个变量之间的差异:

还可以像下面的示例一样执行显式装箱,但显式装箱从来不是必需的:

C#

int i = 123;
object o = (object)i;  // explicit boxing

08.02.03 示例

此示例使用装箱将整型变量 i 转换为对象 o。 这样一来,存储在变量 i 中的值就从 123 更改为 456。 该示例表明原始值类型和装箱的对象使用不同的内存位置,因此能够存储不同的值。

C#

class TestBoxing
{
    static void Main()
    {
        int i = 123;

        // Boxing copies the value of i into object o.
        object o = i;

        // Change the value of i.
        i = 456;

        // The change in i doesn't affect the value stored in o.
        System.Console.WriteLine("The value-type value = {0}", i);
        System.Console.WriteLine("The object-type value = {0}", o);
    }
}
/* Output:
    The value-type value = 456
    The object-type value = 123
*/

08.02.04 取消装箱

取消装箱是从 object 类型到值类型或从接口类型到实现该接口的值类型的显式转换。 取消装箱操作包括:

  • 检查对象实例,以确保它是给定值类型的装箱值。

  • 将该值从实例复制到值类型变量中。

下面的语句演示装箱和取消装箱两种操作:

C#

int i = 123;      // a value type
object o = i;     // boxing
int j = (int)o;   // unboxing

下图演示了上述语句的结果:

要在运行时成功取消装箱值类型,被取消装箱的项必须是对一个对象的引用,该对象是先前通过装箱该值类型的实例创建的。 尝试取消装箱 null 会导致 NullReferenceException。 尝试取消装箱对不兼容值类型的引用会导致 InvalidCastException

08.02.05 示例

下面的示例演示无效的取消装箱及引发的 InvalidCastException。 使用 trycatch,在发生错误时显示错误信息。

C#

class TestUnboxing
{
    static void Main()
    {
        int i = 123;
        object o = i;  // implicit boxing

        try
        {
            int j = (short)o;  // attempt to unbox

            System.Console.WriteLine("Unboxing OK.");
        }
        catch (System.InvalidCastException e)
        {
            System.Console.WriteLine("{0} Error: Incorrect unboxing.", e.Message);
        }
    }
}

此程序输出:

Specified cast is not valid. Error: Incorrect unboxing.

如果将下列语句:

C#

int j = (short)o;

更改为:

C#

int j = (int)o;

将执行转换,并将得到以下输出:

Unboxing OK.

08.03 数值转换方法

08.03.01 字节数组转换为 int

此示例演示如何使用 BitConverter 类将字节数组转换为 int 然后又转换回字节数组。 例如,在从网络读取字节之后,可能需要将字节转换为内置数据类型。 除了示例中的 ToInt32(Byte[], Int32) 方法之外,下表还列出了 BitConverter 类中将字节(来自字节数组)转换为其他内置类型的方法。

返回类型 方法
bool ToBoolean(Byte[], Int32)
char ToChar(Byte[], Int32)
double ToDouble(Byte[], Int32)
short ToInt16(Byte[], Int32)
int ToInt32(Byte[], Int32)
long ToInt64(Byte[], Int32)
float ToSingle(Byte[], Int32)
ushort ToUInt16(Byte[], Int32)
uint ToUInt32(Byte[], Int32)
ulong ToUInt64(Byte[], Int32)

08.03.02 示例

此示例初始化字节数组,并在计算机体系结构为 little-endian(即首先存储最低有效字节)的情况下反转数组,然后调用 ToInt32(Byte[], Int32) 方法以将数组中的四个字节转换为 intToInt32(Byte[], Int32) 的第二个参数指定字节数组的起始索引。

备注

输出可能会根据计算机体系结构的字节顺序而不同。

C#

byte[] bytes = [0, 0, 0, 25];

// If the system architecture is little-endian (that is, little end first),
// reverse the byte array.
if (BitConverter.IsLittleEndian)
    Array.Reverse(bytes);

int i = BitConverter.ToInt32(bytes, 0);
Console.WriteLine("int: {0}", i);
// Output: int: 25

在本示例中,将调用 BitConverter 类的 GetBytes(Int32) 方法,将 int 转换为字节数组。

备注

输出可能会根据计算机体系结构的字节顺序而不同。

C#

byte[] bytes = BitConverter.GetBytes(201805978);
Console.WriteLine("byte array: " + BitConverter.ToString(bytes));
// Output: byte array: 9A-50-07-0C

08.03.02 字符串转换为数字

你可以调用数值类型(intlongdouble 等)中找到的 ParseTryParse 方法或使用 System.Convert 类中的方法将 string 转换为数字。

调用 TryParse 方法(例如,int.TryParse("11", out number))或 Parse 方法(例如,var number = int.Parse("11"))会稍微高效和简单一些。 使用 Convert 方法对于实现 IConvertible 的常规对象更有用。

对预期字符串会包含的数值类型(如 System.Int32 类型)使用 ParseTryParse 方法。 Convert.ToInt32 方法在内部使用 ParseParse 方法返回转换后的数字;TryParse 方法返回布尔值,该值指示转换是否成功,并以 out 参数形式返回转换后的数字。 如果字符串的格式无效,则 Parse 会引发异常,但 TryParse 会返回 false。 调用 Parse 方法时,应始终使用异常处理来捕获分析操作失败时的 FormatException

08.03.03 调用 Parse 或 TryParse 方法

ParseTryParse 方法会忽略字符串开头和末尾的空格,但所有其他字符都必须是组成合适数值类型(intlongulongfloatdecimal 等)的字符。 如果组成数字的字符串中有任何空格,都会导致错误。 例如,可以使用 decimal.TryParse 分析"10"、"10.3"或" 10 ",但不能使用此方法分析从"10X"、"1 0"(注意嵌入的空格)、"10 .3"(注意嵌入的空格)、"10e1"(float.TryParse 在此处适用)等中分析出 10。 无法成功分析值为 nullString.Empty 的字符串。 在尝试通过调用 String.IsNullOrEmpty 方法分析字符串之前,可以检查字符串是否为 Null 或为空。

下面的示例演示了对 ParseTryParse 的成功调用和不成功的调用。

C#

cs 复制代码
using System;

public static class StringConversion
{
    public static void Main()
    {
        string input = String.Empty;
        try
        {
            int result = Int32.Parse(input);
            Console.WriteLine(result);
        }
        catch (FormatException)
        {
            Console.WriteLine($"Unable to parse '{input}'");
        }
        // Output: Unable to parse ''

        try
        {
            int numVal = Int32.Parse("-105");
            Console.WriteLine(numVal);
        }
        catch (FormatException e)
        {
            Console.WriteLine(e.Message);
        }
        // Output: -105

        if (Int32.TryParse("-105", out int j))
        {
            Console.WriteLine(j);
        }
        else
        {
            Console.WriteLine("String could not be parsed.");
        }
        // Output: -105

        try
        {
            int m = Int32.Parse("abc");
        }
        catch (FormatException e)
        {
            Console.WriteLine(e.Message);
        }
        // Output: Input string was not in a correct format.

        const string inputString = "abc";
        if (Int32.TryParse(inputString, out int numValue))
        {
            Console.WriteLine(numValue);
        }
        else
        {
            Console.WriteLine($"Int32.TryParse could not parse '{inputString}' to an int.");
        }
        // Output: Int32.TryParse could not parse 'abc' to an int.
    }
}

下面的示例演示了一种分析字符串的方法,该字符串应包含前导数字字符(包括十六进制字符)和尾随的非数字字符。 在调用 TryParse 方法之前,它从字符串的开头向新字符串分配有效字符。 因为要分析的字符串包含少量字符,所以本示例调用 String.Concat 方法将有效字符分配给新字符串。 对于较大的字符串,可以改用 StringBuilder 类。

C#

cs 复制代码
using System;

public static class StringConversion
{
    public static void Main()
    {
        var str = "  10FFxxx";
        string numericString = string.Empty;
        foreach (var c in str)
        {
            // Check for numeric characters (hex in this case) or leading or trailing spaces.
            if ((c >= '0' && c <= '9') || (char.ToUpperInvariant(c) >= 'A' && char.ToUpperInvariant(c) <= 'F') || c == ' ')
            {
                numericString = string.Concat(numericString, c.ToString());
            }
            else
            {
                break;
            }
        }

        if (int.TryParse(numericString, System.Globalization.NumberStyles.HexNumber, null, out int i))
        {
            Console.WriteLine($"'{str}' --> '{numericString}' --> {i}");
        }
        // Output: '  10FFxxx' --> '  10FF' --> 4351

        str = "   -10FFXXX";
        numericString = "";
        foreach (char c in str)
        {
            // Check for numeric characters (0-9), a negative sign, or leading or trailing spaces.
            if ((c >= '0' && c <= '9') || c == ' ' || c == '-')
            {
                numericString = string.Concat(numericString, c);
            }
            else
            {
                break;
            }
        }

        if (int.TryParse(numericString, out int j))
        {
            Console.WriteLine($"'{str}' --> '{numericString}' --> {j}");
        }
        // Output: '   -10FFXXX' --> '   -10' --> -10
    }
}

08.03.04 调用 Convert 方法

下表列出了 Convert 类中可用于将字符串转换为数字的一些方法。

数值类型 方法
decimal ToDecimal(String)
float ToSingle(String)
double ToDouble(String)
short ToInt16(String)
int ToInt32(String)
long ToInt64(String)
ushort ToUInt16(String)
uint ToUInt32(String)
ulong ToUInt64(String)

下面的示例调用 Convert.ToInt32(String) 方法将输入字符串转换为 int。该示例将捕获由此方法引发的两个最常见异常:FormatExceptionOverflowException。 如果生成的数字可以在不超过 Int32.MaxValue 的情况下递增,则示例将向结果添加 1 并显示输出。

C#

cs 复制代码
using System;

public class ConvertStringExample1
{
    static void Main(string[] args)
    {
        int numVal = -1;
        bool repeat = true;

        while (repeat)
        {
            Console.Write("Enter a number between −2,147,483,648 and +2,147,483,647 (inclusive): ");

            string? input = Console.ReadLine();

            // ToInt32 can throw FormatException or OverflowException.
            try
            {
                numVal = Convert.ToInt32(input);
                if (numVal < Int32.MaxValue)
                {
                    Console.WriteLine("The new value is {0}", ++numVal);
                }
                else
                {
                    Console.WriteLine("numVal cannot be incremented beyond its current value");
                }
           }
            catch (FormatException)
            {
                Console.WriteLine("Input string is not a sequence of digits.");
            }
            catch (OverflowException)
            {
                Console.WriteLine("The number cannot fit in an Int32.");
            }

            Console.Write("Go again? Y/N: ");
            string? go = Console.ReadLine();
            if (go?.ToUpper() != "Y")
            {
                repeat = false;
            }
        }
    }
}
// Sample Output:
//   Enter a number between -2,147,483,648 and +2,147,483,647 (inclusive): 473
//   The new value is 474
//   Go again? Y/N: y
//   Enter a number between -2,147,483,648 and +2,147,483,647 (inclusive): 2147483647
//   numVal cannot be incremented beyond its current value
//   Go again? Y/N: y
//   Enter a number between -2,147,483,648 and +2,147,483,647 (inclusive): -1000
//   The new value is -999
//   Go again? Y/N: n

08.03.05 十六进制字符串与数值类型之间转换

以下示例演示如何执行下列任务:

  • 获取字符串中每个字符的十六进制值。

  • 获取与十六进制字符串中的每个值对应的 char

  • 将十六进制 string 转换为 int

  • 将十六进制 string 转换为 float

  • 字节数组转换为十六进制 string

此示例输出 string 中每个字符的十六进制值。 首先,将 string 分析为字符数组。 然后,对每个字符调用 ToInt32(Char)获取相应的数值。 最后,在 string 中将数字的格式设置为十六进制表示形式。

C#

string input = "Hello World!";
char[] values = input.ToCharArray();
foreach (char letter in values)
{
    // Get the integral value of the character.
    int value = Convert.ToInt32(letter);
    // Convert the integer value to a hexadecimal value in string form.
    Console.WriteLine($"Hexadecimal value of {letter} is {value:X}");
}
/* Output:
    Hexadecimal value of H is 48
    Hexadecimal value of e is 65
    Hexadecimal value of l is 6C
    Hexadecimal value of l is 6C
    Hexadecimal value of o is 6F
    Hexadecimal value of   is 20
    Hexadecimal value of W is 57
    Hexadecimal value of o is 6F
    Hexadecimal value of r is 72
    Hexadecimal value of l is 6C
    Hexadecimal value of d is 64
    Hexadecimal value of ! is 21
 */

此示例分析十六进制值的 string 并输出对应于每个十六进制值的字符。 首先,调用 Split(Char[]) 方法以获取每个十六进制值作为数组中的单个 string。 然后,调用 ToInt32(String, Int32)将十六进制值转换为表示为 int 的十进制值。示例中演示了 2 种不同方法,用于获取对应于该字符代码的字符。 第 1 种方法是使用 ConvertFromUtf32(Int32),它将对应于整型参数的字符作为 string 返回。 第 2 种方法是将 int 显式转换为 char

C#

string hexValues = "48 65 6C 6C 6F 20 57 6F 72 6C 64 21";
string[] hexValuesSplit = hexValues.Split(' ');
foreach (string hex in hexValuesSplit)
{
    // Convert the number expressed in base-16 to an integer.
    int value = Convert.ToInt32(hex, 16);
    // Get the character corresponding to the integral value.
    string stringValue = Char.ConvertFromUtf32(value);
    char charValue = (char)value;
    Console.WriteLine("hexadecimal value = {0}, int value = {1}, char value = {2} or {3}",
                        hex, value, stringValue, charValue);
}
/* Output:
    hexadecimal value = 48, int value = 72, char value = H or H
    hexadecimal value = 65, int value = 101, char value = e or e
    hexadecimal value = 6C, int value = 108, char value = l or l
    hexadecimal value = 6C, int value = 108, char value = l or l
    hexadecimal value = 6F, int value = 111, char value = o or o
    hexadecimal value = 20, int value = 32, char value =   or
    hexadecimal value = 57, int value = 87, char value = W or W
    hexadecimal value = 6F, int value = 111, char value = o or o
    hexadecimal value = 72, int value = 114, char value = r or r
    hexadecimal value = 6C, int value = 108, char value = l or l
    hexadecimal value = 64, int value = 100, char value = d or d
    hexadecimal value = 21, int value = 33, char value = ! or !
*/

此示例演示了将十六进制 string 转换为整数的另一种方法,即调用 Parse(String, NumberStyles) 方法。

C#

string hexString = "8E2";
int num = Int32.Parse(hexString, System.Globalization.NumberStyles.HexNumber);
Console.WriteLine(num);
//Output: 2274

下面的示例演示了如何使用 System.BitConverter 类和 UInt32.Parse 方法将十六进制 string 转换为 float

C#

string hexString = "43480170";
uint num = uint.Parse(hexString, System.Globalization.NumberStyles.AllowHexSpecifier);

byte[] floatVals = BitConverter.GetBytes(num);
float f = BitConverter.ToSingle(floatVals, 0);
Console.WriteLine("float convert = {0}", f);

// Output: 200.0056

下面的示例演示了如何使用 System.BitConverter 类将字节数组转换为十六进制字符串。

C#

byte[] vals = [0x01, 0xAA, 0xB1, 0xDC, 0x10, 0xDD];

string str = BitConverter.ToString(vals);
Console.WriteLine(str);

str = BitConverter.ToString(vals).Replace("-", "");
Console.WriteLine(str);

/*Output:
  01-AA-B1-DC-10-DD
  01AAB1DC10DD
 */

下面的示例演示如何通过调用 .NET 5.0 中引入的 Convert.ToHexString 方法,将字节数组转换为十六进制字符串。

C#

byte[] array = [0x64, 0x6f, 0x74, 0x63, 0x65, 0x74];

string hexValue = Convert.ToHexString(array);
Console.WriteLine(hexValue);

/*Output:
  646F74636574
 */

09 实用技术

09.01 数据初始化,对象和集合初始值设定项

使用 C# 可以在单条语句中实例化对象或集合并执行成员分配。

09.01.01 对象初始值设定项

使用对象初始值设定项,你可以在创建对象时向对象的任何可访问字段或属性分配值,而无需调用后跟赋值语句行的构造函数。 利用对象初始值设定项语法,你可为构造函数指定参数或忽略参数(以及括号语法)。 以下示例演示如何使用具有命名类型 Cat 的对象初始值设定项以及如何调用无参数构造函数。 注意自动实现的属性在 Cat 类中的用法。 有关详细信息,请参阅 自动实现的属性

C#复制

public class Cat
{
    // Automatically implemented properties.
    public int Age { get; set; }
    public string? Name { get; set; }

    public Cat()
    {
    }

    public Cat(string name)
    {
        this.Name = name;
    }
}

C#复制

Cat cat = new Cat { Age = 10, Name = "Fluffy" };
Cat sameCat = new Cat("Fluffy"){ Age = 10 };

对象初始值设定项语法允许你创建一个实例,然后将具有其分配属性的新建对象指定给赋值中的变量。

除了分配字段和属性外,对象初始值设定项还可以设置索引器。 请思考这个基本的 Matrix 类:

C#复制

public class Matrix
{
    private double[,] storage = new double[3, 3];

    public double this[int row, int column]
    {
        // The embedded array will throw out of range exceptions as appropriate.
        get { return storage[row, column]; }
        set { storage[row, column] = value; }
    }
}

可以使用以下代码初始化标识矩阵:

C#复制

var identity = new Matrix
{
    [0, 0] = 1.0,
    [0, 1] = 0.0,
    [0, 2] = 0.0,

    [1, 0] = 0.0,
    [1, 1] = 1.0,
    [1, 2] = 0.0,

    [2, 0] = 0.0,
    [2, 1] = 0.0,
    [2, 2] = 1.0,
};

包含可访问资源库的任何可访问索引器都可以用作对象初始值设定项中的表达式之一,这与参数的数量或类型无关。 索引参数构成左侧赋值,而表达式右侧是值。 例如,如果 IndexersExample 具有适当的索引器,则以下初始值设定项都是有效的:

C#复制

var thing = new IndexersExample
{
    name = "object one",
    [1] = '1',
    [2] = '4',
    [3] = '9',
    Size = Math.PI,
    ['C',4] = "Middle C"
}

对于要进行编译的前面的代码,IndexersExample 类型必须具有以下成员:

C#复制

public string name;
public double Size { set { ... }; }
public char this[int i] { set { ... }; }
public string this[char c, int i] { set { ... }; }

09.01.02 具有匿名类型的对象初始值设定项

尽管对象初始值设定项可用于任何上下文中,但它们在 LINQ 查询表达式中特别有用。 查询表达式常使用只能通过使用对象初始值设定项进行初始化的匿名类型,如下面的声明所示。

C#复制

var pet = new { Age = 10, Name = "Fluffy" };

利用匿名类型,LINQ 查询表达式中的 select 子句可以将原始序列的对象转换为其值和形状可能不同于原始序列的对象。 建议只存储某个序列中每个对象的部分信息。 在下面的示例中,假定产品对象 (p) 包含很多字段和方法,而你只想创建包含产品名和单价的对象序列。

C#复制

var productInfos =
    from p in products
    select new { p.ProductName, p.UnitPrice };

执行此查询时,productInfos 变量包含一系列对象,这些对象可以在 foreach 语句中进行访问,如下面的示例所示:

C#复制

foreach(var p in productInfos){...}

新的匿名类型中的每个对象都具有两个公共属性,这两个属性接收与原始对象中的属性或字段相同的名称。 你还可在创建匿名类型时重命名字段;下面的示例将 UnitPrice 字段重命名为 Price

C#复制

select new {p.ProductName, Price = p.UnitPrice};

09.01.03 带 required 修饰符的对象初始值设定项

可以使用 required 关键字强制调用方使用对象初始值设定项设置属性或字段的值。 不需要将所需属性设置为构造函数参数。 编译器可确保所有调用方初始化这些值。

C#复制

public class Pet
{
    public required int Age;
    public string Name;
}

// `Age` field is necessary to be initialized.
// You don't need to initialize `Name` property
var pet = new Pet() { Age = 10};

// Compiler error:
// Error CS9035 Required member 'Pet.Age' must be set in the object initializer or attribute constructor.
// var pet = new Pet();

通常的做法是保证对象正确初始化,尤其是在要管理多个字段或属性,并且不希望将它们全部包含在构造函数中时。

09.01.04 带 init 访问器的对象初始值设定项

确保无人更改设计,并且可以使用访问器来限制 init 对象。 它有助于限制属性值的设置。

C#复制

public class Person
{
    public string FirstName { get; set; }
    public string LastName { get; init; }
}

// The `LastName` property can be set only during initialization. It CAN'T be modified afterwards.
// The `FirstName` property can be modified after initialization.
var pet = new Person() { FirstName = "Joe", LastName = "Doe"};

// You can assign the FirstName property to a different value.
pet.FirstName = "Jane";

// Compiler error:
// Error CS8852  Init - only property or indexer 'Person.LastName' can only be assigned in an object initializer,
//               or on 'this' or 'base' in an instance constructor or an 'init' accessor.
// pet.LastName = "Kowalski";

必需的仅限 init 的属性支持不可变结构,同时允许该类型用户使用自然语法。

09.01.05 具有类类型属性的对象初始值设定项

初始化对象时,考虑类类型属性的含义至关重要:

C#复制

public class HowToClassTypedInitializer
{
    public class EmbeddedClassTypeA
    {
        public int I { get; set; }
        public bool B { get; set; }
        public string S { get; set; }
        public EmbeddedClassTypeB ClassB { get; set; }

        public override string ToString() => $"{I}|{B}|{S}|||{ClassB}";

        public EmbeddedClassTypeA()
        {
            Console.WriteLine($"Entering EmbeddedClassTypeA constructor. Values are: {this}");
            I = 3;
            B = true;
            S = "abc";
            ClassB = new() { BB = true, BI = 43 };
            Console.WriteLine($"Exiting EmbeddedClassTypeA constructor. Values are: {this})");
        }
    }

    public class EmbeddedClassTypeB
    {
        public int BI { get; set; }
        public bool BB { get; set; }
        public string BS { get; set; }

        public override string ToString() => $"{BI}|{BB}|{BS}";

        public EmbeddedClassTypeB()
        {
            Console.WriteLine($"Entering EmbeddedClassTypeB constructor. Values are: {this}");
            BI = 23;
            BB = false;
            BS = "BBBabc";
            Console.WriteLine($"Exiting EmbeddedClassTypeB constructor. Values are: {this})");
        }
    }

    public static void Main()
    {
        var a = new EmbeddedClassTypeA
        {
            I = 103,
            B = false,
            ClassB = { BI = 100003 }
        };
        Console.WriteLine($"After initializing EmbeddedClassTypeA: {a}");

        var a2 = new EmbeddedClassTypeA
        {
            I = 103,
            B = false,
            ClassB = new() { BI = 100003 } //New instance
        };
        Console.WriteLine($"After initializing EmbeddedClassTypeA a2: {a2}");
    }

    // Output:
    //Entering EmbeddedClassTypeA constructor Values are: 0|False||||
    //Entering EmbeddedClassTypeB constructor Values are: 0|False|
    //Exiting EmbeddedClassTypeB constructor Values are: 23|False|BBBabc)
    //Exiting EmbeddedClassTypeA constructor Values are: 3|True|abc|||43|True|BBBabc)
    //After initializing EmbeddedClassTypeA: 103|False|abc|||100003|True|BBBabc
    //Entering EmbeddedClassTypeA constructor Values are: 0|False||||
    //Entering EmbeddedClassTypeB constructor Values are: 0|False|
    //Exiting EmbeddedClassTypeB constructor Values are: 23|False|BBBabc)
    //Exiting EmbeddedClassTypeA constructor Values are: 3|True|abc|||43|True|BBBabc)
    //Entering EmbeddedClassTypeB constructor Values are: 0|False|
    //Exiting EmbeddedClassTypeB constructor Values are: 23|False|BBBabc)
    //After initializing EmbeddedClassTypeA a2: 103|False|abc|||100003|False|BBBabc
}

以下示例演示了对于 ClassB,初始化过程如何涉及到更新特定的值,同时保留原始实例中的其他值。 初始值设定项重用当前实例:ClassB 的值为:100003(此处分配的新值)、true(在 EmbeddedClassTypeA 的初始化中保留的值)、BBBabc(EmbeddedClassTypeB 中未更改的默认值)。

09.01.06 集合初始值设定项

在初始化实现 IEnumerable 的集合类型和初始化使用适当的签名作为实例方法或扩展方法的 Add 时,集合初始值设定项允许指定一个或多个元素初始值设定项。 元素初始值设定项可以是值、表达式或对象初始值设定项。 通过使用集合初始值设定项,无需指定多个调用;编译器将自动添加这些调用。

下面的示例演示了两个简单的集合初始值设定项:

C#复制

List<int> digits = new List<int> { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 };
List<int> digits2 = new List<int> { 0 + 1, 12 % 3, MakeInt() };

下面的集合初始值设定项使用对象初始值设定项来初始化上一个示例中定义的 Cat 类的对象。 各个对象初始值设定项括在大括号中且用逗号隔开。

C#复制

List<Cat> cats = new List<Cat>
{
    new Cat{ Name = "Sylvester", Age=8 },
    new Cat{ Name = "Whiskers", Age=2 },
    new Cat{ Name = "Sasha", Age=14 }
};

如果集合的 Add 方法允许,则可以将 null 指定为集合初始值设定项中的一个元素。

C#复制

List<Cat?> moreCats = new List<Cat?>
{
    new Cat{ Name = "Furrytail", Age=5 },
    new Cat{ Name = "Peaches", Age=4 },
    null
};

如果集合支持读取/写入索引,可以指定索引元素。

C#复制

var numbers = new Dictionary<int, string>
{
    [7] = "seven",
    [9] = "nine",
    [13] = "thirteen"
};

前面的示例生成调用 Item[TKey] 以设置值的代码。 还可使用以下语法初始化字典和其他关联容器。 请注意,它使用具有多个值的对象,而不是带括号和赋值的索引器语法:

C#复制

var moreNumbers = new Dictionary<int, string>
{
    {19, "nineteen" },
    {23, "twenty-three" },
    {42, "forty-two" }
};

此初始值设定项示例调用 Add(TKey, TValue),将这三个项添加到字典中。 由于编译器生成的方法调用不同,这两种初始化关联集合的不同方法的行为略有不同。 这两种变量都适用于 Dictionary 类。 其他类型根据它们的公共 API 可能只支持两者中的一种。

09.01.07 具有集合只读属性初始化的对象初始值设定项

某些类可能具有属性为只读的集合属性,如以下示例中 CatOwnerCats 属性:

C#复制

public class CatOwner
{
    public IList<Cat> Cats { get; } = new List<Cat>();
}

由于无法为属性分配新列表,因此你无法使用迄今为止讨论的集合初始值设定项语法:

C#复制

CatOwner owner = new CatOwner
{
    Cats = new List<Cat>
    {
        new Cat{ Name = "Sylvester", Age=8 },
        new Cat{ Name = "Whiskers", Age=2 },
        new Cat{ Name = "Sasha", Age=14 }
    }
};

但是,可以通过省略列表创建 (new List<Cat>),使用初始化语法将新条目添加到 Cats,如下所示:

C#复制

CatOwner owner = new CatOwner
{
    Cats =
    {
        new Cat{ Name = "Sylvester", Age=8 },
        new Cat{ Name = "Whiskers", Age=2 },
        new Cat{ Name = "Sasha", Age=14 }
    }
};

要添加的条目集显示在大括号中。 上述代码与编写代码相同:

C#复制

CatOwner owner = new ();
owner.Cats.Add(new Cat{ Name = "Sylvester", Age=8 });
owner.Cats.Add(new Cat{ Name = "Whiskers", Age=2 });
owner.Cats.Add(new Cat{ Name = "Sasha", Age=14 });

09.01.08 数据初始化示例

下例结合了对象和集合初始值设定项的概念。

C#复制

public class InitializationSample
{
    public class Cat
    {
        // Automatically implemented properties.
        public int Age { get; set; }
        public string? Name { get; set; }

        public Cat() { }

        public Cat(string name)
        {
            Name = name;
        }
    }

    public static void Main()
    {
        Cat cat = new Cat { Age = 10, Name = "Fluffy" };
        Cat sameCat = new Cat("Fluffy"){ Age = 10 };

        List<Cat> cats = new List<Cat>
        {
            new Cat { Name = "Sylvester", Age = 8 },
            new Cat { Name = "Whiskers", Age = 2 },
            new Cat { Name = "Sasha", Age = 14 }
        };

        List<Cat?> moreCats = new List<Cat?>
        {
            new Cat { Name = "Furrytail", Age = 5 },
            new Cat { Name = "Peaches", Age = 4 },
            null
        };

        // Display results.
        System.Console.WriteLine(cat.Name);

        foreach (Cat c in cats)
        {
            System.Console.WriteLine(c.Name);
        }

        foreach (Cat? c in moreCats)
        {
            if (c != null)
            {
                System.Console.WriteLine(c.Name);
            }
            else
            {
                System.Console.WriteLine("List element has null value.");
            }
        }
    }
    // Output:
    //Fluffy
    //Sylvester
    //Whiskers
    //Sasha
    //Furrytail
    //Peaches
    //List element has null value.
}

以下示例演示了一个对象,该对象实现 IEnumerable 并包含具有多个参数的 Add 方法。 它使用一个集合初始值设定项,其中列表中的每个项都有多个元素,这些元素对应于 Add 方法的签名。

C#复制

public class FullExample
{
    class FormattedAddresses : IEnumerable<string>
    {
        private List<string> internalList = new List<string>();
        public IEnumerator<string> GetEnumerator() => internalList.GetEnumerator();

        System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => internalList.GetEnumerator();

        public void Add(string firstname, string lastname,
            string street, string city,
            string state, string zipcode) => internalList.Add($"""
            {firstname} {lastname}
            {street}
            {city}, {state} {zipcode}
            """
            );
    }

    public static void Main()
    {
        FormattedAddresses addresses = new FormattedAddresses()
        {
            {"John", "Doe", "123 Street", "Topeka", "KS", "00000" },
            {"Jane", "Smith", "456 Street", "Topeka", "KS", "00000" }
        };

        Console.WriteLine("Address Entries:");

        foreach (string addressEntry in addresses)
        {
            Console.WriteLine("\r\n" + addressEntry);
        }
    }

    /*
        * Prints:

        Address Entries:

        John Doe
        123 Street
        Topeka, KS 00000

        Jane Smith
        456 Street
        Topeka, KS 00000
        */
}

Add 方法可使用 params 关键字来获取可变数量的自变量,如下例中所示。 此示例还演示了索引器的自定义实现,以使用索引初始化集合。 从 C# 13 开始,params 参数不再局限于数组。 它可以是集合类型或接口。

C#复制

public class DictionaryExample
{
    class RudimentaryMultiValuedDictionary<TKey, TValue> : IEnumerable<KeyValuePair<TKey, List<TValue>>> where TKey : notnull
    {
        private Dictionary<TKey, List<TValue>> internalDictionary = new Dictionary<TKey, List<TValue>>();

        public IEnumerator<KeyValuePair<TKey, List<TValue>>> GetEnumerator() => internalDictionary.GetEnumerator();

        System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => internalDictionary.GetEnumerator();

        public List<TValue> this[TKey key]
        {
            get => internalDictionary[key];
            set => Add(key, value);
        }

        public void Add(TKey key, params TValue[] values) => Add(key, (IEnumerable<TValue>)values);

        public void Add(TKey key, IEnumerable<TValue> values)
        {
            if (!internalDictionary.TryGetValue(key, out List<TValue>? storedValues))
            {
                internalDictionary.Add(key, storedValues = new List<TValue>());
            }
            storedValues.AddRange(values);
        }
    }

    public static void Main()
    {
        RudimentaryMultiValuedDictionary<string, string> rudimentaryMultiValuedDictionary1
            = new RudimentaryMultiValuedDictionary<string, string>()
            {
                {"Group1", "Bob", "John", "Mary" },
                {"Group2", "Eric", "Emily", "Debbie", "Jesse" }
            };
        RudimentaryMultiValuedDictionary<string, string> rudimentaryMultiValuedDictionary2
            = new RudimentaryMultiValuedDictionary<string, string>()
            {
                ["Group1"] = new List<string>() { "Bob", "John", "Mary" },
                ["Group2"] = new List<string>() { "Eric", "Emily", "Debbie", "Jesse" }
            };
        RudimentaryMultiValuedDictionary<string, string> rudimentaryMultiValuedDictionary3
            = new RudimentaryMultiValuedDictionary<string, string>()
            {
                {"Group1", new string []{ "Bob", "John", "Mary" } },
                { "Group2", new string[]{ "Eric", "Emily", "Debbie", "Jesse" } }
            };

        Console.WriteLine("Using first multi-valued dictionary created with a collection initializer:");

        foreach (KeyValuePair<string, List<string>> group in rudimentaryMultiValuedDictionary1)
        {
            Console.WriteLine($"\r\nMembers of group {group.Key}: ");

            foreach (string member in group.Value)
            {
                Console.WriteLine(member);
            }
        }

        Console.WriteLine("\r\nUsing second multi-valued dictionary created with a collection initializer using indexing:");

        foreach (KeyValuePair<string, List<string>> group in rudimentaryMultiValuedDictionary2)
        {
            Console.WriteLine($"\r\nMembers of group {group.Key}: ");

            foreach (string member in group.Value)
            {
                Console.WriteLine(member);
            }
        }
        Console.WriteLine("\r\nUsing third multi-valued dictionary created with a collection initializer using indexing:");

        foreach (KeyValuePair<string, List<string>> group in rudimentaryMultiValuedDictionary3)
        {
            Console.WriteLine($"\r\nMembers of group {group.Key}: ");

            foreach (string member in group.Value)
            {
                Console.WriteLine(member);
            }
        }
    }

    /*
        * Prints:

        Using first multi-valued dictionary created with a collection initializer:

        Members of group Group1:
        Bob
        John
        Mary

        Members of group Group2:
        Eric
        Emily
        Debbie
        Jesse

        Using second multi-valued dictionary created with a collection initializer using indexing:

        Members of group Group1:
        Bob
        John
        Mary

        Members of group Group2:
        Eric
        Emily
        Debbie
        Jesse

        Using third multi-valued dictionary created with a collection initializer using indexing:

        Members of group Group1:
        Bob
        John
        Mary

        Members of group Group2:
        Eric
        Emily
        Debbie
        Jesse
        */
}

09.01.09 如何使用对象初始值设定项初始化对象

可以使用对象初始值设定项以声明方式初始化类型对象,而无需显式调用类型的构造函数。

以下示例演示如何将对象初始值设定项用于命名对象。 编译器通过首先访问无参数实例构造函数,然后处理成员初始化来处理对象初始值设定项。 因此,如果无参数构造函数在类中声明为 private,则需要公共访问的对象初始值设定项将失败。

如果要定义匿名类型,则必须使用对象初始值设定项。 有关详细信息,请参阅如何在查询中返回元素属性的子集

下面的示例演示如何使用对象初始值设定项初始化新的 StudentName 类型。 此示例在 StudentName 类型中设置属性:

C#复制

public class HowToObjectInitializers
{
    public static void Main()
    {
        // Declare a StudentName by using the constructor that has two parameters.
        StudentName student1 = new StudentName("Craig", "Playstead");

        // Make the same declaration by using an object initializer and sending
        // arguments for the first and last names. The parameterless constructor is
        // invoked in processing this declaration, not the constructor that has
        // two parameters.
        StudentName student2 = new StudentName
        {
            FirstName = "Craig",
            LastName = "Playstead"
        };

        // Declare a StudentName by using an object initializer and sending
        // an argument for only the ID property. No corresponding constructor is
        // necessary. Only the parameterless constructor is used to process object
        // initializers.
        StudentName student3 = new StudentName
        {
            ID = 183
        };

        // Declare a StudentName by using an object initializer and sending
        // arguments for all three properties. No corresponding constructor is
        // defined in the class.
        StudentName student4 = new StudentName
        {
            FirstName = "Craig",
            LastName = "Playstead",
            ID = 116
        };

        Console.WriteLine(student1.ToString());
        Console.WriteLine(student2.ToString());
        Console.WriteLine(student3.ToString());
        Console.WriteLine(student4.ToString());
    }
    // Output:
    // Craig  0
    // Craig  0
    //   183
    // Craig  116

    public class StudentName
    {
        // This constructor has no parameters. The parameterless constructor
        // is invoked in the processing of object initializers.
        // You can test this by changing the access modifier from public to
        // private. The declarations in Main that use object initializers will
        // fail.
        public StudentName() { }

        // The following constructor has parameters for two of the three
        // properties.
        public StudentName(string first, string last)
        {
            FirstName = first;
            LastName = last;
        }

        // Properties.
        public string? FirstName { get; set; }
        public string? LastName { get; set; }
        public int ID { get; set; }

        public override string ToString() => FirstName + "  " + ID;
    }
}

对象初始值设定项可用于在对象中设置索引器。 下面的示例定义了一个 BaseballTeam 类,该类使用索引器获取和设置不同位置的球员。 初始值设定项可以根据位置的缩写或每个位置的棒球记分卡的编号来分配球员:

C#复制

public class HowToIndexInitializer
{
    public class BaseballTeam
    {
        private string[] players = new string[9];
        private readonly List<string> positionAbbreviations = new List<string>
        {
            "P", "C", "1B", "2B", "3B", "SS", "LF", "CF", "RF"
        };

        public string this[int position]
        {
            // Baseball positions are 1 - 9.
            get { return players[position-1]; }
            set { players[position-1] = value; }
        }
        public string this[string position]
        {
            get { return players[positionAbbreviations.IndexOf(position)]; }
            set { players[positionAbbreviations.IndexOf(position)] = value; }
        }
    }

    public static void Main()
    {
        var team = new BaseballTeam
        {
            ["RF"] = "Mookie Betts",
            [4] = "Jose Altuve",
            ["CF"] = "Mike Trout"
        };

        Console.WriteLine(team["2B"]);
    }
}

下一个示例演示使用带参数和不带参数的构造函数执行构造函数和成员初始化的顺序:

C#复制

public class ObjectInitializersExecutionOrder
{
    public static void Main()
    {
        new Person { FirstName = "Paisley", LastName = "Smith", City = "Dallas" };
        new Dog(2) { Name = "Mike" };
    }

    public class Dog
    {
        private int age;
        private string name;

        public Dog(int age)
        {
            Console.WriteLine("Hello from Dog's non-parameterless constructor");
            this.age = age;
        }

        public required string Name
        {
            get { return name; }

            set
            {
                Console.WriteLine("Hello from setter of Dog's required property 'Name'");
                name = value;
            }
        }
    }

    public class Person
    {
        private string firstName;
        private string lastName;
        private string city;

        public Person()
        {
            Console.WriteLine("Hello from Person's parameterless constructor");
        }

        public required string FirstName
        {
            get { return firstName; }

            set
            {
                Console.WriteLine("Hello from setter of Person's required property 'FirstName'");
                firstName = value;
            }
        }

        public string LastName
        {
            get { return lastName; }

            init
            {
                Console.WriteLine("Hello from setter of Person's init property 'LastName'");
                lastName = value;
            }
        }

        public string City
        {
            get { return city; }

            set
            {
                Console.WriteLine("Hello from setter of Person's property 'City'");
                city = value;
            }
        }
    }

    // Output:
    // Hello from Person's parameterless constructor
    // Hello from setter of Person's required property 'FirstName'
    // Hello from setter of Person's init property 'LastName'
    // Hello from setter of Person's property 'City'
    // Hello from Dog's non-parameterless constructor
    // Hello from setter of Dog's required property 'Name'
}

09.01.10 如何使用集合初始值设定项初始化字典

Dictionary<TKey,TValue> 包含键/值对集合。 其 Add 方法采用两个参数,一个用于键,一个用于值。 若要初始化 Dictionary<TKey,TValue> 或其 Add 方法采用多个参数的任何集合,一种方法是将每组参数括在大括号中,如下面的示例中所示。 另一种方法是使用索引初始值设定项,如下面的示例所示。

备注

我们以具有重复键的情况来举例说明初始化集合的这两种方法之间的主要区别:

C#复制

{ 111, new StudentName { FirstName="Sachin", LastName="Karnik", ID=211 } },
{ 111, new StudentName { FirstName="Dina", LastName="Salimzianova", ID=317 } }, 

Add 方法将引发 ArgumentException'An item with the same key has already been added. Key: 111',而示例的第二部分(公共读/写索引器方法)将使用相同的键静默覆盖已存在的条目。

在下面的代码示例中,使用类型 StudentName 的实例初始化 Dictionary<TKey,TValue>。 第一个初始化使用具有两个参数的 Add 方法。 编译器为每对 int 键和 StudentName 值生成对 Add 的调用。 第二个初始化使用 Dictionary 类的公共读取/写入索引器方法:

C#复制

public class HowToDictionaryInitializer
{
    class StudentName
    {
        public string? FirstName { get; set; }
        public string? LastName { get; set; }
        public int ID { get; set; }
    }

    public static void Main()
    {
        var students = new Dictionary<int, StudentName>()
        {
            { 111, new StudentName { FirstName="Sachin", LastName="Karnik", ID=211 } },
            { 112, new StudentName { FirstName="Dina", LastName="Salimzianova", ID=317 } },
            { 113, new StudentName { FirstName="Andy", LastName="Ruth", ID=198 } }
        };

        foreach(var index in Enumerable.Range(111, 3))
        {
            Console.WriteLine($"Student {index} is {students[index].FirstName} {students[index].LastName}");
        }
        Console.WriteLine();		

        var students2 = new Dictionary<int, StudentName>()
        {
            [111] = new StudentName { FirstName="Sachin", LastName="Karnik", ID=211 },
            [112] = new StudentName { FirstName="Dina", LastName="Salimzianova", ID=317 } ,
            [113] = new StudentName { FirstName="Andy", LastName="Ruth", ID=198 }
        };

        foreach (var index in Enumerable.Range(111, 3))
        {
            Console.WriteLine($"Student {index} is {students2[index].FirstName} {students2[index].LastName}");
        }
    }
}

请注意,在第一个声明中,集合中的每个元素有两对大括号。 最内层的大括号中括住了 StudentName 的对象初始值设定项,最外层的大括号则括住了要添加到 studentsDictionary<TKey,TValue> 的键/值对的初始值设定项。 最后,字典的整个集合初始值设定项被括在大括号中。 在第二个初始化中,左侧赋值是键,右侧是将对象初始值设定项用于 StudentName 的值。

09.02 嵌套类型

构造接口中定义的类型称为嵌套类型。 例如

C#复制

public class Container
{
    class Nested
    {
        Nested() { }
    }
}

不论外部类型是类、接口还是构造,嵌套类型均默认为 private;仅可从其包含类型中进行访问。 在上一个示例中,Nested 类无法访问外部类型。

还可指定访问修饰符来定义嵌套类型的可访问性,如下所示:

以下示例使 Nested 类为 public:

C#复制

public class Container
{
    public class Nested
    {
        Nested() { }
    }
}

嵌套类型(或内部类型)可访问包含类型(或外部类型)。 若要访问包含类型,请将其作为参数传递给嵌套类型的构造函数。 例如:

C#复制

public class Container
{
    public class Nested
    {
        private Container? parent;

        public Nested()
        {
        }
        public Nested(Container parent)
        {
            this.parent = parent;
        }
    }
}

嵌套类型可以访问其包含类型可以访问的所有成员。 它可以访问包含类型的私有成员和受保护成员(包括所有继承的受保护成员)。

在前面的声明中,类 Nested 的完整名称为 Container.Nested。 这是用来创建嵌套类新实例的名称,如下所示:

C#复制

Container.Nested nest = new Container.Nested();

09.03 分部类和方法

拆分一个、一个结构、一个接口或一个方法的定义到两个或更多的文件中是可能的。 每个源文件包含类型或方法定义的一部分,编译应用程序时将把所有部分组合起来。

09.03.01 分部类 partial class

在以下几种情况下需要拆分类定义:

  • 通过单独的文件声明某个类可以让多位程序员同时对该类进行处理。
  • 你可以向该类中添加代码,而不必重新创建包括自动生成的源代码的源文件。 Visual Studio 在创建Windows 窗体、Web 服务包装器代码等时会使用这种方法。 你可以创建使用这些类的代码,这样就不需要修改由Visual Studio生成的文件。
  • 源代码生成器可以在类中生成额外的功能。

若要拆分类定义,请使用 partial 关键字修饰符,如下所示:

C#复制

public partial class Employee
{
    public void DoWork()
    {
    }
}

public partial class Employee
{
    public void GoToLunch()
    {
    }
}

partial 关键字指示可在命名空间中定义该类、结构或接口的其他部分。 所有部分都必须使用 partial 关键字。 在编译时,各个部分都必须可用来形成最终的类型。 各个部分必须具有相同的可访问性,如 publicprivate 等。

如果将任意部分声明为抽象的,则整个类型都被视为抽象的。 如果将任意部分声明为密封的,则整个类型都被视为密封的。 如果任意部分声明基类型,则整个类型都将继承该类。

指定基类的所有部分必须一致,但忽略基类的部分仍继承该基类型。 各个部分可以指定不同的基接口,最终类型将实现所有分部声明所列出的全部接口。 在某一分部定义中声明的任何类、结构或接口成员可供所有其他部分使用。 最终类型是所有部分在编译时的组合。

备注

partial 修饰符不可用于委托或枚举声明中。

下面的示例演示嵌套类型可以是分部的,即使它们所嵌套于的类型本身并不是分部的也如此。

C#复制

class Container
{
    partial class Nested
    {
        void Test() { }
    }

    partial class Nested
    {
        void Test2() { }
    }
}

编译时会对分部类型定义的属性进行合并。 以下面的声明为例:

C#复制

[SerializableAttribute]
partial class Moon { }

[ObsoleteAttribute]
partial class Moon { }

它们等效于以下声明:

C#复制

[SerializableAttribute]
[ObsoleteAttribute]
class Moon { }

将从所有分部类型定义中对以下内容进行合并:

  • XML 注释。 但是,如果分部成员的两个声明都包含注释,则仅包括实现成员的注释。
  • interfaces
  • 泛型类型参数属性
  • class 特性
  • 成员

以下面的声明为例:

C#复制

partial class Earth : Planet, IRotate { }
partial class Earth : IRevolve { }

它们等效于以下声明:

C#复制

class Earth : Planet, IRotate, IRevolve { }

09.03.02 限制

处理分部类定义时需遵循下面的几个规则:

  • 要作为同一类型的各个部分的所有分部类型定义都必须使用 partial 进行修饰。 例如,下面的类声明会生成错误:

    C#复制

    public partial class A { }
    //public class A { }  // Error, must also be marked partial
    
  • partial 修饰符只能出现在紧靠关键字 classstructinterface 前面的位置。

  • 分部类型定义中允许使用嵌套的分部类型,如下面的示例中所示: C#复制

    partial class ClassWithNestedClass
    {
        partial class NestedClass { }
    }
    
    partial class ClassWithNestedClass
    {
        partial class NestedClass { }
    }
    
  • 要成为同一类型的各个部分的所有分部类型定义都必须在同一程序集和同一模块(.exe 或 .dll 文件)中进行定义。 分部定义不能跨越多个模块。

  • 类名和泛型类型参数在所有的分部类型定义中都必须匹配。 泛型类型可以是分部的。 每个分部声明都必须以相同的顺序使用相同的参数名。

  • 下面用于分部类型定义中的关键字是可选的,但是如果某关键字出现在一个分部类型定义中,则必须在相同类型的其他分部定义中指定相同的关键字:

有关详细信息,请参阅类型参数的约束

下面的示例在一个分部类定义中声明 Coords 类的字段和构造函数,在另一个分部类定义中声明成员 PrintCoords

C#复制

public partial class Coords
{
    private int x;
    private int y;

    public Coords(int x, int y)
    {
        this.x = x;
        this.y = y;
    }
}

public partial class Coords
{
    public void PrintCoords()
    {
        Console.WriteLine("Coords: {0},{1}", x, y);
    }
}

class TestCoords
{
    static void Main()
    {
        Coords myCoords = new Coords(10, 15);
        myCoords.PrintCoords();

        // Keep the console window open in debug mode.
        Console.WriteLine("Press any key to exit.");
        Console.ReadKey();
    }
}
// Output: Coords: 10,15

从下面的示例可以看出,你也可以开发分部结构和接口。

C#复制

partial interface ITest
{
    void Interface_Test();
}

partial interface ITest
{
    void Interface_Test2();
}

partial struct S1
{
    void Struct_Test() { }
}

partial struct S1
{
    void Struct_Test2() { }
}

09.03.03 分部成员

分部类或结构可以包含分部成员。 类的一个部分包含成员的签名。 可以在同一部分或另一部分中定义实现。

当签名遵循以下规则时,分部方法不需要实现:

  • 声明未包含任何访问修饰符。 默认情况下,该方法具有 private 访问权限。
  • 返回类型为 void
  • 没有任何参数具有 out 修饰符。
  • 方法声明不能包括以下任何修饰符:

当未提供实现时,在编译时会移除该方法以及对该方法的所有调用。

任何不符合所有这些限制的方法(包括属性和索引器)都必须提供实现。 此实现可以由源生成器提供。 不能使用自动实现的属性实现部分属性 。 编译器无法区分自动实现的属性和分部属性的声明声明。

分部方法允许类的某个部分的实现者声明成员。 类的另一部分的实现者可以定义该成员。 在以下两个情形中,此分离很有用:生成样板代码的模板和源生成器。

  • 模板代码:模板保留方法名称和签名,以使生成的代码可以调用方法。 这些方法遵循允许开发人员决定是否实现方法的限制。 如果未实现该方法,编译器会移除方法签名以及对该方法的所有调用。 调用该方法(包括调用中的任何参数计算结果)在运行时没有任何影响。 因此,分部类中的任何代码都可以随意地使用分部方法,即使未提供实现也是如此。 未实现该方法时调用该方法不会导致编译时错误或运行时错误。
  • 源生成器:源生成器提供成员的实现。 开发人员可以添加成员声明(通常由源生成器读取属性)。 开发人员可以编写调用这些成员的代码。 源生成器在编译过程中运行并提供实现。 在这种情况下,不会遵循不经常实现的分部成员的限制。

C#复制

// Definition in file1.cs
partial void OnNameChanged();

// Implementation in file2.cs
partial void OnNameChanged()
{
  // method body
}
  • 分部成员声明必须以上下文关键字 partial 开头。
  • 分部类型的两个部分中的分部成员签名必须匹配。
  • 分部成员可以有 staticunsafe 修饰符。
  • 分部成员可能是泛型成员。 约束在定义和实现方法声明时必须相同。 参数和类型参数名称在定义和实现方法声明时不必相同。
  • 你可以为已定义并实现的分部方法生成委托,但不能为没有实现的分部方法生成委托。

09.04 如何声明、实例化和使用委托

09.04.01 声明委托

可以使用以下任一方法声明委托:

  • 使用匹配签名声明委托类型并声明方法:

C#复制

// Declare a delegate.
delegate void NotifyCallback(string str);

// Declare a method with the same signature as the delegate.
static void Notify(string name)
{
    Console.WriteLine($"Notification received for: {name}");
}

C#复制

// Create an instance of the delegate.
NotifyCallback del1 = new NotifyCallback(Notify);
  • 将方法组分配给委托类型:

C#复制

// C# 2.0 provides a simpler way to declare an instance of NotifyCallback.
NotifyCallback del2 = Notify;
  • 声明匿名方法:

C#复制

// Instantiate NotifyCallback by using an anonymous method.
NotifyCallback del3 = delegate(string name)
    { Console.WriteLine($"Notification received for: {name}"); };
  • 使用 lambda 表达式:

C#复制

// Instantiate NotifyCallback by using a lambda expression.
NotifyCallback del4 = name =>  { Console.WriteLine($"Notification received for: {name}"); };

有关详细信息,请参阅 Lambda 表达式

下面的示例演示如何声明、实例化和使用委托。 BookDB 类封装用来维护书籍数据库的书店数据库。 它公开一个方法 ProcessPaperbackBooks,用于在数据库中查找所有平装书并为每本书调用委托。 使用的 delegate 类型名为 ProcessBookCallbackTest 类使用此类打印平装书的书名和平均价格。

使用委托提升书店数据库和客户端代码之间的良好分隔功能。 客户端代码程序不知道如何存储书籍或书店代码如何查找平装书。 书店代码不知道它在找到平装书之后对其执行什么处理。

C#复制

// A set of classes for handling a bookstore:
namespace Bookstore
{
    using System.Collections;

    // Describes a book in the book list:
    public struct Book
    {
        public string Title;        // Title of the book.
        public string Author;       // Author of the book.
        public decimal Price;       // Price of the book.
        public bool Paperback;      // Is it paperback?

        public Book(string title, string author, decimal price, bool paperBack)
        {
            Title = title;
            Author = author;
            Price = price;
            Paperback = paperBack;
        }
    }

    // Declare a delegate type for processing a book:
    public delegate void ProcessBookCallback(Book book);

    // Maintains a book database.
    public class BookDB
    {
        // List of all books in the database:
        ArrayList list = new ArrayList();

        // Add a book to the database:
        public void AddBook(string title, string author, decimal price, bool paperBack)
        {
            list.Add(new Book(title, author, price, paperBack));
        }

        // Call a passed-in delegate on each paperback book to process it:
        public void ProcessPaperbackBooks(ProcessBookCallback processBook)
        {
            foreach (Book b in list)
            {
                if (b.Paperback)
                    // Calling the delegate:
                    processBook(b);
            }
        }
    }
}

// Using the Bookstore classes:
namespace BookTestClient
{
    using Bookstore;

    // Class to total and average prices of books:
    class PriceTotaller
    {
        int countBooks = 0;
        decimal priceBooks = 0.0m;

        internal void AddBookToTotal(Book book)
        {
            countBooks += 1;
            priceBooks += book.Price;
        }

        internal decimal AveragePrice()
        {
            return priceBooks / countBooks;
        }
    }

    // Class to test the book database:
    class Test
    {
        // Print the title of the book.
        static void PrintTitle(Book b)
        {
            Console.WriteLine($"   {b.Title}");
        }

        // Execution starts here.
        static void Main()
        {
            BookDB bookDB = new BookDB();

            // Initialize the database with some books:
            AddBooks(bookDB);

            // Print all the titles of paperbacks:
            Console.WriteLine("Paperback Book Titles:");

            // Create a new delegate object associated with the static
            // method Test.PrintTitle:
            bookDB.ProcessPaperbackBooks(PrintTitle);

            // Get the average price of a paperback by using
            // a PriceTotaller object:
            PriceTotaller totaller = new PriceTotaller();

            // Create a new delegate object associated with the nonstatic
            // method AddBookToTotal on the object totaller:
            bookDB.ProcessPaperbackBooks(totaller.AddBookToTotal);

            Console.WriteLine("Average Paperback Book Price: ${0:#.##}",
                    totaller.AveragePrice());
        }

        // Initialize the book database with some test books:
        static void AddBooks(BookDB bookDB)
        {
            bookDB.AddBook("The C Programming Language", "Brian W. Kernighan and Dennis M. Ritchie", 19.95m, true);
            bookDB.AddBook("The Unicode Standard 2.0", "The Unicode Consortium", 39.95m, true);
            bookDB.AddBook("The MS-DOS Encyclopedia", "Ray Duncan", 129.95m, false);
            bookDB.AddBook("Dogbert's Clues for the Clueless", "Scott Adams", 12.00m, true);
        }
    }
}
/* Output:
Paperback Book Titles:
   The C Programming Language
   The Unicode Standard 2.0
   Dogbert's Clues for the Clueless
Average Paperback Book Price: $23.97
*/

09.04.02 可靠编程

  • 声明委托。

    以下语句声明新的委托类型。

    C#复制

    public delegate void ProcessBookCallback(Book book);
    

    每个委托类型描述自变量的数量和类型,以及它可以封装的方法的返回值类型。 每当需要一组新的自变量类型或返回值类型,则必须声明一个新的委托类型。

  • 实例化委托。

    声明委托类型后,则必须创建委托对象并将其与特定的方法相关联。 在上例中,你通过将 PrintTitle 方法传递给 ProcessPaperbackBooks 方法执行此操作,如下面的示例所示:

    C#复制

    bookDB.ProcessPaperbackBooks(PrintTitle);
    

    这将创建一个新的与静态方法 Test.PrintTitle 关联的委托对象。 同样,如下面的示例所示,传递对象 totaller 中的非静态方法 AddBookToTotal

    C#复制

    bookDB.ProcessPaperbackBooks(totaller.AddBookToTotal);
    

    在这两种情况下,都将新的委托对象传递给 ProcessPaperbackBooks 方法。

    创建委托后,它与之关联的方法就永远不会更改;委托对象是不可变的。

  • 调用委托。

    创建委托对象后,通常会将委托对象传递给将调用该委托的其他代码。 委托对象是通过使用委托对象的名称调用的,后跟用圆括号括起来的将传递给委托的自变量。 下面是一个委托调用示例:

    C#复制

    processBook(b);
    

    委托可以同步调用(如在本例中)或通过使用 BeginInvokeEndInvoke 方法异步调用。

09.05 索引器

索引器允许类或结构的实例就像数组一样进行索引。 无需显式指定类型或实例成员,即可设置或检索索引值。 索引器类似于属性,不同之处在于它们的访问器需要使用参数。

以下示例定义了一个泛型类,其中包含用于赋值和检索值的简单 getset 访问器方法。 Program 类创建了此类的一个实例,用于存储字符串。

C#复制

using System;

class SampleCollection<T>
{
   // Declare an array to store the data elements.
   private T[] arr = new T[100];

   // Define the indexer to allow client code to use [] notation.
   public T this[int i]
   {
      get { return arr[i]; }
      set { arr[i] = value; }
   }
}

class Program
{
   static void Main()
   {
      var stringCollection = new SampleCollection<string>();
      stringCollection[0] = "Hello, World";
      Console.WriteLine(stringCollection[0]);
   }
}
// The example displays the following output:
//       Hello, World.

备注

有关更多示例,请参阅相关部分

09.05.01 表达式主体定义

索引器的 get 或 set 访问器包含一个用于返回或设置值的语句很常见。 为了支持这种情况,表达式主体成员提供了一种经过简化的语法。 自 C# 6 起,可以表达式主体成员的形式实现只读索引器,如以下示例所示。

C#复制

using System;

class SampleCollection<T>
{
   // Declare an array to store the data elements.
   private T[] arr = new T[100];
   int nextIndex = 0;

   // Define the indexer to allow client code to use [] notation.
   public T this[int i] => arr[i];

   public void Add(T value)
   {
      if (nextIndex >= arr.Length)
         throw new IndexOutOfRangeException($"The collection can hold only {arr.Length} elements.");
      arr[nextIndex++] = value;
   }
}

class Program
{
   static void Main()
   {
      var stringCollection = new SampleCollection<string>();
      stringCollection.Add("Hello, World");
      System.Console.WriteLine(stringCollection[0]);
   }
}
// The example displays the following output:
//       Hello, World.

请注意,=> 引入了表达式主体,并未使用 get 关键字。

自 C# 7.0 起,get 和 set 访问器均可作为表达式主体成员实现。 在这种情况下,必须使用 getset 关键字。 例如:

C#复制

using System;

class SampleCollection<T>
{
   // Declare an array to store the data elements.
   private T[] arr = new T[100];

   // Define the indexer to allow client code to use [] notation.
   public T this[int i]
   {
      get => arr[i];
      set => arr[i] = value;
   }
}

class Program
{
   static void Main()
   {
      var stringCollection = new SampleCollection<string>();
      stringCollection[0] = "Hello, World.";
      Console.WriteLine(stringCollection[0]);
   }
}
// The example displays the following output:
//       Hello, World.

09.05.02 索引器概述

  • 使用索引器可以用类似于数组的方式为对象建立索引。

  • get 取值函数返回值。 set 取值函数分配值。

  • this 关键字用于定义索引器。

  • value 关键字用于定义由 set 访问器分配的值。

  • 索引器不必根据整数值进行索引;由你决定如何定义特定的查找机制。

  • 索引器可被重载。

  • 索引器可以有多个形参,例如当访问二维数组时。

索引器使你可从语法上方便地创建结构接口,以便客户端应用程序可以像访问数组一样访问它们。 编译器会生成一个 Item 属性(或者如果存在 IndexerNameAttribute,也可以生成一个命名属性)和适当的访问器方法。 在主要目标是封装内部集合或数组的类型中,常常要实现索引器。 例如,假设有一个类 TempRecord,它表示 24 小时的周期内在 10 个不同时间点所记录的温度(单位为华氏度)。 此类包含一个 float[] 类型的数组 temps,用于存储温度值。 通过在此类中实现索引器,客户端可采用 float temp = tempRecord[4] 的形式(而非 float temp = tempRecord.temps[4])访问 TempRecord 实例中的温度。 索引器表示法不但简化了客户端应用程序的语法;还使类及其目标更容易直观地为其它开发者所理解。

若要在类或结构上声明索引器,请使用 this 关键字,如以下示例所示:

C#复制

// Indexer declaration
public int this[int index]
{
    // get and set accessors
}

重要

通过声明索引器,可自动在对象上生成一个名为 Item 的属性。 无法从实例成员访问表达式直接访问 Item 属性。 此外,如果通过索引器向对象添加自己的 Item 属性,则将收到 CS0102 编译器错误。 要避免此错误,请使用 IndexerNameAttribute 重命名本文后面详述的索引器。

索引器及其参数的类型必须至少具有和索引器相同的可访问性。 有关可访问性级别的详细信息,请参阅访问修饰符

有关如何在接口上使用索引器的详细信息,请参阅接口索引器

索引器的签名由其形参的数目和类型所组成。 它不包含索引器类型或形参的名称。 如果要在相同类中声明多个索引器,则它们的签名必须不同。

索引器未分类为变量;因此,索引器值不能按引用(作为 refout 参数)传递,除非其值是引用(即按引用返回。)

若要使索引器的名称可为其他语言所用,请使用 System.Runtime.CompilerServices.IndexerNameAttribute,如以下示例所示:

C#复制

// Indexer declaration
[System.Runtime.CompilerServices.IndexerName("TheItem")]
public int this[int index]
{
    // get and set accessors
}

此索引器被索引器名称属性重写,因此其名称为 TheItem。 默认情况下,默认名称为 Item

下列示例演示如何声明专用数组字段 temps 和索引器。 索引器可以实现对实例 tempRecord[i] 的直接访问。 若不使用索引器,则将数组声明为公共成员,并直接访问其成员 tempRecord.temps[i]

C#复制

public class TempRecord
{
    // Array of temperature values
    float[] temps =
    [
        56.2F, 56.7F, 56.5F, 56.9F, 58.8F,
        61.3F, 65.9F, 62.1F, 59.2F, 57.5F
    ];

    // To enable client code to validate input
    // when accessing your indexer.
    public int Length => temps.Length;
    
    // Indexer declaration.
    // If index is out of range, the temps array will throw the exception.
    public float this[int index]
    {
        get => temps[index];
        set => temps[index] = value;
    }
}

请注意,当评估索引器访问时(例如在 Console.Write 语句中),将调用 get 访问器。 因此,如果不存在 get 访问器,则会发生编译时错误。

C#复制

var tempRecord = new TempRecord();

// Use the indexer's set accessor
tempRecord[3] = 58.3F;
tempRecord[5] = 60.1F;

// Use the indexer's get accessor
for (int i = 0; i < 10; i++)
{
    Console.WriteLine($"Element #{i} = {tempRecord[i]}");
}

09.05.03 使用其他值进行索引

C# 不将索引参数类型限制为整数。 例如,对索引器使用字符串可能有用。 通过搜索集合内的字符串并返回相应的值,可以实现此类索引器。 访问器可被重载,因此字符串和整数版本可以共存。

下面的示例声明了存储星期几的类。 get 访问器采用字符串(星期几)并返回对应的整数。 例如,"Sunday"返回 0,"Monday"返回 1,依此类推。

C#复制

// Using a string as an indexer value
class DayCollection
{
    string[] days = ["Sun", "Mon", "Tues", "Wed", "Thurs", "Fri", "Sat"];

    // Indexer with only a get accessor with the expression-bodied definition:
    public int this[string day] => FindDayIndex(day);

    private int FindDayIndex(string day)
    {
        for (int j = 0; j < days.Length; j++)
        {
            if (days[j] == day)
            {
                return j;
            }
        }

        throw new ArgumentOutOfRangeException(
            nameof(day),
            $"Day {day} is not supported.\nDay input must be in the form \"Sun\", \"Mon\", etc");
    }
}

C#复制

var week = new DayCollection();
Console.WriteLine(week["Fri"]);

try
{
    Console.WriteLine(week["Made-up day"]);
}
catch (ArgumentOutOfRangeException e)
{
    Console.WriteLine($"Not supported input: {e.Message}");
}

下面的示例声明了使用 System.DayOfWeek 存储星期几的类。 get 访问器采用 DayOfWeek(表示星期几的值)并返回对应的整数。 例如,DayOfWeek.Sunday 返回 0,DayOfWeek.Monday 返回 1,依此类推。

C#复制

using Day = System.DayOfWeek;

class DayOfWeekCollection
{
    Day[] days =
    [
        Day.Sunday, Day.Monday, Day.Tuesday, Day.Wednesday,
        Day.Thursday, Day.Friday, Day.Saturday
    ];

    // Indexer with only a get accessor with the expression-bodied definition:
    public int this[Day day] => FindDayIndex(day);

    private int FindDayIndex(Day day)
    {
        for (int j = 0; j < days.Length; j++)
        {
            if (days[j] == day)
            {
                return j;
            }
        }
        throw new ArgumentOutOfRangeException(
            nameof(day),
            $"Day {day} is not supported.\nDay input must be a defined System.DayOfWeek value.");
    }
}

C#复制

var week = new DayOfWeekCollection();
Console.WriteLine(week[DayOfWeek.Friday]);

try
{
    Console.WriteLine(week[(DayOfWeek)43]);
}
catch (ArgumentOutOfRangeException e)
{
    Console.WriteLine($"Not supported input: {e.Message}");
}

09.05.04 可靠编程

提高索引器的安全性和可靠性有两种主要方法:

  • 请确保结合某一类型的错误处理策略,以处理万一客户端代码传入无效索引值的情况。 在本文前面的第一个示例中,TempRecord 类提供了 Length 属性,使客户端代码能在将输入传递给索引器之前对其进行验证。 也可将错误处理代码放入索引器自身内部。 请确保为用户记录在索引器的访问器中引发的任何异常。

  • 在可接受的程度内,为 getset 访问器的可访问性设置尽可能多的限制。 这一点对 set 访问器尤为重要。 有关详细信息,请参阅限制访问器可访问性

09.05.05 接口中的索引器

可以在接口上声明索引器。 接口索引器的访问器与索引器的访问器有所不同,差异如下:

  • 接口访问器不使用修饰符。
  • 接口访问器通常没有正文。

访问器的用途是指示索引器为读写、只读还是只写。 可以为接口中定义的索引器提供实现,但这种情况非常少。 索引器通常定义 API 来访问数据字段,而数据字段无法在接口中定义。

下面是接口索引器访问器的示例:

C#复制

public interface ISomeInterface
{
    //...

    // Indexer declaration:
    string this[int index]
    {
        get;
        set;
    }
}

索引器的签名必须不同于同一接口中声明的所有其他索引器的签名。

下面的示例演示如何实现接口索引器。

C#复制

// Indexer on an interface:
public interface IIndexInterface
{
    // Indexer declaration:
    int this[int index]
    {
        get;
        set;
    }
}

// Implementing the interface.
class IndexerClass : IIndexInterface
{
    private int[] arr = new int[100];
    public int this[int index]   // indexer declaration
    {
        // The arr object will throw IndexOutOfRange exception.
        get => arr[index];
        set => arr[index] = value;
    }
}

C#复制

IndexerClass test = new IndexerClass();
System.Random rand = System.Random.Shared;
// Call the indexer to initialize its elements.
for (int i = 0; i < 10; i++)
{
    test[i] = rand.Next();
}
for (int i = 0; i < 10; i++)
{
    System.Console.WriteLine($"Element #{i} = {test[i]}");
}

/* Sample output:
    Element #0 = 360877544
    Element #1 = 327058047
    Element #2 = 1913480832
    Element #3 = 1519039937
    Element #4 = 601472233
    Element #5 = 323352310
    Element #6 = 1422639981
    Element #7 = 1797892494
    Element #8 = 875761049
    Element #9 = 393083859
*/

在前面的示例中,可通过使用接口成员的完全限定名来使用显示接口成员实现。 例如

C#复制

string IIndexInterface.this[int index]
{
}

但仅当类采用相同的索引签名实现多个接口时,才需用到完全限定名称以避免歧义。 例如,如果 Employee 类正在实现接口 ICitizen 和接口 IEmployee,而这两个接口具有相同的索引签名,则需要用到显式接口成员实现。 即是说以下索引器声明:

C#复制

string IEmployee.this[int index]
{
}

IEmployee 接口中实现索引器,而以下声明:

C#复制

string ICitizen.this[int index]
{
}

ICitizen 接口中实现索引器。

09.05.06 属性和索引器之间的比较

索引器与属性相似。 除下表所示的差别外,对属性访问器定义的所有规则也适用于索引器访问器。

展开表

Property 索引器
允许以将方法视作公共数据成员的方式调用方法。 通过在对象自身上使用数组表示法,允许访问对象内部集合的元素。
通过简单名称访问。 通过索引访问。
可为静态成员或实例成员。 必须是实例成员。
属性的 get 访问器没有任何参数。 索引器的 get 访问器具有与索引器相同的形参列表。
属性的 set 访问器包含隐式 value 参数。 索引器的 set 访问器具有与索引器相同的形参列表,value 参数也是如此。
支持使用 自动实现的属性缩短语法。 支持仅使用索引器的 expression-bodied 成员。

10 泛型

10.01 泛型类型参数

在泛型类型或方法定义中,类型参数是在其创建泛型类型的一个实例时,客户端指定的特定类型的占位符。 泛型类(例如泛型介绍中列出的 GenericList<T>)无法按原样使用,因为它不是真正的类型;它更像是类型的蓝图。 若要使用 GenericList<T>,客户端代码必须通过指定尖括号内的类型参数来声明并实例化构造类型。 此特定类的类型参数可以是编译器可识别的任何类型。 可创建任意数量的构造类型实例,其中每个使用不同的类型参数,如下所示:

C#复制

GenericList<float> list1 = new GenericList<float>();
GenericList<ExampleClass> list2 = new GenericList<ExampleClass>();
GenericList<ExampleStruct> list3 = new GenericList<ExampleStruct>();

GenericList<T> 的每个实例中,类中出现的每个 T 在运行时均会被替换为类型参数。 通过这种替换,我们已通过使用单个类定义创建了三个单独的类型安全的有效对象。 有关 CLR 如何执行此替换的详细信息,请参阅运行时中的泛型

可在有关命名约定的文章中了解泛型类型参数的命名约定。

10.02 类型参数的约束

约束告知编译器类型参数必须具备的功能。 在没有任何约束的情况下,类型参数可以是任何类型。 编译器只能假定 System.Object 的成员,它是任何 .NET 类型的最终基类。 有关详细信息,请参阅使用约束的原因。 如果客户端代码使用不满足约束的类型,编译器将发出错误。 通过使用 where 上下文关键字指定约束。 下表列出了各种类型的约束:

展开表

约束 说明
where T : struct 类型参数必须是不可为 null 的值类型,其中包含 record struct 类型。 有关可为 null 的值类型的信息,请参阅可为 null 的值类型。 由于所有值类型都具有可访问的无参数构造函数(无论是声明的还是隐式的),因此 struct 约束表示 new() 约束,并且不能与 new() 约束结合使用。 struct 约束也不能与 unmanaged 约束结合使用。
where T : class 类型参数必须是引用类型。 此约束还应用于任何类、接口、委托或数组类型。 在可为 null 的上下文中,T 必须是不可为 null 的引用类型。
where T : class? 类型参数必须是可为 null 或不可为 null 的引用类型。 此约束还应用于任何类、接口、委托或数组类型(包括记录)。
where T : notnull 类型参数必须是不可为 null 的类型。 参数可以是不可为 null 的引用类型,也可以是不可为 null 的值类型。
where T : unmanaged 类型参数必须是不可为 null 的非托管类型unmanaged 约束表示 struct 约束,且不能与 struct 约束或 new() 约束结合使用。
where T : new() 类型参数必须具有公共无参数构造函数。 与其他约束一起使用时,new() 约束必须最后指定。 new() 约束不能与 structunmanaged 约束结合使用。
where T : <基类名> 类型参数必须是指定的基类或派生自指定的基类。 在可为 null 的上下文中,T 必须是从指定基类派生的不可为 null 的引用类型。
where T : <基类名>? 类型参数必须是指定的基类或派生自指定的基类。 在可为 null 的上下文中,T 可以是从指定基类派生的可为 null 或不可为 null 的类型。
where T : <接口名> 类型参数必须是指定的接口或实现指定的接口。 可指定多个接口约束。 约束接口也可以是泛型。 在的可为 null 的上下文中,T 必须是实现指定接口的不可为 null 的类型。
where T : <接口名>? 类型参数必须是指定的接口或实现指定的接口。 可指定多个接口约束。 约束接口也可以是泛型。 在可为 null 的上下文中,T 可以是可为 null 的引用类型、不可为 null 的引用类型或值类型。 T 不能是可为 null 的值类型。
where T : U T 提供的类型参数必须是为 U 提供的参数或派生自为 U 提供的参数。 在可为 null 的上下文中,如果 U 是不可为 null 的引用类型,T 必须是不可为 null 的引用类型。 如果 U 是可为 null 的引用类型,则 T 可以是可为 null 的引用类型,也可以是不可为 null 的引用类型。
where T : default 重写方法或提供显式接口实现时,如果需要指定不受约束的类型参数,此约束可解决歧义。 default 约束表示基方法,但不包含 classstruct 约束。 有关详细信息,请参阅default约束规范建议。
where T : allows ref struct 此反约束声明 T 的类型参数可以是 ref struct 类型。 该泛型类型或方法必须遵循 T 的任何实例的引用安全规则,因为它可能是 ref struct

某些约束是互斥的,而某些约束必须按指定顺序排列:

  • 最多可应用 structclassclass?notnullunmanaged 约束中的一个。 如果提供这些约束中的任何一个,则它必须是为该类型参数指定的第一个约束。
  • 基类约束(where T : Basewhere T : Base?)不能与 structclassclass?notnullunmanaged 约束中的任何一个结合使用。
  • 无论哪种形式,都最多只能应用一个基类约束。 如果想要支持可为 null 的基类型,请使用 Base?
  • 不能将接口不可为 null 和可为 null 的形式命名为约束。
  • new() 约束不能与 structunmanaged 约束结合使用。 如果指定 new() 约束,则它必须是该类型参数的最后一个约束。 反约束(如果适用)可以遵循 new() 约束。
  • default 约束只能应用于替代或显式接口实现。 它不能与 structclass 约束结合使用。
  • allows ref struct 反约束不能与 classclass? 约束结合使用。
  • allows ref struct 反约束必须遵循该类型参数的所有约束。

10.02.01 使用约束的原因

约束指定类型参数的功能和预期。 声明这些约束意味着你可以使用约束类型的操作和方法调用。 如果泛型类或方法对泛型成员使用除简单赋值之外的任何操作,包括调用 System.Object 不支持的任何方法,则对类型参数应用约束。 例如,基类约束告诉编译器,只有此类型的对象或派生自此类型的对象可替换该类型参数。 编译器有了此保证后,就能够允许在泛型类中调用该类型的方法。 以下代码示例演示可通过应用基类约束添加到(泛型介绍中的)GenericList<T> 类的功能。

C#复制

public class Employee
{
    public Employee(string name, int id) => (Name, ID) = (name, id);
    public string Name { get; set; }
    public int ID { get; set; }
}

public class GenericList<T> where T : Employee
{
    private class Node
    {
        public Node(T t) => (Next, Data) = (null, t);

        public Node? Next { get; set; }
        public T Data { get; set; }
    }

    private Node? head;

    public void AddHead(T t)
    {
        Node n = new Node(t) { Next = head };
        head = n;
    }

    public IEnumerator<T> GetEnumerator()
    {
        Node? current = head;

        while (current != null)
        {
            yield return current.Data;
            current = current.Next;
        }
    }

    public T? FindFirstOccurrence(string s)
    {
        Node? current = head;
        T? t = null;

        while (current != null)
        {
            //The constraint enables access to the Name property.
            if (current.Data.Name == s)
            {
                t = current.Data;
                break;
            }
            else
            {
                current = current.Next;
            }
        }
        return t;
    }
}

约束使泛型类能够使用 Employee.Name 属性。 约束指定类型 T 的所有项都保证是 Employee 对象或从 Employee 继承的对象。

可以对同一类型参数应用多个约束,并且约束自身可以是泛型类型,如下所示:

C#复制

class EmployeeList<T> where T : notnull, Employee, IComparable<T>, new()
{
    // ...
    public void AddDefault()
    {
        T t = new T();
        // ...
    }
}

在应用 where T : class 约束时,请避免对类型参数使用 ==!= 运算符,因为这些运算符仅测试引用标识而不测试值相等性。 即使在用作参数的类型中重载这些运算符也会发生此行为。 下面的代码说明了这一点;即使 String 类重载 == 运算符,输出也为 false。

C#复制

public static void OpEqualsTest<T>(T s, T t) where T : class
{
    System.Console.WriteLine(s == t);
}

private static void TestStringEquality()
{
    string s1 = "target";
    System.Text.StringBuilder sb = new System.Text.StringBuilder("target");
    string s2 = sb.ToString();
    OpEqualsTest<string>(s1, s2);
}

编译器只知道 T 在编译时是引用类型,并且必须使用对所有引用类型都有效的默认运算符。 如果必须测试值相等性,请应用 where T : IEquatable<T>where T : IComparable<T> 约束,并在用于构造泛型类的任何类中实现该接口。

10.02.02 约束多个参数

可以对多个参数应用多个约束,对一个参数应用多个约束,如下例所示:

C#复制

class Base { }
class Test<T, U>
    where U : struct
    where T : Base, new()
{ }

10.02.03 未绑定的类型参数

没有约束的类型参数(如公共类 SampleClass<T>{} 中的 T)称为未绑定的类型参数。 未绑定的类型参数具有以下规则:

  • 不能使用 !=== 运算符,因为无法保证具体的类型参数能支持这些运算符。
  • 可以在它们与 System.Object 之间来回转换,或将它们显式转换为任何接口类型。
  • 可以将它们与 null 进行比较。 将未绑定的参数与 null 进行比较时,如果类型参数为值类型,则该比较始终返回 false。

10.02.04 类型参数作为约束

在具有自己类型参数的成员函数必须将该参数约束为包含类型的类型参数时,将泛型类型参数用作约束非常有用,如下例所示:

C#复制

public class List<T>
{
    public void Add<U>(List<U> items) where U : T {/*...*/}
}

在上述示例中,TAdd 方法的上下文中是一个类型约束,而在 List 类的上下文中是一个未绑定的类型参数。

类型参数还可在泛型类定义中用作约束。 必须在尖括号中声明该类型参数以及任何其他类型参数:

C#复制

//Type parameter V is used as a type constraint.
public class SampleClass<T, U, V> where T : V { }

类型参数作为泛型类的约束的作用非常有限,因为编译器除了假设类型参数派生自 System.Object 以外,不会做其他任何假设。 如果要在两个类型参数之间强制继承关系,可以将类型参数用作泛型类的约束。

10.02.05 notnull 约束

可以使用 notnull 约束指定类型参数必须是不可为 null 的值类型或不可为 null 的引用类型。 与大多数其他约束不同,如果类型参数违反 notnull 约束,编译器会生成警告而不是错误。

notnull 约束仅在可为 null 上下文中使用时才有效。 如果在过时的可为 null 上下文中添加 notnull 约束,编译器不会针对违反约束的情况生成任何警告或错误。

10.02.06 class 约束

可为 null 的上下文中的 class 约束指定类型参数必须是不可为 null 的引用类型。 在可为 null 上下文中,当类型参数是可为 null 的引用类型时,编译器会生成警告。

10.02.07 default 约束

添加可为空引用类型会使泛型类型或方法中的 T? 使用复杂化。 T? 可以与 structclass 约束一起使用,但必须存在其中一项。 使用 class 约束时,T? 引用了 T 的可为空引用类型。 可在这两个约束均未应用时使用 T?。 在这种情况下,对于值类型和引用类型,T? 解读为 T?。 但是,如果 TNullable<T>的实例,则 T?T 相同。 换句话说,它不会成为 T??

由于现在可在没有 classstruct 约束的情况下使用 T?,因此在重写或显式接口实现中可能会出现歧义。 在这两种情况下,重写不包含约束,但从基类继承。 当基类不应用 classstruct 约束时,派生类需要通过某种方式在不使用任一种约束的情况下指定应用于基方法的重写。 派生方法应用 default 约束。 default 约束不阐明 classstruct 约束。

10.02.08 非托管约束

可使用 unmanaged 约束来指定类型参数必须是不可为 null 的非托管类型。 通过 unmanaged 约束,用户能编写可重用例程,从而使用可作为内存块操作的类型,如以下示例所示:

C#复制

unsafe public static byte[] ToByteArray<T>(this T argument) where T : unmanaged
{
    var size = sizeof(T);
    var result = new Byte[size];
    Byte* p = (byte*)&argument;
    for (var i = 0; i < size; i++)
        result[i] = *p++;
    return result;
}

以上方法必须在 unsafe 上下文中编译,因为它并不是在已知的内置类型上使用 sizeof 运算符。 如果没有 unmanaged 约束,则 sizeof 运算符不可用。

unmanaged 约束表示 struct 约束,且不能与其结合使用。 因为 struct 约束表示 new() 约束,且 unmanaged 约束也不能与 new() 约束结合使用。

10.02.09 委托约束

可以使用 System.DelegateSystem.MulticastDelegate 作为基类约束。 CLR 始终允许此约束,但 C# 语言不允许。 使用 System.Delegate 约束,用户能够以类型安全的方式编写使用委托的代码。 以下代码定义了合并两个同类型委托的扩展方法:

C#复制

public static TDelegate? TypeSafeCombine<TDelegate>(this TDelegate source, TDelegate target)
    where TDelegate : System.Delegate
    => Delegate.Combine(source, target) as TDelegate;

可使用上述方法来合并相同类型的委托:

C#复制

Action first = () => Console.WriteLine("this");
Action second = () => Console.WriteLine("that");

var combined = first.TypeSafeCombine(second);
combined!();

Func<bool> test = () => true;
// Combine signature ensures combined delegates must
// have the same type.
//var badCombined = first.TypeSafeCombine(test);

如果对最后一行取消注释,它将不会编译。 firsttest 均为委托类型,但它们是不同的委托类型。

10.02.10 枚举约束

还可指定 System.Enum 类型作为基类约束。 CLR 始终允许此约束,但 C# 语言不允许。 使用 System.Enum 的泛型提供类型安全的编程,缓存使用 System.Enum 中静态方法的结果。 以下示例查找枚举类型的所有有效的值,然后生成将这些值映射到其字符串表示形式的字典。

C#复制

public static Dictionary<int, string> EnumNamedValues<T>() where T : System.Enum
{
    var result = new Dictionary<int, string>();
    var values = Enum.GetValues(typeof(T));

    foreach (int item in values)
        result.Add(item, Enum.GetName(typeof(T), item)!);
    return result;
}

Enum.GetValuesEnum.GetName 使用反射,这会对性能产生影响。 可调用 EnumNamedValues 来生成可缓存和重用的集合,而不是重复执行需要反射才能实施的调用。

如以下示例所示,可使用它来创建枚举并生成其值和名称的字典:

C#复制

enum Rainbow
{
    Red,
    Orange,
    Yellow,
    Green,
    Blue,
    Indigo,
    Violet
}

C#复制

var map = EnumNamedValues<Rainbow>();

foreach (var pair in map)
    Console.WriteLine($"{pair.Key}:\t{pair.Value}");

10.02.11 类型参数实现声明的接口

某些场景要求为类型参数提供的参数实现该接口。 例如:

C#复制

public interface IAdditionSubtraction<T> where T : IAdditionSubtraction<T>
{
    static abstract T operator +(T left, T right);
    static abstract T operator -(T left, T right);
}

此模式使 C# 编译器能够确定重载运算符或任何 static virtualstatic abstract 方法的包含类型。 它提供的语法使得可以在包含类型上定义加法和减法运算符。 如果没有此约束,需要将参数和自变量声明为接口,而不是类型参数:

C#复制

public interface IAdditionSubtraction<T> where T : IAdditionSubtraction<T>
{
    static abstract IAdditionSubtraction<T> operator +(
        IAdditionSubtraction<T> left,
        IAdditionSubtraction<T> right);

    static abstract IAdditionSubtraction<T> operator -(
        IAdditionSubtraction<T> left,
        IAdditionSubtraction<T> right);
}

上述语法要求实现者对这些方法使用显式接口实现。 提供额外的约束使接口能够根据类型参数来定义运算符。 实现接口的类型可以隐式实现接口方法。

10.02.12 Allows ref struct

allows ref struct 反约束声明相应的类型参数可以是 ref struct 类型。 该类型参数的实例必须遵循以下规则:

  • 它不能被装箱。
  • 它参与引用安全规则
  • 不能在不允许 ref struct 类型的地方使用实例,例如 static 字段。
  • 实例可以使用 scoped 修饰符进行标记。

不会继承 allows ref struct 子句。 在以下代码中:

C#复制

class SomeClass<T, S>
    where T : allows ref struct
    where S : T
{
    // etc
}

S 的参数不能是 ref struct,因为 S 没有 allows ref struct 子句。

具有 allows ref struct 子句的类型参数不能用作类型参数,除非相应的类型参数也具有 allows ref struct 子句。 下面的示例说明了此规则:

C#复制

public class Allow<T> where T : allows ref struct
{

}

public class Disallow<T>
{
}

public class Example<T> where T : allows ref struct
{
    private Allow<T> fieldOne; // Allowed. T is allowed to be a ref struct

    private Disallow<T> fieldTwo; // Error. T is not allowed to be a ref struct
}

前面的示例表明,对于一个可能是 ref struct 类型的类型参数,不能将其替换为不能是 ref struct 类型的类型参数。

10.03 泛型类

泛型类封装不特定于特定数据类型的操作。 泛型类最常见用法是用于链接列表、哈希表、堆栈、队列和树等集合。 无论存储数据的类型如何,添加项和从集合删除项等操作的执行方式基本相同。

对于大多数需要集合类的方案,推荐做法是使用 .NET 类库中提供的集合类。 有关使用这些类的详细信息,请参阅 .NET 中的泛型集合

通常,创建泛型类是从现有具体类开始,然后每次逐个将类型更改为类型参数,直到泛化和可用性达到最佳平衡。 创建自己的泛型类时,需要考虑以下重要注意事项:

  • 要将哪些类型泛化为类型参数。

    通常,可参数化的类型越多,代码就越灵活、其可重用性就越高。 但过度泛化会造成其他开发人员难以阅读或理解代码。

  • 要将何种约束(如有)应用到类型参数(请参阅类型参数的约束)。

    其中一个有用的规则是,应用最大程度的约束,同时仍可处理必须处理的类型。 例如,如果知道泛型类仅用于引用类型,则请应用类约束。 这可防止将类意外用于值类型,并使你可在 T 上使用 as 运算符和检查 null 值。

  • 是否将泛型行为分解为基类和子类。

    因为泛型类可用作基类,所以非泛型类的相同设计注意事项在此也适用。 请参阅本主题后文有关从泛型基类继承的规则。

  • 实现一个泛型接口还是多个泛型接口。

    例如,如果要设计用于在基于泛型的集合中创建项的类,则可能必须实现一个接口,例如 IComparable<T>,其中 T 为类的类型。

有关简单泛型类的示例,请参阅泛型介绍

类型参数和约束的规则对于泛型类行为具有多种含义,尤其是在继承性和成员可访问性方面。 应当了解一些术语,然后再继续。 对于泛型类 Node<T>,,客户端代码可通过指定类型参数来引用类,创建封闭式构造类型 (Node<int>)。或者,可以不指定类型参数(例如指定泛型基类时),创建开放式构造类型 (Node<T>)。 泛型类可继承自具体的封闭式构造或开放式构造基类:

C#复制

class BaseNode { }
class BaseNodeGeneric<T> { }

// concrete type
class NodeConcrete<T> : BaseNode { }

//closed constructed type
class NodeClosed<T> : BaseNodeGeneric<int> { }

//open constructed type
class NodeOpen<T> : BaseNodeGeneric<T> { }

非泛型类(即,具体类)可继承自封闭式构造基类,但不可继承自开放式构造类或类型参数,因为运行时客户端代码无法提供实例化基类所需的类型参数。

C#复制

//No error
class Node1 : BaseNodeGeneric<int> { }

//Generates an error
//class Node2 : BaseNodeGeneric<T> {}

//Generates an error
//class Node3 : T {}

继承自开放式构造类型的泛型类必须对非此继承类共享的任何基类类型参数提供类型参数,如下方代码所示:

C#复制

class BaseNodeMultiple<T, U> { }

//No error
class Node4<T> : BaseNodeMultiple<T, int> { }

//No error
class Node5<T, U> : BaseNodeMultiple<T, U> { }

//Generates an error
//class Node6<T> : BaseNodeMultiple<T, U> {}

继承自开放式构造类型的泛型类必须指定作为基类型上约束超集或表示这些约束的约束:

C#复制

class NodeItem<T> where T : System.IComparable<T>, new() { }
class SpecialNodeItem<T> : NodeItem<T> where T : System.IComparable<T>, new() { }

泛型类型可使用多个类型参数和约束,如下所示:

C#复制

class SuperKeyType<K, V, U>
    where U : System.IComparable<U>
    where V : new()
{ }

开放式构造和封闭式构造类型可用作方法参数:

C#复制

void Swap<T>(List<T> list1, List<T> list2)
{
    //code to swap items
}

void Swap(List<int> list1, List<int> list2)
{
    //code to swap items
}

如果一个泛型类实现一个接口,则该类的所有实例均可强制转换为该接口。

泛型类是不变量。 换而言之,如果一个输入参数指定 List<BaseClass>,且你尝试提供 List<DerivedClass>,则会出现编译时错误。

10.04 泛型接口

为泛型集合类或表示集合中的项的泛型类定义接口通常很有用处。 为避免对值类型执行装箱和取消装箱操作,最好对泛型类使用泛型接口,例如 IComparable<T>。 .NET 类库定义多个泛型接口,以便用于 System.Collections.Generic 命名空间中的集合类。 有关这些接口的详细信息,请参阅泛型接口

接口被指定为类型参数上的约束时,仅可使用实现接口的类型。 如下代码示例演示一个派生自 GenericList<T> 类的 SortedList<T> 类。 有关详细信息,请参阅泛型介绍SortedList<T> 添加约束 where T : IComparable<T>。 此约束可使 SortedList<T> 中的 BubbleSort 方法在列表元素上使用泛型 CompareTo 方法。 在此示例中,列表元素是一个实现 IComparable<Person> 的简单类 Person

C#复制

//Type parameter T in angle brackets.
public class GenericList<T> : System.Collections.Generic.IEnumerable<T>
{
    protected Node head;
    protected Node current = null;

    // Nested class is also generic on T
    protected class Node
    {
        public Node next;
        private T data;  //T as private member datatype

        public Node(T t)  //T used in non-generic constructor
        {
            next = null;
            data = t;
        }

        public Node Next
        {
            get { return next; }
            set { next = value; }
        }

        public T Data  //T as return type of property
        {
            get { return data; }
            set { data = value; }
        }
    }

    public GenericList()  //constructor
    {
        head = null;
    }

    public void AddHead(T t)  //T as method parameter type
    {
        Node n = new Node(t);
        n.Next = head;
        head = n;
    }

    // Implementation of the iterator
    public System.Collections.Generic.IEnumerator<T> GetEnumerator()
    {
        Node current = head;
        while (current != null)
        {
            yield return current.Data;
            current = current.Next;
        }
    }

    // IEnumerable<T> inherits from IEnumerable, therefore this class
    // must implement both the generic and non-generic versions of
    // GetEnumerator. In most cases, the non-generic method can
    // simply call the generic method.
    System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator()
    {
        return GetEnumerator();
    }
}

public class SortedList<T> : GenericList<T> where T : System.IComparable<T>
{
    // A simple, unoptimized sort algorithm that
    // orders list elements from lowest to highest:

    public void BubbleSort()
    {
        if (null == head || null == head.Next)
        {
            return;
        }
        bool swapped;

        do
        {
            Node previous = null;
            Node current = head;
            swapped = false;

            while (current.next != null)
            {
                //  Because we need to call this method, the SortedList
                //  class is constrained on IComparable<T>
                if (current.Data.CompareTo(current.next.Data) > 0)
                {
                    Node tmp = current.next;
                    current.next = current.next.next;
                    tmp.next = current;

                    if (previous == null)
                    {
                        head = tmp;
                    }
                    else
                    {
                        previous.next = tmp;
                    }
                    previous = tmp;
                    swapped = true;
                }
                else
                {
                    previous = current;
                    current = current.next;
                }
            }
        } while (swapped);
    }
}

// A simple class that implements IComparable<T> using itself as the
// type argument. This is a common design pattern in objects that
// are stored in generic lists.
public class Person : System.IComparable<Person>
{
    string name;
    int age;

    public Person(string s, int i)
    {
        name = s;
        age = i;
    }

    // This will cause list elements to be sorted on age values.
    public int CompareTo(Person p)
    {
        return age - p.age;
    }

    public override string ToString()
    {
        return name + ":" + age;
    }

    // Must implement Equals.
    public bool Equals(Person p)
    {
        return (this.age == p.age);
    }
}

public class Program
{
    public static void Main()
    {
        //Declare and instantiate a new generic SortedList class.
        //Person is the type argument.
        SortedList<Person> list = new SortedList<Person>();

        //Create name and age values to initialize Person objects.
        string[] names =
        [
            "Franscoise",
            "Bill",
            "Li",
            "Sandra",
            "Gunnar",
            "Alok",
            "Hiroyuki",
            "Maria",
            "Alessandro",
            "Raul"
        ];

        int[] ages = [45, 19, 28, 23, 18, 9, 108, 72, 30, 35];

        //Populate the list.
        for (int x = 0; x < 10; x++)
        {
            list.AddHead(new Person(names[x], ages[x]));
        }

        //Print out unsorted list.
        foreach (Person p in list)
        {
            System.Console.WriteLine(p.ToString());
        }
        System.Console.WriteLine("Done with unsorted list");

        //Sort the list.
        list.BubbleSort();

        //Print out sorted list.
        foreach (Person p in list)
        {
            System.Console.WriteLine(p.ToString());
        }
        System.Console.WriteLine("Done with sorted list");
    }
}

可将多个接口指定为单个类型上的约束,如下所示:

C#复制

class Stack<T> where T : System.IComparable<T>, IEnumerable<T>
{
}

一个接口可定义多个类型参数,如下所示:

C#复制

interface IDictionary<K, V>
{
}

适用于类的继承规则也适用于接口:

C#复制

interface IMonth<T> { }

interface IJanuary : IMonth<int> { }  //No error
interface IFebruary<T> : IMonth<int> { }  //No error
interface IMarch<T> : IMonth<T> { }    //No error
                                       //interface IApril<T>  : IMonth<T, U> {}  //Error

如果泛型接口是协变的(即,仅使用自身的类型参数作为返回值),那么这些接口可继承自非泛型接口。 在 .NET 类库中,IEnumerable<T> 继承自 IEnumerable,因为 IEnumerable<T>GetEnumerator 的返回值和 Current 属性 Getter 中仅使用 T

具体类可实现封闭式构造接口,如下所示:

C#复制

interface IBaseInterface<T> { }

class SampleClass : IBaseInterface<string> { }

只要类形参列表提供接口所需的所有实参,泛型类即可实现泛型接口或封闭式构造接口,如下所示:

C#复制

interface IBaseInterface1<T> { }
interface IBaseInterface2<T, U> { }

class SampleClass1<T> : IBaseInterface1<T> { }          //No error
class SampleClass2<T> : IBaseInterface2<T, string> { }  //No error

控制方法重载的规则对泛型类、泛型结构或泛型接口内的方法一样。 有关详细信息,请参阅泛型方法

从 C# 11 开始,接口可以声明 static abstractstatic virtual 成员。 声明任一 static abstractstatic virtual 成员的接口几乎始终是泛型接口。 编译器必须在编译时解析对 static virtualstatic abstract 方法的调用。 接口中声明的 static virtualstatic abstract 方法没有类似于类中声明的 virtualabstract 方法的运行时调度机制。 相反,编译器使用编译时可用的类型信息。 这些成员通常是在泛型接口中声明的。 此外,声明 static virtualstatic abstract 方法的大多数接口都声明了其中一个类型参数必须实现已声明的接口。 然后,编译器使用提供的类型参数来解析声明成员的类型。

10.05 泛型方法

泛型方法是通过类型参数声明的方法,如下所示:

C#复制

static void Swap<T>(ref T lhs, ref T rhs)
{
    T temp;
    temp = lhs;
    lhs = rhs;
    rhs = temp;
}

如下示例演示使用类型参数的 int 调用方法的一种方式:

C#复制

public static void TestSwap()
{
    int a = 1;
    int b = 2;

    Swap<int>(ref a, ref b);
    System.Console.WriteLine(a + " " + b);
}

还可省略类型参数,编译器将推断类型参数。 如下 Swap 调用等效于之前的调用:

C#复制

Swap(ref a, ref b);

类型推理的相同规则适用于静态方法和实例方法。 编译器可基于传入的方法参数推断类型参数;而无法仅根据约束或返回值推断类型参数。 因此,类型推理不适用于不具有参数的方法。 类型推理发生在编译时,之后编译器尝试解析重载的方法签名。 编译器将类型推理逻辑应用于共用同一名称的所有泛型方法。 在重载解决方案步骤中,编译器仅包含在其上类型推理成功的泛型方法。

在泛型类中,非泛型方法可访问类级别类型参数,如下所示:

C#复制

class SampleClass<T>
{
    void Swap(ref T lhs, ref T rhs) { }
}

如果定义一个具有与包含类相同的类型参数的泛型方法,则编译器会生成警告 CS0693,因为在该方法范围内,向内 T 提供的参数会隐藏向外 T 提供的参数。 如果需要使用类型参数(而不是类实例化时提供的参数)调用泛型类方法所具备的灵活性,请考虑为此方法的类型参数提供另一标识符,如下方示例中 GenericList2<T> 所示。

C#复制

class GenericList<T>
{
    // CS0693.
    void SampleMethod<T>() { }
}

class GenericList2<T>
{
    // No warning.
    void SampleMethod<U>() { }
}

使用约束在方法中的类型参数上实现更多专用操作。 此版 Swap<T> 现名为 SwapIfGreater<T>,仅可用于实现 IComparable<T> 的类型参数。

C#复制

void SwapIfGreater<T>(ref T lhs, ref T rhs) where T : System.IComparable<T>
{
    T temp;
    if (lhs.CompareTo(rhs) > 0)
    {
        temp = lhs;
        lhs = rhs;
        rhs = temp;
    }
}

泛型方法可重载在数个泛型参数上。 例如,以下方法可全部位于同一类中:

C#复制

void DoWork() { }
void DoWork<T>() { }
void DoWork<T, U>() { }

还可使用类型参数作为方法的返回类型。 下面的代码示例显示一个返回 T 类型数组的方法:

C#复制

T[] Swap<T>(T a, T b)
{
    return [b, a];
}

10.06 泛型和数组

下限为零的单维数组自动实现 IList<T>。 这可使你创建可使用相同代码循环访问数组和其他集合类型的泛型方法。 此技术的主要用处在于读取集合中的数据。 IList<T> 接口无法用于添加元素或从数组删除元素。 如果在此上下文中尝试对数组调用 IList<T> 方法(例如 RemoveAt),则会引发异常。

如下代码示例演示具有 IList<T> 输入参数的单个泛型方法如何可循环访问列表和数组(此例中为整数数组)。

C#复制

class Program
{
    static void Main()
    {
        int[] arr = [0, 1, 2, 3, 4];
        List<int> list = new List<int>();

        for (int x = 5; x < 10; x++)
        {
            list.Add(x);
        }

        ProcessItems<int>(arr);
        ProcessItems<int>(list);
    }

    static void ProcessItems<T>(IList<T> coll)
    {
        // IsReadOnly returns True for the array and False for the List.
        System.Console.WriteLine
            ("IsReadOnly returns {0} for this collection.",
            coll.IsReadOnly);

        // The following statement causes a run-time exception for the
        // array, but not for the List.
        //coll.RemoveAt(4);

        foreach (T item in coll)
        {
            System.Console.Write(item?.ToString() + " ");
        }
        System.Console.WriteLine();
    }
}

10.07 泛型委托

委托可以定义它自己的类型参数。 引用泛型委托的代码可以指定类型参数以创建封闭式构造类型,就像实例化泛型类或调用泛型方法一样,如以下示例中所示:

C#复制

public delegate void Del<T>(T item);
public static void Notify(int i) { }

Del<int> m1 = new Del<int>(Notify);

C# 2.0 版具有一种称为方法组转换的新功能,适用于具体委托类型和泛型委托类型,使你能够使用此简化语法编写上一行:

C#复制

Del<int> m2 = Notify;

在泛型类中定义的委托可以用类方法使用的相同方式来使用泛型类类型参数。

C#复制

class Stack<T>
{
    public delegate void StackDelegate(T[] items);
}

引用委托的代码必须指定包含类的类型参数,如下所示:

C#复制

private static void DoWork(float[] items) { }

public static void TestStack()
{
    Stack<float> s = new Stack<float>();
    Stack<float>.StackDelegate d = DoWork;
}

根据典型设计模式定义事件时,泛型委托特别有用,因为发件人参数可以为强类型,无需在它和 Object 之间强制转换。

C

未完待续。。。

相关推荐
一行玩python13 分钟前
SQLAlchemy,ORM的Python标杆!
开发语言·数据库·python·oracle
「QT(C++)开发工程师」17 分钟前
【qt版本概述】
开发语言·qt
ZwaterZ35 分钟前
el-table-column自动生成序号&&在序号前插入图标
前端·javascript·c#·vue
数据小爬虫@1 小时前
利用Python爬虫获取淘宝店铺详情
开发语言·爬虫·python
高 朗1 小时前
【GO基础学习】基础语法(2)切片slice
开发语言·学习·golang·slice
寒笙LED1 小时前
C++详细笔记(六)string库
开发语言·c++·笔记
IT书架1 小时前
golang面试题
开发语言·后端·golang
初遇你时动了情2 小时前
uniapp 城市选择插件
开发语言·javascript·uni-app
zongzi_4943 小时前
二次封装的天气时间日历选择组件
开发语言·javascript·ecmascript
kikyo哎哟喂3 小时前
Java 代理模式详解
java·开发语言·代理模式