Part 2:使用Java SE8 Streams 处理数据
原文:Part 2: Processing Data with Java SE 8 Streams
通过组合Stream API中高级的操作(operation)来表达多样的数据处理过程。
在这个系列的第一部分,我们看到 stream 可以允许你用类似数据库那样的操作来处理集合。作为回顾,Listing 1 展示了使用 Stream API 如何只对交易额大的交易(transaction)的值进行求和。我们建立了一个由多个操作组成的程序链(pipeline),操作包括中间操作(filter,map)以及终端操作(reduce),如 Figure 1 所示。
int sumExpensive = transactions.stream()
.filter(t -> t.getValue() > 1000)
.map(Transaction::getValue)
.reduce(0, Integer::sum);
Listing 1
Figure 1
但这个系列的前一个部分没有研究下面两个操作:
- flatMap:一个组合了“map”和“flatten”操作的中间操作。
- collect::一个将各种各样方法作为入参的终端操作,这些方法被称为收集器(collectors),用于将一个stream中的元素归拢到一个总结性的结果中。
这两个操作在表达更复杂的查询中是有用的。例如,你可以组合 map,flatMap 和 collect 来表达一个单词 stream 中每个字母出现的个数,就像Listing2中那样。如果这个代码乍一看有点应接不暇,不要担心。这篇文章的目的就是更细致地去解释和探索这两种操作。
import static java.util.function.Function.identity;
import static java.util.stream.Collectors.*;
Stream<String> words = Stream.of("Java", "Magazine", "is", "the", "best");
Map<String, Long> letterToCount = words.map(w -> w.split(""))
.flatMap(Arrays::stream)
.collect(groupingBy(identity(), counting()));
Listing 2
Listing 2中的代码将会产生Listing 3中所给出的输出。令人很惊叹,不是吗?让我们开始探索flatMap和collect操作是怎样工作的。
[a:4, b:1, e:3, g:1, h:1, i:2, ..]
Listing 3
flatMap Operation
假设你想要找出一个文件中所有不同的单词,你会怎样做呢?
你也许会想那很简单,我们可以使用Files.lines(),因为它可以返回一个由文件行组成的stream,这是你在前篇文章中看到的。我们可以使用一个map()操作将每一行分成单词,最后,使用distinct()操作来删除冗余项目。一个期初的代码编写可能是Listing 4这样。
Files.lines(Paths.get("stuff.txt"))
.map(line -> line.split("\\s+")) // Stream<String[]>
.distinct() // Stream<String[]>
.forEach(System.out::println);
Listing 4
不幸的是,这样不是完全正确的。如果你运行这段代码,你会困惑地得到下面结果:
[Ljava.lang.String;@7cca494b
[Ljava.lang.String;@7ba4f24f
…
我们的初衷实际上是想打印出几个stream中的String类型元素的结果!现在出现的是什么?这个写法的问题在于传递给map的lambda返回了一个String类型的数组(String []),文件中的每一行都变成这样的一个数组,结果,由map方法返回的stream实际类型是Stream<String[]>。而我们实际上想要的是一个Stream<String>来表示单词stream。
幸运地是这个问题可以通过使用flatMap解决。让我们来看一看如何一步步地得到正确的结果。
首先,我们想要的是一个单词stream而不是一个数组stream。程序库里有一个叫做Arrays.stream()的方法,它可以将一个数组转成一个stream。请看Listing 5中的例子。
String[] arrayOfWords = {"Java", "Magazine"};
Stream<String> streamOfwords = Arrays.stream(arrayOfWords);
Listring 5
在我们之前的stream链中使用一下看看会发生什么(Listing 6)。结果依然
不正确,这是因为我们使用了一个stream 的list结束程序链(更确切地说是一个Stream<Stream<String>>)。我们是这样做的,首先把每一行转换成单词数组,然后又使用Arrays.stream()把每个数组转换成一个独立的stream。
Files.lines(Paths.get("stuff.txt"))
.map(line -> line.split("\\s+")) // Stream<String[]>
.map(Arrays::stream) // Stream<Stream<String>>
.distinct() // Stream<Stream<String>>
.forEach(System.out::println);
Listing 6
我们可以使用 flatMap 操作来修正这个问题,如 Listing 7 所示。使用 flatMap 方法可以达到用stream的内容替换每一个产生的数组的效果,而不是用一个stream去替换。换句话说,所有产生的独立的stream在使用flatMap(Arrays::stream)的时候被合并了或者说“被拍平”成一个单一的stream。Figure 2阐释了使用flatMap方法的效果。
Files.lines(Paths.get("stuff.txt"))
.map(line -> line.split("\\s+")) // Stream<String[]>
.flatMap(Arrays::stream) // Stream<String>
.distinct() // Stream<String>
.forEach(System.out::println);
Listing 7
Figure 2
简单地说,faltMap 让你用另一个stream替代原来stream中的每个值,然后把所有产生的stream壳去掉串成一个stream。
注意 flatMap是一个普遍的模式,你会在处理Optional或者是CompletableFuture的时候再次见到它。
collect Operation
现在让我们更细致地看一下collect method。在这个系列的第一部分我们所看到的操作不是会返回另一个stream(也就是说他们是中间操作)就是会返回一个值,像是一个boolean,一个int,或者一个Optional对象(也就是说,他们都是终端操作)。
collect 方法是一个终端操作,但是它有一点不同,因为你使用它将一个stream转换成了一个list。例如,得到所有大额交易的ID list,你可以使用Listing 8中那样的代码。
import static java.util.stream.Collectors.*;
List<Integer> expensiveTransactionsIds = transactions.stream()
.filter(t -> t.getValue() > 1000)
.map(Transaction::getId)
.collect(toList());
Listing 8
传递给collect操作的是一个类型为java.util.stream.Collector的对象。一个Collector对象做了什么呢?它十分重要地描述了按照怎样的形式将stream中的结果归拢成最终的结果。之前使用的工厂方法Collectors.toList()返回了一个Collector,描述怎样将一个stream归拢成一个list。已经有许多类似的内嵌Collectors可以使用。
Collecting a stream into other collections。例如使用 toSet() 你可以将一个stream转换成一个Set,这将会删除掉重复的元素。Listing 9中的代码展示了怎样生成一个由拥有大额交易的城市组成的集合。(注意:在所有以后的例子中,我们假设Collectors类的工厂方法都使用import static java.util.stream.Collectors.* 导入了。)
Set<String> cities = transactions.stream()
.filter(t -> t.getValue() > 1000)
.map(Transaction::getCity)
.collect(toSet());
Listing 9
注意上面程序并不会保证什么类型的Set被返回。但使用toCollection()你可以对此有更多地控制。例如,你可以通过传入一个HashSet的构造器来使其返回一个HashSet(See Listing 10)
Set<String> cities = transactions.stream()
.filter(t -> t.getValue() > 1000)
.map(Transaction::getCity)
.collect(toCollection(HashSet::new));
Listing 10
除了上面所说,还有其他的可以使用collect和collectors的场景。上面实际上是应用的一小部分。这里有一些你可以表达的例子:
- 按照货币来组织交易的清单,并对拥有该中货币所有交易的值进行求和(返回一个Map<Currency,Integer>)
- 将交易清单分成两组:大额的和非大额的(返回一个Map<Boolean,List<Transaction>>)
- 创建一个多级的组,例如按照城市来组织交易然后更进一步来对他们是大额的和非大额的进行分类(返回一个Map<String,Map<Boolean,List<Transaction>>>)
兴奋吗?很好,让我们来看一看使用Stream API和Collectors怎样表达这些查询。我们先以一个简单的例子开始,这个例子“总结”了一个stream:计算均值,最大值,以及一个stream的最小值。然后我们了解怎样表达简单的组别,最后我们了解怎样把collectors组合到一起来创造更强大的查询,例如多级组别。
Summarizing。让我们用一些简单的例子来暖暖场。你可以在之前的文章中看到使用reduce操作和使用原生的stream怎样计算元素数目,计算stream的最大值,最小值,以及平均值。有预先定义好的collectors让你直接那样使用就可以了。例如,你可以使用counting()来计算stream中有多少项,就像Listing 11中给出的。
long howManyTransactions = transactions.stream().collect(counting());
Listing 11
你可以使用summing Double(),summingInt(),和summingLong()来对一个Double,Int或者一个Long类型stream中的值求和。在Listing 12中,我们计算了总交易额。
int totalValue = transactions.stream().collect( summingInt(Transaction::getValue));
Listing 12
相似地,你可以使用averging Double(),averagingInt(),和averagingLong()来计算均值,就像Listing 13中那样。
double average = transactions.stream().collect( averagingInt(Transaction::getValue));
Listing 13
除此之外,通过使用maxBy()和minBy()你可以计算一个stream中元素的最大值和最小值。然而,有个陷阱:你需要定义stream中的每个元素的顺序以使其得能够对它们进行比较。这就是为什么maxBy和minBy带了一个Comparator类型的参数,如图Figure 3。
Figure 3
在Listing 14中的例子中,我们使用静态方法comparing(),这将从作为参数被传进来的方法中产生一个Comparator对象。这个函数被用于从stream的元素中抽象出一个比较的key。在这个例子中,我们通过使用一个交易值作为比较key来找到了最高交易额的交易。
Optional<Transaction> highestTransaction = transactions.stream()
.collect(maxBy(comparing(Transaction::getValue)));
Listing 14
也会有一个reducing() collector来允许你组合一个stream中的所有元素来重复的应用一项操作知道有了结果。这与你前面看到的reduce()方法在概念上是相似的。如,Listing 15用reduceing()展示了一种可替代的方式来对所有交易额进行求和。
int totalValue = transactions.stream().collect(reducing( 0, Transaction::getValue, Integer::sum));
Listing 15
reducing()带了三个参数:
- 一个初始值(如果stream是空的话将会返回这个值);在这里例子中,是0。
- 一个作用于stream中每个元素的函数;在这个例子中,我们抽象出了每笔交易的交易额。
- 一个用于组合前面抽象函数产生的两个值的操作;在这个例子中,我们对交易额进行了加操作。
你也许会说,“等一下;通过其他的stream方法我们已经可以那样做了,像是reduce(),max(),和min(),为什么你又向我展示这些呢?”你稍后将会认识到我们可以通过组合collectors来构造更复杂的查询(例如,分组加平均),所以了解这些内嵌的collectors是会带来方便的。
Grouping。一个常见的数据查询是通过使用一个属性来组织数据。例如,你可想要按照货币种类来组织交易。荣国显式迭代来组织这样的一个查询是有些痛苦的,就像你在Listing 16中看到的一样。
Map<Currency, List<Transaction>> transactionsByCurrencies = new HashMap< >();
for(Transaction transaction : transactions) {
Currency currency = transaction.getCurrency();
List<Transaction> transactionsForCurrency = transactionsByCurrencies.get(currency);
if (transactionsForCurrency == null) {
transactionsForCurrency = new ArrayList<>();
transactionsByCurrencies.put(currency, transactionsForCurrency);
}
transactionsForCurrency.add(transaction);
}
Listing 16
你需要创建一个Map用来盛装交易。然后你需要迭代交易列表并抽出每笔交易的货币。在向Map中添加一笔交易之前,你需要检查对应的货币的交易列表是否被创建,等等。这个过程让人觉得很丧,从根本上讲,因为我们想做的就是“用货币来组织交易”。为什么却要用到这么多代码?好消息是:有一个叫做groupingBy()的收集器,让我们以一个更简洁的方式来表达这样的用例。我们可以用Listing 17中的方式表达同一个查询,现在代码读取来和问题的叙述更接近了。
Map<Currency, List<Transaction>> transactionsByCurrencies = transactions.stream().collect(groupingBy( Transaction::getCurrency));
Listing 17
groupingBy() 工厂方法带一个函数的入参以用来抽象出用以对交易进行分类的key。我们称之为分类函数(classification function)。在这个例子中,我们传递了一个方法引用,Transaction::getCurrency,来达到用货币组织交易的目的。Figure 4 阐述了分组操作。
Figure 4
Partitioning。有另一个工厂方法叫做partioningBy(),它可以被视作一种特别的groupingBy()。它的入参是一个断言(也就是,一个返回值类型是boolean的函数),并按照stream中的元素是否满足该断言来分组。换句话说,分割一个交易stream就是把交易组织成Map<Boolean,List<Transaction>>这样的数据结构。例如,如果你想要把交易分成两个列表——贵的和便宜的——你可以使用Listing 18中那样的做法,这里的lambda t->t.getValue() > 1000是一个用来区分贵与不贵的断言。
Map<Boolean, List<Transaction>> partitionedTransactions = transactions.stream().collect(partitioningBy( t -> t.getValue() > 1000));
Listing 18
Composing collectors。如果你对SQL熟悉,你可能会知道你可以把GROUP BY和函数(例如COUNT(),SUM())组合到一起,来实现对交易按照货币进行分组并求和。所以,我们可以用Stream API做类似的事情吗?是的,实际上,有一个重载版本的groupingBy()方法,它还有另一个collector对象作为第二个参数。这个额外的collector用来确定怎样积聚使用groupingBy collector分类后的所有元素。
好吧,这听起来有点抽象,所以让我们看一个简单的例子。我们想要生成一个城市的Map,根据每个城市的交易进行求和(请看Listing 19)。这里,我们告诉groupingBy()使用函数getCity()作为分类函数。结果,Map的key将会是城市。我们正常期望使用groupingBy()返回一个List<Transaction>作为Map中每个key的值。
Map<String, Integer> cityToSum = transactions.stream().collect(groupingBy( Transaction::getCity, summingInt(Transaction::getValue)));
Listing 19
但这里我们传进去了一个额外的collector,summingInt(),它会对按照城市组织的交易值进行求和。作为结果,我们获得了一个Map<String,Integer>类型的Map,它映射了城市与该总交易值之间的关系。很酷,不是吗?仔细想一下:普通版本的groupingBy(Transaction::getCity)实际上是groupingBy(Transaction::getCity,toList())的缩写。
让我们看一下另一个例子。如果你想要生成一个城市最高交易值得映射呢?你也许已经猜到了我们可以使用前面举出的maxBy collector,就像Listing 20中那样。
Map<String, Optional<Transaction>> cityToHighestTransaction =
transactions.stream().collect(groupingBy(
Transaction::getCity, maxBy(comparing(Transaction::getValue))));
Listing 20
你可以看到Stream API的表达是丰富的;我们现在构建一些可以写的更简洁有趣的查询。你可以回顾一下处理一个集合迭代吗?
让我们实践更复杂一点的例子。你已经看到了groupingBy可以带另一个collector对象作为入参来根据更深层的分类积聚。因为groupingBy它自己是一个collector,我们可以通过传递另一个groupingBy collector来创建多级分组,该collector定义了第二个用于对stream中元素进行分类的判定。
在Listing 21中,我们使用城市对交易进行分组,然后更进一步对分组之后的交易按照货币再分组并获得对于货币种类的交易均值。Figure 5阐述了机制。
Map<String, Map<Currency, Double>> cityByCurrencyToAverage =
transactions.stream().collect(groupingBy(Transaction::getCity,
groupingBy(Transaction::getCurrency,
averagingInt(Transaction::getValue))));
Listing 21
Figure 5
**创建属于你的collector。**目前为止所列出的所有的collector实现了java.util.stream.Collector接口。这意味着你可以实现你自己的collector来定义定制的简化操作。然而,这又是另一篇文章了,这里不谈。
Conclusion
在这篇文章中,我们探索了Stream API中两个先进的操作:faltMap和collect。它们是你可以添加到你的装备库中的用于表达丰富数据过程查询工具。
特别地,你已经看到了collect方法可以用于表达总结,分组,分割操作。额外的,这些操作可以被组合来构建更丰富的查询,例如“生成一个关于每个城市每种货币对应的交易均值的二级Map”。
但这篇文章没有研究所有可用的内嵌collector。我们邀你去看看Collectors类,并试一试其他的collectors,例如mapping(),joining()以及collecting AndThen(),也许你会觉得它们有用。