Вариант реализации DSL (domain-specific language) с помощью макросов
Внимание!!!
Данная статья содержит описание синтаксиса NewLnag предыдущей версии.
Актуальную версию синтаксиса языка можно посмотреть тут.
Близится релиз языка NewLang с принципиальной новой «фишкой», переделанным вариантом препроцессора, который позволяет расширять синтаксиса языка для создания различных диалектов DSL за счет макросов.
И, как всегда, используя ранее найденный лайфхак Хабр — ума палата, хотелось бы получить от читателей обратную связь насчет предлагаемого ниже подхода, который планируется к реализации в новом препроцессоре NewLang.
О чем идет речь?
DSL (Предметно-ориентированный язык) - язык программирования, специализированный для конкретной области применения. Считается, что использование DSL существенно повышает уровень абстрактности кода, а это позволяет вести разработку более быстро и эффективно и существенно упрощает решение многих задач.
Условно, можно выделить два подхода к реализации DSL:
- Разработка независимых трансляторов синтаксиса с помощью генераторов лексеров и парсеров для определения грамматики целевого языка посредством БНФ и регулярных выражений (Lex, Yacc, ANTLR и т. д.) и последующей компиляцией полученной грамматики в машинный код.
- Разработка или встраивание диалекта DSL на языке (метаязыке) общего назначения, в том числе за счет применения различных библиотек или специальных парсеров / препроцессоров.
Далее речь пойдет о втором варианте, а именно, о реализации DSL на базе языков (метаязыков) общего назначения и новом варианте реализации макросов в NewLang как основы для разработки DSL.
Две крайности
Наверно имеет смысл начать с описания о двух крайностях при реализации DSL на базе языка (метаязыка) общего назначения:
Ограниченная грамматика
Если язык программирования ограничен собственной фиксированной грамматикой и не допускает её расширения, то при реализации DSL разработчик будет вынужден использовать уже существующую грамматику, правила записи операций и вообще весь синтаксис будет оставаться такими же, как в языке реализации. Например, при использовании в качестве базового языка С/С++ или применении различных библиотек и фреймворков в других языках программирования общего назначения.
В этом случае под термином “DSL” будет скрываться просто набор специфических терминов предметной области, переопределенных макросов и/или операторов, но использование которых будет ограничено грамматикой языка реализации.
Неограниченная грамматика
Если же язык (метаязык) позволяет модифицировать собственную грамматику (например на уровне AST), то DSL уже не будет жестко огранен синтаксисом базового языка программирования, и в результате его грамматика может быть какой угодно. Вплоть до того, что «для каждого нового проекта придется изучать новый язык… ». Это можно сделать с помощью использования специализированных метаязыков (Lisp, ML, Haskell, Nemerle, Forth, Tcl, Rebol и пр.)
Очень рекомендую прочитать о метапрограммровании великолепную статью @NeoCode Метапрограммирование: какое оно есть и каким должно быть.
Для обсуждения предлагается следующая реализация макросов
«Нет в мире совершенства», и после выпуска релиза NewLang 0.2 я получил много отзывов (по большей части негативных), по поводу первого варианта реализации макросов и DSL на их основе. И если положить руку на сердце, эта критика часто была обоснованной. Поэтому я решил попробовать немного переделать макросы, в надежде получить «золотую середину» между двумя описанными выше крайностями при описании DSL.
Используемая терминология
Макросы в NewLang, это один или несколько терминов, которые заменяются на другой термин или на целую синтаксическую конструкцию (последовательность лексем). Макросы являются одновременно и расширением базового синтаксиса языка, при реализации собственных диалектов DSL, и синтаксическим сахаром.
Главная особенность макросов в том, что они позволяют изменять выражения еще до их вычисления во время выполнения. Раскрытие макросов происходит во время работы лексера, что позволяет подменять ими любые другие термины и даже модифицировать сам синтаксис языка.
Поэтому, если перед именем объекта NewLang модификатор не указывать (**\**макрос, **$**локальная_переменная или **@**модуль), то сперва будет производиться поиск объекта среди макросов, потом среди локальных переменных и в последнюю очередь среди модулей (объектов модуля). За счет этого можно использовать термины без обязательных модификаторов для указания конкретных типов объектов.
Определение макросов
Для определения макросов используется точно такой синтаксис, как и для других объектов языка (применяются операторы «::=», «=» или «:=», соответственно для создания нового объекта, присвоение нового значения уже существующему или для создания объекта / присвоения нового значения объекту не зависимо от его наличия или отсутствия).
В общем виде, определение макроса состоит из трех частей <имя макроса> <оператор создания/присвоения> <тело макроса> и завершающая точка с запятой “;”.
Тело макроса
Телом макроса могут быть корректное выражение языка, последовательность лексем (которые заключается в двойные обратные слеши, т.е. \\лексема1 лексема1\\) или обычная текстовая строка (обрамленная в тройные обратные слеши, т.е. \\\ текстовая строка \\\).
Для соединения двух лексем в одну (аналог операции ## в препроцессоре С/С++), используется по аналогии синтаксис ##. Похожий оператор применяется и для обрамления лексемы в кавычки #, например, \macro($arg) := \\ func_ \## \#arg(\#arg) \\;
? тогда вызов macro(arg) будет преобразован в func_arg ("arg")
;
Имя макроса
Именем макроса может быть одиночный идентификатор с префиксом макроса “\” или последовательность из нескольких лексем. Если в качестве имени макроса используется последовательность лексем, то среди них должен быть как минимум один идентификатор и может присутствовать один или несколько шаблонов.
Шаблон — это специальный идентификатор который при сопоставлении может заменяться любым одиночным термином. С помощью шаблонов производится поиск по образцу и замена заданных последовательностей лексем на тело макроса.
Для указания шаблона в начале идентификатора нужно поставить знак доллара (что соответствует записи имени локальной переменой), т. е. \\одна_лексема\\, \\целых три лексемы\\ **\\**лексема $шаблон1 $шаблон2 \\.
Макросы считаются одинаковыми, если их идентификаторы равны, количество элементов в их именах совпадает, а идентификаторы и шаблоны располагаются на тех же самых местах.
Аргументы макросов
Термины или шаблоны в имени макроса могут иметь аргументы, которые указываются в круглых скобках. Переданные аргументы в теле макроса записываются в месте для раскрытия как имя локальной переменой, но перед именем нужно добавить обратный слеш, т.е. \$name
.
Произвольное количество параметров у макроса отмечается троеточием “…”, а место для вставки этих аргументов отмечается лексемой $…. Если у макроса есть несколько идентификаторов с аргументами, то для вставки аргументов из конкретного идентификатора используется лексема с указанием нужного идентификатора, например, $name….
Чтобы вставить количество реально переданных аргументов используется лексема $#, или с указанием нужного идентификатора, например, $#name.
Макросы работают с лексемами, которые содержат различную информацию, в том числе и о типе данных, если она указана. Но на текущий момент типы данных в аргументах макросов никак не обрабатываются и это одна из обязательных фич, которая будет реализована в будущем.
Примеры:
\макрос1 := 123;
\макрос2(arg) := {func( \$arg ); func2(123);};
\\макрос из(...) лексем\\ := \\ call1(); call2( \$... ); call3() \\;
\текстовый_макрос := \\\ строка для для лексера \\\;
# Обычные макросы (тело макроса корректное выражение)
\macro := replace();
\macro2($arg) := { call( \$arg ); call()};
# В функцию передается кол-во аргументов и сами аргументы
\\func name1(...)\\ := name2( \$#, \$name1... );
# Тело макросов из последовательности лексем
\if(...) := \\ [ \$... ] --> \\; # Выражение может быть не полным
\else := \\ ,[ _ ] --> \\; # Выражение может быть не полным
# Тело макроса из текстовой строки (как в препроцессоре С/С++)
\macro_str := \\\ строка - тело макроса \\\; # Строка для лексера
\macro($arg) := \\\ func_ \## \#arg(\#arg)\\\; # macro(arg) -> func_arg ("arg")
Какие возможности это дает?
Таким образом можно определить макросы в следующих комбинациях:
№ п/п Имя макроса Тело макроса
----------------------------------------------------------------
1. \идентификатор выражение
2. \идентификатор \\лексема1 лексема2\\
3. \идентификатор \\\строка для лексера\\\
4. \\лексема1 лексема2\\ выражение
5. \\лексема1 лексема2\\ \\лексема1 лексема2\\
6. \\лексема1 лексема2\\ \\\строка для лексера\\\
Каждая из перечисленных выше комбинации имеет свои свойства и ограничения:
-
Классическая замена одного термина на другой термин или целое выражение. Однократно обрабатывается лексером и парсером при определении. Выражение в теле макроса должно быть корректным с точки зрения синтаксиса и при наличии в нем ошибок, сообщение об этом формируется сразу, еще при определении макроса.
-
Классическая замена одного термина на последовательности лексем, в том числе и не полные синтаксические конструкции. Однократно обрабатывается лексером при определении макроса. Тело макроса анализируется парсером при его использовании, поэтому возможные синтаксические ошибки будут замечены только при раскрытии макроса.
-
Классическая замена одного термина на текстовую строку, которая подается на вход лексера. Однократно обрабатывается лексером только имя макроса при его определении, что позволяет модифицировать тело макроса и изменять/комбинировать/модифицировать лексемы до их подачи в анализатор. Синтаксические ошибки будут замечены только при раскрытии макроса.
4, 5 и 6. Замена последовательности из нескольких лексем (шаблонов) на выражение, последовательность лексем или текстовую строку соответственно.
Назначение и примеры использования
Макросы используются и для преобразования базового синтаксиса NewLang в более привычный синтаксис на основе ключевых слов, так как такой текст гораздо легче воспринимается при последующем чтении исходного кода.
Если перед именем объекта NewLang модификатор не указан (**\**макрос, **$**локальная_переменная или **@**модуль), то сперва ищется имя макроса, потом имя локальной переменной и в последнюю очередь имя модуля (объекта модуля). За счет этого получается определять синтаксис DSL в привычной записи без обязательных префиксов у разных типов объектов.
Например, запись условного оператора на основном синтаксисе NewLang:
[condition] --> {
...
} [ condition2 ] --> {
...
} [ _ ] {
...
};
# С помощью макросов
\if(...) := \\ [ \$... ]--> \\;
\elif(...) := \\ ,[ \$... ]--> \\;
\else := \\ ,[ _ ]--> \\;
# Превращается в классическую запись
if( condition ){
...
} elif( condition2 ) {
...
} else {
...
};
Или цикл до 5:
count:=1;
[ 1 ] <-> {
[count>5] --> {
++ 42 ++;
};
count+=1;
};
будет выглядеть более привычно с использованием соответствующих макросов:
\while(...) := \\ [ \$... ] <-> \\;
\return(...) := ++ \$... ++;
\true := 1;
count := 1;
while( true ) {
if( count > 5 ) {
return 42;
};
count += 1;
};
Удаление макросов
Для удаления макроса нужно присвоить ему пустую последовательность лексем \macro_str := \\\\;
. Так же для удаления можно использовать специальный синтаксис: \\\\ name \\\\;
или \\\\ \\два термина\\ \\\\;
, т.е. указать имя макроса между четырьмя обратными слешами.
Необходимость использования отдельной синтаксической конструкции для удаления макросов вызвана тем, что имена макросов обрабатываются лексером еще до этапа анализа в парсере.
В чем профит?
- Базовый синтаксис языка можно разбавлять дополнительными ключевыми словами и превратить его в привычный «keyword-based».
- Определение макросов соответствует лексике языка, а сами макросы обрабатываются как обычные объекты.
- Простота анализа исходного кода и его отладки.
- Использование терминов DSL и приемов метапрограммирование можно сделать явным, например, всегда перед именем макроса указывать префикс. В этом случае компилятор будет однозначно знать, что требуется выполнить раскрытие макроса.
- Несмотря на то, что синтаксис языка на свой страх и риск можно значительно модифицировать, но это можно сделать только в рамках определенные ограничений (AST нельзя модифицировать напрямую), что не позволяется очень сильно разгуляться и, например, обрушить или подвесить компилятор.
- Несмотря на очень большие возможности по модификации синтаксиса, получается очень простая, быстрая и однозначная реализация. А это положительно сказывается на скорости анализа исходников, детектирования и обработки возможных ошибок и одновременно является разумным компромиссом между сложностью реализации данного функционала и возможностями определения собственных диалектов DSL.
- При желании есть куда развивать возможности метапрограммирования. В будущем можно добавить сопоставление шаблона с образцом (например, на основе регулярных выражений), сделать параметризацию строки для генерации синтаксиса в теле макроса, в том числе и в рантайме, и много других разных способов изящно выстрелить себе в ногу или ногу своего товарища.
Заключение
Буду благодарен за любую обратную связь по данной реализации макросов. И дважды благодарен, если кроме критики будут высказаны еще и предложения по её улучшению и доработкам, если какой-то момент был упущен.