尽管Object是一个具体类,但是设计它主要是为了扩展,它所有的非final方法(equals、hashCode、toString、clone、finalize)都有明确的通用约定,因为它们设计成是可覆盖的(override),任何一个类,它在覆盖这些方法的时候,都有责任遵守这些通用约定,如果不能做到这点,其他依赖于这些约定的类(例如:HashMap和HashSet)就无法结合该类一起正常运作。
覆盖equals方法看起来似乎很简单,但是有许多覆盖方式会导致错误,并且后果非常严重,最容易避免这类问题的办法就是不覆盖equals方法,在这种情况下,类的每个实例都只与它自身相等,如果满足以下任何一个条件,这就正是所期望的结果(就不用覆盖equals方法):
1.类的每个实例本质上是唯一的
:对于代表活动实体而不是值的类确实如此,例如Thread,Object提供的equals实现对于这些类来说正是正确的行为。
2.类没有必要提供“逻辑相等“的测试功能
。例如:java.util.regex.Pattern可以覆盖equals方法,已检查两个Pattern实例是否代表同一个正则表达式,但是设计者并不认为客户端需要或者期望这样的功能。在这类情况下,从Object继承的得到的equals实现就足够了。
3.超类已经覆盖了equals,超类的行为对于这个类也是合适的。
例如,大多数的Set实现都从AbstractSet继承equals实现,Map实现从AbsrtractMap继承equals实现。
4.类是私有的,或者是包级私有的,可以确保它的equals方法永远不会被调用
。如果非常想要避免风险,可以覆盖equals方法,并加以控制,以确保不会被意外调用
@Override
public boolean equals(Object o){
throw new AssertionError();
}
那么,什么时候应该覆盖equals方法呢?如果类具有自己特有的"逻辑相等"概念(不同于对象等同的概念),而且超类还没有覆盖equals方法。这通常属于值类的情形。值类仅仅是一个表示值的类,例如:Integer或者String,程序员在利用equals方法来比较值对象的引用时,希望知道它们在逻辑上是否相等,而不是像了解它们是否指向同一个对象
,不仅必须覆盖equals方法,而且这样做也使得这个类的实例可以被用作映射表(map)的键(key)或者集合(set)的元素,使映射或者集合表现出逾期的行为。
有一种"值类"不需要覆盖equals方法,即用实例受控确保"每个值最多只存在一个对象"的类,枚举类型就属于这种类,对于这样的类而言,逻辑相同与对象相同是一回事。
在覆盖equals方法的时候,必须遵守通用约定,下面是约定的内容,来自Object的规范:
自反性
:对于任何非null的引用值x,x.equals(x),必须返回true。
对称性
:对于任何非null的引用值x、y,当且仅当x.equals(y)返回true时,y.equals(x)也必须返回true。
传递性
:对于任何非null的引用值x、y、z,如果x.equals(y)返回true,y.equals(z)也返回true,那么x.equals(z)也必须返回true。
一致性
:对于任何非null引用值x、y,只要equals的比较操作在对象中所用的信息没有修改,多次调用x.equals(y)就会一致的返回true,或者一致的返回false。
非空性
:对于任何非null引用值x,x.equals(null)必须返false。
这些规定绝对不能忽略,如果违反了,就会发现程序将会表现不正常,甚至崩溃,而且很难找到失败的根源。一个类的实例通常会被频繁的传递给另一个类的实例,有许多类,包括所有的集合类在内,都依赖于传递给它们的对象是否遵守了equals约定。
那么什么是等价关系呢?不严格的说,它是一个操作符,将一组元素划分到其元素与另一个元素等价的分组中
。这些分组被称作等价类。从用户的角度看,对于有用的equals方法,每个等价类中的所有元素都必须是可交换的。现在我们按照顺序逐一查看以下5个要求:
自反性
:第一个要求仅仅说明对象必须等于其自身,假如违背了这一条,然后把该类的实例添加到集合中,该集合的contains方法将果断的告诉你,该集合不包含你刚刚添加的元素。
对称性
:第二个要求是说:任何两个对象对于"它们是否相等"的问题都必须保持一致,与第一个要求不同,若无意中违反了这一条,这种情形倒不难想象,例如下面的类,它实现了一个不区分大小写的字符串的equals方法。
public class CaseInsensitiveString {
private String str;
public CaseInsensitiveString(String str) {
this.str = str;
}
@Override
public boolean equals(Object obj) {
if (obj instanceof CaseInsensitiveString) {
CaseInsensitiveString caseString = (CaseInsensitiveString) obj;
return this.str.equalsIgnoreCase(caseString.str);
}
if (obj instanceof String) {
return this.str.equalsIgnoreCase((String) obj);
}
return false;
}
}
在这个类中,equals方法的意图非常好,它企图与普通的字符串对象进行互操作。假设我们有一个不区分大小写的字符串和一个普通的字符串。
CaseInsensitiveString cis = new CaseInsensitiveString("Java");
String s = "java";
不出所料,cis.equals(s)返回true,问题在于,虽然CaseInsensitiveString类中的equals方法知道普通的字符串对象,但是String类中的equals方法并不知道不区分大小写的字符串,因此,s.equals(cis)返回false,显然违反了对称性。假设你把不区分大小写的字符串对象放到一个集合中
List<CaseInsensitiveString> list = new ArrayList<>();
list.contains(s); // false
此时list.contains(s)会返回false,一旦违反了equals约定,当其他对象面对你的对象时,你完全不知道这些对象的行为会怎么样。
为了解决这个问题,只需把企图与String互操作的这段代码从equals中删除即可。
@Override
public boolean equals(Object obj) {
return obj instanceof CaseInsensitiveString &&
((CaseInsensitiveString) obj).str.equalsIgnoreCase(str);
}
传递性
:equals约定的第三个要求是,如果一个对象等于第二个对象,而第二个对象又等于第三个对象,则第三个对象一定等于第一个对象,同样的,无意识的违反这条规则的情形也不难想象。用子类举例:假设它将一个新的值组件添加到了超类中,换句话说,子类增加的信息会影响equals的比较结果,我们以一个简单的不可变的二位整数型Point类作为开始:
public class Point {
int x;
int y;
public Point(int x, int y) {
this.x = x;
this.y = y;
}
@Override
public boolean equals(Object obj) {
if (obj instanceof Point) {
Point point = (Point) obj;
return x == point.x && y == point.y;
}
return false;
}
}
假设想要扩展这个类,为一个点添加颜色信息:
public class ColorPoint extends Point {
private final Color color;
public ColorPoint(int x, int y, Color color) {
super(x, y);
this.color = color;
}
}
equals方法会是什么样呢?如果不完全提供equals方法,而是直接从Point继承过来,在equals作比较的时候,颜色信息就会被忽略。虽然这样做不会违反equals约定,但是很明显这是无法接收的。假设编写一个equals方法,只有当它的参数是另一个有色点并且具有相同的位置和颜色时,才返回true。
public class ColorPoint extends Point {
private final Color color;
public ColorPoint(int x, int y, Color color) {
super(x, y);
this.color = color;
}
@Override
public boolean equals(Object obj) {
if (!(obj instanceof ColorPoint)) {
return false;
}
ColorPoint colorPoint = (ColorPoint) obj;
return super.equals(obj) && color == colorPoint.color;
}
}
这个equals方法的问题在于,在比较普通点和有色点,以及相反的情形时,可能会得到不一样的结果。前一种比较会忽略颜色信息,而后一种比较总是返回false,因为参数类型不正确,为了直观的说明问题所在,我们创建一个普通点和一个有色点。
Point p1 = new Point(1, 2);
ColorPoint p2 = new ColorPoint(1, 2, Color.BLACK);
System.out.println(p1.equals(p2)); // true
System.out.println(p2.equals(p1)); // false
然而,p1.equals(p2)返回true,p2.equals(p1)返回false,也可以尝试修正这个问题,让ColorPoint.equals在进行"混合比较"式忽略颜色。
@Override
public boolean equals(Object obj) {
if (!(obj instanceof Point)) {
return false;
}
if (!(obj instanceof ColorPoint)) {
return obj.equals(this);
}
ColorPoint colorPoint = (ColorPoint) obj;
return super.equals(obj) && color == colorPoint.color;
}
这种方式虽然提供了对称性,但是却牺牲了传递性。例如:
ColorPoint p1 = new ColorPoint(1, 2, Color.BLACK);
Point p2 = new Point(1, 2);
ColorPoint p3 = new ColorPoint(1, 2, Color.RED);
System.out.println(p1.equals(p2)); // true
System.out.println(p2.equals(p3)); // true
System.out.println(p3.equals(p1)); // false
以上例子很明显这违反了传递性,前两种不需要比较颜色信息,而第三种比较则需要考虑颜色信息。
此外,这种方法还可能导致无线递归的问题,假设Point有两个子类,如ColorPoint和SmellPoint,它们各自都带有这种equals方法,那么ColorPoint.equals(SmellPoint)的调用就会出现递归,将会抛出StackOverflowError
异常:举个例子:
public class ColorPoint extends Point {
private final Color color;
public ColorPoint(int x, int y, Color color) {
super(x, y);
this.color = color;
}
@Override
public boolean equals(Object obj) {
if (!(obj instanceof Point)) {
return false;
}
if (!(obj instanceof ColorPoint)) {
return obj.equals(this);
}
ColorPoint colorPoint = (ColorPoint) obj;
return super.equals(obj) && color == colorPoint.color;
}
}
public class SmellPoint extends Point {
private final Color color;
public SmellPoint(int x, int y, Color color) {
super(x, y);
this.color = color;
}
@Override
public boolean equals(Object obj) {
if (!(obj instanceof Point)) {
return false;
}
if (!(obj instanceof SmellPoint)) {
return obj.equals(this);
}
return super.equals(obj) && ((SmellPoint) obj).color == this.color;
}
}
public class Main{
public static void main(String[] args) {
ColorPoint p1 = new ColorPoint(1, 2, Color.BLACK);
SmellPoint p2 = new SmellPoint(1, 2, Color.YELLOW);
System.out.println(p1.equals(p2));
}
}
console:
Exception in thread "main" java.lang.StackOverflowError
...
那么怎么解决呢?事实上,这是面向对象语言中关于等价关系的一个基本问题,我们*无法*在扩展可实例化的类的同时,即增加新的值组件,同时又保留equals约定
,除非愿意放弃面向对象的抽象所带来的优势。
你可能听说过,在equals方法中用getClass测试代替instanceof测试,可以扩展可实例化的类和增加新的值组件,同时保留约定。
@Override
public boolean equals(Object obj) {
if (obj == null || obj.getClass() != getClass()) {
return false;
}
Point point = (Point) obj;
return point.x == x && point.y == y;
}
这段程序只有当对象具有相同的实现类时,才能是对象相同,但是Point子类的实例任然是一个Point,他仍然需要发挥作用,但是如果采取这种方式,它就无法完成任务!
假设我们要编写这样一个方法,以检验某个点是否存处在单位园中,下面是可以采用的一种方式:
public class CounterPoint extends Point {
private static final AtomicInteger ATOMIC_INTEGER = new AtomicInteger();
public CounterPoint(int x, int y) {
super(x, y);
ATOMIC_INTEGER.incrementAndGet();
}
public static int numberCreated() {
return ATOMIC_INTEGER.get();
}
}
public class Main {
private static final Set<Point> unitCircle = new HashSet<>(Arrays.asList(new Point(1, 2)
, new Point(1, 0),
new Point(-1, 2)));
public static boolean onUnitCircle(Point p) {
return unitCircle.contains(p);
}
}
里氏替换原则认为,一个类型的任何重要属性也将适用于它的子类型,因此为该类型编写的任何方法,在它的子类型上也应该同样运行的很好。针对上述Point的子类(如CountPoint)仍是Point,并且必须发挥作用的例子,这就是它的正式语句。但是假设我们将CountPoint实例传给了onUnitCircle方法,如果Point类使用了基于getClass的equals方法,无论CountPoint实例的x和y值是什么,onUnitCircle都会返回false。这是因为像onUnitCircle方法所用的HashSet这样的集合,利用equals方法检验包含条件,没有任何CountPoint实例与任何Point对应。
CounterPoint counterPoint = new CounterPoint(1, 2);
CounterPoint counterPoint1 = new CounterPoint(1, 0);
CounterPoint counterPoint2 = new CounterPoint(-1, 2);
System.out.println(onUnitCircle(counterPoint)); // false
System.out.println(onUnitCircle(counterPoint1)); // false
System.out.println(onUnitCircle(counterPoint2)); // false
但是,如果在Point上使用基于instanceof的equals方法,当遇到CountPoint时,相同的onUnitCircle方法就会工作得很好。
虽然没有一种令人满意的方式可以即扩展可实例得类,又增加值组件,但是还有一种不错得权宜之计:遵从第18条"复用优先于继承"得建议。我们不再让ColorPoint扩展Point,而是在ColorPoint中加入一个私有Point域,以及一个公有的视图方法,此方法返回一个与该有色点处在相同位置的普通Point对象:
public class ColorPoint {
private final Point point;
private final Color color;
public ColorPoint(int x, int y, Color color) {
this.point = new Point(x, y);
this.color = color;
}
public Point asPoint() {
return point;
}
@Override
public boolean equals(Object obj) {
if (!(obj instanceof ColorPoint)) {
return false;
}
ColorPoint c = (ColorPoint) obj;
return c.point.equals(point) && c.color.equals(color);
}
}
注意,你可以在一个抽象类的子类中增加值组件且不违反equals约定,根据第23条的建议而得到的那种类层次结构来说,这一点非常重要。例如,你可能有一个抽象类Shape,它没有任何值组件,Circle子类添加了一个radius域,Rectangle子类添加了length和width域。只要不可能直接创建超类的实例
,前面所述的种种问题就都不会发生。就保证了传递性。
一致性
:equals约定的第四个要求是,如果两个对象相等,它们就必须始终相等,除非它们中有一个对象(或者两个都)被修改。可变对象在不同的时候可以与不同的对象相等
,而不可变对象则不会这样。当在编写一个类的时候,应该仔细考虑它是否应该是不可变的,如果认为它应该是不可变的,就必须保证equals方法满足这样的限制,相等的对象永远相等,不相等的对象永远不相等。
无论类是否不可变,都不要使equals方法依赖于不可靠的资源
。如果违反了这条
禁令,想要满足一致性的要求就非常困难。
非空性
:最后一个要求没有正式的名字,我们姑且称它为“非空性“,意思是指所有的对象都不能等于null,尽管很难想象在什么情况下o.equals(null)调用会意外的返回true,但是意外的抛出NullPointerException异常的情形却不难想象。通用约定允许抛出NullPointerException异常。许多类的equals方法都通过一个显式的null测试来防止这种情况;
@Override
public boolean equals(Object o){
if(o == null){
return false;
}
....
}
这项测试是不必要的,为了测试其参数的等同性,equals方法必须先把参数转换成适当的类型,以便可以调用它的访问方法,或者访问它的域
,在进行转换之前,equals方法必须使用instanceof操作符,检查其参数的类型是否正确。
@Override
public boolean equals(Object o){
if(!(o instanceof MyType)){
return false;
}
MyType type = (MyType) o;
....
}
如果漏掉了这一步类型检查,并且传递给equals方法参数又是错误的类型,那么equals方法将会抛出ClassCastException异常,这就违反了equals约定。但是,如果
instanceof的第一个操作数为null,那么,不管第二个是哪种类型,instanceof操作符都指定应该返回false,因此,如果把null传给equals方法,类型检查就会返回false,所以不需要显式的null检查。
结合所有要求,得出了以下实现高质量equals方法的诀窍:
1.使用==操作符检查”参数是否为这个对象的引用“
。如果是,则返回true,这只不过是一种性能优化,如果比较操作有可能很昂贵,就值得这么做。
2.使用instanceof操作符检查”参数是否为正确的类型“
,如果不是,则返回false。一般来说,所谓“正确的类型”是指equals方法所在的那个类。某些情况下,是指该类所实现的某个接口。如果类实现的接口进行了equals约定,允许在实现了该接口的类之间进行比较,那就使用接口,集合接口如Set,List,Map和Map.Entry具有这样的特性。
3.把参数转换正确的类型
,因为转换之前进行过instanceof测试,所以确保会成功。
4.对于该类的每个关键域,检查参数中的域是否与该对象中的对应的域相匹配
。如果这些测试全部成功,则返回true,否则返回false,如果第2步中的类型是个接口,就必须接口方法访问参数中的域,如果该类型是一个类,也许就能够直接访问参数中的域,这要取决与它们的可访问性。
对于既不是float也不是double类型的基本类型域,可以直接使用==操作符进行比较,对于对象域,可以递归调用equals方法,对于float域,可以使用静态Float.compare(float f1,float f2)方法;对于double域,则使用Double.compare(double d1,double d2)。对float和double域进行特殊的处理是有必要的,因为存在着Float.NaN、-0.0f以及类似的double常量;虽然可以用静态方法Float.equals和Double.equals对float和double域进行比较,但是每次比较都要进行自动装箱,这回导致性能下降,对于数组域,则要把以上这些原则应用到每一个元素,如果数组域中的每个元素都很重要,就可以使用其中一个Arrays.equals方法。
有些对象引用域包含null可能是合法的,所以,为了避免可能导致NullPointerException异常,则使用静态方法Object.equals(Object o1,Object o2)来检查这类域的等同性。
域的比较顺序可能会影响equals方法的性能,为了获得最佳的性能,应该最先比较最有可能不一致的域,或者开销低的域
,最理想的情况是两个条件同时满足的域,不应该比较那些不属于对象逻辑状态的域,例如用于同步操作的Lock域,也不需要比较衍生域,因为这些域可以由关键域计算获得
,但是这样做有可能提高equals方法的性能,如果衍生域代表了整个对象的综合描述,比较这个域可以节省在比较失败时去比较实际数据所需要的开销
,例如:假设有一个Polygon类,并缓存了该面积,如果两个多边形有着不同的面积,就没有必要再去比较它们的边和顶点。
在编写完equals方法之后,应该问自己三个问题:它是否对称性?它是否传递性?它是否一致性?
并且不要只是自问,还要编写单元测试来检验这些特性,除非用AutoValue(Goole开源的框架)生成equals方法,在这种情况下就可以放心的省略测试。如果答案是否定,就要找出原因,再相应的修改equals方法的代码逻辑。equals方法也必须满足其他两个特征(自反性和非空性),当然,这两种特性通常会自动满足。
根据上面的诀窍构建equals方法的具体例子:
public final class Person {
private final String name;
private final int age;
public Person(String name, int age) {
this.name = name;
this.age = age;
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (!(o instanceof Person)) {
return false;
}
Person person = (Person) o;
return age == person.age && Objects.equals(name, person.name);
}
}
下面是最后一些告诫:
1.覆盖equals方法时总是要覆盖hashCode方法
2.不要企图让equals方法过于智能,如果只是简单的测试域中的值是否相等,则不难做的equals约定,如果项过度的的去寻求各种等价关系,则很容易陷入麻烦之中。把任何一种别名形式考虑到等价范围内,往往不是个好主意。例如,File类不应该试图把指向同一文件的符号链接当作相等对象来看待
。所幸File类没有这样做。
3.不要将equals声明中的Object对象替换为其他的类型。程序员编写出下面这样的equals方法并不鲜见,这会使得程序员花上好几个小时都搞不清楚为什么它不能正常工作。
public boolean equals(MyClass o){
....
}
这个方法并没有覆盖Object.equals方法,因为参数应该是Object类型,相反,它重载了Object.equals方法,在正常的equals方法的基础上,再提供一个强类型的equals方法,这样会导致子类中的Override注解产生错误。
@Override注解的用法一致,就如本条目中所示,可以防止犯这种错误,这和equals方法不能编译,错误信息会告诉你到底哪里出了问题。
总而言之,不要轻易的覆盖equals方法,除非特殊需求,在许多情况下,从Object处继承就够了。如果要覆盖equals方法,一定要比较这个类的所有关键域,并且检查覆盖的equals方法是否遵守equals合约的所有五个条款。