Java8系列 - 从Stream看如何实践函数式编程

函数式编程

Stream

函数式编程和lambda表达式

函数式编程面向对象编程一样,是一种编程思维模式,函数式编程对行为抽象,行为在编程语言中一般指函数、方法,所以叫函数式编程。Java 8引入lambda表达式之前,想要使用函数式编程只能通过匿名内部类实现,但臃肿的代码阻碍了开发人员使用这种方式的积极性,lambda表达式的引入就是解决了这个问题:实现了语句级别的行为参数化进行直接传递。比如:arithmetic(10, 20, (x, y) -> x + y),其中(x, y) -> x + y就是一条语句级别的行为,直接通过参数方式进行传递。

行为可以当成参数很方便的进行传递,从开发角度来说:可将传递进来的多种行为参数进行灵活的组合,构建出扩展性强的高级接口,这就是函数式编程一个核心特性:行为组合,将两个函数组合起来构成第3个函数,其效果等价于连续应用两个组件。行为可以看成业务逻辑代码,以前可能会把业务逻辑代码写死在一个方法内部中,现在可能你会更加倾向通过行为参数传递进来,增强了接口的扩展性。

Stream概述

StreamJava 8lambda表达式的又一大重要更新,很多人开始使用Java 8最直接的需求就是基于Stream这套API,函数式编程可能比较抽象,Stream API的设计基于函数式编程lambda表达式,有力地展现了函数式编程lambda表达式结合带给Java的强大能力,同时又不需要太高的技术门槛即可享受到所带来的好处。

集合框架

集合Java中使用最多的API,要是没有集合,还能做什么呢?几乎每个Java应用程序都会制造和处理集合。集合对于很多编程任务来说都是非常基本的。尽管集合对于几乎任何一个Java应用都是不可或缺的,但集合操作却远远算不上完美。

什么是集合?

集合可以看成是对编程开发中经常使用到的数据结构与算法的封装,数据结构解决如何有效地组织和操作数据,这些数据结构通常称为Java集合框架

业务系统开发有一个比较通俗的叫法:面向数据库开发,基于数据库的存储模型和SQL语法实现对数据的存储及各种数据处理操作,SQL开发在业务系统开发中占有很大的比例。但是数据库会存在网路IO磁盘IO在性能上会存在问题。

如果我们站在一种更高抽象层次上来说,集合其实就可以看成是一种微型数据库,它们关注点都是解决如何有效地组织数据和操作数据,直白点将就是:把存储数据存储起来并可以数据进行增删改查等操作。

集合数据处理能力不足

很多业务逻辑都涉及类似于数据库的操作,比如对学生成绩按照科目分组,找出每个科目成绩最高分:select subject, max(score) from students group by subject。你只需要表达你想要什么,而不需要关注具体如何去做。这个基本的思路意味着,你用不着担心怎么去显式地实现这些查询语句——都替你办好了!怎么到了集合这里就不能这样了呢?

1554648070022

如上图,集合框架顶层接口Collection所包含的方法列表:获取集合元素数量、集合是否为空、集合是否包含某个元素、添加元素、移除元素等很基本操作,和数据库提供的SQL特性相比,集合在数据处理能力上简直弱爆了。比如:select name from student where score < 60 and name = 'lisi' order by score, age desc,简单、直白的完成了将对数据的多种操作组合在一起。

集合并发处理复杂

要是要处理大量元素又该怎么办呢?为了提高性能,你需要并行处理,并利用多核架构。但写并行代码比用迭代器还要复杂,而且调试起来也够受的!那Java语言的设计者能做些什么,来帮助你节约宝贵的时间,让你这个程序员活得轻松一点儿呢?

基本概念

Stream API可以把它看成一种高级迭代器,提供声明式处理数据集合能力,使用类似于数据库的操作帮助你处理集合。它支持两种类型的操作:中间操作终端操作。中间操作可以链接起来,将一个流转成另一个流,这些操作不会消耗流,其目的是建立一个流水线。与此相反,终端操作会消耗流,以产生一个最终结果,它们通常可以通过优化流水线来缩短计算时间。

1554732656219

1555314834704

区分中间操作和终止操作就是看返回类型:中间操作都会返回一个Stream对象,而终止操作则不返回Stream类型,可能不返回值,也可能返回其它类型的单个值。中间操作返回类型是Stream,可以进行串联形成流水线;中间操作具有惰性求值特性,除非流水线上触发一个终端操作,否则中间操作不会执行任何处理。

1555296247833

map

1555292153524

1555292289357

使用map 操作将字符串转换为大写形式:

1
2
3
List<String> collected = Stream.of("a", "b", "hello")
.map(string -> string.toUpperCase())
.collect(toList());

filter

1555292321577

1555292349152

1
2
3
4
List<String> beginningWithNumbers = 
Stream.of("a", "1abc", "abc1")
.filter(value -> isDigit(value.charAt(0)))
.collect(toList());

flatMap

1555292742426

1
2
3
List<Integer> together = Stream.of(asList(1, 2), asList(3, 4))
.flatMap(numbers -> numbers.stream())
.collect(toList());

reduce(规约)

1555293073638

1
2
int count = Stream.of(1, 2, 3)
.reduce(0, (acc, element) -> acc + element);

collect(收集器)

数据分块

1555293629491

1
2
3
public Map<Boolean, List<Artist>> bandsAndSoloRef(Stream<Artist> artists) {
return artists.collect(partitioningBy(Artist::isSolo));
}

数据分组

1555293746892

使用主唱对专辑分组:

1
2
3
public Map<Artist, List<Album>> albumsByArtist(Stream<Album> albums) {
return albums.collect(groupingBy(album -> album.getMainMusician()));
}

类似于SQL中的group by 操作,我们的方法是和这类似的一个概念,只不过在Stream 类库中实现了而已

字符串

很多时候,收集流中的数据都是为了在最后生成一个字符串。

1
2
3
4
5
String result = artists.stream()
.map(Artist::getName)
.collect(Collectors.joining(", ", "[", "]"));

结果样本::[George Harrison, John Lennon, Paul McCartney, Ringo Starr, The Beatles]

内部迭代和外部迭代

集合和流的一个关键区别在于它们遍历数据的方式不同 ,使用Collection API你得用for-eachIterator迭代器一个个去获取元素,拿到元素后续的处理流程完全靠开发人员自己处理,这称为外部迭代。有了Stream API后,你根本用不着操心循环迭代的事情,数据处理流程完全是在Stream库内部驱动进行,这种方式称为内部迭代。 下面的代码列表说明了这种区别。

1
2
3
4
5
6
7
8
List<String> names = new ArrayList<>();
for(Dish d: menu){
names.add(d.getName());
}

List<String> names = menu.stream()
.map(Dish::getName)
.collect(toList());

内部迭代与外部迭代示意图

1553531823081

内部迭代外部迭代的本质区别:驱动方式发生了转变,内部迭代驱动方变成了Stream库;而外部迭代驱动方在是程序开发人员,就好比Spring控制反转概念一样,这种驱动方式转变意义在哪里呢?下面继续通过一个案例说明。

1
2
3
4
5
6
int ret = list.stream()
.filter(x -> x > 2)
.mapToInt(x -> x * 2)
.skip(2)
.limit(2)
.sum();

1555299164559

数据处理常用的处理模式:组件化+处理链,如上述案例,Stream会帮你把各种操作组件化,并将创建的组件构建成一条处理链,如果把这个看成一个需求开发的话,开发人员把只需要把需求点告诉Stream API,由Stream API来构建完整的数据处理流程,并在合适的时机通过回调方式将需求点的业务逻辑嵌入到处理流程中即可。

如果使用传统的集合APIfor-eachIterator迭代器循环一个个去迭代元素,后续如何组件化以及将这些组件构建成处理链完全有开发人员自己开发维护。一个标准组件(见下图)真正的业务逻辑部分只是其中一部分,如:是否包含一个Queue进行组件间解耦、是否使用线程池进行并发控制、如何将处理后的数据传递到下一个组件等等。这些其实与业务逻辑没有太大关联性,但是又是每个组件必须考虑的公共特性。

1553569873509

本身开发难度非常高,再加上开发人员能力水平参差不齐,很容易导致开发出来的代码质量不高、扩展不强、后期很难维护等。Stream就是看到集合存在的问题,由它来帮你如何设计组件以及如何构建处理链,开发人员就好比甲方提出你的需求,由Stream来设计、开发。

Stream API是由JDK高级开发工程师实现的(开发人员能力得到保证),而且在全世界范围广泛使用(代码质量得到保证),Stream作为驱动方,尽可能的帮你做了代码优化,比如这里另一个最大收益就是并行开发。

比如现在处理的集合数据量非常大,服务器是多核处理器,理想情况下,你可能会让这些CPU内核共同分担处理工作,以缩短处理时间。问题在于,通过多线程代码来利用并行(使用先前Java版本中的Thread API)并非易事。你得换一种思路:线程可能会同时访问并更新共享变量。因此,如果没有协调好,数据可能会被意外改变。

1
2
3
Long sum = LongStream.rangeClosed(1, 100)
.parallel()
.sum();

Stream APICollection API通俗例子说明:

公司入职一批新员工要求做自我介绍,有的人表达能力强介绍的很精彩,有的人可能就很茫然不晓得咋个介绍等情况,最后导致大家介绍样式五花八门。现在给新员工统一一份自我介绍模板,只需要把各人的如姓名、工作年限等基本信息填入即可。Stream API就像是这份模板,而开发人员只需要做简单的填空,而集合API就像第一种情况,给你一个需求,完全靠开发人员自由发挥,显然做填空题的难度和工作量要小很多。

Stream和Collection区别

很多刚接触Stream API的,很容易把Stream和Collection概念混淆:Collection主要是为了存储和访问数据,而Stream则主要用于描述对数据的计算处理,即集合关注的是数据和数据存储本身,而流关注的则是对数据的计算。

1555311774940

声明式编程

业务系统开发中SQL使用很高的另一个很重要的原因是:SQL作为一种声明式语言,通过简单的声明式指令即可实现数据处理,不需要过多的关系底层的实现细节,降低了开发难度,提高了开发效率。Stream也是遵循:做什么,而不用关心怎么去做的原则。

SQL语句是一种描述性语言:

select name from student where score < 60 and name = 'lisi' order by score, age desc;

1
2
3
4
5
6
studentList.stream()
.filter(student -> student.getScore() < 60)
.filter(student -> student.getName().equals("lisi"))
.sorted(Comparator.comparingInt(Student::getScore).thenComparingInt(Student::getAge).reversed())
.map(Student::getName)
.collect(Collectors.toList());

总结

集合关注的是数据和数据存储本身,而流关注的则是对数据的计算,流不是替代集合的,流是为了给集合更好的服务。

Stream可以用类似于数据库的操作帮助你处理集合,Stream就是将集合这一面向“存储”的对象赋予了面向“计算”的能力。

坚持原创技术分享,您的支持将鼓励我继续创作!