跳到主要内容
目录

Effective Dart:设计

目录 keyboard_arrow_down keyboard_arrow_up
更多选项

这里是一些为库编写一致、可用 API 的指导方针。

命名

#

命名是编写可读、可维护代码的重要组成部分。以下最佳实践可以帮助你实现这一目标。

DO 始终如一地使用术语

#

在整个代码中,对同一事物使用相同的名称。如果你的 API 之外已经存在用户可能知道的先例,请遵循该先例。

好的示例dart
pageCount         // A field.
updatePageCount() // Consistent with pageCount.
toSomething()     // Consistent with Iterable's toList().
asSomething()     // Consistent with List's asMap().
Point             // A familiar concept.
不好的示例dart
renumberPages()      // Confusingly different from pageCount.
convertToSomething() // Inconsistent with toX() precedent.
wrappedAsSomething() // Inconsistent with asX() precedent.
Cartesian            // Unfamiliar to most users.

目标是利用用户已知的知识。这包括他们对问题领域本身的了解、核心库的约定以及你自己的 API 的其他部分。通过在此基础上构建,你可以减少他们在能够高效工作之前必须获取的新知识量。

AVOID 使用缩写

#

除非缩写比未缩写术语更常见,否则不要使用缩写。如果你确实使用了缩写,请正确大写

好的示例dart
pageCount
buildRectangles
IOStream
HttpRequest
不好的示例dart
numPages    // "Num" is an abbreviation of "number (of)".
buildRects
InputOutputStream
HypertextTransferProtocolRequest

PREFER 将最具描述性的名词放在最后

#

最后一个词应该是对事物的最描述性词语。你可以在其前面加上其他词语,例如形容词,以进一步描述事物。

好的示例dart
pageCount             // A count (of pages).
ConversionSink        // A sink for doing conversions.
ChunkedConversionSink // A ConversionSink that's chunked.
CssFontFaceRule       // A rule for font faces in CSS.
不好的示例dart
numPages                  // Not a collection of pages.
CanvasRenderingContext2D  // Not a "2D".
RuleFontFaceCss           // Not a CSS.

CONSIDER 使代码读起来像句子

#

在命名方面有疑问时,编写一些使用你的 API 的代码,并尝试像读句子一样阅读它。

好的示例dart
// "If errors is empty..."
if (errors.isEmpty) {
  // ...
}

// "Hey, subscription, cancel!"
subscription.cancel();

// "Get the monsters where the monster has claws."
monsters.where((monster) => monster.hasClaws);
不好的示例dart
// Telling errors to empty itself, or asking if it is?
if (errors.empty) {
  // ...
}

// Toggle what? To what?
subscription.toggle();

// Filter the monsters with claws *out* or include *only* those?
monsters.filter((monster) => monster.hasClaws);

尝试使用你的 API 并查看它在代码中的“阅读”效果是有帮助的,但你可能做得过头。添加冠词和其他词性来强制你的名称“字面上”读起来像一个语法正确的句子是没有帮助的。

不好的示例dart
if (theCollectionOfErrors.isEmpty) {
  // ...
}

monsters.producesANewSequenceWhereEach((monster) => monster.hasClaws);

PREFER 为非布尔属性或变量使用名词短语

#

读者的注意力集中在属性是“什么”。如果用户更关心属性是“如何”确定的,那么它可能应该是一个带有动词短语名称的方法。

好的示例dart
list.length
context.lineWidth
quest.rampagingSwampBeast
不好的示例dart
list.deleteItems

PREFER 为布尔属性或变量使用非命令式动词短语

#

布尔名称通常用作控制流中的条件,因此你需要一个在那里读起来很好的名称。比较一下:

dart
if (window.closeable) ...  // Adjective.
if (window.canClose) ...   // Verb.

好的名称通常以几种动词开头:

  • “to be” 的形式:isEnabledwasShownwillFire。这些是迄今为止最常见的。

  • 一个助动词hasElementscanCloseshouldConsumemustSave

  • 一个主动动词:ignoresInputwroteFile。这些很少见,因为它们通常模棱两可。loggedResult 是一个不好的名称,因为它可能意味着“是否记录了结果”或“记录的结果”。同样,closingConnection 可能意味着“连接是否正在关闭”或“正在关闭的连接”。当名称只能被理解为谓词时,允许使用主动动词。

将所有这些动词短语与方法名称区分开来的是,它们不是“命令式”的。布尔名称绝不应该听起来像命令对象做某事,因为访问属性不会改变对象。(如果属性“确实”以有意义的方式修改对象,则它应该是一个方法。)

好的示例dart
isEmpty
hasElements
canClose
closesWindow
canShowPopup
hasShownPopup
不好的示例dart
empty         // Adjective or verb?
withElements  // Sounds like it might hold elements.
closeable     // Sounds like an interface.
              // "canClose" reads better as a sentence.
closingWindow // Returns a bool or a window?
showPopup     // Sounds like it shows the popup.

CONSIDER 为具名布尔“参数”省略动词

#

这细化了上一条规则。对于布尔类型的具名参数,名称通常即使没有动词也同样清晰,并且代码在调用点读起来更好。

好的示例dart
Isolate.spawn(entryPoint, message, paused: false);
var copy = List.from(elements, growable: true);
var regExp = RegExp(pattern, caseSensitive: false);

PREFER 为布尔属性或变量使用“肯定”名称

#

大多数布尔名称在概念上都有“肯定”和“否定”形式,其中前者感觉像是基本概念,后者是其否定——“打开”和“关闭”、“启用”和“禁用”等等。通常,后一个名称字面上带有一个否定前者的前缀:“可见”(visible)和“不可见”(in-visible),“已连接”(connected)和“已断开”(dis-connected),“零”(zero)和“非零”(non-zero)。

在选择 true 代表哪两种情况时——因此属性以哪种情况命名——优先选择肯定或更基本的那个。布尔成员经常嵌套在逻辑表达式中,包括否定运算符。如果你的属性本身读起来像否定,则读者很难在脑海中进行双重否定并理解代码的含义。

好的示例dart
if (socket.isConnected && database.hasData) {
  socket.write(database.read());
}
不好的示例dart
if (!socket.isDisconnected && !database.isEmpty) {
  socket.write(database.read());
}

对于某些属性,没有明显的肯定形式。已刷新到磁盘的文档是“已保存”还是“未更改”?尚未刷新的文档是“未保存”还是“已更改”?在模棱两可的情况下,倾向于选择用户不太可能否定或名称较短的那个。

例外: 对于某些属性,负面形式是用户绝大多数需要使用的。选择肯定情况会迫使他们随处使用 ! 来否定属性。在这种情况下,最好为该属性使用负面情况。

PREFER 为主要目的是产生副作用的函数或方法使用命令式动词短语

#

可调用成员可以向调用者返回结果,并执行其他工作或副作用。在像 Dart 这样的命令式语言中,成员通常主要因其副作用而被调用:它们可能改变对象的内部状态、产生一些输出或与外部世界通信。

这些类型的成员应使用命令式动词短语命名,以阐明成员执行的工作。

好的示例dart
list.add('element');
queue.removeFirst();
window.refresh();

这样,调用读起来就像执行该工作的命令。

PREFER 如果返回值为其主要目的,则为函数或方法使用名词短语或非命令式动词短语

#

其他可调用成员副作用很少,但向调用者返回有用的结果。如果成员不需要参数即可完成此操作,通常应为 getter。但有时逻辑“属性”需要一些参数。例如,elementAt() 从集合中返回一块数据,但它需要一个参数才能知道返回“哪”块数据。

这意味着成员在“语法上”是一个方法,但在“概念上”是一个属性,应使用描述成员返回“什么”的短语来命名。

好的示例dart
var element = list.elementAt(3);
var first = list.firstWhere(test);
var char = string.codeUnitAt(4);

此指导原则有意比前一条更宽松。有时方法没有副作用,但使用像 list.take()string.split() 这样的动词短语命名仍然更简单。

CONSIDER 如果你想引起对函数或方法所执行工作的注意,则使用命令式动词短语

#

当一个成员产生结果而没有任何副作用时,它通常应该是一个 getter 或一个带有描述其返回结果的名词短语的方法。但是,有时产生结果所需的工作很重要。它可能容易发生运行时错误,或使用重量级资源,如网络或文件 I/O。在这种情况下,当你希望调用者考虑成员正在执行的工作时,给成员一个动词短语名称来描述该工作。

好的示例dart
var table = database.downloadData();
var packageVersions = packageGraph.solveConstraints();

但请注意,此指导原则比前两条更宽松。操作执行的工作通常是与调用者无关的实现细节,并且性能和健壮性边界会随时间变化。大多数情况下,根据成员为调用者做了“什么”来命名,而不是根据它们“如何”做来命名。

AVOID 方法名以 get 开头

#

在大多数情况下,该方法应为 getter,并且从名称中删除 get。例如,不要使用名为 getBreakfastOrder() 的方法,而应定义名为 breakfastOrder 的 getter。

即使成员确实需要成为一个方法(因为它需要参数或不适合作为 getter),你也应该避免使用 get。正如前面的指导原则所述,要么:

  • 只需去掉 get使用名词短语命名,例如 breakfastOrder(),如果调用者主要关心方法返回的值。

  • 使用动词短语命名,如果调用者关心正在进行的工作,但选择一个比 get 更精确地描述工作的动词,例如 createdownloadfetchcalculaterequestaggregate 等。

PREFER 如果方法将对象状态复制到新对象,则将其命名为 to___()

#

Linter 规则:use_to_and_as_if_applicable

一个“转换”方法是返回一个新对象的方法,该新对象包含接收者几乎所有状态的副本,但通常采用不同的形式或表示。核心库有一个约定,这些方法名称以 to 开头,后跟结果的类型。

如果你定义转换方法,遵循此约定会很有帮助。

好的示例dart
list.toSet();
stackTrace.toString();
dateTime.toLocal();

PREFER 如果方法返回由原始对象支持的不同表示形式,则将其命名为 as___()

#

Linter 规则:use_to_and_as_if_applicable

转换方法是“快照”。结果对象拥有原始对象状态的独立副本。还有其他类似转换的方法返回“视图”——它们提供一个新对象,但该对象引用回原始对象。对原始对象的后续更改会反映在视图中。

你需要遵循的核心库约定是 as___()

好的示例dart
var map = table.asMap();
var list = bytes.asFloat32List();
var future = subscription.asFuture();

AVOID 在函数或方法名称中描述参数

#

用户将在调用点看到参数,因此在名称本身中提及它通常对提高可读性没有帮助。

好的示例dart
list.add(element);
map.remove(key);
不好的示例dart
list.addElement(element)
map.removeKey(key)

但是,提及参数有助于区分它与接受不同类型的其他同名方法:

好的示例dart
map.containsKey(key);
map.containsValue(value);

DO 命名类型参数时遵循现有助记约定

#

单字母名称并非十分清晰,但几乎所有泛型类型都使用它们。幸运的是,它们大多以一致的、助记的方式使用它们。约定如下:

  • 集合中的“元素”(Element)类型使用 E

    好的示例dart
    class IterableBase<E> {}
    class List<E> {}
    class HashSet<E> {}
    class RedBlackTree<E> {}
  • 关联集合中的“键”(Key)和“值”(Value)类型使用 KV

    好的示例dart
    class Map<K, V> {}
    class Multimap<K, V> {}
    class MapEntry<K, V> {}
  • 用作函数或类方法的“返回”(Return)类型的类型使用 R。这不常见,但有时出现在 typedef 和实现访问者模式的类中

    好的示例dart
    abstract class ExpressionVisitor<R> {
      R visitBinary(BinaryExpression node);
      R visitLiteral(LiteralExpression node);
      R visitUnary(UnaryExpression node);
    }
  • 否则,对于只有一个类型参数且周围类型使其含义明显的泛型,使用 TSU。这里有多个字母,以便嵌套时不会遮盖周围的名称。例如:

    好的示例dart
    class Future<T> {
      Future<S> then<S>(FutureOr<S> onValue(T value)) => ...
    }

    在这里,泛型方法 then<S>() 使用 S 来避免遮盖 Future<T> 上的 T

如果以上情况都不适用,则可以使用另一个单字母助记名称或描述性名称:

好的示例dart
class Graph<N, E> {
  final List<N> nodes = [];
  final List<E> edges = [];
}

class Graph<Node, Edge> {
  final List<Node> nodes = [];
  final List<Edge> edges = [];
}

在实践中,现有约定涵盖了大多数类型参数。

#

前导下划线字符(_)表示成员对其库是私有的。这不仅仅是约定,而是语言本身内置的特性。

PREFER 将声明设为私有

#

库中的公共声明——无论是顶层还是类中——都表明其他库可以并且应该访问该成员。这同时也是你的库承诺支持并在此发生时表现正确的承诺。

如果这不是你的意图,请添加小小的 _ 并放心。窄的公共接口对你来说更容易维护,对用户来说更容易学习。一个不错的额外好处是,分析器会告诉你未使用的私有声明,以便你可以删除死代码。如果成员是公共的,它就无法做到这一点,因为它不知道视图之外的任何代码是否正在使用它。

CONSIDER 在同一个库中声明多个类

#

一些语言,如 Java,将文件组织与类组织关联起来——每个文件只能定义一个顶层类。Dart 没有这个限制。库是与类不同的实体。如果它们在逻辑上都属于一起,一个库包含多个类、顶层变量和函数是完全可以的。

将多个类放在一个库中可以实现一些有用的模式。由于 Dart 中的隐私是在库级别而非类级别工作,这是一种定义“友元”类的方式,就像你在 C++ 中可能做的那样。同一库中声明的每个类都可以访问彼此的私有成员,但该库之外的代码则不能。

当然,这条指导原则并不意味着你应该将所有类都放入一个巨大的、单一的库中,只是允许你在一个库中放置不止一个类。

类与混入

#

Dart 是一种“纯粹”的面向对象语言,因为所有对象都是类的实例。但 Dart 不要求所有代码都定义在类内部——你可以像在过程式或函数式语言中一样定义顶层变量、常量和函数。

AVOID 在简单函数即可满足需求时,定义单成员抽象类

#

Linter 规则:one_member_abstracts

与 Java 不同,Dart 具有第一类函数、闭包以及易于使用的简洁语法。如果只需要像回调这样的东西,只需使用函数即可。如果你正在定义一个类,并且它只有一个抽象成员,名称没有意义(例如 callinvoke),很有可能你只是需要一个函数。

好的示例dart
typedef Predicate<E> = bool Function(E element);
不好的示例dart
abstract class Predicate<E> {
  bool test(E element);
}

AVOID 定义只包含静态成员的类

#

Linter 规则:avoid_classes_with_only_static_members

在 Java 和 C# 中,每个定义都“必须”在类内部,因此常见的是看到“类”仅作为放置静态成员的场所存在。其他类用作命名空间——一种为一组成员提供共享前缀以关联它们或避免名称冲突的方式。

Dart 有顶层函数、变量和常量,因此你不需要一个类来定义某些东西。如果你想要的是命名空间,那么库是更好的选择。库支持导入前缀和 show/hide 组合器。这些强大的工具让你的代码使用者能够以最适合“他们”的方式处理名称冲突。

如果函数或变量在逻辑上不与类绑定,则将其放在顶层。如果你担心名称冲突,则可以给它一个更精确的名称,或者将其移动到可以带前缀导入的独立库中。

好的示例dart
DateTime mostRecent(List<DateTime> dates) {
  return dates.reduce((a, b) => a.isAfter(b) ? a : b);
}

const _favoriteMammal = 'weasel';
不好的示例dart
class DateUtils {
  static DateTime mostRecent(List<DateTime> dates) {
    return dates.reduce((a, b) => a.isAfter(b) ? a : b);
  }
}

class _Favorites {
  static const mammal = 'weasel';
}

在惯用的 Dart 中,类定义“对象类型”。从未实例化过的类型是一种代码异味。

然而,这并非硬性规定。例如,对于常量和类似枚举的类型,将它们分组在一个类中可能是很自然的。

好的示例dart
class Color {
  static const red = '#f00';
  static const green = '#0f0';
  static const blue = '#00f';
  static const black = '#000';
  static const white = '#fff';
}

AVOID 继承不打算被子类化的类

#

如果构造函数从生成构造函数更改为工厂构造函数,则任何调用该构造函数的子类构造函数都将中断。此外,如果类更改其调用 this 的方法,这也可能会中断重写这些方法并期望在特定点被调用的子类。

这两点都意味着一个类需要仔细考虑是否允许子类化。这可以在文档注释中传达,或者通过给类一个明显的名称,例如 IterableBase。如果类的作者没有这样做,最好假定你“不”应该继承该类。否则,对其的后续更改可能会破坏你的代码。

DO 使用类修饰符来控制你的类是否可以被继承

#

类修饰符如 final, interface, 或 sealed 限制了类如何被继承。例如,使用 final class A {}interface class B {} 来阻止在当前库之外的继承。使用这些修饰符来传达你的意图,而不是依赖文档。

AVOID 实现不打算作为接口的类

#

隐式接口是 Dart 中强大的工具,可以避免在可以从合同实现的签名中轻松推断出类的合同的情况下重复该合同。

但是实现类的接口与该类的耦合非常紧密。这意味着几乎对你正在实现的类的接口的“任何”更改都会破坏你的实现。例如,向类添加新成员通常是安全且非破坏性的更改。但是如果你正在实现该类的接口,现在你的类将出现静态错误,因为它缺少新方法的实现。

库维护者需要有能力在不破坏用户的情况下演进现有类。如果你将每个类都视为暴露了一个用户可以自由实现的接口,那么更改这些类将变得非常困难。这种困难反过来意味着你所依赖的库会更慢地增长和适应新的需求。

为了给您使用的类的作者更多余地,请避免实现隐式接口,除非这些类明确打算被实现。否则,您可能会引入作者不打算引入的耦合,并且他们可能会在不知情的情况下破坏您的代码。

DO 使用类修饰符来控制你的类是否可以作为接口

#

设计库时,使用类修饰符如 finalbasesealed 来强制预期的用法。例如,使用 final class C {}base class D{} 来阻止在当前库之外的实现。虽然所有库都使用这些修饰符来强制设计意图是理想的,但开发者仍可能遇到未应用这些修饰符的情况。在这种情况下,请注意意外的实现问题。

PREFER 定义纯 mixin 或纯 class 而非 mixin class

#

Linter 规则:prefer_mixin

Dart 之前(语言版本从 2.122.19)允许满足某些限制(没有非默认构造函数,没有超类等)的任何类混入到其他类中。这令人困惑,因为类的作者可能并不打算将其混入。

Dart 3.0.0 现在要求,任何打算混入到其他类以及作为普通类处理的类型,都必须使用 mixin class 声明明确声明。

然而,需要同时作为 mixin 和类的类型应该属于罕见情况。mixin class 声明主要旨在帮助将用作 mixin 的 3.0.0 之前的类迁移到更明确的声明。新代码应通过仅使用纯 mixin 或纯 class 声明来明确其声明的行为和意图,并避免 mixin class 的歧义。

阅读 Migrating classes as mixins 以获取有关 mixinmixin class 声明的更多指导。

构造函数

#

Dart 构造函数通过声明一个与类同名且可选地带附加标识符的函数来创建。后者称为“具名构造函数”。

CONSIDER 如果类支持,则使你的构造函数为 const

#

如果你有一个类,其中所有字段都是 final,并且构造函数除了初始化它们之外什么也不做,则可以将该构造函数设为 const。这允许用户在需要常量的地方创建你的类实例——在其他更大的常量、switch case、默认参数值等内部。

如果你不明确地将其设为 const,他们就无法做到这一点。

但是请注意,const 构造函数是公共 API 中的承诺。如果你以后将构造函数更改为非 const,它将破坏在常量表达式中调用它的用户。如果你不想承诺这一点,请不要将其设为 const。实际上,const 构造函数对于简单的、不可变的值类型最有用。

成员

#

成员属于对象,可以是方法或实例变量。

PREFER 将字段和顶级变量设为 final

#

Linter 规则:prefer_final_fields

不可变的状态——不会随时间变化的状态——程序员更容易推理。尽量减少处理可变状态的类和库往往更容易维护。当然,拥有可变数据通常很有用。但是,如果你不需要它,你应该默认在可能的情况下将字段和顶层变量设为 final

有时,一个实例字段在初始化后不会改变,但直到实例构造后才能初始化。例如,它可能需要引用 this 或实例上的其他字段。在这种情况下,请考虑将字段设为 late final。这样做时,你还可能能够在声明时初始化字段

DO 对于概念上访问属性的操作使用 getter

#

决定成员应该是 getter 还是方法,是良好 API 设计中一个微妙但重要的部分,因此有这么长的指导原则。一些其他语言的文化回避 getter。他们只在操作几乎完全像字段一样时使用它们——它对完全存在于对象上的状态进行微小的计算。任何比这更复杂或更重量级的操作都会在名称后加上 () 来表示“正在进行计算!”,因为点号后的裸名称表示“字段”。

Dart “不是”那样的。在 Dart 中,“所有”带点的名称都是可能进行计算的成员调用。字段很特别——它们是语言提供的实现的 getter。换句话说,getter 在 Dart 中不是“特别慢的字段”;字段是“特别快的 getter”。

即便如此,选择 getter 而非方法会向调用者发送一个重要的信号。这个信号大致是操作是“类似字段的”。至少原则上,就调用者而言,操作“可以”使用字段实现。这意味着:

  • 操作不接受任何参数并返回结果。

  • 调用者主要关心结果。如果你希望调用者更关心操作“如何”产生结果,而不是关心正在产生的结果,那么给操作一个描述工作的动词名称并将其设为一个方法。

    这“并不”意味着操作为了成为 getter 必须特别快。IterableBase.lengthO(n),这没问题。getter 执行显著计算是允许的。但是如果它执行了“惊人”的工作量,你可能希望通过将其设为一个方法来引起他们的注意,该方法的名称是一个描述其工作的动词。

    不好的示例dart
    connection.nextIncomingMessage; // Does network I/O.
    expression.normalForm; // Could be exponential to calculate.
  • 该操作没有用户可见的副作用。访问真正的字段不会改变对象或程序中的任何其他状态。它不会产生输出、写入文件等。getter 也不应该做这些事情。

    “用户可见”部分很重要。getter 修改隐藏状态或产生带外副作用是可以的。getter 可以延迟计算和存储结果、写入缓存、记录信息等。只要调用者“不关心”副作用,这可能都没问题。

    不好的示例dart
    stdout.newline; // Produces output.
    list.clear; // Modifies object.
  • 操作是幂等的。“幂等”是一个奇怪的词,在这个语境下基本意味着多次调用同一个操作会产生相同的结果,除非这些调用之间有状态被明确修改。(显然,如果你在调用之间向列表中添加元素,list.length 会产生不同的结果。)

    这里的“相同结果”并不意味着 getter 在连续调用时必须字面意思上产生完全相同的对象。要求这样做会迫使许多 getter 拥有脆弱的缓存,这与使用 getter 的初衷相悖。getter 每次调用时返回一个新的 future 或 list 是常见且完全可以接受的。重要的是 future 完成到相同的值,并且 list 包含相同的元素。

    换句话说,结果值在调用者关心的方面应该是一样的。

    不好的示例dart
    DateTime.now; // New result each time.
  • 结果对象不会暴露原始对象的全部状态。字段只暴露对象的一部分。如果你的操作返回一个暴露原始对象全部状态的结果,它可能最好是 to___()as___() 方法。

如果以上所有描述都适用于你的操作,那么它应该是一个 getter。看起来符合条件的情况不多,但令人惊讶的是有很多。许多操作只是对某些状态进行一些计算,其中大多数都可以而且应该成为 getter。

好的示例dart
rectangle.area;
collection.isEmpty;
button.canShow;
dataSet.minimumValue;

DO 对于概念上更改属性的操作使用 setter

#

Linter 规则:use_setters_to_change_properties

在 setter 和方法之间进行选择,类似于在 getter 和方法之间进行选择。在两种情况下,操作都应“类似字段”。

对于 setter,“类似字段”意味着:

  • 操作接受一个参数且不产生返回值。

  • 操作更改对象中的某些状态。

  • 该操作是幂等的。从调用者的角度来看,用相同的值调用同一个 setter 两次,第二次调用应该什么都不做。内部,你可能有一些缓存失效或日志记录。这没问题。但从调用者的角度来看,第二次调用似乎什么也没做。

好的示例dart
rectangle.width = 3;
button.visible = false;

DON'T 定义没有对应 getter 的 setter

#

Linter 规则:avoid_setters_without_getters

用户将 getter 和 setter 视为对象的可见属性。“投递箱”属性只能写入而不能查看,这会令人困惑,并与他们对属性工作方式的直觉相冲突。例如,没有 getter 的 setter 意味着你可以使用 = 来修改它,但不能使用 +=

这条指导原则“不”意味着你应该仅仅为了允许添加你想要的 setter 而添加 getter。对象通常不应该暴露超出其需要状态。如果你有某个对象状态可以修改,但不能以相同方式暴露,则改用方法。

AVOID 使用运行时类型测试来模拟重载

#

API 通常支持对不同类型的参数进行类似的操作。为了强调相似性,一些语言支持“重载”,它允许你定义多个具有相同名称但参数列表不同的方法。在编译时,编译器会查看实际的参数类型来确定调用哪个方法。

Dart 没有重载。你可以通过定义单个方法,然后在方法体内使用 is 类型测试来查看参数的运行时类型并执行适当的行为,从而定义一个看起来像重载的 API。然而,以这种方式模拟重载会将“编译时”方法选择转变为“运行时”发生的选择。

如果调用者通常知道他们拥有哪种类型以及他们想要哪种特定操作,那么最好定义具有不同名称的单独方法,让调用者选择正确的操作。这提供了更好的静态类型检查和更快的性能,因为它避免了任何运行时类型测试。

但是,如果用户可能拥有未知类型的对象,并且“希望”API 在内部使用 is 来选择正确的操作,那么参数是所有支持类型超类型的单个方法可能是合理的。

AVOID 没有初始化器的公共 late final 字段

#

与其他 final 字段不同,没有初始化器的 late final 字段“会”定义一个 setter。如果该字段是公共的,则 setter 也是公共的。这很少是你想要的。字段通常被标记为 late,以便它们可以在实例生命周期中的某个时刻在“内部”初始化,通常是在构造函数体内部。

除非你“确实”希望用户调用该 setter,否则最好选择以下解决方案之一:

  • 不要使用 late
  • 使用工厂构造函数计算 final 字段的值。
  • 使用 late,但在声明时初始化 late 字段。
  • 使用 late,但将 late 字段设为私有,并为其定义一个公共 getter。

AVOID 返回可为空的 FutureStream 和集合类型

#

当一个 API 返回一个容器类型时,它有两种方式表示数据缺失:它可以返回一个空容器,也可以返回 null。用户通常认为并更喜欢你使用空容器表示“无数据”。这样,他们就拥有一个真实的对象,可以调用 isEmpty 等方法。

为了表示你的 API 没有数据提供,最好返回一个空集合、一个不可为空的 Future(类型可为空)或一个不发出任何值的 stream。

例外: 如果返回 null “意味着”与返回空容器“不同”,则使用可空类型可能是有意义的。

AVOID 为了实现链式调用而从方法返回 this

#

Linter 规则:avoid_returning_this

方法级联 (Method cascades) 是更好的链式调用方法。

好的示例dart
var buffer =
    StringBuffer()
      ..write('one')
      ..write('two')
      ..write('three');
不好的示例dart
var buffer =
    StringBuffer()
        .write('one')
        .write('two')
        .write('three');

类型

#

当你写下程序中的类型时,你就约束了流入代码不同部分的数值种类。类型可以出现在两种地方:“声明”上的类型注释和“泛型调用”的类型参数。

类型注释是您通常想到“静态类型”时所想到的。您可以注释变量、参数、字段或返回类型。在以下示例中,boolString 是类型注释。它们附加在代码的静态声明结构上,并且在运行时不会“执行”。

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

泛型调用是一个集合字面量、对泛型类构造函数的调用或对泛型方法的调用。在下一个示例中,numint 是泛型调用上的类型参数。尽管它们是类型,但它们是第一类实体,会在运行时具化并传递给调用。

dart
var lists = <num>[1, 2];
lists.addAll(List<num>.filled(3, 4));
lists.cast<int>();

我们在这里强调“泛型调用”部分,因为类型参数也可能出现在类型注释中:

dart
List<int> ints = [1, 2];

这里,int 是一个类型参数,但它出现在类型注释中,而不是泛型调用中。您通常无需担心这种区别,但在少数地方,我们对类型在泛型调用中与在类型注释中使用的指导有所不同。

类型推断

#

在 Dart 中,类型注释是可选的。如果您省略一个,Dart 会尝试根据附近的上下文推断一个类型。有时它没有足够的信息来推断完整的类型。发生这种情况时,Dart 有时会报告错误,但通常会静默地用 dynamic 填充任何缺失的部分。隐式 dynamic 会导致代码看起来是推断的且安全的,但实际上完全禁用了类型检查。下面的规则通过在推断失败时要求类型来避免这种情况。

Dart 既有类型推断又有 dynamic 类型的事实导致了一些关于代码“无类型”含义的困惑。这是指代码是动态类型的,还是您没有“编写”类型?为了避免这种困惑,我们避免说“无类型”,而是使用以下术语:

  • 如果代码是“类型注释”的,则类型在代码中明确写出。

  • 如果代码是“推断”的,则没有编写类型注释,并且 Dart 成功地自行找出了类型。推断可能会失败,在这种情况下,指导原则不认为这是推断的。

  • 如果代码是“动态”的,则其静态类型是特殊的 dynamic 类型。代码可以显式注释为 dynamic,也可以推断为 dynamic

换句话说,代码是否被注释或推断与它是否是 dynamic 或其他类型是正交的。

类型推断是一个强大的工具,可以让你省去编写和阅读那些显而易见或不重要的类型。它让读者的注意力集中在代码本身的行为上。显式类型也是健壮、可维护代码的关键部分。它们定义了 API 的静态结构,并创建了边界来记录和强制执行允许哪些类型的值到达程序的各个部分。

当然,类型推断并非魔术。有时推断成功并选择了一种类型,但这不是你想要的类型。常见情况是从变量的初始化器推断出过于精确的类型,而你打算稍后将其他类型的数值赋给该变量。在这种情况下,你必须显式地写出类型。

这里的指导原则在简洁性和控制、灵活性和安全性之间找到了我们发现的最佳平衡。有具体的指导原则涵盖所有各种情况,但大致总结如下:

  • 即使 dynamic 是您想要的类型,当推断没有足够的上下文时也要注释。

  • 除非必要,否则不要注释局部变量和泛型调用。

  • 除非初始化器使类型显而易见,否则优先注释顶层变量和字段。

DO 注释未初始化变量的类型

#

Linter 规则:prefer_typing_uninitialized_variables

变量的类型——顶层、局部、静态字段或实例字段——通常可以从其初始化器推断出来。但是,如果没有初始化器,推断就会失败。

好的示例dart
List<AstNode> parameters;
if (node is Constructor) {
  parameters = node.signature;
} else if (node is Method) {
  parameters = node.parameters;
}
不好的示例dart
var parameters;
if (node is Constructor) {
  parameters = node.signature;
} else if (node is Method) {
  parameters = node.parameters;
}

DO 如果类型不明显,则注释字段和顶级变量的类型

#

Linter 规则:type_annotate_public_apis

类型注释是关于如何使用库的重要文档。它们在程序的区域之间形成边界,以隔离类型错误的来源。考虑一下:

不好的示例dart
install(id, destination) => ...

这里不清楚 id 是什么。一个字符串?destination 是什么?一个字符串还是一个 File 对象?这个方法是同步的还是异步的?这样更清楚:

好的示例dart
Future<bool> install(PackageId id, String destination) => ...

然而,在某些情况下,类型非常明显,写出来毫无意义:

好的示例dart
const screenWidth = 640; // Inferred as int.

“明显”没有精确定义,但这些都是很好的候选:

  • 字面量。
  • 构造函数调用。
  • 引用其他明确指定类型的常量。
  • 数字和字符串上的简单表达式。
  • 用户应熟悉的工厂方法,如 int.parse()Future.wait() 等。

如果您认为初始化表达式——无论是什么——足够清晰,则可以省略注释。但如果您认为注释有助于使代码更清晰,则添加一个。

如果拿不准,就添加类型注释。即使类型很明显,您可能仍然希望显式注释。如果推断的类型依赖于来自其他库的值或声明,您可能希望注释您自己的声明,这样对其他库的更改就不会在您未意识到的情况下悄悄更改您自己 API 的类型。

此规则适用于公共和私有声明。正如 API 上的类型注释有助于代码的“用户”一样,私有成员上的类型有助于“维护者”。

DON'T 对已初始化的局部变量进行冗余的类型注释

#

Linter 规则:omit_local_variable_types

局部变量,特别是在函数趋于短小的现代代码中,作用域很小。省略类型可以使读者的注意力集中在更重要的变量“名称”及其初始化值上。

好的示例dart
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;
}
不好的示例dart
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;
}

有时推断的类型不是你希望变量拥有的类型。例如,你可能打算稍后为变量赋其他类型的值。在这种情况下,用你想要的类型注释变量。

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

DO 注释函数声明的返回类型

#

与某些其他语言不同,Dart 通常不会从函数声明的函数体推断返回类型。这意味着你应该自己为返回类型编写类型注释。

好的示例dart
String makeGreeting(String who) {
  return 'Hello, $who!';
}
不好的示例dart
makeGreeting(String who) {
  return 'Hello, $who!';
}

请注意,此指南仅适用于“非局部”函数声明:顶级、静态以及实例方法和 getter。局部函数和匿名函数表达式从其函数体推断返回类型。事实上,匿名函数语法甚至不允许返回类型注释。

DO 注释函数声明的参数类型

#

函数的参数列表定义了其与外部世界的边界。注释参数类型可以使该边界明确定义。请注意,尽管默认参数值看起来像变量初始化器,但 Dart 不会从可选参数的默认值推断其类型。

好的示例dart
void sayRepeatedly(String message, {int count = 2}) {
  for (var i = 0; i < count; i++) {
    print(message);
  }
}
不好的示例dart
void sayRepeatedly(message, {count = 2}) {
  for (var i = 0; i < count; i++) {
    print(message);
  }
}

例外:函数表达式和初始化形式参数有不同的类型注释约定,如下面两条指南所述。

DON'T 注释函数表达式中推断的参数类型

#

Linter 规则:avoid_types_on_closure_parameters

匿名函数几乎总是立即传递给接受某种类型回调的方法。当在类型化上下文中创建函数表达式时,Dart 会尝试根据期望类型推断函数的参数类型。例如,当你将函数表达式传递给 Iterable.map() 时,你的函数参数类型会根据 map() 期望的回调类型进行推断:

好的示例dart
var names = people.map((person) => person.name);
不好的示例dart
var names = people.map((Person person) => person.name);

如果语言能够推断出您想要的函数表达式参数类型,则无需注释。在极少数情况下,周围的上下文不足以提供函数一个或多个参数的类型。在这种情况下,您可能需要注释。(如果函数未立即使用,通常最好将其设为具名声明。)

DON'T 注释初始化形式参数 (initializing formals) 的类型

#

Linter 规则:type_init_formals

如果构造函数参数使用 this. 初始化字段,或使用 super. 转发超级参数,则参数的类型将被推断为与字段或超级构造函数参数的类型相同。

好的示例dart
class Point {
  double x, y;
  Point(this.x, this.y);
}

class MyWidget extends StatelessWidget {
  MyWidget({super.key});
}
不好的示例dart
class Point {
  double x, y;
  Point(double this.x, double this.y);
}

class MyWidget extends StatelessWidget {
  MyWidget({Key? super.key});
}

DO 为未推断的泛型调用写入类型参数

#

Dart 在泛型调用中推断类型参数非常智能。它查看表达式发生的期望类型以及传递给调用的值的类型。然而,有时这些不足以完全确定类型参数。在这种情况下,请显式地写出完整的类型参数列表。

好的示例dart
var playerScores = <String, int>{};
final events = StreamController<Event>();
不好的示例dart
var playerScores = {};
final events = StreamController();

有时调用作为变量声明的初始化器出现。如果变量“不是”局部的,则您可以在声明上放置类型注释,而不是在调用本身上写入类型参数列表:

好的示例dart
class Downloader {
  final Completer<String> response = Completer();
}
不好的示例dart
class Downloader {
  final response = Completer();
}

注释变量也满足此指导原则,因为现在类型参数“已”被推断。

DON'T 为已推断的泛型调用写入类型参数

#

这与上一条规则相反。如果调用的类型参数列表“确实”正确推断出您想要的类型,则省略这些类型,让 Dart 为您完成工作。

好的示例dart
class Downloader {
  final Completer<String> response = Completer();
}
不好的示例dart
class Downloader {
  final Completer<String> response = Completer<String>();
}

在这里,字段上的类型注释提供了周围的上下文,以便推断初始化器中构造函数调用的类型参数。

好的示例dart
var items = Future.value([1, 2, 3]);
不好的示例dart
var items = Future<List<int>>.value(<int>[1, 2, 3]);

在这里,集合和实例的类型可以从其元素和参数自下而上推断出来。

AVOID 编写不完整的泛型类型

#

编写类型注释或类型参数的目的是确定一个完整的类型。然而,如果你写出了泛型类型的名称但省略了它的类型参数,你就没有完全指定类型。在 Java 中,这被称为“原始类型”(raw types)。例如:

不好的示例dart
List numbers = [1, 2, 3];
var completer = Completer<Map>();

在这里,numbers 有一个类型注释,但该注释没有为泛型 List 提供类型参数。同样,CompleterMap 类型参数也没有完全指定。在这种情况下,Dart 不会尝试使用周围的上下文为你“填充”类型的其余部分。相反,它会静默地用 dynamic(或边界,如果类有边界)填充任何缺失的类型参数。这很少是你想要的。

相反,如果你在类型注释中或在某个调用中将泛型类型作为类型参数写入,请确保写入一个完整的类型:

好的示例dart
List<num> numbers = [1, 2, 3];
var completer = Completer<Map<String, int>>();

DO 使用 dynamic 进行注释,而不是让推断失败

#

当推断未能填充类型时,通常会默认使用 dynamic。如果 dynamic 是你想要的类型,这在技术上是最简洁的方式来实现它。然而,这并不是最“清晰”的方式。随手阅读你代码的人看到缺少注释,无法知道你是打算使用 dynamic,期望推断填充其他类型,还是仅仅忘记编写注释。

dynamic 是您想要的类型时,请显式写出,以明确您的意图并突出显示此代码的静态安全性较低。

好的示例dart
dynamic mergeJson(dynamic original, dynamic changes) => ...
不好的示例dart
mergeJson(original, changes) => ...

请注意,当 Dart“成功”推断出 dynamic 时,可以省略类型。

好的示例dart
Map<String, dynamic> readJson() => ...

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

在这里,Dart 为 json 推断出 Map<String, dynamic>,然后从中为 users 推断出 dynamic。可以不为 users 添加类型注释。区别有点微妙。允许推断从其他地方的 dynamic 类型注释将 dynamic“传播”到你的代码是可以的,但你不希望它在你未指定类型的地方注入 dynamic 类型注释。

例外:可以省略未使用参数(_)的类型注释。

PREFER 函数类型注释中的签名

#

标识符 Function 本身不带任何返回类型或参数签名,指的是特殊的 Function 类型。这种类型只比使用 dynamic 稍微有用一点。如果你要注释,最好使用包含函数参数和返回类型的完整函数类型。

好的示例dart
bool isValid(String value, bool Function(String) test) => ...
不好的示例dart
bool isValid(String value, Function test) => ...

例外: 有时,您想要一个表示多种不同函数类型联合的类型。例如,您可能接受一个接受一个参数的函数或一个接受两个参数的函数。由于我们没有联合类型,无法精确地为此类型化,通常您必须使用 dynamicFunction 至少比这稍微有用一些:

好的示例dart
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.');
    }
  }
}

DON'T 为 setter 指定返回类型

#

Linter 规则:avoid_return_types_on_setters

Dart 中的 setter 总是返回 void。写出这个词是毫无意义的。

不好的示例dart
void set foo(Foo value) {
   ...
}
好的示例dart
set foo(Foo value) {
   ...
}

DON'T 使用遗留的 typedef 语法

#

Linter 规则:prefer_generic_function_type_aliases

Dart 有两种定义函数类型的具名 typedef 的表示法。原始语法如下所示:

不好的示例dart
typedef int Comparison<T>(T a, T b);

这种语法存在一些问题:

  • 没有办法为“泛型”函数类型指定名称。在上面的示例中,typedef 本身是泛型的。如果在代码中引用 Comparison 时没有类型参数,您将隐式获得函数类型 int Function(dynamic, dynamic),而“不是” int Function<T>(T, T)。这在实践中不常出现,但在某些角落情况下很重要。

  • 参数中的单个标识符被解释为参数的“名称”,而不是其“类型”。给定:

    不好的示例dart
    typedef bool TestNumber(num);

    大多数用户期望这是一个接受 num 并返回 bool 的函数类型。实际上,这是一个接受“任何”对象(dynamic)并返回 bool 的函数类型。参数的“名称”(在 typedef 中除了文档之外不用于任何目的)是“num”。这在 Dart 中一直是长期存在的错误来源。

新的语法如下所示:

好的示例dart
typedef Comparison<T> = int Function(T, T);

如果您想包含参数名称,也可以:

好的示例dart
typedef Comparison<T> = int Function(T a, T b);

新语法可以表达旧语法可以表达的任何内容,甚至更多,并且没有将单个标识符视为参数名称而不是其类型的容易出错的特性。typedef 中 = 后面的相同函数类型语法也允许出现在任何类型注释可能出现的地方,这为我们在程序中的任何地方编写函数类型提供了一种一致的方式。

旧的 typedef 语法仍然受支持,以避免破坏现有代码,但它已被弃用。

PREFER 内联函数类型而非 typedef

#

Linter 规则:avoid_private_typedef_functions

在 Dart 中,如果你想将函数类型用于字段、变量或泛型类型参数,可以为函数类型定义 typedef。但是,Dart 支持内联函数类型语法,可以在允许类型注释的任何地方使用:

好的示例dart
class FilteredObservable {
  final bool Function(Event) _predicate;
  final List<void Function(Event)> _observers;

  FilteredObservable(this._predicate, this._observers);

  void Function(Event)? notify(Event event) {
    if (!_predicate(event)) return null;

    void Function(Event)? last;
    for (final observer in _observers) {
      observer(event);
      last = observer;
    }

    return last;
  }
}

如果函数类型特别长或经常使用,仍然值得定义一个 typedef。但在大多数情况下,用户希望在函数类型实际使用的地方看到它是什么,并且函数类型语法提供了这种清晰性。

PREFER 为参数使用函数类型语法

#

Linter 规则:use_function_type_syntax_for_parameters

Dart 在定义类型为函数的参数时有一个特殊的语法。有点像 C,你用函数的返回类型和参数签名围绕参数名称:

dart
Iterable<T> where(bool predicate(T element)) => ...

在 Dart 添加函数类型语法之前,这是为参数指定函数类型而不定义 typedef 的唯一方法。现在 Dart 有一个通用的函数类型表示法,你也可以将其用于函数类型参数:

好的示例dart
Iterable<T> where(bool Function(T) predicate) => ...

新语法稍微冗长一些,但与必须使用新语法的其他位置保持一致。

AVOID 使用 dynamic,除非你想禁用静态检查

#

有些操作适用于任何可能的对象。例如,log() 方法可以接受任何对象并对其调用 toString()。Dart 中有两种类型允许所有值:Object?dynamic。然而,它们传达不同的意思。如果你只想声明你允许所有对象,使用 Object?。如果你想允许所有对象“除了” null,则使用 Object

dynamic 类型不仅接受所有对象,还允许所有“操作”。在编译时,对 dynamic 类型的值进行任何成员访问都是允许的,但在运行时可能会失败并抛出异常。如果你想要这种有风险但灵活的动态分派,那么 dynamic 是正确的类型。

否则,最好使用 Object?Object。在访问成员之前,依靠 is 检查和类型提升来确保值的运行时类型支持您要访问的成员。

好的示例dart
/// Returns a Boolean representation for [arg], which must
/// be a String or bool.
bool convertToBool(Object arg) {
  if (arg is bool) return arg;
  if (arg is String) return arg.toLowerCase() == 'true';
  throw ArgumentError('Cannot convert $arg to a bool.');
}

此规则的主要例外是在使用现有 API 时,尤其是泛型类型内部的 API,它们使用 dynamic。例如,JSON 对象的类型是 Map<String, dynamic>,你的代码需要接受相同的类型。即便如此,在使用这些 API 的值时,最好在访问成员之前将其转换为更精确的类型。

DO 对于不产生值的异步成员,使用 Future<void> 作为返回类型

#

当有一个不返回值的同步函数时,你使用 void 作为返回类型。对于不产生值但调用者可能需要 await 的异步方法的等价类型是 Future<void>

您可能会看到使用 FutureFuture<Null> 的代码,因为早期版本的 Dart 不允许将 void 作为类型参数。现在可以了,您应该使用它。这样做更直接地匹配您如何为类似的同步函数指定类型,并为调用者和函数体提供更好的错误检查。

对于不返回有用值且没有调用者需要等待异步工作或处理异步失败的异步函数,使用 void 作为返回类型。

AVOID 使用 FutureOr<T> 作为返回类型

#

如果一个方法接受 FutureOr<int>,它在“接受”方面是“慷慨”的 (健壮性原则)。用户可以使用 intFuture<int> 调用该方法,因此他们不需要将 int 包装在您反正要解包的 Future 中。

如果你“返回”一个 FutureOr<int>,用户在执行任何有用的操作之前需要检查是获得了 int 还是 Future<int>。(或者他们只会 await 该值,有效地总是将其视为 Future。)只需返回一个 Future<int>,这样更简洁。用户更容易理解函数要么总是异步,要么总是同步,而一个既可以是异步又可以是同步的函数则很难正确使用。

好的示例dart
Future<int> triple(FutureOr<int> value) async => (await value) * 3;
不好的示例dart
FutureOr<int> triple(FutureOr<int> value) {
  if (value is int) return value * 3;
  return value.then((v) => v * 3);
}

此指导原则更精确的表述是“仅在 逆变 位置使用 FutureOr<T>”。参数是逆变的,返回类型是协变的。在嵌套函数类型中,这会反转——如果您的参数类型本身是一个函数,则回调的返回类型现在处于逆变位置,回调的参数是协变的。这意味着回调的类型返回 FutureOr<T> 是可以的:

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

参数

#

在 Dart 中,可选参数可以是位置参数或具名参数,但不能同时是两者。

AVOID 位置布尔参数

#

Linter 规则:avoid_positional_boolean_parameters

与其他类型不同,布尔值通常以字面形式使用。像数字这样的值通常被封装在命名常量中,但我们通常直接传递 truefalse。如果布尔值代表的含义不明确,这可能会使调用点难以阅读。

不好的示例dart
new Task(true);
new Task(false);
new ListBox(false, true, true);
new Button(false);

而是优先使用命名参数、命名构造函数或命名常量来阐明调用的作用。

好的示例dart
Task.oneShot();
Task.repeating();
ListBox(scroll: true, showScrollbars: true);
Button(ButtonState.enabled);

请注意,这不适用于设置器(setters),在设置器中,其名称已经清楚地表明了值所代表的含义。

好的示例dart
listBox.canScroll = true;
button.isEnabled = false;

AVOID 可选位置参数,如果用户可能想省略前面的参数

#

可选的位置参数应具有逻辑上的递进关系,以便靠前的参数比靠后的参数更常被传递。用户几乎不应该需要为了传递靠后的位置参数而显式传递一个“空洞”来省略靠前的参数。在这种情况下,最好使用命名参数。

好的示例dart
String.fromCharCodes(Iterable<int> charCodes, [int start = 0, int? end]);

DateTime(
  int year, [
  int month = 1,
  int day = 1,
  int hour = 0,
  int minute = 0,
  int second = 0,
  int millisecond = 0,
  int microsecond = 0,
]);

Duration({
  int days = 0,
  int hours = 0,
  int minutes = 0,
  int seconds = 0,
  int milliseconds = 0,
  int microseconds = 0,
});

AVOID 接受特殊“无参数”值的强制参数

#

如果用户在逻辑上省略一个参数,最好通过将该参数设为可选来让他们真正省略它,而不是强制他们传递 null、空字符串或表示“未传递”的其他特殊值。

省略参数更加简洁,并有助于防止出现错误,例如用户以为自己在提供一个实际值时却意外传递了 null 这样的标记值。

好的示例dart
var rest = string.substring(start);
不好的示例dart
var rest = string.substring(start, null);

DO 使用包含起始和不包含结束参数来接受范围

#

如果你正在定义一个方法或函数,允许用户从某个整数索引序列中选择一个范围内的元素或项,请接受一个起始索引(指向第一个项)和一个(可能是可选的)结束索引(比最后一个项的索引大一)。

这与执行相同操作的核心库保持一致。

好的示例dart
[0, 1, 2, 3].sublist(1, 3) // [1, 2]
'abcd'.substring(1, 3) // 'bc'

在这里保持一致尤其重要,因为这些参数通常是未命名的。如果你的 API 接受长度而不是结束点,那么这种差异在调用点将完全不可见。

相等性

#

为类实现自定义的相等性(equality)行为可能很棘手。用户对相等性的工作方式有很深的直觉,你的对象需要匹配这种直觉,并且像哈希表这样的集合类型有一些微妙的契约(contracts),它们期望元素遵循这些契约。

如果你覆写(override)了 == 运算符,请 DO 同时覆写 hashCode

#

Linter 规则:hash_and_equals

默认的哈希码(hash code)实现提供了身份哈希(identity hash)——两个对象通常只有在它们是完全相同的对象时才拥有相同的哈希码。同样,== 的默认行为也是身份比较。

如果你覆写 ==,这意味着你的类可能存在被认为是“相等”的不同对象。任何两个相等的对象都必须拥有相同的哈希码。否则,映射(maps)和其他基于哈希的集合将无法识别这两个对象是等价的。

请 DO 让你的 == 运算符遵守数学上的相等性规则

#

等价关系应该满足:

  • 自反性(Reflexive): a == a 应始终返回 true

  • 对称性(Symmetric): a == b 的返回值应与 b == a 相同。

  • 传递性(Transitive): 如果 a == bb == c 都返回 true,那么 a == c 也应该返回 true

用户和使用 == 的代码都期望这些规则得到遵守。如果你的类不能遵守这些规则,那么 == 就不是你试图表达的操作的正确名称。

AVOID 为可变类定义自定义相等性

#

Linter 规则:avoid_equals_and_hash_code_on_mutable_classes

当你定义 == 时,你还必须定义 hashCode。这两者都应考虑对象的字段。如果这些字段发生变化,那么对象的哈希码也可能随之变化。

大多数基于哈希的集合并未预料到这一点——它们假设对象的哈希码将永远保持不变,如果事实并非如此,它们的行为可能会变得不可预测。

DON'T 将 == 的参数设为可空(nullable)

#

Linter 规则:avoid_null_checks_in_equality_operators

语言规范规定 null 只等于它自身,并且只有在右侧不为 null 时才会调用 == 方法。

好的示例dart
class Person {
  final String name;

  // ···

  bool operator ==(Object other) => other is Person && name == other.name;
}
不好的示例dart
class Person {
  final String name;

  // ···

  bool operator ==(Object? other) =>
      other != null && other is Person && name == other.name;
}