
Streams
支持的操作很丰富,除了上面介绍的这些比较常用的中间操作,如filter
或map
(参见Stream Javadoc)外。还有一些更复杂的操作,如collect
,flatMap
以及reduce
。接下来,就让我们学习一下:
本小节中的大多数代码示例均会使用以下 List<Person>
进行演示:
class Person {
String name;
int age;
Person(String name, int age) {
this.name = name;
this.age = age;
}
@Override
public String toString() {
return name;
}
}
// 构建一个 Person 集合
List<Person> persons =
Arrays.asList(
new Person("Max", 18),
new Person("Peter", 23),
new Person("Pamela", 23),
new Person("David", 12));
复制代码
6.1 Collect
collect 是一个非常有用的终端操作,它可以将流中的元素转变成另外一个不同的对象,例如一个List
,Set
或Map
。collect 接受入参为Collector
(收集器),它由四个不同的操作组成:供应器(supplier)、累加器(accumulator)、组合器(combiner)和终止器(finisher)。
这些都是个啥?别慌,看上去非常复杂的样子,但好在大多数情况下,您并不需要自己去实现收集器。因为 Java 8通过Collectors
类内置了各种常用的收集器,你直接拿来用就行了。
让我们先从一个非常常见的用例开始:
List<Person> filtered =
persons
.stream() // 构建流
.filter(p -> p.name.startsWith("P")) // 过滤出名字以 P 开头的
.collect(Collectors.toList()); // 生成一个新的 List
System.out.println(filtered); // [Peter, Pamela]
复制代码
你也看到了,从流中构造一个 List
异常简单。如果说你需要构造一个 Set
集合,只需要使用Collectors.toSet()
就可以了。
接下来这个示例,将会按年龄对所有人进行分组:
Map<Integer, List<Person>> personsByAge = persons
.stream()
.collect(Collectors.groupingBy(p -> p.age)); // 以年龄为 key,进行分组
personsByAge
.forEach((age, p) -> System.out.format("age %s: %s\n", age, p));
// age 18: [Max]
// age 23: [Peter, Pamela]
// age 12: [David]
复制代码
除了上面这些操作。您还可以在流上执行聚合操作,例如,计算所有人的平均年龄:
Double averageAge = persons
.stream()
.collect(Collectors.averagingInt(p -> p.age)); // 聚合出平均年龄
System.out.println(averageAge); // 19.0
复制代码
如果您还想得到一个更全面的统计信息,摘要收集器可以返回一个特殊的内置统计对象。通过它,我们可以简单地计算出最小年龄、最大年龄、平均年龄、总和以及总数量。
IntSummaryStatistics ageSummary =
persons
.stream()
.collect(Collectors.summarizingInt(p -> p.age)); // 生成摘要统计
System.out.println(ageSummary);
// IntSummaryStatistics{count=4, sum=76, min=12, average=19.000000, max=23}
复制代码
下一个这个示例,可以将所有人名连接成一个字符串:
String phrase = persons
.stream()
.filter(p -> p.age >= 18) // 过滤出年龄大于等于18的
.map(p -> p.name) // 提取名字
.collect(Collectors.joining(" and ", "In Germany ", " are of legal age.")); // 以 In Germany 开头,and 连接各元素,再以 are of legal age. 结束
System.out.println(phrase);
// In Germany Max and Peter and Pamela are of legal age.
复制代码
连接收集器的入参接受分隔符,以及可选的前缀以及后缀。
对于如何将流转换为 Map
集合,我们必须指定 Map
的键和值。这里需要注意,Map
的键必须是唯一的,否则会抛出IllegalStateException
异常。
你可以选择传递一个合并函数作为额外的参数来避免发生这个异常:
Map<Integer, String> map = persons
.stream()
.collect(Collectors.toMap(
p -> p.age,
p -> p.name,
(name1, name2) -> name1 + ";" + name2)); // 对于同样 key 的,将值拼接
System.out.println(map);
// {18=Max, 23=Peter;Pamela, 12=David}
复制代码
既然我们已经知道了这些强大的内置收集器,接下来就让我们尝试构建自定义收集器吧。
比如说,我们希望将流中的所有人转换成一个字符串,包含所有大写的名称,并以|
分割。为了达到这种效果,我们需要通过Collector.of()
创建一个新的收集器。同时,我们还需要传入收集器的四个组成部分:供应器、累加器、组合器和终止器。
Collector<Person, StringJoiner, String> personNameCollector =
Collector.of(
() -> new StringJoiner(" | "), // supplier 供应器
(j, p) -> j.add(p.name.toUpperCase()), // accumulator 累加器
(j1, j2) -> j1.merge(j2), // combiner 组合器
StringJoiner::toString); // finisher 终止器
String names = persons
.stream()
.collect(personNameCollector); // 传入自定义的收集器
System.out.println(names); // MAX | PETER | PAMELA | DAVID
复制代码
由于Java 中的字符串是 final 类型的,我们需要借助辅助类StringJoiner
,来帮我们构造字符串。
最开始供应器使用分隔符构造了一个StringJointer
。
累加器用于将每个人的人名转大写,然后加到StringJointer
中。
组合器将两个StringJointer
合并为一个。
最终,终结器从StringJointer
构造出预期的字符串。
6.2 FlatMap
上面我们已经学会了如通过map
操作, 将流中的对象转换为另一种类型。但是,Map
只能将每个对象映射到另一个对象。
如果说,我们想要将一个对象转换为多个其他对象或者根本不做转换操作呢?这个时候,flatMap
就派上用场了。
FlatMap
能够将流的每个元素, 转换为其他对象的流。因此,每个对象可以被转换为零个,一个或多个其他对象,并以流的方式返回。之后,这些流的内容会被放入flatMap
返回的流中。
在学习如何实际操作flatMap
之前,我们先新建两个类,用来测试:
class Foo {
String name;
List<Bar> bars = new ArrayList<>();
Foo(String name) {
this.name = name;
}
}
class Bar {
String name;
Bar(String name) {
this.name = name;
}
}
复制代码
接下来,通过我们上面学习到的流知识,来实例化一些对象:
List<Foo> foos = new ArrayList<>();
// 创建 foos 集合
IntStream
.range(1, 4)
.forEach(i -> foos.add(new Foo("Foo" + i)));
// 创建 bars 集合
foos.forEach(f ->
IntStream
.range(1, 4)
.forEach(i -> f.bars.add(new Bar("Bar" + i + " <- " + f.name))));
复制代码
我们创建了包含三个foo
的集合,每个foo
中又包含三个 bar
。
flatMap
的入参接受一个返回对象流的函数。为了处理每个foo
中的bar
,我们需要传入相应 stream 流:
foos.stream()
.flatMap(f -> f.bars.stream())
.forEach(b -> System.out.println(b.name));
// Bar1 <- Foo1
// Bar2 <- Foo1
// Bar3 <- Foo1
// Bar1 <- Foo2
// Bar2 <- Foo2
// Bar3 <- Foo2
// Bar1 <- Foo3
// Bar2 <- Foo3
// Bar3 <- Foo3
复制代码
如上所示,我们已成功将三个 foo
对象的流转换为九个bar
对象的流。
最后,上面的这段代码可以简化为单一的流式操作:
IntStream.range(1, 4)
.mapToObj(i -> new Foo("Foo" + i))
.peek(f -> IntStream.range(1, 4)
.mapToObj(i -> new Bar("Bar" + i + " <- " f.name))
.forEach(f.bars::add))
.flatMap(f -> f.bars.stream())
.forEach(b -> System.out.println(b.name));
复制代码
flatMap
也可用于Java8引入的Optional
类。Optional
的flatMap
操作返回一个Optional
或其他类型的对象。所以它可以用于避免繁琐的null
检查。
接下来,让我们创建层次更深的对象:
class Outer {
Nested nested;
}
class Nested {
Inner inner;
}
class Inner {
String foo;
}
复制代码
为了处理从 Outer 对象中获取最底层的 foo 字符串,你需要添加多个null
检查来避免可能发生的NullPointerException
,如下所示:
Outer outer = new Outer();
if (outer != null && outer.nested != null && outer.nested.inner != null) {
System.out.println(outer.nested.inner.foo);
}
复制代码
我们还可以使用Optional
的flatMap
操作,来完成上述相同功能的判断,且更加优雅:
Optional.of(new Outer())
.flatMap(o -> Optional.ofNullable(o.nested))
.flatMap(n -> Optional.ofNullable(n.inner))
.flatMap(i -> Optional.ofNullable(i.foo))
.ifPresent(System.out::println);
复制代码
如果不为空的话,每个flatMap
的调用都会返回预期对象的Optional
包装,否则返回为null
的Optional
包装类。
笔者补充:关于 Optional 可参见我另一篇译文《Java8 新特性如何防止空指针异常》
6.3 Reduce
规约操作可以将流的所有元素组合成一个结果。Java 8 支持三种不同的reduce
方法。第一种将流中的元素规约成流中的一个元素。
让我们看看如何使用这种方法,来筛选出年龄最大的那个人:
persons
.stream()
.reduce((p1, p2) -> p1.age > p2.age ? p1 : p2)
.ifPresent(System.out::println); // Pamela
复制代码
reduce
方法接受BinaryOperator
积累函数。该函数实际上是两个操作数类型相同的BiFunction
。BiFunction
功能和Function
一样,但是它接受两个参数。示例代码中,我们比较两个人的年龄,来返回年龄较大的人。
第二种reduce
方法接受标识值和BinaryOperator
累加器。此方法可用于构造一个新的 Person
,其中包含来自流中所有其他人的聚合名称和年龄:
Person result =
persons
.stream()
.reduce(new Person("", 0), (p1, p2) -> {
p1.age += p2.age;
p1.name += p2.name;
return p1;
});
System.out.format("name=%s; age=%s", result.name, result.age);
// name=MaxPeterPamelaDavid; age=76
复制代码
第三种reduce
方法接受三个参数:标识值,BiFunction
累加器和类型的组合器函数BinaryOperator
。由于初始值的类型不一定为Person
,我们可以使用这个归约函数来计算所有人的年龄总和:
Integer ageSum = persons
.stream()
.reduce(0, (sum, p) -> sum += p.age, (sum1, sum2) -> sum1 + sum2);
System.out.println(ageSum); // 76
复制代码
结果为76,但是内部究竟发生了什么呢?让我们再打印一些调试日志:
Integer ageSum = persons
.stream()
.reduce(0,
(sum, p) -> {
System.out.format("accumulator: sum=%s; person=%s\n", sum, p);
return sum += p.age;
},
(sum1, sum2) -> {
System.out.format("combiner: sum1=%s; sum2=%s\n", sum1, sum2);
return sum1 + sum2;
});
// accumulator: sum=0; person=Max
// accumulator: sum=18; person=Peter
// accumulator: sum=41; person=Pamela
// accumulator: sum=64; person=David
复制代码
你可以看到,累加器函数完成了所有工作。它首先使用初始值0
和第一个人年龄相加。接下来的三步中sum
会持续增加,直到76。
等等?好像哪里不太对!组合器从来都没有调用过啊?
我们以并行流的方式运行上面的代码,看看日志输出:
Integer ageSum = persons
.parallelStream()
.reduce(0,
(sum, p) -> {
System.out.format("accumulator: sum=%s; person=%s\n", sum, p);
return sum += p.age;
},
(sum1, sum2) -> {
System.out.format("combiner: sum1=%s; sum2=%s\n", sum1, sum2);
return sum1 + sum2;
});
// accumulator: sum=0; person=Pamela
// accumulator: sum=0; person=David
// accumulator: sum=0; person=Max
// accumulator: sum=0; person=Peter
// combiner: sum1=18; sum2=23
// combiner: sum1=23; sum2=12
// combiner: sum1=41; sum2=35
复制代码
并行流的执行方式完全不同。这里组合器被调用了。实际上,由于累加器被并行调用,组合器需要被用于计算部分累加值的总和。
让我们在下一章深入探讨并行流。