Python函数进阶:闭包、装饰器、生成器、协程

返回目录

本篇索引

(1)

(2)

(3)

(4)

  (1)闭包

闭包(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

  (2)装饰器

装饰器(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()函数还是原来那个函数。

  (3)生成器

只要在函数中使用了 yield 语句,这个函数就称为生成器(generator)。生成器本身的概念很简单,理解起来也不难, 但是可以用生成器完出很多花样、写出一些执行效率很高又看上去比较优雅的代码,比如管道、协程等等。

● 基本概念

这里我们先讲生成器的基本概念:调用生成器函数时,函数将返回一个生成器对象,但本身并不运行。 当第一次调用__next__()方法时,函数从头开始运行,直到第一次遇见yield语句,当运行完这句 yield语句后,函数会暂停运行,并将yield语句指定的返回值返回。 之后,可以在这个生成器对象上反复调用__next__()方法,每次调用,都从刚才暂停的地方开始继续运行,并运行到下一个yield语句再次暂停。 最后,当函数中所有数据都迭代完毕,再无yield语句可用时,会引发一个StopIteration异常。用户如捕获到这个异常,可知生成器已迭代完毕。

下例为定义并使用一个生成器的基本方法:

# 定义生成器
def countdown(n):
    print(‘Start counting‘)
    while n > 0:
        yield n
        n -= 1

使用生成器

>>> c = countdown(3)
>>> 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)

  (4)协程

前面的生成器函数只能单向返回数据,而不能在挂起期间动态接收新的数据。其实,只要在生成器函数中将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()方法来搜索不同的目录和文件名。

返回目录

相关推荐