Tip
开发软件的新手? 从入门教程 开始 。
是否在其他语言中有经验? 如果你接触过 Kotlin 的可空类型、TypeScript 的 strictNullChecks,或 Swift 的可选类型,那么你对这种模型应该不陌生。 C# 使用静态分析和警告诊断,而不是单独的类型。 先快速浏览使用注释表达意图和空状态分析,然后转到教程:使用可为 null 和不可为 null 的引用类型表达设计意图来应用此功能。
可为空引用类型是一组功能,可尽量减少代码抛出 System.NullReferenceException 的可能性。 你需要声明哪些变量用于保存 null,哪些变量不是;当这些声明与代码中的使用方式不一致时,编译器就会发出警告。 程序的运行时行为保持不变。 可空引用类型纯粹是编译时特性。
三个构建模块协同工作:
-
变量注解(
string与string?)用于表明哪些引用可进行null。 - 空状态分析会跟踪代码中每个位置处的表达式值是不为 null还是可能为 null。
- API 上的属性描述更细微的协定,例如“此参数可以是
null,但返回值仅在参数为 null 时才为 null。
编译器将这些信号组合在一起以生成诊断。 关于不可为 null 的变量的警告表示,该变量可能会被赋予 null。 关于可为 null 的变量的警告意味着代码可能在未进行 null 检查的情况下 取消引用 它。
取消引用 意味着使用变量引用的值。 例如,若要对它调用方法(variable.Method())、读取属性(variable.Property或为其variable[0]编制索引)。 对值为 null 的变量进行解引用会在运行时引发异常。 任何一种警告都意味着代码的行为与其说明的设计不匹配。
可为空上下文
使用较新的 .NET 模板创建的项目会在项目文件中设置 <Nullable>enable</Nullable>,因此本文中的说明可直接按文中所述适用。 如果使用的是较旧的项目,请打开 .csproj 并检查 <PropertyGroup> 是否包含以下行;如果缺少,请添加它:
<Nullable>enable</Nullable>
有关迁移大型应用程序的更多信息,请参阅关于 可为 null 的迁移策略 的文章,以了解更多设置和指令。
用注释表达意图
默认情况下,每个引用类型变量 不可为 null 。 追加 ? 以声明 可以为 null 的 引用类型:
public static void Annotations()
{
string required = "always set"; // non-nullable: assigning null produces a warning
string? optional = null; // nullable: holding null is allowed
Console.WriteLine(required.Length);
if (optional is not null)
{
Console.WriteLine(optional.Length);
}
}
批注不会更改运行时类型。
string 和 string? 都是 System.String。
? 告知编译器你的设计意图。 该意向塑造编译器生成的警告:
- 不可为 null 的变量的默认 null 状态为 not-null。 如果分配的值可能是
null,编译器将发出警告。 - 可为 null 的变量,其默认的 null-state 是 maybe-null。 如果在未先检查该变量的情况下解引用它,编译器就会发出警告。
使用注解在类型系统中显式标明必需值和可选值。 下面的 Person 类型将 FirstName 和 LastName 声明为不可为 null——每个人都必须同时具备这两项——并将 MiddleName 声明为可为 null,因为不是每个人都有。
public sealed class Person(string firstName, string lastName)
{
public string FirstName { get; } = firstName;
public string? MiddleName { get; init; }
public string LastName { get; } = lastName;
public override string ToString() => MiddleName is null
? $"{FirstName} {LastName}"
: $"{FirstName} {MiddleName} {LastName}";
}
public static void DesignIntent()
{
Person p1 = new("Ada", "Lovelace") { MiddleName = "King" };
Console.WriteLine(p1);
// Output: Ada King Lovelace
Person p2 = new("Grace", "Hopper");
Console.WriteLine(p2);
// Output: Grace Hopper
}
批注驱动 ToString实现 。 由于 FirstName 和 LastName 均不可为 null,因此重写会直接在 插值字符串(将表达式嵌入 {} 占位符中的 $"..." 语法)中使用它们,而不进行 null 检查。
MiddleName 可为 null,因此重写方法会先检查 MiddleName 是否为 null,并且仅在其存在时才将其包含在内。 编译器会强制执行这种区分:如果代码在需要非空值的地方传入一个可能为 null 的值,就会发出警告;如果构造函数没有初始化某个非空成员,也会发出警告。
空状态分析
编译器跟踪每个表达式的 null 状态 。 状态是两个值之一:
-
not-null:已知表达式不是
null。 -
maybe-null:表达式可能是
null。
当编译器分析代码时,将更新局部变量的 null 状态。 有两项更改: 分配 和 null 检查。 赋值后,变量的 null 状态与右侧的表达式匹配。 如果表达式为 null 或可为 null,则变量可能为 null。 如果表达式是非空字面量,则变量变为非空。 进行 null 检查后,变量的 null 状态会反映所采用的分支。
public static void NullStateTracking()
{
string? message = null;
// Warning: dereference of a possibly null reference.
Console.WriteLine(message.Length);
message = "Hello, World!";
// No warning: the compiler tracks that message is now not-null.
Console.WriteLine(message.Length);
}
在前面的示例中,第一个取消引用会生成警告,因为message可能为 null。 将其赋值为非 null 字面量后,编译器知道message不为 null,因此第二次解引用是安全的。
空状态分析适用于 if 检查、模式匹配(例如用于测试值形状的 is null 或 is { } 之类的表达式),以及会循环或提前返回的控制流:
public sealed class Node(string name)
{
public string Name { get; } = name;
public Node? Parent { get; init; }
}
public static void FlowAnalysis(Node start)
{
Node? current = start;
while (current is not null)
{
// Inside the loop, the compiler knows current is not-null.
Console.WriteLine(current.Name);
current = current.Parent;
}
}
分析不会深入到方法体内部。 如果需要一种方法来向其调用方传达 null 状态,请对其签名使用 可为 null 的分析属性 。
使用 ! 忽略警告
有时,你比编译器更了解。
null 包容运算符!指示表达式非 null,即使分析结果并非如此:
public static void NullForgiving()
{
// "ada" matches a switch arm that returns a non-null string,
// but the return type is string? so the compiler treats the
// result as maybe-null.
string? maybeName = LookUpName("ada");
// The ! tells the compiler "trust me, this isn't null." We just
// passed "ada", which the switch maps to "Ada Lovelace".
int length = maybeName!.Length;
Console.WriteLine(length); // => 12
}
// Returns string? because the wildcard arm yields null.
private static string? LookUpName(string id) => id switch
{
"ada" => "Ada Lovelace",
_ => null,
};
请尽量少使用 !。 每出现一次这样的情况,就意味着编译器少了一个无法再为你提供保护的地方。 首选添加 null 检查、重新调整代码或批注相关 API,以便编译器自行得出正确的结论。
描述 API 协定的属性
参数或返回类型的注释并不总是具有足够的表现力。 方法可能接受可能为 null 的参数,但保证非 null 结果。 仅当测试方法的参数不为 null 时,才会返回 true 。 使用 可空性分析特性 表达这些约定:
public static bool IsPresent([NotNullWhen(true)] string? value) =>
!string.IsNullOrEmpty(value);
public static void NullAnalysisAttributes()
{
string? input = ReadInput();
if (IsPresent(input))
{
// No null-forgiving operator needed: the attribute tells the compiler
// input is not-null when IsPresent returns true.
Console.WriteLine(input.Length);
}
}
private static string? ReadInput() => "hello";
NotNullWhenAttribute会告知编译器:当 IsPresent 返回 true 时,该参数非 null。 在 if 代码块内,编译器会将 value 视为 非 null,无需使用 null 宽恕运算符。 从 .NET 5 起,所有.NET运行时 API 都会进行批注,因此分析有利于调用它们的任何代码。
已知缺陷
两种模式可以保留不可为 null 的引用 null ,而不显示警告。 这两种模式都是静态分析的限制,而不是代码中的 bug。
默认结构
可以使用 default 或 new() 创建一个包含不可为 null 的引用字段的结构体。 此方法使结构字段未初始化:
public struct Student
{
public string FirstName;
public string? MiddleName;
public string LastName;
}
public static void DefaultStructPitfall()
{
Student s = default; // No warning, but FirstName and LastName are null.
Console.WriteLine(s.FirstName?.Length ?? -1);
}
字段在运行时保留 null ,但编译器不会发出警告。 如果必须使用结构,首选 必需的成员,这些成员是调用方必须通过对象初始值设定项初始化的成员,或者调用方必须调用的参数化构造函数。
引用和结构的数组
不可为 null 的引用类型的新数组中,所有元素在被逐一赋值之前均为 null:
public static void ArrayPitfall()
{
string[] values = new string[3]; // Elements are null at run time.
Console.WriteLine(values[0]?.Length ?? -1);
string[] initialized = ["a", "b", "c"]; // Collection expression initializes every slot.
Console.WriteLine(initialized[0].Length);
}
同样的陷阱也适用于结构体数组:每个元素的初始值都是该结构体的默认值,因此每个元素中不可为 null 的引用字段的初始值也都是 null。
在创建数组时初始化数组元素。
集合表达式 ( [1, 2, 3] 文本语法)和 目标类型 new 化(当编译器可以推断类型时写入 new() )使完全初始化简洁。