Введение
Итак, вопрос, о чём же конкретно эта статья? Здесь я постараюсь помочь читателю с наименьшими потерями выходить из ситуации, когда надо разобрать какой-то текстовый формат, а библиотекой воспользоваться не получится. То есть решать абсолютно конкретную задачу общими средствами.
Сразу оговорюсь, тема сама по себе мало того, что ОЧЕНЬ непростая, так еще и до невозможности обширная и все охватить в рамках одной статьи просто не получится. Поэтому я начну от общего и буду переходить к частному, конкретно в этой статье давая скорее обзор инструментария (который, однако, может использоваться для решения вполне конкретных задач парсинга), чем погружаться вглубь концепций. Возможно, если читатели будут заинтересованы, статья превратится в цикл, в котором я могу раскрыть какие-то конкретные вопросы более подробно.
Написать её я решил потому, что имеющаяся по теме информация разрознена и зачастую не полна, на русском источников вообще очень мало, а те что есть, подразумевают достаточно приличное знакомство читателя с весьма специфической математикой. Поэтому, чтобы далекий от темы читатель не испытывал боль от сознания своей (мнимой) неполноценности, я и решил попытаться описать эту тему максимально просто.
Крепитесь, статья большая, некоторые места будут не очень интересными, но без них может быть непонятно.
Основные понятия
Перед разговором по теме стоит определиться с основными понятиями, чтобы не было разночтений. Это глоссарий данной статьи. Он может совпадать с общепринятой терминологией, но вообще говоря, не обязан, поскольку показывает картину, формирующуюся в голове автора.
Итак:
- входной символьный поток (далее входной поток или поток) — поток символов для разбора, подаваемый на вход парсера
- parser/парсер (разборщик, анализатор) — программа, принимающая входной поток и преобразующая его в AST и/или позволяющая привязать исполняемые функции к элементам грамматики
- AST(Abstract Syntax Tree)/АСД(Абстрактное синтаксическое дерево) (выходная структура данных) — Структура объектов, представляющая иерархию нетерминальных сущностей грамматики разбираемого потока и составляющих их терминалов. Например, алгебраический поток (1 + 2) + 3 можно представить в виде ВЫРАЖЕНИЕ(ВЫРАЖЕНИЕ(ЧИСЛО(1) ОПЕРАТОР(+) ЧИСЛО(2)) ОПЕРАТОР(+) ЧИСЛО(3)). Как правило, потом это дерево как-то обрабатывается клиентом парсера для получения результатов (например, подсчета ответа данного выражения)
- CFG(Context-free grammar)/КСГ(Контекстно-свободная грамматика) — вид наиболее распространенной грамматики, используемый для описания входящего потока символов для парсера (не только для этого, разумеется). Характеризуется тем, что использование её правил не зависит от контекста (что не исключает того, что она в некотором роде задает себе контекст сама, например правило для вызова функции не будет иметь значения, если находится внутри фрагмента потока, описываемого правилом комментария). Состоит из правил продукции, заданных для терминальных и не терминальных символов.
- Терминальные символы (терминалы) — для заданного языка разбора — набор всех (атомарных) символов, которые могут встречаться во входящем потоке
- Не терминальные символы (не терминалы) — для заданного языка разбора — набор всех символов, не встречающихся во входном потоке, но участвующих в правилах грамматики.
- язык разбора (в большинстве случаев будет КСЯ(контекстно-свободный язык)) — совокупность всех терминальных и не терминальных символов, а также КСГ для данного входного потока. Для примера, в естественных языках терминальными символами будут все буквы, цифры и знаки препинания, используемые языком, не терминалами будут слова и предложения (и другие конструкции, вроде подлежащего, сказуемого, глаголов, наречий и т.п.), а грамматикой собственно грамматика языка.
- BNF(Backus-Naur Form, Backus normal form)/БНФ(Бэкуса-Наура форма) — форма, в которой одни синтаксические категории последовательно определяются через другие. Форма представления КСГ, часто используемая непосредственно для задания входа парсеру. Характеризуется тем, что определяемым является всегда ОДИН нетерминальный символ. Классической является форма записи вида:
<определяемый символ> ::= <посл.1> | <посл.2> | . . . | <посл.n>
Так же существует ряд разновидностей, таких как ABNF(AugmentedBNF), EBNF(ExtendedBNF) и др. В общем, эти формы несколько расширяют синтаксис обычной записи BNF. - LL(k), LR(k), SLR,... — виды алгоритмов парсеров. В этой статье мы не будем подробно на них останавливаться, если кого-то заинтересовало, внизу я дам несколько ссылок на материал, из которого можно о них узнать. Однако остановимся подробнее на другом аспекте, на грамматиках парсеров. Грамматика LL/LR групп парсеров является BNF, это верно. Но верно также, что не всякая грамматика BNF является также LL(k) или LR(k). Да и вообще, что значит буква k в записи LL/LR(k)? Она означает, что для разбора грамматики требуется заглянуть вперед максимум на k терминальных символов по потоку. То есть для разбора (0) грамматики требуется знать только текущий символ. Для (1) — требуется знать текущий и 1 следующий символ. Для (2) — текущий и 2 следующих и т.д. Немного подробнее о выборе/составлении BNF для конкретного парсера поговорим ниже.
- PEG(Parsing expression grammar)/РВ-грамматика — грамматика, созданная для распознавания строк в языке. Примером такой грамматики для алгебраических действий +, -, *, / для неотрицательных чисел является:
Value < [0-9]+ / '(' Expr ')'
Product < Value (('*' / '/') Value)*
Sum < Product (('+' / '-') Product)*
Expr < Sum
И наконец мы закончили с основными понятиями. Перейдем к выбору способа разбора.
Варианты разбора
Когда мы сталкиваемся с задачей создания парсера, решение сводится, как правило, к 4 основным вариантам:
- Решать задачу в лоб, то есть анализировать посимвольно входящий поток и используя правила грамматики, строить АСД или сразу выполнять нужные нам операции над нужными нам компонентами. Из плюсов — этот вариант наиболее прост, если говорить об алгоритмике и наличии математической базы. Минусы — вероятность случайной ошибки близка к максимальной, поскольку у вас нет никаких формальных критериев того, все ли правила грамматики вы учли при построении парсера. Очень трудоёмкий. В общем случае, не слишком легко модифицируемый и не очень гибкий, особенно, если вы не имплементировали построение АСД. Даже при длительной работе парсера вы не можете быть уверены, что он работает абсолютно корректно. Из плюс-минусов. В этом варианте все зависит от прямоты ваших рук. Рассказывать об этом варианте подробно мы не будем.
- Используем регулярные выражения! Я не буду сейчас шутить на тему количества проблем и регулярных выражений, но в целом, способ хотя и доступный, но не слишком хороший. В случае сложной грамматики работа с регулярками превратится в ад кромешный, особенно если вы попытаетесь оптимизировать правила для увеличения скорости работы. В общем, если вы выбрали этот способ, мне остается только пожелать вам удачи. Регулярные выражения не для парсинга! И пусть меня не уверяют в обратном. Они предназначены для поиска и замены. Попытка использовать их для других вещей неизбежно оборачивается потерями. С ними мы либо существенно замедляем разбор, проходя по строке много раз, либо теряем мозговые клеточки, пытаясь измыслить способ удалить гланды через задний проход. Возможно, ситуацию чуть улучшит попытка скрестить этот способ с предыдущим. Возможно, нет. В общем, плюсы почти аналогичны прошлому варианту. Только еще нужно знание регулярных выражений, причем желательно не только знать как ими пользоваться, но и иметь представление, насколько быстро работает вариант, который вы используете. Из минусов тоже примерно то же, что и в предыдущем варианте, разве что менее трудоёмко.
- Воспользуемся кучей инструментов для парсинга BNF! Вот этот вариант уже более интересный. Во-первых, нам предлагается вариант типа lex-yacc или flex-bison, во вторых во многих языках можно найти нативные библиотеки для парсинга BNF. Ключевыми словами для поиска можно взять LL, LR, BNF. Смысл в том, что все они в какой-то форме принимают на вход вариацию BNF, а LL, LR, SLR и прочее — это конкретные алгоритмы, по которым работает парсер. Чаще всего конечному пользователю не особенно интересно, какой именно алгоритм использован, хотя они имеют определенные ограничения разбора грамматики (остановимся подробнее ниже) и могут иметь разное время работы (хотя большинство заявляют O(L), где L — длина потока символов). Из плюсов — стабильный инструментарий, внятная форма записи (БНФ), адекватные оценки времени работы и наличие записи БНФ для большинства современных языков (при желании можно найти для sql, python, json, cfg, yaml, html, csv и многих других). Из минусов — не всегда очевидный и удобный интерфейс инструментов, возможно, придется что-то написать на незнакомом вам ЯП, особенности понимания грамматики разными инструментами.
- Воспользуемся инструментами для парсинга PEG! Это тоже интересный вариант, плюс, здесь несколько побогаче с библиотеками, хотя они, как правило, уже несколько другой эпохи (PEG предложен Брайаном Фордом в 2004, в то время как корни BNF тянутся в 1980-е), то есть заметно моложе и хуже выглажены и проживают в основном на github. Из плюсов — быстро, просто, часто — нативно. Из минусов — сильно зависите от реализации. Пессимистичная оценка для PEG по спецификации вроде бы O(exp(L)) (другое дело, для создания такой грамматики придется сильно постараться). Сильно зависите от наличия/отсутствия библиотеки. Почему-то многие создатели библиотек PEG считают достаточными операции токенизации и поиска/замены, и никакого вам AST и даже привязки функций к элементам грамматики. Но в целом, тема перспективная.
Решаем задачу парсинга
Пойдем по-порядку, варианты brute-force и regexp пропускаем.
BNF
Вот и настало время чуть подробнее остановиться на алгоритмах разборщика, вернее на используемых ими грамматиках. Итак, наиболее часто встречаются GLR (до O(L^3) или до O(L^4), как говорят некоторые источники (antlr)), LR, LALR и LL — все в пределах O(L). Наибольшей “прожорливостью” обладает GLR — она способна лучше обрабатывать неоднозначности грамматики, но за счет этого и более медленна. То есть если рассматривать “размер” класса обрабатываемых парсером грамматик(назовем его мощностью парсера), то при прочих равных, мощности распределятся так GLR > LR > LALR > SLR > LL. Потребление ресурсов, соответственно, близко к обратному. Но вернемся к грамматикам.
Составление или поиск грамматики для LR-парсера в целом достаточно просто и высок шанс, что составленный вами “на коленке” BNF будет спокойно воспринят парсером и обработан. Главное, грамматика должна быть полной, то есть описывать все возможные ситуации входного потока, кроме того попробуйте сами понять навскидку, можно ли зная k следующих символов (в зависимости от выбранного LR-парсера) однозначно определить, какое именно правило должно применяться.
Для LR-парсера могут возникать конфликты следующего вида:
- Перенос-свертка (shift-reduce):
NT ::= x NT | x
. Где длина x > k. Решается так:NT ::= xK; K ::= K | e
- Свертка-свертка (reduce-reduce):
NT :: = e
, где длина u > k
A ::= NT
B ::= NT
D ::= B u v | A u w
Решается так:
R ::= Au
J ::= Bu
D ::= Rw | Jv
Для LL-парсера характерны конфликты вида (необходимо, но не достаточно, их переформулировать, по запросу могу остановиться на LL(k) грамматиках подробнее в следующей статье):
Левая рекурсия:
NT ::= NT x | y
, где x, y — произвольные строки терминалов/не терминалов, но y не начинается с NTПример:
E ::= E + T | T
. Можно переформулировать, как:E ::= T Z
Z ::= ‘+’ TZ | x
Левая факторизация:
NT ::= ax | ay
, где a — строка длины > k нашего парсера. Решается еще проще: NT ::= aC; C = x|y
Итак, что мы будем решать?
Ну, допустим, это будет простой калькулятор:
OPERATOR ::= "+ "| "-" | "*" | "/"
STMT ::= "(" STMT ")" | STMT OPERATOR STMT | FLOAT | INT
FLOAT ::= INT "." INT
INT ::= (POSITIVE_DIGIT INT | DIGIT ) | DIGIT
POSITIVE_DIGIT ::= "1"|"2"|"3"|"4"|"5"|"6"|"7"|"8"|"9"
DIGIT ::= POSITIVE_DIGIT | "0"
Если читатель попробует найти грамматику калькулятора в интернете, он увидит что часто операции сложения/вычитания и умножения/деления обрабатываются разными грамматическими конструкциями. Это сделано специально, чтобы учесть в грамматике такой момент, как приоритет операций (а также раскрыть неоднозначности грамматики). Мы это сделаем дальше по ходу статьи.
Пробуем найти нативное Python-решение, находим их много.
- Используем parglare. Это библиотека/cli-tool на Python, реализующий LR/GLR парсер c достаточно широким спектром возможностей (инлайн-функции, приоритизация, деревья, внятный анализ грамматики, визуализатор КА, получающегося при обработке грамматики).
pip install parglare
Переформулируем наш калькулятор так, как просит parglare
OPERATOR : "+ "| "-" | "*" | "/" | = STMT : "(" STMT ")" | STMT OPERATOR STMT | FLOAT | INT FLOAT : INT "." INT INT : (POSITIVE_DIGIT INT | DIGIT ) | DIGIT POSITIVE_DIGIT : "1"|"2"|"3"|"4"|"5"|"6"|"7"|"8"|"9" DIGIT : POSITIVE_DIGIT | "0"
Достаточно? Сохраним в calc.pg и воспользуемся cli-tool
pglr --debug check calc.pg Error in file "/home/survivor/tests/calc.pg" at position 1,42 => "" | "/" | *=\nSTMT ". Expected: Name or StrTerm or RegExTerm
Упс! кажется, что-то лишнее. Бинго! я зачем-то вставил | = после “/” (нет, я-то знаю, откуда он там (но к теме статьи это не относится) ). Главное в том, что программа нам четко на это указала. Далее после исправления программа поругается еще на отсутствие; в конце обозначения нетерминала и на скобочку в правиле INT. Переформулированный вариант будет выглядеть так:
STMT : "(" STMT ")" | STMT OPERATOR STMT | FLOAT | INT; OPERATOR : "+ "| "-" | "*" | "/"; FLOAT : INT "." INT; INT : POSITIVE_DIGIT INT | POSITIVE_DIGIT DIGIT | DIGIT; POSITIVE_DIGIT : "1"|"2"|"3"|"4"|"5"|"6"|"7"|"8"|"9"; DIGIT : POSITIVE_DIGIT | "0";
В итоге, pglr все нравится и он нам скажет:
Grammar ok!
НО:
There are 4 Shift/Reduce conflicts. Either use 'prefer_shifts' parser mode, try to resolve manually or use GLR parsing. There are 7 Reduce/Reduce conflicts. Try to resolve manually or use GLR parsing.
Ну, а выше по выводу debug можно прочитать их красивое (и понятное) описание. Ну что ж, давайте подумаем. Во первых, давайте не будем самыми умными и выкинем нафиг positive_digit. Если кто-то напишет 0034 — во первых, он сам себе злобный буратино, а во вторых, большинство ЯП, в том числе Python при конвертации этого в число не скажут нам ничего и просто выдадут 34. Прекрасно, это сильно поможет. Во-вторых, нафиг отсюда разделение на int/float, для простоты предположим что все числа с плавающей точкой. Также, pglr понимает регулярные выражения в BNF, воспользуемся этим. Получим:
STMT : "(" STMT ")" | STMT OPERATOR STMT | FLOAT; OPERATOR : "+ "| "-" | "*" | "/"; FLOAT : /\d+(\.\d+)?/;
и по-прежнему
There are 4 Shift/Reduce conflicts. Either use 'prefer_shifts' parser mode, try to resolve manually or use GLR parsing.
Ну, как бы то ни было, грамматика
*** GRAMMAR *** Terminals: + EOF ) ( FLOAT STOP * / - \d+(\.\d+)? EMPTY NonTerminals: OPERATOR S' STMT Productions: 0: S' = STMT STOP 1: STMT = STMT OPERATOR STMT 2: STMT = ( STMT ) 3: STMT = FLOAT 4: OPERATOR = + 5: OPERATOR = - 6: OPERATOR = * 7: OPERATOR = /
Попробуем реально распарсить что-нибудь.
from parglare import Grammar from parglare import Parser grammar = Grammar.from_file(‘calc.pg’) parser = Parser(grammar, build_tree=True, prefer_shifts=True) result = parser.parse('1 + 2 / (3 - 1 + 5)')
Получаем:
result <NonTerm(start=0, end=19, sym=STMT)>
можем получить result.children и далее по дереву. Можем заодно подсчитать итоговое выражение, но делать это здесь не будем. Важно, что мы получили доступ к дереву объектов, для терминальных символов так же их “значение” и можем делать с этим все, что пожелаем.
Если мы хотим поправить конфликтные ситуации, как еще можно разрешить конфликты грамматики?
Есть несколько вариантов:
- Решить задачу переформулировав грамматику
Например, так:
STMT : TERM | STMT ADDOP TERM ; TERM : FACTOR | FACTOR MULOP FACTOR ; FACTOR : "(" STMT ")" | NUMBER; ADDOP : "+" | "-"; MULOP : "*"|"/"; NUMBER: /\d+(\.\d+)?/;
Как видим, задача усложнилась, но не слишком. Тем более, если мы будем делать разбор именно такого дерева, нам будет проще. - Использовать средства самого parglare
В этом случае решение более частное, но в ряде случаев более удобное. Parglare предоставляет хороший инструментарий для приоритезации правил, к примеру, для арифметических выражений можно выставить приоритет операции и ассоциативность (с какой стороны выполняется данная операция — слева направо или справа налево) чем приоритет выше, тем операция выполнится раньше (относительно остальных), к примеру, наша грамматика в такой нотации может выглядеть вот так:
STMT : STMT ADDOP STMT {1, left} | STMT MULOP STMT {2, left} | "(" STMT ")" | NUMBER; ADDOP : "+" | "-"; MULOP : "*"|"/"; NUMBER: /\d+(\.\d+)?/;
Парсится, но работает не правильно. Например, для
1 + 2 / (3 - 1 + 5)
получаем (при не-древовидном парсинге)
['1', u'+', ['2', u'/', [u'(', ['3', u'-', ['1', u'+', '5']], u')']]]
что, очевидно, не соответствует ожидаемому результату:
['1', u'+', ['2', u'/', [u'(', [['3', u'-', '1'], u'+', '5'], u')']]]
Мораль — лучше использовать стандартные описанные в BNF моменты. Блестящие и прикольные штуки блестят и прикольны, но не всегда работают. Или я не умею их готовить. А большинство библиотек парсинга — имеют версию alpha | beta.
По словам автора об этом баге, он происходит из-за того, что, по сути своей, ассоциативность (left) парсера на уровне алгоритма означает — предпочитать reduce, а не shift. То есть, по сути, если есть возможность “обрубить” правило, или продолжить в его рамках — лучше обрубить. Поскольку парсинг идет слева направо, это и означает левую ассоциативность. Причина же ошибки в том, что не определена приоритетность для правил внутри ADDOP/MULOP, что ломает все правило.
Однако, даже если мы зададим приоритетность (например:
ADDOP: “+” {1}
), работать все равно не будет, уже из-за другой ошибки.
| “-” {1}
Напоследок пример с инлайн-функциями, привязываемыми непосредственно к правилам, из документации parglare.
from parglare import Parser, Grammar grammar = r""" E: E '+' E {left, 1} | E '-' E {left, 1} | E '*' E {left, 2} | E '/' E {left, 2} | E '^' E {right, 3} | '(' E ')' | number; number: /\d+(\.\d+)?/; """ # Define inline functions bound to grammar rule actions = { "E": [lambda _, nodes: nodes[0] + nodes[2], # for rule[0] “+” lambda _, nodes: nodes[0] - nodes[2], # for rule[1] “-” lambda _, nodes: nodes[0] * nodes[2], # for rule[2] “*” lambda _, nodes: nodes[0] / nodes[2], # for rule[3] “/” lambda _, nodes: nodes[0] ** nodes[2], # for rule[4] “^” lambda _, nodes: nodes[1], # for rule[5] “()” - just get the middle lambda _, nodes: nodes[0]], # for rule[6] just get node "number": lambda _, value: float(value), # for rule - convert to float } g = Grammar.from_string(grammar) parser = Parser(g, debug=True, actions=actions) result = parser.parse("34 + 4.6 / 2 * 4^2^2 + 78") print("Result = ", result) # Output # -- Debuging/tracing output with detailed info about grammar, productions, # -- terminals and nonterminals, DFA states, parsing progress, # -- and at the end of the output: # Result = 700.8
- Решить задачу переформулировав грамматику
- Дальше попробуем standalone-инструмент ANTLR.
Установка довольно простая, для linux это
$ cd /usr/local/lib $ curl -O http://www.antlr.org/download/antlr-4.7.1-complete.jar $ export CLASSPATH=".:/usr/local/lib/antlr-4.7.1-complete.jar:$CLASSPATH" $ alias antlr4='java -Xmx500M -cp "/usr/local/lib/antlr-4.7.1-complete.jar:$CLASSPATH" org.antlr.v4.Tool' $ alias grun='java org.antlr.v4.gui.TestRig'
Для того, чтобы работать на python2, нужно еще доустановить
pip install antlr4-python2-runtime
Дальше попробуем сделать для него грамматику. У него чуть отличается формат, поэтому двойные кавычки заменим на одинарные и чуть перепишем регулярное выражение, а так же добавим обозначение для WS, который в предыдущем инструменте задавался по умолчанию. Левую рекурсию в нашем случае можно устранить просто переписав на правую.
Важный момент! В ANTLR есть правила парсера и грамматические правила. К появлению узла в результирующем AST ведут правила парсинга. Кроме того, существует некоторое различие, что можно/нельзя в грамматических/правилах парсинга. В частности, в правилах парсинга нет регулярных выражений. Кроме того, парсер должен получить входное правило, стартовый нетерминал (вообще, все несколько сложнее, но в нашем случае вполне хватает и этого). Вообще, стоит заметить, что ANTLR имеет довольно мощный синтаксис, так же поддерживает инлайн функции (пусть и несколько в ином формате) и “не попадающие в дерево” нетерминалы и кое-что еще. В итоге, наша грамматика выглядит так:
grammar calc; stmt : term | term addop stmt ; term : factor | factor mulop factor ; factor : '(' stmt ')' | NUMBER; addop : '+' | '-'; mulop : '*'|'/'; NUMBER: [0-9]+|[0-9]+.[0-9]+; WS : [ \t\r\n]+ -> skip ;
Файл при этом называется calc.g4 (Это важно! название файла должно совпадать с названием грамматики внутри).
Создадим java-парсер.
antlr4 calc.g4 javac calc*.java grun calc stmt -gui
Дальше даем ему какой-то инпут (например,1 + 2 / (3 - 1 + 5)
) и нажимаем конец строки (ctrl-d
на *nix,ctrl-z
на windows) и смотрим на результат. Нам показали красивое дерево разбора, еще и интерактивное. Можно посмотреть, покрутить узлы, подумать, экспортировать в качестве картинки.
Создадим python2-парсер:
antlr4 -Dlanguage=Python2 calc.g4
Далее, мы можем навесить на грамматику листенеры и наслаждаться.
import sys from antlr4 import * from calc_bakLexer import calc_bakLexer from calc_bakListener import calc_bakListener from calc_bakParser import calc_bakParser class StmtPrinter(calc_bakListener): def __init__(self): self.map_results = {} self.result = 0 def exitStmt(self, ctx): print("Oh, a stmt! {}".format(ctx.getText())) def main(argv): input = FileStream(argv[1]) print(input) lexer = calc_bakLexer(input) stream = CommonTokenStream(lexer) parser = calc_bakParser(stream) tree = parser.stmt() printer = StmtPrinter() walker = ParseTreeWalker() walker.walk(printer, tree) if __name__ == '__main__': main(sys.argv)
… Наслаждаться неправильно работающей программой. Вернее, правильно, но неожиданно.
python ./calc_antlr_min.py ./example.antlr 1 + 2 / (3 - 1 + 5) Oh, a stmt! 5 Oh, a stmt! 1+5 Oh, a stmt! 3-1+5 Oh, a stmt! 2/(3-1+5) Oh, a stmt! 1+2/(3-1+5)
Как видно, ассоциативность здесь “правая”. А операции сложения-вычитания, умножения-деления — левые. Я честно пытался несколько дней (sic!) найти решение (разумеется, я занимался этим не все время, но в сумме убил на это часов 12-15). Маркеры ассоциативности, кучи мануалов, переформулирование грамматики…. Не получилось. Я уверен, что решение есть. Более того, пример грамматики калькулятора есть здесь. Но я хотел найти своё решение, по возможности, в терминах общей грамматики. В конце концов, целью было НАУЧИТЬСЯ, а не решить задачу.
И я признаю свою неудачу. Да, задача решаема. Но пользуясь только документацией и поиском на общие темы, мне её решить не удалось. Причины… Во-первых, исключая книгу по ANTLR, доступные в сети источники не отличаются подробностью и выразительностью. Во-вторых, в сети масса материалов по разным (не совместимым) версиям ANTLR. Нет, я все понимаю, развитие и прочее. Но почему-то в процессе знакомства с инструментом у меня сложилось ощущение, что о понятии обратной совместимости автор даже не слышал. В общем, грустно.
Update
Как верно замечено, одна голова хорошо, а две лучше.
Переформулирование грамматики с право-ассоциативной на левую выполняется буквально «щелчком пальцев» (Спасибо Valentin Nechayev netch80 за дополнение) — нужно всего лишь заменить
stmt : term | term addop stmt ;
на
stmt : term | stmt addop term ;
Левую рекурсию в данном случае ANTLR обрабатывает без вопросов (видимо, благодаря каким-то оптимизациям). Вывод простых листенеров в этом случае
python ./calc_antlr_min.py ./example.antlr 1 + 2 / (3 - 1 + 5) Oh, a stmt! 1 Oh, a stmt! 3 Oh, a stmt! 3-1 Oh, a stmt! 3-1+5 Oh, a stmt! 1+2/(3-1+5)
PEG
По сути, являются упрощенной формой BNF, но знать об этом программисту, в целом, не обязательно. В отличие от BNF, изначально больше похожи на регулярные выражения. По сути, можно было бы сказать, что это BNF с возможностью использовать регулярные выражения “по стандарту” (причем, что приятно, не только внутри нетерминальной строки, но и в некоторой степени в самом выражении (PEG поддерживает конструкции вида
A = B ( X C)*
или, к примеру Z = R (K)?
, читаемые как “A состоит из B и любого количества повторений X и C, стоящих последовательно” и “Z состоит из R и следующего за ним K 0 или 1 раз”). Но, на мой взгляд, это все же не главное. Главное в том, что PEG изначально спроектирован, как грамматики ПАРСЕРА. А с какой главной проблемой сталкиваются парсеры, в том числе BNF? Неоднозначность выбора! Для решения этой проблемы в PEG присутствует замечательный оператор последовательного или “/”. В отличие от оператора “|”, который описывает равнозначные альтернативы, “/” четко указывает порядок разрешения правила. Например, A / B / C / D
указывает: сравнить с A, если не подходит, сравнить с B, если не подходит, сравнить с C и т.д. По этой причине, оперирование PEG-грамматиками ПРОЩЕ. И еще, рекомендация — если вам безразличен порядок выполнения сравнения, лучше писать “/”. Это уменьшит количество неоднозначных для парсера ситуаций. Однако, как и с любым инструментом, с этим следует обращаться с осторожностью.Еще, будьте внимательны, создатели PEG-парсеров ещё более подвержены желанию понимать стандарт так, как им хочется, поэтому лексика разных реализаций может существенно различаться.
Итак, перейдем к практике.
Используем Arpeggio от автора parglare:
pip install arpeggio
Дальше разбираемся с документацией и узнаем, что рекомендованным для работы с AST для этой библиотеки является шаблон посетитель (visitor), весьма похожий на рекомендованный в ANTLR слушатель (listener). К счастью, здесь для всего эксперимента мне хватило часа, все оказалось не сложно:
from arpeggio.cleanpeg import ParserPEG
from arpeggio import PTNodeVisitor, EOF, visit_parse_tree
# Setting up our simple grammar
calc_grammar = """
number = r'\d+(\.\d+)?'
factor = number / "(" stmt ")"
term = factor (mulop factor)*
stmt = term (addop term)*
addop = "+" / "-"
mulop = "*" / "/"
calc = stmt EOF
"""
#Creating a visitor class to calculate the result
class CalcVisitor(PTNodeVisitor):
def visit_number(self, node, children):
return float(node.value)
def visit_factor(self, node, children):
# Children are list-like structure of VISITED node results
# visits are made from depth to top
return children[0]
def visit_term(self, node, children):
# For such rules you may use, for example children["factor"]
# Though, you need to know, that children["factor"] is a list of ALL
# factor's of this term
if len(children) == 1:
return children[0]
else:
res = children[0]
for i in xrange(len(children) / 2):
if children[2 * i + 1] == '*':
res *= children[2 * (i + 1)]
else:
res /= children[2 * (i + 1)]
return res
def visit_stmt(self, node, children):
if len(children) == 1:
return children[0]
else:
res = children[0]
for i in xrange(len(children) / 2):
if children[2 * i + 1] == '+':
res += children[2 * (i + 1)]
else:
res -= children[2 * (i + 1)]
return res
# Don’t forget about root rule for your parser, as it will be produced as
# a parsing result
parser = ParserPEG(calc_grammar, "calc")
input_expr = "(4-1)*5+(2+4.67)+5.89/(1.2+7)"
print(input_expr)
parse_tree = parser.parse(input_expr)
result = visit_parse_tree(parse_tree, CalcVisitor(
#debug=True
))
print(result)
input_expr = "1 + 2 / (3 - 1 + 5)"
print(input_expr)
parse_tree = parser.parse(input_expr)
result = visit_parse_tree(parse_tree, CalcVisitor(
#debug=True
))
print(result)
При запуске выведет следующее:
python ./calc_arpeggio.py
(4-1)*5+(2+4.67)+5.89/(1.2+7)
22.3882926829
1 + 2 / (3 - 1 + 5)
1.28571428571
Если есть желание посмотреть, как посетитель обходит дерево, можно раскомментировать debug
Как мы видим, грамматика претерпела небольшие, но важные изменения. В частности, если мы обратим внимание на правила stmt и term, то будет понятно, что они имеют произвольное число элементов. Соответственно, благодаря этому, обработка ассоциативности математических операций целиком и полностью на нашей совести. Несмотря на эти изменения, программа остается простой и понятной.
Общие выводы
На самом деле, выводов несколько:
- Не так страшен чёрт, как его малюют. Создание парсера с помощью инструмента, дело, в общем, посильное. Достаточно изучить общие принципы и потратить полдня на изучение конкретного инструмента, после чего в дальнейшем все уже будет намного проще. А вот велосипеды изобретать — не надо. Особенно, если вам не особенно важна скорость парсинга и оптимизации.
- Грамматики имеют собственную ценность. Имея перед глазами грамматику, гораздо проще оценить, будут ли при использовании составленного по ней парсера возникать ошибки.
- Инструмент можно найти всегда. Возможно, не на самом привычном языке, но почти на всех они есть. Если не повезло, и его все-таки нет, можно взять что-нибудь легко используемое (что-то на js, python, lua или ruby — тут уж кому что больше нравится). Да, получится “почти stand-alone в рамках проекта”, но в большинстве случаев этого достаточно.
- Все инструменты (немного) различаются. Иногда это “:” вместо “=” в BNF, иногда различия более обширны. Не надо этого пугаться. В крайнем случае, переделка грамматики под другой инструмент займет у вас минут 20. Так что если есть возможность достать где-то грамматику, а не писать её самому, лучше это сделать. Но перед использованием все равно лучше её проверьте. Все мы люди, всем нам свойственно ошибаться…
- При прочих равных, лучше используйте более “разговорчивый” инструмент. Это поможет избежать ошибок составления грамматики и оценить, что и как будет происходить.
- Если для вас в первую очередь важна скорость разбора, боюсь, вам придется либо пользоваться инструментом для C (например, Bison), либо решать проблему “в лоб”. Так же, следует задуматься о том, нужен ли вам именно парсинг (об этом стоит задуматься в любом случае, но в случае скоростных ограничений — особенно). В частности, для многих задач подходит токенизация — разбиение строки на подстроки с использованием заданного разделителя или их набора. Возможно, это ваш случай.
Заключение
В заключение хочется сказать, что это было интересное исследование. Я попытался описать свои выводы максимально просто и понятно, надеюсь, мне удалось написать эту статью так, чтобы тема стала понятна даже далеким от математики программистам, хотя бы в общих чертах, достаточных для пользования существующим инструментарием.
Можно задавать любые вопросы, если какие-то подробности по теме будут волновать многих, они могут послужить для написания других статей.
Как и обещал, несколько полезных ссылок по теме статьи:
- В википедии, на английском (на русском статьи существенно меньше):
СFG
BNF
LL
LR
PEG - Если кому-то нужно более серьёзное чтиво, тоже для человека “без математического бекграунда”, могу посоветовать книгу А. Аxo, Дж. Ульман “Теория синтаксического анализа, перевода и компиляции”. Есть, например, здесь. При желании находится достаточно легко, в русском переводе.
Комментарии (23)
netch80
05.02.2018 18:261. TestRig (который ANTLR grun) мне почему-то отвечает одно и то же:
Can't load calc as lexer or parser
Версия ANTLR ровно по указанному URL.
2. По поводу ассоциативности — Вы не пробовали вместо
stmt: term | term addop stmt;
написать
stmt: term | stmt addop term;
?
по документации — ANTLR спокойно отрабатывает такую левую рекурсию.survivorm Автор
06.02.2018 07:581. Есть предположение, что grun тупо запускается не из той папки. Проверьте, что созданные java-файлы находятся там, где вы запускаете визуализатор.
2. Попробовал, заработало. Ценное замечание, спасибо, простые решения самые правильные, обновил статью.
worldmind
05.02.2018 19:54Тема интересная, как -то была идея сделать парсер на bnf для последующей конвертации в другие форматы, но что-то сходу не пошло и забросил, обошёлся регулярками, хотя понимаю что это кривой способ, но время на сделать по нормальному не выделил.
Я правильно понимаю что конвертацию в другие форматы мы должны делать в listener'ах?survivorm Автор
06.02.2018 08:00Если речь об ANTLR — да. Но и здесь есть ньюансы — можете посмотреть ссылку на пример грамматики калькулятора на гитхаб.
Если речь в общем, то есть обоснованное предположение, что быстрее работает привязка функций к грамматическим конструкциям без построения дерева.worldmind
06.02.2018 11:10Интересно в общем случае и скорее важнее не скорость а ясность и читаемость, может есть какой-то пример полного цикла парсинга с последующей конвертацией?
survivorm Автор
06.02.2018 11:41PEG в статье.
Если вас чем-то описанный случай не устраивает, предложите простой use-case, может быть, добавлю
love5an
05.02.2018 22:49LL(k), которые всегда в принципе можно свести к LL(1), на самом деле всегда лучше писать руками или парсер-комбинаторами(ну тот же калькулятор, описанный). Да, надо знать матчасть. Но, при использовании описанных инструментов, даже PEG, на самом деле нужно знать еще больше матчасти :)
Также, имхо, нужно было бы упомянуть, что до стадии парсера, желательно иметь стадию лексера (которую можно и на регулярках заимплементить) — оно такое дело значительно всё упрощает. Кроме PEG, возможно, в случае реализации в виде packrat-парсера.
Ну и кстати, в случае реальных языков программирования, один хрен придется почти всё руками делать — почти у всех языков грамматики контекстно-зависимые. (даже у какого-нибудь лиспа — backquote синтаксис, например)
А так, хорошая статья, жду продолжения. Интересно было бы про GLR подробно почитать — информации про них вообще очень мало в интернете, а на русском вообще ноль.survivorm Автор
06.02.2018 08:30Таки с чего вы решили, что LL(k) сводимо к LL(1)?
Насчет матчасти — тоже довольно странный вывод. Нет, разумеется, можно все тупо зазубрить и писать программу, толком не понимая, что и почему, но «это не наш метод» (с). Лучше один раз понять, чем 10 раз запомнить (а мы все прекрасно помним, что запомненное без понимания выветривается из памяти весьма быстро).
Про лексеры пожалуй соглашусь. Но, пожалуй, они войдут в следующую статью цикла.
Насчет контекстно-зависимых — дааа? Сам я на лиспе не программирую, но, вроде бы, вот это: cuiwww.unige.ch/isi/bnf/LISP/BNFlisp.html Бнф-грамматика для Lisp.
Грамматика для того же питона так же в открытом доступе и, как я понимаю, используется при компиляции языка. Да и вообще, почти все ЯП неплохо укладываются в БНФ (иначе были бы проблемы с парсингом и компиляцией)
Про продолжение — если не будет других запросов, следующая статья будет обзорно именно по АЛГОРИТМАМ парсинга, ну, плюс, о лексерах. Основное — идеи, лежащие в основе каждого алгоритма. Если кому-то будет интересна математика, это уже отдельно.
Еще, не соглашусь с вашим призывом переписывать велосипед. Не нужно это, скорее даже вредно. Вроде бы есть (планирую попробовать чуть позже) разные «оптимизированные» парсеры общего назначения. Наверняка, там много своих ограничений, но тем не менее. И вероятность того, что ваш велосипед будет лучше я оцениваю как весьма низкую. Другое дело, если на вашем любимом ЯП нормальной библиотеки нет. Тогда да, вперед и с песней. И лучше с результатами на гитхабе, чтобы следующему не пришлось повторять.
love5an
06.02.2018 23:40Таки с чего вы решили, что LL(k) сводимо к LL(1)?
Да практически из определения. Сводится оно с помощью левой факторизации.
www.egtry.com/compiler/ll/left_factoring
cuiwww.unige.ch/isi/bnf/LISP/BNFlisp.html Бнф-грамматика для Lisp.
Нет, это оооочень упрощенная грамматика простейших S-выражений, а не какого-либо из лиспов :)
Грамматика для того же питона так же в открытом доступе и, как я понимаю, используется при компиляции языка.
trevorjim.com/python-is-not-context-free
Вкратце, у Питона лексер на себя слишком много берет. В целом, у него КЗ-грамматика.
Да и вообще, почти все ЯП неплохо укладываются в БНФ (иначе были бы проблемы с парсингом и компиляцией)
Дык и есть проблемы :) И их руками и решают.
Вроде бы есть (планирую попробовать чуть позже) разные «оптимизированные» парсеры общего назначения.
Если знать матчасть, то написать руками простейший парсер куда проще, чем бодаться с генераторами вроде ANTLR/etc, что вы вобщем-то, неплохо показали в своей статье :)survivorm Автор
07.02.2018 09:59Спорить не буду, не математик. Возможно, да, возможно, есть какие-то нюансы.
По поводу трудности освоения инструментов — даже спорить не буду. НО. Лучше освоить инструмент, чем писать каждый раз велосипед, это моё ИМХО. Если у вас это вызывает сомнение, значит просто инструмент ненадлежащего качества. Тогда стоит написать ИНСТРУМЕНТ лучше. А не переписывать всякий раз с нуля :)
По поводу оптимизаций я имел в виду не ANTLR, а что-то наподобие ragel или re2c. То есть инструмент, оптимизирующий саму работу с КА при парсинге, за счет чего и сам парсинг будет быстрее.
KvanTTT
07.02.2018 13:25У него чуть отличается формат, поэтому двойные кавычки заменим на одинарные и чуть перепишем регулярное выражение, а так же добавим обозначение для WS, который в предыдущем инструменте задавался по умолчанию.
Некорректно называть лексемы регулярными выражениями.
Левую рекурсию в нашем случае можно устранить просто переписав на правую.
В ANTLR 4 поддерживается левая и правая рекурсия одновременно:
expr : expr '+' expr | Id ;
В ANTLR есть правила парсера и грамматические правила.
Правила парсера и лексера (или токенайзера).
В частности, в правилах парсинга нет регулярных выражений.
Опять регулярные выражения. Их нигде нет, а в парсере тоже можно использовать повторение, отрицание:
a*
,~a
. В том числе не жадное:.+?
.
так же поддерживает инлайн функции (пусть и несколько в ином формате)
Не только функции. Это вставки произвольного кода.
Как видно, ассоциативность здесь “правая”. А операции сложения-вычитания, умножения-деления — левые. Я честно пытался несколько дней (sic!) найти решение (разумеется, я занимался этим не все время, но в сумме убил на это часов 12-15). Маркеры ассоциативности, кучи мануалов, переформулирование грамматики…. Не получилось. Я уверен, что решение есть. Более того, пример грамматики калькулятора есть здесь. Но я хотел найти своё решение, по возможности, в терминах общей грамматики. В конце концов, целью было НАУЧИТЬСЯ, а не решить задачу.
Элементарно же. Что значит общая грамматика?
grammar calc; stmt : term (addop term)*; term : factor (mulop factor)*; factor : '(' stmt ')' | NUMBER; addop : '+' | '-'; mulop : '*'|'/'; NUMBER: [0-9]+|[0-9]+.[0-9]+; WS : [ \t\r\n]+ -> skip ;
Но почему бы не изучить возможности инструмента и сразу не использовать левую рекурсию, с помощью которой запись получится лаконичней, а скорость работы сгенерированных парсеров — выше. Маркеры ассоциативности вообще только для левой рекурсии используются.
grammar calc; stmt : stmt mulop stmp | stmt addop stmt | '(' stmt ')' | NUMBER ; addop : '+' | '-'; mulop : '*'|'/'; NUMBER: [0-9]+|[0-9]+.[0-9]+; WS : [ \t\r\n]+ -> skip ;
И я признаю свою неудачу. Да, задача решаема. Но пользуясь только документацией и поиском на общие темы, мне её решить не удалось. Причины… Во-первых, исключая книгу по ANTLR, доступные в сети источники не отличаются подробностью и выразительностью. Во-вторых, в сети масса материалов по разным (не совместимым) версиям ANTLR. Нет, я все понимаю, развитие и прочее.
Куча разжеванного материала по ANTLR. Не понимаю, что вам не удалось найти. Вот например материал на уровень от новичков до профессионалов: The ANTLR mega tutorial. Моя статья, которая включает и практику: Теория и практика парсинга исходников с помощью ANTLR и Roslyn.
Но почему-то в процессе знакомства с инструментом у меня сложилось ощущение, что о понятии обратной совместимости автор даже не слышал. В общем, грустно.
О какой обратной совместимости идет речь? По-моему автор наоборот слишком консервативен и не вводит новые фичи.
survivorm Автор
07.02.2018 14:20+1Спасибо за содержательный коментарий.
Моё знакомство с ANTLR было знакомством нуба. С точки зрения такого нуба, для таких же нубов.
Соответственно, я попытался с ним познакомиться с налета, используя доки с гитхаба, с сайта, поиск по интересующим темам (конкретным, вроде, как обойти левую рекурсию) в интернете. Набредал на пдф-ки и несколько вопросов на stackoverflow. Оттуда же и сомнения в обратной совместимости, люди обсуждали список ключей парсера v2 и v3 и им было грустно.
Мануалы как таковые серьезно не искал, каюсь, наверное стоило. Вообще, идея была — программист берет абсолютно незнакомый инструмент, разбирается за пару часов, пишет работающий код. С этой точки зрения мне ANTLR не зашел. Вполне вероятно, я сам себе злобный буратино, однако, подозреваю, описанный мной сценарий — то, к чему будет стремиться среднестатистический программист, столкнувшись с подобной задачей.
Естественно, для того, чьё знакомство с инструментом заметно глубже мои слова напоминают детский лепет, но мой опыт с этим инструментом показал [мне], что он имеет несколько более высокий порог вхождения, чем я предполагал. Только и всего. Мне этого вывода было достаточно.KvanTTT
07.02.2018 14:26Оттуда же и сомнения в обратной совместимости, люди обсуждали список ключей парсера v2 и v3 и им было грустно.
Ну так на это и мажорные версии, что в них может не быть обратной совместимости. В перспективе нужно от нее отказываться, чтобы инструмент не захламлялся. В ANTLR 4 очень лаконичные грамматики получается. Если бы еще универсальные вставки кода, то не было бы цены...
ktretyak
Как на счет бенчмарков для сравнения упомянутых AST-разбора и регулярок?
У меня есть бенчмарки для JavaScript-парсеров, часть которых как раз используют AST-разбор, а часть — регулярки. В этом конкретном бенчмарке, входной текст около 300 КБ. Угадайте какие парсеры на первом месте.
По-моим наблюдениям, в случаи с JavaScript-парсерами, AST-разбор лучше начинает работать при входном объеме текста более 2 МБ.
survivorm Автор
Нет, бенчмаркинг не делал. По поводу результатов, приведенных вами — интересно, но при быстром взгляде не понятно, что есть что. Легенда была бы к месту.
Возможно, заморочусь и сделаю сравнение для парсеров «нативных», на регулярках и generic-ов (LL/LR/PEG) для чего-нибудь популярного, наподобие json. Брут-форсы писать не буду, ибо лень, разве что чей-нибудь найду для копи-паста :). Действительно, любопытно посмотреть динамику, в зависимости от размера файла (и, возможно, от сложности структуры).
ktretyak
В моем бенчмарке
marked-ts
иmarked
используют парсинг регулярками,remarkable
иmarkdown-it
используют AST.survivorm Автор
Полагаю, все во многом зависит от алгоритма построения, он может существенно отличаться, все-таки. Как дойдут руки, сделаю бенч для python/json, по хорошему стоит взять несколько библиотек. В конце концов, никто не гарантирует, что создатели AST-парсеров не используют регулярки ВНУТРИ.