装饰器(decorator)是 Python 里最有"Python 味"的特性之一,也是新手觉得最"玄"的语法 —— 一个 @ 符号往函数头上一放,函数就莫名其妙多了功能。但其实装饰器一点都不玄,它背后是三个非常朴素的概念的组合。这篇从原理讲起,一步步推到 functools.wraps、带参数的装饰器、类装饰器、多装饰器叠加的执行顺序,再过一遍标准库里那些经典装饰器的用法和几个常见的坑。看完你不仅会用,还能自己写、能看穿任何框架里满天飞的 @。
地基:理解装饰器前必须接受的三件事
理解装饰器前,先接受三个事实:
1. 在 Python 里,函数是"一等公民" —— 函数可以像普通值一样,
被赋值给变量、当参数传递、当返回值返回。
2. 函数里可以定义函数(嵌套函数)。
3. 内层函数能"记住"它出生时所在的环境(闭包)。
装饰器 = 这三件事的组合:一个"接收函数、返回新函数"的函数。
这三件事里,最关键、也最需要"扭过来"的是第一条 —— "函数是一等公民"。在很多语言里,函数就是函数,是一种特殊的东西,不能当变量用。但在 Python 里,函数和数字、字符串、列表一样,是一个可以被赋值、被传递、被返回的普通值。a = some_function 是完全合法的,a 现在就指向那个函数。
第二条"嵌套函数"和第三条"闭包"也很重要:你可以在一个函数里面再定义一个函数;而这个内层函数,即使被返回到外面去了,它依然"记得"自己出生时所在的那个环境里的变量。
把这三件事组合起来,装饰器的全部"魔法"就一句话能说清:装饰器,本质上就是一个"接收一个函数、返回一个新函数"的函数。它接收你原来的函数,在外面包一层增强逻辑,再把"包装后的版本"返回出去。没有别的了。
手写第一个装饰器
不用任何特殊语法,我们先手动实现一个 —— 给函数加上"自动计时"的功能:
手写一个最朴素的装饰器:给函数加"计时"功能
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 这个名字,重新指向了"被包装后的版本"(也就是 wrapper)。所以之后再调用 slow_task(),实际执行的是 wrapper —— 它会先计时、再调用原始逻辑、再打印耗时。原函数一个字没改,功能却被"增强"了。
那两个 *args, **kwargs 是关键细节:它们让 wrapper 能接收任意的位置参数和关键字参数,并原封不动地转交给 func。正因为有它们,这个 timer 才能去包装任何函数 —— 不管那个函数的参数长什么样。
@ 语法:它只是"语法糖"
每次都手写 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) 是完完全全等价的。@ 没有任何魔法,它就是 Python 提供的一个简写,意思是"把紧跟在下面的这个函数,作为参数传给 @ 后面那个东西,然后用返回值替换掉原来的函数名"。
一旦你脑子里建立起这个等式 —— "@decorator ≡ func = decorator(func)" —— 装饰器对你来说就再没有任何神秘感了。以后看到任何 @xxx,你都能在心里把它"翻译"成那行赋值语句,瞬间就明白它在干什么。
进阶一:用 functools.wraps 保住"身份"
上面那个装饰器有一个隐藏的副作用:被装饰之后,slow_task.__name__ 不再是 'slow_task',而变成了 'wrapper' —— 因为它现在指向的确实是 wrapper 函数啊。函数的文档字符串(__doc__)、签名等元信息,也都跟着丢了。
这在调试、日志、以及一些依赖函数元信息的框架里会出问题 —— 比如某个框架靠 __name__ 来路由,你一装饰,它就找不着北了。修复办法是 functools.wraps:
两个进阶: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 应该成为你的肌肉记忆 —— 它几乎没有成本,却能避免一类很隐蔽的 bug。
进阶二:带参数的装饰器
有时我们希望装饰器本身能接收参数,比如 @retry(times=3) —— 失败了重试 3 次。这怎么实现?上面 $py4 的代码里已经给了答案,关键是再套一层函数。
为什么要多套一层?回顾那个核心等式:@decorator 要求 decorator 是一个"接收函数、返回函数"的东西。而 @retry(times=3) 这个写法里,retry(times=3) 是会先被执行的 —— 它执行的返回值,才是那个真正的装饰器。
所以带参数的装饰器是"三层"结构:最外层(retry)接收"装饰器的参数"(times);中间层(decorator)才是真正的装饰器,接收被装饰的"函数"(func);最内层(wrapper)接收函数被调用时的"参数"。记住这个推导:@retry(times=3) 等价于 call_api = retry(times=3)(call_api) —— 这里有两次调用(先 retry(times=3),再用结果调用 (call_api)),所以需要两层嵌套,加上最里面的 wrapper 一共三层。把这个等式写出来,三层结构的由来就一目了然了。
进阶三:用类来做装饰器
装饰器不一定非得是"函数",也可以是"类"。这听起来奇怪,但回到本质 —— 装饰器只要求是一个"可调用对象"(callable)。函数是可调用的;而一个实现了 __call__ 方法的类的实例,也是可调用的。
用类做装饰器的写法大致是:类的 __init__ 接收被装饰的函数(把它存起来),类的 __call__ 方法里写"增强逻辑 + 调用原函数"。@MyDecorator 作用在一个函数上时,等价于 func = MyDecorator(func) —— func 现在指向一个类的实例,而这个实例是可调用的,调用它就会进 __call__。
什么时候用类装饰器而不是函数装饰器?当装饰器本身需要维护状态的时候。比如一个"统计这个函数被调用了多少次"的装饰器,用类来写,把计数器作为实例属性,会比用函数 + 闭包变量更清晰、更自然。函数装饰器靠闭包存状态,能用但稍微绕;类装饰器靠实例属性存状态,直观。两者没有优劣,看场景选。
进阶四:多个装饰器叠加,执行顺序是怎样的
一个函数上可以叠加多个装饰器,这时候执行顺序常常把人绕晕。先看规律:
多个装饰器叠加,执行顺序是"由近到远"地包裹、"由外到内"地进入
@log # 第 3 个包(最外层)
@cache # 第 2 个包
@retry(3) # 第 1 个包(最贴近函数)
def fetch():
...
# 等价于:fetch = log(cache(retry(3)(fetch)))
# 调用 fetch() 时,执行流程是:进 log → 进 cache → 进 retry → 真正的 fetch
# → 出 retry → 出 cache → 出 log
# 记法:装饰器从下往上"包",调用时从上往下"穿"
规律是这样的:装饰器的"应用顺序"是从下往上 —— 最贴近函数的那个先被应用(先包),离函数最远的那个最后被应用(最后包,在最外层)。所以 @log @cache @retry 叠在一起,等价于 log(cache(retry(fetch)))。
而当你调用这个被层层包裹的函数时,执行流程是从最外层往里"穿":先进 log 的逻辑 → 再进 cache → 再进 retry → 最后才到真正的 fetch;fetch 返回后,再一层层往外"穿"出来。一个好记的口诀:"装饰器从下往上包,调用时从上往下穿。"理解了这个顺序,你才能正确地安排多个装饰器的位置 —— 比如"缓存"应该在"重试"外面还是里面,顺序不同,行为完全不同。
装饰器到底用在哪
装饰器的核心价值,是"把横切关注点(cross-cutting concern)从业务逻辑里抽离出来" —— 那些"很多函数都需要、但又跟业务本身无关"的逻辑。如果硬写进每个业务函数里,会大量重复、还会污染业务代码的可读性;用装饰器抽出来,业务函数保持干净,关注点彻底分离。典型场景:
- 日志 / 计时 / 性能监控:像前面的
timer,给一批函数统一加埋点。 - 缓存:把函数的结果缓存起来,相同输入直接返回缓存,不重复计算。
- 权限校验:Web 框架里常见的
@login_required,在进入业务逻辑之前先检查身份。 - 重试 / 限流 / 熔断:像前面的
@retry,给不稳定的外部调用加上保护。 - 注册 / 路由:Web 框架里用
@app.route('/path')把一个函数注册成某个 URL 的处理器。 - 参数校验 / 类型检查:在函数真正执行前,先把传进来的参数检查一遍。
它们的共同点都是:这些逻辑"游离于业务之外、又被反复需要"。装饰器就是 Python 给这类需求的优雅答案。
标准库里的经典装饰器
你不用什么都自己写 —— Python 标准库里就有一批非常好用的装饰器,而且看懂它们,反过来也加深你对装饰器的理解。
functools.lru_cache:一行加上,函数就自动带了缓存。相同参数的调用,第二次开始直接返回上次的结果,不重新计算。对于"纯函数 + 计算较重 + 会被重复调用"的场景(经典的就是递归求斐波那契),效果立竿见影。
@property:把一个类的方法,变成"像属性一样访问"。本来要 obj.get_area(),加了 @property 之后可以直接 obj.area —— 但底层其实还是执行了一段逻辑。它让"计算属性"用起来很自然。
@staticmethod 和 @classmethod:控制类里方法的"绑定行为" —— @staticmethod 让方法不接收 self 也不接收 cls(就是个普通函数,只是放在类里),@classmethod 让方法接收的是类本身 cls 而不是实例 self(常用于写"另一种构造方式")。
contextlib.contextmanager:把一个生成器函数,变成一个可以用 with 语句的上下文管理器。它是"用装饰器把一种东西变成另一种东西"的精彩例子。
functools.cache(较新版本):lru_cache 的简化版,不限缓存大小。
看到这些你应该有个体会:装饰器不是一个"小众技巧",它是 Python 标准库、以及几乎所有主流框架(Web 框架、测试框架、数据处理库)都大量依赖的核心机制。把它吃透,你看任何 Python 代码库都会顺畅很多。
装饰器的几个坑
坑一:忘了加 @functools.wraps。前面讲过,不加的话原函数的名字、文档、签名都丢了,会在依赖元信息的场景里埋雷。养成习惯,写 wrapper 就顺手加上。
坑二:wrapper 没有把返回值 return 出来。很常见的低级错误 —— 你在 wrapper 里调用了 func(*args, **kwargs),但忘了把它的结果 return。结果就是被装饰的函数,返回值全变成了 None。装饰器是"增强",不是"吞掉返回值"。
坑三:用 lru_cache 装饰了一个"有副作用"或"参数不可哈希"的函数。lru_cache 假设函数是"纯"的(相同输入永远相同输出),如果你的函数每次调用会产生副作用(写文件、改全局状态),缓存会让副作用"被跳过",出诡异 bug。另外它要求参数是可哈希的(list、dict 这种不可哈希的参数会直接报错)。
坑四:在类里给方法加装饰器时,把 self 忘了。类方法的第一个参数是 self,装饰器的 wrapper 用 *args 接收时,self 就在 args[0] 里 —— 只要你老老实实用 *args, **kwargs 透传,一般不会出问题;但如果你在 wrapper 里写死了参数,就容易把 self 搞丢。
FAQ
装饰器会影响性能吗?会有一点点 —— 每次调用被装饰的函数,都多走了一层 wrapper。但这个开销通常微乎其微,完全不值得为它放弃装饰器带来的代码清晰度。除非是在极端性能敏感的热点路径上,否则不用担心。
装饰器和"猴子补丁(monkey patch)"有什么区别?装饰器是在函数定义时、明明白白地用 @ 声明出来的增强,是"显式"的;猴子补丁是在运行时偷偷把某个函数/方法替换掉,是"隐式"的。装饰器更可控、更可读,猴子补丁更"野"、更难追踪。能用装饰器就别用猴子补丁。
能给一个函数动态地加/去装饰器吗?因为 @ 只是语法糖,本质是赋值,所以理论上你可以在运行时手动做 f = some_decorator(f)。但这通常不是好实践 —— 装饰器的价值之一就是"在定义处一眼可见",动态加减会让代码难以理解。
装饰器一定要返回函数吗?严格说,装饰器返回什么都行(返回值会替换掉原来的名字)。但 99% 的情况下你应该返回一个"可调用的、行为和原函数兼容的"东西 —— 否则别人调用这个"被装饰的函数"时就会出意外。遵守"返回一个兼容的可调用对象"这个约定。
换个视角:装饰器在解决一个普遍问题
把视野再拉高一点 —— Python 的装饰器,其实是在解决一个所有语言都会遇到的普遍问题:怎么优雅地"增强"一个已有的功能,而不去改动它本身。
这个需求太常见了:我有一堆函数,我想给它们都加上日志、加上缓存、加上权限检查 —— 但我不想把这些逻辑一份份地复制到每个函数里。不同的语言,给出了不同的答案:
Java 那边,你会听到"AOP(面向切面编程)"、注解(Annotation)+ 代理 —— 本质也是"把横切逻辑从业务里抽出来,在外面织入"。前端那边,你会看到高阶函数、高阶组件(HOC)—— React 早期那个 withXxx 的模式,思路和装饰器几乎一模一样:接收一个组件,返回一个被包装、被增强的新组件。TypeScript 和 ECMAScript 也有自己的装饰器提案和实现。
看穿了这一层,你会发现:Python 装饰器不是一个孤立的"语法糖",它是"用一个东西包装另一个东西、从而增强它"这个通用模式,在 Python 里的、特别简洁优雅的一种表达。因为 Python 有"函数是一等公民"这个特性打底,这个模式才能用 @ 这么轻量的语法表达出来 —— 在没有这个特性的语言里,实现同样的效果往往要绕一大圈。
这个视角的价值在于:当你以后在别的语言、别的框架里遇到"高阶函数""HOC""AOP""中间件(middleware)"这些概念时,你能一眼认出 —— "哦,这又是那个「包装增强」的模式"。理解了 Python 装饰器的本质,你其实理解的是一类跨语言的、非常通用的设计思想。学一个东西,学到能"举一反三"地认出它在别处的变体,才算真的学透了。
写在最后
装饰器的"祛魅"过程,其实就是认清这几步:
- Python 里函数是一等公民,可赋值、可传递、可返回;
- 装饰器就是一个"接收函数、返回新函数"的函数(或可调用对象);
@decorator只是func = decorator(func)的语法糖,没有任何魔法;- 写装饰器记得加
@functools.wraps保住原函数的元信息; - 带参数的装饰器 = 再套一层,因为
@deco(arg)是"先调用、再装饰"; - 多个装饰器从下往上包、调用时从上往下穿;需要维护状态时可以用类装饰器。
把"@ 就是语法糖"这句话刻进脑子,再把上面那个核心等式记牢,你再看任何框架里满天飞的装饰器 —— Web 框架的路由、测试框架的标记、数据库 ORM 的各种声明 —— 都能一眼看穿它在做什么。装饰器从一个"玄学语法",变成了你手里一个清晰、好用、随时能自己造的工具。
—— 别看了 · 2026