免费python编程教程:https://pan.quark.cn/s/2c17aed36b72
一个让我加班两小时的Bug
先给你讲个真实故事。
上周三晚上十一点,我还在工位上盯着屏幕。面前是一个跑了好几天都没问题的Python脚本,今天突然出了个诡异的bug。
代码逻辑很简单:用循环创建几个函数,每个函数记住自己对应的数字。
我当时写的大概是这样:
funcs = [] for i in range(3): def f(): print(i) funcs.append(f) for f in funcs: f()我本以为会输出:
0 1 2结果你猜怎么着?
2 2 2三个2。
我当时第一反应是:“这不可能”。Python这么简单的循环闭包怎么可能出错?一定是我哪里写错了。
检查了三遍代码,没错。又检查了三遍,还是没错。
那一刻我感觉自己像是个刚开始学Python的新手。
后来查了半小时资料,才发现——原来是我对闭包的理解一直有问题。
今天就把这个坑彻底讲清楚。
先搞明白:什么是闭包?
先说定义。闭包就是一个函数,它记住了外部作用域的变量。
举个例子:
def outer(): msg = "hello" def inner(): print(msg) return inner my_func = outer() my_func() # 输出 hello这里的inner就是一个闭包。它记住了outer函数里的msg变量,即使outer已经执行完了。
看起来很简单对吧?那为什么开头的例子会出问题?
问题核心:变量什么时候被“记住”?
很多人(包括以前的我)以为:定义函数的时候,它就会记住当前变量的值。
错。
实际上:函数被调用的时候,才会去查找变量的值。
这就是关键。
看回开头那个例子:
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。
这就是真相:闭包捕获的是变量本身,不是变量的值。
用比喻理解这个问题
想象你在一栋写字楼里。
第1层、第2层、第3层各有一家公司。每家公司都装了一个显示屏,显示“当前楼层号”。
本来每层的显示屏应该显示不同的数字:1、2、3。
但现在有个问题——这三块显示屏都连着同一个传感器,这个传感器告诉你“当前电梯停在哪一层”。
你一开始把电梯停到1楼,传感器显示1。但突然有人按了电梯,电梯跑到2楼了。传感器现在显示2,三块显示屏同时更新成2。电梯再跑到3楼,三块屏又同时变成3。
最后电梯停在2楼不动了,三块屏就都显示2。
你是不是觉得这样设计很蠢?每个楼层明明应该有自己的数字。
对,Python这个行为就有点像那个“共用传感器”的设计。
常见的错误写法
这种错误不只出现在循环里。来看另一个场景:
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 15 15因为n最终变成了3,三个 lambda 用的都是最后那个n。
解决方案一:默认参数
最常见的修复方法:给函数加一个默认参数。
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
如果你觉得默认参数写法有点“取巧”,可以用更明确的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就是提前把参数固定住,生成一个新函数。
解决方案三:再包一层函数
也可以再包一层函数来“冻结”当前的值:
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和嵌套函数语法很简洁,让人觉得好像“随手一写就行”,结果正好踩坑。
更隐蔽的坑:修改外部变量
闭包默认只能读取外部变量。如果想修改,需要nonlocal关键字。
看这个例子:
def counter(): count = 0 def increment(): count += 1 # 报错! return count return increment c = counter() c()运行会报错:
UnboundLocalError: local variable 'count' referenced before assignment因为count += 1相当于count = count + 1,Python 在函数内部看到count =就会认为count是一个局部变量。但局部变量count还没来得及赋值就被引用了。
修复方法:
def counter(): count = 0 def increment(): nonlocal count count += 1 return count return incrementnonlocal告诉 Python:“这个count不是我的局部变量,去外一层作用域找”。
闭包的内存陷阱
还有一个容易被忽略的点:闭包会一直持有外部变量,即使外部函数已经返回了。
看这个例子:
def outer(): large_data = [0] * 10000000 # 一个大列表 def inner(): return len(large_data) return inner func = outer() # 此时 large_data 应该被销毁吗?并不会 # 因为 inner 还在引用它large_data不会被垃圾回收,只要func还存在。
如果你不小心在循环里创建了一堆闭包,每个都引用了大对象,内存可能会暴涨。
实用的调试技巧
当你怀疑闭包变量出问题时,可以用__closure__属性检查:
funcs = [] for i in range(3): def f(): print(i) funcs.append(f) for f in funcs: print(f.__closure__)输出类似:
(<cell at 0x...: int object at 0x...>,) (<cell at 0x...: int object at 0x...>,) (<cell at 0x...: int object at 0x...>,)你可以进一步查看闭包里的值:
for f in funcs: print(f.__closure__[0].cell_contents)输出:
2 2 2果然,三个闭包引用的都是同一个cell对象,里面存着2。
这个工具在排查复杂闭包问题时非常有用。
再看一个经典面试题
很多 Python 面试会考这个:
def create_funcs(): return [lambda x: x * i for i in range(5)] for f in create_funcs(): print(f(2))输出是什么?
如果你读到这里,应该能立刻说出答案:
8 8 8 8 8因为i最终是4,2*4=8。
修复方法:
def create_funcs(): return [lambda x, i=i: x * i for i in range(5)]或者:
def create_funcs(): return [lambda x, n=i: x * n for i in range(5)]总结:记住这三句话就够了
闭包捕获的是变量本身,不是变量的值。变量变了,闭包看到的值也跟着变。
默认参数在定义时求值。想“冻结”当前值,就用默认参数。
修改外部变量要加 nonlocal。否则 Python 会当成本地变量处理。
这三点说起来简单,但每一条背后都有实际踩坑的故事。
回到开头那个让我加班到深夜的Bug。最后怎么解决的?用了默认参数,三行代码,一分钟改完。
但为了搞清楚“为什么”,花了两小时。
很多时候就是这样——改代码很快,真正花时间的是理解“原来我一直想错了”。
希望这篇文章能帮你省下那两小时。
下次写闭包的时候,多问自己一句:“我捕获的是变量,还是变量的值?”
想清楚这个问题,这个坑就再也坑不到你了。