Байт-код Python - назначение и использование

Как работает Python

Если вы когда-либо писали или даже использовали Python, вы, вероятно, привыкли видеть файлы исходного кода Python; у них есть имена, заканчивающиеся на .py. Возможно, вы также видели другой тип файла с именем, оканчивающимся на .pyc, и возможно, вы слышали, что это файлы Python с "байт-кодом". И, возможно, вы слышали, что они экономят время, не позволяя Python пересматривать ваш исходный код при каждом запуске. Но помимо "о, это байт-код Python", знаете ли вы, что находится в этих файлах и как Python использует их?

Если нет, то сегодня ваш счастливый день. Я расскажу вам, что такое байт-код Python, как его использует Python для выполнения вашего кода и как знание о нем может вам помочь.

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

Таким образом, эти файлы .pyc, оставленные Python, – это не просто "более быстрая" или "оптимизированная" версия вашего исходного кода; это инструкции байт-кода, которые будут выполняться виртуальной машиной Python во время работы вашей программы.

Давайте посмотрим на пример. Вот классический "Hello, World!", написанный на Python:

И вот байт-код, в который он превращается (переведенный в удобочитаемую форму):

Если вы наберете функцию hello() и используете для ее запуска интерпретатора CPython, приведенный выше листинг будет выполняться Python. Хотя это может показаться немного странным, поэтому давайте посмотрим детальнее на происходящее.

Внутри виртуальной машины Python

CPython использует виртуальную машину на основе стека. То есть он полностью ориентирован на структуры данных стека (где вы можете "протолкнуть" элемент на "вершину" структуры или "вытолкнуть" элемент с "верха").

CPython использует три типа стеков:

  1. Call stack. Это основная структура работающей программы на Python. У него есть один элемент (фрейм) для каждого в настоящее время активного вызова функции, причем нижняя часть стека является точкой входа в программу. Каждый вызов функции помещает новый кадр в стек вызовов, и каждый раз, когда возвращается вызов функции, его фрейм удаляется.
  2. В каждом фрейме есть evaluation stack (стек оценки), также называемый data stack. В этом стеке происходит выполнение функции Python, а выполнение кода Python состоит в основном из помещения чего-либо в этот стек, манипулирования этим и его возврата обратно.
  3. Также в каждом фрейме есть block stack. Он используется Python для отслеживания определенных типов управляющих структур: циклов, блоков try/except и т. д. Это приводит к тому, что записи помещаются в block stack, а он уже выталкивается всякий раз, когда вы выходите из одной из этих структур. Это помогает Python знать, какие блоки активны в данный момент, так что, например, оператор continue или break может воздействовать на правильный блок.

Большинство инструкций байт-кода Python манипулируют оценочным стеком текущего фрейма стека вызовов, хотя есть некоторые инструкции, которые делают другие вещи (например, переход к конкретным инструкциям или манипулирование стеком блоков).

Чтобы почувствовать это, предположим, что у нас есть некоторый код, который вызывает функцию, например: my_function (my_variable, 2). Python преобразует это в последовательность из четырех инструкций байт-кода:

  1. Инструкция LOAD_NAME, которая ищет функциональный объект my_function и помещает его в верхнюю часть стека оценки.
  2. Еще одна инструкция LOAD_NAME, которая ищет переменную my_variable и помещает ее в верхнюю часть стека оценки.
  3. Инструкция LOAD_CONST, чтобы поместить буквенное целое значение 2 поверх стека оценки.
  4. Инструкция CALL_FUNCTION.

Инструкция CALL_FUNCTION будет иметь аргумент 2, что указывает на то, что Python должен извлечь два позиционных аргумента из верхней части стека; тогда вызываемая функция будет сверху, и она также может быть выдвинута (для функций, включающих аргументы ключевых слов, используется другая инструкция – CALL_FUNCTION_KW, но с аналогичным принципом работы. Также используется третья инструкция, CALL_FUNCTION_EX, для вызовов функций, которые включают распаковку аргументов с помощью операторов * или **). Как только Python получит все это, он выделит новый фрейм в стеке вызовов, заполнит локальные переменные для вызова функции и выполнит байт-код my_function внутри этого кадра. Как только это будет сделано, кадр будет извлечен из стека вызовов, а в исходном кадре возвращаемое значение my_function будет помещено поверх стека оценки.

Доступ и понимание байт-кода Python

Если вы хотите поэкспериментировать с этим, модуль dis в стандартной библиотеке Python очень полезен; модуль dis предоставляет "дизассемблер" для байт-кода Python, что позволяет легко получить удобочитаемую версию и просмотреть различные инструкции байт-кода. Документация для модуля dis просматривает его содержимое и предоставляет полный список инструкций байт-кода, а также информацию о том, что они делают и какие аргументы они принимают.

Например, чтобы получить список байт-кодов для функции hello(), я набрал его в интерпретаторе Python и запустил:

Функция dis.dis() будет дизассемблировать функцию, метод, класс, модуль, скомпилированный объект кода Python или строковый литерал, содержащий исходный код, и напечатает понятную для человека версию. Другая удобная функция в модуле dis – это distb(). Вы можете передать ему объект трассировки Python или вызвать его после возникновения исключения, и он разберет самую верхнюю функцию в стеке вызовов во время исключения, напечатает его байт-код и вставит указатель на инструкцию, которая вызвала исключение.

Также полезно посмотреть на объекты скомпилированного кода, которые Python собирает для каждой функции, поскольку при выполнении функции используются атрибуты этих объектов кода. Например:

Объект кода доступен как атрибут __code__ в функции и содержит несколько важных атрибутов:

  • co_consts – это кортеж любых литералов, встречающихся в теле функции;
  • co_varnames – это кортеж, содержащий имена любых локальных переменных, используемых в теле функции;
  • co_names – это кортеж любых нелокальных имен, на которые есть ссылки в теле функции.

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

Итак, теперь мы можем понять список байт-кода функции hello():

  • LOAD_GLOBAL 0: говорит Python найти глобальный объект, на который ссылается имя с индексом 0 для co_names (который является функцией печати), и поместить его в стек оценки;
  • LOAD_CONST 1: принимает литеральное значение по индексу 1 co_consts и проталкивает его (значение по индексу 0 равно литералу None, которое присутствует в co_consts, поскольку вызовы функций Python имеют неявное возвращаемое значение None, если не достигнут явный оператор возврата);
  • CALL_FUNCTION 1: сообщает Python о вызове функции; ему нужно будет удалить один позиционный аргумент из стека, тогда новая вершина стека будет функцией для вызова.

"Необработанный" байт-код (как нечитаемые человеком байты) также доступен в объекте кода как атрибут co_code. Вы можете использовать список dis.opname для поиска имен инструкций байт-кода по их десятичным значениям байтов, если вы хотите попытаться вручную разобрать функцию.

Использование байт-кода

Сейчас вы наверное задаетесь вопросом: "Какова практическая ценность всей этой информации?".

Во-первых, понимание модели исполнения Python поможет вам понять ваш код. Людям нравится шутить о том, что C является своего рода "переносимым ассемблером", где вы можете догадаться, в какие машинные инструкции превратится тот или иной фрагмент исходного кода C. Понимание байт-кода даст вам те же возможности с Python. Если вы можете предвидеть, во что превращается байт-код вашего исходного кода Python, вы можете принять более правильное решение о том, как его написать и оптимизировать.

Во-вторых, понимание байт-кода – полезный способ ответить на вопросы о Python. Например, я часто вижу новых программистов на Python, которые задаются вопросом, почему одни конструкции быстрее других (например, почему {} быстрее, чем dict()). Знание того, как получить доступ и прочитать байт-код Python, позволяет вам выработать ответы (попробуйте: dis.dis("{}") или dis.dis("dict ()")).

Наконец, понимание байт-кода и того, как Python выполняет его, дает полезные знания о конкретном виде программирования, которым программисты Python нечасто занимаются, – стек-ориентированное программирование.

Наверх
Меню