写点什么

可能是 Java Stream 的最佳实践(一)

用户头像
ES_her0
关注
发布于: 2021 年 02 月 24 日

从这一篇文章开始,我们会由浅入深,全面的学习 stream API 的最佳实践(结合我的使用经验),本想一篇写完,但写着写着发现需要写的内容太多了,所以分成一个系列慢慢来说。给大家分享我的经验的同时,也促使我复习每一个细节,大家共同进步。

Stream 是什么


Java 8 新增了一个 API 叫做 Stream ,Stream 的英文可以理解为流动的液体,可能很多人一听脑子里的第一印象就是流式计算,不自觉地就心生畏惧,感觉非常的高深莫测。其实这就是一个辅助处理集合数据的工具类,工具的更新必然带来的是生产力的提升,这里的生产力代表的就是整洁优雅的代码,更高的灵活度,更好的性能。相信各类的技术文章(包括博客和书籍)已经写过无数遍了。比如下面摘录《Java 8 实战》关于流的描述:

流是 Java API 的新成员,它允许你以声明性方式处理数据集合(通过查询语句来表达,而不是临时编写一个实现)。就现在来说,你可以把它们看成遍历数据集的高级迭代器。此外,流还可以透明地并行处理,你无需写任何多线程代码了!

这段话的表述个人感觉类似于抓手赋能心智之类的 PPT 黑话,看着挺高级的,也能懂一些,但也不是很懂,反正如果对于不知道 Stream 的人,并不能建立直接的理解。

public interface Stream<T> extends BaseStream<T, Stream<T>> {    Stream<T> filter(Predicate<? super T> predicate);    <R> Stream<R> map(Function<? super T, ? extends R> mapper);    void forEach(Consumer<? super T> action);  ...}
复制代码

就是个接口,然后这个接口有一些抽象方法:filter,map,forEach 等等。我们可以看到有些方法返回了新的 Stream,有些直接是 void。这个接口用来干什么用呢?处理集合数据。为什么这么说?看下面一个 Collection 接口的方法:

public interface Collection<E> extends Iterable<E> {  ...  default Stream<E> stream() {        return StreamSupport.stream(spliterator(), false);    }}
复制代码

那么所有继承了 Collection 的接口都可以直接创建 Stream,然后再执行 Stream 里面的操作。所以这么看下来,首先得承认书中的表述是高度抽象且精炼的,这是书籍该做的事情。但从易于理解的角度,我觉得可以说是简洁高效安全的处理集合数据的工具类。如下图所示,Stream 是一个中间过程。

需要注意的点

  • 首先 Stream 不是一个数据结构,它不存储任何数据,它是一种数据处理工具,代表了一种能力。

  • Stream 不会对处理的数据本身做任何修改,永远都是返回新的 Stream 或者最终的处理结果。

  • Stream 可以有多个中间操作,但只能有一个终端操作,因为终端操作就求值了。

  • 一个 Stream 只能用一次,不能多次复用。(因为它不存储数据,只是一个转换能力)。

能力范围

Stream 随着 Java 8 的发布已经 8 年多了,在我有限的职业生涯里,碰到的一些职场新人依然有些人觉得使用 for 或者 iterator 来遍历集合更易读易懂。但如果他真正了解 Stream 所蕴含的能力后,应该会转变想法。下面简单介绍一下 Stream 都提供了什么样的能力。

  1. 生成流

  1. 筛选和切片

这可能是用的最多的功能。对应的方法为:

  • filter:接受一个 Predicate 断言函数,用来遍历元素是否符合断言条件。可以简单的理解为一个过滤器

  • distinct:无参数,将所有元素去重,和数据库的 distinct 关键词能力一样。

  • limit:接受一个 int 型长度字段,表示要保留多少个元素,需要注意的时候 limit 并不排序。

  • skip:和 limit 相对应,接受一个 int 型长度字段表示跳过多少个元素,也不排序。

下面举个例子:

Stream.of("d2", "a2", "b1", "a3", "c1", "a4", "a2", "a1")        .filter(x -> x.startsWith("a"))        .distinct()        .skip(1)        .limit(3)        .forEach(System.out::println);  }// output: a3a4a1
复制代码
  1. 映射/转换

这里主要是 map,map 代表了一种对应关系,即地图坐标与实际地点的对应关系,我们有了经纬度就可以准确的找到地址,这个例子可以很形象的解释 map 命名的由来和功能。

  • map:接受一个 Function 作为参数,即输入一个值,返回另一个值,满足转换的语义。

  • flatmap:同样接受一个 Function 作为参数,不同的是这个 Function 中有一个参数是一个 stream,返回的也是一个 stream,意为将多个 stream 连成一个 stream。

同样,举个简单的例子:

Stream.of("d2", "a2", "b1", "a3", "c1", "a4", "a2", "a1")        .filter(x -> x.startsWith("a"))        .map(String::toUpperCase)        .forEach(System.out::println);//outputA2A3A4A2A1
List<String> list = Stream.of("Hello", "world!") .map(s -> s.split("")) .flatMap(Arrays::stream) .collect(Collectors.toList());System.out.println(list);//output[H, e, l, l, o, w, o, r, l, d, !]
复制代码
  1. 查找和匹配

这里的能力可以认为是一个加强版的contains方法,具备多种查找匹配能力。

  • allMatch:返回 boolean,接受一个 Predicate 断言,确认全部元素均满足这个条件则返回 true,否则返回 false

  • anyMatch:与 allMatch 类似,但从语义上可以区分只要任意元素满足条件即可

  • noneMatch:同样,要求没有任何元素满足条件

  • findFirst:返回一个 Optional,里面是满足条件的第一个元素

  • findAny:返回 Optional,里面是满足条件的任一元素

这里需要解惑的是findAnyfindFirst的区别,因为这两个都是找到满足条件的元素就返回,但findFirst会在限制并行流的计算,会严格按照集合中元素的顺序来依次查找。findAny就不会有这个限制。如果非并行计算场景,这二者并无区别。

boolean b1 = Stream.of("d2", "a2", "b1", "a3", "c1", "a4", "a2", "a1")        .anyMatch(x -> x.startsWith("a"));    System.out.println(b1);//output: true
String s2 = Stream.of("d2", "a2", "b1", "a3", "c1", "a4", "a2", "a1") .filter(x -> x.startsWith("a")) .findFirst() .get(); System.out.println(s2);//output: a2
String s3 = Stream.of("d2", "a2", "b1", "a3", "c1", "a4", "a2", "a1") .filter(x -> x.startsWith("a")) .findAny() .get(); System.out.println(s3);//output: a2
//换成并行流String s4 = Stream.of("d2", "a2", "b1", "a3", "c1", "a4", "a2", "a1") .parallel() .filter(x -> x.startsWith("a")) .findFirst() .get(); System.out.println(s4);//output: a2

String s5 = Stream.of("d2", "a2", "b1", "a3", "c1", "a4", "a2", "a1") .parallel() .filter(x -> x.startsWith("a")) .findAny() .get(); System.out.println(s5);//output: a4
复制代码
  1. 归约

归约是一个比较复杂的数学理论,通常是用于将一个未知的问题转换成另一些已知问题,同时这些已知的问题和未知的问题存在某种关联。这里不做详细探讨。在 Stream API 有一些方法就是用的类似的归约的思想,将大的集合计算分解成小的函数计算并最终合成结果。

  • reduce

  • collect

这两个方法都很重要,且都是终端操作,执行完即返回流的计算结果。我们逐个来说,先看reduce。reduce 的英文含义为减少、归纳,在 stream 接口中的定义如下:

T reduce(T identity, BinaryOperator<T> accumulator);
Optional<T> reduce(BinaryOperator<T> accumulator);
<U> U reduce(U identity, BiFunction<U, ? super T, U> accumulator, BinaryOperator<U> combiner);
复制代码

这样的方法签名如同天书,先看一个简单的例子:

Integer i = Stream.of(1, 4, 6, 7, 9).reduce(1, (sum, i) -> sum + i);System.out.println(i);
复制代码

其中 reduce 我传了 2 个参数:

  • 1 表示初始值,可以不给,不给的话默认从流的第一个元素开始计算,但返回是就是 Optional

  • (sum, i) -> sum + i表示计算函数,每次计算的结果都会暂存在 sum 中,i 则是下一个元素

所以总的来说,这是一个迭代归纳的过程,将多个元素的流按照自己制定的计算规则变成一个元素。不仅仅可以做述职运算,也可以实现复杂对象的转换,先看例子(此例来源于与廖雪峰老师的网站并稍做修改,具体链接:https://www.liaoxuefeng.com/wiki/1252599548343744/1322402971648033):

List<String> props = Lists.newArrayList("profile=native", "debug=true", "logging=warn", "interval=500");    Map<String, String> map = props.stream()        .map(kv -> {          String[] ss = kv.split("=", 2);          Map<String, String> m = Maps.newHashMap();          m.put(ss[0], ss[1]);          return m;        })        .reduce(new HashMap<>(), (m, kv) -> {          m.putAll(kv);          return m;        });    map.forEach((k, v) -> System.out.println(k + " = " + v));
//output:logging = warninterval = 500debug = trueprofile = native
复制代码

第一个 map 执行完之后返回了多个小 map 这里使用 reduce 进行一个 map 的累加:

  • new HashMap<>()是初始值,一个空 map

  • (m, kv) ->中,m 是暂存累加结果,kv 表示下一个元素 map

以上看来,reduce 的使用场景应该会很广泛,尤其是多个集合合成一个大集合的场景。

对于多线程的场景,reduce 也是支持的,这里先引用一段设计者的话来辅助说明:

One of the design principles of the Streams API is that the API shouldn't differ between sequential and parallel streams, or put another way, a particular API shouldn't prevent a stream from running correctly either sequentially or in parallel.

他的意思是说,stream API 的设计原则就是让这些方法在顺序执行和并行执行的场景下使用体验一致。其实要让这些方法高度封装,他们在底层实现并行计算,在外面用起来感觉跟单线程一样。reduce 就实现了这一点,用到是第三个方法签名:

<U> U reduce(U identity, BiFunction<U, ? super T, U> accumulator, BinaryOperator<U> combiner);
复制代码

这里相对之前的签名多了一个 combiner,可以直接从字面上理解就是组合多个线程的结果,但为什么是个 BinaryOperator 呢?先看一个简化版的实现:

U reduce(I, (U, T) -> U, (U, U) -> U)
复制代码

I 依然是初始值

(U, T) -> U表示归纳的计算方法,值得注意的是这里可以允许传一个比的对象进来,但最终是返回一个 U

(U, U) -> U这个很关键,这个算式告诉多个线程怎么组合各自的计算结果,所以应该和上面的计算方法保持一致,返回的值也保持一致

结合一个具体的例子看看:

List<User> users = Arrays.asList(new User("John", 30), new User("Julie", 35));int computedAges =        users.stream().reduce(0, (partialAgeResult, user) -> partialAgeResult + user.getAge(), Integer::sum);
复制代码
  • (partialAgeResult, user) -> partialAgeResult + user.getAge()表示计算所有人年龄的总和

  • Integer::sum则告诉多个线程,把各个线程的计算结果相加,因为这里是在计算加和。如果计算乘积,这里就应该是(a,b)->a*b

机智的你一定发现了,这里没有并行啊,只是单线程顺序执行。没错,这就是设计理念的体现,单线程多线程体验一致。这里变成并行计算只需要这样:

List<User> users = Arrays.asList(new User("John", 30), new User("Julie", 35));int computedAges =        users.stream().parallel().reduce(0, (partialAgeResult, user) -> partialAgeResult + user.getAge(), Integer::sum);
复制代码

执行结果不变。如果你不嫌麻烦,可以只用这一个 reduce 方法,但可能会带来一些可读性的障碍。

因为 collect 要说的东西也非常多,受限于篇幅影响,我放在下一篇。

小结

本文介绍了 stream 是什么、创建 stream 的方法、stream 的一些基本 API 的能力和 reduce 方法的使用。作为 stream 最佳实践的开篇,先从 stream 的基础开始写,后续会逐步深入并总结我个人使用下来的最佳实践,希望大家持续关注,共同学习。


发布于: 2021 年 02 月 24 日阅读数: 22
用户头像

ES_her0

关注

还未添加个人签名 2018.03.21 加入

还未添加个人简介

评论

发布
暂无评论
可能是Java Stream的最佳实践(一)