Embrace the Nullable Reference
Since C#8, we’ve been able to get compile-time insights about when a reference-type variable might be null. As classes abound in C#, this analyzer feature is a fantastic boon. A myriad of articles describe it. Perhaps you’ve even read Mads Torgerson’s original Embracing Nullable Reference Types. (I wanted to call my article ‘Just Embrace Nullable References Already, Jeez’, but opted for less aggression.)
Do we need yet another article? I wept in a code-review seeing the following in a .csproj. So, … apparently, we do.
<! — <Nullable>enable</Nullable> -->
Why? Why?!? A few possibilities came to mind:
a) Confusion about interoperability.
b) Fear of massive overhaul needed on existing components.
c) Unawareness of hint-attributes for reducing noise.
d) Lack of patterns for common scenarios.
e) ‘Warnings Crutch’ fear.
Interop?
One typically declares a variable to be a nullable by suffixing the type with a ‘?’. Person you
cannot be null, Person? roommate
might be null. (This is a nicely consistent syntax, even w/ pre-existing language features like null-conditional ?.
and System.Nullable<value-type>.)
public class Program
{
public string WhoIs( Person? whom )
=> whom?.FirstName ?? "<null>";
}
The compiler ‘lowers’ the Person?
arg this way:
public string WhoIs(
[System.Runtime.CompilerServices.Nullable(2)] Person whom)
{
return ((whom != null) ? whom.FirstName : null) ?? "<null>";
}
(via http://sharplab.io, a tool a Nick Chapsas YouTube vid turned me on to.)
From our WhoIs(…)
lowering, you’ll be delighted to learn Person?
is not a distinct type, rather it’s a plain Person
type with an attribute applied to the argument. Methods/delegates/lambdas resolve agnostic of the ‘?’. This seems consistent with the CS0111 error one gets trying to overload method F(string)
with an F(string?)
.
(Sharplab.io further implies the the Nullable
attribute itself is compiler-generated, which, if so, I guess could help downlevel clients consume your NuGet or something)
namespace System.Runtime.CompilerServices
{
[CompilerGenerated]
[Microsoft.CodeAnalysis.Embedded]
...
internal sealed class NullableAttribute : Attribute
{ ... }
(Maarten Balliauw peeked a bit at the attrib flags here)
Over-the-wire? If you add nullability to existing an existing POCO (plain-old-C#-object), System.Text.Json or Newtsonsoft will emit the same JSON as before. No back-compat breaking change to your customers/partners/workflows. The de-serializer also works the same (and we’ll touch more on how to fine-tune the compiler-warnings later)
So, the interop excuse is put to rest!
Massive Overhaul?
Assemblies A-Sapien referencing B-Troglodyte referencing C-Sapien (nullable-aware, -oblivious, and -aware respectively) can call (2nd-degree) dependencies just fine. The question now is how hard is it to update B-Troglodyte.csproj to <Nullable>enable</Nullable>
?
Try just that change. Count the warnings and where they occur. Then attack the files in steps (e.g. sorted by git churn-rate or fan-in or something). Particular files can be adorned with #nullable disable
(or regions within the file bracketed by #nullable restore
or #nullable enable
) until you’re able to tackle them. (You can also a la carte, e.g. annotations on but warnings off, etc. … but the potential for confusion seems unattractive to me.)
Make a policy in your org that all new files have nullable-references enabled (via .csproj’s setting or explicit directive like above). As a target-of-opportunity, devs should shrink #nullable disable’s scope on each file edit.
I believe generated-code, by default, ignores the csproj’s settings; so don’t forget you may need to write your generator to emit #nullable enable.
So, the massive-overhaul can be smoothed out with directives.
Distracting Noise?
What about noisy false-positives? For starters, you do have the null-forgiving operator, given by the ‘!’ suffix. Once applied, if the var isn’t re-assigned later, everything below treats the null as having been checked-for. It works on expressions, too.
private string? GetIfOdd(int i) => (i % 2)==1 ? "hi" : null;
private string? GetIfEven(int i) => (i % 2)==0 ? "hi" : null;
...// We know one of the two calls must yield a good instance.
string msg = (GetIfOdd(n) ?? GetIfEven(n))!;
But the analyzer’s taking our word for it at this point. Better if we can express this more declaratively and move the knowledge to toward the source, inspect-ably correct and encapsulated.
Nullability-attributes tell the analyzer how nulls flow. This improves the signal-to-noise ratio for all clients of your code and makes it more self-describing (which appeals to even the ‘comments-are-misleading’ crowd.)
[return: NotNullWhenNotNull("input")]
public string? Normalize( string? input ) { ... }public bool TryGet(
string key,
[NotNullWhen(true)] out Thingie? thingie) { ... }[AllowNull]
public string Category
{
get => _category;
set => _category = (value ?? "General");
} public Do([NotNull] ref T val) { ... }
Above, we claim Normalize yields an instance if input was one. In the second example, the NotNullWhen
better clarified the out thingie
as will be an instance if TryGet returns true. In the third, the setter will accept a null (because the code re-interprets this as a safe default), but the getter will never return null to you. Lastly, val might have been passed-in as null, but points to an instance on Do’s return.
Attributes come in especially handy for clarifying generic type-parameters, for which the ‘?’ suffix (oddly) doesn’t really work correctly. The MaybeNull… attribs seem especially apt for generic-types, but others apply too.
public class Wrapper<T>
{
[DisallowNull] public T Value { get; set; }
}... Wrapper<string> wstr = new() { Value = null }; // warning CS8625
Wrapper<int> wi = new() { Value = ... }; // still works
Or even better to apply generic-constraints, e.g. class Wrapper<T> where T : notnull {...}
or where T: class?
Of course there’s still all the tricks you already knew how to use: Method() ?? “some safe default”
; a discard case switch { …, _ => fallbackExpr}
or case null: ...
etc.
Finally, of course, .editorconfig can dial-down the nullability severity on a per-project basis, too. But I find no one ever dials ’em back up again, instead piling on new issues that should have gotten protections.
So, reduce noise with well-doc’d mechanisms above.
Patterns?
The Deserializer Problem
One dilemma I’ve seen is with data-transfer objects, which are typically populated by de-serialization (from an RPC, a data-stream, etc.). The dev wants to say some fields always have objects, but faces warnings none are initialized (null), but if they change to string?
, etc., it’s not expressing the real expectation.
Just init in the ctor? Some deserializers manage to skip calling the default-ctor (e.g. via RuntimeHelpers.GetUnitializedObject(Type) or FormatterServices)! It’s also not assured all possible serializer NuGets you may find will honor the DataAnnotations.Required
attribute.
Happily, an attribute could help you if you wished to add an IsValid
method to your POCO.
public class MyPoco
{
public string? Alpha { get; set; }
public string? Beta { get; set; }
public string? Gamma { get; set; } // really is optional [MemberNotNullWhen(true, nameof(Alpha)),
MemberNotNullWhen(true, nameof(Beta))]
public bool IsValid
=> Alpha is not null && Beta is not null;
}
...MyPoco deserialized = SomeBespokeDeserializer<MyPoco>(...);if (!deserialized.IsValid) throw ...
string notNullValue = deserialized.Alpha; // no warning
Yes, awareness that IsValid was called won’t flow deeper into the callstack; you’d have to repeat the IsValid call each time. (Memoizing its result would be overkill unless you also do way more validation work than null-checks.)
I’m loathe to suggest this, but know many people who’ve used it: you can null-forgive null
itself, e.g public string PrimaryKey { get; set; } = null!;
to get around the CS8618 warning. Then it’s a gilt-edge priority to have some equivalent of IsValid/throw story… lest your lies catch up to you … from the live-site pager going off.
Init Property Pattern
Devs seem to love object-initializer syntax over constructor parameters. Callers might skip a required-property though. You can provide twin ctors.
public class Foo
{
public Foo() => RequiredField = string.Empty;
public Foo(string f) => RequiredField = f; public string RequiredField { get; init; }
public string? OptionalField { get; init; }
}
...
Foo myFoo =
new Foo("required-val")
{
OptionalField = "optional-val",
};
While we could have done public string RequiredField { get; init; } = string.Empty;
it executes before any ctor or the init list, so will incur a (negligible) wasted assignment if the caller makes one. (I don’t like to just leave it to the optimizer to figure-out that’s skippable, even if confident the ctor should be inlined.)
Warnings Crutch?
Someone might claim over-reliance on nullable-ref warnings could lull some dev into a false sense of security to skip param validation. For public classes’ public methods (potentially called from nullable-oblivious assemblies or with a mistaken null-forgiving stringFromInternet!
), you should maintain diligence. Declare don’t expect me to like null and then don’t: public DoIt( Foo f ) { f ??= throw new ArgumentNullException...
All the other use-cases — return-values, out-params, fields within classes — are much more self-describing meanwhile.
So ‘warnings-crutch’ is a bad metaphor, try ‘flashlight’.
Conclusion
Should you or your team have had mixed/ambivalent emotions about adopting nullable-references, hopefully seeing some techniques re-entices you to the idea. And if you see a code-review deleting <Nullable> from a csproj, you’ll feel emboldened to ask “why?” And “what have you tried?”
Do share your thoughts and any patterns you think devs should apply to help their use of nullables go more smoothly. And thanks for reading.