浅谈函数式编程与 Stream
前言
在这一篇文章中,我将介绍函数式编程的基本概念,如何使用函数式编程的思想编写代码以及 Java Stream 的基本使用方法。
本文不会涉及到任何晦涩难懂的数学概念,函数式编程的理论以及函数式编程的高阶特性,譬如:惰性求值(Lazy Evaluation),模式匹配等。所以,请放心食用。
这篇文章对于以下的人群或许有一定的帮助:
说不清什么是函数式编程的人
不知道什么时候可以使用 Java Stream 的人
Java8 出来了这么久,还是无法写好 Stream 操作的人
本文使用的代码语言为:Java,一部分案例使用了 Python 。这篇文章参考并引用了很多优秀文章的内容,所有的参考链接在最后,如果想要更多地了解函数式编程以及 Java Stream 的相关操作,我推荐你把本文最后给出链接的那些资料尽可能详细地看一遍,相信一定会对你有所帮助 :-)
一:函数式编程
1. 什么是函数式编程?
在向你介绍什么是函数式编程之前,我们不妨来简单了解一些历史。
函数式编程的理论基础是阿隆佐.邱奇(Alonzo Church)在 1930 年代开发的 λ 演算(λ-calculus)。
λ 演算其本质是一种数学的抽象,是数理逻辑中的一个形式系统(Formal System)。这个系统是为一个超级机器设计的编程语言,在这种语言里面,函数的参数是函数,返回值也是函数。这种函数用希腊字母 Lambda(λ)来表示。
这个时候,λ 演算还仅仅是阿隆佐的一种思想,一种计算模型,并没有运用到任何的硬件系统上。直到 20 世纪 50 年代后期,一位 MIT 的教授 John McCarthy 对阿隆佐的研究产生了兴趣,并于 1958 年开发了早期的函数式编程语言 LISP,可以说,LISP 语言是一种阿隆佐的 λ 演算在现实世界的实现。很多计算机科学家都认识到了 LISP 强大的能力。1973 年在 MIT 人工智能实验室的一些程序员研发出了一种机器,并把它叫做 LISP 机,这个时候,阿隆佐的 λ 演算终于有了自己的硬件实现!
那么话说回来,什么是函数式编程呢?
维基百科中,函数式编程(Functional Programming)的定义如下:
函数式编程是一种编程范式。它把计算当成是数学函数的求值,从而避免改变状态和使用可变数据。它是一种声明式的编程范式,通过表达式和声明而不是语句来编程。
说到这里,你可能还是不明白,究竟什么是 FP(Functional Programming)?既然 FP 作为一种编程范式,就不得不提与之相对应的另一种编程范式——传统的指令式编程(Imperative Programming)。接下来,我们就通过一些代码案例,让你直观地感受一下,函数式编程与指令式编程有哪些差异;另外,我想告诉你的是即便不了解函数式编程中那些深奥的概念,我们也可以使用函数式编程的思想来写代码:)
案例一:二叉树镜像
这个题目可以在 LeetCode 上找到,感兴趣的朋友可以自行搜索一下。
题目要求是这样的:请完成一个函数,输入一个二叉树,该函数输出它的镜像。
例如输入:
镜像输出:
传统的指令式编程的代码是这样的:
可以看到,指令式编程就像是我们程序员规定的一种描述计算机所需要作出一系列行为的指令集(行动清单)。我们需要详细地告诉计算机每一步需要执行什么命令,就像是这段代码一样,我们首先判断节点是否为空;然后使用一个临时变量存储左子树,并完成左子树与右子树的镜像翻转,最后左右互换。我们只要将计算机需要完成的那些步骤写出,然后交给机器运行即可,这种“面向机器编程”的思想就是指令式编程。
我们再来看一下函数式编程风格的代码:
你可能会觉得不解,这个代码是在哪里体现出函数式编程的呢?
先别急,让我慢慢向你解释。函数(function)这个名词最早是由莱布尼兹在 1694 年开始使用,用来描述输出值的变化同输入值变化的关系。而中文的“函数”一词,由清朝数学家李善兰翻译,其著作《代数学》书中解释为:“凡此变量中函(包含)彼变量者,则此为彼之函数”。无论哪一种解释,我们都知道了,函数这一概念描述的是一种关系映射,即:一种东西到另外一种东西之间的对应关系。
所以,我们可以使用函数式的思维去思考,获得一棵二叉树的镜像这个函数的输入是一棵“原树”,返回的结果是一棵翻转后的“新树”,而这个函数的本质就是从“原树”到“新树”的一个映射。
进而,我们可以找到这个映射的关系为“新树”的每一个节点都递归地和“原树”相反。
虽然这两段代码都使用了递归,但是思考的方式是截然不同的。前者描述的是“从原树得到新树应该怎样做”,后者描述的是从“原树”到“新树”的映射关系。
案例二:翻转字符串
题目为获得一个字符串的翻转。
指令式编程:
这段 Python 代码非常简单,我们模拟将一个字符串先从头至尾执行入栈操作,然后再从尾到头执行出栈操作,得到的就是一个翻转后的字符串了。
函数式编程:
如何理解函数式编程的思想书写翻转字符串的逻辑呢?获得一个字符串的翻转这个函数的输入是“原字符串”,返回的结果是翻转后的“新字符串”,而这个函数的本质就是从“原字符串”到“新字符串”的一个映射,将“原字符串”拆分为首字符和剩余的部分,剩余的部分翻转后放在前,再将首字符放在最后就得到了“新字符串”,这就是输入与输出的映射关系。
通过以上这两个示例,我们可以看到,指令式编程和函数式编程在思想上的不同之处:指令式编程的感觉就像我们在小学求解的数学题一样,需要一步一步计算,我们关心的是解决问题的过程;而函数式编程则关心的是数据到数据的映射关系。
2. 函数式编程的三大特性
函数式编程具有三大特性:
immutable data
first class functions
递归与尾递归的“天然”支持
immutable data
函数式编程中,函数是基础单元,我们通常理解的变量在函数式编程中也被函数所代替了:在函数式编程中变量仅仅代表某个表达式,但是为了大家可以更好地理解,我仍然使用“变量”这个表达。
纯粹的函数式编程所编写的函数是没有“变量”的,或者说这些“变量”是不可变的。这就是函数式编程的第一个特性:immutable data(数据不可变)。我们可以说,对于一个函数,只要输入是确定的,输出也是可以确定的,我们称之为无副作用。如果一个函数内部“变量”的状态不确定,就会导致同样的输入可能得到不同的输出,这是不被允许的。所以,我们这里所说的“变量”就要求是不能被修改的,且只能被赋一次初始值。
first class functions
在函数式编程中,函数是第一类对象,“first class functions” 可以让你的函数像“变量”一样被使用。所谓的“函数是第一类对象”的意思是说一个函数既可以作为其他函数的输入参数值,也可以作为一个函数的输出,即:从函数中返回一个函数。
我们来看一个例子:
这个示例中 inc()
函数返回了另一个函数incx()
,于是,我们可以用 inc()
函数来构造各种版本的 inc
函数,譬如: inc2()
和 inc5()
。这个技术叫做函数柯里化(Currying),它的实质就是使用了函数式编程的 “first class functions” 这个特性。
递归与尾递归的“天然”支持
递归这种思想和函数式编程是很配的,有点像是下雨天,巧克力和音乐更配的那种感觉。
函数式编程本身强调的是程序的执行结果而非执行过程,递归也是一样,我们更多在乎的是递归的返回值,即:宏观语义,而不是它在计算机中是怎么被压栈,怎么被嵌套调用的。
经典的递归程序案例是实现阶乘函数,这里我使用的是 JS 语言:
这段代码可以正常运行。不过,递归程序的本质就是方法的调用,在递归没有达到 basecase 时,方法栈会不停压入栈帧,直到递归调用有返回值时,方法栈的空间才会被释放。如果递归调用很深,就很容易造成性能的下降,甚至出现 StackoverflowError。
而尾递归则是一种特殊的递归,“尾递归优化技术”可以避免上述出现的问题,使其不再发生栈溢出的情况。
什么是尾递归?如果一个函数中,所有递归形式的调用都出现在函数的末尾,我们称这个递归函数就是尾递归的。
上面的求解阶乘的代码就不是尾递归的,因为我们在fact(n - 1)
调用之后,还需要一步计算过程。
而尾递归实现阶乘函数如下:
首先,尾递归优化需要语言或编译器的支持,像 Java,Python 并没有尾递归优化,其不做尾递归优化的原因是为了在抛出异常时可以有完整的 Stack Trace 输出。像 JavaScript,C 等语言则具备对尾递归的优化。而编译器可以做到这点,是因为当编译器检测到一个函数的调用是尾递归时,它就会覆盖当前的栈帧而不是在方法栈中新压入一个,尾递归通过覆盖当前的栈帧,使得所使用的栈内存大大缩减,且实际的运行效率有了显著的提高。
3. Java8 的函数式编程
函数式接口
Java8 中引入了一个概念——函数式接口。这个目的就是为了让 Java 语言可以更好地支持函数式编程。
下面就是一个函数式接口:
函数式接口只能有一个抽象方法,除此之外这个函数式接口看起来和普通的接口并没有啥区别。
如果你想让别人立刻理解这个接口是一个函数式接口的话,可以加上 @FunctionalInterface
注解,该注解除了限定并保证你的函数式接口只有一个抽象方法之外,不会提供任何额外的功能。
其实,早在 Java8 出现以前,就已经有很多函数式接口了,譬如我们熟知的 Runnable
,Comparator
,InvocationHandler
等,这些接口都是符合函数式接口的定义的。
Java8 引入的常用的函数式接口有这么几个:
Function<T,R> { R apply(T t); }
Predicate<T> { boolean test(T t); }
Consumer<T> { void accept(T t); }
Supplier<T> { T get(); }
... ...
说句篇外话,我个人非常讨厌在文章中讲解 API 的用法。
第一点:JDK 文档已经将每种 API 的用法非常详细地写出来了,没必要再次赘述这些东西。
第二点:我的文章字数有限,在有限的文字中,表达出来的应该是可以引导读者思考和评论的东西,而不是浪费大家阅读时间的糟粕。
所以,如果想要搞清所有的函数式接口的用法,大家可以自行查阅文档。
Lambda 表达式与方法引用
下面一段代码实现的功能为按照字符串长度的顺序对列表进行排序:
这段代码将匿名类的缺点暴露了出来——冗长,代码含义不清晰。在 Java8 中,引入了 Lambda 表达式来简化这种形式的代码,如果你使用的代码编译器是 IDEA,你就会发现在写完这段代码之后,编译器提示你:Anonymous new Comparator<String>() can be replaced with lambda
。
当你按下 option + enter
键之后,你就会发现自己开启了一扇新世界的大门:-)
接下来,我会向你不完全解释一下为什么 Lambda 表达式可以这样做:
首先,能够使用 Lambda 表达式的依据是必须有相应的函数式接口,这也反过来说明了,为什么函数式接口只能有一个抽象方法(如果有多个抽象方法,Lambda 怎么知道你写的是啥子嘞)。
第二点,Lambda 表达式的写法在没有经过“类型推断”的简化前应该是这样的:
之所以可以将括号内的类型省略,是因为 Lambda 表达式另一个依据是类型推断机制,在上下文信息足够的情况下,编译器可以推断出参数表的类型,而不需要显式指名。类型推断的机制是极为复杂的,大家可以参考一下 Java8 的 JLS 引入的类型推断这个章节,链接:https://docs.oracle.com/javase/specs/jls/se8/html/jls-18.html。
在将匿名类转化为 Lambda 表达式之后,聪明的你(实际上是聪明的编译器 doge)又发现了,编译器继续提示你:Can be replaced with Comparator.comparingInt
。
我们继续敲下 option + enter
键,发现 Lambda 表达式简化成了这样:
你发现,自己好像又迈入了一扇新世界的大门:-)
String::length
这种表示方式叫做方法引用,即:调用了 String
类的 length()
方法。其实 Lambda 表达式就已经够简洁了,但是方法引用表达的含义更清晰。使用方法引用的时候,只需要使用 ::
双冒号即可,无论是静态方法还是实例方法都可以这样被引用。
《Effective Java》这本 Java 实践圣经中的 Item 42,43 如下:
Prefer lambdas to anonymous classes(Lambda 表达式优于匿名类)
Prefer method references to lambdas(方法引用优于 Lambda 表达式)
从 Java8 开始。Lambda 是迄今为止表示小函数对象的最佳方式。除非必须创建非函数式接口类型的实例,否则不要使用匿名类作为函数对象。而方法引用则是对 Lambda 表达式的进一步优化,它相比于 Lambda 表达式有更清晰化的语义。如果方法引用比 Lambda 表达式看起来更简短更清晰,就使用方法引用吧!
一个策略模式的案例带你再次回顾 Java 函数式编程
这一章节,我为大家准备了一个商场打折的案例:
程序十分简单,我们的 calculatePrice
方法用来计算在不同的商场营销策略下,用户打折后的金额。方便起见,程序中的金额操作我就不考虑精度丢失的问题了。
这个程序最大的问题是,如果我们的商场有了新的促销策略,譬如全场打六折;明天商场倒闭,全场挥泪大甩卖三折起等,就需要在 calculatePrice
方法新添加一个 case
。如果后续我们陆陆续续新增几十种打折方案,就要不停修改我们的业务代码,并且使代码变得冗长难以维护。
所以,我们的“策略”应该和具体的业务分离开,这样才能降低代码之间的耦合,使得我们的代码变得易于维护。
我们可以使用策略模式来改进我们的代码。
DiscountStrategy 接口如下,当然,聪明如你也发现了这就是一个标准的函数式接口(为了防止你看不见,我特意加上了 @FunctionalInterface 注解~ doge):
接下来,我们只需要让不同的打折策略实现 DiscountStrategy 这个接口即可。
NoDiscountStrategy(穷屌丝不配拥有折扣):
Discount8Strategy:
Discount95Strategy:
OnlyVipDiscountStrategy:
这样,我们的业务代码和“打折策略”就实现了分离:
在 Java8 之后,引入了大量的函数式接口,我们发现 DicountStrategy 这个接口和 BiFunction 接口简直就是从一个胚子里刻出来的!
DiscountStrategy:
BiFunction:
是不是瞬间觉得自己吃了没有好好读 API 的亏:-)
经过一番猛如虎的操作之后,我们的代码精简成了这个样子:
从这个商场打折的案例,我们可以看到 Java 对函数式编程支持的一个“心路历程”。值得一提的是,这个案例最后部分的代码,我使用了 Lambda 表达式,这是因为打折策略并没有出现太复杂的情况,并且,我主要也是为了演示 Lambda 表达式的使用。但是,事实上,这是一种非常不好的实践,我仍然推荐你将不同的策略抽取成一个类的这种做法。我们看到:
这段代码已经开始变得有一些复杂了,如果我们的策略逻辑比这段代码还要复杂,即便你使用 Lambda 写出来,阅读这段代码的人仍然会觉得难以理解(并且 Lambda 不是具名的,更是增加了阅读者的困惑)。所以,在你无法使用一行 Lambda 完成你的功能时,就应该考虑将这段代码抽取出来,防止影响阅读它的人的体验。
二:Java Stream
Java Stream 是 Java8 最最最重要的特性,没有之一,它更是 Java 函数式编程中的灵魂!
网上关于 Java Stream 的介绍已经有很多了,在这篇文章中,我不会介绍太多关于 Stream 的特性以及各种 API 的使用方法,诸如:map
,reduce
等(毕竟你自己随便 google 就会出来一大堆文章),我打算和你探究一些新鲜玩意。
如你所见,2021 年的今天,JDK17 已经问世了,可是很多人的业务代码中仍然充斥着大量 Java5 的语法——一些本该使用 Stream 几行代码完成的操作,仍然在用又臭又长的 if...else
和 for
循环来代替着。为什么会出现这种情况呢?Java8 实际上是一个老古董了,为啥不用?
这就是我今天要和你探讨的问题了。
我总结了两类不愿使用 Stream API 的人:
第一类人曰:“不知道什么时候用,即便知道可以用,但是也用不好”
第二类人曰:“Stream 会导致性能的下降,不如不用”
那么接下来,我就从这两种论点入手,和你分析一下 Stream 该啥时候用,Stream 是不是真的那么难写,Stream 是否会影响程序的性能等问题。
1. 什么时候可以使用 Stream,怎么用?
啥时候可以使用 Stream ?简而言之,一句话:当你操作的数据是数组或集合时就可以用(其实不仅仅是数组和集合,Stream 的源除了数组和集合外,还可以是文件,正则表达式模式匹配器,伪随机数生成器等,不过数组和集合是最常见的)。
Java Stream 诞生的原因就是为了解放程序员操作集合(Collection)时的生产力,你可以将它类比成一个迭代器,因为数组和集合都是可迭代的,所以当你操作的数据是数组或集合时,就应该考虑是否可以使用 Stream 来简化自己的代码。
这种意识应该是主观的,只有你经常去操作 Stream,才会渐渐得心应手。
我准备了大量的示例,让我们看一下 Stream 是如何解放你的双手,并且简化代码的:-)
示例一:
假设你有一个业务需求,要求筛选出年龄大于等于 60 的用户,然后将他们按照年龄从大到小排序并将他们的名字放在 List 中返回。
如果不使用 Stream 的操作会是这样的:
如果使用了 Stream,会是这样的:
怎么样?是不是觉得逼格瞬间提升?而且最重要的是代码的可读性增强了,不需要任何注释,你就可以看懂我在做什么。
示例二:
给定一段文本字符串以及一个字符串数组 keywords;判断文本当中是否包含关键词数组中的关键词,如果包含任意一个关键词,返回 true,否则返回 false。
譬如:
结果返回 true。
如果使用正常迭代的逻辑,我们的代码是这样的:
然后,使用 Stream 是这样的:
一行代码就完成了我们的需求,是不是现在觉得有点酷了?
示例三:
统计一个给定的字符串,所有大写字母出现的次数。
Stream 写法:
示例四:
假如你有一个业务需求,需要对传入的 List<Employee>
进行如下处理:返回一个从部门名到这个部门的所有用户的映射,且同一个部门的用户按照年龄进行从小到大排序。
例如
输入为:
输出为:
Stream 的写法如下:
示例五:
给定一个字符串的 Set 集合,要求我们将所有长度等于 1 的单词挑选出来,然后使用逗号连接。
使用 Stream 操作的代码如下:
示例六:
接下来我们看一道 LeetCode 上的问题:
问题我就不再描述了,大家可以自己找一哈~
本题使用 Stream 求解的代码如下:
到目前为止,我给了你六个示例程序,你可能发现了,这些小案例非常贴合我们平时书写的业务需求和逻辑,并且即便你写不好 Stream,也似乎可以看懂这些代码在做什么。
我当然没有那么神奇,可以一下子让你领悟通透世界(《鬼灭之刃》中的一种特殊技法)。我只是想告诉你, 既然能看懂,就可以写好。Stream 是一个货真价实的家伙,它真的可以解放你的双手,提高生产力。所以,只要你明白何时该去使用 Stream,并且刻意练习,这些操作是不在话下的。
2. Stream 会影响性能?
老实说,这个问题笔者也不知道。
不过,我的文章已经硬着头皮写到这里了,你总不能让我把前面的东西删了重写吧。
所以,我就 Google 了一些文章并把它们详细地阅读了一下。
先说结论:
Stream 确实不如迭代操作的性能高,并且 Stream 的性能与运行的机器有着很大的关系,机器性能越好,Stream 和 for-loop 之间的差异就越小,一般在 4 核+ 的计算机上,Stream 和 for-loop 的差异非常小,绝对是可以接受的
对于基本类型而言,for-loop 的性能整体上要比 Stream 好;对于对象而言,虽然 Stream 还是比 for-loop 性能差一些,但是比起和基本类型的比较来说,差距已经不是那么大了。这是因为基本类型是缓存友好的,并且循环本身是 JIT 友好的,自然性能要比 Stream 好上“很多”(实际上完全可以接受)。
对于简单的操作推荐使用循环来实现;对于复杂的操作推荐使用 Stream,一方面是因为 Stream 会让你的代码变得可读性高且简洁,另一方面是因为 Java Stream 还会不断升级,优化,我们的代码不用做任何修改就可以享受到升级带来的好处。
测试程序:
这里面,我的测试直接使用了大佬在 github 的代码。给出大佬的代码链接:
程序我就不贴了,大家可以自己去 github 上下载大佬的源代码
我的测试结果如下
int 基本类型的测试:
String 对象的测试:
大家可以看到,在我自己用的 4 核计算机上 Stream 和 for-loop 几乎是没有啥差异的。而且多核计算机可以享受 Stream 并行迭代带来的好处。
三:总结
到这里这篇文章终于结束了。如果你能看到这里,想必也是一个狠人。
本文介绍了函数式编程的概念与思想。文章中并没有涉及到任何数学理论和函数式编程的高级特性,如果想要了解这一部分的同学,可以自行查找一些资料。
Java Stream 是一个非常重要的操作,它不仅可以简化代码,让你的代码看上去清晰易懂,而且还可以培养你函数式编程的思维。
好啦,至此为止,这一篇文章我就介绍完毕了~欢迎大家关注我的公众号【憨憨二师兄】,在这里希望你可以收获更多的知识,我们下一期再见!
参考资料
版权声明: 本文为 InfoQ 作者【Dobbykim】的原创文章。
原文链接:【http://xie.infoq.cn/article/3cbaefa735bcd7f5b6e92ae22】。文章转载请联系作者。
评论