Привет, Хабр! Каждый, кто пишет на Python, видел этот синтаксис: @decorator. Это не просто "синтаксический сахар", а мощный инструмент для написания чистого и поддерживаемого кода. Если вы до сих пор не писали свои декораторы, то после этой статьи — точно начнете.

1. Введение: решаем реальную проблему

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

import time

def fetch_data_from_db():
    start_time = time.time()
    # --- Основная логика ---
    print("Получаем данные...")
    time.sleep(2)
    print("Данные получены.")
    # --- Конец основной логики ---
    end_time = time.time()
    print(f"Функция выполнилась за {end_time - start_time:.2f} сек.")

def calculate_complex_report():
    start_time = time.time()
    # --- Основная логика ---
    print("Считаем отчет...")
    time.sleep(3)
    print("Отчет готов.")
    # --- Конец основной логики ---
    end_time = time.time()
    print(f"Функция выполнилась за {end_time - start_time:.2f} сек.")

Этот код работает, но у него две серьезные проблемы:

  1. Нарушение принципа DRY: Мы дублируем код таймера в каждой функции. Хотим изменить логику замера? Придется править везде.

  2. Засорение логики: Замер времени — это служебная задача. Она "размазана" по коду, который должен заниматься получением данных или расчетами.

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

@timer
def fetch_data_from_db():
    print("Получаем данные...")
    time.sleep(2)
    print("Данные получены.")

@timer
def calculate_complex_report():
    print("Считаем отчет...")
    time.sleep(3)
    print("Отчет готов.")

Чисто, лаконично, и основная логика функций не тронута. Служебная функциональность вынесена в @timer.

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

2. Основы основ: что нужно знать перед стартом

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

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

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

  1. Присваивать переменной:

    def say_hello(name):
        return f"Hello, {name}!"
    
    # Присваиваем сам объект функции, а не ее результат
    greet = say_hello
    
    # Теперь greet - это еще одно имя для той же функции
    print(greet("Habr"))
    # Вывод: Hello, Habr!
    
  2. Передавать в качестве аргумента другой функции:

    def apply_function(func, value):
        # Вызываем функцию, которую нам передали
        return func(value)
    
    def double(x):
        return x * 2
    
    result = apply_function(double, 10)
    print(result)
    # Вывод: 20
    
  3. Возвращать из другой функции:

    def get_greeter(greeting):
        def greeter_func(name):
            return f"{greeting}, {name}!"
        
        # Возвращаем объект функции
        return greeter_func
    
    greet_morning = get_greeter("Good morning")
    greet_evening = get_greeter("Good evening")
    
    print(greet_morning("Alice"))
    print(greet_evening("Bob"))
    # Вывод:
    # Good morning, Alice!
    # Good evening, Bob!
    

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

Б. Вложенные функции

Как вы уже заметили в примере выше, Python позволяет определять функции внутри других функций.

def outer_func():
    print("Я внешняя функция.")

    def inner_func():
        print("А я внутренняя, и меня не видно снаружи.")

    # Вызвать внутреннюю функцию можно только внутри внешней
    inner_func()

outer_func()
# Вывод:
# Я внешняя функция.
# А я внутренняя, и меня не видно снаружи.

# Эта строка вызовет ошибку NameError, inner_func не определена в глобальной области
# inner_func()

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

В. Замыкания (Closures)

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

Звучит сложно, но пример всё прояснит:

def make_multiplier(n):
    # 'n' - это так называемая "свободная переменная"
    
    def multiplier(x):
        # Внутренняя функция "захватывает" и "помнит" значение 'n'
        return x * n
    
    return multiplier

# Вызываем внешнюю функцию. Она отрабатывает и возвращает нам
# внутреннюю функцию multiplier, которая "запомнила", что n=3
times3 = make_multiplier(3)

# Вызываем внешнюю функцию еще раз. Она создает НОВЫЙ
# экземпляр multiplier, который "запомнил", что n=5
times5 = make_multiplier(5)

# Теперь вызываем сохраненные внутренние функции
print(times3(10))  # Выведет 30
print(times5(10))  # Выведет 50

В этом примере times3 и times5 — это замыкания. Каждая из них — это функция multiplier, которая хранит в себе ссылку на свое лексическое окружение, а именно на переменную n, которая существовала в момент ее создания.

Именно этот механизм — функция, которая принимает другую функцию и возвращает вложенную функцию, "помнящую" исходную — и лежит в самом сердце декораторов.

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

3. Наш первый декоратор: шаг за шагом

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

Наша цель — создать простой логгер, который будет сообщать о вызове функции.

Шаг 1: "Ручное" декорирование (без @)

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

  1. Создадим функцию-декоратор. Назовем ее simple_logger. Она будет принимать один аргумент — функцию, которую мы хотим "украсить" (func).

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

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

  4. Вернем обертку. Наш simple_logger должен вернуть объект функции wrapper.

Посмотрим на код:

def simple_logger(func):
    """Простой декоратор для логирования вызова функции."""
    def wrapper():
        print(f"Выполняется функция: {func.__name__}...") # Логика ДО
        result = func()  # Вызываем исходную функцию
        print(f"Функция {func.__name__} завершила выполнение.") # Логика ПОСЛЕ
        return result
    
    return wrapper

Теперь применим его к нашей функции вручную. Смотрите, что происходит:

def say_whee():
    """Простая функция, которая возвращает строку."""
    print("Whee!")
    return "Готово"

# "Декорируем" нашу функцию вручную
# Передаем say_whee в simple_logger, получаем обратно wrapper
# и записываем его в переменную с тем же именем say_whee.
wrapped_say_whee = simple_logger(say_whee)

# Теперь `wrapped_say_whee` — это наша "улучшенная" функция
print("\nВызываем обернутую функцию:")
result = wrapped_say_whee()
print(f"Результат работы: {result}")

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

Вызываем обернутую функцию:
Выполняется функция: say_whee...
Whee!
Функция say_whee завершила выполнение.
Результат работы: Готово

Мы фактически подменили нашу исходную функцию say_whee на wrapper, который "помнит" про say_whee благодаря замыканию.

Шаг 2: Магия символа @

Конструкция wrapped_say_whee = simple_logger(say_whee) выглядит немного громоздко. Программисты любят краткость и читаемость, поэтому в Python для этого придумали специальный синтаксис — тот самый символ @.

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

Давайте перепишем наш пример:

# Наш декоратор остается без изменений
def simple_logger(func):
    def wrapper():
        print(f"Выполняется функция: {func.__name__}...")
        result = func()
        print(f"Функция {func.__name__} завершила выполнение.")
        return result
    return wrapper

# А вот применение становится гораздо изящнее
@simple_logger
def say_hello():
    """Еще одна простая функция."""
    print("Hello, Habr!")
    return "Успех"

# Просто вызываем функцию, как обычно. Python уже "подменил" ее за нас.
print("\nВызываем функцию, украшенную через @:")
output = say_hello()
print(f"Результат работы: {output}")

Вывод будет абсолютно аналогичным:

Вызываем функцию, украшенную через @:
Выполняется функция: say_hello...
Hello, Habr!
Функция say_hello завершила выполнение.
Результат работы: Успех

Поздравляю! Вы только что поняли самый главный принцип работы декораторов.

@my_decorator над def my_func(): — это просто красивый способ написать my_func = my_decorator(my_func).

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

4. Усложняем задачу: аргументы и метаданные

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

@simple_logger
def add(a, b):
    """Эта функция складывает два числа."""
    return a + b

# Пытаемся вызвать...
# add(5, 10) 
# TypeError: wrapper() takes 0 positional arguments but 2 were given

Мы получаем TypeError, и это логично. Наша функция-обертка wrapper() не была рассчитана на прием каких-либо аргументов, в то время как мы пытаемся передать ей 5 и 10.

Пробрасываем аргументы с *args и **kwargs

Чтобы исправить это, нам нужно сделать две вещи:

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

  2. Передать эти аргументы "транзитом" в исходную функцию func при ее вызове.

Модифицируем наш декоратор:

def logger_with_args(func):
    def wrapper(*args, **kwargs): # 1. Принимаем любые аргументы
        print(f"Выполняется {func.__name__} с аргументами {args} и {kwargs}...")
        result = func(*args, **kwargs) # 2. Передаем их в исходную функцию
        print(f"{func.__name__} вернула результат: {result}")
        return result
    return wrapper

@logger_with_args
def add(a, b):
    """Эта функция складывает два числа."""
    return a + b

@logger_with_args
def greet(name, greeting="Привет"):
    """Эта функция формирует приветствие."""
    return f"{greeting}, {name}!"

# Теперь все работает!
print(add(5, 10))
print("-" * 20)
print(greet("Мир", greeting="Здравствуй"))

Вывод:

Выполняется add с аргументами (5, 10) и {}...
add вернула результат: 15
15
--------------------
Выполняется greet с аргументами ('Мир',) и {'greeting': 'Здравствуй'}...
greet вернула результат: Здравствуй, Мир!
Здравствуй, Мир!

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

Проблема потерянных метаданных

Давайте попробуем получить информацию о нашей "украшенной" функции add. В Python это можно сделать с помощью встроенной функции help() или обратившись напрямую к специальным атрибутам.

print(add.__name__)
# Ожидаем: 'add'
# Получаем: 'wrapper'

print(add.__doc__)
# Ожидаем: 'Эта функция складывает два числа.'
# Получаем: None

Что произошло? Декоратор подменил нашу функцию add своей внутренней функцией wrapper. Теперь, с точки зрения Python, add — это wrapper. Мы потеряли исходное имя, строку документации (docstring) и другие метаданные.

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

Решение: functools.wraps

К счастью, создатели Python позаботились об этой проблеме. В стандартной библиотеке functools есть специальный декоратор @wraps, созданный для... декорирования декораторов!

Он работает как "копировальная машина" для метаданных: @wraps(func) копирует атрибуты __name__, __doc__, __annotations__ и другие из исходной функции func в функцию-обертку wrapper.

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

import functools

def professional_logger(func):
    @functools.wraps(func) # <--- Вот и решение!
    def wrapper(*args, **kwargs):
        """Это внутренняя функция-обертка."""
        print(f"Вызов: {func.__name__}({args}, {kwargs})")
        result = func(*args, **kwargs)
        print(f"Результат {func.__name__}: {result}")
        return result
    return wrapper

@professional_logger
def multiply(x, y):
    """Умножает два числа и возвращает результат."""
    return x * y

# Проверяем метаданные снова
print(f"Имя функции: {multiply.__name__}")
print(f"Docstring: {multiply.__doc__}")

# И вызываем ее
print("\nРезультат вызова:")
multiply(7, 8)

Теперь вывод именно такой, как мы и ожидали:

Имя функции: multiply
Docstring: Умножает два числа и возвращает результат.

Результат вызова:
Вызов: multiply((7, 8), {})
Результат multiply: 56

Запомните правило: всегда используйте @functools.wraps при написании своих декораторов. Это признак профессионального и качественного кода.

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

5. Фабрика декораторов: декораторы с параметрами

Мы научились создавать универсальные декораторы, но что если мы хотим сделать их настраиваемыми? Например, вместо простого @timer, мы хотим @log_level("INFO"). Или, что еще интереснее, мы хотим декоратор @repeat(num_times=3), который выполнит функцию указанное количество раз.

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

Проблема

Давайте вспомним, как работает синтаксис @:

# Эта строка:
@my_decorator
def my_func(): ...

# ...эквивалентна этой:
my_func = my_decorator(my_func)

Декоратор (my_decorator) — это функция, которая принимает в качестве единственного аргумента другую функцию (my_func).

Но когда мы пишем @repeat(3), мы сначала вызываем repeat(3). Результат этого вызова и должен быть настоящим декоратором, который, в свою очередь, примет нашу функцию.

Звучит как лишний уровень вложенности? Да, именно так и есть.

Решение: дополнительный уровень обертки

Чтобы создать декоратор с параметрами, нам нужна трехуровневая структура:

  1. Фабрика (decorator_factory): Внешняя функция. Она принимает параметры для будущего декоратора (например, num_times=3). Ее единственная задача — вернуть готовую функцию-декоратор.

  2. Декоратор (decorator): Средний уровень. Это та самая функция, которую вернет фабрика. Она, как и раньше, принимает в качестве аргумента декорируемую функцию (func).

  3. Обертка (wrapper): Внутренний уровень. Она, как и раньше, выполняет основную логику, используя и параметры из фабрики, и функцию из декоратора.

Давайте напишем наш @repeat:

import functools

# Уровень 1: Фабрика. Принимает параметры для декоратора.
def repeat(num_times):
    print(f"Фабрика вызвана с num_times={num_times}. Она вернет декоратор.")
    
    # Уровень 2: Декоратор. Принимает декорируемую функцию.
    def decorator_repeat(func):
        print(f"Декоратор создан. Он обернет функцию {func.__name__}.")
        
        # Уровень 3: Обертка. Выполняет всю работу.
        @functools.wraps(func)
        def wrapper_repeat(*args, **kwargs):
            print(f"Обертка вызвана. Повторяем {num_times} раз...")
            last_result = None
            for _ in range(num_times):
                last_result = func(*args, **kwargs)
            return last_result # Вернем результат последнего вызова
        
        return wrapper_repeat # Декоратор возвращает обертку
    
    return decorator_repeat # Фабрика возвращает декоратор

Теперь применим его и посмотрим, что происходит "под капотом":

@repeat(num_times=3)
def greet(name):
    """Простая функция приветствия."""
    print(f"Привет, {name}!")

# Вызываем нашу функцию
print("\n--- Начинаем вызов greet('Хабр') ---")
greet("Хабр")
print("--- Вызов завершен ---")

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

Фабрика вызвана с num_times=3. Она вернет декоратор.
Декоратор создан. Он обернет функцию greet.

--- Начинаем вызов greet('Хабр') ---
Обертка вызвана. Повторяем 3 раз...
Привет, Хабр!
Привет, Хабр!
Привет, Хабр!
--- Вызов завершен ---

Обратите внимание на порядок вывода: фабрика и декоратор вызываются один раз, в момент определения функции greet. А вот wrapper вызывается каждый раз, когда мы вызываем greet("Хабр").

Давайте по шагам разберем, что сделал Python:

  1. Он увидел @repeat(num_times=3).

  2. Он немедленно вызвал repeat(num_times=3). Эта функция (наша фабрика) отработала и вернула нам функцию decorator_repeat.

  3. Теперь Python видит ситуацию так, как будто мы написали @decorator_repeat над нашей функцией.

  4. Он применяет этот decorator_repeat к greet, выполняя greet = decorator_repeat(greet).

  5. В результате greet теперь ссылается на wrapper_repeat, который "помнит" и про num_times=3 (из фабрики), и про исходную функцию greet (из декоратора) благодаря замыканиям.

Этот паттерн невероятно мощный. Он позволяет создавать гибкие и переиспользуемые инструменты, например, для проверки прав доступа:

# Концептуальный пример
@require_role('admin')
def delete_user(user_id):
    # ... логика удаления
    pass

Здесь require_role — это фабрика, которая создает декоратор, проверяющий, что у текущего пользователя есть роль 'admin'.

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

6. Практические примеры из реальной жизни

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

Пример 1: @timer — измеряем время выполнения

Мы начали статью с этой проблемы, а теперь напишем для нее каноническое решение. Этот декоратор будет измерять, сколько времени заняло выполнение функции, и выводить результат. Для большей точности воспользуемся time.perf_counter().

Код декоратора:

import time
import functools

def timer(func):
    """Декоратор, который выводит время выполнения функции."""
    @functools.wraps(func)
    def wrapper_timer(*args, **kwargs):
        start_time = time.perf_counter() # 1. Засекаем время начала
        value = func(*args, **kwargs)
        end_time = time.perf_counter()   # 2. Засекаем время окончания
        run_time = end_time - start_time
        print(f"Функция {func.__name__!r} выполнилась за {run_time:.4f} с.")
        return value
    return wrapper_timer
  • !r в f-строке вызывает repr() для объекта, что добавляет кавычки вокруг имени функции для лучшей читаемости.

Применение:

@timer
def process_data(sleep_time):
    """Функция, имитирующая долгую обработку данных."""
    print("Начинаю обработку...")
    time.sleep(sleep_time)
    print("Обработка завершена.")
    return "OK"

process_data(1)
process_data(2)

Результат:

Начинаю обработку...
Обработка завершена.
Функция 'process_data' выполнилась за 1.0007 с.
Начинаю обработку...
Обработка завершена.
Функция 'process_data' выполнилась за 2.0011 с.

Это невероятно удобный инструмент для быстрого профилирования и поиска "узких мест" в вашем коде.

Пример 2: @cache — кэшируем результаты (мемоизация)

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

Код декоратора:

import functools

def cache(func):
    """Простой декоратор для кэширования результатов функции."""
    cache_storage = {}
    @functools.wraps(func)
    def wrapper_cache(*args, **kwargs):
        # Создаем ключ для кэша на основе аргументов.
        # kwargs.items() нужно отсортировать, чтобы порядок не влиял на ключ.
        cache_key = args + tuple(sorted(kwargs.items()))
        
        if cache_key not in cache_storage:
            print(f"Выполняю сложный расчет для {cache_key}...")
            cache_storage[cache_key] = func(*args, **kwargs)
        else:
            print(f"Возвращаю результат из кэша для {cache_key}...")
            
        return cache_storage[cache_key]
    return wrapper_cache

Применение:

@cache
def fibonacci(n):
    """Вычисляет n-ное число Фибоначчи (рекурсивно, очень медленно)."""
    if n < 2:
        return n
    return fibonacci(n - 1) + fibonacci(n - 2)

# Первый вызов будет очень долгим
print(f"Результат: {fibonacci(10)}")
print("-" * 20)
# Второй вызов будет мгновенным
print(f"Результат: {fibonacci(10)}")
print("-" * 20)
# Даже вызов с меньшим числом будет использовать кэш
print(f"Результат: {fibonacci(5)}")

Результат:

Выполняю сложный расчет для (10,)...
Выполняю сложный расчет для (9,)...
... (много вычислений)
Выполняю сложный расчет для (1,)...
Выполняю сложный расчет для (0,)...
Результат: 55
--------------------
Возвращаю результат из кэша для (10,)...
Результат: 55
--------------------
Возвращаю результат из кэша для (5,)...
Результат: 5

На заметку: В реальных проектах для этой задачи лучше использовать готовое и оптимизированное решение из стандартной библиотеки — @functools.lru_cache. Наш пример отлично иллюстрирует сам принцип.

Пример 3: @login_required — проверка прав доступа

Это классический пример из мира веб-разработки (Flask, Django, FastAPI). Декоратор проверяет, авторизован ли пользователь, прежде чем выполнить основную логику функции (например, отобразить страницу профиля).

Код декоратора (концептуальный):

import functools

# --- Представим, что у нас есть такая глобальная сессия ---
# В реальном фреймворке это будет объект запроса.
SESSION = {"username": None, "role": "guest"}

def login_required(func):
    """Декоратор, который проверяет, залогинен ли пользователь."""
    @functools.wraps(func)
    def wrapper_login_required(*args, **kwargs):
        if SESSION.get("username") is None:
            # В реальном приложении здесь был бы редирект на страницу логина
            # или выброс HTTP-ошибки 401/403.
            raise PermissionError("Доступ запрещен: требуется авторизация.")
        return func(*args, **kwargs)
    return wrapper_login_required

Применение:

@login_required
def view_profile():
    """Показывает страницу профиля пользователя."""
    print(f"Добро пожаловать на страницу профиля, {SESSION['username']}!")

# Попытка 1: пользователь не залогинен
try:
    view_profile()
except PermissionError as e:
    print(e)

# Имитируем успешный логин
SESSION["username"] = "HabrUser"
SESSION["role"] = "user"

# Попытка 2: пользователь залогинен
view_profile()

Результат:

Доступ запрещен: требуется авторизация.
Добро пожаловать на страницу профиля, HabrUser!

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

7. Продвинутые темы (краткий обзор)

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

А. Декораторы на основе классов

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

  • __init__(): Принимает декорируемую функцию и сохраняет ее. Он вызывается в момент декорирования.

  • __call__(): Делает экземпляр класса "вызываемым", как функцию. Именно этот метод будет выполняться каждый раз, когда вы вызываете задекорированную функцию.

Когда это полезно? Когда вашему декоратору нужно хранить какое-то состояние между вызовами.

Концептуальный пример: декоратор-счетчик

class CallCounter:
    def __init__(self, func):
        functools.update_wrapper(self, func) # Аналог @wraps для классов
        self.func = func
        self.num_calls = 0

    def __call__(self, *args, **kwargs):
        self.num_calls += 1
        print(f"Вызов номер {self.num_calls} функции {self.func.__name__!r}")
        return self.func(*args, **kwargs)

@CallCounter
def say_hello():
    print("Hello!")

say_hello()
say_hello()
# Вывод:
# Вызов номер 1 функции 'say_hello'
# Hello!
# Вызов номер 2 функции 'say_hello'
# Hello!

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

Б. Стекирование (применение нескольких) декораторов

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

@timer
@professional_logger
def process_heavy_data():
    # ... какая-то работа
    time.sleep(1)

Важно понимать порядок их применения. Он интуитивно понятен, если представить его как "матрешку" или "луковицу":

  1. При декорировании (один раз): Декор��торы применяются снизу вверх. Сначала process_heavy_data оборачивается в @professional_logger, а затем получившийся результат оборачивается в @timer.

  2. При вызове функции (каждый раз): Код выполняется сверху вниз. Сначала сработает логика wrapper'а из @timer, который затем вызовет wrapper из @professional_logger, а тот, в свою очередь, вызовет исходную функцию.

Можно представить это так: timer(professional_logger(process_heavy_data)).

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

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

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

def add_repr(cls):
    """Декоратор, который добавляет классу канонический __repr__."""
    def __repr__(self):
        params = ', '.join(f"{k}={v!r}" for k, v in self.__dict__.items())
        return f"{cls.__name__}({params})"
    
    cls.__repr__ = __repr__
    return cls

@add_repr
class MyData:
    def __init__(self, x, y):
        self.x = x
        self.y = y

data_point = MyData(2, 'a')
print(data_point) # Без декоратора вывод был бы неинформативным
# Вывод: MyData(x=2, y='a')

Это мощная техника для метапрограммирования, часто используемая в фреймворках (например, в dataclasses или Django ORM) для уменьшения количества шаблонного кода.

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

9. Домашнее задание

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

Задача 1 (Легкая): Декоратор `@debug`

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

Требования:

  1. Декоратор должен быть простым (без параметров).

  2. Он должен корректно работать с любым количеством *args и **kwargs.

  3. Используйте @functools.wraps для сохранения метаданных.

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

@debug
def greet(name, greeting="Hello"):
    return f"{greeting}, {name}!"

greet("World")
# Ожидаемый вывод:
# Вызывается greet('World', greeting='Hello')
# 'greet' вернула 'Hello, World!'
Задача 2 (Легкая+): Фабрика декораторов `@prefix`

Условие:
Напишите фабрику декораторов prefix, которая принимает строку-префикс. Декоратор, который она возвращает, должен добавлять этот префикс к результату выполнения декорируемой функции.

Требования:

  1. Декорируемая функция должна возвращать строку.

  2. Декоратор должен принимать один аргумент p (строку-префикс).

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

@prefix(p="[LOG]: ")
def get_status(code):
    return f"Status code is {code}"

print(get_status(200)) # Выведет: [LOG]: Status code is 200
print(get_status(404)) # Выведет: [LOG]: Status code is 404
Задача 3 (Средняя): Декоратор `@require_kwargs`

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

Требования:

  1. Фабрика должна принимать переменное количество строковых аргументов — имена обязательных kwargs.

  2. Если хотя бы один из требуемых kwargs отсутствует при вызове функции, декоратор должен вызывать TypeError.

  3. Если все требуемые kwargs на месте, функция должна выполниться как обычно.

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

@require_kwargs("user_id", "role")
def process_request(**kwargs):
    print(f"Обработка запроса с параметрами: {kwargs}")

process_request(user_id=10, role="admin", action="delete") # OK
# process_request(user_id=11, action="read") # Вызовет TypeError
Задача 4 (Средняя+): Декоратор `@to_json`

Условие:
Напишите декоратор @to_json, который преобразует результат выполнения функции (предполагается, что это словарь) в строку формата JSON.

Требования:

  1. Декоратор должен быть простым (без параметров).

  2. Вам понадобится import json и функция json.dumps().

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

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

import json

@to_json
def get_user_data(user_id):
    return {"id": user_id, "name": "John Doe", "permissions": ["read", "write"]}

json_string = get_user_data(123)
print(json_string) 
# Выведет: {"id": 123, "name": "John Doe", "permissions": ["read", "write"]}
print(type(json_string)) # Выведет: <class 'str'>
Задача 5 (Сложная): Декоратор `@call_limit`

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

Требования:

  1. Фабрика должна принимать один аргумент limit — целое число.

  2. При попытке вызвать функцию больше, чем limit раз, декоратор должен вызывать ValueError.

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

Подсказка: Вам понадобится переменная-счетчик в области видимости декоратора (не фабрики!). Чтобы изменять ее из wrapper, используйте ключевое слово nonlocal.

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

@call_limit(limit=3)
def send_email(address):
    print(f"Отправляю письмо на {address}")

send_email("a@ex.com") # OK
send_email("b@ex.com") # OK
send_email("c@ex.com") # OK
# send_email("d@ex.com") # Вызовет ValueError

Анонс новых статей, полезные материалы, а так же если в процессе решения возникнут сложности, обсудить их или задать вопрос по статье можно в моём Telegram-сообществе.

Уверен, у вас все получится. Вперед, к практике!