Reading 15: 相等性(Equality)

目录 · ← l14 · l16 →

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 == tt == u ⇒ u == tt == u ∧ u == v ⇒ t == v。它保证任何依赖相等的操作(集合成员判断、列表查找、去重、测试断言)行为一致、可预测。
  • 直观解释(”它是什么?”):物理世界里每个对象都是独特的(两片「相同」的雪花在空间位置上终究不同),但它们只有「相似程度」;而数学与语言世界里可以有多个名字指同一个事物——1+2√93 是同一个理想数学值的三种写法。等价关系就是「同一个事物」这个概念必须满足的形式条件:自己和自己一样(自反)、跟别人一样就是互相一样(对称)、一样的东西链条不会断裂(传递)。
  • 关键规则与最佳实践
    • 相等操作永远必须是等价关系,否则会产生「令人惊讶的行为与 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 插入集合两次,如果 x5,集合长度为 1(正常去重);但如果 xNone,因为 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 为 minssecs,AF 为「mins 分 secs 秒的时间跨度」,唯一观察者是 getLength())为例:d1=(1,2)d4=(1,2) 的 rep 完全相同,AF 必然相同;d3=(0,62) 表示的时间跨度与 d1 相同,按 AF 定义也相等(这正是 rep 允许 secs > 60 造成的非规范化表示);d2=(1,3) 不等。换成观察视角,getLength()d1d3d4 都返回 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 与观察粒度匹配,要么把能区分直线的观察者补进规格。
  • 小结:观察相等与 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()」。

Object 契约(The Object Contract)

  • 定义与目的Object 的规格如此重要,以至于被称为 Object 契约。它规定了覆盖 equals() 时必须满足的四个条件:equals 必须定义等价关系(自反、对称、传递);equals 必须一致(consistent)——只要对象没有被以影响比较的方式改变,重复调用必须得到相同结果;对非 null 引用 xx.equals(null) 必须返回 falsehashCodeequals 判定相等的一对对象必须返回相同结果
  • 直观解释(”它是什么?”):把它理解为 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()@Overrideinstanceof、私有 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 Durationthat == 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。用只读操作(lengthget(0)…)去比较,三者当前看起来一样;但一旦执行 arrayA.set(0, 0)arrayB.clear()arrayAarrayB 就会分道扬镳——因此它们只在「此刻」相等。而 arrayBarrayC 指向同一个对象,任何 mutator 都会同时影响两者,因此它们在一切未来状态下都相等。
  • 关键规则与最佳实践
    • 不可变类型,观察相等与行为相等完全相同(没有 mutator 能改变状态),因此只需要一个相等操作。
    • 可变类型,两种相等都有用:Java 用 == 提供行为相等;观察相等则通过一个单独的操作提供(sp22 命名约定是 equalValue();Java 中若需要,建议命名为 similar()sameValue(),作为公开操作)。
    • 实现方案:不要equals() 承载可变类型的观察相等。Java 库对可变类型混用了两种语义(ListSetMap 采用观察相等,StringBuilder 与数组采用行为相等),这是一个历史遗留的不一致,不应模仿。
    • 关键风险:观察相等随时间变化,因此把这种对象放进依赖哈希的结构(HashSetHashMap)会破坏其表示不变量(见场景 3)。
    • Bag(多重集)例子可以清晰区分两者:b1 = {a,b}b2 = {a,b}b3 = b1.remove("b") 后为 {a}b4 = {b,a}。行为相等下只有 b1 == b4 不成立(它们是不同对象)、而每个对象只与自身相等;观察相等下 b1b2b4 三者两两相等(count 结果相同),b3 与它们都不等。

hashCode() 契约与正确实现

  • 定义与目的SetMap 的实现(HashSetHashMap)基于哈希表(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.copyOfList.copyOfCollections.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(多维/嵌套);集合类型(ListSetMap)自己实现了观察相等,所以 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 等)时深相等是安全的;当叶子类型是自定义对象类型(如 DurationBag)时,深相等的表现可能出人意料,应改用类型自己的 equals(或预先定义好的比较操作)。
    • 测试中要区分「引用相等断言」与「值相等断言」:assert.strictEqual([1], [1]) 会失败(不同数组对象),assert.deepStrictEqual([1], [1]) 会成功;Java 中对应的是 assertSameassertEqualsassertEquals 会调用 equals)。

Java 陷阱:自动装箱与相等的交互

  • 定义与目的:Java 的基本类型与其包装类型(intInteger)之间会自动转换(autoboxing / autounboxing),而 == 对引用类型是引用相等、对基本类型是值相等——于是同一次比较的语义取决于编译期类型
  • 直观解释(”它是什么?”)Integer x = new Integer(3); Integer y = new Integer(3); 时,x.equals(y)trueInteger 正确实现了值相等),但 x == yfalse(引用相等);而 (int)x == (int)ytrue(值相等)。更经典的例子是 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> 后由自动装箱变成 Integera.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();
    }
}

【错误代码的问题】

  1. 签名与 Object.equals(Object) 不一致,因此这是一次重载Duration 中同时存在新写的 equals(Duration) 和从 Object 继承来的 equals(Object)(后者做引用相等)。
  2. Java 在编译期按参数的静态类型选择重载版本,于是 d1.equals(d2)(实参静态类型为 Duration)走新版本返回 true,而 d1.equals(o2)Object o2 = d2;,静态类型为 Object)走继承版本返回 false——同一个对象、同一个方法名,结果不同,对称性与一致性都被破坏。
  3. 集合、映射、断言等一切通过 Object 引用调用 equals 的代码(包括 HashSet.containsassertEquals)都会走错版本,于是「内容相同的对象」在集合里查不到。
  4. 这类错误极其常见,且编译器不会报错——除非你使用 @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 覆盖:重载由编译期的静态类型决定(就像 /intdouble 之间选择整数除法或浮点除法),覆盖由运行期的动态分派决定。相等操作的本质是「抽象值之间的关系」,它必须与运行时对象绑定,因此绝不能依赖编译期类型。修复方式不是「改改参数类型试试」,而是「让签名与父类完全一致,并让编译器替你检查」——这也是为什么 6.031 把 @Override 当作强制性习惯。此外,把字段比较移入 sameValue 是一个重要模式:它让 equals 只负责「类型与非空」这类边界问题,而把「什么算同一个抽象值」集中到一个地方,将来修改相等的定义时只改一处。

【设计原则透视】 equals(Object) 的签名就是 Object 契约的一部分,属于 Reading 06/07 意义上的规格:客户端(HashSet、JUnit)只能通过 Object 引用来调用它。任何签名偏离都等于违反了规格的接口部分,即使实现逻辑本身正确。同时,instanceof 的使用限定体现了 Reading 12(Interfaces, Generics, Enums)中「用多态而不是运行时类型检查」的原则:instanceof 在面向对象设计中是坏味道,唯一被允许的例外就是实现 equalsgetClass() 同样被禁止)。最后,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(); }
}

【错误代码的问题】

  1. 未覆盖 hashCode 时,equals 相等的两个 Person 会有不同的哈希码(基于地址),违反 Object 契约:把它们放进 HashSetadd(p2) 之后集合里会出现两个「相等」的元素;用 p2map.get() 也取不到以 p1 为键存进去的值。
  2. 第二种写法把方法名拼成了 hashcode(小写 c),这不是覆盖而是新增方法,Object.hashCode 依然生效——课程原文特别提到曾有学生为此花了数小时定位 bug。缺少 @Override 注解是根本原因。
  3. 若用 firstName.hashCode() + lastName.hashCode() 作为哈希码,则引入了不参与相等判定的字段:两个姓氏相同(忽略大小写)、名字不同的 Personequals 为 true,却可能有不同的哈希码——同样违反契约。
  4. 任何违反契约的实现都会让 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 后,任何拼写或签名错误都会在编译期暴露。

【代码对比解说】 这组对比的核心是「equalshashCode 必须基于同一组字段、同一套规范化规则」。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 自相矛盾
        }
    }
}

【错误代码的问题】

  1. Listequals/hashCode 基于元素内容,因此会被 mutation 影响;插入时元素被放在与其当时哈希码对应的桶里,修改后哈希码改变,HashSet 不会重新分桶,于是永远找不到这个元素。
  2. 破坏的严重性在于「同一个集合的两个操作互相矛盾」:迭代器认为元素在集合中,contains() 认为不在——集合的表示不变量显然已被破坏(这类不一致会导致去重失败、内存泄漏式的「幽灵元素」、以及难以复现的测试失败)。
  3. 更糟糕的是,这种做法会产生静默错误而不是异常:程序继续运行,只是结果不对。
  4. 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;
    }
}

【错误代码的问题】

  1. 破坏传递性d_0_57(57 秒)与 d_1_00(60 秒)相差 3 秒,判定相等;d_1_00d_1_03(63 秒)相差 3 秒,判定相等;但 d_0_57d_1_03 相差 6 秒,判定不相等。于是「a = b、b = c,但 a ≠ c」。
  2. 传递性一旦失效,所有依赖相等的关系结构都会出错:HashSet 会出现「一个元素与集合中两个不同元素分别相等」的情形,去重结果依赖插入顺序;排序、查找、缓存命中判断都变得不可靠。
  3. equals 还要求一致性:容差比较本身在对象未变化时是稳定的,因此这里侥幸没破坏一致性,但传递性的破坏已经足够致命。
  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; }

    /** 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 保持一致。

【代码对比解说】 这组对比说明了一个通用原则:「相等」是形式契约,「相似」是应用需求,两者不能混为一谈equalsHashSetHashMapList.contains、JUnit 断言等大量代码以「等价关系」为前提使用,任何放宽都必须保证三条性质仍然成立;而「在 5 秒内算一样」这种需求天然是有参数、有上下文的,因此它的正确归属是独立的操作(sp22 也正是在可变类型上采用同样的策略:把观察相等命名为 equalValue() 而不是 equals()/===)。附带一点:实现 equals 时也不应把「宽容」的理由建立在浮点误差上——如果类型内部使用 doubleequals 仍然应当基于确定的字段比较,浮点容差属于数值算法的范畴。

【设计原则透视】 传递性通过 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 会调用 equalsassertSame 才比较引用);相等实现错误会让测试要么虚假通过、要么虚假失败。
  • 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()

常见陷阱与注意事项

  1. equals 签名或契约写错(重载、null、一致性) → 参数类型写成自己的类就变成重载,通过 Object 引用调用时走的是引用相等版本,HashSet/assertEquals 全部失效;违反 x.equals(null) == false(例如让 new Duration(0,0).equals(null) 返回 true)会立刻破坏对称性。必须写成 equals(Object) 并加 @Override 让编译器把关。
  2. 覆盖 equals 而不覆盖 hashCode(或把方法名拼错、漏写 @Overrideequals 相等的对象哈希码不同,HashSet 出现重复元素、HashMap 查不到键;这类 bug 不抛异常、只出错结果。
  3. hashCode 使用不参与相等判定或可变的字段 → 前者破坏「相等 ⇒ 同哈希」,后者让对象进表后「消失」;正确做法是只用参与相等判定的字段,并保证其为 final/不可变。
  4. 把可变对象(如 ArrayList)放进 HashSet/HashMap 之后再修改它 → 元素无法再被 contains 找到,迭代器与 contains 互相矛盾;java.util.Set 规格明确说明此时行为未指定。应改用不可变类型或放入不可变快照。
  5. equals 做模糊/容差比较,或用 toString() 实现 equals → 前者破坏传递性(差 5 秒 = 相等、差 10 秒 = 不相等),后者常常破坏对称性(duration.equals("1:30") 为 true 而 "1:30".equals(duration) 为 false);宽容比较与字符串表示都不属于抽象值的相等。
  6. == 比较包装类型或数组/集合内容Integer 的比较因装箱缓存而「有时对有时错」;数组的 equals 是引用相等,比较内容要用 Arrays.equals/Arrays.deepEquals;集合比较用它们自己的 equals

思考题(带答案)

问题 1Duration 的 rep 是 minssecs(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)):d1d4 的 rep 完全相同,AF 必然相同;d3 = (0, 62) 表示的时间跨度与 d1 相同(都是 62 秒),因此按抽象值也相等——这正是 rep 允许 secs > 60(非规范化表示)导致的结果;d2 = (1, 3) 是 63 秒,显然不等。按观察定义(用规格中允许的观察者 getLength() 去区分):d1d3d4 的长度都是 62,无法被区分;d2 是 63,可以区分。两种定义给出相同的结论——这是设计正确的一个信号。它们之所以必须一致,是因为「抽象值」的意义正是「客户端通过规格操作所能感知到的一切」:如果两个对象 AF 相同却能被某个规格内的观察者区分,说明该观察者泄露了 rep 细节(Reading 11 的典型错误);反之,如果两个对象 AF 不同却完全无法区分,说明 AF 过于细化、或者规格缺少必要的观察者。反例可见 LetterSetlength()first()isAllLowercase() 观察的是构造时字符串的细节而非字母集合,把它们纳入观察者集合就会与 AF 相等冲突——正确做法是把这些操作从 ADT 中去掉。

问题 2:下面的 equalshashCode 有哪些问题?请逐条指出并给出修正后的代码。

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 逐个比较。ArrayListequals/hashCode 基于元素内容(这是 Java 库对可变集合采用的观察相等),因此修改列表(list.add("goodbye"))会改变它的 hashCode。但 HashSet 只在插入时用当时的哈希码决定桶位置,它不会在元素自身变化后重新分桶。于是对象仍留在旧桶里,而 contains 会去新哈希码对应的桶里找,什么也找不到,返回 false;同时迭代器是沿着整个数组(所有桶)扫描的,所以它仍然能看到这个「幽灵元素」,两者结论矛盾——哈希表的表示不变量(键必须能从其哈希码决定的槽位被找到)被破坏了。java.util.Set 的规格对此有明确警告:若对象在集合中期间被以影响 equals 比较的方式修改,集合的行为未指定。两条正确做法:① 让作为键的类型不可变(例如本讲的 Pointfinal 字段 + 基于抽象值的 equals/hashCode),这样哈希码在生命周期内稳定;② 若必须使用可变结构,就在放入前取不可变快照List.copyOf(list) / Set.copyOf(set)),或改用不覆盖 equals/hashCode 的引用相等语义(可变类型的推荐做法),并避免在元素进入哈希结构后再修改它。