前言
本篇博客主要梳理一下Java中对象比较的需要注意的地方。
==和equals()方法
在前面对String
介绍时,谈到过使用==
和equals()
去比较对象是否相等的问题。使用==
比较的是两个对象在内存中的地址是否一致,也就是比较两个对象是否为同一个对象。使用equals()
方法可以依据对象的值来判定是否相等。
equals()方法是根类Object的默认方法,查看Object中equals()的默认实现:
public boolean equals(Object obj) {
return (this == obj);
}
可看出没有重写过的equals()
方法和==
是一样的,都是比较两个对象引用指向的内存地址是否一样判断两个对象是否相等。
在介绍String时,我们发现并没有重写过equals()方法,但是可以使用equals()正确判断两个字符串对象是否相等。查看String源码可以发现是String本身重写了equals()方法。
public boolean equals(Object anObject) {
if (this == anObject) {
return true;
}
if (anObject instanceof String) {
String anotherString = (String)anObject;
int n = value.length;
if (n == anotherString.value.length) {
char v1[] = value;
char v2[] = anotherString.value;
int i = 0;
while (n-- != 0) {
if (v1[i] != v2[i])
return false;
i++;
}
return true;
}
}
return false;
}
Java中很多类都自身重写了equals()方法,但是要使我们自定义的对象能正确比较,我们就需要重写equals()方法。
public class Student{
private String name;
private int age;
public Student(String name, int age) {
this.name = name;
this.age = age;
}
@Override //此关键字可以帮助我们检查是否重写合乎要求
public boolean equals(Object obj) {
if (this == obj) //检测this与obj是否指向同一对象。这条语句是一个优化,避免直接去比较同一对象的各个域
return true;
if (obj == null)
return false;
if (getClass() != obj.getClass()) // 比较this和obj是否属于同一个类 若是两个对象都不是同一个类的 则不相等
return false;
Student other = (Student) obj; //将obj转换成相应的Student类型
//对所有需要比较的域进行比较 基本类型使用== 对象域使用equal 数组类型的域,可以使用静态的Arrays.equals方法检测相应的数组元素是否相等
if (age != other.age)
return false;
if (name == null) {
if (other.name != null)
return false;
} else if (!name.equals(other.name))
return false;
return true;
}
public static void main(String[] args) {
Student stu1 = new Student("sakura",20);
Student stu2 = new Student("sakura",20);
System.out.println(stu1.equals(stu2)); //output: true
}
}
以上重写的equals()方法是考虑最为全面的,推荐使用这种方式。当然人性化的IDE会帮助我们写代码,如eclipse,就有快捷键帮助我们自动生成此格式的equals()方法。
hashCode()方法和equals()方法
在上图中,hashCode()
和equals()
是配套自动生成的。为什么要附加生成hashCode()呢?
hashCode()是根类Object中的默认方法,查看JDK文档:
hashCode()方法与equals()方法没有任何关系,hashCode()的存在是为了服务于建立在散列表基础上的类,如Java容器的HashMap, HashSet等。hashCode()方法获取对象的哈希码(散列码)。哈希码是一个int型的整数,用于确定对象在哈希表(散列表)中的索引位置。
hashCode()方法会根据不同的对象生成不同的哈希值,默认情况下为了确保这个哈希值的唯一性,是通过将该对象的内部地址转换成一个整数来实现。
下面我们看一个例子:
public static void main(String[] args) {
Student stu1 = new Student("sakura",20);
Student stu2 = new Student("sakura",20);
HashSet<Student> stuSet = new HashSet<>();
stuSet.add(stu1);
stuSet.add(stu2);
System.out.println(stu1.equals(stu2));
System.out.println(stu1);
System.out.println(stu2);
System.out.println(stuSet);
}
/*
output:
true
prcatice.Student@7852e922
prcatice.Student@4e25154f
[prcatice.Student@7852e922, prcatice.Student@4e25154f]
*/
HashSet不会存储相同的对象。按理来说,stu1和stu2是相等的,不应该被重复放进stuSet里面。但是结果显示,出现了重复的对象。因为stu1和stu2的hashCode()返回值不同,所以它们将会被存储在stuSet中的不同的位置。
对象存储在HashSet中时,先会根据对象的哈希值来查看是否哈希表中相应的索引位置是否有对象,若是没有则直接将对象插入;若是该位置有对象,则使用equals判断该位置上的对象与待插入的对象是否为相同对象,两个对象相等则用新值刷新旧值,不相等就将待插入对象挂在已存在对象的后面(单链表挂载)。
所以,要使stu1和stu2不能都被插入stuSet中,则要在Student中重写hashCode()方法。
@Override
public int hashCode() {
final int prime = 31;
int result = 1;
result = prime * result + age;
result = prime * result + ((name == null) ? 0 : name.hashCode());
return result;
}
在hashCode()中加入31这个奇素数来计算哈希值目的是为了减少哈希冲突(在同一位置插入多个对象)。详细理由可以参考此篇博文:为什么在定义hashcode时要使用31这个数呢?
然后我们在运行一次程序的输出如下:
/*
true
prcatice.Student@c9c6a694
prcatice.Student@c9c6a694
[prcatice.Student@c9c6a694]
*/
Comparator接口和Comparable接口
我们使用equals()方法可以实现比较我们自定义类的对象是否相等,但是却无法得到对象谁大谁小的关系。Java中提供了两种方式来使得对象可以比较大小,实现Comparator
接口或者Comparable
接口。
Comparable接口
以able
结尾的接口都表示拥有某种能力。若是某个自定义类实现了comparable接口,则表示该类的实例对象拥有可以比较的能力。
实现comparable接口需要覆盖其中的compareTo()方法。
int compareTo(T o)
- 返回负数:当前对象小于指定比较的对象;
- 返回0,两个对象相等;
- 返回正数,当前对象大于指定比较的对象。
public class Student implements Comparable<Student>{
private String name;
private int age;
public Student(String name, int age) {
this.name = name;
this.age = age;
}
//重写comparaTo方法 以age作为标准比较大小
@Override
public int compareTo(Student o) {
return return (this.age<o.age ? -1 : (this.age == o.age ? 0 : 1));;//本类接收本类对象,对象可以直接访问属性(取消了封装的形式)
}
@Override
public String toString() {
return "name:" +name + " age:"+age;
}
public static void main(String[] args) {
Student stu1 = new Student("sakura",20);
Student stu2 = new Student("sakura",21);
Student stu3 = new Student("sakura",19);
//TreeSet会对插入的对象进行自动排序,所以要求知道对象之间的大小
TreeSet<Student> stuSet = new TreeSet<>();
stuSet.add(stu1);
stuSet.add(stu2);
stuSet.add(stu3);
//使用foreach(), lambda表达式输出stuSet中的值 forEach()方法从JDK1.8才开始有
stuSet.forEach(stu->System.out.println(stu));
}
}
/*
output:
name:sakura age:19
name:sakura age:20
name:sakura age:21
*/
实现了comparaTo()方法使用age为标准升序排序。也可以以name为标准排序,或者其他自定义的比较依据。
但是当Student已经实现了以age为依据从小到大排序后,我们又想以name为依据排序,在这个简单的程序中可以直接将return this.age-o.age
变为return this.name.compareTo(o.name)
(name为String对象)。
但是这样修改类结构会显得十分麻烦,万一在以后的程序中遇到的是别人封装好的类不能直接改类结构又该怎么办。
有没有其他方便的比较方法,实现对象的大小比较。办法是有的,那就是实现Comparator接口。
Comparator接口
实现Comparator接口需要重写其中的compare()方法。
int compare(T o1,T o2)
根据第一个参数小于、等于或大于第二个参数分别返回负整数、零或正整数,通常使用-1, 0, +1表示。
需要注意,Comparator接口中也有一个equals方法,但是这是判断该比较器与其他Comparator比较器是否相等。
public class Student {
private String name;
private int age;
public Student(String name, int age) {
this.name = name;
this.age = age;
}
@Override
public String toString() {
return "name:"+name + " age:"+age;
}
public static void main(String[] args) {
Student stu1 = new Student("sakuraamy",20);
Student stu2 = new Student("sakurabob",21);
Student stu3 = new Student("sakura",19);
ArrayList<Student> stuList = new ArrayList<>();
stuList.add(stu1);
stuList.add(stu2);
stuList.add(stu3);
//没有必要去创建一个比较器类 采用内部类的方式实现Comparator接口
Collections.sort(stuList, new Comparator<Student>() {
@Override
public int compare(Student o1, Student o2) {
return (o1.age<o2.age ? -1 : (o1.age == o2.age ? 0 : 1));
//return o1.name.compareTo(o2.name);
}
});
//或者使用lambda表达式
//Collections.sort(stuList, (o1,o2)->o1.age-o2.age);
System.out.println(stuList);
}
}
/*
[name:sakura age:19, name:sakuraamy age:20, name:sakurabob age:21]
*/
由上可见,实现Comparator接口比较对象比实现Comparable接口简单和灵活。
使用这两个接口比较对象都需要注意几点:
- 对称性:若存在compare(x, y)>0 则 compare(y, x) <0,反之亦然
- 传递性:((compare(x, y)>0) && (compare(y, z)>0)) 可以推导出compare(x, z)>0
- 相等替代性:compare(x, y)0可以推导出compare(x, z)compare(y, z)
补充
在Comparable中没有使用简洁明了的this.age-o.age作为返回值,是因为这是一个常见的编程错误。它只能在this.age和o.age都是无符号的整数时才能正确工作。而Java只支持有符号数,所以这种方式就存在潜在危险。this.age是很大的正整数而o.age是很大的负整数,二者相减就会溢出从而产生负值,导致错误结果。
小结
简单总结一下本篇关于Java中对象比较的内容:
- 要比较自定义类的对象是否相等需要重写equals()方法;
- 当对象要存储在建立在哈希表基础上的容器中时,还需要重写hashCode()方法用于判定对象在集合中的存储位置;
- 以某种依据比较对象的大小,可以实现Comparable接口或者Comparator接口,前者需要在类中实现表示该类拥有可以比较的能力,后者是在类外实现一个比较器,可以使用多种规则对对象进行比较,更灵活。
参考:
[1] Eckel B. Java编程思想(第四版)[M]. 北京: 机械工业出版社, 2007
[2] 掘金. JAVA 对象比较中的坑[EB/OL]. /2018-12-01. https://juejin.im/entry/586c6a6061ff4b006407e2b9.