资讯详情

浅析Python闭包如何捕获自由变量

📅 2026/10/30 2:54:41 | 华诺云谱 👁 阅读
浅析Python闭包如何捕获自由变量
前言「自由变量」free variable这个名字来自语言规范的措辞一个名字在某个代码块里被使用但不是在这个代码块里绑定的它就是自由变量。Python 语言参考「执行模型」一节给的定义正是这个意思——如果变量在某个代码块中被使用却没有在那里定义它就是自由变量。围绕这个定义有两个流传很广但都不准确的说法。第一种说闭包「捕获了变量的值」所以循环里创建的每个函数拿到的应该各不相同。事实是捕获的是变量本身而变量在循环结束后只剩最后一个绑定于是所有函数读到同一个值——这就是延迟绑定late binding。第二种说 Python 的闭包「和 JavaScript 的闭包不一样」。其实两者在「按变量捕获、运行时查找」这一点上是一致的真正的差别在写法习惯和对nonlocal的要求上。本文只聚焦一件事自由变量是在什么时候、以什么形式被捕获的。先把查找规则说清再看 CPython 用什么数据结构保存它最后用几个可执行的例子说明「什么时候读到哪一个值」。示例以 Python 3 为基准nonlocal自 Python 3.0 起可用。Python 2.7 已于 2020 年 1 月 1 日停止维护本文不使用旧写法。一、名字解析规则LEGBPython 解析一个名字时按固定顺序找Local当前函数局部→ Enclosing外层函数作用域→ Global模块全局→ Builtins内置。自由变量就是在第二步被确定下来的。# 适用于 Python 3.0x globaldef outer():x enclosingdef inner():print(x) # 命中 Enclosing打印 enclosinginner()outer()关键点是确定「这个名字属于哪一层」发生在编译期而不是每次调用时。编译函数体时编译器会扫描出哪些名字是本地的因为出现了赋值动作、哪些是自由的只被读、且在函数参数和局部赋值里都找不到并把结果写进代码对象。# 适用于 Python 3.0def outer():x 1def inner():print(x) # 只读x 是 inner 的自由变量def setter():x 2 # 有赋值x 是 setter 自己的局部变量return inner, settersetter里那行赋值不会报错也不会影响外层——它只是新建了一个属于setter的局部名字。这正是「只读不需要声明、要写必须nonlocal」这条规则的来源。二、捕获的形式cellCPython 为了防止外层函数的栈帧销毁后变量消失把被内层函数引用的局部变量从栈帧槽位搬进单元对象cell。函数对象通过__closure__属性持有这些 cell。观察点位置含义co_freevars内层函数的代码对象该函数用到的自由变量名元组co_cellvars外层函数的代码对象被内层函数引用的局部变量名元组即 cell 变量__closure__内层函数对象与co_freevars一一对应的 cell 元组cell_contents每个 cell 上该 cell 当前保存的对象# 适用于 Python 3.0def outer(a):b 10def inner(c):return a b creturn innerf outer(1)print(f.__code__.co_freevars) # (a, b)print(outer.__code__.co_cellvars) # (a, b)print(len(f.__closure__)) # 2print(f.__closure__[0].cell_contents) # 1print(f.__closure__[1].cell_contents) # 10print(f(100)) # 111三件事值得注意形参a也会被提升成 cell 变量只要它被内层函数引用。co_freevars与__closure__顺序一致按编译期扫描的顺序排列。如果函数不是闭包__closure__的值是None直接下标访问会抛TypeError。__closure__、co_freevars、cell_contents都是 CPython 的实现细节用来观察和调试很方便但不要写进业务逻辑。三、查找发生在调用时而不是捕获时这是延迟绑定的根源。cell 里保存的是一个引用内层函数每次执行到那行代码时才会去 cell 里取当前值。# 适用于 Python 3.0def make():value 1def show():return value # 每次调用 show 都会重新去 cell 里取值return showf make()print(f()) # 1问题在于make的局部变量在返回后已经无法再被赋值了——那循环变量呢循环变量恰恰是在同一个作用域里被反复重新绑定的# 适用于 Python 3.0funcs [lambda: i for i in range(3)]print([f() for f in funcs]) # [2, 2, 2]推理一遍就明白了列表推导式自带一个作用域i是这个作用域里的局部变量三个 lambda 都引用了同一个i于是i被提升为 cell 变量。推导式跑完后i的最终绑定是2三个 lambda 共享这个 cell所以三次调用都返回2。对照下面这段for语句的版本行为完全一样# 适用于 Python 3.0funcs []for i in range(3):funcs.append(lambda: i)print([f() for f in funcs]) # [2, 2, 2]要「每个函数拿到一份自己的值」就必须让每个函数拥有一个属于自己的、在捕获时就固定的绑定。最省事的办法是默认参数# 适用于 Python 3.0funcs [lambda ii: i for i in range(3)]print([f() for f in funcs]) # [0, 1, 2]默认参数和自由变量完全是两回事默认值在函数定义那一刻就被求值并存入__defaults__之后每个函数对象各存一份互不干扰。# 适用于 Python 3.0funcs [lambda ii: i for i in range(3)]print(funcs[0].__closure__) # None —— 没有自由变量了print(funcs[0].__defaults__) # (0,)从上面这两行可以直接看出「快照」是怎么实现的它压根没用闭包而是把值塞进了默认参数。第二种办法是再加一层工厂函数让每次外层调用生成一个新的 cell# 适用于 Python 3.0def bind(i):return lambda: i # i 是 bind 的局部变量每次调用 bind 都是新的 cellfuncs [bind(i) for i in range(3)]print([f() for f in funcs]) # [0, 1, 2]实战三种写法的行为对照# 适用于 Python 3.0def by_closure():共享一个 cell全部读到最终值。return [lambda: i for i in range(3)]def by_default():默认参数做快照。return [lambda ii: i for i in range(3)]def by_factory():工厂函数各建一个 cell。def bind(i):return lambda: ireturn [bind(i) for i in range(3)]print([f() for f in by_closure()]) # [2, 2, 2]print([f() for f in by_default()]) # [0, 1, 2]print([f() for f in by_factory()]) # [0, 1, 2]再看一个「读的是变量、不是值」的更隐蔽版本——变量本身被重新绑定成不同类型闭包照样跟着变# 适用于 Python 3.0def outer():data [1, 2]def show():return dataresult show()data [9, 9, 9] # 重新绑定不是原地修改return result, showbefore, show outer()print(before) # [1, 2] —— 取的是当时的对象print(show()) # [9, 9, 9] —— 再查一次就是新对象对比「原地修改」的情况# 适用于 Python 3.0def outer():data [1, 2]def show():return dataresult show()data.append(3) # 原地修改没动绑定return result, showbefore, show outer()print(before) # [1, 2, 3] —— 拿到的是同一个列表对象print(show()) # [1, 2, 3]这两段合起来说明一件事闭包通过 cell 拿到的是对象引用。绑定变了闭包看到的就是新对象对象本身被原地改动闭包看到的也是改过的样子。常见坑点1. 以为每轮循环都会捕获一份新值❌ 错误写法handlers [lambda: i for i in range(3)]print([h() for h in handlers]) # [2, 2, 2]✅ 正确写法handlers [lambda ii: i for i in range(3)]print([h() for h in handlers]) # [0, 1, 2]2. 在内层函数里给外层变量重新绑定却忘了nonlocal❌ 错误写法def counter():n 0def step():n 1 # UnboundLocalErrorreturn nreturn step✅ 正确写法def counter():n 0def step():nonlocal nn 1return nreturn step3. 以为nonlocal声明只要写了就会自动回溯赋值链❌ 错误想法以为nonlocal能把值「复制」到本层来。✅ 正确认识nonlocal建立的是指向同一个 cell 的绑定写进去的值外层也看得到def outer():n 0def step():nonlocal nn 1step()return n # 1 —— 外层拿到的是被 step 改过的值4. 自由变量与外层同名全局变量混淆❌ 错误写法count 0def make():def bump():global count # 改的是模块级 count外层没有 cell 参与count 1return bump✅ 正确写法需要每个闭包独立计数就把状态定义在外层函数里并用nonlocal。5. 依赖cell_contents修改状态❌ 错误写法f.__closure__[0].cell_contents 99 # 能改但纯属实现细节✅ 正确写法让闭包自己提供修改接口。6. 在同一个作用域里既当自由变量又当循环变量❌ 错误写法callbacks []i 0while i 3:callbacks.append(lambda: i)i 1print([c() for c in callbacks]) # [3, 3, 3]✅ 正确写法用默认参数或工厂函数隔离或者干脆别让循环变量与自由变量同名。7. 以为推导式的作用域和for语句一样会污染外层❌ 错误想法列表推导式里的i会泄漏到外层。✅ 正确认识在 Python 3 里列表推导式有自己的作用域循环变量不会往外泄漏这既是它安全的地方也是它和等价for循环行为一致、都会延迟绑定的原因。此外生成器表达式、集合与字典推导式同样有各自作用域。8. 依赖__closure__的顺序做反射❌ 错误写法假定f.__closure__[0]一定是某个特定变量。✅ 正确写法__closure__与co_freevars顺位对应但顺序属于实现细节。要按名字取值应该显式把需要的数据作为参数传入或者改用类。总结概念结论什么是自由变量在某代码块中使用、但不在该块中绑定的名字解析顺序Local → Enclosing → Global → Builtins判定时机编译期扫描赋值动作确定局部 / 自由捕获形式cell 对象外层co_cellvars、内层co_freevars与__closure__对应取值时机每次调用时从 cell 现取所以是延迟绑定要固定值默认参数lambda ii: i或加一层工厂函数要修改必须nonlocal找不到外层绑定则编译期SyntaxError把「捕获的是变量、取值在调用时」这两句话记住循环里lambda全取最后一个值就不再意外。需要快照就用默认参数把值固化进函数对象需要共享可变状态就老老实实写nonlocal。至于__closure__、co_freevars、cell_contents把它们当成学习和排查问题时的显微镜别当成 API。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑