Effective Java 第三版读书笔记——条款10:重写 equals 方法时遵守通用的规定

重写 equals 方法看起来很简单,但是有很多方法会导致重写出错。避免此问题的最简单方法是不去重写 equals 方法,在这种情况下,类的每个实例只与自身相等。如果满足以下任一条件,则说明不去重写是正确的做法:

  • 每个类的实例都是固有唯一的。例如像 Thread 这样代表活动实体而不是值的类。Object 提供的 equals 实现对这些类来说是正确的。

  • 类不需要提供一个”逻辑相等(logical equality)”的测试功能。例如 java.util.regex.Pattern 可以重写 equals 方法检查两个 Pattern 实例是否代表完全相同的正则表达式,但是设计者并不认为客户需要此功能。在这种情况下,从 Object 继承的 equals 实现是最合适的。

  • 父类已经重写了 equals 方法,并且父类的行为完全适合于该子类。例如,大多数 Set 从 AbstractSet 继承了 equals 实现,List 从 AbstractLis t继承了 equals 实现,Map 从 AbstractMap 继承了 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 方法时,必须遵守它的通用规范。下面是 Java 源码中 Object 类注释里的规范:

equals 方法实现了一个等价关系(equivalence relation)。它有以下这些属性:
• 自反性:对于任何非空引用 x,x.equals(x) 必须返回 true。
• 对称性:对于任何非空引用 x 和 y,x.equals(y) 返回 true 当且仅当 y.equals(x) 返回 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。

一旦违反了它,你的程序很可能运行异常或崩溃,并且很难确定失败的根源。现在让我们依次看下这五个规定:

  • 自反性——第一个要求只是说一个对象必须与自身相等。 这个规定很难在无意中违反。

  • 对称性——第二个要求是任何两个对象必须在是否相等的问题上达成一致。考虑下面的类,它实现了不区分大小写的字符串。

    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,明显违反对称性。要消除这个问题,只需删除 equals 方法中与 String 类相互比较的错误尝试。这样做之后,可以将该方法重构为单个返回语句:

    @Override
    public boolean equals(Object o) {
        return o instanceof CaseInsensitiveString &&
                ((CaseInsensitiveString) o).s.equalsIgnoreCase(s);
    }
    
  • 传递性——第三个要求是:如果第一个对象等于第二个对象,第二个对象等于第三个对象,那么第一个对象必须等于第三个对象。考虑一个添加了新的值组件( 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 属性添加到子类中:

    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 p = new Point(1, 2);
    ColorPoint cp = new ColorPoint(1, 2, Color.RED);
    

    p.equals(cp) 返回 true,但是 cp.equals(p) 返回 false。

    你可能想通过混合比较的方式来解决这个问题:

    @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,很明显违背了传递性的要求。前两个比较都是不考虑颜色信息的,而第三个比较却包含颜色信息。

    那么解决方案是什么? 事实证明,这是面向对象语言中关于等价关系的一个基本问题。 除非您愿意放弃面向对象抽象的好处,否则无法继承可实例化的类,并在保证 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 来运行,但是如果你采用这个方法,就会失败。

    虽然没有令人满意的方法来继承一个可实例化的类并添加一个值组件,但是有一个很好的变通方法:按照条款 18 的建议:“优先使用组合而不是继承”。在 ColorPoint 类中定义一个私有的 Point 属性,和一个公开的视图(view)方法,用来返回具有相同位置的 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
    }
    

    你可以将值组件添加到抽象类的子类中,而不会违反 equals 约定。例如,可以有一个没有值组件的抽象类 Shape,子类 Circle 有一个 radius 属性,另一个子类 Rectangle 包含 length 和 width 属性。直接创建抽象父类的实例是不可能的,因此不会出现前面所示的问题。

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

    不管一个类是不是不可变的,都不要写一个依赖于不可靠资源的 equals 方法。如果违反这一禁令,满足一致性要求是非常困难的。例如,java.net.URL 类中的 equals 方法依赖于与 URL 关联的主机的 IP 地址的比较。将主机名转换为 IP 地址可能需要访问网络,并且不能保证随着时间的推移会产生相同的结果。这可能会导致 URL 类的 equals方法违反 equals 约定,并在实践中造成问题。URL 类的 equals 方法的行为是一个很大的错误,不应该被效仿。

  • 非空性——所有的非空对象都必须不等于 null。许多类中的 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;
        ...
    }
    

    如果第一个操作数为 null,则 instanceof 运算符返回 false,而不管第二个操作数是何种类型。因此,如果传入 null,类型检查将返回 false,所以不需要明确的 null 检查。

综合起来,以下是编写高质量 equals 方法的秘诀:

  • 使用 == 运算符检查参数是否为该对象的引用。如果是,返回true。这只是一种性能优化,但是如果完全比较代价很高的的话,这种方式就可以接受。
  • 使用 instanceof 运算符来检查参数是否具有正确的类型。 如果不是,则返回 false。 通常,正确的类型是 equals 方法所在的那个类。 有时候,该类实现了一些接口。如果类实现了一个接口——该接口改进了 equals 规定以允许实现该接口的类进行比较,那么使用该接口。 集合接口(如 Set,List,Map 和 Map.Entry)具有此特性。
  • 将参数转换为正确的类型。
  • 对于类中的每个“重要”的属性,请检查参数的属性是否与该对象对应的属性相等。如果所有这些测试都成功,返回 true,否则返回 false。如果步骤 2 中的类型是一个接口,那么必须通过接口方法访问参数的属性;如果类型是类,则可以直接访问属性,这取决于属性的访问权限。
  • 对于类型为非 float 或 double 的基本类型,使用 == 运算符进行比较;对于对象引用属性,递归地调用 equals 方法;对于 float 基本类型的属性,使用静态方法 Float.compare(float, float);对于 double 基本类型的属性,使用 Double.compare(double, double) 方法。由于存在 Float.NaN-0.0f 和类似的 double 类型的值,所以需要对 float 和 double 属性进行特殊的处理。 虽然你可以使用静态方法 Float.equalsDouble.equals 方法对 float 和 double 基本类型的属性进行比较,但这会导致每次比较时发生自动装箱,使性能下降很多。
  • equals 方法的性能可能受到属性比较顺序的影响。为了获得最佳性能,你应该首先比较最可能不同的属性和开销比较小的属性。不需要比较可以从“重要属性”计算出来的派生属性,但是这样做可以提高 equals 方法的性能。 如果派生属性相当于对整个对象的摘要描述,比较这个属性将节省时间。 例如,假设有一个Polygon类。 如果两个多边形的面积不相等,则不必费心比较它们的边和顶点。

在下面这个简单的 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
}

总之,能不去重写 equals 方法,就尽量不要去重写,从 Object 继承的实现或许正是你想要的。如果你确实重写了 equals 方法,那么一定要比较这个类的所有重要属性,并且要遵守 equals 规定里的五条规定。

猜你喜欢

转载自blog.csdn.net/sky_asd/article/details/84426517