码出高效读书笔记:Comparable与Comparator、hashCode与equals

1、Comparable和Comparator

Java中两个对象相比较的方法通常用在元素排序里,常用的两个接口分别是Comparable和Comparator。

  • 前者Comparable是自己和自己比,可以看作是自营性质的比较器。从词根上分析,Comparable以-able结尾,表示它有自身具备某种能力的性质,表明Comparable对象本身是可以与同类型进行比较的,它的比较方法是compareTo
  • 后者Comparator是第三方比较器,可以看作是平台性质的比较器。从词根上分析,Comparator以-or结尾,表明自身是比较器的实践者,它的比较方法是compare

我对上面这两点的理解是:

  • Comparable:当一个类实现Comparable接口时,重写比较方法compareTo(参数:这个类的一个实例对象),在进行比较的时候格式是这样的:这个类的一个实例对象.compareTo(这个类的另一个实例对象)。在需要进行比较的类内部重写了比较方法,使用比较方法时是通过这个类的实例对象去调用的,所以作者说他是自营性质的比较器。
  • Comparator:由一个跟“需要比较业务的类1”毫不相关的类2去实现Comparator接口,重写比较方法compare(参数1:类1的实例对象A,参数2:类1的实例对象B)。因为比较功能由毫不相关的第三方类去实现,所以作者说它是一个平台性质的比较器。

我们经常说的自然排序其实是以人类对常识认知的升序排序,比如数字的1、2、3,字母的a、b、c等。我们熟知的Integer和String实现的就是Comparable的自然排序。

我们在使用某个自定义对象时,可能需要按照自己定义的方法排序,比如在搜索列表对象SearchResult中进行大小比较时,先根据相关度排序,然后再根据浏览数排序,实现这种自定义Comparable的示例代码如下:

package Test;

public class SearchResult implements Comparable<SearchResult> {
    
    //相关度
    int relativeRatio;    
    //浏览数
    long count;
    //最近订单数
    int recentOrders;
    
    public SearchResult(int relativeRatio, long count){
        this.relativeRatio = relativeRatio;
        this.count = count;
    }

    @Override
    public int compareTo(SearchResult o) {
        //先比较相关度
        if (this.relativeRatio != o.relativeRatio){
            return this.relativeRatio > o.relativeRatio ? 1 : -1;
        }
        
        //相关度相等时再比较浏览数
        if (this.count != o.count){
            return this.count > o.count ? 1 : -1;
        }
        return 0;
    }
    
    public void setRecentOrders(int recentOrders){
        this.recentOrders = recentOrders;
    }
}

实现Comparable时,可以加上泛型限定,在编译阶段即可发现传入的参数非SearchResult对象,不需要再运行期进行类型检查和强制转换。如果这个排序规则不符合业务方的要求的话,那么只需要修改ComparaTo这个比较方法。

但是如果SearchResult这个类是他人提供的类,我们可能连源码都没有。所以这个时候我们需要在外部定义比较器,即Comparator。正因为Comparator的出现,业务方可以根据需要修改排序规则,如在上面的示例代码中,如果业务方需要在搜索时将最近订单数(recentOrders)的权重调整到相关度与浏览数之间,则使用Comparator实现的比较器,代码如下所示:

package Test;

import java.util.Comparator;

public class SearchResultComparator implements Comparator<SearchResult> {
    @Override
    public int compare(SearchResult o1, SearchResult o2) {
        //相关度是第一排序准则,更高者排前
        if (o1.relativeRatio != o2.relativeRatio){
            return o1.relativeRatio > o2.relativeRatio ? 1 : -1;
        }
        
        //如果相关度一样,则最近订单数多者排前
        if (o1.recentOrders != o2.recentOrders){
            return o1.recentOrders > o2.recentOrders ? 1 : -1;
        }
        
        //如果相关度和最近订单数都一样,则浏览数多者排前
        if (o1.count != o2.count){
            return o1.count > o2.count ? 1 : -1;
        }
        return 0;
    }
}

在JDK中,Comparator最典型的应用是在Arrays.sort()中作为比较器参数进行排序:

public static <t> void sort(T[] a, Comparator<? super T> c){
    if(c == null){
        sort(a);
    }else{
        if(LegacyMergeSort.userRequested)
            LegacyMergeSort(a, c);
        else
            TimSort.sort(a, 0, a.length, c, null, 0, 0);
    }
}

<? super T>为下限通配符(请参考我的另一篇文章集合与泛型),如果本例中不加限定,假定sort对象是Integer,那么传入String时就会编译报错,充分利用多态的向下转型功能。

约定俗成,不管是Comparable还是Comparator,小于的情况返回-1,等于的情况返回0,大于的情况返回1。

2、hashCode和equals

hashCode和equals用来标识对象,两个方法协同工作可用来判断两个对象是否相等。

众所周知,根据生成的哈希将数据离散开来,可以使存取元素更快(通过哈希值来确定元素存放的位置)。对象通过调用Object.hashCode()生成哈希值;由于不可避免地存在哈希值冲突的情况(什么是哈希冲突?由于哈希算法被计算的数据是无限的,但是计算结果的范围是有限的,因此总会存在不同数据经过计算后得到的值一样,这就是哈希冲突。最常见的哈希算法是取模法,假设有一个长度为5的数组,这时有一个数据是6,那怎么把这个数据放到数组中去呢?采用取模法,计算6%5=1,于是6存放在数组中下标为1的位置。依此类推,7放在下标为2的位置,直到出现11这个数据,11%5=1,哈希算法计算出来的存放位置跟6相同,这时就出现了哈希冲突。),因此当hashCode相同时,还需要再调用equals进行一次值的比较;但是,若hashCode不同,将直接判定两个对象不同,跳过equals,这加快了冲突处理的效率。Object类定义中对hashCode和equals要求如下:

  1. 如果两个对象的equals的结果是相等的,则两个对象的hashCode的返回结果也必须是相同的。
  2. 任何时候覆写equals,都必须同时覆写hashCode。

在Map和Set类集合中,用到这两个方法时,首先判断hashCode的值,如果hash相等,则再判断equals的结果,HashMap的get判断代码如下:

if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
    return (e = getNode(hash(key), key)) == null ? null : e.value;

if表达式中的e.hash == hash是先决条件,只有相等才会执行&&后面的语句。如果不相等,则&&后面的语句根本不会被执行。equals不相等时并不强制要求hashCode也不相等,但是一个优秀的哈希算法应尽可能的让元素均匀分布,降低冲突概率,即在equals不相等时尽量使hashCode也不相等,这样&&或||短路操作一旦生效,会极大地提高程序的执行效率。

如果自定义Map和Set的对象的话,必须要覆写hashCode和equals两个方法,如果只是覆写了equals,而没有覆写hashCode会出现什么影响,从下面这段代码中来体会:

package Test;

public class EqualsObject {
    private int id;
    private String name;

    public EqualsObject(int id,String name){
        this.id = id;
        this.name = name;
    }

    @Override
    public boolean equals(Object obj){
        //如果为null,或者并非同类,则直接返回false,getClass()用来比较较两个对象是否是同一个类的实例(第一处)
        if (obj == null || this.getClass() != obj.getClass()){
            return false;
        }
        
        //如果引用指向同一个对象,则返回true,==判断的是引用指向的地址是否相同
        if (this == obj){
            return true;
        }
        
        //需要强制转换来获取EqualsObject的方法
        EqualsObject temp = (EqualsObject)obj;
        
        //本示例判断标准是两个属性值相等,逻辑随业务场景不同而不同
        if (temp.getId() == this.id && name.equals(temp.getName())){
            return true;
        }
        return false;
    }

    public int getId() {
        return id;
    }

    public void setId(int id) {
        this.id = id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }
}

第一处说明:首先判断两个对象的类型是否相同,如果不匹配,则直接返回false。此处使用getClass()方法,就是严格限制了只有EqualsObject对象本身才可以执行equals操作(为什么使用getClass()方法而不是用instanceof方法,是因为子类对象 instanceof 父类对象的返回值为true,而子类.getClass() == 父类.getClass()的返回值为false,防止子类被判断为父类的情况)。

这里没有覆写hashCode,那么把这个对象放置到Set(不允许出现重复元素)集合中去:

Set<EqualsObject> hashSet = new HashSet<>();
EqualsObject a = new EqualsObject(1, "one");
EqualsObject b = new EqualsObject(1, "one");
EqualsObject c = new EqualsObject(1, "one");

hashSet.add(a);
hashSet.add(b);
hashSet.add(c);
System.out.println(hashSet.size());

输出的结果是3。对然这3个对象显而易见是相同的,但在HashSet操作中,应该只剩下一个,为什么结果是3呢?因为如果不覆写hashCode(),即使equals()相等也毫无意义,Object.hashCode()的实现是默认每一个对象生成不同的int数值,它本身是native方法,一般与对象内存地址有关。(hashCode其实就是根据对象的地址进行相关计算得到int类型数值的)

因为EqualsObject没有覆写hashCode,所以得到的是一个与对象地址相关的唯一值,回到刚才HashSet集合上,如果想存储不重复的元素,那么需要在EqualsObject类中覆写hashCode():

@Override
    public int hashCode(){
        return id + name.hashCode();
    }

EqualsObject的name属性是String类型,String覆写了hashCode(),所以可以直接调用。equals()的实现方式与类的具体处理逻辑有关,但又各不相同,因而应尽量分析源码来确定其判断结果,如下例代码所示:

package Test;

import java.util.LinkedList;

public class ListEquals {
    public static void main(String[] args) {
        LinkedList<Integer> linkedList = new LinkedList<Integer>();
        linkedList.add(1);
        ArrayList<Integer> arraylist = new ArrayList<Integer>();
        arraylist.add(1);

        if (arraylist.equals(linkedList)){
            System.out.println("equals is true");
        }else {
            System.out.println("equals is false");
        }
    }
}

两个不同的集合类,输出结果是equals is true。因为ArrayList的equals()只进行了是否为List子类的判断,接着调用了equalsRange()方法:

boolean equalsRange(List<?> other, int from, int to){
        final Object[] es = elementData;
        //用var变量接收linkedList的遍历器(第一处)
        var oit = other.iterator();
        for (; from < to; from++){
            //如果linkedList没有元素,则equals结果直接为false;
            //如果linkedList有元素,则对应下标进行值的比较(第二处)
            if (!oit.hasNext() || !Object.equals(es[from], oit.next())){
                return false;
            }
        }
        //如果arrayList已经遍历完,而linkedList还有元素,则equals结果为false
        return !oit.hasNext();
    }

第一处说明:局部变量类型推断是JDK10引入的变量命名机制,一改Java是强类型语言的传统形象,这是Java致力于未来体积更小,面向生产效率的新语言特性,减少累赘的语法规则,当然这仅仅是一个语法糖,Java仍然是一种静态语言。在初始化阶段,在处理var变量的时候,编译器会检测右侧代码的返回值类型,并将其类型用于左侧。

第二处说明:尽量避免通过实例对象引用来调用equals方法,否则容易抛出空指针异常。推荐使用JDK7引入的Object的equals方法,源码如下,可以有效地防止在equals调用时产生NPE问题:

public static boolean equals(Object a, Object b){
    return (a == b) || (a != null && a.equals(b));
}

  • hashCode()为不同的对象产生不同的int值,根据equals定义:如果两个对象是相等(equals)的,那么它们的hashCode()返回结果也必须是相等的;如果两个对象是不相等的,那么它们的hashCode()返回结果也必须不相等。所以必须同时重写equals()和hashCode()以保证同步性。

请看下面一段代码,这段代码是我写的一个简单的例子,目的是撇去一切干扰,能更清楚明了的明白为什么重写equals的同时也要重写hashCode():

package Test;

import java.util.HashMap;
import java.util.Map;

public class hashCodeTest {
    public static void main(String[] args) {
        Worker worker1 = new Worker("wang");
        Worker worker2 = new Worker("wang");

        System.out.println( "worker1.equals(worker2)的结果为"+worker1.equals(worker2));
        System.out.println("worker1.hashCode()="+worker1.hashCode()+" | worker2.hashCode()="+worker2.hashCode());

        Map<Worker,Integer> map1 = new HashMap<Worker,Integer>();
        map1.put(worker1, new Integer(10));
        System.out.println(map1.get(worker2));
    }
}

class Worker{
    private String name;

    Worker(String name){
        this.name = name;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    //重写equals方法,让其名字相等则判断对象相等
    @Override
    public boolean equals(Object obj){
        return (this.name.equals(((Worker)obj).name));
    }

}

先给出执行结果:

上面这段代码中,我在Worker类中覆写了equals()方法:当对象的name属性相同时则判定两个对象相等。由上面的执行结果可以看到worker1.equals(worker2)的结果为true,但是由hashCode可以看出这其实是两个完全不同的对象。当程序执行map.get(worker2)时,会计算worker2的hashcode,而由于Object.hashCode()返回的worker1和worker2的hash值并不相同,因此,map中未找到worker2对应的key值,所以返回的value便为null。

所以可以得到以下结论:

  • 如果自定义了equals()方法,且返回true的充要条件不是(this == obj),那么就必须覆写hashCode,且必须保证equals为true的时候两者的hashCode必须相等,否则当该对象作为key值存在hash表中的时候,就无法用逻辑上相等的对象取出该key所对应的value
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值