ARTICLE DETAIL

资讯详情

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

Python闭包与装饰器从原理到实战:语法糖、变量作用域与项目应用

Python闭包与装饰器从原理到实战:语法糖、变量作用域与项目应用 不少刚接触Python的朋友跟我抱怨代码里一旦出现符号和嵌套函数就感觉脑子不够用了。闭包和装饰器确实是Python里最劝退新手的语法之一我第一次看到那三层嵌套的装饰器时心里想的是这玩意儿是给函数戴帽子吗为什么要在一个函数外面再套两个函数后来在项目里真正用上、也亲手踩过几个坑之后我才彻底明白闭包是Python作用域机制的自然产物装饰器则是闭包最经典的应用场景。这篇文章不打算讲枯燥的理论我想用一个接一个的真实需求带你从变量作用域一步步走到装饰器的完整实现再分享几个我自己在项目中用坏的、修好的实战代码。读完你不仅能看懂还能照着抄到自己的项目里。1. 先拆一个前提函数名和函数体是两回事1.1 函数名就是个变量函数体才是值想搞懂闭包得先把Python里函数的本质看透。大部分新手会把def foo():理解为定义了一个叫foo的函数这个理解没错但不够底层。实际上def语句背后做了两件事第一创建了一个函数对象也就是函数体那一堆代码第二把这个函数对象绑定到名字foo上。换句话说foo就是一个变量名它指向一个函数对象。这个概念极其重要因为它意味着函数可以像普通变量一样被传递、被赋值、被放在列表里甚至被作为另一个函数的返回值。def hello(): return Hello # 函数也是对象可以赋值给别的变量 hi hello print(hi()) # 输出 Hello # 函数对象可以被放进列表 funcs [hello, hello] print(funcs[0]()) # 输出 Hello这一个特性几乎所有动态语言都有Python只是把函数是一等公民贯彻得很彻底。今天的文章主题是闭包和装饰器它们都建立在这个前提之上——你要在心里建立这样一个认知函数名是普通变量名函数体是普通对象。想通这一点后面的一切都好办了。1.2 嵌套函数其实不是什么高深玩意既然函数名是变量、函数体是对象那么在函数里面再定义一个函数就完全顺理成章了。外层函数执行时会创建内层函数对象并把它绑定到一个局部变量上。这个特性叫嵌套函数。嵌套函数本身不神秘神秘的是它的变量访问规则。Python在函数内部查找变量时遵循一个叫LEGB的顺序局部作用域Local→ 外层函数作用域Enclosing→ 全局作用域Global→ 内置作用域Built-in。写几个简单例子你就明白了x global # 全局 def outer(): x enclosing # 外层函数的局部变量 def inner(): x local # 内层函数的局部变量 print(x) inner() outer() # 输出 local如果把inner里面的x local删掉它会去外层函数找输出enclosing如果外层也没有就去全局找。这个查找规则非常重要因为闭包就是靠内层函数能访问外层函数作用域的变量这一条规则活着的。2. 闭包到底是什么函数带着环境旅行2.1 从需求出发一个计数器为什么这么难写空谈定义没用我们从一个真实需求出发。假设你在写爬虫需要统计一共请求了多少次。最简单的做法是搞一个全局变量count 0 def request(): global count count 1 print(f第 {count} 次请求) request() request()全局变量确实能用但在项目里维护一堆全局变量是件痛苦的事别人在别处也能随意修改你的count多线程下还要担心数据竞争。如果这个计数变量只属于某个特定的功能模块能不能把它藏起来闭包给出了一个非常优雅的解法外层函数里定义一个变量内层函数引用它并返回出去。这样变量既不是全局的又能被这个函数反复使用和修改。看代码def make_counter(): count 0 # 这个变量被关在函数内部 def counter(): nonlocal count # 声明我要修改外层变量 count 1 return count return counter # 返回内层函数 c1 make_counter() print(c1()) # 1 print(c1()) # 2 c2 make_counter() print(c2()) # 1 # 独立的计数器你看count变量没有被定义为全局也没有定义在make_counter外面但每次调用counter()都能记住上一次的值。这就解决了两个问题变量作用域被限制了同时状态又被保存下来了。2.2 闭包三要素一段代码秒懂刚才那个计数器就是标准闭包。一个函数要被认定为闭包需要同时满足三个条件存在嵌套函数即一个函数定义在另一个函数内部内层函数引用了外层函数作用域里的变量注意是外层作用域的变量不是自己作用域内的局变量也不是全局变量外层函数把这个内层函数作为返回值返回把这个带着环境的函数带到外面使用。我见过很多人纠结闭包到底是什么其实你就想象成一个函数被派到外地工作时它还随身带着一个背包背包里装着它出生时见过的外层函数变量。这个背包就是环境带着环境的函数就是闭包。要确认一个函数是不是闭包Python还给你准备了工具def outer(x): def inner(): return x return inner f outer(42) print(f.__closure__) # 闭包cell对象 print(f.__closure__[0].cell_contents) # 输出 42闭包里存的变量值__closure__非空说明这个函数确实是一个闭包。这个属性在调试时特别有用你可以亲眼看到闭包里到底捕获了哪些变量。2.3 闭包保存的是变量不是快照这个点必须单独拉出来讲因为理解错了会让你在写代码时吃大亏。闭包捕获的是变量本身更准确说是变量的引用而不是变量在某一时刻的快照。意思就是外层函数里的变量如果后续被修改了内层函数拿到的也会是修改后的新值。def make(): values [] def push(x): values.append(x) # 修改的是values这个对象 return push p make() p(a) p(b) print(p.__closure__[0].cell_contents) # 输出 [a, b]这里values是个列表闭包捕获的是values这个引用。你往里append引用指向的还是同一个列表对象所以状态被持续保留下来了。这种机制既让闭包变得强大也带来了经典的循环变量陷阱后面我会专门讲那个坑。3. 装饰器闭包的第一大应用场景3.1 需求来了给函数加日志还不改代码理解了闭包装饰器其实就是水到渠成的事。先看需求项目里有好几个函数你想给它们分别加上开始执行和结束执行的日志。最粗暴的办法是往每个函数里手动加打印语句但这样改完十几个函数的代码就被污染了而且后面想统一调整日志格式还得一处一处改。更好的思路是写一个通用的日志增强器它接收任意函数返回一个包装过的、会打印日志的新函数。这不就是闭包吗外层函数接收被装饰的函数内层函数在被装饰函数执行前后加点料然后外层函数把内层函数返回出去。def log_decorator(func): def wrapper(*args, **kwargs): print(f[日志] 准备调用 {func.__name__}) result func(*args, **kwargs) print(f[日志] {func.__name__} 调用结束) return result return wrapper把这层逻辑拆开看log_decorator就是那个工厂它生产出一个新的wrapper函数这个wrapper会先打日志、再调用原函数、再打日志。原函数本身的逻辑一点没动只是被包了一层。这叫装饰器因为它装饰了原有函数。3.2 手写装饰器的完整推导用一下上面的装饰器def say_hello(): print(Hello 世界) # 手动手动装饰把原函数传给装饰器得到一个新的增强函数 say_hello log_decorator(say_hello) say_hello()运行结果如下[日志] 准备调用 say_hello Hello 世界 [日志] say_hello 调用结束注意我这一行say_hello log_decorator(say_hello)。这就是装饰器的全部秘密——装饰器不过是一个接收函数并返回新函数的普通函数。你在代码里看到的符号只是这个赋值的语法糖而已。每次看到log_decorator脑子里就自动翻译成把底下这个函数传进去再把返回值重新绑回原来的名字。用语法糖写法原来那个函数定义就变成了log_decorator def say_hello(): print(Hello 世界) say_hello()效果一模一样。以后你再见到装饰器不要慌心里默念这句传入函数、返回函数、重新赋值所有装饰器代码你都能看懂。3.3 语法糖Python替你干了什么很多人问我到底是Python的什么高级特性真相没那么神奇它就是一步函数调用加赋值。你在定义函数的上方写decoratorPython解释器会在函数定义完成之后自动执行say_hello decorator(say_hello)仅此而已。所以装饰器函数本身必须能接收一个函数作为参数并且返回一个新的可调用对象。返回的新函数通常通过def wrapper(*args, **kwargs)来定义*args和**kwargs的设计是为了让包装函数能接收并转发任意位置的参数、关键字参数这样不管原函数签名是什么样包装函数都能原样调起它。这里有个细节要注意wrapper里调用原函数时要记得return result。因为原函数可能有返回值你包装了一圈不能把人家的返回值弄丢了。我见过不少初学者在这里漏掉return结果被装饰的函数全都神秘地返回None。这是个非常隐蔽的bug排查起来能浪费一下午。4. 带参数的装饰器多包一层工厂模式4.1 从装饰器要参数到三层嵌套上面那个log_decorator不需要参数直接用就行。但真实项目里经常需要给装饰器本身传配置。比如日志装饰器我想指定日志级别重试装饰器我想指定最大重试次数权限装饰器我想指定需要的角色。这时候装饰器就需要参数了。问题来了装饰器的使用方式是decoratorPython会把被装饰函数直接传进去。如果你想写的是retry(times3)Python会先执行retry(times3)然后把返回的结果再当作装饰器应用到函数上。所以你要做的不是返回一个函数而是返回一个真正的装饰器函数。这就多包了一层def retry(max_times3): # 这一层接收装饰器参数 def decorator(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} 次失败重试中...) return wrapper return decorator retry(max_times3) def fetch_data(): # 模拟网络请求前两次失败 import random if random.random() 0.7: raise ConnectionError(连接失败) return 数据这个三层嵌套是装饰器语法里最难看懂的部分我给你一个记忆方法最外层接收配置中间层接收函数最内层接收参数并真正干活。你先看参数往哪传就能判断每一层的作用。带参数的装饰器本质上是个装饰器工厂——它根据你给的参数生产出一个定制好的装饰器。4.2 functools.wraps别丢掉函数的身份证写了几个装饰器之后你可能慢慢会发现一个问题用装饰器包装过的函数它的身份证丢了。集中体现在两点打印say_hello.__name__得到的不是say_hello而是wrapper函数的文档字符串__doc__也被包装函数覆盖了。这会给调试带来麻烦更糟的是一些依赖函数元信息的框架比如Flask的路由注册会被干扰。解决办法特别简单Python标准库已经给你备好了functools.wraps。把装饰器里的wrapper定义改成这样import functools def log_decorator(func): functools.wraps(func) # 把原函数的__name__、__doc__等属性复制到wrapper上 def wrapper(*args, **kwargs): print(f[日志] 准备调用 {func.__name__}) return func(*args, **kwargs) return wrapper加了这个装饰器之后say_hello.__name__又能正确输出say_hello了。我强烈建议只要是写装饰器一律加上functools.wraps(func)这是行业里的默认规范。它内部做的事情说白了就是把func的__name__、__doc__、__module__、__qualname__等属性复制到wrapper上让你那个包装函数在元数据上冒充原函数。5. 项目里高频使用的装饰器集合5.1 计时器性能排查随身工具装饰器最有价值的用途就是把那些和业务逻辑无关的横切关注点日志、计时、鉴权、缓存、重试彻底从业务代码里抽离出去。我先分享一个我自己最常用的计时装饰器import time import functools def timer(func): functools.wraps(func) def wrapper(*args, **kwargs): start time.perf_counter() result func(*args, **kwargs) cost time.perf_counter() - start print(f[计时] {func.__name__} 耗时 {cost:.4f} 秒) return result return wrapper timer def slow_job(): time.sleep(1.2) return 完成 slow_job()这里我用了time.perf_counter()而不是time.time()因为perf_counter是专门用来测量短时间间隔的精度更高而且不受系统时间跳变影响。这个装饰器我基本一直是常驻工具箱的分析某个接口性能瓶颈的时候往怀疑的函数上加个timer比在代码里手动插桩干净得多。5.2 重试器网络请求的保命符写爬虫或调用第三方API时网络抖动导致请求失败太常见了。与其在每次调用处都写try-except循环不如把它包装成一个重试装饰器。上面那个retry就是个可用版本我再扩展一个支持间隔等待的版本def retry(max_times3, delay0.5): 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: print(f[重试] {func.__name__} 在 {max_times} 次尝试后仍然失败) raise print(f[重试] {func.__name__} 第 {attempt} 次失败: {e}{delay}秒后重试) time.sleep(delay) return None return wrapper return decorator retry(max_times4, delay2) def request_api(url): print(f请求 {url}) # 模拟成功概率低 raise TimeoutError(超时)有一点必须提醒重试装饰器只适合幂等操作。如果请求本身会产生副作用比如下单、转账盲目重试可能导致重复扣款。我一般在设计时会让重试装饰器只捕获明显的网络异常如超时、连接重置并严格要求被装饰函数具备幂等性。这个坑是真金白银换来的教训写重试逻辑之前先问问自己这个函数被多调用一次后果是什么5.3 权限校验与缓存业务代码的标配业务系统里权限校验也是装饰器的经典场景。比如一个Web后台不同的操作需要不同的角色。用装饰器把校验逻辑抽出来后每个接口只写自己关心的业务逻辑权限代码彻底集中管理def require_role(role): def decorator(func): functools.wraps(func) def wrapper(user, *args, **kwargs): if user.get(role) ! role: raise PermissionError(f需要{role}权限当前用户无权操作) return func(user, *args, **kwargs) return wrapper return decorator require_role(admin) def delete_user(user, user_id): print(f删除用户 {user_id})缓存装饰器就更不用说了Python自带的functools.lru_cache就是一个用C实现的LRU缓存装饰器它能根据函数参数缓存结果import functools functools.lru_cache(maxsize128) def fib(n): if n 2: return n return fib(n - 1) fib(n - 2) print(fib(50)) # 秒出结果不会卡死这背后其实也是闭包在起作用——递归调用时缓存字典被闭包捕获并复用去重了大量重复计算。6. 闭包和装饰器的坑我踩过你也别踩6.1 循环里的lambda闭包延迟绑定的经典陷阱闭包最著名的坑就是循环里的延迟绑定。看这段代码funcs [] for i in range(3): funcs.append(lambda: i) for f in funcs: print(f()) # 输出 2 2 2而不是 0 1 2很多人第一次看到这个结果都会懵为什么会全部打印2原因就是我前面说的——闭包保存的是变量本身不是快照。循环结束后变量i的值停留在了2三个lambda函数持有的都是同一个i所以打印出来的全是2。解决办法有两个。第一个是用默认参数绑定当前值默认参数是在函数定义时求值的相当于做了一个快照funcs [] for i in range(3): funcs.append(lambda xi: x) for f in funcs: print(f()) # 输出 0 1 2第二个是嵌套一层函数把i作为参数传进去让闭包捕获的是参数每个参数是独立作用域而不是循环变量。这个问题在装饰器工厂里同样会碰到比如循环里给多个函数加装饰器时如果装饰器参数引用了循环变量记得用同样的默认参数技巧处理。6.2 装饰器叠加顺序从上到下装饰从下到上执行一个函数可以叠加多个装饰器但叠加顺序是有讲究的。看这个例子log_decorator timer def job(): time.sleep(0.1) print(干活中)装饰器的应用顺序是从下往上先执行timer再执行log_decorator。所以实际调用链是log_decorator(timer(job))——最外层的log先打印准备调用再进入timer开始计时timer再调用真正的job。从执行效果上看上面的装饰器先执行外层逻辑下面的装饰器更靠近原始函数。这就有个实际含义如果你想让计时更精确包含日志的耗时就得把它放在日志装饰器的上面如果你只想计业务函数本身的时间就把timer放在靠下的位置让它更接近原函数。我在早期写代码时完全没注意这个顺序结果一个接口的计时结果总是偏大排查了半天才发现是Log装饰器在外层把打印的时间也算进去了。装饰器叠加顺序直接影响行为调试时先看顺序对不对往往能节省大量时间。6.3 函数签名丢失inspect教你识破真相用装饰器包装函数还会带来一类隐蔽问题函数的签名变了。签名就是函数的参数定义信息functools.wraps能恢复__name__和__doc__但wrapper(*args, **kwargs)的签名是固定的它无法神奇地变成原函数的签名。也就是说log_decorator def add(a, b): 求两个数之和 return a b import inspect print(inspect.signature(add)) # 输出 (*args, **kwargs)不是 (a, b)在一些用反射/scaffold/IDE提示干活的项目里这个差异会带来问题。比如Flask路由里同时用到methods[GET, POST]而某些依赖Signature做参数校验的库比如pydantic拿到元组(*args, **kwargs)会认为你这个函数接受任意参数导致校验失效。这个问题没有百分百完美的解法标准做法是尽量在装饰器里遵循functools.wraps并且如果框架强依赖签名就考虑用functools.partial或干脆重写包装逻辑而不是用通用的*args, **kwargs。我在实际项目里的经验是先搞清楚这个装饰器会被哪些框架/工具读取元信息再决定要不要做特殊处理。90%的场景下functools.wraps就够用了。最后再分享一个我自己的习惯写装饰器之前先问自己三个问题——这个装饰器需不需要参数被装饰函数有没有返回值会不会有多个装饰器叠加想清楚这三个问题脚手架的草稿基本就在脑子里成形了。闭包和装饰器看再多文章都不如自己动手写一个带参数的装饰器并跑通一遍。建议你今天就拿一个现有函数练手给它加上计时和重试感受一下把横切逻辑从业务代码里剥离开的那种舒畅感。
返回列表