跳到主要内容

空安全:常见问题

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

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

#

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

  • 用户的静态空安全检查在他们迁移代码时首次生效。
  • 当所有代码都已迁移并且健全模式开启时,会进行完整的空安全检查。

需要注意的两个例外是

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

如果一个值只在 null 在测试中为 null 怎么办?

#

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

@required 与新的 required 关键字有何区别?

#

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

使用空安全,具有非可空类型的命名参数必须具有默认值或使用新的 required 关键字标记。否则,它就没有理由是非可空的,因为它在未传递时将默认为 null

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

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

这对迁移意味着什么?如果之前没有 @required 而现在添加 required,请务必小心。任何未传递新要求的参数的调用者将不再编译。相反,您可以添加一个默认值或使参数类型可空。

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

#

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

dart
// 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>();
}

你可以这样做

dart
// 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。有了空安全,你会发现这也使得它更难成为非可空的;如果初始化得太晚,那么在初始化之前它将是 null,并且必须是可空的。幸运的是,您有以下选择

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

如何迁移一个 built_value 类?

#

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

dart
@nullable
int get count;

变为

dart
int? get count; //  Variable initialized with ?

标记为 @nullable 的 getter 应具有可空类型,即使迁移工具建议这样做。根据需要添加 ! 提示,然后重新运行分析。

如何迁移一个可能返回 null 的工厂?

#

优先选择不返回 null 的工厂。 我们见过一些代码,本意是因无效输入抛出异常,结果却返回了 null。

而不是

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

这样做

dart
  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,那么您可以将其转换为静态方法,使其允许返回 null

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

#

当所有内容都完全迁移后,断言将变得不必要,但目前如果您确实想保留检查,它必需的。选项

  • 决定断言确实不必要,并将其删除。这会在断言启用时改变行为。
  • 决定断言可以始终检查,并将其转换为 ArgumentError.checkNotNull。这会在断言未启用时改变行为。
  • 保持原样:添加 // ignore: unnecessary_null_comparison 以绕过警告。

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

#

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

dart
if (arg == null) throw ArgumentError(...)`

如果程序是混合版本,则必须包含此检查。在所有内容完全迁移且代码切换到使用健全空安全运行之前,arg 可能会设置为 null

保留行为最简单的方法是将检查更改为 ArgumentError.checkNotNull

这同样适用于一些运行时类型检查。如果 arg 的静态类型是 String,那么 if (arg is! String) 实际上是在检查 arg 是否为 null。看起来迁移到空安全意味着 arg 永远不会是 null,但在不健全的空安全中它可能是 null。因此,为了保留行为,null 检查应该保留。

Iterable.firstWhere 方法不再接受 orElse: () => null

#

导入 package:collection 并使用扩展方法 firstWhereOrNull 而不是 firstWhere

如何处理带有 setter 的属性?

#

与上述 late final 建议不同,这些属性不能标记为 final。通常,可设置属性也没有初始值,因为它们预期在稍后设置。

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

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

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

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

如何表示 Map 的返回值是非可空的?

#

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

在这种情况下,您应该使用非 null 断言运算符 (!) 将值强制转换回 V

dart
return blockTypes[key]!;

如果 map 返回 null,这将抛出。如果您想对这种情况进行显式处理

dart
var result = blockTypes[key];
if (result != null) return result;
// Handle the null case here, e.g. throw with explanation.

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

#

最终出现这样的可空代码通常是一种代码异味

dart
List<Foo?> fooList; // fooList can contain null values

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

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

dart
_jellyCounts = List<int?>(jellyMax + 1);
for (var i = 0; i <= jellyMax; i++) {
  _jellyCounts[i] = 0; // List initialized with the same value
}
dart
_jellyCounts = List<int>.filled(jellyMax + 1, 0); // List initialized with filled constructor

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

dart
_jellyPoints = List<Vec2D?>(jellyMax + 1);
for (var i = 0; i <= jellyMax; i++) {
  _jellyPoints[i] = Vec2D(); // Each list element is a distinct Vec2D
}
dart
_jellyPoints = [
  for (var i = 0; i <= jellyMax; i++)
    Vec2D() // Each list element is a distinct Vec2D
];

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

dart
_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 */ 注释。此类注释可能表明自动迁移不正确,需要人工干预。例如

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

在这些情况下,迁移工具无法区分防御性编码情况和真正预期 null 值的情况。因此,该工具会告诉您它所知道的(“这个条件看起来总是假的!”),并让您决定如何处理。

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

#

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

以下几点值得强调

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

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

  • 生产 JavaScript 编译器可能会移除不必要的 null 检查。发生这种情况是因为生产 Web 编译器在空安全之前所做的优化在知道值为非 null 时就移除了这些检查。

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

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

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

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

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

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

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

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

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

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

资源

#