ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Python装饰器从入门到实战:闭包、语法糖与坑位指南

Python装饰器从入门到实战:闭包、语法糖与坑位指南 用Python写业务逻辑的人迟早会撞上同一个需求给一堆函数统一加日志、加计时、加权限校验。我第一次认真面对装饰器是接到一个给三十几个接口统一加耗时统计的需求。当时最直接的想法就是把统计代码复制进每个函数等产品说要改阈值时我已经不想再数第几个文件改错了。被逼着研究装饰器之后我才发现这个语法本身并不复杂真正复杂的是它背后的三个基础概念一等公民、闭包、语法糖。这篇文章就从这三个概念说起把装饰器的原理、写法和实战场景完整过一遍顺带把那些网上很少被翻出来的坑也讲明白。适合正在学Python但被装饰器绕晕的人也适合写过一段时间代码、被装饰器坑过之后想彻底搞懂的人。1. 给三十几个接口统一加埋点装饰器要解决的现实问题1.1 复制粘贴不是不行但成本远比你想象的高在没有装饰器概念的时候遇到“给三十几个接口统一加耗时统计”这种需求常规操作是打开每个接口函数在开头加一行start time.time()在结尾加一行统计日志。三十几个函数全部改完手已经酸了。第二天产品说阈值从100毫秒改成200毫秒你还能硬着头皮再改一遍。到第三次要改的时候一定会漏掉某几个函数而且很难察觉。这在工程上叫横切关注点——日志、计费、权限、缓存这些逻辑横跨了所有业务函数又和每个业务函数的核心流程无关。用复制粘贴去处理横切逻辑有三个隐患逻辑分散。统计起始时间、结束时间、算差值这些代码散落在几十个文件里任何一次行为调整都要全局检索安全性全靠肉眼。侵入性强。业务代码里到处夹杂着time.time()和print核心流程被埋点干扰阅读成本急剧上升。改造成本高。一旦要从print改成logging要保证所有散落点同步更新这个过程几乎没有自动化手段。装饰器解决这个问题的思路非常直接不修改原函数本体在原函数外面再包一层。原函数依然是那个纯粹的业务函数埋点逻辑全部收纳到包装层里。产品改阈值只动包装层一处代码。1.2 装饰器本质上是“包装后替换”用手机壳来类比原函数是手机本身装饰器是手机壳。手机壳不改变手机内部电路但给手机增加了防摔、挂绳这些能力然后整个“带壳的手机”替代了原来的裸机继续使用。在Python里装饰器写在函数头上这个函数的函数名就会被重新指向“包了一层的新函数”。这种做法的优势不仅在于少写重复代码更重要的是核心函数保持纯净。业务函数始终只做自己的事测试时可以直接调用原函数验证逻辑不需要关心埋点是否存在。后面谈functools.wraps时会发现Python连“包装后看起来还像原函数”这种细节都考虑到了。2. 装饰器的三层地基一等公民、闭包和语法糖2.1 先理解“函数是一等公民”装饰器能成立依赖Python一个非常关键的语言特性函数和整数、字符串一样是普通的值对象。它可以被赋值给变量def say_hello(): return hello f say_hello print(f()) # hello可以作为参数传给另一个函数def call(func): return func() call(say_hello) # hello还可以作为返回值从函数里出来def get_func(): return say_hello如果函数不具备这个特性装饰器就无从谈起。因为装饰器的核心动作就是把一个函数对象作为参数传入再返回一个新的函数对象。不理解这一层后面看装饰器代码就会觉得“这到底在传什么”。2.2 闭包外层函数手里那本“记忆卡”装饰器还有一个隐藏支柱闭包。先看一个经典例子def make_counter(start0): count start def inc(): nonlocal count count 1 return count return inc counter make_counter() print(counter()) # 1 print(counter()) # 2make_counter已经执行完了但inc仍然能访问到count这个局部变量。这就是闭包的作用内层函数把它所引用的外层变量保存了下来形成一张“记忆卡”。即使外层函数返回了这张卡里的数据依然有效。装饰器里的wrapper函数是内层函数被装饰的func是外层函数的参数。正因为闭包的存在wrapper才能在调用时自由读取到func这个变量在合适的位置调用它。如果Python没有闭包装饰器这种写法就无从生存。2.3 只是语法糖不是魔法很多初学者把符号看得过于神秘其实它只是“装饰”过程的简写timer def work(): pass完全等价于def work(): pass work timer(work)先定义work然后把它传给timer再用返回值重新赋值给work。省掉的是最后那行赋值语句让代码更紧凑。理解这一点就掌握了阅读任何装饰器代码的钥匙看到一个decorator内心翻译成 func decorator(func) 就对了。这里有一个容易被忽略的事实装饰器的执行时机是模块导入时不是函数调用时。timer这行代码在模块加载阶段就会执行timer(work)完成函数对象的替换。这意味着装饰器本身的构造逻辑放在模块顶层是安全的但不要在装饰器外层塞太多耗资源的初始化逻辑否则会拖慢模块导入速度。这一点在后面叠加多个装饰器时会变得更明显。3. 手写第一个装饰器无参版本的完整过程3.1 最朴素的实现计时器从最简单的需求出发。给download_file这个函数加一个计时功能直接写import time def timer(func): def wrapper(): start time.perf_counter() result func() cost time.perf_counter() - start print(f[timer] {func.__name__} 耗时 {cost:.4f} 秒) return result return wrapper def download_file(): time.sleep(0.5) return file content download_file timer(download_file) download_file()这里wrapper函数就是那个“带壳的手机”它负责记录时间、调用原函数、打印耗时并把原函数的返回值原样返回。return result很关键wrapper内部必须把原函数的返回值透传出来否则装饰后函数就变成“只干活不交结果”了。3.2 一个一眼看不见的问题函数元信息丢了上面这个朴素版本能跑但它有一个隐患装饰之后download_file这个函数名指向的不再是原函数而是wrapper。打印函数名会发现print(download_file.__name__) # wrapper__name__、__doc__这些元数据全部丢失。平时没什么影响一旦你的项目用了inspect模块、ORM映射、单元测试的反射机制或者依赖__name__做路由定位就会莫名踩坑。这不是瞎担心我在实际项目里就遇到过装饰后的函数在测试框架里报告的名称全是wrapper导致定位问题多花了不少时间。解决这个问题用functools.wraps。它本身也是一个装饰器作用是把原函数的__name__、__doc__、__qualname__、参数签名等元信息复制到wrapper上。最让人安心的是它还会设置__wrapped__属性指向原函数调试时可以顺着它找到真正的业务代码import functools def timer(func): functools.wraps(func) def wrapper(): start time.perf_counter() result func() cost time.perf_counter() - start print(f[timer] {func.__name__} 耗时 {cost:.4f} 秒) return result return wrapper3.3 无参装饰器的通用模板到这里一个无参装饰器的通用框架已经清楚了import functools def decorator_name(func): functools.wraps(func) def wrapper(*args, **kwargs): # 调用前的逻辑 result func(*args, **kwargs) # 调用后的逻辑 return result return wrapper用*args, **kwargs接收所有参数是覆盖任意签名函数的通用做法。实际写业务装饰器时直接从这份模板改就行不用每次从空白文件开始。我的习惯是所有装饰器统一放在decorators.py或对应业务模块的utils下命名语义清楚比如timer、retry、require_permission这样团队成员一看模块就知道有哪些横切能力可用。4. 带参数装饰器的三层嵌套重试功能的完整实现4.1 需求升级装饰器本身需要参数无参装饰器能搞定计时、打日志这种恒定行为但很多场景装饰器需要配置。比如重试功能对接口A重试3次对接口B重试5次重试间隔还不一样。如果装饰器不能带参数就得为每种配置写一个装饰器等于又回到复制粘贴的泥潭。目标很明确希望这样使用retry(times3, delay1) def fetch_data(): ...4.2 为什么带参数装饰器要写三层这是初学者最容易翻车的地方。以retry(times3)为例它的执行过程是后面的表达式必须先被求值得到一个装饰器然后才把这个装饰器作用到fetch_data上。所以retry(times3)不是装饰器本身它是一个返回装饰器的函数。求值顺序调用retry(times3)返回一个真正的装饰器decoratorPython再把decorator应用到fetch_data上decorator(fetch_data)返回wrapperwrapper替换掉fetch_data这就是三层嵌套的根本原因外层接收参数中间层接收函数内层接收真正的调用参数。用一句话记忆参数层负责配置装图层负责接收函数执行层负责接收调用时的参数。4.3 完整实现一个可配置的重试装饰器import functools import time def retry(times3, delay0.5): def decorator(func): functools.wraps(func) def wrapper(*args, **kwargs): last_exc None for attempt in range(1, times 1): try: return func(*args, **kwargs) except Exception as exc: last_exc exc print(f[retry] {func.__name__} 第 {attempt} 次失败: {exc}) if attempt times: time.sleep(delay) raise last_exc return wrapper return decorator这里times和delay被闭包机制保存wrapper在每次调用时都能读到。raise last_exc的作用是重试次数全部耗尽后把最后一次的异常重新抛出调用方能够感知到失败而不是被静默吞掉。这点非常关键重试只解决“临时故障”如果连续几次都失败说明是稳定故障必须让上游知道。带参数装饰器也可以加日志级别、超时时间、缓存有效期写法完全一致。把三层结构理解透这些场景都只是配置内容不同而已。5. 用类写装饰器和装饰类方法两个高频场景5.1 用__call__实现类装饰器函数装饰器适合无状态场景一旦装饰器自身需要记录状态比如统计函数被调用多少次、累计耗时多少用函数装饰器就得靠外部变量或容器。这时用类装饰器更直观import functools class CountCalls: def __init__(self, func): functools.update_wrapper(self, func) self.func func self.calls 0 def __call__(self, *args, **kwargs): self.calls 1 print(f{self.func.__name__} 已调用 {self.calls} 次) return self.func(*args, **kwargs)原理是__call__让类的实例像函数一样可以被调用。CountCalls等价于func CountCalls(func)此时func变成了一个CountCalls实例。每次调用它实际上是调用了实例的__call__方法实例属性calls则自然存储了累计状态。这里有个比较少人提的细节类装饰器里要对实例本身做functools.update_wrapper(self, func)把原函数的元信息复制到实例上否则每次打印func.__name__依然是wrapper。我见过不少类装饰器写到这就断了这一行导致所有元数据丢失。5.2 装饰器用在类方法上self跑到哪里去了在类方法上挂装饰器常有人遇到一个诡异报错TypeError: wrapper() takes 1 positional argument but 2 were given。很典型的错误代码是这样def debug(func): def wrapper(a): print(f调用: {func.__name__}) return func(a) return wrapper class Demo: debug def say(self, word): print(f{word})问题出在类方法say在装饰完成后仍然是一个普通函数只有等实例访问它时Python才会自动把self传进第一个参数。wrapper只接收了一个a实际传入的是self和word两个参数自然报错。解决方案就是所有装饰器都统一用*args, **kwargs接收参数不要对参数个数做任何假设。装饰器面对的函数签名是不可控的尤其当它可能被挂在实例方法上时self会作为第一个普通参数传进来。用通配参数承接原样传给原函数才能在实例方法、类方法、静态方法上都正常工作。这也是我一直强调通用模板的意义贪图省事写死参数个数等于埋了一颗随时会响的雷。6. 实战应用缓存、日志、权限三个具体案例6.1 给外部接口调用加TTL缓存调用第三方API又慢又不稳定同一批数据短时间内反复请求纯属浪费。一个带有效期的缓存装饰器能解决问题import functools import time def ttl_cache(seconds60): def decorator(func): cache_store {} functools.wraps(func) def wrapper(*args, **kwargs): key (args, tuple(sorted(kwargs.items()))) now time.time() cached cache_store.get(key) if cached and now - cached[0] seconds: return cached[1] result func(*args, **kwargs) cache_store[key] (now, result) return result return wrapper return decorator强调三个细节。第一缓存key要包含参数不同入参意味着不同结果不能混用。第二字典查找用store.get(key)而不是if key in store再取一次值少一次哈希运算。第三过期判断用时间戳差值简单可靠。这个装饰器没有处理缓存无上限的问题生产环境建议加最大条目数或改用functools.lru_cache。6.2 自动打印入参和返回值的日志装饰器排查线上问题最痛苦的事是不知道某个函数到底被传了什么值、返回了什么结果。给关键函数挂一个自动日志装饰器import functools import logging logger logging.getLogger(func_logger) def log_call(func): functools.wraps(func) def wrapper(*args, **kwargs): args_repr [repr(a) for a in args] kwargs_repr [f{k}{v!r} for k, v in kwargs.items()] logger.info(调用 %s(%s), func.__name__, , .join(args_repr kwargs_repr)) result func(*args, **kwargs) logger.info(返回 %s: %r, func.__name__, result) return result return wrapper这个装饰器挂在业务函数上后整个调用链路的输入输出都能在日志系统里查到。两个注意事项一是不要用它打印包含密码、令牌等敏感信息的参数这类字段传入前应该脱敏二是返回值如果非常大打日志会拖慢程序最好加上长度截断。6.3 权限校验装饰器Web项目里最常见的横切逻辑就是权限校验。以前每个接口函数开头都是一段当前用户检查有了装饰器后可以把权限模型独立出来import functools def require_role(*roles): def decorator(func): functools.wraps(func) def wrapper(user, *args, **kwargs): if user.get(role) not in roles: raise PermissionError(f需要角色 {roles}当前角色 {user.get(role)}) return func(user, *args, **kwargs) return wrapper return decorator注意看这个装饰器的参数设计。它假设调用该函数的第一个位置参数是user对象检查通过后原样传给原函数。如果业务里用户信息不在第一个参数可以调整这里的取参逻辑或者改为从关键字参数里取user。这也再次说明装饰器虽然是通用模板但具体实现必须贴着真实调用约定走不能只抄个壳。6.4 其他值得自己实现的装饰器这六种场景我都在项目里实际用过简单列出来你可以按需把代码落到自己的工具库里装饰器主要用途需要留意的点单例模式确保一个类只有一个实例类装饰器比函数装饰器更合适超时控制防止外部调用卡住进程进程级超时和线程级超时差异很大锁同步多线程环境保护临界区记得用可重入锁避免嵌套死锁参数校验统一验证函数入参格式校验逻辑尽量放在原函数调用前7. 装饰器叠加与闭包陷阱最容易翻车的两处7.1 叠加多个装饰器时顺序决定了行为和性能两个装饰器叠在一起执行的先后顺序不是直觉上“从上往下”。看这个例子log_call timer def fetch_data(): ...等价于def fetch_data(): ... fetch_data timer(fetch_data) fetch_data log_call(fetch_data)装饰时自下而上先让timer包住原函数再让log_call包住timer的结果。调用时自上而下先进入log_call的wrapper再进入timer的wrapper最后才是原函数。这个顺序直接影响观测结果。如果用timer在外层log_call在内层最终打出的耗时日志会包含日志打印本身的时间反过来则计时更贴近真实业务耗时。异常处理同理外层装饰器能否捕获到内层抛出的异常完全取决于谁在包外面。实际排查时我会在脑子里把叠加的装饰器画成一个洋葱最外层的先接住调用最内层的最后执行。每次写新装饰器先想清楚它应该处于洋葱的哪一层。7.2 循环里定义函数导致的闭包延迟绑定这是闭包和装饰器结合区最容易踩的坑问题代码长这样def make_funcs(): funcs [] for i in range(3): def f(): return i funcs.append(f) return funcs for f in make_funcs(): print(f()) # 输出 2 2 2三个函数全部返回2而不是0、1、2。原因在于闭包保存的是变量i所在的引用不是某一时刻的具体值。循环结束时i 2所有内部函数读取到的都是这个最终值。修复方式有几种最简单的是把i通过默认参数绑定到具体值上def f(ii): return i在写带循环的装饰器工厂时这个坑尤其隐蔽。比如批量创建带不同重试次数的装饰器一不小心就全变成同一个配置。用默认参数绑定之后每个装饰器拿到的是属于自己的那一份快照。8. 装饰器失效与报错我的排查链路8.1 为什么函数的表现完全没变化遇到“装饰器明明写了行为却和没写一样”优先级最高的检查项是模块里是否重新赋值覆盖了。调试方法很直接打印一下目标函数的__name__和__wrapped__print(work.__name__) print(hasattr(work, __wrapped__))如果__name__还是原函数名且__wrapped__不存在大概率说明decorator没有被应用或者后来又在别处把函数重新赋值回去了。还有一种常见情况在别的模块from xx import work时导入的是装饰前的原对象因为装饰过程发生在模块加载阶段理论上不会出问题但如果模块之间有环形导入模块加载顺序异常装饰器可能还没来得及执行就被引用了。另一个值得怀疑的点装饰器内部语法使用了普通函数调用而非符号但调用返回值被覆盖错了。有些老代码习惯手动装饰work decorate # 把装饰器本身赋值给了函数名这种错误一眼看过去不明显但work的类型已经是函数装饰器了调用时大概率报类型错误。8.2 参数数量不匹配的TypeError错误信息通常是TypeError: wrapper() takes 1 positional argument but 2 were given。出现这个问题的根源几乎都是装饰器里的wrapper写了固定参数列表。排查链路看报错堆栈定位到哪个函数的调用触发了问题。检查这个函数是不是类方法如果是确认wrapper有没有接收self的位置。检查装饰器是否用在了一个带默认参数、关键字参数、可变参数的函数上而wrapper却没写*args, **kwargs。最稳妥的修法就是把所有装饰器的wrapper统一改成(*args, **kwargs)。虽然会损失一部分参数上的静态可读性但换回来的是通用性。8.3 使用inspect和签名检查来定位元信息问题如果你的项目在测试或框架层用了inspect.signature去获取函数参数装饰器没加functools.wraps会让签名变成(*args, **kwargs)极容易触发框架层的签名校验错误。排查手段推荐一个组合拳import inspect print(inspect.signature(work)) print(work.__wrapped__)用inspect.signature看当前函数暴露给外界的签名用__wrapped__回溯最原始的函数。如果你的装饰器写得正确这两个信息应该能对得上原函数。__wrapped__本身也是functools.wraps带来的好处之一没有它只能靠手工追踪代码。我把几个高频问题的症状和对应解法整理成了一个表贴代码库旁边很实用问题现象可能原因快速验证解决方案装饰后行为不变没有被应用函数重新赋值打印__wrapped__检查装饰语句和赋值顺序wrapper参数错误wrapper定死了参数个数查看堆栈和签名改用*args, **kwargs函数名变成wrapper缺functools.wraps打印__name__添加functools.wraps(func)多个装饰器顺序不对叠加顺序理解反了在wrapper里打印标记洋葱模型梳理顺序外层拿不到内层异常装饰器层级覆盖了异常捕获在内外层都写except按依赖方向调整叠加顺序9. 内置装饰器的底层逻辑property和classmethod并没有那么玄9.1 property是描述符也是装饰器property大概是每个Python开发最早接触到却又最晚搞懂的内置装饰器。它的本质是一个实现了描述符协议的类。当你在类里写class Person: def __init__(self, name): self._name name property def name(self): return self._name name.setter def name(self, value): self._name valueproperty把name方法变成了一个property描述符对象。实例访问p.name时Python会调用描述符的__get__也就是你定义的那个getter执行p.name 新名字时调用描述符的__set__。这个机制把方法调用伪装成了属性访问背后的调用链其实不复杂但理解它需要先理解“描述符”这个比装饰器更底层的概念。我的建议是不需要背下property的源码只需记住两个结论——property负责读xxx.setter负责写xxx.deleter负责删除。这三个都是装饰器只是setter和deleter是通过property对象的同名方法再次装饰而已。9.2 classmethod与staticmethod的真实差别这两个装饰器经常被混淆。一句话说明区别classmethod会把类本身作为第一个参数传给函数staticmethod什么都不会传。class Tool: classmethod def create(cls, data): return cls(data) staticmethod def helper(x): return x 1create拿到的是cls也就是调用它的类。如果是Tool.create(...)cls是Tool如果是子类SubTool.create(...)cls是SubTool。这种“跟随实际调用者”的能力使classmethod非常适合做备选构造函数。staticmethod则更像一个恰好被放在类命名空间里的普通函数不访问类也不访问实例。从装饰器的视角看这三个内置装饰器都是在描述符层面改变了函数的绑定行为而不是简单包了一层调用逻辑。理解到这个层面再遇到别人问“装饰器到底能干什么”你就可以回答除了包一层调用它还能改变函数与类之间的关系。写装饰器这件事我现在的习惯是所有装饰器集中放在少数几个专门模块里命名直白比如timing.py、auth.py、retry.py绝不让它们散落在业务代码文件夹的各个角落。给团队新人讲装饰器时我给的第一个建议永远是看到xxx就手动翻译成func xxx(func)把语法糖剥掉再去读代码。很多看似不可理解的嵌套一层层翻译完就变得非常直白。最后再分享一个排查装饰器问题的技巧遇到诡异行为先别急着改逻辑把去掉手动执行一遍func decorator(func)再打印一下函数名和签名大概率一眼就能看出是哪个环节丢了连接。
返回列表