Pchełki Python, odcinek 4: yield

https://xpil.eu/8VVDz

Odkryłem niedawno nową konstrukcję programistyczną, która nieco zatrzęsła posadami mojego świata, widzianego oczyma programisty-amatora starej daty.

Należy bowiem wiedzieć, że mam za sobą wiele lat programowania w tak ezoterycznych językach jak Logo, QBasic oraz VBA, natomiast języków "porządnych" nauczyć się za młodu nie miałem za bardzo okazji. Albo raczej: okazja była, ale jakoś "nie wyszło". Najbardziej zaawansowane rzeczy, które pisałem w szkole, to były jakieś proste symulacje radarów w C++, ewentualnie w dawno już zapomnianym języku MODULA 2, którym z niewiadomych przyczyn katowano nas na studiach.

Niemniej jednak od niedawna próbuję - na ile czas pozwala - zgłębiać tajniki programowania w Pythonie. Python jest całkiem inny od reszty języków, głównie ze względu na niezwykłą zwartość składni. Jednak dzisiaj ja nie o tym. Dziś napiszę o iteratorach.

Czymże jest iterator?

Iterator to taki zwierz, który potrafi "wędrować" po elementach jakiejś kolekcji i "obrabiać je" pojedynczo.

Hm. To była definicja (C) xpil.eu 2014. Zerknijmy teraz do Wiki...

Wiki mówi:

Iterator można rozumieć jako rodzaj wskaźnika udostępniającego dwie podstawowe operacje: odwołanie się do konkretnego elementu w kolekcji (dostęp do elementu) oraz modyfikację samego iteratora tak, by wskazywał na kolejny element (sekwencyjne przeglądanie elementów). Musi także istnieć sposób utworzenia iteratora tak, by wskazywał na pierwszy element, oraz sposób określenia, kiedy iterator wyczerpał wszystkie elementy w kolekcji.

No dobra. Mnóstwo mądrych słów, a na jedno wychodzi 🙂 Tak czy siak, często istnieje konieczność przemieszczania się po elementach jakiejś kolekcji i operowania na nich pojedynczo. Jednak kiedy się jest starej daty programistą, można nie zdawać sobie sprawy z istnienia jakichś tam iteratorów i robić rzeczy "po staremu".

Zerknijmy na konkretny przykład.

Załóżmy, że chcemy zasymulować grę odbywającą się na planszy 4x4. Grą taką może być na przykład Boggle.

Zaczniemy od stworzenia klasy reprezentującej planszę:

class Board:
    fields = [['', '', '', ''], ['', '', '', ''], ['', '', '', ''], ['', '', '', '']]
    def getNeighbours(self, x, y):
        ret_val=list()
        for x_offset, y_offset in ([-1, -1], [-1, 0], [-1, 1], [0, -1], [0, 1], [1, -1], [1, 0], [1, 1]):
            new_x, new_y = x + x_offset, y + y_offset
            if 0<=new_x<=3 and 0<=new_y<=3:
                ret_val.append([new_x, new_y])
        return ret_val

Klasa Board, jak widać, oferuje na razie bardzo podstawową funkcjonalność. Można za jej pomocą:

  • Utworzyć zmienną (instancję) klasy Board
  • Uzyskać informację o tym, które pola sąsiadują z jakimś zadanym polem.

Sprawdźmy jak to działa:

b = Board()
for n in b.getNeighbours(2,3):
    print("neighbour = ", end=" ")
    print(n)

Wynik:

neighbour =  [1, 2]
neighbour =  [1, 3]
neighbour =  [2, 2]
neighbour =  [3, 2]
neighbour =  [3, 3]

Process finished with exit code 0

Przyjrzyjmy się bliżej metodzie getNeighbours. Na samym początku tej metody deklarujemy zmienną ret_val, która reprezentuje wartość (listę współrzędnych, której każdy element jest listą dwuelementową) zwracaną przez tę metodę. Następnie w pętli wypełniamy tę zmienną wartościami, czyli współrzędnymi pól sąsiadujących z (x, y). Na koniec zwracamy zmienną ret_val - i gotowe.

Metoda jest prościutka i w zasadzie ciężko się do czegoś przyczepić. Możnaby to tak zostawić i pójść na piwo.

Aczkolwiek...

Wyobraźmy sobie następujący scenariusz: ktoś chce przejrzeć listę sąsiadów pola (2, 3) w celu sprawdzenia, czy występuje wśród nich litera "W". Po znalezieniu litery "W" kończy przeglądanie listy sąsiadów i przechodzi do kolejnej części kodu.

W tym celu woła metodę getNeighbours(2, 3) a następnie iteruje po wyniku za pomocą pętli for.

Załóżmy dodatkowo, że litera "W" znajduje się w polu (1, 2), a więc pierwszym z pięciu sąsiadów pola (2, 3). Pętla znajduje literę "W" i kończy działanie, ignorując pozostałych czterech sąsiadów. A więc ta czwórka została utworzona zupełnie bezcelowo. Utworzenie jej zajęło 80% czasu i 80% pamięci więcej, niż gdybyśmy "wędrowali" po sąsiadach pojedynczo i sprawdzali każdego z nich indywidualnie.

Co więc można zrobić?

Ano, można zastąpić funkcję getNeighbours(...) iteratorem o tej samej nazwie. Zobaczmy:

class Board:
    fields = [['', '', '', ''], ['', '', '', ''], ['', '', '', ''], ['', '', '', '']]
    def getNeighbours(self, x, y):
        for x_offset, y_offset in ([-1, -1], [-1, 0], [-1, 1], [0, -1], [0, 1], [1, -1], [1, 0], [1, 1]):
            new_x, new_y = x + x_offset, y + y_offset
            if 0<=new_x<=3 and 0<=new_y<=3:
                yield([new_x, new_y])

Powyższy kod różni się od poprzedniej wersji bardzo nieznacznie. Jednak różnica jakościowa jest ogromna. Jak widać, zrezygnowano ze zmiennej ret_val, a także - o zgrozo! - zrezygnowano z operatora return, który przecież zwraca wartość do kodu wołającego funkcję. Jeżeli jednak wywołamy ten iterator, dostaniemy wynik identyczny jak w pierwszym przypadku.

O co chodzi?

Chodzi o to, że iterator nie musi przy każdorazowym wywołaniu budować pełnej listy sąsiadów. Zamiast tego, każde kolejne wywołanie iteratora (w pętli for) sprawi, że "przesunie" on "wskaźnik" na kolejny element kolekcji, i zwróci do kodu wołającego iterator wskaźnik na ten właśnie kolejny element. Dzięki temu zużycie pamięci jest minimalne (w każdym jednym momencie przechowujemy maksymalnie jeden element kolekcji, plus parę bajtów na dane kontrolne), maleje też czas potrzebny na przejrzenie kolekcji; w zależności od tego, czy przeglądanie kolekcji skończy się bliżej jej początku, czy też końca, czas ten może się skrócić drastycznie albo wcale. Ale jednak - może.

Jest jedna zasadnicza różnica między iteratorem a "zwykłą" metodą. Otóż jeżeli zawołamy "zwykłą" metodę, zwróci nam ona zawsze całą kolekcję. Jeżeli natomiast zawołamy w ten sam sposób iterator (bez pętli for, po prostu getNeighbours(...)), dostaniemy na wyjściu wskaźnik do iteratora. A więc trzeba pamiętać, że iterator to nie funkcja, i że jedyny sposób, żeby zmusić go do zwrócenia sensownych danych to iterowanie po tych danych pojedynczo.

Uwaga końcowa: w powyższym scenariuszu użycie iteratora wydaje się być trochę jak strzelanie z armaty do much, przecież to tylko pięć elementów, góra osiem, więc nie ma o co drzeć kotów. Jednak należy sobie uświadomić, że po pierwsze to tylko przykład ilustrujący jak działają iteratory w Pythonie, po drugie, jeżeli powyższy kod wywołamy milion (albo miliard) razy, oszczędność zacznie się robić konkretna, a po trzecie wreszcie jest to rozwiązanie eleganckie.

https://xpil.eu/8VVDz

2 komentarze

  1. “Sprawdźmy jak to działa:

    b = Board()
    for n in b.getNeighbours2(2,3):
    print(“neighbour = “, end=” “)

    jak widać, Perl należy do tej grupy programów, gdzie o błędach informuje klient, nie kompilator [ getNeighbours >2< ]

    1. Po pierwsze, nie Perl tylko Python, po drugie nie klient, tylko czytelnik, a po trzecie, dziękuję za zwrócenie uwagi 🙂 W rzeczywistości najpierw miałem napisane dwie metody (jedna z dwójką, druga bez), no i mi się gdzieś ta dwójka prześlizgnęła.

      Poprawione.

Leave a Comment

Komentarze mile widziane.

Jeżeli chcesz do komentarza wstawić kod, użyj składni:
[code]
tutaj wstaw swój kod
[/code]

Jeżeli zrobisz literówkę lub zmienisz zdanie, możesz edytować komentarz po jego zatwierdzeniu.