Class modifiers for API maintainers
Dart 3.0 增加了一些可以放置在类和 mixin 声明上的新修饰符。如果你是一个库包的作者,这些修饰符让你能更好地控制用户如何使用你的包所导出的类型。这可以使你的包更容易演进,也更容易知道对代码的更改是否会破坏用户。
Dart 3.0 还包含一个关于将类用作 mixin 的重大变更。这个变更可能不会破坏你的类,但可能会破坏你的类的用户。
本指南将带你了解这些变更,以便你知道如何使用新的修饰符,以及它们如何影响你的库的用户。
类上的 mixin
修饰符
需要注意的最重要的修饰符是 mixin
。在 Dart 3.0 之前的语言版本中,任何类都可以用在另一个类的 with
子句中作为 mixin,除非该类:
- 声明了任何非工厂构造函数。
- 继承了除
Object
之外的任何类。
这使得很容易意外地破坏别人的代码,因为你可能在为一个类添加构造函数或 extends
子句时,没有意识到其他人正在 with
子句中使用它。
Dart 3.0 不再默认允许类被用作 mixin。相反,你必须通过声明一个 mixin class
来显式地选择加入该行为:
1 | mixin class Both {} |
如果你将你的包更新到 Dart 3.0 但不更改任何代码,你可能不会看到任何错误。但你可能会无意中破坏你包的用户,如果他们之前将你的类用作 mixin 的话。
迁移用作 mixin 的类
如果一个类有非工厂构造函数、extends
子句或 with
子句,那么它已经不能被用作 mixin。其行为在 Dart 3.0 中不会改变;无需担心,也无需做任何事情。
实际上,这描述了大约 90% 的现有类。对于其余可以被用作 mixin 的类,你必须决定你想要支持什么。
这里有几个问题可以帮助你决定。第一个是 pragmatic 的:
- 你是否想冒着破坏任何用户的风险? 如果答案是坚决的“不”,那么在所有可能被用作 mixin 的类前面加上
mixin
。这完全保留了你的 API 的现有行为。
另一方面,如果你想借此机会重新思考你的 API 提供的功能,那么你可能不想把它变成一个 mixin class
。考虑这两个设计问题:
- 你希望用户能够直接构造它的实例吗? 换句话说,这个类是故意不设置为
abstract
的吗? - 你希望人们能够将该声明用作 mixin 吗? 换句话说,你希望他们能够在
with
子句中使用它吗?
如果两个问题的答案都是“是”,那么就把它变成一个 mixin class
。如果第二个问题的答案是“否”,那么就让它保持为一个 class
。如果第一个问题的答案是“否”而第二个是“是”,那么就把它从一个 class
改为一个 mixin
声明。
最后两个选项,即保持为 class
或将其变为纯 mixin
,都是破坏性的 API 变更。如果你这样做,你需要提升你包的主版本号。
其他可选的修饰符
处理用作 mixin 的类是 Dart 3.0 中唯一影响你包 API 的关键变更。到这里,如果你不想对你的包允许用户做什么进行其他更改,你就可以停下来了。
请注意,如果你继续并使用下面描述的任何修饰符,这都可能是对你包 API 的一个重大变更,需要增加主版本号。
interface
修饰符
Dart 没有单独的语法来声明纯接口。相反,你声明一个恰好只包含抽象方法的 abstract class
。当用户在你的包 API 中看到这个类时,他们可能不知道它是否包含可以通过继承来复用的代码,或者它是否意在用作接口。
你可以通过在类上加上 interface
修饰符来阐明这一点。这允许该类在 implements
子句中使用,但阻止它在 extends
中使用。
即使当类确实有非抽象方法时,你也可能希望阻止用户继承它。继承是软件中最强大的耦合之一,因为它能实现代码复用。但这种耦合也是危险和脆弱的。当继承跨越包边界时,要演进父类而不破坏子类可能会很困难。
将类标记为 interface
让用户可以构造它(除非它也被标记为 abstract
)并实现该类的接口,但阻止他们复用它的任何代码。
当一个类被标记为 interface
时,在其声明所在的库内部,这个限制可以被忽略。在库内部,你可以自由地继承它,因为这都是你自己的代码,并且大概你知道你在做什么。该限制适用于其他包,甚至是你自己包内的其他库。
base
修饰符
base
修饰符在某种程度上是 interface
的反面。它允许你在 extends
子句中使用该类,或在 with
子句中使用一个 mixin
或 mixin class
。但是,它禁止类库之外的代码在 implements
子句中使用该类或 mixin。
这确保了你的类或 mixin 接口的每个实例都继承了你的实际实现。特别是,这意味着每个实例都将包含你的类或 mixin 声明的所有私有成员。这有助于防止可能发生的运行时错误。
考虑这个库:
a.dart
1 | class A { |
这段代码本身看起来没问题,但没有什么能阻止用户创建另一个像这样的库:
b.dart
1 | import 'a.dart'; |
在类上添加 base
修饰符有助于防止这些运行时错误。与 interface
一样,你可以在 base
类或 mixin 声明的同一个库中忽略此限制。然后,同一库中的子类将被提醒去实现私有方法。但请注意,下一节的内容确实适用:
Base 的传递性
将一个类标记为 base
的目标是确保该类型的每个实例都具体地继承自它。为了维持这一点,base
的限制是“会传染的”。一个被标记为 base
的类型的每个子类型——无论是直接还是间接的——也必须阻止被实现。这意味着它必须被标记为 base
(或 final
或 sealed
,我们接下来会讲到)。
因此,将 base
应用于一个类型需要一些谨慎。它不仅影响用户能对你的类或 mixin 做什么,还影响他们的子类可以提供的功能。一旦你将 base
应用于一个类型,其下的整个层次结构都将被禁止实现。
这听起来很严格,但这是大多数其他编程语言一直以来的工作方式。大多数语言根本没有隐式接口,所以当你在 Java、C# 或其他语言中声明一个类时,你实际上也受到了同样的约束。
final
修饰符
如果你想要 interface
和 base
的所有限制,你可以将一个类或 mixin class
标记为 final
。这会阻止库外的任何人创建它的任何类型的子类型:不能在 implements
、extends
、with
或 on
子句中使用它。
这对类的用户来说是最具限制性的。他们能做的就只是构造它(除非它被标记为 abstract
)。作为回报,作为类的维护者,你受到的限制最少。你可以添加新方法、将构造函数变为工厂构造函数等,而不用担心破坏任何下游用户。
sealed
修饰符
最后一个修饰符 sealed
是特殊的。它主要用于在模式匹配中启用**穷尽性检查 (exhaustiveness checking)**。如果一个 switch
对一个被标记为 sealed
的类型的每个直接子类型都有 case
,那么编译器就知道这个 switch
是穷尽的。
amigos.dart
1 | sealed class Amigo {} |
这个 switch
对 Amigo
的每个子类型都有一个 case
。编译器知道 Amigo
的每个实例都必须是这些子类型之一的实例,所以它知道这个 switch
是安全穷尽的,不需要任何最终的默认 case
。
为了保证其健全性,编译器强制执行两个限制:
sealed
类本身不能被直接构造。否则,你可能会有一个Amigo
的实例,但它不是任何子类型的实例。所以每个sealed
类也都是隐式abstract
的。sealed
类型的每个直接子类型都必须与sealed
类型在同一个库中声明。这样,编译器才能找到它们全部。它知道没有其他隐藏的子类型会不匹配任何一个case
。
第二个限制类似于 final
。像 final
一样,它意味着一个被标记为 sealed
的类不能在其声明的库之外被直接继承、实现或混入。但是,与 base
和 final
不同,它没有传递性限制:
amigo.dart
1 | sealed class Amigo {} |
other.dart
1 | // 这是一个错误: |
当然,如果你希望你的 sealed
类型的子类型也受到限制,你可以通过用 interface
、base
、final
或 sealed
来标记它们来实现。
sealed
与 final
的对比
如果你有一个类,不希望用户能够直接对其进行子类型化,那么什么时候应该使用 sealed
而不是 final
呢?有几个简单的规则:
- 如果你希望用户能够直接构造该类的实例,那么它不能使用
sealed
,因为sealed
类型是隐式abstract
的。 - 如果该类在你的库中没有子类型,那么使用
sealed
就没有意义,因为你无法从穷尽性检查中获益。
否则,如果该类确实有一些你定义的子类型,那么 sealed
很可能是你想要的。如果用户看到该类有几个子类型,那么能够将它们分别作为 switch
的 case
来处理,并让编译器知道整个类型都被覆盖了,会非常方便。
使用 sealed
确实意味着,如果你以后在库中添加另一个子类型,这将是一个破坏性的 API 变更。当一个新子类型出现时,所有那些现有的 switch
都会变得非穷尽,因为它们没有处理新的类型。这就像向一个枚举添加一个新值一样。
这些非穷尽的 switch
编译错误对用户很有用,因为它们能将用户的注意力引向他们代码中需要处理新类型的地方。
但这也意味着,每当你添加一个新的子类型,它都是一个重大变更。如果你希望能够以非破坏性的方式自由地添加新的子类型,那么最好用 final
而不是 sealed
来标记父类型。这意味着当用户对该父类型的值进行 switch
时,即使他们对所有已知的子类型都有 case
,编译器也会强制他们添加一个默认的 case
。这样,如果你以后添加更多的子类型,就会执行这个默认 case
。
总结
作为 API 设计者,这些新的修饰符让你能够控制用户如何使用你的代码,反过来,也让你能够演进你的代码而不破坏他们的代码。
但这些选项也带来了复杂性:作为 API 设计者,你现在有更多的选择要做。此外,由于这些功能是新的,我们仍然不知道最佳实践会是什么。每种语言的生态系统都不同,有不同的需求。
幸运的是,你不需要一次性把所有问题都搞清楚。我们特意选择了默认设置,这样即使你什么都不做,你的类也大多拥有与 3.0 之前相同的功能。如果你只是想保持你的 API 原样,就在那些已经支持混入的类上加上 mixin
,然后就完成了。
随着时间的推移,当你感觉到你想要更精细的控制时,你可以考虑应用一些其他的修饰符:
- 使用
interface
来阻止用户复用你的类代码,同时允许他们重新实现其接口。 - 使用
base
来要求用户复用你的类代码,并确保你的类类型的每个实例都是该实际类或其子类的实例。 - 使用
final
来完全阻止一个类被继承。 - 使用
sealed
来选择对一个子类型家族进行穷尽性检查。
当你这样做时,发布你的包时请增加主版本号,因为这些修-符都意味着限制,而这些限制是重大变更。
Class modifiers for API maintainers
https://wuhunyu.top/ai/dart/2025/09/class-modifiers-for-apis/index.html