构造器是创建一个对象实例最基本也最通用的方法,大部分开发者在使用某个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
由于Java在构造函数的调用中无法进行类型的推演,因此也就无法通过构造器的参数类型来实例化指定类型参数的实例化对象。然而通过静态工厂方法则可以利用参数类型推演的优势,避免了类型参数在一次声明中被多次重写所带来的烦忧,见如下代码:
public static
return new HashMap
}
如果一个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
对于单实例模式,相信很多开发者并不陌生,然而如何更好更安全的创建单实例对象还是需要一些推敲和斟酌的,在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();
}
}
}
对于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
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
static {
unitCircle = new HashSet
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方法也需要被覆盖。如果将会导致该类无法和基于散列的集合一起正常的工作,如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 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,从而极大的降低了哈希集合的存取效率。
与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#中了解到的。
和之前提到的通用方法equals、hashCode和toString不同的是compareTo方法属于Comparable接口,该接口为其实现类提供了排序比较的规则,实现类仅需基于内部的逻辑,为compareTo返回不同的值,既A.compareTo(B) > 0可视为A > B,反之则A < B,如果A.compareTo(B) == 0,可视为A == B。在C++中由于提供了操作符重载的功能,因此可以直接通过重载操作符的方式进行对象间的比较,事实上C++的标准库中提供的缺省规则即为此,如bool operator>(OneObject o)。在Java中,如果对象实现了Comparable接口,即可充分利用JDK集合框架中提供的各种泛型算法,如:Arrays.sort(a); 即可完成a对象数组的排序。事实上,JDK中的所有值类均实现了该接口,如Integer、String等。
Object.equals方法的通用实现准则也同样适用于Comparable.compareTo方法,如对称性、传递性和一致性等,这里就不做过多的赘述了。然而两个方法之间有一点重要的差异还是需要在这里提及的,既equals方法不应该抛出异常,而compareTo方法则不同,由于在该方法中不推荐跨类比较,如果当前类和参数对象的类型不同,可以抛出ClassCastException异常。在JDK 1.5 之后我们实现的Comparable
在该条目中针对compareTo的相等性比较给出了一个强烈的建议,而不是真正的规则。推荐compareTo方法施加的等同性测试,在通常情况下应该返回和equals方法同样的结果,考虑如下情况:
public static void main(String[] args) {
HashSet
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
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的原因。这样的差异确实给我们的编程带来了一定的负面影响,由于HashSet和TreeSet均实现了Set
在重载compareTo方法时,应该将最重要的域字段比较方法比较的最前端,如果重要性相同,则将比较效率更高的域字段放在前面,以提高效率,如以下代码:
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;
}
上例给出了一个标准的compareTo方法实现方式,由于使用compareTo方法排序的对象并不关心返回的具体值,只是判断其值是否大于0,小于0或是等于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;
}
信息隐藏是软件程序设计的基本原则之一,面向对象又为这一设计原则提供了有力的支持和保障。这里我们简要列出几项受益于该原则的优势:
1. 更好的解除各个模块之间的耦合关系:
由于模块间的相互调用是基于接口契约的,每个模块只是负责完成自己内部既定的功能目标和单元测试,一旦今后出现性能优化或需求变更时,我们首先需要做的便是定位需要变动的单个模块或一组模块,然后再针对各个模块提出各自的解决方案,分别予以改动和内部测试。这样便大大降低了因代码无规则交叉而带来的潜在风险,同时也缩减了开发周期。
2. 最大化并行开发:
由于各个模块之间保持着较好的独立性,因此可以分配更多的开发人员同时实现更多的模块,由于每个人都是将精力完全集中在自己负责和擅长的专一领域,这样不仅提高了软件的质量,也大大加快了开发的进度。
3. 性能优化和后期维护:
一般来说,局部优化的难度和可行性总是要好于来自整体的优化,事虽如此,然而我们首先需要做的却是如何定位需要优化的局部,在设计良好的系统中,完成这样的工作并非难事,我们只需针对每个涉及的模块做性能和压力测试,之后再针对测试的结果进行分析并拿到相对合理的解决方案。
4. 代码的高可复用性:
在软件开发的世界中,提出了众多的设计理论,设计原则和设计模式,之所以这样,一个非常现实的目标之一就是消除重复代码,记得《重构》中有这样的一句话:“重复代码,万恶之源”。可见提高可用代码的复用性不仅对编程效率和产品质量有着非常重要的意义,对日后产品的升级和维护也是至关重要的。说一句比较现实的话,一个设计良好的产品,即使因为某些原因导致失败,那么产品中应用到的一个个独立、可用和高效的模块也为今后的东山再起提供了一个很好的技术基础。
让我们重新回到主题,Java通过访问控制的方式来完成信息隐藏,而我们的原则是尽可能的使每个类的域成员不被外界访问。对于包内的类而言,则尽可能少的定义公有类,遵循这样的原则可以极大的降低因包内设计或实现的改变而给该包的使用者带来的影响。当然达到这个目标的一个重要前提是定义的接口足以完成调用者的需求。
该条目给出了一个比较重要的建议,既不要提供直接访问或通过函数返回可变域对象的实例,见下例:
public final Thing[] values = { ... };
即便Thing数组对象本身是final的,不能再被赋值给其他对象,然而数组内的元素是可以改变的,这样便给外部提供了一个机会来修改内部数据的状态,从而在主类未知的情况下破坏了对象内部的状态或数据的一致性。其修订方式如下:
private static final Thing[] PRIVATE_VALUES = { ... };
public static final Thing[] values() {
return PRIVATE_VALUES.clone();
}
总而言之,你应该尽可能地降低可访问性。你在仔细地设计了一个最小的公有API之后,应该防止把任何散乱的类、接口和成员变成API的一部分。除了公有静态final域的特殊情形之外,公有类都不应该包含公有域。并且要确保公有静态final域所引用的对象都是不可变的。
这个条目简短的标题已经非常清晰的表达了他的含义,我们这里将只是列出几点说明:
1. 对于公有类而言,由于存在大量的使用者,因此修改API接口将会给使用者带来极大的不便,他们的代码也需要随之改变。如果公有类直接暴露了域字段,一旦今后需要针对该域字段添加必要的约束逻辑时,唯一的方法就是为该字段添加访问器接口,而已有的使用者也将不得不更新其代码,以避免破坏该类的内部逻辑。
2. 对于包级类和嵌套类,公有的域方法由于只能在包内可以被访问,因而修改接口不会给包的使用者带来任何影响。
3. 对于公有类中的final域字段,提供直接访问方法也会带来负面的影响,只是和非final对象相比可能会稍微好些,如final的数组对象,即便数组对象本身不能被修改,但是他所包含的数组成员还是可以被外部改动的,针对该情况建议提供API接口,在该接口中可以添加必要的验证逻辑,以避免非法数据的插入,如:
public
if (index > myArray.length)
return false;
if (!(value instanceof LegalClass))
return false;
...
return true;
}
只在类构造的时候做初始化,构造之后类的外部没有任何方法可以修改类成员的状态,该对象在整个生命周期内都会保持固定不变的状态,如String、Integer等。不可变类比可变类更加易于设计、实现和使用,而且线程安全。
使类成为不可变类应遵循以下五条原则:
1. 不要提供任何会修改对象状态的方法;
2. 保证类不会被扩展,既声明为final类,或将构造函数定义为私有;
3. 使所有的域都是final的;
4. 使所有的域都成为私有的;
5. 确保在返回任何可变域时,返回该域的deep copy。
见如下Complex类:
final class Complex {
private final double re;
private final double im;
public Complex(double re,double im) {
this.re = re;
this.im = im;
}
public double realPart() {
return re;
}
public double imaginaryPart() {
return im;
}
public Complex add(Complex c) {
return new Complex(re + c.re,im + c.im);
}
public Complex substract(Complex c) {
return new Complex(re - c.re, im - c.im);
}
... ...
}
不可变对象还有一个对象重用的优势,这样可以避免创建多余的新对象,这样也能减轻垃圾收集器的压力,如:
public static final Complex ZERO = new Complex(0,0);
public static final Complex ONE = new Complex(1,0);
这样使用者可以重复使用上面定义的两个静态final类,而不需要在每次使用时都创建新的对象。
从Complex.add和Complex.substract两个方法可以看出,每次调用他们的时候都会有新的对象被创建,这样势必会带来一定的性能影响,特别是对于copy开销比较大的对象,如包含几万Bits的BigInteger。如果我们所作的操作仅仅是修改其中的某个Bit,如bigInteger.flipBit(0),该操作只是修改了第0位的状态,而BigInteger却为此copy了整个对象并返回。鉴于此,该条目推荐为不可变对象提供一个功能相仿的可变类,如java.util.BitSet之于java.math.BigInteger。如果我们在实际开发中确实遇到刚刚提及的场景,那么使用BitSet或许是更好的选择。
对于不可变对象还有比较重要的优化技巧,既某些关键值的计算,如hashCode,可以在对象构造时或留待某特定方法(Lazy Initialization)第一次调用时进行计算并缓存到私有域字段中,之后再获取该值时,可以直接从该域字段获取,避免每次都重新计算。这样的优化主要是依赖于不可变对象的域字段在构造后即保持不变的特征。
由于继承需要透露一部分实现细节,因此不仅需要超类本身提供良好的继承机制,同时也需要提供更好的说明文档,以便子类在覆盖超类方法时,不会引起未知破坏行为的发生。需要特别指出的是对于跨越包边界的继承,很可能超类和子类的实现者并非同一开发人员或同一开发团队,因此对于某些依赖实现细节的覆盖方法极有可能会导致预料之外的结果,还需要指出的是,这些细节对于超类的普通用户来说往往是不看见的,因此在未来的升级中,该实现细节仍然存在变化的可能,这样对于子类的实现者而言,在该细节变化时,子类的相关实现也需要做出必要的调整,见如下代码:
//这里我们需要扩展HashSet类,提供新的功能用于统计当前集合中元素的数量,
//实现方法是新增一个私有域变量用于保存元素数量,并每次添加新元素的方法中
//更新该值,再提供一个公有的方法返回该值。
public class InstrumentedHashSet
private int addCount = 0;
public InstrumentedHashSet() {}
public InstrumentedHashSet(int initCap,float loadFactor) {
super(initCap,loadFactor);
}
@Override public boolean add(E e) {
++addCount;
return super.add(e);
}
@Override public boolean addAll(Collection extends E> c) {
addCount += c.size();
return super.addAll(c);
}
public int getAddCount() {
return addCount;
}
}
该子类覆盖了HashSet中的两个方法add和addAll,而且从表面上看也非常合理,然而他却不能正常的工作,见下面的测试代码:
public static void main(String[] args) {
InstrumentedHashSet
s.addAll(Arrays.asList("Snap","Crackle","Pop"));
System.out.println("The count of InstrumentedHashSet is " + s.getAddCount());
}
//The count of InstrumentedHashSet is 6
从输出结果中可以非常清楚的看出,我们得到的结果并不是我们期望的3,而是6。这是什么原因所致呢?在HashSet的内部,addAll方法是基于add方法来实现的,而HashSet的文档中也并未列出这样的细节说明。了解了原因之后,我们应该取消addAll方法的覆盖,以保证得到正确的结果。然而仍然需要指出的是,这样的细节既然未在API文档中予以说明,那么也就间接的表示这种未承诺的实现逻辑是不可依赖的,因为在未来的某个版本中他们有可能会发生悄无声息的发生变化,而我们也无法通过API文档获悉这些。还有一种情况是超类在未来的版本中新增了添加新元素的接口方法,因此我们在子类中也必须覆盖这些方法,同时也要注意一些新的超类实现细节。由此可见,类似的继承是非常脆弱的,那么该如何修订我们的设计呢?答案很简单,复合优先于继承,见如下代码:
//转发类
class ForwardingSet
private final Set
public ForwardingSet(Set
this.s = s;
}
@Override public int size() {
return s.size();
}
@Override public void clear() {
s.clear();
}
@Override public boolean add(E e) {
return s.add(e);
}
@Override public boolean addAll(Collection extends E> c) {
return s.addAll(c);
}
... ...
}
//包装类
class InstrumentedHashSet
private int addCount = 0;
public InstrumentedHashSet(int initCap,float loadFactor) {
super(initCap,loadFactor);
}
@Override public boolean add(E e) {
++addCount;
return super.add(e);
}
@Override public boolean addAll(Collection extends E> c) {
addCount += c.size();
return super.addAll(c);
}
public int getAddCount() {
return addCount;
}
}
由上面的代码可以看出,这种设计最大的问题就是比较琐碎,需要将接口中的方法基于委托类重新实现。
在决定使用继承而不是复合之间,还应该问自己最后一组问题。对于你试图扩展的类,它的API中有没有缺陷呢?如果有,你是否愿意把这些缺陷传播到类的API中?继承机制会把超类API中的所有缺陷传播到子类中,而复合则允许设计新的API来隐藏这些缺陷。
上一条目针对继承将会引发的潜在问题给出了很好的解释,本条目将继续深化这一个设计理念,并提出一些好的建议,以便在确实需要基于继承来设计时,避免这些潜在问题的发生。
1) 为公有方法提供更为详细的说明文档,这其中不仅包扩必要的功能说明和参数描述,还要包含关键的实现细节说明,比如对其他公有方法的依赖和调用。
在上一条目的代码示例中,子类同时覆盖了HashSet的addAll和add方法,由于二者之间存在内部的调用关系,而API文档中并没有给出详细的说明,因而子类的覆盖方法并没有得到期望的结果。
2) 在超类中尽可能避免公有方法之间的相互调用。
HashSet.addAll和HashSet.add给我们提供了一个很好的案例,然而这并不表示HashSet的设计和实现是有问题的,我们只能说HashSet不是为了继承而设计的类。在实际的开发中,如果确实有这样的需要又该如何呢?很简单,将公用的代码提取(extract)到一个私有的帮助方法中,再在其他的公有方法中调用该帮助方法。
3) 可以采用设计模式中模板模式的设计技巧,在超类中将需要被覆盖的方法设定为protected级别。
在采用这种方式设计超类时,还需要额外考虑的是哪些域字段也同时需要被设定为protected级别,以保证子类在覆盖protected方法时,可以得到必要的状态信息。
4) 不要在超类的构造函数中调用可能被子类覆盖的方法,如public和protected级别的域方法。
由于超类的初始化早于子类的初始化,如果此时调用的方法被子类覆盖,而覆盖的方法中又引用了子类中的域字段,这将很容易导致NullPointerException异常被抛出,见下例:
public class SuperClass {
public SuperClass() {
overrideMe();
}
public void overrideMe() {}
}
public final class SubClass extends SuperClass {
private final Date d;
SubClass() {
d = new Date();
}
@Override public void overrideMe() {
System.out.println(dd.getDay());
}
}
public static void main(String[] args) {
SubClass sub = new SubClass();
sub.overrideMe();
}
5) 如果超类实现了Cloneable和Serializable接口,由于clone和readObject也有构造的能力,因此在实现这两个接口方法时也需要注意,不能调用子类的覆盖方法。
众所周知,Java是不支持多重继承但是可以实现多个接口的,而这也恰恰成为了接口优于抽象类的一个重要因素。现将他们的主要差异列举如下:
1) 现有的类可以很容易被更新,以实现新的接口。
如果现存的类并不具备某些功能,如比较和序列化,那么我们可以直接修改该类的定义分别实现Comparable和Serializable接口。倘若Comparable和Serializable不是接口而是抽象类,那么同时继承两个抽象类是Java语法规则所不允许的,如果当前类已经继承自某个超类了,那么他将无法再扩展任何新的超类。
2) 接口是定义mixin(混合类型)的理想选择。
Comparable是一个典型的mixin接口,他允许类表明他的实例可以与其他的可相互比较的对象进行排序。这样的接口之所以被称为mixin,是因为他允许任选的功能可被混合到类型的主要功能中。抽象类不能被用于定义mixin,同样也是因为他们不能被更新到现有的类中:类不可能有一个以上的超类,类层次结构中也没有适当的地方来插入mixin。
3) 接口允许我们构造非层次结构的类型框架。
由于我们可以为任何已有类添加新的接口,而无需考虑他当前所在框架中的类层次关系,这样便给功能的扩展带来了极大的灵活性,也减少了对已有类层次的冲击。如:
public interface Singer { //歌唱家
AudioClip sing(Song s);
}
public interface SongWriter { //作曲家
Song compose(boolean hit);
}
在现实生活中,有些歌唱家本身也是作曲家。因为我们这里是通过接口来定义这两个角色的,所有同时实现他们是完全可能的。甚至可以再提供一个接口扩展自这两个接口,并提供新的方法,如:
public interface SingerWriter extends Singer, SongWriter {
AudioClip strum();
void actSensitive();
}
试想一下,如果将Singer和SongWriter定义为抽象类,那么完成这一扩展就会是非常浩大的工程,甚至可能造成"组合爆炸"的现象。
我们已经列举出了一些接口和抽象类之间的重要差异,下面我们还可以了解一下如何组合使用接口和抽象类,以便他们能为我们设计的框架带来更好的扩展性和层级结构。在Java的Collections Framework中存在一组被称为"骨架实现"(skeletal implementation)的抽象类,如AbstractCollection、AbstractSet和AbstractList等。如果设计得当,骨架实现可以使程序员很容易的提供他们自己的接口实现。这种组合还可以让我们在设计自己的类时,根据实际情况选择是直接实现接口,还是扩展该抽象类。和接口相比,骨架实现类还存在一个非常明显的优势,既如果今后为该骨架实现类提供新的方法,并提供了默认的实现,那么他的所有子类均不会受到影响,而接口则不同,由于接口不能提供任何方法实现,因此他所有的实现类必须进行修改,为接口中新增的方法提供自己的实现,否则将无法通过编译。
简而言之,接口通常是定义允许多个实现的类型的最佳途径。这条规则有个例外,即当演变的容易性比灵活性更为重要的时候。在这种情况下,应该使用抽象类来定义类型,但前提是必须理解并且可以接受这些局限性。如果你导出了一个重要的接口,就应该坚决考虑同时提供骨架实现类。
当类实现接口时,接口就充当可以引用这个类的实例的类型。因此,类实现了接口,就表明客户端可以对这个类的实例实施某些动作。为了任何其他目的定义接口是不恰当的。如实现Comparable接口的类,表明他可以存放在排序的集合中,之后再从集合中将存入的对象有序的读出,而实现Serializable接口的类,表明该类的对象具有序列化的能力。类似的接口在JDK中大量存在。
这里先给出标签类的示例代码:
class Figure {
enum Shape { RECT,CIRCLE };
final Shape s; //标签域字段,标识当前Figure对象的实际类型RECT或CIRCLE。
double length; //length和width均为RECT形状的专有域字段
double width;
double radius; //radius是CIRCLE的专有域字段
Figure(double radius) { //专为生成CIRCLE对象的构造函数
s = Shape.CIRCLE;
this.radius = radius;
}
Figure(double length,double width) { //专为生成RECT对象的构造函数
s = Shape.RECT;
this.length = length;
this.width = width;
}
double area() {
switch (s) { //存在大量的case判断来确定实际的对象类型。
case RECT:
return length * width;
case CIRCLE:
return Math.PI * (radius * radius);
default:
throw new AssertionError();
}
}
}
像Figure这样的类通常被我们定义为标签类,他实际包含多个不同类的逻辑,其中每个类都有自己专有的域字段和类型标识,然而他们又都同属于一个标签类,因此被混乱的定义在一起。在执行真正的功能逻辑时,如area(),他们又不得不通过case语句再重新进行划分。现在我们总结一下标签类将会给我们的程序带来哪些负面影响。
1. 不同类型实例要求的域字段被定义在同一个类中,不仅显得混乱,而且在构造新对象实例时,也会加大内存的开销。
2. 初始化不统一,从上面的代码中已经可以看出,在专为创建CIRCLE对象的构造函数中,并没有提供length和width的初始化功能,而是借助了JVM的缺省初始化。这样会给程序今后的运行带来潜在的失败风险。
3. 由于没有在构造函数中初始化所有的域字段,因此不能将所有的域字段定义为final的,这样该类将有可能成为可变类。
4. 大量的swtich--case语句,在今后添加新类型的时候,不得不修改area方法,这样便会引发因误修改而造成错误的风险。顺便说一下,这一点可以被看做《敏捷软件开发》中OCP原则的反面典型。
那么我们需要通过什么方法来解决这样的问题呢?该条目给出了明确的答案:利用Java语句提供的继承功能。见下面的代码:
abstract class Figure {
abstract double area();
}
class Circle extends Figure {
final double radius;
Circle(double radius) {
this.radius = radius;
}
double area() {
return Math.PI * (radius * radius);
}
}
class Rectangle extends Figure {
final double length;
final double width;
Rectangle(double length,double width) {
this.length = length;
this.width = width;
}
double area() {
return length * width;
}
}
现在我们为每种标签类型都定义了不同的子类,可以明显看出,这种基于类层次的设计规避了标签类的所有问题,同时也大大提供了程序的可读性和可扩展性,如:
class Square extends Rectangle {
Square(double side) {
super(side,side);
}
}
现在我们新增了正方形类,而我们所需要做的仅仅是继承Rectangle类。
简而言之,标签类很少有适用的场景。当你想要编写一个包含显式标签域的类时,应该考虑一下,这个标签是否可以被取消,这个类是否可以用类层次来代替。当你遇到一个包含标签域的现有类时,就要考虑将它重构到一个层次结构中去。
函数对象可以简单的理解为C语言中的回调函数,但是我想他更加类似于C++中的仿函数对象。仿函数对象在C++的标准库中(STL)有着广泛的应用,如std::less等。在Java中并未提供这样的语法规则,因此他们在实现技巧上确实存在一定的差异,然而设计理念却是完全一致的。下面是该条目中对函数对象的描述:
Java没有提供函数指针,但是可以用对象引用实现统一的功能。调用对象上的方法通常是执行该对象(that Object)上的某项操作。然而,我们也可能定义这样一种对象,它的方法执行其他对象(other Objects)上的操作。如果一个类仅仅导出这样的一个方法,它的实例实际上就等同于一个指向该方法的指针。这样的实例被称为函数对象(Function Object),如JDK中Comparator,我们可以将该对象看做是实现两个对象之间进行比较的"具体策略对象",如:
class StringLengthComparator {
public int compare(String s1,String s2) {
return s1.length() - s2.length();
}
}
这种对象自身并不包含任何域字段,其所有实例在功能上都是等价的,因此可以看作为无状态的对象。这样为了提供系统的性能,避免不必要的对象创建开销,我们可以将该类定义为Singleton对象,如:
class StringLengthComparator {
private StringLengthComparator() {} //禁止外部实例化该类
public static final StringLengthComparator INSTANCE = new StringLengthComparator();
public int compare(String s1,String s2) {
return s1.length() - s2.length();
}
}
StringLengthComparator类的定义极大的限制了参数的类型,这样客户端也无法再传递任何其他的比较策略。为了修正这一问题,我们需要让该类成为Comparator
class StringLengthComparator implements Comparator
public int compare(String s1,String s2) {
return s1.length() - s2.length();
}
}
简而言之,函数指针的主要用途就是实现策略模式。为了在Java中实现这种模式,要声明一个接口来表示策略,并且为每个具体策略声明一个实现了该接口的类。当一个具体策略只被使用一次时,可以考虑使用匿名类来声明和实例化这个具体的策略类。当一个具体策略是设计用来重复使用的时候,他的类通常就要被实现为私有的静态成员类,并通过公有的静态final域被导出,其类型为该策略接口。
二十二、优先考虑静态成员类:
在Java中嵌套类主要分为四种类型,下面给出这四种类型的应用场景。
1. 静态成员类:
静态成员类可以看做外部类的公有辅助类,仅当与它的外部类一起使用时才有意义。例如,考虑一个枚举,它描述了计算器支持的各种操作。Operation枚举应该是Calculator类的公有静态成员类,然后,Calculator类的客户端就可以用诸如Calculator.Operation.PLUS和Calculator.Operation.MINUS这样的名称来引用这些操作。
2. 非静态成员类:
一种常见的用法是定义一个Adapter,它允许外部类的实例被看做是另一个不相关的类的实例。如Map接口的实现往往使用非静态成员类来实现它们的集合视图,这些集合视图是由Map的keySet、entrySet和Values方法返回的。
从语法上讲,静态成员类和非静态成员类之间唯一的区别是,静态成员类的声明中包含了static修饰符,尽管语法相似,但实际应用却是大相径庭。每个非静态成员类的实例中都隐含一个外部类的对象实例,在非静态成员类的实例方法内部,可以调用外围实例的方法。如果嵌套类的实例可以在它的外围类的实例之外独立存在,这个嵌套类就必须是静态成员类。由于静态成员类中并不包含外部类实例的对象引用,因此在创建时减少了内存开销。
3. 匿名类:
匿名类没有自己的类名称,也不是外围类的一个成员。匿名类可以出现在代码中任何允许存在表达式的地方。然而匿名类的适用性受到诸多限制,如不能执行instanceof测试,或者任何需要类名称的其他事情。我们也无法让匿名类实现多个接口,当然也不能直接访问其任何成员。最后需要说的是,建议匿名类的代码尽量短小,否则会影响程序的可读性。
匿名类在很多时候可以用作函数对象。
4. 局部类:
是四种嵌套类中最少使用的类,在任何"可以声明局部变量"的地方,都可以声明局部类,并且局部类也遵守同样的作用域规则。
先简单介绍一下泛型的概念和声明形式。声明中具有一个或者多个类型参数的类或者接口,就是泛型类或接口,如List
//原生类型的使用方式
class TestRawType {
private final List stamps = new List();
public static void main(String[] args) {
stamps.add(new Coin(...));
}
}
class MyRunnable implements Runnable {
@Override
void run() {
for (Iterator i = stamps.iterator(); i.hasNext(); ) {
Stamp s = (Stamp)i.next(); //这里将抛出类型转换异常
//TODO: do something.
}
}
}
以上仅为简化后的示例代码,当run()方法中抛出异常时,可以很快发现是在main()中添加了非Stamp类型的元素。如果给stamps对象添加元素的操作是在多个函数或线程中完成的,那么迅速定位到底是哪个或哪几个函数添加了非Stamp类型的元素,将会需要更多的时间去调试。
//泛型类型的使用方式
class TestGenericType {
private final List
public static void main(String[] args) {
stamps.add(new Coin(...)); //该行将直接导致编译错误。
}
}
class MyRunnable implements Runnable {
@Override
void run() {
for (Stamp s : stamps) { //这里不再需要类型转换了。
//TODO: do something
}
}
}
通过以上两个例子可以看出泛型类型相对于原生类型还是有着非常明显的优势的。一般而言,原生类型的使用都是为了保持一定的兼容性,毕竟泛型是在Java 1.5中才推出的。如原有的代码中(Java 1.5之前)包含一个函数,其参数为原生类型,如void func(List l); 在之后的升级代码中,如果给该函数传入泛型类型的List
现在我们比较一下List和List这两个类型之间的主要区别,尽管这两个集合可以包含任何类型的对象元素,但是前者是类型不安全的,而后者则明确告诉使用者可以存放任意类型的对象元素。另一个区别是,如果void func(List l)改为void func(List,List
在新代码中不要使用原生类型,这条规则有两个例外,两者都源于“泛型信息可以在运行时被擦除”这一事实。在Class对象中必须要使用原生类型。JLS不允许使用Class的参数化类型。换句话说,List.class, String[].class和int.class都是合法的,但是List
private void test(Set o) {
if (o instanceof Set) {
Set> m = (Set>)o;
}
}
在进行泛型编程时,经常会遇到编译器报出的非受检警告(unchecked cast warnings),如:Set
public
if (a.length < size)
return (T[])Arrays.copyOf(elements,size,a.getClass());
System.arraycopy(elements,0,a,0,size);
if (a.length > size)
a[size] = null;
return a;
}
编译该代码片段时,编译器会针对(T[])Arrays.copyOf(elements,size,a.getClass())语句产生一条非受检警告,现在我们需要做的就是添加一个新的变量,并在定义该变量时加入@SuppressWarnings注解,见如下修订代码:
public
if (a.length < size) {
//TODO: 加入更多的注释,以便后面的维护者可以非常清楚该转换是安全的。
@SuppressWarnings("unchecked") T[] result =
(T[])Arrays.copyOf(elements,size,a.getClass());
return result;
}
System.arraycopy(elements,0,a,0,size);
if (a.length > size)
a[size] = null;
return a;
}
这个方法可以正确的编译,禁止非受检警告的范围也减少到了最小。
为什么要消除非受检警告,还有一个比较重要的原因。在开始的时候,如果工程中存在大量的未消除非受检警告,开发者认真分析了每一处警告并确认不会产生任何运行时错误,然而所差的是在分析之后没有消除这些警告。那么在之后的开发中,一旦有新的警告发生,极有可能淹没在原有的警告中,而没有被开发者及时发现,最终成为问题的隐患。如果恰恰相反,在分析之后消除了所有的警告,那么当有新警告出现时将会立即引起开发者的注意。
数组和泛型相比,有两个重要的不同点。首先就是数组是协变的,如:Object[] objArray = new Long[10]是合法的,因为Long是Object的子类,与之相反,泛型是不可协变的,如List是非法的,将无法通过编译。因此泛型可以保证更为严格的类型安全性,一旦出现插入元素和容器声明时不匹配的现象是,将会在编译期报错。二者的另一个区别是数组是具体化的,因此数组会在运行时才知道并检查它们的元素类型约束。如将一个String对象存储在Long的数组中时,就会得到一个ArrayStoreException异常。相比之下,泛型则是通过擦除来实现的。因此泛型只是在编译时强化类型信息,并在运行时丢弃它们的元素类型信息。擦除就是使泛型可以与没有使用泛型的代码随意进行交互。由此可以得出混合使用泛型和数组是比较危险的,因为Java的编译器禁止了这样的使用方法,一旦使用,将会报编译错误。见如下用例:
public void test() {
//这里我们先假设该语句可以通过编译
List
//该语句是正常的,intList中将仅包含值为42的一个整型元素
List
//该语句也是合法的,因为数组支持协变
Object[] objects = stringLists;
//由于泛型对象在运行时是擦除对象类型信息的,擦除后intList将变为List类型
//而objects是Object类型的数组,List本身也是Object的子类,因此下面的语句合法。
objects[0] = intList;
//下面的语句将会抛出ClassCastException异常。很显然stringLists[0]是List
String s = stringLists[0].get(0);
}
从以上示例得出,当你得到泛型数组创建错误时,最好的解决办法通常是优先使用集合类型List
static Object reduce(List l, Function f, Object initVal) {
Object[] snapshot = l.toArray();
Object result = initVal;
for (Object o : snapshot) {
return = f.apply(result,o);
}
return result;
}
interface Function {
Object apply(Object arg1,Object arg2);
}
事实上,从以上函数和接口的定义可以看出,如果他们被定义成泛型函数和泛型接口,将会得到更好的类型安全,同时也没有对他们的功能造成任何影响,见如下修改为泛型的示例代码:
static
E[] snapshot = l.toArray();
E result = initVal;
for (E e : snapshot) {
result = f.apply(result,e);
}
return result;
}
interface Function
E apply(E arg1,E arg2);
}
这样的写法回提示一个编译错误,即E[] snapshot = l.toArray();是无法直接转换并赋值的。修改方式也很简单,直接强转就可以了,如E[] snapshot = (E[])l.toArray();在强转之后,仍然会收到编译器给出的一条警告信息,即无法在运行时检查转换的安全性。尽管结果证明这样的修改之后是可以正常运行的,但是这样的写法确实也是不安全的,更好的办法是通过List
static
E[] snapshot = new ArrayList
E result = initVal;
for (E e : snapshot) {
result = f.apply(result,e);
}
return result;
}
如下代码定义了一个非泛型集合类:
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();
Object result = elements[--size];
elements[size] = null;
return result;
}
public boolean isEmpty() {
return size == 0;
}
private void ensureCapacity() {
if (elements.length == size)
elements = Arrays.copyOf(elements,2 * size + 1);
}
}
在看与之相对于的泛型集合实现方式:
public class Stack
private E[] elements;
private int size = 0;
private static final int DEFAULT_INITIAL_CAPACITY = 16;
public Stack() {
elements = new E[DEFAULT_INITIAL_CAPACITY];
}
public void push(E e) {
ensureCapacity();
elements[size++] = e;
}
public E pop() {
if (size == 0)
throw new EmptyStackException();
E result = elements[--size];
elements[size] = null;
return result;
}
public boolean isEmpty() {
return size == 0;
}
private void ensureCapacity() {
if (elements.length == size)
elements = Arrays.copyOf(elements,2 * size + 1);
}
}
上面的泛型集合类Stack
@SuppressWarning("unchecked")
public Stack() {
elements = (E[])new Object[DEFAULT_INITIAL_CAPACITY];
}
总而言之,使用泛型比使用需要在客户端代码中进行转换的类型来的更加安全,也更加容易。在设计新类型的时候,要确保它们不需要这种转换就可以使用。这通常意味着要把类做成是泛型的。
和优先选用泛型类一样,我们也应该优先选用泛型方法。特别是静态工具方法尤其适合于范兴华。如Collections.sort()和Collections.binarySearch()等静态方法。见如下非泛型方法:
public static Set union(Set s1, Set s2) {
Set result = new HashSet(s1);
result.addAll(s2);
return result;
}
这个方法在编译时会有警告报出。为了修正这些警告,最好的方法就是使该方法变为类型安全的,要将方法声明修改为声明一个类型参数,表示这三个集合的元素类型,并在方法中使用类型参数,见如下修改后的泛型方法代码:
public static
Set
result.addAll(s2);
return result;
}
和调用泛型对象构造函数来创建泛型对象不同的是,在调用泛型函数时无须指定函数的参数类型,而是通过Java编译器的类型推演来填充该类型信息,见如下泛型对象的构造:
Map
很明显,以上代码在等号的两边都显示的给出了类型参数,并且必须是一致的。为了消除这种重复,可以编写一个泛型静态工厂方法,与想要使用的每个构造器相对应,如:
public static
return new HashMap
}
我们的调用方式也可以改为:Map
除了在以上的情形下使用泛型函数之外,我们还可以在泛型单例工厂的模式中应用泛型函数,这些函数通常为无状态的,且不直接操作泛型对象的方法,见如下示例:
public interface UnaryFunction
T apply(T arg);
}
private static UnaryFunction
= new UnaryFunction
public Object apply(Object arg) {
return arg;
}
};
@SuppressWarning("unchecked")
public static
return (UnaryFunction
}
调用方式如下:
public static void main(String[] args) {
String[] strings = {"jute","hemp","nylon"};
UnaryFunction
for (String s : strings)
System.out.println(sameString.apply(s));
Number[] numbers = {1,2.0,3L};
UnaryFunction
for (Number n : numbers)
System.out.println(sameNumber.apply(n));
}
对于该静态函数,如果我们为类型参数添加更多的限制条件,如参数类型必须是Comparable
public static
Iterator
T result = i.next();
while (i.hasNext()) {
T t = i.next();
if (t.compareTo(result) > 0)
result = T;
}
return result;
}
总而言之,泛型方法就想泛型对象一样,提供了更为安全的使用方式。
前面的条目已经解释为什么泛型不支持协变,而在我们的实际应用中可能确实需要一种针对类型参数的特化,幸运的是,Java提供了一种特殊的参数化类型,称为有限制的通配符类型(bounded wildcard type),来处理类似的情况。见如下代码:
public class Stack
public Stack();
public void push(E e);
public E pop();
public boolean isEmpty();
}
现在我们需要增加一个方法:
public void pushAll(Iterable
for (E e : src)
push(e);
}
如果我们的E类型为Number,而我们却喜欢将Integer对象也插入到该容器中,现在的写法将会导致编译错误,因为即使Integer是Number的子类,由于类型参数是不可变的,因此这样的写法也是错误的。需要进行如下的修改:
public void pushAll(Iterable extends E> src) {
for (E e : src)
push(e);
}
修改之后该方法便可以顺利通过编译了。因为参数中Iterable的类型参数被限制为E(Number)的子类型即可。
既然有了pushAll方法,我们可能也需要新增一个popAll的方法与之对应,见如下代码:
public void popAll(Collection
while (!isEmpty())
dst.add(pop());
}
popAll方法将当前容器中的元素全部弹出,并以此添加到参数集合中。如果Collections中的类型参数和Stack完全一致,这样的写法不会有任何问题,然而在实际的应用中,我们通常会将Collection中的元素视为更通用的对象类型,如Object,见如下应用代码:
Stack
Collection
numberStack.popAll(objs);
这样的应用方法将会导致编译错误,因为Object和Stack中Number参数类型是不匹配的,而我们对目标容器中对象是否为Number并不关心,Object就已经满足我们的需求了。为了到达这种更高的抽象,我们需要对popAll做如下的修改:
public void popAll(Collection super E> dst) {
while (!isEmpty())
dst.add(pop());
}
修改之后,之前的使用方式就可以顺利通过编译了。因为参数集合的类型参数已经被修改为E(Number)的超类即可。
这里给出了一个助记方式,便于我们记住需要使用哪种通配符类型:
PECS(producer-extends, consumer-super)
解释一下,如果参数化类型表示一个T生产者,就使用 extends T>,如果它表示一个T消费者,就使用 super T>。在我们上面的例子中,pushAll的src参数产生E实例供Stack使用,因此src相应的类型为Iterable extends E>;popAll的dst参数通过Stack消费E实例,因此dst相应的类型为Collection super E>。PECS这个助记符突出了使用通配符类型的基本原则。
在上一个条目中给出了下面的泛型示例函数:
public static
这里的s1和s2都是生产者,根据PECS原则,它们的声明可以改为:
public static
由于泛型函数在调用时,其参数类型是可以通过函数参数的类型推演出来的,如果上面的函数被如下方式调用时,将会导致Java的编译器无法推演出泛型参数的实际类型,因此引发了编译错误。
Set
Set
Set
如果想顺利通过编译并得到正确的执行结果,我们只能通过显示的方式指定该函数类型参数的实际类型,从而避免了编译器的类型参数自动推演,见修改后的代码:
Set
现在我们再来看一下前面也给出过的max方法,其初始声明为:
public static srcList);
下面是修改过的使用通配符类的声明:
public static
下面将逐一给出新声明的解释:
1. 函数参数srcList产生了T实例,因此将类型从List
2. 最初T被指定为扩展Comparable
注:Comparator和Comparable一样,他们始终都是消费者,因此Comparable super T>优先于Comparable
泛型通常用于集合,如Set和Map等。这样的用法也就限制了每个容器只能有固定数目的类型参数,一般来说,这也确实是我们想要的。然而有的时候我们需要更多的灵活性,如数据库可以用任意多的Column,如果能以类型安全的方式访问所有Columns就好了,幸运的是有一种方法可以很容易的做到这一点,就是将key进行参数化,而不是将容器参数化,见以下代码:
public class Favorites {
public
public
}
下面是该类的使用示例:
public static void main(String[] args) {
Favorites f = new Favorites();
f.putFavorite(String.class,"Java");
f.putFavorite(Integer.class,0xcafebabe);
f.putFavorite(Class.class,Favorites.class);
String favoriteString = f.getFavorite(String.class);
int favoriteInteger = f.getFavorite(Integer.class);
Class> favoriteClass = f.getFavorite(Class.class);
System.out.printf("%s %x %s\n",favoriteString
,favoriteInteger,favoriteClass.getName());
}
//Java cafebabe Favorites
这里Favorites实例是类型安全的:当你请求String的时候,它是不会给你Integer的。同时它也是异构的容器,不像普通的Map,他的所有键都是不同类型的。下面就是Favorites的具体实现:
public class Favorites {
private Map
new HashMap
public
if (type == null)
throw new NullPointerException("Type is null");
favorites.put(type,type.cast(instance));
}
public
return type.cast(favorites.get(type));
}
}
可以看出每个Favorites实例都得到一个Map
对于Favorites类的put/get方法,有一个非常明显的限制,即我们无法将“不可具体化”类型存入到该异构容器中,如List
枚举类型是指由一组固定的常量组成合法值的类型,该特征是在Java 1.5 中开始被支持的,之前的Java代码都是通过“公有静态常量域字段”的方法来简单模拟枚举的,如:
public static final int APPLE_FUJI = 0;
public static final int APPLE_PIPPIN = 1;
public static final int APPLE_GRANNY_SMITH = 2;
... ...
public static final int ORANGE_NAVEL = 0;
public static final int ORANGE_TEMPLE = 1;
public static final int ORANGE_BLOOD = 2;
这样的写法是比较脆弱的。首先是没有提供相应的类型安全性,如两个逻辑上不相关的常量值之间可以进行比较或运算(APPLE_FUJI - ORANGE_TEMPLE),再有就是常量int是编译时常量,被直接编译到使用他们的客户端中。如果与该常量关联的int发生了变化,客户端就必须重新编译。如果没有重新编译,程序还是可以执行,但是他们的行为将不确定。
下面我们来看一下Java 1.5 中提供的枚举的声明方式:
public enum Apple { FUJI, PIPPIN, GRANNY_SMITH }
public enum Orange { NAVEL, TEMPLE, BLOOD }
和“公有静态常量域字段”不同的是,如果函数的参数是枚举类型,如Apple,那么他的实际值只能来自于该枚举所声明的枚举值,即FUJI, PIPPIN, GRANNY_SMITH。如果试图将Apple和Orange中的枚举值进行比较,将会导致编译错误。
和C/C++中提供的枚举不同的是,Java中允许在枚举中添加任意的方法和域,并实现任意的接口。下面先给出一个带有域方法和域字段的枚举声明:
public enum Planet {
MERCURY(3.302e+23,2.439e6),
VENUS(4.869e+24,6.052e6),
EARTH(5.975e+24,6.378e6),
MARS(6.419e+23,3.393e6),
JUPITER(1.899e+27,7.149e7),
SATURN(5.685e+26,6.027e7),
URANUS(8.683e+25,2.556e7),
NEPTUNE(1.024e+26,2.477e7);
private final double mass; //千克
private final double radius; //米
private final double surfaceGravity;
private static final double G = 6.67300E-11;
Planet(double mass,double radius) {
this.mass = mass;
this.radius = radius;
surfaceGravity = G * mass / (radius * radius);
}
public double mass() {
return mass;
}
public double radius() {
return radius;
}
public double surfaceGravity() {
return surfaceGravity;
}
public double surfaceWeight(double mass) {
return mass * surfaceGravity;
}
}
在上面的枚举示例代码中,已经将数据和枚举常量关联起来了,因此需要声明实例域字段,同时编写一个带有数据并将数据保存在域中的构造器。枚举天生就是不可变的,因此所有的域字段都应该为final的。下面看一下该枚举的应用示例:
public class WeightTable {
public static void main(String[] args) {
double earthWeight = Double.parseDouble(args[0]);
double mass = earthWeight/Planet.EARTH.surfaceGravity();
for (Planet p : Planet.values())
System.out.printf("Weight on %s is %f%n",p,p.surfaceWeight(mass));
}
}
// Weight on MERCURY is 66.133672
// Weight on VENUS is 158.383926
// Weight on EARTH is 175.000000
// Weight on MARS is 66.430699
// Weight on JUPITER is 442.693902
// Weight on SATURN is 186.464970
// Weight on URANUS is 158.349709
// Weight on NEPTUNE is 198.846116
枚举的静态方法values()将按照声明顺序返回他的值数组。枚举的toString方法返回每个枚举值的声明名称。
在实际的编程中,我们常常需要针对不同的枚举常量提供不同的数据操作行为,见如下代码:
public enum Operation {
PLUS,MINUS,TIMES,DIVIDE;
double apply(double x,double y) {
switch (this) {
case PLUS: return x + y;
case MINUS: return x - y;
case TIMES: return x * y;
case DIVIDE: return x / y;
}
throw new AssertionError("Unknown op: " + this);
}
}
上面的代码已经表达出这种根据不同的枚举值,执行不同的操作。但是上面的代码在设计方面确实存在一定的缺陷,或者说漏洞,如果我们新增枚举值的时候,所有和apply类似的域函数,都需要进行相应的修改,如有遗漏将会导致异常的抛出。幸运的是,Java的枚举提供了一种更好的方法可以将不同的行为与每个枚举常量关联起来:在枚举类型中声明一个抽象的apply方法,并在特定于常量的类主体中,用具体的方法覆盖每个常量的抽象apply方法,如:
public enum Operation {
PLUS { double apply(double x,double y) { return x + y;} },
MINUS { double apply(double x,double y) { return x - y;} },
TIMES { double apply(double x,double y) { return x * y;} },
DIVIDE { double apply(double x,double y) { return x / y;} };
abstract double apply(double x, double y);
}
这样在添加新枚举常量时就不会轻易忘记提供相应的apply方法了。我们在进一步看一下如何将枚举常量和特定的数据进行关联,见如下代码:
public enum Operation {
PLUS("+") { double apply(double x,double y) { return x + y;} },
MINUS("-") { double apply(double x,double y) { return x - y;} },
TIMES("*") { double apply(double x,double y) { return x * y;} },
DIVIDE("/") { double apply(double x,double y) { return x / y;} };
private final String symbol;
Operation(String symbol) {
this.symbol = symbol;
}
@Override public String toString() {
return symbol;
}
abstract double apply(double x, double y);
}
下面给出以上代码的应用示例:
public static void main(String[] args) {
double x = Double.parseDouble(args[0]);
double y = Double.parseDouble(args[1]);
for (Operation op : Operation.values())
System.out.printf("%f %s %f = %f%n",x,op,y,op.apply(x,y));
}
}
// 2.000000 + 4.000000 = 6.000000
// 2.000000 - 4.000000 = -2.000000
// 2.000000 * 4.000000 = 8.000000
// 2.000000 / 4.000000 = 0.500000
没有类型有一个自动产生的valueOf(String)方法,他将常量的名字转变为枚举常量本身,如果在枚举中覆盖了toString方法(如上例),就需要考虑编写一个fromString方法,将定制的字符串表示法变回相应的枚举,见如下代码:
public enum Operation {
PLUS("+") { double apply(double x,double y) { return x + y;} },
MINUS("-") { double apply(double x,double y) { return x - y;} },
TIMES("*") { double apply(double x,double y) { return x * y;} },
DIVIDE("/") { double apply(double x,double y) { return x / y;} };
private final String symbol;
Operation(String symbol) {
this.symbol = symbol;
}
@Override public String toString() {
return symbol;
}
abstract double apply(double x, double y);
//新增代码
private static final Map
static {
for (Operation op : values())
stringToEnum.put(op.toString(),op);
}
public static Operation fromString(String symbol) {
return stringToEnum.get(symbol);
}
}
需要注意的是,我们无法在枚举常量构造的时候将自身放入到Map中,这样会导致编译错误。与此同时,枚举构造器不可以访问枚举的静态域,除了编译时的常量域之外。
Java中的枚举提供了ordinal()方法,他返回每个枚举常量在类型中的数字位置,如:
public enum Color {
WHITE,RED,GREEN,BLUE,ORANGE,BLACK;
public int indexOfColor() {
return ordinal() + 1;
}
}
上面的枚举中提供了一个获取颜色索引的方法(indexOfColor),该方法将返回颜色值在枚举类型中的声明位置,如果我们的外部程序依赖了该顺序值,那么这将会是非常危险和脆弱的,因为一旦这些枚举值的位置出现变化,或者在已有枚举值的中间加入新的枚举值时,都将导致该索引值的变化。该条目推荐使用实例域的方式来代替枚举提供的序数值,见如下修改后的代码:
public enum Color {
WHITE(1),RED(2),GREEN(3),ORANGE(4),BLACK(5);
private final int indexOfColor;
Color(int index) {
this.indexOfColor = index;
}
public int indexOfColor() {
return indexOfColor;
}
}
Enum规范中谈到ordinal时这么写道:“大多数程序员都不需要这个方法。它是设计成用于像EnumSet和EnumMap这种基于枚举的通用数据结构的。”除非你在编写的是这种数据结构,否则最好避免使用ordinal()方法。
下面的代码给出了位域的实现方式:
public class Text {
public static final int STYLE_BOLD = 1 << 0;
public static final int STYLE_ITALIC = 1 << 1;
public static final int STYLE_UNDERLINE = 1 << 2;
public static final int STYLE_STRIKETHROUGH = 1 << 3;
public void applyStyles(int styles) { ... }
}
这种表示法让你用OR位运算将几个常量合并到一个集合中,使用方式如下:
text.applyStyles(Text.STYLE_BOLD | Text.STYLE_ITALIC);
Java中提供了EnumSet类,该类继承自Set接口,同时也提供了丰富的功能,类型安全性,以及可以从任何其他Set实现中得到的互用性。但是在内部具体实现上,没有EnumSet内容都表示为位矢量。如果底层的枚举类型有64个或者更少的元素,整个EnumSet就用单个long来表示,因此他的性能也是可以比肩位域的。与此同时,他提供了大量的操作方法,其实现也是基于位操作的,但是相比于手工位操作,由于EnumSet替我们承担了这部分的开发,从而也避免了一些容易出现的低级错误,代码的美观程度也会有所提升,见如下修改的代码:
public class Text {
public enum Style { BOLD, ITALIC, UNDERLINE, STRIKETHROUGH }
public void applyStyles(Set