Effective.Java 读书笔记(8)关于equals方法

8.Obey the jeneral contract when overriding equals

大意为 当重写equals方法的时候遵循通常的规范

重写equals看上去十分简单对吧,但是我觉得很多时候重写equals可能会招致一些问题,这些问题有时可能会特别严重,当然了不重写不就完事了吗?但是这只适用于那些每个实例只等于自身的类,注意以下几种可以不重写的情况:

  • 类的每个实例都是固有的特殊,比如就拿Thread这个类来说,这个类代表活动的实体而不是那些值。Object类提供了equals的实现对于这些类来说就已经是正确且合适的实现了
  • 你并不在意一个类是否提供一个“逻辑上相等”的测试,举个例子,java.util.Random这个类可以被重写equals来检查两个Random实例是否会产生相同的随机数序列,但是设计者们并没有去考虑到用户会需要或者想要这样的功能,正是在这样的情况下,equals的实现继承于Object的实现是适当的
  • 一个类的父类已经被重写了equals方法了,而且这个父类的行为对于这个类是合适的,举个例子,大多数的Set的equals实现继承于AbstractSet的equals实现,List的equals实现继承于AbstractList,Map的equals实现继承于AbstractMap等等
  • 这个类是private或者是package-prvate,并且你十分清除它的equals方法永远不会被调用,可以说,这样的情况下还是得重写,以防止意外调用的情况:
@Override public boolean equals(Object o) {
     throw new AssertionError(); // Method is never called
}

所以什么时候重写Object.equals方法比较合适呢?即,当一个类有一个逻辑相等的概念,并且这个概念不同于对象的特性,并且父类也没有已经重写了的equals方法来实现需求的情况。依赖于值的类就是一个明显的例子,比如Integer,Date这样的类,你一般不会去想了解两个引用是否来自同一个对象,你通常想比较值的大小这样的逻辑上的比较。这样的重写并不是仅仅满足程序员的需求,它还可以使一个实例当作Map的键或者让元素变得可预测的,有着期望的行为的。

当然值类型的类也不一定需要重写,比如那些有着实例控制来保证每一个值最多只有一个实例存在的类,正如同Enum类。对于这样的类,逻辑上的相等就是对象的相等,所以直接用父类Object的equals方法就可以了

当你重写equals方法的时候,你必须坚持一般性的规范,下面有一些来自于Object的规范[Java SE6]

equals方法实现一种等价关系,这种关系是:

  • 自反的,也就是说对于任意的非空引用x,必须有x.equals(x)返回true(想起离散或者高代了:))
  • 对称的,对于任意的非空引用x,y,如果x.equals(y)返回true那么y.equals(x)也必然返回true
  • 传递性,对于任意的非空引用x,y,z,如果x.equals(y)返回true且y.equals(z)也返回true,那么x.equals(z)也返回true
  • 一致性,对于任意的非空引用x,y,不管多少次调用x.equals(y) ,返回的值有且只有同一个(一直是true或者一直false)
  • 对于任意的非空引用x,x.equals(null)肯定返回false

除非你接触过代数或者离散,上述关系可能看上去有点可怕,但是不要忽略它!,如果你违反上述条件,你的程序可能会炸,并且定位错误的源也相当困难,利用英国一名著名的诗人John Donne的风格来说,没有一个类是一个小岛,一个类的实例总是会频繁地传给另一个。许多的类,包括所有集合类,取决于那些传递给他们的并且遵循equals规范的的对象。

No class is an island. Instances of one class are fre- quently passed to another. Many classes, including all collections classes, depend on the objects passed to them obeying the equals contract.

现在你应该知道违反规范的危险了吧,下面对于这些规范做出更加细致的讨论,好消息是这些规范其实并不复杂,只要你一旦明白,对于坚持这些规范也就变得很简单了

自反性,第一个条件,代表着一个类必须和自身相等,很难去想象无意中会违反这个规范,一般来说是不会违反的,如果你违反了这个规定,比如你创建了一个实例并把它加到一个集合中,那么这个集合中可能没有你刚刚加上去的类,太可怕了

对称性,第二个条件,即两个对象只要一个方向相等,那么就两个方向相等,现实中还是有可能会出一点错的,举个例子,我们来看看下面的这个类,实现了一个不区分大小写的string,大小写在toString里面保留但是在比较的时候忽略了

public final class CaseInsensitiveString {
private final String s;
  public CaseInsensitiveString(String s) {
    if (s == null)
        throw new NullPointerException();
    this.s = s;
  }
// Broken - violates symmetry!
  @Override
  public boolean equals(Object o) {
       if (o instanceof CaseInsensitiveString)
            return s.equalsIgnoreCase(
                 ((CaseInsensitiveString) o).s);
       if (o instanceof String) // One-way interoperability!
            return s.equalsIgnoreCase((String) o);
       return false;
  }
... // Remainder omitted
}

在这个例子中很明显,如果我们创建一个String和一个CaseInsensitiveString的两个实例,就产生了非对称的结果,如下:

CaseInsensitiveString cis = new CaseInsensitiveString("Polish");
String s = "polish";

如同我们预料的那样,cis.equals(s)返回true,但是s.equals(cis)却返回false,因为String类并不知道有CaseInsensitiveString,故直接false掉,我们还可以创建一个List,把cis加上去

List<CaseInsensitiveString> list =
     new ArrayList<CaseInsensitiveString>();
list.add(cis);

然后调用list.contain(s),试试看会发生什么,可能是false,可能是直接抛出异常,一旦你违反了equals规范,你甚至都不知道当其他类面对你的类的时候会怎样地表现

为了消除这个问题,只需要移除对于String不合理的互操作即可,你的方法将会化简成这样

@Override
public boolean equals(Object o) {
     return o instanceof CaseInsensitiveString &&
          ((CaseInsensitiveString) o).s.equalsIgnoreCase(s);
}

传递性,第三个条件,意味着链式的相等,那么这条链上的都是两两相等的,那么这条原则也是可能出错的,考虑一种情况,一个子类,在父类的基础上加了一个新的值的成员组成,换句话说,子类加上了一个会影响equals比较的信息,我们用一个简单不可变的二维的整数点类来作为例子

public class Point {
  private final int x;
  private final int y;
  public Point(int x, int y) {
    this.x = x;
    this.y = y;
  }
@Override
  public boolean equals(Object o) {
    if (!(o instanceof Point))
         return false;
    Point p = (Point)o;
    return p.x == x && p.y == y;
  }
... // Remainder omitted
}

我们来写一个子类继承于这个点类,比如写一个ColorPoint

public class ColorPoint extends Point {
  private final Color color;
  public ColorPoint(int x, int y, Color color) {
    super(x, y);
    this.color = color;
  }
... // Remainder omitted
}

那么equals方法应该变成怎样呢?如果你完全没有重写的发,直接使用Point的equals方法来实现,那么color的信息就会被忽略,在不违反规范的前提下,这是不被接受的,假定你重写了equals方法,如果参数是其他的color point并且x,y和color相同的话这个方法返回true:

@Override
public boolean equals(Object o) {
     if (!(o instanceof ColorPoint))
       return false;
   return super.equals(o) && ((ColorPoint) o).color == color;
}

这方法存在着问题,当你可能想比较一个点和有颜色的点的时候可能会得到不一样的答案,反之亦然,先前的比较忽略了颜色,后来的比较则会因为参数的类型不一样而返回false,我们举个例子来更好地说明

Point p = new Point(1, 2);
ColorPoint cp = new ColorPoint(1, 2, Color.RED);

p.equals(cp)会返回true,但是cp.equals(p)会返回false,你可能会直接在ColorPoint里面修改,如下

// Broken - violates transitivity!
@Override
public boolean equals(Object o) {
if (!(o instanceof Point))
     return false;

// If o is a normal Point, do a color-blind comparison
if (!(o instanceof ColorPoint))
     return o.equals(this);

     // o is a ColorPoint; do a full comparison
     return super.equals(o) && ((ColorPoint)o).color == color;
}

这样貌似解决之前的问题,解决了对称性的矛盾,但是却出现了传递性的问题,如下

ColorPoint p1 = new ColorPoint(1, 2, Color.RED);
Point p2 = new Point(1, 2);
ColorPoint p3 = new ColorPoint(1, 2, Color.BLUE);

这种情况下,p1.equals(p2)和p2.equals(p3)都返回true,但是p1.equals(p3)返回false,明显违反了传递性的原则,原因很简单,我们在做前两次的比较的时候没有涉及到颜色,故颜色的忽略导致传递性的违反

那么应该怎么解决这个问题呢?这样的问题在面向对象语言里面是对等关系的基础问题,没有其他办法来拓展一个可实例化类并且加上一个值成员同时又要保留equals的规范,除非你愿意去放弃面向对象这种抽象性的好处

你可能听说过通过使用一个getClass测试替换instanceof测试来拓展一个可实例化类并且加上一个值成员同时又要保留equals的规范:

@Override
public boolean equals(Object o) {
     if (o == null || o.getClass() != getClass())
     return false;
Point p = (Point) o;
return p.x == x && p.y == y;
}

这的确是有效的,不过只是对于那些有着相同的实现类而言,看上去可能不那么糟糕,不过结果不可以接受

让我们来假定我们需要去写一个方法来告诉我们一个整数点是否在一个单位圆上,我们可以这样做

// Initialize UnitCircle to contain all Points on the unit circle
private static final Set<Point> unitCircle;
static {
unitCircle = new HashSet<Point>();
unitCircle.add(new Point( 1, 0));
unitCircle.add(new Point( 0, 1));
unitCircle.add(new Point(-1, 0));
unitCircle.add(new Point( 0, -1));
}
public static boolean onUnitCircle(Point p) {
return unitCircle.contains(p);
}

可能这不是最快的实现方案,用起来还可以,但是假定你对Point进行拓展,当然没有加上一些值的成员,这样说,利用它的构造方法能够对有多少个实例被创建进行追踪:

public class CounterPoint extends Point {
private static final AtomicInteger counter =
     new AtomicInteger();
public CounterPoint(int x, int y) {
     super(x, y);
     counter.incrementAndGet();
}
public int numberCreated() { return counter.get(); }
}

这里需要谈及Liskov 替代原则,这个替代原则说一个类型的任意的重要属性,也应该被子类型所持有,故这个类型任意的方法都应该平等地作用于子类[Liskov87]。但是假设我们现在传递一个CounterPoint实例到onUnitCircle方法中,如果这个Point类使用getClass为基础的equals方法,那么onUnitCircle方法将会返回false而不管CounterPoint实例的x和y的值,这个原因是由于Collection,就像HashSet被用来测试包含性,但是没有一个CounterPoint的实例是等于Point的。这种情况下,你如果使用instanceof为基础来写equals方法的话,就不会有这个问题了。

并没有什么比较让人满意的方法来拓展一个可实例化的类并且加上一个值成员,在这里有一个比较好的方法,在后面会提到这一点”Favor composition over inheritance“(继承上进行组合)而不是用ColorPoint继承于Point,而是直接给ColorPoint一个private的Point成员和一个public的view方法,该方法返回这个相同位置的point作为这个color point:

// Adds a value component without violating the equals contract
public class ColorPoint {
private final Point point;
private final Color color;
public ColorPoint(int x, int y, Color color) {
if (color == null)
     throw new NullPointerException();
point = new Point(x, y);
this.color = color;
}
/**
* Returns the point-view of this color point.
*/
public Point asPoint() {
     return point;
}
@Override public boolean equals(Object o) {
if (!(o instanceof ColorPoint))
     return false;
ColorPoint cp = (ColorPoint) o;
return cp.point.equals(point) && cp.color.equals(color);
}
... // Remainder omitted
}

在Java平台的库里面也可找到类似的想拓展一个可实例化的类并且添加一个值成员的情况。举个例子,java.sql.Timestamp继承于java.util.Date并且添加了一个nanoseconds的域,对于Timestamp的equals的实现违反了对称原则并且如果Timestamp和Date对象用在同一个集合里面或者以其他方式混合会造成不稳定的表现

Timestamp类有一个程序员反对混合dates和timestamps的免责声明,你只要分开着用就不会出现问题,没有别的方法来避免并且debug起来也比较困难,这个Timestamp的行为是一个错误并且不应该被模仿

需要提到的是,你可以添加一个值成员到一个抽象类的子类之中并且不会造成equals规范的违反,这个对于类的层次划分十分重要,后面也会提及”Prefer class hierarchies to tagged classes.“(更倾向于层次而不是标记),举个例子,你可以有一个抽象类Shape,它没有值成员,一个子类Circle加上了一个radius的域,并且一个子类Rectangle加了length和width的域,区分的问题并不会出现,因为他们的父类并不能被直接实例化

一致性,第四个条件,即只要两个对象是相等的,那么不管多少次调用,结果都应该不变,反之亦然,换句话说,可变的对象可以在不同的时候等于不同的对象,不可变对象则不可以。当你想编写一个类的时候,一定要仔细思考这个类是否是不可变的,如果是不可变的,一定要保证相等和不等的保持性

但是,不管这个类是可变的还是不可变的,千万不要将equals方法依赖于不可信的资源,如果你违反了这个约定的话满足一致性将会变得极端得困难,举个例子,java.net.URL得equals方法依赖于和URL关联的主机的IP地址的比较,将一个主机名翻译成主机的IP地址需要网络访问,并且它不能保证得到的结果一直是相同的,这样就会造成URL类违反了equals的规范同时也会造成一些问题的出现。(不幸运的是,这个行为并不能被改变由于兼容性要求)equals有着十分稀少的异常,这个方法应该在内存驻留型的对象上执行确定性的计算。

”Non-nullity“最后一个条件,这里使用一个复合词来描述---即非空性,即所有的对象都不等于null。当然对于意外返回true的情形还是比较难想象的,可能更多的会想到意外抛出一个空指针异常,通常的规范并不允许这样。很多的类都有对于null测试的防卫:

@Override
public boolean equals(Object o) {
  if (o == null)
       return false;
...
}

这个测试没有必要。为了测试相等性的参数,equals方法必须首先转换参数为合适的类型来使得它的访问器可能被调用或者它的域被访问,当然在做转换之前,方法必须使用instanceof的操作来检查参数是否是正确的类型:

@Override public boolean equals(Object o) {
if (!(o instanceof MyType))
     return false;
MyType mt = (MyType) o;
...
}

如果错过类型检查并且equal方法把错误类型的参数传过去了,那肯定会抛出一个ClassCastException异常,这样就违反了equals的规范。但是instanceof操作当它的第一个操作是对null的时候会特别的返回一个false。如果null被传入的话类型检查会返回false,故并不需要分离出一个null检查

总结一下吧,下面对于一个高质量equals方法的清单

1.如果参数是对这个对象的引用的话,使用==操作符来检查 如果相等,返回true,这只是表现优化,但是当两者的比较有潜在性的高代价的时候值得这样做。

2.使用instanceof操作来检查参数是否是正确类型 特别的,正确的类型是方法产生的类。偶然的,它是这个类实现的一些接口。如果这个类实现了一个接口并且这个接口精炼了equals规范来实现同样是实现了这个接口的类之间的比较,这样的情况下使用一个接口

3.转换参数变成正确类型的参数 由于这个转换前面有着instanceof来测试,它保证了能够成功转换

4.对于类中每个”重要的“域,检查该参数的域是否匹配这个对象的相应的域 如果所有这些测试都成功的话,返回true,否则返回false。如果是步骤2中的接口的话,你必须通过接口方法来访问参数的域;如果类型是一个类,你可能就可以直接访问域了,当然这个需要依赖于可访问性

对于那些类型不是float或者double的原始的域,使用==操作符来比较;对于对象的引用这样的域,递归调用equals方法;对于float域,使用Float.compare方法,同样对于double,使用Double.compare方法来比较。float和double这样特殊的对待是有必要的,原因是Float.NaN,-0.0f和类似的double值的存在;更多的细节请仔细看Float.equals文档的说明;对于数组域,对于每一个元素应用这些指导原则,如果数组中所有的元素都是重要的,你可以使用Array.equals方法来比较(1.5才添加的)

对于一些对象,他们可能合法地持有null,为了避免抛出空指针异常,使用如下的方法来比较这些域:

(field == null ? o.field == null : field.equals(o.field))

还有一种可能更快的方案,如果你的域经常相同的话

(field == o.field || (field != null && field.equals(o.field)))

对于一些类,比如CaseInsenstiveString之类的,域的比较是更加复杂的。如果在这种情况下,你可能想要储存一个这个域的规范形式的表单,故这个equals方法可以在这个规范形式的表单上做一些简单的比较而不是做一些代价巨大的比较。这种技术对于不可变的类来说十分合适,如果对象可以被改变,那么你必须经常的去更新这个规范形式的表单

对于这个equals方法的表现可能受那些要比较的域的顺序而影响,为了更好的表现,你应该首先比较那些很可能是不一样的域,那些容易比较的域,或者,更为理想是这两个特点都有的域。你不可以去比较那些不属于这个对象逻辑状态部分的域,比如用来同步操作的Lock域,你需要不去比较那些冗余的域,那些由”重要的域“计算而来的,这样做可以提升equal方法的整体表现,如果一个冗余的域相当于整个对象的概括描述的话,比较这个域将会节省你的开销

5.当你结束编写你的equals方法的时候,问问自己3个问题:对称否?传递否?一致否? 并且不用只是问自己;写一个单元测试检查一下这些属性是否具备,如果不,找出原因,并且修好它,当然了,你的equal方法也要满足其他两个属性,自反性和非空性,但是这两个一般是没有问题的

对于具体的例子来说,都应该遵循以上的原则,这里有一些最后的警告:

  • 当你重写equals的时候要经常重写hashCode
  • 不要变得太聪明,如果你简单地测试域的相等,对于坚持equals规范来说并不难,如果你特别地在意于等价关系,你可能会轻易就遇到问题了,考虑任何形式的混叠通常是个坏主意,举个例子,File类就不应该试图去等同指向同一文件的符号链接,幸亏它没有
  • 在equals的块里面不要为Object替换其他的类型,经常看见一个程序员写一个equals方法类似这样,然后花费了许多时间来疑惑为什么这样不可以
public boolean equals(MyClass o) {
     ...
}

问题在于这个方法并没有重写Object.equals的方法,Object.equals的方法的参数是Object,这样就变成了重载了,但是明显Object是更强的类型,对于一个方法,你可以提供一个更强的参数作为重载,但是不能提供一个较弱的方法作为重载,因为那没有意义

还有就是要坚持使用@Override注解,可以让你少犯错,还会提示你哪里错了

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

相关文章

来自专栏zhisheng

运算优先级、结合性、求值顺序、副作用和顺序点

标题中这几个概念,是很多C/C++程序员在表达式上容易出问题或不清楚的地方,虽然这些概念在很多语言都有体现,但C里面特别明显,所以就以C语言为例子总结下 运算...

4837
来自专栏从流域到海域

《笨办法学Python》 第29课手记

《笨办法学Python》 第29课手记 本节课讲if语句。 本节内容比较简单,如果觉得你的代码没有错误,但运行时报错,那么你的代码肯定有错误。相信我解释器是已经...

2046
来自专栏Java爬坑系列

【JAVA零基础入门系列】Day8 Java的控制流程

  什么是控制流程?简单来说就是控制程序运行逻辑的,因为程序一般而言不会直接一步运行到底,而是需要加上一些判断,一些循环等等。举个栗子,就好比你准备出门买个苹果...

23810
来自专栏Brian

Python 深浅拷贝

Python浅拷贝和深度拷贝 今天面试了一个计算机专业研究生且大学出身也很好,但是面试的结果来看并没有达到我的预期。很多基础计算机的知识貌似都不是很懂,更别说...

4128
来自专栏一名合格java开发的自我修养

java或判断优化小技巧

写业务代码的时候,我们经常要做条件判断,有的时候条件判断的或判断长达20多个。reg.equals("1") || reg.equals("2") || reg...

661
来自专栏诸葛青云的专栏

想当黑客?浅谈C语言编程:不会这个知识就别想了!

看到标题点进来的朋友,应该对黑客这个名词很敏感吧?我想应该是这样的,但是你们知道作为一名黑客需要学习哪些知识吗?小编不是什么大佬,但小编可以明确的告诉你,学习C...

2580
来自专栏非著名程序员

鸡蛋问题来了,是先有Class还是先有Object?

周末比较无聊,在浏览论坛的时候,偶然看到一个程序猿提问的问题,他时这样提问的:突然想到一个很菜的问题, 倒底先有Object还是先有Class?所有类都是Obj...

2066
来自专栏web前端教室

JS中值的传递方式 | 前端卧谈会第11期

音频请点此进行收听 音频原文: 今天在segmentfault看到一篇文章,是讲JS传值的方式的,觉得很有价值,想和大家分享一下。 都知道JS中有二种值的传递方...

1976
来自专栏程序员互动联盟

【面试宝典】写一个函数将两个数交换

没有参加过面试的同学可能会很忐忑,面试都会出些什么题呢?其实一般情况下,大部分的面试题都是比较基础的。关于如何交换两个数字,应该是非常简单的问题了。看下面几个函...

3538
来自专栏贺贺的前端工程师之路

《JavaScript语言精粹》学习笔记

在JavaScript中,/ *可能出现在正则表达式字面量里,所以块注释对于被注释的代码块来说是<u>不安全的</u>。

782

扫码关注云+社区

领取腾讯云代金券