Примеры идут от простого к сложному: сначала сама идея вложенной функции, затем правило LEGB, на котором держится вся тема, затем nonlocal и его отличие от global, фабрики функций и типичные ловушки. Весь вывод в комментариях получен запуском кода, а не набран по памяти.
Пример 1. Вложенная вспомогательная функция
Вспомогательная функция clean_text используется только внутри process_data и не загрязняет глобальное пространство имён. Снаружи её вызвать нельзя — она не существует до момента вызова process_data.
def process_data(data: list[str]) -> list[str]:
def clean_text(text: str) -> str:
return text.strip().lower()
return [clean_text(item) for item in data]
items = [" Apple ", " BaNaNa ", " CHERRY "]
print(process_data(items))
# ['apple', 'banana', 'cherry']
Что происходит: clean_text определена внутри process_data и видна только там. Такой приём называется инкапсуляцией логики: читающему код не нужно знать про clean_text, чтобы понять, что делает process_data.
Пример 2. Правило LEGB на всех четырёх уровнях
Python ищет имя в порядке Local → Enclosing → Global → Built-in и останавливается на первом уровне, где имя найдено. Чтобы увидеть это буквально, четыре функции ниже последовательно «убирают» определение имени name с самого близкого уровня — и поиск проваливается на уровень глубже.
name = "GLOBAL"
def outer_all():
name = "ENCLOSING"
def inner_all():
name = "LOCAL"
print("1) inner_all():", name)
inner_all()
outer_all()
# 1) inner_all(): LOCAL
def outer_no_local():
name = "ENCLOSING"
def inner_no_local():
# локальной name здесь нет -> поиск идёт на уровень выше, в Enclosing
print("2) inner_no_local():", name)
inner_no_local()
outer_no_local()
# 2) inner_no_local(): ENCLOSING
def outer_no_enclosing():
# локальной name во внешней функции тоже нет -> поиск идёт в Global
def inner_no_enclosing():
print("3) inner_no_enclosing():", name)
inner_no_enclosing()
outer_no_enclosing()
# 3) inner_no_enclosing(): GLOBAL
def outer_builtin():
def inner_builtin():
# у 'len' нет ни Local, ни Enclosing, ни Global версии -> берётся Built-in
print("4) inner_builtin():", len)
inner_builtin()
outer_builtin()
# 4) inner_builtin(): <built-in function len>
Что происходит: в первой функции name определена на всех трёх «своих» уровнях — побеждает Local. Как только локальное определение убрано, поиск поднимается в Enclosing; убрали и его — попадает в Global. Имя len нигде в модуле не переопределено, поэтому единственное место, где Python его находит, — уровень Built-in.
Пример 3. Замыкание, которое только читает внешнюю переменную
Переменная border читается, но не изменяется внутри filter_value, поэтому nonlocal не нужен — правило LEGB само находит её на уровне Enclosing.
def create_filter(border: float):
def filter_value(value: float) -> bool:
return value > border
return filter_value
greater_than_five = create_filter(5)
print(greater_than_five(7)) # True
print(greater_than_five(3)) # False
Что происходит: filter_value возвращается наружу и продолжает «помнить» border, хотя create_filter уже завершилась. Это и есть замыкание: функция вместе с захваченными свободными переменными.
Пример 4. Замыкание с изменением состояния: nonlocal
Как только внутри вложенной функции нужно не читать, а изменять переменную внешней функции, требуется nonlocal — без него присваивание создаст новую локальную переменную вместо изменения внешней.
def make_counter():
count = 0
def increment() -> int:
nonlocal count
count += 1
return count
return increment
counter = make_counter()
print(counter()) # 1
print(counter()) # 2
print(counter()) # 3
Что происходит: nonlocal count говорит Python «не создавай новую локальную переменную, используй count из ближайшей Enclosing-области». Каждый вызов counter() изменяет одну и ту же ячейку памяти, захваченную замыканием.
Пример 5. global в обычной функции vs nonlocal во вложенной
global и nonlocal решают одну задачу — разрешить изменение переменной не из локальной области — но ищут имя на разных уровнях: global всегда идёт к переменной модуля, nonlocal — к ближайшей внешней функции.
total_visits = 0
def register_visit() -> int:
global total_visits
total_visits += 1
return total_visits
print("global:", register_visit()) # global: 1
print("global:", register_visit()) # global: 2
def make_session_counter():
visits = 0
def register() -> int:
nonlocal visits
visits += 1
return visits
return register
session_a = make_session_counter()
print("nonlocal:", session_a()) # nonlocal: 1
print("nonlocal:", session_a()) # nonlocal: 2
Что происходит: register_visit не вложена ни в какую функцию, поэтому единственная переменная, до которой можно «дотянуться» через ключевое слово, — модульная total_visits, и для неё нужен именно global. register, наоборот, вложена в make_session_counter, и nonlocal находит visits в её Enclosing-области.
Пример 6. Ловушка: global во вложенной функции указывает не туда
nonlocal поднимается только до ближайшей внешней функции. global устроен иначе: он всегда идёт прямо к переменной модуля, минуя все промежуточные Enclosing-области — даже если во вложенной функции ожидалось изменить «свою» переменную фабрики.
count = 0 # модульная переменная, случайно совпадает по имени с локальной ниже
def make_counter_trap():
count = 0 # предполагается "своя" переменная фабрики
def increment_trap() -> int:
global count # ЛОВУШКА: указывает на count модуля, а не на count фабрики
count += 1
return count
return increment_trap
trap_counter = make_counter_trap()
print("trap call 1:", trap_counter()) # trap call 1: 1
print("trap call 2:", trap_counter()) # trap call 2: 2
print("module-level count изменился:", count) # module-level count изменился: 2
Что происходит: локальная count внутри make_counter_trap так и остаётся равной 0 и никогда не используется — global count в increment_trap связывает имя с переменной модуля, а не с ней. В результате счётчик «фабрики» на самом деле меняет глобальное состояние, общее для всех вызовов make_counter_trap. Правильным было бы nonlocal count, как в примере 4.
global внутри вложенной функции прямо в лекции не разбирается — это дополнение курса на основе документированного поведения областей видимости. Сверьтесь с global statement и nonlocal statement в справочнике языка.
Пример 7. Фабрика функций умножения
Замыкания часто используют, чтобы создать целое семейство похожих функций с разными настройками — этот приём называется фабрикой функций. Каждый вызов multiplier создаёт отдельное замыкание со своим factor. Тот же приём — в уроке 42 «Lambda-функции. Функции высшего порядка», где фабрика make_multiplier разбирается как пример функции, возвращающей функцию.
def multiplier(factor: float):
def multiply(number: float) -> float:
return number * factor
return multiply
double = multiplier(2)
triple = multiplier(3)
print(double(4)) # 8
print(triple(4)) # 12
Что происходит: double и triple — это два разных объекта-функции, каждый со своей захваченной ячейкой factor. Изменение одного замыкания никак не влияет на другое.
Пример 8. Альтернатива замыканию — functools.partial
Для простых фабрик, которые просто фиксируют один аргумент уже существующей функции, часто удобнее использовать встроенный инструмент, а не писать своё замыкание.
from functools import partial
from operator import mul
double = partial(mul, 2)
triple = partial(mul, 3)
print(double(4)) # 8
print(triple(4)) # 12
Что происходит: partial(mul, 2) возвращает вызываемый объект, который при вызове подставляет 2 первым аргументом в operator.mul. Результат идентичен фабрике из примера 7, но без написания собственной вложенной функции.
functools.partial выходит за рамки лекции — это дополнение курса. Подробнее — в документации functools.
Пример 9. Ловушка позднего связывания
Без захвата текущего значения переменной цикла все лямбды получат одно и то же — последнее — значение, потому что лямбда запоминает саму переменную, а не её значение на момент создания.
# Неправильно
functions_wrong = [lambda x: x * i for i in range(3)]
print([f(2) for f in functions_wrong]) # [4, 4, 4]
# Правильно: захватываем текущее i через значение по умолчанию
functions_right = [lambda x, i=i: x * i for i in range(3)]
print([f(2) for f in functions_right]) # [0, 2, 4]
Что происходит: аргумент по умолчанию i=i вычисляется один раз — в момент создания каждой лямбды — и таким образом «замораживает» текущее значение i. Без него все три лямбды продолжают ссылаться на одну и ту же переменную i, которая после цикла равна 2.
Пример 10. Как посмотреть замыкание изнутри: __closure__
Атрибут __closure__ позволяет увидеть захваченные функцией переменные — это удобно для отладки, когда непонятно, какое значение «внутри» замыкания.
def make_multiplier(factor: float):
def multiply(number: float) -> float:
return number * factor
return multiply
double = make_multiplier(2)
print(double.__closure__) # (<cell at ...>,)
print(double.__closure__[0].cell_contents) # 2
Что происходит: __closure__ возвращает кортеж «ячеек» (cell) — по одной на каждую захваченную свободную переменную. cell_contents раскрывает фактическое значение, лежащее внутри ячейки.
Пример 11. Счётчик вызовов: global-переменная vs замыкание
Одну и ту же задачу — посчитать количество вызовов — можно решить через модульную переменную с global или через фабрику с замыканием. Разница видна, как только нужен второй независимый счётчик.
# вариант 1: счётчик через глобальную переменную
click_count = 0
def track_click() -> None:
global click_count
click_count += 1
print(f"click #{click_count}")
track_click() # click #1
track_click() # click #2
# Недостаток: track_click "жёстко" привязан к click_count. Чтобы завести
# второй независимый счётчик, пришлось бы копировать и переменную, и функцию.
# вариант 2: счётчик через замыкание (та же идея, что в примере 4)
def make_counter():
count = 0
def increment() -> int:
nonlocal count
count += 1
return count
return increment
counter_a = make_counter()
counter_b = make_counter()
print("counter_a:", counter_a()) # counter_a: 1
print("counter_a:", counter_a()) # counter_a: 2
print("counter_b:", counter_b()) # counter_b: 1 — независим от counter_a
Что происходит: click_count одна на весь модуль — второй независимый счётчик кликов потребовал бы отдельной переменной и отдельной функции. make_counter, наоборот, при каждом вызове создаёт новую ячейку count: counter_a и counter_b хранят состояние независимо друг от друга, хотя определены одной и той же фабрикой. Это и есть инкапсуляция, которую даёт замыкание.
Что дальше
Разберите файлы репозитория — там те же идеи в исходном виде (LEGB, nonlocal, замыкания и декораторы как их частный случай), затем переходите к заданиям. Фабрики функций из примеров 7 и 11 перекликаются с уроком 42; закрепить тему помогут практикум 15 и итоговое занятие 16. Если что-то из примеров не запустилось — сначала загляните в типичные ошибки.
Как запустить в VS Code
- Создайте папку
python-fundamentals-practice/lesson-62/. - Создайте файл
closures.pyи скопируйте в него примеры. - Откройте терминал в VS Code (Ctrl + `).
- Активируйте виртуальное окружение:
venv\Scripts\activate(Windows) илиsource venv/bin/activate(Mac/Linux). - Запустите:
python closures.py