Добавление своей языковой конструкции в D

Существует ряд языков программирования (ЯП), синтаксис которых либо допускает введение конструкций, определяемых пользователем; либо разрешает переопределение (доопределение) существующих элементов языка. Такого рода языки делают программирование еще более приятным и интересным, позволяя реализовывать идеи из любого ЯП. Не знаю, как вам, а мне порой хочется иметь в своем любимом языке некоторые элементы, взятые из какого-то другого ЯП. Изучение других ЯП очень стимулирует, а к некоторым вещам просто возникает привычка, от которой никуда не денешься. Хотелось бы, чтобы у D была расширяемость синтаксиса, но, к сожалению, ее нет…

Тем не менее, это не помешает ввести в D элемент, например, из Haskell, а все что для этого потребуется – умелая работа со строками и немного того, что принято называть шаблонной магией… Ради интереса, попробуем добавить в D такую интересную штуку, как генератор списков (list comprehension) и которая нам известна по таким языкам программирования, как Python, Haskell и некоторые другие. В качестве образца возьмем синтаксис генератора списков из Haskell, т.к. его легче реализовать, и он более лаконичен. Синтаксис генератора списков в общем виде выглядит примерно так:

[f(x) | x <- xs],

где f(x) – некоторая функция от элемента x, который берется из списка xs.

Результатом выполнения приведенного генератора будет новый список, элементы которого получаются путем применения функции f к элементам xs.

Чтобы было понятнее, можно предположить, что генератор списка берет некоторый массив, некоторую функцию и создает новый массив, который получен путем воздействия функции на каждый элемент исходного массива. Именно такое поведение, мы и будем реализовывать с помощью разбора строк и шаблонов: разработаем некоторый шаблон, который примет строку с выражением для генератора списков, переведет его в код на D и выполнит этот код, возвратив результат.

Первое, что придется сделать – это разобрать строку с выражением генератора и сгенерировать код на D. Заметим, что выражение заключено в квадратные скобки, которые обозначают, что сам генератор списка дает массив. Поэтому перед разбором строки необходимо удалить эти скобки, а перед их удалением, требуется избавиться от начальных и конечных пробелов в строке выражения (это достигается использованием функции strip из std.string). После удаления квадратных скобок, выражение состоит из двух частей: выражение для функции (т.е. по сути ее тело без сигнатуры и оператора return) и выражения, описывающего взятие элемента из некоторого списка (оно состоит по сути дела из некоторой внутренней переменной и выражения, описывающего сам список). Эти две составные части генератора списков разделены только знаком “|” и больше ничем (кроме пробелов, разумеется).

Этим фактом мы и воспользуемся: с помощью split из std.string разобъем строку по разделителю “|”, уберем начальные и концевые пробелы из двух получившихся частей. Первая часть уже содержит одно из интересующих нас выражений (а именно, тело функции), а вот второе нужно разбирать отдельно…

Почему так? Потому, что из второго выражения можно получить имя переменной и выражение для исходного списка. Стоит заметить, что переменная отделена от списка только символами “<-”, по которым и осуществим разбиение, после которого над каждой частью выполним strip. Теперь у нас есть: имя переменной из функции, функция и сам список, вопрос только в том, как это оттранслировать в D. После некоторых раздумий и экспериментов, я пришел к выводу, что проще всего будет сформировать с помощью format строку следующего вида:

map!(имя_переменной  => тело_функции).array

Это довольно простой и понятный способ, который можно реализовать вот так:

auto translateList(string expression)()
{
    import std.algorithm;
    import std.array;
    import std.string;

    auto parsedExpression = expression.strip[1..$-1].split("|");
    auto listExpression = parsedExpression[1].strip.split("<-");

    string variableName = listExpression[0].strip;
    string functionBody = parsedExpression[0].strip;                                              
    string sourceList = listExpression[1].strip;

    string constructedFunction = format(
      `
        map!(%s => %s)(%s).array
      `, 
            variableName, 
            functionBody, 
            sourceList
        );

    return constructedFunction.strip;
}

Кстати, один забавный момент: работает все это дело только во время компиляции, поскольку сама функция translateList является своеобразной параметризованной функцией, единственный параметр которой подается прямо во время компиляции.

Испытать функцию можно, например, так:

writeln(translateList!"[ 2 * x + 1 | temporary <- iota(0, 10) ]");

Результатом будет строка, в которую будет развернут генератор списков, в данном случае строка будет выглядеть так:

map!(temporary => 2 * x + 1)(iota(0, 10)).array

Выполнение строки с подстановкой результата выглядит несколько сложнее. Сначала введем вспомогательный эпонимичный (т.е. одноименный) шаблон следующего вида:

template evaluateForm(string functionalForm)
{
	enum evaluateForm = functionalForm;
}

Этот код работает очень просто: на место употребления шаблона просто вставляется некоторая (функциональная) форма из языка D, в роли которой может быть практически любое выражение (за некоторым исключением). Шаблон evaluateForm обеспечит лишь простую подстановку, но помимо этого, необходимо гарантированное выполнение оттранслированного в D генератора списка, чего шаблон evaluateForm не сможет сделать. Однако, применяя инструкцию mixin, можно выполнить любую валидную строку кода на D (есть целый ряд ограничений, который описан в документации).

Используя всю мощь этого языкового средства D создадим наконец итоговый генератор списков, совместив все рассмотренные процедуры в одну:

auto overList(string expression)()
{
  import std.algorithm;
  import std.range;

  return mixin(evaluateForm!(translateList!expression));
}

Работает это в свете уже рассмотренного весьма тривиально: шаблон evaluateForm подставляет в mixin инструкцию, которая была сгенерирована функцией translateList; после чего mixin выполняет сгенерированный во время компиляции D код. Использовать функцию overList очень просто:

auto f(int x)
{
    import std.math;
    return sin(cast(double) x);
}

void main()
{
  writeln(translateList!"[ 2 * x + 1 | x <- iota(0, 10) ]");
  writeln(overList!"[ 2 * f(temporary) + 1 | temporary <- iota(0, 5) ]");
  writeln(overList!`[ f(phi) | phi <- iota(0, 10)]`);
  writeln(overList!`[ 2 * x | x <- [1,2,3,4,5] ]`);
}

Результат выглядит так:

[1, 2.68294, 2.81859, 1.28224, -0.513605]
[0, 0.841471, 0.909297, 0.14112, -0.756802, -0.958924, -0.279415, 0.656987, 0.989358, 0.412118]
[2, 4, 6, 8, 10]

Здорово, не правда ли?!

Однако, я в ходе реализации идеи нашел один неприятный момент, связанный с разработанной процедурой overList… Оказалось, что любая функция из стандартной библиотеки (кроме функций из модулей std.algorithm и std.range), введенная в шаблон overList приведет к ошибке, в которой будет написано что-то про undefined. Дело в том, как осуществляется подстановка результата mixin: то, что было в шаблоне на подстановку, то и подставится, иначе говоря, при таком использование шаблона ограничено областью видимости выражения (а точнее, шаблона), попавшего во внутрь overList. Казалось бы, на этом все, но я случайно нашел «грязный хак», который позволяет обойти ограничение: достаточно описать свою функцию, поместив в нее все нужные импорты или просто вставив в нее выполнение нужной функции из стандартной библиотеки, как например, это было в примере выше:

auto f(int x)
{
    import std.math;
    return sin(cast(double) x);
}

Помимо этого, есть еще одна нехорошая особенность overList – дело в том, что все эти манипуляции происходят на стадии компиляции. Впрочем, эта особенность – палка о двух концах, и в большинстве случаев, никакой разницы нет (однако, это не означает, что о том, как выполняется эта процедура следует забыть, в противном случае, можно нарваться на внезапные ошибки).

Резюмируя статью, скажу, что хоть D не является расширяемым языком, и тем более не является динамическим, однако, с помощью продвинутых средств метапрограммирования (выполнение функций на стадии компиляции, шаблоны, встраиваемый код, текстовый импорт и т.д.) можно эмулировать некоторое поведение других ЯП (в том числе, и динамических), а также создавать целые предметно-ориентированные языки (DSL = domain specific language, предметно-ориентированный язык) и препроцессоры!

Добавить комментарий