目录

了解空安全

作者:Bob Nystrom
2020 年 7 月

空安全是我们对 Dart 做出的最大改动,自我们在 Dart 2.0 中用 健全的静态类型系统 替换最初不健全的可选类型系统以来。Dart 最初推出时,编译时空安全是一个罕见的功能,需要一个冗长的介绍。如今,Kotlin、Swift、Rust 和其他语言都对这个问题给出了自己的答案,这个问题已经变得非常 熟悉。 这是一个示例

dart
// Without null safety:
bool isEmpty(String string) => string.length == 0;

main() {
  isEmpty(null);
}

如果不使用空安全运行此 Dart 程序,它将在调用 .length 时抛出 NoSuchMethodError 异常。null 值是 Null 类的实例,而 Null 没有“length”getter。运行时故障很糟糕。在设计为在终端用户设备上运行的 Dart 等语言中尤其如此。如果服务器应用程序发生故障,你通常可以在任何人注意到之前重新启动它。但当 Flutter 应用在用户的手机上崩溃时,他们会不高兴。当你的用户不高兴时,你也不会高兴。

开发人员喜欢 Dart 等静态类型语言,因为它们使类型检查器能够在编译时(通常就在 IDE 中)找到代码中的错误。你越早发现错误,就越早可以修复它。当语言设计人员谈论“修复空引用错误”时,他们的意思是丰富静态类型检查器,以便语言可以检测到上述尝试在可能为 null 的值上调用 .length 之类的错误。

对于这个问题没有一个真正的解决方案。Rust 和 Kotlin 都各自有自己的方法,在这些语言的上下文中是有意义的。本文档详细介绍了我们对 Dart 的答案的所有细节。它包括对静态类型系统的更改以及一系列其他修改和新语言特性,让你不仅可以编写空安全代码,而且希望能够享受这样做。

本文档很长。如果你想要更短的内容,仅涵盖你了解如何启动和运行所需的内容,请从概述开始。当你准备好更深入地了解并有时间时,请回到这里,以便你可以了解语言如何处理null我们为什么以这种方式设计它,以及如何编写习惯用语、现代、空安全的 Dart。(剧透警报:它最终会非常接近你今天编写 Dart 的方式。)

语言处理空引用错误的各种方式各有优缺点。这些原则指导了我们做出的选择

  • 代码应默认安全。如果您编写新的 Dart 代码,并且不使用任何明确的不安全特性,则它在运行时永远不会抛出空引用错误。所有可能的空引用错误都会在静态时捕获。如果您想将其中一些检查推迟到运行时以获得更大的灵活性,您可以这样做,但您必须通过在代码中以文本形式可见的某些特性来选择这样做。

    换句话说,我们不会给您一件救生衣,然后让您记住每次出海时都要穿上它。相反,我们给您一艘不会沉没的船。除非您跳船,否则您会保持干燥。

  • 空安全代码应该易于编写。大多数现有的 Dart 代码在动态上是正确的,并且不会抛出空引用错误。您喜欢 Dart 程序现在的外观,我们希望您能够继续以这种方式编写代码。安全性不应要求牺牲可用性、向类型检查器赎罪或大幅改变您的思维方式。

  • 生成的空安全代码应完全健全。在静态检查的上下文中,“健全性”对不同的人来说意味着不同的事情。对于我们来说,在空安全性的上下文中,这意味着如果一个表达式的静态类型不允许 null,那么该表达式的任何可能的执行都永远不会求值为 null。该语言主要通过静态检查提供此保证,但也可以涉及一些运行时检查。(不过,请注意第一条原则:任何发生这些运行时检查的地方都是您的选择。)

    健全性对于用户信心很重要。一艘几乎浮在水面的船并不是您热衷于在公海上航行的船。但对于我们无畏的编译器黑客来说,这一点也很重要。当语言对程序的语义属性做出严格的保证时,这意味着编译器可以执行假设这些属性为真的优化。当涉及到 null 时,这意味着我们可以生成更小的代码,消除不必要的 null 检查,以及更快的代码,在调用方法之前不需要验证接收器是否为非 null

    一个警告:我们只保证完全空安全的 Dart 程序的健全性。Dart 支持包含较新的空安全代码和较旧的旧版代码的混合程序。在这些混合版本程序中,仍然可能发生空引用错误。在混合版本程序中,您可以在空安全的各个部分获得所有静态安全优势,但直到整个应用程序都是空安全的,您才能获得完全的运行时健全性。

请注意,消除 null 并非目标。null 没有问题。相反,能够表示值的不存在非常有用。直接在语言中构建对特殊“不存在”值的 supports 使得使用不存在变得灵活且可用。它支持可选参数、便捷的 ?. null 感知运算符和默认初始化。null 本身并不糟糕,而是让 null 进入你意想不到的地方才会导致问题。

因此,有了 null 安全性,我们的目标是让你控制了解 null 在程序中可以流向何处,并确信它不会流向会导致崩溃的地方。

类型系统中的可空性

#

Null 安全性始于静态类型系统,因为其他所有内容都基于此。你的 Dart 程序中有一个类型世界:基本类型(如 intString)、集合类型(如 List)以及你和你使用的软件包定义的所有类和类型。在 null 安全性之前,静态类型系统允许值 null 流入任何这些类型的表达式。

在类型理论术语中,Null 类型被视为所有类型的子类型

Null Safety Hierarchy Before

某些表达式允许的操作(获取器、设置器、方法和运算符)集由其类型定义。如果类型为 List,则可以在其上调用 .add()[]。如果是 int,则可以调用 +。但 null 值不定义任何这些方法。允许 null 流入其他类型的表达式意味着任何这些操作都可能失败。这实际上是 null 引用错误的关键所在——每次失败都是因为尝试查找 null 上它不具备的方法或属性。

不可空类型和可空类型

#

Null 安全性通过更改类型层次结构从根本上消除了该问题。Null 类型仍然存在,但它不再是所有类型的子类型。相反,类型层次结构如下所示

Null Safety Hierarchy After

由于 Null 不再是子类型,除了特殊的 Null 类之外,没有类型允许值 null。我们已将所有类型默认设置为非空。如果您有一个类型为 String 的变量,它将始终包含一个字符串。这样,我们修复了所有空引用错误。

如果我们认为 null 完全没有用,我们就可以到此为止了。但 null 是有用的,所以我们仍然需要一种方法来处理它。可选参数是一个很好的说明性案例。考虑以下空安全 Dart 代码

dart
// Using null safety:
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 的简写。

使用可空类型

#

如果您有一个具有可空类型的表达式,您可以对结果做什么?由于我们的原则是默认安全,因此答案并不多。我们不能让您在它上面调用基础类型的函数,因为如果值为 null,这些函数可能会失败

dart
// Hypothetical unsound null safety:
bad(String? maybeString) {
  print(maybeString.length);
}

main() {
  bad(null);
}

如果您让我们运行它,这将崩溃。我们只能安全地让您访问基础类型和 Null 类都定义的方法和属性。那只是 toString()==hashCode。因此,您可以将可空类型用作映射键,将它们存储在集合中,将它们与其他值进行比较,并在字符串插值中使用它们,但仅此而已。

它们如何与非空类型交互?将空类型传递给需要可空类型的东西总是安全的。如果一个函数接受 String?,那么传递一个 String 是允许的,因为它不会造成任何问题。我们通过使每个可空类型成为其基础类型的超类型来对它进行建模。您还可以安全地将 null 传递给需要可空类型的东西,因此 Null 也是每个可空类型的子类型

Nullable

但是,反过来将可空类型传递给需要基础非空类型的东西是不安全的。需要 String 的代码可能会对该值调用 String 方法。如果您将 String? 传递给它,则 null 可能会流入,并且可能会失败

dart
// Hypothetical unsound null safety:
requireStringNotNull(String definitelyString) {
  print(definitelyString.length);
}

main() {
  String? maybeString = null; // Or not!
  requireStringNotNull(maybeString);
}

此程序不安全,我们不应该允许它。但是,Dart 一直都有一个称为隐式向下转换的东西。例如,如果您将类型为 Object 的值传递给需要 String 的函数,类型检查器将允许它

dart
// Without null safety:
requireStringNotObject(String definitelyString) {
  print(definitelyString.length);
}

main() {
  Object maybeString = 'it is';
  requireStringNotObject(maybeString);
}

为了保持健全性,编译器会在 requireStringNotObject() 的参数上静默插入一个 as String 转换。该转换可能会失败并在运行时抛出一个异常,但在编译时,Dart 认为这是可以的。由于非空类型被建模为可空类型的子类型,隐式向下转换将允许你将 String? 传递给期望 String 的内容。允许这样做将违反我们默认情况下安全的目标。因此,通过空安全,我们完全删除了隐式向下转换。

这使得调用 requireStringNotNull() 会产生一个编译错误,而这正是你想要的。但这还意味着所有隐式向下转换都会变成编译错误,包括对 requireStringNotObject() 的调用。你必须自己添加显式向下转换

dart
// Using null safety:
requireStringNotObject(String definitelyString) {
  print(definitelyString.length);
}

main() {
  Object maybeString = 'it is';
  requireStringNotObject(maybeString as String);
}

我们认为这是一个总体上的好改变。我们的印象是,大多数用户从来不喜欢隐式向下转换。特别是,你可能以前就被它坑过

dart
// Without null safety:
List<int> filterEvens(List<int> ints) {
  return ints.where((n) => n.isEven);
}

发现 bug 了吗?.where() 方法是惰性的,因此它返回一个 Iterable,而不是一个 List。此程序会编译,但随后在尝试将该 Iterable 转换为 filterEvens 声明的返回类型的 List 时,会在运行时抛出一个异常。随着隐式向下转换的移除,这变成了一个编译错误。

我们说到哪了?对,好的,所以就好像我们已经将程序中的类型世界分成两半

Nullable and Non-Nullable types

有一个非空类型区域。这些类型允许你访问所有有趣的方法,但永远不能包含 null。然后是所有相应可空类型的一个并行系列。它们允许 null,但你不能对它们做太多操作。我们让值从非空侧流向可空侧,因为这样做是安全的,但不能反向进行。

这似乎意味着可空类型基本上是无用的。它们没有方法,你无法摆脱它们。别担心,我们有一整套功能来帮助你将值从可空半部分移动到另一部分,我们很快就会用到。

顶部和底部

#

本节有点深奥。除了最后两条要点,你基本上可以跳过它,除非你对类型系统感兴趣。想象程序中所有类型之间的边,这些边连接着彼此的子类型和超类型。如果你要绘制它,就像本文档中的图表一样,它将形成一个巨大的有向图,其中超类型(如 Object)位于顶部附近,而叶类(如你自己的类型)位于底部附近。

如果该有向图在顶部形成一个点,其中有一个作为超类型的单个类型(直接或间接),则该类型称为顶部类型。同样,如果在底部有一个奇怪的类型是每个类型的子类型,则你有一个底部类型。(在这种情况下,你的有向图是一个 格。)

如果你的类型系统具有顶层类型和底层类型,则很方便,因为这意味着类型级别操作(例如最小上界(类型推断用于根据其两个分支的类型来确定条件表达式的类型))始终可以生成类型。在空安全之前,Object 是 Dart 的顶层类型,Null 是其底层类型。

由于 Object 现在是不可空的,因此它不再是顶层类型。Null 不是它的子类型。Dart 没有命名的顶层类型。如果你需要顶层类型,则需要 Object?。同样,Null 不再是底层类型。如果它是,则所有内容仍然是可空的。相反,我们添加了一个名为 Never 的新底层类型

Top and Bottom

在实践中,这意味着

  • 如果你想表示允许任何类型的变量,则使用 Object? 代替 Object。事实上,使用 Object 变得非常不寻常,因为该类型表示“可以是除这个奇怪的禁止值 null 之外的任何可能值”。

  • 在极少数情况下需要底层类型时,使用 Never 代替 Null。这对于指示函数从不返回到 帮助可达性分析 非常有用。如果你不知道是否需要底层类型,则可能不需要。

确保正确性

#

我们将类型世界分为可空和不可空两半。为了保持健壮性和我们的原则,即除非你要求,否则你永远不会在运行时收到空引用错误,我们需要保证 null 永远不会出现在不可空侧的任何类型中。

摆脱隐式向下转换并将 Null 作为底层类型涵盖了类型在程序中通过赋值以及从函数调用的参数流入参数的所有主要位置。null 可以潜入的剩余主要位置是当变量首次出现以及当你离开函数时。因此有一些额外的编译错误

无效返回

#

如果函数具有不可空的返回类型,则函数中的每条路径都必须到达返回值的 return 语句。在空安全之前,Dart 对丢失的返回非常宽松。例如

dart
// Without null safety:
String missingReturn() {
  // No return.
}

如果你分析了这一点,你得到了一个温和的提示也许你忘记了一个返回,但如果没有,也没什么大不了的。这是因为如果执行到达函数体的末尾,则 Dart 会隐式返回 null。由于每个类型都是可空的,技术上来说这个函数是安全的,即使它可能不是你想要的。

使用健全的非空类型时,此程序完全错误且不安全。在空安全下,如果具有非空返回类型的函数不可靠地返回一个值,则会出现编译错误。我所说的“不可靠”是指语言分析函数中所有控制流路径。只要它们都返回一些内容,它就会满足。该分析非常智能,因此即使是此函数也可以

dart
// Using null safety:
String alwaysReturns(int n) {
  if (n == 0) {
    return 'zero';
  } else if (n < 0) {
    throw ArgumentError('Negative values not allowed.');
  } else {
    if (n > 1000) {
      return 'big';
    } else {
      return n.toString();
    }
  }
}

我们将在下一节中更深入地探讨新的流分析。

未初始化变量

#

声明变量时,如果您没有给它一个显式的初始化器,Dart 会使用 null 默认初始化变量。这很方便,但如果变量的类型是非空的,则显然是完全不安全的。因此,我们必须为非空变量收紧

  • 顶级变量和静态字段声明必须具有初始化器。由于可以在程序中的任何位置访问和分配这些变量,因此编译器无法保证在使用变量之前已为其赋予值。唯一安全的选择是要求声明本身具有生成正确类型值的初始化表达式

    dart
    // Using null safety:
    int topLevel = 0;
    
    class SomeClass {
      static int staticField = 0;
    }
  • 实例字段必须在声明时具有初始化器,使用初始化形式,或在构造函数的初始化列表中初始化。这是很多术语。以下是一些示例

    dart
    // Using null safety:
    class SomeClass {
      int atDeclaration = 0;
      int initializingFormal;
      int initializationList;
    
      SomeClass(this.initializingFormal)
          : initializationList = 0;
    }

    换句话说,只要在到达构造函数主体之前字段具有值,您就可以了。

  • 局部变量是最灵活的情况。非空局部变量不需要具有初始化器。这完全没问题

    dart
    // Using null safety:
    int tracingFibonacci(int n) {
      int result;
      if (n < 2) {
        result = n;
      } else {
        result = tracingFibonacci(n - 2) + tracingFibonacci(n - 1);
      }
    
      print(result);
      return result;
    }

    规则只是局部变量必须在使用之前明确赋值我们也可以依靠我提到的新的流分析来实现这一点。只要变量使用的每条路径都首先对其进行初始化,那么使用就是可以的。

  • 可选参数必须具有默认值。如果您没有为可选位置或命名参数传递参数,则语言会用默认值填充它。如果您没有指定默认值,则默认默认值是 null,如果参数的类型是非空的,则该值无效。

    因此,如果您希望参数是可选的,则需要使其可空或指定有效的非 null 默认值。

这些限制听起来很繁琐,但实际上并没有那么糟糕。它们与现有的 final 变量限制非常相似,而且您可能已经使用它们多年而没有注意到。此外,请记住,这些限制仅适用于非空变量。您始终可以使类型可空,然后将默认初始化获取为 null

即便如此,这些规则确实会导致摩擦。幸运的是,我们有一套新的语言特性来润滑这些新限制减慢你的速度的最常见模式。不过,首先,是时候讨论流分析了。

流分析

#

控制流分析在编译器中已经存在多年。它通常对用户隐藏,并在编译器优化期间使用,但一些较新的语言已经开始将相同技术用于可见语言特性。Dart 已经有了少量的流分析,形式为类型提升

dart
// With (or without) null safety:
bool isEmptyList(Object object) {
  if (object is List) {
    return object.isEmpty; // <-- OK!
  } else {
    return false;
  }
}

请注意,在标记行上,我们可以在object上调用isEmpty。该方法在List上定义,而不是在Object上定义。这之所以有效,是因为类型检查器会查看程序中的所有is表达式和控制流路径。如果某个控制流构造体的正文仅在变量上的某个is表达式为真时执行,那么在该正文内,变量的类型将“提升”为已测试的类型。

在此示例中,if语句的 then 分支仅在object实际包含列表时才运行。因此,Dart 将object提升为类型List,而不是其声明的类型Object。这是一个方便的特性,但它非常有限。在空安全之前,以下功能相同的程序无法工作

dart
// Without null safety:
bool isEmptyList(Object object) {
  if (object is! List) return false;
  return object.isEmpty; // <-- Error!
}

同样,你只能在object包含列表时才能到达.isEmpty调用,因此此程序在动态上是正确的。但类型提升规则不够智能,无法看出return语句意味着只有当object是列表时才能到达第二个语句。

对于空安全,我们采用了这种有限的分析,并以多种方式使之变得更加强大。

可达性分析

#

首先,我们修复了长期存在的抱怨,即类型提升对于早期返回和其他不可达的代码路径并不明智。在分析函数时,它现在会考虑returnbreakthrow以及函数中执行可能提前终止的任何其他方式。在空安全下,此函数

dart
// Using null safety:
bool isEmptyList(Object object) {
  if (object is! List) return false;
  return object.isEmpty;
}

现在完全有效。由于当object不是List时,if语句将退出函数,因此 Dart 在第二个语句中将object提升为List。这是一个非常好的改进,它帮助了很多 Dart 代码,即使是与空值无关的代码。

永远不可达代码

#

你还可以编程此可达性分析。新的底部类型Never没有值。(什么样的值同时是Stringboolint?)那么表达式具有类型Never意味着什么?这意味着表达式永远无法成功完成求值。它必须引发异常、中止或以其他方式确保期望表达式的结果的周围代码永远不会运行。

事实上,根据语言,throw 表达式的静态类型为 Never。类型 Never 在核心库中声明,你可以将其用作类型注释。也许你有一个帮助器函数,可以让你更轻松地抛出某种异常

dart
// Using null safety:
Never wrongType(String type, Object value) {
  throw ArgumentError('Expected $type, but was ${value.runtimeType}.');
}

你可以像这样使用它

dart
// Using null safety:
class Point {
  final double x, y;

  bool operator ==(Object other) {
    if (other is! Point) wrongType('Point', other);
    return x == other.x && y == other.y;
  }

  // Constructor and hashCode...
}

此程序分析时不会出错。请注意,== 方法的最后一行访问 other 上的 .x.y。即使该函数没有任何 returnthrow,它也已提升为 Point。控制流分析知道 wrongType() 的声明类型为 Never,这意味着 if 语句的 then 分支必须以某种方式中止。由于只有当 otherPoint 时才能到达第二个语句,因此 Dart 会对其进行提升。

换句话说,在自己的 API 中使用 Never 可以让你扩展 Dart 的可达性分析。

确定赋值分析

#

我曾用局部变量简要地提到过这一点。Dart 需要确保在读取非空局部变量之前始终对其进行初始化。我们使用确定赋值分析尽可能灵活地处理它。该语言分析每个函数体,并通过所有控制流路径跟踪对局部变量和参数的赋值。只要在到达变量的某个使用位置的每条路径上都对变量进行赋值,该变量就被视为已初始化。这让你可以声明一个没有初始化程序的变量,然后使用复杂的控制流在之后对其进行初始化,即使该变量具有非空类型。

我们还使用确定赋值分析来让final 变量更灵活。在空安全之前,如果你需要以某种有趣的方式初始化局部变量,则很难对它们使用 final

dart
// Using null safety:
int tracingFibonacci(int n) {
  final int result;
  if (n < 2) {
    result = n;
  } else {
    result = tracingFibonacci(n - 2) + tracingFibonacci(n - 1);
  }

  print(result);
  return result;
}

这将是一个错误,因为 result 变量是 final 的,但没有初始化程序。借助空安全下的更智能流分析,此程序没有问题。该分析可以判断在每个控制流路径上 result 肯定只初始化一次,因此满足将变量标记为 final 的约束。

空检查中的类型提升

#

更智能的流分析有助于许多 Dart 代码,即使是与空安全无关的代码。但我们现在做出这些更改并非巧合。我们将类型划分为可空和不可空集合。如果你有一个可空类型的值,你实际上无法用它任何有用的事情。在值 null 的情况下,此限制是好的。它可以防止你崩溃。

但如果该值不为 null,最好能够将其移至非空一侧,以便对其调用方法。流分析是针对局部变量和参数(以及 Dart 3.2 中的私有 final 字段)执行此操作的主要方法之一。我们已扩展类型提升以查看 == null!= null 表达式。

如果使用可空类型检查局部变量以查看它是否不为 null,则 Dart 会将该变量提升为基础非空类型

dart
// Using null safety:
String makeCommand(String executable, [List<String>? arguments]) {
  var result = executable;
  if (arguments != null) {
    result += ' ' + arguments.join(' ');
  }
  return result;
}

此处,arguments 具有可空类型。通常,这会禁止你对其调用 .join()。但由于我们在 if 语句中保护了该调用,以确保该值不为 null,因此 Dart 会将其从 List<String>? 提升到 List<String>,并允许你对其调用方法或将其传递给需要非空列表的函数。

这听起来像是一件相当小的事情,但基于流的空检查提升是大多数现有 Dart 代码在空安全性下工作的基础。大多数 Dart 代码动态上是正确的,并且确实通过在调用方法之前检查 null 来避免抛出空引用错误。空检查的新流分析将这种动态正确性转化为可证明的静态正确性。

当然,它还可以与我们针对可达性执行的更智能分析配合使用。上述函数可以写得与下面一样好

dart
// Using null safety:
String makeCommand(String executable, [List<String>? arguments]) {
  var result = executable;
  if (arguments == null) return result;
  return result + ' ' + arguments.join(' ');
}

该语言还更智能地了解哪些类型的表达式会导致提升。显式的 == null!= null 当然有效。但使用 as、赋值或后缀 ! 运算符(我们将在后面介绍)进行显式转换也会导致提升。总体目标是,如果代码在动态上是正确的,并且合理地认为可以静态地弄清楚,则分析应该足够聪明地做到这一点。

请注意,类型提升最初仅适用于局部变量,现在在 Dart 3.2 中也适用于私有 final 字段。有关使用非局部变量的更多信息,请参阅使用可空字段

不必要的代码警告

#

具有更智能的可达性分析,并知道 null 可以在程序中流经何处,有助于确保你添加代码来处理 null。但我们还可以使用相同的分析来检测你不需要的代码。在 null 安全之前,如果你编写类似以下内容:

dart
// Using null safety:
String checkList(List<Object> list) {
  if (list?.isEmpty ?? false) {
    return 'Got nothing';
  }
  return 'Got something';
}

Dart 无法判断该 null 感知 ?. 运算符是否有用。据其所知,你可以将 null 传递给该函数。但在 null 安全的 Dart 中,如果你使用当前不可为 null 的 List 类型注释该函数,那么它知道 list 永远不会为 null。这意味着 ?. 永远不会执行任何有用的操作,你应该只使用 .

为了帮助你简化代码,我们已经针对此类不必要的代码添加了警告,因为静态分析现在足够精确,可以检测到此类代码。在不可为 null 的类型上使用 null 感知运算符,甚至使用类似于 == null!= null 的检查,都会被报告为警告。

当然,这也适用于不可为 null 的类型提升。一旦变量提升为不可为 null 的类型,如果你冗余地再次检查它是否为 null,你就会收到警告。

dart
// Using null safety:
String checkList(List<Object>? list) {
  if (list == null) return 'No list';
  if (list?.isEmpty ?? false) {
    return 'Empty list';
  }
  return 'Got something';
}

你将在此处收到有关 ?. 的警告,因为在它执行时,我们已经知道 list 不能为 null。发出这些警告的目的不仅仅是为了清理无意义的代码。通过删除对 null不必要检查,我们确保剩余的有效检查能够脱颖而出。我们希望你能够查看代码并看到 null 可以流经何处。

使用可空类型

#

我们现在已将 null 归入可为 null 的类型集合。借助流分析,我们可以安全地让一些非 null 值跳过围栏,进入不可为 null 的一侧,然后在那里使用它们。这是一个巨大的进步,但如果我们止步于此,那么最终的系统仍然会非常具有限制性。流分析仅对局部变量、参数和私有 final 字段提供帮助。

为了尽可能多地恢复 Dart 在 null 安全之前具有的灵活性,并在某些地方超越它,我们提供了一些其他新功能。

更智能的 null 感知方法

#

Dart 的 null 感知运算符 ?. 比 null 安全要早得多。运行时语义规定,如果接收器为 null,则会跳过右侧的属性访问,并且表达式求值为 null

dart
// Without null safety:
String notAString = null;
print(notAString?.length);

它不会引发异常,而是打印“null”。null 感知运算符是让可为 null 的类型在 Dart 中可用的一个好工具。虽然我们不允许你在可为 null 的类型上调用方法,但我们允许你在这些类型上使用 null 感知运算符。null 安全之后的程序版本为:

dart
// Using null safety:
String? notAString = null;
print(notAString?.length);

它的工作方式与前一个版本完全相同。

但是,如果您曾经在 Dart 中使用过 null 感知运算符,那么在方法链中使用它们时可能会遇到一些麻烦。假设您想查看一个可能不存在的字符串的长度是否为偶数(我知道这不是一个特别现实的问题,但请配合一下)

dart
// Using null safety:
String? notAString = null;
print(notAString?.length.isEven);

即使此程序使用 ?.,它在运行时仍会引发异常。问题在于,.isEven 表达式的接收器是其左侧整个 notAString?.length 表达式的结果。该表达式求值为 null,因此我们在尝试调用 .isEven 时会得到一个空引用错误。如果您曾经在 Dart 中使用过 ?.,您可能会通过艰难的方式了解到,在使用它一次后,您必须将 null 感知运算符应用于链中的每个属性或方法

dart
String? notAString = null;
print(notAString?.length?.isEven);

这很烦人,但更糟糕的是,它会隐藏重要信息。考虑

dart
// Using null safety:
showGizmo(Thing? thing) {
  print(thing?.doohickey?.gizmo);
}

这里有一个问题:Thing 上的 doohickey getter 可以返回 null 吗?看起来可以,因为您在结果上使用了 ?.。但可能只是第二个 ?. 仅用于处理 thingnull 的情况,而不是 doohickey 的结果。您无法判断。

为了解决这个问题,我们借鉴了 C# 设计相同功能的一个巧妙想法。当您在方法链中使用 null 感知运算符时,如果接收器求值为 null,那么方法链的整个其余部分将短路并跳过。这意味着如果 doohickey 具有非空可返回类型,那么您可以且应该编写

dart
// Using null safety:
showGizmo(Thing? thing) {
  print(thing?.doohickey.gizmo);
}

事实上,如果您不这样做,您将在第二个 ?. 上收到不必要的代码警告。如果您看到类似这样的代码

dart
// Using null safety:
showGizmo(Thing? thing) {
  print(thing?.doohickey?.gizmo);
}

那么您肯定知道 doohickey 本身具有可空返回类型。每个 ?. 对应于一个唯一路径,该路径可能导致 null 流入方法链。这使得方法链中的 null 感知运算符既更简洁又更精确。

趁此机会,我们添加了几个其他 null 感知运算符

dart
// Using null safety:

// Null-aware cascade:
receiver?..method();

// Null-aware index operator:
receiver?[index];

没有 null 感知函数调用运算符,但您可以编写

dart
// Allowed with or without null safety:
function?.call(arg1, arg2);

非空断言运算符

#

使用流分析将可空变量移动到非空侧的优点在于,这样做是可证明安全的。您可以在先前可空的变量上调用方法,而不会放弃非空类型的任何安全性或性能。

但许多可空类型的有效用法无法被证明以静态分析满意的方式是安全的。例如

dart
// Using null safety, incorrectly:
class HttpResponse {
  final int code;
  final String? error;

  HttpResponse.ok()
      : code = 200,
        error = null;
  HttpResponse.notFound()
      : code = 404,
        error = 'Not found';

  @override
  String toString() {
    if (code == 200) return 'OK';
    return 'ERROR $code ${error.toUpperCase()}';
  }
}

如果您尝试运行此代码,您将在调用 toUpperCase() 时收到一个编译错误。error 字段是可空的,因为它在成功的响应中没有值。我们可以通过检查类来看到,当 errornull 时,我们从不访问 error 消息。但这需要理解 code 的值与 error 的可空性之间的关系。类型检查器看不到这种联系。

换句话说,我们这些代码的人类维护者知道在使用 error 时它不会为 null,我们需要一种方法来断言这一点。通常,您使用 as 转换来断言类型,您也可以在这里执行相同操作

dart
// Using null safety:
String toString() {
  if (code == 200) return 'OK';
  return 'ERROR $code ${(error as String).toUpperCase()}';
}

如果转换失败,将 error 转换为非空 String 类型将抛出运行时异常。否则,它会给我们一个非空字符串,然后我们可以在其上调用方法。

“消除空值转换”出现得足够频繁,以至于我们有了新的简写语法。后缀感叹号 (!) 取左侧表达式并将其转换为其底层非空类型。因此,上述函数等效于

dart
// Using null safety:
String toString() {
  if (code == 200) return 'OK';
  return 'ERROR $code ${error!.toUpperCase()}';
}

当底层类型冗长时,这个单字符“感叹号运算符”特别方便。仅仅为了从某个类型中消除单个 ? 而必须编写 as Map<TransactionProviderFactory, List<Set<ResponseFilter>>> 确实很烦人。

当然,与任何转换一样,使用 ! 会带来静态安全性损失。必须在运行时检查转换以保持健全性,并且它可能会失败并抛出异常。但您可以控制在何处插入这些转换,并且可以通过查看代码随时看到它们。

延迟变量

#

类型检查器无法证明代码安全性的最常见位置是顶级变量和字段。这是一个例子

dart
// Using null safety, incorrectly:
class Coffee {
  String _temperature;

  void heat() { _temperature = 'hot'; }
  void chill() { _temperature = 'iced'; }

  String serve() => _temperature + ' coffee';
}

main() {
  var coffee = Coffee();
  coffee.heat();
  coffee.serve();
}

此处,在 serve() 之前调用 heat() 方法。这意味着在使用 _temperature 之前,它将被初始化为非空值。但静态分析无法确定这一点。(对于像这样的简单示例,这可能是可能的,但尝试跟踪类的每个实例的状态的一般情况是棘手的。)

由于类型检查器无法分析字段和顶级变量的用法,因此它有一个保守规则,即非空字段必须在其声明时初始化(或对于实例字段,在构造函数初始化列表中)。因此,Dart 会针对此类报告编译错误。

您可以通过使字段可空,然后对用法使用空断言运算符来修复错误

dart
// Using null safety:
class Coffee {
  String? _temperature;

  void heat() { _temperature = 'hot'; }
  void chill() { _temperature = 'iced'; }

  String serve() => _temperature! + ' coffee';
}

这很好用。但它向类的维护者发送了一个令人困惑的信号。通过将 _temperature 标记为可空,您暗示 null 是该字段的有用、有意义的值。但这不是目的。_temperature 字段永远不应该以其 null 状态被观察到

为了处理具有延迟初始化的状态的常见模式,我们添加了一个新修饰符 late。您可以像这样使用它

dart
// Using null safety:
class Coffee {
  late String _temperature;

  void heat() { _temperature = 'hot'; }
  void chill() { _temperature = 'iced'; }

  String serve() => _temperature + ' coffee';
}

请注意,_temperature 字段具有非空类型,但未初始化。此外,在使用它时没有明确的空断言。你可以将一些模型应用于 late 的语义,但我认为它就像这样:late 修饰符表示“在运行时而不是在编译时强制执行此变量的约束”。它几乎就像“late”一词描述了它何时强制执行变量的保证。

在这种情况下,由于该字段肯定未初始化,因此每次读取该字段时,都会插入一个运行时检查以确保已为其分配了一个值。如果没有,则会引发异常。为变量指定类型 String 表示“你永远不应该看到我具有字符串以外的值”,而 late 修饰符表示“在运行时验证”。

在某些方面,late 修饰符比使用 ? 更“神奇”,因为对该字段的任何使用都可能失败,并且在使用站点没有文本可见的内容。但你确实必须在声明中编写 late 以获取此行为,我们相信在那里看到修饰符足以使其可维护。

作为回报,你比使用可空类型获得了更好的静态安全性。因为该字段的类型现在是非空的,所以尝试将 null 或可空 String 赋值给该字段是一个编译错误。late 修饰符允许你延迟初始化,但仍然禁止你像对待可空变量一样对待它。

延迟初始化

#

late 修饰符还有一些其他特殊功能。这可能看起来自相矛盾,但你可以在具有初始化程序的字段上使用 late

dart
// Using null safety:
class Weather {
  late int _temperature = _readThermometer();
}

当你这样做时,初始化程序将变为惰性。它不会在实例构造后立即运行,而是延迟并且在首次访问该字段时惰性运行。换句话说,它的工作方式与顶级变量或静态字段上的初始化程序完全相同。当初始化表达式代价高昂且可能不需要时,这会很方便。

当你对实例字段使用 late 时,惰性运行初始化程序会为你提供额外的奖励。通常,实例字段初始化程序无法访问 this,因为在所有字段初始化程序完成之前,你无法访问新对象。但对于 late 字段,情况不再如此,因此你可以访问 this、调用方法或访问实例上的字段。

延迟 final 变量

#

你还可以将 latefinal 结合使用

dart
// Using null safety:
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 的大部分功能空间。如果你想要一点局部惰性求值,甚至可以在局部变量上使用它。

必需命名参数

#

为了保证你永远不会看到具有非空类型的 null 参数,类型检查器要求所有可选参数要么具有空类型,要么具有默认值。如果你想拥有一个具有非空类型且没有默认值的命名参数,该怎么办?这意味着你希望要求调用者始终传递它。换句话说,你想要一个命名但不是可选的参数。

我用这个表格可视化了各种类型的 Dart 参数

             mandatory    optional
            +------------+------------+
positional  | f(int x)   | f([int x]) |
            +------------+------------+
named       | ???        | f({int x}) |
            +------------+------------+

由于不明原因,Dart 长期以来支持此表格的三个角,但将命名 + 必填的组合留空。有了空安全性,我们就填补了这一点。你可以通过在参数前放置 required 来声明一个必需的命名参数

dart
// Using null safety:
function({int? a, required int? b, int? c, required int? d}) {}

在这里,所有参数都必须按名称传递。参数 ac 是可选的,可以省略。参数 bd 是必需的,必须传递。请注意,必需性与可空性无关。你可以具有空类型的必需命名参数,以及非空类型的可选命名参数(如果它们具有默认值)。

这是我认为让 Dart 变得更好的另一个特性,无论空安全性如何。它只是让这门语言对我来说感觉更完整。

抽象字段

#

Dart 的一个简洁特性是它坚持一个称为 统一访问原则 的东西。从人的角度来说,这意味着字段与 getter 和 setter 没有区别。某个 Dart 类中的“属性”是计算还是存储的,这是一个实现细节。因此,在使用抽象类定义接口时,通常使用字段声明

dart
abstract class Cup {
  Beverage contents;
}

目的是用户仅实现该类,而不扩展它。字段语法只是编写 getter/setter 对的更短方法

dart
abstract class Cup {
  Beverage get contents;
  set contents(Beverage);
}

但 Dart 不知道此类永远不会用作具体类型。它将 contents 声明视为一个真实字段。不幸的是,该字段是不可空的,并且没有初始化程序,所以你会得到一个编译错误。

一种解决方法是使用显式抽象 getter/setter 声明,如第二个示例所示。但这有点冗长,所以有了空安全性,我们还增加了对显式抽象字段声明的支持

dart
abstract class Cup {
  abstract Beverage contents;
}

这与第二个示例完全相同。它只是使用给定的名称和类型声明一个抽象 getter 和 setter。

使用可空字段

#

这些新特性涵盖了许多常见模式,并且在大多数情况下使使用 null 变得相当轻松。但即便如此,我们的经验是,空字段仍然可能很困难。在你可以使字段 late 且不可空的情况下,你很幸运。但在许多情况下,你需要检查字段是否有值,这需要使其可空,以便你可以观察 null

既是私有的又是最终的空字段能够类型提升(排除 一些特定原因)。如果你由于某种原因无法使字段私有且最终,你仍然需要一个解决方法。

例如,你可能希望这样做

dart
// Using null safety, incorrectly:
class Coffee {
  String? _temperature;

  void heat() { _temperature = 'hot'; }
  void chill() { _temperature = 'iced'; }

  void checkTemp() {
    if (_temperature != null) {
      print('Ready to serve ' + _temperature + '!');
    }
  }

  String serve() => _temperature! + ' coffee';
}

checkTemp() 内部,我们检查 _temperature 是否为 null。如果不是,我们访问它并最终调用 +。不幸的是,这是不允许的。

基于流的类型提升只能应用于既是私有的又是最终的字段。否则,静态分析无法证明字段的值在检查 null 的点和使用它的点之间不会改变。(请考虑在病理情况下,字段本身可能被子类中的一个 getter 覆盖,该 getter 在第二次调用时返回 null。)

因此,由于我们关心健全性,公共和/或非最终字段不会提升,并且上述方法无法编译。这很烦人。在像这里这样的简单情况下,你最好的办法是在使用字段时加上一个 !。它看起来是多余的,但 Dart 今天或多或少就是这样工作的。

另一种有帮助的模式是先将字段复制到局部变量,然后使用它

dart
// Using null safety:
void checkTemp() {
  var temperature = _temperature;
  if (temperature != null) {
    print('Ready to serve ' + temperature + '!');
  }
}

由于类型提升确实适用于局部变量,所以现在可以正常工作了。如果你需要更改值,请记住存储回字段,而不仅仅是局部变量。

有关处理这些和其他类型提升问题的更多信息,请参阅 修复类型提升失败

可空性和泛型

#

与大多数现代静态类型语言一样,Dart 具有泛型类和泛型方法。它们以一些看似违反直觉但一旦你思考其含义就会有道理的方式与可空性交互。首先是“这种类型是否可空?”不再是一个简单的 yes 或 no 问题。考虑

dart
// Using null safety:
class Box<T> {
  final T object;
  Box(this.object);
}

main() {
  Box<String>('a string');
  Box<int?>(null);
}

Box 的定义中,T 是可空类型还是不可空类型?正如你所看到的,它可以用任何一种类型实例化。答案是 T 是一个潜在可空类型。在泛型类或方法的主体内部,潜在可空类型具有可空类型不可空类型的全部限制。

前者意味着你不能调用它上的任何方法,除了在 Object 上定义的少数方法。后者意味着你必须在使用任何该类型的字段或变量之前对其进行初始化。这可能会使类型参数很难使用。

在实践中,会出现一些模式。在类型参数可以用任何类型实例化的类似集合的类中,你只需要处理限制。在大多数情况下,就像这里的示例一样,这意味着确保你可以在需要使用类型参数的类型值时访问该值。幸运的是,类似集合的类很少调用其元素上的方法。

在你无法访问值的地方,你可以使类型参数的使用变为可空

dart
// Using null safety:
class Box<T> {
  T? object;
  Box.empty();
  Box.full(this.object);
}

请注意 object 声明上的 ?。现在字段具有明确的可空类型,因此可以不初始化它。

当你使类型参数类型像这里一样变为可空 T? 时,你可能需要将可空性强制转换。正确的方法是使用显式的 as T 转换,而不是 ! 运算符

dart
// Using null safety:
class Box<T> {
  T? object;
  Box.empty();
  Box.full(this.object);

  T unbox() => object as T;
}

如果值为 null! 运算符始终抛出异常。但是,如果类型参数已使用可空类型实例化,那么 nullT 的完全有效值

dart
// Using null safety:
main() {
  var box = Box<int?>.full(null);
  print(box.unbox());
}

此程序应在无错误的情况下运行。使用 as T 可以实现这一点。使用 ! 会引发异常。

其他泛型类型有一些限制,限制了可应用的类型参数的种类

dart
// Using null safety:
class Interval<T extends num> {
  T min, max;

  Interval(this.min, this.max);

  bool get isEmpty => max <= min;
}

如果限制为非空,则类型参数也为非空。这意味着您具有非空类型的限制,不能将字段和变量保留为未初始化状态。此处的示例类必须具有初始化字段的构造函数。

作为该限制的回报,您可以调用类型参数类型上声明的任何方法,这些方法在其限制上声明。但是,具有非空限制会阻止泛型类的用户使用空类型参数对其进行实例化。对于大多数类而言,这可能是一个合理的限制。

您还可以使用空限制

dart
// Using null safety:
class Interval<T extends num?> {
  T min, max;

  Interval(this.min, this.max);

  bool get isEmpty {
    var localMin = min;
    var localMax = max;

    // No min or max means an open-ended interval.
    if (localMin == null || localMax == null) return false;
    return localMax <= localMin;
  }
}

这意味着在类的正文中,您可以灵活地将类型参数视为可空,但您也具有空值的限制。除非首先处理空值,否则不能对该类型的变量调用任何内容。在此示例中,我们在局部变量中复制字段,并检查这些局部变量是否为 null,以便在使用 <= 之前,流分析将它们提升为非空类型。

请注意,空限制不会阻止用户使用非空类型实例化类。空限制表示类型参数可以为空,而不是必须为空。(事实上,如果您不编写 extends 子句,则类型参数的默认限制是空限制 Object?。)没有办法要求空类型参数。如果您希望类型参数的使用可靠地为空,并隐式初始化为 null,则可以在类的正文中使用 T?

核心库更改

#

语言中还有其他一些调整,但它们很小。例如,没有 on 子句的 catch 的默认类型现在是 Object,而不是 dynamic。switch 语句中的穿透分析使用新的流分析。

真正对您重要的其余更改在核心库中。在我们踏上 Grand Null Safety Adventure 之前,我们担心最终没有办法在不严重破坏世界的情况下使我们的核心库实现空安全。结果并非如此可怕。确实有一些重大更改,但大多数情况下,迁移都很顺利。大多数核心库要么不接受 null 并自然迁移到非空类型,要么接受 null 并使用空类型优雅地接受它。

不过,有一些重要的角落

Map 索引运算符可为空

#

这实际上并不是一个改变,而是一个需要知道的事情。Map 类上的索引 [] 运算符在键不存在时返回 null。这意味着该运算符的返回类型必须为空:V? 而不是 V

当键不存在时,我们可以更改该方法以引发异常,然后为其提供一个更易于使用的非空返回类型。但是,根据我们的分析,使用索引运算符并检查 null 以查看键是否不存在的代码非常常见,约占所有用法的 1/2。破坏所有这些代码会让 Dart 生态系统陷入困境。

相反,运行时行为是相同的,因此返回类型必须为空。这意味着通常不能立即使用映射查找的结果

dart
// Using null safety, incorrectly:
var map = {'key': 'value'};
print(map['key'].length); // Error.

这会在尝试对可空字符串调用 .length 时导致编译错误。在您知道键存在的情况下,您可以使用 ! 来教类型检查器

dart
// Using null safety:
var map = {'key': 'value'};
print(map['key']!.length); // OK.

我们考虑为 Map 添加另一个方法,该方法将为您执行此操作:查找键,如果未找到则抛出,否则返回非空值。但如何称呼它?没有名称比单字符 ! 更短,也没有任何方法名称比在调用站点直接看到具有其内置语义的 ! 更清晰。因此,访问地图中已知存在元素的惯用方法是使用 []!。您会习惯它的。

没有未命名 List 构造函数

#

List 上的未命名构造函数创建一个具有给定大小的新列表,但不初始化任何元素。如果您创建了一个非空类型列表,然后访问了一个元素,这会在健全性保证中戳一个非常大的洞。

为避免这种情况,我们完全删除了构造函数。即使使用可空类型,在空安全代码中调用 List() 也是一个错误。这听起来很可怕,但实际上大多数代码使用列表文字、List.filled()List.generate() 创建列表,或作为转换其他集合的结果。对于您想要创建某种类型的空列表的边缘情况,我们添加了一个新的 List.empty() 构造函数。

在 Dart 中创建完全未初始化列表的模式一直感觉格格不入,现在更是如此。如果您的代码因此而中断,您可以随时使用多种其他方法来生成列表来修复它。

无法对不可空列表设置较大的长度

#

这一点鲜为人知,但 List 上的 length getter 还具有相应的setter。您可以将长度设置为较短的值来截断列表。您还可以将其设置为更长的长度,以使用未初始化的元素填充列表。

如果您对非空类型的列表执行此操作,则稍后访问这些未写入的元素时会违反健全性。为了防止这种情况,如果(并且仅当)列表具有非空元素类型并且您将其设置为更长的长度时,length setter 将抛出运行时异常。截断所有类型的列表仍然很好,并且您可以扩展可空类型的列表。

如果您定义自己的扩展 ListBase 或应用 ListMixin 的列表类型,这会产生一个重要后果。这两种类型都提供了 insert() 的实现,该实现以前通过设置长度为插入的元素腾出空间。这在 null 安全性方面会失败,因此我们更改了 ListMixinListBase 共享)中 insert() 的实现,改为调用 add()。如果您想使用继承的 insert() 方法,您的自定义列表类应该提供 add() 的定义。

无法在迭代之前或之后访问 Iterator.current

#

Iterator 类是可变的“游标”类,用于遍历实现 Iterable 的类型的元素。在访问任何元素以推进到第一个元素之前,您应该调用 moveNext()。当该方法返回 false 时,您已到达末尾,并且没有更多元素。

过去,如果您在第一次调用 moveNext() 之前或在迭代完成后调用 current,它会返回 null。使用 null 安全性,这将要求 current 的返回类型为 E?,而不是 E。反过来,这意味着每次元素访问都需要运行时 null 检查。

鉴于几乎没有人以这种错误的方式访问当前元素,这些检查将毫无用处。相反,我们已将 current 的类型设为 E。由于在迭代之前或之后可能有该类型的可用值,因此如果您在不应该调用时调用迭代器,我们已将迭代器的行为保留为未定义。大多数 Iterator 实现会抛出 StateError

摘要

#

这是一次非常详细的关于 null 安全性周围所有语言和库更改的巡览。这是很多东西,但这是一个非常大的语言更改。更重要的是,我们希望到达一个点,即 Dart 仍然感觉有凝聚力和可用性。这不仅需要更改类型系统,还需要更改围绕它的许多其他可用性功能。我们不希望它感觉像是 null 安全性被附加了。

要带走的核心点是

  • 默认情况下,类型不可为 null,并且通过添加 ? 使其可为 null。

  • 可选参数必须可为 null 或具有默认值。您可以使用 required 使命名参数不可选。不可为 null 的顶级变量和静态字段必须具有初始化器。不可为 null 的实例字段必须在构造函数体开始之前初始化。

  • 如果接收器为 null,则 null 感知运算符之后的函数链路将短路。有新的 null 感知级联 (?..) 和索引 (?[]) 运算符。后缀 null 断言“感叹号”运算符 (!) 将其可空操作数强制转换为基础的非空类型。

  • 流分析使您可以安全地将可空局部变量和参数(以及 Dart 3.2 中的私有 final 字段)转换为可用的非空变量。新的流分析还具有更智能的类型提升、缺失的返回、不可达代码和变量初始化规则。

  • late 修饰符使您可以在原本可能无法使用非空类型和 final 的地方使用它们,但代价是运行时检查。它还为您提供延迟初始化的字段。

  • List 类已更改为防止未初始化的元素。

最后,一旦您吸收了所有这些内容并将代码带入空安全的世界,您将获得一个健全的程序,编译器可以对其进行优化,并且代码中会显示可能发生运行时错误的每个位置。我们希望您觉得这值得为此付出努力。