Java常用类库-10-Multimap的多值语义与写入视图
一家商户可以对应多个商品
目录导入把商户编号映射到商品编号,同一商户输入 B、A、B。若记录代表三条导入事件,两个 B 都应保留;若记录代表商户当前拥有的商品集合,重复 B 可以合并。Map<String, String> 无法同时保留多个值,Map<String, List
Guava 的 Multimap 将一个键对应多个值作为公开集合契约。它并没有替所有实现选择相同的重复规则:ArrayListMultimap 的每键值集合是 List,HashMultimap 的每键值集合是 Set。选型首先由业务是否允许重复决定,其次才是 API 调用是否简短。
本篇使用 Guava 33.5.0-jre、Java 8 API,在 Zulu 8.0.472 和 Corretto 21.0.11 上验证。完整测试与 运行说明可复跑各返回视图的修改。前篇 Immutable 集合讨论冻结结果,这里检查可变多值结构如何维持各入口的一致性。
size 计数的是键值对
向 ArrayListMultimap 插入 shop→B、shop→A、shop→B,size 为 3,keySet.size 为 1,get(shop) 为 B、A、B。向 HashMultimap 插入相同输入,size 为 2,因为重复的键值对被合并。同一个值若属于不同的键,仍分别计数,去重范围是每个键对应的 Set。
ArrayListMultimap 文档保证同一键下按添加顺序保存值;HashMultimap 文档不保证键或值的迭代顺序。因此,本实验对 List 比较完整顺序,对 HashMultimap 只检查大小和包含关系。某次运行打印出 A、B,不是可以写进导出格式的顺序保证。
size 的区别会影响指标。页面上“商户数”应来自不同键数量,“关联数”应来自键值对数量;如果使用允许重复的实现,“不同商品数”还需要按定义去重。把这三个指标都记作 map.size,会在迁移到 Multimap 后改变统计含义。
两个可变实现允许 null 键和值,本实验分别插入 null→null 并检查 containsEntry。这个事实不能外推到 ImmutableMultimap,也不等于业务应接受 null 商户编号。类库容器允许的值域与导入入口允许的值域不同,业务校验仍应在创建关联前完成。
| 需求 | ArrayListMultimap | HashMultimap |
|---|---|---|
| 保留同键重复值 | 保留 | 去重 |
| 同键顺序 | 添加顺序 | 不承诺 |
| size | 所有条目数,含重复 | 不同键值对数 |
| null | 允许键和值 null | 允许键和值 null |
| 空分组长期保存 | 不保存 | 不保存 |
缺失键的 get 返回可写视图
普通 Map.get 在键缺失时返回 null。Multimap.get 返回空集合,但读取这一集合并不会立即创建一个持久的空分组。实验取得 shop 的 values 后,values.isEmpty 为 true,containsKey(shop) 为 false,asMap().get(shop) 为 null。这三个结果同时成立。
向 values 添加 A 后,Multimap 中出现 shop→A,containsKey 变成 true。values 是连接到原 Multimap 的视图,不是对缺失结果创建的普通临时列表。随后通过 asMap().get(shop) 添加 B,原先保存的 values 也读到 A、B。修改能够从两个入口传播到同一状态。
Multimap 接口文档用视图描述这些集合。get 适合调用方将“尚无值”作为空集合处理的情形;asMap().get 则保留 Map 风格的缺失 null。把两种访问方式混用而没有检查缺失分支,仍可能产生 NullPointerException。
查询接口若直接返回 get(key),就把修改权限交给了调用方。方法名是 queryProducts 并不会使 List 自动只读。若需求是实时只读视图,可明确包装;若需求是查询时的结构快照,可复制成 ImmutableList。第 09 篇的元素可变边界仍然存在,容器快照没有冻结商品对象内部状态。
可迁移的判断是按返回值能否回写来审查权限。集合返回类型只说明可以调用哪些方法,不说明调用后的影响范围。DAO 返回集合、配置管理器返回 Map、缓存返回对象时,都应检查别名关系,不能仅凭方法名或变量名推断所有权。
删除最后一个值后,键也消失
values.remove(A) 后只剩 B,size 为 1。再调用 values.clear,shop 不再属于 keySet,containsKey(shop) 为 false。Multimap 的结构不会保留“存在但没有任何值”的键。这个约束使其可被理解为键值对集合,而不是任意 Map<K, Collection
例如普通 Map 中可以存在 shop→空 ArrayList,用来表达“商户已经完成同步,目前没有商品”。把这个 Map 的所有条目逐一 putAll 到 Multimap,空组不会被保存,无法再区分“已同步无商品”与“从未同步”。需要保留这种状态时,应另外保存商户同步状态,或继续使用允许空分组的业务模型。
测试还保留了最初取得的 values 视图。clear 后重新通过 Multimap.put(shop, C),旧 values 再次读取时得到 C。根视图仍然代表 shop 当前的值集合,不是某一代内部列表的永久引用。这个行为可以通过 AbstractMapBasedMultimap 固定实现解释。
get 在键不存在时创建底层集合并返回 WrappedCollection 或 WrappedList,包装中保存键和 delegate。首次添加时 addToMap 把非空集合挂到 map;删除变空时 removeIfEmpty 删除键;读取空 delegate 前 refreshIfEmpty 会检查该键是否已有新的底层集合,并更新引用。totalSize 随条目增删维护,因此从视图写入不能绕过全局计数。
这里描述的是根 get 视图。subList、迭代器等派生对象对底层替换有更细的有效性条件,源码中也存在 ConcurrentModificationException 检查。不能把“根视图能够重新连接”扩大成“任意旧迭代器在修改后都能继续使用”。修改期间继续遍历,应遵守各迭代器契约,而非依赖这个刷新分支。
可迁移模式是先找结构不变量,再检查每个写入口如何维持它。Multimap 的“不保存空组”和“size 等于全部条目数”需要 get、asMap、values、entries、keySet 多条路径共同维护。手写 Map of collections 若只封装 put,却把内部 List 暴露出去,就很难保证全局计数与空组清理一致。
asMap 不是普通的全功能 Map
asMap 返回 Map<K, Collection
删除操作又不同。asMap().remove(shop) 删除整组关联,并返回被删除值的集合。之后重新加入 shop→C,先前返回的 removed 仍保持 A、B,说明它是删除结果,不会继续作为 shop 的活视图读取新值。应用若要审计删除内容,可以保留这个返回值;若要观察商户后续变化,应保留或重新取得 get 视图。
其他集合视图也有对应写入传播。values.remove© 删除一个匹配值对应的条目;当这恰是最后一个条目,键也消失。entries 的迭代器 remove 删除当前键值对,keySet.remove 删除该键的整组值。测试将这些动作依次落在只含一个键的结构上,分别断言结果为空,避免仅检查某个局部列表而漏掉全局状态。
这不意味着所有写操作都可通过所有视图执行。例如 values 不包含插入时所需的键,无法凭空确定新增值应属于哪个分组。审查一个视图时,应分别列出查询、添加、删除和替换行为,而不是笼统写成“可修改”或“不可修改”。这些细分契约比实现类是否以 Map 结尾更有用。
与 Map of collections 的选择
Java 8 的 computeIfAbsent 可以方便地构建 Map<K, List
当多个调用入口都要修改分组,Multimap 可以把空组清理和条目计数集中在容器实现中。但它同时确定了“不保留空组”的模型,接口使用者必须接受。数据交换时把 Multimap 转成普通 Map 也要说明是视图还是复制,否则接收方继续写入会影响原数据。
并发需求另行处理。ArrayListMultimap 与 HashMultimap 不是据此自动线程安全;测试中的写入均为单线程,不提供并发更新保证。即使使用同步包装,遍历与复合业务动作仍需依据包装文档安排同步。业务若要求“读取某商户全部商品并原子替换”,应定义一个操作边界,不能从单个集合方法推出整个流程原子。
删除一项与删除整组的区别
允许重复后,“删除商品”必须明确作用范围。ArrayListMultimap 的 remove(key, value) 删除一个键值对出现;removeAll(key) 删除这个键的全部关联。如果导入事件里同一商品出现三次,删除一次不会自动去掉另外两次。HashMultimap 同键下没有重复值,因此两种实现对一次 remove 的可见结果可能不同,迁移时需要保留这个断言。
替换某键的所有值还涉及输入集合是否与现有视图共享。直接把查询得到的集合当作独立工作副本,在原集合清空后再遍历它,会读不到原值。这类问题不依赖 Multimap 特有实现:任何“读视图、清空源、再消费视图”的程序都可能破坏自己的输入。需要在修改之前保留旧值时,应在明确位置创建副本,并把复制时刻写入流程。
全局 values 视图还可能包含属于不同商户的同名商品。调用 values.remove 只能表达删除某个匹配出现,不能携带商户限制;需要指定商户时,使用 remove(key, value) 或该键下的 get 视图。若逻辑要批量删除所有匹配关联,应使用支持删除的迭代器逐项处理,并在测试里覆盖跨键重复,避免在只有一个商户的数据上误判为正确。
这些选择应反映在应用接口的名字和参数中,例如删除一次导入事件、删除某商户全部商品、删除跨商户同编号关联,是三种业务动作。把它们统一封装成只有一个 value 参数的 removeProduct,会隐藏必要条件。类库可以保持结构一致,无法替含糊的应用接口确定删除意图。
审计输出还应明确记录删除前的键和值,而不是稍后再读取活视图。活视图反映当前状态,后续重新插入可能改变审计内容;删除结果或显式复制更适合保存当次操作的输入。
实验与改动题
Chapter10Test 四个方法在 JDK 8、21 各通过 4 项,失败、错误、跳过为 0。它们覆盖 list/set 重复语义、size、null、缺失键、get 与 asMap 回写、最后值删除、旧根视图重新读取、新插入被 asMap 拒绝、删除结果独立以及 values/entries/keySet 删除传播。原始日志与 Surefire XML 位于 evidence/10/。
反例题:Map 中存有两家商户,其中一家商品列表为空,逐项 putAll 到 ArrayListMultimap 后 keySet.size 是否仍是 2?不一定。空分组不产生条目,因此这一家不会出现在 Multimap 的键集合。若空组用于表达完成状态,这种转换丢失业务信息。
改动练习:把“删除所有商户中的某商品”实现为迭代 entries 删除匹配条目。测试至少应有两个键、重复商品及一个不匹配商品,核验总条目数、最后值被删后的键消失,以及未匹配值仍保留。不能把 values.remove 一次调用误认为删除所有匹配项。
| 需求信号 | 可迁移判断 | 具体选择 |
|---|---|---|
| 重复代表不同事件 | 明确关系的重复语义 | ListMultimap |
| 同一关联只出现一次 | 明确关系的重复语义 | SetMultimap |
| 查询返回集合 | 审查回写权限 | 视图、只读包装或快照 |
| 保留已同步空组 | 先确认结构不变量 | 显式状态或普通 Map |
| 多入口共同修改 | 检查各入口维护同一状态 | Multimap 契约与传播测试 |
