《Effective Java》 读书笔记(十) 重写equals方法是遵守通用约定

时间:2022-06-21 16:15:26

想起一个有关Object的梗,在一个java技术群里突然来了一个人,大家看他的网名叫Object,就觉得很意思,后来又忍不住把他踢了,因为这名字实在太骚了

Object 是所有类的父类,对于Object里面的所有非final方法(equals,hashCode,toString,clone),都有清晰通俗的通用约定,任何类都有义务重写这些方法。

但是什么时候直接重写,又什么时候直接使用Object自带的equals方法呢?

首先我们看看Java里面的Object默认的equals方法:

   public boolean equals(Object obj) {
        return (this == obj);
    }

可以看到这直接是用的==,也就是说对比的是引用的对象的地址,什么时候使用默认的equals呢,也就是说什么时候用户不在意对象具体属性的值的时候,可以不重载equals

在以下情况可以使用默认的equals:

  • 每个类的实例都是固定唯一的,就好像Thread这样的代表活动实体而与值无关的的类来说,可以使用默认的equals方法。

  • 类不需要提供一个“逻辑相等”的测试功能,例如java.util.regex.Pattern可以重写equals方法检查两个是否代表完全相同的正则表达式Pattern实例,但是对于Pattern来说,没有人会使用equals,这个时候使用默认的equals方法也是一种正确的选择

  • 父类已经重写了equals方法,并且完全适合于子类。例如,大多数Set从AbstractSet继承了equals实现、List从AbstractList继承了equals实现,Map从AbstractMap的Map继承了equals实现。

  • 类是私有的或者包级私有的,可以确定他的equals方法永远不会被调用,这个时候使用默认的equals方法即可。

在以下情况可以需要重写equals:

  • 如果一个类包含了逻辑相等的概念,并且父类没有重写过equals方法,这通常用在值类(value classes)的情况,也就是说这个类是用来表示对应的值的。。在java库中,Integer或String类等,用户希望使用equals判断两个对象逻辑上是否相等。

  • 当需要在容器里面使用此对象的时候,最好重载equals,否则可能会出现一些出人意料的情况。

equals需要遵守的通用约定:

  • 自反性:对于任何非空引用x,x.equals(x)必须返回true

  • 对称性:对于任何非空引用,如果当且仅当x.equals(y),那么y.equals(x)也必须成立。

  • 传递性:对于任何非空引用,若存在x.equals(y),y.equals(z),那么x.equals(z)也必须成立。

  • 一致性:对于任何非空引用x和y,如果在equals中的属性没有被更改,那么任何时候调用x.equals(y)的结果都必须相同

  • 对于任何非空引用x。x.equals(null)必须返回false。


下面来讨论下各个约定在编程中可能会出现的情况

自反性:

最常见了是在集合中,如果x.equals(x)返回的是false,那么调用contains也会返回false,不过这也是重写equals的第一条,直接判断object==this,这样能节省很大一部分性能。

对称性:

总结一下大概就是equals中最好不要带上两个不同的类型的相互比较,两个不同的类型的相互比较可以放在utils里面单独写一个工具包。

传递性:

这种错误的情况一般出现在继承中,因为equals(Object obj)主要传递的是一个Object类型,涉及到类型转换,而equals使用的instanceof 是结合了面向对象的法则:

  • 子类 instanceof 父类 //true
  • 父类 instanceof 子类 //false

导致父类和子类之间的equals是不可传递的。
Java平台类中的java.sql.Timestamp继承了java.util.Date并添加了一个nanoseconds字段,
这样就违背了对称性,并且无法改正,因此可以看到Timestamp类中包含一段注释告诉用户不要混用Timestamp和Date对象。
那么解决方案是什么呢?
优先使用组合替代继承
上述问题一般出现在父类是可以实例化的情况中的,如果父类不可实例化,比如接口或者抽象类,这个问题是不存在的。

一致性:

这个问题一般出现在可变对象中,如果一个可变对象重载equals方法,请确保equals能保证相等的对象永远相等,不相等的对象任何时候都不会自行相等。

非空性:

通用的约定:对于空对象来说,不应该直接抛出NullPointerException异常,而是
当参数为空时,返回false。
一个比较好的技巧为:

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

将对象作为instanceof的参数,这样当onull,会直接返回为false,而不需要单独判断o==null

总而言之:
综合起来,以下是编写高质量equals方法的配方(recipe):

  • 使用= =运算符检查参数是否为该对象的引用。如果是,返回true。这只是一种性能优化,但是如果这种比较可能很昂贵的话,那就值得去做。

  • 使用instanceof运算符来检查参数是否具有正确的类型。如果不是,则返回false。通常,正确的类型是equals方法所在的那个类。有时候,改类实现了一些接口。如果类实现了一个接口,该接口可以改进equals约定以允许实现接口的类进行比较,那么使用接口。集合接口(如Set,List,Map和Map.Entry)具有此特性。

  • 参数转换为正确的类型。因为转换操作在instanceof中已经处理过,所以它肯定会成功。

  • 对于类中的每个“重要”的属性,请检查该参数属性是否与该对象对应的属性相匹配。如果所有这些测试成功,返回true,否则返回false。如果步骤2中的类型是一个接口,那么必须通过接口方法访问参数的属性;如果类型是类,则可以直接访问属性,这取决于属性的访问权限。


以下是一些最后提醒:

  • 当重写equals方法时,同时也要重写hashCode方法(条目 11)

  • 不要让equals方法试图太聪明。如果只是简单地测试用于相等的属性,那么要遵守equals约定并不困难。如果你在寻找相等方面过于激进,那么很容易陷入麻烦。一般来说,考虑到任何形式的别名通常是一个坏主意。例如,File类不应该试图将引用的符号链接等同于同一文件对象。幸好 File 类并没这么做。

  • 在equal 时方法声明中,不要将参数Object替换成其他类型。对于程序员来说,编写一个看起来像这样的equals方法并不少见,然后花上几个小时苦苦思索为什么它不能正常工作: