空安全:常见问题
在迁移代码时,我应该注意哪些运行时的改动?
如果在测试中的值始终为 null 应该怎样处理?
新的 required 和 @required 关键词有何异同?
我应该如何迁移应该为 final 而目前并不是的字段?
我应该如何迁移 built_value 类?
我应该如何迁移可能返回 null 的工厂方法?
我应该如何迁移现在提示无用的 assert(x != null)?
我应该如何迁移现在提示不必要的运行时空判断?
Iterable.firstWhere 方法不再接受 orElse: () => null。
我应该如何处理有 setters 的属性?
我需要怎样标记映射的返回值为非空类型?
为什么我的 List/Map 中的泛型是可空的?
默认的 List 构造有什么改动?
我在迁移使用了 package:ffi 的代码的时候遇到了 Dart_CObject_kUnsupported 的错误。发生了什么?
为什么迁移工具在我的代码中添加了注释
关于编译为 JavaScript 时的空安全我应该知道什么?
资源
此处涵盖了一些我们在迁移 Google 内部代码至 空安全 时遇到的常见问题。
在迁移代码时,我应该注意哪些运行时的改动?
在空安全迁移中的大部分影响,不会立刻出现在刚刚迁移完成的开发者身上:
-
静态的空安全检查,会在开发者完成迁移后立刻生效。
-
完整的空安全检查,只会在所有代码都已迁移,并且启用了完整的空安全模式时生效。
但是有两项例外你需要注意:
-
对于任何模式而言,
!
操作符都是在运行时进行的空检查。所以在进行迁移时,请确保你仅对null
可能由混合模式造成污染的代码位置添加!
,就算发起调用的代码还未迁移至空安全,也应如此。 -
late
会在运行时检查。所以请你仅在确定它被使用前一定会被初始化的情况下使用late
。
null
应该怎样处理?
如果在测试中的值始终为 如果在测试中某个值始终为 null
,可以通过将测试传值和测试需要的值改为非空,来改进你的测试。
required
和 @required
关键词有何异同?
新的 @required
注解会将参数标记为必须传递。如果未传,分析器会给出一个提示。
有了空安全,非空类型的命名参数要么需要默认值,要么需要使用 required
关键字修饰。否则,它在未传递时默认会是 null
,就显得不合理了。
在旧的代码中,required
关键词会被看作 @required
注解,即参数未传递时会显示一个分析器提示。
当你在空安全的代码中使用空安全代码时,如果 required
修饰的参数未传递,会显示一个错误。
那么它对于迁移来说意味着什么呢?当你给以前没有使用 @required
注解的参数加上 required
时要十分小心。任何没有传递新需要的参数的代码,都无法进行编译。实际上,你可以加上默认值,或是将参数变为可空类型。
final
而目前并不是的字段?
我应该如何迁移应该为 一些赋值计算可以移动到静态的初始化中。与其使用下面的方式:
// Initialized without values
ListQueue _context;
Float32List _buffer;
dynamic _readObject;
Vec2D(Map<String, dynamic> object) {
_buffer = Float32List.fromList([0.0, 0.0]);
_readObject = object['container'];
_context = ListQueue<dynamic>();
}
你可以这样做:
// Initialized with values
final ListQueue _context = ListQueue<dynamic>();
final Float32List _buffer = Float32List.fromList([0.0, 0.0]);
final dynamic _readObject;
Vec2D(Map<String, dynamic> object) : _readObject = object['container'];
然而,在构造函数中通过计算进行初始化的字段,是无法为 final
的。在使用空安全的时候,你会发现想让它们的类型为非空,也不是一件容易的事。如果初始化的时机不合适,那么直到初始化前,它都只能是可空的类型。幸运的是,你还有其他选择:
-
将构造转变为工厂方法,并将其委托给一个直接初始化所有字段的真正的构造函数。在 Dart 中,这样的私有构造通常是一个下划线:
_
。如此一来,字段就可以是final
且非空了。在空安全迁移介入 之前,你就可以这样进行调整。 -
或者,将字段标记为
late final
。这会使得字段只被初始化一次。在它被读取之前必须被初始化。
built_value
类?
我应该如何迁移 使用了 @nullable
注解的 getter 应当直接转变为可空的类型,然后移除所有的 @nullable
注解。例如:
@nullable
int get count;
变成
int? get count; // Variable initialized with ?
就算迁移工具建议,没有 使用 @nullable
注解的 getter 也不应该是可空的类型。这时可以根据需要添加 !
操作符,并且重新进行分析。
null
的工厂方法?
我应该如何迁移可能返回 优先使用不返回 null 的工厂方法。我们看到了很多代码,本意是想在调用不正确时抛出一个异常,但最终却返回了空。
与其这样写:
factory StreamReader(dynamic data) {
StreamReader reader;
if (data is ByteData) {
reader = BlockReader(data);
} else if (data is Map) {
reader = JSONBlockReader(data);
}
return reader;
}
不如这样:
factory StreamReader(dynamic data) {
if (data is ByteData) {
// Move the readIndex forward for the binary reader.
return BlockReader(data);
} else if (data is Map) {
return JSONBlockReader(data);
} else {
throw ArgumentError('Unexpected type for data');
}
}
如果一个工厂方法的初衷就是可能返回空值,将其转为可返回 null
的静态方法是更好的选择。
assert(x != null)
?
我应该如何迁移现在提示无用的 对于完全迁移的代码而言,这个断言是不必要的,但是如果你希望保留该检查,那么它 也需要 留下。几种方式可供你选择:
-
确定是否真的需要这个断言,然后将其删除。当断言启用时,这是一种行为上的变更。
-
确定断言始终会被检查,接着将其转换为
ArgumentError.checkNotNull
。当断言未启用时,这是一种行为上的变更。 -
通过添加
//ignore: unnecessary_null_comparison
来绕过警告并且保持原有的行为。
我应该如何迁移现在提示不必要的运行时空判断?
如果 arg
为非空时,编译器会在运行时将显式空安全判断标记为非必要。
if (arg == null) throw ArgumentError(...)`
混合模式下的程序必须包含这样的判断。在所有代码都迁移且运行在完全的空安全模式下前,
arg
仍然可能为 null
。
保留这项行为的最简单的方法是将判断改为
ArgumentError.checkNotNull
。
运行时的检查同样适用。如果 arg
指定了静态类型为 String
,那么 if (arg is! String)
实际上是在检查 arg
是否为 null
。尽管代码在迁移到空安全后,arg
应该是永远不为 null
的,但是在不完全的空安全中它仍有可能为 null
的。
Iterable.firstWhere
方法不再接受 orElse: () => null
。
导入 package:collection
package 并使用 firstWhereOrNull
扩展方法代替 firstWhere
。
我应该如何处理有 setters 的属性?
与上文说到的 late final
的建议不同的是,这些字段不能被标记为终值。通常,可被修改的属性也没有初始值,因为它们可能会在稍后才被赋值。
在这样的情况下,你有两种选择:
-
为其设置初始值。通常情况下,初始值未被设置是无意的错误,而不是有意为之。
-
如果你 确定 这个属性需要在访问之前被赋值,将它标记为
late
。注意:
late
关键词会在运行时添加检查。如果在set
之前调用了get
,会在运行时抛出异常。
我需要怎样标记映射的返回值为非空类型?
映射类型的
查询操作符
([]
) 返回的值默认是可空类型。此处没有办法告诉 Dart ,返回的值一定是非空的。
在这种情况下,你应该使用强制非空操作符 (!
) 将可空的类型转为非空 (V)。
return blockTypes[key]!;
如果 map 返回了 null,则会抛出异常。如果你希望手动处理这些情况:
var result = blockTypes[key];
if (result != null) return result;
// Handle the null case here, e.g. throw with explanation.
为什么我的 List/Map 中的泛型是可空的?
下面这样以可空内容结尾的代码是一种典型的代码异味:
List<Foo?> fooList; // fooList can contain null values
它隐含了 fooList
可能包含空值的信息。在你以长度初始化列表并循环填入值时,这种情况可能会出现。
如果你仅仅想要以相同的值初始化列表,你应该使用
filled
构造。
_jellyCounts = List<int?>(jellyMax + 1);
for (var i = 0; i <= jellyMax; i++) {
_jellyCounts[i] = 0; // List initialized with the same value
}
_jellyCounts = List<int>.filled(jellyMax + 1, 0); // List initialized with filled constructor
如果你需要通过索引来设置元素,或者使用不同的值填充每个元素,则应该使用列表的字面量表达式来构建列表。
_jellyPoints = List<Vec2D?>(jellyMax + 1);
for (var i = 0; i <= jellyMax; i++) {
_jellyPoints[i] = Vec2D(); // Each list element is a distinct Vec2D
}
_jellyPoints = [
for (var i = 0; i <= jellyMax; i++)
Vec2D() // Each list element is a distinct Vec2D
];
你可以使用 List.generate
构造加
growable
参数设置为 false
来生成固定长度的列表:
_jellyPoints = List.generate(jellyMax, (_) => Vec2D(), growable: false);
默认的 List 构造有什么改动?
你可能会遇到这样的错误:
The default 'List' constructor isn't available when null safety is enabled. #default_list_constructor
默认的列表构造会将列表用 null
填充,会造成问题。
将它变为 List.filled(length, default)
即可。
package:ffi
的代码的时候遇到了 Dart_CObject_kUnsupported
的错误。发生了什么?
我在迁移使用了 通过 ffi 发送的列表只能是 List<dynamic>
,而不能是 List<Object>
或 List<Object?>
。就算你未在迁移过程中手动更改类型,类型也可能会被改变,因为启用空安全后,类型推导推算出了这样的结果。
手动创建 List<dynamic>
类型的列表可以解决这个问题。
为什么迁移工具在我的代码中添加了注释
空安全模式下,在当某个表达式的结果一定为 false 或 true 的时候,迁移工具会自动添加 /* == false */
或者 /* == true */
这样的注释。这样的注释将会导致自动迁移出现错误,并且需要人工干预。例如:
if (registry.viewFactory(viewDescriptor.id) == null /* == false */)
在这些情况下,迁移工具无法区分防御性编码情况或是确实需要空值的情况。那么该工具会告诉你「这看起来永远为 false!」并让你进行决定。
关于编译为 JavaScript 时的空安全我应该知道什么?
空安全带来了代码体积减小及性能提升等优化。表面上 Flutter 编译为原生端的构建的优化会更加明显,例如 AOT。我们先前已经在 Web 的生产构建器上已经引入了一些类似空安全的优化。所以,Web 应用上的变化可能并不如原生端明显。
依然有几点值得注意:
-
生产环境下的 JavaScript 编译器会生成
!
空断言,在比较输出的时候你可能不会注意到它。这是因为编译器已支持了对空值进行检查。 -
无论是健全或非健全的安全,又或是不同的优化等级,编译器都会生成这些空断言,实际上在使用
-O3
或--omit-implicit-checks
时编译器都不会移除!
。 -
生产环境下的 JavaScript 编译器可能会移除没有必要的空检查,一般会发生在生产环境下的 Web 编译器在空安全之前做了一些优化,当它知道值不为空的时候,就删除了这些检查。
-
默认情况下,编译器会生成参数子类型的检查。(用于确保协变的虚拟调用使用了合适的参数的运行时检查)。与先前相同,使用
--omit-implicit-checks
编译器会省略它们。回想一下,如果类型无效,这个开关会让程序出现异常,因此我们依然建议代码测试覆盖率尽可能地高,以避免任何事故。特别是编译器会基于传入值符合类型声明这一条件来优化代码。如果代码提供了无效类型的参数,优化将不是正确的,导致程序异常。这对于之前的类型不一致是正确的,对于现在健全的空安全的可空性不一致也是如此。 -
你可能会注意到开发版的 JavaScript 编译器和 Dart VM 对于空检查的错误有比较特殊的错误提示,但是为了保持应用的轻量体积,生产环境下的编译器并没有这样的提示。
-
你可能会看到
.toString
在null
上未找到的错误。这不是一个 bug,是编译器一直以来添加的空检查。编译器会通过对接收者对象属性的访问来压缩一些空检查。它生成的是a.toString
而不是if (a == null) throw
。在 JavaScript 对象中定义的toString
方法可以快速验证对象是否可空。如果空检查后的第一个行为是当值为空时会崩溃,编译器可以删除空检查并让动作抛出错误。
例如,
print(a!.foo());
语句可以直接转换为:P.print(a.foo$0());
这是因为调用
a.foo$()
会在a
为空时崩溃。如果 dart2js 内联foo
,它将保留空检查。例如,如果foo
是int foo() => 1;
,编译器可能会生成:a.toString; P.print(1);
如果内联方法做的第一件事是对接收者的字段访问,例如
int foo() => this.x + 1;
,那么 dart2js 可以再次删除多余的a.toString
空检查,就像非内联调用一样生成:P.print(a.x + 1);