预计阅读本页时间:-
本章我们将介绍一种新的编程范式:函数式编程(Functional Programming)。函数式编程历史悠久,但其使用一直限于学术圈。随着近年来并行运算的发展,人们发现古老的函数式编程天生地适用于并行运算。函数式编程变得越来越受欢迎。Python虽然不是纯粹的函数式编程,但包含了不少函数式编程的语法。了解函数式编程的概念,有助于写出更加优秀的代码。
7.1 又见函数
1.Python中的函数式
在前面,我们已经见到了面向过程和面向对象两种编程范式。面向过程编程利用选择和循环结构,以及函数、模块等,对指令进行封装。面向对象实现了另一种形式的封装。包含有数据的对象的一系列方法。这些方法能造成对象的状态改变。作为第三种编程范式,函数式编程的本质也在于封装。
正如其名字,函数式编程以函数为中心进行代码封装。在面向过程的编程中,我们已经见识过函数。它有参数和返回值,分别起到输入和输出数据的功能。更进一步,我们也已经知道Python中的函数实际上是一些特殊的对象。这一条已经符合了函数式编程的一个重要方面:函数是第一级对象,能像普通对象一样使用。我将在后面章节中探索它的重要意义。
广告:个人专属 VPN,独立 IP,无限流量,多机房切换,还可以屏蔽广告和恶意软件,每月最低仅 5 美元
函数式编程强调了函数的纯粹性(purity)。一个纯函数是没有副作用的(Side Effect),即这个函数的运行不会影响其他函数。纯函数像一个沙盒,把函数带来的效果控制在内部,从而不影响程序的其他部分。我们曾在函数内部改变外部列表的元素,其他调用该列表的函数也会看到该函数的作用效果。这样就造成了副作用。我们知道,造成这样效果的原因是我们使用了可变更的对象,如列表和词典。因此,为了达到纯函数的标准,函数式编程要求其变量都是不可变更的。
Python并非完全的函数式编程语言。在Python中,存在着可变更的对象,也能写出非纯函数。但如果我们借鉴函数式编程,尽量在编程中避免副作用,就会有许多好处。由于纯函数相互独立,我们不用担心函数调用对其他函数的影响,所以使用起来更加简单。另外一点,纯函数也方便进行并行化运算。在并行化编程时,我们经常要担心不同进程之间相互干扰的问题。当多个进程同时修改一个变量时,进程的先后顺序会影响最终结果。比如下面两个函数:
from threading import Thread
x = 5
def double():
global x
x = x * 2
def plus_ten():
global x
x = x + 10
thread1 = Thread(target=double)
thread2 = Thread(target=plus_ten)
thread1.start()
thread2.start()
thread1.join()
thread2.join()
print(x)
上面的两个函数中使用了关键字global。global说明了x是一个全局变量。函数对全局变量的修改能被其他函数看到,因此有副作用。如果两个进程并行地执行两个函数,函数的执行顺序不确定,则结果可能是double()中的x = x*2先执行,最终结果为20;也有可能是plus_ten()中的x = x + 10先执行,最终结果为30。这被称为竞跑条件(Race Condition),是并行编程中需要极力避免的。
函数式编程消灭了副作用,即无形中消除了竞跑条件的可能。因此,函数式编程天生地适用于并行化运算。其实函数式编程诞生得很早,早在20世纪50年代,Lisp语言就已经诞生。但函数式编程的使用范围局限于学术领域。近年来,电子元件的尺寸已经趋于物理极限。CPU频率的增长逐渐放缓。为了满足运算的需要,人们想到了把多个电脑连接起来,用并行化的方式来提高运算能力。但并行程序与之前的单线程程序有很大区别,程序员要处理竞跑条件等复杂问题。饱受折磨的程序员想起了古董级的函数式编程语言,意外地发现它十分适合于编写并行程序。于是,函数式编程重拾热度,渐渐成为程序员的必修内容。
Python并非一门函数式编程语言。在早期的Python版本中,并没有函数式编程的相关语法。后来Python中加入了lambda函数,以及map、filter、reduce等高阶函数,从而加入了函数式编程的特征。但Python并没有严格地执行语法规范,并且缺乏相关的优化,因此离完整的函数式编程尚有一段距离。Python的作者罗苏姆本人也从不认为Python是一门函数式语言。作为一名渐进式的开发者,罗苏姆非常看重程序的可读性。因此,他只保留了函数式编程中那些让Python更加简洁的语法糖。所以,Python中函数式语法特征可以作为体验的起点。但这还远远不够,你至少应该去深入了解函数式编程的思想。更好的情况是,你能学习一门更加纯粹的函数式语言,与Python中的所学互为对照。
在我的体会中,学习函数式编程能深刻地影响编程的思维方式。程序员编程时,很多时候是自下而上的:创建一个变量,给变量赋值,进行运算,得到结果……这是一种很自然的想法。程序员毕竟是在摆弄机器,因此第一步总会像第一次玩收音机一样,转转按钮、动动天线,看一下机器是什么反应。与之相对,函数式编程的思路是自上而下的。它先提出一个大问题,在最高层用一个函数来解决这个大问题。在这个函数内部,再用其他函数来解决小问题。在这样递归式的分解下,直到问题得到解决。这就好像“把大象放入冰箱”这个函数,在内部调用“打开门”、“把大象放进去”、“关上门”这三个函数。在这三个内部函数中,可以继续通过函数调用,向细节深入。这两种思维方式各有利弊,但让它们相互对比、相互碰撞,是学习编程的一大乐趣。
2.并行运算
在上一节中,我们已经涉及到并行运算。所谓的并行运算,是指多条指令同时执行。一般来说,一台单处理器的计算机同一时间内只能执行一条指令。这种每次执行一条指令的工作方式称为串行运算。

图7-1 串行运算:必须一个一个来
大规模并行运算通常是在有多个主机组成的集群(Cluster)上进行的。主机之间可以借助高速的网络设备通信。一个集群的造价不菲。然而,我们可以在单机上通过多进程或多线程的方式,模拟多主机的并行处理。即使一台单机中,也往往存在着多个运行着的程序,即所谓的进程。例如,我们在打开浏览器上网的同时,还可以流畅的听音乐。这给我们一个感觉,计算机在并行的进行上网和放音乐两个任务。事实上,单机的处理器按照“分时复用”的方式,把运算能力分配给多个进程。处理器在进程间频繁切换。因此,即使处理器同一时间只能处理一个指令,但通过在进程间的切换,也能造成多个进程齐头并进的效果。

图7-2 并行运算:可以齐头并进
从这个角度来说,集群和单机都实现了多个进程的并行运算。只不过,集群上的多进程分布在不同的主机,而单机的多进程存在于同一主机,并借着“分时复用”来实现并行。
下面是多进程编程的一个例子:
import multiprocessing def proc1(): return 999999**9999 def proc2(): return 888888**8888 p1 = multiprocessing.Process(target=proc1) p2 = multiprocessing.Process(target=proc2) p1.start() p2.start() p1.join() p2.join()
上面程序用了两个进程。进程的工作包含在函数中,分别是函数proc1()和函数proc2()。方法start()用于启动进程,而join()方法用于在主程序中等待相应进程完成。
最后,我们要区分一下多进程和多线程。一个程序运行后,就成为一个进程。进程有自己的内存空间,用来存储自身的运行状态、数据和相关代码。一个进程一般不会直接读取其他进程的内存空间。进程运行过程中,可以完成程序描述的工作。但在一个进程内部,又可以有多个称为“线程”的任务,处理器可以在多个线程之间切换,从而形成并行的多线程处理。线程看起来和进程类似,但线程之间可以共享同一个进程的内存空间。
7.2 被解放的函数
1.函数作为参数和返回值
在函数式编程中,函数是第一级对象。所谓“第一级对象”,即函数能像普通对象一样使用。因此,函数的使用变得更加自由。对于“一切皆对象”的Python来说,这是自然而然的结果。既然如此,那么函数可以像一个普通对象一样,成为其他函数的参数。比如下面的程序,函数就充当了参数:
def square_sum(a, b): return a**2 + b**2 def cubic_sum(a, b): return a**3 + b**3 def argument_demo(f, a, b): return f(a, b) print(argument_demo(square_sum, 3, 5)) # 打印34 print(argument_demo(cubic_sum, 3, 5)) # 打印152
函数argument_demo()的第一个参数f就是一个函数对象。按照位置传参,square_sum()传递给函数argument_demo(),对应参数列表中的f。f会在argument_demo()中被调用。我们可以把其他函数,如cubic_sum()作为参数传递给argument_demo()。
很多语言都能把函数作为参数使用,例如C语言。在图形化界面编程时,这样一个作为参数的函数经常起到回调(Callback)的作用。当某个事件发生时,比如界面上的一个按钮被按下,回调函数就会被调用。下面是一个GUI回调的例子:
import tkinter as tk def callback(): """ callback function for button click """ listbox.insert(tk.END, "Hello World!") if __name__ == "__main__": master = tk.Tk() button = tk.Button(master, text="OK", command=callback) button.pack() listbox = tk.Listbox(master) listbox.pack() tk.mainloop()
Python中内置了tkinter的图形化功能。在上面的程序中,回调函数将在列表栏中插入"Hello World!"。回调函数作为参数传给按钮的构造器。每当按钮被点击时,回调函数就会被调用,如图7-3所示。

图7-3 回调函数运行结果
2.函数作为返回值
既然函数是一个对象,那么它就可以成为另一个函数的返回结果。
def line_conf():
def line(x):
return 2*x+1
return line # return a function object
my_line = line_conf()
print(my_line(5)) # 打印11
上面的代码可以成功运行。line_conf()的返回结果被赋给line对象。上面的代码将打印11。
在上面的例子中,我们看到了在一个函数内部定义的函数。和函数内部的对象一样,函数对象也有存活范围,也就是函数对象的作用域。Python的缩进形式很容易让我们看到函数对象的作用域。函数对象的作用域与它的def的缩进层级相同。比如下面的代码,我们在line_conf()函数的隶属范围内定义的函数line(),就只能在line_conf()的隶属范围内调用。
def line_conf():
def line(x):
return 2*x+1
print(line(5)) #作用域内
if __name__=="__main__":
line_conf()
print(line(5)) #作用域外,报错
函数line()定义了一条直线(y = 2x + 1)。可以看到,在line_conf()中可以调用line()函数,而在作用域之外调用line()函数将会有下面的错误:
NameError: name 'line' is not defined
说明这已经超出了函数line()的作用域。Python对该函数的调用失败。
3.闭包
上面函数中,line()定义嵌套在另一个函数内部。如果函数的定义中引用了外部变量,会发生什么呢?
def line_conf():
b = 15
def line(x):
return 2*x+b
b = 5
return line #返回函数对象
if __name__ == "__main__":
my_line = line_conf()
print(my_line(5)) # 打印15
可以看到,line()定义的隶属程序块中引用了高层级的变量b。b的定义并不在line()的内部,而是一个外部对象。我们称b为line()的环境变量。尽管b位于line()定义的外部,但当line被函数line_conf()返回时,还是会带有 b的信息。
一个函数和它的环境变量合在一起,就构成了一个闭包(Closure)。上面程序中,b分别在line()定义的前后有两次不同的赋值。上面的代码将打印15,也就是说,line()参照的是值为5的b值。因此,闭包中包含的是内部函数返回时的外部对象的值。
在Python中,所谓的闭包是一个包含有环境变量取值的函数对象。环境变量取值被复制到函数对象的__closure__属性中。比如下面的代码:
def line_conf():
b = 15
def line(x):
return 2*x+b
b = 5
return line # 返回函数对象
if __name__ == "__main__":
my_line = line_conf()
print(my_line.__closure__)
print(my_line.__closure__[0].cell_contents)
可以看到,my_line()的__closure__属性中包含了一个元组。这个元组中的每个元素都是cell类型的对象。第一个cell包含的就是整数5,也就是我们返回闭包时的环境变量b的取值。
闭包可以提高代码的可复用性。我们看下面三个函数:
def line1(x): return x + 1 def line2(x): return 4*x + 1 def line3(x): return 5*x + 10 def line4(x): return -2*x – 6
如果把上面的程序改为闭包,那么代码就会简单很多:
def line_conf(a, b):
def line(x):
return a*x + b
return line
line1 = line_conf(1, 1)
line2 = line_conf(4, 5)
line3 = line_conf(5, 10)
line4 = line_conf(-2, -6)
这个例子中,函数line()与环境变量a、b构成闭包。在创建闭包的时候,我们通过line_conf()的参数a、b说明直线的参量。这样,我们就能复用同一个闭包,通过代入不同的数据来获得不同的直线函数,如y = x + 1和y = 4x + 5。闭包实际上创建了一群形式相似的函数。
除了复用代码,闭包还能起到减少函数参数的作用:
def curve_closure(a, b, c): def curve(x): return a*x**2 + b*x + c return curve curve1 = curve_closure(1, 2, 1)
函数curve()是一个二次函数。它除了自变量x外,还有a、b、c三个参数。通过curve_closure()这个闭包,我们可以预设a、b、c三个参数的值。从而起到减参的效果。
闭包的减参作用对于并行运算来说很有意义。在并行运算的环境下,我们可以让每台电脑负责一个函数,把上一台电脑的输出和下一台电脑的输入串联起来。最终,我们像流水线一样工作,从串联的电脑集群一端输入数据,从另一端输出数据。由于每台电脑只能接收一个输入,所以在串联之前,必须用闭包之类的办法把参数的个数降为1。
7.3 小女子的梳妆匣
1.装饰器
装饰器(decorator)是一种高级Python语法。装饰器可以对一个函数、方法或者类进行加工。在Python中,我们有多种方法对函数和类进行加工。装饰器从操作上入手,为函数增加额外的指令。因此,装饰器看起来就像是女孩子的梳妆匣,一番打扮之后让函数大变样。Python最初没有装饰器这一语法。装饰器在Python 2.5中才出现,最初只用于函数。在Python 2.6以及之后的Python版本中,装饰器被进一步用于类。
我们先定义两个简单的数学函数,一个用来计算平方和,一个用来计算平方差:
# 获得平方和 def square_sum(a, b): return a**2 + b**2 # get square diff # 获得平方差 def square_diff(a, b): return a**2 - b**2 if __name__ == "__main__": print(square_sum(3, 4)) # 打印25 print(square_diff(3, 4) # 打印-7
在拥有了基本的数学功能之后,我们可能想为函数增加其他的功能,比如打印输入。我们可以改写函数来实现这一点:
# 装饰:打印输入 def square_sum(a, b): print("intput:", a, b) return a**2 + b**2 def square_diff(a, b): print("input", a, b) return a**2 - b**2 if __name__ == "__main__": print(square_sum(3, 4)) print(square_diff(3, 4))
我们修改了函数的定义,为函数增加了功能。从代码中可以看到,这两个函数在功能上的拓展有很高的相似性,都是增加了print("input", a, b)这一打印功能。我们可以改用装饰器,定义功能拓展本身,再把装饰器用于两个函数:
def decorator_demo(old_function): def new_function(a, b): print("input", a, b) # 额外的打印操作 return old_function(a, b) return new_function @decorator_demo def square_sum(a, b): return a**2 + b**2 @decorator_demo def square_diff(a, b): return a**2 - b**2 if __name__ == "__main__": print(square_sum(3, 4)) print(square_diff(3, 4))
装饰器可以用def的形式定义,如上面代码中的decorator_demo()。装饰器接收一个可调用对象作为输入参数,并返回一个新的可调用对象。装饰器新建了一个函数对象,也就是上面的new_function()。在new_function()中,我们增加了打印的功能,并通过调用old_function(a, b)来保留原有函数的功能。
定义好装饰器后,我们就可以通过@语法使用了。在函数square_sum()和square_diff()定义之前调用@decorator_demo,实际上是将square_sum()或square_diff()传递给了decorator_demo(),并将decorator_demo()返回的新的函数对象赋给原来的函数名square_sum()和square_diff()。所以,当我们调用square_sum(3, 4)的时候,实际上发生的是:
square_sum = decorator_demo(square_sum) square_sum(3, 4)
我们知道,Python中的变量名和对象是分离的。变量名其实是指向一个对象的引用。从本质上,装饰器起到的作用就是名称绑定(name binding),让同一个变量名指向一个新返回的函数对象,从而达到修改函数对象的目的。只不过,我们很少彻底地更改函数对象。在使用装饰器时,我们往往会在新函数内部调用旧的函数,以便保留旧函数的功能。这也是“装饰”名称的由来。
下面看一个更有实用功能的装饰器。我们可以利用time包来测量程序运行的时间。把测量程序运行时间的功能做成一个装饰器,将这个装饰器运用于其他函数,将显示函数的实际运行时间:
import time def decorator_timer(old_function): def new_function(*arg, **dict_arg): t1 = time.time() result = old_function(*arg, **dict_arg) t2 = time.time() print("time: ", t2 - t1) return result return new_function
在new_function()中,除调用旧函数外,还前后额外调用了一次time.time()。由于time.time()返回挂钟时间,它们的差值反映了旧函数的运行时间。此外,我们通过打包参数的办法,可以在新函数和旧函数之间传递所有的参数。
装饰器可以实现代码的可复用性。我们可以用同一个装饰器修饰多个函数,以便实现相同的附加功能。比如说,在建设网站服务器时,我们能用不同函数表示对不同HTTP请求的处理。当我们在每次处理HTTP请求前,都想附加一个客户验证功能时,那么就可以定义一个统一的装饰器,作用于每一个处理函数。这样,程序能重复利用,可读性也大为提高。
2.带参装饰器
在上面的装饰器调用中,比如@decorator_demo,该装饰器默认它后面的函数是唯一的参数。装饰器的语法允许我们调用decorator时,提供其他参数,比如@decorator(a)。这样,就为装饰器的编写和使用提供了更大的灵活性。
# 带参装饰器 def pre_str(pre=""): def decorator(old_function): def new_function(a, b): print(pre + "input", a, b) return old_function(a, b) return new_function return decorator # 装饰square_sum() @pre_str("^_^") def square_sum(a, b): return a**2 + b**2 # get square diff # 装饰square_diff() @pre_str("T_T") def square_diff(a, b): return a**2 - b**2 if __name__ == "__main__": print(square_sum(3, 4)) print(square_diff(3, 4))
上面的pre_str是一个带参装饰器。它实际上是对原有装饰器的一个函数封装,并返回一个装饰器。我们可以将它理解为一个含有环境参量的闭包。当我们使用@pre_str("^_^")调用的时候,Python能够发现这一层的封装,并把参数传递到装饰器的环境中。该调用相当于:
square_sum = pre_str("^_^") (square_sum)
根据参数不同,带参装饰器会对函数进行不同的加工,进一步提高了装饰器的适用范围。还是以网站的用户验证为例子。装饰器负责验证的功能,装饰了处理HTTP请求的函数。可能有的关键HTTP请求需要管理员权限,有的只需要普通用户权限。因此,我们可以把“管理员”和“用户”作为参数,传递给验证装饰器。对于那些负责关键HTTP请求的函数,我们可以把“管理员”参数传给装饰器。对于负责普通HTTP请求的函数,我们可以把“用户”参数传给它们的装饰器。这样,同一个装饰器就可以满足不同的需求了。
3.装饰类
在上面的例子中,装饰器接收一个函数,并返回一个函数,从而起到加工函数的效果。装饰器还拓展到了类。一个装饰器可以接收一个类,并返回一个类,从而起到加工类的效果。
def decorator_class(SomeClass): class NewClass(object): def __init__(self, age): self.total_display = 0 self.wrapped = SomeClass(age) def display(self): self.total_display += 1 print("total display", self.total_display) self.wrapped.display() return NewClass @decorator_class class Bird: def __init__(self, age): self.age = age def display(self): print("My age is",self.age) if __name__ == "__main__": eagle_lord = Bird(5) for i in range(3): eagle_lord.display()
在装饰器decorator_class中,我们返回了一个新类NewClass。在新类的构造器中,我们用一个属性self.wrapped记录了原来类生成的对象,并附加了新的属性total_display,用于记录调用display()的次数。我们也同时更改了display方法。通过装饰,我们的Bird类可以显示调用display()的次数。
无论是装饰函数,还是装饰类,装饰器的核心作用都是名称绑定。虽然装饰器端出现较晚,但在各个Python项目中的使用却很广泛。即便不需要自定义装饰器,你也很有可能会在自己的项目中调用其他库中的装饰器。因此,Python程序员需要掌握这一语法。
7.4 高阶函数
1.lambda与map
上面的讲解都围绕着一个中心,函数能像一个普通对象一样应用,从而成为其他函数的参数和返回值。能接收其他函数作为参数的函数,被称为高阶函数(high-order function)。7.3节中介绍的装饰器,本质上就是高阶函数。高阶函数是函数式编程的一个重要组成部分。本节我们讲介绍最具有代表性的高阶函数:map()、filter()和reduce()。
在开始之前,首先引入一种新的定义函数的方式。我们已经见过很多用def来定义函数的例子。除了def,还可以用lambda语法来定义匿名函数,例如:
lambda_sum = lambda x,y: x + y print(lambda_sum(3,4))
通过lambda,我们创建了一个匿名的函数对象。借着赋值语句,这个匿名函数赋予给函数名lambda_sum。函数的参数为x、y,返回值为x与y的和。函数lambda_sum()的调用与正常函数一样。这种用lambda来产生匿名函数的方式适用于简短函数的定义。
现在我们来看高阶函数。所谓高阶函数,就是能处理函数的函数。在第1章中,我们就已经见过了函数对象参数。那个接收函数对象为参数的函数,就是高阶函数。Python中提供了很多有用的高阶函数。我们从map()开始介绍。函数map()是Python的内置函数。它的第一个参数就是一个函数对象。函数map()把这一个函数对象作用于多个元素:
data_list = [1,3,5,6] result= map(lambda x: x+3,data_list)
函数map()的第二个参数是一个可循环对象。对于data_list的每个元素, lambda函数都会调用一次。那个元素会成为lambda函数的参数。换个角度说,map()把接收到的函数对象依次作用于每一个元素。最终,map()会返回一个迭代器(1)。迭代器中的元素,就是多次调用lambda函数的结果。因此,上面的代码相当于:
def equivalent_generator(func, iter): for item in iter: yield func(item) data_list = [1,3,5,6] result = map(lambda x: x+3, data_list)
上面的lambda函数只有一个参数。这个函数也可以是一个多参数的函数。这个时候,map()的参数列表中就需要提供相应数目的可循环对象。
def square_sum(x, y): return x**2 + y**2 data_list1 = [1,3,5,7] data_list2 = [2,4,6,8] result= map(square_sum,data_list1, data_list2)
这里,map()接收了square_sum()作为第一个参数。数square_sum()要求有两个参数。因此,map()调用时需要两个可循环对象。第一个循环对象提供了square_sum()中对应于x的参数,第二个循环对象提供了对应于y的参数。它们的关系如图7-3所示。

图7-3 两个循环对象之间的关系
一定程度上,map()函数能替代循环的功能。用map()函数写出的程序,看起来也相当简洁。从另一个角度来说,map()看起来像是对多个目标“各个击破”。在并行运算中,Map是一个很重要的过程。通过Map这一步,一个大问题可以分拆成很多小问题,从而能交给不同的主机处理。例如在图像处理中,就可以把一张大图分拆成许多张小图。每张小图分配给一台主机处理。
2.filter函数
和map()函数一样,内置函数filter()的第一个参数也是一个函数对象。它也将这个函数对象作用于可循环对象的多个元素。如果函数对象返回的是True,则该次的元素被放到返回的迭代器中。也就是说,filter()通过调用函数来筛选数据。
下面是使用filter()函数的一个例子。作为参数的larger100()函数用于判断元素是否比100大:
def larger100(a): if a > 100: return True else: return False for item in filter(larger100,[10,56,101,500]): print(item)
类似的,filter()用于多参数的函数时,也可以在参数中增加更多的可循环对象。总的来说,map()函数和filter()函数的功能有相似的地方,都是把同一个函数应用于多个数据。
3.reduce函数
函数reduce()也是一个常见的高阶函数。函数reduce()在标准库的functools包中(2),使用之前需要引入。和map()、reduce()一样,reduce()函数的第一个参数是函数,但reduce()对作为参数的函数对象有一个特殊要求,就是这个作为参数的函数必须能接收两个参数。Reduce()可以把函数对象累进的作用于各个参数。这个功能可以用一个简单的例子来说明:
from functools import reduce data_list = [1,2,5,7,9] result = reduce(lambda x, y: x + y, data_list) print(result) # 打印24
函数reduce()的第一个参数是求和的sum()函数,它接收两个参数x和y。在功能上,reduce()累进的运用传给它的二参函数。上一次运算的结果将作为下一次调用的第一个参数。首先,reduce()将用表中的前两个元素1和2做sum()函数的参数,得到3。该返回值3将作为sum()函数的第一个参数,而将表中的下一个元素5作为sum()函数的第二个参数,进行下一次求和得到8。8会成为新的参数,与下一个元素7求和。上面过程不断重复,直到列表中元素耗尽。函数reduce()将返回累进的运算结果,这里是一个单一的整数。上面的例子相当于(((1+2)+5)+7)+9,结果为24。也就是如图7-4所示过程。

图7-4 累进运算过程
函数reduce()通过某种形式的二元运算,把多个元素收集起来,形成一个单一的结果。上面的map()、reduce()函数都是单线程的,所以运行效果和循环差不多。但map()、reduce()可以方便地移植到并行化的运行环境下。在并行运算中,Reduce运算紧接着Map运算。Map运算的结果分布在多个主机上,Reduce运算把结果收集起来。因此,谷歌用于并行运算的软件架构,就称为MapReduce(3)。
4.并行处理
下面的程序就是在多进程条件下使用了多线程的map()方法。这段程序多线程地下载同一个URL下的资源。程序用了第三方包requests来进行HTTP下载:
import time from multiprocessing import Pool import requests def decorator_timer(old_function): def new_function(*arg, **dict_arg): t1 = time.time() result = old_function(*arg, **dict_arg) t2 = time.time() print("time: ", t2 - t1) return result return new_function def visit_once(i, address="http://www.cnblogs.com"): r = requests.get(address) return r.status_code @decorator_timer def single_thread(f, counts): result = map(f, range(counts)) return list(result) @decorator_timer def multiple_thread(f, counts, process_number=10): p = Pool(process_number) return p.map(f, range(counts)) if __name__ == "__main__": TOTAL = 100 print(single_thread(visit_once, TOTAL)) print(multiple_thread(visit_once, TOTAL))
在上面的程序中,我们启动了10个进程,并行地处理100个下载需求。这里把单个下载过程描述为一个函数,即visit_once(),然后用多线程的map()方法,把任务分配给雇佣来的10个工人,也就是10个进程。从结果可以看到,运行时间能大为缩短。
7.5 自上而下
1.便捷表达式
在本章的一开始,我们就提到函数式编程的思维是自上而下式的。Python中也有不少体现这一思维的语法,如生成器表达式、列表解析和词典解析。生成器表达式是构建生成器的便捷方法。考虑下面一个生成器:
def gen(): for i in range(4): yield i
等价的,上面程序可以写成生成器表达式(Generator Expression):
gen = (x for x in range(4))
这一语法很直观,写出来的代码也很简洁。
我们再来看看生成一个列表的方法:
l = []
for x in range(10):
l.append(x**2)
上述代码生成了表l,但有更快的方式。列表解析(List Comprehension)是快速生成列表的方法。它的语法简单,很有实用价值。列表解析的语法和生成器表达式很像,只不过把小括号换成了中括号:
l = [x**2 for x in range(10)]
列表解析的语法很直观。我们直截了当地说明了想要的是元素的平方,然后再通过for来增加限定条件,即哪些元素的平方。除了for,列表解析中还可以使用if。比如下面一个更复杂的例子:
xl = [1,3,5]
yl = [9,12,13]
l = [ x**2 for (x,y) in zip(xl,yl) if y > 10]
词典解析可用于快捷的生成词典。它的语法也与之前的类似:
d = {k: v for k,v in enumerate("Vamei") if val not in "Vi"}
你大概猜出它的结果了,可以在Python上验证一下。
2.懒惰求值
Python中的迭代器也很有函数式编程的意味。从功能上说,迭代器很多时候看起来就像一个列表。比如下面的迭代器和列表,效果上都一样:
for i in (x**2 for x in range(10)): print(i) for i in [x**2 for x in range(10)]: print(i)
但我们在介绍迭代器时曾提到过,迭代器的元素是实时计算出来的。在使用该元素之前,元素并不会占据内存空间。与之相对应,列表在建立时,就已经产生了各个元素的值,并保存在内存中。迭代器的工作方式正是函数式编程中的懒惰求值(Lazy Evaluation)。我们可以对迭代器进行各种各样的操作。但只有在需要时,迭代器才会计算出具体的值。
懒惰求值可以最小化计算机要做的工作。比如下面的程序可以在Python 3中飞速运行完成:
a = range(100000000)
result = map(lambda x: x**2, a)
在Python 3中,上面程序可以迅速执行。因为map返回的是迭代器,所以会懒惰求值(4)。更早之前的range调用返回的同样是迭代器,也是懒惰求值。除非通过某种方式调用迭代器中的元素,或者把迭代器转化成列表,运算过程才会开始。因此,在下面的程序中,如果把结果转化成列表,那么运算时间将大为增加。
a = range(100000000)
result = map(lambda x: x**2, a)
result = list(result)
如果说计算最终都不可避免,那么懒惰求值和即时求值的运算量并没有什么差别。但如果不需要穷尽所有的数据元素,那么懒惰求值将节省不少时间。比如下面的情况中,列表提前准备数据的方式,就浪费了很多运算资源:
for i in (x**2 for x in range(100000000)): if i>1000: break; for i in [x**2 for x in range(100000000)]: if i>1000: break;
除了运算资源,懒惰求职还能节约内存空间。对于即时求职来说,其运算过程的中间结果都需要占用不少的内存空间。而懒惰求值可以先在迭代器层面上进行操作,在获得最终迭代器以后一次性完成计算。除了用map()、filter()等函数外,Python中的itertools包还提供了丰富的操作迭代器的工具。
3.itertools包
标准库中的itertools包提供了更加灵活的生成迭代器的工具,这些工具的输入大都是已有的迭代器。另一方面,这些工具完全可以自行使用Python实现,该包只是提供了一种比较标准、高效的实现方式。这也符合Python“只有且最好只有一个解决方案”的理念。
#引入itertools
from itertools import *
这个包中提供了很多有用的生成器。下面两个生成器能返回无限循环的迭代器:
count(5, 2) #从5开始的整数迭代器,每次增加2,即5, 7, 9, 11, 13 ... cycle("abc") #重复序列的元素,既a, b, c, a, b, c ...
repeat()既可以返回一个不断重复的迭代器,也可以是有次数限制的重复:
repeat(1.2) #重复1.2,构成无穷迭代器,即1.2, 1.2, 1.2, ... repeat(10, 5) #重复10,共重复5次
我们还能组合旧的迭代器,来生成新的迭代器:
chain([1, 2, 3], [4, 5, 7]) # 连接两个迭代器成为一个。1, 2, 3, 4, 5, 7 product("abc", [1, 2]) # 多个迭代器集合的笛卡儿积。相当于嵌套循环
所谓的笛卡儿积可以得出集合元素所有可能的组合方式:
for m, n in product("abc", [1, 2]): print(m, n)
如下所示:
permutations("abc", 2) # 从"abcd"中挑选两个元素,比如ab, bc, …… # 将所有结果排序,返回为新的迭代器。 #上面的组合区分顺序,即ab, ba都返回。 combinations("abc", 2) # 从"abcd"中挑选两个元素,比如ab, bc, …… # 将所有结果排序,返回为新的迭代器。 # 上面的组合不区分顺序, # 即ab, ba,只返回一个ab。 combinations_with_replacement("abc", 2) # 与上面类似, # 但允许两次选出的元素重复。即多了aa, bb, cc
itertools包中还提供了许多有用的高阶函数:
starmap(pow, [(1, 1), (2, 2), (3, 3)]) # pow将依次作用于表的每个tuple。 takewhile(lambda x: x < 5, [1, 3, 6, 7, 1]) # 当函数返回True时, #收集元素到迭代器。一旦函数返回False,则停止。1, 3 dropwhile(lambda x: x < 5, [1, 3, 6, 7, 1]) # 当函数返回False时, #跳过元素。一旦函数返回True,则开始收集剩下的所有元素到迭代器。6, 7, 1
包中提供了groupby()函数,能将一个key()函数作用于原迭代器的各个元素,从而获得各个函数的键值。根据key()函数结果,将拥有元素分组。每个分组中的元素都保罗了键值形同的返回结果。函数groupby()分出的组放在一个迭代器中返回。
如果有一个迭代器,包含一群人的身高。我们可以使用这样一个key()函数: 如果身高大于180,返回"tall";如果身高低于160,返回"short";中间的返回"middle"。最终,所有身高将分为三个迭代器,即"tall"、"short"、"middle"。
from itertools import groupby def height_class(h): if h > 180: return "tall" elif h < 160: return "short" else: return "middle" friends = [191, 158, 159, 165, 170, 177, 181, 182, 190] friends = sorted(friends, key = height_class) for m, n in groupby(friends, key = height_class): print(m) print(list(n))
注意,groupby()的功能类似于UNIX中的uniq命令。分组之前需要使用sorted()对原迭代器的元素,根据key()函数进行排序,让同组元素先在位置上靠拢。
这个包中还有其他一些工具,方便迭代器的构建:
compress("ABCD", [1, 1, 1, 0]) #根据[1, 1, 1, 0]的真假值情况,选择 #保留第一个参数中的元素。A, B, C islice() # 类似于slice()函数,只是返回的是一个迭代器 izip() # 类似于zip()函数,只是返回的是一个迭代器。
至此,本书介绍了Python中包含的函数式编程特征:作为第一级对象的函数、闭包、高阶函数、懒惰求值……这些源于函数式编程的语法,以函数核心提供了一套新的封装方式。当我们编程时,可以在面向过程和面向对象之外,提供一个新的选择,从而给程序更大的创造空间。函数式编程自上而下的思维,也给我们的编程带来更多启发。在并行运算的发展趋势下,函数式编程正走向繁荣。希望本章的内容能对你的函数式编程学习有所助益。
————————————————————
(1) 在Python 2.7中,map()返回的是一个列表
(2) Python 2.7中,reduce()是内置函数,不需要引入。
(3) 参看http://research.google.com/archive/mapreduce.html
(4) Python 2.7中,range()和map()返回的都是列表,所以是即时求值。