Skip to content

函数装饰器与闭包

此笔记记录于《流畅的python》,大部分为其中的摘要,少部分为笔者自己的理解;笔记为jupyter转的markdown,原始版jupyter笔记在这个仓库

作为Python程序员,如果严格遵守基于类的面向对象编程方式,即便不知道这个关键字也不会受到影响。然而,如果你想自己实现函数装饰器,那就必须了解闭包的方方面面,因此也就需要知道nonlocal。

除了在装饰器中有用处之外,闭包还是回调式异步编程和函数式编程风格的基础。

装饰器基础知识

装饰器是可调用的对象,其参数是另一个函数(被装饰的函数)。装饰器可能会处理被装饰的函数,然后把它返回,或者将其替换成另一个函数或可调用对象。

python
@decorate
def target():
    print('running target()')

上述代码的效果和下述写法是一样的:

python
def target():
    print('running target()')

target = decorate(target)
python
# 装饰器通常把函数替换成另一个函数
def deco(func):
    def inner():
        print('running inner()')
    return inner
  
@deco
def target():
    print('running target()')
python
target() # 用被装饰的target其实会运行inner
running inner()
python
target # target现在是inner的引用
<function __main__.deco.<locals>.inner()>

严格来说,装饰器只是语法糖。如前所示,装饰器可以像常规的可调用对象那样调用,其参数是另一个函数。有时,这样做更方便,尤其是做元编程(在运行时改变程序的行为)时。

python何时执行装饰器

装饰器的一个关键特性是,它们在被装饰的函数定义之后立即运行。这通常是在导入时(即Python加载模块时)

python
registry = []


def register(func):
    print('running register(%s)' % func)
    registry.append(func)
    return func


@register
def f1():
    print('running f1()')


@register
def f2():
    print('running f2()')


def f3():
    print('running f3()')


def main():
    print('running main()')
    print('registry->', registry)
    f1()
    f2()
    f3()


if __name__ == '__main__':
    main()
running register(<function f1 at 0x00000219F2A5C040>)
running register(<function f2 at 0x00000219F2B6A320>)
running main()
registry-> [<function f1 at 0x00000219F2A5C040>, <function f2 at 0x00000219F2B6A320>]
running f1()
running f2()
running f3()

变量作用域规则

python
b = 6
def f2(a):
    print(a)
    print(b)
    b = 9
    
f2(3) # 会报错,因为b在函数内部被赋值了,所以被认为是局部变量,但是在赋值之前就被引用了
3



---------------------------------------------------------------------------

UnboundLocalError                         Traceback (most recent call last)

Cell In[6], line 7
      4     print(b)
      5     b = 9
----> 7 f2(3)


Cell In[6], line 4, in f2(a)
      2 def f2(a):
      3     print(a)
----> 4     print(b)
      5     b = 9


UnboundLocalError: local variable 'b' referenced before assignment

闭包

python
def make_averager():
    series = []

    def averager(new_value):
        series.append(new_value)
        total = sum(series)
        return total/len(series)
    return averager
python
avg = make_averager()
avg(10)
10.0
python
avg(11)
10.5
python
avg(12)
11.0

注意,series是make_averager函数的局部变量,因为那个函数的定义体中初始化了series:series=[]。可是,调用avg(10)时,make_averager函数已经返回了,而它的本地作用域也一去不复返了。

在averager函数中,series是自由变量(free variable)。这是一个技术术语,指未在本地作用域中绑定的变量:

python
avg.__code__.co_varnames # ('new_value', 'total')
('new_value', 'total')
python
avg.__code__.co_freevars # ('series',)
('series',)
python
avg.__closure__ # (<cell at 0x7f3e3c3e3d90: list object at 0x7f3e3c3e3e08>,)
(<cell at 0x00000219F264F760: list object at 0x00000219F46C2100>,)
python
avg.__closure__[0].cell_contents
[10, 11, 12]

综上,闭包是一种函数,它会保留定义函数时存在的自由变量的绑定,这样调用函数时,虽然定义作用域不可用了,但是仍能使用那些绑定。

nonlocal声明

前面实现make_averager函数的方法效率不高,我们把所有值存储在历史数列中,然后在每次调用averager时使用sum求和。更好的实现方式是,只存储目前的总值和元素个数,然后使用这两个数计算均值。

python
def make_averager():
    count = 0
    total = 0

    def averager(new_value):
        count += 1
        total += new_value
        return total / count
    return averager
  
avg = make_averager()
avg(10) # 会报错,因为count和total是数字,所以会被认为是局部变量
---------------------------------------------------------------------------

UnboundLocalError                         Traceback (most recent call last)

Cell In[17], line 12
      9     return averager
     11 avg = make_averager()
---> 12 avg(10)


Cell In[17], line 6, in make_averager.<locals>.averager(new_value)
      5 def averager(new_value):
----> 6     count += 1
      7     total += new_value
      8     return total / count


UnboundLocalError: local variable 'count' referenced before assignment

问题是,当count是数字或任何不可变类型时,count+=1语句的作用其实与count=count+1一样。因此,我们在averager的定义体中为count赋值了,这会把count变成局部变量。total变量也受这个问题影响。

  • 上一个例子中没遇到这个问题,因为我们没有给series赋值,我们只是调用series.append,并把它传给sum和len。也就是说,我们利用了列表是可变的对象这一事实。
  • 但是对数字、字符串、元组等不可变类型来说,只能读取,不能更新。如果尝试重新绑定,例如count=count+1,其实会隐式创建局部变量count。这样,count就不是自由变量了,因此不会保存在闭包中。

为了解决这个问题,Python 3引入了nonlocal声明。它的作用是把变量标记为自由变量,即使在函数中为变量赋予新值了,也会变成自由变量。如果为nonlocal声明的变量赋予新值,闭包中保存的绑定会更新。

python
# 下面是使用nonlocal修正后的方法
def make_averager():
    count = 0
    total = 0

    def averager(new_value):
        nonlocal count, total
        count += 1
        total += new_value
        return total / count
    return averager

实现一个简单的装饰器

python
import time

# 一个简单的装饰器,输出函数的运行时间


def clock(func):
    def clocked(*args):  # ➊
        t0 = time.perf_counter()
        result = func(*args)  # ➋
        elapsed = time.perf_counter()-t0
        name = func.__name__
        arg_str = ', '.join(repr(arg) for arg in args)
        print('[%0.8fs]%s(%s)->%r' % (elapsed, name, arg_str, result))
        return result
    return clocked  # ➌
python
# 使用上述装饰器
import time


@clock
def snooze(seconds):
    time.sleep(seconds)


@clock
def factorial(n):
    return 1 if n < 2 else n*factorial(n-1)


if __name__ == '__main__':
    print('*' * 40, 'Calling snooze(.123)')
    snooze(.123)
    print('*' * 40, 'Calling factorial(6)')
    print('6! =', factorial(6))
**************************************** Calling snooze(.123)
[0.13076400s]snooze(0.123)->None
**************************************** Calling factorial(6)
[0.00000100s]factorial(1)->1
[0.00002310s]factorial(2)->2
[0.00003470s]factorial(3)->6
[0.00004460s]factorial(4)->24
[0.00005490s]factorial(5)->120
[0.00006930s]factorial(6)->720
6! = 720

上述实现的clock装饰器有几个缺点:不支持关键字参数,而且遮盖了被装饰函数的__name____doc__属性。下例使用functools.wraps装饰器把相关的属性从func复制到clocked中。此外,这个新版还能正确处理关键字参数。

python
# clockdeco2.py
import time
import functools


def clock(func):
    @functools.wraps(func)
    def clocked(*args, **kwargs):
        t0 = time.time()
        result = func(*args, **kwargs)
        elapsed = time.time()-t0
        name = func.__name__
        arg_lst = []
        if args:
            arg_lst.append(', '.join(repr(arg) for arg in args))
        if kwargs:
            pairs = ['%s=%r' % (k, w) for k, w in sorted(kwargs.items())]
            arg_lst.append(', '.join(pairs))
        arg_str = ', '.join(arg_lst)
        print('[%0.8fs]%s(%s)->%r ' % (elapsed, name, arg_str, result))
        return result
    return clocked

标准库中的装饰器

Python标准库中包含了一些常用的装饰器,以下是其中的一部分:

  1. @functools.lru_cache(maxsize=128, typed=False):这是一个非常有用的装饰器,它可以实现对函数的结果进行缓存,从而提高程序的运行效率。maxsize参数用于指定缓存的最大容量,typed参数则是用于指定是否需要根据参数的类型进行缓存。
  2. @functools.wraps(wrapped, assigned=WRAPPER_ASSIGNMENTS, updated=WRAPPER_UPDATES):这个装饰器主要是用于在定义装饰器时保留被装饰函数的元信息(如函数的名称、文档字符串等)。
  3. @functools.total_ordering:这个装饰器用于在类中定义了__lt__()__le__()__gt__()__ge__()中的一个或多个方法后,自动添加其余的比较方法。
  4. @functools.singledispatch:这个装饰器实现了单分派泛函数。对于注册为泛函数的函数,它们的实现可以根据第一个参数的类型进行切换。
  5. @property:这个装饰器用于将一个方法变成属性,使得我们可以像访问属性一样来访问这个方法。
  6. @staticmethod:这个装饰器用于声明静态方法,即不需要实例化也可以被类本身调用的方法。
  7. @classmethod:这个装饰器用于声明类方法,即这些方法将绑定到类上,而不是类的实例上。
  8. @abc.abstractmethod:这个装饰器用于声明抽象方法,这些方法必须在任何直接或间接的子类中进行重写。
  9. @contextlib.contextmanager:这个装饰器用于定义一个上下文管理器,使得我们可以使用with语句来管理资源。

@functools.singledispatch 是一个装饰器,用于将一个函数转换为单分派泛函数。所谓"单分派",是指根据函数的第一个参数的类型,来决定调用哪个实现。这使得我们可以对同一个函数,针对不同的参数类型,编写不同的实现。

这个装饰器主要用于创建一个简单的、可读性强的、对不同类型进行不同操作的函数。

下面是一个例子:

python
from functools import singledispatch

@singledispatch
def fun(arg, verbose=False):
    if verbose:
        print("Let me just say,", end=" ")
    print(arg)

@fun.register(int)
def _(arg, verbose=False):
    if verbose:
        print("Strength in numbers, eh?", end=" ")
    print(arg)

@fun.register(list)
def _(arg, verbose=False):
    if verbose:
        print("Enumerate this:")
    for i, elem in enumerate(arg):
        print(i, elem)

在这个例子中,fun函数根据输入参数的类型(整数或列表),执行不同的操作。如果输入的是整数,就调用fun.register(int)注册的函数;如果输入的是列表,就调用fun.register(list)注册的函数。如果输入的类型没有被注册,就调用原始的fun

叠放装饰器

下述代码:

python
@d1
@d2
def f():
    print('f')

等同于:

python
def f():
    print('f')

f = d1(d2(f))

参数化装饰器

解析源码中的装饰器时,Python把被装饰的函数作为第一个参数传给装饰器函数。那怎么让装饰器接受其他参数呢?答案是:创建一个装饰器工厂函数,把参数传给它,返回一个装饰器,然后再把它应用到要装饰的函数上。

python
registry = set()


def register(active=True):
    def decorate(func):
        print('running register(active=%s)->decorate(%s)'
              % (active, func))
        if active:
            registry.add(func)
        else:
            registry.discard(func)
        return func
    return decorate


@register(active=False)
def f1():
    print('running f1()')


@register()
def f2():
    print('running f2()')


def f3():
    print('running f3()')
running register(active=False)->decorate(<function f1 at 0x00000219F4517EB0>)
running register(active=True)->decorate(<function f2 at 0x00000219F2A5CEE0>)
python