BT

Facilitating the Spread of Knowledge and Innovation in Professional Software Development

Write for InfoQ

Topics

Choose your language

InfoQ Homepage Articles Adapting Projects to Use C# 8 and Nullable Reference Types

Adapting Projects to Use C# 8 and Nullable Reference Types

Leia em Português

This item in japanese

Bookmarks

Key Takeaways

  • Nullable reference types need to be enabled on a per-project basis
  • Nullable reference types may need to be disabled when working with generics
  • Many warnings can be fixed by caching property reads in local variables
  • Null argument checks are still needed on public methods
  • Deserialization works differently in .NET Framework and .NET Core
     

This report is a case study on upgrading a C# 7 class library to C# 8 with nullable reference types. The project used in this case study, Tortuga Anchor, is a collection of MVVM style base classes, reflection code, and various utility functions. It was chosen because it is reasonably small and has a good mix of idiomatic and unusual C# patterns. 

Project Setup

Currently, nullable reference types are only available for .NET Standard and .NET Core projects. By the time Visual Studio 2019 is production ready, .NET Framework should also be supported. 

In your project file you’ll need to add/modify the following settings:

</PropertyGroup>
    <LangVersion>8.0</LangVersion>
    <NullableContextOptions>enable</NullableContextOptions>
</PropertyGroup>

As soon as you save, you should see nullability errors start appearing. If you don’t, try building the project.

Indicating a type is nullable

In the interface method GetPreviousValue, the return type is meant to be nullable. To make this explicit, the nullable reference type modifier (?) is added to object.

object? GetPreviousValue(string propertyName);

Many of the compiler errors in your project are likely solvable by simply annotating variables, parameters, and return types with this type modifier.

Lazy loading properties

If a property is expensive to calculate, you may find yourself using the lazy-loading pattern. In this pattern, if a private field is null it means the value hasn’t been generated yet.

C# 8 handles this situation well. Without altering the code, it was able to properly analyze the code to determine the result of the getter will always be non-null despite the fact the returned variable is nullable. 

string? m_CSharpFullName;
public string CSharpFullName
{
    get
    {
        if (m_CSharpFullName == null)
        {
            var result = new StringBuilder(m_TypeInfo.ToString().Length);
            BuildCSharpFullName(m_TypeInfo.AsType(), null, result);
            m_CSharpFullName = result.ToString();
        }
        return m_CSharpFullName;
    }
}

It should be noted there is a potential race condition here. In theory, another thread could set the value of m_CSharpFullName back to null and the compiler wouldn’t be able to detect it. So be especially careful when dealing with multi-threaded code.

A variable’s nullability is determined by another variable

In this next code example, the class is designed such that m_ListeningToItemEvents will be true if and only if m_ItemPropertyChanged is not null. There is no way for the compiler to know about this rule. When that happens, you can append the null-forgiving operator(!) to the variable (m_ItemPropertyChanged in this case) to indicate it won’t be null at that point in time.

if (m_ListeningToItemEvents)
{
    if (item is INotifyPropertyChangedWeak)
        ((INotifyPropertyChangedWeak)item).AddHandler(m_ItemPropertyChanged!);
    else if (item is INotifyPropertyChanged)
        ((INotifyPropertyChanged)item).PropertyChanged += OnItemPropertyChanged;
}

Correcting false positives with explicit casting

In this next example, the compiler falsely reports the nullability of m_Base.Values is not compatible that of IEnumerable<TValue>. To remove the warning, I added the explicit cast you see below. 

readonly Dictionary<ValueTuple<TKey1, TKey2>, TValue> m_Base;
IEnumerable<TValue> IReadOnlyDictionary<ValueTuple<TKey1, TKey2>, TValue>.Values
{
    get { return (IEnumerable<TValue>)m_Base.Values; }
}

Do note the compiler flags that line as having a redundant cast. Normally this is a compiler message, rather than a warning, but hopefully it will be corrected in time for the release.

Correcting false positives with temp variables or conditional casts

In this next example, the compiler indicates there is an error in the CancelEdit line. Though the preceding if statement proves item.Value is not null, the compiler doesn’t trust that the next time we read item.Value it will still be non-null.

foreach (var item in m_CheckpointValues)
{
    if (item.Value is IEditableObject)
        ((IEditableObject)item.Value).CancelEdit();
}

One way to satisfy the compiler is to store item.Valuein a temporary variable.

foreach (var item in m_CheckpointValues)
{
    object? value = item.Value;
    if (value is IEditableObject)
        ((IEditableObject)value).CancelEdit();
}

But in this case we can simplify it even further by using a conditional cast (as operator) followed by a conditional method invocation (?. operator).

foreach (var item in m_CheckpointValues)
{
    (item.Value as IEditableObject)?.CancelEdit();
}

Generics and nullable types

If you do a lot of work with generics, you may run into a problem nullable types. Consider this delegate:

public delegate void ValueChanged<in T>(T oldValue, T newValue);

The intended design for this delegate is that oldValue and newValue are both nullable. So, you would think you can tack on a couple question marks and move on. However, doing so returns this error message:

> Error CS8627 A nullable type parameter must be known to be a value type or non-nullable reference type. Consider adding a 'class', 'struct', or type constraint.

If you need to support both value and reference types, there is not an easy way to fix this. Since you can’t express an “or” in the type constraint, you need one delegate for classes and another for structs.

public delegate void ValueChanged<in T>(T? oldValue, T? newValue) where T : class;
public delegate void ValueChanged<T>(T? oldValue, T? newValue) where T : struct;

This doesn’t work, however, because both delegates have the same name. You could give them separate names, but you would then have to duplicate all of the code that uses them as well.

Fortunately, C# has an escape value. Using the #nullable directive, you can revert to C# 7 semantics and the code will continue to work as intended.

#nullable disable
public delegate void ValueChanged<in T>(T oldValue, T newValue);
#nullable enable

This work-around isn’t without flaws. Disabling the nullable references feature is all or nothing; you couldn’t use it to make oldValuenullable and newValuenon-nullable.

Constructors, deserializers, and initialize methods

For this next example, you have to know a trick some serializers use. There is a little-known function to bypass a class’s constructor known as FormatterServices.GetUninitializedObject. Some serializers, such as the DataContractSerializer, will use this to improve performance.

What happens if you need the logic in your constructor to always run? Well that’s where the OnDeserializing attribute comes into play. This attribute acts as a surrogate constructor called after GetUninitializedObject

In order to reduce redundancy and the chance of error, developers will often use a common initializer method as shown in the code example below.

protected AbstractModelBase()
{
    Initialize();
}
 [OnDeserializing]
void _ModelBase_OnDeserializing(StreamingContext context)
{
    Initialize();
}
void Initialize()
{
    m_PropertyChangedEventManager = new PropertyChangedEventManager(this);
    m_Errors = new ErrorsDictionary();
}

This becomes a problem for the null checker. Since the two variables mentioned are not explicitly set in the constructor itself, it will flag them as uninitialized. Which means some copy-and-paste work needs to be done in order to remove the error.

There is also the risk of forgetting to include an OnDeserializing method. Since the null checker doesn’t understand the OnDeserializing method, it won’t be able to alert you of the possibility of unexpected nulls. 

Most developers find this behavior to be confusing. So, in .NET Core, the DataContractSerializer will call your constructor. But that means if you are targeting.NET Standard, you need to make sure you test your deserialization code with both .NET Framework and .NET Core to account for the different behaviors. 

Nullable Parameters and CallerMemberName

A pattern this library uses a lot is CallerMemberName. Named for the attribute it uses, the basic idea is you add an optional parameter to the end of a method. The compiler sees the CallerMemberName and implicitly provides a value for that parameter.

public override bool IsDefined([CallerMemberName] string propertyName = null)

Theoretically the propertyNameparameter could be explicitly set to null, but it is widely understood you shouldn’t do that and unexpected errors could occur. 

When converting this code to C# 8, one may be tempted to mark the parameter as nullable. That is misleading because the method wasn’t actually designed to handle nulls. Instead, you should replace the null with an empty string.

public override bool IsDefined([CallerMemberName] string propertyName = "")

Do I still need null argument checks?

If you are building a library for public consumption (i.e. NuGet), then yes, all of your public methods still need to check for null arguments. The applications consuming your library may not necessarily be using nullable reference types. In fact, they may not even be using C# 8 at all.

If all of your application code uses nullable reference types, then the answer is still, “probably yes”. While in theory you won’t be seeing any unexpected nulls, they can still creep in due to dynamic code, reflection, or the misuse of the null-forgiving operator (!). 

Conclusions

In a project with just under 60 class files, 24 of them required changes. But none of them were particularly significant and the whole process took less than an hour. All in all, it was a painless process and most things work as expected. I expect most projects will benefit from this feature and should apply it once C# 8 is released.

About the Author

Jonathan Allen got his start working on MIS projects for a health clinic in the late 90's, bringing them up from Access and Excel to an enterprise solution by degrees. After spending five years writing automated trading systems for the financial sector, he became a consultant on a variety of projects including the UI for a robotic warehouse, the middle tier for cancer research software, and the big data needs of a major real estate insurance company. In his free time he enjoys studying and writing about martial arts from the 16th century.

Rate this Article

Adoption
Style

BT