- Как создать exe файл для Python кода с помощью PyInstaller
- Установка PyInstaller
- Создание exe файла с помощью PyInstaller
- Добавление файлов с данными, которые будут использоваться exe-файлом
- Добавление файлов с данными и параметр onefile
- Дополнительные импорты с помощью Hidden Imports
- Файл spec
- Вывод:
- Подписывайтесь на канал в Дзене
- Собираем проект на python3&PyQT5 под Windows, используя PyInstaller
- Немного о Pyinstaller
- Приступаем к сборке
- Внутри виртуальной машины Python. Часть 2
- Оглавление
- Компиляция исходного кода Python
- От исходников к дереву парсинга
- Python токены
- От дерева парсинга к абстрактному синтаксическому дереву
- Построение таблицы символов
- Имена и Связывания
- Блоки кода
- Пространства имен
- Области видимости
- Примечание
- Структуры данных таблицы символов
- От AST к объектам кода
- Структура данных: compiler
- Структура данных: compiler_unit
- Структуры данных basic_block и instruction
- Сборка базовых блоков
- Обход графа
Как создать exe файл для Python кода с помощью PyInstaller
Установка PyInstaller
Установка PyInstaller не отличается от установки любой другой библиотеки Python.
Вот так можно проверить версию PyInstaller.
Я использую PyInstaller версии 4.2.
Создание exe файла с помощью PyInstaller
PyInstaller собирает в один пакет Python-приложение и все необходимые ему библиотеки следующим образом:
- Считывает файл скрипта.
- Анализирует код для выявления всех зависимостей, необходимых для работы.
- Создает файл spec, который содержит название скрипта, библиотеки-зависимости, любые файлы, включая те параметры, которые были переданы в команду PyInstaller.
- Собирает копии всех библиотек и файлов вместе с активным интерпретатором Python.
- Создает папку BUILD в папке со скриптом и записывает логи вместе с рабочими файлами в BUILD.
- Создает папку DIST в папке со скриптом, если она еще не существует.
- Записывает все необходимые файлы вместе со скриптом или в одну папку, или в один исполняемый файл.
Если использовать параметр команды onedir или -D при генерации исполняемого файла, тогда все будет помещено в одну папку. Это поведение по умолчанию. Если же использовать параметр onefile или -F , то все окажется в одном исполняемом файле.
Возьмем в качестве примера простейший скрипт на Python c названием simple.py, который содержит такой код.
Создадим один исполняемый файл. В командной строке введите:
После завершения установки будет две папки, BUILD и DIST, а также новый файл с расширением .spec. Spec-файл будет называться так же, как и файл скрипта.
Python создает каталог распространения, который содержит основной исполняемый файл, а также все динамические библиотеки.
Вот что произойдет после запуска файла.
Добавление файлов с данными, которые будут использоваться exe-файлом
Есть CSV-файл netflix_titles.csv, и Python-script, который считывает количество записей в нем. Теперь нужно добавить этот файл в бандл с исполняемым файлом. Файл Python-скрипта назовем просто simple1.py.
Создадим исполняемый файл с данными в папке.
Параметр —add-data позволяет добавить файлы с данными, которые нужно сохранить в одном бандле с исполняемым файлом. Этот параметр можно применить много раз.
Можно увидеть, что файл теперь добавляется в папку DIST вместе с исполняемым файлом.
Также, открыв spec-файл, можно увидеть раздел datas, в котором указывается, что файл netflix_titles.csv копируется в текущую директорию.
Запустим файл simple1.exe, появится консоль с выводом: Всего фильмов: 7787 .
Добавление файлов с данными и параметр onefile
Если задать параметр —onefile , то PyInstaller распаковывает все файлы в папку TEMP, выполняет скрипт и удаляет TEMP. Если вместе с add-data указать onefile, то нужно считать данные из папки. Путь папки меняется и похож на «_MEIxxxxxx-folder».
Скрипт обновлен для чтения папки TEMP и файлов с данными. Создадим exe-файл с помощью onefile и add-data.
После успешного создания файл simple1.exe появится в папке DIST.
Можно скопировать исполняемый файл на рабочий стол и запустить, чтобы убедиться, что нет никакой ошибки, связанной с отсутствием файла.
Дополнительные импорты с помощью Hidden Imports
Исполняемому файлу требуются все импорты, которые нужны Python-скрипту. Иногда PyInstaller может пропустить динамические импорты или импорты второго уровня, возвращая ошибку ImportError: No module named …
Для решения этой ошибки нужно передать название недостающей библиотеки в hidden-import.
Например, чтобы добавить библиотеку os, нужно написать вот так:
Файл spec
Файл spec — это первый файл, который PyInstaller создает, чтобы закодировать содержимое скрипта Python вместе с параметрами, переданными при запуске.
PyInstaller считывает содержимое файла для создания исполняемого файла, определяя все, что может понадобиться для него.
Файл с расширением .spec сохраняется по умолчанию в текущей директории.
Если у вас есть какое-либо из нижеперечисленных требований, то вы можете изменить файл спецификации:
- Собрать в один бандл с исполняемым файлы данных.
- Включить другие исполняемые файлы: .dll или .so.
- С помощью библиотек собрать в один бандл несколько программы.
Например, есть скрипт simpleModel.py, который использует TensorFlow и выводит номер версии этой библиотеки.
Компилируем модель с помощью PyInstaller:
После успешной компиляции запускаем исполняемый файл, который возвращает следующую ошибку.
Исправим ее, обновив файл spec. Одно из решений — создать файл spec.
Команда pyi-makespec создает spec-файл по умолчанию, содержащий все параметры, которые можно указать в командной строке. Файл simpleModel.spec создается в текущей директории.
Поскольку был использован параметр —onefile , то внутри файла будет только раздел exe.
Если использовать параметр по умолчанию или onedir, то вместе с exe-разделом будет также и раздел collect.
Можно открыть simpleModel.spec и добавить следующий текст для создания хуков.
Создаем хуки и добавляем их в hidden imports и раздел данных.
Файлы хуков расширяют возможность PyInstaller обрабатывать такие требования, как необходимость включать дополнительные данные или импортировать динамические библиотеки.
Обычно пакеты Python используют нормальные методы для импорта своих зависимостей, но в отдельных случаях, как например TensorFlow, существует необходимость импорта динамических библиотек. PyInstaller не может найти все библиотеки, или же их может быть слишком много. В таком случае рекомендуется использовать вспомогательный инструмент для импорта из PyInstaller.utils.hooks и собрать все подмодули для библиотеки.
Скомпилируем модель после обновления файла simpleModel.spec.
Скопируем исполняемый файл на рабочий стол и увидим, что теперь он корректно отображает версию TensorFlow.
Вывод:
PyInstaller предлагает несколько вариантов создания простых и сложных исполняемых файлов из Python-скриптов:
- Исполняемый файл может собрать в один бандл все требуемые данные с помощью параметра —add-data .
- Исполняемый файл и зависимые данные с библиотеками можно собрать в один файл или папку с помощью —onefile или —onedir соответственно.
- Динамические импорты и библиотеки второго уровня можно включить с помощью hidden-imports .
- Файл spec позволяет создать исполняемый файл для обработки скрытых импортов и других файлов данных с помощью хуков.
Подписывайтесь на канал в Дзене
Полезный контент для начинающих и опытных программистов в канале Лента Python разработчика — Как успевать больше, делать лучше и не потерять мотивацию.
Собираем проект на python3&PyQT5 под Windows, используя PyInstaller
Причиной написания статьи, явилось огромное количество постоянно возникающих у новичков вопросов такого содержания: «Как собрать проект c pyqt5», «Почему не работает», «Какой инструмент выбрать» и т.д. Сегодня научимся собирать проекты без мучений и танцев с бубном.
Как-то пришлось написать небольшое desktop-приложение. В качестве языка программирования для разработки был выбран python, поскольку для решения моей задачи он подходил идеально. В стандартную библиотеку Python уже входит библиотека tkinter, позволяющая создавать GUI. Но проблема tkinter в том, что данной библиотеке посвящено мало внимания, и найти в интернете курс, книгу или FAQ по ней довольно-таки сложно. Поэтому было решено использовать более мощную, современную и функциональную библиотеку Qt, которая имеет привязки к языку программирования python в виде библиотеки PyQT5. Более подробно про PyQT можете почитать здесь. В качестве примера я буду использовать код:
Если вы более-менее опытный разработчик, то понимаете, что без интерпретатора код на python не запустить. А хотелось бы дать возможность каждому пользователю использовать программу. Вот здесь к нам на помощь и приходят специальные библиотеки позволяющие собирать проекты в .exe, которые можно потом без проблем запустить, как обычное приложение.
Существует большое количество библиотек, позволяющих это сделать, среди которых самые популярные: cx_Freeze, py2exe, nuitka, PyInstaller и др. Про каждую написано довольно много. Но надо сказать, что многие из этих решений позволяют запускать код только на компьютере, с предустановленным интерпретатором и pyqt5. Не думаю, что пользователь будет заморачиваться и ставить себе дополнительные пакеты и программы. Надеюсь, вы понимаете, что запуск программы на dev-среде и у пользователя это не одно и тоже. Также нужно отметить, что у каждого решения были свои проблемы: один не запускался, другой собирал то, что не смог потом запустить, третий вообще отказывался что-либо делать.
После долгих танцев с бубном и активным гуглением, мне все же удалось собрать проект с помощью pyinstaller, в полностью работоспособное приложение.
Немного о Pyinstaller
Pyinstaller собирает python-приложение и все зависимости в один пакет. Пользователь может запускать приложение без установки интерпретатора python или каких-либо модулей. Pyinstaller поддерживает python 2.7 и python 3.3+ и такие библиотеки как: numpy, PyQt, Django, wxPython и другие.
Pyinstaller тестировался на Windows, Mac OS X и Linux. Как бы там ни было, это не кросс-платформенный компилятор: чтобы сделать приложение под Windows, делай это на Windows; Чтобы сделать приложение под Linux, делай это на Linux и т.д.
PyInstaller успешно используется с AIX, Solaris и FreeBSD, но тестирование не проводилось.
Подробнее о PyInstaller можно почитать здесь: документация.
К тому же после сборки приложение весило всего около 15 мб. Это к слову и является преимуществом pyinstaller, поскольку он не собирает все подряд, а только необходимое. Аналогичные же библиотеки выдавали результат за 200-300 мб.
Приступаем к сборке
Прежде чем приступить к сборке мы должны установить необходимые библиотеки, а именно pywin32 и собственно pyinstaller:
Чтобы убедится, что все нормально установилось, вводим команду:
должна высветиться версия pyinstaller. Если все правильно установилось, идем дальше.
В папке с проектом запускаем cmd и набираем:
Собственно это и есть простейшая команда, которая соберет наш проект.
Синтаксис команды pyinstaller таков:
pyinstaller [options] script [script . ] | specfile
Наиболее часто используемые опции:
—onefile — сборка в один файл, т.е. файлы .dll не пишутся.
—windowed -при запуске приложения, будет появляться консоль.
—noconsole — при запуске приложения, консоль появляться не будет.
—icon=app.ico — добавляем иконку в окно.
—paths — возможность вручную прописать путь к необходимым файлам, если pyinstaller
не может их найти(например: —paths D:\python35\Lib\site-packages\PyQt5\Qt\bin)
PyInstaller анализирует файл myscript.py и делает следующее:
- Пишет файл myscript.spec в той же папке, где находится скрипт.
- Создает папку build в той же папке, где находится скрипт.
- Записывает некоторые логи и рабочие файлы в папку build.
- Создает папку dist в той же папке, где находится скрипт.
- Пишет исполняемый файл в папку dist.
В итоге наша команда будет выглядеть так:
После работы программы вы найдете две папки: dist и build. Собственно в папке dist и находится наше приложение. Впоследствии папку build можно спокойно удалить, она не влияет на работоспособность приложения.
Спасибо за внимание. Надеюсь статья была вам полезна.
Внутри виртуальной машины Python. Часть 2
Оглавление
Компиляция исходного кода Python
Python обычно не рассматривается как компилируемый язык, но на самом деле он является таковым. Во время компиляции исходный код, написанный на Python, преобразуется в байт-код, который потом выполняется виртуальной машиной. Однако, процесс компиляции в Python является довольно простым и не включает в себя множество сложных этапов. Он состоит из следующих шагов в указанном порядке:
- Преобразование исходного python кода в «парсинговые» деревья.
- Преобразование «парсинговых» деревьев в абстрактные синтаксические деревья (AST).
- Генерация таблицы символов.
- Создание объектов кода из AST. Этот шаг включает в себя:
- Преобразование AST в граф потока управления.
- Получение объекта кода из графа потока управления.
Создание «парсинговых» деревьев и их преобразование в AST является стандартным процессом. Python не вносит в него каких-либо сложных нюансов, поэтому в этой главе основное внимание уделяется преобразованию AST в граф потока управления и получению из этого графа объектов кода. Для тех, кто заинтересован в парсинговых деревях и генерации AST, существует «драконья» книга, которая даёт более углубленный tour de force [прим. с французского: «Большое усилие»] по обоим из этих тем.
От исходников к дереву парсинга
Парсер Python — это синтаксический анализатор LL (1), он основывается на принципах, которые изложены в «драконьей» книге. Модуль Grammar/Grammar содержит расширенную форму Бэкуса — Наура (Extended Backus-Naur Form (EBNF)) со спецификацией грамматики языка Python. Отрывок этой спецификации показан в листинге 3.0.
Листинг 3.0. Отрывок синтаксиса BNF в Python
При выполнении модуля, переданного интерпретатору в командной строке, совершается вызов PyParser_ParseFileObject. Эта функция инициирует синтаксический анализ файла. Она же вызывает функцию токенизации PyTokenizer_FromFile, передавая ей имя файла-модуля в качестве аргумента. Функция токенизации в Python разбивает содержимое модуля на правильные токены или же выдает исключение при обнаружении недопустимых.
Python токены
Исходный код Python состоит из токенов. Например, слово return является ключевым токеном, а литерал — 2 числовым и так далее. Первая задача при синтаксическом анализе состоит в том, чтобы токенизировать исходный код, разбив его на токены-компоненты. В Python есть несколько видов токенов:
- Идентификаторы — это имена, которые определены программистом. Они включают имена функций, переменных, классов и т.д. Они должны соответствовать правилам именования идентификаторов, указанным в документации python.
- Операторы — это специальные символы, такие как: + и *, которые работают со значениями данных и возвращают какой-то результат.
- Ограничители — эти символы служат для группировки выражений, пунктуации и присваивания. Члены этой категории включают в себя: (, ), <,>, =, *= и т.д.
- Литералы — это символы, которые обеспечивают постоянное значение для некоторого типа. У нас есть строковые и байтовые литералы, такие как «Fred» и b«Fred», числовые литералы, которые включают: целочисленные литералы, такие как 2, литералы с плавающей запятой: 1e100 и мнимые литералы: 10j.
- Комментарии — это строковые литералы, которые начинаются с символа #. Токены комментариев всегда заканчиваются «физическим» концом строки.
- NEWLINE — это специальный токен, который обозначает конец «логической» строки.
- INDENT и DEDENT — эти токены используются для представления уровней отступов, которые группируют инструкции.
Группа токенов, отделённая NEWLINE, образует логическую линию, которая соотносится с python инструкциями. То есть, мы можем сказать, что python-программа состоит из последовательности логических строк, каждая из которых отделена токеном NEWLINE. Каждая из этих логических строк состоит из нескольких физических, которые в свою очередь, оканчиваются символом перевода строки. Но в Python логические строки чаще всего совпадают с физическими, поэтому в большинстве случаев мы можем сказать, что логические строки разделяются символами конца строки. Составные инструкции могут занимать несколько физических строк, как это показано на рисунке 3.0. Логические строки могут быть образованы неявно (через заключение выражения в круглые, квадратные или фигурные скобки) или явно, с помощью символа обратной косой черты. Отступ также играет центральную роль в группировке операторов Python. Таким образом, одна из строк в грамматике питона может выглядеть так: suite: simple_stmt | NEWLINE INDENT stmt+ DEDENT. Как следствие, одна из основных задач токенайзера Python заключается в генерировании токенов отступа и обратного-отступа, которые добавляются в дерево парсинга. Токенайзер использует стек для отслеживания отступов и использует алгоритм из листинга 3.1
Листинг 3.1: Python алгоритм для генерации токенов INDENT и DEDENT.
Функция PyTokenizer_FromFile из модуля Parser/parsetok.c сканирует исходный Python файл слева-направо и сверху-вниз производя токенизацию содержимого. Символы пробелов (отличные от терминаторов) служат для разграничения токенов, но не являются обязательными. Там, где есть некоторая двусмысленность, такая как: 2+2, токенайзер пытается выделить максимально длинную строку, которая формирует легальный токен при чтении справа налево. В данном примере токенами являются литерал 2, оператор + и ещё один литерал 2.
Сгенерированные токены передаются парсеру, который пытается построить из них дерево «парсинга» (в соответствии с грамматикой python, подмножество которой указано в листинге 3.0). Когда анализатор обнаруживает токен, нарушающий грамматику, генерируется исключение SyntaxError. Модуль parser предоставляет ограниченный доступ к дереву конкретного кода Python и используется в листинге 3.2 для получения примера синтаксического дерева.
Листинг 3.2. Использование модуля parser для получения дерева в Python
Вызов parser.suite(source) в листинге 3.2 возвращает объект дерева (ST), который является промежуточным представлением дерева парсинга из исходного кода (предполагается, что исходный код синтаксически корректен). Вызов parser.st2list возвращает фактическое синтаксическое дерево, представленное в виде списка python. Первые элементы в списках — целые числа, которое идентифицируют продукционные правила в грамматике Python.
Рисунок 3.0: Дерево парсинга для листинга 3.2 (функция, возвращающая строку ‘hello world’)
Рисунок 3.0 — это древовидная диаграмма, показывающая то же дерево из листинга 3.2, но уже с некоторыми удаленными токенами, а также часть команд была заменена строковыми описаниями в соответствии числовым номерам. Все эти продукционные правила указаны в заголовочных файлах Include/token.h и Include/graminit.h.
В виртуальной машине CPython для представления дерева парсинга используется древовидная структура данных. Каждое продукционное правило — это узел в ней. Структура данных нода (узла) из Include/node.h показана в листинге 3.3.
Листинг 3.3: Структура данных нода в виртуальной машине
По мере обхода дерева парсинга узлы могут запрашиваться по их типу, дочерним элементам (если таковые имеются), номеру строки исходного файла (которая привела к созданию данного узла) и так далее. Макросы для взаимодействия с узлами дерева парсинга также определены в файле Include/node.h.
От дерева парсинга к абстрактному синтаксическому дереву
Следующим этапом процесса компиляции в python является преобразование деревьев парсинга в абстрактное синтаксическое дерево (AST). Абстрактное синтаксическое дерево является представлением кода, которое не зависит от тонкостей синтаксиса python. Например, дерево парсинга на рисунке 3.0 содержит узел «двоеточие» [прим. двоеточие объявления функции], потому что это синтаксическая конструкция, но, как показано в листинге 3.4, AST не будет содержать таких «подробностей».
Листинг 3.4. Использование astмодуля для управления AST исходного кода Python
Различные определения узлов AST находятся в файле Parser/Python.asdl. Большинство определений в AST соответствуют конкретной исходной конструкции, такой как оператор if или поиск атрибута. Модуль ast вместе с Python-интерпретатором дает нам возможность манипулировать AST. Такие инструменты как codegen могут по конкретному AST вывести исходный код python. В реализации CPython AST-узлы представлены C-структурами, которые определены в Include/Python-ast.h. Эти структуры фактически генерируются кодом Python. Модуль Parser/asdl_c.py генерирует этот файл из AST определения ASDL. Например, вот кусок из определения нода statement, который показан в листинге 3.5.
Union в листинге 3.5 — это ключевое слово языка C, которое используется для создания атрибута, который может принимать один из любых типов, перечисленных в самом union. Функция PyAST_FromNode в модуле Python/ast.c отвечает за генерацию AST из данного дерева парсинга. Теперь пришло время создавать байт-код из сгенерированного AST.
Построение таблицы символов
Имена и Связывания
В python на объекты ссылаются по именам. «Names» похожи на переменные в C++ или Java, но это не совсем так.
В приведенном выше примере x — это имя, которое ссылается на объект: 5. Процесс присвоения ссылки на значение 5 к x называется биндингом. Связывание приводит к тому, что имя начинает ссылаться на объект, расположенный внутри самой вложенной области видимости текущей исполняемой программы. Связывание происходит во многих случаях, например, при присваивании переменной, функции, а также метода, когда переданный параметр привязан к аргументу и т.д. Важно отметить, что имена — это просто символы и они не имеют типа, который ассоциируется с ними. Тип существует у самих объектов, на которые эти имена ссылаются.
Блоки кода
Блоки кода являются центральной частью для Python программы и их понимание имеет первостепенное значение для изучения внутреннего устройства виртуальной машины Python. Блок кода — это фрагмент программного кода, который выполняется в Python как единое целое. Примерами блоков кода являются модули, функции и классы. Команды, введенные в интерактивном режиме в REPL, команды сценария запускаемые с флагом -c, также являются блоками кода. Блок кода имеет несколько пространств имен, связанных с ним. Например, блок кода модуля имеет доступ к пространству имен global, а блок кода функции имеет также доступ к пространству имен local, по-мимо пространства global.
Пространства имен
Пространство имен (namespace) является контекстом, в котором какой-то набор имен связан с объектами. Пространства имен Python реализованы в виде словаря. Встроенное пространство имен является примером namespace, которое содержит все встроенные функции и доступ к нему можно получить через ввод __builtins__.__dict__ в терминале (выходной текст будет довольно большим). Интерпретатор имеет доступ к нескольким пространствам имен, в том числе к глобальному, встроенному и локальному. Данные в namespace создаются в разное время и также имеют разное время жизни. Например, новое локальное пространство имен создается при вызове функции и удаляется при её окончании или выходе из функции. Глобальное пространство имен создаётся в начале выполнения модуля и все имена, определенные в этом namespace, доступны во всём модуле. Встроенная область видимости создаётся, когда вызывается интерпретатор и он уже содержит все встроенные имена. Эти три пространства имен являются основными namespace доступными интерпретатору.
Области видимости
Область видимости — это часть программы, в которой набор биндингов (пространство имен) виден и доступен напрямую без использования точечной нотации. [прим. кажется, здесь опечатка и имеется ввиду доступ через функцию globals()]. Во время выполнения программы могут быть доступны следующие области видимости:
- Самая «внутренняя» область видимости, содержащая локальные переменные
- При вложенных функциях, во вложенной функции можно получить доступ к пространству имён более «внешней».
- Глобальная область видимости текущего модуля
- Область видимости, содержащая встроенное пространство имен
Когда в python упоминается имя, интерпретатор ищет пространство имен области видимости в порядке возрастания (как указано выше) и если имя не найдено ни в одном из пространств имен, возникает исключение. Python поддерживает статическую область видимости (также известную как лексическая область видимости). Это означает, что видимость биндингов имен может быть определена только путем проверки текста программы.
Примечание
В Python есть своеобразное правило для области видимости, которое предотвращает изменение ссылки на объект глобальной области видимости в локальной. Такое действие приведет к исключению UnboundLocalError. Следующий пример иллюстрирует это:
Листинг A3.0. Попытка изменить глобальную переменную из функции
Чтобы изменить «глобальный» объект в локальной области, необходимо использовать ключевое слово global с именем объекта:
Листинг A3.1. Использование ключевого слова global для изменения глобальной переменной из функции
Python также имеет ключевое слово nonlocal. Оно используется, когда необходимо изменить переменную, находящуюся во внешней, но «не глобальной» области видимости. Это очень удобно при работе с вложенными функциями (также называемыми замыканиями). Очень простая иллюстрация ключевого слова nonlocal показана в следующем фрагменте, который определяет простой счетчик для объекта:
Листинг A3.2. Вложенные функции со счётчиком
Последовательность вызовов функций run_mod -> PyAST_CompileObject -> PySymtable_BuildObject запускает процесс построения таблицы символов. Два аргумента функции PySymtable_BuildObject — это ранее сгенерированный AST и имя модуля. Алгоритм построения таблицы символов разбит на две части. В первой «посещается» каждый узел AST, чтобы создать коллекцию символов, используемых в AST. Очень простое описание этого процесса приведено в листинге 3.6, и термины, используемые в нем, станут более очевидными, когда мы обсудим структуры данных, используемые при построении таблицы символов.
Листинг 3.6. Создание таблицы символов из AST
После первого прохода алгоритма таблица символов содержит все имена, которые использовались в модуле, но не содержит контекстной информации о них. Например, интерпретатор не может определить, является ли данная переменная глобальной, локальной или свободной. Вызов функции symtable_analyze из Parser/symtable.c инициирует вторую фазу генерации таблицы символов. Эта фаза алгоритма определяет область видимости (локальную, глобальную или свободную) для символов, полученных на первом этапе. Комментарии в файле Parser/symtable.c достаточно информативны и перефразированы ниже, чтобы дать представление о втором этапе процесса построения таблицы символов:
- Чтобы определить область видимости каждого имени необходимо два этапа. Первый собирает необработанные «факты» из AST через функции symtable_visit_ *, а второй анализирует эти «факты» во время прохода над объектами PySTEntryObject, созданными в первом этапе.
- Когда второй проход заходит внутрь функции, родительский элемент передает набор всех биндингов, видимых для его дочерних элементов. Эти связывания используются, что выяснить: являются ли non-local переменные свободными или неявными глобальными. Имена, которые явно объявлены non-local, должны существовать в этом наборе видимых имен — если их нет, возникает синтаксическая ошибка. После локального анализа функция анализирует каждый из своих дочерних блоков, используя обновленный набор биндингов.
- Есть также два вида глобальных переменных: явные и неявные. Явные глобальные переменные объявляются оператором global. Неявная глобальная переменная — это свободная переменная, для которой компилятор не нашел биндинга в локальной области видимости текующей функции. Неявные глобальные переменные является либо глобальным, либо встроенным.
Модули Python и блоки классов используют опкоды xxx_NAME для обработки этих имен, чтобы реализовать слегка странную семантику. В таком блоке имя обрабатывается как глобальное, пока оно не будет «переприсвоено». После этого переменная трактуется как локальная. - Потомки обновляют набор свободных переменных. Если локальная переменная добавляется в набор свободных переменных c пометкой «установлена дочерним элементом», то переменная помечается как ячейка. Объект функции должен обеспечивать runtime хранилище для переменной, которая может пережить фрейм функции. Поэтому переменные-ячейки удаляются из набора свободных переменных прежде, чем функция анализа возвращается к своему родителю.
Комментарии в модуле пытаются объяснить процесс построения таблицы символов понятным языком, но есть некоторые запутанные моменты. Например: родитель передает набор всех биндингов, видимых его дочерним элементам — но на какого родителя и на какие дочерние элементы они конкретно ссылаются? Чтобы получить представление об этом, нам нужно взглянуть на структуры данных, которые используются в процессе создания таблицы символов.
Структуры данных таблицы символов
Существует две структуры данных, которые являются центральными для генерации таблицы символов:
- Структура данных таблицы символов.
- Структура данных записи таблицы символов.
Структура данных таблицы символов приведена в листинге 3.7. Можно думать о ней, как о таблице состоящей из записей и содержащей информацию об именах, используемых в различных блоках кода данного модуля.
Листинг 3.7. Структура данных таблицы символов.
Модуль Python может содержать несколько блоков кода — например, несколько определений функций. Поле st_blocks является отображением всех существующих блоков кода в один элемент таблицы символов. Запись таблицы st_top — это таблица символов для компилируемого модуля (напомним, что модуль также является блоком кода), поэтому она будет содержать имена, определенные в глобальном пространстве имен модуля. Поле st_cur относится к записи таблицы символов для кодового блока, который обрабатывается в данный момент. Каждый блок кода внутри «блока кода модуля» имеет свою собственную запись таблицы символов, которая содержит символы, определенные в этом блоке кода.
Рисунок 3.1: таблица символов и записи в ней.
В очередной раз, просмотр структуры данных _symtable_entry из файла Include/symtable.h очень полезен, чтобы понять, как она работает. Данная структура данных показана в листинге 3.8.
Листинг 3.8. Структура данных _symtable_entry
Комментарии в исходном коде объясняют, что делает каждое поле. Поле ste_symbols содержит отображение символов/имен, которые встречаются при анализе блока кода. Флаги, на которые отображаются символы, представляют собой числовые значения, которые дают нам информацию о контексте, в котором используется символ/имя. Например, символ может быть аргументом функции или определением глобального оператора. Некоторые примеры этих флагов, определенных в модуле Include/symtable.h, приведены в листинге 3.9.
Листинг 3.9. Флаги, которые определяют контекст определения имени
Возвратимся же к обсуждению таблиц символов. Предположим, что компилируется модуль, содержащий код из листинга 3.10. После того, как таблица символов построена, есть три записи таблицы символов.
Листинг 3.10. Простая функция Python
Первая запись make_counter является замыканием модуля и будет определена областью видимости local. Следующая запись таблицы символов будет о том, что функция make_counter содержит имена count и counter, помеченные как локальные. Последняя запись таблицы символов будет о вложенной функции counter. Она будет иметь переменную count, помеченную как free. Следует отметить, что хоть make_counter и определена как локальная в записи таблицы символов, но она рассматривается как глобальная в самом блоке кода модуля, поскольку *st_global указывает на символы *st_top, которые в данном случае являются символами замыкающего модуля.
От AST к объектам кода
Следующим шагом для компилятора является генерация объектов кода из информации полученной благодаря AST и таблицам символов. Отвечающие за это функции, реализованы в модуле Python/compile.c. Процесс создания объектов кода является многоэтапным. На первом шаге AST преобразуется в базовые блоки инструкций байт-кода Python. Алгоритм преобразования похож на тот, который используется при генерации таблиц символов — функции с именами compiler_visit_xx (где xx этот тип узла) рекурсивно посещают каждый узел и генерируют базовые блоки инструкций байт-кода. Базовые блоки и связи между ними представляются в виде графа — графа потока управления [прим. именуемый также CFG — control flow graph]. Он показывает «пути» кода, которые могут быть использованы во время выполнения программы. На втором этапе сгенерированный граф потока управления «сглаживается» с использованием поиска по графу в глубину (DFS). После того как граф сглажен, рассчитывается смещения перехода и оно используется в качестве аргумента для инструкции jump байт-кода. Объекта кода генерируется из этого набора инструкций. Чтобы лучше разобраться в этом процессе, рассмотрим функцию fizzbuzz в листинге 3.11.
Листинг 3.11. Простая python функция
AST для этой функции показан на рисунке 3.2.
Рисунок 3.2: Очень простой AST для листинга 3.2
Этот AST из рисунка 3.2 при компиляции в CFG возвращает граф, аналогичный показанному на рисунке 3.3. Пустые блоки на рисунке были опущены. Рассмотрение этого графа обеспечит некоторую информацию о том, что скрывается за базовыми блоками. Базовые блоки имеют одну точку входа, но могут иметь несколько выходов. Эти блоки описаны более подробно далее.
Рисунок 3.3: Граф потока управления для функции fizzbuzz из листинга 3.11. Прямая линия представляет нормальное, прямолинейное выполнение кода, в то время как изогнутые линии представляют «прыжки».
В следующие описания включены только фактические инструкции. Для некоторых инструкций нужны аргументы, но он были удалены, поскольку сейчас нас не интересуют.
- Блок 1 содержит инструкции, являющиеся узлом BoolOp в AST на рисунке 3.2. Инструкции в этом блоке реализуют операцию: n%3==0 and n%5==0, используя следующий набор из одиннадцати команд:
Удивительно, но остальная часть узла if (фактический тест, который определяет, нужно ли выполнить код ниже) не включен в этот блок. Причина станет более понятной, когда мы обсудим второй блок. Как показано на рисунке 3.3, есть два способа выйти из этого блока: либо через прямое выполнение всех опкодов, либо путем перехода к блоку 2 через инструкцию JUMP_IF_FALSE_OR_POP.
Блок 2 отображается на первый узел if, инкапсулирует тест if и последующий код. Второй блок содержит следующие четыре инструкции:
Как мы увидим в следующих главах, когда интерпретатор выполняет инструкции байт-кода для оператора if, он производит считывание из стека значения и в зависимости от истинности данного объекта либо выполняет следующую инструкцию байт-кода, либо переходит к другой части набора инструкций и продолжает выполнение оттуда. Именно инструкция POP_JUMP_IF_FALSE отвечает за это. Данный опкод принимает аргумент, который указывает место назначения такого перехода. Можно задаться вопросом: почему инструкции для узла BoolOp и операторов if находятся в разных блоках? Чтобы понять это напомним, что python использует ленивые вычисления для логических операций. Таким образом, если значение n%3==0 равно false, то n%5==0 даже не будет вычислено. Посмотрев на инструкции из первого блока можно заметить инструкцию JUMP_IF_FALSE_OR_POP сразу после первого сравнения. Она как раз и является вариантом jump, а следовательно, нуждается «в цели». Задумайтесь об этом на секунду и потребность в разных блоках станет очевидной. JUMP_IF_FALSE_OR_POP нуждается в «цели», чтобы продолжить выполнение инструкций, когда первое логический выражение принимает значение «ложь» и из-за ленивых вычислений идёт переход к инструкции POP_JUMP_IF_FALSE в самом блоке if. Для того чтобы «прыжок» был возможен, мы оставляем инструкции тела if в другом блоке. Если все компоненты логического выражения оценены, то после выполнения инструкций в блоке BoolOp, вычисления продолжатся как обычно, по инструкциям в блоке if.
Блок 3 соответствует первому узлу orElse в AST и содержит следующие 9 инструкций:
Заметьте, что здесь оператор elif, условие n%3==0, а также тело оператора находятся в одном блоке. Теперь легко понять, почему это так. Единственный вход в этот блок — через «прыжок» из первого if, а выход может быть выполнен либо с помощью инструкции возврата, либо также с помощью прыжка, если тест единственного условия в if не пройден.
Функция LOAD_GLOBAL принимает классическую функцию str в качестве аргумента и загружает ее в стек значений. LOAD_FAST загружает аргумент n в стек, а return_value возвращает значение, полученное через CALL_FUNCTION т.е. через инструкцию str(n).
Как и в предыдущем разделе, мы рассмотрим структуры данных, которые используются при построении базовых блоков, чтобы лучше понять данный процесс.
Структура данных: compiler
На рисунке 3.4 показана взаимосвязь между основными структурами данных, используемыми в процессе генерации базовых блоков, которые составляют граф потока управления.
Рисунок 3.4: Четыре основные структуры данных, используемые при создании объекта кода.
На самом верхнем уровне находится структура данных compiler, которая отвечает за глобальный процесс компиляции модуля. Эта структура данных определена в листинге 3.12.
Листинг 3.12. Структура данных compiler
Поля, которые представляют для нас здесь интерес, следующие:
- *c_st — ссылка на таблицу символов, созданную в предыдущем разделе.
- *u — ссылка на структуру данных compiler unit. Эта инкапсулированная информация необходима для работы с блоком кода. Данное поле указывает на compiler unit выполняемого сейчас блока кода.
- *c_stack — ссылка на стек структур данных compiler_unit. Когда блок кода является составным, это поле управляет сохранением и восстановлением структур данных compile_unit при обнаружении новых блоков. Когда происходит вхождение в новый блок кода, создаётся новая область видимости, а затем compiler_enter_scope() делает «push» текущего compiler_unit (*u) в стек *c_stack, а затем создает новый объект compiler_unit и устанавливает его в качестве текущего. Когда происходит выход из блока, совершается операция «pop» из стека *c_stack, тем самым идёт восстановление предыдущего состояния.
Структура compiler инициализируется для каждого компилируемого модуля. В точности, как AST генерируется для каждого используемого модуля, также и структура compiler_unit создаётся для каждого блока кода в AST.
Структура данных: compiler_unit
Структура данных compiler_unit, показанная ниже в листинге 3.13, собирает информацию необходимую для генерации требуемых инструкций байт-кода в блоках кода. Большинство полей, определенных в compiler_unit, встретится нам при изучении объектов кода.
Листинг 3.13. Структура данных compiler_unit
Поля u_blocks и u_curblock ссылаются на базовые блоки, которые вместе составляют компилируемый блок кода. Поле *u_ste является ссылкой на запись таблицы символов для компилируемого блока кода. Остальные поля имеют довольно понятные имена, которые говорят сами за себя. Во время компиляции происходит обход различных узлов, составляющих блок кода. В зависимости от того, начинает ли данный тип узла новый базовый блок или нет, создается базовый блок (содержащий инструкции этих узлов), или же инструкции для узла добавляются в существующий базовый блок. Вот самые частые типы узлов, которые могут начинать новый базовый блок:
- Функциональные узлы.
- Узлы, где нужно совершить «прыжок».
- Обработчики исключений.
- Булевы операции и т.п.
Структуры данных basic_block и instruction
Структура данных базового блока довольно интересна в рамках процесса генерации графа потока управления. Базовый блок — это последовательность инструкций, которая имеет одну точку входа, но несколько точек выхода. Определение структуры данных basic_block, используемой в виртуальной машине python, приведено в листинге 3.14.
Листинг 3.14. Структура данных basicblock_
Как упоминалось ранее, CFG в основном состоит из базовых блоков и соединительных «путей» между ними. Поле *b_instr ссылается на массив структур данных instruction и каждая из этих структур данных содержит байт-код инструкцию. Эти байт-коды можно найти в заголовочном файле Include/opcode.h. Структура данных instruction показана в листинге 3.15.
Листинг 3.15. Структура данных instruction
Ещё раз взгляните на CFG для функции fizzbuzz. Мы видим, что на самом деле есть два пути способа перейти от выполнения блока 1 к блоку 2. Первый — через нормальное выполнение, когда все инструкции в блоке 1 выполняются и поэтому поток выполнения автоматически продолжается в блоке 2. Второй способ — инструкция перехода, мы видели такую сразу после первой операции сравнения. «Целью» такой инструкции перехода является другой базовый блок, но на самом деле виртуальная машина выполняет объекты кода, которые ничего не знают о базовых блоках. Блок кода содержит только поток байт-кодов, который индексируется с помощью смещения. Получается, мы должны взять блоки с «целями» прыжка и заменить их на смещения в массиве инструкций. Это то, что делает процесс сборки базовых блоков.
Сборка базовых блоков
После того как CFG сгенерирован, базовые блоки содержат инструкции байт-кода, являющиеся репрезентацией AST. Но блоки не упорядочены линейно, а инструкции перехода все ещё содержат базовые блоки в качестве целей перехода, вместо относительного или абсолютного смещения в потоке команд. Функция assemble обрабатывает линеаризацию CFG и создание объекта кода из CFG.
Во-первых, функция сборки базовых блоков добавляет инструкции return None в любой блок, который заканчивается без оператора RETURN. Теперь вы знаете, почему вы можете определять методы без добавления RETURN. Затем следует предварительный post-order обход графа CFG (дочерние элементы посещаются перед их корневым узлом), чтобы «сгладить» блоки.
Обход графа
При post-order depth-first обходе графа мы рекурсивно посещаем левый дочерний узел графа, за которым следует правый дочерний узел графа, а затем сам узел. В нашем графе на рисунке 3.5, когда график сглажен с использованием этого подхода, порядок узлов равен H -> D -> I -> J -> E -> B -> K -> L -> F -> G -> C -> A. При использовании pre-order обхода, мы получили бы A -> B -> D -> H -> E -> I -> J -> C -> F -> K -> L -> G. Также существует способ обхода in-order, где мы получили бы: H -> D -> B -> I -> E -> J -> A -> K -> L -> F -> C -> G
CFG для функции fizzbuzz, приведённый в листинге 3.3, довольно простой граф, поэтому результат при post-order обходе будет таковым: block 5 -> block 4 -> block 3 -> block 2 -> block 1. Как только граф линеаризирован (т.е. сглажен), смещения для «прыжков» можно рассчитать, вызвав функцию assemble_jump_offsets, передав ей полученный граф.
Расчёт смещения прыжка происходит в два этапа. На первом этапе смещение каждой инструкции в массиве инструкций рассчитывается, как показано во фрагменте из листинга 3.16. Это простой цикл, который работает с конца сглаженного массива, создавая смещение от 0.
Листинг 3.16. Расчет смещения байт-кода
На втором этапе, цели прыжка для команд перехода рассчитываются, как показано в листинге 3.17. Происходит вычисление относительных смещений при переходах и замена ими абсолютных значений.
Листинг 3.17. Сборка смещений перехода
Вычисленные смещения «прыжка» добавляются в сглаженный граф в порядке, обратном порядке обхода при линеаризации. Обратный post-order является топологической сортировкой CFG. Это означает, что для каждого ребра от вершины u до вершины v, u идет перед v в отсортированном порядке. Причина этого очевидна: мы хотим, чтобы узел, который перепрыгивает на другой, всегда был раньше «цели перехода». После завершения передачи байт-кода, объекты кода могут быть собраны для каждого блока кода, используя полученный байт-код и информацию из таблицы символов. Сгенерированный объект кода возвращается в вызывающую функцию, тем самым отмечая конец процесса компиляции.