Python 中用于计时、重试、缓存和校验的装饰器

Python 中用于计时、重试、缓存和校验的装饰器

💡 原文英文,约3700词,阅读约需14分钟。
📝

内容提要

本文讲解Python装饰器的原理及四种实用模式:使用@语法、闭包和functools.wraps保留原函数元数据;计时装饰器用try/finally确保异常时也记录耗时;重试装饰器工厂只捕获指定异常,并在最后一次重新抛出而非吞掉;缓存优先使用lru_cache,仅适用于纯函数和可哈希参数;校验装饰器借助inspect.signature绑定参数,在调用前检查不变量。装饰器应保持简单,堆叠顺序会影响行为,逻辑复杂时应显式组合。

🔎

延伸解读

装饰器堆叠顺序的实际影响

文章指出装饰器按从下到上的顺序应用,最上层的装饰器是最外层包装,每次调用最先执行。例如,若希望缓存不存储被拒绝的调用,应将校验装饰器放在缓存外层;若希望计时包含重试耗时,应将计时装饰器放在最外层。但这样会使 cache_info() 只能通过 __wrapped__ 访问。堆叠三个以上装饰器时,读者难以预测调用行为,此时应显式组合函数。

重试装饰器的安全边界

文章强调重试仅适用于可安全重复执行的调用,如获取词汇表。对于支付、发消息、写文件等有副作用的操作,超时可能发生在写入成功后,重试会导致重复写入。此外,重试装饰器必须只捕获配置的异常,并在最后一次尝试时重新抛出异常,而不是返回 None,否则会将失败转为静默错误。time.sleep() 会阻塞线程,生产环境应考虑带退避和抖动的客户端库。

lru_cache 的隐藏成本与限制

lru_cache 要求参数可哈希,且会持有参数和返回值的引用直到被驱逐或清除,因此缓存大数组会延长其生命周期。关键字参数顺序可能影响键,f(a=1, b=2) 与 f(b=2, a=1) 可能占用两个条目。maxsize=None 适合小固定域,但在长期运行且输入来自用户的进程中会导致内存泄漏。缓存返回可变对象时,所有调用者共享同一对象,修改会影响后续结果。cache_info() 可帮助判断缓存是否有效。

校验装饰器的适用边界

文章中的校验装饰器利用 inspect.signature() 在装饰时计算签名,调用时用 bind() 绑定参数并 apply_defaults() 填充默认值,从而在函数执行前检查不变量。它不读取类型注解,因为运行时注解不强制执行。该装饰器适合一个函数参数上的两三个谓词,但不适合字段间有依赖的记录;后者应使用 dataclass 的 __post_init__ 或 Pydantic 等模式库。装饰器应守卫调用,而非充当模式。

❓

Q&A

Python 装饰器到底是怎么工作的?@ 语法背后发生了什么?

装饰器本质上是函数重新赋值。@decorator 放在 def 上方等价于 func = decorator(func)。装饰器表达式在函数定义时求值,返回的值绑定到原函数名。因此装饰器本身在导入时运行一次,而返回的包装函数在每次调用时运行。

为什么写装饰器一定要加 functools.wraps?不加会怎样?

不加 functools.wraps,包装函数会丢失原函数的 __name__、__doc__、模块和注解,导致 traceback 显示 wrapper、文档生成空条目、性能分析器列出多个同名 wrapper。functools.wraps 会复制这些元数据,并设置 __wrapped__ 指向原函数,方便测试和工具绕过装饰器直接调用原函数。

如何写一个计时装饰器,让函数抛异常时也能记录耗时?

在包装函数中用 try/finally 包裹原函数调用:开始前记录 time.perf_counter(),在 finally 中计算差值并打印。这样无论成功还是异常,耗时都会输出,且异常会原样传播给调用者。注意 perf_counter() 只适合测量时间差,不能当作时间戳记录。

重试装饰器怎么只捕获指定异常,并在最后一次失败时重新抛出?

使用装饰器工厂:@retry(max_attempts=3, exceptions=(TimeoutError,)) 先返回装饰器,再接收函数。在包装函数的循环中,except 只捕获配置的异常元组;如果不是最后一次尝试,打印并 sleep 后重试;如果是最后一次,用裸 raise 重新抛出原异常,保留 traceback。不要返回 None,否则会把失败变成静默错误。

functools.lru_cache 适合缓存什么样的函数?有哪些坑?

lru_cache 适合纯函数且参数可哈希、结果会重复出现的场景。坑包括:参数必须可哈希(传列表会 TypeError);缓存会持有参数和返回值的引用,可能造成内存泄漏;maxsize=None 永不淘汰,长期运行可能内存泄漏;返回可变对象时所有调用者共享同一对象;关键字顺序可能影响键。有副作用的函数、依赖时间的函数不应缓存。

校验装饰器如何检查参数,即使参数是位置传递或使用默认值?

使用 inspect.signature(func) 获取签名,在包装函数中调用 sig.bind(*args, **kwargs) 将实际参数映射到参数名,再调用 bound.apply_defaults() 填充默认值。然后遍历规则字典,对每个参数值执行谓词函数,不满足则抛出 ValueError。签名在装饰时计算一次,bind 每次调用执行。注意注解在运行时不会强制校验。

多个装饰器堆叠时顺序如何影响行为?

装饰器从下往上应用:@f1 在 @f2 上方等价于 func = f1(f2(func)),所以最上面的装饰器是最外层包装,每次调用最先运行。顺序决定哪个装饰器看到缓存结果或校验错误。例如把校验放在缓存外层,被拒绝的调用不会进入缓存;把计时放在最外层,耗时包含重试。堆叠超过三个且顺序重要时,建议显式组合函数。

🏷️

标签

➡️

继续阅读