The .NET type system, or the Common Type System (CTS), categorizes types into two groups. The first is value types, where a value represents an instance itself, and any assignment of that value makes a new copy of the instance. Types such as int
, float
, boolean
, or System.DateTime
are examples of value types. The second is reference types, where a value is a reference to an instance allocated in the managed heap. Types such as string
or object
are examples.
As Java allows to assign null
to a class type variable, C# also allows assigning null
to a reference type variable.
void ToUpperAndPrint(string s) {
s = s.ToUpper();
Console.WriteLine(s);
}
string s1 = "Hello";
ToUpperAndPrint(s1); // HELLO
string s2 = null; // Ok, string is a reference type
ToUpperAndPrint(s2); // NullReferenceException in ToUpperAndPrint
This requires the programmer to make a separate logic for checking whether a value is null
or not as below, and that increases the chance of unintentional null
-related errors.
void ToUpperAndPrint(string s) {
if (s == null)
return;
s = s.ToUpper();
Console.WriteLine(s);
}
To solve this problem, C# 8.0 introduced null-state analysis, which determines whether a reference type value can be null
or not at compile time and emits warnings or errors if there are any nullability mismatches. As shown below, if the compiler encounters #nullable enable
at the top of a file, it performs nullability checks for that file.
#nullable enable
void ToUpperAndPrint(string s) {
s = s.ToUpper();
Console.WriteLine(s);
}
string s1 = "Hello";
ToUpperAndPrint(s1); // HELLO
string s2 = null; // Warning: nullability mismatch; s2 is not nullable
ToUpperAndPrint(s2); // NullReferenceException in ToUpperAndPrint
string? s3 = null; // Ok, s3 is nullable
ToUpperAndPrint(s3); // nullability mismatch; s3 is nullable
Thanks to null-state analysis, by postfixing ?
to explicitly specify that the variable can hold null
, we can reduce the bug due to NullReferenceException
dramatically.
From C# 10 (.NET 6), this feature is enabled by default, even without #nullable enable
.
The null-state analysis is based on NullableAttribute
and NullableContextAttribute
, which are the members of System.Runtime.CompileServices
. However, they are not defined in System.Private.CoreLib.dll
(or mscorlib.dll
from the .NET Framework) where the basic .NET types are implemented, but they are generated as internal
classes by the compiler, hence not accessible from C# code directly. (For a detailed description, please refer here.)
For example, the following code with a class User
with a nullable property;
namespace MyService;
public class User {
public string Name { get; set; }
public User? Partner { get; set; }
}
actually interpreted as below:
namespace System.Runtime.CompilerServices {
internal class NullableAttribute : Attribute {
public readonly byte[] NullableFlags;
public NullableAttribute(byte b) { NullableFlags = new byte[1] { b }; }
public NullableAttribute(byte[] bytes) { NullableFlags = bytes; }
}
internal class NullableContextAttribute : Attribute {
public readonly byte Flag;
public NullableContextAttribute(byte b) { Flag = b; }
}
}
namespace MyService {
public class User {
public string Name { get; set; }
[System.Runtime.CompilerServices.Nullable(2)]
private User _partner;
[System.Runtime.CompilerServices.Nullable(2)]
public User Partner {
[System.Runtime.CompilerServices.NullableContext(2)]
get => _partner;
[System.Runtime.CompilerServices.NullableContext(2)]
set => _partner = value;
}
}
}
Note that both NullableAttribute
and NullableContextAttribute
are internal, which makes them inaccessible both from the library where they’re included and from other assemblies referencing the library.
You may wonder what that constant 2 means in the code. As you see above, NullableAttribute
contains an array of bytes and NullableContextAttribute
have a byte. Each byte can be one of 0, 1, and 2, where