Effective Java(3rd)-Item10 当重载equals方法时遵守普遍契约

  重载equals方法看起来很简单,但是有很多方法会导致错误,后果可能会很严重。避免问题的最容易办法是不要去重载equal方法,在这种情况下,每个类的实例只与自己相等。下列是正确的做法如果符合以下任何条件:

  • 每个类的实例在本质上都是唯一的。像Thread这种表示活动实体而不是值的类也是如此。Object提供的equals实现对这些类具有完全正确的行为。
  • 类没有必要提供“逻辑相等”测试。例如,java.util.regex.Pattern可以重写equals方法来检查两个Pattern实例是否表示相同的正则表达式,但是设计者并不认为客户端需要或想要这个功能。在这种情况下,从Object继承实现的equals方法是理想的。
  • 超类早就重载了equals方法,超类的行为适用于此类。例如,大多数Set实现继承了AbstractSet的euqals的实现,List从AbstractList继承实现,Map从AbstractList继承实现。
  • 类是私有的或包私有的,并且确定它的equals方法不会被调用。如果你极度不愿意承担风险,可以重载equals方法来确保它不会被意外调用:
    image.png

      那么什么时候需要重载equals?当一个类具有逻辑相等的概念时,不仅仅是对象身份,并且超类还没有重载equals。这通常是价值类的情况。一个值类只是表示这个类的值,就好像Integer或String。程序员比较值对象的引用使用equals方法期望找到它们是否是逻辑等价,而不是想知道它们是否引用了相同对象。重写equals方法不仅满足了程序员的需要,而且也能使实例作为map的key或set的元素,使它们表现出可预期的行为。
      一种不需要重载equals方法的值类使用了实例控制(item1)来确保每个值最多存在一个对象。 枚举类型 (item34) 就属于这个类。对于这些类,逻辑相等和对象标识是等价的,所以Object的equals方法可以当作逻辑相等方法。
      当你重载了equals方法,你必须遵守它的普遍契约。这是从Object规范的契约:
    equals方法实现了等价关系,它有这些属性:
  • 自反性:对于任何非空引用的值x,x.equals(x)的结果必定是true。
  • 对称性:对于任何非空引用的值x,y,有且仅当y.equals(x)为true时,x.equals(y)必为true。
  • 传递性:对于任何非空引用值x,y,z,如果x.equals(y)返回true,且y.equals(z)返回true,则x.euqals(z)必为true。
  • 一致性:对于任何非空引用值x,y,如果没有修改equals的比较信息,多次调用x.equals(y)必须一直返回true或一直返回false。
  • 任何非空引用值x,x.equals(null)必须返回null。
      除非你熟悉数学,否则你可能会觉得有点可怕,但是不要忽视它!如果你违反了它,你可能会发现你的程序行为不正常或者会奔溃,同时这会非常困难去定位错误的根源。用John Donne的话来说,没有类是岛屿。一个类的实例频繁互相传递对象。许多类,包括所有集合类,依赖于遵守equals七月的对象来互相传递对象。
      既然你意识到了违反equals契约的危险,那就让我们详细了解这个契约。好消息是,尽管外表复杂,但是实际上并不特别复杂。一旦你理解了它,就不难坚持它。
      所以什么是平等关系?松散地说,这是一个运算符,分开一组元素划分为多个子组,这些子元素彼此相等。这些子集被称为等价类。为了使equals方法有用,所有等价类内的元素必须可以用用户的角度进行互换。现在,让我们依次检查五个要求:
      自反性——第一个要求仅仅说了对象必须和自己等价。很难想象会无意违反这个要求。如果你违反它并且添加一个你的类的实例到集合去,contains方法可能会认为这个集合没有包含你刚刚添加的实例。
      对称性——第二个要求说了任何两个对象必须就它们是否相等达成一致。不像第一个要求,不难想象无意违反这个条件的时候。比如,考虑下列class,实现了一个不区分大小写的字符串。这个字符串的大小写在toString中保留,但是在equals中忽略:
    Effective Java(3rd)-Item10 当重载equals方法时遵守普遍契约_第1张图片
    image.png

  好意的equals方法在这个类中天真地尝试去与普通字符串互通操作。假设我们有一个大小写不敏感的字符串和一个大小写敏感的字符串:


image.png

  正如所期望的,cis.equals(s)返回了true。问题是CaseInsensitiveString中的equals方法了解普通字符串,但是String的equals方法并不知道大小写不敏感的字符串。所以,s.equals(cis)返回false,明显违反了对称性。假设你放置一个大小写不敏感的字符串到集合中:


image.png

  list.contain(s)在这个时候该返回什么?谁知道呢?在当前的openJDK的实现中,这会返回false,但这只是一个实现工件。在另一个实现中,这可能会返回true或者抛出运行时异常。当你违反了equals契约,当你面对你的对象时,你根本不知道其他对象的行为是怎样的
  为了消除这个问题,只需删除与String互通的欠妥的尝试。完成这个操作之后,你可以重构这个方法为单个return语句:

image.png

  传递性——equals契约的第三个要求说明了如果一个对象与第二个对象相等,而第二个对象和第三个对象相等,那么第一个对象必定与第三个对象相等。同样,并不难想象会偶然违反这个要求。考虑到一个情况,一个子类向父类添加了一个新的值组件。换句话说,子类添加了一块影响equals比较的信息。让我们从一个简单的不可变的二位整形Point类开始:

Effective Java(3rd)-Item10 当重载equals方法时遵守普遍契约_第2张图片
image.png

假设你想要扩展这个类,向一个point添加颜色的概念:
Effective Java(3rd)-Item10 当重载equals方法时遵守普遍契约_第3张图片
image.png

  equals方法应该长什么样子呢?如果完全忽略,equals的实现则为继承Point的equals方法同时在equals比较方法中颜色信息被忽略。虽然这不违反equals契约,但是显然这是不可接受的。假设你编写了一个equals方法,只有当它的参数是另一个color point并且有着相同的位置和颜色才返回true:


Effective Java(3rd)-Item10 当重载equals方法时遵守普遍契约_第4张图片
image.png

  这个方法的问题在于,当比较point和color point时你可能会得到不同的结果,反之亦然。前者比较忽略了颜色,后者比较总是返回false因为参数的类型不一致。为了让这个更具体,让我们创建一个point和一个color point。


image.png

  p.equals(cp)返回true,而cp.equals(p)返回false。你可能会尝试通过当做“混合比较时”使ColorPoint.equals忽略颜色修复这个问题:

Effective Java(3rd)-Item10 当重载equals方法时遵守普遍契约_第5张图片
image.png

这个方法确实满足了对称性,但是是在牺牲传递性的情况下:
image.png

现在,p1.equals(p2)和p2.equals(p1)返回true,但是p1.equals(p3)返回false,明显违反了传递性。前两个比较叫做“色盲的”,而第三个把颜色考虑在内。
  并且,这个方法可能会造成无限递归:假设有两个Point的子类,叫做ColorPoint和SmellPoint,每个都有这种equals方法。然后调用myColorPoint.equals(mySmellPoint)将会抛出StackOverFlowError.
  那么,什么是解决办法?事实证明,这是面向对象语言中等价关系的基本问题。 没有方法来扩展一个可实例化的类并添加一个值组件时还能保留equals契约。除非你愿意放弃面向对象抽象的好处。
  你可能听说过你可以通过在equals方法中使用getClass代替instance of继承一个可实例化的类并添加一个值组件并保留equals契约:
Effective Java(3rd)-Item10 当重载equals方法时遵守普遍契约_第6张图片
image.png

这具有仅在对象具有相同的实现类时才使对象等效的效果。这看起来并不糟,但是结果是不可接受的:一个Point的子类的实例仍然是一个Point,它仍然需要作为一个功能运行,但是如果你采用这种方法它就不能这样做!让我们假设我们想要编写一个方法来知道point是否在单位圆上,如下是我们可以做的一个方式:
Effective Java(3rd)-Item10 当重载equals方法时遵守普遍契约_第7张图片
image.png

  虽然它可能不是最快的方式来实现这个功能,但是它运行得很好。假设你用一些简单的方式不添加值组件来扩展Point,比如,通过构造方法保持跟踪已被创建的实例数:


Effective Java(3rd)-Item10 当重载equals方法时遵守普遍契约_第8张图片
image.png

  里氏替换原则说明了,任何类型的重要属性也应该适用于所有它的子类型,以至于任何为类型编写的方法应该于它的子类型同样有效。这是我们先前声称的Point的子类(比如CounterPoint)仍然是Point类并且行为与Point一致的正式声明。但是假设我们将CounterPoint传递给onUnitCircle方法上。如果Point类使用基于getClass的equals方法,onUnitCircle方法将返回false,无论CountPoint实例的x和y坐标如何。这是因为大多数集合,包括onUnitCircle方法使用的HashSet,使用equals方法来测试是否包含,并且没有CounterPoint实例与任何Point相等。但是,如果在Point上使用合适的基于instanceOf的equals方法,使用CounterPoint实例时,相同的onUnitCircle方法可以正常工作。
  虽然没有令人满意的方法来扩展一个可实例化的类并添加值组件,但是有一个好的解决方案:跟随item18的忠告,”使用组合而不是继承“。不要让ColorPoint继承Point,给ColorPoint一个私有的Point字段,和一个公有的视图方法(item6 ),它返回此color point相同位置的point:

Effective Java(3rd)-Item10 当重载equals方法时遵守普遍契约_第9张图片
image.png

  在Java平台库中有一些类确实继承了一个可实例化的类并添加了一个值组件。比如,java.sql.Timestamp继承了java.util.Date也添加了字段nanoseconds。Timestamp的equals实现的确违反了对称性,并且如果Timestamp和Date对象在同一集合中使用或以其他方式混合,会造成不稳定的行为。

Effective Java(3rd)-Item10 当重载equals方法时遵守普遍契约_第10张图片
image.png

Timestamp类有一个免责声明,提醒程序员不要混淆日期和时间戳。虽然只要你将它们分开就不会遇到麻烦,但是也没有什么能阻止你混合它们,而且产生的结果难以调试。Timestamp类的这种行为是一个错误,不应该被模拟。
  注意到你可以在不违反equals契约的前提下向一个抽象类的子类添加一个值组件。这对于您按照第23项“将类层次结构添加到标记类”中的建议获得的类层次结构非常重要。例如,你现在有一个没有值组件的抽象类Shape,一个有着半径字段的子类Circle,一个有长和宽的字段的子类Rectangle。只要不直接创建超类实例,就不会出现前面所示的排序问题。
   一致性——equals契约的第四个要求描述了,如果两个对象相等,它们必须始终保持相等,除非它们中的一个(或两个)被修改。换句话说,可变对象可以在不同时间与不同对象相等,而不可变对象则不能。当你编写一个类的时候,认真思考它是都应该是不可变的 (item17)。如果你得出结论它应该是不可变的,确保你的equals方法强制执行限制,即相等对象保持相等,不相等对象始终保持不相等。
  无论一个类是否是不可变的, 不要编写依赖于不可信赖的资源的equals方法。如果你违反了这个禁令,则很难满足一致性需求。例如,java.net.URL的equals方法依赖于与URL关联的主机的IP地址的比较。转换一个主机名为IP地址可能需要网络访问,并且随着时间的推移,它不能保证产生相同结果。这会造成URL equals方法违反了equals契约并在实践中引起问题。URL的equals方法的行为是一个大错误,不应该被模仿。不幸的是,为了兼容性的要求,它不能被改变。为了避免这些问题,equals方法应该只对内存驻留对象执行确定性计算。
   非-无效性——最终要求缺少官方名称,因此我冒昧地称呼其为“非无效性”。它表示所有对象必须不等于null。虽然很难想象调用o.equals(null)方法时偶然返回true,但是不难想象意外地抛出空指针异常。一般契约禁止这么做。许多类的equals方法都有显示测试防范null:
Effective Java(3rd)-Item10 当重载equals方法时遵守普遍契约_第11张图片
image.png

  这个测试是没有必要的。为了测试其相等的参数,equals方法必须首先将它的参数转换为合适的类型,以便于其访问器可以被调用或它的字段可以被访问。在做这个转换之前,该方法必须使用instanceof 操作符来核对它的参数是否是正确类型:
Effective Java(3rd)-Item10 当重载equals方法时遵守普遍契约_第12张图片
image.png

  如果类型检查缺失并且equals方法传递了错误类型,equals方法将会抛出ClassCastException,这违反了equals契约。但是如果第一个操作数是null,无论第二个操作数是什么类型,instanceof操作符指定为返回false。因此,如果传入null,类型检查将会返回false,所以你不需要显示检查null。
  总而言之,这是一份高质量的equals方法的配方:
1. 使用==操作符来检查参数是否是这个对象的引用。如果是,返回true。这只是一个性能优化,但是如果比较可能开销很大,这就是值得的。
2. 使用instanceof操作符来检查参数是否具有正确得类型。如果不是,返回false。通常,正确类型指的是equals方法所在的那个类。有些情况下,是指该类所实现的某个接口。如果一个类实现的一个接口实现了equals契约,以允许跨实现接口的类进行比较,那么使用这个接口作为正确的类型。集合接口比如Set,List,Map和Map.Entry具有该属性。

    /**
     * Compares the specified object with this set for equality.  Returns
     * true if the given object is also a set, the two sets have
     * the same size, and every member of the given set is contained in
     * this set.  This ensures that the equals method works
     * properly across different implementations of the Set
     * interface.

* * This implementation first checks if the specified object is this * set; if so it returns true. Then, it checks if the * specified object is a set whose size is identical to the size of * this set; if not, it returns false. If so, it returns * containsAll((Collection) o). * * @param o object to be compared for equality with this set * @return true if the specified object is equal to this set */ public boolean equals(Object o) { if (o == this) return true; if (!(o instanceof Set)) return false; Collection c = (Collection) o; if (c.size() != size()) return false; try { return containsAll(c); } catch (ClassCastException unused) { return false; } catch (NullPointerException unused) { return false; } }

3.将参数转换为正确的类型。因为有了先前instanceof的测试,转换才能保证成功。
4.对于类中每个“重要”字段,检查参数的字段是否与此对象的相应字段匹配。如果所有这些测试都成功,返回true;否则,返回false。如果步骤2中的类型是一个接口,你必须通过接口方法访问参数字段;如果类型是一个类,你可以直接访问字段,具体决定于那些字段的可访问性。对于不是float或double的那些原始类型字段,使用==操作符来比较;对于对象引用字段,递归地调用equals方法;对于float字段,使用静态方法Float.compare(float,float);以及double字段,使用Double.compare(double,double).float和double的特殊处理是由Float.NaN,-0.0f和类似的double值所需要的。有关详细信息,请参阅JLS 15.21.1或Float.equals的文档。虽然你可以用静态方法Float.equals和Double.equals比较float和double字段,但是这意味着在每次比较的时候自动装箱,这将会有糟糕的性能。对于数组字段,应用以上指南到每个元素中。如果数组中的每个元素都很重要,请使用Arrays.equals方法之一。一些引用对象字段可能合法地包含了null。为了避免空指针引用异常的可能性,使用静态方法Objects.equals(Object,Object)检查此类是否字段相等。
  对于一些类来说,比如如上提到的CaseInsensitiveString,字段比较比简单的相等测试更复杂。如果是这种情况,你可能想要存储这个字段的规范形式,以便equals方法可以在规范形式上做一个廉价的精确比较而不是开销更大的非标准比较。这种技术最适合不可变类 (item17) ;如果对象可能会变,你必须使规范形式保持最新。
  equals方法的性能可能受到字段比较顺序的影响。为了最好的性能,你应该首先比较更可能不同的字段,开销少的字段,或理想情况下两者都有的字段。你不能比较不属于对象逻辑状态的字段,比如用于同步操作的锁定字段。你不需要比较派生字段,但是可以从“重要字段”中计算,这也可能提高equals方法的性能。如果派生字段相当于整个对象的总结描述,如果比较失败,比较这个字段将节省你比较实际数据的开销。例如,假设你由一个Polygon类,你缓存了该区域。如果两个Polygon具有不相等的区域,你不需要比较他们的边和顶点。
  当你写完你的equals方法,问你自己三个问题:它是有对称性吗?它具有传递性吗?它有一致性吗?而且不要只是问自己;编写单元测试来检查,除非你使用autoValue来生产你的equals方法,这样就能安全地忽略测试。如果特性不能保持,找到原因,并且相应修改equals方法。当然你的equals方法必须也满足其他两个特性(自反性和非无效性),但是这两个特性一般来说会照顾好它们自己的。
  在这个简单的PhoneNumber类中显示了根据前一个配方构造的equals方法:

Effective Java(3rd)-Item10 当重载equals方法时遵守普遍契约_第13张图片
image.png

  以下是最后的忠告:

  • 覆写equals方法时始终覆写hashCode (item11)
  • 不要试图太聪明。如果你只是测试字段是否相等,这并不难保持equals方法。如果你过分追求找到等价关系,很容易就会出错。考虑任何形式的别名通常是个坏主意。例如,File类不应该试图把指向同名的符号链接看作相等。谢天谢地,它没有。
  • 不要在equals声明中用Object替换其他类型。程序员编写一个看起来像这样的equals方法然后花费数小时的时间明白为什么它不能正常工作的情况并不少见:
    Effective Java(3rd)-Item10 当重载equals方法时遵守普遍契约_第14张图片
    image.png

    它的问题在于这个方法并不覆写Object.equals,它的参数是Object类型,相反,它重载了它 (item52) 。即使已经有了正常方法,提供这种“强类型”的equals方法也是不可接受的,因为它可能会造成在子类中的Override注解产生误报并提供错误的安全感。
    如本项目所示,一致使用Override注解将阻止你犯错误(item40) 。这个equals方法将不会编译,错误信息将告诉你哪里出错了:
    Effective Java(3rd)-Item10 当重载equals方法时遵守普遍契约_第15张图片
    image.png

      编写和测试equals方法(和hashCode)是繁琐的,结果代码是单调的。一个极好的方法来替代手动编写和测试这些方法就是使用Google的开源框架AutoValue,该框架为你自动生产这些方法,由类上的单个注解触发。在大多数情况下,由AutoValue生成的方法与你自己写的方法基本相同。
      IDE也有生成equals和hashCode方法的工具,但结果源代码比使用AutoValue更冗长,更不易读,不能因为类的改变而自动更改,因此需要测试(译者注:lombok了解一下)。也就是说,让IDE生成equals方法(和hashCode)通常比手动实现它们更可取,因为IDE不会造成粗心的错误,但是人类会。
      总之,不要覆写equals方法除非你不得不:在很多情况下,从Object继承的实现,完全符合你的要求。如果你确实需要覆写equals,确保比较这个类的所有重要字段,并且要符合那五个约定的情况下进行比较。
    本文写于2019.1.24,历时37天

你可能感兴趣的:(Effective Java(3rd)-Item10 当重载equals方法时遵守普遍契约)