Enabling and using C# 9 features on older and “unsupported” runtimes
With this year’s .NET Conf just coming to a close, we have finally been introduced to the public release of .NET 5 and C# 9, which are now widely available and can be used by developers using at least Visual Studio version 16.8, that comes bundled with all the necessary components to build libraries and applications using these two new technologies.
Despite all the excitement around all of this, I have seen plenty of developers on Twitter and other channels feeling left out. The reason was they thought they could not benefit from either of these new tools on older frameworks such as UWP, Xamarin, and all other targets that cannot use neither .NET 5 nor any kind of API that requires more than .NET Standard 2.0 support.
While it is true that if we are working with a framework that cannot just upgrade to .NET 5, we will not be able to enjoy many of the great features that the new version of the runtime and BCL bring to the table, the same is not actually true for C# 9. At least, not for all of C# 9 features. Many of them (most?) can actually be used by every developer regardless of the runtime in use — the only requirement is to have access to a recent version of the Roslyn compiler, such as the one that is included in Visual Studio 16.8!
Why does this work? Is it supported?
Simplifying things a bit, we can divide the features of each new C# version into two categories: those that require proper runtime support to work, and those that are mostly just syntactic sugar. Of course, we will not be able to “backport” any of the new features in the first category to older runtimes, as the C# compiler will simply refuse to work in that case. And that makes perfect sense, as we would not be able to run that code anyway. This category includes features such as default interface methods from C# 8 and covariant return types in C# 9. The second category instead is a bit more interesting: all the C# features that belong here are ones that make things easier for developers (such as the new record types in C# 9), but that result in IL code (the .NET bytecode language) that can be executed by older runtimes without any issues. After all, these features are more about changing how we end up with a given binary to execute, but for the most part they introduce nothing new at the IL level. Which is great for us — it means we can “backport” them!
Level 1: what works right out of the box
Let’s start with what we can use as soon as we set the language version in our project to C# 9 — we simply need to add this line in our .csproj file:
With this, we will already be able to use all C# 9 features that do not require runtime support or additional types — and there’s quite a few of them!
- Top-level statements
- Pattern matching enhancements
- Native sized integers
- Function pointers*
- Target-typed new expressions
- Static anonymous functions
- Target-typed conditional expressions
- Extension GetEnumerator in foreach loops
- Lambda discard parameters
- Attributes on local functions
- Readonly members
- Improved pattern matching
- Using declarations
- Static local functions
- Null-coalescing assignment
- Unmanaged constructed types
- Stackalloc in nested expressions
- Improved interpolated verbatim strings
*On some older frameworks such as UWP, function pointers will still be supported (including the ability to specify an explicit calling convention), but the usage of the unmanaged keyword to declare a function pointer using the default calling convention for the platform in use might not be supported.
What about nullable reference types?
Of course, nullable reference types are a great addition to C# 8, as they make it much easier to avoid all those nasty null reference exceptions. These work just fine, but they are hidden behind a compiler flag that is off by default, so we just need one more line in our .csproj files to enable them:
This will enable support for them in .NET Standard libraries and a number of other targets. One small caveat for those of you that work on UWP: in order to use this feature we will also need to manually annotate each of our .cs files, with a specific compiler directive. We just need to add this line at the top of all our files, and we will be able to use the nullable annotations there as well:
While using nullable reference types in our own code will work just fine, along with the static flow analysis powered by Roslyn, we will not benefit from APIs in the BCL being annotated, as most of that work has only been done in .NET 5. Because of this, we will need to be careful to preserve the proper annotations when passing values to and from all of those methods.
There is one more thing to consider on the topic of nullable reference types: not all attributes are available on .NET Standard 2.0 and other similar runtimes. For that, let’s move to the next section.
Level 2: what we can enable support for
While we still have some features left in C# 8 and 9 that can work on our older targets, these remaining ones will need some extra care in order to make the Roslyn compiler happy. Specifically, these are features that are backwards compatible, but do need some specific types or attributes to be present in the project. The good news is that if we are missing libraries we can just reference them from NuGet, and if we miss types we can literally just copy them from the CoreCLR source on GitHub and paste them into our project! The C# compiler does not actually need those types to be built-in — as long as it can find them in the right place, that will be enough for it to successfully build our code. So, let’s go through all the remaining features one by one!
C# 9 records and init-only properties
The new record types introduced in C# 9 can greatly reduce the amount of boilerplate code that is required to implement simple models, but they are one of the features that needs some help to work properly on older targets. Specifically, the C# compiler will need the special IsExternalInit type to be present in our project. To enable support for this, we can simply copy the following code anywhere in your our and then try building again!
C# 9 [SkipLocalsInit] attribute
This new attribute is useful in performance critical scenarios, and instructs the compiler not to emit the .locals init flag for variables in a method. This can avoid unnecessary zero-initializations of variables and improve performance. The attribute is not available on older runtimes, but as with records, we simply need to copy it into our project and then use it normally. Roslyn will recognize it and use it just fine, since it just checks the type name and namespace, and not the assembly the type actually belongs to. Here it is:
C# 8 asynchronous streams and disposables
This feature relies on the IAsyncEnumerable<T> and IAsyncDisposable types, and allows us to write asynchronous enumerators, to use the new await foreach construct and to implement asynchronous dispose methods to enable the new await using syntax. All we need to do in this case is to add a reference to the Microsoft.Bcl.AsyncInterfaces NuGet package, which includes all these necessary types. Once we have a reference to that package we will be able to use all of those new APIs with no problems at all:
Flow analysis attributes
You might have noticed that, especially with respect to nullable reference types, not all the necessary attributes to decorate fields, properties, methods and other members are available, especially when working on .NET Standard 2.0 and below. These are attributes like [MaybeNullWhen],[NotNullWhen] and others detailed in the docs that enable better static analysis, as they can give the compiler more information about how our code is interacting with values. Thankfully, we can just backport all of these from the CoreCLR source and then use them just like we would have done if they had already been available in the framework we are working with. Here is a gist with all the currently available attributes ready to use — we can just paste this file into our project and start using them wherever we need:
I know that this is not quite the same as just having full built-in support for C# 9, but if you can live without those few features that cannot be ported to older targets, and if you are fine with the small amount of work needed to manually enable some of the remaining ones, hopefully this post will help you have a more pleasant time while maintaining your apps and libraries.
Be sure to know that as a UWP developer myself (so being stuck on .NET Standard 2.0 for the time being), I can say that there are plenty of other devs like me that really appreciate the time and effort put into supporting libraries that all of us still on “older” technologies can use.