闭包是如何实现的?

22 投票
5 回答
3681 浏览
提问于 2025-04-16 00:36

《学习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 个回答

1

局部引用之所以能持续存在,是因为它们被保存在局部范围内,而闭包会保持对这个范围的引用。

8

创建闭包时会发生什么:

  • 闭包是通过指向它创建时所在的框架(大致可以理解为)来构建的:在这个例子中,就是for块。
  • 闭包实际上共享了这个框架的所有权,它会增加框架的引用计数,并把指向这个框架的指针存储在闭包里。这个框架也会保留对它所包含的框架的引用,以便访问更上层的变量。
  • for循环运行时,框架中的i的值会不断变化——每次给i赋值都会更新框架中i的绑定。
  • 一旦for循环结束,框架会从栈中弹出,但并不会像通常那样被丢弃!相反,因为闭包仍然持有对框架的引用,所以它会被保留。不过,这时i的值就不会再更新了。
  • 当闭包被调用时,它会获取调用时父框架中i的值。由于在for循环中你是创建闭包,而不是实际调用它们,所以在调用时i的值将是循环结束后i的最后一个值。
  • 未来对makeActions的调用会创建不同的框架。你不会重用之前for循环的框架,也不会更新那个框架中的i值。

简而言之:框架会像其他Python对象一样被垃圾回收,而在这种情况下,会保留一个额外的引用指向for块对应的框架,以防它在for循环超出作用域时被销毁。

为了达到你想要的效果,你需要为每个想要捕获的i值创建一个新的框架,每个lambda都需要与这个新框架的引用一起创建。你不能直接从for块中获得这个,但可以通过调用一个辅助函数来建立新的框架。有关这方面的一个可能解决方案,请参见THC4k的回答。

10

我觉得很明显,当你把 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]=5mylist[1]=5mylist[2]=5 - 难怪当你重新赋值 i 时它们不会改变。如果 i 是某种可变的东西,比如一个列表,那么改变 i 也会反映在 myList 的所有条目上,因为你只是 为同一个值使用了不同的名字

简单来说,你可以在 = 的左边使用 mylist[0] 证明它确实是一个名字。我喜欢把 = 称为 赋值名字运算符:它在左边取一个名字,在右边取一个表达式,然后计算这个表达式(调用函数,查找名字背后的值),直到得到一个值,最后把这个名字赋给这个值。它并不会 改变任何东西

关于 Mark 的评论,关于编译函数:

好吧,引用(和指针)只有在我们有某种可寻址的内存时才有意义。值存储在内存中的某个地方,引用指向那个地方。使用引用意味着去内存中的那个地方做一些事情。问题是,Python 根本不使用这些概念!

Python 虚拟机没有内存的概念——值 漂浮在某个空间中,而名字是连接到它们的小标签(通过一根小红线)。名字和值存在于不同的世界中!

这在编译函数时会有很大区别。如果你有引用,你知道你所指对象的内存位置。然后你可以简单地用这个位置替换引用。另一方面,名字没有位置,所以你在运行时需要做的就是沿着那根小红线走,使用另一端的东西。这就是 Python 编译函数的方式:在代码中每当出现一个名字时,它会添加一个指令来找出这个名字代表什么。

所以基本上 Python 确实会完全编译函数,但名字是作为在嵌套命名空间中的查找来编译的,而不是作为某种内存引用。

当你使用一个名字时,Python 编译器会尝试找出它属于哪个命名空间。这会导致一个指令,从它找到的命名空间中加载那个名字。

这又回到了你最初的问题:在 lambda x:x**i 中,i 被编译为在 makeActions 命名空间中的查找(因为 i 是在那里使用的)。Python 对它背后的值一无所知,也不在乎(它甚至不必是一个有效的名字)。当代码运行时,i 会在它的原始命名空间中被查找,并给出或多或少预期的值。

撰写回答