资讯动态

Kotlin密封类实战:穷举检查、递归建模与状态管理

发布时间:2026/10/16 22:57:55 来源:尧图企业网站定制
写 Kotlin 写久了凡是涉及状态流转的地方我大概率会打开一个sealed class而不是interface。刚接触那会儿挺不习惯——为什么子类必须写在同一个包里为什么不能随便让别的模块扩展后来连续踩了几个被else分支兜底掩盖的隐蔽 bug才真的明白密封类不是在限制我的自由而是在帮我把所有可能的情况提前钉死在编译期。这篇文章就聊聊密封类的核心约束、递归数据建模、智能转换与穷举检查以及我在客户端项目里实际用下来的一些取舍心得适合正在学 Kotlin 进阶语法、或者想改进项目里状态管理方式的同学参考。1. else分支兜底从一个隐蔽bug看密封类的价值1.1 一个被漏掉的网络请求状态先讲一个真实踩坑场景。某同事接手的旧模块里网络请求结果用一个普通接口建模大概是这样的interface HttpResult { object Loading : HttpResult data class Success(val data: String) : HttpResult data class Error(val code: Int, val message: String) : HttpResult }当时看着没什么问题业务方也跑了挺久。直到后来需求加了一个账号登录态失效的状态同事往接口下面加了一个data class Unauthorized然后去旧页面里补对应逻辑。问题就出在这里页面里渲染结果用的是when (result)而且大部分分支都是处理完就 return只有少数地方写了else - {}兜底。因为when作为语句使用时不强制穷举编译器在没有任何else的情况下也不会拦住他。结果就是新增的Unauthorized状态在某个详情页里被静默忽略了用户看到的现象是——点了重新加载按钮页面一直转圈没有任何错误提示也没有跳转登录页。这个 bug 的根子不在when写没写else而在于选了interface这个建模工具。接口的设计目标是开放扩展谁都可以在自己模块里加一个实现类。编译器在编译当前模块时根本不可能知道整个项目里到底有多少个HttpResult的实现因此它没法帮你判断这个when是不是已经覆盖完所有情况了。一旦覆盖不全就只能靠else兜底而else一出现就把未知分支和理应存在但忘写了的分支混杂在了一起。1.2 接口实现的穷举难题很多人刚开始学 Kotlin 时喜欢用enum表示状态后来发现不够用就自然切到interface。这两种方案各自的短板恰好互补建模方式优势短板enum 类编译器能做穷举检查新增枚举常量时when会提醒每个常量都是同一个类没法携带不同形状的数据interface 实现类每个实现类可以有各自的字段灵活编译器无法穷举所有实现when必须依赖elsesealed class / interface类型集合被编译器感知既能穷举又能携带数据直接子类必须放在同一个包、同一个编译模块里data object无状态单例的规范表示有toString/equals只能表达没有附加数据的情况如果状态只有四五种且每种都不带数据用enum是最舒服的。可一旦某个状态需要携带列表、错误码、提示文案enum 就尴尬了。接口倒是能带数据但穷举检查彻底失效。密封类很像是把 enum 的穷举能力和接口的扩展能力揉在一起类型集合是封闭的但每个成员可以是一个完整的类。所以当团队约定所有请求状态、页面状态、插件事件都用密封类建模之后类似上面的 bug 几乎绝迹了。不是大家写代码变谨慎了而是在我准备收工的时候编译器已经把漏掉的分支怼到脸上想视而不见都难。2. 密封类的继承约束到底在约束什么2.1 同一个包与同一个编译模块密封类的核心语法很简单一个sealed关键字一个抽象类型sealed class LoadState { data object Loading : LoadState() data class Success(val data: ListItem) : LoadState() data class Error(val code: Int, val message: String) : LoadState() }但它的继承规则和普通接口完全不同。密封类的所有直接子类必须和密封类本身位于同一个包、同一个编译模块里。这里的编译模块通常指构建体系里的一个独立模块也可以理解为一个单独编译的单元。这个约束的初衷非常朴素只有把子类限制在编译器能看到的范围内编译器才能在做穷举检查时列出一份完整清单。如果你的密封类在一个模块里实现类散落在其他模块那编译器就无法判断某个when是否已经覆盖完所有分支。早期 Kotlin 版本的约束更严格直接子类甚至必须和密封类写在同一个文件里。后来版本放宽到同一个包又补充了密封接口支持。我自己写项目时反而会刻意利用这个限制需要对外扩展的状态绝不放进密封类需要穷举的状态绝不拆到别的包。如果哪天同事把新状态加到了别的包编译器直接报子类必须位于父类同一个包的错误这本身就是一次代码评审。这里有一个容易踩的认知误区密封类只能限制直接子类不限制孙子类。也就是说密封类下面有一个普通抽象类作为中间层这个中间层的子类可以继续扩展。实践中确实见过有人用这种多层结构来做复杂业务的分层状态但大多数场景没必要倒是会让自己排查分支时多绕一层。2.2 编译器的穷举检查原理密封类的杀手锏是穷举检查。当when的 subject 类型是某个密封类时编译器会去收集它的所有直接子类型然后逐项比对when里的分支。只要某个子类型既没有对应的is分支、也没有相等的对象分支并且这个when又被用作表达式也就是有返回值编译器就会直接报错when expression must be exhaustive。换个说法密封类让你从运行时靠 else 猜变成编译时靠类型清单检查。这不是魔法而是编译器拥有一个封闭的继承列表。刚开始用sealed class时你可能会怕漏写分支怎么办实际恰恰相反想漏都难因为漏一个编译器就不让你编译通过。举个直观例子。给之前的LoadState增加一个Empty状态sealed class LoadState { data object Loading : LoadState() data class Success(val data: ListItem) : LoadState() data class Error(val code: Int, val message: String) : LoadState() data object Empty : LoadState() }只要项目里存在任何一个把LoadState当作when表达式主体的代码编辑器就会在那一行报红色错误提示Empty分支缺失。这个连锁反应特别爽加一个状态所有需要处理它的地方全部浮出水面你只需要挨个决定这里要不要处理空态。如果你使用的是when语句没有返回值Kotlin 并不强制穷举。这里我强烈建议一个项目约定处理密封类的when要么干脆用表达式形式把返回值接住要么故意写一个else - throw IllegalStateException(...)或者注释写明此处其余分支确实不会出现。最怕的就是写else - {}这等于亲手把密封类的优势又还回去了。3. 数据类型搭配普通类、数据类、对象与密封接口3.1 三种声明形式怎么选密封类内部可以有多种形式的子类型用哪一种完全取决于这个状态需不需要携带数据data class状态有多个字段时首选比如Success(data: ListItem, timestamp: Long)。数据类自带equals、hashCode、copy在 UI 层比较新旧状态、做局部刷新时非常省事。object纯单例状态不带任何字段比如Loading、Idle。Kotlin 新版本还引入了data object给单例提供了规范的toString、equals实现和object的写法区别不大我一般直接用data object。普通class当需要自定义equals、或者类本身很复杂不希望被copy误用时可以用普通类。但在状态建模里很少遇到大多数情况数据类就够。举个例子一个常见的加载状态sealed class LoadState { data object Loading : LoadState() data class Success(val data: ListItem) : LoadState() data class Error(val code: Int, val message: String) : LoadState() data class Empty(val hint: String) : LoadState() }Loading是单例因为正在加载这回事没有附加数据任何时候都相等Success和Error用数据类分别携带数据和错误信息Empty虽然也带提示文案但依然用数据类因为它有一个可变化的字段。我还见过一种写法把公共字段塞到密封类父类里sealed class LoadState(open val message: String?) { data object Loading : LoadState(null) data class Error(override val message: String) : LoadState(message) }这可以避免子类重复声明相同的字段但坏处是父类字段会让子类构造函数变得不明显copy也不会自动处理父类属性。除非项目里有非常强的公共字段复用需求否则我建议保持子类各自独立穷举时看得更清楚。3.2 密封接口与多继承Kotlin 1.5 之后引入了sealed interface。密封接口和密封类最大的区别在于一个类可以实现多个接口而一个类只能继承一个父类。对状态建模来说密封接口允许某个数据类既属于状态集合又满足另一套能力接口。举一个实际会遇到的场景客户端里有一组下拉菜单动作事件它们既需要被当成事件处理又需要支持可被埋点的能力。如果用密封接口配合普通接口sealed interface MenuAction { data object OpenProfile : MenuAction data class OpenArticle(val articleId: String) : MenuAction data class Share(val content: String) : MenuAction }假设Share事件还需要被当作分享追踪对象传递给另一个通用埋点组件可以再定义一个接口让密封接口的子类同时实现它interface TrackableEvent { val eventName: String get() this::class.simpleName ?: unknown } sealed interface MenuAction : TrackableEvent { data object OpenProfile : MenuAction data class OpenArticle(val articleId: String) : MenuAction data class Share(val content: String) : MenuAction }这样MenuAction的每一个子类都天然具备TrackableEvent类型可以传给埋点收集器。如果只用sealed class因为 Kotlin 单继承限制你就没法同时让它继承其他能力接口了。不过密封接口也继承了直接实现类必须位于同一个包和模块的约束。对跨模块的 SPI服务提供接口设计来说它反而没那么合适——那些场景更需要的往往就是允许外部模块自由扩展。4. 花式 when表达式穷举与智能转换4.1 表达式形式的穷举强制效果密封类的真正威力不在定义阶段而在使用阶段。当你写when表达式来消费一个密封类型时编译器会要求你把所有子类分支都列出来列齐了你甚至可以不用谢elsefun describe(state: LoadState): String when (state) { is LoadState.Loading - 加载中 is LoadState.Success - 成功共 ${state.data.size} 条 is LoadState.Error - 失败${state.message} is LoadState.Empty - 暂无内容${state.hint} }这个when没有else分支但你不会收到must be exhaustive的报错因为四个子类型全部覆盖了。反过来如果你删掉Empty分支IDE 立刻标红。这背后是一个权衡为了换取编译器的穷举保证你必须放弃一部分自由。但绝大多数场景下这个自由本来也无价值——一个页面状态如果不处理空态那它极可能是漏逻辑而不是故意为之。需要注意的是when语句不是表达式依然允许不穷举。比如when (state) { is LoadState.Success - updateList(state.data) is LoadState.Error - showError(state.message) }这段代码能编译Loading和Empty分支直接被忽略了。有些项目就是这么写状态处理的后果是新增一个子类型后不会立刻出错直到运行到漏掉的路径才出问题。所以我更推荐在这种场景下让when返回一个值哪怕这个值只是Unit也能借表达式形式把穷举检查激活。4.2 智能转换带来的字段访问简化密封类配合智能转换smart cast用起来非常顺手。当你写完is LoadState.Success分支后分支内部可以直接访问state.data不需要再做任何强转。原因在于LoadState.Success是LoadState的具体可识别子类型编译器在is检查成立之后会把state的静态类型自动收窄为LoadState.Success。这个特性在多层嵌套判断里尤其明显。比如fun handle(state: LoadState) { when (state) { is LoadState.Success - { // 这里直接用 state.data无需额外变量 refreshList(state.data) } is LoadState.Error - { // 这里直接用 state.code / state.message logError(state.code, state.message) } LoadState.Loading - showProgress() is LoadState.Empty - showEmpty(state.hint) } }第一次从 Java 转过来的人往往会不习惯不是还要(LoadState.Success) state强转吗Kotlin 里不需要。前提是这些属性必须是val如果你在数据类里把字段声明成var智能转换在部分场景下会失效因为编译器无法保证这个属性在检查之后没有被修改。还有一种比较花哨的用法把when分支条件合并fun isRecoverable(state: LoadState): Boolean when (state) { is LoadState.Error, is LoadState.Empty - true is LoadState.Loading - false is LoadState.Success - false }多个分支共享同一个返回值时可以用逗号合并。这里的智能转换不会让你在 Error 分支里访问state.message时混淆因为两个子类型的交集里能访问的是它们各自都有的字段。不过一旦合并分支分支内部的类型就是联合类型只能访问公共成员。所以这种写法更适合那些不需要访问具体字段的场景。5. 递归数据结构用密封类实现表达式求值5.1 语法树建模密封类在递归数据结构上是天然的绝配。最经典的例子是表达式求值一个表达式要么是数字要么是两个表达式的加法要么是两个表达式的乘法。这种自身包含自身的结构用密封类建模非常干净sealed class Expr { data class Number(val value: Int) : Expr() data class Add(val left: Expr, val right: Expr) : Expr() data class Mul(val left: Expr, val right: Expr) : Expr() }注意Add和Mul的字段类型是Expr本身形成递归引用。如果这里换成枚举或普通接口你很难在类型安全的前提下写出清晰的构造方式如果不用继承而是用多个互不相干的类调用方又必须写一堆instanceof。密封类把节点的类型集合锁死在一个闭合的层级里后续写遍历逻辑时就能依赖穷举保证。这个例子同样演示了密封类的嵌套能力Expr内部嵌套了Number/Add/Mul它们都是Expr的直接子类。即使在同一个文件里嵌套声明也完全满足密封类的约束条件。5.2 求值器与打印器的实现有了语法树写求值器就是一路递归加when穷举fun eval(expr: Expr): Int when (expr) { is Expr.Number - expr.value is Expr.Add - eval(expr.left) eval(expr.right) is Expr.Mul - eval(expr.left) * eval(expr.right) }再写一个把表达式还原成字符串的 render 函数fun render(expr: Expr): String when (expr) { is Expr.Number - expr.value.toString() is Expr.Add - ${render(expr.left)} ${render(expr.right)} is Expr.Mul - ${render(expr.left)} * ${render(expr.right)} }好玩的部分来了如果以后要加一个Subtract节点你的改动集中在三处——定义处、eval、render。因为when是表达式形式漏掉任何一处编译器都会在对应函数上报must be exhaustive。这种改一处模型所有处理点都被编译器点名的体验是接口方案永远给不了的。这个模式可以平移到很多场景JSON 引擎解析 AST、规则引擎的条件树、编译器的字节码中间表示、甚至客户端里的页面路由表。凡是节点类型固定但数量可能不断增长的层级结构密封类都是第一选择。5.3 深层递归与栈溢出必须提醒一点递归求值在表达式深度很大时会触发StackOverflowError。我在测试一个自动生成的表达式时写了一个深度 10000 层的嵌套加法结果求值函数直接崩了。这不代表密封类有问题但现实业务里如果表达式是由用户输入或远端下发的就不能放任递归无限制地往下钻。简单处理办法包括在递归前检查节点深度把递归改成显式栈迭代或者给业务层设置一个表达式树最大深度的上限。我的做法是在语法树构建阶段就拦掉超深输入而不是等到求值时才发现。6. 客户端实战用密封类管理页面状态6.1 页面状态机的统一写法在客户端开发里我最常用密封类的地方就是页面状态管理。一个页面从进入开始无非经历加载中、成功、失败、空态、登录失效等几种状态。用密封类建模容易得出一个高内聚的状态机sealed interface LoginUiState { data object Idle : LoginUiState data object Submitting : LoginUiState data class Success(val token: String) : LoginUiState data class Failure(val reason: String) : LoginUiState }UI 层只需要一个render(state: LoginUiState)函数把所有分支穷举处理就再也不用担心状态组合爆炸的问题。比如我不会在界面里同时存在isSubmittingtrue、errorMessage!null、successtrue这种诡异组合因为这些互斥状态已经由类型系统拆开了。另一个好处是状态之间的流转逻辑可以收敛到一个函数里。比如登录页点击提交按钮时从Idle变成Submitting接口返回后从Submitting变成Success(token)或Failure(reason)。每一步的状态都不可变不用像传统写法那样把一堆 Boolean 翻来覆去地置位。6.2 带附带数据的状态转移数据类还带来了copy能力在状态转移时特别方便。比如用户列表页下拉刷新成功后想要保留上一次筛选条件可以这样data class Success( val list: ListItem, val keyword: String, val page: Int, val hasMore: Boolean ) : LoadState()刷新成功时val newState previousState.copy( list newList, page previousState.page 1, hasMore newList.isNotEmpty() )因为子类是数据类copy免去了手写逐个赋值的样板代码。注意previousState必须先智能转换为Successcopy才能被调用。也就是说只有当你确定当前状态是成功态时才能执行这个拷贝。6.3 反模式把所有状态塞进一个类我要专门点名一个常见的反面写法用一个巨大的data class表示所有状态内部塞满各种 Boolean 和可空字段data class PageUiState( val isLoading: Boolean false, val isError: Boolean false, val isSuccess: Boolean false, val isEmpty: Boolean false, val data: ListItem emptyList(), val errorMessage: String? null )这种写法最大的问题是类型上允许isLoadingtrue且isSuccesstrue且errorMessage空字符串这种没有意义的组合。你不得不在每个使用方做防御性判断不然 UI 就可能渲染出一边转圈一边显示列表的鬼样子。而密封类天然不允许这种非法状态出现因为Loading和Success是两个不可能同时成立的类型。所以我的建议是**互斥的状态交给类型共享的数据再考虑放进公共字段。**先用密封类把当前是哪个状态锁死再在具体子类里保存该状态专属的数据。这不是语法炫技而是从根上消灭了一整类if 条件判断错乱的 bug。7. 版本演进与三个实战心得7.1 从旧版本到现在的迁移记忆我最初接触 Kotlin 密封类时子类必须和密封类写在同一个文件里一个稍大的状态集合就能把一个文件撑得很长封装性也差。后来演进成同一个包即可文件组织终于自由了一些。再后来加入了密封接口和data object建模方式越来越灵活。这些演进中对日常代码影响最大的是data object的引入。早期处理无状态单例时只能用普通object打日志时打印出来的是LoadState$Loadinghash很难看data object提供了统一的toString打印状态时一眼就能分辨是哪个分支。如果团队项目里的 Kotlin 版本已经支持data object建议优先用它替代无参object。7.2 三个实战心得第一不要在密封类的when里随手写else。else会把新增分支的编译期提醒直接抹掉。真遇到确实其他分支都无需处理的场景我宁可显式列出一个空分支体也不写else。这样将来新增子类型时编译器会指出所有需要决策的地方而不是让我默默漏掉。第二给状态类选好不可变风格。我通常把状态子类写成data class字段一律用val。一旦使用var智能转换效果会打折而且状态对象在多个协程之间传递时可变性会带来各种奇怪的时序问题。状态是不可变的更新状态就是创建新对象这个心智模型简单得多。第三对第三方 SDK 传过来的复杂结果不要在入口处就拆散成一堆字段。我会先把来源数据统一映射成本地密封类型再向下分发。这样下层逻辑只依赖我们自己的类型永远不会因为上游新增了某种返回结果而悄悄出错。举个例子曾经接一个老接口响应里藏着十几种业务状态码。团队一开始是把状态码映射成 enum逻辑里到处when (code)后来新增了一种限流码漏改了两处判断用户直接被错误引导。改成密封类以后新增码需要在映射函数和所有消费方同时补分支问题立刻暴露。这个入口映射成密封类的习惯成了我后面所有客户端项目的标配。7.3 一个我自己反复用的小技巧最后分享一个不起眼但很受用的习惯给密封类的when分支写注释时不写这个分支做了什么而是写这个分支为什么没有处理另外某种子类型。因为分支本身的代码已经说明了做什么真正容易丢失的是决策上下文。比如is LoadState.Empty - { // 空态让用户自己选择是否清空筛选条件所以这里不做自动恢复 showEmptyHint() }这类注释在三个月后回头看价值比显示空态提示大得多。密封类保证了分支不会漏但保证不了分支逻辑是否被理解好的注释恰好能补上这一环。把这套密封类 穷举 when 状态即数据的组合用熟你会发现很多曾经需要用运行时判断和防御性代码解决的事在编译阶段就已经结束了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑