effective-Java/ch03所有对象的通用方法/10.重写equals方法时遵守通用约定.md
2019-09-27 18:46:58 +08:00

8.9 KiB
Raw Blame History

重写equals方法时遵守通用约定

虽然 Object 是一个具体的类,但它主要是为继承而设计的。它的所有非 final 方法equals、hashCode、toString、clone 和 finalize都有清晰的通用约定 general contracts因为它们被设计为被子类重写。任何类要重写这些方法时都有义务去遵从它们的通用约定如果不这样做将会阻止其他依赖于约定的类 (例如 HashMap 和 HashSet) 与此类一起正常工作。

重写 equals 方法看起来很简单,但是有很多方式会导致重写出错,其结果可能是可怕的。避免此问题的最简单方法是不覆盖 equals 方法,在这种情况下,类的每个实例只与自身相等。如果满足以下任一下条件,则说明是正确的做法:

  • 每个类的实例都是固有唯一的。 对于像 Thread 这样代表活动实体而不是值的类来说,这是正确的。 Object 提供的 equals 实现对这些类完全是正确的行为。
  • 类不需要提供一个「逻辑相等logical equality」的测试功能。例如 java.util.regex.Pattern 可以重写 equals 方法检查两个是否代表完全相同的正则表达式 Pattern 实例,但是设计者并不认为客户需要或希望使用此功能。在这种情况下,从 Object 继承的 equals 实现是最合适的。
  • 父类已经重写了 equals 方法,则父类行为完全适合于该子类。例如,大多数 Set 从 AbstractSet 继承了 equals 实现、List 从 AbstractList 继承了 equals 实现Map 从 AbstractMap 的 Map 继承了 equals 实现。
  • 类是私有的或包级私有的,可以确定它的 equals 方法永远不会被调用。如果你非常厌恶风险,可以重写 equals 方法,以确保不会被意外调用:
@Override 
public boolean equals(Object o) {
    throw new AssertionError(); // Method is never called
}

  什么时候需要重写 equals 方法呢如果一个类包含一个逻辑相等logical equality的概念此概念有别于对象标识object identity而且父类还没有重写过 equals 方法。这通常用在值类value classes的情况。值类只是一个表示值的类例如 Integer 或 String 类。程序员使用 equals 方法比较值对象的引用,期望发现它们在逻辑上是否相等,而不是引用相同的对象。重写 equals 方法不仅可以满足程序员的期望,它还支持重写过 equals 的实例作为 Map 的键key或者 Set 里的元素,以满足预期和期望的行为。

当你重写 equals 方法时必须遵守它的通用约定。Object 的规范如下: equals 方法实现了一个等价关系equivalence relation。它有以下这些属性:

  • 自反性: 对于任何非空引用 xx.equals(x) 必须返回 true。
  • 对称性: 对于任何非空引用 x 和 y如果且仅当 y.equals(x) 返回 true 时 x.equals(y) 必须返回 true。
  • 传递性: 对于任何非空引用 x、y、z如果 x.equals(y) 返回 truey.equals(z) 返回 truex.equals(z) 必须返回 true。
  • 一致性: 对于任何非空引用 x 和 y如果在 equals 比较中使用的信息没有修改,则 x.equals(y) 的多次调用必须始终返回 true 或始终返回 false。
  • 对于任何非空引用 xx.equals(null) 必须返回 false。

自反性Reflexivity

第一个要求只是说一个对象必须与自身相等。 很难想象无意中违反了这个规定。 如果你违反了它,然后把类的实例添加到一个集合中,那么 contains 方法可能会说集合中没有包含刚添加的实例。

对称性Symmetry

第二个要求是,任何两个对象必须在是否相等的问题上达成一致。与第一个要求不同的是,我们不难想象在无意中违反了这一要求。

例如,考虑下面的类,它实现了不区分大小写的字符串。字符串被 toString 保存,但在 equals 比较中被忽略:

传递性Transitivity

传递性的要求是,如果第一个对象等于第二个对象,第二个对象等于第三个对象,那么第一个对象必须等于第三个对象。

考虑子类的情况, 将新值组件value component添加到其父类中。换句话说子类添加了一个信息它影响了 equals 方法比较。

一致性Consistent

如果两个对象是相等的,除非一个(或两个)对象被修改了, 那么它们必须始终保持相等。

换句话说,可变对象可以在不同时期可以与不同的对象相等,而不可变对象则不会。

非空性Non-nullity

所有的对象都必须不等于 null。虽然很难想象在调用 o.equals(null) 的响应中意外地返回 true但不难想象不小心抛出 NullPointerException 异常的情况。通用的约定禁止抛出这样的异常。

总结

编写高质量 equals 方法的配方recipe

编写高质量 equals 方法的配方recipe

  1. 使用 == 运算符检查参数是否为该对象的引用。如果是,返回 true。这只是一种性能优化但是如果这种比较可能很昂贵的话那就值得去做。
  2. 使用 instanceof 运算符来检查参数是否具有正确的类型。 如果不是,则返回 false。 通常,正确的类型是 equals 方法所在的那个类。 有时候,改类实现了一些接口。 如果类实现了一个接口,该接口可以改进 equals 约定以允许实现接口的类进行比较,那么使用接口。 集合接口(如 SetListMap 和 Map.Entry具有此特性。
  3. 参数转换为正确的类型。因为转换操作在 instanceof 中已经处理过,所以它肯定会成功。
  4. 对于类中的每个「重要」的属性,请检查该参数属性是否与该对象对应的属性相匹配。如果所有这些测试成功,返回 true否则返回 false。如果步骤 2 中的类型是一个接口,那么必须通过接口方法访问参数的属性;如果类型是类,则可以直接访问属性,这取决于属性的访问权限。

对于类型为非 float 或 double 的基本类型,使用 == 运算符进行比较;对于对象引用属性,递归地调用 equals 方法;对于 float 基本类型的属性,使用静态 Float.compare(float, float) 方法;对于 double 基本类型的属性,使用 Double.compare(double, double) 方法。

某些对象引用的属性可能合法地包含 null。 为避免出现 NullPointerException 异常,请使用静态方法 Objects.equals(Object, Object) 检查这些属性是否相等。

equals 方法的性能可能受到属性比较顺序的影响。 为了获得最佳性能你应该首先比较最可能不同的属性开销比较小的属性或者最好是两者都满足derived fields。 你不要比较不属于对象逻辑状态的属性,例如用于同步操作的 lock 属性。 不需要比较可以从“重要属性”计算出来的派生属性,但是这样做可以提高 equals 方法的性能。 如果派生属性相当于对整个对象的摘要描述,比较这个属性将节省在比较失败时再去比较实际数据的开销。

提醒

  1. 当重写 equals 方法时,同时也要重写 hashCode 方法
  2. 不要让 equals 方法试图太聪明。 如果只是简单地测试用于相等的属性,那么要遵守 equals 约定并不困难。如果你在寻找相等方面过于激进那么很容易陷入麻烦。一般来说考虑到任何形式的别名通常是一个坏主意。例如File 类不应该试图将引用的符号链接等同于同一文件对象。幸好 File 类并没这么做。
  3. 在 equal 时方法声明中,不要将参数 Object 替换成其他类型。 对于程序员来说,编写一个看起来像这样的 equals 方法并不少见,然后花上几个小时苦苦思索为什么它不能正常工作:在 equal 时方法声明中,不要将参数 Object 替换成其他类型。对于程序员来说,编写一个看起来像这样的 equals 方法并不少见,然后花上几个小时苦苦思索为什么它不能正常工作。

完整示例

Item10Example06.java