Python学习之路26-函数装饰器和闭包

《流畅的Python》笔记python

本篇将从最简单的装饰器开始,逐渐深刻到闭包的概念,而后实现参数化装饰器,最后介绍标准库中经常使用的装饰器。程序员

1. 初步认识装饰器

函数装饰器用于在源代码中“标记”函数,以某种方式加强函数的行为。装饰器就是函数,或者说是可调用对象,它以另外一个函数为参数,最后返回一个函数,但这个返回的函数并不必定是原函数。算法

1.1 装饰器基础用法

如下是装饰器最基本的用法:编程

# 代码1
#装饰器用法
@decorate
def target(): pass

# 上述代码等价于如下代码
def target(): pass
target = decorate(target)
复制代码

即,最终的target函数是由decorate(target)返回的函数。下面这个例子说明了这一点:缓存

# 代码2
def deco(func):
    def inner():
        print("running inner()")
    return inner

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

target()
print(target)

# 结果
running inner() # 输出的是装饰器内部定义的函数的调用结果
<function deco.<locals>.inner at 0x000001AF32547D90>
复制代码

从上面可看出,装饰器的一大特性是能把被装饰的函数替换成其余函数。但严格说来,装饰器只是语法糖(语法糖:在编程语言中添加某种语法,但这种语法对语言的功能没有影响,只是更方便程序员使用)。bash

装饰器还能够叠加。下面是一个说明,具体例子见后面章节:微信

# 代码3
@d1
@d2
def f(): pass

#上述代码等价于如下代码:
def f(): pass
f = d1(d2(f))
复制代码

1.2 Python什么时候执行装饰器

装饰器的另外一个关键特性是,它在被装饰的函数定义后当即运行,这一般是在导入时,即Python加载模块时:闭包

# 代码4
registry = []

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

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

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

if __name__ == "__main__":
    print("running in main")
    print("registry ->", registry)
    f1()
    f2()

# 结果
running register(<function f1 at 0x0000027745397840>)
running in main # 进入到主程序
registry -> [<function f1 at 0x0000027745397840>]
running f1()
running f2()
复制代码

装饰器register在加载模块时就对f1()进行了注册,因此当运行主程序时,列表registry并不为空。app

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

装饰器在真实代码中的使用方式与代码4中有所不一样:

  • 装饰器和被装饰函数通常不在一个模块中,一般装饰器定义在一个模块中,而后应用到其余模块中的函数上;
  • 大多数装饰器会在内部定义一个函数,而后将其返回。

代码4中的装饰器原封不动地返回了传入的函数。这种装饰器并非没有用,正如代码4中的装饰器的名字同样,这类装饰器常充当了注册器,不少Web框架就使用了这种方法。下一小节也是该类装饰器的一个例子。

1.3 使用装饰器改进策略模式

上一篇中咱们用Python函数改进了传统的策略模式,其中,咱们定义了一个promos列表来记录有哪些具体策略,当时的作法是用globals()函数来获取具体的策略函数,如今咱们用装饰器来改进这一作法:

# 代码5,对以前的代码进行了简略
promos = []

def promotion(promo_func): # 只充当了注册器
    promos.append(promo_func)
    return promo_func

@promotion
def fidelity(order): pass  

@promotion
def bulk_item(order): pass

@promotion
def large_order(order): pass

def best_promo(order):
    return max(promo(order) for promo in promos)
复制代码

该方案相比以前的方案,有如下三个优势:

  • 促销策略函数无需使用特殊名字,即不用再以_promo结尾
  • @promotion装饰器突出了被装饰函数的做用,还便于临时禁用某个促销策略(只需将装饰器注释掉)
  • 促销策略函数在任何地方定义都行,只要加上装饰器便可。

2. 闭包

正如前文所说,多数装饰器会在内部定义函数,并将其返回,已替换掉传入的函数。这个机制的实现就要靠闭包,但在理解闭包以前,先来看看Python中的变量做用域。

2.1 变量做用域规则

经过下述例子来解释局部变量和全局变量:

# 代码6
>>> def f1(a):
...     print(a)
...     print(b)
    
>>> f1(3)
3
Traceback (most recent call last):
  -- snip --
NameError: name 'b' is not defined
复制代码

当代码运行到print(a)时,Python查找变量a,发现变量a存在于局部做用域中,因而顺利执行;当运行到print(b)时,python查找变量b,发现局部做用域中并无变量b,便接着查找全局做用域,发现也没有变量b,最终报错。正确的调用方式相信你们也知道,就是在调用f1(3)以前给变量b赋值。

咱们再看以下代码:

# 代码7
>>> b = 6
>>> def f2(a):
...     print(a)
...     print(b)
...     b = 9
    
>>> f2(3)
3
Traceback (most recent call last):
  -- snip --
UnboundLocalError: local variable 'b' referenced before assignment
复制代码

按理说不该该报错,而且b的值应该打印为6,但结果却不是这样。

事实是:变量b原本是全局变量,但因为在f2()中咱们为变量b赋了值,因而Python在局部做用域中也注册了一个名为b的变量(全局变量b依然存在,有编程基础的同窗应该知道,这叫作“覆盖”)。当Python执行到print(b)语句时,Python先搜索局部做用域,发现其中有变量b,可是b此时尚未被赋值(全局变量b被覆盖,而局部变量b的赋值语句在该句后面),因而Python报错。

若是不想代码7报错,则须要使用global语句,将变量b声明为全局变量:

# 代码8
>>> b = 6
>>> def f2(a):
...     global b
...     -- snip --
复制代码

2.2 闭包的概念

如今开始真正接触闭包。闭包指延伸了做用域的函数,它包含函数定义体中引用,但不在定义体中定义的非全局变量,即这类函数能访问定义体以外的非全局变量。只有涉及嵌套函数时才有闭包问题。

下面用一个例子来讲明闭包以及非全局变量。定义一个计算某商品一段时间内均价的函数avg,它的表现以下:

# 代码9
>>> avg(10)
10.0
>>> avg(11)
10.5
>>> avg(12)
11.0
复制代码

假定商品价格天天都在变化,所以须要一个变量来保存这些值。若是用类的思想,咱们能够定义一个可调用对象,把这些值存到内部属性中,而后实现__call__方法,让其表现得像函数;但若是按装饰器的思想,能够定义一个以下的嵌套函数:

# 代码10
def make_averager():
    series = []

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

    return averager
复制代码

而后以以下方式使用这个函数:

# 代码11
>>> avg = make_averager()
>>> avg(10)
10.0
-- snip --
复制代码

不知道你们刚接触这个内部的averager()函数时有没有疑惑:代码11中,当执行avg(10)时,它是到哪里去找的变量seriesseries是函数make_averager()的局部变量,当make_averager()返回了averager()后,它的局部做用域就消失了,因此按理说series也应该跟着消失,而且上述代码应该报错才对。

事实上,在averager函数中,series自由变量(free variable),即未在局部做用域中绑定的变量。这里,自由变量series和内部函数averager共同组成了闭包,参考下图:

实际上,Python在averager__code__属性中保存了局部变量和自由变量的名称,在__closure__属性中保存了自由变量的值:

# 代码12,注意这些变量的单词含义,一目了然
>>> avg.__code__.co_varnames  # co_varnames保存局部变量的名称
('new_value', 'total')
>>> avg.__code__.co_freevars # co_freevars保存自由变量的名称
('series',)
>>> avg.__closure__ # 单词closure就是闭包的意思
# __closure__是一个cell对象列表,其中的元素和co_freevars元组一一对应
(<cell at 0x0000024EE023D7F8: list object at 0x0000024EDFE76288>,)
>>> avg.__closure__[0].cell_contents 
[10, 11, 12] # cell对象的cell_contents属性才是真正保存自由变量的值的地方
复制代码

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

注意:只有嵌套在其余函数中的函数才可能须要处理不在全局做用域中的外部变量。

2.3 nonlocal声明

代码10中的make_averager函数并不高效,由于若是只计算均值的话,其实不用保存每次的价格,咱们可按以下方式改写代码10

# 代码13
def make_averager():
    count = 0
    total = 0

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

    return averager
复制代码

但此时直接运行代码11的话,则会报代码7中的错误:UnboundLocalError

问题在于:因为count是不可变类型,在执行count += 1时,该语句等价于count = count + 1,而这就成了赋值语句,count再也不是自由变量,而变成了averager的局部变量。total也是同样的状况。而在以前的代码10中没有这个问题,由于series是个可变类型,咱们只是调用series.append,以及把它传给了sumlen,它并无变为局部变量。

**对于不可变类型来讲,只能读取,不能更新,不然会隐式建立局部变量。**为了解决这个问题,Python3引入了nonlocal声明。它的做用是把变量显式标记为自由变量:

# 代码14
def make_averager():
    count = 0
    total = 0

    def averager(new_value):
        nonlocal count, total
        -- snip --
复制代码

3. 装饰器

了解了闭包后,如今开始正式使用嵌套函数来实现装饰器。首先来认识标准库中三个重要的装饰器。

3.1 标准库中的装饰器

3.1.1 functools.wraps装饰器

来看一个简单的装饰器:

# 代码15
def deco(func):
    def test():
        func()
    return test

@deco
def Test():
    """This is a test"""
    print("This is a test")

print(Test.__name__)
print(Test.__doc__)

# 结果
test
None
复制代码

咱们想让装饰器来自动帮咱们作一些额外的操做,但像改变函数属性这样的操做并不必定是咱们想要的:从上面能够看出,Test如今指向了内部函数testTest自身的属性被遮盖。若是想保留函数本来的属性,可使用标准库中的functools.wraps装饰器。下面以一个更复杂的装饰器为例,它会在每次调用被装饰函数时计时,并将通过的时间,传入的参数和调用的结果打印出来:

# 代码16
# clockdeco.py
import time, 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
复制代码

它的使用将和下一个装饰器一块儿展现。

3.1.2 functools.lru_cache装饰器

functools.lru_cache实现了备忘(memoization)功能,这是一项优化技术,他把耗时的函数的结果保存起来,避免传入相同参数时重复计算。以斐波那契函数为例,咱们知道以递归形式实现的斐波那契函数会出现不少重复计算,此时,就可使用这个装饰器。如下代码是没使用该装饰器时的运行状况:

# 代码17
from clockdeco import clock

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

if __name__ == "__main__":
    print(fibonacci.__name__)
    print(fibonacci.__doc__)
    print(fibonacci(6))

# 结果:
fibonacci  # fibonacci本来的属性获得了保留
None
[0.00000000s] fibonacci(0) -> 0
[0.00000000s] fibonacci(1) -> 1
[0.00000000s] fibonacci(2) -> 1
[0.00000000s] fibonacci(1) -> 1
[0.00000000s] fibonacci(0) -> 0
[0.00000000s] fibonacci(1) -> 1
[0.00000000s] fibonacci(2) -> 1
[0.00000000s] fibonacci(3) -> 2
[0.00000000s] fibonacci(4) -> 3
[0.00000000s] fibonacci(1) -> 1
[0.00000000s] fibonacci(0) -> 0
[0.00000000s] fibonacci(1) -> 1
[0.00000000s] fibonacci(2) -> 1
[0.00000000s] fibonacci(3) -> 2
[0.00000000s] fibonacci(0) -> 0
[0.00000000s] fibonacci(1) -> 1
[0.00000000s] fibonacci(2) -> 1
[0.00000000s] fibonacci(1) -> 1
[0.00000000s] fibonacci(0) -> 0
[0.00000000s] fibonacci(1) -> 1
[0.00049996s] fibonacci(2) -> 1
[0.00049996s] fibonacci(3) -> 2
[0.00049996s] fibonacci(4) -> 3
[0.00049996s] fibonacci(5) -> 5
[0.00049996s] fibonacci(6) -> 8
8
复制代码

能够看出,fibonacci(1)调用了8次,下面咱们用functools.lru_cache来改进上述代码:

# 代码18
import functools
from clockdeco import clock

@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.00000000s] fibonacci(0) -> 0
[0.00000000s] fibonacci(1) -> 1
[0.00000000s] fibonacci(2) -> 1
[0.00000000s] fibonacci(3) -> 2
[0.00000000s] fibonacci(4) -> 3
[0.00000000s] fibonacci(5) -> 5
[0.00000000s] fibonacci(6) -> 8
8
复制代码

functools.lru_cache装饰器能够接受参数,而且此代码还叠放了装饰器。

lru_cache有两个参数:functools.lru_cache(maxsize=128, typed=False)

  • maxsize指定存储多少个调用的结果,该参数最好是2的幂。当缓存满后,根据LRU算法替换缓存中的内容,这也是为何这个函数叫lru_cache
  • type若是设置为True,它将把不一样参数类型下获得的结果分开保存,即把一般认为相等的浮点数和整数参数分开(好比区分1和1.0)。
  • lru_cache使用字典存储结果,字典的键是传入的参数,因此被lru_cache装饰的函数的全部参数都必须是可散列的!

3.1.3 functools.singledispatch装饰器

咱们知道,C++支持函数重载,同名函数能够根据参数类型的不一样而调用相应的函数。以Python代码为例,咱们但愿下面这个函数表现出以下行为:

# 代码19
def myprint(obj):
    return "Hello~~~"

# 如下是咱们但愿它拥有的行为:
>>> myprint(1)
Hello~~~
>>> myprint([])
Hello~~~
>>> myprint("hello") # 即,当咱们传入特定类型的参数时,函数返回特定的结果
This is a str
复制代码

单凭这一个myprint还没法实现上述要求,由于Python不支持方法或函数的重载。为了实现相似的功能,一种常见的作法是将函数变为一个分派函数,使用一串if/elif/elif来判断参数类型,再调用专门的函数(如myprint_str),但这种方式不利于代码的扩展和维护,还显得没有B格。。。

为解决这个问题,从Python3.4开始,可使用functools.singledispath装饰器,把总体方案拆分红多个模块,甚至能够为没法修改的类提供专门的函数。被@singledispatch装饰的函数会变成泛函数(generic function),它会根据第一个参数的不一样而调用响应的专门函数,具体用法以下:

# 代码20
from functools import singledispatch
import numbers

@singledispatch
def myprint(obj):
    return "Hello~~~"

# 能够叠放多个register,让同一函数支持不一样类型
@myprint.register(str)
# 注册的专门函数最好处理抽象基类,而不是具体实现,这样代码支持的兼容类型更普遍
@myprint.register(numbers.Integral) 
def _(text): # 专门函数的名称无所谓,使用 _ 能够避免起名字的麻烦
    return "Special types"
复制代码

对泛函数的补充:根据参数类型的不一样,以不一样方式执行相同操做的一组函数。若是依据是第一个参数,则是单分派;若是依据是多个参数,则是多分派。

3.2 参数化装饰器

3.2.1 简单版参数化装饰器

从上面诸多例子咱们能够看到两大类装饰器:不带参数的装饰器(调用时最后没有括号)和带参数的装饰器(带括号)。Python将被装饰的函数做为第一个参数传给了装饰器函数,那装饰器函数如何接受其余参数呢?作法是:建立一个装饰器工厂函数,在这个工厂函数内部再定义其它函数做为真正的装饰器。工厂函数代为接受参数,这些参数做为自由变量供装饰器使用。而后工厂函数返回装饰器,装饰器再应用到被装饰函数上。

咱们把1.2中代码4@register装饰器改成带参数的版本,以active参数来指示装饰器是否注册某函数(虽然这么作有点多余)。这里只给出@register装饰器的实现,其他代码参考代码4

# 代码21
registry = set()

def register(active=True):
    def decorate(func): # 变量active对于decorate函数来讲是自由变量
        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) # 即便不传参数也要做为函数调用@register()
def f():pass

# 上述用法至关于以下代码:
# register(active=False)(f)
复制代码

3.2.2 多层嵌套版参数化装饰器

参数化装饰器一般会把被装饰函数替换掉,并且结构上须要多一层嵌套。下面以3.1.1中代码16里的@clock装饰器为例,让它按用户要求的格式输出数据。为了简便,不调用functools.wraps装饰器:

# 代码22
import time

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

def clock(fmt=DEFAULT_FMT):   # 装饰器工厂,fmt是装饰器的参数
    def decorate(func):       # 装饰器
        def clocked(*_args):  # 最终的函数
            t0 = time.time()
            _result = func(*_args)
            elapsed = time.time() - t0
            name = func.__name__
            args = ", ".join(repr(arg) for arg in _args)
            result = repr(_result)
            print(fmt.format(**locals())) #locals()函数以字典形式返回clocked的局部变量
            return _result
        return clocked
    return decorate
复制代码

能够获得以下结论:装饰器函数有且只有一个参数,即被装饰器的函数;若是装饰器要接受其余参数,请在本来的装饰器外再套一层函数(工厂函数),由它来接受其他参数;而你最终使用的函数应该定义在装饰器函数中,且它的参数列表应该和被装饰的函数一致。

4. 总结

本篇首先介绍了最简单装饰器如何定义和使用,介绍了装饰器在何时被执行,以及用最简单的装饰器改造了上一篇的策略模式;随后更进一步,介绍了与闭包相关的概念,包括变量做用域,闭包和nonlocal声明;最后介绍了更复杂的装饰器,包括标准库中的装饰器的用法,以及如何定义带参数的装饰器。

但上述对装饰器的描述都是基本的, 更复杂、工业级的装饰器还须要更深刻的学习。


迎你们关注个人微信公众号"代码港" & 我的网站 www.vpointer.net ~

相关文章
相关标签/搜索