欢迎大家关注 github.com/hsfxuebao/j… ,希望对大家有所帮助,要是觉得可以的话麻烦给点一下Star哈
42.lambda表达式优于匿名类
在Java 8中,添加了函数式接口,lambda表达式和方法引用,以便更容易地创建函数对象。 Stream API随着其他语言的修改一同被添加进来,为处理数据元素序列提供类库支持。 在本章中,我们将讨论如何充分利用这些功能。
以往,使用单一抽象方法的接口(或者很少使用抽象类)被用作函数类型。 它们的实例(称为函数对象)表示函数(functions)或行动(actions)。 自从JDK 1.1于1997年发布以来,创建函数对象的主要手段就是匿名类(条目 24)。 下面是一段代码片段,按照字符串长度顺序对列表进行排序,使用匿名类创建排序的比较方法(强制排序顺序):
// Anonymous class instance as a function object - obsolete!
Collections.sort(words, new Comparator<String>() {
public int compare(String s1, String s2) {
return Integer.compare(s1.length(), s2.length());
}
});
匿名类适用于需要函数对象的经典面向对象设计模式,特别是策略模式[Gamma95]。 比较器接口表示排序的抽象策略; 上面的匿名类是排序字符串的具体策略。 然而,匿名类的冗长,使得Java中的函数式编程成为一种吸引人的前景。
在Java 8中,语言形式化了这样的概念,即使用单个抽象方法的接口是特别的,应该得到特别的对待。 这些接口现在称为函数式接口,并且该语言允许你使用lambda表达式或简称lambda来创建这些接口的实例。 Lambdas在功能上与匿名类相似,但更为简洁。 下面的代码使用lambdas替换上面的匿名类。 样板不见了,行为清晰明了:
// Lambda expression as function object (replaces anonymous class)
Collections.sort(words,
(s1, s2) -> Integer.compare(s1.length(), s2.length()));
请注意,代码中不存在lambda(Comparator <String>),其参数(s1和s2,都是String类型)及其返回值(int)的类型。 编译器使用称为类型推断的过程从上下文中推导出这些类型。 在某些情况下,编译器将无法确定类型,必须指定它们。 类型推断的规则很复杂:他们在JLS中占据了整个章节[JLS,18]。 很少有程序员详细了解这些规则,但没关系。 除非它们的存在使你的程序更清晰,否则省略所有lambda参数的类型。 如果编译器生成一个错误,告诉你它不能推断出lambda参数的类型,那么指定它。 有时你可能不得不强制转换返回值或整个lambda表达式,但这很少见。
关于类型推断需要注意一点。 条目26告诉你不要使用原始类型,条目29告诉你偏好泛型类型,条目30告诉你偏向泛型方法。 当使用lambda表达式时,这个建议是非常重要的,因为编译器获得了大部分允许它从泛型进行类型推断的类型信息。 如果你没有提供这些信息,编译器将无法进行类型推断,你必须在lambdas中手动指定类型,这将大大增加它们的冗余度。 举例来说,如果变量被声明为原始类型List而不是参数化类型List <String>,则上面的代码片段将不会编译。
顺便提一句,如果使用比较器构造方法代替lambda,则代码中的比较器可以变得更加简洁(条目14,43):
Collections.sort(words, comparingInt(String::length));
实际上,通过利用添加到Java 8中的List接口的sort方法,可以使片段变得更简短:
words.sort(comparingInt(String::length));
将lambdas添加到该语言中,使得使用函数对象在以前没有意义的地方非常实用。例如,考虑条目34中的Operation枚举类型。由于每个枚举都需要不同的应用程序行为,所以我们使用了特定于常量的类主体,并在每个枚举常量中重写了apply方法。为了刷新你的记忆,下面是之前的代码:
// Enum type with constant-specific class bodies & data
public enum Operation {
PLUS("+") {
public double apply(double x, double y) { return x + y; }
},
MINUS("-") {
public double apply(double x, double y) { return x - y; }
},
TIMES("*") {
public double apply(double x, double y) { return x * y; }
},
DIVIDE("/") {
public double apply(double x, double y) { return x / y; }
};
private final String symbol;
Operation(String symbol) { this.symbol = symbol; }
@Override public String toString() { return symbol; }
public abstract double apply(double x, double y);
}
第34条目说,枚举实例属性比常量特定的类主体更可取。 Lambdas可以很容易地使用前者而不是后者来实现常量特定的行为。 仅仅将实现每个枚举常量行为的lambda传递给它的构造方法。 构造方法将lambda存储在实例属性中,apply方法将调用转发给lambda。 由此产生的代码比原始版本更简单,更清晰:
public enum Operation {
PLUS ("+", (x, y) -> x + y),
MINUS ("-", (x, y) -> x - y),
TIMES ("*", (x, y) -> x * y),
DIVIDE("/", (x, y) -> x / y);
private final String symbol;
private final DoubleBinaryOperator op;
Operation(String symbol, DoubleBinaryOperator op) {
this.symbol = symbol;
this.op = op;
}
@Override public String toString() { return symbol; }
public double apply(double x, double y) {
return op.applyAsDouble(x, y);
}
}
请注意,我们使用表示枚举常量行为的lambdas的DoubleBinaryOperator接口。 这是java.util.function中许多预定义的函数接口之一(条目 44)。 它表示一个函数,它接受两个double类型参数并返回double类型的结果。
看看基于lambda的Operation枚举,你可能会认为常量特定的方法体已经失去了它们的用处,但事实并非如此。 与方法和类不同,lambda没有名称和文档; 如果计算不是自解释的,或者超过几行,则不要将其放入lambda表达式中。 一行代码对于lambda说是理想的,三行代码是合理的最大值。 如果违反这一规定,可能会严重损害程序的可读性。 如果一个lambda很长或很难阅读,要么找到一种方法来简化它或重构你的程序来消除它。 此外,传递给枚举构造方法的参数在静态上下文中进行评估。 因此,枚举构造方法中的lambda表达式不能访问枚举的实例成员。 如果枚举类型具有难以理解的常量特定行为,无法在几行内实现,或者需要访问实例属性或方法,那么常量特定的类主体仍然是行之有效的方法。
同样,你可能会认为匿名类在lambda时代已经过时了。 这更接近事实,但有些事情你可以用匿名类来做,而却不能用lambdas做。 Lambda仅限于函数式接口。 如果你想创建一个抽象类的实例,你可以使用匿名类来实现,但不能使用lambda。 同样,你可以使用匿名类来创建具有多个抽象方法的接口实例。 最后,lambda不能获得对自身的引用。 在lambda中,this关键字引用封闭实例,这通常是你想要的。 在匿名类中,this关键字引用匿名类实例。 如果你需要从其内部访问函数对象,则必须使用匿名类。
Lambdas与匿名类共享无法可靠地序列化和反序列化实现的属性。因此,应该很少(如果有的话)序列化一个lambda(或一个匿名类实例)。如果有一个想要进行序列化的函数对象,比如一个Comparator,那么使用一个私有静态嵌套类的实例(条目 24)。
综上所述,从Java 8开始,lambda是迄今为止表示小函数对象的最佳方式。 除非必须创建非函数式接口类型的实例,否则不要使用匿名类作为函数对象。 另外,请记住,lambda表达式使代表小函数对象变得如此简单,以至于它为功能性编程技术打开了一扇门,这些技术在Java中以前并不实用。
43.方法引用优于lambda表达式
lambda优于匿名类的主要优点是它更简洁。Java提供了一种生成函数对象的方法,比lambda还要简洁,那就是:方法引用( method references)。下面是一段程序代码片段,它维护一个从任意键到整数值的映射。如果将该值解释为键的实例个数,则该程序是一个多重集合的实现。该代码的功能是,根据键找到整数值,然后在此基础上加1:
map.merge(key, 1, (count, incr) -> count + incr);
请注意,此代码使用merge方法,该方法已添加到Java 8中的Map接口中。如果没有给定键的映射,则该方法只是插入给定值; 如果映射已经存在,则合并给定函数应用于当前值和给定值,并用结果覆盖当前值。 此代码表示merge方法的典型用例。
代码很好读,但仍然有一些样板的味道。 参数count和incr不会增加太多价值,并且占用相当大的空间。 真的,所有的lambda都告诉你函数返回两个参数的和。 从Java 8开始,Integer类(和所有其他包装数字基本类型)提供了一个静态方法总和,和它完全相同。 我们可以简单地传递一个对这个方法的引用,并以较少的视觉混乱得到相同的结果:
map.merge(key, 1, Integer::sum);
方法的参数越多,你可以通过方法引用消除更多的样板。 然而,在一些lambda中,选择的参数名称提供了有用的文档,使得lambda比方法引用更具可读性和可维护性,即使lambda看起来更长。
对于一个方法引用,你无能为力,因为你不能对lambda执行任何操作(只有一个难懂的异常 - 如果你好奇的话,参见JLS,9.9-2)。 也就是说,方法引用通常会导致更短,更清晰的代码。 如果lambda变得太长或太复杂,它们也会给你一个结果:你可以从lambda中提取代码到一个新的方法中,并用对该方法的引用代替lambda。 你可以给这个方法一个好名字,并把它文档记录下来。
如果你使用IDE编程,它将提供替换lambda的方法,并在任何地方使用方法引用。通常情况下,你应该接受这个提议。偶尔,lambda会比方法引用更简洁。这种情况经常发生在方法与lambda相同的类中。例如,考虑这段代码,它被假定出现在一个名为GoshThisClassNameIsHumongous的类中:
service.execute(GoshThisClassNameIsHumongous::action);
这个lambda类似于等价于下面的代码:
service.execute(() -> action());
使用方法引用的代码段既不比使用lambda的代码片段更短也不清晰,所以更喜欢后者。 在类似的代码行中,Function接口提供了一个通用的静态工厂方法来返回标识函数Function.identity()。 它通常更短,更清洁,而不使用这种方法,而是使用等效的lambda内联代码:x - > x。
许多方法引用是指静态方法,但有四种方法没有。 其中两个是特定(bound)和任意(unbound)对象方法引用。 在特定对象引用中,接收对象在方法引用中指定。 特定对象引用在本质上与静态引用类似:函数对象与引用的方法具有相同的参数。 在任意对象引用中,接收对象在应用函数对象时通过方法的声明参数之前的附加参数指定。 任意对象引用通常用作流管道(pipelines)中的映射和过滤方法(条目 45)。 最后,对于类和数组,有两种构造方法引用。 构造方法引用用作工厂对象。 下表总结了所有五种方法引用:
方法引用类型
举例
等同的Lambda
Static
Integer::parseInt
str -> Integer.parseInt(str)
Bound
Instant.now()::isAfter
Instant then = Instant.now(); t -> then.isAfter(t)
Unbound
String::toLowerCase
str -> str.toLowerCase()
Class Constructor
TreeMap<K,V>::new
() -> new TreeMap<K,V>
Array Constructor
int[]::new
len -> new int[len]
总之,方法引用通常为lambda提供一个更简洁的选择。 如果方法引用看起来更简短更清晰,请使用它们;否则,还是坚持lambda。
44. 优先使用标准的函数式接口
现在Java已经有lambda表达式,编写API的最佳实践已经发生了很大的变化。 例如,模板方法模式[Gamma95],其中一个子类重写原始方法以专门化其父类的行为,变得没有那么吸引人。 现代替代的选择是提供一个静态工厂或构造方法来接受函数对象以达到相同的效果。 通常地说,可以编写更多以函数对象为参数的构造方法和方法。 选择正确的函数式参数类型需要注意。
考虑LinkedHashMap。 可以通过重写其受保护的removeEldestEntry方法将此类用作缓存,每次将新的key值加入到map时都会调用该方法。 当此方法返回true时,map将删除传递给该方法的最久条目。 以下代码重写允许map增长到一百个条目,然后在每次添加新key值时删除最老的条目,并保留最近的一百个条目:
protected boolean removeEldestEntry(Map.Entry<K,V> eldest) {
return size() > 100;
}
这种技术很有效,但是你可以用lambdas做得更好。如果LinkedHashMap是现在编写的,那么它将有一个静态的工厂或构造方法来获取函数对象。查看removeEldestEntry方法的声明,你可能会认为函数对象应该接受一个Map.Entry <K,V>并返回一个布尔值,但是这并不完全是这样:removeEldestEntry方法调用size()方法来获取条目的数量,因为removeEldestEntry是map上的一个实例方法。传递给构造方法的函数对象不是map上的实例方法,无法捕获,因为在调用其工厂或构造方法时map还不存在。因此,map必须将自己传递给函数对象,函数对象把map以及最就的条目作为输入参数。如果要声明这样一个功能接口,应该是这样的:
// Unnecessary functional interface; use a standard one instead.
@FunctionalInterface interface EldestEntryRemovalFunction<K,V>{
boolean remove(Map<K,V> map, Map.Entry<K,V> eldest);
}
这个接口可以正常工作,但是你不应该使用它,因为你不需要为此目的声明一个新的接口。 java.util.function包提供了大量标准函数式接口供你使用。 如果其中一个标准函数式接口完成这项工作,则通常应该优先使用它,而不是专门构建的函数式接口。 这将使你的API更容易学习,通过减少其不必要概念,并将提供重要的互操作性好处,因为许多标准函数式接口提供了有用的默认方法。 例如,Predicate接口提供了组合判断的方法。 在我们的LinkedHashMap示例中,标准的BiPredicate<Map<K,V>, Map.Entry<K,V>>接口应优先于自定义的EldestEntryRemovalFunction接口的使用。
在java.util.Function中有43个接口。不能指望全部记住它们,但是如果记住了六个基本接口,就可以在需要它们时派生出其余的接口。基本接口操作于对象引用类型。Operator接口表示方法的结果和参数类型相同。Predicate接口表示其方法接受一个参数并返回一个布尔值。Function接口表示方法其参数和返回类型不同。Supplier接口表示一个不接受参数和返回值(或“供应”)的方法。最后,Consumer表示该方法接受一个参数而不返回任何东西,本质上就是使用它的参数。六种基本函数式接口概述如下:
接口
方法
示例
UnaryOperator
T apply(T t)
String::toLowerCase
BinaryOperator
T apply(T t1, T t2)
BigInteger::add
Predicate
boolean test(T t)
Collection::isEmpty
Function<T,R>
R apply(T t)
Arrays::asList
Supplier
T get()
Instant::now
Consumer
void accept(T t)
System.out::println
在处理基本类型int,long和double的操作上,六个基本接口中还有三个变体。 它们的名字是通过在基本接口前加一个基本类型而得到的。 因此,例如,一个接受int的Predicate是一个IntPredicate,而一个接受两个long值并返回一个long的二元运算符是一个LongBinaryOperator。 除Function接口变体通过返回类型进行了参数化,其他变体类型都没有参数化。 例如,LongFunction<int[]>使用long类型作为参数并返回了int []类型。
Function接口还有九个额外的变体,当结果类型为基本类型时使用。 源和结果类型总是不同,因为从类型到它自身的函数是UnaryOperator。 如果源类型和结果类型都是基本类型,则使用带有SrcToResult的前缀Function,例如LongToIntFunction(六个变体)。如果源是一个基本类型,返回结果是一个对象引用,那么带有<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接口和42个接口占所有四十三个标准功能接口。 无可否认,这是非常难以接受的,并且不是非常正交的。 另一方面,你所需要的大部分功能接口都是为你写的,而且它们的名字是经常性的,所以在你需要的时候不应该有太多的麻烦。
大多数标准函数式接口仅用于提供对基本类型的支持。 不要试图使用基本的函数式接口来装箱基本类型的包装类而不是基本类型的函数式接口。 虽然它起作用,但它违反了第61条中的建议:“优先使用基本类型而不是基本类型的包装类”。使用装箱基本类型的包装类进行批量操作的性能后果可能是致命的。
现在你知道你应该通常使用标准的函数式接口来优先编写自己的接口。 但是,你应该什么时候写自己的接口? 当然,如果没有一个标准模块能够满足您的需求,例如,如果需要一个带有三个参数的Predicate,或者一个抛出检查异常的Predicate,那么需要编写自己的代码。 但有时候你应该编写自己的函数式接口,即使与其中一个标准的函数式接口的结构相同。
考虑我们的老朋友Comparator <T>,它的结构与ToIntBiFunction <T, T>接口相同。 即使将前者添加到类库时后者的接口已经存在,使用它也是错误的。 Comparator值得拥有自己的接口有以下几个原因。 首先,它的名称每次在API中使用时都会提供优秀的文档,并且使用了很多。 其次,Comparator接口对构成有效实例的构成有强大的要求,这些要求构成了它的普遍契约。 通过实现接口,就要承诺遵守契约。 第三,接口配备很多了有用的默认方法来转换和组合多个比较器。
如果需要一个函数式接口与Comparator共享以下一个或多个特性,应该认真考虑编写一个专用函数式接口,而不是使用标准函数式接口:
- 它将被广泛使用,并且可以从描述性名称中受益。
- 它拥有强大的契约。
- 它会受益于自定义的默认方法。
如果选择编写你自己的函数式接口,请记住它是一个接口,因此应非常小心地设计(条目 21)。
请注意,EldestEntryRemovalFunction接口(第199页)标有@FunctionalInterface注解。 这种注解在类型类似于@Override。 这是一个程序员意图的陈述,它有三个目的:它告诉读者该类和它的文档,该接口是为了实现lambda表达式而设计的;它使你保持可靠,因为除非只有一个抽象方法,否则接口不会编译; 它可以防止维护人员在接口发生变化时不小心地将抽象方法添加到接口中。 始终使用@FunctionalInterface注解标注你的函数式接口。
最后一点应该是关于在api中使用函数接口的问题。不要提供具有多个重载的方法,这些重载在相同的参数位置上使用不同的函数式接口,如果这样做可能会在客户端中产生歧义。这不仅仅是一个理论问题。ExecutorService的submit方法可以采用Callable<T>或Runnable接口,并且可以编写需要强制类型转换以指示正确的重载的客户端程序(条目 52)。避免此问题的最简单方法是不要编写在相同的参数位置中使用不同函数式接口的重载。这是条目52中建议的一个特例,“明智地使用重载”。
总之,现在Java已经有了lambda表达式,因此必须考虑lambda表达式来设计你的API。 在输入上接受函数式接口类型并在输出中返回它们。 一般来说,最好使用java.util.function.Function中提供的标准接口,但请注意,在相对罕见的情况下,最好编写自己的函数式接口。
45. 明智审慎地使用Stream
在Java 8中添加了Stream API,以简化顺序或并行执行批量操作的任务。 该API提供了两个关键的抽象:流(Stream),表示有限或无限的数据元素序列,以及流管道(stream pipeline),表示对这些元素的多级计算。 Stream中的元素可以来自任何地方。 常见的源包括集合,数组,文件,正则表达式模式匹配器,伪随机数生成器和其他流。 流中的数据元素可以是对象引用或基本类型。 支持三种基本类型:int,long和double。
流管道由源流(source stream)的零或多个中间操作和一个终结操作组成。每个中间操作都以某种方式转换流,例如将每个元素映射到该元素的函数或过滤掉所有不满足某些条件的元素。中间操作都将一个流转换为另一个流,其元素类型可能与输入流相同或不同。终结操作对流执行最后一次中间操作产生的最终计算,例如将其元素存储到集合中、返回某个元素或打印其所有元素。
管道延迟(lazily)计算求值:计算直到终结操作被调用后才开始,而为了完成终结操作而不需要的数据元素永远不会被计算出来。 这种延迟计算求值的方式使得可以使用无限流。 请注意,没有终结操作的流管道是静默无操作的,所以不要忘记包含一个。
Stream API流式的(fluent)::它设计允许所有组成管道的调用被链接到一个表达式中。事实上,多个管道可以链接在一起形成一个表达式。
默认情况下,流管道按顺序(sequentially)运行。 使管道并行执行就像在管道中的任何流上调用并行方法一样简单,但很少这样做(第48个条目)。
Stream API具有足够的通用性,实际上任何计算都可以使用Stream执行,但仅仅因为可以,并不意味着应该这样做。如果使用得当,流可以使程序更短更清晰;如果使用不当,它们会使程序难以阅读和维护。对于何时使用流没有硬性的规则,但是有一些启发。
考虑以下程序,该程序从字典文件中读取单词并打印其大小符合用户指定的最小值的所有变位词(anagram)组。如果两个单词由长度相通,不同顺序的相同字母组成,则它们是变位词。程序从用户指定的字典文件中读取每个单词并将单词放入map对象中。map对象的键是按照字母排序的单词,因此『staple』的键是『aelpst』,『petals』的键也是『aelpst』:这两个单词就是同位词,所有的同位词共享相同的依字母顺序排列的形式(或称之为alphagram)。map对象的值是包含共享字母顺序形式的所有单词的列表。 处理完字典文件后,每个列表都是一个完整的同位词组。然后程序遍历map对象的values()的视图并打印每个大小符合阈值的列表:
// Prints all large anagram groups in a dictionary iteratively
public class Anagrams {
public static void main(String[] args) throws IOException {
File dictionary = new File(args[0]);
int minGroupSize = Integer.parseInt(args[1]);
Map<String, Set<String>> groups = new HashMap<>();
try (Scanner s = new Scanner(dictionary)) {
while (s.hasNext()) {
String word = s.next();
[groups.computeIfAbsent(alphabetize(word](http://groups.computeIfAbsent(alphabetize(word)),
(unused) -> new TreeSet<>()).add(word);
}
}
for (Set<String> group : groups.values())
if (group.size() >= minGroupSize)
System.out.println(group.size() + ": " + group);
}
private static String alphabetize(String s) {
char[] a = s.toCharArray();
Arrays.sort(a);
return new String(a);
}
}
这个程序中的一个步骤值得注意。将每个单词插入到map中(以粗体显示)中使用了computeIfAbsent方法,该方法是在Java 8中添加的。这个方法在map中查找一个键:如果键存在,该方法只返回与其关联的值。如果没有,该方法通过将给定的函数对象应用于键来计算值,将该值与键关联,并返回计算值。computeIfAbsent方法简化了将多个值与每个键关联的map的实现。
现在考虑以下程序,它解决了同样的问题,但大量过度使用了流。 请注意,整个程序(打开字典文件的代码除外)包含在单个表达式中。 在单独的表达式中打开字典文件的唯一原因是允许使用try-with-resources语句,该语句确保关闭字典文件:
// Overuse of streams - don't do this!
public class Anagrams {
public static void main(String[] args) throws IOException {
Path dictionary = Paths.get(args[0]);
int minGroupSize = Integer.parseInt(args[1]);
try (Stream<String> words = Files.lines(dictionary)) {
words.collect(
groupingBy(word -> word.chars().sorted()
.collect(StringBuilder::new,
(sb, c) -> sb.append((char) c),
StringBuilder::append).toString()))
.values().stream()
.filter(group -> group.size() >= minGroupSize)
.map(group -> group.size() + ": " + group)
.forEach(System.out::println);
}
}
}
如果你发现这段代码难以阅读,不要担心;你不是一个人。它更短,但是可读性也更差,尤其是对于那些不擅长使用流的程序员来说。过度使用流使程序难于阅读和维护。
幸运的是,有一个折中的办法。下面的程序解决了同样的问题,使用流而不过度使用它们。其结果是一个比原来更短更清晰的程序:
// Tasteful use of streams enhances clarity and conciseness
public class Anagrams {
public static void main(String[] args) throws IOException {
Path dictionary = Paths.get(args[0]);
int minGroupSize = Integer.parseInt(args[1]);
try (Stream<String> words = Files.lines(dictionary)) {
words.collect(groupingBy(word -> alphabetize(word)))
.values().stream()
.filter(group -> group.size() >= minGroupSize)
.forEach(g -> System.out.println(g.size() + ": " + g));
}
}
// alphabetize method is the same as in original version
}
即使以前很少接触流,这个程序也不难理解。它在一个try-with-resources块中打开字典文件,获得一个由文件中的所有行组成的流。流变量命名为words,表示流中的每个元素都是一个单词。此流上的管道没有中间操作;它的终结操作将所有单词收集到个map对象中,按照字母排列的形式对单词进行分组(第46项)。这与之前两个版本的程序构造的map完全相同。然后在map的values()视图上打开一个新的流<List<String>>。当然,这个流中的元素是同位词组。对流进行过滤,以便忽略大小小于minGroupSize的所有组,最后由终结操作forEach打印剩下的同位词组。
请注意,仔细选择lambda参数名称。 上面程序中参数g应该真正命名为group,但是生成的代码行对于本书来说太宽了。 在没有显式类型的情况下,仔细命名lambda参数对于流管道的可读性至关重要。
另请注意,单词字母化是在单独的alphabetize方法中完成的。 这通过提供操作名称并将实现细节保留在主程序之外来增强可读性。 使用辅助方法对于流管道中的可读性比在迭代代码中更为重要,因为管道缺少显式类型信息和命名临时变量。
字母顺序方法可以使用流重新实现,但基于流的字母顺序方法本来不太清楚,更难以正确编写,并且可能更慢。 这些缺陷是由于Java缺乏对原始字符流的支持(这并不意味着Java应该支持char流;这样做是不可行的)。 要演示使用流处理char值的危害,请考虑以下代码:
"Hello world!".chars().forEach(System.out::print);
你可能希望它打印Hello world!,但如果运行它,发现它打印721011081081113211911111410810033。这是因为“Hello world!”.chars()返回的流的元素不是char值,而是int值,因此调用了print的int重载。无可否认,一个名为chars的方法返回一个int值流是令人困惑的。可以通过强制调用正确的重载来修复该程序:
"Hello world!".chars().forEach(x -> System.out.print((char) x));
但理想情况下,应该避免使用流来处理char值。
当开始使用流时,你可能会感到想要将所有循环语句转换为流方式的冲动,但请抵制这种冲动。尽管这是可能的,但可能会损害代码库的可读性和可维护性。 通常,使用流和迭代的某种组合可以最好地完成中等复杂的任务,如上面的Anagrams程序所示。 因此,重构现有代码以使用流,并仅在有意义的情况下在新代码中使用它们。
如本项目中的程序所示,流管道使用函数对象(通常为lambdas或方法引用)表示重复计算,而迭代代码使用代码块表示重复计算。从代码块中可以做一些从函数对象中不能做的事情:
•从代码块中,可以读取或修改范围内的任何局部变量; 从lambda中,只能读取最终或有效的最终变量[JLS 4.12.4],并且无法修改任何局部变量。
•从代码块中,可以从封闭方法返回,中断或继续封闭循环,或抛出声明此方法的任何已检查异常; 从一个lambda你不能做这些事情。
如果使用这些技术最好地表达计算,那么它可能不是流的良好匹配。 相反,流可以很容易地做一些事情:
•统一转换元素序列
•过滤元素序列
•使用单个操作组合元素序列(例如添加、连接或计算最小值)
•将元素序列累积到一个集合中,可能通过一些公共属性将它们分组
•在元素序列中搜索满足某些条件的元素
如果使用这些技术最好地表达计算,那么使用流是这些场景很好的候选者。
对于流来说,很难做到的一件事是同时访问管道的多个阶段中的相应元素:一旦将值映射到其他值,原始值就会丢失。一种解决方案是将每个值映射到一个包含原始值和新值的pair对象,但这不是一个令人满意的解决方案,尤其是在管道的多个阶段需要一对对象时更是如此。生成的代码既混乱又冗长,破坏了流的主要用途。当它适用时,一个更好的解决方案是在需要访问早期阶段值时转换映射。
例如,让我们编写一个程序来打印前20个梅森素数(Mersenne primes)。 梅森素数是一个2p − 1形式的数字。如果p是素数,相应的梅森数可能是素数; 如果是这样的话,那就是梅森素数。 作为我们管道中的初始流,我们需要所有素数。 这里有一个返回该(无限)流的方法。 我们假设使用静态导入来轻松访问BigInteger的静态成员:
static Stream<BigInteger> primes() {
return Stream.iterate(TWO, BigInteger::nextProbablePrime);
}
方法的名称(primes)是一个复数名词,描述了流的元素。 强烈建议所有返回流的方法使用此命名约定,因为它增强了流管道的可读性。 该方法使用静态工厂Stream.iterate,它接受两个参数:流中的第一个元素,以及从前一个元素生成流中的下一个元素的函数。 这是打印前20个梅森素数的程序:
public static void main(String[] args) {
primes().map(p -> TWO.pow(p.intValueExact()).subtract(ONE))
.filter(mersenne -> mersenne.isProbablePrime(50))
.limit(20)
.forEach(System.out::println);
}
这个程序是上面的梅森描述的直接编码:它从素数开始,计算相应的梅森数,过滤掉除素数之外的所有数字(幻数50控制概率素性测试the magic number 50 controls the probabilistic primality test),将得到的流限制为20个元素, 并打印出来。
现在假设我们想在每个梅森素数前面加上它的指数(p),这个值只出现在初始流中,因此在终结操作中不可访问,而终结操作将输出结果。幸运的是通过反转第一个中间操作中发生的映射,可以很容易地计算出Mersenne数的指数。 指数是二进制表示中的位数,因此该终结操作会生成所需的结果:
.forEach(mp -> System.out.println(mp.bitLength() + ": " + mp));
有很多任务不清楚是使用流还是迭代。例如,考虑初始化一副新牌的任务。假设Card是一个不可变的值类,它封装了Rank和Suit,它们都是枚举类型。这个任务代表任何需要计算可以从两个集合中选择的所有元素对。数学家们称它为两个集合的笛卡尔积。下面是一个迭代实现,它有一个嵌套的for-each循环,你应该非常熟悉:
// Iterative Cartesian product computation
private static List<Card> newDeck() {
List<Card> result = new ArrayList<>();
for (Suit suit : Suit.values())
for (Rank rank : Rank.values())
result.add(new Card(suit, rank));
return result;
}
下面是一个基于流的实现,它使用了中间操作flatMap方法。这个操作将一个流中的每个元素映射到一个流,然后将所有这些新流连接到一个流(或展平它们)。注意,这个实现包含一个嵌套的lambda表达式(rank -> new Card(suit, rank))):
// Stream-based Cartesian product computation
private static List<Card> newDeck() {
return Stream.of(Suit.values())
.flatMap(suit ->
Stream.of(Rank.values())
.map(rank -> new Card(suit, rank)))
.collect(toList());
}
newDeck的两个版本中哪一个更好? 它归结为个人偏好和你的编程的环境。 第一个版本更简单,也许感觉更自然。 大部分Java程序员将能够理解和维护它,但是一些程序员会对第二个(基于流的)版本感觉更舒服。 如果对流和函数式编程有相当的精通,那么它会更简洁,也不会太难理解。 如果不确定自己喜欢哪个版本,则迭代版本可能是更安全的选择。 如果你更喜欢流的版本,并且相信其他使用该代码的程序员会与你共享你的偏好,那么应该使用它。
总之,有些任务最好使用流来完成,有些任务最好使用迭代来完成。将这两种方法结合起来,可以最好地完成许多任务。对于选择使用哪种方法进行任务,没有硬性规定,但是有一些有用的启发式方法。在许多情况下,使用哪种方法将是清楚的;在某些情况下,则不会很清楚。如果不确定一个任务是通过流还是迭代更好地完成,那么尝试这两种方法,看看哪一种效果更好。
46. 优先考虑流中无副作用的函数
如果你是一个刚开始使用流的新手,那么很难掌握它们。仅仅将计算表示为流管道是很困难的。当你成功时,你的程序将运行,但对你来说可能没有意识到任何好处。流不仅仅是一个API,它是基于函数式编程的范式(paradigm)。为了获得流提供的可表达性、速度和某些情况下的并行性,你必须采用范式和API。
流范式中最重要的部分是将计算结构化为一系列转换,其中每个阶段的结果尽可能接近前一阶段结果的纯函数( pure function)。 纯函数的结果仅取决于其输入:它不依赖于任何可变状态,也不更新任何状态。 为了实现这一点,你传递给流操作的任何函数对象(中间操作和终结操作)都应该没有副作用。
有时,可能会看到类似于此代码片段的流代码,该代码构建了文本文件中单词的频率表:
// Uses the streams API but not the paradigm--Don't do this!
Map<String, Long> freq = new HashMap<>();
try (Stream<String> words = new Scanner(file).tokens()) {
words.forEach(word -> {
freq.merge(word.toLowerCase(), 1L, Long::sum);
});
}
这段代码出了什么问题? 毕竟,它使用了流,lambdas和方法引用,并得到正确的答案。 简而言之,它根本不是流代码; 它是伪装成流代码的迭代代码。 它没有从流API中获益,并且它比相应的迭代代码更长,更难读,并且更难于维护。 问题源于这样一个事实:这个代码在一个终结操作forEach中完成所有工作,使用一个改变外部状态(频率表)的lambda。forEach操作除了表示由一个流执行的计算结果外,什么都不做,这是“代码中的臭味”,就像一个改变状态的lambda一样。那么这段代码应该是什么样的呢?
// Proper use of streams to initialize a frequency table
Map<String, Long> freq;
try (Stream<String> words = new Scanner(file).tokens()) {
freq = words
.collect(groupingBy(String::toLowerCase, counting()));
}
此代码段与前一代码相同,但正确使用了流API。 它更短更清晰。 那么为什么有人会用其他方式写呢? 因为它使用了他们已经熟悉的工具。 Java程序员知道如何使用for-each循环,而forEach终结操作是类似的。 但forEach操作是终端操作中最不强大的操作之一,也是最不友好的流操作。 它是明确的迭代,因此不适合并行化。 forEach操作应仅用于报告流计算的结果,而不是用于执行计算。有时,将forEach用于其他目的是有意义的,例如将流计算的结果添加到预先存在的集合中。
改进后的代码使用了收集器(collector),这是使用流必须学习的新概念。Collectors的API令人生畏:它有39个方法,其中一些方法有多达5个类型参数。好消息是,你可以从这个API中获得大部分好处,而不必深入研究它的全部复杂性。对于初学者来说,可以忽略收集器接口,将收集器看作是封装缩减策略( reduction strategy)的不透明对象。在此上下文中,reduction意味着将流的元素组合为单个对象。 收集器生成的对象通常是一个集合(它代表名称收集器)。
将流的元素收集到真正的集合中的收集器非常简单。有三个这样的收集器:toList()、toSet()和toCollection(collectionFactory)。它们分别返回集合、列表和程序员指定的集合类型。有了这些知识,我们就可以编写一个流管道从我们的频率表中提取出现频率前10个单词的列表。
// Pipeline to get a top-ten list of words from a frequency table
List<String> topTen = freq.keySet().stream()
.sorted(comparing(freq::get).reversed())
.limit(10)
.collect(toList());
注意,我们没有对toList方法的类收集器进行限定。静态导入收集器的所有成员是一种惯例和明智的做法,因为它使流管道更易于阅读。
这段代码中唯一比较棘手的部分是我们把comparing(freq::get).reverse()传递给sort方法。comparing是一种比较器构造方法(条目 14),它具有一个key的提取方法。该函数接受一个单词,而“提取”实际上是一个表查找:绑定方法引用freq::get在frequency表中查找单词,并返回单词出现在文件中的次数。最后,我们在比较器上调用reverse方法,因此我们将单词从最频繁到最不频繁进行排序。然后,将流限制为10个单词并将它们收集到一个列表中就很简单了。
前面的代码片段使用Scanner的stream方法在scanner实例上获取流。这个方法是在Java 9中添加的。如果正在使用较早的版本,可以使用类似于条目 47中(streamOf(Iterable<E>))的适配器将实现了Iterator的scanner序转换为流。
那么收集器中的其他36种方法呢?它们中的大多数都是用于将流收集到map中的,这比将流收集到真正的集合中要复杂得多。每个流元素都与一个键和一个值相关联,多个流元素可以与同一个键相关联。
最简单的映射收集器是toMap(keyMapper、valueMapper),它接受两个函数,一个将流元素映射到键,另一个映射到值。在条目34中的fromString实现中,我们使用这个收集器从enum的字符串形式映射到enum本身:
// Using a toMap collector to make a map from string to enum
private static final Map<String, Operation> stringToEnum =
Stream.of(values()).collect(
toMap(Object::toString, e -> e));
如果流中的每个元素都映射到唯一键,则这种简单的toMap形式是完美的。 如果多个流元素映射到同一个键,则管道将以IllegalStateException终止。
toMap更复杂的形式,以及groupingBy方法,提供了处理此类冲突(collisions)的各种方法。一种方法是向toMap方法提供除键和值映射器(mappers)之外的merge方法。merge方法是一个BinaryOperator,其中V`是map的值类型。与键关联的任何附加值都使用merge方法与现有值相结合,因此,例如,如果merge方法是乘法,那么最终得到的结果是是值mapper与键关联的所有值的乘积。
toMap的三个参数形式对于从键到与该键关联的选定元素的映射也很有用。例如,假设我们有一系列不同艺术家(artists)的唱片集(albums),我们想要一张从唱片艺术家到最畅销专辑的map。这个收集器将完成这项工作。
// Collector to generate a map from key to chosen element for key
Map<Artist, Album> topHits = albums.collect(
toMap(Album::artist, a->a, maxBy(comparing(Album::sales))));
请注意,比较器使用静态工厂方法maxBy,它是从BinaryOperator静态导入的。 此方法将Comparator <T>转换为BinaryOperator <T>,用于计算指定比较器隐含的最大值。 在这种情况下,比较器由比较器构造方法comparing返回,它采用key提取器函数Album :: sales。 这可能看起来有点复杂,但代码可读性很好。 简而言之,它说,“将专辑(albums)流转换为地map,将每位艺术家(artist)映射到销售量最佳的专辑。”这与问题陈述出奇得接近。
toMap的三个参数形式的另一个用途是产生一个收集器,当发生冲突时强制执行last-write-wins策略。 对于许多流,结果是不确定的,但如果映射函数可能与键关联的所有值都相同,或者它们都是可接受的,则此收集器的行为可能正是您想要的:
// Collector to impose last-write-wins policy
toMap(keyMapper, valueMapper, (oldVal, newVal) ->newVal)
toMap的第三个也是最后一个版本采用第四个参数,它是一个map工厂,用于指定特定的map实现,例如EnumMap或TreeMap。
toMap的前三个版本也有变体形式,名为toConcurrentMap,它们并行高效运行并生成ConcurrentHashMap实例。
除了toMap方法之外,Collectors API还提供了groupingBy方法,该方法返回收集器以生成基于分类器函数(classifier function)将元素分组到类别中的map。 分类器函数接受一个元素并返回它所属的类别。 此类别来用作元素的map的键。 groupingBy方法的最简单版本仅采用分类器并返回一个map,其值是每个类别中所有元素的列表。 这是我们在条目 45中的Anagram程序中使用的收集器,用于生成从按字母顺序排列的单词到单词列表的map:
Map<String, Long> freq = words
.collect(groupingBy(String::toLowerCase, counting()));
groupingBy的第三个版本允许指定除downstream收集器之外的map工厂。 请注意,这种方法违反了标准的可伸缩参数列表模式(standard telescoping argument list pattern):mapFactory参数位于downStream参数之前,而不是之后。 此版本的groupingBy可以控制包含的map以及包含的集合,因此,例如,可以指定一个收集器,它返回一个TreeMap,其值是TreeSet。
groupingByConcurrent方法提供了groupingBy的所有三个重载的变体。 这些变体并行高效运行并生成ConcurrentHashMap实例。 还有一个很少使用的grouping的亲戚称为partitioningBy。 代替分类器方法,它接受predicate并返回其键为布尔值的map。 此方法有两种重载,除了predicate之外,其中一种方法还需要downstream收集器。
通过counting方法返回的收集器仅用作下游收集器。 Stream上可以通过count方法直接使用相同的功能,因此没有理由说collect(counting())。 此属性还有十五种收集器方法。 它们包括九个方法,其名称以summing,averaging和summarizing开头(其功能在相应的原始流类型上可用)。 它们还包括reduce方法的所有重载,以及filter,mapping,flatMapping和collectingAndThen方法。 大多数程序员可以安全地忽略大多数这些方法。 从设计的角度来看,这些收集器代表了尝试在收集器中部分复制流的功能,以便下游收集器可以充当“迷你流(ministreams)”。
我们还有三种收集器方法尚未提及。 虽然他们在收Collectors类中,但他们不涉及集合。 前两个是minBy和maxBy,它们取比较器并返回比较器确定的流中的最小或最大元素。 它们是Stream接口中min和max方法的次要总结,是BinaryOperator中类似命名方法返回的二元运算符的类似收集器。 回想一下,我们在最畅销的专辑中使用了BinaryOperator.maxBy方法。
最后的Collectors中方法是join,它仅对CharSequence实例(如字符串)的流进行操作。 在其无参数形式中,它返回一个简单地连接元素的收集器。 它的一个参数形式采用名为delimiter的单个CharSequence参数,并返回一个连接流元素的收集器,在相邻元素之间插入分隔符。 如果传入逗号作为分隔符,则收集器将返回逗号分隔值字符串(但请注意,如果流中的任何元素包含逗号,则字符串将不明确)。 除了分隔符之外,三个参数形式还带有前缀和后缀。 生成的收集器会生成类似于打印集合时获得的字符串,例如[came, saw, conquered]。
总之,编程流管道的本质是无副作用的函数对象。 这适用于传递给流和相关对象的所有许多函数对象。 终结操作orEach仅应用于报告流执行的计算结果,而不是用于执行计算。 为了正确使用流,必须了解收集器。 最重要的收集器工厂是toList,toSet,toMap,groupingBy和join。
47. 优先使用Collection而不是Stream来作为方法的返回类型
许多方法返回元素序列(sequence)。在Java 8之前,通常方法的返回类型是Collection,Set和List这些接口;还包括Iterable和数组类型。通常,很容易决定返回哪一种类型。规范(norm)是集合接口。如果该方法仅用于启用for-each循环,或者返回的序列不能实现某些Collection方法(通常是contains(Object)),则使用迭代(Iterable)接口。如果返回的元素是基本类型或有严格的性能要求,则使用数组。在Java 8中,将流(Stream)添加到平台中,这使得为序列返回方法选择适当的返回类型的任务变得非常复杂。
你可能听说过,流现在是返回元素序列的明显的选择,但是正如条目 45所讨论的,流不会使迭代过时:编写好的代码需要明智地结合流和迭代。如果一个API只返回一个流,并且一些用户想用for-each循环遍历返回的序列,那么这些用户肯定会感到不安。这尤其令人沮丧,因为Stream接口在Iterable接口中包含唯一的抽象方法,Stream的方法规范与Iterable兼容。阻止程序员使用for-each循环在流上迭代的唯一原因是Stream无法继承Iterable。
遗憾的是,这个问题没有好的解决方法。 乍一看,似乎可以将方法引用传递给Stream的iterator方法。 结果代码可能有点嘈杂和不透明,但并非不合理:
// Won't compile, due to limitations on Java's type inference
for (ProcessHandle ph : ProcessHandle.allProcesses()::iterator) {
// Process the process
}
不幸的是,如果你试图编译这段代码,会得到一个错误信息:
Test.java:6: error: method reference not expected here
for (ProcessHandle ph : ProcessHandle.allProcesses()::iterator) {
为了使代码编译,必须将方法引用强制转换为适当参数化的Iterable类型:
// Hideous workaround to iterate over a stream
for (ProcessHandle ph : (Iterable<ProcessHandle>)ProcessHandle.allProcesses()::iterator)
此代码有效,但在实践中使用它太嘈杂和不透明。 更好的解决方法是使用适配器方法。 JDK没有提供这样的方法,但是使用上面的代码片段中使用的相同技术,很容易编写一个方法。 请注意,在适配器方法中不需要强制转换,因为Java的类型推断在此上下文中能够正常工作:
// Adapter from Stream<E> to Iterable<E>
public static <E> Iterable<E> iterableOf(Stream<E> stream) {
return stream::iterator;
}
使用此适配器,可以使用for-each语句迭代任何流:
for (ProcessHandle p : iterableOf(ProcessHandle.allProcesses())) {
// Process the process
}
注意,条目 34中的Anagrams程序的流版本使用Files.lines方法读取字典,而迭代版本使用了scanner。Files.lines方法优于scanner,scanner在读取文件时无声地吞噬所有异常。理想情况下,我们也会在迭代版本中使用Files.lines。如果API只提供对序列的流访问,而程序员希望使用for-each语句遍历序列,那么他们就要做出这种妥协。
相反,如果一个程序员想要使用流管道来处理一个序列,那么一个只提供Iterable的API会让他感到不安。JDK同样没有提供适配器,但是编写这个适配器非常简单:
// Adapter from Iterable<E> to Stream<E>
public static <E> Stream<E> streamOf(Iterable<E> iterable) {
return StreamSupport.stream(iterable.spliterator(), false);
}
如果你正在编写一个返回对象序列的方法,并且它只会在流管道中使用,那么当然可以自由地返回流。类似地,返回仅用于迭代的序列的方法应该返回一个Iterable。但是如果你写一个公共API,它返回一个序列,你应该为用户提供哪些想写流管道,哪些想写for-each语句,除非你有充分的理由相信大多数用户想要使用相同的机制。
Collection接口是Iterable的子类型,并且具有stream方法,因此它提供迭代和流访问。 因此,Collection或适当的子类型通常是公共序列返回方法的最佳返回类型。 数组还使用Arrays.asList和Stream.of方法提供简单的迭代和流访问。 如果返回的序列小到足以容易地放入内存中,那么最好返回一个标准集合实现,例如ArrayList或HashSet。 但是不要在内存中存储大的序列,只是为了将它作为集合返回。
如果返回的序列很大但可以简洁地表示,请考虑实现一个专用集合。 例如,假设返回给定集合的幂集(power set:就是原集合中所有的子集(包括全集和空集)构成的集族),该集包含其所有子集。 {a,b,c}的幂集为{{},{a},{b},{c},{a,b},{a,c},{b,c},{a,b , C}}。 如果一个集合具有n个元素,则幂集具有2n个。 因此,你甚至不应考虑将幂集存储在标准集合实现中。 但是,在AbstractList的帮助下,很容易为此实现自定义集合。
诀窍是使用幂集中每个元素的索引作为位向量(bit vector),其中索引中的第n位指示源集合中是否存在第n个元素。 本质上,从0到2n-1的二进制数和n个元素集和的幂集之间存在自然映射。 这是代码:
// Returns the power set of an input set as custom collection
public class PowerSet {
public static final <E> Collection<Set<E>> of(Set<E> s) {
List<E> src = new ArrayList<>(s);
if (src.size() > 30)
throw new IllegalArgumentException("Set too big " + s);
return new AbstractList<Set<E>>() {
@Override public int size() {
return 1 << src.size(); // 2 to the power srcSize
}
@Override public boolean contains(Object o) {
return o instanceof Set && src.containsAll((Set)o);
}
@Override public Set<E> get(int index) {
Set<E> result = new HashSet<>();
for (int i = 0; index != 0; i++, index >>= 1)
if ((index & 1) == 1)
result.add(src.get(i));
return result;
}
};
}
}
请注意,如果输入集合超过30个元素,则PowerSet.of方法会引发异常。 这突出了使用Collection作为返回类型而不是Stream或Iterable的缺点:Collection有int返回类型的size的方法,该方法将返回序列的长度限制为Integer.MAX_VALUE或231-1。Collection规范允许size方法返回231 - 1,如果集合更大,甚至无限,但这不是一个完全令人满意的解决方案。
为了在AbstractCollection上编写Collection实现,除了Iterable所需的方法之外,只需要实现两种方法:contains和size。 通常,编写这些方法的有效实现很容易。 如果不可行,可能是因为在迭代发生之前未预先确定序列的内容,返回Stream还是Iterable的,无论哪种感觉更自然。 如果选择,可以使用两种不同的方法分别返回。
有时,你会仅根据实现的易用性选择返回类型。例如,假设希望编写一个方法,该方法返回输入列表的所有(连续的)子列表。生成这些子列表并将它们放到标准集合中只需要三行代码,但是保存这个集合所需的内存是源列表大小的二次方。虽然这没有指数幂集那么糟糕,但显然是不可接受的。实现自定义集合(就像我们对幂集所做的那样)会很乏味,因为JDK缺少一个框架Iterator实现来帮助我们。
然而,实现输入列表的所有子列表的流是直截了当的,尽管它确实需要一点的洞察力(insight)。 让我们调用一个子列表,该子列表包含列表的第一个元素和列表的前缀。 例如,(a,b,c)的前缀是(a),(a,b)和(a,b,c)。 类似地,让我们调用包含后缀的最后一个元素的子列表,因此(a,b,c)的后缀是(a,b,c),(b,c)和(c)。 洞察力是列表的子列表只是前缀的后缀(或相同的后缀的前缀)和空列表。 这一观察直接展现了一个清晰,合理简洁的实现:
// Returns a stream of all the sublists of its input list
public class SubLists {
public static <E> Stream<List<E>> of(List<E> list) {
return Stream.concat(Stream.of(Collections.emptyList()),
prefixes(list).flatMap(SubLists::suffixes));
}
private static <E> Stream<List<E>> prefixes(List<E> list) {
return IntStream.rangeClosed(1, list.size())
.mapToObj(end -> list.subList(0, end));
}
private static <E> Stream<List<E>> suffixes(List<E> list) {
return IntStream.range(0, list.size())
.mapToObj(start -> list.subList(start, list.size()));
}
}
请注意,Stream.concat方法用于将空列表添加到返回的流中。 还有,flatMap方法(条目 45)用于生成由所有前缀的所有后缀组成的单个流。 最后,通过映射IntStream.range和IntStream.rangeClosed返回的连续int值流来生成前缀和后缀。这个习惯用法,粗略地说,流等价于整数索引上的标准for循环。因此,我们的子列表实现似于明显的嵌套for循环:
for (int start = 0; start < src.size(); start++)
for (int end = start + 1; end <= src.size(); end++)
System.out.println(src.subList(start, end));
可以将这个for循环直接转换为流。结果比我们以前的实现更简洁,但可能可读性稍差。它类似于条目 45中的笛卡尔积的使用流的代码:
// Returns a stream of all the sublists of its input list
public static <E> Stream<List<E>> of(List<E> list) {
return IntStream.range(0, list.size())
.mapToObj(start ->
IntStream.rangeClosed(start + 1, list.size())
.mapToObj(end -> list.subList(start, end)))
.flatMap(x -> x);
}
与之前的for循环一样,此代码不会包换空列表。 为了解决这个问题,可以使用concat方法,就像我们在之前版本中所做的那样,或者在rangeClosed调用中用(int) Math.signum(start)替换1。
这两种子列表的流实现都可以,但都需要一些用户使用流-迭代适配器( Stream-to-Iterable adapte),或者在更自然的地方使用流。流-迭代适配器不仅打乱了客户端代码,而且在我的机器上使循环速度降低了2.3倍。一个专门构建的Collection实现(此处未显示)要冗长,但运行速度大约是我的机器上基于流的实现的1.4倍。
总之,在编写返回元素序列的方法时,请记住,某些用户可能希望将它们作为流处理,而其他用户可能希望迭代方式来处理它们。 尽量适应两个群体。 如果返回集合是可行的,请执行此操作。 如果已经拥有集合中的元素,或者序列中的元素数量足够小,可以创建一个新的元素,那么返回一个标准集合,比如ArrayList。 否则,请考虑实现自定义集合,就像我们为幂集程序里所做的那样。 如果返回集合是不可行的,则返回流或可迭代的,无论哪个看起来更自然。 如果在将来的Java版本中,Stream接口声明被修改为继承Iterable,那么应该随意返回流,因为它们将允许流和迭代处理。
48.谨慎使用流并行
在主流语言中,Java一直处于提供简化并发编程任务的工具的最前沿。 当Java于1996年发布时,它内置了对线程的支持,包括同步和wait / notify机制。 Java 5引入了java.util.concurrent类库,带有并发集合和执行器框架。 Java 7引入了fork-join包,这是一个用于并行分解的高性能框架。 Java 8引入了流,可以通过对parallel方法的单个调用来并行化。 用Java编写并发程序变得越来越容易,但编写正确快速的并发程序还像以前一样困难。 安全和活跃度违规(liveness violation)是并发编程中的事实,并行流管道也不例外。
考虑条目 45中的程序:
// Stream-based program to generate the first 20 Mersenne primes
public static void main(String[] args) {
primes().map(p -> TWO.pow(p.intValueExact()).subtract(ONE))
.filter(mersenne -> mersenne.isProbablePrime(50))
.limit(20)
.forEach(System.out::println);
}
static Stream<BigInteger> primes() {
return Stream.iterate(TWO, BigInteger::nextProbablePrime);
}
在我的机器上,这个程序立即开始打印素数,运行到完成需要12.5秒。假设我天真地尝试通过向流管道中添加一个到parallel()的调用来加快速度。你认为它的表现会怎样?它会快几个百分点吗?慢几个百分点?遗憾的是,它不会打印任何东西,但是CPU使用率会飙升到90%,并且会无限期地停留在那里(liveness failure:活性失败)。这个程序可能最终会终止,但我不愿意去等待;半小时后我强行阻止了它。
这里发生了什么?简而言之,流类库不知道如何并行化此管道并且启发式失败(heuristics fail.)。即使在最好的情况下,如果源来自Stream.iterate方法,或者使用中间操作limit方法,并行化管道也不太可能提高其性能。这个管道必须应对这两个问题。更糟糕的是,默认的并行策略处理不可预测性的limit方法,假设在处理一些额外的元素和丢弃任何不必要的结果时没有害处。在这种情况下,找到每个梅森素数的时间大约是找到上一个素数的两倍。因此,计算单个额外元素的成本大致等于计算所有先前元素组合的成本,并且这种无害的管道使自动并行化算法瘫痪。这个故事的寓意很简单:不要无差别地并行化流管道(stream pipelines)。性能后果可能是灾难性的。
通常,并行性带来的性能收益在ArrayList、HashMap、HashSet和ConcurrentHashMap实例、数组、int类型范围和long类型的范围的流上最好。这些数据结构的共同之处在于,它们都可以精确而廉价地分割成任意大小的子程序,这使得在并行线程之间划分工作变得很容易。用于执行此任务的流泪库使用的抽象是spliterator,它由spliterator方法在Stream和Iterable上返回。
所有这些数据结构的共同点的另一个重要因素是它们在顺序处理时提供了从良好到极好的引用位置( locality of reference):顺序元素引用在存储器中存储在一块。 这些引用所引用的对象在存储器中可能彼此不接近,这降低了引用局部性。 对于并行化批量操作而言,引用位置非常重要:没有它,线程大部分时间都处于空闲状态,等待数据从内存传输到处理器的缓存中。 具有最佳引用位置的数据结构是基本类型的数组,因为数据本身连续存储在存储器中。
流管道终端操作的性质也会影响并行执行的有效性。 如果与管道的整体工作相比,在终端操作中完成了大量的工作,并且这种操作本质上是连续的,那么并行化管道的有效性将是有限的。 并行性的最佳终操作是缩减(reductions),即使用流的reduce方法组合管道中出现的所有元素,或者预先打包的reduce(如min、max、count和sum)。短路操作anyMatch、allMatch和noneMatch也可以支持并行性。由Stream的collect方法执行的操作,称为可变缩减(mutable reductions),不适合并行性,因为组合集合的开销非常大。
如果编写自己的Stream,Iterable或Collection实现,并且希望获得良好的并行性能,则必须重写spliterator方法并广泛测试生成的流的并行性能。 编写高质量的spliterator很困难,超出了本书的范围。
并行化一个流不仅会导致糟糕的性能,包括活性失败(liveness failures);它会导致不正确的结果和不可预知的行为(安全故障)。使用映射器(mappers),过滤器(filters)和其他程序员提供的不符合其规范的功能对象的管道并行化可能会导致安全故障。 Stream规范对这些功能对象提出了严格的要求。 例如,传递给Stream的reduce方法操作的累加器(accumulator)和组合器(combiner)函数必须是关联的,非干扰的和无状态的。 如果违反了这些要求(其中一些在第46项中讨论过),但按顺序运行你的管道,则可能会产生正确的结果; 如果将它并行化,它可能会失败,也许是灾难性的。
沿着这些思路,值得注意的是,即使并行的梅森素数程序已经运行完成,它也不会以正确的(升序的)顺序打印素数。为了保持顺序版本显示的顺序,必须将forEach终端操作替换为forEachOrdered操作,它保证以遇出现顺序(encounter order)遍历并行流。
即使假设正在使用一个高效的可拆分的源流、一个可并行化的或廉价的终端操作以及非干扰的函数对象,也无法从并行化中获得良好的加速效果,除非管道做了足够的实际工作来抵消与并行性相关的成本。作为一个非常粗略的估计,流中的元素数量乘以每个元素执行的代码行数应该至少是100,000 [Lea14]。
重要的是要记住并行化流是严格的性能优化。 与任何优化一样,必须在更改之前和之后测试性能,以确保它值得做(第67项)。 理想情况下,应该在实际的系统设置中执行测试。 通常,程序中的所有并行流管道都在公共fork-join池中运行。 单个行为不当的管道可能会损害系统中不相关部分的其他行为。
如果在并行化流管道时,这种可能性对你不利,那是因为它们确实存在。一个认识的人,他维护一个数百万行代码库,大量使用流,他发现只有少数几个地方并行流是有效的。这并不意味着应该避免并行化流。在适当的情况下,只需向流管道添加一个parallel方法调用,就可以实现处理器内核数量的近似线性加速。某些领域,如机器学习和数据处理,特别适合这些加速。
作为并行性有效的流管道的简单示例,请考虑此函数来计算π(n),素数小于或等于n:
// Prime-counting stream pipeline - benefits from parallelization
static long pi(long n) {
return LongStream.rangeClosed(2, n)
.mapToObj(BigInteger::valueOf)
.filter(i -> i.isProbablePrime(50))
.count();
}
在我的机器上,使用此功能计算π(108)需要31秒。 只需添加parallel()方法调用即可将时间缩短为9.2秒:
// Prime-counting stream pipeline - parallel version
static long pi(long n) {
return LongStream.rangeClosed(2, n)
.parallel()
.mapToObj(BigInteger::valueOf)
.filter(i -> i.isProbablePrime(50))
.count();
}
换句话说,在我的四核计算机上,并行计算速度提高了3.7倍。值得注意的是,这不是你在实践中如何计算π(n)为n的值。还有更有效的算法,特别是Lehmer’s formula。
如果要并行化随机数流,请从SplittableRandom实例开始,而不是ThreadLocalRandom(或基本上过时的Random)。 SplittableRandom专为此用途而设计,具有线性加速的潜力。ThreadLocalRandom设计用于单个线程,并将自身适应作为并行流源,但不会像SplittableRandom一样快。Random实例在每个操作上进行同步,因此会导致过度的并行杀死争用( parallelism-killing contention)。
总之,甚至不要尝试并行化流管道,除非你有充分的理由相信它将保持计算的正确性并提高其速度。不恰当地并行化流的代价可能是程序失败或性能灾难。如果您认为并行性是合理的,那么请确保您的代码在并行运行时保持正确,并在实际情况下进行仔细的性能度量。如果您的代码是正确的,并且这些实验证实了您对性能提高的怀疑,那么并且只有这样才能在生产代码中并行化流。