摘要:
本文介绍了一种覆写equals方法的技术,即使在子类扩展一个可以实例化的类并且增加新的字段的情况下,该技术依然能够遵循equals方法的接口约定。
在Effective Java的第七条中,Josh Bloch描述了在面向对象的语言中,当扩展一个类时,要想保持相等性关系是相当困难的。Bloch写到:
在不放弃面向对象的抽象性的好处的情况下,当扩展一个可以实例化的类并且增加新的字段时,要想保持equals方法的语义正确性是很困难的。
在Programming in Scala一书的第二十八章中展示了一种在扩展一个可以实例化的类并且增加新字段的情况下依然保持equals方法语义正确性的技术。尽管该技术是在Scala类的环境中描述的,它同样适用于Java语言。在本文中,将展示用Java语言实现该技术的方法。
相等性的常见陷阱
java.lang.Object类定义了一个可以被子类覆写的equals方法,不幸的是,在面向对象的语言中,编写一个正确的相等性方法是相当困难的。事实上,在研究了大量的Java代码后,2007 paper的作者总结说:
几乎所有的equals方法的实现都是存在缺陷的。
这是很有问题的,因为相等性是很多其他东西的基础。例如,对于一个类C来说,错误的相等性方法可能就意味着你不能放心的把C类的对象放入一个集合中。你可能有两个相等的C类对象elem1和elem2,比如"elem1.equals(elem2)"返回true。但是,如果你的equals方法存在常见的错误,你将看到如下的行为:
下面是在覆写equals方法时引起不一致行为的常见的四种陷阱:
1. 定义了错误的equals方法签名。
2. 改写equals方法的时候没用改写hashCode方法。
3. 定义equals方法的时候使用了可变字段(即具有重新设值方法的字段)。
4. 未能将equals方法定义成等价关系。
这四种陷阱将在本节余下的部分中讨论。
陷阱#1:定义了错误的equals方法签名
考虑为下面的简单的Point类增加一个相等性方法:
一个看似明显的,但却是错误的实现方式如下:
这个方法有什么错误呢?初看起来,它工作的很好:
但是当你把这些points放进一个集合(collection)中时,问题就出现了:
尽管对象p1和p2是相等的,并且你把p1添加到了集合coll中,但是coll却不包含p2对象。在下面演示中,你可以很明显的看到原因所在,下面代码通过隐藏点对象的具体类型,定义了一个p2对象的别名p2a,但是p2a的类型为Object,而不是Point。
现在,重复前面的第一次比较,但是用p2a代替p2,你将得到如下结果:
出现了什么问题?事实上,前面定义的equals方法并没有覆写标准的equals方法,因为它具有不同的参数类型。下面是Object类的equals方法的定义:
因为Point类的equals方法以Point类型而不是Object类型作为参数,它没有覆写Object中的equals方法,而只是重载了它。在Java中重载是用静态参数类型解析的,而不是运行时类型。所以,如果参数类型为Point,则Point的equals方法被调用,而如果参数类型为Object,则Object的equals方法被调用。由于equals方法没有被覆写,所以它仍然比较对象的引用。这就是为什么“p1.equals(p2a)”返回false,尽管p1和p2a具有相同的x和y坐标值。这也是HashSet的contains方法返回false的原因,因为它调用了Object的equals方法而不是重载的Point类的equals方法。
一个比较好的equals方法的实现如下:
现在equals方法具有了正确的类型,它以Object对象作为参数并且返回boolean类型的结果。该方法的实现使用了instanceof和转型操作符,如果Object参数实际上是Point类型,就把它转型为Point对象再比较两个点的坐标值并返回result,否则返回false。
陷阱#2:改写equals方法时没有改写hashCode方法
如果你使用最新的Point类的定义比较p1和p2a对象,你将得到true。但是,如果你执行HashSet的contains方法,你仍然会得到false:
事实上,这个结果并不是100%确定的,你也许会得到true的结果。但是,如果你用具有坐标值1和2的点不断的测试contains方法,你总会得到false值。这儿的问题是Point类改写了equals方法但没有改写hashCode方法。
注意,上面例子中的集合是一个HashSet,这意味着这些元素会根据他们的hash码放入集合的hash桶中。contains方法会首先查找对应的hash桶,然后拿桶中的所有元素和给定的元素比较。最新版本的Point类定义了equals方法,但是没有定义hashCode方法,所以hashCode方法依然是Object类中的:它返回类似于对象地址的信息。尽管p1和p2具有相同的坐标值,但是他们显然具有不同的hash码,不同的hash码极有可能对应Set中不同的hash桶。contains方法查找p2的hash码对应的hash桶中的匹配元素。绝大部分情况下,p1将位于另外一个hash桶中,所以它不会被找到。p1和p2可能在极偶然的情况下位于同一个hash桶中,这时contains方法返回true。
问题在于Point类的实现违反了Object类中关于hashCode方法的约定:
如果两个对象通过equals方法的比较是相等的,那么在这两个对象上调用hashCode方法必将返回相同的整数值。
事实上,在Java中equals方法和hashCode方法总是同时被改写,而且,hashCode方法只依赖于equals方法所使用的字段。对于Point类,下面hashCode方法的一种合适的定义:
这只是hashCode的一种可能的实现。把字段x的值加上41,再把结果乘以41,再加上y的值。这种实现以一种较小的运行时间和代码数量合理的分布了hash码。
增加hashCode方法修复了Point类的相等性问题。但是仍然有其他的问题需要注意。
陷阱#3:定义equals方法的时候使用了可变字段
考虑下面的Point类的简单变形:
唯一的区别是字段x和y不再是非可变的,增加了两个允许客户端改变x和y值的set方法。equals和hashCode方法定义在可变的字段上,所以它们的结果将随着字段的值的改变而改变。当你把这些点放入集合中时可能发生奇怪的现象:
现在,改变p对象的一个字段,集合中仍然包含p对象吗?不妨一试:
看起来很奇怪,p跑到哪儿去了?当你检查集合的迭代Iterator中是否包含p时,更奇怪的事情发生了:
因此,集合中不包含p,但是p在集合的元素中!到底发生了什么?在你修改了x字段后,点p最终位于集合coll中错误的hash桶中。也就是说,原来的hash桶不再对应于新的hash码。换种说法,虽然点p属于集合coll的元素,但是从外部来看已经变的不可见了。
从上面的例子可以看出,当equals和hashCode方法依赖于可变状态时,可能会给用户带来问题,当他们把这种类型的对象放入集合中时,他们就不能再改变这些被依赖的状态。如果你需要根据当前的对象状态进行比较的话,不要把它命名为equals。考虑Point类的最新定义,如果他忽略重新定义的hashCode方法,并且把比较方法命名为equals之外的其他名字,比如equalContents,将更为合适。这时,Point类将继承hashCode和equals方法的默认实现,即使你修改了x字段的值,仍然可以在coll集合中定位到p对象。
陷阱#4:未能将equals方法定义成等价关系
Object类中的equals方法的接口约定指明了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.equals(z)应该返回true。
- 一致性:对于任意的非空对象x和y,对于x.equals(y)的多次调用应该返回一致的结果,除非有一个对象(或者两个都)被修改了。
- 对于任意的非空对象x,x.equals(null)返回false。
Point类的equals方法的当前定义满足了equals方法的接口约定。但是,当考虑到子类时,这个问题会变的更复杂。比如说有一个Point类的子类ColoredPoint,它增加了一个新的Color类型的字段color。假设Color被定义成enum类型:
ColoredPoint类使用color字段重写了equals方法:
很多程序员都会这么写。请注意,在这种情况下,ColoredPoint类不需要覆写hashCode方法,因为ColoredPoint类中的equals方法具有更严格的定义,hashCode方法仍然有效。如果两个colored point是相等的,他们肯定具有相同的坐标,因此也就具有相同的hash码。
对于ColoredPoint类自己来说,它的equals方法的定义看起来很正确。但是,当混合使用points和colored points时,equals接口的约定就被破坏了。考虑下面的代码:
p和cp的比较调用了Point类中定义的equals方法,这个方法只比较两个点的坐标,因此它返回true。另一方面,cp和p的比较调用了ColoredPoint类中定义的equals方法,因为p不是ColoredPoint类型的对象,所以它返回false。equals方法的定义违反了对称性。
失去了对称性对于集合来说会产生不可预料的后果。下面是一个例子:
尽管p和cp是相等的,对contains方法的测试一个成功了另一个却失败了。
怎么样改变equals方法的定义使它具有对称性呢?本质上来说,有两种方法。你可以使相等关系变的更笼统或者更具体。变的更笼统也就是说对于两个对象a和b,比较a和b或者b和a都返回true。下面是代码:
类的equals方法的新定义比老版本多检查了一种情况:如果other对象是Point类型但不是ColoredPoint类型,就使用Point类的equals方法进行比较,这可以使equals方法具有对称性。现在,cp.equals(p)和p.equals(cp)都返回true。但是,这仍然破坏了equals方法的约定,因为新的等价性关系不具有传递性。下面的代码向你展示了这个问题,它定义了一个点和两个有色点,他们都具有相同的坐标值:
ColoredPoint
单独来看,redP和p是相等的,p和blueP也是相等的。
但是,当你比较redP和blueP的时候却返回false:
因此equals方法的传递性被破坏了。
把equals方法变的更笼统看样子是行不通的,那么我们就把它变的更严格。一种使equals变的更严格的方法是总是把不同类的对象区别对待,通过修改Point类和ColoredPoint类的equals方法可以实现。在类Point中,增加一个额外的比较用于检查Point类型的other对象的运行时类和Point类是不是相同的。如下:
你可以把ColoredPoint类的实现恢复到之前的违反了对称性约定的版本:
在这儿,只有Point类的实例具有相同的坐标值并且有相同的运行时类时,他们才被认为是相等的。意思也就是在这些对象上调用.getClass()时返回相同的值。这个定义满足了对称性和传递性,因为只要是不同类的对象它总是返回false。所以一个有色点和一个点总是不相等的。这个约定看起来是很合理的,但是,可能有人会说,这个定义太严格了。
考虑下面的定义坐标点(1,2)的方法:
pAnon和p相等吗?答案是不相等,因为与p和pAnon相关的java.lang.Class对象是不相等的。对于p来说,它是Point,但是对于pAnon来说,它是Point的匿名子类。但是很明显,pAnon只是另一个具有坐标值(1,2)的点,如果认为它和p点不相等是不合理的。
canEqual方法
未完,待续。。。。。。。。。。。。。。。。。。。。。。。。。。。。。