深色模式
类修饰符
修饰符
版本说明
除 abstract
外,类修饰符需要至少 3.0 版本的语言支持。
类修饰符用于控制类或混入在其定义库内部以及外部库中的使用方式。
修饰符关键字位于类或混入声明之前。例如,abstract class
用于定义抽象类。类声明前可以出现的完整修饰符集合包括:
abstract
base
final
interface
sealed
mixin
只有 base
修饰符可以出现在混入声明之前。这些修饰符不适用于其他声明,如 enum
、typedef
、extension
或 extension type
。
在决定是否使用类修饰符时,要考虑类的预期用途以及类需要依赖的行为。
注意: 如果你维护一个库,请阅读《面向 API 维护者的类修饰符》页面,以获取关于如何为你的库应对这些变化的指导。
无修饰符
若要允许任何库不受限制地构造类的实例或创建其子类型,可使用不带修饰符的类或混入声明。默认情况下,你可以:
- 构造类的新实例。
- 扩展类以创建新的子类型。
- 实现类或混入的接口。
- 混入混入类或普通混入。
abstract
若要定义一个不需要完整、具体实现其整个接口的类,可使用 abstract
修饰符。
抽象类不能在任何库(包括其自身所在库和外部库)中被构造。抽象类通常包含抽象方法。
a.dart
dart
abstract class Vehicle {
void moveForward(int meters);
}
b.dart
dart
import 'a.dart';
// 错误:不能被构造。
Vehicle myVehicle = Vehicle();
// 可以被扩展。
class Car extends Vehicle {
int passengers = 4;
@override
void moveForward(int meters) {
// ...
}
}
// 可以被实现。
class MockVehicle implements Vehicle {
@override
void moveForward(int meters) {
// ...
}
}
如果你希望抽象类看起来可以被实例化,可以定义一个工厂构造函数。
base
若要强制继承类或混入的实现,可使用 base
修饰符。基类不允许在其自身所在库之外被实现。这保证了:
- 每当创建该类的子类型的实例时,都会调用基类的构造函数。
- 所有实现的私有成员都存在于子类型中。
- 基类中新增的实现成员不会破坏子类型,因为所有子类型都会继承这个新成员。除非子类型已经声明了一个同名且签名不兼容的成员。
你必须将实现或扩展基类的任何类标记为 base
、final
或 sealed
。这可以防止外部库破坏基类的保证。
a.dart
dart
base class Vehicle {
void moveForward(int meters) {
// ...
}
}
b.dart
dart
import 'a.dart';
// 可以被构造。
Vehicle myVehicle = Vehicle();
// 可以被扩展。
base class Car extends Vehicle {
int passengers = 4;
// ...
}
// 错误:不能被实现。
base class MockVehicle implements Vehicle {
@override
void moveForward() {
// ...
}
}
interface
若要定义一个接口,可使用 interface
修饰符。接口定义库之外的库可以实现该接口,但不能扩展它。这保证了:
- 当类的一个实例方法调用
this
上的另一个实例方法时,它总是会调用同一库中该方法的已知实现。 - 其他库不能以意外的方式重写接口类自身方法可能调用的方法。这减少了脆弱基类问题。
a.dart
dart
interface class Vehicle {
void moveForward(int meters) {
// ...
}
}
b.dart
dart
import 'a.dart';
// 可以被构造。
Vehicle myVehicle = Vehicle();
// 错误:不能被继承。
class Car extends Vehicle {
int passengers = 4;
// ...
}
// 可以被实现。
class MockVehicle implements Vehicle {
@override
void moveForward(int meters) {
// ...
}
}
abstract interface
interface
修饰符最常见的用途是定义纯接口。将 interface
和 abstract
修饰符结合使用可定义抽象接口类。
与接口类一样,其他库可以实现但不能继承纯接口。与抽象类一样,纯接口可以有抽象成员。
final
若要封闭类型层次结构,可使用 final
修饰符。这可以防止当前库之外的类进行子类型化。同时禁止继承和实现可以完全防止子类型化。这保证了:
- 你可以安全地对 API 进行增量更改。
- 你可以调用实例方法,因为知道它们没有在第三方子类中被重写。
最终类可以在同一库中被扩展或实现。final
修饰符包含了 base
的效果,因此任何子类也必须标记为 base
、final
或 sealed
。
a.dart
dart
final class Vehicle {
void moveForward(int meters) {
// ...
}
}
b.dart
dart
import 'a.dart';
// 可以被构造。
Vehicle myVehicle = Vehicle();
// 错误:不能被继承。
class Car extends Vehicle {
int passengers = 4;
// ...
}
class MockVehicle implements Vehicle {
// 错误:不能被实现。
@override
void moveForward(int meters) {
// ...
}
}
sealed
若要创建一组已知的、可枚举的子类型,可使用 sealed
修饰符。这允许你对这些子类型进行 switch
操作,并且编译器会静态确保该操作涵盖了所有可能的情况。
sealed
修饰符防止类在其自身所在库之外被扩展或实现。密封类隐式为抽象类。
- 它们本身不能被构造。
- 它们可以有工厂构造函数。
- 它们可以为其子类定义构造函数。
然而,密封类的子类并非隐式抽象类。
编译器知道所有可能的直接子类型,因为它们只能存在于同一库中。这使得编译器可以在 switch
语句没有涵盖所有可能的子类型时发出警告:
dart
sealed class Vehicle {}
class Car extends Vehicle {}
class Truck implements Vehicle {}
class Bicycle extends Vehicle {}
// 错误:不能被实例化。
Vehicle myVehicle = Vehicle();
// 子类可以被实例化。
Vehicle myCar = Car();
String getVehicleSound(Vehicle vehicle) {
// 错误:switch 语句缺少 Bicycle 子类型或默认情况。
return switch (vehicle) {
Car() => 'vroom',
Truck() => 'VROOOOMM',
};
}
如果你不希望进行详尽的 switch
操作,或者希望能够在不破坏 API 的情况下稍后添加子类型,可使用 final
修饰符。若要进行更深入的比较,请阅读《密封类与最终类》。
组合修饰符
你可以组合一些修饰符以实现分层限制。类声明可以按以下顺序包含修饰符:
- (可选)
abstract
,描述类是否可以包含抽象成员并防止实例化。 - (可选)
base
、interface
、final
或sealed
之一,描述其他库对类进行子类型化的限制。 - (可选)
mixin
,描述该声明是否可以被混入。 class
关键字本身。
有些修饰符不能组合使用,因为它们相互矛盾、冗余或互斥:
abstract
和sealed
。密封类隐式为抽象类。interface
、final
或sealed
与mixin
。这些访问修饰符会阻止混入。
若要获取关于类修饰符如何组合的更多指导,请查看《类修饰符参考》。
对于三方库开发者
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 提供的功能,那么可能不想将其转换为混入类。考虑以下两个设计问题:
- 你是否希望用户能够直接构造该类的实例?换句话说,该类是否有意设计为非抽象类?
- 你是否希望用户能够将该声明用作混入?换句话说,你是否希望他们能够在
with
子句中使用它?
如果两个问题的答案都是“是”,那么将其设为 mixin class
。如果第二个问题的答案是“否”,那么保持它为普通类。如果第一个问题的答案是“否”,而第二个问题的答案是“是”,那么将其从类改为 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 {
// 没有实现 _privateMethod()!
}
main() {
callPrivateMethod(B()); // 运行时异常!
}
在类上添加 base
修饰符可以帮助防止这些运行时错误。与 interface
一样,在声明基类或混入的同一库中可以忽略此限制。这样,同一库中的子类会被提醒实现私有方法。但请注意,以下部分也适用:
基类传递性
将类标记为 base
的目的是确保该类型的每个实例都具体继承自它。为了保持这一点,base
限制是“有传染性的”。标记为 base
的类型的每个子类型(直接或间接)也必须防止被实现。这意味着它必须标记为 base
(或 final
或 sealed
,我们接下来会介绍)。
因此,将 base
应用于类型需要谨慎。它不仅影响用户对类或混入的使用方式,还影响其子类提供的功能。一旦将 base
应用于某个类型,其整个层次结构都将被禁止实现。
这听起来很严格,但大多数其他编程语言一直都是这样的。大多数语言根本没有隐式接口,所以当你在 Java、C# 或其他语言中声明一个类时,实际上也有相同的约束。
final
修饰符
如果你希望同时具备 interface
和 base
的所有限制,可以将类或混入类标记为 final
。这可以防止库外部的任何人创建该类的任何子类型:不能在 implements
、extends
、with
或 on
子句中使用它。
这对类的用户限制最大。他们所能做的就是构造该类(除非它被标记为 abstract
)。作为回报,作为类的维护者,你受到的限制最少。你可以添加新方法、将构造函数转换为工厂构造函数等,而不必担心破坏下游用户。
sealed
修饰符
最后一个修饰符 sealed
很特殊。它主要用于在模式匹配中启用穷举性检查。如果 switch
语句涵盖了标记为 sealed
的类型的每个直接子类型的情况,那么编译器就知道该 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
的每个子类型都提供了一个分支。编译器知道 Amigo
的每个实例都必须是这些子类型之一的实例,所以它知道这个 switch
语句是安全的穷举,不需要任何最后的 default
分支。
为了确保这种检查的正确性,编译器实施了两个限制:
- 密封类本身不能直接构造。否则,你可能会有一个
Amigo
实例,它不是任何子类型的实例。因此,每个密封类也隐式是抽象的。 - 密封类型的每个直接子类型都必须在声明密封类型的同一库中。这样,编译器可以找到所有子类型。它知道不存在其他未匹配任何分支的隐藏子类型。
第二个限制与 final
类似。与 final
一样,这意味着标记为 sealed
的类不能在声明它的库外部直接继承、实现或混入。但是,与 base
和 final
不同,没有传递性限制:
amigo.dart
dart
sealed class Amigo {}
class Lucky extends Amigo {}
class Dusty extends Amigo {}
class Ned extends Amigo {}
other.dart
dart
// 这是一个错误:
class Bad extends Amigo {}
// 但这两个都没问题:
class OtherLucky extends Lucky {}
class OtherDusty implements Dusty {}
当然,如果你希望密封类型的子类型也受到限制,可以使用 interface
、base
、final
或 sealed
标记它们。
sealed
与 final
的比较
如果你有一个不希望用户直接创建子类型的类,什么时候使用 sealed
而不是 final
呢?有几个简单的规则:
- 如果你希望用户能够直接构造类的实例,那么不能使用
sealed
,因为密封类型隐式是抽象的。 - 如果类在你的库中没有子类型,那么使用
sealed
就没有意义,因为你无法获得穷举性检查的好处。 - 否则,如果类确实有一些你定义的子类型,那么
sealed
可能是你想要的。如果用户看到类有几个子类型,能够分别处理每个子类型作为switch
分支,并让编译器知道整个类型都被覆盖了,这会很方便。
使用 sealed
意味着,如果你以后向库中添加另一个子类型,这将是一个 API 破坏变更。当出现新的子类型时,所有现有的 switch
语句都将变得非穷举,因为它们没有处理新类型。这就像向枚举中添加新值一样。
这些非穷举的 switch
编译错误对用户很有用,因为它们会引起用户对代码中需要处理新类型的地方的注意。
但这确实意味着,每当你添加新的子类型时,这都是一个破坏变更。如果你希望能够以非破坏的方式添加新的子类型,那么最好使用 final
而不是 sealed
标记超类型。这意味着,当用户对该超类型的值进行 switch
操作时,即使他们为所有子类型都提供了分支,编译器也会强制他们添加另一个 default
分支。如果以后添加更多子类型,这个 default
分支将被执行。
总结
作为 API 设计者,这些新修饰符让你能够控制用户如何使用你的代码,反之亦然,让你能够在不破坏他们代码的情况下改进你的代码。
但这些选项也带来了复杂性:作为 API 设计者,你现在有更多的选择要做。此外,由于这些功能是新的,我们仍然不知道最佳实践是什么。每种语言的生态系统都不同,有不同的需求。
幸运的是,你不需要一下子弄清楚所有事情。我们特意选择了默认设置,这样即使你什么都不做,你的类在大多数情况下仍然具有 3.0 之前的功能。如果你只想保持 API 不变,在已经支持混入的类上加上 mixin
就可以了。
随着时间的推移,当你觉得需要更精细的控制时,可以考虑应用其他一些修饰符:
- 使用
interface
防止用户复用类的代码,同时允许他们重新实现其接口。 - 使用
base
要求用户复用类的代码,并确保类类型的每个实例都是该类或其子类的实例。 - 使用
final
完全防止类被继承。 - 使用
sealed
对一组子类型启用穷举性检查。
当你这样做时,发布包时要提升主版本号,因为这些修饰符都意味着限制,属于 API 破坏变更。
参考
有效组合
以下是类修饰符的有效组合及其对应的功能:
声明 | 是否可构造? | 是否可继承? | 是否可实现? | 是否可混入? | 是否支持穷举检查? |
---|---|---|---|---|---|
class | 是 | 是 | 是 | 否 | 否 |
base class | 是 | 是 | 否 | 否 | 否 |
interface class | 是 | 否 | 是 | 否 | 否 |
final class | 是 | 否 | 否 | 否 | 否 |
sealed class | 否 | 否 | 否 | 否 | 是 |
abstract class | 否 | 是 | 是 | 否 | 否 |
abstract base class | 否 | 是 | 否 | 否 | 否 |
abstract interface class | 否 | 否 | 是 | 否 | 否 |
abstract final class | 否 | 否 | 否 | 否 | 否 |
mixin class | 是 | 是 | 是 | 是 | 否 |
base mixin class | 是 | 是 | 否 | 是 | 否 |
abstract mixin class | 否 | 是 | 是 | 是 | 否 |
abstract base mixin class | 否 | 是 | 否 | 是 | 否 |
mixin | 否 | 否 | 是 | 是 | 否 |
base mixin | 否 | 否 | 否 | 是 | 否 |
无效组合
某些修饰符的组合是不允许的,原因如下:
组合 | 原因 |
---|---|
base 、interface 和 final | 它们都控制相同的两个功能(继承和实现),因此相互排斥。 |
sealed 和 abstract | 两者都不能被构造,一起使用会造成冗余。 |
sealed 与 base 、interface 或 final | 密封类型已经不能在其他库中被混入、继承或实现,因此与列出的修饰符组合使用会造成冗余。 |
mixin 和 abstract | 两者都不能被构造,一起使用会造成冗余。 |
mixin 与 interface 、final 或 sealed | 混入或混入类声明的目的是用于混入,而列出的修饰符会阻止这种行为。 |
enum 和任何修饰符 | 枚举声明不能被继承、实现、混入,并且总是可以被实例化,因此没有修饰符适用于枚举声明。 |
extension type 和任何修饰符 | 扩展类型声明不能被继承或混入,并且只能由其他扩展类型声明来实现。 |