JDK8函数式编程之Stream API_慕课手记

200 阅读16分钟
原文链接: www.imooc.com

发现许多学员对当前流行的JDK8的stream还是很陌生,特别找了一篇文章。以下是正文:

你可能没意识到Java对函数式编程的重视程度,看看Java 8加入函数式编程扩充多少功能就清楚了。Java 8之所以费这么大功夫引入函数式编程,原因有二:

代码简洁,函数式编程写出的代码简洁且意图明确,使用stream接口让你从此告别for循环。

多核友好,Java函数式编程使得编写并行程序从未如此简单,你需要的全部就是调用一下parallel()方法。

这一节我们学习stream,也就是Java函数式编程的主角。对于Java 7来说stream完全是个陌生东西,stream并不是某种数据结构,它只是数据源的一种视图。这里的数据源可以是一个数组,Java容器或I/O channel等。正因如此要得到一个stream通常不会手动创建,而是调用对应的工具方法,比如:

  • 调用Collection.stream()或者Collection.parallelStream()方法;

  • 调用Arrays.stream(T[] array)方法。

常见的stream接口继承关系如图:

https://img1.mukewang.com/5ac210400001003606400227.jpg

图中4种stream接口继承自BaseStream,其中IntStream, LongStream, DoubleStream对应三种基本类型(int, long, double,注意不是包装类型),Stream对应所有剩余类型的stream视图。为不同数据类型设置不同stream接口,可以1.提高性能,2.增加特定接口函数。

https://img1.mukewang.com/5ac2104e0001481906400339.jpg

你可能会奇怪为什么不把IntStream等设计成Stream的子接口?毕竟这接口中的方法名大部分是一样的。答案是这些方法的名字虽然相同,但是返回类型不同,如果设计成父子接口关系,这些方法将不能共存,因为Java不允许只有返回类型不同的方法重载。

虽然大部分情况下stream是容器调用Collection.stream()方法得到的,但stream和collections有以下不同:

  • 无存储。stream不是一种数据结构,它只是某种数据源的一个视图,数据源可以是一个数组,Java容器或I/O channel等。

  • 为函数式编程而生。对stream的任何修改都不会修改背后的数据源,比如对stream执行过滤操作并不会删除被过滤的元素,而是会产生一个不包含被过滤元素的新stream。

  • 惰式执行。stream上的操作并不会立即执行,只有等到用户真正需要结果的时候才会执行。

  • 可消费性。stream只能被“消费”一次,一旦遍历过就会失效,就像容器的迭代器那样,想要再次遍历必须重新生成。

对stream的操作分为为两类,中间操作(intermediate operations)和结束操作(terminal operations),二者特点是:

  1. 中间操作总是会惰式执行,调用中间操作只会生成一个标记了该操作的新stream,仅此而已。

  2. 结束操作会触发实际计算,计算发生时会把所有中间操作积攒的操作以pipeline的方式执行,这样可以减少迭代次数。计算完成之后stream就会失效。

如果你熟悉Apache Spark RDD,对stream的这个特点应该不陌生。

下表汇总了Stream接口的部分常见方法:

https://img2.mukewang.com/5ac20fc50001de1906400146.jpg

区分中间操作和结束操作最简单的方法,就是看方法的返回值,返回值为stream的大都是中间操作,否则是结束操作。

stream方法使用

stream跟函数接口关系非常紧密,没有函数接口stream就无法工作。回顾一下:函数接口是指内部只有一个抽象方法的接口。通常函数接口出现的地方都可以使用Lambda表达式,所以不必记忆函数接口的名字。

forEach()

我们对forEach()方法并不陌生,在Collection中我们已经见过。方法签名为void forEach(Consumer<? super E> action),作用是对容器中的每个元素执行action指定的动作,也就是对元素进行遍历。

// 使用Stream.forEach()迭代

Stream<String> stream = Stream.of("I", "love", "you", "too");

stream.forEach(str -> System.out.println(str));

由于forEach()是结束方法,上述代码会立即执行,输出所有字符串。

filter()

https://img1.mukewang.com/5ac210890001032106000539.jpg

函数原型为Stream<T> filter(Predicate<? super T> predicate),作用是返回一个只包含满足predicate条件元素的Stream。

// 保留长度等于3的字符串

Stream<String> stream= Stream.of("I", "love", "you", "too");

stream.filter(str -> str.length()==3)

    .forEach(str -> System.out.println(str));

上述代码将输出为长度等于3的字符串you和too。注意,由于filter()是个中间操作,如果只调用filter()不会有实际计算,因此也不会输出任何信息。

distinct()

https://img1.mukewang.com/5ac2109b00015bfc06000715.jpg

函数原型为Stream<T> distinct(),作用是返回一个去除重复元素之后的Stream。

Stream<String> stream= Stream.of("I", "love", "you", "too", "too");

stream.distinct()

    .forEach(str -> System.out.println(str));

上述代码会输出去掉一个too之后的其余字符串。

sorted()

排序函数有两个,一个是用自然顺序排序,一个是使用自定义比较器排序,函数原型分别为Stream<T> sorted()和Stream<T> sorted(Comparator<? super T> comparator)。

Stream<String> stream= Stream.of("I", "love", "you", "too");

stream.sorted((str1, str2) -> str1.length()-str2.length())

    .forEach(str -> System.out.println(str));

上述代码将输出按照长度升序排序后的字符串,结果完全在预料之中。

map()

https://img3.mukewang.com/5ac210bc0001eae506000545.jpg

函数原型为<R> Stream<R> map(Function<? super T,? extends R> mapper),作用是返回一个对当前所有元素执行执行mapper之后的结果组成的Stream。直观的说,就是对每个元素按照某种操作进行转换,转换前后Stream中元素的个数不会改变,但元素的类型取决于转换之后的类型。

Stream<String> stream = Stream.of("I", "love", "you", "too");

stream.map(str -> str.toUpperCase())

    .forEach(str -> System.out.println(str));

上述代码将输出原字符串的大写形式。

flatMap()

https://img2.mukewang.com/5ac210e10001511606000564.jpg

函数原型为<R> Stream<R> flatMap(Function<? super T,? extends Stream<? extends R>> mapper),作用是对每个元素执行mapper指定的操作,并用所有mapper返回的Stream中的元素组成一个新的Stream作为最终返回结果。说起来太拗口,通俗的讲flatMap()的作用就相当于把原stream中的所有元素都”摊平”之后组成的Stream,转换前后元素的个数和类型都可能会改变。

Stream<List<Integer>> stream = Stream.of(Arrays.asList(1,2), Arrays.asList(3, 4, 5));

stream.flatMap(list -> list.stream())

    .forEach(i -> System.out.println(i));

上述代码中,原来的stream中有两个元素,分别是两个List<Integer>,执行flatMap()之后,将每个List都“摊平”成了一个个的数字,所以会新产生一个由5个数字组成的Stream。所以最终将输出1~5这5个数字。


规约操作(reduction operation)又被称作折叠操作(fold),是通过某个连接动作将所有元素汇总成一个汇总结果的过程。元素求和、求最大值或最小值、求出元素总个数、将所有元素转换成一个列表或集合,都属于规约操作。Stream类库有两个通用的规约操作reduce()和collect(),也有一些为简化书写而设计的专用规约操作,比如sum()、max()、min()、count()等。

最大或最小值这类规约操作很好理解(至少方法语义上是这样),我们着重介绍reduce()和collect(),这是比较有魔法的地方。

多面手reduce()

reduce操作可以实现从一组元素中生成一个值,sum()、max()、min()、count()等都是reduce操作,将他们单独设为函数只是因为常用。reduce()的方法定义有三种重写形式:

  • Optional<T> reduce(BinaryOperator<T> accumulator)

  • T reduce(T identity, BinaryOperator<T> accumulator)

  • <U> U reduce(U identity, BiFunction<U,? super T,U> accumulator, BinaryOperator<U> combiner)

虽然函数定义越来越长,但语义不曾改变,多的参数只是为了指明初始值(参数identity),或者是指定并行执行时多个部分结果的合并方式(参数combiner)。reduce()最常用的场景就是从一堆值中生成一个值。用这么复杂的函数去求一个最大或最小值,你是不是觉得设计者有病。其实不然,因为“大”和“小”或者“求和”有时会有不同的语义。

需求:从一组单词中找出最长的单词。这里“大”的含义就是“长”。

// 找出最长的单词![](http://images2015.cnblogs.com/blog/939998/201703/939998-20170314192638495-351834305.png)

 

Stream<String> stream = Stream.of("I", "love", "you", "too");

Optional<String> longest = stream.reduce((s1, s2) -> s1.length()>=s2.length() ? s1 : s2);

//Optional<String> longest = stream.max((s1, s2) -> s1.length()-s2.length());

System.out.println(longest.get());

上述代码会选出最长的单词love,其中Optional是(一个)值的容器,使用它可以避免null值的麻烦。当然可以使用Stream.max(Comparator<? super T> comparator)方法来达到同等效果,但reduce()自有其存在的理由。

https://img2.mukewang.com/5ac2110200018ec706400269.jpg

需求:求出一组单词的长度之和。这是个“求和”操作,操作对象输入类型是String,而结果类型是Integer。

// 求单词长度之和

Stream<String> stream = Stream.of("I", "love", "you", "too");

Integer lengthSum = stream.reduce(0, // 初始值 // (1)

        (sum, str) -> sum+str.length(), // 累加器 // (2)

        (a, b) -> a+b); // 部分和拼接器,并行执行时才会用到 // (3)

// int lengthSum = stream.mapToInt(str -> str.length()).sum();

System.out.println(lengthSum);

上述代码标号(2)处将i. 字符串映射成长度,ii. 并和当前累加和相加。这显然是两步操作,使用reduce()函数将这两步合二为一,更有助于提升性能。如果想要使用map()和sum()组合来达到上述目的,也是可以的。

reduce()擅长的是生成一个值,如果想要从Stream生成一个集合或者Map等复杂的对象该怎么办呢?终极武器collect()横空出世!

终极武器collect() 

不夸张的讲,如果你发现某个功能在Stream接口中没找到,十有八九可以通过collect()方法实现。collect()是Stream接口方法中最灵活的一个,学会它才算真正入门Java函数式编程。先看几个热身的小例子:

// 将Stream转换成容器或Map

Stream<String> stream = Stream.of("I", "love", "you", "too");

List<String> list = stream.collect(Collectors.toList()); // (1)

// Set<String> set = stream.collect(Collectors.toSet()); // (2)

// Map<String, Integer> map = stream.collect(Collectors.toMap(Function.identity(), String::length)); // (3)

上述代码分别列举了如何将Stream转换成List、Set和Map。虽然代码语义很明确,可是我们仍然会有几个疑问:

  1. Function.identity()是干什么的?

  2. String::length是什么意思?

  3. Collectors是个什么东西?

接口的静态方法和默认方法

Function是一个接口,那么Function.identity()是什么意思呢?这要从两方面解释:

  1. Java 8允许在接口中加入具体方法。接口中的具体方法有两种,default方法和static方法,identity()就是Function接口的一个静态方法。

  2. Function.identity()返回一个输出跟输入一样的Lambda表达式对象,等价于形如t -> t形式的Lambda表达式。

上面的解释是不是让你疑问更多?不要问我为什么接口中可以有具体方法,也不要告诉我你觉得t -> t比identity()方法更直观。我会告诉你接口中的default方法是一个无奈之举,在Java 7及之前要想在定义好的接口中加入新的抽象方法是很困难甚至不可能的,因为所有实现了该接口的类都要重新实现。试想在Collection接口中加入一个stream()抽象方法会怎样?default方法就是用来解决这个尴尬问题的,直接在接口中实现新加入的方法。既然已经引入了default方法,为何不再加入static方法来避免专门的工具类呢!

方法引用

诸如String::length的语法形式叫做方法引用(method references),这种语法用来替代某些特定形式Lambda表达式。如果Lambda表达式的全部内容就是调用一个已有的方法,那么可以用方法引用来替代Lambda表达式。方法引用可以细分为四类:

https://img3.mukewang.com/5ac2116c000138d906400174.jpg

我们会在后面的例子中使用方法引用。

收集器

相信前面繁琐的内容已彻底打消了你学习Java函数式编程的热情,不过很遗憾,下面的内容更繁琐。

https://img1.mukewang.com/5ac2117e0001cca606400252.jpg

收集器(Collector)是为Stream.collect()方法量身打造的工具接口(类)。考虑一下将一个Stream转换成一个容器(或者Map)需要做哪些工作?我们至少需要两样东西:

  1. 目标容器是什么?是ArrayList还是HashSet,或者是个TreeMap。

  2. 新元素如何添加到容器中?是List.add()还是Map.put()。

如果并行的进行规约,还需要告诉collect() 3. 多个部分结果如何合并成一个。

结合以上分析,collect()方法定义为<R> R collect(Supplier<R> supplier, BiConsumer<R,? super T> accumulator, BiConsumer<R,R> combiner),三个参数依次对应上述三条分析。不过每次调用collect()都要传入这三个参数太麻烦,收集器Collector就是对这三个参数的简单封装,所以collect()的另一定义为<R,A> R collect(Collector<? super T,A,R> collector)。Collectors工具类可通过静态方法生成各种常用的Collector。举例来说,如果要将Stream规约成List可以通过如下两种方式实现:

// 将Stream规约成List

Stream<String> stream = Stream.of("I", "love", "you", "too");

List<String> list = stream.collect(ArrayList::new, ArrayList::add, ArrayList::addAll);// 方式1

//List<String> list = stream.collect(Collectors.toList());// 方式2

System.out.println(list);

通常情况下我们不需要手动指定collect()的三个参数,而是调用collect(Collector<? super T,A,R> collector)方法,并且参数中的Collector对象大都是直接通过Collectors工具类获得。实际上传入的收集器的行为决定了collect()的行为。

使用collect()生成Collection

前面已经提到通过collect()方法将Stream转换成容器的方法,这里再汇总一下。将Stream转换成List或Set是比较常见的操作,所以Collectors工具已经为我们提供了对应的收集器,通过如下代码即可完成:

// 将Stream转换成List或Set

Stream<String> stream = Stream.of("I", "love", "you", "too");

List<String> list = stream.collect(Collectors.toList()); // (1)

Set<String> set = stream.collect(Collectors.toSet()); // (2)

上述代码能够满足大部分需求,但由于返回结果是接口类型,我们并不知道类库实际选择的容器类型是什么,有时候我们可能会想要人为指定容器的实际类型,这个需求可通过Collectors.toCollection(Supplier<C> collectionFactory)方法完成。

// 使用toCollection()指定规约容器的类型

ArrayList<String> arrayList = stream.collect(Collectors.toCollection(ArrayList::new));// (3)

HashSet<String> hashSet = stream.collect(Collectors.toCollection(HashSet::new));// (4)

上述代码(3)处指定规约结果是ArrayList,而(4)处指定规约结果为HashSet。一切如你所愿。

使用collect()生成Map

前面已经说过Stream背后依赖于某种数据源,数据源可以是数组、容器等,但不能是Map。反过来从Stream生成Map是可以的,但我们要想清楚Map的key和value分别代表什么,根本原因是我们要想清楚要干什么。通常在三种情况下collect()的结果会是Map:

  1. 使用Collectors.toMap()生成的收集器,用户需要指定如何生成Map的key和value。

  2. 使用Collectors.partitioningBy()生成的收集器,对元素进行二分区操作时用到。

  3. 使用Collectors.groupingBy()生成的收集器,对元素做group操作时用到。

情况1:使用toMap()生成的收集器,这种情况是最直接的,前面例子中已提到,这是和Collectors.toCollection()并列的方法。如下代码展示将学生列表转换成由<学生,GPA>组成的Map。非常直观,无需多言。

// 使用toMap()统计学生GPA

Map<Student, Double> studentToGPA =

     students.stream().collect(Collectors.toMap(Functions.identity(),// 如何生成key

                                     student -> computeGPA(student)));// 如何生成value

情况2:使用partitioningBy()生成的收集器,这种情况适用于将Stream中的元素依据某个二值逻辑(满足条件,或不满足)分成互补相交的两部分,比如男女性别、成绩及格与否等。下列代码展示将学生分成成绩及格或不及格的两部分。

// Partition students into passing and failing

Map<Boolean, List<Student>> passingFailing = students.stream()

         .collect(Collectors.partitioningBy(s -> s.getGrade() >= PASS_THRESHOLD));

情况3:使用groupingBy()生成的收集器,这是比较灵活的一种情况。跟SQL中的group by语句类似,这里的groupingBy()也是按照某个属性对数据进行分组,属性相同的元素会被对应到Map的同一个key上。下列代码展示将员工按照部门进行分组:

// Group employees by department

Map<Department, List<Employee>> byDept = employees.stream()

            .collect(Collectors.groupingBy(Employee::getDepartment));

以上只是分组的最基本用法,有些时候仅仅分组是不够的。在SQL中使用group by是为了协助其他查询,比如1. 先将员工按照部门分组,2. 然后统计每个部门员工的人数。Java类库设计者也考虑到了这种情况,增强版的groupingBy()能够满足这种需求。增强版的groupingBy()允许我们对元素分组之后再执行某种运算,比如求和、计数、平均值、类型转换等。这种先将元素分组的收集器叫做上游收集器,之后执行其他运算的收集器叫做下游收集器(downstream Collector)。

// 使用下游收集器统计每个部门的人数

Map<Department, Integer> totalByDept = employees.stream()

                    .collect(Collectors.groupingBy(Employee::getDepartment,

                                                   Collectors.counting()));// 下游收集器

上面代码的逻辑是不是越看越像SQL?高度非结构化。还有更狠的,下游收集器还可以包含更下游的收集器,这绝不是为了炫技而增加的把戏,而是实际场景需要。考虑将员工按照部门分组的场景,如果我们想得到每个员工的名字(字符串),而不是一个个Employee对象,可通过如下方式做到:

// 按照部门对员工分布组,并只保留员工的名字

Map<Department, List<String>> byDept = employees.stream()

                .collect(Collectors.groupingBy(Employee::getDepartment,

                        Collectors.mapping(Employee::getName,// 下游收集器

                                Collectors.toList())));// 更下游的收集器

如果看到这里你还没有对Java函数式编程失去信心,恭喜你,你已经顺利成为Java函数式编程大师了。

使用collect()做字符串join

这个肯定是大家喜闻乐见的功能,字符串拼接时使用Collectors.joining()生成的收集器,从此告别for循环。Collectors.joining()方法有三种重写形式,分别对应三种不同的拼接方式。无需多言,代码过目难忘。

// 使用Collectors.joining()拼接字符串

Stream<String> stream = Stream.of("I", "love", "you");

//String joined = stream.collect(Collectors.joining());// "Iloveyou"

//String joined = stream.collect(Collectors.joining(","));// "I,love,you"

String joined = stream.collect(Collectors.joining(",", "{", "}"));// "{I,love,you}"

collect()还可以做更多

除了可以使用Collectors工具类已经封装好的收集器,我们还可以自定义收集器,或者直接调用collect(Supplier<R> supplier, BiConsumer<R,? super T> accumulator, BiConsumer<R,R> combiner)方法,收集任何形式你想要的信息。不过Collectors工具类应该能满足我们的绝大部分需求,手动实现之间请先看看文档。

github地址:

github.com/kanwangzjm/…

github.com/kanwangzjm/…