我在 Python 的循环里批量造了一串函数,每个本该记住自己那一轮的编号,结果调用时它们却异口同声地全返回了最后一个数,排查半天发现闭包记住的是变量本身、而不是当时那个值的深度复盘
这是一次让我对"记住一个东西"到底记住的是"它本身"还是"它当时的样子"有了刻骨认知的事故。我有个需求:根据一个列表,批量生成一串回调函数,每个函数被调用时应该返回它对应的那个索引——第 0 个函数返回 0,第 1 个返回 1,以此类推。我很自然地在一个 for 循环里用 lambda 把它们一个个造出来,自觉逻辑天衣无缝。
可一调用就傻眼了:这一串函数,无论我调用哪一个,返回的全是同一个数——最后一个索引!第 0 个该返回 0 的,返回的是最后那个值;第 1 个该返回 1 的,也返回最后那个值。它们仿佛集体失忆,谁也没记住自己出生那一刻的编号,而是异口同声地报出了同一个数。我盯着那段看起来再正常不过的循环,百思不得其解:明明每一轮 i 都不一样,我每一轮都用当时的 i 造了个函数,它们怎么会全都变成最后一个 i?
故障现场:一串"本该各记各的"函数,集体返回最后一个值
我把出问题的代码精简还原出来,现象一目了然:
# 想造一串函数: funcs[0]() 返回 0, funcs[1]() 返回 1, ...
funcs = []
for i in range(5):
funcs.append(lambda: i) # 每轮造一个"返回 i"的函数
# 调用看看:
print(funcs[0]()) # 期望 0, 实际: 4 ✗
print(funcs[1]()) # 期望 1, 实际: 4 ✗
print(funcs[2]()) # 期望 2, 实际: 4 ✗
print([f() for f in funcs]) # 期望 [0,1,2,3,4], 实际: [4,4,4,4,4] ✗
# 它们全都返回 4 —— 也就是循环结束后 i 的最终值
看着这清一色的 4,我半天没回过神:我每一轮明明都用"当时的 i"造了一个 lambda,它们怎么会全部变成"最后的 i"?仿佛我以为自己往每个函数里塞了一个"当时的快照",可实际上它们塞进去的,是同一个会变的东西。直到我去查"Python 闭包"的机制,才明白:lambda 里的 i,捕获的根本不是"造它那一刻 i 的值",而是"i 这个变量本身";所有五个 lambda 共享同一个 i 变量;循环结束时 i 停在了 4,于是它们一起读到了 4。
第一件事:搞懂闭包"延迟绑定"——它记住的是变量,不是当时的值
冷静下来,我去把"闭包(closure)与延迟绑定(late binding)"这一课认真补了,才明白这个反直觉现象的根源:
【闭包捕获的是"变量",不是"造它那一刻的值"——这叫延迟绑定】
我的错误心智模型:
for i in range(5):
funcs.append(lambda: i)
我以为: 每轮把"当时 i 的值"(0,1,2,3,4)分别"复制"进了各自的 lambda
真实的机制(延迟绑定 / late binding):
- lambda: i 捕获的是【i 这个变量(名字)】, 不是"此刻 i 的值"
- 五个 lambda 共享【同一个】外层变量 i(它们引用的是同一个名字)
- lambda 体内的 i, 是在【调用 lambda 那一刻】才去读 i 的【当前值】,
而不是在【定义 lambda 那一刻】就把值固定下来
- 循环跑完, i 的最终值是 4; 之后无论调哪个 lambda, 读到的 i 都是 4
关键区分:
- "定义时" 绑定的是: 变量 i 这个引用(指向哪个名字)
- "调用时" 才读取: i 此刻到底是几
→ 中间 i 被循环改了 4 次, 而所有 lambda 都到最后才读, 自然全是 4
为什么 Python 这么设计:
闭包捕获变量(而非值快照)是有用的——它让闭包能看到外层变量的【后续变化】。
问题只在于: 当你在循环里造闭包、又指望它们各记各的"当时值"时,
这个"捕获变量"的语义, 就和你的期望相反了。
这一下点醒了我:我混淆了"记住一个变量"和"记住这个变量此刻的值"这两件截然不同的事。闭包记住的是变量这个"盒子",而不是定义那一刻盒子里装的"东西";盒子里的东西(i 的值)在循环中一直被改,而所有闭包都共享这同一个盒子、都等到被调用时才去看盒子里现在装的是什么。等它们被调用,循环早结束了,盒子里只剩最后一个值。我以为我给每个函数拍了张"当时的照片",其实我只是给了它们同一个"会变的实况画面"的链接。
第二件事:正解——用默认参数或工厂函数,把"当时的值"在定义时就固定下来
找到根因,正解就清晰了:既然闭包捕获的是变量、到调用时才读值,那就在定义那一刻把"当时的值"显式地固定下来——最常用的是用 lambda 的默认参数(默认参数在定义时求值),或者用一个工厂函数给每个闭包一个独立的作用域。让每个函数真正记住自己出生时的那个值。
# 错误: 所有 lambda 共享同一个 i, 调用时才读, 全是 4
funcs = [lambda: i for i in range(5)]
print([f() for f in funcs]) # [4, 4, 4, 4, 4] ✗
# 正解1: 默认参数——默认值在【定义时】求值, 把当时的 i 固定进 x
funcs = [lambda x=i: x for i in range(5)]
print([f() for f in funcs]) # [0, 1, 2, 3, 4] ✓
# ^^^ x=i 在每轮定义时就把"当时的 i"拷进了 x, 各记各的
# 正解2: 工厂函数——每次调用 make() 都有独立作用域, 捕获各自的 n
def make(n):
return lambda: n # 这个 n 是 make 的局部变量, 每次调用都独立
funcs = [make(i) for i in range(5)]
print([f() for f in funcs]) # [0, 1, 2, 3, 4] ✓
# 正解3: functools.partial 也能在定义时绑定值
from functools import partial
funcs = [partial(lambda x: x, i) for i in range(5)]
print([f() for f in funcs]) # [0, 1, 2, 3, 4] ✓
这几种解法的精髓,都是同一个思想:把"到调用时才去读那个会变的变量",改成"在定义时就把当时的值复制/绑定到一个属于这个闭包自己的地方"。默认参数 x=i 利用了"默认值在函数定义时求值且只求一次"的特性,把当时的 i 拷进了每个 lambda 私有的参数 x;工厂函数则让每次调用产生一个全新的局部变量 n,各个闭包捕获的是各自独立的 n,互不干扰。关键不是"不用闭包",而是"让闭包捕获的那个东西,是各自独立、且已固定为当时值的"。
【循环里造闭包, 安全的几条做法】
1. 默认参数固定值: lambda x=i: ... (最常用, 简洁)
利用"默认参数在定义时求值"把当时的值快照进参数
2. 工厂函数: def make(n): return lambda: n
每次调用产生独立作用域, 闭包捕获各自的 n
3. functools.partial: partial(fn, i) 在定义时绑定实参
4. 心里时刻分清: 闭包捕获的是【变量】, 值是【调用时】才读的
——只要你指望"各记各的当时值", 就必须在定义时显式固定
注意: 不只是 for + lambda, 任何"循环里创建、稍后才调用、且引用了
循环变量的可调用对象/回调"(事件处理、异步任务、按钮回调……)
都会踩这个坑。
第三件事:其他"延迟到稍后才读、于是读到的不是当时值"的同类坑
顺着"定义时捕获变量、调用时才读值"这条线,我把项目里同类的坑都排查了一遍,它们都披着不同的外衣、却是同一个内核:
第一个,循环里注册回调/事件处理器。给一批按钮在循环里绑点击回调、每个回调里用了循环变量,点的时候全用最后一个值——和 lambda 完全同源,因为回调也是稍后才执行。
第二个,循环里创建异步任务/协程。在循环里 create_task 一堆协程、协程体引用循环变量,等它们真正跑起来时,循环变量早已是最终值了。
第三个,列表推导/生成器里的延迟求值。生成器是惰性的,里面引用的外层变量也是迭代到那一刻才读;若外层变量在生成器被消费前变了,读到的是变后的值。
第四个,把"引用"误当成"快照"的普遍情形。把一个可变对象的引用存进多处,以为存的是当时的样子,结果对象后续被改,所有引用都看到了变化——本质和闭包捕获变量是同一类认知错误。
第四件事:"捕获变量"还是"快照值"——一张表理清
我把这次踩坑的核心——闭包"捕获变量、调用时读值"和我期望的"快照当时值"之间的差别,整理成一张表:
| 维度 | 闭包默认行为(延迟绑定) | 我期望的(快照值) |
|---|---|---|
| 捕获的是 | 变量(名字/引用)本身 | 定义那一刻的值 |
| 何时读到值 | 调用闭包时才读当前值 | 定义时就已固定 |
| 多个闭包之间 | 共享同一个外层变量 | 各自独立的值 |
| 外层变量后续变化 | 会被闭包看到 | 不受影响 |
| 循环里的结果 | 全读到最终值(如 4) | 各读各的(0,1,2,3,4) |
| 怎么得到期望 | — | 默认参数 x=i / 工厂函数 / partial |
这张表让我彻底想明白:闭包"捕获变量"本身不是 bug,它是一个有用的特性(让闭包能跟踪外层变量的变化);只是当我在循环里造闭包、又指望它们"各记各的当时值"时,这个默认语义恰好和我的期望相反。正解不是对抗这个语义,而是在需要快照时,用默认参数等手段显式地把"当时的值"固定下来。
第五件事:我对"循环里造函数"的几个想当然
这次事故,本质是我对"闭包捕获"抱了一个错误的心智模型。把这些想当然列出来,每一条都值得警惕:
| 我曾经的想当然 | 事故教我的真相 |
|---|---|
| "每轮用当时的 i 造的函数,就记住了当时的 i" | 它记住的是 i 这个变量,不是当时的值 |
| "lambda 里的 i 在定义时就定下来了" | i 是调用 lambda 那一刻才读的(延迟绑定) |
| "循环里每个 lambda 有各自的 i" | 它们共享同一个外层 i,循环结束都读到最终值 |
| "捕获变量就是拷贝了一份值" | 捕获的是引用/变量,不是值的快照 |
| "只有 lambda 才有这问题" | 回调、异步任务、生成器等延迟执行的都会踩 |
| "这是 Python 的 bug 或反常" | 是有意的语义;要快照得自己用默认参数等固定 |
第六件事:在循环里造可调用对象时,我现在的自检习惯
现在每当我在循环里创建闭包、回调、任务,或排查"一串函数怎么返回值全一样",我都会先按这张图问自己:
这张图的精髓,是"分清闭包捕获的是变量、值是调用时才读的;循环里要各记各的当时值,就在定义时显式固定"。写时就用默认参数 x=i 或工厂函数把当时值快照进闭包私有作用域、排查就看是不是一串延迟执行的东西共享了同一个被循环改过的变量。这套习惯,让我从"以为造它时就记住了当时值"变成了"分清记的是变量还是值、要快照就主动固定"——核心始终是:Python 闭包捕获的是变量(名字/引用)本身、而非定义那一刻的值,变量的值要到闭包被调用时才读取(延迟绑定);在循环里造的多个闭包共享同一个循环变量,稍后调用时全读到循环结束后的最终值;正解是在定义时把当时的值显式固定下来——用 lambda 默认参数 x=i(默认值定义时求值)、工厂函数给独立作用域、或 functools.partial 绑定实参。
我立下的几条规矩
这场"一串函数集体返回最后一个值"的事故,换来了我写 Python(及一切有闭包的语言)时,刻进骨子里的几条铁律:
- 闭包捕获的是"变量"(引用),不是"定义那一刻的值";值是在闭包被调用时才去读的。
- 循环里造的多个闭包,共享同一个循环变量;稍后调用时,它们会一起读到循环结束后的最终值。
- 要让每个闭包"各记各的当时值",必须在定义时显式固定:lambda 默认参数 x=i 是最简洁的办法。
- 工厂函数、functools.partial 同样能在定义时绑定值,给每个闭包独立的作用域。
- 这坑不只在 for+lambda:回调、事件处理、异步任务、生成器等一切"延迟执行又引用循环变量"的都会踩。
- 这不是 Python 的 bug,是"捕获变量"这一有用语义的副作用;别对抗它,在需要快照处主动固定。
- 时刻分清"记住一个东西"和"记住它此刻的样子":存引用 ≠ 存快照,这是一类普遍的认知陷阱。
附:一段把"延迟绑定 vs 定义时固定"对照清楚的小实验
这是我后来写的一段小实验,把"闭包共享变量、调用时才读"和"定义时固定快照"两种行为并排跑出来——它帮我把这个抽象的机制,变成了眼见为实的对比,现在我也常拿它给同事讲清这个坑:
def demo():
# A) 延迟绑定: 都引用同一个 i, 调用时才读 → 全是最终值
late = []
for i in range(3):
late.append(lambda: i)
# B) 定义时固定: 默认参数把"当时的 i"快照进 x → 各记各的
early = []
for i in range(3):
early.append(lambda x=i: x)
# C) 中途改外层变量, 看谁会被影响
val = 100
follow = lambda: val # 跟踪 val 这个变量本身
snap = lambda v=val: v # 快照 val 此刻的值
val = 999 # 改掉 val
print("延迟绑定 late :", [f() for f in late]) # [2, 2, 2] ← 全是最终值
print("定义时固定 early:", [f() for f in early]) # [0, 1, 2] ← 各记各的
print("跟踪变量 follow:", follow()) # 999 ← 看到了后续变化
print("快照值 snap :", snap()) # 100 ← 定格在改之前
demo()
这段实验把这次的教训摆得明明白白:late 那组共享同一个 i、调用时才读,所以全是最终值;early 那组用默认参数在定义时把当时的 i 拷进了私有的 x,所以各记各的;follow 跟踪的是 val 这个变量、看到了它后来被改成 999,而 snap 在定义时就把 100 定格了下来。同样是"记住 val",follow 记的是会变的本体、snap 记的是凝固的瞬间——这一字之差,就是我那一串函数集体报出最后一个值的全部秘密。把这两种行为亲手跑一遍、看清差别,比记住任何一条"要加 x=i"的口诀都管用。
这件事过后,我对自己代码里所有在循环里创建、又延迟执行的东西都做了一遍排查:循环里绑的事件回调、批量提交的异步任务、动态生成的一串处理函数……凡是体内引用了循环变量的,我都逐一确认它到底该跟踪变量还是该快照当时的值。结果真又揪出两处潜伏的同类 bug——都是平时数据恰好让最后一个值碰巧也对、于是一直没暴露而已。那一刻我深深体会到:有些坑不是不存在,只是还没等到那个让它现形的输入;与其等线上某天数据一变它突然炸出来,不如趁早把每一个延迟读取的地方,都想清楚记的究竟是本体还是瞬间。
写在最后
回头看,这场由"闭包延迟绑定"引发的"一串函数集体失忆、只记得最后一个值"事故,真正教给我的,远不止"用默认参数 x=i"这一个技巧。它让我对"当我们说'记住了某样东西'时, 我们到底记住的是'那样东西本身(它会变)', 还是'它在那一刻的样子(已凝固)'——这两者天差地别; 而我们常常以为自己存下了一张'当时的快照', 实际上只是握着一个指向'会不断变化的本体'的链接",有了一次刻骨的体会。我栽跟头,是因为我把'持有一个对会变之物的引用', 误当成了'持有它当时那一刻的、凝固的值'——我以为我在每一轮循环里, 都给那个函数'拍下了一张 i 此刻的照片', 让它永远定格;可我真正给它的, 只是一个'指向 i 这个会变的变量'的链接; 而 i 在我背后被循环一遍遍改写, 等函数终于被调用、顺着链接去看时, 看到的只剩 i 折腾完后的最终模样;我混淆了'引用一个本体'与'快照一个瞬间', 于是所有函数都通过同一个链接, 看到了同一个'最终的、而非各自当时的'i。这让我领悟到一个关于"引用与快照、本体与瞬间"的深刻认知:"持有对一个事物的引用" 和 "持有这个事物在某一刻的值/状态的拷贝", 是两件本质不同的事; 前者会随着本体的变化而变化(你看到的永远是'现在'), 后者则把某个瞬间凝固了下来(你看到的永远是'那时');当我们需要的是'那一刻的样子'、手里握的却是'会变的本体的引用'时, 就会在'稍后真正去读取'的那一刻, 惊讶地发现它早已不是当初的模样;所以凡是涉及'延迟读取'(稍后才用、异步才跑、被调用时才求值)的场景, 都要分外警惕: 我此刻'存下'的, 究竟是一个会变的引用, 还是一份凝固的快照?如果我要的是当时的样子, 就必须在'当时'主动把它拷贝、固定下来。这给了我一种看待"一切'先记下、稍后才用'之事"时的清醒:每当我把某个值/状态'记下来'、打算稍后再用时,要追问"我记下的是这个东西本身(它之后还会变), 还是它此刻的快照?等我稍后真正用到时, 它还会是现在这个样子吗?如果我要的恰恰是'现在'这个样子, 我有没有在现在就把它固定下来?"——需要跟踪变化, 就持有引用; 需要定格瞬间, 就在那个瞬间做快照, 而不是握着引用却指望它停在过去;"分清引用与快照、在需要定格时主动固定当时的值",是写对闭包、也是处理一切'延迟读取'逻辑的关键。认清闭包记的是变量而非值、延迟到调用时才读、要当时值就得在定义时固定——这,是我用一次函数集体失忆的事故,换来的、关于 Python、也关于如何区分引用与快照的、最朴素也最深刻的领悟。如果这篇复盘,能让你下次在循环里造一串稍后才调用、又引用了循环变量的函数时,先想想"它们记住的是同一个会变的变量,还是各自当时的值?",并在需要时用一个默认参数把当时的值定格下来,那我对着那一串"异口同声报出 4"的函数折腾的大半天,就值了。
—— 别看了 · 2026