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

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

Определение статический метод или нет по сигнатуре, а не по декоратору
Определение статический метод или нет по сигнатуре, а не по декоратору

Базовое определение и простые примеры

Disclamer: этот раздел небольшая церемония с базовым раскрытием темы. Если вы без помощи гугла можете написать декоратор, добавляющий подсчёт количества вызовов функции, гасящий исключения или ещё каким либо образом дополняющий её работу - можете смело пропускать этот раздел. Но и совсем новичкам придётся самим узнать, что такое wraps. Ну или забить на строчки с его использованием.

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

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

from typing import Optional

from fastapi import FastAPI

app = FastAPI()


@app.get("/")
def read_root():
    return {"Hello": "World"}


@app.get("/items/{item_id}")
def read_item(item_id: int, q: Optional[str] = None):
    return {"item_id": item_id, "q": q}

app.get в примере выше регистрирует функции и связывает их с определённым путём, при этом никак не меняя их реализацию.

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

import logging
from functools import wraps
from typing import Callable


def suppress(f: Callable):
    @wraps(f)
    def inner(*args, **kwargs):
        try:
            return f(*args, **kwargs)
        except Exception as e:
            logging.exception("Something went wrong")

    return inner


def f1():
    1 / 0


@suppress
def f2(x):
    x / 0


f2(2)  # -> первое исключение будет залогированно и программа продолжит работать
f1()   # -> а вот здесь программа завершится с ошибкой
print("I will never be printed")

@suppress - по сути синтаксический сахар, он появился только в python 2.4 в далёком 2003 году, что, однако, не мешало декораторам существовать в языке. Даже classmethod вполне присутствовал раньше. Интерпретатор в данном месте выполняет примерно следующий код:

f2 = suppress(f2)

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

Следующий вариант:

def suppress(f: Callable, ex=Exception):
   ...


@suppress(ZeroDivisionError)
def f2(x):
    x / 0

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

def suppress(ex=Exception):
    def dec(f):
        @wraps(f)
        def inner(*args, **kwargs):
            try:
                return f(*args, **kwargs)
            except ex as e:
                logging.exception("Something went wrong")
        return inner

    return dec

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

Реализация декоратора с параметрами в виде класса

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

class suppress:
    def __init__(self, ex=Exception):
        self._ex = ex
    
    def __call__(self, f: Callable):
        @wraps(f)
        def inner(*args, **kwargs):
            try:
                return f(*args, **kwargs)
            except self._ex:
                logging.exception("Something went wrong")
        return inner

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

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

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

from typing import Type


def auto_str(c: Type):
    def str(self):
        variables = [f"{k}={v}" for k, v in vars(self).items()]
        return f"{c.__name__}({', '.join(variables)})"
    c.__str__ = str

    return c


class Sample1:
    def __init__(self, a, b):
        self.a = a
        self.b = b


@auto_str
class Sample2(Sample1):
    def __init__(self, a, b, c):
        super().__init__(a, b)
        self.c = c


print(str(Sample2(1, 2, 3)))  # -> Sample2(a=1, b=2, c=3)

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

Semantic Self

Меня всегда немного удивляло, что python, заставляя указывать self параметр в сигнатуре каждого метода, никак не использует это своё требование и не делает метод автоматически статическим, если аргументов нет, и не возвращает classmethod, если первый параметр называется cls. Но с помощью декоратора можно исправить данный "недостаток".

import inspect
from typing import Type, Callable


def semantic_self(cls: Type):
    for name, kind, cls, obj in inspect.classify_class_attrs(cls):
        # с помощью модуля inspect возможно пройтись по всем
        # атрибутам класса и определить метод ли это
        if kind == "method" and not _is_special_name(name):
            setattr(cls, name, _get_method_wrapper(obj))
    return cls


def _is_special_name(name: str) -> bool:
    # специальные методы трогать не будем
    return name.startswith("__") and name.endswith("__")


def _get_method_wrapper(obj: Callable):
    # определяем есть ли у метода аргументы, и, в зависимости от имени
    # первого аргумента, меняем его
    args = inspect.getargs(obj.__code__).args
    if args:
        if args[0] == "self":
            return obj
        elif args[0] == "cls":
            return classmethod(obj)
    return staticmethod(obj)

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

@semantic_self
class Sample:
    def obj_method(self, param):
        print(f"object {self} {param}")

    def cls_method(cls, param):
        print(f"class {cls} {param}")

    def static_method(param):
        print(f"static {param}")

Реализация декораторов из стандартной библиотеки

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

    abstracts = set()
    # Check the existing abstract methods of the parents, keep only the ones
    # that are not implemented.
    for scls in cls.__bases__:
        for name in getattr(scls, '__abstractmethods__', ()):
            value = getattr(cls, name, None)
            if getattr(value, "__isabstractmethod__", False):
                abstracts.add(name)
    # Also add any other newly added abstract methods.
    for name, value in cls.__dict__.items():
        if getattr(value, "__isabstractmethod__", False):
            abstracts.add(name)
    cls.__abstractmethods__ = frozenset(abstracts)
    return cls

Ещё интереснее реализован staticmethod, потому что по сути он не делает ничего. Статический метод - это функция определённая в некотором пространнстве имён, этот декоратор возвращает саму функцию. А вот обычные методы, не помеченные таким декоратором преобразуются в boundmethod, это можно видеть на КДПВ.

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

static PyObject *
sm_descr_get(PyObject *self, PyObject *obj, PyObject *type)
{
    staticmethod *sm = (staticmethod *)self;

    if (sm->sm_callable == NULL) {
        PyErr_SetString(PyExc_RuntimeError,
                        "uninitialized staticmethod object");
        return NULL;
    }
    Py_INCREF(sm->sm_callable);
    return sm->sm_callable;  // ф-ция возвращается без изменений
}

А вот так, обычного:

static PyObject *
instancemethod_descr_get(PyObject *descr, PyObject *obj, PyObject *type) {
    PyObject *func = PyInstanceMethod_GET_FUNCTION(descr);
    if (obj == NULL) {
        Py_INCREF(func);
        return func;
    }
    else
        return PyMethod_New(func, obj);  // метод ассоциируется с объектом
}

В случае класс методов, всё тоже довольно предсказуемо:

static PyObject *
cm_descr_get(PyObject *self, PyObject *obj, PyObject *type)
{
    classmethod *cm = (classmethod *)self;

    if (cm->cm_callable == NULL) {
        PyErr_SetString(PyExc_RuntimeError,
                        "uninitialized classmethod object");
        return NULL;
    }
    if (type == NULL)
        type = (PyObject *)(Py_TYPE(obj));
    if (Py_TYPE(cm->cm_callable)->tp_descr_get != NULL) {
        return Py_TYPE(cm->cm_callable)->tp_descr_get(cm->cm_callable, type,
                                                      type);
    }
    // метод ассоциируется с типом объекта
    return PyMethod_New(cm->cm_callable, type);
}

Примеры декораторов


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

@contract(a='int,>0', b='list[N],N>0', returns='list[N]')
def my_function(a, b):
    ...

Есть библиотеки, реализующие некоторые элементы функционального программирования: например отделение чистого кода от side эффектов, преобразование функции, генерирующей исключения. В функцию, возвращающую тип Option/Maybe:

@safe
def _make_request(user_id: int) -> requests.Response:
    # TODO: we are not yet done with this example, read more about `IO`:
    response = requests.get('/api/users/{0}'.format(user_id))
    response.raise_for_status()
    return response

Или алгоритм от способа его выполнения, позволяет выбирать, хотите ли вы выполнять его синхронно или асинхронно:

from effect import sync_perform, sync_performer, Effect, TypeDispatcher

class ReadLine(object):
    def __init__(self, prompt):
        self.prompt = prompt

def get_user_name():
    return Effect(ReadLine("Enter a candy> "))

@sync_performer
def perform_read_line(dispatcher, readline):
    return raw_input(readline.prompt)

def main():
    effect = get_user_name()
    effect = effect.on(
        success=lambda result: print("I like {} too!".format(result)),
        error=lambda e: print("sorry, there was an error. {}".format(e)))

    dispatcher = TypeDispatcher({ReadLine: perform_read_line})
    sync_perform(dispatcher, effect)

if __name__ == '__main__':
    main()

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


  1. baldr
    05.11.2021 13:39

    Спасибо за статью.

    Небольшое замечание - имена параметров "self" и "cls" - не более чем рекомендация. Вы вполне можете называть их "hello" или "vasya" и все будет работать. Кроме вашего декоратора.


    1. LinearLeopard Автор
      05.11.2021 20:04

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

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

      Собственно моя "претензия" и была про то, что имя параметра ничего не значит, а могло бы.