第10项:重写equals时请遵守通用约定
重写equals方法看起来似乎很简单,但是有许多重写方式会导致错误,而且后果非常严重。最容易避免这类问题的办法就是不覆盖equals方法,在这种情况下,类的每个实例都只能与它自身相等。如果满足了以下任何一个条件,那就是正确的做法:
-
类的每个实例都是唯一的。 对于代表活动实体而不是值(value)的类来说确实如此,例如Thread。Object提供的equals实现对这些类具有完全正确的行为(The equals implementation provided by Object has exactly the right behavior for these classes)。
-
不关心类是否提供了“逻辑相等(logical equality)”的测试功能。 例如,java.util.regex.Pattern可以重写equals检查两个Pattern实例是否表示完全相同的正则表达式,但设计者并不认为客户端需要或想要此功能。 在这种情况下,从Object继承的equals实现是理想的方式。
-
超类已经重写了equals,从超类继承过来的行为对于子类也是合适的。 例如,大多数的Set实现都从AbstractSet继承equals实现,List实现从AbstractList继承equals实现,Map实现从AbstractMap继承equals实现。
-
类是私有的或者是包级私有的,可以确定它的equals方法永远不会被调用。 如果你非常讨厌风险,你可以重写equals方法,从而确保它不会被意外调用:
@Override public boolean equals(Object o) { throw new AssertionError(); // Method is never called }
那么什么时候重写equals方法才是合适的呢?当一个类具有逻辑相等的概念时(不同于对象本身相同的概念),而超类还没有重写equals。 这通常是“值类(value class)”的情况。 值类指的是只表示值的类,例如Integer或String。程序猿在利用equals方法来比较对象的引用时,希望知道它们在逻辑上是否相等,而不是像了解它们是否引用了相同的对象。为了满足程序猿的需求,不仅必须重写equals方法,而且这样做也使得这个类的实例可以被用作映射表(map)的键(key),或者集合(set)的元素,使映射或者集合表现出预期的行为。
有一种“值类”不需要重写equals方法,即用实例受控(第1项)确保“每个值之多只存在一个对象”的类【单例模式】。枚举类型(第34项)就属于这种类。对于这样的类而言,逻辑相同与对象等同是一回事,因此Object的equals方法等同于逻辑意义上的equals方法。
当你重写equals方法的时候,你一定要遵守它的通用约定。下面是约定的内容,来自Object的规范:
- 自反性(Reflexive):对于任何非null的引用值x,x.equals(x)必须返回true。
- 对称性(Symmetric):对于任何非null的引用值x和y,当且仅当y.equals(x)返回true时,x.equals(y)必须返回true。
- 传递性(Transitive):对于任何非null的引用值x、y和z,如果x.equals(y)返回true,并且y.equals(z)也返回true,那么x.equals(z)也必须返回true。
- 一致性(Consistent):对于任何非null的引用值x和y,只要equals的比较操作在对象中所用的信息没有被修改,多次调用x.equals(y)就会一致返回true,或者一致返地返回false。
- 对于任何非null的引用值x,x.equals(null)必须返回false。
除非你对数学特别感兴趣,否则这些规定看起来可能有点让人感到恐惧,但是绝对不要忽视这些规定!如果你违反了它们,就会发现你的程序表现不正常,甚至崩溃,而且很难阻止失败的根源(and it can be very difficult to pin down the source of the failure)。引用John Donne的话说,没有哪个类是孤立的。一个类的实例通常会被频繁地传递给另一个类的实例。有许多类,包括所有的集合类(collections classes)在内,都依赖于传递给它们的对象是否遵守了equals约定。
现在你已经知道了违反equals约定有多么可怕,现在我们就来更细致地讨论这些约定。值得欣慰的是,这些约定虽然看起来很吓人,实际上并不复杂。一旦你理解了这些约定,要遵守它们并不困难。
那么什么是等价关系呢?大致来说,它是一个运算符,它将一组元素分成子集,这些子集的元素被认为是彼此相等的。这些子集称为等价类。 要使equals方法有用,每个等价类中的所有元素必须可以从用户的角度进行互换。现在我们依次检查一下5个要求:
自反性(Reflexivity)————第一个要求仅仅说明对象必须等于其自身。很难想象会无意识地违反这一条。假如违背了这一条,然后把该类的实例添加到集合(collection)中,该集合的contains方法将会告诉你,该集合不包含你刚才添加的实例。
对称性(Symmetry)————第二个要求是说,任何两个对象对于“他们是否相等”的问题都必须保持一致。与第一个要求不同,若无意中违反第一条,这种情形倒是不难想象。例如,考虑下面的类,它实现了一个区分大小写的字符串。字符串由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方法的意图非常好,它企图与普通的字符串(String)对象进行互操作。假设我们有一个不区分大小写的字符串和一个普通的字符串:
CaseInsensitiveString cis = new CaseInsensitiveString("Polish"); String s = "polish";
正如所料,cis.equals(s)
返回true。问题在于,虽然CaseInsensitiveString类中的equals方法知道普通的字符串(String)对象,但是String类中的equals方法却并不知道不区分大小写的字符串。因此,s.equals(cis)
返回false,显然违反了对称性。假设你把不区分大小写的字符串对象放到一个集合中:
L