《Effective Java》- Object 通用方法

在日常 coding 中,我们会经常使用或者覆盖 Object 对象中的方法,如:equals、toString、hashcode 等。除此之外,还有 clone、finalize 方法。限于作者实力,很少用到这两个方法,即使看过原书也是一知半解,因此不敢用来「忽悠」别人。因此,这篇文章只对 equals、toString、hashCode、compareTo(该方法并非 Object 的方法)进行讲解。

强烈推荐去看原书《Effective Java》,Java 程序员不能没有这本书,就像西方不能没有耶路撒冷。

equals 覆盖最佳实践

equals 方法对于刚入行的人既熟悉又陌生。给人的感觉是知道这个方法,但是在实际工作中从来没有用过。虽然我也用的很少,但还是知道它的用处,以及在日常业务代码中的一些技巧,作用。

equals 作用

一句话概述:比较两个对象是否相等。仅仅是比较对象是否相等,好像不值得拿出来一说。因为是 Object 的方法,因此所有的类都可以重写这个 equals 方法,满足不同类的需求,以及和 Java 中的集合类搭配使用,会产生不一样的化学反应(后面会具体描述)。

业务场景与 equals 产生的化学反应

假设老板给你提这样一个需求:在一组用户数据中,筛选出每个省份,年龄不重复用户数据。例如不能包含两条籍贯江西,年龄一岁的用户

你可能会想到,每次往筛选好的集合中添加数据,都要进行判断,插入的数据在集合是否有重复,这样就写成了一个双重 for 循环

1
2
3
4
5
6
7
8
9
10
11
12
private List filter(List<User> noDuplicatedUserList, List<User> allUserList) {
for (User user1 : allUserList) {
for (User user2 : noDuplicatedUserList) {
if (user1.getHome.equals(user2.getHome) && user1.getAge().equals(user2.getAge())) {
continue;
}
noDuplicatedUserList.add(user1);
break;
}
}
return noDuplicatedUserList;
}

任务当然可以完成,但是你掌握一些 equals 的用法后,你就可以开始玩一些「骚操作」了。

我们开始覆盖 User 类的 equals 方法,使得这个行为变得更简单,equals 覆盖后代码如下:

1
2
3
4
5
6
7
8
private List filter(List<User> noDuplicatedUserList, List<User> allUserList) {
for (User user : allUserList) {
if(!noDuplicatedUserList.contains(user)){
noDuplicatedUserList.add(user);
};
}
return noDuplicatedUserList;
}

是的,重写完 equals 之后,只需要使用 contains判断两个对象是否相等即可,因为 contains方法内部使用的就是对象的 equals 方法。

你还可以将代码更简化,让他返回一个去重的 HashSet 即可,代码如下:

1
2
3
private Set filter(List<User> allUserList) {
return new HashSet<>(allUserList);
}

覆盖 equals 注意事项

覆盖 equals 能帮我们解决问题,但必须使用得当,如果覆盖的 equals 方法,没有遵守相关约定,那么你的程序必然会在一些类的使用上出现 bug。出现 bug 一点都不可怕,可怕的是你不知道 bug 出现在哪,检查代码也发现不了错误,到头来才发现原来是这个不起眼的 equals 导致的。

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

  • 对称性:对于任何非 null 的引用值 x,x.equals(y) 返回 true,y.equals(x) 也必须返回 true。

  • 传递性:对于任何非 null 的引用值 x,y,z,x.equals(y) 返回 true,并且 y.equals(z) 返回 true,那么 x.equals(z) 必须返回 true。

  • 一致性:如果两个对象相等,在没有改变对象的前提下,他们就始终相等。

  • 非空性:所有的对象都不能等于 null,x.equals(null),因此在一些 equals 方法中都会进行 null 检查,如下:

1
2
3
if(o == null){
return false;
}

看到这些规定,你可能头都炸了,我就写个 equals 还要遵守这么多约定,我还不如直接使用第一种方法呢。别急,往下看,最佳实践有人已经帮你总结出来了。

覆盖 equals 最佳实践

了解 equals 的注意事项后,以下经验就能帮助我们优雅地,高质量地覆盖 equals。拿上面的 User 来举例说明,步骤如下。

步骤一:使用 == 操作检查对象是否相等,如果是,返回 true。

1
if(obj == this) return true;

步骤二:使用 instanceof 操作符检查「参数是否为正确类型」

1
2
3
if(!(obj instanceof User)){
return false;
}

步骤三:把参数转化成正确的类型(需要先通过步骤二的检查)

1
User user = (User) obj;

步骤四:比较参数中的字段与该对象的字段,匹配返回 true,不匹配返回 false;

1
2
3
4
if(user.getHome().equals(home) && user.getAge.equals(age)){
return true;
}
return false;

完整的 equals 方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public boolean equals(Object obj){
if(obj == this) return true;

if(!(obj instanceof User)){
return false;
}

User user = (User) obj;
if(user.getHome().equals(home) && user.getAge.equals(age)){
return true;
}
return false;
}

这样就写完了一个高质量的 equals 方法。但是还需要注意的是,覆盖 equals 方法,必须也覆盖 hashCode 方法(原因见下一节)。equals 的参数是 Object 对象,而不是具体的某个类,因为前者才是覆盖了 equals 方法,后者只是类中的一个普通方法。

hashCode 覆盖最佳实践

覆盖 equals 为什么一定要覆盖 hashCode

回答这个问题很简单,其实就是违反了 hashCode 的约定:两个对象相等,则必须返回相同的 hashCode

由于覆盖了类的 equals 方法,导致对象相等的逻辑发生了改变,而类的 hashCode 方法还是使用的 Object 提供的方法,就会导致相同的对象返回不同的 hashCode。

你可能觉得 hashCode 不同就不同嘛,但是你也就此失去了使用 HashMap,HashSet 等集合类的权利,强行使用会给你的代码带来未知的 bug,因为这些类都是根据 hashCode 约定来实现的。

我们可以通过一个例子演示该行为带来的问题,代码如下:

1
2
Map<User, String> m = new HashMap<>();
m.put(new User("江西","18"), "渣渣辉");

我们向 HashMap 插入了一条数据,你可能期望 m.get(new User("江西","18"))会返回「渣渣辉」,但很可惜并不是,而是返回 null。

这是因为,虽然是两个是相同的对象,但是没有覆盖 hashCode 方法,导致存进 map 的对象的 hashcode 和 取值对象 hashcode 不一样,因此就会取不到对应的 value。

覆盖 hashCode 方法

计算 hashCode

  1. 我们在方法中初始化变量 result,并让他的值等于第一个需要计算字段的值。
  2. 如果字段是基本类型,我们就使用它的装箱基本类型的 hashCode 方法,计算该字段的 hashcode。
  3. 如果是对象,并且类的 equals 方法调用了该对象的 equals 方法进行比较,则我们也需要在类的 hashCode 方法中递归地调用该对象的 hashCode 方法。
  4. 如果是数组,则需要对数组中的重要元素使用上诉的方法计算 hashCode。如果所有元素都很重要,则可以使用 Arrays.hashCode 方法

合并返回 hashCode

1
result = 31 * result + c;  // 其中 C 为单个字段的 hashCode 值

这里估计你又会好奇,为什么是要乘 31。首先 31 是一个奇素数,如果是偶数的话并且乘法导致数据溢出,就会产生信息丢失。31 有个很好的特性,可以使用移位和减法来代替乘法,可以获得更好的性能,如:31 * i == (i << 5) - i

我们把上述实践运用到 User 类中:

1
2
3
4
5
public int hashCode(){
int result = String.hashCode(home);
result = 31 * result + String.hashCode(age);
return result;
}

这样写算是比较高效的合理的覆盖,有时我们需要让 hashCode 值尽可能地不会造成冲突,我们可以使用 Guava 包下得 Hashing。如果对性能没有该要求可以使用 Objects 提供的静态 hash 方法 Objects.hash(home, age);

如果是一个不可变类,且每次计算 hashCode 的开销比较大,我们可以把 hashCode 值保存到对象内部,这样下次计算 hashCode 时可以直接把保存的 hashCode 返回。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class User{
private int hashCode;

@Override public int hashCode(){
int result = hashCode;
if(result == 0){
// 执行相应的 hashCode 计算操作
hashCode = result; // 计算完成记得赋值保存

}
return result;
}

}

优雅地覆盖 toString

我们在开发中,使用打印语句打印对象,或者在 debug 中查看对象的信息,经常能看到如下形式:User@1b456 。上诉这些行为都默认的调用了对象的 toString 方法,如果类本身没有覆盖 toString 的话,则会调用 Object 的 toString 方法,如下:

1
2
3
public String toString() {
return getClass().getName() + "@" + Integer.toHexString(hashCode());
}

从打印的对象信息来看,给我们的开发调试,提供不了任何有用的信息,因此覆盖 toString 旨在提供更多有效的信息,而不是那一串干巴巴的 User@1b456

在 toString 方法中应该包含一些与类相关的信息。例如 User 类,可以返回 User={"江西", "18"},这样清晰明了,比 User@1b456 不知好了多少。当 toString 生成的格式没有那么清晰时,就应该考虑在 javadoc 中添加格式说明,让这一过程容易被调用者接收。

tips: 应该在抽象类中编写 toString 方法,让他的子类享有公共的 toString 方法。大多数集合类就是如此,例如:ArrayList 使用的就是 AbstractCollection 的 toString 方法。

总而言之:如果 toString 能够为你的开发和调试等带来美观的格式,返回对象简明有用的描述,那你就应该毫不犹豫的覆盖它。

优雅地实现 Comparable 接口

虽然这博客说的是 Object 通用方法,但是必须说明的是 compareTo 并不是 Object 中的方法,而是 Comparable 接口的唯一方法。因为该方法很重要,且使用频率很高,所以就放在一起介绍。实现了 Comparable 接口的类,就表明该类有内在的排序关系,当然这个排序肯定是自己定义的。

java 中的有序集合类会根据其中元素类的 compareTo 方法,将内部的元素进行排序。例如:TreeSet、TreeMap 等。

撞脸 equals

compareTo 方法在官方文档的说明如下:

将此对象与指定对象进行比较。当该对象小于、等于、大于指定对象的时候、分别返回一个负整数、零或者正整数。如果由于指定的对象类型与该对象类型不一致、则会抛出 ClassCastExcetion 异常。

除了上诉说明,compareTo 方法还必须遵守和 equals 类似的约定:

  • sgn(x.compareTo(y)) == -sgn(y.compareTo(x)),其中 sgn 是根据表达式的值为负数、正数、零,分别返回 -1、1、0。上面那个公式翻译过来就是:如果 x 大于等于 y,则 y 一定小于或等于 x,并且暗示着后者抛出异常时,前者也必定抛出异常。反之亦然。

  • 可传递性:如果 x 大于 y,并且 y 大于 z,则 x 必须大于 z。反之亦然。

  • 如果 x.compare(y) == 0,则 sgn(x.compare(z)) 等于 sgn(y.compare(z))。

  • 强烈建议,但非必要。(x.compareTo(y) 等于 (x.equals(y))。

相信看完 equals 的约定,遵守这些约定对你来说应该是小菜一碟了。遵守这些约定,就能安全的使用有序集合类,以及包含搜索和排序算法的工具类。

与 equals 的区别

上面提到的强烈建议,与 equals 保持同等性。即当 compareTo 比较两个对象相等时, 两个对象进行 equals 方法比较也应该相等。

如果违反这条建议,那么它的顺序就与 equals 不一致。如果一个类的 compareTo 方法强加了一个与 equals 不一致的顺序,那么这个类仍然可以工作,但是包含该类元素的有序集合可能无法遵守集合接口(Collection、Set 或 Map)的一般约定。这是因为这些接口的一般约定是根据 equals 方法定义的,但是有序集合使用 compareTo 代替了 equals 实施同等性检验。

以下例子可以很好的解释。

Java 中的 BigDecimal 类,它的 compareTo 方法与 equals 不一致(没有遵守上述的建议)。如果你创建一个空的 HashSet 实例,然后添加 new BigDecimal(“1.0”) 和 new BigDecimal(“1.00”),那么该 HashSet 将包含两个元素,因为添加到该集合的两个 BigDecimal 实例在使用 equals 方法进行比较时结果是不相等的。但是,如果你使用 TreeSet 而不是 HashSet 执行相同的过程,那么该集合将只包含一个元素,因为使用 compareTo 方法比较两个 BigDecimal 实例时结果是相等的。

因此,在使用具有排序功能的集合类时,需要特别注意 compareTo 和 equals 实现,防止引入 bug 。

编写 comparaTo 方法

应该杜绝使用关系运算符 “>” 和 “<” 来表示大小关系,推荐使用包装类型提供的 compareTo 方法,来比较值。假设用户的年龄值为 int 类型,比较年龄可以使用如下方法:

1
2
3
4
5
6
7
8
9
public int compareTo(User user){
if(age > user.getAge()){
return 1;
}else if(age < user.getAge()){
return -1;
}else{
return 0;
}
}

上面的写法也能达到比较的目的,但是很容易出错,而且需要比较的字段一多整个逻辑就很复杂。所有的装箱类型在 java7 中已经提供了静态的 compare 方法,并且也都实现了 comparable 接口。

我们给 User 类添加一个字段 name,然后对他进行排序,先比较年龄,然后根据姓名进行排序

使用包装类型的 compareTo 方法,如下:

1
2
3
4
5
6
7
public int compareTo(User user){
int result = age.compareTo(user.getAge);
if(result == 0){
result = name.compareTo(user.getName());
}
return result;
}

使用包装类型的静态 compare 方法,如下:

1
2
3
4
5
6
7
public int compareTo(User user){
int result = Integer.compare(age, user.getAge);
if(result == 0){
result = String.compare(name, user.getName());
}
return result;
}

在 java 8中,Comparator 接口配置了一组比较器构造方法,使得构造比较器非常简单,并且代码的可读性非常高。还是上面的示例,我们为 User 类编写一个比较器。

1
2
3
4
5
6
7
private static final Comparator<User> COMPARATOR = 
comparingInt(User::getAge)
.thenComparing(User::getName);

public int compareTo(User user){
return COMPARATOR.compare(this, user);
}

上面的代码为 User 类编写了一个比较器,先比较年龄,然后比较名字。定义好比较器后,就直接在 User 的compareTo 方法使用即可。comparingInt 对应比较 int 类型,相似的也有 comparingLong_、和 _comparingDouble等。

一个错误地实践,有时我们会把比较结果的正负来判断大小,我们就很容易偷懒写出如下代码:

1
2
3
public int compare(int value1, int value2){
return value1 - value2;
}

然后使用它的比较结果,来判断是正还是负,以此来判断 value1 和 value2 的大小关系,但是我们却没有考虑过整数溢出的情况。因为 int 类型最大,最小能表示正负21 亿左右的数字,假如 value1 是 int 类型最小值,value2 是正数,两者相减就会导致整数越界。因此使用官方为我们提供的比较方法 Integer.compare(value1, value2),更为安全实用。


《Effective Java》- Object 通用方法
http://wszzf.top/2021/12/16/《Effective Java》 - Object 通用方法/
作者
Greek
发布于
2021年12月16日
许可协议