Elementy programowania funkcyjnego

Iteratory

Pętla for pozwala w Pythonie na iterowanie po elementach jakiejkolwiek sekwencji i wykonanie pewnych operacji dla każdego jej elementu.

Iteracja po liście

Iterować można po liście:

for x in [1,4,5,10]:
    print(x, end=' ')
1 4 5 10 

Iteracja po słowniku

Iterując po słowniku, uzyskujemy dostęp do jego kluczy:

prices = { 'GOOG' : 490.10,
    'AAPL' : 145.23,
    'YHOO' : 21.71 
}

for key in prices:
    print(key)
GOOG
AAPL
YHOO

Iteracja po stringu

String (napis) można traktować jako listę znaków. Iterując po napisie, uzyskujemy dostęp do poszczególnych znaków:

text = "Yow!"

for character in text:
    print(character)
Y
o
w
!

Iteracja po pliku

Iterować można nie tylko po kolekcjach, ale także obiektach, które w jakiś sposób reprezentują zbiór obiektów. Na przykład, plik można traktować jako zbiór linii. W wyniku iteracji po pliku otrzymujemy linie (razem ze znakiem końca wiersza):

for line in open("real.txt"):
    print(line, end='')
Real Programmers write in FORTRAN
Maybe they do now,
in this decadent era of
Lite beer, hand calculators, and "user-friendly" software
but back in the Good Old Days,
when the term "software" sounded funny
and Real Computers were made out of drums and vacuum tubes,
Real Programmers wrote in machine code.
Not FORTRAN. Not RATFOR. Not, even, assembly language.
Machine Code.
Raw, unadorned, inscrutable hexadecimal numbers.
Directly.

Protokół iteracji

Możliwość iterowania po różnych obiektach wynika z istnienia ścisłego protokołu. Iterować można po każdym obiekcie, który spełnia ten protokół. W szczególności, instancje Twoich własnych klas również mogą być iterowalne.

items = [1, 4, 5]

iterator = iter(items)
next(iterator)
1
next(iterator)
4
next(iterator)
5
next(iterator)
---------------------------------------------------------------------------
StopIteration                             Traceback (most recent call last)
<ipython-input-8-4ce711c44abc> in <module>
----> 1 next(iterator)

StopIteration: 

Wbudowana funkcja iter(x) wywołuje x.__iter__().

Z kolei next(x) deleguje do x.__next__() pod Pythonem 3 lub do x.next() w przypadku Pythona 2.

items = [1, 4, 5]
iterator = items.__iter__()
iterator.__next__() == 1
iterator.__next__()
iterator.__next__() == 5
iterator.__next__()
---------------------------------------------------------------------------
StopIteration                             Traceback (most recent call last)
<ipython-input-15-600f2bbc793f> in <module>
      4 iterator.__next__()
      5 iterator.__next__() == 5
----> 6 iterator.__next__()

StopIteration: 

Protokół składa się z dwóch metod:

  • Obiekt, który ma być iterowalny, musi mieć metodę __iter__(), która powinna zwrócić iterator.

  • Iterator powinien mieć metodę __next__() (lub next() w Pythonie 2) zwracającą przy kolejnych wywołaniach kolejne elementy. Jeżeli wszystkie elementy zostały już zwrócone, powinien zostać zgłoszony wyjątek StopIteration.

Iterator może być tym samym obiektem, co iterowany obiekt.

W takiej sytuacji implementacja metody __iter__() sprowadza się do zwrócenia tego obiektu:

class Foo:
    def __iter__(self):
        return self
    
    def __next__(self):
        """get next element"""

Należy jednak pamiętać, że po obiekcie takiej klasy można iterować tylko raz (tak jak w przypadku wyrażeń generatorowych).

Iteracja po własnych typach

Poniżej zostanie przedstawiona implementacja klasy Countdown umożliwiającej odliczenia w dół.

Przykład użycia takiej klasy:

for i in Countdown(10):
    print(i, end=' ')

Implementacja wykorzystuje trik przestawiony wcześniej, to znaczy metoda __iter__() zwraca ten sam obiekt. Dzięki temu iterator jest tym samym obiektem, po którym iterujemy. W konsekwencji, nie ma potrzeby pisania dwóch osobnych klas.

class Countdown:
    def __init__(self,start):
        self.count = start
    
    def __iter__(self):
        return self
    
    def __next__(self):
        if self.count <= 0:
            raise StopIteration
        r = self.count
        self.count -= 1
        return r
for i in Countdown(10):
    print(i, end=' ')
10 9 8 7 6 5 4 3 2 1 

Wbudowane funkcje używające obiektów iterowalnych

Python posiada wbudowane funkcje, to znaczy takie, których nie trzeba importować. Niektóre z nich operują na dowolnych obiektach iterowalnych, w szczególności na kolekcjach.

Funkcje sum, min i max agregują przekazaną kolekcję i zwracają jedną wartość (odpowiednio sumę elementów, najmniejszy i największy element). Dwie ostatnie funkcje generują ValueError, jeżeli przekazana kolekcja jest pusta.

Funkcje list, tuple, set i dict służą do stworzenia nowej kolekcji danego typu. Jeżeli nie zostanie podany żaden element, zwrócona zostanie pusta kolekcja (nie zawierająca żadnego elementu). Jednak najczęściej podaje się jeden argument (dowolny iterowalny obiekt).

Często dysponujemy generatorami, to znaczy obiektami przypominającymi kolekcje, ale wyliczającymi elementy na żądanie. Generatory zostaną szczegółowo omówione w następnym rozdziale. Generatory są zwracane na przykład przez funkcje filter, map i zip. Jeżeli chcemy wyświetlić elementy takiego generatora, możemy “przekonwertować” go na listę przy użyciu funkcji list:

a = [1, 2, 3]
b = ['a', 'b', 'c']
ab_zipped = zip(a, b)
list(ab_zipped)
[(1, 'a'), (2, 'b'), (3, 'c')]

Generatory

Generator jest funkcją, która zwraca sekwencję wyników zamiast pojedynczej wartości. Wewnątrz generatora używana jest instrukcja yield zamiast return. Służy ona do zwracania kolejnych wartości.

def countdown(n):
    while n > 0:
        yield n
        n -= 1
for i in countdown(5):
    print(i, end=' ')
5 4 3 2 1 

Wywołanie funkcji generatora tworzy obiekt generatora, ale nie rozpoczyna działania tej funkcji. Przy pierwszym wywołaniu metody __next__() następuje wykonanie funkcji generatora aż do napotkania instrukcji yield. Wtedy wykonywanie funkcji zostaje wstrzymane, a wartość zwrócona. Przy kolejnych wywołaniach metody __next__() następuje wznowienie generatora z miejsca, w którym został on poprzednio wstrzymany.

def countdown(n):
    print('start countdown')
    while n > 0:
        print('before yield')
        yield n
        print('after yield')
        n -= 1
it = countdown(3)
it
<generator object countdown at 0x7fc079107350>
next(it)
start countdown
before yield
3
next(it)
after yield
before yield
2
next(it)
after yield
before yield
1
next(it)
after yield
---------------------------------------------------------------------------
StopIteration                             Traceback (most recent call last)
<ipython-input-29-bc1ab118995a> in <module>
----> 1 next(it)

StopIteration: 

yield from

Jeżeli chcemy na raz zwrócić więcej niż jedną wartość, można użyć instrukcji yield from.

Jest ona szczególnie przydatna, jeżeli chcemy zwrócić rezultat innego generatora.

def flatten_gen():
    yield from ['A', 'B']
    yield from 'CDE'
    yield from range(1, 4)
list(flatten_gen())
['A', 'B', 'C', 'D', 'E', 1, 2, 3]

Generatory a iteratory

Funkcja generatorowa (lub po prostu generator) różni się od obiektu, który wspiera iterację.

Generator jest operacją jednorazową. Można iterować po generowanych danych tylko raz. Ponowna iteracja wymaga wywołania funkcji generatorowej.

Wyrażenia generatorowe

Wprowadzenie

Wyrażenie generatorowe to generatorowa wersja wyrażenia listowego. Wyrażenie generatorowe zwraca generator, który wylicza kolejne elementy na żądanie.

numbers = [1, 2, 3, 4, 5]
squares = [n * n for n in numbers]
squares
[1, 4, 9, 16, 25]

Zamiast tworzyć listę numbers i zużywać pamięć można użyć wyrażenia generatorowego:

squares_generator = (n*n for n in numbers)
squares_generator
<generator object <genexpr> at 0x7fc079109190>
for s in squares_generator:
    print(s, end=' ')
1 4 9 16 25 

Wyrażenia generatorowe przydają się przy pracy na dużej ilości danych (np. z dużymi plikami). Jeżeli nie jest możliwe załadowanie wszystkich danych do pamięci, wówczas nie możemy ich przechowywać w liście. Zamiast tego, można użyć wyrażeń generatorowych.

Z drugiej strony, generatory są mniej wygodne, ponieważ można iterować po nich tylko raz.

Składnia

Podobnie jak w przypadku wyrażeń listowych czy słownikowych, możliwe jest kilkukrotne, “zagnieżdżone” iterowanie. Typowym przykładem jest macierz, którą w Pythonie reprezentujemy jako listę list. Wymaga to najpierw iterowania po macierzy, aby uzyskać dostęp do wewnętrznych list reprezentujących poszczególne wiersze lub kolumny, a następnie po poszczególnych wierszach/kolumnach.

Składnia wyrażeń generatorowych jest następująca:

(expression for i in s if cond1
            for j in t if cond2
            ...
            if condfinal)

Powyższy kod jest równoważny:

for i in s:
    if cond1:
        for j in t:
            if cond2:
                ...
                if condfinal:
                    yield expression

Co ciekawe, nawiasy można pominąć, jeżeli wyrażenie generatorowe jest jedynym argumentem funkcji:

sum((n * n for n in numbers))
55
sum(n * n for n in numbers)
55

Funkcje filter i map

Przy użyciu funkcji filter i map można wykonać te same operacje, co z użyciem wyrażeń generatorowych. Bardzo często korzysta się wówczas z wyrażenia lambda, pozwalającego na zwięzłe stworzenie anonimowej funkcji:

numbers = [1, -3, 4, 5, 42, -665, 5, 3, -7]
positive_numbers = filter(lambda x: x > 0, numbers)
list(positive_numbers)
[1, 4, 5, 42, 5, 3]

W Pythonie 3 obie funkcje zwracają generator. Jest to inne zachowanie niż w Pythonie 2, gdzie zwracana jest lista.

Ze względu na wydajność warto zastąpić filter i map wyrażeniami generatorowymi. Unikamy narzutu związanego z wielokrotnym wywoływaniem funkcji.

Moduł itertools

Python posiada wiele wbudowanych funkcji zwracających iteratory, na przykład zip, map lub filter. Jest wiele innych przydatnych funkcji, które są dostępne w module itertools stanowiącym część standardowej biblioteki. Poniżej zostały omówione najważniejsze z nich.

count

count jest jak range, ale zwraca nieskończony iterator (nie podajemy końcowego indeksu). Podajemy jedynie pierwszy zwracany element (domyślnie zero) oraz krok (domyślnie jeden). Jeżeli nie podamy żadnych argumentów, dostaniemy iterator zwracający kolejne liczby naturalne od zera. Poniżej przedstawiono prosty kalkulator działający w nieskończonej pętli:

import itertools

limit = 1000

def find_nth(limit):
    total = 0
    for iter in itertools.count(1):
        total += iter
        if total > limit:
            return iter

n = find_nth(limit)
print(f"Sum of {n} consecutive integers starting from 1 is greater than {limit}")
Sum of 45 consecutive integers starting from 1 is greater than 1000

O ile range działa tylko na liczbach całkowitych, to w przypadku count krok może być liczbą zmiennoprzecinkową.

chain

Przy użyciu operatora + można połączyć (dokonać konkatenacji) dwie listy:

a = [1, 2, 3, 4]
b = [5, 6, 7]

a + b
[1, 2, 3, 4, 5, 6, 7]

W przypadku dwóch iteratorów, nie możemy użyć operatora +. Zamiast tego, należy użyć funkcji chain:

a = range(1, 5)
b = range(5, 8)

ab_chained = itertools.chain(a, b)
list(ab_chained)
[1, 2, 3, 4, 5, 6, 7]

groupby

groupby wykonuje tę samą operację, co GROUP BY z SQL’a. Przyjmuje listę elementów, a następnie łączy te same elementy w grupy.

Lista lub iterator przekazany jako argument musi być posortowany rosnąco.

data = [1, 3, 2, 1, 2, 2, 4, 3, 3, 3, 3, 1]

data = sorted(data)

for element, iter in itertools.groupby(data):
    print(f"{element} - {list(iter)}")
1 - [1, 1, 1]
2 - [2, 2, 2]
3 - [3, 3, 3, 3, 3]
4 - [4]

Podobnie jak w przypadku funkcji sort, możemy zdefiniować klucz, według którego elementy będą grupowane. groupby zwraca iterator par. Pierwszy element z tej pary to wspólny klucz, natomiast drugi to iterator zwracający wszystkie elementy z danej grupy.

animals = ['duck', 'eagle', 'rat', 'giraffe', 'bear',
           'bat', 'dolphin', 'shark', 'lion']

animals.sort(key=len)

for length, group in itertools.groupby(animals, len):
    print(length, '->', list(group))
3 -> ['rat', 'bat']
4 -> ['duck', 'bear', 'lion']
5 -> ['eagle', 'shark']
7 -> ['giraffe', 'dolphin']

takewhile

takewhile działa podobnie do filter. Przyjmuje dwa argumenty: funkcję zwracającą True lub False dla każdego elementu kolekcji oraz kolekcję. filter poszukuje wszystkich elementów, dla których funkcja zwraca True, natomiast takewhile przerywa przeszukiwanie po natrafieniu na pierwszy element, dla którego funkcja zwróciła False.

squares = (x * x for x in itertools.count(0)) # infinite generator of squares

list(itertools.takewhile(lambda x: x <= 100, squares))
[0, 1, 4, 9, 16, 25, 36, 49, 64, 81, 100]