读《流畅的Python》:函数装饰器与闭包

前言

读书笔记这种内容,放在Evernote等笔记软件里似乎要比放在博客里更合适。但其一是笔者还没有找到适合我的笔记同步方案,其二是博客相比笔记软件更能锻炼自身的表达能力(或许吧),其三是将读书笔记放在博客里可以避免在求职的时候被面试官吐槽博客内容太少(笑),所以还是决定将这篇文章放在博客里。

但不管怎样,读书笔记终归是读书笔记,对其他人的作用还是有限,如有读者觉得碍眼烦请无视。

注:本篇文章使用CPython3.6

函数装饰器

函数装饰器是可调用的对象,其参数是另一个函数(被装饰的函数)。下面是一个简单的装饰器:

1
2
3
4
5
def deco(func: Callable) -> Callable:
def inner():
print('running inner()')

return inner

用法也很容易理解,下面两段代码效果一样:

1
2
3
@deco
def target():
print('running target()')
1
2
3
4
def target():
print('running target()')

target = deco(target)

从上面的例子就可以看出,装饰器只是一个语法糖。即使没有这个语法糖,也只是在进行类似的操作时需要更繁琐的代码,但不会造成功能上的缺失。

既然上面两段代码效果一样,那装饰器何时运行就很明显了:在被装饰的函数定义之后(通常是函数被导入时)立即运行。毕竟,真正被导入的不是target(),而是deco()的返回值。而要获得deco()的返回值,就必须让deco()先运行。

变量作用域

下面两段代码有什么区别?

1
2
3
4
5
b = 9
def f1():
print(b)

f1()
1
2
3
4
5
6
b = 9
def f2():
print(b) # UnboundLocalError: local variable 'b' referenced before assignment
b = 3

f2()

答案是后一段代码在执行print(b)时会抛出UnboundLocalError。因为在f2()中变量b被赋值了(b = 3),于是Python在编译f2()的定义体时将b视为局部变量,而不是像f1()那样在全局变量里查找b。如果想让f2()也将b视为全局变量,就需要使用global关键字:

1
2
3
4
5
6
7
b = 9
def f1():
global b
print(b)
b = 3

f1() # 正常运行

闭包

为什么会突然提到变量作用域这个概念呢?我们知道,Python中的局部变量在离开作用域会后被GC(垃圾回收)机制销毁,比如:

1
2
3
4
5
6
7
>>> import weakref
>>> def foo():
... s = set(range(3))
... weakref.finalize(s, lambda: print('s has gone'))
...
>>> foo()
s has gone

很明显,foo()运行完成后,运行期间创建的变量s被销毁了。但如果我们想在foo()内部再定义一个函数,并让这个函数能访问变量s,那s就不会被销毁:

1
2
3
4
5
6
7
8
9
10
11
12
>>> import weakref
>>> def foo():
... s = set(range(3))
... weakref.finalize(s, lambda: print('s has gone'))
...
... def bar():
... print(s)
... return bar
...
>>> bar = foo()
>>> bar()
{0, 1, 2}

bar()这种延伸了作用域的函数叫做闭包,它能访问不在定义体内定义的非全局变量。从这点可以看出,只有嵌套函数才有闭包这个概念。

变量sfoo()运行函数后已经是孤魂野鬼,被称作自由变量(free variable)。但bar()要能够访问变量s,就需要持有变量s的引用,这点可以从__code__属性(编译后的函数定义体)和__closure__属性中可以看出:

1
2
3
4
5
6
7
8
>>> bar.__code__.co_varnames  # 局部变量
()
>>> bar.__code__.co_freevars # 自由变量
('s',)
>>> bar.__closure__
(<cell at ...: set object at ...>,)
>>> bar.__closure__[0].cell_contents
{0, 1, 2}

危险地带:被删除的自由变量

Python在编译foo()时发现bar()需要访问变量s,于是将s视为自由变量,即使离开作用域也不会销毁。但Python不是静态语言,对象的实际状态需要等到运行时才能确定。看下面这个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def foo():
s = set(range(3))
weakref.finalize(s, lambda: print('s has gone'))
del s # 1. 输出s has gone

def bar():
print(s) # 4. NameError: free variable 's' referenced before assignment in enclosing scope

return bar


bar = foo()
print(bar.__code__.co_freevars) # 2. 输出('s',)
print(bar.__closure__[0]) # 3. 输出<cell at ...: empty>
bar()

这段代码的执行顺序和输出结果已经以注释的形式附加。Python在编译完bar()之后知道应该将s视为自由变量,但实际上bar()在运行时变量s已经被删除了……这个例子告诉我们,Python的动态特性是把双刃剑,更直白点就是:不要作死.

nonlocal关键字

再来看看这样一个返回值为统计平均值的函数的函数:

1
2
3
4
5
6
7
8
9
10
11
12
def make_averager():
nums = []

def averager(num: int) -> float:
nums.append(num)
return sum(nums) / len(nums)

return averager

averager = make_averager()
print(averager(10)) # 10.0
print(averager(5)) # 7.5

嗯,运行很完美。优化一下这个函数,让它不需要将所有的数字都保存起来:

1
2
3
4
5
6
7
8
9
def make_averager():
total, count = 0, 0

def averager(num: int) -> float:
total += num # UnboundLocalError
count += 1
return total / count

return averager

和上一节的第二个例子类似,averager()在运行时会抛出UnboundLocalError异常。但这次不能用global关键字来解决这个异常了:因为totalnum压根就不是全局变量,而是作用域仅限make_averager()的局部变量。那怎样让averager()能修改totalnum呢?答案是nonlocal关键字。和global关键字类似,nonlocal会告知Python:这个变量在更外层的作用域。如代码所示:

1
2
3
4
5
6
7
8
9
10
def make_averager():
total, count = 0, 0

def averager(num: int) -> float:
nonlocal total, count
total += num
count += 1
return total / count # 正常运行

return averager

nonlocal关键字并不能取代global关键字,因为局部变量(locals())和全局变量(globals())是互相独立的,比如如下的代码会抛出SyntaxError,即使f2()并没有被实际执行:

1
2
3
4
5
b = 9
def f2():
nonlocal b
print(b)
b = 3

同时nonlocal关键字的声明是不限深度的,也就是说Python会一直向更外层的作用域寻找这个变量,直到找到为止。如代码所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
def make_averager():
total, count = 0, 0

def averager(num: int) -> float:
def deeper():
nonlocal total, count
total += num
count += 1
return total / count # 正常运行

return deeper()

return averager

计时函数装饰器

了解了函数装饰器、闭包等概念后,就可以编写一个稍微具有一点实用性的函数装饰器了:

1
2
3
4
5
6
7
8
9
10
11
def clock(func: Callable) -> Callable:
def inner(*args, **kwargs):
"""inner doc"""
t0 = time.perf_counter()
result = func(*args, **kwargs)
elapsed = time.perf_counter() - t0
fmt = '[{elapsed:.4f}s] {func.__name__}({args}, {kwargs}): {result}'
print(fmt.format(**locals()))
return result

return inner

同样的,inner函数属于闭包函数,func属于自由变量。使用方法和效果如下:

1
2
3
4
5
6
@clock
def snooze(second: float):
"""snooze doc"""
time.sleep(second)

snooze(0.1) # 输出:[0.1001s] snooze((0.1,), {}): None

functool.wraps

上面这个装饰器的缺点是无法继承被装饰函数的属性(如__name____doc__):

1
2
print(snooze.__name__)  # inner
print(snooze.__doc__) # inner doc

这个时候就需要请出Python内置的装饰器functool.wraps了,这个装饰器会自动复制被装饰函数的属性到返回值函数里(换句话说就是让inner()按目标函数snnoze()的样子整个容):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def clock(func: Callable) -> Callable:
@functools.wraps(func)
def inner(*args, **kwargs):
"""inner doc"""
# ... 略
return inner

@clock
def snooze(second: float):
"""snooze doc"""
time.sleep(second

print(snooze.__name__) # snooze
print(snooze.__doc__) # snooze doc

带参数的函数装饰器

上节提到的functool.wraps装饰器比较特别,这个装饰器可以传入参数。我们知道,对于不带参数的装饰器,下面两段代码是相等的:

1
2
3
@deco
def target():
print('running target()')
1
2
3
4
def target():
print('running target()')

target = deco(target)

而对于带参数的装饰器,下面两段代码也是相等的:

1
2
3
@deco(123)
def target():
print('running target()')
1
2
3
4
def target():
print('running target()')

target = deco(123)(target)

从上面的例子可以看出,装饰器带不带参数,决定了是用这个函数本身来当装饰器,还是用函数的返回值来当装饰器(可能有点绕)。用这个思路改进一下前面提到的函数计时装饰器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
DEFAULT_FMT = '[{elapsed:.4f}s] {func.__name__}({args}, {kwargs}): {result}'

def clock_plus(fmt=DEFAULT_FMT):
def clock(func: Callable) -> Callable:
def inner(*args, **kwargs):
"""inner doc"""
t0 = time.perf_counter()
result = func(*args, **kwargs)
elapsed = time.perf_counter() - t0
print(fmt.format(**locals()))
return result

return inner
return clock

用法和效果如下:

1
2
3
4
5
6
@clock_plus(fmt='{elapsed:.4f}')
def snooze(second: float):
"""snooze doc"""
time.sleep(second)

snooze(0.1) # 输出:0.1001

所以,带参数的函数装饰器只是在不带函数的装饰器外面裹了一层,并没有太多魔术操作。

尾声

《流畅的Python》第七章(函数装饰器和闭包)的概念不算少,同时笔者也没能在提取关键信息的基础上控制好篇幅,再加上文章里还有笔者的一些个人理解,所以文章最后只能草草收尾,没有介绍叠放装饰器、functools.lru_cache等内容。

也许在读完这类书籍后,画一个思维导图,或单纯写一篇给自己看的笔记才是最好的选择,至少不会在博客里丢人(逃


读《流畅的Python》:函数装饰器与闭包
https://www.yooo.ltd/2020/06/19/fluent-python-chap-7/
作者
OrangeWolf
发布于
2020年6月19日
许可协议