Как работают лексические замыкания?

149

Пока я исследовал проблему с лексическими замыканиями в коде Javascript, я столкнулся с этой проблемой в Python:

flist = []

for i in xrange(3):
    def func(x): return x * i
    flist.append(func)

for f in flist:
    print f(2)

Обратите внимание, что этот пример осторожно избегает lambda. На нем печатается «4 4 4», что удивительно. Я ожидаю "0 2 4".

Этот эквивалентный код Perl делает это правильно:

my @flist = ();

foreach my $i (0 .. 2)
{
    push(@flist, sub {$i * $_[0]});
}

foreach my $f (@flist)
{
    print $f->(2), "\n";
}

«0 2 4» печатается.

Можете ли вы объяснить разницу?


Обновить:

Проблема не с iтого глобальными. Это отображает то же поведение:

flist = []

def outer():
    for i in xrange(3):
        def inner(x): return x * i
        flist.append(inner)

outer()
#~ print i   # commented because it causes an error

for f in flist:
    print f(2)

Как показывает закомментированная строка, iв данный момент неизвестно. Тем не менее, он печатает «4 4 4».

Эли Бендерский
источник
3
Вот довольно хорошая статья по этому вопросу. me.veekun.com/blog/2011/04/24/gotcha-python-scoping-closures
updogliu

Ответы:

151

Python на самом деле ведет себя как определено. Создаются три отдельные функции , но каждая из них имеет замыкание среды, в которой они определены, - в данном случае, глобальной среды (или среды внешней функции, если цикл размещен внутри другой функции). Это как раз и есть проблема - в этой среде я мутирован , и все замыкания ссылаются на одно и то же i .

Вот лучшее решение , которое я могу придумать - создать функцию созидатель и вызов , что вместо этого. Это заставит разные среды для каждой из созданных функций, с разными i в каждой.

flist = []

for i in xrange(3):
    def funcC(j):
        def func(x): return x * j
        return func
    flist.append(funcC(i))

for f in flist:
    print f(2)

Это то, что происходит, когда вы смешиваете побочные эффекты и функциональное программирование.

Клаудиу
источник
5
Ваше решение также используется в Javascript.
Эли Бендерский
9
Это не плохое поведение. Он ведет себя точно так, как определено.
Алекс Ковентри
6
ИМО Пиро имеет лучшее решение stackoverflow.com/questions/233673/…
jfs
2
Возможно, я бы поменял внутреннее «я» на «j» для ясности.
eggyntax
7
как насчет того, чтобы просто определить это так:def inner(x, i=i): return x * i
dashesy
152

Функции, определенные в цикле, продолжают обращаться к одной и той же переменной, iпока ее значение не изменится. В конце цикла все функции указывают на одну и ту же переменную, которая содержит последнее значение в цикле: эффект - это то, что сообщается в примере.

Чтобы оценить iи использовать его значение, общий шаблон должен установить его как параметр по умолчанию: значения параметров по умолчанию оцениваются, когда выполняется defинструкция, и, таким образом, значение переменной цикла замораживается.

Следующее работает как ожидалось:

flist = []

for i in xrange(3):
    def func(x, i=i): # the *value* of i is copied in func() environment
        return x * i
    flist.append(func)

for f in flist:
    print f(2)
Пиро
источник
7
s / во время компиляции / в момент выполнения defоператора /
jfs
23
Это гениальное решение, которое делает его ужасным.
Ставрос Корокитакис
В этом решении есть одна проблема: у func теперь есть два параметра. Это означает, что он не работает с переменным количеством параметров. Хуже того, если вы вызовете func со вторым параметром, это перезапишет оригинал iиз определения. :-(
Паскаль
34

Вот как вы это делаете, используя functoolsбиблиотеку (которая, я не уверен, была доступна на момент постановки вопроса).

from functools import partial

flist = []

def func(i, x): return x * i

for i in xrange(3):
    flist.append(partial(func, i))

for f in flist:
    print f(2)

Выходы 0 2 4, как и ожидалось.

Люка Инвернизци
источник
Я действительно хотел использовать это, но моя функция на самом деле является методом класса, и первое переданное значение - это self. Есть ли что-нибудь вокруг этого?
Майкл Дэвид Уотсон
1
Абсолютно. Предположим, у вас есть класс Math с методом add (self, a, b), и вы хотите установить a = 1, чтобы создать метод increment. Затем создайте экземпляр своего класса «my_math», и ваш метод приращения будет «increment = частичный (my_math.add, 1)».
Лука Инвернизци
2
Чтобы применить эту технику к методу, вы также можете использовать functools.partialmethod()в Python 3.4
Мэтт Эдинг
13

посмотри на это:

for f in flist:
    print f.func_closure


(<cell at 0x00C980B0: int object at 0x009864B4>,)
(<cell at 0x00C980B0: int object at 0x009864B4>,)
(<cell at 0x00C980B0: int object at 0x009864B4>,)

Это означает, что все они указывают на один и тот же экземпляр переменной i, который после завершения цикла будет иметь значение 2.

Читаемое решение:

for i in xrange(3):
        def ffunc(i):
            def func(x): return x * i
            return func
        flist.append(ffunc(i))
Null303
источник
1
Мой вопрос более "общий". Почему у Python такой недостаток? Я ожидаю, что язык, поддерживающий лексические замыкания (например, Perl и вся династия Лиспов), будет работать правильно.
Эли Бендерский
2
На вопрос, почему у чего-то есть недостаток, предполагается, что это не недостаток.
Null303
7

Что происходит, так это то, что переменная i захвачена, и функции возвращают значение, к которому она привязана во время ее вызова. На функциональных языках такого рода ситуация никогда не возникает, так как я бы не вернулся. Однако с python, а также, как вы видели с lisp, это больше не так.

Разница с вашим примером схемы состоит в том, чтобы делать с семантикой цикла do. Схема эффективно создает новую переменную i каждый раз в цикле, а не повторно использует существующую привязку i, как в других языках. Если вы используете другую переменную, созданную вне цикла, и измените ее, вы увидите то же поведение в схеме. Попробуйте заменить ваш цикл на:

(let ((ii 1)) (
  (do ((i 1 (+ 1 i)))
      ((>= i 4))
    (set! flist 
      (cons (lambda (x) (* ii x)) flist))
    (set! ii i))
))

Посмотрите здесь для дальнейшего обсуждения этого.

[Edit] Возможно, лучший способ описать это - думать о цикле do как о макросе, который выполняет следующие шаги:

  1. Определите лямбду, принимающую единственный параметр (i), с телом, определенным телом цикла,
  2. Немедленный вызов этой лямбды с соответствующими значениями i в качестве параметра.

то есть. эквивалент приведенного ниже питона:

flist = []

def loop_body(i):      # extract body of the for loop to function
    def func(x): return x*i
    flist.append(func)

map(loop_body, xrange(3))  # for i in xrange(3): body

I больше не тот из родительской области видимости, а совершенно новая переменная в своей области (т. Е. Параметр лямбда-выражения), и поэтому вы получаете поведение, которое вы наблюдаете. В Python нет этой неявной новой области видимости, поэтому тело цикла for просто использует переменную i.

Брайан
источник
Интересный. Я не знал о разнице в семантике цикла do. Спасибо
Эли Бендерский
4

Я до сих пор не до конца убежден, почему в некоторых языках это работает по-другому, а по-другому. В Common Lisp это похоже на Python:

(defvar *flist* '())

(dotimes (i 3 t)
  (setf *flist* 
    (cons (lambda (x) (* x i)) *flist*)))

(dolist (f *flist*)  
  (format t "~a~%" (funcall f 2)))

Выводит «6 6 6» (обратите внимание, что здесь список от 1 до 3 и построен в обратном порядке »). В то время как в схеме он работает как в Perl:

(define flist '())

(do ((i 1 (+ 1 i)))
    ((>= i 4))
  (set! flist 
    (cons (lambda (x) (* i x)) flist)))

(map 
  (lambda (f)
    (printf "~a~%" (f 2)))
  flist)

Принты "6 4 2"

И, как я уже упоминал, Javascript находится в лагере Python / CL. Похоже, здесь есть решение о реализации, к которому разные языки подходят по-разному. Я хотел бы понять, какое именно решение, точно.

Эли Бендерский
источник
8
Разница в (делать ...), а не в правилах. В схеме do создает новую переменную при каждом прохождении цикла, в то время как другие языки повторно используют существующую привязку. Смотрите мой ответ для более подробной информации и примера версии схемы с поведением, аналогичным lisp / python.
Брайан
2

Проблема в том, что все локальные функции связаны с одной и той же средой и, следовательно, с одной и той же iпеременной. Решение (обходной путь) заключается в создании отдельных сред (стековых фреймов) для каждой функции (или лямбда):

t = [ (lambda x: lambda y : x*y)(x) for x in range(5)]

>>> t[1](2)
2
>>> t[2](2)
4
Рафал Доугирд
источник
1

Переменная iявляется глобальной, значение которой равно 2 при каждом fвызове функции .

Я был бы склонен реализовать ваше поведение следующим образом:

>>> class f:
...  def __init__(self, multiplier): self.multiplier = multiplier
...  def __call__(self, multiplicand): return self.multiplier*multiplicand
... 
>>> flist = [f(i) for i in range(3)]
>>> [g(2) for g in flist]
[0, 2, 4]

Ответ на ваше обновление : это не глобальность i как таковая, которая вызывает такое поведение, это факт, что это переменная из охватывающей области, которая имеет фиксированное значение во время вызова f. Во втором примере значение iберется из области действия kkkфункции, и при вызове функций ничего не меняется flist.

Алекс Ковентри
источник
0

Причины такого поведения уже были объяснены, и было опубликовано несколько решений, но я думаю, что это самое питоническое (помните, что все в Python является объектом!):

flist = []

for i in xrange(3):
    def func(x): return x * func.i
    func.i=i
    flist.append(func)

for f in flist:
    print f(2)

Ответ Клаудиу довольно хороший, с использованием генератора функций, но, честно говоря, ответ Пиро - хак, поскольку он превращает меня в «скрытый» аргумент со значением по умолчанию (он будет работать нормально, но не «питонно») ,

darkfeline
источник
Я думаю, это зависит от вашей версии Python. Теперь я более опытный и больше не буду предлагать такой способ. Клаудиу это правильный способ сделать замыкание в Python.
даркфелайн
1
Это не будет работать ни на Python 2, ни на 3 (они оба выводят «4 4 4»). funcВ x * func.iвсегда будет относиться к последней функции , определенной. Таким образом, даже несмотря на то, что каждая функция в отдельности имеет правильный номер, она все равно заканчивает тем, что читает из последней.
Лямбда-фея