Dart的空安全

什么是空安全?

什么是空安全呢? 我想大家在跑程序时经常出现“xxx method is called on null”这么个错误(如果是Flutter还伴随着恐怖的红屏), 这就是不该出现null值的变量(或者实例)在运行时出现了null值造成的. 为了避免这种情况出现, 空安全(Null Safety)就出现了. 简单来说, 空安全就是通过词法分析、语法分析、控制流分析等静态分析方法, 将空值报错从运行时错误提前到编译时错误, 从而保证程序等稳定性(显然程序能跑起来的时候一定是解决了编译时错误的, 那么就能保证不会出现空值报错).

值得说明的是, 空安全的目的是为了避免运行时出现空值引发的错误, 并不是否定空值本身. 事实上, 空值(null)是非常有用的一个常量, 很多地方都会需要它(说的就是你Windows). 即使在引入了空安全, 我们也可以继续去使用空值.

空安全语法

可空类型和类型提升

引入空安全后, Dart的类型系统发生了很大的变化, 但是从我们使用者的视角来看, 能够感知到的就是Dart的类型分成了两大部分——可空类型和非空类型. 顾名思义, 二者的区别就在于, 可空类型可以为空, 非空类型则不可以. 那么在代码上的具体表现呢? 其实非常简单:

1
2
3
4
void main() {
int? nullableInt;
int mustBeInt;
}

带问号的是可空类型, 不带问号的是非空类型. 显然, 可空和非空类型是两个独立的类, 所以下面的代码是不对的:

1
2
3
4
5
6
7
8
9
void main() {
int? nullableInt;
int mustBeInt;
List<int?> listThatCouldHoldNulls = [2, 3, 4];


nullableInt = listThatCouldHoldNulls.first;
mustBeInt = listThatCouldHoldNulls.first;
}

在这段代码中, nullableInt的赋值语句是没有问题的, 因为列表存储的类型是int?; 但是mustBeInt的赋值语句会报错, 显然, 它有可能被赋值为空值.

但是, 很奇妙的是, 下面这段代码又是可以运行的:

1
2
3
4
5
6
7
8
9
void main() {
int? nullableInt = 1;
int mustBeInt;
List<int?> listThatCouldHoldNulls = [2, 3, 4];


mustBeInt = nullableInt;

}

这是因为Dart编译器有着强大的控制流分析功能, 在分析mustBeInt的赋值语句时, 编译器发现nullableInt目前已经是一个非空的常量了, 所以直接把它赋值给mustBeInt不会出现问题. 如果改成下面这样, 那么编译器就又会报错了:

1
2
3
4
5
6
7
8
9
10
11
12
void main() {
int? nullableInt = 1;
int mustBeInt;
List<int?> listThatCouldHoldNulls = [2, 3, 4];


mustBeInt = nullableInt;

nullableInt = null;

mustBeInt = nullableInt;
}

这段代码报错的地方时mustBeInt的第二个赋值语句. 编译器在分析第二个赋值语句时发现, nullableInt的值又成为空值了, 所以编译器就会报错了.

那么, 如果我想把列表里的值赋给mustBeInt, 要怎么做呢? 下面是正确的代码:

1
2
3
4
5
6
7
8
9
void main() {
int? nullableInt = 1;
int mustBeInt;
List<int?> listThatCouldHoldNulls = [2, 3, 4];


mustBeInt = listThatCouldHoldNulls.first!;

}

可以看到, 区别在于在列表值的后面加了一个!. 这个感叹号的作用是对可空类型的值做非空断言, 编译器遇到这个符号, 会认为这个可空类型的值一定非空, 就不会报错了.

当然, 还有另外的实现方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void main() {
int? nullableInt = 1;
int mustBeInt;
List<int?> listThatCouldHoldNulls = [2, 3, 4];

int? tmpNullableInt = listThatCouldHoldNulls.first;

if (tmpNullableInt == null) {
mustBeInt = 0;
}
else {
mustBeInt = tmpNullableInt;
}

}

在这段代码中, 我们先将要列表中指定的值复制给临时变量, 然后再将这个临时变量赋值给mustBeInt. 我们看到, 这个临时变量是一个可空类型, 它被赋予的值不是一个非空的常量, 我们也没有对它加断言, 但是它仍然可以被赋值给mustBeInt. 这是因为在此之前我们已经处理了它为空值的情况. Dart的编辑器会对变量的所有可达路径做分析, 一旦为空的路径有了处理代码, 别的路径就不存在空安全问题了. 在此时, 别的路径的可空类型会隐式转换为非空类型(在这段代码中, tmpNullableInt在else分支里从int?转换成了int), 这叫做类型提升.

延迟加载

有时候, 一些全局变量或者类的成员变量不会出现空值, 但是它们在最开始没有初始值, 比如下面这段代码:

1
2
3
4
5
6
7
8
9
10
11
int anotherMustBeInt;

void main() {
int? nullableInt = 1;
int mustBeInt;
List<int?> listThatCouldHoldNulls = [2, 3, 4];

anotherMustBeInt = 3;

print('$anotherMustBeInt');
}

这段代码中, 全局变量anotherMustBeInt在声明时并没有赋值. 虽然随后这个变量被赋了3, 但是如果没有被赋值直接打印的话, 显然会导致空值错误. 因此, 察觉到这个风险的编译器会报错说”非空变量’anotherMustBeInt’必须要初始化”. 那如果业务逻辑要求这个变量在声明时不初始化, 我们应该怎么写呢? 很简单:

1
2
3
4
5
6
7
8
9
10
11
late int anotherMustBeInt;

void main() {
int? nullableInt = 1;
int mustBeInt;
List<int?> listThatCouldHoldNulls = [2, 3, 4];

anotherMustBeInt = 3;

print('$anotherMustBeInt');
}

与之前相比, 这段代码的改动只是在全局变量anotherMustBeInt的声明前加上了修饰语late. late告诉编译器, 这个变量将在稍后被初始化, 这样编译器就不会报错了.

实际上, late的原理更接近于让编译器把对它所修饰的变量的初始化工作放到这个变量第一次被使用的时候, 也就是我们常说的“懒加载”. 所以其实late还有更多有意思的用法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
int _computeValue() {
print('In _computeValue...');
return 3;
}

class CachedValueProvider {
final _cache = _computeValue();
int get value => _cache;
}

void main() {
print('Calling constructor...');
var provider = CachedValueProvider();
print('Getting value...');
print('The value is ${provider.value}!');
}

我们运行上面这段代码, 会得到下面这样的输出:

Calling constructor…

In _computeValue…

Getting value…

The value is 3!

而当我们把_cache这个字段用late修饰, 像这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
int _computeValue() {
print('In _computeValue...');
return 3;
}

class CachedValueProvider {
late final _cache = _computeValue();
int get value => _cache;
}

void main() {
print('Calling constructor...');
var provider = CachedValueProvider();
print('Getting value...');
print('The value is ${provider.value}!');
}

那么程序的输出就会变成这样:

Calling constructor…

Getting value…

In _computeValue…

The value is 3!

至于为什么有这样的变化, 请大家仔细品味late的“延迟初始化”效果, 思考一下.

泛型容器的改动

在引入了空安全后, 像Map、List这样的泛型容器的已有实现会出现问题, 所以相关部分也有些小改动.

首先是Map. 我们知道, Map[key]当key不存在时会返回null, 这个特性暗示了Map的[]操作符返回类型必须是可空的, 即使我们在声明时明确了键值类型非空:

1
2
3
4
5
void main() {
Map<String, int> map = {'key': 1};

print(map['key'].abs());
}

编译器会对这段代码报错, 理由是map[‘key’]是可空类型, 而函数abs()只接受int. 所以, 在引入空安全后, Map的[]操作符通常都需要跟断言符!:

1
2
3
4
5
void main() {
Map<String, int> map = {'key': 1};

print(map['key']!.abs());
}

然后是List. 在空安全版本, List的非命名构造函数List()被移除了. 因为List的非命名构造函数会创建一个给定大小的列表, 但是并没有为任何元素进行初始化. 如果我们使用这个函数创建了一个存储非空类型的列表, 然后再访问列表, 那么就会出现矛盾——存储非空类型的列表有可空的元素, 这显然是不合理的. 基于同样的理由, List类原来可以通过修改length来实现扩展或者截断列表, 这个功能在空安全版本里也被移除了.

最后是迭代器Iterator. 在原来, 迭代器在首次访问前或者遍历结束后, current都会返回null. 显然, 在空安全版本, 这个功能就不合适了. 如果将current的返回类型从E改成E?, 那虽然可以兼容这个功能, 但是每次还需要对current判空——相比于这个功能的使用次数, 这么改有些本末倒置了. 所以, 谷歌简单粗暴地禁止了在首次访问前或者遍历结束后访问current, 如果这么做, 程序会抛出StateError异常.