PythonMaker 2020-01-19
本篇索引
(1)
(2)
(3)
(4)
闭包(closure)是很多现代编程语言都有的特点,像C++、Java、JavaScript等都实现或部分实现了闭包功能,很多高级应用都会依靠闭包实现。 一般专业文献上对闭包的定义都比较拗口,比如:“将组成函数的语句和这些语句的执行环境打包在一起时,得到的对象称为闭包。”
其实,简单来说,你可以将闭包看成是一个轻载的类,这个类只有一个函数方法,并且只有为数不多的几个成员变量。 闭包的优点是:实现起来比类稍微轻巧一点(意思就是可以少敲一些代码),并且运行速度比类要快得多(据说约快50%)。下面是一个定义闭包的简单例子:
def foo(x, y): def hellofun(): print(‘hellofun x is %d, y is %d.‘ %(x,y)) return hellofun a = foo(1,2) b = foo(30,40) a() b() # 运行结果为: hellofun x is 1, y is 2. hellofun x is 30, y is 40.
上例中,foo就定义了一个闭包,它将内部定义的函数hellofun返回(但并不运行这个函数), 同时将入参x,y作为以后hellofun要运行时的环境,隐式地与hellofun打包一起返回。 因此,a=foo(1,2) 语句的作用就是:生成一个闭包对象a,这个对象是可作为函数运行的,且其内部含有隐式的成员变量x=1和y=2。 当后面执行 a() 时,会真正运行这个hellofun函数,并且其运行时的环境就是闭包中的:x=1和y=2。
● 查看闭包中变量的内容
续上例:
print(a.__closure__) print(a.__closure__[0].cell_contents) print(a.__closure__[1].cell_contents) # 运行结果为: (<cell at 0x0000022B7436CFD8: int object at 0x00007FFDB0E37100>, <cell at 0x0000022B74386288: int object at 0x00007FFDB0E37120>) 1 2
● 用闭包实现计数器的例子
def countdown(n): def next(): nonlocal n # Python3可使用nonlocal关键字,用于声明n为next()函数外部的变量 r = n n -= 1 return r return next next = countdown(10) while True: v = next() if not v: break
装饰器(decorator)是一个函数,其主要用途是包装另一个函数。它可以在不改动原函数的情况下,增强原函数的功能。 相当于给原函数加装了一个增强包。我们来看一个例子:
def square(x): return x*x
上面是一个计算平方的函数,但是功能非常简单。我们可以通过为其加装装饰器的方法,增强其功能,比如:为这个函数增加打印计算结果的功能。 代码如下:
# 定义装饰器函数 def print_result(func): def callf(*args, **kwargs): r = func(*args, **kwargs) print(‘The result is %d.‘ %r) return r return callf # 原函数定义 def square(x): return x*x # 用装饰器函数装饰原函数 square = print_result(square)
我们先不管装饰器函数的定义,先看最后一行:装饰器的原理就是,将原来的函数square作为参数传递给我们新定义的装饰器函数, 再偷偷将square这个名称替换成我们自己定义的装饰器函数print_result中返回的callf函数。这样,当用户执行比如 square(2) 语句时, 并不是在执行原来的square函数,而是在运行我们的 callf(2)。
接下来我们再来看装饰器函数中的内容,print_result(func)实际上是定义了一个闭包,和前面的例子中将数据x,y作为闭包环境数据传进来不同, 这里将整个square()函数定义作为闭包环境数据传了进来,好在Python中万物皆对象,函数定义本质上也是一个对象, 所以将它作为数据传进来也是可以的。
然后在运行callf(2)的时候,将入参"2"通过(*args, **kwargs)参数原封不动地传给了func(*argc, **kwargs)去运行, 而这个func就是闭包对象中的的数据(即原square函数的定义),在运行完func函数之后(其实就是运行了square(2)之后),增加了一句print打印功能, 最后再将func函数返回的结果r再原封不动地返回出去。整个过程就好像给原函数square套了一个壳,故称为装饰器。
运行装饰后的函数:
y = square(2) print(y) # 运行结果为: The result is 4. 4
可以用特殊语法符号@来简写装饰器,以上代码的简写形式为:
# 定义装饰器函数 def print_result(func): def callf(*args, **kwargs): r = func(*args, **kwargs) print(‘The result is %d.‘ %r) return r return callf # 用@装饰原函数 @print_result def square(x): return x*x
● 使用多个装饰器
可以对一个原函数使用多个装饰器,其装饰先后顺序为从下到上、从内到外:
@dec1 @dec2 @dec3 def square(x): pass # 相当于: def square(x): pass square = dec1(dec2(dec3(square)))
● 接收参数的装饰器
装饰器也可以接收参数,用法如下:
@eventhandler(‘BUTTON‘) def handle_button(msg): pass @eventhandler(‘RESET‘) def handle_reset(msg): pass
接收参数的装饰器的语义如下:
temp = eventhandler(‘BUTTON‘) handle_button = temp(handle_button)
接收参数的装饰器通常用于函数的回调注册等用途,下面是以上代码的完整例子:
# 事件处理程序装饰器 event_handlers = {} def eventhandler(event): def register_function(f): event_handlers[event] = f return f return register_function @eventhandler(‘BUTTON‘) def handle_button(msg): pass
当运行带参数的装饰器语句@eventhandler(‘BUTTON‘),首先会运行 temp = eventhandler(‘BUTTON‘),运行完后temp指向的是 register_function(f)函数,并且其环境数据event为‘BUTTON‘。接下来运行 handle_button = temp(handle_button), 这相当于运行:register_function(handle_button),在这个函数中,会把handle_button函数(即入参f)放入全局字典event_handlers中, 然后再把这个handle_button函数原封不动地返回去,返回给全局名称handle_button。
这个装饰器的用法和我们前面见过的普通装饰器的功能稍有不同,它并没有通过偷换handle_button名称来增强handle_buttton()函数的功能, 仅仅是将handle_button函数放入了全局字典event_handlers,做了一个类似注册的工作。handle_button()函数还是原来那个函数。
只要在函数中使用了 yield 语句,这个函数就称为生成器(generator)。生成器本身的概念很简单,理解起来也不难, 但是可以用生成器完出很多花样、写出一些执行效率很高又看上去比较优雅的代码,比如管道、协程等等。
● 基本概念
这里我们先讲生成器的基本概念:调用生成器函数时,函数将返回一个生成器对象,但本身并不运行。 当第一次调用__next__()方法时,函数从头开始运行,直到第一次遇见yield语句,当运行完这句 yield语句后,函数会暂停运行,并将yield语句指定的返回值返回。 之后,可以在这个生成器对象上反复调用__next__()方法,每次调用,都从刚才暂停的地方开始继续运行,并运行到下一个yield语句再次暂停。 最后,当函数中所有数据都迭代完毕,再无yield语句可用时,会引发一个StopIteration异常。用户如捕获到这个异常,可知生成器已迭代完毕。
下例为定义并使用一个生成器的基本方法:
# 定义生成器 def countdown(n): print(‘Start counting‘) while n > 0: yield n n -= 1
使用生成器
>>> print(c.__next__()) Start counting 3 >>> print(c.__next__()) 2 >>> print(c.__next__()) 1 >>> print(c.__next__()) 引发StopIteration异常
上例中,函数countdown()定义了一个生成器。当运行 c = countdown(3) 后,这个生成对象被赋值给了c,在这个生成器对象中,保存了函数运行时内部的上下文变量数据。
(1)当第1次调用 c.__next__()语句时,函数从头开始运行,并运行到第一个 yield n 语句暂停,根据yield n 语句的指示, 将 n(此时值为3)作为返回值返回。
(2)当第2次调用 c.__next__()语句时,函数从刚才暂停的地方(即 yield n的后一句:n -= 1)开始运行,并运行到再次遇到 yield n 为止。 此时函数再次暂停,并将 n(此时值为2)作为返回值返回。
(3)当第3次调用 c.__next__()语句时,函数继续从刚才暂停的地方(即 n -= 1)开始运行,并运行到再次遇到 yield n 为止。 此时函数再次暂停,并将 n(此时值为1)作为返回值返回。
(4)当第4次调用 c.__next__()语句时,函数继续从刚才暂停的地方(即 n -= 1)开始运行。当这句 n -= 1 运行完毕后,n的值为0,再回到上面的while语句时,不再满足 while n > 0 的循环要求, 因此,函数结束while循环,运行到函数结束位置。此时,生成器引发StopIteration异常。
● 在for循环中使用生成器
上例仅用于说明生成器的基本概念。一般我们在使用生成器时,通常不会像上面那样手动去调用__next__()方法, 而是通过一个for语句让Python自动调用生成器的__next__()方法,并自动捕获StopIteration异常来结束for循环。
下例为上面的countdown生成器的通常使用方法:
for i in countdown(3): print(i) # 运行结果为: 3 2 1
● 用生成器实现类似管道的功能
管道是Linux/Unix操作系统中的一种强大的数据流处理方式,它可以将输出程序和输入程序分开实现,非常符合模块化的思想。 而Python的生成器,可以在程序内部模仿这种风格,使得输出函数只要考虑如何输出,输入函数只要考虑如何处理输入的内容, 而将它们的交互糅合交给外部用户去安排。
考虑如下一个任务:假设有一个日志文件 log.txt,这个文件由操作系统负责写入,每当有新用户登录时, 操作系统会在这个文件最后追加一行,记录新登录用户的用户名和登录时间。现在需要编一个Python程序持续监视这个日志文件 (每秒查看一次这个日志文件),每当发现用户名Tom登录时,在屏幕上打印这条登录信息。
如果不使用生成器的话,一般会使用类似如下代码实现这个功能:
import os import time def grep(line, searchtxt): if searchtxt in line: print(line) def tail(f): f.seek(0, os.SEEK_END) # 移动到文件尾 while True: line = f.readline() # 如果没有新的内容,readline()会返回空 if not line: time.sleep(1) continue grep(line, ‘Tom‘) # 下面是用户使用代码 f = open(‘log.txt‘) tail(f)
代码不难,上例中实现了2个函数,tail()函数负责每秒查看一次日志文件log.txt,若无新内容,则睡眠1秒钟;若发现日志文件中有新内容, 则调用grep()函数进行判断,grep()函数若发现新行中有特定的文本(本例中是‘Tom‘),则使用print在屏幕上打印这行内容。
上面代码的问题在于,输入输出函数没有做到严格分开,如果现在变更一下需求,需要监视用户名为Jerry的用户并打印这条登录信息, 那么势必要去更改 tail() 函数的内部实现(第16行改成 grep(line, ‘Jerry‘)),这就不符合模块化编程的思想了。
如果使用生成器来编程,就可以很好地做到这一点,代码如下所示:
import os import time # 输出函数只管输出 def tail(f): f.seek(0, os.SEEK_END) while True: line = f.readline() if not line: time.sleep(1) continue yield line # 输入函数只管处理输入内容 def grep(linegtr, searchtxt): for line in linegtr: if searchtxt in line: yield line # 下面是用户使用代码 f = open(‘log.txt‘) logtxt = tail(f) # logtxt是一个生成器对象,每次 result = grep(logtxt, ‘Jerry‘) # result也是一个生成器对象 for line in result: print(line)
上述代码中,logtxt = tail(f) 产生了一个生成器,这个生成器永远不会耗尽数据(当没有新数据时会睡眠,但生成器永不会耗尽而引发StopIteration异常), 所以当grep()函数中用 for line in linegtr 去迭代这个logtxt生成器时,当有数据时,会使用后面的 if 语句进行判断,当符合条件即用 yield line 语句返回这行文本; 当没有数据时进程就睡眠。
而grep()函数也是一个生成器,也是永不耗尽。所以当下面的用户代码用 for line in result 语句去迭代这个 result 生成器时, 若grep有数据返回,则用print()函数打印这行内容,若没有则睡眠。
以上代码做到了输出函数与输入函数的分开实现,将交互糅合的工作交给了用户来处理。使用生成器的关键好处是: 它可以使函数返回一些内容,又不退出函数。
● 关闭生成器
生成器在所有数据迭代完后会被自动关闭,一般不需要手动去关闭。但在一些特殊情况下,也可以通过调用close()方法去手动关闭生成器。
c = countdown(3) c.__next__() c.close() # 手动关闭生成器 c.__next__() # 报错!close()后再调用__next__()方法会引发StopIteration异常
在生成器内部,在yield语句上出现GeteratorExit异常时,就会调用close()方法,也可以在生成器中手动去捕获这个异常, 以执行一些清理操作,如下例所示:
def countdown(n): try: while n > 0: yield n n -= 1 except GeneratorExit: print(‘The current n is %d‘ %n)
● 生成器表达式
我们以前学过“列表推导”(list comprehension),可以很方便地根据条件来生成一个列表。其缺点也很明显,如果数据量很大, 会在内存中生成一个庞大的列表,很吃内存资源。
对于大量数据,更好的方法是使用“生成器表达式”(generator expression),它的功能与列表推导相同,但不会立即生成一个大列表, 而是生成一个生成器表达式对象,在后面的迭代过程中,每次仅动态生成需要的部分。
“生成器表达式”的语法同“列表推导”非常相似,只是用圆括号替代方括号,其语法格式如下:
(expression for item1 in iterable1 if condition1 for item2 in iterable2 if condition2 ... for itemN in iterableN if conditionN)
下例打开一个文件,并打印其中所有以 # 开头的注释行
f = opeon(‘a.txt‘) comments = (t for t in lines if t[0] == ‘#‘) # comments为生成器表达式对象 for c in comments: print(c)
上例中,运行 comments = (t for t in lines if t[0] == ‘#‘) 语句时,仅仅生成了一个生成器表达式对象,并没有真正去读取整个文件。 而在后面的 for 循环中,才真正去按需读取文件的各行并进行过滤,每一行都是按需生成的。
由于不需要把整个文件都加载到内存中,这对于读取GB级大小的文件时是非常高效的。
最后需要注意的是,生成器表达式不是列表,你不能对它进行下标索引,也不能进行任何诸如append()之类的列表常规操作。 如果需要,你可以使用内置的list()函数,将生成器表达式对象转换成真正的列表。
● 声明式编程
利用生成器表达式,可以写出很多紧凑和高效的代码。假设我们有下面一个文本文件stationery.txt, 第1列为商品名称、第2列为单价、第3列为数量:
pen,20.5,3 ruler,3.0,10 eraser,2.5,8
现在我们要对每行的第2列和第三列求乘积,并将所有行的乘积求和算出总价,传统的实现代码是类似下面这个样子的:
total = 0 for line in open(‘stationery.txt‘): fields = line.split(‘,‘) total += float(fields[1]) * float(fields[2]) print(total)
而如果使用生成器表达式,可以像这样写:
lines = open(‘stationery.txt‘) fields = (line.split(‘,‘) for lien in lines) # fields 为第1个生成器表达式对象 print(sum( float(f[1]) * float(f[2]) for f in fields )) # sum()函数中的内容为第2个生成器表达式对象
这样写的代码比上面的传统写法更紧凑,而且执行速度往往更快。由于在写生成器表达式的时候,仅仅是说明了生成迭代规则, 并不真正运行迭代(真正的迭代运行交给下面的for去完成),有点像写配置文件,故称为“声明式编程”(declarative programming)
生成器表达式还可以与数据库查询语句(SQL select)结合使用,写出非常紧凑的复杂功能代码:
sum(price*qty for price,qty in cursor.execute(‘select price, qty from stationery‘) if price*qty >= 100)
前面的生成器函数只能单向返回数据,而不能在挂起期间动态接收新的数据。其实,只要在生成器函数中将yield反过来用, 将yield放在等号的右边,并加上括号,就可以接收数据。以这种方式使用yield语句的函数称为协程(coroutine)
就如同前面我们说过“闭包”像一个轻载的类,“协程”就像一个轻载的线程。可以将协程当成一个任务,能发给它数据, 让协程根据收到的数据去完成任务,协程完成任务后会自己挂起;直到下次再收到数据,协程会再次激活运行, 运行完任务后继续挂起……很像一个线程的行为(普通线程是收到特定的信号(Signal)激活,运行完任务后自动挂起,但需要操作系统来调度)。
● 协程的基本用法
下例定义了一个简单的协程 receiver:
def receiver(): print("The receiver is running") while True: n = (yield) # 获取外部发给协程的数据 print("Got %s" %n)
使用协程:
>>> r = receiver() # 生成一个协程 >>> r.__next__() # 调用__next__()是必须的,为的是让函数运行到 yield 前一句 The receiver is running >>> r.send(‘Hello‘) Got Hello >>> r.send(1) Got 1
上面的例子中,receiver()是一个协程,其功能是每次收到新数据后将其打印到屏幕。当第一次调用完__next__()方法后, 函数将运行到n = (yield) 语句的右半部分,暂停并返回(这里返回None)。之后每次通过send()方法给这个协程发数据后, 函数恢复运行(注意:这里会继续运行n = (yield)语句的左半部分,将收到的数据赋值给n),一直运行到下一个yield语句为止。
● 给协程上装饰器
在协程使用过程中,一个常见的错误是:经常会忘记写__next__()调用。我们可以写一个装饰器来自动完成这一个功能:
# 定义装饰器coroutine def coroutine(func): def start(*args, **kwargs): g = func(*args, **kwargs) g.__next__() return g return start @coroutine def receiver(): print("The receiver is running") while True: n = (yield) print("Got %s" %n)
使用协程:
>>> r = receiver() # 生成一个协程 The receiver is running # 用户不必自己调用__next__(),装饰器已帮我们自动调用好了 >>> r.send(‘Hello‘) Got Hello >>> r.send(1) Got 1
● 关闭协程
协程一般不会自己退出,会永远执行下去(回忆对比一下生成器:会在迭代数据耗尽后自己退出)。 可以使用 close() 方法显式关闭协程,当协程被关闭后,再给协程发数据会引发StopIteration异常。
>>> r = receiver() >>> r.close() >>> r.send(‘Hello‘) # 报错!close()后再调用send()方法会引发StopIteration异常
同生成器一样,close()操作将在协程内部引发GeneratorExit异常。
另外,还可以在协程对象上使用throw()方法在协程内部引发异常,以这种方式引发的异常将在协程中当前执行的yield语句处出现, 协程可以选择捕捉这个异常并以正确的方式处理它们。顺带提一句,不要通过其他线程给当前线程的协程发送throw()异常。
● 使用协程同时收发数据
协程可以使用yield一句同时接收数据和发出返回值,用法如下:
def receiver(): print("The receiver is running") result_list = [] while True: n = (yield result_list) # 接收数据的同时,将result_list 放入返回值 result_list.append(n) print("Got %s" %n)
使用结果:
>>> r = receiver() >>> print(r.__next__()) The receiver is running [] >>> print(r.send(‘a‘)) Got a [‘a‘] >>> print(r.send(‘b‘)) Got b [‘a‘, ‘b‘]
(1)上例中,先手动调用__next__()执行到第1个yield语句,这时仅执行这个 yield 语句的右半部分 (yield result_list)。 这个右半部分会返回 result_list列表(此时为空列表)。
(2)当之后调用 r.send(‘a‘) 时,协程继续运行 n = (yield result_list) 语句的左半部分,将收到的 ‘a‘ 赋值给n, 然后运行到下一个 n = (yield result_list) 语句的右半部分,此时返回的 result_list 的值为[‘a‘]。
(3)之后再次调用 r.send(‘b‘) 时,分析方法同上类似。
● 使用协程实现并发
在理解了上面协程的基本用法后,我们来看如何用协程实现并发编程。使用协程,可以轻松实现几百几千个轻载任务的并发。 一个典型的应用是处理网络连接,如果有几百个用户连接进来,用协程可以轻松地进行异步处理,相比之下, 如果开几百个线程来处理的话,开销就太大了。
由于网络编程部分还在后面,这里先演示一个用协程处理打开若干个文件的例子,其中coroutine装饰器已在前面的例子中定义。 下面代码的功能是,用户指定一个目录和一个文件名,让程序自动去遍历查找这个目录及其子目录下有没有这个文件, 若有,则打开文件查找其中有没有含“Tom”这个字符串的行,若找到则在屏幕上打印这个行。
import os import fnmatch @coroutine def find_files(target): while True: topdir, filename = (yield) for path, dirlist, filelist in os.walk(topdir): for name in filelist: if name == filename: target.send(os.path.join(path, name)) @coroutine def opener(target): while True: name = (yield) f = open(name) target.send(f) @coroutine def cat(target): while True: f = (yield) for line in f: target.send(line) @coroutine def grep(pattern, target): while True: line = (yield) if pattern in line: target.send(line) @coroutine def printer(): while True: line = (yield) sys.stdout.write(line) # 下面是使用协程 finder = find_files(opener(cat(grep("Tom", printer())))) # 发送值给协程,激活协程去工作 finder.send(‘/var‘, ‘log.txt‘) finder.send(‘testdir1‘, ‘filea.txt‘)
下面使用协程的第一句:finder = find_files(opener(cat(grep("Tom", printer())))) 的功能是启动所有协程。 下面我们对其运行过程一步步进行分析。
(1)按顺序,最里面的printer()协程最先运行,运行到printer()中的yield语句挂起返回,返回值就是这个printer协程对象。
(2)然后将“Tom”字符串和前面这个printer()返回的协程对象作为参数,传递给grep()协程。 同样的,在grep()中也是运行到yield语句挂起返回,返回这个grep协程对象。
(3)然后再将这个grep协程对象作为入参传递给cat()协程,之后的流程也是类似的。依次一步步往外传……
(4)最后一步就是将opener协程对象作为find_files()协程的入参,生成最外层的find_files协程对象。在find_files()中, 也是运行到yield语句挂起返回,不过find_files()中的这句 topdir, filename = (yield) 语句可同时接收2个数据, 若收到数据则将它们分别赋值给topdir和filename变量。
之后,就可以通过调用 finder 协程对象的send()方法,给协程发数据让协程工作了。发送的顺序与刚才完全倒过来,从最外层的find_files()协程开始。
(1)当执行 finder.send(‘/var‘, ‘log.txt‘) 语句后,会激活最外层的find_files协程,它收到“目录”与“文件名”两个数据后,执行os.walk()方法遍历整个目录树,若在目录树中找到与给定文件名相同的文件,则通过 target.send() 语句,将这个文件名(含完整路径)发送给opener协程去打开。这里的target就是最先前find_files协程初始化的时候,传进来的opener协程对象。
(2)当opener协程收到数据后,同样也被激活,然后它执行 f = open(name) 完成打开文件的任务,再将这个文件对象 f 发送给cat协程。
(3)cat协程收到数据后,同样也被激活,然后它执行 for 语句来迭代这个文件对象,每次for迭代会返回文件中的一行,并将这行字符串数据 line 发送给grep协程对象。
(4)grep协程收到一行字符串数据后,通过 if 语句比对这行字符串中是否含有初始化时输入的“Tom”,若有,则将这行字符串发送给printer协程。
(5)最后,当printer协程收到数据后,通过 sys.stdout.write(line) 完成在屏幕打印这行字符串的任务,之后再次运行到yield语句挂起返回。其外层的各个协程也依次一个个挂起返回,最终完成了一次send()调用。
之后可以任意次调用finder.send()方法来搜索不同的目录和文件名。