02 / 72
ТеорияБазовыйMiddle

Замыкания и nonlocal

Частота на собеседованиях: Высокая - база под декораторы и частый вопрос-ловушка на late bindingСпрашивают в: Т-Банк, Яндекс, Big Tech-style

В прошлом уроке была функция make_multiplier, которая создавала и возвращала multiplier. И там оставался открытый вопрос: как multiplier умудряется помнить factor, если make_multiplier уже давно закончила работу и её локальные переменные вроде бы должны были исчезнуть?

Аналогия: представь, что ты уезжаешь в отпуск и оставляешь соседу ключ от квартиры с запиской "полей цветы, пока меня нет". Тебя физически уже нет дома, но сосед всё равно помнит про твою просьбу и про то, где искать цветы. Записка - это как раз то, что удерживает нужную информацию живой даже после того, как хозяин ушёл. Вот это и есть замыкание (closure): внутренняя функция "запоминает" переменные из внешней функции, даже когда внешняя функция уже отработала и вышла.

Как это выглядит в коде

python
Загрузка редактора...

Вывод:

1 2 3

make_counter() отработала один раз и вернула функцию increment. Но increment продолжает помнить переменную count и продолжает её изменять при каждом вызове. Причём у каждого счётчика своя собственная память:

python
Загрузка редактора...

Вывод:

1

counter2 начинает считать с нуля заново, потому что это отдельный вызов make_counter(), а значит отдельная "записка" с отдельным значением count.

Зачем нужен nonlocal

Вот тут обычно и спотыкаются. Смотри, что будет, если убрать nonlocal:

python
Загрузка редактора...

Это упадёт с ошибкой:

UnboundLocalError: cannot access local variable 'count' where it is not associated with a value

Дело в том, что строчка count += 1 - это на самом деле count = count + 1, то есть присваивание. А как только Python видит присваивание переменной внутри функции, он автоматически считает эту переменную локальной для всей функции целиком, даже если присваивание идёт после чтения. Интерпретатор смотрит на тело increment заранее, видит там count = ... и решает: значит count тут своя, локальная. И когда доходит до count += 1, пытается сначала прочитать локальную count, а её ещё не существует. Отсюда и UnboundLocalError.

Просто читать внешнюю переменную можно без всяких ключевых слов:

python
Загрузка редактора...

Вывод:

x видно снаружи: 10

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

Кстати, если тебе нужно менять переменную не из объемлющей функции, а из глобальной области видимости модуля, для этого есть global. Работает по такому же принципу, просто уровень видимости другой: nonlocal смотрит на ближайшую объемлющую функцию, global сразу идёт в область видимости модуля.

Как это устроено внутри: ячейки замыкания

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

python
Загрузка редактора...

Вывод:

(<cell at 0x102794700: int object at 0x1032462f8>,) 0

__closure__ это кортеж объектов-ячеек, cell_contents показывает текущее значение внутри ячейки. Именно поэтому nonlocal вообще может работать: он не копирует значение, он меняет содержимое общей ячейки, к которой обращаются и внешняя, и внутренняя функция.

Классическая ловушка: замыкание в цикле

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

python
Загрузка редактора...

Интуитивно кажется, что должно получиться [0, 1, 2], ведь на каждой итерации i было разным. А на деле:

[2, 2, 2]

Тут работает так называемое late binding, позднее связывание. Замыкание запоминает не значение i в момент создания функции, а саму переменную i целиком, точнее ссылку на её ячейку. А переменная i - одна на весь цикл, и после цикла она равна 2. Когда мы вызываем f() уже после цикла, Python идёт смотреть в ячейку i и находит там финальное значение.

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

python
Загрузка редактора...

Вывод:

[0, 1, 2]

i=i в сигнатуре выглядит непривычно, но означает ровно следующее: "создай параметр с именем i, а значением по умолчанию возьми то, что было в переменной i прямо сейчас, в этот конкретный момент прохода цикла". Значения по умолчанию вычисляются один раз, в момент определения функции, и дальше не меняются - в отличие от переменных внутри тела функции.

Проверь себя

  • Замыкание - это когда внутренняя функция помнит переменные внешней функции даже после её завершения.
  • nonlocal нужен только для изменения внешней переменной, для чтения он не требуется.
  • Присваивание внутри функции автоматически делает переменную локальной для всей функции целиком, даже до самого присваивания по тексту - отсюда UnboundLocalError.
  • Замыкание хранит ссылку на переменную, а не её копию на момент создания - отсюда ловушка позднего связывания в циклах.
  • Лечится ловушка через параметр по умолчанию: def f(i=i).

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