概述
在java中,判断两个字符串是否相等时我们会使用equals进行判断,这是java帮我们设置好的判断逻辑;而我们自定义的类很多时候也需要我们判断两个实例对象是否相同,而判断它们是否相同可能并不需要判断每个字段都是否相等,这个时候我们就可以通过重写equals方法自定义逻辑。
特性
重写equals方法需要满足五个性质:自反性、对称性、传递性、一致性、非空判断。
相关的5个性质具体内容可以见名之意,并且网络上也有足够多的资料,在此不再赘述。
示例
想要遵守equals方法编写的上述5个性质其实并非困难,一个常规equals方法的写法如下。
class Employee{
.....
public final boolean equals(Object otherObject){
if(!(otherObject instanceof Employee))
return false;
Employee other = (Employee) otherObject;
return this.id == other.id;
}
}
五性分析
1.违反"自反性"的后果
你将对象添加入Set集合中后,使用contains方法将会找不到该对象。
2.违反"对称性"的情景
我们定义了一个大小写不敏感的类,其中包含一个实例字段s。
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;
}
}
在我们定义的equals方法中,包含了对被比较对象o分别为CaseInsensitiveString和String类型的两种不同情况的比较逻辑,这就极有可能导致对称性的违反。
因为当比较反过来时,String对象只有可能与一个String对象equals返回true,所以我们一定要极力避免这种根据类型不同来设定不同比较逻辑的情况。
修正的方法很简单,只要删除掉对String类型独立判断逻辑即可:
// Fixed equals method (Page 40)
@Override public boolean equals(Object o) {
return o instanceof CaseInsensitiveString &&
((CaseInsensitiveString) o).s.equalsIgnoreCase(s);
}
后果:如果不遵守对称性,将会导致的后果是在集合类中使用contains方法的不稳定性,即便你当时测试没有什么问题,但它可能换了一个运行环境或在其他什么情况出现完全相反的情况。
3.违反"传递性"的情景
在下面这个例子中,我们定义了一个Point对象存储一个坐标,其扩展类ColorPoint相比Point多了一个color实例。
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;
}
}
public class ColorPoint extends Point {
private final Color color;
public ColorPoint(int x, int y, Color color) {
super(x, y);
this.color = color;
}
// Broken - violates symmetry! (Page 41)
@Override public boolean equals(Object o) {
if (!(o instanceof ColorPoint))
return false;
return super.equals(o) && ((ColorPoint) o).color == color;
}
}
在上面这个情景下,一个Point对象与一个ColorPoint对象做比较将会根据坐标值判断equals,而忽略了color值;反过来,一个ColorPoint与一个Point对象比较则会根据类型直接返回false,这显然违反了前面的对称性:
public static void main(String[] args) {
// First equals function violates symmetry (Page 42)
Point p = new Point(1, 2);
ColorPoint cp = new ColorPoint(1, 2, Color.RED);
System.out.println(p.equals(cp) + " " + cp.equals(p));
}
true false
然后我们可以用一种方法修正这个问题:当ColorPoint对象与Point对象做equals比较时,忽略颜色信息,将其看做一个Point对象与其进行比较,以此满足对称性。
// Broken - violates transitivity! (page 42)
@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与一个Point做比较,再将这个Point与另一个ColorPoint做比较,这两个比较都会忽略颜色进行比较;但将这两个ColorPoint做比较时又会考虑到颜色属性,这样传递性就不满足了。
public static void main(String[] args) {
// Second equals function violates transitivity (Page 42)
ColorPoint p1 = new ColorPoint(1, 2, Color.RED);
Point p2 = new Point(1, 2);
ColorPoint p3 = new ColorPoint(1, 2, Color.BLUE);
System.out.printf("%s %s %s%n",
p1.equals(p2), p2.equals(p3), p1.equals(p3));
}
false true false
后果:1.这种形式的equals意表不明,不能确定什么时候比较颜色和不比较颜色。2.这种方式有可能导致无限递归的问题:假设现在还有一个有味道的坐标SmellPoint,它也写了一个类似的equals方法,那么ColorPoint与其做比较时,就会不断地互相调用对方的equals方法,导致无限递归,进而产生StackOverflowError错误异常。
这里引出了一个关于等价关系的一个基本问题:
我们无法在扩展可实例化的类的同时,既增加新的值组件,同时又保留equals约定。
这将在后文继续介绍。
4.违反"一致性"的情景
如果两个对象相等,他们就必须始终保持相等,除非它们中有一个或者两个都被修改了。
换句话说,可变对象可以在不同时候产生不同的相等性;而如果是不可变对象,那就必须保证相等的对象永远相等,不相等的对象永远不相等。
那么为了保证一致性,我们需要注意的是,一定不要使equals方法依赖于不可靠的资源,如果违反了这条禁令,你可能就根本无法获得可靠的equals返回值。
违反这条禁令的一个典型场景是java.net.URL中的equals方法,有兴趣的读者可以自行查阅相关资料,以下援引chatGPT的回答:
java.net.URL
中的equals
方法首先检查是否是同一个对象,如果是,返回true
。然后,它检查传入的对象是否也是URL
类型,如果是,它会调用URL的处理程序(handler)的equals
方法来比较两个URL。处理程序的equals
方法会执行DNS解析,以确保两个URL的主机名(hostname)是等价的。问题在于,DNS解析是一个网络操作,可能会引发不必要的网络请求和潜在的性能问题。此外,如果两个URL的主机名不一致,但它们实际上指向同一个IP地址,这个比较可能会返回
false
,尽管它们实际上是等价的。因此,使用
URL
类的equals
方法来进行URL相等性比较可能会导致一些不符合预期的行为。为了避免这些问题,通常建议使用自定义的逻辑来比较URL,或者使用java.net.URI
类,因为URI
类中的equals
方法处理URL相等性的方式更可靠。
5.有关"非空性"的判断
很难想到o.equals(null)调用会在什么情况下返回true,但是意外抛出空指针异常确是很常见的,而通常我们都希望避免这种情况的发生,所以许多类的equals方法都会显示的通过一个对null的判断来防止这种情况的发生:
@Override public boolean equals(Object o) {
if (o == null)
return false;
. . .
}
但事实上这项测试是没必要的,因为instanceof方法已经起到了这个作用:
public final boolean equals(Object otherObject){
if(!(otherObject instanceof Employee))
return false;
. . .
}
因为假如otherObject是null,那么它instanceof任何一个类,返回值都必定是false。
等价关系中的一个基本问题
在前文有关"传递性"的内容中我们发现,好像只要类将会扩展,并且有新的实例字段,就没法创造一个新的equals方法,这似乎一定会违反"传递性"。
这是面向对象语言中关于等价关系的一个基本问题:
我们无法在扩展可实例化的类的同时,既增加新的值组件,同时又保留equals约定;除非愿意放弃面向对象的抽象所带来的优势。
后半句是什么意思呢?我们引出一个例子来说明:你可能听说过在equals方法中使用getClass()方法进行测试,代替原来的instanceof测试,以达到扩展可实例化的类和增加新的值组件的目的,这确实可以保留equals的五性约定。
@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;
}
这个方法虽然没有违反equals的五性,但却违背了里氏替换原则:一个类型的任何重要属性也将适用于它的子类型,因此为该类编写的任何方法,在它的子类型上也应该同样运行得很好。
我们这样做,相当于是限制了Point只能与Point做equals比较,其任何派生类也只能跟完全相同的自身类型进行比较,比如ColorPoint;那如果我们想要对ColorPoint再派生一个拥有特定行为的ColorRunPoint类呢?
ColorRunPoint相比ColorPoint没有新的实例字段,只有新的行为run,这意味着新的类从现实含义来说,绝对有理由与ColorPoint做equals比较,但是由于ColorPoint已经使用getClass限制了其只能与ColorPoint做equals比较,因此它派生出的任何类——如ColorRunPoint,与其做equals比较都只会被无情的回应一个false。
我们放眼大局来看,你是不是就会感觉到,虽然ColorRunPoint是ColorPoint的派生类,但它们之间的联系似乎变得很弱,甚至并不像是派生/父子关系。
到这里你应该就能明白什么叫“除非愿意放弃面向对象的抽象所带来的优势”了。
权宜之计
既然等价关系中说"无法在扩展可实例化类的同时",那我们现在其实就有了一个新的思路——用复合代替继承,这种方法虽然不那么令人满意,但当下确是一种权宜之计。
我们不再让ColorPoint扩展Point,而是在ColorPoint中加入一个私有域Point,并且给定一个公有的视图方法使得可以获取ColorPoint中的Point对象:
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);
}
@Override public int hashCode() {
return 31 * point.hashCode() + color.hashCode();
}
}
注:你可以在一个抽象类的子类中增加新的值组件且不违反equals约定,只要不可能直接创建超类的实例,前面所述的种种问题就都不会发生。因为这些不论是“对称性”还是“传递性”的问题,都是建立在超类和子类之间的equals比较上,只要多加思考,便不难理解。
总结
结合上述这些要求,就得出了一下实现高质量equals方法的诀窍:
1.使用双等号判断两个对象的地址是否相同,如果完全是同一个对象就直接返回true,这只不过是一种性能优化,如果具体的比较操作比较“昂贵”,这就是值得的。
2.使用instanceof操作符检查参数类型的正确性,这里的正确性是指并不需要一定是完全类型相同的,比如说集合类中的上层接口类就可能定义了更为通用的equals约定,以用于对具体的实现类进行equals比较。
3.在第二步之后将参数转换为正确的类型。
4.对于该类中每个关键域进行匹配。
注:对关键域进行匹配时,1.对于既不是float也不是double类型的基本域,可以使用双等号进行判断,而对于float可以使用Float.compare(float, float)方法,double类型也类似;2.对于数组域,就需要把比较用于每一个元素上,如果数组域中每个元素都很重要,就可以使用Arrays.equals方法;3.有些对象引用域包含null可能也是合法的,这种情况下可以使用Object.equals(o1, o2)。
5.域的比较顺序可能会影响equals的性能,因此应该最先比较最有可能不一样的域,或者是优先比较开销最低的域,这取决于实际情况。
6.不要让equals方法过于智能,这不会是一个好主意。
7.不要将equals方法中的Object换成具体的类型。
注:这是因为我们希望覆盖/重写上层类(比如说Object类)中的equals方法,如果换成其他类型的话,那你就并没有重写这个方法,而是重载了,使得这个类拥有多个不同的equals方法,这绝对是无法接受的。@Override注释可以在编译时提醒你这个错误。
8.可以使用final关键字防止子类不必要的equals定义。比如说判断两个员工是否相同,只需要判断员工id是否相同,而不论工资是否相同,也不论是否是继承于Employee类的经理或者主管,这时Employee就可以定义一个final的equals方法,使得代码简化,并且防止了其子类不必要的equals定义。