Kilkanaście dni temu opisywałem błąd, który – jak się okazało – był efektem ubocznym pewnej cechy języka Python, uznanej przeze mnie za niemal zupełnie niepotrzebną. Dla równowagi więc dzisiaj przedstawię feature, który wydaje się być bardzo przydatny – żeby nie było, że potrafię tylko krytykować ;-)
Prawdopodobnie najlepiej jest pokazać go na prostym, acz obrazowym przykładzie. Miejmy pewną funkcję na tyle dla nas istotną, że chcemy logować wszystkie jej wywołania. Wydaje się, że nic prostszego:
Ciężko jednak nazwać takie rozwiązanie uniwersalnym. Nie chodzi tu jedynie o jawnie wpisaną nazwę funkcji, ale raczej o konieczność dodawania logging.debug(...)
na początku każdej funkcji, jaką chcemy monitorować. Sprawa komplikuje się jeszcze bardziej, gdy interesują nas też wyjścia z funkcji; wówczas jedynym wyjściem jest chyba opakowanie całej treści w jeden wielki blok try
–finally
. Rezultat na pewno nie będzie piękny :)
I tutaj właśnie z pomocą przychodzą dekoratory – ciekawa opcja języka Python, na pierwszy rzut oka przypominająca adnotacje z Javy. Podobieństwo jest jednak głównie składniowe. Udekorowana wersja naszej ważnej funkcji wygląda bowiem tak:
Znak @
poprzedza tutaj nazwę dekoratora, czyli trace
(ang. śledź – i bynajmniej nie chodzi o rybę ;]). Czym jednak jest ów dekorator? Otóż on sam również jest funkcją, mogącą wyglądać choćby tak:
Jej jedynym argumentem jest w założeniu funkcja, zaś rezultatem wywołania jest… również funkcja :) A zatem nasz dekorator potrafi przekształcić jedną funkcję w drugą, a dokładniej w jej udekorowaną, “opakowaną” wersję. To opakowanie definiowane jest wewnątrz dekoratora i polega, jak widać, na poprzedzeniu wywołania oryginalnej funkcji zapisem do loga.
Oczywiście za to, aby wywołanie funkcji opatrzonej dekoratorem było tak naprawdę odwołaniem do jej udekorowanej wersji odpowiada już sam język. Nie jest to zresztą skomplikowane, bo w istocie cały mechanizm jest tylko cukierkiem składniowym. Jest to jednak bardzo smaczny cukierek, który potrafi wydatnie podnieść czytelność kodu – jeśli stosuje się go właściwie.
Do czego jednak – oprócz logowania wywołań – dekoratory mogą się przydać? Ano przede wszystkim do upewniania się, że pewne konieczne warunki wstępne dla funkcji są spełnione na jej wejściu (i ewentualnie wyjściu). Może to być na przykład:
@connected
– sprawdzenie połączenia z serwerem zanim spróbujemy wymieniać z nim dane i nawiązanie go w razie potrzeby@authorized
– określenie uprawnień wymaganych u aktualnie zalogowanego użytkownika przed wywołaniem funkcji wykonującej potencjalnie niebezpieczną operację@synchronized
– zabezpieczenie wywołania funkcji semaforem lub sekcją krytycznąWspólną cechą takich dekoratorów jest to, że są one swoistymi pseudodeklaracjami, nieodległymi koncepcyjnie zbyt daleko od komentarzy w rodzaju:
Ich przewagą jest jednak rzeczywiste sprawdzanie, czy wymagania zostały spełnione – i to w sposób automatyczny i przezroczysty. Według mnie to właśnie stanowi o ich sporej przydatności.
Nie zostało napisane (jedynie wspomniane), więc mam okazję do pochwalenia się wiedzą ;)
Tak naprawdę zapis
@decorator
def fx(): ...
Jest dokładnie równoważny zapisowi
def fx() ...
decorator(fx)
Opcja dekoratora została wprowadzona do nowszych wersji Pythona właśnie m.in. ze względu na metody statyczne – wcześniej wymagało to nieintuicyjnego zapisu staticmethod(_metoda) po deklaracji funkcji.
@MSM: Prawie. Rezultat wywołania dekoratora na funkcji trzeba jeszcze przypisać do jej pierwotnej nazwy:
W tej wersji jednak musi to nastąpić oczywiście po treści funkcji, więc nie jest to już tak opisowe (i łatwiej można takie przypisanie przeoczyć).
I czym to się różni od anotacji w Javie? Moim zdaniem to różnice, a nie podobieństwa, są składniowe :) W Javie jak zwykle trzeba więcej pisać, ale combo anotacja + pointcut + aspect realizuje wszystko to co opisałeś i jeszcze trochę. I to nawet jest dość znana rzecz – http://en.wikipedia.org/wiki/Aspect-oriented_programming
Dekoratory zasadniczo realizują część programowania aspektowego, więc nie dziwi mnie specjalnie, że znajdują odpowiedniki w innych językach :)
Ale same adnotacje w Javie to – o ile wiem – tylko statyczne znaczniki, które zawierają ewentualnie jakieś tam parametry i są możliwe do odczytania i interpretacji przez JVM (lub kompilator, jak w przypadku @Override
). Są raczej bliżej atrybutów w .NET niż dekoratorów w Pythonie.
Jasne – wszystko racja. Dlatego wspomniałem jeszcze o pointcutach i aspectach :)
A, jak już porównujemy, to te takie dekoratory potrafią przyjmować parametry?
@Authorized(role = Admin, logIfUnauthorized = false) ?
Tak, ale wtedy wygodniej jest dekorator zdefiniować jako klasę. W konstruktorze przyjmuje ona parametry dla dekoratora, zaś przeciążony operator () (w Pythonie funkcja __call__) przyjmuje funkcję do udekorowania. Przykład:
(Można to też zrobić funkcją, ale składniowo wychodzi wtedy potworek w postaci dwukrotnie zagnieżdżonych funkcji wewnętrznych, więcej tutaj).
@up – Tak, zgodzę się że dwukrotnie zagnieżdżone funkcje (wychodzące w najgorszym wypadku) to nieco zamotana rzecz, ale jest dość logiczna, jeśli spojrzymy na dekorator w ogólnej postaci:
@expr
def func():
# kod
Tutaj ‘expr’ to dowolne wyrażenie, które jest obliczone raz w momencie deklaracji i ma zwrócić dekorator (czyli funkcję przyjmującą funkcję jako argument i zwracającą “przetworzoną” funkcję). Idąc tym tropem, z powyższego kodu wychodzi nam coś w stylu:
decorator = expr
def func():
#kod
func = decorator(func)
Zwykle jako ‘expr’ podajemy po prostu obiekt funkcji, czyli sam dekorator.
Jeśli chcemy parametry, to nic prostszego, tyle, że zamiast obiektu funkcji-dekoratora musimy podać cały function call, który [b]zwróci[/b] dekorator. Stąd dodatkowe zagnieżdżenie – piszemy nie tylko dekorator, który zwraca funkcję, ale dodatkową funkcję z dowolnymi parametrami, która zwraca dekorator, który z kolei przyjmuje zwraca funkcję :). I prawdą jest, że często wychodzi z tego właśnie podwójnie zagnieżdżona funkcja.
Niemniej pisanie czegoś takiego wcale nie jest skomplikowane, gdy sobie uświadomimy, że mamy po prostu do zrobienia coś, co z takiej składni:
@foo(a,b)
def func():
#kod
zadziała tak:
decorator = foo(a,b)
def func():
#kod
func = decorator(func)
Zwykle wolę to niecne rozwiązanie z podwójnie zagnieżdżoną funkcją, niż z klasą, choć w powyższym kodzie widać, że wychodzi na to samo.
Swoją drogą, niektóre dekoratory w ogóle nie potrzebują zagnieżdżonych funkcji, np. taki, którego użyłem w jakiejś aplikacji konsolowej do obsługi command line:
def command(func):
global commands #slownik string->funkcja
commands[func.func_name] = func
return func
W ten sposób (+ nieco kodu do czytania parametrów) jedną adnotacją mogłem uczynić każdą funkcję dostępną z poziomu linii komend. Dekoratory są proste i przyjemne :).
(Argh! Wycięło mi leading wcięcia wewnątrz !)
“Dekoratory są proste i przyjemne :)”
I to właśnie najważniejszy wniosek z tych wypocin :)