前言
在 Flutter 2.0 中,一项重要的升级就是 Dart 支持 空安全,空安全究竟是什么?日常开发中我们该怎么使用?下面我们通过几个简单的代码来介绍 Flutter 空安全。
Dart 空安全是什么?
Dart 在语法上支持空安全检查。在空安全的代码编译期下,运行时的NullPointerException
错误提示被提前到了开发阶段。
如下案例:
void main() {
String mName;
print(mName.length);
}
非空安全下,这段代码在 在编译阶段不会有任何提示。如果我们允许这样的代码运行,那么它将毫无疑问地崩溃。因为系统只允许你访问在原有类型和 Null
类下同时定义的方法和属性,比如 toString()
、==
和 hashCode
可以访问。
原因:变量
mName
在没有赋值时值是 null 类型。当调用null类型下的.length
方法时便导致程序异常,因为null类型下根本不存在.length方法。
空安全下,在编译期便给出了报错提示,开发者可以及时进行修复。
可查看官方:健全的空安全 | Dart
非空安全
在空安全推出之前,静态类型系统允许所有类型的表达式中的每一处都可以有 null
。
从类型理论的角度来说,Null
类型被看作是所有类型的子类;
在非空安全下,你可以对 List
类型调用 .add()
或 []
。如果是 int
类型,你可以对其调用 +
。但是 null
值并没有它们定义的任何一个方法。所以当 null
被传递至其他类型的表达式时,任何操作都会失败。这就是空引用的症结所在——所有错误都来源于尝试在 null
上查找一个不存在的方法或属性。
空安全
空安全通过修改了类型的层级结构,从根源上解决了这个问题。 Null
类型仍然存在,但它不再是所有类型的子类。现在的类型层级看起来是这样的:
既然 Null
已不再被看作所有类型的子类,那么除了特殊的 Null
类型允许传递 null
值,其他类型均不允许。
我们已经将所有的类型设置为 默认不可空 的类型。如果你的变量是 String
类型,它必须包含 一个字符串。这样一来,我们就修复了所有的空引用错误。
使用可空类型
如果 null
对我们来说没有什么意义的话,那大可不必再研究下去了。但实际上 null
十分有用,所以我们仍然需要合理地处理它。可选参数就是非常好的例子。让我们来看下这段空安全的代码:
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
的缩写。
如果一个函数接受 String?
,那么向其传递 String
是允许的,不会有任何问题。在此次改动中,我们将所有的可空类型作为基础类型的超类。你也可以将 null
传递给一个可空的类型,即 Null
也是任何可空类型的子类:
隐示\显示转换
但将一个可空类型传递给非空的基础类型,是不安全的。比如,你声明了一个 String
的非空类型变量,然后用这个变量接收会在你传递的值,很可能会去调用这个值的 String
类型下的方法|属性。
但是,如果你传递了 String?,
传入的 null
就将可能产生错误:
requireStringNotNull(String definitelyString) {
print(definitelyString.length);
}
main() {
String? maybeString = null; // Or not!
requireStringNotNull(maybeString);
}
这种不安全的程序是不会允许出现的。
然而,隐式转换 在 Dart 中一直存在。假设你将类型为 Object
的实例传递给了需要 String
的函数,编译器为 requireStringNotObject()
的参数静默添加了 as String
强制转换。
main() {
Object maybeString = 'it is';
requireStringNotObject(maybeString);
}
虽然,在编译时,Dart是允许这样的操作,检查器也不会报红提示。但是,在运行时进行转换很可能会抛出异常,隐式转换允许你给需要非空的 String
内容传递一个可空 String?。
所以,自动隐式转换与我们的安全性目标不符。在空安全时代,完全移除了隐式转换。
为了保持健全性,你需要自己添加显式类型转换:
requireStringNotObject(String definitelyString) {
print(definitelyString.length);
}
main() {
Object maybeString = 'it is';
requireStringNotObject(maybeString as String);//显示转换
}
顶层及底层
如果在一张有向图的顶部有是一个单一的超类(直接或间接),那么这个类型称为 顶层类型。类似的,如果有一个在底部有一个类型,是所有类型的子类,那么这个类型就被称为 底层类型。
如果类型系统中有顶层和底层类型,将给我们带来一定程度的便利。在空安全引入以前,Dart 中的顶层类型是 Object
,底层类型是 Null
。由于空安全时代, Object
不再可空,所以它不再是一个顶层类型了,Null
也不再是它的子类。
Dart 中没有 令人熟知的 顶层类型。如果你需要一个顶层类型,可以用 Object?
。同样的,Null
也不再是底层类型,否则所有类型都仍将是可空。取而代之是一个全新的底层类型 Never
:
依据实际开发中的经验,这意味着:
-
如果你想表明让一个值可以接受任意类型,请用
Object?
而不是Object
。使用Object
后会使得代码的行为变得非常诡异,因为它意味着能够是“除了null
以外的任何实例”。 -
在极少数需要底层类型的情况下,请使用
Never
代替Null
。如果你不了解是否需要一个底层类型,那么你基本上不会需要它。
空值断言操作符
利用流程分析,将可空的变量转移到非空的一侧,是安全可靠的。你可以在先前可空的变量上调用断言方法,同时还能享受到非空类型的安全和性能优势。
但为确保这个行为安全性,开发者必须要理解变量与值的可空性之间的联系,因为类型检查器看不出这种联系。换句话说,作为代码的人类维护者,我们知道在使用 一个变量 时,它的值一定不会是 null
,这时我们就可以对其进行断言。
通常你可以通过使用 as
转换来断言类型,这里你也可以这样做:
final String? error;
String toString() {
if (code == 200) return 'OK';
return 'ERROR $code ${(error as String).toUpperCase()}';
}
“排除可空性的转换”的场景频繁出现,这促使了我们带来了新的短小精悍的语法。一个作为后缀的感叹号标记 (!
) 会让左侧的表达式转换成其对应的非空类型。所以上面的函数等效于:
String toString() {
if (code == 200) return 'OK';
return 'ERROR $code ${error!.toUpperCase()}';
}
关键字: required
我们在开发中,调用方法传参数时会遇到一个问题,即:可选参数是可以不传的。
如果参数是可空的,那么还好。如果遇到不可空的就麻烦了:
Demon invoke(Target target, {String way, String? material}) {
//...
}
当遇到上面的情况时,而我们又确实需要使用这个变量,使用required关键字修改后:
Demon invoke(Target target, {required String way, String? material}) {
...
}
//调用时,如果不传, 或者传入null,编辑器将会报错
invoke(a); //错误
invoke(a, way: null); // 错误
延迟初始化 late
class Weather {
late int _temperature = _readThermometer();
}
当你这么声明时,会让变量的初始化赋值 延迟 到字段首次被访问时。
换句话说,它让字段的初始化方式变得与顶层变量和静态字段完全一致。一般使用场景,是在当初始化表达式比较消耗性能,并且有可能不需要时,这会变得非常有用。
比如,通常实例字段的初始化内容无法访问到 this
,因为在所有的初始化方法完成前,你无法访问到新的实例对象。但是,使用了 late
让这个条件不再为真,所以你 可以 访问到 this
、调用方法以及访问实例的字段。
late
修饰符的强制约束
处理类似延迟初始化的常见行为,late可以告诉编辑器:这个非空变量,我稍后会初始化。
class Coffee {
late String _temperature;
void heat() { _temperature = 'hot'; }
void chill() { _temperature = 'iced'; }
//使用
String serve() => _temperature + ' coffee';
}
此处我们注意到,_temperature
字段是一个非空的类型,但是并没有进行初始化。同时,在使用时也没有明确的空断言。当前场景里,字段并不一定已经被初始化,每次它被读取时,都会插入一个运行时的检查,以确保它已经被赋值。如果并未赋值,就会抛出一个异常。
late
修饰符是“在运行时而非编译时对变量进行约束”。这就让 late
这个词语约等于 何时 执行对变量的强制约束。给一个变量 + String
类型就是在说:“我的值绝对是字符串”。而加上 late
修饰符意味着:“每次运行都要检查是不是真的”。
如果在 编译时 为它赋值为 null
或 String?
就会出错。虽然 late
修饰符让你延迟了初始化,但它仍然禁止你将变量作为可空的类型进行处理。
late延迟的终值
你也可以将 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
的大量特性。如果你需要给局部变量加上一些延迟初始化,你也可以在局部变量上使用它。
参考资料: