装饰器(decorator)是 Python 里最有"Python 味"的特性之一,也是新手觉得最"玄"的语法 —— 一个 @ 符号往函数头上一放,函数就莫名其妙多了功能。但其实装饰器一点都不玄,它背后是三个非常朴素的概念的组合。这篇从原理讲起,一步步推到 functools.wraps、带参数的装饰器这些进阶用法,看完你不仅会用,还能自己写。
地基:理解装饰器前必须接受的三件事
理解装饰器前,先接受三个事实:
1. 在 Python 里,函数是"一等公民" —— 函数可以像普通值一样,
被赋值给变量、当参数传递、当返回值返回。
2. 函数里可以定义函数(嵌套函数)。
3. 内层函数能记住它"出生时"所在的环境(闭包)。
装饰器 = 这三件事的组合:一个"接收函数、返回新函数"的函数。
这三件事里,最关键的是"函数是一等公民"。在很多语言里,函数就是函数,不能当变量用。但在 Python 里,函数和数字、字符串一样,是可以被赋值、被传递、被返回的"普通值"。装饰器的全部"魔法",不过是把这三件事组合起来 —— 它本质上就是一个"接收一个函数、返回一个新函数"的函数。
手写第一个装饰器
不用任何特殊语法,我们先手动实现一个 —— 给函数加上"自动计时"的功能:
手写一个最朴素的装饰器:给函数加"计时"功能
import time
def timer(func): # 接收一个函数
def wrapper(*args, **kwargs): # 定义一个"包装"函数
start = time.time()
result = func(*args, **kwargs) # 调用原函数
print(f"{func.__name__} 耗时 {time.time() - start:.4f}s")
return result
return wrapper # 返回包装函数
# 用法一:手动包一下
def slow_task():
time.sleep(1)
slow_task = timer(slow_task) # 把 slow_task 换成被包装的版本
逐行看 timer 做了什么:它接收一个函数 func;在内部定义了一个 wrapper 函数,wrapper 里先记时间、再调用原来的 func、最后打印耗时;然后把 wrapper 返回出去。
最后一行 slow_task = timer(slow_task) 是精髓:我们把 slow_task 这个名字,重新指向了"被包装后的版本"。之后再调用 slow_task(),实际跑的是 wrapper —— 它会先计时、再调用原始逻辑。原函数没被改一个字,功能却被"增强"了。*args, **kwargs 的作用是让 wrapper 能接收任意参数并原样转交,这样它才能包装任何函数。
@ 语法:它只是语法糖
每次都写 x = timer(x) 有点啰嗦,Python 给了个语法糖 —— 就是那个 @:
@ 语法:只是上面那行的"语法糖"
@timer
def slow_task():
time.sleep(1)
# 完全等价于:
def slow_task():
time.sleep(1)
slow_task = timer(slow_task)
所以 @decorator 没有任何魔法,它就是
"把下面这个函数,传给 decorator,再用返回值替换掉原名字"。
看清楚了:@timer 和手写 slow_task = timer(slow_task) 是完全等价的。@ 没有任何魔法,它就是"把紧跟在下面的函数,作为参数传给 @ 后面那个东西,再用返回值替换掉原来的名字"。一旦你建立起"@decorator ≡ func = decorator(func)"这个等式,装饰器对你就再没有任何神秘感了。
进阶一:用 functools.wraps 保住"身份"
上面的装饰器有个隐藏副作用:被装饰之后,slow_task.__name__ 不再是 'slow_task',而变成了 'wrapper' —— 因为它现在指向的确实是 wrapper 函数。函数的文档字符串、签名等元信息也都丢了。这在调试、日志、以及一些依赖函数元信息的框架里会出问题。
修复办法是 functools.wraps:
两个进阶:functools.wraps 和 带参数的装饰器
# 问题:被装饰后,slow_task.__name__ 变成了 'wrapper',
# 文档字符串也丢了。用 functools.wraps 修复:
from functools import wraps
def timer(func):
@wraps(func) # ← 把原函数的元信息搬到 wrapper 上
def wrapper(*args, **kwargs):
...
return wrapper
# 带参数的装饰器 = 在外面再套一层函数
def retry(times): # 接收"装饰器的参数"
def decorator(func): # 这一层才是真正的装饰器
@wraps(func)
def wrapper(*args, **kwargs):
for i in range(times):
try:
return func(*args, **kwargs)
except Exception:
if i == times - 1:
raise
return wrapper
return decorator
@retry(times=3) # 失败自动重试 3 次
def call_api():
...
@wraps(func) 加在 wrapper 上,作用是把原函数 func 的名字、文档等元信息"搬"到 wrapper 上,让被装饰后的函数"对外看起来还是原来那个"。写装饰器时,加上 @wraps 应该成为肌肉记忆。
进阶二:带参数的装饰器
有时我们希望装饰器本身能接收参数,比如 @retry(times=3) —— 重试 3 次。这怎么实现?
诀窍是再套一层函数。回顾一下:@decorator 要求 decorator 是个"接收函数、返回函数"的东西。而 @retry(times=3) 里,retry(times=3) 会先被执行,它的返回值才是真正的装饰器。所以 retry 的结构是"三层":最外层接收装饰器参数(times),中间层是真正的装饰器(接收 func),最内层是 wrapper(接收调用参数)。上面 $py4 代码里的 retry 就是这个三层结构。
记住这个推导:@retry(times=3) 等价于 call_api = retry(times=3)(call_api) —— 两次调用,所以两层嵌套(再加上 wrapper 共三层)。把这个等式写出来,带参数装饰器的结构就一目了然了。
装饰器到底用在哪
装饰器的核心价值是"把横切关注点(cross-cutting concern)从业务逻辑里抽出来" —— 那些"很多函数都需要、但又跟业务本身无关"的逻辑。典型场景:
- 日志 / 计时 / 性能监控:像上面的
timer,给一批函数统一加埋点。 - 缓存:标准库的
functools.lru_cache就是个装饰器,一行加上,函数自动带缓存。 - 权限校验:Web 框架里常见的
@login_required,在进入业务逻辑前先检查身份。 - 重试 / 限流 / 熔断:像上面的
@retry,给不稳定的外部调用加保护。 - 注册:框架里用
@app.route('/path')把函数注册成路由处理器。
它们的共同点:这些逻辑如果硬写进每个业务函数里,会重复、会污染业务代码;用装饰器抽出来,业务函数保持干净,关注点彻底分离。
写在最后
装饰器的"祛魅"过程,其实就是认清这几步:
- Python 里函数是一等公民,可传递、可返回;
- 装饰器就是一个"接收函数、返回新函数"的函数;
@decorator只是func = decorator(func)的语法糖,没有魔法;- 写装饰器记得加
@functools.wraps保住原函数的元信息; - 带参数的装饰器 = 再套一层,因为
@deco(arg)是先调用、再装饰。
把"@ 就是语法糖"这句话刻进脑子,你再看任何框架里满天飞的装饰器,都能一眼看穿它在做什么。
—— 别看了 · 2026