Effective-Java-10覆盖equals时请遵守通用约定

覆盖 equals 方法看起来容易,但是许多的覆盖方式会导致错误,并且产严重后果。最容易避免这类问题的办法就是不覆盖 equals 方法,在这种情况下,类的每个实例都只与它自身相等。如果满足以下任何一个条件:

  1. 类的每个实例本质上都是唯一的。

    对于代表活动的实体,而不是值 (value) 的类来说的却如此,例如 Thread。

    Object 提供的 equals 实现对于这些类来说是正确的。

  2. 类没有必要提供 “逻辑相等” (logical equality) 的测试功能。

    例如,java.util.regex.Pattern 可以覆盖 equals,以检查两个 Pattern 实例是否代表同一个正则表达式,但是设计者并不认为客户需要或者期望这样的功能。

    在这类情况下,Object 继承得到的 equals 实现已经足够了。

  3. 超类已经覆盖了 equals,超类的行为对于这个类也是合适的。

    大多数的 Set 实现都从 AbstractSet 继承了 equals 实现, List 实现从 AbstractList 继承了 equals 实现,Map 实现从 AbstractMap 继承了 equals 实现。

那么什么时候应该覆盖 equals 方法呢?如果类具有自己的 “逻辑相等” (logical equality) 概念,而且超类还没有覆盖 equals。这通常属于 “值类” (value class) 的情况。值类仅仅是表示值的类,例如 Integer 或者 String。程序员在利用 equals 方法来比较值对象的引用时,希望知道它们逻辑上是否相等,而不是像了解它们是否指向同一个对象。为了满足要求,必须覆盖 equals 方法,这样做也使得这个类的实例可以被用作映射表的键和值,或者集合的元素,使映射或集合表现出预期的行为。

有一种值类不需要覆盖 equals 方法,即用实例受控 (见第1条) 确保 “每个值至多只存在一个对象”的类。枚举类型(见第34条)就属于这种类。对于这种类而言,逻辑相同与对象等同是一回事,此时 Object 的 equals 方法等同于逻辑意义上的 equals 方法。

在覆盖 equals 方法的时候,必须遵守它的通用约定。以下是约定内容,Object 的规范。

equals 方法实现了等价关系 (equivalence relation),其属性如下:(Ryuu : 感觉自己回到了离散数学)

  • 自反性 (reflexive)

    对于任何非 null 的引用 x,x.equals(x) 必须返回 true。

  • 对称性 (symmetric)

    对于任意非 null 的引用 x,y。当且仅当 x.equals(y) 返回 true 时,y.equals(x) 必须返回 true。

  • 传递性 (transitive)

    对于任何非 null 的引用 x,y,z。如果 x.equals(y) 且 y.equals(z) 返回 true,x.equals(z) 也必须返回 true。

  • 一致性 (consistent)

    对于任何非 null 的引用 x,y,只要 equals 的比较操作在对象中所用的信息没有被修改,任意次对 x.equals(y) 的调用,一致返回 true 或一致返回 false。

  • 对于任何非 null 的引用值 x,x.equals(null) 必须返回 false。

除非你对数学特别感兴趣,否则这些规则看起来有点让人恐惧,但是不要忽视这些规则,如果违反了,程序很容易出错且很难找到错误根源。一个类的实例通常会被频繁的传递给另一个类的实例。许多类,包括所有的集合类 (collection class) 在内,都依赖于传递给它们的对象是否遵守了 equals 约定。

幸运的是,虽然这些约定看起来很吓人。实际上并不复杂。一旦了解了这些约定,要遵守它们并不困难。

自反性 (Reflexivity) —— 仅仅说明对象等于自身。假如违反,将该类的实例添加到集合中,调用集合的 contains 方法,将告知该集合不包含刚刚添加的元素。

对称性 (Symmetry) —— 任何两个对象对于 “它们是否等同” 的问题都必须保持一致。违反该条件的清醒不难想象。以下类实现了一个不区分大小写的字符串。字符串由 toString 保存,但在 equals 操作中被忽略。

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
// Broken - violates symmetry
public final class CaseInsensitiveString {
private final String s;

public CaseInsensitiveString(String s) {
this.s = Objects.requireNonNull(s);
}

// Broken - violates symmetry
@Override
public boolean equals(Object o) {
if (o instanceof CaseInsensitiveString)
return s.equalsIgnoreCase(((CaseInsensitiveString) o).s);
if (o instanceof String) // One-way interoperability!
return s.equalsIgnoreCase((String) o);
return false;
}

public static void main(String[] args) {
CaseInsensitiveString cis = new CaseInsensitiveString("Polish");
String s = "Polish";
System.out.println(cis.equals(s)); // true
System.out.println(s.equals(cis)); // false
}
}

在该类中 equals 企图与普通字符串对象进行互操作。正如注释结果,问题在于 CaseInsensitiveString 类中的 equals 知道普通字符串对象,但 String 类中的 equals 方法却不知道 CaseInsensitiveString 。显然这违反了对称性。

若将 CaseInsensitiveString 的对象置入集合:

1
2
3
List<CaseInsensitiveString> list = new ArrayList<>();
list.add(cis);
System.out.println(list.contains(s)); // false

在当前的 OpenJDK list.contains(s) 返回的是 false,实际上根据实现的不同,可能会返回 true 或者抛出一个运行时异常。

一旦违反了 equals 约定,当其他对象面对你的对象时,你完全不知道这些对象的行为会怎么样。

为解决该问题,只需把企图与 String 互操作的这段代码从 equals 方法中去掉就可以了:

1
2
3
4
@Override
public boolean equals(Object o) {
return o instanceof CaseInsensitiveString && ((CaseInsensitiveString) o).s.equalsIgnoreCase(s);
}

传递性 (Transitivity) —— 若一个对象等于第二个对象,第二个对象等于第三个对象,则第一个对象等于第三个对象。用子类举例,将一个新的值组件 (value component) 添加到了超类中。子类增加的信息会影响 equals 的比较结果。首先以一个不可变的二维整型 Point 类作为开始:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Point {
private final int x;
private final int y;

public Point(int x, int y) {
this.x = x;
this.y = y;
}

@Override
public boolean equals(Object o) {
if (!(o instanceof Point p)) return false;
return x == p.x && y == p.y;
}
}

假如想要扩展这个类,为其添加颜色信息:

1
2
3
4
5
6
7
8
public class ColorPoint extends Point {
private final Color color;

public ColorPoint(int x, int y, Color color) {
super(x, y);
this.color = color;
}
}

如果完全不提供 equals 方法,而是直接从 Point 继承过来,在 equals 做比较的时候颜色信息就被忽略了。虽然这不会违反 equals 约定,但很明显此方案是无法接受的。假设编写了一个 equals 方法,只有当它的参数是另一个有色点,并且具有相同的位置和颜色时,他才会返回 true:

1
2
3
4
5
6
// Broken - violates symmetry
@Override
public boolean equals(Object o) {
if (!(o instanceof ColorPoint)) return false;
return super.equals(o) && ((ColorPoint) o).color == color;
}

问题在于,比较普通点和有色点,以及相反的情况时,可能会得到不同的结果。前一种忽略了颜色信息,而后一种则总是返回 false,因为参数的类型不正确。

1
2
3
4
Point p = new Point(1, 2);
ColorPoint cp = new ColorPoint(1, 2, Color.RED);
System.out.println(p.equals(cp)); // true
System.out.println(cp.equals(p)); // false

可以做以下的尝试来修正上述问题,让 ColorPoint.equals 在进行 “混合比较” 时忽略颜色信息:

1
2
3
4
5
6
7
8
9
10
11
12
// Broken - violates transitivity!
@Override
public boolean equals(Object o) {
if (!(o instanceof Point)) return false;

// If o is a normal Point,do a color-blind comparison
if (!(o instanceof ColorPoint))
return o.equals(this);

// o is a ColorPoint; do a full comparison
return super.equals(o) && ((ColorPoint) o).color == color;
}

这种方法提供了对称性,但是牺牲了传递性:

1
2
3
4
5
6
ColorPoint p1 = new ColorPoint(1, 2, Color.RED);
Point p2 = new Point(1, 2);
ColorPoint p3 = new ColorPoint(1, 2, Color.BLUE);
System.out.println(p1.equals(p2)); // true
System.out.println(p2.equals(p3)); // true
System.out.println(p1.equals(p3)); // false

前两种是不考虑颜色的色盲比较,而第三种却考虑了颜色。

此外,该方法还可能导致无限的递归:假设 Point 有两个子类,ColorPoint 和 SmellPoint,它们各自都带有这种 equals 方法。那么对于 colorPoint.equals(smellpoint) 的调用将抛出 StackOverflowError 异常。

那么该如何解决呢?事实上,这是面向对象语言中关于等价关系的一个基本问题。无法在扩展可实例化的类的同时,既增加新的值组件,同时又保留 equals 约定,除非愿意放弃面向对象的抽象所带来的优势。

你可能听说过,在 equals 方法中用 getClass 测试代替 instanceof 测试,可以扩展可实例化的类和新增值组件,同时保留 equals 约定:

1
2
3
4
5
6
7
// Broken - violates Liskov substitution principle
@Override
public boolean equals(Object o) {
if (o == null || getClass() != o.getClass()) return false;
Point point = (Point) o;
return x == point.x && y == point.y;
}

这段程序只有当对象具有相同的实现类事,才能使对象等同。但其结果是无法令人接受的:Point 子类的实例仍然是一个 Point,它仍然需要发挥作用,但是如果采用了这种方法,它将无法完成任务!

假设要编写一个方法,以检验某个点是否在单位圆中,以下是可采用的一种方法:

1
2
3
4
5
6
7
8
9
// Initialize unitCircle to contain all Points on the unit circle
private static final Set<Point> unitCircle = Set.of(
new Point(1, 0), new Point(0, 1),
new Point(-1, 0), new Point(0, -1)
);

public static boolean onUnitCircle(Point p) {
return unitCircle.contains(p);
}

虽然这可能不是最好的方法,但是这是可行的。

但是,若通过某种不添加值组件的方法扩展 Point,例如让它的构造器记录创建了多少个实例:

1
2
3
4
5
6
7
8
9
10
11
12
public class CounterPoint extends Point {
private static final AtomicInteger counter = new AtomicInteger();

public CounterPoint(int x, int y) {
super(x, y);
counter.incrementAndGet();
}

public static int numberCreated() {
return counter.get();
}
}

里氏替换原则 (Liskov substitution principle) 认为,一个类型的任何重要属性也将适用于它的子类型。因此为该类型编写的任何方法,在他的子类型上也应该同样运行得很好 [Liskov 87]。若将 CounterPoint 实例传递给 onUnitCircle 方法。若在 Point 类中使用了基于 getClass 的 equals 方法,则返回 false。这是因为 onUnitCircle 方法所用的 HashSet 这样的集合,利用 equals 方法检验包含条件,没有任何 CounterPoint 实例与任何 Point 对应。但是,如果在 Point 上使用适当的基于 instanceof 的 equals 方法,当遇到 CounterPoint 时,相同的 onUnitCircle 方法就会工作得很好。遵从第18条 “复合优先于继承” 的建议。不再让 ColorPoint 扩展 Point,而是在 ColorPoint 中加入一个私有的 Point,以及一个公有的视图 (view) 方法 (见第6条),此方法返回一个与该有色点处在相同位置的普通 Point 对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// Add a value component without violating the equals contract
public class ColorPoint {
private final Point point;
private final Color color;

public ColorPoint(Point point, Color color) {
this.point = point;
this.color = color;
}

/**
* Returns the point-view of this color point.
* @return point
*/
public Point asPoint() {
return point;
}

@Override
public boolean equals(Object o) {
if (!(o instanceof ColorPoint that)) return false;
return that.point.equals(point) && that.color.equals(color);
}
}

在 Java 平台类库中,有一些类扩展了可实例化类,添加了新的值组件。例如,java.sql.Timestamp 对 java.util.Date 进行扩展,并增加了 nanoseconds 域。Timestamp 的 equals 实现确实违反了对称性,如果 Timestamp 和 Date 对象用于同一个集合中,或者以其他的方式被混合在一起,则会引起不正确的行为。Timestamp 类有一个免责声明,告诫程序员不要混合使用 Date 和 Timestamp 对象。只要不把它们混合在一起就不会有麻烦。除此之外,没有其他的措施可以防止此问题,并且错误将很难调试。Timestamp 的这种行为是个错误,不值得效仿。

1
2
3
4
// java.util.Date * DON'T DO THIS!
public boolean equals(Object obj) {
return obj instanceof Date && getTime() == ((Date) obj).getTime();
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// java.sql.Timestamp extends java.util.Date * DON'T DO THIS!
public boolean equals(java.lang.Object ts) {
if (ts instanceof Timestamp) {
return this.equals((Timestamp)ts);
} else {
return false;
}
}

public boolean equals(Timestamp ts) {
if (super.equals(ts)) {
if (nanos == ts.nanos) {
return true;
} else {
return false;
}
} else {
return false;
}
}

注意,你可以在一个抽象 (abstract) 类的子类中增加新的值组件且不违反 equals 约定。对于根据第23条建议而得到的那种类层次结构来说,这一点十分重要。例如,有一抽象类 Shape ,没有任何的值组件,Circle 子类添加了一个 radius 域,Rectangle 子类添加了 length 和 width 域。只要不能直接创造超类的实例,前面所述的种种问题都不会发生。

一致性 (Consistency) —— 如果两个对象相等,那么它们将始终保持相等,除非它们中有一个对象 (或者两个) 被修改了。换句话说,可变对象在不同的时候可以与不同的对象相等,而不可变对象则不会这样。当编写一个类时,应仔细考虑它是否应该是不可变的 (见第17条)。如果认为它应是不可变的,就必须保证 equals 方法满足这样的限制条件:相等的永远相等,不相等的永远不相等。

无论如何,不要使 equals 方法依赖于不可靠的资源。如果违反了这条禁令,想要满足一致性要求将十分困难。例如,java.net.URL 的 equals 方法依赖于对 URL 中主机 IP 地址的比较。随时间的推移,不能确保产生相同的结果,IP 地址有可能发生变化。这会违反 equals 约定,在实践中可能引发一些问题。URL equals 方法是一个大错误,不应该模仿。遗憾的是,因为兼容性的要求,这一行为无法被改变。为了避免发生这种问题,equals 方法应该对驻留在内存中的对象执行确定性的计算。

1
2
3
4
5
6
7
8
// java.net.URL * DON'T DO THIS!
transient URLStreamHandler handler;

public boolean equals(Object obj) {
if (!(obj instanceof URL)) return false;
URL u2 = (URL)obj;
return handler.equals(this, u2);
}
1
2
3
4
// java.net.URLStreamHandler * DON'T DO THIS!
protected boolean equals(URL u1, URL u2) {
return Objects.equals(u1.getRef(), u2.getRef()) && sameFile(u1, u2);
}

非空性 (Non-nullity) —— 暂且如此称呼。所有的对象都不能等于 null。尽管很难想象,在什么情况下 o.equals(null) 调用会意外地返回 true,但是意外抛出 NullPointerException 异常的情况不难想象。通用约定不允许抛出 NullPointerException 异常。许多类的 equals 方法都通过一个显式的 null 测试来防止该情况:

1
2
3
4
5
6
// Unnessasary!
@Override
public boolean equals(Object o) {
if(o == null) return false;
...
}

然而这项测试是不必要的。为了测试其参数的等同性,equals 方法必须先把参数转换为适当的类型,以便可以调用它的访问方法,或者访问它的域。在进行转换之前, equals 方法必须使用 instanceof 操作符,检查其参数的类型是否正确:

1
2
3
4
5
6
@Override
public boolean equals(Object o) {
if (!(o instanceof MyType)) return false;
MyType mt = (MyType) o;
...
}

如果漏掉类型检查,并且传递给 equals 方法的参数又是错误的类型,那么equals 方法将会抛出 ClassCastException 异常,这违反了 equals 约定。但是,如果 instanceof 操作符的第一个操作数为 null,那么,不管第二个操作数是哪种类型,instanceof 操作符都会指定应该返回的 false [JLS,15.20.2]。因此如果把 null 传给 equals 方法,类型检查就会返回 false,所以不需要显示的 null 检查。

结合所有这些要求,得出了以下实现高质量 equals 方法的诀窍:

  1. 使用 == 操作符检查 “参数是否为这个对象的引用”。

    如果是,则返回 true。这只不过是一种性能优化,如果比较操作性能消耗过大,就值得这么做。

  2. 使用 instanceof 操作符检查 “参数是否为正确的类型”。

    如果不是,则返回 false。一般来说,”正确的类型”是指 equals 方法所在的类。某些情况下是指该类所实现的某个接口。如果类实现的接口改进了 equals 约定,允许了在实现了该接口的类之间进行比较,那么就使用接口。集合接口如Set、List、Map 和 Map.Entry 具有这样的特性。

  3. 把参数转换成正确的类型。

    因为转换之前进行过 instanceof 测试,所以确保会成功。

  4. 对于该类的每个 “关键” (significant) 域,检查参数中的域是否与该对象中对应的域相匹配。

    如果这些测试全部通过,返回 true;否则返回 false。

    如果第二步中的类型是接口,就必须通过接口方法访问参数中的域;

    如果该类型是类,也许就能直接访问其参数,这要取决于它们的可访问性。

对于对象引用域,可以递归地调用 equals 方法;

对于既不是 float 也不是 double 类型的基本类型域,可以使用 == 操作符进行比较;

对于 float 域,可以使用静态 Float.compare(float, float) 方法; 对于 double 域,使用 Double.compare(double, double)。对这两个域进行特殊处理是有必要的,因为存在着 Float.NaN、-0.0f 以及类似的 double 常量;详细信息请参考 JLS 15.21.1 或者 Float.equals 的文档。虽然可以用静态方法 Float.compare Double.compare 进行比较,但是每次比较都要进行自动装箱,这将导致性能下降。对于数组域,则要把以上这些指导原则应用到每一个元素上。如果数组域的每个元素都很重要,可以使用 Arrays.equals 方法。

有些对象引用域包含 null 可能是合法的,所以,为了避免可能导致 NullPointerException 异常,使用静态方法 Objects.equals(Object, Object) 来检查这些类域的等同性。

对于有些类,比如前面提到的CaseInsensitiveString类,域的比较要比简单的等同性测试复杂得多。如果是这种情况,可能希望保存该域的一个“范式”(canonical form),这样equals方法就可以根据这些范式进行低开销的精确比较,而不是高开销的非精确比较。这种方法对于不可变类(见第17条)是最为合适的;如果对象可能发生变化,就必须使其范式保持最新。

域的比较顺序可能会影响equals方法的性能。为了获得最佳的性能,应该最先比较最有可能不一致的域,或者是开销最低的域,最理想的情况是两个条件同时满足的域。不应该比较那些不属于对象逻辑状态的域,例如用于同步操作的 Lock 域。也不需要比较衍生域 (derived field),因为这些域可以由 “关键域” (significant field)计算获得,但是这样做有可能提高 equals 方法的性能。如果衍生域代表了整个对象的综合描述,比较这个域可以节省在比较失败时去比较实际数据所需要的开销。例如,假设有一个 Polygon 类,缓存了其面积。若两个多边形面积不同,则没有必要去比较它们的边和顶点。

在编写完equals方法之后,应该问自己三个问题:它是否是对称的、传递的、一致的?并且不要只是自问,还要编写单元测试来检验这些特性,除非用AutoValue (后面会讲到) 生成 equals 方法,在这种情况下就可以放心地省略测试。如果答案是否定的,就要找出原因,再相应地修改 equals 方法。当然, equals 方法也必须满足其他两个特性 (自反性和非空性),但是这两种特性通常会自动满足。

根据上面的诀窍构建 equals 方法的具体例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// Class with a typical equals method
public final class PhoneNumber {
private final short areaCode, prefix, lineNum;

public PhoneNumber(int areaCode, int prefix, int lineNum) {
this.areaCode = rangeCheck(areaCode, 999, "areaCode");
this.prefix = rangeCheck(prefix, 999, "prefix");
this.lineNum = rangeCheck(lineNum, 9999, "line num");
}

private static short rangeCheck(int val, int max, String arg) {
if (val < 0 || val > max) throw new IllegalArgumentException(arg + " : " + val);
return (short) val;
}

@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof PhoneNumber that)) return false;
return areaCode == that.areaCode && prefix == that.prefix && lineNum == that.lineNum;
}
}

下面是最后的一些告诫:

  • 覆盖 equals 时总要覆盖 hashCode

    为了让关注点在 equals 方法上,本条建议中都没有覆盖 hashCode,详情见第11条。

  • 不要企图让 equals 方法过于智能。

    如果只是简单地测试域中的值是否相等,则不难做到遵守 equals 约定。如果想过度地去寻求各种等价关系,则很容易陷入麻烦之中。把任何一种别名形式考虑到等价的范围内,往往不会是个好主意。例如,File类不应该试图把指向同一个文件的符号链接 (symbolic link) 当作相等的对象来看待。所幸 File 类没有这样做。

  • 不要将 equals 声明中的 Object 对象替换为其他的类型。

    程序员编写出下面这样的 equals 方法并不鲜见,这会使程序员花上数个小时都搞不清为什么它不能正常工作:

    1
    2
    3
    4
    5
    // DON'T DO THIS!
    @Override
    public boolean equals(MyClass o) {
    ...
    }

    问题在于,这个方法并没有 重写 (override) Object.equals,因为它的参数应该是 Object 类型,相反,它重载 (overload) 了 Object.equals (见52条)。在正常 equals 方法的基础上,再提供一个 “强类型” (strongly typed) 的 equals 方法,这是无法接受的,因为会导致子类中的 Override 注解产生错误的正值,带来错误的安全感。
    @Override 注解的用法一致,就如本条目中所示,可以防止犯这种错误 (见第40条)。这个equals方法不能编译,错误消息会告诉你到底哪里出了问题:

    1
    2
    3
    4
    5
    // Still broken, but won't compile
    @Override
    public boolean equals(MyClass o) {
    ...
    }

编写和测试 equals (及 hashCode) 方法都是十分烦琐的,得到的代码也很琐碎。代替手工编写和测试这些方法的最佳途径,是使用 Google 开源的 AutoValue 框架,它会自动替你生成这些方法,通过类中的单个注解就能触发。在大多数情况下,AutoValue 生成的方法本质上与你亲自编写的方法是一样的。

IDE 也有工具可以生成 equals 和 hashCode 方法,但得到的源代码比使用 Auto-Value 的更加冗长,可读性也更差,它无法自动追踪类中的变化,因此需要进行测试。也就是说,让 IDE 生成 equals (及 hashCode) 方法,通常优于手工实现它们,因为 IDE 不会犯粗心的错误,但是程序员会犯错。

总而言之,不要轻易重写 equals 方法,除非迫不得已。因为在许多情况下,从 Object 处继承的实现正是你想要的。如果覆盖 equals,一定要比较这个类的所有关键域,并且查看它们是否遵守 equals 约定的所有五个条款。