先说个真实的感受:我见过太多Python开发者,写业务代码特别溜,一看到装饰器就头皮发麻。总觉得那个@符号像某种黑魔法,能用但不敢自己写,一旦出了bug连从哪儿调试都不知道。其实装饰器远没有想象中那么玄乎,它背后就是“函数也是对象”这一个大前提,再加一层“闭包”的小技巧,剩下的事情都是顺藤摸瓜。这篇文章我想按照自己的理解,把这个魔法从底层到实战彻底拆开,每段代码都讲清楚“为什么这么写”,也把你大概率会踩的坑提前指出来。
1. 装饰器看起来像魔法,底层其实只有两件事
1.1 函数是一等公民:函数能当变量用
很多语言里函数就是“一段可执行的代码”,但在Python里,函数本身也是一个对象,可以赋值给变量、装进列表、当作参数传给另一个函数,也可以作为另一个函数的返回值。这不是装饰器独有的特性,而是装饰器能够存在的土壤。
def say_hello(): return "hello" # 函数能赋值给变量 greet = say_hello print(greet()) # hello print(say_hello.__name__) # say_hello这里有个容易忽略的点:greet = say_hello后面没有括号,说明我们操作的是函数对象本身,而不是调用它的结果。一旦理解了“函数可以像普通值一样被传递”,装饰器的核心机制就已经通了一半。
再看函数作为参数传递的例子:
def call_thrice(func): for _ in range(3): func() def knock(): print("knock") call_thrice(knock)这段代码做的事情是:把一个函数当成普通参数传进另一个函数,由对方来决定什么时候调用它。装饰器的本质,就是“接收一个函数,返回一个新函数”,仅此而已。
1.2 闭包:把状态“关”在函数里
有了“函数可以作为返回值”,还需要一个关键能力:内层函数能记住外层函数的环境变量。这个组合在Python里叫闭包。
def make_multiplier(n): def multiplier(x): return x * n return multiplier double = make_multiplier(2) print(double(5)) # 10make_multiplier(2)执行完之后,局部变量n按理说应该被释放了,但multiplier这个函数仍然记着n = 2,所以double(5)返回10。如果用生活化的类比,闭包就像是一个实习生被派驻到新工位时,包里还揣着原主管发的“工作纪律表”——外面的函数虽然已经结束了,但内层函数仍然带着当时的环境一起走。
装饰器利用的正是这个特性:外层函数接收一个原函数,内层函数在调用原函数的前后插入新逻辑,然后外层把内层函数返回。原函数就那样被“关”在了内层函数里,随时准备被执行。
2. 从零手写第一个装饰器:看看@语法糖背后发生了什么
2.1 不用装饰器时,代码是怎么“膨胀”的
假设现在要统计一个函数的执行耗时,最朴素的做法是直接在函数体里加计时代码:
import time def add(a, b): start = time.time() result = a + b print(f"[{add.__name__}] cost {time.time() - start:.6f}s") return result跑一个函数这么写没问题,但如果reduce、subtract、multiply这些函数都要加同样的统计逻辑,你就只能复制粘贴。更难受的是,以后想改统计方式,所有函数都要跟着改一遍。计时逻辑和业务逻辑搅在一起,代码的味道已经不对了。
2.2 用函数包装函数,计时逻辑独立出来
装饰器思路是:把原函数当成参数“包”起来,在外层套一层新函数,新函数内部先计时、再调用原函数、最后打印耗时。
import time def timer(func): def wrapper(*args, **kwargs): start = time.time() result = func(*args, **kwargs) # 真正调用原函数 print(f"[{func.__name__}] cost {time.time() - start:.6f}s") return result return wrapper def add(a, b): return a + b add = timer(add) # 变量名重新指向新函数最后一行是关键:timer(add)返回的是wrapper,把它重新赋值给add,等于把原来的函数藏进了新函数内部,以后所有调用add(1, 2)的人,其实是先进入wrapper(1, 2),执行完计时逻辑后再调内部保存的原函数,拿到结果返回。这就完成了“给函数加行为但不改函数体”的目标。
这里有个新手常问的问题:为什么wrapper的参数要写成*args, **kwargs,而不是直接写(a, b)?因为timer是被设计成通用工具函数,它不应该知道未来要包装的函数长什么样。用*args, **kwargs通配所有普通参数和关键字参数,然后在内部原封不动地传给原函数,这样这个装饰器对任何签名都有效。
2.3 @语法糖只是简化写法
上面add = timer(add)这种手动赋值的写法已经能工作,只是后来Python增加了@语法:
@timer def add(a, b): return a + b它和add = timer(add)是等价的,纯粹是语法糖。区别在于:使用@时,timer在函数定义完的瞬间就被调用了,返回值直接绑定给函数名。也就是说,这个改写发生在模块导入阶段,而不是你第一次调用add(1, 2)的时候。
执行流程拆开来看,总共三步:
- 定义原始函数
add; - 执行
timer(add),返回wrapper; - 把
wrapper重新绑定给名字add。
我建议所有初学者在学装饰器时,都亲自动手写一遍不带@的版本,把add = timer(add)这种形式跑通,再去用@。很多人卡住,是因为一上来就盯着@符号,忽略了它背后的赋值动作。
3. 带参数的装饰器:三层嵌套是怎么演化出来的
3.1 需求场景:日志级别怎么传进去
第一个timer装饰器没法接收额外参数。很快你会遇到这样的需求:给函数加日志功能,但不同函数需要不同日志级别,有的是INFO,有的是DEBUG。这时候需要的是“装饰器工厂”——先接收配置参数,再返回一个真正的装饰器。
先看最终形态:
import functools def log_with_level(level): def decorator(func): @functools.wraps(func) def wrapper(*args, **kwargs): print(f"[{level}] {func.__name__} is running") return func(*args, **kwargs) return wrapper return decorator @log_with_level("DEBUG") def get_user(user_id): return {"id": user_id, "name": "test"}像@log_with_level("DEBUG")这种写法,很多人第一次看到就晕了。其实拆开只是两个步骤:
- 先执行
log_with_level("DEBUG"),返回结果是一个装饰器,也就是内部的decorator; - 然后这个返回的
decorator被应用到get_user上,执行decorator(get_user),返回wrapper。
整个结构就是为了满足“先给配置参数,再给函数对象”的次序,所以比普通装饰器多了一层。每层各管一件事:
| 层级 | 接收内容 | 返回内容 | 作用 |
|---|---|---|---|
| 最外层函数 | 配置参数,如level | 装饰器函数 | 保存配置状态 |
| 中间层函数 | 函数对象 | 内层函数 | 包装原函数 |
| 内层函数 | 原函数的调用参数 | 原函数的返回结果 | 真正执行逻辑 |
3.2 两种调用风格的微妙区别
不带参数的装饰器用@timer,带参数的用@log_with_level("INFO"),为什么形式上不一样?
因为@后面的整个表达式会被求值,求值结果必须是一个“能够接收函数对象并返回新函数”的装饰器。timer本身是装饰器,所以直接放@timer;log_with_level("INFO")调用完得到的decorator才是装饰器,所以必须带括号。
还有一种隐蔽写法容易踩坑:有人为了让装饰器同时支持@timer和@timer()两种用法,会写得很绕。我的建议很简单:不要强行设计这种兼容形式,约定好项目里不带参数就用无括号写法,带参数就用带括号写法,保持代码直白。额外兼容带来的复杂度远大于那点便利。
另一个常见疑惑是:为什么中间层不能省?如果只有一个接收level的外层函数,它怎么同时接收func?Python函数不能收取“先配置后目标”两次调用而不返回函数。正因为第一层调用完必须留下一个状态(level),中间层才能以闭包的方式记住它。所以三层结构不是刻意追求复杂,而是“配置参数”和“函数对象”分别到达的必然结果。
4. functools.wraps:被装饰后函数为何连名字都丢了
4.1 一个容易被忽视的副作用
不带functools.wraps的装饰器,包装完函数之后,原函数的重要元信息会全部丢失:
def timer(func): def wrapper(*args, **kwargs): ... return wrapper @timer def add(a, b): """This function adds two numbers.""" return a + b print(add.__name__) # wrapper print(add.__doc__) # Noneadd这个名字指向的已经变成wrapper了。__name__变了,__doc__变了,__module__也可能受影响。这意味着:
- 用
inspect.getdoc(add)查看文档时拿到的是wrapper的文档,通常是空; - 用
pydoc自动生成文档时,接口说明全错乱; - 调试时回溯信息显示的函数名一律是
wrapper,堆栈全糊成一团; - 某些依赖函数签名的框架(比如路由系统)会解析到错误签名,直接引发异常。
4.2 wraps到底做了什么
functools.wraps本身是一个装饰器,它本质上是functools.update_wrapper的便捷封装:
from functools import wraps, update_wrapper def timer(func): @wraps(func) def wrapper(*args, **kwargs): ... return wrapper@wraps(func)生效时,会把func的__name__、__doc__、__module__、__qualname__、__annotations__等元信息复制到wrapper上,同时更新wrapper.__dict__,并设置wrapper.__wrapped__ = func。这样外面的调用者看到add.__name__还是add,add.__doc__还是原来的中文文档字符串,调试时的函数名也不会变成wrapper。
提示:从写装饰器的第一行开始,就应该习惯
@wraps(func)。这不是可选项,而是基本卫生习惯。即使你暂时不在乎文档,等到排查线上问题时,__name__没变能省下大量时间。
4.3 除了wraps,签名问题也要留意
@wraps能恢复元信息,但原函数的参数签名(比如add(a, b))并不会自动复制到wrapper.__signature__上。大多数日常场景不受影响,因为调用走的是*args, **kwargs,参数能正常传递。不过如果你写的是供别人使用的库,并且对方可能用inspect.signature来检查你的函数签名,那么光靠wraps还不够,还需要手动设置__signature__。
import inspect import functools def timer(func): @functools.wraps(func) def wrapper(*args, **kwargs): return func(*args, **kwargs) wrapper.__signature__ = inspect.signature(func) return wrapper这段话是我自己觉得最有用的补充,因为90%的教程都只讲wraps,不提醒签名问题。在写公开接口时,这个小细节经常决定别人能否顺利接入。
5. 装饰器执行时机与装饰顺序:两个高频翻车场景
5.1 导入即执行 vs 调用时执行
装饰器的外层代码在模块导入阶段就会执行,而不是等到函数调用时。看下面这个例子:
print("module loaded") def log_with_level(level): print(f"decorator factory called with {level}") def decorator(func): print(f"decorator applied to {func.__name__}") def wrapper(*args, **kwargs): print(f"wrapper executed for {func.__name__}") return func(*args, **kwargs) return wrapper return decorator @log_with_level("INFO") def process(): pass当模块被导入时,你会立刻看到decorator factory called with INFO和decorator applied to process这两行输出。而wrapper executed for process只会在你真正调用process()时才出现。
这个行为带来的实际影响是:如果在装饰器外层写了耗时初始化逻辑、网络连接或文件写入,它会在导入时阻塞,而且导入一次执行一次。对大型应用来说,这可能导致启动变慢。经验法则是:装饰器的外层函数只做轻量配置保存,重活都放到wrapper里再干。
5.2 装饰顺序:离函数最近的最先应用
叠加多个装饰器时,顺序问题经常让人懵。看这段代码:
@outer @inner def foo(): pass它等价于:
foo = outer(inner(foo))所以,执行顺序是:先让inner包装原函数,再把结果交给outer包装。应用的时候是从下往上,调用的时候是从上往下进入。
我用“洋葱”模型来记忆:foo的原函数在最里层,outer在最外层。调用时请求先穿过outer的wrapper,再穿过inner的wrapper,最后才到原函数;返回时原封不动反向穿过。
这个顺序极其容易踩坑。比如我要给一个函数同时加日志和计时,如果写:
@timer # 外层 @logger # 内层 def fetch_data(): ...那么调用fetch_data()时,先走timer的逻辑,再走logger的逻辑,最后才到原函数。日志里显示的时间会包含logger自身的开销,但不会包含timer装饰器的开销(因为它在外层)。如果你想让计时范围覆盖日志输出,就得写成:
@logger @timer def fetch_data(): ...有时候我们调试时发现某个函数的性能数据不太对劲,排查了半天,最后发现只是装饰器的叠加顺序影响了计时范围。所以建议在项目规范里明确“最外层放通用横切逻辑,内层放业务相关逻辑”,顺序不要随手乱写。
6. 类装饰器和内置装饰器:更多实际场景里的用法
6.1 用类实现装饰器:带状态的时候很方便
函数装饰器已经很好用了,但它们天然不带“状态”。如果你需要统计一个函数被调用了多少次,或者在不同调用之间记录历史结果,用类实现装饰器会更顺手。核心是让类的实例变成可调用对象,也就是实现__call__方法。
import time class CountCalls: def __init__(self, func): self.func = func self.count = 0 def __call__(self, *args, **kwargs): self.count += 1 print(f"call {self.count} of {self.func.__name__}") return self.func(*args, **kwargs) @CountCalls def say_hello(): return "hello" say_hello() say_hello() print(say_hello.count) # 2这里@CountCalls的流程是:先创建CountCalls实例,传入原函数,然后把实例绑到say_hello这个名字上。调用say_hello()时,执行的其实是实例的__call__方法。由于self.count保存在实例上,不同调用之间的状态就自然累积起来了,这在函数式装饰器里需要额外借助nonlocal变量来实现。
类装饰器还有个优势:如果以后需要让装饰器本身支持配置参数,可以写成工厂类:
class Retry: def __init__(self, times=3, delay=0): self.times = times self.delay = delay def __call__(self, func): @functools.wraps(func) def wrapper(*args, **kwargs): for attempt in range(self.times): try: return func(*args, **kwargs) except Exception: if attempt == self.times - 1: raise time.sleep(self.delay) return None return wrapper @Retry(times=5, delay=0.1) def make_request(url): ...6.2 内置装饰器:staticmethod/classmethod/property
在类里经常看到的@staticmethod、@classmethod、@property其实也是装饰器。它们的共同点是接收一个函数,返回一个描述符对象,从而改变这个函数在类上的绑定行为。
@staticmethod:让类可以直接调用方法,不自动传入实例;@classmethod:让方法接收类本身作为第一个参数,而不是实例;@property:把一个方法变成只读属性,访问时不加括号,同时可以在赋值时走setter逻辑。
其中@property特别适合做数据校验。比如设置年龄时希望值在合理范围内,就可以用@property+@setter组合:
class Person: def __init__(self, age): self._age = age @property def age(self): return self._age @age.setter def age(self, value): if value < 0 or value > 150: raise ValueError("invalid age") self._age = value这种写法背后的机制是:@property先装饰age方法,得到的属性对象又有.setter方法,这个.setter再返回一个新的属性对象。本质上也是一种装饰器的链式调用,只是应用对象变成了方法。
6.3 functools里常用的内置装饰器
functools.lru_cache是另一个我非常推荐的内置装饰器,它自动帮你做“最近最少使用”缓存:
import functools @functools.lru_cache(maxsize=128) def fib(n): if n < 2: return n return fib(n - 1) + fib(n - 2) print(fib(50))不加lru_cache时这个递归计算慢得让人怀疑人生,加上之后因为每次结果都被缓存,性能提升几个数量级。关键是这行装饰器实现的功能相当于自己做一大串字典缓存逻辑,却一行都不需要你写。
functools.singledispatch也很有用:它可以根据第一个参数的类型来选择不同的实现。类似重载,但不需要写一大串if isinstance。这些内置装饰器很好地体现了装饰器的价值:把通用能力做进一个包装层,业务函数保持干净。
7. 组合实战:把日志、重试和缓存优雅地叠在一起
前面讲了不少分散的点,现在完整做一个真实场景。假设要写一个拉取用户信息的函数,可能访问外部接口,需要同时满足三个要求:记录调用日志、失败后自动重试、对相同参数的结果做缓存。
装饰器拆开来看:
import functools import time def log_with_level(level): def decorator(func): @functools.wraps(func) def wrapper(*args, **kwargs): print(f"[{level}] {func.__name__} called with args={args}, kwargs={kwargs}") return func(*args, **kwargs) return wrapper return decorator def retry(times=3, delay=1): def decorator(func): @functools.wraps(func) def wrapper(*args, **kwargs): for attempt in range(1, times + 1): try: return func(*args, **kwargs) except Exception: print(f"attempt {attempt} failed, retry in {delay}s") if attempt == times: raise time.sleep(delay) return wrapper return decorator def cached(func): cache = {} @functools.wraps(func) def wrapper(*args, **kwargs): key = (args, tuple(sorted(kwargs.items()))) if key in cache: print("cache hit") return cache[key] result = func(*args, **kwargs) cache[key] = result return result return wrapper然后叠加使用:
@log_with_level("DEBUG") @retry(times=2, delay=0.5) @cached def fetch_user(user_id): print(f"fetching user {user_id} from remote") return {"id": user_id, "name": "test"}调用三次试试:
fetch_user(1) fetch_user(1)观察执行顺序:
- 第一次调用:进入
log_with_level的wrapper,打印日志,然后进入retry的wrapper,尝试调用,进入cached的wrapper,发现没有缓存,所以打印fetching user from remote,拿到结果后存缓存; - 第二次调用:最外层日志打印,经过重试层,到缓存层时直接命中,不会发出真正的远程请求。
如果代码里把@log_with_level放在最外层,而@cached放在最内层,那么即使缓存命中,日志依然会打印,重试层也会空转一层。这在大多数场景下是可接受的,但也意味着缓存命中时外层装饰器仍然会参与调用。如果项目对性能敏感,想“命中缓存就完全不进入重试逻辑”,那就得把缓存装饰器放到最外层:
@cached @log_with_level("DEBUG") @retry(times=2, delay=0.5) def fetch_user(user_id): ...这时候命中缓存后,日志层和重试层都不会执行。到底哪个顺序更好,取决于业务上是“每次进函数都要留痕”还是“缓存命中就不必再记录和重试”。这就是装饰器顺序的实际意义,它不是一个纯理论问题,而是直接决定运行时行为的工程决策。
8. 想写好装饰器,先养成的几个小习惯
最后聊一点我个人写装饰器攒下来的经验。
第一,装饰器内部尽量保持单一职责。一个装饰器就做一件事:计时、缓存、重试、鉴权选一个。不要做一个“全能装饰器”,把日志、缓存、异常处理全塞进去。混合逻辑的装饰器调试成本非常高,而且很难复用。真实项目里更常见的做法是定义多个小装饰器,按需叠加,每个都可以单独测试。
第二,异常处理要放在装饰器内部还是外部要想清楚。重试装饰器如果在内部捕获所有异常再重试,调用方可能完全感知不到中间失败;如果你希望失败时向上抛出,就得在重试次数耗尽后重新raise。写的时候要明确装饰器的异常契约,别让上层以为函数“从不失败”。
第三,装饰器本身也要写单元测试。很多人会测被装饰的业务函数,但忘了专门测试装饰器效果。比较好的方式是单独验证装饰器对原函数行为的影响:
def test_retry_success_after_failure(): call_count = {"n": 0} @retry(times=3, delay=0) def flaky_function(): call_count["n"] += 1 if call_count["n"] < 2: raise ValueError("temporary error") return "ok" assert flaky_function() == "ok" assert call_count["n"] == 2第四,在项目里用装饰器时,要约定一整套“装饰器清单”放在公共模块里,而不是散落在各个业务文件中。这样后面的人查看某个接口最终被哪些装饰器包装,可以直接去公共模块看定义,不用到处翻。
我觉得装饰器最迷人的地方是:它让“横切逻辑”第一次有了一个干净的落点。日志、缓存、重试、权限校验,这些本来需要侵入每个业务函数的东西,现在都能被抽成一个薄层,业务代码回归到“只描述业务本身”。弄懂它之后,你会发现在很多场景下,代码不是越写越复杂,而是越写越顺手。希望这篇笔记能帮你在装饰器这条路上少踩几个坑。