3.3 常用的流操作
为了更好地理解Stream API,掌握一些常用的Stream操作十分必要。除此处讲述的几种重要操作之外,该API的Javadoc中还有更多信息。
3.3.1 collect(toList())

collect(toList())方法由Stream里的值生成一个列表,是一个及早求值操作。
Stream的of方法使用一组初始值生成新的Stream。事实上,collect的用法不仅限于此,它是一个非常通用的强大结构,第5章将详细介绍它的其他用途。下面是使用collect方法的一个例子:
List<String> collected = Stream.of("a", "b", "c")➊.collect(Collectors.toList());➋assertEquals(Arrays.asList("a", "b", "c"), collected);➌
这段程序展示了如何使用collect(toList())方法从Stream中生成一个列表。如上文所述,由于很多Stream操作都是惰性求值,因此调用Stream上一系列方法之后,还需要最后再调用一个类似collect的及早求值方法。
这个例子也展示了本节中所有示例代码的通用格式。首先由列表生成一个Stream➊,然后进行一些Stream上的操作,继而是collect操作,由Stream生成列表➋,最后使用断言判断结果是否和预期一致➌。
形象一点儿的话,可以将Stream想象成汉堡,将最前和最后对Stream操作的方法想象成两片面包,这两片面包帮助我们认清操作的起点和终点。
3.3.2 map
如果有一个函数可以将一种类型的值转换成另外一种类型,map操作就可以使用该函数,将一个流中的值转换成一个新的流。
读者可能已经注意到,以前编程时或多或少使用过类似map的操作。比如编写一段Java代码,将一组字符串转换成对应的大写形式。在一个循环中,对每个字符串调用toUppercase方法,然后将得到的结果加入一个新的列表。代码如例3-8所示。
例3-8 使用for循环将字符串转换为大写
List<String> collected = new ArrayList<>();for (String string : asList("a", "b", "hello")) {String uppercaseString = string.toUpperCase();collected.add(uppercaseString);}assertEquals(asList("A", "B", "HELLO"), collected);
如果你经常实现例3-8中这样的for循环,就不难猜出map是Stream上最常用的操作之一(如图3-3所示)。例3-9展示了如何使用新的流框架将一组字符串转换成大写形式。
图3-3:map操作
例3-9 使用map操作将字符串转换为大写形式
List<String> collected = Stream.of("a", "b", "hello").map(string -> string.toUpperCase())➊.collect(toList());assertEquals(asList("A", "B", "HELLO"), collected);
传给map➊的Lambda表达式只接受一个String类型的参数,返回一个新的String。参数和返回值不必属于同一种类型,但是Lambda表达式必须是Function接口的一个实例(如图3-4所示),Function接口是只包含一个参数的普通函数接口。
图3-4:Function接口
3.3.3 filter
遍历数据并检查其中的元素时,可尝试使用Stream中提供的新方法filter(如图3-5所示)。
图3-5:filter操作
上面就是一个使用filter的例子,如果你已熟悉这一概念,也可以选择跳过本节。啊哈!您还没跳过本节?那太好了,我们一起来看看这个方法有什么用。假设要找出一组字符串中以数字开头的字符串,比如字符串"1abc"和"abc",其中"1abc"就是符合条件的字符串。可以使用一个for循环,内部用if条件语句判断字符串的第一个字符来解决这个问题,代码如例3-10所示。
例3-10 使用循环遍历列表,使用条件语句做判断
List<String> beginningWithNumbers = new ArrayList<>();for(String value : asList("a", "1abc", "abc1")) {if (isDigit(value.charAt(0))) {beginningWithNumbers.add(value);}}assertEquals(asList("1abc"), beginningWithNumbers);
你可能已经写过很多类似的代码:这被称为filter模式。该模式的核心思想是保留Stream中的一些元素,而过滤掉其他的。例3-11展示了如何使用函数式风格编写相同的代码。
例3-11 函数式风格
List<String> beginningWithNumbers= Stream.of("a", "1abc", "abc1").filter(value -> isDigit(value.charAt(0))).collect(toList());assertEquals(asList("1abc"), beginningWithNumbers);
和map很像,filter接受一个函数作为参数,该函数用Lambda表达式表示。该函数和前面示例中if条件判断语句的功能一样,如果字符串首字母为数字,则返回true。若要重构遗留代码,for循环中的if条件语句就是一个很强的信号,可用filter方法替代。
由于此方法和if条件语句的功能相同,因此其返回值肯定是true或者false。经过过滤,Stream中符合条件的,即Lambda表达式值为true的元素被保留下来。该Lambda表达式的函数接口正是前面章节中介绍过的Predicate(如图3-6所示)。
图3-6:Predicate接口
3.3.4 flatMap

flatMap方法可用Stream替换值,然后将多个Stream连接成一个Stream(如图3-7所示)。
图3-7:flatMap操作
前面已介绍过map操作,它可用一个新的值代替Stream中的值。但有时,用户希望让map操作有点变化,生成一个新的Stream对象取而代之。用户通常不希望结果是一连串的流,此时flatMap最能派上用场。
我们看一个简单的例子。假设有一个包含多个列表的流,现在希望得到所有数字的序列。该问题的一个解法如例3-12所示。
例3-12 包含多个列表的Stream
List<Integer> together = Stream.of(asList(1, 2), asList(3, 4)).flatMap(numbers -> numbers.stream()).collect(toList());assertEquals(asList(1, 2, 3, 4), together);
调用stream方法,将每个列表转换成Stream对象,其余部分由flatMap方法处理。flatMap方法的相关函数接口和map方法的一样,都是Function接口,只是方法的返回值限定为Stream类型罢了。
3.3.5 max和min
Stream上常用的操作之一是求最大值和最小值。Stream API中的max和min操作足以解决这一问题。例3-13是查找专辑中最短曲目所用的代码,展示了如何使用max和min操作。为了方便检查程序结果是否正确,代码片段中罗列了专辑中的曲目信息,我承认,这张专辑是有点冷门。
例3-13 使用Stream查找最短曲目
List<Track> tracks = asList(new Track("Bakai", 524),new Track("Violets for Your Furs", 378),new Track("Time Was", 451));Track shortestTrack = tracks.stream().min(Comparator.comparing(track -> track.getLength())).get();assertEquals(tracks.get(1), shortestTrack);
查找Stream中的最大或最小元素,首先要考虑的是用什么作为排序的指标。以查找专辑中的最短曲目为例,排序的指标就是曲目的长度。
为了让Stream对象按照曲目长度进行排序,需要传给它一个Comparator对象。Java 8提供了一个新的静态方法comparing,使用它可以方便地实现一个比较器。放在以前,我们需要比较两个对象的某项属性的值,现在只需要提供一个存取方法就够了。本例中使用getLength方法。
花点时间研究一下comparing方法是值得的。实际上这个方法接受一个函数并返回另一个函数。我知道,这听起来像句废话,但是却很有用。这个方法本该早已加入Java标准库,但由于匿名内部类可读性差且书写冗长,一直未能实现。现在有了Lambda表达式,代码变得简洁易懂。
此外,还可以调用空Stream的max方法,返回Optional对象。Optional对象有点陌生,它代表一个可能存在也可能不存在的值。如果Stream为空,那么该值不存在,如果不为空,则该值存在。先不必细究,4.10节将详细讲述Optional对象,现在唯一需要记住的是,通过调用get方法可以取出Optional对象中的值。
3.3.6 通用模式
max和min方法都属于更通用的一种编程模式。要看到这种编程模式,最简单的方法是使用for循环重写例3-13中的代码。例3-14和例3-13的功能一样,都是查找专辑中的最短曲目,但是使用了for循环。
例3-14 使用for循环查找最短曲目
List<Track> tracks = asList(new Track("Bakai", 524),new Track("Violets for Your Furs", 378),new Track("Time Was", 451));Track shortestTrack = tracks.get(0);for (Track track : tracks) {if (track.getLength() < shortestTrack.getLength()) {shortestTrack = track;}}assertEquals(tracks.get(1), shortestTrack);
这段代码先使用列表中的第一个元素初始化变量shortestTrack,然后遍历曲目列表,如果找到更短的曲目,则更新shortestTrack,最后变量shortestTrack保存的正是最短曲目。程序员们无疑已写过成千上万次这样的for循环,其中很多都属于这个模式。例3-15中的伪代码体现了通用模式的特点。
例3-15 reduce模式
Object accumulator = initialValue;for(Object element : collection) {accumulator = combine(accumulator, element);}
首先赋给accumulator一个初始值:initialValue,然后在循环体中,通过调用combine函数,拿accumulator和集合中的每一个元素做运算,再将运算结果赋给accumulator,最后accumulator的值就是想要的结果。
这个模式中的两个可变项是initialValue初始值和combine函数。在例3-14中,我们选列表中的第一个元素为初始值,但也不必需如此。为了找出最短曲目,combine函数返回当前元素和accumulator中较短的那个。
接下来看一下Stream API中的reduce操作是怎么工作的。
3.3.7 reduce
reduce操作可以实现从一组值中生成一个值。在上述例子中用到的count、min和max方法,因为常用而被纳入标准库中。事实上,这些方法都是reduce操作。
图3-8展示了如何通过reduce操作对Stream中的数字求和。以0作起点——一个空Stream的求和结果,每一步都将Stream中的元素累加至accumulator,遍历至Stream中的最后一个元素时,accumulator的值就是所有元素的和。
图3-8 使用reduce操作实现累加
例3-16中的代码展示了这一过程。Lambda表达式就是reducer,它执行求和操作,有两个参数:传入Stream中的当前元素和acc。将两个参数相加,acc是累加器,保存着当前的累加结果。
例3-16 使用reduce求和
int count = Stream.of(1, 2, 3).reduce(0, (acc, element) -> acc + element);assertEquals(6, count);
Lambda表达式的返回值是最新的acc,是上一轮acc的值和当前元素相加的结果。reducer的类型是第2章已介绍过的BinaryOperator。
4.2节将介绍另外一种标准类库内置的求和方法,在实际生产环境中,应该使用那种方式,而不是使用像上面这个例子中的代码。
表3-1显示了求和过程中的中间值。事实上,可以将reduce操作展开,得到例3-17这样形式的代码。
例3-17 展开reduce操作
BinaryOperator<Integer> accumulator = (acc, element) -> acc + element;int count = accumulator.apply(accumulator.apply(accumulator.apply(0, 1),2),3);
表3-1 reduce过程的中间值
| 元素 |
acc
| 结果 |
|---|---|---|
| N/A | N/A | 0 |
| 1 | 0 | 1 |
| 2 | 1 | 3 |
| 3 | 3 | 6 |
例3-18是可实现同样功能的命令式Java代码,从中可清楚看出函数式编程和命令式编程的区别。
例3-18 使用命令式编程方式求和
int acc = 0;for (Integer element : asList(1, 2, 3)) {acc = acc + element;}assertEquals(6, acc);
在命令式编程方式下,每一次循环将集合中的元素和累加器相加,用相加后的结果更新累加器的值。对于集合来说,循环在外部,且需要手动更新变量。
3.3.8 整合操作
Stream接口的方法如此之多,有时会让人难以选择,像闯入一个迷宫,不知道该用哪个方法更好。本节将举例说明如何将问题分解为简单的Stream操作。
第一个要解决的问题是,找出某张专辑上所有乐队的国籍。艺术家列表里既有个人,也有乐队。利用一点领域知识,假定一般乐队名以定冠词The开头。当然这不是绝对的,但也差不多。
需要注意的是,这个问题绝不是简单地调用几个API就足以解决。这既不是使用map将一组值映射为另一组值,也不是过滤,更不是将Stream中的元素最终归约为一个值。首先,可将这个问题分解为如下几个步骤。
找出专辑上的所有表演者。
分辨出哪些表演者是乐队。
找出每个乐队的国籍。
将找出的国籍放入一个集合。
现在,找出每一步对应的Stream API就相对容易了:
Album类有个getMusicians方法,该方法返回一个Stream对象,包含整张专辑中所有的表演者;使用
filter方法对表演者进行过滤,只保留乐队;使用
map方法将乐队映射为其所属国家;使用
collect(Collectors.toList())方法将国籍放入一个列表。
最后,整合所有的操作,就得到如下代码:
Set<String> origins = album.getMusicians().filter(artist -> artist.getName().startsWith("The")).map(artist -> artist.getNationality()).collect(toSet());
这个例子将Stream的链式操作展现得淋漓尽致,调用getMusicians、filter和map方法都返回Stream对象,因此都属于惰性求值,而collect方法属于及早求值。map方法接受一个Lambda表达式,使用该Lambda表达式对Stream上的每个元素做映射,形成一个新的Stream。
这个问题处理起来很方便,使用getMusicians方法获取专辑上的艺术家列表时得到的是一个Stream对象。然而,处理其他实际遇到的问题时未必也能如此方便,很可能没有方法可以返回一个Stream对象,反而得到像List或Set这样的集合类。别担心,只要调用List或Set的stream方法就能得到一个Stream对象。
现在或许是个思考的好机会,你真的需要对外暴露一个List或Set对象吗?可能一个Stream工厂才是更好的选择。通过Stream暴露集合的最大优点在于,它很好地封装了内部实现的数据结构。仅暴露一个Stream接口,用户在实际操作中无论如何使用,都不会影响内部的List或Set。
同时这也鼓励用户在编程中使用更现代的Java 8风格。不必一蹴而就,可以对已有代码渐进性地重构,保留原有的取值函数,添加返回Stream对象的函数,时间长了,就可以删掉所有返回List或Set的取值函数。清理了所有遗留代码之后,这种重构方式让人感觉棒极了!
