面向 API 维护者的类修饰符
Dart 3.0 添加了一些新的修饰符,你可以将它们放在类和mixin 声明上。如果你是库包的作者,这些修饰符可以让你更好地控制用户被允许如何使用你的包导出的类型。这可以使你的包更容易演进,并且更容易知道代码的更改是否会破坏用户。
Dart 3.0 还包括一项关于将类用作 mixin 的破坏性变更。此变更可能不会破坏*你的*类,但可能会破坏你类的*用户*。
本指南将引导你了解这些变更,以便你知道如何使用新修饰符,以及它们如何影响你的库用户。
类上的 mixin
修饰符
#最重要的修饰符是 mixin
。Dart 3.0 之前的语言版本允许任何类在另一个类的 with
子句中用作 mixin,*除非*该类:
- 声明了任何非工厂构造函数。
- 扩展了
Object
以外的任何类。
这使得你很容易在不了解其他用户在 with
子句中使用类的情况下,通过向类添加构造函数或 extends
子句来意外地破坏他人的代码。
Dart 3.0 不再默认允许类用作 mixin。相反,你必须通过声明一个 mixin class
来明确选择启用该行为。
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 声明的所有私有成员。这有助于防止可能发生的运行时错误。
考虑这个库:
class A {
void _privateMethod() {
print('I inherited from A');
}
}
void callPrivateMethod(A a) {
a._privateMethod();
}
这段代码本身看起来没问题,但没有什么能阻止用户创建另一个这样的库:
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
(或 final
或 sealed
,我们接下来会讲到)。
因此,对类型应用 base
需要一些注意。它不仅影响用户如何使用你的类或 mixin,还影响*其*子类可以提供的能力。一旦你将 base
放在一个类型上,其下的整个继承体系都将禁止被实现。
这听起来很激烈,但这是大多数其他编程语言一直以来的工作方式。大多数语言根本没有隐式接口,因此当你在 Java、C# 或其他语言中声明一个类时,你实际上具有相同的约束。
final
修饰符
#如果你想要 interface
和 base
的所有限制,你可以将类或 mixin 类标记为final
。这会阻止库外部的任何人创建它的任何子类型:不允许在 implements
、extends
、with
或 on
子句中使用它。
这对类的用户来说限制最多。他们能做的就是构造它(除非它被标记为 abstract
)。作为回报,你作为类维护者的限制最少。你可以添加新方法,将构造函数转换为工厂构造函数等等,而无需担心破坏任何下游用户。
sealed
修饰符
#最后一个修饰符,sealed
,是特殊的。它主要用于在模式匹配中启用穷举检查。如果一个 switch 为标记为 sealed
的类型的每个直接子类型都有 case,那么编译器就知道该 switch 是穷举的。
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。
为了使其健全,编译器强制执行两个限制:
sealed
类本身不能直接构造。否则,你可能会有一个Amigo
实例,它不是*任何*子类型的实例。因此,每个sealed
类也隐式地是abstract
。sealed
类型的每个直接子类型都必须声明在与sealed
类型相同的库中。这样,编译器可以找到它们所有。它知道没有其他未匹配任何 case 的隐藏子类型存在。
第二个限制与 final
类似。与 final
一样,这意味着标记为 sealed
的类不能在声明它的库之外直接扩展、实现或混合。但是,与 base
和 final
不同,没有*传递性*限制:
sealed class Amigo {}
class Lucky extends Amigo {}
class Dusty extends Amigo {}
class Ned extends Amigo {}
// This is an error:
class Bad extends Amigo {}
// But these are both fine:
class OtherLucky extends Lucky {}
class OtherDusty implements Dusty {}
当然,如果你*希望*你的 sealed
类型的子类型也受到限制,你可以通过使用 interface
、base
、final
或 sealed
标记它们来实现。
sealed
与 final
的比较
#如果你有一个不希望用户直接子类型化的类,那么你何时应该使用 sealed
而不是 final
呢?一些简单的规则:
如果你希望用户能够直接构造类的实例,那么它*不能*使用
sealed
,因为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
选择对子类型族进行穷举检查。
当你这样做时,在发布包时递增主版本号,因为这些修饰符都意味着是破坏性变更的限制。