如安在Java中制止equals要领的埋没陷阱
副标题#e#
译者注 :你大概会以为Java很简朴,Object的equals实现也会很是简朴,可是事实并不是你想象的这样,耐性的读完本文,你会发明你对Java相识的是如此的少。假如这篇文章是一份Java措施员的入职笔试,那么不知道有几多人会掉落到这样的陷阱中。
摘要
本文描写重载equals要领的技能,这种技能纵然是具现类的子类增加了字段也能担保equal语义的正确性。
在《Effective Java》的第8项中,Josh Bloch描写了当担任类作为面向工具语言中的等价干系的基本问题,要担保派生类的equal正确性语义所谋面临的坚苦。Bloch这样写到:
除非你健忘了面向工具抽象的长处,不然在当你担任一个新类或在类中增加了一个值组件时你无法同时担保equal的语义依然正确
在《Programming in Scala》中的第28章演示了一种要领,这种要领答允纵然担任了新类,增加了新的值组件,equal的语义仍然能获得担保。固然在这本书中这项技能是在利用Scala类情况中,可是这项技能同样可以应用于Java界说的类中。在本文中的描写来自于Programming in Scala中的文字描写,可是代码被我从scala翻译成了Java
常见的等价要领陷阱
java.lang.Object 类界说了equals这个要领,它的子类可以通过重载来包围它。不幸的是,在面向工具中写出正确的equals要领长短常坚苦的。事实上,在研究了大量的Java代码后,2007 paper的作者得出了如下的一个结论:
险些所有的equals要领的实现都是错误的!
这个问题是因为等价是和许多其他的事物相关联。譬喻个中之一,一个的范例C的错误等价要领大概意味着你无法将这个范例C的工具可信赖的放入到容器中。好比说,你有两个元素elem1和elem2他们都是范例C的工具,而且他们是相等,即elem1.equals(elm2)返回ture。可是,只要这个equals要领是错误的实现,那么你就有大概会瞥见如下的一些行为:
Set hashSet<C> = new java.util.HashSet<C>();
hashSet.add(elem1);
hashSet.contains(elem2); // returns false!
当equals重载时,这里有4个会激发equals行为纷歧致的常见陷阱:
界说了错误的equals要领签名(signature) Defining equals with the wrong signature.
重载了equals的但没有同时重载hashCode的要领。 Changing equals without also changing hashCode.
成立在会变革字域上的equals界说。 Defining equals in terms of mutable fields.
不满意等价干系的equals错误界说 Failing to define equals as an equivalence relation.
在剩下的章节中我们将依次接头这4中陷阱。
陷阱1:界说错误equals要领签名(signature)
思量为下面这个简朴类Point增加一个等价性要领:
public class Point {
private final int x;
private final int y;
public Point(int x, int y) {
this.x = x;
this.y = y;
}
public int getX() {
return x;
}
public int getY() {
return y;
}
// ...
}
#p#副标题#e#
看上去很是明明,可是凭据这种方法来界说equals就是错误的。
// An utterly wrong definition of equals
public boolean equals(Point other) {
return (this.getX() == other.getX() && this.getY() == other.getY());
}
这个要领有什么问题呢?初看起来,它事情的很是完美:
Point p1 = new Point(1, 2);
Point p2 = new Point(1, 2);
Point q = new Point(2, 3);
System.out.println(p1.equals(p2)); // prints true
System.out.println(p1.equals(q)); // prints false
然而,当我们一旦把这个Point类的实例放入到一个容器中问题就呈现了:
import java.util.HashSet;
HashSet<Point> coll = new HashSet<Point>();
coll.add(p1);
System.out.println(coll.contains(p2)); // prints false
为什么coll中没有包括p2呢?甚至是p1也被加到荟萃内里,p1和p2是是等价的工具吗?在下面的措施中,我们可以找到个中的一些原因,界说p2a是一个指向p2的工具,可是p2a的范例是Object而非Point范例:
Object p2a = p2;
此刻我们反复第一个较量,可是不再利用p2而是p2a,我们将会获得如下的功效:
System.out.println(p1.equals(p2a)); // prints false
到底是哪里出了了问题?事实上,之前所给出的equals版本并没有包围Object类的equals要领,因为他的范例差异。下面是Object的equals要领的界说
public boolean equals(Object other)
因为Point类中的equals要领利用的是以Point类而非Object类做为参数,因此它并没有包围Object中的equals要领。而是一种变革了的重载。在Java中重载被理会为静态的参数范例而非运行期的范例,因此当静态参数范例是Point,Point的equals要领就被挪用。然而当静态参数范例是Object时,Object类的equals就被挪用。因为这个要领并没有被包围,因此它仍然是实现成较量工具标示。这就是为什么固然p1和p2a具有同样的x,y值,”p1.equals(p2a)”仍然返回了false。这也是会什么HasSet的contains要领返回false的原因,因为这个要领操纵的是泛型,他挪用的是一般化的Object上equals要领而非Point类上变革了的重载要领equals
一个更好但不完美的equals要领界说如下:
#p#分页标题#e#
// A better definition, but still not perfect
@Override public boolean equals(Object other) {
boolean result = false;
if (other instanceof Point) {
Point that = (Point) other;
result = (this.getX() == that.getX() && this.getY() == that.getY());
}
return result;
}
此刻equals有了正确的范例,它利用了一个Object范例的参数和一个返回布尔型的功效。这个要领的实现利用instanceof操纵和做了一个造型。它首先查抄这个工具是否是一个Point类,假如是,他就较量两个点的坐标并返回功效,不然返回false。
陷阱2:重载了equals的但没有同时重载hashCode的要领
假如你利用上一个界说的Point类举办p1和p2a的重复较量,你城市获得你预期的true的功效。可是假如你将这个类工具放入到HashSet.contains()要领中测试,你就有大概仍然获得false的功效:
Point p1 = new Point(1, 2);
Point p2 = new Point(1, 2);
HashSet<Point> coll = new HashSet<Point>();
coll.add(p1);
System.out.println(coll.contains(p2)); // 打印 false (有大概)
事实上,这个个功效不是100%的false,你也大概有返回ture的经验。假如你获得的功效是true的话,那么你试试其他的坐标值,最终你必然会获得一个在荟萃中不包括的功效。导致这个功效的原因是Point重载了equals却没有重载hashCode。
留意上面例子的的容器是一个HashSet,这就意味着容器中的元素按照他们的哈希码被被放入到”哈希桶 hash buckets”中。contains要领首先按照哈希码在哈希桶中查找,然后让桶中的所有元素和所给的参数举办较量。此刻,固然最后一个Point类的版本重界说了equals要领,可是它并没有同时重界说hashCode。因此,hashCode仍然是Object类的谁人版本,即:所分派工具的一个地点的调动。所以p1和p2的哈希码理所虽然的差异了,甚至是即时这两个点的坐标完全沟通。差异的哈希码导致他们具有极高的大概性被放入到荟萃中差异的哈希桶中。contains要领将会去找p2的哈希码对应哈希桶中的匹配元素。可是大大都环境下,p1必然是在别的一个桶中,因此,p2永远找不到p1举办匹配。虽然p2和p2也大概偶然会被放入到一个桶中,在这种环境下,contains的功效就为true了。
最新一个Point类实现的问题是,它的实现违背了作为Object类的界说的hashCode的语义。
假如两个工具按照equals(Object)要领是相等的,那么在这两个工具上挪用hashCode要领应该发生同样的值
事实上,在Java中,hashCode和equals需要一起被重界说是众所周知的。另外,hashCode只可以依赖于equals依赖的域来发生值。对付Point这个类来说,下面的的hashCode界说是一个很是符合的界说。
public class Point {
private final int x;
private final int y;
public Point(int x, int y) {
this.x = x;
this.y = y;
}
public int getX() {
return x;
}
public int getY() {
return y;
}
@Override public boolean equals(Object other) {
boolean result = false;
if (other instanceof Point) {
Point that = (Point) other;
result = (this.getX() == that.getX() && this.getY() == that.getY());
}
return result;
}
@Override public int hashCode() {
return (41 * (41 + getX()) + getY());
}
}
这只是hashCode一个大概的实现。x域加上常量41后的功效再乘与41并将功效在加上y域的值。这样做就可以以低本钱的运行时间和低本钱代码巨细获得一个哈希码的公道的漫衍(译者注:性价比相对较高的做法)。
增加hashCode要领重载批改了界说雷同Point类等价性的问题。然而,关于类的等价性仍然有其他的问题点待发明。
陷阱3:成立在会变革字段上的equals界说
让我们在Point类做一个很是微小的变革
public class Point {
private int x;
private int y;
public Point(int x, int y) {
this.x = x;
this.y = y;
}
public int getX() {
return x;
}
public int getY() {
return y;
}
public void setX(int x) { // Problematic
this.x = x;
}
public void setY(int y) {
this.y = y;
}
@Override public boolean equals(Object other) {
boolean result = false;
if (other instanceof Point) {
Point that = (Point) other;
result = (this.getX() == that.getX() && this.getY() == that.getY());
}
return result;
}
@Override public int hashCode() {
return (41 * (41 + getX()) + getY());
}
}
#p#分页标题#e#
独一的差异是x和y域不再是final,而且两个set要领被增加到类中来,并答允客户改变x和y的值。equals和hashCode这个要领的界说此刻是基于在这两个会产生变革的域上,因此当他们的域的值改变时,功效也就随着改变。因此一旦你将这个point工具放入到荟萃中你将会看到很是神奇的结果。
Point p = new Point(1, 2);
HashSet<Point> coll = new HashSet<Point>();
coll.add(p);
System.out.println(coll.contains(p)); // 打印 true
此刻假如你改变p中的一个域,这个荟萃中还会包括point吗,我们将拭目以待。
p.setX(p.getX() + 1);
System.out.println(coll.contains(p)); // (有大概)打印 false
看起来很是的奇怪。p去哪里去了?假如你通过荟萃的迭代器来查抄p是否包括,你将会获得更奇怪的功效。
Iterator<Point> it = coll.iterator();
boolean containedP = false;
while (it.hasNext()) {
Point nextP = it.next();
if (nextP.equals(p)) {
containedP = true;
break;
}
}
System.out.println(containedP); // 打印 true
功效是,荟萃中不包括p,可是p在荟萃的元素中!到底产生了什么!虽然,所有的这一切都是在x域的修改后才产生的,p最终的的hashCode是在荟萃coll错误的哈希桶中。即,原始哈希桶不再有其新值对应的哈希码。换句话说,p已经在荟萃coll的是视野范畴之外,固然他仍然属于coll的元素。
从这个例子所获得的教导是,当equals和hashCode依赖于会变革的状态时,那么就会给用户带来问题。假如这样的工具被放入到荟萃中,用户必需小心,不要修改这些这些工具所依赖的状态,这是一个小陷阱。假如你需要按照工具当前的状态举办较量的话,你应该不要再重界说equals,应该起其他的要领名字而不是equals。对付我们的Point类的最后的界说,我们最好省略掉hashCode的重载,并将较量的要领名定名为equalsContents,或其他差异于equals的名字。那么Point将会担任本来默认的equals和hashCode的实现,因此当我们修改了x域后p依然会呆在其本来在容器中应该在位置。
陷阱4:不满意等价干系的equals错误界说
Object中的equals的类型叙述了equals要领必需实此刻非null工具上的等价干系:
自反原则:对付任何非null值X,表达式x.equals(x)总返回true。
等价性:对付任何非空值x和y,那么当且仅当y.equals(x)返回真时,x.equals(y)返回真。
通报性:对付任何非空值x,y,和z,假如x.equals(y)返回真,且y.equals(z)也返回真,那么x.equals(z)也应该返回真。
一致性:对付非空x,y,多次挪用x.equals(y)应该一致的返回真或假。提供应equals要领较量利用的信息不该该包括悔改的信息。
对付任何非空值x,x.equals(null)应该总返回false.
Point类的equals界说已经被开拓成了足够满意equals类型的界说。然而,当思量到担任的时候,工作就开始变得很是巨大起来。好比说有一个Point的子类ColoredPoint,它比Point多增加了一个范例是Color的color域。假设Color被界说为一个列举范例:
public enum Color {
RED, ORANGE, YELLOW, GREEN, BLUE, INDIGO, VIOLET;
}
ColoredPoint重载了equals要领,并思量到新插手color域,代码如下:
public class ColoredPoint extends Point { // Problem: equals not symmetric
private final Color color;
public ColoredPoint(int x, int y, Color color) {
super(x, y);
this.color = color;
}
@Override public boolean equals(Object other) {
boolean result = false;
if (other instanceof ColoredPoint) {
ColoredPoint that = (ColoredPoint) other;
result = (this.color.equals(that.color) && super.equals(that));
}
return result;
}
}
这是许多措施员都有大概写成的代码。留意在本例中,类ColoredPointed不需要重载hashCode,因为新的ColoredPoint类上的equals界说,严格的重载了Point上equals的界说。hashCode的类型仍然是有效,假如两个着色点(colored point)相等,其坐标肯定相等,因此它的hashCode也担保了具有同样的值。
对付ColoredPoint类自身工具的较量是没有问题的,可是假如利用ColoredPoint和Point殽杂举办较量就要呈现问题。
#p#分页标题#e#
Point p = new Point(1, 2);
ColoredPoint cp = new ColoredPoint(1, 2, Color.RED);
System.out.println(p.equals(cp)); // 打印真 true
System.out.println(cp.equals(p)); // 打印假 false
“p等价于cp”的较量这个挪用的是界说在Point类上的equals要领。这个要领只思量两个点的坐标。因此较量返回真。在别的一方面,“cp等价于p”的较量这个挪用的是界说在ColoredPoint类上的equals要领,返回的功效却是false,这是因为p不是ColoredPoint,所以equals这个界说违背了对称性。
违背对称性对付荟萃来说将导致不行以预期的效果,譬喻:
Set<Point> hashSet1 = new java.util.HashSet<Point>();
hashSet1.add(p);
System.out.println(hashSet1.contains(cp)); // 打印 false
Set<Point> hashSet2 = new java.util.HashSet<Point>();
hashSet2.add(cp);
System.out.println(hashSet2.contains(p)); // 打印 true
因此固然p和cp是等价的,可是contains测试中一个返回乐成,别的一个却返回失败。
你如何修改equals的界说,才气使得这个要领满意对称性?本质上说有两种要领,你可以使得这种干系变得更一般化或更严格。更一般化的意思是这一对工具,a和b,被用于举办比拟,无论是a比b照旧b比a 都返回true,下面是代码:
public class ColoredPoint extends Point { // Problem: equals not transitive
private final Color color;
public ColoredPoint(int x, int y, Color color) {
super(x, y);
this.color = color;
}
@Override public boolean equals(Object other) {
boolean result = false;
if (other instanceof ColoredPoint) {
ColoredPoint that = (ColoredPoint) other;
result = (this.color.equals(that.color) && super.equals(that));
}
else if (other instanceof Point) {
Point that = (Point) other;
result = that.equals(this);
}
return result;
}
}
在ColoredPoint中的equals的新界说比老界说中查抄了更多的环境:假如工具是一个Point工具而不是ColoredPoint,要领就转变为Point类的equals要领挪用。这个所但愿到达的结果就是equals的对称性,不管”cp.equals(p)”照旧”p.equals(cp)”的功效都是true。然而这种要领,equals的类型照旧被粉碎了,此刻的问题是这个新等价性不满意通报性。思量下面的一段代码实例,界说了一个点和这个点上上两种差异颜色点:
ColoredPoint redP = new ColoredPoint(1, 2, Color.RED);
ColoredPoint blueP = new ColoredPoint(1, 2, Color.BLUE);
redP等价于p,p等价于blueP
System.out.println(redP.equals(p)); // prints true
System.out.println(p.equals(blueP)); // prints true
然而,比拟redP和blueP的功效是false:
System.out.println(redP.equals(blueP)); // 打印 false
因此,equals的通报性就被违背了。
使equals的干系更一般化好像会将我们带入到死胡同。我们应该回收更严格化的要领。一种更严格化的equals要领是认为差异类的工具是差异的。这个可以通过修改Point类和ColoredPoint类的equals要领来到达。你能增加特另外较量来查抄是否运行态的这个Point类和谁人Point类是同一个类,就像如下所示的代码一样:
// A technically valid, but unsatisfying, equals method
public class Point {
private final int x;
private final int y;
public Point(int x, int y) {
this.x = x;
this.y = y;
}
public int getX() {
return x;
}
public int getY() {
return y;
}
@Override public boolean equals(Object other) {
boolean result = false;
if (other instanceof Point) {
Point that = (Point) other;
result = (this.getX() == that.getX() && this.getY() == that.getY()
&& this.getClass().equals(that.getClass()));
}
return result;
}
@Override public int hashCode() {
return (41 * (41 + getX()) + getY());
}
}
你此刻可以将ColoredPoint类的equals实现用回适才谁人不满意对称性要的equals实现了。
public class ColoredPoint extends Point { // 不再违阻挡称性需求
private final Color color;
public ColoredPoint(int x, int y, Color color) {
super(x, y);
this.color = color;
}
@Override public boolean equals(Object other) {
boolean result = false;
if (other instanceof ColoredPoint) {
ColoredPoint that = (ColoredPoint) other;
result = (this.color.equals(that.color) && super.equals(that));
}
return result;
}
}
#p#分页标题#e#
这里,Point类的实例只有当和别的一个工具是同样类,而且有同样的坐标时候,他们才被认为是相等的,即意味着 .getClass()返回的是同样的值。这个新界说的等价干系满意了对称性和通报性因为对付较量工具是差异的类时功效老是false。所以着色点(colored point)永远不会便是点(point)。凡是这看起来很是公道,可是这里也存在着别的一种争论——这样的较量过于严格了。
思量我们如下这种稍微的迂回的方法来界说我们的坐标点(1,2)
Point pAnon = new Point(1, 1) {
@Override public int getY() {
return 2;
}
};
pAnon便是p吗?谜底是假,因为p和pAnon的java.lang.Class工具差异。p是Point,而pAnon是Point的一个匿名派生类。可是,很是清晰的是pAnon简直是在坐标1,2上的别的一个点。所以将他们认为是差异的点是没有来由的。
canEqual 要领
到此,我们看其来好像是碰着阻碍了,存在着一种正常的方法不只可以在差异类担任条理上界说等价性,而且担保其等价的类型性吗?事实上,简直存在这样的一种要领,可是这就要求除了重界说equals和hashCode外还要别的的界说一个要领。根基思路就是在重载equals(和hashCode)的同时,它应该也要要明晰的声明这个类的工具永远不等价于其他的实现了差异等价要领的超类的工具。为了到达这个方针,我们对每一个重载了equals的类新增一个要领canEqual要领。这个要领的要领签名是:
public boolean canEqual(Object other)
假如other 工具是canEquals(重)界说谁人类的实例时,那么这个要领应该返回真,不然返回false。这个要领由equals要领挪用,并担保了两个工具是可以彼此较量的。下面Point类的新的也是最终的实现:
public class Point {
private final int x;
private final int y;
public Point(int x, int y) {
this.x = x;
this.y = y;
}
public int getX() {
return x;
}
public int getY() {
return y;
}
@Override public boolean equals(Object other) {
boolean result = false;
if (other instanceof Point) {
Point that = (Point) other;
result =(that.canEqual(this) && this.getX() == that.getX() && this.getY() == that.getY());
}
return result;
}
@Override public int hashCode() {
return (41 * (41 + getX()) + getY());
}
public boolean canEqual(Object other) {
return (other instanceof Point);
}
}
这个版本的Point类的equals要领中包括了一个特另外需求,通过canEquals要领来抉择别的一个工具是否是是满意可以较量的工具。在Point中的canEqual宣称了所有的Point类实例都能被较量。
下面是ColoredPoint相应的实现
public class ColoredPoint extends Point { // 不再违背对称性
private final Color color;
public ColoredPoint(int x, int y, Color color) {
super(x, y);
this.color = color;
}
@Override public boolean equals(Object other) {
boolean result = false;
if (other instanceof ColoredPoint) {
ColoredPoint that = (ColoredPoint) other;
result = (that.canEqual(this) && this.color.equals(that.color) && super.equals(that));
}
return result;
}
@Override public int hashCode() {
return (41 * super.hashCode() + color.hashCode());
}
@Override public boolean canEqual(Object other) {
return (other instanceof ColoredPoint);
}
}
在上显示的新版本的Point类和ColoredPoint类界说担保了等价的类型。等价是对称和可通报的。较量一个Point和ColoredPoint类老是返回false。因为点p和着色点cp,“p.equals(cp)返回的是假。而且,因为cp.canEqual(p)总返回false。相反的较量,cp.equals(p)同样也返回false,由于p不是一个ColoredPoint,所以在ColoredPoint的equals要领体内的第一个instanceof查抄就失败了。
别的一个方面,差异的Point子类的实例却是可以较量的,同样没有重界说等价性要领的类也是可以较量的。对付这个新类的界说,p和pAnon的较量将总返回true。下面是一些例子:
Point p = new Point(1, 2);
ColoredPoint cp = new ColoredPoint(1, 2, Color.INDIGO);
Point pAnon = new Point(1, 1) {
@Override public int getY() {
return 2;
}
};
Set<Point> coll = new java.util.HashSet<Point>();
coll.add(p);
System.out.println(coll.contains(p)); // 打印 true
System.out.println(coll.contains(cp)); // 打印 false
System.out.println(coll.contains(pAnon)); // 打印 true
#p#分页标题#e#
这些例子显示了假如父类在equals的实现界说并挪用了canEquals,那么开拓人员实现的子类就能抉择这个子类是否可以和它父类的实例举办较量。譬喻ColoredPoint,因为它以”一个着色点永远不行以便是普通不带颜色的点重载了” canEqual,所以他们就不能较量。可是因为pAnon引用的匿名子类没有重载canEqual,因此它的实例就可以和Point的实例举办比拟。
canEqual要领的一个潜在的争论是它是否违背了Liskov替换准则(LSP)。譬喻,通过较量运行态的类来实现的较量技能(译者注: canEqual的前一版本,利用.getClass()的谁人版本),将导致不能界说出一个子类,这个子类的实例可以和其父类举办较量,因此就违背了LSP。这是因为,LSP原则是这样的,在任何你能利用父类的处所你都可以利用子类去替换它。在之前例子中,固然cp的x,y坐标匹配那些在荟萃中的点,然而”coll.contains(cp)”仍然返回false,这看起来好像违背得了LSP准则,因为你不能这里能利用Point的处所利用一个ColoredPointed。可是我们认为这种表明是错误的,因为LSP原则并没有要求子类和父类的行为一致,而仅要求其行为能一种方法满意父类的类型。
通过较量运行态的类来编写equals要领(译者注: canEqual的前一版本,利用.getClass()的谁人版本)的问题并不是违背LSP准则的问题,可是它也没有为你指明一种建设派生类的实例能和父类实例举办比拟的的要领。譬喻,我们利用这种运行态较量的技能在之前的”coll.contains(pAnon)”将会返回false,而且这并不是我们但愿的。相反我们但愿“coll.contains(cp)”返回false,因为通过在ColoredPoint中重载的equals,我根基上可以说,一个在坐标1,2上着色点和一个坐标1,2上的普通点并不是一回事。然而,在最后的例子中,我们能通报Point两种差异的子类实例到荟萃中contains要领,而且我们能获得两个差异的谜底,而且这两个谜底都正确。
–全文完–