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
}
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 (!
).
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.
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 (!
).
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.
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()
overloadsIf 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.