理解空安全
作者: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 的答案。它包括对静态类型系统的更改以及一系列其他修改和新语言特性,使您不仅可以编写空安全的代码,而且希望您喜欢这样做。(剧透:它最终与您今天编写 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 的键,将它们存储在集合中,将它们与其他值进行比较,并在字符串插值中使用它们,但仅此而已。
它们如何与不可为空的类型交互?将不可为空的类型传递给期望可为空类型的内容始终是安全的。如果一个函数接受 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
表达式为 true 时执行,那么在该主体内,变量的类型将“提升”为经过测试的类型。
在此示例中,if
语句的 then 分支仅在 object
实际包含列表时运行。因此,Dart 将 object
的类型提升为 List
,而不是其声明的类型 Object
。这是一个方便的功能,但它非常有限。在空安全之前,以下功能相同的程序不起作用:
// 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;
}
现在是完全有效的。由于当 object
不是 List
时,if
语句将退出函数,因此 Dart 将 object
提升为第二个语句中的 List
。这是一个非常好的改进,它有助于很多 Dart 代码,即使是那些与可空性无关的代码。
永不用于不可达的代码
#你还可以编程此可达性分析。新的底部类型 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 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
。它已被提升为 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
时,我们永远不会访问它。但这需要理解 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()}';
}
当基础类型很冗长时,这个单字符的“bang 运算符”特别方便。仅仅为了从某个类型中转换掉一个 ?
,而必须编写 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';
}
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
,调用方法,或访问实例上的字段。
延迟 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 生态系统陷入混乱。
相反,运行时行为是相同的,因此返回类型必须是可空的。这意味着你通常不能立即使用地图查找的结果:
// 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
中 insert()
的实现(ListBase
也共享该实现),改为调用 add()
。 如果你希望能够使用继承的 insert()
方法,则自定义列表类应提供 add()
的定义。
无法在迭代之前或之后访问 Iterator.current
#Iterator
类是用于遍历实现 Iterable
的类型的元素的“游标”可变类。 你应该在访问任何元素之前调用 moveNext()
以前进到第一个元素。 当该方法返回 false
时,你已到达结尾,并且没有更多元素了。
过去,如果你在第一次调用 moveNext()
之前或迭代结束后调用 current
,则它会返回 null
。 在空安全的情况下,这将要求 current
的返回类型为 E?
而不是 E
。 反过来,这意味着每次元素访问都需要运行时 null
检查。
鉴于几乎没有人以这种错误的方式访问当前元素,这些检查将毫无用处。 相反,我们已将 current
的类型设置为 E
。 由于在迭代之前或之后可能存在该类型的值,因此我们已经将迭代器的行为定义为,如果你在不应该调用它时调用它,则行为未定义。 大多数 Iterator
的实现都会抛出 StateError
。
总结
#这是对围绕空安全的所有语言和库更改的非常详细的介绍。 内容很多,但这确实是一个相当大的语言更改。 更重要的是,我们希望达到一个 Dart 仍然感觉连贯且可用的程度。 这不仅需要更改类型系统,还需要更改围绕它的许多其他可用性功能。 我们不希望它感觉像是硬加上了空安全。
需要记住的核心要点是
类型默认为非可空,通过添加
?
使其可空。可选参数必须可空或具有默认值。 你可以使用
required
使命名参数为非可选。 非可空顶级变量和静态字段必须具有初始值设定项。 非可空实例字段必须在构造函数体开始之前初始化。如果接收者为
null
,则在空感知运算符之后的链式方法会短路。 有新的空感知级联 (?..
) 和索引 (?[]
) 运算符。 后缀空断言“bang”运算符 (!
) 将其可空操作数转换为底层非可空类型。流分析使你可以安全地将可空局部变量和参数(以及从 Dart 3.2 开始的私有 final 字段)转换为可用的非可空变量。 新的流分析还具有更智能的类型提升、缺少返回、无法访问的代码和变量初始化的规则。
late
修饰符允许你在否则可能无法使用非可空类型和final
的位置使用它们,但代价是运行时检查。 它还为你提供延迟初始化的字段。更改了
List
类以防止未初始化的元素。
最后,一旦你吸收了所有这些知识并将你的代码带入空安全的世界,你就会得到一个健全的程序,编译器可以对其进行优化,并且代码中可能发生运行时错误的每个位置都是可见的。 我们希望你觉得这值得为此付出努力。
除非另有说明,否则本网站上的文档反映的是 Dart 3.6.0。页面最后更新于 2024-05-14。 查看源文件 或 报告问题。