四十五、将局部变量的作用域最小化:
将局部变量的作用域最小化,可以增强代码的可读性和可维护性,并降低出错的可能性。在C语言中要求局部变量必须在一个代码块的开头处进行声明,出于习惯,有些开发者延续了这样的做法。这个习惯需要改正,Java提供了你在代码块的任何地方声明变量的语法支持。
"要使局部变量的作用域最小化,最有力的实践就是在第一次使用它的地方声明"。如果过早的声明,开发者就有可能在真正使用该变量的时候忘记了它的类型或者初始值了,而且也会带来代码块内变量名的名字污染问题,由此引发的Bug,往往是令人极为沮丧的。
"几乎每个局部变量的声明都应该包含一个初始化表达式"。如果你没有足够的信息来满足对一个变量进行有意义的初始化,就应该推迟这个声明,直到可以初始化为止。这条规则有个例外的情况与try-catch语句有关。如果一个变量被一个方法初始化,而这个方法可能会抛出一个异常,该变量就必须在try块内初始化,如果这个变量的值也必须在try块之外被访问,它就必须在try块之前被声明,但是遗憾的是在try块之前,它还不能被"有意义地初始化"。
循环中提供了特殊的机会将变量的作用域最小化,它们的作用域正好被限定在需要的范围之内。因此,如果在循环终止之后不再需要变量的内容,for循环就优先于while循环,见如下代码片段:
1 Iterator<Element> i = c.iterator();
2 while (i.hasNext()) {
3 doSomething(i.next());
4 }
5 ... ...
6 Iterator<Element> i2 = c2.iterator();
7 while (i.hasNext()) { //BUG!
8 doSomethingElse(i2.next());
9 }
可以看到在第二个循环的循环条件判断处有一个非常明显的BUG,这极有可能是copy-paste所致。然而该类错误如果出现在for循环里,将直接引发编译期错误。见如下代码片段:
1 for (Iterator<Element> i = c.iterator(); i.hasNext(); ) {
2 doSomething(i.next());
3 }
4 ... ...
5 for (Iterator<Element> i2 = c2.iterator(); i.hasNext(); ) {
6 doSomethingElse(i2.next());
7 }
而且,如果使用for循环,犯这种copy-paste错误的可能性大大降低,因为通常没有必要在两个循环中使用不同的变量名。循环是完全独立的,所以重用元素(或者迭代器)变量的名称不会有任何危害。实际上,这也是很流行的做法。
四十六、for-each循环优先于传统的for循环:
for-each循环是在Java 1.5 发行版本之后才支持的,之前只能使用传统的for循环。相比于普通for循环,for-each大大提高了代码可读性,由此也减少了低级BUG出现的几率。见如下代码片段:
1 enum Suit { CLUB,DIAMOND,HEART,SPADE }
2 enum Rank { ACE,DEUCE,THREE,FOUR,FIVE,SIX,SEVEN,EIGHT,NINE,TEN,JACK,QUEEN,KING }
3 ... ...
4 Collection<Suit> suits = Arrays.asList(Suit.values());
5 Collection<Rank> ranks = Arrays.asList(Rank.values());
6 List<Card> deck = new ArrayList<Card>();
7 for (Iterator<Suit> i = suits.iterator(); i.hasNext(); } {
8 for (Iterator<Rank> j = ranks.iterator(); j.hasNext(); )
9 deck.add(new Card(i.next(),j.next()); //BUG, j被多次迭代
10 }
上面代码的BUG是比较隐匿的,很多专家级的程序员也会偶尔犯类似的错误。下面我们来一下修复后的代码片段:
1 ... ...
2 for (Iterator<Suit> i = suits.iterator(); i.hasNext(); } {
3 Suit suit = i.next();
4 for (Iterator<Rank> j = ranks.iterator(); j.hasNext(); )
5 deck.add(new Card(suit,j.next()); //BUG, j被多次迭代
6 }
我们下面再来看一下用for-each循环来实现该逻辑的代码片段:
1 ... ...
2 for (Suit suit : suits) {
3 for (Rank rank : Ranks)
4 deck.add(new Card(suit,rank));
5 }
总之,for-each循环的简洁性和预防Bug方面有着传统for循环无法比拟的优势,并且没有性能损失。应该尽可能地使用for-each循环。遗憾的是,有三种常见的情况无法使用for-each循环:
1. 过滤:如果需要遍历集合,并删除选定的元素,就需要使用显式的迭代器,以便可以调用它的remove方法。
2. 转换:如果需要遍历列表或数组,并取代它部分或者全部的元素值,就需要列表迭代器或者数组索引,以便设定元素的值。
3. 并行迭代:如果需要并行的遍历多个集合,就需要显式的控制迭代器或者索引变量,以便所有迭代器或者索引变量都可以得到同步前移。
四十八:如果需要精确的答案,请避免使用float和double:
float和double类型主要是为了科学计算和工程计算而设计的。它们执行二进制浮点运算,这是为了在广泛的数值范围上提供较精确的快速近似计算而精心设计的。然而,它们并没有提供完全精确的结果,所以不应该被用于需要精确结果的场合,如货币计算等。
该条目给出一个例子,如果你手里有1美元,超市货架上有一排糖果,它们的售价分别为10美分、20美分、30美分,以此类推直到1美元。你打算从标价10美分的开始买,每个糖果买1颗,直到不能支付货架上下一中价格的糖果为止,那么你可以买多少糖果?还会找回多少零头呢?见如下代码:
1 public static void main(String[] args) {
2 double funds = 1.00;
3 int itemsBought = 0;
4 for (double price = .10; funds >= price; price += .10) {
5 funds -= price;
6 itemsBought++;
7 }
8 System.out.println(itemsBought + " items bought.");
9 System.out.println("Change: $" + funds);
10 }
11 // 3 items bought.
12 // Change: $0.39999999999999
很显然,如果我们用手工计算的话是不会得到该结果的,造成这一结果的主要原因就是double类型的精度问题。解决该问题的正确办法是使用BigDecimal、int或者long进行货币计算。下面我们看一下该程序用BigDecimal实现的翻版。
1 public static void main(String[] args) {
2 final BigDecimal TEN_CENTS = new BigDecimal(".10");
3 int itemsBought = 0;
4 BigDecimal funds = new BigDecimal("1.00");
5 for (BigDecimal price = TEN_CENTS; funds.compareTo(price) >= 0;price.add(TEN_CENTS)) {
6 itemsBought++;
7 funds = funds.substract(price);
8 }
9 System.out.println(itemsBought + " items bought.");
10 System.out.println("Money left over: $" + funds);
11 }
12 // 4 items bought.
13 // Money left over: $0.00
现在我们得到了正确的结果。然而,使用BigDecimal有两个主要缺点:和使用基本运算类型相比,这样做很不方便,而且效率也低。除了该方法之外我们还可以使用int或者long,至于使用哪种具体类型,需要视所涉及的数值大小而定。现在我们需要将计算单位转换为分,而不再是以元为单位,下面是这个例子的又一次翻版。
1 public static void main(String[] args) {
2 int itemsBougth = 0;
3 int funds = 100;
4 for (int price = 0; funds >= price; price += 10) {
5 itemsBought++;
6 fund -= price;
7 }
8 System.out.println(itemsBought + " items bought.");
9 System.out.println("Money left over: $" + funds + " cents.");
10 }
11 // 4 items bought.
12 // Money left over: $0.00 cents.
使用int和long代替BigDecimal之后,该段代码的执行效率大大提升。需要指出的是,如果数值所涉及的范围没有超过9位十进制数字,就可以使用int,没有超过18位可以使用long,一旦超过,则必须使用BigDecimal。
四十九、基本类型优先于基本装箱类型:
Java的类型系统中主要包含两个部分,分别是基本类型,如int、double、long,还有就是引用类型,如String、List等。其中每个基本类型都对应着一种引用类型,被称为装箱基本类型,如分别和int、double、long对应的装箱类型Integer、Double和Long等。
Java在1.5 中新增了自动装箱的和自动拆箱的功能。这些特性仅仅是模糊了基本类型和装箱类型之间的区别,但是并没有完全消除他们之间的差异,而这些差别往往会给我们的程序带来一些潜在的问题。我们先看一下他们之间的主要区别:
1. 基本类型只有值,在进行比较时可以直接基于值进行比较,而装箱类型在进行同一性比较时和基本类型相比有着不同的逻辑,毕竟他们是对象,是Object的子类,它们需要遵守Java中类对象比较的默认规则。
2. 基本类型只有功能完备的值,而每个装箱类型除了它对应基本类型的所有功能之外,还有一个非功能值:null。记住,它毕竟是对象。
3. 基本类型通常比装箱类型更节省时间和空间。
见如下代码示例:
1 public class MyTest {
2 private static int compare(Integer first,Integer second) {
3 return first < second ? -1 : (first == second ? 0 : 1);
4 }
5 public static void main(String[] args) {
6 Integer first = new Integer(42);
7 Integer second = new Integer(42);
8 System.out.println("Result of compare first and second is " + compare(first,second));
9 }
10 }
这段代码看起来非常简单,它的运行结果也非常容易得出,然而当我们真正运行它的时候却发现,实际输出的结果和我们的期望是完全不同的,这是为什么呢?见如下分析:
1. compare方法中的第一次比较(first < second)将能够正常工作并得到正确的结果,即first < second为false;
2. 在进行相等性比较的时候问题出现了,如前所述,Integer毕竟是对象,在进行对象之间的同一性比较时它将遵守对象的同一性比较规则,由于这两个参数对象的地址是不同的,因为我们是通过两次不同的new方法构建出的这两个参数对象。结果可想而知,first == second返回false;
3. 现在最后的输出结果已经很清楚了:Result of compare first and second is 1
下面我们看一下如何修正以上代码中存在的错误:
1 public class MyTest {
2 private static int compare(Integer first,Integer second) {
3 int f = first;
4 int s = second;
5 return f < s ? -1 : (f == s ? 0 : 1);
6 }
7 public static void main(String[] args) {
8 Integer first = new Integer(42);
9 Integer second = new Integer(42);
10 System.out.println("Result of compare first and second is " + compare(first,second));
11 }
12 }
我们使用两个临时的基本类型变量来代替装箱类型的参数变量,然后再基于基本类型变量进行之前代码中的比较。在运行这段代码之后,我们发现确实得到了期望的结果。
现在让我们再看一段代码片段:
1 public class Unbelievable {
2 static Integer i;
3 public static void main(String[] args) {
4 if (i == 42)
5 System.out.println("Unbelievable");
6 }
7 }
程序的运行结果并没有打印出"Unbelievable",而是抛出了空指针异常。这是因为装箱类型的i变量并没有被初始化,即它本身为null,当程序计算表达式(i == 42)时,它会将Integer与int进行比较。几乎在任何一种情况下,当在一项操作中混合使用基本类型和装箱基本类型时,装箱类型就会自动拆箱,这种情况无一例外。如果null对象引用被自动拆箱,就会得到一个NullPointerException。修正这一问题也非常简单,只需将i的类型从Integer变为int即可。
在看一下最后一个代码示例:
1 public static void main(String[] args) {
2 Long sum = 0L;
3 for (long i = 0; i < Integer.MAX_VALUE; ++i) {
4 sum += i;
5 }
6 System.out.println(sum);
7 }
这段代码虽然不像之前的两个示例那样有着明显的Bug,然而在运行时却存在着明显的性能问题。因为在执行for循环时,会有不断的自动装箱和自动拆箱的操作发生。修改该代码也是非常容易的,只需将sum的类型从Long变为long即可。
该条目的最后介绍了在以下两种情况下我们将需要使用装箱基本类型:
1. 由于Java泛型中的类型参数不能为基本类型,因此在需要使用基本类型作为类型参数时,我们只能将其替换为与之对应的装箱类型。
2. 在使用反射进行方法调用时。
五十一、当心字符串连接的性能:
字符串连接操作(+)是把多个字符串合并为一个字符串的最为便利的途径。因此如果仅仅是对两个较小字符串进行一次连接并输出连接结果,这样是比较合适的。然而如果是为n个字符串而重复地使用字符串连接操作符,则需要n的平方级的时间。这是由于字符串对象本身是不可变的,在连接两个字符串时,需要copy两个连接字符串的内容并形成新的连接后的字符串。见如下代码:
1 public String statement() {
2 String result = "";
3 for (int i = 0; i < numItems(); i++) {
4 result += lineForItem(i);
5 }
6 return result;
7 }
此时如果项目数量巨大,这个方法的执行时间将难以估量。为了获得可以接受的性能,请使用StringBuilder替代String,见如下修正后的代码:
1 public String statement() {
2 StringBuilder b = new StringBuilder(numItems * LINE_WIDTH);
3 for (int i = 0; i < numItems(); i++)
4 b.append(lineForItem(i));
5 return b.toString();
6 }
上述两种做法在性能上的差异是巨大的,如果numItems()返回100,而lineForItem返回一个固定长度为80的字符串,后者将比前者块85倍。由于第一种做法的开销是随项目数量呈平方级增加,而第二种做法是线性增加的,所以数目越大,差异越大。
五十二、通过接口引用对象:
一般来讲,在函数参数、返回值、域变量等声明中,应该尽量使用接口而不是类作为它们的类型。只有当你利用构造器创建某个对象的时候,才真正需要引用这个对象的类,如:
List<Subscriber> subscribers = new Vector<Subscriber>();
而不是像下面这样的声明:
Vector<Subscriber> subscribers = new Vector<Subscriber>();
如果你养成了用接口作为类型的习惯,你的程序将更加灵活。对于上面的例子,在今后的改进中,如果不想使用Vector作为实例化对象,我们只需在如下一出进行修改即可:
List<Subscriber> subscribers = new ArrayList<Subscriber>();
如果之前该变量的类型不是接口类型,而是它实际类型的本身,那么在做如此修改之前,则需要确认在所有使用该变量的代码行是否用到了Vector的特性,从而导致不行直接进行替换。如果该变量的接口为接口,我们将不受此问题的限制。
那么在哪些情况下不是使用接口而是使用实际类呢?见如下情况:
1. 没有合适的接口存在,如String和BigInteger等值对象,通常它们都是final的,也没有提供任何接口。
2. 对象属于一个框架,而框架的基本类型是类,不是接口。如果对象属于这种基于类的框架,就应使用基类来引用该对象,如TimerTask。
3. 类实现了接口,但是它提供了接口中不存在的额外方法。如果程序此时依赖于这些额外的方法,这种类就应该只被用来引用他的实例。
简而言之,如果类实现了接口,就应该尽量使用其接口引用该类的引用对象,这样可以使程序更加灵活,如果不是,则使用类层次结构中提供了必要功能的最基础的类。
五十三、接口优先于反射机制:
Java中提供了反射的机制,如给定一个Class实例,你可以获取Constructor、Method和Field等实例,分别代表了该Class实例所表示的类的Constructor(构造器)、Method(方法)和Field(域)。与此同时,这些实例可以使你通过反射机制操作它们的底层对等体。然后这种灵活是需要付出一定代价的,如下:
1. 丧失了编译时类型检查的好处,包括异常检查和类型检查等。
2. 执行反射访问所需要的代码往往非常笨拙和冗长,阅读起来也非常困难,通常而言,一个基于普通方式的函数调用大约1,2行,而基于反射方式,则可能需要十几行。
3. 性能损失,反射方法的调用比普通方法调用慢了许多。
核心反射机制最初是为了基于组件的应用创建工具而设计的。它们通常需要动态装载类,并且用反射功能找出它们支持哪些方法和构造器,如类浏览器、对象监视器、代码分析工具、解释性的嵌入式系统等。
在通常情况下,如果只是以非常有限的形式使用反射机制,虽然也要付出少许代价,但是可以获得许多好处。对于有些程序,它们必须用到编译时无法获取的类,但是在编译时却存在适当的接口或超类,通过它们可以引用这个类。如果是这样,可以先通过反射创建实例,然后再通过它们的接口或超类,以正常的方式访问这些实例。见如下代码片段:
1 public static void main(String[] args) {
2 Class<?> cl = null;
3 try {
4 c1 = Class.forName(args[0]);
5 } catch (ClassNotFoundException e) {
6 System.err.println("Class not found.");
7 System.exit(1);
8 }
9 Set<String> s = null;
10 try {
11 s = (Set<String>)c1.newInstance();
12 } catch (IllegalAccessException e) {
13 System.err.println("Class not accessible");
14 System.exit(1);
15 } catch (InstantiationException e) {
16 System.err.println("Class not instantiation.");
17 System.exit(1);
18 }
19 s.addAll(Arrays.asList(args).subList(1,args.length));
20 System.out.println(s);
21 }
上面的代码中体现出了反射的两个缺点:
1. 这个例子有3个运行时异常的错误,如果不使用反射方式实例化,这3个错误都会成为编译时错误。
2. 根据类名生成它的实例需要20行冗长的代码,而调用构造器可以非常简洁的只使用一行代码。
简而言之,反射机制是一种功能强大的机制,对于特定的复杂系统编程任务,它是非常必要的。如果你编写的程序必须要与编译时未知的类一起工作,如有可能,就应该仅仅使用反射机制来实例化对象,而访问对象时则使用编译时已知的某个接口或者超类。
五十四、谨慎地使用本地方法:
JNI允许Java应用程序可以调用本地方法,所谓本地方法是指用本地程序设计语言,如C/C++来编写的特殊方法。本地方法在本地语言中可以执行任意的计算任务,并最终返回Java程序。它的主要用途就是访问一些本地的资源,如注册表、文件锁等,或者是访问遗留代码中的一些遗留数据。当然通过本地方法在有些应用场景中是可以大大提高提高系统执行效率的。
随着Java平台的不断成熟,它提供了越来越多以前只有宿主平台才有的特性,如java.util.prefs和java.awt.SystemTray等。与此同时,随着JVM的不断优化,其效率也在不断的提高,因此只有在很少的情况下才会考虑使用JNI。还需要指出的是,JNI中胶合Java和C++的代码部分非常冗长且难以理解。