Java SE 8 Lambda 标准库概览
Java SE 8 中加入了新的语言特性,主要包括Lambda表达式,和默认方法。JSR335 对这些新特性进行了详细描述,并且OpenJDK Lambda 项目实现了这些新特性。为了更好的利用这些新特性,Java SE 8 的核心标准库也做了相应的修改和增强。这篇文章主要描述核心库中的新特性。阅读该文章前应该先阅读 Lambda表达式新特性及原理(上) 及Lambda表达式新特性及原理(下)。
1,背景
假如Java语言最初就包括了Lambda表达式,那么Collection API会与现在的大不相同的。随着JSR335 中加入Lambda表达式,一个不幸的影响是我们的Collection API过时了。你可能会去重新开始构建一个新的集合框架(“Collection II”),但是当集合接口已经渗入整个Java生态系统并且被使用多年时,替换Collection框架将会是艰巨而复杂的任务。取而代之,我们追求演化的策略,如在当前接口(如Collection,List,Iterable)中添加拓展方法,添加Stream(如java.util.stream.Stream)的概念来执行数据集上的聚合操作,改装现有的类提供Stream视图,以此实现在不替换用户习惯使用的ArrayList和HashMap的同时,应用新的语法。(这不是说Collection 框架就不会被替换了,显然,他的局限性已不单单是限制了Lambda的设计,JDK未来的版本中可能会考虑更现代的集合框架)
这一工作的关键驱动因素是使得开发者能更好的接受并行开发。既然Java平台已经提供了强大的并发与并行的支持,那么当开发者尝试从串行迁移到并行时面临的障碍就不必要了。因此提供串行并行友好的语法变的异常重要。这使得将关注点从怎样执行转到执行什么变得容易。在并行的易用性(目前还并非如此)与透明化之间的权衡非常重要,我们的目标是明确但不明显的并行。(使得并行透明化会导致数据竞争时的不确定性和可能性)
2,内部迭代与外部迭代
集合框架依赖于Collection接口提供的外部迭代的概念,这一概念通过Iterable接口实现,是一种枚举集合元素的手段,客户端使用它顺序访问集合中的元素。举个例子,如果你想将一个Shape集合中每一个shape的颜色设为红色,可以像这样写
for (Shape s : shapes) {
s.setColor(RED);
}
这个例子解释了外部迭代;for-each循环调用 Shape集合的 iterator()方法,并且一个一个的访问集合元素。外部迭代已经足够简洁直接了,但是它依旧有几个问题:
Java的for循环本质上是串行的,并且必须按序处理集合中的元素。
它剥夺了库方法管理控制流程的机会,它可以利用数据的重排序,并行,短路效应,懒惰性获取更好的性能。
有时for-each循环有力的保障(顺序的,有序的)是可取的,但大多数情况下只是阻碍性能。
外部迭代的变通方案是内部迭代,它不再控制迭代,客户端将它委托给库,并传入代码片段,然后会以并行的方式进行计算。
内部迭代等同于之前的例子:
shapes.forEach(s -> s.setColor(RED));
这看上去像是一个小的语法改变,但差异却是重大的。操作的控制已经从客户端代码转移到了库代码,这不仅允许库抽象常见的控制流操作,并且使得他们具有使用懒惰性,并行,无序的执行方式来提供性能。(forEach的实现是否是真的做了这些事情,是由它的实现决定的,但是内部迭代有这种可能性,然而外部迭代却没有)
然而外部迭代混淆了what(设置shapes的颜色为红色)和how(获取Iterator并且顺序迭代),内部迭代允许客户端指定what,然后由库来控制how。这带来了几个潜在的好处:客户端代码更加清晰,因为他只需要关注问题的陈述,而不是怎样去解决它的细节,并且我们可以将复杂的优化代码移到库中,这会让所有人受益。
3,Streams
Java SE 8 新标准库中引入的新概念是stream,在包java.util.stream
中定义的。(有多种stream类型;[`Stream<T>`](https://www.oschina.net/action/GoToLink?url=http%3A%2F%2Fdownload.java.net%2Fjdk8%2Fdocs%2Fapi%2Fjava%2Futil%2Fstream%2FStream.html) 表示一个对象引用的stream,还有一些特殊化的stream, 如表示基本类型的``[`IntStream`](https://www.oschina.net/action/GoToLink?url=http%3A%2F%2Fdownload.java.net%2Fjdk8%2Fdocs%2Fapi%2Fjava%2Futil%2Fstream%2FIntStream.html) ) stream表示一系列值得序列,并且暴露了一组聚合操作的接口,使得我们可以简单清晰的在这些值上进行常用的计算。库提供了方便的途径来获取集合,数组,其他数据源上的stream视图。``
Stream操作链接在一起形成管道。举个例子,如果我们希望只有蓝色的shape颜色设为红色,那么我们可以说:
shapes.stream()
.filter(s -> s.getColor() == BLUE)
.forEach(s -> s.setColor(RED));
Collection 接口上的stream()方法会产生一个集合元素上的stream视图;filter操作产生一个只包含蓝色shape的stream,并且forEach操作将这些元素设为红色。
如果我们想要收集蓝色shape到一个新的列表,我们可以说:
List<Shape> blue = shapes.stream()
.filter(s -> s.getColor() == BLUE)
.collect(Collectors.toList());
collect()操作收集输入的元素到一个集合体(像是List),或者是一个概述;collect()的参数指定聚合操作的行为。在这里我们使用了toList(),这是一个简单的将元素收集到List的方式。(更多colle()的细节可以在“Collectors”一节找到)
如果每一个shape都包含在一个Box里,我们想知道哪一些box包含了至少一个蓝色的shape,我们可以说:
Set<Box> hasBlueShape = shapes.stream()
.filter(s -> s.getColor() == BLUE)
.map(s -> s.getContainingBox())
.collect(Collectors.toSet());
map()操作产生一个对输入stream中每个元素应用mapping函数后的结果的stream(这里,mapping函数取得一个shape并且返回它的包含Box)。
如果我们想要计算蓝色shape的总重量,我们可以这样:
int sum = shapes.stream()
.filter(s -> s.getColor() == BLUE)
.mapToInt(s -> s.getWeight())
.sum();
目前为止,我们还没有提供更多关于stream操作展示的具体签名;这些列子简单的解释了Streams框架要解决的问题类型。
4, Streams 与 Collections
集合框架与流框架虽然看上去很相似,但是却有着不同的目标。集合框架主要关注的是高效的管理和访问元素。相反,流框架不提供直接访问或操作元素的手段,而是陈述式的描述在数据源上执行的总计操作。因此,流式框架与集合框架有以下几点不同:
没有存储。流不会存储数据值;他们会将源数据(可能是一个数据结构,一个生成函数,或者I/0通道等)通过一个计算步骤的管道。
功能性。在流上操作会生成一个结果,但是不会修改它的底层数据结构。
延迟查找。许多流操作(像是过滤,排序,映射,或者去重)都可以实现为延迟的。这有助于整个管道上进行高效的单次执行。同样有助于高效的短路操作。
可选的范围。有很多问题去合理的表示一个无限的流,并让客户端满意的消耗这些值。(如果我们穷举完全数,很容易通过在一个integer流上执行过滤操作来表达)集合被约束为有限的,而流则不是。(一个无限数据源的流管道可以使用短路操作来在有限的时间内终止操作; 或者,你可以在流上请求一个Iterator来手动迭代)。
作为API,Streams框架是完全独立于Collections框架的。然而将集合作为流的数据源(Collection接口提供了stream()和parallelStream()方法)或者将流转储为集合(使用前面展示的collect()操作)是非常容易的。集合外的聚合结果也可以作为流的数据源。许多JDK类,像是BufferedReader,Random 和 BitSet都被改写为可以作为流的数据源,并且Arrays.stream()方法提供了数组的流视图。事实上,任何可以被描述为Iterator的都可以用作流的数据源,如果有更多可用信息(像是流内容的分类源数据或者大小),库可以提供一个优化的操作。
5,延迟
像过滤或者映射这样的操作,既可以被立即执行(即过滤操作会在方法返回前便对所有元素执行了过滤)也可以是延迟的(这里流只表示在数据源上应用了过滤操作之后的过滤结果)。实际上延迟的执行计算是很有益的。举个例子,如果我们执行延迟过滤,我们可以在管道内将过滤操作与之后的其他操作融合执行,以免在一个数据源上执行多个操作。相似的,如果我们想要在一个大的集合中查询满足条件的第一个元素,那么我们可以找到一个后便停止查找,而不用处理整个的集合。(对于无限的数据源这尤其重要;延迟对有限数据源仅仅是优化,然而它却使得对无限数据源的操作称谓可能,而及早的方式将永远不会停止。)
过滤和映射操作可以被认为是天生的延迟,无论他们是否被这样实现。另一方面,产生值得操作如sum(),或者产生副作用的操作如forEach()是“天生饥渴的”,因为他们必须产生具体的结果。
在一个管道中像是:
int sum = shapes.stream()
.filter(s -> s.getColor() == BLUE)
.mapToInt(s -> s.getWeight())
.sum();
过滤与映射操作时延迟的。这意味着知道我们开始sum操作我们才会从数据源中抽取数据,并且当我们执行sum时,我们会将过滤,映射,以及求和等操作融合在一起执行。这最小化了那些需要对中间元素管理的统计成本。
许多循环可以被重述为数据源(数组,集合,生成方程,IO通道)上的聚合操作, 做一系列的延迟操作(过滤,映射等),以及一次即可操作(forEach,toArray,collect等) -- 如过滤-映射-求和,过滤-映射-排序-迭代等。天生延迟操作倾向于被用来计算临时的中间结果,我们在API中利用了这一属性。我们并非让filter和map返回一个集合,取而代之的是返回一个新的流。在Stream API中,返回流的操作时延迟的,返回非流结果的操作(或没有结果的,像forEach)是饥渴的。大多数情况下,潜在的延迟操作被用于聚合,这被证明是我们确切想要的 -- 每一个阶段都需要一个流作为输入,在流上执行一些转换,然后在管道中将值传递到下一个阶段。
当我们在管道中使用数据源-延迟-延迟-即可模式时,延迟是无形的,因为延迟计算是被夹在了数据源(通常是集合)和产生预期结果(或副作用)的操作之间的。这被证明在一个相对小范围的API中会产生好的性能和可用性。
anyMatch(Predicate)或者findFirst()方法,虽然是即可的,但是一旦他们确定最终结果,便可使用短路来停止他们。
像下面的管道:
Optional<Shape> firstBlue = shapes.stream()
.filter(s -> s.getColor() == BLUE)
.findFirst();
因为过滤是延迟的,findFirst将从流的上游抽取数据直到获得元素,这意味我们只需对输入元素应用断言(predicate)直到我们找到一个断言正确的元素,而不是所有的。findFirst()方法返回一个Optional,因为可能没有元素满足想要的条件。Optional提供了一种描述可能存在或可能不存在的值得方法。
记住,用户没必要要求延迟操作,或者根本不用考虑这些;库会安排这一切,并保证正确和尽可能小的计算。
6,并行
流管道既可以被串行执行,也可以被并行执行;这一选择是流的一个属性。除非你显示的请求一个并行流,否则JDK实现总是返回一个串行流(parallel()方法可以将串行流转换为并行流。)
虽然并行总是显示的,但它不是侵入式的。通过在数据源上简单的调用parallelStream()方法,便可以使计算重量总和的例子以并行方式执行:
int sum = shapes.parallelStream()
.filter(s -> s.getColor() == BLUE)
.mapToInt(s -> s.getWeight())
.sum();
结果是同样计算的串行和并行表达式看起来非常的相似,但是并行执行依旧被清晰的标记为并行(而不是并行机械结构压倒代码)。
因为流数据源可能是不可变的集合,那么如果遍历数据源时被修改就有可能产生冲突。流操作通常是在底层数据源在被操作期间保持不变的情况下使用。这一条件通常是易于维护的;如果集合被限制在当前线程中,那么简单的确保传递给流操作的Lambda表达式不会改变流的数据源即可。(这一条件与目前在迭代集合时的限制有着本质上的不同;如果一个集合在被迭代时被修改了,大部分的实现会抛出ConcurrentModificationException
异常。)我们把这一需求当作‘无干扰’。
最好是避免传递给流方法的Lambda表达式的副作用。虽然一些副作用是安全的,像是打印输出值一类的调试语句,但是从这些Lambda中访问可变状态值可能会引起数据竞争或者其他奇怪的行为,因为Lambda可能会被多个线程同时执行,并且可能无法看到元素的自然顺序。无干扰不仅包括不干扰数据源,而且不干扰其他Lambda;当一个Lambda修改一个可变状态值,而另一个Lambda试图读取它时可能会产生这种干扰。
只要满足了无干扰的需求,我们就可以安全的执行并行操作并获得预期的结果了,即使数据源不是线程安全的,像是ArrayList。
7,例子
下面是JDK Class类中的一个代码片段(getEnclosingMethod()方法),它会循环便利所有声明的方法,匹配方法名称,返回类型,以及参数数量和类型。下面是原始代码:
for (Method m : enclosingInfo.getEnclosingClass().getDeclaredMethods()) {
if (m.getName().equals(enclosingInfo.getName()) ) {
Class<?>[] candidateParamClasses = m.getParameterTypes();
if (candidateParamClasses.length == parameterClasses.length) {
boolean matches = true;
for(int i = 0; i < candidateParamClasses.length; i++) {
if (!candidateParamClasses[i].equals(parameterClasses[i])) {
matches = false;
break;
}
}
if (matches) { // finally, check return type
if (m.getReturnType().equals(returnType) )
return m;
}
}
}
}
throw new InternalError("Enclosing method not found");
使用流,我们可以去除所有这些临时变量并且控制逻辑移到库中去。我们通过反射获得方法的列表,然后使用Arrays.stream将它转换为Stream,并且使用一些列的过滤器来排除那些不匹配名称,参数类型和返回值的方法。findFirst()的结果是一个Optional
return Arrays.stream(enclosingInfo.getEnclosingClass().getDeclaredMethods())
.filter(m -> Objects.equals(m.getName(), enclosingInfo.getName())
.filter(m -> Arrays.equals(m.getParameterTypes(), parameterClasses))
.filter(m -> Objects.equals(m.getReturnType(), returnType))
.findFirst()
.orElseThrow(() -> new InternalError("Enclosing method not found");
这个版本的代码更加的紧凑,可读,不易出错。
流操作对于集合上的特定查询时非常有效的。考虑一个假象的‘音乐库’应用,这个库中有一系列专辑,每个专辑有一个标题和一些列歌曲,每个歌曲有一个名字,艺术家,和评价等级。
考虑这样一个查询“找出至少有一个评价等级大于等于4的歌曲的专辑名称,并按名称排序”,我们可以这样去构造这一集合:
List<Album> favs = new ArrayList<>();
for (Album a : albums) {
boolean hasFavorite = false;
for (Track t : a.tracks) {
if (t.rating >= 4) {
hasFavorite = true;
break;
}
}
if (hasFavorite)
favs.add(a);
}
Collections.sort(favs, new Comparator<Album>() {
public int compare(Album a1, Album a2) {
return a1.name.compareTo(a2.name);
}});
我们可以使用流操作来简化这三个主要步骤---判断一个专辑中是否有歌曲等级至少是多少(anyMatch()),排序,以及将符合条件的专辑放入List中:
List<Album> sortedFavs =
albums.stream()
.filter(a -> a.tracks.anyMatch(t -> (t.rating >= 4)))
.sorted(Comparator.comparing(a -> a.name))
.collect(Collectors.toList());
Comparator.comparing()方法接受一个函数,该函数提取一个Comparable的排序键,并且返回一个以此键进行比较的Comparator(请看下面“Comparator 工厂”部分)