《流畅的Python》读书笔记——Python函数装饰器和闭包

引言

函数装饰器(不是设计模式中的装饰器模式)用于在源码中“标记”函数,以某种方式增强函数的行为。这是一项强大的功能,但是若想掌握,必须理解闭包。

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

装饰器基础知识

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

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

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

def target()
	print('running target()')

target = decorate(target)

上述两个代码片段执行完毕后得到的target 不一定是原来那个 target 函数,而是 decorate(target)返回的函数。

装饰器通常把函数替换成另一个函数:

In [1]: def deco(func): 
   ...:     def inner(): 
   ...:         print('running inner()') 
   ...:     return inner  #deco返回inner函数对象
   ...:                                                                         

In [2]: @deco  #使用deco装饰target
   ...: def target(): 
   ...:     print('running target()') 
   ...:                                                                         

In [3]: target()    #其实会运行inner                                                            
running inner()

In [4]: target  #target是inner的引用                                                                
Out[4]: <function __main__.deco.<locals>.inner()>

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

Python何时执行装饰器

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

#registration.py
# -*- coding: utf-8 -*
registry = [] #保存被 @register 装饰的函数引用
def register(func): #参数func是一个函数
    print('running register(%s)' % func) #打印被装饰的函数
    registry.append(func)#存入registry
    return func #必须返回函数,这里返回的就是被装饰的函数

#f1和f2被装饰
@register
def f1():
    print('running f1()')

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

#f3没有被装饰
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 0x0000015C8077E730>) #register在main运行之前运行
running register(<function f2 at 0x0000015C8077E7B8>)
running main()
registry -> [<function f1 at 0x0000015C8077E730>, <function f2 at 0x0000015C8077E7B8>]
running f1()
running f2()
running f3()

如果导入registration.py模块,输出如下:

>>> import registration
running register(<function f1 at 0x0000022BB52DE840>)
running register(<function f2 at 0x0000022BB52DE8C8>)

证明了函数装饰器在导入模块时立即执行,而被装饰的函数只在明确调用时运行。

扫描二维码关注公众号,回复: 8919260 查看本文章

使用装饰器改进策略模式

from collections import namedtuple

Customer = namedtuple('Customer','name fidelity')

class LineItem:
    def __init__(self,product,quantity,price):
        self._product = product
        self._quantity = quantity
        self._price = price

    def total(self):
        return self._price * self._quantity

class Order: #上下文,也就是客户端代码
    def __init__(self,customer,cart,promotion = None):
        self._customer = customer
        self._cart = list(cart)
        self._promotion = promotion

    def total(self):
        if not hasattr(self, '__total'):
            self.__total = sum(item.total() for item in self._cart)
        return self.__total

    def due(self):
        """折扣"""
        if self._promotion is None:
            discount = 0
        else:
            discount = self._promotion(self) #只需调用self.promotion()函数
        return self.total() - discount

    def __repr__(self):
        fmt = '<Order total: {:.2f} due: {:.2f}>'
        return fmt.format(self.total(), self.due())

promos = [] #列表初始为空

def promotion(promo_func):
    promos.append(promo_func) #添加到promos列表
    return promo_func#原封不动将其返回

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

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

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

def best_promo(order):
    """选择可用的最佳折扣"""
    return max(promo(order) for promo in promos)

Python使用一等函数实现设计模式给出的方案相比,该方案有几个优点:

  • 促销函数无需使用特殊的名称
  • @promotion装饰器突出了被装饰的函数的作用,便于临时禁用某个促销策略:只需把装饰器注释掉
  • 促销折扣策略可以在其他模块中定义,只要使用@promotion装饰即可

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

变量作用域规则

一个函数,读取一个局部变量和一个全局变量:

In [1]: def f1(a): 
   ...:     print(a) 
   ...:     print(b) 
   ...:                                                                         

In [2]: f1(3)                                                                   
3
---------------------------------------------------------------------------
NameError                                 Traceback (most recent call last)
<ipython-input-2-db0f80b394ed> in <module>
----> 1 f1(3)

<ipython-input-1-c1318c6d0711> in f1(a)
      1 def f1(a):
      2     print(a)
----> 3     print(b)
      4 

NameError: name 'b' is not defined

如果先给全局变量 b 赋值,然后再调用 f,那就不会出错:

In [3]: b = 6                                                                   
In [5]: f1(3)  

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


In [8]: b = 6                                                                   
In [9]: def f2(a): 
   ...:     print(a) 
   ...:     print(b) 
   ...:     b = 9 
   ...:                                                                         
In [10]: f2(3)                                                                  
3
---------------------------------------------------------------------------
UnboundLocalError                         Traceback (most recent call last)
<ipython-input-10-ddde86392cb4> in <module>
----> 1 f2(3)

<ipython-input-9-2304a03d7bfd> in f2(a)
      1 def f2(a):
      2     print(a)
----> 3     print(b)
      4     b = 9
      5 

UnboundLocalError: local variable 'b' referenced before assignment

从上面的报错信息可知,是在print(b)处报的错,是不是很吃惊,我也以为会打印6,因为有个全局变量b。

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

这不是缺陷,而是设计选择:Python 不要求声明变量,但是假定在函数
定义体中赋值的变量是局部变量。

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

In [11]: b = 6                                                                  
In [12]: def f3(a): 
    ...:     global b  #使用global 声明
    ...:     print(a) 
    ...:     print(b) 
    ...:     b = 9 
    ...:                                                                        

In [13]: f3(3)                                                                  
3
6
In [14]: b                                                                      
Out[14]: 9
In [16]: f3(3)                                                                  
3
9
In [17]: b = 30                                                                 
In [18]: b                                                                      
Out[18]: 30

闭包

闭包指延伸了作用域的函数,其中包含函数定义体中引用、但是
不在定义体中定义的非全局变量。

假如有个名为 avg 的函数,它的作用是计算不断增加的系列值的均
值;例如,整个历史中某个商品的平均收盘价。每天都会增加新价格,
因此平均值要考虑至目前为止所有的价格。

起初,avg 是这样使用的:

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

avg 从何而来,它又在哪里保存历史值呢?
我们通过函数式来实现:

# -*- coding: utf-8 -*
def make_averager():
    series = []#历史值

    def averager(new_value):
        series.append(new_value)
        total = sum(series)
        return total/len(series)

    return averager

if __name__ == '__main__':
    avg = make_averager()
    print(avg(10))
    print(avg(11))
    print(avg(12))

输出为:

10.0
10.5
11.0

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

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

在这里插入图片描述
averager 的闭包延伸到那个函数的作用域之外,包含自由变量series 的绑定。

print(avg.__code__.co_varnames) #('new_value', 'total')
print(avg.__code__.co_freevars) #('series',)
print(avg.__closure__) #(<cell at 0x00000261E22D8498: list object at 0x00000261E2371308>,)
print(avg.__closure__[0].cell_contents) #[10, 11, 12],就是series保存的值

avg.__closure__中的各个元素对应于avg.__code__.co_freevars 中的一个名称。这些元素是 cell 对象,有个 cell_contents 属性,保存着真正的值。

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

只有嵌套在其他函数中的函数才可能需要处理不在全局作用域中的外部变量。

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

当我敲完这些代码后,编译器有提示报错,我尝试运行一下,看是什么错误:

UnboundLocalError: local variable 'count' referenced before assignment

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

在之前的示例没遇到这个问题,因为我们没有给series赋值,只是调用了append方法。也就是说,利用了列表时可变的对象这一事实。

也就是说,重新绑定(赋值)会隐式创建局部变量,就不是自由变量了,因此不会保存在闭包中。

Python 3 引入了 nonlocal 声明。它的作用是把变量标记为自由变量,即使在函数中为变量赋予新值了,也会变成自由变量。

def make_averager():
    count = 0
    total = 0

    def averager(new_value):
        nonlocal  count,total #和之前global的用法类似
        count += 1
        total += new_value
        return total/count

    return averager

实现一个简单的装饰器

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

# -*- coding: utf-8 -*
import time

def clock(func):
    def clocked(*args): #接受任意个定位参数
        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
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.12056875s] snooze(0.123) -> None
**************************************** Calling factorial(6)
[0.00000079s] factorial(1) -> 1
[0.00002686s] factorial(2) -> 2
[0.00003832s] factorial(3) -> 6
[0.00004899s] factorial(4) -> 24
[0.00005926s] factorial(5) -> 120
[0.00007111s] factorial(6) -> 720
6! = 720

如下代码:

@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,然后,clock函数会返回clocked函数,python在背后会把clocked赋值给factorial。可以查看factorial__name__属性:

print(factorial.__name__) #clocked

现在factorial保存的是clocked函数的引用。

每次调用factorial(n),执行的都是clocked(n)clocked 大致
做了下面几件事。

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

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

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

import functools

def clock(func):
    @functools.wraps(func)
    def clocked(*args,**kwargs): #接受任意个定位参数和关键字参数
        t0 = time.time()
        result = func(*args,**kwargs)#clocked的闭包中包含自由变量func
        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只是标准库中拿来即用的装饰器之一。

标准库中的装饰器

标准库中最值得关注的两个装饰器是 lru_cache 和全新的singledispatch(Python 3.4 新增)。这两个装饰器都在 functools模块中定义。

使用functools.lru_cache做备忘

functools.lru_cache 是非常实用的装饰器,它实现了备忘
(memoization)功能。把耗时的函数的结果保存
起来,避免传入相同的参数时重复计算。LRU 三个字母是“Least
Recently Used”的缩写,表明缓存不会无限制增长,一段时间不用的缓存
条目会被扔掉。

生成第 n 个斐波纳契数这种慢速递归函数适合使用 lru_cache:

from clock_deco import clock

@clock
def fibonacci(n):
    if n < 2:
        return n
    return fibonacci(n-2) + fibonacci(n-1)

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

输出:

[0.00000119s] fibonacci(0) -> 0
[0.00000079s] fibonacci(1) -> 1
[0.00008020s] fibonacci(2) -> 1
[0.00000040s] fibonacci(1) -> 1
[0.00000040s] fibonacci(0) -> 0
[0.00000040s] fibonacci(1) -> 1
[0.00002252s] fibonacci(2) -> 1
[0.00004425s] fibonacci(3) -> 2
[0.00014736s] fibonacci(4) -> 3
[0.00000040s] fibonacci(1) -> 1
[0.00000040s] fibonacci(0) -> 0
[0.00000079s] fibonacci(1) -> 1
[0.00002094s] fibonacci(2) -> 1
[0.00004227s] fibonacci(3) -> 2
[0.00000040s] fibonacci(0) -> 0
[0.00000040s] fibonacci(1) -> 1
[0.00002094s] fibonacci(2) -> 1
[0.00000040s] fibonacci(1) -> 1
[0.00000040s] fibonacci(0) -> 0
[0.00000079s] fibonacci(1) -> 1
[0.00002291s] fibonacci(2) -> 1
[0.00004267s] fibonacci(3) -> 2
[0.00008889s] fibonacci(4) -> 3
[0.00015289s] fibonacci(5) -> 5
[0.00032118s] fibonacci(6) -> 8
8

浪费时间的地方很明显:fibonacci(1)调用了 8次,fibonacci(2)调用了 5 次……但是,如果增加两行代码,使用lru_cache,性能会显著改善:

from clock_deco import clock
import functools

@functools.lru_cache() #像常规函数那样调用,因为可以接收参数
@clock
def fibonacci(n):
    if n < 2:
        return n
    return fibonacci(n-2) + fibonacci(n-1)

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

输出:

[0.00000040s] fibonacci(0) -> 0
[0.00000040s] fibonacci(1) -> 1
[0.00008573s] fibonacci(2) -> 1
[0.00000079s] fibonacci(3) -> 2
[0.00010509s] fibonacci(4) -> 3
[0.00000079s] fibonacci(5) -> 5
[0.00012168s] fibonacci(6) -> 8
8

每个值只调用了一次,执行时间优化了一半多。

lru_cache 可以使用两个可选的参数来配置。它的签名是:
functools.lru_cache(maxsize=128, typed=False)

  • maxsize 知道缓存多少个调用结果
  • typed 参数如果设为 True,把不同参数类型得到的结果分开保存,即把通常认为相等的浮点数和整数参数(如 1 和 1.0)区分开。

单分派泛函数

Python 3.4 新增的functools.singledispatch 装饰器可以把整体
方案拆分成多个模块,甚至可以为你无法修改的类提供专门的函数。使
@singledispatch 装饰的普通函数会变成泛函数(generic function):根据第一个参数的类型,以不同方式执行相同操作的一组函数。

singledispatch 创建一个自定义的htmlize.register 装饰器,把多个函数绑在一起组成一个泛函数:

from functools import singledispatch
from collections import abc
import numbers
import html

@singledispatch #@singledispatch 标记处理 object 类型的基函数。
def htmlize(obj):
	content = html.escape(repr(obj))
	return '<pre>{}</pre>'.format(content)
	
@htmlize.register(str) #各个专门函数使用@«base_function».register(«type») 装饰
def _(text): #专门函数的名称无关紧要
	content = html.escape(text).replace('\n', '<br>\n')
	return '<p>{0}</p>'.format(content)
	
@htmlize.register(numbers.Integral) #为每个需要特殊处理的类型注册一个函数。numbers.Integral 是int 的虚拟超类
def _(n):
	return '<pre>{0} (0x{0:x})</pre>'.format(n)
@htmlize.register(tuple) #可以叠放多个 register 装饰器,让同一个函数支持不同类型
@htmlize.register(abc.MutableSequence)
def _(seq):
inner = '</li>\n<li>'.join(htmlize(item) for item in seq)
return '<ul>\n<li>' + inner + '</li>\n</ul>'

只要可能,注册的专门函数应该处理抽象基类(如
numbers.Integralabc.MutableSequence),不要处理具体
实现(如 intlist)。这样,代码支持的兼容类型更广泛。

装饰器是函数,因此可以组合起来使用(即,可以在已经被装饰的函数上应用装饰器)

叠放装饰器

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

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

等同于:

def f():
	print('f')
f = d1(d2(f)) #注意顺序

参数化装饰器

如何让装饰器接受其他参数呢?答案是:创建一个装饰器
工厂函数,把参数传给它,返回一个装饰器,然后再把它应用到要装饰
的函数上。

我们用registration.py模块的删减版为例:

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 执行的函数注册功能,我们为它提供
一个可选的 active 参数,设为 False 时,不注册被装饰的函数:

# -*- coding: utf-8 -*
registry = set() #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)#存入registry
        else:
            registry.discard(func) #实现反向注册(注销)功能
        return func #decorate 是装饰器,必须返回一个函数
    return decorate#register 是装饰器工厂函数,返回 decorate

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

#哪怕不传参数,也要作为函数调用
@register()
def f2():
    print('running f2()')

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

从概念上看,这个新的 register 函数不是装
饰器,而是装饰器工厂函数。调用它会返回真正的装饰器,这才是应用
到目标函数上的装饰器。

如果导入上面的模块,得到的结果如下:

>>> import registration
running register(active=False)->decorate(<function f1 at 0x000001CEA7FBE8C8>)
running register(active=True)->decorate(<function f2 at 0x000001CEA7FBE950>)
>>>registration.registry
{<function f2 at 0x0000026C7B43E950>}

如果不使用 @句法,那就要像常规函数那样使用 register;若想把 f
添加到 registry中,则装饰 f 函数的句法是register()(f);不
想添加(或把它删除)的话,句法是 register(active=False)(f)
下面演示了如何把函数添加到 registry 中,以及如何从中删除函数:

>>> from registration import *
running register(active=False)->decorate(<function f1 at 0x0000028C925CE8C8>)
running register(active=True)->decorate(<function f2 at 0x0000028C925CE950>)
>>> registry #导入模块时,只有f2在registry中
{<function f2 at 0x0000028C925CE950>}
>>> register()(f3) #register() 表达式返回 decorate,然后把它应用到 f3 上
running register(active=True)->decorate(<function f3 at 0x0000028C925CE840>)
<function f3 at 0x0000028C925CE840>
>>> registry#上面把f3注册到了registry中
{<function f3 at 0x0000028C925CE840>, <function f2 at 0x0000028C925CE950>}
>>> register(active=False)(f2)#将f2删除
running register(active=False)->decorate(<function f2 at 0x0000028C925CE950>)
<function f2 at 0x0000028C925CE950>
>>> registry#确认f2已经删除
{<function f3 at 0x0000028C925CE840>}
发布了131 篇原创文章 · 获赞 38 · 访问量 12万+

猜你喜欢

转载自blog.csdn.net/yjw123456/article/details/97887612