Kotlin 02:分支推导与智能转换的稳定前提
一个属性刚被检查为非空,为什么紧接着取它的长度,编译器却说“不可能智能转换”?因为检查时和使用时读到的可能不是同一个值。输入解析也有相似的误会:一个 for 循环计数到了两条文本,并不意味着系统已经提交了两条任务。要说明代码结果,得分别交代分支给出的值、循环改变的局部变量和属性读取发生的时间点。
本篇在 命令行基线 与 可变性实验 上,把 submit、help、batch 收进同一个解析入口,随后用可控 getter 检查智能转换的稳定性。版本仍是 Kotlin 2.2.21、Gradle 8.14、JDK 21;batch 目前只校验输入并报告本地计数,既没有执行者,也没有保存状态。
分支负责给出值,循环只改局部计数
前一章的 submitTask(args) 保持原有约定:只接受 submit <task text> 两个参数。新函数 parseCommand(args) 选择命令分支:
1 | |
when 在这里是一个表达式:submit、help 和 batch 都产生 String,整个函数返回选中分支的结果。else 遇到未知命令直接抛异常,不会返回假装成功的文本。help 的花括号里有先检查、后求值的两步;最后一行的字符串才是该分支提供的值。Kotlin 控制流文档同时解释 if 表达式,when 部分说明了按分支求值的语义。
batch 的 for 在逐条检查输入时更新 var accepted。这项修改是当前进程内的局部副作用,不是一次业务提交;如果第三条文本为空,循环在 require 处中断,甚至不会产生最后一行成功提示。即使最后返回 2,本章也没有队列、标识、存储或执行器,不能把计数升级为“两个任务已经入队”。新测试分别检查 submit alpha、help、batch alpha beta 的表达式结果,以及 batch alpha ""、缺少文本、未知命令的失败条件。
模式:区分选值、局部计数和外部副作用。 when 给调用者一个值,for 改变局部变量,真正的任务提交则需要另一个带持久性与失败契约的接口。三者恰好能出现在同一个函数里,但“循环结束”不能替“外部操作完成”背书。这里也没有用一次耗时比较来推断哪种写法更快。
if 为什么不能替 getter 保证稳定
TaskTextSource 包装一个可控读取函数,属性每次访问都会调用一次 read():
1 | |
返回的 if 是表达式:非空分支产出长度,空分支产出 0,结果类型为 Int。非空检查与 length 访问的是同一个局部 val captured;函数执行期间没有代码改写这个引用。测试让 reader 第一次返回 "payload"、第二次返回 null,检查第一次调用长度 7、总读取次数 1,第二次调用长度 0、总读取次数 2。这不依赖两次 getter 的返回值碰巧相同。
反过来,如果写成:
1 | |
两个 source.text 是两次属性访问。即使第一次返回非空,第二次也可能是 null;编译器不把第一次的判断继承给第二次。verification/02-unstable-smartcast.kt 里放了两种隔离的失败样本:自定义 getter 和可变属性。Kotlin 2.2.21 对前者实际报告 Smart cast to 'String' is impossible, because 'text' is a property that has an open or custom getter.,对后者实际报告 ... 'text' is a mutable property that could be mutated concurrently.;单独编译退出码 1,默认工程不包含它们。Kotlin 智能转换文档与稳定性前提提供对应的语言规则。
这不等于“凡是 var 都不能智能转换”。局部 var 若在检查和使用之间没有改变且满足编译器的其他稳定性条件,也可能被转换;本次被拒绝的是可变属性或每次可重新求值的自定义 getter。将一次读取存入局部 val,固定的首先是这次使用的引用;它既不会使该字符串的外部生产者停止变化,也不会冻结任意可变对象的内容。这里的 String 长度测试只说明同一局部值被使用了两次,不证明对整个任务系统实现了并发安全。
模式:先确定读取次数,再主张类型事实。 要把空值检查用作后续计算的依据,检查和使用必须共享一个稳定的值。重复调用自定义 getter、调用会更新值的函数、跨过可能改写属性的边界时,先读取局部快照,或者明确处理第二次读取的可空结果,不能仅凭视觉上相邻的两行作保证。
让失败路径也留在实验里
从 examples/kotlin-language/ 执行 bash verification/02.sh,脚本记录 UTC 日期、JDK 与 Wrapper 环境,离线 clean test,保存三个测试 suite 的原始 XML,然后实际执行命令行:
1 | |
第一条输出 TASK inputs accepted (not persisted): 2,退出码 0;第二条缺少任务文本,打印 Usage: batch <task text>...,退出码 1。三个 suite 各 2/2,零失败:00 的合法/非法输入和 01 的别名/溢出边界继续保留,02 增加分支结果、无效 batch 与受控 getter 两次返回值的检查。另有隔离编译拒绝的两个诊断,脚本断言这两个拒绝理由都存在,不把“编译失败”误记成服务失败。
完整命令、退出码、JDK 环境、日志和断言在 writing-plans/kotlin/verification/02/。它们证明了当前 Kotlin/JVM 版本中的行为,并不能证明未来 Kotlin 版本会给出一字不差的同一条诊断。本章没有真实线程切换、网络请求或数据库操作;可控 reader 是测试替身,不把它的两次调用解读成真实 I/O。
练习与答案
练习一:batch alpha beta 打印数量 2,能否说“两个后台任务完成了”?答案:不能。程序只在 for 循环里增大局部 accepted,连任务对象和队列都没有;输出前要全部通过校验,输出后进程退出。要证明后台任务完成,还需要受管任务的最终状态与独立查询证据,不能从字符串倒推。
练习二:一个带自定义 getter 的可空属性第一次读取非空,紧接着第二次读取是什么类型?答案:静态类型仍是 String?;两次求值可能不同。使用 source.text.length 被这次固定版本的编译器拒绝;将一次读取保存为局部 val captured 并检查它,才是本例可验证的正例。如果用 source.text?.length,语法允许但那是重新读取后的条件访问,也不能保证两次观察到同一内容。
练习三:var 一定不能被智能转换吗?答案:不能一概而论。局部变量的稳定性由“检查和使用之间是否会改变”等条件决定;共享的可变属性即使这一次运行没有竞争,也不满足编译器要给出的普遍保证。两个编译拒绝样本分别覆盖自定义 getter 与可变属性,不应该被扩大成“所有 var 都非法”的口号。
下一篇会整理创建任务的函数参数和返回边界;Unit、Nothing、默认/命名参数、vararg 与可空返回各有不同契约,目前不把 throw 当一条成功命令的值。
参考资料
- Kotlin:Control flow(2026-10-08 查阅
if、when、循环) - Kotlin:Type checks and casts — Smart casts(2026-10-08 查阅智能转换的条件)
- Kotlin:Smart cast prerequisites(2026-10-08 查阅局部变量与属性、getter 的稳定性边界)




