理解空安全
作者:Bob Nystrom
2020 年 7 月
空安全是我们自 Dart 2.0 中用健全的静态类型系统取代原有的不健全可选类型系统以来,对 Dart 所做的最大改变。当 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 的解决方案。它包括对静态类型系统的更改以及一系列其他修改和新语言特性,让您不仅能够编写空安全代码,而且希望能享受这个过程。
本文档很长。如果您想了解更精简的内容,只包含上手所需知识,请从概览开始。当您准备好更深入地理解并有时间时,请回到这里,以便您了解语言如何处理 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 键,存储在 Set 中,与其他值进行比较,并在字符串插值中使用它们,但仅此而已。
它们如何与非空类型交互?将非空类型传递给预期可空类型的东西总是安全的。如果一个函数接受 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 认为这是可以的。由于非空类型被建模为可空类型的子类型,隐式向下转型将允许您将 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
类型。随着隐式向下转型的移除,这会变成一个编译错误。
我们说到哪了?对了,好的,所以就好像我们把您程序中的类型宇宙分成了两半

有一个非空类型区域。这些类型允许您访问所有有趣的方法,但绝不能包含 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
。由于每种类型都是可空的,所以技术上这个函数是安全的,即使它可能不是您想要的。
有了健全的非空类型,这个程序就完全错误且不安全了。在空安全下,如果一个具有非空返回类型的函数没有可靠地返回值,您将收到编译错误。我所说的“可靠地”,是指语言会分析函数中的所有控制流路径。只要它们都返回了东西,它就满足了。这个分析相当智能,所以即使是这个函数也是可以的
// 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; }
规则仅为:局部变量在使用前必须被明确赋值。 为此,我们也可以依赖我之前提及的新流分析。只要到达变量使用的每条路径都首先对其进行初始化,该使用就是可以的。
可选参数必须有默认值。 如果您没有为可选的位置参数或命名参数传递实参,那么语言会用默认值填充它。如果您没有指定默认值,默认默认值是
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
。Never
类型在核心库中声明,您可以将其用作类型注解。也许您有一个辅助函数,可以更容易地抛出某种类型的异常
// Using null safety:
Never wrongType(String type, Object value) {
throw ArgumentError('Expected $type, but was ${value.runtimeType}.');
}
您可以这样使用它
// Using null safety:
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('Adding two Point instances together: $this + $other');
return Point(x + other.x, y + other.y);
}
// toString, hashCode, and other implementations...
}
此程序分析无误。请注意,+
方法的最后一行访问了 other
上的 .x
和 .y
。它已被提升为 Point
,即使该函数没有任何 return
或 throw
语句。控制流分析知道 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
时我们从不访问 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>>>
会非常恼人。
当然,像任何转型一样,使用 !
会导致静态安全性损失。为了保持健全性,该转型必须在运行时进行检查,并且可能会失败并抛出异常。但您可以控制这些转型插入的位置,并且始终可以通过查看您的代码来发现它们。
late 变量
#类型检查器无法证明代码安全性的最常见地方是顶层变量和字段。这里有一个例子
// 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
,调用方法或访问实例上的字段。
late 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 语句中的贯穿分析也使用了新的流分析。
对您真正重要的其余变化都在核心库中。在我们踏上空安全大冒险之前,我们曾担心,如果不大量破坏现有代码,就无法使我们的核心库实现空安全。事实证明并没有那么糟糕。确实有几个重大变化,但总的来说,迁移进展顺利。大多数核心库要么不接受 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
,它会返回 null
。在空安全下,这将要求 current
的返回类型为 E?
而不是 E
。这反过来意味着每次元素访问都需要一个运行时 null
检查。
考虑到几乎没有人会以那种错误的方式访问当前元素,这些检查将是无用的。相反,我们已将 current
的类型设为 E
。由于在迭代之前或之后可能存在该类型的值,因此如果您在不应该调用它时调用它,我们已将迭代器的行为定义为未定义。大多数 Iterator
的实现都会抛出 StateError
。
总结
#这是关于空安全所有语言和库更改的非常详细的介绍。内容很多,但这是一项相当大的语言变革。更重要的是,我们希望达到一个让 Dart 仍然感觉连贯和可用的程度。这不仅需要改变类型系统,还需要改变其周围的许多其他可用性特性。我们不希望空安全感觉像是后来硬加上去的。
核心要点如下
类型默认非空,通过添加
?
变为可空。可选参数必须是可空的或具有默认值。您可以使用
required
使命名参数成为必需参数。非空的顶层变量和静态字段必须具有初始化器。非空的实例字段必须在构造函数体开始之前初始化。空感知运算符之后的方法链如果接收者为
null
则会短路。新增了空感知级联 (?..
) 和索引 (?[]
) 运算符。后缀非空断言“感叹号”运算符 (!
) 将其可空操作数转换为底层非空类型。流分析允许您安全地将可空的局部变量和参数(以及 Dart 3.2 开始的私有 final 字段)转换为可用的非空变量。新的流分析还具有更智能的类型提升、缺失返回、不可达代码和变量初始化规则。
late
修饰符允许您在某些情况下使用非空类型和final
,而这些情况在没有它时可能无法实现,代价是运行时检查。它还为您提供了惰性初始化的字段。List
类已更改以防止未初始化的元素。
最后,一旦您吸收了所有这些内容并将您的代码带入空安全的世界,您将获得一个编译器可以优化且代码中每个可能发生运行时错误的地方都可见的健全程序。我们希望您觉得这值得为之努力。