Канон / R7 · Импорт — это исполнение кода / Урок 22

Из текста в байткод

Девять фаз между python script.py и первой исполненной инструкцией: запуск процесса, старт интерпретатора, токенайзер, парсер, AST, таблица символов, компилятор, code object, кеш. Каждую можно вызвать руками и посмотреть, что получилось.

L1 · 12 минL2 · 12 мин📱 телефонсверено · CPython 3.11.15, linux x86-64

1Предскажи

Зафиксируй вывод и причину до того, как откроешь ответ.

Фрагмент A
import sys
print(len(sys.modules))
print(sum(1 for m in sys.modules.values()
          if getattr(getattr(m, '__spec__', None), 'origin', None) == 'frozen'))
34
14
До первой твоей строки уже загружено тридцать четыре модуля, четырнадцать из них вшиты в бинарник интерпретатора. Ноль там быть не может: сам механизм импорта — это модуль.
Фрагмент B
code = compile("z = [1, 2, 3]", "<s>", "exec")
print(code.co_consts)
((1, 2, 3), None)
В константах лежит кортеж, хотя в исходнике список. Список изменяем и константой быть не может — компилятор кладёт неизменяемый прототип и разворачивает его в список инструкцией.
Фрагмент C
import io, tokenize
src = "def f():\n    return 1\n"
for t in tokenize.generate_tokens(io.StringIO(src).readline):
    print(tokenize.tok_name[t.type], repr(t.string))
NAME 'def'
NAME 'f'
OP '('
OP ')'
OP ':'
NEWLINE '\n'
INDENT '    '
NAME 'return'
NUMBER '1'
NEWLINE '\n'
DEDENT ''
ENDMARKER ''
INDENT и DEDENT — настоящие токены, наравне со скобками. Отступ в Python не форматирование: он попадает в поток токенов и участвует в грамматике.
Фрагмент D
DEBUG = False

def handle(verbose):
    if verbose:
        DEBUG = True
    if DEBUG:
        return "подробно"
    return "коротко"

print(handle(True))
print(handle(False))
подробно
UnboundLocalError: cannot access local variable 'DEBUG'
where it is not associated with a value
Одна ветка работает, другая падает. Решение о том, что DEBUG — локальная переменная, принято при компиляции, для всей функции сразу, ещё до того как что-то исполнилось.

2Механизм

Между «нажал Enter» и «выполнилась первая инструкция» проходит девять различимых фаз. Ни одна из них не спрятана: у каждой есть модуль в стандартной библиотеке, который позволяет вызвать её отдельно и посмотреть результат. Ниже — весь конвейер по порядку, с числами, снятыми на этой машине.

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

Компиляция происходит не «заранее», а при первом импорте — и её результат кешируется в .pyc. Отсюда следует всё остальное поведение: и время старта, и __pycache__, и то, что синтаксическая ошибка в неимпортированном модуле не мешает программе работать.

Фаза 0 · процесс

Ядро запускает /usr/bin/python3 обычным execve. Динамический загрузчик подтягивает libpython3.11.so (или он вкомпилирован статически), управление уходит в main() из Programs/python.c — файл в двадцать строк, вся его работа состоит в вызове Py_BytesMain.

С точки зрения ОС Python — обычная C-программа. Никакой виртуальной машины уровня JVM с отдельным процессом-хостом здесь нет: интерпретатор это библиотека, слинкованная с крошечным main.

Фаза 1 · инициализация интерпретатора

Modules/main.c разбирает аргументы и строит конфигурацию, после чего Python/pylifecycle.c поднимает интерпретатор. Порядок здесь неслучаен и решает проблему курицы и яйца:

запусквремямодулей в sys.modules
python -c pass13.9 мс34
python -S -c pass9.5 мс23
python -c "import json"22.1 мс—
python -c "import numpy"101.6 мс—

Четыре с половиной миллисекунды — цена site.py. Восемьдесят восемь — цена одного import numpy. Для утилиты командной строки, которую вызывают в цикле из shell-скрипта, это и есть её производительность.

Фаза 2 · чтение исходника

Файл читается как байты и декодируется. По умолчанию UTF-8; PEP 263 разрешает объявить другую кодировку строкой # -*- coding: cp1251 -*- в первых двух строках, и токенайзер вычитывает её раньше, чем начинает разбор. BOM тоже распознаётся.

Это единственная фаза, где Python видит байты. Дальше везде — str, то есть последовательность кодовых точек из урока 02.

Фаза 3 · токенайзер

Parser/tokenizer.c превращает текст в поток токенов. Главная особенность видна во фрагменте C: отступы становятся токенами. Токенайзер держит стек уровней отступа и на каждой строке сравнивает текущий с вершиной стека — глубже выдаёт INDENT, мельче выдаёт по DEDENT на каждый снятый уровень.

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

Фаза 4 · парсер

С версии 3.9 (PEP 617) в CPython PEG-парсер вместо старого LL(1). Грамматика лежит в Grammar/python.gram и из неё генерируется Parser/parser.c — файл на десятки тысяч строк, который никто не пишет руками.

Практическое отличие PEG от LL(1): парсер может попробовать альтернативу, откатиться и попробовать следующую. Это сняло грамматические ограничения, из-за которых часть синтаксиса раньше была невыразима, и дало резко лучшие сообщения об ошибках — «Perhaps you forgot a comma?» появилось именно потому, что парсер способен разобрать неудачную ветку до конца и понять, чего в ней не хватило.

Фаза 5 · AST и свёртка констант

Парсер выдаёт дерево. Модуль ast отдаёт его же в Python-объектах — это буквально то самое дерево, а не отдельная реализация.

На дереве работает Python/ast_opt.c — оптимизатор, который вычисляет то, что можно вычислить сразу. Отсюда фрагмент B и вот это:

import ast
print(ast.dump(ast.parse("x = 1 + 2"), indent=2))
# в дереве останется BinOp: свёртка происходит позже, уже в компиляторе

code = compile("x = 1 + 2\ny = 'ab' * 3", "<s>", "exec")
print(code.co_consts)
# → (3, 'ababab', None)

В константах уже готовые значения: ни сложения, ни умножения при исполнении не будет. Оптимизатор осторожен — он не свернёт 'a' * 10**9, чтобы не раздуть .pyc до гигабайта.

Фаза 6 · таблица символов — самая недооценённая

Python/symtable.c делает отдельный полный проход по дереву до того, как сгенерирована хоть одна инструкция. Его единственная задача — для каждого имени в каждой области видимости решить, что это: локальная, глобальная, ячейка (переменная, которую захватывает вложенная функция) или свободная (захваченная у внешней).

import symtable
code = '''
g = 1
def outer():
    a = 1
    def inner():
        nonlocal a
        b = g
        return a + b
    return inner
'''
st = symtable.symtable(code, "<s>", "exec")
outer = st.get_children()[0]
inner = outer.get_children()[0]
for s in inner.get_symbols():
    print(s.get_name(), "local" if s.is_local() else "",
          "global" if s.is_global() else "", "free" if s.is_free() else "")
# → a   free
# → b  local
# → g   global

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

Из этого одного правила выводится фрагмент D: в handle есть строка DEBUG = True, значит DEBUG локальна на протяжении всей функции, значит if DEBUG читает локальную. При verbose=True она к этому моменту присвоена, при verbose=False — нет. Отсюда же global и nonlocal: это не «модификаторы времени исполнения», а указания этой фазе, единственный способ переопределить её решение.

И отсюда же — почему области видимости в Python лексические и статические, хотя типы динамические. Тип неизвестен до исполнения; принадлежность имени к области известна всегда.

Фаза 7 · компилятор

Python/compile.c обходит дерево, зная решения таблицы символов, и строит граф потока управления из базовых блоков. На графе делаются оптимизации уровня «убрать недостижимый блок», «схлопнуть цепочку переходов», после чего блоки раскладываются в линейный поток байтов.

Одновременно вычисляется три служебных структуры:

Фаза 8 · code object

Результат компиляции — объект code, обычный неизменяемый объект Python. Его можно получить, разобрать, передать, сохранить.

def demo(a, b=2, *args, kw=3, **kwargs):
    total = a + b
    for x in args:
        total += x
    return total

co = demo.__code__
print(co.co_argcount, co.co_kwonlyargcount, co.co_nlocals, co.co_stacksize)
# → 2 1 7 3
print(co.co_varnames)
# → ('a', 'b', 'kw', 'args', 'kwargs', 'total', 'x')
print(len(co.co_code), "байт байткода")
# → 36 байт байткода

Обрати внимание на порядок в co_varnames: сначала позиционные аргументы, потом keyword-only, потом *args и **kwargs, и только потом настоящие локальные. Порядок фиксирован компилятором, и по нему инструкция LOAD_FAST 0 знает, что ноль — это a. Имена в исполнении не участвуют: доступ к локальной переменной это индекс в массиве, а не поиск в словаре.

Это прямое следствие фазы 6. Локальные быстрые именно потому, что таблица символов решила всё заранее.

Фаза 9 · кеш .pyc

Компилировать один и тот же модуль при каждом запуске незачем, поэтому code object сериализуется через Python/marshal.c и кладётся в __pycache__. Формат простой до неприличия: шестнадцать байт заголовка и дальше marshal.

import struct, marshal, importlib.util, os
import json                                  # что угодно из стандартной библиотеки

raw = open(json.__cached__, "rb").read()
magic, flags, mtime, size = struct.unpack("<4sIII", raw[:16])
print(magic.hex(), flags, mtime, size)
print(importlib.util.MAGIC_NUMBER.hex())     # тот же magic
obj = marshal.loads(raw[16:])
print(type(obj).__name__)
# → code
полебайтысмысл
magic0–3Версия формата байткода. Меняется почти каждый релиз — поэтому .pyc от 3.10 просто игнорируется
flags4–70 — валидация по времени, 1 — по хешу источника (PEP 552)
mtime8–11Время модификации исходника на момент компиляции
size12–15Размер исходника в байтах

Валидация по умолчанию — сравнение mtime и размера, а не содержимого. Отсюда практическое: если развернуть код так, что время модификации файла окажется старше, чем записано в кеше, будет исполняться старый байткод. Именно поэтому в сборках, где важна воспроизводимость, включают hash-based .pyc (py_compile --invalidation-mode checked-hash).

Замер на модуле в 1199 строк: первый импорт с компиляцией — 23 мс, повторный с готовым кешем — 14 мс. Сама компиляция стоит 9 мс, и ровно столько платит каждый процесс, у которого __pycache__ недоступен на запись — например контейнер с read-only файловой системой.

3Границы модели

Где сказанное перестаёт держать

Не весь код проходит фазу 9. exec, eval, compile и код из REPL компилируются каждый раз заново — кеша для них нет и быть не может, потому что нет файла. Строка, собираемая в цикле и передаваемая в eval, компилируется на каждой итерации целиком.

Режим компиляции меняет результат. compile(src, name, "exec"), "eval" и "single" дают разный байткод для одного текста: single — режим REPL, он добавляет печать результата выражения. Отсюда разница между «вставил в файл» и «вставил в интерактивную сессию».

Флаги -O и -OO меняют байткод. Первый выкидывает assert и код под if __debug__, второй ещё и строки документации. Кеш при этом кладётся под другим именем (.opt-1.pyc), так что перепутать версии нельзя, но код, чья корректность зависит от assert, ломается молча.

Всё содержимое этой главы — деталь реализации 🔧 целиком. Ни один опкод, ни размер code object, ни формат .pyc не являются частью языка. В 3.12 конвейер уже другой: генерация интерпретатора из Python/bytecodes.c, переработанная линеаризация блоков, другой набор инструкций. Гарантируется семантика, а не путь к ней.

4Корень

Глава разворачивает R7 · импорт — это исполнение кода вниз, к механике. Утверждение «модуль исполняется один раз за процесс» на этом уровне означает: один раз проходятся фазы 2–8, результат кладётся в sys.modules, а фаза 9 переносит его ещё и между процессами.

Отсюда же видно, почему у Python нет фазы линковки. В C имена функций резолвит линковщик и записывает адреса в бинарник. Здесь имён в байткоде почти нет — есть индексы для локальных и строки в co_names для всего остального, и связывание происходит при каждом обращении, в рантайме. Это R2, увиденный со стороны компилятора: связывать заранее нечего, потому что связывать нечем.

И третье: фаза 6 показывает границу между статическим и динамическим в Python точнее любого определения. Область видимости решается статически, тип — динамически. Первое даёт быстрые локальные переменные и UnboundLocalError; второе даёт R8 — невозможность надёжного вывода типов.

5Аналогия

Самая точная аналогия — сборка C-проекта без линковки.

gcc -c file.c даёт file.o: машинный код с дырками на месте внешних имён. Дальше линковщик заполняет дырки адресами. В Python первая половина есть, второй нет: code object — это тот же «объектный файл», где вместо адресов лежат строки, и заполнение дырок происходит при каждом исполнении инструкции.

__pycache__ при этом ведёт себя как make с одной оговоркой: make сверяет зависимости, а Python — только сам файл. Изменил модуль, от которого зависит другой, — второй не перекомпилируется, потому что его собственный mtime не изменился. Ему и не нужно: связывание-то в рантайме.

Отсюда практический вывод, знакомый всем, кто мучился с make clean: в Python не бывает «протухшей сборки» из-за зависимостей. Бывает протухшая из-за mtime самого файла — и это единственный сценарий, который стоит держать в голове.

6Вопросы пытливого ума

Вопросы, которые возникают сами, если читать внимательно. Ответ — под вопросом.

Если Python компилируется, почему его называют интерпретируемым?

Компилируются оба. Разница в том, кому адресован результат: C++ выдаёт машинный код, который исполняет процессор, Python — байткод, который исполняет другая программа. А машинный код Python выдать не может потому, что тип операндов неизвестен до момента выполнения: скомпилировать a + b в машинную инструкцию сложения не из чего.

Дальше — почему сама постановка вопроса неудачна.

В этой паре слов смешаны два разных вопроса, и «компиляция» значит две разные вещи.

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

Настоящий различающий вопрос звучит иначе: кто исполняет результат — железо или программа? После gcc результат исполняет процессор. После compile() результат исполняет другая программа — цикл вычисления из урока 23. По этой оси Python интерпретируемый, и это единственный смысл, в котором утверждение верно.

Причём свойство приписано не тому. В спецификации языка про это нет ни слова: «компилируемый» и «интерпретируемый» — характеристики реализации, а не языка. Nuitka компилирует Python в C, с 3.13 в CPython есть JIT. И наоборот: есть интерпретаторы C — Cling, tcc -run. Никто при этом не говорит, что C стал интерпретируемым.

Добавь сюда то, что почти ничего «интерпретируемое» не интерпретирует текст. Ruby, Lua, JavaScript, Java — все компилируют в промежуточное представление. Прямая интерпретация исходника вымерла вместе с ранним BASIC. То есть популярное значение слова описывает реализации, которых больше нет.

Точная формулировка, которая заменяет всю пару: дело не в компиляции против интерпретации, а в раннем связывании против позднего. В C имена резолвит линковщик и вшивает адреса. В Python имя резолвится при каждом обращении — это R2. Язык с поздним связыванием невозможно скомпилировать в смысле C не потому, что никто не написал компилятор, а потому что вшивать нечего.

И что, до запуска не ловится вообще ничего?

Ловится — но только то, что относится к синтаксису, и только в тех модулях, которые действительно импортируются.

Модуль компилируется целиком до исполнения первой строки. Проверяется буквально:

# selfbad.py
print("первая строка")
print("вторая")
def bad(:
    pass
$ python3 selfbad.py
    def bad(:
            ^
SyntaxError: invalid syntax

Ни одна из двух печатей не выполнилась. Это поведение компилируемого языка, и оно ровно такое же, как у gcc.

А вот сломанный модуль, который никто не импортирует, не проявится никогда — потому что фазы 2–8 для него просто не запускались. Отсюда классическое «в проде упало на редком пути, которого не было в тестах».

Чего не ловится: опечатки в именах, несуществующие атрибуты, несовпадение типов. Но не из-за интерпретации, а потому что в конвейере нет фазы проверки типов — это R8. Добавить её отдельным инструментом можно (mypy), встроить в конвейер нельзя, и урок 18 объясняет почему.

Можно ли поставлять только .pyc, спрятав исходники?

Поставлять можно, спрятать нельзя.

Технически это работает: положи mod.pyc рядом (в старой раскладке, не в __pycache__), удали mod.py — импорт пройдёт.

import py_compile, os, pathlib

pathlib.Path("mod.py").write_text('''
SECRET = "виден ли я"
def f(): return SECRET
''')
py_compile.compile("mod.py", cfile="mod.pyc")
os.remove("mod.py")

import sys; sys.path.insert(0, ".")
import mod
print(mod.f())
# → виден ли я      ← исходника нет, модуль работает

А теперь то, ради чего это обычно затевают:

raw = open("mod.pyc", "rb").read()
print("виден ли я".encode() in raw)   # → True
print(b"SECRET" in raw)               # → True

Все строковые литералы и все имена лежат в .pyc открытым текстом — они обязаны там быть, потому что co_names и co_consts нужны при исполнении. Плюс существуют декомпиляторы, восстанавливающие из байткода читаемый Python почти без потерь: структура управления в байткоде сохранена, а имена переменных лежат в co_varnames.

Итог: .pyc-поставка добавляет ровно один барьер — «нельзя открыть в редакторе». От человека, которому действительно нужно посмотреть, она не защищает никак. И привязывает поставку к минорной версии Python намертво: magic в заголовке меняется каждый релиз.

Почему тогда нельзя собрать Python-программу в один бинарник, как в Go?

Можно, и это делают — но то, что получается, содержит внутри целый интерпретатор.

Два разных подхода, и оба упираются в одно:

PyInstaller, PyOxidizer. Никакой компиляции вообще: складывают в один файл интерпретатор, стандартную библиотеку, твой байткод и зависимости. Это архив с распаковщиком. Размер — десятки мегабайт, старт — плюс время на распаковку.

Nuitka. Настоящая компиляция: превращает Python в C и собирает компилятором. Но сгенерированный C — это не «твой алгоритм на C», а последовательность вызовов CPython API, делающая ровно то, что делал бы цикл вычисления. Поэтому бинарник линкуется с libpython, а ускорение обычно в пределах десятков процентов, а не в разы.

Почему иначе не выходит. Чтобы скомпилировать a + b в машинную инструкцию сложения, компилятору нужно доказать, что a и b — числа определённого типа. В Python этого доказать нельзя: тип определяется в момент обращения (R2), а любое расширение на C может подменить у объекта что угодно (R6). Остаётся генерировать код, который в рантайме проверяет типы и выбирает операцию — то есть ровно интерпретатор, только развёрнутый в прямую линию.

Go компилируется в самостоятельный бинарник не потому, что его авторы старательнее, а потому что там типы известны статически. Это не разница в качестве реализации, это разница в том, что язык обещает.

Если байткод — деталь реализации, почему dis лежит в стандартной библиотеке?

Потому что «не гарантируется» и «не показывается» — разные вещи, и Python последовательно выбирает второе.

dis, symtable, ast, marshal открыты не потому, что их поведение обещано, а потому что закрывать их бессмысленно: на них стоят отладчики, профилировщики, покрытие, линтеры и codemod-инструменты. Спрятать конвейер — значит убить весь этот слой.

Цену за открытость платят регулярно: каждый релиз ломает инструменты, разбирающие байткод. Именно поэтому dis._inline_cache_entries и dis._specialized_instructions названы с подчёркиванием — «смотри, но не жалуйся».

Здесь виден общий принцип канона в чистом виде: механизм показан, обещания на него нет. Ровно та граница, которую размечает шкала 🔒 · 🔧 · 🕳, и ровно тот навык, который она тренирует — различать «я это вижу» и «на это можно опереться».

Итог. Между python script.py и первой инструкцией девять фаз. Процесс стартует как обычная C-программа; интерпретатор поднимает встроенные и замороженные модули, потому что механизм импорта сам написан на Python и импортировать его нечем — 34 модуля и 13.9 мс до первой твоей строки, из них 4.5 мс на site.py. Дальше исходник декодируется, токенайзер превращает отступы в настоящие токены, PEG-парсер строит дерево, оптимизатор сворачивает константы. Затем идёт самая недооценённая фаза: таблица символов отдельным проходом решает для каждого имени, локальное оно или глобальное — статически, для всей функции сразу, без анализа потока управления. Отсюда UnboundLocalError, отсюда global и nonlocal, отсюда быстрые локальные переменные как индекс в массиве вместо поиска в словаре. Компилятор строит граф базовых блоков и раскладывает его в байткод, попутно вычисляя глубину стека, таблицу строк и таблицу исключений. Результат — обычный неизменяемый объект code, который marshal кладёт в .pyc под шестнадцатибайтовым заголовком, а валидируется он по времени модификации и размеру, а не по содержимому. Весь конвейер целиком — 🔧 деталь реализации: гарантируется семантика, а не путь к ней.
Дальше — по желанию
контрфактуалC, Java, JavaScript — три других ответаКомпиляция один раз, компиляция в JVM-байткод, компиляция при каждой загрузке страницы

C и C++. Компиляция полностью отделена от исполнения: препроцессор, компилятор, ассемблер, линковщик — и на выходе машинный код с уже проставленными адресами. Ошибка в неиспользуемой функции ловится до запуска; в Python синтаксическая ошибка в неимпортированном модуле не проявится никогда. Цена симметрична: правка одной строки требует пересборки и перезапуска, а в Python модуль перекомпилируется сам и незаметно.

Java. Ближе всего: javac даёт байткод в .class, JVM его исполняет. Но компиляция там — явный шаг сборки, и .class — часть артефакта, а не кеш. В Python .pyc можно удалить в любой момент без последствий: это оптимизация, а не результат.

JavaScript. Никакого кеша между запусками — движок парсит и компилирует исходник при каждой загрузке. Отсюда весь фронтендный инструментарий минификации: там размер исходника напрямую влияет на время старта. У Python эта проблема решена .pyc — и поэтому в Python никого не волнует длина имён переменных.

🐛 ревьюUnboundLocalError, который проходит ревьюТесты зелёные, падает только вторая ветка — и только в проде

Флаг отладки, который включается по условию. Читается идеально, ревью проходит, тесты проходят.

DEBUG = False

def handle(request, verbose=False):
    if verbose:
        DEBUG = True              # ← «включим подробный режим»
    if DEBUG:
        log_everything(request)
    return process(request)

Что происходит. Присваивание DEBUG = True в теле функции делает имя локальным на всю функцию — решение принято таблицей символов при компиляции, до всякого исполнения. Строка if DEBUG читает локальную переменную. При verbose=True она уже присвоена и всё работает; при verbose=False — UnboundLocalError.

Почему проходит ревью. Глазами читается как «глобальный флаг, который иногда переопределяем». Модуль-уровневый DEBUG = False прямо над функцией усиливает иллюзию. А тесты на подробный режим пишут первыми — и они зелёные.

Проверить за десять секунд:

import dis
dis.dis(handle)
# Ищи LOAD_FAST DEBUG вместо LOAD_GLOBAL DEBUG.
# Ещё быстрее:
print(handle.__code__.co_varnames)   # если DEBUG здесь — он локальный
print(handle.__code__.co_names)      # если DEBUG здесь — глобальный

Как чинить. Не через global — глобальный флаг, который меняют из обработчика запроса, это ошибка сама по себе, особенно с потоками из урока 19. Правильно — не трогать глобальное состояние: if verbose or DEBUG: и никакого присваивания.

Общее правило. Присваивание имени где угодно в функции делает его локальным везде в этой функции. Единственный способ прочитать это из кода — co_varnames против co_names.

⚡ 7.5×Ленивый импорт: 105 мс против 14Утилита командной строки, которая тратит всё время на модуль, который обычно не нужен

Типовой CLI: одна тяжёлая команда, десяток лёгких, и import numpy в шапке файла.

# cli_eager.py — импорт наверху
import sys
import numpy as np              # нужен только для команды "stats"

def main(argv):
    if argv[1:2] == ["stats"]:
        print(np.arange(10).mean())
    else:
        print("usage: cli [stats]")

main(sys.argv)
# cli_lazy.py — импорт по месту
import sys

def main(argv):
    if argv[1:2] == ["stats"]:
        import numpy as np       # платим только когда действительно нужно
        print(np.arange(10).mean())
    else:
        print("usage: cli [stats]")

main(sys.argv)
командаимпорт наверхуимпорт по месту
обычная (usage)105 мс14 мс
тяжёлая (stats)112 мс115 мс

Семь с половиной раз на частом пути и три миллисекунды штрафа на редком. Причина прямо из фазы 1: import на верхнем уровне — это исполнение кода при старте процесса, а не объявление зависимости.

Где искать, не гадая:

python -X importtime -c "import myapp" 2>&1 | sort -k2 -nr | head -15

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

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

Чего не делать. Не переносить в функции всё подряд ради принципа: import внутри функции проверяет sys.modules при каждом вызове, это дёшево, но не бесплатно, а читаемость страдает заметно. Переносить стоит только то, что показал -X importtime.

💻 терминалПроверь в терминалеКаждая фаза вызывается руками: tokenize, ast, symtable, dis, marshal

Весь конвейер доступен из стандартной библиотеки. Прогони по фазам и сверься с предсказанием.

import io, tokenize, ast, symtable, dis, marshal, struct

SRC = '''
DEBUG = False
def handle(verbose):
    if verbose:
        DEBUG = True
    return DEBUG
'''

# фаза 3 — токены
for t in list(tokenize.generate_tokens(io.StringIO(SRC).readline))[:12]:
    print(tokenize.tok_name[t.type], repr(t.string))

# фаза 5 — дерево
print(ast.dump(ast.parse("x = 1 + 2"), indent=2))

# фаза 6 — кто локальный, кто глобальный
st = symtable.symtable(SRC, "<s>", "exec")
fn = st.get_children()[0]
for s in fn.get_symbols():
    print(s.get_name(), "local" if s.is_local() else "global")

# фазы 7–8 — байткод и code object
code = compile(SRC, "<s>", "exec")
handle_co = [c for c in code.co_consts if hasattr(c, "co_code")][0]
print(handle_co.co_varnames, handle_co.co_names)
dis.dis(handle_co)

Вопросы к результату: почему DEBUG оказался в co_varnames, а не в co_names? Какая инструкция читает его — LOAD_FAST или LOAD_GLOBAL? Что изменится, если добавить в функцию строку global DEBUG?

И отдельно — сколько стоит компиляция:

import timeit, pathlib, json
src = pathlib.Path(json.__file__).read_text()
print(timeit.timeit(lambda: compile(src, "json.py", "exec"), number=100) / 100 * 1000, "мс")
# Столько платит каждый процесс, у которого __pycache__ недоступен на запись.
исходникиГде это живёт в реальном кодеtokenize, ast, symtable, dis, py_compile — пять модулей, которые почти не открывают

Lib/tokenize.py. Чистый Python-токенайзер, совместимый с сишным. Полезен не сам по себе, а для инструментов: любой линтер, форматтер и codemod начинается с него, потому что он сохраняет комментарии и пробелы, которые AST теряет.

Lib/ast.py. ast.NodeTransformer — тридцать строк, на которых стоят все автоматические миграции кода. Стоит прочитать ast.unparse: он показывает, что именно дерево не хранит — комментарии, кавычки, скобки для читаемости.

Lib/symtable.py. Тонкая обёртка над фазой 6. Единственный способ увидеть решение компилятора об имени, не читая байткод. Полезен, когда споришь, локальная переменная или нет.

Lib/dis.py. Кроме dis.dis, там есть dis.get_instructions, дающий структурированный поток, и dis._parse_exception_table, показывающий таблицу исключений из фазы 7.

Lib/py_compile.py и Lib/compileall.py. Второй — то, чем прогревают __pycache__ в docker-образе, чтобы не платить компиляцию на каждом старте контейнера. Одна строка в Dockerfile, экономящая десятки миллисекунд на каждом запуске.

Lib/importlib/_bootstrap_external.py. Здесь функция _validate_timestamp_pyc — те самые двадцать строк, которые решают, доверять кешу или перекомпилировать. Читается за минуту и снимает все вопросы про __pycache__.

L2Что лежит в code object и в .pyc побайтово36 байт байткода, 42 байта таблицы строк, 269 байт кеша на 33 байта исходника

Code object целиком. Всё, что нужно для исполнения функции, кроме её текущего состояния:

полезначение для demoзачем
co_argcount2Сколько позиционных параметров принимает
co_kwonlyargcount1Сколько только-именованных
co_nlocals7Размер массива локальных, выделяемого при вызове
co_stacksize3Максимальная глубина стека значений, посчитана статически
co_flags15OPTIMIZED | NEWLOCALS | VARARGS | VARKEYWORDS
co_varnames7 имёнТолько для отладки: исполнение работает по индексам
co_names—Имена, которые придётся искать в рантайме
co_consts(None,)Константы, включая вложенные code object
co_code36 байтСобственно инструкции
co_linetable42 байтаСжатая карта «смещение → строка». Больше, чем сам байткод
co_exceptiontable0 байтПусто: в функции нет try

Таблица строк здесь больше байткода — 42 против 36. Это цена читаемых трассировок, и в 3.11 её сознательно увеличили ради точных колонок в сообщениях об ошибках (PEP 657): теперь стрелочка под выражением указывает на конкретный операнд.

Вложенность. Code object функции лежит в co_consts модуля. Вложенная функция — в co_consts внешней. Всё дерево модуля сериализуется одним marshal-вызовом:

code = compile("def outer():\n    def inner(): pass\n    return inner", "<s>", "exec")
outer = [c for c in code.co_consts if hasattr(c, "co_code")][0]
inner = [c for c in outer.co_consts if hasattr(c, "co_code")][0]
print(code.co_name, "→", outer.co_name, "→", inner.co_name)
# → <module> → outer → inner

Размер кеша. Модуль из двух строк (VALUE = 42 и однострочная функция), 33 байта исходника, даёт .pyc в 269 байт — в восемь раз больше. Причина: заголовки объектов, таблицы строк и имена в каждом code object. Для стандартной библиотеки соотношение лучше, но __pycache__ всё равно обычно крупнее исходников, и в тонких docker-образах это заметная величина.

Свёртка констант в цифрах. Оптимизатор AST сворачивает арифметику, конкатенацию и повторение строк, но с ограничителем на размер результата:

print(compile("x = 'a' * 100", "<s>", "exec").co_consts)
# → ('aaaa...', None)   — свёрнуто
print(compile("x = 'a' * 10**9", "<s>", "exec").co_consts)
# → ('a', 1000000000, None)  — не свёрнуто, иначе .pyc был бы гигабайтным
L3Где это в исходниках CPythonФайл на каждую фазу, все на теге v3.11.15

Редкий случай, когда исходники читаются по порядку: конвейер линеен, и каждой фазе соответствует один файл.

фазафайл
0 · входPrograms/python.c — двадцать строк
1 · конфигурацияPython/initconfig.c, Modules/main.c
1 · подъёмPython/pylifecycle.c — Py_InitializeFromConfig, и там же Py_FinalizeEx для урока 23
1 · frozenPython/frozen.c — список вшитых модулей
3 · токеныParser/tokenizer.c — стек отступов ищи в tok_get
4 · грамматикаGrammar/python.gram — читается как EBNF
4 · парсерParser/pegen.c — движок; Parser/parser.c — сгенерированный
5 · свёрткаPython/ast_opt.c — весь файл про константы
6 · символыPython/symtable.c — комментарий в шапке объясняет фазу лучше любой статьи
7 · компиляторPython/compile.c — самый большой файл конвейера
8 · code objectInclude/cpython/code.h, Objects/codeobject.c
9 · marshalPython/marshal.c — формат сериализации, тип на байт

С чего начать, если открывать один файл. Python/symtable.c: комментарий в шапке на сорок строк излагает фазу 6 целиком, и после него UnboundLocalError, global и nonlocal перестают быть тремя отдельными фактами.

Что изменилось после 3.11. В 3.12 интерпретатор генерируется из Python/bytecodes.c — файла на предметно-ориентированном языке, из которого собираются и цикл вычисления, и таблицы, и документация опкодов. Если читать main, структура будет другой; всё, что здесь описано, относится к 3.11.

связиКуда это ведётL17, L08, L04, L12 · и урок 23 следом
L17 · Импорт насквозь — что происходит с готовым code object дальше: sys.modules, порядок исполнения, циклические импорты. Эта глава — его нижний этаж.
L08 · Функции, замыкания, декораторы — ячейки и свободные переменные, которые здесь появились как решение фазы 6. Там видно, во что это решение превращается при исполнении.
L04 · obj.x насквозь — co_names из фазы 8 это ровно те имена, которые придётся искать по процедуре из четвёртого урока. Локальные быстрые, потому что их разрешили здесь.
L12 · Контекстные менеджеры и исключения — co_exceptiontable из фазы 7 объясняет, почему в 3.11 try перестал стоить хоть что-нибудь. Механика — в уроке 23.
L23 · Машина исполнения — прямое продолжение: что происходит с этим code object, когда его начинают исполнять.
источникиЧто почитатьGreen Tree Snakes 🟥 · PEP 617 🟧 · PEP 552 🟦 · devguide 🟧