闭包是如何实现的?

22

"Learning Python, 4th Ed."提到:

当嵌套函数后被调用时,将会查找封闭作用域变量。

然而,我认为当一个函数退出时,所有本地引用都会消失。

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 来定义每个函数?


我想我只是对这背后的动机感到困惑。当遇到lambda x: i ** x时,Python应该使用“LGEP”并在封闭的for循环中找到变量i。这个变量i指向内存中的某个整数。然后,Python应该在内存中创建lambda函数并将其附加到列表“acts”中。每次循环通过时,变量i引用内存中的不同整数,因此应创建不同的函数。为什么Python在拥有所有必要信息时没有完全创建lambda函数呢? - Mark
抱歉,LGEP 应该是 LEGB(本地、封闭、全局、内置)。 - Mark
@Mark,你为什么要强制自由变量进行早期绑定呢?!现在的情况是,你可以通过在函数体中使用它们来实现晚期绑定,也可以通过使用 i=i 参数默认值习惯性地实现早期绑定——这有什么不好的呢?特别是在 py3 中,新的 nonlocal 让内部函数也能重新绑定这些变量,如果强制早期绑定,那将是一场灾难。而且,你是否希望在函数中使用的全局变量也被早期绑定“固定”呢?这几乎没有任何用处,但对于全局变量和自由变量具有相同名称的情况,这将完全不兼容。 - Alex Martelli
@Alex Martelli - 我不明白为什么我会使用后期绑定。如果我需要编写n个完全相同的函数,那么有更易读的方式来编码。我认为提前绑定全局变量没有问题,并且我不明白为什么提前绑定会与使用nonlocal和global进行重新绑定不兼容。 - Mark
如果你认为这样做需要能够指定晚绑定或早绑定,那么我们现在正好有这样的功能,就像我说的。你的立场是你不明白为什么要使用晚绑定,而我已经给你展示了一个非常显然的案例,足够短小,适合放在注释的狭小空间里。如果你对更多不那么短小的案例感兴趣,我想你应该提出问题以便得到更宽敞的答案;否则,要么停止抱怨,要么继续抱怨但不了解你所抱怨的问题,随你喜欢;-)。 - Alex Martelli
显示剩余6条评论
5个回答

10
我认为当你将 i 看作一个 名称 而不是某种 时会发生什么,这是相当明显的。你的 lambda 函数执行类似于“取 x:查找 i 的值,计算 i**x”...所以当你实际运行函数时,它会刚刚 在那个时刻 查找 i,因此 i4
你也可以使用当前数字,但必须让 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 不知道也不

1
我认为这种引用的思考方式与Python中引用通常的工作方式不一致。考虑:i = 5; myList = [i, i, i]; i = 6; print(myList);尽管i已经改变为6,但myList仍然是[5, 5, 5]。对我来说,创建一个函数应该类似于创建一个列表。列表的索引引用在创建列表时指向内存中的一个位置。为什么lambda函数的变量在创建lambda函数时不指向内存中的位置呢?(抱歉,我似乎无法在评论中使用markdown。) - Mark
@THC4k - 我理解引用和对象之间的区别,并且我意识到myList [0]是一个引用。那不是我的困惑的源头。然而,我认为我在Python在内存中构建函数的方式上是错误的。我曾经认为,在遇到def或lambda时,Python会生成与该函数相关的所有必要的机器指令,并将其保存在内存中的某个位置。现在我认为更像是Python将函数保存为文本字符串(并将其与闭包所需的引用捆绑在一起),并在每次调用函数时重新解析它。 - Mark
@ChaosPandion - 我相信 Python 列表不仅仅是带有整数键的 Python 字典,就像 Javascript 一样。我认为它们的实现方式类似于 Java 的 ArrayList。我认为 THC4k 的观点是 myList[n] 是一个引用,而不是 n 是 Python 字典中键的名称。 - Mark
@THC4k - 现在我对此有了一些更清晰的认识。看起来 makeAction 的命名空间在执行后并没有完全被垃圾回收,因为 Python 发现 lambda 函数引用了名称 i。 - Mark
“Python没有变量”这个愚蠢的口号必须停止。Python有变量,只是它们的工作方式不同于C语言中的变量。 - Ned Batchelder
显示剩余8条评论

8
创建闭包时会发生什么:
  • 闭包将使用指向创建它的框架(或大致上的块)的指针进行构造:在本例中,是for块。
  • 闭包实际上假定该框架的共享所有权,通过增加框架的引用计数并将指向该框架的指针存储在闭包中。该框架反过来保留对其所封闭的框架的引用,以获取更高层次的栈变量。
  • 在for循环运行时,该框架中的i的值会不断改变 - 每次对i的赋值都会更新该框架中i的绑定。
  • 一旦for循环退出,该框架就会从堆栈中弹出,但通常情况下不会被丢弃!相反,它被保留下来,因为闭包对框架的引用仍然活动。但此时,i的值不再更新。
  • 当调用闭包时,它会捕获调用时父框架中i的任何值。由于在for循环中您创建闭包,但实际上没有调用它们,因此调用时i的值将是循环完成后最后一个值。
  • 未来对makeActions的调用将创建不同的框架。在这种情况下,您不会重用for循环的先前框架,也不会更新先前框架的i值。
简而言之:框架与其他Python对象一样进行垃圾回收,在这种情况下,保留了一个额外的对应于for块的框架的引用,因此当for循环超出范围时它不会被销毁。
要获得所需的效果,需要为要捕获的每个i值创建一个新框架,并且每个lambda都需要使用对该新框架的引用创建。您无法从for块本身中获得该效果,但可以从调用辅助函数中获得该效果,该函数将建立新框架。请参见THC4k的答案,以了解可能沿着这些线路的一种解决方案。

那么for循环实际上是Python中的对象吗?您能推荐一些资源,让我了解帧(frame)方面的知识吗?这个答案更符合我正在寻找的内容,谢谢! - Mark
1
这不是实现的方式。闭包不会保留指向它们创建时的堆栈帧的指针。此外,块没有自己的堆栈帧。Python使用闭包单元对象来实现闭包。单元对象保存指向变量内容的指针,需要使用变量的所有内容都会得到指向单元对象的指针。这使得各个闭包变量可以拥有自己的生命周期,而不必保持整个堆栈帧及其所有内容的存活。 - user2357112
你可能把闭包变量机制和 f_back 混淆了,f_back 是一个帧对象对调用者堆栈帧的引用,而不是创建闭包的堆栈帧。 (我认为任何发布的 CPython 版本都没有保留整个堆栈帧来实现闭包。) - user2357112

1

本地引用之所以持久,是因为它们包含在局部作用域中,闭包保留了对其的引用。


1
我曾认为当函数退出时,所有本地引用都会消失。
除了那些在闭包中被封闭的本地引用。即使该函数已经返回,它们也不会消失。

0

直观地,人们可能认为i会被捕获在其当前状态,但事实并非如此。将每个层级视为名称值对的字典。

    Level 1:
        acts
        i
    Level 2:
        x

每次为内部lambda创建闭包时,都会捕获对第一级的引用。我只能假设运行时将查找变量i,从level 2开始,并向level 1移动。由于您不会立即执行这些函数,因此它们都将使用i的最终值。

专家们怎么看?


没错 - 每个 Lambda 函数都保留对相同 i 的引用。 - Igor Serebryany

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接