Kotlin 06:只读 List 为何仍会变化
查询任务标签时,把 MutableList<String> 的返回类型改成 List<String>,调用方就不能写 labels.add("running") 了。可这份查询结果会不会在下一次任务更新后自己“变长”?会,只要返回的仍是原列表。接口限制的是这个引用提供哪些操作,不是把它指向的对象冻结。这个区别会影响查询结果、缓存和外部 API 的契约。
在 05 的可选备注输入之外,本篇给同一个合成任务增加查询标签的最小实验。还没有任务存储层或实际并发请求;列表、集合和映射的修改都发生在可控测试里。使用 Kotlin/JVM 2.2.21、JDK 21、Gradle Wrapper 8.14,完整命令与输出在本篇 06 证据目录。
List 没有修改方法,不等于背后的列表不会变化
两段查询函数只差一个复制动作:
1 | |
Kotlin 集合类型文档区分 List/MutableList、Set/MutableSet、Map/MutableMap:前者只给这一处引用提供读操作,后者还提供修改操作。它没有把“程序里所有引用都不能修改同一对象”写进 List 的类型。因此 taskLabelsView 虽然返回 List<String>,原来的 backing 仍可追加元素。
测试从 mutableListOf("queued", "urgent") 开始,拿到 view 和 snapshot 后才执行 backing.add("running")。断言 view 与 backing 是同一对象;随后 view 读到三项,snapshot 仍读到前两项。后一个观察是本次多元素样本下 stdlib toList 的复制结果;不能反过来说所有 List 值都深度不可变,也不应把特定实现的对象身份当语言保证。
名称里的 view 和 snapshot 表示本实验的两种接口语义,不是两个 Kotlin 语言关键字。生产接口如果只想限制调用者从这个返回值发起修改,List 已经有用;如果还要求调用者看到的集合元素序列不随原集合的增删变化,必须控制别名或者复制,并在契约里说明复制发生在哪一刻。
Set 与 Map 也有同一种别名问题
Set 不允许通过只读引用 add,但从原来的 MutableSet 插入 "task-2" 后,持有同一对象的 Set 引用能读到新元素;事先调用 toSet() 得到的副本只看到原来的 "task-1"。Map 更直观:把 "task-1" 的值从 "queued" 改成 "running",同一对象的 Map 视图显示新值,先前 toMap() 的结果仍显示 "queued"。这些都由 TaskQueryTest.readOnlyInterfacesStillObserveMutableAliases 逐项断言。stdlib toSet 和 toMap 是本章实际使用的复制入口。
不能只凭 List、Set、Map 的接口名把它们当成快照;反过来,看到 MutableList 也不意味着所有使用者会随时修改。判断查询稳定性,至少问两句:返回的对象是否和保存的对象同一个?其元素还指向哪些可变对象?
复制容器仍可能共享可变元素
即使元素序列暂时固定,元素自身也可能变化。第二个测试把 StringBuilder("queued") 放进一个含两项的可变列表,然后用 original.toList() 复制。移除原列表的第二项之后,副本仍有两项;接着通过原来持有的 note 执行 append("!"),副本第一项读到 "queued!"。断言 snapshot[0] 与 note 是同一个 StringBuilder 对象。
1 | |
也就是说,本章的 toList() 在测试中隔离了列表结构的增删,却没有复制 StringBuilder。该反例不是 10 的 data class.copy() 实验:那是另一个 API、另一个待验证的浅复制场景。若元素改成不可变值,或者额外构造新的元素,结果又取决于那些对象的契约,不能只用“快照”二字略过。
只读接口、当前样本的结构复制和并发安全是三道不同的门。本章没有并发更新,也没有证明遍历过程中能得到一致快照或可见性保证;即便复制了列表,复制与其他线程同时修改时的同步仍需单独设计。29 再研究任务索引的复合状态转移。
可复现性与当前边界
仓库内执行 cd examples/kotlin-language && bash verification/06.sh。脚本保存系统时间、JDK/Gradle 环境和原始命令输出,离线 clean test 退出 0,00–06 七组 suite 各 2/2;TaskQueryTest 分别覆盖只读别名/结构快照以及元素共享。原有 CLI submit alpha 退出 0、缺文本退出 1,仅说明旧命令没回归;查询函数没有接入 CLI,不存在可对外读取的任务存储。
练习与答案
练习一:val labels: List<String> = mutableLabels 之后,为什么 mutableLabels.add("running") 仍会影响 labels?答:val 固定的是变量的引用,List 限制的是从 labels 可调用的操作;原对象仍由另一个可变别名持有。要隔离原容器的结构变化,先明确复制点并验证副本行为。
练习二:执行 original.toList() 后,把原列表中的 StringBuilder 内容改掉,副本第一项能否改变?答:能。列表元素仍引用同一个对象;本篇测试在先移除另一个元素后修改第一个,副本元素数量不变而内容变为 "queued!"。
练习三:把返回值换成 toList() 就能跨线程安全读写了吗?答:不能。本章只测顺序执行时的别名与复制;并发复制、可变元素访问、复合状态更新各有独立的同步问题,不能从只读接口或顺序测试推出线程安全。
下一篇把任务标签变换写成 lambda 与函数类型,并观察捕获的变量何时读取、什么时候执行。





