CSDN 社区的小伙伴们大家好啊,许久不见~
在这一篇文章中,我将介绍函数式编程的基本概念,如何使用函数式编程的思想编写代码以及 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 上找到,感兴趣的朋友可以自行搜索一下。
题目要求是这样的:请完成一个函数,输入一个二叉树,该函数输出它的镜像。
例如输入:
4
/ \
2 7
/ \ / \
1 3 6 9
镜像输出:
4
/ \
7 2
/ \ / \
9 6 3 1
传统的指令式编程的代码是这样的:
# Definition for a binary tree node.
# class TreeNode:
# def __init__(self, x):
# self.val = x
# self.left = None
# self.right = None
class Solution:
def mirrorTree(self, root: TreeNode) -> TreeNode:
if root is None:
return None
tmp = root.left;
root.left = self.mirrorTree(root.right)
root.right = self.mirrorTree(tmp);
return root
可以看到,指令式编程就像是我们程序员规定的一种描述计算机所需要作出一系列行为的指令集(行动清单)。我们需要详细地告诉计算机每一步需要执行什么命令,就像是这段代码一样,我们首先判断节点是否为空;然后使用一个临时变量存储左子树,并完成左子树与右子树的镜像翻转,最后左右互换。我们只要将计算机需要完成的那些步骤写出,然后交给机器运行即可,这种“面向机器编程”的思想就是指令式编程。
我们再来看一下函数式编程风格的代码:
# Definition for a binary tree node.
# class TreeNode:
# def __init__(self, x):
# self.val = x
# self.left = None
# self.right = None
class Solution:
def mirrorTree(self, root: TreeNode) -> TreeNode:
if root is None:
return None
return TreeNode(root.val, self.mirrorTree(root.right), self.mirrorTree(root.left))
你可能会觉得不解,这个代码是在哪里体现出函数式编程的呢?
先别急,让我慢慢向你解释。函数(function)这个名词最早是由莱布尼兹在 1694 年开始使用,用来描述输出值的变化同输入值变化的关系。而中文的“函数”一词,由清朝数学家李善兰翻译,其著作《代数学》书中解释为:“凡此变量中函(包含)彼变量者,则此为彼之函数”。无论哪一种解释,我们都知道了,函数这一概念描述的是一种关系映射,即:一种东西到另外一种东西之间的对应关系。
所以,我们可以使用函数式的思维去思考,获得一棵二叉树的镜像这个函数的输入是一棵“原树”,返回的结果是一棵翻转后的“新树”,而这个函数的本质就是从“原树”到“新树”的一个映射。
进而,我们可以找到这个映射的关系为“新树”的每一个节点都递归地和“原树”相反。
虽然这两段代码都使用了递归,但是思考的方式是截然不同的。前者描述的是“从原树得到新树应该怎样做”,后者描述的是从“原树”到“新树”的映射关系。
案例二:翻转字符串
题目为获得一个字符串的翻转。
指令式编程:
def reverse_string(s):
stack = list(s)
new_string = ""
while len(stack) > 0:
new_string += stack.pop()
return new_string
这段 Python 代码非常简单,我们模拟将一个字符串先从头至尾执行入栈操作,然后再从尾到头执行出栈操作,得到的就是一个翻转后的字符串了。
函数式编程:
def reverse_string(s):
if len(s) <= 1:
return s
return reverse_string(s[1:]) + s[0]
如何理解函数式编程的思想书写翻转字符串的逻辑呢?获得一个字符串的翻转这个函数的输入是“原字符串”,返回的结果是翻转后的“新字符串”,而这个函数的本质就是从“原字符串”到“新字符串”的一个映射,将“原字符串”拆分为首字符和剩余的部分,剩余的部分翻转后放在前,再将首字符放在最后就得到了“新字符串”,这就是输入与输出的映射关系。
通过以上这两个示例,我们可以看到,指令式编程和函数式编程在思想上的不同之处:指令式编程的感觉就像我们在小学求解的数学题一样,需要一步一步计算,我们关心的是解决问题的过程;而函数式编程则关心的是数据到数据的映射关系。
2. 函数式编程的三大特性
函数式编程具有三大特性:
- immutable data
- first class functions
- 递归与尾递归的“天然”支持
immutable data
函数式编程中,函数是基础单元,我们通常理解的变量在函数式编程中也被函数所代替了:在函数式编程中变量仅仅代表某个表达式,但是为了大家可以更好地理解,我仍然使用“变量”这个表达。
纯粹的函数式编程所编写的函数是没有“变量”的,或者说这些“变量”是不可变的。这就是函数式编程的第一个特性:immutable data(数据不可变)。我们可以说,对于一个函数,只要输入是确定的,输出也是可以确定的,我们称之为无副作用。如果一个函数内部“变量”的状态不确定,就会导致同样的输入可能得到不同的输出,这是不被允许的。所以,我们这里所说的“变量”就要求是不能被修改的,且只能被赋一次初始值。
first class functions
在函数式编程中,函数是第一类对象,“first class functions” 可以让你的函数像“变量”一样被使用。所谓的“函数是第一类对象”的意思是说一个函数既可以作为其他函数的输入参数值,也可以作为一个函数的输出,即:从函数中返回一个函数。
我们来看一个例子:
def inc(x):
def incx(y):
return x+y
return incx
inc2 = inc(2)
inc5 = inc(5)
print(inc2(5)) # 7
print(inc5(5)) # 10
这个示例中 inc()
函数返回了另一个函数incx()
,于是,我们可以用 inc()
函数来构造各种版本的 inc
函数,譬如: inc2()
和 inc5()
。这个技术叫做函数柯里化(Currying),它的实质就是使用了函数式编程的 “first class functions” 这个特性。
递归与尾递归的“天然”支持
递归这种思想和函数式编程是很配的,有点像是下雨天,巧克力和音乐更配的那种感觉。
函数式编程本身强调的是程序的执行结果而非执行过程,递归也是一样,我们更多在乎的是递归的返回值,即:宏观语义,而不是它在计算机中是怎么被压栈,怎么被嵌套调用的。
经典的递归程序案例是实现阶乘函数,这里我使用的是 JS 语言:
// 正常的递归
const fact = (n) => {
if(n < 0)
throw 'Illegal input'
if (n === 0)
return 0
if (n === 1)
return 1
return n * fact(n - 1)
}
这段代码可以正常运行。不过,递归程序的本质就是方法的调用,在递归没有达到 basecase 时,方法栈会不停压入栈帧,直到递归调用有返回值时,方法栈的空间才会被释放。如果递归调用很深,就很容易造成性能的下降,甚至出现 StackoverflowError。
而尾递归则是一种特殊的递归,“尾递归优化技术”可以避免上述出现的问题,使其不再发生栈溢出的情况。
什么是尾递归?如果一个函数中,所有递归形式的调用都出现在函数的末尾,我们称这个递归函数就是尾递归的。
上面的求解阶乘的代码就不是尾递归的,因为我们在fact(n - 1)
调用之后,还需要一步计算过程。
而尾递归实现阶乘函数如下:
// 尾递归
const fact = (n,total = 1) => {
if(n < 0)
throw 'Illegal input'
if (n