Привет, Хабр! продолжаю цикл статей про python разработку.

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

Как обычно буду очень рад критике и предложениям по улучшению материала.

ПЕРЕМЕСТИТЬСЯ К ОГЛАВЛЕНИЮ

В прошлой статье мы:

  1. Рассмотрели простейшие варианты использования декораторов;

  2. Немного коснулись темы замыкания;

  3. Рассмотрели обычные декораторы;

  4. Декораторы с параметрами;

  5. Декораторы принимающие аргументы;

  6. Но обошлись без примеров использования и практической ценности.

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

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

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

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

  • Оптимизация производительности функций (кэширование);

  • Тайминг и профилирование (измерение времени выполнения функции, проверки производительности);

  • Авторизация и аутентификация (проверки по типу login_required);

  • Логирование;

  • Подавление и обработка исключений;

  • Регистрация объектов;

  • Создание объектов;

  • Добавление / изменение атрибутов объектов;

  • Валидация.

Вот несколько статей на тему:

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

Для большинства задач конечно же достаточно простейших декораторов, которые просто:

  • Выполняют часть кода до вызова функции (используя или нет её аргументы);

  • Выполняют часть кода после вызова функции (используя или нет её аргументы и результат);

  • Обрабатывают исключения;

  • Вызывают функцию в контексте какого-либо контекстного менеджера;

  • Модифицируют функцию или любой другой объект, переданный в декоратор;

  • Используют комбинацию выше указанных действий.

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

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

Мы поговорим с вами о следующих темах

Регистрация объектов

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

Приведу минимально жизнеспособный пример.

Импорты из примеров
import functools
import logging
import time
import inspect
import enum
import pickle
import base64
from dataclasses import dataclass
from functools import wraps
from typing import Any, Callable, Iterable, Mapping, TypeVar, TYPE_CHECKING
_registry = []

def register(obj: Any):
    # просто добавляем декорируемый объект в список _registry
    _registry.append(obj)
    # возвращаем декорируемый объект
    return obj


@register
class Foo:
    pass


# проверяем что _registry содержит объект класса Foo
# _registry
# [<class '__main__.Foo'>]

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

Пример из практики: добавление модели в административную панель django

from django.contrib import admin

# Для класса модели django регистрируем класс раздела в административной панели
@admin.register(ModelClass)
class ModelAdminClass(admin.ModelAdmin):
    pass
Оригинальный код декоратора
def register(*models, site=None):
    from django.contrib.admin import ModelAdmin
    from django.contrib.admin.sites import AdminSite
    from django.contrib.admin.sites import site as default_site

    def _model_admin_wrapper(admin_class):
        if not models:
            raise ValueError("At least one model must be passed to register.")

        admin_site = site or default_site

        if not isinstance(admin_site, AdminSite):
            raise ValueError("site must subclass AdminSite")

        if not issubclass(admin_class, ModelAdmin):
            raise ValueError("Wrapped class must subclass ModelAdmin.")

        admin_site.register(models, admin_class=admin_class)

        return admin_class

    return _model_admin_wrapper

Здесь мы видим декоратор admin.register, принимающий аргументы. В примере мы передаем класс модели ModelClass для которой мы хотим добавить раздел в административной панели.

Вызов admin.register(ModelClass) возвращает нам приватную функцию _model_admin_wrapper, определенную внутри функции admin.register, которая в свою очередь вызывается, принимая в качестве аргумента объект класса ModelAdminClass.

Важно отметить, что переданный в функцию admin.register позиционный аргумент ModelClass доступен в области видимости функции _model_admin_wrapper как кортеж models = (ModelClass, ) (данное поведение обусловлено особенностями распаковки аргументов функций в python).

# если бы мы сделали что-то вроде
admin_decorator = admin.register(ModelClass)

# технически мы могли бы вызвать полученный декоратор 
# неограниченное количество раз, например так

@admin_decorator
class ModelAdminClass(admin.ModelAdmin):
    pass

@admin_decorator
class OtherModelAdminClass(admin.ModelAdmin):
    pass

Результатом выполнения функции admin.register является все тот же класс ModelAdminClass, который был передан как аргумент функции _model_admin_wrapper. Таким образом при обращении к ModelAdminClass мы получим тот же класс, который мы объявили, но "под капотом" код django проведет все необходимые манипуляции для того, чтобы раздел с вашей моделью появился в административной панели.

Вложенные декораторы

Говоря о вложенности декораторов мы можем рассмотреть 3 различных варианта:

  1. Используем последовательно некоторое количество декораторов;

  2. Объявляем один декоратор внутри другого;

  3. Объявляем тот или иной объект, атрибут(ом/ами) которого является декоратор.

Самым часто используемым, конечно, является первый вариант.

Вот минимальный жизнеспособный пример.

def multiply_two(function):
    def wrapper(value):
        return function(value) * 2
    return wrapper


def minus_one(function):
    def wrapper(value):
        return function(value) - 1
    return wrapper


@minus_one
@multiply_two
def some_function(value):
    return value

# some_function(2)
# 3

О порядке выполнения и декорирования я уже рассказывал в прошлой статье, поэтому тут останавливаться не будем.

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

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

Пример того как это могло бы выглядеть.

@record_log
@rate_limit_control
@login_required
@prepare_response
@validate_result
def some_action(request):
    # do something
    return

Во втором варианте, мы объявляем один декоратор внутри другого.

Вот минимальный пример.

Для справки статья про контекстные менеджеры

def manager_decorator(manager) -> Callable:
    # принимаем функцию, которая возвращает контекстный менеджер
    @wraps(manager)
    def manager_wrapper(*manager_args, **manager_kwargs):
        # здесь мы "запоминаем" аргументы, адресованные контекстному
        # менеджеру для дальнейшего использования
        def nested_decorator(function):
            # оборачиваем декорируемую функцию
            @wraps(function)
            def wrapper(*args, **kwargs):
                # объявляем контекст
                with manager(*manager_args, **manager_kwargs) as context:
                    # передаем контекст первым аргументом в вызов декорируемой
                    # функции и возвращаем результат
                    return function(context, *args, **kwargs)

            # возвращаем целевую функцию, которая нас и интересует
            return wrapper

        # возвращаем вложенный декоратор
        return nested_decorator

    # возвращаем уже известный нам декоратор, принимающий аргументы
    return manager_wrapper

Пример использования:

# НЕ ПОВТОРЯЙТЕ ЭТО ДОМА!!!
# Тут мы декорируем функцию объемлющим декоратором
@manager_decorator
def with_open(*args, **kwargs):
    # do something
    return open(*args, **kwargs)


# Вызывая with_open передаем атрибуты, которые далее 
# будут переданы в open при объявлении контекста
# и декорируем функцию вложенным декоратором
@with_open('some.txt', 'r')
def print_text(file):
    print(file.readlines())


# print_text()
# ['Кокой-то текст из файла']

Данный пример мягко говоря является "экзотическим" я бы рекомендовал избегать подобного рода конструкций. Хоть на моей практике и были примеры, когда такого вида решение казалось элегантнее альтернатив, уверяю вас, почти в 100% случаев это будет избыточностью, которую в добавок сложнее поддерживать.

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

class Foo:
    _attr = 1

    # Объявляем свойство, которое будет возвращать значение атрибута _attr
    @property
    def attr(self):
        return self._attr

    # Объявляем функцию, которая будет изменять значение атрибута _attr
    @attr.setter
    def attr(self, value):
        self._attr = value

# Проверяем, что мы действительно получаем значение атрибута _attr
# foo = Foo()
# foo.attr
# 1

# Присваеваем значение 2 атрибуту _attr
# foo.attr = 2

# Проверяем, корректно ли изменилось значение
# foo._attr
# 2
# foo.attr
# 2

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

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

class LoggerDecorator:
    _logger: logging.Logger
    _before_text: str
    _after_text: str

    def __init__(
        self,
        logger: logging.Logger,
        before_text: str = "Время перед выполнением: {}",
        after_text: str = "Время после выполнения: {}",
    ):
        # Записываем переданные аргументы в атрибуты класса
        # для дальнейшего использования в декораторах
        self._logger = logger
        self._before_text = before_text
        self._after_text = after_text

    # Объявляем метод, который будет записывать лог перед выполнением функции
    def before(self, function: Callable):
        @wraps(function)
        def wrapper(*args, **kwargs):
            # Записываем лог
            self._logger.debug(self._before_text.format(time.time()))
            # Возвращаем результат функции
            return function(*args, **kwargs)
        return wrapper

    # Объявляем метод, который будет записывать лог после выполнением функции
    def after(self, function: Callable):
        @wraps(function)
        def wrapper(*args, **kwargs):
            # Записываем результат функции
            result = function(*args, **kwargs)
            # Записываем лог
            self._logger.debug(self._after_text.format(time.time()))
            # Возвращаем результат функции
            return result
        return wrapper

# Получаем текущий логгер
_logger = logging.getLogger(__name__)
# Устанавливаем уровень, начиная с которого логгер будет регистрировать записи
_logger.setLevel(logging.DEBUG)
# Создаем инстанс класса с нашими декораторами
logger_decorator = LoggerDecorator(_logger)

Пример использования:

# Перед выполнением этой функции будет записываться лог
@logger_decorator.before
def with_log_before():
    print("Выполнение функции")

# with_log_before()
# DEBUG:__main__:Время перед выполнением: 1747424614.7285378
# Выполнение функции

# После выполнением этой функции будет записываться лог
@logger_decorator.after
def with_log_after():
    print("Выполнение функции")

# with_log_after()
# Выполнение функции
# DEBUG:__main__:Время после выполнения: 1747424628.5622175

# И перед и после выполнением этой функции будет записываться лог
@logger_decorator.before
@logger_decorator.after
def with_log_before_and_after():
    print("Выполнение функции", time.time())

# with_log_before_and_after()
# Выполнение функции 1747425047.5508897
# DEBUG:__main__:Время перед выполнением: 1747425047.5508294
# DEBUG:__main__:Время после выполнения: 1747425047.5508966

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

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

Декорирование классов

Сразу приведу классический пример:

@dataclass
class FooDTO:
    first_field: str
    second_field: int


# dto = FooDTO(first_field="foo", second_field=1)
# dto.first_field
# 'foo'
# dto.second_field
# 1

Думаю, все согласятся, что это как минимум удобно.

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

Написание декораторов классов на практике линейного разработчика чаще всего сводится к тому, что класс либо необходимо где-то зарегистрировать, либо нужно реализовать singleton, либо необходимо определить или переопределить несколько атрибутов.

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

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

Минимальный пример:

def set_class_attr(name: str, value: Any):
    # Данные декоратор будет присваивать значения атрибутам объекта класса
    def wrapper(cls):
        # Просто определяем переданный атрибут класса
        setattr(cls, name, value)
        # Возвращаем исходный класс.
        # Тут важно понимать, что хоть мы и модифицируем класс "на месте", 
        # значению атрибута модуля с именем, соответствующем имени класса,
        # будет присвоено значение равное результату вызова wrapper
        return cls
    return wrapper


@set_class_attr("first_field", 1)
@set_class_attr("second_field", 2)
class Foo:
    pass


# Проверяем, что класс имеет присвоенные в декораторах атрибуты
# Foo.first_field
# 1
# Foo.second_field
# 2

Часто бывает необходимо модифицировать не сам класс а его экземпляры, для этого, можно повесить декоратор на методы __new__ или __init__ или же полностью их заменить.

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

Для справки статья про "магические методы"

Пример декоратора, модифицирующего экземпляры класса:

def set_instance_attr(name: str, value_factory: Callable):
    # Данный декоратор, будет присваивать значения атрибутам экземпляров класса
    def wrapper(cls):
        # Просто определяем переданный атрибут класса

        # Записываем оригинальный __init__ метод
        __old__init__ = cls.__init__

        # Определяем новый __init__ метод
        def __new_init__(self, *args, **kwargs):
            # Просто присваиваем значение переданному атрибуту
            setattr(self, name, value_factory())
            # Вызываем оригинальный __init__ метод
            __old__init__(self, *args, **kwargs)

        # Подменяем __init__ метод класса
        cls.__init__ = __new_init__
        # Возвращаем исходный класс
        return cls
    return wrapper

Пример использования:

# Данный декоратор добавит поле field со значением [] для каждого 
# экземпляра класса, сам класс этим атрибутом обладать не будет
@set_instance_attr("field", list)
class Foo:
    pass

# hasattr(Foo, "field")
# False

# Создаем экземпляры класса
first_instance = Foo()
second_instance = Foo()

# Проверяем, что значения поля в каждом экземпляре не ссылаются 
# на один и тот же список
first_instance.field.append(1)
second_instance.field.append(2)

# first_instance.field
# [1]
# second_instance.field
# [2]

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

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

Особенности декорирования методов классов

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

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

Реализуем описанный выше декоратор.

def decorator(function):
    @wraps(function)
    def wrapper(*args, **kwargs):
        print("Второй позиционный аргумент в декораторе", args[1])
        return  function(*args, **kwargs)
    return wrapper

Теперь напишем пример объектов, с которыми нам предстоит работать.

@decorator
def foo(*args, **kwargs):
    print("Аргументы функции", args, kwargs)


class FooClass:
    @decorator
    def method(self, *args, **kwargs):
        print("Аргументы функции", self, args, kwargs)

    @classmethod
    @decorator
    def class_method(cls, *args, **kwargs):
        print("Аргументы функции", cls, args, kwargs)

    @staticmethod
    @decorator
    def static_method(*args, **kwargs):
        print("Аргументы функции", args, kwargs)

foo_instance = FooClass()

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

foo(1, 2)
# Второй позиционный аргумент в декораторе 2
# Аргументы функции (1, 2) {}
# Тут мы имеем ожидаемое поведение

FooClass.class_method(1, 2)
# Второй позиционный аргумент в декораторе 1
# Аргументы функции <class '__main__.FooClass'> (1, 2) {}
# С методом класса это не работает

FooClass.static_method(1, 2)
# Второй позиционный аргумент в декораторе 2
# Аргументы функции (1, 2) {}
# Статический метод по факту функция, поэтому проблем с ним нет

foo_instance.method(1, 2)
# Второй позиционный аргумент в декораторе 1
# Аргументы функции <__main__.FooClass object at 0x72efab2423c0> (1, 2) {}
# С методом экземпляра это также не работает

Есть несколько вариантов того, как мы можем решить эту проблему.

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

def decorator(
    _function: Callable | None = None,
    /,
    class_method: bool = False
):
    def _decorator(function):
        @wraps(function)
        def wrapper(*args, **kwargs):
            # Если указано, что декорируется метод класса,
            # нас интересует индекс 2 иначе 1
            second_arg_index = 2 if class_method else 1
            print(
              "Второй позиционный аргумент в декораторе", 
              args[second_arg_index]
            )
            return  function(*args, **kwargs)
        return wrapper

    # Если decorator вызван без параметров, то сразу декорируем функцию
    if _function:
        return _decorator(_function)
    return _decorator

Проверяем, что теперь все работает корректно.

class FooClass:
    @decorator(class_method=True)
    def method(self, *args, **kwargs):
        print("Аргументы функции", self, args, kwargs)

    @classmethod
    @decorator(class_method=True)
    def class_method(cls, *args, **kwargs):
        print("Аргументы функции", cls, args, kwargs)

foo_instance = FooClass()

# Проверяем, что все верно
FooClass.class_method(1, 2)
# Второй позиционный аргумент в декораторе 2
# Аргументы функции <class '__main__.FooClass'> (1, 2) {}
# Получаем ожидаемое значение

foo_instance.method(1, 2)
# Второй позиционный аргумент в декораторе 2
# Аргументы функции <__main__.FooClass object at 0x72efab242660> (1, 2) {}
# Получаем ожидаемое значение

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

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

Конечно можно использовать модуль inspect или как-то иначе проверить имя первого аргумента, например искать self, cls, mcs или что-то еще, но это не гарантирует нам ожидаемого поведения в 100% случаев.

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

Декораторы как классы

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

Если кратко, то дескрипторы, это классы, у которых определены один или несколько "магических методов" из следующего списка __get__, __set__, __delete__, в какой-то мере сюда же можно отнести метод __set_name__.

Такие классы переопределяют поведение получения, присвоения или удаления атрибутов экземпляра класса, значением которых является класс дескриптора.

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

Важно отметить, что если вы работаете с inspect или проверяете типы вызываемых атрибутов, то текущий класс необходимо доработать для имитации поведения обычных функций (например, для Pydantic вам потребуется добавить класс дескриптора в ignored_types), помимо этого стоит учесть, что многие инструменты используют inspect.unwrap, это тоже стоит учесть при доработке. Также, в зависимости от способа получения атрибутов, может понадобится доработка, если вы используете методы экземпляров классов напрямую из класса или через super.

class Decorator:
    _function: Callable

    def __init__(self, function: Callable):
        # Записываем декорируемую функцию в атрибут класса дескриптора
        self._function = function

    def __call__(self, *args, __bind_self: bool = False, **kwargs):
        # Если был передан __bind_self со значением True, то мы имеем
        # дело с методом инстанса класса
        # Если self._is_method(args[0]), то мы имеем дело с classmethod
        # Если указано, что декорируется метод класса, 
        # нас интересует индекс 2 иначе 1
        is_bind = __bind_self or args and self._is_method(args[0])
        second_arg_index = (2 if is_bind else 1)
        print(
          "Второй позиционный аргумент в декораторе",
          args[second_arg_index]
        )
        # Возвращаем результат выполнения функции
        return self._function(*args, **kwargs)

    def __get__(self, instance, owner):
        # При обращении к декорируемым методам инстанса класса, 
        # будет вызываться данная функция
        # functools.partial принимает функцию, которую нужно 
        # будет вызвать и часть аргументов, которые ей будут переданы
        # на данном этапе сама функция вызвана не будет
        return functools.partial(
            self.__call__,
            instance, 
            # аргумент _Decorator__bind_self имеет такой вид
            # из-за особенности реализации передачи приватных аргументов
            _Decorator__bind_self=instance is not None
        )

    def _is_method(self, arg):
        # Проверяем имеет ли аргумент атрибут с именем декорируемой функции
        attr = getattr(arg, self._function.__name__, None)
        # Проверяем являются ли функции в объекте и декорируемая
        # функция одним и тем же объектом
        return getattr(attr, "_function", None) is self._function

Заново определяем объекты, с которыми будем работать.

@Decorator
def foo(*args, **kwargs):
    print("Аргументы функции", args, kwargs)


class FooClass:
    @Decorator
    def method(self, *args, **kwargs):
        print("Аргументы функции", self, args, kwargs)

    @classmethod
    @Decorator
    def class_method(cls, *args, **kwargs):
        print("Аргументы функции", cls, args, kwargs)

    @staticmethod
    @Decorator
    def static_method(*args, **kwargs):
        print("Аргументы функции", args, kwargs)

foo_instance = FooClass()

Проверяем, во всех ли случаях мы получаем ожидаемый результат.

foo(1, 2)
# Второй позиционный аргумент в декораторе 2
# Аргументы функции (1, 2) {}
# Получаем ожидаемое значение

FooClass.class_method(1, 2)
# Второй позиционный аргумент в декораторе 2
# Аргументы функции <class '__main__.FooClass'> (1, 2) {}
# Получаем ожидаемое значение

FooClass.static_method(1, 2)
# Второй позиционный аргумент в декораторе 2
# Аргументы функции (1, 2) {}
# Получаем ожидаемое значение

foo_instance.method(1, 2)
# Второй позиционный аргумент в декораторе 2
# Аргументы функции <__main__.FooClass object at 0x703b09a63b60> (1, 2) {}
# Получаем ожидаемое значение

Наследование классов декораторов

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

Немного перепишем структуру класса.

# Добавим enum для определения того, что именно было прокинуто в вызов функции
class _BindTypes(enum.Enum):
    SELF = enum.auto()
    CLS = enum.auto()


class BaseDecorator:
    _function: Callable

    def __init__(self, function: Callable):
        self._function = function

    def __call__(
      self, 
      *args,
      __bind_type: _BindTypes | None = None,
      **kwargs
    ):
        # Если не был передан инстанс класса, то проверяем, 
        # является ли функция методом класса
        if not __bind_type and args and self._is_method(args[0]):
            __bind_type = _BindTypes.CLS

        # Если передан инстанс класса и определена реализация для 
        # этого типа вызова, вызываем соответствующий метод
        if __bind_type == _BindTypes.SELF and hasattr(self, "_self_bind_call"):
            return self._self_bind_call(*args, **kwargs)
        # Если передан объект класса и определена реализация для
        # этого типа вызова, вызываем соответствующий метод
        if __bind_type == _BindTypes.CLS and hasattr(self, "_cls_bind_call"):
            return self._cls_bind_call(*args, **kwargs)

        # Для кейсов, не имеющих определенных реализаций, вызываем общий метод
        return self._call(*args, **kwargs)

    def __get__(self, instance, owner):
        bind_type = _BindTypes.SELF if instance is not None else None
        return functools.partial(
            self.__call__,
            instance, 
            _BaseDecorator__bind_type=bind_type
        )

    def _is_method(self, arg):
        attr = getattr(arg, self._function.__name__, None)
        return getattr(attr, "_function", None) is self._function

    def _call(self, *args, **kwargs):
        """Общий метод для вызова декорируемой функции"""
        raise NotImplemented

    # Объявляем функции только на этапе проверки типов,
    # чтобы не определять реализации без необходимости
    if TYPE_CHECKING:
        def _cls_bind_call(self, owner, *args, **kwargs):
            """Метод для работы с вызовом методов класса"""

        def _self_bind_call(self, instance, *args, **kwargs):
            """Метод для работы с вызовом методов инстанса класса"""

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

Пример использования:

class FooDecorator(BaseDecorator):
    def _cls_bind_call(self, owner, *args, **kwargs):
        print("Вызов classmethod")
        return self._function(owner, *args, **kwargs)

    def _self_bind_call(self, instance, *args, **kwargs):
        print("Вызов метода инстанса класса")
        return self._function(instance, *args, **kwargs)

    def _call(self, *args, **kwargs):
        print("Вызов остальных функций")
        return self._function(*args, **kwargs)

Создадим тестовые объекты.

@FooDecorator
def foo(*args, **kwargs):
    print(args, kwargs)


class Foo:
    @staticmethod
    @FooDecorator
    def static_method(*args, **kwargs):
        print(args, kwargs)

    @classmethod
    @FooDecorator
    def class_method(cls, *args, **kwargs):
        print(args, kwargs)

    @FooDecorator
    def method(self, *args, **kwargs):
        print(args, kwargs)

Проверяем, что все работает.

foo(1)
# Вызов остальных функций
# (1,) {}
# Получаем ожидаемое значение

Foo.static_method(1)
# Вызов остальных функций
# (1,) {}
# Получаем ожидаемое значение

Foo.class_method(1)
# Вызов classmethod
# (1,) {}
# Получаем ожидаемое значение

foo_instance.method(1)
# Вызов метода экземпляра класса
# (1,) {}
# Получаем ожидаемое значение

Работа с сигнатурой функции

Чаще всего если вам нужно взаимодействовать с сигнатурой функции, вы решаете одну из следующих задач:

  1. Валидация типов

  2. Прокидывание аргументов

  3. Кэширование результата на основе входных параметров

Что касается первой задачи, в виду сложности самостоятельной реализации рекомендую просто использовать уже написанные и проверенные инструменты, например pydantic.validate_call.

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

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

Для справки документация библиотеки inspect.

Код утилит, которые мы будем использовать далее
def get_params_values(
    function: Callable, 
    *args, 
    params: Iterable[str],
    **kwargs
):
    """Данная функция возвращает значения параметров, 
    имена которых переданы в параметре params в зависимости от 
    сигнатуры функции и аргументов, передаваемых в ее вызов

    Args:
        function: функция с сигнатурой которой мы будем работать
        *args: переданные для вызова функции позиционные аргументы
        params: параметры, значения которых мы хотим получить
        **kwargs: переданные для вызова функции именованные аргументы

    Returns:
        list: список значений аргументов, соответствующих порядку
              ключей, переданных в параметр params

    """
    # Контейнер для значений запрашиваемых параметров
    result = [] 
    # Получаем оригинальную функцию
    function = inspect.unwrap(function) 
    # Получаем полную спецификацию сигнатуры функции
    spec = inspect.getfullargspec(function)

    # Итерируемся по запрашиваемым параметрам
    for param in params:
        # Если параметр передан в kwargs, добавляем к результату
        if param in kwargs: 
            result.append(kwargs[param])
        # Если в сигнатуре есть значения по умолчанию для
        # именованных аргументов и есть значение для 
        # запрашиваемого параметра, добавляем к результату
        elif spec.kwonlydefaults and param in spec.kwonlydefaults:
            result.append(spec.kwonlydefaults[param])
        else:
            # Получаем список позиционных аргументов, не переданных 
            # как именованные
            func_args = [
                arg for arg in spec.args 
                if arg not in kwargs
            ]
            # Получаем индекс интересующего нас параметра
            argument_index = func_args.index(param)
            # Если количество переданных позиционных аргументов
            # больше индекса, то мы можем добавить 
            # значение в результат
            if len(args) > argument_index:
                result.append(args[argument_index])
            # В противном случае проверяем значения по умолчанию
            else:
                # Получаем индекс интересующего нас параметра
                # в сигнатуре
                func_argument_index = spec.args.index(param)
                # Здесь мы получаем значение по умолчанию и 
                # добавляем его к результату
                defaults_index = (
                    func_argument_index
                    - len(spec.args)
                    + len(spec.defaults)
                )
                result.append(spec.defaults[defaults_index])

    return tuple(result)


def get_params_with_values(
    function: Callable, 
    *args, 
    params: Iterable[str],
    **kwargs
):
    """В данной функции мы возвращаем словарь, где ключами являются 
    запрошенные параметры, а значениями соответственно их значения

    Args:
        function: функция с сигнатурой которой мы будем работать
        *args: переданные для вызова функции позиционные аргументы
        params: параметры, значения которых мы хотим получить
        **kwargs: переданные для вызова функции именованные аргументы

    Returns:
        dict: словарь где ключами являются запрошенные параметры,
              а значениями соответственно их значения

    """
    # Получаем список значений параметров
    values = get_params_values(
        function, *args, params=params, **kwargs
    )
    # Упаковываем пары ключ значение в словарь
    return {param: value for param, value in zip(params, values)}


def replace_params_values(
    replaces: Mapping[str, Any],
    function: Callable,
    *args,
    none_only=False, 
    **kwargs
):
    """Данная функция реализует функционал принудительной замены
    значений во время вызова функции

    Args:
        replaces: словарь для замены значений где ключами являются
                  имена параметров сигнатуры функции
        function: функция с которой мы будем работать
        *args: переданные для вызова функции позиционные аргументы
        none_only: заменять только если значение равно None
        **kwargs: переданные для вызова функции именованные аргументы

    Returns: модифицированные значения args и kwargs

    """
    # Получаем полную спецификацию сигнатуры функции
    spec = inspect.getfullargspec(function)
    args = list(args)
    # Итерируемся по ключам и значениям которые нам
    # необходимо заменить
    for key, value in replaces.items():
        # Часть реализации когда мы подменяем только None
        if none_only:
            # Если значение, переданное в kwargs не равно None,
            # переходим к следующей итерации
            if key in kwargs and kwargs[key] is not None:
                continue
            # Проверяем сигнатуру функции
            if key in spec.args:
                # Если позиционный аргумент передан и его значение
                # не равно None, переходим к следующей итерации
                argument_index = spec.args.index(key)
                if (
                    argument_index < len(args)
                    and args[argument_index] is not None
                ):
                    continue
            # Обратите внимание, что тут не обработан случай 
            # с дефолтными значениями, его стоит добавить, 
            # но у меня не было в  этом необходимости.
            # Tакже, вам может потребоваться проверять например не, 
            # None, а какое-то иное значение, думаю добавлением
            # еще одного параметра, эта потребность без лишних
            # трудностей закрывается

        # Проверяем наличие параметра в переданных или дефолтных 
        # значениях именованных аргументов
        if (
            key in kwargs 
            or spec.kwonlydefaults and key in spec.kwonlydefaults
        ):
            # заменяем значение
            kwargs[key] = value
        # Если значения нет в позиционных аргументах, 
        # то передаем его в именованных
        # Тут вам возможно потребуется дополнительный параметр,
        # который опционально будет игнорировать параметры, не
        # присутствующие в сигнатуре функции
        elif key not in spec.args:
            # заменяем значение
            kwargs[key] = value
        else:
            # получаем список позиционных аргументов функции
            argument_index = spec.args.index(key)
            # если аргумент был передан, то заменяем его в списке 
            # позиционных аргументов
            if argument_index < len(args):
                args[argument_index] = value
            # в противном случае добавляем его в именованные 
            # аргументы. Тут также вам может понадобиться
            # дополнительная проверка на наличие значения 
            # по умолчанию для позиционных аргументов, 
            # но у меня такой потребности не было
            else:
                kwargs[key] = value

    # возвращаем кортеж позиционных аргументов и словарь именованных
    return tuple(args), kwargs

Теперь, когда у нас есть все необходимое, давайте рассмотрим несколько прикладных примеров.

Если вы работаете с веб фреймворками у вас достаточно часто может стоять задача получения каких либо значений из контекста запроса, например получение пользователя, его id или самого объекта запроса, в большинстве случаев задача решается посредством использования contextvars.ContextVar.

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

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

Давайте напишем декоратор, который будет реализовывать такую логику.

Для справки ссылка на документацию fastapi.Depends, упомянутую ниже

T = TypeVar("T")

# Функция, которая будет аннотировать наш класс, как любой другой,
# переданный входным параметром
def mixin_for(_: T) -> T:
    return object

# Класс, который мы будем использовать в качестве значения по умолчанию,
# если хотим вставить значение из контекста
class CURRENT(mixin_for(Any)): ...


# Для примера напишем функцию, которая будет возвращать фэйковые данные
def get_current_user_id():
    return 1

# Маппинг параметров, значения которых мы будем подменять результатом
# выполнения функции, являющейся значением
# Вы также можете организовать логику так, чтобы не зависеть от имени 
# параметра, основываться только на аннотации
# Такого рода реализацию можно подглядеть например в fastapi.Depends
_CURRENT_MAPPING = {"user_id": get_current_user_id}


def bind_current(target: Callable = None, **mapping: str) -> Callable:
    """Вставляет значения из контекста, если дефолтным значением
    параметра является класс CURRENT

    Args:
        target: целевая функция
        **mapping: словарь, где ключами являются ключи словаря
                  _CURRENT_MAPPING, а значениями имена параметров 
                  в сигнатуре функции

    Returns:
        Callable: декорированная функция или декоратор

    """
    def decorator(function: Callable) -> Callable:
        @wraps(function)
        def wrapper(*args, **kwargs):
            # получаем параметры сигнатуры декорируемой функции
            parameters = inspect.signature(function).parameters
            # итерируемся по ключам и значением словаря, с параметрами, 
            # которые можно получить из контекста
            for key, value in _CURRENT_MAPPING.items():
                # если ключ есть в маппинге, переопределяем ключ
                if key in mapping:
                    key = mapping[key]

                # проверяем наличие ключа в параметрах функции
                if not key in parameters:
                    continue
                
                # получаем значение параметра
                parameter_value = get_params_values(
                    function,
                    *args,
                    params=[key],
                    **kwargs
                )[0] 
                
                # проверяем, что значением параметра является CURRENT
                if parameter_value is not CURRENT:
                    continue
                    
                # заменяем значение результатом выполнения функции
                # из словаря _CURRENT_MAPPING
                args, kwargs = replace_params_values(
                    {key: value()}, 
                    function,
                    *args,
                    **kwargs
                )

            # вызываем функцию с модифицированными аргументами
            return function(*args, **kwargs)

        return wrapper

    # если декоратор вызывался с параметрами, возвращаем декоратор
    if target is None:
        return decorator
    # иначе возвращаем декорированную функцию
    return decorator(target)

Пример использования:

@bind_current
def process_user_id_from_request(user_id: int = CURRENT):
    print(user_id)

# Попробуем передать значение id
process_user_id_from_request(2)
# process_user_id_from_request(2)
# 2
# Получаем ожидаемое поведение

# Попробуем не передавать значение id
# process_user_id_from_request()
# 1
# Ожидаемо получаем результат выполнения функции get_current_user_id

Теперь давайте рассмотрим третий кейс. Попробуем использовать кэширование результатов на основе переданных параметров.

Для простоты в примере в качестве хранилища кэша я буду использовать словарь, и не буду брать в расчет срок его хранения, но на практике, конечно, стоит использовать redis или другие аналоги, и позаботиться о своевременной его очистке.

Напишем декоратор, который удовлетворяет описанным выше условиям.

_cache = {}

def _key_func(*args: Any, **kwargs: Any) -> str:
    # Тут может быть любая функция, которая создает строковый ключ
    # на основании переданных аргументов
    key = args
    if kwargs:
        key += tuple(sorted(kwargs.items()))
    return base64.b64encode(pickle.dumps(key)).decode("utf-8")

  
def cache_decorator(*params: str) -> Callable:
    # Декоратор, который будет записывать кэш на основе переданных 
    # значений параметров, указанных в params
    def decorator(function: Callable) -> Callable:
        # Функция для получения ключа из параметров
        def _get_key(*args, **kwargs):
            # Тут мы получаем словарь со значениями параметров, 
            # указанных в params
            args_values = get_params_with_values(
                inspect.unwrap(function), 
                *args,
                params=params,
                **kwargs
            )
            # Возвращаем ключ, созданный на основании переданных параметров
            return str(_key_func(**args_values))

        # Функция для получения значений из кэша
        def _get_cached(*args, **kwargs):
            # Получаем ключ
            key = _get_key(*args, **kwargs)
            # Проверяем наличие данных в кэше
            if key in _cache:
                print("Получено из кэша")
                # Получаем результат из кэша и преобразуем в объекты python
                return pickle.loads(_cache.get(key))
            return None

        # Функция для сохранения результата в кэш
        def _save_result(key, result):
            # Преобразуем результат в байты
            data = pickle.dumps(result)
            # Записываем в кэш результат
            _cache[key] = data

        @wraps(function)
        def wrapper(*args, **kwargs):
            # Пробуем получить данные из кэша
            if cached := _get_cached(*args, **kwargs):
                return cached
            # Если данных нет в кэше вызываем функцию
            result = function(*args, **kwargs)
            print("Произошел вызов декорированной функции")
            # Сохраняем результат в кэш
            _save_result(_get_key(*args, **kwargs), result)
            # Возвращаем результат
            return result

        return wrapper
    return decorator

Пример использования:

@cache_decorator("a", "b")
def multiplication(a: int | float, b: int | float, other_param: bool = False):
    return a * b

# Попробуем выполнить функцию с одними и теми же параметрами несколько раз
# Первый вызов
# multiplication(2, 3)
# Произошел вызов декорированной функции
# 6

# Второй вызов
# multiplication(2, 3)
# Получено из кэша
# 6

# Теперь проверим, что параметр other_param берет значение из 
# кэша по тому же самому ключу
# multiplication(2, 3, True)
# Получено из кэша
# 6

# А теперь попробуем заменить значение a
# multiplication(3, 3)
# Произошел вызов декорированной функции
# 9
# Получаем ожидаемое поведение

Заключение

В данной статье мы глубже погрузились в тему декораторов, разобрали где и зачем их можно и стоит использовать и рассмотрели несколько прикладных примеров.

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

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


  1. vadimr
    18.05.2025 16:04

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

    @decorator
    def f ()
      ...

    буквально то же, что

    def f ()
      ...
    f = decorator(f)

    Зачем в Питоне ввели отдельный синтаксис для этого частного случая, не очень понятно.