对装饰器背后的机制感兴趣的,推荐读这篇 Python 描述符深度实战——property、slots、cached_property 全是一套协议的不同应用,跟装饰器正好凑成元编程双雄。
前言:凌晨三点,一个装饰器把我坑了
先说个真实的事。上个月有个服务半夜报警,接口全挂。我爬起来一看日志:函数签名对不上、文档全丢了、连函数名都变成了 wrapper。排查半天,罪魁祸首是一行漏掉的 functools.wraps——同事给核心函数套了个装饰器,没保留元数据,框架按名字找函数直接找错对象。
那晚之后我发誓把装饰器彻底搞明白。这篇文章就是我复盘后的产物:从语法糖本质到 functools.wraps 为什么必须用,从带参装饰器到异步装饰器,最后用 200 万次调用的实测数据告诉你:装饰器到底贵不贵。
装饰器本质:不就是个语法糖吗
很多人背了无数遍「装饰器就是闭包」,但写起来还是懵。其实记住一件事就够了:@deco 就是 f = deco(f) 的简写。函数在 Python 里是一等公民,能当参数传、能当返回值。
def deco(fn):
def wrapper(x):
print("before")
result = fn(x)
print("after")
return result
return wrapper
@deco
def double(x):
return x * 2
# 等价于:double = deco(double)
就这么点事。装饰器返回的新函数替换掉旧函数,之后调用 double 实际走的是 wrapper。如果你之前写过闭包、写过 生成器,这套「函数工厂」的思维应该不陌生。
functools.wraps:99% 的人漏掉的那一行
上面那个装饰器有个隐患:double.__name__ 变成了 wrapper。平时没事,一旦你的代码靠函数名做事——Flask 路由注册、日志过滤、序列化、调试器——就全乱了。我那次线上事故就是这么来的。
import functools
def deco(fn):
@functools.wraps(fn) # 这行不能省
def wrapper(x):
return fn(x)
return wrapper
@deco
def double(x):
"""把数字翻倍"""
return x * 2
print(double.__name__) # double(没有 wraps 会输出 wrapper)
print(double.__doc__) # 把数字翻倍(没有 wraps 会输出 None)
wraps 的原理是拷贝 __name__、__doc__、__module__ 等属性,并设置 __wrapped__ 指向原函数,让 inspect 能穿透装饰器看到真相。从今天起,你写的每个装饰器第一行必须是它。
带参装饰器:三层嵌套,别慌
有时候装饰器需要参数,比如「缓存 60 秒」「重试 3 次」。写法就是外面再包一层函数,接收参数:
def ttl_cache(seconds=60):
cache = {}
def deco(fn):
@functools.wraps(fn)
def wrapper(*args):
now = time.monotonic()
if args in cache:
val, ts = cache[args]
if now - ts < seconds:
return val
val = fn(*args)
cache[args] = (val, now)
return val
return wrapper
return deco
@ttl_cache(60)
def get_price(code):
return fetch_price(code) # 模拟昂贵调用
三层结构记一个口诀:外层收参数,中层收函数,内层收调用。很多新手把第二层和第三层合并,结果参数跑到函数参数里,运行时报错一脸懵。
装饰器叠加顺序:从下往上执行
多个装饰器叠一起,顺序经常被搞反。记住:执行时从下往上,越靠近函数的装饰器越先执行。
@auth # 后执行(外层)
@cache # 先执行(内层)
def api():
pass
# 等价于:api = auth(cache(api))
所以「先缓存再鉴权」和「先鉴权再缓存」是不同的。缓存命中就直接返回,根本走不到 auth;如果你希望所有请求都过鉴权,就得把 auth 放外面。这个顺序问题我在生产环境见过不止一次——缓存把没权限的数据发出去了。
性能实测一:装饰器调用到底贵不贵
总有人担心「加了装饰器性能就崩了」。我用 200 万次调用实测了一把(Python 3.12,裸函数 vs 各种装饰器):
结论很干脆:简单装饰器单次调用只多花约 29 纳秒(74.1 vs 44.9 ns)。哪怕你的接口每秒被调 1 万次,装饰器开销也就 0.3ms 的零头。真正吃性能的是装饰器里干的活——打日志、查缓存、跑网络请求,跟那几纳秒完全不是一个量级。所以:装饰器性能不是问题,装饰器里写慢代码才是问题。
顺带一提,异步装饰器单次调用 7 微秒的开销,大头是事件循环调度,不是装饰器本身。想深挖异步调度的,可以看我之前写的 asyncio.TaskGroup 结构化并发实战。
性能实测二:缓存装饰器能快多少
装饰器最值钱的用法之一是缓存。我用斐波那契递归(经典重复计算场景)做了个对比:fib(30) 连算 5 次,朴素递归每次重算,缓存装饰器只算一次。
| 方案 | 耗时 | 加速比 |
|---|---|---|
| 朴素递归(每次重算) | 547.9 ms | 1x |
| 自写 TTL 缓存装饰器 | 0.044 ms | 约 1.2 万倍 |
| functools.lru_cache | 0.030 ms | 约 1.8 万倍 |
但别急着给所有函数套缓存——@lru_cache 的坑我单独写过一篇 《Python @lru_cache 不是银弹》:可变参数、内存爆炸、状态不一致,每个都能在线上给你上课。缓存只该用在「反复调用 + 结果不变 + 参数可哈希」的函数上。
异步装饰器:async def 怎么包装
写异步代码的人经常卡在装饰器上:包装函数是普通 def,return 的是 coroutine 而不是结果,await 不了。解法很简单——wrapper 也写成 async:
def async_retry(times=3):
def deco(fn):
@functools.wraps(fn)
async def wrapper(*args, **kwargs):
for i in range(times):
try:
return await fn(*args, **kwargs)
except Exception:
if i == times - 1:
raise
await asyncio.sleep(0.5 * (i + 1)) # 退避
return wrapper
return deco
@async_retry(3)
async def fetch_order(order_id):
async with aiohttp.ClientSession() as s:
async with s.get(f"/orders/{order_id}") as r:
return await r.json()
注意两个细节:wrapper 内部必须 await fn(...),否则返回的是协程对象而不是结果;重试之间用 asyncio.sleep 而不是 time.sleep,后者会阻塞整个事件循环。这个错误我在 code review 里抓过太多次了。
生产级案例:限流装饰器
最后给一个完整的实战例子。给第三方 API 调用加限流,用装饰器比在业务代码里到处写判断干净得多:
import time
import functools
def rate_limit(max_per_second=5):
interval = 1.0 / max_per_second
last = [0.0] # 用列表存可变状态
def deco(fn):
@functools.wraps(fn)
def wrapper(*args, **kwargs):
now = time.monotonic()
wait = interval - (now - last[0])
if wait > 0:
time.sleep(wait)
last[0] = time.monotonic()
return fn(*args, **kwargs)
return wrapper
return deco
@rate_limit(5)
def call_quota_api():
return requests.post("https://api.example.com/v1/check")
为什么 last 用列表?因为闭包里的普通变量只能读不能写——直接 last = now 会报 UnboundLocalError,这是闭包装饰器最经典的翻车点。用可变对象绕过去,或者用 nonlocal 声明。
避坑清单
- 漏掉 functools.wraps:元数据丢失,调试和框架集成全乱(我踩过,凌晨三点的那种)
- 闭包变量只读:需要修改外层变量时用 nonlocal 或可变容器
- 装饰器顺序:从下往上执行,鉴权/缓存顺序别搞反
- 异步函数用普通装饰器包装:返回协程对象而非结果,记得 wrapper 也 async
- 缓存装饰器无脑套:可变参数、超大结果集、状态依赖的函数不适合
常见问题(FAQ)
Q: functools.wraps 不加会怎样?
函数名变成 wrapper,__doc__ 变 None。依赖函数名的框架(路由、日志、序列化)会行为异常,调试时栈信息也全是 wrapper。线上事故的常见来源。
Q: 装饰器会影响性能吗?
实测简单装饰器单次调用多花约 29 纳秒(200 万次调用 44.9ns vs 74.1ns),相对业务逻辑可忽略。真正影响性能的是装饰器内部执行的操作。
Q: 多个装饰器的执行顺序?
从下往上执行:@a @b def f() 等价于 f = a(b(f)),b 先执行,a 后执行。需要控制访问顺序时把外层装饰器写上面。
Q: 装饰器能用在异步函数上吗?
能,但 wrapper 必须写成 async def 并在内部 await 原函数,否则返回的是协程对象。异步重试、限流、超时都适合用装饰器封装。
总结
装饰器是 Python 里性价比最高的抽象之一:一个装饰器就能给一堆函数统一加缓存、重试、限流、日志。但它的坑也藏在细节里——wraps、闭包变量、叠加顺序、异步包装,每一个都值得花十分钟记牢。
想继续深挖的,这几篇跟本文是连着的:上下文管理器深度实战(with 和装饰器是 Python 两大「切面」武器)、Python 性能翻车现场(看看哪些看似很快的写法其实很慢)、类型注解进阶(装饰器配合类型标注才能写出可维护的代码)。
如果你也被某个装饰器坑过,欢迎留言——说不定下次复盘就是你贡献的案例。

