准备工作

首先,我们要确认一下开启 C# 8.0 Nullable Reference Types 的前提条件:

  • Visual Studio 2019 16.3.0 以上版本
  • .Net Core 3.0

然后我们需要在项目中开启这个特性

<PropertyGroup>
    <Nullable>enable</Nullable>
</PropertyGroup>

然后我们就可以愉快的开始玩耍了 _

最基本的用法

可空引用类型的基本概念是,我们可以对引用类型进行可空的标注,当没有可空类型的标注时,默认就是非可空的类型,我们来看一下最基本的用法吧:

static void Basic()
{
    // Warning: CS8600 C# Converting null literal or possible null value to non-nullable type.
    string name = null;
    
    string? name2 = null;
    // Warning: CS8602 C# Dereference of a possibly null reference.
    WriteLine(name2.Length);
}

编译这段代码的话,我们会看到两个警告:

  1. 第一个警告是说,name 这个字段我们声明为不可空的引用类型,但是却赋值为空。
  2. 第二个警告是说,我们声明了一个可空的引用类型,直接使用它的属性的话可能会出现空引用,需要先进行判断。

看到这里,我们应该对这个特性有了一个基本的了解:可空的引用类型在使用时需要进行非空判断,非可空的引用类型在声明或者初始化的时候必须赋值,否则就会出现警告。

notnull 泛型约束

我们先看一下,一个简单的泛型接口定义:

interface IDoStuff<TIn, TOut>
{
    TOut DoStuff(TIn input);
}

在这个接口定义中,我们可以很清楚的知道,这个接口可以接受两个泛型参数,一个输入,一个输出,那么我们如何把非空引用这个新特性,加在泛型约束中呢?

答案是:notnull 我们来看一下实现:

interface IDoStuff<TIn, TOut>
    where TIn : notnull
    where TOut : notnull
{
    TOut DoStuff(TIn input);
}

Nice! 这样我们就得到了一个具有非空类型约束的接口定义了,我们来试着写一个实现:

// Warning: CS8714 - Nullability of type argument 'TIn' doesn't match 'notnull' constraint.
// Warning: CS8714 - Nullability of type argument 'TOut' doesn't match 'notnull' constraint.
public class DoStuffer<TIn, TOut> : IDoStuff<TIn, TOut>
{
    public TOut DoStuff(TIn input)
    {
        ...
    }
}

可以看到,如果我们的实现没有加上非空类型约束,就会出现对应的警告信息。我们来修复一下:

// No warnings!
public class DoStuffer<TIn, TOut> : IDoStuff<TIn, TOut>
    where TIn : notnull
    where TOut : notnull
{
    TOut DoStuff(TIn input)
    {
        ...
    }
}

我们来继续创建几个这个类的实例,看一下效果:

// Warning: CS8714 - Nullability of type argument 'string?' doesn't match 'notnull' constraint
var doStuffer = new DoStuff<string?, string?>();

// No warnings!
var doStufferRight = new DoStuff<string, string>();

同样的,值类型也一样有效:

// Warning: CS8714 - Nullability of type argument 'int?' doesn't match 'notnull' constraint
var doStuffer = new DoStuff<int?, int?>();

// No warnings!
var doStufferRight = new DoStuff<int, int>();

在泛型编程中,当你需要限定只有非空引用类型可以被当作类型参数时,非常有用。一个现成的例子是 Dictionary<TKey, TValue> ,其中的 TKey 是被约束为 notnull ,禁止了 null 作为 key:

// Warning: CS8714 - Nullability of type argument 'string?' doesn't match 'notnull' constraint
var d1 = new Dictionary<string?, string>(10);

// And as expected, using 'null' as a key for a non-nullable key type is a warning...
var d2 = new Dictionary<string, string>(10);

// Warning: CS8625 - Cannot convert to non-nullable reference type.
var nothing = d2[null];

为什么不能使用 T?

可能有些朋友会问,为什么不使用 T 作为默认的非空类型约束,这样就可以用 T? 作为可空类型呢?这个当作一个思考题,大家自行思考。

可空前置条件:AllowNullDisallowNull

先来看一个例子:

public class MyClass
{
    public string MyValue { get; set; }
}

这是一个 C# 8.0 之前很常见的例子,但是从 C# 8.0 开始,这意味着 string 表示一个非可空的 string! 有些情况下,我们需要可以使用 null 对它赋值,但是 get 的时候能拿到一个 string 的值,我们可以通过 AllowNull 来实现:

public class MyClass
{
    private string _innerValue = string.Empty;

    [AllowNull]
    public string MyValue
    {
        get
        {
            return _innerValue;
        }
        set
        {
            _innerValue = value ?? string.Empty;
        }
    }
}

这样我们就可以保证 getter 得到的值永远都不会为 null,但是 setter 依然可以设置 null 值:

void M1(MyClass mc)
{
    mc.MyValue = null; // Allowed because of AllowNull
}

void M2(MyClass mc)
{
    Console.WriteLine(mc.MyValue.Length); // Also allowed, note there is no warning
}

现在我们来看另外一种情况:

public static HandleMethods
{
    public static void DisposeAndClear(ref MyHandle handle)
    {
        ...
    }
}

在这个情况下,MyHandle 指向某个资源。通常使用这个API的时候我们有一个 not null 的实例通过 ref 传递进去,但是当这个资源被这个API Clear 之后,这个引用就会变成 null. 我们如何能同时兼顾 handle 可为 null,但是传参的时候又不可为 null 呢?答案是 DisallowNull:

public static HandleMethods
{
    public static void DisposeAndClear([DisallowNull] ref MyHandle? handle)
    {
        ...
    }
}

我们来看一下效果:

void M(MyHandle handle)
{
    MyHandle? local = null; // Create a null value here
    HandleMethods.DisposeAndClear(ref local); // Warning: CS8601 - Possible null reference assignment
    
    // Now pass the non-null handle
    HandleMethods.DisposeAndClear(ref handle); // No warning! ... But the value could be null now
    
    Console.WriteLine(handle.SomeProperty); // Warning: CS8602 - Dereference of a possibly null reference
}

这两个属性可以允许我们在需要的情况下使用单向的可空性或者不可空性。

可空后置条件: MaybeNullNotNull

先看一下下面这个API:

public class MyArray
{
    // Result is the default of T if no match is found
    public static T Find<T>(T[] array, Func<T, bool> match)
    {
        ...
    }

    // Never gives back a null when called
    public static void Resize<T>(ref T[] array, int newSize)
    {
        ...
    }
}

现在我们有一个问题,我们希望 Find 在找不到元素的时候返回 defaultdefaultT 为引用类型的时候为 null,另一方面,我们希望 Resize 能接受一个可能为 null 的数组,但是当 Resize 调用之后, array 绝不会为 null. 我们如何才能实现这样的效果呢?

答案是 [MaybeNull][NotNull],通过这两个属性,我们可以实现这种奇妙的效果!让我们来修改一下我们的API:

public class MyArray
{
    // Result is the default of T if no match is found
    [return: MaybeNull]
    public static T Find<T>(T[] array, Func<T, bool> match)
    {
        ...
    }

    // Never gives back a null when called
    public static void Resize<T>([NotNull] ref T[]? array, int newSize)
    {
        ...
    }
}

然后看一下效果:

void M(string[] testArray)
{
    var value = MyArray.Find<string>(testArray, s => s == "Hello!");
    Console.WriteLine(value.Length); // Warning: Dereference of a possibly null reference.

    MyArray.Resize<string>(ref testArray, 200);
    Console.WriteLine(testArray.Length); // Safe!
}

Amazing!

条件性的后置条件:MaybeNullWhen(bool)NotNullWhen(bool)

思考一下另外一个例子:

public class MyString
{
    // True when 'value' is null
    public static bool IsNullOrEmpty(string? value)
    {
        ...
    }
}

public class MyVersion
{
    // If it parses successfully, the Version will not be null.
    public static bool TryParse(string? input, out Version? version)
    {
        ...
    }
}

public class MyQueue<T>
{
    // 'result' could be null if we couldn't Dequeue it.
    public bool TryDequeue(out T result)
    {
        ...
    }
}

像这样的API,在我们平时的开发过程中都会经常使用到,根据API返回的 truefalse,决定了我们传进去的参数是 null 还是 notnull.

我们希望实现下面三个效果:

  1. IsNullOrEmpty 返回 false 的时候,value 不为空
  2. TryParse 返回 true 的时候,version 不为空
  3. TryDequeue 返回 false 的时候,result 可能会为空(当 T 为引用类型的时候)

通过 NotNullWhen(bool)MaybeNullWhen(bool),我们能实现这个更为奇妙的效果,让我们来修改一下代码:

public class MyString
{
    // True when 'value' is null
    public static bool IsNullOrEmpty([NotNullWhen(false)] string? value)
    {
        ...
    }
}

public class MyVersion
{
    // If it parses successfully, the Version will not be null.
    public static bool TryParse(string? input, [NotNullWhen(true)] out Version? version)
    {
        ...
    }
}

public class MyQueue<T>
{
    // 'result' could be null if we couldn't Dequeue it.
    public bool TryDequeue([MaybeNullWhen(false)] out T result)
    {
        ...
    }
}

然后看一下效果:

void StringTest(string? s)
{
    if (MyString.IsNullOrEmpty(s))
    {
        // This would generate a warning:
        // Console.WriteLine(s.Length);
        return;
    }

    Console.WriteLine(s.Length); // Safe!
}

void VersionTest(string? s)
{
    if (!MyVersion.TryParse(s, out var version))
    {
        // This would generate a warning:
        // Console.WriteLine(version.Major);
        return;
    }

    Console.WriteLine(version.Major); // Safe!
}

void QueueTest(MyQueue<string> q)
{
    if (!q.TryDequeue(out var result))
    {
        // This would generate a warning:
        // Console.WriteLine(result.Length);
        return;
    }

    Console.WriteLine(result.Length); // Safe!
}

我们可以看到:

  • 如果 IsNullOrEmpty 返回 false, 那么 s 不为空并且可以安全的访问内在的属性
  • 如果 TryParse 返回 true, 那么 version 不为空并且可以安全的访问内在的属性
  • 如果 TryDequeue 返回 false, 那么 result 可能为空,反之则不为空

输入与输出的可空性依赖

思考一下这个例子:

class MyPath
{
    public static string? GetFileName(string? path)
    {
        ...
    }
}

在这个例子中,我们的参数和返回值都是一个可空的 string,但是我们希望实现这样的一个效果:当参数 path 不为空的时候,返回值也不为空。

在这种情况下,返回参数的可空性依赖于传入的参数,我们要如何实现呢?

通过 NotNullIfNotNull(string) 这个属性,我们可以实现这个最有意思的需求,让我们看一下修改之后的代码:

class MyPath
{
    [return: NotNullIfNotNull("path")]
    public static string? GetFileName(string? path)
    {
        ...
    }
}

看一下效果:

void PathTest(string? path)
{
    var possiblyNullPath = MyPath.GetFileName(path);
    Console.WriteLine(possiblyNullPath.Length); // Warning: Dereference of a possibly null reference
    
    if (!string.IsNullOrEmpty(path))
    {
        var goodPath = MyPath.GetFileName(path);
        Console.WriteLine(goodPath.Length); // Safe!
    }
}

非常棒的一个特性!

总结

这就是 C# 8.0 Nullable Reference Types 特性的绝大部分的应用场景,还有一些较为小众的场景比如控制流属性: DoesNotReturnDoesNotReturnIf(bool) 没有介绍到,大家感兴趣的话可以自行去了解。

Nullable Reference Types 带来了什么呢?它使得程序在编译期更为安全,避免了运行时 NullReferenceException 的发生,我衷心希望大家都能应用上这个新特性,特别是开发公共库的作者们。而且因为这个特性是可以针对某个文件,某段代码进行开启或者关闭,是一个渐进式的特性,所以我们可以逐步引进,不会对项目产生影响。

Enjoy coding!