ITEM 10: 重写equal方法时需要遵守规则

2019-04-21  本文已影响0人  rabbittttt

ITEM 10: OBEY THE GENERAL CONTRACT WHEN OVERRIDING EQUALS
  重写equal方法似乎很简单,但其中存在一些陷阱,可能导致严重后果。避免问题的最简单办法就是不要重写(废话……),此时每个实例仅与自己相等。如符合下列任何一项条件,便应这样做:
(1)类的每个实例本质上都是惟一的。对于表示活动实体而不是值的Thread之类的类,这是正确的。默认的equals实现行为是完全符合我们要求的。
(2)类不需要提供“逻辑相等性”测试。例如 java.util.regex.Pattern 可以选择重写equals 来检查两个 Pattern 实例是否表示完全相同的正则表达式,但是设计人员不认为用户需要或想要这个功能。在这些情况下,从Object继承的equals实现是理想的。
(3)超类已经重写了equals,并且超类行为适合于子类。例如,大多数 Set 实现从AbstractSet 继承它们的 equals 实现,从 AbstractList 继承 List 实现,从AbstractMap 继承 Map 实现。
(4)该类是私有的或包私有的,您可以确定它的equals方法永远不会被调用。但是如果你极度厌恶风险,你可以覆盖equals方法,以确保它不会被意外调用:

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

  那么什么时候应该重写 equals 呢?当一个类具有不同于默认实现的相等概念,并且超类还没有覆盖equals时,就需要重写。通常是值类的情况,值类是表示值的类,例如 Integer 或 String。使用equals方法对比两个引用,程序员希望知道的是两个对象在逻辑上是否相等,而不是它们是否引用相同的对象。重写 equals 方法不仅是满足程序员期望所必需的,它还允许实例充当映射键或集合元素,具有可预测的、理想的行为。
  一种不需要重写equals方法的值类是使用实例控件(Item 1)来确保每个值最多存在一个对象的类。枚举类型(项目34)属于此类别。对于这些类,逻辑等式与对象标识相同,因此对象的 equals 方法作为逻辑 equals 方法发挥作用。
  当您覆盖 equals 方法时,必须遵守它的通用契约,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 = new CaseInsensitiveString("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 返回什么?谁知道呢!在当前的OpenJDK实现中,它碰巧返回false,但这只是一个实现构件。在另一个实现中,它可以很容易地返回true或抛出运行时异常。一旦违反了equals契约,您就不知道其他对象在面对您的对象时会如何表现。
  要消除这个问题,只需从equals方法中删除对String进行互操作的错误尝试。一旦你这样做了,你可以重构方法成一个单一的返回语句:

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

(3)传递 Transitivity —— 等号契约的第三个要求是,如果一个对象等于第二个对象,第二个对象等于第三个对象,那么第一个对象必须等于第三个对象。不难想象无意中会违反了这个要求。考虑这样一种情况:一个子类向它的超类添加了一个新的值组件。换句话说,子类添加了一条影响equals比较的信息。让我们从一个简单的不可变二维整数点类开始:

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方法应该是什么样的呢?如果完全忽略它,实现将从Point继承,而在等号比较中忽略颜色信息。虽然这没有违反平等合同,但显然是不能接受的。假设你写了一个 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 和一个 ColorPoint:

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

  然后 p.equals(cp) = true,而cp.equals(p) = false。您可以尝试使用 ColorPoint 来修复这个问题。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) 将抛出一个堆栈溢出错误。
  那么解决方案是什么呢?事实证明,这是面向对象语言中等价关系的一个基本问题。除非您愿意放弃面向对象抽象的好处,否则无法在保留equals契约的同时扩展可实例化类并添加值组件。
  你可能听说过,你可以扩展一个实例化类,并添加一个值组件,同时保留equals契约,方法是使用getClass测试来代替equals方法中的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 子类的实例仍然是一个点,并且它仍然需要作为一个点运行,但是如果采用这种方法,它就不能这样做!假设我们想写一个方法来判断一个点是否在单位圆上。我们可以这样做:

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替换原则指出,类型的任何重要属性都应该适用于它的所有子类型,这样为该类型编写的任何方法都应该在它的子类型上同样有效[Liskov87]。这是我们前面声明的正式声明,即点的子类(例如对位点)仍然是一个点,并且必须充当一个点。但是假设我们向 onUnitCirclemethod 方法传入一个 CounterPoint 实例。如果 Point 类使用基于 getclass 的equals 方法,onUnitCircle 方法将返回 false,而不管对位实例的x和y坐标如何。
  这是因为大多数集合,包括 onUnitCircle 方法使用的 HashSet,都使用 equals 方法来测试是否包含该元素,并且没有任何 CounterPoint 实例等于任何Point 实例。但是,如果在Point上使用适当的基于instanceof的equals方法,那么在使用对位实例时,onUnitCircle方法可以正常工作。
  虽然没有令人满意的方法来扩展实例化类并添加值组件,但是有一个很好的解决方案:遵循第18条的建议,“优先选择组合而不是继承”。不要使用ColorPoint扩展点,而是给ColorPoint一个私有点字段和一个公共视图方法(第6项),该方法返回与该颜色点相同位置的点:


// 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。时间戳的equals 实现确实违反了对称性,如果在同一集合中使用时间戳和日期对象,或者以其他方式混合使用,则会导致不稳定的行为。
  如果时间戳和日期对象在同一个集合中使用,或者以其他方式混合使用。Timestamp类有一个免责声明,警告程序员不要混淆日期和时间戳。虽然只要将它们分开,就不会陷入麻烦,但是没有什么可以阻止您将它们混合在一起,并且产生的错误可能很难调试。Timestamp类的这种行为是错误的,不应该学习。
  注意,您可以向抽象类的子类添加值组件,此时不违反equals契约。这对于您按照第23项中的建议获得的类层次结构非常重要,“宁可选择类层次结构,也不要选择带标记的类”。例如,可以有一个没有值组件的抽象类形状、一个添加半径字段的子类圆形和一个添加长度和宽度字段的子类矩形。只要无法直接创建超类实例,前面所示的问题就不会发生。
  Consistency 一致性 —— equals契约的第四个要求是:如果两个对象是相等的,那么它们必须始终保持相等,除非其中一个(或两个)被修改。换句话说,可变对象可以在不同的时间等于不同的对象,而不可变对象不能。当您编写类时,请仔细考虑它是否应该是不可变的(第17项)。如果您认为应该这样做,请确保您的equals方法强制执行这样的限制:相等的对象保持相等,而不相等的对象始终保持不相等。
  无论类是否是不可变的,都不要编写依赖于不可靠资源的equals方法。如果违反了这个禁令,就很难满足一致性要求。例如,java.net.URL 的equals方法依赖于与url关联的主机的IP地址的比较。将主机名转换为IP地址可能需要网络访问,而且不能保证随着时间的推移会产生相同的结果。这可能会导致URL equals方法违反equals契约,并在实践中造成问题。URL的equals方法的行为是一个很大的错误,不应该被模仿。不幸的是,由于兼容性要求,它不能更改。为了避免这类问题,equals方法应该只对内存驻留对象执行确定性计算。
  Non-nullity ——它并没有正式名称,所以我冒昧地称之为“Non-nullity”。它的意思是所有对象必须不等于null。虽然很难想象在调用 o.equals(null) 时意外地返回true,但也不难想象意外地抛出 NullPointerException。一般合同禁止这样做。许多类都通过显式的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 检查。
综上所述,这里有一个高质量的实现参考规则:

// 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 
}

  以下是最后的几点注意事项:

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

  问题在于这个方法没有覆盖 Object.equals ,但会重载它。提供这样一个“强类型” equals 方法是不可接受的,即使是在常规方法之外,因为它会导致子类中的覆盖注释生成假阳性,并提供错误的安全性。
始终如一地使用覆盖注释,如本项目中所示,将防止您犯此错误(第40项)。这个 equals 方法不能通过编译,错误提示会告诉你到底哪里出错了:

@Override 
public boolean equals(MyClass o) {
  ... 
}

  编写和测试 equals (和hashCode) 方法很单调,生成的代码也很普通。除了手动编写和测试这些方法之外,使用谷歌的开源 AutoValue 框架是一个很好的替代方法,它可以通过类上的一个注释自动为您生成这些方法。在大多数情况下,AutoValue 生成的方法本质上与您自己编写的方法相同。
  IDE 也有生成equals和hashCode方法的工具,但是生成的源代码比使用AutoValue的代码更冗长,可读性更差,不能自动跟踪类中的更改,因此需要进行测试。也就是说,让 IDE 生成 equals (和hashCode) 方法通常比手工实现方法更好,因为 IDE 不会犯粗心的错误,而人会犯错误。
  总之,除非迫不得已,否则不要覆盖 equals 方法:在许多情况下,从对象继承的实现可以做您想做的事情。如果您确实覆盖equals,请确保比较类的所有重要字段,并确保你的方法符合 equals 契约的所有五个条款。

上一篇下一篇

猜你喜欢

热点阅读