七、通用程序设计
1. 将局部变量的作用域最小化
同“使类和成员的可访问性最小化”一样,将局部变量的作用域最小化,可以增强代码的可读性和可维护性,并降低出错的可能性。
Java允许在任何可以出现语句的地方声明变量,所以,最好的方法就是在第一次使用它的地方声明并初始化。
循环中提供了特殊的机会来将变量的作用域最小化,它们的作用域被限定在正好需要的范围之内。如果在循环终止之后不再需要循环变量的内容,for循环就优先于while循环。
for (Element e : c) {
doSomething(e);
}
for (Iterator i = c.iterator(); i.hasNext(); ) {
doSomething(i.next());
}
//对比之下,for/for-each循环要优于while循环
Iterator<Element> i = c.iterator();
while(i.hasNext()) {
doSomething(i.next());
}
另外,与while循环相比,for循环更简短,可读性更强。
还有种“使局部变量作用域最小化”的方法是:使方法小而集中。方法小而功能集中,就可以减少变量的个数,方法中的局部变量仅和当前功能有关。
2. for-each循环优先于传统的for循环
同传统的for循环相比,for-each循环可以隐藏迭代器或者索引变量,避免了混乱和出错的可能。
for (Iterator i = c.iterator(); i.hasNext(); ) {
doSomething((Element) i.next);
}
for (int i = 0; i < a.length; i++) {
doSomething(a[i]);
}
//隐藏了迭代器or索引变量
for (Element e : elements) {
doSomething(e);
}
在对多个集合进行嵌套式迭代时,for-each循环相对于传统for循环的这种优势更加明显。
有三种常见的情况无法使用for-each循环:
(1)过滤——如果需要遍历集合,并删除选中的元素,就需要使用显式的迭代器,以便可以调用它的remove方法。
(2)转换——如果需要遍历列表或者数组,并取代它部分或者全部的元素值,就需要列表迭代器或者数组索引,以便设定元素的值。
(3)平行迭代——如果需要平行的遍历多个集合,就需要显式地控制迭代器或者索引变量,以便所有迭代器或者索引变量都可以得到同步前移。
3. 了解和使用类库
使用标准库(类库)的好处有:
- 使用标准类库,可以充分利用这些编写标准库的专家的知识,以及其他人的使用经验。
- 不必浪费时间为那些与工作不太相关的问题提供特别的解决方案,把时间花在应用程序上,而不是底层的细节上。
- 标准库的性能往往会随着时间的推移而不断提高。
- 可以使自己的代码融入主流,更易读、易维护、更容易被其他开发人员重用。
4. 如果需要精确的答案,请避免使用float和double
float和double类型主要是为了科学计算和工程计算而设计的。它们执行二进制浮点计算,是为了在广泛的数值范围上提供较为精确的快速近似计算而精心设计的,它们并没有提供完全精确的结果,不应该被用于需要精确结果的场合。尤其不适合用于货币计算。应使用BigDecimal(高精度小数)、int 后者 long 进行货币计算。
5. 基本类型优先于装箱基本类型
每个基本类型都有一个对应的引用类型,称作装箱基本类型。Java1.5增加了自动装箱和自动拆箱。这些特性模糊了基本类型和装箱基本类型的区别,但它们还是有差别的。
基本类型和装箱基本类型之间有三个主要的区别:
(1)基本类型只有值,装箱基本类型则具有与它们的值不同的同一性(两个装箱基本类型可以具有相同的值和不同的同一性)。
(2)基本类型只有功能完备的值,而每个装箱基本类型除了它对应基本类型的所有功能值之外,还有个非功能值:null。
(3)基本类型通常比装箱基本类型更节省时间和空间。
public class Unbelievable {
static Integer i;
public static void main(String[] args) {
if (i == 42) {
System.out.println("Unbelievable");
}
}
}
上述程序在计算i == 42的时候将抛出NullPointerException异常。因为i被声明为基本装箱类型,它的初始值默认是null,在计算i == 42时将执行自动拆箱,null引用被自动拆箱就会抛出NullPointerException异常。
当在一项操作中混合使用基本类型和装箱基本类型时,装箱基本类型就会自动拆箱。
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。程序虽可正常执行,但变量被反复地装箱和拆箱,导致明显的性能下降。
装箱基本类型的用途有:
- 作为集合中的元素、键、值
- 在参数化类型中,必须使用装箱基本类型作为类型参数
- 在进行反射的方法调用时,必须使用装箱基本类型
6. 如果其他类型更合适,则尽量避免使用字符串
不应该使用字符串的情形有:
(1)字符串不适合代替其他的值类型
当一段数据从文件、网络、键盘设备进入到程序之后,它通常以字符串的形式存在,自然的倾向是让它继续保留这种形式,但这是很不恰当的。它应该转化为同应用情景更匹配的类型。如果它是数值,就应该被转换成适当的数值类型,比如int、float或者BigInteger类型。如果它是一个“是-或-否”这种问题的答案,就应该转换为boolean类型。
(2) 字符串不适合代替枚举类型
枚举类型比字符串更加适合用来表示枚举类型的常量。
(3)字符串不适合代替聚集类型
如果一个实体有多个组件,用一个字符串来表示这个实体通常是很不恰当的。
String compoundKey = className + "#" + i.next();
这种方法有很多缺点。如果用来分割域的字符也出现在某个域中,结果就会出现混乱。为了访问单独的域,必须解析该字符串,这个过程非常慢,也很繁琐,还容易出错。更好的做法是,简单地编写一个类来描述这个数据集,通常是一个私有的静态成员类。
(4)字符串不适合代替能力表(capabilities)
有时候,字符串被用作某种功能进行授权访问。例如,考虑设计一个提供线程局部变量的机制。这个机制提供的变量在每个线程中都有自己的值。
public class ThreadLocal {
private ThreadLocal() { }
public static void set(String key, Object value);
public static Object get(String key);
}
这种方法的问题在于,这些字符串键代表了一个共享的全局命名空间。要使这种方法可行,客户端提供的字符串键必须是唯一的。如果两个客户端各自决定为他们的线程局部变量使用同样的名称,它们实际上就无意中共享了这个变量,这样往往会导致两个客户端都失败。而且安全性也很差。恶意的客户端可能有意地使用与另一个客户端相同的键,以便非法地访问其他客户端的数据。
要解决这个问题,只要用一个不可伪造的键(有时被称为能力)来代替字符串即可。
public class ThreadLocal {
private ThreadLocal() { }
public static void set(Key key, Object value);
public static Object get(Key key);
public static Key getKey() {
return new Key();
}
public static class Key {
Key() { }
}
}
虽然这种方法解决了问题,但仍然可以做得更好。实际上这里不再需要静态方法了,它们可以被取代为以键(Key)的实例方法,这样这个键不再是键,而是线程局部变量了。
public final class ThreadLocal {
public ThreadLocal() { }
public void set(Object value);
public Object get();
}
当然它不是线程安全的,因为当从线程局部变量得到它时,必须将Object转换成它实际的值。改进的方法是将ThreadLocal类泛型化。最终这个ThreadLocal类正是java.util.ThreadLocal提供的API,与之前的基于键的API相比,它更快速、更优雅。
public final class ThreadLocal<T> {
public ThreadLocal() { }
public void set(T value);
public T get();
}
7. 当心字符串连接的性能
字符串连接操作符(+)是把多个字符串合并为一个字符串的便利途径。要产生单独一行的输出,或构造一个字符串来表示一个较小的、大小固定的对象,使用连接操作符是非常适合的。字符串连接符不适合运用于大规模的场景中,因为字符串是不可变的,当两个字符串被连接在一起时,它们的内容都要被拷贝,会对性能带来较大的影响。
为了获得可以接受的性能,需使用StringBuilder替代String。
public String statement() {
String result = "";
for (int i = 0; i < numItems(); i++) {
result += lineForItem(i);
}
return result;
}
//使用StringBuilder优化性能
public String statement() {
StringBuilder b = new StringBuilder(numItems() * LINE_WIDTH);
for (int i = 0; i < numItems(); i++) {
b.append(lineForItem(i));
}
return b.toString();
}
8. 通过接口引用对象
优先使用接口而不是类来引用对象。
如果有合适的接口类型存在,对于参数、返回值、变量、域,就都应该使用接口类型进行声明。只有当利用构造器创建某个对象的时候,才真正需要引用这个对象的类。
List<Subscriber> subscribers = new Vector<Subscriber>();
//用类作为类型进行声明(不建议)
Vector<Subscriber> subscribers = new Vector<Subscriber>();
面向接口编程是面向对象编程的一个很重要的设计原则,它使得程序更加灵活,耦合性更低。
不适用接口引用对象的三种情形为:
(1)没有合适的接口存在,如值对象String、BigInteger。
(2)对象属于一个框架,而框架的基本类型是类,而不是接口。这时候就应该用相关的基类来引用对象,而不是实现类。如java.util.TimerTask。
(3)类实现了接口,但是它提供了接口中不存在的额外方法,如果程序依赖于这些额外的方法,这种类就应该只被用来引用它的实例。如LinkedHashMap。
9. 接口优先于反射机制
核心反射机制java.lang.reflect提供了通过程序来访问已装载类的信息的能力。给定一个Class实例,你可以获得Constructor、Method和Field实例,分别代表了该Class类所表示的类的构造器、方法、域。这些对象提供了通过程序来访问类的成员名称、域类型、方法签名等信息的能力。
Constructor、Method和Field实例使你能够通过反射机制操作它们的底层对等体:通过调用Constructor、Method、Field实例上的方法,可以构造底层类的实例、调用底层类的方法,访问底层类中的域。
反射功能很强大,但也会带来一些负面的影响:
- 丧失了编译时类型检查的好处
- 执行反射访问所需要的代码非常笨拙和冗长
- 性能损失
如果只是以非常有限的形式使用反射机制,虽然也要付出少许代价,但是可以获得许多好处。对于有些程序,它们必须用到在编译时无法获取的类,但是在编译时存在适当的接口或者超类,通过它们可以引用这个类。若是这种情况,就可以以反射方式创建实例,然后通过它们的接口或者超类,以正常的方式访问这些实例。
public class InterfaceReference {
public static void main(String[] args) {
Class<?> cl = null;
try {
cl = Class.forName("java.util.HashSet");
} catch (ClassNotFoundException e) {
System.out.println("class not found");
System.exit(1);
}
Set<String> set = null;
try {
set = (Set<String>) cl.newInstance();
} catch (InstantiationException e) {
System.out.println("class not instantiable.");
System.exit(1);
} catch (IllegalAccessException e) {
System.out.println("class not accessible");
System.exit(1);
}
//以接口的方式访问实例
set.addAll(Arrays.asList("Java", "Kotlin", "Python"));
System.out.println(set);
}
}
反射机制是一种功能强大的机制,对于特定的复杂系统编程任务,它是非常必要的,但也有一些缺点。如果你编写的程序必须要与编译时未知的类一起工作,如有可能,就应该仅仅使用反射机制来实例化对象,而访问对象时则使用编译时已知的某个接口或超类。
10. 谨慎地使用本地方法
Java Native Interface(JNI)允许Java程序可以调用本地方法。所谓本地方法是指用本地程序设计语言(C或者C++)来编写的特殊方法。
本地方法主要有三种用途:
(1)提供了“访问特定平台”的能力,比如访问注册表和文件锁。
(2)提供了访问遗留代码库的能力,从而可以访问遗留数据。
(3)通过本地语言,编写应用程序中注重性能的部分,从而提高系统的性能。
使用本地方法来访问特定平台的机制是合法的;使用本地方法来访问遗留代码也是合法的;使用本地方法来提高性能的做法不值得提倡,因为JVM的优化功能已经做的很好了。
使用本地方法有一些严重的缺点:
- 本地语言是不安全的,使用本地方法有内存毁坏的风险。
- 本地语言是与平台相关的,程序不能再自由移植。
- 使用本地方法的程序更难调试。
- 需要“胶合代码”的本地方法编写起来单调乏味且难以阅读。
11. 谨慎地进行优化
优化的弊大于利,特别是不成熟的优化。在优化的过程中,产生的软件可能既不快速,也不正确,而且还不容易修正。
不能因为性能而牺牲合理的结构。要努力编写好的程序而不是快的程序。如果好的程序不够快,它的结构使它可以得到优化。好的程序体现了信息隐藏的原则:只要有可能,就把设计决策集中在单个模块中,可以改变单个决策,而不会影响到系统的其他部分。
在设计过程中必须要考虑性能问题,因为实现上的问题可以通过后期的优化而得到修正,但遍布全局且限制性能的结构缺陷几乎是不可能被改正的。
设计的过程中要努力避免那些限制性能的设计决策,还要考虑API设计决策的性能后果。
12. 遵守普遍接受的命名惯例
通常,命名惯例分为两大类:字面的和语法的。
字面命名惯例:
包名称应该是层次状的,用“.”分割每个部分,每个部分包括小写字母。
类和接口的名称(包括枚举和注解)的名称,都应该包括一个或多个单词,每个单词首字母大写,如Timer、TimerTask,应该避免使用缩写,除非是一些首字母缩写和一些通用的缩写,如max和min。
方法和域的名称与类和接口的名称一样,都遵守相同的字面惯例,只不过方法和域的名称的第一个字母应该小写如remove、ensureCapacity。
局部变量名称的字面惯例与成员名称类似,只不过它允许缩写,单个字符和短字符序列的意义取决于局部变量所在的上下文环境。如i、xref、houseNumber。
类型参数名称通常由单个字母组成,这个字母通常是以下五种类型之一:
- T表示任意的类型
- E表示集合的元素类型
- K和V表示映射的键和值类型
- X表示异常
- 任何类型序列可以是T、U、V或者T1、T2、T3
语法命名惯例:
包没有语法命名惯例。
类通常用一个名词或者名词短语命名,如Timer、BufferedWriter、ChessPiece。
接口的命名与类相似,如Collection、Comparator、或者用一个以-able或-ible结尾的形容词来命名,如Runnable、Iterable、Accessible。
执行某个动作的方法通常用动词或者动词短语来命名,如append、drawImage。
返回boolean值的方法通常以is开头,后面跟名词或名词短语,或者任何具有形容词功能的单词或短语,如isDigit、isEmpty、isEnabled。
返回一个非boolean值的方法通常用名词、名词短语、或者get开头的动词短语来命名,如size、hashCode、getTime。如果方法所在的类是个Bean,就要强制使用以get开头的形式。
有些方法名称值得专门提及:
- 转换对象类型的方法、返回不同类型的独立对象的方法,通常被称为toType,如toString、toArray。
- 返回视图的方法通常被称为asType,如asList。
- 返回一个与被调用对象同值的基本类型方法,通常被称为typeValue,如intValue。
- 静态工厂常命名为valueOf、of、getInstance、newInstance、getType、newType。
应该把标准的命名惯例当作一种内在的机制来看待,并且学着用它们作为第二特性。
命名是极为重要的,好的命名本身具有自注释的功能,可以大大的提高代码的可读性。很多经典书籍中都有介绍对程序元素命名的技巧,可以参考学习。
这类书籍有《编写可读代码的艺术》 、《代码整洁之道》、《实现模式》、《重构》等。