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

Макросы Nemerle – расширенный курс

Часть 2

Автор: Чистяков Владислав Юрьевич
Источник: RSDN Magazine #1-2011
Опубликовано: 18.08.2011
Исправлено: 10.12.2016
Версия текста: 1.0
Создание макросов-выражений
Параметры макроса
Контекст
Макросы, изменяющие синтаксис
Работа с квази-цитированием
Доступ к внутренностям компилятора
Получение информации о текущем методе
Получение доступа к типам
Типизация
Типизация выражений
Заключение ко второй части

Создание макросов-выражений

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

Макрос, являясь, по сути, библиотекой, может использовать как API компилятора, так и внешние библиотеки (по сути, он сам тоже библиотека). Это делает его невероятно мощным оружием.

Чтобы продемонстрировать сказанное, давайте создадим простейший макрос PrintTime, печатающий текущее время на момент его вызова:

      using System;
using Nemerle.Compiler;

namespace MacroExamples
{
  macro PrintTime()
  {
    Message.Hint($"Now $(DateTime.Now)");
    <[ () ]>
  }
}

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

      using System;
using System.Console;
using Nemerle.Utility;
using MacroExamples;

module Program
{
  Main() : void
  {
    PrintTime();    //вызов макроса
    _ = ReadLine(); // Чтобы окно не исчезало после окончания программы.
  }
}

а после этого запустить компиляцию решения (solution), то среди прочих сообщений компилятора вы увидите следующее сообщение:

...\Main.n(10,5):Warning: hint: Now 19.06.2007 14:42:31

Как видите, код макроса был вызван именно во время компиляции тестового проекта и именно в рамках процесса компилятора, так как макрос смог успешно воспользоваться API компилятора для вывода предупреждающего сообщения прямо в консоль IDE.

Многим может показаться загадочным код «<[ () ]>». Дело в том, что макрос уровня выражения должен возвращать некое выражение, которое будет подставлено в код вместо макроса. Конструкция «()» рассматривается компилятором Nemerle как ничего не делающий фрагмент кода (код-пустышка). Тип такой пустышки – void. Компилятор просто проигнорирует этот код, если он не будет вставлен внутрь другого участка кода, а если будет, то выдаст сообщение об ошибке. В общем, возврат из макроса уровня выражения значения «<[ () ]>» равносилен отсутствию какого-либо возврата вовсе. Таким образом, наш первый макрос не генерирует кода, а только лишь выполняет некоторые сервисные функции.

Чтобы этот макрос кроме вывода сообщения в консоль IDE, также генерировал код, выводящий время компиляции при каждом запуске программы (точнее в момент, когда управление в программе доходит до того места, где находится вызов макроса), нам нужно чуть-чуть изменить код макроса:

def now = DateTime.Now;
<[ WriteLine("Макрос вызван в {0}", $(now.ToString() : string)) ]>

Также нужно добавить using для класса System.Console.

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

При просмотре этого кода могут возникнуть следующие вопросы:

  1. Что означает конструкция «$(now.ToString() : string)»?
  2. Почему пришлось преобразовывать значение переменной now в строку?
  3. Что было бы, если бы вместо переменной now в квази-цитировании указать DateTime.Now (т.е. обращение к свойству)?

Ответ на первый вопрос – это сплайсинг. Он позволяет добавить в цитату (т.е. в генерируемый код) что-то из макроса. Этим чем-то могут быть выражения (сгенерированные или полученные макросом в качестве параметров, это самый распространенный случай) и значения простых (встроенных) типов вроде int и string.

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

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

Параметры макроса

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

Пример макроса с переменным числом параметров:

        macro printf (format : string, params parms : array[expr]) 
{
  def (evals, refs) = make_evaluation_exprs (parse_format(format), parms);
  def seq = evals.Append(refs.Map(x => <[ Console.Write ($x) ]>));
  <[ {..$seq } ]>
}

Обратите внимание и на то, что в основном типом параметров макросов является выражение (PExpr). Кроме PExpr, макрос может принимать параметры встроенных типов вроде int, double и string. Однако это не более чем помощь компилятора, который производит свертку константных выражений за нас. Передать что-то отличное от PExpr и констант в макрос невозможно просто потому, что программы пользователя еще не существует. Она в этот момент только компилируется.

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

Контекст

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

Мало того, что с помощью класса Typer можно вручную типизировать некоторое выражение, с его помощью еще можно получить доступ к таким важным вещам, как экземпляр объекта GlobalEnv (через свойство Env), ассоциированного с текущим методом, а через него – к объекту ManagerClass (свойство Manager), через который производится управление сборкой всего проекта, и в котором собрана почти вся информация о типах проекта. В данной статье мы еще не раз столкнемся с контекстом и с данными, которые через него можно получить.

Режим обработки ошибок

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

К сожалению, при переключении в режим обработки ошибок компилятор повторно обрабатывает код, что может привести к повторному исполнению кода макроса, а значит к дублированию сообщений и другим неприятностям. Чтобы избежать этого, нужно проверять свойство IsMainPass объекта Typer (ссылка на который возвращается макросом Nemerle.Macros.ImplicitCTX()). Это свойство равно true во время первого прохода компилятора. Если углубиться в детали, то это свойство всегда имеет значение true при работе в режиме IntelliSense (т.е. в режиме движка интеграции с IDE). Если компилятор работает в автономном режиме, то значение этого свойства зависит от значения свойство InErrorMode (IsMainPass равно true, если InErrorMode равно false, и наоборот).

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

          macro PrintTime()
{
  def now = DateTime.Now;
  
  when (Nemerle.Macros.ImplicitCTX().IsMainPass)
    Message.Hint($"Компиляция. Сейчас $now");
  
  <[ System.Console.WriteLine("Макрос вызван в {0}", $(now.ToString() : string)) ]>
}
ПРЕДУПРЕЖДЕНИЕ

Свойство IsMainPass отсутствовало в компиляторе версии 0.9.3. Так что для работы этого примера вам потребуется свежая версия компилятора.

Макросы, изменяющие синтаксис

Макросы, изменяющие синтаксис – это те же макросы уровня выражения, но расширенные конструкцией, описывающей синтаксис.

Чтобы не пугать вас сложностями, начнем с примитивных примеров. Самые простые из них находятся в файле http://nemerle.org/svn/nemerle/trunk/macros/core.n. Одним из самых простых макросов является макрос if:

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

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

Вот как выглядит применение этого макроса:

      if (a == 123)
{
  def msg = " ‘a’ имеет значение 123";
  MessageBox.Show(msg);
}
else
{
  def msg = " ‘a’ имеет значение, отличное от 123";
  MessageBox.Show(msg);
}

Или другой вариант применения (функциональный):

      def msg = if (a == 123) " ‘a’ имеет значение 123"else" ‘a’ имеет значение, отличное от 123";
MessageBox.Show(msg);

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

Определение синтаксиса

Для начала разберемся, как определять синтаксис. Для этого служит специальное ключевое слово syntax за которым в круглых скобках следует список допустимых токенов. Среди них могут быть имена параметров макроса (перечисленные выше) и текстовые литералы. Кроме того, отдельные токены могут быть помещены внутрь конструкции Optional(), которая позволяет опускать некоторые из параметров (делать их необязательными).

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

Чтобы продемонстрировать эту технику, рассмотрим следующий пример.

Пример: макрос forindex

Чтобы продемонстрировать технику распознавания синтаксиса внутри макроса рассмотрим более сложную, чем if, но все же относительно простую реализацию макроса «forindex», который позволяет перебирать индексы, заданные нотациями «min < x < max», «min <= x < max», «min < x <= max», «min <= x <= max», где «min» – это минимальное значение индекса, «max» – максимальное, а «x» -имя переменной, значение которой на каждом шаге итерации будет увеличиваться на определенную величину (по умолчанию на единицу). Вместо min и/или max в данном случае могут выступать выражения любой сложности. Для простоты не будем обрабатывать ошибочные ситуации (вроде отрицательных диапазонов, приводящих к зацикливанию).

Заметьте, что мы не можем описать синтаксис этого макроса линейно без анализа выражений, так как внутри могут находиться четыре разных сочетания операций сравнения (</<; <=/<; </<=; <=/<=). А писать ради того, чтобы обойти эту ситуацию, четыре различных макроса не позволяет программистская лень, да и различных сочетаний в других макросах может быть на порядки больше.

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

      forindex (0 <= i < 10)
  WriteLine($"i=$i");

Эта конструкция аналогична следующей:

      for (mutable i = 0; i < 10; i++)
  WriteLine($"i=$i");

Кроме того, макрос будет поддерживать необязательное ключевое слово «step», за которым можно указать шаг приращения.

Например, следующий код:

      forindex (0 <= i < 10 step 2)
  forindex (0 <= k <= 2)
    WriteLine($"i=$i  k=$k");

выведет на консоль:

i=1  k=0
i=1  k=1
i=1  k=2
i=3  k=0
i=3  k=1
i=3  k=2
i=5  k=0
i=5  k=1
i=5  k=2
i=7  k=0
i=7  k=1
i=7  k=2
i=9  k=0
i=9  k=1
i=9  k=2

Формализуем задачу. Нам нужно распознать корректный синтаксис выражения и переписать код в соответствующего вида for. Под корректностью синтаксиса я понимаю контроль того, что выражение в скобках содержит только три выражения (больше, меньше и имя переменной), которые разделены двумя знаками «<» или «<=». Все остальные изыски программиста-пользователя должны вызывать у компилятора сочувственные сообщения об ошибках.

Настало время действовать! :)

Вот как выглядит описание синтаксиса для нашего, пока еще, гипотетического макроса:

      macro ForIndex (expr, step, body)
syntax ("forindex", "(", expr, Optional ("step", step), ")", body) 
{
  ...
ПРЕДУПРЕЖДЕНИЕ

Обратите внимание на то, что все литералы, встречаемые в описании синтаксиса макроса, автоматически становятся ключевыми словами (действующими там, где открыто пространство имен, в котором объявлен макрос). Так что создать переменную с именем forindex или step уже не удастся. Будьте осторожны при выборе имен!

Как видите, синтаксис forindex сильно отличается от того, что можно увидеть при использовании этого макроса. В нем нет описания синтаксиса для условия цикла. Как я говорил выше, это связано с тем, что макро-подсистема компилятор не может распознавать очень сложные синтаксические описания. Вместо того чтобы пытаться описать выражение «expr1, oper1, index, oper2, expr2», я описал условие цикла как единый параметр expr. Вот как выглядит макрос целиком:

      macro ForIndex (expr, step, body)
syntax ("forindex", "(", expr, Optional ("step", step), ")", body)
{
  def step = if (step == null) <[ 1 ]> else step;
  
  match (expr)
  {
    | <[ $minExpr <= $i <= $maxExpr ]> => 
      <[ for (mutable $i = $minExpr; $i <= $maxExpr; $i += $step)
           $body ]>

    | <[ $minExpr <  $i <= $maxExpr ]> => 
      <[ for (mutable $i = $minExpr + 1; $i <= $maxExpr; $i += $step)
           $body ]>

    | <[ $minExpr <  $i < $maxExpr ]> => 
      <[ for (mutable $i = $minExpr + 1; $i <  $maxExpr; $i += $step)
           $body ]>

    | <[ $minExpr <= $i <  $maxExpr ]> => 
      <[ for (mutable $i = $minExpr;     $i <  $maxExpr; $i += $step)
           $body ]>

    | _ => Message.Error(expr.Location, $"Syntax error '$expr'"); 
           <[ () ]>
    
  }
}

Разберем, что делается в этом макросе.

Первая строка:

      def step = if (step == null) <[ 1 ]> else step;

проверяет, был ли задан параметр «step» (он объявлен в синтаксисе как необязательный), и если параметр задан не был (в этом случае он содержит значение null), то вместо него используется значение литерала «1». Таким образом, если пользователь опустил шаг цикла, то по умолчанию он будет равен единице.

ПРЕДУПРЕЖДЕНИЕ

Параметры макросов могут быть двух типов 1) выражениями, 2) константами встроенных типов (int, double, string и т.п.). Но по крайней мере целочисленные константы не допускают null-значений. Это не дает использовать их в качестве необязательных параметров.

Далее значение параметра expr подвергается разбору с помощью сопоставления с образцом.

По сути, наше выражение состоит из двух подвыражений «((нечто <оператор сравнения> нечто) <оператор сравнения> нечто)» вложенных одно в другое. Первое сопоставление с образцом распознает «нечто-сложное <оператор сравнения> нечто». В «нечто-сложное» может быть любое подвыражение, но на данном этапе нас это не интересует. Образцы:

| <[ $firstExpr <  $maxExpr ]>with gt2 = true
| <[ $firstExpr <= $maxExpr ]>with gt2 = false =>

распознают сравнение, соответственно, с использованием оператора «меньше» и «меньше или равно». При этом переменная gt2 получает значение true, если используется оператор «меньше», и false, если используется «меньше или равно». Значение этих переменных будет использоваться далее для распознавания конкретных сочетаний.

Теперь необходимо распознать подвыражение, значение которого было помещено в переменную firstExpr на первом шаге распознавания:

      match (firstExpr)
{
  | <[ $minExpr <  $i ]>with gt1 = true
  | <[ $minExpr <= $i ]>with gt1 = false =>

В данном случае переменная gt1 (аналогично gt2) устанавливается в true, если подвыражение использует оператор «<», и в false – если «<=».

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

Немаловажно то, что при передаче макросу неверного выражения (например, содержащего знаки «>» или «>=» вместо тех, что нужно), будет выполнено вхождение match-а, выдающее сообщение об ошибке. Для выдачи сообщений об ошибках используется функция Message.Error():

| _ => Message.Error(expr.Location, $"Syntax error '$expr'");
       <[ () ]>

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

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

Конструкция:

      match (gt1, gt2)
{
  | (false, false) => ...
  | (true,  false) => ...
  | (false, true)  => ...
  | (true,  true)  => ...
}

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

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

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

Работа с квази-цитированием

Квази-цитирование позволяет генерировать код, собирая его из готовых кусков. Часть кода (или даже весь) может быть создан напрямую в AST (без использования квази-цитирования). Отдельные выражения можно объединять в списки выражений. Списки выражений можно «раскрывать» внутри других выражений с помощью специальной нотации «..$переменная». Данная нотация допустима только внутри скобок (круглых, квадратных или фигурных). Как и любой другой список, список выражений может содержать ноль или более элементов. Например, следующий код создает список выражений, после чего раскрывает его внутри блока кода (фигурных скобок).

      mutable exps = [ <[ printf ("%d ", x) ]>, <[ printf ("%d ", y) ]> ];
exps = <[ def x = 1 ]> :: <[ def y = 2 ]> :: exps;
<[ {.. $exps } ]>
ПРИМЕЧАНИЕ

Обратите внимание на то, что выражения описаны в обратном порядке. Это необходимо, так как при добавлении элементов к списку они добавляются в начало списка.

Этот код можно было заменить следующим:

      <[ 
      
      
        def 
      
      x = 1;
      
      
        def 
      
      y = 2;
        printf (
      
        "%d "
      
      , y);
        printf (
      
        "%d "
      
      , x);
      ]>
    

Смысл в применении такого подхода есть потому, что часть кода может генерироваться отдельными функциями или предоставляться внешним кодом (например, через параметры макроса).

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

Я уже упоминал о том, что из макросов доступен контекст компиляции (точнее, объект типа Typer). Кроме него можно получить доступ к списку типов проекта и многому другому (почти к чему угодно). Я недаром описывал внутренности компилятора в первой части этой статьи. Именно эти знания помогут вам выжать из компилятора максимум.

Начнем с простого примера.

      using Nemerle.Compiler;
using PT = Nemerle.Compiler.Parsetree;
...
macro PrintVisibleLocalVariables(msg)
{
  def typer = Nemerle.Macros.ImplicitCTX();
  
  when (typer.IsMainPass)
  {
    Message.Hint("");
    Message.Hint($"Переменные в $msg");

    def locals = typer.LocalContext.GetLocals();
    mutable count = 0;

    foreach ((name : PT.Name, value : LocalValue) in locals)
    {
      Message.Hint($"   Переменная: $(name.Id) Type: '$(value.Type)' ""Kind: $(value.ValKind)");
      count++;
    }
    
    Message.Hint($"В днном месте видно $count переменных(ая).");
  }
  
  <[ () ]>
}

Если теперь добавить этот макрос к предыдущим и изменить код вызова следующим образом:

Main() : void
{
  PrintTime();

  forindex (0 <= i < 10 step 2)
    forindex (0 <= k <= 2)
    {
      PrintVisibleLocalVariables("внутри двух циклов");
      WriteLine($"i=$i  k=$k");
    }
    
  PrintVisibleLocalVariables("в конце кода");
  
  _ = ReadLine();
}

То в консоль VS будет выведены следующие сообщения:

------ Build started: Project: Test, Configuration: Debug Any CPU ------
...: Now 28.06.2007 20:05:06
...: 
...: Переменные в "внутри двух циклов"
...:    Переменная: _N_break Type: '?' Kind: a return from a block
...:    Переменная: _N_continue Type: 'void' Kind: a return from a block
...:    Переменная: _N_return Type: 'void' Kind: a return from a block
...:    Переменная: i Type: 'int' Kind: a local value
...:    Переменная: k Type: 'int' Kind: a local value
...:    Переменная: _N_for_2262 Type: 'void -> void' Kind: a local function
...:    Переменная: _N_for_2268 Type: 'void -> void' Kind: a local function
...: В днном месте видно 7 переменных(ая).
...: 
...: Переменные в "в конце кода"
...:    Переменная: _N_return Type: 'void' Kind: a return from a block
...: В днном месте видно 1 переменных(ая).
Build succeeded -- 15 warnings. Build took: 00:00:00.5442024.

Как видите, кроме переменных, объявленных нами явно, контекст также содержит и переменные, сгенерированные макросами. Макрос for переписывается в набор функций и блоков. Именно их имена мы и наблюдаем. На всякий пожарный приведу код цикла for (тем более что он и сам является интересным примером):

      macro @for (init, cond, change, body)
syntax ("for", "(", Optional (init), ";", Optional (cond), ";",
        Optional (change), ")", body)
{
  def init   = if (init != null)   init   else<[ () ]>;
  def cond   = if (cond != null)   cond   else<[ true ]>;
  def change = if (change != null) change else<[ () ]>;    

  def loop = Nemerle.Macros.Symbol(Util.tmpname ("for_"));
  
  <[     $init;    $("_N_break" : global):    {def $(loop : name) () : void      {when ($cond) {          $("_N_continue" : global):          {            $body : void          }          $change;           $(loop : name)()        }      }      $(loop : name) ();    }  ]>
}

Получение информации о текущем методе

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

        using TT = Nemerle.Compiler.Typedtree;
...
macro DisplayMethodInfo()
{
  def typer = Nemerle.Macros.ImplicitCTX();
  
  when (typer.IsMainPass)
  {
    def mb        = typer.CurrentMethodBuilder;
    def name      = mb.Name;
    def parms     = mb.GetParameters().Map(
(p : TT.Fun_parm) => $"$(p.Name) : $(p.ty)");
    def modifiers = mb.Modifiers.Attributes.ToString().ToLower();

    Message.Hint($"$modifiers $name(..$parms) : $(mb.ReturnType)");
  }
  
  <[ () ]>
}

При запуске в функции Main:

... 
Main(args : array[string]) : void
{
  DisplayMethodInfo();
  ...
}

в консоли IDE будет выведено:

Main.n(11,5):Warning: hint: static Main(args : array [string]) : void

Получение доступа к типам

Через свойство DeclaringType MethodBuilder-а можно получить доступ к типу, в котором объявлен метод. Далее, с помощью метода GetTypeBuilder(), у типа можно получить ссылку на TypeBuilder, через который можно изменять текущий тип. Это, например, может понадобиться, если вам хочется создать некий скрытый метод-помощник, реализующий какую-то сложную логику и использующийся в вашем макросе. Это может также понадобиться, если логика генерируемого макросом кода зависит от особенностей типа, в котором объявлен метод, в теле которого раскрывается макрос.

Если вам нужен доступ к другим типам проекта, то можно воспользоваться свойством Env объекта Typer (контекста). Тип этого свойства – GlobalEnv. Как говорилось в предыдущей части статьи, GlobalEnv описывает текущий контекст (список ключевых слов, открытых пространств имен и т.п.), но, кроме того, он содержит ссылку на дерево пространств имен (свойство NameTree), или, как его еще называют, на дерево типов. Это дерево содержит иерархию пространств имен, типов и макросов, входящих в проект или импортированных из библиотек. Таким образом, получив доступ к ним, мы можем получать информацию о любом типе, используемом в проекте, а также модифицировать как само дерево (т.е. добавить новые типы), так и типы, входящие в него.

ПРИМЕЧАНИЕ

Информация о типах проекта может использоваться для генерации различных универсальных методов, для подключения сквозной функциональности (а-ля АОП) и многого другого.

Модификация имеющихся типов и добавление новых позволяют вам создавать типы- и/или функции-помощники, а также автоматизировать реализацию некоторых паттернов, например, паттернов проектирования. Примеры автоматизации реализации паттернов проектирования можно увидеть в файле http://nemerle.org/svn/nemerle/trunk/macros/DesignPatterns.n.

Их описание находится на странице http://nemerle.org/Design_patterns.

Использование информации о типах проекта будет подробно разобрано в следующей части этой статьи. Сейчас скажу только, что эта информация во многом похожа на рефлексию (reflection) в .NET.

Типизация

Иногда перед разработчиком макроса встает задача типизации ссылки на тип, представленной в виде нетипизированного AST. Для этих целей в Typer-е есть метод MonoBindType. Он принимает ссылку на PExpr и возвращает ссылку на MType. MType – это вариант, описывающий ссылку на тип в Nemerle. Слово Mono и префикс M означают, что данный метод работает исключительно с полностью определенными и конкретными типами (и не имеет никакого отношения к проекту Моно :) ). Из-за этой особенности функции нельзя передавать переменные типов или другие выражения, тип в которых не определен.

Данная функция может использоваться, когда по тем или иным причинам вам доступен только нетипизированный AST, но вы все же хотите вычислить типы. Например, такая ситуация может возникнуть при разработке макро-атрибута, работающего на стадии BeforeTypedMembers. На этой стадии информация о типах параметров методов, полей и свойств еще недоступна, но ее можно вычислить с помощью MonoBindType. Конечно, проще было бы использовать макрос на стадии WithTypedMembers, но на этой стадии многие возможности недоступны. Так, на этой стадии изменение типов (особенно связанное с реализацией интерфейсов и переопределением виртуальных методов) зачастую приводит к проблемам. Идеальным решением было бы ввести еще одну стадию, на которой были бы доступны типы, и после которой все зависимости перевычислялись бы вновь, но, по крайней мере на сегодня, такой возможности не существует, и использование метода MonoBindType на стадии BeforeTypedMembers является единственным полноценным легальным методом решения проблем изменения типов на основании информации об этих типах.

Типизация выражений

Еще более сложной задачей является типизация выражений. Скажу больше. Она иногда не просто сложна, а даже невыполнима. Дело в том, что Nemerle выводит типы из использования. Поэтому если заставить его типизировать отдельное выражение, компилятор не справиться с корректным выводом типов (ведь фрагмент кода может содержать недостаточно информации). Однако, с одной стороны, часто достаточно и такого, локального вывода типов, а с другой, - есть возможность отложить работу до момента, когда типы станут полностью известны.

Чтобы типизировать выражения, можно воспользоваться следующими функциями Typer-а:

TypeExpr(e : PT.PExpr) : TExpr
TypeExpr(e : PT.PExpr, expected : TyVar) : TExpr
TypeExpr(e : PT.PExpr, expected : TyVar, is_toplevel_in_seq : bool) : TExpr

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

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

Надо понять кардинальную разницу между MonoBindType и TypeExpr. Первый метод фактически позволяет типизировать выражение, содержащее путь, к типу, используемому в проекте (путь может быть относительным и хитро заданным, но всё же это всего лишь путь). Результатом работы MonoBindType является всего лишь ссылка на описание типа. Метод же TypeExpr преобразует нетипизированное AST (PExpr) в типизированное AST (TExpr). Типизированное AST содержит все ссылки на типы и другую информацию, но теряет, вследствие множества преобразований, производимых компилятором над кодом, кое-что из информации, полученной из кода (что иногда затрудняет использование TExpr). Интересной особенностью является то, что после процесса типизации в ветки нетипизированного AST добавляются ссылки на соответствующие им ветки типизированного AST. Это позволяет после процесса типизации не возиться с TExpr, а работать все с тем же PExpr и по необходимости обращаться к информации о типах через свойство TypedObject объекта PExpr.

Ниже приведен кусок кода из библиотеки StringTemplate, которую я разрабатывал параллельно написанию этой статьи.

        def pExpr = MainParser.ParseExpr (env, expr, loc);
match (pExpr)
{
  // Данное вхождение распознает, что код состоит из трех подвыражений.
  | <[ $seqExpr; $sep; $cnvFuncExpr; ]> =>
  
    // Если cnvFuncExpr является ссылкой на StringTemplate-метод,// заменить ее на ссылку на метод <имяМетода>__StImpl, кторый    // работает более оптимально (быстро).// Находим __StImpl-метод, соответствующий текущему методу // (он лежит в Ast.UserData).def corespStImplMethod = 
(mb.Ast.UserData :> ClassMember.Function).Builder;
    // Типизируем «поддельное» выражение, чтобы определить, что за     // тип имеет cnvFuncExpr.def expr = <[ NCollectionsUtils.MapLazy($seqExpr, $cnvFuncExpr) ]>;
    // Создаем Typer для метода, описываемого MethodBuilder-ом // corespStImplMethod.def typer = Typer(corespStImplMethod);
      
    _ = typer.TypeExpr(expr); // типизируем выражение// После типизации в свойстве TypedObject находится соответствующее TExpr,    // то есть типизированное AST.match (cnvFuncExpr.TypedObject) 
    {
       // TExpr – является ссылкой на статический метод.
      | TExpr.StaticRef(_, m is MethodBuilder, _) 
             // Объявленный в том же типе...when m.DeclaringType.Equals(mb.DeclaringType) =>

        // Если Ast.UserData содержит...match (m.Ast.UserData) 
        {
           // ссылку на функцию...
          | coresp is ClassMember.Function =>
            // то переписываем код так, чтобы он использовал не указанный // метод, а автосгенерированный метод (имеющий имя coresp.Name).// Этот метод более эффективен.<[ SB.AppendSeq(_builder, $seqExpr, $sep,                  _indent, this.$(coresp.Name : usesite)); ]>

          | _ => <[ SB.AppendSeq(_builder, $seqExpr, $sep,                      _indent, $cnvFuncExpr); ]>
        }
      | _ => <[ SB.AppendSeq(_builder, $seqExpr, $sep,                   _indent, $cnvFuncExpr); ]>
    }

Этот код распознает, что выражение состоит из трех подвыражений (statements), разделенных точкой с запятой. Затем он пытается определить, содержит ли подвыражение cnvFuncExpr ссылку на метод, объявленный в том же классе, и является ли этот метод StringTemplate-методом (у таких методов в Ast.UserData находится ссылка на автосгенерированный метод).

Чтобы понять, что в cnvFuncExpr находится ссылка на нужный метод, я формирую «поддельный» код, использующий ссылку на метод необходимым мне образом, и типизирую это выражение в контексте автосгенерированного метода, подключенного в Ast.UserData текущего метода. После типизации, я «просматриваю» содержимое свойства cnvFuncExpr.TypedObject (в которое при типизации помещается ссылка на описание реального метода). Если это тот метод, что нужно, то выражение в TypedObject будет содержать ссылку на статический метод, в которой сам метод объявлен в том же классе, что и текущий метод. Далее остается только определить, что находится в Ast.UserData этого метода. Если это ссылка на ClassMember.Function (т.е. на фунцию), то остается переписать код вызова нужным мне образом (так, чтобы вместо указанного метода использовался автосгенерированный) и возвратить его. Во всех остальных случаях используется указанный пользователем метод (код при этом тоже немного переписывается, но метод не подменяется).

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

Откладывание выполнения макроса до момента, когда информация о типах становится доступной

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

Система вывода типов Nemerle может выводить типы из использования. Причем использование, определяющее (или уточняющее) тип переменной, может быть хоть самым последним выражением метода. Более того, оно может быть косвенным. Классическим примером мощности вывода типов в Nemerle является работа с Dictionary[K, V]:

          using System;
using System.Console;
using System.Collections.Generic;

def dic = Dictionary();
WriteLine(dic.GetType().FullName);
dic.Add("Текущая дата", DateTime.Now);

Этот код выведет на консоль:

System.Collections.Generic.Dictionary`2[[System.String, mscorlib, Version=2.0.0.
0, Culture=neutral, PublicKeyToken=b77a5c561934e089],[System.DateTime, mscorlib,
 Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089]]

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

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

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

Как все это касается разработчиков макросов? А самым наипрямейшим образом. Дело в том, что процесс раскрытия макросов проходит как раз во время типизации. Когда компилятор последовательно разбирает все ветки нетипизированного AST (т.е. цепочку вариантов PExpr), типизирует их, и, если встречается PExpr.MacroCall, пытается раскрыть макрос, и типизировать получившееся в результате его выполнения выражение (имеющее опять же тип PExpr). Если это выражение тоже содержит обращения к макросам, то раскрываются и они.

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

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

          macro PrintExpressionType(expr)
{
  def typer = Nemerle.Macros.ImplicitCTX();
  def tExpr = typer.TypeExpr(expr);
  
  def msg = $"Во время работы макроса тип '$tExpr' "
    + match (tExpr.Type.Hint)
      {
        | Some(ty) => $"известен: $(ty)."
        | None     =>  "НЕизвестен!"
      };
      
  Message.Hint(msg);
}

Теперь попытаемся применить его. В следующем случае:

          mutable x = array[0];
PrintExpressionType(x);

Макрос выведет в консоль IDE:

Во время работы макроса тип 'x' известен: array [int-].

Однако стоит немного изменить пример:

          mutable x = null;
PrintExpressionType(x);
x = array[0];

и макрос, что называется, «обломается».

Во время работы макроса тип 'x' НЕизвестен!

Что же делать?

Можно заставить компилятор отложить вычисление макроса, как говорится, до лучших времен. Делается это с помощью метода DelayMacro все того же контекста макроса (т.е. объекта типа Typer).

Изменим макрос следующим образом:

          macro PrintExpressionType(expr)
{
  def typer = Nemerle.Macros.ImplicitCTX();
  def tExpr = typer.TypeExpr(expr);
  
  def msg = $"Во время работы макроса тип '$tExpr' "
    + match (tExpr.Type.Hint)
      {
        | Some(ty) => $"известен: $(ty)."
        | None     => "НЕизвестен!"
      };
      
  Message.Hint(msg);
    
  def result = typer.DelayMacro(fun (fail_loudly)
  {
    def tExpr = tExpr;
    match (tExpr.Type.Hint)
    {
      | Some(ty) =>
        // do something with the type
        Message.Hint($"Внутри DelayMacro тип для '$tExpr' известен: $(ty).");
        Some(<[ () ]>)

      | None =>
        when (fail_loudly)
          Message.Error(expr.loc, $ "невозможно вывести тип для '$expr'");
        
        None()
    }
  });

  result
}

И снова выполним код:

          mutable x = null;
PrintExpressionType(x);
x = array[0];

На этот раз макрос выведет в консоль IDE следующее:

Во время работы макроса тип 'x' НЕизвестен!
Внутри DelayMacro тип для 'x' известен: array [int-].

Метода DelayMacro имеет следующую сигнатуру:

DelayMacro(resolve : bool -> option[PT.PExpr], 
           expected : TyVar = null) : PT.PExpr

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

По сути, метод DelayMacro возвращает слоеный пирог:

PExpr.Typed(TExpr.Delayed(...))

в который закладывается ссылка на Typer, переданную нами ссылку на функцию, локальный контекст, переменную типа и другие необходимые данные. Компилятор, встретив PExpr.Typed, просто разворачивает его содержимое и помещает в конструируемый им типизированный AST. На следующем цикле типизации компилятор обнаруживает TExpr.Delayed и пытается вызывать переданную нами ссылку на функцию. Если функция возвращает None(), то компилятор пытается вызвать ее на следующем витке типизации, и так далее. Если функция возвращает Some(PExpr(...)), то PExpr раскрывается и типизируется. Естественно, что при формировании этого PExpr мы вольны использовать уже доступную информацию о типах.

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

Заключение ко второй части

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

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

Если, что-то в этом цикле статей показалось вам непонятным, то не стесняйтесь, пишите мне об этом (по e-mail или в соответствующей ветке форума). Я постараюсь учесть замечания и сделать тем самым информацию максимально доступной для широкого круга читателей.


Любой из материалов, опубликованных на этом сервере, не может быть воспроизведен в какой бы то ни было форме и какими бы то ни было средствами без письменного разрешения владельцев авторских прав.
    Сообщений 13    Оценка 641        Оценить