一、考虑用静态工厂方法代替构造器
1.静态工厂方法有名称,而构造器只能是类名
静态工厂方法的名称可以形象的表述该方法的目的,并且当创建实例时,如果需要两种方式创建实例,并且传入的参数类型相同,对于构造器而言,其只能通过改变传入参数的顺序来达到目的,但是对于静态工厂方法而言,其可以通过改变方法的名称来对产生实例的方式进行更好的说明。
2.不必在每次调用工厂方法的时候都创建一个新对象
对于某些不可变对象或者创建代价非常高昂的对象,可以事先创建好对象,将其缓存起来,然后通过工厂方法将其返回,并且对于这些对象,由于其是单例的,客户端可以直接使用“==”而不是"equals()"来判断对象是否相等,这有助于效率的提升。
3.工厂方法可以返回目标返回类型的任何子类型对象
这种返回数据的方式为返回的实际对象提供了更大的灵活性,其非常适用于基于接口返回类型的框架:a.可在类内部创建一个内部类实现该接口,这样就对客户端程序员隐藏了具体实现,后序需要对内部类实现进行修改也不会影响到客户端程序员的使用;b.可以不创建实现该接口的类,而是由客户端程序员来实现该类,这种思想构成了服务提供者框架的基础。
4.工厂方法可以简化参数化对象创建的繁琐书写方式(jdk1..6以前版本)
在jdk1.6版本以前创建参数化对象时必须要书写两次所传如的参数类型,如果在类内部创建一个工厂方法,并且注以相应的泛型参数,那么就可以利用java的类型推导来创建对象,比如:
private Map
当在HashMap中创建如下工厂方法时:
public Map
return new HashMap
}
可以简化为
private Map
5.静态工厂方法的主要缺点在于含有静态工厂方法的类如果不含有公有或受保护的构造器,那么这个类是不能够被继承的(其默认构造器被声明为私有的)
6.静态工厂方法由于其名称是可以自定义的,如果不遵循统一的命名规范,那么在类的说明文档中很难发现该类的工厂方法具体是哪一个
二、遇到多个构造器参数时考虑使用构建器
静态工厂方法和构造器在构建参数量较少的对象时比较实用,但是当参数量较大时静态工厂方法和构造器就容易出现问题。这里有两种解决方式,一种是使用重叠构造器,一种是使用JavaBean的set方法对其赋值。对于重叠构造器,其优点是构建过程中参数已经初始化,但是当有许多参数的时候,客户端代码就会很难编写,并且仍然较难以阅读(参数过多);对于JavaBean,由于其对象已经构建,具体的赋值时后序通过set方法来进行,这里解决了构造器较难以阅读的问题,但是由于JavaBean的初始化被分割到多个set方法的调用中,在这个构造过程中JavaBean可能处于不一致的状态,此时如果使用处于不一致状态的对象可能导致问题(JavaBean阻止了把类变为不可变类的可能)。
这里有第三种解决方案,就是采用Builder模式,在构建对象之前先构造一个Builder对象,在Builder对象中调用方法对所需要初始化的参数进行初始化,最后通过builder()方法构建一个所需要的对象。在通过Builder对象对所需要的参数进行初始化的过程中,如果某个参数初始化失败,将会抛出异常说明,这就保证了参数设置过程的可阅读性,也保证了对象创建过程的安全性。具体的示例代码如下:
public interface Builder
T build();
}
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 NutritionBuilder implements Builder
// Required parameters
private final int servingSize;
private final int servings;
// Optional parameters - initialized to default values
private int calories = 0;
private int fat = 0;
private int carbohydrate = 0;
private int sodium = 0;
public NutritionBuilder(int servingSize, int servings) {
this.servingSize = servingSize;
this.servings = servings;
}
public NutritionBuilder calories(int val) {
calories = val;
return this;
}
public NutritionBuilder fat(int val) {
fat = val;
return this;
}
public NutritionBuilder carbohydrate(int val) {
carbohydrate = val;
return this;
}
public NutritionBuilder sodium(int val) {
sodium = val;
return this;
}
@Override
public NutritionFacts build() {
return new NutritionFacts(this);
}
}
private NutritionFacts(NutritionBuilder builder) {
servingSize = builder.servingSize;
servings = builder.servings;
calories = builder.calories;
fat = builder.fat;
sodium = builder.sodium;
carbohydrate = builder.carbohydrate;
}
}
/**
- 客户端代码
*/
public class App {
public static void main(String[] args) {
NutritionFacts cocaCola = new NutritionFacts.NutritionBuilder(240, 8)
.calories(200).sodium(35).calories(27).build();
}
}
从最后的客户端代码来看,使用Builder模式构建对象时客户端代码非常容易阅读。Builder模式十分灵活,由于Buider对象在调用build()方法的时候才会创建目标对象,因而可以使用一个Builder对象创建多个目标对象,并且在每次创建对象之前对目标对象的参数进行重复设置。另外,客户端也可以只接收一个Builder接口的实例来达到构建对象的目的。
三、用私有构造器或者枚举类型强化Singleton属性
单例模式是指仅仅被实例化一次的类,Singleton通常被用来代表那些本质上唯一的系统组件,并且对于Singleton的比较可以通过“==”比较,而不是“equals()”,效率上要高一些,但是使类成为Singleton会使它的客户端测试变得十分困难。这里首先介绍两种单例模式的写法:
public class Elvis {
public static final Elvis INSTANCE = new Elvis();
private Elvis() {}
public void leaveBuilding() {}
}
public class Elvis implements Serializable {
private static final Elvis INSTANCE = new Elvis();
private Elvis() {}
public static Elvis getInstance() {
return INSTANCE;
}
public void leaveTheBuilding() {}
}
上面两种单例模式虽然形式上都能够得到Elvis的单例,但是如果我们通过反射或者通过ObjectOutputStream和ObjectInputStream来写入和读取对象,此时还是会创建一个新的实例。为了避免这种问题,我们可以在构造方法中进行判断,如果是第二次创建实例,则抛出异常;对于使用对象流的方式创建,我们需要为Elvis声明一个readResolve()方法以返回之前的实例,具体代码如下:
public class Elvis implements Serializable {
private static int id = 0;
private static final Elvis INSTANCE = new Elvis();
private Elvis() {
if (id >= 1) {
throw new IllegalStateException("Singleton pattern, multiple objects not allowed");
}
id++;
}
public static Elvis getInstance() {
return INSTANCE;
}
public void leaveTheBuilding() {}
public Object readResolve() {
return INSTANCE;
}
}
上述代码是一种创建单例模式的方式,不过在Java中,枚举类型是不能通过反射来创建其对象的实例的,并且其也是天然序列化过的,因而也能防止通过对象流的方式创建对象,具体的代码如下:
public enum Elvis {
INSTANCE;
public void leaveBuilding() {}
}
四、通过私有构造器强化不可实例化的能力
对于某些工具类,对其实例化是没有意义的,因而需要将其构造器私有化
public class UtilityClass {
// noninstantiability class
private UtilityClass() {
throw new AssertionError();
}
// static methods
}
这里需要注意的是,对于这些不可实例化的工具类,我们不能为其创建子类,因为在实例化子类的时候必须显示或隐式的调用父类的构造器。
五、避免创建不必要的对象
1.一般来说,最好能重用对象而不是在每次需要的时候就创建一个相同功能的对象;
2.对于同时提供了静态工厂方法和构造器的不可变类,通常可以使用静态工厂方法而不是构造器,以避免创建不必要的对象;
3.对于一些类中公用的创建比较消耗资源的对象,可以使用延迟初始化的方式来调用方法,但是不建议这样做,因为这样做会使方法的实现更为复杂;
4.要优先使用基本类型而不是自动装箱类型,因为装箱类型是不可变的,每次使用装箱类型时都会创建一个新的对象;
5.对于一些创建和回收动作非常低廉的小对象,如果通过创建附加的对象可以提升程序的清晰性、简洁性和功能性,那么就应该适当多创建一点;
6.通过维护自己的对象池来避免创建对象并不是一种好的做法,除非池中的对象是非常重量级的;
六、消除过期的对象引用
过期引用是指还被某个引用所指向,但是永远不会再被访问的引用。清除过期引用有两个好处:一是可以避免由于内存的不断增加而最终造成的磁盘交换或者程序失败;二是如果它们后序被错误的解除引用,程序会立即抛出NullPointerException,而不是悄悄地错误运行下去。
需要清除过期引用的点主要在这几个位置:一、只要内存是自己管理,程序员就应该警惕内存泄漏问题;二、对于缓存中被遗忘掉或者很久没有使用的对象,其应该被清除掉(当缓存是局部缓存时,可以使用WeakHashMap来保存对象,WeakHashMap的键如果没有外部引用指向,那么其就可能会被垃圾回收器回收;当缓存是全局缓存时,就可以使用LinkedHashMap来保存对象,其表现形式就像一个伸展树一样,并且其有一个removeEldestEntry方法可以移除较久没有被访问的对象);三、内存泄漏的第三个来源是监听器和其他回调(确保回调立即被垃圾回收的最佳方法是只保存他们的弱引用,例如将它们保存为WeakHashMap中的键)。
七、避免使用终结方法
终结方法的特点有如下几条:①终结方法不能保证其会被及时地执行,从一个对象变得不可到达开始到它的终结方法被执行,所花费的这段时间是任意长的;②终结方法线程的优先级比该应用程序的其他线程的要低得多;③java语言规范不仅不能保证终结方法会被及时的执行,而且根本就不保证它们会被执行;④如果未被捕获的异常在终结过程中被抛出来,那么这种异常可以被忽略,并且该对象的终结过程也会终止,未被捕获的异常会使对象处于破坏的状态,如果另一线程企图使用这种被破坏的对象,则可能发生任何不确定的行为,另外,正常的方法抛出异常会打印栈轨迹,但是终结方法抛出的异常则不会打印,甚至连警告都不会打印出来;⑤使用终结方法有一个非常严重的性能损失。
如果类的对象中封装的资源确实需要终止,我们则需要提供一个显示的终结方法。显示的终结方法必须在一个私有域中记录下“该对象是否已经不再有效”。显示的终结方法通常与try-finally结构结合起来使用,以确保其能够及时终止。
终结方法的使用场景非常有限,具体的有以下两条:①当对象的所有者忘记调用前述的显示的终结方法时,终结方法可以充当“安全网”,即在终结方法中调用显示的终结方法,并且如果终结方法发现资源未被终止,应该在日志记录中打印一条警告;②终结方法的第二种合理的用途与对象的本地对等体有关。本地对等体是一个本地对象,普通对象通过本地方法委托给一个本地对象。因为本地对等体不是一个普通对象,所以垃圾回收器不会知道它,当它的java对等体被回收的时候,它不会被回收。在本地对等体并不拥有关键资源的前提下,终结方法正是执行这项任务最合适的工具。如果本地对等体拥有必须及时终止的资源,那么该类就应该有一个显示的终止方法,终止方法应该完成所有必要的工作以便释放关键的资源。终止方法可以是本地方法,也可以调用本地方法。
这里需要注意的一点是,“终结方法链”并不会自动执行,如果类有终结方法,并且子类覆盖了终结方法,子类的终结方法就应该手工调用超类的终结方法,而且我们也应该在一个try块中终结子类,并且在相应的finally块中调用超类的终结方法,以保证超类肯定被终结。
八、覆盖equals时请遵守通用约定
一个类在以下情况下不用覆盖equals方法:①类的每个实例本质上都是唯一的;②不关心类是否提供了“逻辑相等”的测试功能;③超类已经覆盖了equals,从超类继承过来的行为对于子类也是合适的;④类是私有的或包级私有的,可以确定它的equals方法永远不会被调用。
一般子类属于“值类”的时候(即其是否相等与其具体的值有关)是需要覆盖equals方法的,但是有一种“值类”不需要覆盖equals方法,即用实例受控确保“每个值至多只存在一个对象”的类(枚举或单例)。
覆盖equals方法时需要遵守以下规范:①自反性。对于任何非null的引用值x,x.equals(x)必须返回true;②对称性。对于任何非null的引用值x和y,当且仅当y.equals(x)返回true时x.equals(y)必须返回true;③传递性。对于任何非null的引用值x、y和z,如果x.equals(y)返回true,并且y.equals(z)也返回true,那么x.equals(z)也必须返回true;④一致性。对于任何非null的引用值x和y,只要equals的比较操作在对象中所用的信息没有被修改,多次调用x.equals(y)就会一致地返回true或者一致地返回false;⑤对于任何非null的引用值x,x.equals(null)必须返回false。
这里有两点需要说明:①在使用继承的多态体系中,我们无法在扩展可实例化的类的同时,既增加新的值组件,同时又保留equals约定。这是因为对于父类,其有一个equals方法,而对于子类,其一般会重写equals方法,因为对于子类而言,其正是由于其不同于父类和其他子类的属性才使用多态加以区分。这里假设有如下继承体系:
public class Shape {
protected int area;
public boolean equals(Object obj) {
return (obj instanceof Shape) && ((Shape)obj).area == area;
}
}
class Circle extends Shape {
private double radius;
public Circle(double radius) {
super();
this.radius = radius;
area = (int)(Math.PI * radius * radius);
}
public boolean equals(Object obj) {
return (obj instanceof Circle) && Double.compare(((Circle)obj).radius, radius) == 0;
}
}
class Rectangle extends Shape {
private double width;
private double height;
public Rectangle(double width, double height) {
super();
this.width = width;
this.height = height;
area = (int)(width * height);
}
public boolean equals(Object obj) {
if (!(obj instanceof Rectangle)) {
return false;
}
Rectangle r = (Rectangle)obj;
return Double.compare(r.width, width) == 0 && Double.compare(r.height, height) == 0;
}
}
这里在继承之后,对于如下的代码:
Shape c = new Circle(1);
Shape r = new Rectangle(1, 3.14);
System.out.println(c.equals(r));
很明显,返回值将为false,但是对于客户端程序员来说,使用多态就是为了通过父类的引用来使用子类的对象,这里在调用equals方法时却必须要求传入的子类类型一致。如果我们不在子类中重写equals方法,而只是比较面积,虽然这符合了多态的用法,但是从物理意义上讲,一个圆怎么可能和一个矩形相同呢?因此,当扩展子类的时候,若在子类中加入新的属性,那么就无法保证equals的传递性的约定。
②可变对象在不同的时候可以根据具体的属性值来与不同的对象相等,不可变对象则只会与相同属性值的对象永远相等,因为其值不可变。但是无论类是否是不可变的,都不要使equals方法依赖于不可靠的资源。
我们在重写equals方法时,有几条通用约定步骤:①使用==操作符检查“参数是否为这个对象的引用”;②使用instanceof操作符检查“参数是否为正确的类型”;③把参数转换为正确的类型;④对于该类中的每个“关键”域,检查参数中的域是否与该对象中对应的域相匹配;⑤当编写完成了equals方法之后,应该问自己三个问题:它是否是对称的、传递的、一致的?
这里对于比较关键域,有六点需要说明:①对于既不是float也不是double类型的基本类型域,可以使用==操作符进行比较;②对于对象引用域,可以递归的调用equals方法;③对于float域和double域,可以使用Float.compare和Double.compare方法进行比较;④对于数组域,则要把以上这些指导原则应用到每个元素上。如果数组域中每个元素都很重要,就可以使用Arrays.equals方法进行比较;⑤有些对象引用域包含null可能是合法的所以为了避免可能导致NullPointerException异常,则使用下面的习惯用法来比较这样的域
field == null ? o.field == null : field.equals(o.field)
⑥对于某鞋类,域的比较要比简单的等同性测试复杂得多。如果是这样的情况,可能会希望保存该域的一个”范式“,这样equals方法就可以根据这些范式进行低开销的比较,而不是高开销的非精确比较。这种方式对于不可变类是最为合适的;如果对象发生变化,就必须使其范式保持更新;⑦域的比较顺序可能会影响到equals方法的性能,为了获得最佳性能,应该最先比较最有可能不一致的域,或者是开销最低的域,最理想的情况是两个条件同时满足的域。
注意点:①覆盖equals时总要覆盖hashCode;②不要企图让equals方法过于智能;③不要将equals声明中的Object对象替换为其他的类型。
九、覆盖equals时总要覆盖hashCode
在每个覆盖了equals方法的类中,也必须覆盖hashCode方法。对于对象的equals方法和hashCode方法,有一些规范:①在应用程序的执行期间,只要对象的equals方法的比较操作所用到的信息没有被修改,那么对于同一个对象的多次调用,hashCode方法都必须始终如一地返回同一个整数。也就是说hashCode计算中所用到的域必须是equals方法已经用到的;②如果两个对象根据equals方法比较是相等的,那么调用这两个对象中任意一个对象的hashCode方法都必须产生同样的整数结果;③如果两个对象根据equals(Object)方法比较是不想等的,那么调用这两个对象中任意一个对象的hashCode方法,则不一定要产生不同的整数结果。但是程序员应该知道,给不相等的对象产生截然不同的整数结果,有可能提高散列表的性能。
为了使不相等的对象产生不同的hashCode值,这里有几条建议:
1、把某个非零的常数值,比如说17,保存在一个名为result的int类型的变量中;
2、对于对象中每个关键域f(指equals方法中涉及到的每个域),完成以下步骤:
a.为该域计算int类型的散列码c:
Ⅰ.如果该域是boolean类型,则计算(f ? 1 : 0);
Ⅱ.如果该域是byte、char、short或int类型,则计算(int)f;
Ⅲ.如果该域是long类型,则计算(int) (f ^ (f >>> 32));
Ⅳ.如果该域是float类型,则计算Float.floatToIntBits(f);
Ⅴ.如果该域是double类型,则计算Double.doubleToLongBits(f),然后按照步骤Ⅲ计算long类型计算的散列值;
Ⅵ.如果该域是一个对象引用,并且该类的equals方法通过递归地调用equals的方式来比较这个域,则同样为这个域递归地调用hashCode;
Ⅶ.如果该域是一个数组,则要把每一个元素当作单独的域来处理。如果数组域中每个元素都很重要,可以利用Array.hashCode方法计算。
b.按照下面的公司,把步骤2.a中计算得到的散列码c合并到result中:
result = 31 * result + c;
3、返回result;
4、写完hashCode方法之后,问问自己“相等的实例是否都具有相等的散列码”;
对于散列函数,有几点需要说明:1.在散列码的计算过程中,可以把冗余域排除在外;2.必须排除equals比较计算中没有用到的任何域,否则可能违反hashCode约定的第二条;3.hashCode计算中,之所以选择31,是因为它是一个奇素数,如果乘数是偶数,并且乘法溢出的话,信息就会丢失,因为与2相称等价于移位运算。使用奇素数的好处并不明显,但是习惯上都使用素数来计算散列结果。31有个很好的特性,即用移位和减法来代替乘法,可以得到更好的性能:31 * i == (i << 5) - i。现代的jvm可以自动完成这种优化;3.如果一个类是不可变的,并且计算散列码的开销也很大,就应该考虑把散列码缓存在对象内部,而不是每次请求的时候都重新计算散列码。如果这种类型的大多数对象会被用作散列键,就应该创建实例的时候计算散列码。否则,可以选择“延迟初始化”散列码,一直到hashCode被第一次调用的时候才初始化;4.不要试图从散列码计算中排除掉一个对象的关键部分来提高性能。
十、始终要覆盖toString
toString方法包含了当前对象丰富的信息,建议所有的子类都覆盖这个方法。并且在后序使用过程中,一个好的toString方法能够使类使用起来更加舒适。这里对于toString方法的覆盖有几条规则:1.在实际应用中,toString方法应该返回对象中包含的所有值得关注的信息;2.对于值类,比如电话号码、矩阵类,建议在文档中指定返回值的格式,如果指定了格式,最好再提供一个相匹配的静态工厂或者构造器,以便程序员可以很容易地在对象和它的字符串表示法之间进行来回切换。但是对于指定了格式的toString方法,我们必须始终如一的坚持这种格式,因为客户端程序员可能会根据这种格式进行一定的字符串解析;3.无论你是否指定格式,都应该在文档中明确的表明你的意图;4.无论是否指定格式,都为toString返回值中包含的所有信息,提供一种编程式的访问途径,否则客户端程序员就不得不根据字符串进行解析。
十一、谨慎地覆盖clone
Cloneable接口的目的是作为对象的一个mixin接口,表明这样的对象允许克隆。遗憾的是,它并没有成功地达到这个目的,其主要的缺陷在于它缺少一个clone方法,而Object类的clone方法是受保护的。如果一个对象实现了Cloneable接口,那么该对象调用clone方法将返回该对象的逐域拷贝,否则会抛出CloneNotSupportedException异常。
协变返回类型是指子类覆盖父类的方法之后,覆盖方法的返回类型可以是被覆盖方法的返回类型的子类型。这里如果子类覆盖了clone方法,那么子类就可以将返回类型由Object类型修改为当前子类的类型。无论是通过协变返回类型还是强制类型转换,永远都不要让客户去做任何类库能够替客户去完成的工作。
对于前面描述的实现了Cloneable接口的类,如果不定义自己的clone方法,那么继承而来的clone方法将只会返回对象的逐域拷贝,但是如果域为引用类型,那么原对象和克隆而来的对象将指向同一块内存地址,造成错误。实际上,clone方法就是另一个构造器,你必须确保它不会伤害到原始对象,并确保正确的创建被克隆对象中的约束条件。并且我们还需要注意一个问题就是clone方法所使用的对象的域的引用不能是final类型的,因为final类型不可再被赋值。
数组引用有一个clone方法,该方法将产生一个新的数组,但是新的数组的值只是原数组的值的拷贝,也就是说如果数组元素是引用类型,那么虽然是两个不同的数组,但是两个数组的元素都是指向同一块内存地址。
克隆的另一种方法是,先调用super.clone(),然后将结果对象中的所有域都设置成它们的空白状态,然后调用高层的方法类重新产生对象的状态,也即调用类的方法来产生类的当前状态,但是这种方式没有操纵类的内部元素快,比如HashMap的put方法。
clone方法还需要注意的一个问题是,如果子类覆盖了父类的一个方法,那么在clone方法中将不能调用该方法来构建新对象的状态,因为这将导致新对象的状态与原对象不一致,如果确实要调用,那么父类的该方法应该被声明为final类型的,这样子类就不能对其进行重写,或者只调用父类的私有方法。
对于覆盖了clone方法的类,如果该类将该方法的访问范围修改为public的,那么该clone方法就不应该抛出CloneNotSupportedException异常,因为这样客户端能够更方便的使用clone方法。如果该类是用来被继承的,比如抽象类,那么该类的clone方法就应该沿用Object的clone方法,将访问权限设置为受保护的,并且会抛出CloneNotSupportedException。
简而言之,所有实现了Cloneable接口的类都应该用一个公有的方法覆盖clone。此公有方法首先调用super.clone,然后修正任何需要修正的域。一般情况下,这意味着要拷贝任何包含内部“深层接口”的可变对象,并用指向新对象的引用代替原来指向这些对象的引用。
对于对象的克隆,最好的方法并不是通过clone方法,而是通过其他的途径,比如传入当前类型的构造方法和工厂方法,如:
public Yum(Yum yum);
public static Yum newInstance(Yum yum);
拷贝构造器和拷贝静态工厂方法都比clone方法要有更多的优势:它们不依赖于某一种很有风险的、语言之外的对象创建机制;它们不要求遵守尚未制定好文档的规范;它们不会与final域的正常使用发生冲突;它们不会抛出不必要的受检查异常;它们也不需要进行类型转换。另外,拷贝构造器或者工厂方法所传如的参数可以是一个接口,这有助于类型的转换,比如集合类的拷贝构造器。因此,一般的,当要克隆一个对象时,首要考虑的是使用拷贝构造器或拷贝工厂方法。
十二、考虑实现Comparable接口
一个类实现了Comparable接口,就表明该类实现了内在的排序关系。java平台类库中的所有值类都实现了Comparable接口。如果我们正在编写一个值类,它具有非常明显的内在排序关系,比如按字母排序、按数值顺序或年代排序,那你就应该坚决考虑实现Comparable接口。
实现Comparable接口时具有一些规范,具体如下(下面的说明中sgn表示数学中的signum函数,它是根据表达式的值为负值、零和正值,分别返回-1、0或1):
实现者必须确保所有的x和y都满足sgn(x.compareTo(y)) == -sgn(y.compareTo(x))(这也暗示着当且仅当y.compareTo(x)抛出异常时,x.compareTo(y)才必须抛出异常)。
实现者还必须确保这个比较关系是可传递的:(x.compareTo(y) > 0 && y.compareTo(z) > 0)暗示着x.compareTo(z) > 0。
最后,实现者必须确保x.compareTo(y) == 0暗示着所有的z都满足sgn(x.compareTo(z)) == sgn(y.compareTo(z))。
强烈建议(x.compareTo(y) == 0) == (x.equals(y)),但这并非绝对必要。一般说来,任何实现了Comparable接口的类,若违反了这个条件,都应该明确予以说明。推荐使用这样的说法:“注意:该类具有内在的排序功能,但与equals不一致”。
从这四个约定可以看出,由compareTo方法施加的等同性测试,也一定遵守相同于equals约定所施加的限制条件:自反性、对称性和传递性。并且,对于多态,无法在用新的值组件扩展可实例化的类时,同时保持compareTo约定,除非是愿意放弃掉面向对象的抽象优势。如果你想为一个实现了Comparable接口的类增加值组件,请不要扩展这个类;而是要编写一个不相关的类,其中包含第一个类的实例。然后提供一个“视图”方法返回这个实例。
如果一个类没有实现Comparable接口,但是对于这个类的实例,你需要一个非标准的排序关系,那么你可以使用一个显示的Comparator来代替。
如果一个类有多个关键域,那么按什么样的顺序来比较这些域是非常关键的。你必须从最关键的域开始,逐步进行到所有的重要域。如果某个域的比较产生了非零的结果,则整个比较结束,并返回该结果。如果最关键的域是相等的,则进一步比较次最关键的域,依此类推。如果所有的域都是相等的,则对象就是相等的,并返回零。比如如下的代码示例:
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;
}
这里,对于上述compareTo方法体是可以简化的,因为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;
}
这里需要说明的一点是,虽然简化了compareTo方法,但是这种方式却不可取,因为对于整形值的计算,其有可能超出整形值的边界,从而产生不可预料的后果,而这种问题也是非常少见的,这也加大了检查错误的难度。
五十三、接口优先于反射机制
核心反射机制提供了“通过程序来访问已装载的类的信息”的能力。通过一个Class实例,我们可以获得Constructor、Method、和Field实例,并且通过这些实例对类进行相应的构造器,方法和变量的调用。反射机制是一把有其灵活性,但是也有缺点。反射机制的主要优点有:
对于某些程序,它们必须用到在编译时无法获取的类,但是在编译时存在适当的接口或者超类,通过它们可以引用这个类。如果是这种情况,就可以通过反射机制创建该类的实例,并且将该实例转型为其接口类型,我们就可以通过其接口调用该实例的方法。
类对于在运行时可能不存在的其他类、方法或者域的依赖性,用反射进行管理。
反射机制的主要缺点有:
丧失了编译时类型检查的好处,包括异常检查,并且使用反射会产生新的异常类型;
执行反射访问所需要的代码非常笨拙和冗长;
性能损失;
五十四、谨慎地使用本地方法
本地方法是指用本地程序设计语言(比如C或者C++)来编写的特殊方法。本地方法主要有三种用途:
它们提供了“访问特定于平台的机制”的能力,比如访问注册表和文件锁;
它们提供了访问遗留代码库的能力,从而可以访问遗留数据;
本地方法可以通过本地语言,编写应用程序中注重性能的部分,以提高系统的性能;
以上优点只在非常少的情况下才会使用,并且随着现代VM的发展,大多数方法已经可以不使用本地方法就可以获得使用本地方法才能获得的功能,并且也能获得比执行本地方法更快的性能,因此食用本地方法来提高性能的做法不值得提倡。本地方法在使用时主要有以下缺点:
本地语言是不安全的,所以,使用本地方法的应用程序也不再能免受内存毁坏错误的影响;
因为本地语言是与平台相关的,使用本地方法的应用程序也不再是可自由移植的;
使用本地方法的应用程序更难调试;
在进入和退出本地方法时,需要相关的固定开销;
需要“胶合代码”的本地方法编写起来单调乏味,并且难以阅读;
五十五、谨慎地进行优化
优化的弊大于利,特别是不成熟的优化。在优化的过程中,产生的软件可能既不快速,也不正确,而且还不容易修正。关于优化有几条建议:
不要因为性能而牺牲合理的结构。要努力编写好的程序而不是快的程序。如果好的程序不够快,它的结构将使它得到优化。实际上的性能问题可以通过后期的优化而得到修正,但是遍布全局并且限制性能的结构缺陷几乎是不可能被修正的,除非重新编写系统。
努力避免那些限制性能的设计决策。当一个系统设计完成之后,其中最难以更改的组件是那些指定了模块之间交互关系以及模块与外界交互关系的组件。这里在进行API设计的时候,要考虑API设计决策的性能后果,比如使类成为不可变的,或者尽量使用复合而非继承,或者在API中使用接口而非实现类型。
在每次视图做优化之前和之后,要对性能进行测量。
五十六、遵守普遍接受的命名惯例
包的名称应该是层次状的,用句号分隔每个部分。每个部分都包括小写字母和数字(很少使用数字)。任何将在你的组织之外使用的包,其名称都应该以你的组织的Internet域名开头,并且顶级域名放在前面。
包名称的其余部分应该包括一个或多个描述该包的组成部分。这些组成部分应该比较简短,通常不超过8个字符。每个组成部分通常都应该由一个单词或者一个缩写词组成。
类和接口的名称,包括枚举和注解类型的名称,都应该包括一个或者多个单词,每个单词的首字母大写。应该尽量避免使用缩写,除非是一些首字母缩写和一些通用的缩写。对于首字母缩写,强烈建议采用仅首字母大写的形式:即连续出现多个首字母缩写的形式,你仍然可以区分出一个单词的起始处和结束处。
方法和域的名称与类和接口的名称一样,都遵守相同的字面惯例,只不过方法或者域的名称的第一个字母应该小写。
常量域的名称应该包含一个或者多个大写的单词,中间用下划线符号隔开。
局部变量名称的字面命名惯例与成员名称类似,只不过它也允许缩写,单个字符和短字符序列的意义取决于局部变量所在的上下文环境。
类型参数名称通常由单个字母组成。这个字母通常是以下五种类型之一:T表示任意的类型,E表示集合的元素类型,K和V表示映射的键和值类型,X表示异常。任何类型的序列可以是T、U、V或者T1、T2、T3。
类(包括枚举类型)通常用一个名词或者名词短语命名。接口的命名与类相似,或者用一个以-able或者-ible结尾的形容词命名。
执行某个动作的方法通常用动词或者动词短语命名,对于返回boolean值的方法,其名称往往以单词“is”开头,很少用has,后面跟名词或者名词短语,或者任何具有形容词功能的单词或者短语。
如果方法返回被调用对象的一个非boolean的函数或者属性,它通常用名词、名词短语或者以动词“get”开头的动词短语来命名。如果方法所在的类是个Bean,就要强制使用以“get”开头的形式。
转换对象类型的方法、返回不同类型的独立对象的方法,通常被称为toType,例如toString和toArray。返回视图的方法通常被称为asType,例如asList。返回一个与被调用对象同值得基本类型的方法,通常被称为typeValue,例如intValue。静态工厂的常用名称为valueOf、of、getInstance、newInstance、getType和newType。
五十七、只针对异常的情况才使用异常
异常时为了在异常情况下使用而设计的,不要将它们用于普通的控制流,也不要编写迫使它们这么做的API。有些人认为通过异常编写出来的代码比一般的代码要快,因为其要对是否正常进行判断,不如数组遍历,但是这与异常的设计初衷不符,并且在现代JVM中,其效率并不一定更高:①因为异常机制的设计初衷是用于不正常的情形,所以很少会有JVM实现试图对它们进行优化,使得与显示的测试一样快速;②把代码放在try-catch块中反而阻止了现在JVM实现本来可能要执行的某些特定优化;③对数组进行遍历的标准模式并不会导致冗余的检查,有些现代JVM实现会将它们优化掉。顾名思义,异常应该只用于异常的情况下;它们永远不应该用于正常的控制流。更一般地,应该使用标准的、容易理解的模式,而不是那些声称可以提高更好性能的、弄巧成拙地方法。
有的方法是与“状态相关”的,其调用必须是基于某个正常状态的情形,比如Iterator的next()方法,对于这样的方法,比较好的做法是提供一个单独的“状态测试”的方法,比如hasNext()方法;另外一种提供状态测试的做法是,如果该方法调用时,该方法处于不正常的状态中,那么其就返回一个可识别的值,比如null。
对于“状态测试方法”和“可识别的返回值法”的选择,有几点指导原则:①如果是在缺少外部同步的情况下进行调用,那么选择可识别返回值的方法是比较好的选择,因为如果选择状态测试法,在进行状态测试和“状态相关”方法调用中间的间隙时该方法的状态可能被其他线程改变;②如果单独的“状态测试”方法在调用过程中会重复“状态相关”方法的工作,那么可识别返回值的方法效率要更高一些;③如果其他方面都是等同的,那么状态测试法比可识别返回值法要更好一点,因为其具有更好的可读性,并且对于不正常使用情况也更加易于检测和改正。
五十八、对可恢复的情况使用受检异常,对编程错误使用运行时异常
java提供了三种可抛出结构:受检的异常、运行时异常和错误。对于可恢复的情况,则使用受检异常;对于编程错误,则使用运行时异常;如果不清楚是否可能恢复,就使用运行时异常。
五十九、避免不必要地使用受检的异常
受检的异常可以强迫程序员对其进行处理,这样在某种程度上使得代码更加规范,但是,受检的异常会是API的使用变得非常的麻烦。有两种方式可以将受检异常进行重构:①通过将抛出异常的方法分离为一个“状态相关的”方法和一个“状态测试的”方法,即创建一个返回boolean值的方法,如果该方法返回true,则调用该抛出运行时异常的方法,如果该方法返回false,则让程序员自行处理该异常;②如果程序员知道调用会成功或者不介意由于调用失败而造成的线程终止,那么就直接将该抛出受检查异常的方法改为抛出运行期异常即可。对于第一种方法,可查看如下示例代码:
try {
obj.action(args);
} catch(TheCheckedException e) {
// Handle exceptional condition
}
// 重构为如下代码
if (obj.actionPerformed(args)) {
obj.action(args);
} else {
// Handle exceptional condition
}
六十、优先使用标准的异常
重用异常有许多方面的好处,最主要的有三点:①它使我们的程序更易于学习和使用,因为这些异常都是程序员所熟悉的API;②它使程序的可读性更好,因为它们的用法和程序员的习惯用法一致;③所使用的异常类越少,打印出来的栈轨迹就越少,程序所需要装载的类就越少。
以下是一些常用异常的汇总:
IllegalArgumentException 当调用者传递的参数不合适的时候抛出参数类型错误异常
IllegalStateException 当调用者传递的对象状态非法时(比如未构造完毕)抛出状态非法异常
NullPointerException 当调用者传递的参数为空时抛出空指针异常
ConcurrentModificationException 当只被允许单线程或外部同步块调用的方法被多线程同时修改时抛出此异常
UnsupportedOperationException 如果对象不支持所请求的操作,则抛出此异常
ArithmeticException、NumberFormatException 在实现诸如复数或者有理数之类的算术对象时出现异常,则可以抛出这两个异常
最后需要说明的是,具体抛出哪种异常并不是绝对的,因为上述异常类型并不相互排斥。
六十一、抛出与抽象相对应的异常
如果方法抛出的异常与其执行的任务没有直接的联系,那么该异常将使人不知所措,这种情况尤其发生在底层异常抛出时。对于这种问题的解决方式有两种:①如果高层API能够对底层抛出的异常进行转义,那么高层就可以捕获底层将要抛出的异常,将其转换为用户能够理解的形式;②如果高层无法对底层异常进行转义,那么高层异常就应该绕开底层异常,从而将高层异常与底层异常隔离开。这里对于第一点,需要说明的是,在高层异常捕获了底层异常之后,如果底层异常的异常链对于高层异常的理解非常有帮助,那么高层就可以将底层异常的异常链传递给高层异常,高层异常在打印栈轨迹的时候也会打印出底层异常的栈轨迹。