如何在Java中避免equals方法的隐藏陷阱(下)

  接上一篇《如何在Java中避免equals方法的隐藏陷阱(上)》

  陷阱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()); }}

  唯一的不同是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(ntains(p)); // 打印 true

  现在如果你改变p中的一个域,这个集合中还会包含point吗,我们将拭目以待。

  

  p.setX(p.getX() + 1);

  System.out.println(ntains(p)); // (有可能)打印 false

  看起来非常的奇怪。p去那里去了?如果你通过集合的迭代器来检查p是否包含,你将会得到更奇怪的结果。

  

  Iterator<Point> it = erator();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); lor = color; }

  @Override public boolean equals(Object other) { boolean result = false; if (other instanceof ColoredPoint) { ColoredPoint that = (ColoredPoint) other; result = (lor.equals(lor) && super.equals(that)); } return result; }}

  这是很多程序员都有可能写成的代码。注意在本例中,类ColoredPointed不需要重载hashCode,因为新的ColoredPoint类上的equals定义,严格的重载了Point上equals的定义。hashCode的规范仍然是有效,如果两个着色点(colored point)相等,其坐标必定相等,因此它的hashCode也保证了具有同样的值。

  对于ColoredPoint类自身对象的比较是没有问题的,但是如果使用ColoredPoint和Point混合进行比较就要出现问题。

  

  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(ntains(cp)); // 打印 false

  Set<Point> hashSet2 = new java.util.HashSet<Point>();hashSet2.add(cp);System.out.println(ntains(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); lor = color; }

  @Override public boolean equals(Object other) { boolean result = false; if (other instanceof ColoredPoint) { ColoredPoint that = (ColoredPoint) other; result = (lor.equals(lor) && 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 methodpublic 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); lor = color; }

  @Override public boolean equals(Object other) { boolean result = false; if (other instanceof ColoredPoint) { ColoredPoint that = (ColoredPoint) other; result = (lor.equals(lor) && super.equals(that)); } return result; }}

  这里,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); lor = color; }

  @Override public boolean equals(Object other) { boolean result = false; if (other instanceof ColoredPoint) { ColoredPoint that = (ColoredPoint) other; result = (that.canEqual(this) && lor.equals(lor) && 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(ntains(p)); // 打印 true

  System.out.println(ntains(cp)); // 打印 false

  System.out.println(ntains(pAnon)); // 打印 true

  这些例子显示了如果父类在equals的实现定义并调用了canEquals,那么开发人员实现的子类就能决定这个子类是否可以和它父类的实例进行比较。例如ColoredPoint,因为它以”一个着色点永远不可以等于普通不带颜色的点重载了” canEqual,所以他们就不能比较。但是因为pAnon引用的匿名子类没有重载canEqual,因此它的实例就可以和Point的实例进行对比。

  canEqual方法的一个潜在的争论是它是否违背了Liskov替换准则(LSP)。例如,通过比较运行态的类来实现的比较技术(译者注: canEqual的前一版本,使用.getClass()的那个版本),将导致不能定义出一个子类,这个子类的实例可以和其父类进行比较,因此就违背了LSP。这是因为,LSP原则是这样的,在任何你能使用父类的地方你都可以使用子类去替换它。在之前例子中,虽然cp的x,y坐标匹配那些在集合中的点,然而”ntains(cp)”仍然返回false,这看起来似乎违背得了LSP准则,因为你不能这里能使用Point的地方使用一个ColoredPointed。但是我们认为这种解释是错误的,因为LSP原则并没有要求子类和父类的行为一致,而仅要求其行为能一种方式满足父类的规范。

  通过比较运行态的类来编写equals方法(译者注: canEqual的前一版本,使用.getClass()的那个版本)的问题并不是违背LSP准则的问题,但是它也没有为你指明一种创建派生类的实例能和父类实例进行对比的的方法。例如,我们使用这种运行态比较的技术在之前的”ntains(pAnon)”将会返回false,并且这并不是我们希望的。相反我们希望“ntains(cp)”返回false,因为通过在ColoredPoint中重载的equals,我基本上可以说,一个在坐标1,2上着色点和一个坐标1,2上的普通点并不是一回事。然而,在最后的例子中,我们能传递Point两种不同的子类实例到集合中contains方法,并且我们能得到两个不同的答案,并且这两个答案都正确。

  –全文完–

  相关链接:

  如何在Java中避免equals方法的隐藏陷阱(上)

阳光总在风雨后。只有坚强的忍耐顽强的奋斗,

如何在Java中避免equals方法的隐藏陷阱(下)

相关文章:

你感兴趣的文章:

标签云: