stdray: (Default)
stdray ([personal profile] stdray) wrote2012-07-16 11:50 am

Про синтаксические макросы в Nemerle. Часть 1

Пришло время написать макрос. Макрос сам себя не напишет.

Я тут некоторое время писал кое-какую мелочевку на Nemerle, и, можно сказать, язык пришелся мне по нраву. Очень похоже на F# хотя и своими особенностями. У меня пару раз возникало желание написать макрос, который будет заниматься небольшой кодогенерацией по месту, но, после небольшого размышления, я отказывался от этой затеи. Отчасти из-за неприязни к особым формам и неявному поведению (странно, что при этом я обожаю всяческий синтаксический сахар), отчасти из-за сложности написания самих макросов. Чтобы выяснить, что же меня больше смущает, я решил научиться их писать. В этом посте, я старался систематизировать то, что узнал о синтаксических макросах Nemerle.


Вообще-то написать простейший макрос очень просто. Необходимо создать проект, в котором будут находится макросы и добавить в него сам макрос, для этого в студии даже предусмотрен специальный визард, который позволяет выбрать тип макроса, задать некоторые специфичные особенности и параметры. После чего надо добавить ссылку на сборку к проекту, где макрос будет использован. В проекте Nemerle для этого есть два меcта: References и Macro References. При разработке макроса, лучше добавлять сборку в References, тогда изменения будут перекомпилироваться на лету. В два клика можно получить примерно следующее:

  1. macro foo(expr: PExpr)  {  
  2.     fooImpl.DoTransform(Macros.ImplicitCTX(), expr)  
  3. }  
  4.   
  5. module fooImpl  {  
  6.     public DoTransform(_ : Typer, expr : PExpr) : PExpr {  
  7.         <[  Console.WriteLine($(expr.ToString()));   
  8.             $expr  ]>  
  9.     }  
  10. }  


Были сгенерированы макрос и метод его реализации. (Вообще-то в таком делении нет никакой необходимости, но в теле макроса не поддерживается intellisense, а это, пожалуй, один из самых страшных кошмаров .net-разработчика) Макрос принимает на вход один аргумент с типом PExpr, который представляет собой выражение Nemerle. Для демонстрации я просто добавил вывод на экран текста выражения, которое было передано в макрос, а потом и само выражение.



На картинке видно, что произойдет с кодом после раскрытия макроса. Здесь есть несколько не очевидных моментов, которые надо пояснить.

Во-первых, exrp не является текстом, как это может показаться. Это сложный объект, содержащий много разного, вроде позиции, на которой выражение находится в исходном коде, а также позволяющий определить тип выражения. В данном случае можно опустить декларацию типа в описании макроса, потому что тип PExpr принят по умолчанию. Для указания типов параметров макроса возможно использовать типы из следующего списка: PExpr, Token, params array[PExpr], params list[PExpr], bool, byte, decimal, double, float, int, long, sbyte, short, string, uint, ulong, ushort.
Надо понимать, что требование любого типа кроме первых четырех, означает, что макрос будет принимать на вход константу времени компиляции, которыми могут являться только литералы: строковые для string, булевы для bool, и числовые для остальных. Например для макроса, ожидающего long, 10l будет корректным параметром, а long.Parse("10l") - уже нет, поскольку это выражение и его тип PExpr.
Во-вторых, выражение в адских скобках <[ ]> попадет в код целевой программы после работы макроса.
В-третьих, оператор $ означает, что в код целевой программы попадет значение, которое содержится в переменной, идущей следом.


Квази-цитирование и гигиеничность


Макросистема Nemerle использует механизм квази-цитирования, то есть работа идет не с текстом, а с куском AST (абстрактное синтаксическое дерево), в которое будет разобран данный фрагмент кода при компиляции. Тип PExpr как раз представляет собой квази-цитату. Квази-цитату можно формировать при помощи <[ ]>, можно достать из нее значение, используя $, можно узнать тип выражения, которое она содержит, можно разбирать паттерн-матчингом. Я переписал код макроса foo следующим образом, чтобы макроугар прямо в глаза:

  1. public DoTransform(_ : Typer, expr : PExpr) : PExpr {  
  2.     <[  def expr = "trololo";  
  3.         Console.WriteLine(expr);  
  4.         $expr;  
  5.         Console.WriteLine($(expr.ToString())); ]>  
  6. }  


И при компиляции его вызов foo(WriteLine("lol")), будет заменен на

  1. {  
  2.   def expr = "trololo";  
  3.   Console.WriteLine(expr);  
  4.   WriteLine("lol");  
  5.   Console.WriteLine("WriteLine(\"lol\")")  
  6. }  


Вообще-то, я хотел выделить эти два фрагмента кода каким-нибудь клоунским шрифтом вроде comic sans, написать «тут все понятно» и двигаться дальше, поскольку любые мои попытки сформулировать, что здесь происходит скатывались в
Ехало выражение через выражение,
Видит выражение в выражении выражение
Выражение выражение выражение.


Но я все же постараюсь. Грубо говоря, внутри квази-цитаты существует своя область видимости, куда не входят выражение из вне. Потому Console.WriteLine(expr) может иметь доступ только к expr, определенному внутри <[ ]>. А если удалить строку def expr = "trololo", то компилятор ругнется, мол Unbound name exrp. В то же время при использовании $ вставляется значение переменной, следующей за ним, а не ее имя или что-либо еще.

Может возникнуть вопрос, что произойдет, если в коде целевой программы уже используется имя expr. Если с тем же самым макросом foo, написать

  1. def expr = "PSHH PSHHHH";  
  2. foo(WriteLine("lol"));  
  3. WriteLine(expr);  


на экран будет выведено

  1. trololo  
  2. lol  
  3. WriteLine("lol")  
  4. PSHH PSHHHH  


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


Новый синтаксис


Вызов макроса foo был неотличим от обычного вызова функции, но Nemerle позволяет при помощи макросов вводить новый синтаксис. Я буду велосипедить оператор If-Else, потому что он похож на Hello World из мира макросов. Вот его код:

  1. macro IfThenElse(cond, e1, e2)  
  2. syntax ("If", cond, "Then", e1, "Else", e2) {  
  3.     IfThenElseImpl.DoTransform(Macros.ImplicitCTX(), cond, e1, e2)  
  4. }  
  5.   
  6. module IfThenElseImpl {  
  7.     public DoTransform(_ : Typer, cond : PExpr, e1 : PExpr, e2 : PExpr) : PExpr {  
  8.         <[  match($cond){  
  9.                 | true => $e1;  
  10.                 | _    => $e2;  
  11.             }  ]>  
  12.     }  
  13. }  


Теперь у меня есть условный оператор, очень похожий на тот, что используется F#:

  1. def x = If ReadLine().Length==3 Then "lol" Else "fail";  
  2. If ReadLine().Length==3 Then Write("lol") Else Write("fail");  


Синтаксис задается конструкцией syntax, в ней поочередно указывается из чего состоит вывоз макроса. На мой взгляд, здесь все достаточно прозрачно. Механизм вывода типов Nemerle распространяется в том числе и на макросы, потому компилятор, увидев сопоставление значения cond с true, определит, что значение cond должно иметь тип bool и будет требовать его от пользователя, (хотя и не самым пристойным образом). Указанный синтаксис используется парсером для выявления вызова макроса, а специфика его (парсера) работы такова, что общем случае могут быть распознаны только конструкции имеющие определенный префикс, которым в данном случае являет "If". Иными словами, не стоит начинать описание синтаксиса макроса с переменной, а надо первым делом ставить строку.


Типизация


В предыдущих примерах я не использовал информацию о типах выражений, которые передаются на вход макросу. Однако возможность типизировать квази-цитату является одним из главных достоинств макросов Nemerle. Имея информацию о типах во время компиляции, можно реализовывать различные проверки или генерировать код в зависимости от типа полученного выражения. Например, функция printf выполняет проверку типов своих аргументов на этапе компиляции и встроена во многие языки программирования. Это нормальное, но не всегда самое лучшее решении. Правила формирования строк специфичные для конкретной задачи могут быть весьма сложны, потому иметь смысл автоматизировать эти проверки и выполнять их во время компиляции.

Для примера я решил сделать ту самую функцию printf. Вообще-то макрос с таким функционал реализован в одной из статей по Nemerle, но он содержит огромное количество имеративной ёбы, потому мне было сложно выделить моменты характерные для написания макросов. Вот мой игрушечный пример prinf'а (хотя я не вижу проблемы в расширении его до полноценной реализации):

  1. macro MyPrintf(formatString : stringparams args : array[PExpr]){  
  2.     MyPrintfMacroImpl.DoTransform(Macros.ImplicitCTX(), formatString, args)  
  3. }  
  4.   
  5. module MyPrintfMacroImpl {  
  6.       
  7.     //словари соответсвий формат - допустимые типы  
  8.     _patternTypes : Dictionary[string, list[PExpr]] = Dictionary() <- [   
  9.         "%d"   = [<[ int ]>, <[ short ]>],  
  10.         "%s"   = [<[ string ]>],  
  11.         "%f"   = [<[ bool ]>],  
  12.         "%time"= [<[ DateTime ]>]  
  13.     ];  
  14.       
  15.     //автомаичеси собираемая регулярочка, для выделения строк формата  
  16.     _patternRegex : Regex = Regex(string.Format("({0})"string.Join("|", _patternTypes.Keys)));  
  17.   
  18.     public DoTransform(typer : Typer, formatExpr : string, args : array[PExpr]) : PExpr {  
  19.         //Инциализуем тайпер  
  20.         Macros.DefineCTX(typer);  
  21.         //Разбиваем строку формата на фрагменты и нумеруем полученные фрагменты  
  22.         def parts    =  _patternRegex.Split(formatExpr).Select((s,i) => (i, s)).NToList();  
  23.         //Отделяем обычные строки от строк форматирования  
  24.         def(patterns, strings) = parts.Partition((_,p) => _patternRegex.IsMatch(p));  
  25.         //Бросаем ошибку, если количество строк форматирования отличается от количества аргументов  
  26.         when(patterns.Length != args.Length)  
  27.             Message.Error($"MyPrintf expected $(patterns.Length) arguments, got $(args.Length)");  
  28.         //Превращаем строки в выражения (строковые константы)  
  29.         def exprStr = strings.Map((i,s) => (i,<[ $s ]>));  
  30.         //Выполняем подстановку аргументов, вместо строк формата, проверяя соответствие типов  
  31.         def exprVal = patterns.Map2(args, fun((i, p), arg) {  
  32.             //Вычисляем тип аргумета  
  33.             def argType = typer.TypeExpr(arg).Type;  
  34.             //Если тип не соответствует строке форматирования, бросаем ошибку  
  35.             when(_patternTypes[p].All(t=> typer.BindType(t) |> argType.TryRequire |> ! _))  
  36.                 Message.Error(arg.Location,  
  37.                 $"Argument #$i type $(argType) is not compatible for $p, Allowed types: $(_patternTypes[p])");  
  38.             (i, arg)  
  39.         });  
  40.         //Соединяем выражения для обычных строк и строк-форматирования  
  41.         //Упорядочиваем их по номеру  
  42.         //Избавляемся от номера, выбирая только выражения  
  43.         def exrpSeq = exprStr.Concat(exprVal).OrderBy((i,_)=>i).Map((_, e) => e);  
  44.         //Формируем результирующий код  
  45.         <[string.Concat(..$exrpSeq) |> Console.WriteLine]>  
  46.     }  
  47. }  


Я надеюсь, что комментариев достаточно для понимая как это работает в общем виде. Основной интерес здесь представляет первый аргумент typer : Typer функции DoTransform. Именно с его помощью возможно получение доступа к типу выражения, его проверка, уточнение или фиксация. Важно, что в выражении def argType = typer.TypeExpr(arg).Type; argType имеет тип TypeVar, а не родной дотнетовский Type, как могло показаться. TypeVar – это тип, которым оперирует компилятор Nemerle в процессе типизации. Он может быть фиксированным так и неконкретным. В первом случае можно использовать FixedType, который является наследником TypeVar. Список доступной информации достаточно большой. Ниже мемберы TypeVar'а

Методы: _N_GetVariantCode, CheckAccessibility, CompareTo, ConstructSubstForTypeInfo, DeepFix, Equals, EqualsUpperBound, Expand, Fix, Fixate, ForceProvide, ForceRequire, ForceUnify, FunParamsTypes, FunReturnTypeAndParms, GetFunctionArguments, GetHashCode, GetInstantiatedSuperType, GetNonVoidSystemType, GetSystemType, GetType, GetUnfixedFunctionArguments, IsAccessibleFrom, Iter, Provide, Require, SetParsedObject, SigRequire, ToList, ToString, TryEnforcingEquality, TryProvide, TryRequire, TrySigRequire, TryUnify, TypeOfMember, TypeOfMethod, TypeOfMethodWithTyparms, Unify, UpdateRelatedParsedObject

Свойства: AlwaysUnify, AnyHint, CanBeNull, CurrentSolver, FixedValue, Hint, HintFast, ImplicitCastHint, InternalType, IsConstrained, IsEnum, IsFixed, IsFree, IsFresh, IsFromNull, IsFunction, IsGenerated, IsInterface, IsNullable, IsPrimitive, IsSeparated, IsSystemObject, IsValueType, Location, LowerBound, ParsedObject, SystemTypeCache, TypeInfo, UpperBound

Например метод Require сообщает компилятору, какой требуется тип для данного выражения, а метод GetSystemType получается тот самый дотнетовский тип, но им надо пользоваться осторожно, поскольку в момент его вызова тип выражения фиксируется и не может в дальнейшем быть изменен.

В один пост все не влезает. Остальное можно прочитать вот здесь.