我最初认真研究Python装饰器,是被线上日志逼的。项目里有几十个接口函数,每个都要记录入参、出参、耗时、异常情况,一开始全靠复制粘贴样板代码,函数一多,代码看起来就像打满补丁的旧衣服。后来我把这些逻辑抽成一个装饰器,调用处只加一行@logger,世界清静了。
装饰器这个东西,很多Python新手第一次看到会觉得有点神叨叨的,一个@符号加一行代码,函数好像就被“注入了”什么能力。有人直接把网上的例子抄过来用,能用,但改起来就蒙了。也有人翻官方文档,发现版本迭代后装饰器本身还能再套一层,直接劝退。
这篇文章我会从最底层的函数对象讲起,把装饰器的语法原理拆开揉碎,再落到日志、计时、鉴权、缓存、重试这些真实业务场景里,最后把你大概率会踩的坑提前说完。适合刚学完Python基础、想在项目里少写重复代码的读者,也适合已经用过装饰器、但一直没搞懂“为什么它能这样写”的人。看完之后你会知道,装饰器不是语法糖里的花架子,它是Python函数式编程能力最直观的体现。
1. 装饰器到底在解决什么问题
1.1 函数是对象,一切高级特性的起点
想理解装饰器,第一个绕不开的认知是:在Python里,函数本身就是一个对象。
不要小看这句话。你用def定义一个函数时,Python解释器执行的不只是“存一段代码”,而是创建了一个function对象,并且把函数名绑定到这个对象上。这个对象可以被赋值给别的变量、放进列表、作为参数传给另一个函数,也可以作为返回值从函数里带出来。
python复制def add(a, b):
return a + b
print(add) # <function add at 0x...>
print(type(add)) # <class 'function'>
my_func = add
print(my_func(2, 3)) # 5
很多语言里函数只是一种语法结构,但在Python里,函数和整数、字符串、列表一样,是可以被当作普通数据来传递的。我第一次意识到这一点的时候,整个人的编程思路都被打开了:既然函数可以当参数传,那是不是可以把“函数A”塞进“函数B”里面,让B在干活之前先做点别的事?
这正是装饰器存在的前提。
1.2 闭包:装饰器背后真正的地基
既然函数可以当作参数传,那装饰器里的“包装”逻辑靠的是什么?答案是闭包。
闭包说起来也不复杂:如果一个内层函数引用了外层函数的变量,并且外层函数把这个内层函数作为返回值返回,那么这个内层函数连同它引用的变量环境,一起被称作闭包。外层函数执行完之后,本该销毁的变量并不会真的消失,而是被内层函数“记住”了。
python复制def outer(x):
def inner(y):
return x + y
return inner
add_5 = outer(5)
print(add_5(10)) # 15
上面这个例子,outer执行完后,局部变量x的生存期本该结束,但inner还在使用它。Python在编译时发现x是自由变量,就会把它保存到inner的__closure__属性里。所以哪怕outer已经返回了,inner依然能拿到x的值。
这就是装饰器能工作的核心:装饰器本质上是一个外层函数,接收一个函数对象作为参数,在闭包里定义一个新的函数来包裹原函数,然后把这个新函数返回出去。原函数在闭包里被记住,等到真正调用时,新函数可以在调用前后插入自己的逻辑。
注意:
__closure__只保存被引用的外部变量,这一点在排查内存问题时偶尔会遇到。如果一个闭包长期存活,而它引用了大对象,那这个大对象也会跟着一直驻留内存。
1.3 手动包装到@语法糖
明白了函数对象和闭包,你已经能写出第一个装饰器了。先看手动版:
python复制def say_hello():
return "hello"
def logger(func):
def wrapper():
print("before call")
result = func()
print("after call")
return result
return wrapper
say_hello = logger(say_hello)
print(say_hello())
这段代码把say_hello重新绑定成了wrapper,每次调用say_hello()时,实际执行的是wrapper(),而wrapper内部调用了被记住的原始函数。这个“把函数传进去,再返回一个新函数”的过程,就是装饰器。
@语法糖只是把这个手动过程变得更简洁:
python复制@logger
def say_hello():
return "hello"
两段代码完全等价。@logger做的事情,就是让Python在定义好say_hello之后,自动执行say_hello = logger(say_hello)。理解这一点非常关键,因为你在调试装饰器时,脑子里必须时刻清楚:函数名字早就被重新绑定过了,你看到的say_hello已经不是最初定义的那个函数了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 装饰器的核心机制与写法
2.1 @语法糖的完整等价关系
很多教程只告诉你@logger等价于say_hello = logger(say_hello),但实际项目中,装饰器往往要处理带参数的函数,所以真正的通用写法一定是*args和**kwargs都接住。
python复制from functools import wraps
def logger(func):
@wraps(func)
def wrapper(*args, **kwargs):
print(f"calling {func.__name__}")
return func(*args, **kwargs)
return wrapper
@logger
def add(a, b):
return a + b
print(add(2, 3))
这里wrapper接收任意位置的参数和关键字参数,再原样透传给func。不管被装饰的函数签名长什么样,wrapper都能接住。*args和**kwargs是装饰器能通用化的前提,少了任何一个,遇到带关键字参数的函数就会直接报错。
2.2 为什么必须用functools.wraps
接着上面的代码,如果我不加@wraps(func),你猜add.__name__会打印什么?答案是wrapper。
因为add这个名字指向的已经是wrapper对象了。函数名、文档字符串、模块名这些元信息全丢了。更麻烦的是,很多Web框架的URL路由和信号机制是依赖函数名来定位处理函数的。一旦名字被替换,轻则日志里看不出是哪个函数,重则路由匹配直接失效。
functools.wraps做的事情就是把原函数的__name__、__doc__、__module__、__qualname__等属性复制到wrapper上,同时还设置了一个__wrapped__属性,指向原始函数。这样无论是help()查看函数文档,还是日志输出函数名,都能保持优雅。
2.3 从装饰器到带参数的装饰器
有时候光在函数名上放一个@logger还不够,我希望这个装饰器能自定义日志级别。比如有些函数要打印INFO级别的日志,有些要打印WARNING级别的。这时就需要在装饰器外面再包一层,让它接收参数,返回一个真正的装饰器。
python复制def log_with(level="INFO"):
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
print(f"[{level}] calling {func.__name__}")
return func(*args, **kwargs)
return wrapper
return decorator
@log_with(level="WARNING")
def pay_order(order_id):
return f"pay {order_id} success"
@log_with(level="WARNING")的执行过程是这样的:先调用log_with("WARNING"),得到一个decorator,然后pay_order = decorator(pay_order)。也就是说,带参数的装饰器,本质上是一个“装饰器工厂”,工厂根据参数生成特定的装饰器。
这一层三层结构是初学者最容易绕晕的地方。我的记忆方法是:普通装饰器接收“函数”,带参数装饰器接收“参数”,然后返回一个普通装饰器。想清楚这个层次,代码写出来就不会乱。
提示:如果想偷懒,还可以用
functools.partial来实现带参数的装饰器,但可读性不如三层嵌套直观,一般建议还是老老实实写三层。
3. 实际应用场景:从日志到缓存再到重试
3.1 日志采集与调用追踪
把日志统一收进装饰器,是装饰器最经典的应用场景之一。以爬虫为例,一个爬虫项目里往往有成百上千个请求解析函数,如果每个函数都手工写一遍日志记录,那代码会膨胀得没法维护。用装饰器只需要写一次。
python复制import time
from functools import wraps
def call_logger(func):
@wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter()
try:
result = func(*args, **kwargs)
duration = time.perf_counter() - start
print(f"[LOG] {func.__name__} args={args} kwargs={kwargs} "
f"duration={duration:.4f}s status=success")
return result
except Exception as e:
duration = time.perf_counter() - start
print(f"[LOG] {func.__name__} args={args} kwargs={kwargs} "
f"duration={duration:.4f}s status=failed error={e}")
raise
return wrapper
这个装饰器已经覆盖了三条非常实用的需求:记录函数调用来源、统计耗时、在异常时打出错误信息并继续向上抛。实际业务中,你可以把这里的print替换成logging模块,再加上TraceId用于链路追踪,整个微服务里的调用链就串起来了。
有一点要特别提醒:日志装饰器会把args和kwargs直接打出来,如果参数里带密码、token,或者数据库连接串,日志一打出来就是重大安全事故。我见过因此把数据库密码泄露到日志平台的案例,处理方式一般是在装饰器里做参数脱敏,或者干脆只打印参数类型。
3.2 耗时统计:阈值预警版本更实用
普通的计时装饰器网上到处都是,但实际项目中,我更常用的是“超时才记录”的版本。
python复制import time
from functools import wraps
def slow_call_warning(threshold=0.5):
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = func(*args, **kwargs)
duration = time.perf_counter() - start
if duration > threshold:
print(f"[WARN] slow call: {func.__name__} "
f"took {duration:.4f}s, threshold={threshold}s")
return result
return wrapper
return decorator
为什么这样设计?因为接口的耗时分布往往是长尾的,大部分请求很快,但偶尔有慢请求。如果每次调用都打日志,日志量会非常大,真正需要关注的反而被淹没。只在超过阈值时才记录,既能揪出性能瓶颈,又不会制造日志噪音。这个思想在监控告警里叫告警阈值,在性能分析里叫火焰图采样策略,本质都是降低信噪比。
3.3 权限校验与用户认证
Web后端里,很多接口需要先确认用户是否登录、是否有权限。以前用Flask写接口,如果没抽装饰器,每个视图函数里都要重复写一遍登录态检查。
python复制def login_required(func):
@wraps(func)
def wrapper(*args, **kwargs):
# 假设这里从请求环境中拿到当前用户
user = get_current_user()
if not user:
raise PermissionError("login required")
return func(*args, **kwargs, user=user)
return wrapper
@login_required
def get_profile(user):
return f"profile of {user}"
这里有一个很关键的细节:wrapper不仅检查了登录态,还把user对象通过关键字参数传给原始函数。这样一来,视图函数本身不需要再关心“当前用户是谁”这个问题,框架/装饰器已经帮你把用户从请求环境里取出来了,业务代码只关心逻辑。这就是装饰器的一种进阶用法:不只包装,还能注入依赖。
3.4 缓存与结果复用
有些函数计算成本高,但结果在短期内是可以复用的。比如查询数据库得到一个配置列表,或者调用外部API拿到一个汇率。这种场景用装饰器做缓存,能让调用方完全感知不到缓存的存在。
python复制from functools import wraps
def memoize(func):
cache = {}
@wraps(func)
def wrapper(*args):
if args not in cache:
cache[args] = func(*args)
return cache[args]
return wrapper
@memoize
def expensive_query(user_id):
# 模拟耗时查询
time.sleep(1)
return {"id": user_id, "name": "test"}
第一次调用expensive_query(1)时慢,第二次同样的参数直接走缓存,快得飞起。但注意,这个缓存字典放在memoize函数里,是每个装饰器实例独立的。不同函数各自有各自的缓存,这是合理的。
生产环境做缓存,大多数时候不用自己写这个,因为Python标准库已经提供了functools.lru_cache,它支持最大缓存数量、淘汰策略,底层是C实现的,性能比自己写的普通字典好不少。但自己动手写一遍memoize有助于理解缓存原理,也有助于在碰到缓存穿透问题时快速定位。
注意:缓存装饰器有一个经典副作用——它会让函数持有旧数据。如果被装饰的函数读取的是一个随时可能变化的数据库表,你就得考虑缓存失效策略。我见过一个项目因为缓存装饰器加在用户信息接口上,导致用户改完头像十分钟内看到的还是旧头像,最后排查了半天才找到这个“跨函数共享缓存”的问题。
3.5 重试机制与网络容错
网络请求偶尔会超时,数据库连接偶尔会抖动。对于非幂等写作操作,重试是危险的,但对于读接口或幂等操作,重试能显著提升稳定性。装饰器是实现重试逻辑最自然的地方。
python复制import time
from functools import wraps
def retry(max_retries=3, delay=1):
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
for attempt in range(1, max_retries + 1):
try:
return func(*args, **kwargs)
except Exception as e:
if attempt == max_retries:
raise
print(f"[RETRY] {func.__name__} attempt={attempt} "
f"failed: {e}, retry after {delay}s")
time.sleep(delay)
return wrapper
return decorator
@retry(max_retries=3, delay=2)
def fetch_data():
...
这里有个设计决策值得展开:重试第二次时,delay应该固定还是指数递增?如果所有请求在同一时间失败,所有客户端同时重试,会形成惊群效应,把下游服务直接打垮。所以生产环境的重试策略,最好用指数退避加随机抖动。即第n次重试等待base_delay * (2 ^ n) + random(0, jitter)。这个思路不仅在Python装饰器里适用,在微服务、消息队列、分布式系统里都是一样的,理解了它,你对“重试”这个动作的理解就上一个台阶。
4. 进阶玩法:类装饰器与执行顺序
4.1 用类写装饰器,状态管理更清晰
前面写的装饰器都是函数套函数,但Python装饰器并不要求必须是函数。任何实现了__call__方法的可调用对象,都能当装饰器用,这就是类装饰器。
python复制class Timer:
def __init__(self, func):
self.func = func
self.total_time = 0
self.call_count = 0
def __call__(self, *args, **kwargs):
start = time.perf_counter()
result = self.func(*args, **kwargs)
end = time.perf_counter()
self.total_time += end - start
self.call_count += 1
print(f"{self.func.__name__} avg_time={self.total_time / self.call_count:.4f}s")
return result
@Timer
def compute():
time.sleep(0.1)
return 42
类装饰器最大的优势是:状态可以挂在实例属性上。上面这个Timer可以统计被装饰函数累计的调用次数和总耗时,平均耗时随手可得。如果改用函数闭包实现,得往wrapper函数上挂属性,虽然也不是不行,但可读性差很多。所以在需要管理状态的场景,我倾向于用类装饰器。
4.2 多个装饰器组合时的执行顺序
一个函数可以同时叠加多个装饰器,但顺序问题非常容易踩坑。
python复制@decorator_a
@decorator_b
def func():
pass
这个写法等价于func = decorator_a(decorator_b(func))。执行顺序是:先执行decorator_b(func)得到一个新函数,再执行decorator_a(新函数)。也就是说,装饰器的“装饰过程”是从下往上执行的;而调用func()时,实际执行顺序是:先进入最上层装饰器的wrapper,再一层一层往下走,最后才是原始函数体。
记顺序有一个好办法:装饰器本质上像给函数套洋葱。最外层洋葱皮先被碰到,而内部洋葱芯最后被碰到。所以如果你写了:
python复制@login_required
@call_logger
def view():
...
那么view()被调用时,先做登录校验,校验通过后才记录日志,然后才执行原始函数。反过来,如果日志装饰器在外层,那么即使登录校验失败,日志也会记录到一次“未授权访问尝试”。这两种顺序没有绝对对错,但完全对应不同的业务语义。
4.3 装饰器在框架里到底怎么被使用的
很多人学装饰器时有个疑问:我自己写装饰器是一回事,但Flask里的@app.route("/home")是怎么工作的?Django里的@admin.register(MyModel)又是怎么工作的?
它们的本质就是带参数的装饰器加一个注册表。
python复制routes = {}
def route(path):
def decorator(func):
routes[path] = func
@wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
return decorator
@route("/home")
def home():
return "hello home"
print(routes["/home"]())
看到没有,装饰器的作用不只是包装函数,它还可以在装饰阶段就把函数“注册”到某个全局字典里。这样框架在收到HTTP请求时,只需要查一下路由表,找到对应的处理函数,然后调用它。理解了这一点,你再看各种框架源码,就会发现很多“魔法”其实就是装饰器注册机制。
5. 常见问题与排查技巧实录
5.1 函数签名变化带来的连锁问题
用@wraps虽然把__name__、__doc__都复制过来了,但inspect.signature有时候还是会显示出wrapper的签名。原因在于wrapper定义了*args, **kwargs,签名本身就是(*args, **kwargs)。
Python 3.4之后,inspect.signature会通过__wrapped__属性自动“穿透”装饰器,找到原函数的签名。前提是你在装饰器里用了@wraps。如果你用了类装饰器,可能需要手动设置wrapper.__wrapped__ = func,否则一部分依赖函数签名的工具(比如自动化API文档生成器)会失效。
5.2 装饰器顺序导致逻辑反转
前面讲执行顺序时提到过,这里再补充一个排查案例。我遇到过一个问题:接口突然在凌晨报权限异常,但日志里看不到任何调用记录。后来发现,日志装饰器被放在了权限校验装饰器的外层,也就是说权限校验失败时,请求还没走到日志装饰器内部就抛异常了。所以日志里什么都没有。
这个问题不好排查,因为代码看起来完全正常。我的经验是:当接口异常但日志缺失时,第一时间检查装饰器堆叠顺序,看是不是有装饰器在上层提前抛错。这个思路比埋头查日志快得多。
5.3 异步函数装饰器
Python 3.5之后异步编程大规模普及,但很多人的装饰器还是同步写法,直接把async def函数套在普通wrapper里,结果要么返回了一个coroutine对象,要么直接报错。
正确的异步装饰器写法是:wrapper本身也是async def,内部用await调用原函数。
python复制from functools import wraps
def async_logger(func):
@wraps(func)
async def wrapper(*args, **kwargs):
print(f"async call {func.__name__}")
result = await func(*args, **kwargs)
return result
return wrapper
@async_logger
async def fetch_page():
...
如果你写的是通用组件,需要考虑同时兼容同步和异步函数,那就需要检查inspect.iscoroutinefunction(func),根据结果决定返回wrapper还是async wrapper。
5.4 性能影响与内存泄漏
装饰器本质上多了一层函数调用,这个开销虽然小,但在高频调用的热点路径上会被放大。比如每秒调用上万次的函数,如果上面叠了三四个装饰器,多出来的函数调用和属性访问虽然不至于致命,但确实会拖慢速度。
还有一个容易被忽略的内存问题:外层装饰器工厂如果持有大对象,被装饰函数长期存活时,这个大对象会被闭包一路引用,无法释放。我遇到过一个真实案例:装饰器里不小心捕获了配置管理器的实例,导致每次重新加载配置后旧实例一直存活,最后内存曲线一路向上。排查方法是用gc.get_referrers()看看到底谁引用了那个配置对象,最后定位到装饰器的闭包上。
5.5 调试建议:多留一手
写装饰器时,我习惯在wrapper上保留__wrapped__属性,并且确保装饰器内部调用原函数时不要吞掉错误。这样一来,出了问题可以直接用inspect.unwrap(func)剥掉所有装饰层,拿到最原始的函数做单元测试。
另外单元测试的时候,建议对装饰器本身做边界测试。比如说好的重试装饰器,在重试次数达到上限后是否真的把最后一次异常抛出来了?缓存装饰器缓存了异常吗?这些边界不测透,上线后往往在最关键的时候来一下。我犯过的错是:缓存装饰器把异常也缓存住了,导致外层重试逻辑永远拿不到真实异常,一直拿到缓存里的异常,排查了很久才意识到。这种坑写下来,希望你能避开。
我个人在实际操作中,写装饰器之前一定会先问自己两个问题:这个装饰器是帮调用方省事,还是帮框架收函数?想清楚了再动手。最后再分享一个小习惯:任何装饰器,写完之后第一件事就是打印print(func.__name__)看看名字变没变;变了,就老老实实把@wraps补上。这个习惯救了我无数次。
