> 文章列表 > 5天带你读完《Effective Java》(四)

5天带你读完《Effective Java》(四)

5天带你读完《Effective Java》(四)

《Effective Java》是Java开发领域无可争议的经典之作,连Java之父James Gosling都说: “如果说我需要一本Java编程的书,那就是它了”。它为Java程序员提供了90个富有价值的编程准则,适合对Java开发有一定经验想要继续深入的程序员。

本系列文章便是这本著作的精华浓缩,通过阅读,读者可以在5天时间内快速掌握书中要点。为了方便读者理解,笔者用通俗易懂的语言对全书做了重新阐述,避免了如翻译版本中生硬难懂的直译,同时对原作讲得不够详细的地方做了进一步解释和引证。

本文是系列的第四部分,包含对第八、九章的20个准则的解读,约2.4万字。

目录

    • 第八章 方法
      • 第49条 检查参数的有效性
      • 第50条 在需要时制作防御性拷贝
      • 第51条 仔细设计方法签名
      • 第52条 明智地使用重载
      • 第53条 明智地使用可变参数
      • 第54条 返回空集合或数组,而不是 null
      • 第55条 明智地的返回 Optional
      • 第56条 为所有公开的 API 元素编写文档注释
    • 第九章 通用程序设计
      • 第57条 将局部变量的作用域最小化
      • 第58条 for-each 循环优于传统的 for 循环
      • 第59条 了解并使用库
      • 第60条 若需要精确答案就应避免使用 float 和 double 类型
      • 第61条 基本数据类型优于包装类
      • 第62条 其他类型更合适时应避免使用字符串
      • 第63条 当心字符串连接引起的性能问题
      • 第64条 通过接口引用对象
      • 第65条 接口优于反射
      • 第66条 明智地使用本地方法
      • 第67条 明智地进行优化
      • 第68条 遵守被广泛认可的命名约定

第八章 方法

Chapter 8. Methods

第49条 检查参数的有效性

Item 49: Check parameters for validity

每次编写方法或构造函数时,都应该考虑参数存在哪些限制,并在文档中记录下来,然后在方法的开头显式地检查。

如果没有在方法开头就验证参数,可能会违反故障原子性。因为方法可能会在执行过程中出现让人困惑的异常而失败,或者计算出错误的结果然后返回,甚至可能埋藏隐患,导致将来在不确定的某处代码产生错误。

对于公共方法和受保护的方法,使用@throws 标签记录违反参数限制会引发的异常,例如 IllegalArgumentException、IndexOutOfBoundsException 或 NullPointerException。见下面例子:

/**
* Returns a BigInteger whose value is (this mod m). This method
* differs from the remainder method in that it always returns a
* non-negative BigInteger.
**
@param m the modulus, which must be positive
* @return this mod m
* @throws ArithmeticException if m is less than or equal to 0
*/
public BigInteger mod(BigInteger m) {if (m.signum() <= 0)throw new ArithmeticException("Modulus <= 0: " + m);... // Do the computation
}

这里并没有记录m为null导致的NullPointerException,因为它被记录在类级别的文档注释中,这样不用在每个方法上再单独记录。

在 Java 7 中添加的 Objects.requireNonNull 方法非常灵活和方便,它可以检查输入对象是否为null,如果是,那么抛出带指定消息的NullPointerException;否则返回输入对象:

// Inline use of Java's null-checking facility
this.strategy = Objects.requireNonNull(strategy, "strategy");

对于私有方法,作者应该确保只传递有效的参数值。因此,私有方法可以使用断言检查参数,如下所示:

// Private helper function for a recursive sort
private static void sort(long a[], int offset, int length) {assert a != null;assert offset >= 0 && offset <= a.length;assert length >= 0 && length <= a.length - offset;... // Do the computation
}

如果断言没有启用,那么不存在成本。你可以通过将 -ea标志传递给 java 命令来启用它们。

应在对象构造时就检查参数,而不是等待对象使用时再检查。这样做的好处是让问题第一时间暴露,否则后面再暴露,调试起来会比较麻烦。典型的如一个静态工厂方法,它接受一个 int 数组并返回数组的 List 视图。如果客户端传入 null,那么方法就立即抛出 NullPointerException。类似的例子还有构造函数。

但这条规则也有例外,当有效性检查成本较高或计算过程本身就包含参数检查时,会选择在计算过程中隐式检查。例如,考虑一个为对象 List 排序的方法,比如 Collections.sort(List)。List 中的所有对象必须相互比较,如果不能比较,那么将抛出 ClassCastException,这个检查过程放在比较过程中隐式进行,而不是预先检查。

对参数的限制并非越多越好。相反,你应该设计出尽可能通用的方法,对参数的限制越少越好。

第50条 在需要时制作防御性拷贝

Item 50: Make defensive copies when needed

Java 是一种安全的语言,在没有本地方法的情况下,它不受缓冲区溢出、数组溢出、非法指针和其他内存损坏错误的影响。

即使使用一种安全的语言,也可能在不经意间提供修改对象内部状态的方法。例如,下面的类表示一个不可变的时间段:

// Broken "immutable" time period class
public final class Period {private final Date start;private final Date end;/*** @param start the beginning of the period* @param end the end of the period; must not precede start* @throws IllegalArgumentException if start is after end* @throws NullPointerException if start or end is null*/public Period(Date start, Date end) {if (start.compareTo(end) > 0)throw new IllegalArgumentException(start + " after " + end);this.start = start;this.end = end;}public Date start() {return start;}public Date end() {return end;}... // Remainder omitted
}

这个类要求时间段的开始时间不能在结束时间之后。然而,可以通过修改内部保存的Date绕过这个约束:

// Attack the internals of a Period instance
Date start = new Date();
Date end = new Date();
Period p = new Period(start, end);
end.setYear(78); // Modifies internals of p!

从 Java 8 开始,解决这个问题的典型方法就是使用不可变的 Instant(或 LocalDateTime 或 ZonedDateTime)来代替已过时的Date。但是对于包含Date的老代码,必须找到一个通用的解决办法。

解决办法就是将每个可变参数的防御性拷贝复制给构造函数:

// Repaired constructor - makes defensive copies of parameters
public Period(Date start, Date end) {this.start = new Date(start.getTime());this.end = new Date(end.getTime());if (this.start.compareTo(this.end) > 0)throw new IllegalArgumentException(this.start + " after " + this.end);
}

新的构造函数保证之前的攻击不会对 Period 实例产生影响。注意,防御性拷贝是在检查参数的有效性之前制作的,这样保证在检查参数和复制参数之间的时间段,类不受来自其他线程更改的影响。在计算机安全领域,这被称为 time-of-check/time-of-use漏洞或TOCTOU攻击。

之所以没有使用 Date 的 clone 方法来创建防御性拷贝,是因为 Date 不是 final 的,所以不能保证 clone 方法返回一个Date 的实例对象,它可以返回一个不受信任子类的实例,有从这里发起恶意破坏的风险。

还可以用另一种方式修改Period内部状态:

// Second attack on the internals of a Period instance
Date start = new Date();
Date end = new Date();
Period p = new Period(start, end);
p.end().setYear(78); // Modifies internals of p!

解决办法是在访问器上返回可变内部字段的防御性拷贝:

// Repaired accessors - make defensive copies of internal fields
public Date start() {return new Date(start.getTime());
}public Date end() {return new Date(end.getTime());
}

有了新的构造函数和新的访问器,Period 实际上是不可变的,除非使用反射或本地方法修改。

应尽量使用不可变对象作为对象的组件,这样就不必操心防御性拷贝。

防御性拷贝可能会带来性能损失。如果一个类信任它的调用者不会去修改内部组件,那么可以不用做防御性拷贝,而在类文档上加以说明:调用者不能修改相关的参数对象或返回值。

第51条 仔细设计方法签名

Item 51: Design method signatures carefully

本条目是多条 API 设计经验的汇总。

仔细选择方法名字。 应选择可理解的、与同一包中的其他名称风格一致的、被广泛认可的名字。

提供便利的方法不应做过了头。 太多的方法会使得类难以维护。对于类或接口支持的每个操作,一般只提供一个功能齐全的方法就可以了,只有在经常使用时才考虑提供快捷方式。

避免长参数列表。 设定四个或更少的参数,大多数程序员记不住更长的参数列表。长序列的同类型参数尤其有害,极易导致错误。

有三种方法可以缩短过长的参数列表:

  1. 将原方法分解为多个子方法。原方法的功能由多个字方法组合实现,这样每个子方法只需要参数的一个子集。
  2. 创建 helper 类来保存参数组。
  3. 从对象构建到方法调用都采用建造者模式。

对于方法的参数类型,优先选择接口而不是类。例如优先选择Map而不是HashMap作为方法的参数类型。

对于方法的参数类型,优先选择双元素枚举类型而不是boolean 。枚举使代码更容易维护。此外,它们使以后添加更多选项变得更加容易。例如,你可能有一个Thermometer(温度计)类型与静态工厂,采用枚举:

public enum TemperatureScale { FAHRENHEIT, CELSIUS } // 华氏、摄氏

Thermometer.newInstance(TemperatureScale.CELSIUS) 不仅比 Thermometer.newInstance(true) 更有意义,而且你可以在将来的版本中向 TemperatureScale 添加 KELVIN(开尔文、热力学温标)。

第52条 明智地使用重载

Item 52: Use overloading judiciously

注意,这里说的重载是指overload,而不是重写override。

下面的程序是一个善意的尝试,根据一个 Collection 是 Set、List 还是其他的集合类型来进行分类:

// Broken! - What does this program print?
public class CollectionClassifier {public static String classify(Set<?> s) {return "Set";}public static String classify(List<?> lst) {return "List";}public static String classify(Collection<?> c) {return "Unknown Collection";}public static void main(String[] args) {Collection<?>[] collections = {new HashSet<String>(),new ArrayList<BigInteger>(),new HashMap<String, String>().values()};for (Collection<?> c : collections)System.out.println(classify(c));}
}

你期望的是:这个程序打印 Set、List 和 Unknown Collection,但结果是:它打印 Unknown Collection 三次。因为classify方法被重载,并且在编译时就决定了要调用哪个重载。

注意,重载(overload)方法的选择是静态的,而覆盖(override)方法的选择是动态的。 例如:

class Wine {String name() { return "wine"; }
}class SparklingWine extends Wine {@OverrideString name() { return "sparkling wine"; }
}class Champagne extends SparklingWine {@OverrideString name() { return "champagne"; }
}public class Overriding {public static void main(String[] args) {List<Wine> wineList = List.of(new Wine(), new SparklingWine(), new Champagne());for (Wine wine : wineList)System.out.println(wine.name());}
}

修复 CollectionClassifier 程序的最佳方法是用一个方法中用instanceof做类型判断:

public static String classify(Collection<?> c) {return c instanceof Set ? "Set" :c instanceof List ? "List" : "Unknown Collection";
}

应该避免混淆重载的用法。最保守的策略是永远不生成具有相同参数数量的两个重载。或者为方法提供不同的名字,这样就可以不用重载。

如果必须生成具有相同参数数量的两个重载,那么须保证至少有一个参数在这两个重载中具有完全不同的类型。一个反例与Java的自动装箱机制有关:

public class SetList {
public static void main(String[] args) {Set<Integer> set = new TreeSet<>();List<Integer> list = new ArrayList<>();for (int i = -3; i < 3; i++) {set.add(i);list.add(i);}for (int i = 0; i < 3; i++) {set.remove(i);list.remove(i);}System.out.println(set +""+list);}
}

我们期望从set和list中分别删除0、1、2这三个数字,但是只在set中如愿,实际在list中删除的是对应下标0、1、2的三个元素。因为对 list.remove(i) 的调用选择的是重载方法 remove(int i),而不是 remove(Object o)。正确的写法应该是:

for (int i = 0; i < 3; i++) {set.remove(i);list.remove((Integer) i); // or remove(Integer.valueOf(i))
}

lambda 表达式和方法引用也容易引起重载中的混淆。例如:

new Thread(System.out::println).start();
ExecutorService exec = Executors.newCachedThreadPool();
exec.submit(System.out::println);

Thread 构造函数调用和 submit 方法调用看起来很相似,但是前者能通过编译而后者不能。因为submit 方法有一个重载,它接受一个 Callable<T>,而线程构造函数没有。所以编译器会组织后者以免产生歧义。

第53条 明智地使用可变参数

Item 53: Use varargs judiciously

可变参数方法接受指定类型的零个或多个参数。可变参数的底层是一个数组。

一个简单的可变参数例子:

// Simple use of varargs
static int sum(int... args) {int sum = 0;for (int arg : args)sum += arg;return sum;
}

假设要编写一个函数来计算其参数的最小值。如果客户端不传递参数,那么在运行时检查参数长度,并抛出异常:

// The WRONG way to use varargs to pass one or more arguments!
static int min(int... args) {if (args.length == 0)throw new IllegalArgumentException("Too few arguments");int min = args[0];for (int i = 1; i < args.length; i++)if (args[i] < min)min = args[i];return min;
}

这个解决方案有几个问题:

  1. 如果不带参数调用此方法,那么会在运行时而非编译时失败。
  2. 不美观。不能使用for-each循环,除非将min初始化为Integer.MAX_VALUE。

以下写法能避免这些问题:

// The right way to use varargs to pass one or more arguments
static int min(int firstArg, int... remainingArgs) {int min = firstArg;for (int arg : remainingArgs)if (arg < min)min = arg;return min;
}

在性能关键的情况下使用可变参数要小心。每次调用可变参数方法都会导致数组创建和初始化。有一种折中的办法。假设 95% 的调用只需要三个或更少的参数,那么只对三个以上参数使用可变参数:

public void foo() { }
public void foo(int a1) { }
public void foo(int a1, int a2) { }
public void foo(int a1, int a2, int a3) { }
public void foo(int a1, int a2, int a3, int... rest) { }

第54条 返回空集合或数组,而不是 null

Item 54: Return empty collections or arrays, not nulls

如下方法很常见:

// Returns null to indicate an empty collection. Don't do this!
private final List<Cheese> cheesesInStock = ...;
/**
* @return a list containing all of the cheeses in the shop,
* or null if no cheeses are available for purchase.
*/
public List<Cheese> getCheeses() {return cheesesInStock.isEmpty() ? null: new ArrayList<>(cheesesInStock);
}

这样写的坏处是方法调用时需做非null的判断:

List<Cheese> cheeses = shop.getCheeses();
if (cheeses != null && cheeses.contains(Cheese.STILTON))System.out.println("Jolly good, just the thing.");

当忘记做非null的判断时就会出错,应该改成返回空的列表:

//The right way to return a possibly empty collection
public List<Cheese> getCheeses() {return new ArrayList<>(cheesesInStock);
}

如果新创建空集合会损害性能,那么可以通过重复返回空的不可变集合来避免新的创建:

// Optimization - avoids allocating empty collections
public List<Cheese> getCheeses() {return cheesesInStock.isEmpty() ? Collections.emptyList(): new ArrayList<>(cheesesInStock);
}

数组与集合情况类似。永远不要返回 null,而应该返回零长度的数组。下面代码将一个零长度的数组传递到 toArray 方法中:

//The right way to return a possibly empty array
public Cheese[] getCheeses() {return cheesesInStock.toArray(new Cheese[0]);
}

如果你创建零长度数组会损害性能,你可以重复返回相同的零长度数组:

// Optimization - avoids allocating empty arrays
private static final Cheese[] EMPTY_CHEESE_ARRAY = new Cheese[0];
public Cheese[] getCheeses() {return cheesesInStock.toArray(EMPTY_CHEESE_ARRAY);
}

不要为了提高性能而预先分配传递给 toArray 的数组:

// Don’t do this - preallocating the array harms performance!
return cheesesInStock.toArray(new Cheese[cheesesInStock.size()]);

第55条 明智地的返回 Optional

Item 55: Return optionals judiciously

在 Java 8 之前,在编写在某些情况下无法返回值的方法时,可以采用两种方法:抛出异常或者返回 null。这两种方法都不完美。抛出异常代价高昂,返回null需对这种情况做特殊判断。

在 Java 8 中,可以通过返回Optional<T> 解决上述问题。它表示理论上应返回 T,但在某些情况下可能无法返回 T。

在第30条中,有一个根据集合元素的自然顺序计算集合最大值的例子:

// Returns maximum value in collection - throws exception if empty
public static <E extends Comparable<E>> E max(Collection<E> c) {if (c.isEmpty())throw new IllegalArgumentException("Empty collection");E result = null;for (E e : c)if (result == null || e.compareTo(result) > 0)result = Objects.requireNonNull(e);return result;
}

更好的替代方法是返回 Optional<E>

// Returns maximum value in collection as an Optional<E>
public static <E extends Comparable<E>> Optional<E> max(Collection<E> c) {if (c.isEmpty())return Optional.empty();E result = null;for (E e : c)if (result == null || e.compareTo(result) > 0)result = Objects.requireNonNull(e);return Optional.of(result);
}

注意,永远不要从拥有Optional返回值的方法返回空值,因为它违背了这个功能的设计初衷。

许多流上的 Terminal 操作返回 Optional。如果我们使用一个流来重写 max 方法,那么流版本的 max 操作会为我们生成一个 Optional:

// Returns max val in collection as Optional<E> - uses stream
public static <E extends Comparable<E>> Optional<E> max(Collection<E> c) {return c.stream().max(Comparator.naturalOrder());
}

对于返回Optional的方法,调用者可以选择如果该方法不能返回值要采取什么操作。你可以指定一个默认值:

// Using an optional to provide a chosen default value
String lastWordInLexicon = max(words).orElse("No words...");

或者可以抛出任何适当的异常:

// Using an optional to throw a chosen exception
Toy myToy = max(toys).orElseThrow(TemperTantrumException::new);

如果你能判断一个 Optional 非空,那么可以从 Optional 直接获取值,而无需指定空值对应的操作。但如果判断错误,会抛出一个 NoSuchElementException:

// Using optional when you know there’s a return value
Element lastNobleGas = max(Elements.NOBLE_GASES).get();

并非所有的返回类型都适合用Optional封装,不适合的类型有:容器类型,包括集合、Map、流、数组和 Optional。例如应返回空的List,而不是Optional<List>。

适合使用Optional的场景是:方法有可能不会返回值,如果没有返回值,调用者不得不做特殊处理。

在性能关键的场合请谨慎使用Optional,因为它有封装原始对象的额外性能代价。

不应该在除方法返回值以外的任何地方使用Optional。

第56条 为所有公开的 API 元素编写文档注释

Item 56: Write doc comments for all exposed API elements

要正确地编写 API 文档,必须在每个公开的类、接口、构造函数、方法和字段声明之前加上文档注释。

方法的文档注释应该简洁地描述方法与其调用者之间的约定,包括:

  1. 说明方法做了什么,而不是如何做。
  2. 应列举方法所有的前置条件和后置条件。
  3. 说明方法产生的副作用。如启动一个新的后台线程。
  4. 应包含必要的@param、@return 和@throw注释。

以下是一个满足上面要求的方法注释:

/**
* Returns the element at the specified position in this list.
**
<p>This method is <i>not</i> guaranteed to run in constant
* time. In some implementations it may run in time proportional
* to the element position.
**
@param index index of element to return; must be
* non-negative and less than the size of this list
* @return the element at the specified position in this list
* @throws IndexOutOfBoundsException if the index is out of range
* ({@code index < 0 || index >= this.size()})
*/
E get(int index);

为泛型类型或方法编写文档时,请确保说明所有的类型参数:

/**
* An object that maps keys to values. A map cannot contain
* duplicate keys; each key can map to at most one value.
**
(Remainder omitted)
**
@param <K> the type of keys maintained by this map
* @param <V> the type of mapped values
*/
public interface Map<K, V> { ... }

对于枚举类型,文档要覆盖枚举类型本身、常量以及公开的方法:

/**
* An instrument section of a symphony orchestra.
*/
public enum OrchestraSection {
/** Woodwinds, such as flute, clarinet, and oboe. */
WOODWIND,
/** Brass instruments, such as french horn and trumpet. */
BRASS,
/** Percussion instruments, such as timpani and cymbals. */
PERCUSSION,
/** Stringed instruments, such as violin and cello. */
STRING;
}

对于注解类型,文档要覆盖注解类型本身和所有成员:

/**
* Indicates that the annotated method is a test method that
* must throw the designated exception to pass.
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface ExceptionTest {
/**
* The exception that the annotated test method must throw
* in order to pass. (The test is permitted to throw any
* subtype of the type described by this class object.)
*/
Class<? extends Throwable> value();
}

无论类或静态方法是否线程安全,都应该说明它的线程安全级别。如果一个类是可序列化的,那么应该说明它的序列化形式。

Java自带的Javadoc会根据代码自动生成文档,并且检查文档是否符合本条目中提及的许多建议。在 Java 8 和 Java 9 中,默认启用了自动检查。诸如 checkstyle 之类的 IDE 插件在检查是否符合这些建议方面做得更好。

第九章 通用程序设计

Chapter 9. General Programming

第57条 将局部变量的作用域最小化

Item 57: Minimize the scope of local variables()

将局部变量的作用域最小化,最有效的办法就是在第一次使用它的地方声明。

每个局部变量声明都应该包含一个初始化表达式。如果还没足够的信息初始化,那么应该推迟声明。这个规则的一个例外是try-catch语句,如果一个变量要在try块之外使用,那么就要在try块之前声明,此时可能还没有足够的信息初始化这个变量。

最小化局部变量作用域的第二种方法是使用for循环。如果一个变量在循环之后就不再使用,那么使用for循环优于while循环。下面是一个for-each循环的例子:

// Preferred idiom for iterating over a collection or array
for (Element e : c) {... // Do Something with e
}

当需要iterator或者remove时,使用传统for循环替代for-each循环:

// Idiom for iterating when you need the iterator
for (Iterator<Element> i = c.iterator(); i.hasNext(); ) {Element e = i.next();... // Do something with e and i
}

下面的例子说明了为什么for循环优于while循环,因为这个例子暗含了一个bug(将i2错写为i):

Iterator<Element> i = c.iterator();
while (i.hasNext()) {doSomething(i.next());
}
...
Iterator<Element> i2 = c2.iterator();
while (i.hasNext()) { // BUG!doSomethingElse(i2.next());
}

如果改成使用for循环,那么在编译期间就可以发现这个问题:

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;
}

最小化局部变量作用域的第三种方法是保持方法小而集中。例子一个方法包含了两段不同的逻辑,一个变量只用于其中一段逻辑中,那么可以将这个方法按逻辑拆分成两个不同的方法。

第58条 for-each 循环优于传统的 for 循环

Item 58: Prefer for-each loops to traditional for loops

下面是使用传统for循环遍历容器的例子:

// Not the best way to iterate over a collection!
for (Iterator<Element> i = c.iterator(); i.hasNext(); ) {Element e = i.next();... // Do something with e
}

改用for-each要简洁得多:

// The preferred idiom for iterating over collections and arrays
for (Element e : elements) {... // Do something with e
}

嵌套循环中使用传统for循环比for-each循环更容易出bug。如下面使用传统for循环的代码暗含bug,因为执行i.next()的次数超出预期:

// Can you spot the bug?
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()));

改成使用for-each循环就能避免这个问题:

// Preferred idiom for nested iteration on collections and arrays
for (Suit suit : suits)
for (Rank rank : ranks)
deck.add(new Card(suit, rank));

但是有三种情况不应该使用for-each:

  1. 破坏性过滤:如果需要在遍历过程中删除元素,那么应该使用iterator和remove方法。Java 8中可以使用Collection类中提供的removeIf方法达到同样效果。
  2. 转换:如果需要在遍历List或者数组的时候替换其中部分元素的值,那么需要使用迭代器或者数组索引。
  3. 并行迭代:如果需要并行遍历多个容器,那么需要使用迭代器,自行控制迭代进度。

for-each循环还可以用来遍历实现Iterable接口的任何对象。

第59条 了解并使用库

Item 59: Know and use the libraries

有时候程序员喜欢自己造一些轮子。例如实现一个获取随机数的方法:

// Common but deeply flawed!
static Random rnd = new Random();
static int random(int n) {return Math.abs(rnd.nextInt()) % n;
}

这个方法不仅不能做到输出均匀分布,而且在边界情况运行时可能会报错。

正确的做法是使用java库中的Random类。在Java 7之后,更应该选择性能更好的ThreadLocalRandom取代Random。

使用库的好处有:

  1. 利用专家知识,实现特定的复杂算法或逻辑。
  2. 让调用者专注于业务开发,不用在底层实现上多费时间。
  3. 库的性能会不断提升,调用者无需付出额外努力。
  4. 让代码更精简,更容易维护。

在Java每个主要版本中,都会向库中添加许多特性,了解这些新增特性是值得的。

如果说要库的内容太多不好掌握,那么每个程序员至少应该熟悉 java.lang、java.util 和 java.io 及其子包。其中collections框架、streams库和java.util.concurrent库应该重点掌握。

第60条 若需要精确答案就应避免使用 float 和 double 类型

Item 60: Avoid float and double if exact answers are required

float 和 double 类型主要用于工程计算和科学计算。它们本质上是二进制浮点类型,因此不能用来表示精确的结果。特别不适合进行货币计算,因为10的任意负次幂无法精确表示为float或double。

假设原有1.03美元,消费掉0.42美元,还剩多少钱?输出会是0.6100000000000001。

System.out.println(1.03 - 0.42);

假设架上有一排糖果,价格依次为10美分、20美分、30美分…你有1美元,按次数购买糖果,直到把钱用完:

// Broken - uses floating point for monetary calculation!
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计算,可以得到正确结果:

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的另一个好处是:它有8种舍入模式可以选择,如果业务需要做数值舍入,那么将非常方便。不过它也有两个缺点:它与原始算术类型相比使用很不方便,而且速度要慢得多。

另一种方案是使用int表示的美分来计算:

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");
}

第61条 基本数据类型优于包装类

Item 61: Prefer primitive types to boxed primitives

基本类型和包装类型之间有三个主要区别:

  1. 基本类型只有值,而包装类型具有与其值不同的标识。
  2. 基本类型只有全功能值,而每个包装类型除了对应的基本类型的所有功能值外,还有一个非功能值null。
  3. 基本类型比包装类型更节省时间和空间。

基本类型和包装类型的区别会带来一些麻烦。例如下面的比较器对Integer做数值比较:

// Broken comparator - can you spot the flaw?
Comparator<Integer> naturalOrder =(i, j) -> (i < j) ? -1 : (i == j ? 0 : 1);

这里有个隐藏的bug。当执行naturalOrder.compare(new Integer(42), new Integer(42))时,执行结果是1,而非期待中的0。原因是用==比较两个不同的Integer对象,结果肯定是false。将==操作符应用于包装类型几乎都是错误的。

正确的写法是先自动拆箱再比较:

Comparator<Integer> naturalOrder = (iBoxed, jBoxed) -> {int i = iBoxed, j = jBoxed; // Auto-unboxingreturn i < j ? -1 : (i == j ? 0 : 1);
};

下面的程序不会打印出Unbelievable,而是抛出NullPointerException。原因是在操作中混合使用基本类型和包装类型时,包装类型就会自动拆箱:

public class Unbelievable {
static Integer i;
public static void main(String[] args) {if (i == 42)System.out.println("Unbelievable");}
}

下面的代码性能很差,因为sum是Long型的变量,它在与long型的变量i计算时,会经历反复的拆箱和装箱:

// Hideously slow program! Can you spot the object creation?
public static void main(String[] args) {Long sum = 0L;for (long i = 0; i < Integer.MAX_VALUE; i++) {sum += i;}System.out.println(sum);
}

下列场合应该使用包装类型,而不能使用基本类型:

  1. 作为容器中的元素、键和值。
  2. 参数化的类型和方法的类型参数。
  3. 在做反射方法调用时。

第62条 其他类型更合适时应避免使用字符串

Item 62: Avoid strings where other types are more appropriate

字符串被设计用来表示文本。当在其他场景时,如果有更适合的其他类型,那么就应该避免使用字符串。

字符串是其他值类型的糟糕替代品。当从IO输入流读取数据时,通常是字符串的形式。但如果数据本质上是别的类型,如数值类型,那么就应该转换成合适的数值类型,如float、int等。

字符串是枚举类型的糟糕替代品。参见第34条中的讨论。

字符串是聚合类型的糟糕替代品。如果一个实体有多个组件,那么就不适合把它表示成单个字符串。如下所示:

// Inappropriate use of string as aggregate type
String compoundKey = className + "#" + i.next();

有以下缺点:

  1. 如果分隔符#出现在任何一个字段中,将会导致混乱。
  2. 要访问各个字段,需要解析字符串,带来额外的性能代价。
  3. 无法自行提供equals、toString和compareTo方法,只能使用String提供的相应方法。

更好的办法是把这个聚合实体表示成一个类,通常是私有静态成员类。

字符串不能很好地替代capabilities。例如在Java 1.2引入ThreadLocal前,有人提出了如下的线程局部变量设计,用字符串类型的键来标识每个线程局部变量:

// Broken - inappropriate use of string as capability!
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);
}

这样设计的问题是:调用者提供的字符串键必须是惟一的。如果两位调用者恰巧为各自的线程局部变量使用了相同的字符串键,那么无意中就会共享一个变量,造成潜在的bug。

改进方案是用一个不可伪造的键(有时称为 capability)来替换字符串:

public class ThreadLocal {private ThreadLocal() { } // Noninstantiablepublic 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);
}

做进一步的优化。键不再是线程局部变量的键值,而是成为线程局部变量本身:

public final class ThreadLocal {public ThreadLocal();public void set(Object value);public Object get();
}

最后的优化是修改为支持泛型的类。这样能保证类型安全。

public final class ThreadLocal<T> {public ThreadLocal();public void set(T value);public T get();
}

第63条 当心字符串连接引起的性能问题

Item 63: Beware the performance of string concatenation

使用字符串连接运算符(+)连接 n 个字符串需要 n 的平方级时间。因为连接两个字符串时,会复制这两个字符串的内容。

下面代码将多个账单项目汇总到一起,用字符串连接符操作:

// Inappropriate use of string concatenation - Performs poorly!
public String statement() {String result = "";for (int i = 0; i < numItems(); i++)result += lineForItem(i); // String concatenationreturn result;
}

上面代码性能很糟糕。应该用StringBuilder来替代String:

public String statement() {StringBuilder b = new StringBuilder(numItems() * LINE_WIDTH);for (int i = 0; i < numItems(); i++)b.append(lineForItem(i));return b.toString();
}

不要使用字符串连接符操作多个字符串,除非性能无关紧要。

第64条 通过接口引用对象

Item 64: Refer to objects by their interfaces

如果存在合适的接口类型,那么应该使用接口类型声明参数、返回值、变量和字段。下面例子遵循了这个准则:

// Good - uses interface as type
Set<Son> sonSet = new LinkedHashSet<>();

而不应该像下面例子这样:

// Bad - uses class as type!
LinkedHashSet<Son> sonSet = new LinkedHashSet<>();

使用接口作为类型会使程序更加灵活。例如可以灵活调整Set的实现:

Set<Son> sonSet = new HashSet<>();

如果没有合适的接口存在,那么用类引用对象是完全合适的。 此时应使用类层次结构中提供所需功能最底层的类。

第65条 接口优于反射

Item 65: Prefer interfaces to reflection

反射机制java.lang.reflect提供对任意类的编程访问。反射提供的功能包括:

  1. 获取类的成员名、字段类型、方法签名。
  2. 构造类的实例,调用类的方法,访问类的字段。
  3. 允许一个类使用另一个编译时还不存在的类。

但是反射也有一些缺点:

  1. 失去了编译时类型检查的所有好处,包括异常检查。
  2. 执行反射访问时所需的代码比普通代码更加冗长。
  3. 反射方法调用比普通方法调用慢得多。

对于许多程序,它们必须用到在编译时无法获取的类。这时可以用反射创建实例,并通过它们的接口或超类访问它们。

下面例子是一个创建 Set<String> 实例的程序,类由第一个命令行参数指定,剩余的命令行参数被插入到集合中打印出来:

// Reflective instantiation with interface access
public static void main(String[] args) {// Translate the class name into a Class objectClass<? extends Set<String>> cl = null;try {cl = (Class<? extends Set<String>>) // Unchecked cast!Class.forName(args[0]);} catch (ClassNotFoundException e) {fatalError("Class not found.");}// Get the constructorConstructor<? extends Set<String>> cons = null;try {cons = cl.getDeclaredConstructor();} catch (NoSuchMethodException e) {fatalError("No parameterless constructor");}// Instantiate the setSet<String> s = null;try {s = cons.newInstance();} catch (IllegalAccessException e) {fatalError("Constructor not accessible");} catch (InstantiationException e) {fatalError("Class not instantiable.");} catch (InvocationTargetException e) {fatalError("Constructor threw " + e.getCause());} catch (ClassCastException e) {fatalError("Class doesn't implement Set");}// Exercise the sets.addAll(Arrays.asList(args).subList(1, args.length));System.out.println(s);
}private static void fatalError(String msg) {System.err.println(msg);System.exit(1);
}

第66条 明智地使用本地方法

Item 66: Use native methods judiciously

Java 本地接口(JNI)允许 Java 程序调用本地方法。本地方法有三种用途:

  1. 提供对特定于平台的功能(如注册表)的访问。
  2. 提供对现有本地代码库的访问。
  3. 通过本地语言编写应用程序中注重性能的部分,以提高性能。

使用本地方法访问特定于平台的机制是合法的,但是很少有必要。因为随着Java的发展,它不断提供以前只能在特定平台上才有的特性。

为了提高性能,很少建议使用本地方法。同样也是因为Java不断对底层类库做着优化,很多时候本地方法并未有明显性能优势。

使用本地方法有严重的缺点:

  1. 不再免受内存毁坏错误的影响。
  2. 可移植性较差,也更难调试。
  3. 垃圾收集器无法自动跟踪本地内存使用情况。
  4. 需要粘合代码,更难维护。

第67条 明智地进行优化

Item 67: Optimize judiciously

有三条关于优化的格言是每个人都应该知道的:

  1. William A. Wulf:与任何其他单一原因(包括盲目愚蠢)相比,以效率为名(不一定能够实现)犯下的错误更多。
  2. Donald E. Knuth:不要去计较效率上的一些小小的得失,在 97% 的情况下,不成熟的优化才是一切问题的根源。
  3. M. A. Jackson:在你还没有绝对清晰的未优化方案之前,请不要进行优化。

以上格言说明:优化很容易弊大于利,过早的优化是万恶之源。

不要为了性能而牺牲合理的架构。努力编写好的程序,而不是快速的程序。 如果一个好的程序还不够快,那么再着手去优化。

尽量避免限制性能的设计决策。设计中最难更改是那些与外部世界交互的组件。例如API、线路协议和持久数据格式。这些组件可能对系统性能造成重大限制。

考虑API设计决策的性能后果。例如,如果一个公共类型是可变的,那么可能需要大量不必要的防御性拷贝。

通常情况下,好的 API 设计与好的性能是一致的。为了获得良好的性能而改变 API 是一个非常糟糕的想法。因为API的性能可能会在未来的版本有改进,但是改变API带来的问题却会一直存在。

在每次尝试优化之前和之后测量性能。自己的估计可能会和实际的测量结果有差距。有时候花大量时间做的优化没有明显的性能提升。

第68条 遵守被广泛认可的命名约定

Item 68: Adhere to generally accepted naming conventions

命名约定分为两类:排版和语法。

排版约定包括:

  1. 包名和模块名应该是分层的,组件之间用句点分隔。
  2. 包名以当前组织的域名开头,并将组件颠倒过来,如com.google。包名其余部分应该由描述包的一个或多个组件组成,如util、awt。
  3. 类和接口名称,包括枚举和注释类型名称,应该由一个或多个单词组成,每个单词的首字母大写,如List、FutureTask。
  4. 方法和字段名遵循与类和接口名相同的排版约定,除了方法或字段名的第一个字母应该是小写,如如 remove、ensureCapacity。
  5. 常量字段的名称应该由一个或多个大写单词组成,由下划线分隔,如NEGATIVE_INFINITY。
  6. 局部变量名与成员名具有类似的排版命名约定,但允许缩写,如i、denom、houseNum。
  7. 类型参数名通常由单个字母组成。最常见的是以下五种类型之一:T 表示任意类型,E 表示集合的元素类型,K 和 V 表示 Map 的键和值类型,X 表示异常,R表示函数的返回类型。

排版约定举例参见下表:

标识符类型 例子
包或者模块 org.junit.jupiter.api, com.google.common.collect
类或接口 Stream, FutureTask, LinkedHashMap,HttpClient
方法或字段 remove, groupingBy, getCrc
常量字段 MIN_VALUE, NEGATIVE_INFINITY
局部变量 i, denom, houseNum
类型参数 T, E, K, V, X, R, U, V, T1, T2

语法约定包括:

  1. 实例化的类,包括枚举类型,通常使用一个或多个名词短语来命名,例如 Thread、PriorityQueue 或 ChessPiece。
  2. 不可实例化的实用程序类通常使用复数名词来命名,例如 Collectors 或 Collections。
  3. 接口的名称类似于类,例如 Collection 或 Comparator,或者以 able 或 ible 结尾的形容词,例如 Runnable、Iterable 或 Accessible。
  4. 执行某些操作的方法通常用动词或动词短语命名,例如,append 或 drawImage。
  5. 返回布尔值的方法的名称通常以单词 is 或 has开头,后面跟一个名词或者形容词,例如 isDigit、isProbablePrime、isEmpty、isEnabled 或 hasSiblings。
  6. 获取对象属性的方法通常使用以 get 开头的名词、名词短语或动词短语来命名,例如 size、hashCode 或 getTime。
  7. 类型为 boolean 的字段的名称通常类似于 boolean 访问器方法,省略了初始值 is,例如 initialized、composite。