如果在List中存放了一个对象,然后用contains方法判断该对象是否存在,contains方法返回了false,气不气!

时机

  • 类具有自己特有的“逻辑相等”概念(不同于对象等同的概念)
  • 超类没有覆盖equals以实现期望的行为

值类(value class):仅仅表示一个值的类,例如Integer。

程序员在利用equals方法来比较值对象的引用时,希望知道它们在逻辑上是否相当,而不在意是否指向同一个对象。

覆盖equal方法:可以满足程序员的要求,也可以把类的实例当作map的key,或者是set的元素。

单例类、枚举类型不需要覆盖,逻辑相同与对象等同是一回事。

约定

自反性reflexive

x != nullx.equals(x)true

对称性symmetric

1
2
3
4
x!=null,y!=null
if(y.equals(x)){
x.equals(y):true
}

传递性transitive

1
2
3
4
5
x!=null,y!=null,z!=null
if(x.equals(y)){
if(y.equals(z))
x.equals(z):true
}

一致性consistent

1
2
x!=null,y!=null
当equals比较时所用的信息没有被修改,多次调用x.equals(y),返回值相同

x != nullx.quals(null)false

场景

自反性

把类的实例添加到集合中,该集合的contains方法将返回false

对称性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class CaseInsensitiveString {
private String s;

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

@Override
public boolean equals(Object o) {
if (o instanceof CaseInsensitiveString) {
return s.equalsIgnoreCase(((CaseInsensitiveString) o).s);
}
if (o instanceof String) {
return s.equalsIgnoreCase((String) o);
}
return false;
}
}

String类中的equals方法不知道不区分大小写的字符串。如果将这两个对象都放在集合中,结果将不可预料,true? false? runtime exception?

需要将equals方法修改为下面的方式

1
2
3
4
5
@Override
public boolean equals(Object o) {
return o instanceof CaseInsensitiveString &&
((CaseInsensitiveString) o).s.equalsIgnoreCase(s);
}

传递性

使用子类时,超类中如果新添加了一个属性,将会影响equals结果

1
2
3
4
5
6
7
8
9
10
11
12
13
public class Point {
private int x;
private int y;

@Override
public boolean equals(Object o) {
if (!(o instanceof Point)) {
return false;
}
Point p = (Point) o;
return p.x == x && p.y == y;
}
}
1
2
3
4
5
6
7
8
public class ColorPoint extends Point {
private String color;

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

ColorPoint没有实现equal方法,比较时,颜色信息将被忽略,不是所期望的结果。

如果将ColorPoint中增加equals方法

1
2
3
4
5
6
7
8
9
10
public class ColorPoint extends Point {
@Override
public boolean equals(Object o) {
if (!(o instanceof ColorPoint)) {
return false;
}
Point p = (Point) o;
return super.equals(o) && ((ColorPoint) o).color == color;
}
}

比较Point和ColorPoint,以及相反的情形时,可能会得到不同的结果。

1
2
3
4
Point p = new Point(1, 2);
ColorPoint cp = new ColorPoint(1, 2, "red");
System.out.println(p.equals(cp));//true
System.out.println(cp.equals(p));//false

修改ColorPoint的equals方法,在进行父子类比较时,忽略颜色信息。

1
2
3
4
5
6
7
8
9
10
11
12
public class ColorPoint extends Point {
@Override
public boolean equals(Object o) {
if (!(o instanceof Point)) {
return false;
}
if (!(o instanceof ColorPoint)) {
return o.equals(this);
}
return super.equals(o) && ((ColorPoint) o).color == color;
}
}

ok,对称性有保证了,但是却牺牲了传递性。

1
2
3
4
5
6
7
8
public static void main(String[] args) {
ColorPoint p1 = new ColorPoint(1, 2, "red");
Point p2 = new Point(1, 2);
ColorPoint p3 = new ColorPoint(1, 2, "blue");
System.out.println(p1.equals(p2)); //true,不考虑颜色
System.out.println(p2.equals(p3)); //true,不考虑颜色
System.out.println(p1.equals(p3)); //false,考虑颜色
}

这是面向对象语言中关于等价关系的一个基本问题。无法在扩展可实例化的类的同时,即增加新的属性,同时又保留equals约定。

那尝试用getClass代替instanceof?可以扩展可实例化的类和增加新的属性,同时保留equals约定:

1
2
3
4
5
6
7
8
9
10
public class Point {   
@Override
public boolean equals(Object o) {
if (o == null || o.getClass() != getClass()) {
return false;
}
Point p = (Point) o;
return p.x == x && p.y == y;
}
}

只有当对象具有相同的实现时,才能使对象等同。但是实际情况如何呢?

假设要编写一个方法,检验某个整值点是否处于单位圆中。

1
2
3
4
5
6
7
8
9
10
11
12
13
private static final Set<Point> unitCircle;

static {
unitCircle = new HashSet<Point>();
unitCircle.add(new Point(1, 0));
unitCircle.add(new Point(0, 0));
unitCircle.add(new Point(-1, 0));
unitCircle.add(new Point(0, -1));
}

public static boolean onUnitCircle(Point p) {
return unitCircle.contains(p);
}

通过不添加属性的方式扩展Point,判断创建的实例个数。

1
2
3
4
5
6
7
8
9
10
11
12
13
public class CounterPoint extends Point {

private static final AtomicInteger COUNTER = new AtomicInteger();

public CounterPoint(int x, int y) {
super(x, y);
COUNTER.incrementAndGet();
}

public int numberCreated() {
return COUNTER.get();
}
}

假设讲CounterPoint的实例传给了onUnitCircle方法。如果Point类使用了基于getClass的equals方法,无论CounterPoint实例的x和y值是什么,onUnitCircle方法都会返回false。因为在onUnitCircle方法所用的HashSet这样的集合,是利用equals方法检验包含条件,没有任何CounterPoint实例与任何Point对应。但是,如果在Point上使用适当的基于instanceof的equals方法,当遇到CounterPoint时,相同的onUnitCircle方法就会工作得很好。

里氏替换原则Liskov subsitution principle:一个类型的任何重要属性也将适用于它的子类型,因此为该类型编写的任何方法,在它的子类型上上也应该同样运行得很好。

虽然没有一种令人满意的办法既可以扩展不可实例化的类,又增加属性,但有一种权宜之计:复合优先于继承。改造ColorPoint:取消扩展,增加Point属性。

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
public class ColorPoint extends Point {
private String color;
private Point point;

public ColorPoint(int x, int y, String color) {
if (Strings.isNullOrEmpty(color)) {
throw new NullPointerException();
}
Point point = new Point(x, y);
this.color = color;
}

public Point asPoint() {
return point;
}

@Override
public boolean equals(Object o) {
if (!(o instanceof ColorPoint)) {
return false;
}
ColorPoint cp = (ColorPoint) o;
return cp.point.equals(point) && cp.color.equals(color);
}
}

在JDK中,有一些类扩展了可实例化的类,并添加了新的属性。例如java.sql.Timestampjava.util.Date进行了扩展,并增加了nanoseconds属性。Timestampequals实现确实为了对称性,如果Timestamp和Date对象被用于同一个集合中,或者以其他方式被混合在一起,则会引起不正确的行为。Timestamp类有一个免责声明,告诫程序员不要混合使用Date和Timestamp对象。只要不混合使用,就不会有麻烦,除此之外没有其他的措施可以防止,导致的错误也很难调适。这种行为是错误的,不建议效仿。


用类层次代替标签类

可以在一个抽象类的子类中增加新的值组件,而不会违反equals约定。

1
2
3
public abstract class Shape {
abstract double area();
}
1
2
3
4
5
6
7
8
9
10
11
12
public class Circle extends Shape {
private double radius;

Circle(double radius) {
this.radius = radius;
}

@Override
double area() {
return Math.PI * radius;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Rectangle extends Shape {
private double width;
private double length;

Rectangle(double width, double length) {
this.width = width;
this.length = length;
}

@Override
double area() {
return width * length;
}
}

只要Shape类不能被实例化,则上述问题均不可能发生。

一致性

在写一个类的时候,应该仔细考虑它是否是不可变的。如果认为它应该是不可变的,就必须保证equals方法满足:相等的对象永远相等,不想等的对象永远不想等。

无论类是否可变,都不要使equals方法依赖于不可靠的资源

equals方法都应该对驻留在内存中的对象执行确定性的计算。

java.net.URLequals方法依赖于对URL中主机IP地址的比较。但IP是会变化的,会导致一些问题,但因为兼容性的考虑,equal方法无法被改变。

非空性

很难想象什么场景下o.equals(null)会返回true,但抛出PNE异常倒是有可能,通常约定equals方法不抛出NPE异常,很多类会进行一个显式的判断:

1
2
3
if(o == null){
return false;
}

其实这是没有必要的,在进行判断之前,会将参数转换为适当的类型,以调用它的访问方法或访问其属性。在转换之前,equals方法必须使用instanceof操作符,检查参数是否为正确的类型:

1
2
3
4
if (!(obj instanceof Rectangle)) {
return false;
}
Rectangle myType = (Rectangle) obj;

如果漏掉这步检查,参数又为错误的类型,则会抛出ClassCastException异常,这就违反了equals的约定。但是如果instanceof的第一个操作数为null,那么会返回指定的false

问题

输出是什么?

1
2
3
4
System.out.println(Float.NaN == Float.NaN);
System.out.println(Float.compare(Float.NaN, Float.NaN));
System.out.println(+0.0f == -0.0f);
System.out.println(Float.compare(+0.0f , -0.0f));
1
2
3
4
false
0
true
1

根据IEEE 754浮点“单一格式”位布局返回指定浮点值的表示形式,保留非数字(NaN)值。
位31(由掩码0x80000000选择的位)表示浮点数的符号。

位30-23(由掩码0x7f800000选择的位)表示指数。

位22-0(由掩码0x007fffff选择的位)表示浮点数的有效数(有时称为尾数)。
如果参数为正无穷大,则结果为0x7f800000。1111111100000000000000000000000
如果参数为负无穷大,则结果为0xff800000。

11111111100000000000000000000000

如果参数是NaN,则结果是表示实际NaN值的整数。

与floatToIntBits方法不同,floatToRawIntBits不会将编码NaN的所有位模式折叠为单个“规范”NaN值。
在所有情况下,结果都是一个整数,当赋予intBitsToFloat(int)方法时,它将生成一个与floatToRawIntBits的参数相同的浮点值。

诀窍

  • 使用==操作符检查”参数是否为这个对象的引用”。
  • 使用instanceof操作符检查”参数是否为正确类型”。
    • 一般情况下,正确类型为equals方法所在类
    • 如果是接口,允许在实现了该接口的类之间比较,那么久使用接口(比如Set、List)。
1
2
3
4
5
6
7
8
//java.util.AbstractList
public boolean equals(Object o) {
if (o == this)
return true;
if (!(o instanceof List))
return false;
...
}
  • 把参数转换为正确的类型(已经通过instanceof判断)
  • 对于该类中的每个关键属性,检查参数中的属性是否与该对象中对应的属性匹配。

    • 非float、非double的基本类型:==
    • 对象的引用属性:递归调用equals
    • float:Float.compare
      • 存在:Float.Nan、-0.0f、+0.0f
    • double:Double.compare
    • 数组
      • 按照上述原则逐个元素比较
      • Arrays.equals
    • 元素中含null
      • (field == null ? o.field == null : field.equals(o.field))
      • 如果两个field是相同的对象引用
        • (field == o.field || (field != null && field.equals(o.field)))
    • 定制化的类(例如忽略大小写)
      • 合理覆盖equals
  • 先比较最有可能不一致的属性,或者是开销最低的属性

  • 覆盖equals方法后,通过单元测试检查是否符合对称性、传递性、一致性、自反性和非空性

告诫

  • 覆盖equals时总要覆盖hashCode
  • 不要企图让equals方法过于智能
    • 不要过度的寻求各种等价关系,比如不应该把File类指向同一个文件的符号链接当作相等的对象来看待
  • 不要将equals方法的参数类型Object替换为其他类型
    • 替换为其他类型就没有重写equals方法而是重载,相当于在Object类型外提供一个强类型的equals方法,除非这两个方法返回结果是一致的。