高效 Dart: 用法
你可以在日常的 Dart 代码中使用这些指南。你的库的用户可能看不出你已经内化了这里的想法,但它的维护者肯定能看出来。
库
#这些指南帮助你以一致、可维护的方式将程序由多个文件组成。为了使这些指南简明扼要,它们使用“import”来涵盖 import
和 export
指令。这些指南对两者同样适用。
DO 在 part of
指令中使用字符串
#Linter 规则: use_string_in_part_of_directives
许多 Dart 开发者完全避免使用 part
。他们认为当每个库都是一个文件时,更容易理解代码。如果你选择使用 part
将库的一部分拆分到另一个文件中,Dart 要求该文件反过来指明它是哪个库的一部分。
Dart 允许 part of
指令使用库的名称。为库命名是一个遗留特性,现在已不推荐使用。在确定 part 属于哪个库时,库名称可能会引入歧义。
首选的语法是使用指向库文件的 URI 字符串。如果你有一个库 my_library.dart
,其中包含
library my_library;
part 'some/other/file.dart';
则 part 文件应使用库文件的 URI 字符串
part of '../../my_library.dart';
而不是库名称
part of my_library;
DON'T 导入位于另一个包的 src
目录中的库
#Linter 规则: implementation_imports
lib 下的 src
目录被指定包含包自身实现的私有库。包维护者对包进行版本控制时会考虑此约定。他们可以自由地对 src
下的代码进行大幅更改,而无需将其视为对包的破坏性更改。
这意味着如果你导入了其他包的私有库,该包的一个次要的、理论上非破坏性的点版本更新可能会破坏你的代码。
DON'T 允许导入路径进入或离开 lib
#Linter 规则: avoid_relative_lib_imports
package:
导入允许你访问包的 lib
目录内的库,而无需担心包存储在计算机上的位置。为了使其正常工作,你不能有需要 lib
在磁盘上相对于其他文件位于某个位置的导入。换句话说,lib
目录内文件的相对导入路径不能超出 lib
目录访问外部文件,而 lib
目录外的库也不能使用相对路径进入 lib
目录。这两种做法都会导致令人困惑的错误和损坏的程序。
例如,假设你的目录结构如下所示
my_package
└─ lib
└─ api.dart
test
└─ api_test.dart
假设 api_test.dart
以两种方式导入 api.dart
import 'package:my_package/api.dart';
import '../lib/api.dart';
Dart 认为这些是两个完全不相关的库的导入。为了避免混淆 Dart 和你自己,请遵循以下两条规则
- 不要在导入路径中使用
/lib/
。 - 不要使用
../
跳出lib
目录。
相反,当你需要进入包的 lib
目录时(即使是从同一个包的 test
目录或任何其他顶层目录),请使用 package:
导入。
import 'package:my_package/api.dart';
包绝不应该跳出其 lib
目录并从包中的其他地方导入库。
PREFER 使用相对导入路径
#Linter 规则: prefer_relative_imports
只要上一条规则不适用,就遵循本条规则。当导入没有跨越 lib
时,优先使用相对导入。它们更短。例如,假设你的目录结构如下所示
my_package
└─ lib
├─ src
│ └─ stuff.dart
│ └─ utils.dart
└─ api.dart
test
│─ api_test.dart
└─ test_utils.dart
以下是各个库应该如何相互导入
import 'src/stuff.dart';
import 'src/utils.dart';
import '../api.dart';
import 'stuff.dart';
import 'package:my_package/api.dart'; // Don't reach into 'lib'.
import 'test_utils.dart'; // Relative within 'test' is fine.
Null
#DON'T 显式地将变量初始化为 null
#Linter 规则: avoid_init_to_null
如果变量是非可空类型,如果你在它明确初始化之前尝试使用它,Dart 会报告编译错误。如果变量是可空的,那么它会为你隐式地初始化为 null
。Dart 中没有“未初始化内存”的概念,也不需要显式地将变量初始化为 null
以确保“安全”。
Item? bestDeal(List<Item> cart) {
Item? bestItem;
for (final item in cart) {
if (bestItem == null || item.price < bestItem.price) {
bestItem = item;
}
}
return bestItem;
}
Item? bestDeal(List<Item> cart) {
Item? bestItem = null;
for (final item in cart) {
if (bestItem == null || item.price < bestItem.price) {
bestItem = item;
}
}
return bestItem;
}
DON'T 使用显式的默认值 null
#Linter 规则: avoid_init_to_null
如果你将一个可空参数设为可选,但没有给它默认值,语言会隐式地使用 null
作为默认值,因此无需写出来。
void error([String? message]) {
stderr.write(message ?? '\n');
}
void error([String? message = null]) {
stderr.write(message ?? '\n');
}
DON'T 在相等性操作中使用 true
或 false
#对非可空布尔表达式使用相等性运算符与布尔字面量进行比较是多余的。通常更简单的方法是移除相等性运算符,并在必要时使用一元否定运算符 !
if (nonNullableBool) {
...
}
if (!nonNullableBool) {
...
}
if (nonNullableBool == true) {
...
}
if (nonNullableBool == false) {
...
}
要评估一个可空的布尔表达式,你应该使用 ??
或显式的 != null
检查。
// If you want null to result in false:
if (nullableBool ?? false) {
...
}
// If you want null to result in false
// and you want the variable to type promote:
if (nullableBool != null && nullableBool) {
...
}
// Static error if null:
if (nullableBool) {
...
}
// If you want null to be false:
if (nullableBool == true) {
...
}
nullableBool == true
是一个可行的表达式,但不应使用,原因如下
它没有表明代码与
null
有关。因为它与
null
的关联不明显,所以很容易被误认为是非可空的情况,此时相等性运算符是多余的,可以删除。但这仅在左侧的布尔表达式不可能产生 null 时才成立,而当它可能产生 null 时则不成立。布尔逻辑令人困惑。如果
nullableBool
为 null,那么nullableBool == true
意味着条件评估为false
。
??
运算符清楚地表明与 null 相关的事情正在发生,因此不会被误认为是多余的操作。逻辑也更清晰;表达式结果为 null
与布尔字面量相同。
在条件内对变量使用诸如 ??
的 null 感知运算符不会将变量提升为非可空类型。如果你希望在 if
语句体内部提升变量,最好使用显式的 != null
检查而不是 ??
。
如果需要检查 late
变量是否已初始化,则 AVOID 使用它们
#Dart 没有办法判断一个 late
变量是否已经初始化或赋值。如果你访问它,它会立即运行初始化程序(如果有的话)或抛出异常。有时你可能有一些延迟初始化的状态,late
可能很适合,但你也需要能够判断初始化是否已经发生。
尽管你可以通过将状态存储在 late
变量中并使用一个单独的布尔字段来跟踪变量是否已设置来检测初始化,但这冗余了,因为 Dart 内部维护着 late
变量的初始化状态。相反,通常更清晰的方法是使变量非 late
且可空。然后你可以通过检查 null
来判断变量是否已初始化。
当然,如果 null
是变量的有效初始值,那么有一个单独的布尔字段可能确实有意义。
CONSIDER 对可空类型使用类型提升或 null-check 模式
#检查可空变量不等于 null
会将变量提升为非可空类型。这允许你访问该变量上的成员,并将其传递给期望非可空类型的函数。
然而,类型提升仅支持局部变量、参数和私有 final 字段。开放修改的值无法进行类型提升。
将成员声明为私有和final,正如我们通常建议的,通常足以绕过这些限制。但是,这并非总是可行的选项。
绕过类型提升限制的一种模式是使用null-check pattern。这同时确认成员的值不为 null,并将该值绑定到具有相同基础类型的新非可空变量。
class UploadException {
final Response? response;
UploadException([this.response]);
@override
String toString() {
if (this.response case var response?) {
return 'Could not complete upload to ${response.url} '
'(error code ${response.errorCode}): ${response.reason}.';
}
return 'Could not upload (no response).';
}
}
另一种变通方法是将字段的值赋给局部变量。对该变量进行 null 检查会触发类型提升,因此你可以安全地将其视为非可空。
class UploadException {
final Response? response;
UploadException([this.response]);
@override
String toString() {
final response = this.response;
if (response != null) {
return 'Could not complete upload to ${response.url} '
'(error code ${response.errorCode}): ${response.reason}.';
}
return 'Could not upload (no response).';
}
}
使用局部变量时要小心。如果你需要写回字段,确保不要写回局部变量。(将局部变量设为 final
可以防止此类错误。)此外,如果字段在局部变量作用域内可能发生变化,则局部变量的值可能已过期。
有时最好直接对字段使用 !
。然而,在某些情况下,使用局部变量或 null-check pattern 比每次需要将值视为非 null 时都使用 !
更简洁安全
class UploadException {
final Response? response;
UploadException([this.response]);
@override
String toString() {
if (response != null) {
return 'Could not complete upload to ${response!.url} '
'(error code ${response!.errorCode}): ${response!.reason}.';
}
return 'Could not upload (no response).';
}
}
字符串
#以下是在 Dart 中构建字符串时需要记住的一些最佳实践。
DO 使用相邻字符串来连接字符串字面量
#Linter 规则: prefer_adjacent_string_concatenation
如果你有两个字符串字面量——不是值,而是实际的引用字面量形式——你不需要使用 +
来连接它们。就像 C 和 C++ 一样,只需将它们并排放置即可。这是创建无法在一行中显示的单个长字符串的好方法。
raiseAlarm(
'ERROR: Parts of the spaceship are on fire. Other '
'parts are overrun by martians. Unclear which are which.',
);
raiseAlarm(
'ERROR: Parts of the spaceship are on fire. Other ' +
'parts are overrun by martians. Unclear which are which.',
);
PREFER 使用插值来构建字符串和值
#Linter 规则: prefer_interpolation_to_compose_strings
如果你来自其他语言,你可能习惯于使用冗长的 +
链来从字面量和其他值构建字符串。这在 Dart 中也能工作,但使用插值几乎总是更清晰、更简洁
'Hello, $name! You are ${year - birth} years old.';
'Hello, ' + name + '! You are ' + (year - birth).toString() + ' y...';
请注意,本指南适用于组合多个字面量和值。当仅将单个对象转换为字符串时,使用 .toString()
是可以的。
AVOID 在不需要时在插值中使用花括号
#Linter 规则: unnecessary_brace_in_string_interps
如果你插值一个简单标识符,且后面没有立即跟其他字母数字文本,则应省略 {}
。
var greeting = 'Hi, $name! I love your ${decade}s costume.';
var greeting = 'Hi, ${name}! I love your ${decade}s costume.';
集合
#Dart 原生支持四种集合类型:列表、映射、队列和集合。以下最佳实践适用于集合。
DO 尽可能使用集合字面量
#Linter 规则: prefer_collection_literals
Dart 有三种核心集合类型:List、Map 和 Set。Map 和 Set 类像大多数类一样具有无名构造函数。但由于这些集合使用非常频繁,Dart 提供了更友好的内建语法来创建它们
var points = <Point>[];
var addresses = <String, Address>{};
var counts = <int>{};
var addresses = Map<String, Address>();
var counts = Set<int>();
请注意,本指南不适用于这些类的命名构造函数。List.from()
、Map.fromIterable()
等都有其用途。(List 类也有一个无名构造函数,但在空安全 Dart 中禁止使用。)
集合字面量在 Dart 中特别强大,因为它们提供了访问扩展运算符的能力,用于包含其他集合的内容,以及在构建内容时执行控制流的if
和 for
var arguments = [
...options,
command,
...?modeFlags,
for (var path in filePaths)
if (path.endsWith('.dart')) path.replaceAll('.dart', '.js'),
];
var arguments = <String>[];
arguments.addAll(options);
arguments.add(command);
if (modeFlags != null) arguments.addAll(modeFlags);
arguments.addAll(
filePaths
.where((path) => path.endsWith('.dart'))
.map((path) => path.replaceAll('.dart', '.js')),
);
DON'T 使用 .length
来检查集合是否为空
#Linter 规则: prefer_is_empty, prefer_is_not_empty
Iterable 契约不要求集合知道其长度或能够在常数时间内提供长度。仅为了查看集合是否包含任何内容而调用 .length
可能会非常慢。
相反,有更快、更易读的 getter:.isEmpty
和 .isNotEmpty
。使用那个不需要你对结果取反的 getter。
if (lunchBox.isEmpty) return 'so hungry...';
if (words.isNotEmpty) return words.join(' ');
if (lunchBox.length == 0) return 'so hungry...';
if (!words.isEmpty) return words.join(' ');
AVOID 对 Iterable.forEach()
使用函数字面量
#Linter 规则: avoid_function_literals_in_foreach_calls
forEach()
函数在 JavaScript 中广泛使用,因为内建的 for-in
循环通常无法满足你的需求。在 Dart 中,如果你想遍历一个序列,惯用的方式是使用循环。
for (final person in people) {
...
}
people.forEach((person) {
...
});
请注意,本指南特别指出“函数字面量”。如果你想在每个元素上调用一些已存在的函数,使用 forEach()
是可以的。
people.forEach(print);
另请注意,使用 Map.forEach()
总是可以的。Map 不可迭代,因此本指南不适用。
DON'T 使用 List.from()
,除非你打算改变结果的类型
#给定一个 Iterable,有两种显而易见的方式来生成一个包含相同元素的新 List
var copy1 = iterable.toList();
var copy2 = List.from(iterable);
显而易见的区别是第一个更短。重要的区别是第一个保留了原始对象的类型参数
// Creates a List<int>:
var iterable = [1, 2, 3];
// Prints "List<int>":
print(iterable.toList().runtimeType);
// Creates a List<int>:
var iterable = [1, 2, 3];
// Prints "List<dynamic>":
print(List.from(iterable).runtimeType);
如果你想改变类型,那么调用 List.from()
会很有用
var numbers = [1, 2.3, 4]; // List<num>.
numbers.removeAt(1); // Now it only contains integers.
var ints = List<int>.from(numbers);
但如果你的目标只是复制可迭代对象并保留其原始类型,或者你不在乎类型,那么使用 toList()
。
DO 使用 whereType()
根据类型过滤集合
#Linter 规则: prefer_iterable_whereType
假设你有一个包含混合对象的列表,并且只想从中获取整数。你可以像这样使用 where()
var objects = [1, 'a', 2, 'b', 3];
var ints = objects.where((e) => e is int);
这很冗长,但更糟糕的是,它返回的可迭代对象类型可能不是你想要的。在此示例中,它返回一个 Iterable<Object>
,尽管你可能想要一个 Iterable<int>
,因为这是你正在过滤到的类型。
有时你会看到通过添加 cast()
来“修正”上述错误的代码
var objects = [1, 'a', 2, 'b', 3];
var ints = objects.where((e) => e is int).cast<int>();
这很冗长,并且会创建两个包装器,带来两层间接寻址和冗余的运行时检查。幸运的是,核心库提供了whereType()
方法来解决此精确用例
var objects = [1, 'a', 2, 'b', 3];
var ints = objects.whereType<int>();
使用 whereType()
简洁明了,生成所需类型的 Iterable,并且没有不必要的包装层。
当附近的操作可以实现时,DON'T 使用 cast()
#通常当你处理可迭代对象或流时,会对其执行多次转换。最后,你想要生成具有特定类型参数的对象。与其附加一个 cast()
调用,不如看看现有转换中是否有可以改变类型的。
如果你已经调用了 toList()
,将其替换为调用 List<T>.from()
,其中 T
是你想要的列表结果类型。
var stuff = <dynamic>[1, 2];
var ints = List<int>.from(stuff);
var stuff = <dynamic>[1, 2];
var ints = stuff.toList().cast<int>();
如果你正在调用 map()
,请为其提供一个显式的类型参数,以便它生成所需类型的可迭代对象。类型推断通常会根据你传递给 map()
的函数为你选择正确的类型,但有时你需要明确指定。
var stuff = <dynamic>[1, 2];
var reciprocals = stuff.map<double>((n) => n * 2);
var stuff = <dynamic>[1, 2];
var reciprocals = stuff.map((n) => n * 2).cast<double>();
AVOID 使用 cast()
#这是上一条规则的更温和的泛化。有时没有附近的运算可以用来固定某个对象的类型。即使如此,如果可能,也要避免使用 cast()
来“改变”集合的类型。
优先使用以下任一选项
使用正确的类型创建它。更改首次创建集合的代码,使其具有正确的类型。
在访问时转换元素。如果你立即迭代集合,请在迭代内部转换每个元素。
使用
List.from()
急切地进行转换。如果你最终会访问集合中的大多数元素,并且不需要对象由原始活动对象支持,请使用List.from()
进行转换。cast()
方法返回一个惰性集合,它在每次操作时检查元素类型。如果你仅对少量元素执行少量操作,这种惰性可能不错。但在许多情况下,惰性验证和包装的开销超过了好处。
以下是使用正确类型创建它的示例:
List<int> singletonList(int value) {
var list = <int>[];
list.add(value);
return list;
}
List<int> singletonList(int value) {
var list = []; // List<dynamic>.
list.add(value);
return list.cast<int>();
}
以下是在访问时转换每个元素的示例:
void printEvens(List<Object> objects) {
// We happen to know the list only contains ints.
for (final n in objects) {
if ((n as int).isEven) print(n);
}
}
void printEvens(List<Object> objects) {
// We happen to know the list only contains ints.
for (final n in objects.cast<int>()) {
if (n.isEven) print(n);
}
}
以下是使用 List.from()
急切地进行转换的示例:
int median(List<Object> objects) {
// We happen to know the list only contains ints.
var ints = List<int>.from(objects);
ints.sort();
return ints[ints.length ~/ 2];
}
int median(List<Object> objects) {
// We happen to know the list only contains ints.
var ints = objects.cast<int>();
ints.sort();
return ints[ints.length ~/ 2];
}
这些替代方法当然并非总是有效,有时 cast()
也是正确答案。但请记住,该方法存在一定风险且不受欢迎——它可能很慢,并且如果你不小心,可能会在运行时失败。
函数
#在 Dart 中,即使是函数也是对象。以下是一些涉及函数的最佳实践。
DO 使用函数声明将函数绑定到名称
#Linter 规则: prefer_function_declarations_over_variables
现代语言已经意识到局部嵌套函数和闭包的实用性。在一个函数内部定义另一个函数是很常见的。在许多情况下,这个函数立即用作回调,并且不需要名称。函数表达式对此非常有用。
但是,如果你确实需要给它一个名称,请使用函数声明语句,而不是将 lambda 绑定到变量。
void main() {
void localFunction() {
...
}
}
void main() {
var localFunction = () {
...
};
}
当 tear-off 可以实现时,DON'T 创建 lambda
#Linter 规则: unnecessary_lambdas
当你在没有括号的情况下引用函数、方法或命名构造函数时,Dart 会创建一个tear-off。这是一个闭包,它接受与该函数相同的参数,并在你调用它时调用底层函数。如果你的代码需要一个闭包来调用接受与闭包相同参数的命名函数,不要将调用包装在 lambda 中。使用 tear-off。
var charCodes = [68, 97, 114, 116];
var buffer = StringBuffer();
// Function:
charCodes.forEach(print);
// Method:
charCodes.forEach(buffer.write);
// Named constructor:
var strings = charCodes.map(String.fromCharCode);
// Unnamed constructor:
var buffers = charCodes.map(StringBuffer.new);
var charCodes = [68, 97, 114, 116];
var buffer = StringBuffer();
// Function:
charCodes.forEach((code) {
print(code);
});
// Method:
charCodes.forEach((code) {
buffer.write(code);
});
// Named constructor:
var strings = charCodes.map((code) => String.fromCharCode(code));
// Unnamed constructor:
var buffers = charCodes.map((code) => StringBuffer(code));
变量
#以下最佳实践描述了如何在 Dart 中最佳使用变量。
DO 在局部变量上遵循一致的 var
和 final
规则
#大多数局部变量不应具有类型注解,应仅使用 var
或 final
声明。关于何时使用其中之一,有两种广泛使用的规则
对于未重新赋值的局部变量使用
final
,对于重新赋值的局部变量使用var
。对所有局部变量使用
var
,即使是未重新赋值的变量。永远不要对局部变量使用final
。(当然,仍然鼓励对字段和顶级变量使用final
。)
任何一条规则都是可以接受的,但请选择一条并在你的代码中始终如一地应用它。这样,当读者看到 var
时,他们就知道这是否意味着该变量稍后在函数中会被赋值。
AVOID 存储可以计算出的值
#在设计类时,你通常希望公开对同一底层状态的多种视图。你经常看到代码在构造函数中计算所有这些视图然后存储它们
class Circle {
double radius;
double area;
double circumference;
Circle(double radius)
: radius = radius,
area = pi * radius * radius,
circumference = pi * 2.0 * radius;
}
这段代码有两个问题。首先,它可能浪费内存。严格来说,面积和周长是缓存。它们是存储的计算结果,我们可以从已有的其他数据重新计算。它们正在用增加的内存换取减少的 CPU 使用。我们确定存在需要这种权衡的性能问题吗?
更糟糕的是,代码是错误的。缓存的问题在于失效——你怎么知道缓存何时过期需要重新计算?在这里,我们永远不知道,即使 radius
是可变的。你可以赋一个不同的值,area
和 circumference
将保留其之前的、现在不正确的值。
要正确处理缓存失效,我们需要这样做
class Circle {
double _radius;
double get radius => _radius;
set radius(double value) {
_radius = value;
_recalculate();
}
double _area = 0.0;
double get area => _area;
double _circumference = 0.0;
double get circumference => _circumference;
Circle(this._radius) {
_recalculate();
}
void _recalculate() {
_area = pi * _radius * _radius;
_circumference = pi * 2.0 * _radius;
}
}
要编写、维护、调试和阅读这些代码非常麻烦。相反,你的第一个实现应该是
class Circle {
double radius;
Circle(this.radius);
double get area => pi * radius * radius;
double get circumference => pi * 2.0 * radius;
}
这段代码更短,使用更少的内存,并且更不容易出错。它存储表示圆所需的最小数据量。由于只有一个单一的真相来源,因此不会出现字段不同步的情况。
在某些情况下,你可能需要缓存慢速计算的结果,但请仅在你确定存在性能问题后才这样做,并且要小心处理,并留下注释解释该优化。
成员
#在 Dart 中,对象拥有成员,它们可以是函数(方法)或数据(实例变量)。以下最佳实践适用于对象的成员。
DON'T 不必要地将字段包装在 getter 和 setter 中
#Linter 规则: unnecessary_getters_setters
在 Java 和 C# 中,通常会将所有字段隐藏在 getter 和 setter(或 C# 中的属性)之后,即使实现只是转发到字段。这样一来,如果你需要在这些成员中执行更多工作,你可以不必修改调用点。这是因为在 Java 中调用 getter 方法与访问字段不同,而在 C# 中访问属性与访问原始字段不是二进制兼容的。
Dart 没有这个限制。字段和 getter/setter 完全无法区分。你可以在类中公开一个字段,然后将其包装在 getter 和 setter 中,而无需修改任何使用该字段的代码。
class Box {
Object? contents;
}
class Box {
Object? _contents;
Object? get contents => _contents;
set contents(Object? value) {
_contents = value;
}
}
PREFER 使用 final
字段来创建只读属性
#如果你有一个字段,外部代码应该能够看到但不能赋值,一个在许多情况下都奏效的简单解决方案是将其标记为 final
。
class Box {
final contents = [];
}
class Box {
Object? _contents;
Object? get contents => _contents;
}
当然,如果你需要在构造函数外部内部为字段赋值,你可能需要使用“私有字段,公共 getter”模式,但在需要之前不要这样做。
CONSIDER 对简单成员使用 =>
#Linter 规则: prefer_expression_function_bodies
除了对函数表达式使用 =>
外,Dart 还允许你使用它定义成员。这种风格非常适合仅计算并返回值的简单成员。
double get area => (right - left) * (bottom - top);
String capitalize(String name) =>
'${name[0].toUpperCase()}${name.substring(1)}';
编写代码的人似乎很喜欢 =>
,但滥用它很容易导致代码难以阅读。如果你的声明超过几行或包含深度嵌套的表达式——级联和条件运算符是常见的罪魁祸首——为了你自己以及所有需要阅读你代码的人好,请使用块体和一些语句。
Treasure? openChest(Chest chest, Point where) {
if (_opened.containsKey(chest)) return null;
var treasure = Treasure(where);
treasure.addAll(chest.contents);
_opened[chest] = treasure;
return treasure;
}
Treasure? openChest(Chest chest, Point where) => _opened.containsKey(chest)
? null
: _opened[chest] = (Treasure(where)..addAll(chest.contents));
你也可以对不返回值的成员使用 =>
。当 setter 很小且有对应的使用 =>
的 getter 时,这是惯用法。
num get x => center.x;
set x(num value) => center = Point(value, center.y);
DON'T 使用 this.
,除非是为了重定向到命名构造函数或避免遮盖
#Linter 规则: unnecessary_this
JavaScript 需要显式的 this.
来引用当前正在执行方法的对象上的成员,但 Dart——就像 C++、Java 和 C# 一样——没有这个限制。
只有两种情况下你需要使用 this.
。一种是当同名的局部变量遮盖了你想要访问的成员时
class Box {
Object? value;
void clear() {
this.update(null);
}
void update(Object? value) {
this.value = value;
}
}
class Box {
Object? value;
void clear() {
update(null);
}
void update(Object? value) {
this.value = value;
}
}
另一种使用 this.
的情况是重定向到命名构造函数时
class ShadeOfGray {
final int brightness;
ShadeOfGray(int val) : brightness = val;
ShadeOfGray.black() : this(0);
// This won't parse or compile!
// ShadeOfGray.alsoBlack() : black();
}
class ShadeOfGray {
final int brightness;
ShadeOfGray(int val) : brightness = val;
ShadeOfGray.black() : this(0);
// But now it will!
ShadeOfGray.alsoBlack() : this.black();
}
请注意,构造函数参数永远不会在构造函数初始化列表中遮盖字段
class Box extends BaseBox {
Object? value;
Box(Object? value) : value = value, super(value);
}
这看起来令人惊讶,但会按你期望的方式工作。幸运的是,由于初始化形参和 super 初始化器,此类代码相对较少见。
DO 尽可能在声明时初始化字段
#如果一个字段不依赖于任何构造函数参数,那么它可以在声明时初始化,也应该在声明时初始化。当类有多个构造函数时,这样可以减少代码量并避免重复。
class ProfileMark {
final String name;
final DateTime start;
ProfileMark(this.name) : start = DateTime.now();
ProfileMark.unnamed() : name = '', start = DateTime.now();
}
class ProfileMark {
final String name;
final DateTime start = DateTime.now();
ProfileMark(this.name);
ProfileMark.unnamed() : name = '';
}
有些字段无法在声明时初始化,因为它们需要引用 this
——例如,使用其他字段或调用方法。然而,如果字段被标记为 late
,那么初始化程序就可以访问 this
。
当然,如果字段依赖于构造函数参数,或者由不同的构造函数以不同的方式初始化,则本指南不适用。
构造函数
#以下最佳实践适用于为类声明构造函数。
DO 尽可能使用初始化形参 (initializing formals)
#Linter 规则: prefer_initializing_formals
许多字段直接由构造函数参数初始化,例如
class Point {
double x, y;
Point(double x, double y) : x = x, y = y;
}
这里我们需要输入 x
四次来定义一个字段。我们可以做得更好
class Point {
double x, y;
Point(this.x, this.y);
}
构造函数参数前的 this.
语法称为“初始化形参 (initializing formal)”。你并非总是能利用它。有时你希望有一个命名参数,其名称与你正在初始化的字段名称不匹配。但是,当你可以使用初始化形参时,你应该使用。
当构造函数初始化列表可以实现时,DON'T 使用 late
#Dart 要求你在读取非可空字段之前初始化它们。由于字段可以在构造函数体内部读取,这意味着如果你在主体运行之前未初始化非可空字段,你会收到错误。
你可以通过将字段标记为 late
来消除此错误。如果你在字段初始化之前访问它,这会将编译时错误转换为运行时错误。在某些情况下你需要这样做,但通常正确的解决方法是在构造函数初始化列表中初始化字段
class Point {
double x, y;
Point.polar(double theta, double radius)
: x = cos(theta) * radius,
y = sin(theta) * radius;
}
class Point {
late double x, y;
Point.polar(double theta, double radius) {
x = cos(theta) * radius;
y = sin(theta) * radius;
}
}
初始化列表让你能够访问构造函数参数,并在字段可读之前对其进行初始化。因此,如果可以使用初始化列表,那比将字段设为 late
并损失一些静态安全性和性能要好。
DO 对空构造函数体使用 ;
而不是 {}
#Linter 规则: empty_constructor_bodies
在 Dart 中,具有空主体的构造函数可以使用分号结束。(实际上,对于 const 构造函数,这是必需的。)
class Point {
double x, y;
Point(this.x, this.y);
}
class Point {
double x, y;
Point(this.x, this.y) {}
}
DON'T 使用 new
#Linter 规则: unnecessary_new
调用构造函数时,new
关键字是可选的。它的含义不明确,因为 factory 构造函数意味着 new
调用实际上可能不返回新对象。
语言仍然允许使用 new
,但将其视为已弃用,并避免在你的代码中使用它。
Widget build(BuildContext context) {
return Row(
children: [
RaisedButton(child: Text('Increment')),
Text('Click!'),
],
);
}
Widget build(BuildContext context) {
return new Row(
children: [
new RaisedButton(child: new Text('Increment')),
new Text('Click!'),
],
);
}
DON'T 重复使用 const
#Linter 规则: unnecessary_const
在表达式必须是常量的上下文中,const
关键字是隐式的,不需要写,也不应该写。这些上下文是任何表达式内部
- const 集合字面量。
- const 构造函数调用
- 元数据注解。
- const 变量声明的初始化程序。
- switch case 表达式——紧跟在
case
之后但在:
之前的部分,而不是 case 的主体。
(默认值不包含在此列表中,因为 Dart 的未来版本可能支持非 const 默认值。)
基本上,任何写 new
而不是 const
会出错的地方,Dart 都允许你省略 const
。
const primaryColors = [
Color('red', [255, 0, 0]),
Color('green', [0, 255, 0]),
Color('blue', [0, 0, 255]),
];
const primaryColors = const [
const Color('red', const [255, 0, 0]),
const Color('green', const [0, 255, 0]),
const Color('blue', const [0, 0, 255]),
];
错误处理
#当程序中发生错误时,Dart 使用异常。以下最佳实践适用于捕获和抛出异常。
AVOID 没有 on
子句的 catch
#Linter 规则: avoid_catches_without_on_clauses
没有 on
限定符的 catch 子句会捕获 try 块中的代码抛出的任何内容。Pokémon 异常处理很可能不是你想要的。你的代码是否正确处理 StackOverflowError 或 OutOfMemoryError?如果你在该 try 块中错误地向方法传递了错误的参数,你是希望调试器指向错误,还是希望有用的 ArgumentError 被吞掉?你希望该代码中的任何 assert()
语句因为你捕获了抛出的 AssertionError 而实际上消失吗?
答案很可能是“否”,在这种情况下,你应该过滤你捕获的类型。在大多数情况下,你应该有一个 on
子句,将你限制在你了解并正在正确处理的运行时故障类型范围内。
在极少数情况下,你可能希望捕获任何运行时错误。这通常出现在尝试隔离任意应用程序代码以防止其引起问题的框架或底层代码中。即使在这种情况下,捕获 Exception 通常也比捕获所有类型要好。Exception 是所有运行时错误的基类,不包括指示代码中存在程序性错误的错误。
DON'T 丢弃没有 on
子句的 catch 中的错误
#如果你确实觉得需要捕获代码块中可以抛出的所有内容,请对捕获到的内容进行处理。记录它,显示给用户,或者重新抛出它,但不要静默丢弃它。
DO 仅对程序错误抛出实现 Error
的对象
#Error 类是程序性错误的基类。当抛出该类型或其子接口(如 ArgumentError)的对象时,这意味着你的代码中存在错误。当你的 API 想要向调用者报告它被错误使用时,抛出 Error 会清晰地发送该信号。
相反,如果异常是某种运行时故障,不表示代码中存在错误,那么抛出 Error 会产生误导。此时,应抛出核心 Exception 类之一或其他类型。
DON'T 显式捕获 Error
或实现它的类型
#Linter 规则: avoid_catching_errors
这是前述内容的推论。由于 Error 表示你的代码中存在错误,它应该展开整个调用堆栈,中止程序,并打印堆栈跟踪,以便你可以定位并修复错误。
捕获这些类型的错误会中断该过程并掩盖错误。与其事后添加错误处理代码来处理此异常,不如回到代码中修复导致它被抛出的原因。
DO 使用 rethrow
重新抛出捕获的异常
#Linter 规则: use_rethrow_when_possible
如果你决定重新抛出异常,优先使用 rethrow
语句而不是使用 throw
抛出同一个异常对象。rethrow
保留了异常的原始堆栈跟踪。而 throw
则会将堆栈跟踪重置到最后抛出的位置。
try {
somethingRisky();
} catch (e) {
if (!canHandle(e)) throw e;
handle(e);
}
try {
somethingRisky();
} catch (e) {
if (!canHandle(e)) rethrow;
handle(e);
}
异步
#Dart 有多种语言特性来支持异步编程。以下最佳实践适用于异步编码。
PREFER 使用 async/await 而不是原始 future
#异步代码以难以阅读和调试而闻名,即使使用了 Future 这样的良好抽象。async
/await
语法提高了可读性,并允许你在异步代码中使用 Dart 的所有控制流结构。
Future<int> countActivePlayers(String teamName) async {
try {
var team = await downloadTeam(teamName);
if (team == null) return 0;
var players = await team.roster;
return players.where((player) => player.isActive).length;
} on DownloadException catch (e, _) {
log.error(e);
return 0;
}
}
Future<int> countActivePlayers(String teamName) {
return downloadTeam(teamName)
.then((team) {
if (team == null) return Future.value(0);
return team.roster.then((players) {
return players.where((player) => player.isActive).length;
});
})
.onError<DownloadException>((e, _) {
log.error(e);
return 0;
});
}
当 async
没有有用效果时,DON'T 使用它
#很容易养成在任何与异步相关的函数上都使用 async
的习惯。但在某些情况下,它是多余的。如果你可以在不改变函数行为的情况下省略 async
,那就这么做。
Future<int> fastestBranch(Future<int> left, Future<int> right) {
return Future.any([left, right]);
}
Future<int> fastestBranch(Future<int> left, Future<int> right) async {
return Future.any([left, right]);
}
async
有用的情况包括
你正在使用
await
。(这是显而易见的一点。)你正在异步返回一个错误。
async
然后throw
比return Future.error(...)
更短。你正在返回一个值,并且希望它隐式地被包装在 future 中。
async
比Future.value(...)
更短。
Future<void> usesAwait(Future<String> later) async {
print(await later);
}
Future<void> asyncError() async {
throw 'Error!';
}
Future<String> asyncValue() async => 'value';
CONSIDER 使用高阶方法来转换流
#这与上面关于 iterables 的建议相似。Stream 支持许多相同的方法,并且也能正确处理诸如传输错误、关闭等事情。
AVOID 直接使用 Completer
#许多刚接触异步编程的人想要编写生成 Future 的代码。Future 中的构造函数似乎不符合他们的需求,所以他们最终找到了 Completer 类并使用了它。
Future<bool> fileContainsBear(String path) {
var completer = Completer<bool>();
File(path).readAsString().then((contents) {
completer.complete(contents.contains('bear'));
});
return completer.future;
}
Completer 需要用于两种低层代码:新的异步原语,以及与不使用 Future 的异步代码进行接口。大多数其他代码应该使用 async/await 或 Future.then()
,因为它们更清晰,并且使错误处理更容易。
Future<bool> fileContainsBear(String path) {
return File(path).readAsString().then((contents) {
return contents.contains('bear');
});
}
Future<bool> fileContainsBear(String path) async {
var contents = await File(path).readAsString();
return contents.contains('bear');
}
在区分类型参数可能为 Object
的 FutureOr<T>
时,DO 检查 Future<T>
#在你可以对 FutureOr<T>
做任何有用的事情之前,你通常需要进行一次 is
检查,以查看你拥有的是一个 Future<T>
还是一个裸露的 T
。如果类型参数是某个特定类型,如 FutureOr<int>
,你使用哪种检查(is int
还是 is Future<int>
)都无关紧要。两者都有效,因为这两种类型是互斥的。
然而,如果值类型是 Object
或可能用 Object
实例化的类型参数,那么这两个分支就会重叠。Future<Object>
本身实现了 Object
,因此 is Object
或 is T
(其中 T
是可能用 Object
实例化的类型参数)即使当对象是 future 时也会返回 true。因此,应显式检查 Future
情况
Future<T> logValue<T>(FutureOr<T> value) async {
if (value is Future<T>) {
var result = await value;
print(result);
return result;
} else {
print(value);
return value;
}
}
Future<T> logValue<T>(FutureOr<T> value) async {
if (value is T) {
print(value);
return value;
} else {
var result = await value;
print(result);
return result;
}
}
在错误示例中,如果你传递给它一个 Future<Object>
,它会错误地将其视为裸露的同步值。