写Python写了几年的人,多多少少都会遇到这样一个场景:你的函数散落在各个模块里,突然有一天产品说要给每个接口加上耗时统计,你第一反应是打开每个函数,在开头写一行start_time = time.time(),在return之前再写一行print("elapsed:", time.time() - start_time)。改到第三个函数的时候你就开始怀疑人生——同样的代码复制粘贴几十遍,以后要改格式还得逐个文件翻。我当年就是这么过来的,直到搞明白了装饰器(Decorator)之后,才意识到这种"包装函数"的活儿根本不用手动做。这篇单点知识文章,就是把Python装饰器从头到尾讲透:它是什么、为什么存在、怎么写、有哪些坑。适合刚学完Python基础语法想进阶的读者,也适合那些写了一阵子代码却一直对装饰器"半懂不懂"的人。你不用急着背语法,跟着文章的逻辑走一遍,自己动手敲一遍代码,基本就能彻底搞明白。
1. 为什么说"函数也是对象"是一切的前提
1.1 Python函数本质上就是普通变量
装饰器这个东西,很多教材上来就甩一个@xxx的语法糖,看得人一头雾水。如果你想真正理解装饰器而不是背语法,必须先把一个基础概念刻在脑子里:在Python里,函数也是一个对象,可以像普通变量一样被传递、赋值、装进列表、当成参数传给另一个函数。它不是什么高高在上的"编译期魔法",就是一个运行时的普通对象。
def hello(name): return f"Hello, {name}" # 函数可以赋值给变量 greet = hello print(greet("Alice")) # Hello, Alice # 函数可以放进列表 funcs = [hello, greet] print(funcs[0]("Bob")) # Hello, Bob # 函数可以作为参数传给另一个函数 def call_it(func, arg): return func(arg) print(call_it(hello, "Carol")) # Hello, Carol这段代码跑一下,结果全都是正常的字符串输出。也就是说,在Python里"拿到一个函数对象"和"拿到一个字符串、一个整数"没有什么本质区别,不同的只是函数对象后面可以跟圆括号把它call起来。这听起来很简单,但它是理解装饰器的第一块基石:既然函数能当参数传,那我定义一个函数,接收另一个函数作为入参,在里面做一些包装处理,再返回一个新的函数,逻辑上就完全成立了。
理解这一点之后,你再去看那些"把函数名丢了""函数信息被覆盖"的问题,才会有直觉性的判断——因为本质上你就是在做对象的替换和传参,而不是在搞什么玄学。
1.2 闭包:函数套函数带来的"记忆"能力
如果函数只是能传来传去,还撑不起装饰器的全部威力。真正关键的是闭包(closure)——在Python里,你可以在一个函数内部再定义函数,内部函数可以访问外层函数作用域里的变量,而且即使外层函数已经return了,这个内部函数依然"记得"那些变量。
def outer(x): def inner(y): return x + y return inner add5 = outer(5) print(add5(10)) # 15outer(5)执行完已经返回了,但add5这个函数仍然带着x=5这个状态,所以调用add5(10)得到15。这种"带着状态返回"的函数,就是闭包。
你可以把闭包理解成:函数对象不光有代码,还随身带了一个小背包,里面装着它定义时所在作用域的变量引用。这个小背包装的东西,正是装饰器实现"包裹逻辑"的根本。没有闭包,你就只能把被装饰函数传进去,却没办法在外层保存配置、计数、缓存等状态。
1.3 装饰器的雏形:先手动"包一层"
有了上面两个基础,装饰器的核心逻辑已经可以手写了。假设你有一个除法函数:
def divide(a, b): return a / b现在想在每次调用之前先检查b是否为0。最直接的做法是改函数内部,但如果这个函数是你从别的模块import进来的,或者你不想动它的内部实现,那就可以在外面包一层:
def check_zero(func): def wrapper(a, b): if b == 0: raise ValueError("b cannot be zero") return func(a, b) return wrapper safe_divide = check_zero(divide) print(safe_divide(10, 2)) # 5.0 print(safe_divide(10, 0)) # 抛 ValueError这个check_zero就是装饰器,wrapper就是被包装后的新函数。整个过程说白了就一句话:把原来的函数传进去,在外面套一层逻辑,再返回一个新的函数。这种写法的好处是divide本身没有被改动,你得到的safe_divide是带检查逻辑的"增强版divide"。
2. 手写一个装饰器:把@语法糖拆开看
2.1 从手动包装到@语法糖
每次都要手动写safe_divide = check_zero(divide)太啰嗦,尤其是多个装饰器叠加的时候,代码会被套好几层。所以Python在语法层面提供了一个简写:
@check_zero def divide(a, b): return a / b就是这么简单。@check_zero放在函数定义之前,等价于执行了divide = check_zero(divide)。注意,被装饰的divide这个名字,从此就指向了check_zero返回的那个wrapper函数。
所以以后看到某个函数上面挂着@something,不要再觉得神秘,它就是在函数定义完之后做了一次"重新赋值"。你甚至可以不用@,手动做赋值,效果一模一样——不少代码库里还会刻意手动装饰,为的是在运行时按条件选择不同的装饰器。
2.2 为什么wrapper一定要*args和**kwargs
现在有个现实问题:你写一个装饰器的时候,根本不知道将来会用它装饰什么函数。有的函数接收两个位置参数,有的接收一个关键字参数,有的一个参数都没有。为了让装饰器具有通用性,wrapper必须写成这样:
def wrapper(*args, **kwargs): return func(*args, **kwargs)*args负责把所有位置参数收集成元组,**kwargs把关键字参数收集成字典,调用func的时候再原样展开传进去。这样不管被装饰函数的签名是什么,wrapper都能"透明"地承接并转发。
如果装饰器只服务于某一个特定函数,也可以把wrapper写成和该函数签名完全一致的样子;但在通用工具里,*args和**kwargs几乎成了标配。没有这两个东西,你的装饰器一装饰到参数类型不同的函数上就崩,而且崩得莫名其妙。
2.3 装饰器的执行时机:定义时,不是调用时
这点是我见过最多人搞错的。装饰器是在函数定义(模块加载)的时候被执行的,不是在函数被调用的时候执行。
print("module loading...") def timer(func): print(f"decorating {func.__name__} ...") def wrapper(*args, **kwargs): return func(*args, **kwargs) return wrapper @timer def foo(): pass print("module loaded")如果你把这段代码保存成文件import进来,会看到"decorating foo ..."在模块加载阶段就打印出来了,根本不需要调用foo()。
这个特性影响很大。一方面,装饰器本身的开销发生在import时,所以不要在装饰器里放太重的初始化逻辑;另一方面,很多"注册式"框架(比如把函数登记到某个路由表、命令表里)正是利用这个执行时机来实现自动注册的。搞清楚执行时机,你才能理解为什么Flask、FastAPI里那些@app.route("/")装饰器,明明没调用函数就已经能完成路由注册。
3. 进阶三件套:wraps、参数化装饰器和类装饰器
3.1 functools.wraps:保住函数名和文档字符串
前面说了,@check_zero等价于divide = check_zero(divide),而check_zero返回的是wrapper。也就是说,被装饰后的divide.__name__不再叫divide,而是叫wrapper;它的__doc__也变成了wrapper的文档(通常啥也没有)。
这会带来一堆真实问题:
- 调试打印日志时,函数名全是wrapper,根本分不清是哪个函数。
- 测试框架、文档生成工具是根据__name__定位函数的。
- 有些代码用函数名做缓存key或路由映射,名字变了直接出bug。
解决方案就是functools.wraps,它会把原函数的__name__、doc、module、__qualname__等属性复制到wrapper上,同时还会在wrapper上保留一个__wrapped__指向原始函数。
import functools def check_zero(func): @functools.wraps(func) def wrapper(a, b): if b == 0: raise ValueError("b cannot be zero") return func(a, b) return wrapper @check_zero def divide(a, b): """Divide a by b.""" return a / b print(divide.__name__) # divide print(divide.__doc__) # Divide a by b. print(divide.__wrapped__) # <function divide at 0x...>结论很简单:自己写装饰器,wrapper上面永远记得加上@functools.wraps(func)。这已经不是风格问题,而是职业习惯。如果你在第三方代码里看到没加wraps的装饰器,通常可以判定为写得不专业。
3.2 带参数的装饰器:再套一层函数
有些装饰器本身需要配置。比如"重复执行n次"里的n、"重试三次"里的次数,这些配置必须在使用装饰器的时候传进去。这时就需要三层嵌套:
def repeat(times): def decorator(func): @functools.wraps(func) def wrapper(*args, **kwargs): for _ in range(times): result = func(*args, **kwargs) return result return wrapper return decorator @repeat(3) def say_hi(): print("hi")@repeat(3)的执行分两步。第一步:调用repeat(3),得到decorator函数;第二步:Python再把被装饰函数传给decorator,等价于say_hi = repeat(3)(say_hi)。也就是说,参数化装饰器只是在外层多包了一个函数,用闭包把times这个配置先保存下来。
这里容易绕晕的地方在于层数太多:外层函数接收参数,中间层接收函数,内层wrapper接收真正调用时的实参。我自己的记忆口诀是:第一层收配置,第二层收函数,第三层收调用参数。写多了自然就顺了。
3.3 类装饰器:利用__call__的另一种写法
装饰器不一定是函数,只要是"可调用对象"就行。Python里定义一个类,实现__call__方法之后,这个类的实例就变成可调用对象了,于是它可以作为装饰器使用:
class CountCalls: 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"calls: {self.count}") return self.func(*args, **kwargs) @CountCalls def hello(): print("hello")这里用functools.update_wrapper手动复制属性,因为类实例上没有普通装饰器那种自动包装机制。装饰之后,hello这个名字指向的是CountCalls的实例,调用hello()会执行__call__里的逻辑。
类装饰器的好处是能更方便地保存状态和暴露方法,比如上面的count属性在装饰后可以从外部访问。当你需要管理比较复杂的装饰器状态(多个计数器、配置组合等)时,类是个很好的选择。
4. 实战:四个可以直接抄进项目的装饰器模板
4.1 函数耗时统计
这个最基础,也是文章开头那个场景的解法:
def timer(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__} took {elapsed:.4f}s") return result return wrapper测量时间建议用time.perf_counter而不是time.time,因为perf_counter是专门用来测量短时间间隔的高精度时钟,不受系统时间调整(比如NTP校时)影响。而time.time返回的是墙上时钟时间,如果正好赶上系统时间被往前调,你会测出负的耗时,心态直接崩。
实际用的时候,我会给数据库查询、外部API请求这类函数挂上timer,定位慢调用比在业务代码里手工插桩干净得多。不过注意,print在高并发下会拖慢性能,生产环境建议换成logging。
4.2 缓存装饰器
如果有个计算密集函数,同一组参数反复传入,每次都重新算一遍很浪费。可以做一个简单的记忆化装饰器:
def memoize(func): cache = {} @functools.wraps(func) def wrapper(*args, **kwargs): key = (args, tuple(sorted(kwargs.items()))) if key not in cache: cache[key] = func(*args, **kwargs) return cache[key] return wrapper注意两个坑。第一,dict的key必须是可hash的,如果你的函数参数里有列表、字典这种可变对象,key构造会直接抛TypeError。第二,cache会无限增长,长期运行的高频函数必须考虑淘汰策略,比如限制缓存数量或者用LRU。
其实标准库functools.lru_cache就是官方封装的缓存装饰器,用法是@functools.lru_cache(maxsize=128),支持LRU淘汰,比手动写安全。我通常只有在需要自定义缓存存储(比如存到Redis)的时候才自己写memoize。
4.3 重试装饰器
网络请求、外部接口调用难免遇到临时故障,写一个带重试能力的装饰器很实用:
def retry(max_attempts=3, delay=0.5): def decorator(func): @functools.wraps(func) def wrapper(*args, **kwargs): for attempt in range(1, max_attempts + 1): try: return func(*args, **kwargs) except Exception: if attempt == max_attempts: raise time.sleep(delay) return wrapper return decorator用的时候在请求函数上写@retry(max_attempts=5, delay=1)就行。这里有笔账要自己算清楚:哪些异常值得重试?连接超时、瞬时5xx值得,业务参数错误、4xx错误重试一万次也没用。所以严谨的做法是except后面接具体的异常类型,不要一股脑except Exception——否则等于把故障掩盖在重试循环里,线上出问题都不知道真凶是谁。
4.4 鉴权/前置条件校验装饰器
在Web框架或者命令行工具里,经常需要某个函数只有在满足条件时才执行。比如:
def login_required(func): @functools.wraps(func) def wrapper(request, *args, **kwargs): if not request.user.is_authenticated: raise PermissionError("please log in first") return func(request, *args, **kwargs) return wrapper用的时候挂在视图函数上:@login_required。
在Flask里,wrapper通常不显式接收request参数,而是直接使用全局request对象;在FastAPI里,现在更推荐用依赖注入做鉴权,装饰器适合做统一的上下文配置。所以上面的模板是一个"思想演示",具体拿到你的Web框架里,参数形态要按框架习惯调整。核心逻辑是通用的:在调用真正函数之前,先把前置条件检查完。
5. 踩坑记录:这些坑我踩过,别再踩了
5.1 装饰器的叠加顺序:从下往上装饰,从上往下调用
假设你有:
@auth_required @log_execution def foo(): ...被装饰的结果等价于foo = auth_required(log_execution(foo))。也就是说,装饰器的绑定顺序是从下往上的:先执行@log_execution,把foo包一层,再执行@auth_required,把已经包好的foo再包一层。
但调用时顺序恰好相反:请求进来后,先是auth_required这层在运行,验证通过之后才会进入log_execution这一层,最后才真正执行foo函数本体。写装饰器的人必须清楚这个顺序,否则认证层和日志层的先后关系会搞反。一个比较稳妥的设计原则是:越通用、越基础的装饰器放在越下面,越偏业务判断的装饰器放在越上面。
5.2 方法装饰器和self参数的纠缠
在类里装饰实例方法时,有一个和装饰纯函数微妙的区别:实例方法的第一个参数永远是self,而你的装饰器如果为了适应某个特定函数把wrapper参数写死了,就很容易出错。
class Service: def __init__(self): self.base = 10 @add_base def get(self, value): return self.base + value比如add_base的wrapper写成了def wrapper(value),那么在调用service.get(5)时,Python传给get的第一个位置参数是service实例(就是self),第二个位置参数才是5。可是包装后的get指向的是wrapper(value),它只接收一个参数,于是service实例被塞进了value,真正的5反而没有位置。轻则报TypeError,重则参数错位产生诡异的结果。
避开这个坑的方法还是老生常谈:wrapper统一用(*args, **kwargs),不要自己写死参数名和参数个数。我自己就在一个服务类里踩过这个坑,日志一直报missing 1 required positional argument,排查了半天才发现是wrapper签名固化了,不是业务逻辑的问题。
5.3 签名和类型注解:装饰器隐藏的陷阱
即便用了functools.wraps,函数签名也可能暴雷。在Python 3.10之前,inspect.signature(foo)拿到的是wrapper的签名(*args, **kwargs),而不是原始函数的参数列表。某些框架和库依赖inspect.signature来做参数解析,函数签名被装饰器吞掉之后,框架就解析不出正确的参数,轻则报错重则静默传错参。
Python 3.10之后,inspect.signature会自动跟随__wrapped__找到原始函数,这个问题大幅缓解。但如果你在装饰器上再套装饰器、或者用参数化装饰器叠了好几层,__wrapped__链可能被写得不完整,还是要留个心眼。我的习惯是:需要保留真实签名的对外函数,尽量测试一下inspect.signature的输出,必要时手动设置__signature__属性。
5.4 装饰器不是万能的,别把业务逻辑塞进去
把使用边界说清楚。装饰器最擅长处理"横切关注点"这类和业务无关、但很多函数都需要的能力,比如日志、计时、鉴权、重试、缓存、事务。这些逻辑放进装饰器里,能免去大量重复代码,代码仓库也会整洁很多。
但如果你把某个函数的业务判断塞进wrapper里,或者一个函数挂七八个装饰器,调试起来会让你怀疑人生。每套一层函数,调用栈就深一层,异常堆栈、单步调试的成本都更高。根据我个人的项目经验,一个函数最好只叠加两到三个职责清晰的装饰器,再多就该考虑拆分逻辑了。另外,装饰器尽量保持"无副作用",不要偷偷篡改被包装函数的返回值结构,否则调用方很容易懵。
最后再分享一个调试小技巧:只要在装饰器里写上了@functools.wraps(func),被装饰函数就会带一个__wrapped__属性,指向最原始的未包装函数。我在排查"装饰器到底有没有改坏函数行为"的时候,经常在临时脚本里直接调用foo.wrapped(参数)和foo(参数)做对比,结果一目了然。这个小属性在调试多层装饰器的时候,比在堆栈里一层一层翻快多了。