C# 8.0 nullable references: supporting older runtimes
In this post, I'll show how you can use C# 8.0's nullable reference features on code that needs to target versions of .NET that were around before C# 8.0, such as .NET Core 2.1, or .NET Framework.
TL;DR: it's not officially supported, but you can do it; there are some shortcomings, but it's worth doing, especially if you are multi-targetting (i.e., building libraries for multiple versions of .NET).
C# 8.0 not officially supported prior to .NET Core 3.0
C# 8.0 introduced a shift in policy. For many years, upgrading to newer versions of the C# language did not necessarily mean upgrading to a newer version of .NET. Occasionally, new features would depend on changes in the runtime—the biggest example of that was the introduction of generics in C# 2.0 back in 2005, and there have been more subtle examples such as C# 4.0's "no PIA" addition.
In these cases, you really did need to upgrade your .NET runtime to use the new functionality. But most of the features added to C# over the years have been able to work on older versions of .NET.
Starting with C# 8.0, Microsoft has asserted that the language version and runtime version are now tied together. If you go to https://docs.microsoft.com/en-us/dotnet/csharp/language-reference/configure-language-version you will find this statement:
"C# 8.0 (and higher) is supported only on .NET Core 3.x and newer versions."
They go on to say that this is because many of the features require library and runtime features introduced in .NET Core 3.x. However, if you look at the list of features, only one of them is a showstopper: default interface implementation simply cannot be supported on older versions of .NET because it effectively changes what output compilers are allowed to generate. Compiled code that uses default interface implementation will just look wrong to older versions of .NET, so those runtimes won't even attempt to run such code. But everything else in that list could be addressed by providing libraries supplying versions of the relevant types.
For example, they mention that the new index and range syntax in C# 8.0 depends on the System.Index and System.Range types, which didn't appear until .NET Core 3.0 and .NET Standard 2.1. However, it turns out that if you define your own versions of these types, the compiler is perfectly happy to let you use the new syntax on older versions of .NET. Microsoft could, if they chose, define NuGet packages providing canonical definitions of these types for use on older runtimes.
This is true even for the nullable references feature. At first glance this may look surprising—it seems like the sort of feature that should need runtime support because it makes changes to the type system. In fact it doesn't need any changes to the .NET runtime's underlying type system, the Common Type System (CTS)—the changes to the C# type system that support it are layered on top of the CTS through a combination of conventions and attributes, without needing any changes to the CTS.
So with the exception of default interface implementation, the requirement for .NET Core 3.0 or later when using C# 8.0 language features is a matter of support policy, rather than technical necessity.
In fact, the C# compiler appears to go out of its way to make it possible to use nullable references on older versions of .NET. So even though it's not officially supported, it works pretty well.
Compiler-generated attributes
If you've been following this whole blog series, you'll know that I've just finished a fairly long subseries on all the different attributes you can apply to your code to provide the compiler with better information about your code's behaviour with respect to nullability. But despite the seemingly-endless nature of that series, it's only half the story: it turns out that there are a bunch of other attributes that the compiler silently adds for you when you use nullable references.
When performing nullability analysis, any time you use some piece of code in an external component (whether part of the .NET class library, or some 3rd party component) the compiler needs to be able to determine whether that code was compiled with nullable annotations enabled, and if so, which arguments, fields, and return types are nullable, and which are non-nullable.
This is all handled with attributes that you normally won't see. The NullableContextAttribute
crops up on all members declared in an enabled nullable annotation context. You may also see a NullableAttribute
. These attributes are how the compiler represents the distinction between, say, string
and string?
.
Remember .NET's underlying type system, the CTS, doesn't understand that distinction, so it's only through these attributes that C# is able to see the difference.
These attributes are defined in the System.Runtime.CompilerServices
namespace, but interestingly, they don't currently appear when you search the documentation for them—they don't seem to exist in the .NET class library. The compiler adds definitions of these attributes when it builds your components, so there is no need for them to be defined by the class library.
If you take any compiled .NET component that contains code in a nullable annotation context, and you look at it with a low-level tool such as ILDASM you will see that it defines types in the System.Runtime.CompilerServices
namespace even though you haven't explicitly written any such types.
The C# compiler generates whole class definitions for these attributes, and every component using them contains its own copy of these definitions! Normally this sort of thing would cause problems, because type identity is determined by the containing assembly in .NET: two type definitions may be identical but if they're in different assemblies they are, by definition, different types. But it turns out that for attributes relating to nullable references, the compiler doesn't care about the identity of the types, only their names. Any attribute called System.Runtime.CompilerServices.NullableContextAttribute
will do; as long as the necessary members are present, the compiler treats them all as though they are the same.
So with these attributes, it doesn't actually matter what version of .NET you're targetting: the attributes will be available everywhere because they are automatically added to your code.
All the other attributes
More problematic are all the optional attributes I've been discussing recently. Unlike the attributes generated silently by the compiler, you annotate your code with these ones yourself—the whole point of them is to handle scenarios in which the compiler cannot fully infer what it is going on.
So unlike the compiler-generated ones, these are always used explicitly. And although these attributes trigger special behaviour in the compiler once they are present, there's nothing particularly special about how we put them in place to begin with: when it comes to applying these attributes they work in exactly the same way as any other attribute.
The upshot of that is that the compiler isn't going to generate types defining these attributes for you.
If you're targetting .NET Core 3.0 or later, or .NET Standard 2.1 or later, that's fine because these attributes are available as part of the framework. But what if you're not? Microsoft has not supplied NuGet packages to provide down-level versions of these attributes.
But it turns out you can just bring your own. Although the compiler won't generate them for you like it does with NullableContextAttribute
and NullableAttribute
, it's perfectly happy to use your definition of these other attributes.
In fact, you don't even have to do that: someone has written a library that does this for you. If you add the Nullable NuGet package to your project, it will add these attribute definitions for you if required. So just like the compiler-generated attributes, you will find that your compiled component also contains copies of all the other nullable references attributes. (By the way, this component works by adding the class definitions directly to your component. It does not supply any DLLs of its own. It uses the feature that enables a NuGet package to add source files to the component that references it.) And because when it comes to nullable references attributes, the compiler doesn't care which particular definition you use, it is happy to work with ones compiled directly into your project.
Note that this package detects which version of .NET you're building for and only adds the attributes if they're not already available in your target framework. This is useful for projects that build for multiple different target versions.
If you're using this library on a component that is intended for public use you should definitely multi-target: in addition to building for, say, .NET Standard 2.0, if you're building nullable-references-enabled code, you should also target either .NET Standard 2.1, or .NET Core 3.0 (or later). That way, anyone using a version of .NET with these attributes built in will be able to use a version of your component that relies on those same attribute definitions, but people working with older versions of .NET will still be able to benefit from the nullability annotations in your code.
This example shows a project file for an application that targets .NET Core 2.1, enables the latest version of C# 8, enables nullable references, and has a reference to the Nullable
NuGet package:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>netcoreapp2.1</TargetFramework>
<LangVersion>Latest</LangVersion>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Nullable" Version="1.2.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
</Project>
Applications are not typically reused as components by other code, which is why I've not followed my own multi-targetting advice from a couple of paragraphs ago. But if you're writing a library, something more like this is in order:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>netstandard2.0;netstandard2.1</TargetFrameworks>
<LangVersion>Latest</LangVersion>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Nullable" Version="1.2.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
</Project>
In either case, with this package reference in place you can use all the C# 8.0 nullable reference features and attributes, and it just works.
Missing library annotations in the .NET class library
The one big problem in all of this is that even if you bring your own definitions of the attributes, you will be compiling against a version of the .NET class library that does not have any of these annotations. This means that the compiler has less information about these libraries than it does when using newer versions. Take this code, for example:
public static int Foo(string? path)
{
if (string.IsNullOrWhiteSpace(path))
{
return 0;
}
return path.Length;
}
This depends on the .NET class library's string.IsNullOrWhitespace
method being annotated with the NotNullWhen
attribute. If you try this in code that targets an older version of .NET, you'll get a warning on the path.Length
expression, because the absence of the relevant attribute means that the compiler cannot determine that this code will only run if path
is null. If you have a multi-target project, you will see this warning when it builds for the older targets but not the newer ones. And if you're in Visual Studio, whether or not you see the squiggly line indicating a problem will depend on which framework you've selected in the combobox at the top left of the editor window.
In these cases you could use the null forgiving operator to let the compiler know that you know what you're doing. In this case we could write path!.Length
. Alternatively, you could configure your project so that it supresses nullability warnings when building for the older target:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>netstandard2.0;netstandard2.1</TargetFrameworks>
<LangVersion>Latest</LangVersion>
</PropertyGroup>
<PropertyGroup Condition="$(TargetFramework) == 'netstandard2.0'">
<Nullable>annotations</Nullable>
</PropertyGroup>
<PropertyGroup Condition="$(TargetFramework) == 'netstandard2.1'">
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Nullable" Version="1.2.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
</Project>
This enables annotations for both target frameworks, but will only generate nullability warnings for .NET Standard 2.1. (Setting <Nullable>enabled</Nullable>
) enables both annotations and warnings.)
This way, you can build code that is fully annotated for all target frameworks, while taking advantage of the higher quality of compiler analysis that is possible when targetting newer framework versions.