Фотография автора

Привет, меня зовут Влад.

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

Может о чем-то еще.

Декораторы в Python

Декораторы в Python — это паттерн проектирования, предназначенный для расширения функциональности объектов без вмешательства в их код.
Запись декоратора, представленная через символ @ перед названием изменяемой функции, по сути является синтаксическим сахаром в питоне.

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

Области видимости

В Python существует 4 области видимости, но обычно говорят про три области видимости, которые идут первыми в нижеприведенном списке.

LEGB области видимости:

global_variable = 1

def outer():
    enclosing_variable = 2

    def inner():
        local_variable = 3

        print("local", local_variable)
        print("enclosing", enclosing_variable)
        print("global", global_variable)
        print("build-in", __name__)
    
    inner()

outer()

# Терминал выдаст следующее:
# local 3
# enclosing 2
# global 1
# build-in __main__
  1. local_variable — переменная в локальной области видимости, т.к. она объявлена внутри функции inner() и доступна только из нее;
  2. enclosing_variable - переменная, которая объявлена в объемлющей функции (нелокальной). Получить значение можем как внутри объемлющей функции, так и внутри локальной (inner);
  3. global_variable — глобальная переменная в глобальной области видимости, получить значение можем и в других областях видимости;
  4. переменная __name__ получила значение из встроенной области видимости. Это специальная область видимости Python, которая содержит имена встроенные в Python. Имена в этой области Python также доступны повсюду в вашем коде.

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

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

NameError: name 'inner' is not defined.

Замыкания

Перед тем как дать определение замыканию, давай посмотрим несколько примеров.

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

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

global_variable = 1

def outer():
    enclosing_variable = 2

    def inner():
        local_variable = 3

        return (
            f"local -> {local_variable}, "
            f"enclosing -> {enclosing_variable}, "
            f"global -> {global_variable}"
        )

    return inner()

Результат работы данной функции присвоим переменной и выведем в консоль:

test_func = outer()
print(test_func)

# Выведет в консоль:
# local -> 3, enclosing -> 2, global -> 1

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

global_variable = 1

def outer():
    enclosing_variable = 2

    def inner():
        local_variable = 3

        return (
            f"local -> {local_variable}, "
            f"enclosing -> {enclosing_variable}, "
            f"global -> {global_variable}"
        )
    
    return inner()

test_func = outer()
print(test_func)
del outer # Что произойдет после того как мы удалили функцию?
print(test_func)

# Вывод в консоль после запуска скрипта
# local -> 3, enclosing -> 2, global -> 1
# local -> 3, enclosing -> 2, global -> 1

Функция outer() — это своеобразная фабрика для создания и настройки копий функции inner().

После удаления outer() вся её контекстная область видимости будет сохранена, потому что на эту область видимости существуют ссылки в объекте test_func.

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

def print_message(name):
    def inner(message):
        return f"{name}, {message}!"
    return inner

message_to_ivan = print_message("Иван")
message_to_petr = print_message("Петр")

print(message_to_ivan("привет"))
print(message_to_ivan("пока"))
# Иван, привет!
# Иван, пока!

print(message_to_petr("привет"))
print(message_to_petr("пока"))
# Петр, привет!
# Петр, пока!

В вышеприведенном примере, после объявления функции, происходит следующее:

  1. Мы создаем две переменные, где вызываем объемлющую функцию и передаем аргументы для name.
  2. Затем мы уже печатаем результат созданных нами переменных, которые по сути являются функциями и передаем значение для внутренней функции inner().

И тут можно сказать следующее. Замыкание (на английском «closure») — это способность вложенной функции запоминать локальное состояние контекстной области объемлющей функции.

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

def check_age_and_return_message(age, message):
    def entry_allowed():
        return f"{message} {age}? Ну ок. Вход разрешен."

    def no_entry():
        return f"{message} Т.к. тебе {age}, то вход запрещен"

    if age < 18:
        return no_entry
    return entry_allowed

mike = check_age_and_return_message(18, "Сколько тебе лет?")

print(mike())

# Вывод в консоль:
# Сколько тебе лет? 18? Ну ок. Вход разрешен.

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

Так, ну и где тут декораторы?

А вот и пример декоратора, который выводит время выполнения кода функции.

import time

def time_of_function(func):

    def wrapper(*args):
        t0 = time.perf_counter()
        result = func(*args)
        elapsed = time.perf_counter() - t0
        name = func.__name__
        arg_str = ", ".join(repr(arg) for arg in args)
        print(f"{elapsed:0.10f}s -> {name}({arg_str}) -> {result}")
        return result
    return wrapper

@time_of_function
def factorial(n):
    return 1 if n < 2 else n * factorial(n - 1)

factorial(10)

Результат выполнения функции будет следующим:

0.0000004000s -> factorial(1) -> 1
0.0002123000s -> factorial(2) -> 2
0.0004742000s -> factorial(3) -> 6
0.0005836000s -> factorial(4) -> 24
0.0007005000s -> factorial(5) -> 120
0.0008124000s -> factorial(6) -> 720
0.0009196000s -> factorial(7) -> 5040
0.0010263000s -> factorial(8) -> 40320
0.0011475000s -> factorial(9) -> 362880
0.0012911000s -> factorial(10) -> 3628800

time_of_function() — это и есть декоратор. Обёртка-замыкание изменяет поведение декорируемой функции. Сама же декорируемая функция при этом не модифицируется.

time_of_function() делает следующее:

  1. Запоминает начальный момент времени в переменной t0.
  2. Вызывает исходную функцию factorial и сохраняет результат.
  3. Вычисляет сколько прошло времени.
  4. Форматирует и печатает собранные данные.
  5. Возвращает результат, сохраненный на шаге 2.

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

Пример выше можно написать и без использования синтаксического сахара.

import time

def time_of_function(func):

    def wrapper(*args):
        t0 = time.perf_counter()
        result = func(*args)
        elapsed = time.perf_counter() - t0
        name = func.__name__
        arg_str = ", ".join(repr(arg) for arg in args)
        print(f"{elapsed:0.10f}s -> {name}({arg_str}) -> {result}")
        return result
    return wrapper

def factorial(n):
    return 1 if n < 2 else n * factorial(n - 1)

factorial_var = time_of_function(factorial)

factorial_var(10)

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

Использование нескольких декораторов

Функция с замером времени имела несколько недостатков.

Во-первых, функция принимает на вход только позиционные аргументы. Чтобы получать в том числе и именованные, то нужно добавить распаковку **kwargs для wrapper:

def wrapper(*args, **kwargs):
    pass

Этот синтаксис означает, что функция готова принять любое количество позиционных (*args) и именованных (**kwargs) аргументов.

С переменной *args можно работать как с кортежем, а с переменной **kwargs — как со словарем.

Такой синтаксис может применяться в любых функциях, не только в декораторах. Имена args и kwargs не предустановлены, но общеприняты.

Во-вторых, в случае если у нас будет использоваться несколько декораторов, то атрибуты __name__ или __doc__ выведут нам неправильный результат. А для того, чтобы результат был корректным, нужно использовать декоратор из стандартной библиотеки functools — wraps.

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

import time
from functools import wraps

def time_of_function(func):

    @wraps(func)
    def wrapper(*args, **kwargs):
        start_time = time.perf_counter()
        result = func(*args, **kwargs)
        execution_time = time.perf_counter() - start_time
        print(f"Время выполнения функции {func.__name__}: {execution_time:0.2f} с.")
        print()
        return result
    return wrapper

def cache_args(func):
    arguments = {}

    @wraps(func)
    def wrapper(*args):
        result = args

        if result in arguments:
            return arguments[result]

        arguments[result] = func(*args)
        print("Передан аргумент", *args, "Результат выполнения:", arguments[result])
        return arguments[result]
    return wrapper

@time_of_function
@cache_args
def get_square_number(num):
    time.sleep(1)
    return num**2

get_square_number(10)
get_square_number(10)
get_square_number(5)
get_square_number(5)

Результат выполнения:

Передан аргумент 10 Результат выполнения: 100
Время выполнения функции get_square_number: 1.00 с.

Время выполнения функции get_square_number: 0.00 с.

Передан аргумент 5 Результат выполнения: 25
Время выполнения функции get_square_number: 1.00 с.

Время выполнения функции get_square_number: 0.00 с.**

По порядку:

  1. Декоратор time_of_function уже нам знакома, она лишь немного видоизменилась.
  2. Декоратор cache_args отвечает за кеширование переданных аргументов. Если аргумент уже был сохранен в переменной result, то мы просто возвращаем уже ранее сохраненное значение. Если же в result такого аргумента нет, то мы его высчитываем и выводим дополнительно сообщение в консоль.
  3. Сама декорируемая функция get_square_number вполне обычная, только здесь еще добавлен вызов time.sleep(1), чтобы мы могли понять была ли функция вызвана или же результат вернулся из кеша. Задержка в одну секунду прекрасно позволяет это сделать.

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

Выше я писал о декораторе @wraps, которому мы в данном примере передали функцию из аргумента. Именно этот декоратор позволил корректно вывести __name__ внутри декоратора.

Давай попробуем убрать декоратор @wraps и заодно переименуем название функции wrapper внутри cache_args и посмотрим, что случится:

import time

def time_of_function(func):
    def wrapper(*args, **kwargs):
        start_time = time.perf_counter()
        result = func(*args, **kwargs)
        execution_time = time.perf_counter() - start_time
        print(f"Время выполнения функции {func.__name__}: {execution_time:0.2f} с.")
        print()
        return result
    return wrapper

def cache_args(func):
    arguments = {}
    
    def wrapper1(*args):
        result = args
        
        if result in arguments:
            return arguments[result]
            
        arguments[result] = func(*args)
        print("Передан аргумент", *args, "Результат выполнения:", arguments[result])
        return arguments[result]
    return wrapper1

@time_of_function
@cache_args
def get_square_number(num):
    time.sleep(1)
    return num**2

get_square_number(10)
get_square_number(10)
get_square_number(5)
get_square_number(5)

Результат выполнения:

Передан аргумент 10 Результат выполнения: 100
Время выполнения функции wrapper1: 1.00 с.

Время выполнения функции wrapper1: 0.00 с.

Передан аргумент 5 Результат выполнения: 25
Время выполнения функции wrapper1: 1.00 с.

Время выполнения функции wrapper1: 0.00 с.

И что мы здесь видим? Название декорируемой функции некорректное и по факту мы видим, что передается название функции wrapper1 из функции cache_args, т.к. именно эту обертку мы и возвращаем на вход функции time_of_function. Но в остальном все выполнено правильно.

Вывод

Декораторы в Python — очень мощный и часто используемый инструмент. Ты можешь увидеть его и при работе с Django, и с FastApi и с чем-либо еще.

Эта заметка не описывает все возможности, которые есть, но как базовое интро вполне подойдет.

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