Help the Compiler, and the Compiler Will Help You: Subtleties of Working With Nullable Reference Types in C#
Readers will learn about several non-obvious nullable reference-type features. By the end, users will know how to make applications more secure and correct.
Join the DZone community and get the full member experience.
Join For FreeNullable reference types appeared in C# three years ago. By this time, they found their audience. But even those who work with this “beast” may not know all its capabilities. Let’s figure out how to work with these types more efficiently.
Introduction
Nullable reference types are designed to help create a better and safer application architecture. At the code writing stage, it is necessary to understand whether this or that reference variable can be null
or not, whether the method can return null
, and so on.
It is safe to say that every developer has encountered NRE (NullReferenceException
). Because this exception can be generated at the development stage, is a good scenario because you can fix the problem immediately. It is much worse when the user finds the problem when working with the product. Nullable reference types help protect against NRE.
In this article, I will talk about a number of non-obvious features related to nullable reference types. But it’s worth starting with a brief description of these types.
Nullable Reference
In terms of program execution logic, a nullable reference type is no different from a reference type. The difference between them is only in the specific annotation that the first one has. The annotation allows the compiler to conclude whether a particular variable or expression can be null
. To use nullable reference types, you need to make sure the nullable context is enabled for the project or file (I will describe later how to do this).
To declare a nullable reference variable, add ‘?’ at the end of the type name.
Example:
string? str = null;
Now the variable str
can be null
, and the compiler will not issue a warning for this code. If you don't add ‘?’ when declaring a variable and assigning it with null
, a warning will be issued.
It is possible to suppress compiler warnings about possible writing null
to a reference variable that is not marked as nullable.
Example:
object? GetPotentialNull(bool flag)
{
return flag ? null : new object();
}
void Foo()
{
object obj = GetPotentialNull(false);
}
The obj
variable will never be assigned with null
, but the compiler does not always understand this. You can suppress the warning as follows:
object obj = GetPotentialNull(false)!;
Using the ‘!’ operator, we “tell” the compiler that the method will definitely not return null
. Therefore, there will be no warnings for this code fragment.
The functionality available when working with nullable reference types is not limited to declaring variables of that type (using ‘?’) and suppressing warnings with ‘!.’ Below, I’ll look at the most interesting features when working with nullable reference types.
Working With a Nullable Context
There are a number of mechanisms for more flexible work with nullable reference types. Let’s look at some of them.
Working With Attributes
Attributes can be used to tell the compiler the null-state of various elements. Let’s look at the most interesting ones.
Note: check out Attributes for null-state static anaylsis interpret by the C# compiler to find the full list of attributes.
To make it easier, let’s introduce the term—null-state. The null-state is information about whether a variable or expression can be null
at a given time.
AllowNull
Let’s look how the attribute work. Here is an example:
public string Name
{
get => _name;
set => _name = value ?? "defaultName";
}
private string _name;
If you write the null
value to the Name
property, the compiler will issue a warning: “Cannot convert null literal to non-nullable reference type.” But you can see from the implementation of the property that it can be null
. In this case, the defaultName
string is assigned to the _name
field.
If you add ‘?’ to the property type, the compiler will assume that:
- The set accessor can accept
null
(this is correct). - The get accessor can return
null
(this is an error).
For correct implementation, it is worth adding the AllowNull
attribute to the property:
[AllowNull]
public string Name
After that, the compiler will assume that Name
may be assigned with null
, although the property’s type is not marked as nullable. If you assign the value of this property to a variable that should never be null
, then there will be no warnings.
NotNullWhen
Suppose we have a method that checks a variable for null
. Depending on the result of this check, the method returns a value of the bool
type. This method informs us about the null-state of the variable.
Here’s a synthetic code example:
bool CheckNotNull(object? obj)
{
return obj != null;
}
This method checks the obj
parameter for null
and returns a value of the bool
type depending on the check result.
Let’s use the result of this method in the condition:
public void Foo(object? obj1)
{
object obj2 = new object();
if (CheckNotNull(obj1))
obj2 = obj1;
}
The compiler will issue a warning to code above: “Converting null literal or possibly null value to non-nullable type.” But such a scenario is impossible, since the condition guarantees that obj1
is not null
in the then branch. The problem is that the compiler doesn’t understand this, so we have to help it.
Let’s change the signature of the CheckNotNull
method by adding the NotNullWhen
attribute:
bool CheckNotNull([NotNullWhen(true)]object? obj)
This attribute takes a value of the bool
type as the first argument. With NotNullWhen
, we link the null-state of the argument with the return value of the method. In this case, we “tell” the compiler that if the method returns true
, the argument has a value other than null
.
There is a peculiarity associated with this attribute.
Here are some examples:
Using the out modifier:
bool GetValidOrDefaultName([NotNullWhen(true)] out string? validOrDefaultName,
string name)
{
if (name == null)
{
validOrDefaultName = name;
return true;
}
else
{
validOrDefaultName = "defaultName";
return false;
}
}
Here, the compiler will issue a warning: “Parameter validOrDefaultName
must have a non-null value when exiting with true
.” It is quite reasonable, since ‘==’ is used in the condition instead of the ‘!=’ operator. In this implementation, the method returns true
when validOrDefaultName
is null
.
Using the ref modifier:
bool SetDefaultIfNotValid([NotNullWhen(true)] ref string? name)
{
if (name == null)
return true;
name = "defaultName";
return false;
}
We will also get a warning for this code fragment: “Parameter name
must have a non-null value when exiting with true
.” Similarly to the previous example, the warning is reasonable. ‘==’ is used instead of the ‘!=’ operator.
Without using a modifier:
bool CheckingForNull([NotNullWhen(true)] string? name)
{
if (name == null)
return true;
Console.WriteLine("name is null");
return false;
}
The situation here is similar to previous cases. If name
equals null
, the method returns true
. Following the logic of previous examples, a warning should also be issued here: “Parameter name
must have a non-null value when exiting with true
.” However, there is no warning. It’s hard to say what caused this, but it looks strange.
NotNullIfNotNull
This attribute allows you to establish a relationship between the argument and the return value of the method. If the argument is not null
, the return value is also not null
, and vice versa.
Example:
public string? GetString(object? obj)
{
return obj == null ? null : string.Empty;
}
The GetString
method returns null
or an empty string, depending on the null-state of the argument.
Usage of this method:
public void Foo(object? obj)
{
string str = string.Empty;
if(obj != null)
str = GetString(obj);
}
Compiler’s warning for this code: “Converting null literal or possibly null value to non-nullable type.” In this case, the compiler is lying. Assignment is performed in the body of if
, the condition of which guarantees that GetString
will not return null
. To help the compiler, let’s add the NotNullIfNotNull
attribute for the return value of the method:
[return: NotNullIfNotNull("obj")]
public string? GetString(object? obj)
Note: Starting with C#11, you can get the parameter name using the nameof
expression. In this case, it would be nameof(obj)
.
The NotNullIfNotNull
attribute takes the value of the string type as the first argument — the name of the parameter, based on which the null-state of the return value is set. Now the compiler has information about the relationship between obj
and the return value of the method: if obj
is not null
, the return value of the method will not be null
, and vice versa.
MemberNotNull
Let’s start with an example:
class Person
{
private string _name;
public Person()
{
SetDefaultName();
}
private void SetDefaultName()
{
_name = "Bob";
}
}
The compiler will issue a warning to this code fragment: “Non-nullable field _name
must contain a non-null value when exiting constructor. Consider declaring the field as nullable.” However, the SetDefaultName
method is called in the constructor’s body, which initializes the only field of the class. This means the compiler’s message is false. The MemberNotNull
attribute allows you to solve the problem:
[MemberNotNull(nameof(_name))]
private void SetDefaultName()
This attribute takes an argument of the string[]
type with the ‘params’ keyword. The strings need to match the names of the members that are initialized in the method.
Thus, we are indicating that the value of the _name
field will not be null
after this method is called. Now the compiler can understand that the field is initialized in the constructor.
MemberNotNullWhen
Let’s look at the example:
class Person
{
static readonly Regex _nameReg = new Regex(@"^I'm \w*");
private string _name;
public Person(string name)
{
if (!TryInitialize(name))
_name = "invalid name";
}
private bool TryInitialize(string name)
{
if (_nameReg.IsMatch(name))
{
_name = name;
return true;
}
else
return false;
}
}
TryInitialize
will initialize _name
if the argument’s value matches some pattern. The method returns true
when the field has been initialized, otherwise it returns false
. Depending on the result of executing TryInitialize
, a value is assigned to the _name
field in the constructor. In this implementation, _name
cannot be not initialized in the constructor. However, the compiler will issue a warning: “Non-nullable field _name
must contain a non-null value when exiting constructor. Consider declaring the field as nullable.”
To fix the situation, you need to add the MemberNotNullWhen
attribute:
[MemberNotNullWhen(true, nameof(_name))]
private bool TryInitialize(string name)
The type of the first argument is bool
, the second argument’s type is string[]
(with the ‘params’ keyword). The attribute is used for methods with a return value of the bool
type. The logic is simple: if the method returns a value that corresponds to the first argument of the attribute, the class members passed to ‘params’ will be considered initialized.
DoesNotReturn and DoesNotReturnIf
It is not uncommon to have to create methods that throw out exceptions if something has not gone according to plan. Unfortunately, the compiler cannot always understand that program execution will be terminated after such a method is called.
Example:
private void ThrowException()
{
throw new Exception();
}
void Foo(string? str)
{
if (str == null)
ThrowException();
string notNullStr = str;
}
For the code above, the compiler will issue a warning: “Converting null literal or possibly null value to non-nullable type.” However, if str
is null
, the execution of the method will not reach the code fragment with the assignment, as an exception will be thrown. Thus, at the time of assignment, the str
variable cannot be null
.
The DoesNotReturn
attribute allows you to tell the compiler that after executing the method marked with the attribute, the execution of the calling method stops.
Let’s add the attribute for the throwException
:
[DoesNotReturn]
private void ThrowException()
Now the compiler knows that after this method is called, control will not be returned to the calling method. Therefore, null
will never be written to notNullStr
.
The DoesNotReturnIf
attribute works similarly to DoesNotReturn
, except for checking an additional condition.
Example:
private void ThrowException([DoesNotReturnIf(true)] bool flag)
{
if(flag)
throw new Exception();
}
The compiler will assume that throwException
will not return control to the calling method if the flag
parameter is set to true
.
Specifying Context at the Project Level
To change the nullable context at the project level, you need to open the project properties and select the context in the “Build” section.
You can set the nullable context in the project file (.csproj
). You need to open this file and write the value to the Nullable
property:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net6.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>disable</Nullable> //<=
</PropertyGroup>
</Project>
It is likely that many people know you can turn on or turn off the nullable context. However, there are two more context options.
Warnings
Behavior in the nullable warning context:
- The ‘?’ sign does not affect the analysis in any way.
- From the point of view of the compiler, all values of the reference type can be
null
by default. - If you write the ‘?’ sign, the compiler will issue a warning that it should not be used in this context.
- The compiler will issue a warning only for those parts of code where the
null
reference is dereferenced. - You can indicate that the expression is not
null
using the ‘!’ operator.
This mode helps protect against exceptions of NullReferenceException
type. The mode informs about the dereference of null reference.
Annotations
Behavior in the nullable annotation context:
- There are no warnings related to dereference of
null
references and errors when working with nullable reference. - The compiler does not issue warnings when ‘?’ and ‘!’ are used.
This mode helps make a smooth entry into the use of nullable reference types in the project. It allows you to markup variables that can and cannot be null
.
Working With Preprocessor Directives
Preprocessor directives are used at the file level with the .cs
extension and allow you to change the states of the nullable context for fragments of code in this file. The way it works is similar to that described in the previous section. Each directive starts with ‘#’:
Let’s look at all possible directives:
- #nullable disable: disables the nullable context.
- #nullable enable: enables nullable context.
- #nullable restore: restores the nullable context to its value at the project level.
- #nullable disable annotations: disables annotation context.
- #nullable enable annotations: enables annotation context.
- #nullable restore: restores the nullable context to its value at the project level.
- #nullable disable warnings: disables the warning context.
- #nullable enable warnings: enables the warning context.
- #nullable restore warnings: restores the warning context to its value at the project level.
In fact, the enable
value represents the enabled context of annotations and the context of warnings, and disable
– on the contrary, these same contexts are in the disabled state. So the ‘#nullable enable’ directive would be equivalent to writing ‘#nullable enable annotations’ and ‘#nullable enable warnings’ together.
You can use multiple directives in one file at once. This allows you to set a different nullable context for different code fragments.
Let’s look at an example of such usage (at the project level, nullable-context is disabled):
.... // nullable-context is disabled in this code fragment
#nullable enable warnings
.... // the warning context is enabled in this code fragment
#nullable enable annotations
.... // the context of warnings and annotations is enabled
// in this code fragment
#nullable disable annotations
.... // only the warning context is enabled in this code fragment
#nullable restore
.... // nullable-context is disabled in this code fragment
// (since the Nullable property – disable)
Conclusion
In conclusion, being able to use nullable reference types should be of great benefit to developers. These types allow you to make the application more secure and correct from the point of view of architecture.
This mechanism is not without its drawbacks either. The ability to add attributes makes sense largely because of the imperfection of the static analyzer. Therefore, it is necessary to add annotations to methods, fields, etc., manually because the analyzer cannot understand some relationships. For example, the relationship between the return value of a method and the null state of a variable.
A number of drawbacks are the result of insufficient in-depth analysis. Such analysis cannot be done on the fly. On the other hand, it is not required. nullable-context is a good help in the code-writing process. When part of the functionality is ready and it needs to be tested, we recommend using tools for deeper analysis.
Published at DZone with permission of Nikita Panevin. See the original article here.
Opinions expressed by DZone contributors are their own.
Comments