Effective Java 第三版——10. 重写equals方法时遵照通用约定

Tips
《Effective Java, Third Edition》一书英文版已经出版,这本书的第二版想必不少人都读过,号称Java四大名著之一,不过第二版2009年出版,到如今已经将近8年的时间,但随着Java 6,7,8,甚至9的发布,Java语言发生了深入的变化。
在这里第一时间翻译成中文版。供你们学习分享之用。java

Effective Java, Third Edition

10. 重写equals方法时遵照通用约定

虽然Object是一个具体的类,但它主要是为继承而设计的。它的全部非 final方法(equals、hashCode、toString、clone和finalize)都有清晰的通用约定( general contracts),由于它们被设计为被子类重写。任何类都有义务重写这些方法,以听从他们的通用约定;若是不这样作,将会阻止其余依赖于约定的类(例如HashMap和HashSet)与此类一块儿正常工做。程序员

本章论述什么时候以及如何重写Object类的非final的方法。这一章省略了finalize方法,由于它在条目 8中进行了讨论。Comparable.compareTo方法虽然不是Object中的方法,由于具备不少的类似性,因此也在这里讨论。正则表达式

重写equals方法看起来很简单,可是有不少方式会致使重写出错,其结果多是可怕的。避免此问题的最简单方法不是覆盖equals方法,在这种状况下,类的每一个实例只与自身相等。若是知足如下任一下条件,则说明是正确的作法:sql

  • 每一个类的实例都是固有惟一的。 对于像Thread这样表明活动实体而不是值的类来讲,这是正确的。 Object提供的equals实现对这些类彻底是正确的行为。
  • 类不须要提供一个“逻辑相等(logical equality)”的测试功能。例如java.util.regex.Pattern能够重写equals 方法检查两个是否表明彻底相同的正则表达式Pattern实例,可是设计者并不认为客户须要或但愿使用此功能。在这种状况下,从Object继承的equals实现是最合适的。
  • 父类已经重写了equals方法,则父类行为彻底适合于该子类。例如,大多数Set从AbstractSet继承了equals实现、List从AbstractList继承了equals实现,Map从AbstractMap的Map继承了equals实现。
  • 类是私有的或包级私有的,能够肯定它的equals方法永远不会被调用。若是你很是厌恶风险,能够重写equals方法,以确保不会被意外调用:
@Override public boolean equals(Object o) {
    throw new AssertionError(); // Method is never called
}

那何时须要重写 equals 方法呢?若是一个类包含一个逻辑相等( logical equality)的概念,此概念有别于对象标识(object identity),并且父类尚未重写过equals 方法。这一般用在值类( value classes)的状况。值类只是一个表示值的类,例如Integer或String类。程序员使用equals方法比较值对象的引用,指望发现它们在逻辑上是否相等,而不是引用相同的对象。重写 equals方法不只能够知足程序员的指望,它还支持重写过equals 的实例做为Map 的键(key),或者 Set 里的元素,以知足预期和指望的行为。数组

一种不须要equals方法重写的值类是使用实例控制(instance control)(条目 1)的类,以确保每一个值至多存在一个对象。 枚举类型(条目 34)属于这个类别。 对于这些类,逻辑相等与对象标识是同样的,因此Object的equals方法做用逻辑equals方法。缓存

当你重写equals方法时,必须遵照它的通用约定。Object的规范以下:
equals方法实现了一个等价关系(equivalence relation)。它有如下这些属性:
•自反性:对于任何非空引用x,x.equals(x)必须返回true。
•对称性:对于任何非空引用x和y,若是且仅当y.equals(x)返回true时x.equals(y)必须返回true。
•传递性:对于任何非空引用x、y、z,若是x.equals(y)返回true,y.equals(z)返回true,则x.equals(z)必须返回true。
•一致性:对于任何非空引用x和y,若是在equals比较中使用的信息没有修改,则x.equals(y)的屡次调用必须始终返回true或始终返回false。
•对于任何非空引用x,x.equals(null)必须返回false。安全

除非你喜欢数学,不然这看起来有点吓人,但不要忽略它!若是一旦违反了它,极可能会发现你的程序运行异常或崩溃,而且很难肯定失败的根源。套用约翰·多恩(John Donne)的说法,没有哪一个类是孤立存在的。一个类的实例经常被传递给另外一个类的实例。许多类,包括全部的集合类,都依赖于传递给它们遵照equals约定的对象。性能优化

既然已经意识到违反equals约定的危险,让咱们详细地讨论一下这个约定。好消息是,表面上看,这并非很复杂。一旦你理解了,就不难遵照这一约定。网络

那么什么是等价关系? 笼统地说,它是一个运算符,它将一组元素划分为彼此元素相等的子集。 这些子集被称为等价类(equivalence classes)。 为了使equals方法有用,每一个等价类中的全部元素必须从用户的角度来讲是能够互换(interchangeable)的。 如今让咱们依次看下这个五个要求:框架

自反性(Reflexivity)——第一个要求只是说一个对象必须与自身相等。 很难想象无心中违反了这个规定。 若是你违反了它,而后把类的实例添加到一个集合中,那么contains方法可能会说集合中没有包含刚添加的实例。

对称性(Symmetry)——第二个要求是,任何两个对象必须在是否相等的问题上达成一致。与第一个要求不一样的是,咱们不难想象在无心中违反了这一要求。例如,考虑下面的类,它实现了不区分大小写的字符串。字符串被toString保存,但在equals比较中被忽略:

import java.util.Objects;

public final class CaseInsensitiveString {
    private final String s;

    public CaseInsensitiveString(String s) {
        this.s = Objects.requireNonNull(s);
    }

    // Broken - violates symmetry!
    @Override
    public boolean equals(Object o) {
        if (o instanceof CaseInsensitiveString)
            return s.equalsIgnoreCase(
                    ((CaseInsensitiveString) o).s);
        if (o instanceof String)  // One-way interoperability!
            return s.equalsIgnoreCase((String) o);
        return false;
    }
    ...// Remainder omitted
}

上面类中的 equals 试图与正常的字符串进行操做,假设咱们有一个不区分大小写的字符串和一个正常的字符串:

CaseInsensitiveString cis = new CaseInsensitiveString("Polish");
String s = "polish”;

System.out.println(cis.equals(s)); // true
System.out.println(s.equals(cis)); // false

正如所料,cis.equals(s)返回true。 问题是,尽管CaseInsensitiveString类中的equals方法知道正常字符串,但String类中的equals方法却忽略了不区分大小写的字符串。 所以,s.equals(cis)返回false,明显违反对称性。 假设把一个不区分大小写的字符串放入一个集合中:

List<CaseInsensitiveString> list = new ArrayList<>();
list.add(cis);

list.contains(s)返回了什么?谁知道呢?在当前的OpenJDK实现中,它会返回false,但这只是一个实现构件。在另外一个实现中,它能够很容易地返回true或抛出运行时异常。一旦违反了equals约定,就不知道其余对象在面对你的对象时会如何表现了。

要消除这个问题,只需删除equals方法中与String类相互操做的恶意尝试。这样作以后,能够将该方法重构为单个返回语句:

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

传递性(Transitivity)——equals 约定的第三个要求是,若是第一个对象等于第二个对象,第二个对象等于第三个对象,那么第一个对象必须等于第三个对象。一样,也不难想象,无心中违反了这一要求。考虑子类的状况, 将新值组件( value component)添加到其父类中。换句话说,子类添加了一个信息,它影响了equals方法比较。让咱们从一个简单不可变的二维整数类型Point类开始:

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

    public 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;
    }

    ...  // Remainder omitted
}

假设想继承这个类,将表示颜色的Color类添加到Point类中:

public class ColorPoint extends Point {
    private final Color color;

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

    ...  // Remainder omitted
}

equals方法应该是什么样子?若是彻底忽略,则实现是从Point类上继承的,颜色信息在equals方法比较中被忽略。虽然这并不违反equals约定,但这显然是不可接受的。假设你写了一个equals方法,它只在它的参数是另外一个具备相同位置和颜色的ColorPoint实例时返回true:

// Broken - violates symmetry!
@Override public boolean equals(Object o) {
    if (!(o instanceof ColorPoint))
        return false;
    return super.equals(o) && ((ColorPoint) o).color == color;
}

当你比较Point对象和ColorPoint对象时,能够会获得不一样的结果,反之亦然。前者的比较忽略了颜色属性,然后者的比较会一直返回 false,由于参数的类型是错误的。为了让问题更加具体,咱们建立一个Point对象和ColorPoint对象:

Point p = new Point(1, 2);
ColorPoint cp = new ColorPoint(1, 2, Color.RED);

p.equals(cp)返回 true,可是 cp.equals(p)返回 false。你可能想使用ColorPoint.equals 经过混合比较的方式来解决这个问题。

@Override
public boolean equals(Object o) {
    if (!(o instanceof Point))
        return false;

    // If o is a normal Point, do a color-blind comparison
    if (!(o instanceof ColorPoint))
        return o.equals(this);

    // o is a ColorPoint; do a full comparison
    return super.equals(o) && ((ColorPoint) o).color == color;
}

这种方法确实提供了对称性,可是丧失了传递性:

ColorPoint p1 = new ColorPoint(1, 2, Color.RED);
Point p2 = new Point(1, 2);
ColorPoint p3 = new ColorPoint(1, 2, Color.BLUE);

如今,p1.equals(p2)p2.equals(p3) 返回了 true,可是p1.equals(p3)却返回了 false,很明显违背了传递性的要求。前两个比较都是不考虑颜色信息的,而第三个比较时却包含颜色信息。

此外,这种方法可能致使无限递归:假设有两个Point的子类,好比ColorPoint和SmellPoint,每一个都有这种equals方法。 而后调用myColorPoint.equals(mySmellPoint)将抛出一个StackOverflowError异常。

那么解决方案是什么? 事实证实,这是面向对象语言中关于等价关系的一个基本问题。 除非您愿意放弃面向对象抽象的好处,不然没法继承可实例化的类,并在保留 equals 约定的同时添加一个值组件。

你可能据说过,能够继承一个可实例化的类并添加一个值组件,同时经过在equals方法中使用一个getClass测试代替instanceof测试来保留equals约定:

@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;
}

只有当对象具备相同的实现类时,才会产生相同的效果。这看起来可能不是那么糟糕,可是结果是不可接受的:一个Point类子类的实例仍然是一个Point的实例,它仍然须要做为一个Point来运行,可是若是你采用这个方法,就会失败!假设咱们要写一个方法来判断一个Point 对象是否在unitCircle集合中。咱们能够这样作:

private static final Set<Point> unitCircle = Set.of(
        new Point( 1,  0), new Point( 0,  1),
        new Point(-1,  0), new Point( 0, -1));

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

虽然这可能不是实现功能的最快方法,但它能够正常工做。假设以一种不添加值组件的简单方式继承 Point 类,好比让它的构造方法跟踪记录建立了多少实例:

public class CounterPoint extends Point {
    private static final AtomicInteger counter =
            new AtomicInteger();

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

    public static int numberCreated() {
        return counter.get();
    }
}

里氏替代原则( Liskov substitution principle)指出,任何类型的重要属性都应该适用于全部的子类型,所以任何为这种类型编写的方法都应该在其子类上一样适用[Liskov87]。 这是咱们以前声明的一个正式陈述,即Point的子类(如CounterPoint)仍然是一个Point,必须做为一个Point类来看待。 可是,假设咱们将一个CounterPoint对象传递给onUnitCircle方法。 若是Point类使用基于getClass的equals方法,则不管CounterPoint实例的x和y坐标如何,onUnitCircle方法都将返回false。 这是由于大多数集合(包括onUnitCircle方法使用的HashSet)都使用equals方法来测试是否包含元素,而且CounterPoint实例并不等于任何Point实例。 可是,若是在Point上使用了适当的基于instanceof的equals方法,则在使用CounterPoint实例呈现时,一样的onUnitCircle方法能够正常工做。

虽然没有使人满意的方法来继承一个可实例化的类并添加一个值组件,可是有一个很好的变通方法:按照条目18的建议,“优先使用组合而不是继承”。取代继承Point类的ColorPoint类,能够在ColorPoint类中定义一个私有Point属性,和一个公共的试图(view)(条目6)方法,用来返回具备相同位置的ColorPoint对象。

// Adds a value component without violating the equals contract
public class ColorPoint {
    private final Point point;
    private final Color color;

    public ColorPoint(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 ColorPoint))
            return false;
        ColorPoint cp = (ColorPoint) o;
        return cp.point.equals(point) && cp.color.equals(color);
    }
    
    ...    // Remainder omitted
}

Java平台类库中有一些类能够继承可实例化的类并添加一个值组件。 例如,java.sql.Timestamp继承了java.util.Date并添加了一个nanoseconds字段。 Timestamp的等价equals确实违反了对称性,而且若是Timestamp和Date对象在同一个集合中使用,或者以其余方式混合使用,则可能致使不稳定的行为。 Timestamp类有一个免责声明,告诫程序员不要混用Timestamp和Date。 虽然只要将它们分开使用就不会遇到麻烦,但没有什么能够阻止你将它们混合在一块儿,而且由此产生的错误可能很难调试。 Timestamp类的这种行为是一个错误,不该该被仿效。

你能够将值组件添加到抽象类的子类中,而不会违反equals约定。这对于经过遵循第23个条目中“优先考虑类层级(class hierarchies)来代替标记类(tagged classes)”中的建议而得到的类层级,是很是重要的。例如,能够有一个没有值组件的抽象类Shape,子类Circle有一个radius属性,另外一个子类Rectangle包含length和width属性 。 只要不直接建立父类实例,就不会出现前面所示的问题。

一致性——equals 约定的第四个要求是,若是两个对象是相等的,除非一个(或两个)对象被修改了, 那么它们必须始终保持相等。 换句话说,可变对象能够在不一样时期能够与不一样的对象相等,而不可变对象则不会。 当你写一个类时,要认真思考它是否应该设计为不可变的(条目 17)。 若是你认为应该这样作,那么确保你的equals方法强制执行这样的限制:相等的对象永远相等,不相等的对象永远都不会相等。

无论一个类是否是不可变的,都不要写一个依赖于不可靠资源的equals方法。 若是违反这一禁令,知足一致性要求是很是困难的。 例如,java.net.URL类中的equals方法依赖于与URL关联的主机的IP地址的比较。 将主机名转换为IP地址可能须要访问网络,而且不能保证随着时间的推移会产生相同的结果。 这可能会致使URL类的equals方法违反equals 约定,并在实践中形成问题。 URL类的equals方法的行为是一个很大的错误,不该该被效仿。 不幸的是,因为兼容性的要求,它不能改变。 为了不这种问题,equals方法应该只对内存驻留对象执行肯定性计算。

非空性(Non-nullity)——最后equals 约定的要求没有官方的名称,因此我冒昧地称之为“非空性”。意思是说说全部的对象都必须不等于 null。虽然很难想象在调用 o.equals(null)的响应中意外地返回true,但不难想象不当心抛出NullPointerException异常的状况。通用的约定禁止抛出这样的异常。许多类中的 equals方法都会明确阻止对象为null的状况:

@Override public boolean equals(Object o) {
    if (o == null)
        return false;
    ...
}

这个判断是没必要要的。 为了测试它的参数是否相等,equals方法必须首先将其参数转换为合适类型,以便调用访问器或容许访问的属性。 在执行类型转换以前,该方法必须使用instanceof运算符来检查其参数是不是正确的类型:

@Override public boolean equals(Object o) {
    if (!(o instanceof MyType))
        return false;
    MyType mt = (MyType) o;
    ...
}

若是此类型检查漏掉,而且equals方法传递了错误类型的参数,那么equals方法将抛出ClassCastException异常,这违反了equals约定。 可是,若是第一个操做数为 null,则指定instanceof运算符返回false,而无论第二个操做数中出现何种类型[JLS,15.20.2]。 所以,若是传入null,类型检查将返回false,所以不须要 明确的 null检查。

综合起来,如下是编写高质量equals方法的配方(recipe):

  1. 使用= =运算符检查参数是否为该对象的引用。若是是,返回true。这只是一种性能优化,可是若是这种比较可能很昂贵的话,那就值得去作。
  2. 使用instanceof运算符来检查参数是否具备正确的类型。 若是不是,则返回false。 一般,正确的类型是equals方法所在的那个类。 有时候,改类实现了一些接口。 若是类实现了一个接口,该接口能够改进 equals约定以容许实现接口的类进行比较,那么使用接口。 集合接口(如Set,List,Map和Map.Entry)具备此特性。
  3. 参数转换为正确的类型。由于转换操做在instanceof中已经处理过,因此它确定会成功。
  4. 对于类中的每一个“重要”的属性,请检查该参数属性是否与该对象对应的属性相匹配。若是全部这些测试成功,返回true,不然返回false。若是步骤2中的类型是一个接口,那么必须经过接口方法访问参数的属性;若是类型是类,则能够直接访问属性,这取决于属性的访问权限。

对于类型为非float或double的基本类型,使用= =运算符进行比较;对于对象引用属性,递归地调用equals方法;对于float 基本类型的属性,使用静态Float.compare(float, float)方法;对于double 基本类型的属性,使用Double.compare(double, double)方法。因为存在Float.NaN-0.0f和相似的double类型的值,因此须要对float和double属性进行特殊的处理;有关详细信息,请参阅JLS 15.21.1或Float.equals方法的详细文档。 虽然你可使用静态方法Float.equals和Double.equals方法对float和double基本类型的属性进行比较,这会致使每次比较时发生自动装箱,引起很是差的性能。 对于数组属性,将这些准则应用于每一个元素。 若是数组属性中的每一个元素都很重要,请使用其中一个重载的Arrays.equals方法。

某些对象引用的属性可能合法地包含null。 为避免出现NullPointerException异常,请使用静态方法 Objects.equals(Object, Object)检查这些属性是否相等。

对于一些类,例如上的CaseInsensitiveString类,属性比较相对于简单的相等性测试要复杂得多。在这种状况下,你想要保存属性的一个规范形式( canonical form),这样 equals 方法就能够基于这个规范形式去作开销很小的精确比较,来取代开销很大的非标准比较。这种方式其实最适合不可变类(条目 17)。一旦对象发生改变,必定要确保把对应的规范形式更新到最新。

equals方法的性能可能受到属性比较顺序的影响。 为了得到最佳性能,你应该首先比较最可能不一样的属性,开销比较小的属性,或者最好是二者都知足(derived fields)。 你不要比较不属于对象逻辑状态的属性,例如用于同步操做的lock 属性。 不须要比较能够从“重要属性”计算出来的派生属性,可是这样作能够提升equals方法的性能。 若是派生属性至关于对整个对象的摘要描述,比较这个属性将节省在比较失败时再去比较实际数据的开销。 例如,假设有一个Polygon类,并缓存该区域。 若是两个多边形的面积不相等,则没必要费心比较它们的边和顶点。

当你完成编写完equals方法时,问你本身三个问题:它是对称的吗?它是传递吗?它是一致的吗?除此而外,编写单元测试加以排查,除非使用AutoValue框架(第49页)来生成equals方法,在这种状况下能够安全地省略测试。若是持有的属性失败,找出缘由,并相应地修改equals方法。固然,equals方法也必须知足其余两个属性(自反性和非空性),但这两个属性一般都会知足。

在下面这个简单的PhoneNumber类中展现了根据以前的配方构建的equals方法:

public final class PhoneNumber {

    private final short areaCode, prefix, lineNum;

    public PhoneNumber(int areaCode, int prefix, int lineNum) {
        this.areaCode = rangeCheck(areaCode, 999, "area code");
        this.prefix = rangeCheck(prefix, 999, "prefix");
        this.lineNum = rangeCheck(lineNum, 9999, "line num");
    }

    private static short rangeCheck(int val, int max, String arg) {
        if (val < 0 || val > max)
            throw new IllegalArgumentException(arg + ": " + val);
        
        return (short) val;
    }

    @Override
    public boolean equals(Object o) {
        if (o == this)
            return true;
        if (!(o instanceof PhoneNumber))
            return false;

        PhoneNumber pn = (PhoneNumber) o;

        return pn.lineNum == lineNum && pn.prefix == prefix
                && pn.areaCode == areaCode;
    }

    ... // Remainder omitted
}

如下是一些最后提醒:

  1. 当重写equals方法时,同时也要重写hashCode方法(条目 11)。
  2. 不要让equals方法试图太聪明。若是只是简单地测试用于相等的属性,那么要遵照equals约定并不困难。若是你在寻找相等方面过于激进,那么很容易陷入麻烦。通常来讲,考虑到任何形式的别名一般是一个坏主意。例如,File类不该该试图将引用的符号连接等同于同一文件对象。幸亏 File 类并没这么作。
  3. 在equal 时方法声明中,不要将参数Object替换成其余类型。对于程序员来讲,编写一个看起来像这样的equals方法并很多见,而后花上几个小时苦苦思索为何它不能正常工做:
// Broken - parameter type must be Object!public boolean equals(MyClass o) {   
 …
}

问题在于这个方法并无重写Object.equals方法,它的参数是Object类型的,这样写只是重载了 equals 方法(Item 52)。 即便除了正常的方法以外,提供这种“强类型”的equals方法也是不可接受的,由于它可能会致使子类中的Override注解产生误报,提供不安全的错觉。
在这里,使用Override注解会阻止你犯这个错误(条目 40)。这个equals方法不会编译,错误消息会告诉你到底错在哪里:

// Still broken, but won’t compile
@Override public boolean equals(MyClass o) {
…
}

编写和测试equals(和hashCode)方法很繁琐,生的代码也很普通。替代手动编写和测试这些方法的优雅的手段是,使用谷歌AutoValue开源框架,该框架自动为你生成这些方法,只需在类上添加一个注解便可。在大多数状况下,AutoValue框架生成的方法与你本身编写的方法本质上是相同的。

不少 IDE(例如 Eclipse,NetBeans,IntelliJ IDEA 等)也有生成equals和hashCode方法的功能,可是生成的源代码比使用AutoValue框架的代码更冗长、可读性更差,不会自动跟踪类中的更改,所以须要进行测试。这就是说,使用IDE工具生成equals(和hashCode)方法一般比手动编写它们更可取,由于IDE工具不会犯粗枝大叶的错误,而人类则会。

总之,除非必须:在不少状况下,不要重写equals方法,从Object继承的实现彻底是你想要的。 若是你确实重写了equals 方法,那么必定要比较这个类的全部重要属性,而且以保护前面equals约定里五个规定的方式去比较。

相关文章
相关标签/搜索