Python中的闭包和装饰器

基本要点

函数即对象

函数名的本质就是一个变量,保存了函数所在的内存地址。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#!/usr/bin/env python3
# -*- coding: utf-8 -*-


def dns_resolver(fpath='/etc/resolv.conf'):
with open(fpath, 'rt', encoding='utf-8') as fr:
return [line.strip().split()[1]
for line in fr if line.startswith('nameserver ')]


print(dns_resolver) # 不调用函数,直接打印函数名

# 输出结果:
# <function dns_resolver at 0x7f23b8cbfe18>

函数对象可以被赋值给变量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#!/usr/bin/env python3
# -*- coding: utf-8 -*-


def dns_resolver(fpath='/etc/resolv.conf'):
with open(fpath, 'rt', encoding='utf-8') as fr:
return [line.strip().split()[1]
for line in fr if line.startswith('nameserver')]


resolver = dns_resolver

print(resolver())

# 函数对象有一个 __name__ 属性,可以拿到函数的名字
print(resolver.__name__)
print(dns_resolver.__name__)

# 输出结果:
# ['192.168.127.2', '8.8.8.8', '9.9.9.9', '114.114.114.114', '1.1.1.1', '180.96.96.96']
# dns_resolver
# dns_resolver

函数名可以作为另外一个函数的参数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#!/usr/bin/env python3
# -*- coding: utf-8 -*-


def dns_resolver(fpath='/etc/resolv.conf'):
with open(fpath, 'rt', encoding='utf-8') as fr:
print([line.strip().split()[1]
for line in fr if line.startswith('nameserver')])


def runfunc(func):
func()


runfunc(dns_resolver)

函数名可以作为另外一个函数的返回值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#!/usr/bin/env python3
# -*- coding: utf-8 -*-


def resolver():
def dns_resolver(fpath='/etc/resolv.conf'):
with open(fpath, 'rt', encoding='utf-8') as fr:
return [line.strip().split()[1]
for line in fr if line.startswith('nameserver')]

return dns_resolver


b = resolver() # 实质上就是 b = dns_resolver
print(b())

函数名可以作为容器类型(例如列表、元组,字典)的元素。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#!/usr/bin/env python3
# -*- coding: utf-8 -*-


def dns_resolver(fpath='/etc/resolv.conf'):
with open(fpath, 'rt', encoding='utf-8') as fr:
return [line.strip().split()[1]
for line in fr if line.startswith('nameserver')]


resolver = dns_resolver

als = [resolver, dns_resolver]
print(als)

for x in als:
print(x())

# 输出结果:
# [<function dns_resolver at 0x7f29ad3b2e18>, <function dns_resolver at 0x7f29ad3b2e18>]
# ['192.168.127.2', '8.8.8.8', '9.9.9.9', '114.114.114.114', '1.1.1.1', '180.96.96.96']
# ['192.168.127.2', '8.8.8.8', '9.9.9.9', '114.114.114.114', '1.1.1.1', '180.96.96.96']

函数嵌套

函数都有各自的作用域:

1
2
3
4
5
6
7
8
9
def foo():
print('foo')

def bar(): # 作用域为局部
print('bar')
# bar()


bar() # 全局作用域中没有函数 bar 的定义,因此报错

Python 允许创建嵌套函数,通过在函数内部 def 的关键字再声明一个函数即为嵌套:

1
2
3
4
5
6
7
8
9
10
11
def outer():
x = 1

def inner():
print(x)

return inner


in_func = outer() # outer() 调用后返回 inner,即 in_func = inner
in_func() # 在全局作用域直接调用 inner() 则会报错

闭包

认识闭包

在一些语言中,在函数中可以(嵌套)定义另一个函数时,如果内部的函数引用了外部的函数的变量,则可能产生闭包。运行时,一旦外部的函数被执行,一个闭包就形成了,闭包中包含了内部函数的代码,以及所需外部函数中的变量的引用。闭包可以用来在一个函数与一组“私有”变量之间创建关联关系。在给定函数被多次调用的过程中,这些私有变量能够保持其持久性。 —— 维基百科

闭包(closure)是函数式编程的重要的语法结构,在 Python 的嵌套函数中,如果内部函数中对在外部作用域(但不是在全局作用域)的变量进行了引用,并且这个内部函数名被当成对象返回,这就形成了一个闭包(closure)

1
2
3
4
5
6
7
8
9
def echo_info(name):  # name 是外层函数的变量
def echo():
print(name) # 夹带了外部变量

return echo # 返回的是函数名(函数内存地址),并且是夹带着外部变量的函数


printer = echo_info('Tom') # 可以理解为 printer = echo
printer()

在上述的例子中 echo 就是内部函数,并且在 echo 中引用了外部作用域的变量 name ,而变量 name 是在外部作用域 echo_info 里面的,并且不是全局的作用域,函数名 echo 又作为了外层函数的返回值,此时内部函数 echo 就形成了一个闭包 。

闭包 = 函数块 + 定义函数时的环境,echo 就是函数块, name 就是环境,当然这个环境可以有很多,不止一个简单的 name

闭包的用途

闭包的最大用处有两个:

  • 内部函数可以引用外部作用域的变量。

  • 让外部作用域的变量的值始终保持在内存中。

闭包存在有什么意义呢?为什么需要闭包?我们来看爬取网页源码的例子:

普通方式爬取

1
2
3
4
5
6
7
8
9
10
11
12
from urllib.request import urlopen


def geturl():
url = 'https://movie.douban.com'
res = urlopen(url).read()
print(res)


geturl()
geturl()
geturl()

闭包的方式爬取

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

from urllib.request import urlopen


def geturl():
url = 'https://movie.douban.com'

def inner_geturl():
res = urlopen(url).read()
print(res)

return inner_geturl


my_geturl = geturl()
my_geturl()
my_geturl()
my_geturl()

假设我们要对函数调用 100 次,第二种爬取方式要比第一种更节省资源。因为第一种每调用一次函数,就会在内存中创建一次对象引用 url = 'https://movie.douban.com',调用 100 次就创建了一百次对象引用。而第二种是个闭包函数,对象引用 url = 'https://movie.douban.com' 会一直在内存中存在,而不会被创建一百次。

原因就在于 geturlinner_geturl 的父函数,而 inner_geturl 被传入了一个父级函数的变量 url,这导致 inner_geturl 始终在内存中,而 inner_geturl 的存在依赖于 geturl,因此 geturl 也始终在内存中,不会在调用结束后被垃圾回收机制回收。

如何判断是不是闭包

闭包函数相对与普通函数会多出一个 __closure__ 的属性,里面定义了一个元组用于存放所有的 cell 对象,每个 cell 对象保存了这个闭包中所有的外部变量。使用它就可以判断函数是否形成了闭包。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def mkinfo(name, age, sex='F'):
def make():
print('Name : %s' % name)
print('Age : %s' % age)
print('Sex : %s' % sex)

return make


echo = mkinfo('Tom', 10)

print(echo.__closure__)
print(echo.__closure__[0].cell_contents)
print(echo.__closure__[1].cell_contents)
print(echo.__closure__[2].cell_contents)

输出结果如下:

1
2
3
4
(<cell at 0x7f099fdf67c8: int object at 0x7f099fcc5680>, <cell at 0x7f099fdf67f8: str object at 0x7f099fdbc180>, <cell at 0x7f099fdf6828: str object at 0x7f099fdddce0>)
10
Tom
F

装饰器

装饰器前戏

假设有如下函数,被其他各种程序调用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#!/usr/bin/env python3
# -*- coding: utf-8 -*-


def dns_resolver(fpath='/etc/resolv.conf'):
with open(fpath, 'rt', encoding='utf-8') as fr:
print([line.strip().split()[1]
for line in fr if line.startswith('nameserver')])


def os_release(fpath='/etc/redhat-release'):
with open(fpath, 'rt', encoding='utf-8') as fr:
return fr.readline()

# 此处省略 N 多函数....

现在公司要进行绩效考核,考核标准为 Python 代码中每个函数所执行的时间。如果在每个函数中加入时间统计的功能,则会造成大量雷同的代码,为了解决这个问题,我们想到可以重新定义一个函数用来专门计算时间:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import time


def dns_resolver(fpath='/etc/resolv.conf'):
with open(fpath, 'rt', encoding='utf-8') as fr:
print([line.strip().split()[1]
for line in fr if line.startswith('nameserver')])


def os_release(fpath='/etc/redhat-release'):
with open(fpath, 'rt', encoding='utf-8') as fr:
return fr.readline()


# 此处省略 N 多函数....

def spent_time(func):
start_time = time.time()
func()
stop_time = time.time()
print('Spent %s in %s' % (stop_time - start_time, func.__name__))


spent_time(dns_resolver)
spent_time(os_release)

但是这样的话,基础平台的函数修改了名字,很容易被业务线的人投诉的,因为我们每次都要将一个函数作为参数传递给 spent_time 函数。而且这种方式已经破坏了原有的代码逻辑结构,之前执行业务逻辑时运行 dns_resolver()os_release(),但是现在不得不改成 spent_time(dns_resolver)spent_time(os_release) ,这在生产环境是很不切合实际的,使用装饰器就可以很好地解决这个问题。

在此之前,我们先认识一下 开放封闭原则

开放封闭原则

软件开发中的 “开放-封闭” 原则 :

  • 封闭:已实现需求的功能代码块不应该再被修改。

  • 开放:对现有功能的扩展开放。

装饰器

装饰器(Decorator)本质上是一个返回函数对象的高阶函数,该函数用不需要修改源函数代码(函数体、调用方式、返回值)的前提下为其增加额外的功能,装饰器的返回值也是一个函数对象。

概括的讲,装饰器的作用就是为已经存在的对象添加额外的功能 ,它常用于有切面需求的场景,比如插入日志、性能测试、事务处理、缓存、权限校验等应用场景。

现在回到上面绩效的例子,假设我们已经在源代码的基础上单独添加了计算时间的函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import time


def spent_time(funcname):
def inner(*args, **kwargs):
begin = time.time()
retval = funcname(*args, **kwargs)
end = time.time()
print("You have spent %s in %s" % (end - begin, funcname.__name__))
return retval

return inner


def dns_resolver(fpath='/etc/resolv.conf'):
with open(fpath, 'rt', encoding='utf-8') as fr:
print([line.strip().split()[1]
for line in fr if line.startswith('nameserver')])


def os_release(fpath='/etc/redhat-release'):
with open(fpath, 'rt', encoding='utf-8') as fr:
return fr.readline()


dns_resolver = spent_time(dns_resolver)
# spent_time() 调用后返回了 inner,即 dns_resolver = inner
dns_resolver()
# 相当于 inner()

os_release = spent_time(os_release)
# spent_time()调用后返回了 inner,即 os_release = inner

print(os_release('/etc/centos-release'))
# 相当于 print(inner('/etc/centos-release'))

在上面的代码中,函数 spent_time 就是一个装饰器, funcname 就是被装饰的对象。看起来像 dns_resolveros_release 被上下时间函数装饰了,并且没有改变原函数的调用方式,但是每次调用时都必须进行一次赋值操作。为了避免这种重复性赋值操作,Python 使用了 @ 语法糖:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import time


def spent_time(funcname):
def inner(*args, **kwargs):
begin = time.time()
retval = funcname(*args, **kwargs)
end = time.time()
print("You have spent %s in %s" % (end - begin, funcname.__name__))
return retval

return inner


@spent_time # 相当于 dns_resolver = spent_time(dns_resolver) => dns_resolver = inner
def dns_resolver(fpath='/etc/resolv.conf'):
with open(fpath, 'rt', encoding='utf-8') as fr:
print([line.strip().split()[1]
for line in fr if line.startswith('nameserver')])


@spent_time # 相当于 os_release = spent_time(os_release) => os_release = inner
def os_release(fpath='/etc/redhat-release'):
with open(fpath, 'rt', encoding='utf-8') as fr:
return fr.readline()


dns_resolver()
# 相当于 inner()

print(os_release('/etc/centos-release'))
# 相当于 print(inner('/etc/centos-release'))

如上所示,装饰器直接省去赋值操作,这样就提高了程序的可重复利用性,并增加了程序的可读性。并且 dns_resolver = spent_time(dns_resolver) 实际上是把 inner 的对象引用给了 dns_resolver ,而 inner 中的变量 funcname 之所以可以用,是因为 inner 是一个闭包函数,它引用了外部作用域 spent_time 接收到的 funcname

带参数的装饰器

上面我们已经用装饰器解决了调用方式和重复赋值的问题。现在问题又来了,这个月的绩效考核完了,函数代码不需要统计花费时间了,下个月又要再次进行一次考核。

解决这个问题最笨的办法就是,如果不考核了我们把装饰器的使用给注释掉,如果开始考核再把注释取消。如果有一千个函数的话,就要每次都要操作一千次。虽然编辑器都有批量查找替换的功能,但是这种方法很显然并不符合实际。

那么,有没有更简单的办法呢?我们可以使用一个全局变量作为 flag,再配合使用可以传入参数的装饰器即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import time

flag = True


def show_time(flag=False): # 默认值参数,默认为False
def spent_time(funcname):
def inner(*args, **kwargs):
if flag:
begin = time.time()
retval = funcname(*args, **kwargs)
end = time.time()
print("You have spent %s in %s" % ( end - begin, funcname.__name__))
else:
retval = funcname(*args, **kwargs)
return retval

return inner

return spent_time


@show_time(flag)
# 1. 函数调用:show_time(flag), 返回 spent_time
# 2. 可以理解为 @spent_time, 也就是 dns_resolver = spent_time(resolver), 并且可以调用 show_time 的参数flag
# 3. dns_resolver = inner
def dns_resolver(fpath='/etc/resolv.conf'):
with open(fpath, 'rt', encoding='utf-8') as fr:
print([line.strip().split()[1]
for line in fr if line.startswith('nameserver')])


@show_time(flag)
def os_release(fpath='/etc/redhat-release'):
with open(fpath, 'rt', encoding='utf-8') as fr:
return fr.readline()


dns_resolver()
# 相当于 inner()

print(os_release('/etc/centos-release'))
# 相当于 print(inner('/etc/centos-release'))

上面的代码开发完之后,如果想用装饰器就将全局变量 flag 的值改为 True,不想用就改为 False,这样以来就使用带参数的装饰器灵活地解决是否启用装饰的问题。

对象属性复制

函数对象都有 __name__ 等属性,在上面的例子中,看似已经把所有问题都解决掉了,但是当我们打印经过装饰器做了装饰之后的原函数,它们的 __name__ 已经发生了变化:

1
2
print("function dns_resolver's name :", dns_resolver.__name__)
print("function os_release's name :", os_release.__name__)

结果如下:

1
2
function dns_resolver's name : inner
function os_release's name : inner

本来 dns_resolver 在没有被装饰的时候,dns_resolver.__name__ 就是 dns_resolver,而装饰后却变成了 inner ,因为返回的那个 inner() 函数名字就是 inner,所以需要把原始函数的 __name__ 等属性复制到 inner() 函数中,否则有些依赖函数签名的代码执行就会出错。

不需要编写 inner.__name__ = func.__name__ 这样的代码,Python 内置的 functools.wraps 就是干这个事的,所以一个完整的 decorator 的写法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import time
from functools import wraps


def spent_time(funcname):
@wraps(funcname) # 这里就是专门进行属性复制的
def inner(*args, **kwargs):
begin = time.time()
retval = funcname(*args, **kwargs)
end = time.time()
print("You have spent %s in %s" % (end - begin, funcname.__name__))
return retval

return inner


# 下面代码省略

如果是带参数的 decorator,应该这样写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import time
from functools import wraps

flag = True


def show_time(flag=False): # 默认值参数,默认为False
def spent_time(funcname):
@wraps(funcname)
def inner(*args, **kwargs):
if flag:
begin = time.time()
retval = funcname(*args, **kwargs)
end = time.time()
print("You have spent %s in %s" % (
end - begin, funcname.__name__))
else:
retval = funcname(*args, **kwargs)
return retval

return inner

return spent_time


# 下面代码省略

其中 from functools import wraps 是导入 functools 模块的 wraps 功能,现在只需要记住在定义 inner() 的前面加上 @wraps(funcname) 即可。由此一来,属性也完成了复制:

1
2
function dns_resolver's name : dns_resolver
function os_release's name : os_release

多层装饰器

多层装饰器有点类似于俄罗斯套娃,我们来看下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#!/usr/bin/env python3
# -*- coding: utf-8 -*-

from functools import wraps


def mkbold(funcname):
@wraps(funcname)
def inner2():
return "<b>" + funcname() + "</b>"

return inner2


def mkitalic(funcname):
@wraps(funcname)
def inner1():
return "<i>" + funcname() + "</i>"

return inner1


@mkbold # hello = mkbold(hello) => hello = inner2
@mkitalic # hello = mkitalic(hello) => hello = inner1
def hello():
return "Hello world!"


print(hello())

多层装饰器在执行时,最先执行的是离原函数最近的一层装饰器,依次往外执行,下面用一张图理解一下上述代码中装饰器的执行过程:

有钱任性,请我吃包辣条
0%