闭包是如何实现的?
《学习Python,第4版》提到:
当嵌套函数被调用时,会查找它们外层作用域中的变量。
不过,我原以为当一个函数结束时,它的所有局部变量就会消失。
def makeActions():
acts = []
for i in range(5): # Tries to remember each i
acts.append(lambda x: i ** x) # All remember same last i!
return acts
makeActions()[n]
对每个 n
来说都是一样的,因为变量 i
在调用时会被查找。那么,Python是怎么查找这个变量的呢?难道它不应该不存在吗?因为 makeActions
已经结束了。为什么Python不按照代码的直觉来做,而是在循环运行时将每个函数中的i替换为它当前的值呢?
5 个回答
局部引用之所以能持续存在,是因为它们被保存在局部范围内,而闭包会保持对这个范围的引用。
创建闭包时会发生什么:
- 闭包是通过指向它创建时所在的框架(大致可以理解为块)来构建的:在这个例子中,就是
for
块。 - 闭包实际上共享了这个框架的所有权,它会增加框架的引用计数,并把指向这个框架的指针存储在闭包里。这个框架也会保留对它所包含的框架的引用,以便访问更上层的变量。
- 在
for
循环运行时,框架中的i
的值会不断变化——每次给i
赋值都会更新框架中i
的绑定。 - 一旦
for
循环结束,框架会从栈中弹出,但并不会像通常那样被丢弃!相反,因为闭包仍然持有对框架的引用,所以它会被保留。不过,这时i
的值就不会再更新了。 - 当闭包被调用时,它会获取调用时父框架中
i
的值。由于在for
循环中你是创建闭包,而不是实际调用它们,所以在调用时i
的值将是循环结束后i
的最后一个值。 - 未来对
makeActions
的调用会创建不同的框架。你不会重用之前for
循环的框架,也不会更新那个框架中的i
值。
简而言之:框架会像其他Python对象一样被垃圾回收,而在这种情况下,会保留一个额外的引用指向for
块对应的框架,以防它在for
循环超出作用域时被销毁。
为了达到你想要的效果,你需要为每个想要捕获的i
值创建一个新的框架,每个lambda都需要与这个新框架的引用一起创建。你不能直接从for
块中获得这个,但可以通过调用一个辅助函数来建立新的框架。有关这方面的一个可能解决方案,请参见THC4k的回答。
我觉得很明显,当你把 i
看作一个 名字 而不是某种 值 时,会发生什么。你的 lambda 函数就像是在做“拿到 x:查找 i 的值,计算 i**x”... 所以当你实际运行这个函数时,它会在那个时候查找 i
,这时 i
的值是 4
。
你也可以使用当前的数字,但你需要让 Python 把它绑定到另一个名字上:
def makeActions():
def make_lambda( j ):
return lambda x: j * x # the j here is still a name, but now it wont change anymore
acts = []
for i in range(5):
# now you're pushing the current i as a value to another scope and
# bind it there, under a new name
acts.append(make_lambda(i))
return acts
这可能会让人感到困惑,因为你常常被教导变量和它的值是同一回事——这确实是对的,但只在那些真正使用变量的语言中。Python 没有变量,只有名字。
关于你的评论,其实我可以更好地说明这一点:
i = 5
myList = [i, i, i]
i = 6
print(myList) # myList is still [5, 5, 5].
你说你 把 i 改成了 6,但实际上并不是这样:i=6
的意思是“我有一个值,6
,我想把它命名为 i
”。你已经用 i
作为名字这一事实对 Python 来说并没有什么影响,它只是 重新分配了这个名字,而不是 改变它的值(这只在变量中有效)。
你可以说在 myList = [i, i, i]
中,无论 i
当前指向什么值(比如数字 5),都会得到三个新名字:mylist[0], mylist[1], mylist[2]
。这和调用一个函数时发生的事情是一样的:参数被赋予了新名字。但这可能和你对列表的直觉相悖……
这可以解释例子中的行为:你给 mylist[0]=5
,mylist[1]=5
,mylist[2]=5
- 难怪当你重新赋值 i
时它们不会改变。如果 i
是某种可变的东西,比如一个列表,那么改变 i
也会反映在 myList
的所有条目上,因为你只是 为同一个值使用了不同的名字!
简单来说,你可以在 =
的左边使用 mylist[0]
证明它确实是一个名字。我喜欢把 =
称为 赋值名字运算符:它在左边取一个名字,在右边取一个表达式,然后计算这个表达式(调用函数,查找名字背后的值),直到得到一个值,最后把这个名字赋给这个值。它并不会 改变任何东西。
关于 Mark 的评论,关于编译函数:
好吧,引用(和指针)只有在我们有某种可寻址的内存时才有意义。值存储在内存中的某个地方,引用指向那个地方。使用引用意味着去内存中的那个地方做一些事情。问题是,Python 根本不使用这些概念!
Python 虚拟机没有内存的概念——值 漂浮在某个空间中,而名字是连接到它们的小标签(通过一根小红线)。名字和值存在于不同的世界中!
这在编译函数时会有很大区别。如果你有引用,你知道你所指对象的内存位置。然后你可以简单地用这个位置替换引用。另一方面,名字没有位置,所以你在运行时需要做的就是沿着那根小红线走,使用另一端的东西。这就是 Python 编译函数的方式:在代码中每当出现一个名字时,它会添加一个指令来找出这个名字代表什么。
所以基本上 Python 确实会完全编译函数,但名字是作为在嵌套命名空间中的查找来编译的,而不是作为某种内存引用。
当你使用一个名字时,Python 编译器会尝试找出它属于哪个命名空间。这会导致一个指令,从它找到的命名空间中加载那个名字。
这又回到了你最初的问题:在 lambda x:x**i
中,i
被编译为在 makeActions
命名空间中的查找(因为 i
是在那里使用的)。Python 对它背后的值一无所知,也不在乎(它甚至不必是一个有效的名字)。当代码运行时,i
会在它的原始命名空间中被查找,并给出或多或少预期的值。