Jak działają domknięcia leksykalne?


149

Podczas badania problemu, który miałem z zamknięciami leksykalnymi w kodzie JavaScript, napotkałem ten problem w Pythonie:

flist = []

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

for f in flist:
    print f(2)

Zauważ, że ten przykład świadomie unika lambda. Drukuje "4 4 4", co jest zaskakujące. Spodziewałbym się „0 2 4”.

Ten równoważny kod w Perlu robi to dobrze:

my @flist = ();

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

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

Drukowane jest "0 2 4".

Czy możesz wyjaśnić różnicę?


Aktualizacja:

Problem nie jest z ibycia globalnym. To wyświetla to samo zachowanie:

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)

Jak pokazuje skomentowana linia, iw tym momencie nie jest znana. Mimo to wypisuje "4 4 4".



3
Oto całkiem niezły artykuł na ten temat. me.veekun.com/blog/2011/04/24/gotcha-python-scoping-closures
updogliu

Odpowiedzi:


151

Python faktycznie zachowuje się zgodnie z definicją. Tworzone są trzy oddzielne funkcje , ale każda z nich ma zamknięcie środowiska, w którym została zdefiniowana - w tym przypadku środowisko globalne (lub środowisko funkcji zewnętrznej, jeśli pętla jest umieszczona wewnątrz innej funkcji). W tym jednak dokładnie tkwi problem - w tym środowisku i jest zmutowane , a wszystkie zamknięcia odnoszą się do tego samego i .

Tutaj jest najlepszym rozwiązaniem mogę wymyślić - stworzyć creater funkcji i wywołać że zamiast. Wymusi to różne środowiska dla każdej z utworzonych funkcji, z innym i w każdej z nich.

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)

Tak dzieje się, gdy łączysz efekty uboczne i programowanie funkcjonalne.


5
Twoje rozwiązanie jest również używane w Javascript.
Eli Bendersky

9
To nie jest złe zachowanie. Zachowuje się dokładnie tak, jak zdefiniowano.
Alex Coventry,

6
IMO piro ma lepsze rozwiązanie stackoverflow.com/questions/233673/ ...
jfs

2
Może dla jasności zmieniłbym najbardziej wewnętrzne „i” na „j”.
eggsyntax

7
co powiesz na zdefiniowanie tego w ten sposób:def inner(x, i=i): return x * i
dashesy

152

Funkcje zdefiniowane w pętli mają dostęp do tej samej zmiennej i podczas zmiany jej wartości. Na końcu pętli wszystkie funkcje wskazują na tę samą zmienną, która przechowuje ostatnią wartość w pętli: efektem jest to, co podano w przykładzie.

Aby ocenić ii wykorzystać jego wartość, powszechnym wzorcem jest ustawienie go jako domyślnego parametru: wartości domyślne parametrów są oceniane, gdydef instrukcji, a tym samym wartość zmiennej pętli jest zamrażana.

Następujące działa zgodnie z oczekiwaniami:

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 / w czasie kompilacji / w momencie wykonania definstrukcji /
jfs

23
To genialne rozwiązanie, przez co jest okropne.
Stavros Korokithakis

Jest jeden problem z tym rozwiązaniem: func ma teraz dwa parametry. Oznacza to, że nie działa ze zmienną ilością parametrów. Gorzej, jeśli wywołasz func z drugim parametrem, spowoduje to nadpisanie oryginału iz definicji. :-(
Pascal

34

Oto, jak to zrobić, korzystając z functoolsbiblioteki (która nie jestem pewien, była dostępna w momencie zadawania pytania).

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)

Wyjścia 0 2 4, zgodnie z oczekiwaniami.


Naprawdę chciałem tego użyć, ale moja funkcja jest w rzeczywistości metodą klasową, a pierwsza przekazana wartość to self. Czy jest w tym jakiś sposób?
Michael David Watson

1
Absolutnie. Załóżmy, że masz klasę Math z metodą add (self, a, b) i chcesz ustawić a = 1, aby utworzyć metodę „inkrementacji”. Następnie utwórz instancję klasy „my_math”, a metodą inkrementacji będzie „Increment = Partial (my_math.add, 1)”.
Luca Invernizzi

2
Aby zastosować tę technikę do metody, której możesz również użyć functools.partialmethod()od Pythona 3.4
Matt Eding

13

Spójrz na to:

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

Oznacza to, że wszystkie wskazują na tę samą instancję zmiennej i, która po zakończeniu pętli będzie miała wartość 2.

Czytelne rozwiązanie:

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

1
Moje pytanie jest bardziej „ogólne”. Dlaczego Python ma tę wadę? Spodziewałbym się, że język obsługujący zamknięcia leksykalne (jak Perl i cała dynastia Lispów) rozwiąże to poprawnie.
Eli Bendersky

2
Pytanie, dlaczego coś ma wadę, oznacza założenie, że nie jest to wada.
Null303

7

Dzieje się tak, że zmienna i jest przechwytywana, a funkcje zwracają wartość, do której jest przypisana w momencie wywołania. W językach funkcjonalnych taka sytuacja nigdy się nie zdarza, ponieważ nie dałbym rady. Jednak w przypadku Pythona, a także, jak widzieliście w przypadku seplenienia, nie jest to już prawdą.

Różnica w stosunku do twojego przykładu schematu polega na semantyce pętli do. Scheme efektywnie tworzy nową zmienną i za każdym razem w pętli, zamiast ponownie używać istniejącego wiązania i, tak jak w przypadku innych języków. Jeśli użyjesz innej zmiennej utworzonej poza pętlą i zmienisz ją, zobaczysz to samo zachowanie w schemacie. Spróbuj zamienić pętlę na:

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

Spójrz tutaj, aby dowiedzieć się więcej na ten temat.

[Edytuj] Prawdopodobnie lepszym sposobem opisania tego jest myślenie o pętli do jako o makrze, które wykonuje następujące kroki:

  1. Zdefiniuj lambdę, przyjmując pojedynczy parametr (i), z treścią zdefiniowaną przez ciało pętli,
  2. Natychmiastowe wywołanie tej lambdy z odpowiednimi wartościami i jako jej parametru.

to znaczy. odpowiednik poniższego pythona:

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 nie jest już tą z zakresu nadrzędnego, ale zupełnie nową zmienną w swoim własnym zakresie (tj. Parametr do lambda), więc otrzymujesz obserwowane zachowanie. Python nie ma tego niejawnego nowego zakresu, więc treść pętli for po prostu współużytkuje zmienną i.


Ciekawy. Nie byłem świadomy różnicy w semantyce pętli do. Dzięki
Eli Bendersky,

4

Nadal nie jestem do końca przekonany, dlaczego w niektórych językach działa to w jeden, a w inny sposób. W Common Lisp jest jak Python:

(defvar *flist* '())

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

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

Wyświetla "6 6 6" (zwróć uwagę, że tutaj lista jest od 1 do 3 i jest zbudowana w odwrotnej kolejności). W Scheme działa jak w Perlu:

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

Wyświetla „6 4 2”

Jak już wspomniałem, Javascript znajduje się w obozie Python / CL. Wygląda na to, że podjęto tutaj decyzję wdrożeniową, w której różne języki podchodzą na różne sposoby. Bardzo chciałbym dokładnie zrozumieć, jaka jest decyzja.


8
Różnica polega na (do ...), a nie w zasadach określania zakresu. W schemacie tworzy nową zmienną przy każdym przejściu przez pętlę, podczas gdy inne języki ponownie wykorzystują istniejące powiązanie. Zobacz moją odpowiedź, aby uzyskać więcej szczegółów i przykład wersji schematu z podobnym zachowaniem do lisp / python.
Brian,

2

Problem polega na tym, że wszystkie funkcje lokalne wiążą się z tym samym środowiskiem, a więc z tą samą izmienną. Rozwiązaniem (obejściem) jest utworzenie oddzielnych środowisk (ramek stosu) dla każdej funkcji (lub lambda):

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

>>> t[1](2)
2
>>> t[2](2)
4

1

Zmienna ijest globalna, której wartość wynosi 2 za każdym razem, gdy funkcjaf .

Byłbym skłonny zaimplementować zachowanie, którego szukasz, w następujący sposób:

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

Odpowiedź na twoją aktualizację : To nie globalność i per se powoduje to zachowanie, to fakt, że jest to zmienna z otaczającego zakresu, która ma stałą wartość w czasie wywołania f. W drugim przykładzie wartość ijest pobierana z zakresu kkkfunkcji i nic tego nie zmienia, gdy wywołujesz funkcje flist.


0

Powód tego zachowania został już wyjaśniony i opublikowano wiele rozwiązań, ale myślę, że jest to najbardziej Pythonowe (pamiętaj, wszystko w Pythonie jest obiektem!):

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)

Odpowiedź Claudiu jest całkiem dobra, używając generatora funkcji, ale odpowiedź piro to hack, szczerze mówiąc, ponieważ zmienia i w „ukryty” argument z wartością domyślną (będzie działać dobrze, ale nie jest „pythonowy”) .


Myślę, że to zależy od twojej wersji Pythona. Teraz jestem bardziej doświadczony i nie sugerowałbym już tego sposobu. Claudiu to właściwy sposób na zamknięcie w Pythonie.
darkfeline,

1
To nie zadziała ani na Pythonie 2, ani na 3 (oba wyświetlają "4 4 4"). funcNa x * func.izawsze odnoszą się do ostatniej funkcji zdefiniowanej. Więc nawet jeśli każda funkcja z osobna ma przypięty prawidłowy numer, i tak wszystkie kończą czytanie od ostatniej.
Lambda Fairy
Korzystając z naszej strony potwierdzasz, że przeczytałeś(-aś) i rozumiesz nasze zasady używania plików cookie i zasady ochrony prywatności.
Licensed under cc by-sa 3.0 with attribution required.