2021-12-23 / Bartłomiej Kurek
Python: iteratory i generatory

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
class MyRange:
    def __init__(self, start, stop=None, step=None):
        self.start = start
        self.stop = stop
        self.step = (step or 1)

    def __iter__(self):
        start = self.start if self.stop is not None else 0
        stop = self.stop if self.stop is not None else self.start

        if start < stop and self.step > 0:  # INCR
            while start < stop:
                yield start
                start += self.step
        elif start > stop and self.step < 0:  # DECR
            while start > stop:
                yield start
                start += self.step
        else:
            # impossible cases:
            #  (start < stop and self.step < 0)
            #  (start > stop and self.step > 0)
            return []

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
#include <iostream>
#include <vector>

int main()
{
    std::vector<int> vec = {0, 1, 2, 3, 4, 5, 6};
    for(auto it = vec.begin(); it != vec.end(); it++) {
        std::cout << *it << std::endl;
        vec.resize(0);
    }

    return 0;
}

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.