Изначально этот пост опубликован в моем личном блоге. Если у вас возникли проблемы с чтением примеров кода, обязательно загляните в мой блог. У меня там есть подсветка синтаксиса.

«Замыкание - это черепаха, несущая панцирь»,

Мое любимое объяснение закрытий, цитата Раймонда Хеттингера. Благодаря замыканиям в Python возможно множество замечательных функций, таких как функции и декораторы более высокого порядка.

Я наткнулся на этот пост на StackOverflow - Как я могу вернуть функцию, использующую значение переменной?, Который помог мне восполнить пробел в моих знаниях, пробел, который я вроде бы понял, но не мог объяснить. Что ж. Мост - это простое предложение: закрытие связывается поздно. Что именно означает закрытие выполняется с опозданием? Рассмотрим генератор функций, который генерирует серию функций умножения, начиная с 0 до max:

def mult_function_generator(max):
    for i in range(max):
        yield lambda x: x * i
mult_functions = list(mult_function_generator(3))
print([func(3) for func in mult_functions])
# Oh crap, it’s [6, 6, 6], not [0, 3, 6]

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

mult_functions = mult_function_generator(3)
mult_zero = next(mult_functions)
mult_zero
# <function …>

Чтобы посмотреть на замыкание, в каждой функции есть специальный атрибут __closure__ (совершенно очевидно, да?)

mult_zero.__closure__
# (<cell at 0x…, int object at 0x…>,)

Ладно, мы приближаемся. Атрибут __closure__ - это набор из cell объектов. Давайте посмотрим, что внутри ячейки, у которой есть атрибут cell_contents, который нам поможет:

cell_zero = mult_zero.__closure__[0]
cell_zero.cell_contents
# 0

Бинго! Это объект типа int i, который застрял внутри закрытия функции. (Я не понял, как Python находит ссылку на i, поэтому не буду углубляться в подробности. Более подробное обсуждение см. в разделе "Приложение".)

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

mult_one = next(mult_functions)
cell_one = mult_one.__closure__[0]
cell_one.cell_contents
# 1
# So far so good. But…
cell_zero.cell_contents
# 1
# Crap!
cell_zero is cell_one
# True
# Okay fine

Один и тот же объект ячейки означает такую ​​же ссылку на i. Это объясняет, что произошло в этом примере. Или, если хотите, вы можете объяснить все это, сказав: «Закрытие выполняется поздно». Они оба верны, но имеют разные ментальные модели.

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

def mult_function_generator(max):
    for i in range(max):
        yield (lambda i: lambda x: x * i)(i)

Или, если вам нравятся частичные функции:

import functools
def mult_function_generator(max):
    for i in range(max):
        yield functools.partial(lambda x, i: x * i, i=i)

Или мой любимый:

def mult_function_generator(max):
    for i in range(max):
        yield lambda x, i=i: x * i

Последний работает, потому что аргументы по умолчанию назначаются при определении функции.

Бонус

Мой любимый пример использования преимущества позднего закрытия привязки - из 20 библиотек Python, которые вы не используете (но должны):

from time import perf_counter
from array import array
from contextlib import contextmanager

@contextmanager
def timing(label: str):
    t0 = perf_counter()
    yield lambda: (label, t1 — t0)
    t1 = perf_counter()

with timing(‘Array tests’) as total:
    with timing(‘Array creation innermul’) as inner:
        x = array(‘d’, [0] * 1000000)
    with timing(‘Array creation outermul’) as outer:
        x = array(‘d’, [0]) * 1000000

print(‘Total [%s]: %.6f s’ % total())
print(‘  Timing [%s]: %.6f s’ % inner())
print(‘  Timing [%s]: %.6f s’ % outer())
# Total [Array tests]: 0.064896 s
#   Timing [Array creation innermul]: 0.064195 s
#   Timing [Array creation outermul]: 0.000659 s

Приложение - обсуждение загрузки разыменованной переменной

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

3 0 LOAD_FAST 0 (x)
  2 LOAD_DEREF 0 (i)
  4 BINARY_MULTIPLY
  6 RETURN_VALUE

Вторая строка вызывает код операции LOAD_DEREF в Python. Я предполагаю, что имя i никогда не разыменовывалось, потому что объект ячейки все еще содержит ссылку на него. Однако с i нужно обращаться особым образом, поскольку i не виден области за пределами функций закрытия. Следующим шагом будет изучение кода CPython, чтобы увидеть, как именно работает этот операционный код.