DISCLAIMER
В статье используются генераторы Python, поэтому ожидается, что читатель знаком с ними немного больше, чем просто использование
yield
. Также необходимо отметить, что некоторые примеры упрощены в угоду компактности повествования.
TLDR
Статья предлагает взглянуть на опыт разработки парсер комбинаторов для Python, что вылилось в библиотеку PGPC для разработки парсеров на Python. Библиотека была вдохновлена Parsec.
Особый интерес представляет эмуляция do
-нотации через Python генераторы, отсюда и название библиотеки: Python Generator based Parser Combinator library.
Пример использования библиотеки для разбора фразы "Hello, World!"
:
@topology
def parse_word(word: str):
parsed = []
for c in word:
letter = yield char(c)
parsed.append(letter)
return parsed
text = "Hello, World!"
parser = parse_word(text)
print(parser(Scanner(text, Position())))
Мотивация
Задача разбора текста появилась на заре языков программирования, и за это время для нее был создан строгий математический аппарат. Классический подход к анализу синтаксиса и семантики входной последовательности включает в себя разработку:
лексера - программа, которая разбивает поток на токены или лексемы;
парсера - программа, которая пытается сформировать какую-нибудь конструкцию языка на базе входной последовательности лексем.
Указанный подход подробно расписан в книге с драконом "Компиляторы. Принципы, подходы и инструментарий." за авторством А.В. Ахо, М.С. Лам, Р. Сети, Д.Д. Ульман.
С другой стороны, на парсер можно посмотреть как на функцию, которая:
входной строке ставит в соответствие некий объект,
возвращает остаток строки, который не был разобран:
Для Haskell тип парсера выглядит следующим образом: String -> (T, String)
.
На основании идеи, что парсер - это функция, а функции принято объединять в композиции, появились парсер комбинаторы. Смысл в том, чтобы из небольших парсеров, строить более сложные при помощи композиции парсеров.
Парсер комбинаторы
Парсер комбинаторы - это мощный и гибкий подход к разбору текста, который имеет свои преимущества перед классическими подходами:
выразительный и читабельный код: парсер комбинаторы позволяют составлять парсеры через комбинацию простых парсеров, в результате получается код, который близок к BNF форме (форма Бэкуса-Наура);
композиционность: парсер комбинаторы стремятся к композиции по своей природе, в том смысле, что можно легко собрать большой, более сложный парсер из небольших парсеров. Композиционность позволяет разрабатывать парсеры инкрементально, многократно переиспользуя существующие парсеры для разбора разных структур целевого языка;
потоковый разбор: парсер комбинаторы могут работать в условиях, когда входная последовательность формируется в реальном времени;
независимость от языка программирования: парсер комбинаторы можно реализовать в любом языке, который поддерживает функции высшего порядка;
быстрое прототипирование: парсер комбинаторы отлично подходят для прототипов, что позволяет быстро начать работу над парсером без необходимости сложной настройки рабочего пространства;
тестирование: каждый парсер состоит из более простых парсеров, а значит каждый парсер можно легко протестировать.
Парсер комбинаторы предлагают гибкий и интуитивный подход, который облегчает разработку парсеров и повышает читаемость кода. Они наиболее удобны для работы над DSL языками и различными форматами данных.
Разработка
Разработка парсер генераторов будет осуществляться на Python с активным использованием Type Hints.
Основные абстракции
Общая идея заключается в том, что парсер - это функция из входной последовательности в объект произвольного типа. Так, прочитав входную строку, можно сформировать некоторый объект.
Основными абстракциями являются:
Position
- текущее положение парсера во входной последовательности;Scanner
- класс, параметризованный типом элемента входной последовательности, для абстракции входной последовательностью;Parser
- класс, параметризованный типом разбираемого объекта, представляющий непосредственно парсер. Для использования объектовParser
в контексте функций, в классе переопределен__call__
метод.
Position
Класс Position
позволяет абстрагировать позицию во входной последовательности, на которой остановился предыдущий парсер. Ожидается, что позиция может принимать разные формы, например, при разборе файла необходимо запоминать строку и колонку в дополнение к абсолютному смещению относительно начала файла.
class Position:
def __init__(self, offset: int = 0):
self.__offset = offset
def increase(self, offset: int):
self.__offset += offset
Scanner
Класс Scanner
, параметризованный типом элемента входной последовательности, позволяет абстрагировать входную последовательность. Стандартный интерфейс класса включает в себя методы:
mark
- запомнить текущую позицию, чтобы вернуться к ней позже;reset
- вернуться к последней запомненной позиции;advance_if
- переместить вперед текущую позицию, если текущий элемент входной последовательности проходит проверку по переданному в метод предикату. В результате возвращается либо сам текущий элемент, который прошел проверку, либоNone
class Scanner(Generic[ELEMENT]):
def __init__(self, content: Sequence[ELEMENT], pos: Position):
self.__pos: Position = pos
self.__content: Sequence[ELEMENT] = content
self.__marks: List[Position] = []
def mark(self) -> None:
self.__marks.append(self.__pos)
def reset(self) -> None:
if self.__marks:
self.__pos = self.__marks.pop()
@property
def current(self) -> ELEMENT:
if self.__pos.offset >= len(self.content):
raise EOFError(f"No value at {self.__pos.offset}")
result = self.content[self.pos.offset]
return result
def advance_if(self, predicate: Callable[[ELEMENT], bool]) -> ELEMENT | None:
element = self.current
if predicate(element):
self.__pos.increase(1)
return element
else:
return None
Parser
Класс Parser
, параметризованный типом объекта после разбора, позволяет абстрагировать логику разбора. Парсер должен использоваться в контексте функций, а поэтому в нем переопределен __call__
метод, который принимает Scanner
и возвращает объект указанного типа.
Примеры типов парсеров:
Parser[datetime.date]
- парсер, который умеет извлечь дату из входной последовательности;Parser[int]
- парсер, который умеет извлечь число из входной последовательности.
При создании объекта Parser[V]
в конструктор передается функция Callable[[Scanner], V]
, которая будет использоваться для извлечения данных из входной строки.
Стандартный интерфейс парсера включает в себя метод and_then
, который позволяет склеить два парсера в один. На вход метод and_then
принимает функцию consumer
типа Callable[[V], Parser[U]]
и реализует следующую логику:
объект
Scanner
передается на вход текущему парсеру (self.__parser
), который возвращает объект типаV
;результирующий объект типа
V
текущего парсера попадает на вход функцииconsumer
;функция
consumer
возвращает новый парсер, который является результатом методаand_then
.
def and_then(self, consumer: Callable[[V], Parser[U]]) -> Parser[U]:
def __with_scanner(scanner: Scanner) -> U:
v = self.__parser(scanner)
return consumer(v)(scanner)
return Parser(__with_scanner)
Метод and_then
называется монадическим связывателем в функциональных языках программирования, а в Haskell
и Idris
на его основе строится do
-нотация, которая позволяет записывать функциональный код с коллбэками в виде "плоского" кода, напоминающим императивный код.
Обработка ошибок
Если входная последовательность не может быть разобрана парсером, то генерируется Parser.ParserError
исключение, которое можно обрабатывать стандартными средствами Python.
Примитивные парсеры
Парсер satisfy
Парсер satisfy
является основным примитивом, на базе которого строятся остальные парсеры. На вход он принимает предикат: Callable[[str], bool]
, а на выходе получается парсер Parser[str]
.
Разбор входной строки является успешным для каждого элемента входной строки, для которого предикат возвращает True
. Сам элемент является результатом разбора, а Scanner
переходит к следующему элементу во входной последовательности.
def satisfy(predicate: Callable[[str], bool]) -> Parser[str]:
def __with_scanner(scanner: Scanner) -> str:
result = scanner.advance_if(predicate)
if not result:
raise Parser.ParserError(f"Unexpected '{scanner.current}' at {scanner.pos}")
return result
return Parser(__with_scanner)
Парсер char
Парсер char
принимает на вход символ и проверяет равен ли текущий элемент входной последовательности полученному символу.
def char(c: str) -> Parser[str]:
return satisfy(lambda x: c == x)
Парсер ws
Парсер ws
проверяет, является ли текущий символ пробельным.
def ws() -> Parser[str]:
return satisfy(lambda x: x.isspace())
Парсер opt
Парсер opt
является парсером, который принимает на вход парсер и возвращает парсер. Особенность заключается в том, что, если переданному парсеру не удалось разобрать входную последовательность, то это не является ошибкой. Этот парсер используется для разбора опциональных данных, которые могут отсутствовать во входной последовательности.
def opt(parser: Parser[V]) -> Parser[V | None]:
def __with_scanner(scanner: Scanner) -> V | None:
try:
scanner.mark()
return parser(scanner)
except ValueError:
scanner.reset()
return None
return Parser(__with_scanner)
В случае ошибки необходимо отменить все, что успел сделать переданный парсер, для этой цели перед запуском парсера устанавливается метка для сохранения состояния, а в случае ошибки, сканнер возвращается к сохраненному состоянию.
Парсер many
Парсер many
является парсером, который принимает на вход парсер и возвращает парсер. Особенность заключается в том, что переданный парсер применяется к входной последовательности до тех пор, пока не встретится ошибка. Результатом парсера является список из удачно разобранных объектов.
def many(parser: Parser[V]) -> Parser[List[V]]:
def __with_scanner(scanner: Scanner) -> List[V]:
result = []
try:
while True:
element = parser(scanner)
result.append(element)
except ValueError:
return result
return Parser(__with_scanner)
Парсер alt
Парсер alt
является парсером, который пытается применить к входной последовательности два парсера по очереди и возвращает результат первого удачного парсера. Парсер реализован в виде метода alt
класса Parser
:
class Parser(Generic[V]):
...
def alt(self, parser: 'Parser[U]') -> 'Parser[V | U]':
def __with_scanner(scanner: Scanner) -> V:
try:
scanner.mark()
return self.__parser(scanner)
except ValueError:
scanner.reset()
return parser(scanner)
return Parser(__with_scanner)
Пример использования парсеров
Задача
Разработать парсер номеров телефонов. Требования к формату номеров телефонов:
начинается на
79
или89
,в начале может опционально присутствовать
+
,код (
912
,964
, и т.д) может быть внутри скобок,между цифровыми группами могут быть дефисы,
пробелы отсутствуют.
Решение
Решение будет реализовано с учетом следующих идей:
метод
and_then
позволяет "склеивать" парсеры в один большой парсер: применить первый парсер, за ним второй, третий и т.д.;метод
and_then
принимает лямбда функцию, которая первым аргументом принимает результат предыдущего парсера;результатом лямбда функции будет так же парсер, но у этого нового парсера так же можно вызывать метод
and_then
;продолжать вызов
and_then
можно неограниченно много раз;благодаря замыканию самый последний вызов
and_then
будет иметь доступ ко всем результатам предыдущих парсеров.
Приятный бонус в том, что обработка ошибок происходит внутри самих парсер комбинаторов, что ведет к более или менее чистому коду бизнес-логики.
def phone_number(phone_number: str) -> str:
phone_parser: Parser[List[str]] = opt(char('+')) \
.and_then(lambda _plus: char('7').alt('8')
.and_then(lambda _seven_or_eight: opt(char('('))
.and_then(lambda _: char('9')
.and_then(lambda _nine: digit()
.and_then(lambda code1: digit()
.and_then(lambda code2: opt(char(')'))
.and_then(lambda _: digit()
.and_then(lambda d1: digit()
.and_then(lambda d2: digit()
.and_then(lambda d3: opt(char('-'))
.and_then(lambda _: digit()
.and_then(lambda d4: digit()
.and_then(lambda d5: opt(char('-'))
.and_then(lambda _: digit()
.and_then(lambda d6: digit()
.map(lambda d7: ['+', '7', '(', '9', code1, code2, ')', d1, d2, d3, '-', d4, d5, '-', d6, d7])
)))))))))))))))
result = phone_parser(Scanner(phone_number, Position()))
return "".join(result)
print(phone_number("+7(964)111-11-11"))
print(phone_number("8964222-2222"))
print(phone_number("7(964)333-3333"))
Генераторы
Идея
Код из примера выше, хотя и решает поставленную задачу, имеет много синтаксического шума связанного с особенностями вызова and_then
. В функциональных языках программирования метод and_then
называется монадическим связывателем (monadic bind), и, например, в Haskell или Idris для него реализована do
-нотация, которая позволяет превратить код с большим числом коллбэков в плоский код, похожий по форме на императивный.
В Python отсутствует понятие монадического связывателя, а также нет do
-нотации, но зато есть генераторы. Генераторы предлагают двустороннее общение между клиентом и генератором посредством send
метода:
результат вызова
send
- это значение переданное вyield
внутри генератора,входное значение
send
передается внутрь генератора и является результатомyield
.
Таким образом, можно реализовать работу с парсерами через yield
:
передаваемое значение - парсер,
возвращаемое значение - результат разбора парсера.
Реализация
Для удобства клиента функции, которые будут строить парсеры на базе генераторов, будут обернуты декоратором @topology
.
Декоратор реализует обертку вокруг парсера, в которой:
вызывается оригинальная функция парсера для получения объекта генератора,
для запуска генератора вызывается функция
next
, которая вернет первый переданный вyield
парсер,в парсер передается текущий объект
Scanner
, а на выходе получается результат разбора парсера,в бесконечном цикле в генератор передается результат разбора предыдущего парсера,
исключение
StopIteration
появляется, когда в оригинальной функции вызываетсяreturn
,результат оригинальной функции лежит в поле
value
исключенияStopIteration
,значение из поля
value
исключенияStopIteration
будет результатом итогового парсера.
def topology(parser_builder) -> Callable[..., Parser[V]]:
def wrapper(*args: Any, **kwargs: Any) -> Parser[V]:
gen = parser_builder(*args, **kwargs)
def __with_scanner(scanner: Scanner) -> V:
parser = next(gen)
try:
while True:
result = parser(scanner)
parser = gen.send(result)
except StopIteration as e:
return e.value
return Parser(__with_scanner)
return wrapper
Недостатком декоратора является тот факт, что тип возвращаемого значения поменяется с Generator[Any, Parser[Any], V]
на Parser[V]
, что может привести к ложным срабатываниям mypy
.
Пример
Реализовать задание с номерами телефонов из предыдущего раздела при помощи @topology
декоратора можно следующим образом:
@topology
def phone_number_gen():
yield opt(char('+'))
yield char('7').alt(char('8'))
yield opt(char('('))
yield char('9')
code1 = yield digit()
code2 = yield digit()
yield opt(char(')'))
d1 = yield digit()
d2 = yield digit()
d3 = yield digit()
yield opt(char('-'))
d4 = yield digit()
d5 = yield digit()
yield opt(char('-'))
d6 = yield digit()
d7 = yield digit()
return "".join(['+', '7', '(', '9', code1, code2, ')', d1, d2, d3, '-', d4, d5, '-', d6, d7])
def phone_number_gen_parser(phone_number: str) -> str:
parser: Parser[str] = phone_number_gen()
return parser(Scanner(phone_number, Position()))
print(phone_number_gen_parser("+7(964)111-11-11"))
print(phone_number_gen_parser("8964222-2222"))
print(phone_number_gen_parser("7(964)333-3333"))
Обращение к текущей позиции
Во время разбора может появиться необходимость обратиться к текущей позиции. Текущая позиция хранится в объекте Scanner
, а доступ к объекту Scanner
выполняется во время разбора. Значит получение текущей позиции можно также сделать объектом типа Parser[Position]
, другими словами получение позиции - это парсер:
def position() -> Parser[Position]:
def __with_scanner(scanner: Scanner) -> Position:
return copy(scanner.pos)
return Parser(__with_scanner)
Пример
Использовать парсер position()
можно так же как и любой другой парсер: через and_then
или @topology
.
Пример ниже запоминает текущую позицию в начале процесса парсера и в конце, и оба эти значения в итоге возвращаются клиенту.
@topology
def parse_text(text: str):
start = yield position()
for c in text:
yield char(c)
end = yield position()
return start.offset, end.offset
value = "Hello, World!"
parser_hello_world = parse_text(value)
print(parser_hello_world(Scanner(value, Position())))
Заключение
В статье был продемонстрирован подход к реализации парсер комбинаторов на базе возможностей языка Python, который является достаточно выразительным для разработки парсер комбинаторов. В сочетании с генераторами можно достичь высокой степени абстракции, сохраняя при этом читаемость кода. Опыт разработки парсер комбинаторов привел к созданию библиотеки PGPC - Python Generator based Parser Combinator library. Библиотека только в самом начале своего развития, и в ней отсутствует большая часть традиционных парсер комбинаторов, поэтому приветствуется добавление новых парсеров от сторонних разработчиков. В качестве вдохновения можно посмотреть на библиотеку Parsec.
Источники
https://stepik.org/lesson/42245/step/1 - глава, посвященная парсерам в курсе по Haskell от Д.Н. Москвина
https://github.com/haskell/parsec - библиотека парсер комбинаторов
Parsec
Комментарии (4)
brakerkir
24.07.2023 04:25+1В качестве исследовательского проекта - библиотека выглядит отлично и очень интересно. Интересно было бы посмотреть на работу с большими объемами текстов, как она себя ведет по потреблению памяти.
С практической точки зрения функция парсинга получается многословной с большим кол-вом служебных слов (yield, lambda), которые говорят о протекании внутреннего устройства библиотеки в пользовательский код. Регекспы, действительно, выглядят посимпатичнее. Хотя там нет такой удобной работы с position.
Было бы интересно добавить в статью как раз один-два примера, где PGPC удобнее regexp.
Elordis
24.07.2023 04:25+1Я не бог весть какой эксперт по теории формальных грамматик, но то что получилось как-то очень похоже на parsing expression grammar. (Кстати, советую взглянуть на библиотеку pyparsing, там вместо yield используется, на мой взгляд, гораздо более выразительная перегрузка операторов.)
В частности, насколько я вижу, под капотом имеется возможность безлимитного lookahead и уязвимость к леворекурсивным грамматикам.
phaggi
Ощущение, что сделали свой собственный очень многословный regexp с декоратором и комбинациями.
А, как известно, если у вас проблема и вы применили для ее решения regexp, то у вас две проблемы…
neshkeev Автор
В классической книге с драконом про разработку компиляторов есть глава (3.3.3 во втором издании), посвящённая регулярными выражениям, которые являются реализацией конечного автомата, а также одним из подходов к выделению токенов (лексем) из входной последовательности. Так, любой лексер имеет изоморфное ему регулярное выражение. Тот факт, что Вы видите регулярное выражение в примерах, лишь подчеркивает глубокую связь парсер генераторов с теорией разработки компиляторов.