为什么Python的yield语句会形成闭包?

24

我有两个返回函数列表的函数。这些函数接受一个数字x并将i添加到它上面。i是从0-9递增的整数。

def test_without_closure():
    return [lambda x: x+i for i in range(10)]



def test_with_yield():
    for i in range(10):
        yield lambda x: x+i

我预期 test_without_closure 返回一个包含10个函数的列表,每个函数将 x 加上 9,因为 i 的值是 9
print sum(t(1) for t in test_without_closure()) # prints 100

我原以为test_with_yield也会有同样的行为,但它正确地创建了10个函数。

print sum(t(1) for t in test_with_yield()) # print 55

我的问题是,Python中的yield是否形成闭包?

3
尝试使用以下代码:sum(t(1) for t in list(test_with_yield())),结果会是100。当你在第二个求和中评估t(1)时,生成器尚未将i推进到下一个值。执行test_with_yield的过程被暂停并存储,直到下一个值被请求。 - Patrick Haugh
1
把Python的闭包看作总是进行引用复制而不是复制,你就能理解它的行为了... - Bakuriu
3个回答

29

在Python中,yield不会创建闭包,而lambda函数会创建闭包。如果在“test_without_closure”中没有闭包,您将无法访问i,但是你会得到所有的9。问题是所有闭包都包含对同一个变量 i 的引用,这个变量最终值将为9。

test_with_yield中,情况并没有太大区别。那么为什么会得到不同的结果?因为yield暂停函数的运行,所以可以在函数结束之前使用已生成的lambda函数,即i 还没有达到9 的时候。为了看清楚这一点,请考虑以下两个使用 test_with_yield 的示例:

[f(0) for f in test_with_yield()]
# Result: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]

[f(0) for f in list(test_with_yield())]
# Result: [9, 9, 9, 9, 9, 9, 9, 9, 9, 9]
这里发生的是,第一个示例产生了一个 lambda 函数(当 i 为 0 时),调用它(i 仍为 0),然后推进函数直到产生另一个 lambda 函数(此时 i 为 1),调用该 lambda 函数等等。重要的是每个 lambda 在控制流返回到 test_with_yield(即在 i 的值改变之前)之前都会被调用。
在第二个示例中,我们首先创建了一个列表。因此,第一个 lambda 函数被 yield(i 为 0)并放入列表中,第二个 lambda 函数被创建(此时 i 为 1)并放入列表中......直到最后一个 lambda 函数被 yield(此时 i 为 9)并放入列表中。然后我们开始调用这些 lambda 函数。因此,由于 i 现在为 9,所有的 lambda 函数都返回 9。
¹ 这里的重点是闭包引用变量而不是它们在闭包创建时所持有的值的副本。这样,如果你在 lambda 函数内部(或者你使用内部函数创建闭包的方式和使用 lambda 函数相同)给变量赋值,这也将改变 lambda 外部的变量,并且如果你在外部更改了该值,则该更改将在 lambda 函数内部可见。

2
请参见此处:http://docs.python-guide.org/en/latest/writing/gotchas/,第“Late Binding Closures”部分。 - VPfB
4
[lambda x, i=i: x+i for i in range(10)]返回了预期的lambda函数。请注意,这是一个Python代码示例。 - Ashwini Chaudhary
5
Lambda 表达式不保存 i 的任何值,它们只保存对 i 的引用。因此,如果 i 发生变化,通过该引用可见这种变化。 - sepp2k
1
“在“test_without_closure”中得到所有9的原因不是没有闭包”这种说法是不正确的。Lambda表达式在两个函数中都对i进行了闭包。” - jacg
5
@jacg,我认为你误读了双重否定。提问者显然认为没有闭包,但这是错误的,因此这不是导致全部为9的结果的原因;原因是所有的lambda都是闭包,正如你们两个所述。 - deltab
显示剩余11条评论

7
不,yielding与闭包无关。
以下是如何在Python中识别闭包的方法:
1. 一个函数; 2. 在其中执行未限定名称查找; 3. 函数本身不存在名称绑定; 4. 但是,在定义该函数的函数的局部作用域中存在名称绑定,该函数的定义环绕了对该名称进行查找的函数的定义。
你观察到的行为差异是由于惰性引起的,而不是与闭包有关。请比较以下内容。
def lazy():
    return ( lambda x: x+i for i in range(10) )

def immediate():
    return [ lambda x: x+i for i in range(10) ]

def also_lazy():
    for i in range(10):
        yield lambda x:x+i

not_lazy_any_more = list(also_lazy())

print( [ f(10) for f in lazy()             ] ) # 10 -> 19
print( [ f(10) for f in immediate()        ] ) # all 19
print( [ f(10) for f in also_lazy()        ] ) # 10 -> 19
print( [ f(10) for f in not_lazy_any_more  ] ) # all 19 

注意,第一和第三个示例给出相同的结果,第二个和第四个也是如此。第一和第三个是懒惰的,第二和第四个则不是。
请注意,所有四个示例都提供了一堆闭包,涵盖了i的最新绑定,只是在第一和第三种情况下,在重新绑定i之前(甚至在你创建序列中的下一个闭包之前)就评估了这些闭包,而在第二和第四种情况下,你首先等到i被重新绑定为9(在创建和收集所有要制作的闭包后),然后才评估这些闭包。

3
除了@sepp2k的答案之外,您看到这两种不同的行为是因为正在创建的lambda函数不知道它们必须从哪里获取i的值。在创建此功能时,它所知道的只是它必须从本地作用域、封闭作用域、全局作用域或内置作用域中获取i的值。
在这种特定情况下,它是一个闭包变量(封闭作用域)。其值随着每次迭代而改变。

查看Python中的LEGB


为什么第二个函数按预期工作而第一个函数不行?

这是因为每次你产生一个lambda函数,生成器函数的执行就会在那一刻停止,当你调用它时,它将使用此刻的i值。但在第一种情况下,在我们调用任何函数之前,我们已经将i的值提升到了9。

要证明这一点,您可以从__closure__的单元格内容中获取i的当前值:

>>> for func in test_with_yield():
        print "Current value of i is {}".format(func.__closure__[0].cell_contents)
        print func(9)
...
Current value of i is 0
Current value of i is 1
Current value of i is 2
Current value of i is 3
Current value of i is 4
Current value of i is 5
Current value of i is 6
...

但是,如果你将函数存储在某个地方并稍后调用它们,则会看到与第一次相同的行为:

from itertools import islice

funcs = []
for func in islice(test_with_yield(), 4):
    print "Current value of i is {}".format(func.__closure__[0].cell_contents)
    funcs.append(func)

print '-' * 20

for func in funcs:
    print "Now value of i is {}".format(func.__closure__[0].cell_contents)

输出:

Current value of i is 0
Current value of i is 1
Current value of i is 2
Current value of i is 3
--------------------
Now value of i is 3
Now value of i is 3
Now value of i is 3
Now value of i is 3

Patrick Haugh在评论中使用的示例也显示了同样的事情:sum(t(1) for t in list(test_with_yield()))


正确的方法:

i分配为lambda的默认值,当函数被创建时计算默认值,它们不会改变(除非它是一个可变对象)。现在,ilambda函数的局部变量。

>>> def test_without_closure():
        return [lambda x, i=i: x+i for i in range(10)]
...
>>> sum(t(1) for t in test_without_closure())
55

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