Object是一个具体类,设计它主要是为了扩展,它所有的非final方法(equals、hashCode、toString、clone、finalize)都有明确的通用约定,任何一个类在重写这些方法的时候都要遵守这些通用约定,因为有很多类的功能(HashSet、HashMap等)是基于这些通用约定之上的。
本章内容导图:
1.覆盖equals时请遵守通用约定
不用覆盖equals的情形:
1.类的每个实例本质上都是唯一的。
对于像Thread这类代表活动实体而不是值的类,尤为如此。
2.不关心类是否提供了“逻辑相等”的测试功能。
像java.util.Random这样,提供equals毫无意义。
3.超类已经覆盖了equals,从超类继承过来的行为对于子类也是合适的。
例如,骨架类AbstractList、AbstractSet、AbstractMap。
4.类是私有的或是包级私有的,可以确定它的equals方法永远不会被调用。
何时需要覆盖equals?
如果类具有自己特有的逻辑相等概念,而且超类还没有覆盖equals以实现期望的行为,就需要覆盖equals方法。这些具有逻辑相等概念的类属于值类(表示值的类),如Integer、Date等。程序员在利用equals比较值对象的引用时,希望知道它们在逻辑上是否相等,而不是想了解它们是否指向同一个对象。
equals和==表示的概念不一样,对于两个对象引用A、B来说,
A == B
表示A、B指向同样的对象引用,而A.equals(B)
,是为了比较A、B逻辑相等的意思,即虽然A、B是不同的对象,但它们所代表的对象具有“值相等性”。
覆盖equals时,必须遵守equals的通用约定:
1.自反性
对于任何非null的引用值x,x.equals(x)必须返回true。
2.对称性
对于任何非null的引用值x和y,当且仅当y.equals(x)返回true时,x.equals(y)必须返回true。
3.传递性
对于任何非null的引用值x、y、z,如果x.equals(y)返回true,y.equals(z)返回true,那么x.equals(z)也必须返回true。
4.一致性
对于任何非null的引用值x、y,只要equals的比较操作在对象中所用的信息没有被修改,多次调用x.equals(y)就会一致地返回true,或者一致地返回false。
5.非空性
对于任何非null的引用值x,x.equals(null)必须返回false。
实现高质量equals的诀窍:
1.使用==操作符检查“参数是否为这个对象的引用”。
2.使用instanceof操作符检查“参数是否为正确的类型”。
3.把参数转换成正确的类型
4.对于该类中的每个“关键域”,检查参数中的域是否与该对象中对应的域相匹配。
5.编写完成equals后,检查它是否满足对称性、传递性、一致性。
其他一些注意事项:
1.覆盖equals时总要覆盖hashCode。
2.不要企图让equals方法过于智能。
3.不要将equals声明中的Object对象替换为其他的类型。
2.覆盖equals时总要覆盖hashCode
在每个覆盖了equals()的类中,也必须覆盖hashCode()。如果不这样做的话,就会违反Object.hashCode()的通用约定,从而导致该类无法结合所有基于散列的集合(HashMap、HashSet、Hashtable)一起正常运作。
Object中hashCode的规范约定:
1.在应用程序的执行期间,只要对象的equals()中所用到的信息没有被修改,那么对这同一个对象调用多次hashCode()都必须始终返回同一个整数。在同一个应用程序的多次执行过程中,每次执行所返回的整数可以不一致。
2.如果两个对象根据equals(Object)比较是相等的,那么调用这两个对象中任意一个对象的hashCode()都必须产生同样的整数结果。
3.如果两个对象根据equals(Object)比较是不相等的,那么调用这两个对象中任意一个对象的hashCode(),则不一定要产生不同的整数结果。但给不相等的对象产生截然不同的整数结果,可提高散列表的性能。
覆盖equals()而没有覆盖hashCode()违反的是规范的第二条。两个截然不同的实例在逻辑上有可能是相等的(即通过equals比较值相等),如果不覆盖hashCode(),则返回不同的整数结果,其便不能与基于散列的集合一起正常运作,如下面的代码:
public class PhoneNumber {
private final short areaCode;
private final short prefix;
private final short lineNumber;
public PhoneNumber(int areaCode, int prefix, int lineNumber) {
this.areaCode = (short) areaCode;
this.prefix = (short) prefix;
this.lineNumber = (short) lineNumber;
}
@Override
public boolean equals(Object obj) {
if (obj == this) {
return true;
}
if (!(obj instanceof PhoneNumber)) {
return false;
}
PhoneNumber pn = (PhoneNumber) obj;
return pn.lineNumber == lineNumber
&& pn.prefix == prefix
&& pn.areaCode == areaCode;
}
public static void main(String[] args) {
Map<PhoneNumber, String> map = new HashMap<>();
//放入一个对象实例
map.put(new PhoneNumber(707, 867, 5309), "Jenny");
//获取时又新建了一个实例,但同放入的实例通过equals()比较相等
System.out.println(map.get(new PhoneNumber(707, 867, 5309))); //结果为null
}
}
每个对象实例都具有同样的散列码的话,会使得对象都被映射到同一个散列桶中,使散列表退化为链表,对性能产生极大的影响。
一个好的散列函数通常倾向于为不相等的对象产生不相等的散列码(即是hashCode规约中的第三条)。理想情况下,散列函数应该把集合中不相等的实例均匀地分布到所有可能的散列值上。达到理想的情形是比较困难的,一种好的解决办法为:
1.把某个非零的常数值,比如说17,保存在一个名为result的int类型的变量中。
2.对于对象中的每个关键域f(equals方法中涉及的每个域),完成以下步骤:
a.为该域计算int类型的散列码c(不同的域类型,散列码的计算方法不同);
b.按照下面的公式,把步骤a中计算得到的散列码c合并到result中:result = 31 * result + c;
3.返回result。
4.写完了hashCode方法之后,问问自己“相等的实例是否都具有相等的散列码”。要编写单元测试来验证你的判断。如果相等的实例有着不相等的散列码,则要找出原因,并修正错误。
在散列码的计算过程中,可以把冗余域(一个域的值可以根据参与计算的其他域值计算出来)排除在外。必须排除equals比较计算中没有用到的任何域,否则很有可能违反hashCode规约的第二条。
根据上述方法,重写PhoneNumber
的hashCode()
:
@Override
public int hashCode() {
int result = 17;
result = 31 * result + areaCode;
result = 31 * result + prefix;
result = 31 * result + lineNumber;
return result;
}
如果一个类是不可变的,并且计算散列码的开销也比较大,就应该考虑把散列码缓存在对象内部:
private volatile int hashCode;
@Override
public int hashCode() {
int result = hashCode;
if (result == 0) {
result = 17;
result = 31 * result + areaCode;
result = 31 * result + prefix;
result = 31 * result + lineNumber;
hashCode = result;
}
return result;
}
不要试图从散列码计算中排除掉一个对象的关键部分来提高性能。
3.始终要覆盖toString
toString的通用约定指出:被返回的字符串应该是一个简洁的、信息丰富的、且易于阅读的表达形式,建议所有的子类都覆盖这个方法。
在实际应用中,toString方法应该返回对象中包含的所有值得关注的信息,应该是可自描述的。
4.谨慎地覆盖clone
Cloneable接口的目的是作为对象的一个mixin接口,表明这样的对象是允许克隆的,但它没有提供clone(),并没有达到这样的目的。它被设计成了一个标记接口,决定了对象中clone()的行为:如果一个类实现了Cloneable接口,就应该实现clone(),返回对象实例的逐域拷贝;否则就会抛出CloneNotSupportedException异常。
clone要根据具体需求来决定是进行浅拷贝还是深拷贝。
如果实现Cloneable接口的类是线程安全的,则clone()必须要进行同步,保证其线程安全性。
扩展一个实现了Cloneable接口的类,就必须实现clone()。否则,最好是提供某些其它途径来代替对象拷贝,或者不提供拷贝的功能。
实现对象拷贝的其他途径:
1.拷贝构造器
public Yum(Yum yum)
2.拷贝工厂
public static Yum newInstance(Yum yum)
所有通用集合实现都提供了一个拷贝构造器,它的参数类型为Collection或Map。基于接口的拷贝构造器和拷贝工厂允许客户选择拷贝的实现类型,而不是强迫客户接受原始的实现类型,因此,也可以叫作转换构造器和转换工厂。例如,你有一个HashSet,希望把它拷贝成一个TreeSet,clone()无法提供这样的功能,但转换构造器就可以轻易实现:
new TreeSet(s)
。
所有实现了Cloneable接口的类都应该用一个公有的方法覆盖clone。此公有方法首先调用super.clone(),然后修正任何需要修正的域。一般情况下,这意味着要拷贝任何包含内部“深层结构”的可变对象,并用指向新对象的引用代替原来指向这些对象的引用。
5.考虑实现Comparable接口
一旦类实现了Comparable接口,它就可以跟许多泛型算法以及依赖该接口的集合实现进行协作。Java平台类库中的所有值类都实现了Comparable接口。如果你正在编写一个值类,它具有非常明显的内在排序关系,就应该考虑实现Comparable接口:
public interface Comparable<T> {
int compareTo(T t);
}
compareTo
方法中域的比较是顺序的比较,而不是equals
方法的等同性比较。
网友评论