装饰器是Python中的一个高阶概念,装饰器是可调用的对象,其参数是另外一个函数。装饰器可能会处理被装饰的函数然后把它返回,或者将其替换成另外一个函数或者可调用对象。
这么介绍装饰器确实很难懂,还是以例子逐步理解更容易些。
装饰器的强大在于它能够在不修改原有业务逻辑的情况下对代码进行扩展,常见的应用场景有:权限校验、用户认证、日志记录、性能测试、事务处理、缓存等。
下面记录一下我逐步理解装饰器的过程。
一等函数
在Python中,函数是一等对象,也就是说函数是满足以下条件的程序实体:
- 在运行时创建
- 能赋值给变量或者数据结构中的元素
- 能作为参数传给函数
- 能作为函数的返回结果
下面先看一个简单函数的定义
1 | def hello(): |
python解释器遇到这段代码的时候,发生了两件事:
- 编译代码生成一个函数对象
- 将名为”hello”的名字绑定到这个函数对象上
Python中函数是一等对象,也就是说函数可以像int、string、float对象一样作为参数、或者作为返回值等进行传递。
函数作为参数
1 | def foo(bar): |
函数 call_foo_with_arg
接收两个参数,其中一个是可被调用的函数对象 foo
嵌套函数
函数也可以定义在另外一个函数中,作为嵌套函数
1 | def parent(): |
first_child
和 second_child
函数是嵌套在 parent
函数中的函数。
当调用 parent
函数时,内嵌的first_child
和 second_child
函数也被调用,但是如果在 parent
函数中并不是调用first_child
和 second_child
函数, 而是返回这两个函数对象呢?
下面歪个楼,先介绍一下Python的变量作用域的规则。
变量作用域
Python是动态语言,Python的变量名解析机制有时称为LEGB法则,当在函数中使用未认证的变量名时,Python搜索4个作用域:
- local 函数内部作用域
- enclosing 函数内部与内嵌函数之间
- global 全局作用域
- build-in 内置作用域
1 | def f1(a): |
这段程序会抛出错误:”NameError: name ‘b’ is not defined”,这是因为在函数体内,Python编译器搜索上面 LEGB 的变量,没有找到。
再下面一个例子:
1 | b = 6 |
在Python中,Python不要求声明变量,但是假定在函数体中被赋值的变量是局部变量,所以在这个函数体中,变量b被判断成局部变量,所以在print(b)调用时会抛出 “UnboundLocalError: local variable ‘b’ referenced before assignment” 的错误。
要想上面的代码运行,就必须手动在函数体内声明变量b为全局变量
1 | b = 6 |
闭包
有了上面的背景知识,下面就可以介绍闭包了。闭包是指延伸了作用域的函数。
要想理解这个概念还是挺难的,下面还是用例子来说明。
现在有个avg函数,用于计算不断增长的序列的平均值。
1 | def make_averager(): |
调用函数 make_averager
时候,返回一个 averager
函数对象,每次调用 averager
函数,会把参数添加到 series
中,然后计算当前平均值。
series
是 make_averager
函数的局部变量,调用 avg(10)
时, make_averager
函数已经返回,所以本地作用域也就不存在了。但是在 averager
函数中,series
是自由变量
这里的 avg
就是一个闭包,本质上它还是函数,闭包是引用了自由变量(series)的函数(averager)
nonlocal声明
刚才的例子稍稍改动一下,使用total和count来计算移动平均值
1 | def make_averager(): |
这时候会抛出错误 “UnboundLocalError: local variable ‘count’ referenced before assignment”。这是因为:当count为数字或者任何不可变类型时,在函数体定义中 count = count + 1
实际上是为count赋值,所以count就变成了局部变量。为了避免这个问题,python3引入了 nonlocal
声明,作用是把变量标记成 自由变量
1 | def make_averager(): |
装饰器
了解了闭包之后,下面就可以用嵌套函数实现装饰器了。事实上,装饰器就是一种闭包的应用,只不过传递的是函数。
无参数装饰器
下面写一个简单的装饰器的例子
1 | def makebold(fn): |
makeitalic
装饰器将函数 hello
传递给函数 makeitalic
,函数 makeitalic
执行完毕后返回被包装后的 hello 函数,而这个过程其实就是通过闭包实现的
装饰器有一个语法糖@,直接@my_new_decorator就把上面一坨代码轻松化解了,这就是Pythonic的代码,简洁高效,使用语法糖其实等价于下面显式使用闭包
1 | hello_bold = makebold(hello) |
装饰器是可以叠加使用的,对于Python中的”@”语法糖,装饰器的调用顺序与使用 @ 语法糖声明的顺序相反,上面案例中叠加装饰器相当于如下包装顺序:
1 | hello = makebold(makeitalic(hello)) |
被装饰的函数带参数
再来一个例子
1 | import time |
snooze
和factorial
函数会作为func参数传给clock函数,然后clock函数会返回clocked
函数。所以现在factorial
保留的是clocked
函数的引用。但是这也是装饰器的一个副作用:会把被装饰函数的一些元数据,例如函数名、文档字符串、函数签名等信息覆盖掉。下面会使用functools库中的 @wraps
装饰器来避免这个。
内嵌包装函数 clocked
的参数跟被装饰函数的参数对应,这里使用了 (*args, **kwargs)
,是为了适应可变参数。
clocked函数做了以下几件事:
- 记录初始时间
- 调用原来的factorial函数,保存结果
- 计算经过的时间
- 格式化收集的数据,然后打印出来
- 返回第2步保存的结果
1 | print('*'*40, 'Calling factorial(6)') |
装饰器的典型行为就是:把被装饰的函数体换成新函数,二者接受相同的参数,返回被装饰的函数本该返回的值,同时有额外操作
另外,内嵌包装函数 clocked
添加了functools库中的 @wraps
装饰器,这个装饰器可以把被包装函数的元数据,例如函数名、文档字符串、函数签名等信息保存下来。
1 | print(snooze(5)) |
1 | [5.01506114s] snooze(5) -> None |
参数化装饰器
如果装饰器本身需要传入参数,那就需要编写一个返回decorator的高阶函数,也就是针对装饰器进行装饰。
下面代码来自 Python Cookbook:
1 | from functools import wraps |
最外层的函数 logged()
接受参数并将它们作用在内部的装饰器函数上面。 内层的函数 decorate()
接受一个函数作为参数,然后在函数上面放置一个包装器。这个装饰器的处理过程相当于:
1 | spam = logged(x, y)(spam) |
首先执行logged('x', 'y')
,返回的是 decorate
函数,再调用返回的函数,参数是 spam
函数。
装饰器在真实世界的应用
更多的装饰器的案例: PythonDecoratorLibrary
1. 给函数调用做缓存
像求第n个斐波那契数来说,是个递归算法,对于这种慢速递归,可以把耗时函数的结果先缓存起来,在调用函数之前先查询一下缓存,如果没有才调用函数
1 | from functools import wraps |
也可以使用下面的functools库里面的 lru_cache
装饰器来实现缓存。
2. LRUCache
LRU就是Least Recently Used,即最近最少使用,是一种内存管理算法。
1 | import functools |
3. 给函数输出记日志
1 | import time |
4. 数据库连接
1 | def open_and_close_db(func): |
5. Flask路由
拿Flask的 hello world来说:
1 | from flask import Flask |
到这儿,装饰器的一些基本概念就都清楚了。