Kotlin 10:copy 为何没复制任务标签
把任务记录 original.copy() 交给另一个调用方,按直觉似乎给了它一份独立记录。可如果标签是可变列表,对方删掉一项,原记录的标签也会消失。copy 新建了外层 data class 实例,却没有递归复制它指向的对象。这既是查询快照的边界,也会让“对象还能当哈希键吗”变成实际故障。
本篇在 09 的 Task 初始化实验之外新增 TaskRecord,专门观察生成成员。Task 与 TaskRecord 目前都没有接入旧 submit CLI;记录是值对象原型,不是已持久化任务,更没有建模 12 才会定义的状态转移。实验版本仍为 Kotlin/JVM 2.2.21、JDK 21、Gradle Wrapper 8.14。
哪些属性参加比较与复制
本章的类型只有两个主构造属性和一个类体属性:
1 | |
Kotlin data class 文档按主构造中声明的属性生成 equals()、hashCode()、toString()、componentN() 和 copy();类体属性小节强调后者不参加生成函数。实测先把 original.reviewed 设为 true,再调用 duplicate = original.copy():original == duplicate 为真,original === duplicate 为假,duplicate.reviewed 仍为 false。== 问的是本例由生成方法给出的相等关系;=== 问的是两个引用是否指向同一外层对象。类体 reviewed 不参加复制与生成相等性判断。
解构 val (id, labels) = duplicate 得到主构造里的 "task-1" 和标签引用,没有顺带取出 reviewed。如果将来把新字段放在类体而不是主构造中,不能只看到 data class 四个字就假定它会参与业务身份或复制;字段位置本身改变了生成成员的边界。
copy() 新建外层,但嵌套列表仍是同一个
官方 copy 小节明确称这是浅复制。本章测试用 assertSame(original.labels, duplicate.labels) 检验身份:两个外层对象的 labels 指向同一个 MutableList。从原记录追加 "urgent",副本也看到它;从副本移除 "queued",原记录的标签只剩 "urgent"。因此这两次并非“同步成功”,根本没有两份独立列表。和 06 的 `List.toList()` 列表结构快照一样,必须具体检查复制边界,不能把 API 名当深复制保证。
如果接口希望调用方能独立修改标签,应在明确的边界构建一份新列表或采用不可变元素与不可变模型;即使复制了列表,其中还有可变元素时也要继续检查元素别名。本篇故意保留 MutableList 反例,不把它推荐为长期业务状态类型,更不宣称 val labels 意味着标签不可变。
hashCode() 纳入可变字段会使查找失效
data class 生成的 hashCode() 考虑主构造中的 labels。测试先将一个 TaskRecord("task-2", mutableListOf("queued")) 放入 HashSet,此时 contains(record) 为真;再往同一个列表中加入 "running"。冻结环境中前后 hashCode() 不同,遍历集合仍能找到同一个对象,但 contains(record) 返回假。这不是对象凭空被移走,而是哈希结构插入时的分桶与现在的哈希不再匹配。JDK 21 的 HashSet 文档说明它由 HashMap 支持;本段的具体 contains 结果是本次输入和运行时观察,不承诺对任意字符串修改都恰好落进不同的桶。
该反例和浅复制相连:即使没有直接操作这个 key,从 copy() 暴露的同一个 labels 引用也能改动其 hashCode() 所依赖的内容。若需要任务索引,业务身份应有稳定、经过验证的键,不能把可变列表“藏在 data class 里”就交给哈希集合。
数组的相等性还要另看。两个分别创建的 arrayOf("queued"),即使元素内容相同,测试里 first == second 仍为假;使用 stdlib contentEquals 才按元素比较并得到真。不能把 List 的结构比较印象直接套到 Array,更不能只从“元素看起来一样”猜一个包含数组的 data class 的生成相等性。这里的数组测试是独立反例,并未把数组加入 TaskRecord。
本章怎样验收
在仓库根目录执行 cd examples/kotlin-language && bash verification/10.sh。脚本记录 UTC 环境、JDK 与 Gradle,离线 clean test 退出 0,00–10 十一组测试各 2/2。新增 TaskRecordTest 分别覆盖 ==/===、解构、浅复制、类体属性以及 hash set 与数组内容比较。旧 CLI 的 submit alpha 退出 0、缺文本的 submit 退出 1,只用于回归,不能证明新值对象由 CLI 创建。原始命令、测试 XML 与断言在 writing-plans/kotlin/verification/10/。
练习与答案
练习一:duplicate = original.copy() 后,为什么 original === duplicate 为假,original.labels === duplicate.labels 却为真?答:copy 创建了新的外层 TaskRecord,但只复制主构造参数持有的引用,没有递归复制里面的列表。
练习二:把 reviewed 设为 true 后复制,副本的 reviewed 为什么是 false?答:它声明在类体中,不是参与 copy() 和生成相等方法的主构造属性;副本使用属性自身的初始值。这不能代表所有字段都已被复制。
练习三:哈希集合迭代还看得到 record,却可能 contains(record) 为假,应该先怀疑什么?答:检查参与 hashCode() 的字段在插入后是否变了,包括从另一个别名或浅复制对象进行的修改。当前输入实测修改列表后哈希改变;这不是 HashSet 自动重排键的位置。
下一篇才讨论任务存取接口、对象/伴生对象与委托。09 的类和 10 的数据类提供对照,但都没有理由提前写一个扫描容器或抽象工厂。






