Skip to content

Latest commit

 

History

History
1989 lines (1336 loc) · 58.7 KB

File metadata and controls

1989 lines (1336 loc) · 58.7 KB

Урок 36. Декораторы. Создание собственных декораторов

Содержание урока:


МОДУЛЬ 1. Функции как объекты

Блок 1. Функции — объекты первого класса

Прежде чем изучать декораторы, нужно понять фундаментальную вещь:

В Python функция — это обычный объект.

Не «особая конструкция языка».

Не «магический блок кода».

А объект, с которым можно работать так же, как с числами или строками.

Если это станет понятно — декораторы перестанут казаться чем-то сложным.


Что такое объект первого класса?

В программировании существует понятие first-class object (объект первого класса).

Объект считается объектом первого класса, если он:

  1. Может быть присвоен переменной
  2. Может передаваться как аргумент
  3. Может возвращаться из функции
  4. Может храниться в коллекциях

Если сущность удовлетворяет этим четырём условиям — она first-class.

В Python такими объектами являются:

  • числа
  • строки
  • списки
  • словари
  • функции
  • классы

Да, функции находятся в этом списке.

Объект первого класса
│
├── можно сохранить в переменную
├── можно передать в функцию
├── можно вернуть из функции
└── можно хранить в списке/словаре

Функцию можно присвоить переменной (Первое свойство)

Создадим простую функцию:

def greet():
    print("Hello")

Теперь сделаем неожиданную вещь:

a = greet

Что произошло?

Мы не вызвали функцию.

Мы сохранили сам объект функции в переменную a.

Проверим:

a()

Результат:

Hello

Что произошло на самом деле?

Схема:

greet  ──►  (объект функции в памяти)
   │
   └── a  ──►  тот же объект

greet и a — это две ссылки на один и тот же объект.

Можно проверить:

print(id(greet))
print(id(a))

Адрес будет одинаковым.


Функция — это объект типа function

Проверим тип:

print(type(greet))

Результат:

<class 'function'>

Это полноценный объект.

У него есть атрибуты:

print(greet.__name__)
print(greet.__doc__)

Если добавить документацию (описание или докстринг):

def greet():
    """Функция приветствия"""
    print("Hello")

То:

print(greet.__doc__)

Выведет:

Функция приветствия

То есть функция:

  • хранится в памяти,
  • имеет имя,
  • имеет документацию,
  • имеет тип,
  • имеет идентификатор.

Это полноценный объект.


Функцию можно передать в другую функцию (Второе свойство)

Теперь проверим второе свойство.

Создадим функцию, которая принимает другую функцию:

def run(func):
    func()

Теперь передадим в неё greet:

run(greet)

Обратите внимание — без скобок.

Схема работы:

run(greet)
     │
     ▼
func = greet
     │
     ▼
func()

Функция передаётся как обычное значение.

Это фундамент будущих декораторов.


Функция может возвращаться из функции (Третье свойство)

Теперь третье свойство.

def outer():
    def inner():
        print("Внутренняя функция")
    return inner

Что делает outer()?

f = outer()

outer() возвращает саму функцию inner.

Теперь:

f()

Результат:

Внутренняя функция

Схема:

outer()
   │
   ▼
возвращает inner
   │
   ▼
f ──► inner

Функция вернулась как значение.


Функцию можно хранить в коллекциях (Четвёртое свойство)

Четвёртое свойство.

def square(x):
    return x * x

def cube(x):
    return x * x * x

functions = [square, cube]

Теперь можно пройтись по списку:

for func in functions:
    print(func(3))

Результат:

9
27

Схема:

functions = [square, cube]
                 │
                 ▼
           объекты функций

Мы храним функции в списке так же, как числа или строки.


Логический мост к декораторам

Итак:

  • функцию можно сохранить,
  • можно передать,
  • можно вернуть,
  • можно хранить в коллекции.

А значит…

Функция — это объект.

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

Именно на этом построены декораторы.

Декоратор — это функция, которая принимает функцию и возвращает новую функцию.

Но прежде чем к ним перейти, закрепим понимание.


Практика Блок 1

Задача 1. Функция, возвращающая функцию

Создайте функцию add_nums, которая складывает два числа.

Создайте функцию mul_nums, которая умножает два числа.

Создайте функцию choose_operation(operation), которая:

  • если operation == "add" — возвращает функцию сложения
  • если operation == "mul" — возвращает функцию умножения
  • в остальных случаях возвращает None

Задача 2. Список функций

  1. Создайте три функции:

    • прибавляет 10
    • умножает на 2
    • возводит в квадрат
  2. Поместите их в список.

  3. Пройдитесь по списку и примените каждую к числу 5.


Задача 3. Функция, принимающая функцию

Реализовать функцию apply

Создайте функцию:

def apply(func, value):
    ...

Она должна:

  • принимать функцию func
  • принимать значение value
  • возвращать результат вызова func(value)

Блок 2. Вложенные функции и замыкания

Что такое вложенная функция?

Вложенная функция — это функция, которая определяется внутри другой функции.

Пример:

def outer():
    def inner():
        print("Внутренняя функция")
    return inner

Здесь inner — это вложенная функция. Она определена внутри функции outer.

Как работает вложенная функция?

  1. Когда мы вызываем outer(), она возвращает функцию inner.
  2. Функция inner доступна только внутри outer.

Посмотрим, как это работает:

f = outer()  # outer() возвращает inner
f()  # вызывает inner()

Результат:

Внутренняя функция

Это важный момент: мы не просто получили результат работы функции outer, мы получили саму функцию inner.


Область видимости (Кратко)

На прошлых уроках мы изучали, что такое Область видимости.

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

В Python существует несколько уровней областей видимости:

  • Глобальная область (внешний уровень)
  • Локальная область (внутри функции)
  • Область замыкания (если переменная из внешней функции используется во внутренней)

Пример области видимости

def outer():
    x = 10
    def inner():
        print(x)
    return inner

f = outer()  # возвращает inner
f()  # вызов inner

Что происходит здесь?

  • x — это переменная в внешней функции outer.
  • Функция inner имеет доступ к переменной x, потому что она вложена в outer.

Важное замечание:

  • После того как outer завершит своё выполнение, переменная x обычно должна исчезнуть.
  • Но inner продолжает иметь доступ к этой переменной, даже когда outer завершится, так как мы сохранили ссылку на нее в переменной f.

Замыкание

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

Когда мы вызываем outer(), она создает переменную x. По завершении работы outer эта переменная обычно должна исчезнуть. Но:

  1. Внутренняя функция inner продолжает на неё ссылаться.
  2. Переменная остаётся в памяти, пока существует ссылка на inner (Переменная f в нашем случае).

Это и есть замыкание.

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

Если мы хотим увидеть, как это работает, можно использовать атрибут __closure__:

def outer():
    x = 10
    def inner():
        print(x)
    print(inner.__closure__)  # выводит информацию о замыканиях
    return inner

f = outer()
f()  # вызывает inner

Результат:

(<cell at 0x7fd1d01b1b80: int object at 0x7fd1d01b1880>,)

Мы видим, что переменная x хранится в области замыкания функции inner.


Как работает замыкание?

  1. Внешняя функция (например, outer) создаёт переменную (например, x).
  2. Внутренняя функция (например, inner) возвращается из outer и запоминает ссылку на x.
  3. Даже когда outer завершает выполнение, переменная x продолжает существовать в памяти, потому что на неё ссылается inner.
  4. Объект функции inner продолжает существовать, потому что на него остаётся ссылка из внешней области (в нашем случае — переменная f).

Функция-счётчик (Пример)

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

def counter():
    count = 0  # переменная для хранения счётчика
    def increment():
        nonlocal count  # изменяет переменную из внешней функции
        count += 1
        return count
    return increment

count1 = counter()  # создаём счётчик
print(count1())  # 1
print(count1())  # 2

Здесь count1 — это переменная, которая ссылается на функцию, которая возвращает замыкание, и сохраняет значение count между вызовами.

Функция-умножитель (Пример)

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

def make_multiplier(factor):
    def multiplier(x):
        return x * factor
    return multiplier

mul2 = make_multiplier(2)
mul3 = make_multiplier(3)

print(mul2(10))  # 20
print(mul3(10))  # 30

Здесь mul2 и mul3 — это два замыкания, каждое из которых сохраняет свой собственный контекст для переменной factor.


Практика Блок 2

Задача 1: Функция-счётчик

  1. Напишите функцию-счётчик, которая при каждом вызове увеличивает значение на 1.
  2. Создайте два счётчика.
  3. Выведите значения обоих счётчиков.
  4. Для лучшего закрепления материала постарайтесь не смотреть пример из материала выше.

Пример:

counter1 = counter()
print(counter1())  # 1
print(counter1())  # 2

Задача 2: Функция-умножитель

  1. Напишите функцию make_multiplier(factor), которая будет умножать число на переданный множитель.
  2. Создайте два умножителя — на 2 и на 5.
  3. Примените их к числу 10 и выведите результат.
  4. Для лучшего закрепления материала постарайтесь не смотреть пример из материала выше.

Пример:

multiplier2 = make_multiplier(2)
multiplier5 = make_multiplier(5)

print(multiplier2(10))  # 20
print(multiplier5(10))  # 50

Задача 3: Создание функции с состоянием

  1. Напишите функцию make_adder(value), которая будет принимать значение и возвращать функцию, которая прибавляет это значение к переданному числу.

Пример:

add5 = make_adder(5)
print(add5(10))  # 15

МОДУЛЬ 2. Декораторы

Блок 3. Первый декоратор. От функции к "syntactic sugar" (синтаксический сахар @)

До этого момента мы последовательно шли к одной идее:

Если функция — объект, её можно передать другой функции и заменить.

Если функцию можно передать в другую функцию — значит, её можно "обернуть".

Теперь мы используем это на практике.

Наша задача — убрать "магию" и оставить механизм декораторов.


Определение

Декоратор — это функция, принимающая функцию и возвращающая новую функцию.

Схематично:

decorator(func) → wrapper
  • func — исходная функция
  • wrapper — новая функция
  • именно wrapper будет вызываться вместо исходной

Базовая структура декоратора:

def decorator(func):
    def wrapper():
        # дополнительное поведение
        func()
        # дополнительное поведение
    return wrapper

Важно:

  • wrapper — это новая функция
  • мы возвращаем wrapper
  • исходная функция будет заменена

И вопрос: почему нельзя просто вызвать func() внутри decorator?

def decorator(func):
    print("До вызова")
    func()
    print("После вызова")

Разбор механики декоратора

Начнём с двух реализаций. Они похожи, но ведут себя принципиально по-разному.

Вариант 1 — «псевдо-декоратор» (без wrapper)

def fake_decorator(func):
    print(">>> Началось декорирование")
    func()
    print(">>> Декорирование завершено")
    return func

Вариант 2 — настоящий декоратор

def real_decorator(func):
    def wrapper():
        print(">>> Перед вызовом функции")
        result = func()
        print(">>> После вызова функции")
        return result
    return wrapper

Механика применения декоратора. Замена функции

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

def greet_once():
    print("Hello from greet_once")

greet_once = fake_decorator(greet_once)

Что произойдёт?

  • func() вызовется сразу, при передаче в декоратор
  • но новая функция не будет возвращена
  • поведение изменится один раз — при определении
  • это не декоратор, а просто выполнение функции.

Применение настоящего декоратора

def greet_wrapped():
    print("Hello from greet_wrapped")
    
greet_wrapped = real_decorator(greet_wrapped)

Что произошло?

  • decorator получает greet_wrapped
  • создаёт wrapper
  • возвращает wrapper
  • переменная greet_wrapped теперь указывает на wrapper
  • поведение функции greet_wrapped будет изменено при каждом вызове.

Syntactic sugar (синтаксический сахар @)

Символ @ в синтаксисе декораторов Python называется "syntactic sugar" (синтаксический сахар) для применения функции-декоратора к другой функции или методу.

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

Перепишим наши примеры с использованием @:

@fake_decorator
def greet_once():
    print("Hello from greet_once")


@real_decorator
def greet_wrapped():
    print("Hello from greet_wrapped")

Разбор происходящего

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

@fake_decorator
def greet_once():
    print("Hello from greet_once")


@real_decorator
def greet_wrapped():
    print("Hello from greet_wrapped")


print("=== Вызовы greet_once ===")
greet_once()
greet_once()
greet_once()

print("=== Вызовы greet_wrapped ===")
greet_wrapped()
greet_wrapped()
greet_wrapped()

При запуске вы получите:

>>> Началось декорирование
Hello from greet_once
>>> Декорирование завершено
=== Вызовы greet_once ===
Hello from greet_once
Hello from greet_once
Hello from greet_once
=== Вызовы greet_wrapped ===
>>> Перед вызовом функции
Hello from greet_wrapped
>>> После вызова функции
>>> Перед вызовом функции
Hello from greet_wrapped
>>> После вызова функции
>>> Перед вызовом функции
Hello from greet_wrapped
>>> После вызова функции

Что произошло с fake_decorator

  1. Python создаёт функцию greet_once
  2. Вызывает fake_decorator(greet_once)
  3. fake_decorator:
    • печатает сообщение
    • ВЫЗЫВАЕТ функцию
    • возвращает исходную функцию
  4. greet_once остаётся прежней

Расширение произошло один раз — в момент определения функции.

Дальнейшие вызовы не изменены.


Что произошло с real_decorator

  1. Python создаёт функцию greet_wrapped
  2. Вызывает real_decorator(greet_wrapped)
  3. real_decorator создаёт wrapper
  4. Возвращает wrapper
  5. Имя greet_wrapped теперь указывает на wrapper

Схема замены:

До:
greet_wrapped ──► original function

После:
greet_wrapped ──► wrapper ──► original function

Теперь каждый вызов проходит через wrapper.

Вот и вся «магия».


Почему нельзя просто вызвать func внутри decorator?

Потому что:

  • decorator выполняется один раз
  • wrapper выполняется каждый раз

Если вы вызываете func() внутри decorator:

  • вы просто выполняете её при декорировании
  • вы не создаёте новую функцию
  • вы не меняете поведение при вызове

Это не расширение поведения. Это разовый запуск.


Что делает wrapper на самом деле?

  • Это новая функция
  • Она замыкает func
  • Она добавляет поведение
  • Она возвращается вместо оригинала

Если:

  • не вернуть wrapper → функция станет None
  • не вызвать func внутри wrapper → исходная функция не выполнится
  • вызвать func внутри decorator без wrapper → поведение изменится сразу

Практика — Предсказать вывод программы

Это упражнение не про код. Это упражнение на понимание момента выполнения.

Пример:

def decorator(func):
    print("Decorator executed")

    def wrapper():
        print("Wrapper start")
        func()
        print("Wrapper end")

    return wrapper


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


print("After definition")
greet()

Правильная логика:

  1. Создаётся greet
  2. decorator(greet) выполняется
  3. Печатается "Decorator executed"
  4. greet заменяется на wrapper
  5. Печатается "After definition"
  6. greet() вызывает wrapper
  7. wrapper печатает "Wrapper start"
  8. вызывается оригинальная greet
  9. печатается "Hello"
  10. печатается "Wrapper end"

Это упражнение разрушает иллюзию, что декоратор работает «при вызове».


Ключевая мысль блока

Декоратор — это не:

  • специальный механизм Python
  • не магия
  • не скрытая конструкция

Это:

  1. Функция принимает функцию
  2. Создаёт новую функцию
  3. Возвращает её
  4. Имя начинает указывать на новую функцию

Wrapper — это точка расширения поведения.

@ — это просто сокращённая запись переприсваивания.


Практика Блок 3

Задача 1. Декоратор логирования

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

Например, если функция называется greet, то при её вызове в терминале должно появиться следующее:

  1. Перед выполнением функции: "Вызов greet".
  2. После выполнения функции: "Завершение greet".

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


Задача 2. Декоратор замера времени

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

Для замера времени используйте модуль time и функции time.time().

Протестируйте декоратор на функциях, которые выполняют длительные операции, например:

  • Использует time.sleep() для паузы.
  • Ожидает пользовательский ввод через input().

Пример вывода в терминал:

Функция some_function выполнена за 2.003 секунды.

МОДУЛЬ 3. Углубление в построение декораторов

Блок 4. Декоратор с аргументами функции

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

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

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

def real_decorator(func):
    def wrapper():
        print(">>> Перед вызовом функции")
        result = func()
        print(">>> После вызова функции")
        return result
    return wrapper

@real_decorator
def greet(name):
    print(f"Hello, {name}!")

greet('Bob')

Ошибка! Мы забыли учесть, что функция greet принимает аргумент name, а декоратор не передает его.


Чтобы решить эту проблему, нам нужно использовать универсальную сигнатуру *args и **kwargs, которая позволяет обрабатывать любые аргументы, передаваемые в функцию.

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

def real_decorator(func):
    def wrapper(*args, **kwargs):
        print(">>> Перед вызовом функции")
        result = func(*args, **kwargs)  # передаем аргументы в функцию
        print(">>> После вызова функции")
        return result
    return wrapper

Вспоминаем *args и **kwargs

*args — это стандартная конструкция Python для передачи произвольного количества позиционных аргументов.

Внутри функции args — это кортеж, который содержит все позиционные аргументы, переданные в функцию.


**kwargs — это конструкция для передачи произвольного количества именованных (ключевых) аргументов.

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


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

  1. Мы не знаем заранее, сколько аргументов и какие именно они будут.
  2. С помощью *args и **kwargs мы можем обработать любое количество как позиционных, так и именованных аргументов.

Разбор на примере логирования аргументов

Теперь давайте усложним наш декоратор и добавим в него логирование аргументов функции:

def log_decorator(func):
    def wrapper(*args, **kwargs):
        print(f"[LOG] Входные аргументы: {args}, {kwargs}")
        result = func(*args, **kwargs)
        print(f"[LOG] Результат: {result}")
        return result
    return wrapper

Применим его к функции, которая принимает аргументы:

@log_decorator
def greet(name, age):
    return f"Hello, {name}! You are {age} years old."

# Вызов функции с аргументами
greet("Alice", 25)

Вывод будет таким:

[LOG] Входные аргументы: ('Alice', 25), {'name': 'Alice', 'age': 25}
[LOG] Результат: Hello, Alice! You are 25 years old.

Декоратор не должен менять логику, если это не требуется

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

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

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

Блок 5. Декоратор с параметрами (тройная обёртка)

Это самый сложный и концептуально важный блок.

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

Мы уже умеем писать:

@log_calls
def greet():
    ...

Но что если нам нужно:

@repeat(3)
def greet():
    ...

Как правильно определить декоратор, что бы корректно обрабатывать аргументы в самом декораторе?

Почему появляется дополнительный уровень вложенности и структура становится трёхуровневой?

Главная идея декоратора с параметрами

Если декоратор принимает параметры, значит:

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

И только потом она возвращает настоящий декоратор.

Код:

@decorator(param)
def func():
    ...

Эквивалентен:

func = decorator(param)(func)

Именно тут появляется третий уровень:

  • decorator(param) → возвращает real_decorator
  • real_decorator(func) → возвращает wrapper

Строгое разделение уровней. Структура тройной обертки

  • Уровень 1decorator(param)
    • Это конфигурация.
    • Этот уровень вызывается сразу, при определении функции.
  • Уровень 2real_decorator(func)
    • Это настоящий декоратор.
    • Он получает функцию и возвращает wrapper.
  • Уровень 3wrapper(*args, **kwargs)
    • Это обёртка, которая будет выполняться при вызове функции.

Пример декоратора с параметрами:

def repeat(times):                      # Уровень 1
    print(f"[decorator] repeat({times}) created")

    def real_decorator(func):           # Уровень 2
        print(f"[decorator] Decorating {func.__name__}")

        def wrapper(*args, **kwargs):   # Уровень 3
            print(f"[wrapper] Executing {func.__name__}")
            for _ in range(times):
                func(*args, **kwargs)

        return wrapper

    return real_decorator

@repeat(3)
def greet():
    print("Hello")

print("=== After definition ===")
greet()

Пошаговый разбор

Время декорирования

Когда Python встречает:

@repeat(3)
def greet():

Он делает:

greet = repeat(3)(greet)

Последовательность:

  1. Вызывается repeat(3)
  2. repeat возвращает real_decorator
  3. real_decorator получает greet
  4. real_decorator возвращает wrapper
  5. greet теперь указывает на wrapper

Вывод при определении:

[decorator] repeat(3) created
[decorator] Decorating greet
=== After definition ===

Время выполнения

Когда вызываем:

greet()

Теперь выполняется wrapper:

[wrapper] Executing greet
Hello
Hello
Hello

Разделяем два времени

Это критически важно.

Время декорирования

Происходит один раз.

@repeat(3)
  • Вызывается repeat(3)
  • Создаётся real_decorator
  • Функция заменяется на wrapper

Время выполнения

Происходит каждый вызов функции.

greet()

Вызывается wrapper

Выполняется логика


Опять замыкание?

Очень важный вопрос: Где хранятся параметры декоратора?

На первом уровне:

def repeat(times):
    ...

times хранится в замыкании.

wrapper использует переменную times, которая объявлена во внешней функции.

Переменная times продолжает жить, потому что wrapper на неё ссылается, а на сам wrapper будет ссылаться переменная декорированной функции.

Без механизма замыкания такая конструкция была бы невозможна.

wrapper
   ↓
real_decorator
   ↓
repeat(times)

Вывод

Декоратор с параметрами — это:

Функция → возвращает декоратор → возвращает wrapper

или в виде кода:

decorator(param)(func)(*args, **kwargs)

Блок 6. functools.wraps — метаданные функции

Этот блок не про логические конструкции или про «магическую механику».

Этот блок про аккуратность, корректность и профессиональный стиль.

Метаданные функции

Каждая функция в Python — это объект.

И у этого объекта есть атрибуты.

Например:

def greet():
    """Функция приветствия"""
    print("Hello")

Можно проверить:

print(greet.__name__)
print(greet.__doc__)
print(greet.__module__)

Результат:

greet
Функция приветствия
__main__

Это и есть метаданные функции:

  • имя (__name__)
  • документация (__doc__)
  • модуль (__module__)
  • аннотации (__annotations__)
  • и другие служебные атрибуты

Проблема: декоратор ломает метаданные

Возьмём простой декоратор:

def log_calls(func):
    def wrapper(*args, **kwargs):
        print(f"Calling {func.__name__}")
        return func(*args, **kwargs)
    return wrapper

Применим его:

@log_calls
def greet():
    """Функция приветствия"""
    print("Hello")

Теперь проверим:

print(greet.__name__)
print(greet.__doc__)

Результат:

wrapper
None
  • Имя стало wrapper.
  • Документация исчезла.

Это происходит потому что:

greet = log_calls(greet)

После декорирования:

greet ──► wrapper

Оригинальная функция больше не привязана к имени greet.

Мы заменили объект.


В маленьких примерах — это кажется несущественным.

Но в реальных проектах это критично:

  • логирование (func.__name__)
  • автоматическая генерация документации
  • тестовые фреймворки
  • дебаггеры
  • трассировка ошибок
  • аннотации типов
  • introspection (inspect)

Если имя функции в логах — wrapper, отладка превращается в хаос.


Решение: функция wraps из модуля functools

Python уже предусмотрел эту проблему.

Используется wraps из модуля functools.

from functools import wraps

Дополним наш декоратор:

from functools import wraps

def log_calls(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        print(f"Calling {func.__name__}")
        return func(*args, **kwargs)
    return wrapper

Теперь:

@log_calls
def greet():
    """Функция приветствия"""
    print("Hello")

print(greet.__name__)
print(greet.__doc__)

Результат:

greet
Функция приветствия

Метаданные восстановлены.


Функция wraps(func):

  • копирует __name__
  • копирует __doc__
  • копирует __module__
  • копирует __annotations__
  • добавляет атрибут __wrapped__
wrapper  ← копируются атрибуты ←  original func

Это аккуратная передача метаданных от исходной функции к обёртке.

Функция wraps не влияет на логику выполнения.

  • не меняет порядок вызова
  • не влияет на аргументы
  • не меняет замыкание

Она только делает wrapper «прозрачным».

Пример с декоратором с параметрами

Теперь используем wraps в тройной обёртке.

from functools import wraps

def repeat(times):
    def real_decorator(func):

        @wraps(func)
        def wrapper(*args, **kwargs):
            for _ in range(times):
                func(*args, **kwargs)

        return wrapper

    return real_decorator

Применим:

@repeat(2)
def greet():
    """Функция приветствия"""
    print("Hello")

Проверим:

print(greet.__name__)
print(greet.__doc__)

Результат:

greet
Функция приветствия

Связь с отладкой и логированием

Представим систему логирования.

У нас есть декоратор:

def log_calls(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        print(f"[LOG] function {func.__name__} called into decorator")
        return func(*args, **kwargs)
    return wrapper

И мы пытаемся залогировать функцию, задекорированную с помощью log_calls:

@log_calls
def greet(name):
    '''Функция приветствия'''
    return f"Hello, {name}"

greet('Bob')
print(f"[LOG] {greet.__name__} called")

Если wraps не использовать:

[LOG] greet called
[LOG] wrapper called

Если использовать:

[LOG] greet called
[LOG] greet called

Это огромная разница в читаемости логов.

Практическое правило:

Если вы пишете декоратор — всегда используйте @wraps.

Это стандарт индустрии.


МОДУЛЬ 4. Разбор по шагам

Задача: Реализовать декоратор cache_result

Реализуйте декоратор cache_result, который:

  1. Кэширует результаты вызова функции.
  2. Ключом кэша (словаря) являются аргументы функции.
  3. При повторном вызове с теми же аргументами результат должен возвращаться из кэша.
  4. Добавить логирование на уровне использования кэша (Новое вычисленное значение или значение из кэша).
  5. Если аргументы нельзя использовать в качестве ключа словаря — должна быть возвращена декорируемая функция и запись об этом в журнал логов.
  6. Использовать functools.wraps для сохранения метаданных.

Что такое кэш и кэширование (кратко)

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

Кэширование — это процесс сохранения результатов выполнения функции (или других операций) для их повторного использования. Если функция вызывается с теми же аргументами, вместо повторного выполнения возвращается сохранённый результат из кэша.

*В Python можно использовать встроенные инструменты, такие как декоратор functools.lru_cache, но в примере мы реализуем кэширование вручную

Порядок решения

Шаг 1. Понять где будет храниться кэш

Кэш должен:

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

Вывод:

Кэш нужно хранить в замыкании.

Значит словарь создаётся на уровне декоратора.


Шаг 2. Определить структуру декоратора

Так как декоратор без параметров, структура будет:

cache_result(func)
    cache = {}
    wrapper(*args, **kwargs)
        ...
    return wrapper

Шаг 3. Настроить logging

В задании есть пункт о логировании. Логично будет использовать именно эту библиотеку.

Нужно:

  • импортировать logging
  • настроить базовую конфигурацию
  • указать файл логов

Например:

logging.basicConfig(
    filename="cache.log",
    level=logging.INFO,
    format="%(asctime)s - %(message)s"
)

Шаг 4. Сформировать ключ кэша

Нужно объединить:

  • args
  • kwargs

Но:

  • kwargs — словарь
  • словарь нельзя использовать как ключ

Решение:

key = (args, frozenset(kwargs.items()))

или

key = (args, tuple(sorted(kwargs.items())))

frozenset — это неизменяемая (immutable) версия множества (set) в Python. Как и обычное множество, frozenset представляет собой коллекцию уникальных элементов, но его содержимое нельзя изменить после создания.

Шаг 5. Обработать возможную ошибку

Возможная ошибка: Если аргументы содержат список или другой изменяемый объект мы получаем TypeError: unhashable type.

Значит формирование ключа нужно обернуть в try/except. И если мы встречаем такую ошибку, то лучше сразу записать ее в логи, и вернуть результат работы функции.

try:
    key = (args, tuple(sorted(kwargs.items())))
except TypeError:
    logging.info("UNABLE TO ADD KEY TO CACHE (UNHASHABLE ARGUMENTS)")
    return func(*args, **kwargs)

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

Шаг 6. Логика кэширования

Если ключ есть в словаре:

logging.info("CACHE HIT ...")
return cache[key]

Если нет:

logging.info("CACHE MISS ...")
result = func(...)
cache[key] = result
return result

Шаг 7. Использовать wraps

Обязательно:

@wraps(func)

Полная реализация декоратора

import logging
from functools import wraps

logging.basicConfig(
    filename="cache.log",
    level=logging.INFO,
    format="%(asctime)s - %(message)s",
    encoding='utf-8'
)

def cache_result(func):
    cache = {}

    @wraps(func)
    def wrapper(*args, **kwargs):
        try:
            key = (args, tuple(sorted(kwargs.items())))
        except TypeError:
            logging.info("UNABLE TO ADD KEY TO CACHE (UNHASHABLE ARGUMENTS)")
            return func(*args, **kwargs)

        if key in cache:
            logging.info(f"CACHE HIT: {func.__name__} | args={args} | kwargs={kwargs}")
            return cache[key]

        logging.info(f"CACHE MISS: {func.__name__} | args={args} | kwargs={kwargs}")
        result = func(*args, **kwargs)
        cache[key] = result
        return result

    return wrapper

Тестирование

Тест 1 — базовая проверка

@cache_result
def multiply(a, b):
    return a * b

print(multiply(2, 3))
print(multiply(2, 3))
  • первый вызов — добавление в логи CACHE MISS
  • второй вызов — добавление в логи CACHE HIT

Тест 2 — проверка исключения

print(multiply([1, 2], 3))
  • Должна появится запись в логах: "UNABLE TO ADD KEY TO CACHE (UNHASHABLE ARGUMENTS)".

Тест 3 - порядок именных аргументов

@cache_result
def create_info(name, age):
    return f'Name: {name}\nAge: {age}\n'

print(create_info(name='bob', age=32))
print(create_info(age=32, name='bob'))
  • первый вызов — добавление в логи CACHE MISS
  • второй вызов — добавление в логи CACHE HIT

Тест 4 - проверка метаданных

print(create_info.__name__)

Ожидаем:

create_info

Если wrapper — значит забыли wraps.


Итоговая практика урока "Декораторы. Создание собственных декораторов"

Задача 1 — log_call

Реализуйте декоратор log_call, который:

  1. Логирует вызов функции в текстовый файл calls.log

  2. Записывает:

    • имя функции
    • переданные позиционные аргументы
    • переданные именованные аргументы
  3. Возвращает результат выполнения функции

Логирование должно происходить в формате:

Function: calculate_sum | args: (2, 3) | kwargs: {}

Требования

  • Использовать functools.wraps
  • Использовать режим дозаписи файла (a)
  • Обработать возможные ошибки записи в файл через try/except
  • Декоратор должен работать с любой функцией

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

@log_call
def calculate_sum(a, b):
    return a + b

calculate_sum(2, 3)

После вызова в файле calls.log должна появиться запись.


Задача 2 — measure_time

Реализуйте декоратор measure_time, который:

  1. Замеряет время выполнения функции

  2. Записывает в файл performance.log:

    • имя функции
    • время выполнения в секундах
  3. Возвращает результат функции без изменений

Если функция вызвала исключение:

  • ошибка должна быть записана в файл
  • исключение не должно подавляться (его нужно пробросить дальше)

Формат записи логов:

Function: load_data | Time: 0.4521 sec

Если произошла ошибка:

Function: load_data | ERROR: FileNotFoundError

Имя ошибки можно получить с помощью: type(error).__name__


Требования

  • Использовать wraps
  • Использовать time.perf_counter() или time.time()
  • Использовать try/except
  • Использовать finally, если это оправдано логикой

Пример функции для тестирования

@measure_time
def load_data(filename):
    with open(filename) as f:
        return f.read()

Задача 3 - limit_calls(max_calls)

Реализуйте декоратор limit_calls(max_calls), который:

  1. Принимает максимальное количество вызовов функции

  2. Позволяет вызвать функцию не более max_calls раз

  3. После превышения лимита:

    • выбрасывает исключение RuntimeError
    • записывает событие в файл limits.log

Дополнительно:

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

Требования

  • Использовать wraps
  • Реализовать тройную обёртку
  • Использовать замыкание для хранения счётчика
  • Использовать try/except если потребуется

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

@limit_calls(3)
def send_email(address):
    print("Email sent")

send_email("e_mail@gmail.ru")
send_email("e_mail@gmail.ru")
send_email("e_mail@gmail.ru")

send_email("e_mail@gmail.ru")  # ошибка!

После трёх вызовов следующий должен вызвать ошибку.


Задача 4 - retry(attempts)

Реализуйте декоратор retry(attempts), который:

  1. Принимает количество попыток выполнения функции.
  2. Если функция выбрасывает исключение — повторяет вызов.
  3. Если попытки закончились — пробрасывает последнее исключение.
  4. Логирует каждую попытку через logging.
  5. Обязательно использовать wraps.

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

@retry(3)
def read_file(filename):
    with open(filename) as f:
        return f.read()

Если файл отсутствует:

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

Помните:

  • Исключение не хранится где-то глобально. Когда вы выходите из блока except, Python очищает ссылку.
  • Исключения можно сохронять в переменную

Предыдущий урок | Следующий урок