Effective Java笔记之改写equals的通用约定

时间:2022-06-11 13:51:32

改写equals的通用约定

我们知道,在java的世界里,所有的类都是Object的派生类,其实Java设计Object的缘由就是为了扩展,它的所有非fina方法,包括equals、hashCode、toString和finalize都有明确的通用约定。任何一个改写这些方法的时候,都得遵守这些约定。

改写equals方法看起来非常简单,但是许多改写的方式会导致错误,而且后果很严重。要避免问题最简单的方法就是不改写equals方法,在这种情况下,每个实例只与自己相等。以下情况,不需要改写equasl方法,

  1. 一个类的每个实例本质上都是唯一的。对于代表了活动而不是值的类,比如Thread类。
  2. 不关心一个类是否提供了逻辑相等的测试功能
  3. 超类已经改写了equals,从超类继承过来的行为对于子类也是合适的。例如,大多数的Set都继承了AbstractSet的equals实现,类似的还有List和Map。
  4. 一个类是私有的,并且确认它的equals方法永远不会调用

那么,什么时候,应该改写equals呢?当一个类有自己特定的“逻辑相等”概念,而超类并没有改写equals的情况下。这通常适用于“值类”情况下。比如,我们想比较两个实例是否在值一样,而不是他们是否指向同一个对象。

有一种值类对象是不需要改写equals方法的,即类型安全枚举类型。

比如,我们有以下代码,

public class Point {
private int x;
private int y;

public Point(int x, int y) {
this.x = x;
this.y = y;
}
public static void main(String[] args) {
Point p1 = new Point(2, 5);
Point p2 = new Point(2, 5);
System.out.println("p1 equals p2? " + p1.equals(p2));
}
}

我们在Point中,并没有重写equals方法,当我们想要判断p1和p2在值上是否相等的时候,我们判断的是他们的地址是否一样(也就是是否指向同一个对象),这是由于其调用了父类(Object)的equals方法,由于p1和p2是两个实例,所以p1.equals(p2)的结果是false。显然不是我们预期的结果,所以我们得重写equals方法。

改写前,就让我们来了解一下equals的一些约定,
equals方法实现了等价关系;

  1. 自反性。对于任意的x,x.equals(x)一定为true;
  2. 对称性。对于任意的x,y,x.equals(y)和y.equals(x)的值是一样的。
  3. 传递性。对于任意的x,y,z,若x.equals(y)为true,y.equals(z)也为true,则x.equals(z)也为true。
  4. 一致性。对于任意的x,y,如果x和y没有被修改,则多次调用x.equals(y)的结果是一样的。
  5. 非空性。对于任意的x,x.equals(null)一定是false。

让我们来逐条分析,
对于自反性,相比没有啥可以说的。如果自己和自己都不想等的话,那一切不都乱套了么?

对于对称性,其意思是,在x,y是否相等这个问题上,是一致的,不能说x等于y,但y不等于x。
让我们来看下面一个例子,

public class CaseInsensitiveString {

private String s;
public CaseInsensitiveString(String s) {
if (s == null)
throw new NullPointerException();
this.s = s;
}

@Override
public boolean equals(Object obj) {
// TODO Auto-generated method stub
if (obj instanceof CaseInsensitiveString)
return s.equalsIgnoreCase(((CaseInsensitiveString) obj).s);
if (obj instanceof String)
return s.equalsIgnoreCase((String) obj);
return false;
}

public static void main(String[] args) {
CaseInsensitiveString cis = new CaseInsensitiveString("wangfabo");
String s = "WangFaBo";
System.out.println(cis.equals(s));
System.out.println(s.equals(cis));
}

}

上面的例子,我们可以分析cis.equals(s)的结果是true,但是s.equals(cis)结果确是false,这是因为String的equals方法并没有实现不区分大小写。所以上个例子的equals违反了传递性规则,会给程序带来错误。

对于传递性,它的意思是,如果一个对象等于第二个对象,第二个对象等于第三个对象,则第一个对象等于第三个对象,很好理解。考虑这样的情形:一个程序员创建了一个子类,它为超类增加了一个新的特征(变量),那么,新的特征就会影响到equals的比较结果。
超类Point代码,

public class Point {
private int x;
private int y;

public Point(int x, int y) {
this.x = x;
this.y = y;
}

@Override
public boolean equals(Object obj) {
if (!(obj instanceof Point))
return false;
Point p = (Point) obj;
return p.x == x && p.y == y;
}
}

子类ColorPoint代码,

public class ColorPoint extends Point {

private Color color;

public ColorPoint(int x, int y, Color color) {
super(x, y);
this.color = color;
}
}

我们要给ColorPoint添加一个equals方法,如果,你完全不提供的话,那么就会调用Point的equals方法,这样就完全忽略了color变量,显然不符合实际,假设我们这样写equals,

    public boolean equals(Object obj) {
if (!(obj instanceof ColorPoint))
return false;
ColorPoint cp = (ColorPoint) obj;
return super.equals(obj) && cp.color == color;
}

看似很符合逻辑,当不是ColorPoint实例,就返回false,否则,比较x和y是否相等(调用super.equals方法)、和判断Color是否一样。

但是,考虑以下情况,

Point p = new Point(2, 5);
ColorPoint cp = new ColorPoint(2, 5, Color.red);

当我们比较p.equals(cp)时,会调用Point的equals方法,会将cp强制转换为Point类型,然后判断x与y是否分别一致,结果是true;
但是当比较cp.equals(p)时,由于p并不是ColorPoint的实例,所以会返回false。
所以这个equals方法违背了第二个约定,对称性。

我们可以修改代码,让equals方法接收Point类型,

    public boolean equals(Object obj) {
// TODO Auto-generated method stub
if (!(obj instanceof Point))
return false;
if (!(obj instanceof ColorPoint))
return obj.equals(this);
ColorPoint cp = (ColorPoint) obj;
return super.equals(obj) && cp.color.equals(color);
}

我们可以测试,p.equals(cp)和cp.equals(p)的结果都是true。但是这又带来了另一个问题,考虑如下实例,

        Point p = new Point(2, 5);
ColorPoint cp1 = new ColorPoint(2, 5, Color.red);
ColorPoint cp2 = new ColorPoint(2, 5, Color.blue);
System.out.println(p.equals(cp1));
System.out.println(p.equals(cp2));
System.out.println(cp1.equals(cp2));

我们可以得到p和p1相等,p和p2相等,由于传递性,我们可以得到p1和p2相等,但是结果却是false(不相等)。这是,由于,在进行Point和ColorPoint比较的时候,会牺牲掉ColorPoint的属性,所以只要ColorPoint的坐标属性和Point的坐标属性一样,就判断为相等,这显然是不对的。

怎么解决呢?根据Java的一个设计原则:复合优于继承,我们可以不让ColorPoint继承Point,而是使用Point,代码如下,

public class ColorPoint {
private Point point;
private Color color;

public ColorPoint(int x, int y, Color color) {
point = new Point(x, y);
this.color = color;
}

public Point asPoint(){
return point;
}

public boolean equals(Object obj) {
// TODO Auto-generated method stub
if (!(obj instanceof ColorPoint))
return false;
ColorPoint cp = (ColorPoint) obj;
return cp.point.equals(point) && cp.color.equals(color);
}

public static void main(String[] args) {
Point p = new Point(2, 5);
ColorPoint cp1 = new ColorPoint(2, 5, Color.red);
ColorPoint cp2 = new ColorPoint(2, 5, Color.blue);
System.out.println(p.equals(cp1));
System.out.println(p.equals(cp2));
System.out.println(cp1.equals(cp2));
}
}

这样,代码就可以通过测试,符合传递性和对称性。

对于一致性和非空性,就不在多谈。较好理解。
本篇博客就写到这里,下篇介绍改写hashCode的通用约定。