一个让我在GUI程序上崩溃的Bug
去年我在写一个简单的计算器GUI,用Tkinter做界面。界面上有一排数字按钮(0-9),每个按钮点击后把对应的数字输入到文本框里。代码是这样的:
from tkinter import Button, Tk
root = Tk()
for i in range(10):
btn = Button(root, text=str(i), command=lambda: print(i))
btn.pack()
root.mainloop()
看起来没问题吧?每个按钮都有自己对应的数字。我信心满满地运行,然后挨个点了0到9的按钮——所有按钮打印出来的都是9。
我当时脑子里只有一句话:“我明明给每个lambda传了不同的i,为什么全是9?”
更让我崩溃的是,这个问题不是偶然出现的,而是Python闭包的一个经典特性。我那一个下午都在跟这个“偷数据”的lambda作斗争。
先复现这个“灵异”现象
不用GUI,直接用最简单的代码就能复现:
funcs = []
for i in range(5):
funcs.append(lambda: i)
for f in funcs:
print(f(), end=" ") # 输出: 4 4 4 4 4
你期望的可能是0 1 2 3 4,但实际输出全是4。
再换一种写法:
funcs = [lambda: i for i in range(5)]
for f in funcs:
print(f(), end=" ") # 同样是 4 4 4 4 4
结果一样。所有lambda都“偷”了同一个数据——循环结束后的最终值i=4。
这个现象叫作延迟绑定(Late Binding)。lambda表达式捕获的不是变量的值,而是变量的引用。当lambda最终被调用时,循环早已结束,变量i的值已经变成了最后一个循环值。
为什么lambda会“偷”数据?
要理解这个问题,先搞清楚两个概念:
1. 闭包(Closure)
当一个函数(这里是lambda)引用了它所在作用域之外的变量时,就形成了一个闭包。lambda: i里的i,就是来自外层(循环所在的作用域)的变量。
2. 延迟绑定
Python的闭包捕获的是变量本身(引用),而不是变量在创建时刻的值。也就是说,lambda记住的是“我要去外层作用域找i这个变量”,而不是“我创建时i的值是几”。
所以当你调用lambda时,Python会去外层作用域查找i的当前值。而循环结束后,i的当前值是4(因为range(5)的最后一个值是4)。所有lambda都指向同一个i,于是全都打印4。
这和循环的变量作用域也有关系
Python没有块级作用域。循环变量i在循环结束后依然存在,它属于循环所在的整个函数或模块。
for i in range(5):
pass
print(i) # 4 —— i依然活着
所有lambda共享同一个i。如果循环结束后你修改了i,lambda的值也会跟着变:
funcs = [lambda: i for i in range(5)]
i = 100
for f in funcs:
print(f(), end=" ") # 100 100 100 100 100
看到了吗?lambda不是“记住”了0-4,而是每次都在问“i现在是什么”。
不只是lambda,普通函数也一样
这个问题不限于lambda,普通函数同样会中招:
funcs = []
for i in range(5):
def f():
return i
funcs.append(f)
for f in funcs:
print(f(), end=" ") # 同样全是4
所以问题的本质是Python闭包的延迟绑定,跟是不是lambda没关系,lambda只是让代码更短,更容易写出这个问题。
怎么解决?四种方法,总有一款适合你
方法1:默认参数快照(最常用)
把当前i的值作为默认参数传给lambda,默认参数在函数定义时求值,而不是调用时。
funcs = []
for i in range(5):
funcs.append(lambda i=i: i) # i作为默认参数
for f in funcs:
print(f(), end=" ") # 0 1 2 3 4
原理:lambda i=i: i中,i=i表示把当前循环的i值作为默认参数保存下来。默认参数在定义时就被计算并固定了。每个lambda都有自己独立的默认参数,互不影响。
方法2:用functools.partial固定参数
partial可以把函数的某些参数提前固定下来,返回一个新的可调用对象。
from functools import partial
def func(i):
return i
funcs = []
for i in range(5):
funcs.append(partial(func, i))
for f in funcs:
print(f(), end=" ") # 0 1 2 3 4
partial(func, i)在循环执行时就计算出i的值,然后把它固定下来。
方法3:工厂函数(闭包套闭包)
用一个外层函数把i“捕获”住,变成一个局部的、独立的值。
def create_func(i):
return lambda: i
funcs = []
for i in range(5):
funcs.append(create_func(i))
for f in funcs:
print(f(), end=" ") # 0 1 2 3 4
create_func(i)执行时,参数i是传进去的值,这个值被保存在create_func的作用域里。每个lambda捕获的是不同create_func调用中的i,因此各自独立。
方法4:用生成器或列表推导式的“内部作用域”
在Python 3中,列表推导式有自己的作用域,所以下面这种写法其实不会出问题:
funcs = [lambda: i for i in range(5)] # 依然有延迟绑定,会出问题
等等,上面这个写法我们刚才验证过,会出问题。那正确的写法是什么?
实际上,列表推导式里的i作用域只在推导式内部,但lambda捕获的仍然是这个i——延迟绑定依然存在。所以方法4不是一个有效解法。
真正的第四种方法是用map或显式循环代替lambda列表,但实用性不高。建议优先用前三种。
真实场景:GUI按钮的惨案
回到开头的Tkinter例子。正确的写法应该是:
from tkinter import Button, Tk
root = Tk()
for i in range(10):
# 方法1:默认参数快照
btn = Button(root, text=str(i), command=lambda i=i: print(i))
btn.pack()
root.mainloop()
这样每个按钮点击后打印自己的数字,再也不乱了。
一张表总结
| 场景 | 错误写法 | 正确写法 |
| 循环内创建lambda | lambda: i |
lambda i=i: i |
| 循环内创建普通函数 | def f(): return i |
def f(i=i): return i |
| 用partial固定参数 | lambda: func(i) |
partial(func, i) |
| 工厂函数 | 直接循环 | def make(i): return lambda: i |
回到开头的Bug
那个计算器程序,我最后用了默认参数快照:command=lambda i=i: print(i)。所有按钮都能正确打印自己的数字了。
记住:在Python里,循环中的lambda或嵌套函数不会“记住”当前循环变量的值,它们只会记住变量的引用。当函数被调用时,变量的值可能已经变了。
如果你不想被偷数据,就用默认参数把值“冻住”。这个习惯能帮你避开无数类似的坑。