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

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

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

Замыкание (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)


  1. Vindicar
    20.12.2023 08:45

    Мне пригодился nonlocal только один раз, когда делал хитровымудренный декоратор на троттлинг (ограничение частоты вызовов) асинхронной функции. Заодно понял, почему nonlocal-переменные - это private для бедных. Классом, наверно, было бы проще.


    1. milssky
      20.12.2023 08:45

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


  1. SwetlanaF
    20.12.2023 08:45

    Здравствуйте! Большое спасибо за статью, как раз разбираюсь с функциями, как и что туда передается.

    Методом "научного тыка" обнаружила следующее. Если в функцию не передаём параметры, то конфликт имён (глобальная и локальная переменная с одним именем) вызывает ошибку. Если же глобальная переменная передается через параметр, а затем с именем параметра об'является локальная - никакого конфликта нет. Как-то нелогично.

    Может быть, где-то можно про это прочитать, в чем профит?


    1. 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()


      1. SwetlanaF
        20.12.2023 08:45

        Ещё раз спасибо за статью. Всё понятно, осталось только посмотреть пример использования счетчика. Я просто немного в шоке от питона, т.к. ранее писала только на двух простых языках: паскале и прологе. Да, и жду вторую часть))


  1. A-V-tor
    20.12.2023 08:45

    Можно вечно смотреть на 3 вещи:
    - огонь

    - воду

    - и как объясняют концепцию декораторов для питона


    1. sukhe
      20.12.2023 08:45

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


      1. WhiteApfel
        20.12.2023 08:45

        *впал в ступор* (к) рандомный питонист.

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


        1. 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()


          1. milssky
            20.12.2023 08:45

            А вот если используется wraps все гораздо проще :)


  1. syrus_the_virus
    20.12.2023 08:45

    Ребят, ваша проблема в том, что прежде чем начинать что-то объяснять, надо определить проблему. Нужно начинать с того - зачем это нужно и почему, а потом уже рассказывать, как оно работает и как устроено.


    1. Nikola_Piterskiy
      20.12.2023 08:45

      Полностью согласен, больше практических примеров. Особенно сравнительных, ну типа вот если без замыкания то вот 200 строк, с замыканием 20.


  1. conopus
    20.12.2023 08:45

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


  1. Nikola_Piterskiy
    20.12.2023 08:45

    А сколько максимально вложений может быть def def def ?


    1. 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)

      спасибо чатгпт, что мне не нужно писать это руками