准备工作
首先,我们要确认一下开启 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);
}
编译这段代码的话,我们会看到两个警告:
- 第一个警告是说,name 这个字段我们声明为不可空的引用类型,但是却赋值为空。
- 第二个警告是说,我们声明了一个可空的引用类型,直接使用它的属性的话可能会出现空引用,需要先进行判断。
看到这里,我们应该对这个特性有了一个基本的了解:可空的引用类型在使用时需要进行非空判断,非可空的引用类型在声明或者初始化的时候必须赋值,否则就会出现警告。
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?
作为可空类型呢?这个当作一个思考题,大家自行思考。
可空前置条件:AllowNull
和 DisallowNull
先来看一个例子:
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
}
这两个属性可以允许我们在需要的情况下使用单向的可空性或者不可空性。
可空后置条件: MaybeNull
和 NotNull
先看一下下面这个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
在找不到元素的时候返回 default
,default
在 T
为引用类型的时候为 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返回的 true
和 false
,决定了我们传进去的参数是 null
还是 notnull
.
我们希望实现下面三个效果:
- 当
IsNullOrEmpty
返回false
的时候,value
不为空 - 当
TryParse
返回true
的时候,version
不为空 - 当
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
特性的绝大部分的应用场景,还有一些较为小众的场景比如控制流属性: DoesNotReturn
和 DoesNotReturnIf(bool)
没有介绍到,大家感兴趣的话可以自行去了解。
Nullable Reference Types
带来了什么呢?它使得程序在编译期更为安全,避免了运行时 NullReferenceException
的发生,我衷心希望大家都能应用上这个新特性,特别是开发公共库的作者们。而且因为这个特性是可以针对某个文件,某段代码进行开启或者关闭,是一个渐进式的特性,所以我们可以逐步引进,不会对项目产生影响。
Enjoy coding!