user-photo

Bartosz Szmyt

Fullstack Developer | Software Engineer | CERT

Iteratory i generatory w Pythonie - geneza

22/02/2025

Zacznijmy od podstaw. Iteracja to fundamentalna koncepcja w programowaniu polegająca na wielokrotnym wykonywaniu określonego zestawu instrukcji. Proces ten jest zwykle stosowany do przetwarzania zbiorów danych, powtarzania operacji aż do spełnienia określonego warunku lub implementacji algorytmów iteracyjnych.

Jednym z kluczowych podejść do realizacji iteracji jest zastosowanie wzorca projektowego Iterator, co także ma miejsce w Pythonie.

Iterator - wzorzec projektowy

Iterator to behawioralny wzorzec projektowy, który oddziela logikę iteracji od samego zbioru danych. Umożliwia sekwencyjne przechodzenie po elementach zbioru bez konieczności ujawniania jego wewnętrznej struktury.

W klasycznej implementacji wzorca iterator zazwyczaj udostępnia jedną metodę umożliwiającą przechodzenie przez kolejne elementy kolekcji. Klient może wywoływać ją wielokrotnie, aż do zakończenia iteracji. Kluczowe jest, aby każdy iterator implementował ten sam interfejs, co zapewnia spójność jego użycia w kodzie – niezależnie od konkretnej implementacji działającej "pod spodem". Dzięki temu możliwe jest stosowanie różnych wersji iteratora, na przykład takiego, który przechodzi przez elementy wstecz, lub takiego, który iteruje co drugi element kolekcji.

Kolejną zaletą wzorca jest możliwość wstrzymywania i wznawiania iteracji oraz przechowywania jej stanu. Jest to szczególnie przydatne w przypadku generatorów, które omówimy w dalszej części artykułu.

Obiekty iterowalne i iterator w Pythonie

Potocznie, obiekt iterowalny (Iterable) w pythonie to zbiór elementów po których można iterować. Iterowalne będą zatem listy, krotki, słowniki czy łańcuchy :

>>> isinstance({}, Iterable)
True
>>> isinstance((), Iterable)
True
>>> isinstance([], Iterable)
True
>>> isinstance('', Iterable)
True

Technicznie rzecz biorąc, obiekt iterowalny to taki, który może zwrócić iterator poprzez metodę specjalną __iter__(). Co jest prawdą dla każdej z wymienionych powyżej struktur:

>>> iter([])
<list_iterator object at 0x7e9115b22ad0>
>>> iter({})
<dict_keyiterator object at 0x7e9114a14fe0>
>>> iter(())
<tuple_iterator object at 0x7e9115b22ad0>
>>> iter('')
<str_iterator object at 0x7e9115b22a70>

Otrzymany obiekt iteratora daje możliwość sekwencyjnego przejścia po każdym elemencie danego zbioru. Mówiac bardziej technicznie, każdy z iteratorów implementuje protokół iteratora, czyli dwie metody specjalne:

  • __iter__() - zwraca obiekt iteratora
  • __next__() - zwraca kolejny element w trakcie iteracji. W przypadku braku kolejnych elementów zgłasza wyjątek StopIteration

Przejście po elementach listy moglibyśmy zatem zaimplementować następująco :

my_list = [1,2,3,4,5,6]
my_iter = iter(my_list)

while True:
	try:
		item = next(my_iter)
		# zrób coś na elemencie
	except StopIteration:
		break

Najpierw tworzymy iterator za pomocą funkcji iter(), która wywołuje specjalną metodę __iter__() na liście my_list. Następnie w pętli while funkcja next() wywołuje metodę __next__() iteratora, zwracając kolejne elementy listy. Pętla kończy działanie, gdy iterator sygnalizuje brak kolejnych elementów, zgłaszając wyjątek StopIteration.

Warto zauważyć, że aby ponownie przejść przez elementy listy, należy utworzyć nowy obiekt iteratora. Poprzedni, po wyczerpaniu elementów, przy każdej kolejnej próbie wywołania next() nadal będzie zgłaszał wyjątek StopIteration.

Ten przykład doskonale ilustruje mechanizm działania pętli for. Właśnie poznałeś/aś jej działanie "od podszewki"! Korzystając z jej składni, zapis wyglądałby następująco:

for item in [1,2,3,4,5,6]:
	#zrób coś na elemencie

Pętla for automatycznie tworzy nowy iterator przy każdej próbie iteracji po kolekcji oraz samodzielnie obsługuje wyjątek StopIteration, kończąc iterację w odpowiednim momencie.

Własny iterator

W poprzednim przykładzie stworzyliśmy iterator na podstawie obiektu listy (my_iter = iter(my_list)). Teraz zdefiniujemy własny iterator, który zwraca kolejne liczby parzyste do określonego limitu:

class EvenNumbers:
    def __init__(self, limit: int):
        self.limit = limit
        self.current = 0

    def __iter__(self):
        return self

    def __next__(self):
        if self.current > self.limit:
            raise StopIteration
        even = self.current
        self.current += 2
        return even
        
        
for i in EvenNumbers(10):
	print(i)
	
# Wynik: 0, 2, 4, 6, 8, 10

Implementujemy metodę __iter__, która zwraca instancję iteratora oraz metodę __next__, która będzie odpowiedzialna za zwracanie kolejnych elementów czyli w tym przypadku parzystych liczb naturalnych. Oczywiście musimy też zadbać o zgłoszenie wyjątku StopIteration w odpowiednim momencie, którym będzie osiągnięcie wyznaczonej liczby granicznej. W tym momencie iterator może być używany w pętli for. 

Choć taka implementacja działa poprawnie, wymaga napisania całej klasy oraz obsługi stanu iteratora ręcznie. Python oferuje bardziej eleganckie rozwiązanie – generatory.

Generator w Pythonie

Generatory upraszczają tworzenie iteratorów, dając do dyspozycji słowo kluczowe yield, które automatycznie zarządza stanem funkcji. Oto jak można zaimplementować ten sam mechanizm w postaci generatora:

def even_numbers(limit: int):
    current = 0
    while current <= limit:
        yield current
        current += 2
		
for i in even_numbers(10):
	print(i)
	
# Wynik : 0, 2, 4, 6, 8, 10

W momencie napotkania słowa kluczowego yield, generator wstrzymuje wykonanie funkcji i zwraca otrzymaną wartość. Przy kolejnym wywołaniu next() działanie funkcji zostaje wznowione od miejsca, w którym zostało przerwane, aż do kolejnego yield. Jeśli w kodzie nie ma już więcej instrukcji yield, zgłaszany jest wyjątek StopIteration, który pętla for obsługuje wewnętrznie.

send(), throw() i close() – komunikacja z generatorem

Generatory w Pythonie to nie tylko wygodna forma tworzenia iteratorów. Oprócz możliwości iterowania po nich za pomocą next(), oferują one dodatkowe metody: send(), throw(), oraz close(), które umożliwiają interakcję z generatorem i kontrolowanie jego działania.

Wysyłanie wartości do generatora – send()

Metoda send() pozwala na przesyłanie wartości do wnętrza generatora w miejsce instrukcji yield. Poniższy przykład ilustruje jej działanie krok po kroku:

def gen():
    received_val = yield "Uruchamianie..."
    print(f"Otrzymałem: {received_val}")
    received_val = yield "Wczytuję..."
    print(f"Koniec działania, ostatnia wartość: {received_val}")


g = gen()
print(next(g))
print(g.send("Pierwsza wartość")) 
print(g.send("Druga wartość"))  


❯ python3 gen.py
Uruchamianie...
Otrzymałem: Pierwsza wartość
Wczytuję...
Koniec działania, ostatnia wartość: Druga wartość
Traceback (most recent call last):
  File "gen.py", line 11, in <module>
    print(g.send("Druga wartość"))
StopIteration

Omówmy to krok po kroku:

  • Tworzymy generator gen(), a następnie wywołujemy next(g), co powoduje jego rozpoczęcie i zatrzymanie na pierwszym yield, zwracając "Uruchamianie...".
  • Wywołujemy g.send("Pierwsza wartość"). Wartość "Pierwsza wartość" trafia do zmiennej received_val, a generator wykonuje kod po yield, wypisując "Otrzymałem: Pierwsza wartość", po czym ponownie zatrzymuje się na kolejnym yield, zwracając "Wczytuję...".
  • Kolejne g.send("Druga wartość") powoduje, że "Druga wartość" zostaje przypisana do received_val, a generator kończy swoje działanie, wypisując "Koniec działania, ostatnia wartość: Druga wartość", po czym zgłasza wyjątek StopIteration.

Uwaga: Nie można użyć send() jako pierwszego wywołania generatora. Próba wysłania wartości do świeżo utworzonego generatora (g.send("wartość") przed next(g)) spowoduje błąd TypeError, ponieważ generator nie jest jeszcze "uruchomiony" i nie oczekuje wartości.

Przerywanie generatora wyjątkiem – throw()

Metoda throw() pozwala na zgłoszenie wyjątku wewnątrz generatora, co umożliwia obsługę błędów lub przedwczesne zakończenie jego działania.

def gen():
    yield "Start"
    while True:
        try:
            received = yield "Czekam na dane..."
            print(f"Otrzymałem: {received}")
        except ValueError as e:
            print(f"Obsłużono wyjątek: {e}")


g = gen()
print(next(g))
print(next(g))
print(g.send("Pierwsza wartość"))
g.throw(ValueError, "Błąd testowy")
print(g.send("Druga wartość"))


❯ python3 gen.py
Start
Czekam na dane...
Otrzymałem: Pierwsza wartość
Czekam na dane...
Obsłużono wyjątek: Błąd testowy
Otrzymałem: Druga wartość
Czekam na dane...

Użycie throw(ValueError, "Błąd testowy") powoduje rzucenie wyjątku ValueError w miejscu działania yield, co zostaje przechwycone i obsłużone w bloku except. Generator nie zostaje automatycznie zakończony, jeśli wyjątek jest obsłużony. Możemy zatem kontynuować wysyłanie wartości (send()). W przciwnym wypadku - jeśli wyjątek nie zostanie obsłużony wewnątrz generatora, następuje jego propagacja w górę stosu i w tym przypadku działanie programu zostanie przerwane.

Zamykanie generatora – close()

Metoda close() służy do natychmiastowego zakończenia działania generatora. Powoduje ona zgłoszenie wyjątku GeneratorExit wewnątrz generatora, który można następnie przechwycić, jeśli chcemy wykonać jakieś czynności porządkowe przed zamknięciem.

def gen():
    try:
        while True:
            received = yield "Oczekiwanie na dane..."
            print(f"Otrzymałem: {received}")
    except GeneratorExit:
        print("Zamykanie generatora")


g = gen()
print(next(g))
print(g.send("Pierwsza wartość"))
g.close()


❯ python3 gen.py
Oczekiwanie na dane...
Otrzymałem: Pierwsza wartość
Oczekiwanie na dane...
Zamykanie generatora

Po wywołaniu close(), generator przestaje działać, a każda próba jego dalszego użycia spowoduje błąd StopIteration. Obsługa wyjątku GeneratorExit pozwala na wykonanie czynności końcowych przed zamknięciem, np. zapisanie danych lub zwolnienie zasobów.

user-photo

Bartosz Szmyt

Fullstack Developer | Software Engineer | CERT

Copyright 2022-2025 Bartosz Szmyt