Search code examples
c#.netcompiler-errorsnullreferenceexceptioncompiler-warnings

Why isn't obvious NullReferenceException a compile-time error?


I find it surprising that nobody has asked this question before, but why does the compiler limit itself to just a warning in such a flagrant case?

object obj = null;
string str = obj.ToString(); //must be a compile-time error?

I especially fail to understand it after decompiling the code and seeing this:

((object) null).ToString();

I've checked similar (yet different) SO questions (1 and 2), and the answers boil down to "it's not possible to check the value of a variable at compile-time".
But why isn't it possible? What am I missing here? Can the compiler see the result it produced?


Solution

  • The short answer to why it is a warning and not an error is that null reference analysis is not infallible and cannot easily take into account parallel or indirect references. The code itself satisfies all the compiler rules for compilation and execution. The fact that the execution fails at runtime is not necessarily an error, it is only an exception that your application may or may not handle.

    • The developer might specifically want to induce a runtime error.

    The compiler can't assume that the developer doesn't know that this will result in a runtime error. Once we start doing that what other assumptions might we get wrong?

    Errors are for genuine code violations. The fact that the value of a variable is null requires specific knowledge about the expected state of that variable at runtime. Only at the time of executing obj.ToString() can we know with certainty that the value of obj is null, everything else up to that point is an assumption at best. Everything else that the compiler might have opinions about like conventions and code styles become warnings or notes. It is up to each individual developer or team to decide if they want to promote specific warnings to error status or if you treat all warnings as errors.


    It might help to think about this from another perspective. Your example represents 2 lines of code that happen to exist next to each other in your script today:

    object obj = null;
    string str = obj.ToString();
    

    During design time it is conceivable that more code might come to exist between these two statements, the net effect of this is that the additional logic might set a non-null value to obj:

    object obj = null;
    ...
    obj = "hello world!";
    ...
    string str = obj.ToString();
    

    But what about parallel processing, what if obj were set from a different logic block running in parallel, such that between instantiation of obj and the call to obj.ToString() the value was set outside of the code we see here?

    ... What if the method being called was an extension method? Extension methods after all can be called from null references.

    .ToString() is only obvious that it will raise a runtime error because it is a member declared method and if we can be sure that the object has not yet been initialized. There are a lot of possibilities to check for and in a large code base this effort needs to be expended for every variable and every invocation. This type of functionality was not in the original versions of the C# compiler.

    20 years ago, even if we could, the effort to do this level analysis on code on standard hardware would have significantly reduced developer productivity. Good SDLC practices like testing your code before release would pick these types of issues up anyway, to get this level of checking from the compiler was simply not a priority.

    Fast forward to today, the compiler has evolved and the devs have squashed all the bugs and have been able to focus on runtime and productivity improvements. One such improvement is Nullable Reference Type analysis. This pretty much covers checking for your scenario and really complex ones by establishing a new set of conventions that developers need to abide by.

    If you are interested in migrating old code to NRT then refer to Update a codebase with nullable reference types to improve null diagnostic warnings but beware, many existing code bases will have thousands of warnings the first time you turn this on. If you're OK with ignoring warnings, then I'm not sure why you would bother, for the rest of us who treat warnings as errors you will have a lot of code to review and fix or annotate.

    I'm going to call out a specific comment to why I need to enable NUllable element in the csporj to use nullable reference types?, this comment by Jeroen Mostert is the best summary of Why this isn't an error even if NRT means we can reasonably assume that it is an error: (emphasis is mine)

    As to it not making any difference, that's by design. NRTs were specifically and carefully crafted to have minimal impact on existing code except where you explicitly opt-in, so as to make sure the feature can actually add value and be gradually integrated with existing codebases, rather than an all-or-nothing approach that wouldn't see adoption because nobody wants to fix 200 warnings up front in code that was previously working just fine. This is also why, e.g., writing string? where NRTs are not enabled is not flat-out considered an error.

    If you missed it back in 2019, you should read Embracing nullable reference types. There are many of us who are yet to fully embrace this personally and many more legacy code bases that are stable enough or have sufficient robust testing such that there is less or no value in rewriting the codebase to take advantage of NRT, in fact to do so might pose significant risk to the products because we should retest everything that has changed before releasing with the updated code.