编者按: 本文为 Dart 空安全的经典详解,其详细且全面地展开说明了 Dart 处理空安全采用的原则、具体实现和技术细节,对于众多 Dart 开发者而言是不可错过的精华内容。无论您是否已经成为了 Dart 专家,相信在阅读后都会感到受益匪浅。所以,倒一杯清甜的茶,找一张舒适的椅子,让我们带您进入空安全之旅吧!
文 / Bob Nystrom, Google Dart 团队工程师
自 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 的解决方案。它包含了对静态类型系统及诸多方面的修改,以及新的语言特性,让您在编写代码时不仅能写出空安全的代码,同时也能非常享受。
这篇文档很长,推荐您先阅读之前的文章《Flutter专题 | 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)。
- 有向图go2.gdsub.com/directed-graph
- 偏序集合go2.gdsub.com/lattice
如果类型系统中有顶层和底层类型,将给我们带来一定程度的便利,因为它意味着像最小上界这样类型层面的操作 (类型推理常根据一个条件表达式的两个分支推导出一个类型) 一定能推导出一个类型。在空安全引入以前,Dart 中的顶层类型是 Object,底层类型是 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。因为所有的类型都是可空的,所以从代码层面而言,这个函数是安全的,尽管它并不一定与您预期相符。
有了确定的非空类型,这段程序就是错误且不安全的。在空安全下,如果一个返回值为非空类型的函数,没有可靠地返回一个值,您就会看到编译错误。这里所提到的 "可靠",指的是分析器会分析函数中所有的控制流。只要它们都返回了内容,就满足了条件。分析器相当聪明,聪明到下面的代码也能应付:
// 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 仍然可以作为初始化的默认值。
即便如此,这些规则也会让您的适配之路有些小磕碰。幸运的是,我们有一整套新的语言特性,来帮助您平稳渡过一些常见的颠簸。不过,首先,我们是时候来聊一聊流程分析了。
流程分析
控制流程分析 (Control flow analysis) 已经在众多编译器中存在多年了。通常它对于使用者而言是不可见的,只在编译优化流程中使用,但是,部分较新的语言,已经开始在可以看见的语言特性中使用同样的技术了。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,最好是直接将它移到非空的一侧,如此一来您就可以调用它的方法了。流程分析是对变量和局部变量进行处理的主要方法之一。我们在分析 == null 和 != null 表达式时也进行了类型提升的扩展。
如果您判断了一个可空的变量是否不为 null,进行到下一步后 Dart 就会将这个变量的类型提升至非空的对应类型:
// Using null safety: String makeCommand(String executable, [List<String>? arguments]) { var result = executable; if (arguments != null) { result = ' ' arguments.join(' '); } }
此处,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 或赋值,以及我们马上就要提到的后置操作符 ! 也会进行类型提升。总体来说的目标是: 如果代码是动态正确的,而静态分析时又是合理的,那么分析结果也足够聪明,会对其进行类型提升。
无用代码的警告
在您的程序中,一个可以准确知晓 null 去向的可达性分析,能确保您已经增加了对 null 的处理。不过我们也可以用同样的分析来检测您是否有不用的代码。在空安全以前,如果您编写了如下的代码:
// Using null safety: String checkList(List list) { if (list?.isEmpty) { return 'Got nothing'; } return 'Got something'; }
Dart 无法得知避空运算符 ?. 是否有用。它只知道您可以将 null 传递进方法内。但是在有空安全的 Dart 里,如果您将函数声明为现有的非空 List 类型,它就知道 list 永远不会为空。实际上就暗示了 ?. 是不必要的,您完全可以直接使用 .。
为了帮助您简化代码,我们为一些不必要的代码增加了一些警告,静态分析可以精确地检测到它。在一个非空类型上使用避空运算符、用 == null 或 != null 判断,都会出现一个警告。
同时,在非空类型提升的情况中也会看到类似的提示。当一个变量已经被提升至非空类型,您会在不必要的 null 检查时看到一个警告:
// Using null safety: checkList(List? list) { if (list == null) return 'No list'; if (list?.isEmpty) { return 'Empty list'; } return 'Got something'; }
此处由于代码执行后,list 不能为 null,所以您会在 ?. 的调用处看到一个警告。这些警告不仅仅是为了减少无意义的代码,通过移除不必要的 null 判断,我们得以确保其他有意义的判断能够脱颖而出。我们期望您能看到您代码中的 null 会向何处传递。
与可空类型共舞
现在,我们已经将 null 归到了可空类型的集合中。有了流程分析,我们可以让一些非 null 值安全地越过栅栏,到达非空的那一侧,供我们使用。这是相当大的一步,但如果我们就此止步不前,产出的系统仍然饱含痛苦的限制,而流程分析也仅对局部变量和参数起作用。
为了尽可能地保持 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); }// 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; HttpResponse.notFound() : code = 404, error = 'Not found'; 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 变得更好的特性之一。它让这门语言看起来更为完整。
与可空字段共舞
新引入的特性处理了非常多常见的行为模式,并且让大部分处理 null 的工作不再那么痛苦。即便如此,根据我们的经验之谈,处理可空的字段仍然是较为困难的。在您能使用 late 和非空类型的情况下,已经相当稳妥。但在很多场景里,您仍然需要检查字段是否有值,这些情况下,字段会是可空的,您也能观测到 null 的存在。
以下这段代码,您可能会认为可以这么写:
// 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。如果不为空,我们会访问并对它调用 。很遗憾,这样做是不被允许的。基于流程分析的类型提升并不适用于字段,因为静态分析不能证明这个字段的值在您判断后和使用前没有发生变化。(某些极端场景中,字段本身可能会被子类的 getter 重写,从而在第二次调用时返回 null。)
因为代码的健全性也是我们在乎的指标,所以字段的类型不会被提升,且上面的方法也无法编译。这其实不太舒服。在这样的简单例子中,最好的办法是在使用字段时加上 !。它看起来是多余的,但是目前的 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> { final 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; 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。您可以将未初始化的变量声明为类型参数的类型。
但您也受到了可空性的限制,除非您先处理了可空状态,否则您无法调用变量的任何方法。在此处的例子中,我们将字段拷贝至局部变量,并且检查了它们是否为 null,所以在我们调用 <= 前,流程分析将它们提升成了非空类型。
请注意,可空的类型约束并不会阻止用户使用非空类型对类进行实例化。一个可空的类型约束意味着类型参数可以为空,而不是必须为空。(实际上,如果您没有写上 extends 语句,类型参数的默认类型约束是可空的 Object?。) 您没有办法声明一个必需的可空类型参数。如果您希望确保类型参数一定是可空的,您可以在类的主体中使用 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,那么在其避空运算符之后的链式方法调用都会被截断。我们引入了新的空判断级联操作符 (?..) 及索引操作符 (?[])。后缀空断言 "重点" 操作符 (!) 可以将可空的操作对象转换为对应的非空类型。
- 新的流程分析,让您更安全地将可空的局部变量和参数,转变为可用的非空类型。它同时还对类型提升、遗漏的返回、不可达的代码以及变量的初始化,有着更为智能的规则。
- late修饰符以在运行时每次都进行检查的高昂代价,让您在一些原本无法使用的地方,能够使用非空类型和 final。它同时提供了对字段延迟初始化的支持。
- List 类现在不再允许包含未初始化的元素。
最后,当您吸收了这篇文章的所有内容,并且将您的代码真正带到空安全的世界中时,您会得到一个健全的、编译器可以进行优化的程序,并且可以看到您的代码中每一个运行时可能出错的地方。希望您的一切努力都是值得的。
致谢
本文是 Google Dart 团队成员 Bob Nystrom 撰写发布在 Dart 文档的一篇文章,由社区成员 Alex 发起并完成了文章翻译,本文的成功发布离不开下列成员的辛苦工作:
- 本文译者: Alex,Flutter.cn 社区成员,Flutter 项目 GitHub 开源组成员
- 审校: 王鑫磊,Flutter.cn 社区成员,滴滴出行 Flutter 工程师,来自社区的 CaiJingLong 和 Demin 对本文对审校亦有贡献
- 技术讨论: Flutter 社区 Discord 平台 #hackers-nnbd 频道
原文链接:https://flutter.cn/
,