装饰器的一大特性是,能把被装饰的函数替换成其他函数。第二个特性是,装饰器在加载模块时立即执行。

下面是第一个特性:

def deco(func):
	def inner():
		print('running inner()')
	return inner

@deco
def target():
	print('running target()')

>>> target()
running inner()
>>> target
<function deco.<locals>.inner at 0x10063b598>

被装饰的target现在是inner的引用。

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

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

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

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

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

if__name__=='__main__':
main()

运行:

python3 registration.py
running register(<function f1 at 0x100631bf8>)
running register(<function f2 at 0x100631c80>)
running main()
registry -> [<function f1 at 0x100631bf8>, <function f2 at 0x100631c80>]
running f1()
running f2()
running f3()

如果直接导入:

>>> import registration
running register(<function f1 at 0x10063b1e0>)
running register(<function f2 at 0x10063b268>)
>>> registration.registry
[<function f1 at 0x10063b1e0>, <function f2 at 0x10063b268>]

函数装饰器在导入模块时立即执行,而被装饰的函数只在明确调用时运行。这突出了 Python 程序员所说的导入时和运行时之间的区别。

使用装饰器改进“策略”模式

之前的模式的主要问题是,定义体中有函数的名称,但是best_promo 用来判断哪个折扣幅度最大的 promos 列表中也有函数名称。这种重复是个问题,因为新增策略函数后可能会忘记把它添加到promos 列表中,导致 best_promo 忽略新策略,而且不报错,为系统引入了不易察觉的缺陷。 下面是用装饰器实现:

promos = []

def promotion(promo_func):  # promotion 把 promo_func 添加到 promos 列表中,然后原封不动地将其返回。
    promos.append(promo_func)
    return promo_func


@promotion           # 被 @promotion 装饰的函数都会添加到 promos 列表中。
def fidelity(order):
    """为积分为1000或以上的顾客提供5%折扣"""
    return order.total() * .05 if order.customer.fidelity >= 1000 else 0


@promotion
def bulk_item(order):
    """单个商品为20个或以上时提供10%折扣"""
    discount = 0
    for item in order.cart:
        if item.quantity >= 20:
            discount += item.total() * .1
    return discount

@promotion
def large_order(order):
    """订单中的不同商品达到10个或以上时提供7%折扣"""
    distinct_items = {item.product for item in order.cart}
    if len(distinct_items) >= 10:
        return order.total() * .07
    return 0

def best_promo(order):
    return max(promo(order) for promo in promos)

这个方案有几个优点。

  • 促销策略函数无需使用特殊的名称(即不用以 _promo 结尾)。
  • @promotion 装饰器突出了被装饰的函数的作用,还便于临时禁用某个促销策略:只需把装饰器注释掉。
  • 促销折扣策略可以在其他模块中定义,在系统中的任何地方都行,只要使用 @promotion 装饰即可。

变量作用域规则

>>> b = 6
>>> def f2(a):
... print(a)
... print(b)
... b = 9
...
>>> f2(3)
3
Traceback (most recent call last):
	File "<stdin>", line 1, in <module>
	File "<stdin>", line 3, in f2
UnboundLocalError: local variable 'b' referenced before assignment

事实是,Python 编译函数的定义体时,它判断 b 是局部变量,因为在函数中给它赋值了。生成的字节码证实了这种判断,Python 会尝试从本地环境获取 b。后面调用 f2(3) 时, f2 的定义体会获取并打印局部变量 a 的值,但是尝试获取局部变量 b 的值时,发现 b 没有绑定值。

Python不要求声明变量,但是假定在函数定义体中赋值的变量是局部变量。

如果在函数中赋值时想让解释器把 b 当成全局变量,要使用 global 声明:

In [8]: b = 6
In [9]: def f3(a):
   ...:     global b
   ...:     print(a)
   ...:     print(b)
   ...:     b = 9
In [10]: f3(3)
3
6
In [11]: b
Out[11]: 9

In [12]: f3(3)
3
9

闭包

闭包指延伸了作用域的函数,其中包含函数定义体中引用、但是不在定义体中定义的非全局变量。函数是不是匿名的没有关系,关键是它能访问定义体之外定义的非全局变量。

看下面的示例:

class Average:
    def __init__(self):
        self.series = []

    
    def __call__(self, new_value):
        self.series.append(new_value)
        total = sum(self.series)
        return total / len(self.series)

使用:

>>> avg = Averager()
>>> avg(10)
10.0
>>> avg(11)
10.5
>>> avg(12)
11.0

或者可以用函数实现:

def make_averager():   
    series = []  # 自由变量。             #
                                        #    
    def averager(new_value):            #
        series.append(new_value)        # 这一块就是闭包
        total = sum(series)             #
        return total / len(series)      # 
        
    return averager
>>> avg = make_averager()
>>> avg(10)
10.0
>>> avg(11)
10.5
>>> avg(12)
11.0

这两个示例有共通之处:调用 Averager() 或make_averager() 得到一个可调用对象 avg,它会更新历史值,然后计算当前均值。

在 averager 函数中,series 是自由变量(free variable)。
averager 的闭包延伸到那个函数的作用域之外,包含自由变量 series 的绑定。

>>> avg.__code__.co_varnames
('new_value', 'total')
>>> avg.__code__.co_freevars
('series',)
>>> avg.__code__.co_freevars
('series',)
>>> avg.__closure__
(<cell at 0x107a44f78: list object at 0x107a91a48>,)
>>> avg.__closure__[0].cell_contents
[10, 11, 12]

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

nolocal声明

下面用另一种方式实现上面的make_averager():

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)
Traceback (most recent call last):
...
UnboundLocalError: local variable 'count' referenced before assignment
>>>

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

为了解决这个问题,Python 3 引入了 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

实现一个简单的装饰器

import time

def clock(func):
    def clocked(*args):  # 定义内部函数 clocked,它接受任意个定位参数。
        t0 = time.perf_counter()
        result = func(*args)  # 这行代码可用,是因为 clocked 的闭包中包含自由变量 func。
        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     # 返回内部函数,取代被装饰的函数。

使用:

import time
from clockdeco import clock

@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))

工作原理

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

其实等价于
def factorial(n):
    return 1 if n < 2 else n*factorial(n-1)
factorial = clock(factorial)       # 就是一个语法糖

因此,在两个示例中,factorial 会作为 func 参数传给 clock(参见示例 7-15)。然后, clock 函数会返回 clocked 函数,Python 解释器在背后会把 clocked 赋值给 factorial。

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

importimporttime
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

参数化装饰器

为了便于启用或禁用 register 执行的函数注册功能,我们为它提供一个可选的 active 参数,设为 False 时,不注册被装饰的函数。 从概念上看,这个新的 register 函数不是装饰器,而是装饰器工厂函数。调用它会返回真正的装饰器,这才是应用到目标函数上的装饰器。

registry = set()  # registry 现在是一个 set 对象,这样添加和删除函数的速度更快。

def register(active=True):
    def decorate(func): # decorate 这个内部函数是真正的装饰器;注意,它的参数是一个函数。
        print('running register(active=%s)->decorate(%s))' % (active, func))
        if active:   # 只有 active 参数的值(从闭包中获取)是 True 时才注册 func
            registry.add(func)
        else:
            registry.discard(func)  # 如果 active 不为真,而且 func 在 registry 中,那么把它删除
        return func  # decorate 是装饰器,必须返回一个函数

    return decorate  # register 是装饰器工厂函数,因此返回 decorate


@register(active=False) # @register 工厂函数必须作为函数调用,并且传入所需的参数
def f1():
    print('running f1()')

@register() # 即使不传入参数,register 也必须作为函数调用
def f2():
    print('running f2()')

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

这里的关键是,register() 要返回 decorate,然后把它应用到被装饰的函数上。