戳这里了解《Flutter入门与实战》专栏,持续更新、系统学习!
前言
Dart 作为一门比较年轻的语言,拥有了众多现代化编程语言的特征,比如有 JavaScript ES6特性中的...
展开操作符,箭头函数等。同时,在类型定义上又吸收了类型推断特性,使得我们可以使用 var
关键字直接定义变量。此外,Dart 还支持运行时的动态类型,我们可以使用 dynamic 关键字来声明一个未知的类型,然后再运行时再做具体的类型判断。那么,到底如何合理地使用类型?本篇我们来介绍 Dart 的类型使用指南。
类型概述
当我们在代码中写下一个类型的时候,意味着声明的值在接下来的代码中需要遵循类型约定。类型通常在两种地方出现:变量(成员)类型标注或指定泛型类型。
类型标注通常认为是所谓的静态类型。我们可以对变量、函数参数、类成员属性或返回值进行类型标注。例如,下面的例子中,bool
和 String
就是类型标注。这类静态声明的结构意味着在代码运行阶段类型不会改变,事实上,如果使用错误的类型的话编译器会直接报错。
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
类型,导致编译器报错。
解决这种问题的办法是在初始化变量值的时候,尽可能地精确,比如上面的例子应该修改为,此时 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) => ...
如何保证语义上的明晰没有准确的定义,但是下面的几条是很好的例子:
- 字面含义明确,录入变量名为
name
,email
这类的,我们通常会知道是字符串。 - 构造方法初始化变量;
- 引用那些明确类型的常量进行初始化;
- 数值或字符串的简单赋值表达式;
- 工厂方法,例如 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 函数参数最佳实践指南已经有介绍。函数定义了外界调用的接口,明确参数类型可以约束调用者传入的参数类型,有效避免参数类型错误导致的运行异常。同时,明确参数类型也有助于提高代码的可读性。请注意,即便是函数参数的默认值看起来像初始化一样,实际上并不会进行类型推断吗,如果不指定类型则会认为是 dynamic
,因此也需要标注类型。
// 正确示例
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 在更新升级的过程中,越来越注重约定的重要性。良好的约定能够减少很多程序的隐患,比如本篇提到的参数类型标注,函数明确参数类型和返回值类型等等。其实,类型标注本身就是一种约定 —— 告诉代码对象是什么,该如何使用。在我们实际开发过程中,也应该遵循约定这样的习惯,这在团队协作或编写基础类库中十分重要。