Python的装饰器可以用来临时扩展和修改可调用对象(函数、方法和类)的行为,同时又不会永久修改可调用对象本身。
装饰器的一大用途是将通用的功能应用到现有的类或函数的行为上,这些功能包括:
- 日志(logging)
-
访问控制和授权
-
衡量函数,如执行时间
-
限制请求速率(rate-limiting )
-
缓存,等等
为什么要掌握在Python中使用装饰器?毕竟刚刚提到的内容听起来很抽象,可能很难看出装饰器在日常工作中能为Python开发人员带来的好处。下面我尝试通过一个实际例子来回答这个问题。
假设在报告生成程序中有30个处理业务逻辑的函数。在一个下着雨的周一早上,老板走到你的办公桌前说:“周一快乐!记得那些TPS报告吗?我需要你为报告生成器中的每个步骤都添加输入/输出日志记录的功能,X公司需要用其来进行审计。对了,我告诉他们我们可以在周三之前完成。”
如果你对Python装饰器掌握得还不错,那么就能够冷静地应对这个需求,否则就要血压飙升了。
如果没有装饰器,那么可能需要花费整整三天时间来逐个修改这30个函数,在其中添加手动调用日志记录的代码。很悲惨吧?
但如果你了解装饰器,就能带着微笑平静地对老板说:“别担心,我会在今天下午2点之前完成。”
然后,你会着手编写一个通用的@audit_log
装饰器(只有大约10行),并将其快速粘贴到每个函数定义的前面。之后提交代码就能休息了。
这里我稍微夸张了一点。不过装饰器确实很强大。对于所有认真的Python程序员来说,理解装饰器都是一个里程碑。使用装饰器之前,需要牢固掌握Python中的几个高级概念,包括头等函数的若干特性。
我认为,理解装饰器能在Python工作中带来巨大的收益。
当然,在第一次接触的时候,你会觉得装饰器比较复杂。然而装饰器是一个非常有用的特性,在第三方框架和Python标准库中会经常遇到。一个Python教程好不好,看看其中对装饰器的讲解就能知道了。这里,我会竭尽所能逐步介绍清楚。
但在深入之前,现在最好重温Python函数特性。对于理解装饰器来说,“头等函数”中最重要的特性有:
- 函数是对象,可以分配给变量并传递给其他函数,以及从其他函数返回;
-
在函数内部也能定义函数,且子函数可以捕获父函数的局部状态(词法闭包)。
现在准备好了吗?下面就开始吧。
Python 装饰器 基础
那么装饰器到底是什么?装饰器是用来“装饰”或“包装”另一个函数的,在被包装函数运行之前和之后执行一些代码。
装饰器可以用来定义可重用的代码块,改变或扩展其他函数的行为,而无须永久性地修改包装函数本身。函数的行为只有在装饰后才会改变。
那么简单装饰器的实现会是什么样子的呢?用基本术语来说,装饰器是可调用的,将可调用对象作为输入并返回另一个可调用对象。
下面这个函数就具有这种特性,因此可以认为它是最简单的装饰器:
def null_decorator(func):
return func
从中可以看到,null_decorator
是函数,因此是可调用对象。它将另一个可调用对象作为输入,但是不做修改、直接返回。
下面用这个函数装饰(或包装)另一个函数:
def greet():
return 'Hello!'
greet = null_decorator(greet)
>>> greet()
'Hello!'
这个例子中定义了一个greet
函数,然后立即运行null_decorator
函数来装饰它。这个例子看起来没什么用,因为null_decorator
是刻意设计的空装饰器。但后面将用这个例子来讲解Python中特殊的装饰器语法。
刚刚是在greet
上显式调用null_decorator
,然后重新分配给greet
变量,而使用Python的@
语法能够更方便地修饰函数:
@null_decorator
def greet():
return 'Hello!'
>>> greet()
'Hello!'
在函数定义之前放置一个@null_decorator
,相当于先定义函数然后运行这个装饰器。@
只是语法糖,简化了这种常见的写法。
注意,使用@
语法会在定义时就立即修饰该函数。这样,若想访问未装饰的原函数则需要折腾一番。因此如果想保留调用未装饰函数的能力,那么还是要手动装饰需要处理的函数。
Python 装饰器 可以修改行为
在熟悉装饰器语法之后,下面来编写一个有实际作用的装饰器来修改被装饰函数的行为。
这个装饰器稍微复杂一些,将被装饰函数返回的结果转换成大写字母:
def uppercase(func):
def wrapper():
original_result = func()
modified_result = original_result.upper()
return modified_result
return wrapper
这个uppercase
装饰器不像之前那样直接返回输入函数,而是在其中定义一个新函数(闭包)。在调用原函数时,新函数会包装原函数来修改其行为。
包装闭包(新函数)可以访问未经装饰的输入函数(原函数),并且可在调用输入函数之前和之后自由执行额外的代码。(从技术上讲,甚至根本不需要调用输入函数。)
注意,到目前为止被装饰的函数还从未执行过。实际上,在这里调用输入函数没有任何意义,因为装饰器的目的是在最终调用输入函数的时候修改其行为。
你可能需要一点时间消化一下。装饰器看起来有点复杂,但我保证后续会逐步讲解清楚。
现在来看看uppercase
装饰器的实际行为,用它来装饰原来的greet
函数会发生什么:
@uppercase
def greet():
return 'Hello!'
>>> greet()
'HELLO!'
希望这与你的预期一致。仔细看看刚刚发生的事情吧。与null_decorator
不同,uppercase
装饰器在装饰函数时会返回一个不同的函数对象:
>>> greet
<function greet at 0x10e9f0950>
>>> null_decorator(greet)
<function greet at 0x10e9f0950>
>>> uppercase(greet)
<function uppercase.<locals>.wrapper at 0x76da02f28>
正如你之前看到的那样,只有这样装饰器才能修改被装饰函数在调用时的行为。uppercase
修饰器本身就是一个函数。对于被装饰的输入函数来说,修改其“未来行为”的唯一方法是用闭包替换(或包装)这个输入函数。
这就是为什么uppercase
定义并返回了另一个函数(闭包),这个函数在后续调用时会运行原输入函数并修改其结果。
装饰器通过包装闭包来修改可调用对象的行为,因此无须永久性地修改原对象。原可调用对象的行为仅在装饰时才会改变。
利用这种特性可以将可重用的代码块(如日志记录和其他功能)应用于现有的函数和类。因此装饰器是Python中非常强大的功能,在标准库和第三方包中经常用到。
小憩一下
顺便说一句,如果你现在需要稍微休息一下,完全没问题。在我看来,闭包和装饰器位于Python中最难理解的概念之列。
不用着急马上掌握这些内容。在解释器会话中逐个尝试前面的代码示例有助于理解这些概念。
Python 装饰器 将多个装饰器应用于一个函数
当然,多个装饰器能应用于一个函数并叠加各自的效果,因此装饰器能够以组件的形式重复使用。
下面这个例子中有两个装饰器,用于将被装饰函数返回的字符串包装在HTML标记中。从结果中标签嵌套的方式能看出Python应用多个装饰器的顺序:
def strong(func):
def wrapper():
return '<strong>' + func() + '</strong>'
return wrapper
def emphasis(func):
def wrapper():
return '<em>' + func() + '</em>'
return wrapper
现在把这两个装饰器同时应用到greet
函数中。可以使用普通的@
语法在函数前面“叠加”多个装饰器:
@strong
@emphasis
def greet():
return 'Hello!'
现在运行被装饰函数会得到什么输出?是@emphasis
装饰器先添加<em>
标签,还是@strong
先添加<strong>
标签?来一起看看吧:
>>> greet()
'<strong><em>Hello!</em></strong>'
从结果中能清楚地看出装饰器应用的顺序是从下向上。首先是@emphasis
装饰器包装输入函数,然后@strong
装饰器重新包装这个已经装饰过的函数。
为了帮助自己记忆这个从下到上的顺序,我喜欢称之为装饰器栈。栈从底部开始构建,新内容都添加到顶部。
其实称之为“装饰器队列”更准确,因为最先添加的装饰器最先起作用,而不是最后添加的起作用。——译者注
如果将上面的例子拆分开来,以传统方式来应用装饰器,那么装饰器函数调用链如下所示:
decorated_greet = strong(emphasis(greet))
同样,从中可以看到先应用的是emphasis
装饰器,然后由strong
装饰器重新包装前一步生成的包装函数。
这也意味着堆叠过多的装饰器会对性能产生影响,因为这等同于添加许多嵌套的函数调用。在实践中这一般不是什么问题,但如果在注重性能的代码中经常使用装饰器,那么要注意这一点。
Python 装饰器 装饰接受参数的函数
到目前为止,所有的例子都只是装饰了简单的无参函数greet
,没有处理输入函数的参数。
之前的装饰器无法应用于含有参数的函数。那么如何装饰带有参数的函数呢?
这种情况下,Python中用于变长参数的*args
和**kwargs
特性就能派上用场了。下面的proxy
装饰器就用到了这些特性:
def proxy(func):
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
这个装饰器有两个值得注意的地方:
- 它在
wrapper
闭包定义中使用*
和**
操作符收集所有位置参数和关键字参数,并将其存储在变量args
和kwargs
中; -
接着,
wrapper
闭包使用*
和**
“参数解包”操作符将收集的参数转发到原输入函数。
不过星和双星操作符有点复杂,且其具体含义与使用环境有关,先明白这里的含义就行了。
现在将proxy
装饰器中介绍的技术扩展成更有用的示例。下面的trace
装饰器在执行时会记录函数参数和结果:
def trace(func):
def wrapper(*args, **kwargs):
print(f'TRACE: calling {func.__name__}() '
f'with {args}, {kwargs}')
original_result = func(*args, **kwargs)
print(f'TRACE: {func.__name__}() '
f'returned {original_result!r}')
return original_result
return wrapper
使用trace
对函数进行装饰后,调用该函数会打印传递给装饰函数的参数及其返回值。这仍然是一个简单的演示示例,不过有助于调试程序:
@trace
def say(name, line):
return f'{name}: {line}'
>>> say('Jane', 'Hello, World')
'TRACE: calling say() with ("Jane", "Hello, World"), {}'
'TRACE: say() returned "Jane: Hello, World"'
'Jane: Hello, World'
说到调试,在调试装饰器时要注意下面这些事情。
Python 装饰器 如何编写“可调试”的装饰器
在使用装饰器时,实际上是使用一个函数替换另一个函数。这个过程的一个缺点是“隐藏”了(未装饰)原函数所附带的一些元数据。
例如,包装闭包隐藏了原函数的名称、文档字符串和参数列表:
def greet():
"""Return a friendly greeting."""
return 'Hello!'
decorated_greet = uppercase(greet)
如果试图访问这个函数的任何元数据,看到的都是包装闭包的元数据:
>>> greet.__name__
'greet'
>>> greet.__doc__
'Return a friendly greeting.'
>>> decorated_greet.__name__
'wrapper'
>>> decorated_greet.__doc__
None
这增加了调试程序和使用Python解释器的难度。幸运的是,有一个方法能避免这个问题:使用Python标准库中的functools.wraps
装饰器。
在自己的装饰器中使用functools.wraps
能够将丢失的元数据从被装饰的函数复制到装饰器闭包中。来看下面这个例子:
import functools
def uppercase(func):
@functools.wraps(func)
def wrapper():
return func().upper()
return wrapper
将functools.wraps
应用到由装饰器返回的封装闭包中,会获得原函数的文档字符串和其他元数据:
@uppercase
def greet():
"""Return a friendly greeting."""
return 'Hello!'
>>> greet.__name__
'greet'
>>> greet.__doc__
'Return a friendly greeting.'
建议最好在自己编写的所有装饰器中都使用functools.wraps
。这并不会占用太多时间,同时可以减少自己和其他人的调试难度。
恭喜你,现在已经读完了这复杂的一章,学习了很多关于Python装饰器的知识。干得不错!
Python 装饰器 关键要点
-
装饰器用于定义可重用的组件,可以将其应用于可调用对象以修改其行为,同时无须永久修改可调用对象本身。
-
@
语法只是在输入函数上调用装饰器的简写。在单个函数上应用多个装饰器的顺序是从底部到顶部(装饰器栈)。 -
为了方便调试,最好在自己的装饰器中使用
functools.wraps
将被装饰对象中的元数据转移到装饰后的对象中。 -
与软件开发中的其他工具一样,装饰器不是万能的,不应过度使用。装饰器虽然能完成任务,但也容易产生可怕且不可维护的代码,要注意两者间的取舍。