Декораторы, замыкания и области видимости в 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, остальные разбираются в тренажёре.
- Зачем нужны global и nonlocal, если функция и так видит переменные из внешних областей?A)Они ускоряют доступ к внешним переменным, кешируя их значение в локальном пространстве функции на всё время её работыB)Читать внешнее имя можно и без них; но присваивание делает имя локальным — global/nonlocal разрешают присвоить именно внешнемуC)Global и nonlocal нужны для многопоточности — чтобы синхронизировать доступ к переменным между потокамиD)Они запрещают функции менять внешние переменные, делая внешнее окружение доступным для чтения
показать ответ и разбор
+B)Читать внешнее имя можно и без них; но присваивание делает имя локальным — global/nonlocal разрешают присвоить именно внешнему// разбор: По правилу LEGB функция читает имена из внешних областей свободно. Но любое присваивание внутри функции по умолчанию создаёт локальную переменную. Чтобы присвоить именно глобальной, объявляют global, чтобы переменной охватывающей функции — nonlocal. Без них присваивание не «дотянется» до внешнего имени, а лишь заведёт локальное.
- Что по сути делает декоратор над функцией (например, @timing)?A)Декоратор переименовывает функцию и меняет порядок её аргументов на обратный перед каждым её вызовом в программеB)Декоратор — это просто комментарий для читателя кода, который интерпретатор Python при запуске игнорируетC)Декоратор превращает обычную функцию в генератор, заставляя её отдавать значения по одному через yield вместо returnD)Оборачивает функцию: принимает её и возвращает новую с доп-поведением; @dec над def f эквивалентно f = dec(f)
показать ответ и разбор
+D)Оборачивает функцию: принимает её и возвращает новую с доп-поведением; @dec над def f эквивалентно f = dec(f)// разбор: Декоратор — функция, которая принимает функцию и возвращает функцию, обычно обёртку: та вызывает исходную и добавляет поведение (лог, кеш, замер времени, проверку прав). Синтаксис @dec над def f — сахар для f = dec(f). Это не комментарий, не переименование и не про генераторы. Внутри обёртки обычно применяют functools.wraps.
- Функция читает глобальную count, но на строке count += 1 падает с UnboundLocalError. Почему?A)Потому что глобальную переменную в Python не получится ни читать, ни менять изнутри функции без её явной передачи через аргументыB)Потому что имя count занято встроенной функцией языка, и его не получится использовать как обычную переменную в кодеC)Присваивание где-либо в теле делает count локальной на всю функцию; += читает её ещё до присвоения — нужен global countD)Потому что операция += не определена для целых чисел в Python и завершается ошибкой обращения к переменной
показать ответ и разбор
+C)Присваивание где-либо в теле делает count локальной на всю функцию; += читает её ещё до присвоения — нужен global count// разбор: Область имени определяется статически на всю функцию: раз в теле есть присваивание count (а += — это и чтение, и присваивание), count считается локальной во всей функции. На += справа она читается ещё до того, как ей что-то присвоено, — отсюда UnboundLocalError. Лечится объявлением global count (или nonlocal во вложенной функции).
- Что делает functools.lru_cache на функции и когда она опасна?A)Кеширует результат по аргументам; опасна на нечистых функцияхB)Ускоряет функцию, компилируя её тело в машинный кодC)Ограничивает число вызовов функции в единицу времениD)Запоминает последний вызов, чтобы повторить его при ошибке
показать ответ и разбор
+A)Кеширует результат по аргументам; опасна на нечистых функциях// разбор: lru_cache хранит словарь «аргументы → результат» и при повторе функцию не зовёт. Отсюда три условия: аргументы должны быть хешируемыми, функция — чистой (иначе кеш вернёт устаревшее), а размер кеша ограничен, иначе он держит объекты и не пускает их к сборщику мусора. Классическое применение — рекурсия с перекрывающимися подзадачами.
- Что вернёт функция, в теле которой нет ни одного return?A)Ноль — нейтральное значение по умолчаниюB)Значение последнего вычисленного выраженияC)NoneD)Ошибку: функция обязана что-то возвращать
показать ответ и разбор
+C)None// разбор: Дойдя до конца тела, функция неявно возвращает None — то же самое даёт голый return без значения. Отсюда тихий баг: забыли return в ветке if, и вызывающий код получает None вместо числа, а падает всё позже и в другом месте. Возврат последнего выражения — соглашение других языков (Ruby, Rust); Python так не делает.
дальше
Теорию прочитали. Навык ставится повторением
В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.