Osheep

时光不回头,当下最重要。

Java 8 新特性(二)流类库

前面介绍了lambda表达式,但是我们可以看到,lambda表达式其实也就是简化了一部分代码的编写,说起来也不算是非常有用的语言特性。但是如果lambda表达式配合这篇文章介绍的流类库,就会发挥出巨大的作用。

初识流类库

老样子,先来看一个例子。有一个整数列表,我现在希望找到其中所有大于5的数,所以我可能会这么写。虽然这是中规中矩的代码,但是就算是实现这么一个简单的功能,也需要这么一大坨代码,实在是让人不爽。

List<Integer> integers = new ArrayList<>();
integers.addAll(Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8));
//获取大于5的 所有元素
List<Integer> integersGreaterThan5 = new ArrayList<>();
for (int i : integers) {
    if (i > 5) {
        integersGreaterThan5.add(i);
    }
}
System.out.println(integersGreaterThan5);

那么如果配合流类库呢?代码会极大地缩减,而且可读性也大大提高。

integersGreaterThan5.clear();
//使用流类库
integersGreaterThan5 = integers.stream()
        .filter(i -> i > 5)
        .collect(Collectors.toList());
System.out.println(integersGreaterThan5);

流类库是Java 8新增的一组类库,让我们可以对集合类库进行复杂的操作,这些类库代码位于java.util.stream包下,注意不要和Java IO流搞混了。从上面的代码可以看到,使用流类库基本上可以分为以下几步:把集合转换为流、对流进行操作、将流转换为相应的数据结构。

获取流

在支持查看源代码的IDE中追踪上面代码的stream()方法,可以发现这个方法在java.util.Collection接口中,大部分集合类都实现了这个接口,这也意味着大多数集合类都有这个方法,利用这个方法,我们就可以将集合转换为一个流,流类库几乎所有方法都需要在流上才能操作。

当然如果细究一下,这个方法长的是这个样子。这也是Java 8的新特性,由于流类库是在接口中添加的新方法,Java 8以前的代码是没有实现这些新方法的。为了老版本的代码也可以正常运行,Java 8引入了接口默认方法,让接口也可以实现方法,如果在实现类中没有实现,就会使用接口中的默认实现。这样一来,即使老版本的代码没有实现这些新接口,程序也仍然可以正常工作。

default Stream<E> stream() {
    return StreamSupport.stream(spliterator(), false);
}

流操作

过滤

最常见的流操作就是过滤了,过滤相当于SQL中的where语句,就是按某种条件把流中的元素筛选出来,组成一个新流。大部分流操作的结果仍然是一个流,所以我们可以链式调用。下面是一个找出大于3的偶数的例子。

List<Integer> integers = Stream.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
        .filter(i -> i > 3)
        .filter(i -> i % 2 == 0)
        .collect(Collectors.toList());
System.out.println(integers);

映射

另一种常见的流操作是映射,类似于SQL中的select,可以将一组元素转换成另一种元素。下面的例子将一组整数转换为平方。这是一个简单的例子,实际场合中常常需要将一组对象流转换为另一组对象。

List<Integer> integers = Stream.of(1, 2, 3, 4, 5)
        .map(i -> i * i)
        .collect(Collectors.toList());
System.out.println(integers);

平整映射

有时候需要将多个流的结果合并为一个流,这时候需要使用平整映射。

List<Integer> integers = Stream.of(Arrays.asList(1, 2), Arrays.asList(3, 4))
        .flatMap(n -> n.stream())
        .collect(Collectors.toList());
System.out.println(integers);

最大值和最小值

这两个功能不必说了。需要注意的是,minmax方法接受的是比较器形式的lambda表达式,当然也可以用上篇文章介绍的比较器方法来简化比较代码的编写。

int min = Stream.of(1, 2, 3, 4)
        .min(Comparator.comparingInt(i -> i)).get();
int max = Stream.of(1, 2, 3, 4)
        .max(Comparator.comparingInt(i -> i)).get();
System.out.println(String.format("max:%d,min:%d", max, min));

通用迭代

有时候需要进行一种比较复杂的操作:从一个流中取前两个元素执行某个操作,然后用结果和第三个元素继续操作,直到处理完所有元素。这种操作叫做reduce。下面的例子很简单,求和以及求积。

reduce有两种形式,第一种是取前两个元素操作,然后将结果和第三个元素操作,然后以此类推。第二种是用给定的初始值和第一个元素操作,然后结果和第二个元素操作。需要注意第一种形式的返回值是一个Optional对象,为了得到最终的值我们需要调用get()方法。

int sum = Stream.of(1, 2, 3, 4, 5)
        .reduce((acc, e) -> acc + e)
        .get();
System.out.println(String.format("sum:%d", sum));
int product = Stream.of(1, 2, 3, 4, 5)
        .reduce(1, (acc, e) -> acc * e);
System.out.println(String.format("product:%d", product));

谓词操作

还有一些流操作和SQL中的谓词操作类似,可以实现一些判断功能。allMatch当所有元素满足条件时返回trueanyMatch只要有一个元素满足就会返回真;noneMatch当没有元素满足条件时返回真;distinct会去除流中的重复元素。

boolean allGreaterThan5 = Stream.of(1, 2, 3, 4, 5)
        .allMatch(i -> i > 5);
System.out.println("allGreaterThan5:" + allGreaterThan5);

boolean anyEqualsTo2 = Stream.of(1, 2, 3, 4, 5)
        .anyMatch(i -> i == 2);
System.out.println("anyEqualsTo2:" + anyEqualsTo2);

boolean noneLessThan0 = Stream.of(1, 2, 3, 4)
        .noneMatch(i -> i < 0);
System.out.println("noneLessThan0:" + noneLessThan0);

List<Integer> distinct = Stream.of(1, 1, 2, 3, 2, 4, 5)
        .distinct()
        .collect(Collectors.toList());

还有一些操作可以截取流的一部分,limit会保留流的前几个元素,而skip会跳过前几个元素而获取之后的所有元素。

String list1 = Stream.of(1, 2, 3, 4, 5)
        .limit(3)
        .map(String::valueOf)
        .collect(Collectors.joining(", ", "[", "]"));
System.out.println(list1);
String list2 = Stream.of(1, 2, 3, 4, 5)
        .skip(3)
        .map(String::valueOf)
        .collect(Collectors.joining(", ", "[", "]"));
System.out.println(list2);

基本类型流

流类库是一个通用的框架,所以显而易见地用到了Java泛型的技术。但是我们知道由于Java存在一个基本类型装箱拆箱的过程,所以会有性能开销。为了避免这些开销,流类库针对常见的基本类型intlongdouble做了特殊处理,为它们单独准备了一些类和方法。

IntStream intStream = IntStream.of(1, 2, 3, 4, 5);
LongStream longStream = LongStream.of(1, 2, 3, 4);
DoubleStream doubleStream = DoubleStream.of(1.0, 2.0);

对于一些方法也有基本类型的版本,可以将一个对象流转换为对应的基本类型,这些方法的命名规则是方法名+To+基本类型。如果需要处理大量数据的基本类型流,可以考虑使用这些方法。

int sum = Stream.of(1, 2, 3, 4)
        .mapToInt(i -> i)
        .reduce(0, (acc, e) -> acc + e);
System.out.println(String.format("sum:%d", sum));

收集器

使用流类库的最后一步就是将流转换为我们需要的集合了,这就需要用到收集器。收集数据的最后一步需要调用collect方法,它的参数是java.util.stream.Collector类的静态方法。听起来是不是有点奇怪,实际上,接受的是这些方法的返回值。例如toList()方法实际上是这样的,返回的是Collector对象,然后由collect方法处理,获取最后的集合。

public static <T>
Collector<T, ?, List<T>> toList() {
    return new CollectorImpl<>((Supplier<List<T>>) ArrayList::new, List::add,
                               (left, right) -> { left.addAll(right); return left; },
                               CH_ID);
}

获得集合

如果需要把流转换为集合,可以使用toList()toSet()等方法。它们会返回ListSet集合。如果需要使用特定的集合,可以使用toCollection方法,它的参数是一个比较特殊的函数接口,用于创建集合。所以这里可以直接使用构造方法引用来创建集合。

List<Integer> integers = Stream.of(1, 2, 3, 4)
        .collect(Collectors.toList());
Set<Integer> set = Stream.of(1, 2, 3)
        .collect(Collectors.toSet());
//使用自己希望的集合
ArrayList<Integer> integers2 = Stream.of(1, 2, 3)
        .collect(Collectors.toCollection(ArrayList::new));

获得值

收集器不仅可以获得集合,还可以由流获取一个值,这可以通过调用maxByminByaverageXXXsummingXXX方法来实现。下面是一组例子。

int max = Stream.of(1, 2, 3, 4)
        .collect(Collectors.maxBy(Comparator.comparing(i -> i)))
        .get();
int min = Stream.of(1, 2, 3, 4)
        .collect(Collectors.minBy(Comparator.comparing(i -> i)))
        .get();
double average = Stream.of(1, 2, 3, 4)
        .collect(Collectors.averagingDouble(Integer::doubleValue));
int sum = Stream.of(1, 2, 3, 4)
        .collect(Collectors.summingInt(i -> i));
System.out.println(
        String.format("max:%d,min:%d,average:%f,sum:%d", max, min, average, sum));

有时候需要将流的数据组合为一个字符串,这需要joining收集器,它的三个参数分别是分隔符、前缀和后缀。当然由于它需要字符序列,所以这里还需要用map方法将整数流转换为字符串流。

String string = Stream.of(1, 2, 3, 4, 5)
        .map(String::valueOf)
        .collect(Collectors.joining(", ", "[", "]"));
System.out.println(string);
// 结果: [1, 2, 3, 4, 5]

还有一个简单的收集器,作用就是计数,需要注意的是计数返回的结果是long类型就行了。这个收集器的主要作用是和其他收集器一起完成复杂的功能。

long count = Stream.of(1, 2, 3)
        .collect(Collectors.counting());
System.out.println("count:" + count);
// 结果:3

数据分块

数据分块允许你给定一个条件,然后收集器会按照这个条件将流分为满足条件和不满足条件的两个部分,这个收集器的返回结果是一个Map<Boolean, List<T>>。下面的例子将流分为了奇数和偶数两个部分。

Map<Boolean, List<Integer>> map = Stream.of(1, 2, 3, 4, 5)
        .collect(Collectors.partitioningBy(i -> i % 2 == 0));
System.out.println(map);
// 结果
// {false=[1, 3, 5], true=[2, 4]}

数据分组

数据分块只能分为真假两种情况,如果需要更细分的话,需要使用数据分组。这个大概类似于SQL中的group by语句。下面的例子将流按照数组个位数分为好几组。

Map<Integer, List<Integer>> map = Stream.of(21, 32, 43, 54, 11, 33, 22)
        .collect(Collectors.groupingBy(i -> i % 10));
System.out.println(map);
// 结果
// {1=[21, 11], 2=[32, 22], 3=[43, 33], 4=[54]}

组合收集器

如果问题比较复杂,还可以将多个收集器组合起来使用,一些收集器有重载的版本,支持第二个收集器,可以用来实现这个功能。就拿前面那个数据分组的例子来说,这次我不仅要分组,而且只需要每组的十位数字,那么就可以这样写。groupingBy的第二个参数可以使用mapping收集器,mapping这里的作用和流操作的map类似,将流再次进行映射,然后收集结果作为最后Map的键值对。

//按个位数字分组,然后只获取十位数字
Map<Integer, List<Integer>> map = Stream.of(21, 32, 43, 54, 11, 33, 22)
        .collect(Collectors.groupingBy(i -> i % 10,
                Collectors.mapping(i -> i / 10, Collectors.toList())));
System.out.println(map);
// {1=[2, 1], 2=[3, 2], 3=[4, 3], 4=[5]}

再举一个例子,现在我希望对每组的结果进行求值。就可以使用另外一个收集器summingXXX。还有一些收集器可以完成求平均数等的操作,这里就不一一列举了。

//按个位数字分组,然后求各组的和
Map<Integer, Integer> map2 = Stream.of(21, 32, 43, 54, 11, 33, 22)
        .collect(Collectors.groupingBy(i -> i % 10,
                Collectors.summingInt(i -> i)));
System.out.println(map2);
// {1=32, 2=54, 3=76, 4=54}

另外一点内容

还有一点知识点不知道该怎么说,干脆都放在这里好了。

惰性求值

流类库设计的非常精巧,也对性能做了很多优化。所有的流操作都是惰性的,也就是说直到最后调用收集器的时候,整个流操作才开始进行。在这之前,你的流操作只不过类似于SQL的执行计划,这时候还没有真正执行程序。所以下面的代码什么都不会输出。

Stream.of(1, 2, 3, 4, 5)
        .filter(i -> i > 1)
        .filter(i -> {
            System.out.print(i);
            return i <= 3;
        });

单次循环

如果进行了很多流操作,流类库会不会对流进行多次迭代,导致程序速度很慢呢?这个担心也是多余的,流类库经过优化,会保证迭代以最少的次数进行。所以下面的代码输出结果是122334455,这说明流只迭代了一次。

List<Integer> integers = Stream.of(1, 2, 3, 4, 5)
        .filter(i -> {
            System.out.print(i);
            return i > 1;
        })
        .filter(i -> {
            System.out.print(i);
            return i <= 3;
        })
        .collect(Collectors.toList());
System.out.println();

新的for循环

原来,我们如果需要进行一定次数的循环,需要使用for来做到。

//传统for循环
for (int i = 0; i < 3; ++i) {
    System.out.print(i);
}
System.out.println();

现在利用流类库的range方法,我们可以简化这一点。

IntStream.range(0, 3)
        .forEach(i -> System.out.print(i));
System.out.println();

数据并行化

由于流类库的特性,所以让流;并行化非常容易,只需要调用parrallel方法即可。数据的分割、结果的合并都会由类库自动完成。

List<Integer> integers = IntStream.range(1, 101)
        .parallel()
        .filter(i -> i % 2 == 0)
        .boxed()
        .collect(Collectors.toList());

需要注意并不是说并行化之后,速度就一定会比串行化快,这需要根据当前系统、机器、执行的数据流大小来进行综合评估。ArrayList这类容器就比较容易并行化,而HashMap并行化就比较困难。

总之,并行化这个主题比较复杂,这里就不详细讨论了。

最后推荐一本关于Java函数式编程的书籍,这本书对于Java 8的函数式编程做了很多介绍,我觉得很不错。

《Java 8 新特性(二)流类库》

Java 8 函数式编程
点赞