Java equals方法判明

863 阅读7分钟

Object类中的equals方法用于检测一个对象是否等于另一个对象。默认情况下:如果两个对象引用相等,两个对象才相等。对于很多情况,这已经足够了,如:

  1. 类的每个实例本质上都是唯一的。

  2. 不关心类是否提供了“逻辑相等(logical equality)”的功能。

  3. 超类已经覆盖了equals,从超类继承过来的行为对于子类也是合适的。

但如果类具有自己特有的逻辑相等的概念,如判断String对象是否相等往往是判断字符串是否相同。当调用equals方法时,希望知道他们在逻辑上是否相等,而不是判断它们是否指向同一个对象,这时就必须覆盖equalshashCode方法。

编写equals方法时,必须遵守通用约定,JavaSE6规范了equals实现等价关系,即遵守以下五个性质:

  1. 自反性(reflexive)。对于任何非null的引用值x,x.equals(x) = true

  2. 对称性(symmetric)。对于任何非null的引用值x和y,当且仅当y.equals(x) = true时,x.equals(y) = true。

  3. 传递性(transitive)。对于任何非null的引用值x、y和z,如果x.equals(y) = true,并且y.equals(z) = true,那么x.equals(z) = true。

  4. 一致性(consistent)。对于任何非null的引用值x和y,只要equals的比较操作在对象中所用的信息没有被修改,多次调用x.equals(y)就会一致的返回true,或者一致的返回false。

  5. 对于任何非null的引用值x,x.equals(null) = false

实现模板:

public class A{
    private int val;
    private String name;

    @Override
    public boolean equals(Object otherObject){
        if(this == otherObject) return true;

        if(otherObject == null) return false;

        if(getClass() != otherObject.getClass()) return false;

        A other = (A) otherObject;

        return val == other.val
            && Objects.equals(name,other.name); 
    }
}

若使用instanceof,只需要将if(getClass() != otherObject.getClass()) return false;修改为 if(!(otherObject instanceof A)) return false;,同时不需要判断是否为空,因为如果instanceof函数的第一个操作数为null,会返回false。代码实现如下:

public class A {
    private int val;
    private String name;

    @Override
    public boolean equals(Object otherObject) {
        if (this == otherObject) return true;

        if (!(otherObject instanceof A)) return false;

        A other = (A) otherObject;

        return val == other.val
                && Objects.equals(name, other.name);
    }
}

instanceof方式允许受比较的对象属于子类对象;而getClass方式则要求两个参数必须是严格同一类才能比较,否则就返回false。下面分别从instanceof的角度和getClass的角度说明实际应用中二者的局限性和建议实现方式。

instanceof

instanceof方式可以用于子类之间的比较,但需要注意在父类与子类的比较很可能会违反equals所规定的原则,如我们定义类A和B,其中B继承A。

class A {
    private final String a;

    public A(String a) {
        this.a = a;
    }

    @Override
    public boolean equals(Object otherObject) {
        if (this == otherObject) return true;

        if (!(otherObject instanceof A)) return false;

        A other = (A) otherObject;

        return Objects.equals(a, other.a);
    }
}

class B extends A {
    private final String b;

    public B(String a, String b) {
        super(a);
        this.b = b;
    }

    @Override
    public boolean equals(Object otherObject) {
        if (this == otherObject) return true;

        if (!(otherObject instanceof B)) return false;

        B other = (B) otherObject;

        return super.equals(otherObject) && Objects.equals(b, other.b);
    }
}

这时,编写如下测试:

A a = new A("ss");
B b = new B("ss", "bb");
System.out.println(a.equals(b)); // true
System.out.println(b.equals(a));  // false

这是因为前一种比较忽略了字符串b,而后一种比较则总是返回false,因为参数的类型不正确。当然,你可以尝试在比较时查询受比较对象的类型,如果对象是父类类型则调用父类的equals方法,修改如下:

@Override
public boolean equals(Object otherObject) {
    if (this == otherObject) return true;

    if (!(otherObject instanceof A)) return false;
    
    if(!(otherObject instanceof B)) return otherObject.equals(this);

    B other = (B) otherObject;
    
    return super.equals(otherObject) && Objects.equals(b, other.b);
}

这样对称性将得到保证,但是传递性却无法保证了:

B b = new B("ss", "bb");
A a = new A("ss");
B b2 = new B("ss","cc");
System.out.println(b.equals(a)); // true
System.out.println(a.equals(b2));  // true
System.out.println(b.equals(b2));  // false

此处b.equals(a) = true, a.equals(b2) = true,但b.equals(b2) = false,显然传递性无法得到保证。

事实上,这时面向对象程序设计语言中关于等价关系的一个基本问题,即:

我们无法在扩展可实例化的类的同时,既增加新的值组件,又保证equals规定。

这时一些程序员就引入了getClass判断。

getClass

使用getClass方法判断就不会出现instanceof中子类继承父类,父类与子类之间或子类与子类之间进行判断相等的问题,因为它严格要求判断的对象与被判断的对象同属一个类,否则会返回false,这样它显然能满足equals方法的原则。

但是,《effective Java》中提出,这将违反里氏替换原则(Liskov substitution principle),即一个类型的任何重要属性也将适用于它的子类型,因此为该类型的编写的任何方法,在它的子类型上也应该同样运行的很好。一个典型的例子就是AbstractSet和其子类TreeSetHashSet,它们采用不同的算法查找集合元素,但我们肯定希望能够比较这两个集合,但如果采取getClass方法,TreeSetHashSet之间的元素自然不可能相等。

《Java核心技术 卷1》中指出,对于这个问题,解决方法是:应该在AbstractSet中使用instanceof定义equals方法,并将其定义为final类型,确保子类无法实现该方法,但事实上,Java源码中并未将该方法定义为final方法,此时,按照equals方法实现的原则,应该使用getClass方法,(认为是Java实现的一个问题)。

总结

因此,总结一下,目前有三种情形:

  1. 如果超类是抽象类型,那么在子类中新的值组件,采取instanceof方式检测是不会违反equals的约定的,因为我们无法创建一个抽象类型的实例;

  2. 如果非抽象类的子类可以定义自己的equals方法,那么对称性需求要求强制使用getClass方式检测,这样不可以在不同子类或子类与超类之间进行相等性比较;

  3. 如果子类由超类决定自己的相等性概念,那么可以在超类中使用instanceof方式检测,并使用final修饰,符合里氏替换原则,这样可以在不同子类或子类与超类之间进行相等性比较。

注意,对于第2种情形,《effective Java》的作者认为一种不错的权宜之计是采取聚合(复合)而非继承。这种方式本质上是取消了继承,也就不存在子类/超类了,自然也就不会出现问题,不过我认为这是一种逃避的做法,如果一定要使用继承,还是需要使用getClass方式进行验证。

编写一个完美的equals方法的建议(《Java核心技术 卷1》):

  1. 显式参数命名为otherObject,稍后需要将它强制转换成另一个名为other的变量;

  2. 检测this与otherObject是否相等;

  3. 检测otherObject是否为null,如果为null,返回false;

  4. 比较this与otherObject所在的类,如果equals的语义可以在非抽象类型的子类中变化,就必须使用getClass检测,如果equals的语义在抽象类型的子类中变化,可以使用instanceof检测,如果所有的子类(不管是抽象类还是非抽象类的子类)都具有相同的相等性判断,那么可以使用instanceof检测,且仅在超类中定义equals方法,声明为final表示不允许继承;

  5. 将otherObject强制转换为相应类类型的变量;

  6. 现在根据相等性概念的要求来比较字段,使用==比较基本类型字段,使用Objects.equals比较对象字段,如果在子类中重新定义equals,就要在其中包含一个super.equals(other)判断。

equals方法看似简单,但实际上其中却充满着陷阱,例如Java中的TimeStampDate类,TimeStamp继承Date类,且包含自己的equals实现,但Date类中的equals方法却采用instanceof方式进行检测,显然就导致了错误。