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 方法实现了一个等价关系。它具有以下性质:
- 自反 Reflexive :对于任何非空的参考值x, x.equals(x)必须返回true。
- 对称 Symmetric :对于任何非空的参考值x和y,当且仅当y.equals(x)返回true时,x.equals(y)必须返回true。
- 传递 Transitive :对于任何非空的参考值x, y, z,如果x.equals(y)返回true, y.equals(z)返回true,那么x.equals(z)必须返回true。
- 一致 Consistent:对于任何非空的参考值x和y,对x.equals(y)的多次调用必须始终返回true或false,前提是不修改equals比较中使用的信息。
- 对于任何非空的参考值x, x.equals(null)必须返回false。
你没有一些数学知识,上面的描述看起来可能有点可怕,但不要忽视它!如果您违反了它,您很可能会发现您的程序行为异常或崩溃,并且很难确定故障的根源。套用约翰•多恩(John Donne)的话,没有阶级是一座孤岛。一个类的实例经常被传递给另一个类。许多类,包括所有集合类,都依赖于按照equals契约传递给它们的对象。
既然您已经意识到了违反 equals 契约的危险,那么让我们详细检查一下契约。好消息是,尽管表面上看起来很复杂,但实际上并不复杂。一旦你理解了它,就不难坚持下去。那么什么是等价关系呢?简单地说,它是一个操作符,将一组元素划分为子集,这些子集的元素被认为彼此相等。这些子集称为等价类。要使equals方法有效,从用户的角度来看,每个等价类中的所有元素都必须是可互换的。现在让我们依次检查以下五个需求:
(1)自反 Reflexive —— 第一个条件只是说一个对象必须等于它自己。很难想象无意中违反了这一点。如果您违反了它,然后将类的实例添加到集合中,contains 方法很可能会说集合不包含您刚刚添加的实例。
(2)对称 Symmetric —— 第二个要求是任何两个对象必须就是否相等达成一致。与第一个要求不同,不难想象无意中违反了这个要求。例如,考虑下面的类,它实现了不区分大小写的字符串。字符串的大小写由toString保存,但在等号比较中忽略:
// 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 检查。
综上所述,这里有一个高质量的实现参考规则:
- 1 使用 “==” 操作符检查参数是否是对该对象的引用。如果是,返回true。这只是一种性能优化,但是如果比较可能比较昂贵,那么这是值得做的。
- 2 使用 instanceof 操作符检查参数是否具有正确的类型。如果不是返回 false。通常,正确的类型是方法所在的类。有时,它是由该类实现的一些接口。如果类实现了改进 equals 契约的接口,从而允许实现接口的类之间进行比较,则使用接口。集合接口,如Set、List、Map和 Map.Entry 是这样做的。
- 3 将参数转换为正确的类型。因为这个转换之前有一个test,所以它一定会成功。
- 4 对于类中的每个重要字段,检查参数的该字段是否匹配此对象的相应字段。如果所有这些测试都成功,返回 true;否则返回 false。如果步骤2中的类型是接口,则必须通过接口方法访问参数的字段;如果类型是类,则可以直接访问字段,这取决于字段的可访问性。
对于类型不是 float 或 double 的基本类型,使用 “==” 操作符进行比较;对于对象引用字段,递归调用 equals 方法;对于float字段,使用 staticFloat.compare(float, float) 方法;对于double,使用 double.compare(double, 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,字段比较比简单的等式测试更复杂。如果是这种情况,您可能希望存储字段的规范形式,以便equals方法可以对规范形式进行廉价的精确比较,而不是进行成本更高的非标准比较。这种技术最适合于不可变类(第17项);如果对象可以更改,则必须使规范形式保持最新。
equals方法的性能可能受到字段比较顺序的影响。为了获得最佳性能,您应该首先比较更可能不同、比较成本更低的字段,或者理想情况下两者都比较。您不能比较那些不是对象逻辑状态的字段,例如用于同步操作的锁字段。您不需要比较派生字段(可以从“重要字段”计算派生字段),不过这样做可能会提高equals方法的性能。如果派生字段相当于整个对象的摘要描述,那么如果比较失败,那么比较该字段会减少开销(相对于比较实际数据的)。例如,假设您有一个多边形类,并且缓存了该区域。如果两个多边形的面积不相等,你就不需要比较它们的边和顶点。
写完equals方法后,问自己三个问题:它是对称的吗?它是传递吗?它是一致的吗?不要只问自己,编写要检查的单元测试。除非使用 AutoValue 生成 equals 方法,在这种情况下,可以安全地忽略测试。如果上面的三个问题不成立,找出原因,并相应地修改 equals 方法。当然,equals方法还必须满足其他两个性质(自反性和非零度),但这两个性质通常会自行处理。
在这个简单的 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。
- 不要太聪明。如果您只是简单地测试字段是否相等,那么遵守 equals 契约并不难。如果你在寻找等价性时过于激进,就很容易陷入麻烦。考虑到任何形式的混叠通常都不是一个好主意。例如,File类不应该试图使引用同一文件的符号链接相等。值得庆幸的是,它不是。
- 不要在等号声明中替换对象的其他类型。程序员编写一个类似这样的 equals 方法,然后花上几个小时思考为什么它不能正常工作,这并不罕见:
// 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 契约的所有五个条款。
网友评论