内容

了解空安全

作者:Bob Nystrom
2020 年 7 月

空安全是我们自 Dart 2.0 中用 健全的静态类型系统 替换原始的不健全可选类型系统以来对 Dart 做出的最大改动。Dart 首次发布时,编译时空安全是一个需要冗长介绍的罕见特性。如今,Kotlin、Swift、Rust 和其他语言都有自己针对这一问题的答案,而这个问题已变得非常 普遍。以下是一个示例

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

void main() {
  isEmpty(null);
}

如果您在没有空安全的情况下运行此 Dart 程序,它会在调用 .length 时抛出 NoSuchMethodError 异常。null 值是 Null 类的实例,而 Null 没有“length”获取器。运行时故障很糟糕。对于像 Dart 这样旨在在最终用户设备上运行的语言来说尤其如此。如果服务器应用程序发生故障,您通常可以在任何人注意到之前重新启动它。但是,当 Flutter 应用在用户的手机上崩溃时,他们不会感到高兴。当您的用户不高兴时,您也不会高兴。

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

这个问题没有唯一的正确解决方案。Rust 和 Kotlin 都采用了适合其语言环境的方法。本文档介绍了我们针对 Dart 的解决方案的所有细节。它包括对静态类型系统的更改以及一系列其他修改和新的语言特性,不仅可以让您编写空安全的代码,而且希望让您享受编写空安全的代码。

本文档很长。如果您想要更简短的内容,只需涵盖您入门所需的内容,请从 概述 开始。当您准备好深入了解并有时间时,请返回此处,以便您了解语言如何处理null、我们为何这样设计它以及如何编写惯用、现代、空安全的 Dart。(剧透警告:最终结果与您今天编写 Dart 的方式惊人地相似。)

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

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

    换句话说,我们不会给您救生衣,然后让您每次下水时都记得穿上它。相反,我们为您提供一艘不会沉没的船。除非您跳船,否则您会保持干燥。

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

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

    健全性对于用户信心很重要。一艘大部分漂浮的船并不是您渴望在公海上航行的船。但它对我们勇敢的编译器黑客也很重要。当语言对程序的语义属性做出硬性保证时,这意味着编译器可以执行假设这些属性为真的优化。在null方面,这意味着我们可以生成更小的代码,从而消除不必要的null检查,以及更快的代码,而无需在调用对象上的方法之前验证接收者是否为非null

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

请注意,消除null并不是目标。null本身并没有错。相反,能够表示值的不存在非常有用。在语言中直接构建对特殊“不存在”值的支持使处理不存在变得灵活且可用。它是可选参数、方便的?.空感知运算符和默认初始化的基础。不是null不好,而是null出现在您不期望的地方会导致问题。

因此,使用空安全,我们的目标是让您控制了解null如何在您的程序中流动,并确信它不会流向会导致崩溃的地方。

类型系统中的可空性

#

空安全从静态类型系统开始,因为其他所有内容都依赖于它。您的 Dart 程序包含一个完整的类型世界:像intString这样的基本类型、像List这样的集合类型,以及您和您使用的包定义的所有类和类型。在空安全之前,静态类型系统允许值null流入这些类型的任何表达式的中。

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

Null Safety Hierarchy Before

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

不可空类型和可空类型

#

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

Null Safety Hierarchy After

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

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

dart
// Using null safety:
void 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:
void bad(String? maybeString) {
  print(maybeString.length);
}

void main() {
  bad(null);
}

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

它们如何与不可为空类型交互?将不可为空类型传递给期望可为空类型的类型始终是安全的。如果一个函数接受String?,则传递String是允许的,因为它不会导致任何问题。我们通过使每个可为空类型成为其底层类型的超类型来建模这一点。您还可以安全地将null传递给期望可为空类型的类型,因此Null也是每个可为空类型的子类型。

Nullable

但是反过来,将可为空类型传递给期望底层不可为空类型的类型是不安全的。期望String的代码可能会在该值上调用String方法。如果您将String?传递给它,则null可能会传入,这可能会失败。

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

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

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

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

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

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

这使得对requireStringNotNull()的调用产生编译错误,这就是您想要的。但这也意味着所有隐式向下转换都将成为编译错误,包括对requireStringNotObject()的调用。您必须自己添加显式向下转换。

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

void 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);
}

发现错误了吗?.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;
}

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

用于不可达代码的 Never

#

您还可以编程此可达性分析。新的底部类型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。它已被提升为Point,即使函数没有任何returnthrow。控制流分析知道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()。但是,因为我们在一个确保值不为nullif语句中保护了该调用,所以 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。但是我们也可以使用相同的分析来检测您不需要的代码。在空安全之前,如果您编写了类似以下内容

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

Dart 无法知道该空感知?.运算符是否有用。就它而言,您可以将null传递给该函数。但在空安全的 Dart 中,如果您使用现在非空的List类型对该函数进行了注释,那么它就知道list永远不会为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值跳过栅栏到非空侧,在那里我们可以使用它们。这是一大步,但如果我们止步于此,则生成的系统仍然非常严格。流分析仅对局部变量、参数和私有 final 字段有帮助。

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

更智能的空感知方法

#

Dart 的空感知运算符?.比空安全要古老得多。运行时语义指出,如果接收方为null,则右侧的属性访问将被跳过,并且表达式的值为null

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

它不会抛出异常,而是打印“null”。空感知运算符是使可空类型在 Dart 中可用的好工具。虽然我们不允许您在可空类型上调用方法,但我们确实允许您在它们上使用空感知运算符。该程序的空安全后版本为

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

它的工作原理与前一个相同。

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

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

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

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,则方法链的其余部分将短路并跳过。这意味着如果doohickey具有非空返回类型,则您可以并且应该编写

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

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

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

那么您可以确定它意味着doohickey本身具有可为空的返回类型。每个?.对应于可能导致null流入方法链的唯一路径。这使得方法链中的空感知运算符更加简洁和精确。

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

dart
// Using null safety:

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

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

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

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

这个单字符的“bang 运算符”在底层类型冗长时特别方便。如果只是为了从某些类型中强制转换单个?,而不得不写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';
}

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

在这里,heat()方法在serve()之前调用。这意味着_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

既是私有的又是最终的(final)的可空字段能够进行类型提升(除了某些特定原因)。如果您由于某种原因无法将字段设为私有且最终的,则仍然需要变通方案。

例如,您可能期望这段代码能够正常工作

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 具有泛型类和泛型方法。它们与可空性的交互方式有几种看似违反直觉,但一旦您考虑其含义就会变得有道理。首先是“此类型是否可空?”不再是一个简单的“是”或“否”问题。考虑一下

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

void 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:
void 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语句中的贯穿分析使用新的流分析。

真正对您有影响的其余更改位于核心库中。在我们开始伟大的空安全冒险之前,我们担心最终可能无法使我们的核心库空安全,而不会严重破坏世界。事实证明情况并非如此糟糕。确实一些重大的变化,但总的来说,迁移进行得很顺利。大多数核心库要么不接受null并自然地迁移到不可空类型,要么接受并优雅地使用可空类型接受它。

不过,有一些重要的角落情况

Map 索引运算符是可空的

#

这并不是真正的变化,而更多的是需要知道的事情。Map类的索引[]运算符在键不存在时返回null。这意味着该运算符的返回类型必须是可空的:V?而不是V

我们本可以将该方法更改为在键不存在时抛出异常,然后为其提供一个更易于使用的不可空返回类型。但是,使用索引运算符并检查null以查看键是否存在代码非常普遍,根据我们的分析,大约有一半的使用情况属于这种情况。破坏所有这些代码将引发 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()的实现,该实现以前通过设置长度为插入的元素腾出空间。这在空安全下会失败,因此我们改为将ListMixinListBase共享)中的insert()实现更改为改为调用add()。如果您希望能够使用继承的insert()方法,则您的自定义列表类应提供add()的定义。

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

#

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

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

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

总结

#

这是一次非常详细的关于空安全相关的语言和库变更的介绍。内容很多,因为这是一个相当大的语言变更。更重要的是,我们希望 Dart 仍然保持一致性和易用性。这不仅需要更改类型系统,还需要更改围绕它的许多其他可用性特性。我们不希望空安全感觉像是被硬塞进去的。

需要重点了解的要点是

  • 类型默认情况下是非空的,通过添加?使其可为空。

  • 可选参数必须可为空或具有默认值。您可以使用required使命名参数成为必填参数。非空顶级变量和静态字段必须具有初始化程序。非空实例字段必须在构造函数体开始之前进行初始化。

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

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

  • late 修饰符允许您在某些情况下使用非空类型和 final,否则您可能无法使用,但需要以运行时检查为代价。它还为您提供延迟初始化的字段。

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

最后,一旦您吸收了所有这些内容并将代码引入空安全的世界,您将获得一个健全的程序,编译器可以对其进行优化,并且代码中可以出现运行时错误的每个位置都可见。我们希望您认为达到这一点是值得的努力。