Что (лямбда) захватывает функция закрытия?

249

Недавно я начал играть с Python и обнаружил что-то необычное в работе замыканий. Рассмотрим следующий код:

adders=[0,1,2,3]

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

print adders[1](3)

Он создает простой массив функций, которые принимают один вход и возвращают этот вход, добавленный числом. Функции построены в forцикле, где итератор iработает от 0до 3. Для каждого из этих чисел создается lambdaфункция, которая захватывает iи добавляет ее к входу функции. Последняя строка вызывает вторую lambdaфункцию с 3параметром. К моему удивлению, результат был 6.

Я ожидал 4. Я рассуждал так: в Python все является объектом, поэтому каждая переменная является указателем на него. При создании lambdaзамыканий для iя ожидал, что он будет хранить указатель на целочисленный объект, на который в данный момент указывает i. Это означает, что при iназначении нового целочисленного объекта это не должно влиять на ранее созданные замыкания. К сожалению, проверка addersмассива в отладчике показывает, что это так. Все lambdaфункции относятся к последнему значению i, 3, что приводит к adders[1](3)возвращению 6.

Что заставляет меня задуматься о следующем:

  • Что именно запечатлевают крышки?
  • Каков наиболее элегантный способ убедить lambdaфункции захватить текущее значение iтаким образом, чтобы это не повлияло при iизменении его значения?
Боаз
источник
35
У меня была эта проблема в коде пользовательского интерфейса. Гнал меня с ума. Хитрость заключается в том, чтобы помнить, что циклы не создают новую область видимости.
нежно
3
@TimMB Как iпокинуть пространство имен?
детально
3
@ Хорошо, я собирался сказать, что print iэто не сработает после цикла. Но я проверил это для себя, и теперь я понимаю, что вы имеете в виду - это работает. Я понятия не имел, что переменные цикла задерживаются после тела цикла в python.
Тим МБ
1
@TimMB - Да, я это и имел в виду. То же самое для if, with, и tryт.д.
detly
13
Это в официальном FAQ по Python, в разделе Почему лямбды, определенные в цикле с разными значениями, возвращают один и тот же результат? с объяснением и обычным обходным путем.
Абарнерт

Ответы:

161

На ваш второй вопрос был дан ответ, но что касается вашего первого:

что точно захватывает замыкание?

Скоупинг в Python является динамичным и лексическим. Закрытие всегда будет помнить имя и область действия переменной, а не объект, на который она указывает. Поскольку все функции в вашем примере созданы в одной и той же области видимости и используют одно и то же имя переменной, они всегда ссылаются на одну и ту же переменную.

РЕДАКТИРОВАТЬ: Относительно вашего другого вопроса о том, как преодолеть это, есть два способа, которые приходят на ум:

  1. Самый краткий, но не совсем эквивалентный способ - тот, который рекомендован Адриеном Плиссоном . Создайте лямбду с дополнительным аргументом и установите значение по умолчанию для дополнительного аргумента для объекта, который вы хотите сохранить.

  2. Немного более многословным, но менее хакерским было бы создавать новую область каждый раз, когда вы создаете лямбду:

    >>> 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

    Область действия здесь создается с использованием новой функции (лямбда, для краткости), которая связывает свой аргумент и передает значение, которое вы хотите связать в качестве аргумента. В реальном коде, однако, вы скорее всего будете иметь обычную функцию вместо лямбды для создания новой области видимости:

    def createAdder(x):
        return lambda y: y + x
    adders = [createAdder(i) for i in range(4)]
Макс Шавабке
источник
1
Макс, если ты добавишь ответ на мой другой (более простой вопрос), я могу пометить его как принятый ответ. Спасибо!
Вооз
3
Python имеет статическую область видимости, а не динамическую область видимости. Просто все переменные являются ссылками, поэтому, когда вы устанавливаете переменную для нового объекта, сама переменная (ссылка) имеет то же место, но она указывает на что-то другое. то же самое происходит в схеме, если вы set!. посмотрите, что такое динамическая область действия: voidspace.org.uk/python/articles/code_blocks.shtml .
Клавдиу
6
Вариант 2 напоминает то, что функциональные языки назвали бы «функцией Curried».
Crashworks
205

Вы можете принудительно захватить переменную, используя аргумент со значением по умолчанию:

>>> 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)

Адриен Плиссон
источник
7
+1 за использование значений по умолчанию. Оценивание при определении лямбды делает их идеальными для этого использования.
Quornian
21
+1 также потому, что это решение одобрено официальным FAQ .
Абарнерт
23
Это потрясающе. Однако поведение Python по умолчанию - нет.
Сесил Карри
1
Хотя это не кажется хорошим решением ... вы на самом деле меняете сигнатуру функции просто для того, чтобы получить копию переменной. И те, кто вызывает функцию, могут связываться с переменной i, верно?
Дэвид Калланан
@DavidCallanan мы говорим о лямбда-выражении: это специальная функция, которую вы обычно определяете в своем собственном коде, чтобы закрыть дыру, а не то, чем вы делитесь через весь SDK. если вам нужна более сильная подпись, вы должны использовать реальную функцию.
Эдриан Плиссон
33

Для полноты еще один ответ на ваш второй вопрос: вы можете использовать частичное в модуле functools .

С предложением Криса Лутца, импортирующим оператор 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)
Joma
источник
24

Рассмотрим следующий код:

x = "foo"

def print_x():
    print x

x = "bar"

print_x() # Outputs "bar"

Я думаю, что большинство людей не будет смущать это вообще. Это ожидаемое поведение.

Итак, почему люди думают, что все будет по-другому, когда это будет сделано в цикле? Я знаю, что сделал эту ошибку сам, но я не знаю почему. Это петля? Или, может быть, лямбда?

В конце концов, цикл - это просто более короткая версия:

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
truppo
источник
11
Это цикл, потому что во многих других языках цикл может создавать новую область видимости.
нежно
1
Этот ответ хорош, потому что он объясняет, почему iодна и та же переменная доступна для каждой лямбда-функции.
Дэвид Калланан
3

В ответ на ваш второй вопрос, самый элегантный способ сделать это - использовать функцию, которая принимает два параметра вместо массива:

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

Однако использовать лямбду здесь немного глупо. Python предоставляет нам operatorмодуль, который предоставляет функциональный интерфейс для основных операторов. Лямбда, приведенная выше, имеет ненужные издержки только для вызова оператора сложения:

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)
Крис Лутц
источник
2
Крис, конечно, приведенный выше код не имеет ничего общего с моей первоначальной проблемой. Он построен, чтобы проиллюстрировать мою точку зрения простым способом. Это конечно бессмысленно и глупо.
Вооз
3

Вот новый пример, который подчеркивает структуру данных и содержимое замыкания, чтобы помочь уточнить, когда включающий контекст «сохраняется».

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

Обратите внимание (из адресов памяти), что оба замыкания содержат одни и те же объекты. Итак, вы можете начать думать о лямбда-функции как о ссылке на область видимости. Однако my_str не находится в замыкании для f_1 или f_2, а i не находится в замыкании для f_3 (не показано), что предполагает, что сами объекты замыкания являются отдельными объектами.

Являются ли сами объекты замыкания одним и тем же объектом?

>>> print f_1.func_closure is f_2.func_closure
False
Джефф
источник
NB Вывод int object at [address X]>заставил меня думать, что закрытие хранит [адрес X] АКА ссылку. Однако [адрес X] изменится, если переменная будет переназначена после лямбда-оператора.
Джефф