сеньорчикОткрыть в Telegram
← вся теориятеория к собесу · Асинхронность в Python

Декораторы, замыкания и области видимости в Python

Зачем это спрашивают

Области видимости и замыкания - питон-ловушки номер один: UnboundLocalError на ровном месте и лямбды из цикла, дружно вернувшие последнее значение. Оба вопроса - стабильные гости собесов, потому что оба ловятся только пониманием.

// Сюда же декораторы: их просят объяснить или написать почти на каждом мидл-собесе.

LEGB и внезапная локальность

Имя ищется по цепочке Local → Enclosing → Global → Builtins. Тонкость в другом: любое присваивание внутри функции делает имя локальным на всю функцию - ещё до строки присваивания.

Отсюда UnboundLocalError: x += 1 при глобальном x это чтение локальной переменной, которая ещё не создана. Объявить намерение писать наружу - global/nonlocal, но нужда в global - обычно запах дизайна.

// Читается это так: компилятор размечает области при разборе функции целиком, а не построчно.

LEGB
порядок поиска имени: local (внутри функции), enclosing (объемлющая функция), global (модуль), builtins (встроенные имена)

Замыкание держит переменную, не значение

Замыкание захватывает саму переменную: лямбды, созданные в цикле, к моменту вызова видят её последнее значение.

fns = [lambda: i for i in range(3)]
[f() for f in fns]      # [2, 2, 2]

fns = [lambda i=i: i for i in range(3)]
[f() for f in fns]      # [0, 1, 2] — дефолт снял значение

Аргументы и декораторы

Передача аргументов - по ссылке на объект: мутация списка внутри функции видна снаружи, а переприсваивание - нет. Сигнатуры умеют keyword-only (после *) и positional-only (до /) - так фиксируют контракты API.

Декоратор - функция над функцией: принял, обернул, вернул. Обёртке нужен functools.wraps - без него у функции теряются __name__ и докстринг, ломаются логи и тесты.

// Функции - объекты первого класса: лежат в словаре, и elif-лестница превращается в диспетчеризацию handlers[kind](payload).

functools.wraps
переносит метаданные функции на обёртку декоратора

Как отвечать: «Что вернут лямбды, созданные в цикле, и как это починить?»

Все вернут последнее значение переменной цикла - [2, 2, 2] для range(3): замыкание захватывает саму переменную, а не её снимок, и к моменту вызова цикл уже дошёл до конца. Чиню параметром по умолчанию - lambda i=i: i - дефолт вычисляется в момент создания и фиксирует текущее значение; альтернатива - functools.partial. Важно, что это не квирк лямбд: обычные вложенные функции и генераторы, замыкающие переменную цикла, ведут себя так же.

Механизм, два фикса и обобщение за пределы лямбд - три уровня, отличающие понимание от заученного ответа.

На чём валят

  • x += 1 при глобальном x - UnboundLocalError, а не инкремент глобала.
  • [lambda: i for i in range(3)] - все три вернут 2.
  • Мутировать переданный список «незаметно» - сюрприз у вызывающего кода.
  • Декоратор без functools.wraps - сломанные __name__ и __doc__.
  • **kwargs, молча проглатывающий опечатки в именах аргументов.

Проверьте себя

Пять вопросов из банка по этой подтеме. Всего их 18, остальные разбираются в тренажёре.

  1. #functions_scope1 / 5
    Зачем нужны global и nonlocal, если функция и так видит переменные из внешних областей?
    A)Они ускоряют доступ к внешним переменным, кешируя их значение в локальном пространстве функции на всё время её работы
    B)Читать внешнее имя можно и без них; но присваивание делает имя локальным — global/nonlocal разрешают присвоить именно внешнему
    C)Global и nonlocal нужны для многопоточности — чтобы синхронизировать доступ к переменным между потоками
    D)Они запрещают функции менять внешние переменные, делая внешнее окружение доступным для чтения
    показать ответ и разбор
    +B)Читать внешнее имя можно и без них; но присваивание делает имя локальным — global/nonlocal разрешают присвоить именно внешнему

    // разбор: По правилу LEGB функция читает имена из внешних областей свободно. Но любое присваивание внутри функции по умолчанию создаёт локальную переменную. Чтобы присвоить именно глобальной, объявляют global, чтобы переменной охватывающей функции — nonlocal. Без них присваивание не «дотянется» до внешнего имени, а лишь заведёт локальное.

  2. #functions_scope2 / 5
    Что по сути делает декоратор над функцией (например, @timing)?
    A)Декоратор переименовывает функцию и меняет порядок её аргументов на обратный перед каждым её вызовом в программе
    B)Декоратор — это просто комментарий для читателя кода, который интерпретатор Python при запуске игнорирует
    C)Декоратор превращает обычную функцию в генератор, заставляя её отдавать значения по одному через yield вместо return
    D)Оборачивает функцию: принимает её и возвращает новую с доп-поведением; @dec над def f эквивалентно f = dec(f)
    показать ответ и разбор
    +D)Оборачивает функцию: принимает её и возвращает новую с доп-поведением; @dec над def f эквивалентно f = dec(f)

    // разбор: Декоратор — функция, которая принимает функцию и возвращает функцию, обычно обёртку: та вызывает исходную и добавляет поведение (лог, кеш, замер времени, проверку прав). Синтаксис @dec над def f — сахар для f = dec(f). Это не комментарий, не переименование и не про генераторы. Внутри обёртки обычно применяют functools.wraps.

  3. #functions_scope3 / 5
    Функция читает глобальную count, но на строке count += 1 падает с UnboundLocalError. Почему?
    A)Потому что глобальную переменную в Python не получится ни читать, ни менять изнутри функции без её явной передачи через аргументы
    B)Потому что имя count занято встроенной функцией языка, и его не получится использовать как обычную переменную в коде
    C)Присваивание где-либо в теле делает count локальной на всю функцию; += читает её ещё до присвоения — нужен global count
    D)Потому что операция += не определена для целых чисел в Python и завершается ошибкой обращения к переменной
    показать ответ и разбор
    +C)Присваивание где-либо в теле делает count локальной на всю функцию; += читает её ещё до присвоения — нужен global count

    // разбор: Область имени определяется статически на всю функцию: раз в теле есть присваивание count (а += — это и чтение, и присваивание), count считается локальной во всей функции. На += справа она читается ещё до того, как ей что-то присвоено, — отсюда UnboundLocalError. Лечится объявлением global count (или nonlocal во вложенной функции).

  4. #functions_scope4 / 5
    Что делает functools.lru_cache на функции и когда она опасна?
    A)Кеширует результат по аргументам; опасна на нечистых функциях
    B)Ускоряет функцию, компилируя её тело в машинный код
    C)Ограничивает число вызовов функции в единицу времени
    D)Запоминает последний вызов, чтобы повторить его при ошибке
    показать ответ и разбор
    +A)Кеширует результат по аргументам; опасна на нечистых функциях

    // разбор: lru_cache хранит словарь «аргументы → результат» и при повторе функцию не зовёт. Отсюда три условия: аргументы должны быть хешируемыми, функция — чистой (иначе кеш вернёт устаревшее), а размер кеша ограничен, иначе он держит объекты и не пускает их к сборщику мусора. Классическое применение — рекурсия с перекрывающимися подзадачами.

  5. #functions_scope5 / 5
    Что вернёт функция, в теле которой нет ни одного return?
    A)Ноль — нейтральное значение по умолчанию
    B)Значение последнего вычисленного выражения
    C)None
    D)Ошибку: функция обязана что-то возвращать
    показать ответ и разбор
    +C)None

    // разбор: Дойдя до конца тела, функция неявно возвращает None — то же самое даёт голый return без значения. Отсюда тихий баг: забыли return в ветке if, и вызывающий код получает None вместо числа, а падает всё позже и в другом месте. Возврат последнего выражения — соглашение других языков (Ruby, Rust); Python так не делает.

дальше

Теорию прочитали. Навык ставится повторением

В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.