
装饰器Decorator是Python进阶路上绕不开的一块硬骨头。很多人学Python的时候函数、列表、字典、循环这些都还挺顺一到装饰器就开始懵这个符号到底做了什么为什么函数还能被函数包起来我照着例子写了一个换个场景怎么就不会了这些疑问我很熟悉因为我刚学的时候也是一头雾水。我不打算从教科书式的定义讲起而是站在实际项目经验的角度把装饰器为什么存在、底层怎么回事、进阶怎么用、踩过哪些坑一条线完整捋清楚。无论你是刚学完函数、想搞懂装饰器本质的初学者还是写了几个月代码、想提升抽象能力的进阶者应该都能从中找到自己需要的答案。1. 装饰器到底解决什么问题1.1 一次复制粘贴引发的“惨案”先看一个我在刚工作那一年经常干的事情。当时写一个内部服务里面有几个接口函数每个函数的业务逻辑都不一样但上线前都要统计耗时。我当时的做法非常直接在每个函数开头记一下start time.time()函数结束前再算一下耗时然后print出来。第一个函数这么写的时候我觉得问题不大第二个函数也开始复用这个模式到第三个函数时我已经在复制粘贴了。三个函数里塞了三段几乎一模一样的计时代码每段改一下函数名至于日志格式、边界情况完全没有任何考虑。import time def get_user_info(user_id): start time.time() # 假设这里有一大段业务逻辑 time.sleep(0.2) result {user_id: user_id, name: tom} end time.time() print(fget_user_info 耗时: {end - start:.4f} 秒) return result结果到第三个星期需求变了。统计的耗时不仅要打印还要写到统一的日志文件里我改了第一个函数的逻辑第二个、第三个忘记改测试的时候日志只有三分之一。工程师的直觉告诉我这种复制粘贴、到处改三处的写法迟早要出大事。于是我开始认真思考一个问题有没有办法在不改动原函数内部代码的前提下给函数统一附加计时能力这其实就是装饰器要解决的原始问题。其实装饰器解决的不只是少写几行重复代码的问题它解决的是“横切关注点”的代码组织问题。什么叫横切关注点就是那些散落在很多函数里、跟核心业务逻辑无关、但又不得不写的重复逻辑比如打日志、记耗时、做权限校验、加缓存、失败重试。这些逻辑如果写在每个函数内部将来需求一改就得全量修改如果用装饰器抽出来核心函数只需要专注业务公共能力由装饰器统一注入。装饰器的本质就是一个把函数当成原料、加工之后返回新函数的函数。1.2 为什么说它是“钢铁侠战衣”标题里用了“钢铁侠战衣”这个比喻我第一次看到的时候觉得特别形象。战衣穿在托尼·斯塔克身上托尼还是托尼他该研发装备还是研发装备但他出去解决问题的能力和防御能力已经和穿战衣之前完全不一样了。装饰器也是同一回事函数还是那个函数外部调用方式不变但函数被调用时的行为和附加能力已经被彻底增强。这个比喻还有一个很关键的地方就是“透明”。托尼穿战衣前后别人跟他说话还是那么说话该叫他他还是他外部接口没有变。被装饰的函数也一样套上装饰器之后外部调用方完全感知不到函数内部被动过手脚该传几个参数就传几个参数该拿什么返回值就拿什么返回值。这种在不改变调用方式、不侵入业务代码的前提下扩展函数能力的设计就是常说的面向切面编程思想的雏形。理解了这一层你就知道装饰器不是Python特有的奇技淫巧而是一种通用设计思想在语言层面的落地。1.3 适合谁读、需要什么基础这篇文章的定位是“从0到能用”。如果你的Python基础是刚学完函数知道怎么定义、怎么调用那你已经具备读这篇文章的门槛。如果你还想理解装饰器底层的闭包最好对函数嵌套和作用域有一点印象我不太建议零基础直接上手不过下面的章节会把闭包讲得很细哪怕你之前没完全懂跟着代码走一遍也能理解个大概。如果你已经会写最简单的装饰器那可以把重点放在后面三节。那些真实场景和坑是实际项目里一定会遇到的。我个人觉得装饰器是Python里非常典型的“入门容易精通难”的知识点很多教程讲完语法就结束了但项目里报错时往往和语法本身没什么关系真正的问题出在对函数对象替换过程的理解不够深上。所以这篇文章会把原理和实战放在同等重要的位置。2. 装饰器底层原理函数、闭包与语法糖2.1 函数在Python里是一等公民要理解装饰器必须先理解一个前置概念在Python里函数是一个对象。这句话很多人听过但真正体会到的人不多。看一个最简单的例子def say_hello(): return hello f say_hello print(f()) # hello print(say_hello.__name__) # say_hello print(f.__name__) # say_hello这里我把say_hello这个函数对象赋值给了变量f然后通过f()完成调用。关键在于f和say_hello指向的是同一个函数对象所以f.__name__输出的仍然是say_hello。用生活话解释函数定义完成之后解释器在内存里创建了一个函数对象函数名只是贴在对象上的一张标签。我可以再贴一张新标签甚至把旧标签撕掉只要还能找到这个对象就能调用它。不仅仅是赋值在Python里函数还能作为参数传进另一个函数也能作为返回值从函数里出来。能传参、能返回、能赋值这就是“一等公民”。装饰器的所有魔法都建立在这个基础之上。很多人卡在装饰器就是因为没有把函数当作对象来想还停留在“函数是一段代码”的固有印象里。你只要把函数想成一个可以被递来递去的东西接下来的内容就顺了。2.2 闭包内层函数记住了外层环境闭包在Python里经常被当成玄学其实真正理解起来就三条。第一函数可以嵌套定义第二内层函数可以引用外层函数里的变量第三外层函数把内层函数作为返回值返回。满足这三条就形成了一个闭包。直接看代码def outer(x): def inner(y): return x y return inner add_5 outer(5) print(add_5(3)) # 8这个例子里outer(5)执行完了按常理它的局部变量x应该随着函数结束一起消失但实际上没有因为inner还牢牢引用着x。Python在创建inner函数对象的时候会把outer当前的执行环境打包记录下来存到inner.__closure__里。外部拿到的add_5虽然是一个新函数对象但它就像一个人带着记忆记得自己从哪来、记得外层环境里有x5。add_5(3)之所以能算出8就是因为这份记忆还在。这个“记住外层环境”的能力对装饰器来说至关重要。装饰器的本质是一个外层函数它接收被装饰的函数在内部再定义一个wrapperwrapper调用原函数前后插入额外逻辑。原函数、装饰器的参数这些变量全靠闭包机制保存在wrapper周围。所以你看装饰器和闭包不是两套知识装饰器只是在闭包之上加了语法糖而已。2.3 不用先手动给函数穿一次“战衣”我教初学者的时候从来不建议一上来就写符号。语法糖虽然方便但它隐藏了函数被替换的过程如果不知道它替我们做了什么后面一旦接触带参数的装饰器很快就崩溃。所以先手动给函数穿一次战衣import time def timer(func): def wrapper(): start time.time() result func() end time.time() print(f耗时 {end - start:.4f} 秒) return result return wrapper def work(): time.sleep(1) return done work timer(work) print(work())这段代码做了三件事。第一timer接收work作为参数这是它要加工的原料。第二timer内部定义wrapperwrapper里先记录开始时间然后调用真正被包装的函数func调用完再记录结束时间、打印耗时最后把func的返回值原样返回。第三timer把wrapper作为新函数返回给外部。关键的一步是work timer(work)这一行执行完之后外部变量work指向的不再是原来的work函数而是timer返回的wrapper。原来的work并没有消失它被藏在wrapper的闭包里成为被保护起来的“本尊”。之后再调用work()实际发生的事情是wrapper开始计时然后转头去调用旧work拿到返回值打印耗时最后把结果交还给调用方。整个过程对调用方来说完全透明看不到任何额外的东西但函数的行为已经变了。把这个流程理解清楚后面所有装饰器的写法都是在这套流程上面加不同条件而已。2.4 语法糖装饰器真正的打开方式手动写法把work timer(work)放在函数定义后面总觉得有点啰嗦。Python为了解决这个问题干脆提供了一个语法糖让你直接写在函数定义的上方timer def work(): time.sleep(1) return done这两行代码完全等价于先正常定义work函数然后执行work timer(work)。所以千万不要以为timer只是给函数加了一个标记它是真的把work这个函数对象整体替换掉了。替换之后函数名还是work但灵魂已经变成了timer返回的wrapper。这个认知我建议每个初学者都记住因为后面遇到叠加装饰器、带参数装饰器很多糊涂账都是从这里开始乱的。3. 写装饰器必须注意的三个细节3.1 函数的“身份证”会被偷走先看一个非常容易被忽略的问题。写一个最简单的装饰器不使用任何额外的工具def timer(func): def wrapper(): ... return wrapper timer def work(): 这个函数会睡一秒 time.sleep(1) print(work.__name__) # 输出 wrapper print(work.__doc__) # 输出 None运行这段代码你会看到work.__name__输出的是wrapperwork.__doc__是None。也就是说原函数的身份证被装饰器偷走了。这在真实项目里很致命。很多框架依赖函数的__name__做路由命名或任务标识比如Flask里视图函数的endpoint、DRF里action的url_name、Celery任务名一旦被装饰器改成wrapper轻则增加调试难度重则函数识别错乱。所以Python标准库早就提供了一个解决方案functools.wraps。import functools def timer(func): functools.wraps(func) def wrapper(): ... return wrapperfunctools.wraps本身也是一个装饰器它做的事情就是把被装饰函数func的各种元信息复制到wrapper上包括__name__、__doc__、__module__、__qualname__这些并额外设置一个__wrapped__属性指向原函数。用上它之后wrapper看起来、用起来都跟原函数几乎一致。我自己的习惯是所有自定义装饰器wrapper上面一律先写一行functools.wraps(func)先把这个保底动作做了再去写内部的业务逻辑。3.2 参数透传处理五花八门的函数签名前面写的wrapper都是def wrapper()不带参数所以它只能装饰无参函数。但真实项目里的函数千奇百怪有的要传一个参数有的要传五个位置参数有的全是关键字参数。为了解决这个问题wrapper需要写成能接收任意参数的形式def logger(func): functools.wraps(func) def wrapper(*args, **kwargs): print(f调用 {func.__name__}, args{args}, kwargs{kwargs}) result func(*args, **kwargs) print(f{func.__name__} 返回 {result}) return result return wrapper*args会把所有位置参数收进一个元组**kwargs把所有关键字参数收进一个字典然后在调用原函数时用*args和**kwargs把它们原样展开。这样一来无论被装饰的函数签名长什么样装饰器都能通用。这是写装饰器最基础也最重要的一个习惯。需要注意一点在装饰器内部尽量不要修改args元组里的内容元组本身不可变如果确实要临时改参数先把它转成list改完再传进去。3.3 多个装饰器叠加顺序怎么算一个函数同时挂两个装饰器的情况很常见比如既要记录耗时又要校验权限。挂的方式是timer auth_required def upload_file(): ...装饰器的应用顺序是从下往上执行顺序是从上往下。具体说第一轮先由auth_required处理原函数得到一个带权限校验的新函数第二轮timer再来装饰这个新函数最终得到“先计时、再校验、再执行原函数”的链。打个比方穿衣服要先穿T恤再穿外套但脱衣服恰好相反。所以如果你希望“先校验身份再开始计时”把auth_required放下面、timer放上面就是对的。如果写反了计时逻辑就会把权限校验的时间也算进去统计数据失真。这个顺序问题我在代码评审里帮同事纠正过很多次。4. 进阶玩法带参数装饰器和类装饰器4.1 带参数的装饰器三层嵌套结构前面写的装饰器都是直接使用不需要额外参数。但有些场景装饰器本身需要配置。比如我写过一个重试装饰器希望每次调用失败后自动重试重试几次、每次间隔多久得由使用方指定。这时候就要带参数的装饰器def retry(max_times3, delay1): def decorator(func): functools.wraps(func) def wrapper(*args, **kwargs): for attempt in range(1, max_times 1): try: return func(*args, **kwargs) except Exception as e: if attempt max_times: raise print(f第 {attempt} 次失败: {e}, {delay} 秒后重试) time.sleep(delay) return wrapper return decorator retry(max_times3, delay1) def call_api(): ...带参数装饰器为什么要有三层因为每一层负责的事情不一样。最外层retry接收重试参数它必须先根据这些参数生成一个真正的装饰器decoratordecorator接收被装饰的函数funcwrapper才是最后真正要返回给调用方的新函数它负责在调用时执行重试逻辑。调用retry(max_times3, delay1)的时候实际发生的是先执行retry(max_times3, delay1)拿到decorator再用decorator去装饰函数。如果你把它写成retry而不是retry(...)那retry收到的是函数本身而不是配置参数整个层级就乱了。这里我用max_times和delay区分了两个不同维度的“次数”重试间隔的单位我习惯用秒。4.2 类装饰器让装饰器拥有状态函数装饰器写多了你会发现有时候需要让装饰器内部维持状态。比如统计每个函数被调用了几次用函数闭包也能实现但用类写会更清晰import functools class CountCalls: def __init__(self, func): functools.wraps(func)(self) 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) CountCalls def hello(): return hello__init__方法在装饰发生时被调用接收原函数并保存__call__方法在每次真正调用时执行。被装饰后的hello其实已经变成了CountCalls类的实例只不过这个实例是可调用的。每次调用hello()Python都会触发实例的__call__count就累加一次。这种写法在处理需要长期保持状态的场景时比函数闭包更直观。比如我写缓存装饰器时就把缓存字典挂在实例属性self.cache上需要调试的时候直接打印实例属性就能看到缓存里的所有键值比闭包里的非局部变量方便得多。代码里的functools.wraps(func)(self)值得单独解释一下。它等于跳过手动装饰器的中间过程直接把原函数的元信息复制到类实例self上。这样外面的代码用__name__查看函数名时得到的是原函数的名字不会暴露CountCalls这个类的信息。类装饰器在真实代码里使用频率不如函数装饰器高但一旦遇到状态管理、参数配置、多个装饰器组合的场景它往往会比函数装饰器更好用这一点我实测下来的体会很深。4.3 内置装饰器三兄弟Python内置了三个和类方法强相关的装饰器staticmethod、classmethod、property。它们虽然是内置的但本质上和我们自定义装饰器做的事情一样都是把函数替换成另一种可调用对象。staticmethod把方法变成静态方法不接收self等同于把它当成普通函数挂在类里适合逻辑上归属于这个类、但不需要访问实例状态的工具函数。classmethod把方法变成类方法接收cls而不是self可以访问类属性最常见的用途是定义备选构造器比如Date.from_string这种写法。property把方法变成属性调用时不用加括号比如用user.is_active访问的是接口不是函数这在设计对外API时很重要。理解这个统一性是很有价值的。你会发现标准库里还有functools.lru_cache这类函数也是用装饰器思维实现的入参一样、返回值一样调用多少次都只真正计算一次。学完自定义装饰器你等于获得了一条阅读标准库高级用法的通道很多看起来神秘的库底层不过是一个又一个函数包装而已。5. 实战场景装饰器在真实项目中的应用5.1 接口耗时统计与异常日志我在后端项目里经常写一个这样的装饰器统一记录每个接口的耗时、入参和异常。假设log是项目里已经配置好的logger实例def api_logger(service_name): def decorator(func): functools.wraps(func) def wrapper(*args, **kwargs): start time.time() try: result func(*args, **kwargs) elapsed time.time() - start log.info(f[{service_name}] {func.__name__} 成功, 耗时 {elapsed:.3f}s) return result except Exception: elapsed time.time() - start log.error(f[{service_name}] {func.__name__} 异常, 耗时 {elapsed:.3f}s, exc_infoTrue) raise return wrapper return decorator这个装饰器接收一个service_name参数方便在日志里区分服务。wrapper里先记录开始时间然后调用原函数。如果调用成功就打印成功日志和耗时如果抛异常就打印异常日志和耗时并且把异常原样重新抛给上层。这个设计有几个好处。第一日志记录跟业务逻辑完全解耦函数里不用写任何日志相关代码。第二异常被重新抛出外层调用方的try/except照样能捕获行为没有被改变。第三以后想调整日志格式或者加上链路追踪的trace_id只改这一处所有被装饰的函数立刻生效。我现在的项目里几乎所有对外接口都挂了这层装饰器一年下来调整日志格式只要改一个文件。5.2 登录鉴权与权限校验Web开发中很多视图函数要求用户登录才能访问。如果把校验代码复制到每个视图函数开头代码会非常冗余。用一个require_login装饰器统一处理其中redirect_to_login(request)是伪代码实际项目按自己Web框架的写法替换def require_login(func): functools.wraps(func) def wrapper(request, *args, **kwargs): user request.get_current_user() if user is None: return redirect_to_login(request) return func(request, *args, **kwargs) return wrapper这是一个在函数调用前进行拦截的典型例子。wrapper接收request作为第一个参数先尝试从当前请求里取用户信息如果没登录直接重定向到登录页原函数根本不会执行如果已登录再调用原函数继续处理业务。用装饰器做这种前置检查比在每个视图函数里重复写if user is None清爽太多。而且因为装饰器是在wrapper阶段决定要不要放行它天然适合实现鉴权、限流、参数校验、黑名单拦截这一类“先检查后执行”的逻辑。我在团队里经常看到有人把校验逻辑写在视图函数第一行其实用一个装饰器完全能替代而且可复用性更高。5.3 缓存与重试再举两个我实际写过的场景。第一个是缓存装饰器用来避免重复计算。比如一个根据用户ID查询资料的功能同一个用户短时间内被高频查询每次都走一遍数据库很浪费。用装饰器把计算结果缓存起来key由请求参数生成第二次调用时如果key存在就直接返回缓存。第二个是重试装饰器在调用不可靠的外部接口时自动重试4.1里的retry就是一个具体实现。用这两个场景的时候有几个坎。缓存装饰器要注意过期策略没有过期时间的缓存会占着内存不放生产环境建议加上TTL缓存键要小心冲突最好把参数哈希后拼进键名避免不同参数拿到同一个结果。重试装饰器要谨慎选择重试的异常类型如果目标接口返回的是业务校验错误重试一万次也没意义通常只对网络超时、连接异常这类瞬时故障自动重试。这两个场景的代码量都不大但它们对业务稳定性的提升非常明显属于花小钱办大事的典型。5.4 装饰器的边界在哪里装饰器威力很大但它不是银弹我在项目里也吃过滥用装饰器的亏。装饰器是静态替换函数被装饰后逻辑就固定了想在运行时动态增删能力需要配合注册表或工厂函数才能实现解决起来并不轻松。而且装饰器会拉长异常堆栈一个函数挂上四五个装饰器出问题时traceback会有四五层wrapper壳排查问题的成本明显变高。我的经验是一个函数上的装饰器数量尽量控制在两三个以内如果超过三个说明这个函数的关注点可能太多应该考虑把它们合并成一个装饰器或者重新拆分函数职责。装饰器适合处理“横切关注点”但不适合把所有逻辑都往装饰器里塞。比如业务规则判断、状态机流转这类强业务逻辑放在函数内部或者独立的服务层会比装饰器清晰得多。什么时候用装饰器什么时候不用判断标准就一句话这个逻辑是不是和具体业务无关、并且要在很多函数上复用如果是装饰器如果不是老老实实写在函数里。6. 常见问题与排查技巧实录6.1 问题速查表我整理了一份装饰器相关的常见问题速查表都是我在开发或评审时反复见过的类型现象根本原因解决办法被装饰函数的__name__变成了wrapper缺少functools.wraps在wrapper上加functools.wraps(func)带参数装饰器调用时报TypeError装饰器层级写错检查是否满足三层收参数→收函数→收*args, **kwargs多个装饰器执行顺序不对混淆装饰顺序和执行顺序记住从下往上装饰从上往下执行装饰类方法时报缺少selfwrapper没接收参数wrapper写成def wrapper(*args, **kwargs)原函数返回值变成Nonewrapper里没有return调用func(*args, **kwargs)的结果要原样返回装饰器叠加后元信息混乱只有最外层保留了信息每个装饰器内部都用functools.wraps这张表看起来简单但每一个问题背后都对应一次真实的报错现场。尤其是最后一行叠加装饰器时如果某个中间层没有用wraps最终拿到的函数元信息就是乱的排查起来非常迷惑。6.2 装饰类方法时遇到的self报错有一次我写了一个装饰器用来给类方法打日志结果一运行就报missing 1 required positional argument: self。排查了半天才发现wrapper写成了def wrapper():压根没接收参数。类方法被装饰之后调用obj.method()时Python会把obj作为self传给wrapper可wrapper不接受任何参数自然报错。解决办法很简单任何可能用来装饰类方法的装饰器wrapper统一写成def wrapper(*args, **kwargs)再原样展开。这个坑很隐蔽因为平时装饰普通函数看不出来一放到类里就炸也是我把参数透传写进核心细节的原因。如果你也踩了这个坑检查一下装饰器定义处是不是把*args, **kwargs漏掉了。6.3 调试装饰器的两个小技巧调试装饰器有两个小技巧都是老手常用的。第一用functools.wraps尽量保住原函数元数据这样pdb、traceback、IDE的跳转提示都会好很多被装饰函数显示的名字和原函数一致排查问题时不会在一堆wrapper里迷失。第二如果怀疑装饰器顺序或者包装关系出了问题可以打印被装饰函数的__wrapped__属性。只要用了functools.wrapswrapper上就会自动带上__wrapped__属性它指向真正的原函数。多层的装饰器链里顺着__wrapped__一路点下去就能看到函数从里到外到底被包了几层。这个手法我习惯叫它“拆战衣”实测下来比盲改代码有效率得多。6.4 给新手的复盘建议学习装饰器最忌讳死记硬背。我的建议是按顺序手动做完这五步第一步写一个最简单的、不带的手动装饰器把work timer(work)跑通亲眼看到函数对象被替换第二步改成语法糖确认结果一样第三步加上functools.wraps对比__name__的变化第四步写带参数的装饰器走通三层嵌套第五步写一个类装饰器观察__call__的执行时机。每做完一步都打印一下__name__和调用结果。走完这条路装饰器在你脑海里就不再是玄学而是一条清晰的函数包装链路。最后分享一点个人体会。装饰器是我写了十几个之后才真正顺手的东西前几次用的时候总是在带参数装饰器的三层嵌套上卡壳每次都得翻文档。后来想通了装饰器的思维模型其实是这个样子的把一个旧函数交给一个盒子盒子负责加工最后还给你一个新函数旧函数被包在里面你调用新函数时盒子顺便做了点额外的事情。如果你现在正卡在某个装饰器报错里与其急着搜答案不如先把这个函数替换链路画出来。画懂了装饰器这关就真的过了。