23. Python – Closures (Domknięcia)


Szybki quiz – przypomnienie poprzedniej lekcji

Pytanie 1. Poniższy program uruchamia się bez żadnego komunikatu błędu. Jaki rodzaj błędu zawiera i dlaczego trudno go wykryć?

def srednia(liczby):
    return sum(liczby) / len(liczby) + 1

print(srednia([2, 4]))
Odpowiedź

Odpowiedź: To błąd logiczny. Program działa, ale wypisuje 4.0 zamiast 3.0. Python nie zgłasza wyjątku, więc trzeba sprawdzić wynik samodzielnie, np. printem, debuggerem lub testem.

Pytanie 2. Przeanalizuj komunikat błędu. Od której linii zaczynasz czytanie, jaki to typ błędu i w której linii pliku program.py został on zgłoszony?

Traceback (most recent call last):
  File "program.py", line 10, in <module>
    wynik = oblicz(5, 0)
  File "program.py", line 6, in oblicz
    return a / b
ZeroDivisionError: division by zero
Odpowiedź

Odpowiedź: Czytamy od dołu. Ostatnia linia podaje typ błędu (ZeroDivisionError). Błąd zgłoszono w linii 6, w funkcji oblicz. Wywołano ją z linii 10.

Pytanie 3. Jaki wyjątek zgłosi każdy z tych fragmentów?

# a)
lista = [1, 2, 3]
print(lista[3])

# b)
slownik = {"a": 1}
print(slownik["b"])

# c)
print("5" + 5)

# d)
lista.add(4)
Odpowiedź

Odpowiedź: a) IndexError, b) KeyError, c) TypeError, d) AttributeError.

Pytanie 4. Co wyświetli ten program?

def podziel(a, b):
    assert b != 0, "Dzielnik nie może być zerem!"
    return a / b

print(podziel(10, 2))
print(podziel(10, 0))
Odpowiedź

Odpowiedź: Najpierw 5.0. Drugie wywołanie przerwie program błędem AssertionError: Dzielnik nie może być zerem!.

Pytanie 5. Zatrzymałeś program w pdb na linii, w której wywoływana jest funkcja. Którą komendą wejdziesz do środka tej funkcji, a którą przejdziesz do następnej linii bez wchodzenia? Jakie klawisze robią to samo w VS Code?

Odpowiedź

Odpowiedź: s (step) wchodzi do funkcji, n (next) przechodzi do następnej linii. W VS Code to odpowiednio F11 (step into) i F10 (step over).

Pytanie 6. Dobierz poziom logowania (DEBUG, INFO, ERROR) do komunikatów:
a) "Suma: 60" (potwierdzenie, że wszystko działa),
b) "Lista jest pusta!" (poważny problem),
c) "Wywołano oblicz_srednia z [10, 20, 30]" (szczegół diagnostyczny).

Odpowiedź

Odpowiedź: a) INFO, b) ERROR, c) DEBUG.


Closure (domknięcie) to funkcja, która „zapamiętuje” zmienne z zakresu zewnętrznego, nawet po zakończeniu wykonywania funkcji zewnętrznej.

Kluczowe elementy:

  • Funkcja zagnieżdżona (wewnątrz innej funkcji)
  • Funkcja wewnętrzna odwołuje się do zmiennych zewnętrznych
  • Funkcja zewnętrzna zwraca funkcję wewnętrzną

Dlaczego closures są ważne?

  • Enkapsulacja danych
  • Tworzenie funkcji fabryk
  • Dekoratory
  • Callbacks i event handlery

Podstawy – funkcje zagnieżdżone

def zewnetrzna():
    """Funkcja zewnętrzna"""
    wiadomosc = "Hello"
    
    def wewnetrzna():
        """Funkcja wewnętrzna"""
        print(wiadomosc)  # Dostęp do zmiennej zewnętrznej
    
    wewnetrzna()

zewnetrzna()  # Hello

Zwracanie funkcji wewnętrznej

def zewnetrzna():
    wiadomosc = "Hello"
    
    def wewnetrzna():
        print(wiadomosc)
    
    return wewnetrzna  # Zwracamy funkcję (bez nawiasów!)

# Przypisz funkcję do zmiennej
funkcja = zewnetrzna()

# Wywołaj
funkcja()  # Hello

Ważne: Funkcja wewnetrzna „zapamiętała” zmienną wiadomosc mimo że zewnetrzna() już się zakończyła!

Pierwszy prawdziwy closure

def stworz_powitanie(pozdrowienie):
    """Funkcja fabrykująca - tworzy spersonalizowane powitania"""
    
    def powitaj(imie):
        return f"{pozdrowienie}, {imie}!"
    
    return powitaj

# Stwórz różne funkcje powitań
hello = stworz_powitanie("Hello")
czesc = stworz_powitanie("Cześć")
hola = stworz_powitanie("Hola")

# Użycie
print(hello("Anna"))   # Hello, Anna!
print(czesc("Jan"))    # Cześć, Jan!
print(hola("Maria"))   # Hola, Maria!

Co się dzieje:

  1. stworz_powitanie("Hello") tworzy funkcję powitaj która „pamięta” pozdrowienie = "Hello"
  2. Każde wywołanie tworzy niezależne closure z własną wartością

Closure z wieloma zmiennymi

def licznik(start=0):
    """Tworzy licznik zaczynający od start"""
    count = start
    
    def increment():
        nonlocal count  # Pozwala modyfikować zmienną zewnętrzną
        count += 1
        return count
    
    return increment

# Stwórz liczniki
licznik1 = licznik(0)
licznik2 = licznik(100)

print(licznik1())  # 1
print(licznik1())  # 2
print(licznik1())  # 3

print(licznik2())  # 101
print(licznik2())  # 102

Kluczowe słowo nonlocal:

  • Bez nonlocal – Python tworzy nową lokalną zmienną
  • Z nonlocal – modyfikuje zmienną z zakresu zewnętrznego

Przykład: Mnożnik

def stworz_mnoznik(n):
    """Tworzy funkcję mnożącą przez n"""
    
    def mnoz(x):
        return x * n
    
    return mnoz

# Stwórz różne mnożniki
razy_2 = stworz_mnoznik(2)
razy_5 = stworz_mnoznik(5)
razy_10 = stworz_mnoznik(10)

print(razy_2(10))   # 20
print(razy_5(10))   # 50
print(razy_10(10))  # 100

Przykład: Funkcja potęgująca

def stworz_potege(wykladnik):
    """Tworzy funkcję podnoszącą do potęgi"""
    
    def poteguj(podstawa):
        return podstawa ** wykladnik
    
    return poteguj

# Różne potęgi
kwadrat = stworz_potege(2)
szescian = stworz_potege(3)
czwarta = stworz_potege(4)

print(kwadrat(5))   # 25
print(szescian(5))  # 125
print(czwarta(2))   # 16

Closure ze stanem

def stworz_konto(wlasciciel, saldo_poczatkowe=0):
    """Tworzy konto bankowe z enkapsulacją"""
    saldo = saldo_poczatkowe
    
    def wplata(kwota):
        nonlocal saldo
        if kwota > 0:
            saldo += kwota
            return f"{wlasciciel}: Wpłata {kwota}. Saldo: {saldo}"
        return "Kwota musi być dodatnia"
    
    def wyplata(kwota):
        nonlocal saldo
        if kwota > saldo:
            return f"{wlasciciel}: Brak środków. Saldo: {saldo}"
        if kwota > 0:
            saldo -= kwota
            return f"{wlasciciel}: Wypłata {kwota}. Saldo: {saldo}"
        return "Kwota musi być dodatnia"
    
    def sprawdz_saldo():
        return f"{wlasciciel}: Saldo: {saldo}"
    
    # Zwróć słownik funkcji
    return {
        'wplata': wplata,
        'wyplata': wyplata,
        'saldo': sprawdz_saldo
    }

# Stwórz konta
konto_jan = stworz_konto("Jan", 1000)
konto_anna = stworz_konto("Anna", 500)

# Użycie
print(konto_jan['saldo']())        # Jan: Saldo: 1000
print(konto_jan['wplata'](500))    # Jan: Wpłata 500. Saldo: 1500
print(konto_jan['wyplata'](200))   # Jan: Wypłata 200. Saldo: 1300

print(konto_anna['saldo']())       # Anna: Saldo: 500

Zalety:

  • Dane są prywatne (nie można bezpośrednio modyfikować saldo)
  • Każde konto ma niezależny stan
  • Enkapsulacja bez klas

Closure vs Klasy

Z klasą:

class Licznik:
    def __init__(self, start=0):
        self.count = start
    
    def increment(self):
        self.count += 1
        return self.count

licznik = Licznik(0)
print(licznik.increment())  # 1

Z closure:

def licznik(start=0):
    count = start
    
    def increment():
        nonlocal count
        count += 1
        return count
    
    return increment

licznik = licznik(0)
print(licznik())  # 1

Kiedy używać:

  • Closure – proste przypadki, enkapsulacja jednej funkcji
  • Klasy – złożone obiekty, wiele metod, dziedziczenie

Praktyczne zastosowania

1. Dekoratory (najczęstsze!)

def mierz_czas(funkcja):
    """Dekorator mierzący czas wykonania"""
    import time
    
    def wrapper(*args, **kwargs):
        start = time.time()
        wynik = funkcja(*args, **kwargs)
        koniec = time.time()
        print(f"{funkcja.__name__} wykonała się w {koniec - start:.4f}s")
        return wynik
    
    return wrapper

@mierz_czas
def wolna_funkcja():
    import time
    time.sleep(1)
    return "Gotowe"

wynik = wolna_funkcja()
# wolna_funkcja wykonała się w 1.0012s

2. Funkcje fabrykujące

def stworz_validator(min_dlugosc, max_dlugosc):
    """Tworzy validator długości"""
    
    def waliduj(tekst):
        dlugosc = len(tekst)
        if dlugosc < min_dlugosc:
            return False, f"Za krótkie (min: {min_dlugosc})"
        if dlugosc > max_dlugosc:
            return False, f"Za długie (max: {max_dlugosc})"
        return True, "OK"
    
    return waliduj

# Stwórz różne validatory
waliduj_haslo = stworz_validator(8, 20)
waliduj_nick = stworz_validator(3, 15)

print(waliduj_haslo("abc"))      # (False, 'Za krótkie (min: 8)')
print(waliduj_haslo("Python123")) # (True, 'OK')
print(waliduj_nick("Jo"))        # (False, 'Za krótkie (min: 3)')

3. Callbacks z kontekstem

def stworz_button_handler(button_id):
    """Tworzy handler dla przycisku"""
    klikniecia = 0
    
    def on_click():
        nonlocal klikniecia
        klikniecia += 1
        print(f"Przycisk {button_id} kliknięty {klikniecia} razy")
    
    return on_click

# Stwórz handlery
button1_click = stworz_button_handler("Zapisz")
button2_click = stworz_button_handler("Anuluj")

# Symulacja kliknięć
button1_click()  # Przycisk Zapisz kliknięty 1 razy
button1_click()  # Przycisk Zapisz kliknięty 2 razy
button2_click()  # Przycisk Anuluj kliknięty 1 razy

4. Memoizacja (cache wyników)

def memoize(funkcja):
    """Closure cache'ujące wyniki funkcji"""
    cache = {}
    
    def wrapper(*args):
        if args in cache:
            print(f"Zwracam z cache dla {args}")
            return cache[args]
        
        print(f"Obliczam dla {args}")
        wynik = funkcja(*args)
        cache[args] = wynik
        return wynik
    
    return wrapper

@memoize
def fibonacci(n):
    if n < 2:
        return n
    return fibonacci(n-1) + fibonacci(n-2)

print(fibonacci(5))
# Obliczam dla (5,)
# Obliczam dla (4,)
# Obliczam dla (3,)
# Obliczam dla (2,)
# Obliczam dla (1,)
# Obliczam dla (0,)
# Zwracam z cache dla (1,)
# Zwracam z cache dla (2,)
# Zwracam z cache dla (3,)
# 5

5. Rate limiting

def rate_limiter(max_calls, time_window):
    """Ogranicza liczbę wywołań funkcji w czasie"""
    import time
    calls = []
    
    def decorator(funkcja):
        def wrapper(*args, **kwargs):
            nonlocal calls
            teraz = time.time()
            
            # Usuń stare wywołania
            calls = [c for c in calls if teraz - c < time_window]
            
            if len(calls) >= max_calls:
                return f"Za dużo wywołań! Max {max_calls} w {time_window}s"
            
            calls.append(teraz)
            return funkcja(*args, **kwargs)
        
        return wrapper
    return decorator

@rate_limiter(max_calls=3, time_window=10)
def wyslij_email(adres):
    return f"Email wysłany do {adres}"

# Pierwsze 3 wywołania - OK
print(wyslij_email("jan@test.com"))
print(wyslij_email("anna@test.com"))
print(wyslij_email("piotr@test.com"))

# 4-te - za dużo
print(wyslij_email("ewa@test.com"))
# Za dużo wywołań! Max 3 w 10s

Inspekcja closure

def zewnetrzna(x):
    def wewnetrzna(y):
        return x + y
    return wewnetrzna

closure_func = zewnetrzna(10)

# Sprawdź co closure "zapamiętała"
print(closure_func.__closure__)
# (<cell at 0x...: int object at 0x...>,)

# Wartość
print(closure_func.__closure__[0].cell_contents)  # 10

Podsumowanie Closures

Definicja:

  • Funkcja zagnieżdżona
  • Używa zmiennych z funkcji zewnętrznej
  • Jest zwracana przez funkcję zewnętrzną

Kluczowe słowa:

  • nonlocal – modyfikuj zmienną zewnętrzną

Zastosowania:

  • Dekoratory
  • Funkcje fabrykujące
  • Enkapsulacja danych
  • Callbacks z kontekstem
  • Memoizacja

Zalety:

  • Prywatne dane
  • Prostsza składnia niż klasy
  • Funkcyjne programowanie

Ćwiczenia

  1. Napisz closure zwracające funkcję mnożącą przez dowolną liczbę.
  2. Napisz closure tworzące licznik (increment, decrement, reset).
  3. Napisz closure zapisujące historię wywołań funkcji.
  4. Stwórz funkcję fabrykującą validatory (min długość, max długość).
  5. Napisz closure implementujące prostą cache (memoizację).
  6. Stwórz closure z prywatnym stanem (np. konto bankowe).
  7. Napisz closure logujące wywołania funkcji.
  8. Stwórz closure obliczające średnią ruchomą.
  9. Napisz closure rate limitera (max wywołań w czasie).
  10. Stwórz closure generujące unikalne ID.

PODSUMOWANIE

  • Closure to funkcja wewnętrzna, która zapamiętuje zmienne z funkcji zewnętrznej, nawet gdy funkcja zewnętrzna już się zakończyła.
  • Closure powstaje, gdy spełnione są trzy warunki:
    1. funkcja jest zagnieżdżona,
    2. funkcja wewnętrzna używa zmiennej z funkcji zewnętrznej,
    3. funkcja zewnętrzna zwraca funkcję wewnętrzną.
  • Zwracamy samą funkcję, bez nawiasów: return wewnetrzna. Zapis return wewnetrzna() zwróciłby wynik wywołania, a nie funkcję.
  • Każde wywołanie funkcji zewnętrznej tworzy osobny, niezależny closure z własnymi zapamiętanymi wartościami (razy_2 i razy_5 się nie mieszają).
  • Samo odczytywanie zmiennej zewnętrznej nie wymaga niczego dodatkowego. Do zmiany jej wartości (+=, -=, =) potrzebne jest nonlocal.
  • Bez nonlocal instrukcja typu count += 1 w funkcji wewnętrznej kończy się błędem UnboundLocalError.
  • Kilka funkcji zwróconych z jednego wywołania (np. wplata, wyplata, saldo w koncie) współdzieli te same zmienne zewnętrzne.
  • Zapamiętany stan jest “prywatny”: nie ma do niego bezpośredniego dostępu spoza funkcji. Closure to więc enkapsulacja bez klas.
  • Closure sprawdza się w prostych przypadkach (jedna lub kilka funkcji ze stanem). Gdy potrzeba wielu metod i dziedziczenia, lepsza jest klasa.
  • Dekoratory, memoizacja i handlery korzystają z closures: wrapper zapamiętuje funkcja przekazaną do dekoratora.

Minimum, które powinno wejść w pamięć

Jeżeli masz zapamiętać tylko kilka rzeczy z tej lekcji, zapamiętaj te:

  1. Closure = funkcja zagnieżdżona + używa zmiennej z zewnątrz + jest zwracana.
  2. Zwracasz funkcję bez nawiasów: return wewnetrzna.
  3. Każde wywołanie fabryki tworzy osobny closure z własnym stanem.
  4. Zmieniasz zmienną zewnętrzną? Napisz nonlocal nazwa. Tylko ją czytasz? nonlocal nie jest potrzebne.
  5. Brak nonlocal przy += daje UnboundLocalError.
  6. Funkcje zwrócone razem (np. w słowniku) współdzielą zmienne zewnętrzne.
  7. @dekorator nad funkcją działa jak funkcja = dekorator(funkcja). Dekorator jest closure z funkcją wrapper.
  8. Closure ma sens przy prostym stanie. Przy wielu metodach i dziedziczeniu używamy klasy.

Ściągawka – najważniejsze polecenia i składnia

Fabryka funkcji (bez stanu):

def fabryka(parametr):
    def wewnetrzna(x):
        return x * parametr      # używa zmiennej z zewnątrz
    return wewnetrzna            # bez nawiasów!

razy_2 = fabryka(2)
print(razy_2(10))                # 20

Closure ze stanem (nonlocal):

def licznik(start=0):
    count = start
    def increment():
        nonlocal count           # bez tego: UnboundLocalError
        count += 1
        return count
    return increment

Kilka funkcji ze wspólnym stanem:

def stworz_cos():
    stan = 0
    def f1(): ...
    def f2(): ...
    return {"f1": f1, "f2": f2}  # użycie: obiekt["f1"]()

Dekorator (schemat):

def dekorator(funkcja):
    def wrapper(*args, **kwargs):
        wynik = funkcja(*args, **kwargs)
        return wynik
    return wrapper

@dekorator
def moja(): ...

Inspekcja closure:

f.__closure__                    # krotka komórek zapamiętanych zmiennych
f.__closure__[0].cell_contents   # wartość pierwszej zapamiętanej zmiennej

Typowe błędy:

Błąd w kodzieSkutek
return wewnetrzna() zamiast return wewnetrznazwracany jest wynik, a nie funkcja (potem TypeError: ... is not callable)
brak nonlocal przy += / -=UnboundLocalError
brak return wewnetrznafabryka zwraca None
wywołanie fabryka(2, 10) zamiast fabryka(2)(10)TypeError (zła liczba argumentów)

Quiz – sprawdź się

Pytanie 1. (teoria) Wymień trzy warunki, które musi spełniać closure. Kiedy zamiast closure lepiej użyć klasy?

Pytanie 2. (co zostanie wyświetlone?)

def stworz_mnoznik(n):
    def mnoz(x):
        return x * n
    return mnoz

razy_3 = stworz_mnoznik(3)
razy_4 = stworz_mnoznik(4)
print(razy_3(5), razy_4(5))

Pytanie 3. (co zostanie wyświetlone?)

def licznik(start=0):
    count = start
    def increment():
        nonlocal count
        count += 1
        return count
    return increment

a = licznik()
b = licznik(10)
print(a(), a(), b(), a())

Pytanie 4. (dlaczego ten kod nie działa?) Jaki błąd zgłosi program i jak go naprawić?

def licznik():
    count = 0
    def increment():
        count += 1
        return count
    return increment

c = licznik()
print(c())

Pytanie 5. (znajdź błąd) Program kończy się błędem w ostatniej linii. Wskaż przyczynę i popraw jedną linię.

def stworz_powitanie(pozdrowienie):
    def powitaj(imie):
        return f"{pozdrowienie}, {imie}!"
    return powitaj("Anna")

hello = stworz_powitanie("Hello")
print(hello("Jan"))

Pytanie 6. (uzupełnij kod) Co wpisać w miejsce ____, aby program wypisał 25?

def stworz_potege(wykladnik):
    def poteguj(podstawa):
        return ____ ** ____
    return ____

kwadrat = stworz_potege(2)
print(kwadrat(5))

Pytanie 7. (która wersja jest poprawna?) Chcesz obliczyć 5 do potęgi 3 za pomocą funkcji stworz_potege z pytania 6. Która wersja da wynik 125?

# A
print(stworz_potege(3)(5))

# B
print(stworz_potege(5)(3))

# C
print(stworz_potege(5, 3))

Pytanie 8. (przewidywanie działania programu) Co wyświetli ten program? Dlaczego stan() “widzi” zmiany wprowadzone przez wplata?

def stworz_konto(saldo):
    def wplata(kwota):
        nonlocal saldo
        saldo += kwota
        return saldo
    def stan():
        return saldo
    return wplata, stan

wplata, stan = stworz_konto(100)
wplata(50)
wplata(25)
print(stan())

Pytanie 9. (co zostanie wyświetlone?) Wskaż, która funkcja jest tu closure i co ona zapamiętuje.

def glosno(funkcja):
    def wrapper(*args, **kwargs):
        wynik = funkcja(*args, **kwargs)
        return wynik.upper()
    return wrapper

@glosno
def powitanie(imie):
    return f"cześć, {imie}"

print(powitanie("ola"))

Pytanie 10. (zadanie praktyczne) Napisz funkcję stworz_rabat(procent), która zwraca funkcję przyjmującą cenę i zwracającą cenę po obniżce o procent procent. Sprawdź ją tak:

rabat_20 = stworz_rabat(20)
print(rabat_20(100))   # oczekiwany wynik: 80.0
Odpowiedzi

1. Closure musi spełniać trzy warunki: funkcja jest zagnieżdżona, funkcja wewnętrzna używa zmiennych z funkcji zewnętrznej, funkcja zewnętrzna zwraca funkcję wewnętrzną. Klasa jest lepsza przy złożonych obiektach, wielu metodach i dziedziczeniu.

2. 15 20. Każde wywołanie stworz_mnoznik tworzy osobny closure z własnym n.

3. 1 2 11 3. a i b mają niezależne liczniki. Trzecie wywołanie a() zwraca 3, bo a pamięta własny count.

4. UnboundLocalError. Przy count += 1 Python traktuje count jako zmienną lokalną funkcji increment, której jeszcze nie ma. Naprawa: dodać nonlocal count jako pierwszą linię w increment.

5. Funkcja stworz_powitanie zwraca powitaj("Anna"), czyli tekst "Hello, Anna!", a nie funkcję. Wtedy hello("Jan") to próba wywołania tekstu: TypeError: 'str' object is not callable. Poprawka: return powitaj (bez nawiasów).

6.

return podstawa ** wykladnik
return poteguj

7. Wersja A. stworz_potege(3) ustawia wykładnik 3, a (5) podaje podstawę, więc wynik to 5³ = 125. W wersji B wynik to 3⁵ = 243. W wersji C jest za dużo argumentów (TypeError).

8. Wyświetli 175. Obie funkcje, wplata i stan, korzystają z tej samej zmiennej saldo z jednego wywołania stworz_konto. Stąd stan() zwraca wartość zmienioną przez wplata.

9. Wyświetli CZEŚĆ, OLA. Closure to wrapper. Zapamiętuje zmienną funkcja, czyli oryginalne powitanie przekazane do dekoratora.

10. Przykładowe rozwiązanie:

def stworz_rabat(procent):
    def oblicz(cena):
        return cena - cena * procent / 100
    return oblicz

Dla rabat_20(100) wynik to 80.0.