我在 Python 的循环里批量创建了一串函数,本以为每个都记着各自的循环值,结果调用时它们竟然全都返回了同一个最后的值,我排查了大半天的复盘
这是一个让我对 Python 闭包"看清真面目"的故事。我有个需求:根据一个列表,批量生成一串函数,每个函数,都应该"记住"它被创建时对应的那个值。我顺手写了个循环,在里面用 lambda 创建了这些函数,装进一个列表。逻辑看着天经地义。可当我挨个调用这些函数时,诡异的结果出现了:我本以为它们会分别返回 0, 1, 2,结果它们全都返回了 2——也就是循环的最后一个值!每一个函数,都"失忆"了,忘了自己当初对应的值,异口同声地报出了同一个数。
我当时百思不得其解:我明明是在每一轮循环里,用当时的 i 创建的函数啊,为什么它们记住的,不是各自创建时的 i,而是循环结束后那个最终的 i?我顺着这个反直觉的现象深挖,才终于揭开真相,补上了我对 Python 闭包一个最根本的认知漏洞:问题的核心,是 Python 的闭包,采用的是"延迟绑定(late binding)"。我一直想当然地以为,lambda: i 这个闭包,会在创建的那一刻,就"拍下" i 当时的值(比如 0)、把这个值"装进"自己肚子里;可真相是:闭包记住的,根本不是"创建时 i 的值",而是"i 这个变量本身"(对它的引用);它不在创建时取值,而是在被调用的那一刻,才去查看 i 当前的值。而我的那些闭包,全都引用着同一个循环变量 i;当循环结束时,i 已经停在了它的最后一个值 2 上;所以,我之后无论调用哪个闭包,它们去查看 i 时,看到的都是那个最终值 2——于是,它们众口一词地,全返回了 2。我这才痛彻地明白:Python 的闭包,捕获的是变量,而非值;它读取该变量的时机,是在调用时,而非定义时。这个"延迟绑定"的特性,本身并无对错,但它极其违反"闭包应该记住创建时的快照"这一普遍直觉;一旦在循环中批量创建闭包,就会掉进这个"所有闭包共享同一个循环变量、最终都只看到它的末值"的经典陷阱里。理解一门语言的闭包,到底捕获的是"值"还是"变量"、到底在"何时"求值,是绕不开的一道坎。
故障现场:循环里创建的闭包,全都共享同一个循环变量
我把这个"全返回 2"的现场,用代码摊开给你看:
# ✗ 灾难: 循环里创建闭包, 它们共享同一个循环变量 i
funcs = []
for i in range(3):
funcs.append(lambda: i) # ✗ 闭包捕获的是"变量 i", 不是"此刻 i 的值"
# 本以为: funcs[0]()=0, funcs[1]()=1, funcs[2]()=2
# 实际:
print(funcs[0]()) # ✗ 2
print(funcs[1]()) # ✗ 2
print(funcs[2]()) # ✗ 2 —— 全都是 2!
# 为什么全是 2?
# - Python 闭包是"延迟绑定(late binding)":
# 闭包记住的是"变量 i 本身"(引用), 不是创建时 i 的值。
# - 闭包"取 i 的值"发生在"被调用时", 而不是"被定义时"。
# - 三个 lambda 都引用同一个 i; 循环结束后 i 停在 2。
# - 所以之后调用任何一个, 去查 i, 看到的都是 2。
# 同样的坑也出现在 def 定义的函数、列表推导(旧 Python2)、回调注册等场景:
handlers = []
for evt in ["click", "hover", "drag"]:
handlers.append(lambda: print(evt)) # ✗ 全部会打印 "drag"
# 验证一下"它读的是变量当前值":
i = 100
print(funcs[0]()) # ✗ 100! 改了 i, 闭包跟着变 —— 证明它读的是变量, 不是快照
# 根因: Python 闭包延迟绑定, 捕获变量而非值, 调用时才求值;
# 循环里批量创建的闭包共享同一循环变量, 最终都只看到它的末值。
看着这段代码,我才算彻底想明白了这场"全返回 2"的根源。问题的核心,是 Python 闭包的"延迟绑定(late binding)":lambda: i 捕获的,是"变量 i 本身"(引用),而不是"创建时 i 的值";它"取 i 的值"这个动作,发生在"被调用时",而不是"被定义时"。于是:三个 lambda 都引用同一个 i;循环结束后,i 停在了 2;此后调用任何一个,它去查 i,看到的都是 2。我还做了个验证:循环后手动把 i 改成 100,再调用 funcs[0](),它竟然返回 100!——这铁证如山地说明,闭包读的是变量的当前值,而不是某个时刻的快照。这个坑,不只出现在 lambda:def 定义的函数、事件回调的批量注册(for evt in ... : ...lambda: print(evt) 全打印 "drag"),都会中招。归根结底:Python 闭包延迟绑定,捕获变量而非值、调用时才求值;循环里批量创建的闭包共享同一个循环变量,最终都只看到它的末值——这,就是根源。
第一件事:搞懂闭包捕获的是"变量"还是"值"
定位到根源,我必须把"闭包到底捕获了什么、何时求值"这件事,从根上彻底搞清楚:
Python 闭包: 捕获"变量"(引用), 调用时才求值(延迟绑定)
# 什么是闭包?
# - 一个函数, 引用了它"外层作用域"里的变量, 就形成了闭包。
# - 闭包让内层函数"记住"了外层的变量。
# 关键: 它记住的是"变量", 不是"值"!
# - 闭包持有的是对外层变量的"引用"(绑定到那个变量名)。
# - 求值时机 = "调用闭包时", 那时才去读变量的"当前值"。
# - 这叫"延迟绑定 / late binding"。
# 所以循环里的坑:
# - for i in range(3): funcs.append(lambda: i)
# - 三个 lambda 共享同一个变量 i(循环变量在循环结束后依然存在且=末值)。
# - 调用时都读 i 的当前值 → 全是 2。
# 反直觉点:
# - 直觉以为"创建时就把值拍进闭包" → 其实是"调用时才去看变量"。
# - 很多语言(JS 的 var 也有类似坑, let 才修复)都有这个历史包袱。
# 怎么判断会不会中招?
# - 在"循环/会变化的作用域"里创建闭包, 且闭包引用了那个会变的变量 → 危险!
# - 如果闭包立刻就被调用(不留到以后) → 不会中招(那时变量值还对)。
# 关键认知: 闭包捕获变量、调用时求值。
# - 要"冻结"创建时的值, 必须主动把"值"在创建时就"拷贝/绑定"进去。
# 核心: Python 闭包捕获的是变量(引用)、在调用时才求值(延迟绑定);
# 循环里共享循环变量的闭包, 最终都读到末值, 要冻结值需在创建时主动绑定。
原理终于清晰了。什么是闭包?——一个函数,引用了它"外层作用域"里的变量,就形成了闭包,它让内层函数"记住"了外层的变量。而关键在于:它记住的是"变量",不是"值"!——闭包持有的,是对外层变量的"引用"(绑定到那个变量名);求值的时机,是"调用闭包时",那时才去读变量的当前值,这就叫"延迟绑定 / late binding"。所以循环里的坑就清楚了:三个 lambda 共享同一个变量 i(循环变量在循环结束后依然存在、且等于末值),调用时都读 i 的当前值,全是 2。它反直觉的点在于:直觉以为"创建时就把值拍进闭包",其实是"调用时才去看变量"(很多语言都有这个历史包袱,比如 JS 的 var 也是,直到 let 才修复)。怎么判断会不会中招?在"循环/会变化的作用域"里创建闭包、且闭包引用了那个会变的变量,就危险;但如果闭包立刻就被调用(不留到以后),就不会中招(那时变量值还对)。由此,我刻下一个关键认知:闭包捕获变量、调用时求值;要"冻结"创建时的值,必须主动把"值"在创建时就拷贝/绑定进去。归根结底:Python 闭包捕获的是变量(引用)、在调用时才求值(延迟绑定);循环里共享循环变量的闭包,最终都读到末值,要冻结值需在创建时主动绑定。
第二件事:正解——在创建时就把值"冻结"进去
搞懂了原理,正解就清晰了:既然闭包捕获的是变量,那就在创建闭包的那一刻,主动把"当时的值"绑定进去,让每个闭包拥有自己的、独立的那个值。
# ✓ 正解一: 用默认参数, 在创建时就"求值并绑定"(最常用!)
funcs = []
for i in range(3):
funcs.append(lambda i=i: i) # ✓ 默认参数 i=i: 创建时就把当前 i 的值绑给参数 i
# 默认参数的值在"函数定义时"就被求值并固定下来 → 每个闭包有自己独立的 i
print(funcs[0](), funcs[1](), funcs[2]()) # ✓ 0 1 2
# ✓ 正解二: 用工厂函数, 每次调用产生一个新作用域
def make_func(x):
return lambda: x # x 是这次调用独有的局部变量, 互不干扰
funcs = [make_func(i) for i in range(3)]
print(funcs[0](), funcs[1](), funcs[2]()) # ✓ 0 1 2
# ✓ 正解三: functools.partial, 把参数"绑死"在创建时
from functools import partial
def show(x): return x
funcs = [partial(show, i) for i in range(3)]
print(funcs[0](), funcs[1](), funcs[2]()) # ✓ 0 1 2
# 为什么这些能解决?
# - 它们都在"创建闭包的那一刻", 把"当前的值"捕获/拷贝/绑定下来。
# - 默认参数: 定义时求值, 绑给参数(参数是闭包自己的局部变量)。
# - 工厂函数: 每次调用 make_func 都开一个新作用域, x 各自独立。
# - partial: 把实参在创建 partial 对象时就固定进去。
# - 共同点: 从"引用一个共享的变量" → 变成"持有一个自己的值"。
# 现代提示: 列表推导/生成器里, Python3 的 i 作用域已隔离, 但
# "循环里创建闭包留待以后调用"的坑依然存在 —— 仍需上面的写法。
# 核心: 在创建闭包时就把值冻结进去 —— 默认参数 lambda i=i / 工厂函数 / partial,
# 让每个闭包持有自己独立的值, 而不是共享同一个会变的变量。
修复的思路,统一而清晰:既然闭包捕获的是"变量",那就在创建时,主动把"值"绑进去,让每个闭包拥有自己独立的那份。正解一,默认参数(最常用!):lambda i=i: i——默认参数的值,在"函数定义时"就被求值并固定下来,绑给参数 i(参数是闭包自己的局部变量),于是每个闭包都有自己独立的 i。正解二,工厂函数:make_func(i)——每次调用都开一个新作用域,里面的 x 是这次调用独有的局部变量,互不干扰。正解三,functools.partial:把实参在创建 partial 对象时就固定进去。它们为什么都能解决?因为它们都在"创建闭包的那一刻",把"当前的值"捕获/拷贝/绑定了下来;共同点是——从"引用一个共享的、会变的变量",变成了"持有一个自己的、固定的值"。(补充一个现代提示:Python 3 的列表推导/生成器里,i 的作用域已隔离,但"循环里创建闭包、留待以后调用"这个坑依然存在,仍需上面的写法。)归根结底:在创建闭包时就把值冻结进去——默认参数 lambda i=i / 工厂函数 / partial,让每个闭包持有自己独立的值,而不是共享同一个会变的变量。
第三件事:这个坑在真实代码里的几种伪装
这个"延迟绑定"的坑,在真实项目里,常常换一身马甲出现,我把它常见的伪装形态列了出来:
# 这个坑的几种"真实场景"伪装:
# 1. 循环里注册事件回调 / 按钮命令(GUI 最常见)
for name in ["A", "B", "C"]:
button.on_click(lambda: handle(name)) # ✗ 点哪个都 handle("C")
# ✓ button.on_click(lambda name=name: handle(name))
# 2. 循环里创建定时任务 / 调度
for cfg in configs:
scheduler.add(lambda: run(cfg)) # ✗ 全跑最后一个 cfg
# ✓ scheduler.add(lambda cfg=cfg: run(cfg))
# 3. 循环里构建 map: 名字 -> 处理函数
handlers = {}
for k in keys:
handlers[k] = lambda: process(k) # ✗ 全 process(最后一个 k)
# ✓ handlers[k] = lambda k=k: process(k)
# 4. 异步任务里捕获循环变量
tasks = []
for url in urls:
tasks.append(asyncio.create_task(fetch(url))) # 这个 OK: 立刻求值传参
# 但如果是 lambda: fetch(url) 留待以后 → 又中招
# 共同特征(一眼识别):
# - "在循环里" + "创建了一个函数/闭包" + "这个函数留到以后才调用"
# + "函数体里引用了循环变量" → 几乎必中招!
# 通用解法: 凡是循环里创建、延迟调用的闭包, 引用循环变量时
# 一律用默认参数 lambda x=x 把值当场冻结。
# 核心: 这坑常伪装成事件回调/调度任务/处理函数map等; 一眼识别法是
# "循环里建闭包+延迟调用+引用循环变量"; 通用解法是默认参数当场冻结值。
这个坑的"马甲",我算是认全了。它最常出现在:循环里注册事件回调/按钮命令(GUI 重灾区,点哪个按钮都触发最后一个)、循环里创建定时/调度任务(全跑最后一个配置)、循环里构建"名字→处理函数"的 map(所有 key 都处理最后一个)、以及异步任务里捕获循环变量(若是 lambda 延迟调用就中招)。而识别它,有一个"一眼法":"在循环里" + "创建了一个函数/闭包" + "这个函数留到以后才调用" + "函数体里引用了循环变量"——这四个条件凑齐,就几乎必中招!(反过来,如果闭包是立刻求值、立刻传参的,比如 create_task(fetch(url)) 里 url 当场就传进 fetch 了,就没事。)而通用解法极其简单:凡是循环里创建、延迟调用、且引用循环变量的闭包,一律用默认参数 lambda x=x,把值当场冻结。归根结底:这坑常伪装成事件回调/调度任务/处理函数 map 等;一眼识别法是"循环里建闭包 + 延迟调用 + 引用循环变量";通用解法是默认参数当场冻结值。
下面这张图,是这次"闭包全返回末值"的成因与解法:
第四件事:三种修法的对比
这次踩坑后,我把"冻结闭包值"的三种修法,按可读性、适用场景比了一遍,方便对号入座。
| 修法 | 写法 | 优点 | 注意 |
|---|---|---|---|
| 默认参数 | lambda i=i: ... | 最简洁, 一行搞定 | 会让 lambda 多个参数, 别和真参数混淆 |
| 工厂函数 | def make(x): return lambda: x | 清晰、可读、能放复杂逻辑 | 多写几行, 但最易懂 |
| functools.partial | partial(fn, i) | 函数式优雅, 适合已有函数 | 只能绑参数, 不能写额外逻辑 |
| (反面) 直接 lambda: i | lambda: i | — | ✗ 延迟绑定, 全取末值 |
把它们排在一起,选择就清楚了。追求简洁,用默认参数 lambda i=i(一行搞定,但要注意它给 lambda 多加了个参数,别和真正的参数混淆);逻辑复杂、追求可读性,用工厂函数(多写几行,但最直观易懂,也能塞下复杂逻辑);已有现成函数、只是想绑参数,用 functools.partial(函数式优雅)。而那个反面写法 lambda: i,正是本文的祸根。我个人的偏好是:简单场景用默认参数(简洁),稍复杂或要给团队看的用工厂函数(可读性 > 炫技)。这张表给我的启发是:同一个坑,往往有多种解法;但它们的内核是一致的——都是"把延迟求值的'变量引用',变成创建时就固定的'值捕获'";理解了内核,选哪种写法,就只是风格和场景的问题了。
第五件事:Python 里其他"引用 vs 值/拷贝"的坑
这个闭包坑的本质,是"引用了同一个东西"。顺着这个本质,我把 Python 里其他几个同源的、关于"引用 vs 值/拷贝"的坑,一并排查了。
| 坑 | 现象 | 正解 |
|---|---|---|
| 循环闭包延迟绑定 | 闭包全取循环变量末值 | 默认参数 lambda x=x 冻结值(本文) |
| 可变默认参数 | def f(x, lst=[]): 多次调用共享同一 list | 默认 None, 函数内再 lst = lst or [] |
| [[0]*3]*3 建二维数组 | 三行是同一个 list, 改一个全变 | [[0]*3 for _ in range(3)] |
| 浅拷贝嵌套对象 | copy 后改嵌套, 原对象也变 | copy.deepcopy 深拷贝 |
| b = a 赋值"复制"列表 | b 改了 a 也变(同一对象) | b = a.copy() / list(a) |
| 函数里改传入的可变参数 | 把调用方的 list/dict 改了 | 需要时先拷贝再改, 或返回新对象 |
这张表,让我看清了这些坑共同的根。它们本质上是同一个问题:Python 里,变量、参数、默认值、乘法复制、赋值,很多时候操作的都是"对同一个对象的引用",而不是"独立的副本";一旦你以为自己有了好几个独立的东西、其实它们是同一个,改动一个就"牵一发而动全身",或者像本文这样,"共享一个会变的变量"。所以:可变默认参数(多次调用共享同一个 list)、[[0]*3]*3(三行是同一个 list)、浅拷贝改嵌套(原对象跟着变)、b = a 复制列表(其实是同一对象),全是这个根上长出来的。它们共同的启示是:写 Python,要时刻在脑子里分清"我现在拿到的,是一个'引用'(指向某个共享对象),还是一份'独立的拷贝'?"——需要独立时,就要显式地去拷贝/创建新的(deepcopy、[... for ...]、默认参数冻结值);而不能想当然地以为"赋值/创建就等于复制"。看清"引用",是写对 Python 的一道分水岭。
第六件事:在循环里创建函数时,我现在会怎么决策
现在,每当我准备在循环里创建函数/闭包,脑子里都会过一遍这张决策图——核心就一问:这个闭包,会留到以后才调用、且引用了循环变量吗?
这张图的灵魂,是把判断聚焦到那个"四要素"上。第一问:闭包引用了循环变量吗?——没引用(只用常量/外部固定值),随便写,安全。第二问:它会留到以后才被调用吗?——如果当场就调用完了(变量值还对),也安全;只有当它引用了循环变量、又留作回调/任务/map 延迟调用时,才会落入延迟绑定陷阱。一旦判定危险,就冻结值:追求简洁用默认参数 lambda x=x、要可读或有复杂逻辑用工厂函数、绑现成函数的参数用 partial。这套判断,让我以后写循环里的闭包,不再凭感觉、而是有章可循——而它的核心,始终是那句:看清这个闭包,引用的是一个"会变的共享变量",还是一个"已被冻结的独立值"。
我立下的几条规矩
这场"闭包全返回末值"的事故,换来了我写 Python 时,刻进骨子里的几条铁律:
- Python 闭包是延迟绑定:捕获变量、调用时求值。它记的是变量本身,不是创建时的值——这是理解一切的前提。
- 循环里创建、延迟调用、引用循环变量的闭包,必冻结值。四要素凑齐就中招,一律用 lambda x=x 当场绑定。
- 默认参数是最常用的冻结手法。lambda i=i 简洁有效;复杂逻辑用工厂函数,绑现成函数用 partial。
- 警惕这坑的各种马甲。事件回调、调度任务、处理函数 map、异步任务——本质都是"循环里建闭包延迟调用"。
- 分清"引用"和"拷贝"。赋值/创建很多时候是共享同一对象的引用,需要独立就显式拷贝/创建。
- 同源的坑一起记。可变默认参数、[[0]*3]*3、浅拷贝——都是"以为独立其实共享"的引用问题。
- 不确定就立刻验证。写几行调用一下、或改掉变量再调,马上就能看出闭包读的是变量还是值。
附:几行代码彻底看清"延迟绑定"的真面目
口说无凭,这几个对比实验,能让你亲眼看清闭包延迟绑定的真面目,跑一遍胜过千言:
# 实验1: 证明"闭包读的是变量当前值, 不是创建时快照"
funcs = [lambda: i for i in range(3)]
print([f() for f in funcs]) # [2, 2, 2] —— 全是末值
i_outer = 0
def g():
return lambda: i_outer
h = g()
print(h()) # 0
i_outer = 99 # 改外层变量
print(h()) # 99 ! —— 闭包跟着变, 铁证它读的是"变量"
# 实验2: 默认参数 vs 不用默认参数, 直接对照
bad = [lambda: i for i in range(3)]
good = [lambda i=i: i for i in range(3)]
print([f() for f in bad]) # [2, 2, 2] ✗
print([f() for f in good]) # [0, 1, 2] ✓
# 实验3: 用 __defaults__ 看默认参数确实"创建时就固定了值"
fs = [lambda i=i: i for i in range(3)]
print([f.__defaults__ for f in fs]) # [(0,), (1,), (2,)] —— 每个闭包各存了自己的值!
# 实验4: 立即调用就不中招(对比, 理解"延迟"二字)
results = [(lambda: i)() for i in range(3)] # 创建后立刻调用
print(results) # [0, 1, 2] —— 立刻求值时 i 还是当前值, 所以是对的
# 核心: 实验证明 —— lambda: i 读的是变量(改 i 它跟着变, 留到最后全取末值);
# lambda i=i 把值存进了 __defaults__(创建时就冻结); 立即调用则不受延迟绑定影响。
这几个实验,把"延迟绑定"这个抽象概念,变成了肉眼可见的事实。实验 1 最有冲击力:闭包创建后,我在外面把变量改成 99,闭包的返回值竟然跟着变成了 99——这无可辩驳地证明,它读的是"变量"(那个会变的盒子),而非"创建时的快照"。实验 2 把 lambda: i 和 lambda i=i 直接对照,[2,2,2] 与 [0,1,2] 的差别一目了然。而实验 3 最为精妙:我用 f.__defaults__ 把每个闭包的默认参数掏出来看,赫然是 [(0,), (1,), (2,)]——每个闭包,都在自己肚子里,存了一份属于自己的、创建时就固定下来的值!这就从机制上解释了默认参数为什么能解决问题。实验 4 则反过来印证:创建后立刻调用,结果是对的 [0,1,2],因为那一刻 i 的值还没变——这让"延迟"二字,有了最直观的注脚。这,正是我想用这几行代码,留给每一个 Python 学习者的最后一课:当你对一个语言特性的行为感到困惑或"反直觉"时,不要停留在猜测和争论——写几行最小的实验代码,让语言自己把真相演示给你看。那些通过亲手实验、亲眼所见而获得的认知,远比任何文字描述,都来得牢固和深刻。
写在最后
回头看,这场由"循环里的闭包"引发的、所有函数异口同声返回末值的事故,真正教给我的,是一个比"用默认参数冻结值"本身更深的道理:编程语言里,最容易咬人的,往往不是那些"明显复杂"的特性,而是那些"看起来简单、却和你的直觉悄悄相左"的细节。"闭包记住了一个变量"——这句话,听起来朴实无华、理所当然;可"记住的是变量、而非值,且在调用时才去看它"这个精确的语义,却和我"它应该记住创建时那一刻的快照"的朴素直觉,差了十万八千里——而这毫厘之差,就是那个让我所有函数都"失忆"的千里之谬。所以,真正学懂一门语言,不能停留在"我大概知道它是干嘛的"这种模糊的直觉上,而要去较真它"究竟在何时、对什么、做了什么"的精确语义:它捕获的是值还是引用?它在定义时求值还是调用时求值?它复制的是对象还是引用?——这些"较真"出来的精确认知,平时看似吹毛求疵,却恰恰是在关键时刻,把你和那些诡异 bug 隔开的护城河。真正的高手,与新手的差别,常常就在于是否愿意、并有能力,去抠清楚这些"看似简单"之处的精确语义。对"想当然"保持警惕,对"精确语义"保持较真——这,是我用一次"闭包失忆"的事故,换来的、关于 Python、也关于"如何真正学懂一门语言"的、最朴素也最深刻的领悟。如果这篇复盘,能让你在下一次于循环里写下 lambda 时,心里"咯噔"一下、多问一句"它捕获的到底是变量还是值",那我对着那串全返回 2 的函数熬的这大半天,就值了。
—— 别看了 · 2026