《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 |
|
任务当然可以完成,但是你掌握一些 equals 的用法后,你就可以开始玩一些「骚操作」了。
我们开始覆盖 User 类的 equals 方法,使得这个行为变得更简单,equals 覆盖后代码如下:
1 |
|
是的,重写完 equals 之后,只需要使用 contains
判断两个对象是否相等即可,因为 contains
方法内部使用的就是对象的 equals 方法。
你还可以将代码更简化,让他返回一个去重的 HashSet 即可,代码如下:
1 |
|
覆盖 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 |
|
看到这些规定,你可能头都炸了,我就写个 equals 还要遵守这么多约定,我还不如直接使用第一种方法呢。别急,往下看,最佳实践有人已经帮你总结出来了。
覆盖 equals 最佳实践
了解 equals 的注意事项后,以下经验就能帮助我们优雅地,高质量地覆盖 equals。拿上面的 User 来举例说明,步骤如下。
步骤一:使用 == 操作检查对象是否相等,如果是,返回 true。
1 |
|
步骤二:使用 instanceof 操作符检查「参数是否为正确类型」
1 |
|
步骤三:把参数转化成正确的类型(需要先通过步骤二的检查)
1 |
|
步骤四:比较参数中的字段与该对象的字段,匹配返回 true,不匹配返回 false;
1 |
|
完整的 equals 方法
1 |
|
这样就写完了一个高质量的 equals 方法。但是还需要注意的是,覆盖 equals 方法,必须也覆盖 hashCode 方法(原因见下一节)。equals 的参数是 Object 对象,而不是具体的某个类,因为前者才是覆盖了 equals 方法,后者只是类中的一个普通方法。
hashCode 覆盖最佳实践
覆盖 equals 为什么一定要覆盖 hashCode
回答这个问题很简单,其实就是违反了 hashCode 的约定:两个对象相等,则必须返回相同的 hashCode。
由于覆盖了类的 equals 方法,导致对象相等的逻辑发生了改变,而类的 hashCode 方法还是使用的 Object 提供的方法,就会导致相同的对象返回不同的 hashCode。
你可能觉得 hashCode 不同就不同嘛,但是你也就此失去了使用 HashMap,HashSet 等集合类的权利,强行使用会给你的代码带来未知的 bug,因为这些类都是根据 hashCode 约定来实现的。
我们可以通过一个例子演示该行为带来的问题,代码如下:
1 |
|
我们向 HashMap 插入了一条数据,你可能期望 m.get(new User("江西","18"))
会返回「渣渣辉」,但很可惜并不是,而是返回 null。
这是因为,虽然是两个是相同的对象,但是没有覆盖 hashCode 方法,导致存进 map 的对象的 hashcode 和 取值对象 hashcode 不一样,因此就会取不到对应的 value。
覆盖 hashCode 方法
计算 hashCode
- 我们在方法中初始化变量 result,并让他的值等于第一个需要计算字段的值。
- 如果字段是基本类型,我们就使用它的装箱基本类型的 hashCode 方法,计算该字段的 hashcode。
- 如果是对象,并且类的 equals 方法调用了该对象的 equals 方法进行比较,则我们也需要在类的 hashCode 方法中递归地调用该对象的 hashCode 方法。
- 如果是数组,则需要对数组中的重要元素使用上诉的方法计算 hashCode。如果所有元素都很重要,则可以使用 Arrays.hashCode 方法
合并返回 hashCode
1 |
|
这里估计你又会好奇,为什么是要乘 31。首先 31 是一个奇素数,如果是偶数的话并且乘法导致数据溢出,就会产生信息丢失。31 有个很好的特性,可以使用移位和减法来代替乘法,可以获得更好的性能,如:31 * i == (i << 5) - i
。
我们把上述实践运用到 User 类中:
1 |
|
这样写算是比较高效的合理的覆盖,有时我们需要让 hashCode 值尽可能地不会造成冲突,我们可以使用 Guava 包下得 Hashing。如果对性能没有该要求可以使用 Objects 提供的静态 hash 方法 Objects.hash(home, age);
。
如果是一个不可变类,且每次计算 hashCode 的开销比较大,我们可以把 hashCode 值保存到对象内部,这样下次计算 hashCode 时可以直接把保存的 hashCode 返回。
1 |
|
优雅地覆盖 toString
我们在开发中,使用打印语句打印对象,或者在 debug 中查看对象的信息,经常能看到如下形式:User@1b456
。上诉这些行为都默认的调用了对象的 toString 方法,如果类本身没有覆盖 toString 的话,则会调用 Object 的 toString 方法,如下:
1 |
|
从打印的对象信息来看,给我们的开发调试,提供不了任何有用的信息,因此覆盖 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 |
|
上面的写法也能达到比较的目的,但是很容易出错,而且需要比较的字段一多整个逻辑就很复杂。所有的装箱类型在 java7 中已经提供了静态的 compare 方法,并且也都实现了 comparable 接口。
我们给 User 类添加一个字段 name,然后对他进行排序,先比较年龄,然后根据姓名进行排序
使用包装类型的 compareTo 方法,如下:
1 |
|
使用包装类型的静态 compare 方法,如下:
1 |
|
在 java 8中,Comparator 接口配置了一组比较器构造方法,使得构造比较器非常简单,并且代码的可读性非常高。还是上面的示例,我们为 User 类编写一个比较器。
1 |
|
上面的代码为 User 类编写了一个比较器,先比较年龄,然后比较名字。定义好比较器后,就直接在 User 的compareTo 方法使用即可。comparingInt 对应比较 int 类型,相似的也有 comparingLong_、和 _comparingDouble等。
一个错误地实践,有时我们会把比较结果的正负来判断大小,我们就很容易偷懒写出如下代码:
1 |
|
然后使用它的比较结果,来判断是正还是负,以此来判断 value1 和 value2 的大小关系,但是我们却没有考虑过整数溢出的情况。因为 int 类型最大,最小能表示正负21 亿左右的数字,假如 value1 是 int 类型最小值,value2 是正数,两者相减就会导致整数越界。因此使用官方为我们提供的比较方法 Integer.compare(value1, value2)
,更为安全实用。