ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

HoRain云--从零理解 Python 装饰器:给函数增加能力而不改原函数

HoRain云--从零理解 Python 装饰器:给函数增加能力而不改原函数

在多个函数周围添加计时、日志或权限检查时,直接复制代码很容易造成重复。装饰器允许我们在不修改函数主体的情况下,给函数增加通用行为。

装饰器看起来抽象,其核心却只有一句话:接收一个函数,返回一个新函数。

一、函数也是可以传递的对象

Python 中的函数可以赋值给变量:

defgreet(name):returnf"你好,{name}"say_hello=greetprint(say_hello("小林"))

这里的say_hello指向函数对象。既然函数能放进变量,也就能作为参数传给其他函数,并作为返回值传出来。

先看一个最简单的包装函数:

defannounce(func):defwrapper():print("开始执行")result=func()print("执行结束")returnresultreturnwrapper

announce()接收原函数,返回新函数wrapper()。调用包装后的函数时,额外逻辑会在原函数前后执行。

二、@是应用装饰器的语法

@announcedeftask():print("处理任务")task()

可以把它理解为定义完task后,再执行:

task=announce(task)

因此,调用task()时,调用的实际是包装后的函数。装饰器并不是一个与普通函数毫无关系的新机制;@主要让包装过程写起来更自然。

不过,前面的wrapper()不能接收参数。实际项目中,包装器一般需要支持位置参数和关键字参数:

fromfunctoolsimportwrapsdefannounce(func):@wraps(func)defwrapper(*args,**kwargs):print(f"开始执行:{func.__name__}")result=func(*args,**kwargs)print("执行结束")returnresultreturnwrapper

这里还有一个容易遗漏的细节:必须把原函数的返回值传回去。否则,装饰前能返回数据的函数,装饰后会意外得到None。

三、functools.wraps有什么用

包装后的函数对象默认叫wrapper,其文档字符串等信息也来自包装函数。这会影响调试、帮助文档以及部分依赖函数元信息的工具。

@wraps(func)会保留原函数的常用元信息。因此编写通用装饰器时,通常应当使用它。

四、实战:统计函数运行时间

importtimefromfunctoolsimportwrapsdefmeasure_time(func):@wraps(func)defwrapper(*args,**kwargs):start=time.perf_counter()try:returnfunc(*args,**kwargs)finally:elapsed=time.perf_counter()-startprint(f"{func.__name__}耗时{elapsed:.4f}秒")returnwrapper@measure_timedefcalculate(limit):returnsum(range(limit))print(calculate(100_000))

perf_counter()适合测量经过的时间。把输出放进finally,意味着原函数正常返回或抛出异常时,都会记录耗时。异常不会被这个装饰器吞掉,而是继续向外传播。

一次运行的计时结果不能当成严格的性能结论。机器负载、输入规模以及程序预热情况都可能影响耗时。装饰器适合快速观察,严谨比较还需要专门的基准测试。

五、带参数的装饰器为什么多一层

如果希望写成@repeat(3),就需要先接收装饰器自己的参数,再返回真正用于包装函数的装饰器:

fromfunctoolsimportwrapsdefrepeat(times):defdecorator(func):@wraps(func)defwrapper(*args,**kwargs):result=Nonefor_inrange(times):result=func(*args,**kwargs)returnresultreturnwrapperreturndecorator

使用方式:

@repeat(3)defsay(message):print(message)say("你好")

理解时按顺序看:repeat(3)先生成decorator;decorator接收say并返回wrapper;调用say()时,实际上进入wrapper()。

这个示例适合说明结构,但不能随意用于有副作用的函数。发送邮件、扣款或写入数据若重复三次,可能造成严重问题。真正的重试逻辑还必须考虑异常类型、最大次数、等待时间和操作是否允许重复执行。

六、多个装饰器的顺序

@first@seconddefwork():pass

它近似等价于:

work=first(second(work))

距离函数最近的second先包装函数,first再包装其结果。顺序不同,行为可能不同。多个装饰器叠加时,应明确每一层处理的是参数、返回值,还是异常。

装饰器适合多个函数都需要的通用逻辑。若一段逻辑只属于一个函数,直接写在函数内部可能更清楚。掌握装饰器不意味着处处使用它;能让调用关系易读,才是好的抽象。

七、异常与返回值要保持原有语义

包装函数并不意味着可以随意改变原函数的契约。原函数返回一个值,装饰后通常仍应返回这个值;原函数抛出异常,装饰器若没有明确的恢复策略,就不应悄悄返回None。例如日志装饰器可以记录异常后重新抛出:

importloggingfromfunctoolsimportwraps logger=logging.getLogger(__name__)deflog_failure(func):@wraps(func)defwrapper(*args,**kwargs):try:returnfunc(*args,**kwargs)exceptException:logger.exception("%s 执行失败",func.__name__)raisereturnwrapper

这里捕获较宽的Exception是为了在应用边界记录失败,随后立即重新抛出,未把错误当成成功。若上层也会记录相同异常,则应选择一个合适层级记录,避免重复日志。记录参数时还要防止敏感值进入日志。

八、用具体调用理解叠加顺序

假设first在调用前打印 A,second在调用前打印 B:

deffirst(func):@wraps(func)defwrapper():print("A")returnfunc()returnwrapperdefsecond(func):@wraps(func)defwrapper():print("B")returnfunc()returnwrapper@first@seconddefwork():print("工作")

调用work()时会依次输出 A、B、工作。虽然second在定义阶段先包装原函数,但实际调用从最外层的first开始。把“包装顺序”和“调用进入顺序”区分开,就不容易混淆。

九、测试装饰器时检查什么

装饰器是可复用代码,测试应覆盖返回值、位置参数、关键字参数、异常传播,以及是否保留__name__等信息。带参数装饰器还应检查边界值,例如重复次数为零或负数时究竟允许、拒绝,还是有明确默认行为。若装饰器维护共享状态,还要考虑多线程或异步场景,不能默认一个局部演示就适用于所有执行方式。

装饰器的价值在于将真正重复的逻辑集中起来,而不是制造额外的间接层。先确认需求在多个函数中稳定存在,再抽取为装饰器,通常比预先设计一个通用框架更容易维护。

十、装饰器在定义阶段执行

@decorator应用在函数定义处,意味着创建包装函数的过程通常发生在模块加载、定义函数的时候;真正的wrapper主体则在调用函数时运行。这两种时机不能混为一谈。

例如带参数装饰器@repeat(3)会在定义时先执行repeat(3),得到装饰器;每次调用目标函数,才执行包装器中的循环。因此不应在装饰器工厂中意外执行昂贵操作,或者假设它会在每次函数调用时重新读取配置。

装饰器也可以用于方法,但方法调用时的self会作为位置参数传入,通用的*args, **kwargs包装器可以转发它。若装饰器需要根据实例状态作判断,必须明确实例的位置及函数调用约定。对于异步函数,普通同步包装器并不自动具备正确的await行为,需要编写适合异步调用的包装逻辑。

学习装饰器时,最有效的方法是把@写法还原成赋值表达式,画出“原函数、包装函数、最终变量”之间的关系,再逐步加入参数和返回值。理解执行时机与调用顺序后,计时、日志等常见用法都会顺理成章。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进