一文带你理解Dart空安全
作者: 鲍勃·尼斯特罗姆,写于 2020年7月
自 Dart 2.0 中用可靠的静态类型系统替换了原有的不完善的可选类型系统以来,空安全是我们对 Dart 做出的最大改动。Dart 最初发布时,编译时空安全还是一项罕见的特性,需要详细讲解。如今,Kotlin、Swift、Rust 和其他语言都针对这个已经非常普遍的问题提供了各自的解决方案。以下是一个示例:
如果运行这段 Dart 程序时没有启用空安全机制,它会在调用 .length 方法时抛出 NoSuchMethodError 异常。因为空值是 Null 类的一个实例,而 Null 类没有 length 方法。运行时错误非常糟糕。对于像 Dart 这样旨在运行在终端用户设备上的语言来说,这一点尤为重要。如果服务器应用程序崩溃,通常可以在用户注意到之前重启它。但是,当 Flutter 应用在用户的手机上崩溃时,用户会非常不满意。用户不满意,开发者自然也不会满意。
开发者喜欢像 Dart 这样的静态类型语言,因为它们允许类型检查器在编译时(通常直接在 IDE 中)查找代码中的错误。越早发现 bug,就能越早修复它。当语言设计者谈到“修复空引用错误”时,他们指的是增强静态类型检查器的功能,使语言能够检测到类似上述尝试对可能为空的值调用 .length 的错误。
这个问题没有唯一的完美解决方案。Rust 和 Kotlin 各自都有适合其语言特性的方案。本文档详细介绍了我们针对 Dart 的解决方案。它包括对静态类型系统的更改,以及一系列其他修改和新的语言特性,旨在让你不仅能够编写空安全代码,而且希望你能享受编写代码的乐趣。
本文档篇幅较长。如果你想要一份简短的入门指南,仅涵盖你需要了解的内容,请先阅读概述部分。当你准备好深入了解并且有时间时,再回到这里,了解 Dart 语言如何处理空值、我们为什么这样设计,以及如何编写符合习惯的、现代的、空安全的 Dart 代码。(剧透一下:最终的实现方式与你现在编写 Dart 的方式非常接近。)
语言处理空引用错误的方式多种多样,各有优缺点。以下原则指导了我们的选择:
- 代码默认应该是安全的。如果你编写新的 Dart 代码,并且不使用任何显式的不安全特性,那么它在运行时永远不会抛出空引用错误。所有可能的空引用错误都会被静态捕获。如果你希望将部分检查延迟到运行时以获得更大的灵活性,你可以这样做,但你必须通过在代码中明确选择某些特性来实现。
换句话说,我们不会给你一件救生衣,然后让你每次出海都记得穿上。相反,我们给你一艘不会沉没的船。除非你跳入水中,否则你不会感到潮湿。
-
编写空安全代码应该很容易。大多数现有的 Dart 代码都是动态正确的,不会抛出空引用错误。你喜欢你当前的 Dart 程序,我们也希望你能够继续以这种方式编写代码。安全性不应该以牺牲可用性、向类型检查器妥协或大幅改变你的思维方式为代价。
-
最终生成的空安全代码应该是完全健全的。“健全性”在静态检查的语境下对不同的人来说意义不同。对我们而言,在空安全语境下,这意味着如果一个表达式的静态类型不允许为空,那么该表达式的任何执行都不会求值为空。语言主要通过静态检查来提供这种保证,但也可能包含一些运行时检查。(不过,请注意第一原则:运行时检查的执行位置完全由你决定。)
健全性对于用户信心至关重要。一艘大部分时间都无法完全漂浮的船,你不会乐意驾驶它去冒险航行。但它对我们勇敢的编译器开发者来说也同样重要。当语言对程序的语义属性做出严格的保证时,这意味着编译器可以执行基于这些属性为真的优化。对于空值处理而言,这意味着我们可以生成更小的代码,从而消除不必要的空值检查;同时,由于无需在调用方法之前验证接收者是否为非空值,代码运行速度也会更快。
需要注意的是:我们仅保证完全空安全的 Dart 程序的可靠性。Dart 支持包含新旧版本代码混合的程序,这些新旧版本代码混合使用。在这些混合版本程序中,仍然可能出现空引用错误。在混合版本程序中,空安全部分的所有静态安全性优势都将得到充分发挥,但只有当整个应用程序都实现空安全时,才能获得完整的运行时可靠性。
请注意,消除空值并非我们的目标。空值本身并无不妥。相反,能够表示某个值的缺失状态非常有用。在语言中直接构建对特殊“缺失”值的支持,使得处理缺失值变得灵活易用。它为可选参数、便捷的空感知运算符 ?. 以及默认初始化提供了基础。真正糟糕的并非空值本身,而是空值出现在意料之外的地方才会导致问题。
因此,空安全的目标是让你能够控制和了解空值在程序中的流动路径,并确保它不会流向任何会导致程序崩溃的地方。
类型系统中的空值安全¶
空值安全始于静态类型系统,因为其他一切都建立在其之上。你的 Dart 程序中包含着一个庞大的类型宇宙:诸如 int 和 String 之类的基本类型,诸如 List 之类的集合类型,以及你和你使用的包定义的所有类和类型。在空值安全出现之前,静态类型系统允许 null 值流入任何这些类型的表达式中。
用类型理论的术语来说,Null 类型被视为所有类型的子类型:
某些表达式允许的操作集——getter、setter、方法和运算符——由其类型定义。如果类型是 List,你可以调用 .add() 或 []。如果类型是 int,你可以调用 +。但是 null 值本身并不定义任何这些方法。允许 null 值流入其他类型的表达式意味着任何这些操作都可能失败。这正是空引用错误的症结所在——所有失败都源于试图在 null 上查找它不存在的方法或属性。
非空类型与可空类型¶
空安全机制通过改变类型层次结构从根本上解决了这个问题。Null 类型仍然存在,但它不再是所有类型的子类型。类型层次结构如下所示:
由于 Null 不再是子类型,除了特殊的 Null 类之外,任何类型都不允许值为 null。我们默认将所有类型都设为非空类型。如果你有一个 String 类型的变量,它将始终包含一个字符串。这样,我们就修复了所有空引用错误。
如果我们认为 null 完全没有用处,那么到此为止就可以了。但 null 是有用的,所以我们仍然需要一种方法来处理它。可选参数就是一个很好的例子。考虑以下这段空安全的 Dart 代码:
// 使用空安全:
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 的简写形式。
使用可空类型¶
如果表达式中包含可空类型(nullable types),该如何处理结果呢?由于我们的原则默认是安全的,所以可操作的操作并不多。我们不能允许你直接调用底层类型的方法,因为如果值为 null,这些方法可能会失败:
// 假设的非健全空安全:
void bad(String? maybeString) {
print(maybeString.length);
}
void main() {
bad(null);
}
如果允许运行以下代码,程序将会崩溃。我们唯一可以安全访问的方法和属性是底层类型和 Null 类共同定义的。这些方法和属性包括 toString()、== 和 hashCode。因此,你可以将可空类型用作映射的键、存储在集合中、与其他值进行比较以及在字符串插值中使用它们,但仅此而已。
它们如何与非可空类型交互?将非可空类型传递给期望接收可空类型的函数始终是安全的。如果一个函数接受 String? 类型,那么传递 String 类型也是允许的,因为它不会导致任何问题。我们通过将每个可空类型都设为其底层类型的超类型来模拟这种情况。你也可以安全地将 null 传递给期望可空类型的函数,因此 Null 也是所有可空类型的子类型:
但是反过来,将可空类型传递给期望底层不可空类型的函数是不安全的。期望 String 类型的代码可能会调用该值的 String 方法。如果你传递一个 String? 类型的值,null 可能会传入,从而导致调用失败:
// 假设的非健全空安全:
void requireStringNotNull(String definitelyString) {
print(definitelyString.length);
}
void main() {
String? maybeString = null; // 或非null!
requireStringNotNull(maybeString);
}
这个程序是不安全的,我们不应该允许它运行。然而,Dart 一直都有一种叫做 隐式向下转型(implicit downcasts) 的功能。例如,如果你将 Object 类型的值传递给一个期望 String 类型的函数,类型检查器会允许这样做:
// 无空安全:
void requireStringNotObject(String definitelyString) {
print(definitelyString.length);
}
void main() {
Object maybeString = 'it is';
requireStringNotObject(maybeString);
}
为了保证代码的完整性,编译器会在 requireStringNotObject() 的参数上静默地插入一个 as String 类型转换。这种转换在运行时可能会失败并抛出异常,但在编译时,Dart 认为这是可以接受的。由于非空类型被建模为可空类型的子类型,隐式向下转型允许你将一个 String? 传递给一个期望接收 String 的函数。允许这样做会违背我们默认安全的初衷。因此,为了实现空安全,我们完全移除了隐式向下转型。
这使得对 requireStringNotNull() 的调用产生编译错误,这正是我们想要的。但这同时也意味着所有隐式向下转型都会变成编译错误,包括对 requireStringNotObject() 的调用。你需要自己添加显式的向下转型:
// 使用空安全:
void requireStringNotObject(String definitelyString) {
print(definitelyString.length);
}
void main() {
Object maybeString = 'it is';
requireStringNotObject(maybeString as String);
}
我们认为这是一个总体上不错的改动。我们的印象是,大多数用户都不喜欢隐式向下转型。特别是,你可能之前就遇到过这个问题:
发现 bug 了吗?.where() 方法是惰性求值的,所以它返回的是一个 Iterable 类型,而不是 List 类型。这个程序可以编译,但在运行时,当它尝试将该 Iterable 类型转换为 filterEvens 声明返回的 List 类型时,会抛出异常。移除隐式向下转型后,这个问题就变成了编译错误。
我们刚才说到哪儿了?对了,这就好比我们把程序中的所有类型分成了两部分:
一部分是 不可为空的类型(non-nullable)。这些类型允许你访问所有有用的方法,但永远不能包含 null 值。另一部分是所有对应的 可空类型(nullable types)。这些类型允许包含 null 值,但你无法对它们进行太多操作。我们允许值从不可为空的类型流向可空的类型,因为这样做是安全的,但反过来则不行。
看起来可空类型似乎基本没用。它们没有方法,而且你无法摆脱它们。别担心,我们有一整套功能可以帮助你将值从可空类型移到非空类型,我们很快就会介绍。
顶层与底层¶
本节内容略显深奥。除非你对类型系统感兴趣,否则可以跳过大部分内容,只关注最后两点。想象一下程序中的所有类型,它们之间用边连接,边代表彼此的子类型和超类型。如果像本文档中的图表那样画出来,它会形成一个巨大的有向图,顶部附近是类似 Object 的超类型,底部附近是类似你自定义类型的叶子类。
如果这个有向图的顶部存在一个类型,它是所有类型的超类型(直接或间接),那么这个类型就被称为顶部类型。同样地,如果底部存在一个特殊的类型,它是所有类型的子类型,那么这个类型就被称为底部类型。(在这种情况下,你的有向图是一个格(lattice))
如果你的类型系统有顶层类型和底层类型,那就很方便,因为这意味着像最小上界这样的类型级操作(类型推断用它来根据条件表达式两个分支的类型来确定其类型)总是可以生成一个类型。在引入空安全机制之前,Object 是 Dart 的顶层类型,Null 是它的底层类型。
由于 Object 现在不可为空,它不再是顶层类型。Null 也不是它的子类型。Dart 没有命名的顶层类型。如果你需要一个顶层类型,你应该使用 Object?。同样,Null 也不再是底层类型。如果它是,那么所有值仍然都是可空的。取而代之的是,我们添加了一个新的底层类型 Never:
实际上,这意味着:
-
如果你想表示允许任何类型的值,请使用 Object? 而不是 Object。事实上,使用 Object 已经变得非常不寻常,因为该类型意味着“可以是除这个奇怪的禁止值 null 之外的任何可能值”。
-
在极少数情况下,如果你需要指定一个底部类型,请使用 Never 而不是 Null。这对于指示函数永不返回,从而帮助进行可达性分析尤为有用。如果你不确定是否需要指定底部类型,那么很可能你并不需要。
确保正确性¶
我们将类型世界划分为可空类型和不可空类型两部分。为了维护代码的健全性,并遵循“除非你主动指定,否则运行时永远不会出现空引用错误”的原则,我们需要保证不可空类型中永远不会出现 null 值。
消除隐式向下转型并移除 Null 作为底层类型,涵盖了类型在程序中流动的所有主要环节,包括赋值操作以及函数调用中参数的转换。null 值可能出现的主要剩余环节是变量首次创建时以及函数退出时。因此,还会出现一些额外的编译错误:
无效返回¶
如果函数具有不可为 null 的返回类型,则通过该函数的每个路径都必须到达返回值的 return 语句。在空安全之前,Dart 对于丢失返回相当宽松。例如:
如果你分析过这段代码,你会隐约感觉到可能漏掉了一个返回值,但如果没分析,也没什么大不了的。这是因为如果执行到函数体末尾,Dart 会隐式地返回 null。由于所有类型都是可空的,所以从技术上讲,这个函数是安全的,尽管这可能不是你想要的结果。
如果使用可靠的不可空类型,这段程序就完全错误且不安全了。在空安全机制下,如果一个返回类型不可空的函数不能可靠地返回值,就会出现编译错误。“可靠地”指的是语言会分析函数中所有控制流路径。只要所有路径都返回值,就满足空安全要求。这种分析机制非常智能,所以即使是这样的函数也是可以的:
// 使用空安全:
String alwaysReturns(int n) {
if (n == 0) {
return 'zero';
} else if (n < 0) {
throw ArgumentError('不允许负值。');
} else {
if (n > 1000) {
return 'big';
} else {
return n.toString();
}
}
}
我们将在下一节更深入地探讨新的流量分析。
未初始化变量¶
声明变量时,如果没有显式地为其指定初始化器,Dart 默认会将变量初始化为 null。这很方便,但显然,如果变量的类型不可为空,这样做就非常不安全。因此,我们必须加强对不可为空变量的保护:
-
顶层变量和静态字段的声明必须有初始化器。由于程序中任何位置都可以访问和赋值这些变量,编译器无法保证变量在使用前已被赋值。唯一安全的做法是要求声明本身必须包含一个初始化表达式,该表达式能够生成正确类型的值:
-
实例字段必须在声明时提供初始值设定项,使用形式初始化,或者在构造函数的初始化列表中进行初始化。这听起来有点复杂。以下是一些示例:
// 使用空安全: class SomeClass { int atDeclaration = 0; int initializingFormal; int initializationList; SomeClass(this.initializingFormal) : initializationList = 0; }换句话说,只要在构造函数体执行之前字段有值,就没问题。
-
局部变量是最灵活的情况。非空局部变量不需要初始化器。这完全没问题::
// 使用空安全: int tracingFibonacci(int n) { int result; if (n < 2) { result = n; } else { result = tracingFibonacci(n - 2) + tracingFibonacci(n - 1); } print(result); return result; }规则很简单:局部变量必须在使用前被明确赋值。我们也可以依靠我之前提到的新的流程分析方法来解决这个问题。只要每次使用变量的路径都先对其进行初始化,那么这种使用就是合法的。
-
可选参数必须有默认值。 若你不为可选位置或命名参数传参,语言会用默认值填充。若不指定默认值,默认默认值是
null,若参数类型非空则这行不通。 -
可选参数必须具有默认值。如果你没有为可选的位置参数或命名参数传递参数,则语言会使用默认值填充它。如果你没有指定默认值,则默认默认值为
null,但如果参数类型为非空类型,则此默认值不适用。
因此,如果你希望某个参数是可选的,则需要将其设置为可空类型,或者指定一个有效的非空默认值。
这些限制听起来很繁琐,但实际上并没有那么糟糕。它们与现有的 final 变量限制非常相似,而你可能已经使用这些限制多年而没有真正注意到。此外,请记住,这些限制仅适用于非空变量。你始终可以将类型设置为可空,然后将默认初始化设置为 null。
即便如此,这些规则确实会带来一些不便。幸运的是,我们有一系列新的语言特性来优化最常见的模式,避免这些新限制拖慢你的速度。不过,首先,我们来谈谈流程分析。
流分析¶
控制流分析在编译器中已经存在多年。它通常对用户隐藏,用于编译器优化,但一些较新的语言也开始将相同的技术应用于可见的语言特性。Dart 已经以类型提升的形式融入了一些流程分析:
// 有(或无)空安全:
bool isEmptyList(Object object) {
if (object is List) {
return object.isEmpty; // <-- 可以!
} else {
return false;
}
}
注意在标记的行中,我们可以对 object 调用 isEmpty 方法。该方法定义在 List 类型上,而不是 Object 类型上。之所以有效,是因为类型检查器会检查程序中的所有 is 表达式和控制流路径。如果某个控制流结构的主体仅在变量上的特定 is 表达式为真时执行,那么在该主体内部,变量的类型会被“提升”为被测试的类型。
在本例中,if 语句的 then 分支仅在 object 实际包含一个列表时运行。因此,Dart 将 object 提升为 List 类型,而不是其声明的类型 Object。这是一个很方便的功能,但也有其局限性。在引入空安全机制之前,以下功能相同的程序无法正常工作:
// 无空安全:
bool isEmptyList(Object object) {
if (object is! List) return false;
return object.isEmpty; // <-- 错误!
}
同样,只有当 object 包含一个列表时才能执行到 .isEmpty 调用,因此该程序在动态上是正确的。但是类型提升规则不够智能,无法识别出 return 语句意味着只有当 object 是一个列表时才能执行到第二个语句。
为了确保零安全性,我们对这种有限的分析进行了改进,使其在多个方面变得更加强大。
可达性分析¶
首先,我们修复了长期以来关于类型提升无法有效处理提前返回和其他不可达代码路径的问题。现在,在分析函数时,它会考虑 return、break、throw 以及任何其他可能导致函数执行提前终止的情况。在空安全机制下,以下函数:
// 使用空安全:
bool isEmptyList(Object object) {
if (object is! List) return false;
return object.isEmpty; // OK
}
现在完全有效。由于 if 语句会在 object 不是 List 时退出函数,因此 Dart 会在第二个语句中将 object 提升为 List。这是一个非常好的改进,它对很多 Dart 代码都有帮助,甚至包括一些与空安全无关的代码。
Never用于不可达代码¶
你也可以编程这种可达性分析。新的底层类型Never没有值。(什么值能同时是String、bool和int?)那么表达式具有Never类型意味着什么?意味着该表达式永远无法成功完成求值。它必须抛出异常、中止或以其他方式确保期待表达式结果的周围代码永不运行。
你也可以编写程序来实现这种可达性分析。新的底层类型 Never 没有值(什么类型的值可以同时是 String、bool 和 int?),那么,表达式的类型为 Never 意味着什么呢?这意味着该表达式永远无法成功完成求值。它必须抛出异常、中止执行,或者以其他方式确保期望表达式结果的外部代码永远不会运行。
实际上,根据语言规范,throw 表达式的静态类型是 Never。Never 类型在核心库中声明,你可以将其用作类型注解。也许你有一个辅助函数,可以更轻松地抛出某种类型的异常:
// 使用空安全:
Never wrongType(String type, Object value) {
throw ArgumentError('Expected $type, but was ${value.runtimeType}.');
}
你可能这样使用:
// 使用空安全:
class Point {
final int x, y;
Point(this.x, this.y);
Point operator +(Object other) {
if (other is int) return Point(x + other, y + other);
if (other is! Point) wrongType('int | Point', other);
print('将两个Point实例相加:$this + $other');
return Point(x + other.x, y + other.y);
}
// toString、hashCode等实现...
}
此程序分析无错误。请注意,+ 方法的最后一行访问了 other 中的 .x 和 .y。即使该函数没有任何 return 或 throw,它也被提升为 Point 类型。控制流分析知道 wrongType() 的声明类型为 Never,这意味着 if 语句的 then 分支必须以某种方式中止执行。由于只有当 other 为 Point 时才能执行到最终语句,Dart 会将其提升。
换句话说,在你自己的 API 中使用 Never 可以扩展 Dart 的可达性分析。
确定赋值分析¶
我在讨论局部变量时简要提到过这一点。Dart 需要确保非空局部变量在被读取之前始终被初始化。我们使用确定赋值分析来尽可能灵活地实现这一点。该语言会分析每个函数体,并跟踪所有控制流路径中局部变量和参数的赋值情况。只要变量在每个调用路径上都被赋值,该变量就被视为已初始化。这使得你可以声明一个没有初始化器的变量,然后使用复杂的控制流对其进行初始化,即使该变量是非空类型。
我们还使用确定赋值分析来使 final 变量更加灵活。在空安全机制出现之前,如果你需要以任何特殊方式初始化局部变量,则很难使用 final 关键字:
// 使用空安全:
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 的,但没有初始化器。在空安全机制下,更智能的控制流分析可以解决这个问题。分析可以确定结果变量在每个控制流路径上都恰好被初始化了一次,因此满足了将变量标记为 final 的约束条件。
空值检查时的类型提升¶
更智能的流程分析对很多 Dart 代码都有帮助,甚至包括与空值无关的代码。但我们现在进行这些更改并非偶然。我们将类型划分为可空类型和非空类型。如果一个值是可空类型的,你实际上无法对其进行任何有效操作。在值为 null 的情况下,这种限制是有益的,它可以防止程序崩溃。
但如果值不为 null,最好能够将其提升到非空类型,以便可以对其调用方法。流程分析是实现局部变量和参数(以及 Dart 3.2 及之后的私有 final 字段)类型提升的主要方法之一。我们扩展了类型提升功能,使其也能处理 == null 和 != null 表达式。
如果你检查一个可空类型的局部变量是否为 null,Dart 会将该变量提升到其底层的非空类型:
// 使用空安全:
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>,允许你调用方法或将其传给期望非空列表的函数。
这里,arguments 是一个可空类型。通常情况下,这会阻止你对其调用 .join()。但由于我们在 if 语句中对该调用进行了保护,以确保其值不为空,Dart 会将其从 List<String>? 提升为 List<String>,从而允许你对其调用方法或将其传递给期望非空列表的函数。
这听起来似乎微不足道,但这种基于流的空值检查提升机制使得大多数现有的 Dart 代码能够实现空值安全。大多数 Dart 代码都是动态正确的,并且通过在调用方法之前检查是否为 null 来避免抛出空引用错误。新的空值检查流分析机制将这种动态正确性转化为可证明的静态正确性。
当然,它也与我们为可达性所做的更智能的分析相兼容。上面的函数可以写成:
// 使用空安全:
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 值的代码。但我们也可以利用同样的分析来检测你不需要的代码。在空安全机制出现之前,如果你编写了类似这样的代码:
// 使用空安全:
String checkList(List<Object> list) {
if (list?.isEmpty ?? false) {
return 'Got nothing';
}
return 'Got something';
}
Dart 无法判断这个空感知运算符 ?. 是否有用。在它看来,你可能会将 null 传递给函数。但在空安全 Dart 中,如果你使用现在不可为空的 List 类型注解了该函数,那么 Dart 就知道 list 永远不会为空。这意味着 ?. 永远不会执行任何有用的操作,你可以而且应该直接使用 .。
为了帮助你简化代码,我们现在添加了针对此类不必要代码的警告,因为静态分析已经足够精确,可以检测到它们。在不可为空的类型上使用空感知运算符,甚至像 == null 或 != null 这样的检查都会被报告为警告。
当然,这也与非空类型提升配合。一旦变量被提升为非空类型,若你冗余地再次检查null,你会得到警告:
当然,这也与不可为空类型的提升有关。一旦变量被提升为非空类型,如果你再次重复检查它是否为 null,就会收到警告:
// 使用空安全:
String checkList(List<Object>? list) {
if (list == null) return 'No list';
if (list?.isEmpty ?? false) {
return 'Empty list';
}
return 'Got something';
}
这里会因为 ?. 而收到警告,因为在它执行时,我们已经知道 list 不能为空。这些警告的目的不仅仅是清理无意义的代码。通过移除不必要的空值检查,我们确保剩余的有意义的检查更加突出。我们希望你能够查看自己的代码,并发现空值可能出现的地方。
使用可空类型¶
现在,我们已经将 null 值纳入了可空类型的集合。通过流程分析,我们可以安全地让一些非空值“越过边界”进入非空类型区域,从而可以使用它们。这是一个巨大的进步,但如果止步于此,最终的系统仍然非常局限。流程分析仅适用于局部变量、参数和私有 final 字段。
为了尽可能恢复 Dart 在引入空安全机制之前的灵活性,并在某些方面超越它,我们引入了一些其他新特性。
更智能的空感知方法¶
Dart 的空感知运算符 ?. 比空安全机制出现得更早。其运行时语义表明,如果接收者为 null,则跳过右侧的属性访问,表达式的计算结果为 null:
这不会抛出异常,而是打印“null”。空感知运算符是使 Dart 中可空类型可用的一个实用工具。虽然我们不能允许你对可空类型调用方法,但我们允许你对它们使用空值感知运算符。该程序的空值安全版本如下:
它的工作方式与之前的版本完全相同。
然而,如果你曾经在 Dart 中使用过空值感知运算符,你可能在方法链中使用它们时遇到过一些麻烦。假设你想检查一个可能不存在的字符串的长度是否为偶数(我知道这不是一个很实际的问题,但请先理解我的意思):
即使这个程序使用了 ?.,它仍然会在运行时抛出异常。问题在于 .isEven 表达式的接收者是它左侧整个 notAString?.length 表达式的结果。该表达式的值为 null,因此我们在尝试调用 .isEven 时会遇到空引用错误。如果你曾经在 Dart 中使用过 ?.,你可能已经深刻体会到,在使用一次空值感知运算符后,你必须将其应用于链中的每个属性或方法:
这很烦人,但更糟糕的是,它会掩盖重要的信息。考虑以下情况:
这里有一个问题:Thing 上的 doohickey 访问器(getter) 可以返回 null 吗?看起来好像是这样,因为你在结果中使用了 ?.。但第二个 ?. 可能只是为了处理 thing 为空的情况,而不是 doohickey 的结果。你无法确定。
为了解决这个问题,我们借鉴了 C# 中类似功能的巧妙设计。当你在方法链中使用空感知运算符时,如果接收者求值为空,则方法链的其余部分将被短路并跳过。这意味着如果 doohickey 的返回类型不可为空,那么你可以(也应该)这样写:
事实上,如果你不这样做,第二个 ?. 会给你一个不必要的代码警告。如果你看到类似这样的代码:
那么你就可以确定这意味着 doohickey 本身的返回类型为可为空。每个 ?. 都对应着一条可能导致空值流入方法链的唯一路径。这使得方法链中的空感知运算符更加简洁和精确。
趁此机会,我们还添加了一些其他支持空值的运算符:
虽然没有支持空值的函数调用运算符,但你可以这样写:
非空断言运算符¶
使用流分析(flow analysis)将可空变量移至非空类型的一大优势在于,这样做是可证明安全的。你可以调用之前为可空的变量的方法,而不会牺牲非空类型的安全性或性能。
但是,许多可空类型的有效用法无法以静态分析认可的方式证明其安全性。例如:
// 使用空安全,错误:
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 字段时,它不会为空,我们需要一种方法来断言这一点。通常,你会使用强制类型转换来断言类型,这里也可以这样做:
// 使用空安全:
String toString() {
if (code == 200) return 'OK';
return 'ERROR $code ${(error as String).toUpperCase()}';
}
若转型失败,将error转为非空String类型会抛出运行时异常。否则,它给我们一个可调用方法的非空字符串。
如果强制类型转换失败,将 error 转换为不可为空的 String 类型会抛出运行时异常。否则,它会返回一个不可为空的字符串,我们可以对其调用方法。
“强制类型转换以消除空性”的情况非常常见,因此我们提供了一种新的简写语法。后缀感叹号 (!) 会将左侧的表达式强制转换为其底层不可为空的类型。因此,上面的函数等价于:
// 使用空安全:
String toString() {
if (code == 200) return 'OK';
return 'ERROR $code ${error!.toUpperCase()}';
}
当底层类型冗长时,这个单字符的“感叹号运算符”尤其方便。如果仅仅为了从某个类型中移除一个问号 (?) 就必须写成 as Map<TransactionProviderFactory, List<Set<ResponseFilter>>>,那将非常麻烦。
当然,与任何强制类型转换一样,使用 ! 会损失静态安全性。必须在运行时检查强制类型转换以确保其可靠性,并且强制类型转换可能会失败并抛出异常。但是你可以控制这些类型转换的插入位置,并且你始终可以通过查看代码来看到它们。
延迟变量¶
类型检查器无法证明代码安全性的最常见地方是顶层变量和字段。例如:
// 使用空安全,错误:
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 会针对此类报告编译错误。
你可以通过将字段设置为可空,然后在使用时使用 not null 断言运算符来修复此错误:
// 使用空安全:
class Coffee {
String? _temperature;
void heat() { _temperature = 'hot'; }
void chill() { _temperature = 'iced'; }
String serve() => _temperature! + ' coffee';
}
这样做可以正常工作。但这会向类的维护者发出令人困惑的信号。将 _temperature 标记为可空,意味着你暗示 null 是该字段的一个有用且有意义的值。但这并非我们的本意。_temperature 字段永远不应该处于 null 状态。
为了处理常见的延迟初始化状态,我们添加了一个新的修饰符 late。你可以这样使用它:
// 使用空安全:
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 或可空字符串赋值给该字段会导致编译错误。late 修饰符允许你延迟初始化,但仍然禁止你将其视为可空变量。
延迟初始化¶
late 修饰符还有一些其他特殊功能。这看似矛盾,但你可以对带有初始化器的字段使用 late:
这样做之后,初始化器会变成延迟执行的。它不会在实例构造完成后立即运行,而是会被延迟执行,直到第一次访问该字段时才会运行。换句话说,它的工作方式与顶级变量或静态字段的初始化器完全相同。当初始化表达式开销很大且可能不需要执行时,这非常有用。
对实例字段使用 late 时,延迟执行初始化器还能带来额外的好处。通常,实例字段的初始化器无法访问 this,因为在所有字段初始化完成之前,你无法访问新对象。但对于延迟字段,情况就不同了,因此你可以访问 this、调用方法或访问实例上的字段。
延迟初始化的 final 变量¶
你也可以将 late 与 final 结合使用:
// 使用空安全:
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 的所有特性。如果你需要一些局部惰性求值,甚至可以将其用于局部变量。
必需的命名参数¶
为了确保你永远不会遇到不可为空类型的空参数,类型检查器要求所有可选参数要么是可为空类型,要么具有默认值。如果你想要一个不可为空类型且没有默认值的命名参数该怎么办?这意味着你希望调用者始终传递该参数。换句话说,你想要一个命名但非可选的参数。
我用下表可视化了 Dart 的各种参数类型:
必需 可选
+------------+------------+
位置参数 | f(int x) | f([int x]) |
+------------+------------+
命名参数 | ??? | f({int x}) |
+------------+------------+
出于不明原因,Dart 长期以来支持此表中的三个角,但将命名参数和必需参数的组合留空。有了空安全机制,我们填补了这一空白。你可以通过在参数前添加 required 来声明必需的命名参数:
这里,所有参数都必须按名称传递。参数 a 和 c 是可选的,可以省略。参数 b 和 d 是必需的,必须传递。请注意,必需性与是否可为空无关。你可以拥有可为空类型的必需命名参数,以及不可为空类型的可选命名参数(如果它们具有默认值)。
我认为这是 Dart 众多优点之一,即使不考虑空安全问题,它也让 Dart 变得更好。它让我感觉这门语言更加完善。
抽象字段¶
Dart 的一个巧妙之处在于它遵循统一访问原则。简单来说,这意味着字段与 getter 和 setter 并无区别。Dart 类中的某个“属性”是计算值还是存储值,这只是实现细节。因此,在使用抽象类定义接口时,通常会使用字段声明:
这样做的目的是让用户只实现该类,而不是扩展它。字段语法实际上是一种更简洁的 getter/setter 对编写方式:
但是 Dart 并不知道这个类永远不会被用作具体类型。它会将内容声明视为一个真正的字段。不幸的是,该字段不可为空且没有初始化器,因此会产生编译错误。
一种解决方法是使用显式的抽象 getter/setter 声明,如第二个示例所示。但这样写有点冗长,所以为了保证空安全,我们也添加了对显式抽象字段声明的支持:
它的行为与第二个示例完全相同。它只是声明了一个具有给定名称和类型的抽象 getter 和 setter。
处理可空字段¶
这些新特性覆盖了许多常见模式,使处理null在大多数时候相当无痛。但即便如此,我们的经验是可空字段(nullable fields)仍可能棘手。在能将字段设为late和非空的情况下,你很幸运。但在许多情况下你需要检查字段是否有值,这需要将其设为可空以便观察null。
这些新特性涵盖了许多常见模式,使得处理空值(null)在大多数情况下变得非常轻松。但即便如此,我们的经验表明,可空字段仍然可能带来一些问题。如果可以将字段设置为非空且不可空,那就万事大吉了。但在许多情况下,你需要检查字段是否有值,这就需要将其设置为可空,以便观察空值。
私有且 final 的可空字段可以进行类型提升(除非出于某些特殊原因)。如果由于某种原因无法将字段设置为私有且 final,则仍然需要使用变通方法。
例如,你可能期望以下代码可以正常工作:
// 使用空安全,错误:
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 是否为空。如果不是,我们会访问它并最终调用 + 运算符。遗憾的是,这是不允许的。
基于流的类型提升只能应用于私有且最终(final)的字段。否则,静态分析无法证明字段的值在检查是否为空和实际使用该字段之间没有发生变化。(例如,在某些极端情况下,子类中的 getter 方法可能会重写该字段,并在第二次调用时返回 null)
因此,由于我们关心健全性,公共和/或非final字段不提升,上述方法无法编译。这很烦人。在此简单情况下,最好的办法是在字段使用处加个!。看似冗余,但这大致就是Dart现在的行为。
因此,由于我们关注的是代码的健全性,公共字段和/或非 final 字段不会进行类型提升,上述方法也无法编译。这很烦人。在像这样的简单情况下,最好的办法是在该字段的使用处加上一个感叹号 (!)。这看起来似乎多余,但这正是 Dart 目前的行为方式。
另一个有用的方法是先将字段复制到一个局部变量中,然后再使用该局部变量:
// 使用空安全:
void checkTemp() {
var temperature = _temperature;
if (temperature != null) {
print('Ready to serve ' + temperature + '!');
}
}
由于类型提升也适用于局部变量,因此这种方法现在可以正常工作。如果需要更改值,只需记住将值存储回字段本身,而不仅仅是局部变量即可。
关于处理这些及其他类型提升问题的更多信息,参见修复类型提升失败。
可空性与泛型¶
与大多数现代静态类型语言一样,Dart 也支持泛型类和泛型方法。它们与可空性之间存在一些看似违反直觉但仔细思考后便会明白的关联。首先,“这个类型是否可空?”不再是一个简单的“是”或“否”的问题。例如:
// 使用空安全:
class Box<T> {
final T object;
Box(this.object);
}
void main() {
Box<String>('a string');
Box<int?>(null);
}
在 Box 的定义中,T 是可空类型还是不可空类型?正如你所见,它可以被两种类型实例化。答案是 T 是一个潜在可空类型。在泛型类或方法的主体中,潜在可空类型同时具有可空类型和不可空类型的所有限制。
前者意味着除了 Object 中定义的少数方法之外,你不能调用它的任何方法。后者意味着你必须在使用任何该类型的字段或变量之前对其进行初始化。这使得类型参数的使用变得相当困难。
在实践中,会出现一些特定的模式。在集合类中,如果类型参数可以使用任何类型实例化,那么你只需要处理相关的限制即可。大多数情况下,就像这里的例子一样,这意味着你需要确保在需要使用类型参数时,能够访问到该类型参数对应的值。幸运的是,集合类很少会调用其元素的方法。
在无法访问值的地方,你可以将类型参数设置为可空:
注意 object 声明中的问号 (?)。现在该字段具有显式可空类型,因此可以将其保持未初始化状态。
当你将类型参数设置为可空类型(例如此处的 T?)时,可能需要强制转换以消除其可空性。正确的方法是使用显式的 as T 强制转换,而不是使用 ! 运算符:
如果值为 null,! 运算符总是会抛出异常。但是,如果类型参数已使用可空类型实例化,则 null 是 T 的一个完全有效的值:
此程序应该可以无错误地运行。使用 as T 可以实现这一点。使用 ! 会抛出异常。
其他泛型类型有一些限制,用于限制可以应用的类型参数:
// 使用空安全:
class Interval<T extends num> {
T min, max;
Interval(this.min, this.max);
bool get isEmpty => max <= min;
}
作为此限制的补偿,你可以调用在其绑定上声明的类型参数类型的值的任何方法。但是,使用非空绑定会阻止泛型类的用户使用可空类型参数实例化它。对于大多数类来说,这可能是一个合理的限制。
你也可以使用可空绑定:
// 使用空安全:
class Interval<T extends num?> {
T min, max;
Interval(this.min, this.max);
bool get isEmpty {
var localMin = min;
var localMax = max;
// 无min或max意味着开区间。
if (localMin == null || localMax == null) return false;
return localMax <= localMin;
}
}
这意味着在类体中,你可以灵活地将类型参数视为可空类型,但同时也会受到可空性的限制。除非你先处理可空性,否则你无法对该类型的变量调用任何方法。在本例中,我们将字段复制到局部变量中,并检查这些局部变量是否为空,以便在使用 <= 之前,流程分析会将它们提升为非空类型。
请注意,可空绑定并不会阻止用户使用非空类型实例化类。可空绑定意味着类型参数可以为空,而不是必须为空。(实际上,如果你没有编写 extends 子句,类型参数的默认绑定是可空绑定的 Object?)没有办法强制要求类型参数为可空。如果你希望类型参数的使用始终为可空且隐式初始化为 null,可以在类体中使用 T?。
核心库变更¶
语言中还有一些其他的细微调整,但都无关紧要。例如,没有 on 子句的 catch 语句的默认类型现在是 Object 而不是 dynamic。switch 语句中的 fallthrough 分析使用了新的流程分析。
真正对你重要的变更都在核心库中。在我们开始这项“空值安全大冒险”之前,我们曾担心,如果不彻底破坏现有库,就无法实现核心库的空值安全。结果证明,情况并没有那么糟糕。虽然有一些重要的变更,但总体而言,迁移过程非常顺利。大多数核心库要么不接受 null 值并自然地迁移到非空类型,要么接受 null 值并以可空类型优雅地处理它。
不过,也有一些需要注意的地方:
Map 的索引运算符是可空的¶
这不算真正的变更,而是需要了解的事项。Map类上的索引[]操作符在键不存在时返回null。这意味着该操作符的返回类型必须是可空的:V?而非V。
这实际上不算一项变更,而更像是一个需要了解的事项。Map 类的索引 [] 运算符在键不存在时返回 null。这意味着该运算符的返回类型必须是可空的:V?而不是 V。
我们本可以修改该方法,使其在键不存在时抛出异常,并为其赋予一个更易于使用的非空返回类型。但是,使用索引运算符并检查 null 值以确定键是否存在的代码非常常见,根据我们的分析,大约占所有使用情况的一半。破坏所有这些代码将会引发 Dart 生态系统的轩然大波。
因此,运行时行为保持不变,因此返回类型必须是可空的。这意味着你通常不能立即使用映射查找的结果:
这会导致尝试对可空字符串调用 .length 时出现编译错误。如果你知道键存在,可以使用 ! 来告诉类型检查器:
我们曾考虑向 Map 添加另一个方法来为你完成此操作:查找键,如果找不到则抛出异常,否则返回一个非空值。但是该如何命名它呢?没有比单个字符 ! 更短的名称,也没有比看到 ! 更清晰的方法名称。它的内置语义就在调用点。因此,访问映射中已知存在的元素的惯用方法是使用 []!。你会习惯的。
没有未命名的 List 构造函数¶
List 的未命名构造函数会创建一个指定大小的新列表,但不会初始化任何元素。如果你创建了一个非空类型的列表,然后访问其中的元素,这将严重破坏代码的完整性保证。
为了避免这种情况,我们完全移除了该构造函数。即使对于可空类型,在空安全代码中调用 List() 也是错误的。这听起来很吓人,但实际上大多数代码都是使用列表字面量、List.filled()、List.generate() 或通过转换其他集合来创建列表的。对于需要创建某种类型空列表的特殊情况,我们添加了一个新的 List.empty() 构造函数。
在 Dart 中,创建完全未初始化的列表一直显得格格不入,现在更是如此。如果你的代码因此而出错,你始终可以使用其他多种方法来创建列表。
不能为非空列表设置更大的长度¶
这一点鲜为人知,但 List 类的长度 getter 也对应一个 setter。你可以将长度设置为较短的值来截断列表,也可以将其设置为较长的值来用未初始化的元素填充列表。
如果你对非空类型的列表执行此操作,之后访问这些未写入的元素时,就会违反代码的完整性。为了避免这种情况,长度 setter 会在列表元素类型为非空且你将其长度设置为较长值时抛出运行时异常。截断所有类型的列表仍然是可以的,而对于可空类型的列表,你可以增加其长度。
如果你定义了继承自 ListBase 或应用了 ListMixin 的自定义列表类型,则会产生一个重要的后果。这两种类型都提供了 insert() 方法的实现,该方法之前会通过设置长度来为插入的元素预留空间。这样做会违反空安全原则,所以我们修改了 ListMixin(ListBase 也使用 ListMixin)中 insert() 方法的实现,改为调用 add() 方法。如果你希望能够使用继承的 insert() 方法,你的自定义列表类需要提供 add() 方法的定义。
迭代前后均无法访问 Iterator.current¶
Iterator 类是一个可变的“游标”类,用于遍历实现了 Iterable 接口的类型中的元素。在访问任何元素之前,你应该调用 moveNext() 方法来移动到第一个元素。当该方法返回 false 时,表示你已到达末尾,没有更多元素了。
过去,如果你在第一次调用 moveNext() 之前或迭代结束后调用 current,它会返回 null。如果采用空安全机制,则 current 的返回类型必须为 E? 而不是 E。这意味着每次元素访问都需要进行运行时空值检查。
鉴于几乎没有人会以这种错误的方式访问 current 元素,这些检查将毫无意义。因此,我们将 current 的类型设置为 E。由于在迭代前后可能存在该类型的值,因此如果你在不应该调用 current 的情况下调用它,我们将迭代器的行为设为未定义。大多数 Iterator 实现都会抛出 StateError 异常。
总结¶
以上是对围绕空安全的所有语言和库变更的详细讲解。内容很多,但这的确是一项相当大的语言变更。更重要的是,我们希望 Dart 能够保持其内聚性和易用性。这不仅需要改变类型系统,还需要改变围绕它的许多其他可用性特性。我们不希望空安全感觉像是硬加进去的。
需要记住的核心要点如下:
-
类型默认不可为空,可以通过添加
?来设置为可为空。 -
可选参数必须可为空或具有默认值。可以使用
required将命名参数设置为不可为空。不可为空的顶级变量和静态字段必须有初始化器。不可为空的实例字段必须在构造函数体开始之前初始化。 -
如果接收者为空,则空感知运算符之后的方法链会短路。新增了空感知级联运算符
?..和索引运算符?[]。后缀非空断言“感叹号”运算符 (!) 会将其可空操作数强制转换为底层不可空类型。 -
流程分析允许你安全地将可空局部变量和参数(以及 Dart 3.2 及更高版本中的私有
final字段)转换为可用的不可空类型。新的流程分析还针对类型提升、缺少返回值、不可达代码和变量初始化制定了更智能的规则。 -
延迟修饰符允许你在原本可能无法使用的地方使用不可空类型和
final类型,但代价是运行时检查。它还提供了延迟初始化字段。 -
List类已更改,以防止出现未初始化的元素。
最后,一旦你理解了所有这些内容并将代码置于空安全的世界,你就得到了一个编译器可以优化的健全程序,并且代码中所有可能发生运行时错误的地方都清晰可见。我们希望你觉得这一切的努力都是值得的。




