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)
Cell In[8], line 1
----> 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ą.

islice#

Tworzy iterator, który umożliwia zwrócenie określonych n elementów (wycinka) z iterowalnego obiektu (np. generatora):

from itertools import islice
from typing import Optional

def fibonacci(limit: Optional[int] = None):
    a, b = 0, 1
    while limit is None or b <= limit:
         a, b = b, a+b
         yield a    

list(islice(fibonacci(), 15))
[1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233, 377, 610]

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 & dropwhile#

Funkcje takewhile i dropwhile działa podobnie do filter. Przyjmuje dwa argumenty: funkcję (predykat) zwracającą True lub False dla każdego elementu kolekcji oraz kolekcję.

takewhile przerywa zwracanie po natrafieniu na pierwszy element, dla którego predykat zwrócił False.

dropwhile pomija wszystkie początkowe elementy spełniające predykat.

from itertools import takewhile, dropwhile


list(takewhile(lambda x: x <= 200, fibonacci()))
[1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144]
list(takewhile(lambda n: n < 1000, dropwhile(lambda n: n < 100, fibonacci())))
[144, 233, 377, 610, 987]