Уважаемые читатели, рад вас приветствовать в новой статье. Этот материал является продолжением предыдущей публикации, посвященной замыканиям. В данной части обзора мы рассмотрим декораторы.

Эта статья написана в первую очередь для тех, кто только начинает свой путь в программировании или начал изучать Python. Потому здесь я не буду рассматривать декораторы классов, чтобы сделать материал более доступным для новичков. Тем не менее, для тех, кто изучит данную статью, не составит труда разобраться в декораторах классов, так как они не имеют существенных отличий от рассматриваемых здесь декораторов функций.

Продолжая свой рассказ, я предполагаю, что вы уже ознакомились с предыдущей частью, посвященной замыканиям. Хоть эта статья и сфокусирована на декораторах, без знания концепции замыканий будет сложно разобраться с представленным материалом.

Введение в декораторы

В Python декораторы представляют собой мощный инструмент, который позволяет модифицировать поведение функций без изменения их кода. Чаще всего они используются для добавления дополнительной функциональности вокруг существующих функций. Это может включать в себя не только изменение выходных данных или выполнения определенных действий до и после вызова функции, но также возможность комбинирования нескольких декораторов для создания сложной функциональности. Кроме того, декораторы используются для логирования и отладки, обеспечивая прозрачный и эффективный способ отслеживания выполнения кода и выявления возможных проблем. Таким образом, декораторы становятся важным инструментом для улучшения гибкости и эффективности кода в Python.

В широком смысле, декораторы в Python это вызываемые объекты, которые создаются с помощью замыканий, принимают в качестве аргумента и возвращают дополненный или измененный вызываемый объект. В отношении декораторов функций, определение можно сформулировать следующим образом: декоратор это функция, которая создается с помощью замыкания, принимающая в качестве аргумента оборачиваемую функцию и возвращающая обернутую функцию.

Рассмотрим простой пример

def decorator(foo):
    def wrapper():
        print('text before foo call')
        foo()
        print('text after foo call')
    return wrapper

def greet():
    print('Hello')

greet()     # Hello

# Применяем декоратор к функции greet
greet = decorator(greet)

greet()     # text before foo call
            # Hello
            # text after foo call

В данном примере видно, что до декорирования функция выполняет ожидаемое действие, но после декорирования результат ее вызова меняется, хотя код самой функции не менялся. Это и есть изменение (модификация) поведения.

В Python реализован синтаксический сахар позволяющий декорировать функции проще, чем на предыдущем примере. Для этого перед оборачиваемой функцией необходимо указать имя функции-декоратора, добавив перед ним @ при этом не требуется записывать в скобках имя декорируемой функции, т.к. интерпретатор сам понимает, что декорируется функция из следующей строки.

Предыдущий пример с учетом данного синтаксиса будет выглядеть так:

def decorator(foo):
    def wrapper():
        print('text before foo call')
        foo()
        print('text after foo call')
    return wrapper

@decorator
def greet():
    print('Hello')

greet()     # text before foo call
            # Hello
            # text after foo call

Использование декораторной нотации делает код более ясным и удобным для чтения.

К одной функции можно применить несколько декораторов. В развернутом виде, запись будет выглядеть так:

def each_pheasant(foo):
    def wrapper():
        print('каждый')
        foo()
        print('фазан')
    return wrapper

def hunter_is_sitting(foo):
    def wrapper():
        print('охотник')
        foo()
        print('сидит')
    return wrapper

def wishes_where(foo):
    def wrapper():
        print('желает')
        foo()
        print('где')
    return wrapper

def know():
    print('знать')

know = each_pheasant(hunter_is_sitting(wishes_where(know)))
know()

# каждый
# охотник
# желает
# знать
# где
# сидит
# фазан

Результат выполнения данного кода будет аналогичен синтаксису:

@each_pheasant
@hunter_is_sitting
@wishes_where
def know():
    print('знать')

know()

# каждый
# охотник
# желает
# знать
# где
# сидит
# фазан

В обоих случаях интерпретатор получает новую функцию, обернув предыдущую функцию в обертку, из чего следует, что порядок применения декораторов влияет на результат.

Порядок применения декораторов
Порядок применения декораторов

При этом в случае использования синтаксического сахара функция know оборачивается сначала ближайшим декоратором, функция, получившаяся в результате, оборачивается следующим декоратором и так пока не будут применены все декораторы.

Использование декораторов в ином порядке приведет к неверному результату:

@wishes_where
@hunter_is_sitting
@each_pheasant
def know():
    print('знать')

know()

# желает
# охотник
# каждый
# знать
# фазан
# сидит
# где

Декораторы с аргументами

Может возникнуть ситуация, в которой декоратору требуется передать какое-то значение в виде аргумента. Решение может показаться не очевидным, если не разобраться с пошаговым оборачиванием функции.

Допустим, у нас есть сложная функция (longrunningfoo) выполнение которой занимает продолжительное время. Но мы заметили, что в нашем сценарии чаще всего при ее вызове используются одни и те же значения (которые впрочем, могут со временем измениться). Для оптимизации работы приложения, мы можем написать декоратор, который будет кэшировать результаты выполнения этой функции. При этом очевидно, что кол-во кэшированных значений должно быть ограничено, иначе производительность будет снижаться уже из-за объема кэша.
В первую очередь создадим функцию с параметром, который будет указывать кол-во значений в кэше.
def memoize(amount):
Далее мы создаем функцию-декоратор, которая принимает оборачиваемую функцию в качестве аргумента
def inner(foo):
а уже внутри нее мы создаем функцию-обертку
def wrapper(arg):
и описываем логику работы декоратора, условие проверяющее, есть ли у нас уже результат для текущего аргумента и условие проверяющее, достигнут ли лимит значений в кэше. После этого собираем все вместе. Получившаяся функция может иметь следующий вид:

def memoize(amount):
    args_list = []
    memoize_dict = {}

    def inner(foo):
        def wrapper(arg):
            if arg not in memoize_dict:
                if len(args_list) == amount:
                    value = args_list.pop(0)
                    memoize_dict.pop(value)
                args_list.append(arg)
                new_value = foo(arg)
                memoize_dict[arg] = new_value
            return memoize_dict[arg]
        return wrapper
    return inner

Далее используем присваивание, что бы обернуть декорируемую функцию:

decorator = memoize(3)
long_running_foo = decorator(long_running_foo)

В первой строке мы вызываем функцию memoize с аргументом 3 (кол-во значений в кэше), которая возвращает нам замыкание inner, которое и будет нашим декоратором. Его мы присваиваем переменной decorator. Функция inner ожидает в качестве аргумента оборачиваемую функцию. Соответственно, мы вызываем получившуюся функцию и передаем ей в качестве аргумента функцию long_running_foo, после чего обернутую функцию присваиваем переменной с тем же именем. Данный синтаксис можно сократить до одной строки следующим образом:

long_running_foo = memoize(3)(long_running_foo)

В данной строке происходит все то же самое, но без дополнительной переменной. Ну и переходя к использованию синтаксического сахара, вспоминаем, что имя декорируемой функции, в скобках после имени декоратора, писать не нужно, получаем следующую запись
@memoize(3)
Полностью скрипт может выглядеть следующим образом:

def memoize(amount):
    args_list = []
    memoize_dict = {}

    def inner(foo):
        def wrapper(arg):
            if arg not in memoize_dict:
                if len(args_list) == amount:
                    value = args_list.pop(0)
                    memoize_dict.pop(value)
                args_list.append(arg)
                new_value = foo(arg)
                memoize_dict[arg] = new_value
            return memoize_dict[arg]
        return wrapper
    return inner

@memoize(3)
def long_running_foo(arg):
    print('processing..', end=' ')
    return arg

print(long_running_foo(1))      # processing.. 1
print(long_running_foo(2))      # processing.. 2
print(long_running_foo(3))      # processing.. 3
print(long_running_foo(2))      # 2
print(long_running_foo(3))      # 3
print(long_running_foo(4))      # processing.. 4
print(long_running_foo(3))      # 3
print(long_running_foo(1))      # processing.. 1

Декоратор кэширует возвращаемые функцией значения и в случае, если для какого-то аргумента есть результат, возвращает его не вызывая саму функцию.

Сохранение атрибутов при декорировании функции

У любой объявленной функции есть множество атрибутов, начиная с имени, имени модуля и docstring'a, и заканчивая пользовательскими атрибутами. Получить доступ с этим атрибутам можно по соответствующим именам.

def print_greet(name):
    """Some description"""
    print('Hello, ' + name + '!')

print(print_greet.__name__)     # print_greet
print(print_greet.__doc__)      # Some description

Однако, при вызове задекорированной функции, вызывается именно обертка, и при попытке получить какие-либо атрибуты, будут получены атрибуты именно обертки:

def decorator(foo):
    def wrapper(*args):
        foo(*args)
    return wrapper

@decorator
def print_greet(name):
    """Some description"""
    print('Hello, ' + name + '!')

print(print_greet.__name__)     # wrapper
print(print_greet.__doc__)      # None

Мы можем изменить и это поведение, передав функции-обертке соответствующие атрибуты оборачиваемой функции. Для этого после определения функции обертки нужно добавить строки типа wrapper.__name__ = foo.__name__.

def decorator(foo):
    def wrapper(*args):
        foo(*args)
    wrapper.__name__ = foo.__name__
    wrapper.__doc__ = foo.__doc__
    return wrapper

@decorator
def print_greet(name):
    """Some description"""
    print('Hello, ' + name + '!')

print(print_greet.__name__)     # print_greet
print(print_greet.__doc__)      # Some description

Очевидным минусом этого решения является необходимость добавления отдельной строки для каждого атрибута, что захламляет код. Лучшей практикой в данной ситуации считается использование декоратора wraps из модуля functools. С помощью этого декоратора нужно декорировать функцию-обертку (иронично) и передать ему в качестве аргумента оборачиваемую функцию. В данном примере это будет выглядеть следующим образом:

from functools import wraps

def decorator(foo):

    @wraps(foo)
    def wrapper(*args):
        foo(*args)
    return wrapper

@decorator
def print_greet(name):
    """Some description"""
    print('Hello, ' + name + '!')

print(print_greet.__name__)     # print_greet
print(print_greet.__doc__)      # Some description

Однако следует отметить, что декоратор wraps копирует не все атрибуты, а только основные. Подробнее в документации

Несколько популярных декораторов из библиотеки functools

Далее я перечислю несколько декораторов и дам небольшое описание к каждому. Я не буду приводить примеры, но рекомендую читателю самостоятельно изучить документацию к ним, их код и поэкспериментировать с их применением.

  1. @functools.lru_cache: Кэширует результаты вызова функции с использованием алгоритма "Least Recently Used" (LRU). Это позволяет ускорить выполнение функций с дорогостоящими вычислениями.

  2. @functools.partial: Частично применяет функцию, фиксируя некоторые ее аргументы. Это позволяет создавать новые функции с уменьшенным набором параметров.

  3. @functools.cache (добавлен в Python 3.9): Кэширует результаты вызова функции с возможностью настройки параметров кэширования.

  4. @functools.deprecated (добавлен в Python 3.9): Помечает функцию как устаревшую, что предупреждает пользователей о том, что функция будет удалена в будущих версиях.

Больше декораторов с описаниями и примерами доступны по ссылке

Раздекорирование

В контексте использования декораторов порой возникает вопрос о том, можно ли вернуть функцию к ее начальному состоянию. И хорошая рекомендация была дана в другой статье о декораторах:

Вы не можете «раздекорировать» функцию. Безусловно, существуют трюки, позволяющие создать декоратор, который можно отсоединить от функции, но это плохая практика. Правильней будет запомнить, что если функция декорирована — это не отменить.

Я с этой позицией полностью согласен. Тем не менее, в комментариях к первой части этот вопрос так же обсуждался и мне кажется, что знание "как это работает" может быть полезным для более глубокого понимания вопроса.

Как уже было сказано выше, лучшей практикой при написании декораторов является использование декоратора wraps. Кроме прочего, данный декоратор добавляет к обернутой функции атрибут __wrapped__, который содержит в себе ссылку на оборачиваемую функцию.

Воспользуемся одним из предыдущих примеров, переписав его с использованием декоратора wraps

from functools import wraps

def each_pheasant(foo):
    @wraps(foo)
    def wrapper():
        print('каждый')
        foo()
        print('фазан')
    return wrapper

def hunter_is_sitting(foo):
    @wraps(foo)
    def wrapper():
        print('охотник')
        foo()
        print('сидит')
    return wrapper

def wishes_where(foo):
    @wraps(foo)
    def wrapper():
        print('желает')
        foo()
        print('где')
    return wrapper

def know():
    print('знать')

Далее, мы можем передать функцию know функции print в качестве аргумента, не вызывая. В результате мы получим информацию о самой функции, включая её местоположение в памяти.

print(know)         # <function know at 0x7fa16ccab130>

Далее, мы декорируем функцию, через присваивание, без использования синтаксического сахара, и снова получаем информацию о ней.

know = each_pheasant(hunter_is_sitting(wishes_where(know)))
print(know)         # <function know at 0x7fa16ccab2e0>

Очевидно, что адрес в памяти изменился, хотя название функции осталось прежним. Как уже было сказано выше, мы получаем адрес обертки.

У большинства объектов в python, за исключением встроенных типов данных, есть атрибут __dict__ содержащий в себе словарь с атрибутами этого объекта. Мы можем получить доступ к этому словарю, обратившись к соответствующему атрибуту.

print(know.__dict__)    # {'__wrapped__': <function know at 0x7fde13b23250>}

В данном примере, атрибут __wrapped__ был создан именно декоратором wraps. При этом другие декораторы могут создавать другие атрибуты. Например, упоминавшийся выше, lru_cache добавляет 7 атрибутов.

С помощью присваивания, мы можем получить доступ к оборачиваемой функции - развернуть функцию или раздекорировать.

function_without_first_wrapper = know.__wrapped__

После этого мы можем даже вызвать получившуюся функцию.

function_without_first_wrapper()
# охотник
# желает
# знать
# где
# сидит

Повторив ту же операцию несколько раз, мы можем получить переменную, которая будет ссылаться на оригинальную функцию.

# получаем еще одну переменную, ссылающуюся на не обернутую функцию
original_know = know
print(know)             # <function know at 0x7fa16ccab130>

# оборачиваем функцию
know = each_pheasant(hunter_is_sitting(wishes_where(know)))
print(know)             # <function know at 0x7fa16ccab2e0>

# "разворачиваем" функцию, получая ссылку на оборачиваемую функцию
function_without_first_wrapper = know.__wrapped__
function_without_second_wrapper = function_without_first_wrapper.__wrapped__
function_without_third_wrapper = function_without_second_wrapper.__wrapped__

# проверяем, что "развернутая" функция ссылается на тот же объект, что и "оригинальная"
print(function_without_third_wrapper is original_know)     # True

Как мы видим в итоге, мы получили переменную function_without_third_wrapper ссылающуюся на тот же объект в памяти, что и original_know

Весь код
from functools import wraps

def each_pheasant(foo):
    @wraps(foo)
    def wrapper():
        print('каждый')
        foo()
        print('фазан')
    return wrapper

def hunter_is_sitting(foo):
    @wraps(foo)
    def wrapper():
        print('охотник')
        foo()
        print('сидит')
    return wrapper

def wishes_where(foo):
    @wraps(foo)
    def wrapper():
        print('желает')
        foo()
        print('где')
    return wrapper

def know():
    print('знать')

# получаем еще одну переменную, ссылающуюся на не обернутую функцию
original_know = know
print(know)             # <function know at 0x7fa16ccab130>

# оборачиваем функцию
know = each_pheasant(hunter_is_sitting(wishes_where(know)))
print(know)             # <function know at 0x7fa16ccab2e0>

# "разворачиваем" функцию, получая ссылку на оборачиваемую функцию
function_without_first_wrapper = know.__wrapped__
function_without_second_wrapper = function_without_first_wrapper.__wrapped__
function_without_third_wrapper = function_without_second_wrapper.__wrapped__

# проверяем, что "развернутая" функция ссылается на тот же объект, что и "оригинальная"
print(function_without_third_wrapper is original_know)     # True

WhiteApfel в упомянутом обсуждении предложил функцию, которая принимает декорированную функцию и пытается извлечь из нее оригинальную функцию, просматривая замыкания (closures) функции и ищет функции, которые не содержат в своем замыкании других функций. Вы можете ознакомиться со скриптом, перейдя по ссылке.

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

decored_foo = decorator(foo)

Заключение

В данной статье мы рассмотрели различные аспекты использования декораторов в Python, начиная с их основ и заканчивая более сложными концепциями, такими как раздекорирование функций. Узнали, как декораторы позволяют модифицировать поведение функций, добавляя дополнительную функциональность вокруг существующих функций и как они могут использоваться для оптимизации работы приложений.

Важно помнить, что декораторы представляют собой мощный инструмент, который может значительно улучшить гибкость и эффективность вашего кода. Однако необходимо использовать их с умом и пониманием их работы, чтобы избежать потенциальных проблем.

Надеюсь, что данная статья помогла вам лучше понять концепцию декораторов и их применение в Python. Не забывайте экспериментировать с кодом и применять новые знания на практике!

Комментарии (4)


  1. anonymous
    14.03.2024 13:34

    НЛО прилетело и опубликовало эту надпись здесь


    1. vladislav_smirnov Автор
      14.03.2024 13:34

      Тут даже не в минусе печаль, а в том, что человек не счел нужным написать, что ему не понравилось


      1. Fox_exe
        14.03.2024 13:34

        Скорее всего как обычно - "Ничего нового. Всё написанное легко гуглиться или есть в доках".

        Хотя как ещё одна "шпаргалка" вполне имеет место быть.


  1. SwetlanaF
    14.03.2024 13:34
    +1

    Большое спасибо за статью! Я ее ждала)) Как раз прошла на степике два курса (поколение Пайтон продвинутый P. и Инди P. Артема Егорова). Оч. качественные курсы, всем новичкам рекомендую. Сейчас проверю, как я эту тему поняла.