Як працюють лексичні закриття?


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' для наочності.
яйцесинтакс

7
як щодо того, щоб визначити це так:def inner(x, i=i): return x * i
тире

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
Це геніальне рішення, яке робить його жахливим.
Ставрос Корокітакіс

У цьому рішенні є одна проблема: функція тепер має два параметри. Це означає, що він не працює зі змінною кількістю параметрів. Гірше, якщо ви викликаєте функцію з другим параметром, це замінить оригінал 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, як очікувалося.


Я дуже хотів використовувати це, але моя функція є фактично класовим методом, і перше передане значення - це я. Чи є все-таки навколо цього?
Майкл Девід Уотсон

1
Абсолютно. Припустимо, у вас є клас Math з методом add (self, a, b), і ви хочете встановити a = 1 для створення методу "збільшення". Потім створіть екземпляр класу "my_math", і ваш метод збільшення буде "increment = частковий (my_math.add, 1)".
Лука Інверніцці

2
Для застосування цієї методики ви також можете використовувати functools.partialmethod()python 3.4
Matt Eding

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

1
Моє запитання більш "загальне". Чому Python має цей недолік? Я би сподівався, що мова, що підтримує лексичне закриття (як, наприклад, Перл і вся династія Лісп), це правильно розробить.
Елі Бендерський

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

Погляньте тут для подальшого обговорення цього питання.

[Редагувати] Можливо, кращим способом описати це - уявити цикл 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

Я вже не той, що знаходиться у батьківській області, а абсолютно нова змінна у власній області (тобто параметр для лямбда), і таким чином ви отримуєте поведінку, яку ви спостерігаєте. Python не має цієї неявної нової області застосування, тому тіло циклу for просто розділяє i змінну 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)

Відповідь Клавді досить гарна, використовуючи генератор функцій, але чесно, відповідь piro - це злом, оскільки він перетворює я на «прихований» аргумент із значенням за замовчуванням (це буде добре, але це не «пітонічно») .


Я думаю, це залежить від вашої версії python. Зараз я більш досвідчений, і я б більше не пропонував такий спосіб зробити. Клавдіу - це правильний спосіб зробити закриття в Python.
darkfeline

1
Це не працюватиме ні на Python 2, ні на 3 (обидва вони виводять "4 4 4"). funcВ x * func.iзавжди буде ставитися до останньої функції , визначеної. Тож хоча кожна функція окремо має правильне число, закріплене за нею, вони все-таки закінчуються читанням з останньої.
Фея лямбда
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.