Python - Dekoratory

Informacja

Wszystkie przykłady zawarte w tym poście pisane są z myślą o Pythonie w wersji 3.6.

Dekoratory opisano w dwóch dokumentach — PEP 318 oraz PEP 3129. Pierwszy z nich został opublikowany w 2003 roku (Python 2.4) i dotyczy dekoratorów stosowanych do funkcji oraz metod. Po 4 latach (rok 2007, Python 3.0) opublikowano drugi dokument, w którym rozszerzono ich możliwości o dekorowanie klas.

Dekorator to obiekt, który można wywołać jak funkcję (klasa lub funkcja). Obiekt ten jest wrapperem dla pierwotnego obiektu.

Zobaczmy to na przykładzie. Utwórzmy najprostszy dekorator — funkcję, która będzie zwracała przekazany jej obiekt:

def dekorator(obj):
    return obj

Opatrzmy teraz przykładową funkcję naszym nowo utworzonym dekoratorem:

@dekorator
def funkcja():
    print("hello")

Zapis ze znakiem @ przed funkcją to syntactic sugar i jest on równoważny następującemu zapisowi:

funkcja = dekorator(funkcja)

Operacje dokonywane są tutaj na nazwach. Nazwa funkcja przestaje wskazywać na obiekt reprezentujący naszą przykładową funkcję i od tego momentu wskazuje na obiekt zwrócony przez dekorator. W powyższym przypadku jest to ten sam obiekt, ale nietrudno jest sobie wyobrazić funkcję dekoratora w zmienionej postaci — zwracającej inny obiekt:

def inna_funkcja():
    print("inna funkcja")

def dekorator(obj):
    return inna_funkcja

@dekorator
def funkcja():
    print("hello")

funkcja()

Wynikiem działania powyższego skryptu jest:

inna funkcja

Zgodnie z przedstawionym wcześniej równoważnym zapisem, nazwa funkcja wskazuje teraz na obiekt zwrócony przez dekorator — czyli inna_funkcja.

Powszechną praktyką jest umieszczanie inna_funkcja w funkcji dekoratora i wywołanie w niej pierwotnego obiektu (w omawianym przypadku jest to funkcja):

def dekorator(obj):
    def inna_funkcja():
        obj()
        print("world")
    return inna_funkcja

@dekorator
def funkcja():
    print("hello")

funkcja()

Jednym z klasycznych już chyba przykładów na dekoratory jest cache. Utworzymy dekorator o nazwie cache dla funkcji get_web_page zwracającej dane z serwisu internetowego:

 1class WebMock():
 2    def get(self, url):
 3        return url + " always works!"
 4
 5def cache(wrapped_function):
 6    def wrapper(web, url):
 7        if url in "https://chyla.org/":
 8            return "It work's!"
 9        else:
10            return wrapped_function(web, url)
11    return wrapper
12
13@cache
14def get_web_page(web, url):
15    return web.get(url)
16
17
18web = WebMock()
19
20page = get_web_page(web, "chyla.org")
21print("chyla.org content: " + page)
22
23page = get_web_page(web, "google.com")
24print("google.com content: " + page)

Odwołanie do zawartości zdalnej może chwilę potrwać, dlatego zamiast rzeczywistego połączenia stworzyłem klasę WebMock. W przyszłości obiekt może zostać zmieniony, by faktycznie odwoływał się do treści umieszczonej w Internecie.

Na wydruku widzimy również funkcję cache będącą dekoratorem. Zwraca ona funkcję wrapper, która sprawdza, czy zna już podany adres i jeśli tak to zwraca wartość z cache, w przeciwnym wypadku wywołuje funkcję get_web_page odpowiedzialną za pobranie danych.

Pozostała część kodu powinna być dość oczywista. Jeśli nie, to zapraszam do dyskusji w komentarzach.

Przekazywanie argumentów

Do dekoratora możemy przekazać dowolne argumenty. W tym celu wykorzystamy nową funkcję, zobaczmy fragment kodu:

 1class WebMock():
 2    def get(self, url):
 3        return url + " always works!"
 4
 5def cache_with_value(cache_value):
 6    def cache(wrapped_function):
 7        def wrapper(web, url):
 8            if url in "https://chyla.org/":
 9                return cache_value
10            else:
11                return wrapped_function(web, url)
12        return wrapper
13    return cache
14
15@cache_with_value("It work's!")
16def get_web_page(web, url):
17    return web.get(url)
18
19
20web = WebMock()
21
22page = get_web_page(web, "chyla.org")
23print("chyla.org content: " + page)
24
25page = get_web_page(web, "google.com")
26print("google.com content: " + page)

Trzeba przyznać, że ten kod niewiele różni się od poprzedniego. Funkcja cache, widoczna w linii 6, jest prawie taka sama. Zmiana widoczna jest w linii 9, wykorzystywany jest parametr funkcji cache_with_value.

Istotną zmianą jest dodanie wspomnianej funkcji cache_with_value. Przyjmuje ona parametr i zwraca funkcję cache. Spójrzmy na powiązaną z tym zmianę w linii 15, to jest wywołanie funkcji. W poprzednim przykładzie (linia 13) tego wywołania nie było. Ostatecznie w to miejsce zostanie wstawiona funkcja cache.

Spróbujmy zapisać to podobnie jak poprzednio, bez nadmiernej ilości cukru składniowego:

get_web_page = cache_with_value("It works!")(get_web_page)

W efekcie jest to równoważne:

get_web_page = cache(get_web_page)

Technika ta jest szeroko wykorzystywana i warto ją znać.

Dekorator w formie klasy

Do tej pory skupialiśmy się na dekoratorze jako funkcji, ale może on być też klasą. Zobaczmy zmodyfikowany pierwszy przykład:

 1class WebMock():
 2    def get(self, url):
 3        return url + " always works!"
 4
 5class cache():
 6    def __init__(self, fun):
 7        self.fun = fun
 8
 9    def __call__(self, web, url):
10        if url in "https://chyla.org/":
11            return "It work's!"
12        else:
13            return self.fun(web, url)
14
15@cache
16def get_web_page(web, url):
17    return web.get(url)
18
19
20web = WebMock()
21
22page = get_web_page(web, "chyla.org")
23print("chyla.org content: " + page)
24
25page = get_web_page(web, "google.com")
26print("google.com content: " + page)

Zapiszmy fragment odpowiedzialny za dekorator bez cukru składniowego:

get_web_page = cache(get_web_page)

Widzimy, że jest to wywołanie funkcji __init__, czyli nazwa get_web_page będzie wskazywała na instancję klasy. Podczas próby wywołania instancji klasy jak funkcji, wywołana zostanie metoda __call__.

Czy dekorator w formie klasy może przyjmować argumenty? Oczywiście, zobaczmy zmodyfikowany drugi przykład:

 1class WebMock():
 2    def get(self, url):
 3        return url + " always works!"
 4
 5class cache_with_value():
 6    def __init__(self, cache_value):
 7        self.cache_value = cache_value
 8
 9    def __call__(self, obj):
10        def wrapper(web, url):
11            if url in "https://chyla.org/":
12                return self.cache_value
13            else:
14                return obj(web, url)
15        return wrapper
16
17@cache_with_value("It work's!")
18def get_web_page(web, url):
19    return web.get(url)
20
21
22web = WebMock()
23
24page = get_web_page(web, "chyla.org")
25print("chyla.org content: " + page)
26
27page = get_web_page(web, "google.com")
28print("google.com content: " + page)

Widzimy, że został wykonany zabieg podobny do opisywanego już wcześniej. Najpierw tworzymy instancję klasy, po czym używamy jej jako dekoratora. Za pomocą funkcji __init__ możemy przekazać argumenty, natomiast wywołanie funkcji __call__ spowoduje udekorowanie funkcji.

Zapiszmy to bez cukru składniowego:

get_web_page = cache_with_value("It works!")(get_web_page)

Ciąg cache_with_value("It works!") to oczywiście wywołanie konstruktora obiektu, następnie na tym obiekcie wywoływana jest funkcja __calll__, do której przekazywany jest obiekt get_web_page. Widoczna tutaj sytuacja jest analogiczna, do omawianego wcześniej przekazywania parametrów za pomocą funkcji.

Dekorowanie klasy

Dekorowanie klasy odbywa się w sposób analogiczny, do dotychczas omówionych. Jedyną różnicą jest fakt, iż nie dekorujemy funkcji, a klasę.

Zobaczmy przykład zaproponowany przez theheadofabroom na stackoverflow:

def singleton(class_):
  instances = {}
  def getinstance(*args, **kwargs):
    if class_ not in instances:
        instances[class_] = class_(*args, **kwargs)
    return instances[class_]
  return getinstance

@singleton
class MyClass(BaseClass):
  pass

Dlaczego to działa? Otóż rozpisując przykład, w kod pozbawiony cukru składniowego, otrzymujemy:

MyClass = singleton(MyClass)

MyClass wskazuje teraz na funkcję getinstance, którą składniowo wywołujemy w ten sam sposób, w jaki tworzymy nowy obiekt klasy:

my_class_instance = MyClass()

Funkcja ta sprawdza, czy dany obiekt już istnieje i go zwraca, w przeciwnym wypadku jest on tworzony.

Zauważmy, że pierwotna nazwa MyClass nie wskazuje na obiekt klasy, ale na specjalny typ reprezentujący klasę.

Dekorator wraps

Dla przejrzystości kodu, w poprzednich przykładach pominięto, istotny podczas tworzenia własnego dekoratora, dekorator wraps. Jego pominięcie powoduje utratę metadanych dekorowanej funkcji (np. docstringa). Zalecane jest, by był on dodawany do tworzonych dekoratorów.

Oto przykłady, bazujące na tych z dokumentacji, pokazujące utratę metadanych.

Wersja z dekoratorem wraps:

from functools import wraps

def my_decorator(f):
    @wraps(f)
    def wrapper(*args, **kwds):
        print('Calling decorated function')
        return f(*args, **kwds)
    return wrapper

@my_decorator
def example():
    """Docstring"""
    print('Called example function')

example()
print(example.__name__)
print(example.__doc__)

Wynik działania:

Calling decorated function
Called example function
example
Docstring

Wersja bez dekoratora wraps:

def my_decorator(f):
    def wrapper(*args, **kwds):
        print('Calling decorated function')
        return f(*args, **kwds)
    return wrapper

@my_decorator
def example():
    """Docstring"""
    print('Called example function')

example()
print(example.__name__)
print(example.__doc__)

Wynik działania:

Calling decorated function
Called example function
wrapper
None

Literatura

  1. PEP 318 – Decorators for Functions and Methods

  2. PEP 3129 – Class Decorators

  3. Creating a singleton in Python

  4. 10.2. functools — Higher-order functions and operations on callable objects