rakulang, dartlang, nimlang, golang, rustlang, lang lang no see

Understanding Null Safety

焉知非鱼

Understanding Null Safety

空值安全是我们在 Dart 2.0 中用健全的静态类型系统取代了原来的不健全的可选类型系统后,对 Dart 做出的最大改变。当 Dart 刚推出的时候,编译时空安全是一个罕见的功能,需要长时间的介绍。今天,Kotlin、Swift、Rust 和其他语言都有自己的答案,这已经成为一个非常熟悉的问题。下面是一个例子。

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

main() {
  isEmpty(null);
}

如果你在没有 null 安全的情况下运行这个 Dart 程序,它就会在调用.length 时抛出一个 NoSuchMethodError 异常。null 值是 Null 类的一个实例,而 Null 没有 “length” getter。运行时的失败很糟糕。这在像 Dart 这样的语言中尤其如此,因为它被设计成在终端用户的设备上运行。如果一个服务器应用程序失败了,你通常可以在任何人注意到之前重新启动它。但是当一个 Flutter 应用在用户的手机上崩溃时,他们并不高兴。当你的用户不高兴时,你也不高兴。

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

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

这个文档很长。如果你想看一些较短的文件,它只涵盖了你需要知道的东西,以便开始运行,请从概述开始。当你准备好了更深入的理解,并且有时间的时候,请回到这里,这样你就可以理解这个语言是如何处理 null 的,为什么我们要这样设计它,以及如何写出习惯的,现代的,空值安全的 Dart。(Spoiler alert: 它最终会出人意料地接近你今天写 Dart 的方式。)

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

  • 代码默认情况下应该是安全的。如果你写了新的 Dart 代码,并且没有使用任何显式的不安全特性,它永远不会在运行时抛出一个空值引用错误。所有可能的空值引用错误都会被静态地捕获。如果你想将一些检查推迟到运行时以获得更大的灵活性,你可以,但你必须通过使用一些在代码中文本可见的功能来选择。

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

  • 空值安全代码应该很容易写。大多数现有的 Dart 代码都是动态正确的,不会出现空值引用错误。你喜欢你的 Dart 程序现在的样子,我们希望你能够继续这样写代码。安全性不应该要求牺牲可用性,对类型检查器进行忏悔,或者必须显著改变你的思维方式。

由此产生的空安全代码应该是完全健全的。在静态检查的上下文中,“健全"对不同的人意味着不同的东西。对我们来说,在空值安全的上下文中,这意味着如果一个表达式的静态类型不允许空,那么该表达式的任何可能的执行都不可能评估为空。语言主要通过静态检查来提供这种保证,但也可以涉及一些运行时检查。虽然,注意第一个原则:任何发生这些运行时检查的地方都将是你的选择)。

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

有一个注意事项:我们只保证完全空值安全的 Dart 程序的健全性。Dart 支持包含新的空值安全代码和旧的遗留代码混合的程序。在这些混合版本的程序中,仍然可能发生空值引用错误。在一个混合版本的程序中,你可以在空值安全的部分获得所有的静态安全优势,但是在整个应用程序是空安全的之前,你不能获得完整的运行时健全性。

请注意,消除 null 并不是一个目标。null 没有什么不好。相反,能够表示一个值的缺失真的很有用。直接在语言中构建对特殊的 “absence” 值的支持,使得处理缺失的工作变得灵活和可用。它是可选参数、方便的 ?. null-aware 操作符和默认初始化的基础。并不是 null 不好,而是让 null 去了你想不到的地方才会引起问题。

因此,有了 null 安全,我们的目标是让你控制和洞察 null 可以流经你的程序的地方,并确定它不能流到某个地方,从而导致崩溃。

类型系统中的空值 #

空值安全始于静态类型系统,因为其他一切都建立在静态类型系统之上。你的 Dart 程序中有一个完整的类型宇宙:像 int 和 String 这样的基元类型,像 List 这样的集合类型,以及所有你和你使用的包所定义的类和类型。在 null 安全之前,静态类型系统允许值 null 流入任何这些类型的表达式中。

在类型理论的行话中,Null 类型被视为所有类型的一个子类型。

img

在某些表达式上允许的操作集 - getters、setters、methods 和 operator - 由其类型定义。如果类型是 List,你可以对它调用. add()[]。如果它是 int,你可以调用 +。但是空值并没有定义任何这些方法。允许 null 流入其他类型的表达式意味着任何这些操作都可能失败。这就是 null 引用错误的真正症结所在 - 每一次失败都来自于试图在 null 上查找一个它没有的方法或属性。

非可空类型和可空类型 #

Null 安全通过改变类型层次结构,从根本上消除了这个问题。Null 类型仍然存在,但它不再是所有类型的子类型。取而代之的是,类型层次结构是这样的。

img

由于 Null 不再是子类型, 除了特殊的 Null 类之外,没有任何类型允许值为 null。我们已经将所有类型默认为不可空值。如果你有一个 String 类型的变量,它将总是包含一个字符串。在那里,我们已经修复了所有的空值引用错误。

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

// Using null safety:
makeCoffee(String coffee, [String? dairy]) {
  if (dairy != null) {
    print('$coffee with $dairy');
  } else {
    print('Black $coffee');
  }
}

在这里,我们希望允许 dairy 参数接受任何字符串,或者接受 null 值,而不接受其他任何值。为了表达这一点,我们在底层基类型 String 的结尾处加上 ?。 从本质上讲,这就是定义了一个底层类型和 Null 类型的联合。所以,如果 Dart 有全功能的联合类型,那么 String? 将是 String|Null 的简写。

使用可空类型 #

如果你有一个可空类型的表达式,你可以用这个结果做什么?由于我们的原则默认是安全的,所以答案是不多,我们不能让你对它调用底层类型的方法,因为如果值是空的,这些方法可能会失败。

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

main() {
  bad(null);
}

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

它们如何与非可空类型交互?将一个不可空值类型传递给期望空值类型的东西总是安全的。如果一个函数接受 String 吗,那么传递一个 String 是允许的,因为它不会引起任何问题。我们通过使每个可空类型成为其底层类型的超类型来建立模型。你也可以安全地把 null 传给期望是可空类型的东西,所以 Null 也是每个可空类型的一个子类型。

img

但是反过来说,把一个可空类型传递给期待底层非可空类型的东西是不安全的。期待一个 String 的代码可以在值上调用 String 方法。如果你把一个 String? 传给它,null 可能会流进来,这可能会失败。

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

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

这个程序不安全,我们不应该允许它。然而,Dart 一直有这个东西,叫做隐式下传。例如,如果你把一个 Object 类型的值传递给一个期望为 String 的函数,类型检查器就会允许它。

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

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

为了保持合理性,编译器在 requireStringNotObject() 的参数上默默地插入了一个 as String cast。这个转码可能会在运行时失败并抛出一个异常,但在编译时,Dart 说这是确定的。由于非可空类型被建模为可空类型的子类型,所以隐式下投会让你把一个 String? 传递给期待一个 String 的东西。允许这样做会违反我们默认安全的目标。所以,有了空值安全,我们就完全取消了隐式下传。

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

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

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

我们认为这总体上是一个好的变化。在我们的印象中,大多数用户从来都不喜欢隐性降频。尤其是,你可能之前就被这个烧过。

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

发现错误了吗?.where() 方法是懒惰的,所以它返回的是一个 Iterable,而不是 List。这个程序在编译时,当它试图将 Iterable 投射到 filterEvens 声明它返回的 List 类型时,会在运行时抛出一个异常。移除隐式下投后,这就变成了一个编译错误。

我们说到哪里了?对了,好吧,就好像我们把你程序中的类型宇宙分成了两半。

img

有一个非空值类型的区域。这些类型让你可以访问所有有趣的方法,但永远不能包含 null。然后是一个由所有相应的可空类型组成的平行家族。这些类型允许 null,但你不能对它们做太多事情。我们让值从非可空侧流向可空侧,因为这样做是安全的,但不是其他方向。

这样看来,可空类型基本上是无用的。它们没有方法,你无法摆脱它们。别担心,我们有一整套的功能来帮助你把值从可空型的一半移到另一边,我们很快就会讲到。

顶部和底部 这一部分有点深奥。你可以跳过它,除了最后的两个子弹,除非你对类型系统感兴趣。想象一下,在你的程序中,所有的类型之间都有边缘,它们是彼此的子类型和超类型。如果你把它画出来,就像这个文档中的图一样,它将形成一个巨大的有向图,上面有像 Object 这样的超类型,下面有像你自己的类型这样的叶子类。

如果这个有向图到了顶部,有一个单一的类型是超类型(直接或间接),这个类型就被称为顶部类型。同样,如果在那个底部有一个奇怪的类型是每个类型的子类型,你就有一个底部类型。在这种情况下,你的有向图是一个网格)。

如果你的类型系统有顶层和底层类型,那是很方便的,因为这意味着像最小上界这样的类型级操作(类型推理使用它来根据一个条件表达式的两个分支的类型找出它的类型)总是可以产生一个类型。在 null 安全之前,Object 是 Dart 的顶层类型,Null 是其底层类型。

由于现在 Object 是不可空的,所以它不再是顶类型。Null 不是它的子类型。Dart 没有命名的顶类型。如果你需要一个顶类型,你要 Object? 同样,Null 也不再是底层类型。如果是的话,一切都还会是 null。相反,我们添加了一个新的底层类型,名为 Never。

img

在实践中,这意味着。

如果你想表明你允许任何类型的值,就用 Object? 而不是 Object. 事实上,使用 Object 就变得很不寻常了,因为该类型意味着 “可能是任何可能的值,除了这个奇怪的禁止值 null”。

在极少数情况下,你需要一个底层类型,用 Never 代替 Null。如果你不知道是否需要底层类型,你可能不需要。

确保正确性 #

我们将类型的宇宙分为可空和不可空的两半。为了保持健全性和我们的原则,即除非你要求,否则你永远不会在运行时得到一个 null 引用错误,我们需要保证 null 永远不会出现在非 nullable 端的任何类型中。

摆脱隐式下传,去掉 Null 这个底层类型,涵盖了类型在程序中跨赋值流转和在函数调用中从参数流转到参数的所有主要地方。剩下的主要的 null 可以潜入的地方是当一个变量第一次出现和离开一个函数的时候。所以会出现一些额外的编译错误。

无效返回 #

如果一个函数的返回类型是非空的,那么通过该函数的每一条路径都必须到达一个返回值的返回语句。在 null 安全之前,Dart 对于缺失返回的情况非常宽松。比如说

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

如果你分析这个,你就会得到一个温柔的提示,也许你忘了一个返回,但如果没有,也没什么大不了的。这是因为如果执行到了函数体的末端,那么 Dart 就会隐式返回 null。由于每个类型都是可空的,所以从技术上讲,这个函数是安全的,尽管它可能不是你想要的。

对于健全的非可空类型,这个程序是完全错误的,不安全的。在空值安全下,如果一个具有非可空值返回类型的函数不能可靠地返回一个值,你会得到一个编译错误。所谓 “可靠”,是指语言分析了所有通过函数的控制流路径。只要它们都能返回一些东西,它就满足了。这个分析是相当聪明的,所以即使这个函数也是可以的。

// 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 初始化变量。这很方便,但如果变量的类型是不可空的,显然是完全不安全的。所以我们必须对不可空值的变量进行严格的规定。

  • 顶级变量和静态字段的声明必须有一个初始化器。由于这些变量可以在程序中的任何地方被访问和赋值,编译器不可能保证变量在被使用之前就已经被赋予了一个值。唯一安全的选择是要求声明本身有一个初始化表达式,产生一个正确类型的值。
// Using null safety:
int topLevel = 0;

class SomeClass {
  static int staticField = 0;
}
  • 实例字段必须在声明时有一个初始化器,使用初始化形式,或者在构造函数的初始化列表中初始化。这有很多行话。下面是例子。
// Using null safety:
class SomeClass {
  int atDeclaration = 0;
  int initializingFormal;
  int initializationList;

  SomeClass(this.initializingFormal)
      : initializationList = 0;
}

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

  • 局部变量是最灵活的情况。一个不可空的局部变量不需要有一个初始化器。这完全可以。
// 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,或者指定一个有效的非空的默认值。

这些限制听起来很繁琐,但在实践中并不太坏。它们与现有的围绕最终变量的限制非常相似,而且你可能已经使用这些限制多年,甚至没有真正注意到。另外,请记住,这些限制只适用于不可空值的变量。你总是可以让类型可空,然后让默认初始化为空。

即便如此,这些规则也会造成摩擦。幸运的是,我们有一套新的语言特性来润滑最常见的模式,在这些新的限制下,你的速度变慢了。不过,首先,是时候谈谈流分析了。

流程分析 #

控制流分析在编译器中已经存在多年。它大多被用户隐藏起来,在编译器优化过程中使用,但一些新的语言已经开始使用同样的技术来实现可见的语言功能。Dart 已经在类型推广的形式下有了一抹流分析。

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

请注意,在标记行中,我们可以在对象上调用 isEmpty。该方法定义在 List 上,而不是 Object 上。这是因为类型检查器会查看程序中所有的 is 表达式和控制流路径。如果某个控制流构造体只有在变量上的某个 is 表达式为真时才会执行,那么在这个构造体里面,变量的类型就会被 “推广 “到测试类型。

在这里的例子中,if 语句的 then 分支只有在 object 实际包含一个 list 时才会运行。因此,Dart 将对象推广到 List 类型,而不是其声明的 Object 类型。这是一个方便的功能,但它是相当有限的。在 null 安全之前,下面的功能相同的程序无法工作。

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

同样,只有当对象包含一个 list 时,才能达到.isEmpty 的调用,所以这个程序是动态正确的。但是类型推广规则不够聪明,没有看到返回语句意味着只有当对象是一个 list 时才能到达第二个语句。

对于空安全,我们把这种有限的分析方法,在几个方面做得更加强大。

可到达性分析 #

首先,我们修复了长期以来的抱怨,即类型推广对早期返回和其他无法到达的代码路径并不聪明。当分析一个函数时,它现在会考虑到返回、break、抛出以及函数中任何其他可能提前终止执行的方式。在空安全下,这个函数。

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

现在是完全有效的。因为 if 语句会在对象不是 List 时退出函数,所以 Dart 会在第二条语句中促进对象成为 List。这是一个非常好的改进,它帮助了很多 Dart 代码,甚至是与 nullability 无关的东西。

Never - 不可达到的代码 #

您也可以对这种可达到性分析进行编程。新的底类型 Never 没有值。(什么样的值同时是 String、bool 和 int 呢?)那么一个表达式具有 Never 类型意味着什么呢?意味着该表达式永远不能成功完成评估。它必须抛出一个异常,中止,或者以其他方式确保期望表达式结果的周围代码永远不会运行。

事实上,根据语言的规定,抛出表达式的静态类型是 Never。Never 类型在核心库中被声明,你可以将其作为类型注释。也许你有一个帮助函数,以方便抛出某种异常。

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

你可以这样使用。

// 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...
}

这个程序分析起来没有错误。请注意,==方法的最后一行访问了其他的.x 和.y。尽管函数没有任何返回或抛出,但它已经被提升为 Point。控制流分析知道,wrongType()的声明类型是 Never,这意味着 if 语句的 then 分支必须以某种方式中止。由于第二条语句只有在 other 是 Point 时才能到达,所以 Dart 提倡使用它。

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

确定赋值分析 #

这个我简单的提到了局部变量。Dart 需要确保一个不可空的局部变量在读取之前总是被初始化。我们使用确定赋值分析来尽可能灵活地处理这个问题。该语言分析每个函数体,并通过所有控制流路径跟踪局部变量和参数的赋值。只要在每一条到达某个使用变量的路径上都对变量进行了赋值,就认为该变量已经初始化。这让你可以在没有初始化器的情况下声明一个变量,然后在之后使用复杂的控制流对其进行初始化,即使该变量具有不可空值的类型。

我们还使用确定赋值分析来使最终变量更加灵活。在空安全之前,如果你需要以任何一种有趣的方式对局部变量进行初始化,那么使用 final 是很困难的。

// 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;
}

这将是一个错误,因为结果变量是 final,但没有初始化器。在空安全下进行更智能的流分析,这个程序是没有问题的。分析可以知道,在每条控制流路径上,result 肯定是精确地初始化了一次,所以标记变量 final 的约束条件是满足的。

对空检查的类型提升 #

更加智能的流程分析帮助了很多 Dart 代码,甚至是与空性无关的代码。但我们现在做这些改变并不是偶然的。我们把类型分为可空性和非可空性集。如果你有一个可空类型的值,你就不能真正对它做任何有用的事情。在值为空的情况下,这种限制是好的。它可以防止你崩溃。

但如果值不是空的,能够把它移到非可空的一面,这样你就可以对它调用方法,这将是一件好事。对于局部变量和参数来说,流程分析是实现这一点的主要方法之一。我们已经扩展了类型提升,还可以查看 == null!= null 表达式。

如果你检查一个可空类型的变量,看看它是不是空的,Dart 就会把这个变量推广到底层的非可空类型。

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

这里,arguments 有一个可空的类型。通常,这禁止你对它调用.join()。但是由于我们在 if 语句中对该调用进行了保护,检查以确保该值不是空的,所以 Dart 将其从 List<String> 提升为 List<String>,并允许你在其上调用方法或将其传递给期望非空值列表的函数。

这听起来是一件相当小的事情,但这种基于流程的对 null 检查的推广是使大多数现有 Dart 代码在 null 安全下工作的原因。大多数 Dart 代码都是动态正确的,并且确实通过在调用方法之前检查空值来避免抛出空值引用错误。新的关于 null 检查的流程分析将这种动态正确性变成了可证明的静态正确性。

当然,它也能配合我们对可到达性的更智能的分析。上面的函数也可以写成一样。

// 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、assignments 或我们即将提到的后缀 ! 操作符的显式投掷也会导致提升。总的目标是,如果代码是动态正确的,而且静态地找出这一点是合理的,分析应该足够聪明。

不必要的代码警告 #

拥有更智能的可达性分析,并知道 null 可能流经你的程序的地方,有助于确保你添加代码来处理 null。但我们也可以用同样的分析来检测你不需要的代码。在 null 安全之前,如果你写了这样的东西。

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

Dart 没有办法知道那个 null-aware? 操作符是否有用。它只知道,你可以把 null 传给函数。但是在 null safe Dart 中,如果你用现在不可空的 List 类型注释了那个函数,那么它知道 list 永远不会是 null。这就意味着这个 ?. 永远不会做任何有用的事情,你可以而且应该只使用 . 类型。

为了帮助你简化你的代码,我们已经为这样的不必要的代码添加了警告,现在静态分析已经精确到可以检测到它了。在一个不可空类型上使用一个 null-aware 操作符,甚至是像== null!= null 这样的检查,都会被报告为一个警告。

当然,这也与非空类型的晋升有关。一旦一个变量被推广到一个不可空类型,如果你再次对它进行多余的 null 检查,你会得到一个警告。

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

你在这里得到了一个警告,因为在它执行的时候,我们已经知道 list 不能为空。这些警告的目的不仅仅是清理无意义的代码。通过删除不需要的 null 检查,我们确保剩下的有意义的检查能够脱颖而出。我们希望您能够查看您的代码,并看到 null 可以在哪里流动。

使用可空类型 #

我们现在已经把 null 收进了可空类型的集合。通过流程分析,我们可以安全地让一些非空值越过栅栏跳到非可空类型的一边,在那里我们可以使用它们。这是一个很大的进步,但如果我们在这里停下来,所产生的系统仍然是痛苦的限制。流程分析只对局部和参数有帮助。

为了尽量恢复 Dart 在 null 安全之前的灵活性–并且在某些地方超越它,我们有一些其他的新特性。

更加智能的空感知方法 #

Dart 的 null aware 操作符 ?. 比 null safety 更早。运行时语义规定,如果接收者为空,那么右侧的属性访问将被跳过,表达式评价为空。

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

这不是抛出一个异常,而是打印 “null”。null-aware 操作符是一个很好的工具,它使可空类型在 Dart 中可用。虽然我们不能让你在可空类型上调用方法,但我们可以也确实让你在它们上使用 null-aware 操作符。空值后安全版本的程序是。

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

它的工作原理和之前的一样。

然而,如果你曾经在 Dart 中使用过 null-aware 操作符,当你在方法链中使用它们时,你可能会遇到一个烦恼。比方说,你想看看一个可能不存在的字符串的长度是否是一个偶数(不是一个特别现实的问题,我知道,但请和我一起工作)。

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

即使这个程序使用了?,但在运行时还是会抛出一个异常。问题在于.isEven 表达式的接收者是它左边的整个 notAString?.length 表达式的结果。该表达式的值为 null,所以我们在尝试调用.isEven 时得到一个空值引用错误。如果你曾经在 Dart 中使用过?.,你可能学到了一个苦涩的方法,那就是在你使用过一次之后,你必须将 null-aware 操作符应用到链中的每个属性或方法。

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

这很烦人,但更糟糕的是,它掩盖了重要信息。考虑一下:

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

我有个问题要问你。Thing 上的 doohickey getter 可以返回 null 吗?看起来可以,因为你在结果上使用了?。但可能只是第二个?.只是为了处理 thing 为 null 的情况,而不是 doohickey 的结果。你无法判断。

为了解决这个问题,我们借鉴了 C#设计相同功能的一个聪明的想法。当你在一个方法链中使用一个 null-aware 操作符时,如果接收者评估为 null,那么整个方法链的其余部分都会被短路并跳过。这意味着如果 doohickey 有一个不可空的返回类型,那么你可以也应该写。

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

事实上,如果你不这样做,你会在第二个?上得到一个不必要的代码警告。如果你看到这样的代码。

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

那么你就可以肯定地知道,这意味着 doohickey 本身有一个可空的返回类型。每一个?对应一个可以导致 null 流入方法链的唯一路径。这使得方法链中的 null-aware 操作符既更简洁又更精确。

在这时,我们又增加了几个其他的空感知操作符。

// Using null safety:

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

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

没有一个 null-aware 函数调用操作符,但你可以写。

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

Null 断言运算符 #

使用流式分析将一个可空型变量移到非可空型变量的伟大之处在于,这样做被证明是安全的。你可以在之前的可空型变量上调用方法,而不会放弃非可空型的任何安全或性能。

但是,可空类型的许多有效用途无法以取悦静态分析的方式证明其安全性。比如说

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

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

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

如果你尝试运行这个,你在调用 toUpperCase()时得到一个编译错误。错误字段是可空的,因为它在成功的响应中不会有一个值。我们通过检查类可以看到,当错误信息为空时,我们永远不会访问它。但这需要理解代码的值和错误的可空性之间的关系。类型检查器是看不到这种联系的。

换句话说,我们这些代码的人类维护者知道错误在我们使用它的时候不会是空的,我们需要一种方法来断言这一点。通常情况下,你使用 as cast 来断言类型,在这里你也可以做同样的事情。

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

如果投递失败,将错误地投递到不可空的 String 类型,会抛出一个运行时异常。否则,它将为我们提供一个非空值字符串,我们可以在其上调用方法。

“投弃可空性 “经常出现,以至于我们有了一种新的速记语法。一个后缀的感叹号 (!) 将左边的表达式并将其投射到其底层的不可空类型上。所以上面的函数相当于

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

当底层类型是啰嗦的时候,这个单字符的 “bang 操作符 “特别方便。如果仅仅为了从某个类型中投弃一个单一的?,而不得不写成 Map<TransactionProviderFactory, List<Set>,那就真的很烦人了。

当然,就像任何投射一样,使用 !的同时也会损失静态安全。必须在运行时检查投射以保持合理性,而且可能会失败并抛出一个异常。但是你可以控制这些转码被插入的位置,你可以通过查看你的代码随时看到它们。

Late 变量 #

类型检查器不能证明代码安全的最常见的地方是围绕顶层变量和字段。下面是一个例子。

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

这里,heat()方法是在服务()之前调用的。这意味着 _temperature 在使用之前会被初始化为一个非空值。但是静态分析来确定这一点是不可行的。(对于像这个微不足道的例子来说可能是可行的,但是一般情况下,试图跟踪一个类的每一个实例的状态是难以解决的。)。

因为类型检查器不能分析字段和顶层变量的用途,它有一个保守的规则,即不可空值字段必须在声明时初始化(或者在实例字段的构造函数初始化列表中)。所以 Dart 在这个类上报告了一个编译错误。

你可以通过使字段可空,然后在用途上使用 null 断言操作符来修复这个错误。

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

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

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

这样做很好,但它给类的维护者发出了一个混乱的信号。但它给类的维护者发出了一个混乱的信号。通过标记 _temperature 为 nullable,你暗示 null 对于该字段来说是一个有用的、有意义的值。但这不是我们的目的。_temperature 字段永远不应该在其 null 状态下被观察到。

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

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

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

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

请注意,_temperature 字段的类型不可空,但没有初始化。另外,在使用它的时候也没有明确的空断言。有一些模型可以应用到迟到的语义中,但我是这样想的。晚期修饰符的意思是 “在运行时执行这个变量的约束,而不是在编译时执行”。这几乎就像 “late “这个词描述的是什么时候执行变量的保证。

在这种情况下,由于该字段还没有确定初始化,所以每次读取该字段时,都会插入一个运行时检查,以确保它已经被分配了一个值。如果没有,就会抛出一个异常。给变量类型为 String 意味着 “你应该永远不会看到我的值不是字符串”,而后期修饰符意味着 “在运行时验证”。

在某些方面,迟到修饰符比使用? “神奇”,因为对字段的任何使用都可能失败,而且在使用现场没有任何文字可见。但你确实必须在声明处写晚期才能得到这种行为,我们的信念是,在那里看到修饰符已经足够明确,这一点是可以维护的。

作为回报,你可以得到比使用可空类型更好的静态安全。因为现在字段的类型是非可空的,所以试图将 null 或可空的 String 分配给字段是一个编译错误。晚期修饰符可以让你推迟初始化,但仍然禁止你把它当作一个可空变量来处理。

惰性初始化 #

late 修饰符也有一些其他的特殊能力。这可能看起来很矛盾,但你可以在一个有初始化器的字段上使用 late。

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

当你这样做的时候,初始化器会变得懒惰。它不是在实例构造完成后立即运行,而是在第一次访问该字段时就延迟并缓慢运行。换句话说,它的工作方式与顶层变量或静态字段的初始化器完全一样。当初始化表达式的成本很高且可能不需要时,这可以很方便。

当你在实例字段上使用后期,懒惰地运行初始化器会给你一个额外的奖励。通常实例字段初始化器不能访问这个,因为在所有字段初始化器完成之前,你不能访问新对象。但有了迟来的字段,就不再是这样了,所以你可以访问这个,调用方法,或者访问实例上的字段。

late final 变量 #

你也可以把 latefinal 结合起来。

// 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 的大部分功能空间。如果你想在局部变量上使用它,你甚至可以在局部变量上使用它,如果你想进行一点局部的懒惰评估。

所需的命名参数 #

为了保证你永远不会看到一个具有不可空类型的空参数,类型检查器要求所有可选参数要么具有可空类型,要么具有默认值。如果你想让一个命名的参数有一个可空的类型而没有默认值呢?那就意味着你想要求调用者总是传递它。换句话说,你想要一个命名的参数,但不是可选的。

我用这个表直观地展示了 Dart 参数的各种类型。

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

由于不清楚的原因,Dart 长期以来一直支持这个表的三个角,但把 named+mandatory 的组合空了。在空安全的情况下,我们填补了这一点。你在参数前放上 required,就可以声明一个必要的命名参数。

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

在这里,所有的参数都必须以名字传递。参数 a 和 c 是可选的,可以省略。参数 b 和 d 是必填的,必须传递。注意,要求性与可空性无关。可空类型的命名参数可以是必需的,不可空类型的命名参数可以是可选的(如果它们有一个默认值)。

这是另一个我认为无论空值安全性如何都能让 Dart 变得更好的特性之一。它只是让我觉得这个语言更加完整。

抽象字段 #

Dart 的一个特点是它坚持了一个叫做统一访问原则的东西。用人话说就是字段与 getter 和 setter 是没有区别的。在某个 Dart 类中的 “属性 “是计算还是存储,这是一个实现细节。正因为如此,在使用抽象类定义接口的时候,一般都会使用字段声明。

abstract class Cup {
  Beverage contents;
}

其目的是让用户只实现该类,而不要扩展它。字段语法只是写一个 getter/setter 对的较短方式。

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

但 Dart 不知道这个类永远不会被用作具体类型。它把那个内容声明看作是一个真实的字段。而且,不幸的是,这个字段是不可空的,也没有初始化器,所以你得到一个编译错误。

一个解决方法是使用显式的抽象 getter/setter 声明,就像第二个例子中那样。但这有点啰嗦,所以在 null 安全的情况下,我们还增加了对显式抽象字段声明的支持。

abstract class Cup {
  abstract Beverage contents;
}

这和第二个例子的行为完全一样。它只是用给定的名称和类型声明了一个抽象的 getter 和 setter。

使用可空字段 #

这些新特性涵盖了许多常见的模式,并且在大多数时间里,让处理 null 的工作变得相当轻松。但即便如此,我们的经验是,可空字段仍然是困难的。在你能让字段迟到且不可空的情况下,你是金子般的存在。但在很多情况下,你需要检查字段是否有值,这就需要让它可空,这样你就可以观察到空。

你可能会期望这样做是可行的。

// 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 和你使用它的点之间没有变化。考虑到在病理情况下,字段本身可能会被子类中的 getter 覆盖,在第二次调用时返回 null)。

所以,既然我们关心健全性,那么字段就不会推广,上面的方法就不会编译。这是很烦人的。在像这里这样简单的情况下,你最好的选择是在字段的使用上打上一个! 这似乎是多余的,但这多少是 Dart 如今的行为方式。

另一个有用的模式是先把字段复制到一个本地变量中,然后再使用它。

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

由于类型推广确实适用于本地人,所以现在可以正常使用。如果你需要改变值,只要记得存储回字段,而不仅仅是本地。

无效性和属性 #

像大多数现代静态类型的语言一样,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 上定义的少量方法外,你不能调用它的任何方法。后者意味着你必须在使用该类型的任何字段或变量之前初始化它们。这可能会使类型参数变得相当难处理。

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

在你无法访问一个值的地方,你可以使类型参数的使用是可空的。

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

注意对象声明上的? 现在这个字段有一个显式可空的类型,所以可以不初始化它。

当你使一个类型参数类型像这里的 T? 一样可空的时候,你可能需要把可空性抛掉。正确的方法是使用显式为 T 的转写,而不是使用 !操作符。

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

  T unbox() => object as T;
}

!操作符总是在值为 null 时抛出。但是如果类型参数已经被实例化为一个可空类型,那么 null 对于 T 来说是一个完全有效的值。

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

这个程序应该无误地运行。使用 T 就可以实现这一点。使用 !会抛出一个异常。

其他通用类型有一些约束,限制了可以应用的类型参数的种类。

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

  Interval(this.min, this.max);

  bool get isEmpty => max <= min;
}

如果绑定是不可空的,那么类型参数也是不可空的,这意味着你有不可空类型的限制–你不能让字段和变量不初始化。这意味着你有不可空值类型的限制–你不能让字段和变量不初始化。这里的示例类必须有一个初始化字段的构造函数。

作为这种限制的回报,你可以调用在其绑定上声明的参数类型的值的任何方法。然而,拥有一个不可空的绑定确实会阻止你的通用类的用户用一个可空的类型参数来实例化它。对于大多数类来说,这可能是一个合理的限制。

你也可以使用一个可空的绑定。

// Using null safety:
class Interval<T extends num?> {
  T min, 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;
  }
}

这意味着在类的主体中,你可以灵活地将类型参数处理为 nullable。请注意,这次我们没有构造函数,这也没关系。字段将被隐式初始化为 null。你可以声明类型参数类型的未初始化变量。

但是你也有 nullability 的限制–你不能在该类型的变量上调用任何东西,除非你先处理好 nullability。在这里的例子中,我们复制局部变量中的字段,并检查这些局部变量是否为空,以便在使用<=之前,流分析将它们推广到非可空性类型。

请注意,可空性绑定并不妨碍用户用非空类型实例化类。一个可空的边界意味着类型参数可以是可空的,而不是说它必须是可空的。事实上,如果你不写扩展子句,类型参数的默认约束是可空值约束 Object? 没有办法要求类型参数是可空的。如果你想让类型参数的使用可靠地是可空的,你可以在类的主体里面使用 T?

核心库的变化 #

语言中还有一些其他的调整,但都是次要的。例如,没有 on 子句的 catch 的默认类型现在是 Object 而不是动态的。开关语句中的跌穿分析使用了新的流分析。

剩下的真正对你有意义的变化是在核心库中。在我们开始进行空值安全大冒险之前,我们担心原来没有办法在不大规模破坏世界的情况下让我们的核心库实现空值安全。结果并没有那么可怕。有一些重大的变化,但大多数情况下,迁移很顺利。大多数核心库要么不接受 null,自然而然地迁移到非可空类型,要么接受并优雅地用可空类型接受它。

不过有几个重要的角落。

Map 索引操作符是可空的 #

这并不是真正的改变,更多的是一个需要知道的事情。Map 类的 index [] 操作符如果键不存在,则返回 null。这意味着该操作符的返回类型必须是可空的。V?

我们可以将该方法改为当键不存在时抛出一个异常,然后给它一个更容易使用的非空值返回类型。但是,使用索引操作符并检查 null 以查看键是否不存在的代码是非常常见的,根据我们的分析,约占所有使用的一半。打破所有这些代码会让 Dart 生态系统燃起熊熊大火。

相反,运行时的行为是一样的,因此返回类型必须是可空的。这意味着你一般不能立即使用 map 查询的结果。

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

当你试图在一个可空字符串上调用.length 时,会出现编译错误。在你知道键存在的情况下,你可以通过使用!.length 来教导类型检查器。

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

我们考虑过在 Map 中添加另一个方法来为你做这件事:查找 key,如果没有找到就抛出,否则就返回一个非空值。但是该怎么称呼它呢?没有一个名字比单字符的 !更短,也没有一个方法的名字比在调用现场看到一个内置语义的 !更清晰。所以,在地图中访问一个已知存在元素的习惯性方式是使用[]! 你会习惯的。

没有未命名的 List 构造函数 #

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

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

在 Dart 中,创建一个完全未初始化的列表的模式一直让人觉得格格不入,现在更是如此。如果你的代码被这一点破坏了,你可以随时通过使用许多其他的方法来产生一个列表来修复它。

不能在不可空的列表上设置较大的长度 #

这一点鲜为人知,但 List 上的 length getter 也有一个相应的 setter。你可以将长度设置为一个较短的值来截断列表。你也可以将它设置为一个较长的长度,以便用未初始化的元素填充列表。

如果你对一个非空值类型的列表这样做,当你以后访问那些未写入的元素时,你会违反健全性。为了防止这种情况发生,如果(也只有当)列表的元素类型是不可空的,而你又将其设置为较长的长度时,长度设置器会抛出一个运行时异常。截断所有类型的列表仍然是可以的,你可以增长可空类型的列表。

如果你定义了自己的列表类型,扩展了 ListBase 或应用了 ListMixin,那么这有一个重要的后果。这两种类型都提供了 insert()的实现,之前通过设置长度为插入的元素腾出空间。这样做会因空安全而失败,所以我们将 ListMixin(ListBase 共享)中 insert()的实现改为调用 add()。如果你想能够使用继承的 insert()方法,你的自定义列表类应该提供 add()的定义。

不能在迭代之前或之后访问 Iterator.current #

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

过去,如果你在第一次调用 moveNext()之前或在迭代结束后调用它,current 会返回 null。有了 null 安全,那就要求 current 的返回类型是 E? 而不是 E。这又意味着每个元素的访问都需要进行运行时空检查。

鉴于几乎没有人以那种错误的方式访问当前元素,这些检查将毫无用处。由于在迭代之前或之后可能会有一个该类型的值,所以我们让迭代器的行为没有被定义,如果你在不应该调用它的时候调用它。大多数 Iterator 的实现都会抛出一个 StateError。

总结 #

这是一个非常详细的关于 null 安全的语言和库变化的介绍。这是一个很大的东西,但这是一个相当大的语言变化。更重要的是,我们希望达到一个点,让 Dart 仍然感觉到凝聚力和可用性。这不仅需要改变类型系统,还需要改变其他一些围绕它的可用性功能。我们不希望它让人感觉像被栓上了 null safety。

要带走的核心点是。

  • 类型在默认情况下是不可空值的,而通过添加 ?. 来实现空值化。

  • 可选参数必须是可空的,或者有一个默认值。可以使用 required 使命名参数成为非可选参数。不可空值的顶层变量和静态字段必须有初始化器。不可空值的实例字段必须在构造函数主体开始之前初始化。

  • 如果接收者为空,则空感知操作符后的方法链会短路。有新的空感知级联(?..)和索引(?[])运算符。后缀的空断言 “bang” 运算符(!)将其可空操作数投射到底层的非可空类型。

  • 流程分析让你可以安全地将可空的局部变量和参数转化为可用的非可空变量。新的流分析还对类型提升、缺失返回、不可达代码和变量初始化有更智能的规则。

  • late 修饰符让你可以在其他地方使用不可空类型和 final,否则你可能无法使用,但会牺牲运行时检查。它还为你提供了惰性初始化的字段。

  • List 类被修改为防止未初始化元素。

最后,一旦你吸收了所有这些,并让你的代码进入 null 安全的世界,你就会得到一个健全的程序,编译器可以优化,并且在你的代码中可以看到每一个可能发生运行时错误的地方。我们希望你觉得这样的努力是值得的。

原文链接: https://dart.dev/null-safety/understanding-null-safety