Python generator rozwija dalej temat iteratorów w Pythonie. Warto wiedzieć czym jest i jak go używać, a na pewno to zaprocentuje 🙂
Pisanie iteratorów jest męczące !
W poprzednim artykule opisałem iteratory, które towarzyszą nam w codziennym programowaniu. Jednakże pisanie za każdym razem metod protokołu iteratora __next__
i __iter__
wydaje się być trochę niewygodne. Szczególnie wtedy, gdy wykonujemy proste operacje na każdym elemencie.
Już w 2001. roku Guido Van Rossum zaakceptował propozycję dodania instrukcji yield. Ta tajemnicza instrukcja weszła na stałe do Pythona od wersji 2.2. W dniu pisania tego posta, to prawie 20 lat temu :). Głównym pomysłem było stworzenie typu funkcji, która będzie w stanie zwrócić następny (next – brzmi znajomo?) rezultat, jednocześnie utrzymywać swój lokalny stan. Miało to pozwolić na wznowienie wykonywania funkcji tam, gdzie była zatrzymana.
czym jest yield?
Do czego służy i jakie zadanie spełnia yield? Można powiedzieć, że tego słowa kluczowego używa się podobnie do return. Podczas gdy wywołamy return przekazujemy na stałe kontrolę do miejsca w kodzie, który wywołał funkcję (ang. caller). Yield również przekazuje kontrolę, ale tylko tymczasowo. Tym samym tak jakby „usypia” funkcję w której został wywołany i zapamiętuje jej stan lokalny.
Każda funkcja zawierająca instrukcję yield jest funkcją generatora, a każdy generator to iterator. Generatory w Pythonie to sposób na łatwiejszą implementację iteratorów. Zamiast pisania tzw. boilerplate’u (powtarzalny, nadmiarowy kod) typowego dla definiowania iteratorów przy użyciu klas, możemy napisać prostą i krótszą funkcję z instrukcją yield.
Warto też wiedzieć, że generator w Pythonie może oznaczać zarówno funkcję generatora, jak i generator jako iterator.
Generator – funkcja to funkcja, która tak naprawdę jest fabryką tworzącą generator – iterator. W tym aspekcie różni się diametralnie od zwykłych funkcji, bo zachowuje się trochę jak konstruktor. Więc konstrukcja def może być lekko myląca. Jedyną różnicą jest występowanie przynajmniej jednej instrukcji yield. Trzeba przyznać, że jest to subtelna różnica jak na całkiem sporą logiczną różnicę.
Kiedy funkcja generatora jest wywoływana, argumenty są przypisywane do lokalnego kontekstu funkcji (jak w normalnych funkcjach), ale kod wewnątrz funkcji nie zostanie wykonany. Funkcja generatora zwróci obiekt generatora-iteratora, który jest zgodny z protokołem iteratora. Dzięki temu w bardzo prosty sposób możemy użyć takiej funkcji np. w pętli for.
A więc mamy obiekt iteratora generatora. Za każdym wywołaniem na tym obiekcie funkcji wbudowanej next()
kod funkcji generatora jest wykonywany do napotkania instrukcji yield albo return albo końca funkcji. Gdy napotkaną instrukcją jest yield, to stan funkcji generatora jest zamrażany, a wartość po prawej stronie yield jest zwracana do kodu wykonującego next()
. Jeśli natomiast generator wyjdzie w innym sposób niż przy użyciu instrukcji yield, to już na stałe przestaje generować nowe wartości. Nie musimy więc zgłaszać wyjątku StopIteration
co jest na pewno kolejnym udogodnieniem!
Podsumowując tę część, generatory to dużo prostszy sposób na tworzenie iteratorów. Zaleca się raczej używać generatorów, zamiast iteratorów zbudowanych na podstawie klasy (co w znacznej większości przypadków jest możliwe). Oczywiście zalety płynące z iteratorów aplikują się również do tych stworzonych przez generatory. Przede wszystkim oszczędność zasobów.
Najwięcej korzyści zauważymy na różnicy w zużyciu RAMu:
from pympler.asizeof import asizeof
print(asizeof([x for x in range(50000)])/1024) # ~1.9 MB
print(asizeof((x for x in range(50000)))/1024) # ~0.4 KB
W powyższym małym eksperymencie porównałem zużycie pamięci RAM przez listę jak i generator dla 50 000 elementów. Lista zajmuje dość sporo miejsca, bo 1.9 MB, gdzie generator tak naprawdę 0.4 KB! Rożnica prawie 4500-krotna 🙂 A będzie tym większa, im więcej elementów będzie w kontenerze. Generator sprawdza się wspaniale na iterowaniu po wielkich zbiorach.
My chcemy kod!
Myślę, że najlepiej pokażę zalety generatorów, tworząc ciąg Fibonacciego w wersji generatora.
def fibonacci(limit):
iteration = 0
a, b = 0, 1
while iteration <= limit:
yield a
a, b = b, a + b
iteration += 1
Tak napisaną funkcję generatora można normalnie przekazać np. do pętli for:
for i in fibonacci(5):
print(i)
Zauważ, o ile krótszy kod robiący to samo co w poprzednim poście o iteratorach udało nam się napisać. Dwukrotnie mniej kodu to dobry rezultat, co nie?
Schemat działania Python generatora z przykładu powyżej
Aby lepiej zobrazować Ci jak działa python generator przygotowałem dla Ciebie prosty schemat tego jak działa generator z przykładu.
- Wykonanie pierwszej instrukcji
next()
na naszej funkcji generatora. Ustawia wszystkie zmienne początkowe i wchodzi do pętli while. Na koniec zwraca wartość a (czyli 0) jednocześnie 'usypiając’ generator do następnego wywołanianext()
. - Podczas gdy generator jest nieaktywny zaznaczyłem gdzie się zatrzymał czerwoną linią przerywaną. Za każdą kolejną iteracją zatrzyma się w tym samym miejscu. Oczywiście oprócz ostatniej, bo wtedy zakończy generowanie.
- Gdy wykonamy kolejne
next()
na naszym generatorze, pętla while wykona pozostały kod obliczający kolejną wartość ciągu Fibonacciego. Po tym wraca do sprawdzenia warunku w pętli while, aby rozpocząć jej nowy cykl. Jeśli jest spełniony, to generator znowu zwraca wartość poprzez yield i zamraża swój stan. - … i tak aż do momentu, gdy warunek w pętli while nie jest spełniony. W tym momencie kod wychodzi z pętli i dochodzi do końca funkcji. To znaczy nic innego jak koniec iteracji generatora.
yield + return – co z tego wyjdzie?
Jak wspomniałem wcześniej instrukcję yield można swobodnie używać razem z return. W wersjach Pythona przed 3.3 pusta instrukcja return, albo return None przerywa generator – działanie podobne do zgłoszenia wyjątku StopIteration
w iteratorze. Ale przekazanie wartości do return innej niż None spowoduje już wyjątek SyntaxError
. Aczkolwiek od Pythona 3.5 (do 3.7) zapis w funkcji generatora:
return value
był równy semantycznie z:
raise StopIteration(value)
Co ważniejsze od wersji Pythona 3.7 każdy zgłoszony i nieobsłużony wyjątek wewnątrz generatora powoduje RuntimeError! Czyli nawet zgłoszenie wyjątku StopIteration
zakończy się tym błędem! Miało to pozwolić na łatwiejsze wyłapywanie i debugowanie błędów wewnątrz generatorów.
Więc w nowszych wersjach Pythona return służy jako przedwczesne zakończenie generatora.
Ograniczenie generatorów
Instrukcja yield nie może znaleźć się w bloku try
dla konstrukcji try/finally
. Powód jest prosty. Nie ma gwarancji, że generator zostanie wznowiony, a przez to nie ma gwarancji, że kod w bloku finally
zostanie kiedykolwiek uruchomiony.
Można jeszcze krócej!
Idąc dalej, aby jeszcze bardziej ułatwić sprawę w Pythonie 2.4 do języka weszły tzw. Generator Expressions. Już wtedy (2002 rok) list comprehensions były namiętnie używane. Jednak w wielu przypadkach nie potrzeba było trzymać wszystkich elementów w pamięci tak jak robi to lista, tylko jednokrotnie przeiterować po tych wartościach. Składnia generator expressions:
generator_expression = (expression for item in collection)
Dla przykładu zapis:
gen_expr = (x**2 for x in range(10))
Jest równy takiemu zapisowi funkcji python generatora:
def __gen(exp):
for x in exp:
yield x**2
gen_expr = __gen(iter(range(10)))
Wygląda to przyjmnie. W przejrzysty sposób stworziliśmy generator w jednej linijce. Wyobraź sobie pisanie iteratora na podstawie klasy z metodami __next__
i __iter__
. Byłoby znacznie dłużej, a efekt ten sam!
Warto wiedzieć, że generator expressions można użyć bezpośrednio w pętli for:
for element in <generator_expression>:
...
Trzeba pamiętać, że po przeiterowaniu generatora iteratora nie da się go zresetować i zacząć od nowa. W tym przypadku warto rozważyć użycie funkcji generatora czy też iteratora na podstawie klasy.
na zakończenie
Miło, że tutaj dotarłeś/dotarłaś 🙂 Podsumowując temat python generatora oraz iteratora, zamieszczam poniżej dla wzrokowców graficzną reprezentację zależności między poszczególnymi elementami.