我在循环里批量创建了一组函数,本想让它们各自记住循环时的值,结果调用时它们全都返回了循环结束后那个最终值,我对着 Python 闭包捕获的是变量而非值这个延迟绑定的坑排查了大半天的复盘
这是一个让我对 Python "闭包到底记住了什么"彻底搞明白的坑。它的诡异之处在于:我在循环里"分明给每个函数都用了当时的循环值",可这些函数全部"失忆"了——它们记住的不是各自创建时的那个值,而是所有函数共享的、循环结束后的同一个最终值。
需求很常见:我要在一个循环里,批量创建一组函数(比如给一组配置项各生成一个处理函数),每个函数应该"记住"它对应的那个循环值。我写下了这段看起来天经地义的代码:
# 在循环里批量创建函数(有问题的版本)
funcs = []
for i in range(3):
funcs.append(lambda: i) # 每个 lambda 应该"记住"它创建时的 i (0, 1, 2)
# 调用这三个函数, 我期望分别得到 0, 1, 2
print([f() for f in funcs])
# 我的预期: [0, 1, 2]
# 实际输出: [2, 2, 2] 💥 全是 2?! 每个函数都返回了 2?!
# 更实际的场景: 循环里注册回调
handlers = {}
for name in ['a', 'b', 'c']:
handlers[name] = lambda: f"处理 {name}" # 想让每个handler记住自己的name
print(handlers['a']()) # 期望 "处理 a", 实际: "处理 c" (最后一次循环的name)!
我盯着那个 [2, 2, 2],大脑死机。我明明在循环的每一次里,都用当时的 i(0、1、2)创建了一个 lambda,这三个 lambda 应该各自记住 0、1、2 才对啊!怎么调用时全都返回了 2(循环的最后一个值)?就好像这三个函数,记住的不是"创建时的那个值",而是共享了同一个、会变的东西,而这个东西最后停在了 2。在真实业务里(循环里注册一组回调、事件处理器),这种"全都用了最后一次的值"的 bug,极其隐蔽又致命。
第一件事:看清真相——闭包捕获的是"变量"本身,不是"创建时的值"
我去深入理解了 Python 闭包的"延迟绑定(late binding)"机制,才彻底解开这个"全是 2"之谜——Python 的闭包,捕获的是变量(名字)本身,而不是"闭包创建时那个变量的值";闭包里的 i,指向的是外层那个会变的变量 i;而 i 的值,是在闭包被调用时才去查的——那时循环早已结束,i 停在了最终值 2,所以三个闭包查到的都是 2。
闭包延迟绑定的真相
# 1. 关键认知: Python 闭包捕获的是【变量(名字)】, 不是【值】!
# - lambda: i 这个闭包, 记住的是"外层那个叫 i 的变量", 而不是"创建它时 i 的值"。
# - 它没有在创建时"拍下 i 的快照", 而是"记住了去哪里找 i"。
# 2. 【延迟绑定 late binding】: 闭包里 i 的值, 是在【闭包被调用时】才去查的!
# - 不是在"定义 lambda 时"就确定的;
# - 是在"f() 真正执行时", 才去看"外层的 i 现在是多少"。
# 3. 于是 for i in range(3): funcs.append(lambda: i) 发生了什么:
# - 循环创建了3个lambda, 它们【都捕获了同一个变量 i】(同一个i!)
# - 循环结束后, i 的值停在了 2(range(3)的最后一个)
# - 之后调用任何一个 f(): 它去查"i现在是多少" → 都是 2!
# - → [2, 2, 2] (三个闭包共享同一个i, 而i此刻是2)
# 4. 为什么反直觉:
# - 我们以为 lambda: i 是"把当时的i(值)装进闭包";
# - 但实际是"闭包记住了变量i(引用), 用的时候才取值";
# - "记住变量(晚取值)" vs "记住值(早取值)"——这个差异是坑的根源。
# 5. 不只lambda, 普通def定义的内层函数、循环里的闭包都如此:
# for i in ...: def f(): return i # f也捕获变量i, 同样的坑
# 6. 注意: Python的for循环变量在循环结束后【仍然存在】且是最终值
# (不像有些语言每次迭代是新的作用域/变量)——这加剧了这个坑。
# 核心: Python闭包捕获的是变量(名字)而非创建时的值, 且延迟绑定(调用时才取变量当前值);
# 循环里创建的多个闭包共享同一个循环变量, 调用时都取到它的最终值, 这就是"全是最后一个值"的真相。
真相大白,我恍然大悟。原来 Python 的闭包,捕获的是变量(名字)本身,而不是"创建时那个变量的值"——lambda: i 记住的是"外层那个叫 i 的变量"(记住了去哪里找 i),而不是"创建它时 i 的值"(没有拍快照)。而延迟绑定意味着:闭包里 i 的值,是在闭包被调用时才去查的,不是定义 lambda 时就确定的。于是 for i in range(3): funcs.append(lambda: i):三个 lambda 都捕获了同一个变量 i;循环结束后 i 停在 2;之后调用任何一个 f(),它去查"i 现在是多少"——都是 2,所以 [2, 2, 2]。之所以反直觉:我们以为 lambda: i 是"把当时的 i(值)装进闭包",实际是"闭包记住了变量 i(引用),用的时候才取值"——"记住变量(晚取值)" vs "记住值(早取值)"是坑的根源。这不只发生在 lambda,普通 def 定义的内层函数也一样;而且 Python 的 for 循环变量在循环结束后仍然存在且是最终值(不像有些语言每次迭代是新作用域),这加剧了这个坑。
第二件事:正解——用默认参数当场捕获值,或工厂函数/partial
搞懂了原理,正解就清晰了:要让闭包"记住"创建时的值(而非变量),就用默认参数在定义时当场把值绑定进去,或用工厂函数/functools.partial。
# ====== 正解一(最常用): 默认参数当场捕获值 ======
funcs = []
for i in range(3):
funcs.append(lambda i=i: i) # ★ i=i: 把【当时的i的值】作为默认参数, 当场绑定!
print([f() for f in funcs]) # [0, 1, 2] ✓ 正确!
# 原理: 默认参数的值是在【定义函数时】求值的(就是当时的i), 它给每个lambda
# 绑定了一个独立的、固定的默认值, 不再依赖外层那个会变的i。
# 回调场景同理:
handlers = {}
for name in ['a', 'b', 'c']:
handlers[name] = lambda name=name: f"处理 {name}" # name=name 当场捕获
print(handlers['a']()) # "处理 a" ✓
# ====== 正解二: 用工厂函数, 每次调用产生独立作用域 ======
def make_func(x): # x 是这次调用独立的局部变量
return lambda: x # 闭包捕获的是这个独立的 x
funcs = [make_func(i) for i in range(3)]
print([f() for f in funcs]) # [0, 1, 2] ✓
# 原理: 每次调用 make_func(i) 都创建一个【新的、独立的】x, 各闭包捕获各自的x
# ====== 正解三: functools.partial 绑定参数 ======
from functools import partial
def handler(x): return x
funcs = [partial(handler, i) for i in range(3)] # partial 把 i 绑死
print([f() for f in funcs]) # [0, 1, 2] ✓
# ====== 区分: 什么时候不会踩这个坑 ======
# - 如果循环里【立即调用】闭包(不是存起来later调用), 那时i就是当前值, 没问题:
# for i in range(3): print((lambda: i)()) # 0,1,2 (立即调用, 取的就是当时的i)
# - 坑只在"创建闭包存起来、循环结束后再调用"时暴露。
# 核心: 要闭包记住"创建时的值"而非"会变的变量", 用默认参数 lambda x=x:(定义时求值, 当场绑定),
# 或工厂函数(每次产生独立作用域)/partial; 别在循环里创建闭包后延迟调用却依赖循环变量。
修复的核心,是"用默认参数在定义时当场把值绑定进去"。正解一(最常用):默认参数当场捕获值——lambda i=i: i,把当时 i 的值作为默认参数当场绑定;原理是默认参数的值在定义函数时就求值(就是当时的 i),给每个 lambda 绑定了一个独立、固定的默认值,不再依赖外层那个会变的 i。(回调场景同理 lambda name=name: ...。)正解二:工厂函数——make_func(i) 每次调用都创建一个新的、独立的局部变量 x,各闭包捕获各自的 x。正解三:functools.partial——partial(handler, i) 把 i 绑死。还要区分:如果循环里立即调用闭包(不是存起来 later 调用),那时 i 就是当前值、没问题;坑只在"创建闭包存起来、循环结束后再调用"时暴露。归根结底:要闭包记住"创建时的值"而非"会变的变量",用默认参数 lambda x=x(定义时求值、当场绑定)或工厂函数/partial;别在循环里创建闭包后延迟调用却依赖循环变量。
第三件事:闭包 / 作用域相关的其他常见坑
排查后我把 Python 闭包和作用域相关的其他常见坑也系统梳理了一遍。
闭包 / 作用域的其他常见坑
# 1. 循环里闭包延迟绑定(本文): 全取最终值。→ 默认参数 lambda x=x。
# 2. 闭包里想改外层变量, 没写 nonlocal: 会被当成新建局部变量
# def outer(): n=0; def inner(): n+=1 # ✗ UnboundLocalError! 要 nonlocal n。
# 3. 改全局变量没写 global: 同理, 函数里赋值全局变量要先 global x。
# 4. 循环变量泄漏: for i in ...: 循环后 i 还在(是最终值), 可能被误用。
# 5. 可变默认参数(另一个经典坑): def f(x, lst=[]) 共享同一个list(见专文)。
# 6. 列表推导的变量作用域: Python3里推导式有自己的作用域(不泄漏),
# 但和它内部的闭包结合时仍要注意延迟绑定。
# 7. 把循环变量传给线程/异步任务: 同样的延迟绑定坑, 任务执行时i已是最终值。
# → 同样用默认参数/partial当场绑定。
# 共同根源: Python的变量是"名字到对象的绑定", 闭包捕获的是"名字"(会随时间变),
# 而非"某一刻的值"; 加上作用域规则(LEGB、循环变量不新建作用域), 交织出这些坑。
# 核心: 理解闭包捕获变量(名字)非值、延迟绑定; 改外层变量用nonlocal/global; 循环里建闭包
# 延迟调用要当场绑定值(默认参数/partial); 传给线程/异步任务同理。
排查让我把闭包/作用域的其他坑也梳理清了。一、循环里闭包延迟绑定(本文)。二、闭包里改外层变量没写 nonlocal(被当成新建局部变量、UnboundLocalError)。三、改全局变量没写 global。四、循环变量泄漏(循环后 i 还在是最终值)。五、可变默认参数(另一个经典坑)。六、列表推导的变量作用域(Python3 推导式有自己作用域)。七、把循环变量传给线程/异步任务(同样的延迟绑定坑,任务执行时 i 已是最终值,同样用默认参数/partial)。它们的共同根源是:Python 的变量是"名字到对象的绑定",闭包捕获的是"名字"(会随时间变)而非"某一刻的值";加上作用域规则(LEGB、循环变量不新建作用域),交织出这些坑。核心是:理解闭包捕获变量非值、延迟绑定;改外层变量用 nonlocal/global;循环里建闭包延迟调用要当场绑定值(默认参数/partial)。下面这张图,是这次闭包全取最终值的成因与解法:
第四件事:捕获"变量"vs捕获"值"对照表
这次踩坑后,我把几种写法"捕获的是变量还是值"整理成一张表。
| 写法 | 捕获的是 | 循环里延迟调用结果 |
|---|---|---|
| lambda: i | 变量 i(延迟取值) | ✗ 全是最终值 |
| lambda i=i: i | 值(定义时绑定) | ✓ 各自的值 |
| make_func(i) 工厂 | 独立作用域的值 | ✓ 各自的值 |
| partial(f, i) | 值(绑定的参数) | ✓ 各自的值 |
| def f(): return i (循环里) | 变量 i(延迟取值) | ✗ 全是最终值 |
| 立即调用 (lambda: i)() | 当前的 i | ✓ 当时的值(没延迟) |
这张表把"捕获变量还是值"钉清了。核心规律是:默认 lambda: i 和内层 def 捕获的是变量(延迟取值,循环里延迟调用就全是最终值);而默认参数 i=i、工厂函数、partial 捕获的是定义时的值(各自独立、正确);立即调用则没有延迟问题。它给我的最大启发是:"捕获变量(引用/名字,晚求值)"和"捕获值(快照,早求值)",是闭包行为的一个根本分野;而很多语言/场景里,默认的捕获方式("捕获变量、延迟求值")恰恰和我们朴素的直觉("应该捕获当时的值")相反——这正是这类坑反复出现的原因。这其实是一个跨语言的普遍主题:"什么时候求值(eager早求值 vs lazy晚求值)",是编程里一个极其重要、又极易被忽视的维度;同一段代码,"定义时就求值"和"用到时才求值",行为可能天差地别(本文的闭包、LINQ 延迟执行、Python 默认参数、各种惰性求值)。这让我养成一个习惯:当我写一个"会被延迟使用"的东西(闭包、回调、惰性表达式)、且它引用了外部的变量时,我会特意停下来问:"这个外部变量的值,是在我现在定义时确定的,还是在它将来被使用时才确定的?到那时,这个变量会不会已经变了?"。分清"捕获变量(晚求值)"与"捕获值(早求值)"、警惕求值时机——是避开这类闭包坑(乃至一切惰性求值坑)的关键。
第五件事:这个坑为什么跨语言反复出现
这个"循环里闭包"的坑不止 Python 有,我梳理了它在各语言的情况。
| 语言 | 情况 |
|---|---|
| Python | 有此坑, 循环变量不新建作用域。→ 默认参数 |
| JavaScript (var) | 有此坑(var函数作用域)。→ 用 let(块作用域, 每次迭代新绑定) |
| JavaScript (let) | ✓ 无此坑, let每次迭代是新变量 |
| C# (foreach) | 旧版有此坑, C#5+修复了foreach |
| Go (pre-1.22) | 有此坑, 循环变量复用。→ 1.22起每次迭代新变量 |
| Java | 要求捕获的变量effectively final, 一定程度避免了 |
这张表显示这是个"跨语言的经典坑"。核心是:"循环里创建闭包捕获循环变量"这个坑,在很多语言里都曾经/依然存在(Python、旧版 JS 的 var、旧版 C#、旧版 Go);它的根源都是同一个——"循环变量在多次迭代间被复用(是同一个变量),而闭包捕获了这个被复用的变量";有意思的是,很多语言后来都"修复"了它(JS 的 let、C#5、Go 1.22 让每次迭代成为一个新变量)。它给我的深刻启发是:一个坑能在如此多的语言里反复出现,说明它触及的是一个深层的、本质的概念难点(变量、作用域、闭包、求值时机的交互),而不是某门语言的偶然失误;这类"跨语言的经典坑",最值得花时间彻底理解透——因为理解了它,你就同时理解了很多语言的一个共性,以后无论用哪门语言都不会再栽。它还揭示了语言演进的一个有趣规律:很多语言后来对这个坑的"修复"(让循环变量每次迭代都是新的),说明"符合开发者直觉"本身就是一种重要的语言设计价值;一个总是违背直觉、反复坑人的行为,即使有其"道理",也会被社区视为需要改进的设计。重视那些"跨语言反复出现的经典坑"、把它们背后的本质概念彻底吃透——是高效学习多门语言、举一反三的捷径。
第六件事:在循环里创建闭包时,我现在的判断习惯
现在每当我在循环里创建函数/闭包/回调,我都会按这张图先想清楚:
这张图的精髓,是"循环里建闭包、引用了循环变量、又存起来延迟调用,就必须当场捕获值"。闭包没引用循环变量就安全;引用了再看是立即调用(安全)还是存起来 later 调用(危险);延迟调用就必须用默认参数 lambda x=x、工厂函数或 partial 当场把值捕获进去。这套习惯,让我循环里建闭包时,从"随手 lambda 引用循环变量"变成了"先想这闭包啥时候调用、要不要当场绑值"——核心始终是:闭包捕获变量非值且延迟绑定,循环里延迟调用的闭包要用默认参数当场绑定值。
我立下的几条规矩
这场"闭包全取最终值"的事故,换来了我写 Python 时,刻进骨子里的几条铁律:
- 闭包捕获的是变量(名字),不是值。记住的是去哪找,不是当时的快照。
- 延迟绑定:调用时才取变量当前值。不是定义时确定的。
- 循环里建闭包延迟调用,会全取最终值。因为共享同一个循环变量。
- 用默认参数 lambda x=x 当场捕获值。默认参数定义时就求值。
- 或用工厂函数/partial。每次产生独立作用域/绑死参数。
- 改外层变量用 nonlocal,改全局用 global。否则被当成新建局部变量。
- 这是跨语言经典坑。理解透了,JS/Go/C# 同类坑都不怕。
附:一段亲眼看清闭包延迟绑定的实验
口说无凭。下面这段代码,把"捕获变量(延迟绑定)"和"捕获值(当场绑定)"并排对比,让你亲眼看到差别:
print("=== 1. 错误: lambda: i 捕获变量, 延迟绑定 ===")
bad = [lambda: i for i in range(3)]
print([f() for f in bad]) # [2, 2, 2] ← 全是最终值!
# 证明它们捕获的是同一个变量: 循环后改 i, 闭包跟着变
funcs = []
for i in range(3):
funcs.append(lambda: i)
print([f() for f in funcs]) # [2, 2, 2]
i = 99 # ★ 循环后再改 i
print([f() for f in funcs]) # [99, 99, 99] ← 闭包跟着变了! 铁证它们捕获的是变量i
print("\n=== 2. 正确: lambda i=i 捕获值, 当场绑定 ===")
good = [lambda i=i: i for i in range(3)]
print([f() for f in good]) # [0, 1, 2] ✓ 各自记住了创建时的值
print("\n=== 3. 用闭包的 __closure__ / 默认值看它们记住了啥 ===")
f_bad = (lambda: i) # 这个i是模块级的i(此刻99)
print("bad闭包调用:", f_bad()) # 99(取变量i的当前值)
f_good = (lambda i=5: i)
print("good闭包的默认值:", f_good.__defaults__) # (5,) ← 值5被存进了默认值, 固定了
print("\n=== 4. 异步/线程场景同样的坑(示意) ===")
# import threading
# for i in range(3):
# threading.Thread(target=lambda: print(i)).start() # ✗ 可能全打印2
# threading.Thread(target=lambda i=i: print(i)).start() # ✓ 各自的值
# 核心: 跑一遍, 亲眼看到 lambda:i 全取最终值、且循环后改i闭包跟着变(证明捕获的是变量)、
# 而 lambda i=i 各记各的值(值被存进默认值固定了)——闭包延迟绑定的真相一次看清。
这段实验代码,是我这次踩坑后写下的"闭包行为显形器"。它最有力的设计,是第 1 部分的那个"循环后再改 i":你会亲眼看到,那组 lambda: i 闭包,在我把 i 改成 99 之后,调用结果也跟着变成了 [99, 99, 99]——这就是"它们捕获的是变量 i(而非某个值快照)"的铁证:它们的结果会随着那个变量的变化而变化,因为它们记住的就是"去查 i"这件事。而对比之下,lambda i=i 那组,无论外面 i 怎么变,都稳稳返回各自创建时的值——因为那个值已经被存进了函数的默认值(__defaults__ 里看得到)、固定下来了。这正是我想用这段代码,留给每个学闭包的人的核心方法:要验证"一个东西到底捕获/记住的是变量还是值",有一个绝佳的实验——在它创建之后、使用之前,去改变那个源变量,然后看它的行为变不变:如果它跟着变了,说明它记住的是变量(引用);如果它纹丝不动,说明它记住的是值(快照)。这个"改变源、观察果"的实验思路,适用范围远不止闭包:判断两个东西是不是"共享/引用同一个对象"(改一个看另一个变不变)、判断一个值是"拷贝还是引用"、判断缓存有没有生效——很多关于"引用 vs 值、共享 vs 独立"的问题,都能用这个"改变其中一方、观察另一方是否联动"的实验,简单而确凿地验证。用"改变源变量、观察闭包是否联动"的实验来确证它捕获的是变量还是值——这份"用扰动来探测关系"的实验思路,是我搞清一切"引用/共享/捕获"类问题最简单可靠的法门。
写在最后
回头看,这场由"闭包延迟绑定"引发的、函数全取最终值的事故,真正教给我的,远不止"用默认参数 lambda x=x"这一个技巧。它让我对"变量到底是什么",以及"'记住一个东西'到底记住了什么",有了一次清醒的认识。我栽跟头,根源是我对"变量"有一个朴素但不精确的理解:我以为"变量 i"就等于"i 当前的那个值",所以 lambda: i 就该是"把这个值装进去"。可在 Python(及很多语言)里,"变量"不是"值"本身,而是"一个指向值的名字/标签";同一个名字 i,在不同时刻可以指向不同的值。而我的闭包,记住的是这个"名字(标签)",不是它某一刻指向的值——所以当这个名字后来指向了别的值(循环结束后的 2),闭包"看到"的自然也变了。我混淆了"记住一个名字(以后去查它指向谁)"和"记住一个值(此刻它指向谁的快照)"这两件本质不同的事。这让我领悟到一个深刻的认知:在编程里,"引用一个变量"和"捕获一个值",是两件需要严格区分的事——前者是"记住一个会变的'地址/名字',用时再去取"(晚绑定),后者是"记住一个'不变的快照'"(早绑定);当一个"引用"被延迟使用、而它指向的东西在这期间变了时,你拿到的就不是你当初"以为"的那个值了。这其实贯穿了很多概念:闭包捕获变量、指针/引用、惰性求值、异步回调里的变量……它们的坑,本质都是"我以为记住的是当时的值,其实记住的是一个会变的引用,而它在我用它之前变了";分清"引用(晚取值)"和"值(早取值/快照)",并时刻警惕"我引用的东西,在我用它的时候,还是我以为的样子吗",是写对这类代码的根本。看清"变量是会变的名字、闭包记住的是名字而非值"、严格区分引用与值/早绑定与晚绑定——这,是我用一次闭包延迟绑定的事故,换来的、关于 Python、也关于一切语言中"变量、引用、求值时机"的、最朴素也最深刻的领悟。如果这篇复盘,能让你下次在循环里写下一个会被存起来稍后调用、又引用了循环变量的 lambda 时,顺手补上 x=x,那我对着那个 [2, 2, 2] 抓耳挠腮的这大半天,就值了。
—— 别看了 · 2026