编者按: 本文为 Dart 空安全的经典详解,其详细且全面地展开说明了 Dart 处理空安全采用的原则、具体实现和技术细节,对于众多 Dart 开发者而言是不可错过的精华内容。无论您是否已经成为了 Dart 专家,相信在阅读后都会感到受益匪浅。所以,倒一杯清甜的茶,找一张舒适的椅子,让我们带您进入空安全之旅吧!
文 / Bob Nystrom, Google Dart 团队工程师
// Without null safety:
bool isEmpty(String string) => string.length == 0;
main() {
isEmpty(null);
}
.length
时抛出
NoSuchMethodError
异常。
null
值是
Null
类的一个实例,而
Null
null
的值上调用
.length
这样的错误能被检测到。
null
、
为什么
我们会这样设计,以及您如何写出符合现代习惯的空安全 Dart 代码。(剧透一下: 实际上它和您当前写 Dart 代码的方式相差无几。)
处理空引用错误的方法各有利弊。我们基于以下的原则做出选择:
null
的静态类型,那么这个表达式的任何执行结果都不可能为
null
。Dart 语言主要通过静态检查来保证这项特性,但在运行时也有一些检查参与其中。(不过,根据第一条原则,在运行时何时何地进行检查,完全由您自己掌握。)
null
时,意味着可以消除不必要的
null
检查,提供更精悍的代码,并且在对其调用方法前,不需要再校验是否其为空调用。
null
。
null
没有任何错。相反,可以表示一个
空缺
的值是十分有用的。在语言中提供对空缺的值的支持,让处理空缺更为灵活和高效。它为可选参数、
?.
空调用语法糖和默认值初始化提供了基础。
null
并不糟糕,糟糕的是
它在您意想不到的地方出现
,最终引发问题。
null
可见且可控,并且确保它不会传递至某些位置从而引发崩溃。
类型系统中的可空性
因为一切均建立于静态类型系统上,所以空安全也始于此处。您的 Dart 程序中包含了整个类型世界: 基本类型 (如 int
和 String
)、 集合类型 (如 List
) 以及您和您所使用的依赖所定义的类和类型。在空安全推出之前,静态类型系统允许所有类型的表达式中的每一处都可以有 null
。
从类型理论的角度来说,Null
类型被看作是所有类型的子类;
List
类型,您可以对其调用
.add()
或
[]
。如果是
int
类型,您可以对其调用
+
。但是
null
值并没有它们定义的任何一个方法。所以当
null
传递至其他类型的表达式时,任何操作都有可能失败。这就是空引用的症结所在——所有错误都来源于尝试在
null
上查找一个不存在的方法或属性。
非空和可空类型
Null
类型仍然存在,但它不再是所有类型的子类。现在的类型层级看起来是这样的:
Null
已不再被看作所有类型的子类,那么除了特殊的
Null
类型允许传递
null
值,其他类型均不允许。我们已经将所有的类型设置为
默认不可空
的类型。如果您的变量是
String
类型,它必须包含
一个字符串
。这样一来,我们就修复了所有的空引用错误。
null
对我们来说没有什么意义的话,那大可不必再研究下去了。但实际上
null
十分有用,所以我们仍然需要合理地处理它。可选参数就是非常好的例子。让我们来看下这段空安全的代码:
// Using null safety:
makeCoffee(String coffee, [String? dairy]) {
if (dairy != null) {
print('$coffee with $dairy');
} else {
print('Black $coffee');
}
}
dairy
参数能传入任意字符串,或者一个
null
值。为了表达我们的想法,我们在原有类型
String
的尾部加上
?
使得
dairy
成为可空的类型。本质上,这和定义了一个原有类型加
Null
的组合类型没有什么区别。所以如果 Dart 包含完整的组合类型定义,那么
String?
就是
String|Null
的缩写。
使用可空类型
如果您的表达式可能返回空值,您会如何处理它呢?由于安全是我们的原则之一,答案其实所剩无几。因为您在其值为 null
的时候调用方法将会失败,所以我们不会允许您这样做。
// Hypothetical unsound null safety:
bad(String? maybeString) {
print(maybeString.length);
}
main() {
bad(null);
}
Null
类下同时定义的方法和属性。所以只有
toString()
、
==
和
hashCode
可以访问。因此,您可以将可空类型用于 Map 的键值、存储于集合中或者与其他值进行比较,仅此而已。
String?
,那么向其传递
String
是允许的,不会有任何问题。在此次改动中,我们将所有的可空类型作为基础类型的子类。您也可以将
null
传递给一个可空的类型,即
Null
也是任何可空类型的子类:
String
的变量可能会在您传递的值上调用
String
的方法。如果您传递了
String?
,传入的
null
将可能产生错误:
// Hypothetical unsound null safety:
requireStringNotNull(String definitelyString) {
print(definitelyString.length);
}
main() {
String? maybeString = null; // Or not!
requireStringNotNull(maybeString);
}
Object
的实例传递给了需要
String
的函数,类型检查器会允许您这样做:
// Without null safety:
requireStringNotObject(String definitelyString) {
print(definitelyString.length);
}
main() {
Object maybeString = 'it is';
requireStringNotObject(maybeString);
}
requireStringNotObject()
的参数静默添加了
as String
强制转换。在运行时进行转换可能会抛出异常,但在编译时,Dart 允许这样的操作。在可空类型已经变为非空类型的子类的前提下,隐式转换允许您给需要
String
的内容传递
String?
。这项来自隐式转换的允诺与我们的安全性目标不符。所以在空安全推出之际,我们完全移除了隐式转换。
requireStringNotNull()
的调用产生您预料中的编译错误。同时也意味着,类似
requireStringNotObject()
这样的
所有
隐式转换调用都变成了编译错误。您需要自己添加显式类型转换:
// Using null safety:
requireStringNotObject(String definitelyString) {
print(definitelyString.length);
}
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
,底层类型是
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.
}
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
。这的确非常方便,但在变量可空的情况下,明显非常不安全。所以,我们需要加强对非空变量的处理:
// Using null safety:
int topLevel = 0;
class SomeClass {
static int staticField = 0;
}
// Using null safety:
class SomeClass {
int atDeclaration = 0;
int initializingFormal;
int initializationList;
SomeClass(this.initializingFormal)
: initializationList = 0;
}
/ Using null safety:
int tracingFibonacci(int n) {
int result;
if (n < 2) {
result = n;
} else {
result = tracingFibonacci(n - 2) + tracingFibonacci(n - 1);
}
print(result);
return result;
}
可选参数必须具有默认值。如果一个可选位置参数或可选命名参数没有传递内容,Dart 会自动使用默认值进行填充。在未指定默认值的情况下,默认值为 null
,如此一来,非空类型的参数就要出事了。
所以,如果您需要一个可选参数,要么它是可空的,要么它的默认值不为 null
。
final
有关的限制非常相似,您可能没有特别关注过,但它们伴随您已久。另外,请记住,这些限制仅适用于
非空
变量。在您使用可空的类型时,
null
仍然可以作为初始化的默认值。
流程分析
// 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
。该类型已在核心库中定义,您可以将它用于变量声明。也许您会写一个辅助函数,用于简单方便地抛出一个固定的异常:
// Using null safety:
Never wrongType(String type, Object value) {
throw ArgumentError('Expected $type, but was ${value.runtimeType}.');
}
// Using null safety:
class Point {
final double x, y;
bool operator ==(Object other) {
if (other is! Point) wrongType('Point', other);
return x == other.x && y == other.y;
}
// Constructor and hashCode...
}
==
方法的最后一行,在
other
上调用
.x
和
.y
。尽管在第一行并没有包含
return
或
throw
,它的类型仍然提升为了
Point
。控制流程分析意识到
wrongType()
声明的类型是
Never
,代表着
if
语句的 then 分支
一定会
由于某些原因被中断。由于下一句的代码仅能在
other
是
Point
时运行,所以 Dart 提升了它的类型。
Never
让您可以扩展 Dart 的可达性分析。
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
变量而言,约束得以满足。
空检查的类型提升
null
的情况下,这项限制是很有效的,它可以避免您的程序崩溃。null
,最好是直接将它移到非空的一侧,如此一来您就可以调用它的方法了。流程分析是对变量和局部变量进行处理的主要方法之一。我们在分析
== null
和
!= null
表达式时也进行了类型提升的扩展。
null
,进行到下一步后 Dart 就会将这个变量的类型提升至非空的对应类型:
// Using null safety:
String makeCommand(String executable, [List<String>? arguments]) {
var result = executable;
if (arguments != null) {
result += ' ' + arguments.join(' ');
}
}
arguments
是可空的类型。通常来说,对其调用
.join()
是禁止的。但是,由于
if
语句中的判断已经足以确认值不为
null
,Dart 将它的类型从
List<String>?
提升到了
List<String>
,从而让您能够调用它的方法,或将它传递给一个需要非空列表的函数。
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
或赋值,以及我们马上就要提到的后置操作符
!
也会进行类型提升。总体来说的目标是: 如果代码是动态正确的,而静态分析时又是合理的,那么分析结果也足够聪明,会对其进行类型提升。
无用代码的警告
null
去向的可达性分析,能确保您已经
增加
了对
null
的处理。不过我们也可以用同样的分析来检测您是否有
不用
的代码。在空安全以前,如果您编写了如下的代码:
// Using null safety:
String checkList(List list) {
if (list?.isEmpty) {
return 'Got nothing';
}
return 'Got something';
}
?.
是否有用。它只知道您可以将
null
传递进方法内。但是在有空安全的 Dart 里,如果您将函数声明为现有的非空
List
类型,它就知道
list
永远不会为空。实际上就暗示了
?.
是不必要的,您完全可以直接使用
.
。
== null
或
!= null
判断,都会出现一个警告。
null
检查时看到一个警告:
// Using null safety:
checkList(List? list) {
if (list == null) return 'No list';
if (list?.isEmpty) {
return 'Empty list';
}
return 'Got something';
}
list
不能为
null
,所以您会在
?.
的调用处看到一个警告。这些警告不仅仅是为了减少无意义的代码,通过移除
不必要
的
null
判断,我们得以确保其他有意义的判断能够脱颖而出。我们期望您能
看到
您代码中的
null
会向何处传递。
与可空类型共舞
null
归到了可空类型的集合中。有了流程分析,我们可以让一些非
null
值安全地越过栅栏,到达非空的那一侧,供我们使用。这是相当大的一步,但如果我们就此止步不前,产出的系统仍然饱含痛苦的限制,而流程分析也仅对局部变量和参数起作用。
更智能的空判断方法
?.
相对空安全而言俨然是一位老生。根据运行时的语义化规定,如果接收者是 null
,那么右侧的属性访问就会被跳过,表达式将被作为 null
看待。// Without null safety:
String notAString = null;
print(notAString?.length);
这段代码将打印 "null",而不是抛出一个异常。避空运算符是一个不错的工具,让可空类型在 Dart 中变得可用。尽管我们不能让您在可空类型上调用方法,但我们可以让您使用避空运算符调用它们。空安全版本的程序是这样的:
// Using null safety:
String? notAString = null;
print(notAString?.length);
// 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
是否会返回
null
?看上去它
会
返回
null
,因为您在调用后使用了
?.
。但也有可能第二个
?.
仅仅是为了处理
thing
为
null
的情况,而不是
doohickey
的结果。您无法直接得出结论。
null
,那么
整个链式调用的剩余部分都会被截断并跳过
。这意味着如果
doohickey
的返回值是一个可空的类型,您应该这样写:
// Using null safety:
showGizmo(Thing? thing) {
print(thing?.doohickey.gizmo);
}
?.
,您会看到一个警告,提示这段代码是不必要的。所以如果您看到了这样的代码:
// Using null safety:
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;
HttpResponse.notFound()
: code = 404,
error = 'Not found';
String toString() {
if (code == 200) return 'OK';
return 'ERROR $code ${error.toUpperCase()}';
}
}
toUpperCase()
调用的编译错误。
error
属性是可空的,在返回结果成功时,它不会有值。我们通过仔细观察类可以看出,当消息为空时,我们永远不会访问
error
。但为了知晓这个行为,必须要理解
code
的值与
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>>>
这样的代码,会让这个过程变得非常烦人。
!
将会失去部分静态的安全性。这些转换必须在运行时进行,从而确保代码健全,并且有可能失败并抛出异常。但您可以完全控制这些转换的使用位置,并且能从代码中直接看到它们。
懒加载的变量
// Using null safety, incorrectly:
class Coffee {
String _temperature;
void heat() { _temperature = 'hot'; }
void chill() { _temperature = 'iced'; }
String serve() => _temperature + ' coffee';
}
main() {
var coffee = Coffee();
coffee.heat();
coffee.serve();
}
heat()
方法在
serve()
之前就被调用了。这意味着
_temperature
会在它被使用前初始化为一个非空的值。但对于静态分析而言,这样是不可行的。(实际上在与例子类似的情况下,代码可能是可行的,但是在一般情况下,我们难以跟踪每一个实例的状态。)
// 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
修饰符比
?
更为神奇,因为对这个字段的任何调用都有可能失败,且在失败的事故现场不会有任何的文字说明。
null
或可空的
String
就会出错。虽然
late
修饰符让您延迟了初始化,但它仍然禁止您将变量作为可空的类型进行处理。
延迟初始化
late
修饰符也有一些特殊的能力。虽然听起来有一些自相矛盾,但是您可以在一个包含初始化内容的字段上使用
late
:
// Using null safety:
class Weather {
late int _temperature = _readThermometer();
}
late
时,延迟初始化会给您带来更多的便利。通常实例字段的初始化内容无法访问到
this
,因为在所有的初始化方法完成前,您无法访问到新的实例对象。但是,使用了
late
让这个条件不再为真,所以您
可以
访问到
this
、调用方法以及访问实例的字段。
延迟的终值
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
,类型检查器给所有的可选参数提出了要求,要么是一个可空的类型,要么包含一个默认值。如果您需要一个可空的命名参数,同时又不包含默认值,该怎么办呢?这就意味着您要求调用者
每次
都为其传递内容。换句话说,您需要的是一个非可选的
命名
参数。必需的 可选的
+------------+------------+
位置参数 | f(int x) | f([int x]) |
+------------+------------+
命名参数 | ??? | f({int x}) |
+------------+------------+
required
放在参数前,就可以声明一个必需的命名参数:
// Using null safety:
function({int? a, required int? b, int? c, required int? d}) {}
a
和
c
是可选的,可以省略。参数
b
和
d
是必需的,调用时必须传递。在这里请注意,是否必需和是否可空无关。您可以写出可空类型的必需命名参数,以及非空类型的可选命名参数 (如果它们包含了默认值)。
与可空字段共舞
null
的工作不再那么痛苦。即便如此,根据我们的经验之谈,处理可空的字段仍然是较为困难的。在您能使用
late
和非空类型的情况下,已经相当稳妥。但在很多场景里,您仍然需要
检查
字段是否有值,这些情况下,字段会是可空的,您也能观测到
null
的存在。以下这段代码,您可能会认为可以这么写:
// 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
。如果不为空,我们会访问并对它调用
+
。很遗憾,这样做是不被允许的。基于流程分析的类型提升并不适用于字段,因为静态分析不能
证明
这个字段的值在您判断后和使用前没有发生变化。(某些极端场景中,字段本身可能会被子类的 getter 重写,从而在第二次调用时返回
null
。)
!
。它看起来是多余的,但是目前的 Dart 需要这样的操作。
// Using null safety:
void checkTemp() {
var temperature = _temperature;
if (temperature != null) {
print('Ready to serve ' + temperature + '!');
}
}
可空性和泛型
// Using null safety:
class Box<T> {
final T object;
Box(this.object);
}
main() {
Box<String>('a string');
Box<int?>(null);
}
Box
的定义中,
T
是可空还是非空的类型?正如您所看到的那样,它可以通过任意一种类型来进行实例化。答案是:
T
是一个
潜在的可空类型
。在泛型类或泛型方法的主体中,一个潜在的可空类型包含了可空类型
以及
非空类型的所有限制。
// Using null safety:
class Box<T> {
T? object;
Box.empty();
Box.full(this.object);
}
object
声明的
?
。现在这个字段是一个显式的可空类型,所以它可以是未被初始化的。
as T
进行转换,
而不是
使用
!
操作符。
// Using null safety:
class Box<T> {
final T? object;
Box.empty();
Box.full(this.object);
T unbox() => object as T;
}
null
,使用
!
操作符
一定
会抛出异常。但是如果类型参数已被声明为一个可空的类型,那么
null
对于
T
就是一个完全有效的值:
// Using null safety:
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;
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
。您可以将未初始化的变量声明为类型参数的类型。
null
,所以在我们调用
<=
前,流程分析将它们提升成了非空类型。
extends
语句,类型参数的默认类型约束是可空的
Object?
。) 您没有办法声明一个
必需的
可空类型参数。如果您希望确保类型参数一定是可空的,您可以在类的主体中使用
T?
。
核心库的改动
on
的
catch
现在返回的默认类型是
Object
而不是
dynamic
。同时,switch 语句中的条件贯穿分析也使用了新的流程分析。
null
,从而自然地使用了非空的类型,要么接受了
null
,并且优雅地处理了可空类型。
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 查找一个已知存在的元素的方法是
[]!
。相信您会慢慢习惯的。
去除 List 的非命名构造
List
的非命名构造函数会创建一个给定大小的列表,但是并没有为任何元素进行初始化。如果您创建了一个非空类型的列表,接着访问了其中一个元素,这将会是巨大的漏洞。
List()
都会抛出错误。听起来有点吓人,但在实际开发中,大部分的代码都通过 字面量、
List.filled()
、
List.generate()
或是通过其他集合转换来创建列表。为了一些极端情况,比如您需要创建某个类型的一个空的列表,我们新增了
List.empty()
构造。
不能对非空的列表设置更大的长度
List
的
length
getter 也有一个对应的 setter,这一点鲜为人知。您可以对列表设置一个较短的长度,从而截断它。您也可以对列表设置一个
更长的
长度,从而使用未初始化的元素填充它。
length
setter,
并且
准备设置一个
更长的
长度时,会在运行时抛出一个异常。您仍然可以对任何类型的列表进行截断,也可以对一个可空类型的列表进行填充。
ListBase
或者混入了
ListMixin
,那么这项改动可能会造成较大的影响。以上的两种类型都提供了
insert()
的实现,通过设置长度,为插入的元素提供空间。在空安全中这样做可能会出现错误,所以我们将它们的
insert()
实现改为了
add()
。现在您自定义的列表应该继承
add()
方法。
在迭代前后不能访问 Iterator.current
Iterable
是一个可变的 "游标" 类,用于遍历
Iterable
类型的元素。在访问任何元素之前,您都需要调用
moveNext()
来跳到第一个元素。当方法返回了
false
时,意味着您到达了终点,已经没有更多元素了。
moveNext()
前,或者在迭代结束后,调用
current
会返回
null
。有了空安全,就要求
current
的返回类型是
E?
而不是
E
。这样的返回类型意味着在运行时,所有元素的访问前都需要检查是否为
null
。
current
的返回类型确定为
E
。由于迭代前后
有可能
会有一个对应类型的值出现, 当您在不应该调用它的时候调用迭代器时,我们让迭代器的行为保持为未定义。对于
Iterator
的大部分实现都将抛出
StateError
异常。
总结
您需要掌握的核心要点有:
类型默认是非空的,可以添加 ?
变为可空的。
可选参数必须是可空的或者包含默认值的。您可以使用 required
来构建一个非可选命名参数。非空的全局变量和静态字段必须在声明时被初始化。实例的非空字段必须在构造体开始执行前被初始化。
如果接收者为 null
,那么在其避空运算符之后的链式方法调用都会被截断。我们引入了新的空判断级联操作符 (?..
) 及索引操作符 (?[]
)。后缀空断言 "重点" 操作符 (!
) 可以将可空的操作对象转换为对应的非空类型。
新的流程分析,让您更安全地将可空的局部变量和参数,转变为可用的非空类型。它同时还对类型提升、遗漏的返回、不可达的代码以及变量的初始化,有着更为智能的规则。
late
修饰符以在运行时每次都进行检查的高昂代价,让您在一些原本无法使用的地方,能够使用非空类型和 final
。它同时提供了对字段延迟初始化的支持。
List
类现在不再允许包含未初始化的元素。
致谢
本文是 Google Dart 团队成员 Bob Nystrom 撰写发布在 Dart 文档的一篇文章,由社区成员 Alex 发起并完成了文章翻译,本文的成功发布离不开下列成员的辛苦工作:
推荐阅读