六、通用程序设计
(廿九)将局部变量的作用域最小化
使一个局部变量作用域最小化,最有力的技术是在第一次使用它的地方声明。过早地声明一个局部变量不仅会使它的作用与被扩展到太早的点上,同样也会被扩展到太晚的点上。局部变量的作用与从它被声明的点开始,一直到外围块的结束处。如果一个变量是在“使用它的块”之外被声明,那么当程序退出该块之后,该变量仍然是可见的。如果一个变量在它的目标使用区域之前或者之后被意外使用的话,则后果将是灾难性的。
几乎每一个局部变量的声明都应该包含一个初始化表达式。如果你还没有足够的信息来对一个变量进行有意义的初始化,那么你应该推迟这个声明,直到可以初始化为止。
(卅)了解和使用库
通过使用标准库,你可以 充分利用这些编写标准的专家知识,以及在你之前其他人的使用经验。使用标准库的第二个好处是,你不必浪费时间为那些与你的工作关系不大的问题提供特别的解决方案。使用标准库的第三个好处是,它们的性能会不断提高,而无需你做任何努力。使用标准库的最后一个好处是,你可以使自己的代码融入主流。这样的代码更易读、易维护、易被其他的开发人员使用。
(卅一)如果要求精确的答案,请避免使用float和double
对于有些要求精确答案的计算任务,请不要使用float或者double。如果你希望系统来处理十进制小数点,并且不介意因为不使用原语类型带来的不便,那么请使用BigDecimal。使用BigDecimal还有一些额外的好处,它允许你完全控制舍入:当一个操作涉及到舍入的时候,它让你从8种舍入模式中选择其一。如果你正在进行商务计算,并且要求特别的舍入行为,那么使用BigDecimal时非常方便的。如果性能非常关键,并且你又不介意自己处理十进制小数点,而且所涉及的数值又不太大,那么可以使用int或者long。如果数值范围没有超过9为十进制数字,则你可以使用int;如果不超过18位数字,则可以使用long。如果数值范围超过了18位数字,你就必须使用BigDecimal。
(卅二)如果其他类型更合适,则尽量避免使用字符串
字符串不适合代替其他的值类型。如果存在一个适当的值类型,不管是原语类型还是对象引用,你都应该使用这种类型。如果不存在这样的类型,那么你应该编写一个类型。
字符串不适合代替枚举类型。类型安全枚举类型(typesaveenum)和int值都比字符串更加适合用来表示枚举类型的常量。
字符串不适合代替聚集类型。如果一个实体有多个组件,那么,用一个字符串来表示这个实体通常是很不恰当的。
总而言之,如果可以使用更加合适的数据类型,或者可以编写更加适当的数据类型,那么应该避免使用字符串来表示对象。若使用不当,则字符串比其他类型更加笨拙、缺乏灵活性、速度缓慢,更加容易出错。
(卅三)了解字符串连接的性能
字符串连接操作符“+”是把多个字符串合并为一个字符串的便利途径。要想产生一行输出,或者构造一个字符串来表示一个小的、大小固定的对象,使用连接操作符是非常合适的,但是它不适合规模比较大的情形。为连接n各字符串而重复地使用字符串连接操作符,要求n的平方级的时间。这是由于字符串是非可变而导致的不幸结果。
不要使用字符串连接操作符来合并多个字符串,除非性能无关紧要。相反,应该使用StringBuffer的append方法,或者采用其他的方案,比如使用字符数组,或者每次只处理一个字符串,而不是将它们组合起来。
(卅四)通过接口引用对象
你应该优先使用接口而不是类来引用对象。如果有合适的接口存在,那么对参数、返回值、变量和域的生命都应该使用接口类型。只有当你创建某个对象的时候,你才真正需要引用这个对象的类。如果你养成了使用接口作为类型的习惯,那么你的程序将会更加灵活。
(卅五)接口优先于反射机制
反射机制java.lang.reflect,提供了“通过程序来访问关于已装载的类的信息”的能力。这些对象提供了“通过程序来访问类的成员名字、域类型、方法原型等信息”的能力。而且,Contructor、Method和Field实例使你能够维护它们的底层对等体(“反射到底层”):通过调用Constructor、Method和Field实例上的方法,你可以构造底层类的实例、调用底层类的方法、访问底层类中的域。
反射机制允许一个类使用另一个类,即使当前者被编译的时候后者还根本不存在。然而,这种能力也需要付出代价:
1、你损失了编译时类型检查的好处,也包括异常检查。如果一个程序企图用反射方式调用一个不存在的方法,或者一个不可访问的方法,那么在运行时刻它将会失败,除非你采取了特别的预防措施。
2、要求执行映像访问的代码非常笨拙和冗长。编写这样的代码非常乏味,阅读这样的代码也很困难。
3、性能损失。
通常,普通应用在运行时不应该以反射方式访问对象。如果只是在很有限的情况下使用反射机制,那么虽然也会付出少许代价,但你可以获得许多好处。对于有些程序,它们用到的类在编译时刻是不可用的,但是在编译时刻存在适当的接口或者超类,通过它们可以引用到这些类。如果是这种情况,那么你可以以反射方式创建实例,然后通过它们的接口或者超类,以正常方式访问这些实例。如果存在适当的构造函数不带参数,那么你甚至根本不需要使用java.lang.reflect包;Class.newInstance方法就已经提供了所需要的功能。
一种合法使用反射的做法是,打破一个类对于其他类、方法或者域(它们在运行时刻可能不存在)的依赖性。如果你要编写一个包,它运行的时候需要依赖其他某个包的多个版本,那么这种做法可能会非常有用。这项技术是,在你的包所需要的最小环境下对它进行编译,所谓最小环境通常是最老的版本,然后以反射方式访问任何新的类或者方法。
简而言之,反射机制是一项功能强大的设施,对于一些特定的复杂程序设计任务它是非常必要的,但是也有一些缺点。如果你编写的程序必须要与编译时刻未知的类一起工作,那么,有可能的话,仅仅使用反射机制实例化对象,而访问对象时使用编译时刻已知的某个接口或者超类。
(卅六)谨慎地使用本地方法
Java Native Interface(JNI)允许Java应用可以调用本地方法(native method),所谓本地方法是指用本地程序设计语言来编写的特殊方法。本地方法在本地语言中可以执行任意的计算任务,然后返回到Java程序设计语言。随着1.3发行版的推出,使用本地方法来提高性能的做法已经不值得提倡。因为本地语言不是安全的,所以,使用本地方法的应用程序也不免受到内存毁坏错误的影响;因为本地语言是平台相关的,所以使用本地方法的应用程序也不再是可自由移植的。对于每一个目标平台,本地代码都需要经过重新编译,也有可能要求做一些修改。同时,在进入和退出本地代码时,也需要较高的固定开销,所以,如果本地代码知识做少量工作的话,本地方法可能会降低性能。
简而言之,在使用本地方法之前请仔细考虑。很少情况下需要使用本地方法来提高性能。如果你必须要使用本地方法来访问底层的资源,或者遗留代码库,那么尽可能少用本地代码,并且全面进行测试。本地代码中的一个错误可以破坏整个应用程序。
(卅七)谨慎地进行优化
不要因为性能而牺牲合理的结构。努力编写好的程序而不是快的程序。如果一个好的程序不够快,它的结构将使得它可以被优化。好的程序体现了信息隐藏的原则:只要有可能,它们就会把设计决定限定在局部的单个模块中,所以,单个决定可以被改变,并且不会影响到系统的其他部分。
这并不意味这,在完成程序之前你就可以忽略性能问题。实现上的问题可以通过后期的优化而被改正,但是,遍布全局并且限制性能的结构缺陷几乎是不可能被改正的,除非重新编写系统。在系统完成之后再改变你的设计的某个基本方面,会导致你的系统结构病态,从而难以维护和改进。因此,你应该在设计过程中考虑性能问题。
努力避免那些限制性能的决定。当一个系统设计完成之后,其中最难以更改的组件是那些指定了模块之间交互关系以及模块与外界交互关系的组件。在这些设计组件之中,最主要的是API、线路层(wire-level)协议以及永久数据格式。不仅这些设计组件在事后难以设置不可能被改变,而且它们都有可能对系统本该达到的性能产生重要的限制。
考虑你的API设计决定的性能后果。一般而言,好的API设计也伴随着好的性能。为获得好的性能而对API进行曲改,这是一个非常不好的想法。导致你对API进行曲改的性能因素可能会在将来的平台版本中,或者在将来的底层软件中不复存在,但是被曲改的API以及由它引起的问题将永远困扰这你。
总而言之,不要费力去编写快速程序——应该努力编写好的程序,速度自然会随之而来。
(卅八)遵守普遍接受的命名惯例
七、异常
(卅九)只针对不正常的条件才使用异常
因为异常机制的设计初衷是用于不正常的情形,所以很少会有JVM实现试图对它们的性能做优化。所以,创建、抛出和捕获异常的开销是很昂贵的。把代码放在try-catch块中反而阻止了现代JVM实现本来可能要执行的某些特定优化。对数组进行遍历的标准模式并不会导致冗余的检查;有些现代的JVM实现会将它们优化掉。
顾名思义,异常只应该被用于不正常的流程,它们永远不应该被用于正常的控制流。更一般地,你应该优先使用标准的、容易理解的模式,而不是那些声称可以提供更好性能、弄巧成拙的模式。即使真的能够改进性能,面对JVM实现的不断改进,这种模式的性能优势也许不复存在。然而,由这种过渡聪明的模式带来的隐藏的错误,以及维护的痛苦却依然存在。一个设计良好的API不应该强迫它的客户为了正常的控制流而使用异常。
(卌)对于可恢复的条件使用被检查的异常,对于程序错误使用运行时异常
Java程序设计语言提供了三种可抛出结构(throwable):被检查的异常(checked exception)、运行时异常(run-time exception)和错误(error)。在决定使用一个被检查的异常或是一个未被检查的异常时,主要的原则是:如果期望调用者能够恢复,那么对于这样的条件你应该使用被检查的异常。通过抛出一个被检查的异常,你强迫调用者在一个catch子句中处理该异常,或者将它们传播到外面。对于一个方法声明要抛出的每一个被检查的异常,它是对API用户的一种潜在指示:与异常相关联的条件是调用这个方法的一种可能结果。
有两种未被检查的可抛出结构:运行时异常和错误。在行为上两者是等同的:它们都是不需要,也不应该被捕获的抛出物。如果一个程序抛出一个未被检查的异常,或者一个错误,则往往是不可能恢复的情形,继续执行下去有害无益。如果一个程序没有捕捉这样的可抛出结构,则将会导致当前线程停止,并伴以一个适当的错误消息。
用运行时异常来指明程序错误。你所实现的所有的未被检查的跑出结构都应该是RuntimeException的子类(直接的或者间接的)。
(卌一)避免不必要地使用被检查的异常
被检查的异常时Java程序设计语言的一个很好的特征。与返回代码不同,它们强迫程序员处理例外的条件,大大提高了可靠性。然而过分使用被检查的异常会使API用起来非常不方便。如果使用API的程序员无法做得比这个更好,那么未被检查的异常可能更为合适。
如果一个方法抛出的被检查异常是惟一的,那么它给程序员带来的额外负担会非常高。如果这个方法还有其他被检查的异常,那么该方法被调用的时候,必须已经出现在一个try块中,所以这个异常仅仅要求另外一个catch块。如果一个方法只抛出一个被检查异常,那么仅仅为了这个异常,该方法必须放置于try块中。在这样的情形下,你应该问自己,是否可以有别的途径来避免使用被检查的异常。
“把被检查的异常变成未被检查的异常”的一种技术是,把这个要抛出异常的方法分成两个方法,其中第一个方法返回一个boolean,表明是否应该抛出异常。这是一种API转换,它实际上把下面的调用序列:
try{
obj.action(args);
}catch(TheCheckedException e) {
// Handle exceptional condition
……
}
转换为:
if(obj.actionPermitted(args)) {
obj.action(args);
} else {
// Handle exceptional condition
……
}
(卌二)尽量使用标准的异常
重用现有的异常有多方面的好处。其中最主要的好处是,它使得你的API更加易于学习和使用,因为它与程序员原来已经熟悉的习惯用法是一致的。第二个好处是,对于用到这些API的程序而言,它们的可读性好,因为它们不会充斥着程序员不熟悉的异常。最后一点是,异常类越少,意味着内存占用越小,并且装在这些类的时间开销也越小。
常用的异常有:
IllegalArgumentException 参数的值不合适
IllegalStateException 对于这个方法调用而言,对象状态不合适
NullPointerException 在null被禁止的情况下参数值为null
IndexOutOfBoundsException 下标越界
ConcurrentModificationException 在禁止并发修改的情况下,对象检测到并发修改
UnsupportedOperationException 对象不支持客户请求的方法
(卌三)抛出的异常要适合于相应的抽象
如果一个方法抛出的异常与它所执行的任务没有明显的关联关系,这种情形会使人不知所措。当一个方法传递一个由低层抽象抛出的异常时,往往会发生这样的情况。除了使别人感到困惑之外,这也“污染”了具有实现细节的高层API。如果高层的实现在后续的发行版本中发生了变化,那么它所抛出的异常也可能会跟着变化,从而会潜在地打破现有的客户程序。为了避免这个问题,高层的实现应该捕获低层的异常,同时抛出一个可以按照高层抽象进行解释的异常。这种做法被成为异常转译(excpetion translation),如下所示:
// Exception translation
try {
// use lower-level abstraction to do our bidding
……
} catch(LowerLevelException e) {
throw new HigherLevelException(……);
}
尽管异常转译比不加选择地传递低层异常的做法有所改进,但是它也不能被滥用。如果可能的话,处理来自低层异常的最好做法是,在调用低层方法之前确保它们会成功执行,从而避免它们会抛出异常。
如果无法阻止来自低层的异常,那么其次的做法是,让高层来处理这些异常,从而将高层方法的调用者与低层问题隔离开。在这种情况下,用某种适当的记录设施将低层的异常记录下来可能是很合适的。
如果既不能阻止来自低层的异常,也无法将它们与高层隔离开,那么一般的做法是使用异常转译。
(卌四)每个方法抛出的异常都要有文档
总是要单独地声明被检查的异常,并且利用Javadoc的@throws标记,准确地记录下每个异常被抛出的条件。如果一个方法可能会抛出多个异常类,则不要使用“快捷方式”:即声明它会抛出这些异常类的某个超类。作为一个极端的例子,永远不要声明一个方法“throws Exception”,或者更差的做法“throws Throwable”。这样的生命不仅没有为程序员提供“这个方法能够抛出哪些异常”的任何指导信息,而且大大妨碍了该方法的使用,因为它实际上掩盖了在同样的环境中该方法可能会掏出的任何其他异常。
使用Javadoc的@throws标签记录下一个方法可能会抛出的每个未被检查的异常,但是不要使用throws关键字将未被检查的异常包含在方法的声明中。
如果一个类中的许多方法处于同样的原因而抛出同一个异常,那么在该类的文档注释中对这个异常做文档,而不是为每个方法单独做文档,这是可以接受的。一个常见的例子是NullPointerException。如果一个类的文档注释中有这样的描述“如果一个null对象引用被传递到任何一个参数中,那么这个类中的所有方法都会抛出NullPointerException”,或者其他类似的语句,则这种做法是很合适的。
(卌五)在细节消息中包含失败——捕获信息
为了捕获失败,一个异常的字符串表示应该包含所有“对该异常有贡献”的参数和域的值。例如,IndexOutOfBoundsException异常的细节消息应该包含下界、上界,以及没有落在其中的实际下标值。该细节提供了许多关于失败的信息。这三个值中任何一个都有可能是错的。每一种情形都代表了不同的问题,如果程序员知道应该如何去寻找哪一种错误,则可以极大地有助于诊断过程。
(卌六)努力使失败保持原子性
当一个对象抛出异常之后,我们总是期望这个对象仍然保持在一种定义良好的可用状态之中,即使失败发生在某个操作的过程中间。对于被检查的异常而言,这尤为重要,因为调用者会期望能够从这种异常中恢复过来。一般而言,一个失败的方法调用应该使对象保持“它在被调用之前的状态”。具有这种属性的方法被成为具有失败原子性(failure atomic)。
有几种途径可以获得这种效果。最简单的办法莫过于设计一个非可变对象。如果一个对象是非可变的,那么失败原子性是显然的。如果一个操作失败了,它可能会组织创建新的对象,但是永远也不会使已有的对象保持在不一致的状态中,因为当每个对象被创建之后它就处于一致的状态中,以后不会再发生变化。
对于在可变对象上执行操作的方法,获得失败原子性最常见的办法是,在执行操作之前检查参数的有效性。这可以使得在对象的状态被修改之前,适当的异常首先被抛出来。
第三种获得失败原子性的办法没有那么常用,做法是编写一段恢复代码由它来解释操作过程中发生的失败,以及使对象回滚到操作开始之前的状态。这种办法主要用于永久性的数据结构。
最后一种获得失败原子性的办法是,在对象的一份临时拷贝上执行操作,当操作完成之后,再把临时拷贝中的结果复制给原来的对象。
(卌七)不要忽略异常
空的catch块会使异常达不到应有的目的,异常的目的是强迫你处理不正常的条件。至少catch块也应该包含一条说明,用来解释为什么忽略掉这个异常是合适的。