如何在 Java 中编写 equals 等价方法
总结
本文描述了一种重写 equals
方法的技术,该方法即使在具体类的子类添加新字段时也能保留 equals
的约定。
子类化时保留 equals
contract 的是“面向对象语言中等价关系的基本问题”。
除非你愿意放弃面向对象抽象的好处,否则无法在保留
equals
契约的同时扩展可实例化类并添加值组件。
《Scala 编程》第 28 章展示了一种方法,允许子类扩展可实例化类,添加一个值组件,但保留 equals
契约。尽管该技术在书中是在定义 Scala 类的语境中描述的,但它也适用于 Java 中定义的类。在本文中,我们使用改编自 Scala 编程相关章节的文本来介绍这项技术,但使用了从 Scala 翻译成 Java 的代码示例。
常见的等价陷阱
java.lang.Object
类定义了一个 equals
方法,子类可以重写该方法。不幸的是,事实证明,在面向对象的语言中,编写正确的等价方法是非常困难的。事实上,在研究了大量 Java 代码后,2007 年一篇论文的作者得出结论,几乎所有 equals
方法的实现都是错误的。
这是有问题的,因为等价是许多其他事情的基础。首先,类型 C
等价方法带来的错误可能意味着无法可靠地将 C 类型的对象放入集合中。你可能有两个类型为 C 的元素 elem1
、elem2
是相等的,即 elem1.equals(elem2)
生成 true
。尽管如此,对于常见的 equals
方法的错误实现,你仍然可以看到如下行为:
Set<C> hashSet = new java.util.HashSet<C>();
hashSet.add(elem1);
hashSet.contains(elem2); // returns false!
以下是四个常见的陷阱,它们可能会在重写 equals
时导致不一致的行为:
- 使用错误的签名定义
equals
。 - 修改
equals
而没有修改hashCode
。 - 按照可变字段定义
equals
。 - 未能将
equals
定义为等价关系。
陷阱 1: 使用错误的签名定义 equals
假设我们将向下例中的 Point 类添加 equals
方法:
public class Point {
private final int x;
private final int y;
public Point(int x, int y) {
this.x = x;
this.y = y;
}
public int getX() {
return x;
}
public int getY() {
return y;
}
// ...
}
一种看似明显但错误的方式是这样定义它:
// An utterly wrong definition of equals
public boolean equals(Point other) {
return (this.getX() == other.getX() && this.getY() == other.getY());
}
该方法有什么问题?乍一看,它似乎可以正常工作:
Point p1 = new Point(1, 2);
Point p2 = new Point(1, 2);
Point q = new Point(2, 3);
System.out.println(p1.equals(p2)); // prints true
System.out.println(p1.equals(q)); // prints false
然而,当你将其放入到集合时就会出现问题:
import java.util.HashSet;
HashSet<Point> coll = new HashSet<Point>();
coll.add(p1);
System.out.println(coll.contains(p2)); // prints false
在 p1
被添加到其中、并且 p1
和 p2
是相等的对象时,coll
怎么会不包含 p2
?原因在以下交互中变得很清楚,其中一个比较点的精确类型被掩盖。使用类型 Object
而不是 Point
,将 p2a
定义为 p2
的别名:
Object p2a = p2;
现在重复第一个比较,使用别名 p2a
而非 p2
,你会发现:
System.out.println(p1.equals(p2a)); // prints false
哪里错了呢?事实上,前面给出的 equals
版本并没有重写标准方法 equals
,因为它的类型不同。以下是在根类 Object
中定义的 equals
方法的类型:
public boolean equals(Object other)
因为 Point
中的 equals
方法使用 Point
而不是 Object
作为参数,所以它不会覆盖 Object
中的 equals
。相反,它只是一个重载的的替代方案。Java 中的重载是由参数的静态类型而不是运行时类型来解决的。因此,只要参数的静态类型是 Point
,就会调用 Point
中的 equals
方法。但是,一旦静态参数的类型为 Object
,就会调用 Object
中的 equals
方法。此方法尚未被重写,因此它仍然是通过比较对象标识来实现的。这就是为什么即使点 p1
和 p2a
具有相同的 x
和 y
值,比较 “p1.equals(p2a)
” 也会产生错误。这也是为什么 HashSet
中的 contains
方法返回 false
的原因。由于该方 法在泛型集上操作,因此它调用 Object
中的泛型 equals
方法,而不是 Point
中的重载变体。
改进后的 equals
方法如下:
// A better definition, but still not perfect
@Override public boolean equals(Object other) {
boolean result = false;
if (other instanceof Point) {
Point that = (Point) other;
result = (this.getX() == that.getX() && this.getY() == that.getY());
}
return result;
}
现在 equals
使用了正确的类型。它将 Object
类型的值作为参数并生成 boolean
结果。该方法的实现使用 instanceof
并进行强制转换。它先检测 other
对象是否也是 Point
类型。如果是,比较这两个点的坐标并返回结果。否则,结果为 false
。
陷阱 2: 修改 equals
而没有修改 hashCode
如果将 p1
和p2a
与之前定义的 Point
的最新定义重复比较,你将得到 true
,如预期的那样。但是,如果你重复 HashSet.contains
测试,可能仍然会得到 false
:
Point p1 = new Point(1, 2);
Point p2 = new Point(1, 2);
HashSet<Point> coll = new HashSet<Point>();
coll.add(p1);
System.out.println(coll.contains(p2)); // prints false (probably)
事实上,结果不是 100% 确定的。你也可能从实验中得到 true
结果。如果是这样,你可以不断尝试使用坐标为 1 和 2 的其他 Point
。最终,你会得到一个不包含在集合中的点。这里出现错误是因为 Point
重新定义了 equals
,而没有重新定义 hashCode
。
请注意,上面示例中的集合是一个 HashSet
。这意味着集合的元素被放入由其哈希代码决定的“哈希桶(hash bucket)”中。contains
首先检测要查找的哈希桶,然后将给定的元素与该桶中的所有元素进行比较。现在,Point
类的最后一个版本确实重新定义了 equals
,但它并没有同时重新定义hashCode
。所以 hashCode
仍然是 Object
类中的版本:对所分配对象的地址进行一些转换。p1
和 p2
的哈希代码(hash code)几乎可以肯定是不同的,即使这两个 Point
的字段是相同的。不同的哈希代码意味着大概率处于集合中不同的哈希桶中。contains
检测将在其 bucket
中查找与 p2
的哈希代码相对应的匹配元素。在大多数情况下,点 p1
将在另一个桶中,因此永远不会找到它。p1
和 p2
也可能偶然出现在同一散列桶中。在这种情况下,测试将返回 true
。
问题是 Point
的最后一次实现违反了 Object
类上的 hashCode
contract :
如果根据
equals(Object)
方法,两个对象相等,那么对这两个对象中的每一个调用hashCode
方法必须产生相同的整型结果。
事实上,Java 中 hashCode
和 equals
应该同时重定义是众所周知的事情。此外,hashCode
可能只依赖于 equals
所依赖的字段。对于 Point
类,以下是 hashCode
的合适定义:
public class Point {
private final int x;
private final int y;
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 other) {
boolean result = false;
if (other instanceof Point) {
Point that = (Point) other;
result = (this.getX() == that.getX() && this.getY() == that.getY());
}
return result;
}
@Override public int hashCode() {
return (41 * (41 + getX()) + getY());
}
}
这只是 hashCode
的许多可能实现之一。将常数 41
与整型字段 x
相加,将结果与素数 41
相乘,并将另一整型字段 y
与该结果相加,在运行时间和代码大小方面以低成本给出了哈希码的合理分布。
添加 hashCode
可以解决定义类似于 Point
类的类时相等的问题。然而,仍有其他问题需要注意。
陷阱 3: 根据可变字段定义 equals
对 Point
类做如下微调:
public class Point {
private int x;
private int y;
public Point(int x, int y) {
this.x = x;
this.y = y;
}
public int getX() {
return x;
}
public int getY() {
return y;
}
public void setX(int x) { // Problematic
this.x = x;
}
public void setY(int y) {
this.y = y;
}
@Override public boolean equals(Object other) {
boolean result = false;
if (other instanceof Point) {
Point that = (Point) other;
result = (this.getX() == that.getX() && this.getY() == that.getY());
}
return result;
}
@Override public int hashCode() {
return (41 * (41 + getX()) + getY());
}
}
此处唯一的不同是字段 x
和 y
不再是 final,并且添加了两个 set
方法,以允许客户端修改 x
和 y
的值。现在 equals
和 hashCode
方法依据可修改的字段定义,因此字段改变时,结果它们的结果也会产生变化。一旦将点放入集合中,这可能会产生奇怪的效果:
Point p = new Point(1, 2);
HashSet<Point> coll = new HashSet<Point>();
coll.add(p);
System.out.println(coll.contains(p)); // prints true
现在,如果更改点 p
中的字段,那么集合是否仍包含该点?我们将尝试:
p.setX(p.getX() + 1);
System.out.println(coll.contains(p)); // prints false (probably)
这看起来很奇怪。p
去哪儿了?如果检查集合的迭代器是否包含 p
:
Iterator<Point> it = coll.iterator();
boolean containedP = false;
while (it.hasNext()) {
Point nextP = it.next();
if (nextP.equals(p)) {
containedP = true;
break;
}
}
System.out.println(containedP); // prints true
这里有一个不包含(contains) p
的集合,但是 p
在这个集合的元素中!当然,发生的事情是,在对 x
字段进行更改后,点 p
最终出现在集合 coll
的错误哈希桶中。也就是说,其原始哈希桶不再与其哈希代码的新值相对应。从某种意义上说,点 p
在集合 coll
中“消失在视线之外”,尽管它仍然属于它的元素。
从这个例子中可以学到的教训是,当 equals
和 hashCode
依赖于可变状态时,可能会给用户带来问题。如果将这样的对象放入集合中,必须谨慎,永远不要修改其依赖的状态,这很棘手。如果比较需要将对象的当前状态考虑在内,通常应该将其命名为其他名称,而不是 equals
。考虑到 Point
的最后一个定义,最好忽略 hashCode
的重新定义,并将比较方法命名为 equalContents
,或其他与 equals
不同的名称。Point
将继承 equals
和 hashCode
的默认实现。因此,即使在修改了它的 x
字段之后,p
也会在 coll
中保持可定位。
陷阱 4: 未能将 equals
定义为等价关系
Object
中 equals
方法的约定指定 equals
必须在非 null
对象上实现等价关系
- 它是自反的:对于任何非
null
值x
,表达式x.equals(x)
都应该返回true
。- 它是对称的:对于任何非
null
值x
和y
, 当且仅当y.equals(x)
返回true
时,x.equals(y)
应返回true
。- 它是可传递的:对于任何非
null
值x
、y
和z
,如果x.equals(y)
返回true
,y.equals(z)
返回true
,则x.equals(z)
也应返回true
。- 它是一致的:对于任何非
null
值x
和y
,如果不修改对象上的equals
比较中使用的信息,则多次调用x.equals(y)
应一致返回true
或一致返回false
。- 对于所有非
null
值x
,x.equals(null)
应该返回false
。
到目前为止,为 Point
类开发的 equals
定义满足了 equals
的约定。然而,一旦考虑到子类,事情就会变得更加复杂。假设有一个 Point
的子类ColoredPoint
,它添加了 color
类型的字段 color
。假设 Color
被定义为枚举:
public enum Color {
RED, ORANGE, YELLOW, GREEN, BLUE, INDIGO, VIOLET;
}
ColoredPoint
重写了 equals
以将新增的 color
字段考虑在内:
public class ColoredPoint extends Point { // Problem: equals not symmetric
private final Color color;
public ColoredPoint(int x, int y, Color color) {
super(x, y);
this.color = color;
}
@Override public boolean equals(Object other) {
boolean result = false;
if (other instanceof ColoredPoint) {
ColoredPoint that = (ColoredPoint) other;
result = (this.color.equals(that.color) && super.equals(that));
}
return result;
}
}
这是许多编程人员可能编写的代码。请注意,这种情况下,ColoredPoint
类需要重写 hashCode
。因为 ColoredPoint
上新定义的 equals
比 Point
上的重写定义更严格,hashCode
的约定仍然有效。如果两个 colored 点相等,它们一定拥有相同的坐标,因此,它们的哈希代码也会保证相同。
以 ColoredPoint
类本身为例,它对 equals
的定义看起来还可以。然而,一旦点和有色点混合,equals
的契约就被打破了。比如:
Point p = new Point(1, 2);
ColoredPoint cp = new ColoredPoint(1, 2, Color.RED);
System.out.println(p.equals(cp)); // prints true
System.out.println(cp.equals(p)); // prints false
比较“ p
等于 cp
”调用 p
的 equals
方法,该方法在 Point
类中定义。此方法只考虑两个点的坐标。因此,比较结果是 true
。另一方面,比较“ cp
等于(equals) p
”调用 cp
的 equals
方法,该方法在 ColoredPoint
类中定义。此方法返回 false
,因为 p
不是 ColoredPoint
。所以 equals
定义的关系不是对称的。
对称性的丢失可能会对集合产生意想不到的后果。以下是一个示例:
Set<Point> hashSet1 = new java.util.HashSet<Point>();
hashSet1.add(p);
System.out.println(hashSet1.contains(cp)); // prints false
Set<Point> hashSet2 = new java.util.HashSet<Point>();
hashSet2.add(cp);
System.out.println(hashSet2.contains(p)); // prints true
因此,即使 p
和 cp
相等,其中一个 contains
检测成功,而另一个则失败。
如何更改 equals
的定义,使其成为对称的?本质上有两种方式。你可以使这种关系更同样,也可以使之更严格。使其更通用意味着,如果将 a
与 b
进行比较或将 b
与 a
进行比较结果为 true
,则将两个对象 a
和 b
视为相等。以下是执行此操作的代码:
public class ColoredPoint extends Point { // Problem: equals not transitive
private final Color color;
public ColoredPoint(int x, int y, Color color) {
super(x, y);
this.color = color;
}
@Override public boolean equals(Object other) {
boolean result = false;
if (other instanceof ColoredPoint) {
ColoredPoint that = (ColoredPoint) other;
result = (this.color.equals(that.color) && super.equals(that));
}
else if (other instanceof Point) {
Point that = (Point) other;
result = that.equals(this);
}
return result;
}
}
ColoredPoint
中新定义的 equals
比旧定义多检查一种情况:如果另一个对象是 Point
而不是 ColoredPoint
,则该方法将转发到 Point
的 equals
方法。这具有使 equals
对称的预期效果。现在,“cp.equals(p)
” 和 “p.equals(cp)
” 的结果都为 true
。然而,equals
的约定仍然被打破。现在的问题是,新的关系不再是可传递的!以下是一系列说明这一点的语句。在同一位置上定义一个点以及两个不同颜色的彩色点:
ColoredPoint redP = new ColoredPoint(1, 2, Color.RED);
ColoredPoint blueP = new ColoredPoint(1, 2, Color.BLUE);
单独来看,redp
等于 p
,p
等于 bluep
:
System.out.println(redP.equals(p)); // prints true
System.out.println(p.equals(blueP)); // prints true
但是,比较 redP
和 blueP
却产生 false
:
System.out.println(redP.equals(blueP)); // prints false
因此,违反了 equals
约定的传递性条款。
使 equals
关系更加通用似乎是一条死胡同。因此,我们将尝试使其更加严格。使相等 equals
的一种方法是始终将不同类的对象视为不同的对象。这可以通过修改 Point
类和 ColoredPoint
类中的 equals
方法来实现。在 Point
类中,你可以添加一个额外的比较,以检查另一个 Point
的运行时类是否与该 Point
类完全相同,如下所示:
// A technically valid, but unsatisfying, equals method
public class Point {
private final int x;
private final int y;
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 other) {
boolean result = false;
if (other instanceof Point) {
Point that = (Point) other;
result = (this.getX() == that.getX() && this.getY() == that.getY()
&& this.getClass().equals(that.getClass()));
}
return result;
}
@Override public int hashCode() {
return (41 * (41 + getX()) + getY());
}
}
然后,你可以将 ColoredPoint
类的实现恢复到之前违反对称性要求的版本:
public class ColoredPoint extends Point { // No longer violates symmetry requirement
private final Color color;
public ColoredPoint(int x, int y, Color color) {
super(x, y);
this.color = color;
}
@Override public boolean equals(Object other) {
boolean result = false;
if (other instanceof ColoredPoint) {
ColoredPoint that = (ColoredPoint) other;
result = (this.color.equals(that.color) && super.equals(that));
}
return result;
}
}
这里,只有当对象具有相同的坐标并且具有相同的运行时类时,Point
类的实例才被认为等于同一类的其他实例,也就是对象上的 .getClass()
都返回相同的值。新的定义满足对称性和传递性,因为现在不同类的对象之间的每次比较都会产生错误。所以一个彩色点(coloredPoint)永远不可能等于一个点(Point)。这个惯例看起来很合理,但有人可能会认为新定义过于严格。
假设以下稍微迂回的方式来定义坐标(1,2)处的点:
Point pAnon = new Point(1, 1) {
@Override public int getY() {
return 2;
}
};
pAnon
等于 p
吗?答案是否定的,因为与 p
和 pAnon
相关联的 java.lang.Class
对象不同。对于 p
,它是 Point
,而对于 pAnon
,它是一个匿名的 Point
子类。但很明显,pAnon
只是坐标 (1,2) 处的另一个点。将其视为与 p
不同似乎是不合理的。
canEqual
方法
我们似乎又陷入了困境。有没有一种合理的方法可以在保持约定的同时,在多个级别的类层次上重新定义等价?事实上,有这样一种方法,除了重新定义 equals
和 hashCode
之外,重新定义另外一个方法。这个办法是,一旦一个类重新定义了 equals
(以及 hashCode
),它还应该明确声明这个类的对象永远不等于实现不同相等方法的某个超类的对象。这是通过向每个重新定义 equals
的类添加 canEqual
方法来实现的。这是该方法的签名:
public boolean canEqual(Object other)
如果另一个对象 other
是(重新)定义 canEqual
的类的实例,则该方法应返回 true
,否则返回 false
。它在 equals
调用的,以确保对象在两种方式上都是可比较的。以下是 Point
的一个新的(也是最后的)实现:
public class Point {
private final int x;
private final int y;
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 other) {
boolean result = false;
if (other instanceof Point) {
Point that = (Point) other;
result = (that.canEqual(this) && this.getX() == that.getX() && this.getY() == that.getY());
}
return result;
}
@Override public int hashCode() {
return (41 * (41 + getX()) + getY());
}
public boolean canEqual(Object other) {
return (other instanceof Point);
}
}
这个版本的 Point
中的 equals
方法包含了另一个对象可以等于这个对象的附加要求,这是由 canEqual
方法确定的。canEqual
在 Point
中的实现声明 Point
的所有实例都可以是相等。
以下是 ColoredPoint
的相应实现:
public class ColoredPoint extends Point { // No longer violates symmetry requirement
private final Color color;
public ColoredPoint(int x, int y, Color color) {
super(x, y);
this.color = color;
}
@Override public boolean equals(Object other) {
boolean result = false;
if (other instanceof ColoredPoint) {
ColoredPoint that = (ColoredPoint) other;
result = (that.canEqual(this) && this.color.equals(that.color) && super.equals(that));
}
return result;
}
@Override public int hashCode() {
return (41 * super.hashCode() + color.hashCode());
}
@Override public boolean canEqual(Object other) {
return (other instanceof ColoredPoint);
}
}
可以看出,Point
和 ColoredPoint
中的新定义保留了 equals
的约定。相等是对称的和可传递的。将 Point
与 ColoredPoint
进行比较)是会产生 false
。事实上,对于任何点 p
和着色点 cp
,p.equals(cp)
都将返回 false
,因为 cp.canEqual(p)
将返回 false
。反向比较 cp.equals(p)
也将返回 false
,因为 p
不是 ColoredPoint
,因此 ColoredPoint
中的 equals
body 中的第一个 instanceof
检测将失败。
另一方面,只要子类都没有重新定义相等方法,Point
的子类的实例就可以相等。例如,使用新的类定义,p
和 pAnon
的比较将产生 true
。以下是一些例子:
Point p = new Point(1, 2);
ColoredPoint cp = new ColoredPoint(1, 2, Color.INDIGO);
Point pAnon = new Point(1, 1) {
@Override public int getY() {
return 2;
}
};
Set<Point> coll = new java.util.HashSet<Point>();
coll.add(p);
System.out.println(coll.contains(p)); // prints true
System.out.println(coll.contains(cp)); // prints false
System.out.println(coll.contains(pAnon)); // prints true
这些例子表明,如果一个超类 equals
实现定义并调用 canEqual
,那么实现子类的程序员可以决定他们的子类是否等于该超类的实例。例如,因为 ColoredPoint
重写 canEqual
,所以彩色点可能永远不会等于普通的旧点。但由于 pAnon
引用的匿名子类不会覆盖 canEqual
,因此它的实例可以等于 Point
实例。
对 canEqual
方法的一个潜在批评是它违反了 Liskov 替代原则(LSP)。例如,通过比较运行时类来实现 equals
的技术,导致无法定义其实例可以与超类的实例相等的子类,这被描述为违反了 LSP。其理由是 LSP 声明你应该能够在需要超类实例的情况下使用(替代)子类实例。然而,在前面的示例中,coll.contains(cp)
返回 false
,尽管 cp
的 x
和 y
值与集合中的点的值相匹配。因此,这可能看起来像是违反了 LSP,因为你不能在需要 Point
的地方使用 ColoredPoint
。然而,我们认为这是错误的解释,因为 LSP 并如何在 Java 中编写 equals 等价方法要求子类的行为与其超类相同,只是要求它的行为符合其超类的约定。
编写一个比较运行时类的 equals
方法的问题不是它违反了 LSP,而是它没有给你一种方法来创建一个实例可以等于超类实例的子类。例如,如果我们在前面的例子中使用运行时类技术,coll.contains(pAnon)
将返回 false
,而这不是我们想要的。相比之下,我们确实希望 coll.contains(cp)
返回 false
,因为通过覆盖 ColoredPoint
中的 equals
,我们基本上是在说坐标(1,2)处的靛蓝色点与坐标(1、2)处未着色点不同。因此,在前面的例子中,我们能够将两个不同的 Point
子类实例传递给集合的 contains
方法,并且我们得到了两个不同答案,它们都是正确的。