杨柳亭

杨柳亭

02. 当构造方法参数过多时使用builder模式

2. 当构造方法参数过多时使用 builder 模式

静态工厂和构造方法都有一个限制:它们在可选参数很多的情景下,无法很好得扩展。请考虑一个代表包装食品上的营养成分标签的例子。这些标签有几个必需的属性——每次建议的摄入量,每罐的份量和每份卡路里 ,以及超过 20 个可选的属性——总脂肪、饱和脂肪、反式脂肪、胆固醇、钠等等。大多数产品只包含这些可选字段中的少数,且具有非零值(大部分字段为空)。

应该为这样的类编写什么样的构造方法或静态工厂?传统上,程序员使用了可伸缩(telescoping constructor)构造方法模式。在这种模式中,首先提供一个只有必需参数的构造方法,接着提供增加了一个可选参数的构造函数,然后提供增加了两个可选参数的构造函数,等等;最终,在构造函数中包含所有必需和可选参数。以下就是它在实践中的样子。为了简便,只显示了四个可选属性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// Telescoping constructor pattern - does not scale well!
public class NutritionFacts {
private final int servingSize; // (mL) required
private final int servings; // (per container) required
private final int calories; // (per serving) optional
private final int fat; // (g/serving) optional
private final int sodium; // (mg/serving) optional
private final int carbohydrate; // (g/serving) optional

public NutritionFacts(int servingSize, int servings) {
this(servingSize, servings, 0);
}

public NutritionFacts(int servingSize, int servings,
int calories) {
this(servingSize, servings, calories, 0);
}

public NutritionFacts(int servingSize, int servings,
int calories, int fat) {
this(servingSize, servings, calories, fat, 0);
}

public NutritionFacts(int servingSize, int servings,
int calories, int fat, int sodium) {
this(servingSize, servings, calories, fat, sodium, 0);
}

public NutritionFacts(int servingSize, int servings,
int calories, int fat, int sodium, int carbohydrate) {
this.servingSize = servingSize;
this.servings = servings;
this.calories = calories;
this.fat = fat;
this.sodium = sodium;
this.carbohydrate = carbohydrate;
}
}

当想要创建一个实例时,可以使用包含所有你要设置的参数的构造方法:

1
NutritionFacts cocaCola = new NutritionFacts(240, 8, 100, 0, 35, 27);

通常情况下,这个构造方法的调用需要许多你不想设置的参数,但是你不得不为它们传递一个值。 在这种情况下,我们为 fat 属性传递了 0 。「只有」六个参数可能看起来并不那么糟糕,但随着参数数量的增加,它很快就会失控。

简而言之,可伸缩构造方法模式是有效的,但是当有很多参数时,很难编写客户端代码,而且很难读懂它。 读者不知道这些值是什么意思,并且必须仔细地去数参数才能找到答案。一长串相同类型的参数可能会导致一些 bug。如果客户端不小心写反了两个这样的参数,编译器并不会报错,但是程序在运行时会出现与预期不一致的行为 (详见第 51 条)。

当在构造方法中遇到许多可选参数时,另一种选择是 JavaBeans 模式,在这种模式中,调用一个无参的构造方法来创建对象,然后调用 setter 方法来设置每个必需的参数和可选参数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// JavaBeans Pattern - allows inconsistency, mandates mutability
public class NutritionFacts {
// Parameters initialized to default values (if any)
private int servingSize = -1; // Required; no default value
private int servings = -1; // Required; no default value
private int calories = 0;
private int fat = 0;
private int sodium = 0;
private int carbohydrate = 0;

public NutritionFacts() { }

// Setters
public void setServingSize(int val) { servingSize = val; }
public void setServings(int val) { servings = val; }
public void setCalories(int val) { calories = val; }
public void setFat(int val) { fat = val; }
public void setSodium(int val) { sodium = val; }
public void setCarbohydrate(int val) { carbohydrate = val; }
}

这种模式没有伸缩构造方法模式的缺点。有点冗长,但创建实例很容易,并且易于阅读所生成的代码:

1
2
3
4
5
6
NutritionFacts cocaCola = new NutritionFacts();
cocaCola.setServingSize(240);
cocaCola.setServings(8);
cocaCola.setCalories(100);
cocaCola.setSodium(35);
cocaCola.setCarbohydrate(27);

不幸的是,JavaBeans 模式本身有严重的缺陷。由于构造方法被分割成了多次调用,所以在构造过程中 JavaBean 可能处于不一致的状态。 该类仅通过检查构造函数参数的有效性,而没有强制的一致性措施。在不一致的状态下尝试使用对象可能会导致一些错误,这些错误与平常代码的 BUG 很是不同,因此很难调试。一个相关的缺点是,JavaBeans 模式排除了让类不可变的可能性(详见第 17 条),并且需要程序员增加工作以确保线程安全。

通过在对象构建完成时手动「冻结」对象,并且不允许它在解冻之前使用,可以减少这些缺点,但是这种变体在实践中很难使用并且很少使用。 而且,在运行时会导致错误,因为编译器无法确保程序员会在使用对象之前调用 freeze 方法。

幸运的是,还有第三种选择。它结合了可伸缩构造方法模式的安全性和 JavaBean 模式的可读性。 它是 Builder 模式[Gamma95] 的一种形式。客户端不直接构造所需的对象,而是调用一个包含所有必需参数的构造方法 (或静态工厂) 得到获得一个 builder 对象。然后,客户端调用 builder 对象的与 setter 相似的方法来设置你想设置的可选参数。最后,客户端调用 builder 对象的一个无参的 build 方法来生成对象,该对象通常是不可变的。Builder 通常是它所构建的类的一个静态成员类(详见第 24 条)。以下是它在实践中的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
// Builder Pattern
public class NutritionFacts {
private final int servingSize;
private final int servings;
private final int calories;
private final int fat;
private final int sodium;
private final int carbohydrate;

public static class Builder {
// Required parameters
private final int servingSize;
private final int servings;

// Optional parameters - initialized to default values
private int calories = 0;
private int fat = 0;
private int sodium = 0;
private int carbohydrate = 0;

public Builder(int servingSize, int servings) {
this.servingSize = servingSize;
this.servings = servings;
}

public Builder calories(int val) {
calories = val;
return this;
}

public Builder fat(int val) {
fat = val;
return this;
}

public Builder sodium(int val) {
sodium = val;
return this;
}

public Builder carbohydrate(int val) {
carbohydrate = val;
return this;
}

public NutritionFacts build() {
return new NutritionFacts(this);
}
}

private NutritionFacts(Builder builder) {
servingSize = builder.servingSize;
servings = builder.servings;
calories = builder.calories;
fat = builder.fat;
sodium = builder.sodium;
carbohydrate = builder.carbohydrate;
}
}

NutritionFacts 类是不可变的,所有的参数默认值都在一个地方。builder 的 setter 方法返回 builder 本身,这样就可以进行链式调用,从而生成一个流畅的 API。下面是客户端代码的示例:

1
2
NutritionFacts cocaCola = new NutritionFacts.Builder(240, 8)
.calories(100).sodium(35).carbohydrate(27).build();

这个客户端代码很容易编写,更重要的是易于阅读。 采用 Builder 模式模拟实现的的可选参数可以在 Python 和 Scala 都可以找到。

为了简洁起见,省略了有效性检查。 要尽快检测出无效参数,检查 builder 的构造方法和方法中的参数有效性。 在 build 方法调用的构造方法中检查包含多个参数的不变性。为了确保这些不变性不受攻击,在从 builder 复制参数后对对象属性进行检查(详见第 50 条)。 如果检查失败,则抛出 IllegalArgumentException 异常(详见第 72 条),其详细消息指示哪些参数无效(详见第 75 条)。

Builder 模式非常适合类层次结构。 使用平行层次的 builder,每个 builder 嵌套在相应的类中。 抽象类有抽象的 builder;具体的类有具体的 builder。 例如,考虑代表各种比萨饼的根层次结构的抽象类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// Builder pattern for class hierarchies
import java.util.EnumSet;
import java.util.Objects;
import java.util.Set;

public abstract class Pizza {
public enum Topping {HAM, MUSHROOM, ONION, PEPPER, SAUSAGE}
final Set<Topping> toppings;

abstract static class Builder<T extends Builder<T>> {
EnumSet<Topping> toppings = EnumSet.noneOf(Topping.class);

public T addTopping(Topping topping) {
toppings.add(Objects.requireNonNull(topping));
return self();
}

abstract Pizza build();

// Subclasses must override this method to return "this"
protected abstract T self();
}

Pizza(Builder<?> builder) {
toppings = builder.toppings.clone(); // See Item 50
}
}

请注意,Pizza.Builder 是一个带有递归类型参数( recursive type parameter)(详见第 30 条)的泛型类型。 这与抽象的 self 方法一起,允许方法链在子类中正常工作,而不需要强制转换。 Java 缺乏自我类型的这种变通解决方法被称为模拟自我类型(simulated self-type)。

这里有两个具体的 Pizza 的子类,其中一个代表标准的纽约风格的披萨,另一个是半圆形烤乳酪馅饼。前者有一个所需的尺寸参数,而后者则允许指定酱汁是否应该在里面或在外面:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
import java.util.Objects;

public class NyPizza extends Pizza {
public enum Size { SMALL, MEDIUM, LARGE }
private final Size size;

public static class Builder extends Pizza.Builder<Builder> {
private final Size size;

public Builder(Size size) {
this.size = Objects.requireNonNull(size);
}

@Override public NyPizza build() {
return new NyPizza(this);
}

@Override protected Builder self() {
return this;
}
}

private NyPizza(Builder builder) {
super(builder);
size = builder.size;
}
}

public class Calzone extends Pizza {
private final boolean sauceInside;

public static class Builder extends Pizza.Builder<Builder> {
private boolean sauceInside = false; // Default

public Builder sauceInside() {
sauceInside = true;
return this;
}

@Override public Calzone build() {
return new Calzone(this);
}

@Override protected Builder self() {
return this;
}
}

private Calzone(Builder builder) {
super(builder);
sauceInside = builder.sauceInside;
}
}

请注意,每个子类 builder 中的 build 方法被声明为返回正确的子类:NyPizza.Builderbuild 方法返回 NyPizza,而 Calzone.Builder 中的 build 方法返回 Calzone。 这种技术,其一个子类的方法被声明为返回在超类中声明的返回类型的子类型,称为协变返回类型(covariant return typing)。 它允许客户端使用这些 builder,而不需要强制转换。

这些「分层 builder(hierarchical builders)」的客户端代码基本上与简单的 NutritionFacts builder 的代码相同。为了简洁起见,下面显示的示例客户端代码假设枚举常量的静态导入:

1
2
3
4
NyPizza pizza = new NyPizza.Builder(SMALL)
.addTopping(SAUSAGE).addTopping(ONION).build();
Calzone calzone = new Calzone.Builder()
.addTopping(HAM).sauceInside().build();

builder 对构造方法的一个微小的优势是,builder 可以有多个可变参数,因为每个参数都是在它自己的方法中指定的。或者,builder 可以将传递给多个调用的参数聚合到单个属性中,如前面的 addTopping 方法所演示的那样。

Builder 模式非常灵活。 单个 builder 可以重复使用来构建多个对象。 builder 的参数可以在构建方法的调用之间进行调整,以改变创建的对象。 builder 可以在创建对象时自动填充一些属性,例如每次创建对象时增加的序列号。

Builder 模式也有缺点。为了创建对象,首先必须创建它的 builder。虽然创建这个 builder 的成本在实践中不太可能被注意到,但在看中性能的场合下这可能就是一个问题。而且,builder 模式比伸缩构造方法模式更冗长,因此只有在有足够的参数时才值得使用它,比如四个或更多。但是请记住,你可能在以后会想要添加更多的参数。但是,如果你一开始是使用的构造方法或静态工厂,当类演化到参数数量失控的时候再转到 Builder 模式,过时的构造方法或静态工厂就会面临尴尬的处境。因此,通常最好从一开始就创建一个 builder。

总而言之,当设计类的构造方法或静态工厂的参数超过几个时,Builder 模式是一个不错的选择,特别是许多参数是可选的或相同类型的。builder 模式客户端代码比使用伸缩构造方法(telescoping constructors)更容易读写,并且 builder 模式比 JavaBeans 更安全。

33. 优先考虑类型安全的异构容器

33. 优先考虑类型安全的异构容器

泛型的常见用法包括集合,如 Set<E> 和 Map<K,V> 和单个元素容器,如 ThreadLocal<T>AtomicReference<T>。 在所有这些用途中,它都是参数化的容器。 这限制了每个容器只能有固定数量的类型参数。 通常这正是你想要的。 一个 Set 有单一的类型参数,表示它的元素类型; 一个 Map 有两个,代表它的键和值的类型;等等。

然而有时候,你需要更多的灵活性。 例如,数据库一行记录可以具有任意多列,并且能够以类型安全的方式访问它们是很好的。 幸运的是,有一个简单的方法可以达到这个效果。 这个想法是参数化键(key)而不是容器。 然后将参数化的键提交给容器以插入或检索值。 泛型类型系统用于保证值的类型与其键一致。

作为这种方法的一个简单示例,请考虑一个 Favorites 类,它允许其客户端保存和检索任意多种类型的 favorite 实例。 该类型的 Class 对象将扮演参数化键的一部分。其原因是这 Class 类是泛型的。 类的类型从字面上来说不是简单的 Class,而是 Class<T>。 例如,String.class 的类型为 Class<String>Integer.class 的类型为 Class<Integer>。 当在方法中传递字面类传递编译时和运行时类型信息时,它被称为类型令牌(type token)[Bracha04]。

Favorites 类的 API 很简单。 它看起来就像一个简单 Map 类,除了该键是参数化的以外。 客户端在设置和获取 favorites 实例时呈现一个 Class 对象。 如下是 API:

1
2
3
4
5
// Typesafe heterogeneous container pattern - API
public class Favorites {
public <T> void putFavorite(Class<T> type, T instance);
public <T> T getFavorite(Class<T> type);
}

下面是一个演示 Favorites 类,保存,检索和打印喜欢的 StringIntegerClass 实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
// Typesafe heterogeneous container pattern - client
public static void main(String[] args) {
Favorites f = new Favorites();
f.putFavorite(String.class, "Java");
f.putFavorite(Integer.class, 0xcafebabe);
f.putFavorite(Class.class, Favorites.class);

String favoriteString = f.getFavorite(String.class);
int favoriteInteger = f.getFavorite(Integer.class);
Class<?> favoriteClass = f.getFavorite(Class.class);
System.out.printf("%s %x %s%n", favoriteString,
favoriteInteger, favoriteClass.getName());
}

正如你所期望的,这个程序打印 Java cafebabe Favorites。 请注意,顺便说一下,Javaprintf 方法与 C 语言的不同之处在于,你应该在 C 中使用 \n 的地方改用 %n。%n 用于生成适用于特定平台的行分隔符,在大多数平台上面的值为 \n,但并不是所有平台的分隔符都为 \n。

Favorites 实例是类型安全的:当你请求一个字符串时它永远不会返回一个整数。 它也是异构的:与普通 Map 不同,所有的键都是不同的类型。 因此,我们将 Favorites 称为类型安全异构容器(typesafe heterogeneous container)。

Favorites 的实现非常小巧。 这是完整的代码:

1
2
3
4
5
6
7
8
9
10
11
12
// Typesafe heterogeneous container pattern - implementation
public class Favorites {
private Map<Class<?>, Object> favorites = new HashMap<>();

public<T> void putFavorite(Class<T> type, T instance) {
favorites.put(Objects.requireNonNull(type), instance);
}

public<T> T getFavorite(Class<T> type) {
return type.cast(favorites.get(type));
}
}

这里有一些微妙的事情发生。 每个 Favorites 实例都由一个名为 favorites 私有的 Map<Class<?>, Object> 来支持。 你可能认为无法将任何内容放入此 Map 中,因为这是无限定的通配符类型,但事实恰恰相反。 需要注意的是通配符类型是嵌套的:它不是通配符类型的 Map 类型,而是键的类型。 这意味着每个键都可以有不同的参数化类型:一个可以是 Class<String>,下一个 Class<Integer> 等等。 这就是异构的由来。

接下来要注意的是,favoritesMap 的值类型只是 Object。 换句话说,Map 不保证键和值之间的类型关系,即每个值都是由其键表示的类型。 事实上,Java 的类型系统并不足以表达这一点。 但是我们知道这是真的,并在检索一个 favorite 时利用了这点。

putFavorite 实现很简单:只需将给定的 Class 对象映射到给定的 favorites 的实例即可。 如上所述,这丢弃了键和值之间的“类型联系(type linkage)”;无法知道这个值是不是键的一个实例。 但没关系,因为 getFavorites 方法可以并且确实重新建立这种关联。

getFavorite 的实现比 putFavorite 更复杂。 首先,它从 favorites Map 中获取与给定 Class 对象相对应的值。 这是返回的正确对象引用,但它具有错误的编译时类型:它是 Object(favorites map 的值类型),我们需要返回类型 T。因此,getFavorite 实现动态地将对象引用转换为 Class 对象表示的类型,使用 Classcast 方法。

cast 方法是 Java 的 cast 操作符的动态模拟。它只是检查它的参数是否由 Class 对象表示的类型的实例。如果是,它返回参数;否则会抛出 ClassCastException 异常。我们知道,假设客户端代码能够干净地编译,getFavorite 中的强制转换不会抛出 ClassCastException 异常。 也就是说,favorites map 中的值始终与其键的类型相匹配。

那么这个 cast 方法为我们做了什么,因为它只是返回它的参数? cast 的签名充分利用了 Class 类是泛型的事实。 它的返回类型是 Class 对象的类型参数:

1
2
3
public class Class<T> {
T cast(Object obj);
}

这正是 getFavorite 方法所需要的。 这正是确保 Favorites 类型安全,而不用求助一个未经检查的强制转换的 T 类型。

Favorites 类有两个限制值得注意。 首先,恶意客户可以通过使用原始形式的 Class 对象,轻松破坏 Favorites 实例的类型安全。 但生成的客户端代码在编译时会生成未经检查的警告。 这与正常的集合实现(如 HashSetHashMap)没有什么不同。 通过使用原始类型 HashSet(条目 26),可以轻松地将字符串放入 HashSet<Integer> 中。 也就是说,如果你愿意为此付出一点代价,就可以拥有运行时类型安全性。 确保 Favorites 永远不违反类型不变的方法是,使 putFavorite 方法检查该实例是否由 type 表示类型的实例,并且我们已经知道如何执行此操作。只需使用动态转换:

1
2
3
4
// Achieving runtime type safety with a dynamic cast
public<T> void putFavorite(Class<T> type, T instance) {
favorites.put(type, type.cast(instance));
}

java.util.Collections 中有一些集合包装类,可以发挥相同的诀窍。 它们被称为 checkedSetcheckedListcheckedMap 等等。 他们的静态工厂除了一个集合(或 Map)之外还有一个 Class 对象(或两个)。 静态工厂是泛型方法,确保 Class 对象和集合的编译时类型匹配。 包装类为它们包装的集合添加了具体化。 例如,如果有人试图将 Coin 放入你的 Collection<Stamp> 中,则包装类在运行时会抛出 ClassCastException。 这些包装类对于追踪在混合了泛型和原始类型的应用程序中添加不正确类型的元素到集合的客户端代码很有用。

Favorites 类的第二个限制是它不能用于不可具体化的(non-reifiable)类型(详见第 28 条)。 换句话说,你可以保存你最喜欢的 StringString[],但不能保存 List<String>。 如果你尝试保存你最喜欢的 List<String>,程序将不能编译。 原因是无法获取 List<String>Class 对象。 List<String>.class 是语法错误,也是一件好事。 List<String>List<Integer> 共享一个 Class 对象,即 List.class。 如果“字面类型(type literals)”List<String> .class 和 List<Integer>.class 合法并返回相同的对象引用,那么它会对 Favorites 对象的内部造成严重破坏。 对于这种限制,没有完全令人满意的解决方法。

Favorites 使用的类型令牌 type tokens) 是无限制的:getFavoriteputFavorite 接受任何 Class 对象。 有时你可能需要限制可传递给方法的类型。 这可以通过一个有限定的类型令牌来实现,该令牌只是一个类型令牌,它使用限定的类型参数(详见第 30 条)或限定的通配符(详见第 31 条)来放置可以表示的类型的边界。

注解 API(详见第 39 条)广泛使用限定类型的令牌。 例如,以下是在运行时读取注解的方法。 此方法来自 AnnotatedElement 接口,该接口由表示类,方法,属性和其他程序元素的反射类型实现:

1
2
public <T extends Annotation>
T getAnnotation(Class<T> annotationType);

参数 annotationType 是表示注解类型的限定类型令牌。 该方法返回该类型的元素的注解(如果它有一个);如果没有,则返回 null。 本质上,注解元素是一个类型安全的异构容器,其键是注解类型。

假设有一个 Class<?> 类型的对象,并且想要将它传递给需要限定类型令牌(如 getAnnotation)的方法。 可以将对象转换为 Class<? extends Annotation>,但是这个转换没有被检查,所以它会产生一个编译时警告(详见第 52 条)。 幸运的是,Class 类提供了一种安全(动态)执行这种类型转换的实例方法。 该方法被称为 asSubclass,并且它转换所调用的 Class 对象来表示由其参数表示的类的子类。 如果转换成功,该方法返回它的参数;如果失败,则抛出 ClassCastException 异常。

以下是如何使用 asSubclass 方法在编译时读取类型未知的注解。 此方法编译时没有错误或警告:

1
2
3
4
5
6
7
8
9
10
11
12
// Use of asSubclass to safely cast to a bounded type token
static Annotation getAnnotation(AnnotatedElement element,
String annotationTypeName) {
Class<?> annotationType = null; // Unbounded type token
try {
annotationType = Class.forName(annotationTypeName);
} catch (Exception ex) {
throw new IllegalArgumentException(ex);
}
return element.getAnnotation(
annotationType.asSubclass(Annotation.class));
}

总之,泛型 API 的通常用法(以集合 API 为例)限制了每个容器的固定数量的类型参数。 你可以通过将类型参数放在键上而不是容器上来解决此限制。 可以使用 Class 对象作为此类型安全异构容器的键。 以这种方式使用的 Class 对象称为类型令牌。 也可以使用自定义键类型。 例如,可以有一个表示数据库行(容器)的 DatabaseRow 类型和一个泛型类型 Column<T> 作为其键。

47. 优先使用Collection而不是Stream来作为方法的返回类型

47. 优先使用 Collection 而不是 Stream 来作为方法的返回类型

许多方法返回元素序列(sequence)。在 Java 8 之前,通常方法的返回类型是 CollectionSetList 这些接口;还包括 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 方法。 结果代码可能有些乱,但并非不合理:

1
2
3
4
5

// Won't compile, due to limitations on Java's type inference
for (ProcessHandle ph : ProcessHandle.allProcesses()::iterator) {
// Process the process
}

不幸的是,如果你试图编译这段代码,会得到一个错误信息:

1
2
Test.java:6: error: method reference not expected here
for (ProcessHandle ph : ProcessHandle.allProcesses()::iterator) {

为了使代码编译,必须将方法引用强制转换为对应参数的 Iterable 类型:

1
2
// Hideous workaround to iterate over a stream
for (ProcessHandle ph : (Iterable<ProcessHandle>)ProcessHandle.allProcesses()::iterator)

此代码可以工作,但在实践中使用它太乱。 更好的解决方法是使用适配器方法。 JDK 没有提供这样的方法,但是使用上面的代码片段中的相同技术,很容易编写一个方法。 请注意,在适配器方法中不需要强制转换,因为 Java 的类型推断在此上下文中能够正常工作:

1
2
3
4
// Adapter from  Stream<E> to Iterable<E>
public static <E> Iterable<E> iterableOf(Stream<E> stream) {
return stream::iterator;
}

通过这个适配器,你可以使用 for-each 语句迭代任何流:

1
2
3
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 同样没有提供适配器,但是编写这个适配器非常简单:

1
2
3
4
// 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.asListStream.of 方法提供简单的迭代和流访问能力。 如果返回的序列小到足以容易地放入内存中,那么最好返回一个标准集合实现,例如 ArrayListHashSet但是不要在只是为了将它作为集合返回,而在内存中存储很大的序列。

如果你需要返回一很大但可以简洁地表示的序列,请考虑实现一个专用集合。 例如,假设返回给定集合的幂集(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 个元素集和的幂集之间存在自然映射。 这是代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 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 作为返回类型而不是 StreamIterable 的缺点:Collection 有 int 返回类型的 size 的方法,该方法将返回序列的长度限制为 Integer.MAX_VALUE 或 231-1。Collection 规范允许 size 方法返回 231 - 1,如果集合更大,甚至无限,但这不是一个完全令人满意的解决方案。

为了在 AbstractCollection 上编写 Collection 实现,除了 Iterable 所需的方法之外,只需要实现两种方法:containssize。 通常,编写这些方法的有效实现很容易。 如果不可行,可能是因为在迭代发生之前未预先确定序列的内容,返回 Stream 还是 Iterable 的,无论哪种感觉更自然。 如果选择,可以使用两种不同的方法分别返回。

有时,你会仅根据实现的易用性选择返回类型。例如,假设希望编写一个方法,该方法返回输入列表的所有 (连续的) 子列表。生成这些子列表并将它们放到标准集合中只需要三行代码,但是保存这个集合所需的内存是源列表大小的二次方。虽然这没有指数幂集那么糟糕,但显然是不可接受的。实现自定义集合 (就像我们对幂集所做的那样) 会很乏味,因为 JDK 缺少一个框架 Iterator 实现来帮助我们。

然而,实现输入列表的所有子列表的流是直截了当的,尽管它确实需要一点的洞察力(insight)。 让我们调用一个子列表,该子列表包含列表的第一个元素和列表的前缀。 例如,(a,b,c)的前缀是(a),(a,b)和(a,b,c)。 类似地,让我们调用包含后缀的最后一个元素的子列表,因此(a,b,c)的后缀是(a,b,c),(b,c)和(c)。 洞察力是列表的子列表只是前缀的后缀(或相同的后缀的前缀)和空列表。 这一观察直接展现了一个清晰,合理简洁的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 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.rangeIntStream.rangeClosed 返回的连续 int 值流来生成前缀和后缀。这个习惯用法,粗略地说,流等价于整数索引上的标准 for 循环。因此,我们的子列表实现似于明显的嵌套 for 循环:

1
2
3
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 中的笛卡尔积的使用流的代码:

1
2
3
4
5
6
7
8
// 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 ,因为它可以同时被流和迭代处理。

79. 避免过度同步

79. 避免过度同步

第 78 条告诫过我们缺少同步的危险性。本条目则关注相反的问题。依据情况的不同,过度同步则可能导致性能降低、死锁,甚至不确定的行为。

为了避免活性失败和安全性失败,在一个被同步的方法或者代码块中,永远不要放弃对客户端的控制。 换句话说,在一个被同步的区域内部,不要调用设计成要被覆盖的方法,或者是由客户端以函数对象的形式提供的方法(详见第 24 条) 。从包含该同步区域的类的角度来看,这样的方法是外来的( alien ) 。这个类不知道该方法会做什么事情,也无法控制它。根据外来方法的作用,从同步区域中调用它会导致异常、死锁或者数据损坏。

为了对这个过程进行更具体的说明,以下面的类为例,它实现了一个可以观察到的集合包装( set wrapper ) 。该类允许客户端在将元素添加到集合中时预订通知。这就是观察者(Observer )模式[Gamma95 ] 。为了简洁起见,类在从集合中删除元素时没有提供通知,但要提供通知也是一件很容易的事情。这个类是在第 18 条中可重用的 ForwardingSet 上实现的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// Broken - invokes alien method from synchronized block!
public class ObservableSet<E> extends ForwardingSet<E> {
public ObservableSet(Set<E> set) { super(set); }

private final List<SetObserver<E>> observers= new ArrayList<>();

public void addObserver(SetObserver<E> observer) {
synchronized(observers) {
observers.add(observer);
}
}

public Boolean removeObserver(SetObserver<E> observer) {
synchronized(observers) {
return observers.remove(observer);
}
}

private void notifyElementAdded(E element) {
synchronized(observers) {
for (SetObserver<E> observer : observers)
observer.added(this, element);
}
}

@Override
public Boolean add(E element) {
Boolean added = super.add(element);
if (added)
notifyElementAdded(element);
return added;
}

@Override
public Boolean addAll(Collection<? extends E> c) {
Boolean result = false;
for (E element : c)
result |= add(element);
// Calls notifyElementAdded
return result;
}
}

观察者通过调用 addObserver 方法预订通知,通过调用 removeObserver 方法取消预订。在这两种情况下,这个回调( callback )接口的实例都会被传递给方法:

1
2
3
4
5
@FunctionalInterface
public interface SetObserver<E> {
// Invoked when an element is added to the observable set
void added(ObservableSet<E> set, E element);
}

这个接口的结构与 BiConsumer<Obser vableSet<E> ,E> 一样。我们选择定义一个定制的函数接口,因为该接口和方法名称可以提升代码的可读性,且该接口可以发展整合多个回调。也就是说,还可以设置合理的参数来使用 BiConsumer (详见第 44 条)。

如果只是粗略地检验一下, ObservableSet 会显得很正常。例如,下面的程序打印出 0 ~ 99 的数字:

1
2
3
4
5
6
public static void main(String[] args) {
ObservableSet<Integer> set = new ObservableSet<>(new HashSet<>());
set.addObserver((s, e) -> System.out.println(e));
for (int i = 0; i < 100; i++)
set.add(i);
}

现在我们来尝试一些更复杂点的例子。假设我们用一个 addObserver 调用来代替这个调用,用来替换的那个 addObserver 调用传递了一个打印 Integer 值的观察者,这个值被添加到该集合中,如果值为 23 ,这个观察者要将自身删除:

1
2
3
4
5
6
7
set.addObserver(new SetObserver<>() {
public void added(ObservableSet<Integer> s, Integer e) {
System.out.println(e);
if (e == 23)
s.removeObserver(this);
}
});

注意,这个调用以一个匿名类 SetObserver 实例代替了前一个调用中使用的 lambda 。这是因为函数对象需要将自身传给 s.removeObserver ,而 lambda 则无法访问它们自己(详见第 42 条) 。

你可能以为这个程序会打印数字 0 ~ 23 ,之后观察者会取消预订,程序会悄悄地完成它的工作。实际上却是打印出数字 0 ~ 23 ,然后抛出 ConcurrentModificationException 。问题在于,当 notifyElementAdded 调用观察者的 added 方法时,它正处于遍历 observers 列表的过程中。added 方法调用可观察集合的 removeObserver 方法,从而调用 observers.remove 。现在我们有麻烦了。我们正企图在遍历列表的过程中,将一个元素从列表中删除,这是非法的。notifyElementAdded 方法中的迭代是在一个同步的块中,可以防止并发的修改,但是无法防止迭代线程本身回调到可观察的集合中,也无法防止修改它的 observers 列表。

现在我们要尝试一些比较奇特的例子: 我们来编写一个试图取消预订的观察者,但是不直接调用 removeObserver ,它用另一个线程的服务来完成。这个观察者使用了一个 executor service (详见第 80 条):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// Observer that uses a background thread needlessly
set.addObserver(new SetObserver<>() {
public void added(ObservableSet<Integer> s, Integer e) {
System.out.println(e);
if (e == 23) {
ExecutorService exec =
Executors.newSingleThreadExecutor();
try {
exec.submit(() -> s.removeObserver(this)).get();
}
catch (ExecutionException | InterruptedException ex) {
throw new AssertionError (ex);
}
finally {
exec.shutdown();
}
}
}
});

顺便提一句,注意看这个程序在一个 catch 子句中捕获了两个不同的异常类型。这个机制是在 Java 7 中增加的,不太正式地称之为多重捕获( multi-catch ) 。它可以极大地提升代码的清晰度,行为与多异常类型相同的程序,其篇幅可以大幅减少。

运行这个程序时,没有遇到异常,而是遭遇了死锁。后台线程调用 s.removeObserver,它企图锁定 observers,但它无法获得该锁,因为主线程已经有锁了。在这期间,主线程一直在等待后台线程来完成对观察者的删除,这正是造成死锁的原因。

这个例子是刻意编写用来示范的,因为观察者实际上没理由使用后台线程,但这个问题却是真实的。从同步区域中调用外来方法,在真实的系统中已经造成了许多死锁,例如 GUI 工具箱。

在前面这两个例子中(异常和死锁),我们都还算幸运。调用外来方法( added )时,同步区域( observers )所保护的资源处于一致的状态。假设当同步区域所保护的约束条件暂时无效时,你要从同步区域中调用一个外来方法。由于 Java 程序设计语言中的锁是可重入的( reentrant ),这种调用不会死锁。就像在第一个例子中一样,它会产生一个异常,因为调用线程已经有这个锁了,因此当该线程试图再次获得该锁时会成功,尽管概念上不相关的另一项操作正在该锁所保护的数据上进行着。这种失败的后果可能是灾难性的。从本质上来说,这个锁没有尽到它的职责。可重入的锁简化了多线程的面向对象程序的构造,但是它们可能会将活性失败变成安全性失败。

幸运的是,通过将外来方法的调用移出同步的代码块来解决这个问题通常并不太困难。对于 notifyElementAdded 方法,这还涉及给 observers 列表拍张“快照”,然后没有锁也可以安全地遍历这个列表了。经过这一修改,前两个例子运行起来便再也不会出现异常或者死锁了:

1
2
3
4
5
6
7
8
9
// Alien method moved outside of synchronized block - open calls
private void notifyElementAdded(E element) {
List<SetObserver<E>> snapshot = null;
synchronized(observers) {
snapshot = new ArrayList<>(observers);
}
for (SetObserver<E> observer : snapshot)
observer.added(this, element);
}

事实上,要将外来方法的调用移出同步的代码块,还有一种更好的方法。Java 类库提供了一个并发集合( concurrent collection ),详见第 81 条,称作 CopyOnWriteArrayList,这是专门为此定制的。这个 CopyOnWriteArrayListArrayList 的一种变体,它通过重新拷贝整个底层数组,在这里实现所有的写操作。由于内部数组永远不改动,因此迭代不需要锁定,速度也非常快。如果大量使用, CopyOnWriteArrayList 的性能将大受影响,但是对于观察者列表来说却是很好的,因为它们几乎不改动,并且经常被遍历。

如果将这个列表改成使用 CopyOnWriteArrayList ,就不必改动 ObservableSetaddaddAll 方法。下面是这个类的其余代码。注意其中并没有任何显式的同步:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Thread-safe observable set with CopyOnWriteArrayList
private final List<SetObserver<E>> observers = new CopyOnWriteArrayList<>();

public void addObserver(SetObserver<E> observer) {
observers.add(observer);
}

public Boolean removeObserver(SetObserver<E> observer) {
return observers.remove(observer);
}

private void notifyElementAdded(E element) {
for (SetObserver<E> observer : observers)
observer.added(this, element);
}

在同步区域之外被调用的外来方法被称作“开放调用”(open call)[Goetz06, 10 .1.4]。除了可以避免失败之外,开放调用还可以极大地增加并发性。外来方法的运行时间可能为任意时长。如果在同步区域内调用外来方法,其他线程对受保护资源的访问就会遭到不必要的拒绝。

通常来说,应该在同步区域内做尽可能少的工作。 获得锁,检查共享数据,根据需要转换数据,然后释放锁。如果你必须要执行某个很耗时的动作,则应该设法把这个动作移到同步区域的外面,而不违背第 78 条中的指导方针。

本条目的第一部分是关于正确性的。接下来,我们要简单地讨论一下性能。虽然自 Java 平台早期以来,同步的成本已经下降了,但更重要的是,永远不要过度同步。在这个多核的时代,过度同步的实际成本并不是指获取锁所花费的 CPU 时间;而是指失去了并行的机会,以及因为需要确保每个核都有一个-致的内存视图而导致的延迟。过度同步的另一项潜在开销在于,它会限制虚拟机优化代码执行的能力。

如果正在编写一个可变的类,有两种选择:省略所有的同步,如果想要并发使用,就允许客户端在必要的时候从外部同步,或者通过内部同步,使这个类变成是线程安全的(详见第 82 条),你还可以因此获得明显比从外部锁定整个对象更高的并发性。java.util 中的集合(除了已经废弃的 VectorHashtable 之外)采用了前一种方法,而 java.util.concurrent 中的集合则采用了后一种方法(详见第 81 条)。

在 Java 平台出现的早期,许多类都违背了这些指导方针。例如, StringBuffer 实例几乎总是被用于单个线程之中,而它们执行的却是内部同步。为此, StringBuffer 基本上都由 StringBuilder 代替,它是一个非同步的 StringBuffer 。同样地,java.util.Random 中线程安全的伪随机数生成器,被 java.util.concurrent.ThreadLocalRandom 中非同步的实现取代,主要也是出于上述原因。当你不确定的时候,就不要同步类,而应该建立文档,注明它不是线程安全的。

如果你在内部同步了类,就可以使用不同的方法来实现高并发性,例如分拆锁、分离锁和非阻塞并发控制。这些方法都超出了本书的讨论范围,但有其他著作对此进行了阐述[Goetz06, Herlihy12] 。

如果方法修改了静态字段,并且该方法很可能要被多个线程调用,那么也必须在内部同步对这个字段的访问(除非这个类能够容忍不确定的行为) 。多线程的客户端要在这种方法上执行外部同步是不可能的,因为其他不相关的客户端不需要同步也能调用该方法。字段本质上就是一个全局变量,即使是私有的也一样,因为它可以被不相关的客户端读取和修改。第 78 条中的 generateSerialNumber 方法使用的 nextSerialNumber 字段就是这样的一个例子。

总而言之,为了避免死锁和数据破坏,千万不要从同步区字段内部调用外来方法。更通俗地讲,要尽量将同步区字段内部的工作量限制到最少。当你在设计一个可变类的时候,要考虑一下它们是否应该自己完成同步操作。在如今这个多核的时代,这比永远不要过度同步来得更重要。只有当你有足够的理由一定要在内部同步类的时候,才应该这么做,同时还应该将这个决定清楚地写到文档中(详见第 82 条) 。

46. 优先考虑流中无副作用的函数

46. 优先考虑流中无副作用的函数

如果你是一个刚开始使用流的新手,那么很难掌握它们。仅仅将计算表示为流管道是很困难的。当你成功时,你的程序将运行,但对你来说可能没有意识到任何好处。流不仅仅是一个 API,它是基于函数式编程的范式(paradigm)。为了获得流提供的可表达性、速度和某些情况下的并行性,你必须采用范式和 API。

流范式中最重要的部分是将计算结构化为一系列转换,其中每个阶段的结果尽可能接近前一阶段结果的纯函数(pure function)。 纯函数的结果仅取决于其输入:它不依赖于任何可变状态,也不更新任何状态。 为了实现这一点,你传递给流操作的任何函数对象(中间操作和终结操作)都应该没有副作用。

有时,可能会看到类似于此代码片段的流代码,该代码构建了文本文件中单词的频率表:

1
2
3
4
5
6
7
// 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 一样。那么这段代码应该是什么样的呢?

1
2
3
4
5
6
// 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 个单词的列表。

1
2
3
4
5
// 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::getfrequency 表中查找单词,并返回单词出现在文件中的次数。最后,我们在比较器上调用 reverse 方法,因此我们将单词从最频繁到最不频繁进行排序。然后,将流限制为 10 个单词并将它们收集到一个列表中就很简单了。

前面的代码片段使用 Scannerstream 方法在 scanner 实例上获取流。这个方法是在 Java 9 中添加的。如果正在使用较早的版本,可以使用类似于条目 47 中 (streamOf(Iterable<E>)) 的适配器将实现了 Iteratorscanner 序转换为流。

那么收集器中的其他 36 种方法呢?它们中的大多数都是用于将流收集到 map 中的,这比将流收集到真正的集合中要复杂得多。每个流元素都与一个键和一个值相关联,多个流元素可以与同一个键相关联。

最简单的映射收集器是 toMap(keyMapper、valueMapper),它接受两个函数,一个将流元素映射到键,另一个映射到值。在条目 34 中的 fromString 实现中,我们使用这个收集器从 enum 的字符串形式映射到 enum 本身:

1
2
3
4
// 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。这个收集器将完成这项工作。

1
2
3
// 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 策略。 对于许多流,结果是不确定的,但如果映射函数可能与键关联的所有值都相同,或者它们都是可接受的,则此收集器的行为可能正是您想要的:

1
2
// Collector to impose last-write-wins policy
toMap(keyMapper, valueMapper, (oldVal, newVal) ->newVal)

toMap 的第三个也是最后一个版本采用第四个参数,它是一个 map 工厂,用于指定特定的 map 实现,例如 EnumMapTreeMap

toMap 的前三个版本也有变体形式,名为 toConcurrentMap,它们并行高效运行并生成 ConcurrentHashMap 实例。

除了 toMap 方法之外,Collectors API 还提供了 groupingBy 方法,该方法返回收集器以生成基于分类器函数 (classifier function)将元素分组到类别中的 map。 分类器函数接受一个元素并返回它所属的类别。 此类别来用作元素的 map 的键。 groupingBy 方法的最简单版本仅采用分类器并返回一个 map,其值是每个类别中所有元素的列表。 这是我们在条目 45 中的 Anagram 程序中使用的收集器,用于生成从按字母顺序排列的单词到单词列表的 map:

1
2
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())。 此属性还有十五种收集器方法。 它们包括九个方法,其名称以 summingaveragingsummarizing 开头(其功能在相应的原始流类型上可用)。 它们还包括 reduce 方法的所有重载,以及 filter,mappingflatMappingcollectingAndThen 方法。 大多数程序员可以安全地忽略大多数这些方法。 从设计的角度来看,这些收集器代表了尝试在收集器中部分复制流的功能,以便下游收集器可以充当「迷你流(ministreams)」。

我们还有三种收集器方法尚未提及。 虽然他们在收 Collectors 类中,但他们不涉及集合。 前两个是 minBymaxBy,它们取比较器并返回比较器确定的流中的最小或最大元素。 它们是 Stream 接口中 minmax 方法的次要总结,是 BinaryOperator 中类似命名方法返回的二元运算符的类似收集器。 回想一下,我们在最畅销的专辑中使用了 BinaryOperator.maxBy 方法。

最后的 Collectors 中方法是 join,它仅对 CharSequence 实例(如字符串)的流进行操作。 在其无参数形式中,它返回一个简单地连接元素的收集器。 它的一个参数形式采用名为 delimiter 的单个 CharSequence 参数,并返回一个连接流元素的收集器,在相邻元素之间插入分隔符。 如果传入逗号作为分隔符,则收集器将返回逗号分隔值字符串(但请注意,如果流中的任何元素包含逗号,则字符串将不明确)。 除了分隔符之外,三个参数形式还带有前缀和后缀。 生成的收集器会生成类似于打印集合时获得的字符串,例如[came, saw, conquered]

总之,编程流管道的本质是无副作用的函数对象。 这适用于传递给流和相关对象的所有许多函数对象。 终结操作 forEach 仅应用于报告流执行的计算结果,而不是用于执行计算。 为了正确使用流,必须了解收集器。 最重要的收集器工厂是 toListtoSettoMapgroupingByjoin

55. 明智审慎地返回 Optional

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 中,我们展示了根据集合中元素的自然顺序计算集合最大值的方法。

1
2
3
4
5
6
7
8
9
10
11
// 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>。下面是修改后的方法:

1
2
3
4
5
6
7
8
9
10
11
12
// 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,那么 Streammax 操作会为我们生成 Optional 的工作 (尽管我们还是传递一个显式的Comparator):

1
2
3
4
5
// 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,则客户端可以选择在方法无法返回值时要采取的操作。 可以指定默认值:

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

或者可以抛出任何适当的异常。注意,我们传递的是异常工厂,而不是实际的异常。这避免了创建异常的开销,除非它真的实际被抛出:

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

如果你能证明 Optional 非空,你可以从 Optional 获取值,而不需要指定一个操作来执行。但是如果 Optional 是空的,你判断错了,代码会抛出一个 NoSuchElementException 异常:

1
2
// 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 的方法来处理更特殊的用例:filtermapflatMapifPresent。在 Java 9 中,又添加了两个这样的方法: orifPresentOrElse。如果上面描述的基本方法与你的用例不太匹配,请查看这些更高级方法的文档,并查看它们是否能够完成任务。

如果这些方法都不能满足你的需要,Optional 提供 isPresent() 方法,可以将其视为安全阀。如果 Optional 包含值,则返回 true;如果为空,则返回 false。你可以使用此方法对可选结果执行任何喜欢的处理,但请确保明智地使用它。isPresent 的许多用途都可以被上面提到的一种方法所替代。生成的代码通常更短、更清晰、更符合习惯。

例如,请考虑此代码段,它打印一个进程的父进程 ID,如果进程没有父进程,则打印 N/A. 该代码段使用 Java 9 中引入的 ProcessHandle 类:

1
2
3
Optional<ProcessHandle> parentProcess = ph.parent();
System.out.println("Parent PID: " + (parentProcess.isPresent() ?
String.valueOf(parentProcess.get().pid()) : "N/A"));

上面的代码可以被如下代码所替代,使用了 Optional 的 map 方法:

1
2
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,下面是弥补这个差距的代码:

1
2
3
streamOfOptionals
.filter(Optional::isPresent)
.map(Optional::get)

在 Java 9 中,Optional 配备了一个 stream() 方法。这个方法是一个适配器, 此方法是一个适配器,它将 Optional 变为包含一个元素的 Stream,如果 Optional 为空,则不包含任何元素。此方法与 Stream 的 flatMap 方法 (条目 45) 相结合,这个方法可以简洁地替代上面的方法:

1
2
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 是 OptionalIntOptionalLongOptionalDouble。它们包含 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。

88. 保护性的编写 readObject 方法

88. 保护性的编写 readObject 方法

第 50 条介绍了一个不可变的日期范围类,它包含可变的私有变量 Date。该类通过在其构造器和访问方法(accessor)中保护性的拷贝 Date 对象,极力维护其约束条件和不可变性。该类代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// Immutable class that uses defensive copying
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) {
this.start = new Date(start.getTime());
this.end = new Date(end.getTime());
if (this.start.compareTo(this.end) > 0)
throw new IllegalArgumentException(start + " after " + end);
}
public Date start () {
return new Date(start.getTime());
}
public Date end () {
return new Date(end.getTime());
}
public String toString() {
return start + " - " + end;
}
... // Remainder omitted
}

假设你决定要把这个类成为可序列化的。因为 Period 对象的物理表示法正好反映了它的逻辑数据内容,所以,使用默认的序列化形式是合理的(详见 87 条)。因此,为了使这个类成为可序列化的,似乎你所需要做的也就是在类的声明中增加 implements Serializable 字样。然而,如果你真的这么做,那么这个类就不保证它的关键约束了。

问题在于 readObject 方法实际上相当于另外一个公有的构造器,它要求同其他构造器一样警惕所有的注意事项。构造器必须检查其参数的有效性(详见 49 条),并且在必要的时候对参数进行保护性拷贝(详见 50 条),同样的,readObject 方法也需要这样做。如果 readObject 方法无法做到这两者之一,对于攻击者来说要违反这个类的约束条件就相对容易很多。

不严格的说, readObject 方法是一个「用字节流作为唯一参数」的构造器。在正常使用的情况下,对一个正常构造的实例进行序列化可以产生字节流。但是,当面对一个人工仿造的字节流时, readObject 产生的对象会违反它所属类的约束条件,这时问题就产生了。这种字节流可以用来创建一个不可能的对象(impossible object),这时利用普通构造器无法创建的。

假设我们仅仅在 Period 类的声明加上了 implements Serializable 字样。那么这个丑陋的程序代码将会产生一个 Period 实例,他的结束时间比起始时间还早。对于高位 byte 值进行强制类型转换是 Java 缺少 byte 并且做出 byte 类型签名的不幸决定的后果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public class BogusPeriod {
// Byte stream couldn't have come from a real Period instance!
private static final byte[] serializedForm = {
(byte)0xac, (byte)0xed, 0x00, 0x05, 0x73, 0x72, 0x00, 0x06,
0x50, 0x65, 0x72, 0x69, 0x6f, 0x64, 0x40, 0x7e, (byte)0xf8,
0x2b, 0x4f, 0x46, (byte)0xc0, (byte)0xf4, 0x02, 0x00, 0x02,
0x4c, 0x00, 0x03, 0x65, 0x6e, 0x64, 0x74, 0x00, 0x10, 0x4c,
0x6a, 0x61, 0x76, 0x61, 0x2f, 0x75, 0x74, 0x69, 0x6c, 0x2f,
0x44, 0x61, 0x74, 0x65, 0x3b, 0x4c, 0x00, 0x05, 0x73, 0x74,
0x61, 0x72, 0x74, 0x71, 0x00, 0x7e, 0x00, 0x01, 0x78, 0x70,
0x73, 0x72, 0x00, 0x0e, 0x6a, 0x61, 0x76, 0x61, 0x2e, 0x75,
0x74, 0x69, 0x6c, 0x2e, 0x44, 0x61, 0x74, 0x65, 0x68, 0x6a,
(byte)0x81, 0x01, 0x4b, 0x59, 0x74, 0x19, 0x03, 0x00, 0x00,
0x78, 0x70, 0x77, 0x08, 0x00, 0x00, 0x00, 0x66, (byte)0xdf,
0x6e, 0x1e, 0x00, 0x78, 0x73, 0x71, 0x00, 0x7e, 0x00, 0x03,
0x77, 0x08, 0x00, 0x00, 0x00, (byte)0xd5, 0x17, 0x69, 0x22,
0x00, 0x78
};
public static void main(String[] args) {
Period p = (Period) deserialize(serializedForm);
System.out.println(p);
}
// Returns the object with the specified serialized form
static Object deserialize(byte[] sf) {
try {
return new ObjectInputStream(
new ByteArrayInputStream(sf)).readObject();
}
catch (IOException | ClassNotFoundException e) {
throw new IllegalArgumentException(e);
}
}
}

被用来初始化 serializedForm 的 byte 常量数组是这样产生的:首先对一个正常的 Period 实例进行序列化,然后对得到的字节流进行手工编辑。对于这个例子而言,字节流的细节并不重要,如果你对此十分好奇,可以在《Java Object Serialization Specification》[Serialization, 6] 中查到有关序列化字节流格式的描述信息。如果运行这个程序,它会打印出「Fri Jan 01 12:00:00 PST 1999 - Sun Jan 01 12:00:00 PST 1984」。主要把 Period 类声明成为可序列化的,这会使我们创建出其违反类约束条件的对象。

为了修整这个问题,可以为 Period 提供一个 readObject 方法,该方法首先调用 defaultReadObject,然后检查被反序列化之后的对象有效性。如果有效性检查失败,readObject 方法就会抛出一个 InvalidObjectException 异常,这使得反序列化过程不能成功的完成:

1
2
3
4
5
6
7
8
// readObject method with validity checking - insufficient!
private void readObject(ObjectInputStream s)
throws IOException, ClassNotFoundException {
s.defaultReadObject();
// Check that our invariants are satisfied
if (start.compareTo(end) > 0)
throw new InvalidObjectException(start +" after "+ end);
}

尽管这样的修成避免了攻击者创建无效的 Period 实例,但是这里依旧隐藏着一个更为微妙的问题。通过伪造字节流,要想创建可变的 Period 实例仍是有可能的,做法是:字节流以一个有效的 Period 实例开头,然后附加上两个额外的引用,指向 Period 实例中两个私有的 Date 字段。攻击者从 ObjectInputStream 读取 Period 实例,然后读取附加在其后面的「恶意编制的对线引用」。这些对象引用使得攻击者能够访问到 Period 对象内部的私有 Date 字段所引用的对象。通过改变这些 Date 实例,攻击者可以改变 Period 实例。如下的类演示了这种攻击方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
public class MutablePeriod {
// A period instance
public final Period period;
// period's start field, to which we shouldn't have access
public final Date start;
// period's end field, to which we shouldn't have access
public final Date end;

public MutablePeriod() {
try {
ByteArrayOutputStream bos =
new ByteArrayOutputStream();
ObjectOutputStream out =
new ObjectOutputStream(bos);
// Serialize a valid Period instance
out.writeObject(new Period(new Date(), new Date()));
/*
* Append rogue "previous object refs" for internal
* Date fields in Period. For details, see "Java
* Object Serialization Specification," Section 6.4.
*/
byte[] ref = { 0x71, 0, 0x7e, 0, 5 };
// Ref #5
bos.write(ref);
// The start field
ref[4] = 4;
// Ref # 4
bos.write(ref);
// The end field
// Deserialize Period and "stolen" Date references
ObjectInputStream in = new ObjectInputStream(
new ByteArrayInputStream(bos.toByteArray()));
period = (Period) in.readObject();
start = (Date) in.readObject();
end = (Date) in.readObject();
}
catch (IOException | ClassNotFoundException e) {
throw new AssertionError(e);
}
}
}

要查看正在进行的攻击,请运行以下程序:

1
2
3
4
5
6
7
8
9
10
11
public static void main(String[] args) {
MutablePeriod mp = new MutablePeriod();
Period p = mp.period;
Date pEnd = mp.end;
// Let's turn back the clock
pEnd.setYear(78);
System.out.println(p);
// Bring back the 60s!
pEnd.setYear(69);
System.out.println(p);
}

在我本地机器上运行这个程序产生的输出结果如下:

1
2
Wed Nov 22 00:21:29 PST 2017 - Wed Nov 22 00:21:29 PST 1978
Wed Nov 22 00:21:29 PST 2017 - Sat Nov 22 00:21:29 PST 1969

虽然 Period 实例被创建之后,他的约束条件没有被破坏。但是要随意修改它的内部组件仍然是有可能的。一旦攻击者获得了一个可变的 Period 实例,就可以将这个实例传递给一个「安全性依赖于 Period 的不可变性」的类,从而造成更大的危害。这种推断并不牵强:实际上,有许多类的安全性就是依赖于 String 的不可变性。

问题的根源在于,PeriodreadObject 方法并没有完成足够的保护性拷贝。 当一个对象被反序列化的时候,对于客户端不应该拥有的对象引用,如果那个字段包含了这样的对象引用,就必须做保护性拷贝,这是非常重要的。 因此,对于每个可序列化的不可变类,如果它包含了私有的可变字段,那么在它的 readObject 方法中,必须要对这些字段进行保护性拷贝。下面的这些 readObject 方法可以确保 Period 类的约束条件不会遭到破坏,以保持它的不可变性:

1
2
3
4
5
6
7
8
9
10
11
// readObject method with defensive copying and validity checking
private void readObject(ObjectInputStream s)
throws IOException, ClassNotFoundException {
s.defaultReadObject();
// Defensively copy our mutable components
start = new Date(start.getTime());
end = new Date(end.getTime());
// Check that our invariants are satisfied
if (start.compareTo(end) > 0)
throw new InvalidObjectException(start +" after "+ end);
}

注意,保护性拷贝是在有效性检查之前进行的。我们没有使用 Dateclone 方法来执行保护性拷贝机制。这两个细节对于保护 Period 类免受攻击是必要的(详见 50 条)。同时也注意到,对于 final 字段,保护性字段是不可能的。为了使用 readObject 方法,我们必须要将 start 和 end 字段声明成为非 final 的。很遗憾的是,这还算是相对比较好的做法。有了这新的 readObject 方法,并且取消了 start 和 end 的 final 修饰符之后,MutablePeriod 类将不再有效。此时,上面的攻击程序会产生如下输出:

1
2
Wed Nov 22 00:23:41 PST 2017 - Wed Nov 22 00:23:41 PST 2017
Wed Nov 22 00:23:41 PST 2017 - Wed Nov 22 00:23:41 PST 2017

有一个简单的「石蕊」测试,可以用来确定默认的 readObject 方法是否可以被接受。测试方法:增加一个公有的构造器,其参数对应于该对象中每个非 transient 的字段,并且无论参数的值是什么,都是不进行检查就可以保存到相应的字段中。对于这样的做法,你是否会感到很舒适?如果你对这个问题的回答是否定的,就必须提供一个显式的 readObject 方法,并且它必须执行构造器所要求的所有有效性检查和保护性拷贝。另一种方法是,可以使用序列化代理模式(serialization proxy pattern),详见第 90 条。强烈建议使用这个模式,因为它分担了安全反序列化的部门工作。

对于非 final 的可序列化的类,在 readObject 方法和构造器之间还有其他类似的地方。与构造器一样,readObject 方法不可以调用可被覆盖的方法,无论是直接调用还是间接调用都不可以(详见 19 条)。如果违反了这条规则,并且覆盖了该方法,被覆盖的方法将在子类的状态被反序列化之前先运行。这个程序很可能会失败[Bloch05, Puzzle 91]。

总而言之,在编写 readObject 方法的时候,都要这样想:你正在编写一个公有的构造器,无论给它传递什么样的字节流,它都必须产生一个有效的实例。不要假设这个字节流一定代表着一个真正被序列化的实例。虽然在本条目的例子中,类使用了默认的序列化形式,但是所有讨论到的有可能发生的问题也同样适用于自定义序列化形式的类。下面以摘要的形式给出一些指导方针,有助于编写出更健壮的 readObject 方法。

  • 类中的对象引用字段必须保持为私有属性,要保护性的拷贝这些字段中的每个对象。不可变类中的可变组件就属于这一类别
  • 对于任何约束条件,如果检查失败就抛出一个 InvalidObjectException 异常。这些检查动作应该跟在所有的保护性拷贝之后。
  • 如果整个对象图在被反序列化之后必须进行验证,就应该使用 ObjectInputValidation 接口(本书没有讨论)。
  • 无论是直接方法还是间接方法,都不要调用类中任何可被覆盖的方法。

15. 使类和成员的可访问性最小化

15. 使类和成员的可访问性最小化

将设计良好的组件与设计不佳的组件区分开来的最重要的因素是,隐藏内部数据和其他实现细节的程度。一个设计良好的组件隐藏了它的所有实现细节,干净地将它的 API 与它的实现分离开来。然后,组件只通过它们的 API 进行通信,并且对彼此的内部工作一无所知。这一概念,被称为信息隐藏或封装,是软件设计的基本原则[Parnas72]。

信息隐藏很重要有很多原因,其中大部分来源于它将组成系统的组件分离开来,允许它们被独立地开发,测试,优化,使用,理解和修改。这加速了系统开发,因为组件可以并行开发。它减轻了维护的负担,因为可以更快速地理解组件,调试或更换组件,而不用担心损害其他组件。虽然信息隐藏本身并不会导致良好的性能,但它可以有效地进行性能调整:一旦系统完成并且分析确定了哪些组件导致了性能问题(条目 67),则可以优化这些组件,而不会影响别人的正确的组件。信息隐藏增加了软件重用,因为松耦合的组件通常在除开发它们之外的其他环境中证明是有用的。最后,隐藏信息降低了构建大型系统的风险,因为即使系统不能运行,各个独立的组件也可能是可用的。

Java 提供了许多机制来帮助信息隐藏。 访问控制机制(access control mechanism)[JLS,6.6] 指定了类,接口和成员的可访问性。 实体的可访问性取决于其声明的位置,以及声明中存在哪些访问修饰符(private,protected 和 public)。 正确使用这些修饰符对信息隐藏至关重要。

经验法则很简单:让每个类或成员尽可能地不可访问。 换句话说,使用尽可能低的访问级别,与你正在编写的软件的对应功能保持一致。

对于顶层(非嵌套的)类和接口,只有两个可能的访问级别:包级私有(package-private)和公共的(public)。如果你使用 public 修饰符声明顶级类或接口,那么它是公开的;否则,它是包级私有的。如果一个顶层类或接口可以被做为包级私有,那么它应该是。通过将其设置为包级私有,可以将其作为实现的一部分,而不是导出的 API,你可以修改它、替换它,或者在后续版本中消除它,而不必担心损害现有的客户端。如果你把它公开,你就有义务永远地支持它,以保持兼容性。

如果一个包级私有顶级类或接口只被一个类使用,那么可以考虑这个类作为使用它的唯一类的私有静态嵌套类 (详见第 24 条)。这将它的可访问性从包级的所有类减少到使用它的一个类。但是,减少不必要的公共类的可访问性要比包级私有的顶级类更重要:公共类是包的 API 的一部分,而包级私有的顶级类已经是这个包实现的一部分了。

对于成员(字段、方法、嵌套类和嵌套接口),有四种可能的访问级别,在这里,按照可访问性从小到大列出:

  • private —— 该成员只能在声明它的顶级类内访问。
  • package-private —— 成员可以从被声明的包中的任何类中访问。从技术上讲,如果没有指定访问修饰符(接口成员除外,它默认是公共的),这是默认访问级别。
  • protected —— 成员可以从被声明的类的子类中访问(会受一些限制 [JLS, 6.6.2]),以及它声明的包中的任何类。
  • public —— 该成员可以从任何地方被访问。

在仔细设计你的类的公共 API 之后,你的反应应该是让所有其他成员设计为私有的。 只有当同一个包中的其他类真的需要访问成员时,需要删除私有修饰符,从而使成员包成为包级私有的。 如果你发现自己经常这样做,你应该重新检查你的系统的设计,看看另一个分解可能产生更好的解耦的类。 也就是说,私有成员和包级私有成员都是类实现的一部分,通常不会影响其导出的 API。 但是,如果类实现 Serializable 接口(详见第 86 和 87 条),则这些字段可以「泄漏(leak)」到导出的 API 中。

对于公共类的成员,当访问级别从包私有到受保护级时,可访问性会大大增加。 受保护(protected)的成员是类导出的 API 的一部分,并且必须永远支持。 此外,导出类的受保护成员表示对实现细节的公开承诺(详见第 19 条)。 对受保护成员的需求应该相对较少。

有一个关键的规则限制了你减少方法访问性的能力。 如果一个方法重写一个超类方法,那么它在子类中的访问级别就不能低于父类中的访问级别[JLS,8.4.8.3]。 这对于确保子类的实例在父类的实例可用的地方是可用的(Liskov 替换原则,见条目 15)是必要的。 如果违反此规则,编译器将在尝试编译子类时生成错误消息。 这个规则的一个特例是,如果一个类实现了一个接口,那么接口中的所有类方法都必须在该类中声明为 public。

为了便于测试你的代码,你可能会想要让一个类,接口或者成员更容易被访问。 这没问题。 为了测试将公共类的私有成员指定为包级私有是可以接受的,但是提高到更高的访问级别却是不可接受的。 换句话说,将类,接口或成员作为包级导出的 API 的一部分来促进测试是不可接受的。 幸运的是,这不是必须的,因为测试可以作为被测试包的一部分运行,从而获得对包私有元素的访问。

公共类的实例字段很少情况下采用 public 修饰(详见第 16 条)。 如果一个实例字段是非 final 的,或者是对可变对象的引用,那么通过将其公开,你就放弃了限制可以存储在字段中的值的能力。这意味着你放弃了执行涉及该字段的不变量的能力。另外,当字段被修改时,就放弃了采取任何操作的能力,因此带有公共可变字段的类通常不是线程安全的 。即使一个字段是 final 的,并且引用了一个不可变的对象,通过将其公开,你放弃了切换到一个新的内部数据表示的灵活性,而该字段并不存在。

同样的建议适用于静态字段,但有一个例外。 假设常量是类的抽象的一个组成部分,你可以通过 public static final 字段暴露常量。 按照惯例,这些字段的名字由大写字母组成,字母用下划线分隔(详见第 68 条)。 很重要的一点是,这些字段包含基本类型的值或对不可变对象的引用(详见第 17 条)。 包含对可变对象的引用的字段具有非 final 字段的所有缺点。 虽然引用不能被修改,但引用的对象可以被修改,并会带来灾难性的结果。

请注意,非零长度的数组总是可变的,所以类具有公共静态 final 数组字段,或返回这样一个字段的访问器是错误的。 如果一个类有这样的字段或访问方法,客户端将能够修改数组的内容。 这是安全漏洞的常见来源:

1
2
// Potential security hole!
public static final Thing[] VALUES = { ... };

要小心这样的事实,一些 IDE 生成的访问方法返回对私有数组字段的引用,导致了这个问题。 有两种方法可以解决这个问题。 你可以使公共数组私有并添加一个公共的不可变列表:

1
2
private static final Thing[] PRIVATE_VALUES = { ... };
public static final List<Thing> VALUES = Collections.unmodifiableList(Arrays.asList(PRIVATE_VALUES));

或者,可以将数组设置为 private,并添加一个返回私有数组拷贝的公共方法:

1
2
3
4
5
private static final Thing[] PRIVATE_VALUES = { ... };

public static final Thing[] values() {
return PRIVATE_VALUES.clone();
}

要在这些方法之间进行选择,请考虑客户端可能如何处理返回的结果。 哪种返回类型会更方便? 哪个会更好的表现?

在 Java 9 中,作为模块系统(module system)的一部分引入了两个额外的隐式访问级别。模块包含一组包,就像一个包包含一组类一样。模块可以通过模块声明中的导出(export)声明显式地导出某些包 (这是 module-info.java 的源文件中包含的约定)。模块中的未导出包的公共和受保护成员在模块之外是不可访问的;在模块中,可访问性不受导出(export)声明的影响。使用模块系统允许你在模块之间共享类,而不让它们对整个系统可见。在未导出的包中,公共和受保护的公共类的成员会产生两个隐式访问级别,这是普通公共和受保护级别的内部类似的情况。这种共享的需求是相对少见的,并且可以通过重新安排包中的类来消除。

与四个主要访问级别不同,这两个基于模块的级别主要是建议(advisory)。 如果将模块的 JAR 文件放在应用程序的类路径而不是其模块路径中,那么模块中的包将恢复为非模块化行为:包的公共类的所有公共类和受保护成员都具有其普通的可访问性,不管包是否由模块导出[Reinhold,1.2]。 新引入的访问级别严格执行的地方是 JDK 本身:Java 类库中未导出的包在模块之外真正无法访问。

对于典型的 Java 程序员来说,不仅程序模块所提供的访问保护存在局限性,而且在本质上是很大程度上建议性的;为了利用它,你必须把你的包组合成模块,在模块声明中明确所有的依赖关系,重新安排你的源码树层级,并采取特殊的行动来适应你的模块内任何对非模块化包的访问[Reinhold, 3]。 现在说模块是否会在 JDK 之外得到广泛的使用还为时尚早。 与此同时,除非你有迫切的需要,否则似乎最好避免它们。

总而言之,应该尽可能地减少程序元素的可访问性(在合理范围内)。 在仔细设计一个最小化的公共 API 之后,你应该防止任何散乱的类,接口或成员成为 API 的一部分。 除了作为常量的公共静态 final 字段之外,公共类不应该有公共字段。 确保 public static final 字段引用的对象是不可变的。

81. 并发工具优于 wait 和 notify

81. 并发工具优于 wait 和 notify

本书第 1 版中专门用了一个条目来说明如何正确地使用 waitnotify ( Bloch01,详见第 50 条) 。它提出的建议仍然有效,并且在本条目的最后也对此做了概述,但是这条建议现在远远没有之前那么重要了。这是因为几乎没有理由再使用 waitnotify 了。自从 Java 5 发行版本开始, Java 平台就提供了更高级的并发工具,它们可以完成以前必须在 waitnotify 上手写代码来完成的各项工作。 既然正确地使用 wait 和 notify 比较困难,就应该用更高级的并发工具来代替。

java.util.concurrent 中更高级的工具分成三类: Executor Framework 、并发集合(Concurrent Collection)以及同步器(Synchronizer),Executor Framework 只在第 80 条中简单地提到过,并发集合和同步器将在本条目中进行简单的阐述。

并发集合为标准的集合接口(如 ListQueueMap )提供了高性能的并发实现。为了提供高并发性,这些实现在内部自己管理同步(详见第 79 条) 。因此, 并发集合中不可能排除并发活动;将它锁定没有什么作用,只会使程序的速度变慢。

因为无法排除并发集合中的并发活动,这意味着也无法自动地在并发集合中组成方法调用。因此,有些并发集合接口已经通过依赖状态的修改操作(state-dependent modify operation)进行了扩展,它将几个基本操作合并到了单个原子操作中。事实证明,这些操作在并发集合中已经够用,它们通过缺省方法(详见第 21 条)被加到了 Java 8 对应的集合接口中。

例如, MapputIfAbsent(key, value) 方法,当键没有映射时会替它插入一个映射,并返回与键关联的前一个值,如果没有这样的值,则返回 null 。这样就能很容易地实现线程安全的标准 Map 了。例如,下面这个方法模拟了 String.intern 的行为:

1
2
3
4
5
6
// Concurrent canonicalizing map atop ConcurrentMap - not optimal
private static final ConcurrentMap<String, String> map = new ConcurrentHashMap<>();
public static String intern(String s) {
String previousValue = map.putIfAbsent(s, s);
return previousValue == null ? s : previousValue;
}

事实上,你还可以做得更好。ConcurrentHashMap 对获取操作(如 get)进行了优化。因此,只有当 get 表明有必要的时候,才值得先调用 get ,再调用 putIfAbsent :

1
2
3
4
5
6
7
8
9
10
// Concurrent canonicalizing map atop ConcurrentMap - faster!
public static String intern(String s) {
String result = map.get(s);
if (result == null) {
result = map.putIfAbsent(s, s);
if (result == null)
result = s;
}
return result;
}

ConcurrentHashMap 除了提供卓越的并发性之外,速度也非常快。在我的机器上,上面这个优化过的 intern 方法比 String.intern 快了不止 6 倍(但是记住, String.intern 必须使用某种弱引用,避免随着时间的推移而发生内存泄漏)。并发集合导致同步的集合大多被废弃了。比如, 应该优先使用 ​​**ConcurrentHashMap​ ,而不是使用 ​Collections.synchronizedMap​ 。** 只要用并发 Map 替换同步 Map ,就可以极大地提升并发应用程序的性能。

有些集合接口已经通过阻塞操作(blocking operation )进行了扩展,它们会一直等待(或者阻塞)到可以成功执行为止。例如, BlockingQueue 扩展了 Queue 接口,并添加了包括 take 在内的几个方法,它从队列中删除并返回了头元素,如果队列为空,就等待。这样就允许将阻塞队列用于工作队列(work queue),也称作生产者一消费者队列 (producer-consumer queue),一个或者多个生产者线程(producer thread)在工作队列中添加工作项目,并且当工作项目可用时,一个或者多个消费者线程(consumer thread )则从工作队列中取出队列并处理工作项目。不出所料,大多数 ExecutorService 实现(包括 ThreadPoolExecutor)都使用了一个 BlockingQueue(详见第 80 条) 。

同步器(Synchronizer)是使线程能够等待另一个线程的对象,允许它们协调动作。最常用的同步器是 CountDownLatchSemaphore 。较不常用的是 CyclicBarrierExchanger 。功能最强大的同步器是 Phaser

倒计数锁存器(Countdown Latch)是一次性的障碍,允许一个或者多个线程等待一个或者多个其他线程来做某些事情。CountDownLatch 的唯一构造器带有一个 int 类型的参数,这个 int 参数是指允许所有在等待的线程被处理之前,必须在锁存器上调用 countDown 方法的次数。

要在这个简单的基本类型之上构建一些有用的东西,做起来是相当容易。例如,假设想要构建一个简单的框架,用来给一个动作的并发执行定时。这个框架中只包含单个方法,该方法带有一个执行该动作的 executor ,一个并发级别(表示要并发执行该动作的次数),以及表示该动作的 runnable 。所有的工作线程( worker thread )自身都准备好,要在 timer 线程启动时钟之前运行该动作。当最后一个工作线程准备好运行该动作时, timer 线程就「发射发令枪(fires the starting gun)」,同时允许工作线程执行该动作。一旦最后一个工作线程执行完该动作,timer 线程就立即停止计时。直接在 wait 和 notify 之上实现这个逻辑会很棍乱,而在 CountDownLatch 之上实现则相当简单:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// Simple framework for timing concurrent execution
public static long time(Executor executor, int concurrency,
Runnable action) throws InterruptedException {
CountDownLatch ready = new CountDownLatch(concurrency);
CountDownLatch start = new CountDownLatch(1);
CountDownLatch done = new CountDownLatch(concurrency);
for (int i = 0; i < concurrency; i++) {
executor.execute(() -> {
ready.countDown();
// Tell timer we're ready
try {
start.await();
// Wait till peers are ready
action.run();
}
catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
finally {
done.countDown();
// Tell timer we're done
}
});
}
ready.await();
// Wait for all workers to be ready
long startNanos = System.nanoTime();
start.countDown();
// And they're off!
done.await();
// Wait for all workers to finish
return System.nanoTime() - startNanos;
}

注意这个方法使用了三个倒计数锁存器。第一个是 ready ,工作线程用它来告诉 timer 线程它们已经准备好了。然后工作线程在第二个锁存器 start 上等待。当最后一个工作线程调用 ready.countDown 时, timer 线程记录下起始时间,并调用 start.countDown 允许所有的工作线程继续进行。然后 timer 线程在第三个锁存器 done 上等待,直到最后一个工作线程运行完该动作,并调用 done.countDown 。一旦调用这个, timer 线程就会苏醒过来,并记录下结束的时间。

还有一些细节值得注意。传递给 time 方法的 executor 必须允许创建至少与指定并发级别一样多的线程,否则这个测试就永远不会结束。这就是线程饥饿死锁(thread starvationdeadlock) [Goetz06, 8.1.1] 。如果工作线程捕捉到 InterruptedException ,就会利用习惯用法 Thread.currentThread().interrupt() 重新断言中断,并从它的 run 方法中返回。这样就允许 executor 在必要的时候处理中断,事实上也理应如此。注意,我们利用了 System.nanoTime 来给活动定时。对于间歇式的定时,始终应该优先使用 System.nanoTime ,而不是使用 System.currentTimeMillis 。因为 System.nanoTime 更准确,也更精确,它不受系统的实时时钟的调整所影响。最后,注意本例中的代码并不能进行准确的定时,除非 action 能完成一定量的工作,比如一秒或者一秒以上。众所周知,准确的微基准测试十分困难,最好在专门的框架如 jmh 的协助下进行[JMH] 。

本条目仅仅触及了并发工具的一些皮毛。例如,前一个例子中的那三个倒计数锁存器其实可以用一个 CyclicBarrier 或者 Phaser 实例代替。这样得到的代码更加简洁,但是理解起来比较困难。虽然你始终应该优先使用并发工具,而不是使用 wait 方法和 notify 方法,但可能必须维护使用了 wait 方法和 notify 方法的遗留代码。wait 方法被用来使线程等待某个条件。它必须在同步区域内部被调用,这个同步区域将对象锁定在了调用 wait 方法的对象上。下面是使用 wait 方法的标准模式:

1
2
3
4
5
6
7
// The standard idiom for using the wait method
synchronized (obj) {
while (<condition does not hold>)
obj.wait();
// (Releases lock, and reacquires on wakeup)
... // Perform action appropriate to condition
}

始终应该使用 wait 循环模式来调用 wait 方法;永远不要在循环之外调用 wait 方法。循环会在等待之前和之后对条件进行测试。

在等待之前测试条件,当条件已经成立时就跳过等待,这对于确保活性是必要的。如果条件已经成立,并且在线程等待之前, notify (或者 notifyAll)方法已经被调用, 则无法保证该线程总会从等待中苏醒过来。

在等待之前测试条件,如果条件不成立的话继续等待,这对于确保安全性是必要的。当条件不成立的时候,如果线程继续执行,则可能会破坏被锁保护的约束关系。当条件不成立时,有以下理由可使一个线程苏醒过来:

  • 另一个线程可能已经得到了锁,并且从一个线程调用 notify 方法那一刻起,到等待线程苏醒过来的这段时间中,得到锁的线程已经改变了受保护的状态。
  • 条件并不成立,但是另一个线程可能意外地或恶意地调用了 notify 方法。在公有可访问的对象上等待,这些类实际上把自己暴露在了这种危险的境地中。公有可访问对象的同步方法中包含的 wait 方法都会出现这样的问题。
  • 通知线程( notifying thread )在唤醒等待线程时可能会过于「慷慨」 。例如,即使只有某些等待线程的条件已经被满足,但是通知线程可能仍然调用 notifyAll 方法。
  • 在没有通知的情况下,等待线程也可能(但很少)会苏醒过来。这被称为“伪唤醒”(spurious wakeup) [POSIX, 11.4.3.6.1; Java9-api] 。

一个相关的话题是,为了唤醒正在等待的线程,你应该使用 notify 方法还是 notifyAll 方法(回忆一下, notify 方法唤醒的是单个正在等待的线程,假设有这样的线程存在,而 notifyAll 方法唤醒的则是所有正在等待的线程) 。一种常见的说法是,应该始终使用 notifyAll 方法。这是合理而保守的建议。它总会产生正确的结果,因为它可以保证你将会唤醒所有需要被唤醒的线程。你可能也会唤醒其他一些线程,但是这不会影响程序的正确性。这些线程醒来之后,会检查它们正在等待的条件,如果发现条件并不满足,就会继续等待。

从优化的角度来看,如果处于等待状态的所有线程都在等待同一个条件,而每次只有一个线程可以从这个条件中被唤醒,那么你就应该选择调用 notify 方法,而不是 notifyAll 方法。

即使这些前提条件都满足,也许还是有理由使用 notifyAll 方法而不是 notify 方法。就好像把 wait 方法调用放在一个循环中,以避免在公有可访问对象上的意外或恶意的通知一样,与此类似,使用 notifyAll 方法代替 notify 方法可以避免来自不相关线程的意外或恶意的等待。否则,这样的等待会“吞掉”一个关键的通知,使真正的接收线程无限地等待下去。

简而言之,直接使用 wait 方法和 notify 方法就像用“并发汇编语言”进行编程一样,而 java.util.concurrent 则提供了更高级的语言。 没有理由在新代码中使用 ​​**wait​ 方法和 ​notify​ 方法,即使有,也是极少的。** 如果你在维护使用 wait 方法和 notify 方法的代码,务必确保始终是利用标准的模式从 while 循环内部调用 wait 方法。一般情况下,应该优先使用 notifyAll 方法,而不是使用 notify 方法。如果使用 notify 方法,请一定要小心,以确保程序的活性。

19. 要么设计继承并提供文档说明,要么禁用继承

19. 要么设计继承并提供文档说明,要么禁用继承

条目 18 中提醒你注意继承没有设计和文档说明的「外来」类的子类化的危险。 那么对于专门为了继承而设计并且具有良好文档说明的类而言,这又意味着什么呢?

首先,这个类必须准确地描述重写每个方法带来的影响。 换句话说,该类必须文档说明可重写方法的自用性(self-use)。 对于每个 public 或者 protected 的方法,文档必须指明方法调用哪些可重写方法,以何种顺序调用的,以及每次调用的结果又是如何影响后续处理。 (重写方法,这里是指非 final 修饰的方法,无论是公开还是保护的。)更一般地说,一个类必须文档说明任何可能调用可重写方法的情况。 例如,后台线程或者静态初始化代码块可能会调用这样的方法。

调用可重写方法的方法在文档注释结束时包含对这些调用的描述。 这些描述在规范中特定部分,标记为「Implementation Requirements」,由 Javadoc 标签 @implSpec 生成。 这段话介绍该方法的内部工作原理。 下面是从 java.util.AbstractCollection 类的规范中拷贝的例子:

1
2
3
4
5
public boolean remove(Object o)

Removes a single instance of the specified element from this collection, if it is present (optional operation). More formally, removes an element e such that Objects.equals(o, e), if this collection contains one or more such elements. Returns true if this collection contained the specified element (or equivalently, if this collection changed as a result of the call).

Implementation Requirements: This implementation iterates over the collection looking for the specified element. If it finds the element, it removes the element from the collection using the iterator’s remove method. Note that this implementation throws an UnsupportedOperationException if the iterator returned by this collection’s iterator method does not implement the remove method and this collection contains the specified object.

从该集合中删除指定元素的单个实例(如果存在,optional 实例操作)。 更广义地说,如果这个集合包含一个或多个这样的元素 e,就删除其中的一个满足 Objects.equals(o, e) 的元素 e。 如果此集合包含指定的元素(或者等同于此集合因调用而发生了更改),则返回 true。

实现要求: 这个实现迭代遍历集合查找指定元素。 如果找到元素,则使用迭代器的 remove 方法从集合中删除元素。 请注意,如果此集合的 iterator 方法返回的迭代器未实现 remove 方法,并且此集合包含指定的对象,则该实现将引发 UnsupportedOperationException 异常。

这个文档清楚地说明,重写 iterator 方法将会影响 remove 方法的行为。 它还描述了 iterator 方法返回的 Iterator 行为将如何影响 remove 方法的行为。 与条目 18 中的情况相反,在这种情况下,程序员继承 HashSet 并不能说明重写 add 方法是否会影响 addAll 方法的行为。

关于程序文档有句格言:好的 API 文档应该描述一个给定的方法做了什么工作,而不是描述它是如何做到的。那么,上面这种做法是否违背了这句格言呢?是的,它确实违背了!这正是继承破坏了封装性所带来的不幸后果。所以,为了设计一个类的文档,以便它能够被安全地子类化,你必须描述清楚那些有可能未定义的实现细节。

@implSpec 标签是在 Java 8 中添加的,并且在 Java 9 中被大量使用。这个标签应该默认启用,但是从 Java 9 开始,除非通过命令行开关-tag "apiNote:a:API Note:",否则 Javadoc 工具仍然会忽略它。

为了继承而进行的设计不仅仅涉及自用模式的文档设计。为了使程序员能够编写出更加有效的子类,而无须承受不必要的痛苦,类必须以精心挑选的 protected 方法的形式,提供适当的钩子(hook),以便进入其内部工作中。或者在罕见的情况下,提供受保护的属性。 例如,考虑 java.util.AbstractList 中的 removeRange 方法:

1
2
3
4
5
6
7
8
9
10
11
protected void removeRange(int fromIndex, int toIndex)

Removes from this list all of the elements whose index is between fromIndex, inclusive, and toIndex, exclusive. Shifts any succeeding elements to the left (reduces their index). This call shortens the list by (toIndex - fromIndex) elements. (If toIndex == fromIndex, this operation has no effect.)

This method is called by the clear operation on this list and its sublists. Overriding this method to take advantage of the internals of the list implementation can substantially improve the performance of the clear operation on this list and its sublists.

Implementation Requirements: This implementation gets a list iterator positioned before fromIndex and repeatedly calls ListIterator.nextfollowed by ListIterator.remove, until the entire range has been removed. Note: If ListIterator.remove requires linear time, this implementation requires quadratic time.

Parameters:
fromIndex index of first element to be removed.
toIndex index after last element to be removed.

从此列表中删除索引介于 fromIndex(包含)和 inclusive(不含)之间的所有元素。 将任何后续元素向左移(减少索引)。 这个调用通过(toIndex - fromIndex)元素来缩短列表。 (如果 toIndex == fromIndex,则此操作无效。)

这个方法是通过列表及其子类的 clear 操作来调用的。重写这个方法利用列表内部实现的优势,可以大大提高列表和子类的 clear 操作性能。

实现要求: 这个实现获取一个列表迭代器,它位于 fromIndex 之前,并重复调用 ListIterator.removeListIterator.next 方法,直到整个范围被删除。 注意:如果 ​​**ListIterator.remove​ 需要线性时间,则此实现需要平方级时间。**

参数:

  fromIndex 要移除的第一个元素的索引

  toIndex 要移除的最后一个元素之后的索引

这个方法对 List 实现的最终用户来说是没有意义的。 它仅仅是为了使子类很容易提供一个快速 clear 方法。 在没有 removeRange 方法的情况下,当在子列表上调用 clear 方法,子类将不得不使用平方级的时间,否则,或从头重写整个 subList 机制——这不是一件容易的事情!

那么当你设计一个继承类的时候,你如何决定暴露哪些的受保护的成员呢? 不幸的是,没有灵丹妙药。 所能做的最好的就是努力思考,做出最好的测试,然后通过编写子类来进行测试。 应该尽可能少地暴露受保护的成员,因为每个成员都表示对实现细节的承诺。 另一方面,你不能暴露太少,因为失去了保护的成员会导致一个类几乎不能用于继承。

测试为继承而设计的类的唯一方法是编写子类。 如果你忽略了一个关键的受保护的成员,试图编写一个子类将会使得遗漏痛苦地变得明显。 相反,如果编写的几个子类,而且没有一个使用受保护的成员,那么应该将其设为私有。 经验表明,三个子类通常足以测试一个可继承的类。 这些子类应该由父类作者以外的人编写。

当你为继承设计一个可能被广泛使用的类的时候,要意识到你永远承诺你文档说明的自用模式以及隐含在其保护的方法和属性中的实现决定。 这些承诺可能会使后续版本中改善类的性能或功能变得困难或不可能。 因此, 在发布它之前,你必须通过编写子类来测试你的类。

另外,请注意,继承所需的特殊文档混乱了正常的文档,这是为创建类的实例并在其上调用方法的程序员设计的。 在撰写本文时,几乎没有工具将普通的 API 文档从和仅仅针对子类实现的信息,分离出来。

还有一些类必须遵守允许继承的限制。 构造方法绝不能直接或间接调用可重写的方法。 如果违反这个规则,将导致程序失败。 父类构造方法在子类构造方法之前运行,所以在子类构造方法运行之前,子类中的重写方法被调用。 如果重写方法依赖于子类构造方法执行的任何初始化,则此方法将不会按预期运行。 为了具体说明,这是一个违反这个规则的类:

1
2
3
4
5
6
7
8
public class Super {
// Broken - constructor invokes an overridable method
public Super() {
overrideMe();
}
public void overrideMe() {
}
}

以下是一个重写 overrideMe 方法的子类,Super 类的唯一构造方法会错误地调用它:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public final class Sub extends Super {
// Blank final, set by constructor
private final Instant instant;

Sub() {
instant = Instant.now();
}

// Overriding method invoked by superclass constructor
@Override
public void overrideMe() {
System.out.println(instant);
}

public static void main(String[] args) {
Sub sub = new Sub();
sub.overrideMe();
}
}

你可能期望这个程序打印两次 instant 实例,但是它第一次打印出 null,因为在 Sub 构造方法有机会初始化 instant 属性之前,overrideMeSuper 构造方法调用。 请注意,这个程序观察两个不同状态的 final 属性! 还要注意的是,如果 overrideMe 方法调用了 instant 实例中任何方法,那么当父类构造方法调用 overrideMe 时,它将抛出一个 NullPointerException 异常。 这个程序不会抛出 NullPointerException 的唯一原因是 println 方法容忍 null 参数。

请注意,从构造方法中调用私有方法,其中任何一个方法都不可重写的,那么 final 方法和静态方法是安全的。

CloneableSerializable 接口在设计继承时会带来特殊的困难。 对于为继承而设计的类来说,实现这些接口通常不是一个好主意,因为这会给继承类的程序员带来很大的负担。 然而,可以采取特殊的行动来允许子类实现这些接口,而不需要强制这样做。 这些操作在条目 13 和条目 86 中有描述。

如果你决定在为继承而设计的类中实现 CloneableSerializable 接口,那么应该知道,由于 clonereadObject 方法与构造方法相似,所以也有类似的限制: clone​ 和 ​​**readObject​ 都不会直接或间接调用可重写的方法。** 在 readObject 的情况下,重写方法将在子类的状态被反序列化之前运行。 在 clone 的情况下,重写方法将在子类的 clone 方法有机会修复克隆的状态之前运行。 在任何一种情况下,都可能会出现程序故障。 在 clone 的情况下,故障可能会损坏原始对象以及被克隆对象本身。 例如,如果重写方法假定它正在修改对象的深层结构的拷贝,但是尚未创建拷贝,则可能发生这种情况。

最后,如果你决定在为继承设计的类中实现 Serializable 接口,并且该类有一个 readResolvewriteReplace 方法,则必须使 readResolvewriteReplace 方法设置为受保护而不是私有。 如果这些方法是私有的,它们将被子类无声地忽略。 这是另一种情况,把实现细节成为类的 API 的一部分,以允许继承。

到目前为止,设计一个继承类需要很大的努力,并且对这个类有很大的限制。 这不是一个轻率的决定。 有些情况显然是正确的,比如抽象类,包括接口的骨架实现(skeletal implementations)(详见第 20 条)。 还有其他的情况显然是错误的,比如不可变的类(详见第 17 条)。

但是普通的具体类呢? 传统上,它们既不是 final 的,也不是为了子类化而设计和文档说明的,但是这种情况是危险的。每次修改这样的类,则继承此类的子类将被破坏。 这不仅仅是一个理论问题。 在修改非 final 的具体类的内部之后,接收与子类相关的错误报告并不少见,这些类没有为继承而设计和文档说明。

解决这个问题的最佳方法是禁止对在设计上和文档说明中都不支持安全子类化的类进行子类化。 这有两种方法禁止子类化。 两者中较容易的是声明类为 final。 另一种方法是使所有的构造方法都是私有的或包级私有的,并且添加公共静态工厂来代替构造方法。 这个方案在内部提供了使用子类的灵活性,在条目 17 中讨论过。两种方法都是可以接受的。

这个建议可能有些争议,因为许多程序员已经习惯于继承普通的具体类来增加功能,例如通知和同步等功能,或限制原有类的功能。 如果一个类实现了捕获其本质的一些接口,比如 SetListMap,那么不应该为了禁止子类化而感到愧疚。 在条目 18 中描述的包装类模式为增强功能提供了继承的优越选择。

如果一个具体的类没有实现一个标准的接口,那么你禁止继承可能给一些程序员带来不便。 如果你觉得你必须允许从这样的类继承,一个合理的方法是确保类从不调用任何可重写的方法,并文档说明这个事实。 换句话说,完全消除类的自用(self-use)的可重写的方法。 这样做,你将创建一个合理安全的子类。 重写一个方法不会影响任何其他方法的行为。

你可以机械地消除类的自我使用的重写方法,而不会改变其行为。 将每个可重写的方法的主体移动到一个私有的“帮助器方法”,并让每个可重写的方法调用其私有的帮助器方法。 然后用直接调用可重写方法的专用帮助器方法来替换每个自用的可重写方法。

简而言之,专门为了继承而设计类是一件很辛苦的工作。你必须建立文档说明其所有的自用模式,并且一旦建立了文档,在这个类的整个生命周期中都必须遵守。如果没有做到,子类就会依赖父类的实现细节,如果父类的实现发生了变化,它就有可能遭到破坏。为了允许其他人能编写出高效的子类,你还必须暴露一个或者多个受保护的方法。除非意识到真的需要子类,否则最好通过将类声明为 final,或者确保没有可访问的构造器来禁止类被继承。

0%