Reading 15: 相等性(Equality)
Reading 15: 相等性(Equality)
说明:本讲 sp22 原版使用 TypeScript,本笔记按用户要求提供 Java 代码示例;类型/API 与 sp21(6.031 Java 版)原文保持一致。sp22 用
===/equalValue()的场合,Java 对应==/equals();sp22 因语言限制改用 Python__eq__/__hash__讨论哈希的部分,Java 对应equals()/hashCode()。
概述
本讲要解决的问题是:在为一个抽象数据类型(ADT)定义「两个值什么时候相等」时,该如何做出正确的设计。核心结论有三条:任何相等操作都必须是等价关系(自反、对称、传递);不可变类型的相等应以抽象函数(abstraction function, AF)为基础——即两个对象相等当且仅当它们表示同一个抽象值,这要求覆盖 equals() 与 hashCode();可变类型的相等应以引用相等(行为相等)为基础——即不覆盖 equals(),因为「观察相等」会随时间变化,从而破坏哈希表的表示不变量。它与「Safe from bugs」直接相关:错误的 equals/hashCode 会让 HashSet/HashMap 静默丢失元素,也会让测试断言给出错误结论;与「Easy to understand」相关:客户端与读者都期待类型提供恰当的相等操作,否则会困惑于「明明一样却不相等」;与「Ready for change」相关:正确的不可变类型相等把「引用是否共享」这个实现细节对客户端隐藏起来。
核心概念与设计原则详解
等价关系(Equivalence Relation)
- 定义与目的:定义在类型 T 上的相等操作可看作二元关系
E ⊆ T × T;合格相等操作必须满足三条性质:自反(reflexive)(t,t) ∈ E ∀t ∈ T、对称(symmetric)(t,u) ∈ E ⇒ (u,t) ∈ E、传递(transitive)(t,u) ∈ E ∧ (u,v) ∈ E ⇒ (t,v) ∈ E。用==/equals的语言写就是t == t、t == u ⇒ u == t、t == u ∧ u == v ⇒ t == v。它保证任何依赖相等的操作(集合成员判断、列表查找、去重、测试断言)行为一致、可预测。 - 直观解释(”它是什么?”):物理世界里每个对象都是独特的(两片「相同」的雪花在空间位置上终究不同),但它们只有「相似程度」;而数学与语言世界里可以有多个名字指同一个事物——
1+2、√9、3是同一个理想数学值的三种写法。等价关系就是「同一个事物」这个概念必须满足的形式条件:自己和自己一样(自反)、跟别人一样就是互相一样(对称)、一样的东西链条不会断裂(传递)。 - 关键规则与最佳实践:
- 相等操作永远必须是等价关系,否则会产生「令人惊讶的行为与 bug」。
- 违反自反的典型后果:把元素插入集合后,自己却查不到自己(
is_member(set[0], set)返回 false)。 - 违反对称的典型后果:集合的成员判断结果依赖插入顺序(sp21 的练习里,先插
"007"再插7与反过来插,len(set)结果不同)。 - 违反传递的典型后果:「差不多相等」的链式传播(a 与 b 差 5 秒算相等、b 与 c 差 5 秒算相等,于是 a 与 c 也算相等,尽管它们差 10 秒)导致查找、去重、排序全部失序。
- 检验「不满足自反」只需一个对象作为反例;检验对称需要两个;检验传递需要三个——这也解释了为什么传递性最容易被实现者忽略。
为什么必须严格:Python None 与自动类型转换的反例
- 定义与目的:这个思想实验用最直白的方式说明「破坏等价关系会怎样」,而它是语言设计者真实面对的选择。
- 直观解释(”它是什么?”):假设我们规定
None == x对所有 x 都是 false(「None 太糟糕了,谁都不该等于它」)。那么这个规则首先违反自反性:None == None也是 false。后果:把一个值x插入集合两次,如果x是5,集合长度为 1(正常去重);但如果x是None,因为is_member(None, set)永远为 false,None会被重复插入,集合长度为 2,而且连is_member(set[0], set)都是 false——集合里明明放着None,却说它不在里面。 - 直观解释(续):再假设
==在类型不同时自动转换右操作数的类型("5" == 5变成"5" == str(5),为 true;5 == "5"变成5 == int("5"),也为 true)。由于int("007")忽略前导零,7 == "007"为 true 而"007" == 7中右侧7转成字符串"7","007" == "7"为 false——对称性被破坏。后果是集合的去重行为依赖插入顺序:先插"007"再插7与先插7再插"007"得到不同的集合大小。 - 关键规则与最佳实践:
- 一旦相等不是等价关系,所有依赖它的数据结构(集合、映射、去重、查找)就都不可靠了,而且 bug 往往以「顺序相关」「时好时坏」的形式出现,极难调试。
- JavaScript/TypeScript 的
==就是这种「自动类型转换」的反面教材:它有时做引用相等、有时做值相等,甚至不是等价关系,因此现代 TS/JS 程序员极力避免使用它(Java 中没有这种自动转换的==,但 Java 有它自己的陷阱:自动装箱,见后文)。 - Java 的
==在对象类型上始终是引用相等,在基本类型上始终是值相等,语义固定、不做隐式转换——这是好设计,但要注意「编译期类型」决定了调用哪一个版本。
不可变类型的两种相等定义:抽象函数视角 vs 观察视角
- 定义与目的:对不可变类型,相等有两条形式化路线:用抽象函数定义——
a equals b当且仅当AF(a) = AF(b);用观察定义——两个对象相等当且仅当无法通过观察区分,即对 ADT 规格中的每一个操作,两者都产生相同结果。它服务于「安全」与「易理解」,并且两者必须一致,否则说明设计有问题。 - 直观解释(”它是什么?”):观察意义上的相等,就是「用规格允许的全部操作去戳它们,看能否分辨」。集合
{1,2}与{2,1}用集合的观察操作(基数\|…\|与成员关系∈)完全无法区分:基数都是 2,1 ∈都为真,2 ∈都为真,3 ∈都为假……因此它们相等。 - 关键规则与最佳实践:
- 「观察」指的是调用 ADT 规格中的操作。Java 允许客户端突破抽象边界去观察与抽象值无关的差异(
==能看出两个表示同一集合的对象占不同内存、System.identityHashCode()基于内存地址),但这些操作不属于 ADT 规格,因此不参与观察相等的判定。 - 只有当观察者集合与 AF 一致时,两种定义才给出相同答案。以
Duration(rep 为mins、secs,AF 为「mins 分 secs 秒的时间跨度」,唯一观察者是getLength())为例:d1=(1,2)与d4=(1,2)的 rep 完全相同,AF 必然相同;d3=(0,62)表示的时间跨度与d1相同,按 AF 定义也相等(这正是 rep 允许secs > 60造成的非规范化表示);d2=(1,3)不等。换成观察视角,getLength()对d1、d3、d4都返回 62,无法区分,结论一致。 - 反例是「观察者泄露 rep 细节」:考虑
LetterSet,其 AF 为「字符串中出现过的字母集合(忽略非字母与大小写)」。观察者contains()、size()与 AF 一致;但length()(构造时字符串的长度)、first()(字符串的第一个字母)、isAllLowercase()观察的是 rep 的细节而非抽象值——new LetterSet("abc")与new LetterSet("1a2b3c")抽象值相同(都是{a,b,c}),但length()不同。若把它们纳入观察者集合,观察相等就会与 AF 相等冲突,说明这些操作根本不该出现在这个 ADT 的规格里(它们属于 Reading 11 所说的「泄露表示」的观察者)。 - 同一个道理适用于
MyLine:如果该类只暴露slope(),那么所有斜率相同的直线都无法被区分,观察相等的粒度就退化到「斜率相等」;此时若 AF 把 rep 解释为一条具体的直线,就会出现「AF 不同却无法观测区分」的不一致。要让两种定义一致,要么让 AF 与观察粒度匹配,要么把能区分直线的观察者补进规格。
- 「观察」指的是调用 ADT 规格中的操作。Java 允许客户端突破抽象边界去观察与抽象值无关的差异(
- 小结:观察相等与 AF 相等的关系是设计约束。它给出的实践指引是:先明确 AF,再检查每一个 observer/producer 是否与 AF 一致;不一致的操作要么删掉,要么修改 AF(并相应调整规格)。
引用相等 vs 对象相等(Reference Equality vs. Object Equality)
- 定义与目的:大多数语言提供两种相等操作。引用相等测试两个引用是否指向内存中的同一处存储(在快照图上就是两个箭头指向同一个对象气泡);对象相等(值相等)测试两个对象是否表示同一个值。区分的意义在于:客户端常常希望「内容一样就算相等」(例如两个内容相同的
Duration),而语言层面的引用比对太强。 - 直观解释(”它是什么?”):各语言的对照:Python 用
is/==;Java 用==/equals();Objective-C 用==/isEqual:;C# 用==/Equals();TypeScript/JavaScript 用===/ 无内置值相等(需要自己定义equalValue())。注意==的含义在 Python 与 Java 之间恰好相反,不要混淆:Java 的==只比较引用。 - 关键规则与最佳实践:
- 我们无法改变引用相等操作的含义(Java 中
==永远是引用相等),但新定义的数据类型有责任决定对象相等的含义,并正确实现equals()。 - 上表只适用于对象类型;基本类型遵循不同规则:Java 的
int不能用equals(),==做值相等,也没有引用相等的概念。 - Java 中
equals()由Object定义,默认实现就是return this == that;,即默认含义就是引用相等。对不可变类型来说这几乎总是错的,因此必须覆盖。 Object的默认hashCode()与默认equals()是一致的(都基于地址),所以「两个默认实现」本身不违反契约;问题出在「覆盖了equals()却忘了覆盖hashCode()」。
- 我们无法改变引用相等操作的含义(Java 中
Object 契约(The Object Contract)
- 定义与目的:
Object的规格如此重要,以至于被称为 Object 契约。它规定了覆盖equals()时必须满足的四个条件:equals必须定义等价关系(自反、对称、传递);equals必须一致(consistent)——只要对象没有被以影响比较的方式改变,重复调用必须得到相同结果;对非 null 引用x,x.equals(null)必须返回 false;hashCode对equals判定相等的一对对象必须返回相同结果。 - 直观解释(”它是什么?”):把它理解为 ADT 作者与整个 Java 生态(集合库、测试框架、缓存、去重工具)之间的合同:你按合同实现,生态就能可靠地使用你的类型;你违约,生态就会在你完全想不到的地方出问题。
- 关键规则与最佳实践:
- 契约允许
null作为参数,并在后置条件中明确规定结果:x.equals(null)应为 false。若违反(例如让new Duration(0,0).equals(null)返回 true),会立刻破坏对称性——因为null.equals(...)根本无法被调用,也就无法「回敬」true。 - 「一致性」意味着相等的结果不能随时间变化(前提是对象没有被改动)。这正是可变类型采用观察相等会出问题的根源。
- 用
@Override注解强制编译器检查你确实在覆盖而不是重载(见下文场景 1)。 - 覆盖
equals时必须同时覆盖hashCode(见场景 2)。 - 常见错误是把
equals实现成「字符串比较」:this.toString().equals(that.toString())既依赖toString的实现(默认实现无意义),又常常破坏对称性(new Duration(1,30).equals("1:30")可能为 true,而"1:30".equals(new Duration(1,30))为 false)。
- 契约允许
正确实现 equals():@Override、instanceof、私有 sameValue
- 定义与目的:给出一个可以照抄的正确模板:覆盖
equals(Object)、用instanceof做类型检查、把真正的字段比较放进私有辅助方法sameValue。它服务于「安全」——避免重载陷阱与类型转换错误。 - 直观解释(”它是什么?”):
@Override public boolean equals(Object that) { return that instanceof Duration && this.sameValue((Duration)that); } private boolean sameValue(Duration that) { return this.getLength() == that.getLength(); }第一个方法覆盖并替换了从
Object继承的equals(Object);它先检查that确实是Duration((Duration)that是类型转换表达式,向编译器断言你的信心),再调用私有辅助方法做值比较。 - 关键规则与最佳实践:
- 签名必须与
Object.equals(Object)完全一致,并加上@Override:签名写错时(例如参数类型写成Duration)编译器会立刻报错,而不是悄悄做出一个重载版本。 instanceof是动态类型检查,在面向对象编程中通常是「坏味道」;好设计中instanceof只允许出现在equals的实现里,getClass()等其他运行时类型检查手段同样被禁止。that instanceof Duration在that == null时返回 false,因此这一行顺带处理了equals(null)的契约要求(这也是回答「哪一行让 null 返回 false」的答案)。- 用
instanceof还是getClass()有取舍:instanceof允许子类实例与父类实例相等(更符合「同一个抽象值」的直觉),getClass()要求运行时类型完全相同(更严格、能保证对称性在继承体系下不被破坏)。6.031 采用instanceof的模板;若你的类会被继承并改变相等语义,需要重新审视这个选择。 - 私有
sameValue(Duration)承担真正的字段比较:它接受具体类型、不必关心 null 与类型问题,也方便与既有比较逻辑(如getLength())复用。
- 签名必须与
可变类型的两种相等:观察相等 vs 行为相等
- 定义与目的:对可变类型,相等仍然必须是等价关系,也仍然要尊重 AF 与操作;但多了一种新可能——在观察之前调用 mutator,可以改变对象状态,从而制造出差异。因此把「基于观察的相等」细分为两种:观察相等(observational equality)指两个引用此刻无法被区分,客户端只能调用不改变状态的观察者(observers/producers,不含 mutators)来比较,即「它们当前看起来一样吗」;行为相等(behavioral equality)指两个引用现在与将来都无法被区分,即使对其中一个调用 mutator 而对另一个不调用,即「它们在任何状态下都会表现一样吗」。
- 直观解释(”它是什么?”):
arrayA = {1,2,3}、arrayB = {1,2,3}、arrayC = arrayB。用只读操作(length、get(0)…)去比较,三者当前看起来一样;但一旦执行arrayA.set(0, 0)或arrayB.clear(),arrayA与arrayB就会分道扬镳——因此它们只在「此刻」相等。而arrayB与arrayC指向同一个对象,任何 mutator 都会同时影响两者,因此它们在一切未来状态下都相等。 - 关键规则与最佳实践:
- 对不可变类型,观察相等与行为相等完全相同(没有 mutator 能改变状态),因此只需要一个相等操作。
- 对可变类型,两种相等都有用:Java 用
==提供行为相等;观察相等则通过一个单独的操作提供(sp22 命名约定是equalValue();Java 中若需要,建议命名为similar()或sameValue(),作为公开操作)。 - 实现方案:不要用
equals()承载可变类型的观察相等。Java 库对可变类型混用了两种语义(List、Set、Map采用观察相等,StringBuilder与数组采用行为相等),这是一个历史遗留的不一致,不应模仿。 - 关键风险:观察相等随时间变化,因此把这种对象放进依赖哈希的结构(
HashSet、HashMap)会破坏其表示不变量(见场景 3)。 - Bag(多重集)例子可以清晰区分两者:
b1 = {a,b}、b2 = {a,b}、b3 = b1.remove("b")后为{a}、b4 = {b,a}。行为相等下只有b1 == b4不成立(它们是不同对象)、而每个对象只与自身相等;观察相等下b1、b2、b4三者两两相等(count结果相同),b3与它们都不等。
hashCode() 契约与正确实现
- 定义与目的:
Set与Map的实现(HashSet、HashMap)基于哈希表(hash table),要求元素类型或键类型提供哈希函数:把对象值映射成一个整数。契约要求:equals判定相等的两个对象必须具有相同的hashCode。它服务于「安全」与性能。 - 直观解释(”它是什么?”):哈希表内部是一个数组。插入键值对时,先计算键的哈希码,再把它映射到数组下标(例如取模),把值放进那个槽位;当两个键落到同一槽位(冲突)时,槽位里其实是一个键值对列表,称为哈希桶(bucket)。查找时先算哈希码定位槽位,再沿桶逐个比较,直到找到
equals相等的那个键。哈希表的表示不变量中包含一条根本约束:键必须能从它的哈希码所决定的槽位出发被找到。因此如果两个equals相等的对象有不同的hashCode,它们可能被放进不同槽位——用与插入时相等的键去查找就会失败。 - 直观解释(续):
Object的默认hashCode()返回基于内存地址的整数,与默认equals()(引用相等)严格一致。但一旦你覆盖equals()让「内容相同」的两个对象相等,默认hashCode()就会违约:d1.equals(d2)为 true,而d1.hashCode()与d2.hashCode()不同。 - 关键规则与最佳实践:
- 覆盖
equals就必须覆盖hashCode(反之不强制,但强烈建议)。 - 必须加
@Override:课程历史上曾有学生把hashCode拼成hashcode,于是新方法根本不覆盖Object.hashCode,出现了极难定位的怪异行为。 - 标准做法:对参与相等判定的每个字段分别取其哈希码,再用算术运算组合起来。
Duration的抽象值本身就是一个整数,因此return (int) getLength();即可。多字段场景可用Objects.hash(...)(Java 补充说明),或手写31 * result + field的经典组合方式(Josh Bloch《Effective Java》有详细讨论)。 - 一种「简单粗暴」的合规做法是让
hashCode永远返回常数(例如 42):它满足契约,但所有键都挤在同一个槽位,查找退化为线性扫描,性能灾难性下降。 - 只要满足契约,具体哈希技巧不影响正确性,只影响性能:糟糕的哈希函数会产生不必要的冲突,但总比破坏契约好。
- 绝不要用可变字段计算哈希码:对象的哈希码在生命周期内必须保持不变,否则它进入哈希表之后就无法再被找到(见场景 3)。
- 在像 Java 这样所有对象都支持相等比较的语言里:对不可变类型,永远实现与
equals一致的hashCode。补充说明:sp22 指出 TypeScript 的哈希函数是内置且不可覆盖的,因此 TypeScript 中不可变对象类型不能安全地用作Set元素或Map键;Java 没有这个限制,这反而凸显了「正确实现equals/hashCode」的重要性。
- 覆盖
用哈希表的不变量解释「可变字段不能进哈希」
- 定义与目的:把哈希表原理与可变类型结合起来,得到本讲最重要的实践结论。
- 直观解释(”它是什么?”):把一个
ArrayList放进HashSet:插入时它按当时的hashCode()落到某个桶里。随后修改这个列表(例如list.add("goodbye")),它的hashCode()变了,但HashSet不知道需要把它搬到别的桶。于是再也找不到它:set.contains(list)变为 false,而遍历集合时for (List<String> l : set) { set.contains(l); }又会发现集合自己的迭代器和自己的contains()互相矛盾——迭代器说元素在集合里,contains说不在。集合明显已经损坏。java.util.Set的规格里有一段名言:「如果把可变对象用作集合元素,必须极其小心。如果一个对象在集合中的期间被以影响equals比较的方式修改,集合的行为是未指定的。」 - 关键规则与最佳实践:
- 当
equals()/hashCode()会被修改影响时,把该对象用作哈希表键就会破坏哈希表的表示不变量。 - 实践准则(本讲的最终规则):需要放进
HashSet/HashMap的类型,要么不可变(覆盖equals/hashCode,基于抽象值),要么采用引用相等(不覆盖,继承Object的实现)。 - 如果一个可变类型确实需要「当前看起来一样」的判断,就把它做成独立的公开操作(
similar()/sameValue()),不要污染equals()。 - 另一种安全做法:放入哈希结构前先做不可变快照(
Set.copyOf、List.copyOf、Collections.unmodifiableList),并确保快照本身不再被修改。
- 当
深相等(Deep Equality)与测试断言
- 定义与目的:sp22 指出现代语言普遍缺少对
Array/Set/Map的标准「观察相等」操作,于是一些库提供了深相等操作,可以逐层拆解嵌套集合进行比较。它服务于「易理解」与测试便利,但必须小心使用。 - 直观解释(”它是什么?”):TS/JS 世界里,
Assert.deepStrictEqual()(Node)、isEqual()(Underscore/Lodash)能比较Array<Map<T,Set<U>>>这样的多层结构。Java 中的对应情况要更清楚一些(补充说明):数组的equals是引用(行为)相等,因此比较数组内容要用Arrays.equals(一维)或Arrays.deepEquals(多维/嵌套);集合类型(List、Set、Map)自己实现了观察相等,所以listA.equals(listB)会比较元素序列,setA.equals(setB)会比较元素集合;Objects.deepEquals则会在数组与普通对象之间分派到合适的实现。 - 关键规则与最佳实践:
- 深相等操作对内置集合有特殊处理(例如忽略
Map/Set的元素顺序),因此能正确比较这些集合的抽象值。 - 但对用户自定义类型,深相等操作只会盲目地逐字段比较 rep,完全不理解 AF。若 rep 是非规范化的(例如
Duration(0,60)与Duration(1,0)表示同一个抽象值),深相等会得出错误结论。 - 因此:当集合的叶子类型是基本类型(
number/string/boolean,Java 中即int/String/boolean等)时深相等是安全的;当叶子类型是自定义对象类型(如Duration、Bag)时,深相等的表现可能出人意料,应改用类型自己的equals(或预先定义好的比较操作)。 - 测试中要区分「引用相等断言」与「值相等断言」:
assert.strictEqual([1], [1])会失败(不同数组对象),assert.deepStrictEqual([1], [1])会成功;Java 中对应的是assertSame与assertEquals(assertEquals会调用equals)。
- 深相等操作对内置集合有特殊处理(例如忽略
Java 陷阱:自动装箱与相等的交互
- 定义与目的:Java 的基本类型与其包装类型(
int与Integer)之间会自动转换(autoboxing / autounboxing),而==对引用类型是引用相等、对基本类型是值相等——于是同一次比较的语义取决于编译期类型。 - 直观解释(”它是什么?”):
Integer x = new Integer(3); Integer y = new Integer(3);时,x.equals(y)为 true(Integer正确实现了值相等),但x == y为 false(引用相等);而(int)x == (int)y为 true(值相等)。更经典的例子是Map<String,Integer>:a.put(c, 130); b.put(c, 130);之后a.get(c) == b.get(c)的结果取决于自动装箱缓存(补充说明:Java 规范要求-128..127的装箱对象被缓存复用,超出该范围通常不会复用),而a.get(c).equals(b.get(c))永远为 true。 - 关键规则与最佳实践:
- 比较包装类型的值,永远用
equals()(或先拆箱成基本类型再用==),不要直接用==。 - 时刻清楚表达式的编译期类型:
130的编译期类型是int;放进Map<String,Integer>后由自动装箱变成Integer;a.get(c)的编译期类型是Integer。 - 用
Integer.valueOf的心智模型(而非new Integer,后者已废弃)理解装箱缓存,但不要依赖缓存的边界。 - 这条陷阱与 Java 的数组/
StringBuilder一样,属于「语言中相等语义不一致」的现实,写出正确代码的前提是明确每次比较用的是哪一种相等。
- 比较包装类型的值,永远用
代码示例与对比分析
场景 1:重载(overload)而不是覆盖(override)equals
❌ 错误代码
public class Duration {
private final int mins;
private final int secs;
// Rep invariant: mins >= 0, secs >= 0
// Abstraction function: AF(mins, secs) = the span of time of mins minutes and secs seconds
public Duration(int m, int s) { mins = m; secs = s; }
/** @return length of this duration in seconds */
public long getLength() { return (long)mins*60 + secs; }
// 错误:参数类型写成了 Duration,这不是覆盖,而是重载
public boolean equals(Duration that) {
return this.getLength() == that.getLength();
}
}
【错误代码的问题】
- 签名与
Object.equals(Object)不一致,因此这是一次重载:Duration中同时存在新写的equals(Duration)和从Object继承来的equals(Object)(后者做引用相等)。 - Java 在编译期按参数的静态类型选择重载版本,于是
d1.equals(d2)(实参静态类型为Duration)走新版本返回 true,而d1.equals(o2)(Object o2 = d2;,静态类型为Object)走继承版本返回 false——同一个对象、同一个方法名,结果不同,对称性与一致性都被破坏。 - 集合、映射、断言等一切通过
Object引用调用equals的代码(包括HashSet.contains、assertEquals)都会走错版本,于是「内容相同的对象」在集合里查不到。 - 这类错误极其常见,且编译器不会报错——除非你使用
@Override注解。
✅ 正确代码
public class Duration {
private final int mins;
private final int secs;
// Rep invariant: mins >= 0, secs >= 0
// Abstraction function: AF(mins, secs) = the span of time of mins minutes and secs seconds
public Duration(int m, int s) { mins = m; secs = s; }
/** @return length of this duration in seconds */
public long getLength() { return (long)mins*60 + secs; }
@Override
public boolean equals(Object that) {
// that instanceof Duration 在 that == null 时为 false,满足 x.equals(null) == false
return that instanceof Duration && this.sameValue((Duration)that);
}
// returns true iff this and that represent the same abstract value
private boolean sameValue(Duration that) {
return this.getLength() == that.getLength();
}
}
【为什么这样更好】 @Override 让编译器强制检查确实存在同签名的父类方法:如果签名写错(写成 equals(Duration)),编译器立刻报错,而不是悄悄生成一个重载版本。参数类型是 Object,因此无论调用方用什么静态类型引用,都会走到同一个实现,从而恢复「同一个抽象值 → 同一个答案」的性质。instanceof 检查既保证了类型安全(随后的强制转换是合法的),又顺带满足了 equals(null) 返回 false 的契约;真正的值比较被放进私有方法 sameValue(Duration),职责清晰、便于复用。
【代码对比解说】 这组对比的关键概念是重载 vs 覆盖:重载由编译期的静态类型决定(就像 / 在 int 与 double 之间选择整数除法或浮点除法),覆盖由运行期的动态分派决定。相等操作的本质是「抽象值之间的关系」,它必须与运行时对象绑定,因此绝不能依赖编译期类型。修复方式不是「改改参数类型试试」,而是「让签名与父类完全一致,并让编译器替你检查」——这也是为什么 6.031 把 @Override 当作强制性习惯。此外,把字段比较移入 sameValue 是一个重要模式:它让 equals 只负责「类型与非空」这类边界问题,而把「什么算同一个抽象值」集中到一个地方,将来修改相等的定义时只改一处。
【设计原则透视】 equals(Object) 的签名就是 Object 契约的一部分,属于 Reading 06/07 意义上的规格:客户端(HashSet、JUnit)只能通过 Object 引用来调用它。任何签名偏离都等于违反了规格的接口部分,即使实现逻辑本身正确。同时,instanceof 的使用限定体现了 Reading 12(Interfaces, Generics, Enums)中「用多态而不是运行时类型检查」的原则:instanceof 在面向对象设计中是坏味道,唯一被允许的例外就是实现 equals(getClass() 同样被禁止)。最后,AF(a) = AF(b) 这一判据直接落到了 sameValue 的实现里:比较的是 getLength()(抽象值的函数),而不是 mins/secs(rep 字段)——这保证了相等与 AF 一致(Duration(0,60) 与 Duration(1,0) 相等)。
场景 2:覆盖了 equals 却忘记覆盖(或写错)hashCode
❌ 错误代码
public class Person {
private final String firstName;
private final String lastName;
public Person(String first, String last) { firstName = first; lastName = last; }
@Override
public boolean equals(Object that) {
return that instanceof Person && this.sameValue((Person) that);
}
// returns true iff this and that represent the same abstract value
private boolean sameValue(Person that) {
return this.lastName.toUpperCase().equals(that.lastName.toUpperCase());
}
// 错误一:完全没有覆盖 hashCode —— 继承了基于内存地址的实现
// public int hashCode() { return super.hashCode(); } // 隐含行为
// 错误二(另一种常见写法):用了不参与相等判定的字段
// public int hashcode() { return firstName.hashCode() + lastName.hashCode(); }
}
【错误代码的问题】
- 未覆盖
hashCode时,equals相等的两个Person会有不同的哈希码(基于地址),违反 Object 契约:把它们放进HashSet,add(p2)之后集合里会出现两个「相等」的元素;用p2去map.get()也取不到以p1为键存进去的值。 - 第二种写法把方法名拼成了
hashcode(小写 c),这不是覆盖而是新增方法,Object.hashCode依然生效——课程原文特别提到曾有学生为此花了数小时定位 bug。缺少@Override注解是根本原因。 - 若用
firstName.hashCode() + lastName.hashCode()作为哈希码,则引入了不参与相等判定的字段:两个姓氏相同(忽略大小写)、名字不同的Person会equals为 true,却可能有不同的哈希码——同样违反契约。 - 任何违反契约的实现都会让
HashSet/HashMap/equals相关的测试断言出现「静默错误」:不会抛异常,只是结果不对,极难调试。
✅ 正确代码
import java.util.Objects;
public class Person {
private final String firstName;
private final String lastName;
public Person(String first, String last) { firstName = first; lastName = last; }
@Override
public boolean equals(Object that) {
return that instanceof Person && this.sameValue((Person) that);
}
// returns true iff this and that represent the same abstract value
private boolean sameValue(Person that) {
return this.lastName.equalsIgnoreCase(that.lastName);
}
@Override
public int hashCode() {
// 只使用参与相等判定的字段,并且用同一套“忽略大小写”的规范化方式
return lastName.toUpperCase().hashCode();
}
}
【为什么这样更好】 hashCode 只依赖参与相等判定的字段(lastName),并使用与 sameValue 一致的规范化规则(忽略大小写),于是「equals 相等 ⇒ hashCode 相等」必然成立,契约得到满足:相等的对象落在同一个桶里,HashSet 去重与 HashMap 查找都能按预期工作。加上 @Override 后,任何拼写或签名错误都会在编译期暴露。
【代码对比解说】 这组对比的核心是「equals 与 hashCode 必须基于同一组字段、同一套规范化规则」。lastName.toUpperCase().hashCode() 与 lastName.equalsIgnoreCase(...) 表达的是同一个判定口径;若 hashCode 用原始 lastName.hashCode(),则 "Smith" 与 "SMITH" 会 equals 为 true 而哈希码不同,契约立刻被破坏。另一种「合规但糟糕」的选项是 return 42;——它满足契约(相等对象哈希码相同),但所有键挤进同一个桶,查找从 O(1) 退化为 O(n)。还有一种选项是 return firstName.toUpperCase();,它连编译都通不过(String 不能作为 int 返回)。真正需要记住的规则只有一句:哈希码必须由抽象值决定,而不是由 rep 的一部分或与相等无关的字段决定。
【设计原则透视】 hashCode 是 AF 的另一个出口:它把抽象值映射为一个整数(Duration 直接 return (int) getLength(); 就是这个思想的最纯粹形式)。因此它天然与 Reading 11(AF/RI)绑定——如果 hashCode 依赖了 AF 之外的 rep 细节,就等于让哈希码携带了「抽象值无关的信息」,契约必然被破坏。同时,这也是 Reading 08(Immutability)的延伸:只有不可变类型才能保证哈希码在生命周期内稳定,因此「不可变类型覆盖 equals/hashCode」是一条可以无脑遵守的准则。Java 补充说明:多字段场景推荐 Objects.hash(f1, f2, ...),它内部使用与《Effective Java》一致的做法(以 31 为基的组合),避免手写时漏字段或算错。
场景 3:把可变对象当作哈希表的键,并在其间修改它
❌ 错误代码
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
public class BrokenSetDemo {
public static void main(String[] args) {
List<String> list = new ArrayList<>();
list.add("a");
Set<List<String>> set = new HashSet<>();
set.add(list); // 用 list 当时的 hashCode 决定桶位置
System.out.println(set.contains(list)); // true
list.add("goodbye"); // 修改 list —— 它的 hashCode 变了
// 但 HashSet 不知道要把它搬到别的桶
System.out.println(set.contains(list)); // false !元素还在集合里,却查不到
for (List<String> l : set) {
System.out.println(set.contains(l)); // false !迭代器与 contains 自相矛盾
}
}
}
【错误代码的问题】
List的equals/hashCode基于元素内容,因此会被 mutation 影响;插入时元素被放在与其当时哈希码对应的桶里,修改后哈希码改变,HashSet不会重新分桶,于是永远找不到这个元素。- 破坏的严重性在于「同一个集合的两个操作互相矛盾」:迭代器认为元素在集合中,
contains()认为不在——集合的表示不变量显然已被破坏(这类不一致会导致去重失败、内存泄漏式的「幽灵元素」、以及难以复现的测试失败)。 - 更糟糕的是,这种做法会产生静默错误而不是异常:程序继续运行,只是结果不对。
java.util.Set的规格明确规定:若对象在集合中期间被以影响equals比较的方式修改,集合行为未指定——也就是说,写入这种代码的客户端已经站到了「未定义行为」的地面上。
✅ 正确代码
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
public final class Point { // 不可变类型:安全地作为哈希键
private final int x;
private final int y;
// Abstraction function: AF(x, y) = 平面上的点 (x, y)
// Rep invariant: true
public Point(int x, int y) { this.x = x; this.y = y; }
public int getX() { return x; }
public int getY() { return y; }
@Override
public boolean equals(Object that) {
return that instanceof Point && this.sameValue((Point) that);
}
private boolean sameValue(Point that) {
return this.x == that.x && this.y == that.y;
}
@Override
public int hashCode() {
return 31 * x + y; // 只依赖 final 字段:哈希码永不改变
}
}
public class SafeSetDemo {
public static void main(String[] args) {
Set<Point> set = new HashSet<>();
Point p = new Point(1, 2);
set.add(p);
System.out.println(set.contains(new Point(1, 2))); // true:按抽象值查找
// 若确实需要用可变集合做键,就先做不可变快照:
List<String> list = new ArrayList<>();
list.add("a");
Set<List<String>> snapshotSet = new HashSet<>();
snapshotSet.add(List.copyOf(list)); // 不可变副本:哈希码不会再变
list.add("goodbye");
System.out.println(snapshotSet.size()); // 1
System.out.println(snapshotSet.contains(List.of("a"))); // true
}
}
【为什么这样更好】 Point 是不可变类型,其 equals/hashCode 都基于 final 字段,因此哈希码在对象整个生命周期内稳定,放在桶里的位置始终正确;contains(new Point(1,2)) 能按抽象值找到元素,这正是 ADT 相等应有的效果。可变集合若确实需要作为键,就先取一个不可变快照(List.copyOf)再放入,这样后续对原列表的修改不会影响哈希码,集合的不变量得以保持。
【代码对比解说】 两段代码的差别不在「写法技巧」,而在相等语义与可变性的组合。Point 采用的是不可变类型 + 覆盖 equals/hashCode(本讲的最终规则之一);ArrayList 采用的是观察相等且可变(Java 库的历史选择),两者组合起来就会破坏哈希表。真正要记住的判断顺序是:先问「这个类型会不会被放进 HashSet/HashMap(或用在需要稳定哈希的地方)」;如果会,就要求它的相等与哈希在生命周期内不变——要么让类型不可变,要么采用引用相等(不覆盖 equals),要么在放入前做不可变快照。Java 补充说明:List.copyOf/Set.copyOf(Java 10+)返回不可修改的快照,Collections.unmodifiableList 返回的是视图而非副本,两者在「是否仍随原对象变化」上不同,需要注意区分。
【设计原则透视】 这是 AF/RI 在本讲最生动的一次应用:哈希表的 RI 是「每个键都能从其哈希码对应的槽位出发被找到」,而「修改会改变 hashCode」直接违反这条 RI。换句话说,可变对象的观察相等会随时间变化,而哈希表要求相等关系在一段时间内稳定,两个合法设计放在一起就产生了不合法。由此得出本讲的核心结论:可变类型的 equals() 应实现行为相等(也就是不覆盖,继承 Object 的引用比较),从而保证「相等」这个关系在任何未来状态下都不变;如果确实需要「此刻看起来一样」的语义,就把它做成独立的公开操作(similar()/sameValue()),让它与 equals() 各司其职。这同时呼应 Reading 08(不可变性是可复用的安全基石)与 Reading 11(RI 必须被每个操作维护)。
场景 4:为「容差」放宽相等,破坏传递性
❌ 错误代码
public class Duration {
private final int mins;
private final int secs;
// Rep invariant: mins >= 0, secs >= 0
// Abstraction function: AF(mins, secs) = the span of time of mins minutes and secs seconds
public Duration(int m, int s) { mins = m; secs = s; }
public long getLength() { return (long)mins*60 + secs; }
private static final int CLOCK_SKEW = 5; // seconds
@Override
public boolean equals(Object that) {
return that instanceof Duration && this.sameValue((Duration) that);
}
// 错误:让“大致相等”也算相等
private boolean sameValue(Duration that) {
return Math.abs(this.getLength() - that.getLength()) <= CLOCK_SKEW;
}
}
【错误代码的问题】
- 破坏传递性:
d_0_57(57 秒)与d_1_00(60 秒)相差 3 秒,判定相等;d_1_00与d_1_03(63 秒)相差 3 秒,判定相等;但d_0_57与d_1_03相差 6 秒,判定不相等。于是「a = b、b = c,但 a ≠ c」。 - 传递性一旦失效,所有依赖相等的关系结构都会出错:
HashSet会出现「一个元素与集合中两个不同元素分别相等」的情形,去重结果依赖插入顺序;排序、查找、缓存命中判断都变得不可靠。 equals还要求一致性:容差比较本身在对象未变化时是稳定的,因此这里侥幸没破坏一致性,但传递性的破坏已经足够致命。- 这种「好心放宽相等」在工程中非常常见(浮点比较、时间戳比较、字符串模糊匹配),它们看起来都更「实用」,实际上都在削弱整个生态所依赖的形式契约。
✅ 正确代码
public class Duration {
private final int mins;
private final int secs;
// Rep invariant: mins >= 0, secs >= 0
// Abstraction function: AF(mins, secs) = the span of time of mins minutes and secs seconds
public Duration(int m, int s) { mins = m; secs = s; }
public long getLength() { return (long)mins*60 + secs; }
/** true iff this and that represent the same abstract value(精确的等价关系) */
@Override
public boolean equals(Object that) {
return that instanceof Duration && this.sameValue((Duration) that);
}
private boolean sameValue(Duration that) {
return this.getLength() == that.getLength(); // 精确比较:自反、对称、传递
}
@Override
public int hashCode() {
return (int) getLength();
}
/**
* 如果一个“宽容比较”确实有业务价值,把它作为独立操作提供,
* 而不是替换 equals —— 它不需要是等价关系。
* @param that 另一个 Duration
* @param tolerance 允许的秒数误差,要求 tolerance >= 0
* @return true iff 两者的时间跨度之差不超过 tolerance
*/
public boolean closeTo(Duration that, long tolerance) {
return Math.abs(this.getLength() - that.getLength()) <= tolerance;
}
}
【为什么这样更好】 equals 保持精确(getLength() 相等),因此它是一个真正的等价关系:自反(自己与自己差 0)、对称(绝对值)、传递(长度相等是等号,等号天然传递)。宽容比较被移到独立的公开操作 closeTo(that, tolerance) 中——它不必是等价关系(在参数 tolerance 的同一取值下,差不超过 tolerance 的关系其实仍是等价关系,但当不同调用使用不同 tolerance 时就是「伪相等」,绝不能冒充 equals)。这样做同时满足:契约不被破坏、语义清晰(读者一眼看出哪种比较更严格)、并且 hashCode 可以与 equals 保持一致。
【代码对比解说】 这组对比说明了一个通用原则:「相等」是形式契约,「相似」是应用需求,两者不能混为一谈。equals 被 HashSet、HashMap、List.contains、JUnit 断言等大量代码以「等价关系」为前提使用,任何放宽都必须保证三条性质仍然成立;而「在 5 秒内算一样」这种需求天然是有参数、有上下文的,因此它的正确归属是独立的操作(sp22 也正是在可变类型上采用同样的策略:把观察相等命名为 equalValue() 而不是 equals()/===)。附带一点:实现 equals 时也不应把「宽容」的理由建立在浮点误差上——如果类型内部使用 double,equals 仍然应当基于确定的字段比较,浮点容差属于数值算法的范畴。
【设计原则透视】 传递性通过 Math.abs(...) <= CLOCK_SKEW 被破坏这件事,本质上说明「相似」不是一个传递关系——它只是「距离有界」,而距离链条可以任意长。等价关系要求的是「二值判定 + 三条公理」,任何度量式的概念都必须先离散化(例如「把时间量化到分钟后再比较」)才能成为等价关系。这一点与 Reading 06/07 的规格写作直接相关:equals 的规格里写着「must be an equivalence relation」并不是修辞,而是可以逐条验证的义务;实现者的工作就是把抽象值的相等(由 AF 决定)忠实地映射成一个数学上合格的关系。
与其他设计原则的关联
- Reading 08(Immutability):本讲的最终规则几乎完全由不可变性决定——不可变类型应当覆盖
equals/hashCode(观察相等与行为相等一致,哈希码稳定);可变类型不应当覆盖(只有引用相等才能保证关系不随时间变化)。哈希表的破坏案例就是「可变 + 观察相等」的组合。 - Reading 10(Abstract Data Types):相等是 ADT 的一个操作,必须写进规格;客户端会根据规格期待「内容相同即相等」,因此缺失或错误的相等实现会让类型难以使用。
- Reading 11(Abstraction Functions & Rep Invariants):AF 是相等定义的基础(
AF(a) = AF(b)),hashCode是 AF 到整数的另一条出口;观察相等与 AF 相等的冲突,正是「observer 泄露了 rep 细节」的信号。 - Reading 06/07(Specifications / Designing Specs):
equals的前后置条件(等价关系、一致性、x.equals(null)为 false)与@param/@return的书写方式完全一致;把「宽容比较」放进equals是典型的规格腐化。 - Reading 12(Interfaces, Generics, Enums):
equals(Object)的签名与@Override是「接口/继承」机制的直接应用;instanceof的禁令与「用多态代替运行时类型检查」相关,唯一例外就是实现equals。 - Reading 03(Testing):测试断言的行为由
equals决定(Java 的assertEquals会调用equals,assertSame才比较引用);相等实现错误会让测试要么虚假通过、要么虚假失败。 - Reading 13(Debugging):
HashSet里「元素既在又不在」的矛盾是典型的静默失败;调试这类问题要先用切片与断言确认「相等/哈希契约是否被违反」,而在定位前不要靠猜。 - Reading 14(Recursion):递归遍历集合时经常需要判断「是否已访问过」,这依赖相等的正确实现;不可变类型作为
Set元素(例如Set<File>、Set<Point>)也让递归实现保持可重入。 - Reading 21(Concurrency):不可变且相等正确的类型可以安全地在多个线程之间共享作为键;而可变类型的观察相等在并发修改下会带来更严重的不一致(这也是库文档警告「可变元素 + 集合」的原因)。
关键要点
- 相等必须是等价关系:自反、对称、传递,三者缺一不可;「约等于」式的宽松相等不能充当
equals,要另设操作。 - 不可变类型按抽象值定义相等:覆盖
equals(Object)(加@Override、用instanceof、把字段比较放进sameValue),同时覆盖hashCode并只用参与相等的字段。 - 可变类型按引用定义相等:不要覆盖
equals/hashCode,让行为相等保证关系不随时间变化;确需观察相等时,提供独立的similar()/sameValue()操作。 hashCode契约与哈希结构的前提:equals相等则hashCode必须相等;哈希码必须由抽象值决定、在对象生命周期内稳定(因此不能依赖可变字段),否则HashSet/HashMap依赖的「键能从其哈希码对应的桶被找到」这条 RI 会被静默破坏。- 留意语言细节:Java 的
==在对象上是引用相等;数组与StringBuilder用行为相等而List/Set/Map用观察相等;包装类型比较永远用equals()。
常见陷阱与注意事项
equals签名或契约写错(重载、null、一致性) → 参数类型写成自己的类就变成重载,通过Object引用调用时走的是引用相等版本,HashSet/assertEquals全部失效;违反x.equals(null) == false(例如让new Duration(0,0).equals(null)返回 true)会立刻破坏对称性。必须写成equals(Object)并加@Override让编译器把关。- 覆盖
equals而不覆盖hashCode(或把方法名拼错、漏写@Override) →equals相等的对象哈希码不同,HashSet出现重复元素、HashMap查不到键;这类 bug 不抛异常、只出错结果。 hashCode使用不参与相等判定或可变的字段 → 前者破坏「相等 ⇒ 同哈希」,后者让对象进表后「消失」;正确做法是只用参与相等判定的字段,并保证其为final/不可变。- 把可变对象(如
ArrayList)放进HashSet/HashMap之后再修改它 → 元素无法再被contains找到,迭代器与contains互相矛盾;java.util.Set规格明确说明此时行为未指定。应改用不可变类型或放入不可变快照。 - 让
equals做模糊/容差比较,或用toString()实现equals→ 前者破坏传递性(差 5 秒 = 相等、差 10 秒 = 不相等),后者常常破坏对称性(duration.equals("1:30")为 true 而"1:30".equals(duration)为 false);宽容比较与字符串表示都不属于抽象值的相等。 - 用
==比较包装类型或数组/集合内容 →Integer的比较因装箱缓存而「有时对有时错」;数组的equals是引用相等,比较内容要用Arrays.equals/Arrays.deepEquals;集合比较用它们自己的equals。
思考题(带答案)
问题 1:Duration 的 rep 是 mins、secs(RI 为 mins >= 0, secs >= 0),AF 为「mins 分 secs 秒的时间跨度」,唯一的观察者是 getLength()。现有 d1 = new Duration(1, 2)、d2 = new Duration(1, 3)、d3 = new Duration(0, 62)、d4 = new Duration(1, 2)。请分别用「抽象函数定义」与「观察定义」判断哪些与 d1 相等,并解释为什么这两种定义应当一致。
答案:按抽象函数定义(a equals b 当且仅当 AF(a) = AF(b)):d1 与 d4 的 rep 完全相同,AF 必然相同;d3 = (0, 62) 表示的时间跨度与 d1 相同(都是 62 秒),因此按抽象值也相等——这正是 rep 允许 secs > 60(非规范化表示)导致的结果;d2 = (1, 3) 是 63 秒,显然不等。按观察定义(用规格中允许的观察者 getLength() 去区分):d1、d3、d4 的长度都是 62,无法被区分;d2 是 63,可以区分。两种定义给出相同的结论——这是设计正确的一个信号。它们之所以必须一致,是因为「抽象值」的意义正是「客户端通过规格操作所能感知到的一切」:如果两个对象 AF 相同却能被某个规格内的观察者区分,说明该观察者泄露了 rep 细节(Reading 11 的典型错误);反之,如果两个对象 AF 不同却完全无法区分,说明 AF 过于细化、或者规格缺少必要的观察者。反例可见 LetterSet:length()、first()、isAllLowercase() 观察的是构造时字符串的细节而非字母集合,把它们纳入观察者集合就会与 AF 相等冲突——正确做法是把这些操作从 ADT 中去掉。
问题 2:下面的 equals 与 hashCode 有哪些问题?请逐条指出并给出修正后的代码。
public class Person {
private String firstName;
private String lastName;
public Person(String f, String l) { firstName = f; lastName = l; }
@Override public boolean equals(Object that) {
return that instanceof Person && this.sameValue(that);
}
private boolean sameValue(Person that) {
return this.lastName.toUpperCase().equals(that.lastName.toUpperCase());
}
@Override public int hashCode() {
return firstName.hashCode() + lastName.hashCode();
}
public void setLastName(String l) { lastName = l; }
}
答案:问题一:hashCode 用了 firstName,而 sameValue 忽略 firstName,于是两个 Person("Alice","Smith") 与 Person("Bob","Smith") 会 equals 为 true 却可能有不同的哈希码,违反 Object 契约(equals 相等 ⇒ hashCode 必须相等)。问题二:hashCode 用的是原始大小写的 lastName,而 sameValue 忽略大小写,于是 Person("A","smith") 与 Person("B","SMITH") 相等但哈希码不同——必须使用与 equals 相同的规范化规则。问题三:字段 firstName/lastName 不是 final,并且存在 mutator setLastName,因此这个类型是可变的:一旦对象被放进 HashSet/HashMap 后再调用 setLastName,它的哈希码(以及相等性)都会改变,元素将无法再被找到。修正方向有两个:要么把类型变成不可变(字段 final、去掉 setLastName),并让 equals/hashCode 基于同一组字段与同一套规范化规则:
public final class Person {
private final String firstName;
private final String lastName;
public Person(String first, String last) { firstName = first; lastName = last; }
@Override public boolean equals(Object that) {
return that instanceof Person && this.sameValue((Person) that);
}
private boolean sameValue(Person that) {
return this.lastName.equalsIgnoreCase(that.lastName);
}
@Override public int hashCode() {
return lastName.toUpperCase().hashCode();
}
}
要么保持可变,但不覆盖 equals/hashCode(采用行为相等 = 引用相等),另外提供一个公开的观察相等操作(如 similar(Person that))供需要「当前看起来一样」的客户端使用。后一种选择是本讲对可变类型的推荐做法。
问题 3:为什么「把 ArrayList 放进 HashSet 之后再修改它」会让 set.contains(list) 从 true 变成 false,甚至让集合的迭代器与 contains() 互相矛盾?请结合哈希表的表示不变量解释,并说明两条正确的做法。
答案:HashSet 内部是哈希表:插入时先算键的 hashCode(),据此决定数组槽位(桶),把键放进那个桶;查找时同样先算哈希码定位桶,再沿桶用 equals 逐个比较。ArrayList 的 equals/hashCode 基于元素内容(这是 Java 库对可变集合采用的观察相等),因此修改列表(list.add("goodbye"))会改变它的 hashCode。但 HashSet 只在插入时用当时的哈希码决定桶位置,它不会在元素自身变化后重新分桶。于是对象仍留在旧桶里,而 contains 会去新哈希码对应的桶里找,什么也找不到,返回 false;同时迭代器是沿着整个数组(所有桶)扫描的,所以它仍然能看到这个「幽灵元素」,两者结论矛盾——哈希表的表示不变量(键必须能从其哈希码决定的槽位被找到)被破坏了。java.util.Set 的规格对此有明确警告:若对象在集合中期间被以影响 equals 比较的方式修改,集合的行为未指定。两条正确做法:① 让作为键的类型不可变(例如本讲的 Point:final 字段 + 基于抽象值的 equals/hashCode),这样哈希码在生命周期内稳定;② 若必须使用可变结构,就在放入前取不可变快照(List.copyOf(list) / Set.copyOf(set)),或改用不覆盖 equals/hashCode 的引用相等语义(可变类型的推荐做法),并避免在元素进入哈希结构后再修改它。
