Dekoratory część 1

Dekoratory to jeden z najłatwiejszych i jednocześnie najtrudniejszych elementów języka Python.

Każdy kto kiedykolwiek miał okazję korzystać z magii gotowych iteratorów, którymi hojnie obdarzyli nas twórcy biblioteki standardowej i frameworków rozumie pierwszą część tego zdania. Proste dopisanie nazwy dekoratora działa magicznie. Np. @property zmienia funkcję we właściwość, a pylonsowe @validate zajmuje się całą logiką walidacji, sanityzacji, czy ewentualnego przekierowania nieprawidłowych zapytań.

Drugą część zdania pojmie zaś ten, kto kiedyś odważył się zajrzeć do kodu tych magicznych obiektów, lub co gorsza próbował napisać własne. Kod ten kogoś rozpieszczonego czytelnym, prostym i płaskim językiem jakim jest Python przyprawić może o zawrót głowy. Dekorator to bowiem zwykle obiekt wywoływalny (callable) przyjmujący jako argument obiekt wywoływalny i zwracający inny obiekt wywoływalny. Wystarczy jednak zrozumieć zależność między tymi trzema obiektami by okazało się, że tworzenie nowych dekoratorów nie jest wcale takie trudne.

Problem

Przykład poniższy pozwoliłem sobie zaczerpnąć z niniejszej dokumentacji i nieco zmodyfikować. Zdefiniujmy sobie najpierw przykładowe funkcje, na których będziemy pracować

from time import sleep

def fact(arg):
    r = 1
    for i in range(1, arg + 1):
        r *= i
    return r

def GCD (x, y):
    while True:
        if x > y:
            x -= y
        else:
            y -= x
        if not (x and y):
            return x or y


def dummy(arg):
    sleep(1)
    return arg

Pierwsza z nich oblicza silnię z argumentu. Druga liczy NWD dwóch liczb algorytmem Euklidesa. Trzecia zaś udaje, że coś liczy, ale zwraca swój arguement niezmieniony. Wybrałem celowo funkcje iteracyjne, czyli takie, które dla dużych liczb i przy częstych wywołaniach mogą zeżreć jakąś istotną ilośc mocy obliczeniowej procesora. Załóżmy, że funkcje tego typu wykonujemy w naszym programie wielokrotnie, często z tym samym zestawem argumentów. Pytanie – jak zaoszczędzić na obliczeniach.

Podejście naiwne

Każdy zauważy, że możemy zaoszczędzić dużo czasu, jeśli wynik działania tej lub owej funkcji zapiszemy sobie w pamięci. Przygotujmy sobie cache dla wszystkich tych funkcji.

fact_cache = {}
GCD_cache = {}
dummy_cache = {}

I zamiast pisać:

x = fact(23)

Piszmy:

if fact_cache.has_key(23):
    x = fact_cache[23]
else:
    x = fact(23)
    fact_cache[23] = x

Problem rozwiązany. Wynik funkcji jest wyliczany tylko wtedy, gdy jeszcze go nie liczyliśmy. W innym wypadku – czytany jest z cache. Jednak uzyskane przy takim podejściu spagetti stanie się szybko nieczytelne (o zwykłych odczuciach estetycznych nie wspominając). Spiszmy, co nam się tu może nie podobać.

  1. Zamiast jednej linijki otrzymaliśmy pięć.
  2. Kod ten jest podobny do tysięcy innych, które będziemy musieli wprowadzić.
  3. Nie ma żadnego programistycznego powiązania między funkcją fact a słownikiem dict_cache

Zróbmy porządki

Dobrze wyszkolony metodą Pawłowa programista słysząc "wielokrotne powtórzenie tego samego kodu" krzyczy "funkcja!", a na dźwięk słów "brak powiązania między funkcjami a danymi odpowiada "zróbmy obiekt!". Tak też i postąpimy w tym wypadku:

def call_with_cache(fun, args):
    if not hasattr(fun, 'cache'):
        fun.cache = {}
    if fun.cache.has_key(args):
        return fun.cache[args]
    else:
        result = fun(*args)
        fun.cache[args] = result
        return result

Wykorzystaliśmy tutaj fakt, że w Pythonie funkcje są obiektami i możemy przypisywać im dowolne atrybuty. Jeśli ktoś myślał o zbudowaniu obiektu-wrappera, który zawierałby zarówno funkcję, jak i cache, to również kombinował poprawnie. Od tej pory nasze wywołanie funkcji czasochłonnych będzie wyglądać na przkład tak:

x = call_with_cache(fact, (23,))

Funkcja call_with_cache jako pierwszy argument przyjmuje funkcję, którą chcemy wołać, a jako drugi - krotkę (tuple) argumentów. Dla prostoty nie obsługuje funkcji przyjmujących argumenty ze słowem kluczowym i nie akceptuje argumentów podanych jako lista. Implementację takowej zostawia się jako łatwe zadanie dla czytelnika.

A jak to jeszcze uprościć?

Wywołanie takie wygląda już znacznie lepiej i czytelniej. Co jednak, jeśli nas nadal nie zadowala? Co jeśli chcemy po prostu wywołać funkcję jak każdą inną, bez dziwnych zagnieżdżonych notacji? Spróbujmy stworzyć zunifikowaną fabrykę funkcji zapamiętujących swoje wyniki:

def cache(fun):
    fun.cache = {}
    def res_fun(*args):
        if fun.cache.has_key(args):
            return fun.cache[args]
        else:
            result = fun(*args)
            fun.cache[args] = result
            return result
    return res_fun

Kod ten w większości przypomina nam już widzianą funkcję call_with_cache. Jednak w odróżnieniu od tamtej przyjmuje on nie argumenty dla funkcji, ale samą funkcję. Zwraca zresztą również funkcję. Kto nie wierzy, że wszystko działa, niech przetestuje:

cached_dummy = cache(dummy)

Funkcja zużyje sekundę na każde piersze "obliczenie", natomiast za drugim razem "wyliczy" błyskawicznie. Czy możemy chcieć czegoś więcej?

Hura hura!

Spostrzegawczy czytelnicy, a dokładniej ci, których pamięci krótkotrwałej nie zniszczył alkohol już zauważyli, że funkcja cache spełnia te warunki, o których mówiliśmy na początku. Jest bowiem obiektem wywoływalnym, który przyjmuje obiekt wywoływalny i zwraca obiekt wywoływalny. Mamy więc gotowy dekorator. A to co zrobiliśmy tworząc funkcję cached_dummy można uzyskać prościej:

@cache
def dummy(x):
    sleep(1)
    return x

Notacja, którą tu widzimy to cukier syntaktyczny. Gdybyśmy zamiast dekorować definicję funkcji napisali za definicją dummy = cache(dummy) efekt byłby ten sam.

O czym nie napisałem

Jak już wspomniałem nasz dekorator jest maksymalnie uproszczony. Wiele jeszcze mu brakuje, aby stał się naprawdę uogólniony i można go było używać w sposób w pełni "bezszwowy" (seamless).

Udekorujmy nasze funkcje i wywołajmy polecenie help(fact). Efekt jest smutny Funkcja według dokumentacji nazywa się res_fun (w ogóle, każda funkcja dekorowana @cache tak się nazywa) i przyjmuje dowolną krotkę argumentów. Gówno prawda! Wywołanie funkcji fact z np. dwoma argumentami wywoła błąd. Jak sobie z tym poradzić napiszę niedługo.

Nie pisałem też o dekorowaniu metod i klas. Co prawda logika jest tutaj identyczna, ale warto prześledzić, co jest gdzie przekazywane, żeby się potem nie zdziwić.

Niby tylko syntaktyczny cukierek, a potęga jego niezmierzona. Przede wszystkim dlatego, że przez umieszczenie notacji dekoratora nasz (niech mu piwo smakuje) dobrotliwy dyktator wskazał ciekawy schemat metaprogramowania. Kto otarł się o aplikacje WSGI i koncepcję middleware ten pewnie dostrzegł uderzające podobieństwo między nią, a dekoratorami. Nie zna więc Pythona, kto tej idei nie przyswoił.

Jeszcze dwa wpisy co najmiej planuję poświęcić dekoratorom. Następny jednak będzie o czymś innym, a konkretnie o Marsjanach. Do zobaczenia.

Meta

Published: Lis 11, 2010

Author: zefciu

Comments:  

Word Count: 1,035

Next: Wyginanie pythona

Previous: Powitanie

Bookmark and Share

Tags

dekoratory funkcje metaprogramowanie

Follow-Up Articles

Article Links

  1. Error 404 (Not Found)!!1
Comments powered by Disqus