Статьи

Внутренние элементы Python: добавление нового оператора в Python

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

Все кодирование для этой статьи было сделано против передовой ветви Py3k в зеркале репозитория Python Mercurial .

То до заявления

Некоторые языки, как Ruby, не имеют до утверждения, которое является дополнением к в то время ( до пит == 0 не эквивалентно в то время как NUM ! = 0 ). В Ruby я могу написать:

num = 3
until num == 0 do
  puts num
  num -= 1
end

 

И это напечатает:

3
2
1

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

num = 3
until num == 0:
  print(num)
  num -= 1

Отступление от языка

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

Модификация грамматики

Python использует собственный генератор парсеров с именем pgen . Это парсер LL (1), который преобразует исходный код Python в дерево разбора. Входными данными для генератора синтаксического анализатора является файл Grammar / Grammar [1] . Это простой текстовый файл, который определяет грамматику Python.

В файл грамматики должны быть внесены две модификации. Во — первых, чтобы добавить определение для до утверждения. Я нашел , где в то время как было определено утверждение ( while_stmt ), и добавил until_stmt ниже [2] :

compound_stmt: if_stmt | while_stmt | until_stmt | for_stmt | try_stmt | with_stmt | funcdef | classdef | decorated
if_stmt: 'if' test ':' suite ('elif' test ':' suite)* ['else' ':' suite]
while_stmt: 'while' test ':' suite ['else' ':' suite]
until_stmt: 'until' test ':' suite

Обратите внимание, что я решил исключить условие else из моего определения before , просто чтобы сделать его немного другим (и потому что, откровенно говоря, мне не нравится предложение else из циклов и я не думаю, что оно хорошо согласуется с Zen of Python) ,

Второе изменение состоит в том, чтобы изменить правило для составного_стука, чтобы оно включало в себя до_ст.мт , как вы можете видеть в приведенном выше фрагменте. Это сразу после while_stmt , снова.

Когда вы запустите make после изменения Grammar / Grammar , обратите внимание, что запускается программа pgen для повторного генерирования Include / graminit.h и Python / graminit.c , а затем несколько файлов перекомпилируются.

Модификация кода генерации AST

После того, как анализатор Python создал дерево разбора, это дерево преобразуется в AST, поскольку с AST намного проще работать на последующих этапах процесса компиляции.

Итак, мы собираемся посетить Parser / Python.asdl , который определяет структуру НРХА Python и добавить узел AST для нашего нового , пока заявление, снова прямо ниже , а :

| While(expr test, stmt* body, stmt* orelse)
| Until(expr test, stmt* body)

 

Если вы сейчас запустите make , обратите внимание, что перед компиляцией группы файлов запускается Parser / asdl_c.py для генерации кода C из файла определения AST. Это (например, грамматика / грамматика ) является еще одним примером исходного кода Python, использующего мини-язык (другими словами, DSL) для упрощения программирования. Также обратите внимание, что, поскольку Parser / asdl_c.py является скриптом Python, это своего рода начальная загрузка — для сборки Python с нуля Python уже должен быть доступен.

В то время как Parser / asdl_c.py сгенерировал код для управления нашим вновь определенным узлом AST (в файлы Include / Python-ast.h и Python / Python-ast.c ), нам все еще нужно написать код, который преобразует соответствующий синтаксический анализ. узел дерева в него от руки. Это делается в файле Python / ast.c . Там функция с именем ast_for_stmt преобразует узлы дерева разбора для операторов в узлы AST. Опять же, руководствуясь нашим старым другом while , мы прыгнем прямо в большой переключатель для обработки составных операторов и добавим предложение для till_stmt :

case while_stmt:
    return ast_for_while_stmt(c, ch);
case until_stmt:
    return ast_for_until_stmt(c, ch);

Теперь мы должны реализовать ast_for_until_stmt . Вот:

static stmt_ty
ast_for_until_stmt(struct compiling *c, const node *n)
{
    /* until_stmt: 'until' test ':' suite */
    REQ(n, until_stmt);

    if (NCH(n) == 4) {
        expr_ty expression;
        asdl_seq *suite_seq;

        expression = ast_for_expr(c, CHILD(n, 1));
        if (!expression)
            return NULL;
        suite_seq = ast_for_suite(c, CHILD(n, 3));
        if (!suite_seq)
            return NULL;
        return Until(expression, suite_seq, LINENO(n), n->n_col_offset, c->c_arena);
    }

    PyErr_Format(PyExc_SystemError,
                 "wrong number of tokens for 'until' statement: %d",
                 NCH(n));
    return NULL;
}

Опять же, это было написано при внимательном рассмотрении эквивалента ast_for_ while_stmt , с той разницей, что пока я решил не поддерживать предложение else . Как и ожидалось, АСТ создается рекурсивно, используя другие функции AST , создающего как ast_for_expr для выражения состояния и ast_for_suite для тела из до утверждения. Наконец, новый узел с именем До возвращается.

Обратите внимание, что мы получаем доступ к узлу дерева разбора n с помощью некоторых макросов, таких как NCH и CHILD . Это стоит понимать — их код в Include / node.h .

Отступление: композиция АСТ

Я выбрал не создать новый тип AST для до заявления, но на самом деле это не является необходимым. Я мог бы сохранить некоторую работу и реализовать новую функциональность, используя композицию существующих узлов AST, поскольку:

until condition:
   # do stuff

Функционально эквивалентно:

while not condition:
  # do stuff

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

Компиляция AST в байт-код

Следующим шагом является компиляция AST в байт-код Python. Компиляция имеет промежуточный результат — CFG (Control Flow Graph), но поскольку этот код обрабатывает его, я пока проигнорирую эту деталь и оставлю ее для другой статьи.

Далее мы рассмотрим код Python / compile.c . Следуя примеру while , мы находим функцию compiler_visit_stmt , которая отвечает за компиляцию операторов в байт-код. Мы добавляем пункт для До :

case While_kind:
    return compiler_while(c, s);
case Until_kind:
    return compiler_until(c, s);

 

Если вы задаетесь вопросом, что такое Before_kind , это константа (на самом деле значение перечисления _stmt_kind ), автоматически генерируемая из файла определения AST в Include / Python-ast.h . В любом случае, мы вызываем compiler_until, который, конечно же, до сих пор не существует. Я доберусь до этого на мгновение.

Если вам интересно, как я, вы заметите, что compiler_visit_stmt является своеобразным. Никакое количество Grep -ping дерево исходных текстов не показывает , где она называется. В этом случае остается только один вариант — C macro-fu. Действительно, краткое исследование приводит нас к макросу VISIT, определенному в Python / compile.c :

#define VISIT(C, TYPE, V) {\
    if (!compiler_visit_ ## TYPE((C), (V))) \
        return 0; \

Он используется для вызова compiler_visit_stmt в compiler_body . Вернемся к нашему бизнесу, однако …

Как и было обещано, вот compiler_until :

static int
compiler_until(struct compiler *c, stmt_ty s)
{
    basicblock *loop, *end, *anchor = NULL;
    int constant = expr_constant(s->v.Until.test);

    if (constant == 1) {
        return 1;
    }
    loop = compiler_new_block(c);
    end = compiler_new_block(c);
    if (constant == -1) {
        anchor = compiler_new_block(c);
        if (anchor == NULL)
            return 0;
    }
    if (loop == NULL || end == NULL)
        return 0;

    ADDOP_JREL(c, SETUP_LOOP, end);
    compiler_use_next_block(c, loop);
    if (!compiler_push_fblock(c, LOOP, loop))
        return 0;
    if (constant == -1) {
        VISIT(c, expr, s->v.Until.test);
        ADDOP_JABS(c, POP_JUMP_IF_TRUE, anchor);
    }
    VISIT_SEQ(c, stmt, s->v.Until.body);
    ADDOP_JABS(c, JUMP_ABSOLUTE, loop);

    if (constant == -1) {
        compiler_use_next_block(c, anchor);
        ADDOP(c, POP_BLOCK);
    }
    compiler_pop_fblock(c, LOOP, loop);
    compiler_use_next_block(c, end);

    return 1;
}

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

Вот и все, мы закончили … не так ли?

После внесения всех изменений и запуска make мы можем запустить только что скомпилированный Python и попробовать наш новый до утверждения:

>>> until num == 0:
...   print(num)
...   num -= 1
...
3
2
1

Вуаля, это работает! Давайте посмотрим байт-код, созданный для нового оператора с помощью модуля dis, следующим образом:

import dis

def myfoo(num):
    until num == 0:
        print(num)
        num -= 1

dis.dis(myfoo)

Вот результат:

4           0 SETUP_LOOP              36 (to 39)
      >>    3 LOAD_FAST                0 (num)
            6 LOAD_CONST               1 (0)
            9 COMPARE_OP               2 (==)
           12 POP_JUMP_IF_TRUE        38

5          15 LOAD_NAME                0 (print)
           18 LOAD_FAST                0 (num)
           21 CALL_FUNCTION            1
           24 POP_TOP

6          25 LOAD_FAST                0 (num)
           28 LOAD_CONST               2 (1)
           31 INPLACE_SUBTRACT
           32 STORE_FAST               0 (num)
           35 JUMP_ABSOLUTE            3
      >>   38 POP_BLOCK
      >>   39 LOAD_CONST               0 (None)
           42 RETURN_VALUE

Самая интересная операция — номер 12: если условие истинно, мы переходим к циклу. Это правильная семантика для пока . Если переход не выполняется, тело цикла продолжает работать до тех пор, пока не перейдет обратно к состоянию на этапе 35.

Чувствуя себя хорошо, я попытался запустить функцию (выполняя myfoo (3) ) вместо показа ее байт-кода. Результат был менее чем обнадеживающим:

Traceback (most recent call last):
  File "zy.py", line 9, in <module>
    myfoo(3)
  File "zy.py", line 5, in myfoo
    print(num)
SystemError: no locals when loading 'print'

Вау … это не может быть хорошо. Так что пошло не так?

Случай отсутствующей таблицы символов

Одним из шагов, которые выполняет компилятор Python при компиляции AST, является создание таблицы символов для кода, который он компилирует. Вызов PySymtable_Build в PyAST_Compile вызывает модуль таблицы символов ( Python / symtable.c ), который обходит AST аналогично функциям генерации кода. Наличие таблицы символов для каждой области помогает компилятору выяснить некоторую ключевую информацию, например, какие переменные являются глобальными, а какие локальными для области.

Чтобы решить эту проблему, мы должны изменить symtable_visit_stmt функции в Python / symtable.c , добавив код для обработки не до заявлений, после подобного кода для в то время как утверждения [3] :

case While_kind:
    VISIT(st, expr, s->v.While.test);
    VISIT_SEQ(st, stmt, s->v.While.body);
    if (s->v.While.orelse)
        VISIT_SEQ(st, stmt, s->v.While.orelse);
    break;
case Until_kind:
    VISIT(st, expr, s->v.Until.test);
    VISIT_SEQ(st, stmt, s->v.Until.body);
    break;

И теперь мы действительно закончили. Компиляция источника после этого изменения заставляет выполнение myfoo (3) работать как ожидалось.

Вывод

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

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

Рекомендации

Я использовал несколько отличных ссылок для построения этой статьи. Вот они, в произвольном порядке:

  • PEP 339. Разработка компилятора CPython — возможно, самая важная и полная часть официальной документации для компилятора Python. Будучи очень коротким, он болезненно отображает недостаток хорошей документации внутренних компонентов Python.
  • «Внутренние компоненты компилятора Python» — статья Томаса Ли
  • «Питон: дизайн и реализация» — презентация Гвидо ван Россума
  • Виртуальная машина Python (2.5), экскурсия с гидом — презентация Питера Трегера
http://eli.thegreenplace.net/wp-content/uploads/hline.jpg
[1] С этого момента ссылки на файлы в исходном тексте Python даются относительно корня дерева исходных текстов, который является каталогом, в котором вы запускаете configure и make для сборки Python.
[2] Это демонстрирует общую технику, которую я использую при модификации исходного кода, с которой я не знаком: работа по подобию . Этот принцип не решит все ваши проблемы, но он определенно может облегчить процесс. Так как все, что должно быть сделано в то время как также должно быть сделано до тех пор , пока это служит довольно хорошим ориентиром.
[3] Кстати, без этого кода есть предупреждение компилятора для Python / symtable.c . Компилятор замечает, что значение перечисления Before_kind не обрабатывается в операторе switch symtable_visit_stmt, и жалуется. Всегда важно проверять предупреждения компилятора!