商品去重使用哪个身份

商户 A 和商户 B 都提交了 SKU-1。若商品身份是商户编号与商品编号的组合,两条记录必须保留;若用只比较 SKU 的 TreeSet 收集,第二次插入却会返回 false。排序正常执行、集合也没有抛异常,但业务数据已经少了一条。

相等、哈希与排序分别定义不同的关系。equals 决定对象之间的相等关系,hashCode 必须与这个关系相容;比较器定义顺序,同时也给有序集合提供“比较结果为零”的等价关系。工具方法可以组合字段,却不能替业务决定哪些字段属于身份。第 02 篇讨论容器和元素的可变边界,这里继续检查元素作为键以后还允许哪些变化。

实验使用 Java 8 API,在 Zulu 8u472 和 Corretto 21.0.11 上运行。Guava 固定为 33.5.0-jre,但这一章的基础反例只依赖 JDK;先明确标准集合的约束,后续引入 Multimap、BiMap 或 builder 才有判断依据。

哈希值不能代替相等关系

Object 的契约要求相等对象具有相同哈希值,并不要求不同对象的哈希值不同。哈希冲突是集合实现必须处理的情况,因此不能以 a.hashCode() == b.hashCode() 代替 a.equals(b)。相等关系还应满足自反、对称、传递及条件一致性。这里的条件是参与比较的信息没有改变,而非宣称所有对象的相等关系永远不变。Object 契约

在商品导入中,价格通常是可更新的属性。若身份定义为 (merchantId, productId),价格就不应混入键的 equals 和 hashCode。价格变化不产生一个新商品,但可能产生一个新的商品版本。需要对完整快照进行比较时,可以另外比较价格等字段,不能为了方便只保留一套含混的“相等”。

Objects.hash(merchantId, productId) 能减少哈希组合代码,但必须和 equals 使用同一组语义字段。把所有字段自动加入 builder,或者用反射自动遍历字段,都没有消除设计工作:新增一个库存字段后,是否应让原来的键失效,仍然需要明确回答。哈希值也不适合作为跨进程商品标识或永久存储的指纹。

可变键的查找路径

本章测试故意提供一个可变的商户字段。键先以 ("A", "SKU-1") 写入 HashMap,再把商户改成 "B"。同一个引用再次执行 get 返回 null,但集合大小仍为 1,遍历也能得到这个键。把商户恢复为 "A" 后,以新建的等价键查询又能返回原来的值。

这不是普遍保证的“恢复键即可修复 Map”算法。Map 文档明确指出,键作为条目存放期间,若变化影响 equals 比较,行为未被指定。实验仅展示两个固定运行时对这组输入的结果。不同字段、哈希碰撞、集合实现或后续操作都可能产生不同表象。Map 的可变键限制

OpenJDK 8 HashMap.putVal 将计算后的哈希保存在节点中。getNode 根据查询键计算当前哈希,随后核对节点哈希和键相等关系。修改字段没有向 Map 发出重新组织节点的通知。这里引用的 OpenJDK 源码用于解释算法,不等于证明 Zulu 与 Corretto 的所有实现细节完全一致。固定源码 HashMap

1
2
3
写入时:Key(A, SKU-1) -> hash(A, SKU-1) -> 节点保存旧哈希
字段改动:同一个 Key 对象变为 Key(B, SKU-1)
查询时:Key(B, SKU-1) -> hash(B, SKU-1) -> 与旧节点定位信息不一致

可靠的商品索引应保存独立且不可变的身份值。实体对象可以更新价格,身份对象只包含稳定字段。如果业务真的允许商户或商品编号迁移,应显式删除旧身份的索引,再新增新身份,并处理两个操作之间的失败与并发;仅给 Map 换成并发实现不会自动完成这个业务事务。实验没有运行多线程更新,因此没有给这类迁移提供并发正确性证明。

比较器的零值也是一种去重定义

TreeSet 依靠自然顺序或比较器判断元素位置。两个对象比较结果为零时,有序集合把它们作为同一元素处理,即使 equals 为 false。文档并未说这种比较器立即导致集合崩溃,而是指出它会让有序集合偏离 Set 的一般相等契约。Comparator 文档

价格提供了一个标准库中的具体例子:new BigDecimal("1.0") 与 new BigDecimal("1.00") 数值比较为零,但 equals 区分 scale。两者放入 HashSet 得到 2 个元素,放入自然排序的 TreeSet 得到 1 个元素。是否应该去重不能由“哪个集合更高级”决定,而要由价格表示中的 scale 是否属于业务信息决定。BigDecimal 的相等与排序

商品列表通常只需要按价格展示,并不需要依靠价格去重。此时使用列表排序就能保留同价的多个商品。如果同时需要排序和按商品身份去重,可以先用身份键归并,再把结果放进列表排序;或者为有序集合定义包含所有身份字段的比较器。不能直接在按价格比较的 comparator 后补商品编号,就声称与身份 equals 完全一致:同一商品价格不同时,比较结果仍然非零。

下面的完整 Java 8 程序说明组合身份与只按 SKU 比较的差异。它使用不可变键,不把上一节故意制造的可变键反例带入正常实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import java.util.Comparator;
import java.util.Objects;
import java.util.Set;
import java.util.TreeSet;

public final class IdentityOrder {
static final class Key {
final String merchant;
final String sku;
Key(String merchant, String sku) {
this.merchant = Objects.requireNonNull(merchant);
this.sku = Objects.requireNonNull(sku);
}
public boolean equals(Object other) {
if (!(other instanceof Key)) { return false; }
Key key = (Key) other;
return merchant.equals(key.merchant) && sku.equals(key.sku);
}
public int hashCode() { return Objects.hash(merchant, sku); }
}
public static void main(String[] args) {
Set<Key> values = new TreeSet<>(Comparator.comparing((Key k) -> k.merchant)
.thenComparing(k -> k.sku));
values.add(new Key("A", "SKU-1"));
values.add(new Key("B", "SKU-1"));
if (values.size() != 2) { throw new AssertionError(values.size()); }
System.out.println(values.size());
}
}

归并策略与相等关系分开设计

身份相同的两条导入记录不一定应该静默丢弃。假设第一条商品价格为 10.00,第二条同身份商品价格为 12.00。Set 只回答是否已存在等价元素,没有表示“保留较新版本”“拒绝整批”“累计变更”这几种处理策略。系列第 00 篇的目录基线选择拒绝商户内重复商品编号,使冲突显式出现;改成覆盖更新时,应另行定义版本或时间依据。

相等关系还应覆盖所有参与索引的业务边界。只用 SKU 会合并不同商户,使用 (merchantId, sku) 则将身份限制在商户命名空间内。若以后增加渠道或目录版本,不应因为字段出现就立即加进键;需要先判断这个字段变化是否真的产生新身份。已有历史数据、数据库唯一约束与内存索引必须一起审阅,否则一种实现认为重复,另一种实现允许并存。

对普通值对象,限制继承往往让相等契约更容易说明。示例把 Key 声明为 final,并只比较两个稳定字符串,因此不会遇到子类新增字段后,父类认为相等、子类认为不相等的对称性冲突。选择 instanceof 或 getClass 不能脱离继承策略独立讨论。仅复制一段 IDE 生成代码,而不决定子类是否属于同一种值,也没有真正完成建模。

数组字段需要额外注意。数组继承的对象相等不按元素逐项比较,把数组直接交给 Objects.equals 也不会自动获得期望的内容相等。若身份中存在字节摘要,应明确使用内容相等与相容的内容哈希,并隔离外部数组修改。这里没有把数组键混进商品示例,因为当前身份本来就是字符串;这个扩展说明“字段都 final”仍不足以证明整个身份稳定。

测试相等关系时,可以为同一商户同一 SKU 创建多个独立对象,检查等价对象是否在同一集合中合并,再改变一个身份字段检查是否保留两条。不要只把同一个对象放进去两次,那仅验证了引用重复。比较器测试还应覆盖三元组的顺序一致性,以及比较为零时是否满足预定的身份定义;单独测试一个升序样例无法发现不传递的比较规则。

有时确实需要一个与 equals 不同的分组关系,例如按价格把商品放入价格档位。这种情况下应将它命名为价格分组,并使用明确的分组键,让读者看到原始商品仍有自己的身份。把这一关系藏进一个未说明用途的 TreeSet,会同时改变容器大小、contains 结果与后续集合运算的含义。

null 与整数边界必须单独处理

商品编号应在进入索引前拒绝 null。展示列表中若确实允许缺少某个排序字段,则需要定义缺失值的位置。Comparator.nullsFirst 和 nullsLast 表达的是排序策略,不是补齐缺失值,更没有验证其他字段合法性。自然顺序比较 null 会失败,外围包装允许 null 之后也不意味着 comparator 内部访问的每个嵌套字段都安全。

比较器还应避免用 left - right 比较整数。Integer.MAX_VALUE - (-1) 溢出为负数,顺序因此反转;Integer.compare 或 Comparator.comparingInt 没有这个减法陷阱。比较器返回值只需表达负、零、正,没必要追求差值本身。JLS 8 整数运算

Guava ComparisonChain、Ordering 或 Commons 的比较器工具,最终仍依赖输入比较关系。它们能够减少组合代码,却无法修复不传递的比较器,也无法自动区分“缺少价格排最后”和“非法价格必须拒绝”。本章没有测量这些工具的速度;选择标准 comparator 的理由是当前需求已经得到清楚表达。

同一对象通过多个引用被共享时,键稳定性要求也覆盖那些引用。调用方手中保留原对象,集合包装层就无法阻止它修改身份字段。将索引封装成只读接口只限制访问入口;建立独立身份值才能切断这种修改路径。两项措施解决的是不同层面的约束。

运行、结果与练习

完整反例下载为 Chapter03Test.java,按 运行说明 放进第 00 篇实验工程执行。两个 JDK 均完成本章 3 个测试,失败、错误、跳过均为 0。测试结果覆盖下表;集合大小与异常均由断言检查,不仅打印后人工判断。

输入或操作 可观察结果 业务含义
Map 键商户 A 改成 B 查找为 null,大小仍为 1 索引持有对象不代表当前查询仍可命中
价格 1.0 与 1.00 HashSet 为 2,TreeSet 为 1 数值相等与表示相等不同
两商户相同 SKU 仅 SKU 比较为 1,组合身份为 2 排序字段参与了去重
null 与 A、B nullsFirst/Last 放在对应端点 缺失字段有显式位置
最大整数减负一 减法为负,Integer.compare 为正 算术差不能作为安全比较

手算题:把商品比较器改成商户编号,再比较价格,不再比较 SKU。两个不同 SKU、同商户、同价的商品会留下几条?答案是一条,因为比较器对它们返回零。这个结果不依赖两者 equals 的具体实现。

改动练习:在测试里加入同一商户的 SKU-1 和 SKU-2,价格都为 1.00;先用列表排序确认两条都保留,再用按价格的 TreeSet 观察去重。修复时分别写出“商品身份”和“展示顺序”,避免用同一个 comparator 承担两个不同职责。

可迁移做法 适用边界
从实体中提取不可变身份键 实体会更新,但索引身份必须稳定
将去重与展示排序分成两个步骤 展示字段不能唯一标识业务对象

系列入口:可复现基线。下一篇:规范化策略与依赖成本。