对象共有的方法(第十条:当重写EQUALS的时候要遵守基本约定)

尽管Object是一个具体的类,但是它主要还是为了继承而设计出来的。所有的nonfinal的方法(equals , hashCode , toString , clone , and finalize)都有明确的一般性约束,因为它们设计出来就是为了被重写。任何重写这些方法的类都有责任去遵守它们的基本约束;不这样做的话,就会让联合使用它们的一些类(比如HashMap,HashSet)不能正常的工作。这一章会告诉你什么时候并且该怎样去重写nonfinal Object的方法。Finalize方法从这一节删除了,因为它在Item 8已经被讨论过了。还有不是Object的方法,Comparable.compareTo在这一章也被讲到了,因为它有相似的特性。


第十条:当重写EQUALS的时候要遵守基本约定

重写equals方法看起来很简单,但是有很多错的方式,而且它们导致的结果会很可怕。避免问题的最简单的方法是不要去重写equals方法,在这种情况下类的每个实例仅仅跟自身相等。如果下面的任意一种情况符合,那么这样就是正确的方法:

类的每个实例天生就是唯一的。对于一些代表存活的实例对象而不是它们的值的类,比如Thread,它的确是这样的。Object所提供的实现就给这种类提供了完全正确的行为。

给一个类提供一个“逻辑相等”的测试是没有必要的。比如,java.util.regex.Pattern本可以重写equals来检查两个Pattern实例代表的是完全相同的正则表达式,但是设计者认为客户端不需要也不想要这种功能。在这种情况下,从Object继承来的equals实现就是理想中的。

父类已经重写了equals,而且父类的行为也适用这个类。比如,大多数Set的实现里面的equals都是从AbstractSet继承来的,List从AbstractList,Map从AbstractMap。

类是私有的或者包是私有的,并且你确定它的equals方法永远都不会被调用。如果你是极端的冒险反对者,你可以重写equals方法来保证它没有被意外的调用:

@Override public boolean equals(Object o) {
        throw new AssertionError(); // Method is never called
    }

那么什么时候应该去重写equals呢?当一个类有逻辑相等的概念,并且父类还没有重写equals的时候。这种逻辑相等是不同于对象引用相等的。对于value的类通常就是这种情况。一个value的类简单来说就是一个代表值的类,比如Integer或者String。开发人员使用值对象的equals方法来比较它们的引用期望找出它们是不是逻辑上相等,而不是它们是不是指向相同的对象。重写equals方法不仅用来满足开发人员的预期,它使得keys或者元素是这些对象map的或者set能够表现出预期的,令人满意的行为。一种不需要重写equals方法被重写的value类,是使用实例控制(Item 1)来保证每一个值至多只有一个对象存在。枚举类型(Item 34)就落到这一类里面了。对这种类而言,逻辑相等跟对象身份相等是一样的,所以Object的equals方法就可以作为逻辑上的equals方法。

当你重写equals方法的时候,你必须坚守它的基本准则。这是从Object的说明文档里面引用的约定:

Equals方法去实现相等关系。它得有这些特征:

自反性:对于任意的非null的引用值x,x.equals(x)必须返回true。

对称性:对于任意的非null的引用值x和y,有且仅当y.equals(x)返回true,x.equals(y)也必须返回true。

传递性:对于任意的非null的引用值x,y,z,如果x.equals(y)返回true并且y.equals(z)返回true,那么x.equals(z)必须返回true。

一致性:对于任意的非null的引用值x和y,如果用于equals比较的信息没有被修改,那么多次调用x.equals(y)必须一致返回true或者一致返回false。

对于任意的非null的引用值x,x.equals(null)必须返回false。

除非你偏数学一点,否则这可能看起来有点吓人,但是不要忽略它。如果你违背它,你可能会发现你的程序表现的很怪异或者崩溃掉,而且它会使得找到失败的点变得艰难。引用John Donne的话,没有类是孤立的。一个类的实例频繁的被传递给另一个。很多类,包括所有的集合类,都依赖传递给它们的对象要遵守equals约定。既然你已经了然了违背equals约定的危险,那么让我们详细的过一下每个约定。好消息是,事实尽管如此,但是它真的不复杂。一旦你懂了,坚守约定就是小事一桩了。

那么什么是相等关系呢?笼统地讲,它是一种操作,这种操作将一个集合里面的元素分割为多个子集合,而这些子集合里面的元素应该相互相等。这些子集合就被视为具有相等关系的类。从用户的角度上讲,为了体现equals方法的用途,每个具有相等关系的类的元素都必须是可互换的。下面让我们来依次解释这五个要点:

自反性-第一点仅仅说了一个对象必须跟它自己相等。很难想象有人出于不小心去违背了这一规则(言下之意,都是故意的)。如果你违背了它,然后将这个类的实例加到了集合里面,那么contains方法就可能认为这个集合不包含你刚刚添加的实例。

对称性-第二点说任意两个对象必须对它们是否相等达成一致。不像第一点,它不难想象有人出于不小心违背了这一规则。比如,考虑下面实现了大小写不敏感的字符串。这种字符串的案例中,它是通过toString来保存的,但是toString却在equals比较的被忽略了:

// Broken - violates symmetry!
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 = newCaseInsensitiveString("Polish");
String s = "polish";

就像意料中的,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);
    }

传递性-equals约定的第三点说如果一个对象跟第二个对象相等,而且第二个对象跟第三个相等,那么第一个对象必须和第三个对象相等。同样地,也不难想象出于不小心违背了这一点。考虑子类在父类的基础上加了一个新的属性的情况。换句话讲,子类添加的信息影响了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
}

假设你想继承这个类,给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方法应该长什么样呢?如果你将它完全扔下不管,在equals的比较里面是实现就是继承于Point的而且颜色信息就被忽略了。尽管它没有违背equals的约定,但是显然它是不能接受的。假设你写了一个方法,仅仅当它的参数是另一个color point对象,并且这个对象比较对象有相同的坐标和颜色,才返回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跟一个color point比较你可能会得到一个结果,而你在用同一个color point和point作比较你可能得到另一个结果。前面的比较会忽略颜色,然而后面的比较总是返回false,因为参数的type是不正确的。

具体来说,让我们创建一个point还有一个color point:

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

然后p.equals(cp)返回true,但是cp.equals(p)返回false。你可能会试着让ColorPoint.equals在“混合比较中”忽略颜色来解决这个问题:

// Broken - violates transitivity!
    @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)返回成功,但是p1.equals(p3)返回false,明显违背了传递性。前两个比较是“色盲”,但是第三个却把颜色算进去了。

而且,这种方式可能会引起无限递归:假设有两个Point的子类,ColorPoint和SmellPoint,每一个都写了这种equals方法。那么myColorPoint.equals(mySmellPoint)这个调用就会抛出StackOverflowError。那么解决方法是怎样呢?它变为了面向对象语言中相等关系的一个基本问题。没有方法去继承一个可实例化的类,然后给子类添加一个新的部分,同时还要遵守equals约定,除非你愿意放弃面向对象抽象出的优点。

你可能停过你可以去继承一个可实例化的类,然后给子类添加一个新的部分,同时还遵守equals约定,在equals方法里面通过使用getClass的验证代替instanceof的验证:

// Broken - violates Liskov substitution principle (page 43)
    @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,但是如果你用这种方法,它就做不到这一点!让我们假设我们要写一个方法判断一个点在不在一个圆圈上。下面有一种我们可以做的方式:

// Initialize unitCircle to contain all Points on the unit circle
    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(); }
}

里氏替换原则说一个类型中的任何重要的属性都应该在它所有的子类中保持不变,目的是为了让给这个类型写的任何方法都能在它的子类上很好的工作。这就是我们之前所声明的那种形式,Point的子类(比如CounterPoint)仍然是一个Point,而且得表现为一个Point。但是我们假设将一个CounterPoint传递到onUnitCircle方法里面。如果Point类使用基于getClass的equals方法,无论CounterPoint实例的x and y匹配与否,onUnitCircle方法都会返回false。这样的结果是因为大部分集合,包括onUnitCircle方法用到的HashSet,都使用equals方法来测试是否包含,因此没有CounterPoint的实例是跟Point相等的。然而,如果在Point上使用合适的instanceof为基础的equals方法,当面对CounterPoint实例的时候,onUnitCircle方法就会正常工作。

尽管没有一个令人满意的方式既可以继承一个可实例化的类而且还要添加一个属性,但是有一个解决办法:采纳Item 18的建议,“比起继承更推崇组合。”我们不让ColorPoint继承Point,而是给ColorPoint一个私有的Point变量和一个public的试图view方法(Item 6),它可以返回这个color point在同一位置的point:

// 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和Date对象用于同一个集合或者以别的方式混用了,那么Timestamp的equals的实现着实会违背对称原则,并且会引起奇怪的行为。尽管只要你将它们分开使用就不会有麻烦,但是没有什么能阻止你不去混用它们,这样导致的错误是很难调试的。Timestamp的这种行为是错误的而且不应该被效仿。注意你可以给抽象类的子类添加一个属性,并且不会违背equals约定。这对于类的层级是很重要的。而这种层级是在Item 23中建议的,“比起特殊标记的类更推崇类的层级。”比如,你可以写一个没有属性的抽象类Shape,加一个添加了radius属性的子类Circle,外加一个添加了length和width属性的子类Rectangle。只要没办法直接创建一个父类实例,那么之前说的那种问题就不会发生。

一致性-equals约定里面第四点说,如果两个对象相等,那么他们只要不被修改,它们必须在一直相等。换句话说,可变对象可以在不同的时间跟不同的对象相等,然而不可变的对象不能。当你写一个类的时候,努力想想它是否应该是不变的(Item 17)。如果结论是不变的,确保你的equals方法强制相等的对象一直相等,不相等的对象一直不相等。

不管一个类是不是不可变的,都不要写依赖于不可靠的资源的equals方法。如果你打破了这个限制,那么你在想满足一致性就非常困难了。比如,java.net.URL 的 equals方法依赖于跟URLs相关的hosts的IP地址的比较。将一个域名转成一个IP地址需要网络访问,没有谁能保证随着时间的推移都返回相同的结果。这个会导致URL equals方法违背equals约定,而且它已经在实践中引起了问题。URL 的 equals方法的行为是一个巨大的错误,它不应该被效仿。不幸的是,由于兼容性的问题,它不能被改变。为了避免这种问题,equals方法应该只能做在内存对象上可以确定的比较。

不可空-最后一点没有官方名字,所以我就随意给它起名字叫它“non-nullity。”它规定所有的对象都跟null不相等。尽管很难想象调用o.equals(null)意外的返回了true,但是不小心抛出一个NullPointerException就很常见了。基本约定里面是禁止这一点的。很多类会在equals方法里写一个显示的验证来防止这一点:

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

这个测试是没有必要的。为了测试跟参数相等不相等,equals方法必须首先将参数转成合适的类型,这样它的访问方法或者属性就能被访问了。在做转化之前,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。因此如果传null进来,类型检查就会返回false,所以你不用去显示声明null检查。

把它们都总结起来,这有个高质量的equals方法诀窍:

1.       使用=来检查参数是不是当前对象的引用。如果是这样,返回true。如果比较有可能消耗较大,那么这样做是值得的,虽然它只是一个性能优化。

2.       使用instanceof操作来检查参数类型是不是正确。如果不相等,返回false。一般情况下,equal方法所在的类就是正确的类型。特殊情况下,是某个接口,这个类实现了这个接口。如果某个类实现了一个接口,这个接口制定了equals约定来约束接口实现类的相等关系,这个时候instanceof就要用接口类型了。一些集合接口比如Set,List,Map还有Map.Entry就有这种属性。

3.       将参数转成正确的类型。因为这个转化是在instanceof验证之后的,所以它保证是会成功的。

4.       将类中每一个“有意义的”字段,都去检查参数里面的那个字段是否跟当前对象中对应的字段相等。如果所有的检验都成功,那么返回true;否则返回false。如果在第二步得出的类型是接口的话,你必须通过接口方法来访问参数的字段;如果类型是一个类,那么就可以依照可访问性,直接访问字段。

对于不是float或者double的基本类型的字段来说,使用==操作符来做比较;对于引用类型的字段,逐个去调用equals方法;对于float字段,使用静态的Float.compare(float, float)方法;对于double字段,使用Double.compare(double,double)。需要将float和double字段特殊处理的原因是Float.NaN和-0.0f的存在,还有相似的double的值。看 JLS 15.21.1或者Float.equals文档去看具体细节。尽管你可以使用静态工厂方法Float.equals或者Double.equals来比较float和double字段,然而这会使得每次比较都会自动装箱,这会使性能很差。对于数组字段而言,使用这种准则来检测每一个元素。如果数组中的每个元素都是有意义的,使用Arrays.equals中的一个方法。一些对象引用字段可能允许包含null。为了避免NullPointerException发生的可能性,使用静态方法Objects.equals(Object, Object)来检查相等性。对于一些情况,比如上面的CaseInsensitiveString,字段比较比简单的相等检测更复杂。如果是这种情况,你可能要存储一个这个字段的标准形式(canonical form个人查到的解释:https://stackoverflow.com/questions/280107/what-does-the-term-canonical-form-or-canonical-representation-in-java-mean),这样的话equals就可以在标准格式上做一个轻便的确切的比较,而不是消耗更大的非标准的比较。这种技术最适合于不变的类(Item 17);如果对象可变,那么你必须保证这个标准格式一直是新的。

Equals方法的性能可能受要比较的字段的顺序而影响。为了追求最好性能,你应该比较最可能不一样的字段,或者比较起来消耗更小的字段,更理想的情况下是二者合二为一的情况。一定不能比较不是对象逻辑状态的字段,比如用来做synchronize操作的锁字段。你也不用去比较衍生字段,这种字段可以从“有意义的字段里面”算出来,可是做了的话可能会提升equals方法的性能。如果一个对象的衍生字段要合计整个对象的总结描述,如果这个字段的比较要失败的话,比较这个字段会节省比较真实数据的花销。比如,假设你有一个Polygon类,你把面积缓存下来。如果两个多边形的面积不相等,那么你就不必麻烦去比较它们的边和顶点。

当你写好了自己的equals方法后,问你自己三个问题:它是不是对称的?是不是可传递的?是不是一致的?而且不止是问;写单元测试去验证。除非你使用AutoValue产生equals方法,这种情况下,你就可以安全的省去这些测试了。如果哪一点不能满足,去找出原因,并且相应的修改equals方法。当然你的equals也必须满足另外两点(自反性和非null性),但是这两点一般不会出什么大问题。

根据上面的方法,在PhoneNumber的类中写了一个equals方法:

// Class with a typical equals method
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
}

下面是最后的一些警告:

当你要重写equals的时候,一定要重写hashCode(Item 11)。

不要尝试变得太聪明。如果你只是做简单的字段相等测试,要坚守equals约定还是不难的。如果你太过于寻求相等,那么很容易出问题。将任何形式的别名加以考虑通常不是一个好主意。比如,File的类不应该尝试去比较任何符号性的链接是不是指向同一文件。感恩的是,它没有这么做。

在equals声明里面,不要用其他类型来代替Object。对于一个程序员而言,写一个长这样的equals方法然后花半天来找为什么它不能正常工作不是不常见的:

// Broken - parameter type must be Object!
public boolean equals(MyClass o) {
...
}

问题在于这个方法没有重写Object.equals方法,Object.equals方法的参数类型是Object,事实上,它重载了它(Item 52)。除了正常的equals方法,再提供一个“强类型的”equals方法也是不可接受的,因为它会让子类重载的equals方法上产生Override标注,而调用这个方法可能会导致错误的结果,并且它会给你一种虚假的安全感。

正如这个条目里面所阐述的,要始终都是用Override标注就会防止你犯这种错误了(Item 40)。这种equals方法编译会报错,错误信息就会告诉你到底哪里不对了:

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

编写还有测试equals(还有hashCode)方法是很乏味的,并且对应的代码也很普通。手动去写和测试这些方法的好的另一种方式是使用谷歌的开源AutoValue框架,只要你在类上面加上一个标注,它就能自动替你生成这些方法。在大部分情况下,AutoValue生成的方法跟你自己写的在本质上都是一毛一样的。

IDEs也有生成equals还有hashCode方法的机制,比起使用AutoValue,IDE产生的源代码更加冗长并且可读性差。而且它不会自动检查类的改变,因此它是需要测试的。然而,使用IDE产生equals(还有hashCode)方法之后再去手动实现它们是一个好的选择,因为IDE不会犯不小心的错误,然而人会。

总之,不要去重写equals方法除非你不得不:在很多情况下从Object里面继承的实现就已经做了你想做的事情。如果你要重写equals,确保在遵守5个约定的条件下去比较类中所有有意义的属性。


  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值