V této lekci se nebudeme věnovat žádné externí knihovně. Místo toho se seznámíme s jednou vlastností Pythonu, kterou knihovny často využívají, a která obvykle vypadá trochu magicky.
Touto vlastností jsou dekorátory.
Dekorátory se hodí tehdy, když potřebujeme upravit chování nějaké funkce, ale nechceme ji přímo upravovat.
Co je to vlastně dekorátor? Dekorátor je vlastně jenom funkce, která dostane jeden argument a vrátí jednu hodnotu. Je ale trochu speciální v tom, že jak argument, tak návratová hodnota jsou zase jiné funkce.
Funkcím, které operují nad jinými funkcemi, říkáme funkce vyššího řádu.
Použití dekorátorů v kódu vypadá zhruba takto:
@dekorator
def funkce():
pass
Tento zápis se zavináčem je jenom syntaktický cukr. Usnadňuje nám zápis, ale
chová se přesně stejně jako následující kód, na kterém je lépe vidět, že
dekorator
je funkce:
def funkce():
pass
funkce = dekorator(funkce)
Na řádku za zavináčem může být libovolný výraz, který po vyhodnocení vrátí funkci, která má požadované rozhraní.
Jak už při programování bývá zvykem, náš první dekorátor nás pozdraví.
Začneme s jednoduchým programem, který definuje funkci pro pozdrav a zavolá ji.
def ahoj():
print("Ahoj")
if __name__ == "__main__":
ahoj()
Do tohoto programu bychom rádi přidali další pozdravy, a zavolali je všechny.
def ahoj():
print("Ahoj")
def nazdar():
print("Nazdar")
if __name__ == "__main__":
ahoj()
nazdar()
Tento přístup ale povede k tomu, že by na konci byl dlouhý seznam pozdravů. Můžeme si funkce rovnou uložit do seznamu a potom přes něj jenom iterovat.
def ahoj():
print("Ahoj")
def nazdar():
print("Nazdar")
if __name__ == "__main__":
funkce = [ahoj, nazdar]
for f in funkce:
f()
A jako poslední krok přidáme dekorátor, který nám bude funkce rovnou přidávat do seznamu.
funkce = []
def pridej_pozdrav(func):
funkce.append(func)
return func
@pridej_pozdrav
def ahoj():
print("Ahoj")
@pridej_pozdrav
def nazdar():
print("Nazdar")
if __name__ == "__main__":
for f in funkce:
f()
Zkuste přidat ještě jeden pozdrav.
V tomto příkladu jde o docela zbytečné použití dekorátorů. Ukazuje ale
praktický způsob, jak řešit registraci funkcí. Stejné řešení používá
například knihovna flask
pro definování webových služeb nebo click
pro
vytváření příkazů pro terminál.
Podívejme se třeba na tuto na pohled nevinnou funkci. Počítá, jak vypadá n-té číslo ve Fibonacciho posloupnosti. Funguje docela pěkně, pokud jí nezadáme jako argument příliš velké číslo. Na autorově počítači příliš velká čísla začínají kolem 35.
def fib(x):
"""Spočítá x-té číslo ve Fibonacciho posloupnosti."""
if x <= 1:
return x
return fib(x - 1) + fib(x - 2)
Napíšeme si jednoduchý dekorátor, který nám bude vypisovat informace o tom, co se ve funkci děje.
def co_se_deje(func):
print("Aplikuju dekorátor")
return func
@co_se_deje
def fib(x):
"""Spočítá x-té číslo ve Fibonacciho posloupnosti."""
if x <= 1:
return x
return fib(x - 1) + fib(x - 2)
if __name__ == "__main__":
print(fib(4))
Tento dekorátor funkci nijak nemění. Akorát nám oznámí, že byl aplikovaný. V těle dekorátoru ale můžeme nadefinovat novou funkci a vrátit ji.
Zkusme si to:
def co_se_deje(func):
def nahradni_funkce(x):
return "Spočítej si to sám!"
return nahradni_funkce
Nebo můžeme vrátit funkci, která akorát zavolá tu původní.
def co_se_deje(func):
def nahradni_funkce(x):
return func(x)
return nahradni_funkce
Pojďme vracenou funkci rozšířit tak, aby vypisovala informace o tom, co dělá.
def co_se_deje(func):
def nahradni_funkce(x):
print(f"Voláme {func.__name__}({x})")
return func(x)
return nahradni_funkce
Úkol: upravte dekorátor tak, aby vypisoval i vypočítanou hodnotu.
Tento dekorátor není úplně praktický. Pokud toho vypíše trochu víc, tak už se v tom logu nikdo nevyzná. Myšlenka jako taková ovšem není úplně špatná. Kdyby třeba dekorátor počítal, kolikrát se funkce spustí, a jak dlouho obvykle trvá, mohl by nám pomoct najít místa pro optimalizaci.
Zkuste si v interaktivní konzoli Pythonu spustit následující příklad:
>>> help(print)
Help on built-in function print in module builtins:
print(...)
print(value, ..., sep=' ', end='\n', file=sys.stdout, flush=False)
Prints the values to a stream, or to sys.stdout by default.
Optional keyword arguments:
file: a file-like object (stream); defaults to the current sys.stdout.
sep: string inserted between values, default a space.
end: string appended after the last value, default a newline.
flush: whether to forcibly flush the stream.
Dostaneme krátkou nápovědu o tom, jak používat funkci print
.
Odkud se tato nápověda bere? Z dokumentačního komentáře. Takže bychom měli
dostat pěknou nápovědu třeba i pro naši známou funkci fib
.
>>> from fib import fib
>>> help(fib)
Help on function nahradni_funkce in module fib:
nahradni_funkce(x)
>>>
Něco je špatně. Protože jsme původní implementaci funkce fib
pomocí
dekorátoru nahradili naší pomocnou funkcí, komentář se cestou ztratil. Mohli
bychom přidat dokumentační komentář k náhradní funkci, ale přece nebudeme
stejný kód kopírovat dvakrát.
Standardní knihovna má naštěstí možnost, jak to snadno opravit. V modulu
functools
je definovaný dekorátor wraps
, který umí zkopírovat dokumentační
komentář a jméno z jedné funkce do druhé.
import functools
def co_se_deje(func):
@functools.wraps(func)
def nahradni_funkce(x):
pass
>>> from fib import fib
>>> help(fib)
Help on function fib in module fib:
fib(x)
Spočítá x-té číslo ve Fibonacciho posloupnosti.
>>>
Při psaní dekorátorů je dobré myslet na to, jak moc univerzální by měly být.
Například náš co_se_deje
momentálně funguje pouze pro funkce, které mají
jeden argument.
To je ale docela hloupé omezení. Stejně dobře bychom mohli chtít sledovat volání jiné funkce, která má třeba argumentů víc.
Pokud dekorátor nepotřebuje vědět nic o argumentech funkce, je docela praktické jej nadefinovat tak, aby byly prostě všechny předal dál, ať už jich je kolik chce.
To můžeme udělat následovně:
def co_se_deje(func):
@functools.wraps(func)
def nahradni_funkce(*args, **kwargs):
print(f"Voláme {func.__name__}{args}")
vysledek = func(*args, **kwargs)
print(f"Výsledek {func.__name__}{args} = {vysledek}")
return vysledek
return nahradni_funkce
Do n-tice args
posbíráme všechny poziční argumenty, do slovníku kwargs
všechny pojmenované argumenty. A při volání dekorované funkce je všechny zase
předáme dál.
Ve výstupu teď používáme pouze poziční argumenty. Přidání těch pojmenovaných je cvičení pro čtenáře.
Pokud náš program musí pracovat s nějakou externí službou nebo systémem, může se stát, že komunikace mezi nimi nebude vždy bezproblémová. Pěkný příklad je třeba stahování webové stránky se špatným připojením. S tím z Pythonu nic udělat nemůžeme.
Můžeme ale zkusit požadavek zopakovat, pokud poznáme, že je to typ chyby, kde opakování může pomoct.
Začneme s jednoduchým programem, který udělá HTTP požadavek.
Následující příklady používají knihovnu requests. Nainstalujte si ji, pokud ji ve virtuálním prostředí ještě nemáte.
import requests
def stahni():
"""Stáhne stránku a něco s ní udělá."""
print("Stahuju stránku")
odpoved = requests.get("https://httpbin.org/status/200,400,500")
print(f"Dostali jsme {odpoved.status_code}")
odpoved.raise_for_status()
return "OK"
if __name__ == "__main__":
stahni()
Použitá stránka náhodně odpoví jedním z vyjmenovaných kódu, takže ve dvou třetinách případů bychom měli dostat chybu. Pokud požadavek zkusíme zopakovat, máme dobrou šanci, že to projde.
Začneme s jednoduchým dekorátorem, který jenom zavolá funkci.
def opakuj_pri_neuspechu(func):
"""Pokud volání funkce vyhodí výjimku, budeme ji ignorovat a zkusíme funkci
zavolat znovu.
"""
@functools.wraps(func)
def nahradni_funkce(*args, **kwargs):
return func(*args, **kwargs)
return nahradni_funkce
@opakuj_pri_neuspechu
def stahni():
"""Stáhne stránku a něco s ní udělá."""
print("Stahuju stránku")
odpoved = requests.get("https://httpbin.org/status/200,400,500")
print(f"Dostali jsme {odpoved.status_code}")
odpoved.raise_for_status()
return "OK"
Co by měla dělat naše náhradní funkce? Donekonečna bude zkoušet zavolat
dekorovanou funkci. Pokud se to podaří, vrátí její výsledek. Pokud dostaneme
výjimku requests.exceptions.HTTPError
, chvilku počkáme, a půjdeme na další
pokus.
import functools
import time
import requests
def opakuj_pri_neuspechu(func):
"""Pokud volání funkce vyhodí výjimku, budeme ji ignorovat a zkusíme funkci
zavolat znovu.
"""
@functools.wraps(func)
def nahradni_funkce(*args, **kwargs):
while True:
try:
return func(*args, **kwargs)
except requests.exceptions.HTTPError:
print("Chyba, zkusíme to znovu")
time.sleep(1)
return nahradni_funkce
@opakuj_pri_neuspechu
def stahni():
"""Stáhne stránku a něco s ní udělá."""
print("Stahuju stránku")
odpoved = requests.get("https://httpbin.org/status/200,400,500")
print(f"Dostali jsme {odpoved.status_code}")
odpoved.raise_for_status()
return "OK"
Teď by program měl vypisovat, že se snaží stránku stáhnout několikrát, a opakovat to tak dlouho, dokud se to nepodaří.
Co když ale potřebujeme opakování pokusů na více místech, ale chceme reagovat na jiné výjimky?
Mohli bychom si nadefinovat nový dekorátor pro každý typ výjimky, kterou chceme chytat. To zní jako hodně práce a duplicitního kódu.
Místo toho můžeme dekorátor upravit tak, aby přijímal argumenty, a pak mu s jejich pomocí řekneme, kterou výjimku ošetřovat.
Výraz za @
musí při vyhodnocení vždy vracet funkci, která se chová jako
dekorátor. Takže musíme přidat jednu vrstvu do našich vnořených funkcí.
Funkce opakuj_pri_neuspechu
je vlastně továrna na dekorátory. Vždy, když ji
zavoláme, vrátí nám funkci, která se chová podle našich potřeb a funguje jako
dekorátor.
import functools
import time
import requests
def opakuj_pri_neuspechu(vyjimka):
def dekorator(func):
@functools.wraps(func)
def nahradni_funkce(*args, **kwargs):
while True:
try:
return func(*args, **kwargs)
except vyjimka:
print("Chyba, zkusíme to znovu")
time.sleep(1)
return nahradni_funkce
return dekorator
@opakuj_pri_neuspechu(requests.exceptions.HTTPError)
def stahni():
"""Stáhne stránku a něco s ní udělá."""
print("Stahuju stránku")
odpoved = requests.get("https://httpbin.org/status/200,400,500")
print(f"Dostali jsme {odpoved.status_code}")
odpoved.raise_for_status()
return "OK"