Monday, July 18, 2022

Null Conditional Operators in C# - ?. and ?[]

In the last article, we took at look at Nullability in C# - What It Is and What It Is Not. When we have nullability enabled, we need to respond to the warnings that come up in our code. This often means performing null checks or giving the compiler hints to what our code is doing. Fortunately, we have a number of null operators in C# to help us with those tasks. To continue with the series of articles exploring nullability, we will look at the null conditional operators (represented by question mark dot '?.' and question mark square brackets '?[]').

Articles

The source code for this article series can be found on GitHub: https://github.com/jeremybytes/nullability-in-csharp.

The Short Version

The null conditional operators give us a shortened syntax for checking for a null object before calling a method, reading a property, indexing into an object, or accessing another member on a nullable object.

Note: We will focus on the ?. operator for the samples since this is the more common of the 2 operators.

Let's look at 2 blocks of code that are almost equivalent.

This first version checks to make sure that the "tokenSource" field is not null before calling the "Cancel" method. If the field is null, then "Cancel" is not called:


    private void CancelButton_Click(object sender, RoutedEventArgs e)
    {
        if (tokenSource is not null)
        {
            tokenSource.Cancel();
        }
    }

This second version also checks to make sure that the "tokenSource" field is not null before calling the "Cancel" method. If the field is null, then "Cancel" is not called:


    private void CancelButton_Click(object sender, RoutedEventArgs e)
    {
        tokenSource?.Cancel();
    }

I say "almost equivalent" because the null conditional operator in the second version gives us a little bit more -- it also helps with thread safety.

Let's look at these things in more detail.

"Possibly Null" Warning

We are looking at the same code as the previous article. This can be found in the "MainWindow.xaml.cs" file in the "UsingTask.UI" project of the GitHub repository.

As a reminder, the "StartingCode" folder has the starting state of the code at the beginning of the first article in the series. The "FinishedCode" has the completed code. The links in this article will point to the "FinishedCode" folder.

Our code has a nullable "tokenSource" field defined in the class (from the "MainWindow.xaml.cs" file noted above):


    CancellationTokenSource? tokenSource;

    public MainWindow()
    {
        InitializeComponent();
    }

Since we have a "?" at the end of the field type "CancellationTokenSource", we are indicating that this field can be null. (And since we are not assigning a value to it in the constructor, it will be null initially).

This field is used in the Cancel button's event handler:

As we saw in the last article, in the code's initial state, we get a warning when we access the "tokenSource" field:


    private void CancelButton_Click(object sender, RoutedEventArgs e)
    {
        tokenSource.Cancel();
    }

The warning tells us that the field may be null and result in a null reference exception.


CS8602: Dereference of a possibly null reference.

This gives us a warning that "tokenSource" may be null here. And if we call the "Cancel" method on a null field, we will end up with a null reference exception at runtime. (If you'd like to see this, it is shown in the previous article.)

Checking for Nulls

The traditional way to check for nulls is to wrap the code in a guard clause. This is often done with an "if" conditional:


    private void CancelButton_Click(object sender, RoutedEventArgs e)
    {
        if (tokenSource is not null)
        {
            tokenSource.Cancel();
        }
    }

The guard clause makes sure that the "tokenSource" field is not null before it attempts to call the "Cancel" method.

***THREAD SAFETY WARNING***
When we have code that references class-level fields (or other variables external to a method), there is a possibility of running into problems with threading. This is especially important to keep in mind since so much of the code we write today is asynchronous.

So what can happen here?

There is a brief period of time between when we check for the null in the "if" condition and the when we actually run the "Cancel" method. In this brief period it is possible that another method has set the "tokenSource" property to null.

The result of this would be a null reference exception when the code tries to call the "Cancel" method.

To get around this, developers will often make a local copy of the field or variable and act on that, particularly when dealing with delegates. No one else can affect the state of the local variable inside the method, so the "Cancel" method could be called with confidence.

However, there is an easier way to deal with this thread safety issue: use the null conditional operator.

Null Conditional Operator - ?.

The most common null conditional operator consists of a question mark and a dot. 

Note: The other null conditional operator uses a question mark with square brackets - ?[]. This is used to access indexers, and we will look at this briefly below. For more information, take a look at the Microsoft docs site on null conditional operators: Member Access Operators.

Here is what this null conditional operator looks like in use:


    private void CancelButton_Click(object sender, RoutedEventArgs e)
    {
        tokenSource?.Cancel();
    }

This has the same overall effect of the guard clause. At runtime, the "tokenSource" field is checked for null. If the field is not null, then the "Cancel" method is called normally.

If the field is null, the operation stops. The "Cancel" method is not called.

But a shortened syntax is not all that we get with the null conditional operator. We also get thread safety.

Thread Safety
The language designers took threading into account when they created this operator. From the developer's perspective, the null check and "Cancel" method call happen as a single operation, meaning that there is no possibility that something else would be able to set the "tokenSource" field to null in the middle.

I say "from the developer perspective" because the implementation is a little more complex than that. If you're curious, you can always fire up ILDASM (which is still included with the Visual Studio developer tools) and look at the IL (intermediate language) that is generated by the compiler.

What About Return Values or Properties?

The next question is what happens if we use the null conditional operator to call a method that returns a value or reads a property?

The short answer is that if the null conditional operator encounters a null, then the return value or property will be returned as "null".

As an example, let's say that we only want to call the "Cancel" method if the cancellation token is not already in a "canceled" state. Our "tokenSource" field has a property called "IsCancellationRequested" that we can use to check that.

Here's what that code may look like (note: this is not in the final code sample):


    private void CancelButton_Click(object sender, RoutedEventArgs e)
    {
        if (!tokenSource?.IsCancellationRequested)
        {
            tokenSource?.Cancel();
        }
    }

This uses an "if" statement to check the "IsCancellationRequested" property on the "tokenSource" field.

But we have some red squigglies. Here is the error message:


CS0266: Cannot implicitly convert type 'bool?' to 'bool'. An explicit conversion exists (are you missing a cast?)

This tells us that "if" needs a non-nullable Boolean value ("bool"). But since we use the null conditional operator, when we try to read "IsCancellationRequested", we may get a "null" back (this is noted with the nullable Boolean ("bool?") in the message).

We won't go through the trouble of fixing this code here. This sample is to show what happens when we use the null conditional operator on something that returns a value (either by calling a method or reading a property). In those cases, we may get a "null" returned.

Null Coalescing Operator - ??
In a future article, we will take a look at the null coalescing operator that lets us return a default value if we run across a null. This can help us fix things like the scenario above and eliminate possible null values.

Null Conditional Operator - ?[]

The null conditional operator that consists of a question mark and a set of square brackets is used for indexers on objects. The behavior is very similar to using the ?. operator to access a property.

For example, let's consider the following code (note: this is not part of the sample code on GitHub):


    List<Person>? people = null;
    Person? firstPerson = people?[0];

In this code, "people" is a nullable list of "Person" objects (and we set it to null). When we try to index into the "people" list, we put a question mark between "people" and the square brackets with the indexer.

If "people" is null, the indexer is not accessed, and so we do not get a null reference exception here. The "firstPerson" variable would then be assigned "null".

If "people" is not null, the indexer is accessed, and the first item in the collection will be returned.

As a side note, if the list is empty, we will get an "Index Out of Range" exception at runtime. But this is something we normally have to deal with regardless of whether nullability is enabled.

So we can use the ?[] null conditional operator to index into a nullable object. And this works similarly to using the ?. null conditional operator to access a property on a nullable object. If the object is null, then we get a null back. If the object is not null, then we get the item at the index or the value of the property.

Wrap Up

The null conditional operators can help us in 2 ways. First they can shorten our code by eliminating the need for a separate guard clause that specifically checks for "null". Secondly, they give us thread safety during the null check, so we do not need to worry about the possibility of a "null" sneaking into our code in a multi-threaded or async scenario.

Enabling nullability gives us the warnings we need to eliminate possible nulls in our code. And the null operators (including the null conditional operators) can help us in dealing with those warnings.

In the next 2 articles, we will look at the null forgiving operator as well as the null coalescing operators. These give us additional tools when dealing with possible nulls in our code. Be sure to check back for more.


Happy Coding!

No comments:

Post a Comment