When [NotNull] is null
Published by marco on
I prefer to be very explicit about nullability of references, wherever possible. Happily, most modern languages support this feature non-nullable references natively (e.g. TypeScript, Swift, Rust, Kotlin).
As of version 8, C# also supports non-nullable references, but we haven’t migrated to using that enforcement yet. Instead, we’ve used the JetBrains nullability annotations for years.[1]
Recently, I ended up with code that returned a null
even though R# was convinced that the value could never be null
.
The following code looks like it could never produce a null value, but somehow it does.
[NotNull] // The R# checker will verify that the method does not return null
public DynamicString GetCaption()
{
var result = GetDynamic() ?? GetString() ?? new DynamicString();
}
[CanBeNull]
private DynamicString GetDynamic() { … }
[CanBeNull]
private string GetString() { … }
So, here we have a method GetCaption()
whose result can never be null
. It calls two methods that may return null
, but then ensures that its own result can never be null by creating a new object if neither of those methods produces a string. The nullability checker in ReSharper is understandably happy with this.
At runtime, though, a call to GetCaption()
was returning null
. How can this be?
The Culprit: An Implicit Operator
There is a bit of code missing that explains everything. A DynamicString
declares implicit operators that allow the compiler to convert objects of that type to and from a string
.
public class DynamicString
{
// …Other stuff
[CanBeNull]
public static implicit operator string([CanBeNull] DynamicString dynamicString) => dynamicString?.Value;
}
A DynamicString
contains zero or more key/value pairs mapping a language code (e.g. “en”) to a value. If the object has no translations, then it is equivalent to null
when converted to a string
. Therefore, a null
or empty DynamicString
converts to null
.
If we look at the original call, the compiler does the following:
- The call to
GetDynamic()
sets the type of the expression toDynamicString
. - The compiler can only apply the
??
operator if both sides are of the same type; otherwise, the code is in error. - Since
DynamicString
can be coerced tostring
, the compiler decides onstring
for the type of the first coalesced expression. - The next coalesce operator (
??
) triggers the same logic, coercing the right half (DynamicString
) to the type it has in common with the left half (string
, from before). - Since the type of the expression must be
string
in the end, even if we fall back to thenew DynamicString()
, it is coerced to astring
and thus,null
.
Essentially, what the compiler builds is:
var result =
(string)GetDynamic() ??
GetString() ??
(string)new DynamicString();
The R# nullability checker sees only that the final argument in the expression is a new
expression and determines that the [NotNull]
constraint has been satisfied. The compiler, on the other hand, executes the final cast to string
, converting the empty DynamicString
to null
.
The Fix: Avoid Implicit DynamicString
-to-string
Conversion
To fix this issue, I avoided the ??
coalescing operator. Instead, I rewrote the code to return DynamicString
wherever possible and to implicitly convert from string
to DynamicString
, where necessary (instead of in the other direction).
public DynamicString GetCaption()
{
var d = GetDynamic();
if (d != null)
{
return d;
}
var s = GetString();
if (s != null)
{
return s; // Implicit conversion to DynamicString
}
return GetDefault();
}
Conclusion
The takeaway? Use features like implicit operators sparingly and only where absolutely necessary. A good rule of thumb is to define such operators only for structs
which are values and can never be null
.
I think the convenience of being able to use a DynamicString
as a string
outweighs the drawbacks in this case, but YMMV.
@NonNull
and @Nullable
annotations, although it’s unclear which standard you’re supposed to use. (StackOverflow)↩