ARTICLE DETAIL

资讯详情

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

Python闭包深度解析:nonlocal、cell机制与循环延迟绑定

Python闭包深度解析:nonlocal、cell机制与循环延迟绑定 1. 先把闭包的边界划清楚一段看起来不该工作的代码很多人第一次接触闭包是被一段反直觉的代码吓到的明明外层函数已经执行完毕、局部变量按理说应该随栈帧一起消失可返回出来的那个内层函数居然还能读到它。这不是玄学是 Python 的**闭包closure**在起作用。如果你写过装饰器、用过functools.wraps、做过回调注入那你其实早就在用闭包了只是可能没意识到。闭包解决的核心问题很朴素让函数带着一段私有的、外部看不见的状态到处跑。不用全局变量不用定义类不用把状态塞进参数里层层传递。你在写爬虫的时候给每个站点配一个独立的限速计数器、写数据清洗的时候给一段规则配一个可调阈值、写 Flask/Django 视图的时候给某些接口挂一个鉴权包装这些场景闭包都能顶上去而且是几十行以内的轻量方案。这篇文章不讲教科书式的定义堆砌。我会从它到底怎么跑起来的讲到工程里怎么用、哪里会翻车包括解释器层面的cell对象、nonlocal为什么必须存在、循环里闭包为什么全部指向最后一个值以及闭包和类、functools.partial到底该选谁。基础不用太好会写普通函数和for循环就能跟上有几年经验的人第 6、7 章的选择标准和排查手段应该对你更有用。1.1 从计数器讲起不用类也不用全局变量最经典的入门例子是计数器。假设你要做一个每调一次加一的函数最笨的写法是全局变量count 0 def counter(): global count count 1 return count问题显而易见count暴露在模块顶层谁都能改多个计数器会互相污染测试的时候还得手动重置。用类当然可以但对于只有一个状态、一个方法这么简单的需求写一个类有点重——你要定义__init__、写方法、self.count到处出现。闭包的写法是这样的def make_counter(): count 0 def counter(): nonlocal count count 1 return count return counter c1 make_counter() c2 make_counter() print(c1(), c1(), c1()) # 1 2 3 print(c2()) # 1完全独立关键在于c1和c2各自持有一份属于自己的count。外层函数make_counter调用结束后count这个名字按理说应该死掉但它被内层函数引用着于是被保留下来了。这个保留不是 Python 大发慈悲而是有明确的机制在托底第 2 章会拆开看。1.2 闭包的三个必要条件判断一段代码算不算闭包我一般用三个条件去卡有嵌套函数里面定义了函数所谓内层函数/嵌套函数。有引用内层函数引用了外层作用域里的变量这个变量在术语里叫自由变量free variable。有外逃外层函数把这个内层函数当作返回值或者通过其他方式传出去比如塞进列表、注册成回调。三个条件缺一个你就不是在用闭包。比如内层函数引用了外层变量但内层函数只在外面调一次就丢了那外逃不成立反过来内层函数被返回了但它只用了自己的参数和全局变量那引用不成立也就没有自由变量可捕获。这里有个特别容易搞混的点内层函数引用模块级全局变量不叫闭包。全局变量的查找走的是LOAD_GLOBAL在调用时从模块字典里现查跟闭包机制没关系。你去看这种函数的__closure__结果是None。1.3 一个反例为什么这个不叫闭包GREET hello def outer(): def inner(): return GREET # 引用的是全局变量 return inner f outer() print(f.__closure__) # None很多人会以为这里GREET被闭包捕获了其实没有。这也解释了一个实际工作中常见的困惑你在测试里用monkeypatch替换了模块级变量闭包里的值却没变。原因就在于被闭包捕获的只有外层函数的局部变量全局变量永远是运行时现查的而真正的自由变量是焊死在函数对象上的。注意区分闭包捕获和运行时查找是排查为什么改了变量值函数里没反应这类问题的第一把钥匙。2. 解释器是怎么把外层变量焊在函数上的理解了是什么接下来要回答凭什么。这一章会稍微进到 CPython 的实现层面但不会涉及 C 源码只用 Python 自己提供的自省接口就能看清楚。2.1 LEGB 与自由变量的产生时机Python 查找一个名字遵循LEGB顺序Local本函数局部→ Enclosing外层函数作用域→ Global模块级→ Builtins内置。闭包这条线正好卡在 E 这一层。但要注意名字到底属于哪一层是编译期就决定了的不是运行时。CPython 在把源码编译成字节码的时候会先做一次作用域分析如果某个名字在本函数里被赋值过包括for的目标、import的名字、def的函数名、with ... as的名字它就是本地的如果没被赋值但被外层嵌套函数赋值过它就成了自由变量否则继续往外找。这就是为什么下面这段会报错def make(): x 10 def inner(): print(x) # 这一行没问题 x 20 # 这一行让 x 变成了 inner 的局部变量 inner() make() # UnboundLocalError: cannot access local variable x where it is not associated with a value编译器看到inner里有x 20就直接把x登记为inner的局部变量了于是前面那句print(x)引用的是尚未赋值的局部x。这个坑跟闭包的关系是你以为是闭包其实赋值语句把变量降级成局部的了。2.2__closure__、cell 对象和co_freevars的对应关系每个函数对象上都有几个可以直接查看的元信息闭包的关键信息就在里面def outer(): msg hello def inner(): return msg return inner f outer() print(f.__code__.co_freevars) # (msg,) print(f.__closure__) # (cell at 0x...: str object at 0x...,) print(f.__closure__[0].cell_contents) # hello print(outer.__code__.co_cellvars) # (msg,)几个点值得记牢co_freevars是自由变量的名字元组顺序固定__closure__是对应顺序的 cell 元组。两者按下标一一对应这就是为什么你可以用f.__closure__[0].cell_contents精确取出msg的值。co_cellvars是站在外层函数视角看的我有哪些局部变量被别人捕获了。同一个变量在外层眼里是 cellvar在内层眼里是 freevar这是同一件事的两个视角。真正承载值的是cell 对象。它本质上是个带一层间接寻址的容器栈帧里存的是指向 cell 的指针变量赋值等于改 cell 里的内容。所以外层函数返回后栈帧没了但 cell 还在被函数对象的__closure__强引用着值自然不会丢。如果函数没有自由变量__closure__就是None如果__closure__是空元组说明这个函数来自 C 实现或者构造方式比较特殊。看到None和看到()含义不一样排查问题时别混。2.3 反编译看一眼dis告诉我们的事想把这件事看得更实直接反汇编import dis dis.dis(f)你会看到类似LOAD_DEREF msg的指令。这几个前缀值得记住指令含义触发场景LOAD_FAST读本函数局部变量普通局部变量LOAD_DEREF通过 cell 读自由变量闭包里的外层变量LOAD_GLOBAL运行时查模块字典全局变量、内置函数STORE_DEREF通过 cell 写自由变量nonlocal赋值、co_cellvars赋值这张表很有用。当你搞不清楚某个变量到底是被捕获还是运行时查找时看指令前缀比猜快得多。顺带说一句Python 3.12 把列表推导式做了内联优化PEP 709推导式不再单独开栈帧了所以你在dis的输出里可能发现少了一层MAKE_FUNCTION。但推导式不泄漏循环变量这个语义没变别因为看到字少了就以为行为变了最好用sys.version_info加个断言确认清楚。2.4 cell 是可以被外部改写的以及为什么别这么干既然 cell 只是个容器那它就是可写的f.__closure__[0].cell_contents world print(f()) # world这个技巧偶尔能在调试或快速原型里救急比如重置一个闭包计数器而不用重新构造。但我不建议在生产代码里用一是可读性极差别人看不懂你在干什么二是 cell 的顺序依赖co_freevars的顺序而顺序在解释器版本或代码微调之后可能变化——虽然实践中很稳定但没有契约保证。真需要从外部重置状态老老实实给闭包配一个配套的 setter或者干脆用类。提示cell_contents在某些解释器版本上对未赋值的 cell 访问会抛ValueError写调试脚本时记得加异常保护。3.nonlocal存在的意义改值和改内容完全是两回事初学者最常见的报错之一就是UnboundLocalError而且往往出现在已经写了nonlocal又删掉之后。要讲清楚nonlocal先得拆开赋值和修改内容这两个在中文里几乎同义、在 Python 里完全不同的操作。3.1 赋值绑定 vs 原地修改看两个写法# 写法 A需要 nonlocal def make_a(): n 0 def inc(): nonlocal n n 1 return n return inc # 写法 B不需要 nonlocal def make_b(): box {n: 0} def inc(): box[n] 1 return box[n] return incn 1等价于n n 1它包含一次名字绑定也就是我要给n这个新名字绑一个对象。有绑定编译器就会把n认成内层的局部变量于是你必须用nonlocal明确告诉编译器别登记成本地的去外层找。而box[n] 1全程没有给box这个名字重新绑定——它只是调用了box的__setitem__。名字box本身指向的对象没变所以它天然就是自由变量不需要nonlocal。这个区分用一句话记nonlocal管的是这个名字指向谁不管这个名字指向的东西内部怎么变。同理lst.append(x)、d.update(y)、obj.attr v全都不需要nonlocal。3.2 没有nonlocal时的三种替代写法及代价在nonlocal之前的年代以及现在一些需要跨版本兼容的代码里大家用别的手段绕。这几种写法我都用过各自有明确的代价方案写法代价可变容器box [0]用box[0] 1语义隐晦读代码的人要绕一下类型提示不友好属性挂载def f(): ...然后f.n 1状态挂在函数对象上外部可随意篡改多实例不好复用全局变量global污染模块命名空间多实例互相干扰改用类定义__call__代码量变大但对复杂状态更清晰nonlocal之后写法 A 才是首选。但要注意一个多数人踩过的坑nonlocal只能声明到最近的外层函数作用域不能跨多级也不能指到全局。下面这段会报SyntaxErrordef a(): x 1 def b(): def c(): nonlocal x # SyntaxError: no binding for nonlocal x found x 2 c() b()因为x在b里不存在nonlocal找不到最近的绑定。要么在b里也写一句nonlocal x要么把x挪到b里面。3.3 多层嵌套时到底改的是哪个搞清楚最近的外层多级嵌套就不容易出错了def outer(): x outer def mid(): x mid # 这是 mid 的局部变量跟 outer 的 x 无关 def inner(): nonlocal x # 找最近的外层也就是 mid 的 x x inner inner() return x print(mid()) # inner print(x) # outer outer()输出是inner和outer。这说明了nonlocal的精确语义它绑定的是最近一层存在该变量绑定的外层函数作用域。理解了这一点多级装饰器、多级工厂函数里的状态改写就不会写错了。3.4 一个容易忽视的细节默认参数与外层变量的求值时机顺便把默认参数也拉进来对比因为很多人用默认参数伪造闭包def make(x[]): x.append(1) return x print(make()) # [1] print(make()) # [1, 1] ← 意外共享默认参数在函数定义时求值一次之后所有调用共享同一个对象。这跟闭包每次调用外层函数生成一份新 cell的行为完全相反。所以你看到def f(xi)这种写法时要意识到它是在冻结当前值靠的是定义时求值而不是闭包机制。两者的效果可能一样原理完全不同排查问题时的思路也不一样。4. 循环里的闭包为什么全都指向最后一个值这是闭包相关被问得最多的问题也是最容易在生产代码里咬人的地方。面试题里出现的频率高得离谱但真正理解成因的人不多。4.1 延迟绑定的真实成因先看现象funcs [lambda: i for i in range(3)] print([f() for f in funcs]) # [2, 2, 2]原因一点都不神秘闭包捕获的是变量不是变量的值。range(3)三次迭代里只创建了一个名字i在推导式的函数作用域里三个 lambda 的__closure__指向的是同一个 cell。循环结束后这个 cell 里的内容是2所以你调用任意一个读到的都是2。注意这里的关键词是同一个 cell。你可以打印验证funcs [lambda: i for i in range(3)] print(funcs[0].__closure__[0] is funcs[1].__closure__[0]) # True三条 lambda 共享同一个 cell 对象这就是全部真相。所谓延迟绑定late binding说的就是值的读取发生在调用时而不是定义时。4.2 三种修复方案的取舍方案一默认参数冻结funcs [lambda ii: i for i in range(3)] print([f() for f in funcs]) # [0, 1, 2]短小、常见、性能好。缺点是ii这种写法对新手不友好而且如果被冻结的对象是可变类型比如列表后续修改会影响到所有引用者——冻结的只是引用不是内容。方案二工厂函数def make_getter(i): def getter(): return i return getter funcs [make_getter(i) for i in range(3)]这是我个人最推荐的方式。每次调用make_getter都会新建一个栈帧和一份新的 cell天然隔离语义清楚还方便加类型注解和文档字符串。方案三functools.partialfrom functools import partial funcs [partial(lambda i: i, i) for i in range(3)]如果回调本来就需要参数partial 往往比闭包更直白。它把绑定参数这件事显式化了不用凭空造一层嵌套。三种方案怎么选我的判断标准是回调只需要零参数调用且逻辑只有一行用默认参数回调逻辑超过两行或者需要类型注解用工厂函数本来就在做参数绑定且绑定的实参来自外部用 partial。4.3 同类陷阱lambda 列表、事件回调、多线程循环闭包的问题不只在推导式里出现下面这些场景一模一样回调注册给一批按钮注册点击处理最后所有按钮都用了最后一个按钮的数据。这是 GUI 编程里最常见的一类 bug。多线程/多进程提交任务循环里for url in urls: pool.submit(lambda: fetch(url))结果所有任务抓同一个地址。这个坑在爬虫并发里几乎人人踩过一次。定时器循环里注册多个延时任务参数却全变成最后一个。统一的解法就是上面三种之一。我自己的习惯是只要看到lambda出现在for或推导式里面就立刻检查它是否引用了循环变量。这个条件反射能省掉很多调试时间。注意functools.partial固定的是位置参数和关键字参数它不参与闭包机制因此不受延迟绑定影响也不会有__closure__。用它做循环内的任务提交比手写闭包少一层心理负担。5. 闭包在工程里的四个真实落点概念讲完了接下来是它到底能用在哪。这四类场景覆盖了我日常遇到的绝大多数闭包使用需求。5.1 装饰器闭包最有名的应用装饰器本质上就是接收函数、返回新函数的闭包import functools import time def timed(func): functools.wraps(func) def wrapper(*args, **kwargs): start time.perf_counter() try: return func(*args, **kwargs) finally: cost time.perf_counter() - start print(f{func.__name__} 耗时 {cost:.4f}s) return wrapper这里的func就是自由变量被wrapper的 cell 捕获。functools.wraps不可省它把原函数的__name__、__doc__、__module__、__wrapped__等属性复制到wrapper上否则日志里全是wrapper排查问题时根本不知道是哪个函数慢。带参数的装饰器是三层嵌套对应三层 celldef retry(times3, delay0.5): def decorator(func): functools.wraps(func) def wrapper(*args, **kwargs): last None for attempt in range(times): try: return func(*args, **kwargs) except Exception as exc: last exc time.sleep(delay) raise last return wrapper return decorator注意times、delay和func分别来自不同的层级wrapper的co_freevars会是(delay, func, times)这样一组名字。写这类装饰器时最大的心得是把最容易变的参数放到最外层times、delay把函数本身作为第二层参数这样retry(times5)这种用法才自然。5.2 轻量状态机与缓存需要记住上次结果的逻辑闭包比类轻得多。比如一个简单的滑动窗口限速器import time from collections import deque def rate_limiter(max_calls, window): records deque() def allow(): now time.monotonic() while records and now - records[0] window: records.popleft() if len(records) max_calls: return False records.append(now) return True return allow用deque而不是list是因为队首弹出是 O(1)窗口大时差别明显。这类实现完全不需要nonlocal因为我们只修改容器内容没有重新绑定名字。给爬虫、给第三方接口调用做限速二十行就能搞定比引一个限速库更可控。5.3 回调注入与依赖替换测试里经常需要替换某个依赖。闭包可以把依赖注入进去def make_uploader(send_func): def upload(payload): if not payload: raise ValueError(payload 为空) return send_func(payload) return upload def fake_send(payload): return {ok: True, size: len(payload)} upload make_uploader(fake_send) print(upload(abc))这种写法的好处是send_func被捕获之后后续即使你替换掉模块里的同名函数upload用的还是你传进去的那个。这一点在单元测试里既是优点也是陷阱——优点是不会被其他测试的全局替换污染陷阱是你想通过monkeypatch改行为的时候改不动。遇到改了 mock 没生效先检查是不是这个原因。5.4 和functools.partial的分工这两个东西经常被拿来互相替代但业务场景不同场景推荐原因固定几个参数逻辑不变partial一行搞定语义直白还能.func/.args反查需要内部状态、分支逻辑闭包/工厂函数partial 没法保存中间状态需要在原函数前后加行为装饰器本质是闭包但不该用 partial 硬凑需要被 pickle 序列化顶层类或顶层函数两者都不行见第 6 章关于partial有个细节值得知道它用的是 C 实现p.__closure__会报AttributeError因为它根本没有这个属性。想反查绑定内容用p.func、p.args、p.keywords。6. 闭包、类、partial该怎么选一张对比表和判断标准在同一个需求面前闭包、类、partial 经常都能实现这时候选择就变成了风格和可维护性的问题。我的判断依据基本固定。6.1 能力对比维度闭包类实例functools.partial代码量最少最多最少多实例隔离天然支持天然支持天然支持可保存中间状态支持cell支持__dict__不支持可被 pickle否局部定义时顶层类可以顶层函数可以可被多进程传递受限顶层类可以顶层函数可以可自省一般要翻__closure__强vars()、dir()中func/args类型注解友好度一般好一般C 扩展友好不涉及不涉及好可被 pickle和可被多进程传递这两行是实际项目里决定性的。闭包捕获的自由变量和函数本身都是在运行时动态造出来的pickle靠的是按限定名导入再重建找不到路径直接报AttributeError: Cant pickle local object。如果你要把任务扔给multiprocessing.Pool那么多进程那边需要能重建这个函数对象——闭包基本告别这条路除非你改用顶层类、或者用支持序列化闭包的第三方方案。我在项目里吃过一次亏单线程写好的任务分发逻辑改成多进程之后全部报序列化错误排查了半天才发现是循环里生成的闭包。教训是一旦代码有并发或多进程的可能就别用闭包做任务载体。6.2 选择时的五个判断问题我一般按顺序问自己五个问题状态有几个一个状态一个方法闭包超过三个状态或五个方法写类。需要序列化吗需要传给子进程、写进缓存、落盘那就是类顶层定义或者顶层函数。需要外部可观测吗需要打印完整状态、需要遍历属性、需要被调试工具友好展示用类。生命周期多长只在一个函数体内用几次闭包跨模块长期存在用类便于定位和替换。有多少人维护团队里新手多用类自己写脚本、追求紧凑用闭包。这五个问题走一遍选择基本就唯一了。我的实际经验是闭包适合短生命周期 内部使用 逻辑简单其他情况一律用类。反过来强调闭包而硬写类的场景也不少最后得到一堆只有__init__和一个__call__的类那不如改回闭包。6.3 一个混合方案用类管理生命周期用闭包做热路径有些场景两边都要。比如一个配置对象需要被大量调用我通常这样组织class Pipeline: def __init__(self, threshold, normalizer): self.threshold threshold self.normalizer normalizer def make_step(self): threshold self.threshold normalizer self.normalizer def step(record): value normalizer(record[value]) return value threshold return stepmake_step把属性取到局部变量再被闭包捕获有两个实际好处一是热循环里少了两层属性查找二是生成之后step的行为不受后续修改self.threshold的影响语义更稳定。代价是这两份状态会一直驻留第 7 章会讲这个。这个小模式在数据处理流水线里我用了很多次比每次调用都读self.属性要顺手。7. 排查闭包问题的实用手段与自测最后一章讲讲怎么看和怎么查。闭包最大的问题是它藏得深——函数对象上看不出什么dir()里也没有状态字段出问题时容易懵。7.1 用__closure__现场查看捕获了什么写一个通用的小工具遇到可疑函数直接打印def inspect_closure(func): freevars func.__code__.co_freevars cells func.__closure__ or () for name, cell in zip(freevars, cells): try: print(f{name} {cell.cell_contents!r}) except ValueError: print(f{name} 未赋值) inspect_closure(upload)这个函数在处理为什么我的闭包里值变了这类问题时特别有效一眼就能看出捕获的是哪几个名字、当前值是什么。如果发现两个函数的cell是同一个对象is判断为True那必然是循环变量共享的老问题。7.2 引用计数与内存驻留的观察闭包会延长对象生命周期这是它最容易被忽视的成本。__closure__持有的是强引用只要闭包活着被捕获的对象就不会被回收。import sys def make_holder(payload): def holder(): return len(payload) return holder big [0] * 1_000_000 h make_holder(big) print(sys.getrefcount(big)) # 注意这里会多算一个 getrefcount 自己的临时引用判定思路很简单如果闭包只需要对象的一个小属性就不要捕获整个大对象。比如回调里只用config[timeout]那就在外层先把timeout config[timeout]取出来再被捕获让大字典可以被回收。这个技巧在处理大 DataFrame、大字典、大缓冲区时非常实用。还有一个更隐蔽的情况两个闭包互相引用、或者闭包被注册到全局容器里形成环。Python 的循环 GC 最终会收拾但如果你在__del__里依赖及时回收行为可能不符合预期。观察办法是gc.get_objects()里按类型筛选函数对象或者直接在gc.collect()前后比较gc.garbage。7.3 几道自测题能全对说明你真的理解了我整理了几道在面试和自我检查中反复出现的题你可以先自己心算再看解析。第一题def outer(): xs [] for i in range(3): xs.append(lambda: i) return xs print([f() for f in outer()])答案[2, 2, 2]。三个 lambda 共享outer里同一个i的 cell。改法lambda ii: i。第二题def outer(): n 0 def inc(): n 1 return n return inc outer()()直接UnboundLocalError。n 1使n成为inc的局部变量必须加nonlocal n。第三题G global def outer(): G local def inner(): return G return inner print(outer()())答案local。outer里对G的赋值使它成为outer的局部变量inner引用它形成了闭包inner.__closure__不为None。这里的G和模块级G是两个完全不同的变量。第四题def make(): funcs [] for i in range(3): def f(ni): return n * 10 funcs.append(f) return funcs print([x() for x in make()])答案[0, 10, 20]。默认参数在定义时求值每次迭代都冻结了当时的i。第五题def outer(): data {n: 0} def bump(): data[n] 1 def show(): return data[n] bump() return show print(outer()())答案1。data是可变容器原地修改不需要nonlocal而且bump和show共享同一个 cell所以改动对show可见。这个模式在需要读方和写方分离时很好用。如果这五题你都能一眼判断那么闭包这一块在原理层面基本过关了。剩下的就是把它用在合适的地方——我个人的原则是能一眼看懂就用需要想两秒才能说清状态在哪就用类。这个原则帮我省下了不少代码审查的时间。最后再补一个实践里的小技巧。当你不确定某段用了闭包的代码到底是捕获值还是捕获变量时最快的验证方式不是读代码而是在可疑位置打印func.__closure__[0].cell_contents加上id()。两三次对比之后规律就刻在脑子里了以后再看到lambda出现在循环里手会自动停下来。
返回列表