Co wychwytują zamknięcia funkcji (lambda)?

249

Ostatnio zacząłem grać z Pythonem i natrafiłem na coś dziwnego w sposobie działania zamknięć. Rozważ następujący kod:

adders=[0,1,2,3]

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

print adders[1](3)

Buduje prostą tablicę funkcji, które pobierają pojedyncze dane wejściowe i zwracają dane dodane przez liczbę. Funkcje są zbudowane w forpętli, w której iterator ibiegnie od 0do 3. Dla każdej z tych liczb lambdatworzona jest funkcja, która przechwytuje ii dodaje ją do danych wejściowych funkcji. Ostatnia linia wywołuje drugą lambdafunkcję z 3parametrem. Ku mojemu zaskoczeniu wynik był 6.

Spodziewałem się 4. Moje rozumowanie brzmiało: w Pythonie wszystko jest przedmiotem, a zatem każda zmienna jest niezbędnym wskaźnikiem do niej. Podczas tworzenia lambdazamknięć i, spodziewałem się, że zapisze wskaźnik do obiektu liczb całkowitych, na który wskazuje aktualnie i. Oznacza to, że iprzypisany nowy obiekt liczby całkowitej nie powinien wpływać na wcześniej utworzone zamknięcia. Niestety, sprawdzenie adderstablicy w debuggerze pokazuje, że tak. Wszystkie lambdafunkcje znajdują się w ostatniej wartości i, 3, co powoduje adders[1](3)powrót 6.

Zastanawiam się co do następujących rzeczy:

  • Co dokładnie rejestrują zamknięcia?
  • Jaki jest najbardziej elegancki sposób przekonania lambdafunkcji do przechwytywania bieżącej wartości iw sposób, który nie ulegnie zmianie po izmianie jej wartości?
Boaz
źródło
35
Miałem ten problem w kodzie interfejsu użytkownika. Doprowadził mnie do szału. Sztuka polega na tym, aby pamiętać, że pętle nie tworzą nowego zakresu.
detly
3
@TimMB W jaki sposób iopuszcza przestrzeń nazw?
detly
3
@detly Cóż, miałem zamiar powiedzieć, print iże nie będzie działać po pętli. Ale przetestowałem to dla siebie i teraz rozumiem, co masz na myśli - to działa. Nie miałem pojęcia, że ​​zmienne pętli pozostały po pytle w ciele pętli.
Tim MB
1
@TimMB - Tak, o to mi chodziło. Sam dla if, with, tryitd.
detly
13
To jest w oficjalnym FAQ Pythona, w części Dlaczego wszystkie lambdy zdefiniowane w pętli o różnych wartościach zwracają ten sam wynik? , z wyjaśnieniem i zwykłym obejściem.
abarnert

Odpowiedzi:

161

Odpowiedzi na twoje drugie pytanie, ale jak na twoje pierwsze:

co dokładnie rejestruje zamknięcie?

Określanie zakresu w Pythonie jest dynamiczne i leksykalne. Zamknięcie zawsze zapamięta nazwę i zakres zmiennej, a nie obiekt, na który wskazuje. Ponieważ wszystkie funkcje w twoim przykładzie są utworzone w tym samym zakresie i używają tej samej nazwy zmiennej, zawsze odnoszą się do tej samej zmiennej.

EDYCJA: Jeśli chodzi o inne pytanie, jak przezwyciężyć ten problem, przychodzą mi na myśl dwa sposoby:

  1. Najbardziej zwięzły, ale nie do końca równoważny sposób jest zalecany przez Adrien Plisson . Utwórz lambda z dodatkowym argumentem i ustaw domyślną wartość dodatkowego argumentu na obiekt, który chcesz zachować.

  2. Nieco bardziej szczegółowe, ale mniej zhackowane byłoby utworzenie nowego zakresu za każdym razem, gdy tworzysz 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

    Zakres tutaj jest tworzony przy użyciu nowej funkcji (zwięzłej lambda), która wiąże swój argument i przekazuje wartość, którą chcesz powiązać jako argument. Jednak w prawdziwym kodzie najprawdopodobniej będziesz mieć zwykłą funkcję zamiast lambda, aby utworzyć nowy zakres:

    def createAdder(x):
        return lambda y: y + x
    adders = [createAdder(i) for i in range(4)]
Max Shawabkeh
źródło
1
Max, jeśli dodasz odpowiedź na moje drugie (prostsze pytanie), mogę oznaczyć to jako odpowiedź zaakceptowaną. Dzięki!
Boaz,
3
Python ma zakres statyczny, a nie dynamiczny. Po prostu wszystkie zmienne są referencjami, więc kiedy ustawisz zmienną na nowy obiekt, sama zmienna (referencja) ma tę samą lokalizację, ale wskazuje na coś innego. to samo dzieje się w Schemacie, jeśli Ty set!. zobacz tutaj, czym tak naprawdę jest zakres dynamiczny: voidspace.org.uk/python/articles/code_blocks.shtml .
Claudiu
6
Opcja 2 przypomina język funkcjonalny, który nazwałby „funkcją curry”.
Crashworks,
205

możesz wymusić przechwycenie zmiennej za pomocą argumentu o wartości domyślnej:

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

chodzi o to, aby zadeklarować parametr (sprytnie nazwane i) i nadać mu wartość domyślną zmiennej chcesz uchwycić (wartość i)

Adrien Plisson
źródło
7
+1 za użycie wartości domyślnych. Ocena, kiedy zdefiniowana jest lambda, czyni je idealnymi do tego zastosowania.
quornian
21
+1 również dlatego, że jest to rozwiązanie zatwierdzone przez oficjalne FAQ .
abarnert
23
To jest niesamowite. Jednak domyślne zachowanie Pythona nie jest.
Cecil Curry
1
Nie wydaje się to jednak dobrym rozwiązaniem ... tak naprawdę zmieniasz sygnaturę funkcji, aby przechwycić kopię zmiennej. A także osoby wywołujące funkcję mogą zadzierać ze zmienną i, prawda?
David Callanan
@DavidCallanan mówimy o lambda: typie funkcji ad-hoc, którą zwykle definiujesz w swoim własnym kodzie, aby zatkać dziurę, a nie coś, co udostępniasz przez cały sdk. jeśli potrzebujesz silniejszego podpisu, powinieneś użyć prawdziwej funkcji.
Adrien Plisson
33

Dla kompletności kolejna odpowiedź na twoje drugie pytanie: Możesz użyć częściowego w module funkools .

W przypadku importowania add od operatora, jak zaproponował Chris Lutz, przykładem staje się:

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
źródło
24

Rozważ następujący kod:

x = "foo"

def print_x():
    print x

x = "bar"

print_x() # Outputs "bar"

Myślę, że większość ludzi nie uzna tego za mylące. To jest oczekiwane zachowanie.

Dlaczego więc ludzie myślą, że byłoby inaczej, gdy odbywa się to w pętli? Wiem, że sam popełniłem ten błąd, ale nie wiem dlaczego. To jest pętla? A może lambda?

W końcu pętla jest po prostu krótszą wersją:

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
źródło
11
Jest to pętla, ponieważ w wielu innych językach pętla może tworzyć nowy zakres.
detly
1
Ta odpowiedź jest dobra, ponieważ wyjaśnia, dlaczego ta sama izmienna jest dostępna dla każdej funkcji lambda.
David Callanan
3

W odpowiedzi na twoje drugie pytanie najbardziej eleganckim sposobem na zrobienie tego byłoby użycie funkcji, która pobiera dwa parametry zamiast tablicy:

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

Jednak użycie lambda tutaj jest trochę głupie. Python daje nam operatormoduł, który zapewnia funkcjonalny interfejs dla podstawowych operatorów. Powyższa lambda ma niepotrzebny narzut, aby wywołać operatora dodawania:

from operator import add
add(1, 3)

Rozumiem, że bawisz się, próbujesz poznawać język, ale nie wyobrażam sobie sytuacji, w której użyłbym szeregu funkcji, w których przeszkadzałaby dziwna zakresowość Pythona.

Jeśli chcesz, możesz napisać małą klasę, która używa składni indeksowania tablic:

class Adders(object):
    def __getitem__(self, item):
        return lambda a: a + item

adders = Adders()
adders[1](3)
Chris Lutz
źródło
2
Chris, oczywiście powyższy kod nie ma nic wspólnego z moim pierwotnym problemem. Skonstruowano go, aby zilustrować mój punkt widzenia w prosty sposób. Jest to oczywiście bezcelowe i głupie.
Boaz,
3

Oto nowy przykład, który podkreśla strukturę danych i treść zamknięcia, aby pomóc wyjaśnić, kiedy kontekst otaczający jest „zapisywany”.

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

Co jest w zamknięciu?

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

W szczególności, my_str nie jest w zamknięciu F1.

Co zawiera f2?

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

Zauważ (z adresów pamięci), że oba zamknięcia zawierają te same obiekty. Możesz więc zacząć myśleć o funkcji lambda jako o odwołaniu do zakresu. Jednak my_str nie znajduje się w zamknięciu dla f_1 lub f_2, a i nie znajduje się w zamknięciu dla f_3 (nie pokazano), co sugeruje, że same obiekty zamknięcia są odrębnymi obiektami.

Czy same obiekty zamknięcia są tym samym przedmiotem?

>>> print f_1.func_closure is f_2.func_closure
False
Jeff
źródło
NB Dane wyjściowe int object at [address X]>sprawiły, że pomyślałem, że zamknięcie przechowuje [adres X] AKA odniesienie. Jednak [adres X] zmieni się, jeśli zmienna zostanie ponownie przypisana po instrukcji lambda.
Jeff