Skip to content

Latest commit

 

History

History
911 lines (764 loc) · 36.7 KB

File metadata and controls

911 lines (764 loc) · 36.7 KB

7.1 装饰器基础知识

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

假如有个名为decorate的装饰器:

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

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

def target():
    print('running target()')
target = decorate(target)

两种写法的最终结果一样: 上述两个代码片段执行完毕后得到的target不一定是原来那个target函数,而是decorate(target)返回的函数。 为了确认被装饰的函数会被替换,请看示例:

def deco(func):
    def inner():
        print("running inner()")

    return inner  # 返回inner函数对象


@deco  # 使用deco装饰target函数
def target():
    print("running target()")


target()        # 调用被装饰的target其实会运行inner
print(target)   # 查看对象,target现在是inner的引用

输出:

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

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

  • 能把被装饰的函数替换成其他函数。
  • 装饰器在加载模块时立即执行。

上述示例源代码 ------> deco

7.2 Python何时执行装饰器

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

registry = []       # 保存被@register装饰的函数引用

def register(func):     # register的参数是一个函数
    print("running register(%s)" % func)     # 打印被装饰的函数
    registry.append(func)       # 把被装饰的函数装入registry
    return func         # 返回函数,必须返回函数,这里返回的函数与通过参数传入的一样。

@register       # f1、f2被@register装饰
def f1():
    print("running f1()")

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

def f3():       # f3没有被装饰
    print("running f3()")

def main():     # main显示registry,然后调用f1()、f2()、f3()
    print("running main()")
    print("registry ->", registry)
    f1()
    f2()
    f3()

if __name__ == '__main__':      # 只有把本py文件当做脚本运行时才调用main()
    main()

输出:

running register(<function f1 at 0x10954b8b0>)
running register(<function f2 at 0x10954b790>)
running main()
registry -> [<function f1 at 0x10954b8b0>, <function f2 at 0x10954b790>]
running f1()
running f2()
running f3()

注意register在模块中其他函数之前运行(两次)。调用register时,传给它的参数是被装饰的函数。 例如:<function f1 at 0x10954b8b0>

加载模块后,registry中有两个被装饰函数的引用:f1和f2。 这两个函数和f3只在main明确调用它们时才执行。

如果只导入这个模块,即不作为脚本运行,那么会输出以下:

running register(<function f1 at 0x1104ff820>)
running register(<function f2 at 0x1104ff8b0>)

此时查看registry的值:

[<function f1 at 0x1094fe820>, <function f2 at 0x1094fe8b0>]

上述示例源代码 ------> registration

所以这个示例说明,函数装饰器再导入模块时立即执行,而被装饰的函数只在明确调用时运行。 这也突出了python程序员所关注的导入时和运行时的区别。

考虑到装饰器在真实代码中的常用方式,示例有两个不寻常的地方:

  • 装饰器函数与被装饰的函数在同一个模块中定义。但实际情况是,装饰器通常在一个模块中定义,然后应用到其他模块中的函数上。
  • register装饰器返回的函数与通过参数传入的相同。实际上,大多数装饰器会在内部定义一个函数,然后将其返回。

虽然示例中的register装饰器原封不动地返回被装饰的函数,但是这种技术并非没有用处。 很多Python Web框架使用这样的装饰器把函数添加到某种中央注册处,例如把 URL 模式映射到生成 HTTP 响应的函数上的注册处。 这种注册装饰器可能会也可能不会修改被装饰的函数。

7.3 使用装饰器改进"策略"模式

回顾一下,示例 6-6 : 示例6-6 它的主要问题是,定义体中有函数的名称,但是 best_promo 用来判断哪个折扣幅度最大的 promos 列表中也有函数名称。 这种重复是个问题,因为新增策略函数后可能会忘记把它添加到 promos 列表中,导致 best_promo 忽略新策略,而且不报错, 为系统引入了不易察觉的缺陷。 下面的示例使用注册装饰器解决了这个问题。电商促销折扣示例:

promos = []  # 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):  # best_promos 无需修改,因为它依赖 promos 列表。
    """选择可用的最佳折扣"""
    return max(promo(order) for promo in promos)

这个方案有几个优点:

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

上述示例源代码 ------> promotion

不过,多数装饰器会修改被装饰的函数。通常,它们会定义一个内部函数,然后将其返回,替换被装饰的函数。 使用内部函数的代码几乎都要靠闭包才能正确运作。为了理解闭包,我们要退后一步,先了解 Python 中的变量作用域。

7.4 变量作用域规则

下面的示例中,我们定义并测试了一个函数,它读取两个变量的值: 一个是局部变量 a,是函数的参数;另一个是变量 b,这个函数没有定义它。

def f1(a):
    print(a)
    print(b)

f1(3)

输出

3
Traceback (most recent call last):
  File "/Users/fzk27/fzk27/Coding-Daily/content/Python_Generate/python语言总结回顾/装饰器和闭包(流畅的python学习)/7-4.py", line 10, in <module>
    f1(3)
  File "/Users/fzk27/fzk27/Coding-Daily/content/Python_Generate/python语言总结回顾/装饰器和闭包(流畅的python学习)/7-4.py", line 8, in f1
    print(b)
NameError: name 'b' is not defined

出错不奇怪,如果先给b赋值,再调用f1那就不会出错

def f1(a):
    print(a)
    print(b)
b = 6
f1(3)

输出:

3
6

看一下下面示例中的 f2 函数。前两行代码与上面示例中的 f1 一样,然后为 b 赋值,再打印它的值。 可是,在赋值之前,第二个 print 失败了。下面示例中 b 是局部变量,因为在函数的定义体中给它赋值了。

b = 6
def f2(a):
    print(a)
    print(b)
    b = 9
f2(3)

输出:

Traceback (most recent call last):
3
  File "/Users/fzk27/fzk27/Coding-Daily/content/Python_Generate/python语言总结回顾/装饰器和闭包(流畅的python学习)/7-4.py", line 22, in <module>
    f2(3)
  File "/Users/fzk27/fzk27/Coding-Daily/content/Python_Generate/python语言总结回顾/装饰器和闭包(流畅的python学习)/7-4.py", line 20, in f2
    print(b)
UnboundLocalError: local variable 'b' referenced before assignment

注意,首先输出了 3,这表明 print(a) 语句执行了。但是第二个语句 print(b) 执行不了。 一开始我很吃惊,我觉得会打印 6,因为有个全局变量 b,而且是在 print(b) 之后为局部变量 b 赋值的。 可事实是,Python 编译函数的定义体时,它判断 b 是局部变量,因为在函数中给它赋值了。 生成的字节码证实了这种判断,Python 会尝试从本地环境获取 b。 后面调用 f2(3) 时, f2 的定义体会获取并打印局部变量 a 的值,但是尝试获取局部变量 b 的值时,发现 b 没有绑定值。 这不是缺陷,而是设计选择:Python 不要求声明变量,但是假定在函数定义体中赋值的变量是局部变量。

这比 JavaScript 的行为好多了, JavaScript 也不要求声明变量,但是如果忘记把变量声明为局部变量 (使用 var),可能会在不知情的情况下获取全局变量。 如果在函数中赋值时想让解释器把 b 当成全局变量,要使用 global 声明:

b = 6
def f3(a):
    global b
    print(a)
    print(b)
    b = 9
f3(3)
print(b)
f3(3)
b = 30
print(b)

输出

3
6
9
3
9
30

下面比较字节码,dis模块为反汇编 Python 函数字节码提供了简单的方式。

7           0 LOAD_GLOBAL              0 (print)        # 加载全局名称print
              2 LOAD_FAST                0 (a)          # 加载本地名称a
              4 CALL_FUNCTION            1
              6 POP_TOP

  8           8 LOAD_GLOBAL              0 (print)
             10 LOAD_GLOBAL              1 (b)          # 加载全局名称b
             12 CALL_FUNCTION            1
             14 POP_TOP
             16 LOAD_CONST               0 (None)
             18 RETURN_VALUE

以及f2

20           0 LOAD_GLOBAL              0 (print)
              2 LOAD_FAST                0 (a)
              4 CALL_FUNCTION            1
              6 POP_TOP

 21           8 LOAD_GLOBAL              0 (print)
             10 LOAD_FAST                1 (b)          # 加载本地名称b
             12 CALL_FUNCTION            1
             14 POP_TOP

 22          16 LOAD_CONST               1 (9)
             18 STORE_FAST               1 (b)
             20 LOAD_CONST               0 (None)
             22 RETURN_VALUE

加载本地名称 b。这表明,编译器把 b 视作局部变量,即使在后面才为 b 赋值,因为变量的种类(是不是局部变量)不能改变函数的定义体。 运行字节码的 CPython VM 是栈机器,因此 LOAD 和 POP 操作引用 的是栈。

上述示例源代码 ------> f1 f2 f3

7.5 闭包

在博客圈,人们有时会把闭包和匿名函数弄混。这是有历史原因的:在函数内部定义函数不常见,直到开始使用匿名函数才会这样做。 而且, 只有涉及嵌套函数时才有闭包问题。因此,很多人是同时知道这两个概念的。 其实,闭包指延伸了作用域的函数,其中包含函数定义体中引用、但是不在定义体中定义的非全局变量。 函数是不是匿名的没有关系,关键是它能访问定义体之外定义的非全局变量。 这个概念难以掌握,最好通过示例理解。 假如有个名为 avg 的函数,它的作用是计算不断增加的系列值的均值; 例如,整个历史中某个商品的平均收盘价。 每天都会增加新价格,因此平均值要考虑至目前为止所有的价格。 起初,avg 是这样使用的:

avg(10)
》10
avg(11)
》10.5
avg(12)
》11

avg 从何而来,它又在哪里保存历史值呢? 初学者可能会像下面示例那样使用类实现。

class Averager():
    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()
print(avg(10))
print(avg(11))
print(avg(12))

下面是函数式实现,使用高阶函数 make_averager

def make_averager():
    series = []
    def averager(new_value):
        series.append(new_value)
        total = sum(series)
        return total / len(series)
    return averager
avg = make_averager()
print(avg(10))
print(avg(11))
print(avg(12))

注意,这两个示例有共通之处:调用 Averager() 或 make_averager() 得到一个可调用对象 avg,它会更新历史值,然后计算当前均值。 在第一个示例中,avg 是 Averager 的实例; 在第二个示例中,是内部函数 averager。不管怎样,我们都只需调用 avg(n),把 n 放入系列值中,然后重新计算均值。 Averager 类的实例 avg 在哪里存储历史值很明显:self.series 实例属性。

但是第二个示例中的 avg 函数在哪里寻找 series 呢?

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

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

审查返回的 averager 对象,我们发现 Python 在 __code__ 属性(表示 编译后的函数定义体)中保存局部变量和自由变量的名称:

print(avg.__code__.co_varnames)
print(avg.__code__.co_freevars)

输出:

('new_value', 'total')
('series',)

series 的绑定在返回的 avg 函数的 closure 属性中。 avg.closure 中的各个元素对应于 avg.code.co_freevars 中的一个名称。 这些元素是 cell 对象, 有个 cell_contents 属性,保存着真正的值。这些属性值如下:

print(avg.__closure__)
print(avg.__closure__[0].cell_contents)

输出:

(<cell at 0x106dc0190: list object at 0x106e11c00>,)
[10, 11, 12]

综上,闭包是一种函数,它会保留定义函数时存在的自由变量的绑定, 这样调用函数时,虽然定义作用域不可用了,但是仍能使用那些绑定。 注意,只有嵌套在其他函数中的函数才可能需要处理不在全局作用域中的外部变量。

上述示例源代码 ------> Averager和make_averager

7.6 nonlocal声明

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

下面的示例中的实现有缺陷,只是为了阐明观点。你能看出缺陷在哪儿吗?

计算移动平均值的高阶函数,不保存所有历史值,但有缺陷

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):
  File "/Users/fzk27/fzk27/Coding-Daily/content/Python_Generate/python语言总结回顾/装饰器和闭包(流畅的python学习)/7-6.py", line 17, in <module>
    avg(10)
  File "/Users/fzk27/fzk27/Coding-Daily/content/Python_Generate/python语言总结回顾/装饰器和闭包(流畅的python学习)/7-6.py", line 11, in averager
    count += 1
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声明的变量赋予新值,闭包中保存的绑定会更新。最新版 make_averager 的正确实现下所示。 计算移动平均值,不保存所有历史(使用 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

avg = make_averager()
print(avg(10))

Python 2 没有 nonlocal,因此需要变通方法。 基本上,这种处理方式是把内部函数需要修改的变量(如 count 和 total)存储为可变对象(如字典或简单的实例)的元素或属性, 并且把那个对象绑定给一个自由变量。

上述示例源代码 ------> make_averager和nolocal

7.7 实现一个简单的装饰器

下面示例定义了一个装饰器,它会在每次调用被装饰的函数时计时,然后把经过的时间、传入的参数和调用的结果打印出来。 示例:一个简单的装饰器,输出函数的运行时间

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  # 返回内部函数,取代被装饰的函数。下面演示了 clock 装饰器 的用法。

使用 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))

输出:

**************************************** Calling snooze(.123)
[0.12815295s] snooze(0.123) -> None
**************************************** Calling factorial(6)
[0.00000109s] factorial(1) -> 1
[0.00001886s] factorial(2) -> 2
[0.00002929s] factorial(3) -> 6
[0.00003854s] factorial(4) -> 24
[0.00004875s] factorial(5) -> 120
[0.00006140s] factorial(6) -> 720
6! = 720

在两个示例中,factorial 会作为 func 参数传给 clock。 然后,clock 函数会返回 clocked 函数,Python 解释器在背后会把 clocked 赋值给 factorial。 其实,导入 clockdeco_demo 模块后查看 factorial 的 name 属性,会得到如下结果:

print(factorial.__name__)

输出:

clocked

所以,现在 factorial 保存的是 clocked 函数的引用。自此之后,每次调用 factorial(n),执行的都是 clocked(n)。 clocked 大致做了下面几件事:

  1. 记录初始时间 t0。
  2. 调用原来的 factorial 函数,保存结果。
  3. 计算经过的时间。
  4. 格式化收集的数据,然后打印出来。
  5. 返回第 2 步保存的结果。

这是装饰器的典型行为:把被装饰的函数替换成新函数,二者接受相同的参数, 而且(通常)返回被装饰的函数本该返回的值,同时还会做些额外操作。

Gamma 等人写的《设计模式:可复用面向对象软件的基础》 一书是这样概述“装饰器”模式的:“动态地给一个对象添加一些额外的职责。” 函数装饰器符合这一说法。但是,在实现层面,Python 装饰器与《设计模式:可复用面向对象软件的基础》中所述的“装饰器”没有多少相似之处。

上面示例中实现的 clock 装饰器有几个缺点:不支持关键字参数,而且遮盖了被装饰函数的 namedoc 属性。

上述示例源代码 ------> clock装饰器

下面示例functools.wraps 装饰器把相关的属性从 func 复制到 clocked 中。 此外,这个新版还能正确处理关键字参数。

改进后的 clock 装饰器:

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

functools.wraps 只是标准库中拿来即用的装饰器之一。

上述示例源代码 ------> 改进版clock装饰器

下一节将介绍 functools 模块中最让人印象深刻的两个装饰器:lru_cache 和 singledispatch。

7.8 标准库中的装饰器

一个常见的装饰器是 functools.wraps,它的作用是协助构建行为良好的装饰器。 我们在之前用过。标准库中最值得关注的两个装饰器是 lru_cache 和全新的 singledispatch(Python 3.4 新增)。 这两个装饰器都在 functools 模块中定义。

使用functools.lru_cache做备忘

functools.lru_cache 是非常实用的装饰器,它实现了备忘 (memoization)功能。 这是一项优化技术,它把耗时的函数的结果保存起来,避免传入相同的参数时重复计算。 LRU 三个字母是“Least Recently Used”的缩写,表明缓存不会无限制增长,一段时间不用的缓存条目会被扔掉。 生成第 n 个斐波纳契数这种慢速递归函数适合使用 lru_cache,如下示例所示。 生成第 n 个斐波纳契数,递归方式非常耗时:

@clock  # 使用之前的计时装饰器
def fibonacci(n):
    if n < 2: 
        return n
    return fibonacci(n - 2) + fibonacci(n - 1)

if __name__ == '__main__':
    print(fibonacci(6))

运行得到的结果如下。除了最后一行,其余输出都是 clock 装饰器生成的。

[0.00000119s] fibonacci(0) -> 0 
[0.00000000s] fibonacci(1) -> 1 
[0.00004792s] fibonacci(2) -> 1 
[0.00000000s] fibonacci(1) -> 1 
[0.00000000s] fibonacci(0) -> 0 
[0.00000000s] fibonacci(1) -> 1 
[0.00001001s] fibonacci(2) -> 1 
[0.00001788s] fibonacci(3) -> 2 
[0.00007606s] fibonacci(4) -> 3 
[0.00000000s] fibonacci(1) -> 1 
[0.00000000s] fibonacci(0) -> 0 
[0.00000000s] fibonacci(1) -> 1 
[0.00000882s] fibonacci(2) -> 1 
[0.00001788s] fibonacci(3) -> 2 
[0.00000000s] fibonacci(0) -> 0 
[0.00000000s] fibonacci(1) -> 1 
[0.00001073s] fibonacci(2) -> 1 
[0.00000000s] fibonacci(1) -> 1 
[0.00000000s] fibonacci(0) -> 0 
[0.00000000s] fibonacci(1) -> 1 
[0.00000906s] fibonacci(2) -> 1 
[0.00001717s] fibonacci(3) -> 2 
[0.00003576s] fibonacci(4) -> 3 
[0.00006199s] fibonacci(5) -> 5 
[0.00014687s] fibonacci(6) -> 8 
8

浪费时间的地方很明显:fibonacci(1) 调用了 8 次,fibonacci(2) 调用了 5 次......但是, 如果增加两行代码,使用 lru_cache,性能会显著改善,如下: 使用缓存实现,速度更快:

@functools.lru_cache()  # 必须像常规函数那样调用 lru_cache。这一行中有一对括 号:@functools.lru_cache()。这么做的原因是,lru_cache 可以接受配置参数,稍后说明。
@clock  # 这里叠放了装饰器:@lru_cache() 应用到 @clock 返回的函数上。(离函数近的先起作用)
def fibonacci(n):
    if n < 2:
        return n
    return fibonacci(n - 2) + fibonacci(n - 1)

if __name__ == '__main__':
    print(fibonacci(6))

这样一来,执行时间减半了,而且 n 的每个值只调用一次函数:

[0.00000000s] fibonacci(0) -> 0 
[0.00000119s] fibonacci(1) -> 1 
[0.00005198s] fibonacci(2) -> 1 
[0.00000119s] fibonacci(3) -> 2 
[0.00006199s] fibonacci(4) -> 3 
[0.00000000s] fibonacci(5) -> 5 
[0.00007319s] fibonacci(6) -> 8 
8

在计算 fibonacci(30) 的另一个测试中,上面的示例中的版本在 0.0005 秒内调用了 31 次 fibonacci 函数, 而再之前未缓存的版本调用 fibonacci 函数 2692537 次,在使用 Intel Core i7 处理器的笔记本电脑中耗时 17.7 秒。

除了优化递归算法之外,lru_cache在从Web中获取信息的应用中也能发挥巨大作用。 特别要注意,lru_cache 可以使用两个可选的参数来配置。它的签名是:

functools.lru_cache(maxsize=128, typed=False)

maxsize 参数指定存储多少个调用的结果。缓存满了之后,旧的结果会被扔掉,腾出空间。 为了得到最佳性能,maxsize 应该设为** 2 的幂**。typed 参数如果设为 True,把不同参数类型得到的结果分开保存, 即把通常认为相等的浮点数和整数参数(如 1 和 1.0)区分开。

顺便说一下,因为 lru_cache 使用字典存储结果,而且键根据调用时传入的定位参数和关键字参数创建, 所以被 lru_cache 装饰的函数,它的所有参数都必须是可散列的。

上述示例源代码 ------> functools.lru

接下来讨论吸引人的 functools.singledispatch 装饰器。

单分派泛函数

假设我们在开发一个调试 Web 应用的工具,我们想生成HTML,显示不同类型的 Python 对象。 我们可能会编写这样的函数:

import html

def htmlize(obj):
    content = html.escape(repr(obj))
    return '<pre>{}</pre>'.format(content)

这个函数适用于任何 Python 类型,但是现在我们想做个扩展,让它使用特别的方式显示某些类型。 str:把内部的换行符替换为 '
\n';不使用

,而是使用 

。 int:以十进制和十六进制显示数字。 list:输出一个 HTML 列表,根据各个元素的类型进行格式化。

...... ..... ...

7.9 叠放装饰器

把 @d1 和 @d2 两个装饰器按顺序应用到 f 函数上,作用相当于 f = d1(d2(f))。

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

def f(): 
    print('f')
f = d1(d2(f))

7.10 参数化装饰器

解析源码中的装饰器时,Python 把被装饰的函数作为第一个参数传给装器函数。那怎么让装饰器接受其他参数呢? 答案是:创建一个装饰器工厂函数,把参数传给它,返回一个装饰器,然后再把它应用到要装饰的函数上。 不明白什么意思?当然。下面以我们见过的最简单的装饰器为例说明: 是之前registration模块的删减版,这里再次给出是为了便于讲解

registry = []

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

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

print('running main()')
print('registry ->', registry)
f1()

上述示例源代码 ------> register

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

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

def register(active=True):  # register 接受一个可选的关键字参数。
    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 也必须作为函数调用(@register()),即要返回真正的装饰器 decorate。
def f2():
    print('running f2()')

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

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

如果导入这个模块会得到:

running register(active=False) -> decorate(<function f1 at 0x1065a5790>)
running register(active=True) -> decorate(<function f2 at 0x1065a5940>)
{<function f2 at 0x1065a5940>}

注意,只有 f2 函数在 registry 中;f1 不在其中,因为传给 register 装饰器工厂函数的参数是 active=False, 所以应用到 f1 上的 decorate 没有把它添加到 registry 中。 如果不使用 @ 句法,那就要像常规函数那样使用 register,因为register()返回decorate所以 若想把 f 添加到 registry 中,则装饰 f 函数的句法是 register()(f);不想 添加(或把它删除)的话,句法是 register(active=False)(f)。 下面示例演示了如何把函数添加到 registry 中,以及如何从中删除函数。

print(registry)
register()(f3)
print(registry)
register(active=False)(f2)
print(registry)

输出:

{<function f2 at 0x10878d940>}
running register(active=True) -> decorate(<function f3 at 0x10878d8b0>)
{<function f3 at 0x10878d8b0>, <function f2 at 0x10878d940>}
running register(active=False) -> decorate(<function f2 at 0x10878d940>)
{<function f3 at 0x10878d8b0>}

参数化装饰器的原理相当复杂,我们刚刚讨论的那个比大多数都简单。 参数化装饰器通常会把被装饰的函数替换掉,而且结构上需要多一层嵌套。

上述示例源代码 ------> register参数化改进

参数化clock

再次探讨 clock 装饰器,为它添加一个功能:让用户传入一个格式字符串,控制被装饰函数的输出。 用最初实现的那一版改进,-------->clock

import time

DEFAULT_FMT = '[{elapsed:0.8f}s] {name}({args}) -> {result}'

def clock(fmt=DEFAULT_FMT):  # clock 是参数化装饰器工厂函数。
    def decorate(func):  # decorate 是真正的装饰器。
        def clocked(*_args):  # clocked 包装被装饰的函数。
            t0 = time.time()
            _result = func(*_args)  # _result 是被装饰的函数返回的真正结果。
            elapsed = time.time() - t0
            name = func.__name__
            args = ', '.join(repr(arg) for arg in _args)  # _args 是 clocked 的参数,args 是用于显示的字符串。
            result = repr(_result)  # result 是 _result 的字符串表示形式,用于显示。
            print(fmt.format(**locals()))  # 这里使用 **locals() 是为了在 fmt 中引用 clocked 的局部变量。
            return _result  # clocked 会取代被装饰的函数,因此它应该返回被装饰的函数返回的值。

        return clocked  # decorate 返回 clocked。

    return decorate  # clock 返回 decorate。

if __name__ == '__main__':
    @clock()    # 在这个模块中测试,不传入参数调用 clock(),因此应用的装饰器 使用默认的格式 str。
    def snooze(seconds):
        time.sleep(seconds)

    for i in range(3):
        snooze(.123)

输出:

[0.12371898s] snooze(0.123) -> None
[0.12395382s] snooze(0.123) -> None
[0.12615085s] snooze(0.123) -> None

下面尝传入别的模式

@clock('{name}: {elapsed}s')
    def snooze(seconds):
        time.sleep(seconds)

    for i in range(3):
        snooze(.123)

输出:

snooze: 0.1231088638305664s
snooze: 0.12540197372436523s
snooze: 0.12621402740478516s

再来一个例子:

@clock('{name}({args}) dt={elapsed:0.3f}s')
    def snooze(seconds):
        time.sleep(seconds)

    for i in range(3):
        snooze(.123)

输出:

snooze(0.123) dt=0.127s
snooze(0.123) dt=0.127s
snooze(0.123) dt=0.125s

延伸阅读中的资料讨 论了构建工业级装饰器的技术,尤其是 Graham Dumpleton 的博客和 wrapt 模块。

Graham Dumpleton 和 Lennart Regebro(本书的技术审校之一) 认为,装饰器最好通过实现 call 方法的类实现, 不应该像本章的示例那样通过函数实现。

我同意使用他们建议的方式实现非平凡的装饰器更好,但是使用函数解说这个语言特性的基本思想更易于理解。

延伸阅读

小总结:

若想真正理解装饰器,需要区分导入时和运行时,还要知道变量作用域、闭包和新增的 nonlocal 声明。 掌握闭包和 nonlocal 不仅对构建装饰器有帮助,还能协助你在构建 GUI 程序时面向事件编程,或者使用回调处理异步 I/O。

Graham Dumpleton 写了一系列博客文章深入剖析了如何实现行为良好的装饰器, 第一篇是How YouImplemented Your Python Decorator is Wrong

他在这方面的深厚知识充分体现在在他编写的wrapt 模块中。

这个模块的作用是简化装饰器和动态函数包装器的实现,即使多层装饰也支持内省,而且行为正确,既可以应用到方法上,也可以作为描述符使用。

Michele Simionato 开发了一个包,根据文档,它旨在“简化普通程序员使用装饰器的方式,并且通过各种复杂的示例推广装饰器”。 这个包是 decorator,可通过 PyPI 安装。

Fredrik Lundh 写的一篇短文“Closures in Python”解说了闭包这个术语。

“PEP 3104—Access to Names in Outer Scopes”说明了引入 nonlocal 声明的原因: 重新绑定既不在本地作用域中也不在全局作用域中的名称。 这份 PEP 还概述了其他动态语言(Perl、Ruby、JavaScript,等等)解决这个问题的方式,以及 Python 中可用设计方案的优缺点。

Python 装饰器和装饰器设计模式

Python 函数装饰器符合 Gamma 等人在《设计模式:可复用面向对象软件的基础》一书中对“装饰器”模式的一般描述: “动态地给一个对象添加一些额外的职责。就扩展功能而言,装饰器模式比子类化更灵活。”

在实现层面,Python 装饰器与“装饰器”设计模式不同,但是有些相似之处。 在设计模式中,Decorator 和 Component 是抽象类。为了给具体组件添加行为,具体装饰器的实例要包装具体组件的实例。

《设计模式:可复用面向对象软件的基础》一书是这样说的:

装饰器与它所装饰的组件接口一致,因此它对使用该组件的客户透明。 它将客户请求转发给该组件,并且可能在转发前后执行一些额外的操作(例如绘制一个边框)。

透明性使得你可以递归嵌套多个装饰器,从而可以添加任意多的功能:

在 Python 中,装饰器函数相当于 Decorator 的具体子类,而装饰器返回的内部函数相当于装饰器实例。 返回的函数包装了被装饰的函数,这相当于“装饰器”设计模式中的组件。返回的函数是透明的,因为它接受相同的参数,符合组件的接口。 返回的函数把调用转发给组件,可以在转发前后执行额外的操作。 因此,前面引用那段话的最后一句可以改成:“透明性使得你可以递归嵌套多个装饰器,从而可以添加任意多的行为。”这就是叠放装饰器的理论基础。

注意,我不是建议在 Python 程序中使用函数装饰器实现“装饰器”模式。 在特定情况下确实可以这么做,但是一般来说,实现“装饰器”模式时最好使用类表示装饰器和要包装的组件。