W tym artykule omawiamy iteratory i generatory, różnice pomiędzy nimi oraz analizujemy zagadnienia powiązane.
Iteratory
Typy sekwencyjne
Iteracja, to "przeskakiwanie" kolejnych elementów w sekwencji/kolekcji. Typy sekwencyjne to te, które mogą mieć wiele elementów i charakteryzuje je jakieś uporządkowanie. W Python to te, które implementują metodę __iter__
.
Przykłady:
>>> str.__iter__.__doc__
'Implement iter(self).'
>>> list.__iter__.__doc__
'Implement iter(self).'
>>> tuple.__iter__.__doc__
'Implement iter(self).'
>>> dict.__iter__.__doc__
'Implement iter(self).'
>>> set.__iter__.__doc__
'Implement iter(self).'
Przykłady iteracji:
>>> for c in "abc": print(c)
...
a
b
c
>>> L = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
>>> [x for x in L]
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
Typy sekwencyjne vs hash
Wróćmy jeszcze do kwestii uporządkowania. W Pythonie słowniki i zbiory (dict, set) polegają na hashowaniu. Hash to po prostu "skrót funkcji", algorytmu wyliczającego stałą wartość dla konkretnych danych wejściowych. Przykład:
>>> hash("a")
4543139507646774712
>>> hash("b")
322030919933194354
>>> hash("c")
5882347108410817770
>>> hash("a")
4543139507646774712
>>> hash("b")
322030919933194354
>>> hash("c")
5882347108410817770
Takie same dane wejściowe hashują się do takiej samej wartości. Nie możemy określić jednak żadnej kolejności.
Uruchomię to samo w innej sesji interpretera:
>>> hash("a")
5988627248662005859
>>> hash("b")
6908496110980848144
>>> hash("c")
708134308534412344
>>> hash("a")
5988627248662005859
>>> hash("b")
6908496110980848144
>>> hash("c")
708134308534412344
Dla typu set
również nie możemy określić kolejności elementów. W jednej sesji interpretera:
>>> set("aabbcdeefg")
{'a', 'd', 'f', 'b', 'c', 'e', 'g'}
>>> set("aabbcdeefg")
{'a', 'd', 'f', 'b', 'c', 'e', 'g'}
Ale już w innej sesji:
>>> set("aabbcdeefg")
{'b', 'g', 'c', 'e', 'f', 'd', 'a'}
>>> set("aabbcdeefg")
{'b', 'g', 'c', 'e', 'f', 'd', 'a'}
Niektóre typy są "niehashowalne" (nie implementują metody __hash__
). Na przykład właśnie dict
i set
.
>>> hash({})
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: unhashable type: 'dict'
>>> hash(set())
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: unhashable type: 'set'
Po szczegóły odnośnie mutable, immutable, życia obiektu (lifetime) odsyłam jednak do dokumentacji Python, a my wracamy do iteratorów przyjmując do wiadomości, iż w ramach sesji intepretera (runtime, lifetime obiektów) typy implementujące __iter__
posiadają jakieś uporządkowanie. Dzięki temu możemy "przeskakiwać" po kolejnych elementach w tych kontenerach danych.
Najprostszy iterator
Funkcja iter()
tworzy obiekt iteratora kolekcji.
>>> it = iter("codeasap")
>>> it
<str_iterator object at 0x7f9efcd1da60>
>>> it = iter([1, 2, 3])
>>> it
<list_iterator object at 0x7f9efcb6fca0>
Na takim obiekcie możemy operować przy użyciu funkcji next().
>>> it = iter([1, 2, 3])
>>> next(it)
1
>>> next(it)
2
>>> next(it)
3
>>> next(it)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
StopIteration
Kiedy iterator osiągnie koniec ("strażnika", sentinel), to interpreter zgłosi wyjątek StopIteration. Taki sentinel/terminator możemy podać do funkcji iter()
, a jeśli go nie podajemy, to przekazywany do iter()
obiekt musi implementować protokół iteratora. Ale o tym za chwilę.
Kiedy pobierzemy iterator na obiekt, a następnie będziemy iterować go w pętli for, pętla sama przerwie działanie obsługując "pod spodem" ten sygnał końca iteracji (StopIteration).
>>> it = iter([1, 2, 3])
>>> for i in it: print(i)
...
1
2
3
>>>
Wyjątek musimy sami obsłużyć jeśli sami pobieramy kolejne elementy używając next(). Tak, w pętli while
również:
>>> it = iter([1, 2, 3])
>>> while it: next(it)
...
1
2
3
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
StopIteration
Protokół iteratora
Prokół iteratora - najprościej mówiąc - to implementacja dwóch metod w klasie: __iter__()
oraz __next__()
. Metoda __iter__()
jest potrzebna w celu umożliwienia pobierania iteratora na obiekt takiej klasy, a __next__()
implementujemy w celu przesuwania tego iteratora (advancing). Przyjrzyjmy się po prostu implementacji takiej klasy, a od razu wszystko stanie się jasne. Najpierw kod, a zaraz za nim omówienie i użycie.
class MyIterable:
def __init__(self, items):
self.items = items
self.pos = 0
def __iter__(self):
self.pos = 0
return self
def __next__(self):
if self.pos < len(self.items):
tmp = self.items[self.pos]
self.pos += 1
return tmp
raise StopIteration()
Zdefiniowałem klasę MyIterable
. Inicjalizator przyjmuje dowolną kolekcję, metoda __next__()
zwracaja kolejne elementy dopóki pozycja nie przekroczy rozmiaru tej kolekcji (sekwencji), a __iter__()
musi zwrócić obiekt, który zawiera implementację __next__()
- w tym przypadku zatem jest to właśnie ten obiekt (self). Użycie tego typu będzie identyczne jak w poprzednich przykładach. Nie ma tutaj niczego trudnego, czy nowego.
>>> mi = MyIterable([1, 2, 3])
>>> mi
<MyIterable object at 0x7f9f34e1c8e0>
Pobieram iterator na ten obiekt i używam next().
>>> it = iter(mi)
>>> it
<MyIterable object at 0x7f82272c88e0>
>>> next(it)
1
>>> next(it)
2
>>> next(it)
3
>>> next(it)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
raise StopIteration()
StopIteration
Sprawdźmy jeszcze zachowanie w pętli for
:
>>> for i in it: print(i)
...
1
2
3
>>>
Oczywiście można użyć takiego iteratora w kontekście listy/krotki:
>>> it = iter(mi)
>>> list(it)
[1, 2, 3]
Nie ma tutaj żadnej magii.
Wracajac jeszcze do wcześniej wspomnianego sentinela. Funkcja iter() ma taki prototyp:
iter(object[, sentinel])
Dokumentacja informuje nas, że jeśli nie przekażemy tego drugiego argumentu, to obiekt musi wspierać protokół iteratora lub implementować operator indeksowania __getitem__
(z indeksacją liczbami całkowitymi, startując od 0 /czyli jak np. w listach: L[0]/). Jeśli obiekt przekazany do iter() nie będzie spełniał tych wymogów, to interpreter zgłosi wyjątek typu TypeError. Nie będę tego jednak już tutaj demonstrował.
Generatory
Przykładowy problem: Chciałbym iterować liczby od 0 do ∞.
Chcę zatem napisać pętlę, która w nieskończoność będzie wydawać mi kolejne liczby. Nie mam jednak tyle pamięci w komputerze, aby przechować nieskończoną listę liczb (bajtów). Poza tym - chcę tę pętlę umieścić w funkcji, lub w metodzie klasy, by korzystać z niej wielokrotnie. Jeśli wywołam funkcję z pętlą nieskończoną, to program będzie nieskończenie wykonywał tę pętlę. Co zrobić?
Najprostszy generator
W Pythonie tworzę funkcję, która zamiast zwracać wartość (return) wydaje ją (yield).
>>> def gen():
... yield 1
...
>>> gen
<function gen at 0x7f67642bf0d0>
>>> gen()
<generator object gen at 0x7f6764461660>
gen()
to funkcja, która zamiast return
używa yield
. Funkcja jak funkcja, natomiast jej wywołanie zwraca mi obiekt generatora. Taki obiekt mogę przekazać do wywołania next():
>>> G = gen()
>>> next(G)
1
Spróbuję ponownie z tym samym obiektem:
>>> next(G)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
StopIteration
Został zgłoszony wyjątek StopIteration, w funkcji był tylko jeden yield, funkcja niczego więcej nie wydaje. Wróćmy do kodu gen(). Chciałem przecież mieć w niej pętlę nieskończoną.
def gen():
while True:
yield 1
Używam jej teraz identycznie jak wcześniej. Jednak tym razem mogę wielokrotnie wykonywać next()
.
>>> G = gen()
>>> next(G)
1
>>> next(G)
1
>>> next(G)
1
>>> next(G)
1
Generator kolejnych liczb
Świetnie, zatem niech ta funkcja generuje faktycznie kolejne liczby:
def gen():
n = 0
while True:
yield n
n += 1
I sprawdzamy co się stanie:
>>> G = gen()
>>> next(G)
0
>>> next(G)
1
>>> next(G)
2
>>> next(G)
3
>>> next(G)
4
>>> next(G)
5
Zauważ: w funkcji jest pętla nieskończona, a jednak:
- jej wywołanie nie powoduje bezpośrednio wykonania pętli
- przy każdym next() funkcja wydaje (yield) jeden element
- pętla
while
w funkcji gen() jest zawieszana aż do czasu kolejnego wywołania next()
Generatory w Python to funkcje, które mają w sobie wywołanie yield. Może być jedno, może ich być więcej. yield to słowo kluczowe, które "odkłada na bok" wartość i zawiesza działanie funkcji generatora. My tę "odłożoną" wartość odbieramy i jeśli potrzebujemy, to z generatora możemy pobrać kolejny element. Jeśli w funkcji byłoby return - funkcja skończyłaby się w tym momencie, gdyż return powoduje wyjście z funkcji (powrót /return/ do miejsca zaraz za wywołaniem tej funkcji /call/).
StopIteration
Jeżeli generator wygeneruje już wszystkie elementy, to musi w jakiś sposób zgłosić, że nie ma już "nic więcej". Zacznijmy od słowa "iteration". Iteracja to "przeskakiwanie" kolejno po elementach.
Napiszmy generator kwadratów liczb w zadanym przedziale:
def generate_squares(start, stop):
while start < stop:
yield start ** 2
start += 1
Wywołuję tę funkcję i przypisuję generator do zmiennej:
>>> g = generate_squares(1, 4)
>>> g
<generator object generate_squares at 0x7f6764461660>
Moglibyśmy wywoływać next() na generatorze:
>> g = generate_squares(1, 4)
>>> next(g)
1
>>> next(g)
4
>>> next(g)
9
>>> next(g)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
StopIteration
Ale spróbujmy użyć generatora w pętli.
>>> g = generate_squares(1, 4)
>>> for item in g:
... print(item)
...
1
4
9
>>>
Tutaj widzimy, że możemy przeiterować generator, a zgłoszony wyjątek nie powoduje błędu w programie. Pętla for w Pythonie obsługuje ten wyjątek i przerywa działanie bez "wywrotki" programu.
Nie jest to zatem "wyjątek" per se, a sposób na sygnalizowanie końca iteracji.
Zauważ również, że użyłem w funkcji generatora warunku start < stop
. Zrobiłem tak dla zachowania konwencji w Python, którą możemy zaobserwować używając range()
.
>>> range(1, 4)
range(1, 4)
>>> list(range(1, 4))
[1, 2, 3]
range w Python jest wprawdzie klasą...
>>> r = range(1, 4)
>>> type(r)
<class 'range'>
...ale zasada jej działania jest podobna. Nie tworzy od razu całej listy elementów, a generuje je wtedy, kiedy je pobieramy. W powyższym przykładzie dopiero użycie list()
sprawiło iterację range()
.
Dlatego możemy użyć range
jako zakresu (przykładowo) od 0 do "dziesięć do potęgi sto tysięcy":
>>> range(0, 10 ** 100000)
>>>
To się wykona w "mgnieniu oka", ponieważ te liczby nie są w ogóle tworzone. Gdyby były tworzone, to w moim komputerze brakłoby pamięci.
>>> len(str(10 ** 100000))
100001
To jest liczba tak duża, że Python nie poradzi sobie z dzieleniem:
>>> (int.__sizeof__(1) * 10 ** 100000) / 1024
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
OverflowError: integer division result too large for a float
Można zastosować floor division
, ale... wielo-wielo-wielokrotnie...
Klasa generatora
Spróbujmy zatem napisać własny Range, próbując naśladować zachowanie range()
.
Pominę dodatkowe metody range()
(count, index), a skupię się na iteracji i generowaniu wartości.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
|
To co widzimy, to połączenie iteratora z yield.
W inicjalizatorze zapisuję po prostu przekazane wartości (start, stop, step). Jeśli nie podano kroku - domyślnie wynosi on 1
. Następnie dodaję metodę __iter__
, która sprawi, że obiektu tej klasy będzie można używać w kontekście iteracji oraz sekwencji ("pętla for po kolejnych elementach", lub "jako lista/krotka").
Pythonowy range()
potrafi generować elementy w kolejności rosnącej i malejącej. Zależne jest to od wartości start
, stop
i samego kroku (step
). Mamy więc dwa kierunki. Trzeba też sprawdzić czy przypadkiem nie podaliśmy złego kroku (np. "od 0 do 10 co minus 3").
Jeśli nie podamy w ogóle wartości stop
- wystartujemy od 0
i skończymy na start
.
Sam krok zawsze zwiększam (+=
) - jeśli krok będzie liczbą ujemną - "zwiększę" go o liczbę ujemną.
Sprawdzamy MyRange w przypadku wartości rosnących.
>>> r = MyRange(0, 10, 2)
>>> list(r)
[0, 2, 4, 6, 8]
>>> for item in r: print(item)
...
0
2
4
6
8
Działa poprawnie. Sprawdzamy teraz przypadek, w którym chcemy generować "w tył".
>>> r = MyRange(10, 0, -2)
>>> list(r)
[10, 8, 6, 4, 2]
>>> for item in r: print(item)
...
10
8
6
4
2
Pięknie. Teraz przypadki kiedy nie ma argumentów stop
oraz step
, a start jest dodatni, bądź ujemny.
>>> list(MyRange(10))
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
>>> list(MyRange(-10))
[]
Przypadki z błędnym krokiem:
>>> list(MyRange(0, 10, -1))
[]
>>> list(MyRange(10, 0, 1))
[]
A dla pewności - asertywnie - upewniam się, że MyRange
działa jak range
.
>>> assert list(MyRange(0, 10, 2)) == list(range(0, 10, 2)), "INCR"
>>> assert list(MyRange(10, 0, -2)) == list(range(10, 0, -2)), "DECR"
>>>
>>> assert list(MyRange(0, 10, -2)) == list(range(0, 10, -2)), "impossible: -step"
>>> assert list(MyRange(10, 0, 2)) == list(range(10, 0, 2)), "impossible: +step"
>>>
>>> assert list(MyRange(10)) == list(range(10)), "Start only"
>>> assert list(MyRange(-10)) == list(range(-10)), "Negative start"
>>>
Wszystkie przypadki testowe są zgodne, wygląda OK.
Różnice: iterator a generator
Patrząc na kod możemy swierdzić, iż wielkich różnic pomiędzy tymi zagadnieniami nie ma. Jednak powinniśmy zdać sobie sprawę, że podstawowym zastosowaniem iteratora jest iteracja danych istniejących, a zastosowaniem generatorów jest iteracja danych tworzonych "w locie". Iterator stosujemy gdy mamy dane. Jeśli mamy kolekcję miliarda obiektów, to tyle danych mamy w pamięci. Używając generatorów - w danej chwili (przebiegu iteracji) mamy jedynie jeden obiekt, interpreter zawiesza wykonanie funkcji generatora, wydaje wartość, a wykonanie funkcji zostanie wznowione przy kolejnym użyciu generatora. Dopiero wtedy powstanie kolejny obiekt. Możemy zatem "wygenerować nieskończoność".
AsyncIterator / AsyncGenerator
Wykorzystanie yield w Python nie ogranicza się jedynie do omówionych przypadków generatorów. Na bazie mechanizmu zawieszania i odwieszania wykonania funkcji zbudowano interfejs asynchroniczny w Python. Dzisiaj widzimy już słowa kluczowe async oraz await. Nie będę w tym artykule omawiał programowania asynchronicznego, natomiast warto wiedzieć, iż iteratory i generatory mają swoje odpowiedniki asynchroniczne od wersji Python 3.6 (PEP-0525).
Nie wnikając w istotę asynchroniczności - protokół iteratora asynchronicznego wygląda bardzo podobnie. Składa się z metod:
__aiter__()
__anext__()
.
Sama pętla for również ma dla takiego kontekstu swoją formę: async for ...
. Jest to użyteczne choćby w przypadku iterowania danych cząstkowych (np. odczytu danych fragmentami z dysku, czy przez sieć, kiedy czas oczekiwania na załadowanie danych jest na tyle długi, że opłaca się zawiesić wykonywanie iteracji i dać szansę innym funkcjom/operacjom na wykonanie. Dodatkowo wiele takich asynchronicznych iteratorów może wtedy czekać na dane ze swoich źródeł. System operacyjny buforuje w tym czasie dane, asynchroniczna pętla w tym czasie jest zawieszona, "nie kręci się w kółko", a program może ten czas wykorzystać na inne operacje.
Zainteresowanych zagadnieniami asyncio
od podstaw zachęcam do zapoznania się z serią omawiającą od strony praktycznej tworzenie asynchronicznego crawlera w Python. Tam wykorzystujemy wysokopoziomy interfejs asyncio
, pojawia się też scenariusz asynchronicznej iteracji fragmentów danych odczytywanych przez sieć oraz zapoznajemy się z zagadnieniem testowania kodu asynchronicznego.
Inwalidacja iteratorów
Osobom doświadczonym oraz wnikliwym może pojawić się w myślach słuszne pytanie: skoro iterator wskazuje dany element, to co dzieje się, kiedy iterowany obiekt się zmienia?
Przyjrzyjmy się najpierw zagadnieniu modyfikacji obiektu iterowanego.
L = [0, 1, 2, 3, 4, 5, 6]
for i in L:
print(i, end=" -> ")
if i % 2 == 0:
L.remove(i)
print(i)
Wykonuję ten kod i obserwujemy wyniki:
$ python inval.py
0 -> 0
2 -> 2
4 -> 4
6 -> 6
Kiedy "jesteśmy na elemencie parzystym" - usuwamy go. Następuje zmniejszenie rozmiaru listy, pod indeksem usuniętego elementu znajduje się teraz kolejny, który gubimy...
Stanie się tak również kiedy napiszemy: for i in iter(L)
.
Prześledźmy zatem krok po kroku.
>>> L = [0, 1, 2, 3, 4, 5, 6]
>>> it = iter(L)
>>> next(it)
0
>>> L.pop(0)
0
>>> next(it)
2
Zmiana listy w Pythonie wywołuje pod spodem realokację pamięci. Listy w Python to zwykłe tablice w C. Tablica to wskaźnik na ciągły obszar pamięci (contiguous memory).
Przykładem niech będzie list.extend()
. Kiedy zmieniamy rozmiar listy, Python dokonuje realokacji całego bloku i pod wskaźnik (zmienna listy) trafi adres początku nowego bloku.
To w tym bloku znajdą się wcześniejsze elementy listy i te, którymi właśnie rozszerzyliśmy tę listę. Tablica musi być ciągła, ponieważ indeksowanie tablicy to przesuwanie wskaźnika ("arytmetyka wskaźników", wszystkie typy mają swój rozmiar, wskaźniki również). W tej sytuacji iterator jest wciąż "ważny", choć oczywiście - jak wyżej - możemy zgubić dane. Dlatego nie powinniśmy zmieniać rozmiaru obiektów podczas iteracji.
Jeśli użyjemy iteratora ze słownikiem, lub typem set
, to Python zaprotestuje w taki sam sposób jak przy zwykłej iteracji pętlą for:
>>> s = set([1, 2, 3])
>>> it = iter(s)
>>> next(it)
1
>>> s.clear()
>>> next(it)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
RuntimeError: Set changed size during iteration
W tym przypadku nie ma żadnej realokacji, obiekty po prostu giną, są zwalniane z pamięci (free()
).
Można tę ideę zaprezentować np. w C++.
1 2 3 4 5 6 7 8 9 10 11 12 13 |
|
vec.begin()
to wskaźnik na pierwszy element. vec.end()
to NULL, nasz sentinel/terminator. Kiedy go spotkamy, to wiemy, że dotarliśmy do końca kolekcji.
W linii 8 sięgamy pod adres elementu wskazywanego przez iterator, a w linii 9 zmieniamy rozmiar całej kolekcji.
W następnej iteracji wskaźnik wskazuje zatem "w maliny". Wykonanie tego programu zakończy się w najlepszym wypadku "Segmentation fault". W gorszym wypadku może stać się coś poważniejszego (np. SpaceX wybuchnie).
$ c++ iter.cxx -o iter -Wall -Werror
$ ./iter
0
1
2
3
4
5
6
0
0
0
4113
0
858851888
943206709
2617
...
Segmentation fault
Do całego zagadnienia można dodać jeszcze temat programowania wielowątkowego. W Python mamy GIL, więc i tak jednocześnie interpreter zajmuje się jednym wątkiem. Na niższym poziomie możemy jednak używać Python jako języka embedded, albo wykorzystywać w nim własne biblioteki pisane w innych językach, które również mogą wykorzystywać wątki lub modyfikować różne obszary pamięci. GIL można również wyłączyć. Wtedy docieramy do tego czym jest Python. Via Wikipedia:
CPython is the reference implementation of the Python programming language.
Written in C and Python, CPython is the default
and most widely used implementation of the Python language.
range, map, filter, zip, ...
Przy omówieniu generatorów przyglądaliśmy się klasie range
. W Pythonie istnieje więcej podobnych typów, m.in: map, filter, zip.
W przypadku map
nie ma potrzeby wykonywać funkcji dla każdego elementu od razu. map
to klasa, która implementuje protokół iteratora.
>>> map(bool, [0, 1, None, True, ""])
<map object at 0x7fa7dd398b80>
>>> list(map(bool, [0, 1, None, True, ""]))
[False, True, False, True, False]
Podobnie filter
. Zauważmy, że moja funkcja my_bool()
nie została ani raz jeszcze wywołana:
>>> def my_bool(v): print("hello", v); return bool(v)
...
>>> filter(my_bool, [0, 1, None, True, False])
<filter object at 0x7fa7dd3980a0>
Ale kiedy wykorzystam już protokół iteratora, wtedy moja funkcja zostaje wywołana:
>>> list(filter(my_bool, [0, 1, None, True, False]))
hello 0
hello 1
hello None
hello True
hello False
[1, True]
>>>
Identycznie będzie dla zip
:
>>> X = [0, 1, 2]
>>> Y = "ABC"
>>> zip(X, Y)
<zip object at 0x7fa7dd4a3bc0>
>>> list(zip(X, Y))
[(0, 'A'), (1, 'B'), (2, 'C')]
Podsumowanie
Witam czytelników, którzy dotarli do niniejszego podsumowania. Gratuluję ciekawości i wytrwałości. Temat starałem się omówić spójnie i myślę, że mnie samemu niewiele pozostaje tutaj do dodania. Udało się nie zakończyć na wskaźnikach w C. Wszelkie niuanse, które pominąłem, na pewno znajdziemy w dokumentacji języka.