[Из песочницы] Про Parboiled
Часть 1. Почему Parboiled?
Сегодня, в свете бурного роста популярности функциональных языков программирования, всё чаще находят себе применение комбинаторы парсеров — инструменты, облегчающие разбор текста простым смертным. Такие библиотеки, как Parsec (Haskell) и Planck (OCaml) уже успели хорошо себя зарекомендовать в своих экосистемах. Их удобство и возможности в своё время подтолкнули создателя языка Scala, Мартина Одерски, внести в стандартную библиотеку их аналог — Scala Parser Combinators (ныне вынесены в scala-modules), а знание и умение пользоваться подобными инструментами — отнести к обязательным требованиям к Scala-разработчикам уровня A3.
Эта серия статей посвящена библиотеке Parboiled — мощной альтернативе и возможной замене для Scala Parser Combinators. В ней мы подробно рассмотрим работу с текущей версией библиотеки — Parboiled2, а также уделим внимание Parboiled1, так как большая часть существующего кода всё ещё использует именно её.
Структура цикла:
- Часть 1. Почему Parboiled?
- Часть 2. Сопоставление текста
- Часть 3. Извлечение данных
- Часть 4. Суровая действительность
Введение
Parboiled — библиотека, позволяющая с легкостью разбирать (парсить) языки разметки (такие как HTML, XML или JSON), языки программирования, конфигурационные файлы, логи, текстовые протоколы и вообще что угодно текстовое. Parboiled придётся весьма кстати, если вы захотите разработать свой предметно-ориентированный язык (DSL): с её помощью вы сможете быстро получить абстрактное синтаксическое дерево и, вспомнив паттерн интерпретатор, исполнять команды вашего доменного языка.
На данный момент существует несколько версий данной библиотеки:
- Parboiled for Java — самая первая версия библиотеки. Написана Маттиасом Доеницем (Matthias Doeniz) на Java и для Java. До сих пор пользуется популярностью, хоть и находится в состоянии «end of life». Если по воле случая она досталась вам в наследство, или же вы сознательно начинаете проект на Java, советую рассмотреть в качестве альтернативы grappa — форк Parboiled1, который старательно поддерживается в работоспособном состоянии пользователем с ником fge.
- Parboiled — библиотека, теперь уже более известная как Parboiled1, появилась на свет после того, как Маттиас проникся скалой. Он сделал Scala-фронтэнд для Parboiled, заодно забросив поддержку Java-версии. С выходом Parboiled2 потихонечку перестает поддерживаться и Scala-версия Parboiled1, однако не смотря на это, списывать её со счетов пока что не стоит:
- Parboiled2 пока что не научился всем фичам Parboiled1;
- Parboiled1 всё ещё используется гораздо шире, чем Parboiled2, поэтому если вас внезапно перебросят на какой-нибудь старый Scala-проект, высок шанс столкнуться именно с ним.
- Parboiled2 — новейшая версия библиотеки, устраняющая ряд недостатков PB1. Работает быстрее и, что самое главное, поддерживается разработчиками.
Я писал эту статью с упором на Parboiled2 (кстати, дальше я буду писать о нём в мужском роде, без слова «библиотека»), но иногда я буду отвлекаться, чтобы рассказать о важных отличиях между первой и второй версиями.
Основные возможности
Краткая характеристика Parboiled2:
- Следует принципам PEG.
- Генерирует однопроходные парсеры. Отдельный лексер не требуется.
- Используется типобезопасный DSL, являющийся подмножеством языка Scala.
- Оптимизации выполняются на этапе компиляции.
На практике это означает:
- Вам не нужно писать парсер голыми руками.
- Читаемость, сравнимая с лучшими сортами BNF (по-моему, PB даже круче).
- Можно использовать всю мощь PEG и свободно разбирать рекурсивные структуры данных, в то время как регулярные выражения не могут этого по определению. Да, регулярными выражениями вы не распарсите ни JSON, ни даже простейшее арифметическое выражение, что уж говорить о языках программирования. На StackOverflow есть небезызвестная цитата в тему:
Asking regexes to parse arbitrary HTML is like asking Paris Hilton to write an operating system.
- Даже если вам нужно разобрать линейную структуру, Parboiled2 (при использовании должных оптимизаций) будет работать быстрее регулярных выражений. Доказательства приведены в следующем разделе.
- В отличие от генераторов парсеров, таких как ANTLR, вы освобождены от мороки с раздельной генерацией кода и последующей его компиляцией. Весь код с Parboiled пишется на Scala, поэтому вы получаете подсветку синтаксиса и проверку типов из коробки, так же как и отсутствие дополнительных операций над файлами грамматик, в то время как парсер, сгенерированный ANTLR, будет иметь две фазы синтаксического разбора. Правда, несмотря на это, ANTLR всё равно мощнее, документированее и стабильнее, и поэтому может оказаться предпочтительнее во многих (очень нетривиальных) случаях.
- Скаловские парсер-комбинаторы работают медленно. Очень медленно. Неприлично медленно. Маттиас проводил сравнение производительности парсеров для Jackson и JSON, написанных с помощью Parboiled, Parboiled2 и Scala Parser Combinators. С неутешительными результатами для последних можно ознакомиться дальше по тексту.
- В отличие от Language Workbenches, Parboiled — маленькая и простая в использовании библиотека. Вам не нужно скачивать плохо документированного тормозящего монстра и тратить драгоценные часы жизни на изматывающий поиск нужных менюшек и кнопочек всего-навсего для описания небольшого DSL. С другой стороны, вы не получите готовый текстовый редактор с подсветкой вашего DSL из коробки, вместо этого вам придется самостоятельно написать плагин для Vim, Emacs или вашей IDE, но это не делает Parboiled менее достойной альтернативой для разработки небольших предметно-ориентированных языков.
- Parboiled успешно зарекомендовал себя во многих проектах, в том числе и в кровавом энтерпрайзе.
Новое в версии два
Этот раздел, в основном, будет полезен и понятен тем, кто уже работал с первой версией библиотеки. Новичкам, скорее всего, стоит вернуться к этому списку после прочтения всего цикла статей.
Прежде всего, Parboiled2 успешно устраняет ряд детских болезней первой версии:
- Появилась возможность использовать правила более вместительные, чем
Rule7
. Для этого была использована библиотека shapeless с ее знаменитымиHListами
: теперь одно правило может оперировать большим количеством значений на стеке. Это также означает, что в Parboiled2 появилась дополнительная зависимость, которой не было в PB1 — сама библиотека shapeless. - Добавлены недостающие конструкции. Так, в Parboiled1 нельзя было указать динамическое количество повторений для правила
nTimes
и приходилось использовать более «мягкое» правилоoneOrMore
, что не давало нужной точности описания грамматики. - Добавлены встроенные примитивные терминалы. Появился новый класс
CharPredicate
, который содержит такие поля, какAlphaNumeric
,Hex
,Printable
,Visible
и другие. - Добавлена возможность расширения и сужения предиката. Потребность исключить несколько символов из правила возникала и раньше, но только теперь это можно с легкостью взять и сделать, а не создавать белый список символов.
Кроме того:
- Parboiled2 использует макросы, что позволяет генерировать грамматику на этапе компиляции, а не во время выполнения, как это было в Parboiled1. Это многократно увеличивает производительность вашего парсера, так же как увеличивает количество проверок. В связи с этим блок
rule
стал обязательным, хотя Parboiled1 позволял в некоторых случаях обходиться без него. Это нововведение вы заметите в первую очередь, когда будете делать миграцию старого кода. - Улучшена система отчета об ошибках.
- Появилась поддержка scala.js. Демо-проект можно посмотреть здесь.
Сравнения производительности
Parboiled1 известен своей медлительностью (во всяком случае, по отношению к парсерам, генерируемым ANTLR), вызванной тем, что все действия по сопоставлению правил выполнялись в рантайме и компилятор не мог производить над таким парсером каких-либо существенных оптимизаций. В Parboiled2 во главу угла поставили производительность и многие вещи были переделаны на макросах, благодаря чему компилятор получил свободу действий при оптимизации, а пользователь — долгожданную производительность. Ниже мы продемонстрируем, каких неплохих результатов добились разработчики.
Parboiled против парсеров JSON, написанных прямыми руками
Parboiled — это обобщённый инструмент для создания парсеров, а как известно, специализированный инструмент всегда оказывается лучше обобщённого в решении своей специализированной задачи. В мире Java существует небольшое количество парсеров JSON, написанных вручную древними эльфийскими мастерами, и Александр Мыльцев (один из разработчиков Parboiled2) проверил, насколько сильно Parboiled проигрывает в производительности этим артефактам. Результаты оказались достаточно оптимистичными, особенно в случае с Parboiled2.
Тест-кейс │ Время, мс │
──────────────────────────────────────┼───────────┼─────────────────────────────────
Parboiled1JsonParser │ 85.64 │ ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇
Parboiled2JsonParser │ 13.17 │ ▇▇▇▇
Json4SNative │ 8.06 │ ██▍
Argonaut │ 7.01 │ ▇▇
Json4SJackson │ 4.09 │ ▇
Parboiled против регулярных выражений
Благодаря использованию статических оптимизаций, Parboiled2 способен работать значительно быстрее регулярных выражений (как минимум тех, что идут в комплекте с библиотекой классов Java). Вот немного подтверждающих данных из списка рассылки:
Тест-кейс │ Время, мс │
──────────────────────────────────────┼───────────┼───────────────────────────────────
Parboiled2 (warmup) │ 1621.21 │ ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇
Parboiled2 │ 409.16 │ ▇▇▇▇▇▇▇▇
Parboiled2 w/ better types (warmup) │ 488.92 │ ▇▇▇▇▇▇▇▇▇▇
Parboiled2 w/ better types │ 134.68 │ ▇▇▇
Regex (warmup) │ 621.95 │ ▇▇▇▇▇▇▇▇▇▇▇▇
Regex │ 620.38 │ ▇▇▇▇▇▇▇▇▇▇▇▇
Parboiled против Scala Parser Combinators
В списке рассылки можно найти и другой тест производительности, который неплохо согласуется с первым (про JSON) и содержит данные для сравнения со Scala Parser Combinators. Всё очень и очень печально.
Тест-кейс │ Время, мс │
──────────────────────────────────────┼───────────┼─────────────────────────────────
Parboiled1JsonParser | 73.81 | ▇
Parboiled2JsonParser | 10.49 | ▎
ParserCombinators | 2385.78 | ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇
Чего Parboiled не может
Большинство статей про комбинаторы парсеров начинается с изматывающих объяснений того, что такое PEG, с чем его есть и почему его надо бояться. Для того чтобы парсить конфиги, досконально разбираться в этом не обязательно, но знать об ограничениях данного типа грамматик всё равно стоит. Итак, Parboiled принципиально не умеет:
- Разбирать леворекурсивные грамматики. Это не под силу всем нисходящим парсерам (top-down parsers), к коим относятся и PEG. Однако, леворекурсивную грамматику можно адаптировать.
- Разбирать грамматики на отступах (indentation-based grammars), например Python или YAML. Не получается это сделать из-за того, что сгенерированный парсер является однопроходным, без отдельного лексера. Разбор отступов же выполняется на этапе лексического анализа. У этой проблемы есть простое решение: напишите препроцессор, который расставит виртуальные маркеры до (
INDENT
) и после (DEDENT
) выхода в отступ. В Parboiled1 имеются для этого стандарные инструменты, но для Parboiled2 подобную процедуру пока что придётся выполнять самостоятельно. - Использовать потоковый ввод (streaming input). PEG используют поиск с возвратом, он же бэктрекинг. Теоретически, этот недостаток можно устранить при помощи буферизации потока, но ничто не мешает написать такую грамматику, в которой происходит возврат к самому началу. Поэтому, чтобы эта идея заработала на практике, необходимо научиться определять по грамматике границы чанков, между которыми возврат невозможен. Матиас весьма заинтересован в разработке этой фичи, так что возможно ее появление в следующих релизах.
В следующей части я расскажу о том, как в Parboiled описывается пользовательская грамматика, а ещё мы напишем простой распознаватель для древовидного формата конфигурационных файлов.