跳到主要内容

API 维护者的类修饰符

Dart 3.0 添加了一些您可以放置在类和 mixin 声明上的新修饰符。如果您是库包的作者,这些修饰符使您可以更好地控制用户可以对您的包导出的类型执行的操作。这可以使您的包更易于演变,并更容易知道对代码的更改是否会破坏用户。

Dart 3.0 还包括一个关于将类用作 mixin 的破坏性更改。此更改可能不会破坏您的类,但可能会破坏您的类的用户

本指南将引导您了解这些更改,以便您了解如何使用新的修饰符,以及它们如何影响您的库的用户。

类上的 mixin 修饰符

#

最需要注意的修饰符是 mixin。Dart 3.0 之前的语言版本允许任何类在另一个类的 with 子句中用作 mixin,除非该类

  • 声明任何非工厂构造函数。
  • 扩展 Object 以外的任何类。

这使得很容易意外地破坏其他人的代码,方法是向类添加构造函数或 extends 子句,而没有意识到其他人正在 with 子句中使用它。

Dart 3.0 不再允许默认情况下将类用作 mixin。相反,您必须通过声明 mixin class 显式选择加入该行为

dart
mixin class Both {}

class UseAsMixin with Both {}
class UseAsSuperclass extends Both {}

如果您将您的包更新到 Dart 3.0 并且不更改任何代码,您可能不会看到任何错误。但是,如果您的包的用户正在将您的类用作 mixin,则您可能会在不经意间破坏他们。

将类迁移为 mixin

#

如果类具有非工厂构造函数、extends 子句或 with 子句,则它已经不能用作 mixin。行为不会随 Dart 3.0 更改;没有什么可担心的,也没有什么需要做的。

实际上,这描述了大约 90% 的现有类。对于其余可以用作 mixin 的类,您必须决定要支持什么。

以下是一些问题,可以帮助您做出决定。第一个是务实的

  • 您想冒险破坏任何用户吗? 如果答案是坚决的“否”,那么在任何和所有可以用作 mixin 的类之前放置 mixin。这完全保留了您的 API 的现有行为。

另一方面,如果您想借此机会重新思考您的 API 提供的功能,那么您可能希望将其变成 mixin class。考虑以下两个设计问题

  • 您是否希望用户能够直接构造它的实例? 换句话说,该类是否刻意不是抽象的?

  • 是否希望人们能够将声明用作 mixin? 换句话说,您是否希望他们能够在 with 子句中使用它?

如果两个问题的答案都是“是”,则将其设为 mixin 类。如果第二个问题的答案是“否”,则将其保留为类。如果第一个问题的答案是“否”,而第二个问题的答案是“是”,则将其从类更改为 mixin 声明。

最后两个选项,将其保留为类或将其变成纯 mixin,都是破坏性的 API 更改。如果您这样做,您将需要增加您的包的主版本号。

其他选择性加入的修饰符

#

处理类作为 mixin 是 Dart 3.0 中影响您的包 API 的唯一关键更改。一旦您完成了这一步,如果您不想对您的包允许用户执行的操作进行其他更改,您可以停止。

请注意,如果您继续并使用下面描述的任何修饰符,则可能会对您的包的 API 造成破坏性更改,这需要增加主版本号。

interface 修饰符

#

Dart 没有用于声明纯接口的单独语法。相反,您声明一个恰好仅包含抽象方法的抽象类。当用户在您的包的 API 中看到该类时,他们可能不知道它是否包含他们可以通过扩展类来重用的代码,或者它是否旨在用作接口。

您可以通过将 interface 修饰符放在类上来澄清这一点。这允许该类在 implements 子句中使用,但阻止它在 extends 中使用。

即使类确实具有非抽象方法,您也可能希望阻止用户扩展它。继承是软件中最强大的耦合类型之一,因为它支持代码重用。但是这种耦合也是危险且脆弱的。当继承跨越包边界时,在不破坏子类的情况下演进超类可能很困难。

将类标记为 interface 允许用户构造它(除非它也被标记为 abstract)并实现该类的接口,但阻止他们重用其任何代码。

当类被标记为 interface 时,可以在声明该类的库中忽略该限制。在库内部,您可以自由地扩展它,因为这都是您的代码,并且您可能知道自己在做什么。该限制适用于其他包,甚至您自己的包内的其他库。

base 修饰符

#

base 修饰符在某种程度上与 interface 相反。它允许您在 extends 子句中使用该类,或者在 with 子句中使用 mixin 或 mixin 类。但是,它禁止类库外部的代码在 implements 子句中使用该类或 mixin。

这确保了作为您的类或 mixin 接口实例的每个对象都继承了您的实际实现。特别是,这意味着每个实例都将包含您的类或 mixin 声明的所有私有成员。这可以帮助防止可能发生的运行时错误。

考虑以下库

a.dart
dart
class A {
  void _privateMethod() {
    print('I inherited from A');
  }
}

void callPrivateMethod(A a) {
  a._privateMethod();
}

这段代码本身看起来不错,但没有什么可以阻止用户创建另一个像这样的库

b.dart
dart
import 'a.dart';

class B implements A {
  // No implementation of _privateMethod()!
}

main() {
  callPrivateMethod(B()); // Runtime exception!
}

base 修饰符添加到类可以帮助防止这些运行时错误。与 interface 一样,您可以在声明 base 类或 mixin 的同一库中忽略此限制。然后,同一库中的子类将被提醒实现私有方法。但请注意,下一节确实适用

Base 传递性

#

将类标记为 base 的目标是确保该类型的每个实例都具体地从中继承。为了保持这一点,base 限制是“传染性的”。标记为 base 的类型的每个子类型——直接或间接——也必须阻止被实现。这意味着它必须标记为 base(或 finalsealed,我们将在接下来介绍)。

那么,将 base 应用于类型需要一些注意。它不仅影响用户可以对您的类或 mixin 执行的操作,还影响他们的子类可以提供的功能。一旦您将 base 放在类型上,其下的整个层次结构都被禁止实现。

这听起来很强烈,但这是大多数其他编程语言一直以来的工作方式。大多数语言根本没有隐式接口,因此当您在 Java、C# 或其他语言中声明一个类时,您实际上具有相同的约束。

final 修饰符

#

如果您想要 interfacebase 的所有限制,您可以将类或 mixin 类标记为 final。这阻止您库外部的任何人创建它的任何类型的子类型:禁止在 implementsextendswithon 子句中使用它。

这对类的用户来说是最具限制性的。他们所能做的就是构造它(除非它被标记为 abstract)。作为回报,作为类维护者,您的限制最少。您可以添加新方法,将构造函数变成工厂构造函数等等,而无需担心破坏任何下游用户。

sealed 修饰符

#

最后一个修饰符 sealed 很特殊。它主要用于在模式匹配中启用 穷尽性检查。如果 switch 语句具有标记为 sealed 的类型的每个直接子类型的 case,则编译器知道 switch 语句是穷尽的。

amigos.dart
dart
sealed class Amigo {}

class Lucky extends Amigo {}

class Dusty extends Amigo {}

class Ned extends Amigo {}

String lastName(Amigo amigo) => switch (amigo) {
  Lucky _ => 'Day',
  Dusty _ => 'Bottoms',
  Ned _ => 'Nederlander',
};

此 switch 语句具有 Amigo 的每个子类型的 case。编译器知道 Amigo 的每个实例都必须是这些子类型之一的实例,因此它知道 switch 语句是安全穷尽的,并且不需要任何最终的 default case。

为了使这sound,编译器强制执行两个限制

  1. sealed 类本身不能直接构造。否则,您可能会有一个 Amigo 的实例,它不是任何子类型的实例。因此,每个 sealed 类都隐式地也是 abstract

  2. sealed 类型的每个直接子类型都必须在声明 sealed 类型的同一库中。这样,编译器可以找到它们。它知道没有其他隐藏的子类型在周围浮动,而这些子类型将不匹配任何 case。

第二个限制类似于 final。与 final 类似,这意味着标记为 sealed 的类不能在其声明的库外部直接扩展、实现或混入。但是,与 basefinal 不同,没有传递性限制

amigo.dart
dart
sealed class Amigo {}
class Lucky extends Amigo {}
class Dusty extends Amigo {}
class Ned extends Amigo {}
other.dart
dart
// This is an error:
class Bad extends Amigo {}

// But these are both fine:
class OtherLucky extends Lucky {}
class OtherDusty implements Dusty {}

当然,如果您希望您的 sealed 类型的子类型也受到限制,您可以通过使用 interfacebasefinalsealed 标记它们来实现。

sealedfinal 的比较

#

如果您有一个不希望用户直接创建子类型的类,那么您应该何时使用 sealedfinal?几个简单的规则

  • 如果您希望用户能够直接构造该类的实例,那么它不能使用 sealed,因为 sealed 类型是隐式抽象的。

  • 如果该类在您的库中没有子类型,那么使用 sealed 就没有意义,因为您不会获得穷尽性检查的好处。

否则,如果该类确实有一些您定义的子类型,那么 sealed 很可能是您想要的。如果用户看到该类有一些子类型,则能够将它们中的每一个作为 switch case 单独处理,并让编译器知道涵盖了整个类型是很方便的。

使用 sealed 的确意味着如果您稍后向库中添加另一个子类型,那将是一个破坏性的 API 更改。当出现新的子类型时,所有这些现有的 switch 语句都会变成非穷尽的,因为它们不处理新类型。这与向枚举添加新值完全一样。

这些非穷尽的 switch 编译错误对用户很有用,因为它们将用户的注意力吸引到他们代码中需要处理新类型的地方。

但这确实意味着每当您添加新的子类型时,这都是一个破坏性的更改。如果您希望能够以非破坏性的方式添加新的子类型,那么最好使用 final 而不是 sealed 来标记超类型。这意味着当用户切换超类型的值时,即使他们具有所有子类型的 case,编译器也会强制他们添加另一个 default case。然后,如果您稍后添加更多子类型,则将执行该 default case。

总结

#

作为 API 设计者,这些新的修饰符使您可以控制用户如何使用您的代码,反之亦然,您可以如何在不破坏用户代码的情况下演进您的代码。

但是这些选项也带来了复杂性:您现在作为 API 设计者需要做出更多选择。此外,由于这些功能是新的,我们仍然不知道最佳实践是什么。每个语言的生态系统都不同,并且有不同的需求。

幸运的是,您无需一次性解决所有问题。我们特意选择了默认值,以便即使您什么都不做,您的类也主要具有与 3.0 之前相同的功能。如果您只想保持 API 的原样,请在已经支持 mixin 的类上放置 mixin,您就完成了。

随着时间的推移,当您感觉到您想要更精细的控制时,您可以考虑应用其他一些修饰符

  • 使用 interface 来阻止用户重用您的类的代码,同时允许他们重新实现其接口。

  • 使用 base 来要求用户重用您的类的代码,并确保您的类类型的每个实例都是该实际类或子类的实例。

  • 使用 final 来完全阻止类被扩展。

  • 使用 sealed 来选择加入对子类型系列的穷尽性检查。

当您这样做时,发布您的包时请增加主版本号,因为这些修饰符都暗示了破坏性更改的限制。