Java常用类库-23-Collections4多值结构的迁移差异
相同数据结构名称不保证相同删除行为
商品导入含三次 A 和一次 B。计数结构需要保留 A 的次数,普通 Set 不够用。Guava Multiset 与 Commons Collections Bag 都能表示重复次数,但对 remove(“A”) 的解释不同:Guava 删除一次,Commons Bag 删除全部 A。若把一种实现换成另一种,只核对添加与读取结果,会漏掉这个改变库存数量的差异。
双向映射也有类似问题。已有 A→x,再插入 B→x,Guava HashBiMap.put 拒绝重复值;Commons DualHashBidiMap.put 则删除旧键 A,让 x 对应 B。两者最终都维持值唯一,却采用不同冲突策略。数据结构的不变量相同,不代表每一步操作接受相同输入。
本篇固定 Commons Collections 4.6.0、Guava 33.5.0-jre,Java 8 API,在 Zulu 8.0.472 与 Corretto 21.0.11 运行。完整测试和 运行说明使用相同样本核验计数、多值与反向索引;前置为 第 10 篇。
Bag 的 size 与 iterator 包含重复次数
HashBag 添加 A 三次、B 一次、null 一次,size 为 5,getCount(A) 为 3。迭代时 A 也出现三次,而不是只返回不同元素。测试把迭代结果重新累加到普通 Map,断言 A 的次数为 3、null 的次数为 1,避免把唯一元素视图与完整迭代混淆。
Bag 接口固定源码明确记录这些计数语义,并对若干与 Collection 习惯不同的操作标注契约差异。Bag 的 containsAll 尊重重数:只含两个 A 的 Bag 不包含列表 A、A、A 所要求的三个出现。本实验在迭代器删除一次之后,验证这条判断为 false。
remove(Object) 删除所有出现,remove(Object, nCopies) 才表达删除指定次数。迭代器 remove 则删除当前一次出现。三个入口都叫删除,作用数量却不同。循环中使用 iterator.remove 与直接调用 bag.remove,不能互相替换。对于库存扣减,“扣一个”和“清空该商品”应由不同业务动作表达。
AbstractMapBag 固定实现以 Map 保存每个不同元素的可变计数,并另存总 size。remove(Object) 删除映射,再将总 size 减去该元素全部计数;指定次数重载则根据删除量减计数或移除条目。这个分支足以解释差异,无须从方法名猜测。
HashBag 允许 null,但不承诺有业务意义的迭代顺序。统计结果导出若要求按商品编号排序,应显式排序,不把 HashBag 当前打印顺序当成格式保证。计数实现也不能替代业务范围校验,尤其不能让无界外部数量直接进入内存累计;本章只使用小整数,没有测试极限计数或内存容量。
可迁移模式是把重数写进操作契约。对任何“多重集合”,都分别问 size 数什么、iterator 返回多少次、remove 删除多少个、containsAll 是否考虑重数。投票、购物车数量、词频和重复事件统计都可以用同一组问题审查迁移。
与 JDK 计数 Map 的对照
Map<String, Integer> 可以手动表达计数:遇到一次 A 就 merge 加一。它没有自动重复迭代行为,entrySet 每个键只出现一次。若调用方原来依赖 Bag 的完整迭代,替换成 Map.keySet 会丢掉重数;如果调用方只需要汇总条目,普通 Map 可能反而更直接。
删除逻辑也由应用定义。对 Map.remove(A) 是删除整个计数项,减一次需要更新数字,并决定零计数是否删除。保留 A→0 与完全没有 A 是两种状态,应用可能需要区分。Bag 的抽象则以出现次数组织内容,是否适合业务还要看零状态是否携带额外含义。
Guava HashMultiset 在同样三次 A 输入上 remove(A) 后剩两次。本测试只用这一个有明确区分度的操作对照,并没有声明 Commons Bag 与 Guava Multiset 在其他全部 API 上已经完成兼容审计。真正迁移时应列出现有调用点,对添加、删除、批量操作和集合视图逐个建立断言。
这种对照比“哪个类库功能更多”更容易用于决策。若代码只依赖 getCount 和累加,迁移面很小;若还把 Bag 作为 Collection 传入泛型算法,就必须检查算法是否假定普通 Collection 的 containsAll 或 remove 语义。
MultiValuedMap 与旧 MultiMap 分开识别
Commons 的 MultiValuedMap 是现代多值接口,不能与历史 MultiMap 混称。ArrayListValuedHashMap 保留同键重复和顺序,HashSetValuedHashMap 去重。相同输入 shop→B、A、B,在前者中得到三个值,在后者中得到两个值。size 都按总关联数计,不是不同键数量。
MultiValuedMap 官方契约说明 get 返回值集合、asMap 返回非空值集合的 Map 视图。测试对缺失键验证 asMap.get 为 null,随后通过 get(absent).add© 建立关联,clear 后键消失。它与本系列 Guava 示例在这组输入上表现相近,但接口名和删除方法名并不相同。
Commons 用 removeMapping(key, value) 表达删除一个关联,remove(key) 表达删除该键所有值。Guava 的 remove(key, value)、removeAll(key) 命名不同。适配层若按相似名称机械替换,可能把细粒度删除扩大成整组删除。因此迁移清单应记录操作对象和作用范围,而非只记录旧方法与新方法的字符串。
这两个具体实现都允许 null 键和值,测试插入 null→null 后通过 containsMapping 检查。其他实现可以规定不同限制,接口文档也把部分 null 行为列为可选异常。不能从 Hash 变体的测试结果推导装饰器、不可修改变体或应用入口全部接受 null。
返回集合仍是写入入口。若应用只允许查询,直接返回 get 的结果会暴露修改能力;若需要独立结果,应明确复制边界。与第 10 篇相同,空组不保留意味着“商户存在但没有商品”不能仅靠该容器完整表达,还需要商户状态或另一个模型。
DualHashBidiMap 在重复值时替换旧关联
双向映射维护 key→value 与 value→key 两套索引。DualHashBidiMap 在放入新键值前,先移除该键原先对应的反向关联,再移除新值原先对应的正向关联,最后把新条目写到两张 Map。AbstractDualBidiMap.put 固定源码明确执行这几个步骤。
已有 A→x,再 put(B,x),x 原先反向映射到 A,因此 A 被删除,结果只剩 B→x。本章断言 containsKey(A) 为 false、size 为 1。这不是哈希冲突丢数据,而是该 API 为保持值唯一而选择的公开更新语义。
相同输入传给 Guava HashBiMap.put 会抛 IllegalArgumentException;改为 forcePut 才接受新关联并移除旧键。业务若要求重复值代表数据错误,迁移到 Commons 时必须在写入前显式检查并拒绝,不能直接沿用 put。若业务本来需要转移映射,Commons 默认行为可能匹配,但审计仍应记录被替换的旧键。
这项不变量还涉及异常和并发。两张索引的关联更新由实现维护,不意味着整个容器自动线程安全;检查再写入若与其他线程竞争,也不是一项原子业务操作。本章全部写入单线程执行,只验证顺序契约,没有证明多线程转移关联安全。
可迁移模式是分开“不变量”和“冲突处置”。唯一值约束可以通过拒绝、替换旧项、合并或显式转移满足;只有第一项相同,不足以判断库可替换。数据库唯一键、缓存写入和配置覆盖都有相同的分析方式。
inverse 是共享视图
测试通过 inverseBidiMap().put(y,C) 写入,正向随即读到 C→y;再从反向删除 x,正向 B 也消失。反向对象是同一映射关系的另一个入口,不是调用时的快照。对反向结果做权限审查时,应与正向 Map 一样看待。
DualHashBidiMap 使用两张共享索引构造反向视图,反向操作维护相应的另一张表。若应用自己创建两张普通 HashMap,却只更新其中一张,就无法维持这个关系。只有在更新入口能够集中控制、且测试覆盖删除和替换时,手写双索引才具备可审查的边界。
本章还插入 null→null 验证具体实现允许该关联。一个值只能关联一个键的约束仍然有效,null 不是绕过唯一性的特殊通道。类型允许 null 与业务身份允许缺失不同,目录编号的非空校验应在写入容器前完成。
迁移适配层必须保留操作粒度
迁移前可以从业务调用点提取一张动作表:添加一次、添加若干次、删除一次、清空某项、查询全部关联、反向转移唯一值。每个动作先写预期前后状态,再映射到候选类库的方法。这样即使方法名称不同,仍然可以比较同一个业务操作;方法名称相同而粒度不同,也会在状态断言中暴露。
以三次 A 为例,扣减一次后的预期是数量二,总数同步减一,而不是只断言 remove 返回 true。两个库都可能返回表示发生修改的结果,但实际删掉的次数不同。对双向映射也不能只断言新键存在,还应断言旧键是否保留、旧值是否仍可反查、总条目数是否符合规则。
观察视图时,应覆盖取出视图之后的修改。先取得某键值集合,再通过主映射添加或清空,能够检查它是否保持实时关系;反过来,通过集合修改检查主映射是否改变。一次查询返回了相同列表,只能证明那个时刻的内容相同,无法证明后续共享契约一致。
适配层还应决定是否暴露库类型。如果业务接口只承诺返回查询结果,就可以返回明确的快照类型,减少调用者依赖写入视图;如果原接口已经承诺实时共享,换成副本就是可观察的行为变化,应作为接口修改处理。封装不能自动让这些差异消失,只是把决策集中到一个位置。
对于顺序,测试宜按所选实现的保证分别断言。列表型多值集合可以验证同键插入顺序,哈希集合型则应比较元素集合与数量,不能强制某个偶然顺序。导出文件需要稳定顺序时,应在输出边界排序,并把排序规则作为单独契约,而非依赖当前哈希表布局。
这些对照不要求重写整套上游测试。优先保留现有业务实际使用的动作,再补本章识别的高风险差异即可。若迁移暂时不涉及反向写入,也应明确关闭或不暴露该入口;不能因为暂时没见过调用,就将它视为没有修改能力。
实验与改动题
Chapter23Test 双 JDK 各通过四个方法,失败、错误、跳过均为 0。证据在 evidence/23,覆盖 Bag 总数与迭代、单项和全部删除、多值视图及 null、双向重复值和反向修改。源码与文档来自同一上游项目,运行反例提供了本地独立观测,没有把多个官方页面算作独立第三方结论。
反例题:已有 A→x、B→y,再 put(C,x),哪两个键最终存在?DualHashBidiMap 中为 B、C;Guava HashBiMap 普通 put 拒绝这次操作,原来的 A、B 仍在。若未把冲突策略写进验收,两个实现都可能被误判为“维持唯一值所以正确”。
改动练习:为商品条码建立双向映射,要求任何重复条码都拒绝,并保留已有映射。分别给 Commons 与 Guava 写相同业务断言,覆盖同键更新、异键重复值和通过反向入口写入。仅给正向 put 添加检查,还不足以约束暴露出去的 inverse 视图。
| 需求信号 | 判断模式 | 迁移检查 |
|---|---|---|
| 重复次数 | 重数属于契约 | size、iterator、remove |
| 每键多值 | 检查写入视图 | get、asMap、空组 |
| 值唯一 | 分开不变量与冲突处置 | 拒绝还是替换旧键 |
| 反向查询 | 审查共享入口 | inverse 写入与删除 |
| 替换类库 | 同输入比较业务动作 | 不按名称机械映射 |
