这是 “Python 工匠”系列的第 8 篇文章。[查看系列全部文章]html
装饰器*(Decorator)* 是 Python 里的一种特殊工具,它为咱们提供了一种在函数外部修改函数的灵活能力。它有点像一顶画着独一无二 @
符号的神奇帽子,只要将它戴在函数头顶上,就能悄无声息的改变函数自己的行为。python
你可能已经和装饰器打过很多交道了。在作面向对象编程时,咱们就常常会用到 @staticmethod
和 @classmethod
两个内置装饰器。此外,若是你接触过 click 模块,就更不会对装饰器感到陌生。click 最为人所称道的参数定义接口 @click.option(...)
就是利用装饰器实现的。git
除了用装饰器,咱们也常常须要本身写一些装饰器。在这篇文章里,我将从 最佳实践
和 常见错误
两个方面,来与你分享有关装饰器的一些小知识。程序员
绝大多数装饰器都是基于函数和 闭包 实现的,但这并不是制造装饰器的惟一方式。事实上,Python 对某个对象是否能经过装饰器(@decorator
)形式使用只有一个要求:decorator 必须是一个“可被调用(callable)的对象。github
# 使用 callable 能够检测某个对象是否“可被调用”
>>> def foo(): pass
...
>>> type(foo)
<class 'function'>
>>> callable(foo)
True
复制代码
函数天然是“可被调用”的对象。但除了函数外,咱们也可让任何一个类(class)变得“可被调用”(callable)。办法很简单,只要自定义类的 __call__
魔法方法便可。面试
class Foo:
def __call__(self):
print("Hello, __call___")
foo = Foo()
# OUTPUT: True
print(callable(foo))
# 调用 foo 实例
# OUTPUT: Hello, __call__
foo()
复制代码
基于这个特性,咱们能够很方便的使用类来实现装饰器。编程
下面这段代码,会定义一个名为 @delay(duration)
的装饰器,使用它装饰过的函数在每次执行前,都会等待额外的 duration
秒。同时,咱们也但愿为用户提供无需等待立刻执行的 eager_call
接口。设计模式
import time
import functools
class DelayFunc:
def __init__(self, duration, func):
self.duration = duration
self.func = func
def __call__(self, *args, **kwargs):
print(f'Wait for {self.duration} seconds...')
time.sleep(self.duration)
return self.func(*args, **kwargs)
def eager_call(self, *args, **kwargs):
print('Call without delay')
return self.func(*args, **kwargs)
def delay(duration):
"""装饰器:推迟某个函数的执行。同时提供 .eager_call 方法当即执行 """
# 此处为了不定义额外函数,直接使用 functools.partial 帮助构造
# DelayFunc 实例
return functools.partial(DelayFunc, duration)
复制代码
如何使用装饰器的样例代码:bash
@delay(duration=2)
def add(a, b):
return a + b
# 此次调用将会延迟 2 秒
add(1, 2)
# 此次调用将会当即执行
add.eager_call(1, 2)
复制代码
@delay(duration)
就是一个基于类来实现的装饰器。固然,若是你很是熟悉 Python 里的函数和闭包,上面的 delay
装饰器其实也彻底能够只用函数来实现。因此,为何咱们要用类来作这件事呢?闭包
与纯函数相比,我以为使用类实现的装饰器在特定场景下有几个优点:
在写装饰器的过程当中,你有没有碰到过什么不爽的事情?无论你有没有,反正我有。我常常在写代码的时候,被下面两件事情搞得特别难受:
好比,在下面的例子里,我实现了一个生成随机数并注入为函数参数的装饰器。
import random
def provide_number(min_num, max_num):
"""装饰器:随机生成一个在 [min_num, max_num] 范围的整数,追加为函数的第一个位置参数 """
def wrapper(func):
def decorated(*args, **kwargs):
num = random.randint(min_num, max_num)
# 将 num 做为第一个参数追加后调用函数
return func(num, *args, **kwargs)
return decorated
return wrapper
@provide_number(1, 100)
def print_random_number(num):
print(num)
# 输出 1-100 的随机整数
# OUTPUT: 72
print_random_number()
复制代码
@provide_number
装饰器功能看上去很不错,但它有着我在前面提到的两个问题:**嵌套层级深、没法在类方法上使用。**若是直接用它去装饰类方法,会出现下面的状况:
class Foo:
@provide_number(1, 100)
def print_random_number(self, num):
print(num)
# OUTPUT: <__main__.Foo object at 0x104047278>
Foo().print_random_number()
复制代码
Foo
类实例中的 print_random_number
方法将会输出类实例 self
,而不是咱们指望的随机数 num
。
之因此会出现这个结果,是由于类方法*(method)和函数(function)*两者在工做机制上有着细微不一样。若是要修复这个问题,provider_number
装饰器在修改类方法的位置参数时,必须聪明的跳过藏在 *args
里面的类实例 self
变量,才能正确的将 num
做为第一个参数注入。
这时,就应该是 wrapt 模块闪亮登场的时候了。wrapt
模块是一个专门帮助你编写装饰器的工具库。利用它,咱们能够很是方便的改造 provide_number
装饰器,完美解决*“嵌套层级深”和“没法通用”*两个问题,
import wrapt
def provide_number(min_num, max_num):
@wrapt.decorator
def wrapper(wrapped, instance, args, kwargs):
# 参数含义:
#
# - wrapped:被装饰的函数或类方法
# - instance:
# - 若是被装饰者为普通类方法,该值为类实例
# - 若是被装饰者为 classmethod 类方法,该值为类
# - 若是被装饰者为类/函数/静态方法,该值为 None
#
# - args:调用时的位置参数(注意没有 * 符号)
# - kwargs:调用时的关键字参数(注意没有 ** 符号)
#
num = random.randint(min_num, max_num)
# 无需关注 wrapped 是类方法或普通函数,直接在头部追加参数
args = (num,) + args
return wrapped(*args, **kwargs)
return wrapper
<... 应用装饰器部分代码省略 ...>
# OUTPUT: 48
Foo().print_random_number()
复制代码
使用 wrapt
模块编写的装饰器,相比原来拥有下面这些优点:
@wrapt.decorator
能够将两层嵌套减小为一层instance
值进行条件判断后,更容易让装饰器变得通用“设计模式”是一个在计算机世界里鼎鼎大名的词。假如你是一名 Java 程序员,而你一点设计模式都不懂,那么我打赌你找工做的面试过程必定会度过的至关艰难。
但写 Python 时,咱们极少谈起“设计模式”。虽然 Python 也是一门支持面向对象的编程语言,但它的 鸭子类型 设计以及出色的动态特性决定了,大部分设计模式对咱们来讲并非必需品。因此,不少 Python 程序员在工做很长一段时间后,可能并无真正应用过几种设计模式。
不过 “装饰器模式(Decorator Pattern)” 是个例外。由于 Python 的“装饰器”和“装饰器模式”有着如出一辙的名字,我不止一次听到有人把它们俩当成一回事,认为使用“装饰器”就是在实践“装饰器模式”。但事实上,它们是两个彻底不一样的东西。
“装饰器模式”是一个彻底基于“面向对象”衍生出的编程手法。它拥有几个关键组成:一个统一的接口定义、若干个遵循该接口的类、类与类之间一层一层的包装。最终由它们共同造成一种*“装饰”*的效果。
而 Python 里的“装饰器”和“面向对象”没有任何直接联系,**它彻底能够只是发生在函数和函数间的把戏。**事实上,“装饰器”并无提供某种没法替代的功能,它仅仅就是一颗“语法糖”而已。下面这段使用了装饰器的代码:
@log_time
@cache_result
def foo(): pass
复制代码
基本彻底等同于下面这样:
def foo(): pass
foo = log_time(cache_result(foo))
复制代码
装饰器最大的功劳,在于让咱们在某些特定场景时,能够写出更符合直觉、易于阅读的代码。它只是一颗“糖”,并非某个面向对象领域的复杂编程模式。
Hint: 在 Python 官网上有一个 实现了装饰器模式的例子,你能够读读这个例子来更好的了解它。
下面是一个简单的装饰器,专门用来打印函数调用耗时:
import time
def timer(wrapped):
"""装饰器:记录并打印函数耗时"""
def decorated(*args, **kwargs):
st = time.time()
ret = wrapped(*args, **kwargs)
print('execution take: {} seconds'.format(time.time() - st))
return ret
return decorated
@timer
def random_sleep():
"""随机睡眠一小会"""
time.sleep(random.random())
复制代码
timer
装饰器虽然没有错误,可是使用它装饰函数后,函数的原始签名就会被破坏。也就是说你再也没办法正确拿到 random_sleep
函数的名称、文档内容了,全部签名都会变成内层函数 decorated
的值:
print(random_sleep.__name__)
# 输出 'decorated'
print(random_sleep.__doc__)
# 输出 None
复制代码
这虽然只是个小问题,但在某些时候也可能会致使难以察觉的 bug。幸运的是,标准库 functools
为它提供了解决方案,你只须要在定义装饰器时,用另一个装饰器再装饰一下内层 decorated
函数就行。
听上去有点绕,但其实就是新增一行代码而已:
def timer(wrapped):
# 将 wrapper 函数的真实签名赋值到 decorated 上
@functools.wraps(wrapped)
def decorated(*args, **kwargs):
# <...> 已省略
return decorated
复制代码
这样处理后,timer
装饰器就不会影响它所装饰的函数了。
print(random_sleep.__name__)
# 输出 'random_sleep'
print(random_sleep.__doc__)
# 输出 '随机睡眠一小会'
复制代码
装饰器是对函数对象的一个高级应用。在编写装饰器的过程当中,你会常常碰到内层函数须要修改外层函数变量的状况。就像下面这个装饰器同样:
import functools
def counter(func):
"""装饰器:记录并打印调用次数"""
count = 0
@functools.wraps(func)
def decorated(*args, **kwargs):
# 次数累加
count += 1
print(f"Count: {count}")
return func(*args, **kwargs)
return decorated
@counter
def foo():
pass
foo()
复制代码
为了统计函数调用次数,咱们须要在 decorated
函数内部修改外层函数定义的 count
变量的值。可是,上面这段代码是有问题的,在执行它时解释器会报错:
Traceback (most recent call last):
File "counter.py", line 22, in <module>
foo()
File "counter.py", line 11, in decorated
count += 1
UnboundLocalError: local variable 'count' referenced before assignment
复制代码
这个错误是由 counter
与 decorated
函数互相嵌套的做用域引发的。
当解释器执行到 count += 1
时,并不知道 count
是一个在外层做用域定义的变量,它把 count
当作一个局部变量,并在当前做用域内查找。最终却没有找到有关 count
变量的任何定义,而后抛出错误。
为了解决这个问题,咱们须要经过 nonlocal
关键字告诉解释器:“count 变量并不属于当前的 local 做用域,去外面找找吧”,以前的错误就能够获得解决。
def decorated(*args, **kwargs):
nonlocal count
count += 1
# <... 已省略 ...>
复制代码
Hint:若是要了解更多有关 nonlocal 关键字的历史,能够查阅 PEP-3104
在这篇文章里,我与你分享了有关装饰器的一些技巧与小知识。
一些要点总结:
functools.wraps
nonlocal
关键字看完文章的你,有没有什么想吐槽的?请留言或者在 项目 Github Issues 告诉我吧。
系列其余文章: