"ref" 参数和可为空检查的良好做法

Good practice for "ref" arguments and nullable checking

当我遇到以下情况时,最好的方法是什么?

上下文是启用了最新 nullable checking 的 .NET Core 3.x 应用程序,以及带有 ref 关键字的方法。真正的代码更复杂,但更简单的版本可能是这样的:

private static bool _initialized = false;
private static object _initializationLock = new object();
private static MyClass _initializationTarget;  //suggestion to mark as nullable

public MyClass GetInstance()
{
    return LazyInitializer.EnsureInitialized(
        ref _initializationTarget,
        ref _initialized,
        ref _initializationLock,
        () => new MyClass()
        );
}

EnsureInitialized() 方法在开头采用 _initializationTarget 引用,即 null,因此将其标记为可为空似乎是正确的调整。但是,同样的方法可以确保变量被正确填充。

我找不到比以下更好的模式了——但它真的是最好的吗?

private static bool _initialized = false;
private static object _initializationLock = new object();
private static MyClass? _initializationTarget;  //marked as nullable

public MyClass GetInstance()
{
    //the return value must also nullable
    MyClass? inst = LazyInitializer.EnsureInitialized(
        ref _initializationTarget,
        ref _initialized,
        ref _initializationLock,
        () => new MyClass()
        );

    return inst!;  //null-forgive here
}

TL;DR: 如果删除 initialized 参数,那么 EnsureInitialized() 将保证 returned 对象是 [NotNull]—even if you pass in an unitialized MyClass? reference—and thus there won't be a need to use the null-forgiving operator (!).


完整答案

这是个好问题。您的具体示例的答案非常简单,但我也想借此机会解决您关于如何处理[=21]的一般问题=] 参数与 C# 8.0 的可空引用类型。这样做不仅有助于解决类似的其他情况,而且还可以解释为什么您的特定示例的解决方案有效。

具体例子

虽然 the EnsureInitialized() documentation 并未完全清楚这一点,但您调用的特定重载适用于您可能 想要 一个 null 目标的情况。也就是说,如果您要为 initialized 参数传递值 true,那么它将 return null。这个条件就是为什么它 必须 return 可空类型。

由于您不想允许null值——并且不使用值类型——您只需删除initialized 争论。您仍然需要将 _initializationTarget 声明为可为空,因为它未在您的构造函数中初始化。但是,此重载向编译器保证在 EnsureInitialized() 具有 运行:

之后 target 参数将不再是 null
private static object _initializationLock = new object();
private static MyClass? _initializationTarget;  //marked as nullable

public MyClass GetInstance() =>
    LazyInitializer.EnsureInitialized(
        ref _initializationTarget,
        ref _initializationLock,
        () => new MyClass()
    );

请注意,虽然它仍然传递一个 null(able) MyClass?,但它自信地 return 是一个 MyClass 而无需求助于 null-forgiving operator (!).

一般问题

随着您深入研究 C# 8.0 的可空引用类型,您会发现 Roslyn 的静态流分析中存在许多差距,这些差距无法通过简单地使用 ?! 来消除歧义运营商单独。好在微软预见到了这个问题,为我们提供了多种attributes which can be used to provide compiler hints.

例子

这里有一个基本示例来说明一般问题:

public void EnsureNotNull(ref Object? input) => input ??= new Object();

如果您使用以下代码调用此方法,您将收到 CS8602 警告:

Object? object = null;
EnsureNotNull(ref object);
_ = object.ToString(); //CS8602; Dereference of a possibly null reference

但是,您可以通过将 [NotNull] hint 应用于 input 参数来缓解这种情况:

public void EnsureNotNull([NotNull]ref Object? input) => input ??= new Object();

现在,在调用 EnsureNotNull() 之后对 object 的任何引用都将被(编译器)识别为不是 null

EnsureInitialized() 重载

如果计算 the source code for LazyInitializer with the above in mind, the answer to your specific problem becomes a lot more clear. The overload you were calling 标记 target[AllowNull],这等同于 returning MyClass?:

public static T EnsureInitialized<T>([AllowNull] ref T target, ref bool initialized, [NotNull] ref object? syncLock, Func<T> valueFactory) => …

相比之下,the overload that I've recommended 实现了上面讨论的 [NotNull] 属性,相当于 returning MyClass:

public static T EnsureInitialized<T>([NotNull] ref T? target, [NotNull] ref object? syncLock, Func<T> valueFactory) where T : class => …

如果您评估实际逻辑,您会发现它们基本相同,除了前者包含针对 initializedtrue 的情况的免责条款——因此,允许 target 可能保持 null.

由于您知道您正在使用class并且想要null 值,但是,后者重载是最佳选择,并且实现了我对您的一般问题的回答中概述的确切做法。