[Перевод] Внутри виртуальной машины Python. Часть 2

phshndfwok7fnu2ex5x1i_s4d8e.png


Привет, Хабр. Перевод этой статьи занял намного больше времени, чем ожидалось. Мне очень хотелось сделать всё качественно и без обмана, но если найдёте неточности, буду рад услышать о них. Также я буду сам перечитывать и исправлять ошибки предыдущих статей, если где-то оказался не прав. Мне предстоит перевести ещё около 4-5 статей такого объёма, поэтому прошу оценить мой труд, если вам понравилось.

Компиляция исходного кода Python


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

  1. Преобразование исходного python кода в «парсинговые» деревья.
  2. Преобразование «парсинговых» деревьев в абстрактные синтаксические деревья (AST).
  3. Генерация таблицы символов.
  4. Создание объектов кода из AST. Этот шаг включает в себя:
    • Преобразование AST в граф потока управления.
    • Получение объекта кода из графа потока управления.


Создание «парсинговых» деревьев и их преобразование в AST является стандартным процессом. Python не вносит в него каких-либо сложных нюансов, поэтому в этой главе основное внимание уделяется преобразованию AST в граф потока управления и получению из этого графа объектов кода. Для тех, кто заинтересован в парсинговых деревях и генерации AST, существует «драконья» книга, которая даёт более углубленный tour de force [прим. с французского: «Большое усилие»] по обоим из этих тем.

От исходников к дереву парсинга


Парсер Python — это синтаксический анализатор LL (1), он основывается на принципах, которые изложены в «драконьей» книге. Модуль Grammar/Grammar содержит расширенную форму Бэкуса — Наура (Extended Backus-Naur Form (EBNF)) со спецификацией грамматики языка Python. Отрывок этой спецификации показан в листинге 3.0.

 1  stmt: simple_stmt | compound_stmt
 2  simple_stmt: small_stmt (';' small_stmt)* [';'] NEWLINE
 3  small_stmt: (expr_stmt | del_stmt | pass_stmt | flow_stmt |
 4          import_stmt | global_stmt | nonlocal_stmt | assert_stmt)
 5  expr_stmt: testlist_star_expr (augassign (yield_expr|testlist) |
 6                  ('=' (yield_expr|testlist_star_expr))*)
 7  testlist_star_expr: (test|star_expr) (',' (test|star_expr))* [',']
 8  augassign: ('+=' | '-=' | '*=' | '@=' | '/=' | '%=' | '&=' | '|=' | '^=' 
 9          | '<<=' | '>>=' | '**=' | '//=')
10      
11  del_stmt: 'del' exprlist
12  pass_stmt: 'pass'
13  flow_stmt: break_stmt | continue_stmt | return_stmt | raise_stmt | 
14          yield_stmt
15  break_stmt: 'break'
16  continue_stmt: 'continue'
17  return_stmt: 'return' [testlist]
18  yield_stmt: yield_expr
19  raise_stmt: 'raise' [test ['from' test]]
20  import_stmt: import_name | import_from
21  import_name: 'import' dotted_as_names
22  import_from: ('from' (('.' | '...')* dotted_name | ('.' | '...')+)
23          'import' ('*' | '(' import_as_names ')' | import_as_names))
24  import_as_name: NAME ['as' NAME]
25  dotted_as_name: dotted_name ['as' NAME]
26  import_as_names: import_as_name (',' import_as_name)* [',']
27  dotted_as_names: dotted_as_name (',' dotted_as_name)*
28  dotted_name: NAME ('.' NAME)*
29  global_stmt: 'global' NAME (',' NAME)*
30  nonlocal_stmt: 'nonlocal' NAME (',' NAME)*
31  assert_stmt: 'assert' test [',' test]
32     
33  ...


Листинг 3.0. Отрывок синтаксиса BNF в Python

При выполнении модуля, переданного интерпретатору в командной строке, совершается вызов PyParser_ParseFileObject. Эта функция инициирует синтаксический анализ файла. Она же вызывает функцию токенизации PyTokenizer_FromFile, передавая ей имя файла-модуля в качестве аргумента. Функция токенизации в Python разбивает содержимое модуля на правильные токены или же выдает исключение при обнаружении недопустимых.

Python токены


Исходный код Python состоит из токенов. Например, слово return является ключевым токеном, а литерал — 2 числовым и так далее. Первая задача при синтаксическом анализе состоит в том, чтобы токенизировать исходный код, разбив его на токены-компоненты. В Python есть несколько видов токенов:

  1. Идентификаторы — это имена, которые определены программистом. Они включают имена функций, переменных, классов и т.д. Они должны соответствовать правилам именования идентификаторов, указанным в документации python.
  2. Операторы — это специальные символы, такие как: + и *, которые работают со значениями данных и возвращают какой-то результат.
  3. Ограничители — эти символы служат для группировки выражений, пунктуации и присваивания. Члены этой категории включают в себя: (, ), {,}, =, *= и т.д.
  4. Литералы — это символы, которые обеспечивают постоянное значение для некоторого типа. У нас есть строковые и байтовые литералы, такие как «Fred» и b«Fred», числовые литералы, которые включают: целочисленные литералы, такие как 2, литералы с плавающей запятой: 1e100 и мнимые литералы: 10j.
  5. Комментарии — это строковые литералы, которые начинаются с символа #. Токены комментариев всегда заканчиваются «физическим» концом строки.
  6. NEWLINE — это специальный токен, который обозначает конец «логической» строки.
  7. INDENT и DEDENT — эти токены используются для представления уровней отступов, которые группируют инструкции.


Группа токенов, отделённая NEWLINE, образует логическую линию, которая соотносится с python инструкциями. То есть, мы можем сказать, что python-программа состоит из последовательности логических строк, каждая из которых отделена токеном NEWLINE. Каждая из этих логических строк состоит из нескольких физических, которые в свою очередь, оканчиваются символом перевода строки. Но в Python логические строки чаще всего совпадают с физическими, поэтому в большинстве случаев мы можем сказать, что логические строки разделяются символами конца строки. Составные инструкции могут занимать несколько физических строк, как это показано на рисунке 3.0. Логические строки могут быть образованы неявно (через заключение выражения в круглые, квадратные или фигурные скобки) или явно, с помощью символа обратной косой черты. Отступ также играет центральную роль в группировке операторов Python. Таким образом, одна из строк в грамматике питона может выглядеть так: suite: simple_stmt | NEWLINE INDENT stmt+ DEDENT. Как следствие, одна из основных задач токенайзера Python заключается в генерировании токенов отступа и обратного-отступа, которые добавляются в дерево парсинга. Токенайзер использует стек для отслеживания отступов и использует алгоритм из листинга 3.1

Инициируйте стек отступов значением 0.
 Для каждой логической строки с учетом объединения строк:
   A. Если отступ текущей строки больше
   отступа в верхней части стека
       1. Добавьте отступ текущей строки в верхнюю часть стека.
       2. Сгенерируйте токен INDENT.
   B. Если отступ текущей строки меньше отступа
    в верхней части стека
       1. Если в стеке
        нет уровня отступа, соответствующего отступу текущей строки, сообщите об ошибке.
       2.  Для каждого значения в верхней части стека, которое не равно
       отступу текущей строки.
           a.  Удалить значение из верхней части стека.
           b.  Создайте токен DEDENT.
   C.  Токенизация текущей строки.
Для каждого отступа в стеке, кроме 0, создайте токен DEDENT.


Листинг 3.1: Python алгоритм для генерации токенов INDENT и DEDENT.

Функция PyTokenizer_FromFile из модуля Parser/parsetok.c сканирует исходный Python файл слева-направо и сверху-вниз производя токенизацию содержимого. Символы пробелов (отличные от терминаторов) служат для разграничения токенов, но не являются обязательными. Там, где есть некоторая двусмысленность, такая как: 2+2, токенайзер пытается выделить максимально длинную строку, которая формирует легальный токен при чтении справа налево. В данном примере токенами являются литерал 2, оператор + и ещё один литерал 2.

Сгенерированные токены передаются парсеру, который пытается построить из них дерево «парсинга» (в соответствии с грамматикой python, подмножество которой указано в листинге 3.0). Когда анализатор обнаруживает токен, нарушающий грамматику, генерируется исключение SyntaxError. Модуль parser предоставляет ограниченный доступ к дереву конкретного кода Python и используется в листинге 3.2 для получения примера синтаксического дерева.

 1  >>>code_str = """def hello_world():
 2                      return 'hello world'
 3                """
 4  >>> import parser
 5  >>> from pprint import pprint 
 6  >>> st = parser.suite(code_str)
 7  >>> pprint(parser.st2list(st))
 8  [257,
 9  [269,
10  [294,
11  [263,
12      [1, 'def'],
13      [1, 'hello_world'],
14      [264, [7, '('], [8, ')']],
15      [11, ':'],
16      [303,
17      [4, ''],
18      [5, ''],
19      [269,
20      [270,
21      [271,
22          [277,
23          [280,
24          [1, 'return'],
25          [330,
26          [304,
27              [308,
28              [309,
29              [310,
30              [311,
31                  [314,
32                  [315,
33                  [316,
34                  [317,
35                      [318,
36                      [319,
37                      [320,
38                      [321,
39                          [322, [323, [3, '"hello world"']]]]]]]]]]]]]]]]]]]],
40      [4, '']]],
41      [6, '']]]]],
42  [4, ''],
43  [0, '']]
44  >>>  


Листинг 3.2. Использование модуля parser для получения дерева в Python

Вызов parser.suite(source) в листинге 3.2 возвращает объект дерева (ST), который является промежуточным представлением дерева парсинга из исходного кода (предполагается, что исходный код синтаксически корректен). Вызов parser.st2list возвращает фактическое синтаксическое дерево, представленное в виде списка python. Первые элементы в списках — целые числа, которое идентифицируют продукционные правила в грамматике Python.

Если я ещё сам правильно понял термин production rules...

Продукционных правило в информатике — специальное правило, определяющее рекурсивную замену одних символов на другие.

tn4e5q3tcnl4volizk1lb9iob_8.png
Рисунок 3.0: Дерево парсинга для листинга 3.2 (функция, возвращающая строку 'hello world')

Рисунок 3.0 — это древовидная диаграмма, показывающая то же дерево из листинга 3.2, но уже с некоторыми удаленными токенами, а также часть команд была заменена строковыми описаниями в соответствии числовым номерам. Все эти продукционные правила указаны в заголовочных файлах Include/token.h и Include/graminit.h.

В виртуальной машине CPython для представления дерева парсинга используется древовидная структура данных. Каждое продукционное правило — это узел в ней. Структура данных нода (узла) из Include/node.h показана в листинге 3.3.

1  typedef struct _node {
2      short n_type;
3      char	*n_str;
4      int n_lineno;
5      int n_col_offset;
6      int n_nchildren;
7      struct _node *n_child;
8  } node;


Листинг 3.3: Структура данных нода в виртуальной машине

По мере обхода дерева парсинга узлы могут запрашиваться по их типу, дочерним элементам (если таковые имеются), номеру строки исходного файла (которая привела к созданию данного узла) и так далее. Макросы для взаимодействия с узлами дерева парсинга также определены в файле Include/node.h.

От дерева парсинга к абстрактному синтаксическому дереву


Следующим этапом процесса компиляции в python является преобразование деревьев парсинга в абстрактное синтаксическое дерево (AST). Абстрактное синтаксическое дерево является представлением кода, которое не зависит от тонкостей синтаксиса python. Например, дерево парсинга на рисунке 3.0 содержит узел «двоеточие» [прим. двоеточие объявления функции], потому что это синтаксическая конструкция, но, как показано в листинге 3.4, AST не будет содержать таких «подробностей».

1  >>> import ast
2  >>> import pprint
3  >>> node = ast.parse(code_str, mode="exec")
4  >>> ast.dump(node)
5  ("Module(body=[FunctionDef(name='hello_world', args=arguments(args=[], "
6  'vararg=None, kwonlyargs=[], kw_defaults=[], kwarg=None, defaults=[]), '
7  "body=[Return(value=Str(s='hello world'))], decorator_list=[], "
8  'returns=None)])')


Листинг 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.

 1  struct _stmt {
 2      enum _stmt_kind kind;
 3      union {
 4          struct {
 5              identifier name;
 6              arguments_ty args;
 7              asdl_seq *body;
 8              asdl_seq *decorator_list;
 9              expr_ty returns;
10          } FunctionDef;
11 
12          struct {
13              identifier name;
14              arguments_ty args;
15              asdl_seq *body;
16              asdl_seq *decorator_list;
17              expr_ty returns;
18          } AsyncFunctionDef;
19 
20          struct {
21              identifier name;
22              asdl_seq *bases;
23              asdl_seq *keywords;
24              asdl_seq *body;
25              asdl_seq *decorator_list;
26          } ClassDef;
27          ...
28      }v;
29      int lineno;
30      int col_offset
31  }


Union в листинге 3.5 — это ключевое слово языка C, которое используется для создания атрибута, который может принимать один из любых типов, перечисленных в самом union. Функция PyAST_FromNode в модуле Python/ast.c отвечает за генерацию AST из данного дерева парсинга. Теперь пришло время создавать байт-код из сгенерированного AST.

Построение таблицы символов


Следующим шагом после создания AST является генерация таблицы символов. Таблица символов, как и предполагает название, представляет собой набор имен, используемый в блоке кода и другом контекст. Процесс построения таблицы символов включает в себя анализ имен (содержащихся в блоке кода) и присвоение таким именам правильной области видимости. Возможно, прежде чем обсуждать тонкости построения таблиц символов, стоит повторить имена и связывания (binding) в python.

Имена и Связывания


В python на объекты ссылаются по именам. «Names» похожи на переменные в C++ или Java, но это не совсем так.
>>> x = 5

В приведенном выше примере x — это имя, которое ссылается на объект: 5. Процесс присвоения ссылки на значение 5 к x называется биндингом. Связывание приводит к тому, что имя начинает ссылаться на объект, расположенный внутри самой вложенной области видимости текущей исполняемой программы. Связывание происходит во многих случаях, например, при присваивании переменной, функции, а также метода, когда переданный параметр привязан к аргументу и т.д. Важно отметить, что имена — это просто символы и они не имеют типа, который ассоциируется с ними. Тип существует у самих объектов, на которые эти имена ссылаются.

Блоки кода


Блоки кода являются центральной частью для Python программы и их понимание имеет первостепенное значение для изучения внутреннего устройства виртуальной машины Python. Блок кода — это фрагмент программного кода, который выполняется в Python как единое целое. Примерами блоков кода являются модули, функции и классы. Команды, введенные в интерактивном режиме в REPL, команды сценария запускаемые с флагом -c, также являются блоками кода. Блок кода имеет несколько пространств имен, связанных с ним. Например, блок кода модуля имеет доступ к пространству имен global, а блок кода функции имеет также доступ к пространству имен local, по-мимо пространства global.

Пространства имен


Пространство имен (namespace) является контекстом, в котором какой-то набор имен связан с объектами. Пространства имен Python реализованы в виде словаря. Встроенное пространство имен является примером namespace, которое содержит все встроенные функции и доступ к нему можно получить через ввод __builtins__.__dict__ в терминале (выходной текст будет довольно большим). Интерпретатор имеет доступ к нескольким пространствам имен, в том числе к глобальному, встроенному и локальному. Данные в namespace создаются в разное время и также имеют разное время жизни. Например, новое локальное пространство имен создается при вызове функции и удаляется при её окончании или выходе из функции. Глобальное пространство имен создаётся в начале выполнения модуля и все имена, определенные в этом namespace, доступны во всём модуле. Встроенная область видимости создаётся, когда вызывается интерпретатор и он уже содержит все встроенные имена. Эти три пространства имен являются основными namespace доступными интерпретатору.

Области видимости


Область видимости — это часть программы, в которой набор биндингов (пространство имен) виден и доступен напрямую без использования точечной нотации. [прим. кажется, здесь опечатка и имеется ввиду доступ через функцию globals()]. Во время выполнения программы могут быть доступны следующие области видимости:
  1. Самая «внутренняя» область видимости, содержащая локальные переменные
  2. При вложенных функциях, во вложенной функции можно получить доступ к пространству имён более «внешней».
  3. Глобальная область видимости текущего модуля
  4. Область видимости, содержащая встроенное пространство имен

Когда в python упоминается имя, интерпретатор ищет пространство имен области видимости в порядке возрастания (как указано выше) и если имя не найдено ни в одном из пространств имен, возникает исключение. Python поддерживает статическую область видимости (также известную как лексическая область видимости). Это означает, что видимость биндингов имен может быть определена только путем проверки текста программы.

Примечание


В Python есть своеобразное правило для области видимости, которое предотвращает изменение ссылки на объект глобальной области видимости в локальной. Такое действие приведет к исключению UnboundLocalError. Следующий пример иллюстрирует это:
    >>> a = 1
    >>> def inc_a(): a += 2
    ... 
    >>> inc_a()
    Traceback (most recent call last):
     File "<stdin>", line 1, in <module>
     File "<stdin>", line 1, in inc_a
    UnboundLocalError: local variable 'a' referenced before assignment

Листинг A3.0. Попытка изменить глобальную переменную из функции

Чтобы изменить «глобальный» объект в локальной области, необходимо использовать ключевое слово global с именем объекта:

>>> a = 1
>>> def inc_a():
...     global a
...     a += 1
... 
>>> inc_a()
>>> a
2

Листинг A3.1. Использование ключевого слова global для изменения глобальной переменной из функции

Python также имеет ключевое слово nonlocal. Оно используется, когда необходимо изменить переменную, находящуюся во внешней, но «не глобальной» области видимости. Это очень удобно при работе с вложенными функциями (также называемыми замыканиями). Очень простая иллюстрация ключевого слова nonlocal показана в следующем фрагменте, который определяет простой счетчик для объекта:

>>> def make_counter():
...     count = 0
...     def counter():
...         nonlocal count #capture count binding from enclosing not global scope
...         count += 1
...         return count
...     return counter
... 
>>> counter_1 = make_counter()
>>> counter_2 = make_counter()
>>> counter_1()
1
>>> counter_1()
2
>>> counter_2()
1
>>> counter_2()
2

Листинг A3.2. Вложенные функции со счётчиком


Последовательность вызовов функций run_mod -> PyAST_CompileObject -> PySymtable_BuildObject запускает процесс построения таблицы символов. Два аргумента функции PySymtable_BuildObject — это ранее сгенерированный AST и имя модуля. Алгоритм построения таблицы символов разбит на две части. В первой «посещается» каждый узел AST, чтобы создать коллекцию символов, используемых в AST. Очень простое описание этого процесса приведено в листинге 3.6, и термины, используемые в нем, станут более очевидными, когда мы обсудим структуры данных, используемые при построении таблицы символов.

for каждый_узел in AST
    if узел == начало_блока_кода:
        1. Cоздайть новую запись в таблице символов и
           установите это значение текущей таблице символов
        2. Сделайте "push" новой таблицы в st_stack.
        3. Добавьте новую таблицу символов в список
           дочерних предыдущей таблицы.
        4. Замените текущую таблицу только что созданной
        5. for все_узлы in узлы_блока_кода:
            a. Рекурсивно посетить каждый узел функцией
               "symtable_visit_XXX", где "XXX" это тип узла.
        6. Выйти из блока кода, через удаление текущей таблицы
           символов из стека.
        7. Сделайте "pop" следующей таблицы символов из стека
           и обновите значение текующей таблицы символов этим значением.
    else:
        рекурсивно посетить узел и под-узел.


Листинг 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 хранилище для переменной, которая может пережить фрейм функции. Поэтому переменные-ячейки удаляются из набора свободных переменных прежде, чем функция анализа возвращается к своему родителю.


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

Структуры данных таблицы символов


Существует две структуры данных, которые являются центральными для генерации таблицы символов:

  1. Структура данных таблицы символов.
  2. Структура данных записи таблицы символов.


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

 1  struct symtable {
 2      PyObject *st_filename;          /* name of file being compiled */
 3      struct _symtable_entry *st_cur; /* current symbol table entry */
 4      struct _symtable_entry *st_top; /* symbol table entry for module */
 5      PyObject *st_blocks;            /* dict: map AST node addresses
 6                                      *       to symbol table entries */
 7      PyObject *st_stack;             /*list: stack of namespace info */
 8      PyObject *st_global;            /*borrowed ref to 
 9                                      st_top->ste_symbols*/
10      int st_nblocks;                 /* number of blocks used. kept for
11                                      consistency with the corresponding
12                                      compiler structure */
13      PyObject *st_private;           /* name of current class or NULL */
14      PyFutureFeatures *st_future;    /* module's future features that 
15                                      affect the symbol table */
16      int recursion_depth;            /* current recursion depth */
17      int recursion_limit;            /* recursion limit */
18  };


Листинг 3.7. Структура данных таблицы символов.

Модуль Python может содержать несколько блоков кода — например, несколько определений функций. Поле st_blocks является отображением всех существующих блоков кода в один элемент таблицы символов. Запись таблицы st_top — это таблица символов для компилируемого модуля (напомним, что модуль также является блоком кода), поэтому она будет содержать имена, определенные в глобальном пространстве имен модуля. Поле st_cur относится к записи таблицы символов для кодового блока, который обрабатывается в данный момент. Каждый блок кода внутри «блока кода модуля» имеет свою собственную запись таблицы символов, которая содержит символы, определенные в этом блоке кода.

elgr1ajwa_cenmpdkjc1g_mfmnw.png
Рисунок 3.1: таблица символов и записи в ней.

В очередной раз, просмотр структуры данных _symtable_entry из файла Include/symtable.h очень полезен, чтобы понять, как она работает. Данная структура данных показана в листинге 3.8.

 1  typedef struct _symtable_entry {
 2      PyObject_HEAD
 3      PyObject *ste_id;               /* int: key in ste_table->st_blocks */
 4      PyObject *ste_symbols;          /* dict: variable names to flags */
 5      PyObject *ste_name;             /* string: name of current block */
 6      PyObject *ste_varnames;         /* list of function parameters */
 7      PyObject *ste_children;         /* list of child blocks */
 8      PyObject *ste_directives;       /* locations of global and nonlocal 
 9                                       statements */
10      _Py_block_ty ste_type;          /* module, class, or function */
11      int ste_nested;                 /* true if block is nested */
12      unsigned ste_free : 1;          /*true if block has free variables*/
13      unsigned ste_child_free : 1;    /* true if a child block has free 
14                                      vars including free refs to globals*/
15      unsigned ste_generator : 1;     /* true if namespace is a generator */
16      unsigned ste_varargs : 1;       /* true if block has varargs */
17      unsigned ste_varkeywords : 1;   /* true if block has varkeywords */
18      unsigned ste_returns_value : 1; /* true if namespace uses return with
19                                          an argument */
20      unsigned ste_needs_class_closure : 1; /* for class scopes, true if a
21                                              closure over __class__
22                                              should be created */
23      int ste_lineno;          /* first line of block */
24      int ste_col_offset;      /* offset of first line of block */
25      int ste_opt_lineno;      /* lineno of last exec or import * */
26      int ste_opt_col_offset;  /* offset of last exec or import * */
27      int ste_tmpname;         /* counter for listcomp temp vars */
28      struct symtable *ste_table;
29  } PySTEntryObject;


Листинг 3.8. Структура данных _symtable_entry

Комментарии в исходном коде объясняют, что делает каждое поле. Поле ste_symbols содержит отображение символов/имен, которые встречаются при анализе блока кода. Флаги, на которые отображаются символы, представляют собой числовые значения, которые дают нам информацию о контексте, в котором используется символ/имя. Например, символ может быть аргументом функции или определением глобального оператора. Некоторые примеры этих флагов, определенных в модуле Include/symtable.h, приведены в листинге 3.9.

1  /* Flags for def-use information */
2  #define DEF_GLOBAL 1           /* global stmt */
3  #define DEF_LOCAL 2            /* assignment in code block */
4  #define DEF_PARAM 2<<1         /* formal parameter */
5  #define DEF_NONLOCAL 2<<2      /* nonlocal stmt */
6  #define DEF_FREE 2<<4          /* name used but not defined in 
7                                    nested block */


Листинг 3.9. Флаги, которые определяют контекст определения имени

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

def make_counter():
    count = 0
    def counter():
        nonlocal count 
        count += 1
        return count
    return counter


Листинг 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.

1     def fizzbuzz(n):
2         if n % 3 == 0 and n % 5 == 0:
3             return 'FizzBuzz'
4         elif n % 3 == 0:
5             return 'Fizz'
6         elif n % 5 == 0:
7             return 'Buzz'
8         else:
9             return str(n)


Листинг 3.11. Простая python функция

AST для этой функции показан на рисунке 3.2.
bwj0rglts7capafb7f9dytkpmku.png
Рисунок 3.2: Очень простой AST для листинга 3.2

Этот AST из рисунка 3.2 при компиляции в CFG возвращает граф, аналогичный показанному на рисунке 3.3. Пустые блоки на рисунке были опущены. Рассмотрение этого графа обеспечит некоторую информацию о том, что скрывается за базовыми блоками. Базовые блоки имеют одну точку входа, но могут иметь несколько выходов. Эти блоки описаны более подробно далее.

fc93xtxbvcmzwwkfkr_x_bf46r0.jpeg


Рисунок 3.3: Граф потока управления для функции fizzbuzz из листинга 3.11. Прямая линия представляет нормальное, прямолинейное выполнение кода, в то время как изогнутые линии представляют «прыжки».

В следующие описания включены только фактические инструкции. Для некоторых инструкций нужны аргументы, но он были удалены, поскольку сейчас нас не интересуют.

  • Блок 1 содержит инструкции, являющиеся узлом BoolOp в AST на рисунке 3.2. Инструкции в этом блоке реализуют операцию: n%3==0 and n%5==0, используя следующий набор из одиннадцати команд:
    LOAD_FAST               
    LOAD_CONST              
    BINARY_MODULO
    LOAD_CONST              
    COMPARE_OP              
    JUMP_IF_FALSE_OR_POP    
    LOAD_FAST               
    LOAD_CONST              
    BINARY_MODULO
    LOAD_CONST              
    COMPARE_OP

    Удивительно, но остальная часть узла if (фактический тест, который определяет, нужно ли выполнить код ниже) не включен в этот блок. Причина станет более понятной, когда мы обсудим второй блок. Как показано на рисунке 3.3, есть два способа выйти из этого блока: либо через прямое выполнение всех опкодов, либо путем перехода к блоку 2 через инструкцию JUMP_IF_FALSE_OR_POP.
  • Блок 2 отображается на первый узел if, инкапсулирует тест if и последующий код. Второй блок содержит следующие четыре инструкции:
    POP_JUMP_IF_FALSE
    LOAD_CONST
    RETURN_VALUE
    JUMP_FORWARD

    Как мы увидим в следующих главах, когда интерпретатор выполняет инструкции байт-кода для оператора 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 инструкций:
    LOAD_FAST
    LOAD_CONST
    BINARY_MODULO
    LOAD_CONST
    COMPARE_OP
    POP_JUMP_IF_FALSE
    LOAD_CONST
    return_value
    JUMP_FORWARD

    Заметьте, что здесь оператор elif, условие n%3==0, а также тело оператора находятся в одном блоке. Теперь легко понять, почему это так. Единственный вход в этот блок — через «прыжок» из первого if, а выход может быть выполнен либо с помощью инструкции возврата, либо также с помощью прыжка, если тест единственного условия в if не пройден.
  • Блок 4 является зеркальным по отношению к блоку 3 с точки зрения инструкций, но аргументы к инструкциям отличаются.
  • Блок 5 является отображением конечного узла orElse и содержит следующие 4 инструкции:
    LOAD_GLOBAL
    LOAD_FAST
    call_function
    return_value

    Функция LOAD_GLOBAL принимает классическую функцию str в качестве аргумента и загружает ее в стек значений. LOAD_FAST загружает аргумент n в стек, а return_value возвращает значение, полученное через CALL_FUNCTION т.е. через инструкцию str(n).


Как и в предыдущем разделе, мы рассмотрим структуры данных, которые используются при построении базовых блоков, чтобы лучше понять данный процесс.

Структура данных: compiler


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

guvihekbyxdqstvud3wv98lldqw.png
Рисунок 3.4: Четыре основные структуры данных, используемые при создании объекта кода.

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

 1  struct compiler {
 2      PyObject *c_filename;
 3      struct symtable *c_st;
 4      PyFutureFeatures *c_future; /* pointer to module's __future__ */
 5      PyCompilerFlags *c_flags;
 6          
 7      int c_optimize;              /* optimization level */
 8      int c_interactive;           /* true if in interactive mode */
 9      int c_nestlevel;
10          
11      struct compiler_unit *u; /* compiler state for current block */
12      PyObject *c_stack;           /* Python list holding compiler_unit 
13                                  ptrs */
14      PyArena *c_arena;            /* pointer to memory allocation arena */
15  };


Листинг 3.12. Структура данных compiler

Поля, которые представляют для нас здесь интерес, следующие:

  1. *c_st — ссылка на таблицу символов, созданную в предыдущем разделе.
  2. *u — ссылка на структуру данных compiler unit. Эта инкапсулированная информация необходима для работы с блоком кода. Данное поле указывает на compiler unit выполняемого сейчас блока кода.
  3. *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, встретится нам при изучении объектов кода.

 1  struct compiler_unit {
 2      PySTEntryObject *u_ste;
 3        
 4      PyObject *u_name;
 5      PyObject *u_qualname;  /* dot-separated qualified name (lazy) */
 6      int u_scope_type;
 7      
 8      /* The following fields are dicts that map objects to
 9      the index of them in co_XXX.      The index is used as
10      the argument for opcodes that refer to those collections.
11      */
12      PyObject *u_consts;    /* all constants */
13      PyObject *u_names;     /* all names */
14      PyObject *u_varnames;  /* local variables */
15      PyObject *u_cellvars;  /* cell variables */
16      PyObject *u_freevars;  /* free variables */
17         
18      Py
    
            

© Habrahabr.ru