覆盖equals需要注意的事项

前言

Object虽然是一个具体类,但是设计时主要是为了扩展,它的所有非final方法如:equals、hashCode、toString、clone和finalize,这些都有明确的通用约定,都是被设计成要覆盖的,当覆盖这些方法的时候,都要遵守这些通用约定,如果不能的话,会造成一些意料之外的后果。

在覆盖之前要注意一下几种情况

在不合适的覆盖equals时,会导致很严重的后果,避免这类问题的办法就是不覆盖equals方法,这时,类的每个实例都只与自身相等,当满足一下的几种条件时,这(类的每个实例都只与自身相等)就是所希望的。

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

对于代表活动实体而不是值的类来说确实如此,例如Thread。Object提供的equals对于这些类来说所返回的值就是所期望看到的结果,因此不用覆盖equals方法。

不关心类是否提供了“逻辑相等”的测试功能。

例如java.util.Random覆盖了equals,以检查两个Random实例是否产生相同的随机数序列,但是设计者并不认为客户需要这样的功能(随机数大多数不需要下一个产生的随机数不与之前产生的随机数相同,除非作为主键,但是用随机数来作为主键保持绝对不重复是不恰当的)。在这种情况下,从Object中继承得到的equals实现已经足够了。因此不用覆盖equals方法。

父类已经覆盖了equals,并且继承过来的equals的行为对于子类来说也是适用的

例如,大多数的Set实现都从AbstractSet继承equals实现,List实现从AbstractList继承equals实现,Map实现从AbstractMap继承equals实现。当从父类继承的equals实现已经满足使用,那么就不必再次覆盖equals(说得好像废话)。

类是私有的或是包级私有的,可以确定它的equals方法永远不会被调用。

在这种equals永远不会被调用的情况下,无疑是不用覆盖equals方法的。

在《Effective Java中文版》中翻译有误,原书解释为:在这种情况下,无疑是应该覆盖equals方法的,以防它被意外调用。我当时百思不得其解,为什么永远不会被调用的情况还要防止被意外调用?然后我去看了英文原版,原版内容为

The class is private or package-private, and you are certain that its equals method will never be invoked.

然后我一脸懵逼…这就说明有的时候要相信自己的判断,去寻找真相,而不是迷信书籍和权威。

覆盖equals方法时要遵守的通用约定

equals方法实现了等价关系:

自反性。

对于任何非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)就会一直的返回相同的结果。(总不能大嘴巴胡扯吧..程序是一个很严谨的东西,只要引用值所用信息没有改变,那么不管调用多少次,返回值肯定是一样的)

非空性

对于任何非null的引用值x,x.equals(null)必须返回false。(这个要求没有名称,《Effective Java》中作者称之为非空性)

违反equals约定的后果

虽然这些规定看起来很多,有点让人感到恐惧,但是也绝对不能忽视这些规定!如果违反了它们,程序就会表现的不正常甚至崩溃,用John Donne的话说,没有哪个类是孤立的。一个类的实例通常会被频繁的传递给另一个类的实例。有许多泪,包括所有的集合类在内,都依赖于传递给它们的对象是否遵守了equals约定。

自反性

这个要求就是说明对象必须等于自身,这个也是 Object类中提供的最基本的equals方法,加入你违反了这一条,当你添加到集合中,这个集合的contains方法将告诉你:这个集合不包括你刚刚添加的实例。

对称性

这个要求是说,A=B时,也必须B=A,当你违反了这一条时,当你把A添加到集合中,然后list.contains(B)时,没人知道你的程序会发生什么,有可能返回true,也有可能抛出一个运行时异常。一旦违反了equals约定,当其他对象面对你的对象时,你完全不知道这些对象的行为会怎样。

传递性

当一个类有颜色这种属性,这个类的子类有颜色和长度两种属性,在父类的equals方法中只比较颜色,当两个对象的颜色相等时,返回true,而子类的equals方法中当颜色和长度都相等时才会返回true,那么父类的一个对象A与子类的两个对象B互相使用equals方法时会有什么后果呢?后果不难想象,一个返回true一个返回false,可以这样做尝试修正问题,在子类的equals在进行“混合比较”时忽略掉长度信息,这种方法确实提供了对称性,但是却牺牲了传递性。那么怎么解决这种问题呢?事实上,面向对象语言中关于等价关系的一个基本问题。我们无法再扩展可实例化类的同时,既增加新的值组件,同时又保证equals约定,除非愿意放弃面向对象的抽象所带来的优势。在这时,我们应该看一下里氏替换原则,在此不再赘述。
在Java平台类库中,有一些类扩展了可实例化得嘞,并添加了新的值组件,例如java.sql.Timestamp对java.util.Date进行了扩展,并增加了nanoseconds域,Timestamp的equals实现就违反了对称性,如果Timestamp和Date对象呗用于同一集合中,或者以其他方法混合在一起,就会引起不正确的行为,Timestamp类有个免责声明,告诫程序员不要混用这两个类,只要不混合在一起,就不会有麻烦,除此之外没有其他措施可以解决,并且导致的错误将很难调试。
对了,可以在一个抽象类的子类中增加新的值组件,并且不会违反equals约定,我的理解是,只要不可能直接创建父类的对象,那么上面所述问题将不会存在。

一致性

这条要求说的是:如果两个对象相等,他们就必须始终保持相等,除非他们之间有一个或两个被修改过。无论类是否是不可变的,都不要使equals方法依赖于不可靠资源。

非空性

这条的意思是所有的对象都不等于null,在很多类的equals方法中都通过一个显式的null测试来防止这种情况,其实这个检查是不必要的,因为equals方法必须使用instanceof来判断参数是否为正确的类型,在这一步就能把null给过滤掉,返回false。

实现高质量equals方法的诀窍

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

如果是,则返回true,这只是一个性能优化,如果参数是这个对象的引用的话,那么就肯定为true不用进行后边的比较了,如果后边的比较操作有可能很昂贵,那么这么做就是有必要的。

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

如果不是,那么直接返回false,所谓的“正确的类型”是指equals方法所在的哪个类,有些情况下,是指该类所实现的某个接口,如果类实现的接口改进了equals约定,允许在实现了该接口的类之间进行比较,那么就使用接口,集合接口就具有这种特性。

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

因为转换之前进行了instanceof测试,所以转换肯定会成功。

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

如果这些测试全部成功,则返回true,否则则返回false。如果第2步中的类型是个接口,就必须通过接口方法访问参数中的域,如果该类型是个类,那就要取决于它们的可访问性。

5. 当你编写完成equals方法后,你应该问自己三个问题:它们是否是对称的、传递的、一致的?

并且编写单元测试去检验这些特性!如果答案是不是,那就要找出并修改equals方法中的代码,当然,也必须满足其他两个特性(自反性和非空性),但是这两种特性通常会自动满足。

最后的警告

覆盖equals时总要覆盖hashCode

如果不这样做,就会违反Object.hashCode的通用约定,从而导致该类无法结合所有基于散列的集合一起正常运作。因没有覆盖hashCode而违反的关键约定是第二条:相等的对象必须具有相等的散列码。

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

如果只是简单的测试值是否相等,则不难做到遵守equals约定,如果想过度的寻求各种等价关系,则很容易陷入麻烦之中。

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

因为如果改变了参数类型,那么就是重载而不是重写它也许能够稍微改善性能,但是与增加的复杂性想比,这样做是不可取的。

参考资料

《Effective Java 中文版 第2版》 第8条:覆盖equals时请遵守通用约定 P28-38

文中大部分为书中所作,加上本人的理解与感悟。如有错误,欢迎评论区指正。

同时欢迎关注我的微信公众号:猿人族永不为奴。

二维码:“请使用微信扫一扫关注”


-------------本文结束感谢您的阅读-------------