Języki programowaniaPython

Dekoratory w Pythonie – rozszerzanie funkcjonalności obiektów

Python - dekoratory

Dekoratory w Pythonie pełnią ważną rolę, gdyż w prosty sposób pozwalają nam zmienić zachowanie funkcji, metody bądź klasy bez modyfikowania jej kodu. Dekoratory zostały opisane w PEP 318 (Python 2.4) oraz w PEP 3129 (Python 3.0), gdzie zostały dodane dekoratory klas.

Czym właściwie jest dekorator?

Można powiedzieć, że dekoratorem jest funkcja przyjmująca inną funkcję i rozszerzająca ją.
Przypuśćmy, że chcielibyśmy mieć możliwość zapamiętywania wyników dla wywołań funkcji z różnymi parametrami, zmierzyć czas wykonywania funkcji lub sprawdzić czy ktoś ma wystarczające uprawnienia aby wywołać pewne funkcje.
Wszystkie powyższe przykłady możemy rozwiązać za pomocą dekoratorów.

Zacznijmy od prostego przykładu:

def decorator(func):
    def wrapper():
        print("Za chwilę wykona się funkcja.")
        func()
        print("Funkcja została wykonana.")
    
    return wrapper


def helloworld():
    print("Hello World!")


helloworld = decorator(helloworld)
helloworld()

Powyżej zdefiniowaliśmy dwie funkcje.
Funkcja helloworld jedynie wyświetla na ekranie tekst.
Funkcja decorator jest tutaj kluczowa. Jako argument przyjmuje ona inną funkcję, definiuje nową funkcję, a na koniec ją zwraca.
W zdefiniowanej funkcji wrapper wyświetlany jest tekst „Za chwilę wykona się funkcja.”, następnie wykonywana jest funkcja przesłana do funkcji decorator, a na koniec wyświetlany jest tekst „Funkcja została wykonana.”.

Co się dzieje po wykonaniu linii helloworld = decorator(helloworld)?
Funkcja decorator zwróci nam nową funkcję rozszerzoną o wyświetlanie tekstu przed i po wywołaniu przesłanej funkcji.
Po wyświetleniu typu obiektu helloworld linią print(type(helloworld)) dostaniemy wartość <class 'function'>.

Wynikiem wywołania helloworld() będzie następujący tekst:

Za chwilę wykona się funkcja.
Hello World!
Funkcja została wykonana.

Syntactic Sugar

Przypuśćmy że napisaliśmy dekorator i chcemy go użyć do wielu funkcji. Ciągłe wykonywanie funkcji dekoratora ręcznie może być uciążliwe. Python oferuje nam tzw. „lukier składniowy”, przez co możemy dekorować funkcje w prostszy sposób. Powyższy przykład mógłby wyglądać tak:

def decorator(func):
    def wrapper():
        print("Za chwilę wykona się funkcja.")
        func()
        print("Funkcja została wykonana.")
    
    return wrapper

@decorator
def helloworld():
    print("Hello World!")

helloworld()

Dodanie @decorator nad definicją funkcji helloworld jest równoznaczne z wykonaniem linii helloworld = decorator(helloworld).

Bardziej życiowe przykłady

Wróćmy do przykładów podanych na początku wpisu i spróbujmy je zaimplementować.

Mierzenie czasu wykonania funkcji

W przypadku tego przykładu chcielibyśmy napisać dekorator, który zmierzy czas wykonania wybranej funkcji, a następnie zwróci nam wynik oraz zmierzony czas. Wykorzystamy do tego moduł timeit, a dekorator nazwiemy measure_time.

from timeit import default_timer

def measure_time(func):
    def inner(*args):
        t = default_timer()
        result = func(*args)
        t = default_timer() - t
        return result, t
    return inner

@measure_time
def fib(n):
    a = 0
    b = 1
    i = 0
    while i < n:
        a, b = b, a + b
        i += 1
    return a

print("Result:", fib(123))

Wynikiem działania powyższego kodu powinno być wyświetlenie tupli zawierającej wynik oraz czas wykonania funkcji w sekundach.

Result: (22698374052006863956975682, 1.810000000000006e-05)

Cache funkcji

O wiele ciekawszym przykładem jest utworzenie dekoratora zapamiętującego wyniki funkcji dla przesłanych parametrów. Dla przykładu napiszemy rekurencyjną funkcję wyliczającą n-tą liczbę ciągu Fibonacciego.

def fib(n):
    if n < 1:
        return 0
    
    if n == 1:
        return 1
    
    return fib(n - 1) + fib(n - 2)

print("Result:", fib(10))

Podany wyżej kod powinien nam szybko policzyć wartość, ale spróbujmy podmienić argument 10 na 100. Powyższa funkcja dla parametru 100 zostałaby wywołana 708 449 696 358 523 830 149 razy, czego niestety nasze komputery nie są w stanie w rozsądnym (a nawet i nierozsądnym) czasie policzyć 🙂

Spróbujmy to przyspieszyć tworząc dekorator zapamiętujący wyniki.

def cache(func):
    mem = {}
    def inner(*args):
        if args in mem:
            return mem[args]

        result = func(*args)
        mem[args] = result

        return result
    return inner

Omówmy kolejne linie powyższego kodu:
2 – deklarujemy pusty słownik,
4 – sprawdzamy czy w słowniku znajduje się wynik dla danych argumentów funkcji,
5 – jeżeli tak, to zwracamy zapisany wcześniej wynik
7 – jeżeli nie, to liczymy wynik funkcji dla przesłanych argumentów
8 – zapisujemy wynik we wcześniej zdefiniowanym słowniku
10 – zwracamy obliczony wynik

Teraz możemy dodać dekorator cache do naszej funkcji i spróbować policzyć setną liczbę ciągu Fibonacciego. Wynik powinniśmy dostać praktycznie od razu.

Policzenie większych liczb też nie powinno sprawić problemu naszemu komputerowi, ale do tego musimy zmienić limit maksymalnej głębokości rekurencji używając poniższego kodu:

import sys
sys.setrecursionlimit(1500)

Ciekawostka: Dekorator cache znajduje się w module functools, z którym polecam się zapoznać. Oprócz cache mamy również dekorator lru_cache (last recently used cache), do którego możemy podać parametr maxsize. O parametrach porozmawiamy za chwilę.

Parametry dekoratorów

Kolejną przydatną rzeczą jest możliwość podania parametrów do dekoratora. Poziom zagłębienia funkcji rośnie wtedy z 2 do 3. Poniższy kod przedstawia dekorator wykonujący przesłaną funkcję n razy, gdzie n jest podawane jako argument do dekoratora.

def repeat(n):
    def wrapper(func):
        def inner(*args):
            for i in range(n):
                func(*args)
        
        return inner
    return wrapper

@repeat(n=5)
def helloworld():
    print("Hello World!")

helloworld()

Różnica w porównaniu do dekoratorów bezargumentowych polega na tym, że całość jest dodatkowo opakowana w kolejną funkcję (def repeat(n):), przyjmującą argumenty.
Oczywiście argumenty mogą mieć wartości domyślne.

Uwaga! Jeżeli używamy dekoratora przyjmującego argumenty i chcemy użyć wartości domyślnych, musimy i tak na końcu napisać nawiasy. Gdyby nasz dekorator repeat posiadał wartość domyślną i chcielibyśmy jej użyć to powinniśmy go użyć tak: @repeat(), a nie tak: @repeat.

Dekorowanie klas

Dekoratory działają również na klasy. Wtedy po prostu do dekoratora zamiast obiektu funkcji przesyłany jest obiekt klasy. Mając obiekt klasy możemy nią manipulować według własnego uznania.
Prostym przykładem może być utworzenie dekoratora dodającego do klasy jakieś pole.

import logging

def log(level):
    def wrapper(cls):
        cls.logger = logging.getLogger(cls.__name__)
        cls.logger.setLevel(level)
        handler = logging.StreamHandler()
        handler.setLevel(level)
        formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
        handler.setFormatter(formatter)
        cls.logger.addHandler(handler)
        return cls
    return wrapper

@log(logging.INFO)
class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y
        self.logger.info(f"Utworzono punkt ({x}, {y})")

p = Point(3, 4)

W powyższym przykładzie zdefiniowaliśmy dekorator log, który przyjmuje 1 parametr (poziom logowania) oraz konfiguruje i dodaje do klasy pole logger. Następnie dodaliśmy linię @log(logging.INFO) nad definicją klasy Point, dzięki czemu możemy później wywołać self.logger.info(f"Utworzono punkt ({x}, {y})").
Wynikiem powyższego kodu jest wyświetlenie w konsoli linii z logiem:

2021-05-09 20:47:44,003 - Point - INFO - Utworzono punkt (3, 4)

Dekorator jako klasa

Jeżeli nasz dekorator ma bardziej skomplikowaną logikę, to zamiast pisać długą i mało czytelną funkcję możemy użyć klasy. Minusem tego rozwiązania jest to, że jeżeli chcemy przesłać dodatkowe argumenty do dekoratora, to musimy zdefiniować dodatkową funkcję.
Przykładowy dekorator na bazie klasy wygląda następująco:

class Count:
    def __init__(self, function):
        self.function = function
        self.cnt = 0

    def __call__(self, *args, **kwargs):
        self.function(*args, **kwargs)
        self.cnt += 1

@Count
def helloworld():
    print("Hello World!")

helloworld()
helloworld()
helloworld()
print(helloworld.cnt)

Powyższy kod wyświetli 3x tekst „Hello World!” oraz liczbę 3, mówiącą ile razy była wywołana dana funkcja.
Tym razem helloworld nie jest funkcją, a instancją klasy Count. Zdefiniowanie magicznej metody __call__ pozwala nam wywoływać bezpośrednio obiekt klasy, dzięki czemu możemy klasie nadać zachowanie takie jak posiada funkcja.
Jeżeli teraz chcielibyśmy dodać parametr do dekoratora (np. step), musimy zdefiniować dodatkową funkcję oraz dodać parametr do metody __init__:

class _Count:
    def __init__(self, function, step):
        self.function = function
        self.step = step
        self.cnt = 0

    def __call__(self, *args, **kwargs):
        self.function(*args, **kwargs)
        self.cnt += self.step

def Count(step=1):
    def wrapper(function):
        return _Count(function, step)
    return wrapper

@Count(step=3)
def helloworld():
    print("Hello World!")

helloworld()
helloworld()
helloworld()
print(helloworld.cnt)

Tym razem zamiast liczby 3 zostanie zwrócona liczba 9, ponieważ do dekoratora przekazaliśmy parametr step=3.
W przypadku pierwszej wersji kodu

@Count
def helloworld():

zostało zamienione na helloworld = Count(helloworld).
Zaś w przypadku drugiej wersji kodu

@Count(step=3)
def helloworld():

zostało zamienione na helloworld = Count(step=3)(helloworld).

Łączenie dekoratorów

Dekoratory w pythonie możemy łączyć. Dekoratory wykonują się w kolejności od końca. Poniższy kod pokazuje przykład użycia dwóch dekoratorów na jednej funkcji:

def bold(fn):
    return lambda : "<b>" + fn() + "</b>"

def italic(fn):
    return lambda : "<i>" + fn() + "</i>"

@bold
@italic
def hello():
  return "hello world"

print(hello())

Definicja funkcji hello będzie wyglądała następująco: hello = bold(italic(hello)), a wynikiem działania powyższego kodu będzie tekst <b><i>hello world</i></b>.

Atrybuty dekorowanej funkcji

Wartą do poruszenia kwestią są atrybuty dekorowanej przez nas funkcji.
Przypuśćmy że mamy następującą funkcję oraz chcemy wykonać na niej funkcję help:

def hello():
    """This function prints "Hello World!\""""
    return "hello world"

help(hello)

Powinniśmy ujrzeć następujące informacje:

Help on function hello in module __main__:

hello()
    This function prints "Hello World!"

A teraz spróbujmy dodać do niej dekorator:

def bold(fn):
    def wrapper(*args, **kwargs):
        return lambda : "<b>" + fn(*args, **kwargs) + "</b>"
    return wrapper

@bold
def hello():
    """This function prints "Hello World!\""""
    return "hello world"

help(hello)

Tym razem jednak dostajemy informacje o funkcji wrapper, a nie funkcji hello:

Help on function wrapper in module __main__:

wrapper(*args, **kwargs)

Oznacza to że utraciliśmy dostęp do opisu funkcji. Z pomocą przychodzi nam moduł functools oraz dekorator wraps. Dekorując funkcję wrapper jesteśmy w stanie przekopiować docstring, argumenty oraz inne dane z przesłanej do dekoratora funkcji. Poniższy przykład pokazuje wykorzystanie dekoratora wraps.

import functools

def bold(fn):
    @functools.wraps(fn)
    def wrapper(*args, **kwargs):
        return lambda : "<b>" + fn(*args, **kwargs) + "</b>"
    return wrapper

@bold
def hello():
    """This function prints "Hello World!\""""
    return "hello world"

help(hello)

Znowu mamy informacje o funkcji hello 🙂

Help on function hello in module __main__:

hello()
    This function prints "Hello World!"

Podsumowanie

Dowiedzieliśmy się czym jest dekorator i jak napisać własny. Dzieki nim możemy rozszerzyć funkcjonalność nie powodując zmian w jej kodzie i nie zaciemniając logiki. Tworzenie dekoratorów klas jest również ciekawą opcją. Jeżeli ktoś chciałby dodać wzorzec singletona do klasy, może to bez problemu zrobić definiując w dekoratorze słownik, gdzie kluczem jest klasa, a wartością instancja klasy.
We wpisie pojawiła się taka metoda jak __call__ i takie „magiczne metody” omówimy w kolejnym wpisie.

You may also like

Leave a reply

Twój adres e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *