1/36
Mistrzostwo w przetwarzaniu danych

Wykład 8: Iteratory i Generatory

Jak efektywnie przetwarzać tysiące, a nawet miliony elementów nie zapychając pamięci RAM? Dlaczego pętla for w Pythonie jest tak potężna?

  • Iteracja: Proces przechodzenia po elementach zbioru.
  • Protokół Iteratora: Magiczne metody __iter__ i __next__.
  • Generatory: Funkcje, które "pamiętają" swój stan dzięki yield.
  • Lazy Evaluation (leniwe wyliczanie): Leniwe wyliczanie wartości tylko wtedy, gdy są potrzebne.
  • Wydajność: Oszczędność pamięci przy pracy z dużymi zbiorami danych.
  • Generator Expressions: Szybkie tworzenie generatorów w jednej linii.
Co to jest iterator w Pythonie? Iterator to obiekt, który pozwala ci przechodzić przez elementy zbioru (jak lista czy plik) jeden po drugim. Zamiast trzymać wszystkie dane w pamięci na raz, iterator zwraca element tylko wtedy, gdy o niego poprosisz. Dzięki temu możesz przetwarzać miliony rekordów z pliku bezproblemowo.
2/36
Czym jest Iteracja?

Powtarzalne działanie

Iteracja to nic innego jak powtarzanie operacji na kolejnych elementach jakiegoś zbioru (listy, krotki, słownika).

W Pythonie najczęściej używamy do tego pętli for:

imiona = ["Anna", "Jan", "Ewa"]
for imie in imiona:
    print(imie)

Ale co dzieje się pod maską? Skąd pętla wie, który element jest następny i kiedy przestać dzwonić?

Wyjaśnienie dla początkujących: Pętla for w Pythonie to tak naprawdę "skrót" do bardziej skomplikowanego mechanizmu. Kiedy piszesz for imie in imiona, Python robi w tle kilka kroków: pobiera "iterator" z listy, a potem wielokrotnie pyta o kolejny element, aż elementy się skończą. Dzięki temu możesz po prostu napisać for zamiast ręcznie zarządzać pozycją w liście.
3/36
Obiekt Iterowalny (Iterable)

Możliwość przechodzenia

Iterable (obiekt iterowalny) to każdy obiekt, z którego możemy "wyjąć" iterator. W uproszczeniu: to coś, po czym można puścić pętlę for.

  • Listy, stringi, słowniki to obiekty iterowalne.
  • Mają magiczną metodę __iter__().
  • Możemy je sprawdzić funkcją iter().
Ważna różnica: Pamiętaj, że lista (list) jest iterowalna (możesz po niej iterować), ale sama w sobie nie jest iteratorem. Różnica jest taka: jak wywołasz iter(lista), to dostaniesz nowy obiekt "iterator", który "pamięta", gdzie aktualnie jest w liście. Ta "pamięć pozycji" to właśnie robi iterator - lista jest jak książka, a iterator to Twoja zakładka, która mówi, na której stronie jesteś.
4/36
Iterator – Definicja

Wskaźnik postępu

Iterator to obiekt, który reprezentuje strumień danych i wie, jak dostarczyć kolejny element.

Musi posiadać dwie metody (Protokół Iteratora):

  1. __iter__(): Zwraca samego siebie.
  2. __next__(): Zwraca kolejny element lub rzuca wyjątek StopIteration, gdy dane się skończą.

Iterator "pamięta" swoją pozycję. Raz zużyty – nie może być użyty ponownie (chyba że stworzymy nowy).

Jak to działa krok po kroku: Metoda __iter__() mówi "zwróć obiekt, który będzie iterate'm". Zazwyczaj zwraca self (czyli samego siebie), bo iterator też jest iterowalny. Metoda __next__() jest najważniejsza - za każdym razem gdy ją wywołasz, zwraca kolejny element. Kiedy elementów już nie ma, rzuca wyjątek StopIteration - to jest sygnał dla pętli for, że czas przestać.
5/36
Jak działa pętla for?

Mechanizm wewnętrzny

Kiedy piszesz for x in lista:, Python robi to:

wykonawca = iter(lista) # Wywołuje lista.__iter__()
while True:
    try:
        x = next(wykonawca) # Wywołuje wykonawca.__next__()
        # Wykonuje kod pętli...
    except StopIteration:
        break # Koniec danych

To elegancki sposób na ukrycie skomplikowanej logiki sterowania pod prostym słowem kluczowym.

Wyjaśnienie krok po kroku: Ten kod pokazuje, co tak naprawdę robi pętla for "pod maską". Najpierw Python tworzy iterator za pomocą iter(lista). Potem w pętli while True (która teoretycznie by działała w nieskończoność) Python próbuje pobrać następny element za pomocą next(wykonawca). Jeśli wszystko OK, wykonuje Twój kod. Ale kiedy elementy się skończą, next() rzuca wyjątek StopIteration, który jest "łapany" przez except i pętla się kończy. Cały ten mechanizm jest ukryty - Ty po prostu piszesz for x in lista.
6/36
Własny Iterator – Klasa Licznik

Implementacja krok po kroku

class Licznik:
    def __init__(self, start, stop):
        self.obecny = start
        self.stop = stop

    def __iter__(self):
        return self

    def __next__(self):
        if self.obecny >= self.stop:
            raise StopIteration
        wynik = self.obecny
        self.obecny += 1
        return wynik
Co robi ten kod: To jest własna klasa, która działa jak iterator. Konstruktor __init__ zapamiętuje, od jakiej liczby zacząć (start) i na jakiej skończyć (stop). Metoda __iter__ po prostu zwraca self - mówi "jestem swoim własnym iteratorem". Najciekawsza jest metoda __next__: sprawdza czy obecny jest mniejszy niż stop. Jeśli nie - rzuca wyjątek StopIteration> (koniec). Jeśli tak - zwraca obecną liczbę, a potem zwiększa ją o 1 (to jest ta linijka self.obecny += 1). To jest dokładnie tak, jak działa wbudowana funkcja range().
7/36
Użycie własnego iteratora

Praktyczny test

licznik = Licznik(1, 4)

for n in licznik:
    print(n)

# Wynik:
# 1
# 2
# 3

Nasz obiekt Licznik zachowuje się jak wbudowana funkcja range().

Jak to przetestować: Kiedy tworzysz obiekt Licznik(1, 4), Python zapamiętuje, że mamy liczyć od 1 do 4 (ale 4 nie wliczając). Potem pętla for n in licznik działa tak: pyta iterator o kolejny element, dostaje 1 i drukuje 1, potem pyta o następny, dostaje 2, drukuje 2, potem 3, a kiedy pyta o czwarty raz, dostaje wyjątek StopIteration i pętla się kończy. Dlaczego nie drukuje 4? Bo w kodzie mamy if self.obecny >= self.stop - czyli jeśli obecna liczba jest większa lub równa stop, kończymy. Więc 1, 2, 3 - i koniec.
8/36
Problem pamięci (Problematyczne listy)

Wszystko na raz?

Jeśli stworzysz listę miliona liczb, Python musi natychmiast zarezerwować dla nich miejsce w pamięci RAM.

lista = [x for x in range(1000000)]
# RAM: UUUUUUUUUUUUUUUUUUGH! 30-40 MB zajęte od razu.

A co jeśli potrzebujesz tych liczb tylko po kolei? Czy musimy je wszystkie trzymać w pamięci jednocześnie? Tutaj wchodzą generatory.

Dlaczego to jest problem: Kiedy tworzysz listę w Pythonie (nawet za pomocą "list comprehension" z nawiasami []), Python najpierw oblicza WSZYSTKIE wartości i trzyma je wszystkie w pamięci RAM naraz. Dla miliona liczb to może być 30-40 MB pamięci operacyjnej. Ale co jeśli potrzebujesz tylko przejść przez te milion liczb raz, np. żeby zapisać je do pliku lub wysłać przez sieć? Wtedy trzymanie ich wszystkich w pamięci jest niepotrzebne - marnujesz zasoby. Generator rozwiązuje ten problem właśnie dlatego, że nie tworzy całej listy na raz.
9/36
Wprowadzenie do Generatorów

Magiczne słowo yield

Generator to prosty i elegancki sposób na tworzenie iteratora przy użyciu funkcji.

Różnica między normalną funkcją a generatorem:

  • return: Zwraca wartość i kończy (niszczy) funkcję.
  • yield: Zwraca wartość, ale **pauzuje** funkcję, zapamiętując wszystkie jej zmienne i pozycję.

Przy następnym wywołaniu, funkcja startuje dokładnie tam, gdzie skończyła!

Jak działa yield: Słowo kluczowe yield (po polsku "oddaj" lub "ustąp") jest tym, co czyni funkcję generatorem. Kiedy Python napotyka yield, "zapisuje stan" funkcji - zapamiętuje wszystkie zmienne lokalne i dokładnie, w której linii kodu aktualnie jest. Potem zwraca wartość z yield, ale NIE kończy funkcji. Kiedy następnym razem wywołasz next() na tym generatorze, Python "budzi" funkcję dokładnie w tym samym miejscu i kontynuuje wykonanie. To jest wielka oszczędność - nie musisz ręcznie tworzyć klasy z __iter__ i __next__, żeby mieć iterator.
10/36
Pierwszy Generator – Kod

Funkcja, która pauzuje

def moj_generator():
    print("Start")
    yield 1
    print("Krok 2")
    yield 2
    print("Koniec")

gen = moj_generator()
print(next(gen))
# Wypisze: Start, potem 1
print(next(gen))
# Wypisze: Krok 2, potem 2
Co tutaj się dzieje: Kiedy wywołujesz moj_generator(), funkcja się NIE wykonuje od razu - zamiast tego dostajesz obiekt generatora. Dopiero kiedy wywołujesz next(gen), Python zaczyna wykonywać funkcję od początku: drukuje "Start", dochodzi do pierwszego yield 1, zwraca 1 i... zatrzymuje się w tym miejscu! Zmienne funkcji (jeśli byśmy mieli jakieś) są zapamiętane. Kiedy drugi raz wywołujesz next(gen), Python kontynuuje dokładnie od miejsca po pierwszym yield - drukuje "Krok 2", dochodzi do yield 2, zwraca 2 i znowu się zatrzymuje. To jest ta "pauza" o której mówiliśmy.
11/36
Potęga Leniwego Wyliczania

Lazy Evaluation

Generatory nie liczą niczego "na zapas". Produkują elementy tylko na wyraźne żądanie (np. przez next() lub pętlę for).

  • Oszczędność czasu: Nie czekasz na wygenerowanie miliarda elementów, zanim zaczniesz przetwarzać pierwszy.
  • Oszczędność pamięci: Pamięć zajmuje tylko aktualnie przetwarzany element.
Leniwe wyliczanie - co to oznacza: "Lazy" po angielsku znaczy "leniwy". Chodzi o to, że generator jest "leniwy" - nie robi nic, dopóki go nie poprosisz. Wyobraź sobie kogoś, kto ma Ci przynieść milion książek: lista zrobi to od razu i będzie stał z milionem książek, a generator powie "ok, przyniosę pierwszą" i przyniesie jedną, potem "ok, przyniosę drugą" i tak dalej. Nie musisz czekać, aż przyniesie wszystkie, żeby zacząć czytać pierwszą. Dzięki temu oszczędzasz i czas (bo nie czekasz na wszystko), i miejsce (bo nie trzymasz wszystkiego naraz).
12/36
Generator Nieskończony

Wieczna pętla bez crashu

def parzyste_bez_konca():
    liczb = 0
    while True:
        yield liczb
        liczb += 2

gen = parzyste_bez_konca()
print(next(gen)) # 0
print(next(gen)) # 2
# ... i tak do końca świata (lub wyłączenia prądu)

Mimo while True, program nie zawiesza się, bo yield oddaje kontrolę do reszty kodu.

Dlaczego to działa: To jest jeden z najciekawszych aspektów generatorów - możesz stworzyć "nieskończoną" sekwencję, która w teorii działałaby w nieskończoność, ale zawiesza się dopiero kiedy ją zatrzymasz. Zwykła pętla while True bez generatora by "zawiesiła" Twój program - komputer próbowałby wykonać miliardy operacji w ułamku sekundy i albo by się zawiesił, albo by wyskoczył błąd przepełnienia pamięci. Ale generator z yield działa inaczej: za każdym razem jak oddaje wartość, "oddaje też sterowanie" z powrotem do kodu, który go wywołał. Więc Twój program może spokojnie pracować dalej, pobierając tyle liczb, ile potrzebuje.
13/36
Porównanie: Lista vs Generator

Kiedy wybrać co?

Cecha Lista (List) Generator
Dostęp Swobodny (np. lista[5]) Tylko sekwencyjny (po kolei)
Pamięć Duża (wszystkie dane w RAM) Minimalna (tylko 1 element)
Wielokrotność Można czytać wiele razy Tylko raz (potem jest pusty)
Kiedy używać czego: Używaj listy, kiedy potrzebujesz wielokrotnie wracać do tych samych danych (np. sortować, wyszukiwać po indeksie). Używaj generatora, kiedy masz dużo danych i potrzebujesz przejść przez nie tylko raz. Generator jest "jednorazowy" - jak już przejdziesz przez wszystkie elementy, to "się kończy" i musisz stworzyć nowy generator, żeby iterować od nowa.
14/36
Generator Expressions

Generatory w pigułce

Podobnie jak "List Comprehensions", mamy wyrażenia generujące. Różnią się tylko nawiasami:

# List Comprehension (tworzy listę w pamięci)
kwadraty_lista = [x*x for x in range(10)]

# Generator Expression (tworzy iterator)
kwadraty_gen = (x*x for x in range(10))

Jeśli potrzebujemy danych tylko raz do pętli lub sumowania, nawiasy okrągłe są zawsze lepszym wyborem.

Różnica w nawiasach: List Comprehension (listy skrócone) używają nawiasów kwadratowych [] i tworzą pełną listę w pamięci. Generator Expression (wyrażenia generatora) używają nawiasów okrągłych () i tworzą "leniwy" iterator. Zasada jest prosta: jeśli potrzebujesz listy (np. chcesz użyć lista[3] albo sortować), użyj []. Jeśli tylko raz przejdziesz przez dane w pętli, użyj () - zaoszczędzisz pamięć. Zwróć uwagę, że kwadraty_lista to już jest lista, a kwadraty_gen to dopiero obiekt generatora, który będzie produkował kwadraty "na żądanie".
15/36
Wyzwanie: Czytanie dużych logów

Przykład z życia

Wyobraź sobie plik logi_serwera.txt o rozmiarze 20 GB. Chcesz znaleźć tylko linie z błędem "ERROR".

Złe podejście: lines = open().readlines() - komputer padnie (brak RAM).

Dobre podejście: Użycie iteratora pliku (Python domyślnie czyta plik liniami jako generator).

Realny scenariusz: Wyobraź sobie, że masz plik logów z serwera WWW z całego miesiąca - plik ma 20 GB (tyle co kilka filmów w HD). Chcesz znaleźć tylko linie z błędami "ERROR". Podejście z readlines() próbuje wczytać CAŁY plik do pamięci RAM naraz - Twój komputer prawdopodobnie by się zawiesił lub dostałbyś błąd "Memory Error". Natomiast kiedy iterujesz po pliku w Pythonie (for linia in plik), Python w tle tworzy generator, który czyta TYLKO jedną linię naraz z dysku, przetwarza ją, a potem czyta następną. Dzięki temu możesz przetworzyć plik 20GB na komputerze z 8GB RAM - i to działa płynnie!
16/36
Implementacja czytnika logów

Efektywne filtrowanie

def szukaj_bledow(sciezka):
    with open(sciezka) as plik:
        for linia in plik:
            if "ERROR" in linia:
                yield linia.strip()

# Zużycie RAM: stałe i minimalne, niezależnie od rozmiaru pliku
for blad in szukaj_bledow("serwer.log"):
    print(blad)
Jak to działa: Funkcja szukaj_bledow to generator. Kiedy ją wywołujesz z nazwą pliku, ona nie czyta od razu całego pliku - tylko zwraca obiekt generatora. Kiedy w pętli piszesz for blad in szukaj_bledow("serwer.log"), to za każdym razem generator: 1) czyta jedną linię z pliku, 2) sprawdza czy zawiera "ERROR", 3) jeśli tak, to zwraca ją (yield), a jeśli nie, to czyta następną linię. Dzięki temu w pamięci RAM jest zawsze tylko jedna linia na raz - nieważne czy plik ma 100MB czy 100GB. Metoda .strip() usuwa białe znaki (jak enter na końcu linii), żeby wynik był ładniejszy.
17/36
Metoda send() w generatorach

Komunikacja dwustronna

Generatory mogą nie tylko wysyłać dane, ale też je odbierać w trakcie pracy!

def akumulator():
    suma = 0
    while True:
        liczba = yield suma
        if liczba is None: break
        suma += liczba

a = akumulator()
next(a) # Uruchamiamy generator
print(a.send(10)) # Wysyłamy 10, odbieramy sumę 10
print(a.send(20)) # Wysyłamy 20, odbieramy sumę 30
Metoda send() - co to daje: Normalnie generator tylko "oddaje" wartości na zewnątrz. Ale czasami chcesz też "wysłać" wartość z powrotem do generatora, żeby coś zmienić w jego działaniu. W tym przykładzie mamy generator "akumulator", który liczy sumę. Najpierw musisz wywołać next(a) (lub a.send(None)), żeby "uruchomić" generator i dotrzeć do pierwszego yield. Potem możesz użyć a.send(10) - to wysyła 10 do generatora, generator dodaje 10 do sumy i zwraca nową sumę (10). Potem a.send(20) wysyła 20, generator dodaje 20 do sumy (10+20=30) i zwraca 30. To jest przydatne do tworzenia "kooperatywnych" systemów wielozadaniowych.
18/36
Generator delegujący: yield from

Przekazywanie pałeczki

W Pythonie 3.3+ słowo yield from pozwala jednemu generatorowi delegować pracę do innego (lub dowolnego iteratora).

def polacz_ciagi():
    yield from range(3)
    yield from ["A", "B"]

# Wynik: 0, 1, 2, "A", "B"

Usprawnia to budowanie hierarchicznych procesorów danych i czytelność kodu.

Po co jest yield from: Wyobraź sobie, że masz dwa generatory i chcesz "połączyć" ich wyniki w jeden. Bez yield from musiałbyś pisać pętlę for x in generator1: yield x i tak dla każdego. Z yield from możesz po prostu napisać yield from inny_generator - Python automatycznie deleguje (przekazuje) wszystkie wartości z tamtego generatora. W przykładzie: najpierw yield from range(3) zwraca 0, 1, 2, a potem yield from ["A", "B"] zwraca "A", "B". Efekt to połączona sekwencja: 0, 1, 2, "A", "B".
19/36
Itertools – Sklep z narzędziami

Gotowe klocki

Biblioteka itertools zawiera dziesiątki zoptymalizowanych funkcji do pracy na iteratorach.

  • cycle(): Powtarza sekwencję w nieskończoność.
  • chain(): Łączy kilka iteratorów w jeden.
  • islice(): Wycinanie fragmentów z generatora (bez tworzenia listy).
  • combinations(): Tworzy kombinacje elementów.
Itertools - skorzystaj z gotowego: Moduł itertools to biblioteka standardowa Pythona, która zawiera gotowe narzędzia do pracy z iteratorami. Są one napisane w języku C (bardzo szybkim), więc działają dużo szybciej niż gdybyś sam je pisał w Pythonie. Funkcja cycle() powtarza sekwencję w kółko (np. [1,2,3] -> 1,2,3,1,2,3...). Funkcja chain() łączy kilka iteratorów w jeden (jakbyś miał dwie taśmy produkcyjne i chciał je połączyć w jedną). Funkcja islice() pozwala "wyciąć" kawałek z generatora bez tworzenia nowej listy. Zanim napiszesz własną funkcję do obsługi iteratorów, sprawdź czy nie ma jej już w itertools!
20/36
Wydajność – Test praktyczny

RAM przy 10 milionach liczb

Spróbujmy zmierzyć różnicę:

  1. [x for x in range(10**7)]: ok. 400 MB RAM.
  2. (x for x in range(10**7)): ok. 128 bajtów RAM.

Różnica jest kolosalna. W profesjonalnych systemach, gdzie przetwarza się logi, transakcje giełdowe czy dane pogodowe, generatory to jedyny sposób na stabilność.

Liczby mówią same za siebie: Lista z 10 milionami liczb (list comprehension z nawiasami []) zajmuje około 400 MB pamięci RAM - to jest dużo, szczególnie jeśli Twój program robi też inne rzeczy. Ten sam zestaw liczb jako generator (nawiasy ()) zajmuje... 128 bajtów! To jest mniej więcej tyle, co jedno słowo w tym zdaniu. Różnica jest około 3 miliony razy mniejsza! Dlatego w profesjonalnych systemach (np. przetwarzanie logów z serwera, dane z giełdy, dane z czujników IoT) generatory są absolutną koniecznością - bez nich Twój serwer by się zawiesił przy próbie przetworzenia dużych zbiorów danych.
21/36
Stan wewnętrzny Generatora

Jak Python to robi?

Kiedy funkcja generatora jest pauzowana przez yield, Python zachowuje jej "ramkę stosu" (stack frame). To jak zrobienie zdjęcia (snapshot) całego stanu pamięci funkcji.

  • Lokalne zmienne trwają.
  • Instrukcja jest zatrzymana "na linii".
  • Kolejne next() po prostu wznawia wykonanie.
Co to jest "ramka stosu" (stack frame): Kiedy funkcja jest wykonywana, Python trzyma w pamięci "informacje o tym, gdzie aktualnie jest" - to jest właśnie stack frame. Zawiera lokalne zmienne funkcji, informację o tym, która linia kodu ma się wykonać dalej, i inne rzeczy. Kiedy generator "pauzuje" się na yield, Python zachowuje tę ramkę stosu - jakby robił "zdjęcie" całego stanu funkcji w danym momencie. Dzięki temu, kiedy następny raz wywołujesz next(), Python może "wznowić" funkcję dokładnie tam, gdzie ją zostawił. To jest ta "magia" generatorów - nie musisz ręcznie zapisywać stanu w zmiennych.
22/36
Częsty błąd: Ponowne użycie

Generator jest jednorazowy

Wielu początkujących próbuje przeiterować po generatorze dwa razy:

gen = (x for x in range(3))
for a in gen: print(a) # Działa: 0, 1, 2
for b in gen: print(b) # Nic się nie wypisze! Generator jest pusty.

Jeśli potrzebujesz tych samych danych dwa razy, musisz stworzyć nowy obiekt generatora lub (jeśli dane są małe) zamienić go na listę: lista = list(gen).

Ważna cecha generatorów: Pamiętaj, że generator jest jak taśma w magnetofonie - jak już raz ją odtworzysz do końca, to "się skończyła" i musisz przewinąć z powrotem (a generatora nie da się przewinąć). Musisz stworzyć nowy generator, żeby iterować od nowa. Dlatego ten kod nie działa: najpierw pętla for a in gen przechodzi przez wszystkie elementy (0, 1, 2), a potem generator jest "wyczerpany". Druga pętla for b in gen dostaje pusty generator - nie ma już więcej elementów do oddania. Jeśli potrzebujesz użyć danych wielokrotnie, zamień generator na listę: lista = list(gen) - wtedy trzymasz wszystko w pamięci i możesz iterować ile chcesz.
23/36
Iteratory w klasach OOP

Integracja z obiektowością

Twoje klasy biznesowe (np. Magazyn, Druzyna) mogą stać się iterowalne. Wystarczy dodać im metodę __iter__.

class Druzyna:
    def __init__(self):
        self.zawodnicy = []
    
    def __iter__(self):
        # Zwracamy generator zamiast budować iterator od zera!
        for z in self.zawodnicy:
            yield z
Jak zrobić klasę iterowalną: Możesz sprawić, że obiekty Twojej własnej klasy będą działały w pętli for. Wystarczy dodać metodę __iter__, która zwraca iterator. W tym przykładzie metoda __iter__ jest zrobiona jako generator (bo używa yield) - to jest najprostszy sposób. Teraz możesz napisać for zawodnik in moja_druzyna i Python automatycznie będzie iterował przez listę zawodnicy. To jest bardzo przydatne w projektach OOP - np. klasa Sklep mogłaby iterować przez produkty, klasa Bank przez konta itd.
24/36
Magia metody next()

Manualne sterowanie

Funkcja next() przyjmuje opcjonalny drugi argument: wartość domyślną. Zapobiega to rzuceniu błędu StopIteration.

gen = (x for x in range(1))
print(next(gen)) # 0
print(next(gen, "Koniec!")) # Zamiast błędu: Koniec!

To bardzo przydatne przy szukaniu pierwszego elementu spełniającego warunek w strumieniu danych.

Wartość domyślna w next(): Normalnie, kiedy wywołujesz next() na wyczerpanym generatorze, dostajesz błąd StopIteration. Ale możesz podać drugi argument, który będzie zwrócony zamiast błędu. W przykładzie: generator ma tylko jedną wartość (0), więc pierwsze next(gen) zwraca 0. Drugie next(gen, "Koniec!") nie ma już więcej wartości, więc zamiast rzucać błąd, zwraca "Koniec!". To jest przydatne, kiedy chcesz "bezpiecznie" pobrać element z generatora i mieć jakąś wartość "na wypadek", gdyby generator był pusty.
25/36
Pipelines – Potoki przetwarzania

Składanie procesów

Możemy łączyć generatory w łańcuchy. Dane płyną przez nie jak w rurach.

liczby = range(100)
kwadraty = (x*x for x in liczby)
parzyste_kwadraty = (x for x in kwadraty if x % 2 == 0)

# Nic jeszcze nie zostało policzone!
# Liczenie zacznie się dopiero tutaj:
print(next(parzyste_kwadraty))
Potoki danych (pipelines): Możesz "łączyć" generatory w łańcuchy - to jest bardzo potężne! W tym przykładzie: range(100) to "nieskończony" generator liczb, ale działa leniwie. kwadraty bierze każdą liczbę z range i podnosi do kwadratu. parzyste_kwadraty bierze kwadraty i filtruje tylko parzyste. Cała ta "magia" dzieje się LENIWE - NIC nie jest obliczane, dopóki nie wywołasz next() na końcu łańcucha. Dopiero wtedy Python "ciągnie" dane przez cały łańcuch: pobiera jedną liczbę z range, podnosi do kwadratu, sprawdza czy jest parzysta, i zwraca. To jest jak fabryka z taśmą produkcyjną - każdy etap przetwarza jedną rzecz na raz.
26/36
StopIteration – Ukryta kontrola

Dlaczego to nie jest Error?

W Pythonie wyjątki są częścią normalnego sterowania programem (tzw. EAFP - Easier to Ask Forgiveness than Permission - łatwiej prosić o przebaczenie niż pozwolenie).

StopIteration to nie jest błąd krytyczny, to tylko sygnał: "Hej, praca skończona, możesz iść do domu". Pętla for "łapie" ten sygnał i po cichu kończy działanie.

Czym naprawdę jest StopIteration: Wyjątek StopIteration to NIE jest błąd w tradycyjnym sensie - to jest sygnał, że "iterator skończył swoją pracę". W Pythonie obowiązuje filozofia EAFP (Easier to Ask for Forgiveness than Permission - "łatwiej prosić o przebaczenie niż pozwolenie"). Zamiast sprawdzać "czy jest następny element" PRZED jego pobraniem, Python po prostu próbuje pobrać następny element, a jeśli go nie ma, dostaje wyjątek StopIteration i wie, że czas przestać. Pętla for jest "inteligentna" - ona wie, że StopIteration to normalne zakończenie i nie wypisuje żadnego błędu.
27/36
Generatory a Współbieżność

Wstęp do asynchroniczności

Generatory były fundamentem nowoczesnego asyncio w Pythonie. Możliwość wstrzymania i wznowienia funkcji pozwala na "udawanie" wielu zadań naraz bez użycia ciężkich wątków.

Nazywa się to **kooperatywną wielozadaniowością** – zadanie "oddaje" procesor, gdy na coś czeka.

Generatory w asyncio: Współczesny Python ma moduł asyncio do pisania programów "asynchronicznych" (współbieżnych). Okazuje się, że generatory są idealne do tego! Dzięki yield funkcja może "wstrzymać się" i poczekać na dane (np. z sieci), a w tym czasie inna część programu może działać. To jest "kooperatywna wielozadaniowość" - zadania współpracują ze sobą, dobrowolnie oddając sterowanie innym zadaniom, zamiast być wymuszane przez system operacyjny (jak wątki). Dzięki temu możesz obsłużyć tysiące połączeń sieciowych jednocześnie, nie tworząc tysięcy wątków (które są "ciężkie" dla komputera).
28/36
Kiedy nie używać generatorów?

Przeciwwskazania

  • Gdy musisz wielokrotnie wracać do tych samych danych (np. sortowanie).
  • Gdy musisz skakać po indeksach (losowy dostęp).
  • Gdy dane są bardzo małe – narzut na tworzenie generatora może być większy niż zysk.
  • Gdy chcesz modyfikować elementy w trakcie (generatory są "tylko do odczytu").
Kiedy lista jest lepsza: Generatorów nie używaj zawsze i wszędzie. Są sytuacje, gdzie lista jest lepsza: 1) Kiedy musisz mieć dostęp do elementu po indeksie (np. lista[5]) - generator nie pozwala na to. 2) Kiedy musisz sortować dane - musisz mieć wszystkie dane na raz. 3) Kiedy chcesz iterować wielokrotnie po tych samych danych - generator jest jednorazowy. 4) Kiedy dane są małe (np. 10 elementów) - generator ma "narzut" (overhead) na tworzenie, który w tym przypadku jest większy niż oszczędność pamięci. Zasada: jeśli dane mieszczą się w pamięci i potrzebujesz elastyczności, użyj listy. Jeśli dane są duże i przechodzisz przez nie raz, użyj generatora.
29/36
Wyjątki wewnątrz generatora

Zamykanie zasobów

Dzięki temu, że generator pauzuje, możemy bezpiecznie używać w nim try...finally. Blok finally wykona się, nawet jeśli zamkniemy generator manualnie przez .close().

def czytnik():
    try:
        yield "Dane"
    finally:
        print("Zamykam połączenie!")
Zasoby i generatory: Generator może bezpiecznie zarządzać zasobami (np. połączeniem do bazy danych, plikiem), bo możesz użyć bloku try...finally. Blok finally wykona się ZAWSZE - niezależnie od tego, czy generator "normalnie" się skończy, czy zostanie zamknięty "na siłę" przez wywołanie generator.close(). W przykładzie: funkcja czytnik najpierw zwraca "Dane" (yield), ale jeśli ktoś wywoła czytnik().close(), to mimo że generator nie doszedł do końca, blok finally i tak się wykona i wydrukuje "Zamykam połączenie!". To jest bardzo ważne do bezpiecznego zamykania połączeń, plików itp.
30/36
Funkcja enumerate() i zip()

Najpopularniejsze iteratory

Te wbudowane funkcje to w rzeczywistości fabryki iteratorów:

# enumerate: (indeks, wartość)
for i, v in enumerate(["A", "B"]): ...

# zip: paruje elementy z dwóch list
for x, y in zip([1, 2], [3, 4]): ...

Nigdy nie tworzą one list tymczasowych, działają leniwie element po elemencie.

Te funkcje też są leniwe: Funkcje enumerate() i zip() to "fabryki iteratorów" - zwracają iterator, nie listę. Funkcja enumerate(lista) dodaje indeks do każdego elementu: zamiast "Anna" dostajesz tuple (0, "Anna"), (1, "Jan"), (2, "Ewa"). Funkcja zip(lista1, lista2) "paruje" elementy z dwóch list: (1, 3), (2, 4). Obie te funkcje działają leniwie - nie tworzą nowej listy w pamięci, tylko iterator, który produkuje wartości na żądanie. Dlatego możesz ich używać nawet z bardzo dużymi plikami lub "nieskończonymi" generatorami - nic nie zostanie wczytane do pamięci na raz.
31/36
Rekurencyjne generatory

Przeszukiwanie folderów

Idealne do chodzenia po drzewach katalogów lub strukturach typu XML/JSON.

import os

def pokaz_pliki(sciezka):
    for p in os.listdir(sciezka):
        pelna = os.path.join(sciezka, p)
        if os.path.isdir(pelna):
            yield from pokaz_pliki(pelna)
        else:
            yield pelna
Rekurencja + generatory: Możesz łączyć rekurencję (funkcja wywołuje samą siebie) z generatorami! Ten przykład pokazuje funkcję, która przechodzi przez wszystkie pliki w folderze (i w podfolderach). Dla każdego elementu w folderze sprawdza, czy to plik czy folder. Jeśli folder, to wywołuje pokaz_pliki(pelna) (rekurencja!) i deleguje wszystkie wyniki przez yield from. Jeśli plik, to po prostu zwraca jego ścieżkę. Dzięki temu możesz "przejrzeć" cały system plików zużywając minimalną ilość pamięci - generator "leniwie" oddaje jeden plik na raz, nieważne czy masz 100 czy 100000 plików.
32/36
Dobre praktyki nazewnictwa

Czysty kod

  • Funkcje będące generatorami nazywaj czasownikami (np. generuj_raport, pobierz_dane).
  • Iteratory to zazwyczaj rzeczowniki (np. czytnik, emiter).
  • Sygnalizuj, że funkcja jest generatorem w dokumentacji (docstring).
Konwencje nazewnictwa: Dobra nazwa funkcji generatora powinna sygnalizować, że funkcja ZWRACA sekwencję wartości w czasie, a nie od razu wszystkie. Używaj czasowników: pobierz_dane_z_bazy(), wczytaj_linie_pliku(), generuj_liczby(). Dla iteratorów (klas) używaj rzeczowników: CzytnikPliku, Przejściówka. W docstringu (dokumentacji funkcji) warto napisać, że funkcja jest generatorem, np.: "Ta funkcja jest generatorem i zwraca kolejne wiersze pliku.". To pomaga innym programistom (i Tobie w przyszłości) zrozumieć, jak używać tej funkcji.
33/36
Protokół Iteratora w UML

Relacje między klasami

W projektowaniu systemów Iterable i Iterator to często oddzielne klasy:

  • Klasa Kolekcja posiada metodę getIterator().
  • Klasa MojaKolekcjaIterator implementuje mechanizm chodzenia po Kolekcji.

W Pythonie często (choć nie zawsze) upraszczamy to do jednej klasy lub generatora.

Wzorzec Iterator w innych językach: W językach takich jak Java czy C++, wzorzec iteratora jest bardziej rozbudowany - masz osobną klasę "kolekcja" (np. Lista) i osobną klasę "iterator" (np. ListaIterator), która wie jak chodzić po tej konkretnej kolekcji. W Pythonie jest prościej - często jedna klasa może być jednocześnie iterable i iterator (implementuje i __iter__ i __next__), albo po prostu używamy generatora, który automatycznie implementuje cały protokół. Python promuje prostotę - "proste jest lepsze niż skomplikowane".
34/36
Szybka powtórka

Najważniejsze punkty

  • Iterable ma __iter__ i pozwala zacząć pętlę.
  • Iterator ma __next__ i zna aktualny element.
  • Generator to funkcja z yield, która pauzuje stan.
  • Leniwość (Lazy) to oszczędność pamięci i czasu.
  • StopIteration to naturalny koniec taśmociągu danych.
Podsumowanie: Zapamiętaj trzy główne pojęcia: Iterable to coś, po czym można iterować (np. lista) - ma metodę __iter__. Iterator to obiekt, który faktycznie przechodzi przez elementy - ma metodę __next__. Generator to "skrót" do tworzenia iteratorów - zamiast pisać całą klasę, po prostu używasz yield w funkcji. I najważniejsza zaleta: generatory są leniwe - produkują dane tylko kiedy są pytane, dzięki czemu oszczędzają pamięć przy pracy z dużymi zbiorami danych.
35/36
Zadanie dla studenta

Trening czyni mistrza

Napisz generator, który będzie zwracał kolejne liczby Fibonacciego do zadanej granicy.

Podpowiedź: Pamiętaj o dwóch początkowych liczbach (0 i 1) i sumowaniu ich w pętli while.

def fibonacci(limit):
    a, b = 0, 1
    while a < limit:
        yield a
        a, b = b, a + b
Jak to działa - wyjaśnienie: Ciąg Fibonacciego to: 0, 1, 1, 2, 3, 5, 8, 13... Każda liczba to suma dwóch poprzednich. Zmienne a i b to dwie ostatnie liczby. Na początku a=0, b=1. Pętla while a < limit mówi "rób tak długo, jak a jest mniejsze od limitu". yield a zwraca aktualną liczbę. Potem a, b = b, a + b przesuwa "okno" - nowe a staje się stare b, a nowe b to suma starego a i b. Przykład dla limit=10: zwróci 0, 1, 1, 2, 3, 5, 8 - i się zatrzyma, bo następne to 13, które jest >= 10.
36/36
Podsumowanie

Koniec Wykładu 8

Dzisiaj nauczyliśmy się, jak Python radzi sobie z dużymi zbiorami danych. Umiejętność korzystania z iteratorów i generatorów odróżnia początkującego programistę od specjalisty (Seniora), który dba o zasoby systemu.

Co dalej? Nauczyłeś się dziś jednej z najważniejszych koncepcji w Pythonie - generatorów i iteratorów. To jest wiedza, która odróżnia dobrego programistę od świetnego. Umiejętność pisania "leniwego" kodu, który nie marnuje pamięci, jest kluczowa w profesjonalnym programowaniu. Na następnym wykładzie nauczymy się o wyjątkach i ich obsłudze - jak radzić sobie z błędami w programie i jak pisać kod odporny na nieprzewidziane sytuacje. Te dwie umiejętności (generatory + obsługa błędów) pozwolą Ci pisać naprawdę profesjonalne i stabilne aplikacje w Pythonie.

Dziękuję za uwagę!