Scala 10:不可变集合,更新如何保留旧版本
修改一行数量,旧报价为什么还应该有效
订单编辑页保存了一份数量列表,报价计算正在使用它。用户又修改第二行数量,如果这两个操作共享可变数组,报价读到的内容可能取决于修改时点。不可变集合提供另一种接口:更新返回新集合,旧集合继续表示修改前的版本。
这不意味着每次更新都完整复制,也不意味着集合里的对象不会改变。它只先回答一个结构问题:对集合执行 updated、添加或删除后,旧集合的元素排列与索引关系是否仍保持原样。进一步的共享范围、元素可变性和操作代价,需要分别判断。
本文固定 Scala 3.3.7、标准库 2.13.16 和 JDK 21,比较 List、Vector、Map、Set。实验检查结果、版本独立性和能够直接观察的共享关系,不把对象身份断言扩展成完整内存测量,也不从小输入的运行速度推导性能排名。
先看访问方式,再选集合形状
List 适合不断在头部增加元素,并顺序消费头尾。Vector 提供索引访问与持久更新,适合需要按位置处理的序列。Map 用键寻找值,Set 表达成员关系和去重。这些差异比“它们都支持 map”更能决定订单数据怎样组织。
如果订单行总是按顺序扫描,List 和 Vector 都能表达结果;如果编辑器经常按行号更新,Vector 更贴近访问形状;如果要按商品标识累计数量,Map 比反复扫描序列寻找同商品更直接。Set 可以检查唯一标识,但不能同时保存重复行出现的次数,去重会删除这部分信息。
Scala 集合还区分通用接口、可变实现与不可变实现。方法参数写成某个通用集合接口,未必就排除了可变实现。审阅一个“只读”接口时,应检查它的具体类型层次和传入对象,而不是只看调用代码没有执行修改。读权限与底层对象是否可能被其他持有者修改,是两个问题。
本章直接使用标准不可变 List、Vector、Map、Set,避免用过宽接口隐藏选择。对外 API 是否需要抽象成 Seq、Iterable 或 IterableOnce,则要考虑是否允许一次消费、是否需要重复遍历、是否承诺索引效率。越抽象的类型通常表达越少保证,调用者不能继续假设某个具体实现的所有性质。
List 的头插为何能够共享旧尾部
1 | |
可以把 old 看作指向元素 2 的首节点,后面连接元素 3 和空尾。头插只需要一个新的首节点保存 1,再把尾部指向 old。旧列表不需要被修改;新列表前进一步就到达原来的头。实验中的 eq 直接验证了这一次头插的尾部引用共享。
这里同时出现值相等和身份相同。prepended 与预期列表值相等,不要求它们每个节点都是同一对象;prepended.tail 与 old 身份相同,则是更强的具体观察。不能因为某个操作返回值相等,就推导它必定共享对象;也不能要求所有不可变集合更新都满足同样的 eq 关系。
若需要更新第二个元素,List 必须形成能够指向新节点的前缀,因为原来的前缀不能原地改尾指针。更新位置越靠后,需要经过的节点越多。按索引取元素也必须从头前进,因此把 List 当数组,在从零到长度的循环里反复调用下标,会产生大量重复遍历。
这种代价可以用步数推导,不需要先跑计时器。访问第零个元素走很少步骤,第一个多走一步,直到最后一个;反复下标访问的总工作量随一加二加直到元素数增长。顺着尾部一次扫描则每个节点只经过一次。算法访问模式可能比选择某个集合类的局部差异更重要。
Vector 更新保持旧值,但不承诺复制所有元素
1 | |
这两个断言验证了版本独立性:旧集合对应位置仍然是 20,新集合对应位置是 200。它们没有测量分配了多少节点,也没有证明元素被深复制。Vector 的实现利用分层结构处理索引和更新,官方代价表将若干操作描述为实际近似常数,但这不等于所有输入规模和机器上都有同一纳秒成本。
持久数据结构通常通过共享未受影响的部分降低复制工作。对使用者最关键的是旧版本保持可用;至于分支宽度、数组层数和小集合特化等实现细节,需要按标准库版本分析。把实现细节当成永久接口保证,会让未来版本升级时的推理失效。
保留旧版本也会影响生命周期。只要某个历史集合仍可达,它引用的节点与元素就可能继续存活。不可变更新减少并发读写冲突,并不自动提供无限历史的免费存储。若编辑器保留每次按键的整份历史,应设计历史窗口或压缩策略,不能仅凭结构共享认为内存不会增长。
本章没有运行堆分析或 JMH,因此只在数据结构层解释共享和代价。生产场景若需要比较更新十万行订单的吞吐与分配,应使用相同业务结果、固定输入分布、预热和多轮采样,另外保留 GC 与分配证据。正确性断言通过是性能比较的前提,不是性能结论。
Map 与 Set 需要稳定的相等关系
1 | |
第二次构造的 Key(“p1”) 与第一次不是同一次构造产生的对象,但 case class 的值相等让它们能够代表同一个逻辑键。Map 更新这个键的值,Set 则把两个相等键视为同一个成员。键的 equals 与 hashCode 应保持一致,不能让相等对象提供互不相容的哈希行为。
哈希碰撞不意味着两个键相等。哈希用于缩小查找范围,相等关系仍要区分候选。反过来,如果键放入集合后参与哈希或相等的字段发生变化,原来的索引组织可能不再对应当前键状态。集合本身不可变,并不能阻止外部修改一个可变键对象。
因此适合作为键的领域标识通常使用稳定字段,而不是把会变动的订单总额或展示文案混入标识相等关系。订单对象整体的值相等与订单业务身份也不必相同:更新了金额的订单可能仍是同一订单标识。Map 的键应围绕查询身份设计,值保存可变化的版本。
默认 Map、Set 的遍历顺序不能充当业务排序协议。本章断言 Map 的内容相等,不依赖打印顺序;需要稳定报表时,应显式排序键或使用承诺所需顺序的结构。去重与排序是不同操作,不能因为样例恰好按输入顺序打印,就把这种偶然结果写入接口。
不可变容器仍然可能共享可变元素
1 | |
before 的长度仍是一,after 的长度仍是二,结构不变契约没有被破坏。但两个集合的第一个位置保存的是同一个 Item 引用,所以修改数量之后,两者都观察到九。实验还用 eq 验证元素身份相同,把变化原因限定为共享对象,而不是容器更新。
这使“旧报价输入保持不变”的最初目标需要额外条件。如果数量存储在不可变值对象中,替换一个元素会产生新对象,旧版本仍然引用旧数量;如果元素里包含 var,就算最外层是 Vector,历史集合也未必代表历史业务状态。深层不可变性是整个可达对象结构的性质,不能由外层类名推出。
简单 copy 也未必解决问题。case class 若保存一个可变数组,copy 默认仍可能沿用数组引用;把元素集合复制一层,则可能只复制引用列表。修复应明确究竟要隔离哪一层数据,必要时转换成只含稳定值的快照类型。对外部资源句柄和连接对象,复制引用与复制资源更不能混同。
不可变容器对并发很有帮助,但它不是线程安全证明。除了元素状态,还要检查发布方式、缓存字段、外部服务和更新协议。本章断言只覆盖单线程下的引用关系,不能用它代替并发可见性或原子更新实验。
从代价表得到可迁移的判断方法
面对一个订单处理循环,可以把需求拆成访问、更新、保留三项。访问是顺序扫描、按位置还是按键;更新是头插、尾追加还是中间替换;保留是只要最新版本,还是必须同时保存历史。集合选择应与这些操作频率一起确定。
随后检查结果语义:重复是否有意义,顺序是否影响展示,键的相等是否对应业务身份,元素是否可变。比如把订单行转成 Set 可能减少重复,却也可能错误地合并两条价格相同的独立行;转成 Map 若采用商品号作为唯一键,则会覆盖重复商品行,除非业务明确要求合并。
最后才比较常数因子与实际资源消耗。小列表的简单结构可能很合适,频繁随机更新则需要不同布局;多版本保留可能增加共享收益,也可能延长大量对象生命。代价表提供增长趋势与初步排除,不替代目标工作负载的测量。
构建阶段与发布阶段可以采用不同表示
从文件读取大量订单行时,逐条生成最终集合未必需要让每个中间版本都逃逸到调用方。局部 builder 可以在构建期间收集元素,结束后一次返回不可变结果。这样的实现是否合适,取决于可变状态有没有越过方法边界,以及发布之后是否还存在修改通道。方法内部出现可变变量,不会自动破坏对外的值语义;把可变缓冲直接返回,则需要重新分析共享关系。
这也解释了为什么接口讨论和实现讨论应分开。接口可以承诺返回稳定的订单列表,内部选择怎样分配和填充由实现负责。但若调用方传入可变元素,构建一个不可变列表仍只保存那些元素引用。builder 解决构建成本,不承担深复制或业务校验。验收应分别检查集合结构和元素状态,避免把一种机制承担不了的保证写在它身上。
手算与修改练习
手算:old 为 List(2,3),执行 val a = 1 :: old 与 val b = 0 :: old 后,a.tail 与 b.tail 是否同一引用?答案是两者都直接指向 old,因此相同;a 与 b 的首元素不同,整体值不相等。这个结论来自头插构造,不应推广到任意集合变换。
修改练习:把 Item 改成 case class Item(quantity: Int),通过 before.updated(0, before.head.copy(quantity = 9)) 得到新集合。断言旧数量仍是一、新数量为九,并断言两个首元素不是同一对象。再加入多个订单行,分别以 List 和 Vector 更新中间位置,检查结果等价;此练习验证行为,不要求根据运行一次的时间排名。
隔离编译反例试图执行 quantities(0) = 9。对不可变 Vector,这种赋值语法需要的 update 成员不存在,编译应因该原因失败。正确方法 updated 返回新值,若丢弃返回值,旧集合不会因此改变。
实验记录与依据
正常源码为 examples/scala-lab/snippets/10/Chapter10.scala,入口 scalaexamples.Chapter10;实际记录见 RUN.md。
- List 2.13.16 API:核对头插、头尾访问和结构共享。
- 集合操作代价表:核对不同操作的增长特征,不作为本机计时结果。
- IterableOps 2.13.16 API:核对集合操作接口。订单版本与共享元素反例由本章程序给出。
前置阅读:类对象与值相等。

