Search code examples
c#.net-core.net-core-3.0c#-8.0nullable-reference-types

Good practice for "ref" arguments and nullable checking


What's the best approach to follow when I face situations like the following?

The context is a .NET Core 3.x application with the latest nullable checking enabled, and a method with the ref keyword. The real code is more complex, but a simpler version could be this:

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()
        );
}

The EnsureInitialized() method takes the _initializationTarget reference, which is null at the beginning, and hence marking it as nullable seems the correct adjustment. However, the same method ensures the variable to be properly filled.

I couldn't find a better pattern than the following—but is it really the best one?

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
}

Solution

  • TL;DR: If you remove the initialized argument, then EnsureInitialized() will guarantee that the returned object is [NotNull]—even if you pass in an unitialized MyClass? reference—and thus there won't be a need to use the null-forgiving operator (!).


    Complete Answer

    This is a good question. The answer to your specific example is really easy, but I also want to take the opportunity to address your general question regarding how to handle ref arguments with C# 8.0's nullable reference types. In doing so, that will not only help address other scenarios like this, but also offer an explanation of why the solution to your specific example works.

    Specific Example

    While the EnsureInitialized() documentation doesn't make this entirely clear, the particular overload you are calling is for situations where you potentially want a null target. Namely, if you were to instead pass a value of true for the initialized parameter, then it would return null. That condition is why it must return a nullable type.

    Since you don't want to permit null values—and aren't working with a value type—you simply need to remove the initialized argument. You'll still need to declare your _initializationTarget as nullable, since it isn't being initialized in your constructor. This overload, however, guarantees to the compiler that the target parameter will no longer be null after EnsureInitialized() has run:

    private static object _initializationLock = new object();
    private static MyClass? _initializationTarget;  //marked as nullable
    
    public MyClass GetInstance() =>
        LazyInitializer.EnsureInitialized(
            ref _initializationTarget,
            ref _initializationLock,
            () => new MyClass()
        );
    

    Notice that while it still passes in a null(able) MyClass?, it confidently returns a MyClass without needing to resort to the null-forgiving operator (!).

    General Question

    As you dive further into C# 8.0's nullable reference types, you'll discover a number of gaps in Roslyn's static flow analysis which cannot be disambiguated by simply using the ? and ! operators alone. Fortunately, Microsoft foresaw this problem and provided us with a variety of attributes which can be used to provide compiler hints.

    Example

    Here's a basic example to illustrate the general problem:

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

    If you call this method with the following code, you'll get a CS8602 warning:

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

    You can mitigate this, however, by applying the [NotNull] hint to the input parameter:

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

    Now, any references to object after EnsureNotNull() is called will be known (by the compiler) to not be null.

    EnsureInitialized() overloads

    If you evaluate 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 marks target as [AllowNull], which is the equivalent to returning MyClass?:

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

    By contrast, the overload that I've recommended implements the [NotNull] attribute discussed above, which is the equivalent of returning MyClass:

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

    If you evaluate the actual logic, you'll see they're basically the same, except the former includes an escape clause for the scenario where initialized is true—and, therefore, allows target to potentially remain null.

    Since you know that you're working with a class and don't want a null value, however, the latter overload is the best choice, and implements the exact practice outlined in my answer to your general question.