Effective Java学习笔记--Object非final方法的重写(equals与hashCode)

目录

equals方法的重写

无需重写equals方法的情况

equals重写要遵循的几个原则

对称性冲突

传递性冲突

编写高质量equals方法

hashCode方法的重写

hashCode的通用规范

什么是散列表(Hash Table)

hashCode方法的设计流程

一些建议

思考


这一篇开始进入第三章“对所有对象都通用的方法”,这一章重点聚焦Object类几个非final方法(equals、hashcode、toString、clone和finalize),他们在所有的类中都会存在,要么你就不重写它。就基于Object的默认实现,如果你需要重写,就需要遵循一定的约定,否则在与其他遵循这些约定的类进行交互的时候就会遇到各种奇怪的问题。这一章重点就是告诉你这些约定是什么。这一篇先介绍equals和hashCode两个方法。

equals方法的重写

对于equals,Object默认的实现是比较两个对象引用是否指向同一个对象。作者的原则是能不重写尽量别重写,不重写就没有后面那么多约定要check(所以真的评估一下能不能不重写,不要自找麻烦)。下面是作者列出的几个条件,只要符合其中一项,就请不要重写了。

无需重写equals方法的情况

  • 类的每个实例本质上都是唯一的:类的实例代表的是活动实体而不是值(比如Thread)。
  • 类没有必要提供“逻辑相等”的测试功能:用户不会对于某些对象去做逻辑相等的比较(这里说一下什么是逻辑相等,我理解就是两个对象在某一个逻辑上是相等的,这个逻辑是设计者自行定义的,这个定义也就是equals的实现,所以如果没有必要提供这种相等关系,那就不需要自行定义equals了)。
  • 超类已经覆盖了equals,同时超类相等的逻辑也适用于该类:比如在集合框架里,Set、List和Map这些接口的实现类都直接继承了相对应AbstractSet、AbstractList和AbstractMap里默认的equals方法。
  • 类是私有的,或者是包级私有的,可以确定它的equals方法永远不会被调用:这种情况下你可以直接禁用equals方法。
  • @Override
    public boolean equals(Object o){
        throw new AssertionError();
    
    }

    每个值只有一个对象的“值类“:大多数”值类“(类基于自己的属性有自己逻辑相等的概念)在超类无法提供合适equals方法的情况下是需要重写equals方法的,但是像枚举类这样每个值只有一个对象的类就不需要重写,因为逻辑相同和值相同是一样的。

equals重写要遵循的几个原则

equals重写就要遵循它的通用规定,不然这个方法就存在隐患。equals总共有5个通用规定:

  • 自反性:对于任何非null的引用值x,x.equals(x)必须返回true。
  • 对称性:对于任何非null的引用值x,如果x.equals(y)成立,则y.equals(x)必须返回true
  • 传递性:对于任何非null的引用值x、y、z,如果x.equals(y),y.equals(z)返回true,则x.equals(z)必须返回true。
  • 一致性:对于任何非null的引用值x、y,只要equals操作在对象中所用的信息不变,多次调用x.equals(y)就会返回一致的true或者false。
  • 对于任何非null的引用值x,x.equals(null)必须返回false。

这里作者重点提了两个容易冲突的规定对称性、传递性。而违反这两个规定的场景都来自于可实例化类的继承。

在继承这个场景中,设父类需纳入比较的值组件集合为A,子类扩展的需纳入比较值组件集合为B,那么x.equals(y)时x须纳入比较的值组件集合有以下几种情况:

y:子类y:父类y:其他
x:子类A+BAreturn false
x:父类AAreturn false

这里就很明显能看到,当子类需要重写equals方法时,一是要保留与父类和其他类比较的方法,二是要自定义与子类本身比较的方法。

对称性冲突

当子类没有包含与父类的对比方法时,就会出现对称性冲突。作者给了一个Point和ColoredPoint的案例:

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 this.x == p.x && this.y == p.y;

    }
}
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 o){
        if(!(o instanceof Point)){
            return false;
        }
        return ((ColoredPoint) o).color == color && super.equals(o);
    }

}

这样ColoredPoint.equals(Point)返回false,而Point.equals(ColoredPoint)返回true。

对应上面的分类图就是子类没有考虑第二种情况:

传递性冲突

传递性冲突并不是子类漏写了某一种情况,而是继承这种扩展方式自带的缺陷。定义父类M的值组件集合为A,两个子类m1和m2的值组件集合分别是A+B和A+B',从上面的表格能够很容易的推理出来m1.equals(M)和M.equals(m2)都成立,但是m1.equals(m2)不成立,这明显违反了传递性。

所以扩展实例化类的时候有一个不可能三角:

所以要解决传递性冲突只有一种方法那就是放弃继承使用复合(不增加新的值组件这个扩展就没意义)。在ColorPoint中加入一个私有的Point域,以及一个针对性的视图方法,此方法返回一个与该ColorPoint有相同父类值组件的Point实例:

public class ColoredPointSec {
    private final Point point;
    private final Color color;
    public ColoredPointSec(int x, int y, Color color)
    {
        point = new Point(x, y);
        this.color = color;
    }

    public Point asPoint(){
        return point;
    }

    public boolean equals(Object o){
        if(o instanceof ColoredPointSec){
            return point.equals(((ColoredPointSec) o).asPoint()) && color == ((ColoredPointSec) o).color;
        }
        if(o instanceof Point){
            return point.equals(o);
        }

        return false;
    }
}

除了这个方法,作者还介绍了一种方法,利用getClass替代instanceof,使得子类与父类的比较都返回false,这个方法其实打破了我们的前提条件(就是那个表格),变成了下面这样:

这个方法首先违背了里氏替换原则(一个类型的重要属性也要适用于他的子类型),即Point类的子类应该也是Point。另一方面,我个人认为它违背了对称性原则(父类.equals(子类)可以返回true,子类.equals(父类)一定返回false),除非把父类的equals方法也改了。总之这个方法违背了继承的初衷(要保证子类“is a”父类),既然都违背继承了那为什么不干脆使用复合呢?

编写高质量equals方法

结合上面的要求,作者总结了高质量equals方法的设计步骤:

首先用“==”操作符判断参数是否为同一个对象引用:我们知道“==”操作符在对象引用的比较中是用来比较两个引用是否指向同一个对象。由于它是基本操作符,用来执行对比操作比较廉价,所以如果该类型相关的比较操作比较昂贵的话,这个做法至少能分出一块情况(两个引用指向同一对象)降低使用成本。

使用instanceof操作符检查参数是否为正确类型:一般指的是所在的那个类或者这个类的超类和子类,但某些情况可以指该类所实现的接口。如果类实现的接口改进了equals方法,允许在实现了该接口的类之间进行比较,那就使用该接口。

把参数转换成正确的类型:在instanceof判断通过之后,就需要进行强制类型转换,以引用类的值组件。

对于该类的关键值组件进行比较:这里根据不同的值组件类型使用不同的比较方式:

当然基本类型也可以使用他们的包装类的equals方法,但这种方式需要进行自动装箱,导致性能下降,所以没有必要。

另一方面,值组件比较的顺序也很重要,应该最先比较最有可能不一致的组件,或者开销最低的组件,这样可以显著的提升性能。

hashCode方法的重写

hashCode的通用规范

在equals方法里说了,重写equals方法的同时必须重写hashCode方法。因为hashcode是HashMap和HashSet这些基于散列值构建的集合的基础,尤其是在这些集合中进行搜索的时候,基于hashcode的equals方法是重要的工具。下面是Object定义的hashcode通用规范:

一致性:在一个应用程序中,只要对象的equals方法的比较操作所用到的信息没有发生改变,那么对于同一个对象的hashcode方法返回的都必须是同一个哈希值。两个应用程序中,相同属性的对象执行hashCode方法返回的值可以不同

equals相等可以推出hashCode相等:如果两个对象根据equals方法比较是相等的,那么调用这两个对象中的hashCode方法返回的哈希值必须相等。

equals不相等不能推出hashcode不相等:如果两个对象根据equals方法比较不相等,那么调用这两个对象中的hashCode方法返回的哈希值可能会相等,但是我们在设计时请尽量使其不相等,这样可以提高散列表的搜索性能。

什么是散列表(Hash Table)

要想知道为什么hashCode有上述的通用规范,需要知道hashCode最重要的应用场景之一--散列表。

散列表(Hash Table)是一种数据结构,它使用数组作为基本存储单元。每个数组的位置通过一个特殊的函数——哈希函数(Hash Function)计算得出,这个计算结果称为散列值或哈希码(HashCode),用作数组的索引,因此每个数组位置被称为“散列桶”(Hash Bucket),每个散列桶里的元素有相同的散列值。

当散列表进行搜索的时候会通过散列值来匹配对应的散列桶,然后通过相应元素的equals方法来匹配对应的元素。所以这也是为什么重写equals方法必须要重写hashCode方法的原因,如果两个逻辑相等的实例对应的类没有重写hashCode方法,那么他们大概率会有不同的散列值,就会分布在不同的哈希桶里,即使equals方法遍历了整个桶也无法匹配。

hashCode方法的设计流程

声明一个int变量result,将它初始化成对象中第一个关键值组件的散列码c。

然后对象中的每一个关键值组件都按照以下的方式分别计算散列值:

将计算结果合并进入result:

result = 31*result+c

一些建议

  1. 如果一个类是不变的,而且hashcode的计算开销也比较大,就应该把散列值缓存在对象内部。
  2. 不要基于性能考虑试图从hashcode的计算中排除部分关键值组件。
  3. 不要把hashcode计算出来的结果再进行自定义的映射:这会限制在未来版本中改进散列函数的能力

最后请教一下大家

我们前面Point和ColoredPoint的equals方法考虑了ColoredPoint.equals(Point)的情况,即两个坐标(x,y)相同就可以判断相等不必考虑color属性,请问这种情况要怎样考虑到ColoredPoint的hashCode方法中来,而且不影响ColoredPoint类自己实例的比较?

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值