Сообщений 5    Оценка 1180        Оценить  
Система Orphus

Метапрограммирование в Nemerle

Авторы: Kamil Skalski
Вроцлавский университет, Польша
Michal Moskal и Pawel Olszta
Вроцлавский университет, Польша

Перевод: Купаев Михаил
Владислав Чистяков

Источник: RSDN Magazine #1-2006
Опубликовано: 23.05.2006
Исправлено: 03.08.2006
Версия текста: 1.0
1. Введение
1.1. Наш вклад
1.2. Характеристики мета-системы Nemerle
2. Первые примеры
2.1. Выделение подъязыков из строк
3. Переменное число аргументов
4. Использование квази-цитирования в качестве образца в операциях сопоставления с образцом
5. Макросы, оперирующие над декларациями
5.1. Трансформация типов
6. Аспектно-ориентированное программирование
7. Детали дизайна
7.1. Макросы как функции
7.2. Связывание имен внутри цитирования
7.3. Прерывание гигиеничности
7.4. Лексическая область видимости глобальных символов
7.5. Доступ к внутренностям компилятора
8. Контроль типов при исполнении макроса
8.1. Пример
8.2. Пример PrintTuple
9. Как это работает
9.1. Система цитирования
9.3. Компиляция и загрузка
9.4. Раздельная компиляция
10. Связанные работы
10.1. Гигиеничные макросы Scheme
10.2. Template Haskell
10.3. Шаблоны C++
10.4. CamlP4
10.5. MacroML
10.6. MetaML
11. Планы
Благодарности
Ссылки

1. Введение

Идея метапрограммирования времени компиляции изучалась уже давно. Она была встроена в несколько языков, например, в виде макросов Lisp [3], «гигиенических» (hygienic) макросов Scheme[4], макросов препроцессора С, системы шаблонов C++ и, наконец, шаблонного метапрограммирования в Haskell [2]. Их возможности различаются, но все они подразумевают вычисления в процессе компиляции программы и генерирование кода.

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

Метаязык – это язык для программирования подобных операций. У него обычно есть собственный синтаксис для описания различных конструкций языка. Например, в нашей системе <[ 1 + f(2*x) ]> означает синтаксическое дерево выражения 1 + f(2*x). Эта идея называется квази-цитированием (quasi-quotation). Префикс quasi присутствует из-за возможности вставки значений выражений метаязыка в цитируемый контекст – если таким выражением является g(y), можно создать цитирование <[ 1 + $(g(y)) ]>, описывающее синтаксическое дерево, чья вторая часть заменяется результатом вычисления g(y).

1.1. Наш вклад

Наша работа привносит несколько новых идей, но наиболее важна уникальная комбинация мощной системы метапрограммирования и современного языка, обладающего объектно-ориентированными, функциональными и императивными возможностями.

Пример C++ показывает, что индустрии нужны системы метапрограммирования – даже достаточно причудливая система шаблонов широко используется для вычислений во время компиляции. Эта статья является исследованием возможного внедрения техники метапрограммирования в индустриальную среду в более чистой форме. Мы, таким образом, фокусируемся на том, чтобы сделать нашу систему легкой в использовании для программистов, как пишущих, так и использующих макросы.

ПРИМЕЧАНИЕ

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

Ключевые особенности нашего подхода:

  1. Мы разрабатываем цельную, «гигиеничную» и простую систему квази-цитирования, которая не требует изучения внутренних структур данных компилятора для создания и трансформации весьма сложных объектных программ. Она также предоставляет простой способ порождать конструкции с переменным количеством аргументов (типа кортежей или вызывов функций с произвольным числом параметров).
  2. Использование макросов прозрачно с точки зрения пользователя – метапрограмма и обычные вызовы функций неотличимы, так что пользователь может использовать сложнейший макрос, разработанный другими, не имея никакого понятия о метапрограммировании.
  3. Гибкое определение расширений синтаксиса позволяет еще более интуитивно встраивать макросы в язык, не пересекаясь с внутренностями компилятора.
  4. Нашу систему можно использовать для трансформации или генерации любых фрагментов программы, что, в сочетании с объектно-ориентированной структурой .NET, дает мощное средство для использования в различных методологиях разработки, например, в аспектно-ориентированном программировании.
  5. Мы разрешаем макросам в процессе исполнения типизировать фрагменты кода, с которыми они работают. Это позволяет параметризовать макрос не только синтаксическими элементами, но и вещами, зависящими от контекста, в котором раскрывается макрос (такими, как типы выражения).
  6. Раздельная компиляция макросов и кода, в котором эти макросы используются.

1.2. Характеристики мета-системы Nemerle

Наша мета-система предоставляет возможности как генерации, так и анализа программ [8]. Она может легко совершить обход абстрактного синтаксического дерева объектной программы и собрать информацию о нем или изменить его (зачастую с использованием собранных данных).

Система в основном рассчитана на работу с объектными программами во время компиляции. Однако, используя возможности .NET по динамической загрузке кода, можно исполнять макросы и в run-time.

Метаязык однороден. Это означает, что он таков же, как объектный язык. Мы можем использовать внутри макросов обычные функции Nemerle, и синтаксис сгенерированных программ не отличается от испольуемого при написании макросов.

Квази-цитирование дает четкое разделение объектной программы и метаязыка. Это делается с помощью ручного аннотирования, разделяющего этапы исполнения вполне понятным образом. Таким образом, достаточно ясно, какая часть кода генерирует, а какая является генерируемой. Символы в объектном коде специальным образом переименовываются, так что они не пересекаются с внешним кодом.

2. Первые примеры

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

macro for (init, cond, change, body) 
{ 
  <[ 
    $init; 

    def loop() 
    { 
      if ($cond) { $body; $change; loop() } else ()
    }; 

    loop() 
  ]>
}

Этот код создает специальную метафункцию, исполняемую во время компиляции в каждом месте, где помещен ее вызов. Ее результат затем вставляется в программу. Во всех местах, где написано что-то вроде

for(i=0,i< n,i =i+2,a[i]=i) 

макросом for создается соответствующий код, заменяющий исходный вызов.

Макрос может указывать компилятору, как расширять синтаксис языка – например, можно определить макрос для цикла for с С-подобным синтаксисом.

macro for(init, cond, change, body) 
syntax ("for", "(", init, ";", cond, ";", change, ")", body) 
{ 
  ... 
} 

добавит в парсер новое правило, которое позволит писать:

for (i = 0;i < n;i = i + 2)
  a[i] = i 

вместо вызова, упоминавшегося выше.

2.1. Выделение подъязыков из строк

Макросы очень полезны при проверках и обработке кода, написанного в виде строк. Это относится ко многим простым языкам, например, строкам форматирования а-ля printf, регулярным выражениям или даже SQL-запросам, которые часто используются внутри программ.

Рассмотрим распространенную ситуацию, когда нужно параметризовать SQL-запрос некоторыми значениями из нашей программы. Большинство провайдеров БД в .NET Framework позволяют использовать команды с параметрами, но при компиляции не проверяется ни их синтаксис, ни согласованность типов данных в SQL и в программе.

Имея хорошо написанный макрос, можно было бы написать:

sql_loop(conn, "SELECT salary, LOWER(name) AS lname" 
  " FROM employees" 
  " WHERE salary > $min_salary") 
printf("%s : %d\n", lname, salary) 

в целях повышения типобезопасности и усиления связи между подъязыком и основной программой, приведенный выше, код переписывается макросами в следующий:

def cmd = SqlCommand("SELECT salary, LOWER(name)"
  " FROM employees"
  " WHERE salary > @parm1", conn);

(cmd.Parameters.Add(SqlParameter("@parm1", DbType.Int32))).Value = min_salary;
  def r = cmd.ExecuteReader();

while(r.Read()) 
{
  def salary = r.GetInt32(0); 
  def lname = r.GetString(1); 
  printf("%s : %d\n", lname, salary) 
}

На самом деле функция printf здесь – это еще один макрос, который проверяет соответствие параметров строке форматирования во время компиляции.

Приведенная выше конструкция выдает более безопасный и читаемый код по сравнению с .NET-вариантом.

3. Переменное число аргументов

Цитирование дает полную свободу при конструировании любых видов выражений. Например, можно разобрать кортеж (tuple) любого размера и распечатать его элементы.

macro PrintTuple(tup, size : int) 
{ 
  def symbols = array(size); 
  mutable pvars = []; 
  mutable exps = []; 

  for (mutable i = size - 1; i >= 0; i--) 
  { 
    symbols[i] = NewSymbol(); 
    pvars = <[ $(symbols[i] : name) ]> :: pvars; 
    exps = <[ WriteLine($(symbols[i] : name)) ]> :: exps; 
  }; 

  exps = <[ def (.. $pvars) = $tup ]> :: exps;
  <[ {.. $exps } ]>
}

Заметьте, что здесь нам нужно число, описывающее размер кортежа. Дальше мы покажем, как получить в макросе тип данного выражения. Это, например, позволяет вычислить размер кортежа, хранящийся в переменной tup.

4. Использование квази-цитирования в качестве образца в операциях сопоставления с образцом

Цитирование можно использовать для анализа структуры программы так же легко, как и для ее генерации. Стандартный механизм языка, поиск по образцу, вполне подходит для этой цели.

В качестве примера приведем реализацию оператора <->, меняющего значения двух выражений – как в x <-> arr[2]. Сперва рассмотрим простой подход:

macro @<->(e1, e2) 
{
  <[ def tmp = $e1; $e1 = $e2; $e2 = tmp; ]>
}

У этого подхода, однако, есть один недостаток – оба выражения вычисляются дважды. Например, попытка поменять местами значения двух случайно выбранных элементов массива (a[rnd()] <-> a[rnd()]) не даст желаемого результата. Можно справиться с этим, предварительно рассчитав обмениваемые части выражений (атрибут [Hygienic] обсуждается ниже):

[Hygienic]
cache(e : Expr) : Expr * Expr
{ | <[ $obj.$mem ]> => (<[ def tmp = $obj ]>, <[ tmp.$mem ]>) 
  | <[ $tab [$idx] ]> => 
    (<[ def (tmp1, tmp2) = ($tab, $idx) ]>, <[ tmp1 [tmp2] ]>)
  | _ => (<[()]>, e)
}

Эта функция возвращает пару выражений. Первое используется для кеширования значений, а второе – для действий над созданным эквивалентом исходного выражения. Теперь можно реализовать оператор <-> так:

macro @<->(e1, e2) 
{
  def (cached1, safe1) = cache(e1);
  def (cached2, safe2) = cache(e2);

  <[
    $cached1;
    $cached2;
    def tmp = $safe1;
    $safe1 = $safe2;
    $safe2 = tmp;
  ]>
}

5. Макросы, оперирующие над декларациями

Макросы могут работать не только с выражениями, паттернами, типами, но и вообще с любыми частями языка, например, классами, интерфейсами, объявлениями типов, методами и т.д. Синтаксис этих операций совершенно иной. Конструкции языка опять же рассматриваются как объекты, которые могут быть трансформированы, но это делается не совсем с помощью цитирования. Мы используем специальный API, разработанный на основе System.Reflection, используемого в .NET. Однако система типов Nemerle и операции, которые мы выполняем над этими типами, не вполне совместимы с интерфейсом Reflection, поэтому API извлечения информации о метаданных в Nemerle очень похож на API Reflection, но не совместим с ним.

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

Такие макросы не вызываются как обычные функции, а добавляются как атрибуты перед декларациями, так же, как атрибуты в C#:

[SerializeBinary()]
public module Company 
{
  [ToXML("Company.xml")]
  public class Employee
  { 
    ...
  }

  [FromXML("Product.xml"), Comparable()]
  public class Product { }
} 

5.1. Трансформация типов

Макросы, работающие с декларациями, изменяют их в императивной манере. Их первый параметр – всегда объект, представляющий данную декларацию.

macro ToXML(ty : TypeBuilder, file : string)

Можно легко перечислить данные, содержащиеся в этом объекте, например, поля или методы класса, и добавить новый метод, используя их имена:

def fields = ty.GetFields(
  BindingFlags.Instance 
  %| BindingFlags.Public
  %| BindingFlags.DeclaredOnly);

def list_fields = List.Map(fields, 
  fun(x)
  { <[
      xml_writer.WriteAttributeString($(x.Name : string),
        $(x.Name : usesite).ToString())
    ]>
  });

ty.Define(<[ decl:
    public ToXML() : void
    {
      def xml_writer = XmlTextWriter($(file : string), null);
      { ..$list_fields };
      xml_writer.Close();
    }
  ]>);

Используя приведенный выше макрос (возможно, измененный для выполнения дополнительного форматирования) можно сгенерировать методы сериализации для любого класса, просто добавив атрибут [ToXML("file.xml")].

6. Аспектно-ориентированное программирование

Средства метапрограммирования, предоставляемые Nemerle, позволяют решать очень широкий круг задач, в том числе встроить в язык средства аспектно-ориентированного программирования. В статье Meta-programming in Nemerle (http://nemerle.org/metaprogramming.pdf) об этом сказано подробнее. Однако в цели данного обзора разбор реализации АОП средствами Nemerle не входит.

7. Детали дизайна

В этом разделе дается более формальное определение макроса и нашей мета-системы.

Макрос – это предваряемая ключевым словом macro глобальная функция, которая, как и другие функции, может иметь модификаторы доступа (public, private и т.д.), и находится в пространстве имен .NET/Nemerle. Она используется в коде как любая другая функция, но расценивается компилятором особым образом.

Типы ее формальных параметров ограничены набором элементов грамматики Nemerle (включая простые литералы). Параметры макроса часто передаются в виде синтаксических деревьев, которые для некоторых типов дополнительно обрабатываются. Например, по идее литералы в макросах должны представляться (как и все остальное) в виде синтаксических деревьев, но в этом нет особого смысла, так как эти значения принципиально известны во время компиляции.

7.1. Макросы как функции

Макрос не может быть вызван рекурсивно или передан как первоклассный объект (хотя он может сгенерировать код, содержащий вызов этого макроса). Однако он может использовать любую функцию программы в стандартной манере ML, что мы рассматриваем как небольшой недостаток. Если нужны сложные вычисления над синтаксическими деревьями, их можно просто поместить в некую произвольную функцию и вызвать эту функцию из макроса. Такой дизайн позволяет легко определять, какие функции выполняются во время компиляции, не требуя никакого специального аннотирования в местах их вызова.

7.2. Связывание имен внутри цитирования

Очень важное свойство мета-системы – «гигиена» (hygiene). Оно относится к проблеме пересечения (захвата) имен (names capture) в макросах Lisp, решенной позднее в Scheme. Оно оговаривает, что переменные, введенные в макросе, не должны быть связаны с переменными, используемыми в коде, переданном этому макросу. В частности, переменные с одинаковыми именами, но приходящие из разных контекстов, должны быть автоматически распознаны и переименованы.

Рассмотрим следующий пример:

macro identity(e) { <[ def f(x) { x }; f($e) ]> } 

Вызов его с (f(1)) может сгенерировать некорректный код типа:

def f(x) { x }; f(f(1)) 

Чтобы предотвратить пересечение имен, все сгенерированные макросом переменные должны быть переименованы и получить уникальные имена, как в:

def f_42(x_43) { x_43 }; f_42(f(1)) 

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

def d1 = <[ def x = y + foo(4) ]>; 
def d2 = <[ def y = $(Bar.Compute() : int) ]> 
<[
  $d2; 
  deffoo(x) {x+ 1}; 
  $d1; 
  x*2 
]> 

Поскольку макрос может быть большим и сложным, зачастую очень полезно вычислять части выражения независимо, а затем уже составлять из них финальный код. Кроме того, имена в таком макросе специальным образом переименованы, так что они не пересекаются ни с какими внешними определениями. Переименование определяется как помещение имен, созданных при единичном исполнении макроса, в то же «пространство имен», не пересекающееся со всеми остальными «пространствами имен» и кодом верхнего уровня. Это и есть правило «гигиены» - макрос не может ни использовать имена, используемые в месте его использования, ни определять чего-нибудь, пересекающегося с внешним кодом.

Этот подход противоположен используемому в Template Haskell [2], где лексическая область видимости (lexical scoping) означает привязку переменных из объектного кода непосредственно к определениям, видимым в месте создания цитирования. Мы находим наш подход более гибким, так как мы можем более свободно трансформировать код, сохраняя «гигиеничность» системы. Это, конечно, не более чем дизайн-решение, которое, естественно, имеет свою цену. Иногда при просмотре кода не слишком очевидно, как пересоздать связывания, но здесь мы предполагаем, что разработчик макроса знает структуру генерируемого кода. Мы также теряем возможность раннего обнаружения некоторых ошибок, но, поскольку они всегда отлавливаются при компиляции сгенерированного кода, мы считаем это небольшим недостатком.

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

[Hygienic] f(x : Expr) : Expr { ... } 

Атрибут [Hygienic] – это простой макрос, который трансформирует f, чтобы задействовать при исполнении свой собственный контекст. Таким способом функция получает ту же семантику, что и макрос, в отношении гигиены. Мы считаем, что такое поведение плохо использовать по умолчанию, так как код часто генерируется какими-нибудь вспомогательными функциями, специально определенными в макросе локально, а они не должны менять свой контекст.

7.3. Прерывание гигиеничности

Иногда бывает полезно, чтобы при нескольких исполнениях макросов использовались одни имена. Это можно безопасно осуществить, сгенерировав уникальный идентификатор, независимый от исполнения макроса. Это делается с помощью функции NewSymbol(), чье возвращаемое значение можно сохранить в переменной, что позволяет сохранить «гигиеничность».

Бывают также ситуации, когда нам известно точное имя используемой в коде переменной, передаваемой макросу. Если нужно определить имя, ссылающееся на нее, нужно заменить ее область видимости областью видимости макроса. Рассмотрим макрос, определяющий ключевое слово using (ключевое слово C#, упрощено для целей статьи):

macro using(name : string, val, body) 
{ 
  <[ 
    def $(name : usesite) = $val; 
    try { $body } finally { $v.Dispose() } 
  ]>
}

Он должен определить привязку символа к переменным с тем же именем в body. Но если бы он содержал некий внешний код, как здесь:

macro bar(ext) 
{
  <[ using ("x", Foo(), { $ext; x.Compute() }) ]>
}

могло бы произойти ненамеренное пересечение переменных в ext, если х был просто переменной с динамической областью видимости.

Хотя это и не рекомендуется, можно создать и негигиенические символы с помощью $(x : dyn), где тип х – string. Они привязаны к ближайшему определению с тем же именем, появляющемуся в сгенерированном коде, независимо от контекста.

7.4. Лексическая область видимости глобальных символов

Объектный код часто ссылается на переменные, типы или конструкторы, импортируемые из других модулей (например, стандартную библиотеку .NET или символы, определенные в пространстве имен макроса). В обычном коде можно опустить префикс полного имени, использовав ключевое слово using, импортирующее символы из заданного пространства имен. К сожалению, такая возможность при использовании в следующем коде:

using System.Text.RegularExpressions; 
using Finder; 
macro finddigit(x : string) 
{
  <[
    def numreg = Regex(@"\d+-\d+");

    def m = numreg.Match(current + x);
    m.Success();
  ]>
}

public module Finder 
{
  public static current : string;
}

привносит зависимость от импортированных на текущий момент пространств имен. Лучше бы сгенерированному коду вести себя одинаково независимо от того, где он используется, и соответственно, конструктор Regex и переменную current нужно развернуть до их полных имен – System.Text.RegularExpressions.Regex и Finder.current. Эта операция выполняется автоматически системой цитирования. Если символ не определен локально (и в том же контексте, что описан в предыдущей секции), его ищут среди глобальных символов, импортируемых местом, в котором определено цитирование.

Заметьте, что таким способом нельзя нарушить ограничения безопасности. Права доступа из лексической области видимости макроса не экспортируются в место использования сгенерированного кода. Политики .NET не позволяют этого, то есть программист не должен генерировать код, нарушающий статическую безопасность.

7.5. Доступ к внутренностям компилятора

Для метафункций жизненно важно уметь использовать все преимущества, которые дает исполнение во время компиляции. Они могут получать информацию от компилятора, и использовать его методы для доступа, анализа и изменения данных, хранящихся в его внутренних структурах.

Например, мы можем попросить компилятор возвратить декларацию типа для данного типа. Она будет доступна в виде синтаксического дерева, как если бы мы ввели перед этой декларацией специальный атрибут (раздел 5). Конечно, такая декларация не должна быть внешним типом и должна быть доступна в скомпилированной программе в виде исходного кода.

def decl = Macros.GetType(<[ type: Person ]>); 
xmlize(decl); // можно применять макросы к декларациям

8. Контроль типов при исполнении макроса

Есть и более изощренные способы. Они включают более тесное взаимодействие с компилятором, использование его методов и структур данных или даже переплетение с внутренними этапами компиляции.

Например, можно попросить компилятор типизировать AST кода, передаваемого макросу. Это значит, что мы можем втиснуться между многими важными действиями, выполняемыми компилятором, и добавить туда наш собственный код. Это может быть применено для улучшения сообщений об ошибках (особенно для макросов, определяющих сложные расширения синтаксиса), делая кодогенерацию зависимой от типов входящих программ или улучшая анализ кода благодаря дополнительной информации.

8.1. Пример

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

macro @if(cond, e1, e2)
syntax ("if", "(", cond, ")", e1, "else", e1) 
{
  <[
    match ($cond) 
    {
      | true => $e1
      | false => $e2
    }
  ]>
}

Встретив if ("bar") true else false, компилятор пожалуется, что «type of matched expression is not bool». Такое сообщение об ошибке может сбить с толку, так как программист может не знать, что его выражение if превратилось в выражение match. Таким образом, подобные ошибки лучше отлавливать при исполнении макроса, чтобы иметь возможность выдать более подробное сообщение.

Вместо прямой передачи объектных выражений результату макроса, можно сперва заставить компилятор типизировать их и затем проверить, того ли они типа. Тело приведенного выше макроса if должно выглядеть так:

def tcond = TypedExpr(cond);
def te1 = TypedExpr(e1);
def te2 = TypedExpr(e2);
if (tcond.Type == <[ ttype: bool ]> )
{
  <[ 
    match ($(tcond : typed)) 
    {
      | true => $(te1 : typed)
      | false => $(te2 : typed)
    } 
  ]>
}
else
  FailWith("‘if’ condition must have type bool, " 
  + "while it has " + tcond.Type.ToString())

Заметьте, что типизированные выражения снова используются в цитировании, но со специальным splicing-тегом “typed”. Это значит, что компилятору не придется выполнять типизацию (на самом деле, начиная с этого момента он и не может этого сделать) синтаксических деревьев. Такая нотация обеспечивает некоторую отложенность типизации, управляемую непосредственно программистом макроса.

Под термином splicing здесь и далее в этой статье понимается выделение частей цитирования с помощью знака $. Такие части рассматриваются не как описание AST, а как код, порождающий участок AST вследствие своего выполнения. Код внутри splice-ов рассматривается как обычный код, находящийся рядом с цитированием, но вне его. – прим.ред.

8.2. Пример PrintTuple

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

match (TypedExpr(tup).Type) 
{ | <[ ttype: (..args) ]> =>
  def size = List.Length(args);
  ..
}

9. Как это работает

Теперь опишем, как работает наша метасистема изнутри.

Каждый макрос транслируется в отдельный класс, реализующий особый интерфейс IMacro. Он предоставляет метод запуска макроса, который в большинстве случаев включает передачу ему списка грамматических элементов Nemerle (нетипизированных синтаксических деревьев объектных программ).

Поэтому на уровне компилятора макрос – это функция, оперирующая с синтаксическими деревьями. Компилятор Nemerle использует несколько видов синтаксических деревьев. Мы сосредоточимся на деревьях разбора (parse trees) и типизированных деревьях (typed trees).

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

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

Процесс типизации в компиляторе в основном производит трансформацию деревьев разбора в типизированные деревья.

Функция типизации, встречая вызов макроса, исполняет метод Run соответствующего объекта макроса. Вызов макроса выглядит как обычный вызов функции, таким образом мы отличаем эти два случая, ища название вызванной функции в списке загруженных в текущее время макросов (интерфейс IMacro имеет специальный метод GetName).

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

9.1. Система цитирования

Система цитирования – это просто сокращенная запись для явного конструирования синтаксических деревьев. Например, выражение f(x) внутренне представляется как E_call(E_ref("f"), [Parm(E_ref("x"))]), что эквивалентно <[ f(x) ]>. Трансляция цитирования задействует «подъем» синтаксического дерева на следующий уровень – нам дано выражение, представляющее программу (ее синтаксическое дерево), и мы должны создать представление данного выражения (большее синтаксическое дерево). Это подразумевает построение синтаксического дерева для данного синтаксического дерева:

E_call(E_ref("f"), [Parm(E_ref("x"))] 
=> 
E_call("E_call", 
  [Parm(E_call("E_ref", [Parm(E_literal(L_string("f")))])); 
  Parm(E_call("Cons", [Parm(E_call("Parm", 
    [Parm(E_call("E_ref", 
      [Parm(E_literal(L_string("x")))]))]))]))]) 

или, используя цитирование

<[ f(x) ]> 
=> <[ E_call(E_ref("f"), [Parm(E_ref("x"))]) ]> 

Теперь splicing просто означает «не поднимать», поскольку мы хотим передать значение выражения метаязыка как объектный код. Конечно, это верно, только если такое выражение описывает синтаксическое дерево (или имеет его тип). Оператор .. внутри цитирования транслируется как синтаксическое дерево, отражающее список, содержащий «поднятые» выражения из предоставленного списка (который должен следовать за ..).

9.3. Компиляция и загрузка

Ключевой элемент нашей системы – исполнение метапрограмм во время компиляции. Для этого они должны иметь исполняемую форму и быть скомпилированы до использования.

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

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

9.4. Раздельная компиляция

Текущая реализация требует, чтобы макрос компилировался в отдельном проходе, до компиляции использующей его программы. Это выливается в невозможность определения и использования макроса в той же единице компиляции. Мы работаем над генерацией и исполнением макросов при компиляции в один прием, но у принятого нами в текущее время подхода тоже есть ряд достоинств.

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

Главная проблема с ad-hoc макросами (вводимыми и используемыми в одном сеансе компиляции) состоит в том, что сперва нужно скомпилировать транзитивное замыкание типов (классы с методами), используемых данным макросом. Разумеется, данный макрос не может использоваться в этих типах.

Эту проблему может быть трудно понять программистам (почему моя программа не компилируется после добавления поля к этому классу?). С другой стороны, такой набор типов и макросов с замкнутыми зависимостями можно легко вынести из программы в библиотеку.

Опыт сообщества Scheme показывает [5], как много проблем возникает в системах, не предоставляющих четкого разделения этапов компиляции. На самом деле, чтобы избежать их в больших программах, приходится вводить ручное аннотирование с указанием зависимостей между макробиблиотеками.

10. Связанные работы

10.1. Гигиеничные макросы Scheme

У нашей системы много общего с современными макрорасширениями Scheme[1]:

  1. Альфа-переименование и связывание переменных выполняется после развертывания макроса, с использованием контекста, хранимого в макросе.
  2. Макросы могут контролируемо использовать внешние имена, не боясь пересечься с посторонними именами.
  3. Сторона, на которой вызывается макрос, не использует какого-либо особого синтаксиса для вызова макросов, единственное место, где используется особый синтаксис – определение макроса, что обуславливает легкость использования макросов программистами, несведущими в метапрограммировании.

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

Работа над Scheme длилась довольно долго, и предлагалось много интересных вещей. Например, макросы первого класса в [9], кажется, можно реализовать в Nemerle с помощью простой передачи функций, оперирующих кодом объектов.

10.2. Template Haskell

Между макросами Template Haskell [2] и Nemerle есть интересные различия:

  1. Разрешение связываний при трансляции цитирования дает возможность сделать вывод о корректности типов объектного кода до его использования. Это позволяет значительно раньше обнаруживать ошибки. Но наличие splicing-конструкции $ делает типизацию отложенной до следующего этапа компиляции, и в этом случае новые связывания должны быть динамическими.
  2. Макросы Template Haskell, как и любая другая функция Haskell, являются функциями высшего порядка: их можно передавать как аргументы, применять частично и т.д. Это, однако, требует вручную помечать, какой код должен исполняться при компиляции. Мы решили позволить вызывать макросы просто по имени (как в Scheme), так что их использование выглядит как вызов обычной функции. Вы по-прежнему можете использовать функции высшего порядка (функции, оперирующие объектным кодом, могут быть произвольными), но только верхние метафункции (предваряемые macro) запускают вычисления во время компиляции.
  3. Мы не ограничиваем splicing объявлений с кодом верхнего уровня и не вводим особого синтаксиса для вводящих их макросов. Это кажется хорошим способом использования преимуществ связывания имен после раскрытия макроса и императивного стиля, присущего Nemerle. Для императивного программиста естественно думать о вводимых определениях как о побочных эффектах вызова макросов, даже если эти вызовы находятся в цитируемом коде.
  4. Мы вводим макросы, оперирующие с типами, объявляемыми в программе, и способные императивно изменять их. Более того, они выглядят как атрибуты, применяемые к определениям типов, так что опять же программисту не обязательно знать что-нибудь о метапрограммировании, чтобы использовать их.

Тем не менее, есть и множество сходств с Template Haskell. Мы напрямую позаимствовали оттуда идею квази-цитирования и splicing-а. Идеи исполнения функций при компиляции и отложенной проверки типов также навеяна Template Haskell.

10.3. Шаблоны C++

Шаблоны C++ [11], возможно, наиболее часто используемая из существующих систем метапрограммирования. Они предлагают полную по Тьюрингу макросистему времени компиляции. Однако сомнительно, что полнота по Тьюрингу была реализована намеренно, и выражение сложных программ через систему типов может быть весьма неуклюжим. Тем не менее, широкое распространение этой возможности показывает востребованность систем метапрограммирования индустрией.

Есть несколько уроков, усвоенных нами на примере C++. Первое – сохранять простоту системы. Второе – в Nemerle выбрана обязательная предварительная компиляция макросов, чтобы не полагаться на различные нестандартные расширения компиляторов, наподобие прекомпиляции заголовочных файлов C++, которые все равно мало что дают для ускорения метакода.

10.4. CamlP4

CamlP4 [12] – это препроцессор для OCaml. Его LL(1)-парсер – полностью динамический, что позволяет выразить весьма сложные расширения грамматики. Макросы (именуемые расширениями синтаксиса) должны быть скомпилированы и загружены в парсер до использования. При работе они могут конструировать новые выражения (используя систему цитирования), но только на уровне нетипизированного дерева разбора. Более углубленное взаимодействие с компилятором невозможно.

10.5. MacroML

В MacroML [6] было предложено использовать макросы времени компиляции для языка ML. Это имеет много общего с Template Haskell в смысле связывания имен в цитировании до раскрытия макросов. MacroML, кроме того, позволяет захватывать символы в месте применения макроса (это похоже на нашу функцию UseSiteSymbol()). И все это производится без необходимости отказываться от типизации цитат.

Макросы в MacroML ограничены конструированием кода из имеющихся частей, анализ и декомпозиция кода невозможны.

10.6. MetaML

MetaML [7] вдохновил и Template Haskell и MacroML, введя квази-цитирование и идею типизации объектного кода. Он был разработан в основном для операций с кодом и его исполнения в рантайме, так что это несколько отличающаяся от нашей область деятельности.

11. Планы

Генерация программ во время исполнения остается очень широким неисследованным полем деятельности для нашей метасистемы. Это будет полезно для оптимизации, основанной на данных, доступных только во время исполнения.

Мы сосредоточимся на реализации интерфейса с возможностями аспектно-ориентированного программирования в Nemerle. Это кажется хорошим способом введения парадигмы метапрограммирования в коммерческое окружение.

Ранняя типизация объектного кода – для выявления максимума ошибок при компиляции макроса (в противоположность раскрытию макроса) мы должны поддерживать особый вид функций типизации. Конечно, мы не можем определить тип «$()» в объектном коде (это, очевидно, неразрешимая задача). Вдобавок мы ограничены связыванием идентификаторов, выполняемым после раскрытия, но все же мы можем отвергнуть такие программы как <[1+ (x:string) ]>.

Благодарности

Хотелось бы поблагодарить Marcin Kowalczyk за крайне конструктивную дискуссию по гигиеничным системам, Lukasz Kaiser за полезные замечания по системе цитирования и Ewa Dacko за корректуру этой статьи.

Ссылки

  1. Dybvig, R. K., Hieb, R., Bruggeman, C.: Syntactic Abstraction in Scheme. Lisp and Symbolic Computations, 1993
  2. Sheard, T., Jones, S. P.: Template Meta-programming for Haskell. Haskell Workshop, Oct. 2002, Pittsburgh
  3. Steele, Jr., Guy, L. Common Lisp, the Language. Digital Press, second edition (1990)
  4. Clinger, William, Rees, Jonathan et al. The revised report on the algorithmic language Scheme. LISP Pointers, 4, 3 (1991)
  5. Flatt, M.: Composable and Compilable Macros. ICFP, Oct. 2002, Pittsburgh
  6. Ganz, S., Sabry, A., Taha, W.: Macros as multi-stage computations: type-safe, generative, binding macros in MacroML. Preceedings of the ACM SIGPLAN International Conference on Functional Programming (ICFP-2001), New York, Sep. 2001
  7. Sheard, T., Benaissa, Z., Martel, M.: Introduction to multistage programming: Using MetaML.
  8. Sheard, T.: Accomplishments and Research Challenges in Meta-Programming. 2001
  9. Bawden, A.: First-class Macros Have Types. Symposium on Principles of Programming Languages, 2000
  10. http://eclipse.org/aspectj/
  11. Veldhuizen, T.: Using C++ template metaprograms. C++ Report Vol. 7 No. 4 (May 1995), pp. 36-43.
  12. Rauglaudre, D.: Camlp4 – Reference Manual. http://caml.inria.fr/camlp4/

Эта статья опубликована в журнале RSDN Magazine #1-2006. Информацию о журнале можно найти здесь
    Сообщений 5    Оценка 1180        Оценить