函数式编程02:不可变数据与别名
保存了报价输入,却没有保存历史
订单导入器交出一份商品行列表,报价器保存它,准备稍后汇总。导入器随后把第一行数量从二改成九,又增加一行。报价器拿到的变量即使声明为 final,仍可能看到九和新增行。引用没有重新赋值,引用指向的对象却变了,先前所谓的“报价快照”只是同一个对象的另一个入口。
本章要建立的契约是:订单值产生之后,调用方继续修改原始列表或原始元素,不会改变已经产生的订单值。这个目标比“调用方不能在返回列表上执行 add”更强,因为修改可以从其他引用进入。实验同时观察列表长度、元素字段以及返回访问器,分别检查每条修改路径。
相关旧文是 Scala 10 的“不可变容器仍然可能共享可变元素”。该段用两个 Vector 共享同一 Item,修改后两者都看到九。这里不重复 Scala 集合选型,改用 Java 21 的只读包装、List.copyOf 和领域快照,解释别名应在何处截断。
三个层次不能互相代替
final 约束变量绑定不能再次赋值。只读视图限制通过这个接口执行的写操作。深层不可变则要求从公开结果能够到达的业务状态不会继续变化。这三个层次涉及的对象不同,不能从第一个直接推到第三个。
教学实验的可变行只有一个 int quantity。列表被 final 绑定后仍可以 add,行也仍可以修改数量。若换成 record RawOrder(List<MutableLine> lines),记录组件保存的仍是列表引用;自动生成访问器会把它返回,既没有复制容器,也没有冻结元素。record 能减少数据载体的样板代码,但字段类型和构造协议才决定值是否稳定。
1 | |
view 的大小随底层变成二。shallow 的大小保持一,因为它取得了当时的元素序列;但它的首元素与来源仍是同一个可变行,所以数量也变成九。两个观察结果一起解释了浅快照的边界:容器结构稳定,元素状态仍然共享。
JDK 21 List 文档“Unmodifiable Lists”与 copyOf说明列表不可修改不代表元素不可变。文档还提醒,不应对工厂返回对象的身份作假设。Collections.unmodifiableList则明确是读取底层列表的视图。本章因此比较内容和修改行为,不把“copy”解释为必定分配新对象,也不要求每次调用返回不同地址。
沿引用画出修改路径
1 | |
原始列表是一个修改入口,可变行是另一个。只读包装封住经包装器写列表的路径,来源持有者仍能改底层列表。浅复制切断列表结构的共享,却没有切断元素引用。只有把可变行转换成独立稳定的领域行,才能让历史数量不再随来源变化。
这种画法比检查某个类有没有 setter 更可靠。一个类可以没有 setter,却把内部数组原样返回;调用者取得数组之后仍能改内容。另一个类可以在构造期间使用可变缓冲,但发布时只返回稳定值,外部没有修改通道。可变性判断应覆盖输入、字段和返回值之间的整个引用图。
实验只画数量,不意味着真实订单只需处理一个字段。若行中加入标签列表、地址对象或可变日期,每一层都需要重新检查。如果某个字段是文件句柄,通常不能靠“深复制”获得等价且独立的新资源;应把资源能力留在边界,把业务所需内容转换成普通值。
用领域快照控制复制深度
1 | |
snapshot 对每条原始行读取一次数量,构造只含基本类型字段的 Line。Order 构造器再把容器入口规范为不可修改列表。调用方即使直接调用构造器并传入可变 ArrayList<Line>,也不能通过之后清空该列表来改掉订单。这是把防御性复制放在领域对象的入口,而不只依赖某一个工厂调用者守规矩。
读取本身有时间边界。若另一个线程正在同时修改多个字段,逐字段复制并不能自动获得同一时刻的一致快照。本章在单线程中先复制再修改,证明别名隔离;并发版本需要锁、版本号或其他一致性协议。不能拿单线程断言通过当成所有并发发布问题都已解决。
访问器返回的列表也必须符合约定。当前返回 List<Line> 中的 Line 没有可变字段,列表拒绝 add;因此调用者没有通过正常公开接口修改数量或结构的途径。若为了兼容接口改成返回数组,则应考虑出口复制,否则入口复制仍可能被出口泄漏抵消。
空值策略不是不可变性自动决定的。List.copyOf 拒绝 null 元素,本实验断言这条路径抛 NullPointerException。它没有给错误加行号,也没有把负数量变成结构化校验错误。不可变值可以保存错误的数字,因此有效性检查和稳定性检查应分开设计。这里的 Line 只是别名实验模型,后续领域模型必须补业务约束。
更新返回新版本
已有订单包含数量二。要得到数量七的新订单,可以先把旧列表内容复制到局部 ArrayList,替换首元素,再构造新 Order。旧订单继续引用 Line(2),新订单引用 Line(7)。局部 builder 的修改只发生在尚未发布的对象上,构造器收束修改通道,外部得到两份可独立使用的值。
这里不需要为了“函数式”强迫每一步都创建不可变列表。关键是局部缓冲没有泄漏,完成构造后也不继续被共享修改。若新版本只有一个元素改变,完整复制可能比结构共享成本高;第 09 章会用链表显示如何仅复制受影响的前缀。本章先锁住历史保持不变的行为,避免过早把存储技巧混入契约。
两个订单版本的业务身份可以相同,例如都代表订单 A,只是数量不同;它们的值则不同。把订单号相等、整份值相等和对象引用相同混成一个判断,会影响缓存键、去重和审计历史。快照应该保存哪个版本,必须从业务读取需求确定。
如果应用只保留最新版本,旧值失去所有引用后才具备回收条件。如果保留完整修改历史,即使没有复制共享字段,所有历史根也会延长对象存活时间。不可变性方便解释历史,不能自动替代历史保留策略。
复制成本与边界选择
对 n 条原始行做快照,需要读取 n 个数量并创建 n 个领域行,时间与新对象数随 n 线性增长。容器还需要保存 n 个引用。这个推导来自当前循环和结构,不是纳秒基准,也不表示所有 JDK copyOf 都必定发生同样分配。工厂可以复用满足要求的输入,程序不应依赖其内部选择。
如果每次查询都复制整棵大对象图,稳定性可能以过高分配成本实现。更直接的设计是尽早把边界输入转换成稳定领域值,内部继续共享这些稳定值;只有版本更新时创建新的受影响部分。共享不可变元素不破坏历史,重复复制它们没有必然收益。
只读视图也有合理场景:调用者明确需要观察正在变化的集合,但不允许通过这条引用写入。此时文档应称其为只读视图,不能称历史快照。若 API 承诺提交时的报价输入可重放,视图就不满足需求。选择由观察协议决定,而不是由“不可修改”这个标签决定。
对外接口只写 List<Line> 无法在 Java 类型系统中完整表达这些保证。构造器、访问器、字段类型与测试共同形成契约。若未来给 Line 增加可变字段,原有浅复制就可能重新泄漏;回归测试应始终从调用方能持有的引用出发施加修改。
复制契约如何影响相等与查找
假设把可变订单行放进按值计算哈希的键对象,再插入 HashMap。若数量参与键的相等和哈希,随后改数量,就可能使这个对象的当前哈希与插入时使用的桶不一致。问题并不局限于“页面显示了新值”,还会破坏查找依赖的稳定性。给最外层变量加 final 无法阻止元素变化;把入口转换成稳定值,则能让键在驻留期间保持同一含义。这是不可变数据有助于缓存的原因之一,但不是缓存容量和过期策略的替代品。
复制也不必保留来源的所有信息。原始导入对象可能含解析过程的缓冲区、错误提示和行号,报价只需要商品标识、数量与单价。构造领域快照时,可以明确选择这些字段,并把导入诊断留在另一份结果中。这样得到的是满足报价需求的新表示,不是通用对象克隆;遗失了哪些信息应由接口说明。若后续还要回溯原始行号,就应显式保留,不能指望通过引用原对象偶然取得。
相反,盲目深复制会遇到共享关系问题。两条订单行可能原本引用同一份稳定商品描述,逐行复制会制造两个内容相同的对象;若业务只关心描述值,这通常没有必要。如果某个算法依赖两处引用是否相同,复制又可能改变它的行为。因此复制深度应由可变性和观察需要共同决定,而不是统一执行“复制到没有引用为止”。对象图存在环时,通用递归复制还需要记录已访问对象,这已经超出当前列表快照的算法。
当前实验以来源修改后快照保持原值为判据,没有比较对象地址或声明反射也无法修改状态。这个判据足以支撑普通接口下的历史报价,且能直接转成回归测试。增加新字段时,先找出调用者是否还持有其可变来源,再补一项修改来源后的读取断言,才能让不可变承诺随着模型演化继续有效。
自测与修改练习
手算:先执行 var copy = List.copyOf(source),再对 source 清空。copy 的长度是否变成零?不会。若清空之前修改了 source 中某个元素的 quantity,copy 中同一元素是否会变?会,因为结构快照保存了同一个元素引用。两个动作命中不同层级,不能用一个“复制过了”概括。
类型题:record Order(List<Line> lines) 中如果 Line 改成持有 int[] quantities,外层 record 与 copyOf 能否继续保证深层稳定?不能;数组是一条新的修改通道。可以将数组转换成只含整数值的不可修改列表,或在输入和访问器两端复制数组,代价与协议分别说明。
修改练习以 exercise-replace-preserves-history=2/7 为起点,把更新位置改成第二行,加入两行不同数量,检查新版本只改变目标行,旧版本所有数量保持不变。再尝试把 Order 构造器中的 copyOf 删除,已有 constructor-defensive-copy 断言应失败。这是可执行的回归敏感性检查,不能只靠阅读判断复制有没有用。
源码见 Main.java,运行入口为:
1 | |
result.json记录 Java 21 编译与运行、源码哈希和每个显式检查。验收包括视图长度二、浅快照长度一且数量九、领域快照数量二、访问器拒绝 add、构造器隔离来源清空、更新保持旧值和 null 拒绝。这里没有声称完成并发一致性、反射攻击或堆分配基准;这些问题需要不同观察装置。

