40. 始终使用 Override 注解
Java 类库包含几个注解类型。对于典型的程序员来说,最重要的是 @Override。此注解只能在方法声明上使用,它表明带此注解的方法声明重写了父类的声明。如果始终使用这个注解,它将避免产生大量的恶意 bug。考虑这个程序,在这个程序中,类 Bigram 表示双字母组合,或者是有序的一对字母:
// Can you spot the bug?
public class Bigram {
private final char first;
private final char second;
public Bigram(char first, char second) {
this.first = first;
this.second = second;
}
public boolean equals(Bigram b) {
return b.first == first && b.second == second;
}
public int hashCode() {
return 31 * first + second;
}
public static void main(String[] args) {
Set<Bigram> s = new HashSet<>();
for (int i = 0; i < 10; i++)
for (char ch = 'a'; ch <= 'z'; ch++)
s.add(new Bigram(ch, ch));
System.out.println(s.size());
}
}
主程序重复添加二十六个双字母组合到集合中,每个双字母组合由两个相同的小写字母组成。 然后它会打印集合的大小。 你可能希望程序打印 26,因为集合不能包含重复项。 如果你尝试运行程序,你会发现它打印的不是 26,而是 260。它有什么问题?
显然,Bigram 类的作者打算重写 equals 方法(详见第 10 条),甚至记得重写 hashCode(详见第 11 条)。 不幸的是,我们倒霉的程序员没有重写 equals,而是重载它(详见第 52 条)。 要重写 Object.equals,必须定义一个 equals 方法,其参数的类型为 Object,但 Bigram 的 equals 方法的参数不是 Object 类型的,因此 Bigram 继承 Object 的 equals 方法,这个 equals 方法测试对象的引用是否是同一个,就像 == 运算符一样。 每个字母组合的 10 个副本中的每一个都与其他 9 个副本不同,所以它们被 Object.equals 视为不相等,这就解释了程序打印 260 的原因。
幸运的是,编译器可以帮助你找到这个错误,但只有当你通过告诉它你打算重写 Object.equals 来帮助你。 要做到这一点,用 @Override 注解 Bigram.equals 方法,如下所示:
@Override public boolean equals(Bigram b) {
return b.first == first && b.second == second;
}
如果插入此注解并尝试重新编译该程序,编译器将生成如下错误消息:
Bigram.java:10: method does not override or implement a method
from a supertype
@Override public boolean equals(Bigram b) {
^
你会立刻意识到你做错了什么,在额头上狠狠地打了一下,用一个正确的(详见第 10 条)来替换出错的 equals 实现:
@Override public boolean equals(Object o) {
if (!(o instanceof Bigram))
return false;
Bigram b = (Bigram) o;
return b.first == first && b.second == second;
}
因此,应该在你认为要重写父类声明的每个方法声明上使用 Override 注解。 这条规则有一个小例外。 如果正在编写一个没有标记为抽象的类,并且确信它重写了其父类中的抽象方法,则无需将 Override 注解放在该方法上。 在没有声明为抽象的类中,如果无法重写抽象父类方法,编译器将发出错误消息。 但是,你可能希望关注类中所有重写父类方法的方法,在这种情况下,也应该随时注解这些方法。 大多数 IDE 可以设置为在选择重写方法时自动插入 Override 注解。
大多数 IDE 提供了是种使用 Override 注解的另一个理由。 如果启用适当的检查功能,如果有一个方法没有 Override 注解但是重写父类方法,则 IDE 将生成一个警告。 如果始终使用 Override 注解,这些警告将提醒你无意识的重写。 它们补充了编译器的错误消息,这些消息会提醒你无意识重写失败。 IDE 和编译器,可以确保你在任何你想要的地方和其他地方重写方法,万无一失。
Override 注解可用于重写来自接口和类的方法声明。 随着 default 默认方法的出现,在接口方法的具体实现上使用 Override 以确保签名是正确的是一个好习惯。 如果知道某个接口没有默认方法,可以选择忽略接口方法的具体实现上的 Override 注解以减少混乱。
然而,在一个抽象类或接口中,值得标记的是你认为重写父类或父接口方法的所有方法,无论是具体的还是抽象的。 例如,Set 接口不会向 Collection 接口添加新方法,因此它应该在其所有方法声明中包含 Override 注解以确保它不会意外地向 Collection 接口添加任何新方法。
总之,如果在每个方法声明中使用 Override 注解,并且认为要重写父类声明,那么编译器可以保护免受很多错误的影响,但有一个例外。 在具体的类中,不需要注解标记你确信可以重写抽象方法声明的方法(尽管这样做也没有坏处)。
41. 使用标记接口定义类型
标记接口(marker interface),是不包含方法声明的接口,只是指定(或「标记」)一个类实现了具有某些属性的接口。 例如,考虑 Serializable 接口(第 12 章)。通过实现这个接口,一个类表明它的实例可以写入 ObjectOutputStream(或被「序列化」)。
你可能会听说过标记注解(详见第 39 条)使得标记接口过时了。 这个断言是不正确的。 标记接口与标记注解相比具有两个优点。 首先,也是最重要的一点,标记接口定义了一个由标记类实例实现的类型;标记注解则没有定义这样的类型。 标记接口类型的存在允许在编译时捕获错误,如果使用标记注解,则直到运行时才能捕获错误。
Java 的序列化机制(第 6 章)使用 Serializable 标记接口来指示某个类型是可序列化的。 对传递给它的对象进行序列化的 ObjectOutputStream.writeObject 方法要求其参数可序列化。 如果此方法的参数是 Serializable 类型,则在编译时会检测到序列化不适当对象的尝试(通过类型检查)。 编译时错误检测是标记接口的意图,但不幸的是,ObjectOutputStream.writeObject API 没有利用 Serializable 接口:它的参数被声明为 Object 类型,所以尝试序列化一个不可序列化的对象直到运行时才会失败。
标记接口对于标记注解的另一个优点是可以更精确地定位目标。 如果使用目标 ElementType.TYPE 声明注解类型,它就可以被应用于任何类或接口。 假设有一个标记仅适用于特定接口的实现。 如果将其定义为标记接口,则可以扩展它适用的唯一接口,保证所有标记类型也是适用的唯一接口的子类型。
可以说,Set 接口就是这样一个受限的标记接口。 它仅适用于 Collection 子类型,但不会添加超出 Collection 定义的方法。 它通常不被认为是标记接口,因为它改进了几个 Collection 方法的契约,包括 add,equals 和 hashCode。 但很容易想象一个标记接口,它仅适用于某些特定接口的子类型,并且不会改进任何接口方法的契约。 这样的标记接口可以描述整个对象的一些约束条件(invariant),或者说明实例有资格被某个其他类的方法处理(就像 Serializable 接口指示实例有资格被 ObjectOutputStream 处理的方式)。
**标记注解优于标记接口的主要优点是它们是更大的注解工具的一部分。**因此,标记注解允许在基于注解的框架中保持一致性。
所以什么时候应该使用标记注解,什么时候应该使用标记接口?显然,如果标记是应用于除类或接口以外的任何程序元素,则必须使用注解,因为只能使用类和接口来实现或扩展接口。如果标记仅适用于类和接口,那么问自己问题:「可能我想编写一个或多个只接受具有此标记的对象的方法呢?」如果是这样,则应该优先使用标记接口而不是注解。这将使你可以将接口用作所讨论方法的参数类型,这将带来编译时类型检查的好处。如果你能说服自己,永远不会想写一个只接受带有标记的对象的方法,那么最好使用标记注解。另外,如果标记是大量使用注解的框架的一部分,则标记注解是明确的选择。
总之,标记接口和标记注释都有其用处。 如果你想定义一个没有任何关联的新方法的类型,一个标记接口是一种可行的方法。 如果要标记除类和接口以外的程序元素,或者将标记符合到已经大量使用注解类型的框架中,那么标记注解是正确的选择。 如果发现自己正在编写目标为 ElementType.TYPE 的标记注解类型,那么请花时间弄清楚究竟应该用注解类型,还是标记接口更合适。
从某种意义来说,本条目与条目 22 的的意思正好相反,条目 22 的意思是:「如果你不想定义一个类型,不要使用接口」。本条目的意思是:「如果想定义一个类型,一定要使用接口。」
42. lambda 表达式优于匿名类
在 Java 8 中,添加了函数式接口,lambda 表达式和方法引用,以便更容易地创建函数对象。 Stream API 随着其他语言的修改一同被添加进来,为处理数据元素序列提供类库支持。 在本章中,我们将讨论如何充分利用这些功能。
以往,使用单一抽象方法的接口(或者很少使用的抽象类)被用作函数类型。 它们的实例(称为函数对象)表示函数(functions)或行动(actions)。 自从 JDK 1.1 于 1997 年发布以来,创建函数对象的主要手段就是匿名类(详见第 24 条)。 下面是一段代码片段,按照字符串长度顺序对列表进行排序,使用匿名类创建排序的比较方法(强制排序顺序):
// Anonymous class instance as a function object - obsolete!
Collections.sort(words, new Comparator<String>() {
public int compare(String s1, String s2) {
return Integer.compare(s1.length(), s2.length());
}
});
匿名类适用于需要函数对象的经典面向对象设计模式,特别是策略模式[Gamma95]。 比较器接口表示排序的抽象策略; 上面的匿名类是排序字符串的具体策略。 然而,匿名类的冗长,使得 Java 中的函数式编程成为一种不吸引人的设想。
在 Java 8 中,语言形式化了这样的概念,即使用单个抽象方法的接口是特别的,应该得到特别的对待。 这些接口现在称为函数式接口,并且该语言允许你使用 lambda 表达式或简称 lambdas 来创建这些接口的实例。 Lambdas 在功能上与匿名类相似,但更为简洁。 下面的代码使用 lambdas 替换上面的匿名类。 样板不见了,行为清晰明了:
// Lambda expression as function object (replaces anonymous class)
Collections.sort(words,
(s1, s2) -> Integer.compare(s1.length(), s2.length()));
请注意,代码中不存在 lambda(Comparator <String>),其参数(s1 和 s2,都是 String 类型)及其返回值(int)的类型。 编译器使用称为类型推断的过程从上下文中推导出这些类型。 在某些情况下,编译器将无法确定类型,必须指定它们。 类型推断的规则很复杂:他们在 JLS 中占据了整个章节[JLS,18]。 很少有程序员详细了解这些规则,但没关系。 除非它们的存在使你的程序更清晰,否则省略所有 lambda 参数的类型。 如果编译器生成一个错误,告诉你它不能推断出 lambda 参数的类型,那么指定它。 有时你可能不得不强制转换返回值或整个 lambda 表达式,但这很少见。
关于类型推断需要注意一点。 条目 26 告诉你不要使用原始类型,条目 29 告诉你偏好泛型类型,条目 30 告诉你偏向泛型方法。 当使用 lambda 表达式时,这个建议是非常重要的,因为编译器获得了大部分允许它从泛型进行类型推断的类型信息。 如果你没有提供这些信息,编译器将无法进行类型推断,你必须在 lambdas 中手动指定类型,这将大大增加它们的冗余度。 举例来说,如果变量被声明为原始类型 List 而不是参数化类型 List<String>,则上面的代码片段将不会编译。
顺便提一句,如果使用比较器构造方法代替 lambda,则代码中的比较器可以变得更加简洁(详见第 14 和 43 条):
Collections.sort(words, comparingInt(String::length));
实际上,通过利用添加到 Java 8 中的 List 接口的 sort 方法,可以使片段变得更简短:
words.sort(comparingInt(String::length));
将 lambdas 添加到该语言中,使得使用函数对象在以前没有意义的地方非常实用。例如,考虑条目 34 中的 Operation 枚举类型。由于每个枚举都需要不同的应用程序行为,所以我们使用了特定于常量的类主体,并在每个枚举常量中重写了 apply 方法。为了刷新你的记忆,下面是之前的代码:
// Enum type with constant-specific class bodies & data
public enum Operation {
PLUS("+") {
public double apply(double x, double y) { return x + y; }
},
MINUS("-") {
public double apply(double x, double y) { return x - y; }
},
TIMES("*") {
public double apply(double x, double y) { return x * y; }
},
DIVIDE("/") {
public double apply(double x, double y) { return x / y; }
};
private final String symbol;
Operation(String symbol) { this.symbol = symbol; }
@Override
public String toString() { return symbol; }
public abstract double apply(double x, double y);
}
第 34 条目说,枚举实例属性比常量特定的类主体更可取。 Lambdas 可以很容易地使用前者而不是后者来实现常量特定的行为。 仅仅将实现每个枚举常量行为的 lambda 传递给它的构造方法。 构造方法将 lambda 存储在实例属性中,apply 方法将调用转发给 lambda。 由此产生的代码比原始版本更简单,更清晰:
public enum Operation {
PLUS ("+", (x, y) -> x + y),
MINUS ("-", (x, y) -> x - y),
TIMES ("*", (x, y) -> x * y),
DIVIDE("/", (x, y) -> x / y);
private final String symbol;
private final DoubleBinaryOperator op;
Operation(String symbol, DoubleBinaryOperator op) {
this.symbol = symbol;
this.op = op;
}
@Override
public String toString() { return symbol; }
public double apply(double x, double y) {
return op.applyAsDouble(x, y);
}
}
请注意,我们使用表示枚举常量行为的 lambdas 的 DoubleBinaryOperator 接口。 这是 java.util.function 中许多预定义的函数接口之一(详见第 44 条)。 它表示一个函数,它接受两个 double 类型参数并返回 double 类型的结果。
看看基于 lambda 的 Operation 枚举,你可能会认为常量特定的方法体已经失去了它们的用处,但事实并非如此。 与方法和类不同,lambda 没有名称和文档; 如果计算不是自解释的,或者超过几行,则不要将其放入 lambda 表达式中。 一行代码对于 lambda 说是理想的,三行代码是合理的最大值。 如果违反这一规定,可能会严重损害程序的可读性。 如果一个 lambda 很长或很难阅读,要么找到一种方法来简化它或重构你的程序来消除它。 此外,传递给枚举构造方法的参数在静态上下文中进行评估。 因此,枚举构造方法中的 lambda 表达式不能访问枚举的实例成员。 如果枚举类型具有难以理解的常量特定行为,无法在几行内实现,或者需要访问实例属性或方法,那么常量特定的类主体仍然是行之有效的方法。
同样,你可能会认为匿名类在 lambda 时代已经过时了。 这更接近事实,但有些事情你可以用匿名类来做,而却不能用 lambdas 做。 Lambda 仅限于函数式接口。 如果你想创建一个抽象类的实例,你可以使用匿名类来实现,但不能使用 lambda。 同样,你可以使用匿名类来创建具有多个抽象方法的接口实例。 最后,lambda 不能获得对自身的引用。 在 lambda 中,this 关键字引用封闭实例,这通常是你想要的。 在匿名类中,this 关键字引用匿名类实例。 如果你需要从其内部访问函数对象,则必须使用匿名类。
Lambdas 与匿名类共享无法可靠地序列化和反序列化实现的属性。因此,应该很少 (如果有的话) 序列化一个 lambda(或一个匿名类实例)。 如果有一个想要进行序列化的函数对象,比如一个 Comparator,那么使用一个私有静态嵌套类的实例(详见第 24 条)。
综上所述,从 Java 8 开始,lambda 是迄今为止表示小函数对象的最佳方式。 除非必须创建非函数式接口类型的实例,否则不要使用匿名类作为函数对象。 另外,请记住,lambda 表达式使代表小函数对象变得如此简单,以至于它为功能性编程技术打开了一扇门,这些技术在 Java 中以前并不实用。
43. 方法引用优于 lambda 表达式
lambda 优于匿名类的主要优点是它更简洁。Java 提供了一种生成函数对象的方法,比 lambda 还要简洁,那就是:方法引用(method references)。下面是一段程序代码片段,它维护一个从任意键到整数值的映射。如果将该值解释为键的实例个数,则该程序是一个多重集合的实现。该代码的功能是,根据键找到整数值,然后在此基础上加 1:
map.merge(key, 1, (count, incr) -> count + incr);
请注意,此代码使用 merge 方法,该方法已添加到 Java 8 中的 Map 接口中。如果没有给定键的映射,则该方法只是插入给定值; 如果映射已经存在,则合并给定函数应用于当前值和给定值,并用结果覆盖当前值。 此代码表示 merge 方法的典型用例。
代码很好读,但仍然有一些样板的味道。 参数 count 和 incr 不会增加太多价值,并且占用相当大的空间。 真的,所有的 lambda 都告诉你函数返回两个参数的和。 从 Java 8 开始,Integer 类(和所有其他包装数字基本类型)提供了一个静态方法总和,和它完全相同。 我们可以简单地传递一个对这个方法的引用,并以较少的视觉混乱得到相同的结果:
map.merge(key, 1, Integer::sum);
方法的参数越多,你可以通过方法引用消除更多的样板。 然而,在一些 lambda 中,选择的参数名称提供了有用的文档,使得 lambda 比方法引用更具可读性和可维护性,即使 lambda 看起来更长。
只要方法引用能做的事情,就没有 lambda 不能完成的(只有一种情况例外 - 如果你好奇的话,参见 JLS,9.9-2)。 也就是说,使用方法引用通常会得到更短,更清晰的代码。 如果 lambda 变得太长或太复杂,它们也会给你一个结果:你可以从 lambda 中提取代码到一个新的方法中,并用对该方法的引用代替 lambda。 你可以给这个方法一个好名字,并把它文档记录下来。
如果你使用 IDE 编程,它将提供替换 lambda 的方法,并在任何地方使用方法引用。通常情况下,你应该接受这个提议。偶尔,lambda 会比方法引用更简洁。这种情况经常发生在方法与 lambda 相同的类中。例如,考虑这段代码,它被假定出现在一个名为 GoshThisClassNameIsHumongous 的类中:
service.execute(GoshThisClassNameIsHumongous::action);
这个 lambda 类似于等价于下面的代码:
service.execute(() -> action());
使用方法引用的代码段并不比使用 lambda 的代码片段更短也不清晰,所以优先选择后者。 在类似的代码行中,Function 接口提供了一个通用的静态工厂方法来返回标识函数 Function.identity()。 不使用这种方法,而是使用等效的 lambda 内联代码:x -> x,通常更短,更简洁。
许多方法引用是指静态方法,但有 4 种方法没有引用静态方法。 其中两个 Lambda 等式是特定(bound)和任意(unbound)对象方法引用。 在特定对象引用中,接收对象在方法引用中指定。 特定对象引用在本质上与静态引用类似:函数对象与引用的方法具有相同的参数。 在任意对象引用中,接收对象在应用函数对象时通过方法的声明参数之前的附加参数指定。 任意对象引用通常用作流管道(pipelines)中的映射和过滤方法(条目 45)。 最后,对于类和数组,有两种构造方法引用。 构造方法引用用作工厂对象。 下表总结了所有五种方法引用:
| 方法引用类型 Method Ref Type | 举例 Example | Lambda 等式 Lambda Equivalent |
|---|---|---|
| Static | Integer::parseInt | str -> Integer.parseInt(str) |
| Bound | Instant.now()::isAfter | Instant then = Instant.now(); t -> then.isAfter(t) |
| Unbound | String::toLowerCase | str -> str.toLowerCase() |
| Class Constructor | TreeMap<K, V>::new | () -> new TreeMap<K, V> |
| Array Constructor | int[]::new | len -> new int[len] |
总之,方法引用通常为 lambda 提供一个更简洁的选择。 如果方法引用看起来更简短更清晰,请使用它们;否则,还是坚持 lambda。
44. 优先使用标准的函数式接口
现在 Java 已经有 lambda 表达式,编写 API 的最佳实践已经发生了很大的变化。 例如,模板方法模式[Gamma95],其中一个子类重写原始方法以专门化其父类的行为,变得没有那么吸引人。 现代替代的选择是提供一个静态工厂或构造方法来接受函数对象以达到相同的效果。 通常地说,可以编写更多以函数对象为参数的构造方法和方法。 选择正确的函数式参数类型需要注意。
考虑 LinkedHashMap。 可以通过重写其受保护的 removeEldestEntry 方法将此类用作缓存,每次将新的 key 值加入到 map 时都会调用该方法。 当此方法返回 true 时,map 将删除传递给该方法的最久条目。 以下代码重写允许 map 增长到一百个条目,然后在每次添加新 key 值时删除最老的条目,并保留最近的一百个条目:
protected boolean removeEldestEntry(Map.Entry<K,V> eldest) {
return size() > 100;
}
这种技术很有效,但是你可以用 lambdas 做得更好。如果 LinkedHashMap 是现在编写的,那么它将有一个静态的工厂或构造方法来获取函数对象。查看 removeEldestEntry 方法的声明,你可能会认为函数对象应该接受一个 Map.Entry<K,V> 并返回一个布尔值,但是这并不完全是这样:removeEldestEntry 方法调用 size() 方法来获取条目的数量,因为 removeEldestEntry 是 map 上的一个实例方法。传递给构造方法的函数对象不是 map 上的实例方法,无法捕获,因为在调用其工厂或构造方法时 map 还不存在。因此,map 必须将自己传递给函数对象,函数对象把 map 以及最就的条目作为输入参数。如果要声明这样一个功能接口,应该是这样的:
// Unnecessary functional interface; use a standard one instead.
@FunctionalInterface
interface EldestEntryRemovalFunction<K,V>{
boolean remove(Map<K,V> map, Map.Entry<K,V> eldest);
}
这个接口可以正常工作,但是你不应该使用它,因为你不需要为此目的声明一个新的接口。 java.util.function 包提供了大量标准函数式接口供你使用。 如果其中一个标准函数式接口完成这项工作,则通常应该优先使用它,而不是专门构建的函数式接口。 这将使你的 API 更容易学习,通过减少其不必要概念,并将提供重要的互操作性好处,因为许多标准函数式接口提供了有用的默认方法。 例如,Predicate 接口提供了组合判断的方法。 在我们的 LinkedHashMap 示例中,标准的 BiPredicate<Map<K,V>, Map.Entry<K,V>> 接口应优先于自定义的 EldestEntryRemovalFunction 接口的使用。
在 java.util.Function 中有 43 个接口。不能指望全部记住它们,但是如果记住了六个基本接口,就可以在需要它们时派生出其余的接口。基本接口操作于对象引用类型。Operator 接口表示方法的结果和参数类型相同。Predicate 接口表示其方法接受一个参数并返回一个布尔值。Function 接口表示方法其参数和返回类型不同。Supplier 接口表示一个不接受参数和返回值 (或「供应」) 的方法。最后,Consumer 表示该方法接受一个参数而不返回任何东西,本质上就是使用它的参数。六种基本函数式接口概述如下:
| 接口 | 方法 | 示例 |
|---|---|---|
UnaryOperator<T> | T apply(T t) | String::toLowerCase |
BinaryOperator<T> | T apply(T t1, T t2) | BigInteger::add |
Predicate<T> | boolean test(T t) | Collection::isEmpty |
Function<T,R> | R apply(T t) | Arrays::asList |
Supplier<T> | T get() | Instant::now |
Consumer<T> | void accept(T t) | System.out::println |
在处理基本类型 int,long 和 double 的操作上,六个基本接口中还有三个变体。 它们的名字是通过在基本接口前加一个基本类型而得到的。 因此,例如,一个接受 int 的 Predicate 是一个 IntPredicate,而一个接受两个 long 值并返回一个 long 的二元运算符是一个 LongBinaryOperator。 除 Function 接口变体通过返回类型进行了参数化,其他变体类型都没有参数化。 例如,LongFunction<int[]> 使用 long 类型作为参数并返回了 int[] 类型。
Function 接口还有九个额外的变体,当结果类型为基本类型时使用。 源和结果类型总是不同,因为从类型到它自身的函数是 UnaryOperator。 如果源类型和结果类型都是基本类型,则使用带有 SrcToResult 的前缀 Function,例如 LongToIntFunction(六个变体)。如果源是一个基本类型,返回结果是一个对象引用,那么带有 ToObj 的前缀 Function,例如 DoubleToObjFunction (三种变体)。
有三个包含两个参数版本的基本功能接口,使它们有意义:BiPredicate <T,U>,BiFunction <T,U,R> 和 BiConsumer <T,U>。 也有返回三种相关基本类型的 BiFunction 变体:ToIntBiFunction <T,U>,ToLongBiFunction<T,U> 和 ToDoubleBiFunction <T,U>。Consumer 有两个变量,它们带有一个对象引用和一个基本类型:ObjDoubleConsumer <T>,ObjIntConsumer <T> 和 ObjLongConsumer <T>。 总共有九个两个参数版本的基本接口。
最后,还有一个 BooleanSupplier 接口,它是 Supplier 的一个变体,它返回布尔值。 这是任何标准函数式接口名称中唯一明确提及的布尔类型,但布尔返回值通过 Predicate 及其四种变体形式支持。 前面段落中介绍的 BooleanSupplier 接口和 42 个接口占所有四十三个标准功能接口。 无可否认,这是非常难以接受的,并且不是非常正交的。 另一方面,你所需要的大部分功能接口都是为你写的,而且它们的名字是经常性的,所以在你需要的时候不应该有太多的麻烦。
大多数标准函数式接口仅用于提供对基本类型的支持。 不要试图使用基本的函数式接口来装箱基本类型的包装类而不是基本类型的函数式接口。 虽然它起作用,但它违反了第 61 条中的建议:「优先使用基本类型而不是基本类型的包装类」。使用装箱基本类型的包装类进行批量操作的性能后果可能是致命的。
现在你知道你应该通常使用标准的函数式接口来优先编写自己的接口。 但是,你应该什么时候写自己的接口? 当然,如果没有一个标准模块能够满足您的需求,例如,如果需要一个带有三个参数的 Predicate,或者一个抛出检查异常的 Predicate,那么需要编写自己的代码。 但有时候你应该编写自己的函数式接口,即使与其中一个标准的函数式接口的结构相同。
考虑我们的老朋友 Comparator <T>,它的结构与 ToIntBiFunction <T, T> 接口相同。 即使将前者添加到类库时后者的接口已经存在,使用它也是错误的。 Comparator 值得拥有自己的接口有以下几个原因。 首先,它的名称每次在 API 中使用时都会提供优秀的文档,并且使用了很多。 其次,Comparator 接口对构成有效实例的构成有强大的要求,这些要求构成了它的普遍契约。 通过实现接口,就要承诺遵守契约。 第三,接口配备很多了有用的默认方法来转换和组合多个比较器。
如果需要一个函数式接口与 Comparator 共享以下一个或多个特性,应该认真考虑编写一个专用函数式接口,而不是使用标准函数式接口:
- 它将被广泛使用,并且可以从描述性名称中受益。
- 它拥有强大的契约。
- 它会受益于自定义的默认方法。
如果选择编写你自己的函数式接口,请记住它是一个接口,因此应非常小心地设计(详见第 21 条)。
请注意,EldestEntryRemovalFunction 接口(第 199 页)标有 @FunctionalInterface 注解。 这种注解在类型类似于 @Override。 这是一个程序员意图的陈述,它有三个目的:它告诉读者该类和它的文档,该接口是为了实现 lambda 表达式而设计的;它使你保持可靠,因为除非只有一个抽象方法,否则接口不会编译; 它可以防止维护人员在接口发生变化时不小心地将抽象方法添加到接口中。 始终使用 @FunctionalInterface 注解标注你的函数式接口。
最后一点应该是关于在 api 中使用函数接口的问题。不要提供具有多个重载的方法,这些重载在相同的参数位置上使用不同的函数式接口,如果这样做可能会在客户端中产生歧义。这不仅仅是一个理论问题。ExecutorService 的 submit 方法可以采用 Callable<T> 或 Runnable 接口,并且可以编写需要强制类型转换以指示正确的重载的客户端程序(详见第 52 条)。避免此问题的最简单方法是不要编写在相同的参数位置中使用不同函数式接口的重载。这是条目 52 中建议的一个特例,「明智地使用重载」。
总之,现在 Java 已经有了 lambda 表达式,因此必须考虑 lambda 表达式来设计你的 API。 在输入上接受函数式接口类型并在输出中返回它们。 一般来说,最好使用 java.util.function.Function 中提供的标准接口,但请注意,在相对罕见的情况下,最好编写自己的函数式接口。
45. 明智审慎地使用 Stream
在 Java 8 中添加了 Stream API,以简化串行或并行执行批量操作的任务。 该 API 提供了两个关键的抽象:流 (Stream),表示有限或无限的数据元素序列,以及流管道 (stream pipeline),表示对这些元素的多级计算。 Stream 中的元素可以来自任何地方。 常见的源包括集合、数组、文件、正则表达式模式匹配器、伪随机数生成器和其他流。 流中的数据元素可以是对象引用或基本类型。 支持三种基本类型:int,long 和 double。
Stream pipeline 由 Source stream(源流) 的零或多个中间操作(intermediate operations)和一个终结操作( terminal operation)组成。每个中间操作都以某种方式转换流,例如将每个元素映射到该元素的函数,或过滤掉所有不满足某些条件的元素。中间操作都将一个流转换为另一个流,其元素类型可能与输入流相同或不同。终结操作对流执行最后一次中间操作产生的最终计算,例如将其元素存储到集合中、返回某个元素或打印其所有元素。
Stream pipeline 通常是惰性(lazily)计算求值:直到终结操作被调用后才开始计算,而为了完成终结操作而不需要的数据元素永远不会被计算出来。 这种惰性计算求值的方式,使得无限流成为可能。 请注意,没有终结操作的 Stream pipine 是一个静默无操作的指令,所以不要忘记包含一个终止操作。
Stream API 流式的(fluent):它设计允许所有组成 pipeline 的调用被链接到一个表达式中。事实上,多个管道可以链接在一起形成一个表达式。
默认情况下,流管道会按顺序(sequentially)运行。 要使管道并行执行,只需要在管道中的任何流上调用 parallel()方法一样简单,但是通常不建议这么做(详见第 48 条)。
Stream API 具有足够的通用性,实际上任何计算都可以使用 Stream 执行,但是「可以」,并不意味着应该这样做。如果使用得当,流可以使程序更短更清晰;如果使用不当,它们会使程序难以阅读和维护。对于何时使用流没有硬性的规则,但是有一些启发。
考虑以下程序,该程序从字典文件中读取单词并打印其大小符合用户指定的最小值的所有变位词(anagram)组。如果两个单词由长度相通,不同顺序的相同字母组成,则它们是变位词。程序从用户指定的字典文件中读取每个单词并将单词放入 map 对象中。map 对象的键是按照字母排序的单词,因此「staple」的键是「aelpst」,「petals」的键也是「aelpst」:这两个单词就是同位词,所有的同位词共享相同的依字母顺序排列的形式(或称之为 alphagram)。map 对象的值是包含共享字母顺序形式的所有单词的列表。 处理完字典文件后,每个列表都是一个完整的同位词组。然后程序遍历 map 对象的 values() 的视图并打印每个大小符合阈值的列表:
// Prints all large anagram groups in a dictionary iteratively
public class Anagrams {
public static void main(String[] args) throws IOException {
File dictionary = new File(args[0]);
int minGroupSize = Integer.parseInt(args[1]);
Map<String, Set<String>> groups = new HashMap<>();
try (Scanner s = new Scanner(dictionary)) {
while (s.hasNext()) {
String word = s.next();
groups.computeIfAbsent(alphabetize(word),
(unused) -> new TreeSet<>()).add(word);
}
}
for (Set<String> group : groups.values())
if (group.size() >= minGroupSize)
System.out.println(group.size() + ": " + group);
}
private static String alphabetize(String s) {
char[] a = s.toCharArray();
Arrays.sort(a);
return new String(a);
}
}
这个程序中的一个步骤值得注意。将每个单词插入到 map 中(以粗体显示)中使用了 computeIfAbsent 方法,该方法是在 Java 8 中添加的。这个方法在 map 中查找一个键:如果键存在,该方法只返回与其关联的值。如果没有,该方法通过将给定的函数对象应用于键来计算值,将该值与键关联,并返回计算值。computeIfAbsent 方法简化了将多个值与每个键关联的 map 的实现。
现在考虑以下程序,它也能解决同样的问题,但大量过度使用了流。 请注意,整个程序(打开字典文件的代码除外)包含在单个表达式中。 在单独的表达式中打开字典文件的唯一原因是允许使用 try-with-resources 语句,该语句确保关闭字典文件:
// Overuse of streams - don't do this!
public class Anagrams {
public static void main(String[] args) throws IOException {
Path dictionary = Paths.get(args[0]);
int minGroupSize = Integer.parseInt(args[1]);
try (Stream<String> words = Files.lines(dictionary)) {
words.collect(
groupingBy(word -> word.chars().sorted()
.collect(StringBuilder::new,
(sb, c) -> sb.append((char) c),
StringBuilder::append).toString()))
.values().stream()
.filter(group -> group.size() >= minGroupSize)
.map(group -> group.size() + ": " + group)
.forEach(System.out::println);
}
}
}
如果你发现这段代码难以阅读,不要担心;你不是一个人。它更短,但是可读性也更差,尤其是对于那些不擅长使用流的程序员来说。过度使用流使程序难于阅读和维护。
幸运的是,有一个折中的办法。下面的程序解决了同样的问题,使用流而不过度使用它们。其结果是一个比原来更短更清晰的程序:
// Tasteful use of streams enhances clarity and conciseness
public class Anagrams {
public static void main(String[] args) throws IOException {
Path dictionary = Paths.get(args[0]);
int minGroupSize = Integer.parseInt(args[1]);
try (Stream<String> words = Files.lines(dictionary)) {
words.collect(groupingBy(word -> alphabetize(word)))
.values().stream()
.filter(group -> group.size() >= minGroupSize)
.forEach(g -> System.out.println(g.size() + ": " + g));
}
}
// alphabetize method is the same as in original version
}
即使以前很少接触流,这个程序也不难理解。它在一个 try-with-resources 块中打开字典文件,获得一个由文件中的所有行组成的流。流变量命名为 words,表示流中的每个元素都是一个单词。此流上的管道没有中间操作;它的终结操作将所有单词收集到个 map 对象中,按照字母排列的形式对单词进行分组 (第 46 项)。这与之前两个版本的程序构造的 map 完全相同。然后在 map 的 values() 视图上打开一个新的流 List<String>。当然,这个流中的元素是同位词组。对流进行过滤,以便忽略大小小于 minGroupSize 的所有组,最后由终结操作 forEach 打印剩下的同位词组。
请注意,仔细选择 lambda 参数名称。 上面程序中参数 g 应该真正命名为 group,但是生成的代码行对于本书来说太宽了。 在没有显式类型的情况下,仔细命名 lambda 参数对于流管道的可读性至关重要。
另请注意,单词字母化是在单独的 alphabetize 方法中完成的。 这通过提供操作名称并将实现细节保留在主程序之外来增强可读性。 使用辅助方法对于流管道中的可读性比在迭代代码中更为重要,因为管道缺少显式类型信息和命名临时变量。
字母顺序方法可以使用流重新实现,但基于流的字母顺序方法本来不太清楚,更难以正确编写,并且可能更慢。 这些缺陷是由于 Java 缺乏对原始字符流的支持(这并不意味着 Java 应该支持 char 流;这样做是不可行的)。 要演示使用流处理 char 值的危害,请考虑以下代码:
"Hello world!".chars().forEach(System.out::print);
你可能希望它打印 Hello world!,但如果运行它,发现它打印 721011081081113211911111410810033。这是因为 “Hello world!”.chars() 返回的流的元素不是 char 值,而是 int 值,因此调用了 print 的 int 重载。无可否认,一个名为 chars 的方法返回一个 int 值流是令人困惑的。可以通过强制调用正确的重载来修复该程序:
**但理想情况下,应该避免使用流来处理 char 值。 **当开始使用流时,你可能会感到想要将所有循环语句转换为流方式的冲动,但请抵制这种冲动。尽管这是可能的,但可能会损害代码库的可读性和可维护性。 通常,使用流和迭代的某种组合可以最好地完成中等复杂的任务,如上面的 Anagrams 程序所示。 因此,重构现有代码以使用流,并仅在有意义的情况下在新代码中使用它们。
如本项目中的程序所示,流管道使用函数对象 (通常为 lambdas 或方法引用) 表示重复计算,而迭代代码使用代码块表示重复计算。从代码块中可以做一些从函数对象中不能做的事情:
- 从代码块中,可以读取或修改范围内的任何局部变量; 从 lambda 中,只能读取最终或有效的最终变量[JLS 4.12.4],并且无法修改任何局部变量。
- 从代码块中,可以从封闭方法返回,中断或继续封闭循环,或抛出声明此方法的任何已检查异常; 从一个 lambda 你不能做这些事情。
如果使用这些技术最好地表达计算,那么它可能不是流的良好匹配。 相反,流可以很容易地做一些事情:
- 统一转换元素序列
- 过滤元素序列
- 使用单个操作组合元素序列 (例如添加、连接或计算最小值)
- 将元素序列累积到一个集合中,可能通过一些公共属性将它们分组
- 在元素序列中搜索满足某些条件的元素
如果使用这些技术最好地表达计算,那么使用流是这些场景很好的候选者。
对于流来说,很难做到的一件事是同时访问管道的多个阶段中的相应元素:一旦将值映射到其他值,原始值就会丢失。一种解决方案是将每个值映射到一个包含原始值和新值的 pair 对象,但这不是一个令人满意的解决方案,尤其是在管道的多个阶段需要一对对象时更是如此。生成的代码既混乱又冗长,破坏了流的主要用途。当它适用时,一个更好的解决方案是在需要访问早期阶段值时转换映射。
例如,让我们编写一个程序来打印前 20 个梅森素数 (Mersenne primes)。 梅森素数是一个 2p − 1 形式的数字。如果 p 是素数,相应的梅森数可能是素数; 如果是这样的话,那就是梅森素数。 作为我们管道中的初始流,我们需要所有素数。 这里有一个返回该(无限)流的方法。 我们假设使用静态导入来轻松访问 BigInteger 的静态成员:
static Stream<BigInteger> primes() {
return Stream.iterate(TWO, BigInteger::nextProbablePrime);
}
方法的名称(primes)是一个复数名词,描述了流的元素。 强烈建议所有返回流的方法使用此命名约定,因为它增强了流管道的可读性。 该方法使用静态工厂 Stream.iterate,它接受两个参数:流中的第一个元素,以及从前一个元素生成流中的下一个元素的函数。 这是打印前 20 个梅森素数的程序:
public static void main(String[] args) {
primes().map(p -> TWO.pow(p.intValueExact()).subtract(ONE))
.filter(mersenne -> mersenne.isProbablePrime(50))
.limit(20)
.forEach(System.out::println);
}
这个程序是上面的梅森描述的直接编码:它从素数开始,计算相应的梅森数,过滤掉除素数之外的所有数字(幻数 50 控制概率素性测试 the magic number 50 controls the probabilistic primality test),将得到的流限制为 20 个元素, 并打印出来。
现在假设我们想在每个梅森素数前面加上它的指数 §,这个值只出现在初始流中,因此在终结操作中不可访问,而终结操作将输出结果。幸运的是通过反转第一个中间操作中发生的映射,可以很容易地计算出 Mersenne 数的指数。 指数是二进制表示中的位数,因此该终结操作会生成所需的结果:
.forEach(mp -> System.out.println(mp.bitLength() + ": " + mp));
有很多任务不清楚是使用流还是迭代。例如,考虑初始化一副新牌的任务。假设 Card 是一个不可变的值类,它封装了 Rank 和 Suit,它们都是枚举类型。这个任务代表任何需要计算可以从两个集合中选择的所有元素对。数学家们称它为两个集合的笛卡尔积。下面是一个迭代实现,它有一个嵌套的 for-each 循环,你应该非常熟悉:
// Iterative Cartesian product computation
private static List<Card> newDeck() {
List<Card> result = new ArrayList<>();
for (Suit suit : Suit.values())
for (Rank rank : Rank.values())
result.add(new Card(suit, rank));
return result;
}
下面是一个基于流的实现,它使用了中间操作 flatMap 方法。这个操作将一个流中的每个元素映射到一个流,然后将所有这些新流连接到一个流 (或展平它们)。注意,这个实现包含一个嵌套的 lambda 表达式(rank -> new Card(suit, rank)):
// Stream-based Cartesian product computation
private static List<Card> newDeck() {
return Stream.of(Suit.values())
.flatMap(suit ->
Stream.of(Rank.values())
.map(rank -> new Card(suit, rank)))
.collect(toList());
}
newDeck 的两个版本中哪一个更好? 它归结为个人偏好和你的编程的环境。 第一个版本更简单,也许感觉更自然。 大部分 Java 程序员将能够理解和维护它,但是一些程序员会对第二个(基于流的)版本感觉更舒服。 如果对流和函数式编程有相当的精通,那么它会更简洁,也不会太难理解。 如果不确定自己喜欢哪个版本,则迭代版本可能是更安全的选择。 如果你更喜欢流的版本,并且相信其他使用该代码的程序员会与你共享你的偏好,那么应该使用它。
总之,有些任务最好使用流来完成,有些任务最好使用迭代来完成。将这两种方法结合起来,可以最好地完成许多任务。对于选择使用哪种方法进行任务,没有硬性规定,但是有一些有用的启发式方法。在许多情况下,使用哪种方法将是清楚的;在某些情况下,则不会很清楚。如果不确定一个任务是通过流还是迭代更好地完成,那么尝试这两种方法,看看哪一种效果更好。
46. 优先考虑流中无副作用的函数
如果你是一个刚开始使用流的新手,那么很难掌握它们。仅仅将计算表示为流管道是很困难的。当你成功时,你的程序将运行,但对你来说可能没有意识到任何好处。流不仅仅是一个 API,它是基于函数式编程的范式(paradigm)。为了获得流提供的可表达性、速度和某些情况下的并行性,你必须采用范式和 API。
流范式中最重要的部分是将计算结构化为一系列转换,其中每个阶段的结果尽可能接近前一阶段结果的纯函数(pure function)。 纯函数的结果仅取决于其输入:它不依赖于任何可变状态,也不更新任何状态。 为了实现这一点,你传递给流操作的任何函数对象(中间操作和终结操作)都应该没有副作用。
有时,可能会看到类似于此代码片段的流代码,该代码构建了文本文件中单词的频率表:
// Uses the streams API but not the paradigm--Don't do this!
Map<String, Long> freq = new HashMap<>();
try (Stream<String> words = new Scanner(file).tokens()) {
words.forEach(word -> {
freq.merge(word.toLowerCase(), 1L, Long::sum);
});
}
这段代码出了什么问题? 毕竟,它使用了流,lambdas 和方法引用,并得到正确的答案。 简而言之,它根本不是流代码; 它是伪装成流代码的迭代代码。 它没有从流 API 中获益,并且它比相应的迭代代码更长,更难读,并且更难于维护。 问题源于这样一个事实:这个代码在一个终结操作 forEach 中完成所有工作,使用一个改变外部状态(频率表)的 lambda。forEach 操作除了表示由一个流执行的计算结果外,什么都不做,这是「代码中的臭味」,就像一个改变状态的 lambda 一样。那么这段代码应该是什么样的呢?
// Proper use of streams to initialize a frequency table
Map<String, Long> freq;
try (Stream<String> words = new Scanner(file).tokens()) {
freq = words
.collect(groupingBy(String::toLowerCase, counting()));
}
此代码段与前一代码相同,但正确使用了流 API。 它更短更清晰。 那么为什么有人会用其他方式写呢? 因为它使用了他们已经熟悉的工具。 Java 程序员知道如何使用 for-each 循环,而 forEach 终结操作是类似的。 但 forEach 操作是终端操作中最不强大的操作之一,也是最不友好的流操作。 它是明确的迭代,因此不适合并行化。 forEach 操作应仅用于报告流计算的结果,而不是用于执行计算。 有时,将 forEach 用于其他目的是有意义的,例如将流计算的结果添加到预先存在的集合中。
改进后的代码使用了收集器(collector),这是使用流必须学习的新概念。Collectors 的 API 令人生畏:它有 39 个方法,其中一些方法有多达 5 个类型参数。好消息是,你可以从这个 API 中获得大部分好处,而不必深入研究它的全部复杂性。对于初学者来说,可以忽略收集器接口,将收集器看作是封装缩减策略(reduction strategy)的不透明对象。在此上下文中,reduction 意味着将流的元素组合为单个对象。 收集器生成的对象通常是一个集合(它代表名称收集器)。
将流的元素收集到真正的集合中的收集器非常简单。有三个这样的收集器:toList()、toSet() 和 toCollection(collectionFactory)。它们分别返回集合、列表和程序员指定的集合类型。有了这些知识,我们就可以编写一个流管道从我们的频率表中提取出现频率前 10 个单词的列表。
// Pipeline to get a top-ten list of words from a frequency table
List<String> topTen = freq.keySet().stream()
.sorted(comparing(freq::get).reversed())
.limit(10)
.collect(toList());
注意,我们没有对 toList 方法的类收集器进行限定。静态导入收集器的所有成员是一种惯例和明智的做法,因为它使流管道更易于阅读。
这段代码中唯一比较棘手的部分是我们把 comparing(freq::get).reverse() 传递给 sort 方法。comparing 是一种比较器构造方法(详见第 14 条),它具有一个 key 的提取方法。该函数接受一个单词,而“提取”实际上是一个表查找:绑定方法引用 freq::get 在 frequency 表中查找单词,并返回单词出现在文件中的次数。最后,我们在比较器上调用 reverse 方法,因此我们将单词从最频繁到最不频繁进行排序。然后,将流限制为 10 个单词并将它们收集到一个列表中就很简单了。
前面的代码片段使用 Scanner 的 stream 方法在 scanner 实例上获取流。这个方法是在 Java 9 中添加的。如果正在使用较早的版本,可以使用类似于条目 47 中 (streamOf(Iterable<E>)) 的适配器将实现了 Iterator 的 scanner 序转换为流。
那么收集器中的其他 36 种方法呢?它们中的大多数都是用于将流收集到 map 中的,这比将流收集到真正的集合中要复杂得多。每个流元素都与一个键和一个值相关联,多个流元素可以与同一个键相关联。
最简单的映射收集器是 toMap(keyMapper、valueMapper),它接受两个函数,一个将流元素映射到键,另一个映射到值。在条目 34 中的 fromString 实现中,我们使用这个收集器从 enum 的字符串形式映射到 enum 本身:
// Using a toMap collector to make a map from string to enum
private static final Map<String, Operation> stringToEnum =
Stream.of(values()).collect(
toMap(Object::toString, e -> e));
如果流中的每个元素都映射到唯一键,则这种简单的 toMap 形式是完美的。 如果多个流元素映射到同一个键,则管道将以 IllegalStateException 终止。
toMap 更复杂的形式,以及 groupingBy 方法,提供了处理此类冲突 (collisions) 的各种方法。一种方法是向 toMap 方法提供除键和值映射器(mappers)之外的 merge 方法。merge 方法是一个 BinaryOperator<V>,其中 V是 map 的值类型。与键关联的任何附加值都使用 merge 方法与现有值相结合,因此,例如,如果 merge 方法是乘法,那么最终得到的结果是是值 mapper 与键关联的所有值的乘积。
toMap 的三个参数形式对于从键到与该键关联的选定元素的映射也很有用。例如,假设我们有一系列不同艺术家(artists)的唱片集(albums),我们想要一张从唱片艺术家到最畅销专辑的 map。这个收集器将完成这项工作。
// Collector to generate a map from key to chosen element for key
Map<Artist, Album> topHits = albums.collect(
toMap(Album::artist, a->a, maxBy(comparing(Album::sales))));
请注意,比较器使用静态工厂方法 maxBy,它是从 BinaryOperator 静态导入的。 此方法将 Comparator<T> 转换为 BinaryOperator<T>,用于计算指定比较器隐含的最大值。 在这种情况下,比较器由比较器构造方法 comparing 返回,它采用 key 提取器函数 Album::sales。 这可能看起来有点复杂,但代码可读性很好。 简而言之,它说,「将专辑(albums)流转换为地 map,将每位艺术家(artist)映射到销售量最佳的专辑。」这与问题陈述出奇得接近。
toMap 的三个参数形式的另一个用途是产生一个收集器,当发生冲突时强制执行 last-write-wins 策略。 对于许多流,结果是不确定的,但如果映射函数可能与键关联的所有值都相同,或者它们都是可接受的,则此收集器的行为可能正是您想要的:
// Collector to impose last-write-wins policy
toMap(keyMapper, valueMapper, (oldVal, newVal) ->newVal)
toMap 的第三个也是最后一个版本采用第四个参数,它是一个 map 工厂,用于指定特定的 map 实现,例如 EnumMap 或 TreeMap。
toMap 的前三个版本也有变体形式,名为 toConcurrentMap,它们并行高效运行并生成 ConcurrentHashMap 实例。
除了 toMap 方法之外,Collectors API 还提供了 groupingBy 方法,该方法返回收集器以生成基于分类器函数 (classifier function)将元素分组到类别中的 map。 分类器函数接受一个元素并返回它所属的类别。 此类别来用作元素的 map 的键。 groupingBy 方法的最简单版本仅采用分类器并返回一个 map,其值是每个类别中所有元素的列表。 这是我们在条目 45 中的 Anagram 程序中使用的收集器,用于生成从按字母顺序排列的单词到单词列表的 map:
Map<String, Long> freq = words
.collect(groupingBy(String::toLowerCase, counting()));
groupingBy 的第三个版本允许指定除 downstream 收集器之外的 map 工厂。 请注意,这种方法违反了标准的可伸缩参数列表模式 (standard telescoping argument list pattern):mapFactory 参数位于 downStream 参数之前,而不是之后。 此版本的 groupingBy 可以控制包含的 map 以及包含的集合,因此,例如,可以指定一个收集器,它返回一个 TreeMap,其值是 TreeSet。
groupingByConcurrent 方法提供了 groupingBy 的所有三个重载的变体。 这些变体并行高效运行并生成 ConcurrentHashMap 实例。 还有一个很少使用的 grouping 的亲戚称为 partitioningBy。 代替分类器方法,它接受 predicate 并返回其键为布尔值的 map。 此方法有两种重载,除了 predicate 之外,其中一种方法还需要 downstream 收集器。
通过 counting 方法返回的收集器仅用作下游收集器。 Stream 上可以通过 count 方法直接使用相同的功能,因此没有理由说 collect(counting())。 此属性还有十五种收集器方法。 它们包括九个方法,其名称以 summing,averaging 和 summarizing 开头(其功能在相应的原始流类型上可用)。 它们还包括 reduce 方法的所有重载,以及 filter,mapping,flatMapping 和 collectingAndThen 方法。 大多数程序员可以安全地忽略大多数这些方法。 从设计的角度来看,这些收集器代表了尝试在收集器中部分复制流的功能,以便下游收集器可以充当「迷你流(ministreams)」。
我们还有三种收集器方法尚未提及。 虽然他们在收 Collectors 类中,但他们不涉及集合。 前两个是 minBy 和 maxBy,它们取比较器并返回比较器确定的流中的最小或最大元素。 它们是 Stream 接口中 min 和 max 方法的次要总结,是 BinaryOperator 中类似命名方法返回的二元运算符的类似收集器。 回想一下,我们在最畅销的专辑中使用了 BinaryOperator.maxBy 方法。
最后的 Collectors 中方法是 join,它仅对 CharSequence 实例(如字符串)的流进行操作。 在其无参数形式中,它返回一个简单地连接元素的收集器。 它的一个参数形式采用名为 delimiter 的单个 CharSequence 参数,并返回一个连接流元素的收集器,在相邻元素之间插入分隔符。 如果传入逗号作为分隔符,则收集器将返回逗号分隔值字符串(但请注意,如果流中的任何元素包含逗号,则字符串将不明确)。 除了分隔符之外,三个参数形式还带有前缀和后缀。 生成的收集器会生成类似于打印集合时获得的字符串,例如[came, saw, conquered]。
总之,编程流管道的本质是无副作用的函数对象。 这适用于传递给流和相关对象的所有许多函数对象。 终结操作 forEach 仅应用于报告流执行的计算结果,而不是用于执行计算。 为了正确使用流,必须了解收集器。 最重要的收集器工厂是 toList,toSet,toMap,groupingBy 和 join。
47. 优先使用 Collection 而不是 Stream 来作为方法的返回类型
许多方法返回元素序列(sequence)。在 Java 8 之前,通常方法的返回类型是 Collection,Set 和 List 这些接口;还包括 Iterable 和数组类型。通常,很容易决定返回哪一种类型。规范(norm)是返回 Collection 接口。如果该方法仅用于启用 for-each 循环,或者返回的序列不能实现某些 Collection 方法 (通常是 contains(Object)),则使用迭代(Iterable)接口。如果返回的元素是基本类型或有严格的性能要求,则使用数组。在 Java 8 中,将流(Stream)添加到平台中,这使得为序列返回方法选择适当的返回类型的任务变得非常复杂。
你可能听说过,流现在是返回元素序列的明显的选择,但是正如第 45 条所讨论的,流不会使迭代过时:编写好的代码需要明智地将流和迭代结合起来。如果一个 API 只返回一个流,并且一些用户想用 for-each 循环遍历返回的序列,那么这些用户肯定会感到不安。尤其令人沮丧的是, Stream 接口有一个和 Iterable 接口中一样的抽象方法,并且 Stream 的方法规范与 Iterable 中的一致。阻止程序员使用 for-each 循环在流上迭代的唯一原因是 Stream 无法继承 Iterable。
遗憾的是,这个问题没有好的解决方法。 乍一看,似乎可以将方法引用传递给 Stream 的 iterator 方法。 结果代码可能有些乱,但并非不合理:
// Won't compile, due to limitations on Java's type inference
for (ProcessHandle ph : ProcessHandle.allProcesses()::iterator) {
// Process the process
}
不幸的是,如果你试图编译这段代码,会得到一个错误信息:
Test.java:6: error: method reference not expected here
for (ProcessHandle ph : ProcessHandle.allProcesses()::iterator) {
为了使代码编译,必须将方法引用强制转换为对应参数的 Iterable 类型:
// Hideous workaround to iterate over a stream
for (ProcessHandle ph : (Iterable<ProcessHandle>)ProcessHandle.allProcesses()::iterator)
此代码可以工作,但在实践中使用它太乱。 更好的解决方法是使用适配器方法。 JDK 没有提供这样的方法,但是使用上面的代码片段中的相同技术,很容易编写一个方法。 请注意,在适配器方法中不需要强制转换,因为 Java 的类型推断在此上下文中能够正常工作:
// Adapter from Stream<E> to Iterable<E>
public static <E> Iterable<E> iterableOf(Stream<E> stream) {
return stream::iterator;
}
通过这个适配器,你可以使用 for-each 语句迭代任何流:
for (ProcessHandle p : iterableOf(ProcessHandle.allProcesses())) {
// Process the process
}
注意,第 34 条中的 Anagrams 程序的流版本使用 Files.lines 方法读取字典,而迭代版本使用了 scanner。Files.lines 方法优于 scanner,scanner 在读取文件时无声地吞噬所有异常。理想情况下,我们也会在迭代版本中使用 Files.lines。如果 API 只提供对序列的流访问,而程序员希望使用 for-each 语句遍历序列,那么他们就要做出这种妥协。
相反,如果一个程序员想要使用流管道来处理一个序列,那么一个只提供 Iterable 的 API 会让他感到不安。JDK 同样没有提供适配器,但是编写这个适配器非常简单:
// Adapter from Iterable<E> to Stream<E>
public static <E> Stream<E> streamOf(Iterable<E> iterable) {
return StreamSupport.stream(iterable.spliterator(), false);
}
如果你正在编写一个返回对象序列的方法,并且它只会在流管道中使用,那么当然可以自由地返回流。类似地,返回仅用于迭代的序列的方法应该返回一个 Iterable。但是如果你写一个公共 API,它返回一个序列,你应该为用户提供哪些想写流管道,哪些想写 for-each 语句,除非你有充分的理由相信大多数用户想要使用相同的机制。
Collection 接口是 Iterable 的子类型,并且具有 stream 方法,因此它可以同时提供迭代和流访问的能力。 因此,Collection 或适当的子类型通常是公共序列返回方法的最佳返回类型。 数组也使用 Arrays.asList 和 Stream.of 方法提供简单的迭代和流访问能力。 如果返回的序列小到足以容易地放入内存中,那么最好返回一个标准集合实现,例如 ArrayList 或 HashSet。 但是不要在只是为了将它作为集合返回,而在内存中存储很大的序列。
如果你需要返回一很大但可以简洁地表示的序列,请考虑实现一个专用集合。 例如,假设返回给定集合的幂集(power set:就是原集合中所有的子集(包括全集和空集)构成的集族),该集包含其所有子集。 {a,b,c} 的幂集为 {{},{a},{b},{c},{a,b},{a,c},{b,c},{a,b, c}}。 如果一个集合具有 n 个元素,则幂集具有 2n 个。 因此,你甚至不应考虑将幂集存储在标准集合实现中。 但是,在 AbstractList 的帮助下,很容易为此实现自定义集合。
诀窍是使用幂集中每个元素的索引作为位向量(bit vector),其中索引中的第 n 位指示源集合中是否存在第 n 个元素。 本质上,从 0 到 2n-1 的二进制数和 n 个元素集和的幂集之间存在自然映射。 这是代码:
// Returns the power set of an input set as custom collection
public class PowerSet {
public static final <E> Collection<Set<E>> of(Set<E> s) {
List<E> src = new ArrayList<>(s);
if (src.size() > 30)
throw new IllegalArgumentException("Set too big " + s);
return new AbstractList<Set<E>>() {
@Override
public int size() {
return 1 << src.size(); // 2 to the power srcSize
}
@Override
public boolean contains(Object o) {
return o instanceof Set && src.containsAll((Set)o);
}
@Override
public Set<E> get(int index) {
Set<E> result = new HashSet<>();
for (int i = 0; index != 0; i++, index >>= 1)
if ((index & 1) == 1)
result.add(src.get(i));
return result;
}
};
}
}
请注意,如果输入集合超过 30 个元素,则 PowerSet.of 方法会引发异常。 这突出了使用 Collection 作为返回类型而不是 Stream 或 Iterable 的缺点:Collection 有 int 返回类型的 size 的方法,该方法将返回序列的长度限制为 Integer.MAX_VALUE 或 231-1。Collection 规范允许 size 方法返回 231 - 1,如果集合更大,甚至无限,但这不是一个完全令人满意的解决方案。
为了在 AbstractCollection 上编写 Collection 实现,除了 Iterable 所需的方法之外,只需要实现两种方法:contains 和 size。 通常,编写这些方法的有效实现很容易。 如果不可行,可能是因为在迭代发生之前未预先确定序列的内容,返回 Stream 还是 Iterable 的,无论哪种感觉更自然。 如果选择,可以使用两种不同的方法分别返回。
有时,你会仅根据实现的易用性选择返回类型。例如,假设希望编写一个方法,该方法返回输入列表的所有 (连续的) 子列表。生成这些子列表并将它们放到标准集合中只需要三行代码,但是保存这个集合所需的内存是源列表大小的二次方。虽然这没有指数幂集那么糟糕,但显然是不可接受的。实现自定义集合 (就像我们对幂集所做的那样) 会很乏味,因为 JDK 缺少一个框架 Iterator 实现来帮助我们。
然而,实现输入列表的所有子列表的流是直截了当的,尽管它确实需要一点的洞察力(insight)。 让我们调用一个子列表,该子列表包含列表的第一个元素和列表的前缀。 例如,(a,b,c)的前缀是(a),(a,b)和(a,b,c)。 类似地,让我们调用包含后缀的最后一个元素的子列表,因此(a,b,c)的后缀是(a,b,c),(b,c)和(c)。 洞察力是列表的子列表只是前缀的后缀(或相同的后缀的前缀)和空列表。 这一观察直接展现了一个清晰,合理简洁的实现:
// Returns a stream of all the sublists of its input list
public class SubLists {
public static <E> Stream<List<E>> of(List<E> list) {
return Stream.concat(Stream.of(Collections.emptyList()),
prefixes(list).flatMap(SubLists::suffixes));
}
private static <E> Stream<List<E>> prefixes(List<E> list) {
return IntStream.rangeClosed(1, list.size())
.mapToObj(end -> list.subList(0, end));
}
private static <E> Stream<List<E>> suffixes(List<E> list) {
return IntStream.range(0, list.size())
.mapToObj(start -> list.subList(start, list.size()));
}
}
请注意,Stream.concat 方法用于将空列表添加到返回的流中。 还有,flatMap 方法(条目 45)用于生成由所有前缀的所有后缀组成的单个流。 最后,通过映射 IntStream.range 和 IntStream.rangeClosed 返回的连续 int 值流来生成前缀和后缀。这个习惯用法,粗略地说,流等价于整数索引上的标准 for 循环。因此,我们的子列表实现似于明显的嵌套 for 循环:
for (int start = 0; start < src.size(); start++)
for (int end = start + 1; end <= src.size(); end++)
System.out.println(src.subList(start, end));
可以将这个 for 循环直接转换为流。结果比我们以前的实现更简洁,但可能可读性稍差。它类似于条目 45 中的笛卡尔积的使用流的代码:
// Returns a stream of all the sublists of its input list
public static <E> Stream<List<E>> of(List<E> list) {
return IntStream.range(0, list.size())
.mapToObj(start ->
IntStream.rangeClosed(start + 1, list.size())
.mapToObj(end -> list.subList(start, end)))
.flatMap(x -> x);
}
与之前的 for 循环一样,此代码不会包换空列表。 为了解决这个问题,可以使用 concat 方法,就像我们在之前版本中所做的那样,或者在 rangeClosed 调用中用 (int) Math.signum(start) 替换 1。
这两种子列表的流实现都可以,但都需要一些用户使用流-迭代适配器 ( Stream-to-Iterable adapte),或者在更自然的地方使用流。流-迭代适配器不仅打乱了客户端代码,而且在我的机器上使循环速度降低了 2.3 倍。一个专门构建的 Collection 实现 (此处未显示) 要冗长,但运行速度大约是我的机器上基于流的实现的 1.4 倍。
总之,在编写返回元素序列的方法时,请记住,某些用户可能希望将元素序列作为流处理,而其他用户可能希望迭代方式来处理。 尽量满足两个群体。 如果返回集合是可行的,请执行此操作。 如果已经拥有集合中的元素,或者序列中的元素数量足够小到可以创造一个新的序列,那么返回一个标准集合,比如 ArrayList。 否则,请考虑实现自定义集合,就像我们为幂集程序里所做的那样。 如果返回集合是不可行的,则返回流或可迭代的,无论哪个看起来更自然。 如果在将来的 Java 版本中,Stream 接口声明被修改为继承 Iterable,那么你就应该返回 Stream ,因为它可以同时被流和迭代处理。
48. 谨慎使用流并行
在主流语言中,Java 一直处于提供简化并发编程任务的工具的最前沿。 当 Java 于 1996 年发布时,它内置了对线程的支持,包括同步和 wait / notify 机制。 Java 5 引入了 java.util.concurrent 类库,带有并发集合和执行器框架。 Java 7 引入了 fork-join 包,这是一个用于并行分解的高性能框架。 Java 8 引入了流,可以通过对 parallel 方法的单个调用来并行化。 用 Java 编写并发程序变得越来越容易,但编写正确快速的并发程序还像以前一样困难。 安全和活跃度违规(liveness violation)是并发编程中的事实,并行流管道也不例外。
考虑条目 45 中的程序:
// Stream-based program to generate the first 20 Mersenne primes
public static void main(String[] args) {
primes().map(p -> TWO.pow(p.intValueExact()).subtract(ONE))
.filter(mersenne -> mersenne.isProbablePrime(50))
.limit(20)
.forEach(System.out::println);
}
static Stream<BigInteger> primes() {
return Stream.iterate(TWO, BigInteger::nextProbablePrime);
}
在我的机器上,这个程序立即开始打印素数,运行到完成需要 12.5 秒。假设我天真地尝试通过向流管道中添加一个到 parallel() 的调用来加快速度。你认为它的表现会怎样?它会快几个百分点吗?慢几个百分点?遗憾的是,它不会打印任何东西,但是 CPU 使用率会飙升到 90%,并且会无限期地停留在那里 (liveness failure:活性失败)。这个程序可能最终会终止,但我不愿意去等待;半小时后我强行阻止了它。
这里发生了什么?简而言之,流类库不知道如何并行化此管道并且启发式失败(heuristics fail)。 即使在最好的情况下,如果源来自 Stream.iterate 方法,或者使用中间操作 limit 方法,并行化管道也不太可能提高其性能。 这个管道必须应对这两个问题。更糟糕的是,默认的并行策略处理不可预测性的 limit 方法,假设在处理一些额外的元素和丢弃任何不必要的结果时没有害处。在这种情况下,找到每个梅森素数的时间大约是找到上一个素数的两倍。因此,计算单个额外元素的成本大致等于计算所有先前元素组合的成本,并且这种无害的管道使自动并行化算法瘫痪。这个故事的寓意很简单:不要无差别地并行化流管道(stream pipelines)。性能后果可能是灾难性的。
通常,并行性带来的性能收益在 ArrayList、HashMap、HashSet 和 ConcurrentHashMap 实例、数组、int 类型范围和 long 类型的范围的流上最好。 这些数据结构的共同之处在于,它们都可以精确而廉价地分割成任意大小的子程序,这使得在并行线程之间划分工作变得很容易。用于执行此任务的流泪库使用的抽象是 spliterator,它由 spliterator 方法在 Stream 和 Iterable 上返回。
所有这些数据结构的共同点的另一个重要因素是它们在顺序处理时提供了从良好到极好的引用位置( locality of reference):顺序元素引用在存储器中存储在一块。 这些引用所引用的对象在存储器中可能彼此不接近,这降低了引用局部性。 对于并行化批量操作而言,引用位置非常重要:没有它,线程大部分时间都处于空闲状态,等待数据从内存传输到处理器的缓存中。 具有最佳引用位置的数据结构是基本类型的数组,因为数据本身连续存储在存储器中。
流管道终端操作的性质也会影响并行执行的有效性。 如果与管道的整体工作相比,在终端操作中完成了大量的工作,并且这种操作本质上是连续的,那么并行化管道的有效性将是有限的。 并行性的最佳终操作是缩减(reductions),即使用流的 reduce 方法组合管道中出现的所有元素,或者预先打包的 reduce(如 min、max、count 和 sum)。短路操作 anyMatch、allMatch 和 noneMatch 也可以支持并行性。由 Stream 的 collect 方法执行的操作,称为可变缩减(mutable reductions),不适合并行性,因为组合集合的开销非常大。
如果编写自己的 Stream,Iterable 或 Collection 实现,并且希望获得良好的并行性能,则必须重写 spliterator 方法并广泛测试生成的流的并行性能。 编写高质量的 spliterator 很困难,超出了本书的范围。
并行化一个流不仅会导致糟糕的性能,包括活性失败(liveness failures);它会导致不正确的结果和不可预知的行为 (安全故障)。 使用映射器(mappers),过滤器(filters)和其他程序员提供的不符合其规范的功能对象的管道并行化可能会导致安全故障。 Stream 规范对这些功能对象提出了严格的要求。 例如,传递给 Stream 的 reduce 方法操作的累加器(accumulator)和组合器(combiner)函数必须是关联的,非干扰的和无状态的。 如果违反了这些要求(其中一些在第 46 项中讨论过),但按顺序运行你的管道,则可能会产生正确的结果; 如果将它并行化,它可能会失败,也许是灾难性的。
沿着这些思路,值得注意的是,即使并行的梅森素数程序已经运行完成,它也不会以正确的 (升序的) 顺序打印素数。为了保持顺序版本显示的顺序,必须将 forEach 终端操作替换为 forEachOrdered 操作,它保证以遇出现顺序(encounter order)遍历并行流。
即使假设正在使用一个高效的可拆分的源流、一个可并行化的或廉价的终端操作以及非干扰的函数对象,也无法从并行化中获得良好的加速效果,除非管道做了足够的实际工作来抵消与并行性相关的成本。作为一个非常粗略的估计,流中的元素数量乘以每个元素执行的代码行数应该至少是 100,000 [Lea14]。
重要的是要记住并行化流是严格的性能优化。 与任何优化一样,必须在更改之前和之后测试性能,以确保它值得做(详见第 67 条)。 理想情况下,应该在实际的系统设置中执行测试。 通常,程序中的所有并行流管道都在公共 fork-join 池中运行。 单个行为不当的管道可能会损害系统中不相关部分的其他行为。
如果在并行化流管道时,这种可能性对你不利,那是因为它们确实存在。一个认识的人,他维护一个数百万行代码库,大量使用流,他发现只有少数几个地方并行流是有效的。这并不意味着应该避免并行化流。在适当的情况下,只需向流管道添加一个 parallel 方法调用,就可以实现处理器内核数量的近似线性加速。 某些领域,如机器学习和数据处理,特别适合这些加速。
// 作为并行性有效的流管道的简单示例,请考虑此函数来计算π(n),素数小于或等于 n:
// Prime-counting stream pipeline - benefits from parallelization
static long pi(long n) {
return LongStream.rangeClosed(2, n)
.mapToObj(BigInteger::valueOf)
.filter(i -> i.isProbablePrime(50))
.count();
}
在我的机器上,使用此功能计算π(108)需要 31 秒。 只需添加 parallel() 方法调用即可将时间缩短为 9.2 秒:
// Prime-counting stream pipeline - parallel version
static long pi(long n) {
return LongStream.rangeClosed(2, n)
.parallel()
.mapToObj(BigInteger::valueOf)
.filter(i -> i.isProbablePrime(50))
.count();
}
换句话说,在我的四核计算机上,并行计算速度提高了 3.7 倍。值得注意的是,这不是你在实践中如何计算π(n) 为 n 的值。还有更有效的算法,特别是 Lehmer’s formula。
如果要并行化随机数流,请从 SplittableRandom 实例开始,而不是 ThreadLocalRandom(或基本上过时的 Random)。 SplittableRandom 专为此用途而设计,具有线性加速的潜力。ThreadLocalRandom 设计用于单个线程,并将自身适应作为并行流源,但不会像 SplittableRandom 一样快。Random 实例在每个操作上进行同步,因此会导致过度的并行杀死争用(parallelism-killing contention)。
总之,甚至不要尝试并行化流管道,除非你有充分的理由相信它将保持计算的正确性并提高其速度。不恰当地并行化流的代价可能是程序失败或性能灾难。如果您认为并行性是合理的,那么请确保您的代码在并行运行时保持正确,并在实际情况下进行仔细的性能度量。如果您的代码是正确的,并且这些实验证实了您对性能提高的怀疑,那么并且只有这样才能在生产代码中并行化流。
49. 检查参数有效性
本章(第 8 章)讨论了方法设计的几个方面:如何处理参数和返回值,如何设计方法签名以及如何记载方法文档。 本章中的大部分内容适用于构造方法和其他普通方法。 与第 4 章一样,本章重点关注可用性,健壮性和灵活性上。
大多数方法和构造方法对可以将哪些值传递到其对应参数中有一些限制。 例如,索引值必须是非负数,对象引用必须为非 null。 你应该清楚地在文档中记载所有这些限制,并在方法主体的开头用检查来强制执行。 应该尝试在错误发生后尽快检测到错误,这是一般原则的特殊情况。 如果不这样做,则不太可能检测到错误,并且一旦检测到错误就更难确定错误的来源。
如果将无效参数值传递给方法,并且该方法在执行之前检查其参数,则它抛出适当的异常然后快速且清楚地以失败结束。 如果该方法无法检查其参数,可能会发生一些事情。 在处理过程中,该方法可能会出现令人困惑的异常。 更糟糕的是,该方法可以正常返回,但默默地计算错误的结果。 最糟糕的是,该方法可以正常返回但是将某个对象置于受损状态,在将来某个未确定的时间在代码中的某些不相关点处导致错误。 换句话说,验证参数失败可能导致违反故障原子性(failure atomicity)(详见第 76 条)。
对于公共方法和受保护方法,请使用 Java 文档@throws注解来记在在违反参数值限制时将引发的异常(条目 74)。 通常,生成的异常是IllegalArgumentException,IndexOutOfBoundsException或NullPointerException(条目 72)。 一旦记录了对方法参数的限制,并且记录了违反这些限制时将引发的异常,那么强制执行这些限制就很简单了。 这是一个典型的例子:
/**
* 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,mod 抛出 NullPointerException」,尽管该方法正是这样做的,这是调用m.sgn()的副产品。这个异常记载在类级别文档注释中,用于包含的BigInteger类。类级别的注释应用于类的所有公共方法中的所有参数。这是避免在每个方法上分别记录每个NullPointerException的好方法。它可以与@Nullable或类似的注释结合使用,以表明某个特定参数可能为空,但这种做法不是标准的,为此使用了多个注解。
在 Java 7 中添加的Objects.requireNonNull 方法灵活方便,因此没有理由再手动执行空值检查。 如果愿意,可以指定自定义异常详细消息。 该方法返回其输入的值,因此可以在使用值的同时执行空检查:
// Inline use of Java's null-checking facility
this.strategy = Objects.requireNonNull(strategy, "strategy");
你也可以忽略返回值,并使用Objects.requireNonNull作为满足需求的独立空值检查。
在 Java 9 中,java.util.Objects 类中添加了范围检查工具。 此工具包含三个方法:checkFromIndexSize,checkFromToIndex和checkIndex。 此工具不如空检查方法灵活。 它不允许指定自己的异常详细消息,它仅用于列表和数组索引。 它不处理闭合范围(包含两个端点)。 但如果它能满足你的需要,那就很方便了。
对于未导出的方法,作为包的作者,控制调用方法的环境,这样就可以并且应该确保只传入有效的参数值。因此,非公共方法可以使用断言检查其参数,如下所示:
// 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
}
本质上,这些断言声称断言条件将成立,无论其客户端如何使用封闭包。与普通的有效性检查不同,断言如果失败会抛出AssertionError。与普通的有效性检查不同的是,除非使用-ea(或者-enableassertions)标记传递给 java 命令来启用它们,否则它们不会产生任何效果,本质上也不会产生任何成本。有关断言的更多信息,请参阅教程assert。
检查方法中未使用但存储以供以后使用的参数的有效性尤为重要。例如,考虑第 101 页上的静态工厂方法,它接受一个 int 数组并返回数组的 List 视图。如果客户端传入 null,该方法将抛出 NullPointerException,因为该方法具有显式检查 (调用 Objects.requireNonNull 方法)。如果省略了该检查,则该方法将返回对新创建的 List 实例的引用,该实例将在客户端尝试使用它时立即抛出 NullPointerException。 到那时,List 实例的来源可能很难确定,这可能会使调试任务大大复杂化。
构造方法是这个原则的一个特例,你应该检查要存储起来供以后使用的参数的有效性。检查构造方法参数的有效性对于防止构造对象违反类不变性(class invariants)非常重要。
你应该在执行计算之前显式检查方法的参数,但这一规则也有例外。 一个重要的例外是有效性检查昂贵或不切实际的情况,并且在进行计算的过程中隐式执行检查。 例如,考虑一种对对象列表进行排序的方法,例如Collections.sort(List)。 列表中的所有对象必须是可相互比较的。 在对列表进行排序的过程中,列表中的每个对象都将与其他对象进行比较。 如果对象不可相互比较,则某些比较操作抛出 ClassCastException 异常,这正是sort方法应该执行的操作。 因此,提前检查列表中的元素是否具有可比性是没有意义的。 但请注意,不加选择地依赖隐式有效性检查会导致失败原子性(failure atomicity)的丢失(详见第 76 条)。
有时,计算会隐式执行必需的有效性检查,但如果检查失败则会抛出错误的异常。 换句话说,计算由于无效参数值而自然抛出的异常与文档记录方法抛出的异常不匹配。 在这些情况下,你应该使用条目 73 中描述的异常翻译(exception translation)习惯用法将自然异常转换为正确的异常。
不要从本条目中推断出对参数的任意限制都是一件好事。 相反,你应该设计一些方法,使其尽可能通用。 假设方法可以对它接受的所有参数值做一些合理的操作,那么对参数的限制越少越好。 但是,通常情况下,某些限制是正在实现的抽象所固有的。
总而言之,每次编写方法或构造方法时,都应该考虑对其参数存在哪些限制。 应该记在这些限制,并在方法体的开头使用显式检查来强制执行这些限制。 养成这样做的习惯很重要。 在第一次有效性检查失败时,它所需要的少量工作将会得到对应的回报。
50. 必要时进行防御性拷贝
愉快使用 Java 的原因,它是一种安全的语言(safe language)。 这意味着在缺少本地方法(native methods)的情况下,它不受缓冲区溢出,数组溢出,野指针以及其他困扰 C 和 C++ 等不安全语言的内存损坏错误的影响。 在一种安全的语言中,无论系统的任何其他部分发生什么,都可以编写类并确切地知道它们的不变量会保持不变。 在将所有内存视为一个巨大数组的语言中,这是不可能的。
即使在一种安全的语言中,如果不付出一些努力,也不会与其他类隔离。必须防御性地编写程序,假定类的客户端尽力摧毁类其不变量。随着人们更加努力地试图破坏系统的安全性,这种情况变得越来越真实,但更常见的是,你的类将不得不处理由于善意得程序员诚实错误而导致的意外行为。不管怎样,花时间编写在客户端行为不佳的情况下仍然保持健壮的类是值得的。
如果没有对象的帮助,另一个类是不可能修改对象的内部状态的,但是在无意的情况下提供这样的帮助却非常地容易。例如,考虑以下类,表示一个不可变的时间期间:
// 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
}
乍一看,这个类似乎是不可变的,并强制执行不变式,即 period 实例的开始时间并不在结束时间之后。然而,利用 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,因为Instant和其他 java.time 包下的类是不可变的(条目 17)。Date 已过时,不应再在新代码中使用。 也就是说,问题仍然存在:有时必须在 API 和内部表示中使用可变值类型,本条目中讨论的技术也适用于这些时间。
为了保护 Period 实例的内部不受这种攻击,必须将每个可变参数的防御性拷贝应用到构造方法中,并将拷贝用作 Period 实例的组件,以替代原始实例:
// 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 实例产生影响。注意,防御性拷贝是在检查参数 (条目 49) 的有效性之前进行的,有效性检查是在拷贝上而不是在原始实例上进行的。虽然这看起来不自然,但却是必要的。它在检查参数和拷贝参数之间的漏洞窗口期间保护类不受其他线程对参数的更改的影响。在计算机安全社区中,这称为 time-of-check/time-of-use 或 TOCTOU 攻击[Viega01]。
还请注意,我们没有使用 Date 的 clone 方法来创建防御性拷贝。因为 Date 是非 final 的,所以 clone 方法不能保证返回类为 java.util.Date 的对象,它可以返回一个不受信任的子类的实例,这个子类是专门为恶意破坏而设计的。例如,这样的子类可以在创建时在私有静态列表中记录对每个实例的引用,并允许攻击者访问该列表。这将使攻击者可以自由控制所有实例。为了防止这类攻击,不要使用 clone 方法对其类型可由不可信任子类化的参数进行防御性拷贝。
虽然替换构造方法成功地抵御了先前的攻击,但是仍然可以对 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 是真正不可变的。 无论程序员多么恶意或不称职,根本没有办法违反一个 period 实例的开头不跟随其结束的不变量(不使用诸如本地方法和反射之类的语言外方法)。 这是正确的,因为除了 period 本身之外的任何类都无法访问 period 实例中的任何可变属性。 这些属性真正封装在对象中。
在访问器中,与构造方法不同,允许使用 clone 方法来制作防御性拷贝。 这是因为我们知道 Period 的内部 Date 对象的类是 java.util.Date,而不是一些不受信任的子类。 也就是说,由于条目 13 中列出的原因,通常最好使用构造方法或静态工厂来拷贝实例。
参数的防御性拷贝不仅仅适用于不可变类。 每次编写在内部数据结构中存储对客户端提供的对象的引用的方法或构造函数时,请考虑客户端提供的对象是否可能是可变的。 如果是,请考虑在将对象输入数据结构后,你的类是否可以容忍对象的更改。 如果答案是否定的,则必须防御性地拷贝对象,并将拷贝输入到数据结构中,以替代原始数据结构。 例如,如果你正在考虑使用客户端提供的对象引用作为内部 set 实例中的元素或作为内部 map 实例中的键,您应该意识到如果对象被修改后插入,对象的 set 或 map 的不变量将被破坏。
在将内部组件返回给客户端之前进行防御性拷贝也是如此。无论你的类是否是不可变的,在返回对可拜年的内部组件的引用之前,都应该三思。可能的情况是,应该返回一个防御性拷贝。记住,非零长度数组总是可变的。因此,在将内部数组返回给客户端之前,应该始终对其进行防御性拷贝。或者,可以返回数组的不可变视图。这两项技术都记载于条目 15。
可以说,所有这些的真正教训是,在可能的情况下,应该使用不可变对象作为对象的组件,这样就不必担心防御性拷贝(详见第 17 条)。在我们的 Period 示例中,使用 Instant(或 LocalDateTime 或 ZonedDateTime),除非使用的是 Java 8 之前的版本。如果使用的是较早的版本,则一个选项是存储 Date.getTime() 返回的基本类型 long 来代替 Date 引用。
可能存在与防御性拷贝相关的性能损失,并且它并不总是合理的。如果一个类信任它的调用者不修改内部组件,也许是因为这个类和它的客户端都是同一个包的一部分,那么它可能不需要防御性的拷贝。在这些情况下,类文档应该明确指出调用者不能修改受影响的参数或返回值。
即使跨越包边界,在将可变参数集成到对象之前对其进行防御性拷贝也并不总是合适的。有些方法和构造方法的调用指示参数引用的对象的显式切换。当调用这样的方法时,客户端承诺不再直接修改对象。希望获得客户端提供的可变对象的所有权的方法或构造方法必须在其文档中明确说明这一点。
包含方法或构造方法的类,这些方法或构造方法的调用指示控制权的转移,这些类无法防御恶意客户端。 只有当一个类和它的客户之间存在相互信任,或者当对类的不变量造成损害时,除了客户之外,任何人都不会受到损害。 后一种情况的一个例子是包装类模式(详见第 18 条)。 根据包装类的性质,客户端可以通过在包装后直接访问对象来破坏类的不变性,但这通常只会损害客户端。
总之,如果一个类有从它的客户端获取或返回的可变组件,那么这个类必须防御性地拷贝这些组件。如果拷贝的成本太高,并且类信任它的客户端不会不适当地修改组件,则可以用文档替换防御性拷贝,该文档概述了客户端不得修改受影响组件的责任。
51. 仔细设计方法签名
这一条目是 API 设计提示的大杂烩,但它们本身并足以设立一个单独的条目。综合起来,这些设计提示将帮助你更容易地学习和使用 API,并且更不容易出错。
仔细选择方法名名称。名称应始终遵守标准命名约定(详见第 68 条)。你的主要目标应该是选择与同一包中的其他名称一致且易于理解的名称。其次是应该是选择与更广泛的共识一致的名称。避免使用较长的方法名。如果有疑问,可以从 Java 类库 API 中寻求指导。尽管类库中也存在许多不一致之处(考虑到这些类库的规模和范围,这是不可避免的),也提供了相当客观的认可和共识。
不要过分地提供方便的方法。每种方法都应该“尽其所能”。太多的方法使得类难以学习、使用、文档化、测试和维护。对于接口更是如此,在接口中,太多的方法使实现者和用户的工作变得复杂。对于类或接口支持的每个操作,提供一个功能完整的方法。只有在经常使用时,才考虑提供「快捷方式(shortcut)」。如果有疑问,请将其删除。
避免过长的参数列表。目标是四个或更少的参数。大多数程序员不能记住更长的参数列表。如果你的许多方法超过了这个限制,如果未经常引用其文档的情况下,那么你的 API 将无法使用。现代 IDE 编辑器会提供帮助,但是使用简短的参数列表仍然会更好。相同类型参数的长序列尤其有害。用户不仅不能记住参数的顺序,而且当他们意外地弄错参数顺序时,他们的程序仍然会编译和运行。只是不会按照作者的意图去执行。
有三种技术可以缩短过长的参数列表。 一种方法是将方法分解为多个方法,每个方法只需要参数的一个子集。 如果不小心,这可能会导致太多方法,但它也可以通过增加正交性(orthogonality)来减少方法个数。 例如,考虑 java.util.List 接口。 它没有提供查找子列表中元素的第一个或最后一个索引的方法,这两个索引都需要三个参数。 相反,它提供了 subList 方法,该方法接受两个参数并返回子列表的视图。 此方法可以与 indexOf 或 lastIndexOf 方法结合使用,这两个方法都有一个参数,以生成所需的功能。 此外,subList 方法可以与在 List 实例上操作的任何方法组合,以对子列表执行任意计算。 得到的 API 具有非常高的功率重量 ( power-to-weight) 比。
缩短过长参数列表的第二种技术是创建辅助类来保存参数组。这些辅助类通常是静态成员类 (条目 24)。如果看到一个频繁出现的参数序列表示某个不同的实体,建议使用这种技术。例如,假设正在编写一个表示纸牌游戏的类,并且发现不断地传递一个由两个参数组成的序列,这些参数表示纸牌的点数和花色。如果添加一个辅助类来表示卡片,并用辅助类的单个参数替换参数序列的每次出现,那么 API 和类的内部结构可能会受益。
结合前两个方面的第三种技术是,从对象构造到方法调用采用 Builder 模式 (条目 2)。如果你有一个方法有许多参数,特别是其中一些是可选的,那么可以定义一个对象来表示所有的参数,并允许客户端在这个对象上进行多个「setter」调用,每次设置一个参数或较小相关的组。设置好所需的参数后,客户端调用对象的「execute」方法,该方法对参数进行最后的有效性检查,并执行实际的计算。
对于参数类型,优先选择接口而不是类(详见第 64 条)。如果有一个合适的接口来定义一个参数,那么使用它来支持一个实现该接口的类。例如,没有理由在编写方法时使用 HashMap 作为输入参数,相反,而是使用 Map 作为参数,这允许传入 HashMap、TreeMap、ConcurrentHashMap、TreeMap 的子 Map(submap)或任何尚未编写的 Map 实现。通过使用的类而不是接口,就把客户端限制在特定的实现中,如果输入数据碰巧以其他形式存在,则强制执行不必要的、代价高昂的复制操作。
与布尔型参数相比,优先使用两个元素枚举类型,除非布尔型参数的含义在方法名中是明确的。枚举类型使代码更容易阅读和编写。此外,它们还可以方便地在以后添加更多选项。例如,你可能有一个 Thermometer 类型的静态工厂方法,这个方法的签名是以下这个枚举:
public enum TemperatureScale { FAHRENHEIT, CELSIUS }
Thermometer.newInstance(TemperatureScale.CELSIUS) 不仅比 Thermometer.newInstance(true) 更有意义,而且可以在将来的版本中将KELVIN添加到 TemperatureScale 中,而无需向 Thermometer 添加新的静态工厂。 此外,还可以将温度刻度(temperature-scale)依赖关系重构为枚举常量的方法(详见第 34 条)。 例如,每个刻度常量可以有一个采用 double 值并将其转换为 Celsius 的方法。
52. 明智审慎地使用重载
下面的程序是一个善意的尝试,根据 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方法被重载了,在编译时选择要调用哪个重载方法。 对于循环的所有三次迭代,参数的编译时类型是相同的:Collection<?>。 运行时类型在每次迭代中都不同,但这不会影响对重载方法的选择。 因为参数的编译时类型是Collection<?>,所以唯一适用的重载是第三个classify(Collection<?> c)方法,并且在循环的每次迭代中调用这个重载。
此程序的行为是违反直觉的,因为重载(overloaded)方法之间的选择是静态的,而重写(overridden)方法之间的选择是动态的。 根据调用方法的对象的运行时类型,在运行时选择正确版本的重写方法。 作为提醒,当子类包含与父类中具有相同签名的方法声明时,会重写此方法。 如果在子类中重写实例方法并且在子类的实例上调用,则无论子类实例的编译时类型如何,都会执行子类的重写方法。 为了具体说明,请考虑以下程序:
class Wine {
String name() { return "wine"; }
}
class SparklingWine extends Wine {
@Override String name() { return "sparkling wine"; }
}
class Champagne extends SparklingWine {
@Override String 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());
}
}
name方法在Wine类中声明,并在子类SparklingWine和Champagne中重写。 正如你所料,此程序打印出 wine,sparkling wine 和 champagne,即使实例的编译时类型在循环的每次迭代中都是Wine。 当调用重写方法时,对象的编译时类型对执行哪个方法没有影响; 总是会执行“最具体 (most specific)”的重写方法。 将此与重载进行比较,其中对象的运行时类型对执行的重载没有影响; 选择是在编译时完成的,完全基于参数的编译时类型。
在CollectionClassifier示例中,程序的目的是通过基于参数的运行时类型自动调度到适当的方法重载来辨别参数的类型,就像 Wine 类中的 name 方法一样。 方法重载根本不提供此功能。 假设需要一个静态方法,修复CollectionClassifier程序的最佳方法是用一个执行显式instanceof测试的方法替换classify的所有三个重载:
public static String classify(Collection<?> c) {
return c instanceof Set ? "Set" :
c instanceof List ? "List" : "Unknown Collection";
}
因为重写是规范,而重载是例外,所以重写设置了人们对方法调用行为的期望。 正如CollectionClassifier示例所示,重载很容易混淆这些期望。 编写让程序员感到困惑的代码的行为是不好的实践。 对于 API 尤其如此。 如果 API 的日常用户不知道将为给定的参数集调用多个方法重载中的哪一个,则使用 API 可能会导致错误。 这些错误很可能表现为运行时的不稳定行为,许多程序员很难诊断它们。 因此,应该避免混淆使用重载。
究竟是什么构成了重载的混乱用法还有待商榷。一个安全和保守的策略是永远不要导出两个具有相同参数数量的重载。如果一个方法使用了可变参数,除非如第 53 条目所述,保守策略是根本不重载它。如果遵守这些限制,程序员就不会怀疑哪些重载适用于任何一组实际参数。这些限制并不十分繁重,因为总是可以为方法赋予不同的名称,而不是重载它们。
例如,考虑ObjectOutputStream类。对于每个基本类型和几个引用类型,它都有其write方法的变体。这些变体都有不同的名称,例如writeBoolean(boolean)、writeInt(int)和writeLong(long),而不是重载write方法。与重载相比,这种命名模式的另一个好处是,可以为read方法提供相应的名称,例如readBoolean()、readInt()和readLong()。ObjectInputStream类实际上提供了这样的读取方法。
对于构造方法,无法使用不同的名称:类的多个构造函数总是被重载。 在许多情况下,可以选择导出静态工厂而不是构造方法(详见第 1 条)。 此外,使用构造方法,不必担心重载和重写之间的影响,因为构造方法不能被重写。 你可能有机会导出具有相同数量参数的多个构造函数,因此知道如何安全地执行它是值得的。
如果总是清楚哪个重载将应用于任何给定的实际参数集,那么用相同数量的参数导出多个重载不太可能让程序员感到困惑。在这种情况下,每对重载中至少有一个对应的形式参数在这两个重载中具有「完全不同的」类型。如果显然不可能将任何非空表达式强制转换为这两种类型,那么这两种类型是完全不同的。在这些情况下,应用于给定实际参数集的重载完全由参数的运行时类型决定,且不受其编译时类型的影响,因此消除了一个主要的混淆。例如,ArrayList 有一个接受 int 的构造方法和第二个接受 Collection 的构造方法。很难想象在任何情况下,这两个构造方法在调用时哪个会产生混淆。
在 Java 5 之前,所有基本类型都与引用类型完全不同,但在自动装箱存在的情况下,则并非如此,并且它已经造成了真正的麻烦。 考虑以下程序:
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);
}
}
首先,程序将从-3 到 2 的整数添加到有序集合和列表中。 然后,它在集合和列表上进行三次相同的remove方法调用。 如果你和大多数人一样,希望程序从集合和列表中删除非负值(0, 1 和 2)并打印[-3, -2, -1] [ - 3, -2, -1]。 实际上,程序从集合中删除非负值,从列表中删除奇数值,并打印[-3, -2, -1] [-2, 0, 2]。 称这种混乱的行为是一种保守的说法。
实际情况是:调用set.remove(i)选择重载remove(E)方法,其中E是set (Integer)的元素类型,将基本类型 i 由 int 自动装箱为 Integer 中。这是你所期望的行为,因此程序最终会从集合中删除正值。另一方面,对list.remove(i)的调用选择重载remove(int i)方法,它将删除列表中指定位置的元素。如果从列表[-3, -2, -1, 0, 1, 2] 开始,移除第 0 个元素,然后是第 1 个,然后是第二个,就只剩下[-2, 0, 2],谜底就解开了。若要修复此问题,请强制转换list.remove的参数为Integer类型,迫使选择正确的重载。或者,也可以调用Integer.valueOf(i),然后将结果传递给list.remove方法。无论哪种方式,程序都会按预期打印[-3, -2, -1][-3, -2, -1]:
for (int i = 0; i < 3; i++) {
set.remove(i);
list.remove((Integer) i); // or remove(Integer.valueOf(i))
}
前一个示例所演示的令人混乱的行为是由于List<E>接口对remove方法有两个重载:remove(E)和remove(int)。在 Java 5 之前,当 List 接口被“泛型化”时,它有一个remove(Object)方法代替remove(E),而相应的参数类型 Object 和 int 则完全不同。但是,在泛型和自动装箱的存在下,这两种参数类型不再完全不同了。换句话说,在语言中添加泛型和自动装箱破坏了 List 接口。幸运的是,Java 类库中的其他 API 几乎没有受到类似的破坏,但是这个故事清楚地表明,自动装箱和泛型在重载时增加了谨慎的重要性。
在 Java 8 中添加 lambda 表达式和方法引用以后,进一步增加了重载混淆的可能性。 例如,考虑以下两个代码片段:
new Thread(System.out::println).start();
ExecutorService exec = Executors.newCachedThreadPool();
exec.submit(System.out::println);
虽然 Thread 构造方法调用和submit方法调用看起来很相似,但是前者编译而后者不编译。参数是相同的 (System.out::println),两者都有一个带有Runnable的重载。这里发生了什么?令人惊讶的答案是,submit方法有一个带有Callable <T>参数的重载,而Thread构造方法却没有。你可能认为这不会有什么区别,因为println方法的所有重载都会返回void,因此方法引用不可能是Callable
。这很有道理,但重载解析算法不是这样工作的。也许同样令人惊讶的是,如果println方法没有被重载,那么submit方法调用是合法的。正是被引用的方法(println)的重载和被调用的方法(submit)相结合,阻止了重载解析算法按照你所期望的方式运行。
从技术上讲,问题是System.out::println是一个不精确的方法引用[JLS,15.13.1],并且「包含隐式类型的 lambda 表达式或不精确的方法引用的某些参数表达式被适用性测试忽略,因为在选择目标类型之前无法确定它们的含义[JLS,15.12.2]。」如果你不理解这段话也不要担心; 它针对的是编译器编写者。 关键是在同一参数位置中具有不同功能接口的重载方法或构造方法会导致混淆。 因此,不要在相同参数位置重载采用不同函数式接口的方法。 在此条目的说法中,不同的函数式接口并没有根本不同。 如果传递命令行开关-Xlint:overloads,Java 编译器将警告这种有问题的重载。
数组类型和 Object 以外的类是完全不同的。此外,除了Serializable和Cloneable之外,数组类型和其他接口类型也完全不同。如果两个不同的类都不是另一个类的后代[JLS, 5.5],则称它们是不相关的。例如,String和Throwable是不相关的。任何对象都不可能是两个不相关类的实例,所以不相关的类也是完全不同的。
还有其他『类型对 (pairs of types)』不能在任何方向转换[JLS, 5.1.12],但是一旦超出上面描述的简单情况,大多数程序员就很难辨别哪些重载 (如果有的话) 适用于一组实际参数。决定选择哪个重载的规则非常复杂,并且随着每个版本的发布而变得越来越复杂。很少有程序员能理解它们所有的微妙之处。
有时候,可能觉得有必要违反这一条目中的指导原则,特别是在演化现有类时。例如,考虑 String,它从 Java 4 开始就有一个contenttequals (StringBuffer)方法。在 Java 5 中,添加了CharSequence接口,来为StringBuffer、StringBuilder、String、CharBuffer和其他类似类型提供公共接口。在添加CharSequence的同时,String 还配备了一个重载的contenttequals方法,该方法接受CharSequence参数。
虽然上面的重载明显违反了此条目中的指导原则,但它不会造成任何危害,因为当在同一个对象引用上调用这两个重载方法时,它们做的是完全相同的事情。程序员可能不知道将调用哪个重载,但只要它们的行为相同,就没有什么后果。确保这种行为的标准方法是,将更具体的重载方法调用转发给更一般的重载方法:
// Ensuring that 2 methods have identical behavior by forwarding
public boolean contentEquals(StringBuffer sb) {
return contentEquals((CharSequence) sb);
}
虽然 Java 类库在很大程度上遵循了这一条目中的建议,但是有一些类违反了它。例如,String 导出两个重载的静态工厂方法valueOf(char[])和valueOf(Object),它们在传递相同的对象引用时执行完全不同的操作。对此没有任何正当的理由理由,它应该被视为一种异常现象,有可能造成真正的混乱。
总而言之,仅仅可以重载方法并不意味着应该这样做。通常,最好避免重载具有相同数量参数的多个签名的方法。在某些情况下,特别是涉及构造方法的情况下,可能无法遵循此建议。在这些情况下,至少应该避免通过添加强制转换将相同的参数集传递给不同的重载。如果这是无法避免的,例如,因为要对现有类进行改造以实现新接口,那么应该确保在传递相同的参数时,所有重载的行为都是相同的。如果做不到这一点,程序员将很难有效地使用重载方法或构造方法,也无法理解为什么它不能工作。
53. 明智审慎地使用可变参数
可变参数方法正式名称称为可变的参数数量方法「variable arity methods」 [JLS, 8.4.1],接受零个或多个指定类型的参数。 可变参数机制首先创建一个数组,其大小是在调用位置传递的参数数量,然后将参数值放入数组中,最后将数组传递给方法。
例如,这里有一个可变参数方法,它接受一系列 int 类型的参数并返回它们的总和。如你所料, sum(1,2,3) 的值为 6, sum() 的值为 0:
// 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;
}
该解决方案存在几个问题。 最严重的是,如果客户端在没有参数的情况下调用此方法,则它在运行时而不是在编译时失败。 另一个问题是它很难看。 必须在 args 参数上包含显式有效性检查,除非将 min 初始化为 Integer.MAX_VALUE,否则不能使用 for-each 循环,这也很难看。
幸运的是,有一种更好的方法可以达到预期的效果。 声明方法采用两个参数,一个指定类型的普通参数,另一个此类型的可变参数。 该解决方案纠正了前一个示例的所有缺陷:
// 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;
}
从这个例子中可以看出,在需要参数数量可变的方法时,可变参数是有效的。可变参数是为 printf 方法而设计的,该方法与可变参数同时添加到 Java 平台中,以及包括经过改造的核心反射机制。printf 和反射机制都从可变参数中受益匪浅。
在性能关键的情况下使用可变参数时要小心。每次调用可变参数方法都会导致数组分配和初始化。如果你从经验上确定负担不起这个成本,但是还需要可变参数的灵活性,那么有一种模式可以让你鱼与熊掌兼得。假设你已确定 95% 的调用是三个或更少的参数的方法,那么声明该方法的五个重载。每个重载方法包含 0 到 3 个普通参数,当参数数量超过 3 个时,使用一个可变参数方法:
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) { }
现在你知道,在所有参数数量超过 3 个的方法调用中,只有 5% 的调用需要支付创建数组的成本。与大多数性能优化一样,这种技术通常不太合适,但一旦真正需要的时候,它是一个救星。
EnumSet 的静态工厂使用这种技术将创建枚举集合的成本降到最低。这是适当的,因为枚举集合为比特属性提供具有性能竞争力的替换(performance-competitive replacement for bit fields)是至关重要的 (详见第 36 条)。
总之,当需要使用可变数量的参数定义方法时,可变参数非常有用。 在使用可变参数前加上任何必需的参数,并注意使用可变参数的性能后果。
54. 返回空的数组或集合,不要返回 null
像如下的方法并不罕见:
// 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);
}
把没有奶酪(Cheese)可买的情况当做一种特例,这是不合常理的。这样需要在客户端中必须有额外的代码来处理 null 的返回值,如:
List<Cheese> cheeses = shop.getCheeses();
if (cheeses != null && cheeses.contains(Cheese.STILTON))
System.out.println("Jolly good, just the thing.");
在几乎每次使用返回 null 来代替空集合或数组的方法时,都需要使用这种迂回的方式。 这样做很容易出错,因为编写客户端的程序员可能忘记编写特殊情况代码来处理 null 返回。 多年来这种错误可能会被忽视,因为这种方法通常会返回一个或多个对象。 此外,返回 null 代替空容器会使返回容器的方法的实现变得复杂。
有时有人认为,null 返回值比空集合或数组更可取,因为它避免了分配空容器的开销。这个论点有两点是不成立的。首先,除非测量结果表明所讨论的分配是性能问题的真正原因,否则不宜担心此级别的性能(详见第 67 条)。第二,可以在不分配空集合和数组的情况下返回它们。下面是返回可能为空的集合的典型代码。通常,这就是你所需要的:
//The right way to return a possibly empty collection
public List<Cheese> getCheeses() {
return new ArrayList<>(cheesesInStock);
}
如果有证据表明分配空集合会损害性能,可以通过重复返回相同的不可变空集合来避免分配,因为不可变对象可以自由共享(详见第 17 条)。下面的代码就是这样做的,使用了 Collections.emptyList 方法。如果你要返回一个 Set,可以使用 Collections.emptySet ;如果要返回 Map,则使用 Collections.emptyMap。但是请记住,这是一个优化,很少需要它。如果你认为你需要它,测量一下前后的性能表现,确保它确实有帮助:
// Optimization - avoids allocating empty collections
public List<Cheese> getCheeses() {
return cheesesInStock.isEmpty() ? Collections.emptyList()
: new ArrayList<>(cheesesInStock);
}
数组的情况与集合的情况相同。 永远不要返回 null,而是返回长度为零的数组。 通常,应该只返回一个正确长度的数组,这个长度可能为零。 请注意,我们将一个长度为零的数组传递给 toArray 方法,以指示所需的返回类型,即 Cheese []:
//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 调用中,当 cheesesInStock 为空时,这个数组将从 getCheeses 返回。不要为了提高性能而预先分配传递给 toArray 的数组。研究表明,这样做会适得其反[Shipilev16]:
// Don’t do this - preallocating the array harms performance!
return cheesesInStock.toArray(new Cheese[cheesesInStock.size()]);
总之,永远不要返回 null 来代替空数组或集合。它使你的 API 更难以使用,更容易出错,并且没有性能优势。
55. 明智审慎地返回 Optional
在 Java 8 之前,编写在特定情况下无法返回任何值的方法时,可以采用两种方法。要么抛出异常,要么返回 null(假设返回类型是对象是引用类型)。但这两种方法都不完美。应该为异常条件保留异常 (详见第 69 条),并且抛出异常代价很高,因为在创建异常时捕获整个堆栈跟踪。返回 null 没有这些缺点,但是它有自己的缺陷。如果方法返回 null,客户端必须包含特殊情况代码来处理 null 返回的可能性,除非程序员能够证明 null 返回是不可能的。如果客户端忽略检查 null 返回并将 null 返回值存储在某个数据结构中,那么会在将来的某个时间在与这个问题不相关的代码位置上,抛出NullPointerException异常的可能性。
在 Java 8 中,还有第三种方法来编写可能无法返回任何值的方法。Optional<T>类表示一个不可变的容器,它可以包含一个非 null 的T引用,也可以什么都不包含。不包含任何内容的 Optional 被称为空(empty)。非空的包含值称的 Optional 被称为存在(present)。Optional 的本质上是一个不可变的集合,最多可以容纳一个元素。Optional<T>没有实现Collection<T>接口,但原则上是可以。
在概念上返回 T 的方法,但在某些情况下可能无法这样做,可以声明为返回一个Optional<T>。这允许该方法返回一个空结果,以表明不能返回有效的结果。返回 Optional 的方法比抛出异常的方法更灵活、更容易使用,而且比返回 null 的方法更不容易出错。
在条目 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;
}
如果给定集合为空,此方法将抛出IllegalArgumentException异常。我们在条目 30 中提到,更好的替代方法是返回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 很简单。 你所要做的就是使用适当的静态工厂创建 Optional。 在这个程序中,我们使用两个:Optional.empty() 返回一个空的 Optional, Optional.of(value) 返回一个包含给定非 null 值的 Optional。 将 null 传递给 Optional.of(value) 是一个编程错误。 如果这样做,该方法通过抛出 NullPointerException 异常作为回应。 Optional.of(value) 方法接受一个可能为 null 的值,如果传入 null 则返回一个空的 Optional。永远不要通过返回 Optional 的方法返回一个空值:它破坏 Optional 设计的初衷。
Stream 上的很多终止操作返回 Optional。如果我们重写 max 方法来使用一个Stream,那么 Stream 的 max 操作会为我们生成 Optional 的工作 (尽管我们还是传递一个显式的Comparator):
// 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 而不是返回 null 或抛出异常呢?Optional在本质上类似于检查异常(checked exceptions)(详见第 71 条),因为它们迫使 API 的用户面对可能没有返回任何值的事实。抛出未检查的异常或返回 null 允许用户忽略这种可能性,从而带来潜在的可怕后果。但是,抛出一个检查异常需要在客户端中添加额外的样板代码。
如果方法返回一个 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 获取值,而不需要指定一个操作来执行。但是如果 Optional 是空的,你判断错了,代码会抛出一个 NoSuchElementException 异常:
// Using optional when you know there’s a return value
Element lastNobleGas = max(Elements.NOBLE_GASES).get();
有时候,可能会遇到这样一种情况:获取默认值的代价很高,除非必要,否则希望避免这种代价。对于这些情况,Optional 提供了一个方法,该方法接受 Supplier<T>,并仅在必要时调用它。这个方法被称为 orElseGet,但是或许应该被称为 orElseCompute,因为它与以compute开头的三个 Map 方法密切相关。有几个 Optional 的方法来处理更特殊的用例:filter、map 、flatMap 和 ifPresent。在 Java 9 中,又添加了两个这样的方法: or 和 ifPresentOrElse。如果上面描述的基本方法与你的用例不太匹配,请查看这些更高级方法的文档,并查看它们是否能够完成任务。
如果这些方法都不能满足你的需要,Optional 提供 isPresent() 方法,可以将其视为安全阀。如果 Optional 包含值,则返回 true;如果为空,则返回 false。你可以使用此方法对可选结果执行任何喜欢的处理,但请确保明智地使用它。isPresent 的许多用途都可以被上面提到的一种方法所替代。生成的代码通常更短、更清晰、更符合习惯。
例如,请考虑此代码段,它打印一个进程的父进程 ID,如果进程没有父进程,则打印 N/A. 该代码段使用 Java 9 中引入的 ProcessHandle 类:
Optional<ProcessHandle> parentProcess = ph.parent();
System.out.println("Parent PID: " + (parentProcess.isPresent() ?
String.valueOf(parentProcess.get().pid()) : "N/A"));
上面的代码可以被如下代码所替代,使用了 Optional 的 map 方法:
System.out.println("Parent PID: " +
ph.parent().map(h -> String.valueOf(h.pid())).orElse("N/A"));
当使用 Stream 进行编程时,通常会发现使用的是一个 Stream<Optional<T>>,并且需要一个 Stream<T>,其中包含非 Optional 中的所有元素,以便继续进行。如果你正在使用 Java 8,下面是弥补这个差距的代码:
streamOfOptionals
.filter(Optional::isPresent)
.map(Optional::get)
在 Java 9 中,Optional 配备了一个 stream() 方法。这个方法是一个适配器, 此方法是一个适配器,它将 Optional 变为包含一个元素的 Stream,如果 Optional 为空,则不包含任何元素。此方法与 Stream 的 flatMap 方法 (条目 45) 相结合,这个方法可以简洁地替代上面的方法:
streamOfOptionals.
.flatMap(Optional::stream)
并不是所有的返回类型都能从 Optional 的处理中获益。容器类型,包括集合、映射、Stream、数组和 Optional,不应该封装在 Optional 中。与其返回一个空的Optional<List<T>>,不还如返回一个空的 List<T>(详见第 54 条)。返回空容器将消除客户端代码处理 Optional 的需要。ProcessHandle 类确实有 arguments 方法,它返回Optional<String[]>,但是这个方法应该被视为一种异常,不该被效仿。
那么什么时候应该声明一个方法来返回 Optional<T> 而不是 T 呢? 通常,如果可能无法返回结果,并且在没有返回结果,客户端还必须执行特殊处理的情况下,则应声明返回 Optional <T> 的方法。也就是说,返回 Optional<T> 并非没有成本。 Optional 是必须分配和初始化的对象,从 Optional 中读取值需要额外的迂回。 这使得 Optional 不适合在某些性能关键的情况下使用。 特定方法是否属于此类别只能通过仔细测量来确定(详见第 67 条)。
与返回装箱的基本类型相比,返回包含已装箱基本类型的 Optional 的代价高得惊人,因为 Optional 有两个装箱级别,而不是零。因此,类库设计人员认为为基本类型 int、long 和 double 提供类似 Option<T> 是合适的。这些 Option 是 OptionalInt、OptionalLong 和 OptionalDouble。它们包含 Optional<T> 上的大多数方法,但不是所有方法。因此,除了「次要基本类型(minor primitive types)」Boolean,Byte,Character,Short 和 Float 之外,永远不应该返回装箱的基本类型的 Optional。
到目前为止,我们已经讨论了返回 Optional 并在返回后处理它们的方法。我们还没有讨论其他可能的用法,这是因为大多数其他 Optional 的用法都是可疑的。例如,永远不要将 Optional 用作映射值。如果这样做,则有两种方法可以表示键(key)在映射中逻辑上的缺失:键要么不在映射中,要么存在的话映射到一个空的 Optional。这反映了不必要的复杂性,很有可能导致混淆和错误。更通俗地说,在集合或数组中使用 Optional 的键、值或元素几乎都是不合适的。
这里留下了一个悬而未决的大问题。在实例中存储 Optional 属性是否合适吗?通常这是一种“不好的味道”:它建议你可能应该有一个包含 Optional 属性的子类。但有时这可能是合理的。考虑条目 2 中的 NutritionFacts 类的情况。NutritionFacts 实例包含许多不需要的属性。不可能为这些属性的每个可能组合都提供一个子类。此外,属性包含基本类型,这使得很难直接表示这种缺失。对于 NutritionFacts 最好的 API 将为每个 Optional 属性从 getter 方法返回一个 Optional,因此将这些 Optional 作为属性存储在对象中是很有意义的。
总之,如果发现自己编写的方法不能总是返回值,并且认为该方法的用户在每次调用时考虑这种可能性很重要,那么或许应该返回一个 Optional 的方法。但是,应该意识到,返回 Optional 会带来实际的性能后果;对于性能关键的方法,最好返回 null 或抛出异常。最后,除了作为返回值之外,不应该在任何其他地方中使用 Optional。
56. 为所有已公开的 API 元素编写文档注释
如果 API 要可用,就必须对其进行文档化。传统上,API 文档是手工生成的,保持文档与代码的同步是一件苦差事。Java 编程环境使用 Javadoc 实用程序简化了这一任务。Javadoc 使用特殊格式的文档注释 (通常称为 doc 注释),从源代码自动生成 API 文档。
虽然文档注释约定不是 Java 语言的正式一部分,但它们构成了每个 Java 程序员都应该知道的事实上的 API。「如何编写文档注释(How to Write Doc Comments)」的网页[Javadoc-guide] 中介绍了这些约定。 虽然自 Java 4 发布以来该页面尚未更新,但它仍然是一个非常宝贵的资源。 Java 9 中添加了一个重要的文档标签,{@ index}; Java 8 中有一个,{@implSpec};Java 5 中有两个,{@literal} 和 {@code}。 上述网页中缺少这些标签的介绍,但在此条目中进行讨论。
要正确地记录 API,必须在每个导出的类、接口、构造方法、方法和属性声明之前加上文档注释。如果一个类是可序列化的,还应该记录它的序列化形式 (详见第 87 条)。在没有文档注释的情况下,Javadoc 可以做的最好的事情是将声明重现为受影响的 API 元素的唯一文档。使用缺少文档注释的 API 是令人沮丧和容易出错的。公共类不应该使用默认构造方法,因为无法为它们提供文档注释。要编写可维护的代码,还应该为大多数未导出的类、接口、构造方法、方法和属性编写文档注释,尽管这些注释不需要像导出 API 元素那样完整。
方法的文档注释应该简洁地描述方法与其客户端之间的契约。除了为继承而设计的类中的方法 (详见第 19 条)之外,契约应该说明方法做什么,而不是它如何工作的。文档注释应该列举方法的所有前置条件 (这些条件必须为真,以便客户端调用它们),以及后置条件(这些条件是在调用成功完成后才为真)。通常,对于未检查的异常,前置条件由 @throw 标签隐式地描述;每个未检查异常对应于一个先决条件违反( precondition violation)。此外,可以在受影响的参数的 @param 标签中指定前置条件。
除了前置条件和后置条件之外,方法还应在文档中记录它的副作用(side effort)。 副作用是系统状态的可观察到的变化,这对于实现后置条件而言显然不是必需的。 例如,如果方法启动后台线程,则文档应记录它。
完整地描述方法的契约,文档注释应该为每个参数都有一个 @param 标签,一个 @return 标签 (除非方法有 void 返回类型),以及一个 @throw 标签(无论是检查异常还是非检查异常)(详见第 74 条)。如果 @return 标签中的文本与方法的描述相同,则可以忽略它,这取决于你所遵循的编码标准。
按照惯例,@param 或 @retur 标签后面的文本应该是一个名词短语,描述参数或返回值所表示的值。 很少使用算术表达式代替名词短语; 请参阅 BigInteger 的示例。@throw 标签后面的文本应该包含单词「if」,后面跟着一个描述抛出异常的条件的子句。按照惯例,@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);
请注意在此文档注释(<p>和<i>)中使用 HTML 标记。 Javadoc 实用工具将文档注释转换为 HTML,文档注释中的任意 HTML 元素最终都会生成 HTML 文档。 有时候,程序员甚至会在他们的文档注释中嵌入 HTML 表格,尽管这种情况很少见。
还要注意在@throw子句中的代码片段周围使用 Javadoc 的 {@code}标签。这个标签有两个目的:它使代码片段以代码字体形式呈现,并且它抑制了代码片段中 HTML 标记和嵌套 Javadoc 标记的处理。后一个属性允许我们在代码片段中使用小于号(<),即使它是一个 HTML 元字符。要在文档注释中包含多行代码示例,请使用包装在 HTML <pre>标记中的 Javadoc{@code}标签。换句话说,在代码示例前面加上字符<pre>{@code,然后在代码后面加上}</pre>。这保留了代码中的换行符,并消除了转义 HTML 元字符的需要,但不需要转义 at 符号(@),如果代码示例使用注释,则必须转义 at 符号(@)。
最后,请注意文档注释中使用的单词「this list」。按照惯例,「this」指的是在实例方法的文档注释中,指向方法调用所在的对象。
正如条目 15 中提到的,当你为继承设计一个类时,必须记录它的自用模式(self-use patterns),以便程序员知道重写它的方法的语义。这些自用模式应该使用在 Java 8 中添加的@implSpec 标签来文档记录。回想一下,普通的问问昂注释描述了方法与其客户端之间的契约;相反,@implSpec 注释描述了方法与其子类之间的契约,如果它继承了方法或通过 super 调用方法,那么允许子类依赖于实现行为。下面是实际应用中的实例:
/**
* Returns true if this collection is empty.
*
* @implSpec
* This implementation returns {@code this.size() == 0}.
*
* @return true if this collection is empty
*/
public boolean isEmpty() { ... }
从 Java 9 开始,Javadoc 实用工具仍然忽略 @implSpec 标签,除非通过命令行开关:-tag "implSpec:a:Implementation Requirements:"。希望在后续的版本中可以修正这个错误。
不要忘记,你必须采取特殊操作来生成包含 HTML 元字符的文档,例如小于号(<),大于号(>)和 and 符号(&)。 将这些字符放入文档的最佳方法是使用{@literal}标签将它们包围起来,该标签禁止处理 HTML 标记和嵌套的 Javadoc 标记。 它就像{@code}标签一样,除了不会以代码字体呈现文本以外。 例如,这个 Javadoc 片段:
* A geometric series converges if {@literal |r| < 1}.
它会生成文档:「A geometric series converges if |r| < 1.」。{@literal}标签可能只放在小于号的位置,而不是整个不等式,并且生成的文档是一样的,但是文档注释在源代码中的可读性较差。 这说明了文档注释在源代码和生成的文档中都应该是可读的通用原则。 如果无法实现这两者,则生成的文档的可读性要胜过在源代码中的可读性。
每个文档注释的第一个「句子」(如下定义)成为注释所在元素的概要描述。 例如,第 255 页上的文档注释中的概要描述为:「返回此列表中指定位置的元素」。概要描述必须独立描述其概述元素的功能。 为避免混淆,类或接口中的两个成员或构造方法不应具有相同的概要描述。 要特别注意重载方法,为此通常使用相同的第一句话是自然的(但在文档注释中是不可接受的)。
请小心,如果预期的概要描述包含句点,因为句点可能会提前终止描述。例如,以「A college degree, such as B.S., M.S. or Ph.D.」 会导致概要描述为「A college degree, such as B.S., M.S」。问题在于概要描述在第一个句点结束,然后是空格、制表符或行结束符(或第一个块标签处)[Javadoc-ref]。这里是缩写「M.S.」 中的第二个句号后面跟着一个空格。最好的解决方案是用{@literal}标签来包围不愉快的句点和任何相关的文本,这样源代码中的句点后面就不会有空格了:
/**
* A college degree, such as B.S., {@literal M.S.} or Ph.D.
*/
public class Degree { ... }
说概要描述是文档注释中的第一句子,其实有点误导人。按照惯例,它很少应该是一个完整的句子。对于方法和构造方法,概要描述应该是一个动词短语 (包括任何对象),描述了该方法执行的操作。例如:
ArrayList(int initialCapacity)—— 构造具有指定初始容量的空列表。Collection.size()—— 返回此集合中的元素个数。
如这些例子所示,使用第三人称陈述句时态 (“returns the number”)而不是第二人称祈使句(“return the number”)。
对于类,接口和属性,概要描述应该是描述由类或接口的实例或属性本身表示的事物的名词短语。 例如:
Instant—— 时间线上的瞬时点。Math.PI—— 更加接近 pi 的 double 类型数值,即圆的周长与其直径之比。
在 Java 9 中,客户端索引被添加到 Javadoc 生成的 HTML 中。这个索引以页面右上角的搜索框的形式出现,它简化了导航大型 API 文档集的任务。当你在框中键入时,得到一个匹配页面的下拉菜单。API 元素 (如类、方法和属性) 是自动索引的。有时,可能希望索引对你的 API 很重要的其他术语。为此添加了{@index}标签。对文档注释中出现的术语进行索引,就像将其包装在这个标签中一样简单,如下面的片段所示:
* This method complies with the {@index IEEE 754} standard.
泛型,枚举和注释需要特别注意文档注释。 记录泛型类型或方法时,请务必记录所有类型参数:
/**
* 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();
}
包级别文档注释应放在名为 package-info.java 的文件中。 除了这些注释之外,package-info.java 还必须包含一个包声明,并且可以在此声明中包含注解。 同样,如果使用模块化系统(详见第 15 条),则应将模块级别注释放在 module-info.java 文件中。
在文档中经常忽略的 API 的两个方面,分别是线程安全性和可序列化性。无论类或静态方法是否线程安全,都应该在文档中描述其线程安全级别,如条目 82 中所述。如果一个类是可序列化的,应该记录它的序列化形式,如条目 87 中所述。
Javadoc 具有「继承(inherit)」方法注释的能力。 如果 API 元素没有文档注释,Javadoc 将搜索最具体的适用文档注释,接口文档优先于超类文档。 搜索算法的详细信息可以在 The Javadoc Reference Guide [Javadoc-ref] 中找到。 还可以使用{@inheritDoc}标签从超类继承部分文档注释。 这意味着,除其他外,类可以重用它们实现的接口的文档注释,而不是复制这些注释。 该工具有可能减轻维护多组几乎相同的文档注释的负担,但使用起来很棘手并且有一些限制。 详细信息超出了本书的范围。
关于文档注释,应该添加一个警告说明。虽然有必要为所有导出的 API 元素提供文档注释,但这并不总是足够的。对于由多个相互关联的类组成的复杂 API,通常需要用描述 API 总体架构的外部文档来补充文档注释。如果存在这样的文档,相关的类或包文档注释应该包含到外部文档的链接。
Javadoc 会自动检查是否符合此条目中的许多建议。在 Java 7 中,需要命令行开关-Xdoclint来获得这种行为。在 Java 8 和 Java 9 中,默认情况下启用了此检查。诸如 checkstyle 之类的 IDE 插件会进一步检查是否符合这些建议[Burn01]。还可以通过 HTML 有效性检查器运行 Javadoc 生成的 HTML 文件来降低文档注释中出现错误的可能性。可以检测 HTML 标记的许多错误用法。有几个这样的检查器可供下载,可以使用 W3C markup validation service 在线验证 HTML 格式。在验证生成的 HTML 时,请记住,从 Java 9 开始,Javadoc 就能够生成 HTML5 和 HTML 4.01,尽管默认情况下仍然生成 HTML 4.01。如果希望 Javadoc 生成 HTML5,请使用-html5命令行开关。
本条目中描述的约定涵盖了基本内容。尽管撰写本文时已经有 15 年的历史,但编写文档注释的最终指南仍然是《How to Write Doc Comments》[Javadoc-guide]。
如果你遵循本项目中的指导原则,生成的文档应该提供对 API 的清晰描述。然而,唯一确定的方法,是阅读 Javadoc 实用工具生成的 web 页面。对于其他人将使用的每个 API,都值得这样做。正如测试程序几乎不可避免地会导致对代码的一些更改一样,阅读文档通常也会导致对文档注释的一些少许的修改。
总之,文档注释是记录 API 的最佳、最有效的方法。对于所有导出的 API 元素,它们的使用应被视为必需的。 采用符合标准惯例的一致风格 。请记住,在文档注释中允许任意 HTML,但必须转义 HTML 的元字符。
57. 最小化局部变量的作用域
这条目在性质上类似于条目 15,即“最小化类和成员的可访问性”。通过最小化局部变量的作用域,可以提高代码的可读性和可维护性,并降低出错的可能性。
较早的编程语言(如 C)要求必须在代码块的头部声明局部变量,并且一些程序员继续习惯这样做。 这是一个值得改进的习惯。 作为提醒,Java 允许你在任何合法的语句的地方声明变量(as does C, since C99)。
用于最小化局部变量作用域的最强大的技术是再首次使用的地方声明它。 如果变量在使用之前被声明,那就变得更加混乱—— 这也会对试图理解程序的读者来讲,又增加了一件分散他们注意力的事情。 到使用该变量时,读者可能不记得变量的类型或初始值。
过早地声明局部变量可能导致其作用域不仅过早开始而且结束太晚。 局部变量的作用域从声明它的位置延伸到封闭块的末尾。 如果变量在使用它的封闭块之外声明,则在程序退出该封闭块后它仍然可见。如果在其预定用途区域之前或之后意外使用变量,则后果可能是灾难性的。
几乎每个局部变量声明都应该包含一个初始化器。如果还没有足够的信息来合理地初始化一个变量,那么应该推迟声明,直到认为可以这样做。这个规则的一个例外是 try-catch 语句。如果一个变量被初始化为一个表达式,该表达式的计算结果可以抛出一个已检查的异常,那么该变量必须在 try 块中初始化(除非所包含的方法可以传播异常)。如果该值必须在 try 块之外使用,那么它必须在 try 块之前声明,此时它还不能被「合理地初始化」。例如,参照条目 65 中的示例。
循环提供了一个特殊的机会来最小化变量的作用域。传统形式的 for 循环和 for-each 形式都允许声明循环变量,将其作用域限制在需要它们的确切区域。 (该区域由循环体和 for 关键字与正文之间的括号中的代码组成)。因此,如果循环终止后不需要循环变量的内容,那么优先选择 for 循环而不是 while 循环。
例如,下面是遍历集合的首选方式(详见第 58 条):
// Preferred idiom for iterating over a collection or array
for (Element e : c) {
... // Do Something with e
}
如果需要访问迭代器,也许是为了调用它的 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 循环,请考虑以下代码片段,其中包含两个 while 循环和一个 bug:
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,不幸的是,它仍在范围内。 生成的代码编译时没有错误,并且在不抛出异常的情况下运行,但它做错了。 第二个循环不是在 c2 上迭代,而是立即终止,给出了 c2 为空的错误印象。 由于程序无声地出错,因此错误可能会长时间无法被检测到。
如果将类似的复制粘贴错误与 for 循环(for-each 循环或传统循环)结合使用,则生成的代码甚至无法编译。第一个循环中的元素(或迭代器)变量不在第二个循环中的作用域中。下面是它与传统 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 循环,那么发送这种复制粘贴错误的可能性要小得多,因为没有必要在两个循环中使用不同的变量名。 循环是完全独立的,因此重用元素(或迭代器)变量名称没有坏处。 事实上,这样做通常很流行。
for 循环比 while 循环还有一个优点:它更短,增强了可读性。
下面是另一种循环习惯用法,它最小化了局部变量的作用域:
for (int i = 0, n = expensiveComputation(); i < n; i++) {
... // Do something with i;
}
关于这个做法需要注意的重要一点是,它有两个循环变量,i 和 n,它们都具有完全相同的作用域。第二个变量 n 用于存储第一个变量的限定值,从而避免了每次迭代中冗余计算的代价。作为一个规则,如果循环测试涉及一个方法调用,并且保证在每次迭代中返回相同的结果,那么应该使用这种用法。
最小化局部变量作用域的最终技术是保持方法小而集中。 如果在同一方法中组合两个行为(activities),则与一个行为相关的局部变量可能会位于执行另一个行为的代码范围内。 为了防止这种情况发生,只需将方法分为两个:每个行为对应一个方法。
58. for-each 循环优于传统 for 循环
正如在条目 45 中所讨论的,一些任务最好使用 Stream 来完成,一些任务最好使用迭代。下面是一个传统的 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 循环的实例:
// Not the best way to iterate over an array!
for (int i = 0; i < a.length; i++) {
... // Do something with a[i]
}
这些习惯用法比 while 循环更好(详见第 57 条),但是它们并不完美。迭代器和索引变量都很混乱——你只需要元素而已。此外,它们也代表了出错的机会。迭代器在每个循环中出现三次,索引变量出现四次,这使你有很多机会使用错误的变量。如果这样做,就不能保证编译器会发现到问题。最后,这两个循环非常不同,引起了对容器类型的不必要注意,并且增加了更改该类型的小麻烦。
for-each 循环(官方称为「增强的 for 语句」)解决了所有这些问题。它通过隐藏迭代器或索引变量来消除混乱和出错的机会。由此产生的习惯用法同样适用于集合和数组,从而简化了将容器的实现类型从一种转换为另一种的过程:
// The preferred idiom for iterating over collections and arrays
for (Element e : elements) {
... // Do something with e
}
当看到冒号(:) 时,请将其读作「in」。因此,上面的循环读作「对于元素 elements 中的每个元素 e」。使用 for-each 循环不会降低性能,即使对于数组也是如此:它们生成的代码本质上与手工编写的代码相同。
当涉及到嵌套迭代时,for-each 循环相对于传统 for 循环的优势甚至更大。下面是人们在进行嵌套迭代时经常犯的一个错误:
// 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()));
如果没有发现这个 bug,也不必感到难过。许多专业程序员都曾犯过这样或那样的错误。问题是,对于外部集合(suit),next 方法在迭代器上调用了太多次。它应该从外部循环调用,因此每花色调用一次,但它是从内部循环调用的,因此每一张牌调用一次。在 suit 用完之后,循环抛出 NoSuchElementException 异常。
如果你真的不走运,外部集合的大小是内部集合大小的倍数——也许它们是相同的集合——循环将正常终止,但它不会做你想要的。 例如,考虑这种错误的尝试,打印一对骰子的所有可能的掷法:
// Same bug, different symptom!
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 个组合。
要修复例子中的错误,必须在外部循环的作用域内添加一个变量来保存外部元素:
/ Fixed, but ugly - you can do better!
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 循环,问题就会消失。生成的代码也尽可能地简洁:
// 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 循环的:
- 有损过滤(Destructive filtering)——如果需要遍历集合,并删除指定选元素,则需要使用显式迭代器,以便可以调用其 remove 方法。 通常可以使用在 Java 8 中添加的 Collection 类中的 removeIf 方法,来避免显式遍历。
- 转换——如果需要遍历一个列表或数组并替换其元素的部分或全部值,那么需要列表迭代器或数组索引来替换元素的值。
- 并行迭代——如果需要并行地遍历多个集合,那么需要显式地控制迭代器或索引变量,以便所有迭代器或索引变量都可以同步进行 (正如上面错误的 card 和 dice 示例中无意中演示的那样)。
如果发现自己处于这些情况中的任何一种,请使用传统的 for 循环,并警惕本条目中提到的陷阱。
for-each 循环不仅允许遍历集合和数组,还允许遍历实现 Iterable 接口的任何对象,该接口由单个方法组成。接口定义如下:
public interface Iterable<E> {
// Returns an iterator over the elements in this iterable
Iterator<E> iterator();
}
如果必须从头开始编写自己的 Iterator 实现,那么实现 Iterable 会有点棘手,但是如果你正在编写表示一组元素的类型,那么你应该强烈考虑让它实现 Iterable 接口,甚至可以选择不让它实现 Collection 接口。这允许用户使用 for-each 循环遍历类型,他们会永远感激不尽的。
总之,for-each 循环在清晰度,灵活性和错误预防方面提供了超越传统 for 循环的令人注目的优势,而且没有性能损失。 尽可能使用 for-each 循环优先于 for 循环。
59. 了解并使用库
假设你想要生成 0 到某个上界之间的随机整数。面对这个常见任务,许多程序员会编写一个类似这样的小方法:
// Common but deeply flawed!
static Random rnd = new Random();
static int random(int n) {
return Math.abs(rnd.nextInt()) % n;
}
这个方法看起来不错,但它有三个缺点。首先,如果 n 是小的平方数,随机数序列会在相当短的时间内重复。第二个缺陷是,如果 n 不是 2 的幂,那么平均而言,一些数字将比其他数字更频繁地返回。如果 n 很大,这种效果会很明显。下面的程序有力地证明了这一点,它在一个精心选择的范围内生成 100 万个随机数,然后打印出有多少个数字落在范围的下半部分:
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);
}
如果 random 方法工作正常,程序将输出一个接近 50 万的数字,但是如果运行它,你将发现它输出一个接近 666666 的数字。随机方法生成的数字中有三分之二落在其范围的下半部分!
random 方法的第三个缺陷是,在极少数情况下会返回超出指定范围的数字,这是灾难性的结果。这是因为该方法试图通过调用 Math.abs 将 rnd.nextInt() 返回的值映射到非负整数。如果 nextInt() 返回整数。Integer.MIN_VALUE、Math.abs 也将返回整数。假设 n 不是 2 的幂,那么 Integer.MIN_VALUE 和求模运算符 (%) 将返回一个负数。几乎肯定的是,这会导致你的程序失败,并且这种失败可能难以重现。
要编写一个 random 方法来纠正这些缺陷,你必须对伪随机数生成器、数论和 2 的补码算法有一定的了解。幸运的是,你不必这样做(这是为你而做的成果)。它被称为 Random.nextInt(int)。你不必关心它如何工作的(尽管如果你感兴趣,可以研究文档或源代码)。一位具有算法背景的高级工程师花了大量时间设计、实现和测试这种方法,然后将其展示给该领域的几位专家,以确保它是正确的。然后,这个库经过 beta 测试、发布,并被数百万程序员广泛使用了近 20 年。该方法还没有发现任何缺陷,但是如果发现了缺陷,将在下一个版本中进行修复。通过使用标准库,你可以利用编写它的专家的知识和以前使用它的人的经验。
从 Java 7 开始,就不应该再使用 Random。在大多数情况下,选择的随机数生成器现在是 ThreadLocalRandom。 它能产生更高质量的随机数,而且速度非常快。在我的机器上,它比 Random 快 3.6 倍。对于 fork 连接池和并行流,使用 SplittableRandom。
使用这些库的第二个好处是,你不必浪费时间为那些与你的工作无关的问题编写专门的解决方案。如果你像大多数程序员一样,那么你宁愿将时间花在应用程序上,而不是底层管道上。
使用标准库的第三个优点是,随着时间的推移,它们的性能会不断提高,而你无需付出任何努力。由于许多人使用它们,而且它们是在行业标准基准中使用的,所以提供这些库的组织有很强的动机使它们运行得更快。多年来,许多 Java 平台库都被重新编写过,有时甚至是反复编写,从而带来了显著的性能改进。使用库的第四个好处是,随着时间的推移,它们往往会获得新功能。如果一个库丢失了一些东西,开发人员社区会将其公布于众,并且丢失的功能可能会在后续版本中添加。
使用标准库的最后一个好处是,可以将代码放在主干中。这样的代码更容易被开发人员阅读、维护和重用。
考虑到所有这些优点,使用库工具而不选择专门的实现似乎是合乎逻辑的,但许多程序员并不这样做。为什么不呢?也许他们不知道库的存在。在每个主要版本中,都会向库中添加许多特性,了解这些新增特性是值得的。 每次发布 Java 平台的主要版本时,都会发布一个描述其新特性的 web 页面。这些页面非常值得一读 [Java8-feat, Java9-feat]。为了强调这一点,假设你想编写一个程序来打印命令行中指定的 URL 的内容(这大致是 Linux curl 命令所做的)。在 Java 9 之前,这段代码有点乏味,但是在 Java 9 中,transferTo 方法被添加到 InputStream 中。这是一个使用这个新方法执行这项任务的完整程序:
// Printing the contents of a URL with transferTo, added in Java 9
public static void main(String[] args) throws IOException {
try (InputStream in = new URL(args[0]).openStream()) {
in.transferTo(System.out);
}
}
这些标准类库太庞大了,以致于不可能学完所有的文档 [Java9-api],但是 每个程序员都应该熟悉 java.lang、java.util 和 java.io 的基础知识及其子包。 其他库的知识可以根据需要获得。概述库中的工具超出了本条目的范围,这些工具多年来已经发展得非常庞大。
其中有几个库值得一提。Collections 框架和 Streams 库(详见第 45 到 48 条)应该是每个程序员的基本工具包的一部分,java.util.concurrent 中的并发实用程序也应该是其中的一部分。这个包既包含高级的并发工具来简化多线程的编程任务,还包含低级别的并发基本类型,允许专家们自己编写更高级的并发抽象。java.util.concurrent 的高级部分,在第 80 条和第 81 条中讨论。
有时,类库工具可能无法满足你的需求。你的需求越特殊,发生这种情况的可能性就越大。虽然你的第一个思路应该是使用这些库,但是如果你已经了解了它们在某些领域提供的功能,而这些功能不能满足你的需求,那么可以使用另一种实现。任何有限的库集所提供的功能总是存在漏洞。如果你在 Java 平台库中找不到你需要的东西,你的下一个选择应该是寻找高质量的第三方库,比如谷歌的优秀的开源 Guava 库 [Guava]。如果你无法在任何适当的库中找到所需的功能,你可能别无选择,只能自己实现它。
总而言之,不要白费力气重新发明轮子。如果你需要做一些看起来相当常见的事情,那么库中可能已经有一个工具可以做你想做的事情。如果有,使用它;如果你不知道,检查一下。一般来说,库代码可能比你自己编写的代码更好,并且随着时间的推移可能会得到改进。这并不反映你作为一个程序员的能力。规模经济决定了库代码得到的关注要远远超过大多数开发人员所能承担的相同功能。
60. 若需要精确答案就应避免使用 float 和 double 类型
float 和 double 类型主要用于科学计算和工程计算。它们执行二进制浮点运算,该算法经过精心设计,能够在很大范围内快速提供精确的近似值。但是,它们不能提供准确的结果,也不应该在需要精确结果的地方使用。float 和 double 类型特别不适合进行货币计算,因为不可能将 0.1(或 10 的任意负次幂)精确地表示为 float 或 double。
例如,假设你口袋里有 1.03 美元,你消费了 42 美分。你还剩下多少钱?下面是一个简单的程序片段,试图回答这个问题:
System.out.println(1.03 - 0.42);
不幸的是,它输出了 0.6100000000000001。这不是一个特例。假设你口袋里有一美元,你买了 9 台洗衣机,每台 10 美分。你能得到多少零钱?
System.out.println(1.00 - 9 * 0.10);
根据这个程序片段,可以得到 0.0999999999999999998 美元。
你可能认为,只需在打印之前将结果四舍五入就可以解决这个问题,但不幸的是,这种方法并不总是有效。例如,假设你口袋里有一美元,你看到一个架子上有一排好吃的糖果,它们的价格仅仅是 10 美分,20 美分,30 美分,以此类推,直到 1 美元。你每买一颗糖,从 10 美分的那颗开始,直到你买不起货架上的下一颗糖。你买了多少糖果,换了多少零钱?这里有一个简单的程序来解决这个问题:
// 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 类型代替 double。注意,使用 BigDecimal 的 String 构造函数而不是它的 double 构造函数。这是为了避免在计算中引入不准确的值 [Bloch05, Puzzle 2]:
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);
}
如果你运行修改后的程序,你会发现你可以买四颗糖,最终剩下 0 美元。这是正确答案。
然而,使用 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");
}
总之,对于任何需要精确答案的计算,不要使用 float 或 double 类型。如果希望系统来处理十进制小数点,并且不介意不使用基本类型带来的不便和成本,请使用 BigDecimal。使用 BigDecimal 的另一个好处是,它可以完全控制舍入,当执行需要舍入的操作时,可以从八种舍入模式中进行选择。如果你使用合法的舍入行为执行业务计算,这将非常方便。如果性能是最重要的,那么你不介意自己处理十进制小数点,而且数值不是太大,可以使用 int 或 long。如果数值不超过 9 位小数,可以使用 int;如果不超过 18 位,可以使用 long。如果数量可能超过 18 位,则使用 BigDecimal。
61. 基本数据类型优于包装类
Java 有一个由两部分组成的类型系统,包括基本类型(如 int、double 和 boolean)和引用类型(如 String 和 List)。每个基本类型都有一个对应的引用类型,称为包装类型。与 int、double 和 boolean 对应的包装类是 Integer、Double 和 Boolean。
正如条目 6 中提到的,自动装箱和自动拆箱模糊了基本类型和包装类型之间的区别,但不会消除它们。这两者之间有真正的区别,重要的是你要始终意识到正在使用的是哪一种,并在它们之间仔细选择。
基本类型和包装类型之间有三个主要区别。首先,基本类型只有它们的值,而包装类型具有与其值不同的标识。换句话说,两个包装类型实例可以具有相同的值和不同的标识。第二,基本类型只有全功能值,而每个包装类型除了对应的基本类型的所有功能值外,还有一个非功能值,即 null。最后,基本类型比包装类型更节省时间和空间。如果你不小心的话,这三种差异都会给你带来真正的麻烦。
考虑下面的比较器,它的设计目的是表示 Integer 值上的升序数字排序。(回想一下,比较器的 compare 方法返回一个负数、零或正数,这取决于它的第一个参数是小于、等于还是大于第二个参数。)你不需要在实际使用中编写这个比较器,因为它实现了 Integer 的自然排序,但它提供了一个有趣的例子:
// Broken comparator - can you spot the flaw?
Comparator<Integer> naturalOrder =(i, j) -> (i < j) ? -1 : (i == j ? 0 : 1);
这个比较器看起来应该可以工作,它将通过许多测试。例如,它可以与 Collections.sort 一起使用,以正确地排序一个百万元素的 List,无论该 List 是否包含重复的元素。但这个比较存在严重缺陷。要使自己相信这一点,只需打印 naturalOrder.compare(new Integer(42), new Integer(42)) 的值。两个 Integer 实例都表示相同的值 (42),所以这个表达式的值应该是 0,但它是 1,这表明第一个 Integer 值大于第二个!
那么问题出在哪里呢?naturalOrder 中的第一个测试工作得很好。计算表达式 i < j 会使 i 和 j 引用的 Integer 实例自动拆箱;也就是说,它提取它们的基本类型值。计算的目的是检查得到的第一个 int 值是否小于第二个 int 值。但假设它不是。然后,下一个测试计算表达式 i==j,该表达式对两个对象引用执行标识比较。如果 i 和 j 引用表示相同 int 值的不同 Integer 实例,这个比较将返回 false,比较器将错误地返回 1,表明第一个整型值大于第二个整型值。将 == 操作符应用于包装类型几乎都是错误的。
在实际使用中,如果你需要一个比较器来描述类型的自然顺序,你应该简单地调用 Comparator.naturalOrder(),如果你自己编写一个比较器,你应该使用比较器构造方法,或者对基本类型使用静态比较方法(详见第 14 条)。也就是说,你可以通过添加两个局部变量来存储基本类型 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。问题是,i 是 Integer,而不是 int 数,而且像所有非常量对象引用字段一样,它的初值为 null。当程序计算表达式 i==42 时,它是在比较 Integer 与 int。在操作中混合使用基本类型和包装类型时,包装类型就会自动拆箱,这种情况无一例外。如果一个空对象引用自动拆箱,那么你将得到一个 NullPointerException。正如这个程序所演示的,它几乎可以在任何地方发生。修复这个问题非常简单,只需将 i 声明为 int 而不是 Integer。
最后,考虑条目 6 中第 24 页的程序:
// 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);
}
这个程序比它预期的速度慢得多,因为它意外地声明了一个局部变量 (sum),它是包装类型 Long,而不是基本类型 long。程序在没有错误或警告的情况下编译,变量被反复装箱和拆箱,导致产生明显的性能下降。
在本条目中讨论的所有三个程序中,问题都是一样的:程序员忽略了基本类型和包装类型之间的区别,并承担了恶果。在前两个项目中,结果是彻底的失败;第三个例子还产生了严重的性能问题。
那么,什么时候应该使用包装类型呢?它们有几个合法的用途。第一个是作为集合中的元素、键和值。不能将基本类型放在集合中,因此必须使用包装类型。这是一般情况下的特例。在参数化类型和方法(Chapter 5)中,必须使用包装类型作为类型参数,因为 Java 不允许使用基本类型。例如,不能将变量声明为 ThreadLocal<int> 类型,因此必须使用 ThreadLocal<Integer>。最后,在进行反射方法调用时,必须使用包装类型(详见第 65 条)。
总之,只要有选择,就应该优先使用基本类型,而不是包装类型。基本类型更简单、更快。如果必须使用包装类型,请小心!自动装箱减少了使用包装类型的冗长,但没有减少危险。 当你的程序使用 == 操作符比较两个包装类型时,它会执行标识比较,这几乎肯定不是你想要的。当你的程序执行包含包装类型和基本类型的混合类型计算时,它将进行拆箱,当你的程序执行拆箱时,将抛出 NullPointerException。 最后,当你的程序将基本类型装箱时,可能会导致代价高昂且不必要的对象创建。
62. 当使用其他类型更合适时应避免使用字符串
字符串被设计用来表示文本,它们在这方面做得很好。因为字符串是如此常见,并且受到 Java 的良好支持,所以很自然地会将字符串用于其他目的,而不是它们适用的场景。本条目讨论了一些不应该使用字符串的场景。
字符串是其他值类型的糟糕替代品。 当一段数据从文件、网络或键盘输入到程序时,它通常是字符串形式的。有一种很自然的倾向是保持这种格式不变,但是这种倾向只有在数据本质上是文本的情况下才合理。如果是数值类型,则应将其转换为适当的数值类型,如 int、float 或 BigInteger。如果是问题的答案,如「是」或「否」这类形式,则应将其转换为适当的枚举类型或布尔值。更一般地说,如果有合适的值类型,无论是基本类型还是对象引用,都应该使用它;如果没有,你应该写一个。虽然这条建议似乎很多余,但经常被违反。
字符串是枚举类型的糟糕替代品。 正如条目 34 中所讨论的,枚举类型常量比字符串更适合于枚举类型常量。
字符串是聚合类型的糟糕替代品。 如果一个实体有多个组件,将其表示为单个字符串通常是一个坏主意。例如,下面这行代码来自一个真实的系统标识符,它的名称已经被更改,以免引发罪责:
// Inappropriate use of string as aggregate type
String compoundKey = className + "#" + i.next();
这种方法有很多缺点。如果用于分隔字段的字符出现在其中一个字段中,可能会导致混乱。要访问各个字段,你必须解析字符串,这是缓慢的、冗长的、容易出错的过程。你不能提供 equals、toString 或 compareTo 方法,但必须接受 String 提供的行为。更好的方法是编写一个类来表示聚合,通常是一个私有静态成员类(详见第 24 条)。
字符串不能很好地替代 capabilities。 有时,字符串用于授予对某些功能的访问权。例如,考虑线程本地变量机制的设计。这样的机制提供了每个线程都有自己的变量值。自 1.2 版以来,Java 库就有了一个线程本地变量机制,但在此之前,程序员必须自己设计。许多年前,当面临设计这样一个机制的任务时,有人提出了相同的设计,其中客户端提供的字符串键,用于标识每个线程本地变量:
// 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);
}
这种方法的问题在于,字符串键表示线程本地变量的共享全局名称空间。为了使这种方法有效,客户端提供的字符串键必须是惟一的:如果两个客户端各自决定为它们的线程本地变量使用相同的名称,它们无意中就会共享一个变量,这通常会导致两个客户端都失败。而且,安全性很差。恶意客户端可以故意使用与另一个客户端相同的字符串密钥来非法访问另一个客户端的数据。
这个 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 中基于字符串的两个问题,但是你可以做得更好。你不再真正需要静态方法。它们可以变成键上的实例方法,此时键不再是线程局部变量:而是线程局部变量。此时,顶层类不再为你做任何事情,所以你可以删除它,并将嵌套类重命名为 ThreadLocal:
public final class ThreadLocal {
public ThreadLocal();
public void set(Object value);
public Object get();
}
这个 API 不是类型安全的,因为在从线程本地变量检索值时,必须将值从 Object 转换为它的实际类型。原始的基于 String 类型 API 的类型安全是不可能实现的,基于键的 API 的类型安全也是很难实现的,但是通过将 ThreadLocal 作为一个参数化的类来实现这个 API 的类型安全很简单(详见第 29 条):
public final class ThreadLocal<T> {
public ThreadLocal();
public void set(T value);
public T get();
}
粗略地说,这就是 java.lang.ThreadLocal 提供的 API,除了解决基于字符串的问题之外,它比任何基于键的 API 都更快、更优雅。
总之,当存在或可以编写更好的数据类型时,应避免将字符串用来表示对象。如果使用不当,字符串比其他类型更麻烦、灵活性更差、速度更慢、更容易出错。字符串经常被误用的类型包括基本类型、枚举和聚合类型。
63. 当心字符串连接引起的性能问题
字符串连接操作符 (+) 是将几个字符串组合成一个字符串的简便方法。对于生成单行输出或构造一个小的、固定大小的对象的字符串表示形式,它是可以的,但是它不能伸缩。使用 字符串串联运算符重复串联 n 个字符串需要 n 的平方级时间。 这是字符串不可变这一事实导致的结果(详见第 17 条)。当连接两个字符串时,将复制这两个字符串的内容。
例如,考虑这个方法,它通过将每个账单项目重复连接到一行来构造账单语句的字符串表示:
// Inappropriate use of string concatenation - Performs poorly!
public String statement() {
String result = "";
for (int i = 0; i < numItems(); i++)
result += lineForItem(i); // String concatenation
return 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();
}
自 Java 6 以来,为了使字符串连接更快,已经做了大量工作,但是这两个方法在性能上的差异仍然很大:如果 numItems 返回 100,lineForItem 返回 80 个字符串,那么第二个方法在我的机器上运行的速度是第一个方法的 6.5 倍。由于第一种方法在项目数量上是平方级的,而第二种方法是线性的,所以随着项目数量的增加,性能差异会变得越来越大。注意,第二个方法预先分配了一个足够大的 StringBuilder 来保存整个结果,从而消除了自动增长的需要。即使使用默认大小的 StringBuilder,它仍然比第一个方法快 5.5 倍。
道理很简单:不要使用字符串连接操作符合并多个字符串,除非性能无关紧要。否则使用 StringBuilder 的 append 方法。或者,使用字符数组,再或者一次只处理一个字符串,而不是组合它们。
64. 通过接口引用对象
条目 51 指出,应该使用接口而不是类作为参数类型。更一般地说,你应该优先使用接口而不是类来引用对象。如果存在合适的接口类型,那么应该使用接口类型声明参数、返回值、变量和字段。 惟一真正需要引用对象的类的时候是使用构造函数创建它的时候。为了具体说明这一点,考虑 LinkedHashSet 的情况,它是 Set 接口的一个实现。声明时应养成这样的习惯:
// Good - uses interface as type
Set<Son> sonSet = new LinkedHashSet<>();
而不是这样:
// Bad - uses class as type!
LinkedHashSet<Son> sonSet = new LinkedHashSet<>();
如果你养成了使用接口作为类型的习惯,那么你的程序将更加灵活。 如果你决定要切换实现,只需在构造函数中更改类名(或使用不同的静态工厂)。例如,第一个声明可以改为:
Set<Son> sonSet = new HashSet<>();
所有的代码都会继续工作。周围的代码不知道旧的实现类型,所以它不会在意更改。
有一点值得注意:如果原实现提供了接口的通用约定不需要的一些特殊功能,并且代码依赖于该功能,那么新实现提供相同的功能就非常重要。例如,如果围绕第一个声明的代码依赖于 LinkedHashSet 的排序策略,那么在声明中将 HashSet 替换为 LinkedHashSet 将是不正确的,因为 HashSet 不保证迭代顺序。
那么,为什么要更改实现类型呢?因为第二个实现比原来的实现提供了更好的性能,或者因为它提供了原来的实现所缺乏的理想功能。例如,假设一个字段包含一个 HashMap 实例。将其更改为 EnumMap 将为迭代提供更好的性能和与键的自然顺序,但是你只能在键类型为 enum 类型的情况下使用 EnumMap。将 HashMap 更改为 LinkedHashMap 将提供可预测的迭代顺序,性能与 HashMap 相当,而不需要对键类型作出任何特殊要求。
你可能认为使用变量的实现类型声明变量是可以的,因为你可以同时更改声明类型和实现类型,但是不能保证这种更改会正确编译程序。如果客户端代码对原实现类型使用了替换时不存在的方法,或者客户端代码将实例传递给需要原实现类型的方法,那么在进行此更改之后,代码将不再编译。使用接口类型声明变量可以保持一致。
如果没有合适的接口存在,那么用类引用对象是完全合适的。 例如,考虑值类,如 String 和 BigInteger。值类很少在编写时考虑到多个实现。它们通常是 final 的,很少有相应的接口。使用这样的值类作为参数、变量、字段或返回类型非常合适。
没有合适接口类型的第二种情况是属于框架的对象,框架的基本类型是类而不是接口。如果一个对象属于这样一个基于类的框架,那么最好使用相关的基类来引用它,这通常是抽象的,而不是使用它的实现类。在 java.io 类中许多诸如 OutputStream 之类的就属于这种情况。
没有合适接口类型的最后一种情况是,实现接口但同时提供接口中不存在的额外方法的类,例如,PriorityQueue 有一个在 Queue 接口上不存在的比较器方法。只有当程序依赖于额外的方法时,才应该使用这样的类来引用它的实例,这种情况应该非常少见。
这三种情况并不是面面俱到的,而仅仅是为了传达适合通过类引用对象的情况。在实际应用中,给定对象是否具有适当的接口应该是显而易见的。如果是这样,如果使用接口引用对象,程序将更加灵活和流行。如果没有合适的接口,就使用类层次结构中提供所需功能的最底层的类
65. 接口优于反射
核心反射机制 java.lang.reflect 提供对任意类的编程访问。给定一个 Class 对象,你可以获得 Constructor、Method 和 Field 实例,分别代表了该 Class 实例所表示的类的构造器、方法和字段。这些对象提供对类的成员名、字段类型、方法签名等的编程访问。
此外,Constructor、Method 和 Field 实例允许你反射性地操作它们的底层对应项:你可以通过调用 Constructor、Method 和 Field 实例上的方法,可以构造底层类的实例、调用底层类的方法,并访问底层类中的字段。例如,Method.invoke 允许你在任何类的任何对象上调用任何方法(受默认的安全约束)。反射允许一个类使用另一个类,即使在编译前者时后者并不存在。然而,这种能力是有代价的:
- 你失去了编译时类型检查的所有好处, 包括异常检查。如果一个程序试图反射性地调用一个不存在的或不可访问的方法,它将在运行时失败,除非你采取了特殊的预防措施。
- 执行反射访问所需的代码既笨拙又冗长。 写起来很乏味,读起来也很困难。
- 性能降低。 反射方法调用比普通方法调用慢得多。到底慢了多少还很难说,因为有很多因素在起作用。在我的机器上,调用一个没有输入参数和返回 int 类型的方法时,用反射执行要慢 11 倍。
有一些复杂的应用程序需要反射。包括代码分析工具和依赖注入框架。即使是这样的工具,随着它的缺点变得越来越明显,人们也在逐渐远离并反思这种用法。如果你对应用程序是否需要反射有任何疑问,那么它可能不需要。
通过非常有限的形式使用反射,你可以获得反射的许多好处,同时花费的代价很少。 对于许多程序,它们必须用到在编译时无法获取的类,在编译时存在一个适当的接口或超类来引用该类(详见第 64 条)。如果是这种情况,可以用反射方式创建实例,并通过它们的接口或超类正常地访问它们。
例如,这是一个创建 Set<String> 实例的程序,类由第一个命令行参数指定。程序将剩余的命令行参数插入到集合中并打印出来。不管第一个参数是什么,程序都会打印剩余的参数,并去掉重复项。然而,打印这些参数的顺序取决于第一个参数中指定的类。如果你指定 java.util.HashSet,它们显然是随机排列的;如果你指定 java.util.TreeSet,它们是按字母顺序打印的,因为 TreeSet 中的元素是有序的:
// Reflective instantiation with interface access
public static void main(String[] args) {
// Translate the class name into a Class object
Class<? 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 constructor
Constructor<? extends Set<String>> cons = null;
try {
cons = cl.getDeclaredConstructor();
} catch (NoSuchMethodException e) {
fatalError("No parameterless constructor");
}
// Instantiate the set
Set<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 set
s.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);
}
虽然这个程序只是一个小把戏,但它演示的技术非常强大。这个程序可以很容易地转换成一个通用的集合测试器,通过积极地操作一个或多个实例并检查它们是否遵守 Set 接口约定来验证指定的 Set 实现。类似地,它可以变成一个通用的集合性能分析工具。事实上,该技术足够强大,可以实现一个成熟的服务提供者框架(详见第 1 条)。
这个例子也说明了反射的两个缺点。首先,该示例可以在运行时生成六个不同的异常,如果没有使用反射实例化,所有这些异常都将是编译时错误。(有趣的是,你可以通过传入适当的命令行参数,使程序生成六个异常中的每一个。)第二个缺点是,根据类的名称生成类的实例需要 25 行冗长的代码,而构造函数调用只需要一行。通过捕获 ReflectiveOperationException(Java 7 中引入的各种反射异常的超类),可以减少程序的长度。这两个缺点都只限于实例化对象的程序部分。实例化后,与任何其他 Set 实例将难以区分。在实际的程序中,通过这种限定使用反射的方法,大部分代码可以免受影响。
如果编译此程序,将得到 unchecked 的强制转换警告。这个警告是合法的,即使指定的类不是 Set 实现,Class<? extends Set<String>> 也会成功,在这种情况下,程序在实例化类时抛出 ClassCastException。要了解如何抑制警告,请阅读条目 27。
反射的合法用途(很少)是管理类对运行时可能不存在的其他类、方法或字段的依赖关系。如果你正在编写一个包,并且必须针对其他包的多个版本运行,此时反射将非常有用。该技术是根据支持包所需的最小环境(通常是最老的版本)编译包,并反射性地访问任何较新的类或方法。如果你试图访问的新类或方法在运行时不存在,要使此工作正常进行,则必须采取适当的操作。适当的操作可能包括使用一些替代方法来完成相同的目标,或者使用简化的功能进行操作。
总之,反射是一种功能强大的工具,对于某些复杂的系统编程任务是必需的,但是它有很多缺点。如果编写的程序必须在编译时处理未知的类,则应该尽可能只使用反射实例化对象,并使用在编译时已知的接口或超类访问对象。
66. 明智审慎地本地方法
Java 本地接口(JNI)允许 Java 程序调用本地方法,这些方法是用 C 或 C++ 等本地编程语言编写的。从历史上看,本地方法主要有三种用途。它们提供对特定于平台的设施(如注册中心)的访问。它们提供对现有本地代码库的访问,包括提供对遗留数据访问。最后,本地方法可以通过本地语言编写应用程序中注重性能的部分,以提高性能。
使用本地方法访问特定于平台的机制是合法的,但是很少有必要:随着 Java 平台的成熟,它提供了对许多以前只能在宿主平台中上找到的特性。例如,Java 9 中添加的流 API 提供了对 OS 流程的访问。在 Java 中没有等效库时,使用本地方法来使用本地库也是合法的。
使用本地方法来提高性能的行为很少是明智的。 在早期版本(Java 3 之前),这通常是必要的,但是从那时起 JVM 变得更快了。对于大多数任务,现在可以在 Java 中获得类似的性能。例如,在版本 1.1 中添加了 java.math,BigInteger 是在一个用 C 编写的快速多精度运算库的基础上实现的。在当时,为了获得足够的性能这样做是必要的。在 Java 3 中,BigInteger 则完全用 Java 重写了,并且进行了性能调优,新的版本比原来的版本更快。
这个故事的一个可悲的结尾是,除了在 Java 8 中对大数进行更快的乘法运算之外,BigInteger 此后几乎没有发生什么变化。在此期间,对本地库的工作继续快速进行,尤其是 GNU 多精度算术库(GMP)。需要真正高性能多精度算法的 Java 程序员现在可以通过本地方法使用 GMP [Blum14]。
使用本地方法有严重的缺点。由于本地语言不安全(详见第 50 条),使用本地方法的应用程序不再能免受内存毁坏错误的影响。由于本地语言比 Java 更依赖于平台,因此使用本地方法的程序的可移植性较差。它们也更难调试。如果不小心,本地方法可能会降低性能,因为垃圾收集器无法自动跟踪本地内存使用情况(详见第 8 条),而且进出本地代码会产生相关的成本。最后,本地方法需要「粘合代码」,这很难阅读,而且编写起来很乏味。
总之,在使用本地方法之前要三思。一般很少需要使用它们来提高性能。如果必须使用本地方法来访问底层资源或本地库,请尽可能少地使用本地代码,并对其进行彻底的测试。本地代码中的一个错误就可以破坏整个应用程序。
67. 明智审慎地进行优化
有三条关于优化的格言是每个人都应该知道的:
比起任何其他单一原因(包括盲目愚蠢),计算上的过失更多的是以效率为名(不一定能实现)而犯下的。
—William A. Wulf [Wulf72]
不要去计较效率上的一些小小的得失,在 97% 的情况下,不成熟的优化才是一切问题的根源。
—Donald E. Knuth [Knuth74]
在优化方面,我们应该遵守两条规则:
规则 1:不要进行优化。
规则 2 (仅针对专家):还是不要进行优化,也就是说,在你还没有绝对清晰的未优化方案之前,请不要进行优化。
—M. A. Jackson [Jackson75]
所有这些格言都比 Java 编程语言早了 20 年。它们告诉我们关于优化的一个深刻的事实:很容易弊大于利,尤其是如果过早地进行优化。在此过程中,你可能会生成既不快速也不正确且无法轻松修复的软件。
不要为了性能而牺牲合理的架构。努力编写 好的程序,而不是快速的程序。 如果一个好的程序不够快,它的架构将允许它被优化。好的程序体现了信息隐藏的原则:在可能的情况下,它们在单个组件中本地化设计决策,因此可以在不影响系统其余部分的情况下更改单个决策(详见第 15 条)。
这并不意味着在程序完成之前可以忽略性能问题。实现上的问题可以通过以后的优化来解决,但是对于架构缺陷,如果不重写系统,就不可能解决限制性能的问题。在系统完成之后再改变设计的某个基本方面可能导致结构不良的系统难以维护和进化。因此,你必须在设计过程中考虑性能。
尽量避免限制性能的设计决策。 设计中最难以更改的组件是那些指定组件之间以及与外部世界的交互的组件。这些设计组件中最主要的是 API、线路层协议和持久数据格式。这些设计组件不仅难以或不可能在事后更改,而且所有这些组件都可能对系统能够达到的性能造成重大限制。
考虑API设计决策的性能结果。 使公共类型转化为可变,可能需要大量不必要的防御性复制(详见第 50 条)。类似地,在一个公共类中使用继承(在这个类中组合将是合适的)将该类永远绑定到它的超类,这会人为地限制子类的性能(详见第 18 条)。最后一个例子是,在 API 中使用实现类而不是接口将你绑定到特定的实现,即使将来可能会编写更快的实现也无法使用(详见第 64 条)。
API 设计对性能的影响是非常实际的。考虑 java.awt.Component 中的 getSize 方法。该性能很关键方法返回 Dimension 实例的决定,加上维度实例是可变的决定,强制该方法的任何实现在每次调用时分配一个新的 Dimension 实例。尽管在现代 VM 上分配小对象并不昂贵,但不必要地分配数百万个对象也会对性能造成实际损害。
存在几种 API 设计替代方案。理想情况下,Dimension 应该是不可变的(详见第 17 条);或者,getSize 可以被返回 Dimension 对象的原始组件的两个方法所替代。事实上,出于性能原因,在 Java 2 的组件中添加了两个这样的方法。然而,现有的客户端代码仍然使用 getSize 方法,并且仍然受到原始 API 设计决策的性能影响。
幸运的是,通常情况下,好的 API 设计与好的性能是一致的。为了获得良好的性能而改变 API 是一个非常糟糕的想法。 导致你改变 API 的性能问题,可能在平台或其他底层软件的未来版本中消失,但是改变的 API 和随之而来的问题将永远伴随着你。
一旦你仔细地设计了你的程序,成了一个清晰、简洁、结构良好的实现,那么可能是时候考虑优化了,假设此时你还不满意程序的性能。
记得 Jackson 的两条优化规则是「不要做」和「(只针对专家)」。先别这么做。他本可以再加一个:在每次尝试优化之前和之后测量性能。 你可能会对你的发现感到惊讶。通常,试图做的优化通常对于性能并没有明显的影响;有时候,还让事情变得更糟。主要原因是很难猜测程序将时间花费在哪里。程序中你认为很慢的部分可能并没有问题,在这种情况下,你是在浪费时间来优化它。一般认为,程序将 90% 的时间花费在了 10% 的代码上。
分析工具可以帮助你决定将优化工作的重点放在哪里。这些工具提供了运行时信息,比如每个方法大约花费多少时间以及调用了多少次。除了关注你的调优工作之外,这还可以提醒你是否需要改变算法。如果程序中潜伏着平方级(或更差)的算法,那么再多的调优也无法解决这个问题。你必须用一个更有效的算法来代替这个算法。系统中的代码越多,使用分析器就越重要。这就像大海捞针:大海越大,金属探测器就越有用。另一个值得特别提及的工具是 jmh,它不是一个分析器,而是一个微基准测试框架,提供了对 Java 代码性能无与伦比的预测性。
与 C 和 C++ 等更传统的语言相比,Java 甚至更需要度量尝试优化的效果,因为 Java 的性能模型更弱:各种基本操作的相对成本没有得到很好的定义。程序员编写的内容和 CPU 执行的内容之间的「抽象鸿沟」更大,这使得可靠地预测优化的性能结果变得更加困难。有很多关于性能的传说流传开来,但最终被证明是半真半假或彻头彻尾的谎言。
Java 的性能模型不仅定义不清,而且在不同的实现、不同的发布版本、不同的处理器之间都有所不同。如果你要在多个实现或多个硬件平台上运行程序,那么度量优化对每个平台的效果是很重要的。有时候,你可能会被迫在不同实现或硬件平台上的性能之间进行权衡。
自本条目首次编写以来的近 20 年里,Java 软件栈的每个组件都变得越来越复杂,从处理器到 vm 再到库,Java 运行的各种硬件都有了极大的增长。所有这些加在一起,使得 Java 程序的性能比 2001 年更难以预测,而对它进行度量的需求也相应增加。
总而言之,不要努力写快的程序,要努力写好程序;速度自然会提高。但是在设计系统时一定要考虑性能,特别是在设计API、线路层协议和持久数据格式时。当你完成了系统的构建之后,请度量它的性能。如果足够快,就完成了。如果没有,利用分析器找到问题的根源,并对系统的相关部分进行优化。第一步是检查算法的选择:再多的底层优化也不能弥补算法选择的不足。根据需要重复这个过程,在每次更改之后测量性能,直到你满意为止。
68. 遵守被广泛认可的命名约定
Java 平台有一组完善的命名约定,其中许多约定包含在《The Java Language Specification》[JLS, 6.1]。不严格地讲,命名约定分为两类:排版和语法。
有少量的与排版有关的命名约定,包括包、类、接口、方法、字段和类型变量。如果没有很好的理由,你不应该违反它们。如果 API 违反了这些约定,那么它可能很难使用。如果实现违反了这些规则,可能很难维护。在这两种情况下,违规都有可能使其他使用代码的程序员感到困惑和恼怒,并使他们做出错误的假设,从而导致错误。本条目概述了各项约定。
包名和模块名应该是分层的,组件之间用句点分隔。组件应该由小写字母组成,很少使用数字。任何在你的组织外部使用的包,名称都应该以你的组织的 Internet 域名开头,并将组件颠倒过来,例如,edu.cmu、com.google、org.eff。以 java 和 javax 开头的标准库和可选包是这个规则的例外。用户不能创建名称以 java 或 javax 开头的包或模块。将 Internet 域名转换为包名前缀的详细规则可以在《The Java Language Specification》[JLS, 6.1] 中找到。
包名的其余部分应该由描述包的一个或多个组件组成。组件应该很短,通常为 8 个或更少的字符。鼓励使用有意义的缩写,例如 util 而不是 utilities。缩写词是可以接受的,例如 awt。组件通常应该由一个单词或缩写组成。
除了 Internet 域名之外,许多包的名称只有一个组件。附加组件适用于大型工具包,这些工具包的大小要求将其分解为非正式的层次结构。例如 javax.util 包具有丰富的包层次结构,包的名称如 java.util.concurrent.atomic。这样的包称为子包,尽管 Java 几乎不支持包层次结构。
类和接口名称,包括枚举和注释类型名称,应该由一个或多个单词组成,每个单词的首字母大写,例如 List 或 FutureTask。除了缩略语和某些常见的缩略语,如 max 和 min,缩略语应该避免使用。缩略语应该全部大写,还是只有首字母大写,存在一些分歧。虽然有些程序员仍然使用大写字母,但支持只将第一个字母大写的理由很充分:即使多个首字母缩写连续出现,你仍然可以知道一个单词从哪里开始,下一个单词从哪里结束。你希望看到哪个类名,HTTPURL 还是 HttpUrl?
方法和字段名遵循与类和接口名相同的排版约定,除了方法或字段名的第一个字母应该是小写,例如 remove 或 ensureCapacity。如果方法或字段名的首字母缩写出现在第一个单词中,那么它应该是小写的。
前面规则的唯一例外是「常量字段」,它的名称应该由一个或多个大写单词组成,由下划线分隔,例如 VALUES 或 NEGATIVE_INFINITY。常量字段是一个静态的 final 字段,其值是不可变的。如果静态 final 字段具有基本类型或不可变引用类型(第17项),那么它就是常量字段。例如,枚举常量是常量字段。如果静态 final 字段有一个可变的引用类型,那么如果所引用的对象是不可变的,那么它仍然可以是一个常量字段。注意,常量字段是唯一推荐使用下划线用法的。
局部变量名与成员名具有类似的排版命名约定,但允许使用缩写,也允许使用单个字符和短字符序列,它们的含义取决于它们出现的上下文,例如 i、denom、houseNum。输入参数是一种特殊的局部变量。它们的命名应该比普通的局部变量谨慎得多,因为它们的名称是方法文档的组成部分。
类型参数名通常由单个字母组成。最常见的是以下五种类型之一:T 表示任意类型,E 表示集合的元素类型,K 和 V 表示 Map 的键和值类型,X 表示异常。函数的返回类型通常为 R。任意类型的序列可以是 T、U、V 或 T1、T2、T3。
为了快速参考,下表显示了排版约定的示例。
| Identifier Type | Example |
|---|---|
| Package or module | org.junit.jupiter.api, com.google.common.collect |
| Class or Interface | Stream, FutureTask, LinkedHashMap,HttpClient |
| Method or Field | remove, groupingBy, getCrc |
| Constant Field | MIN_VALUE, NEGATIVE_INFINITY |
| Local Variable | i, denom, houseNum |
| Type Parameter | T, E, K, V, X, R, U, V, T1, T2 |
语法命名约定比排版约定更灵活,也更有争议。包没有语法命名约定。可实例化的类,包括枚举类型,通常使用一个或多个名词短语来命名,例如 Thread、PriorityQueue 或 ChessPiece。不可实例化的实用程序类(详见第 4 条)通常使用复数名词来命名,例如 collector 或 Collections。接口的名称类似于类,例如集合或比较器,或者以 able 或 ible 结尾的形容词,例如 Runnable、Iterable 或 Accessible。因为注解类型有很多的用途,所以没有哪部分占主导地位。名词、动词、介词和形容词都很常见,例如,BindingAnnotation、Inject、ImplementedBy 或 Singleton。
执行某些操作的方法通常用动词或动词短语(包括对象)命名,例如,append 或 drawImage。返回布尔值的方法的名称通常以单词 is 或 has(通常很少用)开头,后面跟一个名词、一个名词短语,或者任何用作形容词的单词或短语,例如 isDigit、isProbablePrime、isEmpty、isEnabled 或 hasSiblings。
返回被调用对象的非布尔函数或属性的方法通常使用以 get 开头的名词、名词短语或动词短语来命名,例如 size、hashCode 或 getTime。有一种说法是,只有第三种形式(以 get 开头)才是可接受的,但这种说法几乎没有根据。前两种形式的代码通常可读性更强,例如:
if (car.speed() > 2 * SPEED_LIMIT)
generateAudibleAlert("Watch out for cops!");
以 get 开头的表单起源于基本过时的 Java bean 规范,该规范构成了早期可重用组件体系结构的基础。有一些现代工具仍然依赖于 bean 命名约定,你应该可以在任何与这些工具一起使用的代码中随意使用它。如果类同时包含相同属性的 setter 和 getter,则遵循这种命名约定也有很好的先例。在本例中,这两个方法通常被命名为 getAttribute 和 setAttribute。
一些方法名称值得特别注意。转换对象类型(返回不同类型的独立对象)的实例方法通常称为 toType,例如 toString 或 toArray。返回与接收对象类型不同的视图(详见第 6 条)的方法通常称为 asType,例如 asList。返回与调用它们的对象具有相同值的基本类型的方法通常称为类型值,例如 intValue。静态工厂的常见名称包括 from、of、valueOf、instance、getInstance、newInstance、getType 和 newType(详见第 1 条,第 9 页)。
字段名的语法约定没有类、接口和方法名的语法约定建立得好,也不那么重要,因为设计良好的 API 包含很少的公开字段。类型为 boolean 的字段的名称通常类似于 boolean 访问器方法,省略了初始值「is」,例如 initialized、composite。其他类型的字段通常用名词或名词短语来命名,如 height、digits 和 bodyStyle。局部变量的语法约定类似于字段的语法约定,但要求更少。
总之,将标准命名约定内在化,并将其作为第二性征来使用。排版习惯是直接的,而且在很大程度上是明确的;语法惯例更加复杂和松散。引用《The Java Language Specification》[JLS, 6.1] 中的话说,「如果长期以来的传统用法要求不遵循这些约定,就不应该盲目地遵循这些约定。」,应使用常识判断。
69. 只针对异常的情况下才使用异常
假如你某一天不走运的话,可能遇到如下代码:
/* Horrible abuse of exceptions. Don't ever do this! */
try {
int i = 0;
while ( true )
range[i++].climb();
} catch ( ArrayIndexOutOfBoundsException e ) {
}
这段代码是干什么的?看起来根本不明显,这正是它没有真正被使用的原因(详见 67 条)。事实证明,作为一个要对数组元素进行遍历的实现方式,它的构想是十分拙劣的。当这个循环企图访问数组边界之外的第一个数组元素的时候,使用 try-catch 并且忽略 ArrayIndexOutOfBoundsException 异常的手段来达到终止无限循环的目的。假定它与数组循环的标准模式是等价的,对于它的标准模式每个 Java 程序员都可以一眼辨认出来:
for ( Mountain m : range )
m.climb();
那么为什么有人会企图使用基于异常的循环,而不是使用行之有效的模式呢?这是他们误以为可以使用 Java 的错误判断机制来提高程序性能,因为 VM 对每次数组访问都要检查越界情况,所以他们认为正常的循环终止测试被编译器隐藏了,但是在 for-each 中仍然可见,这是多余的并且应当避免。这种想法有三个错误:
- 因为异常设计的初衷适用于不正常的情形,所有几乎没有 JVM 实现试图对他们进行优化,使它们与显式的测试一样快。
- 把代码放在 try-catch 块中反而阻止了现代 JVM 实现本可能执行的某些特定优化。
- 对数据进行遍历的标准模式并不会导致冗余的检查。有些 JVM 实现会将它们优化掉。
实际上基于异常的模式比标准模式要慢得多。在我本地的机器上,对于一个有 100 个元素的数组进行遍历,标准模式比基于异常的模式快了 2 倍。
基于异常的循环模式不仅模糊了代码的意图,降低了它的性能,而且它还不能保证正常工作!如果出现了不相关的 bug,这个模式会悄悄的消失从而掩盖了这个 Bug,极大地增加了调试过程的复杂性。假设循环体的计算过程中调用了一个方法,这个方法执行了对某个不相关数组的越界访问。如果使用合理的循环模式,这个 Bug 会产生未被捕捉的异常,从而导致线程立即结束,并产生完整的堆栈轨迹。如果使用这个被误导的基于异常的循环模式,与这个 Bug 相关的异常将会被捕捉到,并且被错误的解释为正常的循环终止条件。
这个例子的教训很简单:顾名思义,异常应该只用于异常的情况下;他们永远不应该用于正常的程序控制流程。 一般的,应该优先使用标准的、容易理解的模式,而不是那些声称可以提供更好性能的、弄巧成拙的方法。即使真的能够改进性能,面对平台的不断改进,这种模型的性能优势也不可能一直保持。然而这种过度聪明的模式带来的微妙 Bug 和维护的痛苦将依旧存在。
这条原则对于 API 设计也有启发**。设计良好的 API 不应该强迫它的客户端为了正常的控制流程而使用异常。**如果类中具有「状态相关」(state-dependent)的方法,即只有在特定的不可预知的条件下才可以被调用的方法,这个类往往也应该具有一个单独的「状态测试」(state-testing)方法,即表明是否可以调用这个状态相关的方法。比如 Iterator 接口含有状态相关的 next 方法,以及相应的状态测试方法 hasNext。这使得利用传统的 for 循环(以及 for-each 循环,在内部使用了 hasNext 方法)对集合进行迭代的标准模式成为可能。
for ( Iterator<Foo> i = collection.iterator(); i.hasNext(); ){
Foo foo = i.next();
...
}
如果 Iterator 缺少 hasNext 方法,客户端将被迫改用下面的做法:
/* Do not use this hideous code for iteration over a collection! */
try {
Iterator<Foo> i = collection.iterator();
while ( true )
{
Foo foo = i.next();
...
}
} catch ( NoSuchElementException e ) {
}
这应该非常类似于本条目刚开始时对数据进行迭代的例子。除了代码繁琐令人误解之外,这个基于异常的模式可能执行起来也比标准模式更差,并且还可能掩盖系统中其他不相关部分的 Bug。
另外一种提供单独状态测试的做法是,如果「状态相关」方法无法执行想要的计算,就可以让它返回一个零长度的 optional 值(详见第 55 条),或者返回一个可被识别的返回值,比如 null。
对于「状态测试方法」和「optional 返回值或者可识别的返回值」这两种做法,有些指导原则可以帮助你在两者之间做出选择。如果对象将在缺少外部同步的情况下被并发访问,或者可被外界改变状态,就必须使用「optional 返回值或者可识别的返回值」,因为在调用「状态测试」方法和调用对应的「状态相关」方法的时间间隔之中,对象的状态有可能发生变化。如果单独的「状态测试」方法必须重复「状态相关」方法的工作,从性能的角度考虑,就必须使用可被识别的返回值。如果其他方面都是等同的,那么「状态测试」方法则优于可被识别的返回值。他提供了相对更高的可读性,对于使用不当的情形可能更加易于检测和改正:如果忘了去调用状态测试方法,状态相关的方法就会抛出异常,使得这个 Bug 变得很明显;如果忘了去检查可识别的返回值,这个 Bug 就很难被发现。optional 返回值不会有这方面的问题。
总而言之,异常是为了在异常情况下被设计和使用的。不要将它们勇于普通的控制流程,也不要编写迫使它们这么做的 API。
70. 对可恢复的情况使用受检异常,对编程错误使用运行时异常
Java 程序设计语言提供了三种 throwable:受检异常(checked exceptions)、运行时异常(runtime exceptions)和错误(errors)。程序员中存在着什么情况适合使用哪种 throwable 的困惑。虽然这种决定不总是那么清晰,但还是有一些一般性的原则提出了强有力的指导。
在决定使用受检异常还是非受检异常时,主要的原则是: 如果期望调用者能够合理的恢复程序运行,对于这种情况就应该使用受检异常。 通过抛出受检异常,强迫调用者在一个 catch 子句中处理该异常,或者把它传播出去。因此,方法中声明要抛出的每个受检异常都是对 API 用户的一个潜在提示:与异常相关联的条件是调用这个方法一种可能结果。
API 的设计者让 API 用户面对受检异常,以此强制用户从这个异常条件条件中恢复。用户这可以忽视这样的强制要求,只需要捕获异常即可,但这往往不是个好办法(详见第 77 条)。
有两种非受检的 throwable:运行时异常和错误。在行为上两者是等同的:它们都是不需要也不应该被捕获的 throwable。如果程序抛出非受检异常或者错误,往往属于不可恢复的情形,程序继续执行下去有害无益。如果程序没有捕捉到这样的 throwable,将会导致当前线程中断(halt),并且出现适当的错误消息。
用运行时异常来表明编程错误。大多数运行时异常都表示前提违例(precondition violations)。所谓前提违例是指 API 的客户没有遵守 API 规范建立的约定。例如,数组访问的预定指明了数组的下标值必须在 0 和数组长度-1 之间。ArrayIndexOutOfBoundsException 表明违反了这个前提。
这个建议有一个问题:对于要处理可恢复的条件,还是处理编程错误,情况并非总是那么黑白分明。例如,考虑资源枯竭的情形,这可能是由程序错误引起的,比如分配了一块不合理的过大数组,也可能确实是由于资源不足而引起的。如果资源枯竭是由于临时的短缺,或是临时需求太大造成的,这种情况可能是可恢复的。API 设计者需要判断这样的资源枯竭是否允许恢复。如果你相信一种情况可能允许回复,就使用受检异常;如果不是,则使用运行时异常。如果不清楚是否有可能恢复,最好使用非受检异常,原因参见 71 条。
虽然 JLS(Java 语言规范)并没有要求,但是按照惯例,错误(Error)往往被 JVM 保留下来使用,以表明资源不足、约束失败,或者其他使程序无法继续执行的条件。由于这已经是个几乎被普遍接受的管理,因此最好不需要在实现任何新的 Error 的子类。因此,你实现的所有非受检的 throwable 都应该是 RuntimeExceptiond 子类(直接或者间接的)。不仅不应该定义 Error 的子类,也不应该抛出 AssertionError 异常。
要想定义一个不是 Exception、RuntimeException 或者 Error 子类的 throwable,这也是有可能的。JLS 并没有直接规定这样的 throwable,而是隐式的指定了:从行为意义上讲,他们等同于普通的受检异常(即 Exception 的子类,但不是 RuntimeException 的子类)。那么什么时候应该使用这样的 throwable?一句话,永远也不会用到。它与普通的受检异常相比没有任何益处,还会困扰 API 的使用者。
API 的设计者往往会忘记,异常也是一个完全意义上的对象,可是在它上面定义任何的方法。这些方法的主要用途是捕获异常的代码提供额外信息,特别是关于引发这个异常条件的信息。如果没有这样的方法,程序员必须要懂的如何解析「该异常的字符串表示法」,以便获得这些额外信息。这是极为不好的做法(详见 12 条)。类很少会指定它们的字符串表示法中的细节,因此对于不同的实现及不同的版本,字符串表示法也会大相径庭。由此可见,“解析异常的字符串表示法”的代码可能是不可移植的,也是非常脆弱的。
因为受检异常往往指明了可恢复的条件,所以对于这样的异常,提供一些辅助方法尤其重要,通过这种方法调用者可以获得一些有助于程序恢复的信息。例如,假设因为用户资金不足,当他企图购买一张礼品卡时导致失败,于是抛出受检异常。这个异常应该提供一个访问方法,以便允许客户查询所缺的费用金额,使得使用者可以将这个数值传递给用户。关于这个主题的更多详情,参见 75 条。
总而言之,对于可恢复的情况,要抛出受检异常;对于程序错误,就要抛出运行时异常。不确定是否可恢复,就跑出为受检异常。不要定义任何既不是受检异常也不是运行异常的抛出类型。要在受检异常上提供方法,以便协助程序恢复。
71. 避免不必要的使用受检异常
Java 程序员不喜欢受检异常,但是如果使用得当,它们可以改善 API 和程序。不同于返回码和未受检异常的是,它们强迫程序员处理异常的条件,大大增强了可靠性。也就是说,过分使用受检异常会使 API 使用起来非常不方便。如果方法抛出受检异常,调用该方法代码就必须在一个或者多个 catch 块中处理这些异常,或者它必须声明抛出这些异常,并让它们传播出去。无论使用哪一种方法,都给程序员增添了不可忽视的负担。这种负担在 Java 8 中更重了,因为抛出受检异常的方法不能直接在 Stream 中使用(详见第 45 条至第 48 条)。
如果正确地使用 API 并不能阻止这种异常条件的产生,并且一旦产生异常,使用 API 的程序员可以立即采取有用的动作,这种负担就被认为是正当的。除非这两个条件都成立,否则更适合于使用未受检异常。作为一个石蕊测试(石蕊测试是指简单而具有决定性的测试),你可以试着问自己:程序员将如何处理该异常。下面的做法是最好的吗?
} catch ( TheCheckedException e ) {
throw new AssertionError(); /* Can't happen! */
}
下面这种做法又如何?
} catch ( TheCheckedException e ) {
e.printStackTrace(); /* Oh well, we lose. */
System.exit( 1 );
}
如果使用 API 的程序员无法做得比这更好那么未受检的异常可能更为合适。
如果方法抛出的受检异常是唯一的,它给程序员带来的额外负担就会非常高。如果这个方法还有其他的受检异常,该方法被调用的时候,必须已经出现在一个 try 块中,所以这个异常只需要另外一个 catch 块。如果方法只抛出一个受检异常,单独这一个异常就表示:该方法必须放置于一个 try 块中,并且不能在 Stream 中直接使用。这种情况下,应该问问自己,是否还有别的途径可以避免使用受检异常。
除受检异常最容易的方法是,返回所要的结果类型的一个 optional(详见第 55 条)。这个方法不抛出受检异常,而只是返回一个零长度的 optional。这种方法的缺点是,方法无法返回任何额外的信息,来详细说明它无法执行你想要的计算。相反,异常则具有描述性的类型,并且能够导出方法,以提供额外的信息(详见第 70 条)。
「把受检异常变成未受检异常」的一种方法是,把这个抛出异常的方法分成两个方法,其中第一个方法返回一个 boolean 值,表明是否应该抛出异常。这种 API 重构,把下面的调用序列:
/* Invocation with checked exception */
try {
obj.action( args );
} catch ( TheCheckedException e ) {
... /* Handle exceptional condition */
}
重构为:
/* Invocation with state-testing method and unchecked exception */
if ( obj.actionPermitted( args ) ) {
obj.action( args );
} else {
... /* Handle exceptional condition */
}
这种重构并非总是恰当的,但是,凡是在恰当的地方,它都会使 API 用起来更加舒服。虽然后者的调用序列没有前者漂亮,但是这样得到的 API 更加灵活。如果程序员知道调用将会成功,或者不介意由于调用失败而导致的线程终止,这种重构还允许以下这个更为简单的调用形式:
obj.action(args);
如果你怀疑这个简单的调用序列是否符合要求,这个 API 重构可能就是恰当的。这样重构之后的 API 在本质上等同于第 69 条中的「状态测试方法」,并且同样的告诫依然适用:如果对象将在缺少外部同步的情况下被并发访问,或者可被外界改变状态,这种重构就是不恰当的,因为在 actionPermitted 和 action 这两个调用的时间间隔之中,对象的状态有可能会发生变化。如果单独的 actionPermitted 方法必须重复 action 方法的工作,出于性能的考虑,这种 API 重构就不值得去做。
总而言之,在谨慎使用的前提之下,受检异常可以提升程序的可读性;如果过度使用,将会使 API 使用起来非常痛苦。如果调用者无法恢复失败,就应该抛出未受检异常。如果可以恢复,并且想要迫使调用者处理异常的条件,首选应该返回一个 optional 值。当且仅当万一失败时,这些无法提供足够的信息,才应该抛出受检异常。
1022

被折叠的 条评论
为什么被折叠?



