目录
八、覆盖equals时请遵守通用约定
九、覆盖equals时总要覆盖hashCode
十、始终要覆盖toString
十一、谨慎地覆盖clone
十二、考虑实现Comparable接口
八、覆盖equals时请遵守通用约定
对于Object类中提供的equals方法在必要的时候是需要重载的,然而如果违背了一些通用的重载准则,将会给程序带来一些潜在的运行时错误。如果自定义的class没有重载该方法,那么该类实例之间的相等性的比较将是基于两个对象是否指向同一地址来判定的。因此对于以下几种情况可以考虑不用重载该方法:
1.类的每一个实例本质上都是唯一的。不同于值对象,需要根据其内容作出一定的判定,然而该类型的类,其实例的自身便具备了一定的唯一性,如Thread、Timer等,他本身并不具备更多逻辑比较的必要性。
2.不关心类是否提供了“逻辑相等”的测试功能。如Random类,开发者在使用过程中并不关心两个Random对象是否可以生成同样随机数的值,对于一些工具类亦是如此,如NumberFormat和DateFormat等。
3.超类已经覆盖了equals,从超类继承过来的行为对于子类也是合适的。如Set实现都从AbstractSet中继承了equals实现,因此其子类将不在需要重新定义该方法,当然这也是充分利用了继承的一个优势。
4.类是私有的或是包级别私有的,可以确定它的equals方法永远不会被调用。
那么什么时候应该覆盖Object.equals呢?如果类具有自己特有的“逻辑相等”概念,而且超类中没有覆盖equals以实现期望的行为,这时我们就需要覆盖equals方法,如各种值对象,或者像Integer和Date这种表示某个值的对象。在重载之后,当对象插入Map和Set等容器中时,可以得到预期的行为。枚举也可以被视为值对象,然而却是这种情形的一个例外,对于枚举是没有必要重载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,即o.equal(null) = false。
以下是重载equals方法的最佳逻辑:
1.使用==操作符检查"参数是否为这个对象的引用",如果是则返回true。由于==操作符是基于对象地址的比较,因此特别针对拥有复杂比较逻辑的对象而言,这是一种性能优化的方式。
2.使用instanceof操作符检查"参数是否为正确的类型",如果不是则返回false。
3.把参数转换成为正确的类型。由于已经通过instanceof的测试,因此不会抛出ClassCastException异常。
4.对于该类中的每个"关键"域字段,检查参数中的域是否与该对象中对应的域相匹配。
@Override
public boolean equals(Object o) {
if (o == this)
return true;
if (!(o instanceof MyType))
return false;
MyType myType = (MyType)o;
return objField.equals(o.objField) && intField == o.intField
&& Double.compare(doubleField,o.doubleField) == 0
&& Arrays.equals(arrayField,o.arrayField);
}
从上面的示例中可以看出,如果域字段为Object对象,则使用equals方法进行两者之间的相等性比较,如果为int等整型基本类型,可以直接比较,如果为浮点型基本类型,考虑到精度和Double.NaN和Float.NaN等问题,推荐使用其对应包装类的compare方法,如果是数组,可以使用JDK 1.5中新增的Arrays.equals方法。众所周知,&&操作符是有短路原则的,因此应该将最有可能不相同和比较开销更低的域比较放在最前面。
最后需要提起注意的是Object.equals的参数类型为Object,如果要重载该方法,必须保持参数列表的一致性,如果我们将子类的equals方法写成:public boolean equals(MyType o);Java的编译器将会视其为Object.equals的过载(Overload)方法,因此推荐在声明该重载方法时,在方法名的前面加@Override注释标签,一旦当前声明的方法因为各种原因并没有重载超类中的方法,该标签的存在将会导致编译错误,从而提醒开发者此方法的声明存在语法问题。
九、覆盖equals时总要覆盖hashCode
一个通用的约定,如果类覆盖了equals方法,那么hashCode方法也需要被覆盖。如果将会导致该类无法和基于散列的集合一起正常的工作,如HashMap、HashSet。来自JavaSE6的约定如下:
1.在应用程序执行期间,只要对象的equals方法的比较操作所用到的信息没有被修改,那么对这同一个对象多次调用,hashCode方法都必须始终如一地返回同一个整数。在同一个应用程序的多次执行过程中,每次执行所返回的整数可以不一致。
2.如果两个对象根据equals(Object)方法比较是相等的,那么调用这两个对象中任意一个对象的hashCode方法都必须产生同样的整数结果。
3.如果两个对象根据equals(Object)方法比较是不相等的,那么调用这两个对象中任意一个对象的hashCode方法,则不一定要产生不同的整数结果。但是程序员应该知道,给不相等的对象产生截然不同的整数结果,有可能提高散列表的性能。
如果类没有覆盖hashCode方法,那么Object中缺省的hashCode实现是基于对象地址的,就像equals在Object中的缺省实现一样。如果我们覆盖了equals方法,那么对象之间的相等性比较将会产生新的逻辑,而此逻辑也应该同样适用于hashCode中散列码的计算,既参与equals比较的域字段也同样要参与hashCode散列码的计算。见下面的示例代码:
public final class PhoneNumber {
private final short areaCode;
private final short prefix;
private final short lineNumber;
public PhoneNumber(int areaCode,int prefix,int lineNumber) {
//做一些基于参数范围的检验。
rangeCheck(areaCode, 999, "area code");
rangeCheck(prefix, 999, "prefix");
rangeCheck(lineNumber, 9999, "line number");
this.areaCode = (short)areaCode;
this.prefix = (short)prefix;
this.lineNumber = (short)lineNumber;
}
private void rangeCheck(int arg, int max, String name) {
if (arg < 0 || arg > max)
throw new IllegalArgumentException(name + ":" + arg);
}
@Override public boolean equals(Object o) {
if (o == this)
return true;
if (!(o instanceof PhoneNumber))
return false;
PhoneNumber pn = (PhoneNumber)o;
return pn.lineNumber = lineNumber && pn.prefix == prefix
&& pn.areaCode = areaCode;
}
}
public static void main(String[] args) {
Map<PhoneNumber,String> m = new HashMap<PhoneNumber,String>();
PhoneNumber pn1 = new PhoneNumber(707,867,5309);
m.put(pn1,"Jenny");
PhoneNumber pn2 = new PhoneNumber(707,867,5309);
if (m.get(pn2) == null)
System.out.println("Object can't be found in the Map");
}
}
从以上示例的输出结果可以看出,新new出来的pn2对象并没有在Map中找到,尽管pn2和pn1的相等性比较将返回true。这样的结果很显然是有悖我们的初衷的。如果想从Map中基于pn2找到pn1,那么我们就需要在PhoneNumber类中覆盖缺省的hashCode方法,见如下代码:
@Override散列函数的通常解决办法是:
public int hashCode() {
int result = 17;
result = 31 * result + areaCode;
result = 31 * result + prefix;
result = 31 * result + lineNumber;
return result;
}
1.把某个非零的常数值保存在一个名为result的int类型的变量中。
2.对于对象中的每个关键域f(指equals方法中涉及的每个域),完成以下步骤:
a.为该域计算int类型的散列码c:
i. 如果该域是boolean类型,则计算(f ? 1 : 0)。
ii. 如果该域是byte、char、short或者int类型,则计算(int)f。
iii.如果该域是long类型,则计算(int)(f ^ (f >>> 32))。
iv. 如果该域是float类型,则计算Float.floatToIntBits(f)。
v. 如果该域是double类型,则计算Double.doubleToLongBits(f),然后按照步骤2.a.iii,为得到的 long类型值计算散列值
vi. 如果该域是一个对象引用,并且该类的equals方法通过递归地调用equals方式来比较这个域, 则同样为这个域递归地调用hashCode()。如果这个域的值为null,则返回0。
vii.如果该域是一个数组,可以利用Arrays.hashCode方法计算散列吗
b.按照下面的公式,把步骤2.a中计算得到的散列码合并到result中:result = 31 * result + c;(31是一个 奇素数,可以用移位和减法代替乘法,使性能更好)。
3.返回result。
根据需要,再决定把计算散列码的过程放在hashCode()中还是放在构造函数中。
十、始终要覆盖toString
与equals和hashCode不同的是,该条目推荐应该始终覆盖该方法,以便在输出时可以得到更明确、更有意义的文字信息和表达格式。这样在我们输出调试信息和日志信息时,能够更快速的定位出现的异常或错误。如上一个条目中PhoneNumber的例子,如果不覆盖该方法,就会输出 PhoneNumber@163b91 这样的不可读信息,因此也不会给我们诊断问题带来更多的帮助。以下代码重载了该方法,那么在我们调用toString或者println时,将会得到"(408)867-5309":
@Override
String toString() {
return String.format("(%03d) %03d-%04d", areaCode, prefix, lineNumber);
}
对于toString返回字符串中包含的域字段,如本例中的areaCode、prefix和lineNumber,应该在该类(PhoneNumber)的声明中提供这些字段的getter方法,以避免toString的使用者为了获取其中的信息而不得不手工解析该字符串。这样不仅带来不必要的效率损失,而且在今后修改toString的格式时,也会给使用者的代码带来负面影响。提到toString返回字符串的格式,有两个建议,其一是尽量不要固定格式,这样会给今后添加新的字段信息带来一定的束缚,因为必须要考虑到格式的兼容性问题,再者就是推荐可以利用toString返回的字符串作为该类的构造函数参数来实例化该类的对象,如BigDecimal和BigInteger等装箱类。
还有一点建议是和hashCode、equals相关的,如果类的实现者已经覆盖了toString的方法,那么完全可以利用toString返回的字符串来生成hashCode,以及作为equals比较对象相等性的基础。这样的好处是可以充分的保证toString、hashCode和equals的一致性,也降低了在对类进行修订时造成的一些潜在问题。尽管这不是刚性要求的,却也不失为一个好的实现方式。
十一、谨慎地覆盖clone
Cloneable和Serializable一样都是标记型接口,它们内部都没有方法和属性,implements Cloneable表示该对象能被克隆,能使用Object.clone()方法。如果没有implements Cloneable的类调用Object.clone()方法就会抛出CloneNotSupportedException。
如果每个域包含一个基本类型的值,或者包含一个指向不可变对象的引用,那么被返回的对象则可能正是你所需要的对象,这种情况下不需要做进一步处理。例如,第九条中的PhoneNumber类,这种情况下你需要做的除了声明实现了Cloneable接口之外,就是对Object类中声明为protect的clone方法提供public访问途径:
@Override
public PhoneNumber clone() {
try {
return (PhoneNumber) super.clone();
} catch(CloneNotSupportedException e) {
throw new AssertionError(); // Can't happen
}
}
如果对象中包含的域引用了可变对象时,调用super.clone()方法后还要对其中的可变对象进行手动的拷贝,如第六条中是Stack类,为了使clone方法正常地工作,需要在elements数组中递归地调用clone:
@Override public Stack clone() {
try {
Stack result = (Stack) super.clone();
result.elements = elements.clone();
return result;
} catch (CloneNotSupportedException e) {
throw new AssertionError();
}
}
要注意,如果elements域是final的,上述方案就不能正常工作,因为clone方法禁止给elements域赋新值,clone架构域引用可变对象的final域的正常用法是不相兼容的,此时可能有必要从去掉final修饰符。
还要考虑在拷贝的过程中线程安全的问题,把被拷贝的对象设置为不可外部修改或者实现线程安全。最好提供某些其他的途径来代替对象拷贝,或者干脆不提供这样的功能。另一个实现对象拷贝的好办法是提供一个拷贝构造器或拷贝工厂,如:Public Yum(Yum yum);。
十二、考虑实现Comparable接口
和之前提到的通用方法equals、hashCode和toString不同的是,compareTo方法属于Comparable接口,该接口为其实现类提供了排序比较的规则,实现类仅需基于内部的逻辑,为compareTo返回不同的值,既A.compareTo(B) > 0可视为A > B,反之则A < B,如果A.compareTo(B) == 0,可视为A == B。如果对象实现了Comparable接口,即可充分利用JDK集合框架中提供的各种泛型算法,如:Arrays.sort(a); 即可完成a对象数组的排序。事实上,JDK中的所有值类均实现了该接口,如Integer、String等。
Object.equals方法的通用实现准则也同样适用于Comparable.compareTo方法,如对称性、传递性和一致性等。然而两个方法之间有一点重要的差异还是需要在这里提及的,既equals方法不应该抛出异常,而compareTo方法则不同,由于在该方法中不推荐跨类比较,如果当前类和参数对象的类型不同,可以抛出ClassCastException异常。在JDK 1.5 之后我们实现的Comparable<T>接口多为该泛型接口,不在推荐直接继承1.5之前的非泛型接口Comparable了,新的compareTo方法的参数也由Object替换为接口的类型参数,因此在正常调用的情况下,如果参数类型不正确,将会直接导致编译错误。
public static void main(String[] args) {
HashSet<BigDecimal> hs = new HashSet<BigDecimal>();
BigDecimal bd1 = new BigDecimal("1.0");
BigDecimal bd2 = new BigDecimal("1.00");
hs.add(bd1);
hs.add(bd2);
System.out.println("The count of the HashSet is " + hs.size());
TreeSet<BigDecimal> ts = new TreeSet<BigDecimal>();
ts.add(bd1);
ts.add(bd2);
System.out.println("The count of the TreeSet is " + ts.size());
}
/* 输出结果如下:
The count of the HashSet is 2
The count of the TreeSet is 1
*/
由以上代码的输出结果可以看出,TreeSet和HashSet中包含元素的数量是不同的,这其中的主要原因是TreeSet是基于BigDecimal的compareTo方法是否返回0来判断对象的相等性,而在该例中compareTo方法将这两个对象视为相同的对象,因此第二个对象并未实际添加到TreeSet中。和TreeSet不同的是HashSet是通过equals方法来判断对象的相同性,而恰恰巧合的是BigDecimal的equals方法并不将这个两个对象视为相同的对象,这也是为什么第二个对象可以正常添加到HashSet的原因。
在重载compareTo方法时,应该将最重要的域字段比较方法比较的最前端,如果重要性相同,则将比较效率更高的域字段放在前面,以提高效率。比较整数基本类型的域,可以使用 < 和 > ,而浮点域则用Double.compare或者Float.compare。如以下代码:Double.compare或者Float.compare。如以下代码:上例给出了一个标准的compareTo方法实现方式,由于使用compareTo方法排序的对象并不关心返回的具体值,只是判断其值是否大于0,小于0或是等于0,因此以上方法可做进一步优化,然而需要注意的是,下面的优化方式会导致数值类型的作用域溢出问题:
public int compareTo(PhoneNumer pn) {
if (areaCode < pn.areaCode)
return -1;
if (areaCode > pn.areaCode)
return 1;
if (prefix < pn.prefix)
return -1;
if (prefix > pn.prefix)
return 1;
if (lineNumber < pn.lineNumer)
return -1;
if (lineNumber > pn.lineNumber)
return 1;
return 0;
}
public int compareTo(PhoneNumer pn) {
int areaCodeDiff = areaCode - pn.areaCode;
if (areaCodeDiff != 0)
return areaCodeDiff;
int prefixDiff = prefix - pn.prefix;
if (prefixDiff != 0)
return prefixDiff;
int lineNumberDiff = lineNumber - pn.lineNumber;
if (lineNumberDiff != 0)
return lineNumberDiff;
return 0;
}