Java中的所有类都继承自Object类,Object类中有许多通用的方法,这一章要讨论的是:对于Object类中的通用方法,我们的类要不要继承,以及继承时需要注意的事项。
第1条:equals(),覆盖时请遵守通用约定
首先看一下不需要覆盖的情况:
1.类的每个实例本质上是唯一的。(比如Static的,单例的等等),这样不需要特意覆盖equals方法,用Object类的equals()方法就足够了
2.不关心类是否实现了“逻辑相等”的测试功能。我们用equals的目的就是判断两个对象是否是“逻辑相等”的,比如String类的值是否相等。如果这个类并不需要这个功能,那么我们自然没必要覆盖equals()方法。
3.超类已经覆盖了equals()方法,且从超类继承过来的行为对子类也是合适的。
4.有一种“值类”不需要覆盖,即实例受控类,确保“每个值之多只存在一个对象”,枚举类型就是这种类,对于这样的类,逻辑相等与对象相等是一回事,所以不需要覆盖equals()方法
那么什么时候需要覆盖呢?
如果类有自己特有的“逻辑相等”的概念,而且父类还没有覆盖equals以实现期望的行为,这时我们就需要覆盖equals()方法了。这通常属于“值类”。值类通常是一个只表示值的类,如Integer,程序员在用equals比较时仅仅希望知道它们的值是否相等,而不关心它们是否指向同一个对象。
equals方法需要实现“等价关系”,包含四个方面:
1.自反性。x.equals(x)必须始终返回true.
2.对称性。如果x.equals(y)=true,那么y.equals(x)也必须为true.
3.传递性。如果有x.equals(y)= true,y.equals(z) = true,那么也必有x.quals(z) = true.
4.一致性。只要值没被修改,那么多次调用x.equals(y)的值应该始终相等。
下面举一些违反了这4条的例子。
违反自反性:这种一般很少,因为这一条仅仅要求对象等于自身。如果违反了的话,假设你把一个对象加到一个集合里,然后查询,该集合的contains方法会告诉你集合中不存在这个元素。
违反对称性:考虑下面的类,它实现了一个区分大小写的字符串,但比较时不考虑大小写。
public final class CaseInsensitiveString{ private final String s; ... public boolean equals(Object o){ if( o instanceof CaseInsentiveString){ return s.equalsIgnoreCase( ((CaseInsentiveString) o).s); if( o instanceof String) //考虑了与普通String类的比较 return s.equalsIgnoreCase((String) o); return false; } }
看上去考虑的很好,比较时,考虑了传入的对象是本类和String类的情况。但是,假设我们有两个对象:
CaseInsensitiveString cis = new CaseIntensitiveString("Polish");
String s = "polish";
那么,显然cis.equals(s)=true。但是问题来了,s.equals(cis) = false。这就违反了对称性。
解决方法是,如果A类的equals方法中引入了对B类对象的比较,那么B类反过来也一定要引入对A类的比较。
违反传递性:违反这条一般是因为继承时子类添加了新的值对象。子类添加的信息会影响equals的比较结果。
比如我们有一个简单的Point类:
public class Point{ private final int x; private final int y; public Point(int x,int y){ this.x = x; this.y = y; } public boolean equals(Object o){ if(!(o instanceof Point)) return false; Point p = (Point) o; return this.x == p.x && this.y == p.y; } }
假设你想扩展这个类,为每个点加一个颜色信息:
public class ColorPoint extends Point{ private final Color color; public ColorPoint(int x,int y ,Color c){ super(x,y); color = c; } }
ColorPoint类的equals方法是怎样呢?有两种考虑:1.只和有色点比较,要x,y,color都相等才返回true,对于不是有色点的对象,返回结果始终是false
2.有色点也能够和普通点比较,如果和普通点比较,就比较x,y的值。
看第一种方法的实现:
public boolean equals(Object o){ if(!(o instance of ColorPoint)){ return false; return super.equalso) && ((ColorPoint) o).color == color; } }
这个方法的缺点是:如果有一个有色点对象cp(1,2,red)和一个普通点对象p(1,2),那么p.equals(cp) = true,cp.equals(p) = false。违反了对称性。
第二种方法:
public boolean equals(Object o){ if(!(o instanceof Point)) return false; if(!(o instanceof ColorPoint)) //如果对象不是colorPoint类的话,就用这个对象的equals方法来判断 return o.equals(this); return super.equalso) && ((ColorPoint) o ).color = color; }
这种方法实现了对称性,但是牺牲了传递性。考虑有色点cp1(1,2,red),cp2(1,2,blue)和普通点p(1,2)
有cp1.equals(p) = true ,p.equals(cp2) =true,但是cp1.equals(cp2)=false。违反了传递性。
我们无法在扩展可实例化的类的同时,既增加新的值组件,又保留equals约定。
怎么解决呢?
复合优先于继承。
我们不再继承Point类,而是新建一个ColorPoint类,在其中加入一个Point类的引用。
public class ColorPoint{ private final Point point; private final Color color; .... public boolean equals(Object o){ if(!(o instance of ColorPoint)){ return false; } ColorPoint cp = (ColorPoint) o; return cp.point.equals(point) && cp.color.equals(color); } }
因为ColorPoint 类不是Point的子类了,所以Point类中的equals方法不会与ColorPoint类进行比较,也就不会产生违反对称性的情况。
这样写的代码,普通点只能与普通点比较,有色点只能与有色点比较,不能互相比较。
因为从事实上来说,有色点和普通点其实是不可比的。
前面讲的是我们无法在扩展可实例化的类的同时,既增加新的值组件,又保留equals约定。那么不可实例化的类呢?
你可以在一个抽象类的子类中增加新的值组件,而不会违反equals约定。
也就是说,只要你的父类无法被实例化,前面说的问题就不会发生。因为这些问题都是由子类和父类的比较产生的。
违反一致性:一致性是说,如果两个对象相等,那么它们必须始终相等,除非其中一个对象(或两个)被修改了。java.net.URL的equals方法依赖于对URL中主机IP地址的比较,而随着时间的推移,可能会发生主机IP地址改变,那么equals方法的结果会变得不确定。所以说,无论类是否不可变,都不要使equals方法依赖于不可靠的资源。
下面讲一些实现高质量equals方法的诀窍:
1.用==操作符来检查“参数是否为这个对象的引用”(这个不太清楚,是说o == this 吗?)
2.使用instanceof操作符检查“参数是否为正确的类型”
3.把参数转化为正确的类型。(MyType) o;
4.对于该类中的每个“关键域”,检查参数中的域是否与该对象中对应的域相匹配。
对于非float,double的基本类型,可以用==判断。
对于float,double,用Float.compare和Double.compare.
对于对象引用,用equals方法。
另外,对各个域的比较顺序也会影响equals方法的性能。一般最先比较最有可能不一致的域,或者开销最低的域,不要比较冗余域(即可以通过其他关键域计算出来的)
5.编写完equals方法后,要问自己它是否是对称的,传递的,一致的。
最后是一些告诫:
1.覆盖equals时总要覆盖hashCode
2.不要企图让equals过于智能。如果过度的去寻求各种等价关系,可能会陷入麻烦中。
3.不要将equals方法声明中的Object类换成其他类型。不然会变成对equals方法的重载,而不是你想要的覆盖。
第2条:覆盖equals时总要覆盖hashCode
与Equals一样,hashCode方法也有一些约定:
1.在应用程序执行期间,只要对象的equals方法所用到的信息没被修改,那么对着同一个对象调用多次hashCode方法,都必须返回同一个值。在同一个程序的多次执行过程中,可以返回不同的值。
2.如果两个对象根据equals(Object)方法比较是相等的,那么调用这两个对象中任意一个对象的hashCode方法都必须产生同样的结果。
3.如果两个对象根据equals()方法的结果不一样,那么它们的hashCode方法不一定要产生不同的结果。
重要的是第二点。这说明了类的hashCode是根据equals方法中所比较的值来的。即使是两个不同的对象,只要它们是equals的,那么hashCode就应该是相等的(因为我们希望这样)。如果覆盖了equals方法的同时没有覆盖hashCode,那么对于不同的对象,它们的hashCode值始终是不同的。
我们希望两个equal的对象的hashCode相等,主要是出于使用HashMap的考虑。比如有一个存储电话号码的HashMap m:
Map<PhoneNumber,String> m = new HashMap<PhoneNumber,String>();
m.put(new PhoneNumber(707,867,5309),"jerry");
那么我们会期望m.get(new PhoneNumber(707,867,5309))会返回“jerry",这是符合我们的逻辑的,合理的结果。但是如果没有覆盖hashCode方法,这里实际上会返回null.
至于覆盖的hashCode方法怎么写,这里有一个参考方法:
1.把某个非0的常数值,比如17,保存在一个名为result的int类变量中。
2.对于对象中的每个关键域f(equal涉及的域)
a.如果该域是boolean类,则计算f?1:0
如果是byte,char,short或int,计算int(f)
如果是long,计算int(f^(f>>>32))
如果是float,计算Float.floatToIntBits(f)
如果是double,计算Double.doubleToLongBits(f),得到一个long,然后再对long计算散列值
如果是对象引用,递归调用hashCode
如果是一个数组,要对数组中的每个元素都进行以上处理。
b.把a中得到的散列码c按照下面公式合并到result中
result = 31*result + c;
3.返回result
举例:
public int hashCode(){ int result = 17; result = 31 * result + short1; result = 31 * result + short2; ... return result; }
第3条,始终要覆盖toString
提供一个好的toString方法可以使类使用起来更舒适。当对象被传递给println,pringf,字符串+操作等打印出来的操作时,会调用toString方法。
比如PhoneNumber@163b91和(707)867-5309,当然是后者看上去更加舒服,更容易理解。
覆盖toString有两个要点:
1.返回什么。toString 方法中应该返回对象中包含的所有值得关注的信息。
2.怎样返回。要做一个决定:是否在文档中指定返回值的格式。如果指定了格式,最好再提供一个相匹配的静态工厂或构造器,使得程序员能够容易的在对象和它的字符串表示法中来回切换。
当然了,这样也有缺点,指定了格式后扩展性变差,如果以后破坏了这种格式表示法会导致牵一发而动全身。
注意:无论是否指定格式,都要为toString返回值中的包含的所有信息,提供get方法。如果不这样的话,会使需要这些信息的程序员去解析一整串字符串来寻找他要的那个字段。这是不必要的工作,而且容易出错。
第4条:谨慎的覆盖clone
Object的clone方法是受保护的,也就是说,这个方法不是public的。如果一个类实现了Conleable接口,Object的clone方法会返回对这个对象的逐域拷贝。拷贝对象往往会导致创建它的类的一个新实例,但它同时也会要求拷贝内部的数据结构,这个过程没有调用构造器!这个过程没有调用构造器!这个过程没有调用构造器!(重要的话说3遍)
而一个行为良好的clone方法应该调用构造器来创建对象,再复制内部数据,如果这个类是final的,clone应该返回一个由构造器创建的对象。
来看一个只有3个short类型变量的PhoneNumber类
public PhoneNumber clone(){ try{ return (PhoneNumber) super.clone(); }catch(CloneNotSupprotedException e) { throw new AssertionError(); } }
如果你覆盖了一个非final类中的clone方法,则应该返回一个通过调用super.clone而得到的对象,如果类的所有父类都遵守这个规则,那么最终会调用到Object的Clone方法,从而创建出正确类的实例。
但是,如果对象中包含的域引用了可变的对象,使用上述简单的clone可能会导致灾难性的后果。考虑之前的Stack类
public class Stack{ private Object[] elements; private int size = 0; private static final int DEFAULT_INITIAL_CAPACITY;
public Stack(){
this.elements = new Object[DEFAULT_INITIAL_CAPACITY];
}
.... }
如果这个类实现Cloneable接口,但是它的clone方法中仅仅返回super.clone的话,那么新得到的Stack实例将引用原来的Stack实例的elements域。修改clone后的实例中的elements也会同时修改了原来的stack中的elements。而如果调用stack的构造器,这种情况肯定不会发生。
所以说,clone方法就是另一个构造器,你必须确保它不会伤害到原始的对象,并确保正确的创建被克隆对象的约束条件。
为了Stack类中的clone方法正确工作,它必须拷贝栈的内部信息,可以通过在elements中递归的调用clone方法来实现
public Stack clone(){ try{ Stack result = (Stack) super.clone(); result.elements = elements.clone(); return result; }catch(CloneNotSupportedException e){ throw new AssertionError(); } }
还要注意,如果elements域是final的,上面的方法就不能正常工作了。因为clone架构与引用可变对象的final域的正常用法是不相兼容的。(看到这里已经默默吐槽了……这个方法真是事儿逼)
总之,如果你的类实现了Cloneable接口想要实现克隆功能的话,首先,这个类必须有一个公有的方法覆盖clone()。然后这个公有方法调用super.clone完成对基本值的克隆,还要修正任何需要修正的域,也就是拷贝任何包含内部”深层结构"(比如数组)的可变对象,并用指向新对象的引用代替原来的引用。一定要注意这一点,不然可能在你不知情时,会发生修改克隆对象时也修改了原对象的悲剧。
既然Cloneable这么麻烦,我们可以不实现这个接口,提供其他的途径来代替对象拷贝,或者干脆不提供这种功能。
还有两种实现对象拷贝的好方法:提供一个拷贝构造器。这是一个构造器,唯一的参数类型是本类,比如public Yum(Yum y).或者拷贝工厂:public static Yum newInstance(Yum y).
这两种方法都比Cloneable/clone方法更好。
所以说,很多专家级的程序员从来不覆盖clone方法,也不调用(除了拷贝数组)。
第5条:考虑实现Comparable接口
Comparable接口中唯一的方法是compareTo,它与equals方法具有相似的特征。类实现了Comparable接口,就说明它的实例具有内在的排序关系。
如果你正在编写一个值类,它具有非常明显的内在排序关系,比如按字母排序,按值排序等,那么就应该考虑实现Comparable接口。
public interface Comparable<T>{ int compareTo(T t); }
compareTo方法的通用约定与equals类似:将这个对象与指定的对象进行比较。当该对象小于,等于或大于指定对象时,分别返回一个负整数,0或正整数。
对放在集合中的对象进行排序时,调用的就是这个对象的compareTo方法。依赖于比较关系的类包括有序集合类TreeSet,TreeMap,以及工具类Collections和Arrays,它们内部都有搜索和排序算法。
注意:
1.强烈建议compareTo的结果与Equals一致,意思是,如果a.compareTo(b) = 0 ,那么应该由a.equals(b)=true。这是符合正常思维的。
2.注意compareTo方法的参数是T,而不是Object.这一点与equals方法不一样,要注意区分。
3.建议如果要比较多个域,那么从最关键的域开始比较。
总结
这一章讲的是,创建自己的类的时候,对Object类中的方法的考虑。主要是equals方法,hashCode方法,toString,clone和Comparable接口。
对equals方法的建议是,覆盖时要遵守通用约定,保证对称性传递性一致性,同时如果覆盖了equals方法,也一定要覆盖hashCode方法。这是出于集合中对于相同元素的判断的考虑。
compareTo方法则是出于集合中元素比较和排序的考虑。如果你的类要存放在有序集合中,那么要覆盖compareTo方法。
覆盖toString方法则是为了更好的可读性和可理解性,建议始终覆盖。
clone方法是不被建议的,因为它是个事儿逼,一不小心就会导致“假复制”,可以用复制构造函数来实现它的功能。