在java的基类:Object类中,其中有2个方法是Java开发者非常熟悉的,一个是hashcode,另一个是equals。
不过,这2个非常简单且常常被覆盖的方法,也经常会对java初学者甚至是有一定经验的开发者造成疑惑。
在介绍equals的几种常见错误之前,首先看看hashcode方法。
不过,在介绍hashcode之前呢,首先来看看算法及数据结构里面的hash。
hash算法及数据结构介绍
哈希,我个人认为是数据结构与算法里面最简单的一个东东,也是最实用的东东。
虽然哈希算法和哈希表的结构非常简单,但是要设计一个好的哈希算法并不是一件很容易的事情,那需要一定的数学基础和对数据分布的准确预测。这不是本文的重点,就不展开了。
下面,通过分析最简单的哈希算法和哈希表实现,来看看哈希的原理。
首先,我们需要一个哈希算法,然后我们需要一个数组。事情就结束了。
哈希算法的功能就是:把一个任意长度的数值集合,映射为一个固定长度的数值集合。
例如,在Java中,则是使用了数组和链表来实现哈希表。如下图:
在上图中,我们写了一个非常简单的哈希算法——对输入的任意整数取绝对值,然后除以11取余数。这个余数就是计算后的哈希值。
public int hash(int n) {
return Math.abs(n) % 11;
}
然后,我们来看如何将一个数字放入哈希表。例如数字91。首先,我们计算91对11取余,得到余数3。然后去找数组下标为3的位置是否有值。当前已经有一个值25。那么,将91放在25的后面。(不同的哈希表对这种哈希冲突的处理有不同的方式,Java的HashMap采用的就是这种链表存储哈希冲突值的方式。Java外,也有别的哈希表实现采用多次哈希的方式。具体可以参见哈希表的数据结构和算法的文章。这里也不做展开了。)
hashcode
理解了哈希算法和哈希表实现,再回头来看看hashcode的作用。
在Java中,当我们用到Map接口的具体实现(例如HashMap这类集合)的时候,比如调用map.get(key)方法。首先会调用key.hashCode()方法去获取一个哈希因子,再根据这个哈希因子去产生一个哈希值。所以,如果一个key的hashCode不同,则会定位到数组的不同位置。如果不同的key产生的hashCode相同,则会定位到数组的同一位置,再根据key.equals()方法去比较队列中的keys,最终得到这个key对应的value值。也就是先hash再equals的方式。
equals的常见错误
1、写equals方法的时候,方法签名中的参数类型错误(用重载代替覆盖)
假如我们定义了一个叫做“顶点”的类,x,y 分别代表x坐标和y坐标
public class Vertex {
private int x;
private int y;
public Vertex(int x, int y) {
this.x = x;
this.y = y;
}
public int getX() {
return x;
}
public void setX(int x) {
this.x = x;
}
public int getY() {
return y;
}
public void setY(int y) {
this.y = y;
}
}
现在我们添加equals方法,但是方法签名不对,看看会发生什么。
// 完全错误的定义(重载)
public boolean equals(Vertex obj) {
return (this.getX() == obj.getX() && this.getY() == obj.getY());
}
Vertex v1 = new Vertex(0, 0);
Vertex v2 = new Vertex(0, 0);
System.out.println(v1.equals(v2)); // prints true
System.out.println(v1.equals((Object) v2)); // prints false
当传入equals方法的类型不同的时候,Java会去决定调用哪一个具体的equals方法(重载),所以下面那个会调用Object.equals(Object)方法,最终返回false。
这种错误比较低级,但是具有一定的迷惑性。
2、覆盖equals方法的时候,没有同时覆盖hashCode方法
下面的例子,修正了前面的equals错误,但是没有覆盖hashCode方法。
public class Vertex {
private int x;
private int y;
public Vertex(int x, int y) {
this.x = x;
this.y = y;
}
public int getX() {
return x;
}
public void setX(int x) {
this.x = x;
}
public int getY() {
return y;
}
public void setY(int y) {
this.y = y;
}
@Override
public boolean equals(Object obj) {
boolean result = false;
if (obj instanceof Vertex) {
Vertex other = (Vertex) obj;
result = (this.getX() == other.getX() && this.getY() == other.getY());
}
return result;
}
}
看看这个会出现什么情况。
Vertex v1 = new Vertex(0, 0);
Vertex v2 = new Vertex(0, 0);
System.out.println(v1.equals(v2)); // prints true
HashSet<Vertex> set = new HashSet<Vertex>();
set.add(v1);
System.out.println(set.contains(v2)); // prints false
3、equals方法和hashCode方法中使用的变量会发生变化。
前面第二种错误出现,并不是简单的添加hashCode方法就可以避免的。
更奇怪的问题发生在当我们修改了集合中某个对象的变量值,而这个值的变化又正好会影响hashCode的最终返回值。
下面就是一个例子。
public class Vertex {
private int x;
private int y;
public Vertex(int x, int y) {
this.x = x;
this.y = y;
}
public int getX() {
return x;
}
public void setX(int x) {
this.x = x;
}
public int getY() {
return y;
}
public void setY(int y) {
this.y = y;
}
@Override
public int hashCode() {
return (this.x << 8) * 59 + this.y;
}
@Override
public boolean equals(Object obj) {
boolean result = false;
if (obj instanceof Vertex) {
Vertex other = (Vertex) obj;
result = (this.getX() == other.getX() && this.getY() == other
.getY());
}
return result;
}
}
Vertex v1 = new Vertex(0, 0);
HashSet<Vertex> set = new HashSet<Vertex>();
set.add(v1);
v1.setX(v1.getX() + 5);
System.out.println(set.contains(v1)); // prints false
这个结果会非常奇怪。因为只是修改了对象的一个值,而之前明明放入集合的对象找不到了,但它确实又在集合里面。
原因是因为hashCode方法返回的值在对象的值修改的前后发生了变更,所以改变后无法在哈希表中定位到之前的位置了。跟前面的问题是一样的。这其实是一个悖论,不覆盖hashCode会出问题,覆盖了hashCode也会出问题。
看起来解决办法只有一个——在hashCode方法中只考虑不变的因素。但是,这种方式好不好还需要自行斟酌。至少目前没有更好的办法。
4、由于继承关系导致的equals结果错误
当我们扩展一个类时候,往往也会覆盖equals方法。
例如下面这个例子,我们扩展了之前的“顶点”类,使之支持三维坐标。
同样,我们也覆盖了equals方法。先不考虑hashCode的变更,只关注于equals会不会受到影响。
public class Vertex3D extends Vertex {
private int z;
public Vertex3D(int x, int y, int z) {
super(x, y);
this.z = z;
}
public int getZ() {
return z;
}
public void setZ(int z) {
this.z = z;
}
@Override
public boolean equals(Object obj) {
boolean result = false;
if (obj instanceof Vertex3D) {
Vertex3D other = (Vertex3D) obj;
result = (this.z == other.z && super.equals(other));
}
return result;
}
}
Vertex v = new Vertex(1, 1);
Vertex3D v3d = new Vertex3D(1, 1, 1);
System.out.println(v.equals(v3d)); // prints true
System.out.println(v3d.equals(v)); // prints false
看起来也出了问题。
原因是由于Java本身的多态性,所以需要特别小心Java类之间的层次关系。比如使用关键词instanceof是否属于某个类的时候,子类也会被判断为真。这样,当我们再次调用equals方法的时候,Vertex类在判断的时候也会接收Vertex3D类的实例。然后调用父类已知的属性进行判断,最终结果导致即使子类新扩展的属性不同,但是由于对父类来说不可见,所以被略过。最后返回了非期望的结果。
当这样的类产生的对象在集合中使用时,表现更为奇怪。例如:
HashSet<Vertex> set1 = new HashSet<Vertex>();
set1.add(v);
System.out.println(set1.contains(v3d)); // prints false
HashSet<Vertex> set2 = new HashSet<Vertex>();
set2.add(v3d);
System.out.println(set2.contains(v)); // prints true
这将会导致取出非期望的对象,可能引起系统故障。
如何避免
要避免这种错误的产生,可以采用的一种方式就是不使用instanceof关键词来判断类型,而直接采用getClass()来进行类型的比较。
例如,使用eclipse自动生成的equals方法:
@Override
public boolean equals(Object obj) {
if (this == obj)
return true;
if (obj == null)
return false;
if (getClass() != obj.getClass())
return false;
Vertex other = (Vertex) obj;
if (x != other.x)
return false;
if (y != other.y)
return false;
return true;
}
结论
通过前面的问题总结,我们可以得出一些结论:
- 在覆盖equals方法的时候,一定也要同时覆盖hashCode方法。
- 在编写equals方法和hashCode方法的时候,要考虑对象内属性变更而产生的影响。并做好充分的测试(包括类和集合的测试)。
- 在编写equals方法和hashCode方法的时候,需要考虑继承而带来的影响。最好使用getClass()方法进行类型比较。
- 千万不要重载equals方法,以免引起混乱。