重写equals时遵守规约-第三章通用的方法-Effective Java学习笔记09

文章内容来源于Joshua Bloch - Effective Java (3rd) - 2018.chm一书

第三章

通用的方法

尽管对象是一个具体的类,但它主要是为扩展而设计的

它的所有非final方法(equals、hashCode、toString、clone和finalize)都有显式的常规约定,因为它们被设计是为了被重写

重写这些方法的任何类都有责任遵守它们的常规约定;否则,依赖这些约定的其他类(如HashMap和HashSet)将无法与该类一起正常工作。

本章将告诉您何时以及如何重写非final对象方法

Item 10重新equals时遵守规约

重写equals方法看起来很简单,但是有很多情况会出错,结果是可怕的。
如果以下任何条件适用,则就不用重写的:

  • 类的每个实例本身都是唯一的
  • 类不需要提供“logical equality”测试
  • 一个父本类已经重写了equals,并且父本类的方法适合这个类
  • 类是私有的或包私有的,确定它的equals方法永远不会被调用
    可以用下面方法抛出异常保证不被调用
@Override public boolean equals(Object o) {
    throw new AssertionError(); // Method is never called
}

那么什么时候应该重写equals呢?

当一个类有一个不同于单纯的对象标识的逻辑相等的概念,并且父本类还没有重写equals时

这样类一般叫做Value Classes,值类只是表示值的类,例如整数或字符串。

一种不需要重写equals方法的值类是使用instance control来确保每个值最多存在一个对象的类。

枚举类型属于这一类。对于这些类,逻辑相等与对象标识相同,因此对象的equals方法作为逻辑相等方法起作用。

重写equals方法时,必须遵守其一般约定。以下是规则,来自对象规范:

对于任何非空参考值x、y与z

  • 自映 x.equals(x)必须返回true
  • 对称 y.equals(x)返回true时,x.equals(y)必须返回true
  • 可传递 如果x.equals(y)返回true,y.equals(z)返回true,那么x.equals(z)必须返回true
  • 一致性 x.equals(y)的多次调用必须一致返回true或一致返回false
  • x.equals(null)必须返回false

不能违反上述规则。

Symmetry
举例:

// 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
} 
CaseInsensitiveString cis = new CaseInsensitiveString("Polish");
String s = "polish"; 

正如所料,cis.equals(s) 返回true。问题是,虽然CaseInsensitiveString中的equals方法知道普通字符串,但String中的equals方法对不区分大小写的字符串却不敏感。因此,s.equals(cis)返回false,这明显违反了对称性。

一旦你违反了equals契约,你根本不知道其他对象在面对你的对象时会有什么样的行为。

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

Transitivity
举例

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
} 

假设您想扩展这个类,将颜色的概念添加到一个点上

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方法仅当其参数是另一个具有相同位置和颜色的色点时才返回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 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)返回true,而p1.equals(p3)返回false,这明显违反了传递性。前两个比较是“忽略颜色”,而第三个比较考虑了颜色

而且,这种方法可能会导致无限递归:假设Point有两个子类,比如ColorPoint 和SmellPoint,每一个子类都使用这种equals方法。然后一个调用myColorPoint.equals(mySmellPoint)将抛出StackOverflower错误。

那么解决办法是什么呢?这是面向对象语言中等价关系的一个基本问题。除非你愿意放弃面向对象抽象的好处,否则在保持equals契约的同时,不要扩展可实例化类并添加值组件
您可能听说,您可以扩展一个可实例化类并添加一个值组件,同时通过使用getClass测试来代替equals方法中的instanceof test来保留equals约定:

// 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(); }
}

里氏替换原则(Liskov Substitution Principle)说,类型的任何重要属性也应该适用于它的所有子类型,因此为该类型编写的任何方法都应该同样适用于它的子类型。这是我们先前主张的陈述,即点的子类(如CounterPoint)仍然是一个点,必须作为一个点。
但是假设我们传递一个CounterPoint 到onUnitCircle方法。如果Point类使用基于getClass的equals方法,那么onUnitCircle方法将返回false,而与CounterPoint 实例的x和y坐标无关。这是因为大多数集合(包括onUnitCircle方法使用的HashSet)都使用equals方法来测试包含性,并且没有CounterPoint 实例等于任何点。
但是,如果在点上使用适当的基于instanceof equals方法,则在使用CounterPoint 实例时,相同的onUnitCircle方法可以正常工作。
虽然没有令人满意的方法来扩展一个可实例类并添加一个值组件,但是有一个很好的解决方法:遵循第18项“支持组合而不是继承”的建议,而不是使用ColorPoint继承Point,为ColorPoint提供一个私有Point 字段和一个公共view 方法,该方法返回与此颜色点相同位置的点:

// 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类的这种行为是错误的,不应该被模仿。

请注意,可以向抽象类的子类添加值组件,而不会违反equals约定。这对于按照第23项“首选类层次结构而非标记类”中的建议获得的类层次结构的排序非常重要。例如,可以有一个没有值组件的抽象类Shape 、一个添加radius 字段的子类Circle 和一个添加length 和width 字段的子类Rectangle 。只要不可能直接创建超类实例,就不会出现前面所示的那种问题。

一致性 equals契约的第四个要求,如果两个对象相等,除非其中一个(或两个)被修改,否则它们必须一直保持相等。换句话说,可变对象可以在不同的时间等同于不同的对象,而不可变对象则不能。在编写类时,请仔细考虑它是否应该是不可变的(第17项)。如果您认为应该这样做,请确保equals方法强制执行相等对象始终保持相等和不相等对象始终保持不相等的限制。
无论类是否是不可变的,都不要编写依赖于不可靠资源的equals方法。如果你违反这项禁令,要达到一致性要求是极其困难的。例如,java.net.URL的equals方法依赖于与URL关联的主机的IP地址的比较。将主机名转换为IP地址可能需要网络访问,并且不能保证随着时间的推移会产生相同的结果。这会导致URL equals方法违反equals约定,并在实践中造成问题。URL的equals方法的行为是一个很大的错误,不应该被模仿。不幸的是,由于兼容性要求,它无法更改。为了避免这类问题,equals方法应该只对内存驻留对象执行确定性计算。
Non-nullity 它说所有的对象必须不能与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约定。但是instanceof运算符被指定为在第一个操作数为null时返回false,而不管第二个操作数中出现的类型是什么。因此,如果传入null,类型检查将返回false,因此不需要显式的null检查。

综上所述,这里有一个高质量equals方法的配方:
1使用== 运算符检查参数是否是对此对象的引用。如果是,则返回true。这只是一个性能优化,但是如果比较可能很昂贵,那么这是值得做的。
2使用instanceof运算符检查参数的类型是否正确。否则返回false。通常,正确的类型是方法所在的类。有时,它是由这个类实现的一些接口。如果类实现了一个接口,该接口细化了equals协定,以允许在实现该接口的类之间进行比较,请使用接口。集合接口,如Set、List、Map和地图输入拥有这个。
3将参数强制转换为正确的类型。因为此强制转换之前有一个instanceof test,所以它肯定会成功。
4对于类中的每个“重要”字段,检查参数的字段是否与该对象的相应字段匹配。如果所有这些测试都成功,则返回true;否则,返回false。如果步骤2中的类型是接口,则必须通过接口方法访问参数的字段;如果类型是类,则可以根据字段的可访问性直接访问字段。

对于类型不是float或double的基本字段,请使用
== 运算符进行比较;对于对象引用字段,请递归调用equals方法;对于float字段,请使用static Float.compare(float, float)方法;对于double 字段,使用Double.compare(double, double)。float 和double 的特殊处理是有必要的因为存在Float.NaN, -0.0f和类似于double 这样的值。而您可以将float和double字段用静态方法进行Float.equals and Double.equals,这将导致每次比较都自动装箱,这将导致较差的性能。对于数组字段,将这些准则应用于每个元素。如果数组字段中的每个元素都是重要的,请使用Arrays.equals 方法。

某些对象引用字段可能合法地包含null。为了避免出现NullPointerException,请使用静态方法检查这些字段是否相等Objects.equals(Object, Object)。
对于某些类,例如上面的CaseInsensitiveString,字段比较比简单的相等性测试更复杂。如果是这种情况,您可能需要存储字段的规范形式,这样equals方法就可以对规范形式进行廉价的精确比较,而不是代价更高的非标准比较。这种技术最适合于不可变类(第17项);如果对象可以更改,则必须使规范形式保持最新。
equals方法的性能可能受字段比较顺序的影响。为了获得最佳性能,您应该首先比较可能不同、比较成本较低的字段,或者理想情况下,两者都比较。不能比较不属于对象逻辑状态的字段,例如用于同步操作的锁定字段。您不需要比较派生字段(可以从“有效字段”计算),但是这样做可以提高equals方法的性能。如果派生字段相当于整个对象的摘要描述,则比较此字段将节省比较失败时比较实际数据的费用。例如,假设您有一个Polygon 类,并缓存该区域。如果两个多边形的面积不相等,则无需比较它们的边和顶点。
根据前面的配方构造的equals方法显示在这个简单的PhoneNumber类中:

// 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(第11项)时,始终重写hashCode。
•不要太聪明。如果您只是测试字段是否相等,那么遵守equals契约并不难。如果你在寻找等价物时过于激进,很容易陷入麻烦。一般来说,考虑任何形式的别名都不是一个好主意。例如,File类不应该试图将引用同一个文件的符号链接等同起来。谢天谢地,事实并非如此。
•不要用另一种类型替换equals声明中的Object。对于程序员来说,编写一个类似于这样的equals方法,然后花上几个小时来琢磨它为什么不能正常工作是很常见的:

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

问题是这个方法没有覆盖Object.equals对象,其参数的类型为Object,但代替的重载它(第52项)。提供这样一个“强类型”equals方法是不可接受的,甚至是在普通方法之外,因为它会导致子类中的重写注释生成错误,并提供错误的安全感。
如本条所示,一致使用Override 注解将防止您犯此错误(第40条)。此equals方法不会编译,错误消息将确切地告诉您错误:

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

编写和测试equals(和hashCode)方法是乏味的,并且生成的代码是平凡的。手动编写和测试这些方法的一个很好的替代方法是使用Google的开源AutoValue框架,该框架通过类上的一个注释自动为您生成这些方法。在大多数情况下,AutoValue生成的方法与您自己编写的方法基本相同。
ide也有生成equals和hashCode方法的工具,但是生成的源代码比使用AutoValue的代码更详细,可读性更低,不会自动跟踪类中的更改,因此需要测试。也就是说,让ide生成equals(和hashCode)方法通常比手动实现它们更可取,因为ide不会犯粗心的错误,而人类也会。

总之,不要重写equals方法
除非必须:在许多情况下,从Object继承的实现完全按照您的要求执行。如果重写equals,请确保比较类的所有有效字段,并以保留equals契约的所有五个规定的方式进行比较

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值