目录

API 维护者的类修饰符

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

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

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

类上的 mixin 修饰符

#

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

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

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

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 子句中使用它?

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

最后两个选项,将其保留为类或将其转换为纯混入,都是破坏性的 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 类或混入的同一库中忽略此限制。然后,同一库中的子类将被提醒实现私有方法。但请注意,下一节确实适用

Base 传递性

#

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

因此,将 base 应用于类型需要一些谨慎。它不仅影响用户可以使用您的类或混入执行的操作,还影响子类可以提供的功能。一旦您在类型上放置了 base,它下面的整个层次结构都禁止被实现。

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

final 修饰符

#

如果你想要同时拥有 interfacebase 的所有限制,你可以将一个类或混入类标记为 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 语句是安全的穷尽的,并且不需要任何 final default case。

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

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

  2. 密封类型的每个直接子类型都必须在声明密封类型的同一个库中。这样,编译器就可以找到它们。它知道不会有其他隐藏的子类型,与任何 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 {}

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

sealedfinal

#

如果你的类不希望用户能够直接创建子类型,那么你应该何时使用 sealed 而不是 final?这里有一些简单的规则:

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

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

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

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

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

但这确实意味着每当你添加一个新子类型时,这都是一个破坏性的更改。如果你想要以非破坏性方式添加新子类型的自由,那么最好使用 final 而不是 sealed 来标记超类型。这意味着,当用户在超类型的值上进行 switch 操作时,即使他们为所有子类型都设置了 case,编译器也会强制他们添加另一个 default case。如果以后添加更多子类型,则会执行该 default case。

总结

#

作为 API 设计者,这些新的修饰符使你可以控制用户如何使用你的代码,反之,你如何能够在不破坏他们代码的情况下演变你的代码。

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

幸运的是,你不需要一次性解决所有问题。我们故意选择了默认值,这样即使你什么都不做,你的类也大多具有与 3.0 之前相同的能力。如果你只想保持 API 原样,请在已经支持它的类上添加 mixin,就可以了。

随着时间的推移,当你意识到自己想要更精细的控制时,你可以考虑应用其他一些修饰符。

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

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

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

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

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