💻 Практические примеры

⚡ Минимальный рабочий пример

def multiplier(factor: float):
    def inner(number: float) -> float:
        return number * factor
    return inner

double = multiplier(2)
print(double(5))  # 10
ПонятиеЧто делаетКогда нужно
Вложенная функцияопределена внутри другой функции, снаружи не виднаспрятать вспомогательную логику
LEGBпорядок поиска имени: Local → Enclosing → Global → Built-inобъясняет, какое значение «увидит» функция
nonlocalпривязывает имя к ближайшей Enclosing-областиизменить переменную внешней функции изнутри вложенной
Замыканиефункция, которая помнит своё окружение после возвратавложенная функция возвращается и должна помнить настройки
Фабрика функцийсоздаёт семейство похожих функций с разными настройкаминужно много функций, различающихся одним параметром
Топ-3 ошибки: позднее связывание — лямбды в цикле помнят последнее значение · забыли nonlocal → UnboundLocalError · перепутали global и nonlocal (разная область поиска).

Примеры идут от простого к сложному: сначала сама идея вложенной функции, затем правило 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

  1. Создайте папку python-fundamentals-practice/lesson-62/.
  2. Создайте файл closures.py и скопируйте в него примеры.
  3. Откройте терминал в VS Code (Ctrl + `).
  4. Активируйте виртуальное окружение: venv\Scripts\activate (Windows) или source venv/bin/activate (Mac/Linux).
  5. Запустите: python closures.py