内容

API维护者使用的类修饰符

Dart 3.0 添加了一些您可以放在类和混入声明上的新修饰符。如果您是库包的作者,这些修饰符可以让您更好地控制用户可以使用您的包导出的类型进行哪些操作。这可以简化包的演变过程,并更容易了解代码更改是否会破坏用户。

Dart 3.0 还包含一个围绕使用类作为混入的重大更改。此更改可能不会破坏您的类,但可能会破坏用户的类。

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

类上的mixin修饰符

#

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

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

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

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

dart
mixin class Both {}

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

如果您将包更新到 Dart 3.0 并且没有更改任何代码,您可能看不到任何错误。但是,如果您之前将您的类用作混入,则可能会无意中破坏包的用户。

将类迁移为混入

#

如果该类具有非工厂构造函数、extends子句或with子句,则它已经无法用作混入。在 Dart 3.0 中,行为不会改变;无需担心,也无需执行任何操作。

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

以下是一些有助于做出决定的问题。第一个是务实的

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

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

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

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

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

最后两个选项,将其保留为类或将其转换为纯混入,都是破坏 API 的更改。如果您执行此操作,则需要增加包的主版本。

其他选择加入的修饰符

#

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

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

interface修饰符

#

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

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

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

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

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

base修饰符

#

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

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

考虑以下库

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(或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。

为了保证这一点,编译器强制执行两个限制

  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

#

如果您有一个不想让用户直接子类型的类,那么您应该在何时使用sealed而不是final?一些简单的规则

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

  • 如果类在您的库中没有子类型,那么使用sealed就没有意义,因为您无法获得任何穷举检查的好处。

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

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

这些不穷举的switch编译错误对用户有用,因为它们会吸引用户注意他们代码中需要处理新类型的位置。

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

总结

#

作为API设计者,这些新的修饰符使您可以控制用户如何使用您的代码,以及您如何能够在不破坏其代码的情况下改进您的代码。

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

幸运的是,您不必一次性解决所有问题。我们故意选择了默认值,因此即使您什么也不做,您的类也大多具有与3.0之前相同的可用性。如果您只想保持API的现状,请在已经支持该功能的类上添加mixin,然后就完成了。

随着时间的推移,当您了解到希望进行更精细控制的地方时,您可以考虑应用一些其他修饰符

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

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

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

  • 使用sealed选择加入对子类型族的穷举检查。

当您这样做时,在发布您的包时增加主版本号,因为这些修饰符都意味着是破坏性更改的限制。