Уроки курса
1 Введение в Python и философия дзен
30 мин
2 Переменные и динамическая типизация
30 мин
3 Базовые типы данных: числа, строки и булевы значения
30 мин
4 Изменяемые и неизменяемые объекты (Mutable vs Immutable)
30 мин
5 Форматирование строк и f-строки
30 мин
6 Углубленная работа со списками
30 мин
7 Кортежи и их особенности
30 мин
8 Словари под капотом
30 мин
9 Множества и математические операции
30 мин
10 Генераторы списков (List Comprehensions)
30 мин
11 Генераторы словарей и множеств
30 мин
12 Встроенные функции для коллекций
30 мин
13 Условные операторы и логические выражения
30 мин
14 Циклы while и управление потоком
30 мин
15 Итерация с циклом for
30 мин
16 Конструкции for...else и while...else
30 мин
17 Функции enumerate и zip
30 мин
18 Создание собственных функций (def)
30 мин
19 Позиционные и именованные аргументы
30 мин
20 Проблема изменяемых аргументов по умолчанию
30 мин
21 Произвольное число аргументов (*args и **kwargs)
30 мин
22 Область видимости переменных (LEGB)
30 мин
23 Анонимные функции (lambda)
30 мин
24 Функции высшего порядка
30 мин
25 Замыкания (Closures)
30 мин
26 Введение в объектно-ориентированное программирование
30 мин
27 Атрибуты классов и экземпляров
30 мин
28 Магический метод __init__
30 мин
29 Методы экземпляра
30 мин
30 Инкапсуляция и сокрытие данных
30 мин
31 Декоратор @property
30 мин
32 Наследование классов
30 мин
33 Переопределение методов и функция super()
30 мин
34 Полиморфизм в Python
30 мин
35 Магические методы строк (__str__ и __repr__)
30 мин
36 Обработка исключений (try-except)
30 мин
37 Блоки else и finally
30 мин
38 Генерация собственных исключений (raise)
30 мин
39 Открытие и чтение файлов
30 мин
40 Запись данных в файлы
30 мин
41 Контекстные менеджеры (with)
30 мин
42 Работа с форматом JSON
30 мин
43 Модули и импорты
30 мин
44 Полезные модули стандартной библиотеки
30 мин
45 Модуль datetime
30 мин
46 Модуль collections
30 мин
47 Виртуальные окружения (venv)
30 мин
48 Установка сторонних пакетов через pip
30 мин
49 Организация структуры Python-проекта
30 мин
50 Финальный проект: создание приложения
30 мин

Замыкания (Closures)

Изучение механизма сохранения состояния из внешней функции во внутренней для создания фабрик функций.

Прогресс урока: 0%

Приветствую на 25-м уроке продвинутого курса по Python! Сегодня мы начинаем одну из самых важных и сложных тем для тех, кто хочет перейти от написания простых скриптов к созданию мощных, элегантных и профессиональных программных архитектур — замыкания (от английского closures). Чтобы по-настоящему глубоко и качественно освоить замыкания, мы должны сделать шаг назад и детально вспомнить правила области видимости переменных, известные как правило LEGB (Local, Enclosing, Global, Built-in). Как вы уже знаете из предыдущих уроков, когда интерпретатор Python встречает имя переменной во время выполнения программы, он начинает поиск значения в строго определенном порядке. Сначала он ищет в локальной области видимости функции (Local) — это те переменные, которые вы определили непосредственно внутри текущей функции. Если переменная не найдена, поиск переходит в объемлющую область видимости (Enclosing) — это область видимости внешней функции, внутри которой находится наша текущая вложенная функция. Если и там ничего нет, проверяется глобальная область видимости модуля (Global) и, наконец, встроенные имена Python (Built-in), такие как `len` или `print`. Замыкания неразрывно связаны именно с уровнем Enclosing. В языке программирования Python функции являются объектами первого класса (first-class citizens). Это означает, что функции ничем не отличаются от чисел, строк или списков: их можно передавать в качестве аргументов другим функциям, возвращать их из функций с помощью оператора `return` и присваивать обычным переменным. Когда мы определяем функцию внутри другой функции, мы создаем так называемую вложенную функцию. Эта вложенная функция имеет полноценный доступ к переменным внешней функции благодаря правилу Enclosing. Но что происходит, когда внешняя функция завершает свое выполнение? По строгим правилам управления памятью и сборки мусора (Garbage Collection) в Python, локальные переменные функции должны быть безвозвратно уничтожены сразу после завершения ее работы и выхода из стека вызовов. Однако, если вложенная функция все еще нуждается в этих переменных (например, если мы вернули вложенную функцию наружу в основную программу), интерпретатор Python применяет особую магию: он «сохраняет» эти переменные. Этот сложный механизм сохранения состояния или «захвата» переменных из объемлющего окружения и называется замыканием. Замыкание позволяет внутренней функции навсегда запомнить окружение, в котором она была создана, даже после того, как это окружение формально перестало существовать в оперативной памяти компьютера как часть стека. Это открывает невероятные возможности для создания динамических фабрик функций, инкапсуляции и сокрытия состояния без использования громоздких и медленных классов объектно-ориентированного программирования. Более того, концепция замыканий является абсолютным фундаментом для понимания и создания декораторов — одной из самых популярных фишек Python. В этом объемном уроке мы детально, шаг за шагом, разберем анатомию замыканий, заглянем под капот интерпретатора CPython, изучим байт-код и научимся писать настоящий, чистый функциональный код, избегая типичных ловушек, в которые попадают даже опытные разработчики.

python
def outer_function(message):
    # Эта переменная находится в объемлющей (Enclosing) области видимости
    outer_message = f"Message: {message}"
    
    def inner_function():
        # Вложенная функция использует переменную из внешней
        print(outer_message)
        
    return inner_function  # Возвращаем саму функцию, а не результат ее вызова

# Создаем замыкание
my_closure = outer_function("Hello, Closures!")
# Внешняя функция уже завершила работу, но состояние сохранено!
my_closure()  # Выведет: Message: Hello, Closures!

Какое правило в Python описывает порядок поиска переменных, который делает возможным существование замыканий?

В правиле LEGB, какая область видимости (напишите английское слово) отвечает за доступ к переменным внешней объемлющей функции?

Задание

Ментальная тренировка: отслеживание области видимости.

  • Определите функцию a(), внутри нее переменную x=1.
  • Определите функцию b() внутри a(), внутри нее переменную x=2.
  • Определите функцию c() внутри b(), внутри нее print(x).
  • Ответьте себе на вопрос: какое значение выведет print(x) и почему? (Ответ: 2, так как поиск идет от Local -> Enclosing(b) -> Enclosing(a) -> Global).
10 баллов

Теперь давайте строго определим технические критерии: когда именно обычная вложенная функция превращается в полноценное замыкание. Это критически важно понимать, поскольку далеко не любая функция, написанная внутри другой функции, является замыканием! Чтобы в интерпретаторе Python образовалось замыкание, компилятор строго проверяет наличие трех обязательных условий. Первое условие: у нас должна быть определена вложенная функция, то есть функция, созданная с помощью ключевого слова `def` (или анонимного лямбда-выражения) непосредственно внутри тела другой внешней функции. Второе условие: эта вложенная функция должна в своем теле обязательно ссылаться на значение, аргумент или переменную, которая была определена в объемлющей (enclosing) функции. Эта переменная, на которую ссылается внутренняя функция, называется в теории языков программирования «свободной переменной» (free variable). Свободная переменная — это переменная, которая активно используется в коде блока, но при этом не является локальной переменной этого конкретного блока и не является глобальной переменной уровня всего модуля. Третье и абсолютно обязательное условие: объемлющая внешняя функция должна возвращать эту вложенную функцию как полноценный объект (то есть возвращать саму функцию, а не результат ее однократного вызова — мы пишем код `return inner_func`, а не `return inner_func()`). Только при одновременном выполнении всех этих трех условий интерпретатор создает замыкание. Как именно это элегантно работает под капотом виртуальной машины Python? Когда компилятор анализирует исходный код внешней функции и видит, что её локальная переменная активно используется во вложенной функции, которая затем будет возвращена наружу, он в корне меняет стратегию выделения памяти. Вместо того чтобы размещать эту локальную переменную в обычном фрейме стека (который будет неизбежно уничтожен при возврате из внешней функции), Python динамически создает специальный служебный объект, который называется «ячейкой» (cell object). И внешняя, и внутренняя функции получают невидимые для программиста ссылки на этот изолированный объект-ячейку. Таким образом, даже когда внешняя функция штатно завершает свое выполнение, уничтожает свой локальный контекст и навсегда исчезает из стека вызовов программы, объект-ячейка продолжает безопасно существовать в куче (heap) памяти, так как на него все еще ссылается внутренняя функция. Это изящное инженерное решение CPython обеспечивает абсолютную безопасность памяти и сохранность данных, позволяя внутренней функции читать или изменять эти данные при любых последующих вызовах из совершенно других частей программы. Глубокое понимание этих трех правил и скрытого механизма объектов-ячеек является вашим единственным ключом к написанию надежного, безошибочного кода и отладке неочевидного поведения. Очень часто начинающие программисты тщетно пытаются использовать замыкания, банально забыв вернуть вложенную функцию, или ошибочно ожидают замыкания там, где переменная на самом деле является глобальной.

python
def make_multiplier_of(n):
    # Условие 1: Вложенная функция
    def multiplier(x):
        # Условие 2: Использование свободной переменной (n)
        return x * n
    
    # Условие 3: Возврат вложенной функции как объекта
    return multiplier

# Создаем замыкания
multiply_by_3 = make_multiplier_of(3)
multiply_by_5 = make_multiplier_of(5)

print(multiply_by_3(10))  # Выведет 30
print(multiply_by_5(10))  # Выведет 50

Как называется переменная (например, `n` из предыдущего примера), которая используется во вложенной функции, но определена в объемлющей функции?

Чтобы замыкание сработало, внешняя функция должна вернуть вложенную функцию. Что мы должны написать: `return inner` или `return inner()`? Напишите правильный вариант.

Задание

Напишите простую фабрику приветствий.

  • Определите функцию greeting_maker(greeting_word).
  • Внутри определите функцию greet(name), которая печатает greeting_word + ', ' + name.
  • Верните функцию greet.
  • Создайте две фабрики: say_hello = greeting_maker('Hello') и say_hi = greeting_maker('Hi').
  • Вызовите обе функции с разными именами.
10 баллов
Характеристика Обычная функция Замыкание (Closure)
Сохранение состояния Не сохраняет (локальные переменные уничтожаются) Сохраняет (через свободные переменные)
Доступ к внешним данным Только через аргументы или глобальные переменные Прямой доступ к переменным Enclosing области
Жизненный цикл данных Живут до конца выполнения функции Живут до тех пор, пока существует объект замыкания

Мы много говорили о том, что Python «магическим» образом сохраняет переменные с помощью объектов-ячеек (cell objects). Но как мы можем убедиться в этом на практике? Как опытные инженеры, мы не должны верить на слово — мы должны заглянуть под капот интерпретатора и исследовать структуры данных. В Python абсолютно все является объектом, и функции не исключение. Любой объект функции в Python имеет множество специальных скрытых атрибутов (так называемых dunder-методов и dunder-атрибутов, от слова Double Under). Одним из таких важнейших атрибутов является `__closure__`. Если функция является истинным замыканием, атрибут `__closure__` вернет кортеж (tuple), состоящий из тех самых объектов-ячеек (cell objects). Каждая ячейка в этом кортеже строго соответствует одной «свободной переменной», которую захватила вложенная функция. Если же функция не является замыканием (то есть не использует переменные из объемлющей области или вообще определена глобально), то атрибут `__closure__` вернет просто `None`. Вы можете пойти еще глубже! Каждый объект-ячейка (cell object) имеет свой собственный специальный атрибут `cell_contents`. Обратившись к этому атрибуту, вы можете в реальном времени прочитать фактическое значение переменной, которое было сохранено в памяти в момент создания замыкания. Этот мощный механизм интроспекции (самоанализа кода) невероятно полезен при отладке сложных систем, где состояние передается через множество слоев вложенных вызовов. Вы можете написать скрипт, который программно извлекает скрытое состояние из любой переданной функции, не вызывая ее саму! Стоит отметить, что разработчики языка Python реализовали объекты-ячейки специально для того, чтобы разрешить проблему разделяемого изменяемого состояния. Поскольку ячейка — это независимый объект в куче, если внешняя и внутренняя функции обе попытаются изменить одну и ту же переменную, они обе будут взаимодействовать с одной и той же ячейкой памяти, что гарантирует синхронизацию данных. Далее мы рассмотрим, как именно можно модифицировать эти данные изнутри замыкания, но пока что вам необходимо твердо усвоить: замыкание — это не абстрактная философская концепция. Это совершенно конкретная структура данных (tuple of cell objects), жестко привязанная к объекту вашей внутренней функции при ее компиляции интерпретатором CPython. Давайте перейдем к коду и своими глазами посмотрим на то, что скрыто в атрибуте `__closure__`.

python
def outer():
    x = 100
    y = "Secret String"
    def inner():
        print(x, y)
    return inner

closure_func = outer()

# Проверяем атрибут __closure__
print(closure_func.__closure__)
# Вывод: (<cell at 0x...: int object at 0x...>, <cell at 0x...: str object at 0x...>)

# Исследуем содержимое ячеек
for cell in closure_func.__closure__:
    print(cell.cell_contents)
# Вывод:
# 100
# Secret String

Какое значение содержит атрибут __closure__, если функция НЕ является замыканием?

Какой атрибут объекта-ячейки (cell object) позволяет получить доступ к сохраненному значению свободной переменной? (Напишите точное имя атрибута)

Задание

Практика: интроспекция функции.

  • Создайте любую функцию, которая не является замыканием (например, def test(): pass).
  • Выведите в консоль test.__closure__ и убедитесь, что это None.
  • Создайте замыкание с тремя разными переменными (число, строка, список).
  • С помощью цикла for выведите тип каждого элемента из cell.cell_contents.
10 баллов
Атрибут объекта функции Описание Пример значения
__name__ Имя функции строкой 'inner_function'
__code__ Скомпилированный байт-код функции <code object inner at 0x...>
__closure__ Кортеж ячеек со свободными переменными (<cell ...>,)

До сих пор мы рассматривали замыкания только в режиме «только для чтения» (read-only). Мы создавали переменные во внешней функции и просто читали или печатали их во внутренней. Но что, если мы захотим создать функцию, которая ведет себя как счетчик, накапливая состояние при каждом новом вызове? Например, функцию `counter()`, которая при первом вызове возвращает 1, при втором — 2, и так далее. Если вы попробуете написать такой код интуитивно, используя оператор инкремента `count += 1` внутри вложенной функции, вы столкнетесь с одной из самых классических ошибок Python — исключением `UnboundLocalError`. Почему это происходит? В Python есть строгое правило: если вы пытаетесь присвоить значение переменной внутри функции с помощью оператора `=`, интерпретатор автоматически считает эту переменную локальной для данной функции. Следовательно, когда Python видит `count += 1` (что эквивалентно `count = count + 1`), он предполагает, что `count` — это новая локальная переменная. Но чтобы прибавить к ней 1, он сначала должен прочитать ее текущее значение. И поскольку локальной переменной `count` еще не было присвоено никакого значения в рамках этого локального блока, происходит фатальная ошибка. Как же сказать интерпретатору: «Эй, не создавай новую локальную переменную! Используй ту самую переменную `count` из моей внешней (enclosing) функции!»? Для решения этой проблемы в версии Python 3 было введено специальное ключевое слово `nonlocal`. Ключевое слово `nonlocal` работает аналогично ключевому слову `global`, но с одним критическим отличием. В то время как `global` заставляет функцию искать переменную на самом верхнем уровне модуля, игнорируя все промежуточные функции, `nonlocal` заставляет интерпретатор искать переменную в ближайшей объемлющей (enclosing) области видимости, исключая глобальную. Объявляя `nonlocal count` в первой строке вашей внутренней функции, вы явно привязываете имя `count` к объекту-ячейке замыкания. Это позволяет безопасно и предсказуемо мутировать (изменять) состояние, сохраненное внутри замыкания. Использование `nonlocal` — это эталонный, «питонячий» (pythonic) способ реализации счетчиков, аккумуляторов, генераторов последовательностей и конечных автоматов с сохранением состояния без необходимости создавать целые классы для такой простой задачи. Это делает ваш код компактным, функциональным и очень быстрым, так как доступ к ячейкам замыкания оптимизирован на уровне виртуальной машины C. В следующем блоке мы напишем и протестируем этот классический счетчик.

python
def create_counter():
    count = 0  # Свободная переменная во внешней функции
    
    def counter():
        nonlocal count  # Говорим Python использовать переменную из enclosing scope
        count += 1
        return count
        
    return counter

# Создаем два НЕЗАВИСИМЫХ счетчика
counter_A = create_counter()
counter_B = create_counter()

print(counter_A())  # 1
print(counter_A())  # 2

print(counter_B())  # 1 (свое собственное независимое состояние!)
print(counter_A())  # 3

В чем главное отличие ключевого слова nonlocal от global?

Какое ключевое слово необходимо использовать внутри замыкания, чтобы изменить (мутировать) неизменяемую переменную (например, число или строку) из внешней функции?

Задание

Создайте свой собственный аккумулятор на основе замыкания.

  • Напишите функцию make_accumulator(initial_value=0).
  • Внутри определите функцию add(x), которая прибавляет x к initial_value.
  • Не забудьте использовать nonlocal для initial_value.
  • Верните функцию add и протестируйте ее: прибавляйте 10, потом 20, проверяйте сумму.
10 баллов
python
# Пример типичной ошибки новичка (БЕЗ nonlocal)

def bad_counter():
    count = 0
    def inner():
        # ОШИБКА: UnboundLocalError: local variable 'count' referenced before assignment
        count += 1
        return count
    return inner
Ключевое слово Область поиска Где обычно применяется
Отсутствует Только Local (для записи) Обычные локальные переменные функции
nonlocal Ближайший Enclosing scope Замыкания, сохранение состояния между вызовами
global Уровень модуля (Global) Глобальные конфигурации, синглтоны уровня файла

Ключевое слово `nonlocal` было добавлено только в версии Python 3. Но как же программисты писали замыкания с сохранением состояния во времена Python 2, когда этого удобного инструмента не существовало? Для ответа на этот вопрос нам нужно вспомнить фундаментальное различие между изменяемыми (Mutable) и неизменяемыми (Immutable) типами данных в Python. Как вы помните, числа, строки и кортежи являются неизменяемыми. Когда вы делаете `count += 1`, вы на самом деле создаете совершенно новый объект числа в памяти и пытаетесь привязать к нему локальное имя `count`. Именно эта попытка перепривязки (re-assignment) имени вызывает `UnboundLocalError`. Однако списки (list), словари (dict) и множества (set) являются изменяемыми объектами. Это означает, что вы можете изменять их внутреннее содержимое, не меняя при этом саму ссылку на объект! И здесь кроется гениальный (хотя и немного костыльный) хак, который использовался годами: Хак с изменяемым значением по умолчанию (Mutable Default Hack). Если во внешней функции вы определите свободную переменную не как целое число `count = 0`, а как список с одним элементом `state = [0]`, то внутри замыкания вы можете обращаться к `state[0] += 1`. Обратите пристальное внимание: мы не переопределяем переменную `state`. Переменная `state` продолжает указывать на тот же самый список в памяти. Мы лишь мутируем первый элемент внутри этого списка. Поскольку мы не пытаемся переприсвоить само имя `state` с помощью знака равно (вида `state = ...`), интерпретатор Python не считает `state` локальной переменной внутренней функции! Он спокойно находит ссылку на список в Enclosing области видимости и изменяет его элемент на месте (in-place). Хотя в современном Python 3 у нас есть красивый `nonlocal`, понимание этого трюка со списками и словарями делает вас программистом совершенно иного уровня. Вы начинаете буквально «чувствовать» разницу между переприсвоением имени переменной и мутацией самого объекта в памяти. Более того, этот паттерн с использованием словаря часто применяется и сегодня для создания сложных замыканий, которым нужно возвращать сразу несколько функций (например, геттеры и сеттеры), разделяющих общее состояние. Об этом мощнейшем паттерне инкапсуляции мы поговорим в последних разделах этого урока. А пока — давайте рассмотрим код этого классического трюка.

python
def make_old_school_counter():
    # Используем изменяемый объект (список) для хранения состояния
    state = [0]
    
    def counter():
        # Мы МУТИРУЕМ объект, а не переприсваиваем переменную state
        # Поэтому nonlocal здесь не нужен!
        state[0] += 1
        return state[0]
        
    return counter

my_counter = make_old_school_counter()
print(my_counter())  # 1
print(my_counter())  # 2

# Это работает во всех версиях Python, включая старый Python 2.

Почему использование state[0] += 1 не вызывает ошибку UnboundLocalError даже без nonlocal?

Как называется тип объектов в Python (например, list, dict, set), содержимое которых можно изменять без создания нового объекта в памяти? (Введите английский термин на букву M)

Задание

Практика: счетчик на основе словаря.

  • Напишите функцию dictionary_counter().
  • Определите внутри словарь state = {'count': 0}.
  • Напишите вложенную функцию, которая делает state['count'] += 1 и возвращает значение.
  • Убедитесь, что код работает без использования nonlocal.
10 баллов
python
def make_dict_counter():
    # Использование словаря делает код более читаемым, чем список state[0]
    state = {'count': 0}
    
    def counter():
        state['count'] += 1
        return state['count']
        
    return counter

counter = make_dict_counter()
print(counter(), counter(), counter()) # 1 2 3

Мы уже разобрали, как замыкания могут сохранять состояние, подобно миниатюрным объектам. Теперь давайте рассмотрим их классическое и, пожалуй, самое распространенное применение в функциональном программировании — Фабрики функций (Function Factories). Фабрика — это паттерн проектирования, при котором одна функция динамически генерирует и возвращает другие функции, каждая из которых имеет немного различающееся поведение, основанное на параметрах, переданных фабрике при ее вызове. Представьте себе фабрику по производству штампов. Сама фабрика (внешняя функция) не ставит печати, она лишь создает штамп с определенным узором. Возвращенная функция (замыкание) — это и есть готовый штамп, который вы можете использовать сколько угодно раз в разных частях программы. Самый простой и наглядный пример — это создание серии умножителей (multipliers). Мы передаем во внешнюю функцию аргумент `n` (например, 2 для удвоения, 3 для утроения). Внешняя функция возвращает нам новую функцию, которая всегда будет умножать любой переданный ей аргумент `x` на сохраненное значение `n`. С точки зрения архитектуры, использование фабрик функций позволяет колоссально сократить дублирование кода. Вместо того чтобы вручную писать десятки похожих функций (например, `double(x)`, `triple(x)`, `quadruple(x)`), мы пишем одну универсальную фабрику `make_multiplier(n)` и генерируем из нее столько специализированных функций, сколько нам нужно в процессе работы программы (даже на основе пользовательского ввода!). Фабрики функций — это мощный шаг в сторону метапрограммирования, когда ваш код создает другой код. Встроенные библиотеки Python, такие как `functools`, или популярные веб-фреймворки (Django, Flask) широко используют фабрики функций для создания маршрутизаторов (роутеров) и обработчиков запросов. Это делает код декларативным и выразительным. В следующем блоке мы напишем элегантную математическую фабрику, чтобы закрепить эту важную концепцию.

python
def make_multiplier(n):
    """Фабрика, создающая функции для умножения на n."""
    def multiplier(x):
        return x * n
    return multiplier

# Генерируем специализированные функции 'на лету'
double = make_multiplier(2)
triple = make_multiplier(3)
multiply_by_10 = make_multiplier(10)

# Используем созданные функции
print(double(5))         # Вывод: 10
print(triple(5))         # Вывод: 15
print(multiply_by_10(5)) # Вывод: 50

# Фабрика позволяет создавать функции динамически
multipliers_list = [make_multiplier(i) for i in range(1, 4)]
for m in multipliers_list:
    print(m(10)) # Выведет 10, 20, 30

Что возвращает фабрика функций (например, make_multiplier)?

Как называется паттерн программирования, при котором функция создает и возвращает другие специализированные функции? (Два слова: _______ функций)

Задание

Создайте фабрику возведения в степень.

  • Напишите функцию make_power(exponent).
  • Внутри определите функцию power(base), которая возвращает base ** exponent.
  • Верните функцию power.
  • Создайте функции square = make_power(2) и cube = make_power(3).
  • Проверьте результат для square(4) и cube(3).
10 баллов
python
def make_tag_generator(html_tag):
    """Более сложный пример фабрики: генератор HTML тегов."""
    def generate_tag(text):
        return f"<{html_tag}>{text}</{html_tag}>"
    return generate_tag

# Создаем конкретные функции для генерации тегов
h1 = make_tag_generator('h1')
p = make_tag_generator('p')
div = make_tag_generator('div')

print(h1("Заголовок страницы")) # <h1>Заголовок страницы</h1>
print(p("Это абзац текста."))   # <p>Это абзац текста.</p>
Подход Плюсы Минусы
Много разных функций (def p, def h1...) Просто и понятно для новичков Ужасное дублирование кода, сложно поддерживать
Замыкания / Фабрики (make_tag) Динамичность, лаконичность кода Чуть сложнее для понимания синтаксиса
ООП Классы (class Tag) Мощные возможности, наследование Избыточность кода (boilerplate) для простых задач

Мы подошли к самой коварной, знаменитой и часто встречающейся ошибке при работе с замыканиями в Python — Проблеме позднего связывания (Late Binding Trap). Эту ошибку совершает почти каждый программист на этапе изучения функционального программирования. Представьте классический сценарий: вы в цикле `for` создаете список функций (например, лямбда-выражений или вложенных замыканий), и каждая из них должна умножать аргумент на текущее значение переменной цикла `i`. Вы ожидаете получить список функций, где первая умножает на 0, вторая на 1, третья на 2. Но когда вы запускаете этот код, вы с ужасом обнаруживаете, что все созданные функции умножают на последнее значение цикла (например, на 2)! Почему это происходит? Разве замыкания не должны сохранять состояние на момент своего создания? Ответ кроется в механике работы объектов-ячеек, которую мы обсуждали ранее. Замыкания в Python используют «позднее связывание» (late binding). Это означает, что значения свободных переменных внутри замыкания не оцениваются (не копируются) в момент создания функции. Вместо этого функция сохраняет ссылку на ту самую ячейку памяти. Во время выполнения цикла интерпретатор не создает три разные ячейки для `i`. Он использует одну и ту же переменную `i`, мутируя ее на каждой итерации. Когда цикл завершается, переменная `i` равна своему последнему значению (например, 2). И только когда вы позже вызываете ваши сгенерированные функции, они обращаются к этой переменной-ячейке, находят там последнюю записанную цифру 2 и используют её! Замыкание связывается с самой переменной, а не со значением этой переменной в момент прохождения цикла. Это поведение глубоко заложено в архитектуру языка Python. Непонимание концепции позднего связывания регулярно приводит к критическим багам при создании обработчиков событий (callbacks) в графических интерфейсах (GUI) типа Tkinter или PyQt, когда десятки созданных в цикле кнопок почему-то выполняют действие только для последней кнопки в списке. В следующем блоке мы покажем код, демонстрирующий эту ошибку, а затем элегантно её исправим.

python
# ДЕМОНСТРАЦИЯ ОШИБКИ: Позднее связывание
multipliers = []

for i in range(3):
    # Создаем три замыкания-лямбды в цикле
    # Ожидание: i=0, i=1, i=2
    multipliers.append(lambda x: x * i)

# Вызываем созданные функции
print(multipliers[0](10)) # Ожидаем 0, получаем 20!
print(multipliers[1](10)) # Ожидаем 10, получаем 20!
print(multipliers[2](10)) # Ожидаем 20, получаем 20!

# Почему? Переменная 'i' в конце цикла равна 2.
# Все лямбды ссылаются на одну и ту же переменную 'i' в памяти.

Почему все функции в цикле (из примера) возвращают результат, основанный на последнем значении переменной цикла?

Как называется механизм Python, при котором значение переменной в замыкании определяется только в момент ВЫЗОВА функции, а не в момент ее создания? (Два слова, на английском или русском)

Задание

Проанализируйте ошибку в уме.

  • Представьте код: buttons = [lambda: print(f'Btn {i}') for i in range(5)]
  • Что произойдет, если мы вызовем buttons[0]()?
  • Ответ: выведется 'Btn 4', так как i после цикла равно 4.
10 баллов
python
# Классический пример ошибки в GUI (псевдокод)
buttons = []
for i in range(5):
    # Все кнопки будут выводить "Button 4 is clicked!"
    btn_callback = lambda: print(f"Button {i} is clicked!")
    buttons.append(btn_callback)
Итерация цикла Значение `i` в памяти Что "видит" лямбда при ВЫЗОВЕ
i = 0 0 Ссылку на объект 'i'
i = 1 1 Ссылку на объект 'i'
Конец цикла Финальное значение (напр. 4) Видит 4 (так как обращается к 'i' сейчас)

К счастью, проблема позднего связывания решается крайне изящно с помощью одного фундаментального правила Python: аргументы по умолчанию для функций (default arguments) вычисляются строго в момент СОЗДАНИЯ (определения) функции, а не в момент ее вызова. Это правило известно как «раннее связывание» (early binding) для аргументов по умолчанию. Если вы помните из предыдущих уроков, это же правило является причиной другой популярной проблемы — «изменяемые аргументы по умолчанию» (mutable default arguments, когда пустой список `def func(lst=[])` накапливает значения). Однако то, что в других случаях является источником багов, здесь становится идеальным спасением! Чтобы зафиксировать текущее значение переменной цикла `i` внутри нашего замыкания (или лямбды), мы просто передаем это `i` в качестве аргумента по умолчанию. Например: `lambda x, current_i=i: x * current_i`. Как только интерпретатор доходит до строки создания функции в цикле, он видит дефолтный аргумент `current_i=i`. Он обязан вычислить выражение справа от знака равно прямо сейчас. Он берет текущее значение `i` на данной итерации (например, 0), вычисляет его и жестко «прибивает гвоздями» (сохраняет) внутри объекта самой функции как значение по умолчанию для аргумента `current_i`. На следующей итерации создается совершенно новый объект функции, и для него вычисляется новое значение `i` (например, 1). Таким образом, каждая сгенерированная функция получает свою собственную, независимую, статичную копию переменной цикла, надежно спрятанную в ее аргументах по умолчанию! Когда вы позже вызываете функцию без второго аргумента, она использует это сохраненное дефолтное значение, и магия срабатывает: мы получаем ожидаемое поведение. Существует и альтернативный, более читабельный подход к решению этой проблемы — использование функции `functools.partial`, которая также фиксирует аргументы в момент своего вызова. Профессиональные Python-разработчики обязаны знать оба способа наизусть, так как генерация обработчиков событий в циклах — стандартная задача во многих фреймворках (например, при создании кнопок телеграм-ботов с разными `callback_data`). Давайте исправим наш сломанный код в следующем блоке.

python
# ИСПРАВЛЕНИЕ ОШИБКИ: Раннее связывание через default argument
multipliers_fixed = []

for i in range(3):
    # current_i=i вычисляется В МОМЕНТ СОЗДАНИЯ лямбды!
    # Мы 'замораживаем' текущее значение i
    multipliers_fixed.append(lambda x, current_i=i: x * current_i)

print(multipliers_fixed[0](10)) # Выведет 0
print(multipliers_fixed[1](10)) # Выведет 10
print(multipliers_fixed[2](10)) # Выведет 20

# Это работает и для обычных функций def:
callbacks = []
for index in range(3):
    def callback(val=index):  # Захватываем текущее значение index
        print(f"Captured value: {val}")
    callbacks.append(callback)

Почему использование аргумента по умолчанию (arg=i) решает проблему позднего связывания?

В какой момент времени интерпретатор Python вычисляет значения аргументов по умолчанию для функции: в момент ее вызова или создания?

Задание

Исправьте ошибку в цикле кнопок.

  • Возьмите код: buttons = [lambda: print(i) for i in range(5)]
  • Измените лямбду так, чтобы она принимала аргумент по умолчанию.
  • Правильный ответ: buttons = [lambda current_i=i: print(current_i) for i in range(5)]
10 баллов
python
from functools import partial

# Альтернативное, более профессиональное решение проблемы через partial
callbacks_partial = []

def print_value(val):
    print(f"Value is {val}")

for i in range(3):
    # partial создает новую функцию, жестко фиксируя аргумент val значением i
    bound_function = partial(print_value, val=i)
    callbacks_partial.append(bound_function)

callbacks_partial[0]() # Value is 0
callbacks_partial[2]() # Value is 2

Теперь, когда мы досконально разобрались с тем, как замыкания хранят состояние и как избегать подводных камней, у вас наверняка возник резонный вопрос: «А зачем вообще использовать замыкания, если в Python есть объектно-ориентированное программирование (ООП)? Разве обычные классы не созданы как раз для хранения состояния?». Действительно, классы и объекты `class` с методом `__init__` прекрасно справляются с инкапсуляцией данных. Любое замыкание технически можно переписать в виде класса, где сохраненные переменные станут атрибутами экземпляра (`self.count`), а вложенная функция станет методом класса (часто магическим методом `__call__`, чтобы объект класса можно было вызывать как функцию). Выбор между замыканием и классом — это классический архитектурный компромисс. Замыкания (Функциональный подход) обычно предпочтительнее, когда ваше состояние очень простое (всего 1-2 переменные) и вам нужен ровно один метод для работы с ним. Замыкания гораздо легче (lightweight) с точки зрения потребления оперативной памяти, так как они не тянут за собой тяжелую инфраструктуру классов (словари `__dict__`, разрешение методов MRO, атрибуты классов). Вызов замыкания происходит быстрее, чем создание экземпляра класса и вызов его метода. Кроме того, код на основе замыканий получается более кратким и математически строгим. Классы (ООП подход) становятся абсолютно необходимыми, когда ваше состояние усложняется: если у вас появляется много переменных, если вам нужно несколько различных методов для взаимодействия с этими данными (геттеры, сеттеры, различные вычисления), и если вы планируете использовать наследование для расширения функционала. Если вы попытаетесь реализовать сложную систему с десятком методов исключительно на замыканиях, вы получите нечитаемую «лапшу» из вложенных функций и костылей, которую будет невозможно тестировать и поддерживать. Хороший Python-разработчик не фанатеет от одного подхода, а умеет применять правильный инструмент в зависимости от масштаба задачи. Для простых счетчиков, кэширования, декораторов и коллбеков — мы выбираем изящные замыкания. Для больших сущностей бизнес-логики (пользователь, заказ, база данных) — мощные классы.

python
# СРАВНЕНИЕ: Замыкание против Класса для одной и той же задачи (Счетчик)

# 1. Подход через замыкание (Кратко, быстро, функционально)
def make_closure_counter():
    count = 0
    def counter():
        nonlocal count
        count += 1
        return count
    return counter

my_closure = make_closure_counter()
print(my_closure()) # 1

# 2. Подход через ООП класс (Многословно, но мощно)
class CounterClass:
    def __init__(self):
        self.count = 0
        
    # Магический метод __call__ позволяет вызывать объект как функцию
    def __call__(self):
        self.count += 1
        return self.count

my_class_instance = CounterClass()
print(my_class_instance()) # 1

В каком случае использование замыкания предпочтительнее, чем создание полноценного класса?

Какой магический метод позволяет экземпляру класса вести себя в точности как вызываемое замыкание? (Напишите его имя, включая двойные подчеркивания)

Задание

Практика: конвертация.

  • Напишите класс Greeter, который принимает greeting в __init__ и печатает greeting + name в __call__.
  • Напишите аналогичную фабрику замыканий make_greeter(greeting).
  • Сравните количество строк кода. Замыкание должно быть короче.
10 баллов
Параметр Замыкание (Closure) Класс с __call__
Объем кода Минимальный (2-5 строк) Избыточный (boilerplate: init, self...)
Потребление памяти Крайне низкое (кортеж ячеек) Выше (словарь атрибутов __dict__)
Масштабируемость Плохая для сложной логики Отличная (наследование, методы)

Одним из самых красивых и практически полезных примеров использования замыканий в реальной (Production) разработке является реализация Кэширования (Мемоизации). Мемоизация (Memoization) — это классическая алгоритмическая техника оптимизации, суть которой заключается в сохранении результатов длительных или тяжелых вычислений функций для того, чтобы при повторном вызове функции с теми же самыми аргументами не выполнять ресурсоемкие вычисления заново, а мгновенно вернуть уже готовый результат из памяти (кэша). Представьте, что вы пишете программу, которая делает тяжелые HTTP-запросы к внешнему серверу, парсит сложный XML или решает ресурсоемкие математические задачи (например, вычисление чисел Фибоначчи или сложных факториалов с помощью рекурсии). Без кэша ваша программа будет тратить массу процессорного времени впустую, выполняя одну и ту же работу. Замыкания идеально подходят для создания такого кэша! Мы можем написать функцию `memoize`, которая принимает другую целевую функцию в качестве аргумента (по сути, это уже паттерн Декоратор, к которому мы плавно приближаемся). Внутри этой функции-оболочки мы определяем словарь `cache = {}`, который становится нашей свободной переменной и выступает в роли хранилища состояния (кэша). Вложенная функция (обертка) перед тем как запустить тяжелые вычисления, сначала проверяет: нет ли уже переданных аргументов в нашем словаре `cache`? Если аргументы там есть, она за долю секунды возвращает сохраненное значение (Hit). Если аргументов нет (Miss), она честно выполняет оригинальную функцию, сохраняет результат в словарь `cache` и возвращает его пользователю. Благодаря тому, что словарь — это изменяемый (mutable) объект, нам даже не требуется использовать ключевое слово `nonlocal`. Словарь `cache` надежно инкапсулирован внутри замыкания, никто из глобальной области видимости не может случайно его повредить или удалить, и он сохраняет свое состояние между всеми вызовами целевой функции. Это ярчайший пример того, как буквально пять строчек функционального кода могут ускорить работу вашего алгоритма с экспоненциального времени O(2^n) до линейного O(n)! Давайте реализуем этот блестящий паттерн.

python
def make_memoized_fibonacci():
    # Наш скрытый кэш в виде словаря
    cache = {}
    
    def fibonacci(n):
        # Проверяем кэш
        if n in cache:
            return cache[n]
            
        # Базовые случаи
        if n <= 1:
            return n
            
        # Тяжелое рекурсивное вычисление
        # Обратите внимание, мы вызываем саму fibonacci, которая уже кэширована!
        result = fibonacci(n - 1) + fibonacci(n - 2)
        
        # Сохраняем результат в кэш перед возвратом
        cache[n] = result
        return result
        
    return fibonacci

fast_fib = make_memoized_fibonacci()

# Это вычисляется мгновенно! Без кэша fib(100) повесило бы компьютер.
print(fast_fib(100)) # 354224848179261915075

Что такое Мемоизация (Memoization)?

Какая структура данных Python (тип) идеально подходит для реализации кэша (быстрый поиск по ключу-аргументу)?

Задание

Подумайте о проблемах кэширования.

  • Представьте, что ваша функция-кэш должна работать годами (например, на сервере).
  • Что произойдет со словарем cache = {} через год работы, если на вход поступают миллионы уникальных запросов?
  • Ответ: Утечка памяти (Out Of Memory), так как словарь бесконечно растет. В реальности используют LRU (Least Recently Used) кэши с ограничением размера.
10 баллов
python
import time

# Демонстрация скорости с кэшем и без (псевдокод для понимания)
# def slow_func(n): time.sleep(1); return n
# cached_func = make_memoize(slow_func)

# print(cached_func(5)) # Ждем 1 секунду
# print(cached_func(5)) # Мгновенно! (извлекается из словаря)

Мы приближаемся к финалу нашего погружения в функциональное программирование и должны рассмотреть продвинутую архитектурную концепцию: Разделяемое состояние через множественные замыкания (Shared State Enclosing). До сих пор все наши фабрики возвращали только одну единственную функцию (например, только `counter`). Но язык Python не накладывает никаких ограничений на то, сколько функций может быть определено внутри объемлющей (enclosing) области видимости! Что произойдет, если мы создадим внутри фабрики две, три или более вложенных функций, и все они будут ссылаться на одну и ту же локальную переменную внешней функции? Ответ: они все будут разделять одну и ту же ячейку памяти (cell object)! Если первая функция изменит переменную с помощью `nonlocal`, вторая функция мгновенно увидит это изменение. Эта мощная механика позволяет нам реализовать полноценную инкапсуляцию и сокрытие данных в стиле ООП (с геттерами и сеттерами), но исключительно функциональным путем. Представьте, что мы пишем симулятор банковского счета. У счета есть приватный баланс (сумма денег). Если мы сделаем баланс глобальной переменной или даже атрибутом простого класса, другие программисты смогут случайно (или намеренно) написать код вида `account.balance += 1000000`, минуя все наши проверки безопасности. Используя множественные замыкания, мы можем создать внешнюю функцию `create_account(initial_balance)`, а внутри определить функции `deposit(amount)`, `withdraw(amount)` и `get_balance()`. Эти внутренние функции содержат строгую логику проверок (например, нельзя снять больше, чем есть на счете). Затем внешняя функция возвращает словарь, кортеж или именованный кортеж (namedtuple), содержащий ссылки на эти три безопасные функции. Сама переменная `balance` остается намертво запертой внутри замыкания. Абсолютно никакой код извне не сможет напрямую обратиться к ячейке памяти `balance`. Взаимодействие с деньгами становится возможным только через наши возвращенные функции-интерфейсы. Это высший пилотаж инкапсуляции данных на уровне интерпретатора, паттерн, который часто называют «модулем» или «фасадной фабрикой» в функциональных языках типа JavaScript, но который прекрасно работает и в Python!

python
def create_bank_account(initial_balance=0):
    # Приватная (закрытая) переменная состояния
    balance = initial_balance
    
    def deposit(amount):
        nonlocal balance
        if amount > 0:
            balance += amount
            print(f"Пополнено: {amount}. Баланс: {balance}")
        else:
            print("Ошибка: сумма должна быть > 0")
            
    def withdraw(amount):
        nonlocal balance
        if 0 < amount <= balance:
            balance -= amount
            print(f"Снято: {amount}. Баланс: {balance}")
        else:
            print("Ошибка: недостаточно средств или неверная сумма.")
            
    def get_balance():
        # Только чтение, nonlocal не нужен
        return balance
        
    # Возвращаем интерфейс для работы со счетом в виде словаря функций
    return {
        'deposit': deposit,
        'withdraw': withdraw,
        'get_balance': get_balance
    }

# Создаем защищенный счет
my_account = create_bank_account(100)

# Использование интерфейса
my_account['deposit'](50)   # Пополнено: 50. Баланс: 150
my_account['withdraw'](20)  # Снято: 20. Баланс: 130
print(my_account['get_balance']()) # 130

# Взломать balance НАПРЯМУЮ невозможно! Переменная надежно скрыта.

Почему паттерн разделяемого состояния (как в примере с банковским счетом) считается безопасным способом инкапсуляции?

Как называется принцип ООП, который мы успешно имитируем с помощью замыканий, скрывая прямой доступ к данным и предоставляя только интерфейсные методы? (Существительное)

Задание

Мысленный эксперимент: две фабрики.

  • acc1 = create_bank_account(100)
  • acc2 = create_bank_account(500)
  • Связаны ли балансы этих двух счетов?
  • Ответ: НЕТ. Каждый вызов внешней фабрики создает совершенно новый фрейм и новые, независимые объекты-ячейки.
10 баллов
Уровень защиты Механизм Можно ли взломать извне?
Публичный (Class attrs) self.balance = 0 Да, легко: obj.balance = 99
Условная приватность self._balance или self.__balance Да (name mangling обходится легко)
Замыкание (Closure) Локальная переменная внешней функции Почти невозможно (только через сложные хаки с __closure__)

Подводя итог этому масштабному погружению в замыкания, необходимо затронуть еще один неочевидный, но критически важный системный аспект: Замыкания и сборщик мусора (Garbage Collector, GC). Как мы уже неоднократно подчеркивали, замыкания продлевают жизненный цикл переменных. В обычном потоке выполнения, как только функция выполняет инструкцию `return`, все ее локальные переменные, словари, списки и тяжелые объекты немедленно удаляются из оперативной памяти механизмом подсчета ссылок (reference counting). Однако, если эти объекты захвачены замыканием (попали в `__closure__`), счетчик их ссылок не падает до нуля! Объект внутренней функции хранит ссылку на объект-ячейку, а ячейка хранит ссылку на оригинальные данные. Это означает, что пока в вашей программе живет объект замыкания (например, пока переменная `my_callback` не будет перезаписана или удалена), будут жить и все захваченные им данные. Это может привести к серьезным утечкам памяти (Memory Leaks), если вы неосторожно захватываете замыканием огромные массивы данных, датафреймы Pandas или глубокие графы объектов в тех случаях, когда они вам на самом деле больше не нужны, но вы забыли обнулить ссылку на замыкание. Особенно часто такие утечки происходят в приложениях с долгоживущим циклом событий (asyncio, веб-сокеты, графические интерфейсы), где старые обработчики событий (callbacks) продолжают висеть в памяти, держа за собой мегабайты контекста. Правильное инженерное решение в таких случаях — использовать библиотеку `weakref` (слабые ссылки) или просто явно удалять замыкания с помощью `del my_func`, когда их работа завершена. Замыкания — это фантастически мощный инструмент, который делает код декларативным, избавляет от написания шаблонных классов и лежит в основе самых продвинутых фишек Python. Вы изучили их от базового LEGB до объектов-ячеек и паттерна разделяемого состояния. Теперь вы полностью готовы к следующему гигантскому шагу в вашей карьере Python-разработчика: к Декораторам, которые технически являются просто замыканиями, принимающими другие функции в качестве аргумента. Поздравляю с освоением одной из самых сложных концепций языка!

Как замыкания влияют на работу Сборщика мусора (Garbage Collector) в Python?

Какая ошибка возникает при попытке изменить неизменяемую переменную внутри замыкания БЕЗ использования nonlocal?

Какая встроенная функция/модуль может помочь зафиксировать аргументы (спасти от позднего связывания) без использования лямбд с default arguments?

Задание

Проведите финальный код-ревью (мысленно).

  • Джуниор написал: def make_counter(): count=0; def cnt(): count+=1; return cnt
  • Вы как Senior должны указать на две ошибки.
  • Ошибка 1: забыт nonlocal count.
  • Ошибка 2: забыт return count внутри вложенной функции (хотя это зависит от ТЗ, но без возврата счетчик бесполезен).
10 баллов

Учитывая, что замыкания инкапсулируют логику и данные, к созданию какой мощнейшей конструкции в Python (начинается на 'Д') мы полностью подготовились?

Мастер-квиз: Выберите ВСЕ правильные утверждения о замыканиях (выберите одно наиболее полное).