Kolejny temat będzie nieco trudniejszy. Będzie też bardziej skupiał się na „bebachach” Pythona, czyli o tym, co dzieje się pod powierzchnią, gdy wykonujemy iterację. Zacznijmy od tego, że pętle, a w zasadzie każde działanie, które polega na przechodzeniu przez sekwencję, wymaga wykorzystania tak zwanego protokołu iteracyjnego. Jest to model, który opiera się na wywołaniu metod obiektów iteracyjnych. Z pojęciem protokołu teracyjnego ściśle wiążą się obiekty składane, takie jak listy.
Najbardziej znamiennym przykładem iteracji w Pythonie jest pętla for, która działa na dowolnym typie sekwencji, takich jak listy, łańcuchy znaków czy kropki. W rzeczywistości pętla ta działa na każdym obiekcie, który jest iterowalny. Czym jest obiekt literowalny w Pythonie? Jest to albo fizycznie przechowywana sekwencja, czyli na przykład lista lub łańcuch znaków, albo obiekt, który zwraca jeden wynik naraz, jeśli jest wywołany w kontekście iteracyjnym, takim jak właśnie pętla for.

Protokół iteracyjny oraz iteratory plików
Żeby zrozumieć, jak działa iteracja w Pythonie, warto zacząć od przykładu. Obiektem literowalnym jest na przykład obiekt pliku.
>>> print(open('test.txt').read())
test
nowa linia
kolejna linia
Obiekty plików obsługują metodę readline(), która odczytuje po jednym wierszu z pliku na raz. Każde wywołanie metody sprawia, że kursor przesuwa się do kolejnego wiersza. Na końcu zwracany jest pusty łańcuch znaków. Zobaczmy, jak użycie protokołu iteracyjnego pozwala na wypisywanie kolejnych wierszy w terminalu:
>>> file = open('test.txt')
>>> file.readline()
'test\n'
>>> file.readline()
'nowa linia\n'
>>> file.readline()
'kolejna linia'
>>> file.readline()
"
Jak widzimy metoda readline() rzeczywiście pozwala na wypisania jednego wiersza za każdym jej wywołaniem. W ten sam sposób działa metoda o nazwie __next__, która jest niejawnie wywoływana w trakcie iteracji.
>>> file.__next__()
'test\n'
>>> file.__next__()
'nowa linia\n'
Zgodnie z zasadami protokołu iteracji dowolny obiekt, który:
- obsługuje metodę __next__(), która przechodzi do kolejnego wyniku,
- zgłasza wyjątek StopIteration, gdy wyniki zostaną już wyczerpane
jest określany mianem iteratora.
Dlatego właśnie polecanym sposobem odczytywania zawartości plików jest skorzystanie z pętli for, która automatycznie wywoła metodę __next__() danego obiektu dokładnie tyle razy, ile elementów się w nim znajduje.
>>> for line in open('test.txt'):
... print(line, end=' ')
...
test
nowa linia
kolejna linia
Jest to sposób odczytywania pliku, który nie przeciąża pamięci komputera nawet wtedy, gdy plik jest duży. Tak naprawdę bowiem wczytywany jest tylko jeden wiersz naraz. Z kolei w przypadku metody readlines() obiektu pliku wszystkie wiersze są z kolei wczytywane do listy.
Iter oraz next
Dla uproszczenia w Pythonie od wersji 3.0 możliwe jest korzystanie z funkcji next, które automatycznie wywołuje metodę __next__() danego obiektu:
>>> file = open('test.txt')
>>> next(file)
'test\n'
>>> next(file)
'nowa linia\n'
Wspominając o metodzie i funkcji next należy również wspomnieć o metodzie __iter__() oraz funkcji iter(), czyli o elementach brakujących do stworzenia pełnego obrazu tego, jak działa protokół iteracyjny. Każdy obiekt iterowalny posiada metodę __iter__(), którą Python uruchamia przy wywołaniu funkcji iter(). Wówczas obiekt iterowany zwraca obiekt iteratora, który generuje wartości podczas iteracji i którego metoda __next__() jest uruchamiana przez funkcję next. Jest to istotne dlatego, że niektóre obiekty są zarówno obiektem intererowalnym, jak i swoim iteratorem, co dotyczy na przykład plików. Obiekt iteratora jest jednak używany jedynie wewnętrznie w celach realizowania założeń protokołu iteracyjnego. Do tego dochodzą obiekty, które same w sobie są kontekstem interakcyjnym, a do tego mogą być literowalne, do których należy między innymi funkcja map(), zip() oraz obiekt generatora.
Sposób działania protokołu iteracyjnego można odzwierciedlić w kodzie. Tworzymy najpierw obiekt iterowalny, a następnie pozyskujemy jego iterator:
>>> L = [1, 2, 3]
>>> I = iter(L)
Teraz możemy wywołać na iteratorze metodę __next__(), która zwróci kolejny element obiektu iterowalnego:
>>> I.__next__()
1
>>> I.__next__()
2
>>> I.__next__()
3
>>> I.__next__()
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
StopIteration
Tego typu proces nie jest potrzebny w przypadku obiektów, które są swoimi iteratorami, jak na przykład pliki:
>>> file = open('test.txt')
>>> iter(file) is file
True
Uwaga! Obiekty, które są zarówno obiektami iterowanymi, jak i swoimi iteratorami, obsługują tylko jedną aktywną iterację. Z kolei obiekty, które nie posiadają swojego własnego iteratora obsługują wiele aktywnych iteracji.
Weźmy przykład pliku, który wiemy, że jest zarówno obiektem iterowalny, jak i swoim własnym iteratorem. Można się o tym przekonać poprzez następujący przykład:
>>> file = open('test.txt')
>>> I = iter(file)
>>> I2 = iter(file)
>>> I.__next__()
'test\n'
>>> I2.__next__()
'nowa linia\n'
Jak widzimy, mimo że obiekty iteratorów są dwa, to iteracja jest jedna.
>>> L = [1, 2, 3]
>>> I = iter(L)
>>> I2 = iter(L)
>>> I.__next__()
1
>>> I2.__next__()
1
Z kolei w przypadku listy, która nie jest iteratorem dla samej siebie, oddzielnych iteracji jest tyle, ile iteratorów – są one od siebie odseparowane.
Wbudowane typy iterowalne
Inne budowane typy literowalne to między innymi słowniki, których iterator automatycznie zwraca po jednym kluczu na raz w każdej iteracji:
>>> D = {'a': 5, 'b': 7}
>>> I = iter(D)
>>> I.__next__()
'a'
>>> I.__next__()
'b'
Z powodu protokołu iteracyjnego wyniki działań niektórych funkcji muszą być opakowane w funkcję list(). Chodzi o to, aby wywołać wszystkie wyniki jednocześnie. Dotyczy to między innymi wbudowanej funkcji zip(), map(), range() oraz enumerate().
>>> list(zip((1, 2), (3, 4)))
[(1, 3), (2, 4)]
>>> list(enumerate('Python'))
[(0, 'P'), (1, 'y'), (2, 't'), (3, 'h'), (4, 'o'), (5, 'n')]
>>> list(range(3,8))
[3, 4, 5, 6, 7]
Listy oraz inne obiekty składane
Protokół iteracyjny jest najczęściej wykorzystywany w postaci pętli for. Drugie miejsce pod względem częstotliwości użycia zajmują listy składane, czyli coś, co po angielsku nazywa się list comprehension. Jest to tworzenie listy składającej się z elementów z innej listy oraz/lub takich elementów, które zostały poddane pewnemu działaniu. Przykładowo poniższe działanie:
>>> L = [1, 2, 3, 4, 5]
>>> for i in range(len(L)):
... L[i] += 10
...
>>> L
[11, 12, 13, 14, 15]
Można zapisać dzięki możliwościom list składanych, a więc takich, które wykonują działanie na każdym elemencie listy oraz/lub pobierają wartości z innej listy, w następujący sposób:
>>> L = [1, 2, 3, 4, 5]
>>> L = [x + 10 for x in L]
>>> L
[11, 12, 13, 14, 15]
Lista składana nie jest tym samym, co wersja wykorzystująca pętlę for, ponieważ tworzy nowy obiekt, co może mieć znaczenie wtedy, gdy w kodzie znajduje się wiele referencji do danej listy. Jest to jednak znacznie szybszy sposób na tworzenie nowych list.
Listy składane – forma i zasady działania
Zapis listy składanej zaczyna się od dowolnego wyrażenia, które będzie wykonane dla każdego elementu pętli. Następnie wykorzystywany jest nagłówek z pętli for. Na końcu umieszcza się sekwencję, której kolejne wartości zostaną wykorzystane do stworzenia nowego obiektu. Przykładowo dla poniższej listy składanej:
>>> nowa_L = [x + 1 for x in L]
Najpierw wykonywana jest iteracja po elementach w liście L. Do każdego elementu przypisywana jest zmienna x . Następnie x jest przenoszony do wyrażenia na samym początku, czyli x + 1, po czym wynik tego wyrażenia jest umieszczany w nowej liście.
>>> nowa_L
[12, 13, 14, 15, 16]
Zaletą list składanych jest przede wszystkim zwięzła forma, co ze względu na częste wykorzystanie list w Pythonie ma duże znaczenie. Listy składane działają także o wiele szybciej od pętli for, ponieważ ich iteracje są znacznie szybsze niż w przypadku pętli – listy składane zostały w odpowiedni sposób zoptymalizowane.
Kiedy powinniśmy rozważyć wykorzystanie listy składanej? Wtedy, gdy chcemy stworzyć sekwencję, której każdy element ma zostać poddany pewnej operacji. Dobrym przykładem jest pobranie wierszy pliku, a następnie usunięcie białych znaków po prawej stronie.
>>> file = open('test.txt')
>>> lines = file.readlines()
>>> lines
['test\n', 'nowa linia\n', 'kolejna linia']
>>> lines = [line.rstrip() for line in lines]
>>> lines
['test', 'nowa linia', 'kolejna linia']
Operację tę można wykonać również prościej, jako że plik jest iterowalny, zaś lista składana jest takim samym kontekstem iteracyjnym jak pętla for:
>>> new_lines = [line.rstrip() for line in open('test.txt')]
>>> new_lines
['test', 'nowa linia', 'kolejna linia']
Listy składane mogą wykonywać więcej niż jedno wyrażenie na każdym elemencie sekwencji. Pozwala to zaoszczędzić wiele linijek kodu.
>>> new_lines = [line.rstrip().upper() for line in open('test.txt')]
>>> new_lines
['TEST', 'NOWA LINIA', 'KOLEJNA LINIA']
>>> [('linia' in line, line[0:3]) for line in open('test.txt')]
[(False, 'tes'), (True, 'now'), (True, 'kol')]
Ostatni przykład pokazuje, że możemy zbierać wiele wyników, jeśli tylko są opakowane w kolekcję, taką jak lista lub właśnie krotka.
Lista składana: filtrowanie za pomocą if
W listach składanych możemy korzystać z instrukcji warunkowych, które umożliwiają odfiltrowanie pewnych elementów, jeśli tylko wykonany dla nich test nie zwraca prawdy. Przykładowo chcemy pobrać tylko te wiersze pliku, które zawierają słowo 'linia’, a przy okazji pozbawić je białych znaków po prawej stronie:
>>> [line.rstrip() for line in open('test.txt') if 'linia' in line]
['nowa linia', 'kolejna linia']
To samo wyrażenie można zapisać za pomocą pętli for, jednak w bardziej rozbudowanej postaci:
>>> res = []
>>> for line in open('test.txt'):
... if 'linia' in line:
... res.append(line.rstrip())
...
>>> res
['nowa linia', 'kolejna linia']
Nieco bardziej przydatny przykład to zliczanie pustych wierszy za pomocą listy składanej:
>>> len([line for line in open('test.txt') if line != ''])
3
Jak widać w tym pliku nie ma żadnych pustych wierszy.
Listy składane z pętlą zagnieżdżoną
Listy składane mogą zawierać więcej niż jedną pętlę for, a każda z nich może mieć powiązaną ze sobą klauzulę if. Służy to na przykład do tworzenia kombinacji składających się z elementów z kilku sekwencji:
>>> [x * y for x in 'abc' for y in [1, 2]]
['a', 'aa', 'b', 'bb', 'c', 'cc']
Z wykorzystaniem pętli for wygląda to następująco:
>>> [x * y for x in 'abc' for y in [1, 2]]
['a', 'aa', 'b', 'bb', 'c', 'cc']
Ze względu na to, że listy składane są tak zwięzłe, do bardziej skomplikowanych operacji poleca się używać jednak pętli for.
Inne konteksty iteracyjne
Pętle for oraz listy składane to nie jedyne konteksty iteracyjne, które implementują protokół iteracyjny. Tak naprawdę każda wbudowana operacja, która przychodzi przez elementy obiektu od lewej do prawej wykorzystuje protokół iteracji. Dotyczy to chociażby przypisania sekwencji, przypisania rozszerzonego, przypisania odcinka czy testów przynależności. Protokół iteracyjny wykorzystuje nawet metoda list – extend(), jako że pozwala na dodanie do listy więcej niż jednego elementu na raz wraz z jego rozpakowaniem.
Kontekst iteracyjny jest wykorzystywany w:
- Wbudowanej funkcji map(), które przyjmuje dwa argumenty: funkcję jaką wykona na każdym elemencie obiektu literowego, a także sam obiekt iterowalny:
>>> list(map(str.upper, 'abc'))
['A', 'B', 'C']
Dla wywołania wyników funkcji map() musimy opakować ją w konstruktor listy, jako że wynik zwracany przez tą funkcję jest obiektem literowalnym.
2. Wbudowanej funkcji zip(), która łączy elementy kilku obiektów. Podobnie jak w przypadku map(), do uzyskania pełnego w wyniku działania funkcji musimy skorzystać z konwersji na listę:
>>> list(zip((1, 2), ('a', 'b')))
[(1, 'a'), (2, 'b')]
3. Tak samo zachowuje się funkcja enumerate(), która do każdego elementu dodaje go licznik:
>>> list(enumerate(open('test.txt')))
[(0, 'test\n'), (1, 'nowa linia\n'), (2, 'kolejna linia')]
4. Warto również wspomnieć o funkcji filter(), która filtruje wyniki na podstawie danego warunku:
>>> list(filter(str, open('test.txt')))
['test\n', 'nowa linia\n', 'kolejna linia']
5. Protokół iteracyjny jest wykorzystywany także w funkcji sorted(), która zwraca listę składającą się z uporządkowanych elementów danej sekwencji
>>> sorted(open('test.txt'))
['kolejna linia', 'nowa linia\n', 'test\n']
6. Jak wspomnieliśmy wcześniej, protokół iteracyjny jest wykorzystywany w przypisaniach:
>>> a, b, c = open('test.txt')
>>> a
'test\n'
>>> a, *b = open('test.txt')
>>> b
['nowa linia\n', 'kolejna linia']
7. Stosuje się go także w wywołaniu funkcji tuple i list:
>>> tuple(['abc', 'def', 'ghi'])
('abc', 'def', 'ghi')
8. Protokół iteracyjny jest wykorzystywany nawet w testach przynależności:
>>> 'p' in 'papryka'
True
Można powiedzieć zatem, że protokół iteracji jest wykorzystywany w każdej operacji, która skanuje obiekt od lewej do prawej.
Słowniki składane
Słowniki również można tworzyć w sposób równie zwięzły co listy – mowa wówczas o słownikach składanych. Sposób ich inicjalizacji jest następujący:
>>> {key: value for key, value in [(1,2), (3,4)]}
{1: 2, 3: 4}
Jako sekwencje wykorzystywaną do iteracji musimy użyć obiekt iterowalny, z którego można pobrać co najmniej dwie wartości. Inaczej interpreter zwróci błąd.
>>> {key: value for key, value in [1, 2]}
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "<stdin>", line 1, in <dictcomp>
TypeError: cannot unpack non-iterable int object
Inny przykład słownika składanego:
>>> {key: value for (key, value) in enumerate(open('test.txt'))}
{0: 'test\n', 1: 'nowa linia\n', 2: 'kolejna linia'}
W słownikach również można korzystać z klauzuli if – musi ona znaleźć się na końcu:
>>> {key: value for (key, value) in enumerate(open('test.txt')) if 'l' in value}
{1: 'nowa linia\n', 2: 'kolejna linia'}
Funkcje do działań na liczbach
Protokół iteracji jest powszechnie wykorzystywany w funkcjach, które są standardowo stosowane do działań na liczbach. Należy do nich między innymi:
1. Funkcja sumująca sum():
>>> sum([1,2,3])
6
2. Funkcja any(), która zwraca prawdę, jeśli choć jeden element obiektu literowanego jest interpretowany jako prawda:
>>> any(['', 'abc'])
True
3. Funkcja all(), która zwraca prawdę, gdy wszystkie elementy obiektu są interpretowane jako prawda:
>>> all(['', 'abc'])
False
4. Funkcja max() zwracająca największą wartość obiektu iterowanego:
>>> max([1,2,3])
3
5. Funkcja min() zwracająca najmniejszą wartość w obiekcie literowalnym:
>>> min([1,2,3])
1
Obiekt iterowalny range()
Funkcja range() zwraca obiekt iterowalny, który na żądanie dostarcza liczby z określonego zakresu. Dzięki temu, że działa na żądanie, nie zużywa zbyt wiele pamięci na zachowanie całej listy wyników. W Pythonie 3.x ta funkcja zwraca jednak obiekt literowalny, jej wynik należy opakować wywołanie funkcji list(). Żeby sprawdzić, czy funkcja rzeczywiście zwraca obiekt iterowalny oraz czy rzeczywiście obsługuje jedną iterację na raz, możemy skorzystać z omawianej wcześniej iteracji ręcznej oraz wywołania iteratora:
>>> R = range(10)
>>> iter(R) is R
False
Jak widać obiekt funkcji range() będzie obsługiwał więcej niż jedną iterację, jako że nie jest iteratorem dla samego siebie. Obiekt ten jest jednak jak najbardziej iterowalny, o czym świadczy możliwość wywołania na nim metody __next__(), jednak dla ręcznej iteracji należy wcześniej wygenerować iterator obiektu:
>>> I = iter(R)
>>> I.__next__()
0
>>> I.__next__()
1
Obsługa więcej niż jeden iteracji na raz:
>>> I2 = iter(R)
>>> I2.__next__()
0
Obiekty literowalne: map(), zip() oraz filter()
O funkcjach budowanych map(), zip() oraz filter() mówiliśmy już wcześniej. W Pythonie 3.x ich wyniki są obiektami iterowalnymi, które zwracają wyniki na żądanie. W przeciwieństwie do funkcji range() obsługują one jednak tylko jedną aktywną iterację, czyli są swoimi własnymi literatorami.
>>> M = map(str.upper, 'abc')
>>> iter(M) is M
True
Uzyskanie wyników działania funkcji map() w pętli for lub za pomocą wywołania konstruktora listy:
>>> M = map(str.upper, 'abc')
>>> for x in M:
... print(x)
...
A
B
C
>>> M = map(str.upper, 'abc')
>>> list(M)
['A', 'B', 'C']
To samo dotyczy funkcji zip():
>>> Z = iter(zip())
>>> iter(Z) is Z
True
>>> Z = zip((1,2), (3,4))
>>> next(Z)
(1, 3)
Funkcja filter() zwraca wyniki w obiekcie literowalnym, dla którego przekazana funkcja ma wartość True. Jak każda funkcja zwracająca obiekt literowalny, wywołanie jej bez użycia kontekstu iteracyjnego nie doprowadza do wyświetlenia wyników, lecz do wyświetlenia adresu miejsca, w którym obiekt znajduje się w pamięci:
>>> L = [1, 'abc', 900]
>>> filter(str, L)
<filter object at 0x000001DC46AF06A0>
Dla uzyskania wyników należy skorzystać z kontekstu iteracyjnego (for, list…):
>>> list(filter(str, L))
[1, 'abc', 900]
Iteratory wielokrotne vs iteratory pojedyncze
Różnica pomiędzy funkcjami, które zwracają obiekty literowalne, jednak są lub nie są swoimi własnymi iteratorami, polega na tak zwanym wyczerpywaniu wyników. W przypadku obiektów, która obsługują wiele jednoczesnych iteracji, takich jak obiekt funkcji range(), możemy korzystać z kilku iteratorów bez obawy o wyczerpanie się wyników:
>>> R = range(10)
>>> I1 = iter(R)
>>> I2 = iter(R)
>>> I1.__next__()
0
>>> I1.__next__()
1
>>> I2.__next__()
0
>>> I1.__next__()
2
Jak widać dla każdego iteratora iteracja jest prowadzona niezależnie, a więc zgłoszenie wyjątku StopIteration nie jest tak szybkie dla tego samego obiektu, jak w przypadku wywołania funkcji obsługującej tylko jedną iterację. Poniżej przykład z map():
>>> M = map(lambda x: x + 1, [1,2,3])
>>> I1 = iter(M)
>>> I2 = iter(M)
>>> print(I1.__next__(), I1.__next__(), I1.__next__())
2 3 4
>>> print(I2.__next__())
Traceback (most recent call last):
File "<python-input-5>", line 1, in <module>
print(I2.__next__())
~~~~~~~~~~~^^
StopIteration
Jak widać chęć wywołania kolejnej iteracji za pomocą innego iteratora na obiekcie funkcji map() zgłasza wyjątek, ponieważ iteracja 'wyczerpała’ już zakres wyników zwracanych przez tę funkcję.
Widoki słowników jako obiekty literowalne
Metody słownikowe takie jak keys(), values() oraz items() również zwracają obiekty literowalne, a więc należy umieścić je na przykład w wywołaniu listy, aby uzyskać wszystkie wartości na raz. Możliwe jest również oczywiście wykorzystanie pętli for. Widoki słowników nie są jednak swoimi własnymi literatorami, dlatego żeby iterować ręcznie należy najpierw wywołać iterator.
>>> D = {'a': 1, 'b': 2}
>>> D.keys()
dict_keys(['a', 'b'])
>>> list(D.keys())
['a', 'b']
>>> I = iter(D)
>>> I.__next__()
'a'
>>> next(I)
'b'
Jak widać podczas ręcznej teracji w każdej kolejnej iteracji zwracany jest klucz słownika. Można to wykorzystać na przykład do sortowania słownika:
>>> D = {'d': 4, 'c': 5, 'a': 7}
>>> for key in sorted(D): print(key, D[key])
...
a 7
c 5
d 4
Warto pamiętać, że z pojęciem obiektów literowalnych łączą się generatory obiektów, wyrażenia generatorów, a także możliwość tworzenia własnych iterowanych obiektów za pomocą klas, dla których przeciąża się odpowiednie operatory.
W następnym rozdziale wrócimy do funkcji, a jeszcze później nadejdzie czas na omówienie zasięgów 🙂
Dodaj komentarz
Musisz się zalogować, aby móc dodać komentarz.