Замыкания, LEGB и nonlocal: функции как объекты первого класса
core написать замыкание, которое корректно захватывает внешнюю переменную, и объяснить типичную ошибку захвата переменной цикла
В C функция — это, по сути, указатель на код, и у неё нет собственного состояния. Если callback'у нужны данные, вы вручную собираете структуру контекста и передаёте указатель на неё в каждый вызов:
struct counter_context { int count; };
void callback(void* context) {
struct counter_context* ctx = (struct counter_context*)context;
ctx->count++;
printf("Count is now %d\n", ctx->count);
}
Функция и её данные — две отдельные сущности, связанные вручную.
В Python это встроено в язык: функция, определённая внутри другой функции, автоматически «захватывает» переменные из окружающей области видимости. Такая пара «функция + захваченные переменные» называется замыканием (closure). Это прямая замена паттерна «контекст + callback», только структуру контекста и связывание генерирует сам интерпретатор.
Правило LEGB и nonlocal
Чтобы понимать, что вообще захватывается, нужно правило поиска имён LEGB: Local → Enclosing → Global → Built-in. Интерпретатор ищет имя сначала в локальной области текущей функции, затем в областях всех объемлющих функций (от ближайшей наружу), затем в глобальной области модуля и наконец среди встроенных имён (len, print, range).
def make_counter():
count = 0 # Enclosing для inner
def inner():
nonlocal count # без этого — UnboundLocalError на count += 1
count += 1
return count
return inner
c = make_counter()
print(c(), c(), c()) # 1 2 3
Ключевая ловушка: присваивание внутри вложенной функции по умолчанию создаёт новую локальную переменную, а не изменяет внешнюю. count += 1 без nonlocal — это попытка прочитать count как локальную переменную до того, как ей что-то присвоено, отсюда UnboundLocalError. nonlocal — явная декларация «это имя из объемлющей функции, не создавай новую локальную копию». Для чтения nonlocal не нужен.
flowchart TD
L["Local — переменные внутри самой функции"] --> E["Enclosing — переменные объемлющих функций"]
E --> G["Global — переменные модуля"]
G --> B["Built-in — встроенные имена (len, range...)"]graph TD
subgraph MAKER["кадр make_counter()"]
A["count = 0"]
subgraph INNER["кадр inner()"]
B["nonlocal count"]
C["count += 1"]
end
end
C --> B
B -->|"то же самое имя, та же ячейка"| AЛовушка: захват переменной цикла
funcs = []
for i in range(3):
funcs.append(lambda: i)
print([f() for f in funcs]) # [2, 2, 2], не [0, 1, 2]!
Причина: замыкание захватывает имя i, а не его значение в момент создания лямбды. Значение ищется в момент вызова (позднее связывание, late binding), а к этому времени цикл завершён и i равно 2. Это аналог ситуации в C, когда несколько указателей ссылаются на один и тот же адрес переменной цикла — только в Python это происходит неявно, без единого &i в коде.
Исправление — зафиксировать значение как default-аргумент (он вычисляется при создании функции, то есть отдельно на каждой итерации):
funcs = [lambda i=i: i for i in range(3)]
print([f() for f in funcs]) # [0, 1, 2]
Здесь работает механизм из предыдущего урока — «default вычисляется один раз, в момент создания функции» — только теперь он нам на пользу. Второй, более чистый способ — фабричная функция: каждый её вызов создаёт новую область видимости с собственной переменной.
Разобранный пример
Набор валидаторов «больше порога». Наивная версия страдает от позднего связывания:
validators = []
for min_val in [10, 20, 30]:
validators.append(lambda value: value > min_val)
val_10, val_20, val_30 = validators
print(val_10(15)) # False — ожидали True: все смотрят на min_val == 30
Фабричная функция решает проблему структурно:
def make_validator(min_val):
def is_greater_than(value):
return value > min_val # min_val — Enclosing-переменная этого вызова
return is_greater_than
val_10, val_20, val_30 = [make_validator(m) for m in (10, 20, 30)]
print(val_10(15), val_20(25), val_30(35)) # True True True
Каждый вызов make_validator создаёт новую область видимости со своим min_val, и is_greater_than захватывает именно её. В C это потребовало бы структуры struct validator { int threshold; } и функции, принимающей её первым аргументом — здесь структуру и связывание сгенерировал интерпретатор.
Попробуй сейчас
- Напишите
make_accumulator(), возвращающую замыканиеadd(x), которое накапливает сумму между вызовами (без глобальных переменных, толькоnonlocal):
acc = make_accumulator()
print(acc(10)) # 10
print(acc(5)) # 15
print(acc(1)) # 16
- Воспроизведите баг с захватом переменной цикла на списке из трёх замыканий и исправьте его двумя способами: через default-аргумент и через фабричную функцию.
Получилось, если…
Вы разобрались, если можете объяснить, почему for i in range(3): funcs.append(lambda: i) даёт три одинаковых результата, назвать это следствием захвата имени (LEGB + позднее связывание), предложить фикс через lambda i=i: i или фабрику без подсказки, и сказать, зачем в make_accumulator нужен nonlocal, а в make_validator — нет.
Вывод
Замыкание захватывает имя из объемлющей области, а не копию значения — это ближе к разделяемому указателю, чем к передаче по значению. nonlocal нужен только для присваивания внешней переменной внутри вложенной функции; для чтения он не требуется. Переменные цикла в замыканиях фиксируйте default-аргументом или фабричной функцией.
AI-generated · source-grounded review
🛡 Fact-checked: 0 risky claims verified · 1 removed · confidence: high · figures: 3
A second model re-checked this lesson's claims and corroborated 0 of 4 , and could not confirm 4 either way. This is incomplete corroboration, not a disagreement.
On your own course these buttons answer instantly, quizzes track what you've mastered, and lessons adapt to your gaps. Write my course