Często na rozmowach kwalifikacyjnych pada pytanie o Python Iterator. Warto, żebyś wiedział do czego służy i jakie daje korzyści w codziennym programowaniu.
Iteracja, Iterable i iteratory wyjaśnione w jednym miejscu
Te trzy nazwy powodują dość duże zdezorientowanie wśród programistów. Czas rozwiać wątpliwości i opisać czym jest każda z nich.
Iteracja (ang. iteration) to proces w którym zestaw poleceń/instrukcji powtarzane są skończoną ilość razy lub do momentu spełnienia warunku. Iterujemy wewnątrz pętli typu for czy while.
Iterowalny (ang. iterable – w dalszej części będę posługiwał się angielskim słowem) to wszystko po czym można iterować/przechodzić. Iterable posiada metodę zwracającą tzw. iterator. Ta metoda to `__iter__()`
Przykładowo lista w Pythonie to iterable:
numbers = [2, 4, 8] # Iterable
Iterator (ang. iterator) to obiekt implementujący protokół – tu zdziwienie – iteratora:). O protokołach w Pythonie krótko wspomniałem w artykule o czwartej zasadzie SOLID. Iterator jest obiektem, który przeprowadza iteracje. Dodatkowo trzyma stan iteracji, czyli wie na którym elemencie z iterowalnego obiektu aktualnie jesteśmy. Metoda do pobrania kolejnego obiektu z kolekcji to `__next__()`.
Napiszmy własny Python iterator
Napiszmy więc własny Python iterator. Niech będzie to iterator zwracający ciąg Fibonacciego – tak bardzo znany w procesach rekrutacyjnych 🙂
from collections.abc import Iterable, Iterator
class FibonacciIterable(Iterable):
def __init__(self, limit):
self.limit = limit
def __iter__(self):
return FibonacciIterator(self.limit)
class FibonacciIterator(Iterator):
def __init__(self, limit):
self.iteration = 0
self.a = 0
self.b = 1
self.limit = limit
def __next__(self):
result = self.a
if self.limit < self.iteration:
raise StopIteration
self.a, self.b = self.b, self.a + self.b
self.iteration += 1
return result
Mając tak zdefiniowany iterable oraz iterator, możemy swobodnie iterować po naszym zbiorze danych i np. „wypluć” na ekran wartość z każdej iteracji.
fibonacci = FibonacciIterable(5)
for value in fibonacci:
print(value) # 0, 1, 1, 2, 3, 5
Patrząc na FibonacciIterable widzimy, że implementuje dwie metody:
- __init__ w inicjalizatorze przekazujemy wartość, dla której chcemy obliczać ciąg Fibonacciego.
- __iter__ zgodnie z protokołem iteratora, metoda ta zwraca FibonacciIterator. Zwróć uwagę, że przekazujemy do iteratora wartość, która służy do określenia ilości iteracji. Możemy tak zrobić, bo to właśnie iterator trzyma stan iteracji.
Klasa FibonacciIterator skupia się na zwracaniu kolejnych wartości:
- __init__ w inicjalizatorze przypisujemy wartości początkowe potrzebne dla obliczania ciągu Fibonacciego. Dodatkowo implementujemy pole iteration, które odpowiada za trzymanie stanu iteracji.
- __next__ to druga metoda protokołu iteratora. Jej zadaniem jest zwracanie następnej (stąd nazwa next) wartości. Ważne jest, że do zakończenia iteracji w Pythonie należy zgłosić wyjątek StopIteration. Pętle takie jak 'for <element> in <iterable>’ potrafią złapać ten wyjątek i przerwać dalsze iterowanie. W naszym przykładzie rzucamy tym wyjątkiem w sytuacji, gdy przekroczyliśmy ilość iteracji podanych w parametrze przy tworzeniu FibonacciIterable. Istotną zaletą iteratora jest to, że oblicza wartość w locie przez co oszczędza zużycie zasobów. Ciekawostką jest to, że ta metoda przyjmuje parametr default, która będzie zwaracana przy wyczerpaniu iteratora. Zwrócimy więc wartość default zamiast rzucić wyjątkiem StopIteration.
Kod naszego iteratora napisaliśmy w dwóch oddzielnych klasach, żeby jasno pokazać rozgraniczenie między iterable a iteratorem. Dodatkowo zwróć uwagę, że dziedziczymy po klasach abstrakcyjnych Iterator i Iterable z wbudowanej biblioteki, aby wymusić implementację niezbędnych metod. Warto wiedzieć, że wbudowana klasa abstrakcyjna Iterator dziedziczy po Iterable więc zawiera również metodę __iter__.
W większości przypadków można zaimplementować obie metody(__iter__, __next__) w pojedynczej klasie. Dzięki temu nasz kod staje się jeszcze krótszy.
from collections.abc import Iterator
class Fibonacci(Iterator):
def __init__(self, limit):
self.iteration = 0
self.a = 0
self.b = 1
self.limit = limit
def __iter__(self):
return self
def __next__(self):
result = self.a
if self.limit < self.iteration:
raise StopIteration
self.a, self.b = self.b, self.a + self.b
self.iteration += 1
return result
Klasa Fibonacci implementuje obie kluczowe metody. Jako, że metoda __iter__ musi zwrócić iterator – a ona sama nim jest – więc zwraca sama siebie. Na początku może się to wydawać trochę nieczytelne.
__iter__ vs iter()
Zauważ, że metody z podwójnymi podkreślnikami (tzw. dunder methods albo magic methods) nie powinny być wykonywane bezpośrednio na obiektach. Nie bez powodu. Taki zapis w świecie Pythona oznacza a’la prywatny element danego obiektu.
Tak naprawdę dobrze o tym wiesz, bo gdy chcesz sprawdzić rozmiar listy to wpisujesz „len(twoja_lista)„, a nie „twoja_lista.__len__()„.
Dlaczego nie odwoływać się do magic methods bezpośrednio? Przede wszystkim z powodu gorszej wydajności. Takie wywołanie za każdym razem szuka danej metody w przestrzeni nazw obiektu. Dodatkowo gdy w danej klasie zdefiniowaliśmy metodę __getattribute__ to ona też będzie wywołana za każdym razem.
Natomiast zapis len(object) wywołuje bezpośrednio metodę __len__ na obiekcie, bez przeszukiwania przestrzeni nazw obiektu. Jest to zalecany sposób wywoływania takich metod i dotyczy to również metod protokołu iteratora: iter() oraz next().
Jak działa pętla for „pod spodem”
Mając już własny iterator przedstawię Ci jak z nim obcować.
Najczęściej, aby przejść przez kolekcję, używamy pętli for. Czyli dla naszego przykładu byłoby to mniej więcej tak:
fibonacci = Fibonacci(5)
for value in fibonacci:
print(value) # 0, 1, 1, 2, 3, 5
Ale w jaki sposób działa pętla for? Odwołując się do metod z protokołu iteratora:
- wywołuje metodę __iter__ klasy Fibonacci, która zwraca iterator
- a potem wywołuje __next__ iteratora dopóki nie rzuci on wyjątek StopIteration
Wiedząc to, możemy przyjąć że pętla for działa na zasadzie przedstawionej poniżej:
fibonacci = Fibonacci(3)
iterator = iter(fibonacci) # zwróć obiekt iteratora
while True:
try:
value = next(iterator) # zwróć kolejny element
print(value)
except StopIteration:
break
Dość proste, prawda?
Warto podkreślić, że gdy przeiterujemy przez całą sekwencję i iterator rzuci wyjątkiem, to nie możemy już 'zresetować’ i zacząć od nowa. Aby móc iterować ponownie, musimy stworzyć nowy obiekt iteratora.
Podsumowanie
Iteratory pozwalają nam przechodzić przez sekwencje obiektów. Leniwie zwracają kolejne wartości, przez co oszczędzają zasoby (pamięć). Do stworzenia własnego iteratora potrzebujemy napisać własną klasę zgodną z protokołem iteratora. Python iterator który został przeiterowany raz, nie może być zresetowany i iterowany drugi raz.
Koncept iteratorów brzmi podobnie do generatorów. Jednak generator to podklasa iteratora. O tym napiszę w następnym poście.