Generatory w Pythonie

Cześć.

Generatory to jeden z elementów Pythona, który bardzo często pojawia się na rekrutacjach, natomiast w kodzie pojawia się on znacznie rzadziej niż powinien. Ale zacznijmy od początku. A dokładniej od przeczytania i zrozumienia mojego artykułu o iteratorach w Pythonie – jest tam sporo wiedzy, która pozwoli nam płynnie wejść z temat generatorów.

Czym jest generator?

Generator, jak wszystko w Pythonie, jest obiektem. Możemy go stworzyć na dwa sposoby:

  • wykonując funkcję generującą,
  • wykonując wyrażenie generujące.

I przykład:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
from collections.abc import Iterator, Iterable, Generator

generator_1 = (x for x in [1, 2, 3, 4, 5])


def generator_2():
  yield 1
  yield 2
  yield 3
  yield 4
  yield 5


print(type(generator_1)) # <class 'generator'>
print(type(generator_2)) # <class 'function'>
print(type(generator_2())) # <class 'generator'>

print(isinstance(generator_1, Generator), isinstance(generator_2(), Generator)) # True True

print(isinstance(generator_1, Iterator), isinstance(generator_2(), Iterator)) # True True

print(isinstance(generator_1, Iterable), isinstance(generator_2(), Iterable)) # True True

print(issubclass(Generator, Iterator)) # True

W powyższym kodzie znajdują się komentarze, które opisują treść wypisywaną przez daną linię. Warto to przeanalizować, ponieważ wynika z tego kilka ważnych rzeczy. Przede wszystkim generatory są Iteratorami i są typu Iterable. To sprawia właśnie, że można je używać wygodnie z pętlą for. Generator jest po prostu klasą, która dziedziczy po Iteratorze.

Jak działa generator?

Kluczem do zrozumienia tego, jak działa generator jest oczywiście słówko yield. Sprawia ono, że zawieszamy nasz generator do kolejnego wykonania funkcji next() – czyli do kolejnego obiegu pętli. Ma to jedną ogromną przewagę nad klasycznym iteratorem – generatory same obsługują zapamiętywanie swojego stanu. Zerknij na taki kod:

1
2
3
4
5
6
7
8
9
10
11
12
13
def generator_3():
  print(1)
  yield "jestem po wypisaniu 1"
  print(2)
  yield "jestem po wypisaniu 2"
  print(3)
  yield "jestem po wypisaniu 3"
  print(4)
  yield "jestem po wypisaniu 4"


for x in generator_3():
  print(x)

wypisze on, jak się pewnie domyślasz:

1
jestem po wypisaniu 1
2
jestem po wypisaniu 2
3
jestem po wypisaniu 3
4
jestem po wypisaniu 4

Zadziała to w taki sposób:

  1. Pierwszy obieg pętli wykona na generatorze funkcję next(), która uruchomi zwrócony generator, następnie wykona się pierwsza linijka, tj. print(1) i to, co jest po słówku yield zostanie zwrócone do pętli, jako element Iterable’a.
  2. Nasz generator zwrócił napis „jestem po wypisaniu 1”, a My, już w pętli, wypisujemy go na ekran.
  3. Kolejny obieg pętli ponownie wykona na generatorze funkcję next(), co spowoduje ponowne uruchomienie generatora, ale już od momentu wcześniejszego zawieszenia, więc wypisze to co jest pomiędzy pierwszym a kolejnym yieldem, czyli w tym przypadku print(2), i to co jest po słówku yield zostanie zwrócone do pętli, jako element Iterable’a.
  4. Nasz generator zwrócił napis „jestem po wypisaniu 2”, a My, już w pętli, wypisujemy go na ekran.
  5. … I tak dalej. Aż do skończenia funkcji.

Zauważ, co się stało – funkcja została tak naprawdę podzielona na bloki, których granice wyznacza słówko yield.

I teraz najważniejsze. Zrealizujemy ten sam efekt, ale korzystając z klasycznego iteratora:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
class A:

  def __init__(self):
      self.__iteration = 0

  def __iter__(self):
      return self

  def __next__(self):
      if self.__iteration == 0:
          print(1)
          self.__iteration += 1
          return "jestem po wypisaniu 1"
      elif self.__iteration == 1:
          print(2)
          self.__iteration += 1
          return "jestem po wypisaniu 2"
      elif self.__iteration == 2:
          print(3)
          self.__iteration += 1
          return "jestem po wypisaniu 3"
      elif self.__iteration == 3:
          print(4)
          self.__iteration += 1
          return "jestem po wypisaniu 4"
      else:
          raise StopIteration()


for x in A():
  print(x)

Zauważ, że ify, które się pojawiły, pamiętają stan funkcji next() – czyli dokładnie to, co generator robi za nas. A generator trochę krótszy, prawda?

Wyrażenie generujące

Jednolinijkowa składnia list comprehensions jest dostępna w Pythonie prawie od zawsze. Sprawia to, że w projektach aż roi się od tych oneliner’ów. I nie byłoby w tym nic złego, gdyby nie to, że w sporej ilości przypadków nie jest to najoptymalniejsze rozwiązanie. Aby to udowodnić, użyjemy biblioteki pympler, która policzy nam ile nasze obiekty zajmują w pamięci:

1
2
3
4
5
6
7
from pympler.asizeof import asizeof


as_list_comprehension = [x**2 for x in range(1000000)]
as_generator_expression = (x**2 for x in range(1000000))

print(asizeof(as_list_comprehension), "vs", asizeof(as_generator_expression))

Wynik jest szokujący: 40448720 vs 448 (bajtów). Gdzie leży różnica? Rezultatem list comprehensions jest lista intów a rezultatem generator expression jest generator, który z definicji jest leniwy, tzn. nie generuje wartości, póki nie zaczniemy po nim iterować. Załóżmy, że nasze inty chcemy potem, w zależności od wartości, poprzekazywać do różnych funkcji jako argumenty. W takiej sytuacji, w przypadku skorzystania z generatora, zużycie pamięci będzie równe jednemu elementowi per iteracja, a w przypadku list comprehensions – zużycie pamięci będzie równe całej liście. A skoro w działaniu nie widać różnicy, to po co marnować pamięć? Szczególnie, że takie nagłe wyskoki pamięci to prosta droga, by jakiś element systemu operacyjnego się zainteresował Waszym programem i w trosce o swoje własne bezpieczeństwo – ubił go.


Czekaj, stop!

Podoba Ci się to co tworzę? Jeśli tak to zapraszam Cię do zapisania się na newsletter:

Aby potwierdzić swoją subskrypcję, odbierz pocztę i kliknij w link potwierdzający:) jeśli maila nie ma to poczekaj chwile i/lub sprawdź folder spam/inne/oferty itp :)

Aby potwierdzić swoją subskrypcję, odbierz pocztę i kliknij w link potwierdzający:) jeśli maila nie ma to poczekaj chwile i/lub sprawdź folder spam/inne/oferty itp :)
a w ramach prezentu otrzymasz całkowicie za darmo, dwa dokumenty PDF „6 (nie zawsze oczywistych) błędów popełnianych podczas nauki programowania” który jest jednym z efektów ponad siedmioletniej pracy i obserwacji rozwoju niejednego programisty oraz „Wstęp do testowania w Pythonie”, będący wprowadzeniem do biblioteki PyTest.
Jeśli to Cię interesuje to zapraszam również na swoje social media.

Jak i do ewentualnego postawienia mi kawy :)

Zwracanie wartości do generatora

Ciekawą właściwością generatora jest możliwość wstrzyknięcia mu wartości. W przykładach wyżej yield tylko zwraca, ale yield może też przyjmować. Aby wysłać coś do generatora wystarczy wykonać metodę send(val). Zerknij na przykład:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
from time import sleep


def generator_4():
  i = 1
  step = 1
  while True:
      new_step = yield i

      if new_step:
          step = new_step
      i += step

      if i == 0:
          return
      sleep(0.5)


generator = generator_4()
for x in generator:
  print(x)

  if x == 10:
      print(generator.send(-1))

Ten kawałek kodu tworzy generator, który co 0.5 sekundy generuje kolejną liczbę całkowitą, zaczynając od jedynki. W naszej pętli napisany jest warunek, że jeśli dostaniemy z generatora liczbę 10, to wysyłamy mu liczbę „-1”. Jest to interpretowane przez generator jako skok używany do generowania kolejnej liczby, a to sprawia, że generator policzy od 1 do 10, a następnie zawróci, by zakończyć się na jedynce. Rezultat będzie taki:

 1
 2
 3
 4
 5
 6
 7
 8
 9
 10
 9
 8
 7
 6
 5
 4
 3
 2
 1

Zauważ też, że jeśli chcesz zakończyć generator, to możesz po prostu użyć słówka return.

Generator jako klasa

Chyba nigdy nie widziałem generatora jako klasy, ale wykorzystując wiedzę z poprzedniego i aktualnego wpisu, umiemy już wystarczająco dużo, by taki generator stworzyć. Wystarczy wykorzystać fakt, że klasa, aby była Iterable, musi definiować metodę

1
__iter__()
i zwracać iterator. A przecież generator jest iteratorem, więc nic nie stoi na przeszkodzie, żeby taki kod działał:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class GeneratorAsClass:
  def __init__(self, start, threshold=10):
      self.__start = start
      self.threshold = threshold

  def __iter__(self):
      i = self.__start
      step = 1
      while True:
          yield i
          i += step
          if i == self.threshold:
              step = -1

          if i == 0:
              return


for x in GeneratorAsClass(1, 10):
  print(x)

I rzeczywiście, taki kod działa. Wypisuje liczby od 1 do 10, a potem cofa się do 1 i kończy działanie.

Kończąc

Tak jak wspomniałem na początku tego artykułu, sądzę, że generatory pojawiają się w naszym kodzie zdecydowanie za rzadko. Mam nadzieję, że ten wpis sprawi, że ktoś kiedyś, tworząc swój kod, pomyśli dwa razy i np. robiąc kolejne list comprehension, zamieni je na generator expression.

Dzięki za wizytę,
Mateusz Mazurek
Mateusz M.

Pokaż komentarze

Ostatnie wpisy

Podsumowanie: maj, czerwiec, lipiec i sierpień 2024

Oj daaawnoo mnie tu nie było. Ale wakacje to był czas dużej liczby intensywnych wyjazdów i tak naprawdę, dopiero jakoś… Read More

2 miesiące ago

Podsumowanie: kwiecień 2024

Cześć! Zapraszam na krótkie podsumowanie kwietnia. Wyjazd do Niemiec A dokładniej pod granicę z Francją. Chrześnica miała pierwszą komunię. Po… Read More

6 miesięcy ago

Podsumowanie: luty i marzec 2024

Ostatnio tygodnie były tak bardzo wypełnione, że nie udało mi się napisać nawet krótkiego podsumowanie. Więc dziś zbiorczo podsumuję luty… Read More

7 miesięcy ago

Podsumowanie: styczeń 2024

Zapraszam na krótkie podsumowanie miesiąca. Książki W styczniu przeczytałem "Homo Deus: Historia jutra". Książka łudząco podoba do wcześniejszej książki tego… Read More

9 miesięcy ago

Podsumowanie roku 2023

Cześć! Zapraszam na podsumowanie roku 2023. Książki Zacznijmy od książek. W tym roku cel 35 książek nie został osiągnięty. Niemniej… Read More

10 miesięcy ago

Podsumowanie: grudzień 2023

Zapraszam na krótkie podsumowanie miesiąca. Książki W grudniu skończyłem czytać Mein Kampf. Nudna książka. Ciekawsze fragmenty można by było streścić… Read More

11 miesięcy ago