В статье используются генераторы Python, поэтому ожидается, что читатель знаком с ними немного больше, чем просто использование yield. Также необходимо отметить, что некоторые примеры упрощены в угоду компактности повествования. Статья предлагает взглянуть на опыт разработки парсер комбинаторов для Python, что вылилось в библиотеку PGPC для разработки парсеров на Python. Библиотека была вдохновлена Parsec. Особый интерес представляет эмуляция do-нотации через Python генераторы, отсюда и название библиотеки: Python Generator based Parser Combinator library. Пример использования библиотеки для разбора фразы "Hello, World!": Задача разбора текста появилась на заре языков программирования, и за это время для нее был создан строгий математический аппарат. Классический подход к анализу синтаксиса и семантики входной последовательности включает в себя разработку: лексера - программа, которая разбивает поток на токены или лексемы; парсера - программа, которая пытается сформировать какую-нибудь конструкцию языка на базе входной последовательности лексем. Указанный подход подробно расписан в книге с драконом "Компиляторы. Принципы, подходы и инструментарий." за авторством А.В. Ахо, М.С. Лам, Р. Сети, Д.Д. Ульман. С другой стороны, на парсер можно посмотреть как на функцию, которая: входной строке ставит в соответствие некий объект, возвращает остаток строки, который не был разобран: Для Haskell тип парсера выглядит следующим образом: String -> (T, String). На основании идеи, что парсер - это функция, а функции принято объединять в композиции, появились парсер комбинаторы. Смысл в том, чтобы из небольших парсеров, строить более сложные при помощи композиции парсеров. Парсер комбинаторы - это мощный и гибкий подход к разбору текста, который имеет свои преимущества перед классическими подходами: выразительный и читабельный код: парсер комбинаторы позволяют составлять парсеры через комбинацию простых парсеров, в результате получается код, который близок к BNF форме (форма Бэкуса-Наура); композиционность: парсер комбинаторы стремятся к композиции по своей природе, в том смысле, что можно легко собрать большой, более сложный парсер из небольших парсеров. Композиционность позволяет разрабатывать парсеры инкрементально, многократно переиспользуя существующие парсеры для разбора разных структур целевого языка; потоковый разбор: парсер комбинаторы могут работать в условиях, когда входная последовательность формируется в реальном времени; независимость от языка программирования: парсер комбинаторы можно реализовать в любом языке, который поддерживает функции высшего порядка; быстрое прототипирование: парсер комбинаторы отлично подходят для прототипов, что позволяет быстро начать работу над парсером без необходимости сложной настройки рабочего пространства; тестирование: каждый парсер состоит из более простых парсеров, а значит каждый парсер можно легко протестировать. Парсер комбинаторы предлагают гибкий и интуитивный подход, который облегчает разработку парсеров и повышает читаемость кода. Они наиболее удобны для работы над DSL языками и различными форматами данных. Разработка парсер генераторов будет осуществляться на Python с активным использованием Type Hints. Общая идея заключается в том, что парсер - это функция из входной последовательности в объект произвольного типа. Так, прочитав входную строку, можно сформировать некоторый объект. Основными абстракциями являются: Position - текущее положение парсера во входной последовательности; Scanner - класс, параметризованный типом элемента входной последовательности, для абстракции входной последовательностью; Parser - класс, параметризованный типом разбираемого объекта, представляющий непосредственно парсер. Для использования объектов Parser в контексте функций, в классе переопределен __call__ метод. Класс Position позволяет абстрагировать позицию во входной последовательности, на которой остановился предыдущий парсер. Ожидается, что позиция может принимать разные формы, например, при разборе файла необходимо запоминать строку и колонку в дополнение к абсолютному смещению относительно начала файла. Класс Scanner, параметризованный типом элемента входной последовательности, позволяет абстрагировать входную последовательность. Стандартный интерфейс класса включает в себя методы: mark - запомнить текущую позицию, чтобы вернуться к ней позже; reset - вернуться к последней запомненной позиции; advance_if - переместить вперед текущую позицию, если текущий элемент входной последовательности проходит проверку по переданному в метод предикату. В результате возвращается либо сам текущий элемент, который прошел проверку, либо None Класс 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. Метод and_then называется монадическим связывателем в функциональных языках программирования, а в Haskell и Idris на его основе строится do-нотация, которая позволяет записывать функциональный код с коллбэками в виде "плоского" кода, напоминающим императивный код. Если входная последовательность не может быть разобрана парсером, то генерируется Parser.ParserError исключение, которое можно обрабатывать стандартными средствами Python. Парсер satisfy является основным примитивом, на базе которого строятся остальные парсеры. На вход он принимает предикат: Callable[[str], bool], а на выходе получается парсер Parser[str]. Разбор входной строки является успешным для каждого элемента входной строки, для которого предикат возвращает True. Сам элемент является результатом разбора, а Scanner переходит к следующему элементу во входной последовательности. Парсер char принимает на вход символ и проверяет равен ли текущий элемент входной последовательности полученному символу. Парсер ws проверяет, является ли текущий символ пробельным. Парсер opt является парсером, который принимает на вход парсер и возвращает парсер. Особенность заключается в том, что, если переданному парсеру не удалось разобрать входную последовательность, то это не является ошибкой. Этот парсер используется для разбора опциональных данных, которые могут отсутствовать во входной последовательности. В случае ошибки необходимо отменить все, что успел сделать переданный парсер, для этой цели перед запуском парсера устанавливается метка для сохранения состояния, а в случае ошибки, сканнер возвращается к сохраненному состоянию. Парсер many является парсером, который принимает на вход парсер и возвращает парсер. Особенность заключается в том, что переданный парсер применяется к входной последовательности до тех пор, пока не встретится ошибка. Результатом парсера является список из удачно разобранных объектов. Парсер alt является парсером, который пытается применить к входной последовательности два парсера по очереди и возвращает результат первого удачного парсера. Парсер реализован в виде метода alt класса Parser: Разработать парсер номеров телефонов. Требования к формату номеров телефонов: начинается на 79 или 89, в начале может опционально присутствовать +, код (912, 964, и т.д) может быть внутри скобок, между цифровыми группами могут быть дефисы, пробелы отсутствуют. Решение будет реализовано с учетом следующих идей: метод and_then позволяет "склеивать" парсеры в один большой парсер: применить первый парсер, за ним второй, третий и т.д.; метод and_then принимает лямбда функцию, которая первым аргументом принимает результат предыдущего парсера; результатом лямбда функции будет так же парсер, но у этого нового парсера так же можно вызывать метод and_then; продолжать вызов and_then можно неограниченно много раз; благодаря замыканию самый последний вызов and_then будет иметь доступ ко всем результатам предыдущих парсеров. Приятный бонус в том, что обработка ошибок происходит внутри самих парсер комбинаторов, что ведет к более или менее чистому коду бизнес-логики. Код из примера выше, хотя и решает поставленную задачу, имеет много синтаксического шума связанного с особенностями вызова 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 будет результатом итогового парсера. Недостатком декоратора является тот факт, что тип возвращаемого значения поменяется с Generator[Any, Parser[Any], V] на Parser[V], что может привести к ложным срабатываниям mypy. Реализовать задание с номерами телефонов из предыдущего раздела при помощи @topology декоратора можно следующим образом: Во время разбора может появиться необходимость обратиться к текущей позиции. Текущая позиция хранится в объекте Scanner, а доступ к объекту Scanner выполняется во время разбора. Значит получение текущей позиции можно также сделать объектом типа Parser[Position], другими словами получение позиции - это парсер: Использовать парсер position() можно так же как и любой другой парсер: через and_then или @topology. Пример ниже запоминает текущую позицию в начале процесса парсера и в конце, и оба эти значения в итоге возвращаются клиенту. В статье был продемонстрирован подход к реализации парсер комбинаторов на базе возможностей языка Python, который является достаточно выразительным для разработки парсер комбинаторов. В сочетании с генераторами можно достичь высокой степени абстракции, сохраняя при этом читаемость кода. Опыт разработки парсер комбинаторов привел к созданию библиотеки PGPC - Python Generator based Parser Combinator library. Библиотека только в самом начале своего развития, и в ней отсутствует большая часть традиционных парсер комбинаторов, поэтому приветствуется добавление новых парсеров от сторонних разработчиков. В качестве вдохновения можно посмотреть на библиотеку Parsec. https://stepik.org/lesson/42245/step/1 - глава, посвященная парсерам в курсе по Haskell от Д.Н. Москвина https://github.com/haskell/parsec - библиотека парсер комбинаторов Parsec