《Effective Java》第10条:覆盖 equals 时请遵守通用规定
说明:
关于本博客使用的书籍,源代码Gitee仓库 和 其他的相关问题,请查看本专栏置顶文章:《Effective Java》第0条:写在前面,用一年时间来深度解读《Effective Java》这本书
正文:
在覆盖equals方法的时候,特别是自定义的类覆盖equals方法的时候,经常会导致错误,后果很严重。
最好的方式就是,能不覆盖equals方法的时候就不要去覆盖。
源码中,Object类中equals的默认实现是(==):
public boolean equals(Object obj) {return (this == obj);
}
我们知道,“==” 比较的是对象的地址,而不是值。
所以以下几种情况,我们不需要覆盖equals,直接使用默认实现即可:
情况1、原文P30:类的每个实例本质上都是唯一的。对于代表活动实体而不是值(value)的类来说确实如此,例如 Thread。Object提供的equals实现对于这些类来说正是正确的行为。
// Main类
Thread thread1 = new Thread();
Thread thread2 = new Thread();
System.out.println(thread1 == thread2); // false
System.out.println(thread1.equals(thread2)); // false
因为Thread的每个实例我们都认为他们是唯一的,使用equals比较他们的时候,也不应该是相等的。所以Thread类没有覆盖equals方法
情况2、原文P30:类没有必要提供“逻辑相等”的测试功能。例如,java.util.regex.Pattern可以覆盖equals,以检查两个Pattern实例是否代表同一个正则表达式,但是设计者并不认为客户需要或者期望这样的功能。
// Main类
Pattern pattern1 = Pattern.compile("[0-9]*");
Pattern pattern2 = Pattern.compile("[0-9]*");
System.out.println(pattern1 == pattern2); // false
System.out.println(pattern1.equals(pattern2)); // false
两个 Pattern实例进行比较,没有任何的意义,即使代表同一个表达式,这样的比较也是无意义的,所以Pattern类也没有去覆盖equals方法。
情况3、原文P31:超类已经覆盖了 equals,超类的行为对于这个类也是合适的。例如,大多数的Set实现都从AbstractSet继承equals实现,List实现从AbstractList继承equals实现,Map实现从AbstractMap继承equals实现
这里我们只看下AbstractMap实现的源码,因为实现比较复杂:
// Main类
// 测试 ①
Map<String, Object> map1 = new HashMap<>(8);
Map<String, Object> map2 = map1;
System.out.println(map1.equals(map2)); // true// 测试 ②
Set<String> set1 = new HashSet<>();
System.out.println(map1.equals(set1)); // false// 测试 ③
Map<String, Object> map3 = new HashMap<>(16);
System.out.println(map1.equals(map3)); // true 实际长度都是0
Map<String, Object> map4 = new HashMap<>(8);
map4.put("zs","123");
map4.put("ls","456");
System.out.println(map1.equals(map4)); // false map4的长度为2,map1的长度为0// 测试 ④
Map<String, Object> map5 = new HashMap<>(8);
map5.put("zs","123");
map5.put("ls","456");
System.out.println(map4.equals(map5)); // true map5和map4的每一项的key和val都是一样的// AbstractMap类
public boolean equals(Object o) {// ①首先比较是不是同一个对象if (o == this)return true;// ②判断 o 是不是Map的子类if (!(o instanceof Map))return false;// ③判断Map的长度(size)是否一样,注意比较的是实际长度,而不是初始化长度Map<?,?> m = (Map<?,?>) o;if (m.size() != size())return false;// ④判断每一项的key和val是不是一样的try {Iterator<Entry<K,V>> i = entrySet().iterator();while (i.hasNext()) {Entry<K,V> e = i.next();K key = e.getKey();V value = e.getValue();if (value == null) {if (!(m.get(key)==null && m.containsKey(key)))return false;} else {if (!value.equals(m.get(key)))return false;}}} catch (ClassCastException unused) {return false;} catch (NullPointerException unused) {return false;}return true;
}
所以可以看出,超类中已经覆盖的equals方法能够为子类所用,所以子类也无需再去覆盖equals。
情况4:、原文P31:类是私有的,或者是包级私有的,可以确定它的equals方法永远不会被调用。如 果你非常想要规避风险,可以覆盖equals方法,以确保它不会被意外调用。
一个私有的类如果被反射攻击,其equals方法就会暴露出去,此时就需要覆盖equals方法:
// Demo1 类
public class Demo1 {private Demo1(){}@Overridepublic boolean equals(Object o){throw new AssertionError(); // Method is never called}
}
那么什么时候应该覆盖equals方法呢?
原文P31:如果类具有自己特有的“逻辑相等”概念(不同于对象等同的概念),而且超类还没有覆盖equals。这通常属于“值类”的情形。值类仅仅是一个表示值的类,例如Integer或者string。程序员在利用equals方法来比较值对象的引用时,希望知道它们在逻辑上是否相等,而不是想了解它们是否指向同一个对象。为了满足程序员的要求,不仅必须覆盖equals 方法而且这样做也使得这个类的实例可以被用作映射表(Map)的键(key),或者集合(Set)的元素,使映射或者集合表现出预期的行为。
假设一个场景:生活中有很多重名的现象,现在我们认为,只要是名字相同的人,我们就认为他们“相等”。
// Person 类
public class Person {private String name;private int age;private String sex;public Person(String name, int age, String sex) {this.name = name;this.age = age;this.sex = sex;}@Override // 重写equals方法public boolean equals(Object obj) {// 先判断是不是Person类 或者 其子类if (obj instanceof Person) {Person person = (Person) obj;// 如果两个人重名,那么就认为他们“相等”return this.name.equals(person.name);}return false;}
}// Main类
Person person1 = new Person("张三", 20, "男");
Person person2 = new Person("张三", 28, "女");
Person person3 = new Person("张三", 10, "男");System.out.println(person1.equals(person2)); // true
System.out.println(person1.equals(person3)); // true// 测试Map
Map<Person, String> map = new HashMap<>();
map.put(person1, "123");
map.put(person2, "123");
map.put(person3, "123");
System.out.println(map); // 实际上会打印出三个对象(见下面的解释)// 测试Set
Set<Person> set = new HashSet<>();
set.add(person1);
set.add(person2);
set.add(person3);
System.out.println(set); // 实际上会打印出三个对象(见下面的解释)
原文中提到的 “覆盖equals 方法而且这样做也使得这个类的实例可以被用作映射表(Map)的键(key),或者集合(Set)的元素,使映射或者集合表现出预期的行为”,这个的意思是,如果将person1、person2、person3放入作为Map的key 或者 放到Set集合中,实际 Map或者 Set集合中只会存在一个元素(因为,我们认为名字相同的人是“相等的”),但是要实现这个效果,仅仅只重写equals是不够的,必须还要重写hasCode方法。这个会在第11条的时候详细讲解,这里先不展开。
原文P31:有一种“值类”不需要覆盖equals方法,即用实例受控(单例模式)确保“每个值至多只存在一个对象”的类。枚举类型(详见第34条)就属于这种类。对于这样的类而言,逻辑相同与对象等同是一回事,因此0bject的equals方法等同于逻辑意义上的equals方法。
即:单例模式和枚举类型不需要覆盖equals,用Object默认的“==”就已经足够,因为他们最多只有一个对象,所以“==”比较实际上就是自身在比较,永远返回true。
// Main类
// 使用 article03 的Elvis5 测试单例类
Elvis5 elvis51 = Elvis5.getInstance();
Elvis5 elvis52 = Elvis5.getInstance();
System.out.println(elvis51.equals(elvis52)); // true// 使用 article03 的Elvis7 测试枚举类
Elvis7 elvis71 = Elvis7.INSTANCE;
Elvis7 elvis72 = Elvis7.INSTANCE;
System.out.println(elvis71.equals(elvis72)); // true
原文P31:在覆盖 equals 方法的时候,必须要遵守它的通用约定。下面是约定的内容,来自Object 的规范。 equals方法实现了等价关系,其属性如下:
- 自反性:对于任何非 null 的引用值x,x.equals(x)必须返回 true。
- 对称性:对于任何非 null 的引用值x和y,当且仅当 y.equals(x)返回true 时,x.equals(y)必须返回 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。
接下来我们按照顺序,逐一查看这5个属性:
1、自反性:
原文P32:第一个要求仅仅说明对象必须等于其自身。很难想象会无意识地违反这一条。假如违背了这一条,然后把该类的实例添加到集合中,该集合的contains方法将果断地告诉你,该集合不包含你刚刚添加的实例。
下面是一个违反了自反性的例子:
// Demo2类 重写equals方法,违反了自反性
public class Demo2 {@Overridepublic boolean equals(Object obj) {return false;}
}// Main类
Demo2 demo2 = new Demo2();
System.out.println(demo2.equals(demo2)); // falseList<Demo2> demo2List = new ArrayList<>();
demo2List.add(demo2);
System.out.println(demo2List.contains(demo2)); // false
在 contains 方法中,会调用类本身的 equals 方法来进行判断,由于Demo2类违反了自反性,所以会出现问题。
2、对称性:
原文P32:第二个要求是说,任何两个对象对于“它们是否相等”的问题都必须保持一致。与第一个要求不同,若无意中违反这一条,这种情形倒是不难想象。例如下面的类,它实现了一个区分大小写的字符串。字符串由toString保存,但在 equals 操作中被忽略。
// CaseInsensitiveString类
public class CaseInsensitiveString {private final String s;public CaseInsensitiveString(String s) {this.s = Objects.requireNonNull(s);}@Override// equals比较的时候忽略大小写public boolean equals(Object o) {if (o instanceof CaseInsensitiveString) {return s.equalsIgnoreCase(((CaseInsensitiveString) o).s);}if (o instanceof String) {return s.equalsIgnoreCase((String) o);}return false;}
}// Main类
CaseInsensitiveString cis = new CaseInsensitiveString("Polish");
String s = "polish";
System.out.println(cis.equals(s)); // true
System.out.println(s.equals(cis)); // false
可以看出上面的代码违反了对称性,原文P32:问题在于,虽然CaseInsensitiveString类中的 equals方法知道普通的字符串对象要忽略大小写,但是,string类中的equals方法却并不知道不区分大小写的字符串。因此,s.equals(cis)返回false,显然违反了对称性。假设你把不区分大小写的字符串对象放到一个集合中:
// Main类
CaseInsensitiveString cis = new CaseInsensitiveString("Polish");
String s = "polish";List<CaseInsensitiveString> list1 = new ArrayList<>();
list1.add(cis);
System.out.println(list1.contains(s)); // falseList<String> list2 = new ArrayList<>();
list2.add(s);
System.out.println(list2.contains(cis)); // true
原文P33:此时 list1.contains(s)会返回什么结果呢?没人知道。在当前的OpenJDK实现中,它碰巧返回false,但这只是这个特定实现得出的结果而已。在其他的实现中(比如list2.contains(cis)),它有可能返回 true,或者抛出一个运行时异常。一旦违反了 equals约定,当其他对象面对你的对象时,你完全不知道这些对象的行为会怎么样。
上面代码之所以出现一个false,一个true的情况是因为,在Jdk1.8中,contains的实现是调用的参数的equals方法,即在 list2.contains(cis) 中,调用的是CaseInsensitiveString的equals方法,而它的equals方法是忽略大小写的,所以会返回true。而list1.contains(s)中,调用的是String的equals方法。
原文P33:为了解决这个问题,只需把企图与String互操作的这段代码从equals方法中去掉就可以了。这样做之后,就可以重构该方法,使它变成一条单独的返回语句:
// CaseInsensitiveString2类,去掉了企图与String互操作的代码
// 此时如果与String比较,即使是一样的字符串,也会因为类型不同而返回false
public class CaseInsensitiveString2 {private final String s;public CaseInsensitiveString2(String s) {this.s = Objects.requireNonNull(s);}@Override// equals比较的时候忽略大小写public boolean equals(Object o) {if (o instanceof CaseInsensitiveString2) {return s.equalsIgnoreCase(((CaseInsensitiveString2) o).s);}return false;}
}// Main类
CaseInsensitiveString2 cis = new CaseInsensitiveString2("Polish");
String s2 = "Polish";
System.out.println(cis.equals(s2)); // false
3、传递性:
原文P33:如果一个对象等于第二个对象,而第二个对象又等于第三个对象,则第一个对象一定等于第三个对象。同样地,无意识地违反这条规则的情形也不难想象。用子类举个例子。假设它将一个新的值组件添加到了超类中。换句话说,子类增加的信息会影响equals的比较结果。我们首先以一个简单的不可变的二维整数型Point类作为开始:
// Point类
public class Point {private int x;private int y;public Point(int x, int y) {this.x = x;this.y = y;}@Overridepublic boolean equals(Object o) {if (!(o instanceof Point)) {return false;}Point p = (Point) o;return x == p.x && y == p.y;}
}// ColorPoint类
public class ColorPoint extends Point {private Color color;public ColorPoint(int x, int y, Color color) {super(x, y);this.color = color;}@Overridepublic boolean equals(Object o) {if (!(o instanceof ColorPoint)) {return false;}return super.equals(o) && ((ColorPoint) o).color == color;}
}// Main类
ColorPoint colorPoint = new ColorPoint(1,1, Color.BLACK);
Point point = new Point(1,1);
// 普通点比较有色点
System.out.println(point.equals(colorPoint)); // true
// 有色点比较普通点
System.out.println(colorPoint.equals(point)); // false
上例中,如果ColorPoint类不提供 equals 方法(即没有第26-32行),在equals做比较的时候颜色信息就被忽略掉了。虽然这样做不会违反equals约定,但很明显这是无法接受的。所以编写了一个equals方法,只有当它的参数是另一个有色点,并且具有同样的位置和颜色时,它才会返回true。
但是,这个方法也有问题,问题在于,原文P34:这个方法的问题在于,在比较普通点和有色点,以及相反的情形时(比较有色点和普通点),可能会得到不同的结果。前一种比较忽略了颜色信息,而后一种比较则总是返回false,因为参数的类型不正确。
上面的代码违反了“对称性”,所以做一下修改:让 ColorPoint.equals 在进行“混合比较”时忽略颜色信息:
从ColorPoint的基础上修改得到ColorPoint2类:
public class ColorPoint2 extends Point {private Color color;public ColorPoint2(int x, int y, Color color) {super(x, y);this.color = color;}@Overridepublic boolean equals(Object o) {if (!(o instanceof Point)) {return false;}// 如果只是一个普通点,则调用父类Point的equals方法,仅比较坐标if (!(o instanceof ColorPoint2)) {return o.equals(this);}// 如果是一个有色点,除了比较坐标之外,还要比较颜色return super.equals(o) && ((ColorPoint2) o).color == color;}
}// Main类
ColorPoint2 p1 = new ColorPoint2(1,1,Color.BLUE);
Point p2 = new Point(1,1);
ColorPoint2 p3 = new ColorPoint2(1,1,Color.RED);System.out.println(p1.equals(p2)); // true
System.out.println(p2.equals(p3)); // true
System.out.println(p1.equals(p3)); // false 因为颜色不同
从上例可以看出,由于p1和p3颜色不同,所以 p1.equals(p3) 的结果是false,这显然违反了传递性。
原文P34:此外,这种方法还可能导致无限递归问题:假设Point有两个子类,如ColorPoint 和 SmellPoint,它们各自都带有这种equals方法。那么对myColorPoint.equals(mySmellPoint)的调用将会抛出StackOverflowError异常:
// SmellPoint类 equals方法和ColorPoint2几乎一样
public class SmellPoint extends Point{private String smell;public SmellPoint(int x, int y, String smell) {super(x, y);this.smell = smell;}@Overridepublic boolean equals(Object o) {if (!(o instanceof Point)) {return false;}// 如果只是一个普通点,则调用父类Point的equals方法,仅比较坐标if (!(o instanceof SmellPoint)) {return o.equals(this);}// 如果是一个气味点,除了比较坐标之外,还要比较气味return super.equals(o) && ((SmellPoint) o).smell == smell;}
}// Main类
ColorPoint2 cp1 = new ColorPoint2(1,1,Color.BLUE);
SmellPoint sp1 = new SmellPoint(1,1,"苹果味");
cp1.equals(sp1); // 这里会报StackOverflowError,因为陷入了无限递归
原文P34:那该怎么解决呢?事实上,这是面向对象语言中关于等价关系的一个基本问题。我们无法在扩展可实例化的类的同时,既增加新的值组件,同时又保留equals约定,除非愿意放弃面向对象的抽象所带来的优势。
原文P35:你可能听说过,在equals方法中用getclass测试代替instanceof测试,可以扩展可实例化的类和增加新的值组件,同时保留equals约定:
// Point2类 在Point类的基础上改为使用getClass() 的方式实现
public class Point2 {private int x;private int y;public Point2(int x, int y) {this.x = x;this.y = y;}// 改为 使用getClass() 的方式实现@Overridepublic boolean equals(Object o) {if (o == null || o.getClass() != getClass()) {return false;}Point2 p = (Point2) o;return x == p.x && y == p.y;}
}
原文P35:这段程序只有当对象具有相同的实现类时,才能使对象等同。虽然这样也不算太糟糕,但结果却是无法接受的:Point2子类的实例仍然是一个Point2,它仍然需要发挥作用,但是如果采用了这种方法,它就无法完成任务!
理解这句话:只有当对象是同一个类的实现的时候才能使对象等同,也就是说一个子类的实例 和 一个父类的实例,任何时候都是不想等的。这种方式看起来不算糟糕。但是不要忘了,Point2的子类的实例仍然是一个Point2,Point2的equals应该对所有的子类有效,但是很遗憾,所有的子类和父类的实例比较都会返回false。
接下来看书上举的这个例子:编写一个方法,以检验某个点是否处在单位圆中:
// Utils类,用于判断某个点是否在圆中
public class Utils {private static final Set<Point2> unitCircle = new HashSet<Point2>();static {unitCircle.add(new Point2(1, 0));unitCircle.add(new Point2(0, 1));unitCircle.add(new Point2(-1, 0));unitCircle.add(new Point2(0, -1));}public static boolean onUnitCircle(Point2 p) {return unitCircle.contains(p);}
}// CounterPoint类
public class CounterPoint extends Point2 {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();}
}// Main类
CounterPoint counterPoint1 = new CounterPoint(0,1);
CounterPoint counterPoint2 = new CounterPoint(1,0);
System.out.println(Utils.onUnitCircle(counterPoint1)); // false
System.out.println(Utils.onUnitCircle(counterPoint2)); // false
原文P35:里氏替换原则认为,一个类型的任何重要属性也将适用于它的子类型,因此为该类型编写的任何方法,在它的子类型上也应该同样运行得很好。针对上述 Point2的子类(如 CounterPoint)仍然是 Point2,那么Point2中的equals方法在CounterPoint也应该正确运行。但是我们将CounterPoint实例传给了onUnitcircle方法。无论CounterPoint实例的x和y值是什么,onUnitCircle方法都会返回false。这是因为像onUnitcircle方法所用的HashSet 这样的集合,利用equals方法检验包含条件,没有任何CounterPoint 实例与任何 Point 对应。但是,如果在 Point 上使用适当的基于instanceof的equals方法,当遇到CounterPoint时,相同的onUnitcircle方法就会工作得很好。
上述这一段话解释了,为什么在equals中使用instanceof 而不是使用 getClass()方法。
原文P35:虽然没有一种令人满意的办法可以既扩展不可实例化的类,又增加值组件,但还是有一种不错的权宜之计:遵从第18条“复合优先于继承”的建议。我们不再让 ColorPoint扩展 Point,而是在ColorPoint中加入一个私有的Point域,以及一个公有的视图(asPoint)方法,此方法返回一个与该有色点处在相同位置的普通Point 对象:
public class ColorPoint3 {private final Point point;private final Color color;public ColorPoint3(int x, int y, Color color) {this.point = new Point(x, y);this.color = color;}public Point asPoint() {return point;}@Overridepublic boolean equals(Object o) {if (!(o instanceof ColorPoint3)) {return false;}ColorPoint3 cp = (ColorPoint3) o;return point.equals(cp.point) && cp.color.equals(color);}
}// Main类
ColorPoint3 colorPoint31 = new ColorPoint3(1,1, Color.BLACK);
Point p3 = new Point(1,1);
ColorPoint3 colorPoint32 = new ColorPoint3(1,1, Color.BLACK);System.out.println(colorPoint31.asPoint().equals(p3)); // true
System.out.println(p3.equals(colorPoint32.asPoint())); // true
System.out.println(colorPoint31.asPoint().equals(colorPoint32.asPoint())); // true
原文P36:在 Java平台类库中,有一些类扩展了可实例化的类,并添加了新的值组件。例如:java.sql.Timestamp对java.util.Date 进行了扩展,并增加了nanoseconds 域。Timestamp的 equals 实现确实违反了对称性,如果 Timestamp 和 Date 对象用于同一个集合中,或者以其他方式被混合在一起,则会引起不正确的行为(参考上面的例子)。Timestamp 类有一个免责声明,告诫程序员不要混合使用Date和Timestamp 对象(如下图红框部分)。只要你不把它们混合在一起,就不会有麻烦,除此之外没有其他的措施可以防止你这么做,而且结果导致的错误将很难调试。Timestamp类的这种行为是个错误,不值得仿效。
// Mian类 测试Timestamp.equals的对称性
long nowTime = System.currentTimeMillis();
Timestamp timestamp = new Timestamp(nowTime);
Date date = new Date(nowTime);
System.out.println(timestamp.equals(date)); // false
System.out.println(date.equals(timestamp)); // true
有兴趣的同学可以看看Timestamp源码的实现,这里不再展开。
原文P36:注意,你可以在一个抽象类的子类中增加新的值组件且不违反equals约定。例如,你可能有一个抽象的 shape类,它没有任何值组件,Circle子类添加了一个radius 域Rectangle 子类添加了 length和width域。只要不可能直接创建超类的实例,前面所述的种种问题就都不会发生。
我们可以看到,前面之所以会出现传递性的问题,就是因为超类(父类)的实例参与了传递过程,如果超类是一个抽象类,不能被实例化,那么这个问题就不会存在了!
4、一致性:
原文P36:equals约定的第四个要求是,如果两个对象相等,它们就必须始终保持相等,除非它们中有一个对象(或者两个都)被修改了(equals中比较的信息)。换句话说,可变对象在不同的时候可以与不同的对象相等,而不可变对象则不会这样。当你在写一个类的时候,应该仔细考虑它是否应该是不可变的。如果认为它应该是不可变的,就必须保证 equals方法满足这样的限制条件:相等的对象永远相等,不相等的对象永远不相等。
原文P37:无论类是否是不可变的,都不要使 equals方法依赖于不可靠的资源。如果违反了这条禁令,要想满足一致性的要求就十分困难了。
例如,java.net.URL的equals方法依赖于对 URL中主机IP地址的比较。将一个主机名转变成IP地址可能需要访问网络,随着时间的推移,就不能确保会产生相同的结果,即有可能IP地址发生了改变。这样会导致URL equals 方法违反 equals 约定,在实践中有可能引发一些问题。URL equals 方法的行为是一个大错误并且不应被模仿。遗憾的是,因为兼容性的要求,这一行为无法被改变。为了避免发生这种问题,equals方法应该对驻留在内存中的对象执行确定性的计算。
java.net.URL的equals方法的源码:
// URL类
public boolean equals(Object obj) {if (!(obj instanceof URL))return false;URL u2 = (URL)obj;// 调用 URLStreamHandler类 的equals方法return handler.equals(this, u2);
}// URLStreamHandler类 的equals方法
protected boolean equals(URL u1, URL u2) {String ref1 = u1.getRef();String ref2 = u2.getRef();// 1、首先比较 ref的值:ref是URL 中 # 后面的部分,用于定位文档内的片段(如 HTML 页面中的锚点)return (ref1 == ref2 || (ref1 != null && ref1.equals(ref2))) &&// 调用sameFile方法sameFile(u1, u2);
}// URLStreamHandler类 的sameFile方法
protected boolean sameFile(URL u1, URL u2) {// 2、比较协议类型 如 http、https、ftp、file 等if (!((u1.getProtocol() == u2.getProtocol()) ||(u1.getProtocol() != null &&u1.getProtocol().equalsIgnoreCase(u2.getProtocol()))))return false;// 3、比较路径,资源在服务器上的路径(如 /api/user)if (!(u1.getFile() == u2.getFile() ||(u1.getFile() != null && u1.getFile().equals(u2.getFile()))))return false;// 4、比较主机名:服务器的域名或 IP 地址(如 www.example.com 或 192.168.1.1)int port1, port2;port1 = (u1.getPort() != -1) ? u1.getPort() : u1.handler.getDefaultPort();port2 = (u2.getPort() != -1) ? u2.getPort() : u2.handler.getDefaultPort();if (port1 != port2)return false;// 5、比较端口号if (!hostsEqual(u1, u2))return false;return true;
}
可以看到,equals分别比较了锚点(Ref)、协议(Protocol)、路径(File)、主机名(Port)和端口号(Hosts)
在比较主机名的时候,就会出现原文中描述的问题——“java.net.URL的equals方法依赖于对 URL中主机IP地址的比较。将一个主机名转变成IP地址可能需要访问网络,随着时间的推移,就不能确保会产生相同的结果,即有可能IP地址发生了改变。这样会导致URL equals 方法违反 equals 约定,在实践中有可能引发一些问题。”
所以我们在自己实现equals的时候也要注意,不要依赖于一个不稳定的因素。
5、非空性:
意思是,所有的对象都不等于null(注:null == null 是 true),所以在equals方法中,第一步我们需要判断,如果参数为null,不能报错,而是直接返回false。那么是不是我们就需要在equals方法中加一个判断null的条件呢?(如下面代码)
f (obj == null)return false;
答案是:没有必要的。因为在很多源码中我们可以看到,equals方法的第一个判断都是:(拿上例的URL类当做例子)
if (!(obj instanceof URL))return false;
这个方法有两个作用:
1、当obj 为null时,null 不是 任何类型的子类,所以 instanceof 会返回null
2、当obj 不为null时,obj 必须是URL或其子类,才会通过该判断。
所以, obj instanceof URL 已经包含了对obj == null 的判断,所以不用再显式的null检查了。
原文P37:结合所有这些要求,得出了以下实现高质量equals方法的诀窍:
1.使用 == 操作符检查“参数是否为这个对象的引用”。如果是,则返回 true。这只不过是一种性能优化,如果比较操作有可能很昂贵,就值得这么做。(如下例)
// Main类
Map<String, Object> map1 = new HashMap<>(8);
Map<String, Object> map2 = map1;
System.out.println(map1.equals(map2)); // true// AbstractMap 类的equal方法
public boolean equals(Object o) {if (o == this)return true;if (!(o instanceof Map))return false;
// ...... 其他源码省略
}// Main类
Map<String, Object> map1 = new HashMap<>();
Map<String, Object> map2 = new TreeMap<>(); // 注意,这是TreeMap
System.out.println(map1.equals(map2)); // true
2.使用 instanceof 操作符检查“参数是否为正确的类型”。如果不是,则返回 false。一般说来,所谓“正确的类型”是指equals方法所在的那个类。某些情况下,是指该类所实现的某个接口(比如上例 HashMap 实现了Map接口 那么“正确的类型”指的就是Map而不是HashMap)。如果类实现的接口改进了equals约定,允许在实现了该接口的类之间进行比较,那么就使用接口。集合接口如Set、List、Map和Map.Entry具有这样的特性。
如上例 AbstractMap 类的equal方法,使用的就是Map这个接口来比较的,所以测试map1(HashMap类型)和map2(TreeMap类型)的时候,才会返回true。
3.把参数转换成正确的类型。因为转换之前进行过 instanceof 测试,所以确保会成功。
4.对于该类中的每个“关键”域,检查参数中的域是否与该对象中对应的域相匹配。如果这些测试全部成功,则返回true;否则返回false。
比如上面Person 类的例子,我们规定,名字相同的人就是“相等的”,关键域就是 name。
关键域的不同类型,比较的方式也有所不同,书上给出了解答:
原文P37:对于既不是 float 也不是 double 类型的基本类型域,可以使用 == 操作符进行比较;对于对象引用域,可以递归地调用equals方法;对于float域,可以使用静态Floatcompare(float,float)方法; 对于double域,则使用 Double.compare(double,double)对 float 和 double域进行特殊的处理是有必要的,因为存在着 Float.NaN、-0.0f 以及类似的 double 常量。对于数组域,则要把以上这些指导原则应用到每一个元素上。如果数组域中的每个元素都很重要,就可以使用其中一个 Arrays.equals 方法。
有些对象引用域包含null可能是合法的,所以,为了避免可能导致NullPointerException 异常,则使用静态方法0bjects.equals(0bject,Object)来检查这类域的等同性。
对于有些类,比如前面提到的CaseInsensitivestring类,域的比较要比简单的等同性测试复杂得多。如果是这种情况,可能希望保存该域的一个“范式”(canonicalform),这样 equals方法就可以根据这些范式进行低开销的精确比较,而不是高开销的非精确比较。这种方法对于不可变类是最为合适的;如果对象可能发生变化就必须使其范式保持最新。
举例说明:比如有一个“文本类”(Text),有两个属性,题目 和 内容,传统的equals方法,需要逐字的比较题目和内容是否相等。当内容过多的时候,比较起来就会非常慢。
那么这个时候我们可以保存一个范式,如下例,范式就是题目+内容的 MD5 编码,这样在equals方法比较的时候,可以直接比较已经保存的MD5编码,无需逐字去比较。对于可变类,每次改变属性的值,都需要更新范式。而不可变类,保存的范式永远不会变,所以范式更适合不可变类的比较。
public class Text {private final String topic;private final String content;// 用于比较的范式private String md5Code = null;public Text(String topic, String content) {this.topic = topic;this.content = content;// 每次构建类的时候,都生成范式并保存createMD5Code();}// 构建范式private void createMD5Code() {byte[] tc = (topic + content).getBytes();MessageDigest md = null;try {md = MessageDigest.getInstance("MD5");} catch (NoSuchAlgorithmException e) {throw new RuntimeException(e);}this.md5Code = new String(md.digest(tc));}// equals 方法在比较的时候,无需逐字比较,只需要比较已经保存的 md5编码即可@Overridepublic boolean equals(Object obj) {if (! (obj instanceof Text)) {return false;}Text t = (Text) obj;return this.md5Code.equals(t.md5Code);}
}
原文P38:域的比较顺序可能会影响equals方法的性能。为了获得最佳的性能,应该最先比较最有可能不一致的域,或者是开销最低的域,最理想的情况是两个条件同时满足的域。不应该比较那些不属于对象逻辑状态的域,例如用于同步操作的Lock域。也不需要比较衍生域,因为这些域可以由“关键域”计算获得,这样做有可能提高equals方法的性能。如果衍生域代表了整个对象的综合描述,比较这个域可以节省在比较失败时去比较实际数据所需要的开销。例如,假设有一个Polygon类,并缓存了该面积。如果两个多边形有着不同的面积,就没有必要去比较它们的边和顶点。
以上文字提醒我们,为了提高equals的性能,我们要优先比较最有可能不一致的属性(域),或者是开销比较低的域,或者直接比较最终结果,如果结果都不相同,也没必要比较其中的每一个属性。
书中给了一个 PhoneNumber类的例子,这里只截取equals方法:
@Override
public boolean equals(Object o) {// 1、首先判断是不是null,实际上是不需要的,因为第2步实际上已经包含了null的判断if (o == null) {return false;}// 2、判断是否是PhoneNumber类或其子类if (!(o instanceof PhoneNumber)) {return false;}PhoneNumber pn = (PhoneNumber) o;// 3、在判断的时候,lineNum(最后四位)是最容易不一样的,其次是中间四位,最后是前三位return pn.lineNum == lineNum &&pn.prefix == prefix &&pn.areaCode == areaCode;
}
最后,总结一下:
1、覆盖equals的时候,总要覆盖hashCode(详见第11条)
2、不要企图让equals方法过于智能,只需要简单的比较属性的值是否相等即可,否则很难遵守上述的五条约定。
3、不要将 equals 声明中参数的 Object 对象替换为其他的类型,因为这样就不是重写Object类的equals方法,而变成了重载。
4、如果打算重写某个方法,一定要加上@Override注解
5、最好用自动工具,如IDE或者AutoValue自动生成equals和hashCode代码
6、不要轻易的覆盖equals方法,一定要覆盖的话,最好遵守五个原则