面向 API 维护者的类修饰符
Dart 3.0 添加了一些 新修饰符,你可以将这些修饰符放在类和 混入声明中。如果你是一个库包的作者,这些修饰符可以让你更好地控制用户使用你的包导出的类型时可以做什么。这可以让你更容易地演进你的包,并且更容易地知道你对代码的更改是否会破坏用户。
Dart 3.0 还包括一个 重大更改,即使用类作为混入。此更改可能不会破坏你的类,但可能会破坏你的类的用户。
本指南将引导你了解这些更改,以便你知道如何使用新修饰符,以及它们如何影响你的库的用户。
类上的 mixin
修饰符
#最需要注意的修饰符是 mixin
。Dart 3.0 之前的语言版本允许任何类在另一个类的 with
子句中用作混入,除非 该类
- 声明任何非工厂构造函数。
- 扩展
Object
以外的任何类。
这样很容易意外破坏他人的代码,方法是在类中添加构造函数或 extends
子句,而没有意识到其他人正在 with
子句中使用它。
Dart 3.0 不再默认允许类用作混入。相反,你必须通过声明 mixin class
明确选择加入该行为
mixin class Both {}
class UseAsMixin with Both {}
class UseAsSuperclass extends Both {}
如果你将你的包更新到 Dart 3.0,并且不更改任何代码,你可能不会看到任何错误。但是,如果你将你的类用作混入,你可能会无意中破坏你的包的用户。
将类迁移为 mixin
#如果类具有非工厂构造函数、extends
子句或 with
子句,那么它已经无法用作混入。行为不会随着 Dart 3.0 而改变;无需担心,也无需执行任何操作。
实际上,这描述了大约 90% 的现有类。对于可以作为混入使用的剩余类,你必须决定要支持什么。
以下是一些有助于做出决定的问题。第一个问题很实际
- 您是否愿意冒着破坏任何用户的风险?如果答案是明确的“不”,那么将
mixin
放在任何和所有类之前,这些类可以用作 mixin。这完全保留了 API 的现有行为。
另一方面,如果您想借此机会重新考虑 API 提供的功能,那么您可能希望不将其变成 mixin 类
。考虑以下两个设计问题
您是否希望用户能够直接构造它的实例?换句话说,该类是否故意不是抽象的?
您希望人们能够将声明用作 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
(或 final
或 sealed
,我们接下来会讲到)。
然后,将 base
应用于类型需要小心。它不仅影响用户对类或混合的使用,还影响其子类可以提供的便利。一旦在类型上放置了 base
,则禁止实现其下的整个层次结构。
这听起来很强烈,但这是大多数其他编程语言一直以来的工作方式。大多数语言根本没有隐式接口,因此在 Java、C# 或其他语言中声明类时,实际上具有相同的约束。
final
修饰符
#如果您想要 interface
和 base
的所有限制,则可以将类或混合类标记为 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 是安全穷举的,并且不需要任何最终的默认 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 {}
当然,如果你希望你的密封类型的子类型也受到限制,你可以通过使用 interface
、base
、final
或 sealed
标记它们来实现。
sealed
与 final
#如果你有一个类,你不希望用户能够直接对其进行子类型化,那么你应该何时使用 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
来选择对一系列子类型进行穷尽性检查。
当你这样做时,在发布你的包时增加主版本,因为这些修饰符都暗示着限制,即破坏性更改。