以下核心内容摘自effective java第三版,源码分析基于1.8版本。在effective java中列举了大量源码中的例子(但都是一语带过,不够详尽),我会尽我所能的把书中列举的例子从源码角度分析一遍,力求知其然而知其所以然。
Object类的意义
重点:Object类虽然是一个具体类,但它的设计意义却是扩展。,
同时任何一个类,在覆盖这些方法的时候,都有责任遵守这些通用约定,如果不能做到这一点,其他依赖于这些通用约定的类(例如HashMap和HashSet)就无法协同该类正常工作。
所以本类源码实现是非常简单的,但重要的是基于这些通俗约定,延伸出来的一些常用基础类中的方法重写。我们需要理解它们是怎么写的,同时为什么这么写。那么现在就开始吧~
Object类属于java.lang包,此包下的所有类在使用时无需手动导入,系统会在程序编译期间自动导入。Object类是所有类的基类, 当一个类没有直接继承某个类时,默认继承Object类,也就是说任何类都直接或间接继承此类,Object 类中能访问的方法在所有类中都可以调用。下面看一下其中的内容。
结构图
image源码
package java.lang;
public class Object {
private static native void registerNatives();
static {
registerNatives();
}
public final native Class<?> getClass();
public native int hashCode();
public boolean equals(Object obj) {
return (this == obj);
}
protected native Object clone() throws CloneNotSupportedException;
public String toString() {
return getClass().getName() + "@" + Integer.toHexString(hashCode());
}
public final native void notify();
public final native void notifyAll();
public final native void wait(long timeout) throws InterruptedException;
public final void wait(long timeout, int nanos) throws InterruptedException {
if (timeout < 0) {
throw new IllegalArgumentException("timeout value is negative");
}
if (nanos < 0 || nanos > 999999) {
throw new IllegalArgumentException(
"nanosecond timeout value out of range");
}
if (nanos > 0) {
timeout++;
}
wait(timeout);
}
public final void wait() throws InterruptedException {
wait(0);
}
protected void finalize() throws Throwable { }
}
储备知识
lang包不需要导入
不知道大家注意到没,我们在使用诸如Date类时,需要手动导入import java.util.Date,再比如使用File类时,也需要手动导入import java.io.File。但是我们在使用Object类,String 类,Integer类等不需要手动导入,而能直接使用,这是为什么呢?
这里先告诉大家一个结论:使用 java.lang 包下的所有类,都不需要手动导入。
另外我们介绍一下Java中的两种导包形式,导包有两种方法:
①、单类型导入(single-type-import),例如import java.util.Date
②、按需类型导入(type-import-on-demand),例如import java.util.*
单类型导入比较好理解,我们编程所使用的各种工具默认都是按照单类型导包的,需要什么类便导入什么类,这种方式是导入指定的public类或者接口;
按需类型导入,比如 import java.util.*,可能看到后面的 *,大家会以为是导入java.util包下的所有类,其实并不是这样,我们根据名字按需导入要知道他是按照需求导入,并不是导入整个包下的所有类。
Java编译器会从启动目录(bootstrap),扩展目录(extension)和用户类路径下去定位需要导入的类,而这些目录进仅仅是给出了类的顶层目录,编译器的类文件定位方法大致可以理解为如下公式:
顶层路径名 \ 包名 \ 文件名.class = 绝对路径
单类型导入我们知道包名和文件名,所以编译器可以一次性查找定位到所要的类文件。按需类型导入则比较复杂,编译器会把包名和文件名进行排列组合,然后对所有的可能性进行类文件查找定位。例如:
package com;
import java.io.*;
import java.util.*;
如果我们文件中使用到了 File 类,那么编译器会根据如下几个步骤来进行查找 File 类:
①、File // File类属于无名包,就是说File类没有package语句,编译器会首先搜索无名包
②、com.File // File类属于当前包,就是我们当前编译类的包路径
③、java.lang.File //由于编译器会自动导入java.lang包,所以也会从该包下查找
④、java.io.File
⑤、java.util.File
......
需要注意的地方就是,编译器找到java.io.File类之后并不会停止下一步的寻找,而要把所有的可能性都查找完以确定是否有类导入冲突。假设此时的顶层路径有三个,那么编译器就会进行3*5=15次查找。
如果在查找完成后,编译器发现了两个同名的类,那么就会报错。要删除你不用的那个类,然后再编译。
所以我们可以得出这样的结论:按需类型导入是绝对不会降低Java代码的执行效率的,但会影响到Java代码的其他几个方面:
1.编译速度:在一个很大的项目中,它们会极大的影响编译速度.但在小型项目中使用在编译时间上可以忽略不计。
2.命名冲突:解决避免命名冲突问题的答案就是使用全名。而按需导入恰恰就是使用导入声明初衷的否定。
3.说明问题:毕竟高级语言的代码是给人看的,按需导入看不出使用到的具体类型。
4.无名包问题:如果在编译单元的顶部没有包声明,Java编译器首选会从无名包中搜索一个类型,然后才是按需类型声明。如果有命名冲突就会产生问题。
讲清楚Java的两种导包类型了,我们在回到为什么可以直接使用 Object 类,看到上面查找类文件的第③步,编译器会自动导入 java.lang 包,那么当然我们能直接使用了。至于原因,因为用的多,提前加载了,省资源。
static import静态导入
在Java程序中,是不允许定义独立的函数和常量的。即什么属性或者方法的使用必须依附于什么东西,例如使用类或接口作为挂靠单位才行(在类里可以挂靠各种成员,而接口里则只能挂靠常量)。
如果想要直接在程序里面不写出其他类或接口的成员的挂靠单元,有一种变通的做法 :
将所有的常量都定义到一个接口里面,然后让需要这些常量的类实现这个接口(这样的接口有一个
专门的名称,叫“Constant Interface”。这个方法可以工作。但是,因为这样一来,就可以从
“一个类实现了哪个接口”推断出“这个类需要使用哪些常量”,有“会暴露实现细节”的问题。
于是J2SE 1.5里引入了“Static Import”机制,借助这一机制,可以用略掉所在的类或接口名的方式,来使用静态成员。
有几个问题需要弄清楚:
- Static Import无权改变无法使用本来就不能使用的静态成员的约束
- 导入的静态成员和本地的静态成员名字相同起了冲突,这种情况下的处理规则,本地优先
- 不同的类(接口)可以包括名称相同的静态成员。例如在进行Static Import的时候,出现了“两个导入语句导入同名的静态成员”的情况。在这种时候,J2SE 1.5会这样来加以处理:
- 如果两个语句都是精确导入的形式,或者都是按需导入的形式,那么会造成编译错误。
- 如果一个语句采用精确导入的形式,一个采用按需导入的形式,那么采用精确导入的形式的一个有效。
instanceof和强制类型转换
先补充一些 instanceof 关键字的知识,后面会用
// 表达式 obj instanceof T 的过程可以用如下伪代码描述:
boolean result;
if (obj == null) {
result = false;
} else {
try {
T temp = (T) obj; // checkcast
result = true;
} catch (ClassCastException e) {
result = false;
}
}
通过以上伪代码可以发现,如果 obj 不为 null 并且 (T) obj 不抛 ClassCastException 异常则该表达式值为 true ,否则值为 false 。要注意以下几点:
instanceof 运算符的 obj 操作数的类型必须是引用类型或空类型; 否则,会发生编译时错误。
如果 obj 强制转换为 T 时发生编译错误,则关系表达式的 instanceof 同样会产生编译时错误。 在这种情况下,表达式实例的结果永远为false。(这句话是摘自博客的,其实ide会显示红线运行不了的状态,或许能通过诸如asmtools绕过编译器,强行运行吧,暂且这么理解)
在运行时,如果 obj 可以转换为 T 而不引发ClassCastException,则instanceof运算符的结果为true。 否则结果是false的。
同时需要注意,T只能是类名,而非引用,而obj类型是运行时类型。
简单来说就是:如果 obj 是 T(可以是子类,但不能是超类) 的一个实例,则 instanceof 运算符返回 true。如果 object 不是指定类的一个实例,或者 object 是 null,则返回 false。
需要注意的是:
也就是说,父类强转成子类,以及某个类强转成任何一个接口(而事实上这个类并没有直接或间接的实现这个借口),都不会在编译时报错,只会在运行时报错。同时如果两者没有继承关系,则会在编译时报错。
同时强制类型转换对于被转换对象的运行时类型没有任何影响。
son a=new son();
father b=(father) a;
b.getname();
father c=(father)(new son());
c.getname();
// 结果这两种情况执行的还是son类重写father类的getname方法
所以对于instanceof 会有如下特殊的情况发生:
System.out.println(p1 instanceof String);//编译报错
System.out.println(p1 instanceof List);//false
System.out.println(p1 instanceof List<?>);//false
System.out.println(p1 instanceof List<Person>);//编译报错
构造器
object类中只有默认空参的构造器,所以只能使用如下形式
Object a=new Object();
核心方法(重点)
1.equals方法
public boolean equals(Object obj) {
return (this == obj);
}
可以看出默认情况下equals进行对象比较时只判断了obj对象是否是其自身(判断地址)。
注意:当使用默认的equals方法时,应该把肯定为非空的object对象放前面,而可能为空的object对象放括号里,防止出现空指针异常。
当我们有特殊的“相等”逻辑时,则需要覆盖equals方法。
什么类需要重写equals方法
- 这通常是一个值类,值类的意义就是表示一个或多个值的类如String类和Interger类。我们使用equals方法比较两个值类的对象的引用时,其实是想知道它们在逻辑上是否相等,而不仅仅是了解它们是否指向同一个对象(如果是这样干嘛不用==也更简单呢?)。通过重写equals方法,我们自己来实现逻辑相等的功能。只有重写过equals方法,这个类的实例才可以作为map里的key或者set的元素,而保证map和set集合表现出预期的行为。
- 类的每个实例本质上是惟一的。对于诸如Thread类代表活动实体的类来说是正确的。Object类的equals方法就已经足够不需要重写。
- 同时不是说每一个类都必须提供逻辑相等的功能,比如util.regex.Pattern。即便是重写equals方法,以检查两个Pattern实例是否代表同一个正则表达式,但这并没有什么实际意义。
- 超类已经覆盖了equals,而超类的equals方法表现的行为对子类来说也是合适的。例如大多数Set的实现都从AbstractSet类继承了equals实现:
public boolean equals(Object o) {
if (o == this)
return true;
// instanceof的第二个操作数为Set接口,那么在Set temp=(Set)obj时,如果o对象并没有直接或间接实现Set接口,
// 那么会抛出一个ClassCastException,所以instanceof直接就会返回false
if (!(o instanceof Set))
return false;
// 将o强转为Collection,实现类的size方法返回值与AbstractSet的size方法返回值作比较
// 这是为了性能的优化,containsAll方法是一个从其父抽象类AbstractCollection继承而来的方法
// public boolean containsAll(Collection<?> c) {
// for (Object e : c)
// if (!contains(e))
// return false;
// return true;
// }
// 可以看到这是需要对集合中每一个元素调用父类的contains方法进行比对的,而contains方法是使用迭代器
// 每比较一个元素就要遍历一次集合,如果两个集合元素相同,最坏复杂度n^2级别,很耗费性能:
// public boolean contains(Object o) {
// Iterator<E> it = iterator();
// if (o==null) {
// while (it.hasNext())
// if (it.next()==null)
// return true;
// } else {
// while (it.hasNext())
// if (o.equals(it.next()))
// return true;
// }
// return false;
// }
Collection<?> c = (Collection<?>) o;
if (c.size() != size())
return false;
try {
return containsAll(c);
} catch (ClassCastException unused) {
return false;
} catch (NullPointerException unused) {
return false;
}
}
类似的,List实现类从AbstractList继承equals实现:
public boolean equals(Object o) {
if (o == this)
return true;
// 与上面的分析类似
if (!(o instanceof List))
return false;
// 基础的迭代器Iterator可以应用于所有的集合,Set、List和Map和这些集合的子类型。
// 而ListIterator继承了基础的迭代器在此之上又加入了新的几个方法,但只能用于List及其子类型。
// 关于迭代器的细节,我们留到集合类源码的学习中细致的讲解。
// 我们看到Set作为无序集合比较起来是很麻烦的,而List作为有序集合,我们就不需要在迭代中嵌套了。
// 代码功能非常直观,就不做分析了。但有一个很重要的知识点:o为什么要强转为(List<?>)而不是List呢?
ListIterator<E> e1 = listIterator();
ListIterator<?> e2 = ((List<?>) o).listIterator();
while (e1.hasNext() && e2.hasNext()) {
E o1 = e1.next();
Object o2 = e2.next();
if (!(o1==null ? o2==null : o1.equals(o2)))
return false;
}
return !(e1.hasNext() || e2.hasNext());
}
无限制通配符List<?>和原生态类型List之间有什么区别?首先明确的是,List<?>是一个可以放任何类型的集合。所以可以写成诸如
List<Integer> a1=new ArrayList<>();
List<?> a2=a1;
List a3;
这样的形式。而a3虽然a1,a2都能存放,但两者之间有非常大的区别,这也是泛型诞生的意义所在。
首先明确的是,通配符类型是安全的,而原生态类型不安全。由于可以将任何元素放进使用原生态类型的集合中,因此很容易破坏该集合的类型约束条件;但是不能将任何null之外的元素放入Collection<?>之中,一旦这么做了,就会出现编译错误。如果我们对于如此严厉的限制感到不满,此时就应该使用泛型方法和有限制的通配符类型。所以我们在使用任何泛型容器时,尽可能保证不要使用原生态类型。
但实际使用中也有几个例外的地方。比如在需要表示类名时,是不允许使用参数化类型的(允许使用数组和基本数据类型)。也就是说,List.class,String[].class,和int.class是合法的,但是List<String.class>,List<?>.class,List<String>.class则不合法。
另外一点跟instanceof操作符有关。由于泛型信息可以在运行时被擦除,因此在参数化类型而非无限制通配符类型上使用instanceof操作符是非法的。用无限制通配符类型代替原生态类型,其实并没有任何区别。此时无限制通配符类型反而显得多余。以下是规范而安全的写法:
if(o instanceof Set){
// 注意一旦确定o是set类型,就必须把它转换为无限制通配符类型Set<?>
// 而不是转换为原生类型Set,这是个受检转换,因此不会导致编译时警告
Set<?> s=(Set<?>) o;
...
}
所以综上所述,使用原生态类型会在运行时导致异常,除以上两种情况外不要使用。原生类型现在的最大作用就是为了与引入泛型前的遗留代码进行兼容和互用。新生的代码中集合容器无论是出于安全还是便利,都要尽可能使用泛型。
Map实现类从AbstractMap继承equals实现:
// Entry是Map接口中的局部内部接口,我们对其称为Map的键值对的视图
public boolean equals(Object o) {
if (!(o instanceof Map.Entry))
return false;
Map.Entry<?,?> e = (Map.Entry<?,?>)o;
return eq(key, e.getKey()) && eq(value, e.getValue());
}
Map的内容比较多,我们对基础知识做一下简单回顾:
首先Map是集合框架中的一个顶级接口,并不是一个真正意义上的集合。存储的键值对有如下规则:不能包含相同的key,每一个key至多能映射一个value。为了能够方便的操作实现了Map接口的集合,我们可以借助三种视图来完成,分别对应于key的集合,value的集合,key-value对(Entry)的集合。而接下来分析的Entry就是键值对的视图。
我们先看一下这个接口有哪些内容:
interface Entry<K,V> {
// 获取本键值对的key
K getKey();
// 获取本键值对的value,注意的是获取值,与获取键两个方法在特殊情况比如
// 在映射被移除时(通过迭代器的remove方法)有可能抛出IllegalStateException
V getValue();
// 对本键值对赋值 ,映射变化有可能抛好几种异常,反正没充分把握不要在改变映射后调用
V setValue(V value);
// 比较键值对,建议的实现如下:
// if(
// (e1.getKey()==null ?
// e2.getKey()==null : e1.getKey().equals(e2.getKey())) &&
// (e1.getValue()==null ?
// e2.getValue()==null : e1.getValue().equals(e2.getValue()))
// )
boolean equals(Object o);
// 返回哈希值,Map的哈希值定义是:二者求异或值
// (e.getKey()==null ? 0 : e.getKey().hashCode()) ^
// (e.getValue()==null ? 0 : e.getValue().hashCode())
// 这才能保证两个条目相等时,通过Object.hashCode方法得到的他们的哈希值也一定是相等的.
int hashCode();
// 以下几个方法是static方法,它们是有方法体的。附:java8允许接口中定义有方法体的static方法
// 但static方法必须是public修饰的,java9中允许使用private方法了,但私有方法必须有方法体
// 返回一个比较map.entry的外比较器,按照key的自然顺序排序,同时这个比较器支持序列化
// 传入参数K必须支持Comparable接口,因为需要按照key排序(关于比较器的内容请参考我的其他博文)
// 如果map中的entry有key=null情况,则抛出空指针异常(因为返回结果要按照key排序)
// 值得注意的是(Comparator<Map.Entry<K, V>> & Serializable)
// 表示将结果强制转换为一个实现了Serializable接口的Comparator对象
// 这是Java8的语法,表示同时强制转换为多种类型,必须同时满足全部类型才不抛异常
// 请注意,在进行该类转换时,只能指定一个类(以及无限量的接口),因为 Java 不支持类继承多个类
public static <K extends Comparable<? super K>, V> Comparator<Map.Entry<K,V>> comparingByKey() {
return (Comparator<Map.Entry<K, V>> & Serializable)
(c1, c2) -> c1.getKey().compareTo(c2.getKey());
}
// 同理,只不过把key变成value
public static <K, V extends Comparable<? super V>> Comparator<Map.Entry<K,V>> comparingByValue() {
return (Comparator<Map.Entry<K, V>> & Serializable)
(c1, c2) -> c1.getValue().compareTo(c2.getValue());
}
// 传入一个比较器,根据传入比较器对key排序.
// 如果传入的比较器支持序列化,则返回的结果比较器也支持序列化
public static <K, V> Comparator<Map.Entry<K, V>> comparingByKey(Comparator<? super K> cmp) {
Objects.requireNonNull(cmp);
return (Comparator<Map.Entry<K, V>> & Serializable)
(c1, c2) -> cmp.compare(c1.getKey(), c2.getKey());
}
// 同上
public static <K, V> Comparator<Map.Entry<K, V>> comparingByValue(Comparator<? super V> cmp) {
Objects.requireNonNull(cmp);
return (Comparator<Map.Entry<K, V>> & Serializable)
(c1, c2) -> cmp.compare(c1.getValue(), c2.getValue());
}
}
了解这些功能的基本意义后,我们看一下Map里的源码:
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
Node(int hash, K key, V value, Node<K,V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
public final K getKey() { return key; }
public final V getValue() { return value; }
public final String toString() { return key + "=" + value; }
public final int hashCode() {
return Objects.hashCode(key) ^ Objects.hashCode(value);
}
public final V setValue(V newValue) {
V oldValue = value;
value = newValue;
return oldValue;
}
public final boolean equals(Object o) {
if (o == this)
return true;
if (o instanceof Map.Entry) {
Map.Entry<?,?> e = (Map.Entry<?,?>)o;
if (Objects.equals(key, e.getKey()) &&
Objects.equals(value, e.getValue()))
return true;
}
return false;
}
}
- 类是内部类并且用private修饰,或者类是外部类并且是包访问权限,同时可以确定它的equals方法永远不会调用,自然不需要重写equals方法。而如果想要规避风险,可以覆盖equals方法,来确保它不会被意外调用:
@Override
public boolean equals(Object o) {
// AssertionError 表示断言失败,即认定不会发生的事却发生了,起警示作用
throw new AssertionError();
}
6 .另外值得注意的是有一种值类不需要覆盖equals方法,即实例创建受控同时确保每个值只存在一个对象的类。这种类常见的有单例类和枚举类。单例类很好理解,只能产生一个对象,而一个对象自然与自身相等。而枚举类是一种不可变值的类,而且确保不会存在两个相等的实例,即当且仅当a==b,a.equals(b)才为true(这也是享元模式的基础)。而我们知道枚举类全都继承自Enum类,查看Enum类源码发现它重写了equals方法:
public final boolean equals(Object other) {
return this==other;
}
其实仅仅是把Object类的equals方法在声明上改为了final防止子类重写而已,其行为是一样的,此时逻辑相等和对象等同是可以划等号的。
啰嗦了这么多下面看一下怎么正确重写equals方法。
equals方法的重写规则
对于重写的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)也必须返回ture。
一致性:对于任何非null的引用值x和y,只要equals的比较操作在对象中所用的信息没有被修改,
多次调用x.equals(y)就会一致的返回ture,或者一致的返回false。
非空性:对于任何非null的引用值x,x.equals(null)必须返回false。
下面逐步讲解这几条规则:
- 自反性:要求一个对象必须等同于自身。违背这一条会产生以下情况:将该类的实例添加到集合中,则集合的contains方法将告诉你,不包含刚刚添加的的实例。
注: 在实际使用具体的集合类中,是可能重写contains方法的(提高效率或者改进功能),上文中的contains方法只是提供一个示范
- 对称性:要求任何两个对象对于它们是否相等的的结果都必须保持一致,可以看如下的例子:
public class CaseString {
private final String s;
public CaseString(String s) {
/*Objects的这方法意义就是判断一个T对象是否为空,为空时抛出空指针异常,非空则返回自身
public static <T> T requireNonNull(T obj) {
if (obj == null)
throw new NullPointerException();
return obj;
}
*/
this.s = Objects.requireNonNull(s);
}
@Override
public boolean equals(Object o) {
if(o instanceof CaseString)
return s.equalsIgnoreCase(((CaseString) o).s);
if(o instanceof String){
return s.equalsIgnoreCase((String) o);
}
return false;
}
}
在这个例子中,equals方法意图很好,企图将一个CaseString对象与一个String对象进行忽略大小写的比较。我们进行以下测试:
CaseString a=new CaseString("Tom");
String b="tom";
System.out.println("a.equals(b)="+a.equals(b));
System.out.println("b.equals(a)="+b.equals(a));
结果:
a.equals(b)=true
b.equals(a)=false
显然这违反了对称性规则,CaseString的equals方法知道要比较普通的字符串对象,而String类中的方法却已经定型,只能比对String类(String是final类不可能有子类)对象。
// String类的equals方法会放到String类源码中分析,这里只做简单了解:
public boolean equals(Object anObject) {
if (this == anObject) {
return true;
}
if (anObject instanceof String) {
String anotherString = (String)anObject;
int n = value.length;
if (n == anotherString.value.length) {
char v1[] = value;
char v2[] = anotherString.value;
int i = 0;
while (n-- != 0) {
if (v1[i] != v2[i])
return false;
i++;
}
return true;
}
}
return false;
}
而我们一旦写出如下代码:
List<CaseString> list=new ArrayList<>();
list.add(a);
System.out.println(list.contains(b));
结果:
false
在当前OpenJDK中,返回false,但这只是当前版本的特定实现而已。在其他的实现中,很有可能会返回false,或者抛出一个运行时异常。这显然不是一种可控的可靠行为。所以我们总结如下一条经验:
那么如何解决这个问题,其实也很简单,将企图与String类进行比较的这部分代码删除掉而仅仅保留与CaseString比较的部分:
@Override
public boolean equals(Object o) {
return o instanceof CaseString&& s.equalsIgnoreCase(((CaseString) o).s);
}
- 传递性:他的定义非常简单,类似于数学上的很多传递性的定理。但是在我们使用子类时,将一些新的值的组件添加进去,即子类增加的信息会影响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 (this == o) return true;
if (!(o instanceof Point)) return false;
Point point = (Point) o;
return x == point.x &&
y == point.y;
}
}
这是一个Point类,仅仅有两个属性,分别为x,y轴坐标,而如果我们扩展这个类,在子类ColorPoint中添加颜色信息:
public class ColorPoint extends Point{
// 原书用的是color属性是枚举类,这里为了简化问题,用String类,最好的选择自然还是枚举
private final String color;
public ColorPoint(int x, int y, String color) {
super(x, y);
this.color = color;
}
}
那么子类的equals方法要怎么写呢?我们当然可以直接使用从父类继承而来的equals方法,但这样进行比较的时候颜色信息就完全被忽略掉了,虽然这样不违背equals的规定,但无法实现我们所需的功能也是不可接受的。那么如果我们这么重写equals方法,只有它的参数是一个有色点类型,且具有相同的位置和颜色,才会返回true:
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof ColorPoint)) return false;
return super.equals(o)&&((ColorPoint) o).color.equals(color);
}
这种方式的问题在于,在比较普通点和有色点,有色点和普通点,这两种情况时会产生不同的结果。前一种会忽略颜色的信息,后一种总是会返回false。第二种情况的问题发生在!(o instanceof ColorPoint),在instanceof中o会被强转为ColorPoint,相当于父类转子类,会发生类型转换的异常。在之前讲instanceof中提到过
catch (ClassCastException e) {
result = false;
}
!false为真,所以equals方法会永远返回false。这显然违背第第二条规则,我们做出以下尝试来修正这个问题,让ColorPoint的equals方法在进行混合比较时,忽略颜色信息:
@Override
public boolean equals(Object o) {
if (this == o) return true;
if(!(o instanceof Point)) return false;
// 这一步是关键,当o是Point及其子类类型而非ColorPoint及其子类类型时,
// 选择o的确切类型的equals方法的返回值,这样即可巧妙地在比较中忽略掉ColorPoint的color属性。
if (!(o instanceof ColorPoint))
return o.equals(this);
return super.equals(o)&&((ColorPoint) o).color.equals(color);
}
毋庸置疑,这种形式确实提供了对称性,但却牺牲了传递性:
Point a=new Point(1,2);
ColorPoint b=new ColorPoint(1,2,"red");
ColorPoint c=new ColorPoint(1,2,"blue");
System.out.println("c.equals(a)="+c.equals(a));
System.out.println("a.equals(b)="+a.equals(b));
System.out.println("c.equals(b)="+c.equals(b));
结果:
c.equals(a)=true
a.equals(b)=true
c.equals(b)=false
前两种比较都忽略了颜色的信息,返回都是true,而第三种则有颜色信息的判断,自然返回false了。
同时如果考虑到Point类还有一个子类SizePoint,两个子类各自都带有这种类型的equals方法,那么当SizePoint对象调用参数为ColorPoint类型的equals方法时,会导致无限递归继而抛出StackOverflowError异常。
话说这么多,如何解决这个问题呢?事实上,这是面向对象语言中对于等价关系的一个本质问题。
有一种比较流行的方式是在equals方法中不使用instanceof转而使用getclass(返回对象的运行时类,下面会讲),表面上可以解决上面红字中的矛盾:
// 这是Point类中的方法,如果是ColorPoint类,则只需要把Point p=(Point)o;替换为ColorPoint cp=(ColorPoint)o;
// 然后调用父类的equals方法比较父类的域同时比较自身的color域,然后取逻辑与操作就可以了
@Override
public boolean equals(Object o) {
if (this == o) return true;
if(o==null||o.getClass()!=this.getClass())
return false;
Point p=(Point)o;
return p.x==x&&p.y==y;
}
但仔细观察会发现,这样只有当两个比较对象的实现类相同时,才能使对象等同。所以如果我们使用Point类对象调用参数为ColorPoint对象的equals方法或反过来调用时,它们的结果永远是false。而我们回顾一下设计模式中的里氏替换原则:
一个类型的任何重要属性也应该对所有它的子类有效,以便为类型编写的任意方法,同样应该在它的子类上表现出的行为一致。
大白话其实就是: 一个软件如果使用的是一个父类的话, 那么一定适用于其子类, 而察觉不出父类对象和子类对象的区别。 也即是说,在软件里面, 把父类替换成它的子类, 程序的行为不会有变化, 简单地说, 子类型必须能够替换掉它们的父类型。
也就是说如果我们这么改写,那么假设有Point a,Point b,ColorPoint c三个对象,我们调用a.equals(b)自然是运行良好,有可能为true也有可能为false。但根据里氏替换原则,我们将a替换为c,c.equals(b)将永远为false。这样一来父类的行为特性,在用子类替换之后毫无疑问是发生变化了的。而里氏替换原则的意义, 使得继承复用成为了可能。即只有当子类可以替换掉父类, 软件单位的功能不受到影响时,父类才能真正被复用, 而子类也能够在父类的基础上增加新的行为。
所以虽然用这种形式实现了此时我们需求的功能,但却违背了这条原则,就是捡了芝麻丢了西瓜的举措。那么说了这么多,有没有什么更好的方案呢?
可以明确的是,虽然没有一种完美的方法既可以扩展不(怀疑是原文印错了,应该删掉“不”字)可实例化的的类,又增加值组件的方案,但还是有一种不错的权宜之计:我们使用复合而非继承去扩展一个类。具体到上面的例子,我们不再让ColorPoint类继承Point类,而选择在ColorPoint中加入一个私有的Point域,以及一个公有的视图方法:
public class ColorPoint {
private final Point point;
private final String color;
public ColorPoint(int x, int y, String color) {
point=new Point(x,y);
this.color = Objects.requireNonNull(color);
}
public Point asPoint(){
return point;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o==null||!(o instanceof ColorPoint))
return false;
ColorPoint cp=(ColorPoint) o;
return cp.point.equals(point)&&cp.color.equals(color);
}
}
也就是说我们在调用equals方法的时候,如果想采取的策略是Point和Point对象对比,ColorPoint和ColorPoint对象对比,那么直接拿来用就够了。如果要加上Point和ColorPoint(或者相反)的对比的功能(忽略ColorPoint新增的属性),那么可以使用Point对象.equals(ColorPoint对象.asPoint())的形式来完成。如下:
ColorPoint a=new ColorPoint(1,1,"red");
Point b=new Point(1,1);
System.out.println("a.equals(b)="+a.equals(b));
System.out.println("b.equals(a)="+b.equals(a));
System.out.println("b.equals(a.asPoint())="+b.equals(a.asPoint()));
System.out.println("a.asPoint().equals(b)="+a.asPoint().equals(b));
结果:
a.equals(b)=false
b.equals(a)=false
b.equals(a.asPoint())=true
a.asPoint().equals(b)=true
完美!
还有一点需要注意的是,以下为原文:
还需要补充的一点是,我们可以在一个抽象类的子类中添加新的值组件而不违反equals约定。比如一个抽象的Shape类无任何值组件,Circle子类添加了一个radius域,Rectangle子类添加了length和width域。那么我们只需要在两个子类中单独重写equals方法,而且只比较本类型的域即可。而Shape因为是抽象类是无法实例化的,equals方法运行良好。
思考:如果抽象类Shape本身有值组件呢?首先因为Shape如果无值组件,重写equals并无意义。如果有值组件,因为抽象类无法实例化,所以我们可以确保调用的equals方法一定是子类实现的方法而非Shape重写的(如果有的话),也就是说能保证equals方法的行为符合预期。那么我们只需要在实现的时候注意这几条规则就够了。
继续~~
-
一致性:要求a.equals(b)在这两个对象的内容不发生变化的前提下,它们的比较结果应该是不会发生变化的。也就是说,可变对象的equals方法返回值是有可能发生变化的(谁让它可变呢?),而两个不可变对象比较的结果则绝对不能发生变化。所以这要求我们应首先考虑本类是否是一个不可变类。如果认为它是不可变的,就必须保证这一点:相等的对象始终相等,不相等的对象始终不相等。
而在此之上,
一旦违背此规定,想要满足一致性的要求是及其困难的。例如,java.net.URL 类中的 equals 方法依赖于与 URL 关联的主机的 IP 地址的比较。将主机名转换为 IP 地址可能需要访问网络,并且不能保证随着时间的推移会产生相同的结果。这可能会导致 URL 类的 equals方法违反 equals 约定,并在实践中造成问题。
URL的equals方法的行为是一个大的错误,不应该模仿。不幸的是,由于兼容性要求,这一行为无法被改变。为了避免这样的问题,equals方法应该仅仅对驻留内存的对象执行确定性的计算。 -
非空性:要求所有被比较的对象都不能为空。很难想象何时对o.equals(null)的调用会意外的返回true,但是不难想象意外地抛出一个NullPointerException。为了防止这种情况发生,很多类在equals方法中,用一个显式的null检测:
@Override
public boolean equals(Object o) {
if(o==null)
return false;
...
}
但其实这项测试很多时候是非必要的。因为我们在比较值域前,都会做一个将Object类型的参数转成我们需要的类型的操作。在进行转换之前,一定会使用instanceof操作符或者getclass方法来确定是否可行。
@Override
public boolean equals(Object o) {
if(!(o instanceof MyType))
return false;
MyType mt=(MyType) o;
...
}
而在之前讲到obj instanceof T 只要发现obj是null,就会直接返回false,所以equals方法直接以false作为返回值结束了,既不会在之后类型转换中抛出异常,也符合obj为null时我们对equals方法结果的规定。所以当我们使用instanceof关键字进行类型判断时,null测试是非必须的。但是如果我们选择使用getclass时,一定记得加上,否则会抛出空指针异常。
那么我们综合以上几条原则,总结一下实现高质量equals方法的诀窍:
-
使用==操作符检查参数是否是这个对象引用本身。如果是,则返回true。这并不是逻辑上必须的,但是一种性能的优化,比较操作有时是比较昂贵的,这么做百利而无一害。
-
使用instanceof操作符检查参数是否是正确的类型,如果不是,则返回false。一般而言,正确的类型是 equals 方法所在的那个类。 某些情况,是指该类所实现的某个接口。如果类实现了的接口改进了 equals 约定,从而允许实现该接口的类之间进行比较,那么应该使用该接口。 一些集合接口(如 Set,List,Map 和 Map.Entry)具有此特性。在介绍equals方法的开头我们就看到了Set,List,Map实现类,不记得了可以回到前面看一下。
-
把参数转换成正确的类型。如果能有上一步的保证,必然不存在任何问题。但不一定强转为instanceof的第二个操作数。
-
对于该类中每个关键域,检查参数中的域是否与对象中对应的域相匹配。只有全部都匹配,则返回true,否则返回false。如果第二步中的类型是个接口,则必须通过接口方法访问参数中的域;如果该类型是个类,直接还是使用方法访问取决于类中各个域的可见性。
这一点我们说一些细节知识:对于既不是float也不是double类型的基础类型域,可以直接使用==操作符进行比较;对于对象引用域,可以递归的调用equals方法;对于float域,可以使用静态Float.compare(float,float)方法;对于double域,则使用Double.compare(double,double)方法。
注意:对于浮点数不感兴趣可以略过下面的内容,只需要知道浮点数作比较只能比大小不能比==。
首先看一下java语言规范se8版本的官方说明:
image.png
关于IEEE754标准就不赘述了,csapp第二章讲的非常清楚。
首先我们看一下为什么不建议用包装类自己重写的equals方法进行比较。
public boolean equals(Object obj) {
return (obj instanceof Float)
// value就是包种类存储的浮点值,在构造函数里初始化
// private final float value;
&& (floatToIntBits(((Float)obj).value) == floatToIntBits(value));
}
// 调用了一个读名字大概意思是float转化为int的方法进行==操作,我们接着看
public static int floatToIntBits(float value) {
// 这里又调用了一个用native修饰的新方法
// public static native int floatToRawIntBits(float value);
// 我们查阅官方文档,发现这个native方法有如下说明:
// 根据 IEEE 754 浮点“单一格式”位布局,返回指定浮点值的表示形式,并保留非数字 (NaN) 值。
// 第 31 位(掩码 0x80000000 选定的位)表示浮点数的符号。第 30-23 位(掩码 0x7f800000 选定的位)
// 表示指数。第 22-0 位(掩码 0x007fffff 选定的位)表示浮点数的有效位数(有时也称为尾数)。
// 如果参数为正无穷大,则结果为 0x7f800000。如果参数为负无穷大,则结果为 0xff800000。
// 如果参数为 NaN,则结果是表示实际 NaN 值的整数。与 floatToIntBits 方法不同,
// floatToRawIntBits 不压缩所有将 NaN 编码为一个“规范”NaN 值的位模式。
// 在所有情况下,结果都是一个整数,将其赋予 intBitsToFloat(int) 方法将生成一个与 floatToRawIntBits 的参数相同的浮点值。
// 也就是说floatToIntBits方法主要先通过调用floatToRawIntBits获取到IEEE 754标准对应的整型数,然后再分别用FloatConsts.EXP_BIT_MASK
// 二进制数为01111111100000000000000000000000,取阶码用
// 和FloatConsts.SIGNIF_BIT_MASK 二进制数为11111111111111111111111(前面全为0)取尾数用
// 两个掩码去判断是否为NaN,我们知道阶码全为1,尾数不为0则为非数,0x7fc00000对应的即为NaN。
int result = floatToRawIntBits(value);
// Check for NaN based on values of bit fields, maximum
// exponent and nonzero significand.
if ( ((result & FloatConsts.EXP_BIT_MASK) ==
FloatConsts.EXP_BIT_MASK) &&
(result & FloatConsts.SIGNIF_BIT_MASK) != 0)
result = 0x7fc00000;
return result;
}
那么我们回归到equals方法,发现一个问题,就是说两个NaN的浮点数,它们转化为int类型都是0x7fc00000,做==运算自然是true。也就说此时对于两个float类型中有NaN的话,equals方法和==的语义是有所不同的。我们再看一下Float的compare方法:
public static int compare(float f1, float f2) {
// 这两行代码就是说两个变量都不是非数,通过<和>运算符找出较大值和较小值
if (f1 < f2)
return -1; // Neither val is NaN, thisVal is smaller
if (f1 > f2)
return 1; // Neither val is NaN, thisVal is larger
// 上一步是在变量中无非数情况下,找出大小值,而这一步是在考虑有非数情况下
// 得出大于等于小于三种情况的结果
// 注意这里的官方注释,看来oracle官方故意使用floatToIntBits而不是 floatToRawIntBits就是
// 为了能够让NaN值能比较出一个为0的结果
// 在比较时使用一个嵌套的三目运算
// Cannot use floatToRawIntBits because of possibility of NaNs.
int thisBits = Float.floatToIntBits(f1);
int anotherBits = Float.floatToIntBits(f2);
return (thisBits == anotherBits ? 0 : // Values are equal
(thisBits < anotherBits ? -1 : // (-0.0, 0.0) or (!NaN, NaN)
1)); // (0.0, -0.0) or (NaN, !NaN)
}
我们在此还可以做一个测试:
// 两个非数,但二进制表示是不同的
Float k1=Float.intBitsToFloat(0xff80ffff);
Float k2=Float.intBitsToFloat(0xff800001);
System.out.println("k1="+k1+","+"k2="+k2);
System.out.println("k1==k2 "+(k1==k2));
System.out.println("Float.compare(k1,k2) "+Float.compare(k1,k2));
System.out.println("k1.equals(k2)"+k1.equals(k2));
System.out.println(Float.floatToRawIntBits(k1));
System.out.println(Float.floatToRawIntBits(k2));
System.out.println(Float.floatToIntBits(k1));
System.out.println(Float.floatToIntBits(k2));
结果:
k1=NaN,k2=NaN
k1==k2 false
Float.compare(k1,k2) 0
k1.equals(k2)true
-8323073
-8388607
2143289344
2143289344
那么回到正题,其实compare和equals在功能上是一致的(compare唯一多的就是能比较大小)。
对于数组域,我们应该把以上原则应用到每个元素上。有一种简便方法就是使用Arrays.equals方法,将两个数组放进去比较(但这只限于数组元素是基本数据类型或对应包装类型)。
有些对象的引用域是可能包含null的,但为了避免可能出现的空指针异常,使用静态方法Objects.equals(Object,Object)来检查这类域的等同性。
// 其实就是对a和a2检查是否为null,然后挨个对两个Object数组中的元素检查
// 核心是这一行代码:o1==null ? o2==null : o1.equals(o2),表达了以下语义:
// o1和o2都为空或者o1,o2都不为空且o1.equals(o2)为真,则返回true,其余情况都为false
// 这么写的好处在于只有o1不为空,才会使用o1.equals(o2),没有前面的条件会抛异常
// 另外如果将 if (!(o1==null ? o2==null : o1.equals(o2)))
// return false;
// 改写为 return o1==null ? o2==null : o1.equals(o2);可以么?
// 显然是不可以的程序会在第一次循环中返回,逻辑就错误了
public static boolean equals(Object[] a, Object[] a2) {
if (a==a2)
return true;
if (a==null || a2==null)
return false;
int length = a.length;
if (a2.length != length)
return false;
for (int i=0; i<length; i++) {
Object o1 = a[i];
Object o2 = a2[i];
if (!(o1==null ? o2==null : o1.equals(o2)))
return false;
}
return true;
}
对于有些类,域的比较比简单的等同性测试复杂的多。此时可能希望保存该域的一个范式,这样equals方法就可以根据这些范式进行低开销的精确比较而不是高开销的非精确比较较。这种方法对于不可变类是最为合适的。对可变类来说,一旦对象发生变化,就必须保证其范式更新。
我说一下我的理解。这就好像我们对一系列信息使用诸如SHA256之类的hash算法一样,比对信息的时候不需要逐字逐句的每个值域都equals,而直接比较最后算法生成的密文,相同则为true,不同则为false。因为这样仅仅是比较一次就够了,效率肯定是提高非常多的。在这里的范式,其实就是简单的算法,能保证值域的变化引发范式的变化,而两个对象值域相同,它们的范式一定相同,值域有一点不同,范式则一定不同。
域的比较顺序可能会影响equals方法的性能。为了更好的性能,应该先比较最有可能不一致的域,或者开销最低的域,最理想的情况时连个条件同时满足的域。你不应该去比较那些不属于对象逻辑状态的域,例如用于同步操作的Lock域。衍生域也不是必须要比较的,因为它们可以通过关键域计算获得(比如在一个圆中,在比较过半径之后,面积的比较就不是必须的了)。但是有时衍生域代表了对于整个对象的综合描述,比较这个域如果开销较小,那么就可以在比较失败后时不需要再去比较关键域了(如果关键域的比较是一件很麻烦的事),此时反而能提升equals方法的性能。(比如两个多边形一旦面积不一致,我们直接就可以认为它们不相等了,而如果一开始就比较它们的各个点的位置是否一致,反很浪费时间。)
那么最后需要注意的几点是:
- 覆盖equals时必须覆盖hashCode
- 不要企图让equals过于智能。只是简单的测试域中的值是否相等,其实equals方法还是很容易写,也不容易违背上述规则的。但如果想要过度地去寻求各种等价关系,很容易陷入麻烦中。把任何一种别名形式考虑到等价范围内,其结果往往是因小失大。
- 不用将equals方法中的Object对象替换为其他类型。我们看如下形式:
public boolean equals(MyClass o) {
...
}
细心的朋友会发现其实@override都消失了,所以其实我们并没有覆盖Object.equals方法,而在继承的基础上又重载了一个参数是MyClass类型的equals方法,所以有时找好几个小时错误都不知道原因所在。
我们建议使用Google开源的AutoValue框架,它自动生成的很多方法大部分时候都比IDE的更加智能,可读性也更好,同时可以自动追踪类中的变化。
hashCode()方法(写的不好,待编辑)
hashCode 在 Object 类中定义如下:
public native int hashCode();
这也是一个用 native 声明的本地方法,作用是返回对象的散列码,是 int 类型的数值。
本方法的意义在于如果比较两个对象是否相等,如果用没被重写的object类的equals方法还好,但如果重写后的equals方法比较繁琐,会造成比较大的性能损耗。比如在set集合中添加新元素,还set集合本身已经有n个元素的情况下,需要运行n次equals方法。而当我们优先选择使用hashCode()方法剔除那些hash值不等的元素,而发生hash碰撞即hash值相同时,再用equals方法比较,这样在效率上会提升很多。
所以总结来说:hashCode()被设计用来使得哈希容器能高效的工作。也只有在哈希容器中,才使用hashCode()来比较对象是否相等,但要注意这种比较是一种弱的比较,还要利用equals()方法最终确认。
我们把hashCode()相等看成是两个对象相等的必要非充分条件,把equals()相等看成是两个对象相等的充要条件。
因此,在自定义一个类的时候,我们必须要同时重写equals()和hashCode(),并且必须保证:
其一:如果两个对象的equals()相等,那么他们的hashCode()必定相等。
其二:如果两个对象的hashCode()不相等,那么他们的equals()必定不等。
顺便列一下阿里巴巴对于equals和hashCode方法的书写要求
- 只要重写 equals,就必须重写 hashCode;
- 因为 Set 存储的是不重复的对象,依据 hashCode 和 equals 进行判断,所以 Set 存储的对象必须重写这两个方法;
- 如果自定义对象做为 Map 的键,那么必须重写 hashCode 和 equals;(以上两条简而言之set集合和有hash字样的集合必须重写hashcode方法)
- String 重写了 hashCode 和 equals 方法,所以我们可以非常愉快地使用 String 对象作为 key 来使用;
如何重写hashCode()方法
下面介绍一下effective java第三版中对于hashCode()方法重写的规范:
- 声明一个int变量并命名为result,将它初始化为对象中第一个关键域(关键域指影响equals比较的域)的散列码c,执行下一步;
- 对于对象中的每个关键域f(非关键域一定要排除在外),完成如下步骤:
a.为该域计算int类型的散列码c;
i.如果该域是基本类型,则计算Type.hashCode(f),Type是指f的基本类型装箱后的包装类。
ii.如果该域是byte、char、short或者int类型,则计算(int)f。
iii.如果该域是long类型,则计算(int)(f^(f>>>32))。
iv.如果该域是float类型,则计算Float.floatToIntBits(f)。
v.如果该域是double类型,则计算Double.doubleToLongBits(f),然后按照步骤iii中所述,为得到的long类型值计算散列值。
vi.如果该域是一个对象引用,并且该类的equals方法通过递归的调用equals的方式来比较这个域,则同样为这个域递归的调用hashCode。如果这个域的值为null,则返回0。
vii.如果该域是一个数组,则要把每一个元素当作单独的域来处理,也就是说,递归地应用上述规则,对每个重要元素计算一个散列码,然后根据步骤2.b中的做法把这些散列值组合起来。也可以使用Arrays.hashCode方法。
b.按照下面的公式,把步骤(2)a中计算得到的散列码c合并到result中:
result = 31 * result + c。
- 返回result。
如果对我们之前提到的Fruit类重写hashCode方法,代码如下:
网友评论