Java8函数式编程_3--流(Stream)

1,免责声明,本文大部分内容摘自《Java8函数式编程》。在这本书的基础上,根据自己的理解和网上一些博文,精简或者修改。本次分享的内容,只用于技术分享,不作为任何商业用途。当然这本书是非常值得一读,强烈建议买一本!
2,本次分享的样例代码均上传到github上,请点击这里


第3章 流 (Stream)

Java 8中新增的特性旨在帮助程序员写出更好的代码,其中对核心类库的改进是很关键的一部分,也是本章的主要内容。对核心类库的改进主要包括 集合类的 API新引入的流 (Stream)。流使程序员得以站在更高的抽象层次上对集合进行操作。
本章会介绍 Stream 类中的一组方法,每个方法都对应集合上的一种操作。

注意:本章所有的例子大多围绕 1.3 节介绍的案例展开(音乐)。

3.1 从外部迭代到内部迭代

Java 程序员在使用集合类时,一个通用的模式是在集合上进行迭代,然后处理返回的每一个元素。比如要计算从伦敦来的艺术家的人数,通常代码会写成例 3-1 这样。

// 例 3-1, 使用 for 循环计算来自中国香港的艺术家人数
int count = 0;
for (Artist artist : allArtists) {
	if (artist.isFrom("中国香港")) { 
		count++;
	} 
}

尽管这样的操作可行,但存在几个问题。每次迭代集合类时,都需要写很多样板代码。

此外,**上述代码无法流畅传达程序员的意图。for 循环的样板代码模糊了代码的本意,**我们必须阅读整个循环体才能理解。 若是单一的 for 循环,倒也问题不大,但面对一个满是循环(尤其是嵌套循环)的庞大代码库时,负担就重了。

就其背后的原理来看,**for 循环其实是一个封装了迭代的语法糖,**我们在这里多花点时间, 看看它的工作原理。首先调用 iterator 方法,产生一个新的 Iterator 对象,进而控制整个迭代过程,这就是外部迭代。迭代过程通过显式调用 Iterator 对象的 hasNext 和 next 方法完成迭代。展开后的代码如例 3-2 所示,图 3-1 展示了迭代过程中的方法调用。

// 例 3-2 使用迭代器计算来自中国香港的艺术家人数
int count = 0;
Iterator<Artist> iterator = allArtists.iterator(); 
while(iterator.hasNext()) {
	Artist artist = iterator.next(); 
	if (artist.isFrom("中国香港")) {
		count++; 
	}
}

外部迭代
// 图3-1:外部迭代

然而,外部迭代也有问题。首先,它很难抽象出本章稍后提及的不同操作。此外,它从本质上来讲是一种串行化操作。总体来看,使用 for 循环会将行为和方法混为一谈。


另一种方法就是内部迭代,如例 3-3 所示。首先要注意 stream() 方法的调用(该方法是Java8新增加的函数),它和例 3-2 中调用 iterator() 的作用一样。该方法不是返回一个控制迭代的 Iterator 对象,而是返回内部迭代中的相应接口:Stream。

// 例 3-3 使用内部迭代计算来自中国香港的艺术家人数
long count = allArtists.stream()
						.filter(artist -> artist.isFrom("中国香港"))
						.count();

图 3-2 展示了使用类库后的方法调用流程,与图 3-1 形成对比。
内部迭代
//图 3-2: 内部迭代

Stream是用函数式编程方式在集合类上进行复杂操作的工具。

上例 3-3 可被分解为两步更简单的操作:

  • 找出所有来自中国香港的艺术家;
  • 计算他们的人数。

每种操作都对应 Stream 接口的一个方法。为了找出来自香港的艺术家,需要对 Stream 对象进行过滤:filter。过滤在这里是指“只保留通过某项测试的对象”。filter由一个函数完成,根据艺术家是否来自中国香港,该函数返回true或false。由于Stream API的函数式编程风格,我们并没有改变集合的内容,而是描述出 Stream 里的内容。count() 方法计算给定 Stream 里包含多少个对象。

3.2 实现机制

例 3-3 中,整个过程被分解为两种更简单的操作:过滤和计数,看似有化简为繁之嫌—— 例 3-1 中只含一个 for 循环,两种操作是否意味着需要两次循环?事实上,类库设计精妙, 只需对艺术家列表迭代一次。

通常,在 Java 中调用一个方法,计算机会随即执行操作。比如,System.out.println (“Hello World”); 会在终端上输出一条信息。**Stream 里的一些方法却略有不同,它们虽是普通的 Java 方法,但返回的 Stream 对象却不是一个新集合,而是创建新集合的配方。**现在,尝试思考一下例 3-4 中代码的作用,一时毫无头绪也没关系,稍后会详细解释。

// 例 3-4 只过滤,不计数
allArtists.stream()
          .filter(artist -> artist.isFrom("中国香港"));

这行代码并未做什么实际性的工作,filter 只刻画出了 Stream,但没有产生新的集合。像 filter 这样只描述 Stream,最终不产生新集合的方法叫作惰性求值方法;而像 count 这样 最终会从 Stream 产生值的方法叫作及早求值方法。

如果在过滤器中加入一条 println 语句,来输出艺术家的名字,就能轻而易举地看出其中的不同。例 3-5 对例 3-4 作了一些修改,加入了输出语句。运行这段代码,程序不会输出任何信息!

// 例 3-5 由于使用了惰性求值,没有输出艺术家的名字
allArtists.stream()
          .filter(artist -> {
            	 System.out.println(artist.getName());
				 return artist.isFrom("中国香港"); 
			 });

如果将同样的输出语句加入一个拥有终止操作的流,如例 3-3 中的计数操作,艺术家的名字就会被输出(见例 3-6)。

// 例 3-6 输出艺术家的名字
long count = allArtists.stream()
					.filter(artist -> {
            	 		System.out.println(artist.getName());
				 		return artist.isFrom("中国香港"); 
			 		})
			 		.count();

运行上述程序,filter()内部的System.out.println(artist.getName());代码将会得到执行。

判断一个操作是惰性求值还是及早求值很简单:只需看它的返回值。 如果返回值是 Stream,那么是惰性求值;如果返回值是另一个值或为空,那么就是及早求值。使用这些操作的理想方式就是形成一个惰性求值的链,最后用一个及早求值的操作返回想要的结果,这正是它的合理之处。计数的示例也是这样运行的,但这只是最简单的情况:只含两步操作。
整个过程和建造者模式有共通之处。建造者模式使用一系列操作设置属性和配置,最后调用一个 build 方法,这时,对象才被真正创建。

你一定会问:“为什么要区分惰性求值和及早求值?” **只有在对需要什么样的结果和操作有了更多了解之后,才能更有效率地进行计算。**例如,如果要找出大于 10 的第一个数字,那么并不需要和所有元素去做比较,只要找出第一个匹配的元素就够了。这也意味着可以在集合类上级联多种操作,但迭代只需一次。

3.3 常用的流操作

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

3.3.1 collect(toList())

**collect(toList()) 方法将 Stream 里的值生成一个列表,是一个及早求值操作。**下面是使用 collect 方法的一个例子:

// 例 3-7
List<String> collected = Stream.of("a", "b", "c") //Stream 的 of 方法使用一组初始值生成新的 Stream
							   .collect(Collectors.toList());  
assertEquals(Arrays.asList("a", "b", "c"), collected);  

这段程序展示了如何使用 collect(toList()) 方法从 Stream 中生成一个列表。如上文所述, 由于很多 Stream 操作都是惰性求值,因此调用 Stream 上一系列方法之后,还需要最后再调用一个类似 collect 的及早求值方法。

这个例子也展示了本节中所有示例代码的通用格式。首先由列表生成一个 Stream,然后进行一些 Stream 上的操作,继而是 collect 操作,由 Stream 生成列表,最后使用断言判断结果是否和预期一致。

形象一点儿的话,可以将 Stream 想象成汉堡,将最前和最后对 Stream 操作的方法想象成两片面包,这两片面包帮助我们认清操作的起点和终点。

collect 的用法不仅限于此,它是一个非常通用的强大结构,后期章节将详细介绍它的其它用途。

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);

map 是 Stream 上最常用的操作之一
(如图 3-3 所示)。例 3-9 展示了如何使用新的流框架将一组字符串转换成大写形式。
这里写图片描述
// 图3-3: map操作

// 例 3-9 使用流将字符串转换为大写
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

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 为空,那么该值不存在,如果不为空,则该值存在。先不必细究,后期章节将详细讲述 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 的值就是所有元素的和。

reduce
// 图 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/AN/A0
101
213
336

例 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 中的元素最终归约为一个值。首先,可将这个问题分解为如下几个步骤。

  1. 找出专辑上的所有表演者。
  2. 分辨出哪些表演者是乐队。
  3. 找出每个乐队的国籍。
  4. 将找出的国籍放入一个集合。

现在,找出每一步对应的 Stream API 就相对容易了:

  1. Album 类有个 getMusicians 方法,该方法返回一个 Stream 对象,包含整张专辑中所有的表演者;
  2. 使用 filter 方法对表演者进行过滤,只保留乐队;
  3. 使用 map 方法将乐队映射为其所属国家;
  4. 使用 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 的取值函数。清理了所有遗留代码之后,这种重构方式让人感觉棒极了!

3.4 重构遗留代码

为了进一步阐释如何重构遗留代码,本节将举例说明如何将一段使用循环进行集合操作的代码,重构成基于 Stream 的操作。重构过程中的每一步都能确保代码通过单元测试,当然你也可以自行实际操作一遍,体验并验证。

假定选定一组专辑,找出其中所有长度大于 1 分钟的曲目名称。例 3-19 是遗留代码,首先 初始化一个 Set 对象,用来保存找到的曲目名称。然后使用 for 循环遍历所有专辑,每次 循环中再使用一个 for 循环遍历每张专辑上的每首曲目,检查其长度是否大于 60 秒,如果是,则将该曲目名称加入 Set 对象。如下:

// 例子 3-19 遗留代码:找出长度大于 1 分钟的曲目
public Set<String> findLongTracks(List<Album> albums) { 
	Set<String> trackNames = new HashSet<>();
	
	for(Album album : albums) {
		for (Track track : album.getTrackList()) { 
			if (track.getLength() > 60) {
            	String name = track.getName();
            	trackNames.add(name);
       		}
		} 
	}
	
	return trackNames;
}

如果仔细阅读上面的这段代码,就会发现几组嵌套的循环。仅通过阅读这段代码很难看出它的编写目的,那就来重构一下(使用流来重构该段代码的方式很多,下面介绍的只是其中一种。事实上,对Stream API越熟悉,就越不需要细分步骤。之所以在示例中一步一步地重构,完全是出于帮助大家学习的目的,在工作中无需这样做)。

// 例3-20 重构的第一步:找出长度大于 1 分钟的曲目
public Set<String> findLongTracks(List<Album> albums) { 
	Set<String> trackNames = new HashSet<>(); 
	albums.stream()
          .forEach(album -> {
               album.getTracks()
					.forEach(track -> {
						if (track.getLength() > 60) {
							String name = track.getName();
							trackNames.add(name);
						}
 					}); 
 			});
	return trackNames; 
}

在重构的第一步中,虽然使用了流,但是并没有充分发挥它的作用。事实上,重构后的代码还不如原来的代码好!因此,是时候引入一些更符合流风格的代码了,最内层的 forEach 方法正是主要突破口。

最内层的 forEach 方法有三个功用:找出长度大于 1 分钟的曲目,得到符合条件的曲目名称,将曲目名称加入集合 Set。这就意味着需要三项 Stream 操作:找出满足某种条件的曲目是 filter 的功能,得到曲目名称则可用 map 达成,终结操作可使用 forEach 方法将曲目名称加入一个集合。用以上三项 Stream 操作将内部的 forEach 方法拆分后,代码如例 3-21 所示。

// 例 3-21 重构的第二步:找出长度大于 1 分钟的曲目
public Set<String> findLongTracks(List<Album> albums) { 
	Set<String> trackNames = new HashSet<>();
	albums.stream()
          .forEach(album -> {
               album.getTracks()
                    .filter(track -> track.getLength() > 60)
                    .map(track -> track.getName())
					.forEach(name -> trackNames.add(name));
			});
	return trackNames;
}

现在用更符合流风格的操作替换了内层的循环,但代码看起来还是冗长繁琐。将各种流嵌套起来并不理想,最好还是用干净整洁的顺序调用一些方法。

理想的操作莫过于找到一种方法,将专辑转化成一个曲目的 Stream。众所周知,任何时候想转化或替代代码,都该使用 map 操作。这里将使用比 map 更复杂的 flatMap 操作,把多个 Stream 合并成一个 Stream 并返回。将 forEach 方法替换成 flatMap 后,代码如例 3-22 所示。

// 例 3-22 重构的第三步:找出长度大于1分钟的曲目
public Set<String> findLongTracks(List<Album> albums) {
	Set<String> trackNames = new HashSet<>();
	albums.stream()
      	  .flatMap(album -> album.getTracks())
		  .filter(track -> track.getLength() > 60)
 		  .map(track -> track.getName())
		  .forEach(name -> trackNames.add(name));
		  
		   return trackNames;
}

上面的代码中使用一组简洁的方法调用替换掉两个嵌套的 for 循环,看起来清晰很多。然而至此并未结束,仍需手动创建一个 Set 对象并将元素加入其中,但我们希望看到的是整个计算任务由一连串的 Stream 操作完成。

到目前为止,虽然还未展示转换的方法,但已有类似的操作。就像使用 collect(Collectors. toList()) 可以将 Stream 中的值转换成一个列表,使用 collect(Collectors.toSet()) 可以将 Stream 中的值转换成一个集合。因此,将最后的 forEach 方法替换为 collect,并删掉变量 trackNames,代码如例 3-23 所示。

// 例 3-23 重构的第四步:找出长度大于1分钟的曲目
public Set<String> findLongTracks(List<Album> albums) {
	return albums.stream()
				 .flatMap(album -> album.getTracks())
				 .filter(track -> track.getLength() > 60)
				 .map(track -> track.getName())
				 .collect(toSet());
}

简而言之,选取一段遗留代码进行重构,转换成使用流风格的代码。最初只是简单地使用流,但没有引入任何有用的流操作。随后通过一系列重构,最终使代码更符合使用流的风格。在上述步骤中我们没有提到一个重点,即编写示例代码的每一步都要进行单元测试,保证代码能够正常工作。重构遗留代码时,这样做很有帮助。

3.5 多次调用流操作

用户也可以选择每一步强制对函数求值,而不是将所有的方法调用链接在一起,但是,最好不要如此操作。例 3-24 展示了如何用如上述不建议的编码风格来找出专辑上所有演出乐队的国籍,例 3-25 则是之前的代码,放在一起方便比较。

// 例 3-24 误用 Stream 的例子
List<Artist> musicians = album.getMusicians()
							  .collect(toList());
							  
List<Artist> bands = musicians.stream()
							  .filter(artist -> artist.getName().startsWith("The"))
							  .collect(toList());
							  
Set<String> origins = bands.stream()
                           .map(artist -> artist.getNationality())
                           .collect(toSet());
例 3-25 符合 Stream 使用习惯的链式调用
Set<String> origins = album.getMusicians()
                           .filter(artist -> artist.getName().startsWith("The"))
                           .map(artist -> artist.getNationality())
                           .collect(toSet());

例 3-24 所示代码和流的链式调用相比有如下缺点:

  • 代码可读性差,样板代码太多,隐藏了真正的业务逻辑;
  • 效率差,每一步都要对流及早求值,生成新的集合;
  • 代码充斥一堆垃圾变量,它们只用来保存中间结果,除此之外毫无用处; 难于自动并行化处理。

当然,刚开始写基于流的程序时,这样的情况在所难免。但是如果发现自己经常写出这样的代码,就要反思能否将代码重构得更加简洁易读。如果此时还不习惯Stream API中大量的链式操作,也很正常。随着练习时间增加,经验也会越来越丰富,这些概念理解起来也更加自然。

3.6 高阶函数

**高阶函数是指接受另外一个函数作为参数,或返回一个函数的函数。**高阶函数不难辨认看:函数签名就够了。如果函数的参数列表里包含函数接口,或该函数返回一个函数接口,那么该函数就是高阶函数。

map 是一个高阶函数,因为它的 mapper 参数是一个函数。事实上,本章介绍的 Stream 接口中几乎所有的函数都是高阶函数。之前的排序例子中还用到了 comparing 函数,它接受一 个函数作为参数,获取相应的值,同时返回一个 Comparator。Comparator 可能会被误认为 是一个对象,但它有且只有一个抽象方法,所以实际上是一个函数接口。

可以大胆断言,Comparator 实际上应该是个函数,但是那时的 Java 只有对象,因此才造出了一个类,一个匿名类。成为对象实属巧合,函数接口向正确的方向迈出了一步。

3.7 正确使用Lambda表达式

刚开始介绍 Lambda 表达式时,我们以输出一些信息的回调函数为示例。回调函数是一个合法的 Lambda 表达式,但并不能真正帮助用户写出更简单、更抽象的代码,因为它仍然在指挥计算机执行一个操作。但Java 8引入的Lambda表达式的作用远不止这些。

本章介绍的概念能够帮助用户写出更简单的代码,因为这些概念描述了数据上的操作,明确了要达成什么样的转化,而不是说明如何转化。这种方式写出的代码,潜在的缺陷更少,更直接地表达了程序员的意图。

**明确要达成什么转化,**而不是说明如何转化的另外一层含义在于写出的函数没有副作用。这一点非常重要,这样只通过函数的返回值就能充分理解函数的全部作用。

没有副作用的函数不会改变程序或外界的状态。 本书中的第一个 Lambda 表达式示例是有副作用的,它向控制台输出了信息——一个可观测到的副作用。下面的代码有没有副作用?

private View lastView = null;

private void setOnClickListener() {
	Button button = new Button();

	button.setOnClickListener(view -> {
		lastView = view;
		System.out.println("button clicked!");
	});
}

这里将参数 view 保存至成员变量 lastView。给变量赋值也是一种副作用,而且更难察觉。在程序的输出中可能很难直接观察到,但是它的确更改了程序的状态。Java 在这方面有局限性,例如下面这段代码,赋值给一个局部变量 lastView:

Button button = new Button();
View clickView = null;

button.setOnClickListener(view -> {
	clickView = view;
	System.out.println("button clicked!");
});

这段代码试图将 view 赋给一个局部变量,它无法通过编译,但绝非编写错误。这实际上是语言的设计者有意为之,用以鼓励用户使用 Lambda 表达式获取值而不是变量。获取值使用户更容易写出没有副作用的代码。如第二章所述,在 Lambda 表达式中使用局部变量,可以不使用 final 关键字,但局部变量在既成事实上必须是 final 的。

无论何时,将 Lambda 表达式传给 Stream 上的高阶函数,都应该尽量避免副作用。唯一的 例外是 forEach 方法,它是一个终结方法。

3.8 要点回顾

  • 内部迭代将更多控制权交给了集合类。
  • 和Iterator类似,Stream是一种内部迭代方式。
  • 常用流操作 (collect、map、filter、flatMap、max、min、reduce)
  • 将Lambda表达式和Stream上的方法结合起来,可以完成很多常见的集合操作。

下一篇:Java8函数式编程_4–类库

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值