理解空安全
作者:Bob Nystrom
2020 年 7 月
空安全是我们在 Dart 中做出的最大改变,自从我们用 Dart 2.0 中健全的静态类型系统替换了最初不健全的可选类型系统以来。当 Dart 首次发布时,编译时空安全还是一项罕见的功能,需要长时间的介绍。今天,Kotlin、Swift、Rust 和其他语言都有自己针对这个已经变得非常熟悉的问题的解决方案。这是一个例子
// Without null safety:
bool isEmpty(String string) => string.length == 0;
void main() {
isEmpty(null);
}
如果你在没有空安全的情况下运行这个 Dart 程序,它会在调用 .length
时抛出一个 NoSuchMethodError
异常。null
值是 Null
类的一个实例,而 Null
没有 "length" getter。运行时故障很糟糕。在像 Dart 这样旨在最终用户设备上运行的语言中尤其如此。如果服务器应用程序失败,你通常可以在任何人注意到之前重启它。但是,当 Flutter 应用程序在用户的手机上崩溃时,他们会不高兴。当你的用户不高兴时,你也不会高兴。
开发者喜欢像 Dart 这样的静态类型语言,因为它们使类型检查器能够在编译时(通常直接在 IDE 中)发现代码中的错误。你越早发现错误,就能越早修复它。当语言设计者谈论“修复空引用错误”时,他们的意思是丰富静态类型检查器,以便语言可以检测到像上面尝试在可能为 null
的值上调用 .length
这样的错误。
对于这个问题,没有唯一正确的解决方案。Rust 和 Kotlin 都有自己的方法,这些方法在这些语言的上下文中是有意义的。本文档详细介绍了我们针对 Dart 的解决方案的所有细节。它包括对静态类型系统的更改以及一套其他的修改和新的语言特性,不仅让你可以编写空安全的代码,而且希望你能享受这样做。(剧透警告:最终结果与你今天编写 Dart 代码的方式非常接近。)
本文档很长。如果你想要更短的内容,只涵盖你需要了解的入门知识,请从概述开始。当你准备好进行更深入的理解并有时间时,请回到这里,以便你可以理解语言如何处理 null
,为什么我们这样设计它,以及如何编写惯用的、现代的、空安全的 Dart。(剧透警告:最终结果与你今天编写 Dart 代码的方式非常接近。)
语言可以解决空引用错误的各种方式各有优缺点。以下原则指导了我们所做的选择
代码应该默认是安全的。 如果你编写新的 Dart 代码并且不使用任何显式不安全的功能,它永远不会在运行时抛出空引用错误。所有可能的空引用错误都会被静态捕获。如果你想将一些检查推迟到运行时以获得更大的灵活性,你可以这样做,但你必须通过使用代码中以文本形式可见的某些功能来选择这样做。
换句话说,我们不是给你一件救生衣,然后让你记住每次出海时都要穿上它。相反,我们给你一艘不会沉的船。除非你跳下船,否则你会保持干燥。
空安全代码应该易于编写。 大多数现有的 Dart 代码在动态上是正确的,并且不会抛出空引用错误。你喜欢你现在的 Dart 程序的样子,我们希望你能继续以这种方式编写代码。安全不应以牺牲可用性、向类型检查器忏悔或不得不显着改变你的思维方式为代价。
生成的空安全代码应该是完全健全的。 在静态检查的上下文中,“健全性”对不同的人意味着不同的含义。对我们而言,在空安全的上下文中,这意味着如果表达式具有不允许
null
的静态类型,那么该表达式的任何可能的执行都永远不会计算为null
。该语言主要通过静态检查来提供此保证,但也可能涉及一些运行时检查。(但是,请注意第一个原则:这些运行时检查发生的任何地方都将是你的选择。)健全性对于用户信心很重要。一艘大部分时间都保持漂浮的船不是你会热衷于在公海上勇敢航行的船。但它对于我们无畏的编译器黑客也很重要。当语言对程序的语义属性做出硬性保证时,这意味着编译器可以执行假设这些属性为真的优化。当涉及到
null
时,这意味着我们可以生成更小的代码,消除不必要的null
检查,以及更快的代码,在调用方法之前不需要验证接收者是否为非null
。一个警告:我们仅保证完全空安全的 Dart 程序的健全性。Dart 支持包含较新的空安全代码和较旧的遗留代码的程序。在这些混合版本程序中,仍然可能发生空引用错误。在混合版本程序中,你在空安全的部分中获得所有静态安全优势,但在整个应用程序都为空安全之前,你不会获得完整的运行时健全性。
请注意,消除 null
不是目标。null
本身没有错。相反,能够表示值的缺失是非常有用的。将对特殊“缺失”值的支持直接构建到语言中,使得处理缺失值变得灵活且可用。它支撑着可选参数、方便的 ?.
空感知运算符和默认初始化。糟糕的不是 null
,而是 null
出现在你不期望它的地方 才会引起问题。
因此,通过空安全,我们的目标是让你控制和洞察 null
可以流经你的程序的位置,并确定它不会流向可能导致崩溃的地方。
类型系统中的可空性
#空安全从静态类型系统开始,因为其他一切都建立在此基础上。你的 Dart 程序中有一个完整的类型宇宙:原始类型(如 int
和 String
)、集合类型(如 List
)以及你和你使用的包定义的所有类和类型。在空安全之前,静态类型系统允许值 null
流入任何这些类型的表达式。
在类型理论术语中,Null
类型被视为所有类型的子类型

在某些表达式上允许的操作集(getter、setter、方法和运算符)由其类型定义。如果类型是 List
,你可以在其上调用 .add()
或 []
。如果它是 int
,你可以调用 +
。但是 null
值没有定义任何这些方法。允许 null
流入某些其他类型的表达式意味着任何这些操作都可能失败。这实际上是空引用错误的症结所在——每次失败都来自于尝试查找 null
上它没有的方法或属性。
不可空类型和可空类型
#空安全通过更改类型层次结构从根源上消除了这个问题。Null
类型仍然存在,但它不再是所有类型的子类型。相反,类型层次结构如下所示

由于 Null
不再是子类型,因此除了特殊的 Null
类之外,任何类型都不允许值 null
。我们已将所有类型默认设为不可空。如果你有一个 String
类型的变量,它将始终包含一个字符串。这样,我们就修复了所有空引用错误。
如果我们认为 null
根本没有用,我们可以在这里停止。但是 null
是有用的,因此我们仍然需要一种处理它的方法。可选参数就是一个很好的说明案例。考虑以下空安全的 Dart 代码
// Using null safety:
void makeCoffee(String coffee, [String? dairy]) {
if (dairy != null) {
print('$coffee with $dairy');
} else {
print('Black $coffee');
}
}
在这里,我们希望允许 dairy
参数接受任何字符串或值 null
,但不能接受其他任何值。为了表达这一点,我们通过在底层基本类型 String
的末尾加上 ?
来给 dairy
一个可空类型。在底层,这本质上是定义了底层类型和 Null
类型的联合。因此,如果 Dart 具有全功能的联合类型,则 String?
将是 String|Null
的简写形式。
使用可空类型
#如果你有一个具有可空类型的表达式,你可以用结果做什么?由于我们的原则是默认安全,因此答案是不多。我们不能让你在其上调用底层类型的方法,因为如果值是 null
,这些方法可能会失败
// Hypothetical unsound null safety:
void bad(String? maybeString) {
print(maybeString.length);
}
void main() {
bad(null);
}
如果我们让你运行它,这将崩溃。我们可以安全地让你访问的唯一方法和属性是由底层类型和 Null
类共同定义的。这只是 toString()
、==
和 hashCode
。因此,你可以将可空类型用作 map 键,将它们存储在集合中,将它们与其他值进行比较,并在字符串插值中使用它们,但仅此而已。
它们如何与不可空类型交互?将非可空类型传递给期望可空类型的东西始终是安全的。如果函数接受 String?
,那么传递 String
是允许的,因为它不会引起任何问题。我们通过使每个可空类型成为其底层类型的超类型来建模这一点。你也可以安全地将 null
传递给期望可空类型的东西,因此 Null
也是每个可空类型的子类型

但是,反过来,将可空类型传递给期望底层不可空类型的东西是不安全的。期望 String
的代码可能会在值上调用 String
方法。如果你将 String?
传递给它,null
可能会流入,这可能会失败
// Hypothetical unsound null safety:
void requireStringNotNull(String definitelyString) {
print(definitelyString.length);
}
void main() {
String? maybeString = null; // Or not!
requireStringNotNull(maybeString);
}
这个程序是不安全的,我们不应该允许它。但是,Dart 一直都有一个叫做隐式向下转型的东西。例如,如果你将 Object
类型的值传递给期望 String
的函数,类型检查器会允许它
// Without null safety:
void requireStringNotObject(String definitelyString) {
print(definitelyString.length);
}
void main() {
Object maybeString = 'it is';
requireStringNotObject(maybeString);
}
为了保持健全性,编译器会在 requireStringNotObject()
的参数上静默插入一个 as String
强制转换。该强制转换可能会失败并在运行时抛出异常,但在编译时,Dart 会说这是 OK 的。由于不可空类型被建模为可空类型的子类型,因此隐式向下转型会让你将 String?
传递给期望 String
的东西。允许这样做会违反我们默认安全的目标。因此,通过空安全,我们完全删除了隐式向下转型。
这使得对 requireStringNotNull()
的调用产生编译错误,这正是你想要的。但这也意味着所有隐式向下转型都变成编译错误,包括对 requireStringNotObject()
的调用。你必须自己添加显式向下转型
// Using null safety:
void requireStringNotObject(String definitelyString) {
print(definitelyString.length);
}
void main() {
Object maybeString = 'it is';
requireStringNotObject(maybeString as String);
}
我们认为这是一个总体上好的改变。我们的印象是,大多数用户从不喜欢隐式向下转型。特别是,你以前可能因此而吃过亏
// Without null safety:
List<int> filterEvens(List<int> ints) {
return ints.where((n) => n.isEven);
}
找出错误了吗?.where()
方法是惰性的,因此它返回 Iterable
,而不是 List
。此程序可以编译,但在尝试将该 Iterable
强制转换为 filterEvens
声明它返回的 List
类型时,会在运行时抛出异常。随着隐式向下转型的删除,这变成了一个编译错误。
我们说到哪了?对了,OK,所以好像我们将程序中的类型宇宙分成了两半

有一个不可空类型区域。这些类型允许你访问所有有趣的方法,但永远不能包含 null
。然后,还有所有相应的可空类型的并行系列。这些类型允许 null
,但你不能对它们做太多事情。我们允许值从不可空侧流向可空侧,因为这样做是安全的,但反之则不然。
看起来可空类型基本上是无用的。它们没有方法,你无法摆脱它们。别担心,我们有一整套功能来帮助你将值从可空的一半转移到另一半,我们很快就会讲到。
顶层类型和底层类型
#本节有点深奥。除非你喜欢类型系统的东西,否则你可以跳过它,除了最后两个要点。想象一下程序中的所有类型,它们之间有子类型和超类型之间的边。如果你要绘制它,就像本文档中的图表一样,它将形成一个巨大的有向图,超类型(如 Object
)靠近顶部,叶类(如你自己的类型)靠近底部。
如果该有向图在顶部汇聚到一个点,其中有一个类型是超类型(直接或间接),则该类型称为顶层类型。同样,如果底部有一个奇怪的类型,它是每种类型的子类型,则你有一个底层类型。(在这种情况下,你的有向图是一个格。)
如果你的类型系统具有顶层类型和底层类型,那将很方便,因为这意味着类型级别操作(如最小上界,类型推断使用它来根据条件表达式的两个分支的类型来计算条件表达式的类型)始终可以生成类型。在空安全之前,Object
是 Dart 的顶层类型,Null
是其底层类型。
由于 Object
现在是不可空的,因此它不再是顶层类型。Null
不是它的子类型。Dart 没有命名的顶层类型。如果你需要顶层类型,你想要 Object?
。同样,Null
不再是底层类型。如果是这样,一切都仍然是可空的。相反,我们添加了一个名为 Never
的新底层类型

在实践中,这意味着
如果你想指示你允许任何类型的值,请使用
Object?
而不是Object
。事实上,使用Object
变得非常不寻常,因为该类型意味着“可以是任何可能的值,除了这个奇怪地禁止的值null
”。在极少数情况下,你需要底层类型,请使用
Never
而不是Null
。这对于指示函数永不返回以帮助可达性分析特别有用。如果你不知道是否需要底层类型,你可能不需要。
确保正确性
#我们将类型宇宙分为可空和不可空两半。为了保持健全性和我们的原则,即除非你要求,否则你永远不会在运行时遇到空引用错误,我们需要保证 null
永远不会出现在不可空侧的任何类型中。
消除隐式向下转型并将 Null
作为底层类型删除,涵盖了类型通过程序在赋值时以及从函数调用中的参数流入的所有主要位置。null
可能潜入的主要剩余位置是变量首次出现时以及你离开函数时。因此,还有一些额外的编译错误
无效的返回
#如果函数具有不可空的返回类型,则函数中的每个路径都必须到达返回值的 return
语句。在空安全之前,Dart 对缺少返回非常宽松。例如
// Without null safety:
String missingReturn() {
// No return.
}
如果你分析这个,你会得到一个温和的提示,也许你忘记了返回,但如果没有,也没什么大不了的。这是因为如果执行到达函数体的末尾,则 Dart 会隐式返回 null
。由于每种类型都是可空的,因此技术上此函数是安全的,即使这可能不是你想要的。
使用健全的不可空类型,此程序是完全错误和不安全的。在空安全下,如果具有不可空返回类型的函数无法可靠地返回值,则会收到编译错误。通过“可靠地”,我的意思是语言分析函数中的所有控制流路径。只要它们都返回一些东西,它就会感到满意。分析非常智能,因此即使是这个函数也是 OK 的
// Using null safety:
String alwaysReturns(int n) {
if (n == 0) {
return 'zero';
} else if (n < 0) {
throw ArgumentError('Negative values not allowed.');
} else {
if (n > 1000) {
return 'big';
} else {
return n.toString();
}
}
}
我们将在下一节更深入地探讨新的流分析。
未初始化的变量
#当你声明变量时,如果你没有给它显式初始值设定项,Dart 会使用 null
默认初始化变量。这很方便,但如果变量的类型是不可空的,则显然完全不安全。因此,我们必须收紧不可空变量的限制
顶层变量和静态字段声明必须具有初始值设定项。 由于可以从程序中的任何位置访问和赋值这些变量,因此编译器不可能保证变量在使用之前已被赋予值。唯一安全的选择是要求声明本身具有产生正确类型值的初始化表达式
dart// Using null safety: int topLevel = 0; class SomeClass { static int staticField = 0; }
实例字段必须在声明时具有初始值设定项、使用初始化形式参数或在构造函数的初始化列表中初始化。 这有很多术语。以下是一些示例
dart// Using null safety: class SomeClass { int atDeclaration = 0; int initializingFormal; int initializationList; SomeClass(this.initializingFormal) : initializationList = 0; }
换句话说,只要字段在你到达构造函数体之前有一个值,你就没事了。
局部变量是最灵活的情况。不可空局部变量不需要具有初始值设定项。这完全可以
dart// Using null safety: int tracingFibonacci(int n) { int result; if (n < 2) { result = n; } else { result = tracingFibonacci(n - 2) + tracingFibonacci(n - 1); } print(result); return result; }
规则仅是局部变量在使用前必须明确赋值。 我们也需要依赖我提到的新流分析来实现这一点。只要变量的每次使用路径都首先初始化它,则使用就是 OK 的。
可选参数必须具有默认值。 如果你没有为可选的位置或命名参数传递参数,则语言会使用默认值填充它。如果你没有指定默认值,则默认默认值为
null
,如果参数的类型是不可空的,则这不起作用。因此,如果你希望参数是可选的,你需要使其可空或指定有效的非
null
默认值。
这些限制听起来很繁重,但在实践中并没有那么糟糕。它们与围绕 final
变量的现有限制非常相似,并且你可能已经使用这些限制多年而没有真正注意到。另外,请记住,这些仅适用于不可空变量。你始终可以将类型设为可空,然后获得默认初始化为 null
。
即便如此,这些规则确实会引起摩擦。幸运的是,我们有一套新的语言特性来润滑这些新限制减慢你速度的最常见模式。但是,首先,是时候谈谈流分析了。
流分析
#控制流分析已经在编译器中存在多年了。它在很大程度上对用户是隐藏的,并且在编译器优化期间使用,但是一些较新的语言已经开始将相同的技术用于可见的语言特性。Dart 已经以类型提升的形式具有少量流分析
// With (or without) null safety:
bool isEmptyList(Object object) {
if (object is List) {
return object.isEmpty; // <-- OK!
} else {
return false;
}
}
请注意在标记行上,我们如何在 object
上调用 isEmpty
。该方法在 List
上定义,而不是在 Object
上定义。这之所以有效,是因为类型检查器查看程序中的所有 is
表达式和控制流路径。如果某些控制流结构的 body 仅在变量的某个 is
表达式为真时才执行,则在该 body 内部,变量的类型会“提升”为经过测试的类型。
在此示例中,if
语句的 then 分支仅在 object
实际包含列表时才运行。因此,Dart 将 object
提升为 List
类型,而不是其声明的类型 Object
。这是一个方便的功能,但它非常有限。在空安全之前,以下功能相同的程序不起作用
// Without null safety:
bool isEmptyList(Object object) {
if (object is! List) return false;
return object.isEmpty; // <-- Error!
}
同样,只有当 object
包含列表时,你才能到达 .isEmpty
调用,因此此程序在动态上是正确的。但是类型提升规则不够智能,无法看到 return
语句意味着只有当 object
是列表时才能到达第二条语句。
对于空安全,我们采用了这种有限的分析,并使其在几个方面变得更加强大。
可达性分析
#首先,我们修复了长期存在的抱怨,即类型提升对于早期返回和其他不可达代码路径不够智能。在分析函数时,它现在考虑了 return
、break
、throw
以及执行可能在函数中提前终止的任何其他方式。在空安全下,这个函数
// Using null safety:
bool isEmptyList(Object object) {
if (object is! List) return false;
return object.isEmpty;
}
现在这样做完全有效。由于当 object
不是 List
时,if
语句会退出函数,因此 Dart 会将 object
提升为第二个语句中的 List
类型。这是一个非常好的改进,即使对于与可空性无关的代码,也能帮助很多 Dart 代码。
Never 用于不可达代码
#你也可以编程实现这种可达性分析。新的底部类型 Never
没有值。(什么样的值同时是 String
、bool
和 int
?)那么表达式具有 Never
类型意味着什么?这意味着表达式永远无法成功完成求值。它必须抛出异常、中止或以其他方式确保周围期望表达式结果的代码永远不会运行。
事实上,根据语言规范,throw
表达式的静态类型是 Never
。Never
类型在核心库中声明,你可以将其用作类型注解。也许你有一个辅助函数,可以更轻松地抛出某种类型的异常
// Using null safety:
Never wrongType(String type, Object value) {
throw ArgumentError('Expected $type, but was ${value.runtimeType}.');
}
你可以像这样使用它
// Using null safety:
class Point {
final double x, y;
bool operator ==(Object other) {
if (other is! Point) wrongType('Point', other);
return x == other.x && y == other.y;
}
// Constructor and hashCode...
}
这个程序分析时没有错误。请注意,==
方法的最后一行访问了 other
上的 .x
和 .y
。即使该函数没有任何 return
或 throw
,它也已被提升为 Point
类型。控制流分析知道 wrongType()
的声明类型是 Never
,这意味着 if
语句的 then 分支必须以某种方式中止。由于只有当 other
是 Point
类型时才能到达第二个语句,因此 Dart 会将其提升。
换句话说,在你自己的 API 中使用 Never
可以扩展 Dart 的可达性分析。
明确赋值分析
#我之前在讨论局部变量时简要提到了这一点。Dart 需要确保非可空局部变量在读取之前始终被初始化。我们使用明确赋值分析来尽可能灵活地处理这个问题。语言会分析每个函数体,并跟踪通过所有控制流路径对局部变量和参数的赋值。只要变量在到达变量的任何使用点的每条路径上都被赋值,该变量就被认为是已初始化的。这允许你声明一个没有初始值设定项的变量,然后在之后使用复杂的控制流对其进行初始化,即使该变量具有非可空类型。
我们还使用明确赋值分析来使 final 变量更加灵活。在空安全之前,如果你需要以任何有趣的方式初始化 final
局部变量,可能会很困难
// Using null safety:
int tracingFibonacci(int n) {
final int result;
if (n < 2) {
result = n;
} else {
result = tracingFibonacci(n - 2) + tracingFibonacci(n - 1);
}
print(result);
return result;
}
这将是一个错误,因为 result
变量是 final
的,但没有初始值设定项。在空安全下,使用更智能的流程分析,这个程序是可以的。分析可以判断出 result
在每个控制流路径上都肯定只初始化一次,因此满足了将变量标记为 final
的约束。
空值检查时的类型提升
#更智能的流程分析帮助了很多 Dart 代码,即使是与可空性无关的代码。但我们现在做出这些改变并非巧合。我们已将类型划分为可空和非可空集合。如果你有一个可空类型的值,你实际上无法用它做任何有用的事情。在值为 null
的情况下,这种限制是好的。它可以防止你崩溃。
但是如果值不是 null
,那么最好能够将其移动到非可空侧,这样你就可以调用它的方法。对于局部变量和参数(以及 Dart 3.2 中的私有 final 字段),流程分析是执行此操作的主要方法之一。我们扩展了类型提升,使其也查看 == null
和 != null
表达式。
如果你检查一个可空类型的局部变量以查看它是否不为 null
,那么 Dart 会将该变量提升为底层的非可空类型
// Using null safety:
String makeCommand(String executable, [List<String>? arguments]) {
var result = executable;
if (arguments != null) {
result += ' ' + arguments.join(' ');
}
return result;
}
在这里,arguments
具有可空类型。通常,这会阻止你对其调用 .join()
。但是,由于我们在 if
语句中保护了该调用,该语句检查以确保该值不为 null
,因此 Dart 会将其从 List<String>?
提升为 List<String>
,并允许你对其调用方法或将其传递给期望非可空列表的函数。
这听起来像是一件相当小的事情,但是这种基于流程的空值检查提升是使大多数现有 Dart 代码在空安全下工作的原因。大多数 Dart 代码在动态上是正确的,并且通过在调用方法之前检查 null
来避免抛出空引用错误。新的空值检查流程分析将这种动态正确性转变为可证明的静态正确性。
当然,它也适用于我们为可达性所做的更智能的分析。上面的函数可以同样好地写成
// Using null safety:
String makeCommand(String executable, [List<String>? arguments]) {
var result = executable;
if (arguments == null) return result;
return result + ' ' + arguments.join(' ');
}
该语言在哪些类型的表达式会导致提升方面也更加智能。显式的 == null
或 != null
当然可以工作。但是使用 as
的显式类型转换、赋值或后缀 !
运算符(我们将在稍后介绍)也会导致提升。总的目标是,如果代码在动态上是正确的,并且静态地弄清楚这一点是合理的,那么分析应该足够聪明地做到这一点。
请注意,类型提升最初仅适用于局部变量,现在也适用于 Dart 3.2 中的私有 final 字段。有关使用非局部变量的更多信息,请参阅使用可空字段。
不必要的代码警告
#拥有更智能的可达性分析并了解 null
如何在你的程序中流动有助于确保你添加代码来处理 null
。但我们也可以使用相同的分析来检测你不需要的代码。在空安全之前,如果你写了类似这样的代码
// Using null safety:
String checkList(List<Object> list) {
if (list?.isEmpty ?? false) {
return 'Got nothing';
}
return 'Got something';
}
Dart 无法知道空感知 ?.
运算符是否有用。就它所知,你可以将 null
传递给函数。但是在空安全的 Dart 中,如果你使用现在的非可空 List
类型注释了该函数,那么它知道 list
永远不会是 null
。这意味着 ?.
永远不会做任何有用的事情,你可以并且应该只使用 .
。
为了帮助你简化代码,既然静态分析足够精确地检测到这一点,我们已经为像这样不必要的代码添加了警告。在非可空类型上使用空感知运算符,甚至像 == null
或 != null
这样的检查,都会报告为警告。
而且,当然,这也与非可空类型提升协同工作。一旦变量被提升为非可空类型,如果你再次冗余地检查它是否为 null
,你就会收到警告
// Using null safety:
String checkList(List<Object>? list) {
if (list == null) return 'No list';
if (list?.isEmpty ?? false) {
return 'Empty list';
}
return 'Got something';
}
你会在这里的 ?.
上收到警告,因为在它执行时,我们已经知道 list
不可能是 null
。这些警告的目标不仅仅是清理无意义的代码。通过删除不需要的 null
检查,我们确保剩下的有意义的检查突出显示。我们希望你能够查看你的代码并看到 null
可能在何处流动。
使用可空类型
#我们现在已经将 null
限制在可空类型集中。通过流程分析,我们可以安全地让一些非 null
值跳过栅栏,到达非可空侧,在那里我们可以使用它们。这是一个很大的进步,但如果我们止步于此,结果系统仍然会受到痛苦的限制。流程分析仅对局部变量、参数和私有 final 字段有帮助。
为了尽可能多地重新获得 Dart 在空安全之前的灵活性——并在某些地方超越它——我们还有一些其他新功能。
更智能的空感知方法
#Dart 的空感知运算符 ?.
比空安全早得多。运行时语义声明,如果接收者是 null
,则右侧的属性访问将被跳过,并且表达式的求值结果为 null
// Without null safety:
String notAString = null;
print(notAString?.length);
此代码不会抛出异常,而是打印“null”。空感知运算符是使 Dart 中可空类型可用的好工具。虽然我们不能让你在可空类型上调用方法,但我们可以并且确实允许你在其上使用空感知运算符。程序的后空安全版本是
// Using null safety:
String? notAString = null;
print(notAString?.length);
它的工作方式与之前的版本完全相同。
但是,如果你曾经在 Dart 中使用过空感知运算符,那么你可能在使用方法链中使用它们时遇到过一个烦恼。假设你想查看一个可能不存在的字符串的长度是否为偶数(我知道这不是一个特别现实的问题,但请配合一下)
// Using null safety:
String? notAString = null;
print(notAString?.length.isEven);
即使这个程序使用了 ?.
,它仍然会在运行时抛出异常。问题是 .isEven
表达式的接收者是其左侧整个 notAString?.length
表达式的结果。该表达式的求值结果为 null
,因此我们在尝试调用 .isEven
时会遇到空引用错误。如果你曾经在 Dart 中使用过 ?.
,你可能已经艰难地了解到,在使用一次空感知运算符后,你必须将其应用于链中的每个属性或方法
String? notAString = null;
print(notAString?.length?.isEven);
这很烦人,但更糟糕的是,它掩盖了重要的信息。考虑
// Using null safety:
showGizmo(Thing? thing) {
print(thing?.doohickey?.gizmo);
}
这是一个问题:Thing
上的 doohickey
getter 可以返回 null
吗?看起来可能可以,因为你在结果上使用了 ?.
。但也有可能第二个 ?.
只是为了处理 thing
为 null
的情况,而不是 doohickey
的结果。你无法分辨。
为了解决这个问题,我们借鉴了 C# 在设计相同功能时的一个聪明想法。当你在方法链中使用空感知运算符时,如果接收者的求值结果为 null
,那么方法链的其余部分将被短路并跳过。这意味着如果 doohickey
具有非可空返回类型,那么你可以并且应该写成
// Using null safety:
void showGizmo(Thing? thing) {
print(thing?.doohickey.gizmo);
}
事实上,如果你不这样做,你会在第二个 ?.
上收到不必要的代码警告。如果你看到像这样的代码
// Using null safety:
void showGizmo(Thing? thing) {
print(thing?.doohickey?.gizmo);
}
那么你可以肯定这表示 doohickey
本身具有可空返回类型。每个 ?.
都对应一个可能导致 null
流入方法链的唯一路径。这使得方法链中的空感知运算符既更简洁又更精确。
在我们进行此操作时,我们还添加了几个其他空感知运算符
// Using null safety:
// Null-aware cascade:
receiver?..method();
// Null-aware index operator:
receiver?[index];
没有空感知函数调用运算符,但你可以写成
// Allowed with or without null safety:
function?.call(arg1, arg2);
非空断言运算符
#使用流程分析将可空变量移动到世界的非可空侧的伟大之处在于,这样做是可证明安全的。你可以在以前可空的变量上调用方法,而不会放弃非可空类型的任何安全性或性能。
但是,可空类型的许多有效用法无法以令静态分析满意的方式证明是安全的。例如
// Using null safety, incorrectly:
class HttpResponse {
final int code;
final String? error;
HttpResponse.ok()
: code = 200,
error = null;
HttpResponse.notFound()
: code = 404,
error = 'Not found';
@override
String toString() {
if (code == 200) return 'OK';
return 'ERROR $code ${error.toUpperCase()}';
}
}
如果你尝试运行此代码,则会在调用 toUpperCase()
时收到编译错误。error
字段是可空的,因为在成功响应中它不会有值。我们可以通过检查类来看到,当 error
为 null
时,我们永远不会访问它。但这需要理解 code
的值与 error
的可空性之间的关系。类型检查器看不到这种联系。
换句话说,我们这些代码的人工维护者知道在我们使用 error
的时候它不会是 null
,我们需要一种方法来断言这一点。通常,你使用 as
类型转换来断言类型,你也可以在这里做同样的事情
// Using null safety:
String toString() {
if (code == 200) return 'OK';
return 'ERROR $code ${(error as String).toUpperCase()}';
}
将 error
转换为非可空 String
类型,如果转换失败,则会抛出运行时异常。否则,它会给我们一个非可空字符串,然后我们可以在其上调用方法。
“消除可空性”的情况经常出现,因此我们有一种新的简写语法。后缀感叹号 (!
) 获取左侧的表达式,并将其转换为其底层的非可空类型。因此,上面的函数等效于
// Using null safety:
String toString() {
if (code == 200) return 'OK';
return 'ERROR $code ${error!.toUpperCase()}';
}
当底层类型冗长时,这个单字符的“bang 运算符”特别方便。仅仅为了从某个类型中消除单个 ?
,就必须写出 as Map<TransactionProviderFactory, List<Set<ResponseFilter>>>
,这将非常烦人。
当然,与任何类型转换一样,使用 !
会损失静态安全性。必须在运行时检查类型转换以保持健全性,并且它可能会失败并抛出异常。但是你可以控制在何处插入这些类型转换,并且你始终可以通过查看代码来看到它们。
延迟变量
#类型检查器无法证明代码安全性的最常见位置是围绕顶层变量和字段。这是一个例子
// Using null safety, incorrectly:
class Coffee {
String _temperature;
void heat() { _temperature = 'hot'; }
void chill() { _temperature = 'iced'; }
String serve() => _temperature + ' coffee';
}
void main() {
var coffee = Coffee();
coffee.heat();
coffee.serve();
}
在这里,heat()
方法在 serve()
之前被调用。这意味着 _temperature
将在使用之前初始化为非空值。但是,静态分析无法确定这一点。(对于像这样的简单示例,这可能是可能的,但尝试跟踪类的每个实例的状态的通用情况是棘手的。)
由于类型检查器无法分析字段和顶层变量的用法,因此它有一个保守规则,即非可空字段必须在其声明处(或实例字段的构造函数初始化列表中)初始化。因此,Dart 会在此类上报告编译错误。
你可以通过使字段可空,然后在用法上使用空断言运算符来修复错误
// Using null safety:
class Coffee {
String? _temperature;
void heat() { _temperature = 'hot'; }
void chill() { _temperature = 'iced'; }
String serve() => _temperature! + ' coffee';
}
这可以正常工作。但它向类的维护者发送了一个令人困惑的信号。通过将 _temperature
标记为可空,你暗示 null
是该字段的有用、有意义的值。但这不是本意。_temperature
字段永远不应在其 null
状态下被观察到。
为了处理延迟初始化的常见状态模式,我们添加了一个新的修饰符 late
。你可以像这样使用它
// Using null safety:
class Coffee {
late String _temperature;
void heat() { _temperature = 'hot'; }
void chill() { _temperature = 'iced'; }
String serve() => _temperature + ' coffee';
}
请注意,_temperature
字段具有非可空类型,但未初始化。此外,在使用时没有显式的空断言。你可以将一些模型应用于 late
的语义,但我认为它就像这样:late
修饰符意味着“在运行时而不是在编译时强制执行此变量的约束”。“late”这个词几乎描述了它强制执行变量保证的时间。
在这种情况下,由于字段未明确初始化,因此每次读取字段时,都会插入运行时检查以确保已为其分配值。如果尚未分配,则会抛出异常。为变量提供 String
类型意味着“你不应该看到我的值不是字符串”,而 late
修饰符意味着“在运行时验证这一点”。
在某些方面,late
修饰符比使用 ?
更“神奇”,因为对字段的任何使用都可能失败,并且在使用位置没有任何文本可见。但是你确实需要在声明时编写 late
才能获得此行为,并且我们相信,在那里看到修饰符对于可维护性来说已经足够明确。
作为回报,你获得了比使用可空类型更好的静态安全性。因为字段的类型现在是非可空的,所以尝试将 null
或可空 String
分配给字段是一个编译错误。late
修饰符允许你延迟初始化,但仍然禁止你像对待可空变量一样对待它。
延迟初始化
#late
修饰符还有一些其他特殊功能。看起来可能很矛盾,但你可以在具有初始值设定项的字段上使用 late
// Using null safety:
class Weather {
late int _temperature = _readThermometer();
}
当你这样做时,初始值设定项会变成惰性的。它不会在实例构造后立即运行,而是被延迟并在第一次访问该字段时惰性地运行。换句话说,它的工作方式与顶层变量或静态字段上的初始值设定项完全相同。当初始化表达式成本高昂并且可能不需要时,这会很方便。
当你对实例字段使用 late
时,惰性地运行初始值设定项会给你带来额外的奖励。通常,实例字段初始值设定项无法访问 this
,因为在所有字段初始值设定项完成之前,你无法访问新对象。但是对于 late
字段,情况不再如此,因此你可以访问 this
,调用方法或访问实例上的字段。
延迟 final 变量
#你还可以将 late
与 final
结合使用
// Using null safety:
class Coffee {
late final String _temperature;
void heat() { _temperature = 'hot'; }
void chill() { _temperature = 'iced'; }
String serve() => _temperature + ' coffee';
}
与普通的 final
字段不同,你不必在其声明或构造函数初始化列表中初始化该字段。你可以在运行时稍后为其赋值。但是你只能为其赋值一次,并且在运行时检查该事实。如果你尝试为其赋值多次(例如,在此处同时调用 heat()
和 chill()
),则第二次赋值会抛出异常。这是对最终初始化且之后不可变的状态进行建模的好方法。
换句话说,新的 late
修饰符与 Dart 的其他变量修饰符结合使用,涵盖了 Kotlin 中的 lateinit
和 Swift 中的 lazy
的大部分功能空间。如果你想要一些局部惰性求值,你甚至可以在局部变量上使用它。
必需的命名参数
#为了保证你永远不会看到具有非可空类型的 null
参数,类型检查器要求所有可选参数都必须具有可空类型或默认值。如果你想要一个具有非可空类型且没有默认值的命名参数怎么办?这意味着你想要要求调用者始终传递它。换句话说,你想要一个命名但不是可选的参数。
我用下表可视化了 Dart 的各种参数类型
mandatory optional
+------------+------------+
positional | f(int x) | f([int x]) |
+------------+------------+
named | ??? | f({int x}) |
+------------+------------+
由于不明原因,Dart 长期以来一直支持此表的三个角,但留下了命名+强制组合的空白。在空安全中,我们填补了这一点。你可以在参数前面放置 required
来声明必需的命名参数
// Using null safety:
function({int? a, required int? b, int? c, required int? d}) {}
在这里,所有参数都必须按名称传递。参数 a
和 c
是可选的,可以省略。参数 b
和 d
是必需的,必须传递。请注意,必需性与可空性无关。你可以拥有可空类型的必需命名参数,以及具有默认值的非可空类型的可选命名参数。
这是我认为使 Dart 变得更好的另一个功能,与空安全无关。它只是让语言感觉更完整。
抽象字段
#Dart 的一个巧妙功能是它坚持一个叫做统一访问原则的东西。用通俗的话来说,这意味着字段与 getter 和 setter 没有区别。“属性”在某些 Dart 类中是计算的还是存储的,这是一个实现细节。因此,在使用抽象类定义接口时,通常使用字段声明
abstract class Cup {
Beverage contents;
}
目的是用户只实现该类,而不扩展它。字段语法只是编写 getter/setter 对的更短方法
abstract class Cup {
Beverage get contents;
set contents(Beverage);
}
但是 Dart 不知道这个类永远不会用作具体类型。它将 contents
声明视为实际字段。而且,不幸的是,该字段是非可空的,并且没有初始值设定项,因此你会收到编译错误。
一种修复方法是像第二个示例中那样使用显式抽象 getter/setter 声明。但这有点冗长,因此在空安全中,我们还添加了对显式抽象字段声明的支持
abstract class Cup {
abstract Beverage contents;
}
这与第二个示例的行为完全相同。它只是声明一个具有给定名称和类型的抽象 getter 和 setter。
使用可空字段
#这些新功能涵盖了许多常见模式,并且在大多数情况下使处理 null
变得相当轻松。但即便如此,我们的经验是,可空字段仍然可能很困难。在你可以使字段 late
且非可空的情况下,你就很棒了。但在许多情况下,你需要检查以查看字段是否具有值,这需要使其可空,以便你可以观察到 null
。
可空字段,如果既是私有的又是 final 的,则能够进行类型提升(除非存在一些特定原因)。如果你由于任何原因无法使字段私有且 final,你仍然需要一种解决方法。
例如,你可能期望这样做可以工作
// Using null safety, incorrectly:
class Coffee {
String? _temperature;
void heat() { _temperature = 'hot'; }
void chill() { _temperature = 'iced'; }
void checkTemp() {
if (_temperature != null) {
print('Ready to serve ' + _temperature + '!');
}
}
String serve() => _temperature! + ' coffee';
}
在 checkTemp()
内部,我们检查以查看 _temperature
是否为 null
。如果不是,我们访问它并最终对其调用 +
。不幸的是,这是不允许的。
基于流程的类型提升只能应用于既是私有的又是 final 的字段。否则,静态分析无法证明字段的值在你检查 null
的点和你使用它的点之间不会更改。(考虑到在病态情况下,字段本身可能会被子类中的 getter 覆盖,该 getter 在第二次调用时返回 null
。)
因此,由于我们关心健全性,公共和/或非 final 字段不会提升,并且上述方法无法编译。这很烦人。在像这里这样的简单情况下,你最好的选择是在字段的使用上拍一个 !
。这看起来是多余的,但这或多或少是 Dart 今天的行为方式。
另一种有帮助的模式是首先将字段复制到局部变量,然后使用局部变量代替
// Using null safety:
void checkTemp() {
var temperature = _temperature;
if (temperature != null) {
print('Ready to serve ' + temperature + '!');
}
}
由于类型提升确实适用于局部变量,因此现在可以正常工作。如果你需要更改值,只需记住存储回字段,而不仅仅是局部变量。
有关处理这些和其他类型提升问题的更多信息,请参阅修复类型提升失败。
可空性和泛型
#像大多数现代静态类型语言一样,Dart 具有泛型类和泛型方法。它们以一些看似违反直觉的方式与可空性交互,但一旦你仔细思考其含义,就会明白其中的道理。首先,“此类型是否可空?”不再是一个简单的“是”或“否”问题。考虑
// Using null safety:
class Box<T> {
final T object;
Box(this.object);
}
void main() {
Box<String>('a string');
Box<int?>(null);
}
在 Box
的定义中,T
是可空类型还是非可空类型?正如你所看到的,它可以使用任何一种类型实例化。答案是 T
是一个潜在的可空类型。在泛型类或方法的主体内部,潜在的可空类型具有可空类型和非可空类型的所有限制。
前者意味着你不能在其上调用任何方法,除了在 Object 上定义的少数方法。后者意味着你必须在使用之前初始化该类型的任何字段或变量。这会使类型参数非常难以使用。
在实践中,出现了一些模式。在集合类之类的类中,类型参数可以使用任何类型实例化,你只需处理这些限制。在大多数情况下,就像这里的示例一样,这意味着确保在你需要使用类型参数类型的值时,你确实可以访问它。幸运的是,集合类很少在其元素上调用方法。
在你无法访问值的位置,你可以使类型参数的使用可空
// Using null safety:
class Box<T> {
T? object;
Box.empty();
Box.full(this.object);
}
请注意 object
声明上的 ?
。现在该字段具有显式可空类型,因此可以将其保持未初始化状态。
当你使类型参数类型可空(如这里的 T?
)时,你可能需要消除可空性。执行此操作的正确方法是使用显式的 as T
类型转换,而不是 !
运算符
// Using null safety:
class Box<T> {
T? object;
Box.empty();
Box.full(this.object);
T unbox() => object as T;
}
如果值为 null
,则 !
运算符始终抛出异常。但是,如果类型参数已使用可空类型实例化,则 null
对于 T
是完全有效的值
// Using null safety:
void main() {
var box = Box<int?>.full(null);
print(box.unbox());
}
这个程序应该无错误运行。使用 as T
可以实现这一点。使用 !
会抛出异常。
其他泛型类型具有一些限制可以应用的类型参数种类的边界
// Using null safety:
class Interval<T extends num> {
T min, max;
Interval(this.min, this.max);
bool get isEmpty => max <= min;
}
如果边界是非可空的,那么类型参数也是非可空的。这意味着你具有非可空类型的限制——你不能使字段和变量保持未初始化状态。这里的示例类必须具有初始化字段的构造函数。
为了换取这种限制,你可以在类型参数类型的值上调用在其边界上声明的任何方法。但是,具有非可空边界会阻止你的泛型类的用户使用可空类型参数实例化它。对于大多数类来说,这可能是一个合理的限制。
你也可以使用可空边界
// Using null safety:
class Interval<T extends num?> {
T min, max;
Interval(this.min, this.max);
bool get isEmpty {
var localMin = min;
var localMax = max;
// No min or max means an open-ended interval.
if (localMin == null || localMax == null) return false;
return localMax <= localMin;
}
}
这意味着在该类的主体中,你可以灵活地将类型参数视为可空的,但你也具有可空性的限制。除非你先处理可空性,否则你无法在该类型变量上调用任何内容。在示例中,我们将字段复制到局部变量中,并检查这些局部变量是否为 null
,以便流程分析在我们使用 <=
之前将它们提升为非可空类型。
请注意,可空边界不会阻止用户使用非可空类型实例化类。可空边界意味着类型参数可以是可空的,而不是它必须是可空的。(事实上,如果你不编写 extends
子句,则类型参数上的默认边界是可空边界 Object?
。)没有办法要求可空类型参数。如果你希望类型参数的用法可靠地是可空的并且隐式初始化为 null
,则可以在类的主体内部使用 T?
。
核心库更改
#语言中还有一些其他的调整,但它们是次要的。例如,没有 on
子句的 catch
的默认类型现在是 Object
而不是 dynamic
。switch 语句中的 fallthrough 分析使用新的流程分析。
真正对你重要的其余更改都在核心库中。在我们开始伟大的空安全冒险之前,我们担心最终会发现,如果不大规模破坏世界,就无法使我们的核心库空安全。结果并没有那么可怕。确实有一些重大更改,但在很大程度上,迁移过程很顺利。大多数核心库要么不接受 null
并自然地迁移到非可空类型,要么接受 null
并优雅地使用可空类型接受它。
但是,有一些重要的角落
Map 索引运算符是可空的
#这实际上不是一个更改,而更像是一个需要知道的事情。Map 类上的索引 []
运算符在键不存在时返回 null
。这意味着该运算符的返回类型必须是可空的:V?
而不是 V
。
我们可以更改该方法,使其在键不存在时抛出异常,然后为其提供更易于使用的非可空返回类型。但是,使用索引运算符并检查 null
以查看键是否不存在的代码非常常见,根据我们的分析,约占所有用例的一半。破坏所有这些代码会使 Dart 生态系统陷入混乱。
相反,运行时行为是相同的,因此返回类型有义务是可空的。这意味着你通常无法立即使用 map 查找的结果
// Using null safety, incorrectly:
var map = {'key': 'value'};
print(map['key'].length); // Error.
这会在尝试对可空字符串调用 .length
时给你一个编译错误。在你知道键存在的情况下,你可以通过使用 !
来教导类型检查器
// Using null safety:
var map = {'key': 'value'};
print(map['key']!.length); // OK.
我们考虑过向 Map 添加另一个方法,该方法可以为你执行此操作:查找键,如果找不到则抛出异常,否则返回非可空值。但是该如何称呼它呢?没有哪个名称会比单字符 !
更短,也没有哪个方法名称会比在调用站点看到具有其内置语义的 !
更清晰。因此,访问 map 中已知存在的元素的惯用方法是使用 []!
。你会习惯它的。
没有未命名的 List 构造函数
#List
上的未命名构造函数创建一个具有给定大小的新列表,但不初始化任何元素。如果你创建了一个非可空类型的列表,然后访问一个元素,这将会在健全性保证中戳出一个非常大的漏洞。
为了避免这种情况,我们已完全删除了该构造函数。在空安全代码中调用 List()
是一个错误,即使使用可空类型也是如此。这听起来很可怕,但在实践中,大多数代码都使用列表字面量、List.filled()
、List.generate()
或作为转换其他集合的结果来创建列表。对于你想创建某种类型的空列表的边缘情况,我们添加了一个新的 List.empty()
构造函数。
创建完全未初始化的列表的模式在 Dart 中一直感觉格格不入,现在更是如此。如果你的代码因此而中断,你始终可以使用许多其他方法之一来生成列表来修复它。
无法在不可空列表上设置更大的长度
#鲜为人知的是,List
上的 length
getter 还有一个对应的 setter。你可以将长度设置为较小的值来截断列表。你也可以将其设置为更长的长度,以用未初始化的元素填充列表。
如果你对非可空类型的列表执行此操作,则稍后访问这些未写入的元素时,你将违反健全性。为了防止这种情况,如果(且仅当)列表具有非可空元素类型并且你将其设置为更长的长度,则 length
setter 将抛出运行时异常。截断所有类型的列表仍然可以,并且你可以增长可空类型的列表。
如果你定义自己的扩展 ListBase
或应用 ListMixin
的列表类型,则会产生一个重要的后果。这两种类型都提供了 insert()
的实现,该实现在之前通过设置长度为插入的元素腾出空间。这在空安全下会失败,因此我们更改了 ListMixin
(ListBase
共享)中 insert()
的实现,以改为调用 add()
。如果希望能够使用继承的 insert()
方法,你的自定义列表类应提供 add()
的定义。
在迭代之前或之后无法访问 Iterator.current
#Iterator
类是可变的“游标”类,用于遍历实现 Iterable
的类型的元素。你应在访问任何元素之前调用 moveNext()
以前进到第一个元素。当该方法返回 false
时,你已到达末尾,并且没有更多元素。
过去,如果你在第一次调用 moveNext()
之前或迭代完成后调用 current
,则 current
返回 null
。在空安全下,这将要求 current
的返回类型为 E?
而不是 E
。反过来,这意味着每次元素访问都需要运行时 null
检查。
考虑到几乎没有人会以错误的方式访问当前元素,这些检查将毫无用处。相反,我们将 current
的类型设为 E
。由于在迭代之前或之后 *可能* 存在该类型的值,因此如果您在不应该调用迭代器时调用它,我们已将迭代器的行为定义为未定义。Iterator
的大多数实现都会抛出 StateError
。
总结
#这是一次非常详尽的关于围绕空安全的所有语言和库更改的巡览。内容很多,但这确实是一项重大的语言变更。更重要的是,我们希望达到一个让 Dart 仍然感觉连贯且可用的程度。这不仅需要更改类型系统,还需要更改围绕它的许多其他可用性功能。我们不希望让人感觉空安全是强行附加的。
需要记住的核心要点是
类型默认是不可为空的,通过添加
?
使其可为空。可选参数必须是可为空的或具有默认值。您可以使用
required
使命名参数变为非可选的。不可为空的顶层变量和静态字段必须具有初始值设定项。不可为空的实例字段必须在构造函数体开始之前初始化。在空感知运算符之后的链式方法调用,如果接收者为
null
,则会短路。有新的空感知级联 (?..
) 和索引 (?[]
) 运算符。后缀空断言 “bang” 运算符 (!
) 将其可为空的操作数强制转换为底层的不可为空类型。流程分析使您可以安全地将可为空的局部变量和参数(以及从 Dart 3.2 开始的私有 final 字段)转换为可用的不可为空变量和参数。新的流程分析还针对类型提升、缺少返回、不可达代码和变量初始化提供了更智能的规则。
late
修饰符允许您在原本可能无法使用的地方使用不可为空的类型和final
,但代价是运行时检查。它还为您提供了延迟初始化的字段。List
类已更改,以防止未初始化的元素。
最后,一旦您吸收了所有这些内容并将您的代码带入空安全的世界,您将获得一个健全的程序,编译器可以对其进行优化,并且运行时错误可能发生的每个位置在您的代码中都是可见的。我们希望您觉得为了达到这一目标所做的努力是值得的。
除非另有说明,否则本网站上的文档反映了 Dart 3.7.1 版本。页面上次更新于 2024-05-14。 查看源代码 或 报告问题。