目录

“空安全:常见问题解答”

本页面收集了根据迁移 Google 内部代码的经验,我们听到的一些关于 空安全 的常见问题。

对于已迁移代码的用户,我应该注意哪些运行时更改?

#

迁移的大部分影响不会立即影响已迁移代码的用户:

  • 对于用户而言,静态空安全检查首次应用于他们迁移其代码时。
  • 当所有代码都迁移并且启用健全模式时,将进行完整的空安全检查。

需要注意的两个例外情况是:

  • ! 运算符在所有模式下对所有用户都是运行时空检查。因此,在迁移时,请确保仅在 null 流向该位置时才添加 ! ,即使调用代码尚未迁移也是如此。
  • late 关键字关联的运行时检查适用于所有模式下的所有用户。仅当您确定在使用之前始终初始化字段时,才将字段标记为 late

如果值仅在测试中为 null 该怎么办?

#

如果值仅在测试中为 null ,则可以通过将其标记为不可为空并将测试传递非空值来改进代码。

@required 与新的 required 关键字相比如何?

#

@required 注解标记必须传递的命名参数;如果没有,分析器会报告提示。

使用空安全,具有不可为空类型的命名参数必须具有默认值或用新的 required 关键字标记。否则,将其设置为不可为空是没有意义的,因为它在不传递时将默认为 null

当从遗留代码调用空安全代码时, required 关键字的处理方式与 @required 注解完全相同:未提供参数将导致分析器提示。

当从空安全代码调用空安全代码时,未提供 required 参数是一个错误。

这对迁移意味着什么?如果在以前没有 @required 的地方添加 required ,请小心。任何不传递新所需参数的调用者将不再编译。相反,您可以添加默认值或使参数类型可为空。

如何迁移应该为 final 但不是 final 的不可为空字段?

#

某些计算可以移动到静态初始化器。而不是:

baddart
// 未初始化值
ListQueue _context;
Float32List _buffer;
dynamic _readObject;

Vec2D(Map<String, dynamic> object) {
  _buffer = Float32List.fromList([0.0, 0.0]);
  _readObject = object['container'];
  _context = ListQueue<dynamic>();
}

你可以这样做:

gooddart
// 初始化值
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 。使用空安全,您会发现这也使它难以不可为空;如果初始化得太晚,则它在初始化之前为 null ,并且必须可为空。幸运的是,您可以选择:

  • 将构造函数转换为工厂,然后使其委托给直接初始化所有字段的实际构造函数。此类私有构造函数的常用名称只是一个下划线: _ 。然后,字段可以是 final 和不可为空的。此重构可以在迁移到空安全之前完成。
  • 或者,将字段标记为 late final 。这强制它只初始化一次。必须在读取之前初始化它。

如何迁移 built_value 类?

#

已标注为 @nullable 的 getter 应该改为具有可为空的类型;然后删除所有 @nullable 注解。例如:

dart
@nullable
int get count;

变成

dart
int? get count; // 用 ? 初始化变量

即使迁移工具建议它们,也不应为未标记为 @nullable 的 getter 设置可为空的类型。根据需要添加 ! 提示,然后重新运行分析。

如何迁移可以返回 null 的工厂?

#

更倾向于不返回 null 的工厂。我们看到代码本来应该由于无效输入而抛出异常,但最终却返回了 null。

而不是:

baddart
  factory StreamReader(dynamic data) {
    StreamReader reader;
    if (data is ByteData) {
      reader = BlockReader(data);
    } else if (data is Map) {
      reader = JSONBlockReader(data);
    }
    return reader;
  }

做:

gooddart
  factory StreamReader(dynamic data) {
    if (data is ByteData) {
      // 为二进制读取器向前移动 readIndex。
      return BlockReader(data);
    } else if (data is Map) {
      return JSONBlockReader(data);
    } else {
      throw ArgumentError('Unexpected type for data');
    }
  }

如果工厂的意图确实是返回 null,则可以将其转换为静态方法,以便允许它返回 null

如何迁移现在显示为不必要的 assert(x != null)

#

当一切完全迁移后,断言将是不必要的,但就目前而言,如果您确实想要保留检查,则需要它。选项:

  • 确定断言实际上并非必要,并将其删除。当启用断言时,这是一个行为变化。
  • 确定断言可以始终被检查,并将其转换为 ArgumentError.checkNotNull 。当未启用断言时,这是一个行为变化。
  • 保持行为完全不变:添加 // ignore: unnecessary_null_comparison 来绕过警告。

如何迁移现在显示为不必要的运行时空检查?

#

如果将 arg 设置为不可为空,编译器会将显式运行时空检查标记为不必要的比较。

dart
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 并使用扩展方法 firstWhereOrNull 代替 firstWhere

如何处理具有 setter 的属性?

#

与上面的 late final 建议不同,这些属性不能标记为 final。通常,可设置属性也没有初始值,因为预期它们会在以后的某个时间设置。

在这种情况下,您有两个选择:

  • 将其设置为初始值。很多时候,省略初始值是由于错误而不是故意为之。

  • 如果您 确定 必须在访问之前设置属性,请将其标记为 late

    警告: late 关键字添加运行时检查。如果任何用户在 set 之前调用 get ,他们将在运行时收到错误。

如何表明 Map 中返回值不可为空?

#

Map 上的 查找运算符 ([]) 默认返回可为空的类型。无法向语言发出信号表明该值保证存在。

在这种情况下,您应该使用感叹号运算符 (!) 将值转换回 V:

dart
return blockTypes[key]!;

如果映射返回 null,则会抛出异常。如果您想要针对这种情况进行显式处理:

dart
var result = blockTypes[key];
if (result != null) return result;
// 在这里处理 null 案例,例如抛出带有解释的异常。

为什么我的 List/Map 上的泛型类型可为空?

#

最终得到这样的可为空代码通常是一种代码异味:

baddart
List<Foo?> fooList; // fooList 可以包含 null 值

这意味着 fooList 可能包含 null 值。如果您使用长度初始化列表并通过循环填充它,则可能会发生这种情况。

如果您只是使用相同的值初始化列表,则应该改用 filled 构造函数。

baddart
_jellyCounts = List<int?>(jellyMax + 1);
for (var i = 0; i <= jellyMax; i++) {
  _jellyCounts[i] = 0; // 用相同的值初始化列表
}
gooddart
_jellyCounts = List<int>.filled(jellyMax + 1, 0); // 使用 filled 构造函数初始化列表

如果您通过索引设置列表的元素,或者您使用不同的值填充列表的每个元素,则应该改用列表文字语法来构建列表。

baddart
_jellyPoints = List<Vec2D?>(jellyMax + 1);
for (var i = 0; i <= jellyMax; i++) {
  _jellyPoints[i] = Vec2D(); // 每个列表元素都是一个不同的 Vec2D
}
gooddart
_jellyPoints = [
  for (var i = 0; i <= jellyMax; i++)
    Vec2D() // 每个列表元素都是一个不同的 Vec2D
];

要生成固定长度的列表,请使用 List.generate 构造函数,并将 growable 参数设置为 false

dart
_jellyPoints = List.generate(jellyMax, (_) => Vec2D(), growable: false);

默认的 List 构造函数发生了什么?

#

您可能会遇到此错误:

启用空安全后,默认的 'List' 构造函数不可用。 #default_list_constructor

默认列表构造函数使用 null 填充列表,这是一个问题。

改为使用 List.filled(length, default)

我正在使用 package:ffi ,并在迁移时遇到 Dart_CObject_kUnsupported 错误。发生了什么?

#

通过 ffi 发送的列表只能是 List<dynamic> ,而不是 List<Object>List<Object?> 。如果您在迁移中没有显式更改列表类型,则由于启用空安全时发生的类型推断更改,类型可能仍然已更改。

解决方法是将此类列表显式创建为 List<dynamic>

为什么迁移工具会向我的代码添加注释?{#migration-comments}

#

当迁移工具看到在健全模式下始终为假或为真的条件时,它会添加 /* == false *//* == true */ 注释。此类注释可能表明自动迁移不正确,需要人工干预。例如:

dart
if (registry.viewFactory(viewDescriptor.id) == null /* == false */)

在这些情况下,迁移工具无法区分防御性编码情况和确实期望 null 值的情况。因此,该工具会告诉您它知道什么(“看起来这个条件总是为假!”)并让您决定该做什么。

关于编译到 JavaScript 和空安全,我应该知道什么?

#

空安全带来了许多好处,例如减少代码大小和提高应用程序性能。当编译到 Flutter 和 AOT 等原生目标时,这些好处会更加明显。先前针对生产 Web 编译器的工作引入了类似于空安全后来引入的优化。这可能会使生产 Web 应用程序产生的收益看起来不如其原生目标。

一些值得强调的说明:

  • 生产 JavaScript 编译器会生成 ! 空断言。在比较添加空断言之前和之后的编译器输出时,您可能不会注意到它们。这是因为编译器已经在非空安全的程序中生成了空检查。

  • 编译器会生成这些空断言,而不管空安全的健全性或优化级别如何。事实上,编译器在使用 -O3--omit-implicit-checks 时不会删除 !

  • 生产 JavaScript 编译器可能会删除不必要的空检查。之所以会发生这种情况,是因为生产 Web 编译器在空安全之前进行的优化在知道该值不为 null 时会删除这些检查。

  • 默认情况下,编译器会生成参数子类型检查。这些运行时检查确保协变虚拟调用具有适当的参数。编译器使用 --omit-implicit-checks 选项跳过这些检查。如果代码包含无效类型,则使用此选项可能会生成具有意外行为的应用程序。为了避免任何意外,请继续为您的代码提供强大的测试覆盖率。特别是,编译器基于输入应符合类型声明的事实来优化代码。如果代码提供无效类型的参数,则这些优化将是错误的,并且程序可能会出现故障。这在以前的不一致类型中是正确的,现在在具有健全空安全的不可靠空值中也是正确的。

  • 您可能会注意到,开发 JavaScript 编译器和 Dart VM 对空检查有特殊的错误消息,但为了保持应用程序的小巧,生产 JavaScript 编译器没有。

  • 您可能会看到指示 .toStringnull 上找不到的错误。这不是错误。编译器始终以这种方式编码一些空检查。也就是说,编译器通过对接收器的属性进行不受保护的访问来紧凑地表示一些空检查。因此,它不生成 if (a == null) throw ,而是生成 a.toStringtoString 方法在 JavaScript 对象中定义,并且是验证对象不为 null 的快速方法。

如果空检查后的第一个操作是在值为 null 时会崩溃的操作,则编译器可以删除空检查并让操作导致错误。

例如,Dart 表达式 print(a!.foo()); 可以直接转换为:

js
    P.print(a.foo$0());

这是因为如果 a 为 null,则调用 a.foo$() 将崩溃。如果编译器内联 foo ,它将保留空检查。例如,如果 fooint foo() => 1; ,则编译器可能会生成:

js
    a.toString;
    P.print(1);

如果内联方法首先访问接收器上的字段,例如 int foo() => this.x + 1; ,则生产编译器可以删除冗余的 a.toString 空检查(作为非内联调用),并生成:

js
    P.print(a.x + 1);

资源

#