.NET Tools
Essential productivity kit for .NET and game developers
Nullable Reference Types: Contexts and Attributes – A Look at New Language Features in C# 8
Our C# 8 language features series is coming to an end. Before we jump into nullable contexts and nullable attributes, here is a quick (updated) recap of our roadmap:
- Indices, Ranges, and Null-coalescing Assignments
- Switch Expressions and Pattern-Based Usings
- Recursive Pattern Matching
- Async Streams
- Nullable Reference Types: Migrating a Codebase
- Nullable Reference Types: Contexts and Attributes
In this post, we will take another look at Nullable Reference Types. We will learn about the different nullable contexts, and compare the attributes that are being used in Roslyn and in the JetBrains annotations package.
The JetBrains annotations package has been around for roughly 10 years, and it enables users to employ attributes to extend their codebases with additional information about nullability. For instance, the attributes NotNull
or CanBeNull
could be applied to methods, properties, and parameters. With the introduction of Nullable Reference Types (NRTs), the C# type system has been extended to make this information a first-class citizen. However, even with NRTs we’re sometimes unable to express the nullability knowledge that we have, which is why NRTs also come with their own set of Roslyn nullable attributes (System.Diagnostics.CodeAnalysis
).
Let’s start with a discussion of underlying nullable contexts.
Choosing a Nullable Contexts
In order to maximize the benefit we can get from NRTs and nullability analysis in our specific circumstances, it is important to make an educated choice about the nullable contexts to use. There are two different primitive context types:
- Nullable warning context – When this context is enabled, Roslyn analysis will run and report warnings related to NRTs and static flow analysis. ReSharper’s analysis won’t consider its own
NotNull
/CanBeNull
attributes to match the exact behavior of the compiler. However, it still uses static flow analysis and checks replicated compiler warnings (as code inspections) for more immediate feedback. - Nullable annotation context – When this context is enabled, all the nullability information that includes attributes and NRTs in public signatures is exposed in the compiled assembly through the
NullableContext
andNullable
attributes.
Note that to compile JetBrains annotations into the output assembly, we have to add JETBRAINS_ANNOTATIONS
to the DefineConstants
project property.
As a ReSharper or Rider user, enabling just the nullable annotation context could be a good choice for us. This ensures that our codebase profits from the maturity level of ReSharper’s existing external annotations, while the BCL and other libraries are still being annotated with Roslyn attributes. Roslyn annotations are gradually being incorporated into ReSharper’s and Rider’s own analysis. Furthermore, ReSharper’s analysis includes dedicated support for closures and LINQ, allowing you, for instance, to transfer the information from Where(x => x != null)
to the next call.
To enable a context for the whole project, we can set the Nullable
property (for example, <Nullable>annotations</Nullable>
). We can also use preprocessor directives to enable contexts for a whole file (for example, #nullable warnings
) or limit them to only specific lines using enabling and restoring preprocessor directives (e.g., #nullable enable/restore annotations
). In a simpler form, we can use <Nullable>enable</Nullable>
to enable both contexts, or #nullable disable
to disable them. Even combinations are allowed so that we can globally enable NRTs on a project, but disable them for specific files and lines, or vice-versa.
Check out this comprehensive example on SharpLab.
Comparing Nullable Attributes
One key difference between ReSharper’s and Roslyn’s annotations is how they are used for input and output values. ReSharper uses the same attributes for both, while Roslyn employs separate attributes:
// Roslyn: MaybeNull, NotNull, AllowNull, DisallowNull [return: MaybeNull] string M([AllowNull] string a) {} [return: NotNull] string M([DisallowNull] string a) {} // ReSharper: CanBeNull, NotNull [CanBeNull] string M([CanBeNull] string a) {} [NotNull] string M([NotNull] string a) {}
While JetBrains annotations might seem a bit simpler to use in this case, the new Roslyn annotations give us the option of creating a more fine-grained contract when needed. For instance, we could mark an attribute to allow null
input values but to always return a non-null
value:
[AllowNull] // input value! string Name { set => _name = value ?? "N/A"; get => _name; }
Another good example are ref
parameters, which could be passed with a null
value but then initialized with a real value (other than null
) from the called method:
void Init([AllowNull] ref string a) => s ??= GetValue(); void M(bool weNeedStr) { string? str = null; if (weNeedStr) { Init(ref s); // no warning since null input is allowed Console.WriteLine(s.Length); // no warning since str is not nullable } }
However, if our contract doesn’t require such a fine-grained distinction or if our method is not generic (T?
won’t work, since T
must be known to be of either value or nullable reference type), we will likely use the NRT format:
string? NullableString { get; set; } string NonNullableString { get; set; }
For more advanced usages, JetBrains annotations include the ContractAnnotation
attribute, which allows us to define pre- and postconditions for a method call using a function definition table syntax. Roslyn, on the other hand, uses a set of simple attributes to express this. Let’s look at some examples. An Assert
method can be used to halt the execution if a certain condition is not met:
// Roslyn void Assert([DoesNotReturnIf(false)] bool condition, string message) {} // ReSharper [ContractAnnotation("condition: false => halt")] void Assert(bool condition, string message) {}
Similarly, we can also define an assertion method AssertNotNull
to check for null
:
// Roslyn void AssertNotNull([NotNull] object? obj, string message) {} // ReSharper [ContractAnnotation("obj: null => halt")] void AssertNotNull(object obj, string message) {}
Another good example is when we access a dictionary using TryGetValue
. The returned boolean value indicates whether we’ve found an actual value or not:
// Roslyn bool TryGetValue(TKey key, [MaybeNullWhen(false)] out TValue value) {} // ReSharper [ContractAnnotation("=> false; value:null")] bool TryGetValue(TKey key, out TValue value) {}
Last but not least, there’s this simple example of a Fail
method that halts the execution no matter what. This knowledge comes in handy during analysis to identify unused code:
// Roslyn [DoesNotReturn] void Fail(string message) {} // ReSharper [ContractAnnotation("=> halt")] void Fail(string message) {}
Strictly speaking, the ContractAnnotation
attribute can cover more cases than Roslyn attributes. For instance, we can provide information not only about nullability but also about boolean return types (e.g., obj: null => false
). Moreover the attribute allows us to consider multiple input values at the same time (e.g., format: notnull, obj1: null => halt
). On the other hand, Roslyn attributes are likely to be more accessible initially and less "stringly typed" in some cases.
One final interesting difference is that JetBrains annotations are inherited, while Roslyn attributes aren’t. So with Roslyn, we need to duplicate them on every derived type:
interface ICache<T> { [NotNull] T GetObject(string key); } // Roslyn class SimpleCache<T> : ICache<T> { [NotNull] T GetObject(string key); }
We hope that you enjoyed this series and that ReSharper and Rider can help you make better use of the new language features. Let us know what you think. Any feedback is welcome! Download ReSharper 2020.1 or check out Rider 2020.1.