《Effective Java》学习笔记10 Obey the general contract when overriding equals

本栏是博主根据如题教材进行Java进阶时所记的笔记,包括对原著的概括、理解,教材代码的报错和运行情况。十分建议看过原著遇到费解地方再来参考或与博主讨论。致敬作者Joshua Bloch和各路翻译者们,以及为我提供可参考博文的博主们。

覆盖equals方法时应遵守通用规约

不需要覆盖equals的情景

equals方法是Object类自带的基本方法之一,也是一个非常常用的方法。我们覆盖它时应当十分谨慎,因为很可能导致严重后果。最容易的避免方式就是不要去覆盖它。不覆盖的情况下,equals方法默认每个实例只和它本身相等。如果满足了以下条件之一,那就直接继承而不要去重写它:

  1. 类的每个实例本质上唯一。比如{@link Thread}这种,所代表的不是值而是一个活动实体,应当使equals()方法保证能够区分它们。
  2. 无需为该类提供“逻辑相等”测试(简称“重写无意义”)。比如,虽然{@link java.util.regex.Pattern}可以重写equals来比较两个Patten匹配形式表达式是否相同,但感觉完全不会有什么地方会要求比对这个,所以这时候也完全无需重写这个方法
  3. 父类已经重写过这个方法,而这对子类仍然适用。比如大多数Set继承了父类{@link java.util.AbstractSet}重写后的equals方法,Map , List同理。
  4. 类的访问权限是private或者缺省(包私有)的,而且确定它的equals不会被调用(简称“没用”)。当然如果还是不放心,也可以重写一下比如这样:public boolean equals(Object o) {throw new AssertionError();}

需要覆盖equals的情景

那么什么时候需要覆盖它?那就是该类(可以称为“值类”value class)有独特的逻辑相等判断方式(不同于对象等同),而父类的equals又没办法满足这一需求的时候。这种情况通常发生在当类仅仅表示一个值的时候,比如Integer或者Date的比较,无需了解两者是否同一对象,而要知道其值是否等同。这时为满足需求,不仅需要覆盖equals()方法,还要保证该类的实例被用到map映射作“键”,或者在set集合中作为其中元素时符合逻辑,满足预期行为。

    *注意,有一种“值类”(value class)不需要覆盖equals方法,称为“实例受控类”(单例?)。这种类型已确保创建时,每个实例绝对唯一。比如枚举类型Enum就属于这种类型。对其而言,逻辑相等与对象相等实际上是等价的,于是也便无需重写equals()了。

覆盖时需遵守的通用规约

当确认需要覆盖它的时候,也必须遵守一定的通用规约,即在x,y不为null的前提下满足:

  1. 自反性。x.equals(x) == true;
  2. 对称性。x.equals(y) == y.equals(x);
  3. 传递性。x.equals(y) == true && y.equals(z) == true ,则 x.equals(z)必须为true;
  4. 一致性。x.equals(y)在x与y的值没有改变的情况下返回结果不会变;
  5. x.equals(null) == false 总是成立。

似乎这些公式看起来很头大,但是想传达的意思非常容易理解,学过一点离散数学更容易理解。不过要警惕,如果不小心违反了其中规定,那么你的麻烦就大了,因为实例之间有时会频繁通信、传递,而且包括{@link java.util.Collections}在内的所有集合类以及其他很多类,都需要依靠equals方法进行判断,而出现问题之后却不太容易联想到是equals的锅。

对通用规约的解释

 下面逐一解释上面5条通用规约。

1.自反性(reflexivity)

不是很容易违反的一条,调试时可以添加到collection中然后查看contains结果,以确定是否包含刚刚添加进去的类。

2.对称性(symmetry)

举一个大小写匹配是否相同导致违反对称性的CaseInsensitiveString和它的测试类的例子

/**忽略大小写的字符串类型,重写了equals导致违反对称性。*/
public class CaseInsensitiveString {

    private final String s;

    CaseInsensitiveString(String s) {
        this.s = Objects.requireNonNull(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;
    }

    boolean equals2(Object o) {
        return o instanceof CaseInsensitiveString &&
                ((CaseInsensitiveString) o).s.equalsIgnoreCase(s);
    }
}

 

它的equals方法设计初衷很好,希望可以跟String类型相互操作,但问题是{@link String#equals(Object)}是区分大小写的,所以下面测试类中的两个equals的结果是不同的。而且,如果把这种违反自反性的对象放到List里面,那么调用contains方法传入String类型的参数,返回结果也可能在不同环境下出现变化,在当前版本的JDK中,它恰好返回的是false,但在其他的实现中, true或者抛出异常都是有可能发生的。也就是说:

一旦违反了通用规约,就不能确定其他对象在面对你的对象时将做出什么反应

/**
 * 不小心违反了对称性的例子{@link CaseInsensitiveString}
 *
 * 若要遵守规约,修改也不麻烦,只要放弃与String类型的相互操作就好了,比如:
 * {@link CaseInsensitiveString#equals2(Object)} (由于想放在一起作对比,
 * 就没办法同名&加override标记了)
 */
public class AsymmetryExample {

    public static void main(String[] args) {
        String str = "String";
        CaseInsensitiveString cis = new CaseInsensitiveString("string");
        //return false
        System.out.println(str.equals(cis));
        //return true
        System.out.println(cis.equals(str));

        List<CaseInsensitiveString> list = new ArrayList<>();
        list.add(cis);
        //return false
        System.out.println(list.contains(str));
    }
}

3.传递性(transitivity)

无意识地违反这一条一般发生在子类继承中,因子类增加的信息会影响equals结果,比如:

/**
 * 一个演示子类影响equals结果的测试类
 *
 * {@link Point}仅有坐标属性,而当继承它的子类{@link ColorPointA}和{@link ColorPointB}添加颜色属性时,
 * 设计者企图使其equals方法能够与父类相比较,但是在子类A中,由于继承关系,mColorPointA.equals(mPoint)与
 * mPoint.equals(mColorPointA)的结果是不一样的,即违反了对称性;
 * 于是对其改进,有了子类B.子类B通过在equals方法中加入对父类的处理逻辑,满足了对称性,但由于不得不忽略颜色,
 * 导致违反了传递性
 *
 * 那么如何解决?实际上这是面向对象语言中等价关系的一个基本问题,我们无法在扩展可实例化的类的同时,
 * 既增加新的变量,又保留原有的equals约定,除非愿意放弃面向对象的抽象所带来的优势。
 *
 * @author LightDance
 */
public class NonTransitivityExample {

    public static void main(String[] args) {
        Point mPoint = new Point(1, 1);
        ColorPointA mColorPointA = new ColorPointA(1, 1, Color.RED);
        ColorPointB mColorPointB1 = new ColorPointB(1, 1, Color.RED);
        ColorPointB mColorPointB2 = new ColorPointB(1, 1, Color.BLUE);

        System.out.println("asymmetry:");
        System.out.println("mPoint:mColorPointA is:" + mPoint.equals(mColorPointA));
        System.out.println("mColorPointA:mPoint is:" + mColorPointA.equals(mPoint));

        System.out.println("symmetry but non-transitivity:");
        System.out.println("mPoint:mColorPointB1 is:" + mPoint.equals(mColorPointB1));
        System.out.println("mColorPointB1:mPoint is:" + mColorPointB1.equals(mPoint));
        System.out.println("mColorPointB2:mPoint is:" + mColorPointB2.equals(mPoint));
        System.out.println("mColorPointB1:mColorPointB2 is:" + mColorPointB1.equals(mColorPointB2));

    }
}

运行结果:

asymmetry:
mPoint:mColorPointA is:true
mColorPointA:mPoint is:false
symmetry but non-transitivity:
mPoint:mColorPointB1 is:true
mColorPointB1:mPoint is:true
mColorPointB2:mPoint is:true
mColorPointB1:mColorPointB2 is:false

三个Point类:

/**只有坐标的点*/
public class Point {
    private final int x;
    private final int y;

    Point(int x, int y) {
        this.x = x;
        this.y = y;
    }
    @Override
    public boolean equals(Object o) {
        if (!(o instanceof Point)) {
            return false;
        }
        Point p = (Point) o;
        return p.x == x && p.y == y;
    }
}
/**
 * 颜色+坐标的点,第一种重写equals的方式。
 * 由于继承关系,违反了自反性
 */
public class ColorPointA extends Point{
    private final Color color;

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

    /**违反了自反性*/
    @Override
    public boolean equals(Object o) {
        if (!(o instanceof ColorPointA)) {
            return false;
        }
        return super.equals(o) && ((ColorPointA) o).color == color;
    }
}
/**
 * 颜色+坐标的点,第二种重写equals的方式。
 * 改进后并不违反自反性,但是由于非要跟父类互相操作,颜色的忽略与不忽略导致违反了传递性
 */
public class ColorPointB extends Point{
    private final Color color;

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

    /**违反了自反性*/
    @Override
    public boolean equals(Object o) {
        if (!(o instanceof Point)) {
            return false;
        }

        //如果o是无颜色参数的点,比较时忽略颜色
        if (!(o instanceof ColorPointB)){
            return o.equals(this);
        }else {
            return super.equals(o) && ((ColorPointB) o).color == color;
        }
    }
}

我们无法在扩展可实例化的类的同时,既增加新的成员变量,又保留原有的equals约定,除非愿意放弃面向对象的抽象所带来的优势。

似乎用getClass代替instanceof能够通过防止子类“钻空子”而解决这些冲突,对此专门做一些解释:

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

    public Point(int x, int y) {
        this.x = x;
        this.y = y;
    }
    private static final Set<Point> unitCircle = new HashSet<Point>();
    static {
        unitCircle.add(new Point(1,0));
        unitCircle.add(new Point(-1,0));
        unitCircle.add(new Point(0,1));
        unitCircle.add(new Point(0,-1));
    }

    public static boolean onUnitCircle(Point p) {
        return unitCircle.contains(p);
    }
    @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;
    }

    public static void main(String[] args) {
        Point p1 = new Point(1, 1);
        Point p2 = new Point(   1,1);
        System.out.println(p1.equals(p2));
        System.out.println(p2.equals(p1));
    }
}

这个Point类与上面的Point的不同在于,它的equals方法中把instanceof换成了getClass.这个类只有在对象具有相同实现(类相同)时才能使对象等同,也就避免了子类“钻空子”导致违反传递性的情况,虽然似乎不是太糟糕,但请看这个例子:

我们希望添加一个能够检测某整值点是否处在单位圆上的方法如下{@link #onUnitCircle(Point)},然后又要通过继承为其添加一些新的成员变量,比如记录创建了多少个实例的计数器{@link CounterPoint}。假如把CounterPoint的实例传给{@link #onUnitCircle(Point)}方法,那么即使该实例满足在单位圆上的条件,结果也仍然会返回false,这违反了传说中的里氏置换原则

里氏置换原则:一个类的任何重要属性也应适用于它的子类。

由于Point使用了基于getClass的equals方法,而{@link #onUnitCircle(Point)}中的contains()检验是基于equals方法的,因此它总是返回false.父类Point的实例在该方法上完美运行,而继承它的子类CounterPoint的实例却不行,这显然和面向对象的继承关系有矛盾。

 

虽然没有办法能既扩展可实例化的类,在增加成员变量的同时保留原equals约定,但有一种比较好的权宜之计:

根据复合优先于继承,我们不再让ColorPoint继承Point,而是在新的ColorPoint类中加入私有的point域,以及一个公有的视图(view)方法

/**
 * 折中方法,不再让ColorPoint继承Point,而是在新的ColorPoint类中加入私有的point域,
 * 以及一个公有view方法{@link #asPoint()}。
 */
public class ColorPointC {
    private final Point point;
    private final Color color;

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

    /**
     * Returns the point-view of this color point.
     */
    public Point asPoint() {
        return point;
    }

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

这样,既能用view方法得到Point,并通过Point类的equals方法与Point类的实例比较;又能够直接用equals与本类型比较。这种方法在之后的其他条目中也会涉及到。

另外,Java的类库中有一些类扩展了可实例化的类,加入了新的成员变量。比如{@link java.sql.Timestamp}就扩展了{@link java.util.Date},甚至还违反了对称性。对此,Timestamp整了个免责声明,告诫程序员不要把Timestamp和Date混合使用,否则会引起不正确的行为,但除了建议以外,没有任何阻止该行为的措施,而且一旦出错将会很难调试,小朋友们千万不要学习它哦
 

在抽象类的子类中对父类进行扩展是不会违反equals通用规约的

对于“Prefer class hierarchies to tagged classes”这一条来说挺重要。比如先创建啥都没有的抽象Shape类,再用Circle去继承它并加入新的成员变量和成员方法,只要别直接创建超类的实例,上述问题就不会发生。

4.一致性(consistency)

想保证一致性,就别让equals方法依赖不可靠的资源。

比如{@link java.net.URL#equals(Object)}依赖于对URL中主机IP地址的比较,而将主机名转化成IP地址,可能需要访问网络。这随着时间推移,不能确保永远产生相同的结果,所以违反了equals规约。但遗憾的是,因为兼容性的要求,这一行为无法更改。为了避免这种问题,所有equals方法应仅仅对内存中的对象执行确定性计算。

5.非空性(not-nullity)

所有对象与null比较都应该返回false,因为如果返回true,那么很可能在之后的运行过程中抛出 {@link NullPointerException}.因此,很多类的equals方法中都会对null进行显式的if判断去识别它:

if(o == null)return false;

但实际上并没有这个必要,因为使用instanceof的时候已经可以检查是否为空了。

如果漏掉这一步类型检查,而传入的参数类型又有问题,就会抛{@link ClassCastException};

如果instance的第一个操作数为null,则无论第二个操作数是什么,都会返回false,就不需要单独对null的if判断了。

写出高质量equals方法的秘籍

综上,总结写出高质量equals方法的秘籍:

1.==操作符检查“是否为这个对象的引用”。是则返回true.这只是一种性能优化,如果比较的代价很高可以考虑这么做。

2.instanceof检查“参数类型是否正确”。正确指的是equals方法所在的那个类,或者该类所实现的某个接口。如果接口需要类实现经过改进的equals方法以允许跨类比较,那么就让接口作为第二个操作数。比如Set,Map,List这些,它们都实现了Collection接口,所以都应该这么干。

3.将参数强转为正确类型。由于之前的instanceof判断,所以这一步骤不会出现什么问题

4.对该类中的每个“关键”域,检查它们是否相等(比如之前{@link transitivity.Point}中对坐标值的比较)。如果步骤2中的第二操作数是接口,就必须通过接口方法访问参数的字段;如果是类,就可以根据类中这些参数的访问权限,直接或间接地访问这些字段。

  • 对于float或者double类型,需要使用{@link Float#compare(float, float)}或者{@link Double#compare(double, double)}进行比较,否则因浮点型精度问题,可能会有“相等值比较返回fasle”这样的bug出现;而且对float和double的特殊值特殊处理是有必要的,因为存在NaN,0.0f这种常量。float与double之间的比较可以用{@link Float#equals(Object)}或者{@link Double#equals(Object)},但是由于涉及到自动装箱,效率会相对较低。对于数组来说,需要确认对应的每个元素都相同,这时如果能保证每个元素都是有效的,那么可以考虑使用{@link java.lang.reflect.Array#equals(Object)}
  • 有些情况下,null是合法的,为了防止抛出空指针异常,可以用{@link Object#equals(Object)}检测是否相同,有另一些情况下,对象之间的比较或许非常复杂繁琐耗时,那么可以考虑把一些“范式”置为常量存储,到时候直接跟这些范式比较,会省时很多。这对于一些不可变类非常管用。因为一旦类发生改变,范式也要做出相应变化。
  • equals方法性能受字段比较的顺序影响,应将最有可能不一样的、比较起来容易的字段,将其比较的顺序提前。并且,不应该比较不属于对象逻辑状态域之外的字段,比如用于同步操作的Lock域;也不要比较可以由关键字段计算得出的冗余数据,因为计算方式不变时,特定参数应该对应相同的结果,不过如果这种结果能体现该类的整体特征,换句话讲就是比较完这一个参数就能确定其他十几个参数是否相等时,当然可以先比较这个参数。比如对两个多边形类进行比较时,如果面积area不同,那么两个多边形必然不相同

5.当编辑完equals方法之后,需要检查它是否满足自反、对称、一致三个特性(另外两个特性一般会自动满足),并且稍稍花些时间验证。

6.覆盖equals方法的同时也要覆盖hashCode(),说明见后面的文章(Always override hashCode when you override equals).

7.不要企图将equals方法搞得多么智能,并且不要把任何有关数据分析的语句放在其中,比如帮文件的符号链接自动找到对应文件然后进行比较。(关于两种文件的介绍可以百度一下以了解其区别)

8.不要将形参类型Object换成其他什么类型。换成其他类型后,其他程序员使用时或许会花很多时间去搞清它为什么写成这样,然后为什么出现异常。原因在于,改变成了其他类型之后,并没有覆盖掉父类的equals方法,而是重载了它,在原有equals 方法的基础上又提供了一个“强类型(strongly typed)”的equals方法,这可能会导致子类重写该方法时,不注意就重写了错误的方法,然后再不注意又调用了错误的方法,严重影响系统正常运行,拖慢开发速度。

为了防止这种问题,强烈建议不要手动输入方法声明,而是使用@Override的方式重写(在IDEA 里面快捷键是ctrl+O),可以有效避免这种失误。例如{@link #equals(Integer)},如果将@Override注解前的双斜杠注释符号去掉,这个方法编译时会报错然后编译失败。(Error:(118, 5) java: 方法不会覆盖或实现超类型的方法)

写这些equals方法可能比较枯燥,好在现在已经有很多像Google.AutoValue这样的开源框架自动生成equals方法 (以及hashCode),而且IDE(Integrated Development Environment集成开发环境)也有很多类似的插件,但使用起来还是Auto Value最方便,简洁、自动追踪并修改代码。所以,尽量使用IDE或者AutoValue自动生成这两种方法,这样可以避免很多粗心造成的错误。

全代码git地址:点我点我

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值