Вы можете писать от руки или анализировать на компьютере
Обновлено: 21.11.2024
Синтаксический анализ данных — это процесс получения данных в одном формате и преобразования их в другой формат. Вы найдете синтаксические анализаторы, используемые повсюду. Они обычно используются в компиляторах, когда нам нужно проанализировать компьютерный код и сгенерировать машинный код.
Это происходит постоянно, когда разработчики пишут код, который запускается на оборудовании. Парсеры также присутствуют в механизмах SQL. Механизмы SQL анализируют запрос SQL, выполняют его и возвращают результаты.
В случае парсинга веб-страниц это обычно происходит после извлечения данных с веб-страницы с помощью парсинга веб-страниц. После того, как вы собрали данные из Интернета, следующим шагом будет сделать их более читабельными и удобными для анализа, чтобы ваша команда могла эффективно использовать результаты.
Хороший анализатор данных не ограничен определенными форматами. Вы должны иметь возможность вводить данные любого типа и выводить данные другого типа. Это может означать преобразование необработанного HTML в объект JSON или они могут взять данные, извлеченные из страниц, отображаемых с помощью JavaScript, и преобразовать их в полноценный CSV-файл.
Синтаксические анализаторы активно используются при очистке веб-страниц, потому что получаемый нами необработанный HTML-код нелегко понять. Нам нужно преобразовать данные в формат, понятный человеку. Это может означать создание отчетов из строк HTML или создание таблиц для отображения наиболее релевантной информации.
Несмотря на то, что синтаксические анализаторы можно использовать по-разному, в этой статье блога основное внимание будет уделено синтаксическому анализу данных для извлечения веб-страниц, поскольку это онлайн-операция, с которой ежедневно сталкиваются тысячи людей.
Как создать анализатор данных
Независимо от того, какой тип синтаксического анализатора данных вы выберете, хороший синтаксический анализатор определит, какая информация из строки HTML является полезной, и основываясь на заранее определенных правилах. Обычно процесс синтаксического анализа состоит из двух этапов: лексического анализа и синтаксического анализа.
Лексический анализ — это первый шаг в анализе данных. По сути, он создает токены из последовательности символов, которые поступают в синтаксический анализатор в виде строки неструктурированных данных, таких как HTML. Синтаксический анализатор создает токены, используя лексические единицы, такие как ключевые слова и разделители. Он также игнорирует ненужную информацию, такую как пробелы и комментарии.
После того как синтаксический анализатор разделил данные между лексическими единицами и нерелевантной информацией, он отбрасывает всю нерелевантную информацию и передает релевантную информацию на следующий шаг.
Следующая часть процесса анализа данных — синтаксический анализ. Здесь происходит построение дерева синтаксического анализа. Синтаксический анализатор берет соответствующие токены из шага лексического анализа и упорядочивает их в виде дерева. Любые дополнительные нерелевантные токены, такие как точки с запятой и фигурные скобки, добавляются во вложенную структуру дерева.
После завершения анализа дерева у вас остается соответствующая информация в структурированном формате, который можно сохранить в файле любого типа. Существует несколько различных способов создания анализатора данных, от создания его программно до использования существующих инструментов. Это зависит от потребностей вашего бизнеса, количества времени, бюджета и некоторых других факторов.
Для начала давайте взглянем на библиотеки синтаксического анализа HTML.
Библиотеки синтаксического анализа HTML
Библиотеки синтаксического анализа HTML отлично подходят для автоматизации процесса веб-скрейпинга. Вы можете подключить многие из этих библиотек к парсеру через вызовы API и анализировать данные по мере их получения.
Вот несколько популярных библиотек для синтаксического анализа HTML:
Scrapy или BeautifulSoup
Это библиотеки, написанные на Python. BeautifulSoup — это библиотека Python для извлечения данных из файлов HTML и XML. Scrapy — это парсер данных, который также можно использовать для парсинга веб-страниц. Когда дело доходит до парсинга веб-страниц с помощью Python, доступно множество вариантов, и все зависит от того, насколько практичным вы хотите быть.
Ура
Если вы привыкли работать с Javascript, вам подойдет Cheerio. Он анализирует разметку и предоставляет API для управления результирующей структурой данных. Вы также можете использовать Puppeteer. Это можно использовать для создания снимков экрана и PDF-файлов определенных страниц, которые можно сохранить и в дальнейшем анализировать с помощью других инструментов. Существует множество других веб-скраперов и веб-парсеров на основе JavaScript.
JSoup
Для тех, кто работает в основном с Java, также есть варианты. JSoup - один из вариантов. Он позволяет вам работать с реальным HTML через свой API для извлечения URL-адресов, извлечения и обработки данных. Он действует как веб-скребок и веб-парсер. Найти другие варианты Java с открытым исходным кодом может быть непросто, но на них определенно стоит обратить внимание.
Нокогири
Есть вариант и для Ruby. Взгляните на Нокогири. Это позволяет вам работать с HTML и HTML с Ruby. У него есть API, аналогичный другим пакетам на других языках, который позволяет вам запрашивать данные, которые вы извлекли из веб-скрейпинга. Он добавляет дополнительный уровень безопасности, поскольку по умолчанию считает все документы ненадежными.Синтаксический анализ данных в Ruby может быть сложным, так как бывает сложнее найти драгоценные камни, с которыми можно работать.
Регулярное выражение
Теперь, когда у вас есть представление о том, какие библиотеки доступны для веб-скрапинга и синтаксического анализа данных, давайте рассмотрим распространенную проблему с синтаксическим анализом HTML — регулярными выражениями. Иногда данные плохо отформатированы внутри тега HTML, и нам нужно использовать регулярные выражения для извлечения нужных данных.
Вы можете создавать регулярные выражения, чтобы получить именно то, что вам нужно, из сложных данных. Такие инструменты, как regex101, могут быть простым способом проверить, правильно ли вы ориентируетесь на данные или нет. Например, вы можете захотеть получить данные конкретно из всех тегов абзаца на веб-странице. Это регулярное выражение может выглядеть примерно так:
Синтаксис регулярных выражений немного меняется в зависимости от того, с каким языком программирования вы работаете. В большинстве случаев, если вы работаете с одной из перечисленных выше библиотек или чем-то подобным, вам не придется беспокоиться о создании регулярных выражений.
Если вы не заинтересованы в использовании одной из этих библиотек, вы можете рассмотреть возможность создания собственного синтаксического анализатора. Это может быть непросто, но потенциально стоит затраченных усилий, если вы работаете с чрезвычайно сложными структурами данных.
Создание собственного парсера
Если вам нужен полный контроль над анализом ваших данных, создание собственного инструмента может стать отличным вариантом. Вот несколько моментов, на которые следует обратить внимание, прежде чем создавать собственный синтаксический анализатор.
Собственный синтаксический анализатор можно написать на любом языке программирования, который вам нравится. Вы можете сделать его совместимым с другими используемыми вами инструментами, такими как веб-сканер или веб-скребок, не беспокоясь о проблемах интеграции.
В некоторых случаях создание собственного инструмента может быть рентабельным. Если у вас уже есть команда разработчиков, это может быть не слишком сложной задачей для них.
У вас есть полный контроль над всем. Если вы хотите настроить таргетинг на определенные теги или ключевые слова, вы можете это сделать. Каждый раз, когда вы обновляете свою стратегию, у вас не возникнет проблем с обновлением анализатора данных.
С другой стороны, создание собственного синтаксического анализатора сопряжено с рядом проблем.
HTML-код страниц постоянно меняется. Это может стать проблемой обслуживания для ваших разработчиков. Если вы не предвидите, что ваш инструмент синтаксического анализа станет очень важным для вашего бизнеса, отнимать время от разработки продукта может быть неэффективно.
Создание и поддержка собственного анализатора данных может быть дорогостоящим. Если у вас нет команды разработчиков, вы можете заключить контракт на работу, но это может привести к поэтапным счетам, основанным на почасовых ставках разработчиков. Кроме того, приходится платить за повышение квалификации разработчиков, которые не знакомы с проектом, поскольку они выясняют, как все работает.
Вам также потребуется купить, построить и обслуживать сервер, на котором будет размещен ваш пользовательский парсер. Он должен быть достаточно быстрым, чтобы обрабатывать все данные, которые вы отправляете через него, иначе вы можете столкнуться с проблемами последовательного анализа данных. Вам также необходимо убедиться, что сервер остается в безопасности, поскольку вы можете анализировать конфиденциальные данные.
Наличие такого уровня контроля может быть полезным, если синтаксический анализ данных является важной частью вашего бизнеса, в противном случае это может добавить больше сложности, чем необходимо. Есть множество причин, по которым вам нужен пользовательский синтаксический анализатор, просто убедитесь, что он стоит вложений по сравнению с использованием существующего инструмента.
Существует множество практических причин, по которым люди хотят анализировать метаданные схемы. Например, компании могут захотеть проанализировать схему продукта электронной коммерции, чтобы найти обновленные цены или описания. Журналисты могли анализировать определенные веб-страницы, чтобы получить информацию для своих новостных статей. Существуют также веб-сайты, на которых могут собираться такие данные, как рецепты, практические руководства и технические статьи.
Схема бывает разных форматов. Вы услышите о JSON-LD, RDFa и схеме микроданных. Это форматы, которые вы, вероятно, будете анализировать.
Консорциум World Wide Web (W3C) рекомендует RDFa (структура описания ресурсов в атрибутах). Он используется для встраивания операторов RDF в XML и HTML. Одно большое различие между этим и другими типами схем заключается в том, что RDFa определяет только метасинтаксис для семантической маркировки.
Все эти типы схем легко анализируются с помощью ряда инструментов на разных языках. Есть библиотека от ScrapingHub, еще одна от RDFLib.
Существующие инструменты анализа данных
Мы рассмотрели ряд существующих инструментов, но есть и другие отличные сервисы. Например, API поиска Google ScrapingBee. Этот инструмент позволяет вам очищать результаты поиска в режиме реального времени, не беспокоясь о времени безотказной работы сервера или обслуживании кода. Вам нужен только ключ API и поисковый запрос, чтобы начать сбор и анализ веб-данных.
Существует множество других инструментов веб-скрейпинга, таких как JSoup, Puppeteer, Cheerio или BeautifulSoup.
Вот несколько преимуществ покупки веб-парсера:
- Использование существующего инструмента требует минимального обслуживания.
- Вам не нужно тратить много времени на разработку и настройку.
- У вас будет доступ к службе поддержки, специально обученной использованию и устранению неполадок этого конкретного инструмента.
Некоторые из недостатков покупки веб-парсера включают:
- У вас не будет точного контроля над всем, как ваш парсер обрабатывает данные. Хотя у вас будет несколько вариантов на выбор.
- Это могут быть большие первоначальные затраты.
- Решение проблем с сервером не является чем-то, о чем вам нужно беспокоиться.
Заключительные мысли
Синтаксический анализ данных — это обычная задача, связанная с самыми разными задачами: от исследования рынка до сбора данных для процессов машинного обучения. После того как вы собрали данные, используя сочетание веб-сканирования и веб-скрапинга, они, скорее всего, будут в неструктурированном формате. Из-за этого сложно извлечь из него глубокий смысл.
Использование синтаксического анализатора поможет вам преобразовать эти данные в любой формат, будь то JSON, CSV или любое другое хранилище данных. Вы можете создать свой собственный синтаксический анализатор для преобразования данных в строго заданный формат или использовать существующий инструмент для быстрого получения данных. Выберите вариант, который принесет наибольшую пользу вашему бизнесу.
Кевин 10 лет работал в сфере парсинга веб-страниц, прежде чем стал одним из основателей ScrapingBee. Он также является автором руководства Java Web Scraping Handbook.
Я пишу компилятор и создал анализатор с рекурсивным спуском для анализа синтаксиса. Я хотел бы улучшить систему типов для поддержки функций как допустимого типа переменной, но я создаю язык со статической типизацией, и мой желаемый синтаксис для типа функции делает грамматику временно* неоднозначной, пока она не будет разрешена. Я бы предпочел не использовать генератор синтаксических анализаторов, хотя я знаю, что Elkhound был бы вариантом. Я знаю, что могу изменить грамматику, чтобы она анализировалась за фиксированное количество шагов, но меня больше интересует, как реализовать это вручную.
Я предпринял несколько попыток выяснить высокоуровневый поток управления, но каждый раз, когда я это делаю, я в конечном итоге забываю об одном аспекте сложности, и мой синтаксический анализатор становится невозможным в использовании и обслуживании.
Существует два уровня неоднозначности: оператор может быть выражением, определением переменной или объявлением функции, а объявление функции может иметь сложный возвращаемый тип.
Подмножество грамматики, демонстрирующее двусмысленность:
Мой основной подход заключался в написании функций, которые могли бы анализировать однозначные компоненты этого подмножества грамматики, а затем распределять более сложный поток управления по отдельным функциональным блокам для захвата состояния в вызовах функций. Это оказалось безуспешным, какие методы можно использовать для решения такого рода проблем?
*Возможно, для этого есть более подходящее слово
$\begingroup$ Я предпочитаю понимать концепции. Я бегу вслепую, и никакие сложные структуры управления не помогают. $\endgroup$
$\begingroup$ Хорошо. Почему вам нужно, чтобы ваша грамматика была двусмысленной? Как правило, это плохая идея (для языков программирования). $\endgroup$
$\begingroup$ @Raphael Итак, я полностью одобряю эту идею, хотя причины, по которым ОП делает это, еще предстоит полностью понять. Что я считаю ужасной идеей, так это делать это вручную, так как это, вероятно, больше работы, чем использование генератора, и очень подвержено ошибкам и трудностям в обслуживании. Общий анализ CF имеет свою собственную технологию, и он может быть очень болезненным, если вы не полностью овладеете его концепциями. Также есть хорошие и плохие способы сделать это. Но прежде чем углубляться в технические вопросы, мне нужно понять, чего на самом деле добивается ОП, и его вопрос очень длинный и технический. $\endgroup$
$\begingroup$ @skeggse: если у вас есть недвусмысленная грамматика, для анализа которой требуется неограниченный просмотр вперед, то это то, что у вас есть; вам не нужно больше словарный запас, чем это. Была проделана большая работа по созданию эффективных алгоритмов разбора таких языков, но я не знаю ни одного подхода, который пытался бы скомпилировать грамматику в компьютерную программу. Мой вопрос: почему вы не хотите использовать это исследование и использовать генератор синтаксических анализаторов (который создаст какой-то конечный автомат)? Неограниченный просмотр вперед во многих случаях может быть эффективно проанализирован. Вы пробовали синтаксический анализатор GLR от bison? $\endgroup$
3 ответа 3
Этот ответ основан на предоставленной вами дополнительной информации о том, что ваша проблема может быть скорее недетерминированной, чем двусмысленной, и что, по вашему мнению, вы могли бы решить ее, если бы у вас был какой-то прогноз на сколь угодно далеком расстоянии.
Поскольку этот случай допускает совершенно другой ответ, я думаю, что будет понятнее ответить на него отдельно от моего предыдущего ответа.
Если все, что вам нужно, это неограниченный просмотр вперед, вам следует выяснить, может ли синтаксический анализ RLR выполнить эту работу за вас. Идея RLR заключается в том, что вы используете конечный автомат для получения информации, которая может быть сколь угодно далекой. Я не уверен во всех деталях техники в ее общем виде, но она была реализована в некоторых свободно доступных генераторах синтаксических анализаторов.
Учитывая, что вы настаиваете на анализаторе, написанном от руки, возможно, вы сможете применить его вручную к своей собственной грамматике.
Лучший способ объяснить это на хорошо известном примере, потому что многие языки на самом деле нуждаются в неограниченном заглядывании вперед, но решают проблему скрытно, чтобы вы ее не заметили.
Обычно ковер называют лексическим анализом.
Предположим, что грамматика включает следующие правила:
где id — любой идентификатор, а foo и bar — ключевые слова языка.
и вам нужно разобрать . xxxx yyy very_long_identifier foo . .
После сокращения xxxx до X и yyy до Y вам нужно решить, следует ли уменьшать X Y до U или, скорее, сканировать приходящий идентификатор. Совершенно очевидно, что это зависит от ключевого слова foo или bar, следующего за идентификатором. С этим можно легко справиться с помощью ограниченного просмотра вперед, поскольку решающее ключевое слово находится всего в двух токенах, когда нужно принять решение.
Но это предполагает, что идентификатор рассматривается как одно ключевое слово.
Некоторые люди утверждают, что, поскольку языки CF закрыты при замещении регулярными наборами, лексический анализ на самом деле не важен, и синтаксические анализаторы CF должны быть в состоянии обрабатывать его с остальным синтаксисом CF. Это верно для общего синтаксического анализатора CF (хотя и спорно с практической точки зрения). Но это требует осторожности и адаптации для классической технологии детерминированного синтаксического анализа, поскольку теоретически неограниченная длина идентификаторов создает проблемы с опережением.
В приведенном выше примере, если идентификатор не был распознан как таковой лексическим анализатором, то это просто произвольно длинная последовательность буквенно-цифровых токенов, так что решающие ключевые слова, foo и bar находятся неограниченно далеко. р>
Это показывает, что распознавание промежуточной структуры через проход с помощью простого конечного устройства может устранить такую проблему, как неограниченный просмотр вперед, по крайней мере, в некоторых случаях, а это все, что вам нужно, если вы разрабатываете конкретный синтаксический анализатор вручную.< /p>
Если вам нужно проанализировать именно свою грамматику, чтобы увидеть, что можно сделать, но вышеизложенное предлагает следующий подход:
В худшем случае я не могу точно знать, представляет ли данная строка токенов оператор функции, выражение или оператор определения, до конца любой из этих структур.
Итак, вопрос (до некоторых, возможно, поддающихся изменению деталей): можете ли вы распознать, что последовательность символов является одной из этих двух конструкций вашего языка, и определить, какая из них (без создания дерева синтаксического анализа) с помощью простого конечный автомат (FSA)? Если ответ положительный, то всякий раз, когда вы сталкиваетесь с началом такой последовательности, вы активируете соответствующий FSA, чтобы быстро определить тип последовательности, а затем используете результат, чтобы принять решение о синтаксическом анализе.
$\begingroup$ Хм, звучит многообещающе. Я соединяю свой парсер и лексер, значит ли это, что мне нужно будет буферизовать кучу токенов для FSA, чтобы я мог их проанализировать, когда он будет готов? $\endgroup$
$\begingroup$ @skeggse Да. Поскольку вы не можете создать детерминированный КПК для анализа в соответствии с этой грамматикой, вам придется рассмотреть что-то, что не является КПК, потеряв некоторые свойства КПК, такие как его поведение в сети. Таким образом, вам придется сканировать ввод, чтобы найти информацию, и сохранить ее в буфере для сканирования вашим парсером. Но сначала вы должны убедиться, что сколь угодно длинная структура, отделяющая голову синтаксического анализатора от соответствующего токена, может быть точно идентифицирована, даже если она фактически не анализируется. $\endgroup$
$\begingroup$ В итоге я остановился на этом ответе, отчасти потому, что я основывал свое окончательное решение на вашем ответе, а отчасти потому, что ваш ответ напрямую отвечал на мой «фактический вопрос» (по сравнению с исходным письменным вопросом). Я создал что-то вроде анализатора рекурсивного спуска, который быстро просматривал неоднозначные операторы или выражения, чтобы выяснить, как их следует анализировать, и просто сигнализировал фактическому анализатору, как действовать дальше. $\endgroup$
$\begingroup$ @skeggse Возможно, вас заинтересует ANTLR, (бесплатный) генератор компиляторов. Он генерирует парсеры LL (концептуально близкие к рекурсивным приличным) и может обрабатывать произвольный просмотр вперед. То есть с отдельным этапом лексирования (большое преимущество) и хорошо интегрированными функциями для работы с деревом синтаксического анализа, соответственно. АСТ. $\endgroup$
Я не читал подробно вашу грамматику, которая слишком велика для моего свободного времени, и за исключением примера, который вы должны предоставить (поскольку вы утверждаете, что он неоднозначен), я не буду пытаться проверить, так ли это, что вообще неразрешима.
При этом, как уже заметил пользователь rici, существуют общие контекстно-свободные анализаторы (GCF), которые анализируют любую грамматику CF, независимо от того, является ли она неоднозначной или нет. (Я игнорирую очень старый рекурсивный синтаксический анализатор глубины во-первых). Синтаксический анализ GCF обычно занимает время $O(n^3)$, где $n$ — размер входного предложения. Однако это будет всего лишь $O(n^2)$ для однозначных, но, возможно, недетерминированных грамматик. И они обычно линейны для многих грамматик или для значительного подмножества языка, определяемого грамматиками (см. ниже).
Я думаю, что было бы оскорбительным квалифицировать эти синтаксические анализаторы как использующие неограниченный просмотр вперед. На самом деле они параллельно пробуют все возможности синтаксического анализа и отказываются от некоторых, когда дополнительная информация из ввода становится несовместимой с попыткой синтаксического анализа. Это не то же самое, что не пытаться выполнить синтаксический анализ с использованием некоторых известных фактов об остальной части строки, о чем и идет речь. В самом деле, некоторые из этих алгоритмов могут пытаться не выполнять некоторые синтаксические анализы, основываясь на информации с ограниченным опережением: это верно для алгоритмов Эрли, GLL и GLR. Кроме того, концепция просмотра вперед имеет смысл для онлайн-алгоритмов, чего нельзя сказать о синтаксических анализаторах GCF.
Возможно, лучший способ изучить эту технологию — это краткий исторический обзор.
Самым (почти) старым парсером GCF является хорошо известный алгоритм CYK. Это чистое динамическое программирование, построенное непосредственно на грамматике. Он чрезвычайно прост, но не самый лучший по производительности.
Следующий — алгоритм Эрли (1968 г.). Вопреки тому, что часто говорят, он не прост, по крайней мере, в общем виде, и нет известного мне стандартного справочника относительно формата создаваемых деревьев разбора (parse-forest). Не ясно, являются ли его выступления лучшими, на которые можно было бы рассчитывать. Алгоритм Эрли на самом деле является производным от конструкции синтаксического анализатора LR(k) Кнута. Это, конечно, никоим образом не умаляет важности его вклада во внедрение новых концепций синтаксического анализа.
Это было обобщено Лангом (1974), который предложил общую динамическую программную интерпретацию любого КПК, которая может быть объединена с любым методом построения КПК, таким как LR, LL или приоритет, что дает методы, известные как синтаксический анализ GLR или GLL. Когда метод дает дертерминированный автомат, синтаксический анализатор GCF работает за линейное время.
Tomita реализовала первую реализацию этой техники, примененную к конструкции КПК LR(k) в 1984 году, тем самым реализовав первый синтаксический анализатор GLR. Затем он также использовался для создания КПК LL(k), в этом тысячелетии.
Что касается производительности, люди часто пытались улучшить ее, используя сложные методы построения КПК, которые были разработаны для увеличения количества грамматик, которые можно анализировать детерминистически. Эта народная мудрость, по мнению Billot и Lang (1989), явно опрометчива. Это говорит о том, что эффективные синтаксические анализаторы GCF могут иметь довольно простую структуру.
Все эти парсеры могут достаточно быстро работать на современных компьютерах. Они производят все возможные деревья синтаксического анализа в сжатой форме, называемой лесом синтаксического анализа. Одной из проблем может быть представление леса синтаксического анализа, которое может быть более или менее удобным в зависимости от реализации. Конечно, если грамматика CF недвусмысленна, существует только одно дерево синтаксического анализа, что может решить проблему с лесом синтаксического анализа.
Основное отличие от традиционных детерминированных синтаксических анализаторов заключается в том, что для детерминированных синтаксических анализаторов синтаксические анализы производятся в режиме онлайн, создавая левую часть дерева синтаксического анализа синхронно с чтением соответствующей левой части анализируемого предложения. С синтаксическими анализаторами GCF это скорее автономное поведение, так как вам, возможно, придется дождаться самого конца процесса синтаксического анализа, прежде чем вы узнаете, какая структура синтаксического анализа имеет значение.
Еще одна проблема заключается в том, что я не знаю, насколько хорошо текущая реализация может обрабатывать ошибки синтаксического анализа. Я знаю, что сообщество Natural Language проделало очень интересную работу над этим.
Что касается вашей собственной грамматики, я думаю, что попытка использовать эти методы в написанном от руки синтаксическом анализаторе просто приведет к неприятностям. Вы должны иметь отличное владение технологией. Просто ничего не получается. Но, учитывая то, как вы формулируете свой вопрос, если вам нужна простая реализация, которую вы можете освоить, в целях тестирования вы можете попробовать алгоритм CYK. Его главное преимущество заключается в том, что, как и алгоритм Эрли, он работает непосредственно с грамматикой, что может показаться вам более интуитивно понятным.
Многие системы генерации синтаксических анализаторов теперь предлагают ту или иную форму синтаксического анализа GCF, но у меня нет личного опыта ни с одной из них, поэтому нет рекомендаций.
Как исходный код превращается в работающую программу, часто неясно: «Просто запустите компилятор» — это все, что обычно нужно знать разработчикам. Написание интерпретатора с нуля, включая его лексер и синтаксический анализатор, — непростая задача.
Сакиб, специалист по бэкенду, является создателем генератора статических сайтов Hepek.Постоянно учась, он пишет уроки на английском и боснийском языках.
Некоторые говорят, что «все сводится к единицам и нулям», но действительно ли мы понимаем, как наши программы преобразуются в эти биты?
И компиляторы, и интерпретаторы берут необработанную строку, представляющую программу, анализируют ее и анализируют. Хотя интерпретаторы являются более простыми из двух, написание даже очень простого интерпретатора (который выполняет только сложение и умножение) будет поучительным. Мы сосредоточимся на том, что общего у компиляторов и интерпретаторов: лексическом анализе и разборе входных данных.
Что нужно и что нельзя делать при написании собственного интерпретатора
Читатели могут задаться вопросом: Что не так с регулярными выражениями? Регулярные выражения — это мощное средство, но грамматика исходного кода недостаточно проста для их анализа. Ни один из них не является доменно-ориентированным языком (DSL), и клиенту может потребоваться собственный DSL, например, для выражений авторизации. Но даже не применяя этот навык напрямую, написание интерпретатора значительно упрощает оценку усилий многих языков программирования, форматов файлов и DSL.
Правильно писать синтаксические анализаторы вручную может быть непросто со всеми задействованными пограничными случаями. Вот почему существуют популярные инструменты, такие как ANTLR, которые могут генерировать синтаксические анализаторы для многих популярных языков программирования. Существуют также библиотеки, называемые комбинаторами синтаксических анализаторов, которые позволяют разработчикам писать синтаксические анализаторы непосредственно на предпочитаемых ими языках программирования. Примеры включают FastParse для Scala и Parsec для Python.
Мы рекомендуем читателям в профессиональном контексте использовать такие инструменты и библиотеки, чтобы не изобретать велосипед. Тем не менее, понимание проблем и возможностей написания интерпретатора с нуля поможет разработчикам более эффективно использовать такие решения.
Обзор компонентов интерпретатора
Интерпретатор — это сложная программа, поэтому он состоит из нескольких этапов:
- лексер — это часть интерпретатора, которая преобразует последовательность символов (обычный текст) в последовательность токенов.
- парсер, в свою очередь, берет последовательность токенов и создает абстрактное синтаксическое дерево (AST) языка. Правила, по которым работает синтаксический анализатор, обычно определяются формальной грамматикой.
- Интерпретатор – это программа, интерпретирующая AST исходного кода программы "на лету" (без предварительной компиляции).
Мы не будем создавать специальный интегрированный интерпретатор. Вместо этого мы рассмотрим каждую из этих частей и их общие проблемы на отдельных примерах. В итоге пользовательский код будет выглядеть так:
После трех этапов мы ожидаем, что этот код вычислит окончательное значение и напечатает Result is: 19 . В этом руководстве используется Scala, потому что это:
- Очень лаконично, много кода помещается на один экран.
- Ориентирован на выражения, не требует неинициализированных/нулевых переменных.
- Безопасный ввод с мощной библиотекой коллекций, перечислениями и классами case.
В частности, код здесь написан в синтаксисе необязательных фигурных скобок Scala3 (синтаксис на основе отступов, подобный Python). Но ни один из этих подходов не является специфичным для Scala, а Scala похож на многие другие языки: читатели сочтут простым преобразование этих примеров кода на другие языки. За исключением этого, примеры можно запускать онлайн с помощью Scastie.
Наконец, разделы Lexer, Parser и Interpreter содержат разные примеры грамматик. Как показано в соответствующем репозитории GitHub, зависимости в более поздних примерах немного меняются для реализации этих грамматик, но общие концепции остаются прежними.
Компонент интерпретатора 1: Написание лексера
Допустим, мы хотим лексировать эту строку: "123 + 45 true * false1" . Он содержит различные типы токенов:
- Целые литералы
- Оператор +
- Оператор *
- Настоящий литерал
- Идентификатор, false1
В этом примере пробелы между токенами будут пропущены.
На данном этапе выражения не обязательно должны иметь смысл; лексер просто преобразует входную строку в список токенов. (Работа по «осмыслению токенов» возложена на синтаксический анализатор.)
Мы будем использовать этот код для представления токена:
Каждый токен имеет тип, текстовое представление и положение в исходном вводе. Позиция может помочь конечным пользователям лексера с отладкой.
Токен EOF — это специальный токен, который отмечает конец ввода. Его нет в исходном тексте; мы используем его только для упрощения этапа парсера.
Это будет вывод нашего лексера:
Давайте рассмотрим реализацию:
Мы начинаем с пустого списка токенов, затем просматриваем строку и добавляем токены по мере их поступления.
Мы используем символ lookahead, чтобы определить тип следующего токена. Обратите внимание, что опережающий символ не всегда является самым дальним исследуемым символом.Основываясь на предварительном просмотре, мы знаем, как выглядит токен, и используем currentPos для сканирования всех ожидаемых символов в текущем токене, а затем добавляем токен в список:
Если опережение содержит пробелы, мы его пропускаем. Однобуквенные токены тривиальны; мы добавляем их и увеличиваем индекс. Для целых чисел нам нужно позаботиться только об индексе.
Теперь мы подошли к более сложному вопросу: идентификаторы и литералы. Правило состоит в том, что мы берем самое длинное совпадение и проверяем, является ли оно буквальным; если нет, то это идентификатор.
Будьте осторожны при работе с такими операторами, как и . Там вы должны заглянуть вперед еще на один символ и посмотреть, является ли он =, прежде чем сделать вывод, что это оператор. В противном случае это просто .
После этого наш лексер создал список токенов.
Компонент интерпретатора 2: Написание синтаксического анализатора
Нам нужно придать некоторую структуру нашим токенам — одним списком мы мало что можем сделать. Например, нам нужно знать:
Какие выражения являются вложенными? Какие операторы применяются в каком порядке? Какие правила области действия применяются, если таковые имеются?
Древовидная структура поддерживает вложенность и порядок. Но сначала мы должны определить некоторые правила построения деревьев. Мы хотели бы, чтобы наш синтаксический анализатор был однозначным — всегда возвращал одну и ту же структуру для данного ввода.
Обратите внимание, что следующий синтаксический анализатор не использует предыдущий пример лексера. Этот предназначен для сложения чисел, поэтому его грамматика имеет только две лексемы, '+' и NUM :
Эквивалент с использованием вертикальной черты ( | ) в качестве символа «или», как в регулярных выражениях:
В любом случае у нас есть два правила: одно говорит, что мы можем суммировать два expr , а другое говорит, что expr может быть токеном NUM, что здесь будет означать неотрицательное целое число.
Правила обычно определяются с помощью формальной грамматики. Формальная грамматика состоит из: самих правил, как показано выше; начального правила (первое указанное правило согласно соглашению); двух типов символов, определяющих правила: терминалы: «буквы» (и другие символы) нашего языка — неприводимые символы, из которых состоят токены. Нетерминалы: промежуточные конструкции, используемые для синтаксического анализа (т. е. символы, которые можно заменить)
Слева от правила может находиться только нетерминал; правая часть может иметь как терминалы, так и нетерминалы. В приведенном выше примере терминалами являются '+' и NUM, а единственным нетерминалом является expr. Для более широкого примера, в языке Java у нас есть терминалы, такие как 'true', '+', Identifier и '[', и нетерминалы, такие как BlockStatements, ClassBody и MethodOrFieldDecl.
Существует множество способов реализовать этот синтаксический анализатор. Здесь мы будем использовать метод разбора «рекурсивный спуск». Это самый распространенный тип, потому что его проще всего понять и реализовать.
Синтаксический анализатор рекурсивного спуска использует одну функцию для каждого нетерминала в грамматике. Он начинается с начального правила и спускается оттуда (отсюда «спуск»), выясняя, какое правило применить в каждой функции. «Рекурсивная» часть жизненно важна, потому что мы можем рекурсивно вкладывать нетерминалы! Регулярные выражения не могут этого сделать: они даже не могут обрабатывать сбалансированные скобки. Поэтому нам нужен более мощный инструмент.
Парсер для первого правила будет выглядеть примерно так (полный код):
Функция eat() проверяет, соответствует ли упреждающий маркер ожидаемому токену, а затем перемещает упреждающий индекс. К сожалению, это пока не сработает, потому что нам нужно исправить некоторые проблемы с нашей грамматикой.
Грамматическая неоднозначность
Первая проблема заключается в двусмысленности нашей грамматики, которая может быть незаметна на первый взгляд:
Учитывая входные данные 1 + 2 + 3 , наш синтаксический анализатор может сначала вычислить либо левое выражение, либо правое выражение в результирующем AST:
Левши и правши AST.
Вот почему нам нужно ввести некоторую асимметрию:
Набор выражений, которые мы можем представить с помощью этой грамматики, не изменился со времени ее первой версии. Только вот однозначно: Парсер всегда идет влево. Как раз то, что нужно!
Это делает нашу операцию + левой ассоциативной, но это станет очевидным, когда мы перейдем к разделу "Интерпретатор".
Правила левой рекурсии
К сожалению, приведенное выше исправление не решает другую нашу проблему, левую рекурсию:
Здесь у нас бесконечная рекурсия. Если бы мы вошли в эту функцию, то в конечном итоге получили бы ошибку переполнения стека. Но теория синтаксического анализа может помочь!
Предположим, у нас есть такая грамматика, где альфа может быть любой последовательностью терминалов и нетерминалов:
Мы можем переписать эту грамматику следующим образом:
Здесь эпсилон — это пустая строка — ничего, нет токена.
Возьмем текущую версию нашей грамматики:
Следуя методу переписывания правил синтаксического анализа, подробно описанному выше, где альфа является нашими маркерами '+' NUM, наша грамматика принимает следующий вид:
Теперь с грамматикой все в порядке, и мы можем разобрать ее с помощью анализатора рекурсивного спуска. Давайте посмотрим, как такой синтаксический анализатор будет искать эту последнюю итерацию нашей грамматики:
Здесь мы используем токен EOF, чтобы упростить наш синтаксический анализатор.Мы всегда уверены, что в нашем списке есть хотя бы один токен, поэтому нам не нужно обрабатывать частный случай пустого списка.
Кроме того, если мы переключимся на потоковый лексер, у нас будет не список в памяти, а итератор, поэтому нам нужен маркер, чтобы знать, когда мы подошли к концу ввода. Когда мы подойдем к концу, токен EOF должен быть последним оставшимся токеном.
Просматривая код, мы видим, что выражение может быть просто числом. Если ничего не осталось, то следующим токеном не будет Plus, поэтому мы прекратим синтаксический анализ. Последним токеном будет EOF , и все будет готово.
Если во входной строке больше токенов, они должны выглядеть как + 123 . Вот где рекурсия в exprOpt() срабатывает!
Создание AST
Теперь, когда мы успешно проанализировали наше выражение, трудно что-либо сделать с ним как есть. Мы могли бы поместить несколько обратных вызовов в наш парсер, но это было бы очень громоздко и нечитаемо. Вместо этого мы вернем AST, дерево, представляющее входное выражение:
Это похоже на наши правила, использующие простые классы данных.
Чтобы узнать о eat() , error() и других деталях реализации, см. соответствующий репозиторий GitHub.
Упрощение правил
Наш нетерминал ExprOpt еще можно улучшить:
Трудно распознать шаблон, который он представляет в нашей грамматике, просто взглянув на него. Оказывается, мы можем заменить эту рекурсию более простой конструкцией:
Эта конструкция просто означает, что "+" NUM встречается ноль или более раз.
Теперь наша полная грамматика выглядит так:
И наш AST выглядит лучше:
Полученный синтаксический анализатор имеет ту же длину, но его проще понять и использовать. Мы убрали Epsilon , что теперь подразумевается при запуске с пустой структурой.
Здесь нам даже не понадобился класс ExprOpt. Мы могли бы просто указать case class Expr(num: Int, exprOpts: Seq[Int]) или в формате грамматики NUM ('+' NUM)* . Так почему же мы этого не сделали?
Учтите, что если бы у нас было несколько возможных операторов, таких как - или * , у нас была бы такая грамматика:
В этом случае AST требуется ExprOpt для размещения типа оператора:
Обратите внимание, что синтаксис [+-*] в грамматике означает то же самое, что и в регулярных выражениях: «один из этих трех символов». Мы скоро увидим это в действии.
Компонент интерпретатора 3: Написание интерпретатора
Наш интерпретатор будет использовать наш лексер и синтаксический анализатор, чтобы получить AST нашего входного выражения, а затем оценить этот AST любым удобным для нас способом. В данном случае мы имеем дело с числами и хотим вычислить их сумму.
В реализации нашего примера с интерпретатором мы будем использовать следующую простую грамматику:
(Мы рассмотрели, как реализовать лексер и парсер для похожих грамматик, но любой читатель, который застрял, может просмотреть реализации лексера и парсера для этой грамматики в репозитории.)
Теперь мы посмотрим, как написать интерпретатор для приведенной выше грамматики:
Если мы проанализировали наши входные данные в AST без ошибок, мы уверены, что у нас всегда будет хотя бы один NUM . Затем мы берем необязательные числа и добавляем их к результату (или вычитаем из него).
Примечание с самого начала о левой ассоциативности + теперь ясно: мы начинаем с самого левого числа и добавляем другие слева направо. Это может показаться неважным для сложения, но рассмотрим вычитание: выражение 5 - 2 - 1 оценивается как (5 - 2) - 1 = 3 - 1 = 2, а не как 5 - (2 - 1) = 5 - 1 = 4. !
Но если мы хотим выйти за рамки интерпретации операторов "плюс" и "минус", необходимо определить еще одно правило.
Приоритет
Мы знаем, как анализировать простое выражение, такое как 1 + 2 + 3 , но когда дело доходит до 2 + 3 * 4 + 5 , у нас возникает небольшая проблема.
Большинство людей согласны с тем, что умножение имеет более высокий приоритет, чем сложение. Но парсер этого не знает. Мы не можем просто оценить его как ((2 + 3) * 4) + 5. Вместо этого мы хотим (2 + (3 * 4)) + 5 .
Это означает, что нам нужно сначала вычислить умножение. Умножение должно быть дальше от корня AST, чтобы принудительно оценить его перед добавлением. Для этого нам нужно ввести еще один уровень косвенности.
Исправляем наивную грамматику от начала до конца
Это наша исходная леворекурсивная грамматика, в которой нет правил приоритета:
Во-первых, мы даем ему правила приоритета и устраняем его двусмысленность:
Затем он получает нелеворекурсивные правила:
В результате получается красиво выразительный AST:
Это оставляет нам краткую реализацию интерпретатора:
Как и прежде, идеи в отношении необходимого лексера и грамматики были рассмотрены ранее, но при необходимости читатели могут найти их в репозитории.
Следующие шаги в написании интерпретаторов
Мы не говорили об этом, но обработка ошибок и создание отчетов являются важными функциями любого синтаксического анализатора. Как разработчики, мы знаем, как неприятно, когда компилятор выдает запутанные или вводящие в заблуждение ошибки.Это область, в которой нужно решить много интересных проблем, таких как предоставление правильных и точных сообщений об ошибках, не отпугивание пользователя большим количеством сообщений, чем необходимо, и изящное восстановление после ошибок. Разработчики должны написать интерпретатор или компилятор, чтобы обеспечить удобство для будущих пользователей.
Проходя через наши примеры лексеров, синтаксических анализаторов и интерпретаторов, мы только поверхностно коснулись теорий, лежащих в основе компиляторов и интерпретаторов, которые охватывают такие темы, как:
- Области действия и таблицы символов
- Статические типы
- Оптимизация времени компиляции
- Статические анализаторы программ и линтеры
- Форматирование кода и красивая печать
- Языки домена
Для дальнейшего чтения я рекомендую следующие ресурсы:
- Шаблоны языковой реализации Теренса Парра
- Бесплатная онлайн-книга Crafting Interpreters Боба Нистрома
- Введение в грамматику и синтаксический анализ Пола Клинта
- Написание хороших сообщений об ошибках компилятора, Калеб Мередит
- Заметки из курса «Перевод и компиляция программ» Университета Восточной Каролины
Понимание основ
Как создать интерпретатор?
Чтобы создать интерпретатор, сначала вам нужно создать лексер, чтобы получить токены вашей программы ввода. Затем вы создаете синтаксический анализатор, который берет эти токены и, следуя правилам формальной грамматики, возвращает AST вашей входной программы. Наконец, интерпретатор берет этот AST и каким-то образом интерпретирует его.
В чем разница между компилятором и интерпретатором?
Компилятор берет программу на языке более высокого уровня и преобразует ее в программу на языке более низкого уровня. Интерпретатор берет программу и запускает ее на лету. Он не создает никаких файлов.
На каком языке написан переводчик?
Интерпретаторы могут быть написаны на любом языке программирования. Популярным выбором являются функциональные языки, потому что они имеют отличные абстракции для преобразования данных.
Как работает интерпретатор?
Интерпретаторы в основном выполняют по одному оператору за раз. Они берут AST, возвращенный синтаксическим анализатором, и выполняют его. В этом процессе они обычно используют некоторые вспомогательные конструкции, такие как таблицы символов, генераторы и оптимизаторы.
Как работает лексер?
Лексер принимает строку символов и возвращает список токенов, которые в основном представляют собой сгруппированные символы. Токены обычно определяются с помощью регулярных выражений.
Как работают парсеры программирования?
Синтаксический анализатор программирования определяется формальной грамматикой, описывающей правила языка, который он анализирует. Наиболее распространенным видом является анализатор рекурсивного спуска, и он напоминает данную грамматику, имея одну функцию для каждого нетерминала. Он принимает последовательность токенов на вход и возвращает AST на выходе.
Что подразумевается под абстрактным синтаксическим деревом?
Абстрактное синтаксическое дерево (AST) — это представление структуры исходного кода программы. Он содержит только те данные, которые важны для интерпретатора или компилятора. Он не содержит пробелов, фигурных скобок, точек с запятой и подобных частей входной программы.
Для чего используется абстрактное синтаксическое дерево?
Абстрактное синтаксическое дерево используется как промежуточное представление входной программы. Затем интерпретатор/компилятор может делать с ним все, что ему нужно: оптимизировать, упростить, выполнить или что-то еще.
Последние 6 месяцев я работал над языком программирования под названием Pinecone. Я бы не назвал его зрелым, но в нем уже есть достаточно функций, которые можно использовать, например:
- переменные
- функции
- определяемые пользователем структуры
Если вам это интересно, посетите целевую страницу Pinecone или репозиторий GitHub.
Я не эксперт. Когда я начинал этот проект, я понятия не имел, что делаю, и до сих пор не знаю. Я не посещал никаких курсов по созданию языка, лишь немного читал об этом в Интернете и не следовал большинству советов, которые мне давали.
И тем не менее, я все же сделал совершенно новый язык. И это работает. Значит, я делаю что-то правильно.
В этом посте я загляну под капот и покажу конвейер, который Pinecone (и другие языки программирования) использует для превращения исходного кода в волшебство.
Я также расскажу о некоторых компромиссах, на которые мне пришлось пойти, и о том, почему я принял такие решения.
Это ни в коем случае не полное руководство по написанию языка программирования, но это хорошая отправная точка, если вы интересуетесь разработкой языка.
Начало работы
«Я совершенно не представляю, с чего бы мне вообще начать» — это то, что я часто слышу, когда говорю другим разработчикам, что пишу язык. На случай, если вы так отреагируете, сейчас я расскажу о некоторых первоначальных решениях и шагах, которые предпринимаются при запуске любого нового языка.
Скомпилированные и интерпретированные
Существует два основных типа языков: компилируемые и интерпретируемые:
- Компилятор вычисляет все, что будет делать программа, превращает это в "машинный код" (формат, который компьютер может выполнять очень быстро), а затем сохраняет его для последующего выполнения.
- Интерпретатор проходит по исходному коду строка за строкой, выясняя, что он делает по ходу дела.
Технически любой язык может быть скомпилирован или интерпретирован, но тот или иной вариант обычно имеет больше смысла для конкретного языка. Как правило, интерпретация имеет тенденцию быть более гибкой, а компиляция имеет более высокую производительность. Но это лишь малая часть очень сложной темы.
Я очень ценю производительность и заметил недостаток языков программирования, ориентированных одновременно на высокую производительность и простоту, поэтому я выбрал скомпилированный для Pinecone.
Это важное решение было принято с самого начала, потому что оно влияет на многие решения по проектированию языков (например, статическая типизация дает большое преимущество для компилируемых языков, но не так много для интерпретируемых).
Несмотря на то, что Pinecone разрабатывался с учетом компиляции, у него есть полнофункциональный интерпретатор, который какое-то время был единственным способом запустить его. Этому есть ряд причин, которые я объясню позже.
Выбор языка
Я знаю, что это немного мета, но язык программирования сам по себе является программой, и поэтому вам нужно писать его на языке. Я выбрал C++ из-за его производительности и большого набора функций. Кроме того, мне действительно нравится работать на C++.
Если вы пишете на интерпретируемом языке, имеет смысл написать его на скомпилированном языке (например, C, C++ или Swift), потому что производительность снижается на языке вашего интерпретатора и интерпретаторе, который интерпретирует ваш интерпретатор. будет складываться.
Если вы планируете компилировать, лучше использовать более медленный язык (например, Python или JavaScript). Время компиляции может быть плохим, но, на мой взгляд, это не так важно, как плохое время выполнения.
Дизайн высокого уровня
Язык программирования обычно структурирован как конвейер. То есть он имеет несколько стадий. Каждый этап имеет данные, отформатированные определенным, четко определенным образом. Он также имеет функции для преобразования данных с каждого этапа на следующий.
Первый этап представляет собой строку, содержащую весь входной исходный файл. Заключительный этап — это то, что можно запустить. Все это станет ясно, когда мы шаг за шагом пройдемся по конвейеру Pinecone.
Лексинг
Первым шагом в большинстве языков программирования является лексирование или токенизация. «Лекс» — это сокращение от лексического анализа, очень красивое слово для разделения текста на лексемы. Слово «токенизатор» имеет гораздо больше смысла, но слово «лексер» настолько забавно произносить, что я все равно использую его.
Токены
Токен – это небольшая единица языка. Маркером может быть имя переменной или функции (также известное как идентификатор), оператор или число.
Задание лексера
Предполагается, что лексер принимает строку, содержащую целые файлы исходного кода, и выдает список, содержащий каждый токен.
Будущие этапы конвейера не будут ссылаться на первоначальный исходный код, поэтому лексер должен предоставить всю необходимую им информацию. Причина такого относительно строгого формата конвейера заключается в том, что лексер может выполнять такие задачи, как удаление комментариев или определение того, является ли что-то числом или идентификатором. Вы хотите сохранить эту логику запертой внутри лексера, чтобы вам не приходилось думать об этих правилах при написании остальной части языка, и чтобы вы могли изменить этот тип синтаксиса в одном месте.
В тот день, когда я запустил язык, первое, что я написал, был простой лексер. Вскоре после этого я начал узнавать об инструментах, которые предположительно сделают лексирование проще и избавят от ошибок.
Преобладающим таким инструментом является Flex, программа, которая генерирует лексеры. Вы даете ему файл со специальным синтаксисом для описания грамматики языка. Из этого он генерирует программу C, которая лексизирует строку и выдает желаемый результат.
Мое решение
Я решил пока оставить написанный мной лексер. В конце концов, я не увидел существенных преимуществ использования Flex, по крайней мере, недостаточных, чтобы оправдать добавление зависимости и усложнение процесса сборки.
Мой лексер состоит всего из нескольких сотен строк и редко доставляет мне проблемы. Создание собственного лексера также дает мне больше гибкости, например, возможность добавлять оператор в язык без редактирования нескольких файлов.
Разбор
Второй этап конвейера – синтаксический анализатор. Парсер превращает список токенов в дерево узлов.Дерево, используемое для хранения данных этого типа, известно как абстрактное синтаксическое дерево или AST. По крайней мере, в Pinecone AST не имеет никакой информации о типах или идентификаторах. Это просто структурированные токены.
Обязанности парсера
Синтаксический анализатор добавляет структуру к упорядоченному списку токенов, создаваемых лексером. Чтобы исключить двусмысленность, синтаксический анализатор должен учитывать круглые скобки и порядок операций. Простой синтаксический анализ операторов не так уж и сложен, но по мере добавления новых языковых конструкций синтаксический анализ может стать очень сложным.
Бизон
И снова было принято решение о привлечении сторонней библиотеки. Преобладающей библиотекой синтаксического анализа является Bison. Bison во многом похож на Flex. Вы пишете файл в пользовательском формате, в котором хранится информация о грамматике, затем Bison использует его для создания программы на C, которая будет выполнять ваш синтаксический анализ. Я не решил использовать Bison.
Почему собственный формат лучше
С лексером решение использовать мой собственный код было довольно очевидным. Лексер — настолько тривиальная программа, что не написать свою собственную было почти так же глупо, как не написать собственную «левую панель».
С парсером дело обстоит иначе. Мой синтаксический анализатор Pinecone в настоящее время состоит из 750 строк, и я написал три из них, потому что первые две были мусором.
Изначально я принял решение по ряду причин, и хотя оно не прошло гладко, большинство из них верны. Основные из них следующие:
- Сведите к минимуму переключение контекста в рабочем процессе: переключение контекста между C++ и Pinecone и без использования грамматической грамматики Bison достаточно плохо.
- Сохраняйте простоту сборки: каждый раз, когда изменяется грамматика, перед сборкой необходимо запускать Bison. Это можно автоматизировать, но при переключении между системами сборки возникает проблема.
- Мне нравится создавать крутые штуки: я не стал делать Сосновую шишку, потому что думал, что это будет легко, так зачем мне делегировать главную роль, если я могу сделать это сам? Пользовательский синтаксический анализатор может быть нетривиальным, но вполне выполнимым.
Вначале я не был полностью уверен, что иду по жизнеспособному пути, но мне придало уверенности то, что Уолтер Брайт (разработчик ранней версии C++ и создатель языка D) должен был сказать по теме:
«Что несколько более спорно, я бы не стал тратить время на генераторы лексеров или синтаксических анализаторов и другие так называемые «компиляторы-компиляторы». Это пустая трата времени. Написание лексера и синтаксического анализатора составляет крошечный процент работы по написанию компилятора. Использование генератора займет примерно столько же времени, сколько и написание его вручную, и это женит вас на генераторе (что имеет значение при переносе компилятора на новую платформу). Кроме того, генераторы имеют дурную репутацию из-за того, что выдают паршивые сообщения об ошибках».
Дерево действий
Теперь мы вышли из области общих, универсальных терминов, или, по крайней мере, я больше не знаю, что это за термины. Насколько я понимаю, то, что я называю «деревом действий», больше всего похоже на IR LLVM (промежуточное представление).
Между деревом действий и абстрактным синтаксическим деревом есть тонкая, но очень существенная разница. Мне потребовалось довольно много времени, чтобы понять, что между ними вообще должна быть разница (что способствовало необходимости перезаписи парсера).
Дерево действий и AST
Проще говоря, дерево действий — это AST с контекстом. Этот контекст представляет собой информацию, такую как тип, возвращаемый функцией, или то, что два места, в которых используется переменная, фактически используют одну и ту же переменную. Поскольку коду, генерирующему дерево действий, необходимо вычислить и запомнить весь этот контекст, необходимо множество таблиц поиска пространств имен и других штуковин.
Запуск дерева действий
Когда у нас есть дерево действий, запустить код несложно. У каждого узла действия есть функция «выполнить», которая принимает некоторый ввод, делает все, что должно действие (включая, возможно, вызов поддействия) и возвращает результат действия. Это интерпретатор в действии.
Параметры компиляции
«Но подождите!» Я слышал, вы говорите: «Разве Pinecone не должен быть скомпилирован?» Да это так. Но компилировать сложнее, чем интерпретировать. Есть несколько возможных подходов.
Создать собственный компилятор
Сначала мне это показалось хорошей идеей. Мне очень нравится делать что-то самому, и мне не терпелось найти предлог, чтобы научиться хорошо собирать.
К сожалению, написать переносимый компилятор не так просто, как написать машинный код для каждого элемента языка. Из-за большого количества архитектур и операционных систем для любого человека нецелесообразно писать серверную часть кросс-платформенного компилятора.
Даже команды Swift, Rust и Clang не хотят возиться со всем этим в одиночку, поэтому вместо этого все используют…
LLVM — это набор инструментов для компиляции. По сути, это библиотека, которая превратит ваш язык в скомпилированный исполняемый двоичный файл. Это казалось идеальным выбором, поэтому я сразу же вскочил.К сожалению, я не проверил глубину воды и тут же утонул.
LLVM, хотя и не сложный язык ассемблера, представляет собой гигантскую сложную библиотеку. Его можно использовать, и у них есть хорошие учебные пособия, но я понял, что мне нужно немного попрактиковаться, прежде чем я буду готов полностью реализовать с его помощью компилятор Pinecone.
Транспиляция
Мне нужен был какой-то скомпилированный сосновый конус, и я хотел, чтобы он был быстрым, поэтому я обратился к одному методу, который, как я знал, мог бы работать: транспиляции.
Я написал транспилятор Pinecone в C++ и добавил возможность автоматически компилировать исходный код с помощью GCC. В настоящее время это работает почти для всех программ Pinecone (хотя есть несколько крайних случаев, которые его ломают). Это решение нельзя назвать переносимым или масштабируемым, но пока оно работает.
Будущее
Если я продолжу разработку Pinecone, рано или поздно он получит поддержку компиляции LLVM. Я подозреваю, что сколько бы я ни работал над ним, транспилятор никогда не будет полностью стабильным, а преимущества LLVM многочисленны. Вопрос лишь в том, когда у меня будет время сделать несколько примеров проектов в LLVM и освоить их.
До тех пор интерпретатор отлично подходит для тривиальных программ, а транспиляция C++ подходит для большинства задач, требующих большей производительности.
Заключение
Надеюсь, я сделал языки программирования менее загадочными для вас. Если вы хотите сделать его самостоятельно, я очень рекомендую его. Есть масса деталей реализации, которые нужно выяснить, но приведенного здесь описания должно быть достаточно, чтобы начать работу.
Вот мой общий совет для начала работы (помните, я на самом деле не знаю, что делаю, поэтому отнеситесь к этому с долей скептицизма):
- Если сомневаетесь, обратитесь к переводчику. Интерпретируемые языки, как правило, легче проектировать, создавать и изучать. Я не отговариваю вас от написания скомпилированного, если вы знаете, что хотите сделать, но если вы сомневаетесь, я бы пошел на интерпретацию.
- Что касается лексеров и синтаксических анализаторов, делайте, что хотите. Существуют веские аргументы за и против написания собственного. В конце концов, если вы продумаете свой дизайн и реализуете все осмысленно, это не имеет большого значения.
- Извлеките уроки из конвейера, к которому я пришел. Много проб и ошибок было потрачено на разработку конвейера, который у меня есть сейчас. Я пытался устранить AST, AST, которые превращаются в деревья действий на месте, и другие ужасные идеи. Этот конвейер работает, поэтому не меняйте его, если у вас нет действительно хорошей идеи.
- Если у вас нет времени или мотивации для реализации сложного языка общего назначения, попробуйте реализовать эзотерический язык, такой как Brainfuck. Эти интерпретаторы могут содержать всего несколько сотен строк.
Я почти не жалею о разработке Pinecone. По пути я сделал несколько неверных решений, но переписал большую часть кода, затронутого такими ошибками.
Сейчас сосновая шишка находится в достаточно хорошем состоянии, поэтому ее можно легко улучшить. Написание «Сосновой шишки» было для меня чрезвычайно познавательным и приятным опытом, и это только начало.
Читайте также: