函数式编程
函数是Python内建支持的一种封装,我们通过把大段代码拆成函数,通过一层一层的函数调用,就可以把复杂任务分解成简单的任务,这种分解可以称之为面向过程的程序设计。函数就是面向过程的程序设计的基本单元。
函数式编程(请注意多了一个“式”字)——Functional Programming,虽然也可以归结到面向过程的程序设计,但其思想更接近数学计算。 我们首先要搞明白计算机(Computer)和计算(Compute)的概念。
在计算机的层次上,CPU执行的是加减乘除的指令代码,以及各种条件判断和跳转指令,所以汇编语言是最贴近计算机的语言。
计算则是指数学意义上的计算,越是抽象的计算,离计算机硬件越远。
对应到编程语言,就是越低级的语言,越贴近计算机,抽象程度低,执行效率高,比如C语言;越高级的语言,越贴近计算,抽象程度高,执行效率低,比如Lisp语言。
归纳一下:
低级语言 | 高级语言 | |
---|---|---|
特点 | 贴近计算机 | 贴近计算(数学意义上) |
抽象程度 | 低 | 高 |
执行效率 | 高 | 低 |
例子 | 汇编和C | Lisp |
函数式编程就是一种抽象程度很高的编程范式,纯粹的函数式编程语言编写的函数没有变量,因此,任意一个函数,只要输入是确定的,输出就是确定的,这种纯函数我们称之为没有副作用。而允许使用变量的程序设计语言,由于函数内部的变量状态不确定,同样的输入,可能得到不同的输出,因此,这种函数是有副作用的。
函数式编程的一个特点就是,允许把函数本身作为参数传入另一个函数,还允许返回一个函数!
Python仅对函数式编程提供部分支持。由于Python允许使用变量,因此,Python不是纯函数式编程语言。
目录
函数式编程的三大特性
immutable data
变量不可变,或者说没有变量,只有常量。 函数式编程输入确定时输出就是确定的,函数内部的变量和函数外部的没有关系,不会受到外部操作的影响。first class functions
第一类函数(也称高阶函数),意思是函数可以向变量一样用,可以像变量一样创建、修改、传递和返回。 这就允许我们把大段代码拆成函数一层层地调用,这种面向过程的写法相比循环更加直观。尾递归优化
之前一章的递归函数中已经提及过了,就是递归时返回函数本身而非表达式。 可惜Python中没有这个特性。
函数式编程的几个技术
map & reduce
函数式编程最常见的技术就是对一个集合做Map和Reduce操作。这比起传统的面向过程的写法来说,在代码上要更容易阅读(不需要使用一堆for、while循环来倒腾数据,而是使用更抽象的Map函数和Reduce函数)。pipeline
这个技术的意思是把函数实例成一个一个的action,然后把一组action放到一个数组或是列表中组成一个action list,然后把数据传给这个action list,数据就像通过一个pipeline一样顺序地被各个函数所操作,最终得到我们想要的结果。recursing
递归最大的好处就简化代码,它可以把一个复杂的问题用很简单的代码描述出来。注意:递归的精髓是描述问题,而这正是函数式编程的精髓。currying
把一个函数的多个参数分解成多个函数, 然后把函数多层封装起来,每层函数都返回一个函数去接收下一个参数这样,可以简化函数的多个参数(减少函数的参数数目)。higher order function
高阶函数:所谓高阶函数就是函数当参数,把传入的函数做一个封装,然后返回这个封装函数。现象上就是函数传进传出。
对currying进行一点补充,举个例子:
1 | def pow(i, j): |
这里就是把原本平方函数square
的参数j分解了,它返回幂函数pow
函数,把幂次封装在里面,从而减少了求平方时所需用到的参数。
关于函数式编程的一些概念理解可以看傻瓜函数式编程或者英文原版的Functional Programming For The Rest of Us。
函数式编程的几个好处
parallelization 并行
在并行环境下,各个线程之间不需要同步或互斥(变量都是内部的,不需要共享)。lazy evaluation 惰性求值
表达式不在它被绑定到变量之后就立即求值,而是在该值被取用的时候求值。determinism 确定性
输入是确定的,输出就是确定的。
简单举例
以往面向过程式的编程需要引入额外的逻辑变量以及使用循环:
1 | upname =['HAO', 'CHEN', 'COOLSHELL'] |
而函数式编程则非常简洁易懂:
1 | def toUpper(item): |
再看一个计算一个列表中所有正数的平均数的例子:
1 | num =[2, -5, 9, 7, -2, 5, 3, 1, 0, -3, 8] |
如果采用函数式编程:
1 | positive_num = filter(lambda x: x>0, num) |
可以看到函数式编程减少了变量的使用,也就减少了出Bug的可能,维护更加方便。可读性更高,代码更简洁。
更多的例子和解析详见函数式编程。
高阶函数
前面已经提到了函数式编程中的高阶函数特性,这一节将针对Python中的使用方式进行更详细的描述。
变量可以指向函数
1 | abs |
这个例子表明在Python中变量是可以指向函数的,并且这样赋值的变量能够作为函数的别名使用。
函数名也是变量
1 | abs = 10 |
这里把abs函数赋值为10,这样赋值以后abs就变成一个整形变量,指向int型对象10而不指向原本的函数了。所以无法再作为函数使用。
想恢复abs函数要重启Python交互环境。 abs函数定义在 __builtin__
模块中,要让修改abs变量的指向在其它模块也生效,用 __builtin__.abs = 10
就可以了。 当然实际写代码绝对不应该这样做..
传入函数
函数能够作为参数传递,接收这样的参数的函数就称为高阶函数。 简单举例:
1 | def add(x, y, f): |
这里abs函数可以作为一个参数传入我们编写的add函数中,add函数就是一个高阶函数。
map/reduce
map()函数和reduce()函数是Python的两个内建函数(BIF)。
map函数
map()函数接收两个参数,一个是函数,一个是Iterable对象,map将传入的函数依次作用到序列的每个元素,并把结果作为Iterator对象(惰性序列,可以用list转换为列表输出)返回。例如:
1 | def f(x): |
这里直接使用list()函数将迭代器对象转换为一个列表。
写循环也能达到同样效果,但是显然没有map()函数直观。 map()函数作为高阶函数,大大简化了代码,更易理解。
1 | list(map(str, [1, 2, 3, 4, 5, 6, 7, 8, 9])) |
将一个整数列表转换为字符列表仅仅需要一行代码。
reduce函数
reduce接收两个参数,一个是函数(假设该函数称为f),一个是Iterable对象(假设是l)。函数f必须接收两个参数,reduce函数每次会把上一次函数f返回的值和l的下一个元素传入到f中,直到l中所有元素都参与过运算时返回函数f最后返回的值(第一次传入时传入l的头两个元素)。
1 | from functools import reduce |
这里举了一个最简单的序列求和作例子(当然实际上我们直接用sum()函数更方便,这里只是为了举例子)。 这里reduce函数每次将add作用于序列的前两个元素,并把结果返回序列的头部,直到序列只剩下1个元素就返回结果(这样理解可能更直观一些)。
1 | from functools import reduce |
可以整理一下,作为一个整体的str2int函数:
1 | def str2int(s): |
使用lambda匿名函数还可以进一步简化:
1 | def char2num(s): |
练习
1.利用map()函数,把不规范的英文名字变为首字母大写其他字母小写的规范名字。
Hint:
- 字符串支持切片操作,并且可以用加号做字符串拼接。
- 转换大写用upper函数,转换小写用lower函数。
1 | def normalize(name): |
2.编写一个prod()函数,可以接受一个list并利用reduce()求积。
Hint:
- 用匿名函数做两数相乘
- 用reduce函数做归约,得到列表元素连乘之积。
1 | from functools import reduce |
3.利用map和reduce编写一个str2float函数,把字符串'123.456'转换成浮点数123.45。
Hint:
- 这题的思路是找到小数点的位置i(从个位开始数i个数字之后),然后让转换出的整数除以10的i次方。
- 另外一种思路是在转换时遇到小数点后,改变转换的方式由
num*10+当前数字
变为num+当前数字/point
。 point初始为1,每次加入新数字前除以10。
1 | from functools import reduce |
filter
filter()函数同样是内建函数,用于过滤序列。 filter()接收一个函数和一个Iterable对象。 和map()不同的时,filter()把传入的函数依次作用于每个元素,然后根据函数返回值是True还是False决定保留还是丢弃该元素。
简单的奇数筛选例子:
1 | def is_odd(n): |
筛掉列表的空字符串:
1 | def not_empty(s): |
其中,strip
函数用于删除字符串中特定字符,格式为:s.strip(rm)
,删除s字符串中开头、结尾处的包含在rm删除序列中的字符。 rm为空时默认删除空白符(包括'', ', ', ' ')。
注意到 filter()
函数返回的是一个 Iterator对象,也就是一个惰性序列,所以要强迫 filter()
完成计算结果,需要用 list()
函数获得所有结果并返回list。
filter函数最重要的一点就是正确地定义一个筛选函数(即传入filter作为参数的那个函数)。
练习
1.用filter筛选素数
这里使用埃氏筛法。
首先,列出从2开始的所有自然数,构造一个序列:
2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, ...
取序列的第一个数2,它一定是素数,然后用2把序列的2的倍数筛掉:
3, 5, 7, 9, 11, 13, 15, 17, 19, ...
取新序列的第一个数3,它一定是素数,然后用3把序列的3的倍数筛掉:
5, 7, 11, 13, 17, 19, ...
以此类推...
首先构造一个生成器,输出3开始的奇数序列:
1 | def _odd_iter(): |
然后定义一个筛选函数,传入n,判断x能否除尽n:
1 | def _not_divisible(n): |
这里x是匿名函数的参数,由外部提供。
然后就是定义返回素数的生成器了。
首先输出素数2,然后初始化奇数队列,每次输出队首(必然是素数,因为前一轮的过滤已经排除了比当前队首小且非素数的数)。
构造新的队列,每次用当前序列最小的数作为除数,检验后面的数是否素数。
定义如下:
1 | def primes(): |
这里因为it是一个迭代器,每次使用next就得到队列的下一个元素,实际上就类似队列的出列操作,挤掉队首,不用担心重复。
然后这里filter的原理,就是把当前it队列的每个数都放进_not_divisible(n)中检测一下,注意不是作为参数n传入而是作为匿名函数的参数x传入!
_not_divisible(n)
实际是作为一个整体来看的,它返回一个自带参数n的函数(也即那个匿名函数),然后filter再把列表每一个元素一一传返回的匿名函数中。一定要搞清楚这一点。
- 最后,因为primes产生的也是一个无限长的惰性序列,我们一般不需要求那么多,简单写个循环用作退出即可:
1 | # 打印1000以内的素数: |
2.用filter筛选回文数
Hint:
- str可以把整数转换为字符串
- [::-1]可以得到逆序的列表。
1 | def is_palindrome(n): |
sorted
Python内置的 sorted()
函数就可以对list进行排序:
1 | sorted([36, 5, -12, 9, -21]) |
并且 sorted()
作为高阶函数还允许接受一个key函数用于自定义排序,例如:
1 | sorted([36, 5, -12, 9, -21], key=abs) |
key指定的函数将作用于list的每一个元素上,并根据key函数返回(映射)的结果进行排序,最后对应回列表中的元素进行输出。
再看一个字符串排序例子:
1 | sorted(['bob', 'about', 'Zoo', 'Credit']) |
默认情况下是按ASCII码排序的,但我们往往希望按照字典序来排,思路就是把字符串变为全小写/全大写再排:
1 | sorted(['bob', 'about', 'Zoo', 'Credit'], key=str.lower) |
默认排序是由小到大,要反相排序只需把reverse参数设为True。 温习前面的知识,这里reverse参数是一个命名关键字参数。
1 | sorted(['bob', 'about', 'Zoo', 'Credit'], key=str.lower, reverse=True) |
使用好sorted函数的关键就是定义好一个映射函数。
练习
给出成绩表,分别按姓名和成绩进行排序。
1 | 'Bob', 75), ('Adam', 92), ('Bart', 66), ('Lisa', 88)] L = [( |
返回函数
函数作为返回值
高阶函数除了可以接受函数作为参数外,还可以把函数作为结果值返回。
比方说我们想实现一个对可变参数求和的函数,可以这样写:
1 | def calc_sum(*args): |
调用时可以传入任意个数字,并得到这些数字的和。而如果我们不需要立即求和,而是后面再根据需要来进行求和,可以写为返回求和函数的形式:
1 | def lazy_sum(*args): |
在调用 lazy_sum
时,返回一个sum函数,但sum函数内部的求和代码没有执行:
1 | 1, 3, 5, 7, 9) f = lazy_sum( |
当我们再调用返回的这个sum函数时,就能得到和值了:
1 | f() |
注意!每一次调用 lazy_sum
返回的函数都是不同的!即使传入相同的参数,返回函数也是不同的!举个例子:
1 | 1, 3, 5, 7, 9) f1 = lazy_sum( |
f1和f2是不同的两个函数,虽然调用它们得到同样的结果,但它们是互不影响的。
闭包
在Python中,从表现形式上来讲,闭包可以定义为:如果在一个内部函数里,对外部作用域(非全局作用域)的变量进行了引用,那么这个内部函数就被认为是闭包(closure)。 如上面例子中的f就是一个闭包,它调用了变量i,变量i属于外面的循环体而不是全局变量。
看一个例子:
1 | def count(): |
三个返回函数的调用结果是:
1 | f1() |
解析一下,这里count函数是一个返回三个函数的函数,里面使用了一个循环体来产生要返回的三个函数。从i为1开始到i等于3,每次产生一个函数f,返回i的平方值。如果按照平常的思路来看,可能会觉得返回的三个函数f1、f2、f3应该分别输出1、4、9。
但实际上并不是这样的,这是因为返回一个函数时,函数内部的代码是没有执行的! 只有在调用这个返回的函数时才会执行!
调用count函数时,实际上返回了3个新的函数,循环变量i的值也变为3。在调用这3个返回的函数时,它们的代码才会执行,这时引用的i的值就都是3。
如果一定要在闭包中用到外部的循环变量,要怎么办呢? 我们先定义一个函数,用它的参数绑定循环变量,然后再在它的里面定义要返回的函数。 这样无论后面循环变量怎么变,已经绑定到参数的值是不会变的,就能得到我们期望的结果了。也即把上面的例子改写为:
1 | def count(): |
调用结果:
1 | f1, f2, f3 = count() |
这里闭包g用到的变量j是外部作用域f的,并且j作为参数绑定在f中不再改变,不同于外部作用域count函数中的变量i。 所以执行count返回的3个函数,每个的结果都不同。
总结
返回闭包时,不要在闭包的代码中引用外部作用域的循环变量或者外部作用域中会变化的变量。
不应该在闭包中修改外部作用域的局部变量。
匿名函数
当我们在使用函数作为参数时,有些时候,不需要预先显式地定义函数,直接传入一个匿名函数更方便。
举个简单例子,计算 f(x)=x²
时,不需要显示定义f(x),使用匿名函数可以直接一行解决,这样写非常简洁。
1 | list(map(lambda x: x * x, [1, 2, 3, 4, 5, 6, 7, 8, 9])) |
关键字lambda表示要定义一个匿名函数,冒号前面的x表示函数的参数。
匿名函数有个限制,就是只能包含一个表达式,不用写return,返回值就是该表达式的结果。
所以上面这个匿名函数就是:x作为参数传入,返回 x*x
的结果。
用匿名函数有个好处,因为函数没有名字,不必担心函数名冲突。此外,匿名函数也是一个函数对象,也可以把匿名函数赋值给一个变量,再利用变量来调用该函数:
1 | lambda x: x * x f = |
并且匿名函数作为一个函数对象,也能被函数返回(像上一节那样):
1 | def build(x, y): |
这里返回的函数是一个匿名函数,它没有参数,里面调用的变量x和变量y是绑定在外部作用域build中的参数。所以调用build时会根据使用的参数返回这个匿名函数,调用返回的这个函数时使用的变量x和变量y就是调用build时用的参数。
装饰器
有时候我们希望为函数增加一些额外的功能,比如在调用函数的前后自动打印某些信息,但又不希望修改定义函数的代码。这时就可以使用装饰器(Decorator)了,它是python中对装饰器模式的实现,可以在代码运行期间动态增加功能(装饰器的代码和要装饰的函数的代码还是要写好,这里只是说对要装饰的函数使用装饰器后,在运行时要装饰的函数会被重新包装一遍,使得它具有了装饰器中定义的功能)。
比方说我们要实现一个打印函数名的额外功能,它是通过调用函数对象的 __name__
属性获得的:
1 | def now(): |
如果我们不想在每个函数中都重复写实现这个功能的代码,可以把它写为装饰器的形式,然后为每个函数添加这个装饰器。装饰器本身也是一个函数,这个例子可以写为:
1 | def log(func): |
它像正常函数一样定义(没有特别的语法),接收一个函数作为参数,并且返回一个函数 wrapper
(Python中函数也是对象,既可以作为参数,也能被返回)。回顾前一节的内容,可以看出 wrapper
函数是一个闭包,它本身接收可变参数 *args
和关键字参数 **kw
,并且引用了外部作用域中绑定在 log
函数参数中的 func
变量。
使用装饰器时,借助Python的@语法,把装饰器放在函数定义的上一行即可:
1 |
|
运行时:
1 | now() |
原理是这样的,把 @log
放在 now()
函数的定义前,运行代码的时候,实际上是在函数定义后执行了:
1 | now = log(now) |
也即执行了 log
函数,并把 now
这个变量名赋值为 log(now)
返回的 wrapper(*args, **kw)
函数(也即 now
引用的函数对象变了)。此时查看 now
变量指向的函数对象的名字,会发现已经变成了 wrapper
:
1 | now.__name__ |
此时我们调用这个新的 now()
函数时,实际上执行的就是 wrapper
函数中的代码,打印出函数信息,然后再调用原来的 now
函数。要注意 wrapper
调用的 now
函数和我们调用的 now
函数是不同的两个函数,我们调用的 now
函数已经变成了 wrapper
函数,而 wrapper
函数调用的则是绑定在 log
函数参数中的原本的 now
函数。
简单归纳一下:
- 装饰器也是一个函数
- 装饰器实际上是把传入的函数进行一层包装,返回一个新函数
- 要为函数添加装饰器时,在函数定义前使用
@装饰器名
即可
装饰器的原理部分如果还有不清楚的,不妨看看知乎上李冬的答案,讲得比较浅显和清楚。
带参数的decorator
前面提到装饰器可以用于为函数提供增强功能而无须修改函数本身的代码,在装饰器函数中,闭包 wrapper
接收的参数就是函数的参数。但是,如果我们希望在使用装饰器时可以更灵活一些,为不同的函数添加功能类似但又略有不同的装饰器呢?这时我们可以使用带参数的装饰器来实现(装饰器本身也是函数,是可以传入参数的)。
比方说要实现一个自定义打印文本的功能:
1 | def log(text): |
注意到这里在 wrapper
和 log
之间又套了一层函数,现在变为了 log
接收参数 text
并返回一个装饰器 decorator
。这个 decorator
接收一个函数对象,输出文本 text
和函数对象的名字,理解起来其实不难。
使用这个装饰器:
1 |
|
执行结果如下:
1 | now() |
事实上,把 @log
放在 now()
函数的定义前,运行代码时实际上在函数定义后执行了:
1 | 'execute')(now) now = log( |
也即是先调用 log
函数,传入参数 log('execute')
,这时返回了 decorator
这个装饰器,然后传入了 now
函数,最后返回包装好的 now
函数(也即 wrapper
函数)。
属性复制
前面已经提到使用 @语法
之后,now变量指向的函数名字等属性都改变了,变成了 wrapper
函数的,实际上,我们希望变量 now
的属性依然是原本 now()
函数的属性,这时就需要进行属性复制。
我们不需要编写类似 wrapper.__name__ = func.__name__
这样的代码来逐个把原函数的属性复制给 wrapper
,Python内置的 functools.wraps
装饰器可以满足我们的需求。方法如下:
1 | import functools |
和原来定义装饰器的代码对比,唯一修改的就是加上了 @functools.wraps(func)
这一句。 当然,还要注意先导入functools模块。
练习
习题1
编写一个decorator,能在函数调用的前后分别打印出
'begin call'
和'end call'
的日志。
解析:
这题很简单,在 wrapper
调用原函数之前,各编写一条打印语句就可以了。
代码:
1 | def decorator(func): |
执行结果:
1 | # now是一个函数 now |
习题2
写出一个@log的decorator,使它既支持:
1 |
|
又支持:
1 |
|
解析:
思路很简单,我们知道使用不带参数的装饰器时,传入装饰器函数(即这里的 log
)的参数就是要装饰的函数(比方说 now
函数);而带参数的装饰器接收的参数则不是要装饰的函数而是别的(比方说一个字符串)。所以呀,我们可以依然使用带参数的装饰器作为原型,但在里面加入对参数类型的判断,如果接收到字符串参数则表示这次调用的是有参数的装饰器,否则就是调用不带参数的装饰器。
代码:
1 | import functools |
执行结果:
1 | now1() |
偏函数
Python的functools模块提供了很多有用的功能,其中一个就是偏函数(Partial function)。
functools.partial(f, *args, **kw)
的作用就是创建一个偏函数,把一个函数的某些参数给固定住(也就是设置默认值),返回一个新的函数,调用这个新函数会更简单。
举个例子,字符串转整型数的函数int,可以使用关键字参数base,指定字符串的进制是多少,然后转换为int的时候会按照base进行进制转换,把字符串转换成十进制整数。 如:
1 | int('1000000', base=2) |
如果有大量的二进制字符串要转换,每次都写base=2很麻烦,我们就会希望定义一个新函数,把base参数固定为2,无须每次指定:
1 | def int2(x, base=2): |
实际上我们不需要自己定义,使用 functools.partial
就可以轻松创建偏函数:
1 | import functools |
运行int('1000000')实际相当于:
1 | kw = { 'base': 2 } |
Notice:
这里创建偏函数只是设定了默认值为2,调用偏函数时依然可以把参数设置为其他值。
1 | '1000000', base=10) int2( |
functools.partial
不但可以接收关键字参数,还可以接收可变参数 *args
,如:
1 | max, 10) max2 = functools.partial( |
相当于max函数每次接收到若干数字时,都默认再放入一个整数10,然后找其中的最大值。