57 最小化局部变量的作用域
要使局部变量的作用域最小化,最好的方法是在首次使用的地方声明它。过早地声明局部变量可能导致其作用域不仅过早开始而且结束太晚。
每个局部变量声明都应该包含一个初始化表达式。这个规则的一个例外是try-catch语句。如果该值必须在try块之外使用,那么它必须在try块之前声明,此时它还不能被「合理地初始化」。
循环允许声明循环变量,将其作用域限制在需要它们的确切区域。如果循环终止后不需要循环变量的内容,那么优先选择for循环而不是while循环。
for (Element e : c) { ... // Do Something with e }
如果需要访问迭代器,也许是为了调用它的remove方法,首选的习惯用法,使用传统的for循环代替for-each循环:
for (Iterator<Element> i = c.iterator(); i.hasNext(); ) { Element e = i.next(); ... // Do something with e and i }
要了解为什么这些for循环优于while循环,考虑以下代码片段:
Iterator<Element> i = c.iterator(); while (i.hasNext()) { doSomething(i.next()); } ... Iterator<Element> i2 = c2.iterator(); while (i.hasNext()) { // BUG! doSomethingElse(i2.next()); }
第二个循环包含一个复制粘贴错误:它初始化一个新的循环变量i2,但是使用旧的变量i,不幸的是,它仍在作用域范围内。生成的代码编译时没有错误,并且在不抛出异常的情况下运行,但是它的逻辑已经错了。
如果将类似的复制粘贴错误与for循环(for-each循环或传统循环)结合使用,则生成的代码就无法编译。
for (Iterator<Element> i = c.iterator(); i.hasNext(); ) { Element e = i.next(); ... // Do something with e and i } ... // Compile-time error - cannot find symbol i for (Iterator<Element> i2 = c2.iterator(); i.hasNext(); ) { Element e2 = i2.next(); ... // Do something with e2 and i2 }
for循环比while循环还有一个优点:它更短,增强了可读性。
下面是另一种对局部变量的作用域最小化的循环做法:
for (int i = 0, n = expensiveComputation(); i < n; i++) { ... // Do something with i; }
它有两个循环变量,i和n,它们都具有完全相同的作用域。第二个变量n用于存储第一个变量的限定值,从而避免了每次迭代中冗余计算的代价。
最后一种“最小化局部变量作用域”的最终技术是保持方法小而集中。如果把两个操作(activities)合并到同一个方法中,与其中一个操作相关的局部变量就有可能会出现在执行另一个操作的代码范围之内。为了防止这种情况发生,只需将方法分为两个:每个操作用一个方法完成。
58 for-each循环优先于传统的for循环
下面是一个传统的for循环来遍历一个集合:
for (Iterator<Element> i = c.iterator(); i.hasNext(); ) { Element e = i.next(); ... // Do something with e }
下面是数组的传统for循环的实例:
for (int i = 0; i < a.length; i++) { ... // Do something with a[i] }
它们并不完美。迭代器和索引变量都很混乱,好在for-each循环解决了所有这些问题。它通过隐藏迭代器或索引变量来消除
混乱和出错的机会:
for (Element e : elements) { ... // Do something with e }
此外,使用for-each循环也不会降低性能
当涉及到嵌套迭代时,for-each循环相对于传统for循环的优势甚至更大。下面是人们在进行嵌套迭代时经
常犯的一个错误:
enum Suit { CLUB, DIAMOND, HEART, SPADE } enum Rank { ACE, DEUCE, THREE, FOUR, FIVE, SIX, SEVEN, EIGHT, NINE, TEN, JACK, QUEEN, KING } ... static Collection<Suit> suits = Arrays.asList(Suit.values()); static Collection<Rank> ranks = Arrays.asList(Rank.values()); List<Card> deck = new ArrayList<>(); for (Iterator<Suit> i = suits.iterator(); i.hasNext(); ) for (Iterator<Rank> j = ranks.iterator(); j.hasNext(); ) deck.add(new Card(i.next(), j.next()));
问题是,对于外部集合(suit),i.next会被调用很多次
下面的代码本意是要打印一对骰子的所有可能的组合:
enum Face { ONE, TWO, THREE, FOUR, FIVE, SIX } ... Collection<Face> faces = EnumSet.allOf(Face.class); for (Iterator<Face> i = faces.iterator(); i.hasNext(); ) for (Iterator<Face> j = faces.iterator(); j.hasNext(); ) System.out.println(i.next() + " " + j.next());
该程序不会抛出异常,但它只打印6个重复的组合(从“ONE ONE”到“SIX SIX”),而不是预期的36个组合。
要修复例子中的错误,必须在外部循环的作用域内添加一个变量来保存外部元素:
for (Iterator<Suit> i = suits.iterator(); i.hasNext(); ) { Suit suit = i.next(); for (Iterator<Rank> j = ranks.iterator(); j.hasNext(); ) deck.add(new Card(suit, j.next())); }
如果使用嵌套for-each循环,问题就会消失。生成的代码也尽可能地简洁:
for (Suit suit : suits) for (Rank rank : ranks) deck.add(new Card(suit, rank));
有三种常⻅的情况是不能分别使用for-each循环的:
- 解构过滤
如果需要遍历集合,并删除指定选元素,则需要使用显式迭代器,以便可以调用其remove方法。通常可以使用在Java 8中添加的Collection类中的removeIf方法,来避免显式遍历。
- 转换
如果需要遍历一个列表或数组并替换其元素的部分或全部值,那么需要迭代器或数组索引来替换元素的值。
- 并行迭代
如果需要并行地遍历多个集合,那么需要显式地控制迭代器或索引变量,以便所有迭代器或索引变量都可以同步进行
for-each循环还允许遍历实现Iterable接口的任何对象
public interface Iterable<E> { // Returns an iterator over the elements in this iterable Iterator<E> iterator(); }
59 了解并使用类库
假设你想要生成0到某个上界之间的随机整数。许多程序员会编写一个类似这样的小方法:
static Random rnd = new Random(); static int random(int n) { return Math.abs(rnd.nextInt()) % n; }
这个方法有三个缺点:
1. 如果n是较小的2的平方数,随机数序列会在短的时间内重复
2. 如果n不是2的幂,平均而言,一些数字将比其他数字更高概率返回
public static void main(String[] args) { int n = 2 * (Integer.MAX_VALUE / 3); int low = 0; for (int i = 0; i < 1000000; i++) if (random(n) < n/2) low++; System.out.println(low); }
如果运行它,你将发现它输出一个接近666666的数字。随机方法生成的数字中有三分之二落在其范围的前半部分
3. 在极少数情况下会返回超出指定范围的数字,这是灾难性的结果
如果nextInt()返回Integer.MIN_VALUE、Math.abs也会因为越界而返回Integer.MIN_VALUE。假设n不是2的幂,那么求模运算符 (%) 将返回一个负数。
幸运的是,已经有现成的成果可以直接使用:Random.nextInt(int)
通过使用标准库,你可以利用编写它的专家的知识和以前使用它的人的经验。
从Java 7开始,就不应该再使用Random,而是用ThreadLocalRandom,原因有以下几点:
它能产生更高质量的随机数,而且速度非常快
不必浪费时间为那些与你的工作无关的问题编写专⻔的解决方案
随着时间的推移,它们的性能会不断提高
随着时间的推移,它们往往会获得新功能
可以让自己的代码融入主流。这样的代码更容易被开发人员阅读、维护和重用
在每个主要版本中,都会向库中添加许多特性,了解这些新增特性是值得的
每个程序员都应该熟悉java.lang、java.util和java.io的基础知识及其子包
如果你在Java平台库中找不到你需要的东西,你的下一个选择应该是寻找高质量的第三方库,比如谷歌的优秀的开源Guava库
60 若需要精确答案就应避免使用float 和double 类型
float和double类型特别不适合进行货币计算,因为不可能将0.1(或10的任意负次幂)精确地表示为float或double。
例如,假设你口袋里有1.03美元,你消费了42美分。你还剩下多少钱?
System.out.println(1.03 - 0.42);
1
你可能认为,只需在打印之前将结果四舍五入就可以解决这个问题,但不幸的是,这种方法并不总是有效。例如,假设你口袋里有一美元,你看到一个架子上有一排好吃的糖果,它们的价格仅仅是10美分,20美分,30美分,以此类推,直到1美元。你每买一颗糖,从10美分的那颗开始,直到你买不起货架上的下一颗糖。
public static void main(String[] args) { double funds = 1.00; int itemsBought = 0; for (double price = 0.10; funds >= price; price += 0.10) { funds -= price; itemsBought++; } System.out.println(itemsBought +"items bought."); System.out.println("Change: $" + funds); }
如果你运行这个程序,你会发现你可以买得起三块糖,你还有0.399999999999999999美元。这是错误的
答案。解决这个问题的正确方法是使用BigDecimal、int或long进行货币计算。
这里是前一个程序的一个简单改版,使用BigDecimal类型代替double。注意,使用BigDecimal的String构造函数而不是它的double构造函数。这是为了避免在计算中引入不准确的值
public static void main(String[] args) { final BigDecimal TEN_CENTS = new BigDecimal(".10"); int itemsBought = 0; BigDecimal funds = new BigDecimal("1.00"); for (BigDecimal price = TEN_CENTS;funds.compareTo(price) >= 0;price = price.add(TEN_CENTS)) { funds = funds.subtract(price); itemsBought++; } System.out.println(itemsBought +"items bought."); System.out.println("Money left over: $" + funds); }
使用BigDecimal有两个缺点:
- 与原始算术类型相比很不方便
- 速度慢得多
除了使用BigDecimal,另一种方法是使用int或long,在这个例子中,最明显的方法是用美分而不是美元来计算
public static void main(String[] args) { int itemsBought = 0; int funds = 100; for (int price = 10; funds >= price; price += 10) { funds -= price; itemsBought++; } System.out.println(itemsBought +"items bought."); System.out.println("Cash left over: " + funds + " cents"); }
使用BigDecimal的另一个好处是,它可以完全控制舍入,当执行需要舍入的操作时,可以从8种舍入模式中进行选择。
61 基本类型优先于包装基本类型
自动装箱减少了使用包装类型的繁琐性,但没有减少它的风险。
Java 每个基本类型都有一个对应的引用类型,称为包装类型。与int、double和boolean对应的包装类是Integer、Double和Boolean。
基本类型和包装类型之间有三个主要区别:
两个包装类型实例可以具有相同的值,但这两个实例却是不一样的
包装类型可以是null
基本类型比包装类型更节省时间和空间
考虑下面的比较器,它的设计目的是表示Integer值上的升序数字排序。
Comparator<Integer> naturalOrder =(i, j) -> (i < j) ? -1 : (i == j ? 0 : 1);
这个比较器存在严重缺陷,对于
naturalOrder.compare(new Integer(42), new Integer(42))
两个Integer实例都表示相同的值(42),所以这个表达式的值应该是0,但它是1,这表明第一个Integer值大于第二个。
i==j表达式对两个对象引用执行比较。如果i和j引用表示相同int值的不同Integer实例,这个比较将返回false,所以将==操作符应用于包装类型几乎都是错误的。
可以通过添加两个局部变量来存储基本类型int值,并对这些变量执行所有的比较,从而修复比较器中的问题:
Comparator<Integer> naturalOrder = (iBoxed, jBoxed) -> { int i = iBoxed, j = jBoxed; // Auto-unboxing return i < j ? -1 : (i == j ? 0 : 1); };
接下来考虑另外一段代码:
public class Unbelievable { static Integer i; public static void main(String[] args) { if (i == 42) System.out.println("Unbelievable"); } }
它在计算表达式 i==42 时抛出NullPointerException。原因是在操作中混合使用基本类型和包装类型时,包装类型就会自动拆箱,如果一个空对象引用自动拆箱,那么你将得到一个NullPointerException。
修复这个问题非常简单,只需将i声明为int:
最后在考虑第6条里曾经出现过的代码:
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,变量被反复装箱和拆箱,导致产生明显的性能下降。
什么时候应该使用包装类型呢?
作为集合中的元素、键和值
不能将基本类型放在集合中,因此必须使用包装类型
在参数化类型和方法中,必须使用包装类型作为类型参数
例如,不能将变量声明为ThreadLocal类型,因此必须使用ThreadLocal
在进行反射方法调用时,必须使用包装类型
62 如果其他类型更合适,尽量避免使用字符串
本条目讨论了一些不应该使用字符串的场景:
1. 字符串不适合替代其他值类型
当一段数据从文件、网络或键盘输入到程序时,它通常是字符串形式的。但是这种倾向只有在数据本质上是文本的情况下才合理。
2. 字符串不适合替代枚举类型
如第34条,枚举类型比字符串更适合表示枚举类型的常量。
3. 字符串不适合替代聚合类型
如果一个实体有多个组件,将其表示为单个字符串通常是很不好的。例如:
String compoundKey = className + "#" + i.next();
这种方法有很多缺点。如果用于分隔字段的字符出现在其中一个字段中,可能会导致混乱。要访问各个字段,必须解析字符串,这是缓慢的、冗⻓的、容易出错的过程。
更好的方法是编写一个类来表示聚合,通常是一个私有静态成员类。
4. 字符串不能很好地替代capabilities
例如,考虑线程局部变量机制的设计。这样的机制提供的变量在每个线程中都有自己的值。许多年前,当面临设计这样一个机制的任务时,有人提出了相同的设计,其中客户端提供的字符串键,用于标识每个线程本地变量:
public class ThreadLocal { private ThreadLocal() { } // Noninstantiable // Sets the current thread's value for the named variable. public static void set(String key, Object value); // Returns the current thread's value for the named variable. public static Object get(String key); }
这种方法的问题在于,为了使这种方法有效,客户端提供的字符串键必须是惟一的:如果两个客户端各自决定为它们的线程本地变量使用相同的名称,它们无意中就会共享一个变量。
这个API可以通过用一个不可伪造的键(有时称为capability)替换字符串来修复:
public class ThreadLocal { private ThreadLocal() { } // Noninstantiable public static class Key { // (Capability) Key() { } } // Generates a unique, unforgeable key public static Key getKey() { return new Key(); } public static void set(Key key, Object value); public static Object get(Key key); }
虽然这解决了API中基于字符串的两个问题,但是你可以做得更好。
public final class ThreadLocal { public ThreadLocal(); public void set(Object value); public Object get(); }
通过将ThreadLocal类泛型化,使这个API变成类型安全的:
public final class ThreadLocal<T> { public ThreadLocal(); public void set(T value); public T get(); }