Kotlin 01:val 与对象可变性不是一回事
任务标签写在 val labels 里,就不会被改了吗?如果另一个函数仍持有同一个列表,答案相反。另一方面,把重试次数从 Int 赋给 Long,也不意味着原来那次加法没有溢出。两个问题都来自同一种混淆:把“变量所在的边界”当成了“它指向或计算的值的性质”。
本篇在 00 的命令行任务 上增加一个尚未接入 CLI 的任务配置实验:观察 val 引用、var 重新绑定和 Int 运算。当前 submit 仍只接收一条合成输入,不会因为有了重试参数就自动执行任务;配置接口也还没有并发访问保证。
val 锁住的是哪一层
01 新增的 TaskSettings 很小,恰好把容易说错的边界暴露出来:
1 | |
labels 这个属性用 val 声明:在对象构造后不能通过属性赋值把它重新指向别的列表。但它的类型明确是 MutableList<String>,对这个列表调用 add 是另一件事。retryLimit 用 var 声明,允许重新赋值;这并没有自动保证每次赋入的新值满足“不能为负”的业务条件,所以 maxAttempts() 在真正使用它时仍做校验。
测试里的 val firstReference = settings.labels 与 settings.labels 指向同一个列表;执行 settings.labels.add("manual") 后,两条路径都观察到 "manual"。随后令 var selected = settings,再把 selected 指向另一个 TaskSettings("another"):原来的 firstReference 仍看到旧对象的两条标签。var 改的是 selected 的绑定,不会神奇地把已有别名全部转向新对象。
单独隔离的编译拒绝样本是:
1 | |
对这个样本运行 Kotlin 2.2.21 的 compileKotlin,诊断是 'val' cannot be reassigned.,退出码 1。把它放进默认源码目录会让所有正常实验都编不过,因此源码保存在 verification/01-val-reassignment.kt,验证脚本临时创建独立工程后才尝试编译;失败是预期断言,不是忽略掉的测试错误。Kotlin 基础语法文档把 val 称为只读变量、把 var 称为可重新赋值的变量;属性文档说明了属性访问器。两份文档都不能被误读成“val 会冻结对象深处的所有字段”。
模式:先画别名,再讨论可变性。 对每个变量问三件事:绑定是否可以改,引用指向的对象是否有修改操作,其他地方是否仍持有同一对象。只回答第一个问题就断言“线程安全”或“不可变”,既看不到共享状态,也解释不了运行结果。
这里使用的是可变列表本身。06 还要验证另一个更隐蔽的情形:即使暴露的是 List 只读接口,外部保留的可变别名仍可能修改底层对象。Kotlin 集合概览区分只读与可变接口;这不是“对象永远不会改变”的同义词。本篇不把初始教学用的 MutableList 属性包装成合格的生产 API,更不拿它证明并发安全。
先扩大运算类型,再做加法
假定重试上限是“初次尝试之外还允许几次”,那么最多尝试次数就是 retryLimit + 1。retryLimit 用 Int 存放;Int.MAX_VALUE 是合法的 Int 值,给它再加 1 却超出 Int 范围。
1 | |
maxAttempts() 先调用 toLong(),然后在 Long 中加 1:测试断言得到 2_147_483_648L。直接在 Int 上加 1,本次 Kotlin/JVM 构建中得到 Int.MIN_VALUE;把已经溢出的 Int 结果事后转成 Long,只会得到一个精确表示的错误值,不会倒转前面的算术步骤。Kotlin 数字文档说明数字类型之间的显式转换,溢出章节讨论定宽整数算术的边界。
如果业务规则要求发现溢出就报错,而不是使用更宽的计数,可以在 JVM 边界选择 Math.addExact(settings.retryLimit, 1):同一输入会抛出 ArithmeticException,JDK 21 Math Javadoc明确规定了这种溢出行为。它与 toLong() 并非相同策略:前者拒绝超出 Int 的结果,后者允许结果进入更宽的 Long;真正的任务上限还要由业务容量约束,而不能因为类型容得下 2_147_483_648 次,就宣称系统真能运行这么多次。
retryLimit = -1 是另一类错误:没有整数溢出,但语义上不允许负的重试次数。require(retryLimit >= 0) 抛出 IllegalArgumentException,与 Math.addExact 的算术异常不同。这两条检查分别回答“业务上可不可以”与“按所选类型算不算得下”,不能相互替代。
模式:把检查放在运算发生前。 确定值域、选择中间表达式的类型,再计算;溢出检测和领域校验分别写清失败条件。修改结果变量的声明类型,不能修复先发生的窄类型运算。
证据如何覆盖反例
在累计工程里运行 bash verification/01.sh。脚本记录 JDK、Gradle 版本与系统信息,先 ./gradlew --offline --no-daemon --console=plain clean test,再提取测试 XML,并把非法赋值样本复制到临时工程编译。TaskSettingsTest 的两条测试各覆盖别名可见性与数值/领域边界;00 的 MainTest 两条回归仍通过:两个 suite 均为 tests="2" skipped="0" failures="0" errors="0"。隔离编译返回 1 并打印 'val' cannot be reassigned.,只以这个明确诊断判定“预期编译失败”。
原始命令、退出码、XML 和完整错误见仓库的 writing-plans/kotlin/verification/01/。实验基线仍是 Gradle 8.14、Kotlin 插件/编译器/stdlib 2.2.21、JDK 21、language/API 2.2 与 jvmTarget=21;没有因新章节悄悄升级工具链。当前的 TaskSettings 没有接入 CLI,也没有实现重试执行、失败隔离或取消;这些还需要后面章节自己的验收。
练习与答案
练习一:val tags = mutableListOf("a"),随后 tags.add("b") 和 tags = mutableListOf("c"),哪一行编译不通过?答案:前者修改列表内容,后者试图给 val 重新赋值,Kotlin 2.2.21 会拒绝后者。若另一变量握有原列表,前者的结果还会被它观察到;本章隔离编译样本与别名测试分别验证这两个边界。
练习二:var current = settings,随后 current = TaskSettings("new"),此前保存的 val originalTags = settings.labels 会自动看到新设置的标签吗?答案:不会。被改的是 current 指向哪个配置;originalTags 仍指向原对象里的列表。只有对原列表的实际修改才会改变这条别名的观察。
练习三:对于 Int.MAX_VALUE,为什么 val count: Long = (settings.retryLimit + 1).toLong() 不能替代 settings.retryLimit.toLong() + 1?答案:括号内先完成 Int 加法并溢出,后续转换只能扩大溢出结果;先转换再计算才让 + 1 在 Long 范围运行。如果规则不允许超出 Int,用会报告溢出的操作而不是静默扩大。
00 的任务输入与本章的配置仍是进程内、非持久的。下一篇才把命令分支和智能转换放到同一个输入解析器里;可变属性及自定义访问器导致智能转换不能成立的反例,会在那里实际编译验证。
参考资料
- Kotlin:Basic syntax — Variables(2026-10-07 核对
val/var) - Kotlin:Properties(2026-10-07 核对属性读写边界)
- Kotlin:Collections overview(2026-10-07 核对只读/可变接口区别)
- Kotlin:Numbers(2026-10-07 核对显式数值转换与溢出)
- Java SE 21:Math.addExact(2026-10-07 核对检测溢出的异常契约)





