Python 装饰器

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闭包定义中使用***操作符收集所有位置参数和关键字参数,并将其存储在变量argskwargs中;

  • 接着,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将被装饰对象中的元数据转移到装饰后的对象中。

  • 与软件开发中的其他工具一样,装饰器不是万能的,不应过度使用。装饰器虽然能完成任务,但也容易产生可怕且不可维护的代码,要注意两者间的取舍。

Python教程

Java教程

Web教程

数据库教程

图形图像教程

大数据教程

开发工具教程

计算机教程