作为Python开发者,你迟早会遇到“装饰器”这个词。不管是看开源源码、写Web接口、还是做爬虫,装饰器就像影子一样无处不在。有人把它当成炫技的黑魔法,有人觉得它难懂,但真正理解了之后你会发现,它就是Python里一个极其优雅的“函数包装”工具。这篇博文我们不谈虚的,就从底层原理到实际项目中的应用场景,把装饰器的皮扒开,让你看完就能上手写,甚至能避开我踩过的那些坑。
无论你是刚入门Python的新手,还是写了两三年脚本想进阶的开发者,把装饰器吃透,你的代码能瘦一圈,复用性提升一个档次,排查问题也会少很多弯路。我会用大量可运行的代码示例和真实项目里会遇到的坑,一步步拆解装饰器背后的“闭包”机制、常见写法、进阶玩法以及典型应用。看完之后,你不仅能看懂别人写的装饰器,还能自己设计出贴合业务的装饰器。
1. 装饰器到底解决什么问题
1.1 从一段重复代码说起
你在写业务代码时,一定遇到过这种情景:好几个不同函数里,需要做同样一件事,比如记录日志、统计运行时间、检查用户是否登录、打印一下进出参数。如果直接在每一个函数里粘贴同样的代码,初期看着不痛不痒,但一旦需求变化,比如日志格式要改、计时单位要从秒变成毫秒,你就得把所有函数挨个翻出来改一遍,稍有不慎就会漏改。
举个最实在的例子。你写了一个爬虫模块,里面有三个函数抓不同的页面:
def fetch_detail_page(url): print(f"[INFO] 开始请求 {url}") # 假装花了2秒 time.sleep(2) print("[INFO] 请求结束") return {"status": 200, "data": "detail"} def fetch_list_page(url): print(f"[INFO] 开始请求 {url}") time.sleep(3) print("[INFO] 请求结束") return {"status": 200, "data": "list"} def fetch_search_page(url): print(f"[INFO] 开始请求 {url}") time.sleep(1) print("[INFO] 请求结束") return {"status": 200, "data": "search"}三个函数都有同样的两行打印。如果再加新的抓取函数,又得复制一遍。如果有一天日志要改成写入文件,或者要统计每个请求的耗时,改动量会直线上升。这就是装饰器最典型的应用场景——把横切逻辑从业务函数里抽离出来。
简单说,装饰器就是一个“包装函数”:它在不改动原函数内部代码的前提下,给原函数增加额外的能力。这种“开闭原则”的思想在工程里非常重要:对扩展开放,对修改关闭。你不需要侵入业务函数内部去改东西,只需要在它头上加一行@xxx,就能像贴标签一样给函数附加新功能。
1.2 函数也是一等公民:一切的基础
理解装饰器,首先要接受一个Python里很核心的概念:函数也是对象,它和其他变量一样,可以被赋值、当作参数传递、作为返回值返回。
你平时写一个函数,函数名本质上就是一个变量名,指向函数对象。这意味着你可以这么做:
def say_hello(): return "hello" # 把函数赋值给另一个变量 greet = say_hello print(greet()) # 输出 hello print(type(say_hello)) # 输出 <class 'function'>你还可以把函数塞进列表、字典,作为参数传给另一个函数:
def call_twice(func): func() func() def say(): print("hi") call_twice(say) # 输出 hi # 输出 hi def make_multiplier(n): def multiplier(x): return x * n return multiplier double = make_multiplier(2) print(double(5)) # 输出 10最后这个make_multiplier例子尤其关键,它演示了“函数内部再定义函数,并且把内部函数返回出去”。这就是装饰器最底层的结构。
1.3 闭包:让函数记住“环境”
上面make_multiplier(2)返回的multiplier函数,它在执行时使用了外层的n。虽然外层函数make_multiplier已经执行完毕,但内部函数仍然“记着”当时n=2这个环境。这种把外层变量绑定到内层函数的机制,就叫闭包(Closure)。
Python的闭包有三个必要条件:必须有一个内层函数;内层函数必须引用了外层函数的变量;外层函数必须把内层函数作为返回值返回。装饰器本质上就是一个闭包,只不过它接收的参数非常特殊——它接收的是一个函数对象。
你可以通过__closure__属性查看闭包捕获的变量:
print(double.__closure__[0].cell_contents) # 输出 2理解闭包后,后面所有装饰器写法都不再神秘。装饰器无非就是:你写一个外层函数,它接收一个函数作为参数,然后在内部定义一个包装函数,在包装函数里调用传入的函数,最后把包装函数返回。Python等你写完这个外层函数后,用@语法把这个过程简化成一行。
2. 手把手实现你的第一个装饰器
2.1 最基础的装饰器写法
我们先写一个最简单的装饰器,它的作用是在函数执行前后各打印一条日志。按照闭包套路,外层接收函数,内层调用函数:
import functools def log_decorator(func): @functools.wraps(func) def wrapper(*args, **kwargs): print(f"[日志] 准备调用函数: {func.__name__}") result = func(*args, **kwargs) print(f"[日志] 函数调用完成: {func.__name__}") return result return wrapper @log_decorator def add(a, b): return a + b print(add(1, 2))输出结果:
[日志] 准备调用函数: add [日志] 函数调用完成: add 3注意,我把func(*args, **kwargs)的返回值用result接住,然后return result。这一点非常关键,否则被装饰函数如果有返回值,会被包装函数悄悄吞掉。很多新手写装饰器容易漏掉这步,结果发现函数返回None,排查半天。
*args, **kwargs保证了任意参数的函数都能被这个装饰器包装。args是位置参数元组,kwargs是关键字参数字典。调用时直接透传。这种写法是所有装饰器的通用模板。
@log_decorator等价于执行add = log_decorator(add)。我想强调一下:装饰器在函数定义完成后立即执行,不是在函数调用时才执行。如果你在模块里写了个带装饰器的函数并打印日志,你会发现在import该模块时日志就会输出一次(装饰器执行),而不是调用函数时输出。
2.2 用functools.wraps保留函数元信息
看到上面我写了@functools.wraps(func),这是很多教程里容易忽略的细节。为什么需要它?
如果你不加这一行,被装饰后的函数,它的__name__、__doc__、__module__等元信息全变成了包装函数wrapper的。比如:
def my_decorator(func): def wrapper(*args, **kwargs): return func(*args, **kwargs) return wrapper @my_decorator def compute(): """这是一个计算函数""" return 1 print(compute.__name__) # 输出 wrapper print(compute.__doc__) # 输出 None这会导致两个后果:一是调试的时候打印函数名,你会看到千篇一律的“wrapper”;二是有些框架或库依赖函数的签名做路由、参数绑定,比如FastAPI、Flask这种Web框架,如果函数的__name__丢了,接口名都会乱。functools.wraps的作用就是把你原函数的__name__、__doc__、__module__、__dict__等属性复制到包装函数上,让它看起来像原函数。
functools.wraps实际上是一个偏函数,内部通过update_wrapper完成属性复制。它还能通过__wrapped__属性找到原始函数。所以,我个人的习惯是:所有自定义装饰器,永远第一行都写上@functools.wraps(func),养成习惯,不然早晚会吃亏。
2.3 装饰器带参数:给装饰器加配置
很多时候,装饰器本身需要配置。比如日志装饰器,你可能想控制日志级别;重试装饰器,你想指定最大重试次数。这时候就不能直接写一个接收func的外层函数了,因为@log_decorator(level="DEBUG")的调用方式是先执行log_decorator(level="DEBUG")返回一个真正的装饰器,然后用返回的装饰器去作用在函数上。
写法是:再包一层,形成三层结构。外层接收配置参数,中间层接收函数,内层处理逻辑:
import functools def log_decorator(level="INFO"): def decorator(func): @functools.wraps(func) def wrapper(*args, **kwargs): print(f"[{level}] 调用 {func.__name__}") return func(*args, **kwargs) return wrapper return decorator @log_decorator(level="WARNING") def send_request(): return "ok" send_request() # 输出 [WARNING] 调用 send_request如果你不想写三层,也可以用functools.partial来简化,但三层是最直观的。这里有一个常见的坑:如果你写成@log_decorator而不是@log_decorator(level="INFO"),Python会直接把send_request函数作为level参数传给log_decorator。这会导致代码行为完全改变。所以设计带参数的装饰器时,一定要确定调用时加括号。为了兼容这两种用法,有些复杂的写法会在内部做判断,但实际工程里,明确约定带括号是更清晰的做法。
3. 装饰器的进阶玩法:类装饰器与内置装饰器
3.1 用类写装饰器
函数能写装饰器,类同样可以。类装饰器靠的是__call__魔法方法,让实例对象“可调用”。因为类本身就是可调用对象(调用类生成实例),只要给类加上__call__方法,实例也能像函数一样被调用。
用类实现无参装饰器:
class CallCounter: def __init__(self, func): functools.update_wrapper(self, func) self.func = func self.count = 0 def __call__(self, *args, **kwargs): self.count += 1 print(f"函数 {self.func.__name__} 被调用 {self.count} 次") return self.func(*args, **kwargs) @CallCounter def hello(): return "hello" hello() hello()类装饰器最大的优势是:可以用属性来保存状态。上面的count就是装饰器自身的状态,每次调用都会更新。如果用函数装饰器实现计数器,需要借助闭包里的nonlocal变量,可读性没有类属性直观。当装饰器的内部状态比较复杂、或者要提供额外方法时,类装饰器是更合适的选择。
类装饰器实现带参数的版本:
class Retry: def __init__(self, times=3): self.times = times def __call__(self, func): @functools.wraps(func) def wrapper(*args, **kwargs): for i in range(self.times): try: return func(*args, **kwargs) except Exception as e: print(f"第{i+1}次调用失败: {e}") if i == self.times - 1: raise return wrapper @Retry(times=2) def flaky_request(): import random if random.random() < 0.5: raise ConnectionError("网络异常") return "success"注意这里的__init__接收的是配置参数,__call__接收的是函数,逻辑依然清晰。如果用函数装饰器写,就是三层嵌套;用类写,就是两个魔法方法,各司其职。
3.2 内置装饰器:property/staticmethod/classmethod
Python自带几个装饰器,它们给类里的方法赋予了不同的行为。理解它们能帮你更好地理解装饰器在真实代码中如何影响函数。
@property把一个方法变成属性访问。原本你写user.get_name(),有了它,直接user.name就行,而且可以在属性赋值时加校验:
class User: def __init__(self, name): self._name = name @property def name(self): return self._name @name.setter def name(self, value): if not isinstance(value, str): raise ValueError("name 必须是字符串") self._name = value u = User("张三") print(u.name) # 这里不是调用方法,而是访问属性 u.name = "李四"@staticmethod和@classmethod用于定义不依赖实例的方法。区别在于staticmethod不接收任何自动参数,classmethod会自动接收类本身cls。比如你要写一个根据年份创建实例的工厂方法,用classmethod很合适:
class Person: def __init__(self, age): self.age = age @classmethod def from_birth_year(cls, birth_year, current_year=2025): return cls(current_year - birth_year) @staticmethod def is_adult(age): return age >= 18这些内置装饰器本质上也遵循“包装”的思想,只不过它们是Python解释器内部已经定义好的一些包装逻辑。用它们的时候,你会发现装饰器不只是用来加日志的,它还能改变一个方法在类中的作用域和调用方式。
3.3 装饰器叠加的执行顺序
一个函数可以同时被多个装饰器装饰,写法从上到下一行一行叠加:
@decorator_a @decorator_b def target(): pass这里要特别搞清楚顺序:装饰器从下往上执行。也就是说,target先被decorator_b包装,得到的结果再被decorator_a包装。等价于target = decorator_a(decorator_b(target))。
可以用一个例子验证:
def wrap_a(func): print("A 包装") def inner(): print("A 调用前") func() print("A 调用后") return inner def wrap_b(func): print("B 包装") def inner(): print("B 调用前") func() print("B 调用后") return inner @wrap_a @wrap_b def work(): print("实际工作") work()输出顺序:
B 包装 A 包装 A 调用前 B 调用前 实际工作 B 调用后 A 调用后了解顺序在实际项目中很重要。比如你同时用了权限校验装饰器和日志装饰器,你希望日志记录的是校验之后的成功请求,还是校验之前的失败请求?这就取决于装饰器的顺序。通常我会把最底层的装饰器写成“最贴近业务”的,把外层的装饰器写成“横切关注点”的。没有绝对标准,但一定要心里有数,否则排查问题时顺序搞反,会看到诡异现象。
4. 实战:装饰器在真实项目里的四个典型应用
4.1 执行耗时统计
性能分析时,我们经常要统计某个函数的耗时。写一个计时装饰器是每个Python开发者都绕不过去的练习:
import time import functools def timeit(func): @functools.wraps(func) def wrapper(*args, **kwargs): start = time.perf_counter() result = func(*args, **kwargs) elapsed = time.perf_counter() - start print(f"函数 {func.__name__} 耗时 {elapsed:.4f} 秒") return result return wrapper @timeit def parse_data(): time.sleep(0.5) return {"ok": True} parse_data()这里用time.perf_counter()而不是time.time(),是因为perf_counter提供最高精度的单调时钟,不受系统时间调整影响,测耗时更准。在量化交易或者爬虫里,你可能会用它监控某个关键接口的响应速度。如果需要把耗时返回给调用方,可以在wrapper里额外把结果包装成元组,但要小心破坏原有返回值结构。我通常会把这个装饰器设计成可以传一个output_fn参数,把耗时记录到日志文件或者推送告警。
4.2 用户权限校验
在Web应用里,权限校验是装饰器最高频的用法之一。比如用Flask写接口,你希望某些接口必须登录才能访问,某些接口必须是管理员才能访问:
import functools from flask import abort, session def login_required(func): @functools.wraps(func) def wrapper(*args, **kwargs): if not session.get("user_id"): abort(401) return func(*args, **kwargs) return wrapper def admin_required(func): @functools.wraps(func) def wrapper(*args, **kwargs): user_id = session.get("user_id") # 这里假设通过user_id查询角色;实际项目中会写缓存 role = get_user_role(user_id) if role != "admin": abort(403) return func(*args, **kwargs) return wrapper @app.route("/admin/data") @admin_required def admin_data(): return {"data": "机密"}这个场景里装饰器的好处就是,你不需要在视图函数里写一堆if判断。而且多个接口能共用同一个装饰器,一旦鉴权逻辑要升级(比如加个token校验),只改装饰器一处即可。同理,在爬虫框架里,你可以用装饰器给某些采集函数加代理校验、频率限制,思路一模一样。
4.3 函数结果缓存
递归、重复查询、大计算量的幂等函数,都适合做结果缓存。装饰器可以轻松实现一个内存版缓存:
import functools def memoize(func): cache = {} @functools.wraps(func) def wrapper(*args, **kwargs): # 简单起见,用参数构造缓存key;工程中需要考虑类型和顺序 key = (args, tuple(sorted(kwargs.items()))) if key not in cache: cache[key] = func(*args, **kwargs) return cache[key] return wrapper @memoize def fibonacci(n): if n < 2: return n return fibonacci(n - 1) + fibonacci(n - 2) print(fibonacci(50))这里用args和kwargs组成可哈希的key。不过要注意,如果参数里有不可哈希的对象(比如列表、字典),直接放进key会报错。工程上一般会用repr或者序列化处理。Python 3.9+的functools.cache和functools.lru_cache已经内置了这个功能,lru_cache还支持最大缓存数量,用于限制内存占用:
@functools.lru_cache(maxsize=128) def get_weather(city): pass如果自己实现缓存装饰器,还要考虑缓存失效策略。比如爬虫里对页面内容做短时缓存,防止短时间内重复请求同一个URL,你可以在装饰器里记录过期时间,过期后重新请求。
4.4 失败重试机制
调用第三方API时,网络抖动时有发生。给请求函数加一个重试装饰器,能在遇到临时故障时自动重试,而不是直接抛出异常导致程序崩溃。前面已经用类写过一个Retry版本,这里用函数带参数的版本再展示一下:
import time import functools def retry(max_retries=3, delay=1, exceptions=(Exception,)): def decorator(func): @functools.wraps(func) def wrapper(*args, **kwargs): for attempt in range(max_retries + 1): try: return func(*args, **kwargs) except exceptions as e: if attempt == max_retries: raise print(f"第 {attempt+1} 次失败,{delay} 秒后重试: {e}") time.sleep(delay) return wrapper return decorator @retry(max_retries=2, delay=0.5, exceptions=(ConnectionError, TimeoutError)) def fetch_remote_data(): # 模拟不稳定网络 import random if random.random() < 0.7: raise ConnectionError("网络连接失败") return "data" print(fetch_remote_data())这里有个关键设计:exceptions=(ConnectionError, TimeoutError)。如果你不做限制,except Exception会把所有异常都捕获,包括你代码里的ValueError、TypeError。如果业务函数里因为参数错误抛了异常,你重试多少次都没用,反而掩盖了真实的Bug。所以重试装饰器一定要明确只捕获“可重试的临时异常”,其他异常直接抛出去。另外一个细节是重试间隔可以递增,比如第一次等0.5秒,第二次等1秒,这就是退避策略;实际中还可以加随机抖动,防止多个请求同时重试造成雪崩。
5. 常见问题与排查心得
5.1 装饰器装饰后函数签名变了怎么办
这是使用装饰器最经典的坑。你定义了一个函数,明明有两个参数,但调用时Python告诉你参数不对,或者IDE里提示签名变成了(*args, **kwargs)。这就是因为包装函数把原函数的参数签名吞掉了。
解决方案很简单:第一,用@functools.wraps(func)复制元信息,它会顺带把__wrapped__属性指向原函数;第二,如果你使用FastAPI、Flask、Django这类框架,框架内部依赖函数的inspect.signature做参数解析,一定要确保你的装饰器用了wraps。如果某些特殊库连wraps都不够用,你还可以用第三方库wrapt,它保留了函数签名的可见性,但绝大多数场景functools.wraps已经足够。
5.2 带参数装饰器不小心多了一层括号
初学者经常在两种写法之间来回切换,导致出错。比如定义了带参数的装饰器@retry(max_retries=3),但又不想传参数,直接写@retry,这时候Python会报错“intobject is not callable”之类的奇怪错误。原因前面说过:@retry等价于func = retry(func),而你的retry函数接收的第一个参数是max_retries,传进来的是函数对象,那内部decorator就永远不会被返回了,于是func被绑定到max_retries上,数值不能作为函数调用,自然爆炸。
为了避免这种问题,我建议在写带参数装饰器时,养成函数名后面加括号的习惯,即使你不打算传参:@retry()。同时可以在装饰器内部加一个判断,如果第一个参数是可调用对象,说明用户用了不带括号的写法,自动兼容两种风格。但为了代码清晰,更推荐统一约定。
5.3 装饰器修改了函数返回值或异常被吞掉
有的装饰器在包装函数时,忘了return result,导致被装饰函数永远返回None。或者装饰器的内部wrapper里,因为多包了一层try...except,把异常捕获后没有重新抛出,导致业务代码看不到异常,程序在奇怪的地方静默失败。
我的排查经验是:一旦发现装饰过的函数行为不对,第一反应就是写一个最简单的原函数,直接调用,对比装饰前后的输出。如果发现异常被吞,检查装饰器内部是否有裸的except Exception:,并且没有重新raise。重试装饰器尤其要小心:重试完最后一次再报错,要在else块里把异常抛出,不要默默处理。正确姿势是重试循环外重新raise,或者让异常自然传播。
5.4 循环导入与装饰器执行的坑
装饰器的执行时机是模块导入时,而不是函数调用时。这意味着,如果你的装饰器依赖另一个模块的对象,而那个模块又反过来依赖你的模块,就可能出现循环导入错误。比如a.py导入b.py里的一个check_permission装饰器,而b.py在模块顶层又用了a.py里的某个配置常量。当Python加载a.py时,它去加载b.py,b.py又回头找a.py的配置,此时a.py还在初始化阶段,配置还没定义,就会报错。
处理办法:
- 把装饰器的依赖对象改成延迟导入,即在装饰器内部函数里导入,而不是模块顶层导入;
- 或者把公共依赖抽到第三个模块;
- 也可以在装饰器中只做标记,实际逻辑放到调用时再解析。
另外一个隐蔽的坑:装饰器如果既用类又用元类,可能会影响子类收集。比如类装饰器作用于类时,它的执行顺序和元类、__init_subclass__的交互要格外注意。不过日常业务中很少碰这些,遇到再查也不迟。
5.5 排查心得:善用__wrapped__和栈追踪
functools.wraps给包装函数设置了__wrapped__属性,指向原始函数。很多调试工具依赖这个属性来还原真实函数信息。你在多级装饰器叠加时,可以通过一层层解开__wrapped__来定位原函数:
def outer_decorator(func): @functools.wraps(func) def wrapper(*args, **kwargs): return func(*args, **kwargs) return wrapper def inner_decorator(func): @functools.wraps(func) def wrapper(*args, **kwargs): return func(*args, **kwargs) return wrapper @outer_decorator @inner_decorator def target(): pass print(target.__wrapped__) # 原始 target 函数另外,当异常发生时,栈信息里的函数名会是wrapper而不是原函数名,这会给你调试增加不少困难。用functools.wraps后,函数名会显示为原函数名,这一点看似不起眼,但排障时太省心了。我见过太多不写wraps的项目,线上日志里全是“wrapper”报错,连是哪个接口挂的都分不清。
结尾:一些实在的经验
我自己刚学装饰器那会儿,也经历了“看着都懂,一写就错”的阶段。后来我发现,最好的学习方式不是背语法,而是把装饰器当成“函数工厂”:外层函数是生产线,它根据你的要求(配置参数)生产出一个包装函数,这个包装函数内部把原函数包起来,顺便加点料。想清楚这层模型,再去看各种复杂的装饰器,就能一眼看出它是把逻辑放在哪一层、什么时候执行。
如果你要在项目里引入自定义装饰器,我强烈的建议是:保证装饰器足够通用,不要在里面写死和具体业务绑定的逻辑;同时给装饰器配上清晰的文档字符串,说明参数含义、异常行为;最重要的,一定要用functools.wraps。这些习惯看起来琐碎,但在多人协作的项目里,能让你的装饰器成为大家的福音而不是坑。
最后送你一个小技巧:如果你发现自己写出了一个装饰器,它的代码比被装饰的业务函数还长、还复杂,那就停下来想想,装饰器的职责是不是太“重”了。一个合格的装饰器,应该是轻巧的、专注横切逻辑的。如果它需要管理复杂状态,可以考虑把它改成类,甚至用多个装饰器组合。实践多了,你自然能找到那个度。希望这篇内容能让你对Python装饰器从“听过”到“会用”,再到“用得漂亮”。