《Java8函数式编程》笔记

《Java8函数式编程》笔记。重点是函数式编程涉及到的概念以及流的操作。

例子和课后练习的代码:

https://github.com/lmtsunnie/java-8-lambdas-exercises

简介

为了编写处理批量数据的并行类库,让代码在多核 CPU 上高效运行,增加 Lambda 表达式。

面向对象编程是对数据进行抽象,而函数式编程是对行为进行抽象

在写回调函数和事件处理程序时,程序员不必再纠缠于匿名内部类的冗繁和可读性,函数式编程让事件处理系统变得更加简单。能将函数方便地传递也让编写惰性代码变得容易,惰性代码在真正需要时才初始化变量的值。(类似builder模式)

Java 8还让集合类可以拥有一些额外的方法:default方法。程序员在维护自己的类库时,可以使用这些方法。

其核心是:在思考问题时,使用不可变值和函数,函数对一个值进行处理,映射成另一个值。

例子模板

本书中的示例全部都围绕在专辑上常常看到的信息来构造,有关术语定义如下:

  • Artist:创作音乐的个人或团队。

    • name:艺术家的名字(例如“甲壳虫乐队”)。

    • members:乐队成员(例如“约翰·列侬”),该字段可为空。

    • nationality:乐队来自哪里(例如“利物浦”)。
  • Track:专辑中的一支曲目。

    • name:曲目名称(例如《黄色潜水艇》)。
    • length:曲目的时间长短(例如350,以秒为单位)
  • Album:专辑,由若干曲目组成。

    • name:专辑名(例如《左轮手枪》)。
    • tracks:专辑上所有曲目的列表。
    • musicians:参与创作本专辑的艺术家列表。

lambda表达式

第一个lambda表达式

1
2
3
4
5
button.addActionListener(new ActionListener() {
@Override
public void actionPerformed(ActionEvent event) {
System.out.println("button clicked");
}

我们创建了一个新对象,它实现了 ActionListener 接口。这个接口只有一个方法 actionPerformed,当用户点击屏幕上的按钮时,button 就会调用这个方法。匿名内部类实现了该方法。在例 2-1 中该方法所执行的只是输出一条信息,表明按钮已被点击。设计匿名内部类的目的,就是为了方便 Java 程序员将代码作为数据传递。

1
button.addActionListener(event -> System.out.println("button clicked"));

和传入一个实现某接口的对象不同,我们传入了一段代码块——一个没有名字的函数。 event 是参数名,和上面匿名内部类示例中的是同一个参数。-> 将参数和 Lambda 表达式 的主体分开,而主体是用户点击按钮时会运行的一些代码。

和使用匿名内部类的另一处不同在于声明 event 参数的方式。使用匿名内部类时需要显式 地声明参数类型ActionEvent event,而在Lambda表达式中无需指定类型,程序依然可以编译。这是因为 javac 根据程序的上下文(addActionListener 方法的签名)在后台推断出了参数 event 的类型。这意味着如果参数类型不言而明,则无需显式指定。稍后会介绍类型推断的更多细节,现在先来看看编写 Lambda 表达式的各种方式。

尽管与之前相比,Lambda表达式中的参数需要的样板代码很少,但是Java 8 仍然是一种静态类型语言。为了增加可读性并迁就我们的习惯,声明参数时也可以包括类型信息,而且有时编译器不一定能根据上下文推断出参数的 类型。

lambda表达式的形式

Lambda 表达式除了基本的形式之外,还有几种变体:

不含参数的lambda表达式

1
Runnable noArguments = () -> System.out.println("Hello World"); 􏰐

不包含参数,使用空括号 () 表示没有参数。该 Lambda 表达式实现了 Runnable 接口,该接口也只有一个 run 方法,没有参数,且返回类型为 void。

只含一个参数

1
ActionListener oneArgument = event -> System.out.println("button clicked"); 􏰑

包含且只包含一个参数,可省略参数的括号。

主体是代码块

1
2
3
4
Runnable multiStatement = () -> { 􏰒
System.out.print("Hello");
System.out.println(" World");
};

Lambda 表达式的主体不仅可以是一个表达式,而且也可以是一段代码块,使用大括号{}将代码块括起来。该代码块和普通方法遵循的规则别无二致,可以用返回或抛出异常来退出。只有一行代码的 Lambda 表达式也可使用大括号,用以明确 Lambda 表达式从何处开始、到哪里结束。

包含多个参数

1
BinaryOperator<Long> add = (x, y) -> x + y; 􏰓

Lambda 表达式也可以表示包含多个参数的方法,这时就有必要思考怎样去阅读该 Lambda 表达式。这行代码并不是将两个数字相加,而是创建了一个函数,用来计算两个数字相加的结果。变量 add 的类型是 BinaryOperator<Long>,它不是两个数字的和, 而是将两个数字相加的那行代码。

到目前为止,所有 Lambda 表达式中的参数类型都是由编译器推断得出的。

显式声明参数类型

1
BinaryOperator<Long> addExplicit = (Long x, Long y) -> x + y; 􏰔

最好也可以显式声明参数类型,此时就需要使用小括号将参数括起来,多个参数的情况也是如此。

目标类型

目标类型是指 Lambda 表达式所在上下文环境的类型。比如,将 Lambda 表达式赋值给一个局部变量,或传递给一个方法作为参数,局部变量或方法参数的类型就是 Lambda 表达式的目标类型。

上述例子还隐含了另外一层意思:Lambda 表达式的类型依赖于上下文环境,是由编译器推断出来的。目标类型也不是一个全新的概念。如下面的例子中,Java 中初始化数组时,数组的类型就是根据上下文推断出来的。另一个常见的例子是 null,只有将 null 赋值给一个变量,才能知道它的类型。

1
final String[] array = { "hello", "world" };

等号右边的代码并没有声明类型,系统根据上下文推断出类型信息

引用值,而不是变量

如果你曾使用过匿名内部类,也许遇到过这样的情况:需要引用它所在方法里的变量。这时,需要将变量声明为final,意味着不能为其重复赋值。同时也意味着在使用 final 变量时,实际上是在使用赋给该变量的一个特定的值

匿名内部类中使用 final 局部变量:

1
2
3
4
5
final String name = getUserName(); 
button.addActionListener(new ActionListener() {
public void actionPerformed(ActionEvent event) {
System.out.println("hi " + name);
} });

Java 8虽然放松了这一限制,可以引用非final变量,但是该变量在既成事实上必须是 final。虽然无需将变量声明为final,但在Lambda表达式中,也无法用作非终态变量。如果坚持用作非终态变量,编译器就会报错。

既成事实上的 final 是指只能给该变量赋值一次。换句话说,Lambda 表达式引用的是值, 而不是变量

Lambda 表达式中引用既成事实上的 final 变量:

1
2
String name = getUserName();
button.addActionListener(event -> System.out.println("hi " + name));

是否显式指明这种既成事实上的 final 变量,完全取决于个人喜好。

如果你试图给该变量多次赋值,然后在 Lambda 表达式中引用它,编译器就会报错。

未使用既成事实上的 final 变量,导致无法通过编译,并显示出错信息:local variables referenced from a Lambda expression must be final or effectively final

1
2
3
String name = getUserName();
name = formatUserName(name);
button.addActionListener(event -> System.out.println("hi " + name));

这种行为也解释了为什么 Lambda 表达式也被称为闭包。未赋值的变量与周边环境隔离起来,进而被绑定到一个特定的值。

函数接口

函数接口是只有一个抽象方法的接口,用作 Lambda 表达式的类型。

1
2
3
public interface ActionListener extends EventListener { 
public void actionPerformed(ActionEvent event);
}

由于该单一方法定义在一个接口里,因此 abstract 关键字不是该方法必需的。该接口也继承自一个不具有任何方法的父接口EventListener。接口中单一方法的命名并不重要,只要方法签名和 Lambda 表达式的类型匹配即可。可在函数接口中为参数起一个有意义的名字,增加代码易读性,便于更透彻地理解参数的用途。

但 Java 开发工具包(JDK)提供的一组核心函数接口会频繁出现。下面罗列了一些最重要的函数接口javac 推断Lambda 表达式中参数的类型都是将参数对应某一个接口进行推断:

接口 参数 返回类型 示例
Predicate<T> T boolean 这张唱片已经发行了吗
Consumer<T> T void 输出一个值
Function<T,R> T R 获得 Artist 对象的名字
Supplier<T> None T 工厂方法
UnaryOperator<T> T T 逻辑非(!)
BinaryOperator<T> (T, T) T 求两个数的乘积(*)

前面已讲过函数接口接收的类型,也讲过 javac 可以根据上下文自动推断出参数的类型,且用户也可以手动声明参数类型。

小结

  • Lambda 表达式是一个匿名方法,将行为像数据一样进行传递。
  • Lambda 表达式的常见结构:BinaryOperator<Integer> add = (x, y) → x + y。 􏰈
  • 函数接口指仅具有单个抽象方法的接口,用来表示Lambda表达式的类型。

习题

【1】ThreadLocal Lambda表达式。Java有一个ThreadLocal类,作为容器保存了当前线程里局部变量的值。Java8为该类新加了一个工厂方法,接受一个Lambda表达式,并产生一个新的 ThreadLocal 对象,而不用使用继承,语法上更加简洁。

  1. 在 Javadoc 或集成开发环境(IDE)里找出该方法。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    /**
    * Creates a thread local variable. The initial value of the variable is
    * determined by invoking the {@code get} method on the {@code Supplier}.
    *
    * @param <S> the type of the thread local's value
    * @param supplier the supplier to be used to determine the initial value
    * @return a new thread local variable
    * @throws NullPointerException if the specified supplier is null
    * @since 1.8
    */
    public static <S> ThreadLocal<S> withInitial(Supplier<? extends S> supplier) {
    return new SuppliedThreadLocal<>(supplier);
    }
  2. DateFormatter 类是非线程安全的。使用构造函数创建一个线程安全的 DateFormatter 对象,并输出日期,如“01-Jan-1970”。

1
public final static ThreadLocal<DateFormatter> formatter = ThreadLocal.withInitial(() -> new DateFormatter(new SimpleDateFormat("dd-MMM-yyyy")));

【2】类型推断规则。下面是将 Lambda 表达式作为参数传递给函数的一些例子。javac 能正确推断出 Lambda 表达式中参数的类型吗?换句话说,程序能编译吗?

a. Runnable helloWorld = () -> System.out.println("hello world"); 【能】

b. 使用 Lambda 表达式实现 ActionListener 接口: 【能】

1
2
JButton button = new JButton();
button.addActionListener(event -> System.out.println(event.getActionCommand()));

c. 以如下方式重载 check 方法后,还能正确推断出 check(x -> x > 5) 的类型吗? 【不能】

1
2
3
4
5
6
interface IntPred {
boolean test(Integer value);
}
boolean check(Predicate<Integer> predicate);

boolean check(IntPred predicate);

Java8对核心类库的改进主要包括集合类的 API 和新引入的流 (Stream)。流使程序员得以站在更高的抽象层次上对集合进行操作。 本章会介绍 Stream 类中的一组方法,每个方法都对应集合上的一种操作。

从外部迭代到内部迭代

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

例 3-1 使用 for 循环计算来自伦敦的艺术家人数

1
2
3
4
5
6
int count = 0;
for (Artist artist : allArtists) {
if (artist.isFrom("London")) {
count++;
}
}

尽管这样的操作可行,但存在几个问题。每次迭代集合类时,都需要写很多样板代码。将 for 循环改造成并行方式运行也很麻烦,需要修改每个 for 循环才能实现。

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

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

例 3-2 使用迭代器计算来自伦敦的艺术家人数

1
2
3
4
5
6
7
8
int count = 0;
Iterator<Artist> iterator = allArtists.iterator();
while (iterator.hasNext()) {
Artist artist = iterator.next();
if (artist.isFrom("London")) {
count++;
}
}

P.S 迭代只能查询元素的值,不能增删改,迭代是把每一个元素复制出来,如果修改了元素,修改的并不是原来的元素。比如你在迭代一个ArrayList,迭代器的工作方式是依次返回给你第0个元素,第1个元素,等等,假设当你迭代到第5个元素的时候,你突然在ArrayList的头部插入了一个元素,使得你所有的元素都往后移动,于是你当前访问的第5个元素就会被重复访问。 java认为在迭代过程中,容器应当保持不变。因此,java容器中通常保留了一个域称为modCount,每次你对容器修改,这个值就会加1。当你调用iterator方法时,返回的迭代器会记住当前的modCount,随后迭代过程中会检查这个值,一旦发现这个值发生变化,就说明你对容器做了修改,就会抛异常。

图3-1:外部迭代

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

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

例 3-3 使用内部迭代计算来自伦敦的艺术家人数

https://github.com/lmtsunnie/java-8-lambdas-exercises/blob/master/src/main/java/com/sunnie/java8/examples/chapter3/GetArtistCountFromLondon3.java

1
2
3
long count = allArtists.stream()
.filter(artist -> artist.isFrom("London"))
.count();

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

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

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

  • 找出所有来自伦敦的艺术家
  • 计算他们的人数

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

实现机制

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

通常,在 Java 中调用一个方法,计算机会随即执行操作:比如,System.out.println ("Hello World");会在终端上输出一条信息。Stream里的一些方法却略有不同,它们虽是普通的 Java 方法,但返回的 Stream 对象却不是一个新集合,而是创建新集合的配方。

例 3-4 只过滤,不计数

1
2
allArtists.stream()
.filter(artist -> artist.isFrom("London"));

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

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

例 3-5 由于使用了惰性求值,没有输出艺术家的名字

1
2
3
4
5
allArtists.stream()
.filter(artist -> {
System.out.println(artist.getName());
return artist.isFrom("London");
});

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

例 3-6 输出艺术家的名字

1
2
3
4
5
6
long count = allArtists.stream()
.filter(artist -> {
System.out.println(artist.getName());
return artist.isFrom("London");
})
.count();

以披头士乐队的成员作为艺术家列表,运行上述程序,命令行里输出的内容如例 3-7 所示。

例 3-7 显示披头士乐队成员名单的示例输出

1
2
3
4
John Lennon
Paul McCartney
George Harrison
Ringo Starr

判断一个操作是惰性求值还是及早求值很简单:只需看它的返回值。如果返回值是 Stream, 那么是惰性求值;如果返回值是另一个值或为空,那么就是及早求值。使用这些操作的理想方式就是形成一个惰性求值的链,最后用一个及早求值的操作返回想要的结果,这正是它的合理之处。计数的示例也是这样运行的,但这只是最简单的情况:只含两步操作。

整个过程和建造者模式有共通之处。建造者模式使用一系列操作设置属性和配置,最后调用一个 build 方法,这时,对象才被真正创建。

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

常用的流操作

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

collect(toList())

collect(toList()) +import static java.util.stream.Collectors.toList;= collect(Collectors.toList()) 。该方法由 Stream 里的值生成一个列表,是一个及早求值操作。

Stream 的 of 方法使用一组初始值生成新的 Stream。事实上,collect 的用法不仅限于此, 它是一个非常通用的强大结构,第 5 章将详细介绍它的其他用途。下面是使用 collect 方法的一个例子:

https://github.com/lmtsunnie/java-8-lambdas-exercises/blob/master/src/main/java/com/sunnie/java8/examples/chapter3/StringToList7.java

1
2
3
List<String> collected = Stream.of("a", "b", "c") ➊ //  多个字符串 -> Stream
.collect(Collectors.toList()); 􏰑➋ // Stream -> 列表
assertEquals(Arrays.asList("a", "b", "c"), collected); ➌

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

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

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

map

如果有一个函数可以将一种类型的值转换成另外一种类型,map 操作就可以使用该函数,将一个流中的值转换成一个新的流

比如编写一段 Java 代码,将一组字符串转换成对应的大写形式。在一个循环中,对每个字符串调用 toUpperCase 方法,然后将得到的结果加入一个新的列表。代码如例 3-8 所示。

例 3-8 使用 for 循环将字符串转换为大写

1
2
3
4
5
6
List<String> collected = new ArrayList<>();
for (String string : Arrays.asList("a", "b", "hello")) {
String uppercaseString = string.toUpperCase();
collected.add(uppercaseString);
}
assertEquals(Arrays.asList("A", "B", "HELLO"), collected);

如果你经常实现例 3-8 中这样的 for 循环,就不难猜出 map 是 Stream 上最常用的操作之一

(如图 3-3 所示)。例 3-9 展示了如何使用新的流框架将一组字符串转换成大写形式。

图 3-3:map 操作

例 3-9 使用 map 操作将字符串转换为大写形式

https://github.com/lmtsunnie/java-8-lambdas-exercises/blob/master/src/main/java/com/sunnie/java8/examples/chapter3/StringToUpperCase9.java

1
2
3
4
List<String> collected = Stream.of("a", "b", "hello")
.map(string -> string.toUpperCase()) ➊
.collect(Collectors.toList());
assertEquals(Arrays.asList("A", "B", "HELLO"), collected);

传给 map ➊ 的 Lambda 表达式只接受一个 String 类型的参数,返回一个新的 String。参数和返回值不必属于同一种类型,但是 Lambda 表达式必须是 Function 接口的一个实例(如图 3-4 所示),Function 接口是只包含一个参数的普通函数接口。

图 3-4 Function 接口

filter

遍历数据并检查其中的元素时,可尝试使用 Stream 中提供的新方法 filter (如图 3-5 所示)。

图 3-5:filter 操作

假设要找出一组字符串中以数字开头的字符串,比如字符串 “1abc” 和 “abc”,其中 “1abc” 就是符合条件的字符串。 可以使用一个 for 循环,内部用 if 条件语句判断字符串的第一个字符来解决这个问题,代码如例 3-10 所示。

例 3-10 使用循环遍历列表,使用条件语句做判断

1
2
3
4
5
6
7
List<String> beginningWithNumbers = new ArrayList<>(); 
for (String value : Arrays.asList("a", "1abc", "abc1")) {
if (isDigit(value.charAt(0))) {
beginningWithNumbers.add(value);
}
}
assertEquals(Arrays.asList("1abc"), beginningWithNumbers);

你可能已经写过很多类似的代码:这被称为 filter 模式。该模式的核心思想是保留 Stream 中的一些元素,而过滤掉其他的。例 3-11 展示了如何使用函数式风格编写相同的代码。

例 3-11 函数式风格

https://github.com/lmtsunnie/java-8-lambdas-exercises/blob/master/src/main/java/com/sunnie/java8/examples/chapter3/FindStringListBeginWithNum11.java

1
2
3
4
5
List<String> beginningWithNumbers
= Stream.of("a", "1abc", "abc1")
.filter(value -> isDigit(value.charAt(0)))
.collect(Collectors.toList());
assertEquals(Arrays.asList("1abc"), beginningWithNumbers);

和map很像,filter接受一个函数作为参数,该函数用Lambda表达式表示。该函数和前面示例中 if 条件判断语句的功能一样,如果字符串首字母为数字,则返回 true。若要重构遗留代码,for 循环中的 if 条件语句就是一个很强的信号,可用 filter 方法替代。

由于此方法和 if 条件语句的功能相同,因此其返回值肯定是 true 或者 false。经过过滤, Stream 中符合条件的,即 Lambda 表达式值为 true 的元素被保留下来。该 Lambda 表达式 的函数接口正是前面章节中介绍过的 Predicate(如图 3-6 所示)。

图 3-6:Predicate 接口

flatMap

flatMap 方法可用 Stream 替换值,然后将多个 Stream 连接成一个 Stream (n个值->n个Stream->一个Stream)

图 3-7:flatMap 操作

前面已介绍过 map 操作,它可用一个新的值代替 Stream 中的值。但有时,用户希望让 map 操作有点变化,生成一个新的 Stream 对象取而代之。用户通常不希望结果是一连串的流, 此时 flatMap 最能派上用场。

我们看一个简单的例子。假设有一个包含多个列表的流,现在希望得到所有数字的序列(把两个列表合并为一个列表)。 该问题的一个解法如例 3-12 所示。

例 3-12 包含多个列表的 Stream

https://github.com/lmtsunnie/java-8-lambdas-exercises/blob/master/src/main/java/com/sunnie/java8/examples/chapter3/MergeTwoList12.java

1
2
3
4
List<Integer> together = Stream.of(Arrays.asList(1, 2), Arrays.asList(3, 4))
.flatMap(numbers -> numbers.stream())
.collect(Collectors.toList());
assertEquals(Arrays.asList(1, 2, 3, 4), together);

或者写成

1
2
3
4
List<Integer> together = Stream.of(Arrays.asList(1, 2), Arrays.asList(3, 4))
.flatMap(Collection::stream)
.collect(Collectors.toList());
assertEquals(Arrays.asList(1, 2, 3, 4), together);

调用 stream 方法,将每个列表转换成 Stream 对象,其余部分由 flatMap 方法处理。 flatMap 方法的相关函数接口和 map 方法的一样,都是 Function 接口,只是方法的返回值限定为 Stream 类型罢了。

max和min

Stream 上常用的操作之一是求最大值和最小值。Stream API 中的 max 和 min 操作足以解决 这一问题。例 3-13 是查找专辑中最短曲目所用的代码,展示了如何使用 max 和 min 操作。 为了方便检查程序结果是否正确,代码片段中罗列了专辑中的曲目信息。

例 3-13 使用 Stream 查找最短曲目

https://github.com/lmtsunnie/java-8-lambdas-exercises/blob/master/src/main/java/com/sunnie/java8/examples/chapter3/FindShortestTrack13.java

1
2
3
4
5
6
7
List<Track> tracks = Arrays.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);

或者写成

1
2
3
4
5
6
7
List<Track> tracks = Arrays.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::getLength))
.get();
assertEquals(tracks.get(1), shortestTrack);

查找 Stream 中的最大或最小元素,首先要考虑的是用什么作为排序的指标。以查找专辑中的最短曲目为例,排序的指标就是曲目的长度。

为了让Stream对象按照曲目长度进行排序,需要传给它一个Comparator对象。Java 8提供了一个新的静态方法 comparing,使用它可以方便地实现一个比较器。放在以前,我们需要比较两个对象的某项属性的值,现在只需要提供一个存取方法就够了。本例中使用 getLength 方法。

花点时间研究一下 comparing 方法是值得的。实际上这个方法接受一个函数并返回另一个函数。这个方法本该早已加入 Java 标准库,但由于匿名内部类可读性差且书写冗长,一直未能实现。现在有了 Lambda 表达式,代码变得简洁易懂。

此外,还可以调用空 Stream 的 min/max 方法,返回 Optional 对象。Optional 对象有点陌生, 它代表一个可能存在也可能不存在的值。如果 Stream 为空,那么该值不存在,如果不为空,则该值存在。先不必细究,4.10 节将详细讲述 Optional 对象,现在唯一需要记住的是,通过调用 get 方法可以取出 Optional 对象中的值。

通用模式

max 和 min 方法都属于更通用的一种编程模式。要看到这种编程模式,最简单的方法是使用 for 循环重写例 3-13 中的代码。例 3-14 和例 3-13 的功能一样,都是查找专辑中的最短曲目,但是使用了 for 循环。

例 3-14 使用 for 循环查找最短曲目

1
2
3
4
5
6
7
8
9
List<Track> tracks = Arrays.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;
}
}

这段代码先使用列表中的第一个元素初始化变量 shortestTrack,然后遍历曲目列表,如果找到更短的曲目,则更新 shortestTrack,最后变量 shortestTrack 保存的正是最短曲目。 程序员们无疑已写过成千上万次这样的 for 循环,其中很多都属于这个模式。例 3-15 中的伪代码体现了通用模式的特点。

例 3-15 reduce 模式

1
2
3
4
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 操作是怎么工作的。

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 求和

https://github.com/lmtsunnie/java-8-lambdas-exercises/blob/master/src/main/java/com/sunnie/java8/examples/chapter3/GetSum16.java

1
2
3
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 操作

1
2
3
4
5
6
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 使用命令式编程方式求和

1
2
3
4
5
intacc = 0;
for (Integer element : asList(1, 2, 3)) {
acc = acc + element;
}
assertEquals(6, acc);

在命令式编程方式下,每一次循环将集合中的元素和累加器相加,用相加后的结果更新累加器的值。对于集合来说,循环在外部,且需要手动更新变量。

整合操作

Stream 接口的方法如此之多,有时会让人难以选择,像闯入一个迷宫,不知道该用哪个方法更好。本节将举例说明如何将问题分解为简单的 Stream 操作。

第一个要解决的问题是,找出某张专辑上所有乐队的国籍。艺术家列表里既有个人,也有乐队。利用一点领域知识,假定一般乐队名以定冠词 The 开头。当然这不是绝对的,但也差不多。

需要注意的是,这个问题绝不是简单地调用几个 API 就足以解决。这既不是使用 map 将一 组值映射为另一组值,也不是过滤,更不是将 Stream 中的元素最终归约为一个值。首先,可将这个问题分解为如下几个步骤。

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

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

  1. Album 类有个 getMusicianStream 方法,该方法返回一个 Stream 对象,包含整张专辑中所有的表演者;

  2. 使用 filter 方法对表演者进行过滤,只保留乐队(在这里认为以定冠词 The 开头是乐队);

  3. 使用 map 方法将乐队映射为其所属国家;

  4. 使用 collect(Collectors.toList())方法将国籍放入一个列表。

    最后,整合所有的操作,就得到如下代码:

    https://github.com/lmtsunnie/java-8-lambdas-exercises/blob/master/src/main/java/com/sunnie/java8/examples/chapter3/GetBankNationalities19.java

    1
    2
    3
    4
    Set<String> origins = album.getMusicianStream()
    .filter(artist -> artist.getName().startsWith("The"))
    .map(Artist::getNationality)
    .collect(Collectors.toSet());

这个例子将 Stream 的链式操作展现得淋漓尽致,调用 getMusicianStream、filter 和 map 方法都返回 Stream 对象,因此都属于惰性求值,而 collect 方法属于及早求值。map 方法接受一 个 Lambda 表达式,使用该 Lambda 表达式对 Stream 上的每个元素做映射,形成一个新的 Stream。

这个问题处理起来很方便,使用 getMusicianStream 方法获取专辑上的艺术家列表时得到的是一 个 Stream 对象。然而,处理其他实际遇到的问题时未必也能如此方便,很可能没有方法可以返回一个 Stream 对象,反而得到像 List 或 Set 这样的集合类。别担心,只要调用 List 或 Set 的 stream 方法就能得到一个 Stream 对象。

现在或许是个思考的好机会,你真的需要对外暴露一个 List 或 Set 对象吗?可能一个 Stream 工厂才是更好的选择。通过 Stream 暴露集合的最大优点在于,它很好地封装了内部实现的数据结构。仅暴露一个 Stream 接口,用户在实际操作中无论如何使用,都不会影响内部的 List 或 Set。

同时这也鼓励用户在编程中使用更现代的Java 8风格。不必一蹴而就,可以对已有代码渐进性地重构,保留原有的取值函数,添加返回 Stream 对象的函数,时间长了,就可以删掉所有返回 List 或 Set 的取值函数。清理了所有遗留代码之后,这种重构方式让人感觉棒极了!

重构遗留代码

https://github.com/lmtsunnie/java-8-lambdas-exercises/blob/master/src/main/java/com/sunnie/java8/examples/chapter3/FindLongTracks19To23.java

为了进一步阐释如何重构遗留代码,本节将举例说明如何将一段使用循环进行集合操作的代码,重构成基于 Stream 的操作。重构过程中的每一步都能确保代码通过单元测试。

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

例 3-19 遗留代码:找出长度大于 1 分钟的曲目

1
2
3
4
5
6
7
8
9
10
11
12
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 越熟悉,就越不需要细分步骤。之所以在示例中一步一步地重构,完全是出于帮助大家学习的目的,在工作中无需这样做)。

第一步要修改的是 for 循环。首先使用 Stream 的 forEach 方法替换掉 for 循环,但还是暂时保留原来循环体中的代码,这是在重构时非常方便的一个技巧。调用 stream 方法从专辑列表中生成第一个 Stream,同时不要忘了在上一节已介绍过,getTrackStream 方法本身就返回一个 Stream 对象。经过第一步重构后,代码如例 3-20 所示。

P.S. Stream.of(artists) vs artists.stream()

1
2
3
4
5
6
List<Artist> artists = generateArtists();
Stream<Artist> stream1 = artists.stream();
Stream<List<Artist>> stream2 = Stream.of(artists);

Stream<Integer> numbers1 = Stream.empty();
Stream<Integer> numbers2 = Stream.of(1, 3, -2);

例 3-20 重构的第一步:找出长度大于 1 分钟的曲目

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public Set<String> findLongTracks(List<Album> albums) {
Set<String> trackNames = new HashSet<>();
albums.forEach(album ->
album.getTrackStream()
.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 分钟的曲目

1
2
3
4
5
6
7
8
9
10
public Set<String> findLongTracks(List<Album> albums) {
Set<String> trackNames = new HashSet<>();
albums.forEach(album ->
album.getTrackStream()
.filter(track -> track.getLength() > 60)
.map(Track::getName)
.forEach(trackNames::add)
);
return trackNames;
}

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

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

例 3-22 重构的第三步:找出长度大于 1 分钟的曲目

1
2
3
4
5
6
7
8
9
public Set<String> findLongTracks(List<Album> albums) {
Set<String> trackNames = new HashSet<>();
albums.stream()
.flatMap(Album::getTrackStream)
.filter(track -> track.getLength() > 60)
.map(Track::getName)
.forEach(trackNames::add);
return trackNames;
}

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

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

例 3-23 重构的第四步:找出长度大于 1 分钟的曲目

1
2
3
4
5
6
7
public Set<String> findLongTracks(List<Album> albums) {
return albums.stream()
.flatMap(Album::getTrackStream)
.filter(track -> track.getLength() > 60)
.map(Track::getName)
.collect(Collectors.toSet());
}

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

多次调用流操作

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

例 3-24 误用 Stream 的例子

1
2
3
4
5
6
7
8
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 使用习惯的链式调用

1
2
3
4
Set<String> origins = album.getMusicians()
.filter(artist -> artist.getName().startsWith("The"))
.map(artist -> artist.getNationality())
.collect(toSet());

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

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

高阶函数

本章中不断出现被函数式编程程序员称为高阶函数的操作。高阶函数是指接受另外一个函数作为参数,或返回一个函数的函数。高阶函数不难辨认:看函数签名就够了。如果函数的参数列表里包含函数接口,或该函数返回一个函数接口,那么该函数就是高阶函数。

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

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

正确使用Lambda表达式

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

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

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

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

1
2
3
4
5
6
private ActionEvent lastEvent;
private void registerHandler() {
button.addActionListener((ActionEvent event) -> {
this.lastEvent = event;
});
}

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

1
2
3
4
ActionEvent localEvent = null;
button.addActionListener(event -> {
localEvent = event;
});

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

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

小结

  • 内部迭代将更多控制权交给了集合类。
  • 和Iterator类似,Stream是一种内部迭代方式。
  • 将Lambda表达式和Stream上的方法结合起来,可以完成很多常见的集合操作。

习题

【1】 常用流操作。实现如下函数:

https://github.com/lmtsunnie/java-8-lambdas-exercises/blob/master/src/main/java/com/sunnie/java8/answers/chapter3/Question1.java

a. 编写一个求和函数,计算流中所有数之和。例如,int addUp(Stream<Integer> numbers);

1
2
3
public static int addUp(Stream<Integer> numbers) {
return numbers.reduce(0, (acc, number) -> acc + number);
}

b. 编写一个函数,接受艺术家列表作为参数,返回一个字符串列表,其中包含艺术家的姓名和国籍

1
2
3
4
5
public static List<String> getArtistNamesAndNationalities(List<Artist> artists) {
return artists.stream()
.flatMap(artist -> Stream.of(artist.getName(), artist.getNationality()))
.collect(Collectors.toList());
}

c. 编写一个函数,接受专辑列表作为参数,返回一个由最多包含 3 首歌曲的专辑组成的列表。

1
2
3
4
5
public static List<Album> getAlbumsWithAtMostThreeTracks(List<Album> albums) {
return albums.stream()
.filter(album -> album.getTrackList().size() <= 3)
.collect(Collectors.toList());
}

【2】迭代。修改如下代码,将外部迭代转换成内部迭代:

https://github.com/lmtsunnie/java-8-lambdas-exercises/blob/master/src/main/java/com/sunnie/java8/answers/chapter3/Question2.java

1
2
3
4
5
6
7
8
public static int countArtistsMembersExternal(List<Artist> artists) {
int totalMembers = 0;
for (Artist artist : artists) {
Stream<Artist> members = artist.getMemberStream();
totalMembers += members.count();
}
return totalMembers;
}
1
2
3
4
5
6
7
8
9
10
11
public static int countArtistsMembersInternal(List<Artist> artists) {
/* return artists.stream()
.map(artist -> artist.getMemberStream().count())
.reduce(0L, (acc, number) -> acc + number)
.intValue();*/
/* return artists.stream()
.map(artist -> artist.getMemberStream().count())
.reduce(0L, Long::sum)
.intValue();*/
return (int) artists.stream().flatMap(Artist::getMemberStream).count();
}

【3】计算一个字符串中小写字母的个数(提示:参阅 String 对象的 chars 方法)

https://github.com/lmtsunnie/java-8-lambdas-exercises/blob/master/src/main/java/com/sunnie/java8/answers/chapter3/Question6.java

1
2
3
4
5
public static int countLowerCaseLetters(String string) {
return (int) string.chars()
.filter(Character::isLowerCase)
.count();
}

【4】在一个字符串列表中,找出包含最多小写字母的字符串。对于空列表,返回 Optional<String>对象。

https://github.com/lmtsunnie/java-8-lambdas-exercises/blob/master/src/main/java/com/sunnie/java8/answers/chapter3/Question7.java

1
2
3
4
public static Optional<String> mostLowerCaseString(List<String> strings) {
return strings.stream()
.max(Comparator.comparing(Question6::countLowerCaseLetters));
}

【5】只用 reduce 和 Lambda 表达式写出实现 Stream 上的 map 操作的代码,如果不想返回 Stream,可以返回一个 List。

【6】只用 reduce 和 Lambda 表达式写出实现 Stream 上的 filter 操作的代码,如果不想返回 Stream,可以返回一个 List。

高级集合类和收集器

元素顺序

关于集合类的内容是流中的元素以何种顺序排列。一些集合类型中的元素是按顺序排列的,比如 List;而另一些则是无序的,比如 HashSet。 增加了流操作后,顺序问题变得更加复杂。

直观上看,流是有序的,因为流中的元素都是按顺序处理的。这种顺序称为出现顺序。出现顺序的定义依赖于数据源和对流的操作。

在一个有序集合中创建一个流时,流中的元素就按出现顺序排列,因此,例 5-1中的代码总是可以通过。

例 5-1 顺序测试永远通过(有序List -> 打散成Stream后有序 -> 收集成的List有序

1
2
3
4
List<Integer> numbers = asList(1, 2, 3, 4);
List<Integer> sameOrder = numbers.stream()
.collect(toList());
assertEquals(numbers, sameOrder);

如果集合本身就是无序的,由此生成的流也是无序的。HashSet 就是一种无序的集合,因此不能保证例 5-2 所示的程序每次都通过。

例 5-2 顺序测试不能保证每次通过(无序Set -> 打散成Stream后无序 -> 收集成的Set无序

1
2
3
4
5
Set<Integer> numbers = new HashSet<>(asList(4, 3, 2, 1));
List<Integer> sameOrder = numbers.stream()
.collect(toList());
// 该断言有时会失败
assertEquals(asList(4, 3, 2, 1), sameOrder);

流的目的不仅是在集合类之间做转换,而且同时提供了一组处理数据的通用操作。有些集合本身是无序的,但这些操作有时会产生顺序,试看例 5-3 中的代码。

例 5-3 生成出现顺序(无序Set -> 打散成Stream后无序 -> 排序 -> 收集成的List有序

1
2
3
4
5
Set<Integer> numbers = new HashSet<>(asList(4, 3, 2, 1));
List<Integer> sameOrder = numbers.stream()
.sorted()
.collect(toList());
assertEquals(asList(1, 2, 3, 4), sameOrder);

一些中间操作会产生顺序,比如对值做映射时,映射后的值是有序的,这种顺序就会保留下来。如果进来的流是无序的,出去的流也是无序的。看一下例 5-4 所示代码,我们只能断言 HashSet 中含有某元素,但对其顺序不能作出任何假设,因为 HashSet 是无序的,使用了映射操作后,得到的集合仍然是无序的。

例 5-4 本例中关于顺序的假设永远是正确的(List -> 映射 -> 有序 / Set -> 映射 -> 无序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
List<Integer> numbers = asList(1, 2, 3, 4);
List<Integer> stillOrdered = numbers.stream()
.map(x -> x + 1)
.collect(toList());
// 顺序得到了保留
assertEquals(asList(2, 3, 4, 5), stillOrdered);

Set<Integer> unordered = new HashSet<>(numbers);
List<Integer> stillUnordered = unordered.stream()
.map(x -> x + 1)
.collect(toList());
// 顺序得不到保证
assertThat(stillUnordered, hasItem(2));
assertThat(stillUnordered, hasItem(3));
assertThat(stillUnordered, hasItem(4));
assertThat(stillUnordered, hasItem(5));

一些操作在有序的流上开销更大,调用 unordered 方法消除这种顺序就能解决该问题。大多数操作都是在有序流上效率更高,比如 filter、map 和 reduce 等

这会带来一些意想不到的结果,比如使用并行流时,forEach 方法不能保证元素是按顺序处理的(第 6 章会详细讨论这些内容)。如果需要保证按顺序处理,应该使用 forEachOrdered 方法

使用收集器

前面我们使用过 collect(toList()),在流中生成列表。显然,List 是能想到的从流中生成的最自然的数据结构,但是有时人们还希望从流生成其他值,比如 Map 或 Set,或者你希望定制一个类将你想要的东西抽象出来。

前面已经讲过,仅凭流上方法的签名,就能判断出这是否是一个及早求值的操作。reduce 操作就是一个很好的例子,但有时人们希望能做得更多。

这就是收集器,一种通用的、从流生成复杂值的结构。只要将它传给 collect 方法,所有的流就都可以使用它了。

标准类库已经提供了一些有用的收集器,让我们先来看看。本章示例代码中的收集器都是从java.util.stream.Collectors 类中静态导入的。

转换成其他集合

有一些收集器可以生成其他集合。比如前面已经见过的 toList,生成了 java.util.List 类的实例。还有 toSet 和 toCollection,分别生成 Set 和 Collection 类的实例。到目前为止, 我已经讲了很多流上的链式操作,但总有一些时候,需要最终生成一个集合——比如:

  • 􏰈已有代码是为集合编写的,因此需要将流转换成集合传入;
  • 在集合上进行一系列链式操作后,最终希望生成一个值;
  • 写单元测试时,需要对某个具体的集合做断言。

通常情况下,创建集合时需要调用适当的构造函数指明集合的具体类型:

1
List<Artist> artists = new ArrayList<>();

但是调用 toList 或者 toSet 方法时,不需要指定具体的类型。Stream 类库在背后自动为你挑选出了合适的类型。本书后面会讲述如何使用 Stream 类库并行处理数据,收集并行操作的结果需要的 Set,和对线程安全没有要求的 Set 类是完全不同的。

可能还会有这样的情况,你希望使用一个特定的集合收集值,而且你可以稍后指定该集合的类型。比如,你可能希望使用 TreeSet,而不是由框架在背后自动为你指定一种类型的 Set。此时就可以使用 toCollection,它接受一个函数作为参数,来创建集合(见例 5-5)。

例 5-5 使用 toCollection,用定制的集合收集元素

1
stream.collect(toCollection(TreeSet::new));

转换成值

还可以利用收集器让流生成一个值。maxBy 和 minBy 允许用户按某种特定的顺序生成一个值。例 5-6 展示了如何找出成员最多的乐队。它使用一个 Lambda 表达式,将艺术家映射为成员数量,然后定义了一个比较器,并将比较器传入 maxBy 收集器。

例 5-6 找出成员最多的乐队

1
2
3
4
5
public Optional<Artist> biggestGroup(Stream<Artist> artists) { 
// Function<Artist, Long> getCount = artist -> artist.getMembers().count();
return artistStream.collect(maxBy(comparing(artist -> artist.getMembers().size())));
// return artistStream.max(comparing(artist -> artist.getMembers().size()));
}

minBy 就如它的方法名,是用来找出最小值的。

还有些收集器实现了一些常用的数值运算。让我们通过一个计算专辑曲目平均数的例子来看看,如例 5-7 所示。

例 5-7 找出一组专辑上曲目的平均数

1
2
3
4
public double averageNumberOfTracks(List<Album> albums) { 
return albums.stream()
.collect(averagingInt(album -> album.getTrackList().size()));
}

和以前一样,通过调用stream方法让集合生成流,然后调用collect方法收集结果。 averagingInt 方法接受一个 Lambda 表达式作参数,将流中的元素转换成一个整数,然后再计算平均数。还有和 double 和 long 类型对应的重载方法,帮助程序员将元素转换成相应类型的值。

第 4 章介绍过一些特殊的流,如 IntStream,为数值运算定义了一些额外的方法。事实上, Java 8也提供了能完成类似功能的收集器,如averagingInt。可以使用summingInt及其重载方法求和。SummaryStatistics 也可以使用 summingInt 及其组合收集。

数据分块

另外一个常用的流操作是将其分解成两个集合。假设有一个艺术家组成的流,你可能希望将其分成两个部分,一部分是独唱歌手,另一部分是由多人组成的乐队。可以使用两次过滤操作,分别过滤出上述两种艺术家。

但是这样操作起来有问题。首先,为了执行两次过滤操作,需要有两个流。其次,如果过滤操作复杂,每个流上都要执行这样的操作,代码也会变得冗余。

幸好我们有这样一个收集器 partitioningBy,它接受一个流,并将其分成两部分(如图5-1 所示)。它使用 Predicate 对象判断一个元素应该属于哪个部分,并根据布尔值返回一个Map到列表。因此,对于true List中的元素,Predicate返回true;对其他List中的元素,Predicate 返回 false。

图 5-1:partitioningBy 收集器

使用它,我们就可以将乐队(有多个成员)和独唱歌手分开了。在本例中,分块函数指明艺术家是否为独唱歌手。实现如例 5-8 所示。

例 5-8 将艺术家组成的流分成乐队和独唱歌手两部分

1
2
3
public Map<Boolean, List<Artist>> bandsAndSolo(Stream<Artist> artists) { 
return artists.collect(partitioningBy(artist -> artist.isSolo()));
}

也可以使用方法引用代替 Lambda 表达式,如例 5-9 所示。

例 5-9 使用方法引用将艺术家组成的 Stream 分成乐队和独唱歌手两部分

1
2
3
public Map<Boolean, List<Artist>> bandsAndSoloRef(Stream<Artist> artists) { 
return artists.collect(partitioningBy(Artist::isSolo));
}

数据分组

数据分组是一种更自然的分割数据操作,与将数据分成 ture 和 false 两部分不同,可以使 用任意值对数据分组。比如现在有一个由专辑组成的流,可以按专辑当中的主唱对专辑分组。代码如例 5-10 所示。

例 5-10 使用主唱对专辑分组

1
2
3
public Map<Artist, List<Album>> albumsByArtist(Stream<Album> albums) {
return albums.collect(groupingBy(album -> album.getMainMusician()));
}

和其他例子一样,调用流的 collect 方法,传入一个收集器。groupingBy 收集器(如图5-2 所示)接受一个分类函数,用来对数据分组,就像 partitioningBy 一样,接受一个Predicate 对象将数据分成 ture 和 false 两部分。我们使用的分类器是一个 Function 对象,和 map 操作用到的一样。

图 5-2:GroupingBy 收集器

这和SQL中的group by是类似的一个概念, 只不过在 Stream 类库中实现了而已。

字符串

很多时候,收集流中的数据都是为了在最后生成一个字符串。假设我们想将参与制作一张专辑的所有艺术家的名字输出为一个格式化好的列表,以专辑Let It Be为例,期望的输出为:”[George Harrison, John Lennon, Paul McCartney, Ringo Starr, The Beatles]”。

在Java 8还未发布前,实现该功能的代码可能如例5-11所示。通过不断迭代列表,使用一 个 StringBuilder 对象来记录结果。每一步都取出一个艺术家的名字,追加到 StringBuilder 对象。

例 5-11 使用 for 循环格式化艺术家姓名

1
2
3
4
5
6
7
8
9
10
StringBuilder builder = new StringBuilder("["); 
for (Artist artist : artists) {
if (builder.length() > 1) {
builder.append(", ");
}
String name = artist.getName();
builder.append(name);
}
builder.append("]");
String result = builder.toString();

显然,这段代码不是非常好。如果不一步步跟踪,很难看出这段代码是干什么的。使用Java 8 提供的流和收集器就能写出更清晰的代码,如例 5-12 所示。

例 5-12 使用流和收集器格式化艺术家姓名

1
2
3
4
String result =
artists.stream()
.map(Artist::getName)
.collect(Collectors.joining(", ", "[", "]"));

这里使用 map 操作提取出艺术家的姓名,然后使用 Collectors.joining 收集流中的值,该方法可以方便地从一个流得到一个字符串,允许用户提供分隔符(用以分隔元素)、前缀和后缀。

组合收集器

虽然读者现在看到的各种收集器已经很强大了,但如果将它们组合起来,会变得更强大。

之前我们使用主唱将专辑分组,现在来考虑如何计算一个艺术家的专辑数量。一个简单的方案是使用前面的方法对专辑先分组后计数,如例 5-13 所示。

例 5-13 计算每个艺术家专辑数的简单方式

1
2
3
4
5
6
Map<Artist, List<Album>> albumsByArtist
= albums.collect(groupingBy(album -> album.getMainMusician()));
Map<Artist, Integer> numberOfAlbums = new HashMap<>();
for (Entry<Artist, List<Album>> entry : albumsByArtist.entrySet()) {
numberOfAlbums.put(entry.getKey(), entry.getValue().size());
}

这种方式看起来简单,但却有点杂乱无章。这段代码也是命令式的代码,不能自动适应并行化操作。

这里实际上需要另外一个收集器,告诉 groupingBy 不用为每一个艺术家生成一个专辑列表,只需要对专辑计数就可以了。幸好,核心类库已经提供了一个这样的收集器: counting。使用它,可将上述代码重写为例 5-14 所示的样子。

例 5-14 使用收集器计算每个艺术家的专辑数

1
2
3
public Map<Artist, Long> numberOfAlbums(Stream<Album> albums) { 
return albums.collect(groupingBy(album -> album.getMainMusician(), counting()));
}

groupingBy 先将元素分成块,每块都与分类函数 getMainMusician 提供的键值相关联,然后使用下游的另一个收集器收集每块中的元素,最好将结果映射为一个 Map。 让我们再看一个例子,这次我们不想生成一组专辑,只希望得到专辑名。这个问题仍然可以用前面的方法解决,先将专辑分组,然后再调整生成的 Map 中的值,如例 5-15 所示。

例 5-15 使用简单方式求每个艺术家的专辑名

1
2
3
4
5
6
7
8
9
10
11
12
public Map<Artist, List<String>> nameOfAlbumsDumb(Stream<Album> albums) { 
Map<Artist, List<Album>> albumsByArtist =
albums.collect(groupingBy(album ->album.getMainMusician()));
Map<Artist, List<String>> nameOfAlbums = new HashMap<>();
for (Entry<Artist, List<Album>> entry : albumsByArtist.entrySet()) {
nameOfAlbums.put(entry.getKey(), entry.getValue()
.stream()
.map(Album::getName)
.collect(toList()));
}
return nameOfAlbums;
}

同理,我们可以再使用一个收集器,编写出更好、更快、更容易并行处理的代码。我们已经知道,可以使用 groupingBy 将专辑按主唱分组,但是其输出为一个 Map<Artist, List<Album>>对象,它将每个艺术家和他的专辑列表关联起来,但这不是我们想要的,我们想要的是一个包含专辑名的字符串列表。

此时,我们真正想做的是将专辑列表映射为专辑名列表,这里不能直接使用流的 map 操作,因为列表是由 groupingBy 生成的。我们需要有一种方法,可以告诉 groupingBy 将它的值做映射,生成最终结果。

每个收集器都是生成最终值的一剂良方。这里需要两剂配方,一个传给另一个。

mapping 允许在收集器的容器上执行类似 map 的操作。但是需要指明使用什么样的集合类存储结果,比如 toList。这些收集器就像乌龟叠罗汉,龟龟相驮以至无穷。

mapping 收集器和 map 方法一样,接受一个 Function 对象作为参数,经过重构后的代码如例 5-16 所示。

例 5-16 使用收集器求每个艺术家的专辑名

1
2
3
4
public Map<Artist, List<String>> nameOfAlbums(Stream<Album> albums) { 
return albums.collect(groupingBy(Album::getMainMusician,
mapping(Album::getName, toList())));
}

这两个例子中我们都用到了第二个收集器,用以收集最终结果的一个子集。这些收集器叫作下游收集器。收集器是生成最终结果的一剂配方,下游收集器则是生成部分结果的配方,主收集器中会用到下游收集器。这种组合使用收集器的方式,使得它们在 Stream 类库中的作用更加强大。

那些为基本类型特殊定制的函数,如 averagingInt、summarizingLong 等,事实上和调用特殊 Stream 上的方法是等价的,加上它们是为了将它们当作下游收集器来使用的。

重构和定制收集器

尽管在常用流操作里,Java 内置的收集器已经相当好用,但收集器框架本身是极其通用的。JDK 提供的收集器没有什么特别的,完全可以定制自己的收集器,而且定制起来相当简单,这就是本节要讲的内容。

读者可能还没忘记在例 5-11 中,如何使用 Java 7 连接字符串,尽管形式并不优雅。让我们逐步重构这段代码,最终用合适的收集器实现原有代码功能。在工作中没有必要这样做, JDK 已经提供了一个完美的收集器 joining。这里只是为了展示如何定制收集器,以及如 何使用 Java 8 提供的新功能来重构遗留代码。

例 5-17 复制了例 5-11,展示了如何在 Java 7 中连接字符串。

例 5-17 使用 for 循环和 StringBuilder 格式化艺术家姓名

1
2
3
4
5
6
7
8
9
10
StringBuilder builder = new StringBuilder("["); 
for (Artist artist : artists) {
if (builder.length() > 1) {
builder.append(", ");
}
String name = artist.getName();
builder.append(name);
}
builder.append("]");
String result = builder.toString();

显然,可以使用 map 操作,将包含艺术家的流映射为包含艺术家姓名的流。例 5-18 展示了使用了流的 map 操作重构后的代码。

例 5-18 使用 forEach 和 StringBuilder 格式化艺术家姓名

1
2
3
4
5
6
7
8
9
10
11
StringBuilder builder = new StringBuilder("[");
artists.stream()
.map(Artist::getName)
.forEach(name -> {
if (builder.length() > 1) {
builder.append(", ");
}
builder.append(name);
});
builder.append("]");
String result = builder.toString();

将艺术家映射为姓名,就能更快看出最终是要生成什么,这样代码看起来更清楚一点。可惜 forEach 方法看起来还是有点笨重,这与我们通过组合高级操作让代码变得易读的目标不符。

暂且不必考虑定制一个收集器,让我们想想怎么通过流上已有的操作来解决该问题。和生成字符串目标最近的操作就是 reduce,使用它将例 5-18 中的代码重构如下。

例 5-19 使用 reduce 和 StringBuilder 格式化艺术家姓名

1
2
3
4
5
6
7
8
9
10
11
12
StringBuilder reduced =
artists.stream()
.map(Artist::getName)
.reduce(new StringBuilder(), (builder, name) -> {
if (builder.length() > 0)
builder.append(", ");
builder.append(name);
return builder;
}, (left, right) -> left.append(right));
reduced.insert(0, "[");
reduced.append("]");
String result = reduced.toString();

我曾经天真地以为上面的重构会让代码变得更清晰,可惜恰好相反,代码看起来比以前更糟糕。让我们先来看看怎么回事。和前面的例子一样,都调用了 stream 和 map 方 法,reduce 操作生成艺术家姓名列表,艺术家与艺术家之间用“,”分隔。首先创建一 个 StringBuilder 对象,该对象是 reduce 操作的初始状态,然后使用 Lambda 表达式将 姓名连接到 builder 上。reduce 操作的第三个参数也是一个 Lambda 表达式,接受两个 StringBuilder 对象做参数,将两者连接起来。最后添加前缀和后缀。

在接下来的重构中,我们还是使用 reduce 操作,不过需要将杂乱无章的代码隐藏掉——我的意思是使用一个 StringCombiner 类对细节进行抽象。代码如例 5-20 所示。

例 5-20 使用 reduce 和 StringCombiner 类格式化艺术家姓名

1
2
3
4
5
6
7
StringCombiner combined =
artists.stream()
.map(Artist::getName)
.reduce(new StringCombiner(", ", "[", "]"),
StringCombiner::add,
StringCombiner::merge);
String result = combined.toString();

尽管代码看起来和上个例子大相径庭,其实背后做的工作是一样的。我们使用 reduce 操作将姓名和分隔符连接成一个 StringBuilder 对象。不过这次连接姓名操作被代理到了StringCombiner.add 方法,而连接两个连接器操作被 StringCombiner.merge 方法代理。让我们现在来看看这些方法,先从例 5-21 中的 add 方法开始。

例 5-21 add 方法返回连接新元素后的结果

1
2
3
4
5
6
7
8
9
public StringCombiner add(String element) { 
if (areAtStart()) {
builder.append(prefix);
} else {
builder.append(delim);
}
builder.append(element);
return this;
}

add 方法在内部其实将操作代理给一个 StringBuilder 对象。如果刚开始进行连接,则在最前面添加前缀,否则添加分隔符,然后再添加新的元素。这里返回一个 StringCombiner 对象,因为这是传给 reduce 操作所需要的类型。合并代码也是同样的道理,内部将操作代理给 StringBuilder 对象,如例 5-22 所示。

例 5-22 merge 方法连接两个 StringCombiner 对象

1
2
3
4
public StringCombiner merge(StringCombiner other) { 
builder.append(other.builder);
return this;
}

reduce 阶段的重构还差一小步就差不多结束了。我们要在最后调用 toString 方法,将整个步骤串成一个方法链。这很简单,只需要排列好 reduce 代码,准备好将其转换为Collector API 就行了(如例 5-23 所示)。

例 5-23 使用 reduce 操作,将工作代理给 StringCombiner 对象

1
2
3
4
5
6
7
String result =
artists.stream()
.map(Artist::getName)
.reduce(new StringCombiner(", ", "[", "]"),
StringCombiner::add,
StringCombiner::merge)
.toString();

现在的代码看起来已经差不多完美了,但是在程序中还是不能重用。因此,我们想将reduce 操作重构为一个收集器,在程序中的任何地方都能使用。不妨将这个收集器叫作StringCollector,让我们重构代码使用这个新的收集器,如例 5-24 所示。

例 5-24 使用定制的收集器 StringCollector 收集字符串

1
2
3
4
String result =
artists.stream()
.map(Artist::getName)
.collect(new StringCollector(", ", "[", "]"));

既然已经将所有对字符串的连接操作代理给了定制的收集器,应用程序就不需要关心 StringCollector 对象的任何内部细节,它和框架中其他 Collector 对象用起来是一样的。

先来实现 Collector 接口(例 5-25),由于 Collector 接口支持泛型,因此先得确定一些具体的类型:

  • 待收集元素的类型,这里是String;
  • 累加器的类型StringCombiner;
  • 最终结果的类型,这里依然是String。

例 5-25 定义字符串收集器

1
public class StringCollector implements Collector<String, StringCombiner, String> {

一个收集器由四部分组成。首先是一个 Supplier,这是一个工厂方法,用来创建容器,在这个例子中,就是 StringCombiner。和 reduce 操作中的第一个参数类似,它是后续操作的初值(如例 5-26 所示)。

例5-26 Supplier是创建容器的工厂

1
2
3
public Supplier<StringCombiner> supplier() {
return () -> new StringCombiner(delim, prefix, suffix);
}

让我们一边阅读代码,一边看图,这样就能看清到底是怎么工作的。由于收集器可以并行收集,我们要展示的收集操作在两个容器上(比如 StringCombiners)并行进行。

收集器的每一个组件都是函数,因此我们使用箭头表示,流中的值用圆圈表示,最终生成 的值用椭圆表示。收集操作一开始,Supplier 先创建出新的容器(如图 5-3)。

图 5-3:Supplier

收集器的 accumulator 的作用和 reduce 操作的第二个参数一样,它结合之前操作的结果 和当前值,生成并返回新的值。这一逻辑已经在 StringCombiners 的 add 方法中得以实现, 直接引用就好了(如例 5-27 所示)。

例 5-27 accumulator 是一个函数,它将当前元素叠加到收集器

1
2
3
public BiConsumer<StringCombiner, String> accumulator() { 
return StringCombiner::add;
}

这里的 accumulator 用来将流中的值叠加入容器中(如图 5-4 所示)。

combine 方法很像 reduce 操作的第三个方法。如果有两个容器,我们需要将其合并。同样,在前面的重构中我们已经实现了该功能,直接使用 StringCombiner.merge 方法就行了(例 5-28)。

例 5-28 combiner 合并两个容器

1
2
3
public BinaryOperator<StringCombiner> combiner() { 
return StringCombiner::merge;
}

在收集阶段,容器被 combiner 方法成对合并进一个容器,直到最后只剩一个容器为止(如图 5-5 所示)。

图 5-5:Combiner

读者可能还记得,在使用收集器之前,重构的最后一步将 toString 方法内联到方法链的末端,这就将 StringCombiners 转换成了我们想要的字符串(如图 5-6 所示)。

图 5-6:Finisher

收集器的 finisher 方法作用相同。我们已经将流中的值叠加入一个可变容器中,但这还不 是我们想要的最终结果。这里调用了 finisher 方法,以便进行转换。在我们想创建字符串 等不可变的值时特别有用,这里容器是可变的。

为了实现 finisher 方法,只需将该操作代理给已经实现的 toString 方法即可(例 5-29)。

例 5-29 finisher 方法返回收集操作的最终结果

1
2
3
public Function<StringCombiner, String> finisher() { 
return StringCombiner::toString;
}

从最后剩下的容器中得到最终结果。 关于收集器,还有一点一直没有提及,那就是特征。特征是一组描述收集器的对象,框架可以对其适当优化。characteristics 方法定义了特征。

在这里我有必要重申,这些代码只作教学用途,和 joining 收集器的内部实现略有出入。 读者也许会认为 StringCombiner 看起来非常有用,别担心——你没必要亲自去编写,Java 8 有一个 java.util.StringJoiner 类,它的作用和 StringCombiner 一样,有类似的 API。

做这些练习的主要目的不仅在于展示定制收集器的工作原理,而且还在于帮助读者编写自 己的收集器。特别是你有自己特定领域内的类,希望从集合中构建一个操作,而标准的集 合类并没有提供这种操作时,就需要定制自己的收集器。

以 StringCombiner 为例,收集值的容器和我们想要创建的值(字符串)不一样。如果想要 收集的是不可变对象,而不是可变对象,那么这种情况就非常普遍,否则收集操作的每一 步都需要创建一个新值。

想要收集的最终结果和容器一样是完全有可能的。事实上,如果收集的最终结果是集合, 比如 toList 收集器,就属于这种情况。

此时,finisher 方法不需要对容器做任何操作。更正式地说,此时的 finisher 方法其实是 identity 函数:它返回传入参数的值。如果这样,收集器就展现出 IDENTITY_FINISH 的特 征,需要使用 characteristics 方法声明。

对收集器的归一化处理

就像之前看到的那样,定制收集器其实不难,但如果你想为自己领域内的类定制一个收集 器,不妨考虑一下其他替代方案。最容易想到的方案是构建若干个集合对象,作为参数传 给领域内类的构造函数。如果领域内的类包含多种集合,这种方式又简单又适用。

当然,如果领域内的类没有这些集合,需要在已有数据上计算,那这种方法就不合适了。 但即使如此,也不见得需要定制一个收集器。你还可以使用 reducing 收集器,它为流上的归一操作提供了统一实现。例 5-30 展示了如何使用 reducing 收集器编写字符串处理程序。

例 5-30 reducing 是一种定制收集器的简便方式

1
2
3
4
5
6
7
String result =
artists.stream()
.map(Artist::getName)
.collect(Collectors.reducing(
new StringCombiner(", ", "[", "]"),
name -> new StringCombiner(", ", "[", "]").add(name), StringCombiner::merge))
.toString();

这和我在例 5-20 中讲到的基于 reduce 操作的实现很像,这点从方法名中就能看出。 区别在于 Collectors.reducing 的第二个参数,我们为流中每个元素创建了唯一的 StringCombiner。如果你被这种写法吓到了,或是感到恶心,你不是一个人!这种方式非 常低效,这也是我要定制收集器的原因之一。

一些细节

Lambda 表达式的引入也推动了一些新方法被加入集合类。让我们来看看 Map 类的一些变化。

构建 Map 时,为给定值计算键值是常用的操作之一,一个经典的例子就是实现一个缓存。

传统的处理方式是先试着从 Map 中取值,如果没有取到,创建一个新值并返回。 假设使用Map<String, Artist> artistCache定义缓存,我们需要使用费时的数据库操作查询艺术家信息,代码可能如例 5-31 所示。

例 5-31 使用显式判断空值的方式缓存

1
2
3
4
5
6
7
8
public Artist getArtist(String name) { 
Artist artist = artistCache.get(name);
if (artist == null) {
artist = readArtistFromDB(name);
artistCache.put(name, artist);
}
return artist;
}

Java 8引入了一个新方法computeIfAbsent,该方法接受一个Lambda表达式,值不存在时使用该 Lambda 表达式计算新值。使用该方法,可将上述代码重写为例 5-32 所示的形式。

例 5-32 使用 computeIfAbsent 缓存

1
2
3
public Artist getArtist(String name) {
return artistCache.computeIfAbsent(name, this::readArtistFromDB);
}

你可能还希望在值不存在时不计算,为 Map 接口新增的 compute 和 computeIfAbsent 就能处 理这些情况。

在工作中,你可能尝试过在 Map 上迭代。过去的做法是使用 value 方法返回一个值的集合, 然后在集合上迭代。这样的代码不易读。例 5-33 展示了本章早些时候介绍的一种方式,创 建一个 Map,然后统计每个艺术家专辑的数量。

例 5-33 一种丑陋的迭代 Map 的方式

1
2
3
4
5
6
Map<Artist, Integer> countOfAlbums = new HashMap<>(); 
for (Map.Entry<Artist, List<Album>> entry : albumsByArtist.entrySet()) {
Artist artist = entry.getKey();
List<Album> albums = entry.getValue();
countOfAlbums.put(artist, albums.size());
}

谢天谢地,Java 8为Map接口新增了一个forEach方法,该方法接受一个BiConsumer对象为参数(该对象接受两个参数,返回空),通过内部迭代编写出易于阅读的代码,关于内部迭代请参考 3.1 节。使用该方法重写后的代码如例 5-34 所示。

例 5-34 使用内部迭代遍历 Map 里的值

1
2
3
4
Map<Artist, Integer> countOfAlbums = new HashMap<>();
albumsByArtist.forEach((artist, albums) -> {
countOfAlbums.put(artist, albums.size());
});

要点回顾

  • 􏰀方法引用是一种引用方法的轻量级语法,形如:ClassName::methodName。 􏰀
  • 收集器可用来计算流的最终值,是reduce方法的模拟。
  • Java 8 提供了收集多种容器类型的方式,同时允许用户自定义收集器。
1
import static java.util.stream.Collectors;
  • counting:得到总数
  • partitionBy:按yes/no分成两组
  • groupingBy:分成n组
  • mapping(A, B):根据A映射出B
  • joining:连接
  • minBy:找最小
  • maxBy:找最大
  • averageInt:找平均整数

给定List<Person>,根据城市分类出lastName

1
2
3
<City, Set<String>> lastNamesByCity = people.stream()
.collect(groupingBy(Person::getCity,
mapping(Person::getLastName, toSet())));
谢谢小天使请我吃糖果
0%