W tym poście skupimy się na bardziej zaawansowanych zagadnieniach związanych z funkcjami. Poznamy między innymi funkcje rekurencyjne oraz atrybuty funkcji, nazywane również anotacjami.
Co jest ważne podczas projektowania funkcji?
Tworząc funkcję, tak naprawdę staramy się zaprojektować sposób wykonania pewnego zadania. Jako, że funkcja zawsze istnieje w jakimś kontekście, musimy pomyśleć o różnych czynnikach mogących wpływać na działanie samej funkcji, a także pozostałej części kodu. Szczególnie istotne są takie kwestie jak:
- sprzęganie (coupling), czyli sposób, w jaki funkcje komunikują się ze sobą,
- spójność (cohesion), czyli rozbicie dużej funkcji na mniejsze funkcje,
- rozmiar funkcji, który wpływa na użyteczność kodu.
Sprzęganie funkcji
- sprzęganie, czyli komunikacja pomiędzy funkcjami, tzn. używanie argumentów jako danych wejściowych, a także stosowanie instrukcji return do zwracania wyników, co pozwala uniezależnić funkcję od jej otoczenia, a jednocześnie pozwala stworzyć punkty styku z innymi częściami kodu,
- ograniczanie użycia zmiennej globalnych, które mogą powodować wiele zależności,
- ograniczanie modyfikowania argumentów mutowalnych, co ponownie może wpłynąć w niechciany sposób na kod,
- unikanie modyfikowania zmiennych w innych plikach modułów, gdyż powoduje to nadmierne połączenie pomiędzy modułami.
Spójność funkcji
- funkcja powinna mieć określony cel, czyli wykonywać pojedyncze zadanie. Jeśli funkcja wykonuje zbyt ogólne lub zbyt szczegółowe zadanie, należy przemyśleć rozbicie jej na mniejsze części. Dzięki temu stanie się ona bardziej uniwersalna.
- funkcja powinna być relatywnie mała, co zwiększa jej czytelność oraz jest bezpośrednio powiązane z powyższym punktem.
Oczywiście istnieją wyjątki od wskazanych wyżej reguł, jak na przykład w przypadku klas, gdzie modyfikowanie instancji klasy (czyli pojedynczych obiektów stworzonych na bazie jednej klasy) jest na porządku dziennym. Używanie danych lub stanu między wywołanymi funkcji również jest przeprowadzane poprzez zmienne globalne, co nie jest niebezpieczne tylko wtedy, gdy jest spodziewanym i dobrze przemyślanym zachowaniem.
Funkcje rekurencyjne
Funkcje rekurencyjne to funkcje, które wywołują siebie same bezpośrednio lub przy użyciu innych funkcji. Są one wykorzystywane wtedy, gdy należy przetworzyć dane o trudnej do przewidzenia strukturze i poziomie zagnieżdżenia. Rekurencję wykorzystuje się między innymi w analizie językowej, ustalaniu tras podróży czy przeglądaniu łączy danej witryny internetowej – innymi słowy, wszędzie tam, gdzie nie do końca wiemy, do jakiego poziomu chcemy przetwarzać dane.
Przykład rekurencji – dodawanie przy użyciu rekurencji
Rekurencji można używać na wiele sposobów, a jednym z prostszych przykładów jest sumowanie elementów kolekcji. Kod wygląda następująco:
>>> def mysum(L):
... if not L:
... return 0
... else:
... return L[0] + mysum(L[1:]) #rekurencja – funkcja wywołuje samą siebie; jednocześnie przechodzi do kolejnego elementu danej struktury
>>> mysum([1, 2, 3])
6
Za każdym wywołaniem funkcja wywołuje samą siebie w celu policzenia sumy pozostałych elementów listy. Następnie ta suma jest dodawana do pierwszego odczytanego elementu (L[0]). Gdy podlista jest już pusta, funkcja zwraca 0, a tym samym zwraca również ostateczny wynik – określa się to mianem odwijania rekurencji. W wywołaniu rekurencyjnym każdy poziom zagnieżdżenia rekurencyjnego otrzymuje własną kopię lokalnego zakresu funkcji, co oznacza, że przekazany argument (w tym przypadku L) staje się inną zmienną dla każdego wywołania.
Dla lepszego zrozumienia tego przykładu można dodać funkcję print, która wyświetli wartość L dla każdego wywołania.
>>> def mysum(L):
... print(L)
... if not L:
... return 0
... else:
... return L[0] + mysum(L[1:])
...
>>> mysum([1, 2, 3, 4, 5])
[1, 2, 3, 4, 5]
[2, 3, 4, 5]
[3, 4, 5]
[4, 5]
[5]
[]
15
Rekurencja w dwóch linijkach
Co ciekawe powyższy kod można wywołać na wiele różnych sposobów, w tym zamykając całą operację w zaledwie dwóch linijkach, korzystając z trójskładnikowej operacji if/else.
>>> def mysum(L):
... return 0 if not L else L[0] + mysum(L[1:])
>>> mysum([1, 2, 3])
6
Dodatkowo możemy stworzyć funkcję referencyjną, która obsługuje również dodawanie np. znaków:
>>> def mysum_string(L):
... print(L)
... return L[0] if len(L) == 1 else L[0] + mysum_string(L[1:])
>>> mysum_string('jajko')
jajko
ajko
jko
ko
o
'jajko'
Możemy nawet stworzyć funkcję referencyjną, która obsługuje dowolne obiekty iterowalne, w tym również pliki. Wówczas jednak dostęp do poszczególnych elementów nie może odbywać się po indeksie. Przykład takiej funkcji to:
def my_sum_all(L):
first, *rest = L
return first if not rest else first + my_sum_all(rest)
W tym przypadku podczas każdego wywołania funkcji stosowane jest rozszerzone przypisanie. Następnie zwracana jest suma pierwszego elementu aktualnej wersji zmiennej L oraz reszty argumentów. Odwijanie funkcji następuje, gdy nie ma już więcej elementów w przekazanej zmiennej.

Czy z rekurencji często się korzysta?
Powyższe przykłady są dość sztuczne i stworzone w zasadzie jedynie jako przykłady, ponieważ w celu prostego dodawania nie należy wykorzystywać zaawansowanych technik programistycznych. Rekurencja nie jest tak często wykorzystywana w Pythonie jak w innych językach, zdecydowanie częściej korzysta się z pętli, które są prostsze w zrozumieniu. Rekurencja jest również mniej wydajna niż pętla czy lista składana. Powyższe przykłady można zapisać za pomocą prostej pętli, na przykład w ten sposób:
L = [1, 2, 3,4 ,5]
sum = 0
while L:
sum += L[0]
L = L[1:]
print(sum)
>>> 15
Kiedy korzysta się z rekurencji?
Jak wspomnieliśmy wcześniej rekurencja jest przydatna wtedy, gdy chcemy przejrzeć dowolnie złożoną strukturę. Może to być na przykład wielokrotnie zagnieżdżona lista. Zwykła pętla nie poradzi sobie z takim zadaniem, ponieważ przejrzenie listy z dowolną liczbą podlist nie może zostać obsłużone przez iterację liniową. Nie możemy bowiem określić, jak wiele zagnieżdżonych pętli będziemy musieli wykonać. Przykład:
L = [1, 2, [3, 4], 4, 5, [6, 7]]
def sumtree(some_list):
tot = 0
for x in L:
if not isinstance(x, list):
tot += x
else:
tot += sumtree(x)
return tot
print(sumtree(L))
>>> 32
Rekurencja a kolejka i stos
Rekurencja przypomina kolejkę lub stos. W kolejce typu FIFO – first in, first out – pierwszy element będący na wejściu staje się pierwszym elementem na wyjściu. W kontekście zagnieżdżonych list oznacza to, że najbardziej zagnieżdżone elementy zostaną dodane na końcu głównej listy. Prześledźmy działanie kolejki z wykorzystaniem funkcji print.
def sumtree(L):
tot = 0
items = list(L)
while items:
print(items)
front = items.pop(0)
if not isinstance(front, list):
tot += front
else:
items.extend(front)
return tot
print(sumtree([1,2,3,[4,5], [7,8,9]]))
Wynik:
[1, 2, 3, [4, 5], [7, 8, 9]]
[2, 3, [4, 5], [7, 8, 9]]
[3, [4, 5], [7, 8, 9]]
[[4, 5], [7, 8, 9]]
[[7, 8, 9], 4, 5]
[4, 5, 7, 8, 9]
[5, 7, 8, 9]
[7, 8, 9]
[8, 9]
[9]
39
Instrukcja return jest tutaj bardzo ważna, gdyż to ona powoduje zwrócenie wartości z każdym rekurencyjnym wywołaniem. Innymi słowy to ona odpowiada za dodanie wartości do zmiennej tot.
Jak widzimy kod przenosi najbardziej zagnieżdżony element na koniec listy, aby odpakować go na samym końcu – przykład z zagnieżdżoną tablicą [4, 5].
Kod możemy zmienić w ten sposób, aby to najbardziej zagnieżdżony elementy były dodawane na początku listy głównej, implementując tym samym strukturę znaną jako stos typu FILO – first in, last out.
def sumtree(L):
tot = 0
items = list(L)
while items:
print(items)
front = items.pop(0)
if not isinstance(front, list):
tot += front
else:
items[:0] = front
return tot
print(sumtree([1,2,3,[4,5], 5, [7,8,9]]))
[1, 2, 3, [4, 5], 5, [7, 8, 9]]
[2, 3, [4, 5], 5, [7, 8, 9]]
[3, [4, 5], 5, [7, 8, 9]]
[[4, 5], 5, [7, 8, 9]]
[4, 5, 5, [7, 8, 9]]
[5, 5, [7, 8, 9]]
[5, [7, 8, 9]]
[[7, 8, 9]]
[7, 8, 9]
[8, 9]
[9]
44
Jak widać zagnieżdżone struktury nie są przenoszone na koniec, ale odpakowywane 'na miejscu’.
Atrybuty i adnotacje funkcji
Czas przejść do zupełnie innego zagadnienia związanego z funkcjami. Funkcje w Pythonie to obiekty, dlatego można robić z nimi to samo, co robi się na przykład z ciągiem znaków lub listą. Można je przypisywać do zmiennych, przekazywać jako parametry do innej funkcji, osadzać w strukturach danych lub zwracać w wyniku działania innych funkcji. Takie podejście nazywane jest modelem obiektowym pierwszej klasy. Wyjątkową cechą funkcji jest sposób ich definiowania za pomocą instrukcji def, którą można porównać do przypisania za pomocą znaku równości.
Pośrednie wywołanie funkcji:
def echo(message):
print(message)
x=echo
x("wywołanie pośrednie")
Przekazanie funkcji jako argumentu:
def indirect(func, arg):
func(arg)
indirect(echo, "wywołanie funkckji jako argumentu")
Tabela operacji
Funkcje można zagęszczać również w bardziej skomplikowanych strukturach danych takich jak na przykład krotka. Wówczas tworzy się coś na kształt tabeli operacji. Przykład w poniższym kodzie:
def echo(message):
print(message)
schedule = [(echo, 'pierwsze wywołanie'), (echo, "drugie wywołanie")]
for (func, arg) in schedule:
func(arg)
>>> pierwsze wywołanie
>>> drugie wywołanie
Jak możemy 'zbadać’ funkcję?
Funkcje mają wiele wbudowanych atrybutów, które możemy poznać poprzez wykorzystanie standardowych narzędzi, choćby takich jak wywołanie atrybutu name.
def echo(message):
print(message)
print(echo.__name__)
Możemy też poznać wszystkie atrybuty funkcji za pomocą funkcji dir()
print(dir(echo))
>>> ['__annotations__', '__builtins__', '__call__'....
Możliwe jest także poznanie zmiennych wykorzystywanych w danej funkcji oraz liczby jej agumentów za pomocą poniższego kodu:
print(echo.__code__.co_varnames)
>>> ('message',)
print(echo.__code__.co_argcount)
>>> 1
Do funkcji możemy również dodać własne atrybuty:
echo.new_message = "hello world"
print(dir(echo))
['__annotations__', '__builtins__', '__call__' ... , 'new_message']
Atrybuty funkcji to alternatywa dla innych metod przechowywania danych pomiędzy wywołaniami funkcji. Nie trzeba wówczas korzystać z takich sposobów jak klasy, domknięcia czy zmienne globalne lub nielokalne. Atrybuty funkcji są ponadto dostępne w każdym miejscu, a ich wartość jest zachowana po zakończeniu działania funkcji.
Adnotacje funkcji
Od wersji Pythona 3.0 możliwe jest dołączanie dodatkowych informacji do obiektu funkcji. Są to informacje dotyczące argumentów przekazywanych do funkcji oraz wyników z niej zwracanych. W tym celu należy skorzystać ze specjalnej składni, nazywanej również adnotacjami. Adnotacje funkcji znajdują się w zmiennej __annotations__.
Jak stworzyć anotację funkcji?
Tworzenie adnotacji funkcji jest proste – wystarczy wprowadzić odpowiednie zmiany w nagłówku podczas deklaracji funkcji. Adnotacje funkcji są to wyrażenia związane z argumentami i zwracanymi wartościami. W przypadku argumentów adnotacja następuje po nazwie atrybutu i dwukropku, natomiast w przypadku wartości zwracanej jest to sekwencja znaków -> następująca po liście argumentów.
Funkcja bez adnotacji:
def func(a, b, c):
return a + b+ c
print(func(1, 2, 3))
Funkcja z adnotacją
def func(a: 'przykładowy argument', b: 6, c: int) -> int:
return a + b+ c
print(func(1, 2, 3))
Funkcja zawierająca adnotację jest wywoływana tak samo jak każda inna funkcja. Adnotacje są zapisywane w słowniku i dołączane do obiektów funkcji. Nazwy argumentów są kluczami, ich wartości – wartościami kluczy, z kolei adnotacja wartości zwracanej i jest przechowywana pod kluczem return.
print(func.__annotations__)
>>> {'a': 'przykładowy argument', 'b': 6, 'c': <class 'int'>, 'return': <class 'int'>}
Adnotacje można oczywiście łączyć z wartościami domyślnymi:
def func(a: 'przykładowy argument' = 2, b: 6 = 3, c: int = 7) -> int:
return a + b + c
print(func())
>>> 12
Adnotacje można wykorzystać do określania ograniczeń typów lub wartości przekazywanych do funkcji lub zwracanych przez funkcję. Na razie nie są one dostępne dla np. funkcji anonimowych typu lambda, które omówimy w następnym rozdziale.
Dodaj komentarz
Musisz się zalogować, aby móc dodać komentarz.