5.8.9.1 密封类与密封接口

原文链接: https://kotlinlang.org/docs/sealed-classes.html

5.8.9.1 密封类与密封接口

密封类与密封接口为类层级提供受控的继承。密封类的所有直接子类在编译期都是已知的。在定义该密封类的模块和包之外,不会出现其他子类。同样的逻辑也适用于密封接口及其实现:一旦包含密封接口的模块编译完成,就无法再创建新的实现。

注意: 直接子类是直接继承其超类的类。间接子类是比其超类低一级以上继承下来的类。

当你把密封类和密封接口与 when 表达式结合使用时,就可以覆盖所有可能子类的行为,并确保不会出现会影响你代码的新的子类。

密封类最适合以下场景:

  • 希望限制类继承: 你有一组预定义的、有限的子类继承某个类,且它们在编译期都是已知的。
  • 需要类型安全的设计: 安全性与模式匹配在项目中至关重要,尤其是在状态管理或处理复杂条件逻辑时。示例请参阅把密封类与 when 表达式一起使用。
  • 处理封闭的 API: 你希望为库提供健壮且可维护的公开 API,确保第三方客户端按预期方式使用这些 API。

关于更详细的实战应用,请参阅用例场景。

提示: Java 15 引入了类似的概念,其中密封类使用 sealed 关键字配合 permits 子句来定义受限的层级。

声明密封类或密封接口

要声明密封类或密封接口,请使用 sealed 修饰符:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// 创建一个密封接口
sealed interface Error

// 创建一个实现密封接口 Error 的密封类
sealed class IOError(): Error

// 定义继承密封类 'IOError' 的子类
class FileReadError(val file: File): IOError()
class DatabaseError(val source: DataSource): IOError()

// 创建一个实现 'Error' 密封接口的单例对象
object RuntimeError : Error

这个例子可以表示某个库的 API,其中包含错误类,让库的使用者能够处理该库可能抛出的错误。如果这类错误类的层级中包含在公开 API 中可见的接口或抽象类,那么没有什么能阻止其他开发者在客户端代码中实现或扩展它们。由于库并不知道外部声明的错误,它无法把外部错误与自己的类一致地处理。而借助密封的错误类层级,库作者可以确信自己知道所有可能的错误类型,并且之后不会再出现其他错误类型。

该示例的层级结构如下:

密封类与密封接口的层级示意图

构造函数

密封类本身始终是抽象类,因此不能被直接实例化。不过,它可以包含或继承构造函数。这些构造函数不是用来创建密封类自身实例的,而是供其子类使用的。请看下面这个名为 Error 的密封类及其若干子类,我们对它们进行了实例化:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
sealed class Error(val message: String) {
    class NetworkError : Error("Network failure")
    class DatabaseError : Error("Database cannot be reached")
    class UnknownError : Error("An unknown error has occurred")
}

fun main() {
    val errors = listOf(Error.NetworkError(), Error.DatabaseError(), Error.UnknownError())
    errors.forEach { println(it.message) }
}
// Network failure
// Database cannot be reached
// An unknown error has occurred

你可以在密封类中使用 enum 类,用枚举常量表示状态并提供额外细节。每个枚举常量只存在一个实例,而密封类的子类可以有多个实例。在这个例子中,sealed class Error 及其若干子类使用 enum 表示错误严重程度。每个子类的构造函数都会初始化 severity,并且可以改变它的状态:

1
2
3
4
5
6
7
8
enum class ErrorSeverity { MINOR, MAJOR, CRITICAL }

sealed class Error(val severity: ErrorSeverity) {
    class FileReadError(val file: File): Error(ErrorSeverity.MAJOR)
    class DatabaseError(val source: DataSource): Error(ErrorSeverity.CRITICAL)
    object RuntimeError : Error(ErrorSeverity.CRITICAL)
    // 可以在这里添加更多错误类型
}

密封类的构造函数可以有两种可见性:protected(默认)或 private:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
sealed class IOError {
    // 密封类的构造函数默认具有 protected 可见性。它在此类及其子类中可见
    constructor() { /*...*/ }

    // 私有构造函数,只在此类中可见。
    // 在密封类中使用私有构造函数可以实现更严格的实例化控制,从而在类内部执行特定的初始化流程。
    private constructor(description: String): this() { /*...*/ }

    // 这里会报错,因为密封类不允许 public 和 internal 构造函数
    // public constructor(code: Int): this() {}
}

继承

密封类和密封接口的直接子类必须在同一个包中声明。它们可以是顶层声明,也可以嵌套在任意数量的其他具名类、具名接口或具名对象中。子类可以具有任何可见性,只要符合 Kotlin 中通常的继承规则,包括重写属性的规则。

密封类的子类必须有正确限定的名称。它们不能是局部对象或匿名对象。

注意: enum 类不能继承密封类或其他任何类。不过,它们可以实现密封接口:kotlin sealed interface Error // 继承密封接口 Error 的 enum 类 enum class ErrorType : Error { FILE_ERROR, DATABASE_ERROR }

这些限制不适用于间接子类。如果密封类的某个直接子类没有被标记为 sealed,那么它可以按其修饰符允许的任何方式被继承:

1
2
3
4
5
6
7
8
// 密封接口 'Error' 只在同一个包和模块中有实现
sealed interface Error

// 密封类 'IOError' 继承 'Error',且只能在同一个包中扩展
sealed class IOError(): Error

// 开放类 'CustomError' 继承 'Error',在它可见的任何地方都可以被扩展
open class CustomError(): Error

多平台项目中的继承

在多平台项目中还有一条继承限制:密封类的直接子类必须位于同一个源集中。这适用于没有 expect 和 actual 修饰符的密封类。

如果某个密封类在公共源集中声明为 expect,并在平台源集中有 actual 实现,那么 expect 和 actual 两个版本都可以在各自的源集中有子类。此外,如果你使用层级结构,还可以在 expect 与 actual 声明之间的任意源集中创建子类。

进一步了解多平台项目的层级结构。

把密封类与 when 表达式一起使用

使用密封类的关键好处体现在把它用于 when 表达式时。与密封类一起使用的 when 表达式让 Kotlin 编译器能够穷尽检查所有可能情况是否都已覆盖。在这种情况下,你不需要添加 else 子句:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 密封类及其子类
sealed class Error {
    class FileReadError(val file: String): Error()
    class DatabaseError(val source: String): Error()
    object RuntimeError : Error()
}

// 用于记录错误的函数
fun log(e: Error) = when(e) {
    is Error.FileReadError -> println("Error while reading file ${e.file}")
    is Error.DatabaseError -> println("Error while reading from database ${e.source}")
    Error.RuntimeError -> println("Runtime error")
    // 不需要 `else` 子句,因为所有情况都已覆盖
}

// 列出所有错误
fun main() {
    val errors = listOf(
        Error.FileReadError("example.txt"),
        Error.DatabaseError("usersDatabase"),
        Error.RuntimeError
    )

    errors.forEach { log(it) }
}

提示: 要减少 when 表达式中的重复,可以试试上下文敏感解析(目前为预览功能)。当预期类型已知时,该功能允许你在匹配密封类成员时省略类型名。更多信息请参阅上下文敏感解析预览或相关的 KEEP 提案。

把密封类与 when 表达式一起使用时,你还可以添加守卫条件,在单个分支中加入额外检查。更多信息请参阅 when 表达式中的守卫条件。

注意: 在多平台项目中,如果你的公共代码里有一个带 when 表达式的密封类作为预期声明,仍然需要 else 分支。这是因为 actual 平台实现中的子类可能继承公共代码中未知的密封类。

用例场景

我们来看看密封类和密封接口特别有用的一些实际场景。

UI 应用中的状态管理

你可以用密封类表示应用中的不同 UI 状态。这种方式可以结构化且安全地处理 UI 变化。下面的例子演示了如何管理各种 UI 状态:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
sealed class UIState {
    data object Loading : UIState()
    data class Success(val data: String) : UIState()
    data class Error(val exception: Exception) : UIState()
}

fun updateUI(state: UIState) {
    when (state) {
        is UIState.Loading -> showLoadingIndicator()
        is UIState.Success -> showData(state.data)
        is UIState.Error -> showError(state.exception)
    }
}

支付方式处理

在实际业务应用中,高效处理各种支付方式是一项常见需求。你可以用密封类配合 when 表达式来实现这类业务逻辑。把不同的支付方式表示为密封类的子类,可以为交易处理建立清晰且易于管理的结构:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
sealed class Payment {
    data class CreditCard(val number: String, val expiryDate: String) : Payment()
    data class PayPal(val email: String) : Payment()
    data object Cash : Payment()
}

fun processPayment(payment: Payment) {
    when (payment) {
        is Payment.CreditCard -> processCreditCardPayment(payment.number, payment.expiryDate)
        is Payment.PayPal -> processPayPalPayment(payment.email)
        is Payment.Cash -> processCashPayment()
    }
}

Payment 是一个密封类,表示电商系统中不同的支付方式:CreditCard、PayPal 和 Cash。每个子类都可以有自己的特定属性,例如 CreditCard 的 number 和 expiryDate,以及 PayPal 的 email。

processPayment() 函数演示了如何处理不同的支付方式。这种方式确保考虑了所有可能的支付类型,同时系统仍然可以灵活地支持将来新增的支付方式。

API 请求与响应处理

你可以用密封类和密封接口实现一个处理 API 请求与响应的用户认证系统。该用户认证系统具有登录和登出功能。密封接口 ApiRequest 定义了具体的请求类型:用于登录的 LoginRequest,以及用于登出操作的 LogoutRequest。密封类 ApiResponse 封装了不同的响应场景:带用户数据的 UserSuccess、表示用户不存在的 UserNotFound,以及表示任何失败的 Error。handleRequest 函数使用 when 表达式以类型安全的方式处理这些请求,而 getUserById 则模拟用户查询:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
// 导入必要的模块
import io.ktor.server.application.*
import io.ktor.server.resources.*

import kotlinx.serialization.*

// 使用 Ktor 资源为 API 请求定义密封接口
@Resource("api")
sealed interface ApiRequest

@Serializable
@Resource("login")
data class LoginRequest(val username: String, val password: String) : ApiRequest

@Serializable
@Resource("logout")
object LogoutRequest : ApiRequest

// 定义带详细响应类型的 ApiResponse 密封类
sealed class ApiResponse {
    data class UserSuccess(val user: UserData) : ApiResponse()
    data object UserNotFound : ApiResponse()
    data class Error(val message: String) : ApiResponse()
}

// 成功响应中使用的用户数据类
data class UserData(val userId: String, val name: String, val email: String)

// 用于校验用户凭据的函数(仅用于演示)
fun isValidUser(username: String, password: String): Boolean {
    // 一些校验逻辑(这里只是占位)
    return username == "validUser" && password == "validPass"
}

// 处理 API 请求并返回详细响应的函数
fun handleRequest(request: ApiRequest): ApiResponse {
    return when (request) {
        is LoginRequest -> {
            if (isValidUser(request.username, request.password)) {
                ApiResponse.UserSuccess(UserData("userId", "userName", "userEmail"))
            } else {
                ApiResponse.Error("Invalid username or password")
            }
        }
        is LogoutRequest -> {
            // 假设在本例中登出操作总是成功
            ApiResponse.UserSuccess(UserData("userId", "userName", "userEmail")) // 仅用于演示
        }
    }
}

// 模拟 getUserById 调用的函数
fun getUserById(userId: String): ApiResponse {
    return if (userId == "validUserId") {
        ApiResponse.UserSuccess(UserData("validUserId", "John Doe", "john@example.com"))
    } else {
        ApiResponse.UserNotFound
    }
    // 错误处理也会返回 Error 响应。
}

// 演示用法的主函数
fun main() {
    val loginResponse = handleRequest(LoginRequest("user", "pass"))
    println(loginResponse)

    val logoutResponse = handleRequest(LogoutRequest)
    println(logoutResponse)

    val userResponse = getUserById("validUserId")
    println(userResponse)

    val userNotFoundResponse = getUserById("invalidId")
    println(userNotFoundResponse)
}

接下来学什么

进一步了解 Kotlin 中的继承。