装饰器深水区:机制拆解与工程模式
拆解装饰器的机制与工程模式:带参数装饰器三层结构逐层追踪、叠加顺序推演、类装饰器 __call__、functools.wraps 缺失的真实代价,以及 lru_cache、singledispatch、contextmanager 三个标准库实战。
前置知识
- 必读 装饰器。分工声明:500 篇负责建立心智模型(@ 是换绑定的语法糖、wrapper 转发参数、wraps 保元信息、带参数装饰器初见);本篇是分工的另一半——拆机制、讲工程模式,不重复入门示例,两篇示例互不复用;
- 面向对象:知道类、实例与方法即可。类装饰器会用到
__call__(让实例能像函数一样被调用),正文当场解释。
学习目标
读完本文你将能够:
- 逐层追踪带参数装饰器中「配置、函数、调用参数」各被谁接住,写出等价展开式;
- 推演
@a @b叠加时的应用顺序与运行顺序,正确预测输出; - 用类加
__call__写出带状态的装饰器,说清它与闭包版的取舍; - 列出不用
functools.wraps的三项真实代价,并用它修复; - 用
lru_cache、singledispatch、contextmanager三个标准库装饰器解决真实问题。
预计 75 分钟,含 1 组修改实验、2 道预测题与 1 道挑战题。
1. 两笔遗留账
接手同事留下的 decorators.py,第一屏就卡住:
def audit(action):
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
...500 篇的 timed 只有一层,这个为什么三层?@audit("下单") 与 @timed 差的那个括号背后发生了什么?这是账一。第二笔账你在 500 篇第 3 节见过症状:没用 wraps 时 __name__、__doc__、函数签名集体污染——真实代价到底多大?本文逐笔算清,最后再给你三件标准库的现成货。
2. 账一:三层嵌套逐层追踪
在每一层加一行 print,让参数去向自己招供:
from functools import wraps
def audit(action): # 第一层:吃装饰器参数
print(f"[1] audit 收到配置 action={action}")
def decorator(func): # 第二层:吃被装饰函数
print(f"[2] decorator 收到函数 {func.__name__}")
@wraps(func)
def wrapper(*args, **kwargs): # 第三层:吃调用参数
print(f"[3] wrapper 收到调用参数 {args}")
return func(*args, **kwargs)
return wrapper
return decorator
@audit("下单")
def buy(item):
return f"{item} 购买成功"
print(buy("限定皮肤"))预期输出:
[1] audit 收到配置 action=下单
[2] decorator 收到函数 buy
[3] wrapper 收到调用参数 ('限定皮肤',)
限定皮肤 购买成功[1]、[2] 在任何业务调用之前出现——它们发生在 def 语句执行时,因为 @audit("下单") 的等价展开是两次连续调用:
buy = audit("下单")(buy)先调用 audit("下单"):打印 [1],返回 decorator——这一层是装饰器工厂,专职吃配置。再调用 decorator(buy):打印 [2],返回 wrapper——这一层是真装饰器,专职吃函数。真正调用 buy("限定皮肤") 时跑的是 wrapper,打印 [3]——最内层专职吃调用参数。三层各司其职:没有工厂,配置挂在哪?没有 decorator,函数传给谁?没有 wrapper,调用参数谁转发?至于「带不带括号」的两种报错形态,第 8 节实录见分晓。
3. 叠加顺序推演:@a @b 到底谁先
装饰器叠加时,应用自下而上,运行自外而内。用两层中性的 stamp 推演:
from functools import wraps
def stamp(tag):
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
print(f"进入 {tag}")
result = func(*args, **kwargs)
print(f"离开 {tag}")
return result
return wrapper
return decorator
@stamp("外层")
@stamp("内层")
def query(word):
print(f"查询 {word}")
query("python")预期输出:
进入 外层
进入 内层
查询 python
离开 内层
离开 外层推演写开:先应用下面的 stamp("内层") 得到第一个 wrapper,再应用上面的 stamp("外层") 把它包住,等价展开是:
query = stamp("外层")(stamp("内层")(query))外层 wrapper 包在最外面,所以它的「进入」最先打印、「离开」最后打印,像洋葱一层套一层。每叠一层就是一次完整的函数调用开销——这是「装饰器别叠超过三层」的物理原因。
4. 类装饰器:需要状态时换类上场
统计函数被调用了多少次,闭包版要在 wrapper 外面维护计数变量(还得用列表当外壳,090 篇讲过为什么)。状态一多闭包就凌乱,类天生装状态:
import functools
class CountCalls:
def __init__(self, func):
functools.update_wrapper(self, func) # 类版的 @wraps
self.func = func
self.count = 0
def __call__(self, *args, **kwargs): # 让实例能像函数一样被调用
self.count += 1
return self.func(*args, **kwargs)
@CountCalls
def roll_dice(sides=6):
return f"掷出 {sides} 面骰"
print(roll_dice())
print(roll_dice())
print(roll_dice.count)
print(roll_dice.__name__)预期输出:
掷出 6 面骰
掷出 6 面骰
2
roll_dice关键在 __call__:定义了它的实例可以加括号调用。@CountCalls 展开是 roll_dice = CountCalls(roll_dice)——名字贴到实例上,之后每次 roll_dice() 都在调实例的 __call__,状态存进 self.count,随取随用还能加方法(比如 reset())。update_wrapper 是 wraps 的函数形态,把原函数元信息抄到实例上,所以第四行输出仍是 roll_dice。取舍:无状态增强用闭包(500 篇写法更短);多个状态、状态可读可改、要复用配置,用类——两者机制同构,都是「接函数、返回可调用物」。
5. 账二:不用 wraps 的真实代价
把 500 篇的代价列全,一段代码验证:
import inspect
def plain(func):
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@plain
def greet(name, greeting="你好"):
"""问候"""
return f"{greeting},{name}"
print(greet.__name__, "|", greet.__doc__)
print(inspect.signature(greet))预期输出:
wrapper | None
(*args, **kwargs)三项代价:__name__ 污染让日志、监控、序列化工具集体失明;__doc__ 丢失让 help(greet) 读不出文档;签名变成 (*args, **kwargs),IDE 补全与基于签名的文档生成全部失效——调用方本该看到 (name, greeting='你好')。叠加两层且都没加 wraps 时,wrapper 里还套着 wrapper,污染加倍。修复照旧是 @wraps(func) 一行,修好后签名还原;wraps 还设置 __wrapped__ 指向最初那个函数,需要绕过包装时用它逃生。纪律:每个 wrapper 头上都必须有 @wraps,没有例外。
6. 标准库三件套:别急着手写
手写之前先看标准库,这三件覆盖八成日常需求。
lru_cache:给纯函数加缓存
递归斐波那契不带缓存时,fib(30) 要调用 2692537 次(用一个列表计数器现场数:calls = [0],函数体里 calls[0] += 1)。加上缓存:
from functools import lru_cache
@lru_cache(maxsize=None)
def fib(n):
return n if n < 2 else fib(n - 1) + fib(n - 2)
fib(30)
print(fib.cache_info())预期输出:
CacheInfo(hits=28, misses=31, maxsize=None, currsize=31)只发生 31 次未命中——每个值只真正算一次,其余全是命中。cache_info() 随时报命中率;maxsize 限制条目数,超出按「最近最少使用」淘汰。注意参数必须可哈希(列表不行,元组可以),因为缓存键就是参数元组。
singledispatch:按参数类型分派
处理异构消息,与其写一长串 isinstance 分支,不如按类型注册处理函数:
from functools import singledispatch
@singledispatch
def render(value):
raise TypeError(f"不支持的消息类型: {type(value).__name__}")
@render.register
def _(value: int):
return f"积分变动: {value} 分"
@render.register
def _(value: str):
return f"系统公告: {value}"
print(render(100))
print(render("今晚停服维护"))预期输出:
积分变动: 100 分
系统公告: 今晚停服维护singledispatch 装饰基础版本,register 按第一个参数的类型注解注册变体;没有匹配类型时落到基础版本,这里选择抛 TypeError。这就是 Python 的「函数重载」形态,底层正是一个装饰器加一张注册表。
contextmanager:把收尾逻辑做成 with
进战斗前自动存档、退出时回滚——「进入、退出」成对的逻辑适合 with。@contextmanager 把一个生成器一分为二:yield 之前是进入逻辑,finally 里是退出逻辑:
from contextlib import contextmanager
@contextmanager
def battle_save(slot):
print(f"[存档] 写入槽位 {slot}")
try:
yield
finally:
print(f"[回滚] 读取槽位 {slot}")
with battle_save("auto"):
print("战斗中:血量降到 1")预期输出:
[存档] 写入槽位 auto
战斗中:血量降到 1
[回滚] 读取槽位 auto本文只把它当装饰器实战案例;with 与 __enter__/__exit__ 的完整机制,上下文管理器 整篇讲。
7. 修改实验
基于以上代码,每个先预测再运行:
- 给 CountCalls 加
reset()方法:调用 roll_dice 两次后归零。思考为什么闭包版改计数变量要绕一层(090 篇的不可变对象换绑),类版直接self.count = 0; render(3.14)现在抛 TypeError——照第 6 节的注册写法补一个 float 处理器,让输出变成金币: 3.14;- lru_cache 的
maxsize改成 8 再跑fib(30),misses 还是 31 吗?对照「最近最少使用淘汰」解释 cache_info 的变化。
8. 常见错误与调试实录
装饰器工厂漏掉最外层括号——把工厂本身当成了装饰器:
# deco.py
from functools import wraps
def retry(times):
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
return decorator
@retry # 漏了括号
def fetch():
return "ok"
fetch()报错(Python 3.12 实录):
Traceback (most recent call last):
File "deco.py", line 16, in <module>
fetch()
~~~~~^^
TypeError: retry.<locals>.decorator() missing 1 required positional argument: 'func'读报错三步:崩溃在 fetch() 调用行;报错说 decorator() 缺参数 func——可 fetch() 没传过参数;名字上移:@retry 让 fetch = retry(fetch),retry 的 times 接住的是函数,返回的 decorator 成了 fetch 的新名字,调用 fetch 实际是调用 decorator,它还等着接函数。修复:写成 @retry(times=3),让工厂先吃配置。记法:@ 后面必须是一个「能接收函数的东西」。
9. 小练习
预测题(10 分钟,先写答案再运行):
- 把第 3 节的
@stamp("外层")与@stamp("内层")交换位置,query("python")的输出顺序变成什么?参考答案(先算再看):进入 内层、进入 外层、查询 python、离开 外层、离开 内层——交换后原内层变成最外包装,运行顺序整体反转; fib(30)之后立刻调用fib(31),cache_info 的 hits 与 misses 各是多少?参考答案:hits=30、misses=32——fib(31) 只需新算自己一次(+1 miss),它用到的 fib(30) 与 fib(29) 都在缓存里(+2 hits)。
挑战题(半小时,不给代码):写带参数装饰器 suppress(*exc_types):捕获指定类型的异常并返回 None,其它异常原样抛出,并保住原函数元信息。验收:
@suppress(ValueError, TypeError)
def parse(text):
return int(text)
assert parse("42") == 42
assert parse("坏数据") is None
assert parse.__name__ == "parse"
@suppress(ValueError)
def boom():
raise RuntimeError("不该被吞")
try:
boom()
except RuntimeError:
print("RuntimeError 原样抛出")提示:三层结构,最外层吃异常元组;展开:except 后面可以直接接异常元组 except exc_types,捕获后 return None。
自检问题:
- 不看正文默画三层结构,标出每层收到的东西,写出
@audit("下单")的等价展开式; - 为什么类装饰器适合「有状态」的增强?说出两个理由;
- plain 装饰器叠加两层且都没加 wraps,
inspect.signature看到什么?__wrapped__指向谁? - lru_cache 为什么要求参数可哈希?
10. 什么时候该用 / 不该用装饰器
该用:标准库优先——lru_cache、singledispatch、contextmanager、dataclass 都是现成装饰器,先找再写;需要跨调用状态或可配置增强用类装饰器;插件注册表(import 时自动收集函数与类)是装饰器的杀手级应用,设计模式 展开单例与注册器。不该用:def 时有副作用(注册、路由收集)的装饰器必须写明「import 时发生了什么」;叠加超过三层改用显式函数组合,洋葱太厚没人剥得开。
11. 与之前和之后的知识的关系
- 往前:装饰器 的心智模型是本文全部地基——三层嵌套是 500 篇「初见」的机制答案,类装饰器的
__call__是 460/480 篇魔术方法的落地; - 往后:上下文管理器 拆 with 的底牌;dataclass 与 Pydantic 的 @dataclass 是官方「装饰器改写类」范例;描述符 解释 cached_property 这类装饰器的底层;协程 里的 async 装饰器是同一套思想的重演。
官方文档
- functools(wraps、lru_cache、singledispatch):https://docs.python.org/zh-cn/3/library/functools.html
- contextlib(contextmanager):https://docs.python.org/zh-cn/3/library/contextlib.html
- PEP 318(装饰器语法):https://peps.python.org/pep-0318/
自我检查
- 能逐层说出带参数装饰器每层的职责,并推演两个叠加装饰器的展开式;
- 能写出带
__call__的类装饰器,说清 update_wrapper 与 @wraps 的关系; - 能列出不用 wraps 的三项代价,见到「日志全叫 wrapper」「签名变 args/kwargs」知道修哪里;
- 见到
missing 1 required positional argument: 'func'能想到「工厂漏了括号」。
本章总结
带参数装饰器是「工厂 + 装饰器 + wrapper」三层结构:@audit("下单") 先调用工厂吃配置、再装饰吃函数、wrapper 吃调用参数,等价展开 buy = audit("下单")(buy)。叠加装饰器应用自下而上、运行自外而内。需要状态用类装饰器:__call__ 让实例可调用,update_wrapper 是类版的 wraps。不用 wraps 的三项代价是名字污染、文档丢失、签名失效。标准库三件套——lru_cache 缓存纯函数、singledispatch 按类型分派、contextmanager 生成 with——覆盖八成需求,先找现成再手写。
下一步
进入 上下文管理器:本文的 contextmanager 只露了半张脸,with 语句与 __enter__/__exit__ 的完整机制在那篇揭底。