▩Dart-深入理解空安全
Posted itzyjr
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了▩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。运行时才出现的错误。如果一个服务端应用出现了异常,你可以快速对它进行重启,而不被用户察觉。但当一个 Flutter 应用在用户的手机上崩溃了,他们的体验就会大打折扣。
当语言设计者在谈论“修复空引用错误”时,他们指的是加强静态类型检查器,使得诸如在可能为 null
的值上调用 .length 这样的错误能被检测到。
针对这个问题,从来没有一个标准答案。 Rust 和 Kotlin 在其语言内都各自拥有合理的解决方案。这篇文档将带你了解 Dart 的解决方案。它包含了对静态类型系统及诸多方面的修改,以及新的语言特性,让你在编写代码时不仅能写出空安全的代码,同时也能非常享受。
彼时你可以了解到语言是如何处理 null
、为什么我们会这样设计,以及你如何写出符合现代习惯的空安全 Dart 代码。
处理空引用错误的方法各有利弊。我们基于以下的原则做出选择:
- 代码在默认情况下是安全的。
如果你写的新代码中没有显式使用不安全的特性,运行时将不会有空引用错误抛出。所有潜在的空引用错误都将被静态捕获。如果你想为了灵活度而将某些检查放到运行时进行,当然不成问题,但你必须在代码中显式使用一些功能来达成你的目的。 - 空安全的代码应可以轻松编写。
现有的大多数 Dart 代码都是动态正确的,并且不会抛出空引用错误。想必你非常喜欢现在你编写 Dart 代码的方式,我们也希望你可以继续使用这样的方式编写代码。安全性不应该要求易用性作出妥协、不应花更多时间耗费在类型检查器上,也不应使你显著改变你的思维方式。 - 产出的空安全代码应该是非常健全的。
对于静态检查而言,“健全”有着多层含义。而对我们来说,在空安全的上下文里,“健全”意味着如果一个表达式声明了一个不允许值为null
的静态类型,那么这个表达式的任何执行结果都不可能为null
。 Dart 语言主要通过静态检查来保证这项特性,但在运行时也有一些检查参与其中。(不过,根据第一条原则,在运行时何时何地进行检查,完全由你自己掌握。)
代码的健全性极大程度地决定了开发者对于自己的代码是否有自信。一艘大部分时间都在飘忽不定的小船,是不足以让你鼓起勇气,驶往公海进行冒险的。这对于我们无畏的“黑客”编译器而言,同样十分重要。当语言对程序中语义化的属性做出硬性保证时,说明编译器能真正意义上为这些属性作出优化。当它涉及到 null
时,意味着可以消除不必要的 null
检查,提供更精悍的代码,并且在对其调用方法前,不需要再校验是否其为空调用。
需要注意一点:目前我们只能完全保证使用了空安全的代码的健全性。 Dart 程序支持新的空安全代码和旧的传统代码混合。在这些使用混合空安全的程序版本中,空引用的错误仍有可能出现。这类程序里让你可以在使用了空安全的部分,享受到所有静态部分的空安全福利。但在整个程序都使用了空安全之前,代码在运行时仍然不能保证是空安全的。
值得注意的是,我们的目标并不是 消除 null
。null
没有任何错。相反,可以表示一个空缺的值是十分有用的。在语言中提供对空缺的值的支持,让处理空缺更为灵活和高效。它为[可选参数],[?.],空调用语法糖和默认值初始化提供了基础。 null
并不糟糕,糟糕的是它在你意想不到的地方出现,最终引发问题。
因此,对于空安全而言,我们的目标是让你对代码中的 null
可见且可控,并且确保它不会传递至某些位置从而引发崩溃。
一、类型系统中的可空性
因为一切均建立于静态类型系统上,所以空安全也始于此处。你的 Dart 程序中包含了整个类型世界:基本类型(如 int 和 String)、集合类型(如 List)以及你和你所使用的依赖所定义的类和类型。在空安全推出之前,静态类型系统允许所有类型的表达式中的每一处都可以有 null
。
从类型【理论】的角度来说,Null
类型被看作是所有类型的子类。(实例上并不是,下文有介绍,是Never
):
类型会定义一些操作对象,包括 getters、setters、方法和操作符,在表达式中使用。如果是 List 类型,你可以对其调用 .add() 或 []。如果是 int 类型,你可以对其调用 +。但是 null
值并没有它们定义的任何一个方法。所以当null
传递至其他类型的表达式时,任何操作都有可能失败。这就是空引用的症结所在——所有错误都来源于尝试在 null
上查找一个不存在的方法或属性。
1.非空和可空类型
空安全通过修改了类型的层级结构,从根源上解决了这个问题。 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
的缩写。
2.使用可空类型
如果你的表达式可能返回空值,你会如何处理它呢?由于安全是我们的原则之一,答案其实所剩无几。因为你在其值为 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';
// 传Object类型,隐式转换:obj [as String]
requireStringNotObject(maybeString);
为了保持健全性,编译器为 requireStringNotObject() 的参数静默添加了 as String 强制转换。在运行时进行转换可能会抛出异常,但在编译时,Dart 允许这样的操作。在可空类型已经变为非空类型的子类的前提下,隐式转换允许你给需要 String 的内容传递 String?。这项来自隐式转换的允诺与我们的安全性目标不符。所以在空安全推出之际,我们完全移除了隐式转换。
所以,以上代码出错:
这会让 requireStringNotNull() 的调用产生你预料中的编译错误。同时也意味着,类似 requireStringNotObject() 这样的所有隐式转换调用都变成了编译错误。你需要自己添加显式类型转换(用as
关键字):
// Using null safety:
requireStringNotObject(String definitelyString)
print(definitelyString.length);// 5
main()
Object maybeString = 'it is';
requireStringNotObject(maybeString as String);// must explicit declaration
总的来说,我们认为这是项非常好的改动。在我们的印象中,大部分用户非常厌恶隐式转换。你可能已经遭受过它的摧残:
// Without null safety:
List<int> filterEvens(List<int> ints)
return ints.where((n) => n.isEven);
.where() 方法是懒加载的,所以它返回了一个 Iterable 而非 List。这段代码会正常编译,但会在运行时抛出一个异常,提示你在对 Iterable 进行转换为 filterEvens 声明的返回类型 List 时遇到了错误。在隐式转换移除后,这就变成了一个编译错误。不然的话,只有在运行时才能检测到错误。
所以正如我们在类型世界中将所有类型拆分成两半一样:
此处有一个非空类型的区域划分。该区域中的类型能访问到你想要的所有方法,但不能包含 null
。接着有一个对应并行的可空类型家族。它们允许出现 null
,但你并没有太多操作空间。让值从非空的一侧走向可空的一侧是安全的,但反之则不是。
这么看来,可空类型基本宣告毫无作用了。它们不包含任何方法,但是你又无法摆脱它们。别担心,接下来我们有一整套的方法来帮助你把值从可空的一半转移到另一半。
3.顶层及底层
这一节会略微深奥。除非你对类型系统非常感兴趣,否则你可以直接跳过这一节,并且在本文最后部分,还有两项有趣的内容。想象一下,在你的程序里,所有的类型都互为子类或超类。如果将它们的关系用画图表示出来,就像文中的那些图一样,那将会是一幅巨大的有向图,诸如 Object 的超类会在顶层,子类在底层。
如果这张有向图的顶部有是一个单一的超类(直接或间接),那么这个类型称为顶层类型。类似的,如果有一个在底部有一个奇怪的类型,是所有类型的子类,这个类型就被称为底层类型。(在这个情况下,你的有向图是一种 偏序集合 (lattice))
如果类型系统中有顶层和底层类型,将给我们带来一定程度的便利,因为它意味着像最小上界这样类型层面的操作(类型推理常根据一个条件表达式的两个分支推导出一个类型)一定能推导出一个类型。在空安全【引入以前】,Dart 中的顶层类型是 Object,底层类型是 Null。
由于现在 Object 不再可空,所以它不再是一个顶层类型了。Null 也不再是它的子类。 Dart 中没有令人熟知的顶层类型。如果你需要一个顶层类型,可以用 Object?。同样的,Null 也不再是底层类型,否则所有类型都仍将是可空。取而代之是一个全新的底层类型 Never
:
依据实际开发中的经验,这意味着:
- 如果你想表明让一个值可以接受任意类型,请用 Object? 而不是 Object。使用 Object 后会使得代码的行为变得非常诡异,因为它意味着能够是“除了
null
以外的任何实例”。 - 在极少数需要底层类型的情况下,请使用
Never
代替Null
。如果你不了解是否需要一个底层类型,那么你基本上不会需要它。
二、确保正确性
我们将类型世界划分为了非空和可空的两半。为了保持代码的健全和我们的原则:“除非你需要,否则你永远不会在运行时遇到空引用错误”,我们需要保证 null
不会出现在非空一侧的任何类型里。
通过取代了隐式转换,并且不再将 Null 作为底层类型,我们覆盖了程序中声明、函数参数和函数调用等所有的主要位置。现在只有当变量首次出现和你跳出函数的时候,null
可以悄悄潜入。所以我们还会看到一些附加的编译错误:
1.无效的返回值
如果一个函数的返回类型非空,那么函数内最终一定要调用 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();
2.未初始化的变量
当你在声明变量时,如果没有传递一个显式的初始化内容,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!
Dart中如果判断不是某个类型,使用 “is!”
与之前一样,你只能在 object 是列表的时候调用 .isEmpty,所以实际上这段代码是正确的。但是类型提升规则并不那么智能,它无法预测到 return 让下面代码只能在 object 为列表时才能访问到。
在空安全中,我们从不同的维度增强了这项能力,让它不再只能进行有限的分析。
1.可达性分析
首先,长期以来类型提升在处理提前返回和无法到达的代码路径时不够智能的问题,已经被我们修复。当我们在分析一个函数时,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 代码来说,这是一项非常棒的改进,就算对于一些与空安全无关的代码来说也是。\\
2.为不可达的代码准备的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 的【可达性分析】。
3.绝对的赋值分析
前文已经在提到局部变量时简单提到了这个分析。 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 变量而言,约束得以满足。
4.空检查的类型提升
更智能的流程分析对于众多 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(' ');
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
或赋值,以及我们马上就要提到的后置操作符 !
也会进行类型提升。总体来说的目标是:如果代码是动态正确的,而静态分析时又是合理的,那么分析结果也足够聪明,会对其进行类型提升。
5.无用代码的警告
在你的程序中,一个可以准确知晓 null
去向的可达性分析,能确保你已经增加了对 null
的处理。不过我们也可以用同样的分析来检测你是否有不用的代码。在空安全以前,如果你编写了如下的代码:
// Using null safety:
String checkList(List<Object> list)
if (list?.isEmpty ?? false)
return 'Got nothing';
return 'Got something';
Dart 无法得知避空运算符(null-aware operators) ?.
是否有用。它只知道你可以将 null
传递进方法内。但是在有空安全的 Dart 里,如果你将函数声明为现有的非空 List 类型,它就知道 list 永远不会为空。实际上就暗示了 ?.
是不必要的,你完全可以直接使用 .
。
为了帮助你简化代码,我们为一些不必要的代码增加了一些警告,静态分析可以精确地检测到它。在一个非空类型上使用避空运算符?.
以及用 == null
或 != null
判断,都会出现一个警告。
同时,在非空类型提升的情况中也会看到类似的提示。当一个变量已经被提升至非空类型,你会在不必要的 null
检查时看到一个警告:
// Using null safety:
String checkList(List<Object>? list)
if (list == null) return 'No list';
// 代码不出错,但有警告
// list不可能为null,list.isEmpty不可能为null(为true/false)
// 所以,以下if块可修正为:
<!--
if (list.isEmpty)
return 'Empty list';
-->
if (list?.isEmpty ?? false)
return 'Empty list';
return 'Got something';
此处由于代码执行后,list 不能为 null
,所以你会在 ?.
的调用处看到一个警告。这些警告不仅仅是为了减少无意义的代码,通过移除不必要的 null
判断,我们得以确保其他有意义的判断能够脱颖而出。我们期望你能看到你代码中的 null
会向何处传递。
四、与可空类型共舞
现在,我们已经将 null
归到了可空类型的集合中。有了流程分析,我们可以让一些非 null
值安全地越过栅栏,到达非空的那一侧,供我们使用。这是相当大的一步,但如果我们就此止步不前,产出的系统仍然饱含痛苦的限制,而流程分析也仅对局部变量和参数起作用。
为了尽可能地保持 Dart 在拥有空安全之前的灵活度,并且在一定程度上超越它,我们带来了一些其他的实用新特性。
1.更智能的空判断方法
Dart 的避空运算符 ?.
相对空安全而言俨然是一位老生。根据运行时的语义化规定,如果接收者是 null
,那么右侧的属性访问就会被跳过,表达式将被作为 null
看待。
// Without null safety:
// 错误:A value of type 'Null' can't be assigned to a variable of type 'String'.
String notAString = null;
print(notAString?.length);
避空运算符是一个不错的工具,让可空类型在 Dart 中变得可用。尽管我们不能让你在可空类型上调用方法,但我们可以让你使用避空运算符调用它们。空安全版本的程序是这样的:
// Using null safety:
// 警告:Don't explicitly initialize variables to null.
String? notAString = null;
print(notAString?.length);// null
然而,如果你曾经在 Dart 中使用过避空运算符,你可能经历过链式方法调用的恼人操作。假设你需要判断一个可能为空的字符串的长度是否为偶数(这可能不是个贴合实际的问题,但请继续往下看):
// Using null safety:
String? notAString = null;
print(notAString?.length.isEven);
就算这个程序使用了 ?.
,它仍然会在运行时抛出异常。这里的问题在于,.isEven 的接收器是左侧整个 notAString?.length 表达式的结果。这个表达式被认为是 null
,所以我们在尝试调用 .isEven 的时候出现了空引用的错误。如果你在 Dart 中使用过 ?.
,你可能已经学会了一个非常繁琐的方法,那就是在使用了一次避空运算符后,其每一处属性或方法的链式调用处都加上它。
String? notAString = null;
print(notAString?.length?.isEven深入理解 Dart 中的类型系统和泛型