IDisposable 字段怎么能在这里为空?

How can IDisposable field be null here?

我有很多例子,一次性 class 使用空条件运算符(如 _field?.Dispose())处理其一次性字段。并且 Resharper 还生成带有空检查的代码。

class Foo : IDisposable
{
    private readonly Bar _bar;

    public Foo()
    {
        _bar = new Bar();
    }
    
    public void Dispose()
    {
        _bar?.Dispose();
    }
}

class Bar : IDisposable
{
    public void Dispose()
    {
    }
}

我的问题是为什么这里使用字段 _bar 的空检查?此时它不能为空。它不会被垃圾回收,除非包含对象被垃圾回收,因为它持有对它的引用。如果包含对象,即 Foo 对象为 null,我们永远不能对其调用 .Dispose(),因为它会抛出空引用异常。

你是对的,_bar 在此特定示例中不能为 null。这是几个因素的组合:

  • 只有一个构造函数总是初始化一个 Bar 对象
  • _bar 是只读的,这保证之后没有人可以将其设置为 null

但不难想出一个它可能为空的示例(例如,更改上述任何要点)。即使对于这个简单的代码示例,我已经争辩说,如果您希望这是一个干净的代码库,那么 Bar 很可能应该被注入,这抛出了您可以保证 _bar 不会的论点。 t 为空。

专门创建 null-protecting 和 non-null 保护代码变体,而不是始终防止空值,这有什么好处?这需要更多的努力,您可能会选择错误的选项;有什么好处?没有。

所以如果故意省略空值保护没有任何好处,为什么还要费心弄清楚是否有必要防止空值?只包括 null 保护比花时间弄清楚在这种特殊情况下是否不需要 null 保护要容易得多。

另请注意,您具体指的是在线演示代码或 Resharper 生成的模板,这两种情况都旨在覆盖广泛的受众并保持广泛适用性。

虽然对于类型的大多数“正常”使用而言,另一个答案是正确的,但至少有两个稍微不寻常的场景,其中 _bar 可以 Dispose() 中为 null,尽管似乎总是通过唯一的构造函数进行初始化。

值得理解的是,在 .NET 中创建对象本质上是一个两步过程:

  1. 分配对象
  2. 调用适当的构造函数

步骤 1 可以通过多种方式发生 而没有 步骤 2,这使您得到一个对象,其所有字段都处于相应类型的默认状态(例如,null 代表引用类型,零代表数字类型,等等)。

第一个是有意通过 FormatterServices.GetUninitializedObject(),它主要由旨在反序列化对象的代码使用(例如,在持久化到文件之后)。顾名思义,它分配一个对象的实例(步骤 1),但 执行构造函数。对于您的示例 Foo class,这将导致 _bar 保持为空。如果随后在实例上调用 Dispose() 而没有通过其他方式(例如反射)初始化 _bar,则将在 Dispose() 方法中抛出空引用异常,而无需_bar.

空检查

另一种更不常见的可能性是 long-standing(将近 5 岁)bug in .NET itself。虽然它不会出现在您的示例中的确切代码上,但稍作修改(引入终结器)就可以实现。

这是一个例子:

class Foo : IDisposable
{
    private readonly Bar _bar;

    // The constructor needs to have at least one argument
    public Foo(string someArg)
    {
        _bar = new Bar();
    }

    public void Dispose()
    {
        Dispose(true);
    }

    // And we need a finalizer
    ~Foo()
    {
        Dispose(false);
    }

    private void Dispose(bool disposing)
    {
        Console.WriteLine("{0} and _bar is {1}", disposing ? "Disposing" : "Finalizing", _bar == null ? "null" : "not null");
    }
}

并触发它:

static void Main(string[] args)
{
    try
    {
        // We intentionally trigger an exception when getting the arg to pass to `Foo()`
        // to trigger the bug, however in real-life example you might call a method
        // here that sometimes throws
        using (var foo = new Foo(args[-1]))
        {
            // ...
        }
    }
    catch
    {
    }

    // These two lines aren't required for the bug to be triggered,
    // they simply allow us to see it without waiting for a GC.
    GC.Collect();
    GC.WaitForPendingFinalizers();
}

当我 运行 时的输出是:

Finalizing and _bar is null

解释相对简单,也是从创建对象的两个步骤过程得出的。人们可能期望 .NET 总是会在步骤 #1 之后立即执行步骤 #2,但实际上它将确定构造函数参数值的代码放在 两个步骤之间(如果有的话)该代码的一部分抛出异常,就像我们(有意)在这里所做的那样,然后跳过步骤 #2。虽然我们不能访问分配的对象(因为它永远不会分配给 foo 变量),因为它是可终结的,.NET 仍然跟踪它以便稍后调用它的终结器。

虽然这两个例子看起来有些假设,但多年来我在生产代码库中都遇到过它们,所以值得一提!