C-10-编程指南-四-

291 阅读1小时+

C#10 编程指南(四)

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

译者:飞龙

协议:CC BY-NC-SA 4.0

第七章:对象生命周期

.NET 托管执行模型的一个好处是运行时可以自动化大部分应用程序的内存管理。我已经展示了许多使用new关键字创建对象的示例,没有一个显式释放这些对象消耗的内存。

在大多数情况下,您无需采取任何措施来回收内存。运行时提供了一个垃圾收集器(GC),¹,一种自动发现对象不再使用并回收它们占用的内存的机制,以便可以用于新对象。然而,某些使用模式可能会导致性能问题,甚至完全失效 GC,因此了解其工作原理是很有用的。这在运行时间可能长达数天的长时间进程中尤为重要(短暂进程可能能够容忍一些内存泄漏)。

GC 旨在高效管理内存,但内存并不是您可能需要处理的唯一有限资源。有些东西在 CLR 中的内存占用很小,但代表相对昂贵的东西,例如数据库连接或来自 OS API 的句柄。GC 并不总是有效处理这些情况,因此我将解释IDisposable,这是专门设计用于处理比内存更紧急需要释放的东西的接口。

值类型通常有完全不同的生命周期规则——例如,一些局部变量值仅在其包含的方法运行期间存在。尽管如此,值类型有时会表现得像引用类型,并由 GC 管理。我将讨论为何这可能很有用,并解释使其成为可能的装箱机制。

垃圾收集

CLR 维护着一个,这是一个为由 GC 管理生命周期的对象和值提供内存的服务。每次使用new构造类的实例或创建新的数组对象时,CLR 都会分配一个新的堆块。GC 决定何时释放该块。

注意

如果您正在编写一个在 Android 设备上运行使用.NET 的 Xamarin 工具的.NET 应用程序,则会有两个垃圾收集堆:一个用于.NET,一个用于 Java。在 Xamarin 应用程序中正常的 C#活动使用.NET 堆,因此只有当您编写使用 Xamarin 服务处理 Java 对象的 C#代码时,Java 堆才会出现。这是一本.NET 书籍,所以我将专注于.NET GC。

堆块包含对象的所有非静态字段,或者如果是数组,则包含所有元素。CLR 还会添加一个头部,该头部对于程序来说不是直接可见的。这包括指向描述对象类型的结构体的指针。这支持依赖于对象的真实类型的操作。例如,如果在引用上调用GetType,运行时会使用此指针来查找类型。(类型通常不完全由引用的静态类型决定,静态类型可以是接口类型或实际类型的基类。)它还用于确定在调用虚拟方法或接口成员时应使用哪个方法。CLR 还使用此信息来知道堆块的大小——头部不包括块大小,因为运行时可以从对象的类型推断出来。(大多数类型都是固定大小。只有两个例外,字符串和数组,CLR 将其作为特殊情况处理。)头部包含另一个字段,用于各种不同的目的,包括多线程同步和默认哈希码生成。堆块头部只是一个实现细节,不同的运行时可能会选择不同的策略。² 但是,了解开销是有用的。在 32 位系统上,头部长度为 8 字节;在 64 位进程中运行时,长度为 16 字节。因此,一个仅包含一个double类型字段的对象在 32 位进程中将消耗 16 字节,在 64 位进程中将消耗 24 字节。

尽管对象(即类的实例)始终位于堆上,值类型的实例却有所不同:一些位于堆上,而另一些则不是。³ 例如,CLR 将一些值类型的局部变量存储在堆栈上,但如果该值是类的实例字段,则类实例将位于堆上,因此该值将驻留在堆上的该对象内部。在某些情况下,一个值将拥有自己的整个堆块。

如果你通过引用类型变量使用某物,则正在访问堆上的内容。非常重要的一点是要明确我所说的引用类型变量的含义,因为遗憾的是,这里的术语有点混乱:在 C# 中,引用 这个术语描述了两种完全不同的东西。在本讨论中,引用是指你可以存储在派生自object类型(但不是ValueType)或接口类型的变量中的内容。这并不包括每个in-、out-或ref-风格的方法参数,也不包括ref变量或返回值。虽然它们也是某种形式的引用,但ref int参数是对值类型的引用,这与引用类型并不相同。(CLR 实际上使用与 C# 不同的术语来支持refinout的机制:它称这些为托管指针,明确表明它们与对象引用有着不同。)

C# 使用的托管执行模型(以及所有 .NET 语言)意味着 CLR 知道您的代码创建的每个堆块,还知道程序存储引用的每个字段、变量和数组元素。这些信息使运行时能够随时确定哪些对象是可达的——即程序可能访问以使用其字段和其他成员的对象。如果一个对象不可达,则根据定义,程序将永远无法再次使用它。为了说明 CLR 如何确定可达性,我编写了一个简单的方法,从我的雇主网站获取网页,如示例 7-1 所示。

示例 7-1. 使用和丢弃对象
public static string FetchUrl(string relativeUri)
{
    var baseUri = new Uri("https://endjin.com/");
    var fullUri = new Uri(baseUri, relativeUri);
    var w = new HttpClient();
    HttpResponseMessage response = w.Send(
        new HttpRequestMessage(HttpMethod.Get, fullUri));
    return new StreamReader(response.Content.ReadAsStream()).ReadToEnd();
}

CLR 分析我们使用局部变量和方法参数的方式。例如,虽然relativeUri参数在整个方法中都是作用域内的,但我们只在构造第二个Uri时使用了一次作为参数,然后再也没有使用它。变量从接收值的第一个点到最后使用的最后点称为活跃。方法参数从方法开始直到最后使用,除非它们未被使用,否则它们将永远不活跃。局部变量稍后才会活跃;baseUri在分配初始值后变为活跃,然后在此示例中与relativeUri的最后使用同时停止活跃。活跃性是确定特定对象是否仍在使用的重要属性。

要了解活跃性的作用,请假设在示例 7-1 达到构造HttpClient行时,CLR 没有足够的空闲内存来容纳新对象。此时,CLR 可以向操作系统请求更多内存,但也可以选择尝试从不再使用的对象中释放内存,这意味着我们的程序不需要消耗比它已经使用的内存更多。⁴ 接下来的部分描述了当 CLR 选择第二个选项时的过程。

确定可达性

.NET 的基本方法是确定堆上哪些对象是可达的。如果程序无法获取某个对象,那么可以安全地丢弃它。CLR 首先确定程序中所有的根引用。根引用是指存储位置,例如局部变量,可能包含引用并已知已初始化,并且您的程序在将来某个时候可以使用它,而无需通过其他对象引用。并非所有存储位置都被视为根引用。如果对象包含某个引用类型的实例字段,则该字段不是根引用,因为在使用它之前,您需要获取对包含对象的引用,并且该对象本身可能不可达。但是,引用类型的静态字段是根引用,因为程序可以随时读取该字段的值——该字段将在组件定义该类型的组件卸载时变得不可访问,这在大多数情况下将是在程序退出时。

局部变量和方法参数更加有趣。有时它们是根引用,但有时并非如此。这取决于当前执行的方法的确切部分。只有在执行流程当前位于变量或参数活跃的区域内时,局部变量或参数才能成为根引用。因此,在示例 7-1 中,只有在baseUri获得其初始值并在构造第二个Uri之前,它才是根引用的。fullUri变量的根引用时间略长一些,因为它在接收到初始值后变为活跃,并在下一行构造HttpClient期间继续保持活跃;只有在调用HttpRequestMessage构造函数后,其生命周期才会结束。

注意

当一个变量的最后一次使用是作为方法或构造函数调用的参数时,当方法调用开始时,它就不再是活跃的。在那一点上,被调用的方法接管—它自己的参数在开始时是活跃的(除了它不使用的参数)。然而,它们通常会在方法返回之前不再是活跃的。这意味着在示例 7-1 中,由 fullUri 引用的对象在 HttpRequestMessage 构造函数返回之前可能会因根引用的消失而无法访问。

由于程序执行时活跃变量集合会变化,根引用集合也会随之演变。为了确保在这一移动目标面前的正确行为,CLR 可以在垃圾回收时必要时暂停所有正在运行托管代码的线程。

活跃变量和静态字段并不是唯一的根引用种类。作为评估表达式结果所创建的临时对象需要在完成评估所需的时间内保持活跃,因此可能存在一些根引用,并不直接对应代码中的任何命名实体。还有其他类型的根。例如,GCHandle 类允许您显式创建新的根引用,在互操作场景中非常有用,以便让一些非托管代码访问特定对象。还有一些情况下根引用是隐式创建的。某些类型的应用程序可以与非.NET 基于对象的系统进行互操作(例如,在 Windows 应用程序中的 COM,或者在 Android 上的 Java),这些系统可以在不显式使用 GCHandle 的情况下建立根引用。如果 CLR 需要生成一个包装器,使您的某个.NET 对象对其他运行时可用,那么该包装器实际上将是一个根引用。调用非托管代码时可能还涉及传递指向堆上内存的指针,这意味着在调用的整个过程中相关堆块需要被视为可达。总体原则是,根引用将存在于必要的地方,以确保仍在使用中的对象保持可达。

在为所有线程建立了当前根引用的完整列表后,GC 确定哪些对象可以从这些引用中访问到。它依次检查每个引用,如果非空,GC 就知道它所引用的对象是可达的。可能会有重复的引用——多个根引用可能指向同一个对象,因此 GC 要追踪它已经看过的对象。对于每个新发现的对象,GC 将该对象中的所有引用类型的实例字段添加到它需要检查的引用列表中,并再次丢弃重复项(包括编译器生成的隐藏字段,例如自动属性中描述的那些,我在第三章中有描述)。对于它发现的任何引用类型数组的每个元素,它都会执行相同的操作。这意味着如果一个对象是可达的,它所引用的所有对象也都是可达的。GC 重复这个过程,直到没有新的引用需要检查为止。GC 没有发现可达的对象就意味着这些对象是不可达的,因为 GC 所做的只是程序做的事情:程序只能使用直接或间接通过其变量、临时本地存储、静态字段和其他根引用可访问的对象。

回到示例 7-1,如果 CLR 在构造HttpClient时决定运行 GC,那会意味着什么?fullUri变量仍然是活动的,所以它引用的Uri是可达的,但是baseUri不再活动。我们将baseUri的副本传递给第二个Uri的构造函数,如果它在字段中存储了引用的副本,那么baseUri不再活动也没关系;只要通过根引用开始就能访问到对象,那么对象就是可达的。但实际上,第二个Uri不会这样做,因此示例分配的第一个Uri将被视为不可达,CLR 将可以回收它所使用的内存。

如何确定可达性的一个重要结果是,GC 不会被循环引用搞糊涂。这就是.NET 使用 GC 而不是引用计数的一个原因(引用计数是另一种流行的自动内存管理方法)。如果你有两个相互引用的对象,引用计数方案会认为两个对象都在使用中,因为每个对象至少被引用了一次。但是这些对象可能是不可达的——如果没有其他引用指向它们,应用程序将无法使用它们。引用计数无法检测到这一点,因此可能导致内存泄漏;但 CLR 的 GC 方案不会受到它们相互引用的影响——GC 不会处理这两个对象中的任何一个,因此它会正确地确定它们不再使用。

意外地挫败了垃圾回收器

尽管垃圾回收器可以发现程序如何达到一个对象,但它无法证明它必然会这样做。拿示例 7-2 中那令人印象深刻的愚蠢代码来说吧。虽然你不会写出这么糟糕的代码,但它却犯了一个常见的错误。这个问题通常以更微妙的方式出现,但我想先用一个更明显的例子来展示它。一旦我展示了它如何阻止 GC 释放我们将不再使用的对象,我会描述一个不太直接但更现实的场景,这种问题经常发生在其中。

示例 7-2. 一个效率极低的代码片段
static void Main()
{
    var numbers = new List<string>();
    long total = 0;
    for (int i = 1; i < 100_000; ++i)
    {
        numbers.Add(i.ToString());
        total += i;
    }
    Console.WriteLine("Total: {total}, average: {total / numbers.Count}");
}

这里将从 1 加到 100,000 的数字相加,然后显示它们的平均值。这里的第一个错误是,我们甚至不需要在循环中进行加法,因为对于这种求和,有一个简单且非常有名的封闭形式解:n*(n+1)/2,在这种情况下n为 100,000。尽管存在这个数学错误,但这段代码做了更愚蠢的事情:它建立了一个包含它添加的每个数字的列表,但它所做的一切只是在最后检索它的Count属性以计算平均值。更糟糕的是,代码在将每个数字放入列表之前将其转换为字符串。它实际上从未使用过这些字符串。(我在这里展示了Main方法的声明,以明确说明numbers后来没有被使用。)

显然,这是一个刻意构造的例子,虽然我希望我能说在真实程序中从未遇到过这种令人困惑的毫无意义的事情。可悲的是,我遇到过至少和这个糟糕的例子一样糟糕的真实例子,尽管它们都更加隐晦——当你在野外遇到这种情况时,通常需要半个小时左右才能确定它确实在做如此惊人地毫无意义的事情。然而,我这里的重点并不是为软件开发标准叹息。这个例子的目的是展示你如何遇到 GC 的一个限制。

假设示例 7-2 中的循环已经运行了一段时间——也许是在第 90,000 次迭代,并且正在尝试向numbers列表添加一个条目。假设List<string>已经使用完了它的备用容量,因此Add方法将需要分配一个新的、更大的内部数组。CLR 此时可能会决定运行 GC,看看能否释放一些空间。会发生什么?

示例 7-2 创建了三种对象:在开始时构造了一个List<string>,在循环中每次调用intToString()方法创建一个新的string,还有更微妙的是,List<string>将分配一个string[]来保存对这些字符串的引用。因为我们不断添加新的项,它将不得不分配越来越大的数组。(这个数组是List<string>的实现细节,所以我们不能直接看到它。)因此问题是:GC 可以丢弃哪些对象来为Add调用中的更大数组腾出空间?

我们的numbers变量保持活动状态直到程序的最后一条语句,并且我们正在看代码中的较早部分,因此它引用的List<string>对象是可达的。它目前使用的string[]数组对象也必须是可达的:它正在分配一个更新、更大的数组,但它将需要复制旧数组的内容到新数组中,因此列表必须仍然有一个对当前数组的引用存储在其一个字段中。由于该数组仍然是可达的,数组引用的每个字符串也将是可达的。到目前为止,我们的程序已经创建了 90,000 个字符串,GC 将通过从我们的numbers变量开始,查看List<string>对象的字段,然后查看列表的一个私有字段引用的数组中的每个元素来找到所有这些字符串。

GC 可能能够收集的唯一分配的项目是List<string>在列表较小时创建的旧string[]数组,它现在不再有引用。当我们添加了 90,000 个项时,列表可能已经调整了自身大小多次。因此,取决于上次 GC 运行的时间,它可能能够找到一些现在未使用的数组。但更有趣的是这里它无法释放的内容。

程序永远不会使用它创建的 90,000 个字符串中的任何一个,因此理想情况下,我们希望垃圾收集器可以释放它们占用的内存 —— 它们将占用几兆字节。我们可以很容易地看出这些字符串没有被使用,因为这是一个如此简短的程序。但是垃圾收集器不知道这一点;它基于可达性做出决策,并且它正确地确定这 90,000 个字符串都是可达的,从numbers变量开始。对于垃圾收集器来说,列表的Count属性可能会在循环结束后查看列表的内容。我们知道它不会这样做,因为它不需要,但这是因为我们知道Count属性的含义。为了让垃圾收集器推断我们的程序永远不会直接或间接使用列表的任何元素,它需要了解List<string>在其AddCount方法内部的工作方式。这意味着需要进行比我描述的机制更为详细的分析,这可能使得垃圾收集器的成本显著增加。此外,即使在需要严格复杂的步骤来检测此示例永远不会使用的可达对象的情况下,更现实的场景中,垃圾收集器也不太可能能够做出明显优于仅依赖可达性的预测。

例如,在缓存中更有可能遇到这个问题。如果你编写一个类来缓存获取或计算昂贵的数据,想象一下如果你的代码只是向缓存中添加项而不移除它们,会发生什么。只要缓存对象本身可达,所有缓存的数据都将是可达的。问题在于,你的缓存将占用越来越多的空间,除非你的计算机有足够的内存来容纳程序可能需要使用的每一个数据片段,否则最终会耗尽内存。

一个天真的开发者可能会抱怨,这应该是垃圾收集器的问题。垃圾收集的整点在于我不需要考虑内存管理,为什么突然间就内存不足了呢?但是,问题在于垃圾收集器无法知道哪些对象是安全可移除的。它并非能预见未来,因此无法准确预测你的程序将来可能需要哪些缓存项——如果代码在服务器上运行,未来的缓存使用可能取决于服务器收到的请求,而这是垃圾收集器无法预测的。因此,虽然我们可以想象到足够智能的内存管理可以分析像示例 7-2 这样简单的东西,但通常情况下,这不是垃圾收集器能解决的问题。因此,如果你将对象添加到集合中并保持这些集合可达,垃圾收集器将把这些集合中的所有东西都视为可达。你需要决定何时删除这些项。

集合不是唯一可以欺骗 GC 的情况。正如我将在 第九章 中展示的那样,存在一种常见的情况,即对事件的不慎使用可能导致内存泄漏。更一般地说,如果你的程序使得某个对象可以被访问到,GC 无法确定你是否会再次使用该对象,因此它必须保守处理。

话虽如此,有一种技术可以在一定程度上通过 GC 的帮助来缓解这个问题。

弱引用

尽管 GC 会跟踪可达对象字段中的普通引用,但也可能存在弱引用。GC 不会跟踪弱引用,因此如果通过弱引用是唯一可达对象的方式,GC 会将其视为不可达对象并将其移除。弱引用提供了一种告诉 CLR 的方式:“不要因为我而保留这个对象,但只要其他地方需要它,我希望能够访问它。” 示例 7-3 展示了使用 WeakReference<T> 的缓存。

示例 7-3. 在缓存中使用弱引用
public class WeakCache<TKey, TValue>
    where TKey : notnull
    where TValue : class
{
    private readonly Dictionary<TKey, WeakReference<TValue>> _cache = new ();

    public void Add(TKey key, TValue value)
    {
        _cache.Add(key, new WeakReference<TValue>(value));
    }

    public bool TryGetValue(
        TKey key, [NotNullWhen(true)] out TValue? cachedItem)
    {
        if (_cache.TryGetValue(key, out WeakReference<TValue>? entry))
        {
            bool isAlive = entry.TryGetTarget(out cachedItem);
            if (!isAlive)
            {
                _cache.Remove(key);
            }
            return isAlive;
        }
        else
        {
            cachedItem = null;
            return false;
        }
    }
}

这个缓存通过 WeakReference<T> 存储所有值。它的 Add 方法将希望作为弱引用的对象作为新 WeakReference<T> 的构造函数参数。TryGetValue 方法尝试检索之前使用 Add 存储的值。首先检查字典是否包含相关条目。如果包含,则该条目的值将是我们之前创建的 WeakReference<T>。我的代码调用该弱引用的 TryGetTarget 方法,如果对象仍然可用,则返回 true,否则返回 false

注意

可用性并不一定意味着可达性。自最近的 GC 以来,对象可能已经变得不可达。或者自对象分配以来可能根本没有进行 GC。TryGet​Tar⁠get 只能告诉你 GC 是否已经检测到它符合回收的条件。

如果对象可用,TryGetTarget将通过out参数提供它,这将是一个强引用。因此,如果此方法返回true,我们无需担心对象随后变得不可达的竞争条件——事实上,我们现在将该引用存储在通过cachedItem参数由调用方提供的变量中,将保持目标活动。如果TryGetTarget返回false,我的代码将从字典中删除相关条目,因为它代表一个不再存在的对象。这很重要,因为虽然弱引用不会保持其目标的活动状态,但WeakReference<T>本身是一个对象,GC 在我从字典中移除它之前无法释放它。示例 7-4 尝试运行此代码,强制进行了几次垃圾回收,以便我们可以看到它的运行情况。(这将每个阶段分成独立的方法,禁用内联,否则.NET 的 JIT 编译器将内联这些方法,这样会创建隐藏的临时变量,可能会使数组保持可达的时间比预期长,从而扭曲此测试的结果。)

示例 7-4. 练习弱缓存
internal class Program
{
    private static WeakCache<string, byte[]> cache = new ();
    private static byte[]? data = new byte[100];

    private static void Main(string[] args)
    {
        AddData();
        CheckStillAvailable();

        GC.Collect();
        CheckStillAvailable();

        SetOnlyRootToNull();
        GC.Collect();
        CheckNoLongerAvailable();
    }

 [MethodImpl(MethodImplOptions.NoInlining)]
    private static void AddData()
    {
        cache.Add("d", data!);
    }

 [MethodImpl(MethodImplOptions.NoInlining)]
    private static void CheckStillAvailable()
    {
        Console.WriteLine("Retrieval: " +
            cache.TryGetValue("d", out byte[]? fromCache));
        Console.WriteLine("Same ref?  " +
            object.ReferenceEquals(data, fromCache));
    }

 [MethodImpl(MethodImplOptions.NoInlining)]
    private static void SetOnlyRootToNull()
    {
        data = null;
    }

 [MethodImpl(MethodImplOptions.NoInlining)]
    private static void CheckNoLongerAvailable()
    {
        byte[]? fromCache;
        Console.WriteLine("Retrieval: " + cache.TryGetValue("d", out fromCache));
        Console.WriteLine("Null?  " + (fromCache == null));
    }
}

首先创建我的缓存类的实例,然后将一个 100 字节数组的引用添加到缓存中。它还将同一个数组的引用存储在名为data的静态字段中,保持其可达性,直到代码调用SetOnlyRootToNull,将其值设置为null。示例尝试在添加后立即从缓存中检索该值,并使用object.ReferenceEquals检查我们获取的值确实是指向我们放入的同一个对象。然后我强制进行垃圾回收,并再次尝试。(这种人为的测试代码是少数情况之一,您需要执行此操作,请参阅“强制垃圾回收”一节了解详情。)由于data字段仍然持有数组的引用,因此数组仍然是可达的,因此我们期望从缓存中仍然可以获取该值。接下来,我将data设置为null,因此我的代码不再保持该数组可达。唯一剩余的引用是一个弱引用,因此当我强制进行另一次 GC 时,我们期望该数组被收集,并且在缓存中的最终查找失败。为了验证这一点,我检查返回值和通过out参数返回的值,预期值为falsenull。当我运行程序时,确实发生了这种情况,如您所见:

Retrieval: True
Same ref?  True
Retrieval: True
Same ref?  True
Retrieval: False
Null?  True

编写用于说明 GC 行为的代码意味着进入危险的领域。操作原理保持不变,但小示例的确切行为随时间变化,通常是由于 JIT 编译期间执行的优化。完全有可能,如果您尝试这些示例,由于运行时的更改,您可能会看到不同的行为。

后面我会描述终结,这会通过引入一个暮光区域使情况变得更加复杂,其中对象被确定为不可达但尚未消失。处于此状态的对象通常没有多大用处,因此默认情况下,弱引用将把等待终结的对象视为已经消失。这称为短弱引用。如果出于某种原因,您需要知道对象是否确实已经消失(而不仅仅是正在逐渐移除),WeakReference<T> 类的构造函数具有多个重载,其中一些可以创建长弱引用,即使在不可达性和最终移除之间的这个区域中,也可以访问对象。

回收内存

到目前为止,我描述了 CLR 如何确定哪些对象不再使用,但还没有描述接下来会发生什么。在确定了垃圾之后,运行时必须进行收集。CLR 对小对象和大对象使用不同的策略。(默认情况下,.NET CLR 将大对象定义为大于 85,000 字节。Mono 将此标准设定为 8,000 字节以下。)大多数分配涉及小对象,因此我将首先介绍这些对象。

CLR 尝试保持堆的空闲空间连续。当应用程序刚启动时,这很容易,因为只有空闲空间,可以通过为每个新对象直接分配内存来保持连续。但是在第一次 GC 后,堆看起来可能不再那么整齐。大多数对象的生命周期很短,通常在任何一个 GC 后分配的大多数对象在下次 GC 运行时都不可达。然而,还是会有一些对象在使用中。应用程序不时会创建长时间存在的对象,GC 运行时可能正在使用一些对象,因此最近分配的堆块可能仍在使用中。这意味着堆的末尾可能看起来像图 7-1,灰色矩形表示可达块,白色矩形表示不再使用的块。

图 7-1. 堆中部分可达对象的部分

一种可能的分配策略是在需要新内存时开始使用这些空块,但这种方法存在几个问题。首先,它往往是浪费的,因为应用程序需要的块可能不会精确地适应可用的空洞。其次,在许多间隙存在且尝试选择能最小化浪费的空块时,找到合适的空块可能会有些昂贵。当然,并非不可能实现——许多堆都是这样工作的——但比起最初的情况,每个新块都可以直接分配到上一个块之后,因为所有的空闲空间都是连续的,这种堆碎片化的代价是相当昂贵的,因此 CLR 通常会尝试将堆恢复到自由空间连续的状态。如图 7-2 所示,它将所有可达对象向堆的起始位置移动,以便所有的空闲空间位于末尾,从而使其重新处于有利的状态,能够在连续的空闲空间块中一个接一个地分配新的堆块。

图 7-2. 堆在压缩后的部分

在这些重新定位的块移动后,运行时必须确保对这些块的引用仍然有效。CLR 偶然将引用实现为指针(尽管没有什么需要这样做——引用只是标识堆上某个特定实例的值)。它已经知道任何特定块的所有引用位置,因为它必须找到它们以发现哪些块是可达的。它在移动块时调整所有这些指针。

除了使堆块分配成本相对廉价外,压缩还提供了另一个性能优势。因为块被分配到连续的空闲空间区域中,快速创建的对象通常会在堆中彼此紧邻。这是很重要的,因为现代 CPU 中的高速缓存倾向于局部性(即当相关数据片段存储在一起时表现最佳)。

分配的低成本和良好局部性的高可能性有时意味着,垃圾收集堆比需要程序显式释放内存的传统堆提供更好的性能。这可能令人惊讶,因为 GC 似乎在非收集堆中做了很多额外的无用工作。然而,其中一些“额外”工作实际上并非如此——必须有东西来跟踪哪些对象正在使用,并且传统堆只是将这些管理开销推到我们的代码中。然而,重新定位现有内存块是有代价的,因此 CLR 使用一些技巧来最小化它需要做的复制量。

对于 CLR 来说,一个对象的年龄越长,一旦最终变得不可达时,压缩堆的成本就会越高。如果在 GC 运行时最近分配的对象是不可达的,那么对于该对象来说,压缩是免费的:它后面没有更多的对象,所以不需要移动任何东西。与你的程序分配的第一个对象相比——如果那个对象变得不可达,压缩意味着需要移动堆上的每个可达对象。更一般地说,一个对象的年龄越长,它后面放置的对象就越多,因此需要移动的数据量就越大才能压缩堆。复制 20 MB 的数据来节省 20 字节并不像是一个很好的权衡。因此,CLR 经常会推迟对堆中较老部分的压缩。

为了确定什么是“老”的,.NET 运行时将堆划分为。⁵ 每次 GC 时,代之间的边界会移动,因为代是根据一个对象经历了多少次 GC 来定义的。在最近的 GC 之后分配的任何对象都在第 0 代中,因为它还没有经历任何收集。当下次 GC 运行时,仍然可达的第 0 代对象将按需移动以压缩堆,并被认为是在第 1 代中。

第 1 代中的对象还不被认为是老对象。GC 通常会在代码正在执行的过程中运行——毕竟,当堆上的空间被使用完时,它才会运行,如果程序处于空闲状态,这种情况就不会发生。因此,有很大的机会,一些最近分配的对象代表正在进行的工作,尽管它们当前是可达的,但它们很快就会变得不可达。第 1 代充当一种持有区,我们等待看看哪些对象是短命的,哪些是长寿的。

随着程序继续执行,GC 会不时运行,将新生的幸存对象提升到第 1 代。第 1 代中的一些对象将变得不可达。然而,GC 不一定会立即压缩堆的这一部分——它可能允许几次第 0 代的收集和压缩,然后才进行一次第 1 代的压缩,但最终还是会发生的。在此阶段幸存下来的对象将被移到第 2 代,这是最老的一代。

CLR 尝试从第 2 代中较不频繁地回收内存。研究显示,在大多数应用程序中,进入第 2 代的对象很可能会保持可访问状态很长时间,因此当其中一个对象最终变得不可达时,它很可能已经非常老了,周围的对象也是如此。这意味着为了回收内存而压缩堆的这一部分代价高昂,有两个原因:不仅可能是因为这个老对象后面跟着大量的其他对象(需要复制大量数据),而且它所占用的内存可能已经很长时间没有使用,意味着它可能不再位于 CPU 的缓存中,进一步减慢了复制的速度。而且,在收集之后,缓存成本会持续存在,因为如果 CPU 不得不在堆的旧区域移动数兆字节的数据,这可能会导致将其他数据从 CPU 的缓存中冲出。缓存的大小可以从低功耗、低成本端的 512 KB 开始,到高端、服务器导向芯片的超过 90 MB,但在中端,2 MB 到 16 MB 的缓存是典型的,并且许多 .NET 应用程序的堆将比这更大。应用程序之前使用的大部分数据将在第 2 代 GC 之前一直存在于缓存中,但是一旦 GC 完成,这些数据就会消失。因此,当 GC 完成并且正常执行恢复时,代码将会在一段时间内以慢动作运行,直到应用程序需要的数据重新加载到缓存中。

第 0 代和第 1 代有时被称为短暂代,因为它们主要包含存在时间很短的对象。(Mono 堆的这一部分通常称为nursery,因为它是为年轻对象而设的。)堆的这些部分的内容通常会在 CPU 的缓存中,因为它们最近已经被访问过,所以对于这些区域来说,压缩并不特别昂贵。此外,由于大多数对象的生命周期很短,GC 能够收集的大部分内存将来自这前两代对象,因此这些对象很可能会以消耗的 CPU 时间为代价提供最大的回报(即内存回收)。因此,在繁忙的程序中,每秒钟可能会看到几次短暂的收集,但在连续的第二代收集之间可能也常见几分钟的间隔。

CLR 对第 2 代对象还有另一个小技巧。它们通常变化不大,因此在 GC 的第一阶段中——即运行时检测可达对象的阶段——有很高的可能性会重复一些早期完成的工作,因为它将完全遵循相同的引用并对堆的显著部分产生相同的结果。因此,CLR 有时会使用操作系统的内存保护服务来检测旧的堆块何时被修改。这使得它能够依赖于早期 GC 操作的总结结果,而无需每次都重新执行所有工作。

GC 如何决定仅从第 0 代回收,还是从第 1 或甚至第 2 代回收?所有三代的回收都是通过消耗一定量的内存来触发的。因此,对于第 0 代的分配,一旦自上次 GC 以来分配了一些特定字节数,将会发生新的 GC。幸存下来的对象将移入第 1 代,CLR 会跟踪自上次第 1 代回收以来添加到第 1 代的字节数;如果该数字超过阈值,也会回收第 1 代。第 2 代的工作方式相同。这些阈值未记录,事实上它们甚至不是常量;CLR 监视您的分配模式,并修改这些阈值,以尝试找到在内存高效利用、最小化在 GC 中的 CPU 时间以及避免 CLR 在集合之间等待时间过长时产生的过度延迟之间的良好平衡。

注意

这解释了为什么 CLR 不一定等到内存实际耗尽才触发 GC,正如前面提到的那样。提早运行 GC 可能更有效。

你可能会想知道前面信息的实际意义有多大。毕竟,底线似乎是 CLR 确保堆块在可访问时保持,一旦它们变得不可访问,它将最终回收它们的内存,并且它采用一种旨在高效执行此操作的策略。这种分代优化方案的细节对开发者有影响吗?它们告诉我们某些编码实践可能比其他实践更高效。

这个过程最显而易见的结果是,您分配的对象越多,GC 就越难工作。但即使不了解实现方式,您也可能猜到这一点。更微妙的是,较大的对象会导致 GC 工作更加艰难——每代的回收都是由应用程序使用的内存量触发的。因此,更大的对象不仅增加了内存压力,它们还由于触发更频繁的 GC 而消耗了更多的 CPU 周期。

或许从理解收集器的代性质中得出的最重要的事实是,对象的生存期对 GC 的工作量有影响。生存时间非常短的对象能够得到有效处理,因为它们使用的内存在第 0 代或第 1 代的收集中将很快被回收,并且需要移动以压缩堆的数据量很小。而生存时间非常长的对象也没问题,因为它们最终会进入第二代。它们不会经常被移动,因为对该堆部分的收集是不频繁的。此外,CLR 可能能够利用操作系统内存管理器的写入检测功能来更有效地管理老对象的可达性发现。然而,虽然生存时间非常短和非常长的对象都能得到有效处理,但是那些存活到第二代但又不久的对象则是一个问题。微软有时将这种情况描述为中年危机

如果你的应用程序经常创建大量进入第二代但最终成为不可达的对象,CLR 将需要比通常更频繁地在第二代执行回收(实际上,第二代仅在全局回收期间进行回收,这也会回收之前由大对象使用的空闲空间)。这些通常比其他回收显著昂贵。压缩需要更多处理较老对象的工作,但同时在破坏第二代堆时也需要更多的清理工作。CLR 在堆的这一部分建立的关于可达性的图像可能需要重建,并且在压缩堆时,GC 将需要禁用用于启用写入检测的检测,这会带来成本。此外,这一部分堆中的大部分内容很可能也不会位于 CPU 的缓存中,因此处理它可能会很慢。

全局垃圾回收(Full GC)消耗的 CPU 时间明显多于短暂代的回收。在 UI 应用中,这可能导致用户遭遇到足以引起不适的延迟,尤其是如果堆的某些部分已被操作系统分页出去。在服务器应用中,全局回收可能导致服务请求处理时间显著波动。这些问题并非世界末日,正如我后面将描述的那样,CLR 提供了一些机制来减轻这些问题。即便如此,在设计将有趣数据缓存到内存中的代码时,最小化对象存活到第二代的数量对性能是有益的。在这一过程中,你需要考虑到垃圾回收行为的缓存老化策略可能会表现出低效,如果你不了解中年对象的危险,很难弄清楚原因。而且,正如我将在本章后面展示的那样,中年危机问题是你可能希望尽量避免使用 C#析构函数的一个原因。

顺便说一句,我没有提到一些堆操作的详细信息。 例如,我没有讨论 GC 通常如何将地址空间的部分专用于以固定大小的块分配内存,也没有详细讨论它如何提交和释放内存。 尽管这些机制很有趣,但与您如何设计代码有关的假设性 GC 对典型对象生命周期的了解要比意识更重要。 它们也往往会发生变化- .NET 6.0 在这个领域做出了重大修改以提高性能。

在讨论从不可达对象中收集内存的主题时,还有一件事要说。 正如前面提到的,大对象的工作方式不同。 有一个名为大对象堆(LOH)的单独堆,.NET 运行时会将大于 85,000 字节的对象放入其中;⁶ Mono 运行时使用 8,000 字节的阈值,因为它经常用于内存受限的环境。 这仅仅是对象本身,而不是对象在构建过程中分配的所有内存总和。 在示例 7-5 中的GreedyObject类的一个实例将非常小 - 它只需要足够的空间来存储单个引用,再加上堆块的开销。 在 32 位进程中,引用将占用 4 字节,开销将占用 8 字节,在 64 位进程中,这个空间将是两倍。 然而,它所引用的数组长度为 400,000 字节,因此会放在 LOH 中,而GreedyObject本身会放在普通堆中。

Example 7-5. 一个带有大数组的小对象
public class GreedyObject
{
    public int[] MyData = new int[100_000];
}

在技术上,可以创建一个需要 LOH 的实例的类,但在生成的代码或高度构造的示例之外,这种情况不太可能发生。 实际上,大多数 LOH 堆块将包含数组和可能是字符串。

LOH 与普通堆的最大区别在于,GC 通常不会压缩 LOH,因为复制大对象很昂贵。(应用程序可以请求在下一个完整的 GC 时压缩 LOH。 但在当前 CLR 实现中,没有明确请求此操作的应用程序将永远不会使其 LOH 被压缩。) 它更像传统的 C 堆:CLR 维护一个空闲块列表,并根据请求的大小决定使用哪个块。 然而,空闲块列表是由与堆的其余部分使用相同的不可达性机制填充的。

垃圾收集器模式

尽管 .NET 运行时将在运行时调整触发每一代收集的阈值等方面调整 GC 的某些行为,它还提供了可配置的选择,以适应不同类型的应用程序。这些可分为两大类别——工作站和服务器,在每个类别中,您可以选择使用后台或非并发收集。后台收集默认开启,但默认的顶层模式取决于项目类型:对于控制台应用程序和使用 WPF 等 GUI 框架的应用程序,GC 运行在工作站模式下,但 ASP.NET Core Web 应用程序将其更改为服务器模式。您可以通过在您的 .csproj 文件中定义一个属性来显式控制 GC 模式,如 示例 7-6 所示。这可以放在根 Project 元素的任何位置。

示例 7-6. 在 .NET Core 应用程序项目文件中启用服务器 GC
<PropertyGroup>
  <ServerGarbageCollection>true</ServerGarbageCollection>
</PropertyGroup>
注意

ServerGarbageCollection 属性使构建系统在生成您的应用程序的 YourApplication.runtimeconfig.json 文件时添加一个设置。这个文件包含一个 configProperties 部分,其中可以包含一个或多个 CLR 主机配置开关。在项目文件中启用服务器 GC 将在此配置文件中将 Sys⁠tem.​GC.⁠Ser⁠ver 开关设置为 true。所有 GC 设置也通过配置开关控制,如 JIT 编译器模式等 CLR 行为。

工作站模式是为客户端代码通常必须处理的工作负载设计的,在这种情况下,进程通常在任何时间都在处理单个任务或少量任务。工作站模式提供两种变体:非并发和后台。

在后台模式(默认模式)下,GC 尽量减少在 GC 期间挂起线程的时间。在某些 GC 阶段,CLR 需要暂停执行以确保一致性。对于短暂代的收集,线程将在操作的大部分时间内被挂起。这通常没问题,因为这些收集通常运行非常快速,它们花费的时间与未引起任何磁盘活动的页面错误相似。(这些非阻塞页面错误发生频率相当高,并且足够快,以至于许多开发人员甚至不知道它们发生过。)完整收集是问题所在,而后台模式处理这些情况有所不同。并非所有在收集中完成的工作都需要使一切停顿,后台模式利用这一点,使得完整(第二代)收集可以在后台线程上进行,而不强制其他线程阻塞,直到该收集完成。这对于具有 UI 的应用程序特别有用,因为它减少了由于 GC 而导致应用程序变得不响应的可能性。

非并发模式旨在优化单处理器单核心上的吞吐量。相比非并发 GC,后台 GC 在任何特定工作负载下使用的内存和 CPU 周期略多,但换取更低的延迟,可能更高效。对于某些工作负载,如果在项目文件中将ConcurrentGarbageCollection属性设置为false,则可能会发现代码运行更快。对于大多数客户端代码,最大的关注点是避免用户可见的延迟。用户对非响应更为敏感,而对次优平均 CPU 利用率的感知性较低,因此在交互式应用程序中,为了改善感知性能而多消耗一些内存和 CPU 周期通常是一个不错的权衡。

服务器模式与工作站模式显著不同。仅当您拥有多个硬件线程时才可用,例如,多核 CPU 或多个物理 CPU。(如果您已启用服务器 GC 但您的代码最终在单核机器上运行,它将退回到使用工作站 GC。)它的可用性与您运行的操作系统无关,例如,如果您拥有适当的硬件,不管您运行哪个 Windows 版本(包括非服务器和服务器版本),服务器模式都可用,而工作站模式始终可用。在服务器模式下,每个处理器核心都有其自己的堆部分,因此当一个线程独立于进程的其余部分工作时,它可以以最小的争用分配堆块。在服务器模式下,CLR 创建几个专用于 GC 的线程,每个逻辑 CPU 都有一个。这些线程比普通线程具有更高的优先级,因此当 GC 发生时,所有可用的 CPU 核心都会处理自己的堆,这可以在具有大堆的情况下提供比工作站模式更好的吞吐量。

由一个线程创建的对象仍然可以被其他线程访问——从逻辑上讲,堆仍然是一个统一的服务。服务器模式只是一种针对大部分情况下每个线程独立工作的工作负载优化的实现策略。请注意,如果所有作业具有类似的堆分配模式,它的效果最佳。

在服务器模式下可能会出现一些问题。当机器上只有一个进程使用此模式时,效果最佳,因为它设置为在收集期间尝试同时使用所有 CPU 核心。它还倾向于使用比工作站模式更多的内存。如果单个服务器托管多个 .NET 进程并且所有进程都这样做,资源争用可能会降低效率。服务器 GC 的另一个问题是它更偏重于吞吐量而非响应时间。特别是,收集发生得较少,因为这倾向于增加多 CPU 收集能够提供的吞吐量优势,但也意味着每个单独的收集时间更长。

与工作站 GC 一样,服务器 GC 默认使用后台收集。在某些情况下,禁用它可能会提高吞吐量,但要注意可能引起的问题。例如,在非并发服务器模式下进行完整收集可能会导致网站响应严重延迟,特别是如果堆很大的情况下。您可以通过几种方式来缓解这个问题。您可以在收集发生之前请求通知(使用System.GC类的RegisterForFullGCNotificationWaitForFullGCApproachWaitForFullGCComplete方法),如果您有服务器群,则运行完整 GC 的服务器可能会要求负载均衡器避免在 GC 完成之前传递请求给它。更简单的选择是保留后台收集功能。由于后台收集允许应用程序线程继续运行,甚至可以在后台进行 0 代和 1 代收集,因此它显著提高了应用程序在收集期间的响应时间,同时仍然提供服务器模式的吞吐量优势。

暂时挂起垃圾回收

可以要求.NET 在特定代码段运行时禁止 GC。如果您正在执行时间敏感的工作,这很有用。Windows、macOS 和 Linux 不是实时操作系统,因此从来没有任何保证,但是在关键时刻暂时排除 GC 可能仍然有助于减少事情在最糟糕的时刻变慢的机会。请注意,此机制通过提前执行可能在相关代码段中本来会发生的任何 GC 工作,因此这可能会导致 GC 相关的延迟比预期更早地发生。它只保证一旦您指定的代码区域开始运行,如果您满足某些要求,将不会再有进一步的 GC 发生——实际上,在时间关键工作开始之前,它会将必要的延迟排除在外。

GC类提供了TryStartNoGCRegion方法,您可以调用该方法指示您要开始一些需要不受 GC 中断影响的工作。您必须传入一个值,指示在此工作期间您将需要多少内存,它将尝试确保在继续之前至少有这么多内存可用(如果需要,执行 GC 以释放该空间)。如果该方法指示成功,则只要您不使用比请求的内存更多的内存,您的代码将不会被 GC 中断。在完成时间关键工作后,您应该调用EndNoGCRegion,使 GC 可以恢复其正常操作。如果在调用EndNoGCRegion之前,您的代码使用的内存超过了请求的量,CLR 可能会执行 GC,但只有在绝对不能避免直到调用EndNoGCRegion之前时才会执行。

虽然TryStartNoGCRegion的单参数形式会在必要时执行完整的 GC 以满足您的请求,但某些重载采用bool,使您能够告诉它,如果需要完整的阻塞 GC 来释放必要的空间,您更愿意中止。还有一些重载,您可以在其中分别指定普通堆和大对象堆的内存需求。

意外地破坏压缩

堆压缩是 CLR 的 GC 的重要特性,因为它对性能有显著积极影响。某些操作可能会阻止压缩,这是您希望尽量减少的事情,因为碎片化可能会增加内存使用并显著降低性能。

要能够压缩堆,CLR 需要能够移动堆块。通常情况下,它可以做到这一点,因为它知道应用程序引用堆块的所有位置,并且在重新定位块时可以调整所有引用。但是,如果您调用直接使用您提供的内存的操作系统 API 呢?例如,如果您从文件或网络套接字读取数据,那么这如何与 GC 交互?

如果使用读取或写入数据的系统调用,使用诸如硬盘或网络接口这样的设备,这些通常直接使用您应用程序的内存。如果您从磁盘读取数据,则操作系统可能会指示磁盘控制器将字节直接放入您应用程序传递给 API 的内存中。操作系统将执行必要的计算,以将虚拟地址转换为物理地址。(使用虚拟内存时,您的应用程序在指针中放置的值只间接相关于计算机 RAM 中的实际地址。)操作系统将在 I/O 请求期间锁定页面,以确保物理地址保持有效。然后,它将向磁盘系统提供该地址。这使得磁盘控制器可以将数据直接从磁盘复制到内存中,无需 CPU 进一步参与。这非常高效,但在遇到紧凑的堆时会遇到问题。如果内存块是堆上的byte[]数组怎么办?假设我们请求读取数据和磁盘能够提供数据之间发生了 GC。(机械硬盘的旋转盘片可能需要 10 毫秒或更长时间才能开始提供数据,从 CPU 的角度来看这是一个时代。)如果 GC 决定重新定位我们的byte[]数组以压缩堆,则操作系统提供给磁盘控制器的物理内存地址将过时,因此当控制器开始将数据放入内存时,它将写入错误的位置。

CLR 处理这个问题有三种方式。一种是让 GC 等待——在 I/O 操作进行期间,堆重定位可以暂停。但这是行不通的;一个忙碌的服务器可以连续运行数天,而没有进入没有 I/O 操作正在进行的状态。事实上,服务器甚至不需要忙碌。它可能会分配几个 byte[] 数组来容纳接下来的几个入站网络请求,并通常会尝试避免进入没有至少一个这样的缓冲区可用的状态。操作系统将拥有所有这些的指针,并且很可能已经为网络卡提供了相应的物理地址,以便它可以在数据开始到达时立即开始工作。因此,即使是空闲的服务器也有某些不能被重定位的缓冲区。

CLR 另一种选择是为这类操作提供一个单独的非移动堆。也许我们可以为 I/O 操作分配一个固定的内存块,然后在 I/O 完成后将结果复制到 byte[] 数组中的 GC 堆。但这也不是一个明智的解决方案。复制数据是昂贵的——你复制的入站或出站数据越多,服务器运行速度就越慢,因此你确实希望网络和磁盘硬件直接将数据复制到其自然位置或从其自然位置复制。如果这个假设的固定堆不仅仅是 CLR 的一个实现细节——如果它可以供应用程序代码直接使用以最小化复制,那可能会打开 GC 应该消除的所有内存管理错误的大门。

因此,CLR 使用第三种方法:有选择地防止堆块重定位。GC 在 I/O 操作进行期间可以自由运行,但某些堆块可以被固定。固定一个块会设置一个标志,告诉 GC 当前不能移动该块。因此,如果 GC 遇到这样的块,它将简单地将其留在原地,但会尝试重新定位其周围的所有内容。

通常有五种方式 C# 代码导致堆块被固定。你可以使用 fixed 关键字显式地这样做。这允许你获取一个指向存储位置(如字段或数组元素)的原始指针,编译器将生成确保固定指针在作用域内时,它引用的堆块将被固定的代码。固定块的更常见方式是通过互操作(即调用非托管代码,如操作系统 API)。如果你调用一个需要指向某物的指针的 API,CLR 将检测到指向堆块的情况,并自动固定该块。默认情况下,CLR 在方法返回时会自动取消固定。如果你调用一个异步 API,在返回后将继续使用内存,你可以使用前面提到的 GCHandle 类来固定一个堆块,直到你明确取消固定;这是第三种固定技术。

固定堆块的第四种和最常见的方法也是最不直接的:许多运行时库 API 会代表你调用非托管代码,并且会固定作为结果传递的数组。例如,运行时库定义了一个代表字节流的Stream类。这个抽象类有几个实现。一些流完全在内存中工作,但一些包装了 I/O 机制,提供对文件或通过网络套接字发送或接收的数据的访问。抽象的Stream基类定义了通过byte[]数组读取和写入数据的方法,而基于 I/O 的流实现通常会在必要时固定包含这些数组的堆块。

第五种方法是使用GC类的AllocateArray<T>方法。与其写new byte[4096],你可以写GC.AllocateArray<byte>(4096, pinned: true)。通过将第二个参数设置为true,你告诉 CLR 你希望这个数组永久固定。CLR 为此目的维护了一个额外的堆,称为固定对象堆(POH)。与 LOH 一样,POH 中的数组不会被移动,避免了固定可能造成的开销。

注意

POH 在.NET Framework 或 Mono 上不可用。它是在.NET 5.0 中引入的,因此在.NET Core 3.1 上也不可用(将完全支持直到 2022 年 12 月)。因此,AllocateArray<T>在这些较旧的.NET 版本上不可用。

如果你正在编写一个频繁进行固定操作的应用程序(例如大量的网络 I/O),你可能需要仔细考虑如何分配这些被固定的数组。固定对于最近分配的对象造成的损害最大,因为这些对象存在于堆的紧凑活动最频繁的区域。固定最近分配的块往往会导致堆的短暂部分碎片化。通常几乎立即恢复的内存现在必须等待块解固,因此当收集器能够访问这些块时,已经分配了更多其他块,这意味着需要更多工作来恢复内存。

如果固定导致你的应用程序出现问题,将会有一些常见的症状。在 GC 中花费的 CPU 时间百分比将相对较高——超过 10%被认为是不好的。但这并不一定说明固定是罪魁祸首——可能是中年对象导致了太多的全收集。因此,你可以监控堆上固定块的数量⁸,看看这是否是特定的罪魁祸首。如果看起来过度固定正在给你带来痛苦,那么如果你能使用.NET 5.0 或更高版本,你可以使用GC.AllocateArray<T>在 POH 上分配相关的块。

如果你需要支持没有 POH 的 .NET 版本,仍然有两种方法可以避免固定的开销。其中一种方法是设计你的应用程序,以便只固定在 LOH 上的块。记住,默认情况下 LOH 不会被压缩,因此固定不会产生任何成本 —— GC 无论如何都不会移动块。这样做的挑战在于它强制你只能使用至少 85,000 字节长的数组进行所有 I/O。这不一定是个问题,因为大多数 I/O API 可以告诉它们只使用数组的一部分。因此,如果你实际上想要处理 4,096 字节块,你可以创建一个足够大的数组来容纳至少 21 个这样的块。你需要编写一些代码来跟踪数组中使用的槽位,但如果它修复了性能问题,那可能是值得努力的。

警告

如果你选择通过尝试使用 LOH 来减少固定,你需要记住它是一个实现细节。未来的 .NET 版本有可能完全删除 LOH。因此,你需要针对每个新版本的 .NET 重新审视你设计的这一方面。

讨论的 Span<T>Memory<T> 类型在 第十八章 中可以使数组处理变得更加容易。它们不仅使得处理不存储在 GC 堆上的内存变得比以前容易得多,而且可以完全避免固定。事实上,处理固定的最佳策略通常是仅仅使用 MemoryPool<T>。在没有 POH 的运行时,它会采取措施为你减少固定的开销,而在 .NET 5.0 或更高版本中,默认情况下将内存分配到 POH 中。

减少固定影响的另一种方法是确保大部分固定只发生在第 2 代对象上。如果你为应用程序分配了一组缓冲区并在应用程序的整个生命周期内重用它们,这将意味着你正在固定 GC 几乎不太可能移动的块,使得临时代随时可以进行压缩。越早分配缓冲区越好,因为对象越老,GC 移动的可能性就越小,所以如果可能的话,在应用程序启动期间使用这种方法会更好。

强制垃圾收集

System.GC 类提供了一个 Collect 方法,允许你强制进行 GC。你可以传递一个表示你想收集的代数的数字,不带参数的重载执行完全收集。你很少会有充分的理由去调用 GC.Collect。我在这里提到它是因为它在网络上经常出现,这可能会让它看起来比实际更有用。

强制触发垃圾回收(GC)可能会导致问题。GC 监控自身的性能,并根据应用程序的分配模式调整其行为。但要做到这一点,它需要允许足够的时间进行收集,以便准确评估当前设置的效果。如果你过于频繁地强制进行收集,它将无法进行自我调整,结果将是双重的:GC 将运行比必要更频繁,并且当运行时,其行为将是次优的。这两个问题都可能增加在 GC 中消耗的 CPU 时间。

那么什么时候会强制进行收集?如果你知道你的应用程序刚刚完成了一些工作,并且即将进入空闲状态,那么考虑强制进行收集可能是值得的。GC 通常是由活动触发的,因此如果你知道你的应用程序即将进入休眠状态——也许它是一个刚刚完成了批处理作业并且在接下来的几个小时内不会再做任何工作的服务——你知道它不会分配新对象,因此不会自动触发 GC。因此,在应用程序进入休眠状态之前强制进行 GC 可以在应用程序进入休眠状态之前为操作系统释放内存提供机会。尽管如此,如果这是你的情况,也许值得考虑那些能够使你的进程完全退出的机制——当它们不活动时,只需要偶尔执行的作业或服务可以在完全不活动时完全卸载。但如果由于某些原因这种技术不适用——也许你的进程具有很高的启动成本或需要保持运行以接收传入的网络请求——那么强制进行完全的收集可能是下一个最佳选项。

值得注意的是,有一种情况下 GC 可能会在没有应用程序需要做任何事情的情况下被触发。当系统内存不足时,Windows 向所有运行中的进程广播消息。CLR 会处理此消息,并在发生时强制进行 GC。因此,即使你的应用程序不主动尝试释放内存,如果系统中其他部分需要内存,内存最终可能会被回收。

析构函数和终结

CLR 为了我们的利益而努力工作,以找出何时不再使用我们的对象。它可以通知你这一点——而不是简单地删除不可达对象,CLR 可以首先告知一个对象即将被删除。CLR 称之为终结,但在 C#中通过特殊语法来表达:要利用终结,你必须编写一个析构函数。

警告

如果你的背景是 C++,不要被名称或类似的语法所误导。正如你将看到的,C#中的析构函数在某些重要方面与 C++中的析构函数是不同的。

示例 7-7 展示了一个析构函数。这段代码编译成了一个名为 Finalize 的方法的覆盖,正如第六章所提到的,这是由 object 基类定义的一个特殊方法。Finalizer 必须总是调用它们所覆盖的 Finalize 的基类实现。C# 为我们生成了这个调用,以防止我们违反这个规则,这也是为什么我们不能直接编写 Finalize 方法。你不能编写调用 finalizer 的代码——它们由 CLR 调用,因此我们不指定析构函数的可访问性级别。

示例 7-7. 带析构函数的类
public class LetMeKnowMineEnd
{
    ~LetMeKnowMineEnd()
    {
        Console.WriteLine("Goodbye, cruel world");
    }
}

CLR 不保证按任何特定的时间表运行 finalizer。首先,它需要检测到对象已变为不可达,这要等到 GC 运行才会发生。如果你的程序空闲,可能会很长一段时间不会发生;GC 通常只会在程序在执行某些操作时,或者系统范围的内存压力导致 GC 开始运行时才会运行。完全可能会在对象变为不可达与 CLR 注意到它已不可达之间经过几分钟、几小时,甚至几天的时间。

即使 CLR 确实检测到不可达性,它仍不保证会立即调用 finalizer。Finalizer 在专用线程上运行。因为当前版本的 CLR 只有一个 finalization 线程(无论你选择哪种 GC 模式),一个慢速的 finalizer 将会导致其他 finalizer 等待。

在大多数情况下,CLR 甚至不保证会运行所有的 finalizer。当一个进程退出时,如果 finalization 线程还没来得及运行所有尚存的 finalizer,它将会立即退出,而不会等待它们全部完成。

总之,如果你的程序既空闲又繁忙,finalizer 可能会被无限期地延迟,并且不能保证会运行。更糟糕的是——在 finalizer 中实际上无法做太多有用的事情。

你可能会认为 finalizer 是确保某些工作得以完全完成的好地方。例如,如果你的对象将数据写入文件但缓冲了数据以便能够写入少量大块而不是小而散的写入(因为大块写入通常更有效率),你可能会认为 finalization 是确保缓冲区中的数据已安全刷新到磁盘的明显场所。但请再次考虑。

在终结期间,一个对象不能信任其引用的其他对象。如果你的对象的析构函数运行了,你的对象必须已经变得不可达。这意味着你的对象引用的任何其他对象也很可能已经变得不可达。CLR 可能会同时发现相关对象组的不可达性——如果你的对象创建了三四个对象来帮助它完成工作,那么这些对象都将在同一时间变得不可达。CLR 不保证按任何顺序运行终结器。这意味着可能在你的析构函数运行时,你使用的所有对象都已经被终结。因此,如果它们执行任何最后的清理工作,现在已经为时过晚。例如,派生自Stream并提供对文件访问的FileStream类,在其析构函数中关闭其文件句柄。因此,如果你希望将数据刷新到FileStream中,现在已经为时过晚。

注意

说实话,事情比我之前描述的要稍微好一些。尽管 CLR 不能保证运行大多数终结器,但实际上它通常会运行它们。缺乏保证仅在相对极端的情况下才有影响。即便如此,这并不能减轻一个事实,即通常不能依赖于析构函数中的其他对象。

由于析构函数似乎用处极小——也就是说,你不知道它们何时会运行,也不能在析构函数中使用其他对象——那么它们有什么用呢?

终结存在的主要原因是使得可以编写.NET 类型,这些类型是传统上由句柄表示的实体的包装器,例如文件和套接字。这些类型在 CLR 之外创建和管理——文件和套接字需要操作系统分配资源;库也可能提供基于句柄的 API,并且它们通常会在自己的私有堆上分配内存来存储有关句柄表示的信息。CLR 看不到这些活动——它只看到一个包含整数字段的.NET 对象,并不知道这个整数是 CLR 之外某些资源的句柄。因此,CLR 不知道当对象不再使用时关闭句柄的重要性。这就是终结器的作用:它们是放置代码的地方,告诉 CLR 之外的某些东西,由句柄表示的实体不再使用。在这种情况下,不能使用其他对象并不是问题。

注意

如果你正在编写包装句柄的代码,通常应该使用从SafeHandle派生的内置类之一,或者在绝对必要的情况下,派生自己的类。这个基类通过一些面向句柄的辅助函数扩展了基本的终结机制。此外,它从互操作层获得特殊处理,以避免资源过早释放。

尽管前面已经讨论了其不可预测性和不可靠性,但还有一些其他用途需要最终化,这意味着它对你的帮助是有限的。有些类包含一个仅检查对象是否处于未完成工作状态的终结器。例如,如果你编写了一个在将数据缓冲到文件之前进行缓冲的类(如前所述),你需要定义一些方法,调用者在完成对象使用时应该使用这些方法(例如FlushClose),然后你可以编写一个终结器来检查对象是否在被抛弃之前被放入了安全状态,如果没有,则引发错误。这将提供一种方式来发现程序是否忘记正确清理事物。

如果你编写了一个终结器,当你的对象处于不再需要最终化的状态时,你应该禁用它,因为最终化有其代价。如果你提供了一个CloseFlush方法,一旦这些方法被调用,最终化就不再需要了,所以你应该调用System.GC类的SuppressFinalize方法,让 GC 知道你的对象不再需要最终化。如果你的对象状态随后发生变化,你可以调用ReRegisterForFinalize方法来重新启用它。

最终化的最大成本是保证你的对象至少会存活到第一代,甚至可能更久。请记住,所有从第 0 代存活下来的对象都会进入第 1 代。如果你的对象有一个终结器,并且你没有通过调用SuppressFinalize来禁用它,CLR 不能在运行其终结器之前摆脱你的对象。由于终结器在单独的线程上异步运行,即使对象已被发现为不可达,它也必须保持活动状态。因此,尽管它是不可达的,但对象还不可收集。因此,它会继续存在到第 1 代。通常情况下,它将很快被最终化,这意味着对象随后会变成空间的浪费,直到进行第 1 代收集为止。这些收集比第 0 代收集频率低。如果你的对象在变得不可达之前已经进入第 1 代,那么终结器会增加在对象即将不再使用之前进入第 2 代的机会。因此,一个已最终化的对象对内存的使用效率不高,这是要避免最终化的原因,也是在确实需要最终化的对象中尽可能禁用它的原因。

警告

即使 SuppressFinalize 可以避免大部分终结的昂贵开销,但是使用这种技术的对象仍然比完全没有终结器的对象有更高的开销。CLR 在构造可终结对象时会做一些额外的工作,以跟踪那些尚未终结的对象(调用 SuppressFinalize 只是将对象从这个跟踪列表中移除)。因此,尽管抑制终结比让它发生要好得多,但如果一开始就不要求它的话,会更好。

终结的一个稍微奇怪的后果是,GC 发现的一个不可达的对象可以使自身重新变得可达。可以编写一个析构函数,将 this 引用存储在根引用中,或者存储在通过根引用可达的集合中。没有任何限制阻止你这样做,对象将继续工作(尽管如果对象再次变得不可达,则其终结器不会第二次运行),但这是一件奇怪的事情。这被称为复活,但仅仅因为你能做到并不意味着你应该这样做。最好避免这种情况。

希望到现在为止,我已经说服你析构函数并不提供一种通用的机制来清理对象。它们主要只对处理那些在 CLR 控制范围之外的句柄有用,并且最好避免依赖它们。如果你需要及时、可靠地清理资源,还有更好的机制。

IDisposable

运行时库定义了一个名为 IDisposable 的接口。CLR 不会特别对待这个接口,但是 C# 对其有一些内置的支持。IDisposable 是一个简单的抽象;如示例 7-8 所示,它仅定义了一个成员,即 Dispose 方法。

示例 7-8. IDisposable 接口
public interface IDisposable
{
    void Dispose();
}

IDisposable 背后的理念很简单。如果你的代码创建了一个实现了这个接口的对象,在你使用完该对象之后,应该调用 Dispose 方法(有时候会有例外,参见“可选的释放”)。这样可以让对象有机会释放它可能已经分配的资源。如果被处理的对象使用的是由句柄表示的资源,它通常会立即关闭这些句柄,而不是等待终结发生(同时应该抑制终结)。如果对象正在以有状态的方式使用某个远程机器上的服务——例如保持打开到服务器的连接以便能够发出请求——它会立即通知远程系统它不再需要这些服务,以任何必要的方式(例如关闭连接)。

有一个持续存在的谬误,即调用 Dispose 会导致 GC 执行某些操作。您可能在网上看到 Dispose 会终结对象,甚至导致对象被垃圾回收。这纯属无稽之谈。CLR 并不会对 IDisposableDispose 进行特殊处理,与其他接口或方法无异。

IDisposable 很重要,因为一个对象可能占用的内存很少,但却绑定了一些昂贵的资源。例如,考虑一个代表与数据库连接的对象。这样的对象可能不需要很多字段——甚至可能只有一个包含表示连接的句柄的字段。从 CLR 的角度来看,这是一个相当便宜的对象,我们甚至可以分配成百上千个而不触发 GC。但在数据库服务器中情况可能不同——它可能需要为每个传入的连接分配大量内存。连接甚至可能受到许可条款的严格限制。(这说明了“资源”是一个相当广泛的概念——它几乎意味着任何可能耗尽的东西。)

依赖 GC 注意到数据库连接对象不再使用很可能是一个糟糕的策略。CLR 将知道我们已经分配了,比如说,50 个东西,但如果总共只消耗了几百字节,它将看不到运行 GC 的理由。然而我们的应用程序可能即将停滞——如果我们只有 50 个数据库连接许可证,下一个尝试创建连接将失败。即使没有许可限制,我们仍可能通过打开比需要更多的连接而对数据库资源使用效率极低。

我们必须尽快关闭连接对象,而不是等待 GC 告诉我们哪些对象不再使用。这就是 IDisposable 的作用所在。当然,它不仅仅适用于数据库连接。对于任何代表生活在 CLR 之外的东西的对象,如文件或网络连接,它至关重要。即使对于不受特别限制的资源,IDisposable 也提供了一种告知对象我们已经完成使用它们的方法,以便它们可以干净地关闭,解决了之前描述的对于执行内部缓冲的对象的问题。

注意

如果资源创建成本高昂,可能希望重复使用它。数据库连接经常是这种情况,因此通常的做法是维护一个连接池。在完成使用连接后,不关闭连接,而是将其返回到池中,使其可以重新使用。(.NET 的许多数据访问提供程序可以为您执行此操作。)在这里仍然很有用的是 IDisposable 模型。当您向资源池请求资源时,通常会提供一个围绕真实资源的包装器,当您处置该包装器时,它会将资源返回到池中,而不是释放它。因此,调用 Dispose 实际上只是表示:“我不再需要这个对象了”,由 IDisposable 实现决定接下来如何处理它所代表的资源。

IDisposable 的实现必须能够容忍对 Dispose 的多次调用。尽管这意味着消费者可以多次调用 Dispose 而不会有害,但是在对象被处理后不应再试图使用它。事实上,运行库为此定义了一个特殊的异常,如果以这种方式误用对象,它们可以抛出:ObjectDisposedException。(我将在 第 8 章 中讨论异常。)

当然,您可以直接调用 Dispose,但是 C# 还支持三种方式使用 IDisposableforeach 循环,using 语句和 using 声明。using 语句是一种确保一旦完成对实现 IDisposable 的对象的使用就可靠地释放它的方式。示例 7-9 展示了如何使用它。

示例 7-9. 一个 using 语句
using (StreamReader reader = File.OpenText(@"C:\temp\File.txt"))
{
    Console.WriteLine(reader.ReadToEnd());
}

这相当于 示例 7-10 中的代码。tryfinally 关键字是 C# 异常处理系统的一部分,我将在 第 8 章 中详细讨论它们。在这种情况下,它们被用于确保在 try 块内的代码出现问题时,finally 块内的 Dispose 调用仍能执行。这也确保了如果在块的中间执行 return 语句,Dispose 也会被调用。(即使使用 goto 语句跳出块也是如此。)

示例 7-10. using 语句的扩展方式
{
    StreamReader reader = File.OpenText(@"C:\temp\File.txt");
    try
    {
        Console.WriteLine(reader.ReadToEnd());
    }
    finally
    {
        if (reader != null)
        {
            ((IDisposable) reader).Dispose();
        }
    }
}

如果 using 语句中声明的变量类型是值类型,C# 将不会生成检查 null 的代码,而直接调用 Dispose

C# 支持一个更简单的替代方案,即使用声明,如 示例 7-11 所示。区别在于我们不需要提供一个块。使用声明在变量超出范围时释放其变量。它仍然生成 tryfinally 块,因此在使用语句的块恰好完成于其他块的末尾的情况下(例如,它在方法的末尾完成),可以改为使用声明而不改变行为。这减少了嵌套块的数量,使您的代码更易读。(另一方面,对于普通的使用块,可能更容易看到对象何时不再使用。因此,每种样式都有其利弊。)

示例 7-11. 使用声明
using StreamReader reader = File.OpenText(@"C:\temp\File.txt");
Console.WriteLine(reader.ReadToEnd());

如果您需要在同一作用域内使用多个可释放资源,并且希望使用使用语句而不是声明(例如,因为您希望尽快释放资源而不是等待相关变量超出范围),您可以嵌套它们,但如果您在一个单独的块前堆叠多个使用语句可能更易于阅读。示例 7-12 使用此方法将一个文件的内容复制到另一个文件中。

示例 7-12. 堆叠使用语句
using (Stream source = File.OpenRead(@"C:\temp\File.txt"))
using (Stream copy = File.Create(@"C:\temp\Copy.txt"))
{
    source.CopyTo(copy);
}

堆叠使用语句不是一种特殊语法;这只是一个事实的结果,即使用语句总是后跟一个单独的嵌入语句,在调用 Dispose 之前将执行该语句。通常,该语句是一个块,但在 示例 7-12 中,第一个使用语句的嵌入语句是第二个使用语句。如果您使用使用声明而不是,堆叠是不必要的,因为这些没有相关的嵌入语句。

如果枚举器实现了 IDisposableforeach 循环将生成使用 IDisposable 的代码。示例 7-13 展示了使用这种枚举器的 foreach 循环。

示例 7-13. foreach 循环
foreach (string file in Directory.EnumerateFiles(@"C:\temp"))
{
    Console.WriteLine(file);
}

Directory 类的 EnumerateFiles 方法返回一个 IEnumerable<string>。正如您在 第五章 中看到的,它有一个 GetEnumerator 方法返回一个 IEnumer⁠ator​<string>,这是继承自 IDisposable 的接口。因此,C# 编译器将生成与 示例 7-14 等效的代码。

示例 7-14. foreach 循环如何展开
{
    IEnumerator<string> e =
        Directory.EnumerateFiles(@"C:\temp").GetEnumerator();
    try
    {
        while (e.MoveNext())
        {
            string file = e.Current;
            Console.WriteLine(file);
        }
    }
    finally
    {
        if (e != null)
        {
            ((IDisposable) e).Dispose();
        }
    }
}

编译器可以生成几种变体,取决于集合的枚举器类型。如果它是实现了 IDisposable 的值类型,编译器在 finally 块中不会生成对 null 的检查(就像在 using 语句中一样)。如果枚举器的静态类型不实现 IDisposable,则结果取决于类型是否对继承开放。如果它是密封的,或者如果它是值类型,编译器将不会生成尝试调用 Dispose 的代码。如果它没有被密封,编译器将在 finally 块中生成代码,在运行时测试枚举器是否实现了 IDisposable,如果是,则调用 Dispose,否则不执行任何操作。

IDisposable 接口在使用起来最简单的情况是,在同一个方法中获取资源并在使用完毕后释放它,因为你可以编写一个 using 语句(或在适当的情况下,一个 foreach 循环)来确保调用 Dispose。但有时,你会编写一个创建可释放对象并将其引用放入字段的类,因为它需要在较长时间内使用该对象。例如,你可能会编写一个日志记录类,如果日志记录器对象将数据写入文件,则可能会保留 StreamWriter 对象。在这种情况下,C# 不会提供自动帮助,因此你需要确保任何包含的对象都被释放。你将编写自己的 IDisposable 实现来释放其他对象,就像示例 7-15 所示。请注意,此示例将 _file 设置为 null,因此不会尝试两次释放文件。这并非绝对必要,因为 StreamWriter 可以容忍对 Dispose 的多次调用。但这确实为 Logger 对象提供了一种简单的方法来知道它处于已释放状态,因此如果我们添加了一些真正的方法,我们可以检查 _file,如果为 null,则抛出 ObjectDisposedException

示例 7-15. 释放包含的实例
public sealed class Logger : IDisposable
{
    private StreamWriter? _file;

    public Logger(string filePath)
    {
        _file = File.CreateText(filePath);
    }

    public void Dispose()
    {
        if (_file != null)
        {
            _file.Dispose();
            _file = null;
        }
    }
    // A real class would go on to do something with the StreamWriter, of course
}

此示例避开了一个重要的问题。该类是密封的,这避免了如何处理继承的问题。如果你编写一个未密封的类并实现了 IDisposable,你应该提供一种方法,让派生类添加自己的清理逻辑。最直接的解决方案是将 Dispose 声明为虚方法,以便派生类可以重写它,在调用基类实现的同时执行自己的清理。然而,在 .NET 中有时会看到更复杂的模式。

有些对象实现了IDisposable并且还有一个终结器。自从引入了SafeHandle及其相关类以来,一个类需要同时提供这两者的情况就相对不常见了(除非它是从SafeHandle派生而来)。通常只有处理句柄的包装器才需要终结器,而现在通常使用句柄的类会推迟到SafeHandle提供这个功能,而不是自己实现终结器。不过,也有例外情况,一些库类型实现了一种模式,旨在支持终结和IDisposable,使你能够在派生类中为两者提供自定义行为。例如,Stream基类就是这样工作的。

警告

这种模式被称为dispose 模式,但不要认为在实现IDisposable时通常应该使用它。相反,几乎不需要这种模式。即使在它被发明时,也只有少数类需要它,而现在有了SafeHandle之后,几乎从不需要了(SafeHandle在.NET 2.0 中引入,所以自从 dispose 模式广泛有用以来已经很长时间了)。不幸的是,一些人误解了这种模式的狭窄实用性,所以你会找到一些善意但完全错误的建议告诉你应该对所有IDisposable实现使用它。请忽略这些建议。这种模式今天的主要相关性在于你有时会在旧类型(如Stream)中遇到它。

这种模式是定义一个受保护的Dispose重载,它接受一个bool参数。基类从其公共Dispose方法以及析构函数中调用此方法,分别传递truefalse。这样,你只需重写一个方法,即受保护的Dispose方法。它可以包含对终结和处理通用的逻辑,比如关闭句柄,但你也可以执行任何特定于处理或终结的逻辑,因为参数告诉你正在执行哪种类型的清理。示例 7-16 展示了这种模式可能的样子。(这仅用于示例,MyCustomLibraryInteropWrapper类是为这个例子而虚构的。)

示例 7-16. 自定义终结和处理逻辑
public class MyFunkyStream : Stream
{
    // For illustration purposes only. Usually better to avoid this whole
    // pattern and to use some type derived from SafeHandle instead.
    private IntPtr _myCustomLibraryHandle;
    private Logger? _log;

    protected override void Dispose(bool disposing)
    {
        base.Dispose(disposing);

        if (_myCustomLibraryHandle != IntPtr.Zero)
        {
            MyCustomLibraryInteropWrapper.Close(_myCustomLibraryHandle);
            _myCustomLibraryHandle = IntPtr.Zero;
        }
        if (disposing)
        {
            if (_log != null)
            {
                _log.Dispose();
                _log = null;
            }
        }
    }

    // ...overloads of Stream's abstract methods would go here
}

这个假设性示例是对Stream抽象的自定义实现,它使用了一些外部非.NET 库,该库提供基于句柄的资源访问。我们更倾向于在公共Dispose方法调用时关闭句柄,但如果在我们的终结器运行时还没有发生这种情况,我们希望在那时关闭句柄。因此,代码检查句柄是否仍然打开,并在必要时关闭它,无论调用Dispose(bool)重载是否因显式释放对象或终结器运行而发生,我们都需要确保句柄在任一情况下都被关闭。然而,这个类似乎也使用了来自示例 7-15 的Logger类的实例。因为那是一个普通对象,我们不应在终结器中尝试使用它,所以我们只在对象被释放时尝试处理它。如果我们正在进行终结处理,那么尽管Logger本身不可终结,它使用的FileStream是可终结的;而且很可能FileStream的终结器已经在我们的MyFunkyStream类的终结器运行时运行过,因此在Logger上调用方法会是个坏主意。

当基类提供了这种虚拟的受保护的Dispose形式时,应该在其公共Dispose方法中调用GC.SuppressFinalizationStream基类就是这样做的。更一般地说,如果你发现自己编写了一个既提供了Dispose又提供了终结器的类,那么无论你选择是否支持继承这种模式,当调用Dispose时,你都应该抑制终结处理。

既然我建议避免这种模式,那么像示例 7-15 这样的代码在不接受使用sealed的情况下应该怎么办?答案很简单:如果你正在编写一个实现了IDisposable的类,并且希望该类可以被继承(即不是sealed),请将你的Dispose方法设为virtual。这样,派生类型可以重写它以添加它们自己的处理逻辑(而这些重写应始终调用基类的Dispose)。

可选的处理

尽管你应该在大多数实现了IDisposable接口的对象上的某个时刻调用Dispose,但也有少数例外情况。例如,.NET 的反应式扩展(在 第十一章 中描述)提供了表示事件流订阅的IDisposable对象。你可以调用Dispose来取消订阅,但有些事件源会自然结束,自动关闭任何订阅。如果发生这种情况,你就不需要调用Dispose。此外,广泛与异步编程技术结合使用的Task类型(在 第十七章 中描述)实现了IDisposable,但除非你引起它分配一个WaitHandle,在正常使用中是不会发生的。Task通常的使用方式使得在它上面找到一个合适的时间调用Dispose特别麻烦,所以幸运的是通常情况下不需要这样做。

HttpClient 类是另一个例外,但方式不同。我们很少对这种类型的实例调用Dispose,这是因为我们被鼓励重用实例。如果每次需要时构造、使用和处理一个HttpClient,你会破坏其重用现有连接的能力,当多次向同一服务器发送请求时。这可能会导致两个问题。首先,打开 HTTP 连接有时可能比发送请求和接收响应更耗时,因此阻止HttpClient重用连接以随时间发送多个请求可能会引起显著的性能问题。只有重用HttpClient才能使连接重用起效果。⁹ 其次,TCP 协议(HTTP 的基础)具有的特性意味着操作系统不能总是立即回收与连接相关的所有资源:它可能需要保留连接的 TCP 端口相当长的时间(可能几分钟),即使你已告诉操作系统关闭了连接,也可能耗尽端口,阻止所有进一步的通信。

这样的例外情况并不常见。仅当你使用的类的文档明确说明不需要调用Dispose时,才可以安全地省略调用Dispose

装箱

当我讨论 GC 和对象生命周期时,还有一个话题我应该在这一章节中讲述:装箱。装箱是使得类型为object的变量能够引用值类型的过程。一个object变量只能持有对堆上某物的引用,那么它如何能引用一个int呢?当代码在 示例 7-17 中运行时会发生什么?

示例 7-17. 使用int作为对象
static void Show(object o)
{
    Console.WriteLine(o.ToString());
}

int num = 42;
Show(num);

Show方法期望一个对象,而我正在传递num,这是一个值类型int的局部变量。在这些情况下,C#会生成一个箱子,这实质上是一个值的引用类型包装器。CLR 可以自动为任何值类型提供一个箱子,尽管如果它没有提供,你可以编写自己的类来执行类似的操作。示例 7-18 展示了一个手工构建的箱子。

示例 7-18. 实际上不是箱子的工作原理
// Not a real box but similar in effect.
public class Box<T>
    where T : struct
{
    public readonly T Value;
    public Box(T v)
    {
        Value = v;
    }

    public override string? ToString() => Value.ToString();
    public override bool Equals(object? obj) => Value.Equals(obj);
    public override int GetHashCode() => Value.GetHashCode();
}

这是一个包含一个值类型实例作为唯一字段的相当普通的类。如果你在箱子上调用object的标准成员,这个类的重写使它看起来好像你直接调用了字段本身。因此,如果我将new Box<int>(num)作为参数传递给示例 7-17 中的ShowShow将接收到该箱子的引用。当Show调用ToString时,箱子将调用int字段的ToString,所以你会期望程序显示 42。

我们不需要编写示例 7-18,因为 CLR 将为我们构建这个箱子。它将在堆上创建一个包含装箱值副本的对象,并将标准object方法转发给装箱值。它还做了一些我们无法做到的事情。如果调用一个装箱的intGetType方法来获取其类型,它将返回与直接调用int变量的GetType方法相同的Type对象。对于我的自定义Box<T>,我无法这样做,因为GetType不是虚拟的。此外,与手工构建的箱子相比,获取底层值更容易,因为解箱是 CLR 的内置特性。

如果你有一个类型为object的引用,并将其转换为int,CLR 将检查该引用是否确实指向一个装箱的int;如果是,CLR 将返回装箱值的副本。(如果不是,它会抛出InvalidCastException异常。)因此,在示例 7-17 的Show方法中,我可以写(int) o来获取原始值的副本,而如果我在示例 7-18 中使用该类,我将需要更复杂的((Box<int>) o).Value

我还可以使用模式匹配来提取一个装箱值。示例 7-19 使用声明模式来检测变量o是否包含一个装箱的int的引用,如果是,则将其提取到局部变量i中。正如我们在第二章中看到的那样,当你像这样使用is操作符与模式时,如果模式匹配,则结果表达式评估为true,如果不匹配则为false。因此,仅当那里确实有一个int值需要解箱时,才会运行此if语句的主体。

示例 7-19. 使用类型模式进行拆箱
if (o is int i)
{
    Console.WriteLine(i * 2);
}

所有结构体都自动支持装箱,¹⁰ 不仅仅是内置的值类型。如果结构体实现了任何接口,该装箱将提供相同的所有接口。 (这是 示例 7-18 无法执行的另一个技巧。)

一些隐式转换会导致装箱。你可以在 示例 7-17 中看到这一点。我传递了一个 int 类型的表达式到需要 object 的地方,而不需要显式转换。隐式转换也存在于值与其类型实现的任何接口之间。例如,你可以将类型为 int 的值分配给类型为 IComparable<int> 的变量(或将其作为该类型的方法参数传递),而不需要进行转换。这将创建一个装箱,因为任何接口类型的变量都类似于 object 类型的变量,它们只能保存对堆上项目的引用。

注意

隐式装箱转换不等同于隐式引用转换。这意味着它们在协变或逆变中不起作用。例如,IEnumerable<int>IEnumerable<object> 不兼容,尽管从 intobject 存在隐式转换,因为这不是隐式引用转换。

隐式装箱偶尔可能会因两个原因之一而引起问题。首先,它会导致 GC 需要额外的工作。CLR 不会尝试缓存装箱,因此如果你编写一个执行 100,000 次的循环,并且该循环包含使用隐式装箱转换的表达式,你将会生成 100,000 个装箱,最终 GC 将不得不像清理堆上的其他任何内容一样清理它们。其次,每个装箱操作(和每个拆箱操作)都会复制值,这可能不会提供您预期的语义。示例 7-20 展示了一些可能令人惊讶的行为。

示例 7-20. 阐明可变结构的潜在问题
static void CallDispose(IDisposable o)
{
    o.Dispose();
}

DisposableValue dv = new ();
Console.WriteLine("Passing value variable:");
CallDispose(dv);
CallDispose(dv);
CallDispose(dv);

IDisposable id = dv;
Console.WriteLine("Passing interface variable:");
CallDispose(id);
CallDispose(id);
CallDispose(id);

Console.WriteLine("Calling Dispose directly on value variable:");
dv.Dispose();
dv.Dispose();
dv.Dispose();

Console.WriteLine("Passing value variable:");
CallDispose(dv);
CallDispose(dv);
CallDispose(dv);

public struct DisposableValue : IDisposable
{
    private bool _disposedYet;

    public void Dispose()
    {
        if (!_disposedYet)
        {
            Console.WriteLine("Disposing for first time");
            _disposedYet = true;
        }
        else
        {
            Console.WriteLine("Was already disposed");
        }
    }
}

DisposableValue 结构实现了我们之前看到的 IDisposable 接口。它跟踪它是否已被处理。程序包含一个 CallDispose 方法,该方法在任何 IDisposable 实例上调用 Dispose。程序声明了一个类型为 DisposableValue 的单一变量,并将其传递给 CallDispose 三次。以下是程序该部分的输出:

Passing value variable:
Disposing for first time
Disposing for first time
Disposing for first time

在所有三个场景中,该结构体似乎认为这是我们首次调用其 Dispose 方法。这是因为每次调用 CallDispose 都创建了一个新的装箱——我们实际上并没有传递 dv 变量;每次都传递了一个新的装箱副本,因此 CallDispose 方法每次都在不同的结构体实例上工作。这与值类型通常的工作方式一致——即使没有装箱,当你将其作为参数传递时,你得到的是一个副本(除非使用 refin 关键字)。

程序的下一部分最终只生成了一个装箱——它将值分配给另一个类型为IDisposable的局部变量。这使用了与我们直接将变量作为参数传递时相同的隐式转换,因此这创建了另一个装箱,但是仅仅是一次。然后我们将同一个引用传递给这个特定装箱的三次调用,这解释了为什么程序这一阶段的输出看起来不同:

Passing interface variable:
Disposing for first time
Was already disposed
Was already disposed

这三次对CallDispose的调用都使用了同一个装箱,其中包含我们结构体的一个实例,所以在第一次调用后,它就记住它已经被处理了。接下来,我们的程序直接在局部变量上调用Dispose,生成了这个输出:

Calling Dispose directly on value variable:
Disposing for first time
Was already disposed
Was already disposed

这里完全没有涉及装箱,所以我们正在修改局部变量的状态。只看了一眼代码的人可能没有预料到这个输出——我们已经将dv变量传递给一个调用其参数Dispose的方法,因此在第一次执行时,它可能会认为它尚未被处理。但是一旦你理解了CallDispose需要一个引用,因此不能直接使用值,那么在这一点之前每次调用Dispose操作的都是某个装箱副本,而不是局部变量本身,一切就显而易见了。

最后,我们再次进行三次调用,直接将dv传递给CallDispose。这正是我们在代码开头所做的,所以这些调用产生了更多的装箱副本。但这一次,我们复制的是一个已经处于已处理状态的值,因此我们看到了不同的输出:

Passing value variable:
Was already disposed
Was already disposed
Was already disposed

当你理解发生了什么时,这些行为都很简单,但是这要求你注意你正在处理一个值类型,并且理解何时装箱会导致隐式复制。这就是微软建议开发人员不要编写可能改变其状态的值类型的一个原因——如果一个值不能改变,那么该类型的装箱值也不能改变。无论你处理的是原始值还是装箱副本,影响都较小,因此避免性能损失时,理解何时会发生装箱仍然很有用。

在早期的.NET 版本中,装箱在集合类中十分普遍,直到.NET 2.0 引入泛型之前。集合类都是以object为基础工作的,所以如果你想要一个可变大小的整数列表,列表中的每个int都会被装箱。泛型集合类不会导致装箱——List<int>能够直接存储未装箱的值。

装箱 Nullable

第三章描述了Nullable<T>类型,这是一个包装器,为任何值类型添加了空值支持。请记住,C#为此有特殊的语法,在值类型名称末尾加上一个问号,所以我们通常会写int?而不是Nullable<int>。当涉及到装箱时,CLR 对Nullable<T>有特殊支持。

Nullable<T>本身是一个值类型,因此如果您尝试获取对它的引用,编译器将生成试图将其装箱的代码,就像处理任何其他值类型一样。然而,在运行时,CLR 不会生成包含Nullable<T>本身副本的装箱。相反,它会检查值是否处于空状态(即其HasValue属性返回false),如果是,则返回null。否则,它将装箱包含的值。例如,如果Nullable<int>有一个值,将其装箱将产生类型为int的箱。这与您从普通int值开始时得到的箱无法区分。(其中一个结果是,示例 7-19 中显示的模式匹配无论最初装箱的变量类型是int还是int?,都可以使用int在声明模式中。)

您可以将装箱的int解包为int?int类型的变量。因此,示例 7-21 中的所有三个解包操作都将成功。如果将第一行修改为从未处于空状态的Nullable<int>初始化boxed变量,则它们也将成功。 (如果您从处于空状态的Nullable<int>初始化boxed,那将产生与将其初始化为null相同的效果,此示例的最后一行将抛出NullReferenceException。)

示例 7-21. 将int解包成可空和非可空变量
object boxed = 42;
int? nv = boxed as int?;
int? nv2 = (int?) boxed;
int v = (int) boxed;

这是一个运行时特性,而不仅仅是编译器的聪明。IL box指令(这是 C#在想要装箱值时生成的内容)检测到Nulla⁠ble​<T>值;unboxunbox.any IL 指令能够从null或引用基础类型的装箱值产生Nulla⁠ble​<T>值。因此,如果您编写自己的看起来像Nullable<T>的包装类型,它不会表现出相同的行为;如果您将您的类型的值分配给一个object,它将像处理任何其他值一样对您的整个包装进行装箱。只有因为 CLR 知道Nullable<T>的存在,它才会表现出不同的行为。

总结

在本章中,我描述了运行时提供的堆。我展示了 CLR 用于确定哪些堆对象仍可被你的代码访问的策略,以及它用于回收不再使用的对象所占用内存的基于代的机制。GC 并非能预见,因此如果你的程序保持了一个对象的可访问性,GC 必须假设你将来可能会使用该对象。这意味着有时你需要小心确保不会因为意外保留对象太长时间而导致内存泄漏。我们看了最终化机制及其各种限制和性能问题,并且我们还看了IDisposable,它是清理非内存资源的首选系统。最后,我们看到了值类型如何因装箱而表现得像引用类型。

下一章中,我将展示 C#如何呈现 CLR 的错误处理机制。

¹ 本章中“GC”缩写用来指代垃圾收集器机制以及垃圾收集,即垃圾收集器的功能。

² Mono 运行时的 GC 与.NET GC 没有共享代码,尽管它们现在都驻留在同一个 GitHub 仓库中。尽管如此,它们在这里使用相同的方法。

³ 使用ref struct定义的值类型是一个例外:它们总是存在于堆栈上。第十八章讨论了这些内容。

⁴ CLR 并不总是等到内存用尽才进行垃圾回收。稍后我会详细讨论这些细节。目前,重要的是时不时地它会尝试释放一些空间。

⁵ Mono 运行时使用了稍微简化的方案,但仍然依赖于将新旧对象区分对待的基本原则。

⁶ .NET 提供了一个配置设置,允许你更改这个阈值。

⁷ 虽然单核 CPU 如今已经很少见,但在虚拟机上运行,将只有一个核心呈现给它们托管的代码仍然很常见。例如,如果你的应用程序在使用按消耗计费的云托管服务。

⁸ 你可以使用一个名为 PerfView 的免费 Microsoft 工具来完成这个操作。另外,免费的 BenchmarkDotNet 工具具有内存诊断功能。

⁹ 严格来说,需要重复使用的是底层的MessageHandler。如果你从IHttpClientFactory获取一个HttpClient,释放它是无害的,因为工厂会持有处理程序并在多个HttpClient实例中重用它。

¹⁰ 除了ref struct类型,因为它们总是存在于堆栈上。

第八章:异常

有些操作可能会失败。如果你的程序从存储在外部驱动器上的文件读取数据,可能会有人断开驱动器。您的应用程序可能尝试构造数组,但发现系统没有足够的空闲内存。间歇性的无线网络连接问题可能会导致网络请求失败。程序发现这些故障的一个广泛使用的方法是每个 API 返回一个值,指示操作是否成功。这要求开发人员保持警惕,以便检测所有的错误,因为程序必须检查每个操作的返回值。这确实是一种可行的策略,但它可能会使代码变得难以理解;当没有问题时执行的工作的逻辑顺序可能会被所有的错误检查淹没,使得代码难以维护。C# 支持另一种流行的错误处理机制,可以缓解这个问题:异常

当 API 报告异常导致操作失败时,会打断正常的执行流程,直接跳转到最近的适当错误处理代码。这种方式使得错误处理逻辑与尝试执行任务的代码分离,这样做可以使得代码更易读和维护,尽管其缺点是可能较难看出代码可能执行的所有可能路径。

异常还可以报告操作问题,而这些问题可能不适合使用返回码进行处理。例如,运行时可以检测和报告基本操作的问题,甚至简单到使用引用。引用类型变量可能包含null,如果尝试在空引用上调用方法,则会失败。运行时会用异常报告这种情况。

.NET 中的大多数错误都表示为异常。但是,一些 API 提供了返回代码和异常之间的选择。例如,int 类型具有一个 Parse 方法,该方法接受一个字符串并尝试解释其内容为数字,如果传递了一些非数字文本(例如 "Hello"),它将通过抛出 FormatException 来指示失败。如果您不喜欢这样,可以改用 TryParse,它完全执行相同的任务,但如果输入非数字,则返回 false 而不是抛出异常。(由于方法的返回值负责报告成功或失败,该方法通过 out 参数提供整数结果。)数字解析并不是唯一使用此模式的操作,在这种情况下,一对方法(在本例中为 ParseTryParse)提供了异常和返回值之间的选择。正如您在 第五章 中看到的那样,字典也提供了类似的选择。索引器如果使用不在字典中的键,则会抛出异常,但您还可以使用 TryGetValue 查找值,如果失败,则返回 false,就像 TryParse 一样。尽管此模式在几个地方出现,但对于大多数 API 来说,异常是唯一的选择。

如果您设计一个可能会失败的 API,应如何报告失败?应该使用异常、返回值还是两者兼有?微软的类库设计指南包含似乎毫不含糊的说明:

不要返回错误代码。在框架中,异常是报告错误的主要手段。

.NET Framework 设计指南

但是这与存在 int.TryParse 的事实如何相符呢?指南中有关异常性能考虑的部分如下所述:

考虑在常见情况下可能会抛出异常的成员使用“尝试-解析”模式,以避免与异常相关的性能问题。

.NET Framework 设计指南

解析数字失败不一定是错误。例如,您可能希望应用程序允许以数字或文本指定月份。因此,在操作可能失败的常见情况下,还有另一个标准:它建议在“极其性能敏感的 API”中使用 TryParse 方法,因此只有在操作速度比抛出和处理异常的时间快时,您才应该提供这种方法。

异常通常可以在几毫秒内抛出和处理,因此它们并不是非常慢的——例如,并不像读取网络连接上的数据那么慢——但它们也不是极快的。我发现在我的电脑上,使用.NET 6.0,单线程可以以约每秒大约 8000 万个字符串的速率解析五位数字字符串,并且如果我使用TryParse,它能够以类似的速度拒绝非数字字符串。Parse方法处理数字字符串同样快,但在拒绝非数字字符串方面大约慢 400 倍,这要归因于异常的成本。当然,将字符串转换为整数是一种非常快速的操作,所以这使得异常看起来特别糟糕,但这也是为什么这种模式在自然快速的操作中最为常见。

调试时,异常可能特别慢。部分原因是调试器必须决定是否中断,但特别是在程序首次遇到未处理的异常时尤为明显。这可能给人一种异常的成本远高于实际情况的印象。上述段落中的数字基于观察到的运行时行为,没有考虑调试开销。尽管如此,这些数字略微低估了成本,因为处理异常往往会导致 CLR 运行代码片段并访问否则不需要使用的数据结构,这可能会导致有用的数据被推出 CPU 的缓存,使代码在异常处理后的短时间内运行更慢,直到非异常代码和数据重新进入缓存。我的示例的简单性减少了这种影响。

大多数 API 不提供Try*Xxx*形式,并且会将所有失败报告为异常,即使在失败可能很常见的情况下也是如此。例如,文件 API 不提供一种在文件丢失时打开现有文件进行读取而不抛出异常的方式。(您可以使用不同的 API 先测试文件是否存在,但这并不能保证成功。总是可能会有其他进程在您询问文件是否存在和尝试打开它之间删除该文件。)由于文件系统操作本质上是慢速的,即使在这里Try*Xxx*模式也不会提供值得的性能提升,尽管逻辑上可能有意义。

警告

如果您使用Try*Xxx*模式,请注意,如果操作可能会失败的原因有多种,false返回值通常只表示一种特定类型的失败。因此,这种类型的方法在某些失败模式下仍可能抛出异常。

异常来源

类库 API 不是异常的唯一来源。它们可以在以下任何场景中抛出:

  • 您自己的代码检测到了一个问题。

  • 您的程序使用了一个类库 API,它检测到了一个问题。

  • 运行时检测到操作失败(例如,在检查上下文中发生算术溢出,或者尝试使用空引用,或者尝试为没有足够内存的对象分配内存)。

  • 运行时检测到影响您代码的情况,这些情况不在您的控制之内(例如,运行时尝试为某些内部目的分配内存,却发现没有足够的可用内存)。

尽管所有这些情况都使用相同的异常处理机制,但异常发生的位置各不相同。当您自己的代码抛出异常时(我稍后会告诉您如何操作),您将知道导致它发生的条件,但这些其他场景何时会产生异常呢?我将在接下来的部分描述在哪里期望每种类型的异常。

来自 API 的异常

使用 API 调用时,可能会出现几种导致异常的问题。您可能提供了毫无意义的参数,比如需要非空引用而提供了 null 引用,或者期望文件名而提供了空字符串。或者这些参数在单独看起来是合理的,但在集体使用时却不行。例如,您可能调用了一个将数据复制到数组的 API,请求它复制超过数组容量的数据。您可以将这些错误描述为“那绝对行不通”的错误类型,通常是由于代码中的错误而导致的。(一个曾在 C# 编译器团队工作过的开发者将这些称为 愚蠢的 异常。)

另一类问题是,参数看起来都合理,但基于当前世界状态,操作实际上不可能进行。例如,您可能要求打开某个特定的文件,但该文件可能不存在;或者它存在,但某个其他程序已经打开并要求独占访问该文件。还有另一种情况是,事情可能一开始顺利,但情况可以改变,例如您成功打开了一个文件并且已经读取了一段时间的数据,但随后该文件变得不可访问。如前所述,可能是有人拔掉了磁盘,或者驱动器由于过热或老化而失败。

与外部服务通过网络通信的软件需要考虑,异常并不一定表示真的有什么问题—有时请求因某些临时条件而失败,您可能只需重试操作。这在云环境中特别常见,在那里单个服务器作为负载平衡的一部分会频繁上下线—因此偶尔会有几次操作由于没有明确的原因而失败。

小贴士

当使用库通过服务时,你应该弄清楚它是否已经为你处理了这些问题。例如,Azure 存储库默认会自动执行重试,并且只有在你禁用此行为或者在多次尝试后问题仍然存在时才会抛出异常。你通常不应该为这种类型的错误在已经处理了这一问题的库周围添加自己的异常处理和重试循环。

异步编程还增加了另一种变化。在第十六章和 17 章中,我将展示各种异步 API——其中工作可以在启动它的方法返回后继续进行。异步运行的工作也可能会异步失败,在这种情况下,库可能必须等到你的代码下次调用它之前才能报告错误。

尽管情况各异,在所有这些情况中,异常都将来自你的代码调用的某个 API。(即使异步操作失败,异常也会在你尝试收集操作结果时或显式询问是否发生错误时产生。)示例 8-1 展示了可能出现这类异常的一些代码。

示例 8-1. 从库调用中获取异常
static void Main(string[] args)
{
    using (var r = new StreamReader(@"C:\Temp\File.txt"))
    {
        while (!r.EndOfStream)
        {
            Console.WriteLine(r.ReadLine());
        }
    }
}

没有什么绝对错误的这段代码,所以我们不会得到任何关于参数明显错误的异常。(在非正式术语中,它不会犯愚蠢的错误。)如果你的电脑 C: 驱动器有一个 Temp 文件夹,并且其中包含一个 File.txt 文件,而且运行程序的用户有权限读取该文件,并且计算机上没有其他内容已经独占了该文件,并且没有问题——比如磁盘损坏——可能导致文件的任何部分不可访问,并且在程序运行时没有新问题(比如驱动器着火),这段代码就会完美地工作:它将显示文件中的每一行文本。但是这里有很多 如果

如果没有这样的文件,StreamReader 构造函数将无法完成。相反,它会抛出一个异常。这个程序没有尝试处理这种情况,所以应用程序会终止。如果你在 Visual Studio 的调试器外运行程序,你会看到以下输出:

Unhandled exception. System.IO.FileNotFoundException: Could not find file 'C:\Te
mp\File.txt'.
File name: 'C:\Temp\File.txt'
   at Microsoft.Win32.SafeHandles.SafeFileHandle.CreateFile(String fullPath, Fil
eMode mode, FileAccess access, FileShare share, FileOptions options)
   at Microsoft.Win32.SafeHandles.SafeFileHandle.Open(String fullPath, FileMode
mode, FileAccess access, FileShare share, FileOptions options, Int64 preallocati
onSize)
   at System.IO.Strategies.OSFileStreamStrategy..ctor(String path, FileMode mode
, FileAccess access, FileShare share, FileOptions options, Int64 preallocationSi
ze)
   at System.IO.Strategies.FileStreamHelpers.ChooseStrategyCore(String path, Fil
eMode mode, FileAccess access, FileShare share, FileOptions options, Int64 preal
ocationSize)
   at System.IO.Strategies.FileStreamHelpers.ChooseStrategy(FileStream fileStrea
m, String path, FileMode mode, FileAccess access, FileShare share, Int32 bufferS
ize, FileOptions options, Int64 preallocationSize)
   at System.IO.StreamReader.ValidateArgsAndOpenPath(String path, Encoding encod
ing, Int32 bufferSize)
   at System.IO.StreamReader..ctor(String path)
   at Exceptional.Program.Main(String[] args) in c:\Examples\Ch08\Example1\Progr
am.cs:line 10

这告诉我们发生了什么错误,并显示了程序在问题发生时的完整调用堆栈。在 Windows 上,系统级错误处理也会介入,所以根据计算机的配置,你可能会看到其错误报告对话框,甚至可能会向 Microsoft 的错误报告服务报告崩溃情况。如果你在调试器外运行相同的程序,它会告诉你有关异常,并突出显示发生错误的代码行,就像 图 8-1 所示。

Visual Studio 报告异常

图 8-1. Visual Studio 报告异常

我们在这里看到的是程序在不处理异常的情况下的默认行为:如果附加了调试器,它将中断,否则程序就会崩溃。不久我将展示如何处理异常,但这说明了你不能简单地忽略它们。

顺便说一下,在示例 8-1 中,对 StreamReader 构造函数的调用并不是唯一可能引发异常的行。代码多次调用 ReadLine,其中任何调用都可能失败。一般来说,任何成员访问都可能导致异常,甚至仅仅是读取属性,尽管类库设计者通常试图最小化属性引发异常的情况。如果犯了“那绝对行不通”的错误(愚蠢的错误),那么属性可能会引发异常,但通常不是“这个特定操作失败”的错误。例如,文档说明了在示例 8-1 中使用的 EndOfStream 属性,如果在对 StreamReader 对象调用 Dispose 后尝试读取它,将引发异常——这是一个明显的编码错误,但如果在读取文件时出现问题,StreamReader 仅会从方法或构造函数中抛出异常。

运行时检测到的失败

另一个异常源是当 CLR 自身检测到某些操作失败时。示例 8-2 展示了可能发生这种情况的方法。与示例 8-1 类似,这段代码本质上没有问题(除了不是很有用)。完全可以在不引起问题的情况下使用它。但是,如果有人将第二个参数传入 0,那么代码将尝试执行非法操作。

示例 8-2. 可能的运行时检测到的失败
static int Divide(int x, int y)
{
    return x / y;
}

CLR 将检测到此除法操作试图除以零并将引发 DivideByZeroException。这将与来自 API 调用的异常具有相同的效果:如果程序未尝试处理异常,则会崩溃,或者调试器将中断。

注意

在 C# 中,除零操作并非总是非法的。浮点类型支持表示正无穷大和负无穷大的特殊值,这是在将正数或负数除以零时得到的值;如果除以零,则得到特殊的非数值。整数类型不支持这些特殊值,因此整数除以零总是错误的。

我之前描述的异常的最终来源也是运行时检测到某些失败的地方,但它们的工作方式略有不同。它们不一定直接由线程上的代码触发。这些有时被称为异步异常,理论上可以在代码的任何地方抛出,这使得确保正确处理它们变得困难。然而,它们往往只在相当灾难性的情况下抛出,通常是在程序即将关闭时,因此通常无法有用地处理它们。例如,Sta⁠ckO⁠ver⁠flow​Exc⁠ept⁠ionOutOfMemoryException理论上可以在任何时候抛出(因为 CLR 可能需要为自己的目的分配内存,即使您的代码并未明确尝试这样做)。

我已经描述了异常被抛出的常见情况,您也看到了默认行为,但如果您希望程序执行与崩溃不同的操作怎么办?

处理异常

当抛出异常时,CLR 会寻找处理异常的代码。只有在整个调用堆栈上没有合适的处理程序时,默认的异常处理行为才会起作用。为了提供处理程序,我们使用 C#的trycatch关键字,正如示例 8-3 所示。

示例 8-3. 处理异常
try
{
    using (var r = new StreamReader(@"C:\Temp\File.txt"))
    {
        while (!r.EndOfStream)
        {
            Console.WriteLine(r.ReadLine());
        }
    }
}
catch (FileNotFoundException)
{
    Console.WriteLine("Couldn't find the file");
}

紧跟在try关键字之后的块通常称为try 块,如果程序在此类块内部抛出异常,CLR 会寻找匹配的catch 块。示例 8-3 只有一个单独的catch块,在catch关键字后的括号中,您可以看到此特定块旨在处理FileNotFoundException类型的异常。

如前所示,如果没有C:\Temp\File.txt文件,StreamReader构造函数会抛出FileNotFoundException。在示例 8-1 中,这导致我们的程序崩溃,但因为示例 8-3 中有一个catch块来处理该异常,CLR 将运行该catch块。此时,它会认为异常已经被处理,因此程序不会崩溃。我们的catch块可以自由地执行任何操作,在这种情况下,我的代码只是显示一个消息,指示找不到该文件。

异常处理程序不需要位于引发异常的方法中。CLR 会沿着调用堆栈向上查找,直到找到合适的处理程序。如果失败的StreamReader构造函数调用位于从示例 8-3 的try块内部调用的其他方法中,我们的catch块仍然会运行(除非该方法为相同异常提供了自己的处理程序)。

异常对象

异常是对象,其类型派生自Exception基类。¹ 这定义了提供异常信息的属性,一些派生类型还添加了特定于它们所代表问题的属性。如果需要了解出了什么问题,你的catch块可以获取异常的引用。示例 8-4 显示了来自示例 8-3 的catch块的修改。在catch关键字后的括号中,除了指定异常类型,我们还提供了一个标识符(x),用于catch块中的代码引用异常对象。这使得代码能够读取特定于FileNotFoundException类的属性:FileName

示例 8-4. 在catch块中使用异常
try
{
    // ...same code as Example 8-3...
}
catch (FileNotFoundException x)
{
    Console.WriteLine($"File '{x.FileName}' is missing");
}

这将显示找不到的文件的名称。通过这个简单的程序,我们已经知道我们试图打开哪个文件,但是在处理多个文件的更复杂程序中,这个属性可能会有所帮助。

基类Exception定义的通用成员包括Message属性,它返回包含问题文本描述的字符串。控制台应用程序的默认错误处理显示这个信息。当我们首次运行示例 8-1 时看到的文本Could not find file 'C:\Temp\File.txt'来自Message属性。在诊断意外异常时,这个属性非常重要。

警告

Message属性用于人类阅读,因此 API 可能会本地化这些消息。因此,试图通过检查Message属性来解释异常是一个不好的主意,因为当你的代码在配置为运行在语言与你不同的区域的计算机上运行时,这可能会失败。 (并且微软不将异常消息更改视为破坏性更改,因此即使在同一区域内,文本也可能会更改。)最好依赖实际的异常类型,尽管某些异常如IOException在模棱两可的情况下会被使用。因此,有时需要检查HResult属性,该属性将设置为操作系统中的错误代码。

Exception 还定义了一个 InnerException 属性。通常情况下这是 null,但当一个操作由于其他失败而失败时,它会变得有用。有时,在库的深层中发生的异常如果允许一直传播到调用者可能会让人感到困惑。例如,.NET 提供了一个用于解析 XAML 文件的库。(XAML——可扩展应用程序标记语言——被各种 .NET UI 框架使用,包括 WPF。)XAML 是可扩展的,因此您的代码(或者第三方代码)可能会作为加载 XAML 文件过程的一部分而运行,而这些扩展代码可能会失败——假设您的代码中存在错误导致在访问数组元素时抛出 IndexOutOfRangeException。如果这种异常从 XAML API 中出现,那将有些费解,因此无论失败的根本原因是什么,库都会抛出 XamlParseException。这意味着,如果您想要处理加载 XAML 文件失败的情况,您可以确切地知道要处理的异常,但失败的根本原因不会丢失:当其他异常导致失败时,它将在 InnerException 中。

所有异常都包含关于异常抛出位置的信息。StackTrace 属性提供了调用堆栈的字符串表示。正如您已经看到的,默认的控制台应用程序异常处理程序会显示这些信息。还有一个 TargetSite 属性,告诉您正在执行的方法。它返回反射 API 的 MethodBase 类的实例。详细信息请参见第十三章关于反射的部分。

多个 catch 块

try 块后面可以跟多个 catch 块。如果第一个 catch 不匹配抛出的异常,CLR 将查看下一个,依此类推。示例 8-5 提供了对 FileNotFoundExceptionDirectoryNotFoundExceptionIOException 的处理程序。

示例 8-5. 处理多个异常类型
try
{
    using (var r = new StreamReader(@"C:\Temp\File.txt"))
    {
        while (!r.EndOfStream)
        {
            Console.WriteLine(r.ReadLine());
        }
    }
}
catch (FileNotFoundException x)
{
    Console.WriteLine($"File '{x.FileName}' is missing");
}
catch (DirectoryNotFoundException)
{
    Console.WriteLine($"The containing directory does not exist.");
}
catch (IOException x)
{
    Console.WriteLine($"IO error: '{x.Message}'");
}

这个示例的一个有趣特性是 FileNotFoundExceptionDirectoryNotFoundException 都派生自 IOException。我可以移除前两个 catch 块,这仍然可以正确处理这些异常(只是显示的消息会更少具体),因为 CLR 会认为 catch 块匹配异常的基类型时也是有效的。因此,示例 8-5 为 FileNotFoundException 提供了两个可行的处理程序,并且为 DirectoryNotFoundException 也提供了两个可行的处理程序。(第三个处理程序仍然有用,因为文档告诉我们,对于某些类型的失败,StreamReader 将抛出 IOException,而不是更特定的类型。)在这些情况下,C# 要求更具体的处理程序首先出现。如果我将 IOException 处理程序移动到其他处理程序的上方,那么对于每个更具体的处理程序,编译器将会报错:

error CS0160: A previous catch clause already catches all exceptions of this or
of a super type ('IOException')

如果为Exception基类型编写catch块,它将捕获所有异常。在大多数情况下,这是不正确的做法。虽然处理你可以预期的异常是好的,但如果你不知道异常代表什么,通常应该让它继续传播。否则,你可能会掩盖问题。如果让异常继续传播,它更有可能被注意到,增加了在某个时刻正确修复问题的机会。如果你打算将所有异常都包装在另一个异常中并抛出,就像前面描述的XamlParseException一样,那么一个捕获所有异常的处理程序可能是适当的。如果在异常只能由系统提供的默认处理方式处理的地方,捕获所有异常并将细节写入日志文件或类似的诊断机制也许是合适的。即便如此,在记录日志之后,你可能仍然希望重新抛出异常,就像本章后面描述的那样,甚至终止具有非零退出代码的进程。

警告

对于非常重要的服务,你可能会考虑编写代码来吞噬异常,以便你的应用程序可以继续运行。这是一个坏主意。如果发生了你没有预料到的异常,你的应用程序内部状态可能不再可信,因为在故障发生时,你的代码可能已经进行到一半的操作。如果你不能承担应用程序离线的代价,最好的方法是安排它在故障后自动重启。例如,可以配置 Windows 服务自动执行此操作。

异常过滤器

你可以使catch块有条件地执行:如果为catch块提供了一个异常过滤器,那么只有在过滤器条件为真时才会捕获异常。 示例 8-6 展示了这样做的实用性。它使用了 Azure 表存储的客户端 API,这是 Microsoft Azure 云计算平台的一部分提供的 NoSQL 存储服务。该 API 的TableClient类有一个AddEntity方法,如果出现问题就会抛出RequestFailedException。问题是,“出现问题”非常广泛,涵盖了不仅仅是连接和身份验证失败。在某些乐观并发模型中,尝试插入具有相同键的另一行时,也会看到此异常。这不一定是错误,有时在正常使用中会出现。

示例 8-6. catch块的异常过滤器
public static bool InsertIfDoesNotExist(MyEntity item, TableClient table)
{
    try
    {
        table.AddEntity(item);
        return true;
    }
    catch (RequestFailedException x)
    `when` `(``x``.``Status` `=``=` `409``)`
    {
        return false;
    }
}

示例 8-6 查找特定的失败案例,并在异常继续向上传播之前返回false。它使用包含过滤器的when子句来实现这一点,该过滤器必须是bool类型的表达式。如果Execute方法抛出的StorageException不符合过滤条件,则异常将像没有catch块一样传播。

提示

在使用异常过滤器时,单个try块可以有多个针对同一异常的catch块。通常情况下,这会导致编译器错误,因为只有第一个这样的catch块会起作用,但是使用过滤器时,情况并非一定如此,因此编译器允许这样做。甚至可以为特定异常类型的一个未经过滤的catch块与同一类型的过滤catch块共存,但未经过滤的必须出现在最后。

异常过滤器必须是生成bool的表达式。如果需要,它可以调用外部方法。示例 8-6 只是获取一个属性并执行比较,但您可以自由地在表达式中调用任何方法。² 但是,应注意避免在过滤器中执行可能引发另一个异常的操作。如果发生这种情况,第二个异常将丢失。

嵌套的 try 块

如果在try块中发生异常,而没有提供适当的处理程序,则 CLR 将继续查找。必要时会沿着堆栈向上走,但是可以通过将一个try/catch嵌套在另一个try块中,在单个方法中嵌套多组处理程序,就像示例 8-7 所示。ShowFirstLineLength在另一个try/catch对的try块内部嵌套了一个try/catch对。也可以跨方法进行嵌套——Main方法将捕获从ShowFirstLineLength方法抛出的任何NullReferenceException(如果文件完全为空,则调用ReadLine将返回null)。

示例 8-7. 嵌套异常处理
static void Main(string[] args)
{
    try
    {
        ShowFirstLineLength(@"C:\Temp\File.txt");
    }
    catch (NullReferenceException)
    {
        Console.WriteLine("NullReferenceException");
    }
}

static void ShowFirstLineLength(string fileName)
{
    try
    {
        using (var r = new StreamReader(fileName))
        {
            try
            {
                Console.WriteLine(r.ReadLine()!.Length);
            }
            catch (IOException x)
            {
                Console.WriteLine("Error while reading file: {0}",
                    x.Message);
            }
        }
    }
    catch (FileNotFoundException x)
    {
        Console.WriteLine("Couldn't find the file '{0}'", x.FileName);
    }
}

我在这里嵌套了IOException处理程序,使其仅适用于工作的某个特定部分:它仅处理在成功打开文件后读取时发生的错误。有时,对此情况作出不同响应可能比对导致无法打开文件的错误响应更有用。

此处的跨方法处理有些刻意。可以通过测试ReadLine的返回值是否为null来避免NullReference​Excep⁠tion。然而,这里展示的 CLR 底层机制非常重要。特定的try块可以定义仅对其知道如何处理的那些异常的catch块,允许其他异常逃逸到更高级别。

让异常继续向上堆栈传播通常是正确的做法。除非你的方法能够在发现错误时采取一些有用的措施,否则它将需要告知其调用者存在问题,所以除非你想用另一种异常包装异常,否则你可以让异常自由传播。

注意

如果你熟悉 Java,你可能想知道 C#是否有等效于已检查异常的东西。它没有。方法不会正式声明它们可能抛出的异常,因此编译器无法告诉你是否未能处理它们或声明你的方法可能反过来抛出它们。

你也可以将一个try块嵌套在一个catch块内。如果你的错误处理程序本身可能失败,这一点很重要。例如,如果你的异常处理程序将有关磁盘故障的信息记录到磁盘上,那么如果磁盘出现问题,它可能会失败。

有些 try 块永远不会捕获任何东西。编写不紧跟着catch块的try块是非法的,但那个东西不必是一个catch块:它可以是一个finally 块

finally 块

一个finally块包含在其关联的try块完成后始终运行的代码。它无论是通过达到结尾、从中间返回或抛出异常离开try块,都会运行。即使你使用goto语句直接跳出块,finally块也会运行。示例 8-8 展示了finally块的使用。

示例 8-8. 一个finally
using Microsoft.Office.Interop.PowerPoint;

...

[STAThread]
static void Main(string[] args)
{
    var pptApp = new Application();
    Presentation pres = pptApp.Presentations.Open(args[0]);
    try
    {
        ProcessSlides(pres);
    }
    finally
    {
        pres.Close();
    }
}

这是我编写的用于处理 Microsoft Office PowerPoint 文件内容的实用程序的摘录。这只显示了最外层的代码;我省略了实际的详细处理代码,因为这里并不重要(尽管如果你好奇的话,本书的可下载示例的完整版本将动画幻灯片导出为视频剪辑)。我展示它是因为它使用了finally。这个示例使用 COM 互操作来控制 PowerPoint 应用程序。这个示例在完成后关闭文档,我将该代码放在finally块中的原因是,如果程序在中途出现问题,我不希望它留下未关闭的东西。这是因为 COM 自动化的工作方式。这不像打开文件,操作系统在进程终止时会自动关闭所有内容。如果程序突然退出,PowerPoint 不会关闭已经打开的任何东西,它只是假设你是要保留打开的。 (当创建用户将编辑的新文档时,你可能会故意这样做。)我不希望这样,将文件在finally块中关闭是避免这种情况的可靠方法。

通常,您会为此类事物编写using语句,但是 PowerPoint 的基于 COM 的自动化 API 不支持.NET 的IDisposable接口。实际上,正如我们在上一章中看到的那样,using语句在内部使用finally块工作,foreach也是如此,因此即使在编写using语句和foreach循环时,您也依赖于异常处理系统的finally机制。

注意

当异常块嵌套时,finally块会正确运行。如果某个方法抛出异常,由调用堆栈中较高级别的方法处理,而中间某些方法位于using语句、foreach循环或带有关联finally块的try块中,则所有这些中间finally块(无论是显式声明的还是编译器隐式生成的)都会在处理程序运行之前执行。

处理异常当然只是问题的一半。您的代码可能会检测到问题,并且异常可能是适当的报告机制。

抛出异常

抛出异常非常直接。只需构造适当类型的异常对象,然后使用throw关键字。当position参数超出合理范围时,示例 8-9 会这样做。

示例 8-9. 抛出异常
public static string GetCommaSeparatedEntry(string text, int position)
{
    string[] parts = text.Split(',');
    if (position < 0 || position >= parts.Length)
    {
        `throw` `new` `ArgumentOutOfRangeException``(``nameof``(``position``)``)``;`
    }
    return parts[position];
}

CLR 为我们完成了所有工作。它捕获了异常所需的信息,以便能够通过StackTraceTargetSite属性报告其位置。(它不计算它们的最终值,因为这些值相对昂贵。它只是确保它具有生成这些值所需的信息,以备查询。)然后它寻找合适的try/catch块,如果需要运行任何finally块,它将执行它们。

示例 8-9 展示了在抛出报告方法参数问题的异常时常用的一种技术。诸如ArgumentNull​Excep⁠tionArgumentOutOfRangeException及其基类ArgumentException等异常都可以报告有问题的参数的名称。(这是可选的,因为有时需要报告多个参数之间的不一致性,此时没有单个参数需要命名。)使用 C#的nameof运算符是个不错的主意。您可以将其与任何引用命名项的表达式一起使用,例如参数、变量、属性或方法。它编译为包含该项名称的字符串。

在这里,我本可以简单地使用字符串字面量"position",但nameof的优点在于它可以避免愚蠢的错误(如果我输入positon而不是position,编译器会告诉我找不到这样的符号),并且可以帮助避免由于重命名符号时引起的问题。如果我在示例 8-9 中重命名position参数,很容易忘记更改以匹配的字符串字面量。但是通过使用nameof(position),如果我更改参数名称为pos而没有同时更改nameof(position),编译器会报告找不到名为position的标识符。如果我请求一个了解 C# 的 IDE(例如 Visual Studio 或 JetBrains Rider)重命名参数,它将自动更新代码中使用该符号的所有地方,因此它将为我替换异常的构造函数参数为nameof(input)

我们可以使用类似的技术处理ArgumentNullException,但是.NET 6.0 添加了一个可以简化抛出此特定异常的帮助函数。正如示例 8-10 所示,与其编写一个测试输入的if语句,其主体抛出标识正确参数名称的异常,我们可以直接调用ArgumentNullException.ThrowIfNull

示例 8-10. 抛出ArgumentNullException
public static int CountCommas(string text)
{
    `ArgumentNullException``.``ThrowIfNull``(``text``)``;`
    return text.Count(ch => ch == ',');
}

此方法测试传递的任何参数,并在其为 null 时抛出ArgumentNullException。但是如何正确设置参数名称呢?此ThrowIfNull方法利用了新的 C# 10.0 功能:它带有CallerArgument​Ex⁠pression属性的注释。正如第十四章所述,此属性使ThrowIfNull助手能够发现调用方用作参数的表达式文本。由于我们将我们的text参数传递给此助手,它将传递一个额外的隐藏参数,字符串"text"。因此,这与使用其他参数异常的nameof具有相同的所有好处,但它还为我们执行相关的测试。

警告

许多异常类型提供了一个构造函数重载,允许您设置Message文本。更专业的消息可能会使问题更容易诊断,但要小心一件事。异常消息经常出现在诊断日志中,并且可能也会通过监控系统自动发送电子邮件。因此,请注意您在这些消息中放入的信息。如果您的软件将在有数据保护法的国家使用,这一点尤为重要——在异常消息中放入任何与特定用户有关的信息有时可能违反这些法律。

重新抛出异常

有时编写一个catch块以响应错误并允许该错误在完成工作后继续是有用的。这有一种明显但错误的方法,例如示例 8-11 中所示。

示例 8-11. 如何不重新抛出异常
try
{
    DoSomething();
}
catch (IOException x)
{
    LogIOError(x);
    // This next line is BAD!
    throw x;  // Do not do this
}

这将编译而不会出错,甚至看起来可以工作,但它有一个严重的问题:它丢失了最初引发异常的上下文。CLR 将其视为全新的异常(即使您正在重用异常对象),并将重置位置信息:StackTraceTargetSite将报告错误的源自于catch块内部。这可能会导致诊断问题变得困难,因为您将无法看到其最初抛出的位置。示例 8-12 展示了如何避免此问题。

示例 8-12. 不丢失上下文而重新抛出
try
{
    DoSomething();
}
catch (IOException x)
{
    LogIOError(x);
    `throw``;`
}

除了删除警告评论之外,这与示例 8-11 的唯一区别在于,我使用throw关键字而没有指定要用作异常的对象。您只能在catch块内执行此操作,并且它会重新抛出catch块正在处理的任何异常。这意味着报告异常原始抛出位置的Exception属性仍将指向原始的抛出位置,而不是重新抛出位置。

警告

在.NET Framework 上(即,如果您不使用.NET 或.NET Core),示例 8-12 并未完全解决此问题。虽然将异常抛出的点(在此示例中发生在DoSomething方法内部的某处)将被保留,但在堆栈跟踪中显示示例 8-12 方法达到的部分将不会。而是将指示它位于包含throw的行。这有点奇怪的效果是,堆栈跟踪看起来好像是DoSomething方法被throw关键字调用。.NET Core 3.1 及更高版本不会出现此问题。

处理异常时还需要注意另一个与上下文相关的问题,可能需要重新抛出异常,这与 CLR 向 Windows 错误报告(3)(WER)提供信息的方式有关。在 Windows 上应用程序崩溃时,WER 可能会显示崩溃对话框,其中可以提供包括重新启动应用程序、向 Microsoft 报告崩溃、调试应用程序或仅终止应用程序在内的选项。除此之外,当 Windows 应用程序崩溃时,WER 会捕获多个信息片段来确定崩溃位置。对于.NET 应用程序,这包括组件失败的名称、版本和时间戳,抛出的异常类型以及异常抛出位置的信息。这些信息有时被称为bucket值。如果应用程序以相同的值崩溃两次,这两次崩溃会进入同一个 bucket 中,这意味着它们在某种意义上被认为是相同的崩溃。

从 Windows 事件日志中检索这些信息对于在您控制的计算机上运行的代码来说是很好的(或者您可能更喜欢使用更直接的方法来监视此类应用程序,例如使用 Microsoft 的应用程序洞察来收集遥测数据,此时 WER 就不是很有趣了)。WER 变得更重要的地方是那些可能在您控制之外的其他计算机上运行的应用程序,例如完全本地运行的带有 UI 的应用程序或控制台应用程序。计算机可以配置为将崩溃报告上传到错误报告服务,通常只发送 bucket 值,尽管服务可以在最终用户同意的情况下请求额外的数据。在决定如何优先修复 bug 时,bucket 分析非常有用:从最大的 bucket 开始是有意义的,因为这是您的用户最常见的崩溃情况。(或者,至少,这是由于用户未禁用崩溃报告而最经常见到的情况。我总是在我的计算机上启用这个功能,因为我希望程序中遇到的 bug 能够尽快修复。)

注意

获取累积崩溃 bucket 数据的方法取决于您正在编写的应用程序类型。对于仅在您企业内部运行的业务应用程序,您可能希望运行自己的错误报告服务器,但如果应用程序在您的管理之外运行,则可以使用 Microsoft 自己的崩溃服务器。有一个基于证书的验证过程,用于验证您有权访问数据,但一旦您通过相关的程序,Microsoft 将显示所有应用程序的已报告崩溃,按 bucket 大小排序。

某些异常处理策略可能会破坏崩溃桶系统。如果编写通用的错误处理代码,涉及所有异常,有风险,WER 将认为您的应用程序只会在该通用处理程序内部崩溃,这意味着所有类型的崩溃将进入同一个桶中。这并非不可避免,但要避免这种情况,您需要了解您的异常处理代码如何影响 WER 崩溃桶数据。

如果一个异常在未被处理时到达堆栈的顶部,WER 将准确地了解崩溃发生的确切位置,但如果在最终允许它(或其他异常)继续上升堆栈之前捕获异常,可能会出现问题。有点令人惊讶的是,即使使用示例 8-11 中显示的错误方法,.NET 也会成功保留 WER 的位置(仅从应用程序内部的.NET 视角来看,这会丢失异常上下文——StackTrace将显示重新抛出位置。因此,WER 不一定报告与.NET 代码中异常对象中看到的相同的崩溃位置)。当您将异常包装为新异常的InnerException时,情况类似:.NET 将使用该内部异常的位置作为崩溃桶值的位置。

这意味着相对容易保留 WER 桶。丢失原始上下文的唯一方法是完全处理异常(即不崩溃),或者编写一个catch块,处理异常,然后抛出一个新异常而不将原异常作为InnerException传递。

尽管示例 8-12 保留了原始上下文,但这种方法有一个限制:只能在捕获异常的块内部重新抛出异常。随着异步编程越来越普遍,异常越来越可能在某个随机工作线程上发生。我们需要一种可靠的方法来捕获异常的完整上下文,并能在随后的任意时间点重新抛出异常,可能是从不同的线程。

ExceptionDispatchInfo 类解决了这些问题。如果从catch块中调用它的静态Capture方法,并传入当前异常,它会捕获完整的上下文,包括 WER 需要的信息。Capture方法返回一个ExceptionDispatchInfo的实例。当你准备重新抛出异常时,可以调用这个对象的Throw方法,CLR 将以原始上下文完全不变地重新抛出异常。与示例 8-12 中显示的机制不同,重新抛出时不需要在catch块内部。甚至不需要在最初引发异常的线程上。

注意

如果你使用了在第 17 章中描述的asyncawait关键字,它们为你使用ExceptionDispatchInfo来确保异常上下文被正确保存。

快速失败

有些情况需要采取激烈的行动。如果检测到你的应用程序处于无法挽回的腐败状态,抛出异常可能不足够,因为总是有可能有些东西会处理它,然后试图继续。这会冒着损坏持久状态的风险——也许无效的内存状态可能会导致你的程序将错误数据写入数据库。在造成任何持久性损坏之前,最好立即退出。

Environment类提供了一个FailFast方法。如果调用此方法,CLR 将终止你的应用程序。(如果你在 Windows 上运行,它还将向 Windows 事件日志写入消息,并向 WER 提供详细信息。)你可以传递一个字符串以包含在事件日志条目中,并且还可以传递一个异常,在这种情况下,在 Windows 上将写入异常的详细信息,包括异常抛出时的 WER 桶值。

异常类型

当你的代码检测到问题并抛出异常时,你需要选择抛出哪种类型的异常。你可以定义自己的异常类型,但运行时库定义了大量的异常类型,因此在许多情况下,你可以选择已有的类型。有数百种异常类型,因此在这里列出完整列表是不合适的;如果你想看到完整的集合,可以查看Exception类的在线文档列出的派生类型。然而,有一些异常类型是重要的需要了解的。

运行时库定义了一个ArgumentException类,它是几个异常的基类,用于指示方法使用了错误的参数。示例 8-9 使用了ArgumentOutOfRangeException,而示例 8-10 间接地抛出了ArgumentNullException。基类ArgumentException定义了一个ParamName属性,其中包含提供了错误参数的名称。这对于多参数方法很重要,因为调用方需要知道哪个参数出错了。所有这些异常类型都有构造函数,允许你指定参数名,你可以在示例 8-9 中看到其中之一的使用。基类ArgumentException是一个具体类,因此如果参数以未被派生类型覆盖的方式错误,你可以直接抛出基本异常,提供问题的文本描述。

除了刚才描述的通用类型外,一些 API 定义了更专门的派生参数异常。例如,System.Globalization命名空间定义了一个称为CultureNotFoundException的异常类型,它派生自ArgumentException。你也可以做类似的事情,而有两个理由你可能想这么做。如果你可以提供关于为什么参数无效的额外信息,你将需要一个自定义异常类型,以便将该信息附加到异常上(CultureNotFoundException提供了描述其搜索文化信息方面的三个属性)。或者,可能某种形式的参数错误可以被调用者特别处理。通常,参数异常仅表示编程错误,但在可能表示环境或配置问题的情况下(例如,未安装正确的语言包),开发人员可能希望以不同方式处理该特定问题。在这种情况下使用基本的ArgumentException将不会有帮助,因为很难区分他们想要处理的特定失败和参数的任何其他问题。

有些方法可能会执行可能会产生多个错误的工作。也许你正在运行某种批处理作业,如果批处理中的某些单个任务失败,你希望中止这些任务但继续执行其余任务,并在最后报告所有失败。对于这些场景,了解AggregateException是值得的。它扩展了基本ExceptionInnerException概念,添加了一个InnerExceptions属性,返回一个异常集合。

提示

如果嵌套了可能会产生AggregateException的工作(例如,在一个批处理中运行另一个批处理),那么你可能会得到一些内部异常也是AggregateException类型。这个异常提供了一个Flatten方法,它递归地遍历任何这样的嵌套异常,并生成一个扁平的异常列表。它返回一个AggregateException,其InnerExceptions是该列表。

另一个常用的类型是InvalidOperationException。如果某人试图在其当前状态下对对象进行不支持的操作,则会抛出此异常。例如,假设你编写了一个表示可以发送到服务器的请求的类。你可能会设计成每个实例只能使用一次,因此如果请求已经发送,尝试进一步修改请求将是一个错误,这时抛出此异常就是合适的。另一个重要的例子是,如果你的类型实现了IDisposable接口,并且在被释放后有人试图使用实例,那么有一个从InvalidOperationException派生的专门类型叫做ObjectDisposedException

您应该注意NotImplementedException与听起来相似但在语义上不同的NotSupportedException之间的区别。当接口要求时,后者应该被抛出。例如,IList<T>接口定义了修改集合的方法,但不要求集合可修改——相反,它表示只读集合应该从会修改集合的成员抛出NotSupportedExceptionIList<T>的实现可以抛出这个异常并被认为是完整的,而NotImplementedException意味着有东西缺失。您最常见到的是 IDE 生成的代码——如果您要求它们生成接口实现或提供事件处理程序,它们可以创建存根方法。它们生成这些代码以便您不必输入完整的方法声明,但仍然需要您实现方法的主体,因此生成的方法将抛出此异常,以免您意外地保留空方法。

在发布之前,您通常会希望删除所有抛出NotImplementedException的代码,并替换为适当的实现。然而,有一种情况可能需要抛出它。假设您编写了一个包含抽象基类的库,并且您的客户编写了从这个基类派生的类。当您发布库的新版本时,您可以向该基类添加新方法。现在想象一下,您想要为该库添加一个新的特性,似乎应该向基类添加一个新的抽象方法。这将是一个破坏性变更——成功从旧版本类派生的现有代码将不再工作。您可以通过提供虚方法而不是抽象方法来避免这个问题,但如果您无法提供有用的默认实现怎么办?在这种情况下,您可以编写一个基本实现来抛出NotImplementedException。构建于旧版本库的代码将不会尝试使用新功能,因此永远不会尝试调用该方法。但如果客户尝试在其类中使用新库功能而没有覆盖相关方法,则会收到此异常。换句话说,这提供了一种强制要求的方式:如果您想要使用它表示的功能,则必须覆盖此方法。(当向接口添加新成员并提供默认实现时,您可以使用相同的方法。)

当然,在框架中还有其他更专业的异常情况,你应该始终尝试找到与你想要报告的问题相匹配的异常。然而,有时您需要报告的错误是运行时库没有提供合适异常的情况。在这种情况下,您将需要编写自己的异常类。

自定义异常

自定义异常类型的最低要求是它应该从Exception(直接或间接)派生。但是,还有一些设计准则。首先要考虑的是直接基类:如果你查看内置的异常类型,你会注意到其中许多只间接地通过ApplicationExceptionSystemExceptionException派生。你应该避免使用这两者。它们最初是引入的,目的是区分应用程序产生的异常和.NET 产生的异常。然而,这种区分并没有证明是有用的。某些异常在不同的场景下可能由应用程序和系统抛出,而且通常情况下,编写一个捕获所有应用程序异常但不捕获所有系统异常的处理程序是没有用的,反之亦然。类库设计准则现在告诉你不要使用这两个基础类型。

自定义异常类通常直接从Exception派生,除非它们代表某种现有异常的专门形式。例如,我们已经看到ObjectDisposedExceptionInvalidOperationException的一个特例,运行时库定义了几个更专门的派生类,如用于网络代码的ProtocolViolationException。如果你希望你的代码报告的问题明显是某种现有异常类型的例子,但仍然有必要定义一个更专门的类型,那么你应该从该现有类型派生。

虽然Exception基类有一个无参数的构造函数,但通常不应该使用它。异常应该提供有用的错误文本描述,因此你自定义异常的构造函数应该调用一个接受字符串参数的Exception构造函数。你可以在派生类中硬编码消息字符串⁴,或定义一个接受消息的构造函数,并将其传递给基类;异常类型通常提供这两种方式,尽管如果你的代码只使用其中一个构造函数,这可能是一种浪费。这取决于你的异常是否可能被其他代码抛出,还是仅仅是你自己的代码。

通常也会提供一个接受另一个异常作为参数的构造函数,这将成为InnerException属性的值。再次强调,如果你编写的异常仅供自己的代码使用,那么在需要之前添加这个构造函数没有太多意义;但如果你的异常是可重复使用的库的一部分,这是一个常见的特性。示例 8-13 展示了一个假设性的示例,提供了各种构造函数以及由异常添加的属性使用的枚举类型。

示例 8-13. 自定义异常
public class DeviceNotReadyException : InvalidOperationException
{
    public DeviceNotReadyException(DeviceStatus status)
        : this("Device status must be Ready", status)
    {
    }

    public DeviceNotReadyException(string message, DeviceStatus status)
        : base(message)
    {
        Status = status;
    }

    public DeviceNotReadyException(string message, DeviceStatus status,
                                   Exception innerException)
        : base(message, innerException)
    {
        Status = status;
    }

    public DeviceStatus Status { get; }
}

public enum DeviceStatus
{
    Disconnected,
    Initializing,
    Failed,
    Ready
}

在这里选择自定义异常的理由是,这个特定错误除了告诉我们某些东西不处于适当状态外,还提供了关于对象在操作失败时刻状态的信息。

.NET Framework 设计指南曾建议异常应该是可序列化的。从历史上看,这是为了使它们能够在应用程序域之间传递。应用程序域是一个隔离的执行上下文;然而,它们现在已被弃用,因为它们只在.NET Framework 中受支持,而在.NET Core 或.NET 中则不支持。尽管如此,在一些应用程序类型中,异常序列化仍然是有趣的,特别是基于微服务的体系结构,例如在运行于Akka.NET或 Microsoft Service Fabric 上的体系结构,在这些体系结构中,单个应用程序跨多个进程运行,通常分布在许多不同的机器上。通过使异常可序列化,你使得异常能够跨进程边界传递——原始异常对象不能直接在边界上使用,但序列化使得可以在目标进程中构建异常的副本。

因此,尽管不再建议对所有异常类型进行序列化,但对于可能在这些多进程环境中使用的异常来说,它是有用的。出于这个原因,.NET Core 和 .NET 中的大多数异常类型继续支持序列化。如果你不需要支持这一点,你的异常就不必被设计为可序列化,但由于这种情况相当常见,我将描述你需要进行的更改。首先,你需要在类声明前添加[Serializable]属性。然后,你需要重写Exception定义的一个处理序列化的方法。最后,你必须提供一个特殊的构造函数,在反序列化你的类型时使用。示例 8-14 显示了你需要添加的成员,以使示例 8-13 中的自定义异常支持序列化。GetObjectData方法简单地将异常的Status属性的当前值存储在在序列化过程中提供的名称/值容器中。它在反序列化期间调用的构造函数中检索此值。

示例 8-14. 添加序列化支持
public override void GetObjectData(SerializationInfo info,
                                   StreamingContext context)
{
    base.GetObjectData(info, context);
    info.AddValue("Status", Status);
}

protected DeviceNotReadyException(SerializationInfo info,
                               StreamingContext context)
    : base(info, context)
{
    Status = (DeviceStatus) info.GetValue("Status", typeof(DeviceStatus))!;
}

未处理的异常

早些时候,你看到控制台应用程序在你的应用程序抛出它无法处理的异常时展示的默认行为。它显示异常的类型、消息和堆栈跟踪,然后终止进程。这无论异常是在主线程上未处理,还是你明确创建的线程上未处理,甚至是 CLR 为你创建的线程池线程上未处理,都会发生这种情况。

请注意,多年来未处理异常的行为已经发生了一些变化,这些变化仍然具有一定的相关性,因为您可以选择重新启用旧的行为。在 .NET 2.0 之前,CLR 为您创建的线程会吞噬异常而不报告它们或崩溃。您偶尔可能会遇到仍依赖此行为的旧应用程序:如果应用程序有一个包含 legacyUnhandledExceptionPolicy 元素和 enabled="1" 属性的 .NET Framework 风格配置文件,则旧的 .NET 1 行为将返回,意味着未处理的异常可能会悄然消失。在 .NET 4.5 中,某一功能朝相反方向移动了一步。如果您使用 Task 类(在 第十六章 中描述)来运行并发工作,而不是直接使用线程或线程池,任何任务内的未处理异常曾经会终止进程,但自 .NET 4.5 起,默认不再如此。您可以通过配置文件恢复到旧的行为。(详见 第十六章。)

CLR 提供了一种方法来发现当未处理异常到达堆栈顶部时的情况。AppDomain 类提供了一个 UnhandledException 事件,在任何线程上发生这种情况时 CLR 将引发此事件。⁵ 我将在 第九章 中描述事件,但稍微超前一点,示例 8-15 展示了如何处理此事件。它还抛出一个未处理的异常以测试处理程序。

示例 8-15. 未处理异常通知
static void Main(string[] args)
{
    AppDomain.CurrentDomain.UnhandledException += OnUnhandledException;

    // Crash deliberately to illustrate the UnhandledException event
    throw new InvalidOperationException();
}

private static void OnUnhandledException(object sender,
    UnhandledExceptionEventArgs e)
{
    Console.WriteLine($"An exception went unhandled: {e.ExceptionObject}");
}

当处理程序收到通知时,要阻止异常已经为时过晚——CLR 在调用处理程序后不久将终止进程。这个事件存在的主要原因是提供一个放置日志代码的地方,以便您可以记录一些有关故障的信息用于诊断目的。原则上,您还可以尝试存储任何未保存的数据,以便在程序重新启动时进行恢复,但您应当小心:如果您的未处理异常处理程序被调用,则您的程序处于可疑状态,因此保存的任何数据可能无效。

一些应用程序框架提供了它们自己的处理未处理异常的方法。例如,UI 框架(如 Windows Forms 或 WPF)为 Windows 桌面应用程序做到了这一点,部分原因是默认的写入控制台的行为对不显示控制台窗口的应用程序而言并不是很有用。这些应用程序需要运行一个消息循环来响应用户输入和系统消息。它检查每个消息并可能决定调用你代码中的一个或多个方法,在这种情况下,它会将每个调用包装在try块中,以便捕获你的代码可能抛出的任何异常。框架可能会在窗口中显示错误信息。Web 框架(如 ASP.NET Core)需要不同的机制:至少,它们应该生成一个响应,指示服务器端错误的方式符合 HTTP 规范的推荐方法。

这意味着,在你的代码中出现未处理异常并逃逸时,示例 8-15 使用的UnhandledException事件可能不会被触发,因为它可能被框架捕获了。如果你正在使用应用程序框架,应检查是否提供了处理未处理异常的机制。例如,ASP.NET Core 应用程序可以在应用程序启动期间提供一个名为Use​Ex⁠ceptionHandler的方法的回调。WPF 有其自己的Application类,其DispatcherUnhandledException事件是应用的一部分。同样,Windows Forms 提供了一个Application类和一个ThreadException成员。

即使在使用这些框架时,它们的未处理异常机制也仅处理框架控制的线程上发生的异常。如果你创建一个新线程并在其上抛出一个未处理异常,它将显示在AppDomain类的UnhandledException事件中,因为框架无法控制整个 CLR。

摘要

在.NET 中,错误通常通过异常报告,除了在某些预计失败是常见且异常成本可能高于正在处理的工作成本的情况下。异常允许将错误处理代码与执行工作的代码分开。它们还使得难以忽略错误——意外错误会向上传播并最终导致程序终止并生成错误报告。catch块允许我们处理那些我们可以预期的异常。(你也可以用它们来无差别地捕获所有异常,但那通常是一个坏主意——如果你不知道为什么发生了特定的异常,你无法确定如何安全地从中恢复。)finally块提供了一种无论代码成功执行还是遇到异常都可以安全执行清理的方法。运行时库定义了许多有用的异常类型,但如果必要,我们也可以编写自己的异常类型。

在迄今为止的章节中,我们已经看过代码、类和其他自定义类型、集合以及错误处理的基本元素。还有 C# 类型系统的最后一个特性需要注意:一种特殊的对象称为 委托

¹ 严格来说,CLR 允许任何类型作为异常。但是,C# 只能抛出派生自 Exception 的类型。有些语言允许抛出其他类型的异常,但这是强烈不推荐的。C# 可以处理任何类型的异常,尽管这是因为编译器自动在其生成的每个组件上设置了 RuntimeCompatibility 属性,请求 CLR 将不派生自 Exception 的异常包装在 RuntimeWrappedException 中。

² 异常过滤器不能使用 await 关键字,关于这一点可以在第十七章中找到讨论。

³ 有些人称 WER 为一个旧的 Windows 崩溃报告机制的名字:Dr. Watson。

⁴ 您还可以考虑使用 System.Resources 命名空间中的设施查找本地化字符串,而不是将其硬编码。运行时库中的异常都这样做了。这不是强制性的,因为并非所有程序在多个地区运行,即使对于那些运行的程序,异常消息也不一定会显示给最终用户。

⁵ 虽然 .NET Core 和 .NET 不支持创建新的应用程序域,但它们仍提供 AppDomain 类,因为它公开了某些重要的特性,例如此事件。它将通过 AppDomain.CurrentDomain 提供单一实例。

第九章:委托、Lambda 和事件

使用 API 的最常见方法是调用其类提供的方法和属性,但有时需要反向操作——API 可能需要调用您的代码,这种操作通常称为回调。在第五章中,我展示了数组和列表提供的搜索功能。为了使用这些功能,我编写了一个方法,在其参数满足我的条件时返回true,相关的 API 会为每个检查的项调用我的方法。并非所有的回调都是如此即时的。异步 API 在长时间运行的工作完成时可以调用我们代码中的方法。在客户端应用程序中,我希望我的代码在用户以特定方式与某些视觉元素交互时运行,例如点击按钮。

接口和虚方法可以实现回调。在第四章中,我展示了IComparer<T>接口,它定义了一个CompareTo方法。像Array.Sort这样的方法在我们需要定制排序顺序时会调用它。您可以想象一个 UI 框架,它定义了一个IClickHandler接口,具有一个Click方法,可能还有DoubleClick。如果我们希望被通知按钮点击,框架可以要求我们实现此接口。

实际上,没有.NET 的 UI 框架使用基于接口的方法,因为当你需要多种类型的回调时,这种方法变得很麻烦。单击和双击只是用户交互的冰山一角——在 WPF 应用程序中,每个 UI 元素可以提供超过 100 种通知方式。大多数时候,您只需要处理来自任何特定元素的一个或两个事件,所以一个有 100 个方法需要实现的接口会很烦人。

将通知分散到多个接口可能会减少这种不便。默认接口实现可能会有所帮助,因为它可以提供所有回调的默认空实现,这意味着我们只需要覆盖我们感兴趣的那些。(.NET Standard 2.0 和 .NET Framework 都不支持这种语言特性,但一个针对这些目标的库可以提供一个带有虚方法的基类。) 但即使有了这些改进,这种面向对象的方法仍然存在严重的缺点。想象一个 UI 有四个按钮。在一个使用我刚才描述的方法的假设 UI 框架中,如果你希望每个按钮都有自己的点击处理程序,你需要四个不同的IClickHandler接口的实现类。一个类只能实现特定接口一次,所以你需要编写四个类。当我们真正想要做的是告诉一个按钮在点击时调用特定的方法时,这似乎非常麻烦。

C#提供了一个更简单的解决方案,即 委托,它是对方法的引用。如果你希望库为任何原因调用你的代码,通常你只需传递一个委托引用到你想让它调用的方法。我在第五章中展示了一个例子,我在示例 9-1 中重现了它。这个例子找到了一个int[]数组中第一个大于零的元素的索引。

示例 9-1. 使用委托搜索数组
public static int GetIndexOfFirstNonEmptyBin(int[] bins) =>
    Array.FindIndex(bins, IsGreaterThanZero);

private static bool IsGreaterThanZero(int value) => value > 0;

乍一看,这似乎非常简单:Array.FindIndex的第二个参数需要一个方法,它可以调用以询问特定元素是否匹配,因此我传递了我的IsGreaterThanZero方法作为参数。但是传递方法真正意味着什么,以及它如何与.NET 的类型系统,CTS 结合在一起?

委托类型

示例 9-2 显示了在示例 9-1 中使用的FindIndex方法的声明。第一个参数是要搜索的数组,但我们感兴趣的是第二个参数,那就是我传递了一个方法。

示例 9-2. 带有委托参数的方法
public static int FindIndex<T>(
      T[] array,
      `Predicate``<``T``>` `match`)

方法的第二个参数的类型是Predicate<T>,其中T是数组元素的类型,因为示例 9-1 使用了int[],所以这将是一个Predicate<int>。(如果你对形式逻辑或计算机科学没有背景的话,这种类型使用 predicate 这个词表示一个函数,用来确定某件事是真还是假。例如,你可以有一个判断一个数是否为偶数的 predicate。这些谓词经常在这种过滤操作中使用。)示例 9-3 展示了如何定义这种类型。这是整个定义,不是摘录;如果你想写一个等同于Predicate<T>的类型,那么你只需要写这些。

示例 9-3. Predicate<T>委托类型
public delegate bool Predicate<in T>(T obj);

分解示例 9-3,我们像往常一样从可访问性开始,我们可以使用所有其他类型的关键字,例如publicinternal。(像任何类型一样,委托类型可以选择地嵌套在其他类型中,在这种情况下,你也可以使用privateprotected。)接下来是delegate关键字,告诉 C#编译器我们正在定义一个委托类型。定义的其余部分看起来不偶然,就像一个方法声明。我们有一个bool的返回类型。你把委托类型的名称放在你通常看到方法名称的地方。尖括号表示这是一个具有单个类型参数T的泛型类型,并且in关键字指示T是逆变的。最后,方法签名有一个该类型的单个参数。

提示

在这里使用逆变性使您能够使用比通常所需更一般的谓词。例如,因为所有string类型的值都与object类型兼容,所以所有Predicate<object>类型的值也与Predicate<string>类型兼容。或者简单地说,如果一个 API 需要检查一个string的方法,那么如果您传递一个能够检查任何object的方法,它也能完美运行。第六章详细描述了逆变性。

委托类型在.NET 中是特殊的,并且它们的工作方式与类或结构完全不同。编译器生成一个表面上看起来正常的类型定义,其中包含各种我们稍后将详细讨论的成员,但是所有这些成员都是空的——C#不会为任何这些成员生成 IL。CLR 在运行时提供实现。

委托类型的实例通常称为委托,并且它们引用方法。如果方法的签名匹配,那么该方法与(即可由特定委托类型的实例引用)特定委托类型兼容。示例 9-1 中的IsGreaterThanZero方法接受一个int并返回一个bool,因此它与Predicate<int>兼容。匹配不必精确。如果参数类型可以进行隐式引用转换,则可以使用更一般的方法。(尽管这听起来与T逆变性的要点非常相似,但这是一个微妙不同的问题。在Predicate<T>中,T的逆变性确定了现有的Predicate<T>实例可以被转换成哪些类型。这与您是否可以从特定方法构造某个特定类型的新委托的规则是分开的:我现在描述的签名匹配规则即适用于非泛型委托,也适用于具有不变类型参数的泛型委托。)例如,一个返回类型为bool,单个参数类型为object的方法将与Predicate<object>兼容,但因为这样的方法可以接受string参数,它也将与Predicate<string>兼容。(它不会与Predicate<int>兼容,因为从intobject没有隐式引用转换。有一个隐式转换,但这是一个装箱转换,而不是引用转换。)

创建委托

创建委托的最简单方法是仅编写方法名称。示例 9-4 声明了一个变量p,并使用示例 9-1 中的IsGreaterThanZero方法对其进行初始化。(此代码要求IsGreaterThanZero在作用域内,因此我们只能在同一个类中编写此代码。)

示例 9-4. 创建委托
var p = IsGreaterThanZero;

这个示例没有提到特定需要的委托类型,这导致编译器从我将在本章后面描述的几组泛型类型中选择一个。¹ 在您无法使用这些类型的罕见情况下,它会为您定义一个类型。在这种情况下,它将使用Func<int, bool>,反映了IsGreaterThanZero是一个接受int并返回bool的方法。这是一个合理的选择,但如果我想使用Predicate<int>类型,因为我打算将其传递给Array.FindIndex,如示例 9-1 中所示,如果您不想使用编译器的默认选择,可以使用new关键字,正如示例 9-5 所示。这允许您声明类型,并在通常传递构造函数参数的地方,您可以提供兼容方法的名称。

示例 9-5. 构造委托
var p = new Predicate<int>(IsGreaterThanZero);

在实际应用中,我们很少对委托使用new关键字。只有在编译器无法推断出正确的委托类型时才是必需的。通常情况下,编译器可以从上下文中推断出正确的类型。示例 9-6 声明了一个带有显式类型的变量,因此编译器知道需要一个Predicate<int>类型 —— 我们不需要在这里使用new关键字。这将编译成与示例 9-5 相同的代码。

示例 9-6. 隐式委托构造
Predicate<int> p = IsGreaterThanZero;

这仍然明确提到了委托类型的名称,但通常我们甚至不需要这样做。示例 9-1 正确确定IsGreaterThanZero需要转换为Predicate<int>,而无需我们明确说明。编译器知道FindIndex的第二个参数是Predicate<T>,并且因为我们提供了类型为int[]的第一个参数,它推断出Tint,因此知道第二个参数的完整类型是Predicate<int>。在解决了这个问题后,它使用相同的内置隐式转换规则来构造委托,就像示例 9-6 一样。因此,当您将委托传递给方法时,编译器通常会自动确定正确的类型。

当代码像这样按名称引用方法时,该名称在技术上称为方法组,因为一个名称可能存在多个重载。编译器通过查找最佳匹配来缩小范围,类似于调用方法时如何选择重载。与方法调用一样,可能不存在匹配项或存在多个同样好的匹配项,在这些情况下,编译器会产生错误。

方法组可以采用几种形式。在迄今为止的示例中,我使用了未限定的方法名,这仅在所讨论方法在范围内时有效。如果要引用另一个类中定义的静态方法,则需要使用类名限定它,正如 Example 9-7 所示。

Example 9-7. 委托引用另一个类中的方法
internal class Program
{
    static void Main(string[] args)
    {
        `Predicate``<``int``>` `p1` `=` `Tests``.``IsGreaterThanZero``;`
        `Predicate``<``int``>` `p2` `=` `Tests``.``IsLessThanZero``;`
    }
}

internal class Tests
{
    public static bool IsGreaterThanZero(int value) => value > 0;

    public static bool IsLessThanZero(int value) => value < 0;
}

委托不必引用静态方法。它们可以引用实例方法。有几种方法可以实现这一点。一种方法是简单地从处于该方法范围内的上下文中按名称引用实例方法。Example 9-8 中的GetIsGreaterThanPredicate方法返回引用IsGreaterThan的委托。两者都是实例方法,因此只能与对象引用一起使用,但GetIsGreaterThanPredicate具有隐式的this引用,并且编译器会自动将其提供给隐式创建的委托。

Example 9-8. 隐式实例委托
public class ThresholdComparer
{
    public int Threshold { get; set; }

    public bool IsGreaterThan(int value) => value > Threshold;

    public Predicate<int> GetIsGreaterThanPredicate() => IsGreaterThan;
}

或者,您可以明确指定您想要的实例。Example 9-9 从 Example 9-8 创建了ThresholdComparer类的三个实例,然后为IsGreaterThan方法创建了三个委托,每个实例一个。

Example 9-9. 显式实例委托
var zeroThreshold = new ThresholdComparer { Threshold = 0 };
var tenThreshold = new ThresholdComparer { Threshold = 10 };
var hundredThreshold = new ThresholdComparer { Threshold = 100 };

Predicate<int> greaterThanZero = zeroThreshold.IsGreaterThan;
Predicate<int> greaterThanTen = tenThreshold.IsGreaterThan;
Predicate<int> greaterThanOneHundred = hundredThreshold.IsGreaterThan;

您不必局限于形式为*variableName*.*MethodName*的简单表达式。您可以取任何评估为对象引用的表达式,然后只需附加.*MethodName*;如果对象具有一个或多个名为*MethodName*的方法,则将其视为有效的方法组。

到目前为止,我只展示了单参数委托,但您可以定义带有任意数量参数的委托类型。例如,运行时库定义了Comparison<T>,它比较两个项目,因此需要两个参数(均为类型T)。

C#不允许您创建引用实例方法的委托,而不指定您想要的实例(隐式或显式),并且它将始终使用该实例初始化委托。

当您将委托传递给其他代码时,该代码无需知道委托的目标是静态方法还是实例方法。对于实例方法,使用委托的代码不会提供实例。引用实例方法的委托始终知道它们引用的实例以及方法。

有另一种创建委托的方式,如果你在运行时并不一定知道要使用哪个方法或对象,这种方式可能会很有用:你可以使用反射 API(我将在第十三章中详细解释)。首先,你获取一个MethodInfo,这是表示特定方法的对象。然后调用它的CreateDelegate方法,传递委托类型和必要时的目标对象。(如果你要创建一个引用静态方法的委托,就没有目标对象,因此有一个只接受委托类型的重载。)这将创建一个引用MethodInfo实例所标识的任何方法的委托。示例 9-10 使用了这种技术。它获取一个Type对象(也是反射 API 的一部分;它是引用特定类型的一种方式),表示ThresholdComparer类。接下来,它要求该对象获取表示IsGreaterThan方法的MethodInfo。然后调用它上面的Create​Dele⁠gate重载,传递委托类型和目标实例。

示例 9-10. CreateDelegate
MethodInfo m = typeof(ThresholdComparer).GetMethod("IsGreaterThan")!;
var greaterThanZero = (Predicate<int>) m.CreateDelegate(
    typeof(Predicate<int>), zeroThreshold);

还有另一种执行相同工作的方式:Delegate类型有一个静态的CreateDelegate方法,它避免了获取MethodInfo的需要。你传递两个Type对象——委托类型和定义目标方法的类型——还有方法名。如果你已经有了MethodInfo,那么最好直接使用它,但如果只有方法名,这种替代方式更加方便。

总结到目前为止,委托标识特定的函数,如果这是一个实例函数,委托还包含一个对象引用。但有些委托可以做更多的事情。

多播委托

如果你用像 ILDASM 这样的反向工程工具查看任何委托类型,² 你会看到无论是运行库提供的类型还是你自己定义的类型,它们都派生自一个称为MulticastDelegate的基类型。顾名思义,这意味着委托可以引用多个方法。这主要在通知场景中很有用,当某个事件发生时可能需要调用多个方法。然而,所有委托都支持这一点,无论你是否需要。

即使具有非void返回类型的委托也派生自MulticastDelegate。这通常没有太多意义。例如,需要Predicate<T>的代码通常会检查返回值。Array.FindIndex 使用它来判断元素是否符合搜索条件。如果单个委托引用多个方法,FindIndex应该如何处理多个返回值?事实上,它将执行所有方法,但只会返回最后一个方法的返回值。(可以编写代码为多播委托提供特殊处理,但FindIndex并未如此。)

多播功能可通过 Delegate 类的静态 Combine 方法使用。它接受任何两个委托并返回单个委托。当调用结果委托时,就像您依次调用两个原始委托一样。即使您传递给 Combine 的委托已经引用多个方法,也可以将其链接在一起形成越来越大的多播委托。如果两个参数中都引用了相同的方法,则生成的组合委托将调用它两次。

注意

委托的组合总是产生一个新的委托。而 Combine 方法不会修改您传递的任何一个委托。

实际上,我们很少显式调用 Delegate.Combine,因为 C# 内置支持组合委托。您可以使用 ++= 运算符。示例 9-11 展示了将 示例 9-9 中的三个委托组合成一个多播委托的两种方式。两个结果委托是等效的——这只是展示了两种编写相同内容的方式。这两种情况都编译成对 Delegate.Combine 的几次调用。

示例 9-11. 组合委托
Predicate<int> megaPredicate1 =
    greaterThanZero + greaterThanTen + greaterThanOneHundred;

Predicate<int> megaPredicate2 = greaterThanZero;
megaPredicate2 += greaterThanTen;
megaPredicate2 += greaterThanOneHundred;

您还可以使用 --= 运算符,这将产生一个新的委托,它是第一个操作数的副本,但是其对第二个操作数引用的方法的最后引用已被移除。正如您可能猜到的那样,这将转换为对 Delegate.Remove 的调用。

调用委托

到目前为止,我展示了如何创建一个委托,但是如果您正在编写需要调用来自调用者提供的方法的自己的 API 呢?首先,您需要选择一个委托类型。您可以使用运行时库提供的一个,或者必要时,您可以定义自己的委托类型。然后,您可以将这个委托类型用作方法参数或属性。示例 9-12 展示了当您想要调用委托引用的方法(或方法)时该怎么做。

示例 9-12. 调用委托
public static void CallMeRightBack(Predicate<int> userCallback)
{
    `bool` `result` `=` `userCallback``(``42``)``;`
    Console.WriteLine(result);
}

正如这个并不十分现实的示例所示,您可以像使用函数一样使用委托类型的参数。这也适用于局部变量、字段和属性。事实上,任何产生委托的表达式后面都可以跟随括号中的参数列表。编译器将生成调用委托的代码。如果委托具有非 void 返回类型,则调用表达式的值将是底层方法返回的值(或者,在委托引用多个方法的情况下,将是最终方法返回的值)。

尽管委托是具有运行时生成代码的特殊类型,但调用它们并没有什么神奇之处。调用单目标方法的委托的效果就好像你的代码以传统方式调用目标方法一样。调用多播委托就像依次调用其每个目标方法一样。无论哪种情况,调用都发生在同一个线程上,并且异常以与直接调用方法时完全相同的方式传播出来。

如果你想从多播委托中获取所有返回值,可以控制调用过程。委托提供了GetInvocationList方法,该方法返回一个数组,数组中包含每个原始多播委托所引用的单方法委托。如果在普通的、非多播委托上调用此方法,则该列表将只包含一个委托;但如果正在利用多播特性,则可以循环遍历数组,依次调用每个委托。

还有一种偶尔有用的调用委托的方法。基类Delegate提供了DynamicInvoke方法。你可以在任何类型的委托上调用它,而无需在编译时精确知道需要哪些参数。它接受一个object[]类型的params数组,因此你可以传递任意数量的参数。它将在运行时验证参数的数量和类型。这可以实现某些后期绑定的场景。通过dynamic关键字(在第二章中讨论)启用的内在语言特性更为全面,但由于额外的灵活性,稍微更为复杂,所以如果DynamicInvoke正好符合你的需求,那么它是更好的选择。

常见委托类型

运行时库提供了几种有用的委托类型,通常情况下你可以使用这些类型而不需要定义自己的委托。例如,有一组名为Action的泛型委托,其类型参数数量各不相同。所有这些委托都遵循一个共同的模式:对于每个类型参数,都有一个相应类型的方法参数。示例 9-13 展示了前四个委托,包括零参数形式。

示例 9-13. 前几个Action委托
public delegate void Action();
public delegate void Action<in T1>(T1 arg1);
public delegate void Action<in T1, in T2 >(T1 arg1, T2 arg2);
public delegate void Action<in T1, in T2, in T3>(T1 arg1, T2 arg2, T3 arg3);

尽管这显然是一个开放性的概念——你可以想象具有任意数量参数的这种形式的委托——但 CTS 不提供一种将此类类型定义为模式的方法,因此运行时库必须将每种形式定义为单独的类型。因此,没有Action的 200 参数形式。最大的形式有 16 个参数。

Action的明显限制是这些类型都有void返回类型,因此无法引用返回值的方法。但是有一类类似的委托类型,Func,它允许任何返回类型。示例 9-14 展示了这个家族中的前几个委托,正如你所见,它们与Action非常相似。它们只是多了一个额外的最终类型参数,TResult,用于指定返回类型。与Action<T>类似,这些委托可以有多达 16 个参数。

示例 9-14. 前几个Func委托
public delegate TResult Func<out TResult>();
public delegate TResult Func<in T1, out TResult>(T1 arg1);
public delegate TResult Func<in T1, in T2, out TResult>(T1 arg1, T2 arg2);
public delegate TResult Func<in T1, in T2, in T3, out TResult>(
    T1 arg1, T2 arg2, T3 arg3);

这些ActionFunc类型是 C#在可能的情况下用作委托表达式的自然类型。你之前在示例 9-4 中看到过这一点,在没有其他指定时,编译器选择了Func<int, bool>。它将使用Action家族来处理返回类型为void的方法。

这两类委托看起来已经涵盖了大多数需求。除非你在编写超过 16 个参数的大型方法,否则你几乎不会需要其他东西。然而,有些情况无法用泛型类型参数来表达。例如,如果你需要一个可以使用refinout参数的委托,你不能简单地写Func<bool, string, out int>。这是因为在.NET 中并没有out int这样的类型。out关键字确切地说明了参数如何传递给方法。泛型类型参数只能指定类型,无法完全传达inoutref参数之间的区别。³ 因此,在这些情况下,你必须编写一个匹配的委托类型。

定义自定义委托类型的另一个原因是你不能将ref struct作为泛型类型参数使用。(第十八章讨论了这些类型。) 因此,如果你尝试使用ref struct类型Span<int>实例化泛型Action<T>类型,例如写Action<Span<int>>,你将会得到一个编译器错误。这种限制存在是因为ref struct类型只能在某些情况下使用(它们必须始终存在于堆栈上),而无法确定任何特定的泛型类型或方法是否仅在允许的方式中使用其类型参数。(你可以想象一种新的类型参数约束来表达这一点,但在撰写本文时,还没有这样的约束存在。)因此,如果你需要一个能够引用接受ref struct参数的方法的委托类型,它必须是一个专用的非泛型委托。

注意

如果你依赖编译器确定委托表达式的自然类型(例如,你写了var m = SomeMethod;),那么这些不能使用FuncAction委托的情况就是编译器为你生成委托类型的情况。

这些限制中没有一个可以解释为什么运行库定义了一个单独的 Predicate<T> 委托类型。Func<T, bool> 完全可以很好地工作。有时这种专门的委托类型的存在是历史的偶然:许多委托类型早在添加这些通用的 ActionFunc 类型之前就存在了。但这并不是唯一的原因——即使现在也在不断添加新的委托类型。主要原因是有时定义一个专门的委托类型以指示特定语义是很有用的。

如果你有一个 Func<T, bool>,你只知道有一个接受 T 并返回 bool 的方法。但是对于 Predicate<T>,有一个暗示的含义:它对该 T 实例做出决策,并相应地返回 truefalse;并非所有接受单个参数并返回 bool 的方法都适合这种模式。通过提供 Predicate<T>,你不仅仅是说你有一个具有特定签名的方法;你在说你有一个服务于特定目的的方法。例如,HashSet<T>(在 第五章 中描述)有一个 Add 方法,接受单个参数并返回 bool,因此与 Predicate<T> 的签名匹配,但不符合语义。Add 的主要工作是执行带有副作用的操作,并返回执行信息,而断言只是告诉你关于值或对象的一些信息。

运行库定义了许多委托类型,其中大多数比 Predicate<T> 更专门化。例如,System.IO 命名空间及其派生类定义了几个与特定事件相关的委托类型,例如 SerialPinChangedEventHandler,仅在处理老式串行端口(如一度无处不在的 RS232 接口)时使用。

类型兼容性

委托类型之间不会相互派生。在 C# 中定义的任何委托类型都会直接派生自MulticastDelegate,就像运行库中的所有委托类型一样。然而,类型系统通过协变和逆变支持某些泛型委托类型的隐式引用转换。这些规则与接口的规则非常相似。正如示例 9-3 中的 in 关键字所示,Predicate<T> 中的类型参数 T 是逆变的,这意味着如果两个类型 AB 之间存在隐式引用转换,那么类型 Predicate<B>Predicate<A> 之间也存在隐式引用转换。示例 9-15 展示了由此启用的隐式转换。

示例 9-15. 委托协变性
public static bool IsLongString(object o)
{
    return o is string s && s.Length > 20;
}

static void Main(string[] args)
{
    Predicate<object> po = IsLongString;
    `Predicate``<``string``>` `ps` `=` `po``;`
    Console.WriteLine(ps("Too short"));
}

Main 方法首先创建一个引用 IsLongString 方法的 Predicate<object>。该谓词类型的任何目标方法都能检查任何类型的 object,因此,显然它能够满足需要检查字符串的代码的需求,因此隐式转换为 Predicate<string> 应该成功 —— 这得益于逆变性。协变也与接口的工作方式相同,因此通常与委托的返回类型相关联。我们使用 out 关键字表示协变类型参数。所有内置的 Func 委托类型都具有协变类型参数 TResult,表示函数的返回类型。函数参数的类型参数都是逆变的,所有 Action 委托类型的类型参数也是如此。

注意

基于变异的委托转换是隐式引用转换。这意味着当你转换引用时,结果仍然指向同一个委托实例。(所有隐式引用转换都具有这个特性,但并非所有隐式转换都是这样工作的。隐式数值转换会创建目标类型的新实例;隐式装箱转换会在堆上创建一个新的装箱。)因此,在 Example 9-15 中,pops 引用堆上的同一个委托。这与将 IsLongString 分配给两个变量的方式略有不同 —— 那会创建两个不同类型的委托。

你可能也期望看起来相同的委托是兼容的。例如,Predicate<int> 可以引用任何 Func<int, bool> 可以使用的方法,反之亦然,因此你可能期望这两种类型之间存在隐式转换。你可能会受到 C# 规范中“委托兼容性”部分的鼓励,该部分指出具有相同参数列表和返回类型的委托是兼容的(事实上,它进一步指出允许某些差异,例如,我之前提到的参数类型可能不同,只要有特定的隐式引用转换可用)。然而,如果你尝试在 Example 9-16 中的代码,它不会工作。

Example 9-16. 非法委托转换
Predicate<string> pred = IsLongString;
Func<string, bool> f = pred;  // Will fail with compiler error

添加显式强制转换也不行 —— 它会移除编译器错误,但你只会得到一个运行时错误。CTS 认为这些是不兼容的类型,因此使用一个委托类型声明的变量不能持有指向不同委托类型的引用,即使它们的方法签名是兼容的(除非涉及到两个委托类型基于相同泛型委托类型并且由于协变或逆变而兼容)。这不是 C# 委托兼容性规则设计的情况 —— 它们主要用于确定特定方法是否可以作为特定委托类型的目标。

“兼容”委托类型之间的类型不兼容可能看起来有些奇怪,但结构上相同的委托类型不一定具有相同的语义,正如我们在Predicate<T>Func<T,bool>中已经看到的。如果你发现自己需要执行这种类型的转换,这可能表明你的代码设计有些问题。⁴

语法背后

尽管只需一行代码即可定义委托类型(正如示例 9-3 所示),但编译器将其转换为定义了三个方法和一个构造函数的类型。当然,该类型还继承自其基类的成员。所有委托都派生自MulticastDelegate,尽管所有有趣的实例成员都来自其基类Delegate。(Delegate继承自object,因此委托也都具有普遍存在的object方法。)甚至GetInvocationList,一个明显面向多播的特性,也是由Delegate基类定义的。

注意

DelegateMulticastDelegate之间的分割是历史意外的毫无意义和任意结果。最初的计划是支持多播和单播委托,但在.NET 1.0 的预发布期末期间放弃了这种区分,现在所有委托类型都支持多播实例。这件事情发生得相当晚,以至于微软认为将两个基类合并为一个太过冒险,因此尽管没有任何实际目的,这种分割仍然存在。

我已经描述了Delegate定义的一些公共实例成员:DynamicInvokeGetInvocationList方法。还有两个:Method属性返回表示目标方法的MethodInfo。(第十三章描述了MethodInfo类型。)Target属性返回将作为目标方法的隐式this参数传递的对象;如果委托引用静态方法,则Target将返回null。示例 9-17 展示了委托类型的编译器生成构造函数和方法的签名。具体细节因类型而异;这些是Predicate<T>类型的生成成员。

示例 9-17. 委托类型的成员
public Predicate(object target, IntPtr method);

public bool Invoke(T obj);

public IAsyncResult BeginInvoke(T obj, AsyncCallback callback, object state);
public bool EndInvoke(IAsyncResult result);

你定义的任何委托类型都会有四个相似的成员。编译后,它们都还没有实现体。编译器只生成它们的声明,因为 CLR 会在运行时提供它们的实现。

构造函数接受目标对象(对于静态方法为null)和标识方法的IntPtr。⁵ 请注意,这不是由Method属性返回的MethodInfo。相反,这是一个函数标记,用于表示目标方法的不透明二进制标识符。CLR 可以为所有成员和类型提供二进制元数据标记,但在 C#中没有用于处理它们的语法,因此我们通常看不到它们。当你构造委托类型的新实例时,编译器会自动生成检索函数标记的 IL。委托在内部使用标记的原因是,它们比使用反射 API 类型如MethodInfo更高效。

Invoke方法是调用委托的目标方法(或方法)的方法。你可以从 C#显式地使用它,就像示例 9-18 展示的那样。它几乎与示例 9-12 完全相同,唯一的区别是委托变量后面跟着.Invoke。这生成的代码与示例 9-12 完全相同,所以是使用Invoke还是像将委托标识符视为方法名使用的语法风格问题。作为一名以前的 C++开发者,我一直觉得示例 9-12 的语法很熟悉,因为它类似于在那种语言中使用函数指针,但有人认为显式写出Invoke可以更容易地看出代码正在使用委托。

示例 9-18. 显式使用Invoke
public static void CallMeRightBack(Predicate<int> userCallback)
{
    bool result = userCallback.Invoke(42);
    Console.WriteLine(result);
}

这种显式形式的一个好处是,你可以使用空值条件运算符来处理委托变量为null的情况。示例 9-19 使用这种方法仅在提供非空参数时尝试调用。

示例 9-19. 使用空值条件运算符调用Invoke
public static void CallMeMaybe(Action<int>? userCallback)
{
    userCallback?.Invoke(42);
}

Invoke方法是委托类型方法签名的所在地。当你定义委托类型时,这是你指定的返回类型和参数列表的地方。当编译器需要检查一个特定方法是否与委托类型兼容时(例如,当你创建该类型的新委托时),编译器将Invoke方法与你提供的方法进行比较。

如 示例 9-17 所示,所有委托类型都有BeginInvokeEndInvoke方法。这些方法曾经提供了一种使用线程池的方式,但它们已被弃用,并且在当前版本的.NET 上不起作用(如果调用任一方法将导致PlatformNotSupportedException)。它们仍然在.NET Framework 上工作,但已经过时。您应该忽略这些过时的方法,而是使用 第十六章 中描述的技术。这些方法曾经流行的主要原因是它们提供了一种从一个线程传递一组值到另一个线程的简单方法 - 您可以将您需要的任何东西作为委托的参数传递。但是,C#现在有了解决这个问题的更好方式:匿名函数。

匿名函数

C#允许您创建委托而无需显式定义单独的方法。您可以编写一个特殊类型的表达式,其值为一个方法。您可以将它们视为方法表达式函数表达式,但官方名称是匿名函数。表达式可以直接作为参数传递或直接分配给变量,因此这些表达式产生的方法没有名称。 (至少在 C#中是这样。运行时要求所有方法都有名称,因此 C#为这些东西生成了隐藏的名称,但从 C#语言的角度来看,它们是匿名的。)

对于简单的方法,内联表达式的能力可以消除大量的混乱。正如我们将在 “捕获变量” 中看到的那样,编译器利用了委托不仅仅是方法的引用这一事实,以便为匿名函数提供对包含方法中作用域的任何变量的访问。

由于历史原因,C# 提供了两种定义匿名函数的方式。较旧的方式涉及delegate关键字,并在 示例 9-20 中展示。这种形式被称为匿名方法。⁶ 我将FindIndex的每个参数放在单独的行上,以突出显示匿名函数(作为第二个参数),但 C#并不要求这样做。

示例 9-20. 匿名方法语法
public static int GetIndexOfFirstNonEmptyBin(int[] bins)
{
    return Array.FindIndex(
        bins,
        `delegate` `(``int` `value``)` `{` `return` `value` `>` `0``;` `}`
    );
}

在某些方面,这类似于定义方法的普通语法。参数列表出现在括号内,后面跟着包含方法体的块(顺便说一句,它可以包含任意数量的代码块,局部变量,循环和任何其他可以放入正常方法的内容)。但是,我们没有方法名,而是关键字delegate。编译器推断返回类型。在这种情况下,FindIndex方法的签名声明第二个参数为Predicate<T>,告诉编译器返回类型必须是bool

实际上,编译器不仅仅知道返回类型。我已经传递了一个int[]数组给FindIndex,因此编译器会推断类型参数Tint,使得第二个参数成为Predicate<int>。这意味着在示例 9-20 中,我必须提供信息——委托参数的类型——而编译器已经知道。C#的后续版本引入了更紧凑的匿名函数语法,更好地利用了编译器的推断能力,如示例 9-21 所示。

示例 9-21. Lambda 语法
public static int GetIndexOfFirstNonEmptyBin(int[] bins)
{
    return Array.FindIndex(
        bins,
        `value` `=``>` `value` `>` `0`
    );
}

这种形式的匿名函数称为lambda 表达式,它是一种基于函数的计算模型的数学分支的名称。选择希腊字母 lambda (λ) 没有特别的意义。这是 1930 年代印刷技术限制的意外结果。lambda 演算的发明者 Alonzo Church 最初希望有一个不同的符号,但当他首次发表有关该主题的论文时,排版机操作员决定打印 λ,因为这是机器能产生的最接近 Church 符号的符号。尽管起源不佳,这个任意选择的术语已经变得无处不在。LISP,一个早期和有影响力的编程语言,用 lambda 来表示函数表达式,从那时起,许多语言都效仿,包括 C#。

示例 9-21 与示例 9-20 完全等价;我只是能够省略掉各种东西。=>符号明确标记这是一个 lambda 表达式,因此编译器不需要那个笨重且丑陋的delegate关键字来识别这是一个匿名函数。编译器从周围的上下文知道方法必须接受一个int,因此不需要指定参数的类型;我只提供了参数的名称:value。对于只包含单个表达式的简单方法,lambda 语法允许你省略块和return语句。这些都使得 lambda 变得非常紧凑,但在某些情况下,你可能不想省略那么多,正如示例 9-22 所示,这里有各种可选的特性。本示例中的每个 lambda 都是等效的。

示例 9-22. Lambda 变体
Predicate<int> p1 = value => value > 0;
Predicate<int> p2 = (value) => value > 0;
Predicate<int> p3 = (int value) => value > 0;
Predicate<int> p4 = value => { return value > 0; };
Predicate<int> p5 = (value) => { return value > 0; };
Predicate<int> p6 = (int value) => { return value > 0; };
Predicate<int> p7 = bool (value) => value > 0;
Predicate<int> p8 = bool (int value) => value > 0;
Predicate<int> p9 = bool (value) => { return value > 0; };
Predicate<int> pA = bool (int value) => { return value > 0; };

第一种变体是你可以在参数周围加括号。对于单个参数来说是可选的,但是对于多参数 lambda 是强制的。你还可以显式地指定参数的类型(在这种情况下,即使只有一个参数,也需要括号)。如果 lambda 返回一个值并且你喜欢的话,你还可以使用一个块而不是单个表达式,此时如果 lambda 返回一个值,你还必须使用 return 关键字。使用块的正常理由是如果你想在方法内部编写多个语句。C# 10.0 添加的最后四行展示了一种新能力:你可以显式地指定返回类型,尽管只有在参数列表在括号内时才允许这样做。

也许你会想知道为什么有这么多不同的形式——为什么不只有一种语法形式就行了呢?尽管示例 9-22 的最后一行显示了最一般的形式,但比起第一行,它也更加凌乱。由于 lambda 的目标之一是提供一个比匿名方法更简洁的替代方案,C#支持这些可以在没有歧义的情况下使用的较短形式。

你也可以编写一个不带参数的 lambda。就像示例 9-23 展示的那样,我们只需在 => 符号前面放置一个空括号对即可。(正如这个示例还展示的那样,使用大于等于运算符 >= 的 lambda 看起来可能有些奇怪,因为 =>>= 之间的无意义相似性。)

示例 9-23. 零参数 lambda
Func<bool> isAfternoon = () => DateTime.Now.Hour >= 12;

灵活而简洁的语法意味着 lambda 函数几乎取代了较老的匿名方法语法。然而,旧语法有一个优点:它允许你完全省略参数列表。在一些情况下,当你提供一个回调时,你只需要知道你等待的事情现在已经发生了。这在使用本章后面描述的标准事件模式时尤为常见,因为这要求事件处理程序即使在没有作用的情况下也接受参数。例如,当点击按钮时,除了点击了这一事实之外,没有其他太多要说的了,但是在.NET 的各种 UI 框架中,所有按钮类型都会向事件处理程序传递两个参数。示例 9-24 通过使用省略参数列表的匿名方法成功地忽略了这一点。

示例 9-24. 忽略匿名方法中的参数
EventHandler clickHandler = delegate { Debug.WriteLine("Clicked!"); };

EventHandler 是一个委托类型,要求其目标方法接受两个参数,类型分别为 objectEventArgs。如果我们的处理程序需要访问其中任何一个,当然可以添加参数列表,但匿名方法语法允许我们想省略就省略。lambda 则无法做到这一点。尽管如此,C# 10.0 增加了一个新功能,使忽略参数稍微不那么繁琐,示例 9-25 就展示了这一点。

示例 9-25. 一个丢弃其参数的 lambda
EventHandler clickHandler = (_, _) => Debug.WriteLine("Clicked!");

这与示例 9-24 具有完全相同的效果,但使用了 lambda 语法。我在括号中提供了参数列表,但因为我不想使用任何参数,所以在每个位置放置了一个下划线。这表示一个丢弃。您在早期章节的模式中看到过_字符,其意义在这里基本相似:它表明我们知道有一个可用的值;只是我们不关心它是什么,也不打算使用它。

提示

在 C# 10.0 引入对此废弃语法的支持之前,人们经常使用类似的约定。下划线符号是一个有效的标识符,因此对于单参数 lambda,没有什么可以阻止您定义一个名为_的参数并选择不引用它。对于多个参数,情况会变得奇怪,因为您不能为两个参数使用相同的名称,这意味着示例 9-25 在旧版本的 C#中无法编译。为了解决这个问题,人们只是使用多个下划线,因此您可能会看到一个以(_, __, ___) =>开头的 lambda。幸运的是,C# 10.0 允许我们在整个过程中只使用一个_

捕获的变量

虽然匿名函数在源代码中通常比完整的普通方法占用更少的空间,但它们不仅仅是简洁。C#编译器利用委托不仅能够引用方法,还能引用一些额外上下文的能力,提供了一个极其有用的功能:它可以使包含方法中的变量对匿名函数可用。示例 9-26 展示了一个返回Predicate<int>的方法。它使用一个 lambda 创建这个,该 lambda 使用包含方法中的参数。

示例 9-26. 使用包含方法中的变量
public static Predicate<int> IsGreaterThan(int threshold)
{
    return value => value > threshold;
}

这提供了与示例 9-8 中的ThresholdComparer类相同的功能,但我们只需编写一个简单的方法,而不是整个类。通过使用表达式主体方法,可以使其更加紧凑,正如示例 9-27 所示。(这可能有点过于简洁——在>附近使用两个不同的=>并排,不会为可读性赢得任何奖项。)

示例 9-27. 使用包含方法中的变量(表达式主体)
public static Predicate<int> IsGreaterThan(int threshold) =>
    value => value > threshold;

无论是哪种形式,代码都几乎看似简单至极,因此值得仔细查看其作用。IsGreaterThan方法返回一个委托实例。该委托的目标方法执行简单的比较——它评估value > threshold表达式并返回结果。该表达式中的value变量只是委托的参数——由调用IsGreaterThan返回的Predicate<int>的代码传递的int。示例 9-28 的第二行调用该代码,并将 200 作为value参数传入。

示例 9-28. value 参数的来源
Predicate<int> greaterThanTen = IsGreaterThan(10);
bool result = greaterThanTen(200);

表达式中的 threshold 变量比较棘手。这不是匿名函数的参数。它是 IsGreaterThan 的参数,而 示例 9-28 将 10 作为 threshold 参数传递。但是,在我们调用它返回的委托之前,IsGreaterThan 必须返回。由于该方法的参数已经返回,你可能会认为变量在调用委托时不再可用。事实上,这没问题,因为编译器为我们做了一些工作。如果匿名函数使用了包含方法声明的局部变量,或者使用了该方法的参数,编译器会生成一个类来保存这些变量,以便它们可以超越创建它们的方法的生命周期。编译器会在包含方法中生成代码来创建这个类的实例。(记住,每个块的调用都有自己的一组局部变量,因此如果任何局部变量被推入对象以延长它们的生命周期,每个调用都将需要一个新对象。)这也是流行神话的原因之一,该神话声称值类型的局部变量总是存储在堆栈上是不正确的——在这种情况下,编译器将传入的 threshold 参数的值复制到堆上对象的字段中,并且使用 threshold 变量的代码最终使用该字段。示例 9-29 显示了编译器为 示例 9-26 中的匿名函数生成的代码。

示例 9-29. 为匿名函数生成的代码
[CompilerGenerated]
private sealed class <>c__DisplayClass0_0
{
    public int threshold;

    public bool <IsGreaterThan>b__0(int value)
    {
        return (value > this.threshold);
    }
}

所有类和方法的名称都以 C# 标识符中非法的字符开头,以确保这些编译器生成的代码不会与我们编写的任何内容冲突——这在技术上称为 不可言说的名称。(顺便说一句,确切的名称并不固定,如果你尝试的话可能会发现它们略有不同。)这些生成的代码与 示例 9-8 中的 ThresholdComparer 类非常相似,这并不奇怪,因为目标是一样的:委托需要某种可以引用的方法,而该方法的行为取决于一个不固定的值。匿名函数不是运行时类型系统的一个特性,因此编译器必须生成一个类来提供这种行为,超越 CLR 基本委托功能的范围。

注意

局部函数(在第三章描述)也可以访问其包含方法的局部变量。通常情况下,这不会改变这些变量的生命周期,因为局部函数在其包含方法之外是不可访问的。但是,如果你创建一个引用局部函数的委托,这意味着它可能在包含方法返回后被调用,因此编译器会执行与匿名函数相同的技巧,使变量能够在外部方法返回后继续存在。

一旦你了解到在编写匿名函数时实际发生的情况,自然而然地就能知道内部方法不仅能读取变量,还能修改它。这个变量只是一个对象中的字段,两个方法——匿名函数和包含方法——都可以访问到。示例 9-30 利用这一点来维护一个从匿名函数更新的计数。

示例 9-30. 修改被捕获的变量
static void Calculate(int[] nums)
{
    int zeroEntryCount = 0;
    int[] nonZeroNums = Array.FindAll(
        nums,
        v =>
        {
            if (v == 0)
            {
                zeroEntryCount += 1;
                return false;
            }
            else
            {
                return true;
            }
        });
    Console.WriteLine($"Number of zero entries: {zeroEntryCount}");
    Console.WriteLine($"First non-zero entry: {nonZeroNums[0]}");
}

对于包含方法的所有作用域内的内容,匿名函数也同样适用。如果包含方法是一个实例方法,这还包括类型的任何实例成员,因此你的匿名函数可以访问字段、属性和方法。(编译器通过在生成的类中添加一个字段来持有 this 引用的副本来支持这一点。)编译器只在需要时将生成的类中的内容添加到类中,如示例 9-29 所示,如果不使用包含作用域的变量或实例成员,则可能生成静态方法。

前面示例中的 FindAll 方法在返回后不会保留委托——任何回调将在 FindAll 运行时发生。然而,并非所有情况都是这样的。一些 API 执行异步工作,并且将在将来的某个时刻调用你,到那时包含方法可能已经返回了。这意味着任何被匿名函数捕获的变量将比包含方法的生存周期更长。总体来说,这是可以接受的,因为所有被捕获的变量都存储在堆上的对象中,因此匿名函数并不依赖于不再存在的堆栈帧。但有一件事需要特别注意,在回调完成之前一定要显式释放资源。示例 9-31 展示了一个容易犯的错误。它使用了一个异步、基于回调的 API 来通过 HTTP 下载特定 URL 的资源。(这在 HttpClient.GetStreamAsync 返回的 Task<Stream> 上调用 ContinueWith 方法,传递一个委托,该委托将在 HTTP 响应返回后调用。这个方法是第十六章描述的任务并行库的一部分。)

示例 9-31. 过早释放
HttpClient http = GetHttpClient();
using (FileStream file = File.OpenWrite(@"c:\temp\page.txt"))
{
    http.GetStreamAsync("https://endjin.com/")
        .ContinueWith((Task<Stream> t) => t.Result.CopyToAsync(file));
} // Will probably dispose FileStream before callback runs

在此示例中的using语句将在外部方法的作用域中的file变量离开范围的地方立即处置FileStream。问题在于,这个file变量也被用在一个匿名函数中,这很可能会在执行该外部方法的线程离开该using语句的块之后运行。编译器不知道内部块将何时运行——它不知道这是否像Array.FindAll使用的同步回调或异步回调。因此,在这里它无法做任何特殊处理——它只是在块的结尾调用Dispose,因为这是我们的代码告诉它要做的事情。

注意

讨论的异步语言特性见第十七章,可以帮助避免这种问题。当您使用这些特性来消耗展现这种Task-based 模式的 API 时,编译器可以确切地知道事物保持在作用域中的时间。这使得编译器可以为您生成继续回调,并且作为其中的一部分,它可以安排一个using语句在正确的时刻调用Dispose

在性能关键的代码中,你可能需要考虑匿名函数的成本。如果匿名函数使用外部作用域的变量,那么除了创建用于引用匿名函数的委托对象之外,您可能还会创建另一个对象:用于保存共享局部变量的生成类的实例。编译器在可以时会重用这些变量持有者——例如,如果一个方法包含两个匿名函数,它们可能能够共享一个对象。即使有了这种优化,您仍然在创建额外的对象,增加了垃圾回收的压力。(而且在某些情况下,即使您从未触发创建委托的代码路径,也可能会创建此对象。)这并不是特别昂贵——通常这些对象很小——但是如果您面临特别严峻的性能问题,通过以更加冗长的方式编写来减少对象分配的数量,您可能能够稍微改善一些性能。

注意

本地函数并不总是产生相同的开销。当本地函数使用其外部方法的变量时,它并不延长其生命周期。因此,编译器不需要在堆上创建对象来保存共享变量。它仍然会创建一个类型来保存所有共享变量,但将其定义为struct,作为隐藏的in参数传递引用,从而避免了对堆块的需求。(如果创建一个引用本地函数的委托,它就不能使用此优化,而是恢复到使用匿名函数时使用的相同策略,将共享变量放在堆上的对象中。)

更微妙的是,在匿名函数中使用外部范围的局部变量将延长这些变量的生存期,这可能意味着 GC 在检测这些变量引用的对象不再使用时需要更长时间。正如您可能从第 7 章中记得的那样,CLR 分析您的代码以确定何时使用变量,以便它可以在等待引用它们的变量超出范围之前释放对象。这使得某些对象使用的内存可以显著提前回收,特别是在需要长时间完成的方法中。但是,活跃性分析仅适用于传统的局部变量。它不能应用于在匿名函数中使用的变量,因为编译器会将这些变量转换为字段。(从 CLR 的角度来看,它们根本不是局部变量。)由于 C#通常将特定范围的所有这些转换变量放入单个对象中,您会发现在方法完成并且包含这些变量的对象变得不可访问之前,这些变量引用的对象都无法被回收。这意味着在某些情况下,当您完成后使用null设置一个局部变量,可能会使得特定对象的内存在下次 GC 时被回收。 (通常,这是一个不好的建议,即使对于匿名函数也可能没有实际上有用的效果。只有在性能测试显示明显优势的情况下才应该这样做。但是,在您看到与 GC 相关的性能问题,并且您大量使用长时间运行的匿名函数的情况下,进行调查是值得的。)

避免在匿名函数中出现这些潜在的性能问题非常简单:不要使用捕获的变量。如果一个匿名函数从未尝试使用其包含范围中的任何内容,C#编译器将不会启用相应的机制,完全避免所有开销。您可以通过使用static关键字来告知编译器,您打算避免捕获变量,如示例 9-32 所示。正如普通的static方法没有对其定义类型的实例的隐式访问一样,static匿名函数也无法访问其包含范围。使用static不会改变代码生成方式 —— 任何不依赖于捕获的匿名函数都会避免所有与捕获相关的开销,无论是否标记为static。这只是要求编译器在您意外尝试使用函数包含范围中的变量时报告错误。

示例 9-32. 使用static退出变量捕获
public static Predicate<int> IsGreaterThan10() => static value => value > 10;

变量捕获有时也可能导致错误,特别是由于for循环中与子范围相关的微妙问题。(foreach循环不会出现这个问题。)示例 9-33 遇到了这个问题。

Example 9-33. for 循环中的问题变量捕获
public static void Caught()
{
    var greaterThanN = new Predicate<int>[10];
    for (int i = 0; i < greaterThanN.Length; ++i)
    {
        greaterThanN[i] = value => value > i; // Bad use of i
    }

    Console.WriteLine(greaterThanN5);
    Console.WriteLine(greaterThanN5);
}

本示例初始化了一个 Predicate<int> 委托数组,其中每个委托测试值是否大于某个数字。(顺便说一句,您不必使用数组来看到我即将描述的问题。您的循环可以将其创建的委托传递给 第十六章 中描述的某种机制,该机制通过在多个线程上运行代码来实现并行处理。但数组使得更容易展示问题。)具体来说,它将值与循环计数器 i 比较,后者决定数组中每个委托的位置,因此您可能期望索引为 5 的元素引用与 5 进行比较的方法。如果是这样,此代码将显示两次 True。实际上,它显示 True 然后是 False。结果发现,Example 9-33 生成了一个委托数组,其中每个元素都将其参数与 10 进行比较。

当人们遇到这种情况时,通常会感到惊讶。事后来看,当您知道 C# 编译器如何使匿名函数能够使用其包含作用域的变量时,很容易理解为什么会发生这种情况。for 循环声明了变量 i,因为它不仅被包含的 Caught 方法使用,还被循环创建的每个委托使用,所以编译器将会生成一个类似于 Example 9-29 中的类,并且该变量将存在于该类的一个字段中。由于变量在循环开始时进入作用域,并在循环的整个过程中保持在作用域中,编译器将创建一个该生成类的实例,并且这个实例将被所有委托共享。因此,当循环增加 i 时,这会修改所有委托的行为,因为它们都使用相同的 i 变量。

从根本上说,问题在于这里只有一个 i 变量。您可以通过在循环内部引入一个新变量来修复代码。Example 9-34 将 i 的值复制到另一个本地变量 current 中,该变量在迭代开始时才进入作用域,并在每次迭代结束时退出作用域。因此,尽管只有一个 i 变量,该变量在循环运行期间持续存在,但我们实际上在每次循环中都得到一个新的 current 变量。由于每个委托都有自己独特的 current 变量,这种修改意味着数组中的每个委托将其参数与特定迭代时循环计数器的值进行比较。

Example 9-34. 修改循环以捕获当前值
for (int i = 0; i < greaterThanN.Length; ++i)
{
    `int` `current` `=` `i``;`
    greaterThanN[i] = value => value > current;
}

编译器仍然会生成类似于 Example 9-29 中的类,用于保存内联方法和包含方法共享的 current 变量,但这一次,它会在每次循环时创建该类的新实例,以便为每个匿名函数提供该变量的不同实例。(当使用 foreach 循环时,作用域规则略有不同:其迭代变量的作用域是每次迭代的,这意味着每次循环逻辑上是变量的不同实例,因此不需要像在 for 循环中那样在循环内部添加额外变量。)

或许你会想知道,如果编写一个使用多个作用域变量的匿名函数会发生什么。Example 9-35 声明了一个名为 offset 的变量,在循环之前,并且 lambda 同时使用了那个变量以及只在一次迭代中存在的变量。

Example 9-35. 在不同作用域捕获变量
`int` `offset` `=` `10``;`
for (int i = 0; i < greaterThanN.Length; ++i)
{
    int current = i;
    `greaterThanN``[``i``]` `=` `value` `=``>` `value` `>` `(``current` `+` `offset``)``;`
}

在这种情况下,编译器会生成两个类,一个用于保存每次迭代共享变量(例如本例中的 current),另一个用于保存整个循环范围的变量(例如本例中的 offset)。每个委托的目标对象都包含内部作用域变量,并且该作用域变量包含对外部作用域的引用。

Figure 9-1 大致展示了这种工作方式,尽管它已经简化只展示了前五个项目。greaterThanN 变量包含一个对数组的引用。每个数组元素包含对委托的引用。每个委托引用同一个方法,但每个委托都有不同的目标对象,这就是每个委托如何捕获不同实例的 current 变量。每个目标对象都引用一个包含从循环外部捕获的 offset 变量的单一对象。

Figure 9-1. 委托和捕获作用域

Lambdas and Expression Trees

Lambdas 除了提供委托之外,还有一个额外的小技巧。某些 lambda 会生成表示代码的数据结构。当你在需要 Expression<T> 的上下文中使用 lambda 语法时,就会发生这种情况,其中 T 是委托类型。Expression<T> 本身不是委托类型;它是运行时库中的特殊类型(位于 System.Linq.Expressions 命名空间),触发编译器对 lambda 的替代处理。Example 9-36 就使用了这种类型。

Example 9-36. 一个 lambda 表达式
Expression<Func<int, bool>> greaterThanZero = value => value > 0;

此示例看起来与本章节中已展示的一些 lambda 和委托很相似,但编译器处理方式完全不同。它不会生成一个方法——不会有编译后的 IL 代码表示 lambda 的主体。相反,编译器会生成类似于 Example 9-37 中的代码。

示例 9-37. 编译器对 lambda 表达式的处理
ParameterExpression valueParam = Expression.Parameter(typeof(int), "value");
ConstantExpression constantZero = Expression.Constant(0);
BinaryExpression comparison = Expression.GreaterThan(valueParam, constantZero);
Expression<Func<int, bool>> greaterThanZero =
    Expression.Lambda<Func<int, bool>>(comparison, valueParam);

这段代码调用Expression类提供的各种工厂函数,为 lambda 中的每个子表达式生成一个对象。从简单的操作数开始——value参数和常量值0。这些被输入一个代表“大于”比较表达式的对象中,进而成为代表整个 lambda 表达式的对象的主体。

能够为表达式生成对象模型使得编写一个 API 成为可能,其行为由表达式的结构和内容控制。例如,某些数据访问 API 可以接受类似于示例 9-36 和 9-37 生成的表达式,并用它来生成数据库查询的一部分。我将在第十章中讨论 C#的集成查询特性,但示例 9-38 展示了 lambda 表达式如何被用作查询的基础。

示例 9-38. 表达式和数据库查询
var expensiveProducts = dbContext.Products.Where(p => p.ListPrice > 3000);

此示例恰好使用了一个名为 Entity Framework 的 Microsoft 库,但是其他各种数据访问技术也支持相同的方法。在此示例中,Where方法接受一个类型为Expression<Func<Product,bool>>的参数。⁷ Product是一个对应数据库实体的类,但这里重要的是使用了Expression<T>。这意味着编译器将生成代码,创建一个对象树,其结构对应于 lambda 表达式。Where方法处理这个表达式树,生成包含此子句的 SQL 查询:WHERE [Extent1].[ListPrice] > cast(3000 as decimal(18))。因此,尽管我将查询编写为 C#表达式,但查找匹配对象的所有工作都将在我的数据库服务器上完成。

表达式树被添加到 C#中,以作为 LINQ 的一部分来处理此类查询(在第十章中讨论)。但是,与大多数与 LINQ 相关的功能一样,也可以用于其他用途。例如,用于自动化测试的流行.NET 库称为Moq就利用了这一点。它创建接口的假实现用于测试目的,并使用 lambda 表达式提供一个简单的 API 来配置这些假实现的行为。示例 9-39 使用 Moq 的Mock<T>类创建.NET 的IEqualityComparer<string>接口的假实现。代码调用Setup方法,该方法接受一个表达式,指示我们想要为其定义特殊处理的特定调用——在本例中,如果假实现的IEqualityComparer<string>.Equals"Color""Colour"作为参数被调用,则希望它返回true

示例 9-39. Moq 库使用 lambda 表达式的例子
var fakeComparer = new Mock<IEqualityComparer<string>>();
fakeComparer
    .Setup(c => c.Equals("Color", "Colour"))
    .Returns(true);

如果Setup的参数只是一个委托,Moq 将无法检查它。但因为它是一个表达式树,Moq 能够深入其中并找出我们所要求的内容。

警告

不幸的是,表达式树是 C#中落后于语言其余部分的一个领域。它们在 C# 3.0 中引入,自那以后增加的各种语言特性,如对元组和异步表达式的支持,无法在表达式树中使用,因为对象模型无法表示它们。

事件

有时候,对象能够在有趣的事情发生时提供通知是很有用的——在客户端 UI 框架中,例如,你会想知道用户何时点击应用程序的按钮。委托提供了通知所需的基本回调机制,但你可以用许多方法来使用它们。委托应该作为方法参数传递、构造函数参数传递,还是作为属性传递?你应该如何支持取消订阅通知?CTS 通过一种特殊的类成员——事件来正式回答这些问题,并且 C#有与事件一起工作的语法。示例 9-40 展示了一个带有事件成员的类。

示例 9-40. 一个带有事件的类
public class Eventful
{
    `public` `event` `Action``<``string``>``?` `Announcement``;`

    public void Announce(string message)
    {
        Announcement?.Invoke(message);
    }
}

和所有成员一样,你可以从一个可访问性限定符开始,如果你省略了它,它将默认为private。接下来,event关键字将其单独标识为事件。然后是事件的类型,可以是任何委托类型。我使用了Action<string>,尽管你很快会看到,这是一个不正统的选择。最后,我们放置成员名称,所以这个例子定义了一个名为Announcement的事件。

要处理一个事件,你必须提供一个正确类型的委托,并且你必须使用+=语法将该委托附加为处理程序。示例 9-41 使用了一个 lambda 表达式,但你可以使用任何产生或隐式转换为事件所需类型的委托的表达式。

示例 9-41. 处理事件
var source = new Eventful();
source.Announcement += m => Console.WriteLine("Announcement: " + m);

除了定义事件之外,示例 9-40 还展示了如何引发事件——也就是说,如何调用已附加到事件的所有处理程序。它的Announce使用了相同的语法,如果Announcement是一个包含我们想要调用的委托的字段,我们将使用这个语法。实际上,就类内部代码而言,事件看起来确实像是一个字段。我选择在这里显式地使用委托的Invoke成员,而不是写Announcement(message),因为这让我可以使用空值条件运算符(?.)。这会导致编译器只在委托不为 null 时才生成调用代码。否则,我必须编写一个if语句来验证字段不为 null 才能调用它。

那么为什么我们需要一种特殊的成员类型,如果这看起来只是一个字段?好吧,它只从定义类的内部看起来像一个字段。类外的代码无法引发事件,所以在 示例 9-42 中显示的代码将无法编译。

示例 9-42. 如何不引发事件
var source = new Eventful();
source.Announcement("Will this work?"); // No, this will not even compile

从外部看,你只能对事件做两件事:使用 += 添加处理程序和使用 -= 删除处理程序。添加和删除事件处理程序的语法是不寻常的,因为这是 C# 中唯一可以使用 +=-= 而没有相应独立的 +- 运算符的情况。+=-= 对事件的操作最终都是伪装成方法调用。就像属性实际上是具有特殊语法的方法对一样,事件也是如此。它们在概念上类似于 示例 9-43 中显示的代码。(实际代码包括一些相当复杂的无锁、线程安全代码。我没有显示这些代码,因为多线程会模糊其基本意图。)这不会产生完全相同的效果,因为 event 关键字向类型添加了标识方法为事件的元数据,因此这只是用于说明的示例。

示例 9-43. 声明事件的近似效果
private Action<string>? Announcement;

// Not the actual code.
// The real code is more complex, to tolerate concurrent calls.
public void add_Announcement(Action<string> handler)
{
    Announcement += handler;
}
public void remove_Announcement(Action<string> handler)
{
    Announcement -= handler;
}

与属性类似,事件主要存在是为了提供一种方便且独特的语法,并使工具更容易知道如何呈现类提供的特性。事件对于 UI 元素尤为重要。在大多数 UI 框架中,表示交互元素的对象通常可以触发多种事件,对应不同形式的输入,例如键盘、鼠标或触摸。通常还会有与特定控件行为相关的事件,比如在列表中选择新项目。因为 CTS 定义了一种标准习语,使元素可以公开事件,因此视觉 UI 设计工具(例如内置于 Visual Studio 中的工具)可以显示可用事件并为您生成处理程序。

标准事件委托模式

示例 9-40 中的事件使用 Action<T> 委托类型,这是不寻常的,因为几乎所有事件实际上都使用符合特定模式的委托类型。该模式要求委托的方法签名具有两个参数。第一个参数的类型是 object,第二个参数的类型要么是 EventArgs,要么是从 EventArgs 派生的某种类型。示例 9-44 展示了 System 命名空间中的 EventHandler 委托类型,这是这种模式中最简单且最广泛使用的例子。

示例 9-44. EventHandler 委托类型
public delegate void EventHandler(object sender, EventArgs e);

第一个参数通常称为sender,因为事件源会将自身的引用传递给此参数。这意味着,如果你将单个委托附加到多个事件源,处理程序始终可以知道哪个源引发了特定的通知。

第二个参数提供了一个放置特定事件信息的地方。例如,WPF UI 元素定义了各种处理鼠标输入的事件,使用更专门的委托类型,例如MouseButtonEventHandler,其签名指定了一个相应的专用事件参数,提供关于事件的详细信息。例如,MouseButtonEventArgs定义了一个GetPosition方法,告诉你鼠标在按钮点击时的位置,它还定义了各种其他属性,包括ClickCountTimestamp

无论第二个参数的专用类型是什么,它始终会派生自基本的EventArgs类型。这个基本类型并不是很有趣——它除了object提供的标准成员外没有添加任何成员。然而,它确实使得可以编写一个通用方法,可以附加到使用这种模式的任何事件上。委托兼容性的规则意味着,即使委托类型指定了第二个参数类型为MouseButtonEventArgs,一个第二个参数类型为EventArgs的方法也是一个可以接受的目标。这在代码生成或其他基础设施场景中偶尔是有用的。然而,标准事件模式的主要好处仅仅是熟悉性——有经验的 C#开发人员通常期望事件能够以这种方式工作。

自定义添加和删除方法

有时,你可能不想使用 C#编译器生成的默认事件实现。例如,一个类可能定义了大量事件,其中大多数在大多数实例上都不会被使用。UI 框架经常具有这种特性。WPF UI 可以有成千上万的元素,每个元素都提供超过 100 个事件,但通常你只会给少数几个元素附加处理程序,甚至对于这些元素,你也只处理提供的少数事件。在这种情况下,让每个元素都为每个可用事件分配一个字段是低效的。

对于大量很少使用的事件,默认的基于字段的实现可能会为 UI 中的每个元素增加数百字节的占用空间,这可能会对性能产生可察觉的影响。(在典型的 WPF 应用程序中,这可能会累积到几十万字节。虽然在现代计算机的内存容量下这听起来不多,但它可能使你的代码无法有效利用 CPU 缓存,导致应用响应速度急剧下降。即使缓存的大小为几兆字节,但最快速的部分通常要小得多,而在关键数据结构中浪费几百千字节可能会对性能造成重大影响。)

如果你要避免使用默认的编译器生成的事件实现,另一个原因是你可能希望在引发事件时拥有更复杂的语义。例如,WPF 支持事件冒泡:如果一个 UI 元素不处理某些事件,这些事件将会被传递给父元素,然后是父元素的父元素,依此类推直到找到一个处理程序或达到顶部。虽然在 C#提供的标准事件实现中可以实现这种方案,但当事件处理程序相对稀少时,采用更高效的策略是可能的。

为了支持这些场景,C#允许你为事件提供自己的 add 和 remove 方法。从外部看,它看起来像一个普通的事件——使用你的类的任何人都将使用相同的+=-=语法来添加和移除处理程序——并且不可能知道它提供了自定义实现。示例 9-45 展示了一个具有两个事件的类,并使用一个共享的字典跟踪哪些对象处理了哪些事件。该方法可扩展到更多事件——字典使用对象对作为键,因此每个条目代表特定的(源,事件)对。(顺便说一句,这不是生产质量的代码。在多线程使用时不安全,当仍附有事件处理程序的ScarceEventSource实例不再使用时还会泄露内存。这个例子只是展示了自定义事件处理程序的外观;它不是一个完全工程化的解决方案。)

示例 9-45. 自定义addremove用于稀疏事件
public class ScarceEventSource
{
    // One dictionary shared by all instances of this class,
    // tracking all handlers for all events.
    // Beware of memory leaks - this code is for illustration only.
    private static readonly
     Dictionary<(ScarceEventSource, object), EventHandler> _eventHandlers
      = new();

    // Objects used as keys to identify particular events in the dictionary.
    private static readonly object EventOneId = new();
    private static readonly object EventTwoId = new();

    public event EventHandler EventOne
    {
        add
        {
            AddEvent(EventOneId, value);
        }
        remove
        {
            RemoveEvent(EventOneId, value);
        }
    }

    public event EventHandler EventTwo
    {
        add
        {
            AddEvent(EventTwoId, value);
        }
        remove
        {
            RemoveEvent(EventTwoId, value);
        }
    }

    public void RaiseBoth()
    {
        RaiseEvent(EventOneId, EventArgs.Empty);
        RaiseEvent(EventTwoId, EventArgs.Empty);
    }

    private (ScarceEventSource, object) MakeKey(object eventId) => (this, eventId);

    private void AddEvent(object eventId, EventHandler handler)
    {
        var key = MakeKey(eventId);
        _eventHandlers.TryGetValue(key, out EventHandler? entry);
        entry += handler;
        _eventHandlers[key] = entry;
    }

    private void RemoveEvent(object eventId, EventHandler handler)
    {
        var key = MakeKey(eventId);
        EventHandler? entry = _eventHandlers[key];
        entry -= handler;
        if (entry == null)
        {
            _eventHandlers.Remove(key);
        }
        else
        {
            _eventHandlers[key] = entry;
        }
    }

    private void RaiseEvent(object eventId, EventArgs e)
    {
        var key = MakeKey(eventId);
        if (_eventHandlers.TryGetValue(key, out EventHandler? handler))
        {
            handler(this, e);
        }
    }
}

自定义事件的语法与完整属性语法类似:在成员声明后添加一个块,其中包含两个成员,虽然它们称为addremove而不是getset。(与属性不同的是,你必须始终提供这两种方法。)这会禁用通常会保存事件的字段的生成,这意味着ScarceEventSource类根本没有实例字段——这种类型的实例尽可能小。

这种小内存占用的代价是复杂性显著增加;我编写的代码行数大约是使用编译器生成事件所需的 16 倍,而且为了修复前面描述的缺陷,我们可能还需要更多。此外,只有在大多数情况下事件确实没有被处理时,这种技术才会提供改进——如果我为该类的每个实例都附加了这两个事件的处理程序,那么基于字典的存储将消耗比每个类实例中简单拥有一个字段更多的内存。因此,只有在你需要非标准事件触发行为或非常确定你确实会节省内存并且节省是值得的情况下,你应该考虑这种自定义事件处理方式。

事件与垃圾回收器

就 GC 而言,委托和任何其他普通对象一样。如果 GC 发现委托实例是可达的,那么它将检查 Target 属性,以及该属性所引用的任何对象也将被视为可达,以及该对象再次引用的任何对象。虽然这没有什么显著之处,但是在某些情况下,保留事件处理程序可能导致对象在内存中持续存在,而你可能希望它们被 GC 收集。

关于委托和事件本身没有任何固有的特性使它们异常可能导致 GC 失败。如果你确实遇到与事件相关的内存泄漏,它的结构与任何其他 .NET 内存泄漏相同:从根引用开始,会有一些引用链使得对象在使用完毕后仍然可达。尽管如此,事件通常因为它们经常用于可能导致问题的方式而特别受到内存泄漏的责备。

例如,假设你的应用程序维护一些表示其状态的对象模型,而你的 UI 代码位于一个单独的层中,利用该底层模型,使其适应屏幕上的展示。通常建议采用这种分层方式——将处理用户交互的代码与实现应用逻辑的代码混合在一起是一个不好的主意。但是如果底层模型广播状态变化,UI 需要反映这些变化,则可能会出现问题。如果这些变化是通过事件广播的,那么你的 UI 代码通常会将处理程序附加到这些事件上。

现在想象有人关闭你应用程序的一个窗口。你希望表示该窗口 UI 的对象在下次 GC 运行时都被检测为不可达。UI 框架很可能已经尝试使这成为可能。例如,WPF 确保其每个 Window 类的实例在相应窗口打开时都是可达的,但一旦窗口关闭,它就停止保持对窗口的引用,以便能够收集该窗口的所有 UI 对象。

然而,如果你在主应用程序模型中处理事件,并在 Window 派生类中的方法中未显式删除该处理程序,那么你将会遇到问题。只要你的应用程序仍在运行,可能会有某个地方保持你的应用程序的底层模型可达。这意味着任何被应用程序模型的委托所持有的目标对象(例如作为事件处理程序添加的委托)将继续可达,阻止 GC 释放它们。因此,如果一个现在关闭的窗口的 Window 派生对象仍在处理来自你的应用程序模型的事件,那么该窗口及其包含的所有 UI 元素仍将可达,并且不会被垃圾回收。

注意

有一种持久的错误观念认为,这种基于事件的内存泄漏与循环引用有关。事实上,GC 完全可以处理循环引用。确实,在这些场景中通常存在循环引用,但它们不是问题的根源。问题是在你不再需要它们之后,意外地保持对象的可达性。无论是否存在循环引用,这样做都会导致问题。

你可以通过确保,如果你的 UI 层附加处理程序到长时间保持活跃的对象上,当相关的 UI 元素不再使用时,移除这些处理程序来处理这个问题。或者,你可以使用弱引用来确保,如果你的事件源是唯一持有目标引用的东西,它不会保持其活跃性。WPF 可以帮助你处理这个问题——它提供了一个 WeakEventManager 类,允许你以一种使处理对象能够在不需要取消订阅事件的情况下被垃圾回收的方式处理事件。WPF 在将 UI 数据绑定到提供属性更改通知事件的数据源时,就使用了这种技术。

注意

虽然事件相关的泄漏通常出现在用户界面中,但它们可能发生在任何地方。只要事件源仍然可达,所有附加的处理程序也将保持可达。

事件与委托

一些 API 通过事件提供通知,而其他一些直接使用委托。你应该如何决定使用哪种方法?在某些情况下,决策可能已经为你做出,因为你想支持某种特定习惯用语。例如,如果你希望你的 API 支持 C# 中的异步特性,你将需要实现 第十七章 中描述的模式,该模式使用委托而不是事件作为完成回调。另一方面,事件提供了明确的订阅和取消订阅的方式,在某些情况下将使它们成为更好的选择。约定是另一个考虑因素:如果你正在编写一个 UI 元素,事件很可能是合适的,因为那是主要的习惯用语。

在约束或惯例无法提供答案的情况下,您需要考虑回调的使用方式。如果通知会有多个订阅者,事件可能是最佳选择。这并非绝对必要,因为任何委托都能够支持多播行为,但按照惯例,这种行为通常通过事件提供。如果您的类的用户将需要在某个时候移除处理程序,事件也可能是一个不错的选择。尽管如此,如果需要更高级的功能,则IObservable接口也支持多播和取消订阅,并且可能是一个更好的选择。此接口是.NET 的响应式扩展的一部分,并在第十一章中描述。

如果只有一个目标方法才能实现,通常将委托作为方法或构造函数的参数传递。例如,如果委托类型具有非void返回值,并且 API 依赖于它(例如传递给Array.FindAll的谓词返回的bool),那么具有多个目标或零个目标是没有意义的。在这里,事件的用法不正确,因为它的订阅模型认为无论是附加零个处理程序还是多个处理程序都是完全正常的。

偶尔会出现一些场景,可能希望有零个处理程序或一个处理程序,但从不超过一个处理程序。例如,WPF 的CollectionView类可以对集合中的数据进行排序、分组和过滤。通过提供Predicate<object>来配置过滤。这不是作为构造函数参数传递的,因为过滤是可选的,所以类定义了一个Filter属性。在这里使用事件是不合适的,部分原因是Predicate<object>不符合通常的事件委托模式,但主要是因为类需要一个明确的是或否的答案,所以不希望支持多个目标。(当然,所有委托类型都支持多播,这意味着仍然可以提供多个目标。但使用属性而不是事件的决定表明在此尝试提供多个回调并不有用。)

委托与接口

在本章的开头,我提到委托比接口提供了一个更不繁琐的回调和通知机制。那么为什么一些 API 要求调用者实现接口来启用回调呢?为什么我们有IComparer<T>而不是委托?实际上,我们两者都有 —— 有一个委托类型称为Comparison<T>,许多接受IComparer<T>的 API 也支持它作为替代。数组和List<T>有重载的Sort方法,可以接受任一种类型。

在某些情况下,面向对象的方法可能比使用委托更可取。实现IComparer<T>的对象可以提供属性来调整比较的方式(例如,选择不同的排序标准)。您可能希望跨多个回调收集和汇总信息,尽管您可以通过捕获变量来实现这一点,但如果通过对象的属性在最后再次获取信息会更容易。

这实际上是由编写被调用代码的人决定的问题,而不是由编写调用代码的开发者决定的。委托更灵活,因为它允许 API 的消费者决定如何组织他们的代码,而接口则强加了约束。然而,如果接口恰好与您想要的抽象一致,委托可能会显得像是一个令人恼火的额外细节。这就是为什么一些 API 提供两种选择的原因,例如接受IComparer<T>Comparison<T>的排序 API。

如果你需要提供多个相关的回调,接口可能比委托更可取。.NET 的响应式扩展定义了一个通知的抽象,包括在事件序列结束或出现错误时知道的能力,因此在该模型中,订阅者实现一个包含三个方法的接口——OnNextOnCompletedOnError。使用接口是有道理的,因为这三种方法通常需要一起使用才能完成订阅。

总结

委托是提供对方法引用的对象,可以是静态方法或实例方法。对于实例方法,委托还保存对目标对象的引用,因此调用委托的代码不需要提供目标。委托还可以引用多个方法,尽管如果委托的返回类型不是void,这会使事情复杂化。虽然委托类型在 CLR 中得到特殊处理,但它们仍然只是引用类型,这意味着可以将委托的引用作为参数传递、从方法中返回并存储在字段、变量或属性中。委托类型为目标方法定义了一个签名。这通过类型的Invoke方法表示,但 C# 可以隐藏这一点,提供一个语法,可以直接调用委托表达式,而不必显式引用Invoke。您可以构造一个委托,引用任何具有兼容签名的方法。您还可以让 C# 为您做更多的工作——如果您使用 lambda 语法创建一个匿名函数,C# 将为您提供一个合适的声明,并且可以在幕后为内部方法使包含方法中的变量可用。委托是事件的基础,它为通知提供了一个正式的发布/订阅模型。

C# 中特别广泛使用委托的一个特性是 LINQ,这将在下一章讨论。

¹ 在 C# 10.0 之前,编译器不会为你选择,而且这个例子会产生编译器错误。如果你遇到的代码费力地指定了编译器本来会选择的委托类型,那么它很可能是在 C# 10.0 发布之前编写的。

² ILDASM 随 Visual Studio 一起提供。在撰写本文时,微软并未提供跨平台版本,但你可以使用开源项目 ILSpy

³ 你可能记得泛型类型定义可以使用inout关键字,但那是不同的。它指示泛型类型中的类型参数是反变还是协变。当你为类型参数提供具体的参数时,你不能使用inout

⁴ 或者,你可能只是自然界动态语言的爱好者之一,对通过静态类型表达语义感到过敏。如果是这样的话,C# 可能不是适合你的语言。

IntPtr 是一个通常用于不透明句柄值的值类型。在与互操作方案中有时你也会看到它 —— 在.NET 中,如果你看到一个来自操作系统 API 的原始句柄,它可能被表示为IntPtr,尽管在许多情况下,这已被SafeHandle取代。

⁶ 不幸的是,有两个相似的术语,它们几乎但不完全意味着同一件事情。C# 文档将匿名函数作为这两种方法表达式的通用术语。匿名方法可能更合适一些,因为并不是所有这些东西严格上都是函数 —— 它们可以有一个void返回值 —— 但在微软需要一个通用术语来指代这些东西时,那个名字已经被使用了。

⁷ 在这里看到Func<Product,bool>而不是Predicate<Product>可能会让你感到惊讶。Where方法是一个名为 LINQ 的 .NET 功能的一部分,该功能广泛使用委托。为了避免定义大量新的委托类型,LINQ 使用Func类型,并且为了 API 的一致性,即使其他标准类型也适用,它也更喜欢使用Func