Наследование Комбинаторных Парсеров В Julia

Комбинаторные (монадические) парсеры достаточно известны ( викибуки ).

Это библиотека небольших парсеров, распознающих простые элементы грамматики, и способы объединения нескольких парсеров в один (объединить — отсюда и название).

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

В Haskell это позволяет вам воспользоваться мощными сервисами, предоставляемыми языком и библиотеками.

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

Самый простой способ реализации комбинаторных парсеров — в языках, поддерживающих замыкания, но можно использовать и классический ООП (пример описан Ребеккой Парсонс в книге Мартина Фаулера «Доменно-специфичные языки»).

К преимуществам комбинаторных парсеров можно отнести простоту использования (написание на языке программирования практически ничем не отличается от обычного описания грамматики), независимость от препроцессора (типа yacc/bison, Happy или окамлякк ), возможность реализовать некоторые элементы, которые плохо вписываются в контекстно-свободную грамматику, непосредственно на языке программирования общего назначения.

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

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

Но есть и другие тонкости, которые требуют внимания, если требуется эффективность.

) Как альтернативу можно рассмотреть реализации в виде макросов (например Парсеры потоков OCaml ).

Perl6 имеет встроенную в язык поддержку грамматики.

Наследование.

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

В этом отношении парсеры напоминают методы объекта.

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

Чтобы поэкспериментировать с таким подходом (а также с целью выучить следующий язык), я выбрал язык Юлия — динамически типизированный с особым подходом к наследованию (аналогично CLOS из Common Lisp и R).

В отличие от обычных комбинаторных анализаторов, подход наследования является экспериментальным (хотя в некотором роде поддерживается библиотекой макросов OCaml и языком Perl6).

Пока что получается код, который не очень читаем.

Исходный код доступен по адресу Гитхаб .

Выполнение Обычные комбинаторные парсеры — это функция, которая принимает строку (или другой неизменяемый поток) и возвращает контейнер (массив) пар — результат синтаксического анализа, остаток неразобранного текста.

Если распознавание не удалось, возвращается пустой контейнер.

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

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

Julia допускает множественную диспетчеризацию (выбор конкретного метода на основе типов нескольких аргументов — в отличие от перегрузки методов в C++ и Java диспетчеризация происходит динамически), но я использую тип только первого аргумента, как в обычном ООП.

Дерево наследования в Julia может быть построено только на абстрактных типах, но листьями могут быть конкретные типы, экземпляры которых можно создавать.

  
  
  
  
  
  
  
  
  
  
  
  
  
  
   

abstract Grammar type Gmin <: Grammar end geof(g::Grammar, s) = if length(s) == 0 [((),s)] else empty end

Он описывает абстрактный тип Grammar, конкретную запись без полей, которая является подтипом Grammar, и анализатор, распознающий конец строки.

Если мы хотим передать парсерам текстовое представление, отличное от строк, в двух простейших парсерах (geof и getTocken, которые извлекают один символ из потока) мы можем использовать множественную отправку и специализировать второй аргумент s — остальную часть парсеры будут работать через них, не зная представления потока.

«пустой» — это массив типа [Любой].

Julia поддерживает переопределение многих операторов (за исключением тех, которые требуют специальной поддержки в языке, например '&', которые могут не оценивать второй аргумент), и я этим воспользовался:

>(g::Grammar, p1, p2) = (g,s) -> reduce((a,r) -> let local v local s v,s = r [a, p2(g,s)] end, empty, p1(g,s))

Метод '> ' (комбинатор) объединяет два парсера (при успешном применении первого, второй применяется к остальной части строки), при этом результат первого парсера игнорируется, и результатом объединения становится результат второго.

Методы '<', '-' (concatenation of several parsers, the results of all are combined into an array), '|' are defined similarly. (alternatively, recognize a string recognized by any of the parsers).

There is also the '+' combinator - a limited alternative that ignores parsers after the first one successfully applied. It does not allow you to organize a return to an earlier source if there is an error in a later one. This is important for efficiency, especially in lazy languages like Haskell, where the ability to do this backtracking results in a large number of undercomputed values accumulating in memory, but it is also useful in energetic ones. Давайте проверим, как это работает:

julia> include("Parsers.jl") julia> g = Gmin() Gmin() julia> (-(g,getTocken, getTocken, getTocken))(g,"12345") 1-element Array{Any,1}: (Char['1','2','3'],"45") julia> (|(g,getTocken, getTocken, getTocken))(g,"12345") 3-element Array{(Char,ASCIIString),1}: ('1',"2345") ('1',"2345") ('1',"2345") julia> (+(g,getTocken, getTocken, getTocken))(g,"12345") 1-element Array{(Char,ASCIIString),1}: ('1',"2345")

Есть небольшое неудобство — необходимость везде добавлять грамматический объект (в данном случае типа Gmin).

Я сделал это ради возможности наследования — классические комбинаторные парсеры пишутся проще.

Также отмечу полезные функцииlift, позволяющие «поднять» функцию в парсер и gfilter, проверяющую значение, возвращаемое парсером.



lift(g::Grammar, f, p) = (g,s) -> map((r) -> (f(r[1]),r[2]), p(g,s)) julia> lift(g,int,getTocken)(g,"12") 1-element Array{(Int64,ASCIIString),1}: (49,"2") julia> (|(g,gfilter(g,(c) -> c=='+', getTocken),gfilter(g,(c) -> c=='*', getTocken)))(g,"*123") 1-element Array{(Char,ASCIIString),1}: ('*',"123")

Парсеры могут быть рекурсивными:

cut(g::Grammar,n) = if(n==0); mreturn(g,[]) else (-(g,getTocken,cut(g,n-1))) end julia> cut(g,4)(g,"12345678") 1-element Array{Any,1}: (Any['1','2','3','4'],"5678")

Немного монадический

mreturn(g::Grammar, v) = (g,s) -> [(v,s)] mbind(g::Grammar, p, fp) = (g,s) -> reduce((a,v)->[a,fp(g,v[1])(g,v[2])], empty, p(g,s))

В Haskell эти функции называются return (это функция, а не оператор языка) и > > = (часто произносится как «связывание»).

Что делают эти странные функции? 'mreturn' ничего не делает - он просто завершается успешно, ничего не читая из входной строки и не возвращая предопределенное значение.

Это не только имеет глубокий математический смысл, но и часто заменяет сложную логику, которую в противном случае пришлось бы применять с помощью «лифта».

«mbind» сложнее.

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

julia> mbind(g, lift(g, (c) -> c-'0', getTocken), cut)(g,"23456") 1-element Array{Any,1}: (Any['3','4'],"56")

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

Арифметика

abstract Arith <: Grammar type ArithExpr <: Arith end

Для арифметического анализатора объявлен абстрактный подтип Grammar и его конкретная реализация.

Он определяет функцию gnumber(g::Arith, base), которая создает жадный анализатор числа в заданной системе счисления, и анализатор snumber, который анализирует число в десятичной системе счисления.

«Жадность» выражается в том, что если парсер найдет одну цифру, он не остановится, пока не прочитает все.

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

Теперь давайте определим сложение и умножение.



amul(g::Arith,s) = lift(g, (x) -> x[1]*x[2], -(g, snumber, +(g, >(g, gfilter(g, (c) -> c == '*', getTocken), amul), mreturn(g,1))))(g,s) asum(g::Arith,s) = lift(g, (x) -> x[1]+x[2], -(g, amul, +(g, >(g, gfilter(g, (c) -> c == '+', getTocken), asum), mreturn(g,0))))(g,s)

Здесь стоит отметить, что используется правило (в БНФ)

amul = snumber ('*' amul | '')

и не самый простой

amul = snumber '*' amul | snumber

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

Давайте попробуем, как это работает:

julia> a=ArithExpr() ArithExpr() julia> asum(a,"12+34*56") 1-element Array{Any,1}: (1916,"") julia> 12+34*56 1916

А теперь наследство! Некоторые языки позволяют указывать числа в произвольной системе счисления.

Например, в Erlang система счисления может быть указана перед числом явно с использованием знака «#».

Создадим новую «арифметику», переопределив в ней snumber для записи чисел как в Эрланге.

Новый номер всегда начинается со старого.

Для доступа к переопределенному парсеру нам понадобится функция cast, которая позволяет нам брать парсер из конкретной грамматики, минуя наследование.



cast(g::Grammar,p) = (_,s) -> p(g,s)

Сама реализация использует уже знакомые приёмы:

abstract GArith <: Arith type GExpr <: GArith end snumber(g::GArith, s) = mbind(g, cast(ArithExpr(),snumber), (g,v) -> (+(g, (>(g, gfilter(g, (c) -> c == '#', getTocken), gnumber(g,v))), mreturn(g,v))))(g,s)

Давайте проверим, как это работает:

julia> c = GExpr() GExpr() julia> asum(a,"12+34*13#12") 1-element Array{Any,1}: (454,"#12") julia> asum(c,"12+34*13#12") 1-element Array{Any,1}: (522,"")

Немного о языке Джулия явно вдохновилась языком R и пытается исправить некоторые его недостатки.

Язык разрабатывался с упором на производительность (которая в R иногда подводит) — для этого используется LLVM. По сравнению с R, у Julia более удобный синтаксис замыканий и более развитая система типов (в частности, есть кортежи/кортежи).

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

Как и в R, индексы начинаются с единицы.

На мой взгляд, это неудачное решение, отражающее страх нематематиков перед нулем, но многим оно нравится.

Конечно, у Юлии пока нет таких богатых и хорошо организованных библиотек, как у Р.

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

И чтобы извлечь эти данные, вам придется читать различные текстовые форматы.

Надеюсь, моя библиотека когда-нибудь пригодится для этого.

Теги: #julia #монадические парсеры #комбинаторные парсеры #наследование #мультиметоды #Аномальное программирование #компиляторы #julia

Вместе с данным постом часто просматривают:

Автор Статьи


Зарегистрирован: 2019-12-10 15:07:06
Баллов опыта: 0
Всего постов на сайте: 0
Всего комментарий на сайте: 0
Dima Manisha

Dima Manisha

Эксперт Wmlog. Профессиональный веб-мастер, SEO-специалист, дизайнер, маркетолог и интернет-предприниматель.