Effective Java学习笔记 --- 对所有对象通用的方法

重写equals方法时遵守通用约定

通常在下面的几种情况下都没有必要重写equals()方法:

  • 类的每个实例意义上都是唯一
  • 不关心类是否提供逻辑相等的测试功能
  • 父类已经重写了equals()方法,并且该方法对子类也适用
  • 类为私有时,equals()方法不会被调用

当类需要拥有逻辑相等的概念且父类没有重写equals()方法时,我们需要进行equals()方法的重写.在重写时要遵循下面的规则:

  • 自反性,即对象必须等于其自身.
  • 对称性,对于两个对象的比较是否相等必须保持一致.
  • 传递性,若x.equals(y)为true,y.equals(z)为true,那么x.equals(z)必须为true.
  • 一致性,比较操作内所用的数据没有修改时,多次的调用结果必须一致.

实现高质量equals()方法的技巧:

  1. 使用==操作符检查 “参数是否为这个对象的引用” .如果是,返回true.若比较操作代价较大,这种优化可提高性能.
  2. 使用instanceof操作符检查 “参数是否为正确的类型” .如果不是,返回false.这里 “正确的类型” 通常指的是equals()方法所在的类,有些情况下指的是所在类实现的接口类型.
  3. 把参数转换为正确的类型.上一条已确保能转换成功.
  4. 对于该类的每个关键域,检查参数中的域是否与对象对应的域匹配.若测试全部成功,返回true,否则返回false.
  5. 使用单元测试进行检验

最后需要注意的几点:

  • 重写equals()时总要重写hashCode()
  • 不要企图让equals()方法过于智能
  • 不要将equals声明中的Object对象替换为其他的类型

    重写equals时总要重写hashCode

    在每个重写了equals()的类中,也必须重写hashCode().如果不这样做的话,会导致该类无法在所有基于散列的集合中正常工作,像HashMap,HashSet和HashTable等.
    下面是约定的内容:
  • 只要对象的equals()方法的比较操作中所用到的信息没有被修改,对于这个对象多次调用hashCode()必须返回同一个整数.
  • 如果两个对象根据equals()比较是相等的,调用任意一个对象的hashCode()必须产生同样得到整数结果.
  • 如果两个对象根据equals()比较是不想等的,调用任意一个对象的hashCode()则不一定要产生不同的整数结果.但给不相等的对象产生不同的整数结果会提高散列表的性能.

下面来看看重写了equals()但没有重写hashCode()会有什么样的后果.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public class PhoneNumber {
private final short areaCode;
private final short prefix;
private final short lineNumber;

public PhoneNumber(int areaCode, int prefix, int lineNumber) {
rangeCheck(areaCode, 999, "area code");
rangeCheck(prefix, 999, "prefix");
rangeCheck(lineNumber, 9999, "line number");
this.areaCode = (short) areaCode;
this.prefix = (short) prefix;
this.lineNumber = (short) lineNumber;
}

private static void rangeCheck(int arg, int max, String name) {
if (arg < 0 || arg > max)
throw new IllegalArgumentException(name + ": " + arg);
}

@Override
public boolean equals(Object o) {
if (o == this)
return true;
if (!(o instanceof PhoneNumber))
return false;
PhoneNumber pn = (PhoneNumber) o;
return pn.lineNumber == lineNumber &&
pn.prefix == prefix &&
pn.areaCode == areaCode;
}
}

上面是只重写了equals()的类,当我们尝试运行下面的代码时会得到我们不希望的结果.

1
2
3
4
Map<PhoneNumber,String> m = new HashMap<>();
m.put(new PhoneNumber(707,867,5309),"Jenny");
String s = m.get(new PhoneNumber(707, 867, 5309));
System.out.println(s); // s打印的结果是null

我们所希望的结果是会得到”Jenny”这个字符串,但是结果却是null.上面的代码涉及到两个PhoneNumber的实例,第一个是插入到HashMap中,第二个实例与第一个是相等的,用于获取名字.由于PhoneNumber类并没有重写hashCode()导致两个相等的实例具有不相等的散列码.put()把电话号码放到一个hash bucket中,get()却在另一个hash bucket中查找这个电话号码.
解决上述问题的方法很简单,就是重写hashCode()方法.但hashCode()方法的实现也需要技巧,下面是实现hashCode()的一种简单的方法:

  1. 把一个非零的常数值保存在一个名为result的int类型变量中.
  2. 对于对象中每个关键域(即equals()比较时用到的域),完成下面步骤:
    • 为该域计算int类型的散列码c:
      • boolean类型:计算( f ? 1 : 0)
      • byte,char,short,int类型,计算 (int)f
      • long类型,计算(int)(f ^ (f >>> 32))
      • float类型,计算Float.floatToBits(f)
      • double类型,计算Double.doubleToLongBits(f)然后按long类型计算
      • 对象引用类型并equals()中递归调用equals()来比较,则递归调用hashCode().若该域值为null,则返回0.
      • 数组类型,数组中的每个元素单独处理.
    • 把上面的散列码c合并到result中: result = 31 * result + c;
  3. 返回result
  4. 进行单元测试验证

根据上面的方法我们重写PhoneNumber类的hashCode():

1
2
3
4
5
6
7
public int hashCode() {
int result = 17;
result = 31 * result + areaCode;
result = 31 * result + prefix;
result = 31 * result + lineNumber;
return result;
}

有了hashCode()的实现后,再次运行我们的测试代码就能得到正确的结果了.除了上面所提供的方法外,还有很多其他的散列方法.
如果一个类是不可变的,每次都请求散列码都进行运算是不合理的,可以把散列码缓存在对象内部.如果这种类型的大多数对象会被用在散列键,就应该创建实例时计算散列码.否则可以选择lazily initialize,当hashCode()被第一次调用才进行计算.下面是PhoneNumber的lazily initialize的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
private volatile int hashCode;
@Override
public int hashCode() {
int result = hashCode;
if(result == 0) {
result = 17;
result = 31 * result + areaCode;
result = 31 * result + prefix;
result = 31 * result + lineNumber;
hashCode = result;
}
return result;
}

建议始终重写toString

重写toString()可以易于阅读类信息.当对象被传递给println,printf,字符串连接符(+)以及assert或者被调试器打印出来时,toString()会被自动调用.程序员可以根据toString()所返回的信息快速的判断程序的问题在哪里.

考虑实现Comparable接口

Comparable接口中只有一个compareTo()方法,该方法不但允许进行简单的等同性比较,而且允许执行顺序比较,实现了Comparable接口的对象数组排序是非常简单的,只需调用Java的数组工具类的sort()即可.