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

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

Часть 3

Автор: Чистяков Влад (VladD2)
The RSDN Group

Источник: RSDN Magazine #3-2007
Опубликовано: 05.02.2008
Исправлено: 10.12.2016
Версия текста: 1.0
Метаатрибуты
Описание метаатрибута
К чему применимы метаатрибуты?
Параметры метаатрибутов
Дополнительные параметры метаатрибута
Пример макроса NotNull
К делу...
Пробуем...
Проблемы...
Устраняем непроизводительные накладные расходы...
Стремление к совершенству...
Пораскинем мозгами...
Типы – это важно...

ПРИМЕЧАНИЕ

В дополнение к материалам статьи, на CD ROM приведен компилятор Nemerle для платформы .NET

Метаатрибуты

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

Метаатрибуты сами по себе не меняют синтаксис, но, тем не менее, позволяют получать весьма красивые и эффективные решения.

Многие из тех, кто программировал на C# или VB.NET, использовали атрибуты. Атрибуты – это замечательный способ расширить метаинформацию кода (добавить к некоторым конструкциям языка, таким как классы или методы, дополнительную информацию). Однако воспользоваться этой метаинформацией можно только на этапе выполнения. Конечно, и это иногда дает очень неплохой эффект, но зачастую хочется обработать эту метаинформацию еще на стадии компиляции.

Гуру .NET-программирования нашли два решения этой задачи:

  1. Динамическая компиляция кода. Тут есть два подхода. Первый заключается в генерации исходных текстов на некотором ЯП высокого уровня (например, на C#), использовании компиляторов (или соответствующего API .NET, активирующего их) и последующей динамической загрузке сборки. Так была реализована работа Xmlserializer во Framework 1.х. Второй заключается в использовании низкоуровневого API System.Reflection.Emit (далее – SRE). С использованием этого подхода реализована, например, библиотека Игоря Ткачева (известного на RSDN как IT) Business Logic Toolkit for .NET (BL Toolkit, www.bltoolkit.net).
  2. Использование внешних генераторов кода, которые отрабатывают в процессе компиляции между компиляцией отдельных сборок. При этом сначала компилируется сборка, содержащая метаинформацию, затем запускается генератор кода, который с помощью стандартного API System.Reflection (далее – SR) читает эту метаинформацию и генерирует некоторый код. После этого запускается компиляция другой сборки, в состав которой включается генерируемый код. Приблизительно так работает улучшенный генератор кода для Xmlserializer, который появился во Framework 2.0.

Каковы же преимущества и недостатки перечисленных подходов? Преимущество второго подхода заключается в том, что генерируемый код относительно просто отлаживать. Недостатком же является то, что генерация исходных кодов на языках вроде C# – весьма трудоемкое занятие. К тому же такой подход довольно заметно замедляет компиляцию. Ведь, по сути, приходится осуществлять компиляцию двух сборок плюс еще загружать одну из сборок в память, чтобы считать с нее метаинформацию. Еще одним недостатком этого подхода является то, что для генерации кода нельзя использовать данные, которые могут быть доступны только на этапе выполнения. Однако, как показывает практика, в подавляющем большинстве случаев генерация использует данные, присутствующие на стадии разработки/компиляции. К тому же компиляцию можно запускать и во время исполнения, так что по сути вариант 1, использующий генерацию на высокоуровневом языке, можно считать разновидностью варианта 2.

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

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

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

На этом месте многие читатели, наверно, задумались над тем, к чему я веду. Конечно же, я веду к тому, что метаатрибуты – это лучшее средство «оживить» атрибуты. Внешне метатрибуты ничем не отличаются от обычных атрибутов C#, но вместо того, чтобы после обработки компилятором попадать в метаатрибуты внутри сборки, они приводят к активации соответствующих макросов. Все, что передается таким макросам в качестве параметров, передается макросам в виде разобранного кода (то есть в виде PExpr). При этом значение параметров никак не интерпретируется, и вы вольны интерпретировать его как вам угодно. А это, между прочим, позволяет использовать метаатрибуты как контейнеры для наших DSL-ей.

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

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

Так, метаатрибутами можно было бы оформить инфраструктуру для работы с Web-сервисами, ASP.NET, XML- и обычную сериализацию, и многое, многое другое – и все это вообще без изменения синтаксиса языка (а ведь изменение синтаксиса чаще всего приводится как недостаток макросов их противниками)!

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

Описание метаатрибута

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

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

[MacroUsage(MacroPhase.BeforeTypedMembers, 
  MacroTargets.Class, Inherited = true)
]
macro MyFirstMetaattribute(_tb : TypeBuilder)
{
  ...
}

Стадия компиляции, на которой вызывается макрос, задается первым параметром, тип которого – MacroPhase. Это перечисление уже было описано в первой части статьи (ищите ее в первом номере журнала за этот год или на сайте/CD-ROM).

К чему применимы метаатрибуты?

Как и простые пользовательские (custom) атрибуты, метаатрибуты могут быть применены к разным элементам кода. За то, к чему применим метаатрибут, отвечает перечисление MacroTargets, которое реально является синонимом для стандартного .NET-перечисления System.AttributeTargets. Но не все значения этого перечисления допустимы для метаатрибутов. Ниже перечислены допустимые значения:

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

Параметры метаатрибутов

Как и простые атрибуты, метаатрибуты могут иметь параметры. Вот только параметры передаются не в виде объектов (как при реализации простых атрибутов), а в виде PExpr (т.е. в виде AST).

Кроме того, у метаатрибутов есть дополнительные параметры, которые присутствуют всегда. Типы дополнительных параметров зависят от типа метаатрибута (задаваемого с помощью MacroTargets) и стадии, на которой работает макрос (задаваемой с помощью MacroPhase). В таблице 1 приведена матрица соответствия между MacroPhase и MacroTarget. В их пересечении описываются типы параметров (первый описывает тип первого параметра, а второй, если есть, соответственно, тип второго параметра).

MacroTarget MacroPhase
BeforeInheritance BeforeTypedMembers WithTypedMembers
Class TypeBuilder TypeBuilder TypeBuilder
Method TypeBuilder, ParsedMethod TypeBuilder, ParsedMethod TypeBuilder, MethodBuilder
Field TypeBuilder, ParsedField TypeBuilder, ParsedField TypeBuilder, FieldBuilder
Property TypeBuilder, ParsedProperty TypeBuilder, ParsedProperty TypeBuilder, PropertyBuilder
Event TypeBuilder, ParsedEvent TypeBuilder, ParsedEvent TypeBuilder, EventBuilder
Parameter TypeBuilder, ParsedMethod, ParsedParameter TypeBuilder, ParsedMethod, ParsedParameter TypeBuilder, MethodBuilder, ParameterBuilder
Assembly - - -
Таблица 1. Типы и список обязательных параметров метаатрибутов в зависимости от значений MacroPhase, MacroTarget.

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

Типы ParsedField, ParsedMethod, ParsedEvent и ParsedParameter на самом деле являются синонимами к описанным мной ранее типам AST (таблица 2).

Синоним Реальный тип
ParsedField ClassMember.Field
ParsedMethod ClassMember.Function
ParsedProperty ClassMember.Property
ParsedEvent ClassMember.Event
ParsedParameter Fun_parm
ParameterBuilder Nemerle.Compiler.Typedtree.Fun_parm
Таблица 2. Соответствие между синонимами типов параметров макросов и реальными типами в компиляторе.

Оба типа (ClassMember и Fun_parm) объявлены в пространстве имен Nemerle.Compiler.Parsetree. ClassMember – это вариант, описывающий члены класса, а Fun_parm – класс, описывающий параметр метода.

Как уже говорилось в предыдущих частях, для конструирования и/или анализа ClassMember можно использовать квази-цитирование и сопоставление с образцом (для анализа). Это существенно упрощает решение многих задач.

Если внимательно присмотреться к таблице 1, станет очевидным, что до стадии MacroPhase.WithTypedMembers работа ведется с AST, а на этой стадии – уже с Builder-ами (PropertyBuilder, MethodBuilder и т.п.). Однако, несмотря на стадию компиляции, тип всегда описывается классом TypeBuilder. Почему так? Отчасти так сделано потому, что типы требуется помещать в дерево пространств имен (о котором говорилось в первой части статьи), а отчасти – по недоразумению. Как бы то ни было, надо понимать, что TypeBuilder-ы на разных стадиях содержат разную информацию. До MacroPhase.WithTypedMembers они содержат только AST, а на этой стадии типизированные коллекции членов. Если попытаться запросить список членов у TypeBuilder-а на ранних стадиях, будет выдано исключение. Напротив, AST доступно (через свойство AstParts или Ast) на всех стадиях компиляции. AST для типа доступно через свойство AstParts (в TypeBuilder-е) потому, что у одного типа может быть несколько частей (объявленных с ключевым словом partial). AstParts содержит список частей класса. Если класс не является partial-классом, то данное свойство содержит список, состоящий из одного элемента.

СОВЕТ

Всегда используйте именно свойство AstParts, так как иначе ваш код может неверно работать с partial-классами.

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

Свойства AstParts и Ast появились относительно недавно. Раньше получить доступ к AST было возможно не всегда.

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

Дополнительные параметры метаатрибута

Если метаатрибуту требуется дополнительная информация, ее можно задать с помощью дополнительных параметров макроса. Как и у параметров обычных макросов, эти параметры должны иметь тип PExpr или быть встроенными типами языка (int, double, string, ...).

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

СОВЕТ

Более того, есть способ передать в метаатрибут голый список токенов, и тогда не обязательно будет соблюдать даже синтаксические правила. Правда, для этого придется создать еще один лексический макрос. Единственным ограничением при этом будет то, что код должен удовлетворять весьма гибким лексическим правилам Nemerle. Впрочем, есть и ложка дегтя. Дело в том, что скобки в передаваемом коде должны быть парными и корректно (рекурсивно) вложенными друг в друга (об этом также говорилось в предыдущих частях статьи). Данное ограничение не позволяет создавать уж очень произвольные DSL-и, но для большинства DSL подходит как нельзя лучше. О том, как реализовать это решение, я постараюсь рассказать в следующих частях данной статьи (когда буду рассказывать о нетривиальных решениях в этой области; нетерпеливые же могут освоить его самостоятельно по описанию в форуме: http://rsdn.ru/forum/message/2465692.1.aspx).

ПРИМЕЧАНИЕ

Возможно, в будущем будет создан еще один тип макросов (PreParse-макросы), который позволит избавиться и от этого ограничения. Впрочем, и сегодня можно помещать особо экстравагантные DSL-и в новый вид строк (рекурсивные строки). Эти строки ограничиваются сочетанием символов <# и #>, а также допускают вложенность и перенос строк, т.е можно объявить строку:

        <# Здравствуй, "<# безумный #>
  <# 'безумный' 
    <# безумный #> #>"
      мир!
#>
      
ПРИМЕЧАНИЕ

На базе этого вида строк реализуется новый DSL NemerleStringTemplate – движок текстовых шаблонов (что-то вроде XSLT для объектов и с человеческим лицом. :)

Пример макроса NotNull

К делу...

Иногда бывает, что прочитав целый том, не понимаешь, как же все-таки работает та или иная вещь, но, взглянув на пример ее использования, говоришь себе «Это же тривиально, Ватсон!» :). Так что чем мучить вас теорией и далее, плавно перейдем к демонстрации макросов.

Начнем с простого примера. Всем, кто писал на C# или аналогичных языках, приходилось сталкиваться с написанием рутинного кода, связанного со стандартными проверками параметров «на вшивость». Например, зачастую приходится проверять, что значение того или иного параметра не равно null. Код, который мы вынуждены писать, выглядит примерно так:

...
publicstatic Method(parameter : string) : void
{
  Assert(parameter : object != null, <#The "NotNull" contract of parameter #>
                                    <#"parameter" has been violated.#>);
  def len = parameter.Length;
  _ = len; // Производим некоторую работу...
}
ПРИМЕЧАНИЕ

Assert – это System.Diagnostics.Trace.Assert(). Напоминаю, что в Nemerle можно открывать не только пространства имен, но и классы.

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

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

        public module Helper
{
  public AssertNotNull(param : object, paramName : string) : void
  {
     Assert(param != null, <#The "NotNull" contract of parameter #>
                        + $<#"$paramName" has been violated.#>);
  }
}

Тогда код нашего гипотетического метода будет выглядеть так:

...
publicstatic Method(parameter : string) : void
{
  AssertNotNull(parameter, "parameter");
  def len = parameter.Length;
  _ = len; // Производим некоторую работу...
}

Лучше, чем первый вариант? Несомненно!

Остались ли проблемы? К сожалению, да.

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

А что если сделать атрибут NotNull, который говорил бы нам, что параметр, к которому он относится, не допускает null-значения (или, выражаясь языком контрактно-ориентированного программирования, – поддерживает NotNull-контракт)?

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

В общем, большинство C#-программистов остановилось бы на предыдущем решении (т.е. на использовании метода AssertNotNull), но у нас в руках есть волшебная палочка – метаатрибуты! С их помощью мы можем заставить атрибуты изменять логику нашей программы, причем делать это как бы за кадром, не привнося дополнительных проблем в нашу жизнь.

Вот как будет выглядеть каркас нашего макроса:

[MacroUsage(MacroPhase.BeforeInheritance, MacroTargets.Parameter,
            Inherited = true, AllowMultiple = false)]
macro NotNull(_ : TypeBuilder, m : ParsedMethod, p : ParsedParameter)
{
  Message.Hint($"Тип параметра m: $(m.GetType()) - '$m'");
  Message.Hint($"Тип параметра p: $(p.GetType()) - '$p'");
}

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

Создадим код, тестирующий этот атрибут:

        public
        class A
{
  publicstatic Method1([NotNull] parameter : string) : void
  {
    WriteLine(parameter)
  }
} 

Если вы правильно сконфигурировали решение (Soliution), то в консоль VS будут выведены два сообщения:

...hint: Тип параметра m: Nemerle.Compiler.Parsetree.ClassMember+Function – 
   'Function: public static Method1(parameter : string) : void ;'

...hint: Тип параметра p: Nemerle.Compiler.Parsetree.Fun_parm – 
   'parameter : string'

Все, как было обещано в теоретической части. Мы имеем два объекта. Один описывает метод. Другой – параметр.

Теперь нам нужно сгенерировать код проверки (так, как будто мы писали его вручную) и подставить в начало тела метода. Вот этот код:

[MacroUsage(MacroPhase.BeforeInheritance, MacroTargets.Parameter,
            Inherited = true, AllowMultiple = false)]
macro NotNull(_ : TypeBuilder, m : ParsedMethod, p : ParsedParameter)
{
  def msg = <#The "NotNull" contract of parameter "#>
          + $<#$(p.Name)" has been violated.#>;
  
  m.Body = <[
    Assert($(p.ParsedName : name) : object != null, $(msg : string));
    $(m.Body)
  ]>;
}

Ничего сложного, но на всякий случай я поясню этот код.

В первой строке производится формирование строки, которая будет отображаться в окне сообщения, если проверка в Assert будет неуспешной:

        def msg = <#The "NotNull" contract of parameter "#>
          + $<#$(p.Name)" has been violated.#>;

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

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

  m.Body = <[
    Assert($(p.ParsedName : name) : object != null, $(msg : string));
    $(m.Body)
  ]>;

Свойство ParsedName содержит имя во внутреннем представлении компилятора. Уточнение «... : name» говорит компилятору, что ему передается именно имя, а не выражение (PExpr). В msg уже находится строка, о чем мы и сообщаем компилятору.

ПРИМЕЧАНИЕ

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

В итоге формируется код вызова Assert-а, аналогичный рукописному, но имя переменной в нем формируется автоматически на базе информации, передаваемой макросу компилятором.

Строчка $(m.Body) просто помещает в данное место код тела метода.

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

СОВЕТ

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

Пробуем...

Остается добавить вызов тестового метода:

        module Program
{
  Main() : void
  {
    A.Method1(null);
  }
}

произвести компиляцию проекта и запустить его на исполнение. При этом вы увидите стандартное окно Assert (см. рисунок 1).


Рисунок 1. Диалог «Assert», сообщающий о том, что был нарушен контракт NotNull.

Думаю, не стоит объяснять, что при этом вы можете или проигнорировать проблему, или, нажав «Retry», перейти непосредственно к месту, где был нарушен контракт NotNull. Впрочем, я уже это объяснил :).

Проблемы...

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

Произошло это потому, что мы изменили не только тело метода, но и информацию о его местоположении. А именно эту информацию учитывает пакет интеграции с VS для вычисления положения методов и принятия решения, производится ли редактирование в рамках тела метода или вне него.

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

ПРИМЕЧАНИЕ

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

Чтобы устранить проблему, нужно вернуть телу метода первоначальное значение Location.

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

[MacroUsage(MacroPhase.BeforeInheritance, MacroTargets.Parameter,
            Inherited = true, AllowMultiple = false)]
macro NotNull(_ : TypeBuilder, m : ParsedMethod, p : ParsedParameter)
{
  def loc = m.Body.Location; // запоминаем значение местоположенияdef msg = <#The "NotNull" contract of parameter "#>
          + $<#$(p.Name)" has been violated.#>;
  
  m.Body = <[
    Assert($(p.ParsedName : name) : object != null, $(msg : string));
    $(m.Body)
  ]>;
  
  m.Body.Location = loc; // востанавливаем IsGenerated в false
}

Если теперь перекомпилировать решение, то IntelliSense будет работать корректно.

Устраняем непроизводительные накладные расходы...

Итак, всего за считанные минуты мы умудрились создать макрос, который избавляет нас от рутинной ручной работы и порождаемых этим ошибок, а также делает наш код более декларативным!

О чем же еще можно мечтать?

Оказывается, можно помечтать об увеличении производительности. Конечно JIT в .NET крут, и порой умудряется сам устранить код, снижающий производительность. Если бы я писал код вручную, то в первую очередь бы заботился о его читабельности. Таким образом, я бы не стал городить лишние проверки только ради того, чтобы не делался лишний вызов метода (в данном случае речь идет о вызове метода Assert). Но в данном случае код генерируется автоматически, по единому шаблону, и он не будет виден программисту, использующему макрос. Исходя из этого, можно изменить макрос следующим образом:

[MacroUsage(MacroPhase.BeforeInheritance, MacroTargets.Parameter,
            Inherited = true, AllowMultiple = false)]
macro NotNull(_ : TypeBuilder, m : ParsedMethod, p : ParsedParameter)
{
  def loc = m.Body.Location;
  def msg = <#The "NotNull" contract of parameter "#>
         + $<#$(p.Name)" has been violated.#>;

  m.Body = <[
when ($(p.ParsedName : name) : object == null)      Assert(false, $(msg : string));
    $(m.Body)
  ]>;
  
  m.Body.Location = loc;
}

Изменения опять же выделены красным.

Стремление к совершенству...

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

ПРИМЕЧАНИЕ

Более того, подобный макрос уже есть в поставке Nemerle. Это Nemerle.Assertions.NotNull, код которого расположен в файле http://nemerle.org/svn/nemerle/trunk/macros/assertions.n (наряду с множеством других полезных макросов предназначенных для программирования в стиле Design by contract). Описание этих макросов можно найти на странице http://nemerle.org/Design_by_contract_macros. Если у меня хватит времени и сил, я постараюсь перевести описания всех стандартных макросов, или просто посвящу отдельную статью стандартным макросам.

Однако в макросе есть одна шероховатость, которая, в общем-то, является пустяком, но позволяет продемонстрировать, как получать и использовать информацию о типах.

Дело в том, что созданный нами макрос можно применять как к ссылочным типам, так и к типам-значениям (value types). Например, мы можем изменить код примера следующим образом:

        public
        class A
{
  publicstatic Method1([NotNull] parameter : int) : void
  {
    WriteLine(parameter)
  }
} 

module Program
{
  Main() : void
  {

    A.Method1(1);
  }
}

Обратите внимание на выделение красным! Строка, ссылочный тип, была заменена целым – типом-значением.

Если мы попытаемся скомпилировать данный код, он прекрасно скомпилируется, и будет работать, но в коде проверки будет производиться boxing. А кому хочется по невнимательности получить boxing в методе, критичном к скорости выполнения? Мне так точно нет! Желательно, чтобы компилятор не генерировал глупого кода и выдавал предупреждение о неразумном применении макроса (ошибку, пожалуй, было бы выдавать не очень разумно, ведь никакого криминала все же нет). Если неоптимальность для простых типов-значений простить можно (все же не надо было применять данный атрибут к параметрам, имеющим такой тип), то для nullable-типов (которые тоже являются типами-значениями) это уже явный «косяк»! Тут следовало бы сделать проверку и по-разному реагировать на параметры ссылочных типов, обычных типов-значений и nullable-типов.

Что мешает нам сделать такую проверку? Дело в том, что на выбранной нами стадии (т.е. на MacroPhase.BeforeTypedMembers) получить информацию о типе параметра довольно трудно (хотя и можно, если как следует постараться). Как я уже говорил выше, информацию о типах параметров легко получить на стадии MacroPhase.WithTypedMembers. Я также говорил, что на этой стадии проблематично изменять описание типов и их членов, но она прекрасно подходит для макросов, которые генерируют или изменяют код членов типов (в том числе методов). Собственно, для нашего случая как раз отлично подходит стадия MacroPhase.WithTypedMembers.

Ниже приводится измененный код макроса, который использует стадию MacroPhase.WithTypedMembers и обрабатывает ситуацию, когда метаатрибут применяют к параметру, тип которого не поддерживает null. Кроме того, в этом варианте специальным образом поддерживаются nullable-типы.

[MacroUsage(MacroPhase.WithTypedMembers, MacroTargets.Parameter,
            Inherited = true, AllowMultiple = false)]
macro NotNull(_ : TypeBuilder, m : MethodBuilder, p : ParameterBuilder)
{
  if (p.ty.CanBeNull)
  {
def loc = m.Body.Location;
    def msg = <#The "NotNull" contract of parameter "#>
            + $<#$(p.Name)" has been violated.#>;
    
    def name = <[ $(p.AsParsed().ParsedName : name) ]>;
    def condition = if (p.ty.Fix().IsValueType) name
                    else                        <[ $name : object ]>;

    m.Body = <[
      when ($condition == null)
        Assert(false, $(msg : string));
        
      $(m.Body)
    ]>;
    
    m.Body.Location = loc;
  }
  else
    Message.Warning(p.Location, 
      $"Parametr '$(p.Name)' has type '$(p.ty)' which not support null");
}

Изменения в коде выделены красным.

Главное, что изменилось в коде – это стадия компиляции. Теперь макрос выполняется на стадии MacroPhase.WithTypedMembers. В соответствии с таблицей 1 пришлось заменить ParsedMethod на MethodBuilder, а ParsedParameter – на ParameterBuilder (напомню, что в соответствии с таблицей 2, ParameterBuilder – это синоним к типу Typedtree.Fun_parm, описывающий типизированный параметр). Это позволило воспользоваться информацией о типе параметра (вычисленной к этой стадии компилятором). К счастью, описание типа в Nemerle уже имеет свойство CanBeNull, которое отвечает на вопрос, может ли экземпляр описываемого типа принимать значение null. Более того, это свойство весьма разумно и учитывает наличие nullable-типов (принимая значение true и для них).

Отдельно стоит объяснить пертурбации, которые производятся с выражением условия. Теперь оно задается не по месту, а с помощью переменной condition. Это потребовалось сделать вследствие того, что nullable-типы, в отличие от ссылочных, нельзя приводить к object. Точнее не "нельзя", а "не желательно", так это приведет к боксингу значений nullable-типиов. Чтобы было понятно, объясню на пальцах. Код вида:

        def value = null : int?;

when (value == null)
  WriteLine("Переменная 'value' не содержит значения");

превращается в следующий MSIL:

L_0000: nop 
L_0001: nop 
L_0002: ldloca.s nullable
L_0004: initobj [mscorlib]System.Nullable`1<int32>
L_000a: ldloc.0 
L_000b: stloc.1 
L_000c: nop 
L_000d: nop 
L_000e: ldloca.s 'value'
L_0010: callinstancebool [mscorlib]System.Nullable`1<int32>::get_HasValue()
L_0015: ldc.i4.0 
L_0016: ceq 
L_0018: brfalse L_002e

Когда как такой код:

        def value = null : int?;

when (value : object == null)
  WriteLine("Переменная 'value' не содержит значения");

превращается в:

L_0000: nop 
L_0001: nop 
L_0002: ldloca.s nullable
L_0004: initobj [mscorlib]System.Nullable`1<int32>
L_000a: ldloc.0 
L_000b: stloc.1 
L_000c: nop 
L_000d: nop 
L_000e: ldloc.1 
L_000f: box [mscorlib]System.Nullable`1<int32>
L_0014: ldnull 
L_0015: ceq 
L_0017: brfalse L_002d

То есть компилятор распознает, что работа идет с nullable-типом и переписывает код так, чтобы он использовал свойства этого типа (вроде HasValue), но при приведении к object компилятор не раздумывая выполняет приказ и производит boxing значения. При этом все работает правильно, так как CLR содержит специальный код, распознающий boxing значений nullable-типов, но производительность при этом теряется. Так вот фрагмент кода:

            def name = <[ $(p.AsParsed().ParsedName : name) ]>;
    def condition = if (p.ty.Fix().IsValueType) name
                    else                        <[ $name : object ]>;

      

формирует в переменной condition-выражение, подходящее для типа обрабатываемого параметра. Для ссылочных типов генерируется выражение:

имя_переменной : object

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

    m.Body = <[
      when ($condition == null)
        Assert(false, $(msg : string));
   ...

Это приводит к требуемому результату.

Метод AsParsed() позволяет получить представление параметра в виде ParsedParameter. Оно нам необходимо для того, чтобы получить имя параметра во внутреннем формате компилятора (пригодном для использования в квази-цитировании).

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

Вот она – сила макросов в статически-типизированном языке!

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

...\Main.n(25,35):Warning: Parametr 'parameter' 
has type 'int' which not support null

Собственно даже нет нужды компилировать код. Предупреждение появляется в IDE, прямо после изменения типа параметра со string на int (рисунок 2).


Рисунок 2. Сгенерированное макросом NotNull предупреждение, отображаемое в IDE.

Проверим рассуждения о поддержке nullable-типов. Допишем к int (типу параметра) «?» (то есть заменить int на Nullable[int]):

        public
        class A
{
  publicstatic Method1([NotNull] parameter : int?) : void
  {
    WriteLine(parameter)
  }
} 

module Program
{
  Main() : void
  {

    A.Method1(1);
  }
}

О боги! Предупреждение исчезло! :)

Изменим код вызова еще раз, так, чтобы метод получал в качестве параметра null:

A.Method1(null);

Запустим тестовое приложение на выполнение... Assert-диалог на месте.

Декомпиляция кода также показывает высокое качество кодогенерации :).

Пораскинем мозгами...

Что же, в итоге наш макрос оказался не так прост, как казалось при его исходном описании. Пришлось сделать финт ушами, чтобы не испортить работу IDE. Пришлось разобраться с типами, выдать предупреждение, поменять стадию, на которой отрабатывает макрос, но в итоге мы добились своего, и добились довольно просто. Где еще можно вот так, запросто, расширить возможности компилятора и IDE? Пожалуй, только в Lisp. Но Lisp – это явно не для всех. Уж точно это не для тех, кто хочет выжать максимум из .NET и статической типизации.

Типы – это важно...

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

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

В следующей части статьи я расскажу о том как работать с типами.

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


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