D非空值类型的终极指南

129 阅读13分钟

Null Safety的引入是Dart语言的一个重要里程碑。Null Safety通过在开发过程中而不是在运行时捕获空值错误来帮助你避免整类问题。

这篇文章概述了哪些变化,并通过实例展示了如何使用新的Null Safety功能。

Null Safety在Flutter 2.0中作为稳定版发布,并在Flutter 2.2创建的所有项目中默认启用。你可以用Dartpad试试。

[

赞助商

Code with Andrea对每个人都是免费的。通过检查这个赞助商来帮助我保持这种方式。

Open-Source Backend Server for Flutter Developers

**面向Flutter开发者的开源后端服务器。**Appwrite是一个安全的、自我托管的解决方案,为开发者提供了一套易于使用的REST API,以管理他们的核心后台需求。你可以用Appwrite构建任何东西!点击这里了解更多。

](appwrite.io/?utm_source…)

一些背景

1965年,空引用首次出现在ALGOL编程语言中,此后被大多数主流编程语言所采用。

然而,空值错误是如此普遍,以至于空值引用被称为 "十亿美元的错误"。

空值引用:十亿美元的错误

因此,让我们看看Dart有什么变化来解决这个问题。

Dart类型系统

在讨论Null安全问题之前,让我们先谈谈Dart类型系统。

据说Dart有一个健全的类型系统。当我们写Dart代码时,类型检查器会确保我们不能写这样的代码。

int age = "hello world"; // A value of type `String` can't be assigned to a variable of type `int`

这段代码会产生一个错误,告诉我们*"一个String 的值不能被分配给一个int 类型的变量"*。

同样地,当我们在Dart中写一个函数时,我们可以指定一个返回类型

int square(int value) {
  return value * value;
}

由于类型安全,Dart可以100%地保证这个函数总是返回一个int

类型安全帮助我们写出更安全的程序,并更容易推理代码。

但是仅靠类型安全并不能保证一个变量(或返回值)不被null

因此,这段代码可以编译,但在运行时产生了一个异常。

square(null);
// Unhandled Exception: NoSuchMethodError: The method '*' was called on null.

在这个例子中,要发现问题是很容易的。但在大型代码库中,很难跟踪什么可以和不可以null

运行时null 检查可以缓解这个问题,但它们会增加噪音。

int square(int value) {
  assert(value != null); // for debugging
  if (value == null) throw Exception();
  return value * value;
}

我们在这里真正想要的是告诉Dart,value 的参数应该是null

我们需要一个更好的解决方案--现在我们有了它。😎

Dart的空值安全。优点

Dart 2.12默认启用了Sound Null Safety,并带来了三个主要好处。

  • 我们可以编写具有强大编译时保证的空值安全代码。这使得我们的生产力提高,因为Dart可以告诉我们什么时候做错了。
  • 我们可以更容易地声明我们的意图。这导致了API的自我记录和更容易使用。
  • Dart编译器可以优化我们的代码,从而使程序更小更快。

所以,让我们看看Null Safety在实践中是如何工作的。

声明不可为空的变量

主要的语言变化是,现在所有的类型默认都是不可置空的。

这意味着这段代码不会被编译。

void main() {
  int age; // non-nullable
  age = null; // A value of type `Null` can't be assigned to a variable of type 'int'
}

当使用不可置空的变量时,我们必须遵循一个重要的规则。

非空值变量必须始终以非空值进行初始化。

如果你沿着这些思路推理,就会更容易理解所有新的语法变化。


让我们再来看看这个例子。

int square(int value) {
  return value * value;
}

在这里,value 参数和返回值现在都被保证为非null

因此,运行时 null ,不再需要检查,这段代码现在产生一个编译时错误。

square(null);
// The argument type 'Null' can't be assigned to the parameter type 'int'

但是,如果现在所有的类型在默认情况下都是不可归零的,那么我们如何声明可归零变量呢?

声明可归零的变量

? 符号是我们需要的。

String? name;  // initialized to null by default
int? age = 36;  // initialized to non-null
age = null; // can be re-assigned to null

注意:在使用一个可空变量之前,你不需要初始化它。它默认被初始化为null

下面是其他一些声明可空变量的方法。

// nullable function argument
void openSocket(int? port) {
  // port can be null
}

// nullable return type
String? lastName(String fullName) {
  final components = fullName.split(' ');
  return components.length > 1 ? components.last : null;
}

// using generics
T? firstNonNull<T>(List<T?> items) {
  // returns first non null element in list if any
  return items.firstWhere((item) => item != null);
}

收获:你可以在代码的任何地方? 语法声明可空变量。

可空变量是表达不存在一个值的好方法,这在很多API中都很有用。

当你设计一个API时,问问自己一个变量是否应该是可忽略的,并相应地声明它。

但在有些情况下,我们知道某些东西不能被null ,但我们不能向编译器证明它。在这些情况下,断言运算符可以提供帮助。

断言运算符

我们可以使用断言运算符! ,将一个可归零的表达式分配给一个不可归零的变量。

int? maybeValue = 42;
int value = maybeValue!; // valid, value is non-nullable

通过这样做,我们告诉Dart,maybeValue 不是null ,而且把它赋值给一个非空值变量是安全的。

请注意,对一个null 的值应用断言操作符会引发一个运行时异常。

String? name;
print(name!); // NoSuchMethodError: '<Unexpected Null Value>'
print(null!); // NoSuchMethodError: '<Unexpected Null Value>'

当你的假设是错误的,! 操作符会导致运行时异常。


有时,我们需要与返回nullable值的API一起工作。让我们重新审视一下lastName 这个函数。

String? lastName(String fullName) {
  final components = fullName.split(' ');
  return components.length > 1 ? components.last : null;
}

在这里,类型系统帮不上忙。如果我们知道函数为一个给定的参数返回一个非null 的值,我们应该尽快把它赋给一个非空值的变量。

这可以通过! 操作符来实现。

// prefer this:
String last = lastName('Andrea Bizzotto')!;
// to this:
String? last = lastName('Andrea Bizzotto');

综上所述。

  • 在可能的情况下,尽量创建不可置空的变量,因为这些变量在编译时将被保证null
  • 如果你知道一个可归零的表达式不会是null ,你可以用! 操作符把它赋给一个不可归零的变量。

流程分析。推广

Dart可以通过考虑null ,对可归零变量进行检查,使你的生活更加轻松。

int absoluteValue(int? value) {
  if (value == null) {
    return 0;
  }
  // if we reach this point, value is non-null
  return value.abs();
}

这里我们使用一个if 语句来提前返回,如果value 的参数是null

超过这一点,value 不能成为null ,并被处理(或晋升)为一个非空值。因此,我们可以安全地使用value.abs() ,而不是value?.abs() (使用null-aware操作符)。

同样地,如果值是null ,我们可以抛出一个异常。

int absoluteValue(int? value) {
  if (value == null) {
    throw Exception();
  }
  // if we reach this point, value is non-null
  return value.abs();
}

再一次,value 被提升为一个不可置空的值,并且不需要null-aware操作符?.

综上所述。

  • 使用前期的空值检查来提前返回或抛出异常
  • 在空值检查之后,可空值变量被提升为不可空值

而在一个可忽略的变量被忽略后,Dart让你把它作为一个不可忽略的变量来使用,这是相当不错的。

流程分析。明确的赋值

Dart知道变量在哪里被赋值,在哪里被读取

这个例子展示了如何检查条件初始化一个非空变量。

int sign(int x) {
  int result; // non-nullable
  print(result.abs()); // invalid: 'result' must be assigned before it can be used
  if (x >= 0) {
    result = 1;
  } else {
    result = -1;
  }
  print(result.abs()); // ok now
  return result;
}

只要使用一个不可归零的变量之前给它一个值,Dart就会很高兴。

在类中使用不可归零的变量

如果类中的实例变量是不可置空的,就必须对它们进行初始化。

class BaseUrl {
  String hostName; // Non-nullable instance field 'hostName' must be initialized

  int port = 80; // ok
}

如果一个不可归零的实例变量不能用默认值来初始化,那就用构造函数来设置它。

class BaseUrl {
  BaseUrl(this.hostName);
  String hostName; // now valid

  int port = 80; // ok
}

不可置空的命名参数和位置参数

在Null Safety中,不可归零的命名参数必须始终是必需的或有一个默认值

这适用于普通方法和类的构造函数。

void printAbs({int value}) {  // 'value' can't have a value of null because of its type, and no non-null default value is provided
  print(value.abs());
}

class Host {
  Host({this.hostName}); // 'hostName' can't have a value of null because of its type, and no non-null default value is provided
  final String hostName;
}

我们可以用新的required 修改器来修复上面的代码,它取代了旧的@required 注解

void printAbs({required int value}) {
  print(value.abs());
}

class Host {
  Host({required this.hostName});
  final String hostName;
}

而当我们使用上述API时,Dart可以告诉我们是否做错了什么。

printAbs(); // The named parameter 'value' is required, but there's no corresponding argument
printAbs(value: null); // The argument type 'Null' can't be assigned to the parameter type 'int'
printAbs(value: -5); // ok

final host1 = Host(); // The named parameter 'hostName' is required, but there's no corresponding argument
final host2 = Host(hostName: null); // The argument type 'Null' can't be assigned to the parameter type 'String'
final host3 = Host(hostName: "example.com"); // ok

反过来说,如果我们使用可归零的实例变量,我们可以省略required 修改器(或默认值)。

class Host {
  Host({this.hostName});
  final String? hostName; // nullable, initialized to `null` by default
}
// all valid cases
final host1 = Host(); // hostName is null
final host2 = Host(hostName: null); // hostName is null
final host3 = Host(hostName: "example.com"); // hostName is non-null

位置参数也要遵守同样的规则。

class Host {
  Host(this.hostName); // ok
  final String hostName;
}

class Host {
  Host([this.hostName]); // The parameter 'hostName' can't have a value of 'null' because of its type, and no non-null default value is provided
  final String hostName;
}

class Host {
  Host([this.hostName = "www.codewithandrea.com"]); // ok
  final String hostName;
}

class Host {
  Host([this.hostName]); // ok
  final String? hostName;
}

在可归零和不可归零的变量之间,命名参数和位置参数之间,要求值和缺省值之间,有很多东西需要接受。如果你感到困惑,请记住这条黄金规则。

非空值变量必须始终以非空值进行初始化。

*为了完全理解所有的Null安全特性,请在Dartpad上练习使用它们。如果你做错了,Dart会告诉你 - 所以请仔细阅读错误信息。*🔍

Null-aware级联运算符

为了处理Null Safety,级联运算符现在获得了一个新的null-aware variant:?.. 。例子。

Path? path;
// will not do anything if path is null
path
  ?..moveTo(0, 0)
  ..lineTo(0, 2)
  ..lineTo(2, 2)
  ..lineTo(2, 0)
  ..lineTo(0, 0);

上面的级联操作只有在path 不是null 的情况下才会被执行。

无意识的级联运算符可以短路,所以在序列的开始只需要一个?..

空值感知下标运算符

到目前为止,在使用下标运算符之前检查一个集合是否是null ,是很啰嗦的。

int? first(List<int>? items) {
  return items != null ? items[0] : null; // null check to prevent runtime null errors
}

Dart 2.9引入了null 意识到的操作符?[] ,这使得这件事变得简单了许多。

int? first(List<int>? items) {
  return items?[0]; 
}

迟到的关键字

使用late 关键字来初始化一个变量,当它第一次被读取时,而不是当它被创建时。

一个很好的例子是在initState() 中初始化变量时。

class ExampleState extends State {
  late final TextEditingController textEditingController;

  @override
  void initState() {
    super.initState();
    textEditingController = TextEditingController();
  }
}

甚至更好的是,initState() 可以完全删除。

class ExampleState extends State {
  // late - will be initialized when first used (in the build method)
  late final textEditingController = TextEditingController();
}

通常使用latefinal 结合使用, 只读变量的创建时间推迟到第一次读取时。

当创建初始化器做一些繁重工作的变量时,这是很理想的。

late final taskResult = doHeavyComputation();

当在一个函数体中使用时,latefinal 可以像这样使用。

void foo() {
  late final int x;

  x = 5; // ok
  x = 6; // The late final local variable is already definitely initialized
}

尽管我并不建议这样使用晚期变量。因为这种风格会导致非明显的运行时错误。例子。

class X {
  late final int x;
  
  void set1() => x = 1;
  
  void set2() => x = 2;
}

void main() {
  X x = X();
  x.set1();
  print(x.x);
  x.set2(); // LateInitializationError: Field 'x' has already been initialized.
  print(x.x);
}

通过声明一个不可置空的late 变量,我们承诺它在运行时是不可置空的,而且Dart帮助我们提供了一些编译时的保证。

但是我建议,只需少用late ,并且在声明late 变量时,一定要初始化它们。

静态变量和全局变量

现在所有的全局变量在声明时都必须被初始化,除非它们是late

int global1 = 42; // ok

int global2; // The non-nullable variable 'global2' must be initialized

late int global3; // ok

这同样适用于静态类变量。

class Constants {
  static int x = 10; // ok
  static int y; // The non-nullable variable 'y' must be initialized
  static late int z; // ok
}

但正如我之前所说,我不建议这样使用late ,因为这可能导致运行时错误。

[

赞助者

Andrea的代码对每个人都是免费的。帮助我保持这种方式,请查看这个赞助商。

Open-Source Backend Server for Flutter Developers

**面向Flutter开发者的开源后端服务器。**Appwrite是一个安全的、自我托管的解决方案,为开发者提供一套易于使用的REST API来管理他们的核心后台需求。你可以用Appwrite构建任何东西!点击这里了解更多。

](appwrite.io/?utm_source…)

结论

Null Safety是Dart语言的一个重大变化,只要你正确使用它,它可以帮助你写出更好、更安全的代码。

对于新项目,请记住我们所涉及的所有语法变化。这看起来似乎有很多东西要接受,但都可以归结为这一点。

每当你在Dart中声明一个变量时,都要考虑它是否应该是nullable。这是额外的工作,但它会带来更好的代码,而且Dart会一路帮助你。

对于Null Safety之前创建的现有项目,事情就比较麻烦了,你必须先迁移你的代码。这涉及到多个步骤,应该按照正确的顺序进行。这里有一个实际的案例研究,展示了如何做到这一点。

其他资源

本文主要受到这些资料的启发。

迁移资源。

完整的Dart开发指南

如果你想用一种更有条理的方法深入学习Dart,可以考虑参加我的完整Dart课程。这涵盖了所有最重要的语言特性,包括练习、项目和额外的材料。