可为空引用类型是一组功能,可最大程度地减小代码导致运行时引发 System.NullReferenceException 的可能性。 三项功能,可帮助避免这些异常,包括将引用类型显式标记为可为 null 的功能:
- 经过优化的静态流分析,用于在取消引用变量之前确定其是否为
null
。 - 属性,用于注释 API 以便流分析确定 null 状态。
- 开发人员使用的变量注释,用于显式声明变量的预期空状态。
编译器在编译时跟踪代码中每个表达式的 null 状态。 空状态 有两个值之一。
-
not-null:已知表达式为 not-
null
。 -
maybe-null:表达式可能是
null
。
变量注解确定引用类型变量的可空性:
-
不可为 null:如果将值
null
或 maybe-null 表达式分配给变量,编译器会发出警告。 不可为 Null 的变量的默认 null 状态 为 not-null。 -
可为 null:可以为变量赋值
null
或 maybe-null 表达式。 当变量的 null 状态为 maybe-null 时,如果取消引用变量,编译器会发出警告。 变量的默认 null 状态为 maybe-null。
本文的其余部分介绍了当你的代码可能取消引用null
值时,这三个功能区域如何生成警告。 取消引用变量意味着使用 .
(点)运算符访问其成员之一,如下例所示:
string message = "Hello, World!";
int length = message.Length; // dereferencing "message"
取消引用值为 null
的变量时,运行时会引发 System.NullReferenceException。
当使用[]
表示法访问对象的成员,但该对象为null
时,可能会生成类似警告:
using System;
public class Collection<T>
{
private T[] array = new T[100];
public T this[int index]
{
get => array[index];
set => array[index] = value;
}
}
public static void Main()
{
Collection<int> c = default;
c[10] = 1; // CS8602: Possible dereference of null
}
您将了解:
- 编译器的 null 状态分析:编译器如何确定表达式为 not-null 或 maybe-null。
- 应用于 API 的属性,这些 API 为编译器的 null 状态分析提供更多上下文。
- 可为 null 的变量注释,用于提供有关变量意向的信息。 批注对于字段、参数和返回值非常有用,用于设置默认 null 状态。
- 控制泛型类型参数的规则。 添加了新约束,因为类型参数可以是引用类型或值类型。 后缀
?
针对可为 null 的值类型和可为 null 的引用类型的实现方式不同。 - 可为空上下文可帮助你迁移大型项目。 在应用迁移过程中,你可以在应用的部件中启用可为空上下文的警告和注释。 解决更多警告后,可以为整个项目启用这两个设置。
最后,了解 struct
类型和数组中 null 状态分析的已知陷阱。
还可以通过关于 C# 中可为 Null 的安全性的学习模块了解这些概念。
空状态分析
空状态分析跟踪引用的空状态。 表达式为“not-null”或“maybe-null”。 编译器通过两种方式确定变量是否非 null:
- 该变量被赋值为一个已知的非 null值。
- 该变量已针对
null
进行检查,并且自该检查以来未分配该变量。
编译器无法确定为非 null 的任何变量均视为可能为 null。 如果意外取消引用 null
值,分析会发出警告。 编译器根据 null 状态生成警告。
- 变量为非 null 时,可安全地取消引用该变量。
- 变量可能为 null 时,必须先检查该变量,确保其不为 ,然后才能取消引用它
null
。
请考虑以下示例:
string? message = null;
// warning: dereference null.
Console.WriteLine($"The length of the message is {message.Length}");
var originalMessage = message;
message = "Hello, World!";
// No warning. Analysis determined "message" is not-null.
Console.WriteLine($"The length of the message is {message.Length}");
// warning!
Console.WriteLine(originalMessage.Length);
在上例中,编译器在打印第一条消息时确定 message
是否可能为 null。 对于第二条消息,没有警告。
originalMessage
可能为 null,因此最后一行代码发出警告。 下面的示例演示了一个更实际的用途,即遍历节点树直到根,并在遍历过程中处理每个节点:
void FindRoot(Node node, Action<Node> processNode)
{
for (var current = node; current != null; current = current.Parent)
{
processNode(current);
}
}
上述代码不会因取消引用变量 current
而生成任何警告。 静态分析确定当 current
可能为 null 时永不会被取消引用。 访问 current
以及将 null
传递给 current.Parent
操作之前,会检查变量 current
是否为 ProcessNode
。 上述示例演示了编译器如何在初始化、分配或与 比较时确定局部变量的 null 状态。
null 状态分析不会跟踪到调用的方法。 因此,在所有构造函数调用的常见帮助程序方法中初始化的字段可能会生成包含以下消息的警告:
在退出构造函数时,不可为 null 的属性“name”必须包含非 null 值。
可以通过以下两种方式之一消除这些警告:帮助程序方法上的构造函数链接或可以为 null 的属性。 下面的代码展示了每种情况的示例。
Person
类使用由所有其他构造函数调用的通用构造函数。
Student
类具有一个用 System.Diagnostics.CodeAnalysis.MemberNotNullAttribute 特性标注的辅助方法。
using System.Diagnostics.CodeAnalysis;
public class Person
{
public string FirstName { get; set; }
public string LastName { get; set; }
public Person(string firstName, string lastName)
{
FirstName = firstName;
LastName = lastName;
}
public Person() : this("John", "Doe") { }
}
public class Student : Person
{
public string Major { get; set; }
public Student(string firstName, string lastName, string major)
: base(firstName, lastName)
{
SetMajor(major);
}
public Student(string firstName, string lastName) :
base(firstName, lastName)
{
SetMajor();
}
public Student()
{
SetMajor();
}
[MemberNotNull(nameof(Major))]
private void SetMajor(string? major = default)
{
Major = major ?? "Undeclared";
}
}
可为 null 的状态分析和编译器生成的警告有助于通过取消引用 null
来避免程序错误。 有关解决可为 null 的警告的文章提供了用于更正代码中可能看到的警告的技术。 从空值状态分析生成的诊断仅仅是警告。
API 签名上的属性
null 状态分析需要开发人员的提示才能理解 API 的语义。 某些 API 提供 null 检查,它们应将变量的 null 状态从“可能为 null”更改为“非 null” 。 其他 API 返回非 null 或可能为 null 的表达式,具体取决于输入参数的 null 状态 。 例如,请考虑以下以大写形式显示消息的代码:
void PrintMessageUpper(string? message)
{
if (!IsNull(message))
{
Console.WriteLine($"{DateTime.Now}: {message.ToUpper()}");
}
}
bool IsNull(string? s) => s == null;
根据检查,所有开发人员都认为此代码安全,不应生成警告。 但是,编译器不知道 IsNull
提供 null 检查,并且将针对 message.ToUpper()
语句发出警告,认为 message
是一个 maybe-null 变量。 若要解决此警告,可使用 NotNullWhen
属性:
bool IsNull([NotNullWhen(false)] string? s) => s == null;
此属性会通知编译器,如果 IsNull
返回 false
,则参数 s
不为 null。 编译器在块内将message
的null 状态更改为if (!IsNull(message)) {...}
。 未发出任何警告。
属性详细说明了用于调用成员的对象实例的参数、返回值和成员的 null 状态。 若要详细了解每个属性,可查看关于可为 null 的引用属性的语言参考文章。 从 .NET 5 起,所有 .NET 运行时 API 都会进行批注。 您可以通过为 API 添加注释来提供参数和返回值的 null 状态 的语义信息,从而改进静态分析。
可为 null 的变量注释
null 状态分析为本地变量提供可靠的分析。 编译器需要你提供有关成员变量的更多信息。 编译器需要更多信息才能在成员的起始括号处设置所有字段的null 状态。 可使用任何可访问的构造函数来初始化对象。 如果某个成员字段曾设为 null
,则编译器必须在每个方法开始时假定其 null 状态是“可能为 null” 。
你可以使用标注来声明变量是可为 null 的引用类型还是不可为 null 的引用类型。 这些注释对变量的 null 状态进行重要声明:
- 引用不应为 null。 不可为 Null 的引用变量的默认状态为 not-null。 编译器会强制执行规则,确保在无需先检查这些变量是否为 null 的情况下,也能安全地解引用它们。
- 必须将变量初始化为非 null 值。
- 变量永远不能赋值为
null
。 当代码将可能为 null 的表达式分配给不应为 null 的变量时,编译器会发出警告。
-
引用可为 null。 可为 Null 的引用变量的默认状态为 maybe-null。 编译器会强制执行规则,以确保正确地检查
null
引用。- 只有在编译器可以保证该值不是
null
时,才能对变量取消引用。 - 可以使用默认值
null
来初始化这些变量,并且在其他代码中可以赋予它们null
的值。 - 代码将 maybe-null 表达式分配给可能为 null 的变量时,编译器不会发出警告。
- 只有在编译器可以保证该值不是
任何不可为 null 的引用变量都具有不为 Null 的初始 null 状态。 任何可为空的引用变量的初始 空状态都为 可能为空。
使用与可为空值类型相同的语法表示可为空引用类型:在变量的类型后附加一个 ?
。 例如,以下变量声明表示可为空的字符串变量 name
:
string? name;
启用可为 null 的引用类型时,任何未在类型名称中附加 ?
的变量都是“非空引用类型”。 这包括启用此功能时现有代码中的所有引用类型变量。 不过,任何隐式类型本地变量(使用 var
声明)都是可空引用类型。 如上述部分所示,静态分析确定局部变量的 null 状态,从而在取消引用前确定其是否为 maybe-null。
有时,当你知道变量不为 null,但编译器确定其“null 状态”是“可能为 null”时,你必须忽略警告。 可在变量名称后使用 null 容忍操作符!
来将 null 状态强制为非 null。 例如,如果知道 name
变量不为 null
,但编译器仍发出警告,你可编写以下代码来覆盖编译器的分析:
name!.Length;
可为 null 的引用类型和可为 null 的值类型提供类似的语义概念:变量可表示值或对象,或者该变量可以为 null
。 但可为 null 引用类型和可为 null 值类型的实现方式不同:可为 null 值类型是使用 System.Nullable<T> 实现的,而可为 null 引用类型是使用编译器读取的属性实现的。 例如,string?
和 string
由同一类型表示:System.String。 但 int?
和 int
分别由 System.Nullable<System.Int32>
和 System.Int32 表示。
可为 null 的引用类型是编译时功能。 这意味着调用方可以忽略警告,故意地将 null
用作期望非空引用的方法的参数。 库作者应纳入针对 null 参数值的运行时检查。 这是 ArgumentNullException.ThrowIfNull 在运行时针对 null 检查参数的首选项。 此外,如果删除了所有可为 null 注释(?
和 !
),则使用可为 null 注释的程序的运行时行为是相同的。 它们的唯一用途是表达设计意向,并为 null 状态分析提供信息。
重要
启用可为 null 的注释后,可以更改 Entity Framework Core 确定是否需要数据成员的方式。 你可在 Entity Framework Core 基础知识:使用可为 null 的引用类型一文中了解更多详细信息。
泛型
泛型需要通过详细的规则来处理任何类型参数 T?
的 T
。 由于历史原因以及可为 Null 的值类型和可为 Null 的引用类型的实现各不相同,这些规则必须详细。
可为 Null 的值类型是使用 System.Nullable<T> 结构实现的。
可为 Null 的引用类型实现为向编译器提供语义规则的类型注释。
- 如果
T
的类型参数为引用类型,则T?
会引用相应的可为 Null 的引用类型。 例如,如果T
是string
,则T?
是string?
。 - 如果
T
的类型参数是值类型,则T?
将引用相同的值类型T
。 例如,如果T
是int
,则T?
也是int
。 - 如果
T
的类型参数是可为 Null 的引用类型,则T?
将引用相同的可为 Null 的引用类型。 例如,如果T
是string?
,则T?
也是string?
。 - 如果
T
的类型参数是可为 Null 的值类型,则T?
将引用相同的可为 Null 的值类型。 例如,如果T
是int?
,则T?
也是int?
。
对于返回值,T?
等效于 [MaybeNull]T
;对于参数值,T?
等效于 [AllowNull]T
。 有关详细信息,请参阅语言参考中有关 null-state 分析的属性的文章。
可以使用约束指定不同的行为:
-
class
约束意味着T
必须是不可为 Null 的引用类型(例如string
)。 如果您使用可为空的引用类型,例如将string?
用作T
,编译器会生成警告。 -
class?
约束意味着T
必须是引用类型,可以是不可为 Null 的引用类型 (string
),也可以是可为空的引用类型(例如string?
)。 当类型参数是可为 Null 的引用类型(例如string?
)时,T?
的表达式将引用相同的可为 Null 的引用类型(例如string?
)。 -
notnull
约束意味着T
必须是不可为 null 引用类型或不可为 null 值类型。 如果为类型参数使用可为 Null 的引用类型或可为 Null 的值类型,编译器会生成警告。 此外,当T
是值类型时,返回值是该值类型,而不是相应的可为 Null 的值类型。
这些约束帮助为编译器提供有关如何使用 T
的更多信息。 这有助于开发人员为 T
选择类型,并且可在使用泛型类型的实例时提供更好的 null 状态分析。
可为空上下文
可为空上下文确定如何处理可为空的引用类型注释,以及由静态 null 状态分析产生的警告。 可空上下文包含两个标志:批注 设置和警告设置。
对于现有项目,默认禁用了注释和警告设置。 从 .NET 6(C# 10)开始,这两个标志在 新 项目中默认启用。 使用两个不同的标志来设置可为空的上下文的原因是:为了更轻松地迁移在引入可为空的引用类型之前开发的大型项目。
对于小型项目,可以启用可为 null 的引用类型、修复警告并继续。 但是,对于大型项目和多项目解决方案,可能会生成大量警告。 可以使用 pragma 在开始使用可为 null 的引用类型时逐文件启用可为 null 的引用类型。 在现有代码库中,防止引发 System.NullReferenceException 的新功能在启用后可能会导致服务中断:
- 所有显式类型引用变量都均解释为不可为 null 引用类型。
- 泛型中
class
约束的含义已更改为非可空引用类型。 - 由于这些新规则,将生成新警告。
可为 null 注释上下文决定了编译器的行为。 可为空上下文设置有四种组合:
-
两者都禁用:代码是 nullable-oblivious。
禁用与启用可为 null 引用类型之前的行为匹配,但新语法生成警告而不是错误。
- 禁用可为 null 警告。
- 所有引用类型变量都是可为 null 引用类型。
- 使用
?
后缀来声明可为 null 引用类型会生成警告。 - 可以使用 null 容忍运算符
!
,但它不起任何作用。
-
均启用:编译器启用所有 null 引用分析和所有语言功能。
- 启用所有新的可为 null 警告。
- 可使用
?
后缀来声明可为 null 引用类型。 - 没有
?
后缀的引用类型变量都是不可为 null 的引用类型。 - null 容忍运算符禁止对可能的
null
取消引用发出警告。
-
已启用警告
null
时,编译器会执行所有 null 分析并发出警告。- 启用所有新的可为 null 警告。
- 使用
?
后缀来声明可为 null 引用类型会生成警告。 - 所有引用类型变量均可为 null。 但是,除非使用 后缀声明成员,否则成员在所有方法的左大括号处都具有非 null 的 null 状态
?
。 - 可以使用 null 容忍运算符
!
。
-
已启用注释:当代码可能取消引用
null
或为可能为 null 的表达式赋予不可为 null 的变量时,编译器不会发出警告。- 禁用所有新的可为 null 警告。
- 可使用
?
后缀来声明可为 null 引用类型。 - 没有
?
后缀的引用类型变量都是不可为 null 的引用类型。 - 可以使用 null 容忍运算符
!
,但它不起任何作用。
可以在 .csproj 文件中使用 <Nullable>
元素 为项目设置空性注释上下文和空性警告上下文。 此元素配置编译器如何解释类型的可空性以及发出哪些警告。 下表显示了允许的值并汇总了它们指定的上下文。
上下文 | 取消引用警告 | 赋值警告 | 引用类型 |
? 后缀 |
! 运算符 |
---|---|---|---|---|---|
disable |
已禁用 | 已禁用 | 全部可为 null | 生成警告 | 没有作用 |
enable |
已启用 | 已启用 | 不可为 null,除非使用 ? 声明 |
声明可为 null 的类型 | 禁止为可能的 null 赋值显示警告 |
warnings |
已启用 | 不适用 | 所有成员都可为 null,但在方法的左大括号处,成员被视为 not-null | 生成警告 | 禁止为可能的 null 赋值显示警告 |
annotations |
已禁用 | 已禁用 | 不可为 null,除非使用 ? 声明 |
声明可为 null 的类型 | 没有作用 |
对于已禁用的上下文中编译的代码中的引用类型变量,其为 Null 性未知。 可将 null
文本或 maybe-null 变量分配给 Null 性未知的变量。 但是,nullable-oblivious 变量的默认状态为 not-null。
可选择最适合你的项目的设置:
- 对于根据诊断或新功能不想更新的旧项目,请选择“禁用”。
- 选择“警告”,确定代码可能引发 System.NullReferenceException 的位置。 可先处理这些警告,然后修改代码来启用不可为 null 引用类型。
- 选择注释来表达您的设计意图,然后再启用警告。
- 对于希望避免出现 null 引用异常的新项目和活动项目,请选择“启用”。
示例:
<Nullable>enable</Nullable>
还可以使用指令在源代码中的任何位置设置这些相同的标志。 这些指令在迁移大型代码库时最有用。
-
#nullable enable
:将注释和警告标志设置为启用。 -
#nullable disable
:将批注和警告标志设置为 禁用。 -
#nullable restore
:将批注标志和警告标志还原到项目设置。 -
#nullable disable warnings
:将警告标志设置为 禁用。 -
#nullable enable warnings
:将警告标志设置为 启用。 -
#nullable restore warnings
:将警告标志还原到项目设置。 -
#nullable disable annotations
:将批注标志设置为 禁用。 -
#nullable enable annotations
:将批注标志设置为 启用。 -
#nullable restore annotations
:将注释标志还原到项目设置。
对于任何代码行,可设置以下任意组合:
警告标志 | 注释标志 | 使用 |
---|---|---|
项目默认 | 项目默认 | 默认 |
使 | 禁用 | 修复分析警告 |
使 | 项目默认 | 修复分析警告 |
项目默认 | 使 | 添加类型注释 |
使 | 使 | 已迁移的代码 |
禁用 | 使 | 在修复警告之前注释代码 |
禁用 | 禁用 | 将旧代码添加到已迁移的项目 |
项目默认 | 禁用 | 很少 |
禁用 | 项目默认 | 很少 |
通过这九种组合,可精细控制编译器为代码发出的诊断。 你可在正在更新的任何区域中启用更多功能,而不显示尚未准备好解决的其他警告。
重要
全局可为空上下文不适用于生成的代码文件。 在这两种策略下,都会针对标记为“已生成”的任何源文件禁用可为空上下文。 这意味着生成的文件中的所有 API 都没有批注。 不会为生成的文件生成可为 null 的警告。 可采用四种方法将文件标记为“已生成”:
- 在 .editorconfig 中,在应用于该文件的部分中指定
generated_code = true
。 - 将
<auto-generated>
或<auto-generated/>
放在文件顶部的注释中。 它可以位于该注释中的任意行上,但注释块必须是该文件中的第一个元素。 - 文件名以 TemporaryGeneratedFile_ 开头
- 文件名用以 .designer.cs、.generated.cs、.g.cs 或 .g.i.cs 结尾。
生成器可以选择使用 #nullable
预处理器指令。
默认情况下,可为空注释和警告标志处于禁用状态。 这意味着无需更改现有代码即可进行编译,并且不会生成任何新警告。 从 .NET 6 开始,新项目在所有项目模板中包含 <Nullable>enable</Nullable>
元素,并且会将这些标志设置为启用。
这些选项提供两种不同的策略来更新现有代码库以使用可为 null 的引用类型。
已知缺陷
包含引用类型的数组和结构是可为 null 引用中以及确定 null 安全性的静态分析中的已知缺陷。 在这两种情况下,不可为 null 的引用均可初始化为 null
,且不会生成警告。
结构
包含不可为 null 的引用类型的结构允许为其分配 default
,而不会出现任何警告。 请考虑以下示例:
using System;
#nullable enable
public struct Student
{
public string FirstName;
public string? MiddleName;
public string LastName;
}
public static class Program
{
public static void PrintStudent(Student student)
{
Console.WriteLine($"First name: {student.FirstName.ToUpper()}");
Console.WriteLine($"Middle name: {student.MiddleName?.ToUpper()}");
Console.WriteLine($"Last name: {student.LastName.ToUpper()}");
}
public static void Main() => PrintStudent(default);
}
在前面的示例中,不可为 null 引用类型 PrintStudent(default)
和 FirstName
为 null 时,LastName
中未出现警告。
另一种较为常见的情况是处理泛型结构。 请考虑以下示例:
#nullable enable
public struct S<T>
{
public T Prop { get; set; }
}
public static class Program
{
public static void Main()
{
string s = default(S<string>).Prop;
}
}
在上述示例中,属性 Prop
的运行时类型为 null
。 它被分配到不可为 null 的字符串,且不会生成任何警告。
数组
数组也是可为 null 的引用类型中的已知缺陷。 请考虑以下示例,它不会生成任何警告:
using System;
#nullable enable
public static class Program
{
public static void Main()
{
string[] values = new string[10];
string s = values[0];
Console.WriteLine(s.ToUpper());
}
}
在前面的示例中,数组的声明显示它保留不可为 null 的字符串,而其元素都已初始化为 null
。 然后,为变量 s
分配一个 null
值(数组的第一个元素)。 最后,对变量 s
进行取消引用,导致了运行时异常。
构造函数
类的构造函数仍将调用终结器,即使该构造函数引发异常也是如此。
以下示例演示了该行为:
public class A
{
private string _name;
private B _b;
public A(string name)
{
ArgumentNullException.ThrowIfNullOrEmpty(name);
_name = name;
_b = new B();
}
~A()
{
Dispose();
}
public void Dispose()
{
_b.Dispose();
GC.SuppressFinalize(this);
}
}
public class B: IDisposable
{
public void Dispose() { }
}
public void Main()
{
var a = new A(string.Empty);
}
在前面的示例中,如果 System.NullReferenceException 参数为 _b.Dispose();
,则运行 name
时,将引发 null
。 当构造函数成功完成时,对 _b.Dispose();
的调用永远不会引发。 但是,编译器没有发出警告,因为静态分析无法确定方法(如构造函数)是否完成,而不会引发运行时异常。
另请参阅
- 可为 null 的引用类型规范
- 不受约束的类型参数批注
- 可为 null 引用教程简介
- Nullable(C# 编译器选项)
- CS8602:可能取消引用 null 警告