Deep Engineering
Средний·Опубликовано·3.11 · 3.12 · 3.13 · 3.14·35 МИН

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

Переменная включения наружу не течёт — это знают все. Но первый итерируемый вычисляется снаружи, а остальные части внутри, и в теле класса это даёт NameError на имя, написанное строкой выше. Наружу при этом всё-таки течёт одна вещь: морж.

Полное техническое изложение

TL;DR

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

Отсюда главное следствие: четыре строки подряд в одном теле класса, из которых работает первая. Справочник: aside from the iterable expression in the leftmost for clause, the comprehension is executed in a separate implicitly nested scope (кроме выражения-источника в самом левом предложении for, включение выполняется в отдельной неявно вложенной области видимости). Поэтому [x for x in raw] работает, а [x * multiplier for x in raw] даёт NameError, хотя multiplier написан строкой выше; в функции те же строки работают все. Хуже случай, который не падает: включение в теле класса находит модульное имя вместо классового и даёт [2, 3, 5] там, где обычный цикл строкой ниже даёт [200, 300, 500].

Дальше — то, что отличает знающего от читавшего. Наружу течёт ровно одна вещь — морж: [(seen := v) for v in items] оставляет seen связанным снаружи, и PEP 572 предписывает это нормативно; он же запрещает морж во включении в теле класса — такая запись не компилируется вовсе. PEP 709 (3.12) встроил включения в вызывающий код — MAKE_FUNCTION у них больше нет; область видимости при этом не изменилась: одни и те же три NameError на 3.11, 3.12, 3.13 и 3.14. Выбор «включение или цикл или map» стоит около пятнадцати процентов, и включение здесь не проигрывает: оно и самое быстрое из трёх. Решает при этом другое — убрать вызов кода на Python, 1,7 раза.

Порог входа
Перед уроком достаточно понимать
  • обычный цикл for по списку и накопление результата через append;
  • что у функции свои локальные имена, а у модуля — свои, и одно и то же имя может существовать в обоих местах;
  • что тело class — это тоже код, который выполняется сверху вниз, когда класс создаётся.
Заранее знать не нужно
  • что такое «неявно вложенная область видимости», чем самый левый for отличается от остальных частей включения;
  • морж :=, PEP 572, PEP 709, MAKE_FUNCTION, асинхронные включения.

Что здесь на самом деле спрашивают

Расхожее знание про включения — одна строка: «переменная цикла наружу не течёт, в отличие от обычного for». Это верно, проверяется в три строки и исчерпывает тему ровно наполовину.

Вторая половина в справочнике стоит рядом с первой, и её обычно дочитывают до запятой:

However, aside from the iterable expression in the leftmost for clause, the comprehension is executed in a separate implicitly nested scope.

Справочник языка — отображения списков, множеств и словарей

Дословно: «однако, за исключением итерируемого выражения в самом левом for, включение исполняется в отдельной, неявно вложенной области видимости». То есть граница проходит не по квадратным скобкам, а внутри них: одна часть включения остаётся снаружи, все остальные — внутри.

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

База: включение — это цикл, записанный одним выражением

Включение не вводит нового вида вычисления. Всё, что оно делает, можно записать обычным циклом, и обычно именно так его и объясняют — как более короткую запись того же самого.

Вот две записи одного расчёта рядом:

PYTHON
raw = [2, 3, 5]
 
doubled = []                    # длинная запись
for x in raw:
    doubled.append(x * 10)      # [20, 30, 50]
 
doubled = [x * 10 for x in raw]  # короткая запись — то же самое, [20, 30, 50]

Читаются они одинаково, если читать включение справа налево: сначала for x in raw — откуда берутся элементы, потом x * 10 — что положить в результат. Третья часть, необязательная, — условие if, которое решает, попадёт элемент в результат или нет. Всё вместе даёт список, и на этом уровне между двумя записями действительно нет разницы: одни и те же элементы, один и тот же порядок, один и тот же результат.

Одно отличие всё же есть, и его знают все: переменная включения наружу не течёт, а переменная обычного цикла остаётся. У этого отличия есть причина — включение выполняется не там же, где окружающий код, а в собственной, отдельной от него области видимости.

И вот главный вопрос урока: если у включения своя область видимости, то куда оно смотрит, когда ищет имя? Обычный цикл ищет имена там же, где стоит сам. Включение — не обязательно там же; и границей между «там же» и «не там же» оказываются вовсе не квадратные скобки.

Этого уже достаточно, чтобы ответить на базовый вопрос собеседования. Всё дальнейшее — про то, где именно проходит эта граница, что через неё всё-таки проходит наружу и почему единственное место, где разница заметна невооружённым глазом, — тело класса.

Механизм 1: переменная не течёт — и это половина правила

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

Начнём с той половины, которую знают:

PYTHON
y = "значение снаружи"
squares = [y for y in range(3)]
y                       # 'значение снаружи' — не тронуто
 
for y2 in range(3):
    pass
y2                      # 2 — обычный цикл переменную оставляет

Это и есть тот самый ответ, который дают на собеседовании. Он верен. Дальше начинается то, что из него не следует.

Механизм 2: первый итерируемый вычисляется снаружи

Справочник говорит и это, следующей фразой: The iterable expression in the leftmost for clause is evaluated directly in the enclosing scope and then passed as an argument to the implicitly nested scope (Выражение-источник в самом левом предложении for вычисляется прямо в объемлющей области видимости и затем передаётся аргументом в неявно вложенную область).

Внутри функции разницу не увидеть: у функции есть замыкание, и включение прекрасно видит её локальные имена. Место, где разница вылезает, — тело класса, и правило для него в справочнике записано отдельно:

The scope of names defined in a class block is limited to the class block; it does not extend to the code blocks of methods. This includes comprehensions and generator expressions.

Справочник языка — модель исполнения, разрешение имён

Сложив две цитаты, получаем предсказание: в теле класса включение увидит первый итерируемый и не увидит ничего больше. Проверка:

PYTHON
class Prices:
    raw = [2, 3, 5]
    multiplier = 10
    limit = 3
 
    ok_first   = [x for x in raw]                    # [2, 3, 5]
    bad_expr   = [x * multiplier for x in raw]       # NameError
    bad_cond   = [x for x in raw if x > limit]       # NameError
    bad_second = [x + y for x in raw for y in raw]   # NameError
 
    loop = []                                        # а цикл видит всё
    for x in raw:
        loop.append(x * multiplier)                  # [20, 30, 50]
строкарезультат
[x for x in raw][2, 3, 5]
[x * multiplier for x in raw]NameError: name 'multiplier' is not defined
[x for x in raw if x > limit]NameError: name 'limit' is not defined
[x + y for x in raw for y in raw]NameError: name 'raw' is not defined
тот же расчёт через for/append[20, 30, 50]

Самая наглядная — четвёртая. Имя raw написано в ней дважды, и первое вхождение видно, а второе нет: первое стоит в самом левом for, второе — уже внутри вложенной области.

И последняя строка таблицы важна не меньше первых: обычный цикл в том же теле класса, на том же уровне отступа, видит и raw, и multiplier. Никакой отдельной области он не заводит.

Механизм 3: наружу всё-таки течёт одна вещь

Правило «переменная не течёт» читатель достраивает до «из включения наружу ничего не течёт». Одно исключение есть, и оно не побочный эффект реализации, а записанное решение:

an assignment expression occurring in a list, set or dict comprehension or in a generator expression … binds the target in the containing scope, honoring a nonlocal or global declaration for the target in that scope, if one exists.

PEP 572 — Assignment Expressions

То есть := внутри включения связывает имя снаружи:

PYTHON
items = [4, 7, 2]
result = [(seen := v) for v in items]
result                  # [4, 7, 2]
v                       # NameError — переменная цикла осталась внутри
seen                    # 2 — а морж вышел наружу

Две переменные в одном выражении ведут себя противоположно.

Мотив PEP называет прямо, и он не про удобство записи: allows us to conveniently capture a "witness" for an any() expression, or a counterexample for all() (позволяет удобно поймать «свидетеля» для выражения any() или контрпример для all()). Без моржа свидетеля пришлось бы искать вторым проходом:

PYTHON
data = [1, 3, 8, 5]
if any((witness := d) > 4 for d in data):
    print(witness)      # 8 — первое значение, на котором сработало

У мотива есть обратная сторона, и она в этом же примере. Если условие не сработало ни разу, имя всё равно связано — последним проверенным значением:

PYTHON
small = [1, 2, 3]
if any((w := d) > 100 for d in small):
    ...
else:
    w                   # 3, а не «ничего»

Проверять надо результат any(), а не наличие имени.

Три запрета, которые ловятся при компиляции

PEP оговаривает три случая, и все три падают ещё до запуска. Проверено компиляцией на всех четырёх версиях — прогон bench/comprehensions/class_scope_walrus.py:

записьсообщение
[(i := i) for i in range(3)]assignment expression cannot rebind comprehension iteration variable 'i'
[x for x in (n := range(3))]assignment expression cannot be used in a comprehension iterable expression
[x for r in [[1]] for x in (n := r)]то же сообщение
class C: [(j := i) for i in range(5)]assignment expression within a comprehension cannot be used in a class body

Первый запрет PEP формулирует так: an assignment expression target name cannot be the same as a for-target name appearing in any comprehension containing the assignment expression (имя цели присваивающего выражения не может совпадать с именем цели for ни в одном включении, содержащем это присваивающее выражение). Запрет узкий: он про ПЕРЕСВЯЗЫВАНИЕ переменной включения, и морж соседним именем — [(k := i) for i in range(3)] — компилируется без возражений.

Второй — шире, чем можно подумать: морж запрещён в итерируемой части любого for во включении, не только первого.

Крайний случай: в теле класса морж во включении запрещён вовсе

Третий запрет и есть тот, из-за которого этот раздел стоит рядом с разделом про тело класса, и он стоит отдельного упоминания: это единственное место, где сама запись оказывается недопустимой, а не даёт неверный результат.

Морж во включении связывает имя в объемлющей области — но в теле класса связывать нечего: включение туда не достаёт (это и есть NameError выше), и язык запрещает такую запись целиком. Отказ приходит на компиляции, до первого запуска, сообщением assignment expression within a comprehension cannot be used in a class body. Тот же морж в функции и на уровне модуля компилируется и связывает имя снаружи:

морж в теле класса:            SyntaxError
тот же морж в теле функции:    компилируется
тот же морж на уровне модуля:  компилируется, снаружи j = 4

Механизм 4: ошибка, которая не падает

NameError из раздела выше — ошибка честная: она падает, и её видно. Хуже, когда имя всё-таки находится, но не то.

Включение в теле класса не видит атрибутов класса — но прекрасно видит имена модуля. Если имя есть и там и там, разойдутся не типы, а числа:

PYTHON
SCALE = 1                       # модульная константа
 
class Prices:
    SCALE = 100                 # «переопределили» на уровне класса
    raw = [2, 3, 5]
 
    scaled = [x * SCALE for x in raw]   # берёт МОДУЛЬНОЕ имя
 
    loop = []                           # берёт КЛАССОВОЕ
    for x in raw:
        loop.append(x * SCALE)

Замер:

как посчитанорезультат
через включение[2, 3, 5]
через обычный цикл[200, 300, 500]
верно[200, 300, 500]

Две строки в одном теле класса, один отступ, разница в сто раз. Ни одного исключения.

Второй способ получить неверный ответ молча — утёкший морж, затирающий имя, которым пользуются ниже:

PYTHON
def report(events):
    status = "ok"
    failures = [e for e in events if (status := e["status"]) != "ok"]
    return {"failures": len(failures), "overall": status}

Число ошибок здесь считается правильно, а overall — нет: после включения status равен статусу последнего события, а не «ok».

событияfailuresoverall
ok, ok, failed1failed ← неверно
failed, ok, ok1ok ← верно, случайно

Обратите внимание на вторую строку: те же события в другом порядке — и ответ внезапно верный. Один и тот же код на одних и тех же данных отвечает по-разному в зависимости от порядка, и никакой прогон такое надёжно не ловит.

Правильная форма — без моржа: [e for e in events if e["status"] != "ok"].

Глубже: что изменил PEP 709 и чего не изменил

деталь реализации · CPython 3.12Встраивание включений — PEP 709, то есть реализация. Семантика областей видимости при этом сохранена намеренно.

В 3.12 включения перестали быть отдельной функцией. PEP формулирует это одной фразой: This PEP proposes to inline list, dictionary, and set comprehensions into the code where they are defined (Этот PEP предлагает встраивать списковые, словарные и множественные включения прямо в код, где они записаны).

Видно без замеров:

3.113.12, 3.13, 3.14
списковое включениеMAKE_FUNCTION, объект кода <listcomp>ни того, ни другого
словарное включениеMAKE_FUNCTION, <dictcomp>ни того, ни другого
множественное включениеMAKE_FUNCTION, <setcomp>ни того, ни другого
генераторное выражениеMAKE_FUNCTION, <genexpr>MAKE_FUNCTION, <genexpr>

Генераторных выражений PEP 709 не касается — у них отдельная функция и отдельный кадр остались во всех четырёх версиях.

А область видимости не изменилась. Это стоит проверить, а не предположить: встраивание выглядит как раз тем изменением, которое могло бы её сдвинуть. Проверка — тот же замер на четырёх версиях: три одинаковых NameError везде, с точностью до текста сообщения. PEP перечисляет три видимых изменения — поведение locals(), отсутствие отдельного кадра в трассировке и то, что sys.settrace больше не видит вызова и возврата, — и области видимости среди них нет.

Заявленный эффект PEP называет двумя числами и с разной силой: «1.96x faster» на микрозамере одних только включений и «11% faster» на pyperformance. Второе число и есть то, на что стоит смотреть: первое меряет включения в отрыве от остального кода.

Глубже: асинхронное включение — те же правила плюс точка переключения

Внутри async def включение может стать асинхронным, и определение у этого шире, чем принято думать:

If a comprehension contains async for clauses, or if it contains await expressions or other asynchronous comprehensions anywhere except the iterable expression in the leftmost for clause, it is called an asynchronous comprehension. An asynchronous comprehension may suspend the execution of the coroutine function in which it appears.

перевод

Если включение содержит предложения async for либо содержит выражения await или другие асинхронные включения где угодно, кроме выражения итерируемого объекта в самом левом предложении for, оно называется асинхронным включением. Асинхронное включение может приостанавливать выполнение корутинной функции, в которой оно записано.

Справочник языка — Выражения

Два следствия этого определения обычно пропускают (bench/iteration/async_comprehension.py).

Первое: async for не обязателен. Одного await в выражении хватает:

4) [await slow(v) for v in (1, 2)] -> [10, 20]
   порядок: ['await-1', 'await-2']
   async for нет, но await есть — включение всё равно асинхронное

Второе: заражается и внешнее включение. Справочник говорит об этом прямо, пометкой об изменении в 3.11: Outer comprehensions implicitly become asynchronous (Внешние включения неявно становятся асинхронными).

5) [[y async for y in ...] for _ in range(2)] -> [[0, 1], [0, 1]]
   внешнее включение выглядит обычным, но внутри асинхронное

Главное практическое отличие — то самое «может приостанавливать». Включение перестаёт быть неделимым шагом (события скрипт печатает по одному в строку, ниже они сведены в одну и обрезаны после последнего интересного):

1) порядок событий:
   включение началось / src-выдал-0 / фон-0 / src-выдал-1 / фон-1
   / src-выдал-2 / фон-2 / включение кончилось
   фоновые записи попали МЕЖДУ шагами включения

Между элементами списка успевает поработать другая задача. Всё, что в уроке про async сказано про состояние, которое нельзя менять через await, начинается здесь: [await fetch(u) for u in urls] — это не «собрать список», а длинная последовательность точек переключения, и данные под ней могут измениться.

Правило видимости от асинхронности не меняется:

3) после включения внешнее x = 'внешнее' | значения: [0, 1]
   имя цели живёт в неявном вложенном пространстве имён, как у обычного

PEP 709, разобранный выше, встроил и асинхронное включение вместе с остальными: на 3.11 тело компилируется отдельным объектом кода <listcomp>, с 3.12 — в тело самой функции. Асинхронные инструкции при этом тоже сменились: GET_AWAITABLE, SEND на 3.11 против END_ASYNC_FOR, END_SEND, SEND начиная с 3.12. На правило видимости это не повлияло — ровно как и для синхронного случая.

И две мелочи на память. Генераторное выражение с async for — это async_generator, и собрать его можно только через async for, но не list(). А снаружи async def асинхронное включение не компилируется вовсе: SyntaxError: asynchronous comprehension outside of an asynchronous function.

Глубже: сколько стоит выбор между включением, циклом и map

наблюдение замераbench/comprehensions/cost.py, CPython 3.11–3.14. Числа сняты на одной машине; сравнивать их между версиями нельзя — сборки разные.

Спор ведут так, будто выбор что-то стоит. Замер (3.13.7, список из 1000, лучшее из семи прогонов):

способвремяпротив включения
[f(x) for x in src]48,3 мкс×1,00
цикл с out.append(f(x))54,5 мкс×1,13
list(map(f, src))56,4 мкс×1,17

Разброс между тремя — 1,17 раза, и это различимо: прогон повторяет весь замер пять раз и сам сопоставляет расстояние между способами с их собственным шатанием.

  РАЗМАХ ПО 5 ПОВТОРАМ ВСЕГО ЗАМЕРА, в кратностях к включению:
    включение [f(x) for x in src]    x1.00 .. x1.03   размах 0.03
    цикл с out.append(...)           x1.13 .. x1.14   размах 0.01
    list(map(f, src))                x1.17 .. x1.18   размах 0.01
    расстояние между крайними способами   0.17
    наибольший размах одного способа      0.03
    различим ли выбор способа прибором    да

Порядок строк устойчив во всех пяти повторах, и вывод из него получается не тот, ради которого обычно затевают спор: включение и читается лучше, и меряется быстрее остальных двух. Переписывать его в map ради скорости незачем — на этих числах map оказывается самым медленным из трёх. А вот переписывать цикл во включение ради пятнадцати процентов имеет смысл ровно тогда, когда эти пятнадцать процентов кому-то видны, — и следующая таблица показывает, где лежит множитель на порядок больше.

Решает другое — есть ли на каждый элемент вызов кода на Python:

способвремяпротив включения с вызовом
[f(x) for x in src]48,3 мкс×1,00
[x + 1 for x in src]28,4 мкс×0,59
[x for x in src if x]19,7 мкс×0,41
list(filter(None, src))10,5 мкс×0,22

Убрать вызов функции — 1,7 раза. Заменить условие на Python вызовом filter(None, …), который целиком на C, — ещё 1,9. Оба множителя вчетверо больше, чем весь разброс между тремя способами написать цикл.

И здесь же граница: 1,7 и 1,8 — это числа с одной машины, одной сборки и списка из тысячи элементов; на другой машине, другой длине списка и другой функции они будут другими. Переносить с собой стоит не их, а порядок величин: выбор синтаксиса стоит проценты, а убранный вызов кода на Python — разы. Если решение упирается в конкретное число, его меряют на своих данных.

Приём, который перестал помогать

Отдельная строка — совет «вынести метод в локальную переменную»:

PYTHON
out = []
append = out.append          # «чтобы не искать атрибут каждый раз»
for x in src:
    append(f(x))

Замер даёт обратный знак: такой цикл медленнее обычного — на шесть процентов на 3.13.7 и на девять на 3.14.7, то есть устойчиво по знаку на обеих сборках.

Инструкций в теле цикла при этом поровну — девять и девять:

out.append(f(x)) :  STORE_FAST LOAD_FAST LOAD_ATTR   LOAD_FAST PUSH_NULL LOAD_FAST CALL CALL POP_TOP
append(f(x))     :  STORE_FAST LOAD_FAST PUSH_NULL   LOAD_FAST PUSH_NULL LOAD_FAST CALL CALL POP_TOP

Разница не в количестве, а в том, что стоит на месте LOAD_ATTR. Документация dis описывает эту форму так: if STACK[-1] has a method with the correct name, the bytecode pushes the unbound method and STACK[-1]. STACK[-1] will be used as the first argument (self) by CALL (если у STACK[-1] есть метод с нужным именем, байт-код кладёт несвязанный метод и STACK[-1]. STACK[-1] будет использован инструкцией CALL как первый аргумент (self)). То есть self кладётся на стек прямо здесь, и объекта связанного метода не возникает вовсе. Вызов же через локальную переменную — обычный вызов обычного объекта, которому self подставляется на каждом вызове.

Совет пришёл из времён, когда поиск атрибута был непредсказуемо дорогим. С тех пор подешевел он, а не альтернатива ему.

Что с этим делать

Во включении в теле класса обращайтесь только к самому левому источнику. Он вычисляется снаружи и класс-локальные имена видит; всё остальное во включении выполняется во вложенной области и не видит их — это единственное место, где граница области видимости заметна, и заметна она в виде NameError или, хуже, в виде чужого значения. Если нужно больше одного класс-локального имени, обычный цикл или staticmethod там ведут себя предсказуемо.

Морж во включении — только ради свидетеля. Захватить значение, на котором сработал any(), он позволяет и для этого сделан. Использовать его как способ «заодно обновить переменную» — значит писать код, ответ которого зависит от порядка элементов.

Выбирайте между включением, циклом и map по читаемости — она здесь совпадает со скоростью. Разница между ними около пятнадцати процентов, и самым быстрым на этих сборках оказывается включение, то есть тот же вариант, который обычно и читается лучше. Спорить не о чем.

Если горячо — убирайте вызов, а не переписывайте цикл. Выражение вместо функции даёт 1,7 раза, filter(None, …) вместо условия — ещё 1,9. Это вчетверо больше, чем весь спор о синтаксисе.

Не выносите метод в локальную переменную. На проверенных сборках приём стоит шесть и девять процентов, и стоит их в неправильную сторону: с адаптивной специализацией обычный obj.method(...) перестал быть медленнее. Это наблюдение CPython 3.13.7 и 3.14.7, а не правило языка — на другой реализации или после очередной правки интерпретатора знак может снова поменяться, и проверяется это одним прогоном.

История версий

ВерсияИзменениеЧто это значит для кода
3.8PEP 572 вводит := и отдельным правилом задаёт, что внутри включения он связывает имя в объемлющей области. Там же — три запрета, которые ловятся при компиляции.
3.12PEP 709 встраивает списковые, словарные и множественные включения в вызывающий код: MAKE_FUNCTION и вложенного объекта кода у них больше нет. Генераторные выражения не затронуты. Область видимости не изменилась — проверено запуском на 3.11, 3.12, 3.13 и 3.14.
3.14Дизассемблер тех же циклов выглядит иначе: вместо LOAD_FAST появляется LOAD_FAST_BORROW. На поведение и на выводы урока это не влияет — счёт инструкций в теле цикла тот же.

Как отвечать на собеседовании

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

Этого достаточно, чтобы ответить верно. Дальше — то, что добавляют, если собеседник копает.

Если интервьюер копает глубже

Наружу течёт ровно одна вещь — морж: [(seen := v) for v in items] оставляет seen связанным снаружи, и PEP 572 предписывает это нормативно.

Что отличает хороший ответ: показать следствие, которое не падает. Включение в теле класса находит модульное имя вместо классового и даёт [2, 3, 5] там, где обычный цикл строкой ниже даёт [200, 300, 500]. И не путать это со встраиванием: PEP 709 в 3.12 убрал у включений MAKE_FUNCTION, но область видимости не изменил — те же три NameError на 3.11, 3.12, 3.13 и 3.14.

И одно место, где легко ошибиться в другую сторону: морж во включении в теле класса не «утекает не туда», а не компилируется вовсе — SyntaxError ещё до запуска.

Дальше спросят

Спросят дальше

Переменная включения наружу не течёт. А что-нибудь оттуда наружу всё-таки видно?

Короткий ответ

Да, и это вторая половина правила: самый левый источник вычисляется прямо в объемлющей области и передаётся внутрь аргументом. Внутри функции разницы не видно — включение видит её локальные имена; вылезает она в теле класса, где область класса на вложенные блоки не распространяется.

Спросят дальше

PEP 709 убрал у включения отдельный кадр. Значит, включение теперь быстрее цикла?

Короткий ответ

Из встраивания это не следует. С 3.12 у спискового, словарного и множественного включения действительно исчезли MAKE_FUNCTION и отдельный объект кода — но что из трёх записей выгоднее на вашей задаче, решает замер в уроке, а не сам факт встраивания.

Частые заблуждения

Утверждение

включение не видит внешних имён — у него своя область

На самом деле

Видит, и внутри функции — все. Отдельная область у включения действительно есть, но справочник оговаривает исключение: aside from the iterable expression in the leftmost for clause (кроме выражения-источника в самом левом предложении for). Внутри функции разницы не заметить вовсе: у функции есть замыкание. Заметна она только в теле класса, где замыкания нет.

Утверждение

из включения наружу ничего не течёт

На самом деле

Течёт морж, и это записано нормативно. PEP 572: присваивающее выражение во включении binds the target in the containing scope (связывает цель в объемлющей области видимости). [(seen := v) for v in items] оставляет seen связанным снаружи, тогда как v — нет. Две переменные в одном выражении ведут себя противоположно.

Утверждение

утечка моржа — побочный эффект встраивания из 3.12

На самом деле

Нет: морж утекает и из генераторного выражения, которого PEP 709 не касается вовсе. И работает это с 3.8, за четыре года до встраивания. Это отдельное правило языка, а не следствие реализации.

Утверждение

включение в теле класса не видит имён класса и потому падает

На самом деле

Падает, только если имени больше негде взяться. Если такое же имя есть на уровне модуля, включение молча возьмёт модульное: [x * SCALE for x in raw] в теле класса даёт [2, 3, 5] при SCALE = 1 в модуле, тогда как обычный цикл строкой ниже даёт [200, 300, 500] при SCALE = 100 в классе.

Утверждение

включение быстрее цикла, поэтому его и пишут

На самом деле

Быстрее — на пятнадцать процентов, и это не повод его писать. Разброс между всеми тремя способами 1,17 раза (включение ×1,00, цикл ×1,13, map ×1,17), порядок устойчив в пяти повторах замера при размахе каждой строки 0,03. Но настоящие множители в другом месте и вчетверо больше: убрать вызов функции из тела — 1,7 раза, заменить условие на Python вызовом filter(None, …) — ещё 1,9. Включение пишут потому, что оно читается, а не потому, что оно на проценты быстрее.

Утверждение

метод, вынесенный в локальную переменную, вызывается быстрее

На самом деле

Медленнее: на шесть процентов на 3.13.7 и на девять на 3.14.7 — знак устойчив на обеих сборках. Инструкций в теле цикла поровну; разница в том, что out.append(...) компилируется в LOAD_ATTR в форме для вызова метода, которая кладёт на стек метод и self сразу, — а вызов через локальную переменную идёт обычным путём. Совет верен для интерпретаторов десятилетней давности.

Практика

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

Практика · что напечатает

Имя multiplier есть и в модуле, и в теле класса. Включение и обычный цикл стоят в теле класса рядом. Что напечатает этот код?
multiplier = 1
raw = [2, 3, 5]


class Config:
  multiplier = 100
  scaled = [x * multiplier for x in raw]

  loop = []
  for x in raw:
      loop.append(x * multiplier)


print(Config.scaled)
print(Config.loop)

Практика · оцените

Одно и то же включение по списку из двухсот строк-значений: с вызовом своей функции на Python и с вызовом метода напрямую. Во сколько раз быстрее вариант без кода на Python?
раза

Проверка знаний

Вопрос 1 из 5

В теле класса написано raw = [2, 3, 5] и строкой ниже [x for x in raw]. Работает?

Чем измерено

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

Источники и что читать дальше

5 ИСТОЧНИКОВ

  1. Справочник языка — отображения списков, множеств и словарейОфициальная документация. Нормативная формулировка, на которой держится весь урок: «However, aside from the iterable expression in the leftmost `for` clause, the comprehension is executed in a separate implicitly nested scope» (Однако, за исключением выражения-источника в самом левом предложении `for`, включение выполняется в отдельной неявно вложенной области видимости). Там же — что происходит с первым итерируемым: «The iterable expression in the leftmost `for` clause is evaluated directly in the enclosing scope and then passed as an argument to the implicitly nested scope» (Выражение-источник в самом левом предложении `for` вычисляется прямо в объемлющей области видимости и затем передаётся аргументом в неявно вложенную область). Проверено запуском на всех четырёх версиях: в теле класса первый итерируемый виден, второй — нет.https://docs.python.org/3.14/reference/expressions.html#displays-for-lists-sets-and-dictionaries
  2. Справочник языка — модель исполнения, разрешение имёнОфициальная документация. Правило, из-за которого включение в теле класса ведёт себя не так, как внутри функции: «The scope of names defined in a class block is limited to the class block; it does not extend to the code blocks of methods. This includes comprehensions and generator expressions, but it does not include annotation scopes, which have access to their enclosing class scopes» (Область видимости имён, определённых в блоке класса, ограничена этим блоком; она не распространяется на блоки кода методов. Сюда входят включения и генераторные выражения, но не входят области аннотаций — у них доступ к объемлющей области класса есть). В самом справочнике рядом стоит пример, падающий ровно так же, как пример этого урока.https://docs.python.org/3.14/reference/executionmodel.html#resolution-of-names
  3. PEP 572 — Assignment ExpressionsPEP. Крис Анджелико, Тим Питерс, Гвидо ван Россум; Final, Python 3.8. Отсюда единственное исключение из правила «наружу ничего не течёт»: «an assignment expression occurring in a list, set or dict comprehension or in a generator expression … binds the target in the containing scope, honoring a nonlocal or global declaration for the target in that scope, if one exists» (присваивающее выражение внутри спискового, множественного или словарного включения либо генераторного выражения … связывает цель в объемлющей области видимости, соблюдая объявление nonlocal или global для этой цели, если оно там есть). Там же назван мотив — «allows us to conveniently capture a 'witness' for an any() expression, or a counterexample for all()» (позволяет удобно поймать „свидетеля“ для выражения any() или контрпример для all()) — и три запрета, все проверены компиляцией.https://peps.python.org/pep-0572/
  4. PEP 709 — Inlined comprehensionsPEP. Карл Мейер, Final, Python 3.12. «This PEP proposes to inline list, dictionary, and set comprehensions into the code where they are defined» (Этот PEP предлагает встраивать списковые, словарные и множественные включения прямо в код, где они записаны). Заявленный эффект назван двумя числами и с разной силой: «1.96x faster» на микрозамере включений и «11% faster» на pyperformance. Из трёх названных видимых изменений ни одно не касается области видимости — и это проверено запуском на всех четырёх версиях.https://peps.python.org/pep-0709/
  5. dis — LOAD_ATTR и форма для вызова методаОфициальная документация. Объяснение того, почему приём «вынести метод в локальную переменную» перестал помогать: «if STACK[-1] has a method with the correct name, the bytecode pushes the unbound method and STACK[-1]. STACK[-1] will be used as the first argument (self) by CALL» (если у STACK[-1] есть метод с нужным именем, байт-код кладёт несвязанный метод и STACK[-1]. STACK[-1] будет использован инструкцией CALL как первый аргумент (self)). Про LOAD_METHOD (страница dis 3.13, в 3.14 фраза убрана): «Emitted as a LOAD_ATTR opcode with a flag set in the arg» (Выдаётся как инструкция LOAD_ATTR с флагом, установленным в аргументе).https://docs.python.org/3.14/library/dis.html#opcode-LOAD_ATTR