42.Lambda 优先于匿名类
在之前的做法中(Historically),使用单个抽象方法的接口(或很少的抽象类【只有一个抽象方法的抽象类数量比较少】)被用作函数类型。它们的实例称为函数对象,代表一个函数或一种行为。自 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]。Comparator 接口表示用于排序的抽象策略; 上面的匿名类是排序字符串的具体策略。然而,匿名类的冗长使得 Java 中的函数式编程成为一个没有吸引力的前景。
在 Java 8 中,该语言正式成为这样一种概念,即使用单一抽象方法的接口是特殊的,值得特别对待。这些接口现在称为功能接口,该语言允许你使用 lambda 表达式或简称 lambdas 创建这些接口的实例。Lambdas 在功能上与匿名类相似,但更加简洁。以下是上面的代码片段如何将匿名类替换为 lambda。样板消失了,行为很明显:
// 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 项告诉你支持泛型方法。当你使用 lambdas 时,这个建议是非常重要的,因为编译器获得了从泛型的执行类型推断出的大多数类型信息。如果你不提供此信息,编译器将无法进行类型推断,你必须在 lambdas 中手动指定类型,这将大大增加它们的详细程度【也就是代码量】。举例来说,如果变量词被声明为原始类型 List 而不是参数化类型 List <String>,那么上面的代码片段将无法编译。
顺便提一下,如果使用比较器构造方法代替 lambda,则片段中的比较器可以更简洁(第 14. 43 项):
Collections.sort(words, comparingInt(String::length));
实际上,通过利用 Java 8 中添加到 List 接口的 sort 方法,可以使代码段更短:
words.sort(comparingInt(String::length));
将 lambda 添加到语言中使得使用函数对象变得切实可行。例如,请考虑第 34 项中的 Operation 枚举类型。因为每个枚举对其 apply 方法需要不同的行为,所以我们使用特定于常量的类主体并覆盖每个枚举常量中的 apply 方法。为了让你有清晰的记忆,这里是代码:
// Enum type with constant-specific class bodies & data (Item 34)
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 项说 enum 实例字段比特定于常量的类体更可取。使用前者而不是后者,Lambdas 可以轻松实现特定于常量的行为。只需将实现每个枚举常量行为的 lambda 传递给它的构造函数。构造函数将 lambda 存储在实例字段中,apply 方法将调用转发给 lambda。生成的代码比原始版本更简单,更清晰:
// Enum with function object fields & constant-specific behavior
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);
}
}
请注意,我们使用 DoubleBinaryOperator 接口来表示枚举常量行为的 lambdas。这是 java.util.function(第 44 项)中许多预定义的功能接口之一。它表示一个函数,它接受两个 double 参数并返回一个 double 结果。
查看基于 lambda 的 Operation 枚举,您可能会认为特定于常量的方法体已经过时了,但事实并非如此。跟类和方法不一样,lambdas 缺乏名称和文档; 如果一个运算过程不能自我解释【代码就是最好的文档】,或超过几行,请不要将它放在 lambda 中。一行【代码】对于 lambda 是理想的,三行【代码】是合理的最大值。如果违反此规则,可能会严重损害程序的可读性。如果 lambda 很长或难以阅读,要么找到简化它的方法,要么重构你的程序来取代 lambda。此外,传递给枚举构造函数的参数在静态上下文中进行运算。因此,枚举构造函数中的 lambdas 无法访问枚举的实例成员。如果枚举类型具有难以理解的特定于常量的行为,无法在几行【代码】中实现,或者需要访问实例字段或方法,则仍然可以使用特定于常量的类主体。
同样,你可能会认为匿名类在 lambdas 时代已经过时了。这很接近事实,但是你可以用匿名类做一些你无法用 lambdas 做的事情。Lambdas 仅限于函数接口。如果要创建抽象类的实例,可以使用匿名类,但不能使用 lambda。同样,你可以使用匿名类来创建具有多个抽象方法的接口实例。最后,lambda 无法获得对自身的引用。在 lambda 中,this 关键字引用封闭的实例,这通常是你想要的。在匿名类中,this 关键字引用匿名类实例。如果需要从其体内【类内部】访问函数对象,则必须使用匿名类。【在 lambda 表达式中使用 this 关键字,获得的引用是 lambda 所在的实例的引用,在匿名类中使用 this 关键字,获得的是当前匿名类的实例的引用】
Lambdas 与匿名类都具有无法在实现中可靠地序列化和反序列化它们的属性【lambda 和匿名类都无法被序列化和反序列化】。因此,你应该很少(如果有的话)序列化 lambda(或匿名类实例)。如果您有一个要进行序列化的函数对象,例如 Comparator,请使用私有静态嵌套类的实例(第 24 项)。
总之,从 Java 8 开始,lambda 是迄今为止表示小函数对象的最佳方式。除非必须创建非功能接口类型的实例,否则不要对函数对象使用匿名类。另外,请记住,lambda 使得通过使用对象来代表小函数变得如此容易,以至于它打开了以前在 Java 中不实用的函数式编程技术的大门。
43.方法引用优先于 Lambda
lambda 优于匿名类的主要优点是它们更简洁。Java 提供了一种生成函数对象的方法,它比 lambda 更简洁:方法引用。这是一个程序的代码片段,它维护从任意 key 到 Integer 值的映射。如果该值被解释为 key 实例数的计数,则该程序是多集实现。代码段的功能是将数字 1 与 key 相关联(如果它不在映射中),并在 key 已存在时增加相关值:
map.merge(key, 1, (count, incr) -> count + incr);
请注意,此代码使用 merge 方法,该方法已添加到 Java 8 中的 Map 接口。如果给定键 key 没有映射,则该方法只是插入给定的值; 如果已存在映射,则 merge 将给定的函数应用于当前值和给定值,并使用结果覆盖当前值。这段代码表示 merge 方法的典型用例。
代码读起来很 nice,但仍然有一些样板【代码】。参数 count 和 incr 不会增加太多值,并且占用相当大的空间。实际上,所有 lambda 告诉你的是该函数返回其两个参数的总和。从 Java 8 开始,Integer(以及所有其他包装的数字基本类型)提供了一个完全相同的静态方法 sum。我们可以简单地传递对此方法的引用,获得相同的结果,并且【代码】看起来不会那么乱:
map.merge(key, 1, Integer::sum);
方法具有的参数越多,使用方法引用可以消除的样板【代码】就越多。但是,在某些 lambda 中,你选择的参数名称提供了有用的文档,使得 lambda 比方法引用更易读和可维护,即使 lambda 更长。
对于一个你不能用 lambda 做的方法引用,你无能为力(有一个模糊的例外 - 如果你很好奇,请参阅 JLS,9.9-2)。也就是说,方法引用通常会导致更短,更清晰的代码。如果 lambda 变得太长或太复杂,它们也会给你一个方向(out):你可以将 lambda 中的代码提取到一个新方法中,并用对该方法的引用替换 lambda。你可以为该方法提供一个好名称,并将其记录在核心的内容中。
如果你使用 IDE 进行编程,如果可以的话,它就会提供方法引用替换 lambda。你要经常(并不总是)接受 IDE 提供的建议。有时候,lambda 将比方法引用更简洁。当方法与 lambda 属于同一类时,这种情况最常发生。例如,考虑这个片段,假定它出现在名为 GoshThisClassNameIsHumongous 的类中:
service.execute(GoshThisClassNameIsHumongous::action);
使用 lambda 看起来像这样:
service.execute(() -> action());
使用方法引用的代码段既不比使用 lambda 的代码段更短也更清晰,所以更喜欢后者。类似地,Function 接口提供了一个通用的静态工厂方法来返回 Identity 函数 Function.identity()。它通常更短更清洁,不使用此方法,而是编写等效的 lambda 内联:x -> x。
许多方法引用会引用静态方法,但有四种方法引用不会引用静态方法。其中两个是绑定和未绑定的实例方法引用。在绑定引用中,接收对象在方法引用中指定。绑定引用在本质上类似于静态引用:函数对象采用与引用方法相同的参数。在未绑定的引用中,在应用函数对象时,通过方法声明的参数之前的附加参数指定接收对象。未绑定引用通常用作流管道(stream pipelines)(第 45 项)中的映射和过滤功能。最后,对于类和数组,有两种构造函数引用。构造函数引用充当工厂对象。所有五种方法参考总结在下表中:
Method Ref Type | Example | Lambda Equivalent |
---|---|---|
Static | Integer::parseInt | str -> Integer.parseInt(str) |
Bound | Integer::parseIntr | 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。(Where method references are shorter and clearer, use them; where they aren’t, stick with lambdas.)
44.坚持使用标准的函数接口
既然 Java 有 lambda,那么编写 API 的最佳实践已经发生了很大变化。例如,模板方法模式[Gamma95],其中子类重写基本方法进而具体化其超类的行为,远没那么有吸引力。现在的替代方案是提供一个静态工厂或构造函数,它接受一个函数对象来实现相同的效果。更一般地说,你将编写更多以函数对象作为参数的构造函数和方法。需要谨慎地选择正确的功能参数类型。
考虑 LinkedHashMap。你可以通过重写其受保护的 removeEldestEntry 方法将此类用作缓存,该方法每次将新 key 添加到 map 时都会调用。当此方法返回 true 时,map 将删除其最旧的 entry,该 entry 将传递给该方法。 以下覆盖允许 map 增长到一百个 entry,然后在每次添加新 key 时删除最旧的 entry,保留最近的一百个 entry:
protected boolean removeEldestEntry(Map.Entry<K,V> eldest) {
return size() > 100;
}
这种技术【实现方式】很好,但你可以用 lambda 做得更好。如果现在编写 LinkedHashMap,它将有一个带有函数对象的静态工厂或构造函数。查看 removeEldestEntry 的声明,你可能会认为函数对象应该采用 Map.Entry <K,V>并返回一个布尔值,但是不会这样做:removeEldestEntry 方法调用 size()来获取 map 中 entry 的数目,因为 removeEldestEntry 是 map 的实例方法。传递给构造函数的函数对象不是 map 上的实例方法,并且无法捕获它,因为在调用其工厂或构造函数时 map 尚不存在。因此,map 必须将自身传递给函数对象,因此函数对象必须在输入的地方获得 map,就像获取最老的 entry【方式】一样【函数的形参需要传入 map 本身以及最老的 entry】。如果你要声明这样一个功能性接口,它看起来像这样:
// 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 学习起来更容易,通过减少其概念表面积(by reducing its conceptual surface area),并将提供重要的互操作性优势(and will provide significant interoperability benefits),因为许多标准功能性接口提供有用的默认方法。例如,Predicate 接口提供了结合断言(combine predicates)的方法。对于 LinkedHashMap 示例,应优先使用标准 BiPredicate <Map <K,V>,Map.Entry <K,V >>接口,而不是自定义 EldestEntryRemovalFunction 接口。java.util.Function 中有 43 个接口。不指望你记住它们,但如果你记得 6 个基本接口,你可以在需要时得到其余的接口。基本接口对对象引用类型进行操作。Operator 接口表示结果和参数类型相同的函数。Predicate 接口表示一个接收一个参数并返回布尔值的函数。Function 接口表示其参数和返回类型不同的函数。Supplier 接口表示不带参数并返回(或“提供”)值的函数。最后,Consumer 表示一个函数,它接受一个参数并且什么都不返回,本质上消费它的参数(essentially consuming its argument)。6 个基本功能接口总结如下:
Interface | Function Signature | Example |
---|---|---|
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 |
Function 接口有九个附加变体,供结果类型为基本类型时使用。源(source)类型和结果类型总是不同,因为从类型到自身的函数是 UnaryOperator。如果源类型和结果类型都是基本类型,则使用 SrcToResult 作为前缀 Function,例如 LongToIntFunction(六个变体)。如果源是基本类型并且结果是对象引用,则使用<Src>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 接口和前面段落中描述的四十二个接口占所有四十三个标准功能接口。不可否认,这是一个很大的合并,而不是非常正交(Admittedly, this is a lot to swallow, and not terribly orthogonal)。另一方面,你需要的大部分功能接口都是为你编写的,并且它们的名称足够常规,以便你在需要时不会遇到太多麻烦。
大多数标准功能接口仅提供对基本类型的支持。不要试图用基本类型的包装类来使用基本的功能性接口,而不是用基本类型的功能性接口(Don’t be tempted to use basic functional interfaces with boxed primitives instead of primitive functional interfaces)。 虽然它有效,但是它违了第 61 项的建议,“基本类型优先于装箱基本类型”。使用装箱基本类型进行批量操作可能会导致致命的性能后果。
现在你知道,通常【情况下】应该使用标准功能性接口而不是编写自己的接口。但你应该什么时候写自己的【功能性接口呢】?当然,如果那些标准【接口】没有符合您的需要,您需要自己编写,例如,如果您需要一个带有三个参数的谓词(predicate),或者一个抛出已检查异常的谓词(predicate)。但有时你应该编写自己的功能性接口,即使其中一个标准结构完全相同。
考虑我们的老朋友 Comparator<T>,它在结构上与 ToIntBiFunction <T,T>接口相同。即使后者接口已经存在,当前者被添加到库中时,使用它也是错误的。Comparator 有几个值得拥有自己的接口原因。首先,它的名称每次在 API 中使用时都提供了优秀的文档,并且它被大量使用。其次,Comparator 接口对构成有效实例的内容有很强的要求,有效实例包含其通用约定( general contract)。通过接口的实现,你承诺遵守其约定。第三,接口配备了大量有用的默认方法来转换和组合比较器(comparators)。
如果你需要一个与 Comparator 共享以下一个或多个特性的功能接口,您应该认真考虑编写专用的功能接口而不是使用标准接口:
- 它将被普遍使用,并可从描述性名称中受益。
- 它与之相关的约定很强(It has a strong contract associated with it)。
- 它将受益于自定义的默认方法。
如果您选择编写自己的功能性接口,请记住它是一个界面,因此应该非常谨慎地设计(第 21 项)。
请注意,EldestEntryRemovalFunction 接口(原书第 199 页)标有@FunctionalInterface 注释。此注释类型在灵魂(spirit)上与@Override 类似。它是程序员意图的声明,有三个目的:它告诉读者该类及其文档,该接口旨在启用 lambda;它保持诚实,因为如果它包含多个抽象方法,接口就无法编译;并且它可以防止维护者在接口升级时意外地将抽象方法添加到接口。始终使用@FunctionalInterface 注释来注释您的功能接口。
最后应该关心的点是关于 API 中功能性接口的使用。如果在客户端中有可能产生歧义,则不要提供具有多个重载的方法,这些方法在相同的参数位置采用不同的功能接口。这不仅仅是一个理论问题。ExecutorService 的 submit 方法可以采用 Callable <T>或 Runnable,并且可以编写一个需要强制转换的客户端程序来表示正确的重载(第 52 项)。避免此问题的最简单方法是不要编写在同一参数位置使用不同功能接口的重载。这是第 52 项建议中的一个特例,“慎用重载”。
总而言之,既然 Java 已经有了 lambda,那么在设计 API 时必须考虑到 lambda。接受输入上的功能接口类型并在输出上返回它们。通常最好使用 java.util.function.Function 中提供的标准接口,但请注意那些相对少见的情况,那就最好编写自己的功能接口。
45.谨慎使用 Stream
在 Java 8 中添加了 Stream API,以简化串行或并行批量执行操作的任务。这个 API 提供了两个关键的抽象概念:流(stream)表示有限或无限的数据元素序列,流管道(stream pipeline)表示对这些元素的多级计算。流中的元素可以来自任何地方。常见的来源包括集合,数组,文件,正则表达式模式匹配器,伪随机数生成器和其他流。流中的数据元素可以是对象的引用或基本类型。支持三种基本类型:int,long 和 double。
流管道由源流和零个或多个中间操作(intermediate operations )以及一个终端操作( terminal operation)组成。每个中间操作以某种方式转换流,例如将每个元素映射到该元素的函数或过滤掉不满足某些条件的所有元素。中间操作都将一个流转换为另一个流,其元素类型可以与输入流相同或与之不同。终端操作对从最后的中间操作产生的流执行最终计算,例如将其元素存储到集合中,返回某个元素或打印其所有元素。
流管道是懒求值(evaluated lazily):在调用终端操作之前是不会开始求值的,并且不会去计算那些在完成终端操作的过程中不需要的数据元素。这种懒求值使得可以使用无限流。请注意,没有终端操作的流管道是静默无操作的,因此不要忘记包含一个【终端操作】(Stream pipelines are evaluated lazily: evaluation doesn’t start until the terminal operation is invoked, and data elements that aren’t required in order to complete the terminal operation are never computed. This lazy evaluation is what makes it possible to work with infinite streams. Note that a stream pipeline without a terminal operation is a silent no-op, so don’t forget to include one. )。
流 API 非常流畅:它旨在允许将构成管道的所有调用链接(chain)到单个表达式中。实际上,多个管道可以链接(chain)在一起形成一个表达式。
默认情况下,流管道按顺序运行。使管道并行执行就像在管道中的任何流上调用并行方法一样简单,但很少这样做(第 48 项)。
流 API 具有足够的通用性(The streams API is sufficiently versatile),几乎任何计算都可以使用流来执行,但仅仅因为你可以这么做并不意味着你应该这样做。如果使用得当,流可以使程序更短更清晰; 如果使用不当,可能会使程序难以阅读和维护。
考虑以下程序,该程序从字典文件中读取单词并打印其大小符合用户指定的最小值的所有相同字母异序词组(anagram groups)。回想一下,如果两个单词由不同顺序的相同字母组成,则它们是相同字母异序词。程序从用户指定的字典文件中读取每个单词并将单词放入 map 中。map 的键是用字母按字母顺序排列的单词,因此“staple”的键是“aelpst”,“petals”的键也是“aelpst”:两个单词是相同字母异序词,所有的相同字母异序词共享相同的字母形式(或 alphagram,因为它有时是已知的((or alphagram, as it is sometimes known))。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 中(以粗体显示的:groups.computeIfAbsent(alphabetize(word), (unused) -> new TreeSet<>()).add(word);)使用了在 Java 8 中添加的 computeIfAbsent 方法。此方法在 map 中查找键:如果键存在,则该方法仅返回与其关联的值。如果不是,则该方法通过将给定的函数对象应用于键来计算值,将该值与键相关联,并返回计算的值。computeIfAbsent 方法简化了将多个值与每个键相关联的映射的实现。
现在考虑以下程序,它解决了同样的问题,但大量使用了流。请注意,除了打开字典文件的代码之外,整个程序都包含在一个表达式中。在单独的表达式中打开字典的唯一原因是允许使用 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 块中打开字典文件,获取包含文件中所有行的流。stream 变量被命名为 words,表示流中的每个元素都是一个 word。此流上的管道没有中间操作; 它的终端操作将所有 word 收集到一个 map 中,该 map 按字母顺序排列单词(第 46 项)。这与在以前版本的程序中构建的 map 完全相同。然后在 map 的 values()中打开一个新的 Stream<List<String>>。当然,这个流中的元素是相同字母异序词组。过滤流以便忽略大小小于 minGroupSize 的所有组,最后,通过终端操作 forEach 打印剩余的组。
请注意,小心选择了 lambda 参数名称。参数 g 应该真正命名为 group,但是生成的代码行对于本书来说太宽了。在没有显式类型的情况下,仔细命名 lambda 参数对于流管道的可读性至关重要。
另外请注意,单词字母化是在单独的 alphabetize 方法中完成的。这通过提供操作的名称并将实现细节保留在主程序之外来增强可读性。使用辅助方法对于流管道中的可读性比在迭代代码中更为重要,因为管道缺少显式类型信息和命名临时变量。
可以使用流重新实现 alphabetize 方法,但是基于流的 alphabetize 方法不太清晰,更难以正确编写,并且可能更慢。这些缺陷是由于 Java 缺乏对原始 char 流的支持(这并不意味着 Java 应该支持 char 流;这样做是不可行的)。要演示使用流处理 char 值的危险,请考虑以下代码:
"Hello world!".chars().forEach(System.out::print);
你可能希望它打印 Hello world!,但如果你运行它,你会发现它打印 721011081081113211911111410810033。这是因为“Hello world!”.chars()返回的流的元素不是 char 值而是 int 值,因此调用的是 print 的 int 重载【方法】。令人遗憾的是,名为 chars 的方法返回一个 int 值流。你可以通过使用强制转换来强制调用正确的重载来修复程序:
"Hello world!".chars().forEach(x -> System.out.print((char) x));
但理想情况下,你应该避免使用流来处理 char 值。
当你开始使用流时,你可能会有将所有循环转换为流的冲动的感觉,但要抵制这种冲动。尽管这只是有可能发生,但它会损害代码库的可读性和可维护性。通常,使用流和遍历的某种组合可以最好地完成中等复杂程度的任务,如上面的 Anagrams 程序所示。因此,重构现有代码以使用流,并仅在有意义的情况下在新代码中使用它们。
如该项目中的程序所示,流管道使用函数对象(通常是 lambdas 或方法引用)表示重复计算,而遍历代码使用代码块表示重复计算。以下操作你可以在代码块中执行,但无法在函数对象中执行:
在代码块中,你可以读取或修改范围内的任何局部变量; 在 lambda 中,你只能读取最终或有效的最终变量[JLS 4.12.4],并且你无法修改任何局部变量。
在代码块中,不可以从封闭方法返回,中断或继续封闭循环,或抛出声明此方法被抛出的任何已受检异常; 在一个 lambda 你无法做到这些事情。
如果使用这些技巧可以更好地表达计算【过程】,那么流就可能不是最好的方式(If a computation is best expressed using these techniques, then it’s probably not a good match for streams)。相反,流可以很容易做一些事情:
- 均匀地转换元素序列
- 过滤元素序列
- 使用单个操作组合元素序列(例如,添加它们,串联(concatenate )它们或计算它们的最小值)
- 将元素序列累积(accumulate)到集合中,或者通过一些常见属性对它们进行分组
- 在元素序列中搜索满足某个条件的元素
如果使用这些技巧可以更好地表达计算【过程】,那么流是它的良好候选者。
使用流很难做的一件事是同时从管道的多个阶段访问相应的元素:一旦将值映射到某个其他值,原始值就会丢失。一种解决方法是将每个值映射到包含原始值和新值的对对象(pair object),但这不是一个令人满意的解决方案,尤其是如果管道的多个阶段需要对对象。由此产生的代码是混乱和冗长的,这破坏了流的主要目的。如果适当使用的话,更好的解决方法是在需要访问早期阶段值的时候反转映射。(When it is applicable, a better workaround is to invert the mapping when you need access to the earlier-stage value)。
例如,让我们编写一个程序来打印前 20 个梅森素数(Mersenne primes)。为了更新你的记忆,梅森数是一个 2^p-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 tes)),将得到的流限制为 20 个元素,并打印出来。
现在假设我们想要在每个梅森素数之前加上它的指数(p)。该值仅出现在初始流中,因此在终端操作中无法访问,从而打印结果。幸运的是,通过反转第一个中间操作中发生的映射,可以很容易地计算出梅森数的指数。指数只是二进制表示中的位数,因此该终端操作生成所需的结果:
.forEach(mp -> System.out.println(mp.bitLength() + ": " + mp));
有很多任务,无论是使用流还是迭代都不明显。例如,考虑初始化一副新牌的任务。假设 Card 是一个值不可变的类,它封装了 Rank 和 Suit,两者都是枚举类型。此任务代表任何需要的计算可以从两组中选择所有元素对的任务。数学家称之为两组的笛卡尔积(Cartesian product )。这是一个带有嵌套 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。此操作将流中的每个元素映射到流,然后将所有这些新流连接成单个流(或展平它们(or flattens them))。请注意,此实现包含嵌套的 lambda,以粗体显示;
// 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.优先选择 Stream 中无副作用的函数
如果你是一个【使用】流的新手,可能很难掌握它们。仅仅将你的计算【过程】表示为流管道可能很难。当你成功的时候【成功地将计算过程用流管道表示出来】,你的程序会运行,但你可能几乎没有任何好处。Streams 不仅仅是一个 API,它还是一个基于函数式编程的范例。为了获得流必须提供的表现力,速度和某些情况下的并行性,你必须采用范例和 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 是令人生畏的:它有三十九种方法,其中一些方法有多达五种类型参数。好消息是,你可以从这个 API 中获得大部分好处,而无需深入研究其完整的复杂性。对于初学者,你可以忽略 Collector 接口,并将收集器视为封装缩减策略的不透明对象(an opaque object that encapsulates a reduction strategy)。在这种情况下,缩减意味着将流的元素组合成单个对象。收集器生成的对象通常是一个集合(它代表名称收集器((which accounts for the name collector))。
用于将流的元素收集到真正的集合中的收集器是很简单的。有三个这样的收集器:toList(),toSet()和 toCollection(collectionFactory)。它们分别返回一个集合,一个列表和一个程序猿指定的集合类型。有了这些知识,我们可以编写一个流管道来从频率表中提取前十个列表。
// 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());
请注意,我们没有使用其类 Collectors 限定 toList 方法。习惯性地将收集器的所有成员都静态导入是明智的,因为它使流管道更具可读性。
这段代码中唯一棘手的是我们传递给 sorted【方法】的部分,compare(freq::get).reversed()的比较器。comparing 方法是采用密钥提取功能的比较器构造方法(第 14 项)。该函数接收一个单词,“提取(extraction)”实际上是一个表查找:绑定方法引用 freq::get 在频率表中查找单词并返回单词在文件中出现的次数。最后,我们在比较器上调用 reverse,因此我们将单词【出现的频率】从最频繁到最不频繁进行排序。然后将流限制为十个单词并将它们收集到一个列表中是一件简单的事情。
之前的代码片段使用 Scanner 的流方法通过扫描程序获取流。该方法时在 Java 9 中添加的。如果你使用的是早期版本,则可以使用类似于第 47 项(streamOf(Iterable <E>))的适配器来将实现了 Iterator 的 scanner 转换为流。
那么 Collectors 的其他 36 种方法呢?它们中的大多数存在是为了让你将流收集到 map 中,这比将它们收集到真实集合中要复杂得多。每个流元素与键和值相关联,并且多个流元素可以与相同的键相关联。
最简单的 map 收集器是 toMap(keyMapper,valueMapper),它接受两个函数,其中一个函数将一个流元素映射到一个键,另一个函数映射到一个值。我们在第 34 项的 fromString 实现中使用了这个收集器来创建从枚举的字符串形式到枚举本身的映射:
// 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 方法)为你提供了各种方法来提供处理此类冲突的策略。一种方法是除了键和值映射器之外,还为 toMap 方法提供合并函数。合并函数是 BinaryOperator<V>,其中 V 是映射的值类型。使用合并函数将与键关联的任何其他值与现有值组合,因此,例如,如果合并函数是乘法,则通过值映射最终得到的值是与键关联的所有值的乘积。
toMap 的三参数形式对于创建从键到与该键关联的所选元素的映射也很有用。例如,假设我们有各种艺术家的唱片专辑流,我们想要一个从录音艺术家到最畅销专辑的 map 映射。这个 collector 就能完成这项工作。
// 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 extractor function)Album::sales。这可能看起来有点复杂,但代码可读性很好。简而言之,它说,“将专辑流转换为 map,将每位艺术家映射到销售量最佳专辑的专辑。”这接近问题的陈述【程度】令人感到惊讶【意思就是说这代码的意思很接近问题的描述(OS:臭不要脸)】。
toMap 的三参数形式的另一个用途是产生一个收集器,当发生冲突时强制执行 last-write-wins 策略【保留最后一个冲突值】。对于许多流,结果将是不确定的,但如果映射函数可能与键关联的所有值都相同,或者它们都是可接受的,则此收集器的行为可能正是你想要的:
// Collector to impose last-write-wins policy
toMap(keyMapper, valueMapper, (v1, v2) -> v2)
toMap 的第三个也是最后一个版本采用第四个参数,即一个 map 工厂,用于指定特定的 map 实现,例如 EnumMap 或 TreeMap。
toMap 的前三个版本也有变体形式,名为 toConcurrentMap,它们并行高效运行并生成 ConcurrentHashMap 实例。
除了 toMap 方法之外,Collectors API 还提供了 groupingBy 方法,该方法返回【一个】收集器用来生成基于分类器函数(classifier function)将元素分组到类别中的映射。分类器函数接收一个元素并返回它的所属类别。此类别用作元素的 map 的键。groupingBy 方法的最简单版本是仅采用分类器并返回一个映射,其值是每个类别中所有元素的列表。这是我们在第 45 项中的 Anagram 程序中使用的收集器,用于生成从按字母顺序排列的单词到共享字母顺序的单词列表的映射:
words.collect(groupingBy(word -> alphabetize(word)))
如果希望 groupingBy 返回一个生成带有除列表之外的值的映射的收集器,则除了分类器之外,还可以指定下游收集器(downstream collector)。下游收集器从一个包含类别中所有元素的流中生成一个值。此参数的最简单用法是传递 toSet(),这将生成一个映射,其值是元素集而不是列表。这会生成一个映射,该映射将每个类别与类别中的元素数相关联,而不是包含元素的集合。这就是你在本项目开头的频率表示例中看到的内容:
Map<String, Long> freq = words.collect(groupingBy(String::toLowerCase, counting()));
groupingBy 的第三个版本允许你指定除下游收集器之外的 map 工厂。请注意,此方法违反了标准的 telescoping 参数列表模式:mapFactory 参数位于 downStream 参数之前,而不是之后。此版本的 groupingBy 使你可以控制包含的映射以及包含的集合(This version of groupingBy gives you control over the containing map as well as the contained collections),因此,例如,你可以指定一个收集器,该收集器返回一个 value 为 TreeSet 的 TreeMap。
groupingByConcurrent 方法提供了 groupingBy 的所有三个重载的变体。 这些变体并行高效运行并生成 ConcurrentHashMap 实例。还有一个很少使用的 grouping 的相近【的方法】叫做 partitioningBy。代替分类器方法,它接收一个谓词(predicate)并返回键为布尔值的 map。此方法有两个重载【版本】,其中一个除谓词之外还包含下游收集器。通过 counting 方法返回的收集器仅用作下游收集器。通过 count 方法直接在 Stream 上提供相同的功能,因此没有理由说 collect(counting())( there is never a reason to say collect(counting())) 。此属性还有十五种收集器方法。它们包括九个方法,其名称以 summing,averaging 和 summarizing 开头(其功能在相应的基本类型流上可用)。它们还包括 reducing 方法的所有重载,以及 filter,mapping,flatMapping 和 collectingAndThen 方法。大多数程序猿可以安心地忽略大多数这种方法。从设计角度来看,这些收集器代表了尝试在收集器中部分复制流的功能,以便下游收集器可以充当“迷你流(ministreams)”。
我们还有三种 Collectors 方法尚未提及。虽然他们在 Collectors 里面,但他们不涉及集合。前两个是 minBy 和 maxBy,它们取比较器并返回由比较器确定的流中的最小或最大元素。它们是 Stream 接口中 min 和 max 方法的小扩展【简单的实现】,是 BinaryOperator 中类似命名方法返回的二元运算符的收集器类似物。回想一下,我们在最畅销专辑的例子中使用了 BinaryOperator.maxBy。
最后的 Collectors 方法是 join,它只对 CharSequence 实例的流进行操作,例如字符串。 在其无参数形式中,它返回一个简单地连接元素的收集器。它的一个参数形式采用名为 delimiter 的单个 CharSequence 参数,并返回一个连接流元素的收集器,在相邻元素之间插入分隔符。如果传入逗号作为分隔符,则收集器将返回逗号分隔值字符串(但请注意,如果流中的任何元素包含逗号,则字符串将不明确)。除了分隔符之外,三个参数形式还带有前缀和后缀。生成的收集器会生成类似于打印集合时获得的字符串,例如[came, saw, conquered]。
总之,流管道编程的本质是无副作用的功能对象。这适用于传递给流和相关对象的几乎所有的函数对象(This applies to all of the many function objects passed to streams and related objects)。终端操作 forEach 仅应用于报告流执行的计算结果,而不是用于执行计算。为了正确使用流,你必须了解收集器。最重要的收集器工厂是 toList,toSet,toMap,groupingBy 和 join。
47.Stream 要优先用 Collection 作为返回类型
许多方法返回元素序列。在 Java 8 之前,这些方法的返回类型是集合的接口 Collection,Set 和 List;Iterable;和数组类型。通常,很容易决定返回哪些类型。准确来说是一个集合接口。如果该方法仅用于 for-each 循环或返回的序列无法实现某些 Collection 方法(通常为 contains(Object)),则使用 Iterable 接口。如果返回的元素是基本类型值或者存在严格的性能要求,则使用数组。在 Java 8 中,流被添加到 java 库中,这使得为返回序列的方法选择恰当的返回类型的任务变得非常复杂。
你可能听说过,流现在是返回一系列元素的公认选择,正如第 45 项所描述的,流不会使迭代过时:编写好的代码需要适当地组合流和遍历。如果 API 只返回一个流,而某些用户想要使用 for-each 循环遍历返回的序列,那么这些用户理所当然会感到不安。特别令人沮丧的是,Stream 接口包含 Iterable 接口中唯一的抽象方法,Stream 的此方法规范与 Iterable 兼容。
可悲的是,这个问题没有好的解决方法。乍一看,似乎可以将方法引用传递给 Stream 的迭代器方法。结果代码可能有点嘈杂和模糊,但并非不合理:
// 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,它可以在读取文件时悄悄地处理(silently swallows)任何异常。理想情况下,我们也会在迭代版本中使用 Files.lines。如果 API 仅提供对序列的流的访问并且他们希望使用 for-each 语句遍历序列,那么程序员将会做出这种折中的方法【在迭代的版本中使用 Files.lines】。
相反,想要使用流管道处理序列的程序猿理所当然会因为 API 仅提供 Iterable 而感到难过【傲娇】。再提一次 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 语句的用户提供服务。除非你有充分的理由相信【使用该 API 的】大多数用户希望使用相同的机制。
Collection 接口是 Iterable 的子类型,并且具有 stream 方法,因此它提供迭代和流访问。因此,Collection 或适当的子类型通常是公共序列返回方法的最佳返回类型。 Arrays 还提供了 Arrays.asList 和 Stream.of 方法的简单遍历和流访问。如果你返回的序列小到足以容易地放入内存中,那么最好返回一个标准的集合实现,例如 ArrayList 或 HashSet。但是不要在内存中存储大的序列而只是为了将它作为集合返回。
如果你返回的序列很大但可以简洁地表示,请考虑实现一个特殊用途的集合。例如,假设你要返回给定集的幂集(power set),该集包含其所有子集。{a,b,c}的幂集为{{},{a},{b},{c},{a,b},{a,c},{b,c},{a,b,c}}。如果一个集合具有 n 个元素,则其幂集具有 2^n 个。因此,你甚至不应该考虑将幂集存储在标准集合的实现中。但是,在 AbstractList 的帮助下,很容易为此实现自定义集合。
技巧是使用幂集中每个元素的索引作为位向量,其中索引中的第 n 位表示源集合中是否存在第 n 个元素。本质上,从 0 到 2^n - 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 返回大小的方法,该方法将返回序列的长度,限制为 Integer.MAX_VALUE 或 2^31-1。如果集合更大,甚至无限,Collection 规范允许 size 方法返回 2^31-1,但这不是一个完全令人满意的解决方案。
为了在 AbstractCollection 上编写 Collection 实现,你只需要实现 Iterable 所需的两个方法:contains 和 size。通常,编写这些方法的有效实现是很容易的。如果不可行,可能是因为在迭代发生之前无法预先确定序列的内容,返回流或可迭代的【结果】,哪种感觉起来更自然就返回哪种。如果你要选择的话,你可以使用两种不同的方法将两种类型都返回。
有时你会根据实施方式的难易程度选择返回类型。例如,假设你要编写一个返回输入列表的所有(连续)子列表的方法。生成这些子列表只需要三行代码并将它们放在标准集合中,但保存此集合所需的内存是源列表大小的二次方。虽然这并不像指数级的幂集那么糟糕,但显然是不可接受的。正如我们为幂集所做的那样,实现自定义集合将是冗长的,因为 JDK 缺乏 Iterator 框架实现来帮助我们。
但是【我们可以】直接实现输入列表的所有子列表的流,尽管它确实需要一些洞察力。让我们调用一个子列表,该子列表包含列表的第一个元素和列表的前缀(prefix)。例如,(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 循环的流等价物( This idiom is, roughly speaking, the stream equivalent of the standard for-loop on integer indices)。因此,我们的子列表实现的思想类似明显的嵌套 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 循环一样,此代码不会产生(emit)空列表。为了解决这个问题,你可以使用 concat,就像我们在之前版本中所做的那样,或者在 rangeClosed 调用中用(int)Math.signum(start)替换 1。
这些子列表的流实现中的任何一个都很好,但两者都需要用户使用一些 Stream-to-Iterable 适配器或在迭代更自然的地方使用流。Stream-to-Iterable 适配器不仅使客户端代码混乱,而且还会使我的机器上的循环速度降低 2.3 倍。专用的 Collection 实现(此处未显示)相当冗长,但运行速度是我机器上基于流的实现的 1.4 倍。
总之,在编写返回元素序列的方法时,请记住,你的某些用户可能希望将它们作为流进行处理,而其他用户可能希望使用它们进行遍历。尽量适应这两个群体。如果返回集合是可行的,那么就返回集合。如果你已经拥有集合中的元素,或者序列中的元素数量很小足以证明创建新元素是正确的,那么就返回标准集合,例如 ArrayList。否则,请考虑实现自定义的集合,就像我们为幂集所做的那样。如果返回集合是不可行的,则返回一个流或可迭代的【类型】,无论哪个看起来更自然。如果在将来的 Java 版本中,Stream 接口声明被修改为扩展(extend)Iterable,那么你应该随意返回流,因为它们将允许进行流处理和遍历。
48.谨慎使用 Stream 并行
在主流语言中,在提供便于并发编程任务功能方面,Java 始终处于最前沿【的位置】(Among mainstream languages, Java has always been at the forefront of providing facilities to ease the task of concurrent programming)。当 Java 于 1996 年发布时,它内置了对线程的支持,具有同步和等待/通知【的功能】(When Java was released in 1996, it had built-in support for threads, with synchronization and wait/notify)。Java 5 引入了 java.util.concurrent 库,包含并发集合和执行器框架。 Java 7 引入了 fork-join 包,这是一个用于并行分解(parallel decomposition)的高性能框架。Java 8 引入了流,可以通过对并行方法的单个调用来并行化。用 Java 编写并发程序变得越来越容易,但编写正确快速的并发程序就跟以前一样困难。安全性和活性违规(liveness violations )是并发编程中的事实,并行流管道也不例外。
考虑第 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)失败。即使在最好的情况下,如果源来自 Stream.iterate,或者使用中间操作限制,并行化管道也不太可能提高其性能(parallelizing a pipeline is unlikely to increase its performance if the source is from Stream.iterate, or the intermediate operation limit is used.)。这条管道必须应对这两个问题。更糟糕的是,默认的并行化策略是通过假设处理一些额外元素并丢弃任何不需要的结果不会带来任何损失的前提下来处理限制的不可预测性。在这种情况下,找到每个梅森质数需要大约两倍的时间才能找到前一个。因此,计算单个额外元素的成本大致等于计算所有先前元素组合的成本,并且这种看起来没什么损失的管道会使自动并行化算法瘫痪。这个故事的寓意很简单:不要不加选择的地使用并行化流。导致的性能后果可能是灾难性的。
并行性的性能增益最好是在 ArrayList,HashMap,HashSet 和 ConcurrentHashMap 实例上;int 数组;和 long 数组(performance gains from parallelism are best on streams over ArrayList, HashMap, HashSet, and ConcurrentHashMap instances; arrays; int ranges; and long ranges),将这作为一项规则。这些数据结构的共同之处在于它们都可以准确且分成任何所需大小的子范围的代价是很小的,这使得在并行线程之间划分工作变得容易。流库用于执行此任务的抽象是 spliterator,它由 Stream 和 Iterable 上的 spliterator 方法返回。
所有这些数据结构的另一个重要因素是它们在顺序处理时提供了非常好的位置引用(locality of reference):元素的顺序和【元素的】引用一起存储在存储器中。这些引用所引用的对象在存储器中可能彼此不接近,这减少了位置引用(The objects referred to by those references may not be close to one another in memory, which reduces locality-of-reference.)。对于并行化操作而言,位置引用非常重要:如果没有位置引用,线程大部分时间会处在空闲状态,等待数据从内存传输到处理器的缓存。具有最佳位置引用的数据结构是原始数组,因为数据本身连续存储在存储器中。
流管道终端操作的本质也会影响并行执行的有效性。如果与管道的整体工作相比在终端操作中完成了大量工作并且该操作本质上是按顺序的,那么并行化管道的有效性是受限的。并行性最佳的终端操作是减少(reductions),其中从管道中出现的所有元素使用 Stream 的 reduce 方法或减少预打包(prepackaged reductions)(例如 min,max,count 和 sum)进行组合。短路操作(shortcircuiting)anyMatch,allMatch 和 noneMatch 也适用于并行操作。Stream 的 collect 方法执行的操作(称为可变约简( mutable reductions))不是并行性的良好选择,因为组合集合的开销是很昂贵的。
如果你编写自己的 Stream,Iterable 或 Collection 实现并且希望获得良好的并行性能,则必须覆盖 spliterator 方法并广泛测试生成的流的并行性能。编写高质量的 spliterators 是很困难的,超出了本书的范围。
并行化流不仅会导致性能不佳,包括活性失败; 它可能导致不正确的结果和不可预测的行为(安全性失败)。使用映射器,过滤器和其他程序员提供的不符合其规范的功能对象的管道并行化可能会导致安全性失败。Stream 规范对这些功能对象提出了严格的要求。例如,传递给 Stream 的 reduce 操作的累加器和组合器函数必须是关联的,非侵入的和无状态的。如果你违反了这些要求(其中一些在第 46 项中讨论过),但按顺序运行你的管道,则可能会产生正确的结果; 如果你将它并行化,它可能会失败,也许是灾难性的。
沿着这些思路,值得注意的是,即使并行化的梅森素数程序已经完成,它也不会以正确的(升序)顺序打印素数。要保留顺序版本显示的顺序,你必须使用 forEachOrdered 替换 forEach 终端操作,该操作保证以相遇顺序(encounter order)遍历并行流。
即使假设你正在使用有效可拆分的源流(带有一个并行化或代价低的终端操作)和非侵入(non-interfering)的函数对象,你无法从并行化中获得很好的加速效果,除非管道做了足够的实际工作来抵消使用并行化相关的成本(unless the pipeline is doing enough real work to offset the costs associated with parallelism)。作个非常粗略的估计,流中元素的数量乘以每个元素执行的代码行数应该至少为十万[Lea14]。
重要的是要记住并行化流是严格的性能优化。与任何优化一样,你必须在更改之前和之后测试性能,以确保它【的优化是】值得做【的】(第 67 项)。理想情况下,你应该在实际的系统设置中执行测试。通常,程序中的所有并行流管道都在公共 fork-join 线程池中运行。单个行为不当的管道可能会影响系统中其他不相关部分的行为。
听起来使用流并行会一直在违背你的意愿,它们确实是这样的(If it sounds like the odds are stacked against you when parallelizing stream pipelines, it’s because they are.)。那些维护数百万行代码的人大量使用流,只发现了在很少数的地方使用并行流是有效地。这并不意味着你应该避免并行化流。在适当的情况下,只需通过向流管道添加并行调用,就可以实现处理器内核数量的近线性(near-linear)加速。某些领域,例如机器学习和数据处理,特别适合这些加速。
作为并行性有效的流管道的一个简单示例,请考虑此函数来计算 π(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();
}
在我的机器上,使用此功能计算 π(10^8)需要 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 的公式。
如果要并行化随机数流,请从 SplittableRandom 实例开始,而不是 ThreadLocalRandom(或基本上过时的 Random)。SplittableRandom 是专门为此而设计的,具有线性加速的潜力。ThreadLocalRandom 设计用于单个线程,并将自适应为并行流的源,但不会像 SplittableRandom 一样快。随机同步每个操作,因此会导致过度(近似杀戮)的争抢(so it will result in excessive, parallelism-killing contention)【意思应该是导致的资源争抢会很激烈】。
总之,除非你有充分的理由相信它将保持计算的正确性并提高其速度,否则就不应该尝试并行化流管道。不恰当地并行化流的成本可能是程序失败或性能灾难。如果你认为并行性可能是合理的,请确保在并行运行时代码保持【运行结果的】正确,并在实际条件下进行详细的性能测试。如果你的代码仍然正确并且这些实验证明你对性能提升的猜疑,那么才能在生产环境的代码中使用并行化流(If your code remains correct and these experiments bear out your suspicion of increased performance, then and only then parallelize the stream in production code.)。
参考链接
https://gitee.com/lin-mt/effective-java-third-edition
Kotlin开发者社区
专注分享 Java、 Kotlin、Spring/Spring Boot、MySQL、redis、neo4j、NoSQL、Android、JavaScript、React、Node、函数式编程、编程思想、"高可用,高性能,高实时"大型分布式系统架构设计主题。
High availability, high performance, high real-time large-scale distributed system architecture design。
分布式框架:Zookeeper、分布式中间件框架等
分布式存储:GridFS、FastDFS、TFS、MemCache、redis等
分布式数据库:Cobar、tddl、Amoeba、Mycat
云计算、大数据、AI算法
虚拟化、云原生技术
分布式计算框架:MapReduce、Hadoop、Storm、Flink等
分布式通信机制:Dubbo、RPC调用、共享远程数据、消息队列等
消息队列MQ:Kafka、MetaQ,RocketMQ
怎样打造高可用系统:基于硬件、软件中间件、系统架构等一些典型方案的实现:HAProxy、基于Corosync+Pacemaker的高可用集群套件中间件系统
Mycat架构分布式演进
大数据Join背后的难题:数据、网络、内存和计算能力的矛盾和调和
Java分布式系统中的高性能难题:AIO,NIO,Netty还是自己开发框架?
高性能事件派发机制:线程池模型、Disruptor模型等等。。。
合抱之木,生于毫末;九层之台,起于垒土;千里之行,始于足下。不积跬步,无以至千里;不积小流,无以成江河。