PYTHON TUTORIAL
Python 装饰器到底是什么?从函数对象、闭包到实战代码完整拆解
看懂函数如何被包装、替换与增强,掌握日志、计时、重试和权限检查的通用写法。
先从一个常见疑问开始
很多人在开展 Python 学习的时候,第一次看到函数上方出现 @timer、@retry 或者 @login_required 这样的代码,往往都会产生一个比较直接的疑问:这究竟属于什么样的特殊语法?为什么只是在原有函数的上方增加一行代码,就能够让这个函数拥有运行时间统计、失败重试、日志记录以及权限检查等多个方面的能力?
这篇内容会讲清楚
01 函数对象与闭包为什么是装饰器的基础
02 wrapper 如何在不改原函数的前提下扩展能力
03 实战场景、常见错误与多层装饰顺序
01 / FOUNDATION
先理解函数对象与闭包
装饰器成立的前提,是函数可以被赋值、传参和返回。
实际上,Python 装饰器并不是什么难以理解的魔法功能。它在本质方面的逻辑相对比较简单,也就是接收一个函数,然后在原有函数的基础上返回一个经过包装之后的新函数。
在正式理解装饰器之前,需要先明确 Python 函数所具有的一个重要特性:在 Python 语言当中,函数本身也是一种对象。函数不仅能够像平常一样被调用,同时还可以赋值给变量、作为参数传递给其他函数,也能够作为另外一个函数的返回结果进行使用。
例如,可以先定义一个 greet 函数,然后把它赋值给 say_hello 变量。即便后续通过 say_hello() 的方式开展调用,仍然能够执行原来 greet 函数当中的相关逻辑。这说明变量保存的并不是函数执行之后的结果,而是对函数对象本身的引用。
在理解函数可以被传递之后,再去观察闭包的相关概念,往往就会容易很多。所谓闭包,主要是指内部函数引用了外部函数作用域当中的变量,并且在外部函数已经执行结束之后,内部函数仍然可以继续访问以及使用这些变量。
装饰器之所以能够实现函数能力的扩展,正是因为同时利用了两个方面的特性:一方面,函数对象可以作为参数进行传递;另一方面,闭包可以保存外部函数作用域当中的相关状态。
KEY POINT
函数可以传递,闭包可以保存状态,这两点共同构成了装饰器的基础。
02 / MECHANISM
装饰器的本质:替换函数引用
先看一段基础代码,再拆开 @decorator 背后的等价过程。
from functools import wraps
def log_call(func):
@wraps(func)
def wrapper(*args, **kwargs):
print(f"开始调用:{func.__name__}")
result = func(*args, **kwargs)
print(f"调用结束:{func.__name__}")
return result
return wrapper
在这段代码当中,log_call 函数会接收一个名为 func 的函数对象。它的内部又定义了一个 wrapper 包装函数,用来在原函数执行之前以及执行之后,分别增加相对应的日志输出逻辑。
wrapper 使用了 *args 和 **kwargs 来接收参数,这样就能够适配不同参数形式的函数,而不需要提前把参数数量以及参数名称全部写死。在调用原函数之后,还需要把执行结果保存到 result 当中,并且在最后将这个结果重新返回出去。
@log_call
def add(a, b):
return a + b
def add(a, b):
return a + b
add = log_call(add)
也就是说,原来的 add 函数首先会被作为参数传递给 log_call。随后,log_call返回内部定义的 wrapper 函数,并且变量 add 会重新指向这个已经经过包装的新函数。
03 / CALL FLOW
wrapper 如何接管一次函数调用
被装饰后的函数先进入包装层,再由包装层调用原函数。
因此,当后续调用 add(2, 3) 的时候,程序并不会直接进入原来的加法函数,而是会先进入 wrapper。包装函数首先执行原函数调用之前的日志逻辑,然后通过 func(*args, **kwargs)调用最开始定义的 add 函数,接着执行调用结束后的相关逻辑,最后再把原函数返回的计算结果传递出去。
从这个过程可以看出,装饰器并没有直接修改原函数内部的代码,而是在原函数外部增加了一层包装。这样就能够在不改变原有业务逻辑的情况下,为多个函数统一增加相对应的功能。
2调用原函数通过 func(*args, **kwargs) 传入原始参数。
3完成后置逻辑并返回执行调用结束后的逻辑,并把原函数结果继续传出去。
04 / PRACTICE
装饰器在项目中的常见应用
日志、计时、缓存、重试、权限检查和参数校验都属于典型场景。
在实际开展项目开发的时候,装饰器经常会被应用在日志记录、运行时间统计、缓存处理、接口重试、权限检查以及参数校验等多个场景当中。
例如,计时装饰器可以在函数执行之前记录开始时间,在函数执行结束之后计算整体耗时,从而统一完成性能方面的统计工作。重试装饰器则可以在网络请求暂时失败的时候,按照设定的次数重新开展请求,而不需要在每一个业务函数内部重复编写相同的重试代码。
权限检查装饰器通常会在原函数执行之前,先判断当前用户是否已经登录,或者是否具备访问相关资源的权限。只有验证通过之后,才会继续调用真正的业务函数。通过这种方式,可以把通用的权限逻辑与具体业务逻辑进行一定程度上的分离。
05 / PITFALLS
五个高频问题与多层装饰顺序
装饰器能否稳定复用,关键在于是否保留返回值、元数据、参数和异常语义。
不过,在编写以及使用装饰器的时候,也存在一些相对高频的问题。
第一个问题,就是在包装函数当中忘记返回原函数的执行结果。如果只调用了 func(*args, **kwargs),却没有使用 return 把结果传递出去,那么经过装饰之后的函数可能会默认返回 None,从而改变原函数原本所具有的行为。
第二个问题,是没有使用 functools.wraps。如果直接定义并返回 wrapper,那么装饰后的函数名称、说明文档以及其他元数据信息,可能都会变成包装函数自身的信息。使用 @wraps(func),可以在较大程度上保留原函数的 __name__、__doc__ 等相关属性。
第三个问题,是把包装函数的参数形式写死。如果原函数之间的参数数量以及传递方式并不完全相同,那么固定参数的装饰器就难以重复使用。因此,在通用装饰器当中,通常会使用 *args 和 **kwargs 来接收位置参数以及关键字参数。
第四个问题,是在装饰器内部捕获异常之后没有继续抛出,导致真正的错误被直接吞掉。某些重试装饰器会在捕获异常之后重新发起调用,但是当所有重试次数都已经使用完毕时,仍然需要把最终异常进行记录或者重新抛出,否则上层代码可能无法知道函数已经执行失败。
重试边界
只重试明确指定的暂时性异常,并设置次数上限;认证失败、参数错误等问题通常不应盲目重试。
第五个容易产生混淆的地方,是多个装饰器同时使用时的执行顺序。例如:
@decorator_a
@decorator_b
def test():
pass
test = decorator_a(decorator_b(test))
也就是说,装饰器在进行包装的时候,需要按照从下往上的顺序进行理解。最靠近函数的 decorator_b 会先对原函数开展包装,然后 decorator_a 再对已经包装后的结果继续增加一层处理。
而在函数真正被调用的时候,则会先进入最外层的 decorator_a,再进入内层的 decorator_b,最后执行原始函数。因此,可以使用一句话来进行记忆:装饰时从下往上,调用时从外往内。
∞ / SUMMARY
从记模板到真正会设计装饰器
增强能力,但不破坏原函数的基本语义。
一个相对合理的装饰器,应该是在保持原函数基本语义的前提之下,为函数增加额外的能力,而不是让装饰后的函数产生完全不同或者难以预测的行为。
只有真正理解函数对象、闭包、包装函数以及重新赋值之间的关系,才能够从单纯记忆装饰器代码模板的阶段,逐渐走向能够根据实际需求自行设计装饰器的阶段,并且把相关能力稳定地应用在自动化脚本、运维工具、接口服务以及实际项目当中。
SUMMARY
装饰器不是修改原函数,而是用包装函数接管调用入口,并把结果继续传递出去。
关于作者
我是翔翔,希望这篇文章对你学习python有所帮助
如果你觉得今天这篇有收获
欢迎点赞、在看、转发三连,我们下篇见