一、考虑用静态工厂方法代替构造器:
构造器是创建一个对象实例最基本也最通用的方法,大部分开发者在使用某个class的时候,首先需要考虑的就是如何构造和初始化一个对象示例,而构造的方式首先考虑到的就是通过构造函数来完成,因此在看javadoc中的文档时首先关注的函数也是构造器。然而在有些时候构造器并非我们唯一的选择,通过反射也是可以轻松达到的。我们这里主要提到的方式是通过静态类工厂的方式来创建class的实例,如:
public static Boolean valueOf(boolean b) {
return b ? Boolean.TRUE : Boolean.FALSE;
}
静态工厂方法和构造器不同有以下主要优势:
1. 有意义的名称。
在框架设计中,针对某些工具类通常会考虑dummy对象或者空对象以辨别该对象是否已经被初始化,如我曾在我的C++基础库中实现了String类型,见如下代码:
void showExample() {
String strEmpty = String::empty();
String strEmpty2 = "";
String strData = String::prellocate(1024);
if (strEmpty.isEmpty()) {
//TODO: do something
}
}
static String String::emptyString;
String& String::empty() {
return emptyString;
}
bool String::isEmpty() {
if (this->_internal == &emptyString->_internal)
return true;
//TODO: do other justice to verify whether it is empty.
}
在上面的代码中,提供了两个静态工厂方法empty和preallocate用于分别创建一个空对象和一个带有指定分配空间的String对象。从使用方式来看,这些静态方法确实提供了有意义的名称,使用者很容易就可以判断出它们的作用和应用场景,而不必在一组重载的构造器中去搜寻每一个构造函数及其参数列表,以找出适合当前场景的构造函数。从效率方面来讲,由于提供了唯一的静态空对象,当判读对象实例是否为空时(isEmpty),直接使用预制静态空对象(emptyString)的地址与当前对象进行比较,如果是同一地址,即可确认当前实例为空对象了。对于preallocate函数,顾名思义,该函数预分配了指定大小的内存空间,后面在使用该String实例时,不必担心赋值或追加的字符过多而导致频繁的realloc等操作。
2. 不必在每次调用它们的时候创建一个新的对象。
还是基于上面的代码实例,由于所有的空对象都共享同一个静态空对象,这样也节省了更多的内存开销,如果是strEmpty2方式构造出的空对象,在执行比较等操作时会带来更多的效率开销。事实上,Java在String对象的实现中,使用了常量资源池也是基于了同样的优化策略。该优势同样适用于单实例模式。
3. 可以返回原返回类型的任何子类型。
在Java Collections Framework的集合接口中,提供了大量的静态方法返回集合接口类型的实现类型,如Collections.subList()、Collections.unmodifiableList()等。返回的接口是明确的,然而针对具体的实现类,函数的使用者并不也无需知晓。这样不仅极大的减少了导出类的数量,而且在今后如果发现某个子类的实现效率较低或者发现更好的数据结构和算法来替换当前实现子类时,对于集合接口的使用者来说,不会带来任何的影响。本书在例子中提到EnumSet是通过静态工厂方法返回对象实例的,没有提供任何构造函数,其内部在返回实现类时做了一个优化,即如果枚举的数量小于64,该工厂方法将返回一个经过特殊优化的实现类实例(RegularEnumSet),其内部使用long(64bits在Java中) 中的不同位来表示不同的枚举值。如果枚举的数量大于64,将使用long的数组作为底层支撑。然而这些内部实现类的优化对于使用者来说是透明的。
4. 在创建参数化类型实例的时候,它们使代码变得更加简洁。
Map<String,String> m = new HashMap<String,String>();
由于Java在构造函数的调用中无法进行类型的推演,因此也就无法通过构造器的参数类型来实例化指定类型参数的实例化对象。然而通过静态工厂方法则可以利用参数类型推演的优势,避免了类型参数在一次声明中被多次重写所带来的烦忧,见如下代码:
public static <K,V> HashMap<K,V> newInstance() {
return new HashMap<K,V>();
}
二、遇到多个构造参数时要考虑用构建器(Builder模式):
如果一个class在构造初始化的时候存在非常多的参数,将会导致构造函数或者静态工厂函数带有大量的、类型相同的函数参数,特别是当一部分参数只是可选参数的时候,class的使用者不得不为这些可选参数也传入缺省值,有的时候会发现使用者传入的缺省值可能是有意义的,而并非class内部实现所认可的缺省值,比如某个整型可选参数,通常使用者会传入0,然后class内部的实现恰恰认为0是一种重要的状态,而该状态并不是该调用者关心的,但是该状态却间接导致其他状态的改变,因而带来了一些潜在的状态不一致问题。与此同时,过多的函数参数也给使用者的学习和使用带来很多不必要的麻烦,我相信任何使用者都希望看到class的接口是简单易用、函数功能清晰可见的。在Effective C++中针对接口的设计有这样的一句话:"接口要完满而最小化"。针对该类问题通常会考虑的方法是将所有的参数归结到一个JavaBean对象中,实例化这个Bean对象,然后再将实例化的结果传给这个class的构造函数,这种方法仍然没有避免缺省值的问题。该条目推荐了Builder模式来创建这个带有很多可选参数的实例对象。
class NutritionFacts {
private final int servingSize;
private final int servings;
private final int calories;
private final int fat;
private final int sodium;
private final int carbohydrate;
public static class Builder {
//对象的必选参数
private final int servingSize;
private final int servings;
//对象的可选参数的缺省值初始化
private int calories = 0;
private int fat = 0;
private int carbohydrate = 0;
private int sodium = 0;
//只用少数的必选参数作为构造器的函数参数
public Builder(int servingSize,int servings) {
this.servingSize = servingSize;
this.servings = servings;
}
public Builder calories(int val) {
calories = val;
return this;
}
public Builder fat(int val) {
fat = val;
return this;
}
public Builder carbohydrate(int val) {
carbohydrate = val;
return this;
}
public Builder sodium(int val) {
sodium = val;
return this;
}
public NutritionFacts build() {
return new NutritionFacts(this);
}
}
private NutritionFacts(Builder builder) {
servingSize = builder.servingSize;
servings = builder.servings;
calories = builder.calories;
fat = builder.fat;
sodium = builder.sodium;
carbohydrate = builder.carbohydrate;
}
}
//使用方式
public static void main(String[] args) {
NutritionFacts cocaCola = new NutritionFacts.Builder(240, 8).calories(100)
.sodium(35).carbohydrate(27).build();
System.out.println(cocaCola);
}
对于Builder方式,可选参数的缺省值问题也将不再困扰着所有的使用者。这种方式还带来了一个间接的好处是,不可变对象的初始化以及参数合法性的验证等工作在构造函数中原子性的完成了。
Map<String,String> m = MyHashMap.newInstance();
三、用私有构造器或者枚举类型强化Singleton属性:
对于单实例模式,相信很多开发者并不陌生,然而如何更好更安全的创建单实例对象还是需要一些推敲和斟酌的,在Java中主要的创建方式有以下三种,我们分别作出解释和适当的比较。
1. 将构造函数私有化,直接通过静态公有的final域字段获取单实例对象:
public class Elvis {
public static final Elvis INSTANCE = new Elvis();
private Elivs() { ... }
public void leaveTheBuilding() { ... }
}
这样的方式主要优势在于简洁高效,使用者很快就能判定当前类为单实例类,在调用时直接操作Elivs.INSTANCE即可,由于没有函数的调用,因此效率也非常高效。然而事物是具有一定的双面性的,这种设计方式在一个方向上走的过于极端了,因此他的缺点也会是非常明显的。如果今后Elvis的使用代码被迁移到多线程的应用环境下了,系统希望能够做到每个线程使用同一个Elvis实例,不同线程之间则使用不同的对象实例。那么这种创建方式将无法实现该需求,因此需要修改接口以及接口的调用者代码,这样就带来了更高的修改成本。
2. 通过公有域成员的方式返回单实例对象:
public class Elvis {
public static final Elvis INSTANCE = new Elvis();
private Elivs() { ... }
public static Elvis getInstance() { return INSTANCE; }
public void leaveTheBuilding() { ... }
}
这种方法很好的弥补了第一种方式的缺陷,如果今后需要适应多线程环境的对象创建逻辑,仅需要修改Elvis的getInstance()方法内部即可,对用调用者而言则是不变的,这样便极大的缩小了影响的范围。至于效率问题,现今的JVM针对该种函数都做了很好的内联优化,因此不会产生因函数频繁调用而带来的开销。
3. 使用枚举的方式(Java SE5):
public enum Elvis {
INSTANCE;
public void leaveTheBuilding() { ... }
}
就目前而言,这种方法在功能上和公有域方式相近,但是他更加简洁更加清晰,扩展性更强也更加安全。
四、通过私有构造器强化不可实例化的能力:
我在设计自己的表达式解析器时,曾将所有的操作符设计为enum中不同的枚举元素,同时提供了带有参数的构造函数,传入他们的优先级、操作符名称等信息。
对于有些工具类如java.lang.Math、java.util.Arrays等,其中只是包含了静态方法和静态域字段,因此对这样的class实例化就显得没有任何意义了。然而在实际的使用中,如果不加任何特殊的处理,这样的classes是可以像其他classes一样被实例化的。这里介绍了一种方式,既将缺省构造函数设置为private,这样类的外部将无法实例化该类,与此同时,在这个私有的构造函数的实现中直接抛出异常,从而也避免了类的内部方法调用该构造函数。
public class UtilityClass {
//Suppress default constructor for noninstantiability.
private UtilityClass() {
throw new AssertionError();
}
}
这样定义之后,该类将不会再被外部实例化了,否则会产生编译错误。然而这样的定义带来的最直接的负面影响是该类将不能再被子类化。
五、避免创建不必要的对象:
试比较以下两行代码在被多次反复执行时的效率差异:
String s = new String("stringette");
String s = "stringette";
由于String被实现为不可变对象,JVM底层将其实现为常量池,既所有值等于"stringette" 的String对象实例共享同一对象地址,而且还可以保证,对于所有在同一JVM中运行的代码,只要他们包含相同的字符串字面常量,该对象就会被重用。
我们继续比较下面的例子,并测试他们在运行时的效率差异:
Boolean b = Boolean.valueOf("true");
Boolean b = new Boolean("true");
前者通过静态工厂方法保证了每次返回的对象,如果他们都是true或false,那么他们将返回相同的对象。换句话说,valueOf将只会返回Boolean.TRUE或Boolean.FALSE两个静态域字段之一。而后面的Boolean构造方式,每次都会构造出一个新的Boolean实例对象。这样在多次调用后,第一种静态工厂方法将会避免大量不必要的Boolean对象被创建,从而提高了程序的运行效率,也降低了垃圾回收的负担。
继续比较下面的代码:
public class Person {
private final Date birthDate;
//判断该婴儿是否是在生育高峰期出生的。
public boolean isBabyBoomer {
Calender c = Calendar.getInstance(TimeZone.getTimeZone("GMT"));
c.set(1946,Calendar.JANUARY,1,0,0,0);
Date dstart = c.getTime();
c.set(1965,Calendar.JANUARY,1,0,0,0);
Date dend = c.getTime();
return birthDate.compareTo(dstart) >= 0 && birthDate.compareTo(dend) < 0;
}
}
public class Person {
private static final Date BOOM_START;
private static final Date BOOM_END;
static {
Calender c = Calendar.getInstance(TimeZone.getTimeZone("GMT"));
c.set(1946,Calendar.JANUARY,1,0,0,0);
BOOM_START = c.getTime();
c.set(1965,Calendar.JANUARY,1,0,0,0);
BOOM_END = c.getTime();
}
public boolean isBabyBoomer() {
return birthDate.compareTo(BOOM_START) >= 0 && birthDate.compareTo(BOOM_END) < 0;
}
}
改进后的Person类只是在初始化的时候创建Calender、TimeZone和Date实例一次,而不是在每次调用isBabyBoomer方法时都创建一次他们。如果该方法会被频繁调用,效率的提升将会极为显著。
集合框架中的Map接口提供keySet方法,该方法每次都将返回底层原始Map对象键数据的视图,而并不会为该操作创建一个Set对象并填充底层Map所有键的对象拷贝。因此当多次调用该方法并返回不同的Set对象实例时,事实上他们底层指向的将是同一段数据的引用。
在该条目中还提到了自动装箱行为给程序运行带来的性能冲击,如果可以通过原始类型完成的操作应该尽量避免使用装箱类型以及他们之间的交互使用。见下例:
public static void main(String[] args) {
Long sum = 0L;
for (long i = 0; i < Integer.MAX_VALUE; ++i) {
sum += i;
}
System.out.println(sum);
}
本例中由于错把long sum定义成Long sum,其效率降低了近10倍,这其中的主要原因便是该错误导致了2的31次方个临时Long对象被创建了。
六、消除过期的对象引用:
尽管Java不像C/C++那样需要手工管理内存资源,而是通过更为方便、更为智能的垃圾回收机制来帮助开发者清理过期的资源。即便如此,内存泄露问题仍然会发生在你的程序中,只是和C/C++相比,Java中内存泄露更加隐匿,更加难以发现,见如下代码:
public class Stack {
private Object[] elements;
private int size = 0;
private static final int DEFAULT_INITIAL_CAPACITY = 16;
public Stack() {
elements = new Object[DEFAULT_INITIAL_CAPACITY];
}
public void push(Object e) {
ensureCapacity();
elements[size++] = e;
}
public Object pop() {
if (size == 0)
throw new EmptyStackException();
return elements[--size];
}
private void ensureCapacity() {
if (elements.length == size)
elements = Arrays.copys(elements,2*size+1);
}
}
以上示例代码,在正常的使用中不会产生任何逻辑问题,然而随着程序运行时间不断加长,内存泄露造成的副作用将会慢慢的显现出来,如磁盘页交换、OutOfMemoryError等。那么内存泄露隐藏在程序中的什么地方呢?当我们调用pop方法是,该方法将返回当前栈顶的elements,同时将该栈的活动区间(size)减一,然而此时被弹出的Object仍然保持至少两处引用,一个是返回的对象,另一个则是该返回对象在elements数组中原有栈顶位置的引用。这样即便外部对象在使用之后不再引用该Object,那么它仍然不会被垃圾收集器释放,久而久之导致了更多类似对象的内存泄露。修改方式如下:
public Object pop() {
if (size == 0)
throw new EmptyStackException();
Object result = elements[--size];
elements[size] = null; //手工将数组中的该对象置空
return result;
}
由于现有的Java垃圾收集器已经足够只能和强大,因此没有必要对所有不在需要的对象执行obj = null的显示置空操作,这样反而会给程序代码的阅读带来不必要的麻烦,该条目只是推荐在以下3中情形下需要考虑资源手工处理问题:
1) 类是自己管理内存,如例子中的Stack类。
2) 使用对象缓存机制时,需要考虑被从缓存中换出的对象,或是长期不会被访问到的对象。
3) 事件监听器和相关回调。用户经常会在需要时显示的注册,然而却经常会忘记在不用的时候注销这些回调接口实现类。
七、避免使用终结方法:
任何事情都存在其一定的双面性或者多面性,对于C++的开发者,内存资源是需要手工分配和释放的,而对于Java和C#这种资源托管的开发语言,更多的工作可以交给虚拟机的垃圾回收器来完成,由此C++程序得到了运行效率,却失去了安全。在Java的实际开发中,并非所有的资源都是可以被垃圾回收器自动释放的,如FileInputStream、Graphic2D等class中使用的底层操作系统资源句柄,并不会随着对象实例被GC回收而被释放,然而这些资源对于整个操作系统而言,都是非常重要的稀缺资源,更多的资源句柄泄露将会导致整个操作系统及其运行的各种服务程序的运行效率直线下降。那么如何保证系统资源不会被泄露了?在C++中,由于其资源完全交由开发者自行管理,因此在决定资源何时释放的问题上有着很优雅的支持,C++中的析构函数可以说是完成这一工作的天然候选者。任何在栈上声明的C++对象,当栈退出或者当前对象离开其作用域时,该对象实例的析构函数都会被自动调用,因此当函数中有任何异常(Exception)发生时,在栈被销毁之前,所有栈对象的析构函数均会被自动调用。然而对于Java的开发者而言,从语言自身视角来看,Java本身并未提供析构函数这样的机制,当然这也是和其资源被JVM托管有一定关系的。
在Java中完成这样的工作主要是依靠try-finally机制来协助完成的。然而Java中还提供了另外一种被称为finalizer的机制,使用者仅仅需要重载Object对象提供的finalize方法,这样当JVM的在进行垃圾回收时,就可以自动调用该方法。但是由于对象何时被垃圾收集的不确定性,以及finalizer给GC带来的性能上的影响,因此并不推荐使用者依靠该方法来达到关键资源释放的目的。比如,有数千个图形句柄都在等待被终结和回收,可惜的是执行终结方法的线程优先级要低于普通的工作者线程,这样就会有大量的图形句柄资源停留在finalizer的队列中而不能被及时的释放,最终导致了系统运行效率的下降,甚至还会引发JVM报出OutOfMemoryError的错误。
Java的语言规范中并没有保证该方法会被及时的执行,甚至都没有保证一定会被执行。即便开发者在code中手工调用了System.gc和System.runFinalization这两个方法,这仅仅是提高了finalizer被执行的几率而已。还有一点需要注意的是,被重载的finalize()方法中如果抛出异常,其栈帧轨迹是不会被打印出来的。在Java中被推荐的资源释放方法为,提供显式的具有良好命名的接口方法,如FileInputStream.close()和Graphic2D.dispose()等。然后使用者在finally区块中调用该方法,见如下代码:
public void test() {
FileInputStream fin = null;
try {
fin = new FileInputStream(filename);
//do something.
} finally {
fin.close();
}
}
那么在实际的开发中,利用finalizer又能给我们带来什么样的帮助呢?见下例:
public class FinalizeTest {
//@Override
protected void finalize() throws Throwable {
try {
//在调试过程中通过该方法,打印对象在被收集前的各种状态,
//如判断是否仍有资源未被释放,或者是否有状态不一致的现象存在。
//推荐将该finalize方法设计成仅在debug状态下可用,而在release
//下该方法并不存在,以避免其对运行时效率的影响。
System.out.println("The current status: " + _myStatus);
} finally {
//在finally中对超类finalize方法的调用是必须的,这样可以保证整个class继承
//体系中的finalize链都被执行。
super.finalize();
}
}
}
八、覆盖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。
如果违反了该原则,当x对象实例被存入集合之后,下次希望从该集合中取出该对象时,集合的contains方法将直接无法找到之前存入的对象实例。
2. 对称性:对于任何非null的引用值x和y,如果y.equals(x)为true,那么x.equals(y)也为true。
public final class CaseInsensitiveString {
private final String s;
public CaseInsensitiveString(String s) {
this.s = s;
}
@Override public boolean equals(Object o) {
if (o instanceof CaseInsensitiveString)
return s.equalsIgnoreCase((CaseInsensitiveString)o).s);
if (o instanceof String) //One-way interoperability
return s.equalsIgnoreCase((String)o);
return false;
}
}
public static void main(String[] args) {
CaseInsensitiveString cis = new CaseInsensitiveString("Polish");
String s = "polish";
List<CaseInsensitiveString> l = new ArrayList<CaseInsensitiveString>();
l.add(cis);
if (l.contains(s))
System.out.println("s can be found in the List");
}
对于上例,如果执行cis.equals(s)将会返回true,因为在该class的equals方法中对参数o的类型针对String作了特殊的判断和特殊的处理,因此如果equals中传入的参数类型为String时,可以进一步完成大小写不敏感的比较。然而在String的equals中,并没有针对CaseInsensitiveString类型做任何处理,因此s.equals(cis)将一定返回false。针对该示例代码,由于无法确定List.contains的实现是基于cis.equals(s)还是基于s.equals(cis),对于实现逻辑两者都是可以接受的,既然如此,外部的使用者在调用该方法时也应该同样保证并不依赖于底层的具体实现逻辑。由此可见,equals方法的对称性是非常必要的。以上的equals实现可以做如下修改:
@Override public boolean equals(Object o) {
if (o instanceof CaseInsensitiveString)
return s.equalsIgnoreCase((CaseInsensitiveString)o).s);
return false;
}
这样修改之后,cis.equals(s)和s.equals(cis)都将返回false。
3. 传递性:对于任何非null的引用值x、y和z,如果x.equals(y)返回true,同时y.equals(z)也返回true,那么x.equals(z)也必须返回true。
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 (!(o instanceof Point))
return false;
Point p = (Point)o;
return p.x == x && p.y == y;
}
}
对于该类的equals重载是没有任何问题了,该逻辑可以保证传递性,然而在我们试图给Point类添加新的子类时,会是什么样呢?
public class ColorPoint extends Point {
private final Color c;
public ColorPoint(int x,int y,Color c) {
super(x,y);
this.c = c;
}
@Override public boolean equals(Object o) {
if (!(o instanceof ColorPoint))
return false;
return super.equals(o) && ((ColorPoint)o).c == c;
}
}
如果在ColorPoint中没有重载自己的equals方法而是直接继承自超类,这样的相等性比较逻辑将会给使用者带来极大的迷惑,毕竟Color域字段对于ColorPoint而言确实是非常有意义的比较性字段,因此该类重载了自己的equals方法。然而这样的重载方式确实带来了一些潜在的问题,见如下代码:
public void test() {
Point p = new Point(1,2);
ColorPoint cp = new ColorPoint(1,2,Color.RED);
if (p.equals(cp))
System.out.println("p.equals(cp) is true");
if (!cp.equals(p))
System.out.println("cp.equals(p) is false");
}
从输出结果来看,ColorPoint.equals方法破坏了相等性规则中的对称性,因此需要做如下修改:@Override public boolean equals(Object o) {
if (!(o instanceof Point))
return false;
if (!(o instanceof ColorPoint))
return o.equals(this);
return super.equals(o) && ((ColorPoint)o).c == c;
}
经过这样的修改,对称性确实得到了保证,但是却牺牲了传递性,见如下代码:
public void test() {
ColorPoint p1 = new ColorPoint(1,2,Color.RED);
Point p2 = new Point(1,2);
ColorPoint p1 = new ColorPoint(1,2,Color.BLUE);
if (p1.equals(p2) && p2.equals(p3))
System.out.println("p1.equals(p2) && p2.equals(p3) is true");
if (!(p1.equals(p3))
System.out.println("p1.equals(p3) is false");
}
再次看输出结果,传递性确实被打破了。如果我们在Point.equals中不使用instanceof而是直接使用getClass呢?
@Override public boolean equals(Object o) {
if (o == null || o.getClass() == getClass())
return false;
Point p = (Point)o;
return p.x == x && p.y == y;
}
这样的Point.equals确实保证了对象相等性的这几条规则,然而在实际应用中又是什么样子呢?
class MyTest {
private static final Set<Point> unitCircle;
static {
unitCircle = new HashSet<Point>();
unitCircle.add(new Point(1,0));
unitCircle.add(new Point(0,1));
unitCircle.add(new Point(-1,0));
unitCircle.add(new Point(0,-1));
}
public static boolean onUnitCircle(Point p) {
return unitCircle.contains(p);
}
}
如果此时我们测试的不是Point类本身,而是ColorPoint,那么按照目前Point.equals(getClass方式)的实现逻辑,ColorPoint对象在被传入onUnitCircle方法后,将永远不会返回true,这样的行为违反了"里氏替换原则"(敏捷软件开发一书中给出了很多的解释),既一个类型的任何重要属性也将适用于它的子类型。因此该类型编写的任何方法,在它的子类型上也应该同样运行的很好。
如何解决这个问题,该条目给出了一个折中的方案,既复合优先于继承,见如下代码:
public class ColorPoint {
//包含了Point的代理类
private final Point p;
private final Color c;
public ColorPoint(int x,int y,Color c) {
if (c == null)
throw new NullPointerException();
p = new Point(x,y);
this.c = c;
}
//提供一个视图方法返回内部的Point对象实例。这里Point实例为final对象非常重要,
//可以避免使用者的误改动。视图方法在Java的集合框架中有着大量的应用。
public Point asPoint() {
return p;
}
@Override public boolean equals(Object o) {
if (!(o instanceof ColorPoint))
return false;
ColorPoint cp = (ColorPoint)o;
return cp.p.equals(p) && cp.c.equals(c);
}
}
4. 一致性:对于任何非null的引用值x和y,只要equals的比较操作在对象中所用的信息没有被改变,多次调用x.equals(y)就会一致的返回true,或者一致返回false。
在实际的编码中,尽量不要让类的equals方法依赖一些不确定性较强的域字段,如path。由于path有多种表示方式可以指向相同的目录,特别是当path中包含主机名称或ip地址等信息时,更增加了它的不确定性。再有就是path还存在一定的平台依赖性。
5. 非空性:很难想象会存在o.equals(null)返回true的正常逻辑。作为JDK框架中极为重要的方法之一,equals方法被JDK中的基础类广泛的使用,因此作为一种通用的约定,像equals、toString、hashCode和compareTo等重要的通用方法,开发者在重载时不应该让自己的实现抛出异常,否则会引起很多潜在的Bug。如在Map集合中查找指定的键,由于查找过程中的键相等性的比较就是利用键对象的equals方法,如果此时重载后的equals方法抛出NullPointerException异常,而Map的get方法并未捕获该异常,从而导致系统的运行时崩溃错误,然而事实上,这样的问题是完全可以通过正常的校验手段来避免的。综上所述,很多对象在重载equals方法时都会首先对输入的参数进行是否为null的判断,见如下代码:
@Override public boolean equals(Object o) {
if (o == null)
return false;
if (!(o instanceof MyType))
return false;
...
}
注意以上代码中的instanceof判断,由于在后面的实现中需要将参数o进行类型强转,如果类型不匹配则会抛出ClassCastException,导致equals方法提前退出。在此需要指出的是instanceof还有一个潜在的规则,如果其左值为null,instanceof操作符将始终返回false,因此上面的代码可以优化为:
@Override public boolean equals(Object o) {
if (!(o instanceof MyType))
return false;
...
}
鉴于之上所述,该条目中给出了重载equals方法的最佳逻辑:
1. 使用==操作符检查"参数是否为这个对象的引用",如果是则返回true。由于==操作符是基于对象地址的比较,因此特别针对拥有复杂比较逻辑的对象而言,这是一种性能优化的方式。
2. 使用instanceof操作符检查"参数是否为正确的类型",如果不是则返回false。
3. 把参数转换成为正确的类型。由于已经通过instanceof的测试,因此不会抛出ClassCastException异常。
4. 对于该类中的每个"关键"域字段,检查参数中的域是否与该对象中对应的域相匹配。
如果以上测试均全部成功返回true,否则false。见如下示例代码:
@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) {
//做一些基于参数范围的检验。
this.areaCode = areaCode;
this.prefix = prefix;
this.lineNumber = lineNumber;
}
@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(pn) == 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;
}
在上面的代码中,可以看到参与hashCode计算的域字段也同样参与了PhoneNumber的相等性(equals)比较。对于生成的散列码,推荐不同的对象能够尽可能生成不同的散列,这样可以保证在存入HashMap或HashSet中时,这些对象被分散到不同的散列桶中,从而提高容器的存取效率。对于有些不可变对象,如果需要被频繁的存取于哈希集合,为了提高效率,可以在对象构造的时候就已经计算出其hashCode值,hashCode()方法直接返回该值即可,如:
public final class PhoneNumber {
private final short areaCode;
private final short prefix;
private final short lineNumber;
private final int myHashCode;
public PhoneNumber(int areaCode,int prefix,int lineNumber) {
//做一些基于参数范围的检验。
this.areaCode = areaCode;
this.prefix = prefix;
this.lineNumber = lineNumber;
myHashCode = 17;
myHashCode = 31 * myHashCode + areaCode;
myHashCode = 31 * myHashCode + prefix;
myHashCode = 31 * myHashCode + lineNumber;
}
@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;
}
@Override public int hashCode() {
return myHashCode;
}
}
另外,该条目还建议不要仅仅利用某一域字段的部分信息来计算hashCode,如早期版本的String,为了提高计算哈希值的效率,只是挑选其中16个字符参与hashCode的计算,这样将会导致大量的String对象具有重复的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的一致性,也降低了在对类进行修订时造成的一些潜在问题。尽管这不是刚性要求的,却也不失为一个好的实现方式。该建议并不是源于该条目,而是去年在看effective C#中了解到的。