Впервые столкнувшись с концепцией замыканий, я мало что понял, и мне потребовалось потратить какое-то время на поиск и изучение дополнительных материалов, чтобы разобраться. Если у вас возникли те же проблемы, я постараюсь коротко, но понятно объяснить эту тему.
При написании этого туториала, я ожидаю, что читатель уже знаком с понятием "область видимости" (неплохая статья).
От простого к сложному
Замыкание (closure) или фабричная функция это функция определяемая и возвращаемая другой функцией, при этом замыкание получает доступ к значениям и объектам в области видимости "родительской" (или объемлющей) функции независимо от того из какой области видимости происходит вызов замыкания.
def outers():
n = 2
def closure():
return n ** 2
return closure
closure_foo = outers() # Вызываем внешнюю функцию, возвращаемая функция (замыкание) присваивается переменной
print(closure_foo) # <function outers.<locals>.closure at 0x7f254d6fe170>
num = closure_foo() # Вызываем замыкание, результат присваивается переменной
print(num) # 4
# Второй вариант вызова замыкания
print(outers()()) # 4
На примере видно, что функция closure
имеет доступ к переменной n
определенной в родительской функции, несмотря на то, что интерпретатор уже не находится в соответствующей зоне видимости.
Второй вариант вызова замыкания не сложно понять, если проследить эволюцию значений:
Скобки после имени функции говорят интерпретатору о том, что ее необходимо вызвать. После вызова 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 плоский код, как правило, лучше вложенного». Однако важно отметить, что существует множество ситуаций, в которых от использования замыканий отказаться нельзя, так как их применение является оптимальным и предпочтительным решением. Понимание механизмов их работы является ключевым для освоения более сложных концепций, таких как декораторы, о которых будет рассказано в следующей части.
Комментарии (15)
SwetlanaF
20.12.2023 08:45Здравствуйте! Большое спасибо за статью, как раз разбираюсь с функциями, как и что туда передается.
Методом "научного тыка" обнаружила следующее. Если в функцию не передаём параметры, то конфликт имён (глобальная и локальная переменная с одним именем) вызывает ошибку. Если же глобальная переменная передается через параметр, а затем с именем параметра об'является локальная - никакого конфликта нет. Как-то нелогично.
Может быть, где-то можно про это прочитать, в чем профит?
vladislav_smirnov Автор
20.12.2023 08:45Добрый день!
Не совсем понял о какой ошибке речь. Может проблема в том что вы пытаетесь изменить значение переменной, до того как ей будет что-то присвоено?a = 5 def foo(): a = 10 print(a) foo() # 10
Этот код выполнится, не смотря на то что имена локальной и глобальной переменной совпадают. Если же вы попробуете внутри функции изменить значение переменной, например `a += 10` тогда возникнет ошибка, т.к. интерпретатор не видит в локальной области видимости переменную с именем
a
. Что бы не сталкиваться с ней, я вижу 3 пути: первый, плохой, объявить внутри функции `global a`. Второй ваш, передать функции переменную в качестве аргумента. Третий, использовать внутри переменную с другим именем.a = 5 def foo(): b = a + 10 print(b) foo()
SwetlanaF
20.12.2023 08:45Ещё раз спасибо за статью. Всё понятно, осталось только посмотреть пример использования счетчика. Я просто немного в шоке от питона, т.к. ранее писала только на двух простых языках: паскале и прологе. Да, и жду вторую часть))
A-V-tor
20.12.2023 08:45Можно вечно смотреть на 3 вещи:
- огонь- воду
- и как объясняют концепцию декораторов для питона
sukhe
20.12.2023 08:45Декоратор кто угодно напишет. А вот когда просишь рандомного питониста сделать обратную операцию - "раздекорировать" декорированную функцию, он, обычно, впадает в ступор.
WhiteApfel
20.12.2023 08:45*впал в ступор* (к) рандомный питонист.
Но если подумать, то, насколько помню, есть атрибут, который содержит порядок применения декораторов. Если не изменяет память, то он содержит оригинальные объекты функций, но это совсем не точно, даже интересно почитать доку на этот счёт
WhiteApfel
20.12.2023 08:45Для тех, кто впал в ступор и всё ещё не выпал:
import inspect from types import FunctionType def decorator(func): def wrapper(): print("before") func() print("after") return wrapper @decorator @decorator def my_function(): print("func") print("Decorated:") my_function() def extract_wrapped(decorated): closure = decorated.__closure__ if closure: for cell in closure: if isinstance(cell.cell_contents, FunctionType) and cell.cell_contents.__closure__ is None: return cell.cell_contents else: return extract_wrapped(cell.cell_contents) return None original = extract_wrapped(my_function) print("\nOriginal source:") print(inspect.getsource(original)) print("\nOriginal:") original()
syrus_the_virus
20.12.2023 08:45Ребят, ваша проблема в том, что прежде чем начинать что-то объяснять, надо определить проблему. Нужно начинать с того - зачем это нужно и почему, а потом уже рассказывать, как оно работает и как устроено.
Nikola_Piterskiy
20.12.2023 08:45Полностью согласен, больше практических примеров. Особенно сравнительных, ну типа вот если без замыкания то вот 200 строк, с замыканием 20.
conopus
20.12.2023 08:45"Однако важно отметить, что существует множество ситуаций, в которых от использования замыканий отказаться нельзя" -- для меня такой пример это написание параметризованных фикстур для pytest.
Nikola_Piterskiy
20.12.2023 08:45А сколько максимально вложений может быть def def def ?
vladislav_smirnov Автор
20.12.2023 08:45Я не думаю, что есть технические ограничения в кол-во вложений, т.к. "глубина" определяется в момента определения функции, а не в момент выполнения, как с рекурсией.
С другой стороны, нужно писать понятный код, потому что его кому-то придется поддерживать. 3 уровня замыканий используются в декораторах с параметрами. Большее кол-во уровней вложений мне сейчас в голову не приходит.Но это работает
def create_closure(a): def level_two(b): def level_three(c): def level_four(d): def level_five(e): def level_six(f): def level_seven(g): def level_eight(h): def level_nine(i): def level_ten(j): def level_eleven(k): def level_twelve(l): def level_thirteen(m): def level_fourteen(n): def level_fifteen(o): def level_sixteen(p): def level_seventeen(q): def level_eighteen(r): def level_nineteen(s): def level_twenty(t): def level_twenty_one(u): def level_twenty_two(v): def level_twenty_three(w): def level_twenty_four(x): def level_twenty_five(y): return a + b + c + d + e + f + g + h + i + j + k + l + m + n + o + p + q + r + s + t + u + v + w + x + y return level_twenty_five return level_twenty_four return level_twenty_three return level_twenty_two return level_twenty_one return level_twenty return level_nineteen return level_eighteen return level_seventeen return level_sixteen return level_fifteen return level_fourteen return level_thirteen return level_twelve return level_eleven return level_ten return level_nine return level_eight return level_seven return level_six return level_five return level_four return level_three return level_two result = create_closure(1)(2)(3)(4)(5)(6)(7)(8)(9)(10)(11)(12)(13)(14)(15)(16)(17)(18)(19)(20)(21)(22)(23)(24)(25) print(result)
спасибо чатгпт, что мне не нужно писать это руками
Vindicar
Мне пригодился nonlocal только один раз, когда делал хитровымудренный декоратор на троттлинг (ограничение частоты вызовов) асинхронной функции. Заодно понял, почему nonlocal-переменные - это private для бедных. Классом, наверно, было бы проще.
milssky
Так замыкания тоже можно считать классами для бедных. Финт ушами, позволяющий хранить и изменять данные