理解空安全
Bob Nystrom 著
2020年7月
空安全是我们自 Dart 2.0 中用 健全的静态类型系统 替换原始的不健全可选类型系统以来对 Dart 做出的最大改变。Dart 首次发布时,编译时空安全是一个罕见的特性,需要冗长的介绍。如今,Kotlin、Swift、Rust 等其他语言都对这个已变得非常 常见的问题 给出了自己的答案。这是一个例子:
// 没有空安全:
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 代码:
// 使用空安全:
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
,这些方法可能会失败:
// 假设的不健全空安全:
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; // 或不是!
requireStringNotNull(maybeString);
}
此程序不安全,我们不应该允许它。但是,Dart 始终具有所谓的 隐式向下转换。例如,如果您将 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);
}
我们认为这是一个整体上的良好改变。我们的印象是大多数用户从未喜欢过隐式向下转换。特别是,您之前可能被这烧过:
// 没有空安全:
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 对缺少返回非常宽松。例如:
// 没有空安全:
String missingReturn() {
// 没有返回。
}
如果您分析了这一点,您会得到一个温和的 提示,即您 可能 忘记了返回,但如果不是,也没什么大不了的。这是因为如果执行到达函数体的末尾,则 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
对变量进行默认初始化。这很方便,但如果变量的类型不可为空,则显然完全不安全。因此,我们必须加强不可空变量的限制:
顶级变量和静态字段声明必须具有初始化程序。 由于这些可以从程序中的任何地方访问和赋值,因此编译器无法保证在使用变量之前已为其赋予值。唯一安全的选择是要求声明本身具有一个初始化表达式,该表达式生成正确类型的的值:
dart// 使用空安全: int topLevel = 0; class SomeClass { static int staticField = 0; }
实例字段必须在声明中具有初始化程序,使用初始化形式参数,或在构造函数的初始化列表中进行初始化。 这有很多术语。以下是示例:
dart// 使用空安全: class SomeClass { int atDeclaration = 0; int initializingFormal; int initializationList; SomeClass(this.initializingFormal) : initializationList = 0; }
换句话说,只要在到达构造函数体之前字段具有值,就可以了。
局部变量是最灵活的情况。不可空的局部变量 不需要 具有初始化程序。这完全没问题:
dart// 使用空安全: 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
表达式为 true 时执行,则在该正文内,变量的类型将被“提升”到测试类型。
在此示例中, 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;
}
现在完全有效了。由于 if
语句将在 object
不是 List
时退出函数,因此 Dart 会将 object
在第二个语句中提升为 List
。这是一个非常好的改进,它可以帮助很多 Dart 代码,即使是不与可空性相关的代码。
用于不可达代码的 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 double x, y;
bool operator ==(Object other) {
if (other is! Point) wrongType('Point', other);
return x == other.x && y == other.y;
}
// 构造函数和 hashCode...
}
此程序分析没有错误。请注意, ==
方法的最后一行在 other
上访问 .x
和 .y
。它已被提升为 Point
,即使函数没有任何 return
或 throw
。控制流分析知道 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
但没有初始化程序。在空安全下使用更智能的流分析,此程序是可以的。分析可以确定 result
在每条控制流路径上都恰好被初始化一次,因此标记变量为 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>
,并允许您在其上调用方法或将其传递给期望不可空列表的函数。
这听起来像是一件相当小的事情,但是这种基于流的空检查提升使得大多数现有的 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
类型对该函数进行了注释,那么它就知道 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
。这些警告的目标不仅仅是清理无意义的代码。通过删除对 null
的 不必要 检查,我们确保其余有意义的检查脱颖而出。我们希望您能够查看代码并 查看 null
可以流向何处。
处理可空类型
#我们现在已将 null
限制在可空类型的集合中。使用流分析,我们可以安全地让一些非 null
值跳过栅栏到不可空的一侧,在那里我们可以使用它们。这是一大步,但如果我们到此为止,则生成的系统仍然非常严格。流分析仅适用于局部变量、参数和私有 final 字段。
为了尝试恢复 Dart 在空安全之前拥有的许多灵活性——并在某些地方超越它——我们还有一些其他新功能。
更智能的空感知方法
#Dart 的空感知运算符 ?.
比空安全要古老得多。运行时语义指出,如果接收者为 null
,则右侧的属性访问将被跳过,表达式将计算为 null
:
// 没有空安全:
String notAString = null;
print(notAString?.length);
此程序不会抛出异常,而是打印“null”。空感知运算符是使可空类型在 Dart 中可用的好工具。虽然我们不能让您在可空类型上调用方法,但我们确实允许您在它们上使用空感知运算符。该程序的空安全后版本为:
// 使用空安全:
String? notAString = null;
print(notAString?.length);
它的工作方式与之前的版本相同。
但是,如果您曾经在 Dart 中使用过空感知运算符,那么在方法链中使用它们时,您可能遇到过烦人的问题。假设您想查看可能不存在的字符串的长度是否为偶数(我知道这不是一个特别现实的问题,但请与我一起研究):
// 使用空安全:
String? notAString = null;
print(notAString?.length.isEven);
即使此程序使用 ?.
,它仍然会在运行时抛出异常。问题是 .isEven
表达式的接收者是其左侧的整个 notAString?.length
表达式的结果。该表达式计算为 null
,因此我们在尝试调用 .isEven
时会得到一个空引用错误。如果您曾经在 Dart 中使用过 ?.
,您可能已经很难学习到必须在使用一次后对链中的 每个 属性或方法应用空感知运算符:
String? notAString = null;
print(notAString?.length?.isEven);
这很烦人,但更糟糕的是,它掩盖了重要信息。考虑:
// 使用空安全:
showGizmo(Thing? thing) {
print(thing?.doohickey?.gizmo);
}
这里有一个问题: Thing
上的 doohickey
getter 可以返回 null
吗?它看起来 可以 ,因为您正在对结果使用 ?.
。但这可能仅仅是因为第二个 ?.
只是为了处理 thing
为 null
的情况,而不是 doohickey
的结果。你不知道。
为了解决这个问题,我们借鉴了 C# 对相同功能的设计的一个巧妙想法。当您在方法链中使用空感知运算符时,如果接收者计算为 null
,则 方法链的其余部分将被短路并跳过 。这意味着如果 doohickey
具有不可空的返回类型,那么您可以并且应该编写:
// 使用空安全:
void showGizmo(Thing? thing) {
print(thing?.doohickey.gizmo);
}
事实上,如果您不这样做,您将收到关于第二个 ?.
的不必要代码警告。如果您看到如下代码:
// 使用空安全:
void showGizmo(Thing? thing) {
print(thing?.doohickey?.gizmo);
}
那么您肯定知道这意味着 doohickey
本身具有可空返回类型。每个 ?.
都对应于可以导致 null
流入方法链的 唯一 路径。这使得方法链中的空感知运算符更加简洁和精确。
在此期间,我们添加了几个其他空感知运算符:
// 使用空安全:
// 空感知级联:
receiver?..method();
// 空感知索引运算符:
receiver?[index];
没有空感知函数调用运算符,但您可以编写:
// 使用或不使用空安全均允许:
function?.call(arg1, arg2);
非空断言运算符
#使用流分析将可空变量移动到世界的不可空侧的好处在于,这样做是可证明安全的。您可以调用以前可空变量上的方法,而不会放弃不可空类型的任何安全性和性能。
但是,许多可空类型的有效用法无法以静态分析满意的方式被 证明 是安全的。例如:
// 使用空安全,不正确:
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
转换来断言类型,您也可以在这里执行相同的操作:
// 使用空安全:
String toString() {
if (code == 200) return 'OK';
return 'ERROR $code ${(error as String).toUpperCase()}';
}
如果转换失败,将 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 会在此类上报告编译错误。
您可以通过使字段可空然后在使用中使用空断言运算符来修复错误:
// 使用空安全:
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
或可空 String
是一个 编译 错误。 late
修饰符允许您 延迟 初始化,但仍然禁止您将其视为可空变量。
延迟初始化
#late
修饰符还有一些其他特殊功能。这似乎自相矛盾,但您可以在具有初始化程序的字段上使用 late
:
// 使用空安全:
class Weather {
late int _temperature = _readThermometer();
}
当您这样做时,初始化程序将变为 惰性。它不会在实例构建后立即运行,而是延迟运行,并在第一次访问该字段时惰性运行。换句话说,它的工作方式与顶级变量或静态字段上的初始化程序完全相同。当初始化表达式成本高昂且可能不需要时,这将非常方便。
当您在实例字段上使用 late
时,惰性运行初始化程序会给您额外的奖励。通常,实例字段初始化程序无法访问 this
,因为在所有字段初始化程序都完成后,您才能访问新对象。但是对于 late
字段,情况不再如此,因此您 可以 访问 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
的大部分功能空间。如果您想要一点局部惰性计算,您甚至可以在局部变量上使用它。
必需命名参数
#为了保证您永远不会看到具有不可空类型的 null
参数,类型检查器要求所有可选参数都具有可空类型或默认值。如果您想要一个具有不可空类型且没有默认值的命名参数怎么办?这意味着您希望要求调用者 始终 传递它。换句话说,您想要一个 命名 但不是可选的参数。
我用此表来可视化各种类型的 Dart 参数:
强制性 可选
+------------+------------+
位置参数 | f(int x) | f([int x]) |
+------------+------------+
命名参数 | ??? | f({int x}) |
+------------+------------+
由于不明确的原因,Dart 长期以来一直支持此表的三角,但将命名+强制的组合留空。使用空安全,我们填补了这个空白。您可以通过在参数前放置 required
来声明必需的命名参数:
// 使用空安全:
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
。
私有且最终的可空字段能够进行类型提升(排除 某些特定原因 )。如果您由于某种原因无法使字段私有且最终,则仍然需要变通方法。
例如,您可能期望这可以工作:
// 使用空安全,不正确:
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 当今的行为。
另一种有帮助的模式是首先将字段复制到局部变量,然后使用它:
// 使用空安全:
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 上定义的少数方法。后者意味着您必须在使用该类型的任何字段或变量之前对其进行初始化。这会使类型参数非常难以使用。
在实践中,出现了一些模式。在可以实例化任何类型的集合类中,您只需要处理这些限制。在大多数情况下,就像这里的例子一样,这意味着无论何时需要使用类型参数的类型的值,您都必须确保可以访问它。幸运的是,类似集合的类很少会调用其元素上的方法。
在您无法访问值的地方,您可以使类型参数的使用可空:
// 使用空安全:
class Box<T> {
T? object;
Box.empty();
Box.full(this.object);
}
请注意 object
声明上的 ?
。现在该字段具有明确的可空类型,因此可以将其保留为未初始化状态。
当您像这里一样将类型参数类型设为可空( T?
)时,您可能需要转换掉可空性。执行此操作的正确方法是使用显式的 as T
转换,而不是 !
运算符:
// 使用空安全:
class Box<T> {
T? object;
Box.empty();
Box.full(this.object);
T unbox() => object as T;
}
如果该值为 null
,则 !
运算符 始终 会抛出异常。但是,如果类型参数已使用可空类型进行实例化,则 null
是 T
的完全有效值:
// 使用空安全:
void main() {
var box = Box<int?>.full(null);
print(box.unbox());
}
此程序应该在没有错误的情况下运行。使用 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;
// 没有最小值或最大值意味着开区间。
if (localMin == null || localMax == null) return false;
return localMax <= localMin;
}
}
这意味着在类的正文中,您可以灵活地将类型参数视为可空,但您也具有可空性的限制。除非您首先处理可空性,否则您不能在该类型的变量上调用任何内容。在此示例中,我们将字段复制到局部变量中并检查这些局部变量是否为 null
,以便在使用 <=
之前,流分析将它们提升为不可空类型。
请注意,可空边界不会阻止用户使用不可空类型实例化该类。可空边界意味着类型参数 可以 为可空,而不是 必须 为可空。(事实上,如果您不编写 extends
子句,则类型参数的默认边界是可空边界 Object?
。)没有办法 要求 可空类型参数。如果您希望类型参数的使用可靠地为可空并隐式初始化为 null
,则可以在类的正文中使用 T?
。
核心库更改
#语言中还有其他一些调整,但它们很小。例如,没有 on
子句的 catch
的默认类型现在是 Object
而不是 dynamic
。switch 语句中的贯穿分析使用新的流分析。
对您真正重要的其余更改位于核心库中。在我们开始伟大的空安全冒险之前,我们担心结果可能是无法使我们的核心库空安全,而不会大量破坏世界。结果并非如此可怕。确实有一些重大变化,但在大多数情况下,迁移进展顺利。大多数核心库要么不接受 null
并自然地迁移到不可空类型,要么接受它并使用可空类型优雅地接受它。
不过,还有一些重要的角落:
Map 索引运算符是可空的
#这并不是真正的变化,而是需要了解的事情。Map 类上的索引 []
运算符如果键不存在则返回 null
。这意味着该运算符的返回类型必须是可空的: V?
而不是 V
。
如果键不存在,我们可以将该方法更改为抛出异常,然后为其提供更容易使用的不可空返回类型。但是使用索引运算符并检查 null
以查看键是否不存在的代码非常常见,根据我们的分析,大约占所有使用的一半。破坏所有这些代码会使 Dart 生态系统陷入混乱。
相反,运行时行为是相同的,因此返回类型必须是可空的。这意味着您通常不能立即使用映射查找的结果:
// 使用空安全,不正确:
var map = {'key': 'value'};
print(map['key'].length); // 错误。
这会在尝试对可空字符串调用 .length
时给您一个编译错误。在您 知道 键存在的情况下,您可以通过使用 !
来教导类型检查器:
// 使用空安全:
var map = {'key': 'value'};
print(map['key']!.length); // 正确。
我们考虑向 Map 添加另一种方法来为您执行此操作:查找键,如果未找到则抛出异常,否则返回不可空值。但是如何称呼它呢?没有哪个名称会比单字符 !
更短,也没有哪个方法名称会比在调用站点看到具有其内置语义的 !
更清晰。因此,访问映射中已知存在的元素的惯用方法是使用 []!
。你会习惯的。
没有未命名的 List 构造函数
#List
上的未命名构造函数使用给定的 size 创建一个新列表,但不初始化任何元素。如果您创建了一个不可空类型的列表,然后访问一个元素,这会在健全性保证中留下一个非常大的漏洞。
为了避免这种情况,我们完全删除了构造函数。即使使用可空类型,在空安全代码中调用 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
类已更改,以防止未初始化的元素。
最后,一旦您吸收了所有这些内容并将您的代码带入空安全的世界,您将获得一个健全的程序,编译器可以对其进行优化,并且可以在您的代码中看到可能发生运行时错误的每个位置。我们希望您觉得达到这个目标是值得的。
除非另有说明,否则本网站上的文档反映的是 Dart 3.6.0。页面最后更新于 2025-02-05。 查看源代码 或 报告问题.