[size=small]第一章 前言
第二章 创建和销毁对象
1、 考虑用静态工厂方法代替构造器
创建对象方法:一是最常用的公有构造器,二是静态工厂方法。下面是一个Boolean的简单示例:
public static Boolean valueOf(boolean b) {
return (b ? Boolean.TRUE : Boolean.FALSE);
}
l 静态工厂方法与构造器不同的第一大优势在于,它们有名称。
作用不同的公有构造器只能通过参数来区别(因为一个类只有一个带有指定签名的构造器,所以多个构造器只能使用不同的参数列表来区分),如果使用静态的工厂方法,则方法名会很清楚地表达方法的作用。
l 静态工厂方法与构造器不同的第二大优势在于,不必在每次调用它们的时候都创建一个新对象。
不可变类完全可以使用预先构建好的实例,而不必每次使用时都创建一个对象。另外,将构建好的实例缓存起来重复使用,从而避免创建不必要的重复对象。Boolean.valueOf(boolean)方法就使用了这项技术——它从来不创建对象。
l 静态工厂方法与构造器不同的第三大优势在于,它们可以返回原返回类型的任何子类型的对象。
这样我们在选择返回对象的类时就有了更大的灵活性。这种灵活性的一种应用是,API可以返回对象,同时又不会使对象的类变成公有的,比如我们完全可以先定义一个产品接口类,然后采用私有的内部类去实现这个接口,静态工厂方法返回这个类的实例,这样就隐藏了具体的实现。另外,使用静态工厂方时,要求客户端通过接口来引用被返回的对象,而不是通过它的实现类来引用被返回的对象,这是一种良好的编程习惯。
公有静态工厂方法所返回的对象的类不仅可以是private,而且通过静态工厂方法的参数,还可以随着每次的返回不同的类的实例,只要是已声明返回类型的子类型。这样的好处是,可以在以后的版本中删除这个类重新实现也不会影响到已使用的客户。
静态工厂方法返回的对象所属的类,在编写包含该静态工厂方法的类时可能不必存在。这种灵活的静态工厂方法构成了服务提供者框架的基础,例如JDBC API。服务提供者框架是指这样一个系统:多个服务提供者实现一个服务,系统为服务提供者的客户端提供多个实现,并把他们从多个实现中解耦出来。
服务提供者框架有三个重要组件:服务接口(Service Interface),这是提供者实现的;提供者注册API(Provider Registration API),这是系统用来注册实现,让客户端访问它们的;服务访问API(Service Access API),是客户端用来获取服务的实例的方法接口。服务访问API一般允许但是不要求客户端指定某种选择提供者的条件。如果没有这样的规定,API就会返默认实现的一个实例。服务访问API是“灵活的静态工厂”,它构成了服务提供者框架的基础。
服务提供者框架的第四个组件是可选的:服务提供者接口(Service Provider Interface)(即工厂方法模式中的工厂接口),这些提供者负责创建其服务实现的实例。如果没有服务提供者接口,实现就按照类名称注册,并通过反射方式进行实例化。对于JDBC来说,Connection就是它的服务接口,DriverManager.registerDriver是提供者注册API,DriverManager.getConnection是服务访问API,Driver就是服务提供者接口。
下面看看这四个组件的应用:
// 服务接口,就是我们的业务接口。(相当于Connection接口,由Sun提供)
public interface Service {
// ...
}
// 服务提供都接口,即业务工厂接口。(相当于Driver接口,由第三方厂家实现)
public interface Provider {
Service newService();
}
// 服务提供者注册与服务提供者接口(好比DriverManager)
public class Services {
private Services() {}
// 服务名与服务映射,即注册容器
private static final Map providers = new ConcurrentHashMap();
public static final String DEFAULT_PROVIDER_NAME = "def";
// 服务提供者注册API,即注册工厂实现,相当于DriverManager.registerDriver
public static void registerDefaultProvider(Provider p) {
registerProvider(DEFAULT_PROVIDER_NAME, p);
}
public static void registerProvider(String name, Provider p) {
providers.put(name, p);
}
// 服务访问API,向外界提供业务实现,相当于DriverManager.getConnection
public static Service newInstance() {
return newInstance(DEFAULT_PROVIDER_NAME);
}
public static Service newInstance(String name) {
Provider p = (Provider) providers.get(name);
if (p == null) {
throw new IllegalArgumentException(
"NO provider registered with name:" + name);
}
return p.newService();
}
}
静态工厂方法的第四大优势在于,在创建参数化类型实例的时候,它们使代码变得更加简洁。比如要创建一个参数化的HashMap,我们需要如下做:
Map> m= new HashMap>();
这么长的类型参数实在是不太好,而且随着类型参数变得越来越长,也越来越复杂。但如果有了静态工厂方法,编译器就可以替你推导出类型,new时不需要提供参数类型。例如,假设HashMap提供了这个静态工厂:
public static HashMap newInstance(){
return new HashMap();
}
那么你就可以使用以下简洁的代码来代替上面这段繁琐的声明:
Map> m= HashMap.newInstance();
但可惜的是,到现在发行的版本1.6止还未加入,不过我们可以把这些方法放在自己的工具类中。
静态工厂方法的一些惯用名称:
valueOf——不太严格地讲,该方返回的实例与它的参数具有相同的值。这样的静态工厂方法实际上是类型转换方法。
of——valueOf的一种更为简洁的替换,在EnumSet中使用并流行起来。
getInstance——返回的实例是通过方法的参数来描述的,但是不能够说与参数具有同样的值。对于Singleton来说,该方法没有参数,并返回唯一值。
newInstance——像getInstance一样,但newInstance能够确保返回每个实例都与把有其他实例不同。
getType——像getInstance一样,但是在工厂方法处于不同的类中的时候使用。Type表示工厂方法所返回的对象类型。
newType——像newInstance一样,但是在工厂方法处于不同的类中的时候使用。Type表示工厂方法所返回的对象类型。
2、 遇到多个构造器参数时要考虑构造器
如果实例化时需要多个参数时,且这些参数中只有少数几个是必须的,而很多是可选的,这时我们一般考虑使用构造器的方式,而不是使用静态工厂方法。
对于此情况,我们可以使用重叠构造器模式——你提供一个只有必要参数的构造器,第二构造器有一个可选参数,第三个有两个可选参数,依此类推,最后一个构造器包含所有可选参数。
public 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 NutritionFacts(int servingSize, int servings) {
// 调用另一个构造器
this(servingSize, servings, 0);// 第三个参数为默认值
}
// 第二个构造器在第一个构造器的基础上加上一个可先参数
public NutritionFacts(int servingSize, int servings,
int calories) {
// 第四个参数为默认值
this(servingSize, servings, calories, 0);
}
public NutritionFacts(int servingSize, int servings,
int calories, int fat) {
this(servingSize, servings, calories, fat, 0);
}
public NutritionFacts(int servingSize, int servings,
int calories, int fat, int sodium) {
this(servingSize, servings, calories, fat, sodium, 0);
}
public NutritionFacts(int servingSize, int servings,
int calories, int fat, int sodium, int carbohydrate) {
this.servingSize = servingSize;
this.servings = servings;
this.calories = calories;
this.fat = fat;
this.sodium = sodium;
this.carbohydrate = carbohydrate;
}
}
当你想要创建实例时,就利用参数列表最短的构造器。虽然重叠构造器模式可行,但是当有许多参数的时候,客户端代码会行难编写,并且难以阅读,随着参数的增加,它很快就失去控制。
遇到许多构造器参数时,还有第二种代替办法,即JavaBean模式,在这种模式下先调用一个无参数构造器来创建对象,然后调用setter方法来设置每个必要的参数,以及每个相关的可先参数:
public class NutritionFacts {
private int servingSize = -1; //必选,没有默认值
private int servings = -1; //必选,没有默认值
private int calories = 0; //可选,有默认值
private int fat = 0; //可选,有默认值
private int sodium = 0; //可选,有默认值
private int carbohydrate = 0; //可选,有默认值
public NutritionFacts() {}
// set方法
public void setServingSize(int val) { servingSize = val; }
public void setServings(int val) { servings = val; }
public void setCalories(int val) { calories = val; }
public void setFat(int val) { fat = val; }
public void setSodium(int val) { sodium = val; }
public void setCarbohydrate(int val) { carbohydrate = val; }
}
这种模式弥补了重叠构造器模式的不足,他创建实例很容易,代码阅读也很容易。但遗憾的是,JavaBean模式自身有着很严重的缺点。因为构造过程中被分到了几个调用中,在构造过程中JavaBean可能处于不一致的状态,并且类也无法仅仅通过检验构造器参数的有效性来保证一致。另外,JavaBeans模式阻止了把一个类做成不可变的可能,这就需要应用中确保线程安全。
幸运的是,还有第三替代方法,既能保证重叠构造器模式那样的安全性,也能保证像JavaBeans模式那么好的可读性,这就是Builder模式的一种形式——不直接生成想要的对象,而是让客户端利用所有必要的参数调用构造(或者静态工厂),得到一个builder对象,然后客户端在builder对象上调用类似于setter方法,来设置每个样的可选参数,这个builder是它构建的类的静态成员类,下面是示例:
public 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);
}
}
// 构造器需要一个builder对象
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();
}
}
如果类的构造器或者静态工厂中具有多个参数,设计这种类时,Builder模式就是种不错的选择,特别是当大多数参数都是可选的时候。与使用传统的重叠构造器模式相比,使用Builder模式的客户端代码将更易于阅读和编写,构建器也比JavaBeans更安全。
3、 用私有构造器或者枚举类型强化Singleton属性
Singleton指仅仅被实例化一次的类。在Java1.5发行版本之前,实例Singleton有两种方法,这两种方法都要把构造器设置成私有的,并导出公有的静态成员,以便允许客户端能够访问该类的唯一实例。在第一种方法中,公有静态成员是个final域:
public class Elvis {
public static final Elvis INSTANCE = new Elvis();
private Elvis() { }
}
私有构造器仅被调用一次,用来实例化仅有的静态final域INSTANCE。由于没有公有的或受保护的构造器,所以保证了实例的全局唯一性:一旦Elvis类被实例化,只会存在一个Elvis实例。客户端任何行为都不会改变这一点,但要提醒一点:享有特权的客户端可以借助于AccessibleObject.setAccessible方法,通过反射机制(第53条)调用私有构造器,如果要抵御这种攻击,可以修改构造器,让他在要求创建第二个实例的时候抛出异常。
第二种方法中公有的成员是个静态工厂方法:
public class Elvis {
private static final Elvis INSTANCE = new Elvis();
private Elvis() { }
public static Elvis getInstance() { return INSTANCE; }
}
对于静态方法getInstance的所有调用,都会返回同一个对象引用,所以永远不会创建其他实例(上述提醒依然适用)。
第一种可以在以前的VM上效率要高一点,但在现在的JVM实现几乎都能够将静态工厂方法的调用内联化了,所以不存在性能的差异了,再说第二种方式的主要好处在于:组成类的成员的声明很清楚地表明了这个类是一个Singleteon。
另外,如果一个Singleton类实现了Serializable是不够的,为了维护并保证Singleton,必须(原因请见76,简单的说就是防止私有域导出到序列化流中而受到攻击)声明所有实例域都是瞬时(transient)的,并提供一个readResolve方法,否则,每次序列化时,都会创建一个新的实例,正确的作法:
public class Elvis implements Serializable {
public static final Elvis INSTANCE = new Elvis();
private Elvis() {}
private Object readResolve() {
// Return the one true Elvis and let the garbage collector
// take care of the Elvis impersonator.
return INSTANCE;
}
}
从Java1.5版本起,实例Singleton还有第三种方法。只需写一个包含单个元素的枚举类型:
public enum Elvis {
INSTANCE;
public void leaveTheBuilding() {
System.out.println("Whoa baby, I'm outta here!");
}
// This code would normally appear outside the class!
public static void main(String[] args) {
Elvis elvis = Elvis.INSTANCE;
elvis.leaveTheBuilding();
}
}
这种方法在功能上与公有域方法相近,但是它更加简洁,无偿地提供了化机制,绝对防止多次实例化,即使是在相对复杂的序列化或者反射攻击的时候。虽然这种方法还没有广泛采用,但是单元素的枚举类型已经成为了实现Singleton的最佳方法。
上面前面两种是懒汉式单例,还有其他的设计方法,请参考XXX
4、 通过私有构造器强化不可实例化的能力
有时候,你可能需编写只包含静态方法和静态域的类。这些类的名声很不好,因为有些人在面向对象的语言中滥用这样的类来编写过程化的程序。尽管如此,它们也确实有它们特有的用处。比如java.util.Arrays、java.util.Collections,这样的工具类不希望被实例化(这与单例是不一样的),实例对它没有任何意义,它们里的成员都是静态的,所有构造器也定义成了private,但这样是否就能确保不能实例化了呢?如果采用反射就不一定了。那怎样才能做完全不能实例化呢?有的人可能企图通过将类做成抽象类来强制该类不可被实例化,这是行不通的,该类可以被子类化,并且该子类也可以被实例化。这样做甚至会误导用户,以为这种类是专门为了继承设计的。正确的作法:
public class UtilityClass {
// Suppress default constructor for noninstantiability
private UtilityClass() {
throw new AssertionError();//抛异常,防止内部实例化与外部通过反射来实例化
}
}
5、 避免创建不必要的对象
一般来说,最好能重用对象而不是在每次需要的时候就创建一个相同功能的新对象。如果对象是不可变的,它就始终可以被重用。
反例: String s = new String("stringette");
该语句每次被执行的时候都创建一个新的String实例,但是这些创建对象的动作全都是不必要的,传递给String构造器的参数("stringette")本身就是一个String实例,功能方面等同于构造器创建的对象。如果这种用法是在一个循环中,或者是在一个被频繁调用的方法中,就会创建出很多不必要的String实例。
改进:String s = "stringette";
改进后,只用一个String实例,而不是每次执行的时候都创建一个新的实例,而且,它可以保证,对于所有在同一虚拟机中运行的代码,只要它们包含相同的字符串字面常量,该对象就会被重用。
关于字符串对象创建细节请看:《String,到底创建了多少个对象?》
对于同时提供了静态工厂方法和构造器的不可变类,通常可以使用静态工厂方法而不是构造器,以避免创建不必要的对象。例如,静态工厂方法Boolean.valueOf(String)几乎总是比构造器Boolean(String)好,构造器在每次被调用的时候都会创建一个新的对象,而静态工厂方法则从来不要求这样做,实际上也不会这样做。
除了重用不可变的对象之外,也可以重用那此已知不会被修改的可变对象,即当一个对象创建后,以后不会去改变其内部状态,此时也不会去创建新的对象,而是直接利用以前创建的对象。比如某方法里定义了一个大的对象,而这个对象一但创建完后,就可以共以后方法调用使用时,最好将它定义为成员域,而不是局部变量。
例如,Map接口的keySet方法就是每次返回的是keySet实例,当创建好KeySet视图对象后,它会将它存储到keySet成员域中以便下次使用:
public Set keySet() {
Set ks = keySet;
return (ks != null ? ks : (keySet = new KeySet()));
}
它返回的Map对象的Set视图,其中包含该Map中所有的键。粗看起来,好像每次调用keySet都应该创建一个新的Set实例,但是,对于一个给定的Map对象,实际上每次调用keySet都返回同样的Set实例,虽然被返回的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);
}
这段程序算出的答案是正确的,但是比实际情况要更慢一些,只因为打错了一个字符。变量sum被声明成Long而不是long,这就意味着程序构造了大约2^31个多的Long实例。
不要错误地认为“创建对象的代价非常昂贵,我们应该要尽可能地避免创建对象,而不是不创建对象”,相反,由于小对象的构造器只做很少量的工作,所以,小对象的创建和回收动作是非常廉价的,特别是在现代的JVM实现上更是如此。通过创建附加的对象,提升程序的清晰性、简洁性和功能性,这通常也是件好事。
反之,通过维护自己的对象池来创建对象并不是一种好的做法,除非池中的对象是非常重量级的。真正正确使用对象池的典型对象示例就是数据库连接池。建立数据库连接的代价是非常昂贵的,因此重用这些对象非常有意义。
另外,在1.5版本里,对基本类型的整形包装类型使用时,要使用形如 Byte.valueOf来创建包装类型,因为-128~127的数会缓存起来,所以我们要从缓冲池中取,Short、Integer、Long也是这样。
6、 消除过期的对象引用
Java中会有内存泄漏,听起来似乎是很不正常的,因为Java提供了垃圾回收器针对内存进行自动回收,但是Java还是会出现内存泄漏的。什么是Java中的内存泄漏:在Java语言中,内存泄漏就是存在一些被分配的对象,这些对象有两个特点:这些对象可达,即在对象内存的有向图中存在通路可以与其相连;其次,这些对象是无用的,即程序以后不会再使用这些对象了。如果对象满足这两个条件,该对象就可以判定为Java中的内存泄漏,这些对象不会被GC回收,然而它却占用内存,这就是Java语言中的内存泄漏。Java中的内存泄漏和C++中的内存泄漏还存在一定的区别,在C++里面,内存泄漏的范围更大一些,有些对象被分配了内存空间,但是却不可达,由于C++中没有GC,这些内存将会永远收不回来,在Java中这些不可达对象则是被GC负责回收的,因此程序员不需要考虑这一部分的内存泄漏。二者的图如下:
INCLUDEPICTURE "mhtml:file://G:\\新建文件夹\\12_Java内存模型%20-%20风中的索莉逖亚%20-%20CSDN博客.mht!http://docs.google.com/File?id=dc9d7w83_68ckf7x437_b" \* MERGEFORMATINET
下面看内存泄漏的例子:
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];//size~elements.length间的元素为过期元素
}
/**
* Ensure space for at least one more element, roughly
* doubling the capacity each time the array needs to grow.
*/
private void ensureCapacity() {
if (elements.length == size){
Object[] oldElements = elements;
elements = new Object[2 * elements.length +1];
System.arraycopy(oldElements, 0, elements, 0, size);
}
}
}
从栈中弹出来的对象不会被当作垃圾回收,即使使用栈的程序不再引用这些对象,它们也不会被回收,这是因为,栈内部维护着对象这些对象的过期引用,过期引用是指永远也不会被解除的引用,在本例中,凡是在elements数组的“活动部分”之外的任何引用都是过期的,活动部分是指定elements中下标小于size的那些元素。
这类问题的修复方法很简单:一旦对象引用已经过期,只需清空这些引用即可。对于上面例子,只要一个单元被弹出栈,指向它的引用就过期了。pop方法的修改如下:
public Object pop() {
if (size == 0)
throw new EmptyStackException();
Object result =elements[--size];
elements[size] = null;//消除过期引用,只要外界不再引用即可回收
return result;
}
清空对象引用应该是一种例外,而不是一种规范行为:我们不必对每个对象引用一旦程序不再用到它就把它清空,这样做即没必要,也不是我们所期望的,因为这样做会把程序代码弄得很乱。消除过期引用最好的方法是让引用结束其任命周期,如果你在小的作用域内定义的每一个变量,退出作用域就会自动结束。
内存泄漏的另一个常见来源是缓存。一旦你把对象引用放到缓存中,它就很容易被遗忘掉,
从而使得它不再有用之后很长一段时间内仍然留在缓存中。对于这个问题,有几种可能的解决方案。如果你正好要实现这样的缓存:只要在缓存之外存在对某个项的键的引用,该项就有意义,那么就可以用WeakHashMap(弱键映射,允许垃圾回收器回收无外界引用指向象Map中键)代表缓存;当缓存中的项过期之后,它们就会自动被删除。记住只有当所要的缓存项的任命周期是由该键的外部引用而不是由值决定时,WeakHashMap才有用外。
另外,随着时间的推移,早些存入的项会变得越来越没有价值,在这种情况下,缓存应该时不时地清除掉没用的项。我们可以使用的是LinkedHashMap来实现,可以在给缓存添加新条目的时候顺便进行清理,如果要实现这种功能,我们需继承LinkedHashMap并重写它的removeEldestEntry方法(默认返回false,即不会删除最旧项),put 和 putAll 将调用此方法,下面是自己写的测试项:
public class CacheLinkedHashMap extends LinkedHashMap {
//允许最大放入的个数,超过则可能删除最旧项
private static final int MAX_ENTRIES = 5;
@Override
// 是否删除最旧项(最先放入)实现
protected boolean removeEldestEntry(Map.Entry eldest) {
Integer num = (Integer) eldest.getValue();// 最早项的值
//如果老的项小于3且已达到最大允许容量则会删除最老的项
if (num.intValue() < 3 && size() > MAX_ENTRIES) {
System.out.println("超容 - " + this);
return true;
}
return false;
}
public static void main(String[] args) {
CacheLinkedHashMap lh = new CacheLinkedHashMap();
for (int i = 1; i <= 5; i++) {
lh.put("K_" + Integer.valueOf(i), Integer.valueOf(i));
}
System.out.println(lh);
// 放入时会删除最早放入的 k_1 项
lh.put("K_" + Integer.valueOf(11), Integer.valueOf(0));
System.out.println(lh);
}
}
输出:
{K_1=1, K_2=2, K_3=3, K_4=4, K_5=5}
超容 - {K_1=1, K_2=2, K_3=3, K_4=4, K_5=5, K_11=0}
{K_2=2, K_3=3, K_4=4, K_5=5, K_11=0}
说到这里,我们再来看看LinkedHashMap的另一特性 —— 可以按照我们访问的顺序来重新排序(即访问的项放到链表最后),平时我们构造的LinkedHashMap 是按照存放的顺序来排的,如果要按照我们访问(调用get或者是修改时,即put已存在的键时相当于修改,如果放入的不是存在的项则还是放在链的最后)的顺序来重排集合,则需使用LinkedHashMap(int initialCapacity, float loadFactor, boolean accessOrder)来构造,并将accessOrder设置为true:
public class OrderLinkedHashMap {
public static void main(String[] args) {
//按存入顺序连接
LinkedHashMap lh = new LinkedHashMap();
init(lh);
print(lh);//{0=0, 1=1, 2=2, 3=3, 4=4}
lh.get(0);//不将访问过的项放到链表最后
print(lh);//{0=0, 1=1, 2=2, 3=3, 4=4}
lh = new LinkedHashMap(10, 0.75f, true);//按访问顺序
init(lh);
print(lh);//{0=0, 1=1, 2=2, 3=3, 4=4}
lh.get(0);//会将访问过的项放到链表最后
print(lh);//{1=1, 2=2, 3=3, 4=4, 0=0}
lh.put(1, 11);//会将访问过的项放到链表最后
print(lh);//2=2, 3=3, 4=4, 0=0, 1=11,
}
static void init(Map map) {
for (int i = 0; i < 5; i++) {
map.put(i, i);
}
}
static void print(Map map) {
Iterator it = map.entrySet().iterator();
while (it.hasNext()) {
Entry entry = (Entry) it.next();
System.out.print(entry.getKey() + "=" + entry.getValue() + ", ");
}
System.out.println();
}
}
内存泄漏的第三个常见来源是监听器和其他回调。如果你实现了一个API,客户端在这个API中注册回调(即将回调实例存储到某个容器中),却没有显示地取消注册,那么除非你采取某些动作,否则它们就会积聚。确保立即被垃圾回收的最佳方法是只保存它的弱引用,例如,只将它们保存成WeakHashMap中的键。
内存泄漏剖析工具:Heap Profiler
7、 避免使用终结方法
终结方法(finalize)通常是不可预测的,也是限危险的,一般情况下是不必要的,它不是C++中的析构函数。为什么呢?在C++中所有的对象运用delete()一定会被销毁,而JAVA里的对象并非总会被垃圾回收器回收,所以调用的时机是不确定的。C++的析构器也可以被用来回收其他非内存资源,而在Java中,一般用try-finally块来完成类似的工作。
终结方法的缺点是不能保证会被及时地执行。
Java语言规范不仅不保证终结方法会被及时地执行,而且根本就不保证它们会被执行。当一个程序终止的时候,某些已经无法访问的对象上的终结方法却根本没有被执行,这是完全有可能的。
不要被System.gc和System.runFinalization这两个方法所诱惑,它们确实增加了终结方法被执行的机会,但是它们并不保证终结方法一定会被执行。唯一声称保证终结方法被执行的方法是System.runFinalizersOnExit,以及它臭名昭著的孪生兄弟Runtime.runFinalizersOnExit,这两个方法都有致命的缺陷(它可能对正在使用的对象调用终结方法,而其他线程正在操作这些对象,从而导致不正确的行为或死锁),已经被废弃了。注,runFinalizersOnExit(true)只是在JVM退出时才开始调用那些还没有调用的对象上的finalize方法(默认情况下JVM退出时不会调用这些方法),而不像前面的gc与runFinalization在调用稍后执行finalize方法(也可能不执行,因为垃圾收集器并未开始工作)。
如果未被捕获的异常在终结过程中被抛出来,那么该异常将被忽略,并且该对象的终结过程也会终止,并且不会打印异常栈轨迹信息,但该对象仍可以被垃圾收集器收集。
还有一点,使用终结方法有一个非常严重的性能损失。换句话说,用终结方法创建和销毁对象慢了很多。
如果类对象中封闭的资源确实需要终止,我们首先需提供一个显示的终止方法(如InputStream、OutputStream、Connection、Timer上的close方法),并通常与try-finally结构结合起来使用,以确保及时终止(另外要注意的是,该实例应该记录下自己是否已经被关闭了,如果用户已显示地关闭了,则在终结方法中不得再关闭),而不是将它们的释放工作放在finalize终结方法中执行。
当然终结方法不是一无事处的,它有两种合法用途。第一种用途是,当对象的所有者忘记调用前面段落中建议的显示终止方法时,终结方法可以充当“安全网”,我们可以在finalize方法中再进行一次释放资源的工作。这样做并不能保证终结方法会被及时调用或甚至不会被调用,但是在客户端无法通过调用显示的终止方法(或者是根本未调用或忘记调用)来正常结束操作的情况下,这样迟一点释放关键资源总比永远不释放要好。但是如果终结方法发现资源还未被终止,则应该在日志中记录一条警告(最好再调用一次释放资源的方法),因为这表示客户端代码中的一个Bug,应该得到修复,如果你正考虑编写这样的安全网终结方法,就要认真考虑清楚,这种的保护是否值得你付出这份额外的代价。
显示终止方法模式的类(如InputStream、OutputStream、Connection、Timer)都具有终结方法,当它们的终止方法未能被调用的情况下,可以再次在终结方法中显示调用它们的关闭方法,这样终结方法充当了安全网;第二种就是可以回收那些并不重要的本地资源(即本地方法所分配的资源)。
“终结方法链(父类的终结方法)”并不会被自动被执行。如果类(不是Object)有终结方法,并且子类覆盖了终结方法,子类的终结方法就必须手工调用超类的终结方法。你应该在一个try块中终结子类,并在相应的finally块中调用超类的终结方法。这样做可以保证:即使子类的终结过程抛出异常,超类的终结方法也会得到执行,如下面示例:
protected void finalize() throws Throwable{
try{
…// 子类回收动作
}finally{
super.finalize();// 调用父类的终结方法
}
}
如果子类实现者覆盖了超类的终结方法,但是忘了手式调用超类的终结方法,那么超类的终结方法永远也不会被调用到。要防范这样的粗心大意,我们可以为每个将被终结的对象创建一个附加的对象。不是把终结方法放在要求终结处理的类中,而是把终结方法放在一个匿名的类中,该匿名类的唯一作用就是终结它的外围实例。该匿名类的单个实例被称为终结方法守卫者,外围类的每个实例都会创建这样一个守卫者。外围实例在它的私有实例哉中保存着一个对其终结方法守卫者的唯一引用,因些终结方法守卫都与外围实例可以同时启动终结过程。当守卫都被终结的时候,它执行外围实例所期望的终结行为,就好像它的终结方法是外围对象上的一个方法一样:
class Foo{
//终结守卫者
private final Object finalizerGuardian = new Object(){
protected void finalize() throws Throwable{
System.out.println("Foo gc");
}
};
}
class Sub extends Foo{
// 即使没有在子类的终结方法中调用父类的终结方法,父类也会终结
protected void finalize() throws Throwable {
System.out.println("Sub gc");
}
}
注意,公有类Foo并没有终结方法(除了它从Object中继承了一个无关紧要的的之外),所以子类的终结方法是否调用super.finalize并不重要。
总之,除非是作为安全网,或者是为了终止非关键的本地资源,否则请不要使用终结方法。当然如果使用了终结方法,就要记得调用super.finalize。如果用终结方法作为安全网,要记得记录终结方法的非法调用。最后,如果需要把终结方法与公有的非final类关联起来,请考虑使用终结方法守卫者,以确保即使子类的终结方法未能调用super.finalize,该终结方法也会被执行。
补充:
虽然终结一个对象时,它不会去自动调用父类的终结方法,除非手工调用super.finalize,但是父类里的成员域一定会被调用终结方法,这就是为什么终结守卫者安全的原因。另外,回收的顺序是不确定的,不会像C++中的那样,先调用子类析构函数,再调用父类析构函数。还有一点要注意的是,即使对象的finalize 已经运行了,不能保证该对象被销毁。因为对象可以重生。
对于任何给定对象,Java 虚拟机最多只调用一次 finalize 方法。
垃圾收集器的工作过程大致是这样的:一旦垃圾收集器准备好释放无用对象占用的存储空间,它首先调用那些对象的finalize()方法,然后才真正回收对象的内存。
与 Java 不同,C++ 支持局部对象(存储在栈中)和全局对象(存储在堆中),C++ 能对栈中的对象自动析构,但对于堆中的对象就要手动分配内存与释放。在 Java 中,所有对象都驻留在堆内存,而内存的回收则由垃圾收集器统一回收。
finalize可以用来保护非内存资源被释放,即使我们定义了其它的方法来释放非内存资源,但是其它人未必会调用该方法来释放。在finalize里面可以检查一下,如果没有释放就释放好了,晚释放总比不释放好,这样好比“双保险”。通常我们可以在finalize方法中释放容易被忽略的资源,并且这些都是非常重要的资源。
第三章 对所有对象都通用的方法
8、 覆盖equals时请遵守通用约定
如果类具有自己特定的“逻辑相等”概念(不同于对象等同概念),而且超类还没有覆盖equals以实现期望的行为,这时我们就需要覆盖equals方法,这通常属于“值类”的情形,例如Integer或者是Data,程序员在利用equals方法来比较值对象的引用时,希望知道它们在逻辑上是否相等,而不是想了解它们是否指向同一个对象。
在覆盖equals方法时,你必须要遵守它的通用约定。下面是约定的内容,来自Object的规范:
l 自反性:对于任何非null的引用值x,x.equals(x)必须返回true。如果自已不等于自己的话,将其放入集合中后,该集合的contains方法将告诉你,集合中不包括你刚添加的实例。
l 对称性:对于任何非null的引用值x和y,当且仅当y.equals(x)返回true时,x.equals(y)必须返回true。这就要求不同类的实例如果在逻辑值相同的情况下,要求这两个实例所对应的类的equals方法比较逻辑要相同,不然的话,对称性将不再满足。
l 传递性:对于任何非null的引用值x、y和z,如果x.equals(y)返回true,并且y.equals(z)也返回true,那么x.equals(z)也必须返回true。
l 一致性:对于任何非null的引用值x和y,只要equals的比较操作在对象中所用的信息没有被修改,多次调用x.equals(y)就会一致地返回true,或者一致地返回false。
l 非空性:对于任何非null的引用值x,x.equals(null)必须返回false。
实现高质量的equals方法:
1、 使用==操作符检查“参数是否为这个对象的引用”。如果是,则返回true,这只不过是一种性能优化,如果比较操作有可能很昂贵,就值得这么做。
2、 使用instanceof操作符检查“参数是否为正确的类型”。如果不是,则返回false。一般说来所谓“正确的类型”是指定equals所在的那个类。有些情况下,是指该类所实现的某个接口。如果类实现的接口改进了equals约定,允许在实现了该接口的类之间进行比较,那么就使用接口。集合接口如Set、List、Map和Map.Entry具有这样的特性。注,这步会过滤掉null,因为 null instanceof XX 一定会返回false。另外,要注意的是,如果你只与自己本身类型的类相比,则可以使用if(getClass() == obj.getClass())来限制为同一个类比较而不希望是父类或其子类(思想来源于《Practice Java》)。
3、 把参数转换成正确的类型。因为转换之前进行过instanceof测试,所以确保会成功。
4、 对于该类中的每个“关键”域,检查参数中的域是否与该对象中对应的域相匹配。如果这些测试全部成功,则返回true,否则返回false。
对于既不是float也不是double类型的基本类型域,可以使用==操作符进行比较;对于对象引用域,可以递归地调用equals方法;对于float域,可以使用Float.compare方法;对于double域,则使用Double.compare。对于数组域,则要把以上这些指导原则应用到每个元素上,如果数组域中的每个元素都需要比较的话,可以使用1.5版本中发行的Arrays.equals方法。
对于float和double域进行特殊的处理是有必要的,因为存在着Float.NaN、-0.0f以及类似的double常量,详细信息请参考Float.equals的文档,看看Float.equals源码与文档描述:
public boolean equals(Object obj) {
return (obj instanceof Float)
&& (floatToIntBits(((Float) obj).value) == floatToIntBits(value));
}
在比较是否相等时使用是floatToIntBits(float)方法,即将浮点的二进制位看作是整型位后比较的。注意,在大多数情况下,对于 Float 类的两个实例 f1 和 f2,让 f1.equals(f2) 的值为 true 的条件是当且仅当 f1.floatValue() == f2.floatValue() 的值也为 true。但是也有下列两种例外:
l 如果 f1 和 f2 都表示 Float.NaN(规定Float.NaN = 0x7fc00000),那么即使 Float.NaN = = Float.NaN 的值为 false,equals 方法也将返回 true(因为他们所对应的整型位是相同的)。
l 如果 f1 表示 +0.0f,而 f2 表示 -0.0f,或相反的情况,则 equal 测试返回的值是 false(因为他们所对应的整型位是不同的),即使 0.0f = = -0.0f 的值为 true 也是如此。
另外,来看看Float.compare的源码:
public static int compare(float f1, float f2) {
if (f1 < f2)
return -1; // Neither val is NaN, thisVal is smaller
if (f1 > f2)
return 1; // Neither val is NaN, thisVal is larger
int thisBits = Float.floatToIntBits(f1);
int anotherBits = Float.floatToIntBits(f2);
return (thisBits == anotherBits ? 0 : // Values are equal
(thisBits < anotherBits ? -1 : // (-0.0, 0.0) or (!NaN, NaN)
1)); // (0.0, -0.0) or (NaN, !NaN)
}
compare是从数字上比较两个 Float 对象。在应用到基本 float 值时,有两种方法来比较执行此方法产生的值与执行Java 语言的数字比较运算符(<、<=、== 和 >= >)产生的那些值之间的区别:
l 该方法认为 Float.NaN 将等于其自身,且大于其他所有 float 值(包括 Float.POSITIVE_INFINITY)。
l 该方法认为 0.0f 将大于 -0.0f。
请记住,如果是通过Java 语言的数字比较运算符(<、<=、== 和 >= >)而不是compare方法来比较时,只要其中有一个操作为Float.NaN,那么比较结果就是false。
对象引用域的值为null是有可能的,所以,为了避免可能导致的空指针异常,则使用下面的作法: filed = = null ? o.field = = null : filed.equals(o.filed)
如果field和o.field通常是相等的对象引用,那么下面的做法就会更快一些:(field == o.field || (field != null && field.equals(o.field)))
5、 当你编写完成了equals方法之后,应该问自己:它是否是对称的、传递的、一致的?当然,equals方法也必须满足其他两个我(自反性和非空性),但是这两种我通常会自动满足。
另一点要注意的是如果该类有除Object以外的父类,则要考虑是否调用父类的equals方法,如super.equals(obj)(思想来源于《Practice Java),因为可能有些逻辑状态在父类中也需要比较。
下面的例子是根据上面的诀窍构建equals方法:
public final class PhoneNumber {
private final short areaCode;
private final short prefix;
private final short 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;
}
}
最后一点要注意的是,在重写equals方法时,参数类型应该为Object,而不应该是某个要比较的具体类,因为这样在调用equals方法时可能为调用成Object里的equals方法,比如外界将比较的对象赋值给一个Object类型的变量时就会有这个问题。
9、 覆盖equals时总是要覆盖hashCode
在每个覆盖了equals方法的类中,也必须覆盖hashCode方法。如果不这样做的话,就会违反Object.hashCode的能用约定,从而导致该类无法结合所有基于散列的集合一起正常动作,这些集合包括HashMap、HashSet、Hashtable等。
下面是约定的内容:
l 在应用程序的执行期间,只要对象的equals方法的比较操作所用到的信息没有被修改,那么对这同一个对象调用多次,hashCode方法都必须始终如一地返回同一个整数。在同一个应用程序的多次执行过程中,每次执行所返回的整数可以不一致(我想可能是因为对象的状态信息被修改过)。
l 如果两个对象根据equals(Object)方法比较是相等的,那么调用这两个对象中任意一个对象的hashCode方法都必须产生相同的整数结果。
l 如果两个对象根据equals(Object)方法比较是不相等的,那么调用这两个对象中任意一个对象的hashCode方法,则不一定要产生不同的整数结果。但是程序员应该知道,给不相等的对象产生截然不同的整数结果,有可能提高散列表(has table)的性能。
hash集合查找某个键是否存在时,采用取了优化方式,它们先比较的是两者的hashcode,如果不同,则直接返回false(因为放入合希集合的过程中元素的hashcode就已计算出并存Entry里的hash域中了,所以先比较这哈希值很快),否则再比较内容,源码部分如下:if (e.hash == hash && (x == y || x.equals(y))) 。
一个好的散列函数通常倾向于“为不相等的对象产生不相等的散列码”,这正是上面约定中第三条含义。理想情况下,散列函数应该把集合中不相等的实例均匀地分布所有可能的散列值上。要完全达到这种理想的情形是非常困难的。但我们如果按照如下的规则来写hashCode函数,则可能比较理想:
1、 把某个非零的常数值,比如说17(值17是任选的,困此即即使2.a步骤中计算出的散列值为0初始域也会影响到散列值,这样会大大的避免了冲突的可能性,所以这个一般是一个非零的常数值),保存在一个名为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、 如果该域是dobule类型,则计算Double.doubleToLongBits(f),然后按照步骤2.a.III。
VI、 如果该域是一个对象引用,并且该类的equals方法通过递归地调用equals方式来比较这个域,则同样为这个域递归地调用hashCode。如果这个域的值为null,则返回0。
VII、 如果该域是一个数组,则要把每一个元素当做单独的域来处理。也就是说,递归地应用上述规则,对每个重要的元素计算一个散列码,然后根据步骤2.b中的做法把这些散列值组合起来。如果数组的每个元素都需要求,则可以使用1.5版本发行的Arrays.hashCode方法。
b. 按照下面的公式,把步骤2.a中计算得到的散列码c合并到result中:
result = 31 * result + c;
步骤2.b中的乘法部分使得散列值依赖于域的顺序,如果一个类包含多个包含多个相似的域,这样的乘法运算就会产生一个更好的散列函数。例如,如果String散列函数省略了这个乘法部分,那么只要组成该字符串的字符是一样的,而不管它们的排列的顺序,则会导致只要有相同字符内容的字符串就会相等的问题,而String的equals方法是与字符排序顺序有关的。另外,之所以选择31,是因为它是一个奇素数。如果乘数是偶数,并且乘法溢出的话,信息就会全丢失,因为与2相乘等价于移位运算。31还有个很好的特性,即用移位和减法来代替乘法,可以得到更好的性能:31 * i = i ^32 - i = (i << 5) – i,现代的VM可以自动完成这种优化。
3、 返回result。
4、 写完了hashCode方法后,问问自己“相等的实例是否都具有相等的散列码”。
在散列码计算的过程中,可以把冗余域排除在外,换句话说,如果一个域的值可以根据参与计算的其他域值计算出来,则可以把这样的域排除在外。但必须排除equals比较计算中没有用到的所有域,否则很有可能违反hashCode约定的第二条。
不要试图从散列码计算中排除掉一个对象的关键部分来提高性能。虽然这样得到的散列函数运行起来可能更快,但是它的效果不见得会好,可能会导致散列表慢到根本无法使用。
下面看看根据上面hashCode规则的实例:
public final class PhoneNumber {
private final short areaCode;
private final short prefix;
private final short 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() {
int result = 17;
result = 31 * result + areaCode;
result = 31 * result + prefix;
result = 31 * result + lineNumber;
return result;
}
}
当然,如果一个类是不可变的,并且计算散列码的开销也比较大,就应该考虑把散列码缓存在对象的内部,而不是每次请求的时候都重新计算散列码:
private volatile int hashCode; // (See Item 71)
@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;
}
10、 始终要覆盖toString
toString方法应该返回对象中包含的所有值得关注的信息。建议所有的子类都覆盖这个方法。
在实现toString的时候,必须要做出一个很重要的决定:是否在文档中指定返回值的格式。如果格式化,则易阅读,但你得要一直保持这种格式,因而缺乏灵活性。
11、 谨慎地覆盖clone
Cloneable是一个标识性接口,没有任何方法,那么它到底有什么作用?它决定了Object中受保护的clone方法实现的行为:如果一个类实现了Cloneable,Object的clone方法就返回该对象的逐域拷贝,否则就会抛出CloneNotSupportedException异常。
Object.clone()能够按对象大小创建足够的内存空间,从旧对象到新对象,复制所有的比特位。 这被称为逐位复制。但是,Object.clone()在执行操作前,会先检查此类是否可克隆,即检查 它是否实现了Cloneable接口。如果没有实现此接口,Object.clone()会抛出CloneNotSuppo rtedException异常,说明它不能被克隆。
如果某个类中每个成员域是一个基本类型的值(但不包括基本类型数组),或者是指向一个不可变对象的引用,那么我们直接调用Object中的clone方法就是我们要返回的拷贝对象了,而不需要对这个对象再做进一步的处理:
public final class PhoneNumber implements Cloneable {
private final short areaCode;
private final short prefix;
private final short lineNumber;
// …
// 注,这里返回的是PhoneNumber而不是Object,1.5版本后支持参数有协变:覆盖方法的返回烦劳可以是被覆盖方法的返回类型的子类。这样不用在客户端强转了。
@Override public PhoneNumber clone() {
try {
return (PhoneNumber) super.clone();
} catch(CloneNotSupportedException e) {
throw new AssertionError(); // Can't happen
}
}
}
如果对象含有引用类型且指向了可变对象,使用上述这种简单的clone实现可能会导致灾难性后果,考虑像上面那样克隆如下类(类来自于第6条):
public class Stack {
private Object[] elements;
private int size = 0;
private static final int DEFAULT_INITIAL_CAPACITY = 16;
// …
}
假设你希望把这个类做成可克隆的(Cloneable),如果它的clone方法仅仅是返回super.clone(),这样得到的Stack实例,在其size域中具有正确的值,但是它的elements域将引用与原始Stack实例相同的数组。为了使用Stack类中的clone方法正常地工作,它必须拷贝栈的内部信息,最容易的做法是,在elements数组路递归地调用clone:
@Override public Stack clone() {
try {
Stack result = (Stack) super.clone();
// 1.5版本后不必将返回的Object类型结果强转成为Object[]类型,自1.5起,在数组上调用clone返回的数组,其编译时类型与被克隆数组的类型相同,1.5前返回的是Object对象。
result.elements = elements.clone();
return result;
} catch (CloneNotSupportedException e) {
throw new AssertionError();
}
}
注意,上面如果elements域是final的,上述方案就不能正常工作了,因为clone方法是被禁止给elements域赋新值的。这是个根本的问题:clone架构与引用可变对象的final域的正常用法是不相兼容的,可以说是相违背的,除非在原始对象和克隆对象之间可以安全地共享此可变对象(比如使用final修饰的StringBuffer就不可安全共享,如果是不可变对象如String则可安全共享,就可以不必克隆)。为了使类成为可克隆的,可能有必要从某些域中去掉final修饰符。
另外result.elements = elements.clone();所克隆的也只是elements中所有对象地址罢了,克隆出的数组里的元素还是与原数组指向同一个对象,如果要真真深层次克隆,则还是要对数组循环来一个个调用对象上的clone方法才行,下面就来看看这个问题的相应例子。
例如,假设你正在为自己设计的一个散列表编写clone方法,它的内部数据包含一个散列桶数组,每个散列桶都指向“键 — 值”对单向链表的第一个节点,如果桶是空的,则为null,该类如下:
public class Hashtable implements Cloneable {
private transient Entry buckets[];
private static class Entry {
final Object key;
Object value;
Entry next;
protected Entry(Object key, Object value, Entry next) {
this.key = key;
this.value = value;
this.next = next;
}
}
…
}
假设你仅仅递归地克隆这个散列桶数组,就像我们能Stack类所做的那样:
@Override public HahsTable clone() {
try{
HashTable result = (HashTable)super.clone();
result.buckets = buckets.clone();
return result;
} catch (CloneNotSupportedException e) {
throw new AssertionError();
}
}
虽然被克隆对象有它自己的散列桶数组,但是,这个数组引用的链表对象与原始对象是一样的,从而很容易地引起克隆对象和原始对象中不确定的行为。为了修正这个问题,必须单独地拷贝并组成每个桶的链表,下面是一种常见做法:
public class Hashtable implements Cloneable {
private transient Entry buckets[];
private static class Entry {
final Object key;
Object value;
Entry next;
protected Entry(Object key, Object value, Entry next) {
this.key = key;
this.value = value;
this.next = next;
}
Entry deepCopy(){// 递归地深层复制每个链表节点对象
return new Entry(key, value, next == null ? null : next.deepCopy());
}
}
@Override public HahsTable clone() {
try{
HashTable result = (HashTable)super.clone();
result.buckets = new Entry[buckets.length];
// 采用循环的方式对每个桶引用的单链表进行深层拷贝
for(int i = 0; i < buckets.length; i++){
if(buckets[i] != null){
result.buckets[i] = buckets[i].deepCopy();
}
}
return result;
} catch (CloneNotSupportedException e) {
throw new AssertionError();
}
}
…
}
Entry类中的深度拷贝方法递归地调用它自身,以便拷贝整个链表。虽然这种方法很灵活,因为针对列表中的每个元素,它都要消耗一段空间。如果链表比较长,这很容易导致栈溢出,为了避免发生这种情况,你可以在deepCopy中用迭代代替递归:
Entry deepCopy(){
Entry result = new Entry(key, value, next);
for(Entry p = result; p.next != null; p = p.next){
p.next = new Entry(p.next.key, p.next.value, p.next.next);
}
return result;
}
简而言之,所有实现了Cloneable接口的类都应该用一个公有的方法覆盖clone(如果不是公有的且没有覆盖Object中的clone方法,则外界不能克隆该类的实例)。此公有方法首先调用super.clone,然后修正任何需要修正的域。
数组具有clone方法,但我们不能使用反射来调用该方法,但可以拿到数组对象后直接调用。
直接通过调用数组对象的clone方法克隆出的对象是否是深度克隆,则要看这个数组是否是基本类型的数组,如果是则属于深度克隆,否则不是(但是数组对象本身还是被复制了一份的,而不是指向数组同一存储空间了,只是数组里的引用还是指向原来数组指向的对象,如果此时需对元素再次深度克隆,则需要对数组里的每个元素进行单独克隆处理)。
使用Object中的默认clone对某个类进行克隆时,任何类型的数组属性成员都只是浅复制,即克隆出来的数组与原来类中的数组指向同一存储空间,其他引用也是这样,只有基本类型才深复制。
12、 考虑实现Comparable接口
如果一个类实现了Comparabler接口,就表明它的实例具有内在的自然排序规则了。事实上,Java平台类库中的所有值类都实现了Comparable接口。如果你正在编写一个值类,它具有非常的内在排序关系,比如按字母顺序、按数值顺序或按年代,那你就应该考虑实现这个接口:
public interface Comparable{
int compareTo(T t);
}
依赖于比较关系的类包括有序集全类TreeSet和TreeMap,以及工具类Collections和Arrays,它们内部包含有搜索和排序算法。
如果一个域并没有实现Comparable接口,或者你需要使用一个非标准的排序关系,就可以使用一个显式的Comparable来代替:
public interface Comparator {
int compare(T o1, T o2);
}
比较整型基本类型的域,可以使用关系操作符 == 、< 和 >。但浮点域要使用Double.compare或者Float.comprae,而不是用关系操作符。
如果一个类有多个关键域,那么从最关键的域开始,逐步进行到所有的重要域。如果某个域的比较产生了非零的结果,则整个比较操作结束,并返回该结果。如果最关键的域是相等的,则进一步比较次最关键的域,以此类推。如果所有的域都是相等,则对象就是相等的,并返回零,下面是第9条的PhoneNumber类的compareTo方法:
public int compareTo(PhoneNumber pn) {
// Compare area codes
if (areaCode < pn.areaCode)
return -1;
if (areaCode > pn.areaCode)
return 1;
// Area codes are equal, compare prefixes
if (prefix < pn.prefix)
return -1;
if (prefix > pn.prefix)
return 1;
// Area codes and prefixes are equal, compare line numbers
if (lineNumber < pn.lineNumber)
return -1;
if (lineNumber > pn.lineNumber)
return 1;
return 0; // All fields are equal
}
虽然这个方法可行,但可以改进一下,因为compareTo方法的规定并没有指定返回值的大小,而只是指定了返回值的符号:
public int compareTo(PhoneNumber pn) {
// Compare area codes
int areaCodeDiff = areaCode - pn.areaCode;
if (areaCodeDiff != 0)
return areaCodeDiff;
// Area codes are equal, compare prefixes
int prefixDiff = prefix - pn.prefix;
if (prefixDiff != 0)
return prefixDiff;
// Area codes and prefixes are equal, compare line numbers
return lineNumber - pn.lineNumber;
}
虽然比前面快一点,但用起来要非常小心。除非这些域不会为负数,或都更一般的情况:最小和最大的可能域值之差小于或等于Integer.MAX_VALUE,否则就不要使用这种方法。比如i是一个很大的正整数,而j是一个很大的负整数,那么i-j将会溢出。这不是理论,它已经在实际的系统中导致了失败,所以要格外小心,因为这样的compareTo方法对于大多数的输入值都能正常工作。
第四章 类和接口
13、 使类和成员的可访问性最小化
要区别设计良好的模块与设计不好的模块,最后重要的因素在于,这个模块对于外部的其他模块而言,是否隐藏其内部了数据和其他实现细节。设计良好的模块会隐藏所有的实现细节,把它的API与它的实现清晰地隔离开来。然后,模块之间只通过它们的API进行通信,一个模块不要知道其他模块的内部工作情况。这个概念被称为信息隐藏或封装,也是软件设计的基本原则之一。
封装有效地解除组成系统的各模块之间的耦合关系,使得这些模块可以独立地开发、测试与维护,还提高了软件的可重用性。
Java里的封装是通过访问控制符来加以限制的。
第一规则很简单:尽可能地使每个类或者成员不被外界访问,即使用尽可能最小的访问级别。
对于顶层类(非嵌套的)类和接口,只有两种可能的访问级别:包级私有的和公有的。如果使用pulbic修饰符声明了顶层类或者接口,那它就是公有的;否则,它将是包级私有的。如果顶层类或者接口能够被做成包级私有的,它就应该被做成包级私有,这样类或者是接口就成了这个包的实现的一部分,而不是该包导出的API的一部分,在以后的版本中,可以对它进行修改、替换、或者删除,而无需担心会影响到现有的客户端程序。
如果一个包级私有的顶层类(或者接口)只是在某一个类的内部用到,就应该考虑使它成为唯一使用它的那个类的私有嵌套类。这样可以将它的可访问范围从包中的所有类缩小到了使用它的那个类。然而,降低不必要公有类的可访问性,比降低包级私有的顶层类的更重要得多:因为公有类是包的API的一部分,而包级私有的顶层类只是这个包的实现的一部分,包级是我们可以掌控的一部分。
对于成员(域、方法、嵌套类和嵌套接口)有四种可能的访问级别,下面按照可访问性的递增顺序罗列出来:
1、 私有的(private):只有在声明该成员的顶层类内部才可以访问这个成员。
2、 包级私有的:声明该成员的包内部的任何的任何类都可以访问这个成员。这也是“缺省”访问级别,如果没有为成员指定访问修饰符,就采用这个访问级别。
3、 受保护的(protected):声明该成员的类的子类可以访问这个成员(但有一些限制[JLS,6.6.2]),并且声明该成员的包内部的任何类也可以访问这个成员。
4、 公有的(public):在任何地方都可以访问该成员。
只有当同一个包内的另一个类真正需要访问一个成员的时候,你才应该删除private修饰符,使该成员变成包私有的。
私有成员和包级私有成员都是一个类的实现中的一部分,一般不会影响它的导出的API。然而,如果这个类实现了Serializable接口(见第74和75),这些域就有可能会被“泄漏”到导出的API中。
对于公有类的成员,当访问级别从包级私有变成保护级别时,会大大增强可访问性。受保护的成员是类的导出的API的一部分,必须永远得到支持。导出的类的受保护成员也代表了该类对于某个实现细节的公开承诺(见第17条)。受保护的成员应该尽量少用。
如果方法覆盖了超类中的一个方法,子类中的访问级别就不允许低于超类中的访问级别。这样就可以确保任何可使用超类的实例的地方也都可以使用子类的实例。
如果一个类实现了一个接口,那么接口中所有的类方法在这个类中也都必须被声明为公有的。之所以如此,是因为接口中的所有方法都隐含着公有访问级别。
为了测试而将一个公有类的私有成员变成包级别私有的,这还可以接受,但是如果要将访问级别提高到超过包访问级别,这就无法接受了。换包话说,不能为了测试,而将类、接口、或成员变成包的导出的API的一部分。幸运的是,也没有必要这么做,因为可以让测试作为被测试的包的一部分来运行,从而能够访问它的包级私有的元素。
实例域决不能是公有的(见第14条)。如果域是非final的,或者即是final但指向的却是可变对象,那么一旦使这个域成为公有的,就放弃了对存储在这个域中的值进行限制的能力;这意味着,你也放弃了强制这个域不可变的能力。因此,包含公有可变域的类并不是线程安全的。即使域是final的,并且引用不可变的对象,当把这个域变成公有的时候,也就放弃了“切换换到一种新的内部了数据表示法”(比如将这个字段删除掉,或者使用多个字段来表示等)的灵活性。
public final修饰的域要么包含基本类型的值,要么包含指向不可变对象的引用(见第15条)(同样的建议也适用于静态域)。如果final域包含可变对象的引用,它更具有非final域的所有缺点,虽然引用本身不能被修改,但是它所引用的对象却可以被修改。
长度非零的数组总是可变的,所以,类具有公有的静态final数组域,或者返回这种域的访问方法(这与方法返回的是局部数组是不一样的,因为这个是共享的,而返回的局部数组是单个线程共享的),这几乎总是错误的。如果类具有这样的域或者访问方法,客户端将能够修改数组中的内容,这是安全漏洞的一个常见根源:
public static final Thing[] VALUES={…};
修正这个问题有两种方法,可以使公有数组变成私有的,并增加一个公有的不可变列表:
private static final Thing[] PRIVATE_VALUES={…};
public static final LIST VALUES = Collections.unmodifiableList(Arrays.asList(PRIVATE_VALUES));
另一种方法是,可以使用数组变成私有的,并添加一个公有方法,它返回私有的数组的一个备份:
private static final Thing[]PRIVATE_VALUES ={…};
public static final Thing[] values(){
return PRIVATE_VALUES.clone();
}
但依我个人看,上面的克隆还是不起根本作用(如果数组元素是基本类型的,就没有问题):虽然各个元素引用已被复制,但是它们所引用的对象却是同一份,如果指向的对象是可变的,则还是可以通过引用去修改它们所指向对象的问题,所以上面的修改也有潜在的问题。
从上面分析来看public static final最好修改的是基本类型的变量,或者是不可变的类。
总而言之,你应该始终尽量可能地降低可访问性。除了公有静态final域的特殊情形之外,公有类都不应该包含公有域。并且要确保公有静态final域所引用的对象都是不可变的。
14、 在公有类中使用访问方法而非公有域
如果类可以在它所在的包的外部进行访问,就将域设为私有的并提供域的访问与设置公有方法,以保留将来改变该类的内部表示法的灵活性。如果公有类暴露了它的数据域,要想在将来改变其内部表示法是不可能的,因为公有类的客户端已经遍布各处了。另外,如果不采取这种方式,则当域被访问的时候,无法彩任何辅助的行动。
如果类是包级私有的(虽然将域暴露了,但也只仅限于包含该类的包中,还在你可控的范围之内),或者是私有的嵌套类,直接暴露它的数据域并没有本质的错误——假设这些数据域确实描述了该类所提供的抽象。比如LinkedList中的Entry静态内部类,就暴露了所有的成员给外类了,这样用起来更简洁方便:
public class LinkedList{
//...
private static class Entry {
Object element;
Entry next;
Entry previous;
//...
}
}
让公有类直接暴露域虽然从来都不是种好办法,但是如果域是不可变的,这种做法危害就比较小一些(只是不能改变其指向,但指向的内容是否安全就不好说了)。如果不改变类的API,就无法改变这种类的表示法,当域被读取的时候,你也无法采取辅助的行动。
总之,公有类永远不应该暴露可变的域。虽然还是有问题,但是让公有类暴露不可变域其危害比较小。但是,有时候会需要使包级私有的或者私有的嵌套灰来暴露域,无论这个类是可变还是不可变的。
15、 使可变性最小化
不可变类是其实例不能被修改的类(不只是类前加上了final就可以了)。每个实例中包含的所有信息都必须在创建该实例时候就提供,并在对象的整个生命周期内固定不变。
Java平台类库上有很多不可变的类,其中有String、基本类型的包装类、BigInteger和BigDecimal。
存在不可变内的许多理由:不可变类比可变类更加易于设计、实现和使用。它们不容易出错,且更加安全。
为使类成为不可变,要遵循以下5条规则:
1、 不要提供任何会修改对象状态(属性)的方法。
2、 保证类不会被扩展。一般做法是使这个类成为final的,另外作法就是让类所有构造器都变成私有的或者是包级私有的。
3、 使用有的域都是final的(一般是针对非静态变量)。通过这种加上final修饰的强制方式,这可以清楚地表明你的意图:确保该域在使用前得到正确的初始化。而且,如果一个指向新创建的实例的引用在缺乏同步机制(一般不可变对象的访问是不需要同步的,因为状态一旦确定,就不会再更改)的情况下,从一个线程切换另一个线程就必需确保实例的正确状态(比如刚创建完这个实例,但还没来得及将工作内存中的数据存回到主内存中时就会有问题,但如果是加上了final修饰符后,则不会出现使用前final域还会初始化完成的情况,这样一定能保证构造器调用完后final域也会随之初始化完成。虽然以前很早的虚拟机上会出现构造器执行完成后final域未初始化完成的问题,但现已JMM已修复),正如果内存模型中所述那样[JLS 17.5]。
4、 使用所有的域都成为私有的。这样可以防止客户端获得访问被域引用的可变对象的权限,并防止客户端直接修改这些对象。虽然从技术上讲,允许不可变的类具有公有的final域,只要这些域包含基本类型的值或都指向不可变对象的引用,但是不建议这样做,因为这样会使得在以后的版本中无法以再改变内部的表示法。
5、 确保对于任何可变域的互斥访问。如果类具有指向可变对象域,则必须确保该类的客户端无法获得指向这些对象的引用。并且,永远不要用客户端提供的对象引用来初始化这样的域,也不要从任何访问方法中返回该对象的引用(即进出都不行)。在构造器、访问方法、readObject方法(见76条)中请使用保护性拷贝技术。
下面是一个不可变复数(具有实部和虚部)类的例子:
//复数
public final class Complex {
private final double re;//实部
private final double im;//虚部
// 私有的,让它无法扩展
private Complex(double re, double im) {
this.re = re;
this.im = im;
}
//静态工厂方法
public static Complex valueOf(double re, double im) {
return new Complex(re, im);
}
public static Complex valueOfPolar(double r, double theta) {
return new Complex(r * Math.cos(theta), r * Math.sin(theta));
}
public static final Complex ZERO = new Complex(0, 0);
public static final Complex ONE = new Complex(1, 0);
public static final Complex I = new Complex(0, 1);
// 可以直接返回基本类型的值
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 subtract(Complex c) {
return new Complex(re - c.re, im - c.im);
}
public Complex multiply(Complex c) {
return new Complex(re * c.re - im * c.im, re * c.im + im * c.re);
}
public Complex divide(Complex c) {
double tmp = c.re * c.re + c.im * c.im;
return new Complex((re * c.re + im * c.im) / tmp, (im * c.re - re
* c.im)
/ tmp);
}
public boolean equals(Object o) {
if (o == this)
return true;
if (!(o instanceof Complex))
return false;
Complex c = (Complex) o;
// 浮点数的比较要使用Double.compare,而不能直接使用==比较
return Double.compare(re, c.re) == 0 && Double.compare(im, c.im) == 0;
}
public int hashCode() {
int result = 17 + hashDouble(re);
result = 31 * result + hashDouble(im);
return result;
}
private int hashDouble(double val) {
long longBits = Double.doubleToLongBits(re);
return (int) (longBits ^ (longBits >>> 32));
}
public String toString() {
return "(" + re + " + " + im + "i)";
}
}
不可变对象只有一种状态,即被创建时的状态。不可变对象本质上是线程安全的,它们不要求同步,可以被自由的共享。不可变类应该充分利用这种优势,鼓励客户端尽可能地重用现有的实例,要做到这一点,一个很简便的办法就是,对于频繁用到的值,为它们提供公有的静态final常量,例如上面的常量:
public static final Complex ZERO = new Complex(0, 0);
public static final Complex ONE = new Complex(1, 0);
public static final Complex I = new Complex(0, 1);
这种方法可以被进一步的扩展,不可变以的类可以提供一些静态工厂,它们把频繁请求主的实例缓存起来,在请求合适的对象时候,就不必要创建新的实例。所有的基本类型的包装类和BigInteger都有这样的静态工厂。使得实例可以共享,从而降低内存与垃圾回收成本。在设计类时,选择用静态工厂代替公有的构造器可以让你以后有缓存的灵活性,而不必影响客户端。
“不可变对象可以被自由地共享”,我们永远也不需要,也不应该为不可变对的类提供clone方法或者拷贝构造器。这一点在Java平台早期的版本中做得并不好,所以String类仍然具有拷贝构造器,但是应该尽量少用它。
不仅可以共享不可变对象,甚至也可以共享它们的内部信息。如BigInteger的negate方法产生一个新的BigInteger,其中数组是一样的,符号则是相反的,它并不需要拷贝数组;新建的BigInteger也指向原始实例中的同一个内部数组。
不可变类真正唯一的缺点是,对于每个不同的值都需要一个单独的对象,创建这种对象的代价可能很高,特别是对于大型对象的情形。
如果你选择让自己的不可变实现Serializable接口,并具它包含一个或者多个指向可变对象的域,就必须提供一个显示的readObject或者readResolve方法,或者使用ObjectOutputStream.writeUnshared和ObjectInputStream.readUnshared方法,否则攻击者或能从不可变的类创建可变的实例。这个话题详细请参见76条。
除非有很好的理由要让类成为可变的类,否则就应该是不可变的。不可变的类优点有很多,唯一缺点是在特定的情况下存在潜在的性能问题。你应该总是使用一些小的值对象(如Complex),成为不可变的。但你也应该认真考虑把一些较大的值对象做成不可变的(String、BigInteger),只有当你确认有性能问题时,才应该为不可变的类提供一个公有的可变配套类(如String的配套类StringBuffer、StringBuilder;还有BigInteger的配套类为BitSet)。
对于有些类而言,基不可变性是不切实际的。如果为不能被做成是不可变的,仍然应该尽可能地限制它的可变性。除非有使人信服的理由要使域变成是非final的,否则要使每个域都是final的。
构造器应该创建完全初始化的对象,并建立起所有的约束关系。不要在构造器或者静态工厂之外再提供公有的初始化方法,除非有使人信服的理由。同样也不应该提供“重新初始化”方法(比如TimerTask类,它是可变的,一旦完成或被取消,就不可能再对它重新调度),与所增加的复杂性相比,通常并没有带来太多的性能。
关于 不可变对象可变的修复问题 请参考:http://www.ibm.com/developerworks/cn/java/j-jtp02244/
总之,新的内存模型中,以前不可变对象与final域的可变问题得到了修复,可以放心的使用了(为了确保String的安全初始化,1.5中String的value[]、offset、count三个字段前都已加上了final修饰符,这样新的Java内存模型会确认final字段的初始化安全)。
16、 组合优先于继承
继承是实现代码重用的有力手段,但它并非是最好的手段。
在包的内部使用继承是非常安全的,在那里,子类和超类的实现都处在同一个程序员的控制之下,即你还可以修改一下超类的东西。对普通的具体类进行跨越包边界的继承,则是非常危险的,因为你一旦发布包之后,你就得要遵循你的接口,而不能轻易的去修改它而影响客户端已有的应用。
与组合复用不同的是,继承打破了封装性。换句话说,子类依赖于其超类中特定的功能的实现细节。超类的实现有可能会随着发行版本的不同而有所变化,如果真的发生了变化,子类可能会遭到破坏,即使它的代码完全没有改变。
下面程序要使用HashSet功能,直接采用了继承:
public class InstrumentedHashSet extends HashSet {
/*
* 用来记录添加了多少个元素,与HashSet的size是不一样的
* HashSet的size会受到删除的影响,而这个只记录添加的元素
* 个数
*/
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);//最终还是调用HashSet 的add方法将元素存入
}
/*
* super.addAll是以调用HashSet 的add方法来实现的,而add方法又被子类
* 重写了,所以该类的add方法也会被再次调用,即实质上调用addAll
* 方法的时候,它也会去调用一下add方法,这在我们不调试的情况下
* 是很难发现的,这就是继承所带来的后果:因为我们依赖了父类的实现的细节。
*/
@Override public boolean addAll(Collection extends E> c) {
addCount += c.size();
return super.addAll(c);
}
public int getAddCount() {
return addCount;
}
public static void main(String[] args) {
InstrumentedHashSet s = new InstrumentedHashSet();
s.addAll(Arrays.asList("Snap", "Crackle", "Pop"));
System.out.println(s.getAddCount());
}
}
这个类看起来非常合理,但是它并不能正常工作,上面的子例返回的是6,而不是我们期望的3,为什么?在HashSet的内部,addAll方法是基于它的add方法来实现的,即addAll是循环调用add来实现的(即使HashSet的文档并没有说明这样的细节也是合理的,因为该类就不是为了用来继承的),而这add方法又被我们设计的这个类InstrumentedHashSet重写了,所以会再次调用我们的add方法。
我们只去掉被重写的addAll方法中的“addCount += c.size();”,就可以“修正”这个子类,但我们不能否认这样的实事:HashSet的addAll方法是在它的add方法上实现的,这种“自用性(self-use)”是实现的细节,它不是承诺,所以Sun不能保证在Java平台的所有版本实现中都保持不变,即不能保证随着发行版本的不同而不发生变化(比如说将来HashSetr addAll不是以调用add方法来实现),这样的实现细节是不必承诺了,他们能承诺的也只是公开的接口而已,因此,这样得到的InstrumentedHashSet类将是非常脆弱的。
这里稍微好一点的做法是,在InstrumentedHashSet中重写addAll方法来遍历指定的集合,为每个元素调用一次add方法,这样做可以保证得到正确的结果 ,不管HashSet的addAll方法是否是以调用add方法来实现的,因为HashSet的addAll实现将不会再被调用:
public boolean addAll(Collection extends E> c) {
boolean modified = false;
Iterator extends E> e = c.iterator();
while (e.hasNext()) {
//直接调用自己重写过的add方法,重写addAll后,不再依赖于HashSet的addAll
if (add(e.next()))
modified = true;
}
return modified;
}
以上实现只是把HashSet的addAll方法Copy过来而已。然而,这项技术并没有解决所有的问题,它相当于重新自己实现了超类的方法,这些超类的方法可能是自用的(self-use),也可能不是,如果是则这里的重新实现还有点意义,如果不是,则重新实现则完全是没有必要的,并且可能很容易出错。另外有一点,也许你说管他父类的addAll方法是否是自用的,我重新实现就是了,但是,你想过没有,如果父类中的addAll修改了某个私有的域的状态,那你该如何做?我们是无法在重新实现时修改父类中的那个私有域的状态的,因为我们根本无法访问到,所以,总之这种方案是不可取的,因为有太多的不确定因素。
导致子类脆弱的一个相关的原因就是,它们的超类在后续的版本中可能添加新的方法。假设我们的程序的有这样一个要求:所有被插入到某个集合中的元素都必须满足某个条件(比如上面在放入之前addCount会记录一下)。这样做是可以确保一点:对集合进行子类化,并覆盖所有能够添加元素的方法,以便确保在加入每个元素之前它是满足这个先决条件的。如果在后续的版本中,超类中没有增加能插入元素的新方法,这种做法是安全的。然而,一旦超类增加了这样的新方法,则很可能仅仅由于调用了这个未被覆盖的新的方法,而将“非法的”元素添加到子类的实例中。这不是纯粹的理念问题,在把Hashtable和Vector加入到集合框架中时,就修正了这几个类性质的安全漏洞。
上面的问题都是来源于覆盖动作。如果在继承一个类的时候,仅仅是添加新的方法,而没有覆盖现有的父类中的方法,你可能会认为是安全的,但是不然,比如你现在已继承了某个类,并扩展了父类,并添加了父类中没有方法,但是有可能将来父类也会添加同样签名的方法,但重写的条件不满足时,此时你设计的子类将不能编译,或者即使满足重写条件,这样又会有问题(如父类私有域状态的维护工作)。这样的问题不是不存在的,因为当你在编写子类方法时候,你肯定是不会知道将来父类会增加这样的名称的方法。
幸运的是,有一种办法可以避免前面提到的两个问题(覆盖可能影响父类私有域的状态不正确,部分覆盖又可能影响“自用性(self-use)”),不用继承现有类,而是在新的类中增加一个私有域,它引用现有类的一个实例。这种设计被称做为“组合”,因为现有类变成了新的类的一个组件。新类中的每个实例方法都可以调用被包含的现有类实例中对应的方法,并返回它的结果。这被称为“转发”,新类中的方法被称为转发方法,这样得到的类将会非常稳固,它不依赖于现有类的实现细节,即使现有的类添加了新的方法,也不会影响新的类。下面我们使用“组合”来修正前面两个问题,注意这个实现分为两部分:类本身和可重用转发类,转发类没有其他方法,全是转发方法:
//功能不变,接口也不变,这叫转发
public class ForwardingSet implements Set {
private final Set s;//组合
public ForwardingSet(Set s) { this.s = s; }
//下面全是转发方法
public void clear() { s.clear(); }
public boolean contains(Object o) { return s.contains(o); }
public boolean isEmpty() { return s.isEmpty(); }
public int size() { return s.size(); }
public Iterator iterator() { return s.iterator(); }
public boolean add(E e) { return s.add(e); }
public boolean remove(Object o) { return s.remove(o); }
public boolean containsAll(Collection> c)
{ return s.containsAll(c); }
public boolean addAll(Collection extends E> c)
{ return s.addAll(c); }
public boolean removeAll(Collection> c)
{ return s.removeAll(c); }
public boolean retainAll(Collection> c)
{ return s.retainAll(c); }
public Object[] toArray() { return s.toArray(); }
public T[] toArray(T[] a) { return s.toArray(a); }
//下面全是重写Object中的方法
@Override public boolean equals(Object o)
{ return s.equals(o); }
@Override public int hashCode() { return s.hashCode(); }
@Override public String toString() { return s.toString(); }
}
public class InstrumentedSet extends ForwardingSet {
private int addCount = 0;
public InstrumentedSet(Set s) {
super(s);
}
@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;
}
public static void main(String[] args) {
InstrumentedSet s =
new InstrumentedSet(new HashSet());
s.addAll(Arrays.asList("Snap", "Crackle", "Pop"));
System.out.println(s.getAddCount());
}
}
Set接口的存在使得InstrumentedSet类的设计成Set集合类成为可能,因为Set接口保存了HashSet类的功能特性,除了获得健壮性之外,这种设计也带来了格外灵活性。InstrumentedSet类实现了Set接口,并且拥有单个构造器,它的参数也是Set类型,从本质上讲,这个类把一个Set转变成了另一个Set,同时增加了计数器功能。
因为每一个InstrumentedSet实例都把另一个Set实例包装起来了,所以InstrumentedSet类被称做为包装类,这也正是Decorator装饰模式。InstrumentedSet类对一个集合进行了装饰,为它增加了计数特性。有时,组合和转发的结合也被错误地称为“委托”,从技术的角度而言,这不是委托,除非包装对象(InstrumentedSet)把自身传递给被包装的对象(HashSet)。
组合类几乎没有缺点,但需要注意的一点是,包装类不适合用在回调框架中;在回调框架中,对象把自身的引用传递给其他的对象,所以它传递一个指向自身的引用(this),回调时避开了外面包装对象,这被称为SELF问题。
有些人担心转发方法调用所带来的性能影响,或者包装对象导致的内存占用。在实践中,这两者都不会造成很大的影响。编写转发方法倒有点琐碎,但是只需要给每个接口编写一次构造器,转发类则可以通过包含接口的包替你提供。
只有当子类真正是超类的子类型时,才适合用继承。换句话说,对于两个类A和B,只有当两者之间确实存在“is-a”关系的时候,类B才应该扩展类A。如果你打算让类B扩展类A,就应该问问自己了:每个B确实也是A吗?如果不能确定,通常情况下,B应该包含A的一个私有实例,并且暴露一个较小的、比较简单的API:A本质上不是B的一部分,只是它的实现细节而已。
在Java平台类库中,有许多明显违反这条原则的地方,例如,栈并不是向量,所以Stack不能继承Vecotr;属性列表也不是散列,所以Properties不能继承Hashtable,在这种情况下,复合模式才是恰当的。
如果在适合于使用组合的地方使用了继承,则会不必有地暴露实现细节(如暴露不必要的接口导致外界调用这些接口来非法改变实例的状态)。这样得到的API会把你限制在原始的实现上,永远限定了类的性能,更为严重的是,由于暴露了内部的细节或不必要的接口,客户端就有可能直接访问这些内部细节,从而破坏实现的内部状态。
继承机制会把超API中的所有缺陷传播到子类中,而复合则允许设计新的API来隐藏这些缺陷。
简而言之,继承的功能非常强大,但是也存在诸多的问题,因为它违背了封装的原则。只有当子类和超类之间确实存在子类型关系时,使用继承才是恰当的。即使如此,如果子类和超类外在不同的包中,并且超类并不是为了继承而设计的,那么继承将会脆弱性,为了避免这种脆弱性,可以用复合和转发机制来代替继承,尤其是当存在适当的接口可以实现包装类的时候。包装为不仅比子类更加健壮,而且功能也更加强大。
17、 要么为继承而设计并提供文档说明,要么就禁止继承
第16条提醒我们,继承自一个不是为了继承而设计、并且没有文档说明的“外来”类是危险的。
如果一个类是专为了继承而设计的,要求有哪些呢?
首先,该类的文档必须精确地描述覆盖每个方法所带来的影响。换句话说,该类必须有文档说明它可覆盖的方法的自用性(self-use)。对于每个公有的或受保护的方法或者构造器,它的文档必须指明该方法或者构造器调用了哪些可覆盖的方法,是以什么顺序调用的,每个调用的结果以是如何影响后续的处理过程的(如在哪些情况下它会调用可覆盖的方法,必须在文档中说明)。
按照惯例,如果方法调用到了可覆盖的方法,在它的文档注释的末尾该包含关于这些调用的描述信息。这段描述信息要以这样的句子开头:“This implementation.(该实现...)”。这样的句子不是在表明该方法可能会随着版本的变迁而改变,而是表明了该方法的内部工作情况。下面是摘自java.util.AbstractCollection的规范:
public boolean remove(Object o):
从此 collection 中移除指定元素的单个实例(如果存在)(可选操作)。更正式地说,如果该 collection 包含一个或多个满足 (o==null ? e==null : o.equals(e)) 的元素 e,则移除 e。如果该 collection 包含指定的元素(或等价元素,如果该 collection 随调用的结果发生变化),则返回 true。
此实现在该 collection 上进行迭代,查找指定的元素。如果找到该元素,那么它会使用迭代器的 remove 方法从该 collection 中移除该元素。注意,如果此 collection 的 iterator 方法所返回的迭代器无法实现 remove 方法,并且此 collection 包含指定的对象,那么此实现会抛出 UnsupportedOperationException。
该文档清楚地说明了,覆盖iterator方法将会影响remove方法的行为。而且,它确切地描述了iterator方法返回的Iterator的行为将会臬影响remove方法的行为。与此相反,在第16条的情形中,程序员在子类化HashSet时,并没有说明覆盖add方法是否会影响addAll方法的行为。
关于程序文档有句格言:好的API文档应该描述一个给定的方法做了什么工作,而不是描述它是如何做到的。而HashSet的add方法违背了这句格言,这正是继承破坏了封装性所带来的不幸后果。所以,为了设计一个类的文档,以便它能够被安全的继承,你必须描述清楚那些有可能未定义的实现细节(引导子类如何实现)。
类可以通过某种形式提供适当的钩子(hook),以便能够进行入到它的内部工作流程,这种形式可以是精心选择的受保护(protected)方法。
为了允许继承,类还必须遵守其他一些约束。构造器决不能调用可被覆盖的方法。因为超类的构造器在子类的构造器之前运行,所以,子类中覆盖版本的方法将会在子类的构造器运行之前就先被调用,如果该覆盖版本的方法依赖于子类构造器所执行的任何初始化工作,该方法将不会如预期般地执行。
如果你决定在一个为了继承而设计的类中实现Cloneable或者Serializable接口,就应该意识到,因为clone和readObject方法在行为上非常类似于构造器,所以类似的限制规则也是适用的:无论是clone还是readObject,都不可以调用可覆盖的方法。对于readObject方法,覆盖版本的方法将在子类的状态被反序列化前先被运行;而对于clone方法,覆盖版本的方法则在子类的clone方法调用来修正 被克隆对象的状态之前先被运行(即父类的clone方法调用了覆盖的方法,而子类的clone却还没调用,这样就可能没有初始化这个覆盖方法所依赖的数据)。
对于普通的具体类应该怎么办?他们即不是final的,也不是为了子类化而设计和编写文档的,所以这种状况很危险。这个问题的最佳解决方案是,对于那些并不能安全进行子类化又没有编写文档的类,要禁止子类化。禁止子类化有两种方法,一种就是定义成final的,另一种就是私有构造器,交提供静态工厂方法,可参考第15条。
这条建议可能会引来争议,因为许多程序员已经习惯了对普通的具体类进行子类化,以便增加新的功能。如果这个普通类实现了某个接口,比如Set、List或者Map,就完全可以禁止该普通类可子类化,因为即使禁止了,我们可以采第16条里的组合与转发即包装模式来提供一种更好的办法;如果具休的类并没有实现标准的接口,那么禁止继承可能会给有些程序员带来不便,如果认为必须允许从这样的类继承,一种合理的办法是确保这个类永远不会自己调用自已的任何可覆盖的方法,并在文档中说明这一点,换句话说,完全消除这个类中可覆盖方法的自用特性,这样做后就可以创建“能够安全地进行子类化”的类。
当然,你可以使用一种机械的做法来消除可覆盖方法的自用特性,而不改变它的功能行为。就是直接将被调用的可覆盖方法的实现代码Copy到另外可覆盖方法中,让它们不相互依赖调用,那么此时子类就可以继承并覆盖这些方法了。
18、 接口优于抽象类
如果这些方法尚不存在,你所需要做的就是只是增加必要的方法,然后在类的声明中增加一个implements子句,实现抽象方法即可。但一般来说,你无法直接修改当前类来扩展新的抽象类,此时你只能将抽象类从继承树的最底向上移到最顶层,
实现新的接口,现有类可以很容易被更新:从代码重构的角度上讲,将一个单独的Java具体类重构成一个实现某个Java接口是很容易的,只需要声明一个Java接口,并将重要的方法添加到接口声明中,然后在具体定义语句后面加上一个合适的implements关键字即可, 例如,当Comparable接口被引入到Java平台中时,会更新许多现有的类,以实现Comparable接口;而为一个具体类添加一个抽象类作为抽象类型却不是那么容易,因为这个具体类可能已经有了一个超类。这样一来,这样新定义的抽象类只好继续向上移动,变成这个超类的超类,这样,最后这个新定义的抽象类必定处于整个类型等级结构的最上端,从而使等级结构中的所有成员都会受到影响,无论这个抽象类对于这些后代类是否合适。
接口是定义混合类型的理想选择:混合类型就是类除了实现或继承了它的“基本类型(也可叫主要类型)”之外,还可以实现这个混合类型,以表明它提供了某些可供选择的行为。例如,Comparable就是一个混合接口,它允表明实现了Comparable混合接口的实例可以与其他的可相互比较的对象进行排序。抽象类是不能被用于定义混合类型的,同样也是因为它们不能被添加到现有的类中:类不可能有一个以上的父类,类层次结构中也没有适当的地方来插入这种混合类型。
接口允许我们构造非层次结构的类型框架:类型层次对于组织某些事物是非常合适的,但是其他有些事物并不能被整齐地组织成一个严格的层次结构。例如,假设我们有一个接口代表一个Singer(歌唱家),另一个接口代表一个SongWriter(作曲家):
public interface Singer{
AudioClip sing(Song s);
}
public interface SongWriter{
Song compose(boolean hit);
}
在现实生活中,有些歌唱家本身也是作家。因为我们这里使用了接口而不是抽象类来定义这些类型,所以对于单个类而言,它同时实现Singer和SongWriter是完全允许的。实际上,我们可以定义第三个接口,它同时扩展了Singer和SongWriter,并添加了一些适合于这种组合的新方法:
public interface SingerSongwriter extends Singer,SongWriter{
AudioClip strum();
void actSensitive();
}
你也许并不总需要这种灵活性,但是一旦你这样做了,接口可就成了救世主,能帮助你解决大问题,因为这种非层关系的类我们可以自由的组合。这里另外一种做法是编写一个臃肿的类层次,对于每一种要被支持的属性组合,都包含一个单独的类。如果在整个类型系统中有n个属性,那么就必须支持2^n种可能的组合,这种现象被称为“组合爆炸”,类层次的臃肿会导致类也臃肿,这些类包含许多方法,并且这些方法只是在参数类型上有所不同而已,因为类层次中没有任何类型体现了公共的行为特征。所以这里如果使用抽象类来定义的话显示是不合适的,
虽然接口不允许包含方法的实现,但是,使用接口来定义类型并不妨碍你为程序提供实现上的帮助。通过对你导出的每个重要接口都提供一个抽象类,将公用的方法实现,把接口和抽象类的优点结合起来。接口的作用仍然是定义类型,但是抽象类接管了所有与接口实现相关的工作。
按照惯例,我们一般将提供的抽象类命名为AbstractInterface这种形式,这里的Interface是指所实现的接口的名字。例如,集合框架为每个重要的集合接口都提供了一个骨架实现,如AbstractCollection、AbstractSet、AbstractList和AbstractMap。再来看看这些集合类的定义:
public class ArrayList extends AbstractList implements List{}
public class HashSet extends AbstractSet implements Set{}
public class HashMap extends AbstractMap implements Map{}
如果设计得当,抽象类可以使用程序员很容易就提供他们自己的接口实现。例如,下面是一个静态工厂方法,它包含一个完整的、功能全面的List实现:
public class IntArrays {
static List intArrayAsList(final int[] a) {
if (a == null)
throw new NullPointerException();
return new AbstractList() {
public Integer get(int i) {
return a[i]; // Autoboxing (Item 5)
}
@Override public Integer set(int i, Integer val) {
int oldVal = a[i];
a[i] = val; // Auto-unboxing
return oldVal; // Autoboxing
}
public int size() {
return a.length;
}
};
}
public static void main(String[] args) {
int[] a = new int[10];
for (int i = 0; i < a.length; i++)
a[i] = i;
List list = intArrayAsList(a);
Collections.shuffle(list);
System.out.println(list);
}
}
编写抽象类相对比较简单,只是有点单调乏味。首先,必须认真研究接口,并确定哪些方法是最为基本的,其他的方法则可以根据它们来实现。这些基本的方法将成为抽象类中的抽象方法。然后,必须为接口中所有其他的方法提供具体的实现。例如,下面是Map.Entry接口的抽象类:
public abstract class AbstractMapEntry implements Map.Entry {
// 基本操作,定义为抽象的
public abstract K getKey();
public abstract V getValue();
// 可修改的Map中的实体Entry需要实现
public V setValue(V value) {
throw new UnsupportedOperationException();
}
// 实现共(通)用接口Map.Entry.equals
@Override public boolean equals(Object o) {
if (o == this)
return true;
if (! (o instanceof Map.Entry))
return false;
Map.Entry,?> arg = (Map.Entry) o;
return equals(getKey(), arg.getKey()) &&
equals(getValue(), arg.getValue());
}
private static boolean equals(Object o1, Object o2) {
return o1 == null ? o2 == null : o1.equals(o2);
}
// 实现共(通)用接口Map.Entry.hashCode
@Override public int hashCode() {
return hashCode(getKey()) ^ hashCode(getValue());
}
private static int hashCode(Object obj) {
return obj == null ? 0 : obj.hashCode();
}
}
抽象类的唯一优点就是抽象类的演变比接口的演变要容易得多:如果在后续的发行版本中,你希望在抽象类中增加新的方法,你始终可以增加具体方法,如果向一个抽象类加入一个新的具体方法,那么所有的子类一下子就都得到了这个新的具体方法,而Java接口做不到这一点,如果向一个Java接口加入一个新方法的话,所有实现这个接口的类就不能通过编译了,因为它们都没有实现这个新声明的方法。这显然是Java接口的一个缺点。
由于Java抽象类具有提供缺省实现的优点,而Java接口具有其他所有的优点,所以联合使用就是一个很好的选择。如果一个具体类直接实现这个Java接口的话,它就必须自行实现所有的接口;相反,如果它继承自抽象类的话,它可以省去一些不必要的方法,因为它可以从抽象类中自动得到这些方法的缺省实现。如果需要向Java接口加入一个新的方法的话,那么只要同时向这个抽象类加入这个方法的一个具体实现就可以了,因为所有继承自这个抽象类的子类都会从这个抽象类得到这个具体方法。这其实就是一种缺省适配器模式。一般来说,要想在公开的接口增加方法,而不破坏实现这个接口的所有现有类,这是不可能的,除非像上面说的那样,一开始就让某个类实现接口的时候,也继承抽象类,但这是不完全可能的,所以不从抽象类继承的接口实现类仍然会无法编译。
因此,设计公有接口要非常谨慎,接口一旦被公开发行,并且已被广泛实现,再想改变这个接口几乎是不可能的。你必须在初次设计的时候就保证接口是正确的。如果接口包含即使微小的瑕疵,它将会一直影响接口用户。如果接口具有严重缺陷,它可以导致API彻底的失败。
简而言之,接口通常是定义允许多个实现的类型的最佳途径。这条规则有个例外,即当演变的容易性比灵活性和功能更为重要的时候,在这种情况下,应该使用抽象类来定义类型,但前提是必须理解并且可以接受抽象类的局限性。如果你导出一个重要接口,就应该坚决考虑同时提供一个抽象类。最后,应该尽可能谨慎地设计所有公有接口,一旦发行,将不可更改。
19、 接口只用于定义类型
当类实现接口时,接口就充当可以引用这个类的实例的类型。因此,类实现了接口,就表明客户端可以对这个类的实例实施某些动作。为了任何其他目的而定义接口是不恰当的。
有一种接口称为常量接口,它违反了上面的条件,这种接口不包含任何方法,它只包含静态的final域,每个域都导出一个常量。使用这些常量的类实现这个接口,以避免用类名来修饰常量名:
public interface PhysicalConstants {
static final double AVOGADROS_NUMBER = 6.02214199e23;
}
class Sub implements PhysicalConstants {
public static void main(String[] args) {
System.out.println(AVOGADROS_NUMBER);
}
}
常量接口模式是对接口的不良使用。类在内部使用某些常量,这纯粹是实现细节。实现常量接口,会导致把这样的实现细节泄露到该类的导出API中。类实现常量接口没有什么价值。如果在将来的发行版本中,这个类被修改了,它不再需要使用这些常量了,它依然必须实现这个接口,以确保二制兼容性。如果非final类实现了常量接口,它的所有子类的命名空间也会被接口中的常量所“污染”。Java平台类库中有几个常量接口,例如java.io.ObjectStreamConstants,被认为是反面例子,不值得效仿。
如果要导出常量,可以有几种合理的方案。如果这些常量与某个现有的类或者接口紧密相关,就应该把这些常量添加到这个类或者接口。例如,在Java平台类库中所有的数值包装类,如果Integer和Double,都导出了MIN_VALUE和MAX_VALUE常量。如果这些常量最好被看作枚举类型的成员,就应该使用枚举类型(见第30条)来导出这些常量。否则,应该使用不可实例化的工具类(见第4条)来导出这些常量,下面是前面的PhysicalConstants例子的工具类翻版:
public class PhysicalConstants {
private PhysicalConstants() { } // 私有构造器
public static final double AVOGADROS_NUMBER = 6.02214199e23;
}
如果大量利用工具类导出的常量,可以通过利用静态导入机制,避免用类名来修饰常量,不过,静态导入机制是在1.5中才引用的。
简而言之,接口应该只被用来定义类型,它们不应该被用来导出常量。
20、 类层次优于标签类
有时候,可能会遇到带有两种甚至更多风格(功能)的实例的类,并包含表示实例风格的标签域,例如:
class Figure {
enum Shape { RECTANGLE, CIRCLE };
// 标签域 - 是圆形还是长方形
final Shape shape;
// 这两个域仅用于长方形
double length;
double width;
// 这个域仅用于圆形
double radius;
// 圆形构造器
Figure(double radius) {
shape = Shape.CIRCLE;
this.radius = radius;
}
// 长方形构造器
Figure(double length, double width) {
shape = Shape.RECTANGLE;
this.length = length;
this.width = width;
}
// 求面积
double area() {
//不同的形状有不同的算法
switch(shape) {
case RECTANGLE:
return length * width;
case CIRCLE:
return Math.PI * (radius * radius);
default:
throw new AssertionError();
}
}
}
这种标签类有着许多缺点。它们中充斥着样板代码(同一时刻只用到一种功能),包括枚举声明、标签域以及条件语句。破坏了可读性,内存占用也增加,因为实例承担着属于其他风格的不相关的域。总之,标签类过于冗长、容易出错、并且效率低下。
使用子类化能更好地设计这个类。为了将标签转变成类层次,首先要为每个依赖于标签的方法都定义成一个抽象方法并放在抽象类中。在Figure类中,只有一个这样的方法:area。这个抽象类是类层次的根。如果还有其他的方法其行为不依赖于标签域,就应该把这样的方法放在这个抽象类中。同样地,如果所有的方法都用到了某些数据域,应该把它们放在这个抽象类中。不过,在Figure类中,不存在这种类型独立的方法或者数据域,以下是重构:
abstract class Figure {
abstract double area();
}
class Circle extends Figure {
private final double radius;
Circle(double radius) { this.radius = radius; }
double area() { return Math.PI * (radius * radius); }
}
class Rectangle extends Figure {
private final double length;
private final double width;
Rectangle(double length, double width) {
this.length = length;
this.width = width;
}
double area() { return length * width; }
}
所有的域都是final的,编译器确保每个类的构造器都初始化它的数据域,对于根类中声明的每个抽象方法,都确保有一个实现,这样杜绝了由于遗漏swithc case而导致运行时失败的可能性。这个类层次纠正了前面提到过的标签类所有缺点。
类层次的另一好处在于,它们可以用来反映类之间本质上的层次关系,有助于后期的扩充。假设现有加一个正方形有,标签类就需要修改源码,而利用类层次结构只需新加一个正文型类,并继承自长方形,这样可以反映出它是一种特殊的长方性:
class Square extends Rectangle {
Square(double side) {
super(side, side);
}
}
简而言之,标签类很少有适用的时候,当你想使用的时,这个标签是否可以被取消,这个类是否可以用类层次来代替。当你遇到一个包含标标签域的现有类时,就要考虑将它重构到一个层次结构中去。
21、 用函数对象表示策略
Java没有提供函数指针,但是可以用对象引用实现同样的功能。
考虑下面的类:
public class StringLengthComparator {
public int comare(String s1, String s2) {
return s1.length() - s2.length();
}
}
它是一个字符长度比较器,只有这样的一个比较功能的方法,如果一个类仅仅导出某个特定功能的方法,它的实例实际上就等同于一个指向该方法的指针,这样的实例被称为函数对象。这是一种策略的应用,换句话说,StringLengthComparator实例是用于字符串比较操作的具体策略对象。
作为典型的具体策略类,StringLengthComparator类是无状态的:它没有域,所以,这个类的所有实例在功能上都是相互等价的。因此,它作一个Singleton是非常合适的,可以节省不必要的对象创建开销(见第3与第5条):
public class StringLengthComparator {
private StringLengthComparator(){}
public int comare(String s1, String s2) {
return s1.length() - s2.length();
}
}
为了把StringLengthComparator实例传递给方法,需要适当的参数类型,直接使用StringLengthComparator并不好,因为客户端将无法传递任何其他的比较策略,即不能随时动态的改变比较性为。此时,我们可以定义一个比较策略接口,这个接口在java.util包就已提供,我们不需要再提供,我们直接实现它:
import java.util.Comparator;
public class StringLengthComparator implements Comparator {
private StringLengthComparator() {}
public int compare(String s1, String s2) {
return s1.length() - s2.length();
}
}
具体的策略类往往使用匿名类(见第22条)来声明,下面的语句根据长度对一个字符串数组进行排序:
Arrays.sort(strArr,new Comparator(){
public int compare(String s1, String s2) {
return s1.length() - s2.length();
}});
但是注意,以这种方式使用匿名类时,将会在每次执行调用的时候创建一个新的实例。如果它被重复执行,考虑将函数对象存储到一个私有的静态final域时重复使用它。
因为策略接口被用做所有具体策略实例的类型,所以我们并不需要为了导出具体策略,而把具体策略类做成公有的。我们可以把具体策略类定义成静态的内部类,让外部类直接导出公有的静态策略对象域(或定义成私有的后通过静态的工厂方法返回),其类型为策略接口。下面的例子使用静态成员类,而不是匿名类,这样允许我们的具体的策略类实现第二个接口Serializable:
public class Outer {
private static class StrLenCmp implements Comparator, Serializable {
public int compare(String s1, String s2) {
return s1.length() - s2.length();
}
}
public static final Comparator STRING_LEN_CMP = new StrLenCmp();
//...
}
这新我们很容易改变比较的策略算法,比如要导出一个不区分大小定的字符串比较器就很容易了,只要提供的接口不变。
简而言之,函数的主要用途就是实现策略模式。为了在Java中实现这种模式,要声明一个接口来表示该策略,并且为每个具体策略声明一个实现了该接口的策略类。当一个具体策略只被使用一次时,通常使用匿名类来声明和实例化这个具体匿名策略类。当一个具体策略是设计用来重复使用的时候,它的类通常要被实现为私有的静态成员类,并通过公有的静态final域被导出,其类型为该策略接口。
22、 四种嵌套类中优先考虑静态成员类
嵌套类是指被定义在一另一个类的内部的类。它的目的只为外围类提供服务,如果将来它可能会单独用于其他的某个环境中,它应该是顶层类。嵌套类有四种:静态成员类、非静态成员类、匿名类、局部类,除了第一种,其他三种被称为内部类,静态与非静态成员类又称为成员类。
静态成员类的一种常见用法是作为公有的辅助类,仅当与它的外部类一起使用时才有意义。例如,这样的一个枚举类,它描述了计算器支持的各种操作(见30条)。Operation枚举应该是Calculator类的公有静态成员类,然后,Calculator类的客户端就可以采用如Calculator.Operation.PLUS和Calculator.Operation.MINUS这样的名称来引用这些操作。
如果嵌套类的实例可以在它外围类的实例之外独立存在,这个嵌套类就必须是静态成员类:在没有外围实例的情况下,要想创建非静态成员类的实例是不可能的。
非静态成员类一种常见用法是定义一个Adapter(适配器,将Map集合转换成Set或Collection接口),它允许外部类的实例被看作是另一个不相关的类的实例。例如,Map接口的实现往往使用非静态成员类来实现它们的集合视图,这些集合视图由Map的entrySet、keySet、values方法返回的,同样,如Set和List这种集合接口的实现往往也使用非静态成员类来实现它的迭代器,下面看看HashMap的组成部分:
public class HashMap extends AbstractMap implements Map, Cloneable, Serializable{
//静态实体组件类
static class Entry implements Map.Entry {
final Object key;
Object value;
final int hash;
Entry next;
//...
}
//HashMap的抽象迭代器,迭代出的是一个个Entry。专用来被key、value迭代器继承
private abstract class HashIterator implements Iterator {
//...
Entry nextEntry() {
//...
}
}
// 对Map中的value进行迭代
private class ValueIterator extends HashIterator {
public Object next() {
return nextEntry().value;
}
}
// 对Map中的key进行迭代
private class KeyIterator extends HashIterator {
public Object next() {
return nextEntry().getKey();
}
}
//Entry迭代器
private class EntryIterator extends HashIterator {
public Object next() {
return EntryIterator ();
}
}
// 下面的集合视图都是建立在上面迭代器之上的
// Key视图
private class KeySet extends AbstractSet {
public Iterator iterator() {
return KeyIterator ();
}
//...
}
//value视图
private class Values extends AbstractCollection {
public Iterator iterator() {
return ValueIterator ();
}
// ...
}
如果声明成员类不要求访问外围实例,就应加上static修饰符。如果省略了,则每个实例都将包含一个额外指向外围对象的引用,保存这份引用要消耗时间和空间,并且会导致外围实例在符合垃圾回收(见第6条)时却仍然得到保留。
私有静态成员类的一种常见用法是用来代表外围类所代表的对象的组件。Map实现的内部都有一个Entry类,表示Map中的每个健-值对。虽然每个entry都与一个Map关联,但是entry上的方法(getKey、getValue和setValue)并不需要访问该Map,因此,使用非静态成员来表示entry是很浪费的:私有的静态成员类是最好的选择。如果漏掉了Entry声明成static,该Map仍可以工作,但是每个entry中将会包含一个指向Map的引用,这样就浪费空间和时间了。
非静态的内部类中不能定义static成员,但可以是定义final static成员。因为一个成员类实例必然与一个外部类实例关联,这个static定义完全可以移到其外围类中去。
当且仅当匿名类出现在非静态环境中时,它才有外围实例。但是如果它出现在静态的环境中,如静态的方法中时,就没不会指向外围类对象。而且匿名类不能包含静态的非final成员。
匿名类的适用性受到很多的限制,除了在它们声明的同时实例化外,不可以在其他地方实例化。你不能执行instanceof测试,或者做任何需要命名类的其他事情。你无法声明在一个匿名类上再实现一个或多个接口,或者是继承一个类。匿名类的客户端无法调用到它里面定义的父类中没有成员,而只能是访问到父类或接口中的成员,因为客户端引用父类类型。另外,由于匿名类出现在表达式中,它们必须保持简短——大约10行或更少——否则会影响程序的可读性。
匿名类的一种常见用法是动态地创建函数对象(见第21条),与策略接口或回调接口一起使用;匿名类的另一种常见用法是创建过程对象,比如Runnable、Thread或者TimerTask实例;第三种常见的用法是在静态工厂方法的内部(见第18条中的intArrayAsList方法)。
局部类是四种嵌套类中用得最少的类。在任何“可以声明局部变量”的地方,都可以声明局部类,并且也只能在当前作用域类使用。局部类与其他三种嵌套类中的每一种都有一些共同点:与成员类(静态与非静态成员类)一样,局部类有名字,可以被重复地使用;与匿名类一样,只有当局部类是在非静态环境中定义的时候,才有外围实例,与匿名类一样局部类中都不能定义静态的非final成员。与匿名类一样,它们也必须非常简短,以便不会影响到可读性。下面是静态与非静态环境中的局部类,它们是否拥有指向外围类的引用:
public class Outer {
static void f() {//静态方法中的内部类没有外围对象引用
class Inner {
static final int i = 1;
}
}
void g() {
class Inner {}
}
}
使用javap反编译后如下:
class Outer$1Inner extends java.lang.Object{
static final int i;
Outer$1Inner();
}
class Outer$2Inner extends java.lang.Object{
final Outer this$0; //非静态方法中的内部类有外围对象引用
Outer$2Inner(Outer);
}
静态方法中的局部类为静态的,没有指向外围类实例,非静态方法中的局部类有指向外围实例的引用(从上面反编译可以看出)。
另外,不管是在静态的还是非静态的环境中定义的局部或匿名类,都不是static的(所以为什么局部或匿名类不能定义静态的非final成员了),或者更严格的讲,没有静态的局部或匿名类,因为局部环境中是不可以使用static修饰的。
简而言之,四种不同的嵌套类,都有自己的用途。如果一个嵌套类需要在单个方法之外仍然是可见的,或者它太长了,不适合于放在方法内部,就该使用成员类。如果成员类的每个实例都需要一个指向其外围实例的引用,就要把成员类做成非静态的,否则做成静态的。假设这个嵌套类属于一个方法的内部,如果你只需要在一个地方创建实例,并且已经有了一个预置的类型接口可以说明这个类的特征,就要把它做成匿名类,否则,就做成局部类吧。
第五章 泛型
23、 请不要在新代码中使用原生态类型
声明中具有一个或者多个类型参数的类或者接口,就是泛型类或者泛型接口。泛型类和接口统称为泛型。
每种泛型可以定义一种参数化的类型,格式为:先是类或者接口的名称,接着用尖括号(<>)把对应于泛型的类型参数的实际类型参数列表括起来。
每个泛型都定义一个原生态类型,即不带任何实际类型参数的泛型名称,也是没有泛型之前的类型。
泛型能将运行时期的错误提前到编译时期检测。
如果使用原生态类型,就失掉了泛型在安全性和表述性方面的所有优势。既然不应该使用原生态类型,为什么Java设计还要允许使用它们呢?这是为了提供兼容性,要兼容以前没有使用泛型的Java代码。
原生态类型List和参数化的类型List之间到底有什么区别呢?不严格地说,前者逃避了泛型检查,后者则明确告知编译器,它能够持有任意类型的对象。泛型有子类型化的规则:List是原生态类型List的一个子类型,而不是参数化类型List的子类型(见25条)。因此,如果用不用像List这样的原生态类型,就会失掉类型安全性,但是如果使用像List这样的参数化类型,则不会。
在无限制通配类型Set>和原生态类型Set之间有什么区别呢?由于可以将任何元素放进使用原生态类型的集合中,因此很容易破坏该集合的类型约束条件;但不能将任何元素(除了null之外)放到Collection>中。
“不要在新代码中使用原生态类型”,这条规则有两个例外,这是因为“泛型信息在运行时就会被擦除”。在获取类信息中必须使用原生态类型(数组类型和基本类型也算原生态类型),规范不允许使用参数化类型。换句话说:List.class,String[].class和int.class都是合法,但是List.class和List>.class都是不合法的。这条规则的第二个例外与instanceof操作符有关,由于泛型信息在运行时已被擦除,因此在参数化类型而不是无限制通配符类型(如List>)上使用instanceof操作符是非法的,用无限制通配符类型代替原生态类型,对instanceof操作的行为不产生任何影响。在这种情况下,尖括号<>和问号?就显得多余了。下面是利用泛型来使用instanceof操作符的首先方法:
if(o instanceof set){
Set> m = (Set>)o;
// ...
}
注意,一旦确定这个o是个Set,就必须将它转换成通配类型Set>,则不是转换成原生态类型Set,否则Set会引起编译时警告。
总之,使用原生态类型会在运行时导致异常,因此不要在新代码中使用。原生态类型只为了与引入泛型之前的遗留代码进行兼容和互用而提供的。另外Set是个参数化类型,表示可以包括任何对象类型的一个集合;Set>则是一个通配符类型,表示只能包含某种未知对象类型的一个集合;Set则是个原生态类型,它脱离了泛型系统。前两者是安全的,最后一种不安全。
术语介绍:
原生态类型:List
参数化的类型:List
泛型:List
有限制类型参数:List
形式类型参数:E
无限制通配符类型:List>
有限制通配符类型:List extends Number>
递归类型限制:List >
泛型方法: static List asList(E[] a)
24、 消除非受检警告
用泛型编程时,会遇到许多编译器警告:非受检强制转换警告、非受检方法调用警告、非受检普通数组创建警告,以及非受检转换警告。
要尽可能地消除每一个非受检警告。如果消除了所有警告,就可以确保代码是类型安全的。
如果无法消除警告,同时可以证明引起警告的代码是类型安全的,只有在这种情况下才可以用一个@SuppressWarnings("unchecked")注解来禁止这条警告。
SuppressWarnings注解可以用在任何粒度的级别中,从单独的局部变量到整个类都可以。应该始终在尽可能小的范围中使用SuppressWarnings注解。它通常是个变量声明,或者是非常简短的方法或者构造器。永远不要在整个类上使用SuppressWarnings,这么做可能会掩盖了重要的警告。
总而言之,非受检警告很重要,不要忽略它们。每一条警告都表示可能在运行时抛出ClassCastException异常。要尽最大的努力消除这些警告。如果无法消掉同时确实是类型安全的,就可以在尽可能小的范围中,用@SuppressWarnings("unchecked")注解来禁止这条警告。要用注释把禁止该警告的原因记录下来。
25、 列表优先于数组
数组与泛型相比,有两个重要的不同点:首先,数组是协变的,如Sub为Super的子类型,那么数组类型Sub[]就是Super[]的子类型。但泛型则是不可变的,对于任意两个不同的类型Type1和Type2,List与List没有任何父子关系。
下面的代码片段是合法的:
Object[] objectArray = new Long[1];
objectArray[0]= "";//运行时抛异常
但下面这段代码则在编译时就不合法:
List ol = new ArrayList();//编译时就不能通过
ol.add("");
利用数组,你会在运行时才可以发现错误,而利用列表,则可以在编译时发现错误。而我们最好是编译时发现错误,及早的处理它。
数组与泛型之间的第二大区别在于,数组是具体化的[JLS,4.7]。因此数组会在运行时才知道并检查它们的元素类型约束。相比,泛型则是通过擦除[JLS,4.6]来实现的。因此泛型只在编译时强化它们的类型信息,并在运行时丢弃它们的元素类型信息。擦除就是使泛型可以与没有使用泛型的代码随意进行互用。
由于上述这些根本的区另,因此数组和泛型不能很好混合使用。例如,创建泛型、参数化类型或者类型参数的数组是非法的,如:new List[]、new List[]、new E[]都是非法的。
为什么不允许创建泛型数组呢?看具体例子:
List[] stringLists= new List[1];//1
List intList = Arrays.asList(42); //2
Object[] objects = stringLists; //3
objects[0] = intList; //4
String s = stringLists[0].get(0); //5
这里首先假设第一行可以,其他行本身编译是没有问题的,但运行到5行时肯定会抛出ClassCastException异常。为了防止出现这种情况,创建泛型数组第1行就不允许了。
从技术角度说,像List、List、E这样的类型应称作为不可具体化的类型[JLS,4.7]。直观地说,不可具体化的类型是指其运行时表示法包含的信息比它的编译时表示法包含的信息更少的类型。唯一可具体化的参数化类型是无限制的符类型,如List>和Map,?>(Map,?>[] maps = new Map,?>[1];),虽然不常用,但是创建无限制通配类型的数组是合法。
当你得到泛型数组创建错误时,最好的解决办法通常是优先使用集合类型List,而不是E[]。这样可以会损失一些性能或者简洁性,但是挽回的是更高的类型安全性和互用性。
总之,数组和泛型有着非常不同的类型规则。数组是协变且可以具体化的,泛型是不可变的且可以被擦除的。因此,数组提供了运行时的类型安全,但是没有编译时的类型安全,反之,对于泛型也一样。一般来说,数组和泛型不能很好地混合使用。如果你发现自己将它们混合起来使用,并且得到了编译时错误或者警告,你的第一反应就应该是用列表来代替数组。
26、 优先考虑泛型
考虑第6条中的堆栈实现,将它定义成泛型类。第一种是将elements定义成类型参数数组:
public class Stack {
private E[] elements;//定义成类型参数数组
private int size = 0;
private static final int DEFAULT_INITIAL_CAPACITY = 16;
// 通过pust(E)我们只能将E类型的实例放入到elements中,这已充分确保类型
// 安全,所以这里可以强转。但是运行时数组的类型不是E[],它仍然是Objct[]类型的!
@SuppressWarnings("unchecked")
public Stack() {
//elements =new E[DEFAULT_INITIAL_CAPACITY];//不能创建类型参数数组
/*
* 编译器不可能证明你的程序是类型安全的,但是你可以证明。你自己必须确保未受
* 检的转换不会危及到程序的类型安全。因为elements保存在一个私有的域中,永远
* 不会返回到客户端。或者传给任何其他方法。这个数组中保存的唯一元素,是传给
* push方法的那些元素,它们的类型为E,因此未受检的转换不会有任何危害。一旦
* 你证明了未受检的转换是安全的,就要在尽可能小的范围中禁警告。然后你就可以
* 使用的它了,无需显示转换,也不需担心会出ClassCastException异常。
*/
elements = (E[]) new Object[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;
}
//...
}
第二种是将elements域的类型从E[]改为Object[]:
public class Stack {
private Object[] elements;//直接定义成Object[]类型数组
private int size = 0;
private static final int DEFAULT_INITIAL_CAPACITY = 16;
public Stack() {
//这里就不需要转型了
elements = new Object[DEFAULT_INITIAL_CAPACITY];
}
public void push(E e) {
ensureCapacity();
elements[size++] = e;
}
// Appropriate suppression of unchecked warning
public E pop() {
if (size == 0)
throw new EmptyStackException();
// 放入栈中的元素类型一定是E类型的,所以转换是没有问题的!
@SuppressWarnings("unchecked")//将@SuppressWarnings尽量应用到最小的范围上
E result = (E) elements[--size];//转型会移到这里,但会有警告
elements[size] = null; // 解除过期引用
return result;
}
// ...
}
总之,使用泛型比使用需要在客户端代码中进行转换的类型来得更加安全,也更加容易。在设计新类型时,要确保它们不需要这种转换就可以使用。这通常意味着要把类做成是泛型的,只要时间允许,就把现有的类型都泛型化。这对于这些类型的新用户来说会变得更加轻松,又不会破坏现有的客户端。
27、 优先考虑泛型方法
静态工具方法尤其适合泛型方法。Collections工具类中的所有算法方法都泛型化了。
public class Union {
// 泛型方法
public static Set union(Set s1, Set s2) {
Set result = new HashSet(s1);
result.addAll(s2);
return result;
}
}
union方法的局限性在于,三个集合的类型(两个输入参数和一个返回值)必须全部相同。利用有限制的通配符类型,可以使这个方法变得更加灵活。
泛型方法的一个显著特性是,无需明确指定类型参数的值,不像调用泛型构造器的时候是必须指定的。编译器通过检查方法参数的类型来计算出类型参数的值。对于上面两个参数都是Set类型,因此知道类型参数E必须为String,这个过程称作为类型推导。
可以利用泛型方法调用所提供的类型推导,使用创建参数化类型实例的过程变得更加轻松。下面的写法有点冗余:
Map> anagrams = new HashMap>();
为了消除这种冗余,可以编写一个泛型静态工厂方法,与想要的每个构造器相对应,如:
// 静态的泛型工厂方法
public static HashMap newHashMap() {
return new HashMap();
}
通过这个泛型静态工厂方法,可以用下面这段简洁的代码来取代上面那个冗余的行:
// 使用泛型静态工厂方法来创建参数化的类的实例
Map> anagrams = newHashMap();
在泛型上调用构造器时,如果语言本身支持类型推导与调用泛型方法时所做的相同,那就好了,将来也许可以,但现在还不行。
递归类型限制最普遍的用途与Comparable接口有关,例如在集合中找最大的元素泛型静态方法:
public static > T max(List list){
Iterator i = list.iterator();
T result = i.next();
while(i.hasNext()){
T t = i.next();
if(t.compareTo(result)>0){
result = t;
}
}
return result;
}
总之,泛型方法就像泛型一样,使用起来比要求客户端转换输出参数并返回值的方法来得更加安全,也更加容易,就像类型一样,你应该确保新的方法可以不用转换就能使用,这通常意味着要将它们泛型化。
28、 利用有限制通配符来提升API的灵活性
现在在前面前面第26条中的Stack中加上以下方法,以便将某个集合一次性放入到栈中:
public void pushAll(Iterable src) {
for (E e : src)
push(e);
}
这样使用:
Stack numberStack = new Stack();//1
Iterable integers = Arrays.asList(3, 1, 4, 1, 5, 9); //2
numberStack.pushAll(integers); //3
按照上面的写法,我们是无法将Integer类型的元素放入Number类型的栈中,这是不合理的,因为Integer为Number的子类。原因是第一行执行完后,pushAll方法中的类型参数E就会固定为Number类型,这就好比要将Iterable赋值给Iterable一样,这显然是不可以的,因为Iterable不是Iterable的子类型。这样就显得缺少灵活性了,幸好限制通配符类型可以帮我们解决:
// 限制通配符意味这这里的元素只少是E类型
public void pushAll(Iterable extends E> src) {
for (E e : src)
push(e);
}
经过上面的修改后src不只是可以接受E类型的Iterable,还可以接受E的子类型Iterable,而Integer恰好为Number的子类型,所以现在第3行可以正常编译与运行。
注:List extends E> src这种集合只能读不能写,即只能传进参数(如add方法),而不能返回E类型元素(如get方法),当然不带泛型类型参数与返回泛型类型的方法是可以随便调用的,比如size();
完成上面的pushAll方法的后,我们现在想编写一个popAll对应的方法,它从栈中弹出每个元素,并将这些元素到传进去参数集合中,下面如果这样设计:
public void popAll(Collection dst) {
while (!isEmpty())
dst.add(pop());
}
应用代码如下:
Collection objects = new ArrayList();//1
numberStack.popAll(objects);//2
System.out.println(objects);
很不幸的是,第2行根本无法编译通过,按理来说我们完全可以将Number类型的元素放入到一个Object类型参数的集合中,可这里失败了,这不是不可以,是我们设计的错误。这里失败的原因与上面一样,是由于numberStack的类型为Number,所以popAll的类型参数固定为Number,因而不能将一个Collection赋值给Collection,因为Collection不是Collection的子类型。像上面一样,也有这样一样限制通配类型来解决这个问题:
public void popAll(Collection super E> dst) {
while (!isEmpty())
dst.add(pop());
}
现在dst不只是可以接受E类型的Collection了,而且还可以接受E的父类型Collection,这里的Object为Number的父类,所以这里可以正常运行。
注:List super E> dst这种集合只能写不能读,即只能写入元素(如调用add方法)而不能读取元素(如调用get),当然不带泛型类型参数与返回泛型类型的方法是可以随便调用的,比如size();
XXX extends T> x:使用 extends T>定义的引用x,x可以用来接受类型参数为T及T的所有子类类型的实例,但通过这个引用调用x上的泛型方法时有一定的限制:如果方法带泛型参数,则不能调用,因为真真实例方法的类型参数类型完全有可能比你传进这个泛型方法的参数的类型要窄,一个子类型的引用是不能接受一个父类类型引用的,所以不能通过这种限制通配类型定义的引用x来调用一切带泛型参数的方法;但你可以调用那此具有返回类型为泛型类型的方法,因为不管真真实例的类型如果,它们都是T的子类,所以此时的返回类型只少是T类型;最后如果方法不带泛型类型参数,则是可以随便调用的。总之,这种限制通配类型一般用于从集合中读取操作。
XXX super T> x:使用 super T>定义的引用x,x可以用来接受类型参数为T及T的所有父类类型的实例,但通过这个引用调用x上的泛型方法时有一定的限制:如果方法的返回类型为泛型类型,则接收这个返回类型的变量类型不能是T,而只能是以Object类型变量来接收,因为方法的实际返回的类型完全有可能比方法定义的返回类型T要宽,但我们又不知道究竟比T宽多少,你总不能将Object类型对象赋值给T类型的引用吧,所以通过这种限制通配类型定义的引用x来调用返回类型为泛型参数的方法时会失去类型限制;但你可以调用方法参数类型为泛型类型的方法,因为现在方法的实例类型至少为T或比T要宽,所以可以接收T及T子类类型参数;最后如果方法的返回类型不为泛型类型参数时,则也是可以随便调用的。总之,这种限制通配类型一般用于将元素写入集合。
不要用符类型作为返回类型,除了为用户提供额外的灵活性外,它还会强制用户在客户端代码中使用通配符类型。
将第27条的union方法修改一下,让它能同时接受Integer与Double集合,由于这两个集合是用来读的,所以使用 extends E>限制通配符:
public static Set union(Set extends E> s1, Set extends E> s2) ;
如果有这以下两个集合:
Set integers = new HashSet();
Set doubles = new HashSet();
调用union(integers, doubles)时类型推断为 extends Number>,所以以下编译不能通过:
Set nubers =Union.union(integers, doubles);
只能是这样显示的指定返回类型,而不使用类型推导:
Set nubers =Union.union(integers, doubles);
或者使用类型推导,则只能以通配类型来接受,因为Set并不是Set的父类:
Set extends Number> nubers = union(integers, doubles);
接下来,我们将第27条的max方法,让它更灵活,做如下修改:因为list只用来读取或生产元素(第1、2、4行都是从list中读,即只通过list直接或间接地调用过返回类型为泛型类型的方法,而没有去调用过参数为泛型类型的方法),所以从List修改成List extends T>,让list可以接受T及其它的子类。而Comparable应该修改成Comparable super T>,因为Comparable只是来消费T的实例(第5行属于消费,因为T的实例调用带有泛型类型参数的compareTo方法),传递进的参数要求是最宽的,这样可以确保compareTo中的参数能接受T及其T的子类类型:
public static > T max(List extends T> list) {
//只能使用通配类型来接受,因为iterator()方法返回的为Iterator 类型,又Iterator并不是Iterator的父类,所以这里也需要修改一下
Iterator extends T> i = list.iterator();//1
//但这里不需要使用通配类型来接收,因为next()返回的类型直接就是类型参数E,而不像上面返回的为Iterator泛型类型
T result = i.next();//2
while (i.hasNext()) {//3
T t = i.next();//4
if (t.compareTo(result) > 0) {//5
result = t;
}
}
return result;
}
假如现在有以下两个接口:
interface I1 extends Comparable {}
interface I2 extends I1 {}
如果上面不这样修改的话,下面第二行将不适用:
max(new ArrayList());
max(new ArrayList());
现在我们具体的分析一下上面代码:如果Comparable不修改成Comparable super T>,第一行还是可正常运行,但是第二行则不可以,因为此时的T为I2,而I2又没有实现Comparable接口,而方法声明>部分则要求I2直接实现Comparable接口,但父类I1实现了Comparable接口,I2又已经继承了I1,我们不需要再实现该接口,所以这里变通的作法是让Comparable可以接收T及T的父类类型,所以修改成Comparable super T>即可,并且这样修改也符合前面的描述。
所以,使用时始终应该是Comparable super T>优先于Comparable,对于comparator也一样,使用时始终应该是Comparator super T>优先于Comparator。
类型参数和通配符之间具有双重性,许多方法都可以利用其中一个或者另一个进行声明。例如下面是可能的两种静态方法声明,来交换列表中的两个元素,第一个使用无限的类型参数,第二个使用的是无限的通配符:
public static void swap(List list, int i, int j);
public static void swap(List> list, int i, int j);
一般来说,如果类型参数只在方法声明中出现一次(即只在方法参数声明列表中出现过,而方法体没有出现),就可以用通配符取代它:如果是无限制的类型参数,就用无限制的通配符取代它>;如果是有限制的类型参数,就用有限制的通配符取代它 extends Number>。
第二种实质上会有问题,下面是简单的实现都不能通过编译:
public static void swap(List> list, int i, int j) {
list.set(i, list.get(j));
}
原因很简单了,不再累述,但可以修改它,编写一个私有的辅助方法来捕捉通配符类型:
public static void swap(List> list, int i, int j) {
swapHelper(list, i, j);
}
private static void swapHelper(List list, int i, int j) {
list.set(i, list.get(j));
}
不过,还可以将List >修改成List super Object>也可以:
public static void swap(List super Object> list, int i, int j) {
list.set(i, list.get(j));
}
总之,在API中使用通配符类型使API变得灵活多。如果编写的是一个被广泛使用的类库,则一定要适当地利用通配类型。记住基本原则:producer-extends,consumer-super(PECS)。还要记住所有的Comparable和Comparator都是消费者,所以适合于 super XXX>。
PECS:如果参数化类型表示一个T生产者,就使用 extends T>;如果它表示一个T是消费者,就使用 super T>。
29、 优先考虑类型安全的异构容器
通过对泛型的学习我们知道,泛型集合一旦实例化,类型参数就确定下来,只能存入特定类型的元素,比如:
Map map = new HashMap();
则只能将K、V及它们的子类放入Map中,就不能将其他类型元素存入。如果使用原生Map又会得到类型安全检查,也许你这样定义:
Map map = new HashMap();
map.put("Derive", new Derive());
map.put("Sub", new Sub());
这样是可以存入各种类型的对象,虽然逃过了警告,但取出时我们无法知道确切的类型,它们都是Object,那么有没有一种这样的Map,即可以存放各种类型的对象,但取出时还是可以知道其确切类型,这是可以的:
public class Favorites {
// 可以存储不同类型元素的类型安全容器,但每种类型只允许一个值,如果存放同一类型
// 多个值是不行的
private Map, Object> favorites = new HashMap, Object>();
public void putFavorite(Class type, T instance) {
if (type == null)
throw new NullPointerException("Type is null");
// favorites.put(type, instance);
/*
* 防止客户端传进原生的Class对象,虽然这会警告,但这就不能
* 确保后面instance实例为type类型了,所以在这种情况下强制
* 检查
*/
favorites.put(type, type.cast(instance));
}
public T getFavorite(Class type) {
//返回的还是存入时真真类型
return type.cast(favorites.get(type));
}
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());
}
}
总之,集合API说明了泛型的一般用法,限制你每个容器只能有固定数目的类型参数。你可以通过将类型参数放在键上而不是容器上来避开这一限制。对于这种类型安全的异构容器,可以用Class对象作为键。
第六章 枚举和注解
30、 用enum代替int常量
枚举类型是指由一组固定的常量组成合法值的类型,例如一年中的季节或一副牌中的花色。在没引入枚举时,一般是声明一组int常量,每个类型成员一个常量:
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;
这种方法称作int枚举模式,存在很多不足,不具有类型安全与使用方便性。如果你将apple传到一个想要接收orange的方法中,编译器也不会出现警告,而且还可以使用==来比较apple与orange。
注意每个apple常量都以APPLE_作为前缀,每个orange常量都以ORANGE_作为前缀,这是因为可以防止名称冲突。
采用int枚举模式的程序是十分脆弱,因为int枚举是编译时常量,被编译到使用它们的客户端中。如果与枚举常量关联的int发生了变化,客户端就必须重新编译,如果不重新编译,程序还是可以运行,但不是最新的值了。
另外从使用方便性来看,没有便利的toString方法,打印出来的为数字,没有多大的用处。要遍历一组中所有的int枚举常量,也没有可靠的方法。
既然int枚举常量有这么多的缺点,那使用String枚举常如何?同样也不是我们期望的。虽然在可以打印字符串,但它会导致性能问题,因为它依赖于字符串的比较操作。另外与int枚举常量一样会编译到客户端代码中,编译时难以发现,但会在运行时出错。
幸运的是1.5版本开始,枚举可以避免int和String枚举模式的缺点,并提供许多额外的好处。下面是最简单的形式:
public enum Apple{FUJI,PIPPIN,GRANNY_SMITH}
public enum Orange{NAVEL,TEMPLE,BLOOD}
Java枚举类型背后的基本想法很简单:本质上是int值,它们是通过公有的静态final域为每个枚举常量导出实例的类。因为没有可以访问的构造器,枚举类型是真正的final。因为客户端即不能创建枚举类型的实例,也不能对它进行扩展,因此对它进行实例化,而只有声明过的枚举常量。换句话说,枚举类型是实例受控的。它们是单例的泛型化,本质上是单元素的枚举。
枚举提供了编译时类型安全。如果声明一个参数的类型为Apple,就可以保证,被传到该参数上的任何非null的对象引用一定属于三个有效的Apple值之一。试图传递类型错误的值时,会导致编译时错误,就像试图将某种枚举类型的表达式赋值给另一种枚举类型的变量,或者试图利用==操作符比较不同枚举类型的值一样,都会出错。
枚举提供了单独的命名空间,同一系统中可以有多个同名的枚举类型变量。你可以增加或者重新排序枚举类型常量,而无需重新编译它的客户端代码,因为导出常量的域在枚举类型和它的客户端之间提供了一个隔离层:常量值并没有被编译到客户端代码中,而是在int枚举模式之中。最终,可以通过调用toString方法,将枚举转换成可打印的字符串。
除了完善了int枚举模式不足外,枚举还允许添加任意的方法和域,并实例任意接口,它们提供了所有Object(见第3章)的高级实现,实现了Comparable和Serializable接口,并针对枚举型的可任意改变性设计了序列化方式。
如果一个枚举具有普遍适用性,它就应该成为一个顶层类,如果它只是被用在一个特定的顶层类中,它就应该成为该顶层类的一个成员类。
可以为枚举类型添加数据域与方法,下面是一个算术运行的枚举类:
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) {//构造函数,存储操作符供toString打印使用
this.symbol = symbol;
}
@Override
//重写Enum中的打印name的性为
public String toString() {
return symbol;
}
//抽像方法,不同的常量具有不同的功能,需在每个常量类的主体里重写它
abstract double apply(double x, double y);
/*
* 初始化时,存储操作符与枚举常量的对应关系,用来实现 fromString 方法
* 这样我们就可以通过 操作符来获取到对应的枚举常量,有点像valueOf方法,
* 只不过它是通过枚举常量的名字name来获取常量的。这种通用的方法还可以
* 应用到其他枚举类中
*/
private static final Map stringToEnum = new HashMap();
static { // 从name到枚举常量转换到从某个域到枚举常量的转换
for (Operation op : values())
stringToEnum.put(op.toString(), op);
}
// 根据操作符来获取对应的枚举常量,如果没有返回null,模拟valueOf方法
public static Operation fromString(String symbol) {
return stringToEnum.get(symbol);
}
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));
for (Operation op : Operation.values())
System.out.printf("%f %s %f = %f%n", x, op, y, Operation
.fromString(op.toString()).apply(x, y));
}
}
在opr包下会看见Operation.class、Operation$4.class、Operation$2.class、Operation$3.class 、Operation$1.class这样几个类,Operation$X.class都是继承自Operation类,而Operation又继承自Enum类,下面是反编译这些类的代码:
public abstract class opr.Operation extends java.lang.Enum{
public static final opr.Operation PLUS;
public static final opr.Operation MINUS;
public static final opr.Operation TIMES;
public static final opr.Operation DIVIDE;
private final java.lang.String symbol;
private static final java.util.Map stringToEnum;
private static final opr.Operation[] ENUM$VALUES;
static {};
private opr.Operation(java.lang.String, int, java.lang.String);
public java.lang.String toString();
abstract double apply(double, double);
public static opr.Operation fromString(java.lang.String);
public static void main(java.lang.String[]);
public static opr.Operation[] values();
public static opr.Operation valueOf(java.lang.String);
opr.Operation(java.lang.String, int, java.lang.String, opr.Operation);
}
class opr.Operation$1 extends opr.Operation{
opr.Operation$1(java.lang.String, int, java.lang.String);
double apply(double, double);
}
枚举构造器不可以访问枚举的静态域,除了编译时常量域之外,这一限制是有必要的,因为构造器运行的时候,这些静态域还没有被初始化。
枚举常量中的方法有一个美中不足的地方,它们使用在枚举常量中共享代码变得更加因难了。例如,考虑用一个枚举来实现星期中的工资数。算法是这样的,在五个工作日中,除正常的工作时间外,算加班;在双休日中,所有工作时数都算加班时间,下面是第一次简单的实现:
public enum PayrollDay {
MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY;
private static final int HOURS_PER_SHIFT = 8;//正常工作时数
/**
* 工资计算
* @param hoursWorked 工作时间(小时)
* @param payRate 每小时工资
* @return
*/
double pay(double hoursWorked, double payRate) {
//基本工资,注这里使用的是double,真实应用中请不要使用
double basePay = hoursWorked * payRate;
double overtimePay;//加班工资,为正常工资的1.5倍
switch (this) {
case SATURDAY:
case SUNDAY://双休日加班工资
overtimePay = hoursWorked * payRate / 2;
default: //正常工作日加班工资
overtimePay = hoursWorked <= HOURS_PER_SHIFT ? 0
: (hoursWorked - HOURS_PER_SHIFT) * payRate / 2;
break;
}
return basePay + overtimePay;//基本工资+加班工资
}
}
不可否认,这段代码很简单,但是从维护来看,非常危险。假设将一个元素添加到枚举中,如一个假期的特殊值,但忘了给switch语句添加相应的case,这时会计算出错。
为了针对不同的常量有不同的安全计算工资法,你必须重复每个常量的加班工资,或者将计算移到两个辅助方法中(一个用来计算工作日,一个用来计算双休日),并从每个常量调用相应的辅助方法。这任何一种方法都会产生很多的重复的样板代码,第二次如下实现:
public enum PayrollDay {
MONDAY() {
@Override
double overtimePay(double hoursWorked, double payRate) {
return weekdayPay(hoursWorked, payRate);
}
},
TUESDAY {
@Override
double overtimePay(double hoursWorked, double payRate) {
return weekdayPay(hoursWorked, payRate);
}
},
WEDNESDAY {
@Override
double overtimePay(double hoursWorked, double payRate) {
return weekdayPay(hoursWorked, payRate);
}
},
THURSDAY {
@Override
double overtimePay(double hoursWorked, double payRate) {
return weekdayPay(hoursWorked, payRate);
}
},
FRIDAY {
@Override
double overtimePay(double hoursWorked, double payRate) {
return weekdayPay(hoursWorked, payRate);
}
},
SATURDAY {
@Override
double overtimePay(double hoursWorked, double payRate) {
return weekendPay(hoursWorked, payRate);
}
},
SUNDAY {
@Override
double overtimePay(double hoursWorked, double payRate) {
return weekendPay(hoursWorked, payRate);
}
};
private static final int HOURS_PER_SHIFT = 8;//正常工作时数
//抽象出加班工资计算
abstract double overtimePay(double hoursWorked, double payRate);
//计算工资
double pay(double hoursWorked, double payRate) {
double basePay = hoursWorked * payRate;//公用
return basePay + overtimePay(hoursWorked, payRate);
}
//双休日加班工资算法
double weekendPay(double hoursWorked, double payRate) {
return hoursWorked * payRate / 2;
}
//正常工作日加班工资
double weekdayPay(double hoursWorked, double payRate) {
return hoursWorked <= HOURS_PER_SHIFT ? 0
: (hoursWorked - HOURS_PER_SHIFT) * payRate / 2;
}
}
上面设计中存在很多的样板代码,如正常工作日都是调用weekdayPay方法来完成的,而双休都是调用weekendPay来完成的,有没有一种可以减少这些重复样板代码呢?请看下面:
enum PayrollDay {
MONDAY(PayType.WEEKDAY), TUESDAY(PayType.WEEKDAY), WEDNESDAY(
PayType.WEEKDAY), THURSDAY(PayType.WEEKDAY), FRIDAY(PayType.WEEKDAY), SATURDAY(
PayType.WEEKEND), SUNDAY(PayType.WEEKEND);
private final PayType payType;//策略枚举类
PayrollDay(PayType payType) {
this.payType = payType;
}
double pay(double hoursWorked, double payRate) {
//计算委托给策略类
return payType.pay(hoursWorked, payRate);
}
// 嵌套的枚举策略类
private enum PayType {
WEEKDAY {//工作日枚举策略实例常量
double overtimePay(double hours, double payRate) {
return hours <= HOURS_PER_SHIFT ? 0 : (hours - HOURS_PER_SHIFT)
* payRate / 2;
}
},
WEEKEND {//双休日枚举策略实例常量
double overtimePay(double hours, double payRate) {
return hours * payRate / 2;
}
};
private static final int HOURS_PER_SHIFT = 8;
abstract double overtimePay(double hrs, double payRate);
double pay(double hoursWorked, double payRate) {
double basePay = hoursWorked * payRate;
return basePay + overtimePay(hoursWorked, payRate);
}
}
}
虽然这种模式没有前面两种那么简单,便更加安全,也更加灵活。
从上面加班工资计算三种实现来看,如果多个枚举常量同时共享相同的行为时,则考虑策略枚举。
枚举适用于一组固定常量,当然枚举类型中的常量集并不一定要始终保持不变。
31、 不要使用ordinal,用实例域代替序数
永远不要根据枚举序数ordinal()导出与它关联的值,即不要依赖于枚举序数,否则重新排序这些枚举或添加新的常量,维护起来将是很困难的:
public enum Ensemble {
SOLO, DUET, TRIO, QUARTET, QUINTET, SEXTET, SEPTET;
public int numberOfMusicians() {
return ordinal() + 1;
}
}
我们要将它保存在一个实例域中:
public enum Ensemble {
SOLO(1), DUET(2), TRIO(3), QUARTET(4), QUINTET(5), SEXTET(6), SEPTET(7);
private final int numberOfMusicians;
Ensemble(int size) {
this.numberOfMusicians = size;
}
public int numberOfMusicians() {
return numberOfMusicians;
}
}
Enum规范中谈到ordinal时这么定道:“大多数程序员都不需要这个方法。它是设计成用于像EunmSet和EnumMap这种基于枚举的通用数据结构”,除非你在编写的是这种数据结构,否则最好完全避免使用ordinal方法。
32、 用EnumSet代替位域
如果一个枚举类型的元素主要用在集合(组合)中,一般就使用int枚举模式,做法是将1向左移位来实现,这样就会有很多的组合形式,下面是四种字体样式的应用,可以组合出 2^4 – 1 = 15种样式来:
class Text{
public static final int STYLE_BOLD = 1 << 0;//1 字体加粗
public static final int STYLE_ITALTC = 1 << 1;// 2 斜体
public static final int STYLE_UNDERLINE = 1 << 2;//4 下划线
public static final int STYLE_STRIKETHROUGH = 1 << 3;//8 删除线
//应用样式
public void applyStyles(int styles){
//...
}
public static void main(String[] args) {
//应用粗体与斜体组合样式
new Text().applyStyles(STYLE_BOLD|STYLE_ITALTC);
}
}
位域表示法允许利用位操作,有效地执行了像组合和交集这样的集合操作。但位域有着int枚举常量的所有缺点,甚至更多,如当位域以数字形式打印时,翻译位域比翻译简单的(单个的)枚举常要困难得多。那么有没有一种好的方案来代替上面的设计呢?使用EnumSet类来有效地表示从单个枚举类型中提取的多个值的多个集合。EnumSet内容都表示为位矢量,如果底层的枚举类型有64个或者更少的元素——大多如此——整个EnumSet就是用单个long来表示,因此它的性能比得上位域的性能。批处理,如removeAll和retainAll,都是利用位算法来实现的,就像手工替位域实现那样,但可以避免手工位操作时容易出现的错误以及复杂的代码。
下面是前一个实例改用枚举代替位域后的代码,它更加简短、清楚、安全:
public class Text {
public enum Style {
BOLD, ITALIC, UNDERLINE, STRIKETHROUGH
}
/*
* 这里使用的Set接口而不是EnumSet类,最好还是使用接口
* 类型而非实现类型,这样还可以传递一些其他的Set实现
*/
public void applyStyles(Set