Вложенные функции, замыкания и nonlocal

Цели урока
  • Определять функцию внутри функции и объяснять, что она видит из объемлющей области (Enclosing)
  • Объяснить, что такое замыкание (Closure) — функция, которая помнит переменные даже после завершения внешней функции
  • Понимать, почему изменение внешней переменной вызывает UnboundLocalError, и исправлять это через nonlocal
  • Различать global и nonlocal

Что мы изучим?

  • Вложенную функцию (Nested Function) — функцию, определённую внутри функции.
  • Enclosing — букву E в LEGB, вблизи.
  • Замыкание (Closure) — функцию, которая «помнит» переменные.
  • nonlocal — как изменить переменную объемлющей функции.

Зачем это нужно?

  • Приватная вспомогательная функция: иногда функции нужна маленькая вспомогательная функция, которая принадлежит только ей. Нет причин делать её глобальной и смешивать с остальным кодом.
  • Функции, которые помнят состояние: в предыдущем уроке мы видели, что make_multiplier(2) возвращает функцию, которая «помнит» число 2. Сегодня поймём, как это работает, — и как построить счётчик, накопитель или кэш без global.

Объяснение с основ

Функция внутри функции

Можно определить def внутри def. Внутренняя функция:

  • Существует только внутри внешней функции — снаружи её нельзя вызвать.
  • Видит переменные и параметры внешней функции.

Enclosing — буква E в LEGB

В уроке об области видимости мы изучили порядок поиска L → E → G → B. Когда внутренняя функция использует имя, Python ищет его:

L  Local       → inside the inner function
E  Enclosing   → inside the outer function
G  Global      → top level of the file
B  Built-in    → print, len, ...

Замыкание — функция, которая помнит

Когда внешняя функция возвращает внутреннюю, происходит интересное: внешняя функция уже завершилась, а внутренняя всё ещё помнит её переменные. Это сочетание — функция + переменные, которые она помнит, — называется замыканием (Closure).

Аналогия: письмо в конверте

Внешняя функция пишет письмо (внутреннюю функцию) и кладёт его в конверт вместе с запиской с данными (переменными). Она отправляет конверт и исчезает, — но тот, кто откроет конверт, получит и письмо, и записку. В каждом конверте своя записка.

nonlocal

Читать переменную из объемлющей области можно всегда. Изменить её (присвоить новое значение) без объявления нельзя — точно как с глобальной. Объявление — nonlocal name: «эта переменная принадлежит объемлющей функции».

ОбъявлениеПеременная принадлежит…
global xУровню файла
nonlocal xБлижайшей объемлющей функции

Синтаксис

def outer():
    count = 0                 # enclosing variable

    def inner():
        nonlocal count        # assignments go to outer's count
        count += 1
        return count

    return inner              # return the function itself (no parentheses)

Первый пример кода

Приватная вспомогательная функция. week-2/receipt.py:

def format_receipt(items):
    def line(name, price):
        return name.ljust(10) + str(price)

    lines = []
    for name, price in items:
        lines.append(line(name, price))
    return "\n".join(lines)

print(format_receipt([("Milk", 6), ("Bread", 9)]))

Вывод:

Milk      6
Bread     9

Разбор кода построчно

СтрокаОбъяснение
def line(name, price):Внутренняя функция — существует только внутри format_receipt.
name.ljust(10)Дополняет имя пробелами до 10 символов, чтобы цены стояли в столбец.
lines.append(line(name, price))Внешняя функция использует свою вспомогательную функцию.
"\n".join(lines)Соединяет все строки в один текст с переводом строки между ними.

Ещё один пример

Замыкание — функция, которая помнит. week-2/greeter.py:

def make_greeter(greeting):
    def greet(name):
        return greeting + ", " + name
    return greet

hello = make_greeter("Hello")
shalom = make_greeter("Shalom")
print(hello("Dana"))
print(shalom("Avi"))

Вывод:

Hello, Dana
Shalom, Avi

make_greeter уже завершилась на строке hello = ..., но hello всё ещё помнит, что greeting — это "Hello". У каждой созданной функции своя копия переменной — shalom помнит "Shalom".

Счётчик с nonlocal. week-2/counter.py:

def make_counter():
    count = 0

    def increment():
        nonlocal count
        count += 1
        return count

    return increment

counter = make_counter()
print(counter(), counter(), counter())

other = make_counter()
print(other())

Вывод:

1 2 3
1

Каждый счётчик помнит свой count: other начинает с нуля, независимо от counter. Это приватное состояние — без глобальной переменной, которую может менять весь код.

Типичная ошибка

Ошибка: изменить переменную объемлющей функции без nonlocal.

def make_counter():
    count = 0

    def increment():
        count += 1
        return count

    return increment

counter = make_counter()
counter()
UnboundLocalError: cannot access local variable 'count' where it is not associated with a value

Почему? Из-за присваивания count += 1 Python решает, что count — локальная переменная increment, — и затем пытается прочитать локальную, у которой ещё нет значения. Это ровно та же ошибка, что мы видели с глобальными переменными в уроке об области видимости.

Исправление: nonlocal count в начале increment.

Практика

Упражнения опираются только на то, что пройдено в этом уроке и в предыдущих. Сначала попробуйте сами, и только потом открывайте решение.

Упражнение 1: Внутренняя вспомогательная функция

Напишите функцию format_receipt(items), которая получает список пар (название, цена) и возвращает текст чека. Внутри неё определите внутреннюю функцию line(name, price), которая дополняет название до 10 символов (ljust).

print(format_receipt([("Milk", 6), ("Bread", 9)]))

Ожидаемый вывод:

Milk      6
Bread     9
Показать решение
def format_receipt(items):
    def line(name, price):
        return name.ljust(10) + str(price)

    lines = []
    for name, price in items:
        lines.append(line(name, price))
    return "\n".join(lines)


print(format_receipt([("Milk", 6), ("Bread", 9)]))
Milk      6
Bread     9

line существует только внутри format_receipt — это приватная вспомогательная функция, и она не смешивается с остальным кодом.

Упражнение 2: Замыкание

Напишите make_greeter(greeting), которая возвращает функцию, получающую имя и возвращающую приветствие.

hello = make_greeter("Hello")
shalom = make_greeter("Shalom")
print(hello("Dana"))
print(shalom("Avi"))

Ожидаемый вывод:

Hello, Dana
Shalom, Avi
Показать решение
def make_greeter(greeting):
    def greet(name):
        return greeting + ", " + name
    return greet


hello = make_greeter("Hello")
shalom = make_greeter("Shalom")
print(hello("Dana"))
print(shalom("Avi"))
Hello, Dana
Shalom, Avi

make_greeter уже завершилась, но каждая возвращённая ею функция помнит свой greeting. Это и есть замыкание (Closure).

Упражнение 3: Предскажите вывод

def outer():
    message = "outer"

    def show():
        print("show:", message)

    def change():
        nonlocal message
        message = "changed"

    show()
    change()
    show()

outer()
Показать решение
def outer():
    message = "outer"

    def show():
        print("show:", message)

    def change():
        nonlocal message
        message = "changed"

    show()
    change()
    show()


outer()
show: outer
show: changed

change использует nonlocal, поэтому присваивание меняет message функции outer, а не создаёт новую локальную переменную.

Упражнение 4: Счётчик с шагом

Напишите make_counter(step) — каждый вызов счётчика увеличивает его на step и возвращает новое значение. Создайте два счётчика и проверьте, что они не влияют друг на друга.

by_two = make_counter(2)
by_ten = make_counter(10)
print(by_two(), by_two())
print(by_ten())
print(by_two())

Ожидаемый вывод:

2 4
10
6
Показать решение
def make_counter(step):
    count = 0

    def increment():
        nonlocal count
        count += step
        return count

    return increment


by_two = make_counter(2)
by_ten = make_counter(10)
print(by_two(), by_two())
print(by_ten())
print(by_two())
2 4
10
6
  • step внутри increment только читается, поэтому объявление не нужно.
  • count меняется, поэтому нужен nonlocal count.

Упражнение 5: Без nonlocal

def make_counter():
    count = 0
    def increment():
        count += 1
        return count
    return increment

counter = make_counter()
counter()
  1. Какую ошибку вы получите и почему?
  2. В чём разница между global и nonlocal?
Показать решение
  1. UnboundLocalError. Из-за присваивания count += 1 Python решает, что count — локальная переменная increment, — и у неё нет значения, когда вы пытаетесь её увеличить. Исправление: nonlocal count в начале increment.
  2. global — переменная принадлежит уровню файла. nonlocal — переменная принадлежит ближайшей объемлющей функции. Замыкание с nonlocal даёт каждому счётчику приватное состояние без глобальной переменной, которую может менять весь код.

Итоги

  • Вложенная функция — функция внутри функции. Существует только внутри и видит переменные объемлющей (Enclosing).
  • Замыкание (Closure) — внутренняя функция, которую вернули и которая помнит переменные создавшей её функции. У каждой — своя копия.
  • Читать переменную из объемлющей области — можно. Изменять — только с nonlocal, иначе UnboundLocalError.
  • global — переменная на уровне файла. nonlocal — переменная в объемлющей функции.
  • Замыкание даёт приватное состояние без глобальной переменной. И всё же, когда возможно, параметр на входе и return на выходе — лучше.

Официальная документация