编程

如何在 Java 中编写 equals 等价方法

797 2024-06-29 02:28:00

总结

本文描述了一种重写 equals 方法的技术,该方法即使在具体类的子类添加新字段时也能保留 equals的约定。

子类化时保留 equals contract 的是“面向对象语言中等价关系的基本问题”。

除非你愿意放弃面向对象抽象的好处,否则无法在保留 equals 契约的同时扩展可实例化类并添加值组件。

《Scala 编程》第 28 章展示了一种方法,允许子类扩展可实例化类,添加一个值组件,但保留 equals契约。尽管该技术在书中是在定义 Scala 类的语境中描述的,但它也适用于 Java 中定义的类。在本文中,我们使用改编自 Scala 编程相关章节的文本来介绍这项技术,但使用了从 Scala 翻译成 Java 的代码示例。

常见的等价陷阱

java.lang.Object 类定义了一个 equals 方法,子类可以重写该方法。不幸的是,事实证明,在面向对象的语言中,编写正确的等价方法是非常困难的。事实上,在研究了大量 Java 代码后,2007 年一篇论文的作者得出结论,几乎所有 equals 方法的实现都是错误的。

这是有问题的,因为等价是许多其他事情的基础。首先,类型 C 等价方法带来的错误可能意味着无法可靠地将 C 类型的对象放入集合中。你可能有两个类型为 C 的元素 elem1elem2 是相等的,即 elem1.equals(elem2) 生成 true。尽管如此,对于常见的 equals 方法的错误实现,你仍然可以看到如下行为:

Set<C> hashSet = new java.util.HashSet<C>();
hashSet.add(elem1);
hashSet.contains(elem2);    // returns false!

以下是四个常见的陷阱,它们可能会在重写 equals 时导致不一致的行为:

  1. 使用错误的签名定义 equals
  2. 修改 equals 而没有修改 hashCode
  3. 按照可变字段定义 equals
  4. 未能将 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 被添加到其中、并且 p1p2 是相等的对象时,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 方法。此方法尚未被重写,因此它仍然是通过比较对象标识来实现的。这就是为什么即使点 p1p2a 具有相同的 xy值,比较 “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

如果将 p1p2a 与之前定义的 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 类中的版本:对所分配对象的地址进行一些转换。p1p2 的哈希代码(hash code)几乎可以肯定是不同的,即使这两个 Point 的字段是相同的。不同的哈希代码意味着大概率处于集合中不同的哈希桶中。contains 检测将在其 bucket 中查找与 p2 的哈希代码相对应的匹配元素。在大多数情况下,点 p1 将在另一个桶中,因此永远不会找到它。p1p2 也可能偶然出现在同一散列桶中。在这种情况下,测试将返回 true

问题是 Point 的最后一次实现违反了 Object 类上的 hashCode contract :

如果根据 equals(Object) 方法,两个对象相等,那么对这两个对象中的每一个调用 hashCode 方法必须产生相同的整型结果。

事实上,Java 中 hashCodeequals 应该同时重定义是众所周知的事情。此外,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());
    }
}

此处唯一的不同是字段 xy 不再是 final,并且添加了两个 set 方法,以允许客户端修改 xy 的值。现在 equalshashCode 方法依据可修改的字段定义,因此字段改变时,结果它们的结果也会产生变化。一旦将点放入集合中,这可能会产生奇怪的效果:

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 中“消失在视线之外”,尽管它仍然属于它的元素。

从这个例子中可以学到的教训是,当 equalshashCode 依赖于可变状态时,可能会给用户带来问题。如果将这样的对象放入集合中,必须谨慎,永远不要修改其依赖的状态,这很棘手。如果比较需要将对象的当前状态考虑在内,通常应该将其命名为其他名称,而不是 equals。考虑到 Point 的最后一个定义,最好忽略 hashCode 的重新定义,并将比较方法命名为 equalContents,或其他与 equals 不同的名称。Point 将继承 equalshashCode 的默认实现。因此,即使在修改了它的 x 字段之后,p 也会在 coll 中保持可定位。

陷阱 4: 未能将 equals 定义为等价关系

Objectequals 方法的约定指定 equals 必须在非 null 对象上实现等价关系

  • 它是自反的:对于任何非 nullx,表达式 x.equals(x) 都应该返回 true
  • 它是对称的:对于任何非 nullxy, 当且仅当 y.equals(x) 返回 true 时,x.equals(y) 应返回 true
  • 它是可传递的:对于任何非 nullxyz,如果 x.equals(y) 返回 truey.equals(z) 返回 true,则 x.equals(z) 也应返回 true
  • 它是一致的:对于任何非 nullxy,如果不修改对象上的 equals 比较中使用的信息,则多次调用 x.equals(y) 应一致返回true 或一致返回 false
  • 对于所有非 null xx.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 上新定义的 equalsPoint 上的重写定义更严格,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”调用 pequals 方法,该方法在 Point 类中定义。此方法只考虑两个点的坐标。因此,比较结果是 true。另一方面,比较“ cp 等于(equals) p ”调用 cpequals 方法,该方法在 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

因此,即使 pcp 相等,其中一个 contains 检测成功,而另一个则失败。
如何更改 equals 的定义,使其成为对称的?本质上有两种方式。你可以使这种关系更同样,也可以使之更严格。使其更通用意味着,如果将 ab 进行比较或将 ba 进行比较结果为 true,则将两个对象 ab 视为相等。以下是执行此操作的代码:

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,则该方法将转发到 Pointequals 方法。这具有使 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 等于 pp 等于 bluep

System.out.println(redP.equals(p)); // prints true

System.out.println(p.equals(blueP)); // prints true

但是,比较 redPblueP 却产生 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 吗?答案是否定的,因为与 ppAnon 相关联的 java.lang.Class 对象不同。对于 p,它是 Point,而对于 pAnon,它是一个匿名的 Point 子类。但很明显,pAnon 只是坐标 (1,2) 处的另一个点。将其视为与 p 不同似乎是不合理的。

 canEqual 方法

我们似乎又陷入了困境。有没有一种合理的方法可以在保持约定的同时,在多个级别的类层次上重新定义等价?事实上,有这样一种方法,除了重新定义 equalshashCode 之外,重新定义另外一个方法。这个办法是,一旦一个类重新定义了 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 方法确定的。canEqualPoint 中的实现声明 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);
    }
}

可以看出,PointColoredPoint 中的新定义保留了 equals 的约定。相等是对称的和可传递的。将 PointColoredPoint 进行比较)是会产生 false。事实上,对于任何点 p 和着色点 cpp.equals(cp) 都将返回 false,因为 cp.canEqual(p) 将返回 false。反向比较 cp.equals(p) 也将返回 false,因为 p 不是 ColoredPoint ,因此 ColoredPoint 中的 equals body 中的第一个 instanceof 检测将失败。

另一方面,只要子类都没有重新定义相等方法,Point 的子类的实例就可以相等。例如,使用新的类定义,ppAnon 的比较将产生 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,尽管 cpxy 值与集合中的点的值相匹配。因此,这可能看起来像是违反了 LSP,因为你不能在需要 Point 的地方使用 ColoredPoint。然而,我们认为这是错误的解释,因为 LSP 并如何在 Java 中编写 equals 等价方法要求子类的行为与其超类相同,只是要求它的行为符合其超类的约定。

编写一个比较运行时类的 equals 方法的问题不是它违反了 LSP,而是它没有给你一种方法来创建一个实例可以等于超类实例的子类。例如,如果我们在前面的例子中使用运行时类技术,coll.contains(pAnon) 将返回 false,而这不是我们想要的。相比之下,我们确实希望 coll.contains(cp) 返回 false,因为通过覆盖 ColoredPoint 中的 equals,我们基本上是在说坐标(1,2)处的靛蓝色点与坐标(1、2)处未着色点不同。因此,在前面的例子中,我们能够将两个不同的 Point 子类实例传递给集合的 contains 方法,并且我们得到了两个不同答案,它们都是正确的。