原文地址: http://cr.openjdk.java.net/~briangoetz/lambda/lambda-libraries-final.html
这是对OpenJDK Lambda(http://openjdk.java.net/projects/lambda/).项目 JSR 335 主要类库增强的非正式概述。在阅读这篇文章之前,我们建议你首先了解Java8的新特性,具体内容可以在State of the Lambda中找到,
背景
如果Lambda表达式最初就存在于Java中,类似Collections这样的API就会与现在截然不同。由于JSR 335会将Lambda表达式增加到Java中,这让Collections这样的接口显得更加过时!虽然从头开始构建一个全新的集合框架(Collections Framework)这个想法十分具有诱惑力,但是集合类接口贯穿于整个Java生态系统,想要完全替换掉它们可能需要很长时间。因此我们采用了循序渐进的策略,为现有的接口(比如Collection, List和Iterable)增加了拓展方法,添加了一个流(比如 java.util.stream.Stream)的抽象 (stream abstraction)用于数据集的聚合操作(aggregate operations),改进现有的类来提供流视图(stream views),引入新的语法可以让人们不通过ArrayLists和HashMaps类来进行相应操作。(这并不是说Collections这样的辅助类永远不会被替代,很显然除了设计不符合Lambda以外,它还有更多其他的限制。一个更合适的集合类框架可能需要考虑到JDK未来版本的变化和趋势)
这个项目的一个核心目的是使并行化编程更加容易。虽然Java 已经提供了对并发和并行的强大支持,但是开发者仍然在需要将串行代码迁移至并发时面对着不必要的障碍。因此,我们提倡一种无论在串行还是并行下都十分友好的语法和编程习惯。我们通过将关注点从"怎么进行代码计算"转移到"我们要计算什么"达到这目的。而且我们要在并行的易用性和可见性中找到一个平衡点,达到一个清晰(explicit )但是不突兀(unobstrusive)的并行化是我们的最终目标。(使并行对用户完全透明会导致很多不确定性,也会带来用户意想不到的数据竞争)
内部 vs 外部迭代(iteration)
Collections框架依赖于外部迭代的概念,提供通过实现Iterable接口列举出它的元素的方法,用户使用这个方法顺序遍历集合中的元素。例如,如果我们有一个形状(shape)的集合类,然后想把里面每一个形状都涂成红色,我们会这么写:
for (Shape s : shapes) {
s.setColor(RED);
}
这个例子阐述了什么是外部迭代,这个for-each循环直接调用shapes的iterator方法,依次遍历集合中元素。外部遍历非常直接了当,不过也有一些问题:
1) Java的for循环本身是连续的,必须按照集合定义的顺序进行操作
2) 它剥夺了类库对流程控制的机会,我们本有可能通过重排序,并行化,短路操作(short-circuiting)和惰性求值(laziness)来获得更好的性能。
注:惰性求值可以参考 https://hackhands.com/lazy-evaluation-works-haskell/
有时候,我们希望利用for循环带来的好处(连续并且有序),但是大部分情况下它妨碍了性能的提升。
另一种替代方案是内部迭代,它并不控制迭代本身,客户端将控制流程委托给类库,将代码分片在不同的内核进行计算。
和上面对应的内部迭代的例子如下:
shapes.forEach(s -> s.setColor(RED));
从语法上看差别似乎并不大,实际上他们有着巨大的差异。操作的控制权从客户端转移到了类库之中,不但可以抽象出通用的控制流程操作,还可以使用惰性求值,并行化和无序执行来提高性能(无论这个forEach的实现是否利用了这些特性,至少决定权在实现本身。内部迭代提供了这种可能性,但是外部迭代不可能做到这一点)。
外部迭代将"什么"(将形状涂成红色)和"怎么做"(拿到迭代器来顺序迭代)混在一起。内部迭代使客户端决定"什么",让类库来控制"怎么做"。这样有几个潜在的好处:
- 客户端代码可以更加清晰,因为只需要关注解决问题本身,而不是通过什么形式来解决问题。
- 我们可以把复杂的代码优化移至类库中,所有用户都可从中受益。
流 (Streams)
我们在Java8中引入了一个新的关键的类库"stream", 定义在java.util.stream包中。(我们有不同的Stream类型, Stream<T> 代表了引用类型是object的流,还有一些定制化的流比如IntStream来描述原始类型的流) 流代表了值的序列,并且暴露(expose)了一系列的聚合操作,允许我们很轻松并且清晰的对值进行通用的操作。对于获取集合,数组以及其他数据源的流视图(stream view),类库提供了非常便捷的方式。
流操作被链接在一起至"管道"(pipeline)中。例如,如果我们只想把蓝色的形状涂成红色,我们可以这样:
shapes.stream()
.filter(s -> s.getColor() == BLUE)
.forEach(s -> s.setColor(RED));
Collection的stream方法产生了一个集合所有元素的流视图,filter操作接着产生了一个只含有蓝色形状的流,我们再通过forEach方法将其涂成红色。
如果我们想把蓝色的形状收集到一个新的List当中,我们可以这样:
List<Shape> blue = shapes.stream()
.filter(s -> s.getColor() == BLUE)
.collect(Collectors.toList());
collect操作将输入的元素收集到一个聚合体(aggregate, 比如List)或者一个总结概述(summary description)中。collection中的参数表示应当如何进行聚合。在这里,我们用了了toList,这只是一个简单的把元素聚合到一个List里的方法(更多细节请参照“Collectors”章节)。
如果每个形状都在一个Box里面,并且我们想知道哪些Box至少包含一个蓝色的形状,我们可以这样:
Set<Box> hasBlueShape = shapes.stream()
.filter(s -> s.getColor() == BLUE)
.map(s -> s.getContainingBox())
.collect(Collectors.toSet());
map操作产生了一个流,这个流的值由输入元素的映射(这里返回的是包含蓝色形状的Box)产生。
如果我们想计算出蓝色形状的总重量,我们可以这样:
int sum = shapes.stream()
.filter(s -> s.getColor() == BLUE)
.mapToInt(s -> s.getWeight())
.sum();
至此,我们还没有提供以上Stream操作的具体签名的详细信息; 这些例子仅仅是为了阐述设计Streams框架想要解决的问题。
流(Streams) vs集合(Collections)
流和集合尽管具有表面上的相似之处,但是他们设计的目标完全不同。集合主要关注在有效的管理和访问它的元素。与之相反,流并不提供直接访问或者操作它的元素的方法,而是关注对执行在聚合数据源的计算操作的声明式描述(declaratively describing)。
因此,流和集合主要有以下几点不同:
1) 没有存储。流不存在值的存储,而是通过有着一系列计算步骤的管道来承载数据源(可以是数据结构,可以是生成的函数,可以是I/O通道等等)中的值
2) 函数式本质。对于流的操作产生的结果并不改变它基本的数据源。
3) 惰性倾向。很多流操作(例如过滤,映射,排序或者去重)都可以惰性实现。这一点有助于整个管道的single-pass执行,也有助于高效的实现短路操作
4) 边界不限定。很多问题我们可以转换为无限流(infinite stream)的形式,用户可以一直使用流中的数据,直到满意为止(比如完全数问题就可以轻易的转换为对所有整数的过滤操作),而集合类则是有限的。(如果需要在有限的时间内终止一个无限流,我们可以使用短路操作,或者可以在流中直接调用一个迭代器进行手动遍历)
作为一个API, 流和集合类之间完全独立。因此我们可以很轻易地把一个集合作为流的数据源(集合有stream和parallelStream 方法)或者把流中的数据转储(dump)到一个结合中(使用collect操作),Collection以外的聚合体也可以作为流中的数据源。很多JDK中的类,例如BufferedReader, Random, 和 BitSet已经被改进,也可以作为流的数据源。Arrays的stream方法提供了一个数组的流视图。事实上,任何可以用Iterator描述的类都可以作为流的数据源。如果提供了更多的信息(例如大小或者排序信息),类库可以提供优化的执行。
惰性求值(Laziness)
类似filter或者mapping这种的操作可以是"急性"(在filter方法返回之前对所有元素进行filter)或者"惰性"(只去按需过滤数据源中的元素)的。惰性计算可以给我们带来潜在收益,比如我们可以将filter和管道中的其他操作融合,以免进行多次数据传递。与此类似,如果我们在一个大的数据集合中根据某些条件寻找第一个元素,我们可以在找到以后立刻停止而不是处理整个数据集(对于有界的数据,源惰性求值仅仅是一个优化措施。但是它使对无界数据源的操作成为了可能,而如果采用"急性"的方式,那我们永远停不下来)。
无论采用怎样的实现方式,像filter或者mapping这样的操作可以被认为是"天然的惰性"。另一方面,求值运算如sum, "副作用运算"(side-effect-producing)如forEach是"天然的急性",因为他们必须生成一个具体的值。
在如下的一个管道中 :
int sum = shapes.stream()
.filter(s -> s.getColor() == BLUE)
.mapToInt(s -> s.getWeight())
.sum();
filter和mapping操作是惰性的。这意味着直到开始sum操作时我们才会从数据源中取值。并且执行sum操作时我们会把filter,mapping合并使数据只被传递一次。这使得我们减少了管理中间变量所需的记账(bookkeeping)消耗。
很多循环可以被重新描述为从数据源获取数据的聚合操作,先进行一系列的惰性操作(filter, mapping...)然后再执行一个急性操作(forEach, toArray,collect...),比如 filter-map-accumulate或者filter-map-sort-foreach。天然惰性的操作适合用于计算临时中间结果,我们在API设计的时候利用了这个特点,filter和map返回了一个新的stream而不是一个collection。
在Stream API中, 返回一个stream的操作是惰性的,返回一个非stream或者没有返回值的操作是急性的。大多数情况下,潜在的惰性操作应用于聚合上,这也是我们希望看到的 -- 每个阶段都会获取一个输入流,对其进行一些转换,然后将值传递到管道的下一阶段。
在source-lazy-lazy-eager 这种管道中,惰性大多不可见,因为计算过程夹在source和生成结果的操作之间。在规模相对小的API中会有很好的可用性和不错的性能。
一些急性方法,例如anyMatch(Predicate)或者findFirst同样也可以用来进行短路操作,只要他们能确定最终结果,执行就可以被结束。例如我们有以下管道
Optional<Shape> firstBlue = shapes.stream()
.filter(s -> s.getColor() == BLUE)
.findFirst();
因为filter这一步是惰性的,因此findFirst只有在获得一个元素以后才会把它从上游取出。这意味着我们只需要在输入(filter)应用predicate直至找到一个predicate的结果是true的元素,而不需要在所有的元素应用predicate。findFirst方法返回了一个Optional,因为有可能所有元素都不满足条件。Optional描述了一个可能存在的值。
用户其实无需在意惰性,类库已经做好了必要的事情来精简运算。
并行化
管道流可以选择串行或并行执行,除非显式调用并行流,JDK默认实现返回一个串行流(串行流可以通过parallel方法转化为并行流)。
之前重量累加的方法可以直接通过调用parallelStream方法使其变成并行流。
int sum = shapes.parallelStream()
.filter(s -> s.getColor() == BLUE)
.mapToInt(s -> s.getWeight())
.sum();
虽然对于同样的计算,串行和并行看起来十分类似,但是并行流明确表示了它是并行的(我们并不需要像以前那样为并行而写一大堆代码)。
stream的数据源可能是可变(mutable)集合,遍历的过程中数据源被修改的可能性是存在的。流则期望在操作过程中,数据源能保持不变。如果数据源只被一个线程使用,我们只需保证输入的Lambda不会更改数据源(这和外部迭代的限定是一样的,大部分会抛出ConcurrentModificationException)。我们将这个要求称为不可干扰。
最好避免传入stream中的Lambda表达式带来任何"副作用"(side-effects)。虽然一些副作用比如打印一些值进行调试通常是线程安全的,但是从Lambda中获取可变(mutable)变量可能会引起数据竞争(data racing)。这是因为一个Lambda有可能在多个线程内被执行,对于数据的执行顺序并不一定是他们看起来的顺序。不可干扰不仅针对数据源,同样也指 不能干扰其它的Lambda,例如在一个Lambda对一个可变数据源进行修改的时候,另外一个Lambda需要读取它。
只要不可干扰这个条件满足,即使对非线程安全的数据源(比如ArrayList),我们也可以安全的进行并行操作。
举例说明
以下是JDK中 Class这个类 getEnclosingMethod 方法的一部分,它遍历了所有声明的(declared)方法,匹配方法名,返回方法类型,方法个数和参数类型。
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");
如果使用stream,我们可以消除临时变量并且把控制流程置于类库中。我们通过反射获得方法列表,通过Arrays.stream把它转换为一个Stream,然后使用一系列filter过滤掉名字,参数类型和返回类型不匹配的方法。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");
这个版本的代码更为紧凑,可读性强而且不容易出错。
流操作对于集合的临时查询(ad hoc queries)十分有效。假设我们有一个"音乐库"的应用,其中有一系列的专辑,专辑又有它的名字和一系列歌曲,每首歌曲又有它的名字,作者和评分。
假设我们需要找到所有评价在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);
}});
如果使用流操作,我们只需要3个主要步骤:
- 在专辑中是否存在评价在4星以上的歌曲
- 对专辑进行排序
- 将满足条件的专辑放到一个列表中
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 方法利用了一个Lambda返回的可比较的key的方法,返回一个比较器来做比较 (详细内容请参照"比较器工厂"章节)
收集器(Collectors)
目前为止出现的例子中,我们使用collect方法,传入一个Collector参数,把stream中的元素收集至一个List或者Set这样的数据结构中。Collectors这个类包含了很多通用collector的工厂方法,toList和toSet是最常用的两种,此外还有很多更复杂的对数据转换的方法。
收集器通过输入和输出类型进行参数化。toList的输入类型是T,输出类型是List<T>。稍微复杂一点的Collector是toMap,有几个不同的版本。最简单的版本是利用一对(pair)函数,一个把输入映射为map中的key,另外一个把其映射为value。输入参数是一个T,最后生成map<K,V>, K和V分别是之前提到的映射函数产生的结果(更复杂的版本允许自定义生成结果的类型,或者解决映射过程中出现重复的key的情况)。例如,有一组有唯一的key(CatalogNumber)的数据,我们需要根据他生成反向索引:
Map<Integer, Album> albumsByCatalogNumber =
albums.stream()
.collect(Collectors.toMap(a -> a.getCatalogNumber(), a -> a));
跟map相关的是groupingBy。假设我们想根据作者来列出我们喜欢的曲目,我们想要一个Collector, 歌曲(Track)是入参,生成一个Map<Artist,List<Track>>,这个需求和最简单的具有groupingBy的collector恰好匹配,这个collector利用一个分类函数(classification function)生成一个map,它的值是一个对应生成的key的List。
Map<Artist, List<Track>> favsByArtist =
tracks.stream()
.filter(t -> t.rating >= 4)
.collect(Collectors.groupingBy(t -> t.artist));
Collectors可以组合和重用产生复杂的收集器。最简单的groupingBy收集器根据分类函数将元素分组并放入桶(bucket)中,然后再把映射到同一个桶中的元素放入一个List里面。对于使用收集器来组织桶中的元素,我们有一个更通用的版本。我们将分类函数和下游收集器作为参数,依据分类函数分到同一个桶的所有元素都会传递给下游收集器。(一个参数的groupingBy方法隐式使用toList方法作为下游收集器)。例如我们如果想把每个作者相关的歌曲收集到Set而不是List中,我们可以使用toSet:
Map<Artist, Set<Track>> favsByArtist =
tracks.stream()
.filter(t -> t.rating >= 4)
.collect(Collectors.groupingBy(t -> t.artist,
Collectors.toSet()));
如果我们想根据评分和作者创建一个多层的map,我们可以这样:
Map<Artist, Map<Integer, List<Track>>> byArtistAndRating =
tracks.stream()
.collect(groupingBy(t -> t.artist,
groupingBy(t -> t.rating)));
最后一个例子,假设我们想得到在曲目标题中单词的出现频率的分布。首先可以使用Stream.flatMap和Pattern.splitAsStream拿到曲目的流,把曲目的名字分解成单词,再生成一个单词的流。然后可以使用groupingBy函数,传入String.toUpperCase作为分类函数(这里我们忽略单词的大小写)并且使用counting收集器作为下游收集器来统计每个单词的出现频率(这样我们不需要创建中间集合):
Pattern pattern = Pattern.compile(\\s+");
Map<String, Integer> wordFreq =
tracks.stream()
.flatMap(t -> pattern.splitAsStream(t.name)) // Stream<String>
.collect(groupingBy(s -> s.toUpperCase(),
counting()));
flatMap方法将一个把输入元素映射到流中的函数作为参数,它将这个函数应用到每个输入元素中,使用生成的流的内容替换每个元素(这里我们认为有两个操作,首先将流中的每个元素映射到零个或者多个其他元素的流中, 然后把所有的结果扁平化到一个流当中)。因此flapMap的结果是一个包含所有曲目中不同单词的流。然后把单词进行分组放入桶中,再用counting收集器来获得桶中单词出现的次数。
Collector这个类有很多方法构建collector,可用于常见的查询,汇总和列表,你也可以实现你自己的Collector。
隐藏的并行(Parallelism under the hood)
Java7中新增了Fork/Join框架,提供了一个高效并行计算的API。然而Fork/Join框架看起来与等效的串行代码完全不同,这妨碍了并行化的实现。串行和并行流的操作完全一样,用户可以轻松在串行/并行之间切换而不需要重写代码,这使得并行化更容易实施而且不易出错。
通过递归分解实现并行计算的步骤是:将问题分解为子问题,顺序解决并产生部分结果,然后将两个部分结果组合。Fork/Join框架用来设计自动完成以上过程。
为了支持在任何数据源的流上的全部操作,我们使用一个称为Spliterator的抽象方式将流的数据源模块化,它是传统迭代器的泛化(generalization)。除了支持对数据元素的顺序访问以外,Spliterator还支持分解(decomposition)功能:类似于迭代器可以剥离单个元素并保留其余元素,Spliterator可以剥离一个更大的块(通常是一半)把它放入一个新的Spliterator中,把剩下的元素保留在原来的Spliterator中(这两个Spliterator还可以进行进一步的分解)。此外,Spliterator可以提供数据源的元数据比如元素的数量或者一组boolean(比如元素是否被排序), Stream框架可以通过这些元数据进行优化执行。
这种方式把递归分解的结构特性和算法分离,而且对于可分解的数据可以并行执行。数据结构的作者只需要提供数据分解的逻辑,就可以立即在stream上并行执行提高效率。
大多数用户不需要实现Spliterator, 只需要在现有的集合上使用stream等方法。但是如果你需要实现一个集合类或者其他stream的数据源,你可能需要自定义Spliterator。Spliterator的API如下:
public interface Spliterator<T> {
// Element access
boolean tryAdvance(Consumer<? super T> action);
void forEachRemaining(Consumer<? super T> action);
// Decomposition
Spliterator<T> trySplit();
// Optional metadata
long estimateSize();
int characteristics();
Comparator<? super T> getComparator();
}
基础接口比如Iterable和Collection提供了正确但是低效的spliterator实现,但是子接口(比如Set)或者实现类(比如ArrayList) 利用基础接口无法获得的一些信息复写了spliterator,使其更加高效。spliterator实现的质量会影响stream执行的效率,返回一个比较均衡分割结果的split方法会提高CPU利用率,如果能提供正确的元数据也会对优化提供帮助。
出现顺序(Encounter Order)
很多数据源例如lists,arrays和I/O channel有其自带的出现顺序,这意味着元素出现的顺序很重要。其他例如HashSet没有定义出现顺序(因此HashSet的迭代器可以处理任意顺序的元素)
由Spliterator纪录并且应用在stream的实现中的特征之一便是stream是否定义了出现顺序。除了几个特例(比如Stream.forEach 和Stream.findAny),并行操作受到出现顺序的限制,这意味着在以下stream管道中:
List<String> names = people.parallelStream()
.map(Person::getName)
.collect(toList());
结果中names的顺序必须和输入流中的顺序一致。通常情况下,这是我们想要的结果,而且对很多流操作而言,存储这个顺序代价并不大。如果数据源是一个HashSet,那么结果中的names可以以任意顺序出现,而且在不同的执行中的顺序也会不一样。
JDK中的流和Lambda
我们希望通过把Stream的抽象级别提高使得它的特性尽可能广泛应用于JDK中。Collection已经增加了stream和parallelStream方法来把集合转换成流,数组可以使用Arrays.stream方法进行转换。
此外,Stream中有静态工厂方法来创建流,比如Stream.of, Stream.generate和IntStream.range。还有很多其他的类也增加了Stream相关的方法,比如BufferedReader.lines, Pattern.splitAsStream, Random.ints, 和 BitSet.stream。
最后,我们提供了一组构建流的API给希望在非标准聚合(non-standard aggregates)上使用stream功能的类库作者。创建流所需的最小"信息"是一个迭代器,如果可以额外提供元数据(比如size),JDK在实现Spliterator的时候会更有效率(就像现有的集合类那样)
比较器工厂(Comparator factories)
Comparator 这个类已经添加了一些对于构建比较器十分有用的新方法。
Comparator.comparing这个静态方法利用了一个提取可比较(Comparable)key并且生成一个比较器的方法,实现如下:
public static <T, U extends Comparable<? super U>> Comparator<T> comparing(
Function<? super T, ? extends U> keyExtractor) {
return (c1, c2)
-> keyExtractor.apply(c1).compareTo(keyExtractor.apply(c2));
}
以上方法是一个"高阶函数"(higher order functions)的例子 --- 高阶函数指至少满足一下一个条件的函数:
1) 接收一个或者多个函数作为输入
2) 输出一个函数
利用这个comparing我们可以减少重复,简化客户端代码,例子如下:
List<Person> people = ...
people.sort(comparing(p -> p.getLastName()));
这个比老方法清晰很多,通常包含了一个实现了Comparator的匿名内部类实例。但是这种方法真正牛逼的地方在于提高了"组合性"。比如Comparator有一个默认方法来颠倒顺序,所以我们如果想以姓的逆序进行排列,我们可以创建和之前一样的comparator,然后让它进行逆序:
people.sort(comparing(p -> p.getLastName()).reversed());
类似的是,当初始比较器认为两个元素一样的时候,thenComparing这个默认方法允许你获得比较器并且改进它的行为。如果要我们根据名+姓排序的话,我们可以这样:
Comparator<Person> c = Comparator.comparing(p -> p.getLastName())
.thenComparing(p -> p.getFirstName());
people.sort(c);
可变集合操作(Mutative collection operations)
集合的Stream操作产生了一个新值,集合或者副作用。然而有时我们想对集合进行直接修改,我们在Collection,List和Map中引入了一些新方法来利用Lambda达到目的。比如terable.forEach(Consumer), Collection.removeAll(Predicate), List.replaceAll(UnaryOperator), List.sort(Comparator), 和 Map.computeIfAbsent()。此外,我们也把ConcurrentMap中的一些方法例如replace和putIfAbsent增加了非原子操作的版本放进了Map中。
总结
虽然引入Lambda是一个巨大的进度,但是开发者依旧每天使用核心库完成工作。所以语言的进化和库的进化需要结合在一起,这样用户就可以第一时间使用这些新特性。流的抽象化是库的新特性的核心,提供了在数据集上进行聚合操作的强大功能,并且和现有的集合类们紧密集成在了一起。
网友评论