peco-грамматика и правила переписывания AST по PEP 636 системы символьного дифференцирования
peco-грамматика и правила переписывания AST по PEP 636 системы символьного дифференцирования

Задачи разработки компиляторов и интерпретаторов конфигурационных языков или даже полноценных Тьюринг-полных языков программирования время от времени встают перед разработчиками программного обеспечения. На практике, как правило, речь идёт о разработке предметно-ориентированных языков (англ. Domain Specific Language, DSL), проектируемых специально для решения узкого класса прикладных задач.

DSL применяются, в том числе, для:

Классическая архитектура компилятора включает фронтенд (англ. Compiler Frontend) и бэкенд (англ. Compiler Backend). Фронтенд компилятора преобразует программу на языке высокого уровня в промежуточное представление — дерево абстрактного синтаксиса (англ. Abstract Syntax Tree, AST), выполняя также семантический анализ AST, а бэкенд синтезирует целевой код из AST, при необходимости применяя оптимизирующие преобразования.

Замечание

В современных промышленных компиляторах, таких как LLVM и GCC, также выделяют стадию Middle-End, на которой к программе применяются оптимизирующие преобразования, не зависящие ни от входного, ни от выходного языка. Для упрощения мы относим эту стадию к бэкенду рассматриваемого в статье миниатюрного транслятора DSL, хотя такие его части, как система переписывания AST и графовое промежуточное представление, о которых пойдёт речь далее, можно было бы выделить в отдельную стадию Middle-End.

Как указано в ГОСТ 19781-90, компиляция — это трансляция программы с языка высокого уровня в форму, близкую к программе на машинном языке. Трансляция — это преобразование программы, представленной на одном языке программирования, в программу на другом языке и в определенном смысле равносильную первой. На высоком уровне транслятор можно описать как:

Code -> AST -> Code'

В настоящей статье рассматривается один из способов реализации DSL на примере разработки системы символьного дифференцирования, как в SymPy, с использованием парсер-комбинаторов peco и структурного сопоставления с образцом по PEP 636. Материал рассчитан на прикладных разработчиков, уже знакомых с Python, но, надеюсь, может быть полезен и продолжающим компиляторщикам.

Средство для символьного дифференцирования выражений

Рассмотрим следующую задачу. Пусть дан текст на формальном языке для описания простых арифметических выражений с поддержкой переменных и вызовов функций, причём грамматика этого языка в расширенной форме Бэкуса-Наура (англ. Extended Backus-Naur Form, EBNF) по стандарту ISO/IEC 14977:1996 имеет вид:

' var = [a-z∂]+
' num = [0-9]+
op   = "+" | "*" | "^" | "-" | "/";
expr = "(" , expr , op , expr , ")"
     | var , "(" , expr , { "," , expr } , ")"
     | var
     | num;

Замечание. В целях упрощения, для определения правил var и num использованы регулярные выражения. Однако, в «настоящем» EBNF пришлось бы явно перечислить все подходящие символы: letter = "a" | "b" | ...

По грамматике выше, имена переменных и функций (см. правило var) в рассматриваемом языке состоят из одной или нескольких букв в нижнем регистре, при этом поддерживаются операторы (см. правило op) сложения, умножения, возведения в степень и другие.

Грамматика expr уже интереснее — выражение может быть применением бинарного оператора к двум другим выражениям, вызовом функции, переменной или числом. EBNF, соответствующую стандарту ISO/IEC 14977:1996, легко визуализировать при помощи PlantUML и получить железнодорожную диаграмму (англ. railroad diagram), описывающую синтаксис выражения:

Железнодорожная диаграмма (англ. railroad diagram) для правила expr из EBNF выше
Железнодорожная диаграмма (англ. railroad diagram) для правила expr из EBNF выше

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

(x(y(5))-x)
(exp((x+1))/(y^2))
x(z(y(x,(z+1))))
∂(((2*(x^y))+(log(x))),x)
∂((x^(2+1)),x)
...

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

(exp((x+1))/(y^2))
∂(((2*(x^y))+(log(x))),x)
∂((x^(2+1)),x)
...

Выражение ∂(expr,x) выше обозначает производную выражения expr по x.

Именно такие выражения нас будут интересовать в следующих разделах.

Фронтенд транслятора

Классическая реализация фронтенда транслятора включает два этапа — лексический разбор и синтаксический разбор. На этапе лексического разбора строка с текстом программы преобразуется в список лексических единиц — токенов, а на этапе синтаксического разбора из списка токенов строится AST. Подобным образом работают такие инструменты, как flex и bison или python lex/yacc Д. Бизли.

Однако, мы применим другой подход, известный в литературе как РВ-грамматика — грамматику, разбирающую выражение (англ. Parsing Expression Grammar, PEG). PEG, в отличие от контекстно-свободных грамматик, не могут быть неоднозначными — проверка альтернатив оператором | в PEG производится всегда последовательно. Кроме того, PEG позволяет разобрать текст сразу в AST, без выделенного этапа лексического разбора строки:

Code -> AST

Начало работы

Для разбора нашего формального языка мы воспользуемся комбинаторами разбора, реализованными в библиотеке parsing expression combinators (peco). По сравнению с существующими аналогами [1, 2], библиотека peco проста и минималистична: она не имеет внешних зависимостей и представляет собой единственный Python-файл, содержащий менее 100 непустых строк кода (sic!), 0 классов и всего 16 комбинаторов — функций высшего порядка, принимающих на вход функции и возвращающих другие функции. Эти комбинаторы генерируют парсеры, которые принимают на вход и возвращают состояние разбора:

Peco = namedtuple('Peco', 'text pos ok stack glob')

Для начала работы необходимо подключить peco к нашему проекту. В случае, если мы работаем в git-репозитории, один из простейших способов сделать это — добавить peco в наш репозиторий как подмодуль git, выполнив из корня нашего репозитория команду:

git submodule add https://github.com/true-grue/peco.git peco

Теперь можно приступать к разработке фронтенда транслятора!

Начнём с разбора имён переменных и функций:

from peco.peco import *

var = eat(r'[a-z∂]+')

print(parse('∂', var).ok)   # True
print(parse('exp', var).ok) # True
print(parse('42', var).ok)  # False

Отлично! Имена переменных по самому первому правилу в EBNF выше у нас получилось распознать. Функция parse запускает процесс разбора заданной строки заданным правилом, а комбинатор eat принимает на вход регулярное выражение и во время разбора текста пробует применить регулярное выражение к строке, начиная с текущей позиции pos. В случае успеха eat перемещает курсор pos вперёд на число распознанных регулярным выражением символов.

Аналогичным образом разберём числа:

num = eat(r'[0-9]+')

print(parse('1', num).ok)  # True
print(parse('42', num).ok) # True
print(parse('x', num).ok)  # False

Также разберём операторы, поддерживающиеся в нашем формальном языке:

op = eat(r'\+|\*|\^|-|/')

print(parse('+', op).ok) # True
print(parse('%', op).ok) # False

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

Реализуем теперь правило разбора для выражения expr:

expr = lambda s: expr(s)
expr = alt(
    seq(eat(r'\('), expr, op, expr, eat(r'\)')),
    seq(var, eat(r'\('), expr, many(seq(eat(r','), expr)), eat(r'\)')),
    var,
    num,
)

print(parse('(exp((x+1))/(y^2))', expr).ok)      # True    
print(parse('∂(((2*(x^y))+log(x)),x)', expr).ok) # True
print(parse('∂((x^(2+1)),x)', expr).ok)          # True
print(parse('∂(x^(2+1)),x)', expr).ok)           # False

Здесь регулярных выражений оказалось недостаточно, добавились новые комбинаторы!

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

  • Комбинатор seq применяет к тексту все переданные ему парсеры последовательно, передвигая курсор вправо после каждого удачного распознавания, при этом должны сработать все переданные парсеры.

  • Комбинатор many работает как звезда Клини * — применяет парсер 0 или ∞ раз.

Замечание. Выражение expr = lambda s: expr(s) позволяет сделать правило expr рекурсивным: сначала в области видимости создаётся функция с именем expr, вызывающая в своём теле функцию с именем expr. Однако после выполнения инструкции expr = alt(...) имя expr в текущей области видимости переопределяется, и ранее определённая функция expr теперь в своём теле вызывает новую функцию. Убедитесь в этом при помощи вызова locals() или globals().

Мы реализовали грамматику, успешно распознающую все выражения из вводной части! Но, всё-таки, ожидается, что результатом работы фронтенда транслятора окажется AST, построенное из текста программы.

Внимательный читатель наверняка обратит внимание, что помимо полей text, pos и ok, с которыми мы уже работали, у состояния peco есть поле stack. Для обновления содержимого этого поля в peco реализованы комбинаторы push, to и group:

  • Комбинатор push(f) добавляет на стек распознанную парсером f строку.

  • Комбинатор to(f) снимает с вершины стека n элементов, где n — число аргументов функции f, позволяет обработать извлечённый набор элементов и поместить результат обработки обратно на стек.

  • Комбинатор group(f) объединяет все элементы, размещённые парсером f на стеке, в единственный кортеж.

Перепишем нашу грамматику, используя стек для хранения и преобразования результатов распознавания:

from peco.peco import *
from pprint import pprint

# Добавим на стек распознанную строку (push).
var = push(eat(r'[a-z∂]+'))

# Добавим на стек распознанную строку, состоящую из цифр (push).
# Снимем строку с вершины стека, преобразуем в int, добавим обратно (to). 
num = seq(push(eat(r'[0-9]+')), to(lambda x: int(x)))

# Добавим на стек распознанную строку из одного символа (push).
op = push(eat(r'\+|\*|\^|-|/'))

expr = lambda s: expr(s)

# Вынесем аргументы функции в отдельное правило args для читаемости.
# Сгруппируем аргументы, добавленные на стек парсерами expr, в кортеж (group).
args = group(seq(expr, many(seq(eat(r','), expr))))
expr = alt(
    # Снимем 3 элемента со стека и добавим 1 кортеж
    # в префиксной форме обратно на стек (to).
    seq(eat(r'\('), expr, op, expr, eat(r'\)'),
        to(lambda lhs, op, rhs: (op, lhs, rhs))),
    # Снимем 2 элемента со стека и добавим 1 кортеж
    # в префиксной форме обратно на стек (to).
    seq(var, eat(r'\('), args, eat(r'\)'),
        to(lambda fun, args: (fun, *args))),
    var,
    num,
)

pprint(parse('x', expr).stack[0]) # 'x'
pprint(parse('1', expr).stack[0]) # 1
pprint(parse('∂((x^2),x)', expr).stack[0]) # ('∂', ('^', 'x', 2), 'x')
pprint(parse('∂(((2*(x^y))+log(x)),x)', expr).stack[0])
# ('∂', ('+', ('*', 2, ('^', 'x', 'y')), ('log', 'x')), 'x')

Отлично, мы получили AST, описанное при помощи вложенных друг в друга кортежей!

Первый элемент кортежа — это оператор или имя функции, такую форму в дальнейшем будет удобно интерпретировать. Кстати, реализованную грамматику несложно доработать для поддержки пробелов и переносов строк. Читаемость грамматики можно улучшить, воспользовавшись комбинатором list_of и назначив имена mk* для функций-«строителей» AST:

from peco.peco import *

# Поддержка пробелов и переносов строк.
space = eat(r'\s*')
token = lambda f: seq(space, f)
tok = lambda c: token(push(eat(c)))
skip = lambda c: token(eat(c))

# Функции-«строители» AST.
mknum = to(lambda x: int(x))
mkbop = to(lambda lhs, op, rhs: (op, lhs, rhs))
mkfun = to(lambda fun, args: (fun, *args))

var = tok(r'[a-z∂]+')
num = seq(tok(r'[0-9]+'), mknum)
bop = tok(r'\+|\*|\^|-|/')

expr = lambda s: expr(s)
args = group(list_of(expr, skip(',')))
expr = alt(
    seq(skip(r'\('), expr, bop, expr, skip(r'\)'), mkbop),
    seq(var, skip(r'\('), args, skip(r'\)'), mkfun),
    var,
    num,
)

src = '''
∂(( (2 * ( var ^ power )) +
    log(var) ),
  var)
'''

ast, = parse(src, seq(expr, space)).stack
print(ast)
# ('∂', ('+', ('*', 2, ('^', 'var', 'power')), ('log', 'var')), 'var')

Грамматика заняла всего 20 строк кода!

Бэкенд транслятора

Бэкенд транслятора синтезирует целевой код из AST:

AST -> Code'

Мы реализуем несколько модулей бэкенда транслятора для синтеза кода на разных языках.

Синтез кода на DSL dot для Graphviz

Начнём с визуализации вложенных друг в друга кортежей — структуры данных, которую в предыдущем разделе мы назвали AST. Нужно ведь показать, что у нас действительно получилось синтаксическое дерево!

Сначала полезно преобразовать вложенные кортежи в графовое промежуточное представление (англ. Intermediate Representation, IR). Граф — это совокупность непустого множества вершин и множества связей между вершинами, но для упрощения работы мы сохраним вершины в словаре dict[int, str], где ключ — это номер вершины при обходе AST. Связи сохраним в словаре dict[int, list[int]]:

def walk(ast, names, graph):
    i = len(names)
    match ast:
        case op, *args:
            names[i] = op
            graph[i] = [walk(arg, names, graph) for arg in args]
        case atom:
            names[i] = atom
    return i

ast = ('∂', ('+', ('*', 2, ('^', 'x', 'y')), ('log', 'x')), 'x')
names, graph = {}, {}
walk(ast, names, graph)
print(names) # {0: '∂', 1: '+', 2: '*', 3: 2, 4: '^', 5: 'x', 6: 'y', 7: 'log', 8: 'x', 9: 'x'}
print(graph) # {4: [5, 6], 2: [3, 4], 7: [8], 1: [2, 7], 0: [1, 9]}

В операторе match структурного сопоставления с образцом (см. PEP 636) выражение case op, *args соответствует всем последовательностям, содержащим хотя бы один элемент — таким как ('+', 'x', 'y') или ('exp', 2). При этом в переменную op помещается первый элемент последовательности, а в переменную args — список из всех остальных элементов.

От графового IR легко перейти, например, к коду на DSL dot визуализатора graphviz:

def dot(names, graph):
    print('digraph {')
    print('node [shape=rect, fontname="Ubuntu Mono"]')
    for i, name in names.items():
        print(f'{i} [label="{name}"]')
    for source in graph:
        for target in graph[source]:
            print(f'{source} -> {target}')
    print('}')

dot(names, graph)

Визуализировать полученный код на языке dot легко в онлайн-версии graphviz:

Результат визуализации AST из исходного кода на DSL dot визуализатора графов graphviz
Результат визуализации AST из исходного кода на DSL dot визуализатора графов graphviz

Синтез команд LaTeX

Поскольку мы работаем с формулами и собираемся сделать систему символьного дифференцирования, было бы удобно смотреть на наши выражения не как на код или деревья, а как на настоящие формулы.

Формулы мы сможем получить, если научимся транслировать наше AST в набор команд системы компьютерной вёрстки LaTeX. Для этого создадим словарь OPS с шаблонами для поддерживаемых операторов и функций, а затем вновь воспользуемся структурным сопоставлением с образцом для рекурсивного обхода AST:

OPS = {
    '+': '{%s}+{%s}',
    '*': '{%s}{%s}',
    '^': '{%s}^{%s}',
    '-': '{%s}-{%s}',
    '/': '\\frac{%s}{%s}',
    '∂': '\\frac{\\partial{\\left(%s\\right)}}{\\partial{%s}}',
}

def tex(tree):
    match tree:
        case op, *args:
            return OPS[op] % tuple(map(tex, args))
        case atom:
            return atom

ast = ('∂', ('+', ('*', 2, ('^', 'x', 'y')), ('log', 'x')), 'x')
print(tex(ast), '.')
# \frac{\partial{\left({{2}{{x}^{y}}}+{\log{x}}\right)}}{\partial{x}}.

Визуализировать формулу LaTeX можно, например, в онлайн-редакторе LaTeX:

\frac{\partial{\left({{2}{{x}^{y}}}+{\log{x}}\right)}}{\partial{x}}.

Система переписывания AST

В настоящих компиляторах к AST, построенному фронтендом компилятора, применяется целый ряд оптимизирующих преобразований: выполняется свёртка констант (англ. constant folding), преобразование AST к IR в форме статического одиночного присваивания (англ. static single assignment form, SSA), которое затем переписывается для удаления недостижимого кода, экономии выражений, раскрутки циклов, ...

Нашу систему символьного дифференцирования мы также реализуем как переписывающую систему (англ. term rewriting system, TRS) на основе таблицы производных функций. Алгоритм здесь следующий: если мы в процессе обхода AST встречаем поддерево, соответствующее формуле из левой части таблицы, то мы это поддерево заменяем на поддерево, соответствующее формуле из правой части таблицы:

def topdown(rule, expr):
    match rule(expr):
        case op, *args:
            return (op, *(topdown(rule, arg) for arg in args))
        case expr:
            return expr

def derive(expr):
    match expr:
        case ('∂', ('+', u, v), var):
            return ('+', ('∂', u, var), ('∂', v, var))
        case ('∂', ('*', int(c), expr), var):
            return ('*', c, ('∂', expr, var))
        case ('∂', ('log', expr), var):
            return ('/', ('∂', expr, var), expr)
        case ('∂', ('^', expr, n), var):
            return ('*', n, ('*', ('^', expr, ('-', n, 1)), ('∂', expr, var)))
        case ('∂', str(var0), str(var)) if var0 == var:
            return 1
        case expr:
            return expr

# Попробуем переписать AST без спуска от корня к листьям:
ast = ('∂', ('+', ('*', 2, ('^', 'x', 'y')), ('log', 'x')), 'x')
print(tex(ast), '=', tex(derive(ast)), '.')

Для преобразования формулы в LaTeX мы воспользовались реализованной функцией tex:

\frac{\partial{\left({{2}{{x}^{y}}}+{\log{x}}\right)}}{\partial{x}} = {\frac{\partial{\left({2}{{x}^{y}}\right)}}{\partial{x}}}+{\frac{\partial{\left(\log{x}\right)}}{\partial{x}}}.

Теперь обойдём AST, переписывая всё, что можно, при помощи функции topdown:

ast = ('∂', ('+', ('*', 2, ('^', 'x', 'y')), ('log', 'x')), 'x')
print(tex(ast), '=', tex(derive(ast)), '=', tex(topdown(derive, ast)), '.')
\frac{\partial{\left({{2}{{x}^{y}}}+{\log{x}}\right)}}{\partial{x}} = {\frac{\partial{\left({2}{{x}^{y}}\right)}}{\partial{x}}}+{\frac{\partial{\left(\log{x}\right)}}{\partial{x}}} = {{2}{{y}{{{x}^{{y}-{1}}}{1}}}}+{\frac{1}{x}} .

Ура, посчитали! Но в конце первого слагаемого находится единица, которую обычно не записывают в ответе. Как нам избавиться от такой избыточности? Правильно, реализовать ещё один набор правил переписывания, для упрощения выражений!

def simplify(expr):
    match expr:
        case ('*', 1, expr) | ('*', expr, 1):
            return expr
        case ('-', int(lhs), int(rhs)):
            return lhs - rhs
        case ('^', expr, 1):
            return expr
        case expr:
            return expr

ast = ('∂', ('+', ('*', 2, ('^', 'x', 'y')), ('log', 'x')), 'x')
print(tex(ast), '=', tex(topdown(simplify, topdown(derive, ast))), '.')
\frac{\partial{\left({{2}{{x}^{y}}}+{\log{x}}\right)}}{\partial{x}} = {{2}{{y}{{x}^{{y}-{1}}}}}+{\frac{1}{x}} .

Отлично, убрали избыточность. А что, если после упрощения выражения при обходе AST сверху вниз появляется возможность упростить его ещё сильнее? Например, применение наших правил переписывания к следующему AST порождает выражение, всё ещё содержащее избыточное возведение в степень ('^', 'x', 1):

ast = ('∂', ('+', ('^', 'x', 2), ('^', 'x', 3)), 'x')
print(tex(ast), '=', tex(topdown(simplify, topdown(derive, ast))), '.')
\frac{\partial{\left({{x}^{2}}+{{x}^{3}}\right)}}{\partial{x}} = {{2}{{x}^{1}}}+{{3}{{x}^{2}}} .

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

Простое решение нашей проблемы — найти неподвижную точку (англ. fixed point), то есть такое AST, для которого справедливо утверждение: ast == topdown(simplify, ast). Реализуем функцию rewrite, которая найдёт неподвижную точку, и будем упрощать наши выражения всегда с её помощью:

def rewrite(rule, expr):
    while (new_expr := topdown(rule, expr)) != expr:
        expr = new_expr
    return new_expr

ast = ('∂', ('+', ('^', 'x', 2), ('^', 'x', 3)), 'x')
print(tex(ast), '=', tex(rewrite(simplify, rewrite(derive, ast))), '.')

Готово! Получаем желаемый результат:

\frac{\partial{\left({{x}^{2}}+{{x}^{3}}\right)}}{\partial{x}} = {{2}{x}}+{{3}{{x}^{2}}} .

Обзор основных результатов

Кажется, нам удалось реализовать транслятор простого минималистичного DSL, который умеет дифференцировать и упрощать выражения, транслировать выражения в LaTeX-код для визуализации формул, а также в код на языке dot для визуализации деревьев при помощи graphviz. Архитектура нашего транслятора теперь имеет следующий вид:

Архитектура разработанного транслятора с поддержкой символьного дифференцирования. Для визуализации диаграммы использовалась онлайн-версия инструмента Mermaid
Архитектура разработанного транслятора с поддержкой символьного дифференцирования. Для визуализации диаграммы использовалась онлайн-версия инструмента Mermaid

Нашим транслятором теперь можно пользоваться следующим образом:

src = '∂( ((( (3 * (x ^ y)) + log((x ^ y))) + (4*x)) + (x^2)), x)'
ast, = parse(src, seq(expr, space)).stack
print(tex(ast), '=', tex(rewrite(simplify, rewrite(derive, ast))), '.')
\frac{\partial{\left({{{{3}{{x}^{y}}}+{\log{{x}^{y}}}}+{{4}{x}}}+{{x}^{2}}\right)}}{\partial{x}} = {{{{3}{{y}{{x}^{{y}-{1}}}}}+{\frac{{y}{{x}^{{y}-{1}}}}{{x}^{y}}}}+{4}}+{{2}{x}} .

Задача 1. Добавьте новые правила переписывания в derive и simplify для поддержки символьного дифференцирования других выражений на нашем формальном языке из вводной части статьи, а также для упрощения 2-го слагаемого из правой части формулы выше.

Задача 2. Попробуйте реализовать трансляцию нашего графового IR в форматы визуализаторов диаграмм и графов Mermaid или PlantUML.

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

Дальнейшее изучение

Литература по компиляторам для новичков и продолжающих собрана в замечательном репозитории Что читать о разработке компиляторов? на GitHub. Кроме того, выделю материалы, которые заинтересуют и практикующих компиляторщиков, и им сочувствующих:

Отдельное спасибо Петру Советову @true-grue за ценные комментарии, позволившие (существенно!) упростить примеры кода в статье, а также за рецензирование материала в целом. Спасибо пользователям Habr за активное участие в обсуждении статьи, разъяснение материала по PEG в комментариях и за рекомендации, позволившие повысить качество рукописи.

Комментарии (32)


  1. dolfinus
    15.12.2024 08:11

    добавить peco в наш репозиторий как подмодуль git, выполнив из корня нашего репозитория команду

    Довольно странный способ установки. В Python обычно публикуют пакет на pypi.org, и тогда его можно установить любым пакетным менеджером.


    1. worldbeater Автор
      15.12.2024 08:11

      Возможно, это пока не окончательная версия библиотеки, и API ещё поменяется — поэтому автор не спешит с публикацией пакета в реестре PyPi.

      Есть и другие примеры компиляторов без зависимостей, которые предоставляются только в виде исходного кода — например, компилятор XamlX, используемый в кроссплатформенном GUI-фреймворке AvaloniaUI для трансляции разметки интерфейса в низкоуровневое представление и не опубликованный в реестре NuGet.

      Видимо, обновлять связанные репозитории иногда проще через git pull, чем через реестр пакетов. А переключиться на нужную версию библиотеки легко через git checkout :)


      1. dolfinus
        15.12.2024 08:11

        Есть и другие примеры компиляторов без зависимостей, которые предоставляются только в виде исходного кода

        В мире C# это возможно и норма, с учётом того, что пользователи в конечном итоге скачивают программу в виде скомпилированного бинарника, и способ распространения исходного кода им не так важен. Но в питоне упаковка в бинарный файл это скорее исключение из правил - зачастую все распространяется в виде пакетов, и указывать в метаданных пакета зависимость на git репозиторий довольно неудобное решение.


        1. mk2
          15.12.2024 08:11

          В мире C# это тоже не норма, там сразу были динамические скомпилированные библиотеки, а сейчас пакеты, которые публикуют на nuget.org

          А вот в мире C++ возможно всякое...


  1. MasterMentor
    15.12.2024 08:11

    Роскошная статья, побольше бы таких на Хабре! И подборка ссылок соответствующая.

    +/+


    1. worldbeater Автор
      15.12.2024 08:11

      Спасибо!


  1. funny_falcon
    15.12.2024 08:11

    Я конечно нуб в теме парсеров, но идею со стеком считаю великолепной!
    В первый раз вижу такое сочетание парсер-комбинаторов и стэка.


    1. worldbeater Автор
      15.12.2024 08:11

      Соглашусь! Есть ещё интересный момент — состояние peco можно переопределить, добавив новые поля, не сломав при этом уже реализованные peco-грамматики. Это позволит выполнять семантические действия при разборе и реализовать таким образом, например, lexer hack.


      1. funny_falcon
        15.12.2024 08:11

        Хмм… а я issue открыл на эту тему…


  1. WASD1
    15.12.2024 08:11

    Несколько неточностей в предисловии лучше поправить:

    1. Переводом кода с одного ЯП на другой занимается транслятор (иногда применяют термин "транспилятор"). Компилятор - частный случай транслятора: компилятор переводит код с высокоуровневого языка (обычно ЯП) в машинный \ объектный код.
    2. "передним/задним планом" фронтэнд и бэкэнд азывать не принято.
    3. Сегодня (начиная с llvm) компилятор делят на frontend / middleend / backend
    4. Лексер + парсер - малая часть фронтэнда. Основная часть фронтэнда - построение AST и специфические (обычно глобальные) оптимизации над AST.

    Также добавлю вопрос от себя:
    Лексер (в лексере-парсере) нужен для того, чтобы ваш парсинг был устойчив к разделителям и форматированию пользователя.
    5. С peco (и вообще парсерами в python) не работал - а что у вас обеспечивает эту фунциональность?


    1. Mingun
      15.12.2024 08:11

      Не автор, но могу ответить на пятый вопрос — разделители (пробельные символы любого вида) обрабатываются вот этими правилами грамматики:

      space = eat(r'\s*')
      token = lambda f: seq(space, f)
      

      Правило space просто пропускает все пробельные символы. Функция token строит правило, пропускающее пробельные символы перед основным правилом f. Все остальные правила так или иначе определяются через функцию token. Например, функция

      tok = lambda c: token(push(eat(c)))
      

      формирует токен, кладя строку составляющих его символов c (через eat(c)) на стек (через push(...)) и пропуская пробельные символы перед ней (через token(...)). А правило

      skip = lambda c: token(eat(c))
      

      просто пропускает символы c (через eat(c)) + необязательные пробельные символы перед ними (через token(...)).

      Поскольку все остальные правила строятся через эти примитивы, то парсер успешно обрабатывает наличие пробельных символов в любом месте разбираемого выражения.


      1. WASD1
        15.12.2024 08:11

        Ок, спасибо (грепнул skip).
        Ручной скип delimiters без лексера.

        expr = alt(
        seq(skip(r'\('), expr, bop, expr, skip(r'\)'), mkbop),
        seq(var, skip(r'\('), args, skip(r'\)'), mkfun),
        var,
        num,)

        Углы срезаны, as-is, смысл имеет (сложность-то обычно не в лексере, а в заворачивании лексера под капот парсера бесшовно лениво и с нормальными сообщениями об ошибках). В минимально поддежриваемом проекте лучше бы в этих местах не срезать.

        В целом "пара месяцев поддержки и зумеры переизобретут лексер" - если тут есть сарказм (а он есть), то абсолютно беззлобный.
        @worldbeater


        1. Mingun
          15.12.2024 08:11

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


        1. Mingun
          15.12.2024 08:11

          И кстати, «ручной скип delimiters» происходит ровно в двух вышеуказанных мной правилах, если конечно, под «разделителями» вы не имели в виду служебные скобки, являющиеся частью синтаксиса. Не предлагаете же вы засовывать их пропуск в лексер?


          1. WASD1
            15.12.2024 08:11

            Чтобы сэкономить 20-30 строк на PEG-парсере превратим входное выражение в нечитаемую мешанину из скобок.

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

            П.С.
            > Вообще-то именно так и принято писать PEG-парсеры, не знаю, что вас здесь удивило.
            А где и зачем так принято, - что ради довольно небольшого кол-ва строк и исходная грамматика менее удобна и код regex начитает содержать?


            1. Mingun
              15.12.2024 08:11

              Преимущество PEG-грамматик в том, что они принципиально не сваливаются в бесконечный бэктрекинг, как может происходить с LL-парсерами, поскольку выбор альтернативы alt в PEG-парсерах упорядоченный, то есть, если мы хотя бы частично успешно прошли по первой ветке, то уже никогда не пройдём по второй. Это значительно упрощает код парсера и повышает скорость его работы (а также понижает требования к памяти). Но для этого надо аккуратно прописать порядок этих альтернатив, что, конечно, сделать сложнее, чем в LL-парсерах.

              нечитаемую мешанину из скобок

              Делаете так:

              L_PAREN = skip(r'\(')
              R_PAREN = skip(r'\)')
              
              binary_op = seq(L_PAREN, expr, bop, expr, R_PAREN, mkbop)
              call_function = seq(var, L_PAREN, args, R_PAREN, mkfun)
              expr = alt(binary_op, call_function, var, num)
              

              и никакой мешанины из скобок.

              Ни грамматика ни код не видятся мне хорошими

              Какой же код тогда на ваш взгляд хороший? Вот приведённый в статье, например, один в один повторяет структуру грамматики из EBNF, чем же это плохо?


              1. WASD1
                15.12.2024 08:11

                1. В LL-парсерах бэктрэкинг не бесконечный, а экспоненциальной.

                  1. Но на практике вы в эту экспоненту вообще вряд ли когда-нибудь упираетесь при парсинге чего-то похожего на ЯП (corner case - разумеется возможны) - т.е. бэктрегинг в каких-то разумных случаях в профиле вообще не виден.

                2. С заменой как у вас L_PAREN = skip(r'\(')- куда лучше;
                  На мой взгляд там нарушение PEP 8: Function and Variable Names (но я не спец по Python - возможно для 0-арных ф-ий там хитрое исключение) .

                3. Наличие regexp внутри бизнес-логики - делают код куда менее поддерживаемым. Странно, что это следует объяснять.

                4. С лексером:

                  1. куда более "compiler way" - когда сложная системаа строится из набора максимально простых фаз, каждая из которых делает свою законченную часть работы;

                  2. лексер имеет законченную функциональность;

                  3. Не сложно - здесь достаточно линейного прохода лексера, потом прохода парсера;

                  4. скорее всего быстрее;

                  Что можно перефразирвовать, как "с лексером лучше".


                1. Mingun
                  15.12.2024 08:11

                  В LL-парсерах бэктрэкинг не бесконечный, а экспоненциальной

                  Совершенно верно. Однако в практических случаях, когда он вас всё-таки настигает, он именно что «бесконечный». Поэтому я позволил себе такую художественную вольность описания явления.

                  Наличие regexp внутри бизнес-логики - делают код куда менее поддерживаемым.

                  Эм. То есть, вас испугал «ужасно сложный» регексп \( (открывающая круглая скобка) или \s* (возможно, пустой список пробельных символов), я правильно понял? Что вы предлагаете ни под каким видом не пускать регекспы в бизнел-логику. Мне кажется, здесь каким-то культом «безрегексповедения» попахивает.

                  На мой взгляд там нарушение PEP 8: Function and Variable Names

                  Имена выбраны чисто на мой вкус и привычку называть всякие «константы» КАПСОМ_С_ПОДЧЁРКИВАНИЯМИ. Вы вольны выбрать другие имена.

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

                  Поздравляю, это суть всех парсер-комбинаторов, коим peco, несомненно и является. Его кирпичики (и наши кирпичики тоже!) — именно что максимально простые фазы, имеющие законченную функциональность. «Пропустить символы». «Положить на стек». «Разобрать вызов функции».

                  скорее всего быстрее

                  Непонятно, почему оно должно быть быстрее, если с лексером получается всё то же самое, только ещё и с лексером.


                  1. WASD1
                    15.12.2024 08:11

                    Update:

                    какие проблемы решает текущая грамматика - я понимаю:

                    Это например упрощение обработки, включая избегание ошибок: a - b + x / y * z (а не аргументы про скорость (*))
                    Ценой заставление пользователя делать мешанину из скобок.
                    Моё утверждение, что плюсы от такого парсера вовсе не перевешивают минусов.

                    *) Ваши аргументы про скорость - вероятно неактуальны. За исключением corner-case (шаблоны \ хиндли-милнер \ ...) фронтэнд, а тем более парсинг, не является сколь бы то ни было прожорливой частью.

                    ======= спор по маловажной perf-части ========

                    Совершенно верно. Однако в практических случаях, когда он вас всё-таки настигает, он именно что «бесконечный». Поэтому я позволил себе такую художественную вольность описания явления.

                    А где посмотреть примеры с экспоненциальным парсингом, скажем C (кратно более сложная вещь, чем парсинг подинтегрального выражения) зависал? Чтобы оценить насколько актуальна проблема для кардинально более сложного случая?

                    Непонятно, почему оно должно быть быстрее, если с лексером получается всё то же самое, только ещё и с лексером.

                    Тут был не прав, причём неправ в том месте, где "можно вынести лексер из-под капота парсера". Токенизировать всё-таки надо по ходу парсинга выплёвывая в парсинг только правильные токены.

                    > максимально простых фаз, каждая из которых делает свою законченную часть работы;

                    Поздравляю, это суть всех парсер-комбинаторов ... Его кирпичики (и наши кирпичики тоже!) — именно что максимально простые фазы

                    Как у вас сочетается "совместить парсер с лексером" и "делать максимально простые фазы".
                    Ну это не говоря уже о том, что компилятор делит на фазы компиляции, а парсер-комбинатор - делает работу внутри фазы компиляции.


    1. worldbeater Автор
      15.12.2024 08:11

      Спасибо за развёрнутый комментарий!

      Переводом кода с одного ЯП на другой занимается транслятор (иногда применяют термин "транспилятор"). Компилятор - частный случай транслятора: компилятор переводит код с высокоуровневого языка (обычно ЯП) в машинный \ объектный код.

      Рискну предположить, что Вы, вероятно, используете определение компилятора из S.S. Muchnick, Advanced Compiler Design and Implementation, 1997, где компилятором называют «средство перевода программ, написанных на высокоуровневом языке, в эквивалентные программы в объектном коде или машинном коде, предназначенном для выполнения компьютером»? Однако, в начале статьи я намеренно акцентирую внимание читателя на том, что в статье мы используем более широкое определение компилятора из книги K.D. Cooper & L. Torczon, Engineering a Compiler, 2011:

      Кроме того, в Advanced Compiler Design and Implementation S.S. Muchnick пишет следующее:

      "Передним/задним планом" фронтэнд и бэкэнд называть не принято.

      Так называют фронтенд и бэкенд компиляторов в отечественной научной литературе, например, в научных работах [1—3]. Убрал из статьи упоминание этих терминов, чтобы академическая терминология не вызывала вопросов у читателя!

      1. Аветисян А.И., Курмангалеев К.Ю., Курмангалеев Ш.Ф. Динамическое профилирование программы для системы LLVM // Труды Института системного программирования РАН. – 2011. – Т. 21. – С. 71-82.

      2. Советов П.Н. Разработка компиляторов предметно-ориентированных языков для спецпроцессоров // Труды Института системного программирования РАН. – 2020. – Т. 32. – № 5. – С. 35-56.

      3. Стасенко А.П. Модели и реализация транслирующих компонентов системы функционального программирования: дис. // Институт систем информатики им. АП Ершова СО РАН, 2009.

      Сегодня (начиная с llvm) компилятор делят на frontend / middleend / backend. Лексер + парсер - малая часть фронтэнда. Основная часть фронтэнда - построение AST и специфические (обычно глобальные) оптимизации над AST.

      Постарался прояснить п.3 и п.4 в предисловии — надеюсь, в нынешней редакции статья улучшилась. Отдельное спасибо @Mingun за разъяснения по п.5 в соседнем комментарии!


      1. WASD1
        15.12.2024 08:11

        Учился я как раз по Ахо-Ульман, где компилятором называется программа переводящая с одного языка на другой.

        Но в реальной жизни семантика translator && compiler разделилась - оставив компилятору узкое значение, конкретного вида трансляторов.

        > Rosetta is a dynamic binary translator developed by Apple Inc. for macOS, an application compatibility
        > Online Rust to Csharp Converter

        Проверил для DSL - их это тоже касается (см число найденных ответов)


        1. worldbeater Автор
          15.12.2024 08:11

          На 2-й картинке выше опечатка, без неё разница в количестве результатов несущественна:

          Но это говорит только о распространенности обсуждаемых определений, правильно?

          Конечно, студентам я рассказываю об исторически сложившихся определениях и разнице между ними (транслятор, транспайлер/транспилятор, компилятор, интерпретатор, ...), но в короткой обучающей статье, на мой взгляд, лучше сослаться на определение из классического учебника. Да и потом, после sed s/компилятор/транслятор статья выглядит уже не так интригующе. А цель — заинтересовать читателя, показать, что сделать свой DSL проще, чем кажется на первый взгляд!


          1. WASD1
            15.12.2024 08:11

            странно у меня всё ещё 500к
            странно у меня всё ещё 500к


          1. WASD1
            15.12.2024 08:11

            Но это говорит только о распространенности обсуждаемых определений, правильно?

            Ничего себе "только" - значения слов это узус, то как они употребляются.
            Тем более вы преподаватель (а не студент, как изначально думал).
            Сделать хорошую работу, и снабдить её разъяснительным текстом лет на 20 устаревшим? А потом студенты тут на хабре пишут - нас учили всякой устаревшей фигне.

            Что вообще может человека мотивировать поступать так? Нежелание признавать свои ошибки "якобы под давлением"? Ну ок мне не сложно:
            - вы сделали хорошую полезную для студентов работу;
            - сопровождение хорошо написано и отлично выглядит;
            - если поменяете терминологию на более современную - это сделает и без того хорошую работу ещё лучше;


            1. worldbeater Автор
              15.12.2024 08:11

              Сделать хорошую работу, и снабдить её разъяснительным текстом лет на 20 устаревшим?

              А что именно устарело, не расскажете? Выше в вопросах терминологии компилятор/транслятор пока видно только «так принято». Кем принято, где и когда принято? Выше цитаты из учебников были попыткой намекнуть, что нужен более весомый источник, чем число результатов в поисковой выдаче Google или скопированный текст из README.md случайного проекта, причем источник желательно академического толка, если уж мы полемизируем с S.S. Muchnik и др.

              А потом студенты тут на хабре пишут - нас учили всякой устаревшей фигне.

              А можно поконкретнее? Определитесь, пожалуйста, «устарело» или «зумеры изобрели», а то, если честно, уже не улавливаю. Спасибо.


              1. WASD1
                15.12.2024 08:11

                А что именно устарело, не расскажете?

                употребление терминов так, как у вас в предисловии.

                Вы действительно сомниваетесь в том, что сегодня compiler (по поводу DSL-компилятор к стати у меня изначально вопросов не было - такое употребление встречал, хотя с DSL особо не работаю - чем объяснить разную выдачу гугла пока не понимаю) в большинстве случаев употребляется как частный стучай транслятора, когда целевой язык - биранрый \ объектный \ ассемблер (или другой язык платформы)?


                1. worldbeater Автор
                  15.12.2024 08:11

                  Вы действительно сомневаетесь в том, что сегодня compiler в большинстве случаев употребляется как частный случай транслятора, когда целевой язык - бинарный \ объектный \ ассемблер (или другой язык платформы)?

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

                  Мне встречался и тот, и другой подход к определению «компилятора» и «транслятора». Наверное, надо аккуратно измерить частоту встречаемости этих терминов на ресурсах сообществ по компиляторам, чтобы можно было сделать однозначный вывод, какой вариант более массовый? Персонализированная выдача Google для сбора такой статистики вряд ли подходит. Именно поэтому во избежание путаницы в самом начале статьи явно указано, каким конкретно определением компилятора, и из какой книги, мы будем пользоваться в пределах статьи, чтобы не вводить читателя в заблуждение. Мы можем заменить термин «компилятор» в предисловии и далее в статье, если на это есть веские основания, но надо понять, на какой именно термин и на какой именно приличный SSOT можно было бы сослаться.

                  Кстати, вот доводы в пользу того, что не всё так однозначно:

                  1. На главной странице проекта Babel указано, что «Babel is a JavaScript compiler», хотя данный инструмент транслирует JavaScript-код, в котором используются новые и экспериментальные синтаксические конструкции, в JavaScript-код, совместимый с не самыми новыми браузерами, такими как Internet Explorer 11. Такие инструменты иногда называют транспиляторами/транскомпиляторами, но авторы называют свой инструмент именно компилятором. Впрочем, этот довод на уровне доказательства ссылкой на «README.md случайного проекта на GitHub» можно пропустить, хоть проект и широко используемый.

                  2. В репозитории проекта Cython указано, что Cython — это «The most widely used Python to C compiler», при этом указано также, что «Cython translates Python code to C/C++ code». Впрочем, C иногда называют высокоуровневым ассемблером, поэтому данный пункт также можно пропустить. Хотя есть и managed-реализации C, например, Cesium.

                  3. В англо-русском словаре терминов по вопросам компиляции русскоязычного сообщества разработчиков компиляторов указано: «Compiler — компилятор, транслятор». То есть, здесь эти 2 термина считают взаимозаменяемыми, насколько я могу судить. Кстати, у сообщества есть чат в Telegram, можно обсудить терминологию ещё и там, помимо этой ветки комментариев, чтобы выяснить, почему написали именно так.

                  Обратите внимание, правки в соответствии с Вашими замечаниями в части п.2, п.3 и п.4 были ведь сразу внесены, а Вы на ad hominem переходите несколькими комментариями выше в связи с вопросами к п.1. Надеюсь, что цель обсуждения, всё же, в том, чтобы выяснить истину / сделать правильнее, а не в том, чтобы отстоять точку зрения :)


                  1. worldbeater Автор
                    15.12.2024 08:11

                    @WASD1 отвечу на свой же комментарий, если позволите.

                    Почему-то нам с Вами в этой ветке не напомнили, что как только разгорается спор о терминах, применение которых разнится от источника к источнику, полезно обратиться к стандартам — воспользоваться терминами из стандарта, если он есть, или опубликовать новый стандарт, если о терминах ещё не договорились.

                    Итак, внимание!

                    ГОСТ 19781-90 Обеспечение систем обработки информации программное <...> Трансляция — преобразование программы, представленной на одном языке программирования, в программу на другом языке и в определенном смысле равносильную первой. Транслятор — программа или техническое средство, выполняющие трансляцию программы. Компиляция — трансляция программы с языка высокого уровня в форму, близкую к программе на машинном языке. Компилятор — программа или техническое средство, выполняющие компиляцию. <...> Переиздание. Январь 2010 г.

                    Спасибо @WASD1 за плодотворную дискуссию, обновил терминологию в соответствии с ГОСТ 19781-90, добавил Вас в благодарности! Пожалуйста, пишите, если ещё найдёте неточности. Улучшим.