Сообщений 5 Оценка 1180 Оценить |
Идея метапрограммирования времени компиляции изучалась уже давно. Она была встроена в несколько языков, например, в виде макросов 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).
Наша работа привносит несколько новых идей, но наиболее важна уникальная комбинация мощной системы метапрограммирования и современного языка, обладающего объектно-ориентированными, функциональными и императивными возможностями.
Пример C++ показывает, что индустрии нужны системы метапрограммирования – даже достаточно причудливая система шаблонов широко используется для вычислений во время компиляции. Эта статья является исследованием возможного внедрения техники метапрограммирования в индустриальную среду в более чистой форме. Мы, таким образом, фокусируемся на том, чтобы сделать нашу систему легкой в использовании для программистов, как пишущих, так и использующих макросы.
ПРИМЕЧАНИЕ Метапрограммирование подразумевает наличие двух программ – метапрограммы и порождаемой или модифицируемой программы. В этой статье порождаемая (модифицируемая) программа в дальнейшем будет называться объектной программой, а код этой программы – объектным кодом. Встретив в этой статье слово «объектный» без дополнительных комментариев, считайте, что речь идет о коде порождаемой программы. – прим. пер. |
Ключевые особенности нашего подхода:
Наша мета-система предоставляет возможности как генерации, так и анализа программ [8]. Она может легко совершить обход абстрактного синтаксического дерева объектной программы и собрать информацию о нем или изменить его (зачастую с использованием собранных данных).
Система в основном рассчитана на работу с объектными программами во время компиляции. Однако, используя возможности .NET по динамической загрузке кода, можно исполнять макросы и в run-time.
Метаязык однороден. Это означает, что он таков же, как объектный язык. Мы можем использовать внутри макросов обычные функции Nemerle, и синтаксис сгенерированных программ не отличается от испольуемого при написании макросов.
Квази-цитирование дает четкое разделение объектной программы и метаязыка. Это делается с помощью ручного аннотирования, разделяющего этапы исполнения вполне понятным образом. Таким образом, достаточно ясно, какая часть кода генерирует, а какая является генерируемой. Символы в объектном коде специальным образом переименовываются, так что они не пересекаются с внешним кодом.
Предположим, что мы хотим ввести в наш язык новый синтаксис, например, цикл 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
|
вместо вызова, упоминавшегося выше.
Макросы очень полезны при проверках и обработке кода, написанного в виде строк. Это относится ко многим простым языкам, например, строкам форматирования а-ля 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-вариантом.
Цитирование дает полную свободу при конструировании любых видов выражений. Например, можно разобрать кортеж (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.
Цитирование можно использовать для анализа структуры программы так же легко, как и для ее генерации. Стандартный механизм языка, поиск по образцу, вполне подходит для этой цели.
В качестве примера приведем реализацию оператора <->, меняющего значения двух выражений – как в 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; ]> } |
Макросы могут работать не только с выражениями, паттернами, типами, но и вообще с любыми частями языка, например, классами, интерфейсами, объявлениями типов, методами и т.д. Синтаксис этих операций совершенно иной. Конструкции языка опять же рассматриваются как объекты, которые могут быть трансформированы, но это делается не совсем с помощью цитирования. Мы используем специальный 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 { } } |
Макросы, работающие с декларациями, изменяют их в императивной манере. Их первый параметр – всегда объект, представляющий данную декларацию.
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")].
Средства метапрограммирования, предоставляемые Nemerle, позволяют решать очень широкий круг задач, в том числе встроить в язык средства аспектно-ориентированного программирования. В статье Meta-programming in Nemerle (http://nemerle.org/metaprogramming.pdf) об этом сказано подробнее. Однако в цели данного обзора разбор реализации АОП средствами Nemerle не входит.
В этом разделе дается более формальное определение макроса и нашей мета-системы.
Макрос – это предваряемая ключевым словом macro глобальная функция, которая, как и другие функции, может иметь модификаторы доступа (public, private и т.д.), и находится в пространстве имен .NET/Nemerle. Она используется в коде как любая другая функция, но расценивается компилятором особым образом.
Типы ее формальных параметров ограничены набором элементов грамматики Nemerle (включая простые литералы). Параметры макроса часто передаются в виде синтаксических деревьев, которые для некоторых типов дополнительно обрабатываются. Например, по идее литералы в макросах должны представляться (как и все остальное) в виде синтаксических деревьев, но в этом нет особого смысла, так как эти значения принципиально известны во время компиляции.
Макрос не может быть вызван рекурсивно или передан как первоклассный объект (хотя он может сгенерировать код, содержащий вызов этого макроса). Однако он может использовать любую функцию программы в стандартной манере ML, что мы рассматриваем как небольшой недостаток. Если нужны сложные вычисления над синтаксическими деревьями, их можно просто поместить в некую произвольную функцию и вызвать эту функцию из макроса. Такой дизайн позволяет легко определять, какие функции выполняются во время компиляции, не требуя никакого специального аннотирования в местах их вызова.
Очень важное свойство мета-системы – «гигиена» (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, чтобы задействовать при исполнении свой собственный контекст. Таким способом функция получает ту же семантику, что и макрос, в отношении гигиены. Мы считаем, что такое поведение плохо использовать по умолчанию, так как код часто генерируется какими-нибудь вспомогательными функциями, специально определенными в макросе локально, а они не должны менять свой контекст.
Иногда бывает полезно, чтобы при нескольких исполнениях макросов использовались одни имена. Это можно безопасно осуществить, сгенерировав уникальный идентификатор, независимый от исполнения макроса. Это делается с помощью функции 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. Они привязаны к ближайшему определению с тем же именем, появляющемуся в сгенерированном коде, независимо от контекста.
Объектный код часто ссылается на переменные, типы или конструкторы, импортируемые из других модулей (например, стандартную библиотеку .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 не позволяют этого, то есть программист не должен генерировать код, нарушающий статическую безопасность.
Для метафункций жизненно важно уметь использовать все преимущества, которые дает исполнение во время компиляции. Они могут получать информацию от компилятора, и использовать его методы для доступа, анализа и изменения данных, хранящихся в его внутренних структурах.
Например, мы можем попросить компилятор возвратить декларацию типа для данного типа. Она будет доступна в виде синтаксического дерева, как если бы мы ввели перед этой декларацией специальный атрибут (раздел 5). Конечно, такая декларация не должна быть внешним типом и должна быть доступна в скомпилированной программе в виде исходного кода.
def decl = Macros.GetType(<[ type: Person ]>); xmlize(decl); // можно применять макросы к декларациям |
Есть и более изощренные способы. Они включают более тесное взаимодействие с компилятором, использование его методов и структур данных или даже переплетение с внутренними этапами компиляции.
Например, можно попросить компилятор типизировать AST кода, передаваемого макросу. Это значит, что мы можем втиснуться между многими важными действиями, выполняемыми компилятором, и добавить туда наш собственный код. Это может быть применено для улучшения сообщений об ошибках (особенно для макросов, определяющих сложные расширения синтаксиса), делая кодогенерацию зависимой от типов входящих программ или улучшая анализ кода благодаря дополнительной информации.
Рассмотрим следующее преобразование оператора 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-ов рассматривается как обычный код, находящийся рядом с цитированием, но вне его. – прим.ред.
Как говорится в замечаниях к функции PrintTuple, макрос, способный произвести типизацию ее параметров, может получить размер кортежа. Нужно только добавить следующие строки:
match (TypedExpr(tup).Type) { | <[ ttype: (..args) ]> => def size = List.Length(args); .. } |
Теперь опишем, как работает наша метасистема изнутри.
Каждый макрос транслируется в отдельный класс, реализующий особый интерфейс IMacro. Он предоставляет метод запуска макроса, который в большинстве случаев включает передачу ему списка грамматических элементов Nemerle (нетипизированных синтаксических деревьев объектных программ).
Поэтому на уровне компилятора макрос – это функция, оперирующая с синтаксическими деревьями. Компилятор Nemerle использует несколько видов синтаксических деревьев. Мы сосредоточимся на деревьях разбора (parse trees) и типизированных деревьях (typed trees).
При исполнении макроса деревья разбора, как и цитирования, генерируются парсером. Они, в основном, идентичны грамматике языка.
Типизированные деревья содержат меньше языковых конструкций (в частности, нет вызовов макросов). Однако эти конструкции более явные. В частности, они содержат типы выражений.
Процесс типизации в компиляторе в основном производит трансформацию деревьев разбора в типизированные деревья.
Функция типизации, встречая вызов макроса, исполняет метод Run соответствующего объекта макроса. Вызов макроса выглядит как обычный вызов функции, таким образом мы отличаем эти два случая, ища название вызванной функции в списке загруженных в текущее время макросов (интерфейс IMacro имеет специальный метод GetName).
Для поддержки типизированной части параметров макроса мы ввели специальную ветку в дереве разбора, которая просто содержит тип. Функция типизации просто выделяет содержимое этого узла.
Система цитирования – это просто сокращенная запись для явного конструирования синтаксических деревьев. Например, выражение 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 просто означает «не поднимать», поскольку мы хотим передать значение выражения метаязыка как объектный код. Конечно, это верно, только если такое выражение описывает синтаксическое дерево (или имеет его тип). Оператор .. внутри цитирования транслируется как синтаксическое дерево, отражающее список, содержащий «поднятые» выражения из предоставленного списка (который должен следовать за ..).
Ключевой элемент нашей системы – исполнение метапрограмм во время компиляции. Для этого они должны иметь исполняемую форму и быть скомпилированы до использования.
После компиляции макросы хранятся в сборках (скомпилированных библиотеках кода). Все макросы, определенные в сборке, перечисляются в ее метаданных. Поэтому, если при компиляции производится связывание со сборкой, можно сконструировать экземпляры всех классов макросов и зарегистрировать их в компиляторе по именам.
Каждому макросу соответствует пространство имен. Имя макроса предваряется именем пространства имен. Чтобы использовать короткое имя макроса, нужно создать объявление using для каждого из соответствующих пространств имен. Механика здесь аналогична используемой при работе с обычными функциями. Если макрос определяет расширение синтаксиса, он активируется, только если задействовано соответствующее пространство имен.
Текущая реализация требует, чтобы макрос компилировался в отдельном проходе, до компиляции использующей его программы. Это выливается в невозможность определения и использования макроса в той же единице компиляции. Мы работаем над генерацией и исполнением макросов при компиляции в один прием, но у принятого нами в текущее время подхода тоже есть ряд достоинств.
Наиболее важное достоинство состоит в том, что это просто и легко для понимания – сперва нужно скомпилировать макросы (возможно, объединив их в какую-то библиотеку), затем загрузить их в компилятор, и, наконец, использовать. Таким образом, этапы компиляции четко разделены хорошо понятным образом – важное преимущество в промышленной среде, где метапрограммирование – новая и все еще малопонятная область.
Главная проблема с ad-hoc макросами (вводимыми и используемыми в одном сеансе компиляции) состоит в том, что сперва нужно скомпилировать транзитивное замыкание типов (классы с методами), используемых данным макросом. Разумеется, данный макрос не может использоваться в этих типах.
Эту проблему может быть трудно понять программистам (почему моя программа не компилируется после добавления поля к этому классу?). С другой стороны, такой набор типов и макросов с замкнутыми зависимостями можно легко вынести из программы в библиотеку.
Опыт сообщества Scheme показывает [5], как много проблем возникает в системах, не предоставляющих четкого разделения этапов компиляции. На самом деле, чтобы избежать их в больших программах, приходится вводить ручное аннотирование с указанием зависимостей между макробиблиотеками.
У нашей системы много общего с современными макрорасширениями Scheme[1]:
Поддерживая перечисленное выше, мы встроили систему макросов в статически типизированный язык. Типы в сгенерированном коде проверяется после раскрытия. Мы также обеспечиваем четкое разделение этапов – метафункции до использования должны быть скомпилированы и сохранены в библиотеке.
Работа над Scheme длилась довольно долго, и предлагалось много интересных вещей. Например, макросы первого класса в [9], кажется, можно реализовать в Nemerle с помощью простой передачи функций, оперирующих кодом объектов.
Между макросами Template Haskell [2] и Nemerle есть интересные различия:
Тем не менее, есть и множество сходств с Template Haskell. Мы напрямую позаимствовали оттуда идею квази-цитирования и splicing-а. Идеи исполнения функций при компиляции и отложенной проверки типов также навеяна Template Haskell.
Шаблоны C++ [11], возможно, наиболее часто используемая из существующих систем метапрограммирования. Они предлагают полную по Тьюрингу макросистему времени компиляции. Однако сомнительно, что полнота по Тьюрингу была реализована намеренно, и выражение сложных программ через систему типов может быть весьма неуклюжим. Тем не менее, широкое распространение этой возможности показывает востребованность систем метапрограммирования индустрией.
Есть несколько уроков, усвоенных нами на примере C++. Первое – сохранять простоту системы. Второе – в Nemerle выбрана обязательная предварительная компиляция макросов, чтобы не полагаться на различные нестандартные расширения компиляторов, наподобие прекомпиляции заголовочных файлов C++, которые все равно мало что дают для ускорения метакода.
CamlP4 [12] – это препроцессор для OCaml. Его LL(1)-парсер – полностью динамический, что позволяет выразить весьма сложные расширения грамматики. Макросы (именуемые расширениями синтаксиса) должны быть скомпилированы и загружены в парсер до использования. При работе они могут конструировать новые выражения (используя систему цитирования), но только на уровне нетипизированного дерева разбора. Более углубленное взаимодействие с компилятором невозможно.
В MacroML [6] было предложено использовать макросы времени компиляции для языка ML. Это имеет много общего с Template Haskell в смысле связывания имен в цитировании до раскрытия макросов. MacroML, кроме того, позволяет захватывать символы в месте применения макроса (это похоже на нашу функцию UseSiteSymbol()). И все это производится без необходимости отказываться от типизации цитат.
Макросы в MacroML ограничены конструированием кода из имеющихся частей, анализ и декомпозиция кода невозможны.
MetaML [7] вдохновил и Template Haskell и MacroML, введя квази-цитирование и идею типизации объектного кода. Он был разработан в основном для операций с кодом и его исполнения в рантайме, так что это несколько отличающаяся от нашей область деятельности.
Генерация программ во время исполнения остается очень широким неисследованным полем деятельности для нашей метасистемы. Это будет полезно для оптимизации, основанной на данных, доступных только во время исполнения.
Мы сосредоточимся на реализации интерфейса с возможностями аспектно-ориентированного программирования в Nemerle. Это кажется хорошим способом введения парадигмы метапрограммирования в коммерческое окружение.
Ранняя типизация объектного кода – для выявления максимума ошибок при компиляции макроса (в противоположность раскрытию макроса) мы должны поддерживать особый вид функций типизации. Конечно, мы не можем определить тип «$()» в объектном коде (это, очевидно, неразрешимая задача). Вдобавок мы ограничены связыванием идентификаторов, выполняемым после раскрытия, но все же мы можем отвергнуть такие программы как <[1+ (x:string) ]>.
Хотелось бы поблагодарить Marcin Kowalczyk за крайне конструктивную дискуссию по гигиеничным системам, Lukasz Kaiser за полезные замечания по системе цитирования и Ewa Dacko за корректуру этой статьи.
Сообщений 5 Оценка 1180 Оценить |