037、装饰器三部曲(二):带参装饰器、类装饰器与 functools.wraps
一个让我熬夜到凌晨三点的Bug
上周五晚上,我正美滋滋地准备下班,突然收到线上告警:某个核心API的响应时间从200ms飙升到了5秒。我赶紧翻日志,发现所有被装饰器包装过的函数,它们的__name__属性全变成了wrapper。更诡异的是,某个带参数的装饰器,参数传进去后居然被“吞掉”了,导致配置完全失效。
这种问题,十有八九是装饰器写崩了。我一边骂骂咧咧地打开IDE,一边心想:要是当初写装饰器的时候,把functools.wraps和参数传递的细节搞清楚,也不至于现在被拉出来加班。
带参装饰器:别被“两层嵌套”吓到
先看一个最常见的场景:你想写一个装饰器,能控制是否打印日志,或者指定日志级别。
# 别这样写!参数会丢失
def log(level):
def decorator(func):
def wrapper(*args, **kwargs):
print(f"[{level}] 调用 {func.__name__}")
return func(*args, **kwargs)
return wrapper
return decorator
@log("INFO")
def say_hello(name):
return f"Hello, {name}"
这段代码看起来没问题,但如果你在wrapper里想访问level,它确实能访问到——因为闭包捕获了外层变量。真正坑人的是,当你需要动态修改level时,你会发现闭包里的level是只读的,除非你用nonlocal声明。
这里踩过坑:我曾经写过一个带参装饰器,用来控制缓存过期时间。参数传进去后,在wrapper里修改了level变量,结果Python报错UnboundLocalError。解决方案很简单:在wrapper里用nonlocal level声明一下。
def cache(expire=300):
def decorator(func):
cache_data = {}
def wrapper(*args, **kwargs):
nonlocal expire # 这里踩过坑,不加nonlocal会报错
# 缓存逻辑...
return func(*args, **kwargs)
return wrapper
return decorator
类装饰器:用__call__实现更优雅的状态管理
函数装饰器写多了,你会发现一个问题:当装饰器需要维护状态(比如统计调用次数、缓存数据)时,用函数闭包会显得很别扭。这时候,类装饰器就派上用场了。
class CountCalls:
def __init__(self, func):
self.func = func
self.count = 0
def __call__(self, *args, **kwargs):
self.count += 1
print(f"第 {self.count} 次调用 {self.func.__name__}")
return self.func(*args, **kwargs)
@CountCalls
def greet(name):
return f"Hi, {name}"
类装饰器的好处是:状态变量(比如count)直接挂在实例上,不用纠结闭包里的变量作用域。但别这样写:如果你在__init__里做了耗时操作,比如读取配置文件,那每次装饰一个函数都会执行一次——这通常不是你想要的。
更骚的操作是:用类装饰器实现带参数的装饰器。这时候需要三层嵌套:类初始化接收参数,__call__接收函数,再返回一个包装函数。
class Retry:
def __init__(self, max_retries=3):
self.max_retries = max_retries
def __call__(self, func):
def wrapper(*args, **kwargs):
for i in range(self.max_retries):
try:
return func(*args, **kwargs)
except Exception as e:
print(f"第 {i+1} 次重试失败: {e}")
raise Exception("重试耗尽")
return wrapper
@Retry(max_retries=5)
def unstable_api():
import random
if random.random() < 0.7:
raise ConnectionError("网络波动")
return "成功"
functools.wraps:救命的“身份恢复术”
回到开头的那个Bug。为什么被装饰的函数,__name__会变成wrapper?因为装饰器本质上是用wrapper替换了原函数。Python解释器不知道你包装了谁,它只知道现在这个函数叫wrapper。
def my_decorator(func):
def wrapper(*args, **kwargs):
print("before")
result = func(*args, **kwargs)
print("after")
return result
return wrapper
@my_decorator
def test():
"""这是一个测试函数"""
pass
print(test.__name__) # 输出: wrapper
print(test.__doc__) # 输出: None
这会导致什么问题?如果你用inspect模块做参数检查,或者用help()查看文档,全都会乱套。更严重的是,某些框架(比如Flask的路由注册)依赖函数的__name__来生成URL,这时候装饰器就会破坏路由映射。
解决方案就是functools.wraps。它会把原函数的__name__、__doc__、__module__、__annotations__等属性复制到wrapper上。
from functools import wraps
def my_decorator(func):
@wraps(func) # 这里加一行,救你一命
def wrapper(*args, **kwargs):
print("before")
result = func(*args, **kwargs)
print("after")
return result
return wrapper
@my_decorator
def test():
"""这是一个测试函数"""
pass
print(test.__name__) # 输出: test
print(test.__doc__) # 输出: 这是一个测试函数
别这样写:有些人图省事,只在最外层的装饰器上加@wraps,内层嵌套的装饰器就不加了。结果就是,多层装饰器叠加后,__name__依然会丢失。正确的做法是:每个wrapper函数上都要加@wraps。
实战:一个带参数、带状态、保留元数据的完整装饰器
把上面所有知识点揉在一起,写一个生产级别的装饰器:
from functools import wraps
import time
class RateLimiter:
"""限流装饰器,支持自定义速率和单位"""
def __init__(self, max_calls=10, period=1.0):
self.max_calls = max_calls
self.period = period
self.calls = []
def __call__(self, func):
@wraps(func) # 保留原函数信息
def wrapper(*args, **kwargs):
now = time.time()
# 清理过期记录
self.calls = [t for t in self.calls if now - t < self.period]
if len(self.calls) >= self.max_calls:
raise Exception(f"超过限流阈值: {self.max_calls}次/{self.period}秒")
self.calls.append(now)
return func(*args, **kwargs)
return wrapper
@RateLimiter(max_calls=5, period=2.0)
def api_request(url):
"""发送API请求"""
return f"请求 {url} 成功"
这个装饰器:支持参数配置、维护调用记录状态、保留原函数的元数据。如果你在线上看到类似的代码,大概率是踩过坑之后重构出来的。
个人经验:什么时候该用哪种装饰器
- 简单日志、计时、权限校验:用函数装饰器,轻量、直观。
- 需要维护状态(计数、缓存、限流):用类装饰器,状态管理更清晰。
- 装饰器本身需要参数:函数装饰器用三层嵌套,类装饰器用
__init__+__call__。 - 任何装饰器:只要写了
wrapper,就加上@wraps(func),这是最低成本的防御性编程。
最后说一句:别迷信“一行代码实现XX”的装饰器。真正生产环境用的装饰器,往往需要处理异常、日志、超时、重试、限流等复杂逻辑。把基础打牢,遇到问题才能快速定位——就像我那个凌晨三点的Bug,最后发现只是少了个@wraps。
:带参装饰器、类装饰器与 functools.wraps&spm=1001.2101.3001.5002&articleId=162268234&d=1&t=3&u=dd2355f5f46f4032b7360adde49a7454)
822

被折叠的 条评论
为什么被折叠?



