Python语言高级特性:装饰器 #P006#

在这一篇文章中,我们将会介绍Python语言中的装饰器。装饰器或许是Python中最复杂的知识点,也是很多人学习时遇到的难点。希望在这篇文章中,我能够以由浅入深的方式,带领大家彻底搞清楚装饰器,领悟装饰器的好处。

1 准备知识

Python 2.4 开始提供了装饰器(decorator),装饰器作为修改函数的一种便捷方式,为工程师编写程序提供了便利性和灵活性。适当使用装饰器,能够有效的提高代码的可读性和可维护性。装饰器本质上就是一个函数,这个函数接受其他函数作为参数,并将其以一个新的修改后的函数进行替换。

为了深入理解装饰器的原理,首先需要了解Python中的一些函数知识,包括Python中函数可以赋值给另外一个变量名,函数可以嵌套,以及函数对象可以作为另外一个函数的参数等。装饰器仅仅是利用前面这些Python知识,再加上Python的语法糖,实现的一种高级语法。

函数对象

在Python语言中,def语句定义了一个函数对象,并将其赋值给函数名。也就是说,函数名其实只是一个变量,这个变量引用了这个函数对象。因此,我们可以将函数赋值给另外一个变量名,通过这个新的变量名调用函数。如下所示:

def say_hi():
    print("Hi")

hello = say_hi
hello()

嵌套函数

在Python语言中,def语句是一个实时执行的语句,当它运行的时候,它会创建一个新的函数对象,并将其赋值给一个变量名。这里所说的变量名,也就是函数的名称。因为def是一个语句,因此,函数定义可以出现在其他语句之中。如下所示:

import random

n = random.randint(1, 5)
if n % 2 == 0:
    def display(n):
        print("{0} is an even number".format(n))
else:
    def display(n):
        print("{0} is a old number".format(n))

display(n)

既然函数定义可以出现在其他语句之中,那么,函数的定义也可以出现在另外一个函数的定义之中。如下所示:

def outer(x, y):
    def inner():
        return x + y
    return inner

f = outer(1, 2)
print(f())

在这个例子中,我们定义了一个名为outer的函数,并在outer函数内部定义了inner函数。outer函数以返回值的形式返回inner函数,我们将返回值保存在变量f中。f引用的是outer函数内部的inner函数。所以,当我们调用函数f时,实际是调用的inner函数。

装饰器原型

我们再来看一个回调函数的例子。回调函数是指,可以将函数作为参数,传递给另外一个函数,并在另外一个函数中进行调用。回调函数并不是Python语言所特有的,在各个编程语言中都存在。下面是一个回调函数的例子:

def greeting(f):
    f()

def say_hi():
    print("Hi")

def say_hello():
    print("Hello")

greeting(say_hi)
greeting(say_hello)

在这个例子中,我们定义了三个函数,分别是greeting、say_hi和say_hello。其中,say_hi和say_hello这两个函数作为一个普通的参数传递给greeting函数。greeting函数通过函数参数获得了say_hi函数和say_hello函数的引用。因此,在greeting函数中,调用f函数时,实际调用的是作为参数的say_hi函数和say_hello函数。

接下来看一个更加复杂的例子。在这个例子中,我们用到了前面介绍的所有知识。首先,我们定义了一个名为say_hi的函数和一个名为bread的函数,bread函数接收say_hi函数作为参数。在bread函数中,我们还定义了一个名为wrapper的嵌套函数。在嵌套函数中,我们首先打印begin消息,然后再调用作为函数参数传递进来的say_hi函数。在say_hi函数调用完成以后,再打印end消息。最后,我们将嵌套函数作为返回值,返回给了bread函数的调用者。bread函数的调用者得到返回值以后,执行函数调用。如下所示:

def say_hi():
    print("Hi")

def bread(f):
    def wrapper(*args, **kwargs):
        print("begin call {0}".format(f.__name__))
        f()
        print("finish call {0}".format(f.__name__))
    return wrapper

say_hi_copy = bread(say_hi)
say_hi_copy()

上面这段代码的执行结果如下:

begin call say_hi
Hi
finish call say_hi

在上面这段程序中,bread函数接收say_hi函数作为参数,并将其以一个新的修改后的函数进行替换。可以看到,bread函数实现的逻辑与前面提到的装饰器的逻辑是一样的。这是因为,bread本身就是一个合法的装饰器。接下来,只需要使用Python的语法糖,改造前面的程序,就完成了一个装饰器的定义和使用的完整例子。如下所示:

def bread(f):
    def wrapper(*args, **kwargs):
        print("begin")
        f()
        print("end")
    return wrapper

@bread
def say_hi():
    print("Hi")

say_hi()

这段程序和前面的程序作用一模一样,产生的结果也相同。区别在于,前面的程序显示地调用了bread函数来封装say_hi函数,这段程序通过Python的语法糖来封装say_hi函数。在Python中,say_hi函数定义语句前一行的“@bread”语句,表示对该函数应用bread装饰器,其中,“@”是装饰器的语法,“bread”是装饰器的名称。

2 装饰器的例子

前面只是一步一步介绍了装饰器的语法,接下来再看一个例子,用以演示装饰器的作用。假设我们有一个特殊的栈,这个栈不但实现了先进后出数据结构,还会检查操作栈的用户是否具有相应的权限,只有管理员才能够进行栈操作。如下所示:

class Stack:
    def __init__(self):
        self.storage = []

    def put(self, username, elem):
        if username != 'admin':
            raise Exception("This user is not allowed to put elem")

        self.storage.append(elem)

    def get(self, username):
        if username != 'admin':
            raise Exception("This user is not allowed to get elem")

        if not self.storage:
            raise Exception("There is no elem in stack")
        return self.storage.pop()

在这个特殊的栈中,相对于普通的栈,put操作和get操作多了一个额外的参数,即username。如果username不为admin,则会抛出异常。可以看到,这段程序虽然实现了相应的功能,但是,也存在一些不足。在put操作和get操作中,都需要检查username是否admin,这就存在了代码重复。试想一下,如果以后增加了新的管理员、或者管理员改变了名字,那么,我们需要在每一处检查权限的地方进行修改。如果修改有遗漏,那就留下了一个线上bug。因此,这里的程序存在代码重复。作为一名优秀的软件工程师,我们时刻谨记DRY(Don’t Repeat Yourself)原则。所以,比较好的做法是,将检查权限的操作提炼成一个独立的函数。如下所示:

def check_is_admin(username):
    if username != 'admin':
        raise Exception("This user is not allowed to put/get elem")

class Stack:
    def __init__(self):
        self.storage = []

    def put(self, username, elem):
        check_is_admin(username=username)
        self.storage.append(elem)

    def get(self, username):
        check_is_admin(username=username)
        if not self.storage:
            raise Exception("There is no elem in stack")
        return self.storage.pop()

将检查权限的操作提炼成一个独立的函数以后,在put和get操作中,只需要调用check_is_admin这个函数即可。通过这种方式,不但增加了代码可读性,减少了代码冗余,也降低了后期的维护代价。虽然将检查权限的操作提炼成函数以后,代码质量有了较大的提升,但是,如果使用装饰器,效果将会更好。如下所示:

def check_is_admin(f):
    def wrapper(*args, **kwargs):
        if kwargs.get('username') != 'admin':
            raise Exception("This user is not allowed to put/get elem")
        return f(*args, **kwargs)
    return wrapper

class Stack:
    def __init__(self):
        self.storage = []

    @check_is_admin
    def put(self, username, elem):
        self.storage.append(elem)

    @check_is_admin
    def get(self, username):
        if not self.storage:
            raise Exception("There is no elem in stack")
        return self.storage.pop()

使用装饰器以后,代码行数更多了,但是,代码反而更清晰了。对于put函数和get函数,向列表里面添加元素和获取元素才是真正的逻辑。在不使用装饰器时,当他人来阅读我们的代码时,首先看到的是一个check_is_admin的函数调用,然后看到的才是添加元素和获取元素的逻辑。使用装饰器以后,其他人阅读我们的代码时,将清楚明了地看到添加元素和获取元素的业务逻辑,不会被check_is_admin函数调用所干扰。所以,使用装饰器以后代码的可读性会更好。

在这个例子中,我们使用装饰器来进行参数检查。装饰器作为一种修改函数的方式,灵活应用,可以实现很多有意思的功能。例如:

  1. 注入参数。为函数提供默认参数,生成新的参数等;
  2. 记录函数的行为。可以统计函数的调用次数,缓存函数的结果,计算函数调用耗费的时间;
  3. 预处理与后处理;
  4. 修改调用时的上下文。

例如,下面的benchmark函数就是一个统计函数运行时间的装饰器。读者可以将该装饰器应用到自己的代码之中。

from __future__ import print_function
import time

def benchmark(func):
    def wrapper(*args, **kwargs):
        t = time.time()
        res = func(*args, **kwargs)
        print(func.__name__, time.time() - t)
        return res
    return wrapper

@benchmark
def add(a, b):
    time.sleep(1)
    return a + b

print(add(1, 2))

笔者在编写一些应用脚本的时候,还很喜欢使用下面这个装饰器。这个装饰器的作用是,记录函数调用的轨迹,并且打印出调用函数时的参数。通过这种方式,能够在日志中清晰地看到程序的执行轨迹,在出现问题时,可以快速的进行排查。如下所示:

def record(f):
    def wrapper(*args, **kwargs):
        logging.debug('call {0} function with args: {1} {2}:'.format(
            f.__name__, args, kwargs))
        return f(*args, **kwargs)
    return wrapper

3 装饰器的注意事项

虽然装饰器是Python中一个比较高级的语言特性,在某些特殊场景下非常有用。但是,使用装饰器也有不少注意事项,如果不注意这些注意事项,将会导致程序变得晦涩难懂。

使用装饰器以后函数属性的变化

装饰器接受一个函数作为参数,并将其以一个新的修改后的函数进行替换。因此,默认情况下,获取一个被装饰器修改过的函数的属性,将不能够获取到正确属性信息。例如,对于一个函数,我们可以通过__name__属性得到函数的名字,通过__doc__属性得到函数的帮助信息。但是,一个被装饰器装饰过的函数,默认情况下,我们通过__doc__和__name__获取属性时,得到的却是装饰器中嵌套函数的信息。如下所示:

def mul(a, b):
    """Calculate the product of two numbers"""
    return a * b

@benchmark
def add(a, b):
    """Calculate the sum of two numbers"""
    return a + b

print(mul.__name__)
print(mul.__doc__)

print(add.__name__)
print(add.__doc__)

在这段程序中,我们定义了两个函数,即mul与add。为了进行功能演示,我们为这两个非常简单的函数添加了帮助文档。接着,我们使用前面介绍的benchmark装饰器来装饰add函数。在这段代码的最后,我们通过__name__和__doc__获取函数的名字和函数的帮助信息。

这段程序的执行结果如下:

$ python lost_attr.py
mul
Calculate the product of two numbers
wrapper
None

可以看到,没有使用装饰器装饰的函数,我们可以正确的获取函数的名字和帮助信息。使用了benchmark装饰器装饰的函数,我们无法获取到函数的属性。这种情况也很好理解,因为装饰器接受一个函数作为参数,并将其以一个新的修改后的函数进行替换。因此,当我们获取add函数的属性时,由于它被benchmark装饰器装饰过,所以我们获取到的函数属性,实际上是装饰器返回给我们的这个函数的属性。

这个问题也很好解决,使用标准库的functools模块中的wraps装饰器即可。wraps装饰器的作用是,复制函数的属性给被装饰的函数。如下所示:

from __future__ import print_function
import time
import functools

def benchmark(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        t = time.time()
        res = func(*args, **kwargs)
        print(func.__name__, time.time() - t)
        return res
    return wrapper

在这段程序中,我们只增加了两行代码,分别是导入functools模块,以及使用functools.wraps装饰器装饰wrapper函数。通过这样简单的修改,我们就可以获取到add函数的正确属性了。

使用inspect获取函数参数

如果读者去使用我们定义的Stack这个类,将会发现一个奇怪的现象。对于下面这段调用程序,第一个put没有问题,第二个put将会提示出错。

s = Stack()
s.put(username='admin', elem=1)
s.put('admin', elem=2)

按照Python的函数参数的匹配规则,关键字参数将会根据名字进行匹配,位置参数将会根据参数所在的顺序进行匹配。那么,在第二个put语句中,admin这个值依然是传个username这个变量的,为什么还会出错呢?

出错的原因是因为我们的装饰器实现有问题。在我们的check_is_admin装饰器中,我们直接从kwargs中获取username这个键的值,获取完以后与admin进行比较。如下所示:

if kwargs.get('username') != 'admin':
    raise Exception("This user is not allowed to put/get elem")

问题就出现在装饰器的参数传递中。如果用户使用关键字参数的形式传递username,那么username变量以及值将位于kwargs中。如果用户通过位置参数传递username,那么username的值将会出现在args中。这就存在一个问题,从Python的语法来讲,用户使用位置参数或关键字参数都是合法的,如何才能够正确的判断用户是否具有相应的权限呢?对于这个问题,难点在于我们无法控制用户使用位置参数还是关键字参数。

对于这种情况,比较好的做法是使用Python标准库的inspect模块。inspect模块提供了许多有用的函数,用以获取活跃对象的信息。其中,getcallargs函数用以获取函数的参数信息。

getcallargs会返回一个字典,该字典保存了函数的所有参数,包括关键字参数和位置参数。也就是说,getcallargs能够根据函数的定义和传递给函数的参数,推测出哪一个值传递给函数的哪一个参数。getcallargs推测出这些信息以后,以一个字典的形式返回给我们所有的参数和取值。因此,我们在检查username参数的取值是否admin之前,可以先使用getcallargs函数获取函数的所有参数。然后从getcallargs函数返回的字典中,去获取username的取值。通过这种方式,无论用户使用的是位置参数还是关键字参数,都能够正确的处理。如下所示:

import functools
import inspect
def check_is_admin(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        func_args = inspect.getcallargs(func, *args, **kwargs)
        if func_args.get('username') != 'admin':
            raise Exception("This user is not allowed to put/get elem")
        return f(*args, **kwargs)
    return wrapper

给装饰器传递参数

在Python的装饰器语法中,内层函数的参数是被装饰的函数的参数,外层函数的参数是被装饰的函数。那么,如果装饰器本身也有参数应该怎么办呢?在Python的装饰器语法中,如果装饰器本身也有参数,则需要再嵌套一层函数。这也是为什么读者看到Python的装饰器,有时候是两层嵌套,有时候是三层嵌套的原因。

例如,下面是一个带参数的装饰器。在这个装饰器的实现中,最外层的函数是装饰器的名称。这个装饰器的作用是,将被装饰的函数执行多次。具体执行的次数由装饰器的参数指定。如下所示:

from __future__ import print_function

def times(length=1):
    def bread(func):
        def wrapper(*args, **kwargs):
            for i in range(length):
                func(*args, **kwargs)
        return wrapper
    return bread

@times(5)
def sandwich(name):
    print(name)

sandwich('Helo, World')

装饰器的缺点

虽然装饰器功能强大,但是,也不能够乱用。一方面装饰器的语法比较复杂,另一方面,装饰器具有速度慢和难以调试的缺点。因此,装饰器的使用比较强调应用场景,需要使用得恰到好处才能够发挥它的优势。

4 总结

在这篇文章中,我们深入浅出地介绍了Python的装饰器,并给出了两个装饰器的用法。在笔者看来,之所以大多数工程师在学习装饰器的时候会遇到困难,主要是因为对Python中的函数了解不够。当我们有了足够多的知识储备以后,就可以编写特定作用的装饰器。与此同时,装饰器也有一些注意事项,如果不了解这些注意事项,再加上装饰器复杂的语法,很容易产生一些难以调试的bug。

作者介绍

赖明星,架构师、作家。现就职于腾讯,参与并主导下一代金融级数据库平台研发。有多年的 Python 开发经验和一线互联网实战经验,擅长 C、Python、Java、MySQL、Linux 等主流技术。国内知名的 Python 技术专家和 Python 技术的积极推广者,著有《Python Linux 系统管理与自动化运维》一书。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值