Python装饰器(从入门到进阶)
当我们在阅读别人的代码时,可能会经常看到在def
函数时,会使用@xxxxx
的语句,此外,在定义类时,总会用@staticmethod
、@property
等语句,这便是本文要聊到的话题:装饰器(Decorator)。
装饰器是Python的一个高级话题,装饰器的构建对于工具构架者比对于应用程序员的意义会更加重要。目前,在流行的Python框架中变得越来越常见,对其基本的理解有助于认识它们的作用。我们将深入装饰器的内部工作机制,并学习自己编写新的装饰器的更多高级方法。
一、准备知识
在学习装饰器之前,我们要搞清楚以下基础知识:
-
函数的可变长参数
在定义函数时不确定调用的时候会传递多少个参数,那根据调用方式的不同,不确定个数的参数可能带有关键字,也可以没带关键字,分为包裹位置参数和包裹关键字参数。详情戳这里
-
函数是一个对象
在Python中,一切皆对象,函数也不例外。既然函数是一个对象,便可以将函数作为其他函数的参数进行传递,也可以作为其他函数的返回值进行返回。
-
函数的闭包
先看一段示例代码
def fun(a): b = 100 def i_fun(): print(F'a = {a}, b = {b}') return i_fun ifun = fun(10) ifun()
这里定义一个函数fun,其返回值也是一个函数对象。注意
i_fun
函数中的变量a
和b
,它们的作用域只在函数fun
中有效,而当我们执行ifun()
时,会发现程序能正确输出a
和b
的值,这便是所说的闭包:闭包 = 函数块+定义函数时的引用环境
-
当内嵌函数体内引用到函数体外的变量时,将会把定义时涉及到的引用环境和函数体打包成一个整体(闭包)返回
-
如果在一个内部函数里,对在外部作用域(但不是在全局作用域)的变量进行引用,那么内部函数就被认为是闭包(closure)
-
可以通过属性
__closure__
查看单元格对象的元组,如果执行print(ifun.__closure__)
, 将输出如下结果(<cell at 0x000002216A512400: int object at 0x00007FFD6CD32840>, <cell at 0x000002216A512370: int object at 0x00007FFD6CD33380>)
两个
int object
便是引用环境中的变量a
和b
下面给出两段关于闭包的经典错误代码:
-
示例1
def foo(): a = 1 def bar(): a = a + 1 print(a) return bar c = foo() c()
在执行到第四行代码时,会出现错误
UnboundLocalError: local variable 'a' referenced before assignment
,主要原因是:- 定义
bar
函数时,对a
进行赋值,Python认为a
是一个局部变量 - 当执行到此句时,因为a已经认为是局部变量,在计算
=
右边的表达式时,结果不能找到局部变量a
- 定义
-
示例2
fun_list = [lambda x:x+i for i in range(3)] for fun in fun_list: print(fun(2), end=',')
按照代码的意思,其结果应该输出
2,3,4,
,而最终的结果却是4,4,4,
。- 主要原因是:当把函数加入fun_list列表里时,python并没有给变量
i
赋值,只是将变量i
的引用环境进行了打包,当真正执行时,这时i
的值已经变成了2
,所以执行的结果都是4。可以打印每个函数的__closure__
属性验证原因。 - 解决方法:
fun_list = [lambda x,i=i:x+i for i in range(3)]
,这里利用默认参数消除了闭包带来的影响
- 主要原因是:当把函数加入fun_list列表里时,python并没有给变量
-
二、什么是装饰器
1. 理解装饰器
装饰器(Decorator),从字面上看,起到的作用便是装饰功能,可以从两个方面来理解这一概念:
- 装饰器是什么:装饰器本身的形式是处理其他的可调用对象的可调用对象,其返回值是一个可调用对象,这里的可调用对象可以是函数,也可以是类,所以装饰器又分为函数装饰器和类装饰器。
- 被装饰的对象是什么:被装饰的对象是函数或者是类
上面两句话描述得非常的清楚,你也一定能看懂(毕竟这些文字都是小学生应该掌握的),但是有99%的可能,你还是不清楚装饰器是什么鬼。如果你是那99%的一员,请不要走开,接下来通过编程中两个常见的应用场景,让你真正理解到什么是装饰器,为什么要用装饰器。为了方便理解,我们先以函数装饰器为例进行讲解。
场景一:函数性能测试
import time
def do_sth():
time.sleep(2)
这里写了一个示例函数do_sth
,使用time.sleep(2)
模拟函数的实际代码运行。
如果想要测试该函数的性能,我们该如何做:
-
方法一:直接修改原函数
import time def do_sth(): start_time = time.time() time.sleep(2) print(time.time()-start_time)
优点:对函数的调用者比较友好
缺点:
- 修改已经正常运行的源代码不是一个很好的方式
- 如果这样的函数有1000个,你确定这样的工作你不想吐😱?
-
方法二:在调用时添加性能测试
import time def do_sth(): time.sleep(2) #调用 start_time = time.time() do_sth() print(time.time()-start_time)
优点:不需要修改原函数
缺点:调用函数太麻烦
-
方法三:定义一个额外测试性能的函数
import time def do_sth(): time.sleep(2) def do_perf(fun): start_time = time.time() result = fun() print(time.time()-start_time) return result do_perf(do_sth) #调用
优点:不需要修改原函数,性能测试的代码得到了复用
缺点:对函数调用者不友好
-
方法四:对方法三进行改进
import time def stat_time(fun): def i_fun(): start_time = time.time() result = fun() print(time.time() - start_time) return result return i_fun def do_sth(): time.sleep(2) do_sth = stat_time(do_sth) do_sth() #调用
优点:不需要修改原函数,性能测试的代码得到了复用,对函数调用者友好。
缺点:非得说缺点吗,不够优雅😜,(当然有其他的缺点,在后面会聊)
写了一堆的方法做铺垫,实际想表达的是方法四不错,但我们说它不够优雅。Python是一门优雅的编码语言,Python之禅的第一句话便是Beautiful is better than ugly
(如果不知道什么是python之禅,请在Python交互界面输入import this
)。
Python提供了一种语法糖@decorator
,其中decorator
便是我们的装饰器,它可以在不改变原函数代码的基础上,提供额外的功能,比如示例中的性能统计。我们将按装饰器的写法修改方法四的代码如下:
import time
def stat_time(fun):
def i_fun():
start_time = time.time()
result = fun()
print(time.time() - start_time)
return result
return i_fun
@stat_time
def do_sth():
time.sleep(2)
do_sth() #调用,此时可以等价理解为 stat_time(do_sth)()
场景二:参数有效性验证
def add2(a, b):
print(a+b)
def some_op(a, b, c):
print((a+b)*c)
这里有两个函数add
和some_op
,假设函数要求传入的参数必须是整数,我们需要使用上面装饰器的方式完成参数的有效性验证,此时该如何?
第一个问题: 如何带参数?假设我们为add2
函数定义了装饰函数valid_int_2
,按照上一个示例给我们的提示,执行add2(1, 2)
等价于执行valid_int_2(add2)(1, 2)
,也就是说装饰函数valid_int_2
的返回值应该是一个可以接收两个参数的函数形式,所以我们可以为add_2
定义装饰函数为如下形式:
def valid_int_2(fun):
def i_fun(a,b):
pass
return i_fun
第二个问题:装饰函数的功能是什么?既然是完成参数有效性检验,则参数满足条件的执行被装饰函数,不满足条件的返回约定值或者输出提示(这里以输出提示的方式演示)
def valid_int_2(fun):
def i_fun(a,b):
if isinstance(a,int) and isinstance(b,int):
return fun(a,b)
else:
print('参数类型错误')
return i_fun
@valid_int_2
def add2(a, b):
print(a+b)
add2(1,1) #输出 2
add2('a',5) #输出 参数类型错误
第三个问题:如何统一参数形式?现在我们解决了两个参数的问题,那如何解决任意个参数的类型检查,以及任意形式的参数类型(位置参数,关键字参数)检查?这里要用来可变长参数的知识点,对于所有的参数形式,都可以使用fun(*args, **kwargs)
的形式来表示,下面写一个通用参数形式的表示
def valid_int(fun):
def i_fun(*args, **kwargs):
for i in args:
if not isinstance(i, int): raise ValueError('不是整数')
for i in kwargs.values():
if not isinstance(i, int): raise ValueError('不是整数')
return fun(*args, **kwargs)
return i_fun
@valid_int
def add2(a, b):
print(a+b)
@valid_int
def some_op(a, b, c):
print((a+b)*c)
add2(1,1) #正常输出 2
some_op(1,2,3) #正常输出 9
some_op('1',1,1) #抛出异常: ValueError: 不是整数
第四个问题:如果想验证参数是否满足其他类型(比如字符串)要求时,是不是又需要重新写一个装饰函数呢?答案是No! 这就需要提到装饰器参数,我们在进阶部分进行讲解。
2. 回顾装饰器的概念
回顾一下装饰器的概念:装饰器本身的形式是处理其他的可调用对象(或者叫被装饰对象)的可调用对象,其返回值是一个可调用对象,这里的可调用对象可以是函数,也可以是类,所以装饰器又分为函数装饰器和类装饰器,被装饰对象目前支持函数和类。
上面的示例我们讲的是函数装饰器,可以将函数装饰器简单的理解为:函数装饰器本质上是一个函数,它可以让其他函数在不需要做任何代码变动的前提下增加额外功能,装饰器的返回值也是一个函数对象。
函数装饰器已经从Python 2.5开始可用。它们主要只是一种语法糖:通过在一个函数的def
语句的末尾来运行另一个函数,把最初的函数名重新绑定到结果。函数装饰器是一种关于函数的运行时声明,函数的定义需要遵守此声明。装饰器在紧挨着定义一个函数或方法的def
语句之前的一行编写,并且它由@
符号以及紧随其后的对于原函数的一个引用组成。
Talk is cheap show me the code!,用代码形式来理解这一句话:
@decorator #装饰函数decorator
def fun(arg): #被装饰函数fun
...
fun(66) #调用函数
等价于
def fun(arg):
...
fun = decorator(fun) # 将装饰器的返回值重新绑定到原函数名
fun(66) # 本质上相当于调用 decorator(fun)(99)
Python学习手册中描述为:
Python装饰器以两种相关的形式呈现:
- 函数装饰器在函数定义的时候进行名称重绑定,提供一个逻辑层来管理函数和方法或随后对它们的调用。
- 类装饰器在类定义的时候进行名称重绑定,提供一个逻辑层来管理类,或管理随后调用它们所创建的示例。
简而言之,装饰器提供了一种方法,在函数和类定义语句的末尾插入自动运行代码——对于函数装饰器,在def的末尾;对于类装饰器,在class的末尾。这样的代码可以扮演不同的角色。
关于类装饰器在进阶部分进行讲解
3. 为什么需要装饰器
为什么需要装饰器呢,如果仔细看过理解装饰器部分内容,相信不难回答。如果从纯技术的角度来看等装饰器,它不是一个严格需要的内容,因为我们可以在不顾及代码的优雅、冗余时,实现相同的功能。但,我们要说的是使用装饰器给我们带来的好处:
- 明确的语法:使用装饰器可以让扩展意图更明确。使用
@
语法会比在源代码中添加特殊代码更容易识别,也更优雅简洁。 - 代码可维护性:装饰器避免了在每个函数或类调用中重复扩展的代码。由于它们只出现一次,在类或者函数自身的定义中,它们排除了冗余性并简化了未来的代码维护。
- 一致性:装饰器使得我们忘记使用必需的包装逻辑的可能性大大减少。这主要得益于两个优点——由于装饰是显式的并且只出现一次,出现在装饰的对象自身中,与必须包含在每次调用中的特殊代码相比较,装饰器促进了更加一致和统一的API使用。
4. 装饰器的应用
在实际工作中,你可能会成为装饰器的用户或提供者,比如在定义类的过程中,你可能经常要用到静态方法装饰器、属性装饰器以及其他一些内置的装饰器。此外,很多流行的Python工具包括了执行管理数据库或用户接口逻辑等任务的装饰器。在这样的情况中,我们不需要知道装饰器如何编码就可以完成任务。对于更为通用的场景,我们可以编写自己的任意装饰器。
装饰器的应用从大的方面可以分为两种:
-
管理调用和实例
装饰器通过自动把函数和类名重绑定到其他可调用对象,来实现自动运行扩展代码(或者说功能代码),以增强对函数和类的调用。函数装饰器可以理解成在被装饰函数调用之前,根据不同的需要进行相应的拦截处理,然后再调用相应的函数(对于类装饰器是实例化处理)。
-
管理函数和类
我们最常使用装饰器来拦截处理随后对函数和类的调用,另外,装饰器也可以用来管理函数对象或者类对象本身。换句话说,函数装饰器可以用来管理函数调用和函数对象,类装饰器可以用来管理类实例和类自身。通过返回装饰的对象自身而不是一个包装器,装饰器变成了针对函数和类的一种简单的后创建步骤。
这里列举一些常见的应用场景:
-
在调试时执行参数验证测试
-
自动获取和释放线程锁
-
统计调用函数的次数以进行优化等
-
添加跟踪调用,插入日志
-
性能测试
-
权限校验
-
…
示例:Flask框架的路由设置
@app.route('/')
def index():
return 'Hello, Flask World'
app.route装饰器,实际执行的额外功能是使用add_url_rule
函数关联路径和视图函数。
def route(self, rule: str, **options: t.Any) -> t.Callable[[F], F]:
def decorator(f: F) -> F:
endpoint = options.pop("endpoint", None)
self.add_url_rule(rule, endpoint, f, **options)
return f
return decorator
5. 装饰器的缺点
装饰器在不修改被装饰对象源代码的情况下可以优雅地添加辅助功能,极大地减少代码冗余,并且用户调用友好。但它并不是没有缺点:
-
额外调用
通过装饰添加一个包装层,在每次调用装饰对象的时候,会引发一次额外调用所需的额外性能成本,因此,装饰包装器可能会使程序变慢。实际开发中这点额外性能成本可忽略。 -
类型修改
当使用装饰器后,被装饰对象(函数或类)不会保持其最初的
类型,导致其元信息消失,比如函数的docstring、__name__
、参数列表等。import time import functools def stat_time(fun): def i_fun(): start_time = time.time() result = fun() print(time.time() - start_time) return result return i_fun @stat_time def do_sth(): time.sleep(2) print(do_sth.__name__) #输出i_fun
可以在定义装饰函数的时候添加一个
functools.wraps
装饰器进行改进:import time import functools def stat_time(fun): @functools.wraps(fun) def i_fun(): start_time = time.time() result = fun() print(time.time() - start_time) return result return i_fun @stat_time def do_sth(): time.sleep(2) print(do_sth.__name__) #输出do_sth
三、进阶知识
对装饰器有一定的认识和应用后,我们再谈一谈一些进阶知识。
1. 装饰器参数
还记得参数有效性验证示例吗,当时说如果想验证参数是否满足其他类型(比如字符串,或者其它)要求时,该如何写装饰函数。这便是这里提到的装饰器参数。记得装饰器是什么吗,它是一个处理其他的可调用对象的可调用对象,其返回值是一个可调用对象。如果装饰函数本身可以带入一个参数后,然后返回的仍然是一个装饰函数不就可以达到想要的效果吗?
请看示例代码:
def com_valid(v_type):
def valid_arg(fun):
def i_fun(*args, **kwargs):
for i in args:
if not isinstance(i, v_type): raise ValueError
for i in kwargs.values():
if not isinstance(i, v_type): raise ValueError
return fun(*args, **kwargs)
return i_fun
return valid_arg
@com_valid((int,float))
def do_test(*args, **kwargs):
print('Just for test')
do_test(1, 3.1, 4)
do_test('A')
需要注意的是:
com_valid((int,float))
的执行结果是valid_arg
函数本身,valid_arg
函数才是这里真正的装饰函数。- 装饰器只有一个参数,就是被装饰对象的引用
2. 装饰器嵌套
有的时候,一个装饰器不够。为了支持多步骤的扩展,装饰器语法允许我们向一个装饰的函数或方法添加包装器逻辑的多个层。当使用这一功能的时候,每个装饰器必须出现在自己的一行中。这种形式的装饰器语法:
@A
@B
@C
def fun(...):
...
其等价于
def fun(...):
...
f = A(B(C(f)))
示例:
def A(fun):
print('添加的第一段扩展代码A')
return fun
def B(fun):
print('添加的第一段扩展代码B')
return fun
def C(fun):
print('添加的第一段扩展代码C')
return fun
@A
@B
@C
def fun():
print('Just for test')
fun()
输出:
添加的第一段扩展代码C
添加的第一段扩展代码B
添加的第一段扩展代码A
Just for test
可以理解成:执行
fun()
时,相当于执行A(B(C(fun)))
3. 类装饰器
函数装饰器设计用来只增强一个特定函数或方法调用,而不是一个完整的对象接口。类装饰器更好地充当后一种角色——因为它们可以拦截实例创建调用,它们可以用来实现任意的对象接口扩展或管理任务。类装饰器在Python 2.6和Python 3.0中开始支持使用。
类装饰器的语法如下:
@decorator # Decorate class
class C:
...
x = C(99) # Make an instance
等同于下面的语法:类自动地传递给装饰器,并且装饰器的结果返回来分配给类名:
class C:
...
C = decorator(C) # 重新将装饰器的返回值绑定到类名
x = C(99) # 本质上调用 decorator(C)(99)
为了更好的理解类装饰器,请理解下面的示例代码:
示例1:统计类C的实例化次数
class CountIns:
count = 0
def __init__(self, C):
self._C = C
def __call__(self):
CountIns.count += 1
print(F'{CountIns.count}次')
return self._C()
@CountIns
class C:
pass
输出
1次
2次
3次
理解:C( )
等价于count_ins(C)( )
,其实相当于先后执行了CountIns
类的__init__
和__call__
的两个方法 ,区别只在于不管调用了多少次C()
,__init__
只执行了一次而已。
如果想统计某个函数的调用次数,该如何做呢?
class Count: count = 0 def __init__(self, F): self._F = F def __call__(self, *args,**kwargs): Count.count += 1 print(F'{Count.count}次') return self._F(*args,**kwargs) @Count def fun(a,b): print(F'{a}+{b}={a+b}') fun(1,1) fun(2,2) fun(3,3)
输出:
1次 1+1=2 2次 2+2=4 3次 3+3=6
都看到这里了,麻烦顺手点个赞呗!