在南极的冰冻之地,有一家名为Cecil’s Ice Shop的商店,这是一家当地人可以购买三种不同尺寸冰块的蓬勃发展的企业。运作模式很简单——当顾客想要下单或申请退款时,他们会来到前台填写申请表。然后前台将请求发送到冰块工厂,由工厂处理履行。
店主 Cecil 也在用 Kotlin 代码对他的运营进行建模。首先,他创建了一个枚举类来表示这三种尺寸的冰块包装。
enum class Size { CUP, BUCKET, BAG }
接下来,对于订单和退款请求,他创建了一个名为Request的接口。
前台每天处理大量请求,为了跟踪所有请求,每个请求都有一个唯一的 ID 号码。同样,他的Request接口有一个名为id的属性。
interface Request {
val id: Int
}接下来,他添加了两个实现该接口的类——一个用于下单,一个用于申请退款。
class OrderRequest(override val id: Int, val size: Size) : Request
class RefundRequest(override val id: Int, val size: Size, val reason: String) : Request然后,在他的 Kotlin 代码中,Cecil 创建了一个FrontDesk对象来接收Request。前台通过打印其 ID 号码来记录它收到了请求。
之后,他使用when条件语句进行智能转换,并将请求发送到冰块工厂的正确函数,在那里请求将被处理。
object FrontDesk {
fun receive(request: Request) {
println("Handling request #${request.id}")
when (request) {
is OrderRequest -> IceCubeFactory.fulfillOrder(request)
is RefundRequest -> IceCubeFactory.fulfillRefund(request)
}
}
}说到冰块工厂,Cecil 并不太关心它具体如何处理订单和退款。因此,在他的 Kotlin 代码中,他创建了一个IceCubeFactory,只是在每个请求被处理时打印出一条消息。
object IceCubeFactory {
fun fulfillOrder(order: OrderRequest) = println("Fulfilling order #${order.id}")
fun fulfillRefund(refund: RefundRequest) = println("Fulfilling refund #${refund.id}")
}通过这段简单的 Kotlin 代码,顾客现在可以订购一杯冰块了!前台收到订单后,会将其转发到冰块工厂,在那里订单将被处理。
val order = OrderRequest(123, Size.CUP)
FrontDesk.receive(order)Handling request #123 Fulfilling order #123
当然,Cecil 的代码也允许客户通过向前台提交退款请求来申请退款。
val refund = RefundRequest(456, Size.CUP, "Accidentally ordered too much")
FrontDesk.receive(refund)Handling request #456 Fulfilling refund #456
通过这段代码,Cecil’s Ice Shop 继续处理订单和退款,让许多顾客满意……直到有一天 Cecil 需要添加另一种请求类型!
添加另一种类型
有一天,一位名叫 Wallace 的顾客需要帮助打开他的冰袋。以他庞大的身体和光滑的鳍肢,很难责怪一只海象打不开那个袋子!
Cecil 决定是时候开始为客户提供支持服务了。为了帮助像 Wallace 这样的客户,Cecil 构思了一项计划——添加一种新的请求类型,称为支持请求。需要帮助的客户只需填写一份支持请求表,描述他们遇到的问题,然后将其提交给前台。前台会将其转交给一个新的帮助台。
当然,Cecil 在他的 Kotlin 代码中添加新的请求类型很容易。他只需创建一个名为 SupportRequest 的新类,实现 Request 接口。除了 id 属性外,这个新类只添加了一个 text 属性,客户可以在其中写下他们需要的帮助描述。
class SupportRequest(override val id: Int, val text: String) : Request与冰块工厂一样,Cecil 的代码没有包含帮助台实际如何提供帮助的细节,只是添加了一条 println() 语句来记录已收到请求。
object HelpDesk {
fun handle(request: SupportRequest) = println("Help desk is handling ${request.id}")
}太棒了!Cecil 在他的 IDE 中点击了”运行”按钮,他的商店又重新开张了。Wallace 提交了他的帮助请求,并被告知帮助台会有人与他联系。
val request = SupportRequest(789, "I can't open the bag of ice!")
FrontDesk.receive(request)然而,几天后,Wallace 又回来了,和以前一样脾气暴躁。”为什么没有人联系我关于我的支持请求的事情?”他问道。
尴尬之余,Cecil 仔细检查了他程序的输出,查找 Wallace 的支持请求 ID 号,想看看发生了什么。令他惊讶的是,他只找到了关于它的一行信息:
Handling request #789
”前台记录了它收到了请求,但也就仅此而已!帮助台显然从未收到!”Cecil 说道。发生了什么?Cecil 再次调出了前台的代码。
object FrontDesk {
fun makeRequest(request: Request) {
println("Handling request #${request.id}")
when (request) {
is OrderRequest -> IceCubeFactory.fulfillOrder(request)
is RefundRequest -> IceCubeFactory.fulfillRefund(request)
}
}
}”当然!”他喊道,”我忘记为新的 SupportRequest 类型在 when 条件语句中添加分支了!”他真想拍拍自己的额头,但他的鳍肢够不到头。
Cecil 注意到,当他添加一个新的子类型时,在 when 条件语句中忘记添加分支是多么容易。他沉思道:”我的代码中现在只有一个 when。如果我在整个代码中还有更多 when,忘记添加分支会多么容易!糟糕的是,我只在客户投诉之后才发现这个问题!”
不必等待客户报告他代码中的问题,如果 Kotlin 能立即用编译时错误消息告诉他,那就太好了。换句话说,如果他忘记在 when 中添加分支,他希望在代码运行之前就知道——而且要在任何客户受到影响之前很久就知道了!
幸好,Kotlin 有一个功能可以解决这个问题!
密封类型简介
你可能还记得,当一个条件语句涵盖每一种可能的情况时,我们称这个条件语句是穷举的。正如我们在第 5 章>中所见,我们可以使用枚举类来确保我们的 when 条件语句是穷举的。例如,如果 Cecil 想描述不同尺寸的冰块包装,他可以这样写:
when (size) {
Size.CUP -> println("A 12-ounce cup of ice")
Size.BUCKET -> println("A bucket with 1 quart of ice")
Size.BAG -> println("A bag with 1 gallon of ice")
}如果这个 when 语句中缺少其中一个分支,那么 Kotlin 会给出编译器错误。
when (size) {
Size.CUP -> println("A 12-ounce cup of ice")
Size.BAG -> println("A bag with 1 gallon of ice")
}这正是 Cecil 希望看到的编译器错误类型,但他的 when 条件语句检查的是变量的类型,而不是变量的值。
when (request) {
is OrderRequest -> IceCubeFactory.fulfillOrder(request)
is RefundRequest -> IceCubeFactory.fulfillRefund(request)
}那么,Cecil 如何告诉 Kotlin 他希望这个 when 语句是穷举的,以确保 Request 接口的每个子类型都有一个分支?秘诀是使用一个称为密封类型的功能。使用密封类型很容易——我们只需要在接口或类声明前添加一个名为 sealed 的修饰符。
为了演示这一点,让我们将 Cecil 的 Request 接口更新为密封接口。sealed 修饰符放在 interface 关键字之前,如下所示。
sealed interface Request {
val id: Int
}当像 Request 这样的类型被密封时,Kotlin 会跟踪它所有的直接子类型。这样,Kotlin 就能知道你在检查子类型时是否做到了穷举匹配。
事实上,只需在 Request 接口上添加 sealed 修饰符,就会在 when 语句上产生一个编译器错误。
object FrontDesk {
fun receive(request: Request) {
println("Handling request #${request.id}")
when (request) {
is OrderRequest -> IceCubeFactory.fulfillOrder(request)
is RefundRequest -> IceCubeFactory.fulfillRefund(request)
}
}
}完美!正如 Cecil 所愿,Kotlin 现在会在他忘记分支时发出警报,而且由于他在编译时就收到了这个警报,他可以在任何客户受到影响之前修复它。说到修复,这也很容易做到——Cecil 只需为 SupportRequest 添加一个分支,编译器错误就会消失。
object FrontDesk {
fun receive(request: Request) {
println("Handling request #${request.id}")
when (request) {
is OrderRequest -> IceCubeFactory.fulfillOrder(request)
is RefundRequest -> IceCubeFactory.fulfillRefund(request)
is SupportRequest -> HelpDesk.handle(request)
}
}
}值得一提的是,这个编译器错误也可以通过使用 else 分支来满足。然而,在上面的代码中,Cecil 需要智能转换才能将其发送到帮助台。
现在,帮助台正在接收支持请求!Cecil 可以安心了,因为知道帮助台正在照顾像 Wallace 这样的客户。
密封类
在上面的例子中,我们将 sealed 修饰符添加到了接口声明中。但是,也可以将其添加到类声明中。例如,Cecil 可以将 Request 改为抽象类,而不是要求客户在每个请求上输入 id 号码,并自动为其分配一个随机数。由于接口不能持有状态,Cecil 需要将接口改为类,如下所示。
sealed class Request {
val id: Int = kotlin.random.Random.nextInt()
}通过这个简单的更改,他现在使用的是密封类而不是密封接口。当然,这个更改意味着子类需要进行一些更新——比如移除 id 属性并调用 Request 的构造函数。
class OrderRequest(val size: Size) : Request()
class RefundRequest(val size: Size, val reason: String) : Request()
class SupportRequest(val text: String) : Request()请注意,sealed class 按定义也是 abstract class。这意味着你不能直接实例化它——你只能实例化它的子类之一。sealed 修饰符也隐含 abstract 修饰符。虽然同时包含两者不是错误,但这样做是多余且不必要的。因此,如果你使用 sealed 修饰符,就省略 abstract 修饰符。
为什么必须使用 sealed 修饰符?
现在,你可能想知道为什么我们需要在接口或类声明中添加 sealed 修饰符。为什么 Kotlin 不能在没有它的情况下使那些 when 语句穷举?我们将回答这个问题,但首先,让我们谈谈冰箱。
你可能随时都在使用冰箱。你确保它插上电源,然后打开门,把需要冰箱冷藏的东西放进去,然后再关上门。冰箱是一种家用电器——它是一种为人类交互而设计的设备。
现在考虑一下压缩机。压缩机是冰箱的主要部件。没有它,冰箱就不会给你的食物保鲜。然而,作为一个人,你不会直接与压缩机互动。你使用冰箱,而冰箱使用它的压缩机。
与冰箱类似,我们编写的一些代码旨在供人类直接交互。我们不把这类软件称为电器,而是称其为应用程序。另一方面,类似于压缩机,我们编写的其他程序并非旨在供人类直接使用——它们旨在作为应用程序的组件使用。这种软件组件称为库。
在这本书中,你已经在使用一个名为Kotlin 标准库的库。这个库包括基本类和接口、我们用于集合处理的函数等等。事实上,就像如果没有压缩机,冰箱几乎没什么用一样,如果你不包含标准库,Kotlin 项目也几乎做不了什么!
你可以从自己的代码创建一个库,让其他开发者可以使用。例如,Cecil 可以取出他的代码,编译它,并将其打包成一个库,其中包含他的 Request 接口及其子类,还有 FrontDesk 和 IceCubeFactory 对象。
然后,如果来自Bert’s Snips & Clips(见第 7 章)的 Bert 想使用那个接口,他可以在他的代码中引入 Cecil 的库。
当 Bert 使用 Cecil 的库时,他可以看到有一个 Request 接口,并且可以创建自己的子类。例如,他可能会创建一个 SubscriptionRequest,让他的客户可以订阅他的优惠券邮件列表!
然而,这个库在 Bert 开始使用之前就已经编译好了。因此,FrontDesk 代码假设只有三个子类,因为编译时只有这么多。但 Bert 创建了第四个!所以,在 Cecil 构建他的库的时候,Kotlin 无法知道 Bert 或任何其他开发者在未来使用该库时可能创建的所有子类。
因此,通过在接口上添加 sealed 修饰符,可以防止 Bert 在使用库时添加另一个 Request 子类型。当然,Cecil 仍然可以添加,但任何使用该库的人都无法这样做。将 Request 标记为 sealed 有点像把它的三个子类放进信封,然后”密封”信封,这样任何收到信封的人都无法再往里面放东西!
总之,如果你想要穷举的子类型匹配,你需要包含 sealed 修饰符,无论你是在构建应用程序还是库。
密封类型子类型的限制
如我们所见,密封类型在你想让 Kotlin 确保穷举匹配 when 条件语句中的子类型时非常有用。根据设计,它们有一些限制。具体来说,密封接口或类的每个直接子类型……
- ……必须声明在同一个代码库中。换句话说,如果你把你的代码创建成一个库,使用该库的任何人将在不同的代码库中工作,并且无法对其创建子类型。
- ……必须声明在同一个包中。即使在同一个 Kotlin 项目中,密封类型的子类型也必须与密封类型本身在完全相同的包中。
值得注意的是,这些规则与 Kotlin 1.0 时期相比已经放宽了!当时只支持密封类(密封接口是在 Kotlin 1.5 中添加的),而且所有子类都必须声明在密封类的类体内部!
请注意,这些限制仅适用于密封类型的直接子类型。如果你想创建密封类型的子类型的子类型,你可以这样做,即使密封类型在你使用的库中。
例如,Bert 无法创建 Request 的新直接子类型。但是,只要 Cecil 将其标记为 open 或 abstract,他就可以创建 SupportRequest 的子类。为什么直接子类型受到限制但间接子类型却可以?
那么,让我们再看看 FrontDesk 的代码。
object FrontDesk {
fun receive(request: Request) {
println("Handling request #${request.id}")
when (request) {
is OrderRequest -> IceCubeFactory.fulfillOrder(request)
is RefundRequest -> IceCubeFactory.fulfillRefund(request)
is SupportRequest -> HelpDesk.handle(request)
}
}
}假设 Bert 正在使用 Cecil 的库,他添加了一个 Request 的新直接子类型,称为 SubscriptionRequest。在这段代码中,如果用 SubscriptionRequest 的实例调用 FrontDesk.receive(),这个 when 条件语句中没有任何分支会匹配,所以这个条件语句实际上不会是穷举的。因此,Kotlin 不允许这样做。
现在,假设他创建了一个名为 CouponSupportRequest 的 SupportRequest 子类型。在这种情况下,当用 CouponSupportRequest 的实例调用 FrontDesk.receive() 时,第三个分支会匹配,因为 CouponSupportRequest 是 SupportRequest 的更特定类型。因此,在这种情况下,条件语句仍然是穷举的。
所以再说一次,这两个限制只适用于直接子类型,因为间接子类型不会破坏条件语句的完整性。
密封类型与枚举类的对比
如前所述,早在第 5 章>中,我们就第一次看到了 Kotlin 如何告诉我们什么时候我们的 when 条件语句是穷举的,不需要 else 分支,如下所示。
enum class SchnauzerBreed { MINIATURE, STANDARD, GIANT }
fun describe(breed: SchnauzerBreed) = when (breed) {
SchnauzerBreed.MINIATURE -> "Small"
SchnauzerBreed.STANDARD -> "Medium"
SchnauzerBreed.GIANT -> "Large"
}正如我们在本章中所见,Kotlin 对密封类型也可以做同样的事情。
when (request) {
is OrderRequest -> IceCubeFactory.fulfillOrder(request)
is RefundRequest -> IceCubeFactory.fulfillRefund(request)
is SupportRequest -> HelpDesk.handle(request)
}看到这种相似性很容易得出结论:密封类型只是一种更复杂的枚举类,但这种比较通常会让人更加困惑而非有帮助。密封类型和枚举类有一些重要的区别需要了解。
首先,条件语句检查的内容有所不同。对于密封类型,你的条件语句正在检查密封类型的子类型。
另一方面,对于枚举类,条件语句不是在检查类型——而是在检查值。
这是因为枚举类中的每个条目都是一个对象,而不是一个类。
其次,枚举类具有密封类没有的各种内置属性>和函数。例如:
- 你可以获取枚举条目的
ordinal属性,但密封类型的子类型没有顺序。 - 枚举类提供
entries()函数,允许你轻松遍历其条目。密封类型没有针对其子类型的此类函数。1
基于这些原因最好不要把密封类型看作更复杂的枚举类。它们在条件语句中达到类似的效果,但除此之外,它们具有不同的特性,使每个在不同情况下都有优势。
如果你发现自己正在犹豫使用密封类型还是枚举类,请问问自己你想要限制的是什么。如果你需要限制值,就使用枚举类。如果你需要限制类型,就使用密封类型。
让我们以第 5 章>中的雪纳瑞犬品种为例。如果你想要表示雪纳瑞的三个有效品种,那么枚举类很合适。该类型只是 SchnauzerBreed,其值限于 MINIATURE、STANDARD 和 GIANT。
// SchnauzerBreed instances are limited to three:
enum class SchnauzerBreed { MINIATURE, STANDARD, GIANT }另一方面,如果你想表示实际的雪纳瑞犬——也就是说狗本身而不是品种——那么考虑使用密封类型。这允许你将类型限制为仅三个子类型……
// Subtypes of Schnauzer are limited to three:
sealed class Schnauzer(val name: String, val sound: String)
class MiniatureSchnauzer(name: String) : Schnauzer(name, "Yip! Yip!")
class StandardSchnauzer(name: String) : Schnauzer(name, "Bark!")
class GiantSchnauzer(name: String) : Schnauzer(name, "Ruuuuffff!")…但它允许创建无限数量的实例。
// No limit on how many Schnauzer instances you can create:
val dogs = listOf(
MiniatureSchnauzer("Shadow"),
StandardSchnauzer("Agent"),
MiniatureSchnauzer("Scout"),
GiantSchnauzer("Rex"),
GiantSchnauzer("Brutus")
// ... as many as you want ...
)枚举类和密封类型都很重要,各有各的优势!
本章小结
好吧,Cecil 的冰块店现在经营得很好,处理订单、退款,甚至支持工单!在本章中,我们学习了:
- 普通类型在条件语句中不会被穷举匹配。
- 密封类型在条件语句中会被穷举匹配。
- 为什么 Kotlin 要求你使用
sealed修饰符来实现此功能。 sealed修饰符如何应用于接口和类声明。- 密封类型子类型受到的限制。
- 密封类型与枚举类之间的区别。
在本章中,我们看到了几个编译时错误的例子。在下一章中,我们将看看如何在代码运行时处理错误的不同方法!
-
一般来说,你不需要遍历密封类型的子类型。然而,从技术上讲,可以使用 Kotlin 的反射库来实现。↩︎