任务已经失败,变量 failed 打印 failed:disk;把同一个对象交给只接受 TaskState 的格式化函数,输出却是 terminal。失败信息没有消失,变的是调用处可见的接收者类型。本篇接续 13 的失败类型,在不启动后台任务的前提下核对扩展函数的分派,再用 let、run、apply、also 检查一段任务格式化代码究竟返回字符串还是原对象。

基线仍是 Kotlin 2.2.21 / JDK 21 / Gradle Wrapper 8.14;一手资料见 官方扩展文档 与 作用域函数文档,实际测试和命令见本章证据目录。文档是语义依据,具体运行结果由冻结组合验证。

同一个实例,为何选择另一段实现

给领域状态提供一个粗略标签,给失败状态提供一个更详细的标签:

1
2
3
4
5
6
7
8
9
10
fun TaskState.statusLabel(): String = when (this) {
TaskState.Queued -> "queued"
TaskState.Running -> "running"
is TaskState.Succeeded, is TaskState.Failed, TaskState.Cancelled -> "terminal"
}

fun TaskState.Failed.statusLabel(): String = "failed:$reason"

fun formatTaskState(taskId: String, state: TaskState): String =
state.statusLabel().let { label -> "$taskId:$label" }

对 TaskState.Failed("disk") 直接调用扩展,测试得到 failed:disk;写成 val general: TaskState = failed 再调用,则得到 terminal。formatTaskState("task-1", failed) 的参数静态类型也是 TaskState,得到 task-1:terminal。这三条断言使用同一失败对象,变化的是编译期可见类型,不是运行中任务突然更改状态。

扩展没有给类的虚方法表添加覆盖点;官方文档的静态解析规则用父/子类的同名扩展说明:选择目标取决于表达式声明类型。若需要所有 TaskState 都显示携带的失败理由,不应指望重载同名扩展自动分派;在 TaskState.statusLabel() 的 when 中针对 is TaskState.Failed 显式处理,或者调用真正的多态成员。本例故意让通用函数输出粗标签,以检验这一界线。

名字撞上真正的成员时谁先执行

在测试里给 Holder 定义成员 label() 返回 member:x,同一作用域再定义 fun Holder.label() 返回 extension:x。Holder("x").label() 的断言结果是 member:x;编译器选择同签名的真实成员。扩展与成员同名规则也限定为“同名且适用时成员优先”;不同参数列表仍可能选择扩展,不可概括为“只要类里有此名字就永远不会调用扩展”。测试中的局部扩展仅作为负例,没有注入公共任务 API。

作用域函数改变了什么,又没改变什么

formatTaskState 中 let 接受 statusLabel() 产生的字符串作为 it 对象,lambda 的 "$taskId:$label" 是整个 let 的返回值。因此该函数不会返回 TaskState,也不会将失败对象复制或存储。若想确认作用域函数是“计算值”还是“仍持有原对象”,在同一个 Failed("disk") 上做四个对照:

1
2
3
4
5
6
val failed = TaskState.Failed("disk")
val sideEffects = mutableListOf<String>()
val letResult = failed.let { state -> "let:${state.reason}" }
val runResult = failed.run { "run:$reason" }
val applyResult = failed.apply { sideEffects.add("apply:$reason") }
val alsoResult = failed.also { state -> sideEffects.add("also:${state.reason}") }

本机两项字符串断言分别得到 let:disk、run:disk;后两项用 assertSame(failed, ...) 检查对象身份,副作用记录依次是 apply:disk、also:disk。let / also 把上下文对象作为 lambda 参数,这里命名为 state;run / apply 将它当接收者,可直接访问 reason。前两者返回 lambda 计算结果,后两者返回上下文对象。官方选择表提供了接收者与返回值的对照;本篇用断言验证了这四次具体调用,没有证明把它们串成多层 this 嵌套一定更好读。

若将 apply { ... } 的返回值误认为记录条数,下一步便会拿 TaskState.Failed 去做数值操作;若将 also 中 it 当成新的状态,则会误写任务逻辑。这里的 sideEffects 只是列表测试替身,既不是审计日志,也不代表任何外部写入完成。用普通局部变量和 when 表达更清楚时无需作用域函数。

复跑与练习答案

在仓库运行 bash examples/kotlin-language/verification/14.sh,脚本用离线 clean test 复测 00–14 的十五组测试并记录原始 XML、CLI 正反输入与退出码;格式化函数还没接入旧 CLI,命令行输出只是回归。正文素材 可解包的完整累计工程与证据(SHA-256)包含 Wrapper 和运行说明;源码与测试分别在 TaskFormatting.kt、TaskFormattingTest.kt。

练习:把 val general: TaskState = failed 改为智能转换分支 if (general is TaskState.Failed) general.statusLabel(),该次扩展调用可能变成哪一个?分支内静态类型已被缩窄,应选择失败专用扩展,输出 failed:disk;这仍是静态解析,不是运行时虚分派。若需要外部调用者稳定看到失败原因,应在公共 TaskState 格式化入口显式区分子类型,而不是依赖调用方记住写一段 is 判断。

[PATTERN] 判断一段扩展/作用域代码时,先记下接收者在调用处的静态类型,再记下lambda 的接收方式与表达式返回类型。这两条信息比“代码看起来像对象方法”更能解释实际结果。

调用 本次选择或返回 不应推断
failed.statusLabel() / general.statusLabel() failed:disk / terminal 扩展参与虚分派
failed.let / failed.run lambda 的字符串结果 自动持久化任务
failed.apply / failed.also 原 failed 对象 副作用必定成功

下一篇再把只读与可写任务接口拆成泛型边界;本篇没有真实任务执行、并发或跨 Java ABI 结论。