一个让我加班到深夜的Bug
先讲一个真实的 Python 开发踩坑经历。

上周三晚上十一点,我还坐在工位上盯着屏幕。一个已经稳定跑了好几天的 Python 脚本,突然冒出了一个非常诡异的 bug。
代码逻辑其实很简单:在循环里创建几个函数,让每个函数记住自己对应的数字。大致写法如下:
funcs = []
for i in range(3):
def f():
print(i)
funcs.append(f)
for f in funcs:
f()
你可能会以为输出一定是:0、1、2。结果呢?实际打印出来的是三个2。
我的第一反应就是:“这不可能。”Python 这种看起来再基础不过的循环闭包,怎么会在这里翻车?我把代码来回检查了三遍,没发现问题;又重新看了三遍,还是觉得写法没错。那一瞬间,真有种自己像刚学 Python 的新手一样的感觉。
后来查了半个多小时资料,才意识到——原来自己对 Python 闭包和变量作用域的理解一直有偏差。今天这篇文章,就把这个经典坑彻底讲明白。
先搞明白:什么是闭包?
定义其实不复杂:闭包就是一个函数,它能够记住并访问外部作用域中的变量。
来看一个简单例子:
def outer():
msg = "hello"
def inner():
print(msg)
return inner
my_func = outer()
my_func() # 输出 hello
这里的 inner 就是一个典型的闭包。它记住了 outer 函数里的 msg 变量,即使 outer 已经执行结束了,inner 依然可以访问它。看到这里你可能会觉得很简单,那么文章开头那个 Python 闭包变量作用域问题,为什么会出错?
问题核心:变量到底在什么时候被“记住”?
很多人(包括以前的我)都会下意识认为:函数在定义时,就已经把外部变量的当前值保存下来了。其实这是一种常见误解。
真正的规则是:函数在被调用时,才会去查找它引用的变量当前是什么值。这一点,正是理解 Python 闭包 bug 的关键。
再看回最开始的示例:
funcs = []
for i in range(3):
def f():
print(i)
funcs.append(f)
这段循环创建了三个函数,每个函数的逻辑都是“打印变量 i”。但这里的 i 并不是每个函数各自独立保存的一份副本,而是同一个外部变量 i。等循环执行完成后,i 最终的值就是 2。
当你后面再去调用这些函数时:
for f in funcs:
f()
每个函数都会到当前作用域里查找变量 i 的值,查到的都是 2。所以最终三个函数全部打印 2。这就是问题的本质:Python 闭包捕获的是变量本身,而不是变量当时的值。
用比喻理解这个问题
你可以把它想象成一栋写字楼。第1、2、3层各有一家公司,每家公司都装了一块屏幕,用来显示“当前楼层号”。按理说,每层屏幕应该显示不同的数字:1、2、3。但问题在于——这三块屏幕其实都连接到同一个传感器,而这个传感器显示的只是“电梯当前停在哪一层”。
一开始电梯在1楼,传感器显示1;有人按了电梯,电梯到了2楼,三块屏幕就同时变成2;最后电梯停在2楼不动,三块屏幕也都保持显示2。Python 中闭包引用循环变量的行为,就很像这种“共用同一个传感器”的设计。
常见的错误写法
这种闭包变量作用域错误,不只会出现在 for 循环里,下面这个例子也非常常见:
def create_multipliers():
multipliers = []
for n in [1, 2, 3]:
multipliers.append(lambda x: x * n)
return multipliers
for m in create_multipliers():
print(m(5))
很多人会以为输出是 5、10、15,但实际结果却是三个15。原因也一样:循环结束后,n 的最终值是 3,所以这三个 lambda 表达式使用的都是最后那个 n。
解决方案一:默认参数
修复这种 Python 闭包坑最常见、也最实用的方法,就是给函数添加默认参数:
funcs = []
for i in range(3):
def f(i=i): # 注意这里
print(i)
funcs.append(f)
for f in funcs:
f() # 输出 0 1 2
为什么这样就能解决?因为 Python 默认参数会在函数定义时求值。当循环运行到 i=0 时,f(i=i) 里右边那个 i 会立刻被计算成数字 0,然后作为默认参数绑定到当前函数对象上。这样每个函数都有自己独立的值,不再依赖外部那个会变化的变量 i。
解决方案二:functools.partial
如果你觉得默认参数这种写法稍微有点“技巧性”,也可以使用更明确的 functools.partial:
from functools import partial
funcs = []
for i in range(3):
def f(x):
print(x)
funcs.append(partial(f, i))
for f in funcs:
f()
partial 的作用就是提前固定参数,返回一个新的函数对象。在一些强调代码可读性的场景里,这种写法也很适合解决 Python 闭包变量绑定问题。
解决方案三:再包一层函数
还有一种很常见的做法,就是额外再包一层函数,用新的局部作用域把当前值“冻结”下来:
funcs = []
for i in range(3):
def outer(i):
def inner():
print(i)
return inner
funcs.append(outer(i))
for f in funcs:
f()
当 outer(i) 被调用时,参数 i 会进入一个新的局部作用域中。此时内部闭包 inner 记住的是这个局部变量,而不是外层循环里不断变化的那个 i。
为什么初学者特别容易踩这个坑?
因为很多编程语言在类似场景下的行为并不完全一样。比如 JavaScript(ES6 之前)也有类似问题,但如果使用 let 声明循环变量,那么每次迭代都会创建一个新的绑定。相比之下,Python 的 for 循环不会为每一次迭代生成新的作用域,循环变量始终是同一个变量,只是不断被赋予新值。再加上 Python 的 lambda 和嵌套函数写法非常简洁,很容易让人误以为“这样写肯定没问题”,结果反而正中这个经典陷阱。
更隐蔽的坑:修改外部变量
除了读取变量,Python 闭包还有另一个常见坑点:闭包默认只能读取外部变量,如果你想在内部函数里修改它,就必须使用 nonlocal 关键字:
def counter():
count = 0
def increment():
count += 1 # 报错!
return count
return increment
c = counter()
c()
运行这段代码会直接报错:UnboundLocalError。原因是 count += 1 本质上等价于 count = count + 1,而 Python 只要在函数内部看到 count = 这样的赋值语句,就会把 count 认定为局部变量。正确修复方式如下:
def counter():
count = 0
def increment():
nonlocal count
count += 1
return count
return increment
nonlocal 的意思就是明确告诉 Python:这个 count 不是当前函数自己的局部变量,请去外层作用域里查找并修改它。
闭包的内存陷阱
还有一个经常被忽略的问题是:闭包会持续持有它引用的外部变量,即便外层函数早就执行结束了。例如:
def outer():
large_data = [0] * 10000000 # 一个大列表
def inner():
return len(large_data)
return inner
func = outer()
# 此时 large_data 应该被销毁吗?并不会,因为 inner 还在引用它
这意味着,如果你在循环中创建了大量闭包,并且每个闭包都引用了大对象、缓存数据或复杂上下文,那么内存占用就可能持续升高。在 Python 性能优化和内存排查中,这也是闭包需要特别注意的一点。
实用的调试技巧
当你怀疑自己遇到的是 Python 闭包作用域问题时,可以通过 __closure__ 属性来检查闭包内部引用了哪些变量:
funcs = []
for i in range(3):
def f():
print(i)
funcs.append(f)
for f in funcs:
print(f.__closure__)
如果想进一步查看闭包中实际保存的内容,可以这样写:
for f in funcs:
print(f.__closure__[0].cell_contents)
你会看到输出是三个2。说明这三个闭包引用的其实是同一个 cell 对象,而这个对象里存放的正是最终值 2。在调试复杂的嵌套函数、lambda 表达式或回调逻辑时,这个方法非常实用。
再看一个经典面试题
很多 Python 面试题里都会考察这个知识点:
def create_funcs():
return [lambda x: x * i for i in range(5)]
for f in create_funcs():
print(f(2))
输出是什么?如果你已经读到这里,应该能马上给出答案:五个8。因为循环结束后,i 的最终值是 4,所以每个函数实际执行的都是 2 * 4 = 8。对应的修复方式也很直接:lambda x, i=i: x * i。
总结:记住这三句话就够了
- 闭包捕获的是变量本身,不是变量的值。 变量一旦变化,闭包看到的结果也会随之变化。
- 默认参数在定义时求值。 如果想“冻结”当前值,默认参数是最常用的解决方案。
- 修改外部变量要加 nonlocal。 否则 Python 会把它当作局部变量处理。
这三句话看似简单,但每一句背后几乎都对应着真实开发中的踩坑案例。回到最开始那个让我加班到深夜的 Bug,最后是怎么修好的?答案其实很简单:用了默认参数,改动只有三行,几分钟就搞定了。但为了真正弄懂“为什么会这样”,我前前后后花了两个小时。
很多时候,写代码并不一定最耗时间,真正耗时的是理解问题背后的机制,尤其是像 Python 闭包、变量作用域、默认参数求值这种看起来基础、实际却很容易误解的知识点。希望这篇文章能帮你少走弯路,直接避开这个经典坑。下次再写闭包时,不妨先问自己一句:“我捕获的到底是变量,还是变量当时的值?”只要把这个问题想清楚,这类 bug 基本就很难再坑到你了。
