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

Замыкания

Вложенная функция всегда имеет доступ к переменным, определенным в её внешней функции. И этот доступ – не просто временная передача значений, а постоянная ссылка на ячейки памяти, где эти значения хранятся.

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

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

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

Например, создадим функцию make_adder(), которая принимает число n и возвращает внутреннюю функцию adder(). Эта функция принимает число число x и возвращает сумму чисел n и x:

def make_adder(n: int) -> "function":
    def adder(x: int) -> int:
        return x + n
    return adder

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

Если мы просто вызовем функцию make_adder(), передав ей какое-то число, ничего не произойдёт, так как она возвращает ссылку на внутреннюю функцию adder(), но мы можем сохранить этот объект в отдельной переменной:

adder_5 = make_adder(5) # n = 5
adder_10 = make_adder(10) # n = 10

Здесь каждый вызов make_adder() создает свою уникальную копию переменной n, к которой имеет доступ только возвращаемое замыкание. Поэтому adder_5 и adder_10 работают независимо друг от друга, и мы можем передать им разные числа:

print(adder_5(10))  # n = 5, x = 10
# Вывод: 15 
print(adder_10(10)) # n = 10, x = 10
# Вывод: 20

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

Декораторы

Замыкания находят своё практическое применение в декораторах, которые добавляют новую функциональность к существующим функциям, не изменяя их код. По сути, декоратор – это функция, которая «оборачивает» другую функцию, расширяет её поведение и возвращает новую, изменённую функцию.

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

Робот Кеша читает

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

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

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

Декорирование функции без параметров

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

def log_decorator(func: "function") -> "function":
    def wrapper() -> None:
        print(f"Функция начинает работу...")
        func()
        print(f"Работа функции завершена")
    return wrapper

Здесь функция log_decorator() принимает функцию func и возвращает вложенную функцию wrapper, которая помнит функцию func благодаря механизму замыкания. То есть после использования декоратора имя функции func будет указывать не на исходную функцию, а на дополненную функцию wrapper. Такую функцию часто называют обёрткой.

Для применения декоратора к функции необходимо указать его имя с символом @ перед ним на строке до объявления этой функции:

@log_decorator
def say_smth_nice() -> None:
    print("Хочу сказать тебе, что ты молодец!")


say_smth_nice()
# Вывод: Функция начинает работу...
# Вывод: Хочу сказать тебе, что ты молодец!
# Вывод: Работа функции завершена

Здесь, когда мы вызываем функцию say_smth_nice(), на самом деле выполняется вложенная функция декоратора @log_decorator, которая сначала выводит на экран строку "Функция начинает работу...", затем вызывает исходную say_smth_nice(), а после этого выводит строку "Работа функции завершена".

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

say_smth_nice = log_decorator(say_smth_nice)
say_smth_nice()
# Вывод: Функция начинает работу...
# Вывод: Хочу сказать тебе, что ты молодец!
# Вывод: Работа функции завершена

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

Декорирование функции с параметрами

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

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

def check_positive(func: "function") -> "function":
    def wrapper(*args, **kwargs):
        if any(arg < 0 for arg in args):
            print("Ошибка: переданы отрицательные числа")
            return None
        return func(*args, **kwargs)
    return wrapper

Здесь вложенная функция wrapper принимает все аргументы декорируемой функции func, и проверяет, что все аргументы args больше нуля. Тип возвращаемого значения функции wrapper() зависит от того, какую функцию мы декорируем, поэтому здесь он не указывается.

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

@check_positive
def multiply(x: int, y: int) -> int:
    return x * y


result1 = multiply(5, 10)
print(f"Результат: {result1}")
# Вывод: Результат: 50

result2 = multiply(5, -10)
# Вывод: Ошибка: переданы отрицательные числа
print(f"Результат: {result2}")
# Вывод: Результат: None

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

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

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

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

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

def access_level(required_level):
    # Внешняя функция (принимает параметр декоратора)
    def decorator(func):
        # Промежуточная функция (принимает декорируемую функцию)
        def wrapper(user_level, *args, **kwargs):
            # Внутренняя функция (принимает аргументы декорируемой функции)
            if user_level >= required_level:
                print("Доступ разрешен")
                return func(user_level, *args, **kwargs)
            else:
                print("Ошибка доступа")
                return
        return wrapper
    return decorator

Здесь декоратор принимает параметр required_level – уровень доступа и, если уровень доступа пользователя, передаваемый из декорируемой функции больше или равен ему – декоратор разрешает доступ к данным, иначе – запрещает. При этом внешняя функция access_level() выступает в роли фабрики декораторов, то есть она принимает значение параметра и возвращает декоратор – функцию decorator().

Такой декоратор универсален и позволяет задать любой уровень доступа:

@access_level(required_level=5)
def sensitive_data_access(user_level: int) -> None:
    print(f"Уровень прав пользователя: {user_level}")
    # Какая-то очень секретная логика


sensitive_data_access(user_level=10)
# Вывод: Доступ разрешен.
# Вывод: Уровень прав пользователя: 10

sensitive_data_access(user_level=3,)
# Вывод: Ошибка доступа

Здесь уровень прав доступа для вызова функции sensitive_data_access() должен быть выше 5, поэтому декоратор вызывает функцию для пользователя с уровнем 10, но сообщает об ошибке доступа для пользователя с уровнем 3.

Примеры

Пример 1. Калькулятор налога на доходы физических лиц

В разных странах величина налога на доходы физических лиц отличается. Например, в России он может быть равен 13 %, а в США – 22 % (число может меняться в зависимости от условий). Решение задачи разработки калькулятора, подсчитывающего НДФЛ, может решаться напрямую путём создания отдельной функции для каждой страны:

def calculate_tax_rus(salary: int) -> float:
    """Возвращает НДФЛ в России
    """
    return salary * 0.13

def calculate_tax_usa(salary: int) -> float:
    """Возвращает НДФЛ в Америке
    """
    return salary * 0.22


print(f"Налог в России на 1000 рублей: {calculate_tax_rus(1000)} руб.")
print(f"Налог в США на 1000 долларов: {calculate_tax_usa(1000)} $")

Вывод:

Налог в России на 1000 рублей: 130.0 руб.
Налог в США на 1000 долларов: 220.0 $

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

def tax_calculator_factory(tax_rate: float) -> "function":
    """Возвращает НДФЛ в зависимости от ставки.
    """
    def calculate_tax(salary: int) -> float:
        return salary * tax_rate
    return calculate_tax


# Создание функции для расчёта НДФЛ по ставке 13 %
calculate_tax_rus = tax_calculator_factory(0.13)
print(f"Налог в России на 1000 рублей: {calculate_tax_rus(1000)} руб.")

# Создание функции для расчёта НДФЛ по ставке 22 %
calculate_tax_usa = tax_calculator_factory(0.22)
print(f"Налог в США на 1000 долларов: {calculate_tax_usa(1000)} $")

Вывод:

Налог в России на 1000 рублей: 130.0 руб.
Налог в США на 1000 долларов: 220.0 $

Пример 2. Мемоизация результатов функции

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

Декоратор memoize() хранит все результаты вызовов функции func в словаре cache. Вложенная функция wrapper сначала проверяет, есть ли результат для текущих аргументов в кэше. Если есть – возвращает его, иначе – вызывает исходную функцию, сохраняет результат в кэше и только потом возвращает:

def memoize(func):
    """Декоратор для кэширования результатов функции.
    """
    cache = {}
    def wrapper(*args):
        """Внутренняя функция декоратора, которая принимает все позиционные              
        аргументы декорируемой функции и использует их (кортеж args)
        в качестве ключа словаря с кэшем.
        """
        if args in cache:
            print("Возвращаем результат из кэша...")
            return cache[args]
        
        print("Вычисляем результат...")
        result = func(*args)
        cache[args] = result
        return result
    return wrapper


@memoize
def expensive_calculation(n: int) -> int:
    """Возвращает число n, возведённое в степень 10.
    
    Параметры:
        n: Число, которое возводится в степень.
        
    Возвращает:
        Число n в степени 10. 
    """
    return n ** 10


# Вызов функции несколько раз с одинаковыми аргументами
expensive_calculation(5)
expensive_calculation(5)
expensive_calculation(10)
expensive_calculation(10)
expensive_calculation(5)

Вывод:

Вычисляем результат...
Возвращаем результат из кэша...
Вычисляем результат...
Возвращаем результат из кэша...
Возвращаем результат из кэша...

Пример 3. Форматирование вывода

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

Внешняя функция декоратора format_output() принимает параметры декоратора: строку prefix, которой должен начинаться вывод, строку suffix, которой должен заканчиваться вывод, и флаг to_upper для приведения к верхнему регистру. Вложенная функция wrapper() вызывает исходную функцию, а затем применяет к ее результату настройки форматирования, переданные при применении декоратора к функции:

def format_output(prefix: str="", suffix: str="", to_upper: bool=False):
    """Декоратор с параметрами ля форматирования вывода результата функ-ции.
    
    Параметры:
        prefix: Строка, которой начинается вывод результата функции.
        suffix: Строка, которой заканчивается вывод результата функции.
        to_upper: Флаг приведения к верхнему регистру. 
            По умолчанию False, то есть регистр не меняется.
    """
    def decorator(func):
        def wrapper(*args, **kwargs):
            result = str(func(*args, **kwargs))
            if to_upper:
                result = result.upper()
            return f"{prefix}{result}{suffix}"
        return wrapper
    return decorator


@format_output(prefix="-> ", suffix=" <-", to_upper=True)
def get_user_status(status: str) -> str:
    """Возвращает отформатированную строку со статусом пользователя.
    
    Параметры:
        status: Статус пользователя.
        
    Возвращает:
        Отформатированная строка.
    """
    return f"Статус пользователя: {status}"


print(get_user_status("активен"))


@format_output(prefix="[INFO]: ")
def get_log_message(message: str) -> str:
    """Возвращает отформатированную строку с системным сообщением.
    
    Параметры:
        message: Системное сообщение.
        
    Возвращает:
        Отформатированная строка.
    """
    return f"Лог: {message}"


print(get_log_message("Система запущена"))

Вывод:

-> СТАТУС ПОЛЬЗОВАТЕЛЯ: АКТИВЕН <-
[INFO]: Лог: Система запущена

Итоги

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

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

1. Что такое замыкание? Какие условия должны быть выполнены для его создания?

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

2. Объясните, что такое декоратор и из чего он состоит.

Декоратор – это функция высшего порядка, которая изменяет поведение другой функции, не изменяя её исходный код. Он представляет собой функцию, которая содержит другую функцию. При этом внешняя функция принимает декорируемую функцию и возвращает вложенную функцию, которая принимает аргументы декорируемой функции и вызывает её.

3. Найдите все ошибки в этом коде и исправьте их так, чтобы на экран были последовательно выведены числа 1, 2 и 3.

def create_counter() -> "function":
    count = 0
    def increment() -> int:
        count += 1
        return count
    return increment


counter = increment()
print(counter()) 
# Вывод: 1
print(counter()) 
# Вывод: 2
print(counter()) 
# Вывод: 3
def create_counter() -> "function":
    count = 0
    def increment() -> int:
        nonlocal count 
        count += 1
        return count
    return increment


counter = create_counter()
print(counter()) 
# Вывод: 1
print(counter()) 
# Вывод: 2
print(counter()) 
# Вывод: 3

Здесь пропущено ключевое слово nonlocal перед переменной count, а также переменной counter следует присвоить внешнюю функцию create_counter().

4. Допишите декоратор print_start_end(), который выводит сообщение "Начало выполнения" перед вызовом декорируемой функции process_data() и "Конец выполнения" после её завершения:

def print_start_end():
    pass


@print_start_end
def process_data(data: str) -> None:
    print(f"Обработка данных: {data}")


process_data("Секретные материалы")
def print_start_end(func: "function") -> "function":
    def wrapper(*args, **kwargs):
        print("Начало выполнения")
        result = func(*args, **kwargs)
        print("Конец выполнения")
        return result
    return wrapper


@print_start_end
def process_data(data: str) -> None:
    print(f"Обработка данных: {data}")


process_data("Секретные материалы")
# Вывод: Начало выполнения
# Вывод: Обработка данных: Секретные материалы
# Вывод: Конец выполнения

5. Допишите декоратор repeat(n), который принимает число n и повторяет вызов декорируемой функции say_a_poem() n раз.

def repeat():
    pass


@repeat(n=3)
def say_a_poem() -> None:
    print("Однажды, в студёную зимнюю пору\n"
        "Я из лесу вышел; был сильный мороз.\n")


say_a_poem()
def repeat(n: int):
    def decorator(func: "function") -> "function":
        def wrapper(*args, **kwargs) -> None:
            for _ in range(n):
                func(*args, **kwargs)
        return wrapper
    return decorator


@repeat(n=3)
def say_a_poem() -> None:
    print("Однажды, в студёную зимнюю пору\n"
          "Я из лесу вышел; был сильный мороз.")


say_a_poem()
# Вывод: Однажды, в студёную зимнюю пору
# Вывод: Я из лесу вышел; был сильный мороз.
# Вывод: Однажды, в студёную зимнюю пору
# Вывод: Я из лесу вышел; был сильный мороз.
# Вывод: Однажды, в студёную зимнюю пору
# Вывод: Я из лесу вышел; был сильный мороз.