Java中重载equals方法的大坑(OOP通用)

看《Effective Java》看到的,主要讲对Java equals的override。C++对==的重载也是一样的。下文就用Java举例子了,因为是OOP的通用问题,所以很容易类比到C++。

问题描述:

class A {
    int x;

    public A(int x) {
        this.x = x;
    }

    @Override
    boolean equals(Object o) {
        if (o instanceof A) {
            return x == (A)(o).x;
        } else {
            return false;
        }
    }
}

class B extends A {
    int y;

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

以上代码中B如果想Override equals使得y也能加入判断,应该怎么做?

也许最直接的答案是

    @Override
    boolean equals(Object o) {
        if (o instanceof B) {
            return x == (B)(o).x && y == (B)(o).y;
        } else {
            return false;
        }
    }

然而equals需要的是一种等价关系,需要满足三个条件:

  1. 自反性。即x.equals(x)为真。
  2. 对称性。即x.equals(y) == y.equals(x)
  3. 传递性。即x.equals(y) && y.equals(z),则x.equals(z)

上面这个做法明显对称性会崩盘。

A a = new A(0);
A b = new B(0, 0);
a.equals(b); //true
b.equals(a); //false

那如果加入对父类的特判呢?对称性是没问题的,但是传递性会崩。

A a = new B(0, 0);
A b = new A(0);
A c = new B(0, 1);
a.equals(b); //true
b.equals(c); //true
a.equals(c); //false

实际结论是B根本没有办法重载一个合理的equals。
证明:因为不能更改A类的equals,因此A的实例equals B的实例的时候一定会只判x然后返回true。那么根据对称性,B类的实例equals对应A类的实例的时候,也一定要返回true。所以如果想要在B的equals中判断y,上面传递性的问题一定会出现。至此对称性和传递性不可能同时满足,所以不可能存在科学的重载方法。

这个从本质来讲,父类和子类的相等是一种偏序关系,然而equals要的是等价关系,所以被父类强行equals为true以后,就怎么也救不回来了。

那么我们放松一下条件,允许修改父类呢?
从上面的教训可以知道如果子类和父类之间可以相等的话,一定会GG。那只能做成不相等了。
于是直接getClass判吧,直接看new的时候是什么的类,不同就直接判不等。那么假如A多了一个方法。

    private static final Set <a> luckyA = new HashSet<>()
    static {
        luckyA.add(new A(5));
        luckyA.add(new A(10));
    }

    public boolean isLucky(A a) {
        return luckyA.contains(a);
    }

这个isLucky写得再正常不过了,然而如果我们那么做,那这个isLucky对于所有子类就失效了。这违背了OOP的Liskov substitution principle
Wiki上的定义:Subtype Requirement: Let \phi (x) be a property provable about objects x of type T. Then \phi (y) should be true for objects y of type S where S is a subtype of T.

你也许会想,不加这个isLucky不就没有违反了吗?是的,但是只要A中有依赖A的equals的方法,就会违反这个规则。

然而你觉得因为子类的一个equals问题,给父类定下不能依赖自己的equals实现任何功能的规矩,合理吗?

综上所述,父类与子类equals可以为true是不行的;然而一律为false又会break OOP的原则。于是我们可以推导出即使允许同时修改父类和子类的equals,也没有任何合理的写法。这也是《Effective Java》给出的结论。

形式化描述一下这个结论:

如果父类是可实例化的(非abstract,非interface),子类如果添加了新的域想override equals把这个域放进去,没有任何一种override的方法是合理的。

那么该怎么做呢?答案:不要用继承,用组合。然后加一个fallback的方法。

class B {
    A a;
    int y;

    public asA() {
        return a;
    }
}

加入对话

2条评论

  1. 对的,最近写代码也是觉得继承是一个威力巨大的双刃剑。
    优势在于is a关系的明确,以及各个父类应用场景的方便复用。

    但是很多关键方法重载会带来无数的细节考虑,很容易搞错。

留下评论

您的邮箱地址不会被公开。 必填项已用 * 标注