Lambda函数闭包捕获了什么?

368

最近我开始尝试使用Python,并发现闭包的工作方式有些奇怪。考虑以下代码:

adders=[None, None, None, None]

for i in [0,1,2,3]:
   adders[i]=lambda a: i+a

print adders[1](3)

它构建了一个简单的函数数组,这些函数接受一个输入并返回该输入加上一个数字。这些函数是在for循环中构造的,其中迭代器i从0到3运行。为每个数字创建了一个lambda函数,它捕获i并将其添加到函数的输入中。最后一行使用3作为参数调用第二个lambda函数。令人惊讶的是,输出结果是6。
我期望得到4。我的推理是:在Python中,所有东西都是对象,因此每个变量实际上都是指向它的指针。当为i创建lambda闭包时,我希望它存储指向当前由i指向的整数对象的指针。这意味着当i分配一个新的整数对象时,它不应影响先前创建的闭包。可悲的是,在调试器中检查adders数组显示它确实会受到影响。所有lambda函数都引用i的最后一个值,即3,这导致adders[1](3)返回6。
这让我想到以下问题:
闭包究竟捕获了什么?
说服lambda函数以一种不受i更改其值影响的方式捕获i的当前值,最优雅的方法是什么?

如果您想了解在Python中循环(或列表推导式、生成器表达式等)中创建函数(或lambda)的情况下更易于理解和实用的问题,请参见Creating functions (or lambdas) in a loop (or comprehension)。本问题侧重于理解Python代码的基本行为。

如果您在尝试修复Tkinter中制作按钮的问题,请尝试tkinter creating buttons in for loop passing command arguments以获取更具体的建议。

请参见What exactly is contained within a obj.__closure__?以了解Python如何实现闭包的技术细节。请参见What is the difference between Early and Late Binding?以进行相关术语讨论。


53
我在UI代码中遇到了这个问题,让我烦透了。诀窍是要记住循环不会创建新的作用域。 - detly
4
i 如何离开命名空间? - detly
4
嗯,我原本想说在循环之后使用“print i”是无效的。但是我亲自测试了一下,现在我明白你的意思了——它确实有效。我不知道在Python中循环变量会在循环体之后继续存在这一点。 - Tim MB
30
这在官方的Python FAQ中,位于为什么在循环中定义的具有不同值的lambda函数都返回相同的结果?下,提供了解释和通常的解决方法。 - abarnert
2
@SteveJessop:请看页面下方的词法环境第一段,其中解释了在命令式语言中,闭包必须是“按引用传递”的。Python使这有点混乱,因为它具有可变值,这些值具有自己固有的内存位置,但它也具有不会改变值而是改变环境的赋值语句——但是假设您希望赋值语句起作用,则重要的是名称而不是内存中的位置。 - abarnert
显示剩余11条评论
8个回答

316

您可以使用带有默认值的参数来强制捕获变量:

>>> for i in [0,1,2,3]:
...    adders[i]=lambda a,i=i: i+a  # note the dummy parameter with a default value
...
>>> print( adders[1](3) )
4

这个方法的思路是声明一个参数(巧妙地命名为 i ),并将它的默认值设置为你想要捕获的变量(即 i 的值)。


11
使用默认值加1。在定义lambda函数时进行评估使它们非常适合这种用途,让代码更加简洁易懂。 - quornian
40
因为这是官方FAQ所认可的解决方案,所以要+1。 - abarnert
65
这很惊人。然而,默认的Python行为却不是这样。 - Cecil Curry
6
然而,这似乎不是一个好的解决方案...你实际上改变了函数签名只是为了捕获变量的副本。并且那些调用函数的人可以搞乱变量i,对吧? - David Callanan
6
@DavidCallanan,我们正在讨论一个lambda:一种通常在自己的代码中定义以填补空缺的临时函数类型,而不是通过整个SDK共享的内容。如果您需要更强的签名,请使用真正的函数。 - Adrien Plisson
@AdrienPlisson 好的,完全可以理解,我从来没有好好考虑过那条消息哈哈。 - David Callanan

226

闭包到底捕获了什么?

Python 中的闭包使用词法作用域:它们记住了封闭变量的名称和范围,即它在哪里创建。然而,它们仍然是后期绑定的:名字在闭包代码使用时被查找,而不是在闭包创建时被查找。由于您示例中的所有函数都是在相同的范围内创建并使用相同的变量名,因此它们始终引用相同的变量。

至少有两种方法可以实现早期绑定:

  1. The most concise, but not strictly equivalent way is the one recommended by Adrien Plisson. Create a lambda with an extra argument, and set the extra argument's default value to the object you want preserved.

  2. More verbosely but also more robustly, we can create a new scope for each created lambda:

    >>> adders = [0,1,2,3]
    >>> for i in [0,1,2,3]:
    ...     adders[i] = (lambda b: lambda a: b + a)(i)
    ...     
    >>> adders[1](3)
    4
    >>> adders[2](3)
    5
    

    The scope here is created using a new function (another lambda, for brevity), which binds its argument, and passing the value you want to bind as the argument. In real code, though, you most likely will have an ordinary function instead of the lambda to create the new scope:

    def createAdder(x):
        return lambda y: y + x
    adders = [createAdder(i) for i in range(4)]
    

4
Python采用静态作用域,而不是动态作用域。它的所有变量都是引用,因此当你将一个变量设置为一个新对象时,变量本身(即引用)具有相同的位置,但指向其他东西。如果在Scheme中使用set!,也会发生相同的情况。请参考此处以了解动态作用域的真正含义:http://www.voidspace.org.uk/python/articles/code_blocks.shtml。 - Claudiu
10
选项2类似于函数式语言中所谓的“柯里化函数”。 - Crashworks
1
方案2更好。我喜欢它胜过默认参数。它更合乎逻辑,且不太依赖Python的特定设计方式。第二个lambda提供了类似闭包的局部变量。 - Sohail Si
严谨地说,闭包是一种实现技术,它使词法作用域与晚期绑定在函数作为一等对象的语言(如Python)中正常工作。动态作用域语言没有或不需要它们,因为它们可以通过沿着调用堆栈向后查找名称来执行相同的动态作用域解析。 - Karl Knechtel

52

为了完整性,对于您的第二个问题,还有另外一个答案:您可以在functools模块中使用partial

像Chris Lutz所建议的那样,通过从operator导入add,示例变成了:

from functools import partial
from operator import add   # add(a, b) -- Same as a + b.

adders = [0,1,2,3]
for i in [0,1,2,3]:
    # store callable object with first argument given as (current) i
    adders[i] = partial(add, i) 

print adders[1](3)

1
随着岁月的流逝,我越来越确信这是解决问题的最佳方式。 - Karl Knechtel

33

考虑以下代码:

x = "foo"

def print_x():
    print x

x = "bar"

print_x() # Outputs "bar"

我认为大多数人不会感到困惑。这是预期的行为。

那么,为什么人们认为在循环中执行时会有所不同呢?我知道我自己也犯了这个错误,但我不知道为什么。是循环吗?还是 lambda 表达式?

毕竟,循环只是下面代码的简写:

adders= [0,1,2,3]
i = 0
adders[i] = lambda a: i+a
i = 1
adders[i] = lambda a: i+a
i = 2
adders[i] = lambda a: i+a
i = 3
adders[i] = lambda a: i+a

27
循环结构会产生新作用域,这是因为在许多其他编程语言中,循环可以创建新的作用域。 - detly
2
这个答案很好,因为它解释了为什么每个 lambda 函数都访问相同的 i 变量。 - David Callanan
我认为混淆并不是因为循环,即使有新的作用域,值仍然会改变。我认为混淆是因为 lambda - 更确切地说,是因为在闭包中创建了一个名称查找外部作用域的闭包。因为 Python 的函数是一等对象,所以很容易有这样的直觉,即 lambda 在创建时应该“知道”它需要的所有内容 - 就像当我们实例化一个类时,__init__ 会将值分配给 self 属性,而 self 作为一个早期绑定的命名空间。 - Karl Knechtel
当然,在这里的例子中,我们看到通过晚期查找全局变量而揭示出其缺陷。但是当我很多年前第一次遇到这个问题时,我认为全局命名空间应该是个特例,而局部和封闭命名空间不会表现出这种行为。毕竟,离开那些作用域是可能的,对吧?所以你必须提前绑定以避免这个问题,对吧?如果 adders [1](3) 不给出 4为什么它在我们将 adders 返回并在别处使用它们时没有引发 NameError ,超出了范围内的 i ?当然,答案是闭包。 - Karl Knechtel

6

以下是一个新的示例,突出了闭包的数据结构和内容,以帮助澄清何时保留封闭上下文。

def make_funcs():
    i = 42
    my_str = "hi"

    f_one = lambda: i

    i += 1
    f_two = lambda: i+1

    f_three = lambda: my_str
    return f_one, f_two, f_three

f_1, f_2, f_3 = make_funcs()

闭包中包含什么内容?

>>> print f_1.func_closure, f_1.func_closure[0].cell_contents
(<cell at 0x106a99a28: int object at 0x7fbb20c11170>,) 43 

值得注意的是,my_str不在f1的闭包中。
f2的闭包中有什么?
>>> print f_2.func_closure, f_2.func_closure[0].cell_contents
(<cell at 0x106a99a28: int object at 0x7fbb20c11170>,) 43

从内存地址可以看出,两个闭包包含相同的对象。因此,您可以开始将lambda函数视为对作用域的引用。然而,my_str不在f_1或f_2的闭包中,i也不在f_3(未显示)的闭包中,这表明闭包对象本身是不同的对象。

闭包对象本身是相同的吗?

>>> print f_1.func_closure is f_2.func_closure
False

1
输出“int object at [address X]>”让我想到闭包存储了[地址X],也就是一个引用。但是,如果变量在lambda语句之后被重新赋值,则[地址X]将发生改变。 - Jeff

2

回答你的第二个问题,最优雅的方法是使用一个带有两个参数的函数,而不是数组:

add = lambda a, b: a + b
add(1, 3)

然而,在这里使用lambda有点傻。Python提供了operator模块,该模块为基本运算符提供了一个函数接口。上面的lambda只是为了调用加法运算符而存在不必要的开销:

from operator import add
add(1, 3)

我知道你在尝试探索这门语言,但我无法想象何时会使用函数数组而被Python的作用域问题所干扰。
如果你愿意,你可以编写一个小类来使用你的数组索引语法:
class Adders(object):
    def __getitem__(self, item):
        return lambda a: a + item

adders = Adders()
adders[1](3)

5
克里斯,当然,以上代码与我的实际问题无关。它只是为了简单说明我的观点而构建的。当然,这是毫无意义和愚蠢的。 - Boaz

0
创建一个函数来捕获值的加法器。
def create_adder(i):
    return lambda a: i + a


if __name__ == '__main__':
    adders = [None, None, None, None]

    for i in [0, 1, 2, 3]:
        adders[i] = create_adder(i)

    print(adders[1](3))

-1

解决i的作用域问题的一种方法是在另一个作用域(闭包函数)中生成lambda,将必要的参数传递给它以生成lambda:

def get_funky(i):
    return lambda a: i+a

adders=[None, None, None, None]

for i in [0,1,2,3]:
   adders[i]=get_funky(i)

print(*(ar(5) for ar in adders))

当然会返回 5 6 7 8


已经有多个答案展示了这种技巧。我不明白这个答案应该添加什么。 - Karl Knechtel
这里没有使用多个答案。仔细审查其他答案后,我看到它在Mark Shawabkeh的答案末尾提到了。 - Joffan

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