hashcode和equals 简单的方法也可能引发问题

在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


由于我们定义的v1和v2对象完全相同,所以我们期望最后的返回应该是true,但是由于没有覆盖hashCode方法,所以v1.hashCode() != v2.hashCode()而导致最终v1和v2的哈希值不同,无法从哈希表中定位到同一个地方。


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方法,以免引起混乱。

引用列表

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值