深入理解空安全
Written by Bob Nystrom
July 2020
文/ Bob Nystrom, Google Dart 团队工程师, 2020 年 7 月
自 Dart 2.0 替换了静态可选类型系统为 健全的静态类型系统 后,空安全是我们对 Dart 作出最大的改变。在 Dart 初始之际,编译时的空安全是一项少有且需要大量时间推进的功能。时至今日,Kotlin、Swift、Rust 及众多语言都拥有他们自己的解决方案,空安全已经成为 屡见不鲜的话题。让我们来看下面这个例子:
// Without null safety:
bool isEmpty(String string) => string.length == 0;
main() {
isEmpty(null);
}
如果你在运行这个 Dart 程序时并未使用空安全,它将在调用 .length
时抛出 NoSuchMethodError
异常。
null
值是 Null
类的一个实例,而 Null
没有 “length” getter。运行时才出现的错误十分恼人,在本就是为终端打造的 Dart 语言上尤其如此。如果一个服务端应用出现了异常,你可以快速对它进行重启,而不被用户察觉。但当一个 Flutter 应用在用户的手机上崩溃了,他们的体验就会大打折扣。用户不开心,想必开发者也不会开心。
开发者偏爱像 Dart 这样的静态类型语言,因为它通常可以让使用 IDE 的开发者通过类型检查发现错误。
Bug 越早被发现,就能越早处理。当语言设计者在谈论“修复空引用错误”时,他们指的是加强静态类型检查器,使得诸如在可能为 null
的值上调用 .length
这样的错误能被检测到。
针对这个问题,从来没有一个标准答案。 Rust 和 Kotlin 在其语言内都各自拥有合理的解决方案。这篇文档将带你了解 Dart 的解决方案。它包含了对静态类型系统及诸多方面的修改,以及新的语言特性,让你在编写代码时不仅能写出空安全的代码,同时也能非常 享受。
这篇文档很长。如果你只需要一份如何开始并运行的简短的文档,请从 概览 开始。当你认为你有充足的时间,且已经准备好深入理解它时,再回到这里,彼时你可以了解到语言是 如何 处理 null
、为什么 我们会这样设计,以及你如何写出符合现代习惯的空安全 Dart 代码。(剧透一下:实际上它和你当前写 Dart 代码的方式相差无几。)
处理空引用错误的方法各有利弊。我们基于以下的原则做出选择:
-
代码在默认情况下是安全的。 如果你写的新代码中没有显式使用不安全的特性,运行时将不会有空引用错误抛出。所有潜在的空引用错误都将被静态捕获。如果你想为了灵活度而将某些检查放到运行时进行,当然不成问题,但你必须在代码中显式使用一些功能来达成你的目的。
换句话说,我们并不是在你每次出海前给你一件救生衣,提醒你记得穿戴。相反,我们提供给你一艘不会沉的小船,只要你不跳下水里,就无事发生。
-
空安全的代码应可以轻松编写。 现有的大多数 Dart 代码都是动态正确的,并且不会抛出空引用错误。想必你非常喜欢现在你编写 Dart 代码的方式,我们也希望你可以继续使用这样的方式编写代码。安全性不应该要求易用性作出妥协、不应花更多时间耗费在类型检查器上,也不应使你显著改变你的思维方式。
-
产出的空安全代码应该是非常健全的。 对于静态检查而言,“健全”有着多层含义。而对我们来说,在空安全的上下文里,“健全”意味着如果一个表达式声明了一个不允许值为
null
的静态类型,那么这个表达式的任何执行结果都不可能为null
。 Dart 语言主要通过静态检查来保证这项特性,但在运行时也有一些检查参与其中。(不过,根据第一条原则,在运行时何时何地进行检查,完全由你自己掌握。)代码的健全性极大程度地决定了开发者对于自己的代码是否有自信。一艘 大部分时间 都在飘忽不定的小船,是不足以让你鼓起勇气,驶往公海进行冒险的。这对于我们无畏的“黑客”编译器而言,同样十分重要。当语言对程序中语义化的属性做出硬性保证时,说明编译器能真正意义上为这些属性作出优化。当它涉及到
null
时,意味着可以消除不必要的null
检查,提供更精悍的代码,并且在对其调用方法前,不需要再校验是否其为空调用。需要注意一点:目前我们只能完全保证使用了空安全的代码的健全性。 Dart 程序支持新的空安全代码和旧的传统代码混合。在这些使用混合空安全的程序版本中,空引用的错误仍有可能出现。这类程序里让你可以在使用了空安全的部分,享受到所有 静态部分的 空安全福利。但在整个程序都使用了空安全之前,代码在运行时仍然不能保证是空安全的。
值得注意的是,我们的目标并不是 消除 null
。null
没有任何错。相反,可以表示一个 空缺 的值是十分有用的。在语言中提供对空缺的值的支持,让处理空缺更为灵活和高效。它为可选参数、?.
空调用语法糖和默认值初始化提供了基础。
null
并不糟糕,糟糕的是 它在你意想不到的地方出现,最终引发问题。
因此,对于空安全而言,我们的目标是让你对代码中的 null
可见且可控,并且确保它不会传递至某些位置从而引发崩溃。
类型系统中的可空性
因为一切均建立于静态类型系统上,所以空安全也始于此处。你的 Dart 程序中包含了整个类型世界:基本类型(如 int
和 String
)、集合类型(如 List
)以及你和你所使用的依赖所定义的类和类型。在空安全推出之前,静态类型系统允许所有类型的表达式中的每一处都可以有 null
。
从类型理论的角度来说,Null
类型被看作是所有类型的子类;
类型会定义一些操作对象,包括 getters、setters、方法和操作符,在表达式中使用。如果是 List
类型,你可以对其调用 .add()
或 []
。如果是 int
类型,你可以对其调用 +
。但是 null
值并没有它们定义的任何一个方法。所以当 null
传递至其他类型的表达式时,任何操作都有可能失败。这就是空引用的症结所在——所有错误都来源于尝试在 null
上查找一个不存在的方法或属性。
非空和可空类型
空安全通过修改了类型的层级结构,从根源上解决了这个问题。
Null
类型仍然存在,但它不再是所有类型的子类。现在的类型层级看起来是这样的:
既然 Null
已不再被看作所有类型的子类,那么除了特殊的 Null
类型允许传递 null
值,其他类型均不允许。我们已经将所有的类型设置为 默认不可空 的类型。如果你的变量是 String
类型,它必须包含 一个字符串。这样一来,我们就修复了所有的空引用错误。
如果 null
对我们来说没有什么意义的话,那大可不必再研究下去了。但实际上 null
十分有用,所以我们仍然需要合理地处理它。可选参数就是非常好的例子。让我们来看下这段空安全的代码:
// Using null safety:
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:
bad(String? maybeString) {
print(maybeString.length);
}
main() {
bad(null);
}
如果我们允许这样的代码运行,那么它将毫无疑问地崩溃。我们只允许你访问同时在原有类型及 Null
类下同时定义的方法和属性。所以只有 toString()
、==
和 hashCode
可以访问。因此,你可以将可空类型用于 Map 的键值、存储于集合中或者与其他值进行比较,仅此而已。
那么原有类型是如何与可空类型交互的呢?我们知道,将一个 非 空类型的值传递给可空类型是一定安全的。如果一个函数接受 String?
,那么向其传递 String
是允许的,不会有任何问题。在此次改动中,我们将所有的可空类型作为基础类型的超类。你也可以将 null
传递给一个可空的类型,即 Null
也是任何可空类型的子类:
但将一个可空类型传递给非空的基础类型,是不安全的。声明为 String
的变量可能会在你传递的值上调用 String
的方法。如果你传递了 String?
,传入的 null
将可能产生错误:
// Hypothetical unsound null safety:
requireStringNotNull(String definitelyString) {
print(definitelyString.length);
}
main() {
String? maybeString = null; // Or not!
requireStringNotNull(maybeString);
}
我们不会允许这样不安全的程序出现。然而,隐式转换 在 Dart 中一直存在。假设你将类型为 Object
的实例传递给了需要 String
的函数,类型检查器会允许你这样做:
// Without null safety:
requireStringNotObject(String definitelyString) {
print(definitelyString.length);
}
main() {
Object maybeString = 'it is';
requireStringNotObject(maybeString);
}
为了保持健全性,编译器为 requireStringNotObject()
的参数静默添加了 as String
强制转换。在运行时进行转换可能会抛出异常,但在编译时,Dart 允许这样的操作。在可空类型已经变为非空类型的子类的前提下,隐式转换允许你给需要 String
的内容传递 String?
。这项来自隐式转换的允诺与我们的安全性目标不符。所以在空安全推出之际,我们完全移除了隐式转换。
这会让 requireStringNotNull()
的调用产生你预料中的编译错误。同时也意味着,类似 requireStringNotObject()
这样的
所有 隐式转换调用都变成了编译错误。你需要自己添加显式类型转换:
// Using null safety:
requireStringNotObject(String definitelyString) {
print(definitelyString.length);
}
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
时遇到了错误。在隐式转换移除后,这就变成了一个编译错误。
(我是谁我在哪?刚刚说到哪了?)所以正如我们在类型世界中将所有类型拆分成两半一样:
此处有一个非空类型的区域划分。该区域中的类型能访问到你想要的所有方法,但不能包含 null
。接着有一个对应并行的可空类型家族。它们允许出现 null
,但你并没有太多操作空间。让值从非空的一侧走向可空的一侧是安全的,但反之则不是。
这么看来,可空类型基本宣告毫无作用了。它们不包含任何方法,但是你又无法摆脱它们。别担心,接下来我们有一整套的方法来帮助你把值从可空的一半转移到另一半。
顶层及底层
这一节会略微深奥。除非你对类型系统非常感兴趣,否则你可以直接跳过这一节,并且在本文最后部分,还有两项有趣的内容。想象一下,在你的程序里,所有的类型都互为子类或超类。如果将它们的关系用画图表示出来,就像文中的那些图一样,那将会是一幅巨大的有向图,诸如 Object
的超类会在顶层,子类在底层。
如果这张有向图的顶部有是一个单一的超类(直接或间接),那么这个类型称为 顶层类型。类似的,如果有一个在底部有一个奇怪的类型,是所有类型的子类,这个类型就被称为 底层类型。(在这个情况下,你的有向图是一种 偏序集合 (lattice))
如果类型系统中有顶层和底层类型,将给我们带来一定程度的便利,因为它意味着像最小上界这样类型层面的操作(类型推理常根据一个条件表达式的两个分支推导出一个类型)一定能推导出一个类型。在空安全引入以前,Dart 中的顶层类型是 Object
,底层类型是 Null
。
由于现在 Object
不再可空,所以它不再是一个顶层类型了。Null
也不再是它的子类。
Dart 中没有 令人熟知的 顶层类型。如果你需要一个顶层类型,可以用 Object?
。同样的,Null
也不再是底层类型,否则所有类型都仍将是可空。取而代之是一个全新的底层类型 Never
:
依据实际开发中的经验,这意味着:
-
如果你想表明让一个值可以接受任意类型,请用
Object?
而不是Object
。使用Object
后会使得代码的行为变得非常诡异,因为它意味着能够是“除了null
以外的任何实例”。 - On the rare occasion that you need a bottom type, use
Never
instead ofNull
. If you don’t know if you need a bottom type, you probably don’t. -
在极少数需要底层类型的情况下,请使用
Never
代替Null
。对于不会调用成功返回的方法,它可以 帮助代码可达性分析。如果你不了解是否需要一个底层类型,那么你基本上不会需要它。
确保正确性
我们将类型世界划分为了非空和可空的两半。为了保持代码的健全和我们的原则:“除非你需要,否则你永远不会在运行时遇到空引用错误”,我们需要保证 null
不会出现在非空一侧的任何类型里。
通过取代了隐式转换,并且不再将 Null
作为底层类型,我们覆盖了程序中声明、函数参数和函数调用等所有的主要位置。现在只有当变量首次出现和你跳出函数的时候,null
可以悄悄潜入。所以我们还会看到一些附加的编译错误:
无效的返回值
如果一个函数的返回类型非空,那么函数内最终一定要调用 return
返回一个值。在空安全引入以前,Dart 在限制未返回内容的函数时非常松懈。举个例子:
// Without null safety:
String missingReturn() {
// No return.
}
如果分析器检查了这个函数,你会看到一个轻微的 提示,提醒你 可能 忘记返回值,但不返回也无关紧要。这是因为代码执行到最后时,Dart 会隐式返回一个 null
。因为所有的类型都是可空的,所以 从代码层面而言,这个函数是安全的,尽管它并不一定与你预期相符。
有了确定的非空类型,这段程序就是错误且不安全的。在空安全下,如果一个返回值为非空类型的函数,没有可靠地返回一个值,你就会看到编译错误。这里所提到的“可靠”,指的是分析器会分析函数中所有的控制流。只要它们都返回了内容,就满足了条件。分析器相当聪明,聪明到下面的代码也能应付:
// 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
。这的确非常方便,但在变量可空的情况下,明显非常不安全。所以,我们需要加强对非空变量的处理:
-
顶层变量和静态字段必须包含一个初始化方法。 由于它们能在程序里的任何位置被访问到,编译器无法保证它们在被使用前已被赋值。唯一保险的选项是要求其本身包含初始化表达式,以确保产生匹配的类型的值。
// Using null safety: int topLevel = 0; class SomeClass { static int staticField = 0; }
-
实例的字段也必须在声明时包含初始化方法,可以为常见初始化形式,也可以在实例的构造方法中进行初始化。 这类初始化非常常见。举个例子:
// Using null safety: class SomeClass { int atDeclaration = 0; int initializingFormal; int initializationList; SomeClass(this.initializingFormal) : initializationList = 0; }
换句话说,字段在构造体执行前被赋值即可。
-
局部变量的灵活度最高。一个非空的变量 不一定需要 一个初始化方法。这里有个很好的例子:
// 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; }
此处遵循的规则是 局部变量必须确保在使用前被赋值。 我们也可以依赖于之前所提到的全新的流程分析来实现。只要所有使用变量的路径,在使用前都先初始化,就可以正常调用。
-
可选参数必须具有默认值。 如果一个可选位置参数或可选命名参数没有传递内容,Dart 会自动使用默认值进行填充。在未指定默认值的情况下,默认的 默认值为
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
表达式,以及控制流的路径,所以这段代码是有效的。如果部分控制流的代码主体只在变量的某个 is
表达式为真时才执行,那么这个代码块中的变量,将会是经过推导得出的类型。
在这个例子中,if
语句的 then 分支仅会在 object
是列表的时候执行。因此,在这里 Dart 将 object
的类型从它声明的 Object
提升到了 List
。这项功能非常方便,但它有着诸多限制。在空安全引入以前,下面的程序无法运行:
// 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;
}
现在是完全有效的。由于 if
语句会在 object
不是 List
时退出这个函数,因此 Dart 将下一句的 object
类型提升至了 List
。对于众多 Dart 代码来说,这是一项非常棒的改进,就算对于一些与空安全无关的代码来说也是。
为不可达的代码准备的 Never
你可以自己 码出 这项可达性分析。新的底层类型 Never
是没有任何值的。(什么值能同时是 String
、bool
和 int
呢?)那么一个类型为 Never
的表达式有什么含义呢?它意味着这个表达式永远无法成功的推导和执行。它必须要抛出一个异常、中断或者确保调用它的代码永远不会执行。
事实上,根据语言的细则,throw
表达式的静态类型就是 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 提升了它的类型。
换句话说,在你的代码中使用 Never
让你可以扩展 Dart 的可达性分析。
绝对的赋值分析
前文已经在提到局部变量时简单提到了这个分析。 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(' ');
}
Dart 语言也对什么表达式需要提升变量判断地更智能了。除了显式的 == 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
检查时看到一个警告:
// 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
是否会返回 null
?看上去它 会 返回 null
,因为你在调用后使用了 ?.
。但也有可能第二个 ?.
仅仅是为了处理 thing
为 null
的情况,而不是 doohickey
的结果。你无法直接得出结论。
为了解决这类问题,我们从 C# 相同功能的设计中借鉴了一个聪明的处理方法。当你在链式方法调用中使用避空运算符时,如果接收器被判断为 null
,那么 整个链式调用的剩余部分都会被截断并跳过。这意味着如果 doohickey
的返回值是一个非空的类型,你应该这样写:
// Using null safety:
showGizmo(Thing? thing) {
print(thing?.doohickey.gizmo);
}
实际上,如果你不去掉第二个 ?.
,你会看到一个警告,提示这段代码是不必要的。所以如果你看到了这样的代码:
// Using null safety:
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
。但为了知晓这个行为,必须要理解 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()}';
}
当原有的类型非常繁琐的时候,这个只有一个字符的“重点操作符”就会非常上手。如果仅仅是为了将一个类型转换为非空,就需要写出类似于
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';
}
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
修饰符比 ?
更为神奇,因为对这个字段的任何调用都有可能失败,且在失败的事故现场不会有任何的文字说明。
作为回报,它在静态安全方面比可空类型更靠谱。因为这个字段现在是非空的了,在 编译时 为它赋予 null
或可空的 String
就会出错。虽然 late
修饰符让你延迟了初始化,但它仍然禁止你将变量作为可空的类型进行处理。
延迟初始化
late
修饰符也有一些特殊的能力。虽然听起来起来有一些自相矛盾,但是你可以在一个包含初始化内容的字段上使用 late
:
// Using null safety:
class Weather {
late int _temperature = _readThermometer();
}
当你这么声明时,会让初始化 延迟 执行。实例的构造将会延迟到字段首次被访问时执行,而不是在实例构造时就初始化。换句话说,它让字段的初始化方式变得与顶层变量和静态字段完全一致。当初始化表达式比较消耗性能,并且有可能不需要时,这会变得非常有用。
当你在实例字段上使用 late
时,延迟初始化会给你带来更多的便利。通常实例字段的初始化内容无法访问到 this
,因为在所有的初始化方法完成前,你无法访问到新的实例对象。但是,使用了 late
让这个条件不再为真,所以你 可以 访问到 this
、调用方法以及访问实例的字段。
延迟的终值
你也可以将 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 的各种参数:
必需的可选的
+------------+------------+
位置参数 | f(int x) | f([int x]) |
+------------+------------+
命名参数 | ??? | 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 的字段。否则,静态分析就无法 证明 这个字段的值在你判断后和使用前没有发生变化。(某些极端场景中,字段本身可能会被子类的 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);
}
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
声明的 ?
。现在这个字段是一个显式的可空类型,所以它可以是未被初始化的。
当你将类型参数像此处一样变为可空类型时,你可能需要强制将它转换为非空类型。正确的做法是显式地使用 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:
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 语句中的条件贯穿分析也使用了新的流程分析。
剩余的重要改动,都存在于核心库中。在我们开始这次的空安全大冒险之前,我们曾经担心过,为了让核心库用上空安全,也许我们要对现有的语言系统做出大规模的破坏性改动。而结果并没有想象中的那么可怕。尽管确实 有 一些重大的变化,但在大部分情况下,迁移进行得十分顺利。大多数的核心库要么不接受 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()
的实现,通过设置长度,为插入的元素提供空间。在空安全中这样做可能会出现错误,所以我们将它们的 insert()
实现改为了 add()
。现在你自定义的列表应该继承 add()
方法。
在迭代前后不能访问 Iterator.current
Iterable
是一个可变的“游标”类,用于遍历 Iterable
类型的元素。在访问任何元素之前,你都需要调用 moveNext()
来跳到第一个元素。当方法返回了 false
时,意味着你到达了终点,已经没有更多元素了。
在以前,在首次调用 moveNext()
前,或者在迭代结束后,调用 current
会返回 null
。有了空安全,就要求 current
的返回类型是 E?
而不是 E
。这样的返回类型意味着在运行时,所有元素的访问前都需要检查是否为 null
。
鉴于目前几乎没有人会以这种错误的方式访问当前元素,这些检查其实毫无用处。所以我们将 current
的返回类型确定为 E
。由于迭代前后 有可能 会有一个对应类型的值出现,当你在不应该调用它的时候调用迭代器时,我们让迭代器的行为保持为未定义。对于 Iterator
的大部分实现都将抛出 StateError
异常。
总结
这是一场非常详尽的空安全旅途,途中走遍了所有语言和库的变更。这其中的内容真的很多,但是这也是一项非常大的语言变更。更重要的是,我们希望 Dart 仍然让你感到好用且具备一致性。所以不仅类型系统需要作出变动,一些可用性的特性也同时围绕着一起改变。我们不希望空安全仅仅是一个简陋的语法附加特性。
你需要掌握的核心要点有:
-
类型默认是非空的,可以添加
?
变为可空的。 -
可选参数必须是可空的或者包含默认值的。你可以使用
required
来构建一个非可选命名参数。非空的全局变量和静态字段必须在声明时被初始化。实例的非空字段必须在构造体开始执行前被初始化。 -
如果接收者为
null
,那么在其避空运算符之后的链式方法调用都会被截断。我们引入了新的空判断级联操作符 (?..
) 及索引操作符 (?[]
)。后缀空断言“重点”操作符 (!
) 可以将可空的操作对象转换为对应的非空类型。 -
新的流程分析,让你更安全地将可空的局部变量和参数(包括 Dart 3.2 的私有 final 字段),转变为可用的非空类型。它同时还对类型提升、遗漏的返回、不可达的代码以及变量的初始化,有着更为智能的规则。
-
late
修饰符以在运行时每次都进行检查的高昂代价,让你在一些原本无法使用的地方,能够使用非空类型和final
。它同时提供了对字段延迟初始化的支持。 -
List
类现在不再允许包含未初始化的元素。
最后,当你掌握了这篇文章的所有内容,并且将你的代码真正带到空安全的世界中时,你会得到一个健全的、编译器可以进行优化的程序,并且在你的代码中,你可以看到每一个运行时可能出错的地方。希望你的一切努力都是值得的。