likes
comments
collection
share

Dart 是该使用类型推断还是主动声明类型?

作者站长头像
站长
· 阅读数 17

前言

Dart 作为一门比较年轻的语言,拥有了众多现代化编程语言的特征,比如有 JavaScript ES6特性中的...展开操作符,箭头函数等。同时,在类型定义上又吸收了类型推断特性,使得我们可以使用 var 关键字直接定义变量。此外,Dart 还支持运行时的动态类型,我们可以使用 dynamic 关键字来声明一个未知的类型,然后再运行时再做具体的类型判断。那么,到底如何合理地使用类型?本篇我们来介绍 Dart 的类型使用指南。

类型概述

当我们在代码中写下一个类型的时候,意味着声明的值在接下来的代码中需要遵循类型约定。类型通常在两种地方出现:变量(成员)类型标注或指定泛型类型。 类型标注通常认为是所谓的静态类型。我们可以对变量、函数参数、类成员属性或返回值进行类型标注。例如,下面的例子中,boolString 就是类型标注。这类静态声明的结构意味着在代码运行阶段类型不会改变,事实上,如果使用错误的类型的话编译器会直接报错。

bool isEmpty(String parameter) {
  bool result = parameter.isEmpty;
  return result;
}

泛型是一种类型集合的语法,用于同一份代码可以处理不同的类型,从而提高编码效率。这种特性早在 Java 中得到了广泛的应用,TypeScript 也有这样的特性。泛型包括泛型类或泛型方法,我们常见的 List<E>Map<K,V>Set<E> 就属于泛型类。而像 ValueChanged<T>就属于泛型函数。泛型类指定类型有两种形式,一种是类型声明时制定,一种是在初始化值的时候指定。

// 泛型类
var list = <int>[1, 2, 3];
List<int> anotherList = [1, 2, 3];

// 泛型函数:ValueChanged
typedef ValueChanged = void Function<T>(T item);

类型推断

在 Dart 中,类型标注是可选的。如果省略类型标注的话,Dart 会根据最近的上下文推断具体的类型。但是有时候未必会有足够的信息去推断准确的类型(比如声明数值的时候,可能会推断为整数或浮点数)。当无法准确推断时,Dart 有时候会报错,而有时候会隐式地赋予 dynamic 类型。隐式地赋予 dynamic 会导致代码看起来类型是没问题的,但是实际上是禁用了类型检查。 由于 Dart 同时支持类型推断和 dynamic 类型导致了一个问题,那就是“无类型”意味着什么?是说代码是动态类型,还是说写代码的时候无需写类型?为了避免这种困惑,实际上应该避免说是“无类型”吗。实际上,我们无需纠结这个概念,代码中要么是做类型标注或类型准确推断,要么就是 dynamic。而对于 dynamic 这种,实际上应该尽量避免,毕竟引入了不确定性。 类型推断的好处是能够节省我们编写类型代码的时间,以及阅读代码时候不需要关注类型信息,而专注于业务代码本身。而显示地声明类型可以增强代码的健壮性和可维护性,这种情况下为 API 限定了静态的类型,从而约束了程序中不同部分代码中可用的类型。 类型推断虽然很强大,但并没有什么魔力。有些时候,它也会失灵,比如下面的例子:

void main() {
  var aNumber = 1;
  inferError(aNumber);
}

void inferError(double floatValue) {
  print('value: $floatValue');
}

实际上类型推断会把 aNumber 推断为 int 类型,导致编译器报错。 Dart 是该使用类型推断还是主动声明类型? 解决这种问题的办法是在初始化变量值的时候,尽可能地精确,比如上面的例子应该修改为,此时 aNumber 会被推断为符合要求的 double 类型。这其实也是一个好的编程习惯,通过精确地赋值,也能让代码阅读者清晰地知道类型。

void main() {
  var aNumber = 1.0;
  inferError(aNumber);
}

下面是三条很实用的指南,能够有效在简洁性、可控性、灵活性和可扩展性上取得最佳的平衡。

  • 当推断没有足够的上下文时,请声明类型,哪怕是这个类型是 dynamic。
  • 如无必要,无需标注局部变量或泛型的类型。
  • 除非是初始化值能够很明显表示对应类型,否则对于全局变量或成员属性,建议是使用类型标注。

接下来是一些具体的编码建议。

对于没有初始化值的变量,务必标注类型

对于全局变量、局部变量、静态属性或成员属性,通常可以通过初始值推断出来它们的类型,但是如果没有初始化值的话会导致类型推断失败。因此,对于没有初始化值的情况,务必明确标注类型。

// 正确示例
List<AstNode> parameters;
if (node is Constructor) {
  parameters = node.signature;
} else if (node is Method) {
  parameters = node.parameters;
}

// 错误示例
var parameters;
if (node is Constructor) {
  parameters = node.signature;
} else if (node is Method) {
  parameters = node.parameters;
}

如果字段和全局变量的语义上很难推断出类型,那么应该标注类型

类型标注一定程度上能够起到文档的作用,可以通过边界约束来避免类型使用错误,考虑一下下面的错误示例。

// 错误示例
install(id, destination) => ...

我们没法从上下文得知 id 是什么类型,可能是 int,也可能是String,甚至是其他类型。然后 destination 更加不知道是什么类型的对象。此时对调用者而言,不得不去阅读源码才知道该如何调用 install 方法。如果改成下面的样子就很清晰了。

// 正确示例
Future<bool> install(PackageId id, String destination) => ...

如何保证语义上的明晰没有准确的定义,但是下面的几条是很好的例子:

  • 字面含义明确,录入变量名为 nameemail 这类的,我们通常会知道是字符串。
  • 构造方法初始化变量;
  • 引用那些明确类型的常量进行初始化;
  • 数值或字符串的简单赋值表达式;
  • 工厂方法,例如 int.parse(),Futrue.wait()等常见的知道返回类型的工厂方法。

其实遵循的原则也很简单,如果你觉得有任何可能导致类型理解不清晰的地方,那么就应该加上类型标注。同样的,对于类型推断依赖于其他库中返回值的情况,建议也加上类型标注,这样如果其他库的返回值类型改变了,我们能够通过编译器错误找到具体的解决方法。

不要对局部变量重复进行类型标注

局部变量在短小的函数中作用范围很小,省略类型标注可以让代码阅读者专注更为重要的变量名以及初始值,从而提高代码的阅读效率。例如下面的例子:

// 正确示例
List<List<Ingredient>> possibleDesserts(Set<Ingredient> pantry) {
  var desserts = <List<Ingredient>>[];
  for (final recipe in cookbook) {
    if (pantry.containsAll(recipe)) {
      desserts.add(recipe);
    }
  }

  return desserts;
}

// 错误示例
List<List<Ingredient>> possibleDesserts(Set<Ingredient> pantry) {
  List<List<Ingredient>> desserts = <List<Ingredient>>[];
  for (final List<Ingredient> recipe in cookbook) {
    if (pantry.containsAll(recipe)) {
      desserts.add(recipe);
    }
  }

  return desserts;
}

还有一种情况是如果推断的类型并不是我们想要的时候,那么我们可以使用其他兼容的类型(通常是父类)来重新声明,以便后续可以更改类型。

// 正确示例
Widget build(BuildContext context) {
  Widget result = Text('You won!');
  if (applyPadding) {
    result = Padding(padding: EdgeInsets.all(8.0), child: result);
  }
  return result;
}

务必标注函数的返回值类型

Dart 并不会从函数体推断函数的返回值类型,因此我们需要自己标注函数的返回值类型。实际上,如果不标注的话,会默认返回的是 dynamic 类型。

// 正确示例
String makeGreeting(String who) {
  return 'Hello, $who!';
}

// 错误示例
makeGreeting(String who) {
  return 'Hello, $who!';
}

Dart 是该使用类型推断还是主动声明类型? 当然,这一条对于匿名函数是没必要的,匿名函数会从函数体推断出返回值类型。

函数声明中务必标注参数类型

// 正确示例
void sayRepeatedly(String message, {int count = 2}) {
  for (var i = 0; i < count; i++) {
    print(message);
  }
}

// 错误示例
void sayRepeatedly(message, {count = 2}) {
  for (var i = 0; i < count; i++) {
    print(message);
  }
}

对于能够推断参数类型的函数不用标注类型

注意,这里的函数通常是只回调函数,而不是声明式函数。我们在很多场合已经见过了,例如集合类的 map 操作。

// 正确示例
var names = people.map((person) => person.name);

// 错误示例
var names = people.map((Person person) => person.name);

虽然回调函数的参数名可以自定义,但是推荐使用和对象类型名称一致的变量名,以提高可读性。

对于构造函数初始化参数,无需标注类型

在 Dart 中,我们通常会使用 this.xx 放在构造函数中来对成员属性进行初始化。这种情况下,构造函数声明的参数没必要使用类型标注。

// 正确示例
class Point {
  double x, y;
  Point(this.x, this.y);
}

// 错误示例
class Point {
  double x, y;
  Point(double this.x, double this.y);
}

使用泛型时,如果无法推断类型则需要明确标注类型

虽然 Dart 能够有效推断泛型的类型,但是有些情况下,没有足够上下文信息直接推断出类型,这个时候就需要明确标注类型。

// 正确示例
var playerScores = <String, int>{};
final events = StreamController<Event>();

// 错误示例
var playerScores = {};
final events = StreamController()

还有些时候,泛型的赋值是通过一个表达式完成的,如果泛型的初始值不是局部变量,那么使用类型标注会使得我们的代码更健壮也更易读。

// 正确示例
class Downloader {
  final Completer<String> response = Completer();
}

// 错误示例
class Downloader {
  final response = Completer();
}

针对这一条,其实也有对应的另一点,那就是如果泛型的类型能够推断出来了,那就没必要再标注类型了。比如上面的例子,Completer 已经能够推断是Completer<String>() 了,就没必要额外加一个<String>标注。

// 错误示例
class Downloader {
  final Completer<String> response = Completer<String>();
}

避免使用不完整的泛型类型

这种情况通常出现在集合情况下,比如认为 List 已经能够推断类型了,从而省略类型参数,或者在使用 Map 的时候不指定具体的 K、V 类型。这种情况下,Dart 会认为是 dynamic 类型。

// 正确示例
List<num> numbers = [1, 2, 3];
var completer = Completer<Map<String, int>>();

// 错误示例
List numbers = [1, 2, 3];
var completer = Completer<Map>();

与其让类型推断失败,不如标注为 dynamic

通常,在类型推断没有匹配到类型时,会默认为 dynamic。但是,如果我们本身就需要一个 dynamic 类型的话,那么主动标注为 dynamic 会更好,因为这会让代码阅读者知道这里本身就需要的是一个 dynamic 类型,这种情况下在接收后端的数据时会经常用到。

// 正确示例
dynamic mergeJson(dynamic original, dynamic changes) => ...
  
Map<String, dynamic> readJson() => ...

void printUsers() {
  var json = readJson();
  var users = json['users'];
  print(users);
}

// 错误示例
mergeJson(original, changes) => ...

当使用函数作为参数时,最好是做类型标注

Dart 的 Function 是一个特殊的函数标识符,理论上我们可以使用 Function 匹配任何函数参数,但是这样会导致一个问题就是滥用或者误用导致程序可读性、可维护性下降。因此,对于 Function 作为函数参数的情况下,最好是明确类型,包括返回值和参数类型。此外,如果函数参数过长,可以使用 typedef 定义函数别名的方式来提高可读性。

// 正确示例
bool isValid(String value, bool Function(String) test) => ...
  
// 错误示例
bool isValid(String value, Function test) => ...

这种情况也有例外,那就是传递的函数参数需要处理多种类型的时候,可以直接传递 Function,例如下面的错误处理函数,

// 正确示例
void handleError(void Function() operation, Function errorHandler) {
  try {
    operation();
  } catch (err, stack) {
    if (errorHandler is Function(Object)) {
      errorHandler(err);
    } else if (errorHandler is Function(Object, StackTrace)) {
      errorHandler(err, stack);
    } else {
      throw ArgumentError('errorHandler has wrong signature.');
    }
  }
}

不要使用弃用的 typedef 语法

早期的 Dart支持下面的 typedef 语法:

// 已弃用
typedef int Comparison<T>(T a, T b);
typedef bool TestNumber(num);

看起来像是泛型,实际上是使用了 dynamic,上面的两个函数等价于:

int Comparison(dynamic a, dynamic b);
bool TestNumber(dynamic);

正确的用法是使用赋值的方式:

// 正确示例
typedef Comparison<T> = int Function(T, T);
typedef Comparison<T> = int Function(T a, T b);

对于没有返回值的异步函数,使用 Future作为返回类型

对于没有返回值的异步函数,我们可能直接声明为 void。但是,不排除调用者会使用 await 调用,此时会需要返回值为 Future<void>。因此,一个好的习惯是使用 Future<void>作没有返回值的异步函数的返回类型。当然,如果确定没有任何调用者会需要使用await等待异步函数执行完成(比如上报错误日志这种非关键操作),那么可以声明为 void。

避免使用 FutureOr作为返回类型

使用FutureOr<T>作为返回类型时,意味着调用者需要先检查返回值类型再做接下来的业务处理,而直接使用 Future返回会使得调用者的代码更一致。

// 正确示例
Future<int> triple(FutureOr<int> value) async => (await value) * 3;

// 错误示例
FutureOr<int> triple(FutureOr<int> value) {
  if (value is int) return value * 3;
  return value.then((v) => v * 3);
}

只有一种情况下使用 FutureOr,那就是在协变(contravariant)场合。这种情况下,实际上是将一种类型通过异步操作转换为另一种类型。例如下面的例子:

Stream<S> asyncMap<T, S>(
    Iterable<T> iterable, FutureOr<S> Function(T) callback) async* {
  for (final element in iterable) {
    yield await callback(element);
  }
}

总结

可以看到,Dart 在更新升级的过程中,越来越注重约定的重要性。良好的约定能够减少很多程序的隐患,比如本篇提到的参数类型标注,函数明确参数类型和返回值类型等等。其实,类型标注本身就是一种约定 —— 告诉代码对象是什么,该如何使用。在我们实际开发过程中,也应该遵循约定这样的习惯,这在团队协作或编写基础类库中十分重要。