First steps with nullable reference types
This blog post is effectively a log of my experience with the preview of the C# 8 nullable reference types feature.
There are lots of caveats here: it's mostly "as I go along" so there may well be backtracking. I'm not advising the right thing to do, as I'm still investigating that myself. And of course the feature is still changing. Oh, and this blog post is inconsistent about its tense. Sometimes I write in the present tense as I go along, sometimes I wrote in the past tense afterwards without worrying about it. I hope this isn't/wasn't/won't be too annoying.
I decided that the best way of exploring the feature would be to try to use it with Noda Time. In particular:
- Does it find any existing bugs?
- Do my existing attributes match what Roslyn expects?
- Does the feature get in the way, or improve my productivity?
I started at the preview page on GitHub. There are two really important points to note:
- Do this on a non-production machine. I used an old laptop, but presumably you can use a VM instead.
- Uninstall all versions of Visual Studio other than VS2017 first
I ended up getting my installation into a bad state, and had to reinstall VS2017 (and then the preview) before it would work again. Fortunately that takes a lot less time than it used to.
Check it worksThe preview does not work with .NET Core projects or the command-line csc
It's only for old-style MSBuild projects targeting the .NET framework, and only from Visual Studio.
So to test your installation:
- Create a new .NET Framework desktop console app
- Edit Program.cs to include: string? x = null;
- Build
If you get an error CS0453 ("The type 'string' must be a non-nullable value type"") then it's not working. If it builds with maybe a warning about the variable not being used, you're good to go.
First steps with Noda TimeThe first thing I needed to do was convert Noda Time to a desktop project. This didn't require the preview to be installed, so I was able to do it on my main machine.
I created a new solution with three desktop projects (NodaTime, NodaTime.Test and NodaTime.Testing), and added the dependencies between the projects and external ones. I then copied these project files over the regular Noda Time ones.
Handy tip: if you add <Compile Include="**\*.cs" /> in an MSBuild file and open it in Visual Studio, VS will replace it with all the C# files it finds. No need for tedious "Add existing" all over the place.
A small amount of fiddling was required for signing and resources, and then I had a working copy of Noda Time targeting just .NET 4.5. All tests passed :)
For anyone wanting to follow my progress, the code is in a branch of my fork of Noda Time although I don't know how long I'll keep it for.
Building with the previewAfter fetching that branch onto my older laptop, it built first time - with 228 warnings, most of which were "CS8600: Cannot convert null to non-nullable reference." Hooray - this is exactly what we want. Bear in mind that this is before I've made any changes to the code.
The warnings were split between the three projects like this:
- NodaTime: 94
- NodaTime.Testing: 0
- NodaTime.Test: 134
Noda Time already uses [CanBeNull] and [NotNull] attributes for both parameters and return types to indicate expectations. The first obvious step is to visit each application of [CanBeNull] and use a nullable reference type there.
To make it easier to see what's going on, I first unloaded the NodaTime.Test project. This was so that I could concentrate on making NodaTime self-consistent to start with.
Just doing that actually raised the number of warnings from 94 to 110. Clearly I'm not as consistent as I'd like to be. I suspect I've got plenty of parameters which can actually be null but which I didn't apply the annotation to. It's time to start actually looking at the warnings.
Actually fix thingsI did this in a completely haphazard fashion: fix one warning, go onto another.
I've noticed a pattern that was already feasible before, but has extra benefits in the nullable reference type world. Instead of this:
// Old codestring id = SomeMethodThatCanReturnNull();if (id == null){ throw new SomeException();}// Use id knowing it's not null
" I can use the ?? operator with the C# 7 feature of throw expressions:
// Old codestring id = SomeMethodThatCanReturnNull() ?? throw new SomeException();// Use id knowing it's not null
That avoids having a separate local variable of type string?, which can be very handy.
I did find a few places where the compiler could do better at working out nullity. For example:
// This is finestring? x = SomeExpressionThatCanReturnNull();if (x == null){ return;}string y = x;// This creates a warning: the compiler doesn't know that x// can't be null on the last linestring? x = SomeExpressionThatCanReturnNull();if (ReferenceEquals(x, null)){ return;}string y = x;
The preview doc talks about this in the context of string.IsNullOrEmpty; the ReferenceEquals version is a little more subtle as we can't determine nullity just from the output - it's only relevant if the other argument is a constant null. On the other hand, that's such a fundamental method that I'm hopeful it'll get fixed.
Fixing these warnings didn't take very long, but it was definitely like playing Whackamole. You fix one warning, and that causes another. For example, you might make a return type nullable to make
a return null; statement work - and that affects all the callers.
I found that rebuilding would sometimes find more warnings, too. At one point I thought I was done (for the time being) - after rebuilding, I had 26 warnings.
I ran into one very common problem: implementing IEquatable<T> (for a concrete reference type T). In every case, I ended up making it implement IEquatable<T?>. I think that's the right thing to do" (I didn't do it consistently though, as I found out later on. And IEqualityComparer<T> is trickier, as I'll talk about later.)
Reload the test projectSo, after about an hour of fixing warnings in the main project, what would happen when I reload the test project? We previously had 134 warnings in the test project. After reloading" I was down to 123.
Fixing the test project involved fixing a lot more of the production code, interestingly enough. And that led me to find a limitation not mentioned on the preview page:
public static NodaFormatInfo GetInstance(IFormatProvider? provider){ switch (provider) { case null: return ...; case CultureInfo cultureInfo: return ...; case DateTimeFormatInfo dateTimeFormatInfo; return ...; default: throw new ArgumentException($"Cannot use provider of type {provider.GetType()}"); }}
This causes a warning of a possible dereference in the default case - despite the fact that provider clearly can't be null, as otherwise it would match the null case. Will try to provide a full example in a bug report.
The more repetitive part is fixing all the tests that ensure a method throws an ArgumentNullException if called with a null argument. As there's no compile-time checking as well, the argument
needs to be null!, meaning "I know it's really null, but pretend it isn't." It makes me chuckle in terms of syntax, but it's tedious to fix every occurrence.
I have discovered an interesting problem. It's hard to implement IEqualityComparer<T> properly. The signatures on the interface are pretty trivial:
public interface IEqualityComparer<in T>{ bool Equals(T x, T y); int GetHashCode(T obj);}
But problems lurk beneath the surface. The documentation for the Equals() method doesn't state what should happen if x or y is null. I've typically treated this as valid, and just used the normal equality rules (two null references are equal to each other, but nothing else is equal to a null reference.) Compare that with GetHashCode(), where it's explicitly documented that the method should throw ArgumentNullException if obj is null.
Now think about a type I'd like to implement an equality comparer for - Period for example. Should I write:
public class PeriodComparer : IEqualityComparer<Period?>
This allows x and y to be null - but also allows obj to be null, causing an ArgumentNullException, which this language feature is trying to eradicate as far as possible.
I could implement the non-nullable version instead:
public class PeriodComparer : IEqualityComparer<Period>
Now the compiler will check that you're not passing a possibly-null value to GetHashCode(), but will also check that for Equals, despite it being okay.
This feels like it's a natural but somewhat unwelcome result of the feature arriving so much later than the rest of the type system. I've chosen to implement the nullable form, but still throw the
exception in GetHashCode(). I'm not sure that's the right solution, but I'd be interested to hear what others think.
One of the things I was interested in finding out with all of this was how consistent Noda Time is in terms of its nullity handling. Until you have a tool like this, it's hard to tell. I'm very pleased to say that most of it hangs together nicely - although so far that's only the result of getting down to no warnings, rather than a file-by-file check through the code, which I suspect I'll want to do eventually.
I did find two bugs, however. Noda Time tries to handle the case where TimeZoneInfo.Local returns a null reference, because we've seen that happen in the wild. (Hopefully that's been fixed now in Mono, but even so it's nice to know we can cope.) It turns out that we have code to cope with it in one place, but there are two places where we don't" and the C# 8 tooling found that. Yay!
Found a bug in the preview!To be clear, I didn't expect the preview code to be perfect. As noted earlier, there are a few places I think it can be smarter. But I found a nasty bug that would hang Visual Studio and cause csc.exe to fail when building. It turns out that if you have a type parameter T with a constraint of T : class, IEquatable<T?>, that causes a stack overflow. I've reported the bug (now filed on GitHub thanks to diligent Microsoft folks) so hopefully it'll be fixed long before the final version.
Admittedly the constraint is interesting in itself - it's not necessarily clear what it means, if T is already a nullable reference type. I'll let smarter people than myself work that out.
ConclusionWell, that was a jolly exercise. My first impressions are:
- We really need class library authors to embrace this as soon as C# 8 comes out, in order to make it as useful as possible early. Noda Time has no further dependencies, fortunately.
- It didn't take as long as I feared it might to do a first pass at annotating Noda Time, although I'm sure I missed some things while doing it.
- A few bugs aside, the tooling is generally in very good shape; the warnings it produced were relevant and easy to understand.
- It's going to take me a while to get used to things like IList<string?>? for a nullable list of nullable strings.
Overall I'm very excited by all of this - I'm really looking forward to the final release. I suspect more blog posts will come over time"