Kotlin 15:泛型边界如何限定任务状态的读写
已经有一批 TaskState.Failed,想让只关心 TaskState 的代码读取它们;另有一个接收所有 TaskState 的写入口,想把它交给只会提交失败状态的调用方。前者与后者的赋值方向正好相反。若把可读、可写混在同一个泛型接口里,调用方既难获得类型上的自由,也可能把不兼容的状态放进列表。本篇在 14 的任务状态格式化旁边建立两个小端口,并用独立编译失败样本检查类型允许的动作;外部来源的嵌套列表仍需逐值校验。
本章继续使用 Kotlin/JVM 2.2.21、JDK 21、Gradle 8.14。术语与约束取自 官方泛型文档的型变与投影,具体编译诊断和运行结果取自冻结版本的 15 实验。没有 Java 适配、持久化或受管任务工作者。
只读状态源能向哪个方向赋值
把“读取一个状态”与“接收一个待写状态”拆开,接口只暴露必要的一种操作:
1 | |
out 参数只出现在源接口的返回位置;in 参数只出现在接收器的参数位置。实验中创建 TaskStateSource<TaskState.Failed>,再赋给 TaskStateSource<TaskState>,调用 current() 取回的是原始 Failed("disk") 同一对象。这种赋值成立,因为调用方只要求读到某一种 TaskState,源保证给出更具体的失败状态。反过来若接口允许传入一般状态要求此源写入,将无法维持“源里只有失败状态”的约定;因此源接口不提供写方法。
写入口反向:RecordedTaskStateSink 接受任意 TaskState,所以可赋给 TaskStateSink<TaskState.Failed>,随后 accept(failure) 记录一个失败状态。允许接受所有状态的接收器自然也接受子类型;只接受 Failed 的接收器不能承担“接受所有 TaskState”的要求。测试既断言方向赋值可编译,也检查最终记录恰好包含该失败对象。这里的 RecordedTaskStateSink 只是进程内列表,并非持久化队列或线程安全命令总线。
官方文档的 declaration-site variance把这种方向约束写在接口声明上。在不知道具体实现的地方,编译器据此限制哪些操作可以暴露,并不会复制对象或改变任务生命周期。RecordedTaskStateSink.snapshot() 调用了 toList() 做列表结构快照;与 06 的集合别名实验一样,返回 List 接口绝非一般意义上的“内部对象深度不可变”。
有投影的可变列表,能不能随手添加
独立负例将 states 声明为 MutableList<out TaskState> 后尝试 states.add(TaskState.Running)。本机编译器退出 1,并输出:
1 | |
若实际列表是 MutableList<TaskState.Failed>,随意塞一个 Running 会破坏其元素约束;out 投影让调用方可以安全读取为 TaskState,但并不知道安全写入哪种具体元素。这是禁止一次写入的编译约束,不是冻结列表:持有原始 MutableList<TaskState.Failed> 的另一处代码仍可添加元素;只读视图也可能看到变化。样本置于 verification/15-invalid-generic-checks.kt,未进入正常编译源码集;正文里的诊断来自该文件的隔离编译,不是预测的编译器输出。
List<*> 检查了外壳,谁检查嵌套的值
外部适配层暂时只能取得 List<*>。如果某行本身是一段列表,is List<*> 可以判断它的运行时容器形状,却无法证实每个元素都实现 TaskState。本章用一个小函数将失败尽早定位到哪一层:
1 | |
测试传入 [[Queued, Failed("disk")]] 得到两层任务状态列表;把整行换成字符串、把内层值换成字符串或 null,分别抛 IllegalArgumentException。require 的路径在运行时对每个实际值检查 is TaskState,因而不把“外层恰好是 List”误当成“内层已经验证”。它也没有接受 JSON 字符串或未知版本字段的能力,序列化契约要到 33 才建设。
另一个隔离负例写成 if (unknown is List<String>),编译器退出 1,报告 Cannot check for instance of erased type 'List<String>'.。官方类型擦除说明指出运行时无法按这种完整泛型实参执行检查;单凭 is List<*> 并不能恢复 String 或 TaskState 的元素类型。对于这里的嵌套状态列表,正确替代是逐行、逐值检查,而不是无条件强转 List<List<TaskState>>。后续遇到 reified 也不能由此推断嵌套元素会自动验证。
原始证据、复跑与练习
bash examples/kotlin-language/verification/15.sh 执行全量离线 clean test、累计十六组各 2/2 的 XML 核对、现有 CLI 正反回归,以及单独的负例 compileKotlin。两条预期诊断原文在 writing-plans/kotlin/verification/15/rejected.txt;首次隔离环境漏复制依赖导致额外失败,保留 rejected-initial.txt 和修正后的完整输出,不能把首次失败也说成目标诊断通过。素材 下载完整工程、Wrapper与证据(SHA-256校验)提供实际解包离线复跑命令。
练习:TaskStateSink<TaskState.Failed> 可以赋给 TaskStateSink<TaskState> 吗?不能,因为后一调用方可能传入 Queued,而只接受失败状态的接收器不保证处理它。若某 List<*> 的第一项是 listOf(TaskState.Queued, "bad"),只检查 row is List<*> 后能安全当成 List<TaskState> 吗?不能;第二个元素的实际类型仍是字符串,须逐个确认,verifyStateRows 会拒绝。
[PATTERN] 先列出接口“生产值”还是“消费值”的方向,再确定赋值方向;外部数据进入时,另列容器形状和每层元素的运行时检查。型变处理静态读写权限,校验处理未知数据的实际内容,两项不能互相代替。
| 边界 | 此篇已验证 | 未证明 |
|---|---|---|
TaskStateSource<out State> |
子类型源赋给父类型源 | 对象被深复制或状态永久不变 |
TaskStateSink<in State> |
父类型接收器用于子类型请求 | 命令已入队或已持久化 |
List<*> / is List<String> |
逐值校验成功;擦除检查编译拒绝 | 运行时自动验证嵌套泛型 |
下一篇会引入 inline、reified 和任务 ID 值类,并用实际字节码与装箱实验界定“泛型信息留到运行时”的范围。






