Перейти до змісту

Декоратори

Декоруємо подарунки

Уявімо, що у нас є певний предмет, який ми хочемо комусь подарувати. Давайте оформимо цей подарунок як функцію:

>>> def gift_function():
...     print('Я — подарунок!')
...
>>> gift_function()
Я  подарунок!
>>>

Але щоб наш подарунок виглядав привабливо, давайте обгорнемо його у привабливе святкове упакування. Упакування ми теж представимо функцією, яка, буде в якості аргумента приймати наш подарунок, ну, саме той подарунок, який нам і треба обгорнути:

>>> def wrap_function(gift_to_wrap_function):
...     print('Я — святкова обгортка! Я обгорну подарунок.')
...     gift_to_wrap_function()
...     print('Подарунок обгорнуто!')
...
>>> wrap_function(gift_function)
Я  святкова обгортка! Я обгорну подарунок.
Я  подарунок!
Подарунок обгорнуто!
>>>

І уявімо собі людину, яка займається обгортанням подарунків у святкове упакування. Назвемо її "декоратором".

Що потрібно декоратору? Упакування у нього вже є, він добре підготувався до виконання своїх обов'язків. Отже йому лише треба дати той подарунок, який треба "декорувати" святковою обгорткою.

Що буде робити декоратор? Візме упакування, обгорне ним подарунок, і поверне вже упакований подарунок.

Декоратор у нас теж буде функцією:

>>> def decorator_function(gift_to_wrap_function):
...     def wrap_function():
...             print('Я — святкова обгортка! Я обгорну подарунок.')
...             gift_to_wrap_function()
...             print('Подарунок обгорнуто!')
...     return wrap_function
...
>>> decorated_gift_function = decorator_function(gift_function)

Давайте подивимось що ми тепер маємо:

>>> gift_function()
Я  подарунок!
>>> decorated_gift_function()
Я  святкова обгортка! Я обгорну подарунок.
Я  подарунок!
Подарунок обгорнуто!
>>>

Упс... Два подарунки — декорований і не декорований! А має ж бути один подарунок — просто той, що був раніше недекорованим став декорованим. Давайте виправимо помилку:

>>> gift_function = decorator_function(gift_function)
>>> gift_function()
Я  святкова обгортка! Я обгорну подарунок.
Я  подарунок!
Подарунок обгорнуто!
>>>

Те, що треба! Віддали подарунок декоратору, і отримали його вже декорованим.

Які можна зробити висновки на даний момент?

Ми взяли функцію, за допомогою декоратора додали до неї певний функціонал, і таким чином отримали цю функцію, але вже "покращену".

Декоруємо зручно!

Декоратори — дуже корисна і зручна штука, і на практиці декоратори застосовуються досить часто. Тому для декораторів в Python придумали спеціальний синтаксис, який дозволяє використовувати декоратори більш зручно. З вищенаведеного прикладу наступний запис:

>>> def gift_iphone():
...     print('Я — айфон!')
...
>>> gift_iphone = decorator_function(gift_iphone)

буде ідентичним такому:

>>> @decorator_function
... def gift_iphone():
...     print('Я — айфон!')
...
>>> gift_iphone()
Я  святкова обгортка! Я обгорну подарунок.
Я  айфон!
Подарунок обгорнуто!
>>>

Тобто спочатку вказуємо ім'я декоратора після значка @, і на наступному рядку ту функцію, яку треба декорувати вищенаведеним декоратором.

Давайте на прикладах розглянемо як ще можна використовувати декоратори.

Практикум: хто швидше?

Задача: написати функцію яка повертає символьнлий рядок з одного мільйона пробілів.

Задачу можна вирішити багатьма способами, ось наприклад:

>>> def f1():
...     res = ''
...     for i in range(10**6):
...             res += ' '
...
>>> def f2():
...     res = ' ' * 10**6
...
>>>

Яка з функцій буде виконуватись швидше? Щоб з'ясувати, давайте просто заміряємо час який треба на виконання кожної з функцій.

Для вимірювання часу скористаємось функцією clock() з вбудованого модуля timie:

>>> from time import clock
>>> def f2():
...     t1 = clock()
...     res = ' ' * 10**6
...     print('f2:', clock()-t1)
...
>>> s = f2()
f2: 8.621491873839204e-05
>>>

Ми написали код для функції f2(), який вимірює і виводить час необхідний для виконання цієї функції. І вже тут ми бачимо наступні недоліки:

  • ми змінили функцію, час роботи якої хочемо вимірювати
  • для інших функції для вирішення задачі так само треба буде змінити код, причому код цей буде кожного разу повторюватись

А чи не можна було б зробити таку штуку, щоб кожна функція вимірювала і виводила б час потрібний на її виконання? І тут на допомогу приходять декоратори. За допомогою декоратора ми зможемо як би розширити можливості функції, що передамо йому.

>>> def timeit(func):
...     def wrapper():
...         t1 = clock()
...         func()
...         print(clock()-t1)
...     return wrapper
...
>>>
>>> @timeit
... def f1():
...     res = ' ' * 10**6
...
>>> @timeit
... def f2():
...     res = ''
...     for i in range(10**6):
...             res += ' '
...
>>> f1()
0.001224114997057768
>>> f2()
0.28307779048941484
>>>

Ланцюжки з декораторів

Синтаксис Python дозволяє одночасне використання декількох декораторів.

>>> def bread(func):
...     def wrapper():
...             print('Хліб')
...             func()
...             print('Хліб')
...     return wrapper
...
>>> def salad(func):
...     def wrapper():
...             print('Зеленина')
...             func()
...             print('Зеленина')
...     return wrapper
...
>>> @bread
... @salad
... def stake():
...     print("М'ясо")
...
...
>>> stake()
Хліб
Зеленина
М'ясо
Зеленина
Хліб
>>>

Застосування тих самих декораторів, але без "магічного" символа @:

stake = bread(salad(stake))

Зауважте що послідовність застосування декораторів має значення:

>>> @salad
... @bread
... def stake():
...     print("М'ясо")
...
>>> stake()
Зеленина
Хліб
М'ясо
Хліб
Зеленина
>>>

Декоруємо функції з параметрами

У попередніх прикладах ми декорували функції, які не мали ніяких параметрів. А як щодо функцій з параметрами?

Припустимо маємо таку функцію:

>>> def div(a, b):
...     return a / b
...
>>>

При певних умовах ми отримаємо помилку:

>>> div(2, 0)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 2, in div
ZeroDivisionError: division by zero
>>>

Давайте спробуємо створити декоратор, який буде перевіряти вхідні параметри функції:

>>> def smart_div(func):
...     def wrapper(a, b):
...             print("I am going to divide",a,"and",b)
...             if b == 0:
...                     print("Oops! cannot divide")
...                     return
...             return func(a, b)
...     return wrapper
...
>>>

Такий варіант поверне None якщо умова спрацює:

>>> @smart_div
... def div(a, b):
...     return a / b
...
>>> div(1, 0)
I am going to divide 1 and 0
Oops! cannot divide
>>> div(1, 1)
I am going to divide 1 and 1
1.0
>>>

Можна зауважити, параметри вкладеної функції-обгортки wrapper() всередині декоратора такі самі як і у функції, що декорується. Знаючи це ми можемо створити "загальний" декоратор, який буде працювати з будь-якою кількістю параметрів.

Напишемо декоратор, який буде повідомляти нам, що ми передали функції, яку він декорує:

>>> def works_for_all(func):
...     def wrapper(*args, **kwargs):
...             print('I got next parameters:', args, kwargs)
...             return func(*args, **kwargs)
...     return wrapper
...
>>> @works_for_all
... def f(a, b):
...     pass
...
>>> f('string', b=123)
I got next parameters: ('string',) {'b': 123}
>>>

Декоратори з параметрами

Давайте напишемо декоратор, який буде готувати нам сендвічі:

>>> def make_sandwich(func):
...     def wrapper(sandwich_with):
...             print('Хліб')
...             func(sandwich_with)
...             print('Хліб')
...     return wrapper
...
>>> @make_sandwich
... def sandwich(sandwich_with):
...     print(sandwich_with)
...
>>> sandwich("М'ясо")
Хліб
М'ясо
Хліб
>>>

Непогано. Але як щодо ситуації, коли для приготування сендвіча ми захочемо використовувати не хліб, а, припустимо, тости? Напрошується ідея повідомити декоратору що ми хочемо використовувати в якості "обгортки" нашого сендвіча, тобто передати декоратору якісь параметри.

Отже, декоратор приймає свої параметри, але потім він ще має прийняти функцію, яку має декорувати, а потім ще й аргументи декорованої функції. Таким чином нам доведеться зробити аж 3 функції:

>>> def make_sandwich(sandwich_cover):
...     def decorator(func):
...             def wrapper(sandwich_with):
...                     print(sandwich_cover)
...                     func(sandwich_with)
...                     print(sandwich_cover)
...             return wrapper
...     return decorator
...
>>> @make_sandwich('Тост')
... def sandwich(sandwich_with):
...     print(sandwich_with)
...
>>> sandwich("Ковбаса")
Тост
Ковбаса
Тост
>>> @make_sandwich('Хліб')
... def sandwich(sandwich_with):
...     print(sandwich_with)
...
>>> sandwich("Сало")
Хліб
Сало
Хліб
>>>

Функція make_sandwich приймає в якості параметра "обгортку" для сендвіча sandwich_cover і повертає функцію decorator яка, як і раніше, приймає тільки функцію що треба декорувати і повертає свою внутрішню функцію wrapper, яка у свою чергу приймає аргументи декорованої функції func.

Те ж саме можна написати "без магії":

>>> def sandwich(sandwich_with):
...     print(sandwich_with)
...
>>> sandwich = make_sandwich('Хліб')(sandwich)
>>> sandwich("Сало")
Хліб
Сало
Хліб
>>>

З декораторів з параметрами теж можна будувати ланцюжки:

>>> @make_sandwich('Хліб')
... @make_sandwich('Хрін')
... def super_sandwich(sandwich_with):
...     print(sandwich_with)
...
>>> super_sandwich("Сало")
Хліб
Хрін
Сало
Хрін
Хліб
>>>

Клас-декоратор

Отже щоб створити декоратор з параметрами ми використовували три вкладені одна в одну функції. Виглядає трішечки монструозно... І саме так було прийнято писати параметризовані декоратори, доки комусь у голову не прийшла світла думка, що декоратор можна створити у вигляді класа.

По суті нам необхідно розділити етапи створення з запам'ятовуванням переданих параметрів і виклика, тобто зробити те, що роблять класи.

class sandwich_cover:
    def __init__(self, cover):
        self._cover = cover

    def __call__(self, func):
        def wrapper(sandwich_with):
            print(self._cover)
            func(sandwich_with)
            print(self._cover)
        return wrapper

Приготуємо "класний" сендвіч:

>>> @sandwich_cover('Тост')
... @sandwich_cover('Хрін')
... def sandwich(sandwich_with):
...     print(sandwich_with)
...
>>> sandwich("Ковбаса")
Тост
Хрін
Ковбаса
Хрін
Тост
>>>

Код виглядає набагато читабельнішим. Залишилось лише змиритись з назвою класа з маленької букви, або ж з назвою декоратора з великої 😊.

Декорування класів

Декорувати можна не лише функції, але і класи. Реалізуємо декоратор, який додає до класа метод для представлення класа у вигляді символьного рядка:

def auto_str_class(any_class):
    def str(self):
        variables = (f'{key}={value!r}' for key, value in vars(self).items())
        return f'{any_class.__name__}({", ".join(variables)})'

    any_class.__str__ = str
    return any_class

Тепер декоровані класи можуть детальнло розказати про себе:

>>> @auto_str_class
... class Person:
...     def __init__(self, name, age):
...         self.name = name
...         self.age = age
...
>>> p = Person('Jane', 26)
>>> print(p)
Person(name='Jane', age=26)
>>>

Резюме

  • декоратор — спосіб модифікувати поведінку функції, зберігаючи читабельність кода
  • декоратори дещо сповільнюють виклики функцій
  • порядок вказання декораторів має значення
  • клас може виступати декоратором
  • декорувати можна не лише функції, але і класи
Back to top