上一篇:Java 8 新特性概述

下一篇:传统 for 循环的函数式替代方案

Java 8 被动迭代式特性介绍

编程语言一般都需要提供一种机制用来遍历软件对象的集合,现代的编程语言支持更为复杂的数据结构,如列表、集合、映射和数组。遍历能力是通过公共方法提供,而内部细节都隐藏在类的私有部分,所以程序员不需要了解其内部实现就能够遍历这些数据结构中的元素,这就是迭代的目的。迭代器是对集合中的所有元素进行顺序访问并可以对每个元素执行某些操作的机制。迭代器在本质上提供了在封装的对象集合上做“循环”的装置。

常见的使用迭代器的例子有:

  1. 访问目录中的每个文件并显示文件名;

  2. 访问队列中的每个客户 (如银行排队) 并判断用户等待了多久。使用迭代器时,一般情况下可以循环嵌套,即可以在同一时间做多个遍历;

  3. 迭代器应该是无损的,即迭代行为不应该改变集合本身,如迭代时不要从集合中移除或插入元素;

  4. 在某些情况下,你需要使用迭代器的不同遍历方法,例如,树的前序遍历和后序遍历,或者深度优先、广度优先遍历。

迭代器模式

迭代器设计模式是一种行为模式,其核心思想是负责访问和遍历列表中的对象,并把这些对象放到一个迭代器对象中。迭代器的实现方法根据谁来控制迭代分为两种:主动迭代和被动迭代。主动迭代器是由客户程序创建迭代器,调用 next() 行进到下一个元素,测试查看是否所有元素已被访问。被动迭代器是 Java 8 新引入的机制,它是迭代器本身控制迭代,即迭代器自行 next() 向下走,针对客户程序来说迭代是透明的,是不能操作的。这种方法在 LISP 语言中很常见。

GOF 给出的定义是,在不暴露该对象的内部细节前提下,通过提供一种方法用于访问一个容器(container)对象中各个元素。深层次的目的是为了把遍历算法从容器对象中独立出来。

面向对象设计的一大难点是如何正确辨认对象的职责。理想状态下,一个类应该只有一个单一的职责。职责分离可以最大限度的去除对象之间的耦合程度,但是实际开发过程中,想要做到职责单一着实不易。具体到本模式,以迭代器模式为例,容器对象提供了两个职责,一是组织管理数据对象,二是提供遍历算法。所以 Iterator 模式就是分离了集合对象的遍历行为,抽象出一个迭代器类来负责,这样既可以做到不暴露集合的内部结构,又可让外部代码透明的访问集合内部的数据。

迭代器模式由以下几个角色组成:

1) 迭代器角色(Iterator):迭代器角色负责定义访问和遍历元素的接口。

2) 具体迭代器角色(Concrete Iterator):具体迭代器角色要实现迭代器接口,并要记录遍历中的当前位置。

3) 容器角色(Container):容器角色负责提供创建具体迭代器角色的接口。

4) 具体容器角色(Concrete Container):具体容器角色实现创建具体迭代器角色的接口,这个具体迭代器角色与该容器的结构相关。

迭代器模式的类图如下所示。

图 1. 迭代器模式类图

在 JDK 内部,与迭代器相关的接口有两个 Iterator、Iterable。Iterator 及其子类通常是迭代器本身的结构与方法。Iterable 是可迭代的,如 AbstractList HashMap 等需要使用迭代器功能的类,都需要实现该接口。Iterator 源代码如清单 1 所示。

清单 1. Iterator 源代码

Iterable 源代码如清单 2 如示。

清单 2. Iterable 源代码

实际开发过程中,我们如何使用迭代器?一般来说,我们有三种方式:

  1. 比如类 A 想要使用迭代器,它的类声明部分应该是 class A implement Iterable;

  2. 在类 A 实现中,要实现 Iterable 接口中的唯一方法:Iterator<T> iterator();这个方法用于返回一个迭代器,即 Iterator 接口及其子类;

  3. 在类 A 中,定义一个内部类 S,专门用于实现 Iterator 接口,定制类 A 自已的迭代器实现。

具体实现代码如清单 3 所示:

清单 3. 迭代器实现示例

Lambda 表达式

Java 8 引入了独特的 Lambda 表达式用于遍历集合。Lambda 表达式本质上是一个匿名方法,我们来看下面这个例子。

清单 4. 匿名方法

转成 Lambda 表达式后就变成 (int x, int y) -> x + y;这一行表达式。参数类型可以省略,Java 编译器会根据上下文推断出来,(x, y) -> x + y; //返回两数之和,也可能是 (x, y) -> { return x + y; } //显式指明返回值。

从上面的描述可见 Lambda 表达式由三部分组成:参数列表,箭头(->),以及一个表达式或语句块。

下面这个例子里的 Lambda 表达式没有参数,也没有返回值,即相当于一个方法接受 0 个参数,返回 void,JDK 里 Runnable 接口的 run 方法就是这样一个实现。

() -> { System.out.println("Hello Lambda!"); }

如果只有一个参数且可以被 Java 推断出类型,那么参数列表的括号也可以省略,c -> { return c.size(); }

Java 8 全新集合遍历方式

Java 8 提供了全新的遍历对象集合方式,该方式主要包含被动迭代、流、并行流三种方法。总的来说,Java 8 提供的迭代器较 JDK 早期版本而言,它的可读性更好、不易出错、更容易并行化。进入 Java 8 的新方式学习之前,我们先来回顾一下集合类访问的变化过程。

Java 1.0 和 1.1 中两个主要的集合类是 Vector 和 Hashtable,迭代器是通过一个叫做枚举的类实现的。今天无论是 Vector 还是 Hashtable 都是泛型类,但退回到那时泛型还不是 Java 语言的一部分,泛型是在 JDK5 的时候被引入的。清单 5 所示代码演示了使用枚举来处理字符串向量的方法。

清单 5. Java 1 的枚举方式处理字符串向量

Java 1.2 推出了集合类 (Collections),并通过一个迭代器类 (Iterator) 实现了迭代器设计模式。因为 Java 1.2 当时还没有推出泛型概念,所以我们需要对迭代器返回的对象进行强制类型转换。对于 Java 1.2 至 1.4 版本,遍历字符串列表方式如清单 6 代码所示。

清单 6. Java 2 的遍历字符串列表

Java 5 提出了泛型、Iterator 接口、增强 for 循环这三种新的方式。在增强 for 循环中,迭代器的创建和调用它的 hasNext() 和 next() 方法都发生在程序后端,不需要明确地写在代码中,因此,代码显得更为紧凑。Java 5 的例子代码如清单 7 所示。

清单 7. Java 5 的 for 循环方式

Java 7 为了避免泛型的冗长给出了钻石运算符<>,从而避免了使用 new 运算符实例化泛型类时重复指定数据类型。

从 Java 7 开始,第一行代码可以简化成以下形式:List<String> names = new LinkedList<>();

Java 8 提供了新的迭代途径,它使用之前介绍的 Lambda 表达式对集合进行遍历。

Java 8 最主要的新特性就是 Lambda 表达式以及与此相关的特性,如流 (streams)、方法引用 (method references)、功能接口 (functional interfaces)。正是因为这些新特性,我们能够使用被动迭代器而不是传统的主动迭代器,特别是 Iterable 接口提供了一个被动迭代器的缺省方法叫做 forEach()。缺省方法是 Java 8 的又一个新特性,是一个接口方法的缺省实现,在这种情况下,forEach() 方法实际上是用类似于 Java 5 这样的主动迭代器方式来实现的。

实现了 Iterable 接口的集合类 (如:所有列表 List、集合 set) 现在都有一个 forEach() 方法,这个方法接收一个功能接口参数,实际上传递给 forEach() 方法的参数是一个 Lambda 表达式。使用 Java 8 的功能,代码变化如清单 8 所示,该代码易读性更好,且多线程环境下逻辑是线程安全的,更容易进行并行化。

清单 8. Java 8 的操作集合方式

请注意清单 8 代码中的被动迭代与前面三段代码中的主动迭代之间的差异。在主动迭代中由循环结构控制迭代,并且每次通过循环从列表中获取一个对象,然后打印出来。上面的代码中没有显示的循环结构,我们只是告诉 forEach() 方法对列表中的对象实施打印,迭代控制隐含在 forEach() 方法中。

流是应用在一组元素上的一次执行的操作序列。集合和数组都可以用来产生流,因此称作数据流。流不存储集合中的元素,相反,流是通过管道操作来自数据源的值序列的一种机制。流管道由数据源、若干中间操作 (Intermediate Operations)、一个最终操作 (Terminal Operation) 组成,中间操作对数据集完成过滤、检索等中间业务,而最终操作完成对数据集处理的最终处理,或调用 forEach() 方法。

当你处理集合时,通常会迭代所有元素并对其中的每一个进行处理。例如,假设我们希望统计一个文件中的所有长单词 (超过 12 个字母以上的单词我们认为是长单词),代码如清单 9 所示。

清单 9. Java 6 方式统计长单词

在 Java 8 中,为了实现清单 9 所示代码功能的高并行性,我们可以按照清单 10 来编写代码。

清单 10. Java 8 方式统计长单词

stream 方法会为单词列表生成一个 Stream。filter 方法会返回另一个只包含单词长度大于 12 的 Stream,count 方法会将 Stream 化简为一个结果。

Stream 表面上看与一个集合很类似,允许你改变和获取数据,但是实际上它与集合是有很大区别的:

  1. Stream 自己不会存储元素,元素可能被存储在底层的集合中,或者根据需要产生出来;

  2. Stream 操作符不会改变源对象,相反,它们会返回一个持有结果的新 Stream;

  3. Stream 操作符可能是延迟执行的,这意味着它们会等到需要结果的时候才执行。

许多人发现 Stream 表达式比循环的可读性更好。此外,它们还很容易进行并行执行。清单 11 所示代码是一段如何并行统计长单词的代码。

清单 11. Java 8 并行运行统计程序

与清单 10 不同的是,将 stream() 方法改成了 parallelStream 方法,这样可以让 Stream API 并行执行过滤和统计操作。

总的来说,Stream 遵循“做什么,而不是怎么去做”的原则。在我们的示例中,描述了需要做什么,比如获得长单词并对它们的个数进行统计。我们没有指定按照什么顺序,或者在哪个线程中做。相反,循环在一开始就需要指定如何进行计算,因此就失去了优化的机会。

当你使用 Stream 时,你会通过三个阶段来建立一个操作流水线:

创建一个 Stream。

在一个或多个步骤中,指定将初始 Stream 转换为另一个 Stream 的中间操作。

使用一个终止操作来产生一个结果。该操作会强制它之前的延迟操作立即执行。在这之后,该 Stream 就不会再被使用。

在我们的示例中,通过 stream 或者 parallelStream 方法来创建 Stream,再通过 filter 方法对其进行转换,而 count 就是终止操作。

注意,Stream 操作不会按照元素的调用顺序执行。在我们的例子中,只有在 count 被调用的时候才会执行 Stream 操作。当 count 方法需要第一个元素时,filter 方法会开始请求各个元素,直到找到一个长度大于 12 的元素。

结合上面这么多描述,清单 12 所示代码使用 Java 8 方式编写代码实现流管道计算,统计字母 A 开头的人名的个数。列表 names 用于创建流,然后使用过滤器对数据集进行过滤,filter() 方法只过滤出以字母 A 开头的名字,该方法的参数是一个 Lambda 表达式。最后,流的 count() 方法作为最终操作,得到应用结果。中间操作除了 filter() 之外,还有 distinct()、sorted()、map() 等,一般是对数据集的整理,返回值一般也是数据集。以下代码使用的是主动式迭代方式,在多线程环境下该逻辑不是线程安全的。

清单 12. Java 8 实现统计 A 打头字母

同样的代码在 Java 7 里面如清单 13 所示。

清单 13. Java 7 实现统计 A 打头字母

最终的处理方法往往是需要完成对数据的集中处理,如 forEach()、allMatch()、anyMatch()、findAny()、findFirst(),数值计算类的方法有 sum()、max()、min()、averag() 等等,最终方法也可以是对集合的处理,如 reduce()、collect() 等等。reduce() 方法的处理方式一般是每次都产生新的数据集,而 collect() 方法是在原数据集的基础上进行更新,过程中不产生新的数据集。

Java 8 集合类不仅具有 Stream() 方法,该方法返回一个连续的数据流,Java 8 集合类还有一个 parallelStream() 方法,该方法返回一个并行流。并行流的作用在于允许管道操作同时在不同的 Java 线程中执行以提高性能。但要注意的是,集合元素的处理顺序可能发生改变。

结束语

Java 8 支持一种新的特性和功能可以对集合进行迭代操作,它属于一种声明性的方法,这种新方法带来的好处是代码的可读性更好、不易出错、对于多线程支持更好且更丰富、程序更容易并行化。但是众所周知,针对不同的应用场景应该采用不同的集合类、迭代方式,学术界公布的测试报告表明,并行流对每一种集合类而言不一定性能更快。

上一篇:Java 8 新特性概述

下一篇:传统 for 循环的函数式替代方案