logo头像

路漫漫兮其修远兮

Java SE基础巩固(十六):Stream(流)

1 什么是Stream(流)

计算机科学中有很多带“流”的概念,例如字符流,字节流,比特流等等,很少有书籍在讲到这些概念的时候会详解介绍什么是流,所以有时候会导致读者感到迷惑,在这里,我大胆尝试简单解释一下“流”到底是个什么东西。

举个例子,水流大家都见过吧(无论是水管中的水流,还是海流或者河流),从微观的角度看水流,它就是由一个一个的水分子和其他物质组合形成的(至于怎么流动的,这就是流体力学的事了,先不管),从一个或者多个流动到一个或者多个目的地,例如大家的生活用水就是从水库流到各位的家中。在水流动的过程中,可以采取一些处理,使得水变得更加洁净,安全。

从这个例子中,不难看到有几个关键词:分子,源,目的地,处理等。现在,再来看看计算机中的所谓的比特流,比特流里的“分子”就是一个一个的比特(0和1),源就是计算机本身(从宏观的角度看),而目的地则是其他的计算机,在源和目的地之间,我们同样可以加入一些处理操作来处理比特,使得目的地收到的数据是符合需求的。

经过这么一个类比,各位应该大概知道什么是“流”了吧,现在给出一个比较简短的定义(来源是《Java8实战》):“从支持数据处理操作的源生成的元素序列”。看起来不像是人话对吧,不要害怕,把这句话拆开来看就好了:

  • 元素序列。一个一个的分子根据一定的规则排列形成的集合,例如比特流从一个一个比特组成的序列叫做比特序列,字符流中一个一个字符组成的序列叫做字符序列。
  • 数据处理操作。对序列的元素进行处理,例如污水处理等。
  • 源。生成元素数据的机器或者程序。

除此之外,流还有一些特点:

  • 在同一地点,不同时刻,看到的元素是不同的,即流是具有动态性的,错过了就是错过了,无法再次拿出来做处理。
  • 可以有多个处理操作,某个处理操作的输出就是下一个处理操作的输入,就想生产手机的流水线一样。
  • …..

作为补充理解,可以看看下面这张图,来源也是《Java8 实战》一书,描述的是集合和流的区别(个人觉得是一个很形象的比喻):

FXGqfA.png

2 为什么需要流呢

假设有一个需求:现在有一个Car对象的集合,我们希望从集合中找到并返回所有符合age <= 2条件的对象,然后根据age字段对对象集合进行排序,最后返回对象的brand字段的集合。

如果使用传统的方式编写代码,代码可能是下面这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
List<Car> filteredCars = new ArrayList<>();
for (Car car : cars) {
if (car.getAge() <= 2) {
filteredCars.add(car);
}
}

Collections.sort(filteredCars, new Comparator<Car>() {
@Override
public int compare(Car o1, Car o2) {
return o1.compareTo(o2);
}
});

List<String> carNames = new ArrayList<>();
for (Car car : filteredCars) {
carNames.add(car.getBrand());
}

注意到代码中使用了一个filteredCars集合,该集合既不是源集合,也不是目标集合,只是一个中间集合,如果我们采用这种方式编程,这个中间集合是不得不使用的,但中间集合是有空间消耗的,如果集合数据很多,那么这个中间集合的影响就会很大。除此之外,这种方式编写的代码并不简洁,如果没有注释的话,想要完全弄清楚这段代码是在干什么应该不是一件容易的事。那有没有办法简化代码,让人一看就知道代码的目的呢?答案是例如Java8的Stream API,下面来看看使用这种新的方式编写代码是怎样的:

1
2
3
4
List<String> carNames = cars.stream().filter((car) -> car.getAge() <= 2)
.sorted(Car::compareTo)
.map(Car::getBrand)
.collect(Collectors.toList());

非常简单,就四行代码!(如果你愿意,写成一行也可以,但是不推荐)而且非常易读,看了第一行,就能发现:“哦,这里要做一个filter的操作”,看了第二行就能发现这是一个sorted排序操作,剩下的同理。

先不用管stream()是什么,sorted()是什么,后面会介绍到。

上述例子表现了Stream的一个优点,除此之外,Stream还可以用于应对数据是无限的情况,例如素数流,偶数流等等,更多的应用无法在这短短一篇文章中特性,还需要多多修炼!

3 使用Stream API

Stream API的操作很多,例如filter,sorted,map,reduce,collect等等,大致可以分为两类操作:中间操作和终端操作。流经过中间操作后,其输出仍然可以作为下一个操作的输入,但经过终端操作之后,就无法继续进行操作了,所以一般终端操作都是一些具有“聚合”功能的操作,例如collect将流中的数据“收集”成集合或者其他什么用于保存数据的数据结构(用户可以自定义这个收集操作,也可以使用内置的API)。而中间操作一般都是对数据进行“处理”,例如filter用于筛选数据,map用于对数据进行映射,即将数据转换成另一种形式等。下图是常用的API:

FXYFED.png

下面我将选择几个最常用的操作进行介绍:

  • filter。对数据进行过滤操作,参数类型是Predicate,这是一个函数式接口,故可以传递lambda表达式。
  • map。对数据进行映射,参数类型是Function<T,R>,也是一个函数式接口,可以传递lambda表达式。
  • flatMap。扁平化的map,在一些场景下尤其重要。
  • anyMatch。是一个终端操作,参数是Predicate,语义是一旦找到任何符合条件的元素,就立即返回了。其他几个match也类似。
  • forEach。遍历,比较常用,参数是Consumer,也是函数式接口。
  • collect。收集,是数据聚合操作,如果我们的最终目的是返回一个集合,那么就可以使用这个API。

先给出共用的数据集合以及类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
public class Car implements Comparable<Car> {

private String brand;

private Color color;

private Integer age;

public Car(String brand, Color color, Integer age) {
this.brand = brand;
this.color = color;
this.age = age;
}

public String getBrand() {
return brand;
}

public void setBrand(String brand) {
this.brand = brand;
}

@Override
public String toString() {
return "Car{" +
"brand='" + brand + '\'' +
", color=" + color +
", age=" + age +
'}';
}

@Override
public int compareTo(Car o) {
return this.getAge() < o.getAge() ? 1 : this.getAge() == o.getAge() ? 0 : -1;
}

public enum Color {
RED,WHITE,PINK,BLACK,BLUE;
}

//getter and setter
}
1
2
3
4
5
6
private static final List<Car> cars = Arrays.asList(
new Car("BWM",Car.Color.BLACK, 2),
new Car("Tesla", Car.Color.WHITE, 1),
new Car("BENZ", Car.Color.RED, 3),
new Car("Maserati", Car.Color.BLACK,1),
new Car("Audi", Car.Color.PINK, 5));

3.1 filter

filter的作用就是筛选数据,如果数据符合条件,就让他继续在流里流动,否则直接取出来,使其离开所在的流。假设现在我们要筛选cars集合中所有颜色是黑色的car对象,该怎么做呢?非常简单,如下所示:

1
2
cars.stream().filter(car -> car.getColor().equals(Car.Color.BLACK))
.forEach(System.out::println);

forEach先不用管,后面会讲到。

尝试一下,验证一下答案是不是BWM和Maserati呢?filter接受一个参数,参数类型是Predicate<? super T>,在上一篇文章中,我已经介绍了函数式接口以及lambda表达式,所以这里就不再赘述了。

3.2 map

Google有一个很著名的大数据框架,即Map-Reduce,如果对Hadoop有一些了解的话,应该都知道。Map其实就是一个映射,即将原始数据转换成另一种形式。现在我们继续上面filter的例子,如果现在我想让返回的仅仅是筛选后的对象的brand字段集合,该如何做呢?可以使用map,如下所示:

1
2
3
cars.stream().filter(car -> car.getColor().equals(Car.Color.BLACK))
.map(Car::getBrand)
.forEach(System.out::println);

这里的map就是将Car对象实例转换成brand字符串,这个操作是非常有意义的,因为如果如果我们仅仅需要一个brand字符串,对其他的根本不关系,又有什么必要还留着其他数据来影响后续的处理呢?

3.3 flatMap

这是扁平化的map操作,和普通的map操作最大的不同就是flatMap能把流中的某个元素都换成另一个流,然后把所有的流连接起来形成一个新的流。还是有些难以理解是吧,借用书上的一个例子来说明一下:

1
2
3
4
5
String[] arrayOfWords = {"Hello", "World"};
Arrays.stream(arrayOfWords)
.map(word -> word.split(""))
.distinct()
.forEach(System.out::println);

这段代码的目的是找出所有字母,这些字母是在数组中的单词里出现的,例如Helllo,World中出现了H,e,l,l,o,r,d,w这几个字母。但如果你运行一下上面这段代码,会发现返回的并不是我们所想的那样,而是类似这样的:

1
2
[Ljava.lang.String;@15aeb7ab
[Ljava.lang.String;@7b23ec81

即返回是两个数组,怎么会这样呢?简单剖析一下,map操作的对象类型是String,即每个单词,然后调用split()方法,该方法的返回值是Spring[]类型,所以map的返回类型是Stream<String[]>类型,而后面又没有太多的处理了,故最后forEach遍历的起始是String[]类型的对象,并不是我们想要的字符串。那么,怎么修改呢?答案是:使用flatMap使其扁平化:

1
2
3
4
5
Arrays.stream(arrayOfWords)
.map(word -> word.split(""))
.flatMap(Arrays::stream)
.distinct()
.forEach(System.out::println);

只是在map后面多了一个flatMap操作就能解决问题了吗?刚刚说了,map的返回值类型是Stream<String[]>,即现在流中的元素类型是String[],flatMap尝试把String[]数组类的内容展开,即如果数组里的内容是”Hello”,那么flatMap(Arrays::stream),就把H,e,l,l,o当做新的流,然后再组合成一个新的更大的流。一图胜千言,来看看具体的分析图:

FXNrcR.png

3.4 anyMatch

即找到任何一个符合条件的元素就立即返回一个true,如果都没找到,那么就返回false。如下所示:

1
2
boolean IsExist = cars.stream()
.anyMatch(car -> car.getAge() <= 2);

非常简单,不多作解释了。

3.5 forEach

即遍历,在Java8以前,我们要么显式的使用迭代器或者用增强for循环的方式,要么就使用索引的方式(前提是集合支持索引)来遍历集合的元素,在Java8之后,遍历集合元素变得更加简单了,不再需要显式的构造for循环,这种方式被称作“内部迭代”。关于其使用,在上面的例子已经多次使用到了,这里就不再写例子了,但我想提的是内部迭代的效率和外部显式迭代的效率对比,网上有很多文章有提到,内部迭代的效率比外部迭代更低,我在我的机器上稍微测试了一下(有预热),发现确实如此,但并不会低很多。我想表达的是,如果真的对性能特别敏感,那么用传统的外部显式迭代也许会是一个更好的选择,否则我更推荐基于Stream API的内部迭代,因为它更简洁,语义更明确(个人看法)。

其实这里提到的“内部迭代”的概念并不仅仅存在于forEach操作,可以说整个流API的操作都是基于“内部迭代”的,这里特别说明一下。

3.6 collect

最后就是collect操作了,这是一个“聚合”或者叫做“归约”操作。其参数是Collector<? super T, A, R> collector类型,但这并不是一个函数式接口,在Collectors这个类里提供了一些常用的聚集成集合操作,例如toList就是聚集成一个List,toSet就是聚集成一个Set,同时,这是一个终端操作,一旦调用了该操作,就无法继续进行其他操作了。如下所示:

1
2
3
cars.stream().filter(car -> car.getColor().equals(Car.Color.BLACK))
.map(Car::getBrand)
.collect(Collectors.toList());

其实我们也可以自定义聚集成其他更多类型的集合或者一些自定义的数据结构,具体的实现方法可以参考Collectors里的几个方法,就不多说了。

4 默认方法

在Java8之前,接口(Interface)里不能包含任何的方法实现,在Java8中,添加了”默认方法“,使用关键字default或者将方法声明成静态方法,我们就可以在接口中提供方法的实现,如下所示:

1
2
3
4
5
6
public interface MyInterface {

default void defaultMethod() {
System.out.println("default method");
}
}

为什么要把这个特性放在这讨论呢?因为Stream API的源码大量的使用了这一特性,例如Stream.iterate()方法,Stream是位于java.util.stream包下的一个接口,其中iterate()并不是一个抽象方法,而是一个有具体实现的方法,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public static<T> Stream<T> iterate(final T seed, final UnaryOperator<T> f) {
Objects.requireNonNull(f);
final Iterator<T> iterator = new Iterator<T>() {
@SuppressWarnings("unchecked")
T t = (T) Streams.NONE;

@Override
public boolean hasNext() {
return true;
}

@Override
public T next() {
return t = (t == Streams.NONE) ? seed : f.apply(t);
}
};
return StreamSupport.stream(Spliterators.spliteratorUnknownSize(
iterator,
Spliterator.ORDERED | Spliterator.IMMUTABLE), false);

其实非常简单,是吧。那Java8为啥要搞出这一套东西来呢?在以前,我们只能在接口中声明抽象方法,这种方式的好处是,可以约束实现类的行为,使得程序更加健壮,但缺点也比较明显,就是当接口声明的抽象方法改变的时候,就不得不同时修改所有的实现类,这是一种非常非常麻烦的事(所以接口的定义需要非常谨慎)。举个例子,java8在List接口中新增了sort()方法,如果只是简单的像以前一样添加方法声明,那么所以依赖于List接口的实现类都不得不做出修改:实现sort()方法,这会非常麻烦。所以,在Java8中引入了“默认方法”,此时只需要修改List接口就行了,其实现类自动的继承了sort()方法的实现。

到这里,你可能又有疑惑了?这样一来,接口和抽象类有什么不同呢?嗯,还是有一些区别的,例如接口仍然无法定义实例字段(静态字段一直都可以定义)。除此之外,因为接口是可以继承多个其他的接口的,而且现在接口可以有具体的方法实现了,那么是不是意味着Java可以实现“多继承”了呢?确实可以,而且和C++一样存在所谓的“菱形继承”问题,具体的内容建议自行查阅资料,自己多多尝试,这里就不多说了。(但我个人感觉,Java中的“菱形继承”问题似乎没C++那么严重,几个规则还是比较清晰的,而C++明面上的规则也比较清晰,但真正要弄好,依然会有千奇百怪的问题)

5 小结

本文简单介绍了什么是流,为什么要使用流以及如何使用Java8提供的Stream API,但其实Stream API的功能远不止这样,还有一些更加强大的功能,例如count()操作等等等等…..Java8除了提供实现好的API,还可以自定义一些符合用户需求的功能,这也是Stream API强大的原因之一。

Stream是一个非常庞大的体系,我这一篇短短几千词的文章远远不能囊括所有。如果本文有什么地方有错误或者不足,真诚的希望您能指出,大家共同进步!