为什么要学习函数式编程
函数式编程是编程范式中的一种,是一种典型的编程思想和方法。其他的编程范式还包括面向对象编程,逻辑编程等。
许多人会有这样的疑惑:为什么要学习编程范式?为什么要多学习一种编程范式?
答案是
为了更好的模块化
编程范式的意义在于它提供了模块化代码的各种思想和方法。函数式编程亦然。
模块化对工程的意义不言而喻,它是工程师的追求和骄傲。
- 模块化使得开发更快、维护更容易
- 模块可以重用
- 模块化便于单元测试和debug
所谓人如其名,正如面向对象编程是以对象为单位来构建模块一样,如果以一句话介绍函数式编程,我会说:
函数式编程是以函数为核心来组织模块的一套编程方法。
文章结构
本文首先会介绍函数式编程的两点基本主张
- 函数是第一等公民
- 纯函数
这两点是函数式编程的基础,他带来了更高层次的模块化代码手段,是单元测试工程师的梦想天堂。
在以上基本主张之上,函数式编程带来了诸多酷炫的技术:
- 利用Memorization提升性能
- 利用延迟求值写出更好的模块化代码
- 使用currying技术进行函数封装
好,旅途正式开启。
函数是第一等公民
既然函数式编程的基本理念是以函数为核心来组织代码,很自然的,它首先将函数的地位提高,视其为“第一等公民” (first class)。
所谓“第一等公民”,是指函数和其他数据类型拥有平等的地位,可以赋值给变量,也可以作为参数传入另一个函数,或者作为别的函数的返回值。
当编程语言将函数视作“第一等公民”,那么相当于它支持了高阶函数,因为高阶函数就是至少满足下列一个条件的函数
- 接受一个或多个函数作为输入
- 输出一个函数
为什么将函数视作“第一等公民”有利于写出模块化的代码?不妨来看这样一个例子
有数组numberList = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],编写程序完成以下目标:
- 1.1 将numberList中的每个元素加1得到一个新的数组
- 1.2 将numberList中的每个元素乘2得到一个新的数组
- 1.3 将numberList中的每个元素模3得到一个新的数组
函数不是“第一等公民”情况下的代码:
# 1.1 将numberList中的每个元素加1得到一个新的数组
newList = []
for num in numberList:
newList.append(num + 1)
# 1.2 将numberList中的每个元素乘2得到一个新的数组
newList = []
for num in numberList:
newList.append(num * 2)
# 1.3 将numberList中的每个元素模3得到一个新的数组
newList = []
for num in numberList:
newList.append(num % 3)
有没有发现每段代码除了加1,乘2,模3的部分不一样之外,其他的代码都是一样的,
也就是说这三段代码只有原数组到新数组的映射函数是不同的(分别是加1,乘2,模3)。如果这个映射函数能够以参数的方式传递,那么就可以复用上面的大部分代码了。我们将可复用的代码抽取出来编写成高阶函数map
,如下
# 高阶函数`map`
# 该函数接受一个函数和一个数组作为输入,函数体中将这个函数作用于数组的每个元素然后作为返回值返回
def map(mappingFuction, numberList):
newList = []
for num in numberList:
newList.append(mappingFuction(num))
事实上几乎所有函数式编程语言都提供了这样的map函数,于是完成上面的作业事实上只需3行代码,如下:
(PS:lambda关键字用于定义一个匿名函数(anonymous function),x表示输入,冒号后是函数体同时也是返回值)
# 1.1 将numberList中的每个元素加1得到一个新的数组
map(lambda x: x + 1, numberList)
# 1.2 将numberList中的每个元素乘2得到一个新的数组
map(lambda x: x * 2, numberList)
# 1.3 将numberList中的每个元素模3得到一个新的数组
map(lambda x: x % 3, numberList)
除了map函数之外,一般函数式编程语言还会配套提供一些非常通用的高阶函数,使得写出的代码就像是对原问题的一句描述,简练又易读——有时人们也称这样的编程风格为声明式编程 (declarative_programming)。而前一种代码看起来是一步一步的求解步骤,这种编程风格被称为指令式编程 (imperative_programming)。
纯函数
除了将函数视作“一等公民”,函数式编程语言还主张甚至强制将函数写成纯函数 (pure function)。
纯函数是指同时满足下面两个条件的函数:
- 函数的结果只依赖于输入的参数且与外部系统状态无关——只要输入相同,返回值总是不变的。
- 除了返回值外,不修改程序的外部状态(比如全局变量、入参)。——满足这个条件也被称作“没有副作用 (side effect)”
由纯函数的两点条件可以看出,纯函数是相对独立的程序构件。因为函数的结果只依赖于输入的参数且与外部系统状态无关,使得单元测试和debug变得异常容易,而这也正是模块化的优点之一。
除此之外,可以并发执行是纯函数的另一优点,比如如下代码
t1 = pureFunction1(arg1)
t2 = pureFunction2(arg2)
result = concatFuction(t1, t2)
由于纯函数pureFunction1
和pureFunction2
只与入参相关而不依赖其他的外部系统状态,前两句函数调用的执行顺序与程序结果是无关的,完全可以并发执行。
当人们讨论函数式编程的时候,常常会提到一个词——引用透明(Referential transparency)。其实引用透明的概念与纯函数很接近:
如果一个表达式,对于相同的输入,总是有相同的结果并且不修改程序其他部分的状态,那么这个表达式是引用透明的。
由前面纯函数的定义可以看到,由于函数调用也是表达式的一种,因此任何纯函数的调用都满足引用透明。
作为一等公民的纯函数还带来了什么
函数式编程语言中一等公民的函数地位,以及纯函数的强制要求,可以带来诸多好处。
(1)可以利用Memoization技术提升性能。
满足引用透明的表达式(包括任意纯函数调用)满足这样一个特点,就是任意两次调用只要输入相同,其结果总是不变的。于是可以将第一次的计算结果缓存起来,遇到下一次执行时直接替换,依然能保证程序的正确性。这种优化方法称为Memoization。
(2)延迟求值 ( Lazy Evaluation )
延迟求值是指表达式不在它被绑定到变量时就立即求值,而是在该值被用到的时候才计算求值。
很显然,延迟求值的正确性需要纯函数的性质来保证——即在输入参数相同的情况下,无论什么时候被执行,结果总是不变的。
延迟求值有利于程序性能的提升。
比如下面这段代码,trace函数在debugFlag等于True时会将debug信息打印到标准输出。
def trace(debugFlag, debugStr):
if debug == True:
print debugStr
在实际使用中debug信息可能会由几部分拼接而成,如下:
trace(debugFlag, 'str1' + 'str2' + 'str3')
如果是没有延迟求值的语言,无论debugFlag参数等于True还是Flase, 'str1' + 'str2' + 'str3'
这段代码都是会被执行的。
而如果是采用延迟求值策略,只要当debug参数等于True且真正执行到print语句时,字符串拼接代码才会被执行。也就是说真正被用到时才执行。
延迟求值更大的好处仍是利于模块化,而且是超酷的模块化。比如思考求解下面这组问题。
- 1.1 输出斐波那契数列的第10个到第20个数
- 1.2 输出斐波那契数列中前十个偶数
- 1.3 输出斐波那契数列数列的前五个能被3整除的数
如果不考虑具体的语言和实现,可以将问题拆解成几个函数。一个函数负责生成斐波那契数列,一个函数负责筛选数列中的偶数,再写个函数挑出任意数列中能被3整除的数。
将第一个函数的输出作为后面两个函数的输入,问题就得到解决了。
但问题是斐波那契数列是一个无穷数列,一般的语言无法输出一个无穷的数据结构。不过这对于支持延迟求值的语言来说不成什么问题,因为每个值只有在真正被用到的时候才会被计算出来,因此完全可以像这样定义一组无穷的斐波那契数列
fiboSequence = createFibonacci()
然后完成上面三道题只需这样
- 1.1 输出斐波那契数列的第10个到第20个数
print fiboSequence[10 : 20] #数列中第20个之后的数不会被计算
- 1.2 输出斐波那契数列中前十个偶数
evenFiboSequence = pickEven(fiboSequence) # 函数pickEven从斐波那契数列中挑出所有的偶数,此时并不会真正计算
print evenFiboSequence[0 : 10] #直到输出时才会把用到的值计算出来
- 1.3 输出斐波那契数列数列的前五个能被3整除的数
newList = pick3(fiboSequence) #函数pick3从斐波那契数列中挑出所有能被3整除的数,此时并不会真正计算
print newLIst[0 : 5] #直到输出时才会把用到的值计算出来
(3)可以使用 currying 技术进行函数封装
curryiny 将接受多个参数的函数变换成接受其中部分参数,并且返回接受余下参数的新函数。(维基百科中的定义是接受一个单一参数的新函数,然而现实中currying技术的涵义被延伸了。)
比如幂函数pow(x, y)
,它接受两个参数——x和y,计算x^y。使用currying技术,可以将y固定为2转化为只接受单一参数x的平方函数,或者将y固定为3转化为立方函数。代码如下:
#平方函数
def square (int x):
return pow(x, 2)
#立方函数
def cube (int x):
return pow(x, 3)
熟悉设计模式的朋友已经感觉到,currying完成的事情就是函数(接口)封装,它将一个已有的函数(接口)做封装,得到一个新的函数(接口),这与适配器模式(Adapter pattern)的思想是一致的。但由于函数式编程语言更高的抽象层次,使得许多使用者甚至感觉不到自己在使用函数封装,它的语法太直观自然了。比如使用python语言functools
中提供的partial
函数,currying只需一行代码,如下
#将pow函数的x参数固定为2,返回平方函数
square = partial.partial(pow, x=2)
#将pow函数的x参数固定为3,返回立方函数
cube = partial.partial(pow, x=3)
总结
最后用两句话来归纳全文:
函数式编程通过
1)支持高阶函数
2)倡导纯函数
的设计,使得它在模块化设计、单元测试、并行化方面具有独特的魅力。
在这样的设计之下
1)利用Memorization提升性能
2)利用延迟求值模块化代码
3)使用currying技术进行函数封装
这些炫酷的技术成为现实。
我爱函数式编程。
参考资料
Why Functional Programming Matters —— John Hughes
函数式编程 —— 陈浩
函数式编程初探 —— 阮一峰
Functional Programming For The Rest of Us —— Slava Akhmechet
傻瓜函数式编程