引入

空安全在现代编程语言中并不是 Dart 的专利,像其他的一些语言,比如 Kotlin,TypeScript,C# 等都有自己的解决方案,编译时的空安全对于开发者来说不一定是必要的,但对于开发效率的提升是不可否认的。

非空与可空

在引入了空安全后,所有的类型都是默认非空的,假如声明了一个 String 类型的变量,那么它必须是非空的,如果想要声明一个可以为空的变量,需要在类型声明后加上 ? ,就像下面这样:

//非空
String str = "not null";
//可空
String? nullStr = null;
//报错
String wrong = null;

本质上,这样的写法和定义了一个原有类型加上 Null 的联合类型没有什么区别,如果 Dart 包含完整的联合类型定义,那么 String? 本质上就是 String|Null 的缩写。

空类型的提升

可空变量的出现虽然解决了空安全的问题,但是也会带来其他的问题,就拿上面的例子来说,当你将 nullStr 这个变量定义为 String? 时,你无法对它做任何有用的事情,就算它可能为 String 类型,你也不能够直接调用 String 类下的方法,比如 .length 来获取它的长度。不过不用担心,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 语句已经判断值不为空,Dart 就会将它的类型从 List<String>? 提升到 List<String> 从而让你可以调用它的方法,当然 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 字段。

空值断言操作符

将一个可空变量提升为非空变量是很有必要的,你可以在先前的可空变量上调用对应非空类型的方法,还可以享受非空类型带来的安全和性能优势,Dart 中强大的静态分析功能可以帮你解决一部分问题,但是,有些可空类型的使用方法,不能向静态分析证明它们的安全性,也就意味着 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 可能为空,但实际上,通过观察可以发现,当 error 为空时,我们永远不会访问它。但要知道这个行为,必须要理解 codeerror 之间存在的联系,作为人类我们可以很容易知道这一点,但是类型检查器做不到。

为了应对这一情况,通常我们可以使用 as 来转换断言类型,就像这样:

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

这里我们将可空的类型转换为了 String,当 errornull 时,编译器会抛出一个异常,如果转换成功,error 就会变为一个字符串供我们使用。

虽然 as 加上对应类型的操作可以非常直观的断言一个可空的类型,但是如果面对一个复杂的类型,就会变得非常繁琐,假如有一个这样的类型 Map<TransactionProviderFactory, List<Set<ResponseFilter>>>,不烦吗?

为了应对这种情况,Dart 提供了一个操作符 ! 来将一个可空的类型断言为非空,上面的示例就可以变成这样,是不是更加优雅了。

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

延迟初始化

对于顶层的变量和字段,类型检查器通常无法证明其是否安全。这里有一个例子:

// 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()serve() 之前被调用了,虽然这里对于 _temperature 初始化是合法的(对于人类而言),但就像在上面的例子一样,类型检查器无法跟踪这样的状态。所以在这里,Dart 会给出一个编译错误。

为了解决这个问题,你可以将顶层变量声明为可空,然后使用空断言操作符来使用它,就像这样:

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

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

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

这样一来,虽然代码可以正常工作了,但是又带来了一个新的问题。在这里将 _temperature 定义为了一个可空类型,仅仅是为了消除编译错误,但实际上 _temperature 永远不会为空。

为了处理类似延迟初始化的问题,Dart 新增了一个修饰符 late,现在你可以这样使用:

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

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

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

此处 _temperature 作为非空的类型,但是并没有进行初始化。同时,使用时也没用明确的空断言,在这里 late 关键字意味着“在运行时而非编译时对变量进行约束”,就好像在对编译器说:”每次运行时都要检查它是不是真的“。

late 关键字还有一些其他的功能,虽然听起来很奇怪,但是你可以在一个包含初始化的字段上使用 late,就像这样:

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

这样声明会使初始化延迟执行。实例的构造将会延迟到字段首次被访问时才执行,而不是在被构造时就被初始化,换句话说,它让一个普通字段的初始化方式变得和顶层字段一样了!当初始化表达式非常消耗性能,并且有可能不需要时,他会变得非常有用。

可空性和泛型

和主流的静态类型语言一样,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);
}

当你将类型参数变为可空时,你可能需要强制将其转换为非空类型,正确的做法是显式使用 as T 来进行转换,而不能使用 !

这是因为,如果值为 null 使用 ! 一定会抛出异常。但是如果类型参数已经被声明为一个可空的值,那么 null 对于 T 来说就是完全合法的值。