Замыкания и декораторы в Python: часть 1 — замыкания

25d46959ea4e41716e388a532eb35354.png

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

При написании этого туториала, я ожидаю, что читатель уже знаком с понятием «область видимости» (неплохая статья).

От простого к сложному

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

def outers(): 
    n = 2

    def closure(): 
        return n ** 2 
    return closure


closure_foo = outers()      # Вызываем внешнюю функцию, возвращаемая функция (замыкание) присваивается переменной 
print(closure_foo)          # .closure at 0x7f254d6fe170> 
num = closure_foo()         # Вызываем замыкание, результат присваивается переменной 
print(num)                  # 4 

# Второй вариант вызова замыкания 
print(outers()())           # 4

На примере видно, что функция closure имеет доступ к переменной n определенной в родительской функции, несмотря на то, что интерпретатор уже не находится в соответствующей зоне видимости.

Второй вариант вызова замыкания не сложно понять, если проследить эволюцию значений:

f40880a2078460e0375e66b4521765da.gif

Скобки после имени функции говорят интерпретатору о том, что ее необходимо вызвать. После вызова outers(), на ее место возвращается замыкание closure, к которому добавляется оставшаяся пара скобок. Замыкание вызывается, возвращая на свое место результат.

Немного истории о замыканиях в Python

«В ранних версиях Python (до Python 2.2) вложенные операторы def ничего не делали в отношении областей видимости. В показанном ниже коде ссылка на переменную внутри f2 инициировала бы поиск только в локальной (f2), далее в глобальной (код вне f1) и затем во встроенной области видимости. Из-за того, что поиск пропускал области видимости объемлющих функций, результатом была ошибка. В качестве обходного приема программисты обычно применяли стандартные значения аргументов для передачи и запоминания объектов в объемлющей области видимости:

def f1():
    х = 88

    def f2(х=х):    # Запоминает X из объемлющей области видимости
                    # посредством стандартных значений
        print(х)
    f2()


f1()    # Выводит 88

Такой стиль написания кода подходит для всех выпусков Python и вы будете по-прежнему встречать данный шаблон в существующем коде Python. На самом деле, как вскоре будет показано, он все еще обязателен для переменных цикла и потому заслуживает изучения даже в наши дни. Если кратко, то синтаксис arg=val в заголовке def означает, что аргумент arg по умолчанию получит значение val, когда никакого реального значения для arg в вызове не передается. Здесь этот синтаксис используется для явной установки подлежащего сохранению состояния из объемлющей области видимости»

Марк Лутц «Изучаем Python».

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

def outers(lst):

    def closure():
        return lst[0] * 2
    return closure


x = ['a']
closure_foo = outers(x)    # Вызываем внешнюю функцию, передав ей список в качестве аргумента
print(closure_foo())       # aa

x[0] = 'b'                 # меняем единственный элемент списка
print(closure_foo())       # bb

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

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

def multiplier(factor):

    def closure(x):
        return factor * x
    return closure


double = multiplier(2)
triple = multiplier(3)

print(double(5))  # 10 результат аналогичен вызову multiplier(2)(5)
print(triple(4))  # 12 результат аналогичен вызову multiplier(3)(4)

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

Так же для создания замыкания может использоваться анонимная функция.

def modify(foo):
    return lambda x: foo(x)


"""
# результат аналогичен обычному синтаксису
def modify(foo):

    def closure(x):
        return foo(x)
    return closure
"""


to_str = modify(str)
to_str(152)              # '152'
to_bool = modify(bool)
to_bool('John Cena')     # True
to_bool('')              # False
adder = modify(lambda x: x + 1)
adder(152)               # 153

В данном примере функции modify передаются различные функции (в том числе анонимные). Полученное замыкание возвращает результат применения функции к своему аргументу.

Почти настоящий код

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

def count_calls():
    counter = 0

    def closure(print_result=False):
        nonlocal counter
        if print_result:
            return counter
        counter += 1
        return counter
    return closure


counter = count_calls()     # Вызвав функцию, получаем счетчик (замыкание)

for _ in range(5):
    counter()               # Вызываем счетчик

print(counter(True))        # Проверяем результат подсчета: 5

for _ in range(2):
    counter()

print(counter(1))           # 7

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

Пример использования счетчика в учебном проекте

Для игры на угадывание типа числа (простое/составное) нужно это число сгенерировать. Предполагается, что randrange или randint генерирует числа из некоторого диапазона с равной вероятностью. Поскольку в любом диапазоне (длиной более 4 элементов) составных чисел больше, чем простых, вероятность получить составное число при простой генерации выше. Что бы уровнять вероятность получения простого или составного числа, я решил, что буду сначала с помощью choice определять тип генерируемого числа, а потом уже генерировать искомое. Чатгпт предложил сначала собирать 2 списка для чисел в нужном диапазоне (я использовал от 1 до 200) и далее с помощью choice выбирать случайное число из нужного списка. Мне это решение показалось не очень оптимальным. Минимум 200 итераций на создании списка, и это при условии, что я смогу генерировать списки только раз на все 3 раунда. Я решил, что нужно генерировать число, а потом просто прибавлять к нему единицу пока его тип не будет соответствовать заданному.
К получившемуся коду я добавил счетчик, что бы узнать, сколько реально итераций использует мое решение. Сгенерировал 100_000 чисел в диапазоне от 1 до 200:
«Максимальное количество итераций (13) потребовалось при генерации числа 127»
Пошел дальше и сгенерировал 100_000 чисел от 1 до 10_000_000:
«Максимальное количество итераций (147) потребовалось при генерации числа 4652507»
В итоге, как мне кажется, у меня получилось достаточно эффективное решение.

Код
import random


def count_calls():
    counter = 0

    def closure(print_result=False):
        nonlocal counter
        if print_result:
            return counter
        counter += 1
        return counter
    return closure


def is_prime(num: int) -> bool:
    for divisor in range(2, int(num ** (0.5)) + 1):
        if num % divisor == 0:
            return False
    return True


def generate_num_and_check_is_prime():

    def _cast_num_to_target_type(num, prime) -> int:
        while prime != is_prime(num):
            num += 1
            counter()
        return num

    prime = True    # random.choice([True, False])
    random_num = random.randint(1, 10_000_000)
    num = _cast_num_to_target_type(random_num, prime)
    return num


attempts_max = 0
attempts_max_num = 0
for i in range(100_000):
    counter = count_calls()
    num = generate_num_and_check_is_prime()
    attempts = counter(1)
    if attempts > attempts_max:
        attempts_max = attempts
        attempts_max_num = num

print(
    f'Максимальное количество итераций ({attempts_max}) '
    f'потребовалось при генерации числа {attempts_max_num}'
)

Вместо вывода

В своей книге Марк Лутц пишет: «Разумеется, наилучшая рекомендация для большей части кода заключается в том, чтобы избегать вложения операторов def внутрь def, т.к. тогда программа станет гораздо проще — согласно духу Python плоский код, как правило, лучше вложенного». Однако важно отметить, что существует множество ситуаций, в которых от использования замыканий отказаться нельзя, так как их применение является оптимальным и предпочтительным решением. Понимание механизмов их работы является ключевым для освоения более сложных концепций, таких как декораторы, о которых будет рассказано в следующей части.

© Habrahabr.ru