Python装饰器完全指南(1)

181 阅读7分钟
原文链接: zhuanlan.zhihu.com

假设我们有一组函数,它们有共同的错误处理方法,比如打印日志和记录审计信息等。很显然,在每一个函数中都重复这些逻辑是不恰当的,它们应该被提炼到一个函数里,在这个函数的保护下,再调用我们的业务逻辑处理功能。

尽管错误处理可能占据代码的主要部分,但业务逻辑才是程序的核心价值。因此,从代码结构上看,错误处理应该处于可被忽略的非中心地带。如果我们每次调用业务逻辑处理功能前,都要先显式地从一个错误处理函数开始,这种写法显然是头重脚轻,也会打断代码阅读者的思绪。基于这些原因,开发语言引入了面向切面的编程(AOP):把与主业务无关的事情,放到代码之外去做。

装饰器是AOP编程中不可缺少的语法糖。通过装饰器语法,可以使得程序更简洁易读。本文对装饰器的基础原理、一般写法、corner case和常见场景进行了探讨。


假设我们有一组函数,它们有共同的错误处理方法,比如打印日志和记录审计信息等。很显然,在每一个函数中都重复这些逻辑是不恰当的,它们应该被提炼到一个函数里,在这个函数的保护下,再调用我们的业务逻辑处理功能。

尽管错误处理可能占据代码的主要部分,但业务逻辑才是程序的核心价值。因此,从代码结构上看,错误处理应该处于可被忽略的非中心地带。如果我们每次调用业务逻辑处理功能前,都要先显式地从一个错误处理函数开始,这种写法显然是头重脚轻,也会打断代码阅读者的思绪。基于这些原因,开发语言引入了面向切面的编程(AOP):把与主业务无关的事情,放到代码之外去做。

装饰器是AOP编程中不可缺少的语法糖。通过装饰器语法,可以使得程序更简洁易读。本文对装饰器的基础原理、一般写法、corner case和常见场景进行了探讨。

1. 从一个最简单的装饰器开始

假设我们有一个功能函数(从现在开始,我们把被装饰器修饰,完成业务逻辑的那些函数称作功能函数,以区别于装饰器函数),出于调试目的,我们希望打印出它的参数及每次调用的返回值。

假设功能函数如下:

# block 1
def buggy_incr_by(number):
  import random
  return random.randint(0,10) + number

我们可以定义这样一个函数:

# block 2
def snoop(func):
  def wrapper(number):
    print(f" >>> invoke {func.__name__} with parameter: {number}")
    result = func(number)
    return result
    print(f"<<< {func.__name__} returned {result}")
  return wrapper

现在,运用装饰器语法:

# block 3
@snoop
def buggy_incr_by(number):
  import random
  return random.randint(0,10) + number

# call and check the result
buggy_incr_by(3)
# --- output ---
>>> invoke buggy_incr_by with parameter: 3
<<< buggy_incr_by returned 13

2. 装饰器究竟是如何工作的?

现在我们来看一看这一切是如何发生的。

这里最基本的原理有: 1. 在python中,function(函数)也是一种对象(当不带括号引用时)。你可以任意选择一个函数f,通过dir(f)来查看它有哪些属性。 2. 在函数内也可以定义函数,并返回这个定义的函数对象。这是因为根据原理1,函数本身也是对象。 3. 模块加载器调用exec_module时,会查找和解析@语句,通过执行 func = decorator(func),重新定义功能函数。

在上面的例子中,我们定义了装饰器函数snoop,它接受一个规定好的参数(必须),即功能函数对象本身。decorator的主要功能是定义并返回一个函数对象(下面称之为替换函数)。这个函数对象中,完成我们需要的面向切面的功能,并且调用功能函数,返回其返回值。

当上述代码所在的模块文件被importlib加载并执行时,加载器(Loader)发现存在'@'语法糖,于是执行:

# block 4
buggy_incr_by = snoop(buggy_incr_by)

结合snoop的代码不难发现,snoop将返回一个名为wrapper的函数对象(替换函数),赋值给buggy_incr_by,所以此后调用buggy_incr_by,实际上就是在调用这个wrapper。

下面是写一个最简单的装饰器时的一般要诀: 1. 装饰器decorator只接受一个形参(名字可以任意取),这个形参将模块加载器调用exec_module时,从@注解的下一行函数的定义中找到被定义的函数对象传入。见上一个代码块的说明。 2. 装饰器的函数体必须定义并返回一个wrapper函数(名字可以任意取)。这个wrapper(替换)函数的签名一般情况下等同于功能函数。例外情况在下文中叙述。 3. 在添加装饰器注解(即'@'语法)时,不需要显式地将功能函数参数传给装饰器,这将由模块加载器自动完成。因此,如果装饰器只有这一个参数,注解中必须是不带括号引用,见上面第2行。 4. 如果功能函数有返回值,则在wrapper的函数体中,也需要将返回值返回,参见block 2第6行。

通过上述分析,我们还有几个重要的结论: 1. 装饰器语法在模块加载时就运行了,并且重新定义了功能函数的指向(即上述代码中的wrapper)。 2. 在定义wrapper时,功能函数并没有真正被调用,因此需要延迟绑定的参数,比如self对象,此时是不存在的。 3. 在代码的其它地方调用功能函数时,实际上是在调用上述wrapper,此时实现参数的绑定(即给形参赋值)。

3. 找回丢失的调试信息

从前面的分析可以看出,功能函数在模块加载过程中,实际上被替换成了wrapper函数。我们可以通过下面的测试来发现这一点:

print(buggy_incr_by.__name__)
# --output---
wrapper

显然buggy_incr_by已经被替换了。但这里也暴露出一个问题:如果程序出错,则在需要显示栈信息的地方,则都会显示为wrapper,而不是功能函数的名字。比如下面一例:

def snoop(func):
   def wrapper(number):
     print("passed in param is ", number)
     result = func(number)
     print("buggy_incr_by returned ", result)
   return wrapper

@snoop
def buggy_incr_by(number):
   import random
   breakpoint()
   return random.randint(0,10) + number

buggy_incr_by(3)

我们在第11行放置了一个断点,运行之后,我们查看堆栈信息如下:

-> command.run()
  /mnt/c/Program Files/JetBrains/PyCharm/plugins/python/helpers/pydev/_pydev_bundle/pydev_console_types.py(35)run()
-> self.more = self.interpreter.runsource(text, '<input>', symbol)
  /home/aaron/miniconda3/envs/soloquotes/lib/python3.8/code.py(74)runsource()
-> self.runcode(code)
  /home/aaron/miniconda3/envs/soloquotes/lib/python3.8/code.py(90)runcode()
-> exec(code, self.locals)
  <input>(14)<module>()
  <input>(4)wrapper()

断点设置在bugg_incr_by中,但显示的最底层的函数名却为wrapper,这会使得调试变困难,因此我们需要更正这一信息。

函数作为一种对象,它有以下元属性:

#  __module__,  __name__,  __qualname__,  __doc__,  __annotations__

for name in ['__module__', '__name__', '__qualname__', '__doc__', '__annotations__']:
    print(getattr(buggy_incr_by, name))

# --output--
__main__
wrapper
snoop.<locals>.wrapper
None
{}

我们需要用功能函数的这些元属性来改写替换函数的相关属性:

setattr(buggy_incr_by, '__name__', 'gime new name')
for name in ['__module__', '__name__', '__qualname__', '__doc__', '__annotations__']:
    print(getattr(buggy_incr_by, name))
#--output--
__main__
gime new name
snoop.<locals>.wrapper
None
{}

通过使用setattr,我们可以很容易替换掉这些信息。我们看到buggy_incr_by现在有了新的名字,即'gime new name'

不过,我们没有必要亲自去做这些琐事。我们可以在代码段block 2的第三行,即在wrapper之前,调用functools.wraps来为我们解决这个问题,这里functools.wrapper是另一个装饰器:

import functools
def snoop(func):
  @functools.wraps(func)    # wraps需要接收func参数
  def wrapper(number):
    print("passed in param is ", number)
    result = func(number)
    print("buggy_incr_by returned ", result)
  return wrapper

@snoop
def buggy_incr_by(number):
  import random
  breakpoint()
  return random.randint(0,10) + number

buggy_incr_by.__name__

注意第3行的注释。很显然functools.wraps需要这个参数,因为它要从func中获取__name__,__qualname__, __doc__等信息,以便去更新下面的wraper。实际上,functools.wraps是接收了两个函数对象作为参数。 从func中获取__name__,__qualname__, __doc__等信息,以便去更新下面的wraper。实际上,functools.wraps是接收了两个函数对象作为参数。