C-10-编程指南-三-

135 阅读1小时+

C#10 编程指南(三)

原文:zh.annas-archive.org/md5/f6bf98ae10aa686be15d58fe9358e0e2

译者:飞龙

协议:CC BY-NC-SA 4.0

第四章:泛型

在 第三章 中,我展示了如何编写类型,并描述了它们可以包含的各种成员。然而,关于类、结构体、接口和方法还有一个额外的维度我没有展示。它们可以定义类型参数,这些占位符让你在编译时可以插入不同的类型。这使得你只需编写一个类型,然后就可以生成多个版本。执行此操作的类型称为泛型类型。例如,运行时库定义了一个名为 List<T> 的泛型类,它充当可变长度数组。这里的 T 是一个类型参数,你可以几乎使用任何类型作为参数,因此 List<int> 是一个整数列表,List<string> 是一个字符串列表,依此类推。¹ 你也可以编写泛型方法,它是一种具有自己类型参数的方法,与其包含的类型是否是泛型无关。

泛型类型和方法在视觉上是有区别的,因为它们的名称后面总是有尖括号 (<>)。这些尖括号包含一个逗号分隔的参数或参数列表。与方法一样,这里也有参数/参数列表的区分:声明时指定参数列表,然后在使用方法或类型时为这些参数提供参数。因此,List<T> 定义了一个单一的类型参数 T,而 List<int> 提供了一个类型参数 int

对于类型参数,你可以使用任何喜欢的名称,只需符合 C#中标识符的通常约束即可,但有一些流行的约定。当只有一个参数时,通常(但不是普遍)使用 T。对于多参数泛型,你倾向于看到稍微更具描述性的名称。例如,运行时库定义了 Dictionary<TKey, TValue> 集合类。有时即使只有一个参数,你也会看到像这样的描述性名称,但无论如何,你通常会看到以 T 为前缀,这样在你的代码中使用它们时类型参数就会显眼。

泛型类型

类、结构体、记录和接口都可以是泛型,委托也可以,我们将在 第九章 中详细讨论它们。 示例 4-1 展示了如何定义一个泛型类。

示例 4-1. 定义泛型类
public class NamedContainer<T>
{
    public NamedContainer(T item, string name)
    {
        Item = item;
        Name = name;
    }

    public T Item { get; }
    public string Name { get; }
}

structs(结构体)、records(记录)和 interfaces(接口)的语法基本相同:类型名称紧跟着类型参数列表。 示例 4-2 展示了如何编写类似于 示例 4-1 中类的泛型记录。

示例 4-2. 定义泛型记录
public record NamedContainer<T>(T Item, string Name);

在通用类型的定义内部,我可以在通常会看到类型名称的任何地方使用类型参数T。在第一个示例中,我已将其用作构造函数参数的类型,在两个示例中都用作Item属性的类型。我也可以定义类型为T的字段。(实际上我已经这样做了,尽管不是显式地。自动属性会生成隐藏字段,因此我的Item属性将有一个关联的类型为T的隐藏字段。)你还可以定义类型为T的局部变量。并且你可以自由地将类型参数作为其他通用类型的参数。例如,我的NamedContainer<T>可以声明类型为List<T>的成员。

示例 4-1 和 4-2 定义的类型,像任何通用类型一样,都不是完整的类型。通用类型声明是未绑定的,意味着必须填入类型参数才能生成完整的类型。基本问题,例如NamedContainer<T>实例需要多少内存,无法在不知道T是什么的情况下回答——如果Tint,那么Item属性的隐藏字段将需要 4 字节,但如果是decimal,则需要 16 字节。如果 CLR 不知道如何安排内存中的内容,就不能为类型生成可执行代码。因此,为了使用这个或任何其他通用类型,我们必须提供类型参数。示例 4-3 展示了如何做到这一点。当提供类型参数时,结果有时被称为构造类型。(这与构造函数无关,我们在第三章中看过的一种特殊的成员类型。事实上,示例 4-3 也使用了它们——它调用了几个构造类型的构造函数。)

示例 4-3. 使用通用类
var a = new NamedContainer<int>(42, "The answer");
var b = new NamedContainer<int>(99, "Number of red balloons");
var c = new NamedContainer<string>("Programming C#", "Book title");

你可以在任何普通类型可以使用的地方使用构造的通用类型。例如,你可以将它们用作方法参数和返回值的类型,属性或字段的类型。你甚至可以将一个作为另一个通用类型的类型参数,就像示例 4-4 所示的那样。

示例 4-4. 作为类型参数的构造通用类型
// ...where a, and b come from Example 4-3. var namedInts = new List<NamedContainer<int>>() { a, b };
var namedNamedItem = new NamedContainer<NamedContainer<int>>(a, "Wrapped");

每次我向NamedContainer<T>提供不同的类型作为参数,都会构造一个不同的类型。(对于具有多个类型参数的通用类型,每个不同的类型参数组合将构造一个不同的类型。)这意味着NamedContainer<int>是一种不同于NamedContainer<string>的类型。这就是为什么在将NamedContainer<int>用作另一个NamedContainer的类型参数时不存在冲突的原因,正如示例 4-4 的最后一行所示——这里没有无限递归。

因为每组不同的类型参数产生不同的类型,所以在大多数情况下,同一通用类型的不同形式之间并没有隐含的兼容性。你不能将NamedContainer<int>赋给类型为Nam⁠ed​Con⁠tai⁠ner⁠<str⁠ing>的变量,反之亦然。这两种类型不兼容是有道理的,因为intstring是完全不同的类型。但如果我们使用object作为类型参数呢?正如第二章所述,几乎可以将任何东西放入一个object变量中。如果你写了一个参数类型为object的方法,传递一个string是可以的,因此你可能期望一个接受NamedContainer<object>的方法也接受NamedContainer<string>。然而这是行不通的,但某些通用类型(特别是接口和委托)可以声明它们希望具有这种兼容关系。支持这一点的机制(称为协变逆变)与类型系统的继承机制密切相关。第六章详细讨论了继承和类型兼容性的这一方面,因此我将在那里讨论通用类型的这一方面。

类型参数的数量构成了未绑定通用类型的一部分身份。这使得可以引入具有相同名称但具有不同类型参数数量的多个类型。(类型参数数量的技术术语是arity。)

因此,你可以定义一个名为,比如说,Operation<T>的通用类,然后是另一个类,Operation<T1, T2>,还有Operation<T1, T2, T3>等等,都在同一命名空间中,而不会引入任何歧义。当你使用这些类型时,通过参数数量清楚地表明了使用的是哪种类型——例如,Operation<int>明显使用第一个,而Operation<string, double>使用第二个。出于同样的原因,一个非通用的Operation类会与具有相同名称的通用类型不同。

我的NamedContainer<T>示例对其类型参数T的实例不做任何操作——它从不调用任何方法或使用任何属性或其他成员的T。它所做的只是接受一个T作为构造函数参数,并将其存储以便稍后检索。运行时库中许多通用类型也是如此——我提到过一些集合类,它们都是对包含数据以便稍后检索这一主题的变体。

这是有原因的:通用类能够处理任何类型,因此对其类型参数的假设很少。然而,并非只能这样。你可以为你的类型参数指定约束

约束

C#允许您声明类型实参必须满足某些要求。例如,假设您希望能够根据需要创建类型的新实例。示例 4-5 展示了一个简单的类,提供了延迟构造的功能——它通过静态属性提供了一个实例,但只有在第一次读取属性时才会尝试构造该实例。

示例 4-5. 创建参数化类型的新实例
// For illustration only. Consider using Lazy<T> in a real program. public static class Deferred<T>
    `where` `T` `:` `new``(``)`
{
    private static T? _instance;

    public static T Instance
    {
        get
        {
            if (_instance == null)
            {
                `_instance` `=` `new` `T``(``)``;`
            }
            return _instance;
        }
    }
}
警告

在实践中,您不会编写这样的类,因为运行时库提供了Lazy<T>,它能够以更灵活的方式完成相同的工作。Lazy<T>可以在多线程代码中正确工作,而示例 4-5 则不行。示例 4-5 只是为了说明约束的工作原理。不要使用它!

为了使这个类能够完成它的工作,它需要能够构造一个供T类型实参使用的实例。get访问器使用了new关键字,由于没有传递参数,显然需要T提供无参构造函数。但并非所有类型都提供这样的构造函数,那么如果我们尝试使用一个没有适当构造函数的类型作为Deferred<T>的实参会发生什么?

编译器会拒绝这样做,因为它违反了这个泛型类型为T声明的约束条件。约束条件出现在类的开放大括号之前,并以where关键字开始。在示例 4-5 中的new()约束声明了T必须提供一个无参数的构造函数。

如果没有这个约束条件,示例 4-5 中的类将无法编译——在尝试构造T的新实例时会出错。泛型类型(或方法)只能使用通过约束指定的类型参数的特性,或者基本object类型定义的特性。(例如,object类型定义了ToString方法,因此您可以在任何类型的实例上调用它,而无需指定约束。)

C#仅提供了一套非常有限的约束条件。例如,您不能要求带有参数的构造函数。事实上,C#仅支持六种类型参数的约束:类型约束、引用类型约束、值类型约束、notnullunmanaged以及new()约束。我们刚刚看到了最后一种,现在让我们来看看其他几种。

类型约束

您可以约束类型参数的实参与特定类型兼容。例如,您可以使用这个特性要求实参类型实现特定接口。示例 4-6 展示了相应的语法。

示例 4-6. 使用类型约束
public class GenericComparer<T> : IComparer<T>
    `where` `T` `:` `IComparable``<``T``>`
{
    public int Compare(T? x, T? y)
    {
        if (x == null) { return y == null ? 0 : -1; }
        return x.CompareTo(y);
    }
}

在描述如何利用类型约束之前,我将简要解释此示例的目的。这个类提供了.NET 中两种值比较风格之间的桥梁。某些数据类型提供它们自己的比较逻辑,但有时将比较作为独立函数实现在其自己的类中可能更有用。这两种风格分别由IComparable<T>IComparer<T>接口代表,它们都是运行时库的一部分(分别位于SystemSystem.Collections.Generic命名空间中)。我在第三章展示了IComparer<T>——这个接口的实现可以比较两个类型为T的对象或值。该接口定义了一个Compare方法,接受两个参数,根据第一个参数分别小于、等于或大于第二个参数时返回负数、0 或正数。IComparable<T>非常相似,但其CompareTo方法只接受一个参数,因为使用此接口时,你要求一个实例比较它自己与另一个实例。

运行时库的某些集合类要求你提供一个IComparer<T>来支持排序等操作。它们使用这种模式,其中一个单独的对象执行比较,因为这比IComparable<T>模式有两个优点。首先,它使你能够使用不实现IComparable<T>的数据类型。其次,它允许你插入不同的排序顺序。(例如,假设你希望使用不区分大小写的顺序对一些字符串进行排序。string类型实现了IComparable<string>,但它提供了区分大小写、具有特定区域设置的顺序。)因此,IComparer<T>是更灵活的模式。但是,如果你使用实现了IComparable<T>的数据类型,并且你对其提供的顺序非常满意,那么当你在使用要求IComparer<T>的 API 时,你会怎么做呢?

实际上,答案是你可能只需使用.NET 专为这种情况设计的功能:Comparer<T>.Default。如果T实现了IComparable<T>,该属性将返回一个精确满足你需求的IComparer<T>。因此,在实践中,你不需要编写示例 4-6 中的代码,因为 Microsoft 已经为你编写了。然而,看到如何编写自己版本仍然很有教育意义,因为它展示了如何使用类型约束。

where关键字开头的那行声明,说明这个泛型类要求其类型参数T实现IComparable<T>。如果没有这个限制,Compare方法将无法编译——它在类型为T的参数上调用CompareTo方法。该方法并不适用于所有对象,C#编译器之所以允许这样做,只是因为我们约束T必须实现提供这种方法的接口。

接口约束有点奇怪:乍一看,似乎我们真的不应该需要它们。如果一个方法需要特定的参数来实现特定的接口,你通常会将该接口作为参数的类型。然而,示例 4-6 无法做到这一点。你可以通过尝试示例 4-7 来证明这一点。它将无法编译通过。

示例 4-7. 编译失败:未实现接口
public class GenericComparer<T> : IComparer<T>
{
    public int Compare(IComparable<T>? x, T? y)
    {
        if (x == null) { return y == null ? 0 : -1; }
        return x.CompareTo(y);
    }
}

编译器会抱怨我没有实现IComparer<T>接口的Compare方法。示例 4-7 有一个Compare方法,但其签名是错误的——第一个参数应该是T。我也可以尝试不指定约束的正确签名,就像示例 4-8 所示。

示例 4-8. 编译失败:缺少约束
public class GenericComparer<T> : IComparer<T>
{
    public int Compare(T? x, T? y)
    {
        if (x == null) { return y == null ? 0 : -1; }
        return x.CompareTo(y);
    }
}

这也无法通过编译,因为编译器找不到我试图使用的CompareTo方法。在示例 4-6 中对T的约束使编译器能够了解该方法的真正含义。

顺便说一句,类型约束不一定是接口。你可以使用任何类型。例如,你可以要求特定类型参数派生自特定基类。更微妙的是,你还可以根据另一个类型参数来定义一个参数的约束。例如,示例 4-9 要求第一个类型参数派生自第二个类型参数。

示例 4-9. 将一个参数约束为从另一个派生
public class Foo<T1, T2>
    where T1 : T2
...

类型约束非常具体——它们要求特定的继承关系或实现某些接口。然而,你可以定义稍微不那么具体的约束。

引用类型约束

可以将类型参数约束为引用类型。如示例 4-10 所示,这看起来类似于类型约束。你只需使用关键字class而不是类型名称。如果你处于启用的可空注解上下文中,此注解的含义会发生变化:它要求类型参数为非空引用类型。如果指定class?,则允许类型参数为可空或非空引用类型。

示例 4-10. 要求引用类型的约束
public class Bar<T>
    where T : class
...

这个约束会阻止将值类型,如 intdouble 或任何 struct,用作类型参数。其存在使得你的代码能够做三件否则不可能做到的事情。首先,它意味着你可以编写测试相关类型变量是否为 null 的代码。² 如果你没有将类型约束为引用类型,它始终有可能是值类型,而这些类型不能有 null 值。第二个能力是你可以将其用作 as 运算符的目标类型,我们将在第六章中讨论这一点。这实际上只是第一个特性的变体——as 关键字需要一个引用类型,因为它可能产生一个 null 结果。

注意

class 约束会阻止使用可空类型,如 int?(或 CLR 中称为 Nullable<int>)。虽然你可以测试 int? 是否为 null 并使用 as 运算符,但编译器对可空类型和引用类型在这两个操作上生成的代码完全不同。如果你使用这些特性,它就不能编译一个可以处理引用类型和可空类型的单个方法。

引用类型约束的第三个功能是能够使用某些其他泛型类型。对于泛型代码来说,将其中一个类型参数用作另一个泛型类型的参数通常很方便,如果另一个类型指定了约束,你需要在自己的类型参数上放置相同的约束。因此,如果其他某个类型指定了类约束,这可能要求你以相同的方式约束自己的某个参数。

当然,这确实引出了为什么你正在使用的类型首先需要这个约束的问题。也许它只是想要测试 null 或使用 as 运算符,但应用这个约束还有另一个原因。有时候,你只需要一个类型参数是引用类型——有些泛型方法可能能够在没有 class 约束的情况下编译,但如果与值类型一起使用,它将无法正确工作。为了说明这一点,我将描述我有时发现自己需要使用这种约束的场景。

我经常编写测试,创建被测试类的实例,并且也需要一个或多个虚拟对象来替代真实对象,以便与被测试对象交互。使用这些替身减少了每个单独测试需要执行的代码量,并且可以更轻松地验证被测试对象的行为。例如,我的测试可能需要验证我的代码在正确的时机向服务器发送消息,但我不想在单元测试期间运行真实服务器,因此我提供了一个对象,它实现了与负责传输消息的类相同的接口,但实际上并不会发送消息。被测试对象加上一个虚拟对象的组合是一种常见的模式,可能有助于将代码放入可重用的基类中。使用泛型意味着该类可以适用于被测试的类型和虚拟类型的任意组合。示例 4-11 展示了我在这些情况下有时编写的一种辅助类的简化版本。

示例 4-11. 受另一个约束的限制
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Moq;

public class TestBase<TSubject, TFake>
    where TSubject : new()
    where TFake : class
{
    public TSubject? Subject { get; private set; }
    public Mock<TFake>? Fake { get; private set; }

 [TestInitialize]
    public void Initialize()
    {
        Subject = new TSubject();
        Fake = new Mock<TFake>();
    }
}

有多种方法可以构建用于测试目的的虚拟对象。您可以编写实现与您的真实对象相同接口的新类,但也有第三方库可以生成它们。一个这样的库称为 Moq(一个 免费提供的开源项目),Mock<T> 类就是来自于这里,在 示例 4-11 中。它能够生成任何接口或任何未密封类的虚拟实现。(第六章 描述了 sealed 关键字。) 它默认提供所有成员的空实现,并且如果需要,您还可以配置更有趣的行为。您还可以验证代码在使用虚拟对象时是否按预期进行了使用。

这与约束有什么关系?Mock<T> 类在其自身类型参数 T 上指定了引用类型约束。这是因为它在运行时创建类型的动态实现的方式;这种技术仅适用于引用类型。Moq 在运行时生成类型,如果 T 是一个接口,则生成的类型将实现它;而如果 T 是一个类,则生成的类型将从它派生。³ 如果 T 是一个结构体,它将无法执行任何有用的操作,因为不能从值类型派生。这意味着当我在 示例 4-11 中使用 Mock<T> 时,我需要确保传递的任何类型参数不是结构体(即必须是引用类型)。但是,我使用的类型参数是我的类的类型参数之一:TFake。因此,我不知道那将是什么类型——这将取决于谁在使用我的类。

为了使我的类能够编译而不报错,我必须确保已满足我使用的任何泛型类型的约束。我必须保证Mock<TFake>是有效的,而唯一的方法就是在自己的类型上添加一个要求TFake为引用类型的约束。这就是在类定义的第三行中做的事情,在示例 4-11 中。如果没有这个,编译器会在引用Mock<TFake>的两行上报错。

总的来说,如果你想使用自己的类型参数作为泛型的类型参数,并指定一个约束条件,你需要在自己的类型参数上也指定相同的约束条件。

值类型约束

就像你可以约束一个类型参数为引用类型一样,你也可以约束它为值类型。如示例 4-12 所示,语法与引用类型约束类似,但使用struct关键字。

示例 4-12. 要求值类型的约束
public class Quux<T>
    where T : struct
...

到目前为止,我们只在自定义值类型的上下文中看到过struct关键字,但尽管它的外观如此,此约束允许boolenum类型以及任何内置数值类型(如int),以及自定义结构体。

.NET 的Nullable<T>类型施加了这个约束。从第三章回忆起,Nullable<T>为值类型提供了一个包装器,允许变量既可以持有一个值,也可以没有值。(通常我们使用 C#提供的特殊语法,例如,我们会写int?而不是Nullable<int>。)这种类型存在的唯一理由是为不能持有 null 值的类型提供可空性。因此,只有将此类型用于值类型才有意义——引用类型变量已经可以被设置为null而不需要这个包装器。值类型约束阻止你将Nullable<T>用于不需要它的类型。

使用非托管约束实现全面的值类型

你可以指定unmanaged作为约束条件,这要求类型参数是一个值类型,但也要求它不包含引用。该类型的所有字段必须是值类型,如果任何字段不是内置基元类型,则其类型必须进一步仅包含值类型字段,以此类推直到底部。实际上,这意味着所有实际数据必须是固定集合中的一种内置类型(基本上是所有数值类型、bool或指针)或enum类型。这主要在互操作场景中非常重要,因为符合unmanaged约束的类型可以安全高效地传递给非托管代码。

非空约束

如果使用了第三章描述的可空引用特性(在创建新项目时默认启用),则可以指定notnull约束。这允许值类型或非可空引用类型,但不允许可空引用类型。

其他特殊类型约束

第三章描述了各种特殊类型,包括枚举类型(enum)和委托类型(在第九章中详细介绍)。有时将类型参数约束为这些类型之一是很有用的。不过,这没有什么特别的技巧:你只需使用类型约束即可。所有委托类型都派生自System.Delegate,所有枚举类型都派生自System.Enum。正如示例 4-13 所示,你可以编写一个约束类型参数必须派生自其中任何一个的类型约束。

示例 4-13. 要求委托和enum类型的约束
public class RequireDelegate<T>
    where T : Delegate
{
}

public class RequireEnum<T>
    where T : Enum
{
}

多重约束

如果你希望对单个类型参数施加多重约束,可以将它们放在一个列表中,正如示例 4-14 所示。有一些限制。你不能结合使用classstructnotnullunmanaged约束 —— 这些是互斥的。如果使用了其中一个关键字,必须将其放在列表的最前面。如果存在new()约束,则必须放在最后。

示例 4-14. 多重约束
public class Spong<T>
    where T : IEnumerable<T>, IDisposable, new()
...

当你的类型具有多个类型参数时,需要为每个想要约束的类型参数编写一个where子句。实际上,我们在前面看到了这一点 —— 示例 4-11 为其两个参数定义了约束。

零值类似的值

所有类型都支持的某些特性,因此不需要约束。这包括由object基类定义的方法集,详见第三章和第六章。但在泛型代码中有时可以使用更基本的特性。

任何类型的变量都可以初始化为默认值。正如在前面章节中所看到的,有些情况下 CLR 会为我们做这件事。例如,新构造对象中的所有字段将具有已知值,即使我们没有编写字段初始化器并且没有在构造函数中提供值。同样,任何类型的新数组将所有元素初始化为已知值。CLR 通过填充相关内存区域为零来完成此操作。这的确切含义取决于数据类型。对于任何内置数值类型,该值将几乎肯定是数字0,但对于非数值类型,情况则不同。对于bool,默认值是false,对于引用类型,则为null

有时,对于通用代码而言,能够将变量设置为这种初始默认的零值可能非常有用。但在大多数情况下,您无法使用文字表达式来完成这一点。您不能将null赋给一个由类型参数指定的变量,除非该参数已被约束为引用类型。并且您也不能将字面量0赋给任何此类变量,因为当前没有一种方法来约束类型参数为数值类型。

相反,您可以使用default关键字请求任何类型的零值。(这与我们在第二章中在switch语句内部看到的相同关键字,但用法完全不同。C#保持了 C 家族传统,为每个关键字定义多个不相关的含义。)如果您编写default(*SomeType*),其中*SomeType*可以是特定类型或类型参数,则会获得该类型的默认初始值:如果是数值类型,则为0,对于任何其他类型,则为其等效值。例如,表达式default(int)的值为0default(bool)falsedefault(string)null。您可以将其与泛型类型参数一起使用,以获取相应类型参数的默认值,如示例 4-15 所示。

示例 4-15. 获取类型参数的默认(类似零值)值
static void ShowDefault<T>()
{
    Console.WriteLine(default(T));
}

在定义类型参数T的泛型类型或方法内部,表达式default(T)将产生类型T的默认零值——无论T是什么——而无需约束。因此,您可以使用示例 4-15 中的泛型方法来验证我所述的intboolstring的默认值。

注意

当启用了可空引用功能(在第三章中描述)时,编译器将考虑default(T)作为一个可能为空的值,除非您通过应用struct约束来排除引用类型的使用。

在编译器能够推断所需类型的情况下,您可以使用更简单的形式。而不是编写default(T),您只需编写default。这在示例 4-15 中是行不通的,因为Console.WriteLine几乎可以接受任何东西,所以编译器无法缩小到一个选项,但在示例 4-16 中可以正常工作,因为编译器可以看到泛型方法的返回类型是T,所以这必须需要一个default(T)。由于它可以推断出来,我们只需写default

示例 4-16. 获取推断类型的默认(类似零值)
static T? GetDefault<T>() => default;

而且,既然我刚刚向您展示了一个示例,这似乎是一个谈论泛型方法的好时机。

泛型方法

除了泛型类型,C# 还支持泛型方法。在这种情况下,泛型类型参数列表位于方法名之后,并且在方法的普通参数列表之前。示例 4-17 展示了一个具有单个类型参数的方法。它将该参数用作其返回类型,并将其用作将传递给方法的数组的元素类型。该方法返回数组中的最后一个元素,并且因为它是泛型的,所以对于任何数组元素类型都有效。

示例 4-17. 泛型方法
public static T GetLast<T>(T[] items) => items[¹];
注意

您可以在泛型类型或非泛型类型中定义泛型方法。如果泛型方法是泛型类型的成员,则包含类型的所有类型参数都在方法内部有效,以及方法特定的类型参数。

与泛型类型类似,您可以通过指定方法名和类型参数来使用泛型方法,如示例 4-18 所示。

示例 4-18. 调用泛型方法
int[] values = { 1, 2, 3 };
int last = GetLast<int>(values);

泛型方法与泛型类型类似,但类型参数仅在方法声明和方法体内有效。您可以像处理泛型类型那样指定约束条件。如示例 4-19 所示,约束条件出现在方法参数列表后和方法体前。

示例 4-19. 具有约束条件的泛型方法
public static T MakeFake<T>()
    where T : class
{
    return new Mock<T>().Object;
}

然而,泛型方法与泛型类型有一个显著的区别:您不必总是显式指定泛型方法的类型参数。

类型推断

C# 编译器通常能够推断出泛型方法的类型参数。例如,我可以通过从方法调用中移除类型参数列表来修改示例 4-18,如示例 4-20 所示。这并不改变代码的含义。

示例 4-20. 泛型方法类型参数推断
int[] values = { 1, 2, 3 };
int last = GetLast(values);

当遇到这种普通的方法调用时,如果没有同名的非泛型方法可用,编译器会开始寻找合适的泛型方法。如果示例 4-17 中的方法在作用域内,则它将是一个候选项,并且编译器将尝试推断类型参数。这是一个相当简单的情况。该方法期望一个类型为T的数组,而我们传递了一个元素类型为int的数组,因此很容易推断出这段代码应该被视为对GetLast<int>的调用。

随着更复杂的案例的出现,情况变得更加复杂。C# 规范大约有六页专门讨论类型推断算法,但它的目标始终是:在类型参数冗余时让您可以省略类型参数。顺便说一句,类型推断始终在编译时进行,因此它基于方法参数的静态类型。

对于广泛使用泛型的 API(例如 LINQ,这是第 10 章的主题),显式列出每个类型参数可能会使代码非常难以理解,因此通常依赖类型推断。如果使用匿名类型,则类型参数推断变得至关重要,因为无法显式提供类型参数。

泛型和元组

C# 的轻量级元组具有独特的语法,但在运行时看来,它们并没有什么特别之处。它们都只是一组通用类型的实例。看看示例 4-21。这里使用 (int, int) 作为局部变量的类型,表示它是一个包含两个 int 值的元组。

示例 4-21. 正常方式声明元组变量
(int, int) p = (42, 99);

现在看看示例 4-22。这里使用了位于 System 命名空间中的 ValueTuple<int, int> 类型。但这与示例 4-21 中的声明完全等效。在 Visual Studio 或 VS Code 中,如果你将鼠标悬停在 p2 变量上,它将报告其类型为 (int, int)

示例 4-22. 声明带有其底层类型的元组变量
ValueTuple<int, int> p2 = (42, 99);

C# 的特殊语法允许给元组元素命名,这是其特有的一点。ValueTuple 系列为其元素命名为 Item1Item2Item3 等,但在 C# 中我们可以选择其他名称。当你声明一个带有命名元组元素的局部变量时,这些名称在 C# 中实际上是虚构的——在运行时完全没有表现。但是,当一个方法返回一个元组时,例如在示例 4-23 中,情况就不同了:这些名称需要可见,以便消费此方法的代码可以使用相同的名称。即使此方法位于我代码已引用的某个库组件中,我也希望能够编写 Pos().X,而不是必须使用 Pos().Item1

示例 4-23. 返回一个元组
public static (int X, int Y) Pos() => (10, 20);

为了使这一点实现,编译器将一个名为 TupleElementNames 的属性应用于方法的返回值,其中包含一个列出要使用的属性名称的数组。(第 14 章描述了属性。) 你实际上不能自己编写能够执行此操作的代码:如果你编写一个返回 ValueTuple<int, int> 的方法,并尝试将 TupleElementNamesAttribute 作为 return 属性应用,编译器将生成错误消息,告诉你不要直接使用此属性,而是使用元组语法。但是编译器正是通过该属性来报告元组元素的名称。

请注意,运行库中还有另一组元组类型,Tuple<T>Tuple<T1, T2>等。这些几乎与ValueTuple系列看起来相同。不同之处在于,Tuple系列的泛型类型都是类,而所有ValueTuple类型都是结构体。C#的轻量级元组语法仅使用ValueTuple系列。尽管如此,Tuple系列在运行库中已经存在很长时间了,因此在旧代码中经常看到它们,这些代码需要将一组值捆绑在一起而不需要为此定义新类型。

在泛型内部

如果你熟悉 C++模板,现在应该已经注意到 C#泛型与模板有很大不同。表面上看,它们有一些相似之处,并且可以用类似的方式使用——例如,都适用于实现集合类。然而,有些基于模板的技术在 C#中根本行不通,比如示例 4-24 中的代码。

示例 4-24. C# 泛型中无法工作的模板技术
public static T Add<T>(T x, T y)
{
    return x + y;  // Will not compile
}

在 C++模板中可以做这种事情,但在 C#中不行,并且无法通过约束完全修复。你可以添加一个类型约束,要求T从某个类型派生或实现某个定义了自定义+运算符的接口,这样就可以编译通过,但这相当有限——它只适用于从该基类型派生的类型。在 C++中,你可以编写一个模板,它将任何支持加法的类型的两个项相加在一起,无论是内置类型还是自定义类型。此外,C++模板不需要约束;编译器能够自行判断特定类型是否适用作为模板参数。

这个问题并非特定于算术运算。根本问题在于,由于泛型代码依赖于约束来确定其类型参数上可用的操作,它只能使用作为接口成员或共享基类的特性。如果.NET 中的算术运算是基于接口的,那么可以定义一个需要该接口的约束。但是操作符都是静态方法,尽管接口可以包含静态成员,⁴ 但没有支持的方法让各个类型提供自己的实现——允许每个类型提供自己接口实现的动态调度机制仅适用于实例成员。⁵

C# 泛型的限制是由其设计原理决定的,因此理解其机制非常有用。(顺便说一下,这些限制并不特定于任何特定的 CLR 实现。它们是泛型如何融入.NET 运行时设计的必然结果。)

通用方法和类型在编译时并不知道将用作参数的具体类型。这是 C#泛型与 C++模板之间的根本区别——在 C++中,编译器可以看到模板的每个实例化。但在 C#中,你可以在编译代码很久之后,实例化泛型类型,而无需访问任何相关源代码。毕竟,微软多年前就写了通用的List<T>类,但你今天完全可以写一个全新的类,并作为类型参数嵌入其中。 (你可能会指出 C++标准库的std::vector存在更久。然而,C++编译器可以访问定义类的源文件,而对于 C#和List<T>来说则不然。C#只看到已编译的库。)

结果是,C#编译器需要足够的信息来在编译泛型代码时生成类型安全的代码。看看示例 4-24。它无法知道这里的+运算符具体是什么意思,因为对于不同的类型它可能是不同的。对于内置数值类型,该代码需要编译为执行加法的特定中间语言(IL)指令。如果该代码位于检查上下文中(即使用第 2 章中显示的checked关键字),我们可能已经遇到问题,因为使用溢出检查的整数加法代码会为有符号和无符号整数使用不同的 IL 操作码。此外,由于这是一个泛型方法,我们可能根本不处理内置数值类型——也许我们正在处理定义了自定义+运算符的类型,在这种情况下,编译器需要生成一个方法调用。(自定义运算符实际上就是方法。)或者如果相关类型不支持加法操作,编译器应该生成一个错误。

对于编译简单的加法表达式,存在几种可能的结果,这取决于实际涉及的类型。当编译器知道类型时,这很好,但它必须在不知道将用作参数的类型的情况下编译泛型类型和方法的代码。

或许你会认为微软可以支持一种类似于泛型代码的暂定半编译格式,从某种意义上说,它确实做到了。在引入泛型时,微软修改了类型系统、文件格式和 IL 指令,允许泛型代码使用代表类型参数的占位符,以便在类型完全构造时填充。那么为什么不扩展以处理运算符?为什么不让编译器在编译试图使用泛型类型的代码时生成错误,而不是坚持在编译泛型代码本身时生成错误呢?好吧,事实证明,你可以在运行时插入新的类型参数集合——我们将在第十三章看到的反射 API 允许你构造泛型类型。在明显出现错误的时间点可能没有可用的编译器,因为并非所有 .NET 版本都提供了 C# 编译器的副本。无论如何,如果一个泛型类是用 C# 编写的,但被完全不同的语言消费,也许这种语言不支持操作符重载,那么该使用哪种语言的规则来决定对+操作符的处理呢?应该是编写泛型代码的语言还是编写类型参数的语言呢?(如果有多个类型参数,并且每个参数使用不同语言编写的类型,那又该怎么办呢?)或者规则应该来自于决定将类型参数插入泛型类型或方法的语言,但是如果一段泛型代码将其参数传递给其他泛型实体呢?即使你能决定哪种方法最好,这也假设在运行时确定一行代码的含义所使用的规则是可用的,这一假设再次因为运行代码的机器上可能没有相关的编译器而遇到困难。

.NET 泛型通过在泛型代码编译时使用编写泛型代码的语言的规则,要求完全定义泛型代码的含义来解决这个问题。如果泛型代码涉及使用方法或其他成员,它们必须在编译时静态解析(即这些成员的标识必须在编译时精确确定)。关键在于,这意味着泛型代码本身的编译时间,而不是消费泛型代码的代码的编译时间。这些要求解释了为什么 C# 泛型不像 C++ 使用的消费者编译时替换模型那样灵活。回报是,你可以将泛型编译成二进制形式的库,并且它们可以被支持泛型的任何 .NET 语言使用,具有完全可预测的行为。

摘要

泛型使我们能够编写带有类型参数的类型和方法,在编译时可以填充这些参数,从而生成适用于特定类型的不同版本的类型或方法。在它们首次引入时,泛型的最重要用例之一是使得编写类型安全的集合类成为可能,比如List<T>。我们将在下一章节中查看一些这样的集合类型。

¹ 在说泛型类型名称时,惯例是使用“of”这个词,比如“List of T”或“List of int”。

² 即使在启用了可空注解上下文中使用了普通的class约束,也是允许的。可空引用特性并不能完全保证非空性,因此允许与null进行比较。

³ Moq 依赖于 Castle 项目的动态代理功能来生成这种类型。如果您想在您的代码中使用类似的东西,可以在Castle 项目找到它。

⁴ 静态接口成员在.NET Framework 中不可用。

⁵ 已经有一个提案用于为静态接口成员添加动态调度。尽管它不是官方的 C# 10.0 的一部分,但.NET 6.0 SDK 包含了一个预览实现。您可以通过将EnablePreviewFeatures项目属性设置为 true 来尝试它。如果在未来版本中得到支持,也许我们会看到一个IAddable<T>

第五章:集合

大多数程序需要处理多个数据片段。例如,你的代码可能需要迭代一些交易来计算账户的余额,或者在社交媒体 Web 应用程序中显示最近的消息,或者更新游戏中角色的位置。在大多数应用程序中,处理信息集合的能力可能是非常重要的。

C# 提供了一种简单的集合类型,称为数组。CLR 的类型系统本身支持数组,因此它们很高效,但对于某些场景来说可能太基础了,因此运行库在数组提供的基础服务上构建了更强大和灵活的集合类型。我会从数组开始讲起,因为它们是大多数集合类的基础。

数组

数组是一个包含特定类型多个元素的对象。每个元素都是一个类似于字段的存储位置,但不同于字段的是,数组元素仅仅是按数字编号。数组的元素数量在其生命周期内是固定的,因此在创建数组时必须指定大小。示例 5-1 展示了创建新数组的语法。

示例 5-1. 创建数组
int[] numbers = new int[10];
string[] strings = new string[numbers.Length];

与所有对象一样,我们使用 new 关键字和类型名称构造数组,但是与构造函数参数用括号不同的是,我们使用包含数组大小的方括号。正如示例所示,定义大小的表达式可以是一个常量,但不必如此——第二个数组的大小将通过在运行时评估 numbers.Length 来确定。在这种情况下,第二个数组将有 10 个元素,因为我们使用了第一个数组的 Length 属性。所有数组都有这个只读属性,它返回数组中的总元素数。

Length 属性的类型是 int,这意味着它可以处理多达约 21 亿个元素的数组。在 32 位进程中,数组大小的限制因素可能是可用地址空间,但在.NET 支持 64 位进程后,可以使用更大的数组,因此还有一个 LongLength 属性,类型为 long。然而,你不经常看到它被使用,因为运行时当前不支持创建超过 2,147,483,591 (0x7FFFFFC7) 个元素的数组。因此,只有矩形多维数组(本章后面描述)可以包含比 Length 报告的更多元素。甚至这些数组在当前版本的.NET 上也有上限,为 4,294,967,295 (0xFFFFFFFF) 个元素。

注意

如果你使用的是.NET Framework,你将首先遇到另一个限制:单个数组通常不能占用超过 2 GB 的内存。(这是任何单个对象大小的上限。实际上,通常只有数组会遇到这个限制,尽管你可能会用特别长的字符串达到这个限制。)你可以通过在项目的App.config文件的<runtime>部分内添加<gcAllowVeryLargeObjects enabled="true" />元素来克服这一限制。前面段落中的限制仍然适用,另外还有一个额外的限制:元素类型不是byte的数组不能超过 0x7FFEFFFF 个元素。即便如此,这些限制要比 2 GB 的上限宽松得多。

在示例 5-1 中,我打破了避免变量声明中多余类型名称的常规规则。初始化表达式清楚地表明变量分别是intstring数组,所以我通常会对这种代码使用var,但我在这里做了一个例外,以便展示如何写出数组类型的名称。数组类型在其自身的权利中是不同的类型,如果我们想引用特定元素类型的单维数组类型,我们将在元素类型名称之后放置[]

所有的数组类型都派生自一个名为System.Array的共同基类。这个类定义了LengthLongLength属性以及其他我们接下来会看到的成员。你可以在所有可以使用其他类型的地方使用数组类型。所以你可以声明一个类型为string[]的字段或者方法参数。你也可以将数组类型用作泛型类型参数。例如,IEnumerable<int[]>将会是一个整数数组的序列(每个数组可能大小不同)。

无论元素类型如何,数组类型始终是引用类型。尽管如此,在引用类型和值类型元素之间的选择在数组的行为上有重大差异。正如在第三章中讨论的,当对象具有值类型字段时,该值本身存在于为对象分配的内存中。对于数组也是如此——当元素为值类型时,值存在于数组元素本身,但对于引用类型,元素只包含引用。每个引用类型的实例都有其自己的标识,由于多个变量可能最终都引用该实例,CLR 需要独立管理其生存周期,因此它将拥有自己独立的内存块。因此,虽然包含 1,000 个int值的数组可以全部存在于一个连续的内存块中,但对于引用类型,数组只包含引用,而不包含实际实例。包含 1,000 个不同字符串的数组将需要 1,001 个堆块——一个用于数组本身,每个字符串一个。

在使用引用类型元素时,你不必让引用数组中的每个元素都引用不同的对象。你可以将任意数量的元素设置为null,而且还可以自由地使多个元素引用同一个对象。这只是数组元素中引用工作方式的另一种变化,它与局部变量和字段中的引用工作方式基本相同。

要访问数组中的元素,我们使用包含我们想要使用的元素索引的方括号。索引是从零开始的。示例 5-2 展示了一些示例。

示例 5-2. 访问数组元素
// Continued from Example 5-1
numbers[0] = 42;
numbers[1] = numbers.Length;
numbers[2] = numbers[0] + numbers[1];
numbers[numbers.Length - 1] = 99;

与数组大小在构建时一样,数组索引可以是一个常量,但也可以是在运行时计算的更复杂的表达式。实际上,直接位于开放括号之前的部分也是如此。在示例 5-2 中,我只是使用了一个变量名来引用一个数组,但是你可以在任何数组类型的表达式之后使用括号。示例 5-3 检索由方法调用返回的数组的第一个元素。(示例的细节并不严格相关,但如果你在想,它找到与定义对象类型的组件相关联的版权信息。例如,如果你将一个string传递给方法,它将返回“© Microsoft Corporation. All rights reserved.” 这使用了反射 API 和自定义属性,这些是第 13 和第十四章的主题。)

示例 5-3. 复杂的数组访问
public static string GetCopyrightForType(object o)
{
    Assembly asm = o.GetType().Assembly;
    var copyrightAttribute = (AssemblyCopyrightAttribute)
        `asm``.``GetCustomAttributes``(``typeof``(``AssemblyCopyrightAttribute``)``,` `true``)``[``0``]``;`
    return copyrightAttribute.Copyright;
}

涉及数组元素访问的表达式是特殊的,因为 C#将它们视为一种变量。这意味着与局部变量和字段一样,无论是简单的表达式(如示例 5-2 中的表达式)还是更复杂的表达式(如示例 5-3 中的表达式),你都可以将它们用作赋值语句的左操作数。你还可以使用ref关键字(如第 3 章所述)将特定元素的引用传递给方法,将其存储在ref局部变量中,或者将其作为具有ref返回类型的方法的返回值。

CLR 始终检查索引与数组大小是否匹配。如果尝试使用负索引或大于或等于数组长度的索引,运行时将抛出IndexOutOfRangeException异常。

尽管数组的大小固定不变,但其内容始终可修改——并不存在只读数组。 (正如我们将在“ReadOnlyCollection”中看到的,.NET 提供了一个可以作为数组的只读外观的类。)当然,您可以创建一个具有不可变元素类型的数组,这将阻止您在原地修改元素。因此,使用.NET 提供的不可变Complex值类型的示例 5-4 将无法编译。

示例 5-4. 如何不修改具有不可变元素的数组
var values = new Complex[10];
// These lines both cause compiler errors:
values[0].Real = 10;
values[0].Imaginary = 1;

编译器抱怨因为RealImaginary属性是只读的;Complex不提供任何修改其值的方法。尽管如此,您仍然可以修改数组:即使无法就地修改现有元素,您始终可以通过提供不同的值来覆盖它,正如示例 5-5 所示。

示例 5-5. 修改具有不可变元素的数组
var values = new Complex[10];
values[0] = new Complex(10, 1);

无论如何,只读数组在任何情况下都没有什么用,因为所有数组最初都填充了默认值,您无法指定。CLR 会用零填充新数组的内存,因此您将看到0nullfalse,具体取决于数组的元素类型。

警告

C# 10.0 添加了为struct编写零参数构造函数的能力。您可能期望数组创建自动调用此类构造函数。事实并非如此。

对于某些应用程序来说,全零(或等效)内容可能是数组的有用初始状态,但在某些情况下,您可能需要在开始工作之前设置一些其他内容。

数组初始化

初始化数组最直接的方法是依次为每个元素分配值。示例 5-6 创建了一个string数组,由于string是引用类型,创建五个元素的数组并不会创建五个字符串。我们的数组最初有五个空值。(即使您已启用 C#的可空引用功能,如第三章所述。不幸的是,数组初始化是使该功能无法提供绝对非空性保证的漏洞之一。)因此,示例继续为每个数组元素填充了对字符串的引用。

示例 5-6. 繁琐的数组初始化
var workingWeekDayNames = new string[5];
workingWeekDayNames[0] = "Monday";
workingWeekDayNames[1] = "Tuesday";
workingWeekDayNames[2] = "Wednesday";
workingWeekDayNames[3] = "Thursday";
workingWeekDayNames[4] = "Friday";

这种方法虽然可行,但冗长了。C#支持一种更简洁的语法,可以实现相同的效果,详见示例 5-7。编译器将其转换为与示例 5-6 类似的代码。

示例 5-7. 数组初始化语法
var workingWeekDayNames = new string[]
    { "Monday", "Tuesday", "Wednesday", "Thursday", "Friday" };

你可以更进一步。示例 5-8 显示,如果在变量声明中明确指定类型,你可以只写初始化列表,省略 new 关键字。顺便说一句,这只在初始化表达式中有效;在其他表达式中(如赋值或方法参数),你不能使用这种语法创建数组。(在 示例 5-7 中更详细的初始化表达式在所有这些上下文中都有效。)

示例 5-8. 更短的数组初始化语法
string[] workingWeekDayNames =
    { "Monday", "Tuesday", "Wednesday", "Thursday", "Friday" };

我们还可以更进一步:如果数组初始化列表内的所有表达式都是相同类型,编译器可以推断出数组类型,因此我们可以只写 new[] 而不需要显式元素类型。示例 5-9 就是这样做的。

示例 5-9. 元素类型推断的数组初始化语法
var workingWeekDayNames = new[]
    { "Monday", "Tuesday", "Wednesday", "Thursday", "Friday" };

实际上,这比 示例 5-8 稍微长一些。但是,与 示例 5-7 一样,这种风格并不局限于变量初始化。例如,在需要将数组作为参数传递给方法时,也可以使用它。如果你创建的数组只会被传递到方法中并且不再被引用,你可能不想声明一个变量来引用它。直接在参数列表中写数组可能更加简洁。示例 5-10 就是使用这种技术将字符串数组传递给方法的示例。

示例 5-10. 作为参数的数组
SetHeaders(new[] { "Monday", "Tuesday", "Wednesday", "Thursday", "Friday" });

搜索和排序

有时候,你可能不知道需要的数组元素的索引。例如,假设你正在编写一个显示最近使用文件列表的应用程序。每次用户在你的应用程序中打开一个文件时,你都希望将该文件移动到列表顶部,并且你需要检测文件是否已经在列表中,以避免出现多次显示。如果用户恰好使用你的最近文件列表打开文件,你已经知道它在列表中且位于哪个偏移量。但是如果用户以其他方式打开文件呢?在这种情况下,你有一个文件名,需要找出它在列表中的位置,如果存在的话。

数组可以帮助你在这种情况下找到你想要的项。有些方法会逐个检查每个元素,停在第一个匹配项上,还有一些方法可以在特定顺序存储其元素的数组中更快地工作。为了帮助处理这种情况,还有一些方法可以对数组内容进行排序,按照你需要的任何顺序排序。

静态的 Array.IndexOf 方法提供了搜索元素的最简单方法。它不需要你的数组元素处于任何特定顺序:你只需传递要搜索的数组和你要查找的值,它将遍历元素直到找到与你想要的值相等的元素。它返回找到的第一个匹配元素的索引,如果在数组末尾没有找到匹配项则返回 −1。示例 5-11 展示了如何在更新最近打开文件列表的逻辑中使用此方法。

示例 5-11. 使用 IndexOf 进行搜索
int recentFileListIndex = Array.IndexOf(myRecentFiles, openedFile);
if (recentFileListIndex < 0)
{
    AddNewRecentEntry(openedFile);
}
else
{
    MoveExistingRecentEntryToTop(recentFileListIndex);
}

该示例从数组的开头开始搜索,但你也有其他选项。IndexOf 方法是重载的,你可以传递一个起始搜索的索引,还可以选择传递第二个数字,表示在放弃搜索之前要查看的元素数。还有一个 LastIndexOf 方法,它是反向工作的。如果你不指定索引,它将从数组的末尾开始向前工作。与 IndexOf 类似,你可以提供一个或两个额外的参数,指示你想要开始搜索的偏移量以及要检查的元素数。

如果你确切知道你要查找的值,这些方法都很好用。但通常情况下,你可能需要更加灵活:你可能想找到符合某些特定条件的第一个(或最后一个)元素。例如,假设你有一个表示直方图中箱子值的数组。找到第一个非空箱子可能是有用的。因此,你不是在寻找特定值,而是想找到第一个值不为零的元素。示例 5-12 展示了如何使用 FindIndex 方法来定位第一个符合条件的条目。

示例 5-12. 使用 FindIndex 进行搜索
public static int GetIndexOfFirstNonEmptyBin(int[] bins)
    => Array.FindIndex(bins, IsNonZero);

private static bool IsNonZero(int value) => value != 0;

我的 IsNonZero 方法包含决定任何特定元素是否匹配的逻辑,并将该方法作为参数传递给 FindIndex。你可以传递任何具有合适签名的方法 —— FindIndex 需要一个接受数组元素类型的实例并返回 bool 的方法。(严格来说,它接受一个 Predicate<T>,这是一种委托,我将在第九章讨论。)由于任何具有适当签名的方法都可以,我们可以使我们的搜索条件简单或者复杂,随心所欲。

顺便提一句,这个特定示例的逻辑如此简单,以至于为条件单独编写一个方法可能有些大材小用。对于这类简单情况,你几乎肯定会使用 lambda 语法(使用 => 表示表达式代表内联函数),而不是单独编写方法。这也是我将在 第九章 中讨论的内容,所以这有些超前,但我只是展示一下它的样子,因为更为简洁。示例 5-13 的效果与 示例 5-12 完全相同,但不需要我们显式声明和编写一个完整的额外方法。(并且在撰写本文时,它也更高效,因为使用 lambda,编译器生成代码以重用它创建的 Predicate<T> 对象,而 示例 5-12 每次都会构造一个新的对象。)

示例 5-13. 使用 lambda 和 FindIndex
public static int GetIndexOfFirstNonEmptyBin(int[] bins)
    => Array.FindIndex(bins, value => value != 0);

IndexOf 类似,FindIndex 提供了重载,允许您指定开始搜索的偏移量和在放弃之前检查的元素数量。Array 类还提供了 FindLastIndex,它向后工作,对应于 LastIndexOf,就像 FindIndex 对应于 IndexOf 一样。

当您搜索满足某些特定条件的数组条目时,您可能并不那么关心匹配元素的索引,您可能只需要知道第一个匹配的值。显然,获得这个值非常容易:您可以结合数组索引语法使用 FindIndex 返回的值。然而,您并不需要这样做,因为 Array 类提供了 FindFindLast 方法,以完全相同的方式进行搜索,但返回第一个或最后一个匹配的值,而不是返回找到该值的索引。

数组可能包含多个满足您条件的项,您可能希望找到它们全部。您可以编写一个循环调用 FindIndex,将前一个匹配项的索引加一作为下一个搜索的起点,重复此过程,直到达到数组的末尾或得到一个结果为 -1,表示没有找到更多的匹配项。如果您只对知道所有匹配值感兴趣,而不需要准确知道这些值在数组中的位置,您可以使用 示例 5-14 中展示的 FindAll 方法来完成所有工作。

示例 5-14. 使用 FindAll 查找多个项
public static T[] GetNonNullItems<T>(T[] items) where T : class
    => Array.FindAll(items, value => value != null);

这个方法接受任何包含引用类型元素的数组,并返回一个仅包含该数组中非空元素的数组。

到目前为止,我展示的所有搜索方法都是按顺序遍历数组的元素,逐个测试每个元素。这种方法已经足够有效,但对于大型数组来说,可能会显得不必要地昂贵,特别是在比较相对复杂的情况下。即使是简单的比较,如果你需要处理数百万个元素的数组,这种搜索方式也可能耗费足够长的时间以至于引入明显的延迟。但是,我们可以做得更好。例如,如果给定一个按升序排序的值数组,二分查找 的性能可以提高数个数量级。示例 5-15 展示了两种方法。首先,Sort 方法将数字数组按升序排序。然后,如果我们有这样一个已排序的数组,我们可以将其传递给 Find 方法,该方法使用 Array.BinarySearch 方法。

示例 5-15. 排序数组和 BinarySearch
void Sort(int[] numbers)
{
    Array.Sort(numbers);
}

int Find(int[] numbers, int searchFor)
{
    return Array.BinarySearch(numbers, searchFor);
}

二分查找是一种广泛使用的算法,利用输入已排序的事实,能够在每一步排除一半的数组。它从数组中间开始。如果恰好这个值就是我们需要的值,搜索可以停止,否则,根据它找到的值是高于还是低于我们想要的值,它可以立即知道值会在数组的哪一半(如果存在的话)。然后它跳到剩余一半的中间,如果那不是正确的值,再次可以确定哪一部分将包含目标。在每一步中,它通过一半来缩小搜索范围,几次减半后,搜索将缩小到单个项。如果这不是它正在寻找的值,那么所需的项就不存在。

提示

BinarySearch 在未找到值时会生成负数。在这些情况下,这个二分搜索过程将会在最接近我们正在寻找的值处结束,并且这可能是有用的信息。因此,负数仍然告诉我们搜索失败,但这个数是最接近匹配的索引的负数。

二分查找比简单的线性搜索更复杂,但对于大数组来说,它效果很明显,因为需要的迭代次数大大减少。给定一个包含 100,000,000 个元素的数组,它只需执行 27 步,而不是 100,000,000 步。显然,对于较小的数组,改进有限,而二分查找的相对复杂性达到了一定的最小数组大小,超过这个最小数组大小时,线性搜索可能更快。但对于包含 100,000,000 个int元素的数组,二分查找肯定是明显的胜利者。需要最多工作的情况是它找不到匹配项(产生负结果),在这些情况下,BinarySearchArray.IndexOf执行的线性搜索快了超过 19,000 倍。但是,你需要注意:二分查找仅适用于已经排序的数据,将数据排序的成本可能会超过改进搜索速度的好处。对于包含 100,000,000 个int的数组,你需要在成本超过改进搜索速度之前进行大约 500 次搜索,并且,当然,只有在这期间没有任何强制您重新排序的变化时才有效。在性能调整中,查看整体场景而不仅仅是微基准测试非常重要。

顺便说一下,Array.BinarySearch提供了用于在数组某个子段内搜索的重载,类似于我们看到的其他搜索方法。它还允许您自定义比较逻辑。这与我在早期章节展示的比较接口一起工作。默认情况下,它将使用数组元素自身提供的IComparable<T>实现,但您可以提供自定义的IComparer<T>。我用来对元素进行排序的Array.Sort方法也支持缩小范围和使用自定义比较逻辑。

除了Array类本身提供的搜索和排序方法之外,还有其他的搜索和排序方法。所有的数组都实现了IEnumerable<T>(其中T是数组的元素类型),这意味着你也可以使用.NET 的LINQ to Objects功能提供的任何操作。这为搜索、排序、分组、过滤以及一般对象集合处理提供了更广泛的功能;第十章将详细描述这些功能。数组在.NET 中存在的时间比 LINQ 更长,这是功能重叠的一个原因,但数组提供了自己的标准 LINQ 操作符等价物,有时会更高效,因为 LINQ 是一个更通用的解决方案。

多维数组

到目前为止,我展示的数组都是一维的,但 C#支持两种多维形式:交错数组矩形数组

交错数组

不规则数组简单地是数组的数组。这种类型的数组的存在是数组类型与其元素类型不同的自然结果。因为int[]是一种类型,您可以将其用作另一个数组的元素类型。示例 5-16 展示了几乎毫不意外的语法。

示例 5-16. 创建不规则数组
int[][] arrays = new int[5][]
{
    new[] { 1, 2 },
    new[] { 1, 2, 3, 4, 5, 6 },
    new[] { 1, 2, 4 },
    new[] { 1 },
    new[] { 1, 2, 3, 4, 5 }
};

再次,我打破了通常的变量声明规则 —— 通常我会在第一行使用var,因为类型从初始化器中就能明确,但我想展示声明变量和构造数组的语法。在示例 5-16 中还有第二个冗余之处:使用数组初始化器语法时,不必明确指定大小,因为编译器会自动计算。我已经利用了这一点来处理嵌套数组,但为了显示大小(5)出现的位置,我明确为外部数组设置了大小,因为这可能不是您期望的位置。

不规则数组的类型名称足够简单。一般而言,数组类型的形式是*ElementType*[],因此如果元素类型是int[],我们期望结果数组类型应写成int[][],这也是我们看到的。构造函数的语法稍微奇特一些。它声明了一个包含五个数组的数组,乍一看,new int[5][]似乎是表达这种意图的完全合理的方式。对于不规则数组的数组索引语法保持一致;我们可以写arrays[1][3],它获取这五个数组中的第二个数组,然后从该第二个数组中检索第四个元素。(顺便说一句,这不是专门的语法 —— 这里没有需要特别处理的地方,因为任何求值为数组的表达式都可以跟随方括号中的索引。表达式arrays[1]求值为一个int[]数组,所以我们可以跟随[3]。)

然而,new关键字确实会特殊对待不规则数组。它使它们看起来与数组元素访问语法一致,但必须稍微扭曲一下才能做到这一点。对于一维数组,构造新数组的模式是new *ElementType*[*length*],因此创建五个元素的数组,您希望写成new *ElementType*[5]。如果您要创建的是int数组,您是否期望看到int[]代替*ElementType*?这将意味着语法应该是new int[][5]

这看起来是逻辑的,但似乎是错误的,这是因为数组类型的语法本身实际上是反向的。数组是构造类型,就像泛型一样。对于泛型,我们从中构造实际类型的泛型类型名称在类型参数之前(例如,List<int> 使用泛型 List<T> 类型,并用 int 类型参数构造它)。如果数组具有类似泛型的语法,我们可能会期望看到 array<int> 表示一维数组,array<array<int>> 表示二维数组,依此类推——元素类型会在表示我们想要数组的部分之后出现。但数组类型反其道而行——数组性由 [] 字符表示,因此元素类型首先出现。这就是为什么数组构造的假设逻辑正确的语法看起来很奇怪。C# 避免了这种奇怪感,不过于强调逻辑,在大多数人期望的地方放置尺寸而不是应该放置的地方。

注意

语法可以显而易见地扩展——例如,int[][][] 表示类型,new int[5][][] 表示构造。C# 不定义维度数量的特定限制,但存在一些特定于实现的运行时限制。(微软的编译器在我要求创建一个 5000 维 jagged 数组时毫不畏惧,但 CLR 拒绝加载生成的程序。事实上,它不会加载超过 1166 维的任何东西。)

示例 5-16 用五个一维 int[] 数组初始化数组。代码的布局应该很清楚地说明为什么这种类型的数组被称为 jagged:每一行长度不同。对于数组的数组,没有要求是矩形布局。我可以进一步讲解。数组是引用类型,所以我可以将一些行设置为 null。如果我放弃数组初始化器语法,逐个初始化数组元素,我可以决定让一些一维 int[] 数组出现在多行中。

因为这个 jagged 数组中的每一行都包含一个数组,所以这里我最终有了六个对象——五个 int[] 数组,然后是包含对它们引用的 int[][] 数组。如果引入更多维度,将会得到更多数组。对于某些工作来说,非矩形和大量对象可能会成为问题,这就是为什么 C# 支持另一种多维数组的原因。

矩形数组

矩形数组是支持多维索引的单个数组对象。如果 C#没有提供多维数组,我们可以按照惯例构建类似它们的东西。如果您想要一个包含 10 行和 5 列的数组,您可以构造一个具有 50 个元素的一维数组,然后使用像myArray[i + (5 * j)]这样的代码来访问它,其中i是列索引,j是行索引。那将是一个您选择将其视为二维的数组,尽管它实际上只是一个大的连续块。矩形数组本质上是相同的概念,但是在其中 C#为您做了这项工作。 示例 5-17 展示了如何声明和构造矩形数组。

注意

矩形数组不仅仅是便利性问题。还有一个类型安全的方面:int[,]是与int[]int[,,]不同的类型,因此如果您编写一个期望二维矩形数组的方法,C#将不允许传递其他类型。

示例 5-17. 矩形数组
int[,] grid = new int[5, 10];
var smallerGrid = new int[,]
{
    { 1, 2, 3, 4 },
    { 2, 3, 4, 5 },
    { 3, 4, 5, 6 }
};

矩形数组类型名称仅使用一对方括号,无论它们有多少维。括号内部逗号的数量表示维度的数量,因此这些具有一个逗号的示例是二维的。与不规则数组相比,运行时似乎对矩形数组的维度数设置了一个更低的限制。在.NET 6.0 中,试图使用超过 32 个维度的矩形数组的程序将无法加载。

初始化语法与多维数组非常相似(参见示例 5-16),但我没有像那样用new[]来开始每一行,因为这是一个大数组,而不是数组的数组。 示例 5-17 中的数字形成了一个明显是矩形的形状,如果您尝试使事情变得不规则(使用不同的行大小),编译器将报告错误。这种情况也适用于更高的维度。如果您想要一个三维的“矩形”数组,它将需要是一个cuboid。 示例 5-18 展示了一个 cuboid 数组。您可以将初始化器视为由两个矩形切片组成的 cuboid 的列表。而且您可以升级,使用hypercuboid数组(尽管无论您使用多少维度,它们仍然被称为矩形数组)。

示例 5-18. 一个 2 × 3 × 5 的 cuboid“矩形”数组
var cuboid = new int[,,]
{
    {
        { 1, 2, 3, 4, 5 },
        { 2, 3, 4, 5, 6 },
        { 3, 4, 5, 6, 7 }
    },
    {
        { 2, 3, 4, 5, 6 },
        { 3, 4, 5, 6, 7 },
        { 4, 5, 6, 7, 8 }
    }
};

访问矩形数组的语法足够可预测。如果来自示例 5-17 的第二个变量在作用域内,我们可以写smallerGrid[2, 3]来访问数组中的最后一项;与单维数组一样,索引是从零开始的,因此这指的是第三行的第四个项目。

请记住,数组的 Length 属性返回数组中的元素总数。由于矩形数组将所有元素存储在单个数组中(而不是引用其他数组),因此它将返回所有维度大小的乘积。例如,一个具有 5 行和 10 列的矩形数组的 Length 为 50。如果你想在运行时发现特定维度的大小,请使用 GetLength 方法,该方法接受一个 int 参数,指示你想知道大小的维度。

复制和调整大小

有时你会希望在数组中移动数据块。也许你想在数组的中间插入一个项目,将其后的项目向上移动一个位置(并丢失最后一个元素,因为数组大小是固定的)。或者你可能想要将数据从一个数组移动到另一个数组,也许它们大小不同。

静态 Array.Copy 方法接受两个数组的引用,以及一个指示要复制多少个元素的数字。它提供了多个重载,以便你可以指定在两个数组中开始复制的位置。(更简单的重载从每个数组的第一个元素开始。)你可以将源数组和目标数组作为同一个数组传递,并且它会正确处理重叠:复制动作就像首先将所有元素复制到临时位置,然后开始将它们写入目标位置。

警告

除了静态的 Copy 方法外,Array 类还定义了非静态的 CopyTo 方法,它将整个数组复制到目标数组中,从指定的偏移位置开始。此方法存在的原因是因为所有数组实现了某些集合接口,包括 ICollection<T>(其中 T 是数组的元素类型),该接口定义了这个 CopyTo 方法。它比 Copy 方法不够灵活 —— CopyTo 不能复制数组的子范围。在两种方法都能使用的情况下,文档建议使用 Array.Copy —— CopyTo 只是为了通用代码的利益,可以与任何集合接口的实现一起使用。

当需要处理可变数量的数据时,将元素从一个数组复制到另一个数组可能是必要的。通常情况下,你会分配一个比最初需要的更大的数组,如果最终填满了,你将需要一个新的更大数组,并且需要将旧数组的内容复制到新数组中。事实上,Array 类可以通过其 Resize 方法为一维数组执行此操作。方法名有些误导,因为数组无法调整大小,所以它会分配一个新的数组,并将旧数组的数据复制到其中。Resize 可以构建一个更大或更小的数组,如果你要求一个更小的数组,它只会复制尽可能多的元素。

当我谈论复制数组数据的方法时,我应该提到Reverse,它简单地颠倒数组元素的顺序。还有,虽然这不严格属于复制的范畴,但Array.Clear方法在处理需要频繁变换数组大小的场景时非常有用——它允许你将数组的某个范围重置为初始的零值状态。

这些在数组内部移动数据的方法对于在基本数组服务的基础上构建更灵活的数据结构非常有用。但通常你自己不需要使用它们,因为运行时库提供了几个有用的集合类来代替这些工作。

List<T>

List<T>类定义在System.Collections.Generic命名空间中,包含类型为T的元素的可变长度序列。它提供了一个索引器,允许你按编号获取和设置元素,因此List<T>表现得像一个可调整大小的数组。它并非完全可互换—你不能将List<T>作为期望T[]数组的参数传递—但数组和List<T>都实现了各种常见的泛型集合接口,我们稍后将会讨论这些接口。例如,如果你编写一个接受IList<T>的方法,它将能够与数组或List<T>一起使用。

虽然使用索引器的代码看起来像是访问数组元素,但实际上并非完全相同。索引器是一种属性,因此它在可变值类型方面与我在第三章中讨论过的问题相同。给定类型为List<Point>(其中PointSystem.Windows命名空间中的可变值类型)的变量pointList,你不能编写pointList[2].X = 2,因为pointList[2]返回的是值的副本,而这段代码实际上是要求修改那个临时副本。这会导致更新丢失,因此 C#禁止这样做。但对于数组来说,这是可行的。如果pointArray的类型是Point[]pointArray[2]不是获取元素,而是标识元素,这使得通过写pointArray[2].X = 2可以直接修改数组元素的值。尽管在 C#中添加了ref返回值后,可以编写按此方式工作的索引器,但List<T>IList<T>是在此之前创建的。对于像Complex这样的不可变值类型,这种区别是无关紧要的,因为无论是使用数组还是列表,你都不能直接修改它们的值—你必须用新值覆盖元素。

不像数组,List<T> 提供了可以改变其大小的方法。Add 方法将一个新元素追加到列表的末尾,而 AddRange 可以添加多个元素。InsertInsertRange 在列表的任意位置添加元素,将插入点后的所有元素向下移动以腾出空间。这四种方法都使列表变长,但是 List<T> 也提供了 Remove 方法,用于移除指定值的第一个实例;RemoveAt 方法用于移除特定索引处的元素;以及 RemoveRange 方法,用于从特定索引开始移除多个元素。所有这些方法都会将元素向下移动,以关闭被移除元素或元素留下的空隙,从而使列表变短。

注意

List<T> 在内部使用数组来存储其元素。这意味着所有元素都存储在单个内存块中,并且它们是连续存储的。这使得正常的元素访问非常高效,但也是为什么插入需要将元素向上移动以腾出空间,并且移除需要将元素向下移动以关闭空隙的原因。

示例 5-19 展示了如何创建一个 List<T>。它只是一个类,因此我们使用常规的构造函数语法。它展示了如何添加和移除条目,以及如何使用类似数组的索引器语法访问元素。这还显示了 List<T> 通过 Count 属性提供其大小。这个名字可能看起来和数组提供的 Length 有些不同,但原因是:这个属性是由 ICollection<T> 定义的,而 List<T> 实现了它。并非所有的 ICollection<T> 实现都是序列,因此在某些情况下 Length 可能不合适。(恰好,数组也提供 Count,因为它们也实现了 ICollectionICollection<T>。然而,它们使用显式接口实现,这意味着只能通过这些接口类型的引用看到数组的 Count 属性。)

示例 5-19. 使用 List<T>
var numbers = new List<int>();
numbers.Add(123);
numbers.Add(99);
numbers.Add(42);
Console.WriteLine(numbers.Count);
Console.WriteLine($"{numbers[0]}, {numbers[1]}, {numbers[2]}");

numbers[1] += 1;
Console.WriteLine(numbers[1]);

numbers.RemoveAt(1);
Console.WriteLine(numbers.Count);
Console.WriteLine($"{numbers[0]}, {numbers[1]}");

因为 List<T> 可以根据需要增长和收缩,所以在构造时不需要指定其大小。但是,如果需要的话,可以指定其容量。列表的容量是它当前可用于存储元素的空间量,这通常与它包含的元素数量不同。为了避免在每次添加或移除元素时都分配一个新的内部数组,它会独立跟踪使用的元素数量,而不是数组的大小。当需要更多空间时,它会过度分配,创建一个比需要的大的新数组,过度分配的量与列表当前大小成比例。这意味着,如果程序重复向列表添加项目,列表越大,它需要分配新数组的频率就越低,但每次重新分配后剩余容量的比例将保持大致相同。

If you know up front that you will eventually store a specific number of elements in a list, you can pass that number to the constructor, and it will allocate exactly that much capacity, meaning that no further reallocation will be required. If you get this wrong, it won’t cause an error—you’re just requesting an initial capacity, and it’s OK to change your mind later.

If the idea of unused memory going to waste in a list offends you, but you don’t know exactly how much space will be required before you start, you could call the TrimExcess method once you know the list is complete. This reallocates the internal storage to be exactly large enough to hold the list’s current contents, eliminating waste. This will not always be a win. To ensure that it is using exactly the right amount of space, TrimExcess has to create a new array of the right size, leaving the old, oversized one to be reclaimed by the garbage collector later on, and in some scenarios, the overhead of forcing an extra allocation just to trim things down to size may be higher than the overhead of having some unused capacity.

Lists have a third constructor. Besides the default constructor, and the one that takes a capacity, you can also pass in a collection of data with which to initialize the list. You can pass any IEnumerable<T>.

You can provide initial content for lists with syntax similar to an array initializer. Example 5-20 loads the same three values into the new list as at the start of Example 5-19.

Example 5-20. List initializer
var numbers = new List<int> { 123, 99, 42 };

If you’re not using var, you can omit the type name after the new keyword, as Example 5-21 shows. But in contrast to arrays, you cannot omit the new keyword entirely. Nor will the compiler infer the type argument, so whereas with an array you can write just new[] followed by an initializer, you cannot write new List<>.

Example 5-21. List initializer with target-typed new
List<int> numbers = new() { 123, 99, 42 };

Examples 5-20 and 5-21 are equivalent, and each compile into code that calls Add once for each item in the list. You can use this syntax with any type that has a suitable Add method and implements the IEnumerable interface. This works even if Add is an extension method. (So if some type implements IEnumerable, but does not supply an Add method, you are free to use this initializer syntax if you provide your own Add.)

List<T> provides IndexOf, LastIndexOf, Find, FindLast, FindAll, Sort, and Bin⁠ary​Sea⁠rch methods for finding and sorting list elements. These provide the same services as their array namesakes, although List<T> chooses to provide these as instance methods rather than statics.

我们现在已经看到了两种表示值列表的方式:数组和列表。幸运的是,接口使得可以编写既可以与数组又可以与列表一起工作的代码,因此如果想支持这两种情况,您不需要编写两套函数。

列表和序列接口

运行时库定义了几个代表集合的接口。其中三个与简单线性序列相关,可以存储在数组或列表中:IList<T>ICollection<T>IEnumerable<T>,全部位于 Sys⁠tem.​Col⁠lec⁠tio⁠ns.⁠Gen⁠eri⁠cs 命名空间。这里有三个接口,因为不同的代码有不同的要求。有些方法需要对集合中的任何编号元素进行随机访问,但并非所有情况都需要,也不是所有集合都能支持这样做——有些序列会逐渐产生元素,可能没有办法直接跳到第 n 个元素。例如,考虑表示按键的序列——每个项目只会在用户按下下一个键时出现。如果选择较少要求的接口,您的代码可以与更广泛的数据源一起工作。

IEnumerable<T> 是集合接口中最通用的一个,因为它对其实现者的要求最少。我已经多次提到它了,因为它是一个经常出现的重要接口,但直到现在我还没有展示其定义。正如示例 5-22 所示,它只声明了一个方法。

示例 5-22. IEnumerable<T>IEnumerable
public interface IEnumerable<out T> : IEnumerable
{
    IEnumerator<T> GetEnumerator();
}

public interface IEnumerable
{
    IEnumerator GetEnumerator();
}

使用继承,IEnumerable<T> 要求其实现者同时实现 IEnumerable,后者几乎与前者相同。它是 IEnumerable<T> 的非泛型版本,其 GetEnumerator 方法通常不会做更多事情,只是调用泛型实现。之所以存在这两种形式,是因为在 .NET 1.0 中就有非泛型的 IEnumerable,但该版本不支持泛型。在 .NET 2.0 中引入泛型后,可以更精确地表达 IEnumerable 的意图,但为了保持兼容性,旧接口仍然存在。因此,这两个接口实际上要求相同的内容:一个返回枚举器的方法。什么是枚举器?示例 5-23 显示了泛型和非泛型接口。

示例 5-23. IEnumerator<T>IEnumerator
public interface IEnumerator<out T> : IDisposable, IEnumerator
{
    T Current { get; }
}

public interface IEnumerator
{
    bool MoveNext();
    object Current { get; }
    void Reset();
}

对于 IEnumerable<T>(以及 IEnumerable),使用模型是调用 GetEnumerator 来获取枚举器,该枚举器可用于遍历集合中的所有项目。您调用枚举器的 MoveNext() 方法;如果它返回 false,则表示集合为空。否则,Current 属性现在将提供集合中的第一个项目。然后,再次调用 MoveNext() 来移动到下一个项目,并且只要它继续返回 trueCurrent 将提供下一个项目。(Reset 方法是一种历史遗留物,用于帮助与 COM(Windows 中的 .NET 之前的跨语言对象模型)兼容。文档允许实现从 Reset 抛出 NotSupportedException,因此您通常不会使用此方法。)

注意

请注意,IEnumerator<T> 的实现必须实现 IDisposable。完成枚举后,您必须调用枚举器的 Dispose 方法,因为其中许多依赖于此。

在 C# 中,foreach 循环会为您完成遍历可枚举集合所需的所有工作[¹],包括生成调用 Dispose 的代码,即使循环由于 break 语句、错误或者(不可思议的)goto 语句而提前终止。第七章将更详细地描述 IDisposable 的用法。

IEnumerable<T> 是 LINQ to Objects 的核心,在第十章中将进行讨论。LINQ 操作符可用于实现此接口的任何对象。运行时库定义了一个相关的接口 IAsyncEnumerable<T>。从概念上讲,它与 IEnumerable<T> 相同:它表示提供项目序列的能力。不同之处在于,它允许异步枚举项目。正如示例 5-24 所示,此接口及其对应的 IAsyncEnumerator<T>IEnumerable<T>IEnumerator<T> 类似。主要区别在于使用异步编程功能 ValueTask<T>CancellationToken,将在第十六章中描述。还有一些小的区别:这些接口没有泛型版本,也没有重置现有异步枚举器的功能(尽管如前所述,许多同步枚举器在调用 Reset 时会抛出 NotSupportedException)。

示例 5-24. IAsyncEnumerable<T>IAsyncEnumerator<T> 的用法模型
public interface IAsyncEnumerable<out T>
{
    IAsyncEnumerator<T> GetAsyncEnumerator(
        CancellationToken cancellationToken = default);
}

public interface IAsyncEnumerator<out T> : IAsyncDisposable
{
    T Current { get; }

    ValueTask<bool> MoveNextAsync();
}

您可以使用特殊形式的 foreach 循环消耗 IAsyncEnumerable<T>,在这种情况下,您需要在前面加上 await 关键字。这只能在使用 async 关键字标记的方法中使用。第十七章详细描述了 asyncawait 关键字,以及 await foreach 的用法。

虽然IEnumerable<T>非常重要且被广泛使用,但它相当受限制。你只能依次要求它一个项,并且它会按照它认为合适的顺序分发它们。它不提供修改集合的方法,甚至没有办法在不迭代整个集合的情况下找出集合包含的项数。对于这些工作,我们有ICollection<T>,它在示例 5-25 中展示。

示例 5-25. ICollection<T>
public interface ICollection<T> : IEnumerable<T>, IEnumerable
{
    void Add(T item);
    void Clear();
    bool Contains(T item);
    void CopyTo(T[] array, int arrayIndex);
    bool Remove(T item);

    int Count { get; }
    bool IsReadOnly { get; }
}

这要求实现者还必须提供IEnumerable<T>,但注意这个接口并不继承非泛型的ICollection。确实有这样一个接口,但它代表了一个不同的抽象:它除了CopyTo方法外没有任何方法。在引入泛型时,微软审查了旧的非泛型集合类型的使用方式,并得出结论,旧的ICollection增加的那一个额外方法并没有使它比IEnumerable更加有用。更糟糕的是,它还包含了一个名为SyncRoot的属性,旨在帮助管理某些多线程场景,但实际上证明这是一个解决该问题的不良方案。因此,ICollection所代表的抽象并没有得到泛型等价物,并且并没有被深切怀念。在审查过程中,微软还发现缺少一个通用的可修改集合的接口是一个问题,因此制定了ICollection<T>以解决这个问题。将这个旧名称附加到不同的抽象上固然不完全有助于问题的解决,但由于几乎没有人在使用旧的非泛型ICollection,这似乎并没有造成太大麻烦。

第三个顺序集合的接口是IList<T>,所有实现它的类型都必须实现ICollection<T>,因此也必须实现IEnumerable<T>。正如你所预料的那样,List<T>实现了IList<T>。数组也实现了它,使用它们的元素类型作为T的参数。示例 5-26 展示了接口的样子。

示例 5-26. IList<T>
public interface IList<T> : ICollection<T>, IEnumerable<T>, IEnumerable
{
    int IndexOf(T item);
    void Insert(int index, T item);
    void RemoveAt(int index);

    T this[int index] { get; set; }
}

虽然有一个非泛型的IList,但这个接口与它没有直接关系,尽管两个接口表示类似的概念——非泛型的IListIList<T>成员的对应物,它还包括了几乎所有ICollection<T>的成员,包括ICollection缺失的所有成员。因此,要求IList<T>的实现同时实现IList本来是可能的,但这将会强制实现者提供大多数成员的两个版本,一个使用类型参数T,另一个使用object,因为旧的非泛型接口就是这样使用的。这也将迫使集合提供没有用处的SyncRoot属性。这些不便之处远不值得好处,因此IList<T>的实现不需要实现IList。它们可以选择这样做,List<T>选择了,但这取决于各个集合类。

由于这三个泛型接口的关系方式,不幸的是它们没有提供一个表示只读索引集合的抽象,甚至没有提供固定大小的抽象。虽然IEnumerable<T>是一个只读的抽象,但它是一个按顺序的抽象,没有直接访问第n个值的方法。IList<T>提供了索引访问,但它还定义了插入和索引移除的方法,并且要求实现ICollection<T>,其包括添加和基于值的移除方法。所以你可能会想知道为什么数组可以实现这些接口,因为所有的数组都是固定大小的。

数组通过使用显式接口实现来缓解这个问题,隐藏可以改变列表长度的IList<T>方法,阻止你尝试使用它们。(正如你在第三章看到的,这种技术使你能够提供接口的完整实现,但可以选择性地使某些成员直接可见。)然而,你可以将数组的引用存储在类型为IList<T>的变量中,从而使这些方法可见——示例 5-27 使用此方法调用数组的IList<T>.Add方法。然而,这将导致运行时错误。

示例 5-27. 尝试(并失败)扩展数组
IList<int> array = new[] { 1, 2, 3 };
array.Add(4);  // Will throw an exception

Add方法抛出一个NotSupportedException,错误消息表明集合的大小是固定的。如果你查看IList<T>ICollection<T>的文档,你会看到所有可能修改集合的成员都可以抛出此错误。你可以使用ICollection<T>接口的IsReadOnly属性在运行时发现是否会发生这种情况。然而,这并不能帮助你事先发现集合只允许某些更改。(例如,数组的大小是固定的,但你仍然可以修改元素。)

这造成了一个令人恼火的问题:如果你在编写确实需要可修改集合的代码,却无法声明这一事实。如果一个方法接受IList<T>,很难知道该方法是否会尝试调整列表的大小。不匹配会导致运行时异常,这些异常很可能出现在并没有做错事情的代码中,而错误——传递了错误类型的集合——是调用者的问题。这些问题并不是致命错误;在动态类型语言中,这种编译时不确定性实际上是常态,并且不会妨碍你编写良好的代码。

这里有一个ReadOnlyCollection<T>类,但正如我们稍后将看到的,它解决的是不同的问题——它是一个包装类,而不是一个接口,因此有很多固定大小的集合并不提供ReadOnlyCollection<T>。如果您要编写一个参数类型为ReadOnlyCollection<T>的方法,它将无法直接与某些类型的集合(包括数组)一起工作。无论如何,它甚至不是相同的抽象——只读比固定大小的限制更严格。

.NET 定义了IReadOnlyList<T>,这是一个更好的解决方案,用于表示只读索引集合(尽管它仍然无法处理像数组这样的可修改的固定大小集合)。像IList<T>一样,它要求实现IEnumerable<T>,但不需要ICollection<T>。它定义了两个成员:Count,返回集合的大小(就像ICollection<T>.Count一样),以及一个只读的索引器。这解决了使用IList<T>处理只读集合时遇到的大部分问题。一个小问题是,由于它比我在此处描述的大多数其他接口都要新,因此并不是普遍受支持的。(它在 2012 年的.NET 4.5 中推出,比IList<T>晚了七年。)因此,如果遇到要求IReadOnlyList<T>的 API,您可以确信它不会尝试修改集合,但如果一个 API 要求IList<T>,那么很难知道这是因为它打算修改集合,还是仅仅是因为它是在IReadOnlyList<T>被发明之前编写的。

注意

集合并不需要是只读的才能实现IReadOnlyList<T>——一个可修改的列表可以很容易地呈现一个只读的外观。因此,所有数组和List<T>都实现了这个接口。

我刚刚讨论的问题和接口引发了一个问题:在编写与集合工作的代码或类时,应该使用什么类型?如果你的 API 要求能够处理最少具体类型的需求,通常会得到最大的灵活性。例如,如果IEnumerable<T>适合你的需求,就不要要求一个IList<T>。同样,接口通常比具体类型更好,所以你应该优先选择IList<T>而不是List<T>T[]。偶尔可能会有性能优化的争论,如果你有一个关键循环对应用程序整体性能至关重要,通过集合内容工作时,如果仅使用数组类型可能会使代码运行更快,因为 CLR 在知道期望的情况下可以执行更好的优化。但在许多情况下,差异可能太小而无法测量,并且不足以证明被绑定到特定实现的不便,因此在没有测量任务的性能之前,不应采取此类步骤。 (如果您正在考虑这样的性能导向变更,您还应该查看第十八章中描述的技术。) 如果您发现有可能提高性能,但正在编写共享库,希望同时提供灵活性和最佳性能,有几种同时满足两者的选项。您可以提供重载,以便调用者可以传递接口或特定类型。或者,您可以编写一个单一的公共方法,接受接口但测试已知类型,并根据调用者传递的内容选择不同的内部代码路径。

我们刚刚查看的接口并不是唯一的通用集合接口,因为简单的线性列表并不是唯一的集合类型。但在转向其他接口之前,我想展示一下可枚举和列表的另一面:我们如何实现这些接口?

实现列表和序列

IEnumerable<T>IList<T>的形式提供信息通常很有用。前者尤其重要,因为.NET 提供了一个强大的工具包,用于处理序列,即 LINQ to Objects,我将在第十章中展示。 LINQ to Objects 提供了各种操作符,所有这些操作符都以IEnumerable<T>的形式工作。 IList<T>在任何需要通过索引随机访问任何元素的地方都是一个有用的抽象。某些框架期望一个IList<T>。例如,如果你想将一组对象绑定到某种列表控件,一些 UI 框架将期望一个IListIList<T>

你可以手动实现这些接口,因为它们都不是特别复杂。然而,C# 和运行时库可以提供帮助。直接支持在语言级别实现IEnumerable<T>,而运行时库则为通用和非通用列表接口提供支持。

使用迭代器实现 IEnumerable

C# 支持一种称为迭代器的特殊方法。迭代器是使用yield关键字生成可枚举序列的方法。 示例 5-28 展示了一个简单的迭代器及其使用的一些代码。这将显示从 5 到 1 倒数的数字。

示例 5-28. 一个简单的迭代器
public static IEnumerable<int> Countdown(int start, int end)
{
    for (int i = start; i >= end; --i)
    {
        yield return i;
    }
}

private static void Main(string[] args)
{
    foreach (int i in Countdown(5, 1))
    {
        Console.WriteLine(i);
    }
}

迭代器看起来很像任何普通方法,但它返回值的方式不同。示例 5-28 中的迭代器具有IEnumerable<int>的返回类型,但看起来并未返回任何该类型的内容。它不是使用普通的return语句,而是使用yield return语句,该语句返回单个int,而不是一个集合。迭代器通过yield return语句逐个产生值,并且与普通的return不同,方法在返回值后可以继续执行 —— 只有当方法运行到结尾或通过yield break语句或抛出异常提前结束时,它才算完成。 示例 5-29 更明显地展示了这一点。每个yield return导致从序列中发出一个值,因此这个迭代器将产生数字 1 到 3。

示例 5-29. 一个非常简单的迭代器
public static IEnumerable<int> ThreeNumbers()
{
    yield return 1;
    yield return 2;
    yield return 3;
}

尽管这在概念上相当简单,但它的工作方式有些复杂,因为迭代器中的代码不像其他代码那样运行。记住,对于IEnumerable<T>,调用者负责何时检索下一个值;foreach循环将获取一个枚举器,然后重复调用MoveNext()直到返回false,并期望Current属性提供当前值。那么示例 5-28 和 5-29 如何适应这个模型呢?也许你会认为,也许 C# 在一个List<T>中存储迭代器产生的所有值,并在迭代器完成后返回它,但很容易通过编写一个永不完成的迭代器(如 示例 5-30 中的迭代器)来证明这并不正确。

示例 5-30. 一个无限迭代器
public static IEnumerable<BigInteger> Fibonacci()
{
    BigInteger v1 = 1;
    BigInteger v2 = 1;

    while (true)
    {
        yield return v1;
        var tmp = v2;
        v2 = v1 + v2;
        v1 = tmp;
    }
}

此迭代器运行无限期;它有一个带有true条件的while循环,并且不包含break语句,因此它永远不会自愿停止。如果 C#试图在返回任何内容之前完成迭代器的运行,它将在此处卡住。 (数字会增长,因此如果运行时间足够长,该方法最终会通过抛出OutOfMemoryException而终止。) 但是如果你尝试这样做,你会发现它立即开始从斐波那契序列中返回值,并且只要你继续迭代其输出,它将继续这样做。 显然,C#并非简单地在返回之前运行整个方法。

C#对您的代码进行了一些严肃的手术以使其工作。 如果您使用像 ILDASM(与.NET SDK 一起提供的.NET 代码反汇编器)这样的工具检查迭代器的编译器输出,您会发现它生成了一个作为方法返回的IEnumerable<T>的实现以及IEnumerable<T>GetEnumerator方法返回的IEnumerator<T>的私有嵌套类。 您的迭代器方法的代码最终位于此类的MoveNext方法内部,但几乎无法识别,因为编译器以一种方式将其拆分,使得每次yield return都能返回给调用者,但在下次调用MoveNext时继续执行。 在必要时,它将存储局部变量在此生成的类中,以便它们的值可以在多次调用MoveNext时保持不变。 或许了解 C#在编译迭代器时所需做的工作的最简单方法就是手动编写等效代码。 示例 5-31 提供了与示例 5-30 相同的斐波那契序列,但没有使用迭代器的帮助。 它不完全是编译器所做的,但它展示了其中的一些挑战。

示例 5-31. 手动实现IEnumerable<T>
public class FibonacciEnumerable :
    IEnumerable<BigInteger>, IEnumerator<BigInteger>
{
    private BigInteger v1;
    private BigInteger v2;
    private bool first = true;

    public BigInteger Current => v1;

    public void Dispose() { }

    object IEnumerator.Current => Current;

    public bool MoveNext()
    {
        if (first)
        {
            v1 = 1;
            v2 = 1;
            first = false;
        }
        else
        {
            var tmp = v2;
            v2 = v1 + v2;
            v1 = tmp;
        }

        return true;
    }

    public void Reset()
    {
        first = true;
    }

    public IEnumerator<BigInteger> GetEnumerator() =>
        new FibonacciEnumerable();

    IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}

这不是一个特别复杂的例子,因为它的枚举器基本上处于两种状态之一——要么是第一次运行,因此需要运行循环之前的代码,要么是在循环内部。 即便如此,这段代码比示例 5-30 要难读得多,因为支持枚举的机制掩盖了基本逻辑的本质。

如果我们需要处理异常,代码会变得更加复杂。 您可以编写using块和finally块,这使得您的代码能够在面对错误时正确运行,正如我将在第 7 和 8 章中展示的那样,编译器最终可能会为了保留这些正确的语义而做很多工作,当方法的执行在多个迭代之间分割时。² 在您手动编写多个枚举之前,您可能会感谢 C#可以为您完成这些工作的方式。

顺便说一下,迭代器方法并不一定要返回 IEnumerable<T>。如果你愿意,你可以返回 IEnumerator<T>。而且,正如你之前看到的,实现这些接口的对象也总是实现它们的非泛型版本,因此如果你需要一个普通的 IEnumerableIEnumerator,你不需要额外的工作——你可以将一个 IEnumerable<T> 传递给任何期望普通 IEnumerable 的地方,对于枚举器也是一样。如果出于某种原因你想要提供其中一个非泛型接口,并且你不想提供泛型版本,你可以直接编写返回非泛型形式的迭代器。

迭代器需要小心的一点是,直到调用者第一次调用 MoveNext 方法时,它们才会执行非常少的代码。因此,如果你逐步执行调用 示例 5-30 中的 Fibonacci 方法的代码,该方法调用似乎根本不会做任何事情。如果你尝试在调用时步入方法,在方法运行时将不会执行任何代码。只有当迭代开始时,你才会看到迭代器的主体执行。这有几个后果。

首先要记住的是,如果你的迭代器方法接受参数,并且你想要验证这些参数,你可能需要做一些额外的工作。默认情况下,验证将在迭代开始时才会发生,因此错误可能会比预期晚发生。如果你想立即验证参数,你需要编写一个包装器。示例 5-32 展示了一个例子——它提供了一个名为 Fibonacci 的普通方法,不使用 yield return,因此不会得到迭代器的特殊编译器行为。这个普通方法在调用嵌套的迭代器方法之前验证其参数。(这也说明了局部方法可以使用 yield return。)

示例 5-32. 迭代器参数验证
public static IEnumerable<BigInteger> Fibonacci(int count)
{
    if (count < 0)
    {
        throw new ArgumentOutOfRangeException(nameof(count));
    }
    return Core(count);

    static IEnumerable<BigInteger> Core(int count)
    {
        BigInteger v1 = 1;
        BigInteger v2 = 1;

        for (int i = 0; i < count; ++i)
        {
            yield return v1;
            var tmp = v2;
            v2 = v1 + v2;
            v1 = tmp;
        }
    }
}

第二点需要记住的是,迭代器可能会执行多次。IEnumerable<T> 提供了一个 GetEnumerator 方法,可以被多次调用,而你的迭代器体每次都会从头开始运行。所以即使你的迭代器方法可能只被调用了一次,它也可能会运行多次。

Collection

如果你查看运行时库中的类型,你会发现当它们提供暴露 IList<T> 实现的属性时,通常是间接的。与接口不同,属性通常提供某种具体类型,尽管通常不是 List<T>List<T> 被设计为你代码的实现细节,如果直接暴露它,可能会给你的类的用户过多的控制权。你希望他们能修改列表吗?即使你希望如此,你的代码是否需要知道这种情况发生的时机呢?

运行时库提供了一个Collection<T>类,旨在作为类型公开的集合的基类使用。它类似于List<T>,但有两个显著的区别。首先,它的 API 更小——它提供了IndexOf,但所有其他适用于List<T>的搜索和排序方法都不包括,并且它不提供独立于其大小的方式来发现或更改其容量。其次,它为派生类提供了一种发现添加或移除项时机制的方法。List<T>则没有这样的机制,因为它是你的列表,所以你应该知道何时添加和移除项。通知机制并不是免费的,所以List<T>通过不提供它们来避免不必要的开销。但Collection<T>假设外部代码将访问你的集合,并且你因此不能控制每一次添加和移除,这正当了提供一种让你发现列表何时被修改的开销。 (这仅适用于从Collection<T>派生的代码。如果你希望使用你的集合的代码能够检测到变化,ObservableCollection<T>类型就是为这种情况设计的。例如,如果你在桌面和移动 UI 框架(如 WPF、UWP、MAUI 和 Xamarin)中将此类型用作列表的源,它们将能够在修改集合时自动显示列表。)

你通常从Collection<T>派生一个类,并且你可以重写它定义的虚方法来发现集合的变化。(第六章将讨论继承和重写。)Collection<T>实现了IListIList<T>,因此你可以通过接口类型的属性展示基于Collection<T>的集合,但通常会将派生的集合类型公开并使用它而不是接口作为属性类型。

ReadOnlyCollection

如果你想提供一个不可修改的集合,那么你可以使用ReadOnlyCollection<T>而不是使用Collection<T>。顺便说一下,这比数组施加的限制更进一步:不仅你不能添加、移除或插入项目,而且你甚至不能替换元素。这个类实现了IList<T>,它要求一个带有getset的索引器,但是set会抛出异常。(当然,它也实现了IReadOnlyCollection<T>。)

如果你集合的元素类型是引用类型,将集合设为只读并不能防止元素引用的对象被修改。例如,我可以从只读集合中检索第 12 个元素,并且它会返回给我一个引用。获取引用算是一个只读操作,但现在我已经得到了那个引用,集合对象已经不再受限制,我可以随心所欲地对那个引用进行操作。由于 C# 并没有提供任何类似于 C++ const 引用的概念,因此要展示一个真正只读的集合的唯一方法是与不可变类型结合使用 ReadOnlyCollection<T>

使用 ReadOnlyCollection<T> 有两种方法。你可以直接将它用作现有列表的包装器——它的构造函数接受一个 IList<T>,并且会提供对其的只读访问。(顺便说一句,List<T> 提供了一个名为 AsReadOnly 的方法,用于为你构造一个只读包装器。)或者,你可以从它派生一个类。与 Collection<T> 一样,一些类为希望通过属性公开的集合执行此操作,通常是因为它们希望定义与集合用途相关的附加方法。即使从这个类派生,你仍然会使用它来包装一个底层列表,因为它提供的唯一构造函数就是接受列表的构造函数。

警告

ReadOnlyCollection<T> 通常不适合自动映射对象模型和外部表示之间的场景。例如,在作为数据传输对象(DTOs)使用的类型中,它会在转换为和从通过网络连接发送的 JSON 消息中引起问题,并且在通过对象关系映射系统通过对象模型呈现数据库内容时也会出现问题。这些场景的框架需要能够实例化你的类型并将其填充数据,因此,尽管只读集合可能是你的模型某些部分的理想匹配,但它可能与这些映射框架期望初始化对象的方式不符。

使用索引和范围语法访问元素

无论是使用数组、List<T>IList<T> 还是前面讨论的各种相关类型和接口,我们都使用简单的示例来识别元素,例如 items[0],以及更一般的形式为 *arrayOrListExpression*[*indexExpression*] 的表达式。到目前为止,所有示例都使用了 int 类型的表达式作为索引,但这并不是唯一的选择。示例 5-33 使用了另一种语法来访问数组的最后一个元素。

示例 5-33. 使用端相对索引访问数组的最后一个元素
char[] letters = { 'a', 'b', 'c', 'd' };
char lastLetter = letters[¹];

这展示了用于索引器的两个运算符之一:^运算符和范围运算符。后者在示例 5-34 中展示,是一对点号(..),用于标识数组、字符串或任何实现特定模式的可索引类型的子范围。

示例 5-34. 使用范围运算符获取数组的子范围
int[] numbers = { 1, 2, 3, 4, 5, 6, 7 };
// Gets 4th and 5th (but not the 3rd or 6th, for reasons explained shortly) int[] theFourthTheFifth = `numbers``[``3..5``]``;`

使用^..运算符的表达式分别是IndexRange类型。这些类型在.NET Standard 2.1 中可用,意味着它们内置于.NET Core 3.1 和.NET 5.0 或更高版本中。然而,在.NET Framework 上这些类型不可用,这意味着你只能在较新的运行时中使用这些语言特性。

System.Index

你可以将^运算符放在任何int表达式的前面。它产生一个System.Index,这是一个值类型,表示一个位置。当你用^创建一个索引时,它是结束相对的,但你也可以创建起始相对索引。没有特殊的运算符,但由于Index提供从int的隐式转换,你可以直接将int值分配到Index类型的变量中,正如示例 5-35 所示。你也可以显式地构造一个索引,就像var行所示。最后的bool参数是可选的——默认为false——但我展示它来说明Index如何知道你想要哪种类型。

示例 5-35. 一些起始相对和结束相对的Index
Index first = 0;
Index second = 1;
Index third = 2;
var fourth = new Index(3, fromEnd: false);

Index antePenultimate = ³;
Index penultimate = ²;
Index last = ¹;
Index directlyAfterTheLast = ⁰;

如示例 5-35 所示,结束相对索引存在于任何特定集合之外。(在内部,Index将结束相对索引存储为负数。这意味着Indexint大小相同。这也意味着负的起始或结束相对值是非法的——如果尝试创建一个,你会得到一个异常。)C#会生成代码,在使用索引时确定实际元素位置。如果smallbig分别是包含 3 和 30 个元素的数组,small[last]将返回第三个元素,而big[last]将返回第 30 个元素。C#将把这些转换为small[last.GetOffset(small.Length)]big[last.GetOffset(big.Length)]

人们常说,计算机科学中三大难题是为事物命名和一错再错的错误。乍一看,示例 5-35 使人觉得Index可能在加剧这些问题。第三个项目的索引是二而不是三可能会使人困惑,但这至少与 C#中数组的工作方式一致,并且对于任何零基索引系统都是正常的。但鉴于零基准约定,为什么结束相对索引看起来是一基的呢?我们用0表示第一个元素,但用¹表示最后一个元素!

这样做有一些很好的理由。其核心洞见是,在 C#中,索引始终指定距离。当编程语言设计者选择零基索引系统时,并非真的决定将第一个元素称为 0:而是决定将索引解释为从数组开始的距离。由此产生的一个结果是,索引并不真正指代一个项。图 5-1 展示了一个包含四个元素的集合,并指示了在该集合中各种索引值指向的位置。注意,所有的索引都指向各个项之间的边界。这可能看起来有些吹毛求疵,但这是理解所有零基索引系统的关键,也是示例 5-35 中显露的表面不一致的背后原因。

索引位置

图 5-1. Index值指向的位置

当你通过索引访问集合的元素时,你要求的是从索引指示的位置开始的元素。因此,array[0]检索的是从数组开头开始的单个元素,填充索引 0 和 1 之间的空间的元素。同样,array[1]检索的是索引 1 和 2 之间的元素。那么array[⁰]意味着什么?³ 这将尝试获取从数组末尾开始的元素。由于元素都占据一定的空间,从数组末尾开始的元素必然会在数组末尾之后一个位置结束。在这个四元素数组中,array[⁰]相当于array[4],因此我们要求的是占据从开头计算起四个元素开始并结束在开头五个元素的空间的元素。由于这是一个四元素数组,显然是行不通的。

表面上的差异——即array[0]获取第一个元素,但我们需要写array[¹]来获取最后一个元素——是因为元素位于两个索引之间,而数组索引器总是检索指定索引和其后索引之间的元素。即使指定了一个末尾相关的索引,它们也会这样做,这就是为什么这些看起来是基于一的原因。这种语言特性本可以设计得不同:你可以想象一种规则,即末尾相关的索引始终访问从末尾指定距离结束并从这之前一位置开始的元素。这样设计本应更对称,因为这会使得array[⁰]指向最后一个元素,但这样做带来的问题比解决的问题更多。

使索引器在两种不同方式下解释索引会很令人困惑——这意味着两个不同的索引可能指向同一个位置,但提取不同的元素。无论如何,C# 开发人员已经习惯了这种工作方式。正如 Example 5-36 所示,在 ^ 索引运算符之前,访问数组的最后一个元素的方法是使用从长度中减去一计算出的索引。如果想要倒数第二个元素,则从长度中减去两个,依此类推。正如你所见,新的结束相对语法与长期存在的现有实践完全一致。

示例 5-36. 结束相对索引和 Index 前等价物
int lastOld = numbers[numbers.Length - 1];
int lastNew = numbers[¹];

int penultimateOld = numbers[numbers.Length - 2];
int penultimateNew = numbers[²];

还有一种思考方法是想象如果我们通过指定范围来访问数组会是什么样子。第一个元素在范围 0–1 中,最后一个元素在范围 ¹–⁰ 中。以这种方式表达,起始相对和结束相对形式之间显然存在对称性。说到范围……

System.Range

正如我之前所说,C# 有两个对处理数组和其他可索引类型非常有用的运算符。我们刚刚看过 ^ 和对应的 Index 类型。另一个称为 范围运算符,它在 System 命名空间中也有相应的类型 RangeRange 是一对 Index 值,通过 StartEnd 属性提供。Range 提供了一个接受两个 Index 值的构造函数,但在 C# 中,创建它的习惯方式是使用范围运算符,正如 Example 5-37 所示。

示例 5-37. 不同的范围
Range everything = 0..⁰;
Range alsoEverything = 0..;
Range everythingAgain = ..⁰;
Range everythingOneMoreTime = ..;
var yetAnotherWayToSayEverything = Range.All;

Range firstThreeItems = 0..3;
Range alsoFirstThreeItems = ..3;

Range allButTheFirstThree = 3..⁰;
Range alsoAllButTheFirstThree = 3..;

Range allButTheLastThree = 0..³;
Range alsoAllButTheLastThree = ..³;

Range lastThreeItems = ³..⁰;
Range alsoLastThreeItems = ³..;

正如你所见,如果在 .. 前面没有放置起始索引,默认为 0;如果省略结束索引,默认为 (即最开始和最后,分别)。示例还显示,起始索引可以是起始相对的,也可以是结束相对的,结束索引也是如此。

警告

Range 的默认值——在未显式初始化的字段或数组元素中得到的值——是 0..0。这表示一个空范围。虽然这是由于值类型默认总是初始化为类似于零的值所导致的自然结果,但这可能与你期望的不同,因为 .. 等同于 Range.All

由于 Range 是基于 Index 工作的,起始和结束表示偏移量,而不是元素。例如,考虑范围 1..3 对应于 Figure 5-1 中显示的元素的含义。在这种情况下,两个索引都是起始相对的。起始索引 1 是第一个和第二个元素(ab)之间的边界,结束索引 3 是第三个和第四个元素(cd)之间的边界。因此,这是一个从 b 的开头到 c 的结尾的范围,正如 Figure 5-2 所示。因此,这确定了一个包含两个元素 bc 的范围。

范围 1..3

Figure 5-2. 一个范围

数组范围的解释有时会让人感到惊讶,当他们第一次看到时:有些人期望1..3表示第一、第二和第三个元素(或者,如果考虑到 C# 的从零开始索引,可能是第二、第三和第四个元素)。起始索引看起来是包含的,而结束索引是排除的,这一点一开始可能显得不一致。但是一旦你记住索引指的不是项目而是偏移量,因此是两个项目之间的边界,这一切就都说得通了。如果你画出范围索引表示的位置,就像图 5-2 那样,就会完全明白1..3范围只覆盖了两个元素。

那么我们可以用Range做什么呢?正如示例 5-34 所示,我们可以使用它来获取数组的子范围。这会创建一个相应大小的新数组,并将范围内的值复制到其中。同样的语法也适用于获取子字符串,正如示例 5-38 所示。

示例 5-38. 使用范围获取子字符串
string t1 = "dysfunctional";
string t2 = t1[3..6];
Console.WriteLine($"Putting the {t2} in {t1}");

你还可以在ArraySegment<T>中使用Range,这是一个值类型,用于引用数组中的一段元素。示例 5-39 对示例 5-34 稍作修改。它不是将范围传递给数组的索引器,而是首先创建一个表示整个数组的Ar⁠ray​Seg⁠men⁠t<i⁠nt>,然后使用范围获取第四和第五个元素的第二个ArraySegment<int>。这样做的好处是不需要分配新的数组——两个ArraySegment<int>值引用相同的基础数组;它们只是指向它的不同部分,并且由于ArraySegment<int>是值类型,这可以避免分配新的堆块。(顺便说一句,ArraySegment<int>没有直接支持范围。编译器会将此转换为调用段的Slice方法。)

示例 5-39. 使用范围运算符获取ArraySegment<T>的子范围
int[] numbers = { 1, 2, 3, 4, 5, 6, 7 };
ArraySegment<int> wholeArrayAsSegment = numbers;
ArraySegment<int> theFourthTheFifth = wholeArrayAsSegment[3..5];

自 .NET 2.0 起(并在 .NET Standard 1.0 中存在),ArraySegment<T> 类型是一种避免额外分配的有用方式,但它有限制:它只适用于数组。那么字符串呢?所有当前版本的 .NET 都支持提供这个概念更一般化的类型,即 Span<T>ReadOnlySpan<T>。(在 .NET Framework 中,通过 System.Memory NuGet 包可用。它们内置于其他 .NET 版本中。)与 ArraySegment<T> 类似,Span<T> 表示其他某物中的子序列,但关于这个“其他某物”它更加灵活。它可以是一个数组,也可以是字符串,堆栈帧中的内存,或者完全在 .NET 之外由某个库或系统调用分配的内存。关于 Span<T>ReadOnlySpan<T> 类型的更详细讨论见第十八章,但现在,示例 5-40 展示了它们的基本用法。

示例 5-40. 使用范围运算符获取跨度的子范围
int[] numbers = { 1, 2, 3, 4, 5, 6, 7 };
Span<int> wholeArrayAsSpan = numbers;
Span<int> theFourthTheFifth = wholeArrayAsSpan[3..5];
ReadOnlySpan<char> textSpan = "dysfunctional".AsSpan();
ReadOnlySpan<char> such = textSpan[3..6];

这些与前面的示例在逻辑上具有相同的含义,但它们避免了复制底层数据。

我们已经看到可以在几种类型上使用范围:数组、字符串、Arr⁠ay​Seg⁠men⁠t<T>Span<T>ReadOnlySpan<T>。这引发了一个问题:C# 是否有一个特殊处理的类型列表,或者我们可以在我们自己的类型中支持索引器和范围?答案分别是肯定的。C# 对数组和字符串有一些内建处理:它知道调用特定的运行时库方法以生成子数组和子字符串。然而,对于数组段或跨度没有特殊的范围处理:它们之所以有效是因为它们符合一种模式。支持使用 Index 也有一种模式。如果你支持相同的模式,你可以让 IndexRange 在你自己的类型中工作。

支持在自定义类型中使用索引和范围

数组类型并没有定义接受 Index 类型参数的索引器。在本章早些时候展示的任何泛型数组样式类型也没有 —— 它们都只有普通的基于 int 的索引器;然而,你仍然可以与它们一起使用 Index。正如我之前解释的那样,形如 col[index] 的代码将展开为 col[index.GetOffset(a.Length)]。⁴ 因此,你只需要一个基于 int 的索引器和一个名为 LengthCountint 类型属性。示例 5-41 展示了使你的类型的索引器能接受 Index 参数的最小化实现。它不是一个非常有用的实现,但足以让 C# 快乐。

示例 5-41. 最小化启用 Index
public class Indexable
{
    public char this[int index] => (char)('0' + index);

    public int Length => 10;
}
Tip

有一个更简单的方法:只需定义一个接受Index类型参数的索引器即可。但是,大多数可索引类型都提供了基于int的索引器,因此在实践中,你会重载你的索引器,提供这两种形式。这并不简单,但它可以使你的代码区分起始和结束相对索引。如果我们在示例 5-41 中使用1,无论哪种情况,其索引器都会看到 1,因为 C#会生成将Index转换为基于起始的int的代码,但如果你编写一个接受Index参数的索引器,C#会直接传递Index。如果你重载索引器以使intIndex形式都可用,它将永远不会生成将Index转换为int的代码以调用int索引器:只有在没有Index特定索引器可用时才会出现这种模式。

IList<T>符合模式的要求(例如实现它的List<T>类型),因此你可以将Index传递给任何实现此接口的内容的索引器。它提供Count属性而不是Length,但是模式接受任何一种。这是一个广泛实现的接口,因此在实践中,许多类型在Index引入之前就自动获得了对Index的支持。这是一个模式化支持Index的例子,即使是面向较旧.NET 版本(如.NET Standard 2.0)的库也可以定义在新版本.NET 中使用Index的类型。

支持Range的模式不同:如果你的类型提供了一个接受两个整数参数的实例方法Slice,C#允许代码将Range作为索引器参数。示例 5-42 展示了使类型最少支持Range的方式,尽管这不是一个非常有用的实现。(与Index类似,你也可以直接定义一个接受Range的索引器重载。但是,模式方法的优势在于你可以在针对较旧版本(如不支持RangeIndex类型的.NET Standard 2.0)时使用它,同时仍支持针对新版本的代码的范围。)

示例 5-42. 最小化启用Range
public class Rangeable
{
    public int Length => 10;

    public Rangeable Slice(int offset, int length) => this;
}

你可能已经注意到,这种类型并没有定义索引器。这是因为基于模式的支持形式x[1..¹]不需要索引器。它看起来像是在使用索引器,但实际上只是调用了Slice方法。(同样,先前的使用string和数组的范围示例会编译成方法调用。)你需要Length属性(或Count),因为编译器生成的代码依赖于此来解析范围的索引。示例 5-43 大致展示了编译器如何使用支持此模式的类型。

示例 5-43. 范围索引的扩展方式
Rangeable r1 = new();
Range r = 2..²;

Rangeable r2;

r2 = r1[r];
// is equivalent to
int startIndex = r.Start.GetOffset(r1.Length);
int endIndex = r.End.GetOffset(r1.Length);
r2 = r1.Slice(startIndex, endIndex - startIndex);

到目前为止,我们看到的所有集合都是线性的:我只展示了一些对象或值的简单序列,其中一些提供了索引访问。但是,.NET 提供了其他类型的集合。

字典

最有用的一种集合之一是字典。.NET 提供了Dictionary<TKey, TValue>类,还有一个相应的接口称为IDictionary<TKey, TValue>,以及一个只读版本IReadOnlyDictionary<TKey, TValue>。这些表示键/值对的集合,它们最重要的功能是根据键查找值,使字典在表示关联时非常有用。

假设您正在为支持在线讨论的应用程序编写用户界面。在显示消息时,您可能希望显示发送消息的用户的某些信息,例如他们的姓名和图片,并且您可能希望避免每次从持久存储获取这些详细信息;如果用户正在与几个朋友进行对话,那么同样的人将会重复出现,因此您需要某种缓存来避免重复查找。您可以在此缓存的一部分中使用字典。示例 5-44 展示了这种方法的概要(省略了实际获取数据的应用程序特定细节以及何时从内存中删除旧数据)。

示例 5-44. 将字典用作缓存的一部分
public class UserCache
{
    private readonly Dictionary<string, UserInfo> _cachedUserInfo = new();

    public UserInfo GetInfo(string userHandle)
    {
        RemoveStaleCacheEntries();
        if (!_cachedUserInfo.TryGetValue(userHandle, out UserInfo? info))
        {
            info = FetchUserInfo(userHandle);
            _cachedUserInfo.Add(userHandle, info);
        }
        return info;
    }

    private UserInfo FetchUserInfo(string userHandle)
    {
        // fetch info...
    }

    private void RemoveStaleCacheEntries()
    {
        // application-specific logic deciding when to remove old entries...
    }
}

public class UserInfo
{
    // application-specific user information...
}

第一个类型参数,TKey,用于查找,本例中我使用的是某种方式标识用户的字符串。TValue参数是与键相关联的值的类型,在这种情况下是先前为用户获取并在UserInfo实例中本地缓存的信息。GetInfo方法使用TryGetValue在字典中查找与用户句柄关联的数据。还有一种更简单的方法来检索值。正如示例 5-45 所示,字典提供了一个索引器。但是,如果指定的键没有条目,它会抛出KeyNotFoundException。如果您的代码始终期望找到它正在查找的内容,那么这没问题,但在我们的情况下,对于任何数据不在缓存中的用户,键将丢失。这可能会经常发生,这就是为什么我使用TryGetValue。作为替代方案,我们可以使用ContainsKey方法来查看条目是否存在,但如果值存在,则效率低下——字典将在调用ContainsKey时两次查找条目,然后在使用索引器时再次查找。TryGetValue将测试和查找作为单个操作执行。

示例 5-45. 使用索引器进行字典查找
UserInfo info = _cachedUserInfo[userHandle];

正如你所预期的那样,我们也可以使用索引器来设置与键相关联的值。在示例 5-44 中,我并没有这样做。相反,我使用了Add方法,因为它具有微妙的不同语义:通过调用Add,你表明你认为不存在具有指定键的任何条目。而字典的索引器如果存在相同键的条目则会悄无声息地覆盖它,如果你尝试使用已存在的键,Add会抛出异常。在存在已有键可能意味着有问题的情况下,最好调用Add,这样问题就不会被忽视。

IDictionary<TKey, TValue>接口要求其实现也提供ICollection<KeyValuePair<TKey, TValue>>接口,因此也提供IEnumerable<KeyValuePair<TKey, TValue>>。只读对应接口要求后者但不要求前者。这些接口依赖于泛型结构KeyValuePair<TKey, TValue>,它是一个简单的容器,将键和值包装在单个实例中。这意味着你可以使用foreach遍历字典,并依次返回每个键值对。

存在IEnumerable<T>Add方法意味着我们可以使用集合初始化器语法。这与简单列表不完全相同,因为字典的Add方法接受两个参数:键和值。但集合初始化器语法可以处理多参数的Add方法。你需要将每组参数包裹在嵌套的大括号中,就像示例 5-46 所示。

示例 5-46. 使用字典的集合初始化器语法
var textToNumber = new Dictionary<string, int>
{
    { "One", 1 },
    { "Two", 2 },
    { "Three", 3 },
};

如你在第三章中所看到的,有一种替代方式来填充字典:不使用集合初始化器,而是使用对象初始化器语法。你可能还记得,这种语法允许你在新创建的对象上设置属性。这是初始化匿名类型属性的唯一方法,但你可以在任何类型上使用它。索引器只是一种特殊的属性,因此能够使用对象初始化器设置它们是有道理的。尽管第三章已经展示了这一点,但将对象初始化器与集合初始化器进行比较仍是值得的,因此示例 5-47 展示了初始化字典的替代方式。

示例 5-47. 使用字典的对象初始化器语法
var textToNumber = new Dictionary<string, int>
{
 ["One"] = 1,
 ["Two"] = 2,
 ["Three"] = 3
};

尽管此处效果与示例 5-46 和 5-47 相同,但编译器对每种情况生成的代码略有不同。对于 示例 5-46,它通过调用 Add 来填充集合,而 示例 5-47 使用索引器。对于 Dictionary<TKey, TValue>,结果是相同的,因此没有客观理由选择其中之一,但对于某些类来说,这种差异可能很重要。例如,如果你正在使用一个具有索引器但没有 Add 方法的类,那么只有基于索引的代码才能工作。另外,使用对象初始化语法,可以在支持此操作的类型上设置索引值和属性(尽管你不能在 Dictionary<TKey, TValue> 上这样做,因为它除了索引器之外没有可写的属性)。

Dictionary<TKey, TValue> 集合类依赖哈希来提供快速查找。第 3 章 描述了 GetHashCode 方法,你应确保作为键使用的任何类型都提供了良好的哈希实现。string 类的工作效果良好。对于其他类型,只有当类型的不同实例始终被视为具有不同值时,默认的 GetHashCode 方法才可行,但对于这种情况,对键类型本身提供的 GetHashCodeEquals 实现而言,字典类提供了接受 IEqualityComparer<TKey> 的构造函数。示例 5-48 使用此功能制作了 示例 5-46 的不区分大小写版本。

示例 5-48. 不区分大小写的字典
var textToNumber =
    new Dictionary<string, int>(StringComparer.InvariantCultureIgnoreCase)
{
    { "One", 1 },
    { "Two", 2 },
    { "Three", 3 },
};

这里使用了 StringComparer 类,它提供了 IComparer<string>IEqualityComparer<string> 的各种实现,提供不同的比较规则。在这里,我选择了一个忽略大小写并且忽略配置的区域设置的排序,以确保在不同区域中表现一致。如果我要使用字符串进行显示,我可能会使用其中一种支持文化感知的比较方式。

排序字典

因为 Dictionary<TKey, TValue> 使用基于哈希的查找,当你遍历其内容时返回元素的顺序很难预测并且没有什么用处。它通常与添加内容的顺序无关,并且与内容本身也没有明显的关系。(顺序通常看起来是随机的,尽管实际上与哈希码有关。)

有时,能够以某种有意义的顺序检索字典的内容是很有用的。您可以将内容放入数组中然后进行排序,但是System.Collections.Generic命名空间包含两个更实用的IDictionary<TKey, TValue>接口实现,它们会保持其内容永久有序。这就是SortedDictionary<TKey, TValue>和更令人困惑的SortedList<TKey, TValue>,尽管名字相似,但实现了IDictionary<TKey, TValue>接口并且并没有直接实现IList<T>

这些类不使用哈希码。它们仍然提供相对快速的查找,但是通过保持其内容排序来实现。每次添加新条目时,它们都会保持顺序,这使得这两个类的添加速度比基于哈希的字典慢,但这意味着当您遍历内容时,它们会按顺序输出。与数组和列表排序一样,您可以指定自定义比较逻辑,但如果您不提供它,这些字典要求键类型实现IComparable<T>接口。

SortedDictionary<TKey, TValue>维护的顺序只有在使用其枚举支持(例如,通过foreach)时才显现出来。SortedList<TKey, TValue>也按顺序枚举其内容,但它还额外提供了对键和值的数值索引访问。这不是通过对象的索引器来完成的——它期望像任何字典一样传递一个键。相反,排序列表字典定义了两个属性,KeysValues,分别提供所有键和值作为IList<TKey>IList<TValue>,并按升序排序键。(Values也按键的顺序排序,就像Keys一样。)

对排序列表进行插入和删除对象操作相对较昂贵,因为它必须上移或下移键和值列表的内容。(这意味着单个插入具有*O(n)的复杂度。)另一方面,排序字典使用树数据结构来保持其内容排序。具体细节未指定,但插入和删除性能被记录为具有O(log n)*的复杂度,这比排序列表好得多。⁵ 然而,这种更复杂的数据结构使得排序字典的内存占用显著增加。这意味着两者都没有绝对更快或更好的选择——这完全取决于使用模式,这也是为什么运行时库同时提供了这两种。

在大多数情况下,基于哈希的Dictionary<TKey, Value>比排序字典在插入、删除和查找性能上都更好,并且比SortedDictionary<TKey, TValue>具有更低的内存消耗,因此只有在需要按顺序访问字典内容时才应使用这些排序字典集合。

集合

命名空间System.Collections.Generic定义了ISet<T>接口。这提供了一个简单的模型:特定值要么是集合的成员,要么不是。您可以添加或删除项目,但集合不会跟踪您添加项目的次数,而且ISet<T>不要求项目以任何特定顺序存储。

所有集合类型都实现了ICollection<T>,它提供了添加和删除项目的方法。事实上,它还定义了用于确定成员资格的方法:虽然我现在还没有引起注意,但您可以在示例 5-25 中看到,ICollection<T>定义了一个Contains方法。这个方法接受一个值,并在集合中返回true,如果该值在集合中。

鉴于ICollection<T>已经为集合提供了定义操作,您可能会想知道为什么我们还需要ISet<T>。但它确实增加了一些东西。虽然ICollection<T>定义了一个Add方法,但ISet<T>定义了自己略有不同的版本,它返回一个bool,因此您可以找出刚刚添加的项是否已经在集合中。示例 5-49 使用这个功能在显示其输入中的每个字符串时检测重复项。(这展示了使用方法,但实际上使用在第十章描述的Distinct LINQ 运算符会更简单。)

示例 5-49. 使用集合来确定新内容
public static void ShowEachDistinctString(IEnumerable<string> strings)
{
    var shown = new HashSet<string>();  // Implements ISet<T>
    foreach (string s in strings)
    {
        if (shown.Add(s))
        {
            Console.WriteLine(s);
        }
    }
}

ISet<T>还定义了一些用于合并集合的操作。UnionWith方法接受一个IEnumerable<T>,并将该序列中之前不在集合中的所有值添加到集合中。ExceptWith方法从集合中删除也在您传递的序列中的项目。IntersectWith方法从集合中删除不在您传递的序列中的项目。而SymmetricExceptWith还接受一个序列,并从集合中删除序列中的元素,但还将序列中以前不在集合中的值添加到集合中。

还有一些用于比较集合的方法。同样,这些方法都接受一个IEnumerable<T>参数,表示要执行比较的另一个集合。IsSubsetOfIsProperSubsetOf允许您检查调用方法的集合是否仅包含也存在于序列中的元素,后者方法还要求序列至少包含一个不在集合中的项。IsSupersetOfIsProperSupersetOf在相反的方向执行相同的测试。Overlaps方法告诉您这两个集合是否至少共享一个公共元素。

数学集合不为其内容定义顺序,因此引用集合的第 1 个、第 10 个或第 n 个元素是没有意义的——你只能询问元素是否在集合中。为了符合数学集合的这一特性,.NET 集合不支持索引访问,因此 ISet<T> 不要求支持 IList<T>。集合可以按照它们喜欢的任何顺序生成其成员在其 IEnumerable<T> 实现中。

运行时库提供了两个类来提供这个接口,采用不同的实现策略:HashSetSortedSet。从名称可以猜到,这两个内置的集合实现中的一个确实选择保持其元素的顺序;SortedSet 始终保持其内容排序,并通过其 IEnumerable<T> 实现以此顺序呈现项目。文档没有描述用于维护顺序的确切策略,但似乎使用了平衡二叉树来支持高效的插入和删除,并在尝试确定特定值是否已在列表中时提供快速查找。

另一种实现方式,HashSet,更像是 Dictionary<TKey, TValue>。它使用基于哈希的查找,这通常比有序方法更快,但如果你用 foreach 枚举集合,结果将不会按任何有用的顺序排列。(因此,HashSetSortedSet 之间的关系与基于哈希的字典与有序字典之间的关系非常类似。)

队列和栈

队列 是一个只能在列表末尾添加项目,并且只能移除第一个项目(此时,如果有第二个项目,则成为新的第一个项目)的列表。这种列表风格通常称为先进先出(FIFO)列表。这使得它比 List<T> 更不方便,因为你可以在 List<T> 中的任何位置读取、写入、插入或删除项目。然而,这些限制使得可以实现具有更好插入和删除性能特征的队列。从 List<T> 中移除项目时,必须将被移除项目后的所有项目移动到前面来填补空隙,插入需要类似的移动。在 List<T> 的末尾进行插入和删除是高效的,但如果需要 FIFO 语义,不能完全在末尾工作,而是需要在开始时进行插入或移除操作,使得 List<T> 不是一个好的选择。Queue<T> 可以使用更高效的策略,因为它只需要支持队列语义。(它在内部使用一个循环缓冲区,尽管这是一个未记录的实现细节。)

要向队列末尾添加新项,请调用 Enqueue 方法。要移除队列头部的项,请调用 Dequeue 方法,或者使用 Peek 方法查看项而不移除它。如果队列为空,这两种操作都会抛出 InvalidOperationException 异常。你可以通过 Count 属性查看队列中的项数。

虽然你无法在列表中间插入、移除或更改项,但可以检查整个队列,因为 Queue<T> 实现了 IEnumerable<T>,并且提供了 ToArray 方法,返回包含当前队列内容副本的数组。

类似于队列,但你从插入的同一端检索项,所以这是一个后进先出(LIFO)列表。Stack<T> 看起来与 Queue<T> 非常相似,但是添加和移除项的方法使用了传统的栈操作名称:PushPop(其他方法如 PeekToArray 等保持不变)。

运行时库不提供双端队列。然而,链表可以提供该功能的超集。

链表

LinkedList<T> 类提供了经典的双向链表数据结构的实现,在此结构中,序列中的每个项都被包装在一个对象中(类型为 LinkedListNode<T>),该对象提供对其前驱和后继的引用。链表的优势在于插入和删除操作成本低廉,不需要在数组中移动元素,也不需要重新平衡二叉树。它只需要交换几个引用。缺点是链表在内存开销上相对较高,每个集合中的项都需要额外的堆对象,并且获取第 n 个项对 CPU 来说比较昂贵,因为你必须从开头开始遍历 n 个节点。

LinkedList<T> 中的第一个和最后一个节点可以通过可预见的 FirstLast 属性访问。你可以使用 AddFirstAddLast 在列表的开头或结尾插入项。要在列表中间添加项,请调用 AddBeforeAddAfter 方法,传入要在其前面或后面添加新项的 LinkedListNode<T>

该列表还提供了 RemoveFirstRemoveLast 方法,以及两个重载的 Remove 方法,允许你移除具有特定值的第一个节点或特定的 LinkedListNode<T>

LinkedListNode<T> 本身提供了一个 Value 属性,类型为 T,包含序列中该节点点的实际项。其 List 属性引用回包含它的 LinkedList<T>PreviousNext 属性允许你找到前一个或后一个节点。

要遍历链表的内容,你可以从First属性检索第一个节点,然后按照每个节点的Next属性依次遍历,直到遇到null为止。但是,LinkedList<T>实现了IEnumerable<T>,所以更容易的方法是使用foreach循环。如果想要逆序获取元素,从Last开始,按照每个节点的Previous遍历。如果链表为空,FirstLast将都是null

并发集合

到目前为止描述的集合类都是为单线程使用设计的。你可以在不同的线程上同时使用不同的实例,但任何这些类型的特定实例在任一时刻只能由一个线程使用。⁶ 但是有些类型设计成可以同时被多个线程使用,而不需要使用在第十六章讨论过的同步机制。这些类型位于System.Collections.Concurrent命名空间中。

并发集合并不为每种非并发集合类型提供等价物。某些类是为了解决特定的并发编程问题而设计的。即使是那些有非并发对应物的集合,由于需要在不锁定的情况下并发使用,它们的 API 可能与任何普通集合类略有不同。

ConcurrentQueue<T>ConcurrentStack<T>类看起来最像我们已经见过的非并发集合,尽管它们并非完全相同。队列的DequeuePeek方法被TryDequeueTryPeek替代,因为在并发世界中,无法可靠地预知尝试从队列获取项是否成功(你可以检查队列的Count,但即使这个值非零,其他线程也可能在你检查计数和尝试检索项之间清空队列)。因此,获取项的操作必须与检查项是否可用的操作原子化,因此引入了可能失败而不抛出异常的Try形式。同样,并发栈提供了TryPopTryPeek方法。

ConcurrentDictionary<TKey, TValue>看起来与其非并发版本相似,但它添加了一些额外的方法,以提供并发世界所需的原子性:TryAdd方法结合了对键是否存在的测试和新条目的添加;GetOrAdd方法在同一原子操作中执行相同的操作,并返回已存在的值(如果有的话)。

没有并发列表,因为在并发世界中,为了成功使用有序的、索引的列表,你往往需要更粗粒度的同步。但如果你只想要一堆对象,可以使用ConcurrentBag<T>,它不保持任何特定的顺序。

还有一个BlockingCollection<T>,它的作用类似于队列,但允许想要从队列中取出项目的线程选择阻塞,直到有可用的项目为止。您还可以设置有限的容量,并使将项目放入队列的线程在队列当前已满时阻塞,直到空间可用为止。

不可变集合

Microsoft 提供了一组保证不可变性的集合类,并提供一种轻量级方法来生成集合的修改版本,而无需制作完整的新副本。(这些内置在 .NET Core 和 .NET 中,但在 .NET Framework 中,您需要引用 System.Collections.Immutable NuGet 包才能使用这些功能。)

在多线程环境中,不可变性可以是一个非常有用的特性,因为如果您知道正在使用的数据不会更改,那么您就不需要采取特殊预防措施来同步对其的访问。(这比仅防止修改集合的 IReadOnlyList<T> 提供了更强的保证;它可能只是一个外观,覆盖了其他线程能够修改的集合。)但是,如果您的数据偶尔需要更新,该怎么办?在您预期冲突很少的情况下,放弃不可变性并承担传统多线程同步的开销似乎有些可惜。

一种低技术手段是每次数据发生变化时(例如,当您想要向集合添加项目时),构建所有数据的新副本(创建一个包含所有旧元素和新元素副本的新集合,并从那时起使用该新集合)。这种方法有效,但可能效率极低。然而,存在技术可以有效地重用现有集合的部分。其基本原则是,如果要向集合添加项目,则构建一个新集合,该集合仅指向已有数据,并附加一些额外信息以表明发生了什么变化。实际上要复杂得多,但关键点在于,已经建立了可以实现各种集合的方式,因此您可以高效地构建看起来像原始数据的完整独立副本,并应用一些小的修改,而无需修改原始数据或完全构建新副本的集合。不可变集合为您完成所有这些工作,并将工作封装在一些简单的接口背后。

这使得一种模型成为可能,即在不影响正在使用当前数据版本的代码的情况下更新应用程序的模型。因此,您无需在读取数据时保持锁定状态——在获取数据的最新版本时可能需要一些同步,但此后,您可以在不考虑并发问题的情况下处理数据。编写多线程代码时,这一点尤为有用。微软的 C#编译器基础.NET 编译器平台(通常以其代号 Roslyn 而闻名)使用这种技术来实现有效利用多个 CPU 核心进行编译。

System.Collections.Immutable 命名空间定义了其自己的接口—— IImmutableList<T>IImmutableDictionary<TKey, TValue>IImmutableQueue<T>IImutableStack<T>IImutableSet<T>。这是必要的,因为所有修改集合的操作都需要返回一个新的集合。示例 5-50 展示了向字典添加条目的意义。

示例 5-50. 创建不可变字典
IImmutableDictionary<int, string> d = ImmutableDictionary.Create<int, string>();
d = d.Add(1, "One");
d = d.Add(2, "Two");
d = d.Add(3, "Three");

不可变类型的整个要点在于,使用现有对象的代码可以确保不会发生任何变化,因此添加、删除或修改必然意味着创建一个新对象,该对象看起来与旧对象完全相同,但应用了修改。(内置的 string 类型也是不可变的,工作方式完全相同——诸如 Trim 这类看起来会改变值的方法实际上返回一个新的字符串。)因此,在 示例 5-50 中,变量 d 连续引用四个不同的不可变字典:一个空字典,一个包含一个值的字典,一个包含两个值的字典,最后一个包含所有三个值的字典。

如果您像这样添加一系列值,并且不会将中间结果提供给其他代码,那么通过单个操作添加多个值更为高效,因为它不必为每个添加的条目生成单独的 IIm⁠mut⁠ab⁠le​Dic⁠tio⁠nar⁠y<T⁠Key⁠, TValue>。(您可以将不可变集合视为类似源代码控制系统的工作方式,其中每个更改对应一个提交——对于每个提交,将存在一个版本的集合,该版本代表其在该更改之后的内容。)将一组相关的更改批量处理为单个“版本”更为高效,因此所有集合都具有 AddRange 方法,可让您一次添加多个项。

当你从头开始构建一个新集合时,同样的原则适用:如果你将所有初始内容放入集合的第一个版本中,而不是逐个添加项目,那么效率会更高。每种不可变集合类型都提供了一个嵌套的Builder类,使这一过程更加简单,使你能够逐个添加项目,但在完成后延迟实际集合的创建。示例 5-51 展示了如何做到这一点。

示例 5-51. 使用构建器创建不可变字典
ImmutableDictionary<int, string>.Builder b =
    ImmutableDictionary.CreateBuilder<int, string>();
b.Add(1, "One");
b.Add(2, "Two");
b.Add(3, "Three");
IImmutableDictionary<int, string> d = b.ToImmutable();

构建器对象不是不可变的。与StringBuilder类似,它是一个可变对象,提供了一种有效的方式来构建不可变对象的描述。

除了不可变列表、字典、队列、栈和集合类型之外,还有一种不同于其他的不可变集合类:ImmutableArray<T>。这本质上是一个包装器,在数组周围提供了一个不可变的外观。它实现了IImmutableList<T>,这意味着它提供了与不可变列表相同的服务,但性能特征有着显著的不同。

当你在不可变列表上调用Add方法时,它会尝试重用已经存在的大部分数据,因此如果你的列表中有一百万个项目,“新”列表由Add返回的并不包含这些项目的新副本,而是大部分重用了已经存在的数据。然而,为了实现这一点,ImmutableList<T>在内部使用了一种相当复杂的树数据结构。结果是,在ImmutableList<T>中通过索引查找值的效率远不如使用数组(或List<T>)。ImmutableList<T>的索引器具有*O(log n)*的复杂度。

对于读取操作来说,ImmutableArray<T>要高效得多——作为数组的包装器,它具有*O(1)*的复杂度,即获取条目的时间是常数,无论集合大小如何。然而,代价是对于构建修改版本的所有IImmutableList<T>方法(AddRemoveInsertSetItem等)来说,都需要构建一个完整的新数组,包括需要传递的任何数据的新副本。(换句话说,与所有其他不可变集合类型不同,ImmutableArray<T>采用了我之前描述的低技术方法来实现不可变性。)这使得修改变得非常昂贵,但如果你有一些数据在创建数组后不期望修改,这是一个很好的权衡,因为你只会构建一个数组的副本。如果偶尔需要进行修改,每次变更的高成本总体来看可能仍然是值得的。

摘要

在本章中,我们看到了运行时提供的数组的固有支持,以及.NET 在需要更多于固定大小项目列表时提供的各种集合类。接下来,我们将看一个更高级的主题:继承。

¹ 令人惊讶的是,foreach并不需要任何特定的接口;它将使用任何具有返回提供MoveNext方法和Current属性的对象的GetEnumerator方法的类型。在泛型出现之前,这是通过值类型元素集合进行迭代的唯一方法,而不会对每个项进行装箱。第七章描述了装箱。尽管泛型已经解决了这个问题,但基于非接口的枚举继续很有用,因为它使集合类能够提供额外的GetEnumerator方法,返回一个struct,在foreach循环开始时避免额外的堆分配。List<T>就是这样做的。

² 在调用Dispose时,部分清理工作已经完成。请记住,所有IEnumerator<T>的实现都实现了IDisposable接口。foreach关键字在遍历集合后会调用Dispose(即使遍历由于错误而提前终止)。如果不使用foreach,而是手动进行迭代,则必须牢记调用Dispose的重要性。

³ 由于终点相对索引存储为负数,你可能会想知道是否合法,因为int类型不区分正零和负零。它是允许的,因为正如你很快就会看到的那样,当使用范围时,Index中是有用的,所以Index能够进行区分。

⁴ 在直接针对int类型的数组索引器使用^的情况下(例如,a[^i],其中i是一个int),编译器会生成稍微简单的代码。它不会将i转换为Index,然后调用GetOffset,而是会生成等效于a[a.Length - i]的代码。

⁵ 常规的复杂性分析警告适用于小型集合,更简单的数据结构可能表现更好,其理论优势只在处理较大集合时才能显现。

⁶ 这个规则有一个例外:只要没有线程尝试修改它,你可以在多个线程中使用同一个集合。

第六章:继承

C#类支持继承,这是一种流行的面向对象代码重用机制。当你编写一个类时,可以选择性地指定一个基类。你的类将从这个基类派生,这意味着基类中的所有内容将出现在你的类中,以及你添加的任何成员。

类和基于类的记录类型仅支持单一继承(因此只能指定一个基类)。接口提供了一种多重继承的形式。值类型,包括record struct类型,根本不支持继承。其中一个原因是值类型通常不通过引用使用,这就移除了继承的主要好处之一:运行时多态性。继承与值类型的行为不一定不兼容——某些语言可以处理它——但通常存在问题。例如,将某个派生类型的值赋给其基类型的变量将导致丢失派生类型添加的所有字段,这是一个被称为slicing的问题。C#通过将继承限制为引用类型来避免这个问题。当你将某个派生类型的变量赋给基类型的变量时,你复制的是一个引用,而不是对象本身,因此对象保持完整。Slicing 仅在基类提供了克隆对象的方法且未提供派生类扩展它的方法时出现问题(或者提供了,但某个派生类未扩展它)。

类使用在示例 6-1 中展示的语法来指定基类——基类型出现在紧随类名后的冒号之后。本示例假设在项目的其他地方或其使用的库中已经定义了一个名为SomeClass的类。

示例 6-1 指定一个基类
public class Derived : SomeClass
{
}

public class AlsoDerived : SomeClass, IDisposable
{
    public void Dispose() { }
}

如同你在第三章看到的那样,如果类实现了任何接口,这些接口也会在冒号后列出。如果你想要从一个类派生,并且还想实现接口,基类必须首先出现,正如示例 6-1 所示。

你可以从一个又从另一个类派生的类派生。示例 6-2 中的MoreDerived类派生自Derived,后者又派生自Base

示例 6-2 继承链
public class Base
{
}

public class Derived : Base
{
}

public class MoreDerived : Derived
{
}

这意味着MoreDerived从技术上讲具有多个基类:它直接从Derived派生,间接从Base派生(通过Derived)。这不是多重继承,因为只有一个继承链——任何单个类最多直接从一个基类派生。(所有类都直接或间接地从object派生,如果你没有指定基类,则默认为基类。)

由于派生类继承了基类的所有内容——包括所有字段、方法和其他成员,无论是公有的还是私有的——因此,派生类的一个实例可以执行基类实例可以执行的所有操作。这是许多语言中继承所暗示的经典的是一个关系。任何MoreDerived的实例都是Derived的实例,也是Base的实例。C#的类型系统认识到这种关系。

继承与转换

C# 提供了各种内置的隐式转换。在第二章中,我们看到数字类型的转换,但引用类型也有转换。如果某个类型DB派生(直接或间接),那么类型D的引用可以隐式地转换为类型B的引用。这是基于我在前一节中描述的是一个关系——任何D的实例都是B。这种隐式转换使多态成为可能:编写的针对B的代码将能够与任何从B派生的类型一起工作。

隐式引用转换是特殊的。与其他转换不同,它们不会以任何方式改变值。(所有内置的隐式数字转换都会从其输入创建一个新值,通常涉及表示的变化。例如,整数 1 的二进制表示对于floatint类型是不同的。)实际上,它们转换的是引用的解释,而不是引用本身或它所引用的对象。正如你将在本章后面看到的,CLR 在考虑隐式引用转换的可用性时,会考虑到这些地方,但不会考虑其他形式的转换。

警告

两个引用类型之间的自定义隐式转换不算作这些目的的隐式引用转换,因为需要调用方法来实现这样的转换。在隐式引用转换的特殊情况中,依赖于“转换”在运行时无需工作这一事实。

反向没有隐式转换——虽然一个类型为B的变量可以引用类型为D的对象,但不能保证它会这样做。可能有任意数量从B派生的类型,一个B变量可以引用它们中的任何一个实例。然而,有时你可能希望尝试将引用从基类型转换为派生类型,这种操作有时称为下转型。也许你知道某个变量确实持有某种类型的引用。或者你不确定,希望你的代码为特定类型提供额外的服务。C# 提供了三种方法来执行此操作。

我们可以使用强制转换语法尝试进行下转型。这是我们用于执行非隐式数字转换的相同语法,如示例 6-3 所示。

示例 6-3. 感觉下转型
public static void UseAsDerived(Base baseArg)
{
    var d = (Derived) baseArg;

    // ...go on to do something with d
}

因此,这种转换不能保证成功——这就是为什么我们不能使用隐式转换。如果在baseArg参数引用的对象既不是Derived的实例,也不是Derived的派生类时尝试这样做,转换将失败,抛出InvalidCastException(异常在第 8 章中描述)。

只有当你确信对象确实是你期望的类型时,才适合使用转换。如果对象类型不符合预期,你将认为这是一个错误。当 API 接受一个稍后将返回给你的对象时,这很有用。许多异步 API 会这样做,因为在同时启动多个操作的情况下,当你收到完成通知时,需要一种方法来确定哪个操作已经完成(尽管正如我们将在后面的章节中看到的,有各种方法来解决这个问题)。由于这些 API 不知道你将要关联到操作的数据类型,它们通常只接受object类型的引用,当引用最终交还给你时,你通常会使用转换将其转换回所需类型的引用。

有时,你不能确定一个对象是否具有特定类型。在这种情况下,你可以使用as运算符,如示例 6-4 所示。这允许你尝试转换而不会引发异常。如果转换失败,该运算符将返回null

示例 6-4. as运算符
public static void MightUseAsDerived(Base b)
{
    var d = b as Derived;

    if (d != null)
    {
        // ...go on to do something with d
    }
}

尽管这种技术在现有代码中非常常见,但在 C# 7.0 中引入的模式提供了一种更简洁的替代方法。示例 6-5 与示例 6-4 具有相同的效果:只有在b引用Derived的实例时,if语句的主体才会执行,并且可以通过变量d访问它。此处的is关键字表示我们想要对b进行模式测试。在这种情况下,我们使用的是声明模式,它执行与as运算符相同的运行时类型测试。应用带有is的模式的表达式生成一个bool,指示模式是否匹配。我们可以将此用作if语句的条件表达式,无需与null进行比较。由于声明模式包含变量声明和初始化,示例 6-4 中需要两个语句的工作都可以合并到示例 6-5 的if语句中。

示例 6-5. 带有声明模式的is运算符
public static void MightUseAsDerived(Base b)
{
    if (b is Derived d)
    {
        // ...go on to do something with d
    }
}

除了更紧凑外,is运算符还有一个好处,即在as不起作用的一个情况下也可以起作用:您可以测试类型为object的引用是否引用值类型的实例,例如int。(这可能看起来有些矛盾——您怎么可能有一个指向不是引用类型的东西的引用?第七章将展示这是可能的。)as运算符不起作用,因为当实例不是指定类型时它返回null,但是当然它不能对值类型执行此操作——没有int类型的null。由于声明模式消除了对null的测试的需要——我们只使用is运算符生成的bool结果——我们可以自由使用值类型。

提示

偶尔您可能希望检测特定类型是否存在,而无需执行转换。由于is可以跟随任何模式,因此您可以使用类型模式,例如,is Derived。这执行与声明模式相同的测试,而无需引入新变量。

使用刚才描述的技术进行转换时,不一定需要指定确切的类型。只要对象的真实类型到您要查找的类型之间存在隐式引用转换,这些操作就会成功。例如,假设您有一个类型为Base的变量,当前包含对MoreDerived实例的引用。显然,您可以将引用转换为MoreDerived(对于该类型,asis也会成功),但正如您可能期望的那样,转换为Derived也可以工作。

这四种机制也适用于接口。当您尝试将引用转换为接口类型的引用(或使用类型模式测试接口类型)时,如果所引用的对象实现了相关接口,则转换将成功。

接口继承

接口支持继承,但与类继承并不完全相同。语法类似,但正如示例 6-6 所示,接口可以指定多个基接口。虽然.NET 仅提供单一实现继承,但这种限制不适用于接口,因为大多数可能导致多重继承的复杂性和潜在歧义都不适用于纯抽象类型。最棘手的问题围绕着字段的处理,这意味着即使具有默认实现的接口也支持多重继承,因为这些接口不能向实现类型添加字段或公共成员。(当类使用成员的默认实现时,该成员只能通过接口类型的引用访问。)

示例 6-6. 接口继承
interface IBase1
{
    void Base1Method();
}

interface IBase2
{
    void Base2Method();
}

interface IBoth : IBase1, IBase2
{
    void Method3();
}

尽管 接口继承 是这个特性的官方名称,但这是一个误称——尽管派生类从它们的基类继承所有成员,派生接口却不是这样。看起来它们似乎是这样的——给定类型为 IBoth 的变量,你可以调用由其基类定义的 Base1MethodBase2Method 方法。然而,接口继承的真正含义是,实现一个接口的任何类型都有义务实现所有继承的接口。因此,实现 IBoth 的类必须同时实现 IBase1IBase2。这是一个微妙的区别,特别是因为 C# 不要求你显式列出基接口。在 示例 6-7 中的类仅声明它实现了 IBoth。然而,如果你使用 .NET 的反射 API 来检查类型定义,你会发现编译器已将 IBase1IBase2 添加到类实现的接口列表中,以及显式声明的 IBoth

示例 6-7. 实现一个派生接口
public class Impl : IBoth
{
    public void Base1Method()
    {
    }

    public void Base2Method()
    {
    }

    public void Method3()
    {
    }
}

由于派生接口的实现必须实现所有基接口,C# 允许你通过派生类型的引用直接访问基类的成员,因此类型为 IBoth 的变量提供了对 Base1MethodBase2Method 的访问,以及该接口自身的 Method3。从派生接口类型到它们的基类存在隐式引用转换。例如,类型为 IBoth 的引用可以分配给类型为 IBase1IBase2 的变量。

泛型

如果你从一个泛型类派生,你必须提供它所需的类型参数。如果你的派生类型也是泛型的,你可以选择使用自己的类型参数作为参数,只要它们符合基类定义的任何约束。示例 6-8 展示了这两种技术,并且还说明了当从具有多个类型参数的类派生时,你可以使用混合方法,直接指定一个类型参数,对另一个类型参数则放任不管。

示例 6-8. 从泛型基类派生
public class GenericBase1<T>
{
    public T? Item { get; set; }
}

public class GenericBase2<TKey, TValue>
    where TValue : class
{
    public TKey? Key { get; set; }
    public TValue? Value { get; set; }
}

public class NonGenericDerived : GenericBase1<string>
{
}

public class GenericDerived<T> : GenericBase1<T>
{
}

public class MixedDerived<T> : GenericBase2<string, T>
    where T : class
{
}

尽管你可以自由地将任何类型参数用作基类的类型参数,但你不能从类型参数派生。如果你习惯于允许这样做的语言,这可能有点令人失望,但是 C# 语言规范明确禁止这样做。然而,你可以使用自己的类型作为基类的类型参数。你还可以为类型参数指定约束,要求它必须从你自己的类型派生。示例 6-9 展示了这些情况。

示例 6-9. 自引用类型参数
public class SelfAsTypeArgument : IComparable<SelfAsTypeArgument>
{
    // ...implementation removed for clarity
}

public class Curious<T>
    where T : Curious<T>
{
}

协变性和逆变性

在 第四章 中,我提到泛型类型有特殊的类型兼容规则,称为 协变性逆变性。这些规则确定了当类型参数之间存在隐式转换时,某些泛型类型的引用是否可以相互隐式转换。

注:

协变和逆变仅适用于接口和委托的泛型类型参数。(委托在第九章中描述。)你不能定义协变或逆变的类、结构体或记录。

考虑前面示例 6-2 中展示的简单的 BaseDerived 类,并查看接受任何 Base 的方法示例 6-10。 (它对它什么都不做,但这里重要的是它的签名说它可以使用什么。)

示例 6-10. 接受任何 Base 的方法
public static void UseBase(Base b)
{
}

我们已经知道,除了接受任何 Base 的引用外,这也可以接受任何从 Base 派生的类型的实例,比如 Derived。考虑一下示例 6-11 中的方法。

示例 6-11. 接受任何 IEnumerable<Base> 的方法
public static void AllYourBase(IEnumerable<Base> bases)
{
}

这需要一个实现了第五章中描述的 IEnumerable<T> 泛型接口的对象,其中 TBase。如果我们尝试传递一个未实现 IEnumerable<Base> 但实现了 IEnumerable<Derived> 的对象,你认为会发生什么?示例 6-12 就这样做了,并且编译通过。

示例 6-12. 传递一个派生类型的 IEnumerable<T>
IEnumerable<Derived> derivedItems = new[] { new Derived(), new Derived() };
AllYourBase(derivedItems);

从直觉上讲,这是有道理的。AllYourBase 方法期望一个能够提供类型为 Base 的对象序列的对象。IEnumerable<Derived> 符合要求,因为它提供了 Derived 对象的序列,而任何 Derived 对象也都是 Base。但是,关于示例 6-13 中的代码呢?

示例 6-13. 接受任何 ICollection<Base> 的方法
public static void AddBase(ICollection<Base> bases)
{
    bases.Add(new Base());
}

回想一下第五章中提到的 ICollection<T> 派生自 IEnumerable<T>,并且它添加了以某些方式修改集合的能力。这个特定的方法通过向集合中添加一个新的 Base 对象来利用这一点。对于示例 6-14 中的代码来说,这将是个麻烦。

示例 6-14. 错误:尝试传递一个带有派生类型的 ICollection<T>
ICollection<Derived> derivedList = new List<Derived>();
AddBase(derivedList);  // Will not compile

使用 derivedList 变量的代码将期望该列表中的每个对象都是 Derived 类型(或者从中派生的类型,比如示例 6-2 中的 MoreDerived 类)。但是 示例 6-13 中的 AddBase 方法尝试添加一个普通的 Base 实例。这是不正确的,编译器也不允许这样做。调用 AddBase 将产生编译器错误,指出 ICollection<Derived> 类型的引用不能隐式转换为 ICollection<Base> 类型的引用。

编译器是如何知道这是不允许的,而非常相似的从IEnumerable<Derived>IEnumerable<Base>的转换却是允许的?顺便说一句,并不是因为示例 6-13 包含可能引起问题的代码。即使AddBase方法完全为空,你仍然会得到相同的编译器错误。之所以在示例 6-12 中没有错误,是因为IEnumerable<T>接口将其类型参数T声明为协变。你在第五章中看到了这种语法,但我并没有特别强调,因此示例 6-15 再次展示了该接口定义中相关的部分。

示例 6-15. 协变类型参数
public interface IEnumerable<out T> : IEnumerable

那个out关键字完成了任务。(同样,C#延续了 C 家族传统,为每个关键字赋予多种功能——我们首次在方法参数返回信息给调用者的情境中见到此关键字。直观地说,将类型参数T描述为out是有意义的,因为IEnumerable<T>接口只提供T—它并不定义任何接受T的成员。(该接口仅在一个地方使用了这个类型参数:它的只读Current属性。

将其与ICollection<T>进行比较。这个接口从IEnumerable<T>派生,因此显然可以从中获取T,但也可以将T传递给其Add方法。因此,ICollection<T>不能使用out注释其类型参数。(如果您尝试编写自己的类似接口,如果您声明类型参数为协变,编译器将产生错误。它不仅仅是凭您的话,而是检查确实不能在任何地方传递T。)

编译器拒绝示例 6-14 中的代码,因为ICollection<T>中的T不是协变的。术语协变逆变来自数学中的范畴论分支。类似IEnumerable<T>T行为的参数被称为协变,因为泛型类型的隐式引用转换与类型参数的转换方向相同:Derived可以隐式转换为Base,并且由于IEnumerable<T>中的T是协变的,IEnumerable<Derived>隐式转换为IEnumerable<Base>

逆变工作方式相反,并且你可能会猜到,我们用in关键字表示它。使用类型成员的代码最容易看到它的实际作用,因此示例 6-16 展示了一对稍微有趣的类,比之前的示例稍有不同。

示例 6-16. 带有实际成员的类层次结构
public class Shape
{
    public Rect BoundingBox { get; set; }
}

public class RoundedRectangle : Shape
{
    public double CornerRadius { get; set; }
}

示例 6-17 定义了两个使用这些形状类型的类。它们都实现了我在第四章中介绍的 IComparer<T>BoxAreaComparer 根据其边界框的面积比较两个形状 —— 边界框覆盖面积较大的形状将被认为比较大。另一方面,CornerSharpnessComparer 比较圆角矩形,看它们的角有多尖。

示例 6-17. 比较形状
public class BoxAreaComparer : IComparer<Shape>
{
    public int Compare(Shape? x, Shape? y)
    {
        if (x is null)
        {
            return y is null ? 0 : -1;
        }
        if (y is null)
        {
            return 1;
        }

        double xArea = x.BoundingBox.Width * x.BoundingBox.Height;
        double yArea = y.BoundingBox.Width * y.BoundingBox.Height;

        return Math.Sign(xArea - yArea);
    }
}

public class CornerSharpnessComparer : IComparer<RoundedRectangle>
{
    public int Compare(RoundedRectangle? x, RoundedRectangle? y)
    {
        if (x is null)
        {
            return y is null ? 0 : -1;
        }
        if (y is null)
        {
            return 1;
        }

        // Smaller corners are sharper, so smaller radius is "greater" for
        // the purpose of this comparison, hence the backward subtraction.
        return Math.Sign(y.CornerRadius - x.CornerRadius);
    }
}

RoundedRectangle 类型的引用隐式转换为 Shape,那么IComparer<T>呢?我们的 BoxAreaComparer 可以比较任何形状,并通过实现 IComparer<Shape> 声明了这一点。比较器的类型参数 T 只在 Compare 方法中使用,它可以接受任何 Shape。如果我们传递一对 RoundedRectangle 引用,它也不会感到困惑,因此我们的类完全可以作为 IComparer<RoundedRectangle>。因此,从 IComparer<Shape>IComparer<RoundedRectangle> 的隐式转换是有意义的,并且实际上是允许的。然而,CornerSharpnessComparer 更挑剔。它使用 CornerRadius 属性,该属性仅在圆角矩形上可用,而不是在任何旧的 Shape 上。因此,从 IComparer<RoundedRectangle>IComparer<Shape> 不存在隐式转换。

这与我们在IEnumerable<T>中看到的情况正好相反。当存在从 T1T2 的隐式引用转换时,IEnumerable<T1>IEnumerable<T2> 之间可以进行隐式转换。但是在 IComparer<T>IComparer<T2> 之间的隐式转换则是在另一个方向上存在隐式引用转换:从 T2T1。这种反向关系称为逆变。示例 6-18 是 IComparer<T> 的定义摘录,显示了这种逆变类型参数。

示例 6-18. 逆变类型参数
public interface IComparer<in T>

大多数泛型类型参数既不是协变也不是逆变(它们是不变的)。ICollection<T> 不能是变体,因为它包含一些接受 T 的成员和一些返回 T 的成员。ICollection<Shape> 可能包含不是 RoundedRectangles 的形状,所以你不能将它传递给一个期望 ICollection<RoundedRectangle> 的方法,因为这样的方法会期望从集合检索到的每个对象都是圆角矩形。相反,ICollection<RoundedRectangle> 不能期望允许添加除了圆角矩形之外的形状,所以你不能将 ICollection<RoundedRectangle> 传递给一个期望 ICollection<Shape> 的方法,因为该方法可能尝试添加其他类型的形状。

数组是协变的,就像IEnumerable<T>一样。这很奇怪,因为我们可以编写像示例 6-19 中的方法一样。

示例 6-19. 修改数组中的元素
public static void UseBaseArray(Base[] bases)
{
    bases[0] = new Base();
}

如果我试图使用 Example 6-20 中的代码调用此方法,我将犯与 Example 6-14 相同的错误,那里我试图将ICollection<Derived>传递给一个试图将不是Derived的东西放入集合中的方法。但是,虽然 Example 6-14 不能编译,Example 6-20 却可以,这归因于数组的令人惊讶的协变性。

Example 6-20. 传递一个具有派生元素类型的数组
Derived[] derivedBases = { new Derived(), new Derived() };
UseBaseArray(derivedBases);

这使得看起来我们可以偷偷地让这个数组接受一个不是数组元素类型实例的引用——在这种情况下,将一个Base的引用放入Derived[]中。但这将违反类型系统。这是否意味着天要塌下来了?

实际上,C#正确禁止这种违规行为,但依赖 CLR 在运行时执行此操作。尽管类型为Derived[]的数组的引用可以隐式转换为类型为Base[]的引用,但任何试图将不符合类型系统的数组元素设置的尝试都会抛出ArrayTypeMismatchException异常。因此,当试图将一个Base的引用分配给Derived[]数组时,Example 6-19 会抛出该异常。

运行时检查确保了类型安全的维护,并且这使得一个便利特性得以实现。如果我们编写一个仅从数组中读取的方法,我们可以传递一些派生元素类型的数组。但缺点是 CLR 在运行时需要额外工作,当修改数组元素时,以确保没有类型不匹配。它可以优化代码以避免每次赋值都进行检查,但仍然会有一些开销,这意味着数组并不像可能的那么高效。

这种有些奇特的安排可以追溯到.NET 在正式化协变和逆变的概念之前的时期——这些概念是随着泛型引入.NET 2.0 而引入的。也许如果从一开始就有泛型,数组将不会如此奇怪,尽管说了这些,即使在.NET 2.0 之后,它们特有的协变形式多年来仍是框架中唯一内建的通过索引读取集合协变传递到方法的机制。直到.NET 4.5 引入了IReadOnlyList<T>(其中T是协变的),框架中才有只读索引集合接口,因此没有带有协变类型参数的标准索引集合接口(IList<T>是读/写的,因此像ICollection<T>一样,它不能提供协变)。

在我们讨论类型兼容性和继承带来的隐式引用转换时,还有一个类型需要我们关注:object

System.Object

在 C#中,System.Object类型,或者我们通常称之为object,非常有用,因为它可以充当一种通用容器:这种类型的变量可以持有几乎任何东西的引用。我之前提到过这一点,但我还没有解释为什么它是真的。这能行得通的原因是几乎所有东西都从object派生。

当你在编写一个类或记录时没有指定基类时,C# 编译器会自动将object作为基类。稍后我们会看到,对于某些类型,如结构体,它会选择不同的基类,但即使这些类型间接地从object派生。(作为例外,指针类型是一个例外——它们不从object派生。)

接口和对象之间的关系稍微复杂一些。接口不从object派生,因为接口只能指定其他接口作为其基类。然而,任何接口类型的引用都可以隐式转换为object类型的引用。这种转换总是有效的,因为能够实现接口的所有类型最终都从object派生。此外,即使严格来说,这些方法并不是接口的成员,C# 选择通过接口引用使object类的成员可用。这意味着任何类型的引用始终提供了由object定义的以下方法:ToStringEqualsGetHashCodeGetType

System.Object 的普遍方法

我已经在几个例子中使用了ToString。默认实现返回对象的类型名称,但许多类型提供了它们自己的ToString实现,返回对象当前值的更有用的文本表示。例如,数值类型返回其值的十进制表示,而bool返回"True""False"

我在 第三章 中讨论了 EqualsGetHashCode 方法,但我会在这里简要回顾一下。Equals 允许将一个对象与任何其他对象进行比较。默认实现只执行标识比较,即只有当对象与自身比较时返回 true。许多类型提供了一个 Equals 方法,执行类似值的比较——例如,两个不同的 string 对象可能包含相同的文本,这种情况下它们将报告彼此相等。(如果需要对提供值比较的对象执行基于标识的比较,可以使用 object 类的静态 ReferenceEquals 方法。)顺便说一句,object 还定义了一个接受两个参数的静态版本的 Equals 方法。这检查参数是否为 null,如果两个参数都为 null 则返回 true,如果只有一个参数为 null 则返回 false;否则,它将委托给第一个参数的 Equals 方法。正如在 第三章 中讨论的那样,GetHashCode 返回一个整数,它是对象值的简化表示,被哈希机制(例如 Dictionary<TKey, TValue> 集合类)使用。任何两个 Equals 返回 true 的对象必须返回相同的哈希码。

GetType 方法提供了一种发现对象类型信息的方式。它返回一个 Type 类型的引用。这是反射 API 的一部分,是 第十三章 的主题。

除了这些可以通过任何引用访问的公共成员外,object 还定义了另外两个不是普遍可访问的成员。对象只能在自身上访问这些成员。它们是 FinalizeMemberwiseClone。CLR 调用 Finalize 方法来通知你的对象不再使用,并且它占用的内存即将被回收。在 C# 中,我们通常不直接使用 Finalize 方法,因为 C# 通过析构函数(我将在 第七章 中展示)来呈现这一机制。MemberwiseClone 创建一个与你的对象相同类型的新实例,其初始化为你的对象所有字段的副本。如果需要一种方式来创建对象的克隆,这可能比手动复制所有内容的代码更简单,尽管它不是非常快速。

最后两个方法之所以只能从对象内部访问,是因为你可能不希望其他人克隆你的对象,而且如果外部代码能调用 Finalize 方法,让你的对象误以为即将释放内存,这将毫无帮助。object 类限制了这些成员的可访问性。但它们不是私有的——这意味着只有 object 类本身才能访问它们,因为私有成员甚至对派生类也不可见。相反,object 将这些成员设置为 protected,这是为继承场景设计的访问限定符。

访问权限和继承

到目前为止,你应该已经熟悉了大多数可用于类型及其成员的访问级别。标记为 public 的元素对所有人可见,private 成员仅能从声明它们的类型内部访问,而 internal 成员对同一组件中定义的代码可见。¹ 但是通过继承,我们还可以获得其他三种访问权限选项。

protected 标记的成员在定义它的类型内部和任何派生类型内部都可用。但是对于使用你的类型实例的代码而言,protected 成员是不可访问的,就像 private 成员一样。

类型成员的下一个保护级别是 protected internal。(如果你喜欢,也可以写成 internal protected;顺序没有影响。)这使得成员比单独的 protectedinternal 更容易访问:成员将对所有派生类型和共享同一个程序集的所有代码可见。

继承增加的第三个保护级别是 protected private。使用此标记的成员(或等效的 private protected)仅对从定义类型派生且位于同一组件中的类型可用。

你可以使用 protectedprotected internalprotected private 来修饰类型的任何成员,而不仅仅是方法。你甚至可以使用这些访问权限修饰符来定义嵌套类型。

尽管 protectedprotected internal(尽管不包括 protected private)成员不能通过定义类型的普通变量访问,它们仍然是类型公共 API 的一部分,这意味着任何有权访问你的类的人都能够使用这些成员。与大多数支持类似机制的语言一样,C# 中的 protected 成员通常用于提供派生类可能找到有用的服务。如果你编写了一个支持继承的 public 类,那么任何人都可以从它派生,并且能够访问其 protected 成员。因此,删除或更改 protected 成员会像删除或更改 public 成员一样,可能会破坏依赖于你的类的代码。

当你从一个类派生时,不能使你的类比其基类更可见。例如,如果你从一个 internal 类派生,你不能将你的类声明为 public。你的基类形成了你的类 API 的一部分,因此任何希望使用你的类的人实际上也在使用其基类;这意味着如果基类不可访问,你的类也将不可访问,这就是为什么 C# 不允许一个类比其基类更可见的原因。如果你从一个 protected 的嵌套类派生,你的派生类可以是 protectedprivateprotected private,但不能是 publicinternalprotected internal

这个限制不适用于你实现的接口。public类可以自由实现internalprivate接口。但是,它适用于接口的基接口:public接口不能从internal接口派生。

在定义方法时,还有另一个关键字可以为派生类型增加效果:virtual

虚拟方法

虚方法是派生类型可以替换的方法。object定义的几种方法都是虚拟的:ToStringEqualsGetHashCodeFinalize方法都被设计为可替换的。用于生成对象值的有用文本表示所需的代码会因类型不同而大不相同,判断相等性和生成哈希码所需的逻辑也会不同。类型通常仅在需要在其不再使用时执行一些专门的清理工作时定义终结器。

并非所有方法都是虚方法。事实上,C#默认情况下使方法为非虚方法。object类的GetType方法不是虚方法,因此您可以始终信任它返回的信息,因为您知道您调用的是.NET 提供的GetType方法,而不是某种特定类型的替代品,旨在愚弄您。要声明方法应为虚方法,请使用virtual关键字,如示例 6-21 所示。

示例 6-21. 带有虚方法的类
public class BaseWithVirtual
{
    `public` `virtual` `void` `ShowMessage``(``)`
    {
        Console.WriteLine("Hello from BaseWithVirtual");
    }
}
注意

你也可以将virtual关键字应用于属性。属性在底层只是方法,因此这会使访问器方法变为虚方法。事件也是如此,这在第 9 章中有讨论。

调用虚方法的语法并无特殊之处。如示例 6-22 所示,它看起来就像调用任何其他方法一样。

示例 6-22. 使用虚方法
public static void CallVirtualMethod(BaseWithVirtual o)
{
    o.ShowMessage();
}

虚方法调用与非虚方法调用的区别在于,虚方法调用会在运行时决定调用哪个方法。在示例 6-22 中的代码实际上会检查传入的对象,如果对象的类型提供了自己的ShowMessage实现,那么会调用那个实现,而不是在BaseWithVirtual中定义的实现。方法的选择基于目标对象在运行时的实际类型,而不是在编译时确定的表达式的静态类型。

注意

由于虚方法调用是基于调用方法的对象的类型选择方法,静态方法不能是虚方法。

派生类型不必替换虚方法。示例 6-23 展示了两个从示例 6-21 派生的类。第一个保留了基类对ShowMessage的实现。第二个对其进行了重写。请注意override关键字——C#要求我们明确声明我们打算重写虚方法。

示例 6-23. 重写虚方法
public class DeriveWithoutOverride : BaseWithVirtual
{
}

public class DeriveAndOverride : BaseWithVirtual
{
    public override void ShowMessage()
    {
        Console.WriteLine("This is an override");
    }
}

我们可以将这些类型与示例 6-22 中的方法一起使用。示例 6-24 调用它三次,每次传入不同类型的对象。

示例 6-24. 利用虚方法
CallVirtualMethod(new BaseWithVirtual());
CallVirtualMethod(new DeriveWithoutOverride());
CallVirtualMethod(new DeriveAndOverride());

这产生了以下输出:

Hello from BaseWithVirtual
Hello from BaseWithVirtual
This is an override

显然,当我们传递基类的实例时,我们会得到基类的ShowMessage方法的输出。对于未提供重写的派生类,我们也会得到相同的输出。只有最终重写了该方法的类才会产生不同的输出。这表明虚方法提供了编写多态代码的一种方式:示例 6-22 可以使用多种类型。

当重写一个方法时,方法名和其参数类型必须完全匹配。在大多数情况下,返回类型也会相同,但并非总是如此。如果virtual方法的返回类型不是void,并且不是ref返回,则重写方法的返回类型可以不同,只要存在从该类型到virtual方法返回类型的隐式引用转换。简而言之,重写方法允许在返回类型上更为具体。这意味着像示例 6-25 这样的例子是合法的。

示例 6-25. 缩小返回类型的重写
public class Product { }
public class Book : Product { }

public class ProductSourceBase
{
    public virtual Product Get() { return new Product(); }
}

public class BookSource : ProductSourceBase
{
    public override Book Get() { return new Book(); }
}

注意,Get的重写的返回类型是Book,即使它重写的virtual方法返回Product也是如此。这是可以接受的,因为通过ProductSourceBase类型的引用调用此方法的任何东西都将期望得到一个Product类型的引用,并且由于继承关系,BookProduct的一种。因此,ProductSourceBase类型的用户将不会察觉到或受到此更改的影响。在直接处理派生类型的代码需要知道将返回的具体类型的情况下,此功能有时会很有用。

也许你会想知道为什么我们需要虚方法,考虑到接口也能实现多态代码。在 C# 8.0 之前,虚方法相对于接口的一个主要优势是,基类可以提供一个默认实现,派生类将默认获取这个实现,并仅在真正需要不同实现时提供自己的实现。语言中添加默认接口实现的功能意味着接口现在也可以做到这一点,尽管默认接口成员实现不能定义或访问非静态字段,因此与定义虚函数的类相比受到一定限制。 (由于默认接口实现需要运行时支持,对于需要在.NET Framework 上运行的代码是不可用的,这包括任何目标为.NET Standard 2.0 或更早版本的库。)但是,虚方法还有一个更微妙的优势,但在我们能够看到它之前,我们需要探索虚方法的一个特性,即乍一看更像接口工作方式的东西。

抽象方法

你可以定义一个虚方法而不提供默认实现。在 C#中,这称为抽象方法。如果一个类包含一个或多个抽象方法,则该类是不完整的,因为它未提供所有定义的方法。这种类也被描述为抽象类,无法创建抽象类的实例;尝试使用new运算符与抽象类将导致编译器错误。有时在讨论类时,明确某个特定类不是抽象类是有用的,我们通常使用术语具体类

如果你从抽象类派生,那么除非你为所有抽象方法提供实现,否则你的派生类也将是抽象的。你必须使用abstract关键字声明你要写的抽象类;如果一个类有未实现的抽象方法(无论是自己定义的还是继承的),而没有使用abstract关键字声明为抽象类,C#编译器将报错。示例 6-26 展示了定义单个抽象方法的抽象类。抽象方法在定义上是虚的;如果没有办法让派生类提供方法体,定义一个没有方法体的方法也就没有多大用处。

示例 6-26. 一个抽象类
public abstract class AbstractBase
{
    public abstract void ShowMessage();
}

抽象方法声明只定义了签名,不包含方法体。与接口不同的是,每个抽象成员都有自己的可访问性——可以将抽象方法声明为publicinternalprotected internalprotected privateprotected。(将抽象或虚方法声明为private没有意义,因为该方法对派生类型是不可访问的,因此无法重写。)

注意

虽然包含抽象方法的类必须是抽象的,但反之并非如此。尽管不寻常,将一个本来可以作为具体类的类定义为抽象是合法的。这样可以防止该类被实例化。从这种类派生的类将是具体类,而无需重写任何抽象方法。

抽象类有选择地声明它们实现接口,而无需提供完整的实现。但是,不能只省略未实现的成员。您必须显式声明其所有成员,并将您希望保留未实现的任何成员标记为抽象,就像示例 6-27 所示的那样。这迫使具体派生类型提供实现。

示例 6-27. 抽象接口实现
public abstract class MustBeComparable : IComparable<string>
{
    public abstract int CompareTo(string? other);
}

抽象类和接口之间显然存在一些重叠。两者都提供了一种定义抽象类型的方式,使得代码在运行时无需知道确切的类型即可使用。每种选项都有其利弊。接口的优势在于单个类型可以实现多个接口,而类只能指定一个基类。但是抽象类可以定义字段,并且可以在其提供的任何默认成员实现中使用这些字段,并且它们还提供了一种在.NET Framework 上提供默认实现的方式。然而,在发布多个版本的库时,虚方法可以发挥出更为微妙的优势。

继承和库版本控制

想象一下,如果您编写并发布了一个定义了一些公共接口和抽象类的库,并且在该库的第二个版本中,您决定向其中一个接口添加一些新成员,会发生什么情况。这可能对使用您的代码的客户不会造成问题。当然,他们在使用接口类型的引用的任何地方都不会受到新功能添加的影响。但是,如果您的某些客户已编写了实现您接口的类型,会怎么样呢?例如,假设在未来的.NET 版本中,Microsoft 决定向IEnumerable<T>接口添加一个新成员。

如果接口不为新成员提供默认实现,那将是一场灾难。这个接口被广泛使用,但也被广泛实现。已经实现IEnumerable<T>的类将变得无效,因为它们不会提供这个新成员,因此旧代码将无法编译,已经编译的代码将在运行时抛出MissingMethodException错误。C# 对接口中的默认成员实现的支持可以减轻这一问题:如果微软确实向IEnumerable<T>添加了新成员,它可以提供一个默认实现以防止这些错误。这对于使用 .NET Framework 的人没有帮助,因为它不支持这个功能,但对于较新的运行时环境,这使得修改现有接口定义似乎是可行的。然而,还有一个更微妙的问题。一些类可能碰巧已经具有与新添加方法相同名称和签名的成员。如果该代码针对新的接口定义重新编译,编译器将将该现有成员视为接口实现的一部分,即使编写该方法的开发人员并没有这样的意图。因此,除非现有代码碰巧确实执行了新成员所需的操作,否则我们将遇到问题,并且我们不会收到编译器错误或警告来提醒我们。

因此,广泛接受的规则是一旦接口发布后就不要更改接口。如果您完全控制使用和实现接口的所有代码,那么您可以修改接口,因为您可以对受影响的代码进行任何必要的修改。但一旦接口可用于您无法控制的代码库中——也就是说,一旦它被发布——就不可能在不冒破坏其他人代码风险的情况下更改它。默认接口实现可以减轻这种风险,但它们无法消除现有方法在重新编译时被错误解释的问题。

抽象基类不必遭受这个问题的困扰。显然,引入新的抽象成员将导致完全相同的MissingMethodException失败,但引入新的虚拟方法则不会。(而且自从 C# v1 开始就有虚拟方法,这使您可以针对 .NET Framework,其中不支持默认接口实现支持。)

但是,如果在发布版本 1.0 的组件之后,在版本 1.1 中添加了一个新的虚拟方法,结果发现该方法与某个客户恰好在派生类中添加的方法具有相同的名称和签名呢?也许在版本 1.0 中,您的组件定义了示例 Example 6-28 中显示的相当无趣的基类。

示例 6-28. 基类版本 1.0
public class LibraryBase
{
}

如果您发布此库,可能会在NuGet 软件包管理网站上,或作为应用程序的某个软件开发工具包(SDK)的一部分。客户可能会编写一个派生类型,比如示例 6-29 中的一个。他们编写的Start方法显然不意图覆盖基类中的任何内容。

示例 6-29. 从版本 1.0 基础派生的类
public class CustomerDerived : LibraryBase
{
    public void Start()
    {
        Console.WriteLine("Derived type's Start method");
    }
}

由于您可能无法看到客户编写的每一行代码,因此您可能不知道这个Start方法。因此,在您组件的 1.1 版本中,您可能决定添加一个新的虚方法,也叫做Start,正如示例 6-30 所示。

示例 6-30. 基础类型版本 1.1
public class LibraryBase
{
    public virtual void Start() { }
}

想象一下,您的系统作为引入 v1.1 版本初始化过程的一部分调用了这个方法。您定义了一个默认的空实现,这样从LibraryBase派生的类型如果不需要参与该过程,则无需执行任何操作。希望参与的类型将覆盖此方法。但是在示例 6-29 中的类会发生什么呢?显然,编写此代码的开发人员并不打算参与您的新初始化机制,因为在编写代码时它并不存在。如果您的代码调用了CustomerDerived类的Start方法,则可能会出现问题,因为开发人员可能期望仅在他们的代码决定调用时才调用它。幸运的是,编译器会检测到此问题。如果客户尝试使用版本 1.1 的库编译示例 6-29,编译器会警告他们存在某些问题:

warning CS0114: 'CustomerDerived.Start()' hides inherited member
'LibraryBase.Start()'. To make the current member override that implementation,
add the override keyword. Otherwise add the new keyword.

这就是为什么当我们替换虚拟方法时,C#编译器要求使用override关键字的原因。它想知道我们是否打算覆盖现有的方法,以便如果我们没有打算这样做,它可以警告我们可能出现的命名冲突。(没有任何等效的关键字表示意图实现接口成员,这也是编译器无法检测到默认接口实现问题的原因。而这种缺失的原因是在 C# 8.0 之前不存在默认接口实现。)

我们得到了一个警告而不是错误,因为编译器在这种由于库的新版本发布而产生的情况下提供了一个可能是安全的行为。编译器推测——在这种情况下是正确的——编写CustomerDerived类型的开发人员并不打算覆盖LibraryBase类的Start方法。因此,与其让CustomerDerived类型的Start方法覆盖基类的虚方法,它隐藏了它。当派生类型引入一个与基类同名的新成员时,称为派生类型隐藏基类成员。

隐藏方法与重写方法有很大不同。当发生隐藏时,基础方法不会被替换。示例 6-31 展示了如何保留隐藏的Start方法。它创建了一个CustomerDerived对象,并将该对象的引用放入两个不同类型的变量中:一个是CustomerDerived类型,另一个是LibraryBase类型。然后通过每个变量分别调用Start方法。

示例 6-31. 隐藏与虚方法
var d = new CustomerDerived();
LibraryBase b = d;

d.Start();
b.Start();

当我们使用变量d时,调用Start方法实际上调用了派生类型的Start方法,即隐藏了基类成员的方法。但变量b的类型是LibraryBase,所以会调用基类的Start方法。如果CustomerDerived类重写了基类的Start方法而不是隐藏它,那么这两个方法调用都会调用重写的方法。

当由于新库版本而发生名称冲突时,通常隐藏行为是正确的做法。如果客户代码有一个CustomerDerived类型的变量,则该代码将希望调用特定于该派生类型的Start方法。然而,编译器会产生警告,因为它不能确定这是否是问题的原因。可能是你确实想要重写方法,只是忘记写override关键字了。

像许多开发人员一样,我不喜欢看到编译器警告,并且尽量避免提交产生警告的代码。但是如果新的库版本让你处于这种情况下,你应该怎么办?最好的长期解决方案可能是在派生类中更改方法的名称,以避免与新版本库中的方法冲突。然而,如果你面临截止日期,可能需要一个更快的解决方案。因此,C#允许您声明您知道存在名称冲突,并且您绝对想要隐藏基类成员,而不是重写它。正如示例 6-32 所示,您可以使用new关键字声明您已经意识到这个问题,并且绝对要隐藏基类成员。代码仍将以相同的方式工作,但您将不再收到警告,因为您已向编译器保证您知道发生了什么。但这是您应该在某个时候解决的问题,因为 sooner or later,在同一类型上存在两个意义不同但名称相同的方法很可能会导致混淆。

示例 6-32. 隐藏成员时避免警告
public class CustomerDerived : LibraryBase
{
    `public` `new` `void` `Start``(``)`
    {
        Console.WriteLine("Derived type's Start method");
    }
}
注意

C#不允许你使用new关键字来处理默认接口实现带来的等价问题。没有办法保留接口提供的默认实现并声明具有相同签名的公共方法。这稍微让人沮丧,因为在二进制级别是可能的:如果在添加新成员并未重新编译实现接口的代码时,这就是你会得到的行为。你仍然可以拥有ILibrary.StartCustomerDerived.Start的单独实现,但必须使用显式接口实现。

偶尔你可能会看到new关键字以这种方式使用,而不是处理库版本问题。例如,我在第五章中展示的ISet<T>接口使用它来引入一个新的Add方法。ISet<T>派生自ICollection<T>,一个已经提供了接受T实例并具有void返回类型的Add方法的接口。ISet<T>对此进行了微妙的更改,如示例 6-33 所示。

示例 6-33. 隐藏以更改签名
public interface ISet<T> : ICollection<T>
{
    new bool Add(T item);
    // ...other members omitted for clarity
}

ISet<T>接口的Add方法告诉你刚刚添加的项目是否已经存在于集合中,而基本的ICollection<T>接口的Add方法不支持这一点。ISet<T>需要其Add方法具有不同的返回类型——bool而不是void——因此它使用new关键字来隐藏ICollection<T>的方法。这两种方法仍然可用——如果你有两个变量,一个类型为ICollection<T>,另一个类型为ISet<T>,两者都引用同一个对象,你将能够通过前者访问void Add,通过后者访问bool Add

微软本可以不这样做。它本可以将新的Add方法命名为其他名称,比如AddIfNotPresent。但只有一个方法名称用于向集合中添加事物可能会更少引起混淆,尤其是当你可以忽略返回值时,此时新的Add看起来与旧的Add无法区分。而大多数ISet<T>实现将通过直接调用ISet<T>.Add方法来实现ICollection<T>.​Add方法,因此它们具有相同的名称是有道理的。

除了上述示例之外,到目前为止,我只讨论了在编译旧代码以针对新版本库的情况下进行方法隐藏。如果您有已经编译但最终在新版本下运行的旧代码呢?当涉及的库是.NET 运行时库时,这是一个您极有可能遇到的情景。假设您正在使用第三方组件,这些组件只有二进制形式(例如,您从不提供源代码的公司购买了许可的组件)。供应商将这些组件构建为使用某个特定版本的.NET。如果您升级应用程序以与新版本的.NET 一起运行,您可能无法获取到更新的第三方组件版本——也许供应商尚未发布它们,或者可能已经停业。

如果您使用的组件是为.NET Standard 1.2 编译的,而您将它们用于为.NET 6.0 构建的项目中,所有这些旧组件将最终使用.NET 6.0 版本的运行时库。.NET 有一个版本策略,确保特定程序使用的所有组件都使用相同版本的运行时库,而不管每个组件可能是为哪个版本构建的。因此,完全有可能某些组件,如OldControls.dll,包含从.NET Standard 1.2 派生的类,并定义与.NET 6.0 中新增成员名称相冲突的成员。

这基本上与我之前描述的情景相同,只是针对旧版本库编写的代码不会重新编译。我们不会收到关于隐藏方法的编译器警告,因为那需要运行编译器,而我们只有相关组件的二进制文件。现在会发生什么呢?

幸运的是,我们不需要重新编译旧组件。C#编译器会为每个编译的方法设置各种标志,指示诸如方法是否虚拟,方法是否打算覆盖基类中的某个方法等内容。当您在方法上放置new关键字时,编译器会设置一个标志,指示该方法不打算覆盖任何东西。CLR 将其称为newslot标志。当 C#编译一个像示例 6-29 中的方法时,该方法既不指定override也不指定new,编译器也会为该方法设置相同的 newslot 标志,因为在编译该方法时,基类上没有同名方法。对于开发人员和编译器而言,CustomerDerived类的Start方法就像是一个全新的方法,与基类上的任何内容都没有关联。

当旧组件与定义基类的新版本库一起加载时,CLR 可以看到作者在 CustomerDerived 类中的意图——即使没有重新编译,CLR 也可以看到作者认为 Start 并不意味着要覆盖任何东西。因此,它将 CustomerDerived.Start 视为与 LibraryBase.Start 不同的方法——就像我们重新编译时一样隐藏基础方法。

顺便说一句,我提到的关于虚方法的一切也适用于属性,因为属性的访问器只是方法。因此,您可以定义虚属性,并且派生类可以以与方法完全相同的方式重写或隐藏这些属性。在 第九章 中我不会讲事件,但它们也是方法的一种形式,所以它们也可以是虚的。

偶尔,您可能希望编写一个类来重写虚方法,然后阻止派生类再次重写它。对此,C# 定义了 sealed 关键字,事实上,不仅仅是方法可以被标记为 sealed。

Sealed Methods and Classes

虚方法故意通过继承来修改。而 sealed 方法则是相反的——它是一个不可重写的方法。在 C# 中,默认情况下方法是 sealed 的:方法不能被重写,除非声明为 virtual。但是当您重写虚方法时,可以使用 sealed 关键字将其封闭,阻止进一步修改。示例 6-34 使用这种技术提供了一个自定义的 ToString 实现,不能被派生类进一步重写。

示例 6-34. 一个 sealed 方法
public class FixedToString
{
    public sealed override string ToString() => "Arf arf!";
}

您还可以封闭整个类,防止任何人从中派生。示例 6-35 展示了一个不仅仅是什么都不做,还防止任何人扩展它以做一些有用事情的类。(通常您只会封闭做某些事情的类。这个例子只是为了说明关键字的使用位置。)

示例 6-35. 一个 sealed 类
public sealed class EndOfTheLine
{
}

一些类型本质上是 sealed 的。例如,值类型不支持继承,因此结构体、记录结构体和枚举实际上是 sealed。内置的 string 类也是 sealed 的。

密封类或方法通常有两个正常的原因。一是你希望保证某个特定不变量,如果你将类型开放以进行修改,将无法保证该不变量。例如,string类型的实例是不可变的。string类型本身不提供修改实例值的方法,因此由于无法从string派生,你可以保证如果你有一个string类型的引用,你就拥有一个不可变对象的引用。这使得在你不希望值改变的场景中使用它变得安全——例如,当你将对象作为字典的键(或任何依赖哈希码的其他东西)时,你需要值不变,因为如果在项目作为键使用时哈希码发生变化,容器将发生故障。

留下事物密封的另一个通常原因是,设计能够通过继承成功修改的类型很难,特别是如果你的类型将在你自己组织之外使用。简单地打开事物以进行修改是不够的——如果你决定使所有方法都虚拟化,这可能会使使用你的类型的人修改其行为变得容易,但当你维护基类时,你将会给自己找麻烦。除非你控制所有从你的类派生的代码,否则几乎不可能更改基类中的任何内容,因为你永远不会知道哪些方法可能已在派生类中被覆盖,这使得难以确保你类的内部状态始终一致。编写派生类型的开发人员无疑会尽力避免破坏事物,但他们将不可避免地依赖于未记录的你类行为的某些方面。因此,在通过继承开放你类的每个方面以供修改时,你剥夺了自己改变类的自由。

对于哪些方法(如果有的话)你应该非常谨慎地选择使其虚拟化。你还应该记录调用者是否允许完全替换方法,或者是否要求调用基本实现作为其覆盖的一部分。说到这一点,你该如何做到呢?

访问基类成员

在基类中范围内的所有内容,只要不是私有的,也将在派生类型中范围内,并且可访问。如果你想访问基类的某个成员,通常只需像访问自己类的普通成员一样访问即可。你可以通过this引用访问成员,或者直接按名称访问,而无需限定符。

然而,有些情况下你需要明确表示你打算引用基类成员。特别是,如果你重写了一个方法,用名称调用该方法将递归调用你的重写。如果你想调用原始的你重写的方法,有一个特殊的关键字用于这个,如 Example 6-36 中所示。

Example 6-36. 覆盖后调用基类方法
public class CustomerDerived : LibraryBase
{
    public override void Start()
    {
        Console.WriteLine("Derived type's Start method");
        `base``.``Start``(``)``;`
    }
}

使用base关键字,我们选择了不使用正常的虚方法分发机制。如果我们只写了Start(),那将是一个递归调用,在这里是不希望的。通过写base.Start(),我们获得了在基类实例上可用的方法,也就是我们重写的方法。

如果继承链更深呢?假设CustomerDerived派生自IntermediateBase,而IntermediateBase又派生自LibraryBase并重写了Start方法。在这种情况下,在我们的Cus⁠tom⁠er​Der⁠iv⁠ed类型中写base.Start()将调用IntermediateBase定义的重写。没有办法绕过这一点直接调用原始的LibraryBase.Start

在这个例子中,我在完成我的工作后调用了基类的实现。C#并不关心你什么时候调用基类——你可以在方法开始时调用它,在最后调用它,或者在方法中间的任何地方调用它。你甚至可以多次调用它,或者根本不调用它。调用基类方法的时间由基类的作者来决定,他需要文档化方法的重写是否应该调用基类实现。

你也可以对其他成员使用base关键字,如属性和事件。但是,对基类构造函数的访问方式略有不同。

继承和构造

尽管派生类继承了其基类的所有成员,但这对构造函数的意义与其他所有成员并不相同。对于其他成员,如果它们在基类中是公共的,它们也将成为派生类的公共成员,可供任何使用你的派生类的人访问。但构造函数是特殊的,因为使用你的类的人无法通过使用基类定义的构造函数来构造它。

这有一个很简单的原因:如果你想要一个某种类型D的实例,那么你会希望它是一个完整的D,包含了所有适当初始化的内容。假设D派生自B。如果你能直接使用B的其中一个构造函数,它将不会对D特有的部分进行任何操作。基类的构造函数不会知道任何由派生类定义的字段,因此无法对其进行初始化。如果你想要一个D,你只能使用知道如何初始化D的构造函数。因此,对于派生类,你只能使用由该派生类提供的构造函数,不管基类提供了哪些构造函数。

在本章中我展示的示例中,我之所以能忽略这一点,是因为 C#提供的默认构造函数。如你在第三章中看到的,如果你不写一个构造函数,C#会为你写一个不带参数的构造函数。对于派生类也是如此,生成的构造函数将调用基类的无参数构造函数。但是如果我开始编写自己的构造函数,情况就会改变。示例 6-37 定义了一对类,其中基类定义了一个显式的无参数构造函数,派生类定义了一个需要参数的构造函数。

示例 6-37. 派生类中没有默认构造函数
public class BaseWithZeroArgCtor
{
    public BaseWithZeroArgCtor()
    {
        Console.WriteLine("Base constructor");
    }
}

public class DerivedNoDefaultCtor : BaseWithZeroArgCtor
{
    public DerivedNoDefaultCtor(int i)
    {
        Console.WriteLine("Derived constructor");
    }
}

因为基类有一个零参数构造函数,我可以用new BaseWithZeroArgCtor()来构造它。但是对于派生类型,我不能这样做:我只能通过传递一个参数来构造它——例如,new DerivedNoDefaultCtor(123)。因此,就DerivedNoDefaultCtor的公开可见 API 而言,派生类似乎没有继承其基类的构造函数。

然而,实际上它确实已经继承了它,你可以通过构造派生类型的实例来看到得到的输出:

Base constructor
Derived constructor

在构造DerivedNoDefaultCtor的实例时,基类的构造函数会立即在派生类的构造函数之前运行。由于基类构造函数已运行,显然它是存在的。所有基类的构造函数都对派生类型可用,但只能由派生类中的构造函数调用。示例 6-37 隐式调用了基类构造函数:所有构造函数都要求在其基类上调用一个构造函数,如果你没有指定调用哪一个,编译器将为你调用基类的零参数构造函数。

如果基类没有定义一个无参构造函数怎么办?在这种情况下,如果你派生一个不指定调用哪个构造函数的类,你将会得到一个编译器错误。示例 6-38 展示了一个没有零参数构造函数的基类。(显式构造函数的存在禁用了编译器正常生成默认构造函数的机制,因此,这个基类只提供了一个带参数的构造函数。)它同时展示了一个派生类有两个构造函数,它们都使用base关键字显式调用基类的构造函数。

示例 6-38. 显式调用基类构造函数
public class BaseNoDefaultCtor
{
    public BaseNoDefaultCtor(int i)
    {
        Console.WriteLine("Base constructor: " + i);
    }
}

public class DerivedCallingBaseCtor : BaseNoDefaultCtor
{
    public DerivedCallingBaseCtor()
        `:` `base``(``123``)`
    {
        Console.WriteLine("Derived constructor (default)");
    }

    public DerivedCallingBaseCtor(int i)
        `:` `base``(``i``)`
    {
        Console.WriteLine("Derived constructor: " + i);
    }
}

这里的派生类决定提供一个无参构造函数,尽管基类没有这样的构造函数——它为基类需要的参数提供了一个常量值。第二个则直接将其参数传递给基类。

注意

这里有一个经常被问到的问题:如何提供与我的基类完全相同的所有构造函数,只需直接传递参数? 答案是:手动编写所有构造函数。没有办法让 C#编译器生成一个看起来与基类提供的构造函数完全相同的构造函数集。您需要用比较冗长的方式来完成。

至少 Visual Studio、VS Code 或 JetBrains Rider 可以为您生成代码——如果您点击类声明,然后点击出现的快速操作图标,它将提供生成与基类中任何非私有构造函数具有相同参数的构造函数的选项,并自动为您传递所有参数。

正如第三章所示,类的字段初始化器在其构造函数之前运行。一旦涉及继承,情况就变得更加复杂,因为涉及多个类和多个构造函数。预测将会发生什么最简单的方法是理解,尽管实例字段初始化器和构造函数具有不同的语法,但 C#最终将所有特定类的初始化代码编译到构造函数中。此代码执行以下步骤:首先,运行特定于此类的字段初始化器(因此此步骤不包括基类字段初始化器——基类将自己照顾好);接下来,调用基类构造函数;最后,运行构造函数体。这意味着在派生类中,您的实例字段初始化器将在基类构造之前运行——不仅仅是在基类构造函数体之前,甚至在基类的实例字段初始化之前。示例 6-39 说明了这一点。

示例 6-39。探索构造顺序
public class BaseInit
{
    protected static int Init(string message)
    {
        Console.WriteLine(message);
        return 1;
    }

    private int b1 = Init("Base field b1");

    public BaseInit()
    {
        Init("Base constructor");
    }

    private int b2 = Init("Base field b2");
}

public class DerivedInit : BaseInit
{
    private int d1 = Init("Derived field d1");

    public DerivedInit()
    {
        Init("Derived constructor");
    }

    private int d2 = Init("Derived field d2");
}

我把字段初始化放在构造函数的两侧,只是为了表明它们相对于非字段成员的位置无关紧要。字段的顺序很重要,但只涉及彼此。构造DerivedInit类的实例会产生以下输出:

Derived field d1
Derived field d2
Base field b1
Base field b2
Base constructor
Derived constructor

这证实了派生类型的字段初始化器首先运行,然后是基类的字段初始化器,接着是基类构造函数,最后是派生类构造函数。换句话说,虽然构造函数体始于基类,但实例字段的初始化是反向进行的。

这就是为什么你不能在字段初始化器中调用实例方法的原因。静态方法是可用的,但实例方法不是,因为类远未准备就绪。如果派生类型的一个字段初始化器能够在基类上调用方法,可能会有问题,因为此时基类根本没有进行任何初始化——不仅其构造函数体尚未运行,而且其字段初始化器也尚未运行。如果实例方法在此阶段可用,我们将不得不编写所有的代码非常谨慎,因为我们不能假设我们的字段包含任何有用的内容。

正如你所见,构造函数体在进程中运行较晚,这就是我们可以在其中调用方法的原因。但这里仍然存在潜在的危险。如果基类定义了一个虚方法,并在其构造函数中调用该方法,如果派生类型覆盖了该方法,我们将在派生类型的构造函数体运行之前调用该方法。(在那时,它的字段初始化器将已经运行。实际上,这是字段初始化器以看似相反顺序运行的主要原因——这意味着派生类有一种在基类构造函数调用虚方法之前执行一些初始化的方式。)如果你熟悉 C++,你可能会猜想,当基类构造函数调用虚方法时,它将运行基本实现。但 C#的做法不同:基类的构造函数将在这种情况下调用派生类的重写方法。这不一定是问题,而且偶尔会很有用,但这意味着如果你希望你的对象在构造过程中调用自身的虚方法,你需要仔细思考并清楚地记录你的假设。

记录类型

当你定义一个record类型(或者你使用更明确但功能上相同的record class语法),从运行时的角度来看,生成的记录类型仍然是一个类。记录类型可以做大多数普通类能做的事情——尽管它们通常关注属性,你还可以添加其他成员,如方法和构造函数。事实证明,基于类的记录类型也支持继承。(自然地,由于record struct类型是值类型,它们不支持继承。)

记录类型存在一些继承约束。普通类不允许从记录类型继承——只有记录类型可以从记录类型派生。同样,记录类型只能从另一个记录类型或通常的object基类型继承。但在这些约束条件下,记录类型的继承工作方式与类相似。示例 6-40 展示了一个基本记录和几个派生类型。

示例 6-40. 记录继承
public abstract record OptionallyLabeled
{
    public string? Label { get; init; }
}

public record OptionallyLabeledItem : OptionallyLabeled;

public record Product(string Name) : OptionallyLabeled;

正如这显示的,我们可以将记录类型定义为abstract。当记录不使用位置语法时,我们从基类型(抽象或非抽象)继承的方式与类的方式相同:如OptionallyLabeledItem所示,我们在类型名称后放置一个冒号,后跟基类型名称。如果我们的派生类型想使用位置语法,则在参数列表后放置冒号和基类型,如Product类型所示。示例 6-41 展示了如何实例化在示例 6-40 中定义的两种派生类型。

示例 6-41. 实例化派生记录类型
var unlabeled = new OptionallyLabeledItem();
var labeled = new OptionallyLabeledItem
{
    Label = "New, improved!"
};

var unlabeledProduct = new Product("Book");
var labeledProduct = new Product("Shirt")
{
    Label = "Half price"
};

由于基类的Label属性不需要设置,我们可以自由地构造两种派生类型中的任意一种而不设置它。但是如果我们确实想设置它,我们使用的对象初始化语法与如果Label属性直接由OptionallyLabeledItemProduct定义的方式完全相同。但是如果基类型使用位置语法定义非可选属性,那么怎么办?正如示例 6-42 所示,记录继承语法允许我们向基类提供参数列表。

示例 6-42. 从位置记录派生
public abstract record Colorful(string Color);

public record LightBulb(string Color, int Lumens) : Colorful(Color);

LightBulb本身使用位置语法,并使用其两个构造参数之一作为基类要求的Color属性的值。但在某些情况下,您可能不想这样传递值:有时派生类型将知道要传递给基记录类型的值,就像示例 6-43 所示的那样。

示例 6-43. 将常量传递给位置基记录
public record FordModelT() : Colorful("Black");

因此,在这种情况下,尽管基Colorful记录使用位置语法,要求提供Color属性,但这个派生类型不传递该要求。流行的故事是,福特早期的汽车 Model T 只有一种颜色可供选择,因此这个特定的派生类型可以直接设置Color本身。FordModelT记录的用户无需提供Color,尽管这对于基Colorful类型是强制性参数。书呆子们现在可能渴望指出,这种油漆约束只适用于 Model T 生产的 19 年中的 12 年。我想吸引他们注意示例 6-44,它显示了尽管FordModelT类型在构建过程中不需要传递Color属性,但仍可以使用对象初始化设置。因此,这种记录类型使得可以像早期和晚期 Model T 一样指定颜色,但默认与这种汽车绝大多数确实是黑色的事实保持一致。

示例 6-44. 使用一个将强制基属性变为可选的派生记录
var commonModelT = new FordModelT();
var lateModelT = new FordModelT { Color = "Green" };

要能够使用示例 6-42 和 6-43 中显示的语法,在记录本身必须使用位置语法。 如果您仔细观察示例 6-43,您会发现在 FordModelT 类型名称之后有一个空参数列表。 虽然这可能看起来多余,但在这种情况下,需要将其放在这里,因为如果没有它,我们将不允许在冒号后直接写 Colorful("Black")

还有其他方法可以将参数传递给位置基础记录。 正如第 3 章所述,当我们使用位置语法时,我们只是定义一个构造函数,因此另一种方法是使用常规语法来调用基类构造函数,正如示例 6-45 所示。

示例 6-45. 通过普通构造函数传递位置基础记录参数
public record RedDelicious : Colorful
{
    public RedDelicious() : base("Red")
    { }
}

最近几个示例处理了基类使用位置语法但派生类型不使用的情况。 但如果反过来,基类型不是位置的,而派生类型想要是位置的怎么办? 如果派生类型只想添加一个或多个自己的属性,这很简单。 实际上,我们已经看到了产品类型在示例 6-40 中确实如此。 但是,如果基类型定义了一个可选属性(例如 OptionallyLabeled.Label),而派生类型想要将其变为强制性的,您可以这样做,但不能使用位置语法。 您必须像示例 6-46 所示一样完全编写构造函数。

示例 6-46. 使可选基本属性类位置性
public record LabeledDemographic : OptionallyLabeled
{
    public LabeledDemographic(string label)
    {
        Label = label;
    }

    public void Deconstruct(out string? label) => label = Label;
}

尽管这不使用位置语法,但它具有类似的效果,因为位置语法通过定义构造函数起作用。在示例 6-46 中构造函数的存在将阻止编译器生成默认的零参数构造函数,这意味着使用 LabeledDemographic 的代码在构造时必须提供 Label 属性,就像使用位置语法一样。在使用位置语法时,自动获得析构函数,但我这里不得不自己写。当试图对非位置记录派生类型强加位置行为时,编译器不生成析构函数会导致析构有点奇怪。基类将 Label 定义为可选,尽管我们定义了需要非空参数的构造函数,但在构造函数后可以使用对象初始化程序将其设置回 null。 (这看起来很奇怪但不违法)。所以我们的析构函数最终与构造函数不完全匹配。

记录、继承和with关键字

第三章展示了如何使用with表达式创建记录类型的修改副本。这会构建一个新实例,该实例除了在with关键字后面的大括号中指定的新属性值外,其他所有属性与原始实例相同。这种机制考虑了继承:with关键字生成的实例总是与其输入具有相同的类型,即使代码是以基础类型编写的,如示例 6-47。

示例 6-47. 在基础记录类型上使用with
OptionallyLabeled Discount(OptionallyLabeled item)
{
    return item with
    {
        Label = "60% off!"
    };
}

这里使用了从示例 6-40 中的抽象OptionallyLabeled记录类型。我们可以传递任何从该抽象基类派生的具体类型。示例 6-48 两次调用它,并传入两种不同的类型。

示例 6-48. 测试with如何与继承交互
Console.WriteLine(Discount(new OptionallyLabeledItem()));
Console.WriteLine(Discount(new Product("Sweater")));

运行该代码会产生以下输出:

OptionallyLabeledItem { Label = 60% off! }
Product { Label = 60% off!, Name = Sweater }

Console.WriteLine在其输入上调用ToString,而记录类型通过报告其名称及其属性值来实现此方法。因此,您可以从中看到,当Discount方法生成其输入的修改副本时,它成功地保留了类型信息。因此,即使DiscountProduct记录类型或其Name属性一无所知,当它创建一个带有新Label值的副本时,Name属性也被正确地保留了下来。

这是由编译器为记录类型生成的代码所能够实现的。我已经在第三章中描述了复制构造函数,但光是这个还不够——Discount方法并不知道OptionallyLabeledItemProduct类型,因此它不会调用它们的复制构造函数。因此,记录类型还会得到一个隐藏的virtual方法,名为<Clone>$。在示例 6-47 中的with表达式会调用这个方法(然后继续设置Label属性)。由编译器生成的<Clone>$方法会调用自己的复制构造函数。由于派生的记录类型会重写<Clone>$,所以with表达式无论输入的类型如何,都会获得一个完全复制的记录。

特殊的基础类型

.NET 运行时库在 C#中定义了几种具有特殊意义的基础类型。其中最明显的是System.Object,我已经对其进行了详细描述。

还有System.ValueType。这是所有值类型的抽象基类型,因此你定义的任何structrecord struct,以及所有内置的值类型,如intbool,都派生自ValueType。讽刺的是,ValueType本身是一个引用类型;只有从ValueType派生的类型才是值类型。像大多数类型一样,ValueType也派生自System.Object。在这里存在一个明显的概念上的困难:通常情况下,派生类包含其基类的所有功能,以及它们添加的任何功能。因此,考虑到objectValueType都是引用类型,从ValueType派生的类型不是值类型似乎有些奇怪。而且,一个object变量如何能够持有一个不是引用类型的实例的引用,这也不是很明显。我将在第七章中解决所有这些问题。

在 C#中,不允许你编写一个明确从ValueType派生的类型。如果你想要编写一个从ValueType派生的类型,struct关键字就是为此而设计的。你可以声明一个ValueType类型的变量,但由于该类型未定义任何公共成员,ValueType引用不允许你做任何object引用做不到的事情。唯一显著的区别是,使用该类型的变量可以分配任何值类型的实例,但不能分配引用类型的实例。除此之外,它与object完全相同。因此,在 C#代码中明确提到ValueType相对比较少见。

所有枚举类型也都派生自一个共同的抽象基类型:System.Enum。由于枚举是值类型,你不会感到意外的是,Enum派生自ValueType。与ValueType类似,你永远不会明确从Enum派生——你使用enum关键字来定义枚举类型。与ValueType不同的是,Enum添加了一些有用的成员。例如,它的静态方法GetValues返回该枚举所有值的数组,而GetNames返回将所有值转换为字符串的数组。它还提供了Parse方法,用于从字符串表示转换回枚举值。

如第五章所述,所有的数组都源于一个共同的基类,System.Array,你已经看到了它所提供的特性。

System.Exception基类非常特殊:当你抛出异常时,C#要求抛出的对象必须是这种类型或者从它派生的类型。(异常是第八章的主题。)

委托类型都源于一个共同的基类型,System.MulticastDelegate,后者又从System.Delegate派生。我将在第九章中讨论这些内容。

这些都是 CTS 视为特殊的基本类型。还有一种基本类型被 C#编译器赋予特殊意义,那就是Sys⁠tem.​Att⁠rib⁠ute。在第一章中,我为方法和类应用了某些注解,以告诉单元测试框架将它们视为特殊处理。这些属性都对应于类型,因此当我将[TestClass]属性应用于一个类时,我使用了名为TestClassAttribute的类型。设计用作属性的类型都需要派生自System.Attribute。其中一些被编译器识别—例如,有些控制编译器将其生成的 EXE 和 DLL 文件的文件头中的版本号。我将在第十四章中展示所有这些内容。

摘要

C#支持单一实现继承,仅限于类或引用类型记录—你无法从结构体派生。然而,接口可以声明多个基类,类可以实现多个接口。从派生类型到基类型存在隐式引用转换,并且泛型接口和委托可以选择使用协变或逆变来提供额外的隐式引用转换。所有类型都派生自System.Object,确保所有变量都可用某些标准成员。我们看到虚方法如何允许派生类修改其基类的选定成员,以及如何使用封闭禁用该功能。我们还探讨了派生类型在访问成员时与其基类之间的关系,特别是构造函数。

我们对继承的探索已经完成,但是它引发了一些新问题,比如值类型和引用之间的关系以及终结器的作用。因此,在下一章中,我将讨论引用与对象生命周期之间的关系,以及 CLR 如何弥合引用和值类型之间的差距。

¹ 更准确地说,同一个程序集,还有友元程序集。第十二章描述了程序集。