ДУШКИН Роман Викторович
[email protected] http://roman-dushkin.narod.ru/
ФП 02005-06 01 Ерон Фоккер (Jeroen Fokker)
Взам. инв. № Инв. № дубл.
Подп. и дата
ФУНКЦИОНАЛЬНЫЕ ПАРСЕРЫ
Факультет вычислительной техники, Университет Утрехта
Инв. № подл.
Подп. и дата
[email protected] 2006
Копирова
Формат
АННОТАЦИЯ В неформальном виде изложен метод «список благоприятных исходов», используемый для написания синтаксических анализаторов на функциональном языке с отложенными вычислениями Gofer. Для написания синтаксических анализаторов выражений с вложенными скобками и операторами используется разрабатываемая библиотека функций высшего порядка (известных как «комбинаторы синтаксического анализа»). Метод применён сам к себе для написания синтаксического анализатора грамматик, что позволяет получить синтаксический анализатор для языка, порождаемого грамматикой. Текст сопровождается упражнениями, решения для которых приведены в конце статьи.
Инв. № подл.
Подп. и дата
Взам. инв. № Инв. № дубл.
Подп. и дата
Ключевые слова: парсер, список благоприятных исходов, синтаксический анализ, денотационная семантика.
ФП 02005-06 01 ИзмЛист № докум. Разраб. Душкин Р. Пров. Н. контр. Утв.
Подп. Дата
Лит. Функциональное программирование Копирова
Лист Листов 2 40
Формат
ФП 02005-06 01
СОДЕРЖАНИЕ 1. ВВЕДЕНИЕ ...............................................................................................................................5 2. ТИП «PARSER» .........................................................................................................................7 3. ПРОСТЕЙШИЕ ПАРСЕРЫ.....................................................................................................9 4. КОМБИНАТОРЫ СИНТАКСИЧЕСКОГО АНАЛИЗА......................................................12 5. ПРЕОБРАЗОВАТЕЛИ ПАРСЕРОВ......................................................................................14 6. СОГЛАСОВАНИЕ СКОБОК.................................................................................................16 7. ДОПОЛНИТЕЛЬНЫЕ КОМБИНАТОРЫ СИНТАКСИЧЕСКОГО АНАЛИЗА ..............19 8. АНАЛИЗ НЕОБЯЗАТЕЛЬНЫХ ЭЛЕМЕНТОВ ..................................................................23
10.
ОБОБЩЁННЫЕ ВЫРАЖЕНИЯ ......................................................................................28
11.
ПРИМЕНЕНИЕ К САМОМУ СЕБЕ ................................................................................30
11.1.
Окружение................................................................................................................30
11.2.
Грамматика...............................................................................................................30
11.3.
Деревья разбора .......................................................................................................32
11.4.
Парсеры вместо грамматик ....................................................................................32
11.5.
Генератор парсеров .................................................................................................33
11.6.
Лексические блоки трансляторов ..........................................................................33
12.
БЛАГОДАРНОСТЬ............................................................................................................35
13.
ССЫЛКИ.............................................................................................................................36
14.
РЕШЕНИЯ ДЛЯ УПРАЖНЕНИЙ....................................................................................37
Инв. № подл.
Подп. и дата
Взам. инв. № Инв. № дубл.
Подп. и дата
9. АРИФМЕТИЧЕСКИЕ ВЫРАЖЕНИЯ .................................................................................26
Лист
ФП 02005-06 01 ИзмЛист № докум.
3
Подп. Дата Копирова
Формат
ФП 02005-06 01
СПИСОК СОКРАЩЕНИЙ — форма Бэкуса-Наура
Инв. № подл.
Подп. и дата
Взам. инв. № Инв. № дубл.
Подп. и дата
БНФ
Лист
ФП 02005-06 01 ИзмЛист № докум.
4
Подп. Дата Копирова
Формат
ФП 02005-06 01
1. ВВЕДЕНИЕ Эта статья представляет собой неформальное введение в написание синтаксических анализаторов на ленивом функциональном языке с использованием «комбинаторов синтаксического анализа». Большинство методов было описано Бурджем (Burge) [2], Вадлером (Wadler) [5], Хаттоном (Hutton) [3]. В последнее время в связи с комбинаторами синтаксического анализа [6, 7] стало довольно популярным использование так называемых монад. Однако мы не будем использовать их в данной статье с тем чтобы показать, что нет никакого волшебства в использовании комбинаторов синтаксического анализа. Тем не менее иногда вас будут подталкивать к изучению монад, поскольку они составляют полезное обобщение описанных здесь приёмов.
Подп. и дата
В части 4 представлены первые комбинаторы синтаксического анализа, которые могут быть использованы для комбинирования анализаторов последовательно или параллельно. В части 5 дано определение некоторых функций, позволяющих вычислять значение в процессе синтаксического анализа. Вы можете использовать эти функции для того, что традиционно называется «определением семантических функций»: некоторый полезный смысл может быть связан с синтаксическими структурами. В качестве примера, в части 6 мы строим синтаксический анализатор для строк, состоящих из согласующихся скобок, где вычисляются разные семантические величины: дерево, описывающее структуру, и целое число, показывающее глубину вложенности.
Инв. № подл.
Подп. и дата
Мы начнём с объяснения определения типа функций синтаксического анализа. Используя этот тип, мы сможем построить синтаксические анализаторы для языков неоднозначных грамматик. Далее мы представим некоторые элементарные парсеры, которые могут быть использованы для синтаксического анализа терминальных символов языка.
Взам. инв. № Инв. № дубл.
В данной статье мы придерживаемся конструкций стандартного функционального языка таких как функции высшего порядка, списки и алгебраические типы. Все программы написаны на языке Gofer [4]. В нескольких местах использованы списочные структуры, но они не являются существенными и могут быть легко заменены с помощью функций map, filter и concat. Типовые классы использованы только для перегрузки равенства и арифметических операций.
В частях 7 и 8 мы рассматриваем некоторые новые комбинаторы синтаксического анализа. Не только они сами облегчат жизнь в будущем, но и их определения также являются хорошими примерами использования комбинаторов синтаксического анализа. Реальное приложение — разработанный парсер арифметических выражений — приведено в части 9. Далее приведено обобщение парсера для случая произвольного числа уровней старшинства. Это сделано без программирования приоритетов операторов как целых чисел и мы избежим использования индексов и эллипсисов.
Лист
ФП 02005-06 01 ИзмЛист № докум.
5
Подп. Дата Копирова
Формат
ФП 02005-06 01
Инв. № подл.
Подп. и дата
Взам. инв. № Инв. № дубл.
Подп. и дата
В последней части комбинаторы синтаксического анализа используются для разбора строкового представления грамматики. Как семантическая величина, парсер порождается для языка грамматики, который в свою очередь, может быть применён для входной строки. Таким образом, по существу, мы получаем генератор грамматического разбора.
Лист
ФП 02005-06 01 ИзмЛист № докум.
6
Подп. Дата Копирова
Формат
ФП 02005-06 01
2. ТИП «PARSER» Задачей синтаксического анализа является построение дерева, описывающего структуру заданной строки. В функциональном языке мы можем определить тип данных Tree (дерево). Парсер может быть реализован посредством функции следующего типа: type Parser
=
String -> Tree
Для разбора подструктур парсер может вызвать другие парсеры или рекурсивно самого себя. Этим вызовам необходимо обмениваться не только своими результатами, но и информацией о том, какая часть входной строки осталась необработанной. Поскольку это не может быть сделано при помощи глобальной переменной, необработанная часть входной строки должна быть частью результата работы анализатора. Два результата могут быть сгруппированы в кортеж. Более удачным определением для типа Parser является следующее:
Инв. № подл.
Подп. и дата
Взам. инв. № Инв. № дубл.
Подп. и дата
type Parser
=
String -> (String, Tree)
Тип String определён в стандартной вводной части как список символов. Однако, тип Tree ещё не определён. Тип возвращаемого дерева зависит от приложения. Поэтому лучше превратить тип анализатора в полиморфный тип, параметризируя его типом дерева разбора. Таким образом мы абстрагируемся от типа поступающего дерева разбора, заменяя его переменным типом а: type Parser a =
String -> (String, a)
Например, анализатор, возвращающий структуру типа Oak теперь имеет тип Parser Oak. Для деревьев разбора, представляющих структуру Expression (выражение) мы можем определить тип Expr, делая возможным разработку анализатора, возвращающего выражение: Parser Expr. Другим примером анализатора является функция, распознающая строку цифр и возвращающая число, представленное этой строкой, в качестве «дерева разбора». В этом случае данная функция имеет тип Parser Int. До сих пор мы предполагали, что каждая строка может быть разобрана ровно одним способом. В общем случае это предположение не обязательно верно: одна строка может быть разобрана различными способами или может не существовать ни одного способа разбора строки. В качестве ещё одного усовершенствования определения типа мы допустим, что вместо одного дерева разбора (и связанной с ним необработанной частью строки) парсер возвращает список деревьев. Каждый элемент результата является списком, состоящим из дерева и части строки, оставшейся необработанной после разбора. Следовательно, более подходящим является следующее определение типа Parser: type Parser a =
String -> [(String, a)]
Лист
ФП 02005-06 01 ИзмЛист № докум.
7
Подп. Дата Копирова
Формат
ФП 02005-06 01
Если существует единственный разбор, то результатом работы функциианализатора будет список, состоящий из одного элемента. Если разбор провести невозможно, то результатом будет пустой список. В случае неоднозначной грамматики элементами результирующего списка будут альтернативные варианты разбора.
Парсеры имеющий тип, описываемый нами до сих пор, работают со строками, которые являются списками символов. Однако, это не мешает допустить разбор строк, состоящих из элементов, отличных от символов. Можно предположить ситуацию, когда препроцессор подготавливает список лексем, который затем разбирается. Чтобы учесть этот случай, в качестве последнего усовершенствования типа парсера, мы снова абстрагируемся от типа — от типа элементов входной строки. Обозначив его а, а тип результата b, можно определить тип парсера следующим образом: type Parser a b
=
[a] -> [([a], b)]
или так, если вы предпочитаете кратким идентификаторам более выразительные: type Parser symbol result
=
[symbol] -> [([symbol], result)]
Мы будем использовать это определение типа в оставшейся части данной статьи.
Инв. № подл.
Подп. и дата
Взам. инв. № Инв. № дубл.
Подп. и дата
Этот метод называется «список благоприятных исходов», его описал Вадлер (Wadler) [5]. Он может быть использован в тех случаях, когда возможно применение поиска с возвратом. В учебнике Бёрда (Bird) и Вадлера (Wadler) он используется для решения комбинаторных задач, таких как задача о восьми ферзях [1]. Если необходимо получить только одно решение, а не все возможные, то вы можете взять голову списка благоприятных исходов. Благодаря отложенному вычислению, если требуется только первое значение, то не все элементы списка будут определены, так что потери эффективности не произойдет. Отложенные вычисления позволяют использовать поиск с возвратом для получения первого решения.
Лист
ФП 02005-06 01 ИзмЛист № докум.
8
Подп. Дата Копирова
Формат
ФП 02005-06 01
3. ПРОСТЕЙШИЕ ПАРСЕРЫ Мы начнём с довольно простого, дав определение функции разбора, которая распознаёт символ «а». В этом случае типом символов входной строки будет Char и в качестве «дерева разбора» мы также просто используем Char: symbola symbola [] symbola (x:xs)
| x == ‘a’ | otherwise
:: Parser Char Char = [] = [(xs, ‘a’)] = []
Инв. № подл.
Подп. и дата
Взам. инв. № Инв. № дубл.
Подп. и дата
Сразу же становится очевидным преимущество списка благоприятных исходов, потому что теперь мы можем вернуть пустой список в том случае, когда разбор невозможен (так как входная строка пуста или не начинается с символа «а»). Таким же образом мы можем написать парсеры, распознающие другие символы. Как всегда вместо того, чтобы определять много тесно связанных функций, лучше абстрагироваться от распознаваемого символа, сделав его дополнительным параметром функции. Также функция может оперировать со строками, состоящими из элементов, отличных от символов, таким образом, она может быть применена в приложениях, ориентированных на обработку не только символьных данных. Единственным необходимым условием является то, что символы, которые нужно разобрать могут пройти проверку на равенство. В языке Gofer это обозначается предикатом Eq в типе функции: symbol symbol a [] symbol a (x:xs)
| a == x | otherwise
:: Eq s => s -> Parser s s = [] = [(xs, x)] = []
Как обычно существует несколько способов определить ту же самую функцию. Если вам нравятся списки, то вы возможно предпочтете следующее определение: symbol a [] symbol a (x:xs)
= [] = [(xs, a) | a == x]
В языке Gofer список без генераторов, лишь с условием, определён как пустой или состоящий из одного элемента, в зависимости от условия. Функция symbol — это функция, которая возвращает парсер для заданного символа. Парсер, в свою очередь также является функцией. Вот почему в определении функции symbol появилось два параметра. Теперь мы определим некоторые простейшие парсеры, которые могут выполнять работу, традиционно выполняемую лексическими анализаторами. Например, полезным является парсер, распознающий фиксированную строку символов, такую как «begin» или «end». Мы назовем эту функцию token.
Лист
ФП 02005-06 01 ИзмЛист № докум.
9
Подп. Дата Копирова
Формат
ФП 02005-06 01
token k xs
| k == take n xs = [ (drop n xs, k) ] | otherwise = [] where n = length k
Также как и в случае с функцией symbol, мы параметризировали эту функцию распознаваемой строкой, превращая её таким образом в семейство функций. Конечно же, область применения этой функции не ограничена строками символов. Однако, нам необходима проверка на равенство для типа входной строки; типом token является следующее: token
::
Eq [s] => [s] -> Parser s [s]
Функция token является обобщением функции symbol, поскольку она распознаёт более одного символа.
Инв. № подл.
Подп. и дата
Взам. инв. № Инв. № дубл.
Подп. и дата
Другим обобщением symbol является функция, которая может возвращать различные результаты разбора, в зависимости от входных данных. Функция satisfy является примером такого обобщения. Там, где в функции symbol находится проверка на равенство заданному символу, в satisfy может быть указан произвольный предикат. Функция satisfy, таким образом, опять же является семейством функций-анализаторов. Здесь приведено её определение с использованием списочной нотации: satisfy satisfy p [] satisfy p (x:xs)
:: = =
(s -> Bool) -> Parser s s [] [(xs, x) | p x]
Упражнение 1. Поскольку функция satisfty является обобщением функции symbol, функция symbol может быть определена как частный случай satisfy. Как это можно сделать? В книгах по теории грамматик пустая строка часто называется «epsilon». Следуя этой традиции, мы определим функцию epsilon, «разбирающую» пустую строку. Она не принимает ничего на вход и соответственно всегда возвращает пустое дерево разбора и неизменённые входные данные. В качестве результирующей величины может быть использован кортеж, состоящий из 0 элементов: () является единственным значением типа (). epsilon epsilon xs
:: =
Parser s ( ) [(xs, ())]
Её разновидностью является функция succeed, которая также не принимает ничего на вход, но всегда возвращает данное, фиксированное значение (или «дерево разбора», если можно назвать результат обработки нуля символов деревом разбора...): succeed succeed v xs
:: =
r -> Parser s r [(xs, v)]
Конечно же, функция epsilon может быть определена через функцию succeed:
Лист
ФП 02005-06 01 ИзмЛист № докум.
10
Подп. Дата Копирова
Формат
ФП 02005-06 01
epsilon epsilon
:: =
Parser s () succeed ()
Двойственной по отношению к функции succeed является функция fail, которая не распознаёт ни один символ входной строки. Она всегда возвращает пустой список благоприятных исходов: fail fail xs
:: =
Parser s r []
Инв. № подл.
Подп. и дата
Взам. инв. № Инв. № дубл.
Подп. и дата
Позже нам понадобится этот тривиальный парсер в качестве нейтрального элемента для функции foldr. Обратите внимание на отличие от функции epsilon, которая имеет один элемент в своём списке благоприятных исходов (хотя и пустой).
Лист
ФП 02005-06 01 ИзмЛист № докум.
11
Подп. Дата Копирова
Формат
ФП 02005-06 01
4. КОМБИНАТОРЫ СИНТАКСИЧЕСКОГО АНАЛИЗА Используя элементарные парсеры из предыдущей части, можно сконструировать парсеры для терминальных символов грамматики. Более интересными являются синтаксические анализаторы для нетерминальных символов. Конечно, их можно написать вручную, но более удобно сконструировать их путём частичной параметризации функций высшего порядка. Важными операциями над парсерами являются последовательное и параллельное соединение. Мы создадим для них две функции, которые для удобства обозначения определены как операторы: для последовательного соединения и для параллельного соединения. Приоритеты этих операторов определены таким образом, чтобы минимизировать использование скобок в практических ситуациях: infixr 6 infixr 4
Инв. № подл.
Подп. и дата
Взам. инв. № Инв. № дубл.
Подп. и дата
Оба оператора имеют два парсера в качестве параметров, и возвращают парсер в качестве результата. Снова соединяя результат с другими парсерами, можно создать даже ещё более сложные парсеры. В определениях, приведённых ниже, функции действуют на парсеры p1 и p2. Кроме параметров p1 и p2, функция действует на строку, которую можно рассматривать как строку, разбираемую парсером, являющимся результатом комбинирования p1 и p2. Для начала напишем определение оператора . Для последовательного соединения сначала к входным данным должен быть применён р1. После этого р2 применяется к оставшейся части строки, указанной в результате. Поскольку р1 возвращает список решений, мы используем списочную запись, согласно которой р2 применяется ко всем остаточным строкам в списке: () (p1 p2) xs
:: Parser s a -> Parser s b -> Parser s (a, b) = [(xs2, (v1, v2)) | (xs1, v1) Parser s a p1 xs ++ p2 xs
Лист
ФП 02005-06 01 ИзмЛист № докум.
12
Подп. Дата Копирова
Формат
ФП 02005-06 01
Благодаря использованию списка благоприятных исходов и p1 и p2 возвращают списки возможных вариантов разбора. Для того, чтобы получить все возможные благоприятные исходы, полученные путём выбора из p1 и p2, нам нужно лишь конкатенировать эти два списка. Упражнение 2. Определяя приоритет оператора , с использованием ключевого слова infixr мы также указали, что оператор является правоассоциативным. Почему это более удачное решение по сравнению с левой ассоциативностью? Результатом применения комбинаторов синтаксического анализа является снова парсер, который может быть соединён с другими парсерами. Деревья разбора, получаемые в результате, представляют собой сложные кортежи, отражающие способ, которым были соединены парсеры. Таким образом, термин «дерево разбора» является действительно подходящим. Например парсер р, где p = symbol 'a' symbol 'b' symbol 'c'
Несмотря на то, что кортежи ясно описывают структуру дерева разбора, существует трудность, заключающаяся в том, что мы не можем соединять парсеры случайным образом. Например, мы не можем последовательно соединить парсер p, описанный ранее и symbol ‘a’, поскольку последний имеет тип Parser Char Char, а параллельно можно соединять только парсеры одного типа. Хуже того, невозможно рекурсивно соединить парсер с самим собой, так как это приведёт к возникновению типов, представляющих собой бесконечно вложенные кортежи. Нам необходимо иметь способ изменения структуры дерева разбора, возвращаемого данным парсером.
Инв. № подл.
Подп. и дата
Взам. инв. № Инв. № дубл.
Подп. и дата
имеет тип Parser Char (Char, (Char, Char)).
Лист
ФП 02005-06 01 ИзмЛист № докум.
13
Подп. Дата Копирова
Формат
ФП 02005-06 01
5. ПРЕОБРАЗОВАТЕЛИ ПАРСЕРОВ Помимо операторов и , которые комбинируют парсеры, мы можем определить некоторые функции, которые модифицируют или преобразуют существующие парсеры. Мы создадим три из них: sp позволяет данному парсеру игнорировать начальные пробелы, just преобразует парсер таким образом, что он требует, чтобы остаток строки был пустым, и Parser Char a p . dropWhile (== ' ')
или если вы предпочитаете функциональные определения:
Инв. № подл.
Подп. и дата
Взам. инв. № Инв. № дубл.
Подп. и дата
sp = (. dropWhile (== ' '))
Вторым преобразователем парсеров является just. Для данного парсера р он возвращает парсер, который делает то же, что и р, но также гарантирует, что остаток строки будет пустым. Это достигается путём применения фильтра к списку благоприятных исходов, выделяющего из него пустые остаточные строки. Поскольку остаток строки является первым элементом в списке, функция может быть определена следующим образом: just just p
:: =
Parser s a -> Parser s a filter (null.fst) . p
Упражнение 3. Дайте определение функции just, используя списки вместо функции filter. Наиболее важным преобразователем парсеров является тот, который выполняет преобразование парсера, изменяющее возвращаемое им значение. Мы определим такой преобразователь как оператор Parser s (a->a->a) -> Parser s a p many (s p) Parser s (a->a->a) -> Parser s a p many (s p)