Разбор это … сложно. И все же без этого у нас не было бы многих граней современного мира. Наша способность выводить поведение из компьютера ограничена выразительностью наших языков программирования. И доступные нам языки программирования ограничены нашими методами синтаксического анализа.
Конструкция языка программирования состоит как минимум из трех компонентов:
- лексический
- синтаксический анализатор
- интерпретатор / компилятор
Лексер, или сканер, как его часто называют, обычно представляет собой простую процедуру, которая воспринимает ввод как поток символов. Лексер преобразует поток символов в поток токенов, который он отправляет анализатору.
Парсер состоит из правил, которые определяют, организован ли поток токенов в приемлемом порядке. Кроме того, синтаксический анализатор отвечает за сборку графа узлов, во многих случаях абстрактного синтаксического дерева (обычно AST), которое представляет входные данные осмысленным образом.
Синтаксический граф, генерируемый синтаксическим анализатором, обычно преобразуется в байт-код, который может работать на виртуальной машине или компилируется в машинный код. Для небольшого DSL это может быть обработано простой средой выполнения, реализованной на языке хоста.
Написание этих компонентов вручную может быть проблемой, особенно для новичка. Было много инструментов, помогающих в их создании, сначала с генераторами лексеров и парсеров, а недавно с генераторами кода, такими как LLVM.
Драгоценный камень Parslet предоставляет DSL для создания языков программирования в Ruby. В этой статье мы рассмотрим историю анализа, где подходит Parslet и как вы можете использовать его для создания собственных языков программирования.
Исходный код этого руководства доступен на github .
Время и место
В 1965 году Дональд Кнут изобрел парсер LR . LR — это сокращение слева направо, крайнее справа. Парсер LR может распознавать любую детерминированную контекстно-свободную грамматику за линейное время. Из-за поддержки таблиц перехода состояний анализаторы LR были быстрыми, но имели чрезмерные требования к памяти (для 1960-х) и, таким образом, были отклонены как слишком непрактичные.
В 1969 году Франк ДеРемер предложил упрощенные версии синтаксического анализатора LR: Look-Ahead LR (LALR) и Simple LR (SLR). Эти парсеры снизили требования к памяти за счет распознавания языка. Самым популярным вариантом стал LALR (1), LALR с 1 прогнозным терминалом.
В 1970 году Стивен Джонсон создал YACC (еще один компилятор компилятора), работая над языком B в AT & T. Сначала он и Аль Ахо пытались реализовать таблицу LALR вручную , но потребовалось 2 дня, чтобы получить 30 правил грамматики, и выдало немало ошибок, поэтому он решил потратить время на генератор синтаксических анализаторов LALR (1), который мы теперь знаем как YACC.
Первоначально YACC был довольно медленным, но после нескольких лет работы Джонсон смог ускорить его на несколько порядков и сделать его ведущим инструментом для генерации синтаксического анализатора. К тому времени, когда компьютеры значительно улучшились и стали доступны альтернативные алгоритмы синтаксического анализа, YACC и LALR (1) утвердились в культуре информатики .
Написание полезной грамматики для YACC непросто. Уточнения LALR (1) (а именно сдвиг / уменьшение и уменьшение / уменьшение конфликтов) представляют собой высокий барьер для входа для тех, кто заинтересован в анализе нетривиального языка. В результате YACC мира все больше конкурируют с альтернативными подходами, включая написание парсеров с нуля.
Новая эра
Сегодня YACC живет в GNU Bison (и других вариантах) и остается довольно популярным благодаря своей скорости и истории. Тем не менее, последовательная тема компьютерных наук о производительности, отводящая место в ремонтопригодности, теряет свою актуальность.
Первоначально проект GCC использовал Bison для генерации синтаксических анализаторов для C, C ++ и Objective C, но в версиях 3.4-4.1 он постепенно переключился на рукописные синтаксические анализаторы с рекурсивным спуском . Их основой было то, что парсерам LALR (1) требовалось слишком много хаков, чтобы соответствовать любому из языков Си.
Сопровождающие Clang решили придерживаться аналогичного направления, чтобы в конечном итоге создать единый анализатор рекурсивного спуска для семейства языков C и его многочисленных диалектов.
Проект ANTLR генерирует парсеры рекурсивного спуска с использованием грамматик LL (*). В версии 4 создатель ANTLR Теренс Парр решил сосредоточиться на гибкости грамматики, а не на производительности, удачно назвав релиз «медовый барсук».
Рекурсивный спуск — не единственный современный ответ на разбор требовательных языков. С появлением большего количества оперативной памяти (без каламбура) и увеличенными тактовыми циклами возобновился интерес к некоторым из более дорогих алгоритмов, предлагаемых с середины 20-го века. К ним относятся алгоритм CYK и парсер Earley.
Bison теперь поддерживает Generalized LR (GLR) в дополнение к LALR (1). GLR работает аналогично LR за исключением того, что он разветвляет стеки разбора при возникновении конфликта. Каждый стек анализа читает токены, пока не завершит ввод или не достигнет недопустимого состояния. Это позволяет GLR избегать сдвига / уменьшения и уменьшения / уменьшения конфликтов за счет увеличения потребления памяти.
Разбор грамматики выражения
Чтобы понять, почему грамматика синтаксического разбора интересна, она помогает узнать альтернативу. В 1950-х годах Ноам Хомский предложил свою ныне знаменитую иерархию Хомского, которая состояла из формальных грамматик, типов языков, которые они могут генерировать, и какого рода машины (автоматы) были бы необходимы для распознавания языка.
Большинство генераторов синтаксических анализаторов ожидают, что синтаксические анализаторы будут описываться с помощью контекстно-свободной грамматики, второй по сложности в иерархии Хомского. Проблема с грамматиками Хомского в том, что они все порождают . Они были разработаны, чтобы выразить неоднозначность в языке, потому что Хомский был наиболее заинтересован в применении их к естественным языкам. Исторически неоднозначность, выражаемая CFG для генераторов синтаксических анализаторов, плохо сочеталась с алгоритмами синтаксического анализа, стоящими за ними (например, LALR (1)).
В 2004 году Брайан Форд опубликовал статью о грамматике синтаксического анализа. Он утверждал, что роль контекстно-свободных грамматик как генераторов языков была смещена с ролью синтаксических анализаторов как распознавателей языков. В статье он предложил альтернативный, основанный на признании, формализм, который мы теперь называем PEG.
Грамматика синтаксического анализа (PEG) приносит две важные инновации:
- лексический анализ и анализ — это одно и то же действие (вместо того, чтобы требовать отдельных инструментов, таких как lex и yacc)
- двусмысленность заменяется жадностью
Оператор выбора в CFG неупорядочен (коммутативен). Это подводный камень в предположении нескольких потенциальных деревьев разбора (когда может быть только одно). Тем не менее, оператор выбора в PEG является жадным. Если первый параметр действителен, второй игнорируется, как и в большинстве языков программирования. Следует отметить, что это не волшебное исправление, и вполне возможно, что у вас возникнут проблемы с ним.
В отличие от CFG, PEG не особенно подходят для анализа естественного языка. Тем не менее, они гораздо больше подходят для анализа языков программирования. В то время как CFG — это как спецификация для генерации строк в языке, PEG — как набор правил для создания парсера рекурсивного спуска.
Parslet
Parslet предоставляет DSL для определения синтаксических анализаторов с грамматикой выражения синтаксического анализа.
Сначала установите жемчужину.
gem install parslet
Вот как выглядит простой парсер в Parslet.
#demo_parser.rb require 'parslet' class DemoParser < Parslet::Parser rule(:identifier) { match('[az]').repeat(1) } rule(:number) { match('[0-9]').repeat(1) } rule(:space) { match('\s').repeat(0) } rule(:line) { identifier >> space >> number } root(:line) end demo_parser = DemoParser.new result = demo_parser.parse("foo 1") puts result.inspect
правила
Парсер Parslet создается с помощью набора правил и блоков определения.
rule(name) { definition_block }
Блок определения содержит ожидаемый синтаксис для этого правила, в том числе:
— порядок поступления элементов.
— сколько раз ожидается появление какого-либо элемента
— любой выбор между элементами
Вот как можно ожидать одну или несколько цифр, используя #repeat.
rule(:number) { match('[0-9]').repeat(1) }
На правила в ссылочных блоках можно ссылаться по их именам.
rule(:number) { match('[0-9]').repeat(1) } rule(:zipcode) { number }
Каждому парсеру нужно корневое правило. Корень — это первое правило, которое ожидает синтаксический анализатор.
rule(:line) { match('[a-zA-Z0-9]').repeat >> '\n' } rule(:lines) { line.repeat } root(:lines)
атомы
Парслет относится к элементам синтаксиса языка как атомы или петрушки (интересно, что «атом» обычно используется в документации).
Атомы бывают следующих сортов:
- Регулярные выражения — ex:
match('[a-zA-Z0-9]')
- Строки — например:
str('foo')
- любой символ — соответствует регулярному выражению /
any.repeat(0)
ex:any.repeat(0)
Поведение парсера определяется отношениями между его атомами и правилами.
-
atom1 >> atom2
— atom2 ожидается после atom1 -
atom.repeat(x[,y])
— атом ожидается x или более раз (или до y раз) -
atom.maybe
— эквивалент atom.repeat (0,1) -
atom1 | atom2
atom1 | atom2
— любой атом ожидается, но если atom1 виден, то atom2 игнорируется
Хотя это кажется подразумеваемым, документация Parslet не проясняет, что атомы являются чисто терминальными (и, следовательно, правила, будучи нетерминальными, не считаются атомами), но кажется, что все, что вы можете применить к атому, Вы можете обратиться к правилу.
Ожидайте «a» или правило b:
rule(:aorb) { str('a') | b }
Ожидайте слово, за которым следует двоеточие:
rule(:wordcolon) { match('[a-zA-Z]') >> str(':') }
Хитрый пробел
Очень заманчиво использовать #repeat(0)
чтобы сделать ваш анализатор гибким, но нужно быть осторожным. При работе с пробелами обычно рекомендуется иметь такие правила:
rule(:space) { match('\s').repeat(1) } rule(:space?) { space.maybe }
Если вы просто используете одно всеобъемлющее правило пробелов, вы рискуете запутаться в двусмысленности. Играя с пробелами, мне удалось создать парсер, который зависает.
# tricky_whitespace.rb # Be careful with #repeat(0) require 'parslet' class BadWhitespaceParser < Parslet::Parser rule(:space) { match('\s').repeat(0) } rule(:identifier) { match('[a-zA-Z0-9]').repeat(1) } rule(:line) { identifier | space } rule(:lines) { (line >> space).repeat(0) } root(:lines) end doc = <<-eof east west eof parser = BadWhitespaceParser.new # This will hang. Use ctrl-c to interrupt. puts parser.parse(doc).inspect
Отчет об ошибках
Parslet предоставляет отличные отчеты об ошибках, если вы используете его. Если вы просто позволите Parslet потерпеть неудачу, он скажет вам, с какой последовательностью он не соответствует. Однако, если вы Parslet::ParseFailed
, вы можете получить дерево сбоев.
# parse_failed.rb require 'parslet' class FailParser < Parslet::Parser rule(:alpha) { match('[a-zA-Z]').repeat(1) } rule(:number) { digit.repeat(1) >> (str('.') >> digit.repeat(1)).maybe } rule(:digit) { match('[0-9]').repeat(1) } rule(:space) { str(' ') } rule(:line) { alpha >> space >> number >> space >> alpha } root(:line) end input = "ab 1.2 d1" parser = FailParser.new # not so great error reporting puts "--STANDARD ERROR REPORTING--" begin parser.parse(input) rescue Exception => error puts error end puts ("\n") # better error reporting puts "--ASCII TREE ERROR REPORTING--" begin parser.parse(input) rescue Parslet::ParseFailed => error puts error.cause.ascii_tree end
Это распечатывает:
--STANDARD ERROR REPORTING-- Failed to match sequence (ALPHA SPACE NUMBER SPACE ALPHA) at line 1 char 8. --ASCII TREE ERROR REPORTING-- Failed to match sequence (ALPHA SPACE NUMBER SPACE ALPHA) at line 1 char 8. `- Extra input after last repetition at line 1 char 9. `- Failed to match [a-zA-Z] at line 1 char 9.
Parslet предоставляет удобный метод, так что вам не нужно каждый раз записывать обработку исключений.
require 'parslet/convenience' parser.parse_with_debug(input)
Генерация дерева
По умолчанию Parslet просто проверяет ввод. Он не генерирует синтаксическое дерево. Метод #as
создает узел в дереве.
rule(:assignment) { left.as(:left) >> str('=') >> right.as(:right) }
Вот более полный пример.
# assignment_parser.rb require 'parslet' class AssignmentParser < Parslet::Parser rule(:identifier) { match('[a-zA-Z0-9_]').repeat(1) } rule(:value) { match('[0-9]').repeat(1) } rule(:assignment) { identifier.as(:left) >> str('=') >> value.as(:right) >> str("\n").maybe } rule(:assignments) { assignment.as(:assignment).repeat } root(:assignments) end doc = <<-eof a=23 b=56 eof parser = AssignmentParser.new puts parser.parse(doc).inspect
Если вы запустите assignment_parser.rb, вместо того, чтобы вернуть ввод, вы получите массив хэшей присваивания.
[{: assignment => {: left => ”a” @ 0,: right => ”23 ″ @ 2}}, {: assignment => {: left =>” b ”@ 5,: right =>” 56 «@ 7}}]
преобразование
Parslet предоставляет Parslet :: Transform для понимания синтаксических графов, созданных Parslet::Parser
. Вы можете использовать его для создания интерпретатора или генератора байт-кода.
Как и Parslet :: Parser, Parslet :: Transform состоит из правил и блоков.
rule(nodes => capture_method) { action_block }
Метод захвата определяет, что правило будет соответствовать и является одним из:
- просто — любой объект, кроме хэшей или массивов
- последовательность — хэши или массивы
- поддерево — все
Результат блока действия заменит шаблон узла, указанный в правиле. Если указан только конечный узел, будет заменен только конечный узел.
# transform_replace.rb require 'parslet' ast = {:document => {:words => "hello world"}} # replaces the words node class LeafTransform < Parslet::Transform rule(:words => simple(:x)) { x.upcase } end # replaces document and words nodes class TreeTransform < Parslet::Transform rule(:document => {:words => simple(:x)}) { x.upcase } end leaf_transform = LeafTransform.new puts leaf_transform.apply(ast).inspect tree_transform = TreeTransform.new puts tree_transform.apply(ast).inspect
Здесь LeafTransform просто заменяет {: words => «hello world»}, а TreeTransform заменяет все дерево.
{:document=>"HELLO WORLD"} "HELLO WORLD"
Давайте преобразуем дерево, созданное синтаксическим анализатором присваивания (Примечание: убран символ Parslet «@»).
# assignment_transform.rb require 'parslet' tree = [{:assignment=>{:left=>"a", :right=>"23"}}, {:assignment=>{:left=>"b", :right=>"56"}}] class Assignment def initialize(left, right) @left = left @right = right end def generate_code # spit out bytecode/machinecode associated with assignment operation end end class AssignmentTransform < Parslet::Transform rule(:left => simple(:left), :right => simple(:right)) do Assignment.new(left, Integer(right)) end end transform = AssignmentTransform.new puts transform.apply(tree).inspect
Метод #apply возвращает преобразованное дерево с объектами Assignment вместо хешей.
[{:assignment=>#<Assignment:0x000000019df520 @left="a",@right=23>}, {:assignment=>#<Assignment:0x000000019c9018 @left="b", @right=56>}]
Чтобы узнать больше о Parlset :: Transform, ознакомьтесь с документацией .
Вывод
Если вы новичок в создании языков программирования, Parslet — отличный способ начать. Он обеспечивает большую грамматическую гибкость, чем типичный генератор LALR (1), и может использовать ваш код Ruby. Тем не менее, Parslet — не единственная игра PEG в городе для Ruby. Вот некоторые другие:
Вот несколько дополнительных ресурсов, если вы хотите больше узнать о создании языков с помощью Parslet: